From 6ca82328ea6a3f1037d91651a3a17e213494c7cb Mon Sep 17 00:00:00 2001 From: Ben Vinegar Date: Sun, 6 Sep 2026 01:08:02 -0400 Subject: [PATCH] refactor: extract bundled Sapling provider --- .changeset/funky-candles-drive.md | 2 + .dependency-cruiser.cjs | 13 +++- AGENTS.md | 7 +- bun.lock | 11 +++ docs/extension-architecture.md | 16 ++-- docs/source-architecture.md | 4 +- knip.json | 7 +- nix/bun.lock.nix | 1 + package.json | 1 + packages/hunk-git/src/index.ts | 10 +-- packages/hunk-jj/src/commands.ts | 4 +- packages/hunk-jj/src/index.ts | 5 +- packages/hunk-sapling/package.json | 26 +++++++ .../src}/commands.test.ts | 0 .../sapling => hunk-sapling/src}/commands.ts | 4 +- .../src}/index.test.ts | 0 .../vcs/sapling => hunk-sapling/src}/index.ts | 5 +- .../hunk/src/core/changeset/fileSource.ts | 4 +- packages/hunk/src/core/vcs/untracked.ts | 3 +- packages/hunk/src/core/watch/plan.ts | 2 +- .../hunk/src/extensions/default/vcs/index.ts | 2 +- packages/hunk/src/lib/largeFile.ts | 2 - packages/hunk/src/lib/osPath.ts | 2 - packages/hunk/src/lib/sourceText.ts | 2 - scripts/check-pack.ts | 15 +++- scripts/hunk-sapling-package.test.ts | 54 +++++++++++++ scripts/hunk-vcs-package.test.ts | 4 + scripts/source-boundaries.test.ts | 76 ++++++++++--------- tsconfig.json | 1 + 29 files changed, 208 insertions(+), 75 deletions(-) create mode 100644 .changeset/funky-candles-drive.md create mode 100644 packages/hunk-sapling/package.json rename packages/{hunk/src/extensions/default/vcs/sapling => hunk-sapling/src}/commands.test.ts (100%) rename packages/{hunk/src/extensions/default/vcs/sapling => hunk-sapling/src}/commands.ts (98%) rename packages/{hunk/src/extensions/default/vcs/sapling => hunk-sapling/src}/index.test.ts (100%) rename packages/{hunk/src/extensions/default/vcs/sapling => hunk-sapling/src}/index.ts (95%) delete mode 100644 packages/hunk/src/lib/largeFile.ts delete mode 100644 packages/hunk/src/lib/osPath.ts delete mode 100644 packages/hunk/src/lib/sourceText.ts create mode 100644 scripts/hunk-sapling-package.test.ts diff --git a/.changeset/funky-candles-drive.md b/.changeset/funky-candles-drive.md new file mode 100644 index 000000000..a845151cc --- /dev/null +++ b/.changeset/funky-candles-drive.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/.dependency-cruiser.cjs b/.dependency-cruiser.cjs index 91d26ddd3..ea7952c4f 100644 --- a/.dependency-cruiser.cjs +++ b/.dependency-cruiser.cjs @@ -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: @@ -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/" }, }, diff --git a/AGENTS.md b/AGENTS.md index 17c4d6d61..5dfbd2b06 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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//` 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 diff --git a/bun.lock b/bun.lock index 17be3509e..4a43c3db7 100644 --- a/bun.lock +++ b/bun.lock @@ -8,6 +8,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:*", @@ -88,6 +89,14 @@ "hunkdiff": "workspace:*", }, }, + "packages/hunk-sapling": { + "name": "@hunk/sapling", + "version": "0.0.0", + "dependencies": { + "@hunk/vcs": "workspace:*", + "hunkdiff": "workspace:*", + }, + }, "packages/hunk-vcs": { "name": "@hunk/vcs", "version": "0.0.0", @@ -151,6 +160,8 @@ "@hunk/jj": ["@hunk/jj@workspace:packages/hunk-jj"], + "@hunk/sapling": ["@hunk/sapling@workspace:packages/hunk-sapling"], + "@hunk/session-broker": ["@hunk/session-broker@workspace:packages/session-broker"], "@hunk/session-broker-bun": ["@hunk/session-broker-bun@workspace:packages/session-broker-bun"], diff --git a/docs/extension-architecture.md b/docs/extension-architecture.md index f4f633929..d75ee36fe 100644 --- a/docs/extension-architecture.md +++ b/docs/extension-architecture.md @@ -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. @@ -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//`. `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 diff --git a/docs/source-architecture.md b/docs/source-architecture.md index 2c85c9627..1a65a159f 100644 --- a/docs/source-architecture.md +++ b/docs/source-architecture.md @@ -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 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. diff --git a/knip.json b/knip.json index d9c76b24c..9e958541c 100644 --- a/knip.json +++ b/knip.json @@ -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"], diff --git a/nix/bun.lock.nix b/nix/bun.lock.nix index f154ef058..47e5194ae 100644 --- a/nix/bun.lock.nix +++ b/nix/bun.lock.nix @@ -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; diff --git a/package.json b/package.json index 096ad3984..d56dd53b5 100644 --- a/package.json +++ b/package.json @@ -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:*", diff --git a/packages/hunk-git/src/index.ts b/packages/hunk-git/src/index.ts index 79016c012..64dc1e246 100644 --- a/packages/hunk-git/src/index.ts +++ b/packages/hunk-git/src/index.ts @@ -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. */ diff --git a/packages/hunk-jj/src/commands.ts b/packages/hunk-jj/src/commands.ts index e734251bb..143b5ac44 100644 --- a/packages/hunk-jj/src/commands.ts +++ b/packages/hunk-jj/src/commands.ts @@ -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.`, @@ -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"; diff --git a/packages/hunk-jj/src/index.ts b/packages/hunk-jj/src/index.ts index f964c61d7..af577c4dc 100644 --- a/packages/hunk-jj/src/index.ts +++ b/packages/hunk-jj/src/index.ts @@ -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. */ diff --git a/packages/hunk-sapling/package.json b/packages/hunk-sapling/package.json new file mode 100644 index 000000000..247f56207 --- /dev/null +++ b/packages/hunk-sapling/package.json @@ -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" + } +} diff --git a/packages/hunk/src/extensions/default/vcs/sapling/commands.test.ts b/packages/hunk-sapling/src/commands.test.ts similarity index 100% rename from packages/hunk/src/extensions/default/vcs/sapling/commands.test.ts rename to packages/hunk-sapling/src/commands.test.ts diff --git a/packages/hunk/src/extensions/default/vcs/sapling/commands.ts b/packages/hunk-sapling/src/commands.ts similarity index 98% rename from packages/hunk/src/extensions/default/vcs/sapling/commands.ts rename to packages/hunk-sapling/src/commands.ts index 3e6177edd..4cd543dd8 100644 --- a/packages/hunk/src/extensions/default/vcs/sapling/commands.ts +++ b/packages/hunk-sapling/src/commands.ts @@ -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.`, @@ -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"; diff --git a/packages/hunk/src/extensions/default/vcs/sapling/index.test.ts b/packages/hunk-sapling/src/index.test.ts similarity index 100% rename from packages/hunk/src/extensions/default/vcs/sapling/index.test.ts rename to packages/hunk-sapling/src/index.test.ts diff --git a/packages/hunk/src/extensions/default/vcs/sapling/index.ts b/packages/hunk-sapling/src/index.ts similarity index 95% rename from packages/hunk/src/extensions/default/vcs/sapling/index.ts rename to packages/hunk-sapling/src/index.ts index f5e25a52c..54c8c6c83 100644 --- a/packages/hunk/src/extensions/default/vcs/sapling/index.ts +++ b/packages/hunk-sapling/src/index.ts @@ -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. */ diff --git a/packages/hunk/src/core/changeset/fileSource.ts b/packages/hunk/src/core/changeset/fileSource.ts index b654fdfd8..acda20868 100644 --- a/packages/hunk/src/core/changeset/fileSource.ts +++ b/packages/hunk/src/core/changeset/fileSource.ts @@ -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. diff --git a/packages/hunk/src/core/vcs/untracked.ts b/packages/hunk/src/core/vcs/untracked.ts index ea5187aa7..ee73aebd9 100644 --- a/packages/hunk/src/core/vcs/untracked.ts +++ b/packages/hunk/src/core/vcs/untracked.ts @@ -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. diff --git a/packages/hunk/src/core/watch/plan.ts b/packages/hunk/src/core/watch/plan.ts index 902dd869c..cb3d0a8ae 100644 --- a/packages/hunk/src/core/watch/plan.ts +++ b/packages/hunk/src/core/watch/plan.ts @@ -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"; diff --git a/packages/hunk/src/extensions/default/vcs/index.ts b/packages/hunk/src/extensions/default/vcs/index.ts index e213e6968..62f7ea6f9 100644 --- a/packages/hunk/src/extensions/default/vcs/index.ts +++ b/packages/hunk/src/extensions/default/vcs/index.ts @@ -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, diff --git a/packages/hunk/src/lib/largeFile.ts b/packages/hunk/src/lib/largeFile.ts deleted file mode 100644 index 726cb7b41..000000000 --- a/packages/hunk/src/lib/largeFile.ts +++ /dev/null @@ -1,2 +0,0 @@ -/** Re-export the shared large-untracked-file policy for existing Hunk callers. */ -export { inspectLargeUntrackedFile, type LargeFileCheck } from "@hunk/vcs/large-file"; diff --git a/packages/hunk/src/lib/osPath.ts b/packages/hunk/src/lib/osPath.ts deleted file mode 100644 index a9badf49f..000000000 --- a/packages/hunk/src/lib/osPath.ts +++ /dev/null @@ -1,2 +0,0 @@ -/** Re-export VCS path normalization for existing non-provider Hunk callers. */ -export { normalizePathForOS } from "@hunk/vcs/path"; diff --git a/packages/hunk/src/lib/sourceText.ts b/packages/hunk/src/lib/sourceText.ts deleted file mode 100644 index 0d931912b..000000000 --- a/packages/hunk/src/lib/sourceText.ts +++ /dev/null @@ -1,2 +0,0 @@ -/** Re-export bounded VCS source readers for existing non-provider Hunk callers. */ -export { DEFAULT_SOURCE_TEXT_MAX_BYTES, readFileTextWithLimit } from "@hunk/vcs/source"; diff --git a/scripts/check-pack.ts b/scripts/check-pack.ts index c83256f36..d219ae687 100644 --- a/scripts/check-pack.ts +++ b/scripts/check-pack.ts @@ -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 — diff --git a/scripts/hunk-sapling-package.test.ts b/scripts/hunk-sapling-package.test.ts new file mode 100644 index 000000000..0844f9417 --- /dev/null +++ b/scripts/hunk-sapling-package.test.ts @@ -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; + dependencies: Record; + }; + + 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; + }; + 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"); + }); +}); diff --git a/scripts/hunk-vcs-package.test.ts b/scripts/hunk-vcs-package.test.ts index 4090b7f93..a15ebb63e 100644 --- a/scripts/hunk-vcs-package.test.ts +++ b/scripts/hunk-vcs-package.test.ts @@ -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; + dependencies?: Record; }; 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(); }); diff --git a/scripts/source-boundaries.test.ts b/scripts/source-boundaries.test.ts index bb82c1751..5c7d672e5 100644 --- a/scripts/source-boundaries.test.ts +++ b/scripts/source-boundaries.test.ts @@ -6,10 +6,26 @@ const REPO_ROOT = resolve(import.meta.dir, ".."); const SRC_ROOT = join(REPO_ROOT, "packages", "hunk", "src"); const CORE_ROOT = join(SRC_ROOT, "core"); const EXTENSIONS_ROOT = join(SRC_ROOT, "extensions"); -const BUNDLED_PROVIDER_ROOT = join(EXTENSIONS_ROOT, "default", "vcs"); +const BUNDLED_VCS_COMPOSITION_ROOT = join(EXTENSIONS_ROOT, "default", "vcs"); const GIT_PACKAGE_ROOT = join(REPO_ROOT, "packages", "hunk-git", "src"); const JJ_PACKAGE_ROOT = join(REPO_ROOT, "packages", "hunk-jj", "src"); +const SAPLING_PACKAGE_ROOT = join(REPO_ROOT, "packages", "hunk-sapling", "src"); const VCS_PACKAGE_ROOT = join(REPO_ROOT, "packages", "hunk-vcs", "src"); +const PROVIDER_IMPORTS = new Set([ + "hunkdiff/extension", + "@hunk/vcs/async-process", + "@hunk/vcs/diff-target", + "@hunk/vcs/large-file", + "@hunk/vcs/path", + "@hunk/vcs/source", +]); +const PROVIDER_ROOTS = [GIT_PACKAGE_ROOT, JJ_PACKAGE_ROOT, SAPLING_PACKAGE_ROOT] as const; +const PROVIDER_HOST_IMPORTS = new Map([ + [ + repoPath(join(BUNDLED_VCS_COMPOSITION_ROOT, "index.ts")), + ["@hunk/git", "@hunk/jj", "@hunk/sapling"], + ], +]); const REVIEW_MODEL_ROOT = join(CORE_ROOT, "review"); // The published extension contract, which the review model may name for the annotation shapes // that are simultaneously internal model types and part of `hunkdiff/extension`. It cannot widen @@ -158,38 +174,27 @@ function unexpectedExternalImports( }); } -/** Find bundled provider imports outside local modules, Node, and approved public leaves. */ +/** Find provider imports outside local modules, platform runtimes, and approved public leaves. */ function unexpectedProviderImports() { - const allowedImports = new Set([ - "hunkdiff/extension", - "@hunk/vcs/async-process", - "@hunk/vcs/diff-target", - "@hunk/vcs/large-file", - "@hunk/vcs/path", - "@hunk/vcs/source", - ]); - return [BUNDLED_PROVIDER_ROOT, GIT_PACKAGE_ROOT, JJ_PACKAGE_ROOT].flatMap((providerRoot) => - sourceFiles(providerRoot).flatMap((path) => - importSpecifiers(path) - // The lightweight scanner can match prose ending in `from "…"`; module specifiers - // never contain whitespace. - .filter((specifier) => !/\s/.test(specifier)) - .filter((specifier) => { - if ( - specifier.startsWith(".") || - specifier.startsWith("node:") || - specifier.startsWith("bun:") || - allowedImports.has(specifier) - ) { - return false; - } - return !( - path === join(BUNDLED_PROVIDER_ROOT, "index.ts") && - (specifier === "@hunk/git" || specifier === "@hunk/jj") - ); - }) - .map((specifier) => `${repoPath(path)} -> ${specifier}`), - ), + return [...PROVIDER_ROOTS, BUNDLED_VCS_COMPOSITION_ROOT].flatMap((providerRoot) => + sourceFiles(providerRoot).flatMap((path) => { + const exceptions = PROVIDER_HOST_IMPORTS.get(repoPath(path)) ?? []; + return ( + importSpecifiers(path) + // The lightweight scanner can match prose ending in `from "…"`; module specifiers + // never contain whitespace. + .filter((specifier) => !/\s/.test(specifier)) + .filter( + (specifier) => + !specifier.startsWith(".") && + !specifier.startsWith("node:") && + !specifier.startsWith("bun:") && + !PROVIDER_IMPORTS.has(specifier) && + !exceptions.includes(specifier), + ) + .map((specifier) => `${repoPath(path)} -> ${specifier}`) + ); + }), ); } @@ -314,9 +319,10 @@ describe("source architecture boundaries", () => { }); test("keeps bundled providers on the public contract and explicit VCS helper leaves", () => { - expect(forbiddenImports(BUNDLED_PROVIDER_ROOT, CORE_ROOT)).toEqual([]); - expect(escapingImports(GIT_PACKAGE_ROOT, [GIT_PACKAGE_ROOT])).toEqual([]); - expect(escapingImports(JJ_PACKAGE_ROOT, [JJ_PACKAGE_ROOT])).toEqual([]); + expect(forbiddenImports(BUNDLED_VCS_COMPOSITION_ROOT, CORE_ROOT)).toEqual([]); + for (const providerRoot of PROVIDER_ROOTS) { + expect(escapingImports(providerRoot, [providerRoot])).toEqual([]); + } expect(unexpectedProviderImports()).toEqual([]); }); diff --git a/tsconfig.json b/tsconfig.json index d39e36a65..9d40ee91e 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -16,6 +16,7 @@ "paths": { "@hunk/git": ["packages/hunk-git/src/index.ts"], "@hunk/jj": ["packages/hunk-jj/src/index.ts"], + "@hunk/sapling": ["packages/hunk-sapling/src/index.ts"], "@hunk/session-broker": ["packages/session-broker/src/index.ts"], "@hunk/session-broker-bun": ["packages/session-broker-bun/src/index.ts"], "@hunk/session-broker-core": ["packages/session-broker-core/src/index.ts"],