Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .changeset/funky-candles-drive.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
---
---
13 changes: 12 additions & 1 deletion .dependency-cruiser.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,17 @@ module.exports = {
pathNot: "^packages/(hunk-jj|hunk-vcs)/src/|^packages/hunk/src/extension-api/",
},
},
{
name: "hunk-sapling-stays-on-vcs-contract",
comment:
"@hunk/sapling owns the Sapling provider and may reach only its local modules, the public extension contract, and explicit dependency-bottom @hunk/vcs leaves.",
severity: "error",
from: { path: "^packages/hunk-sapling/src/" },
to: {
path: "^packages/",
pathNot: "^packages/(hunk-sapling|hunk-vcs)/src/|^packages/hunk/src/extension-api/",
},
},
{
name: "lib-is-a-leaf",
comment:
Expand Down Expand Up @@ -203,7 +214,7 @@ module.exports = {
severity: "error",
from: {
path: "^packages/(?!hunk/)",
pathNot: "^packages/hunk-(git|jj)/",
pathNot: "^packages/hunk-(git|jj|sapling)/",
},
to: { path: "^packages/hunk/src/" },
},
Expand Down
7 changes: 4 additions & 3 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,9 +53,10 @@ ReviewIntent + caller facts -> planReviewIntent -> ReviewAction[] -> reducer ->
repaid seam finding deletes copies, adds a file or banned-symbol tombstone and adversarial
fixture, registers consumers, and updates `docs/browser-review-seam-audit.md`.

- Bundled VCS implementations live under `packages/hunk/src/extensions/default/vcs/<provider>/` and consume the
public extension contract; `packages/hunk/src/app` composes their registrations into the provider-neutral
core VCS catalog. Do not add provider commands, spawning, or source readers under `packages/hunk/src/core`.
- Bundled VCS implementations live in the private `packages/hunk-{git,jj,sapling}` workspaces and consume
the public `hunkdiff/extension` contract plus explicit `@hunk/vcs/*` implementation leaves;
`packages/hunk/src/app` composes their registrations into the
provider-neutral core VCS catalog. Do not add provider commands, spawning, or source readers under `packages/hunk/src/core`.
- `hunk daemon serve` is the one loopback daemon for all live sessions; sessions auto-start and
register with it rather than opening per-TUI ports. Reuse `classifyReviewPublication` and
`ReviewChunkAssembler` for publication ordering and bounded, digest-verified resources. Browser
Expand Down
11 changes: 11 additions & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

16 changes: 9 additions & 7 deletions docs/extension-architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,11 @@ object and registry collection (`packages/hunk/src/extensions/runExtension.ts`):
`loadAppBootstrap` (`packages/hunk/src/extensions/startup.ts`, `packages/hunk/src/extensions/host.ts`).
Discovery groups and trust gating: `packages/hunk/src/extensions/discovery.ts`,
`packages/hunk/src/extensions/trust.ts`.
- **Bundled extensions** live in `packages/hunk/src/extensions/default/` and are compiled
into the binary. `default/vcs/{git,jujutsu,sapling}` is statically imported
by the app composition root (`app/vcsCatalog.ts`) and loaded synchronously
before config resolution, so backends exist without making core import the
extension host. `default/ui/index.ts` is deliberately not part of that list:
- **Bundled extensions** are compiled into the binary. The private
`packages/hunk-{git,jj,sapling}` provider workspaces are statically imported by
`packages/hunk/src/extensions/default/vcs/index.ts`; the app composition root
(`app/vcsCatalog.ts`) loads them synchronously before config resolution, so backends exist
without making core import the extension host. `default/ui/index.ts` is deliberately not part of that list:
it synchronously loads the bundled files and delegated review-info panes through
`runExtensionFactory` only where the app resolves UI panes.

Expand Down Expand Up @@ -337,8 +337,10 @@ watch. Detection is uniform across tiers: nearest checkout wins, priority breaks
equal-distance ties, and an explicit `vcs` id owned by the catalog wins.

Provider implementations — command construction, spawning, error translation,
and exact-source reading — live entirely under
`packages/hunk/src/extensions/default/vcs/<provider>/`. `packages/hunk/src/extensions/vcsPatchResult.ts` is
and exact-source reading — live entirely in the private `packages/hunk-{git,jj,sapling}`
workspaces and import the public `hunkdiff/extension` contract plus only explicit
provider-neutral `@hunk/vcs/*` implementation subpaths.
`packages/hunk/src/extensions/vcsPatchResult.ts` is
the one conversion boundary where a published `ExtensionVcsPatchResult`
becomes Hunk's internal diff model, including structural `too-large` source
results. `packages/hunk/src/core/process/projectRoot.ts` treats `.hunk` as a provider-independent
Expand Down
4 changes: 3 additions & 1 deletion docs/source-architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,4 +75,6 @@ This is an incremental migration, not a bulk rename:

Current composition lives in `app/`: `app/vcsCatalog.ts` assembles bundled registrations into a
provider-neutral catalog, and `app/sessionBootstrap.ts` extends that catalog with user adapters.
Provider commands, source readers, and tests live under `extensions/default/vcs/<provider>/`.
Provider commands, source readers, and tests live in the private
`packages/hunk-{git,jj,sapling}` workspaces; `extensions/default/vcs/index.ts` preserves their
bundled activation order.
7 changes: 5 additions & 2 deletions knip.json
Original file line number Diff line number Diff line change
Expand Up @@ -44,8 +44,11 @@
},
"packages/hunk-jj": {
"entry": ["src/**/*.test.ts"],
"project": ["src/**/*.ts"],
"ignoreExportsUsedInFile": true
"project": ["src/**/*.ts"]
},
"packages/hunk-sapling": {
"entry": ["src/**/*.test.ts"],
"project": ["src/**/*.ts"]
},
"packages/hunk-vcs": {
"entry": ["src/**/*.test.ts"],
Expand Down
1 change: 1 addition & 0 deletions nix/bun.lock.nix
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@
};
"@hunk/git" = copyPathToStore ../packages/hunk-git;
"@hunk/jj" = copyPathToStore ../packages/hunk-jj;
"@hunk/sapling" = copyPathToStore ../packages/hunk-sapling;
"@hunk/session-broker" = copyPathToStore ../packages/session-broker;
"@hunk/session-broker-bun" = copyPathToStore ../packages/session-broker-bun;
"@hunk/session-broker-core" = copyPathToStore ../packages/session-broker-core;
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@
"@changesets/changelog-github": "^0.7.0",
"@hunk/git": "workspace:*",
"@hunk/jj": "workspace:*",
"@hunk/sapling": "workspace:*",
"@hunk/session-broker": "workspace:*",
"@hunk/session-broker-bun": "workspace:*",
"@hunk/session-broker-core": "workspace:*",
Expand Down
10 changes: 5 additions & 5 deletions packages/hunk-git/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,11 +44,11 @@ import {
* file sources, skipped-too-large placeholders, untracked files, watch plans,
* rich failures — so it is deliberately written the way a third-party backend
* would be: it sees only the published `hunkdiff/extension` contract plus
* implementation helpers owned by this extension directory and explicit
* `@hunk/vcs` infrastructure leaves. Nothing here reaches into core, the diff
* engine, or the adapter registry. If something Git needs cannot be said in
* these types, the published contract is missing it, and that is the point of
* shipping it this way.
* implementation helpers owned by this package and explicit `@hunk/vcs`
* infrastructure leaves. Nothing here reaches into core, the diff engine, or
* the adapter registry. If something Git needs cannot be said in these types,
* the published contract is missing it, and that is the point of shipping it
* this way.
*/

/** Return the last path segment for review titles. */
Expand Down
4 changes: 2 additions & 2 deletions packages/hunk-jj/src/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ function parseJjCommitIds(output: string) {
}

/** Reject a Jujutsu revision that could be interpreted as a command option. */
export function requireJjRevisionArg(input: JjBackedInput, value: string) {
function requireJjRevisionArg(input: JjBackedInput, value: string) {
if (value.length === 0) {
throw new HunkExtensionUserError(
`\`${formatJjCommandLabel(input)}\` refused an empty revision.`,
Expand Down Expand Up @@ -100,7 +100,7 @@ export function buildJjShowArgs(input: ExtensionVcsShowInput, pinnedRevision?: s
return args;
}

export function formatJjCommandLabel(input: JjBackedInput) {
function formatJjCommandLabel(input: JjBackedInput) {
if (input.kind === "vcs") {
if (input.staged) {
return "hunk diff --staged";
Expand Down
5 changes: 3 additions & 2 deletions packages/hunk-jj/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,9 @@ import {
*
* This file is written the way a third-party VCS extension would be: it sees
* only the published `hunkdiff/extension` contract plus implementation helpers
* owned by this extension directory. If something here cannot be said in those
* types, the contract is missing something.
* owned by this package and explicit `@hunk/vcs` infrastructure leaves. If
* something here cannot be said in those types, the contract is missing
* something.
*/

/** Return the last path segment for review titles. */
Expand Down
26 changes: 26 additions & 0 deletions packages/hunk-sapling/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
{
"name": "@hunk/sapling",
"version": "0.0.0",
"private": true,
"description": "Private statically bundled Sapling provider for Hunk.",
"license": "MIT",
"files": [
"src"
],
"type": "module",
"sideEffects": false,
"exports": {
".": {
"types": "./src/index.ts",
"import": "./src/index.ts"
}
},
"dependencies": {
"@hunk/vcs": "workspace:*",
"hunkdiff": "workspace:*"
},
"engines": {
"bun": ">=1.3.14",
"node": ">=22"
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ export interface RunSlTextOptions {
}

/** Reject a Sapling revision that could be interpreted as a command option. */
export function requireSlRevisionArg(input: SlBackedInput, value: string) {
function requireSlRevisionArg(input: SlBackedInput, value: string) {
if (value.length === 0) {
throw new HunkExtensionUserError(
`\`${formatSlCommandLabel(input)}\` refused an empty revision.`,
Expand Down Expand Up @@ -87,7 +87,7 @@ function buildSlStatusArgs(input: ExtensionVcsDiffInput) {
}

/** Format a user-facing label for the Sapling command being run. */
export function formatSlCommandLabel(input: SlBackedInput) {
function formatSlCommandLabel(input: SlBackedInput) {
if (input.kind === "vcs") {
if (input.staged) {
return "hunk diff --staged";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,9 @@ import {
/**
* Hunk's Sapling backend, as a bundled extension.
*
* Like the Jujutsu one, this file sees only the published contract plus helpers
* owned by this extension directory.
* Like the Jujutsu one, this file sees only the published `hunkdiff/extension`
* contract, explicit `@hunk/vcs` infrastructure leaves, and modules owned by
* this package.
*/

/** Return the last path segment for review titles. */
Expand Down
4 changes: 2 additions & 2 deletions packages/hunk/src/core/changeset/fileSource.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { DEFAULT_SOURCE_TEXT_MAX_BYTES, readFileTextWithLimit } from "../../lib/sourceText";
import { DEFAULT_SOURCE_TEXT_MAX_BYTES, readFileTextWithLimit } from "@hunk/vcs/source";

export { DEFAULT_SOURCE_TEXT_MAX_BYTES } from "../../lib/sourceText";
export { DEFAULT_SOURCE_TEXT_MAX_BYTES } from "@hunk/vcs/source";

/**
* Generic full-file source fetcher primitives used by input loaders and VCS adapters.
Expand Down
3 changes: 1 addition & 2 deletions packages/hunk/src/core/vcs/untracked.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,9 @@ import { join } from "node:path";
import { createSkippedBinaryMetadata, isProbablyBinaryFile } from "../changeset/binary";
import { buildDiffFile, createSkippedLargeMetadata } from "../changeset/diffFile";
import { createFileSourceFetcher } from "../changeset/fileSource";
import { inspectLargeUntrackedFile } from "../../lib/largeFile";
import { inspectLargeUntrackedFile, type LargeFileCheck } from "@hunk/vcs/large-file";
import { escapeUntrackedPatchPath } from "../../lib/patchPath";
import { parseSingleFilePatch } from "../patch/singleFile";
import type { LargeFileCheck } from "../../lib/largeFile";

/**
* Host-side synthesis of untracked files into reviewable diffs.
Expand Down
2 changes: 1 addition & 1 deletion packages/hunk/src/core/watch/plan.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import type {
ExtensionVcsWatchTarget,
ExtensionVcsWatchTargetSource,
} from "../../extension-api/types";
import { normalizePathForOS } from "../../lib/osPath";
import { normalizePathForOS } from "@hunk/vcs/path";
import type { CliInput } from "../run/commandInputs";
import { createVcsWatchPlan, getConfiguredVcsAdapter, operationFromInput } from "../vcs";
import type { VcsCatalog } from "../vcs/types";
Expand Down
2 changes: 1 addition & 1 deletion packages/hunk/src/extensions/default/vcs/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import gitExtension from "@hunk/git";
import jjExtension from "@hunk/jj";
import slExtension from "./sapling";
import slExtension from "@hunk/sapling";
import { runExtensionFactory } from "../../runExtension";
import {
createEmptyExtensionRegistry,
Expand Down
2 changes: 0 additions & 2 deletions packages/hunk/src/lib/largeFile.ts

This file was deleted.

2 changes: 0 additions & 2 deletions packages/hunk/src/lib/osPath.ts

This file was deleted.

2 changes: 0 additions & 2 deletions packages/hunk/src/lib/sourceText.ts

This file was deleted.

15 changes: 14 additions & 1 deletion scripts/check-pack.ts
Original file line number Diff line number Diff line change
Expand Up @@ -435,12 +435,25 @@ const forbiddenPaths = ["AGENTS.md", "bun.lock"];
for (const file of pack.files) {
if (
forbiddenPrefixes.some((prefix) => file.path.startsWith(prefix)) ||
forbiddenPaths.includes(file.path)
forbiddenPaths.includes(file.path) ||
file.path.startsWith("packages/")
) {
throw new Error(`Unexpected file in npm package: ${file.path}`);
}
}

// The private provider workspaces compile into Hunk's runtime bundle. None may survive as a
// runtime import that an npm install would have to resolve separately.
const bundledRuntime = readFileSync(path.join(appRoot, "dist", "npm", "main.js"), "utf8");
for (const packageName of ["@hunk/git", "@hunk/jj", "@hunk/sapling", "@hunk/vcs"]) {
const escapedName = packageName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
if (
new RegExp(`(?:from\\s*|import\\s*\\(\\s*)["']${escapedName}(?:/|["'])`).test(bundledRuntime)
) {
throw new Error(`The Hunk runtime bundle must not import private package ${packageName}.`);
}
}

// `hunkdiff/extension` is a façade: its declarations must describe the authoring
// contract and nothing else. Whole-program declaration emission happily ships
// every module the entry reaches, so the published tree is allowlisted here —
Expand Down
54 changes: 54 additions & 0 deletions scripts/hunk-sapling-package.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { describe, expect, test } from "bun:test";
import slExtension, { SaplingVcsAdapter } from "@hunk/sapling";
import { readFileSync } from "node:fs";
import { join, resolve } from "node:path";

const REPO_ROOT = resolve(import.meta.dir, "..");
const PACKAGE_ROOT = join(REPO_ROOT, "packages", "hunk-sapling");

describe("@hunk/sapling package boundary", () => {
test("exports only the explicit Sapling provider entry", () => {
const manifest = JSON.parse(readFileSync(join(PACKAGE_ROOT, "package.json"), "utf8")) as {
name: string;
private: boolean;
files: string[];
exports: Record<string, unknown>;
dependencies: Record<string, string>;
};

expect(manifest.name).toBe("@hunk/sapling");
expect(manifest.private).toBe(true);
expect(manifest.files).toEqual(["src"]);
expect(manifest.exports).toEqual({
".": {
types: "./src/index.ts",
import: "./src/index.ts",
},
});
expect(manifest.dependencies).toEqual({
"@hunk/vcs": "workspace:*",
hunkdiff: "workspace:*",
});
});

test("registers the private workspace without leaking it into hunkdiff", () => {
const rootManifest = JSON.parse(readFileSync(join(REPO_ROOT, "package.json"), "utf8")) as {
devDependencies: Record<string, string>;
};
const hunkManifest = readFileSync(join(REPO_ROOT, "packages", "hunk", "package.json"), "utf8");
const bunLock = readFileSync(join(REPO_ROOT, "bun.lock"), "utf8");
const nixLock = readFileSync(join(REPO_ROOT, "nix", "bun.lock.nix"), "utf8");

expect(rootManifest.devDependencies["@hunk/sapling"]).toBe("workspace:*");
expect(hunkManifest).not.toContain("@hunk/sapling");
expect(hunkManifest).not.toContain("workspace:");
expect(bunLock).toContain('"packages/hunk-sapling": {');
expect(bunLock).toContain('"@hunk/sapling@workspace:packages/hunk-sapling"');
expect(nixLock).toContain('"@hunk/sapling" = copyPathToStore ../packages/hunk-sapling;');
});

test("loads its provider entrypoint through the workspace", () => {
expect(typeof slExtension).toBe("function");
expect(SaplingVcsAdapter.id).toBe("sl");
});
});
4 changes: 4 additions & 0 deletions scripts/hunk-vcs-package.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,11 +26,15 @@ describe("@hunk/vcs package boundary", () => {
const manifest = JSON.parse(readFileSync(join(PACKAGE_ROOT, "package.json"), "utf8")) as {
name: string;
private: boolean;
files: string[];
exports: Record<string, unknown>;
dependencies?: Record<string, string>;
};

expect(manifest.name).toBe("@hunk/vcs");
expect(manifest.private).toBe(true);
expect(manifest.files).toEqual(["src"]);
expect(manifest.dependencies ?? {}).toEqual({});
expect(Object.keys(manifest.exports).sort()).toEqual(EXPECTED_EXPORTS);
expect(manifest.exports["."]).toBeUndefined();
});
Expand Down
Loading
Loading