From 07306dd1223a0ca7710a7dce8acc4489f0ed69bf Mon Sep 17 00:00:00 2001 From: Ben Vinegar Date: Sun, 6 Sep 2026 00:06:43 -0400 Subject: [PATCH] refactor: extract shared VCS infrastructure --- .changeset/calm-vcs-foundation.md | 2 + .dependency-cruiser.cjs | 15 +- bun.lock | 7 + docs/module-boundaries.md | 12 +- knip.json | 5 +- nix/bun.lock.nix | 1 + package.json | 1 + packages/hunk-vcs/package.json | 38 ++++++ .../src/async-process.test.ts} | 2 +- .../src/async-process.ts} | 0 .../src/diff-target.ts} | 15 +- packages/hunk-vcs/src/large-file.ts | 96 +++++++++++++ .../src/path.test.ts} | 2 +- packages/hunk-vcs/src/path.ts | 25 ++++ .../src/source.test.ts} | 2 +- packages/hunk-vcs/src/source.ts | 127 +++++++++++++++++ .../extensions/default/vcs/git/commands.ts | 8 +- .../src/extensions/default/vcs/git/index.ts | 12 +- .../src/extensions/default/vcs/git/source.ts | 2 +- .../default/vcs/jujutsu/commands.ts | 6 +- .../extensions/default/vcs/jujutsu/history.ts | 2 +- .../extensions/default/vcs/jujutsu/index.ts | 2 +- .../extensions/default/vcs/jujutsu/source.ts | 2 +- .../default/vcs/sapling/commands.ts | 6 +- .../extensions/default/vcs/sapling/index.ts | 2 +- packages/hunk/src/lib/largeFile.ts | 98 +------------ packages/hunk/src/lib/osPath.ts | 27 +--- packages/hunk/src/lib/sourceText.ts | 129 +----------------- scripts/hunk-vcs-package.test.ts | 96 +++++++++++++ scripts/source-boundaries.test.ts | 48 +++++-- tsconfig.json | 1 + 31 files changed, 497 insertions(+), 294 deletions(-) create mode 100644 .changeset/calm-vcs-foundation.md create mode 100644 packages/hunk-vcs/package.json rename packages/{hunk/src/extensions/default/vcs/asyncProcess.test.ts => hunk-vcs/src/async-process.test.ts} (96%) rename packages/{hunk/src/extensions/default/vcs/asyncProcess.ts => hunk-vcs/src/async-process.ts} (100%) rename packages/{hunk/src/extensions/default/vcs/diffRange.ts => hunk-vcs/src/diff-target.ts} (65%) create mode 100644 packages/hunk-vcs/src/large-file.ts rename packages/{hunk/src/lib/osPath.test.ts => hunk-vcs/src/path.test.ts} (96%) create mode 100644 packages/hunk-vcs/src/path.ts rename packages/{hunk/src/lib/sourceText.test.ts => hunk-vcs/src/source.test.ts} (99%) create mode 100644 packages/hunk-vcs/src/source.ts create mode 100644 scripts/hunk-vcs-package.test.ts diff --git a/.changeset/calm-vcs-foundation.md b/.changeset/calm-vcs-foundation.md new file mode 100644 index 000000000..a845151cc --- /dev/null +++ b/.changeset/calm-vcs-foundation.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/.dependency-cruiser.cjs b/.dependency-cruiser.cjs index 150226b52..4385874e9 100644 --- a/.dependency-cruiser.cjs +++ b/.dependency-cruiser.cjs @@ -63,13 +63,24 @@ module.exports = { from: { path: "^packages/hunk/src/extension-api/" }, to: { path: "^packages/", pathNot: "^packages/hunk/src/extension-api/" }, }, + { + name: "hunk-vcs-stays-provider-neutral", + comment: + "@hunk/vcs owns dependency-bottom implementation helpers; it never imports Hunk, public contracts, or a provider implementation.", + severity: "error", + from: { path: "^packages/hunk-vcs/src/" }, + to: { path: "^packages/", pathNot: "^packages/hunk-vcs/src/" }, + }, { name: "lib-is-a-leaf", comment: - "packages/hunk/src/lib holds dependency-free helpers usable from any tier; it may reach the import-free extension API contract and nothing else.", + "packages/hunk/src/lib holds leaf compatibility exports; it may reach the extension contract and dependency-bottom @hunk/vcs helpers only.", severity: "error", from: { path: "^packages/hunk/src/lib/" }, - to: { path: "^packages/", pathNot: "^packages/hunk/src/(lib|extension-api)/" }, + to: { + path: "^packages/", + pathNot: "^packages/(hunk/src/(lib|extension-api)/|hunk-vcs/src/)", + }, }, { name: "core-stays-domain", diff --git a/bun.lock b/bun.lock index a22e14aa2..21e11d221 100644 --- a/bun.lock +++ b/bun.lock @@ -10,6 +10,7 @@ "@hunk/session-broker-bun": "workspace:*", "@hunk/session-broker-core": "workspace:*", "@hunk/term-video": "workspace:*", + "@hunk/vcs": "workspace:*", "@opentui/core": "^0.5.6", "@opentui/react": "^0.5.6", "@pierre/diffs": "1.3.5", @@ -69,6 +70,10 @@ "@pierre/diffs", ], }, + "packages/hunk-vcs": { + "name": "@hunk/vcs", + "version": "0.0.0", + }, "packages/session-broker": { "name": "@hunk/session-broker", "version": "0.0.0", @@ -134,6 +139,8 @@ "@hunk/term-video": ["@hunk/term-video@workspace:packages/term-video"], + "@hunk/vcs": ["@hunk/vcs@workspace:packages/hunk-vcs"], + "@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.2.2", "", { "dependencies": { "@tybys/wasm-util": "^0.10.3" }, "peerDependencies": { "@emnapi/core": "^1.7.1 || ^2.0.0-alpha.3", "@emnapi/runtime": "^1.7.1 || ^2.0.0-alpha.3" } }, "sha512-JfB4kuJQjaoHuCTseIINHtHWeJnvgEcxjwA5t/Y00ZgaOO1Crz3fjT/p8kT28zA/Caz7oiUMn3d6H2yOVCVwuw=="], "@opentui/core": ["@opentui/core@0.5.6", "", { "dependencies": { "bun-ffi-structs": "0.3.1", "diff": "9.0.0", "marked": "17.0.1", "string-width": "7.2.0", "strip-ansi": "7.1.2" }, "optionalDependencies": { "@opentui/core-darwin-arm64": "0.5.6", "@opentui/core-darwin-x64": "0.5.6", "@opentui/core-linux-arm64": "0.5.6", "@opentui/core-linux-arm64-musl": "0.5.6", "@opentui/core-linux-x64": "0.5.6", "@opentui/core-linux-x64-musl": "0.5.6", "@opentui/core-win32-arm64": "0.5.6", "@opentui/core-win32-x64": "0.5.6" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-e38H1VceXoSSOEYmhUeHDE3F3LHXn/sFEZ6NhbXkbYdrkdV+1MMTgEkGZJ9I5fHhBlJdGJ1LDTPw8V0ZSUXSyQ=="], diff --git a/docs/module-boundaries.md b/docs/module-boundaries.md index 95df201bc..fe3e6ba65 100644 --- a/docs/module-boundaries.md +++ b/docs/module-boundaries.md @@ -21,11 +21,13 @@ Tiers, bottom to top. A tier may import anything strictly below it and nothing a ```text packages/hunk/src/extension-api published contract; imports nothing -packages/hunk/src/lib dependency-free helpers; may import extension-api only -packages/hunk/src/core domain model (changesets, review, vcs catalog, config) -packages/* standalone publishable units (session broker, term-video); - never import packages/hunk/src/; the per-app broker contract is in - docs/session-broker-sdk.md +packages/hunk-vcs private dependency-bottom VCS implementation helpers +packages/hunk/src/lib compatibility helpers; may import extension-api and hunk-vcs +packages/hunk/src/core domain model (changesets, review, vcs catalog, config) +packages/* isolated workspace units; private provider packages may import + hunkdiff/extension and explicit @hunk/vcs subpaths, while other + packages never import packages/hunk/src/; the per-app broker + contract is in docs/session-broker-sdk.md packages/hunk/src/extensions extension host + bundled extensions; consume core, never surfaces packages/hunk/src/session daemon/broker transport + protocol; consumes core and packages packages/hunk/src/app startup composition: CLI parsing plus the wiring of core, diff --git a/knip.json b/knip.json index 049c9ba80..aa80b9ce8 100644 --- a/knip.json +++ b/knip.json @@ -38,6 +38,10 @@ "project": ["src/**/*.{ts,tsx}", "bin/**/*.cjs"], "ignoreExportsUsedInFile": true }, + "packages/hunk-vcs": { + "entry": ["src/**/*.test.ts"], + "project": ["src/**/*.ts"] + }, "packages/session-broker*": { "entry": ["src/**/*.test.ts"], "project": ["src/**/*.ts"] @@ -55,7 +59,6 @@ "packages/hunk/src/core/process/updateNotice.ts": ["exports", "types"], "packages/hunk/src/extensions/types.ts": ["exports", "types"], "packages/hunk/src/extensions/default/ui/sidebar/index.tsx": ["exports", "types"], - "packages/hunk/src/extensions/default/vcs/diffRange.ts": ["exports"], "packages/hunk/src/session/broker/appContract.ts": ["exports", "types"], "packages/hunk/src/ui/diff/worker/index.ts": ["exports", "types"] } diff --git a/nix/bun.lock.nix b/nix/bun.lock.nix index 8db4df848..b76898128 100644 --- a/nix/bun.lock.nix +++ b/nix/bun.lock.nix @@ -58,6 +58,7 @@ "@hunk/session-broker-core" = copyPathToStore ../packages/session-broker-core; "@hunk/session-broker-node" = copyPathToStore ../packages/session-broker-node; "@hunk/term-video" = copyPathToStore ../packages/term-video; + "@hunk/vcs" = copyPathToStore ../packages/hunk-vcs; "@napi-rs/wasm-runtime@1.2.2" = fetchurl { url = "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.2.2.tgz"; hash = "sha512-JfB4kuJQjaoHuCTseIINHtHWeJnvgEcxjwA5t/Y00ZgaOO1Crz3fjT/p8kT28zA/Caz7oiUMn3d6H2yOVCVwuw=="; diff --git a/package.json b/package.json index 310a6ab93..f745f682e 100644 --- a/package.json +++ b/package.json @@ -81,6 +81,7 @@ "@hunk/session-broker-bun": "workspace:*", "@hunk/session-broker-core": "workspace:*", "@hunk/term-video": "workspace:*", + "@hunk/vcs": "workspace:*", "@opentui/core": "^0.5.6", "@opentui/react": "^0.5.6", "@pierre/diffs": "1.3.5", diff --git a/packages/hunk-vcs/package.json b/packages/hunk-vcs/package.json new file mode 100644 index 000000000..619494cd9 --- /dev/null +++ b/packages/hunk-vcs/package.json @@ -0,0 +1,38 @@ +{ + "name": "@hunk/vcs", + "version": "0.0.0", + "private": true, + "description": "Shared implementation infrastructure for Hunk VCS backends.", + "license": "MIT", + "files": [ + "src" + ], + "type": "module", + "sideEffects": false, + "exports": { + "./async-process": { + "types": "./src/async-process.ts", + "import": "./src/async-process.ts" + }, + "./diff-target": { + "types": "./src/diff-target.ts", + "import": "./src/diff-target.ts" + }, + "./large-file": { + "types": "./src/large-file.ts", + "import": "./src/large-file.ts" + }, + "./path": { + "types": "./src/path.ts", + "import": "./src/path.ts" + }, + "./source": { + "types": "./src/source.ts", + "import": "./src/source.ts" + } + }, + "engines": { + "bun": ">=1.3.14", + "node": ">=22" + } +} diff --git a/packages/hunk/src/extensions/default/vcs/asyncProcess.test.ts b/packages/hunk-vcs/src/async-process.test.ts similarity index 96% rename from packages/hunk/src/extensions/default/vcs/asyncProcess.test.ts rename to packages/hunk-vcs/src/async-process.test.ts index b95d2231d..526bb8ce2 100644 --- a/packages/hunk/src/extensions/default/vcs/asyncProcess.test.ts +++ b/packages/hunk-vcs/src/async-process.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test"; -import { runAbortableCommand } from "./asyncProcess"; +import { runAbortableCommand } from "./async-process"; describe("abortable bundled VCS subprocesses", () => { test("does not spawn after cancellation already won", async () => { diff --git a/packages/hunk/src/extensions/default/vcs/asyncProcess.ts b/packages/hunk-vcs/src/async-process.ts similarity index 100% rename from packages/hunk/src/extensions/default/vcs/asyncProcess.ts rename to packages/hunk-vcs/src/async-process.ts diff --git a/packages/hunk/src/extensions/default/vcs/diffRange.ts b/packages/hunk-vcs/src/diff-target.ts similarity index 65% rename from packages/hunk/src/extensions/default/vcs/diffRange.ts rename to packages/hunk-vcs/src/diff-target.ts index 78fdfb1c6..351351064 100644 --- a/packages/hunk/src/extensions/default/vcs/diffRange.ts +++ b/packages/hunk-vcs/src/diff-target.ts @@ -1,4 +1,11 @@ -import type { ExtensionVcsDiffInput } from "hunkdiff/extension"; +/** The revision fields needed to describe a provider's diff target. */ +interface DiffTargetInput { + readonly range?: string; + readonly rangeEndpoints?: { + readonly from: string; + readonly to: string; + }; +} /** * Describe a diff's comparison in compact range form for titles and Git arguments. @@ -7,18 +14,18 @@ import type { ExtensionVcsDiffInput } from "hunkdiff/extension"; * revset rather than Git's direct two-tree comparison, so they must build process * arguments from `rangeEndpoints` instead. */ -export function describeDiffRange(input: ExtensionVcsDiffInput) { +export function describeDiffRange(input: DiffTargetInput) { const endpoints = input.rangeEndpoints; return endpoints ? `${endpoints.from}..${endpoints.to}` : input.range; } /** Describe the targets using the positional spelling the user supplied. */ -export function describeDiffTargets(input: ExtensionVcsDiffInput) { +export function describeDiffTargets(input: DiffTargetInput) { const endpoints = input.rangeEndpoints; return endpoints ? `${endpoints.from} ${endpoints.to}` : input.range; } /** Return whether a VCS diff has any explicit revision or range target. */ -export function hasExplicitDiffTarget(input: ExtensionVcsDiffInput) { +export function hasExplicitDiffTarget(input: DiffTargetInput) { return describeDiffRange(input) !== undefined; } diff --git a/packages/hunk-vcs/src/large-file.ts b/packages/hunk-vcs/src/large-file.ts new file mode 100644 index 000000000..e0b3d68fa --- /dev/null +++ b/packages/hunk-vcs/src/large-file.ts @@ -0,0 +1,96 @@ +import fs from "node:fs"; +import { join } from "node:path"; + +/** + * Thresholds past which Hunk lists a file instead of rendering its diff. + * + * Shared by every backend so "too large to review" means one thing across the + * product, and by the host's own untracked-file synthesizer. + */ +export const LARGE_DIFF_FILE_MAX_BYTES = 1_000_000; +export const LARGE_DIFF_FILE_MAX_LINES = 20_000; + +/** How much of a file line counting reads before giving up and reporting a truncated count. */ +const LARGE_DIFF_FILE_SNIFF_BYTES = 256 * 1024; + +interface CountedLines { + complete: boolean; + lines: number; +} + +/** Count text lines with a byte cap so huge skipped-file stats do not block startup. */ +function countLinesInFile(path: string, maxBytes: number, size: number): CountedLines { + let fd: number | undefined; + + try { + fd = fs.openSync(path, "r"); + const buffer = Buffer.alloc(Math.min(64 * 1024, maxBytes)); + let position = 0; + let lineCount = 0; + let lastByte: number | undefined; + + while (position < maxBytes) { + const bytesToRead = Math.min(buffer.length, maxBytes - position); + const bytesRead = fs.readSync(fd, buffer, 0, bytesToRead, position); + if (bytesRead === 0) { + break; + } + + position += bytesRead; + for (let index = 0; index < bytesRead; index += 1) { + lastByte = buffer[index]; + if (lastByte === 0x0a) { + lineCount += 1; + } + } + } + + return { + complete: position >= size, + lines: lastByte !== undefined && lastByte !== 0x0a ? lineCount + 1 : lineCount, + }; + } catch { + return { complete: true, lines: 0 }; + } finally { + if (fd !== undefined) { + fs.closeSync(fd); + } + } +} + +export interface LargeFileCheck { + shouldSkip: boolean; + stats?: { additions: number; deletions: number }; + /** True when `stats` came from a capped read and undercount the real file. */ + statsTruncated?: boolean; +} + +/** + * Return whether a whole file on disk is too large to synthesize into a patch. + * + * Used for files that would be rendered from their current contents — untracked + * files, and anything else a backend adds as a full-file addition — where the + * cost is the file itself rather than the size of a change to it. + */ +export function inspectLargeUntrackedFile(repoRoot: string, filePath: string): LargeFileCheck { + const absolutePath = join(repoRoot, filePath); + + let stat: fs.Stats; + try { + stat = fs.statSync(absolutePath); + } catch { + return { shouldSkip: false }; + } + + const byteLimit = + stat.size > LARGE_DIFF_FILE_MAX_BYTES ? LARGE_DIFF_FILE_MAX_BYTES : LARGE_DIFF_FILE_SNIFF_BYTES; + const counted = countLinesInFile(absolutePath, byteLimit, stat.size); + const shouldSkip = + stat.size > LARGE_DIFF_FILE_MAX_BYTES || counted.lines > LARGE_DIFF_FILE_MAX_LINES; + + return { + shouldSkip, + stats: shouldSkip ? { additions: counted.lines, deletions: 0 } : undefined, + statsTruncated: shouldSkip ? !counted.complete : undefined, + }; +} diff --git a/packages/hunk/src/lib/osPath.test.ts b/packages/hunk-vcs/src/path.test.ts similarity index 96% rename from packages/hunk/src/lib/osPath.test.ts rename to packages/hunk-vcs/src/path.test.ts index cbfe4b561..4ecd79f36 100644 --- a/packages/hunk/src/lib/osPath.test.ts +++ b/packages/hunk-vcs/src/path.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test"; -import { normalizePathForOS } from "./osPath"; +import { normalizePathForOS } from "./path"; describe("normalizePathForOS", () => { test("normalizes Unix-style Windows paths for native subprocess cwd", () => { diff --git a/packages/hunk-vcs/src/path.ts b/packages/hunk-vcs/src/path.ts new file mode 100644 index 000000000..a7dcf16f2 --- /dev/null +++ b/packages/hunk-vcs/src/path.ts @@ -0,0 +1,25 @@ +/** Normalize compatibility-layer paths into native paths for the current OS. */ +export function normalizePathForOS(path: string, platform = process.platform) { + switch (platform) { + case "win32": + return normalizeWindowsCompatibilityPath(path); + default: + return path; + } +} + +/** Convert Unix-style Windows paths to native paths usable as Bun cwd. */ +function normalizeWindowsCompatibilityPath(path: string) { + const normalized = path + // Some Windows tools can report slash-prefixed drive paths as `/C:/...`. + .replace(/^\/([a-zA-Z]):(?:[\\/]|$)/, (_, drive) => `${drive.toUpperCase()}:/`) + // Keep specific compatibility-layer prefixes before the generic `/c/...` form. + // Cygwin commonly reports drive paths as `/cygdrive/c/...`. + .replace(/^\/cygdrive\/([a-zA-Z])(?:[\\/]|$)/, (_, drive) => `${drive.toUpperCase()}:/`) + // WSL-style paths are commonly reported as `/mnt/c/...`. + .replace(/^\/mnt\/([a-zA-Z])(?:[\\/]|$)/, (_, drive) => `${drive.toUpperCase()}:/`) + // Git Bash/MSYS2 commonly reports drive paths as `/c/...`. + .replace(/^\/([a-zA-Z])(?:[\\/]|$)/, (_, drive) => `${drive.toUpperCase()}:/`); + + return normalized === path ? path : normalized.replaceAll("/", "\\"); +} diff --git a/packages/hunk/src/lib/sourceText.test.ts b/packages/hunk-vcs/src/source.test.ts similarity index 99% rename from packages/hunk/src/lib/sourceText.test.ts rename to packages/hunk-vcs/src/source.test.ts index 6e3ae939f..ff65a5702 100644 --- a/packages/hunk/src/lib/sourceText.test.ts +++ b/packages/hunk-vcs/src/source.test.ts @@ -2,7 +2,7 @@ import { afterEach, describe, expect, test } from "bun:test"; import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { readFileTextWithLimit, terminateSourceSubprocess } from "./sourceText"; +import { readFileTextWithLimit, terminateSourceSubprocess } from "./source"; const tempDirs: string[] = []; diff --git a/packages/hunk-vcs/src/source.ts b/packages/hunk-vcs/src/source.ts new file mode 100644 index 000000000..788ca40cf --- /dev/null +++ b/packages/hunk-vcs/src/source.ts @@ -0,0 +1,127 @@ +/** Default byte ceiling for exact source text loaded for expanded context. */ +export const DEFAULT_SOURCE_TEXT_MAX_BYTES = 1_000_000; + +/** Structural result shared by bounded filesystem source readers. */ +export type LimitedSourceTextResult = string | null | { kind: "too-large"; maxBytes: number }; + +/** Keep source-load diagnostics terse enough to be useful in logs. */ +export function logSourceDiagnostic(message: string, detail?: unknown) { + if (detail instanceof Error) { + console.error(`hunk: ${message}: ${detail.message}`, detail); + return; + } + + const firstLine = + typeof detail === "string" + ? detail + .split("\n") + .map((line) => line.trim()) + .find(Boolean) + : undefined; + console.error(firstLine ? `hunk: ${message}: ${firstLine}` : `hunk: ${message}`); +} + +/** Read one filesystem source as text without exceeding the supplied byte ceiling. */ +export async function readFileTextWithLimit( + absolutePath: string, + maxBytes: number, +): Promise { + try { + const file = Bun.file(absolutePath); + if (!(await file.exists())) { + return null; + } + if (file.size > maxBytes) { + return { kind: "too-large", maxBytes }; + } + return await file.text(); + } catch (error) { + logSourceDiagnostic(`failed to read source file ${absolutePath}`, error); + return null; + } +} + +const SOURCE_SUBPROCESS_GRACE_MS = 100; +const SOURCE_SUBPROCESS_FORCE_WAIT_MS = 250; + +/** Wait a bounded interval for a source-reader subprocess to exit. */ +function waitForSourceSubprocessExit(proc: Bun.ReadableSubprocess, timeoutMs: number) { + return new Promise((resolve) => { + let settled = false; + const finish = (exited: boolean) => { + if (settled) { + return; + } + settled = true; + clearTimeout(timer); + resolve(exited); + }; + const timer = setTimeout(() => finish(false), timeoutMs); + + void proc.exited.then( + () => finish(true), + () => finish(true), + ); + }); +} + +/** Terminate a source-reader subprocess without letting cleanup block indefinitely. */ +export async function terminateSourceSubprocess(proc: Bun.ReadableSubprocess) { + try { + proc.kill("SIGTERM"); + } catch { + // The process may have exited between the stream failure and cleanup. + } + if (await waitForSourceSubprocessExit(proc, SOURCE_SUBPROCESS_GRACE_MS)) { + return; + } + + try { + proc.kill("SIGKILL"); + } catch { + // A concurrent exit needs no further signal. + } + await waitForSourceSubprocessExit(proc, SOURCE_SUBPROCESS_FORCE_WAIT_MS); +} + +/** Read a byte stream as text while enforcing a caller-defined resource limit. */ +export async function readStreamTextWithLimit( + stream: ReadableStream | null, + maxBytes: number, + onTooLarge?: () => void, + createLimitError: (maxBytes: number) => Error = (limit) => + new Error(`Source text exceeds ${limit} bytes.`), +) { + if (!stream) { + return ""; + } + + const reader = stream.getReader(); + const chunks: Uint8Array[] = []; + let totalBytes = 0; + + for (;;) { + const { done, value } = await reader.read(); + if (done) { + break; + } + + totalBytes += value.byteLength; + if (totalBytes > maxBytes) { + onTooLarge?.(); + await reader.cancel().catch(() => undefined); + throw createLimitError(maxBytes); + } + + chunks.push(value); + } + + const combined = new Uint8Array(totalBytes); + let offset = 0; + for (const chunk of chunks) { + combined.set(chunk, offset); + offset += chunk.byteLength; + } + + return new TextDecoder().decode(combined); +} diff --git a/packages/hunk/src/extensions/default/vcs/git/commands.ts b/packages/hunk/src/extensions/default/vcs/git/commands.ts index 0b6080bfd..93135098d 100644 --- a/packages/hunk/src/extensions/default/vcs/git/commands.ts +++ b/packages/hunk/src/extensions/default/vcs/git/commands.ts @@ -6,10 +6,10 @@ import { type ExtensionVcsShowInput, type ExtensionVcsStashShowInput, } from "hunkdiff/extension"; -import { LARGE_DIFF_FILE_MAX_BYTES, LARGE_DIFF_FILE_MAX_LINES } from "../../../../lib/largeFile"; -import { normalizePathForOS } from "../../../../lib/osPath"; -import { describeDiffRange, describeDiffTargets } from "../diffRange"; -import { runAbortableCommand } from "../asyncProcess"; +import { LARGE_DIFF_FILE_MAX_BYTES, LARGE_DIFF_FILE_MAX_LINES } from "@hunk/vcs/large-file"; +import { normalizePathForOS } from "@hunk/vcs/path"; +import { describeDiffRange, describeDiffTargets } from "@hunk/vcs/diff-target"; +import { runAbortableCommand } from "@hunk/vcs/async-process"; /** * Every Git command Hunk runs, and the failures they translate into. diff --git a/packages/hunk/src/extensions/default/vcs/git/index.ts b/packages/hunk/src/extensions/default/vcs/git/index.ts index cf9e96e6e..79016c012 100644 --- a/packages/hunk/src/extensions/default/vcs/git/index.ts +++ b/packages/hunk/src/extensions/default/vcs/git/index.ts @@ -25,7 +25,7 @@ import { } from "./commands"; import { openGitHistory } from "./history"; import { gitEndpointSourceSpec, readGitFileSource } from "./source"; -import { describeDiffRange } from "../diffRange"; +import { describeDiffRange } from "@hunk/vcs/diff-target"; import { HUNK_VCS_DETECTION_BASELINE_PRIORITY, type ExtensionVcsAdapter, @@ -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 generic `packages/hunk/src/lib` - * utilities. 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 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. */ /** Return the last path segment for review titles. */ diff --git a/packages/hunk/src/extensions/default/vcs/git/source.ts b/packages/hunk/src/extensions/default/vcs/git/source.ts index b5a767291..ca60beb88 100644 --- a/packages/hunk/src/extensions/default/vcs/git/source.ts +++ b/packages/hunk/src/extensions/default/vcs/git/source.ts @@ -9,7 +9,7 @@ import { readFileTextWithLimit, readStreamTextWithLimit, terminateSourceSubprocess, -} from "../../../../lib/sourceText"; +} from "@hunk/vcs/source"; import type { GitDiffEndpoint } from "./commands"; /** A provider-local signal converted to the public structural result at this boundary. */ diff --git a/packages/hunk/src/extensions/default/vcs/jujutsu/commands.ts b/packages/hunk/src/extensions/default/vcs/jujutsu/commands.ts index 917d6c816..e734251bb 100644 --- a/packages/hunk/src/extensions/default/vcs/jujutsu/commands.ts +++ b/packages/hunk/src/extensions/default/vcs/jujutsu/commands.ts @@ -4,9 +4,9 @@ import { type ExtensionVcsRangeEndpoints, type ExtensionVcsShowInput, } from "hunkdiff/extension"; -import { normalizePathForOS } from "../../../../lib/osPath"; -import { describeDiffTargets } from "../diffRange"; -import { runAbortableCommand } from "../asyncProcess"; +import { normalizePathForOS } from "@hunk/vcs/path"; +import { describeDiffTargets } from "@hunk/vcs/diff-target"; +import { runAbortableCommand } from "@hunk/vcs/async-process"; export type JjBackedInput = ExtensionVcsDiffInput | ExtensionVcsShowInput; diff --git a/packages/hunk/src/extensions/default/vcs/jujutsu/history.ts b/packages/hunk/src/extensions/default/vcs/jujutsu/history.ts index 4f2138538..e650f9ac0 100644 --- a/packages/hunk/src/extensions/default/vcs/jujutsu/history.ts +++ b/packages/hunk/src/extensions/default/vcs/jujutsu/history.ts @@ -7,7 +7,7 @@ import { type ExtensionVcsHistoryInput, type ExtensionVcsHistorySource, } from "hunkdiff/extension"; -import { normalizePathForOS } from "../../../../lib/osPath"; +import { normalizePathForOS } from "@hunk/vcs/path"; const HISTORY_FIELDS_PER_COMMIT = 13; const REF_SEPARATOR = "\x1f"; diff --git a/packages/hunk/src/extensions/default/vcs/jujutsu/index.ts b/packages/hunk/src/extensions/default/vcs/jujutsu/index.ts index cc065bc65..f964c61d7 100644 --- a/packages/hunk/src/extensions/default/vcs/jujutsu/index.ts +++ b/packages/hunk/src/extensions/default/vcs/jujutsu/index.ts @@ -13,7 +13,7 @@ import { } from "./commands"; import { openJjHistory } from "./history"; import { readJjFileSource } from "./source"; -import { describeDiffRange } from "../diffRange"; +import { describeDiffRange } from "@hunk/vcs/diff-target"; import { HUNK_VCS_DETECTION_BASELINE_PRIORITY, type ExtensionVcsAdapter, diff --git a/packages/hunk/src/extensions/default/vcs/jujutsu/source.ts b/packages/hunk/src/extensions/default/vcs/jujutsu/source.ts index be598916f..ed02d56c5 100644 --- a/packages/hunk/src/extensions/default/vcs/jujutsu/source.ts +++ b/packages/hunk/src/extensions/default/vcs/jujutsu/source.ts @@ -7,7 +7,7 @@ import { logSourceDiagnostic, readStreamTextWithLimit, terminateSourceSubprocess, -} from "../../../../lib/sourceText"; +} from "@hunk/vcs/source"; /** Stops a Jujutsu source read as soon as it crosses Hunk's byte limit. */ class JjSourceTooLargeError extends Error { diff --git a/packages/hunk/src/extensions/default/vcs/sapling/commands.ts b/packages/hunk/src/extensions/default/vcs/sapling/commands.ts index d0c8efe10..3e6177edd 100644 --- a/packages/hunk/src/extensions/default/vcs/sapling/commands.ts +++ b/packages/hunk/src/extensions/default/vcs/sapling/commands.ts @@ -5,9 +5,9 @@ import { type ExtensionVcsDiffInput, type ExtensionVcsShowInput, } from "hunkdiff/extension"; -import { normalizePathForOS } from "../../../../lib/osPath"; -import { describeDiffTargets } from "../diffRange"; -import { runAbortableCommand } from "../asyncProcess"; +import { normalizePathForOS } from "@hunk/vcs/path"; +import { describeDiffTargets } from "@hunk/vcs/diff-target"; +import { runAbortableCommand } from "@hunk/vcs/async-process"; export type SlBackedInput = ExtensionVcsDiffInput | ExtensionVcsShowInput; diff --git a/packages/hunk/src/extensions/default/vcs/sapling/index.ts b/packages/hunk/src/extensions/default/vcs/sapling/index.ts index fcfe201ff..f5e25a52c 100644 --- a/packages/hunk/src/extensions/default/vcs/sapling/index.ts +++ b/packages/hunk/src/extensions/default/vcs/sapling/index.ts @@ -11,7 +11,7 @@ import { runSlText, runSlTextAsync, } from "./commands"; -import { describeDiffRange } from "../diffRange"; +import { describeDiffRange } from "@hunk/vcs/diff-target"; import { HUNK_VCS_DETECTION_BASELINE_PRIORITY, type ExtensionVcsAdapter, diff --git a/packages/hunk/src/lib/largeFile.ts b/packages/hunk/src/lib/largeFile.ts index e0b3d68fa..726cb7b41 100644 --- a/packages/hunk/src/lib/largeFile.ts +++ b/packages/hunk/src/lib/largeFile.ts @@ -1,96 +1,2 @@ -import fs from "node:fs"; -import { join } from "node:path"; - -/** - * Thresholds past which Hunk lists a file instead of rendering its diff. - * - * Shared by every backend so "too large to review" means one thing across the - * product, and by the host's own untracked-file synthesizer. - */ -export const LARGE_DIFF_FILE_MAX_BYTES = 1_000_000; -export const LARGE_DIFF_FILE_MAX_LINES = 20_000; - -/** How much of a file line counting reads before giving up and reporting a truncated count. */ -const LARGE_DIFF_FILE_SNIFF_BYTES = 256 * 1024; - -interface CountedLines { - complete: boolean; - lines: number; -} - -/** Count text lines with a byte cap so huge skipped-file stats do not block startup. */ -function countLinesInFile(path: string, maxBytes: number, size: number): CountedLines { - let fd: number | undefined; - - try { - fd = fs.openSync(path, "r"); - const buffer = Buffer.alloc(Math.min(64 * 1024, maxBytes)); - let position = 0; - let lineCount = 0; - let lastByte: number | undefined; - - while (position < maxBytes) { - const bytesToRead = Math.min(buffer.length, maxBytes - position); - const bytesRead = fs.readSync(fd, buffer, 0, bytesToRead, position); - if (bytesRead === 0) { - break; - } - - position += bytesRead; - for (let index = 0; index < bytesRead; index += 1) { - lastByte = buffer[index]; - if (lastByte === 0x0a) { - lineCount += 1; - } - } - } - - return { - complete: position >= size, - lines: lastByte !== undefined && lastByte !== 0x0a ? lineCount + 1 : lineCount, - }; - } catch { - return { complete: true, lines: 0 }; - } finally { - if (fd !== undefined) { - fs.closeSync(fd); - } - } -} - -export interface LargeFileCheck { - shouldSkip: boolean; - stats?: { additions: number; deletions: number }; - /** True when `stats` came from a capped read and undercount the real file. */ - statsTruncated?: boolean; -} - -/** - * Return whether a whole file on disk is too large to synthesize into a patch. - * - * Used for files that would be rendered from their current contents — untracked - * files, and anything else a backend adds as a full-file addition — where the - * cost is the file itself rather than the size of a change to it. - */ -export function inspectLargeUntrackedFile(repoRoot: string, filePath: string): LargeFileCheck { - const absolutePath = join(repoRoot, filePath); - - let stat: fs.Stats; - try { - stat = fs.statSync(absolutePath); - } catch { - return { shouldSkip: false }; - } - - const byteLimit = - stat.size > LARGE_DIFF_FILE_MAX_BYTES ? LARGE_DIFF_FILE_MAX_BYTES : LARGE_DIFF_FILE_SNIFF_BYTES; - const counted = countLinesInFile(absolutePath, byteLimit, stat.size); - const shouldSkip = - stat.size > LARGE_DIFF_FILE_MAX_BYTES || counted.lines > LARGE_DIFF_FILE_MAX_LINES; - - return { - shouldSkip, - stats: shouldSkip ? { additions: counted.lines, deletions: 0 } : undefined, - statsTruncated: shouldSkip ? !counted.complete : undefined, - }; -} +/** 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 index a7dcf16f2..a9badf49f 100644 --- a/packages/hunk/src/lib/osPath.ts +++ b/packages/hunk/src/lib/osPath.ts @@ -1,25 +1,2 @@ -/** Normalize compatibility-layer paths into native paths for the current OS. */ -export function normalizePathForOS(path: string, platform = process.platform) { - switch (platform) { - case "win32": - return normalizeWindowsCompatibilityPath(path); - default: - return path; - } -} - -/** Convert Unix-style Windows paths to native paths usable as Bun cwd. */ -function normalizeWindowsCompatibilityPath(path: string) { - const normalized = path - // Some Windows tools can report slash-prefixed drive paths as `/C:/...`. - .replace(/^\/([a-zA-Z]):(?:[\\/]|$)/, (_, drive) => `${drive.toUpperCase()}:/`) - // Keep specific compatibility-layer prefixes before the generic `/c/...` form. - // Cygwin commonly reports drive paths as `/cygdrive/c/...`. - .replace(/^\/cygdrive\/([a-zA-Z])(?:[\\/]|$)/, (_, drive) => `${drive.toUpperCase()}:/`) - // WSL-style paths are commonly reported as `/mnt/c/...`. - .replace(/^\/mnt\/([a-zA-Z])(?:[\\/]|$)/, (_, drive) => `${drive.toUpperCase()}:/`) - // Git Bash/MSYS2 commonly reports drive paths as `/c/...`. - .replace(/^\/([a-zA-Z])(?:[\\/]|$)/, (_, drive) => `${drive.toUpperCase()}:/`); - - return normalized === path ? path : normalized.replaceAll("/", "\\"); -} +/** 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 index 788ca40cf..0d931912b 100644 --- a/packages/hunk/src/lib/sourceText.ts +++ b/packages/hunk/src/lib/sourceText.ts @@ -1,127 +1,2 @@ -/** Default byte ceiling for exact source text loaded for expanded context. */ -export const DEFAULT_SOURCE_TEXT_MAX_BYTES = 1_000_000; - -/** Structural result shared by bounded filesystem source readers. */ -export type LimitedSourceTextResult = string | null | { kind: "too-large"; maxBytes: number }; - -/** Keep source-load diagnostics terse enough to be useful in logs. */ -export function logSourceDiagnostic(message: string, detail?: unknown) { - if (detail instanceof Error) { - console.error(`hunk: ${message}: ${detail.message}`, detail); - return; - } - - const firstLine = - typeof detail === "string" - ? detail - .split("\n") - .map((line) => line.trim()) - .find(Boolean) - : undefined; - console.error(firstLine ? `hunk: ${message}: ${firstLine}` : `hunk: ${message}`); -} - -/** Read one filesystem source as text without exceeding the supplied byte ceiling. */ -export async function readFileTextWithLimit( - absolutePath: string, - maxBytes: number, -): Promise { - try { - const file = Bun.file(absolutePath); - if (!(await file.exists())) { - return null; - } - if (file.size > maxBytes) { - return { kind: "too-large", maxBytes }; - } - return await file.text(); - } catch (error) { - logSourceDiagnostic(`failed to read source file ${absolutePath}`, error); - return null; - } -} - -const SOURCE_SUBPROCESS_GRACE_MS = 100; -const SOURCE_SUBPROCESS_FORCE_WAIT_MS = 250; - -/** Wait a bounded interval for a source-reader subprocess to exit. */ -function waitForSourceSubprocessExit(proc: Bun.ReadableSubprocess, timeoutMs: number) { - return new Promise((resolve) => { - let settled = false; - const finish = (exited: boolean) => { - if (settled) { - return; - } - settled = true; - clearTimeout(timer); - resolve(exited); - }; - const timer = setTimeout(() => finish(false), timeoutMs); - - void proc.exited.then( - () => finish(true), - () => finish(true), - ); - }); -} - -/** Terminate a source-reader subprocess without letting cleanup block indefinitely. */ -export async function terminateSourceSubprocess(proc: Bun.ReadableSubprocess) { - try { - proc.kill("SIGTERM"); - } catch { - // The process may have exited between the stream failure and cleanup. - } - if (await waitForSourceSubprocessExit(proc, SOURCE_SUBPROCESS_GRACE_MS)) { - return; - } - - try { - proc.kill("SIGKILL"); - } catch { - // A concurrent exit needs no further signal. - } - await waitForSourceSubprocessExit(proc, SOURCE_SUBPROCESS_FORCE_WAIT_MS); -} - -/** Read a byte stream as text while enforcing a caller-defined resource limit. */ -export async function readStreamTextWithLimit( - stream: ReadableStream | null, - maxBytes: number, - onTooLarge?: () => void, - createLimitError: (maxBytes: number) => Error = (limit) => - new Error(`Source text exceeds ${limit} bytes.`), -) { - if (!stream) { - return ""; - } - - const reader = stream.getReader(); - const chunks: Uint8Array[] = []; - let totalBytes = 0; - - for (;;) { - const { done, value } = await reader.read(); - if (done) { - break; - } - - totalBytes += value.byteLength; - if (totalBytes > maxBytes) { - onTooLarge?.(); - await reader.cancel().catch(() => undefined); - throw createLimitError(maxBytes); - } - - chunks.push(value); - } - - const combined = new Uint8Array(totalBytes); - let offset = 0; - for (const chunk of chunks) { - combined.set(chunk, offset); - offset += chunk.byteLength; - } - - return new TextDecoder().decode(combined); -} +/** 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/hunk-vcs-package.test.ts b/scripts/hunk-vcs-package.test.ts new file mode 100644 index 000000000..4090b7f93 --- /dev/null +++ b/scripts/hunk-vcs-package.test.ts @@ -0,0 +1,96 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { cpSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; + +const REPO_ROOT = resolve(import.meta.dir, ".."); +const PACKAGE_ROOT = join(REPO_ROOT, "packages", "hunk-vcs"); +const EXPECTED_EXPORTS = ["./async-process", "./diff-target", "./large-file", "./path", "./source"]; +const tempDirs: string[] = []; + +/** Create one temporary package consumer tracked for cleanup. */ +function createTempConsumer() { + const directory = mkdtempSync(join(tmpdir(), "hunk-vcs-package-")); + tempDirs.push(directory); + return directory; +} + +afterEach(() => { + for (const directory of tempDirs.splice(0)) { + rmSync(directory, { recursive: true, force: true }); + } +}); + +describe("@hunk/vcs package boundary", () => { + test("exports only explicit implementation leaves", () => { + const manifest = JSON.parse(readFileSync(join(PACKAGE_ROOT, "package.json"), "utf8")) as { + name: string; + private: boolean; + exports: Record; + }; + + expect(manifest.name).toBe("@hunk/vcs"); + expect(manifest.private).toBe(true); + expect(Object.keys(manifest.exports).sort()).toEqual(EXPECTED_EXPORTS); + expect(manifest.exports["."]).toBeUndefined(); + }); + + 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/vcs"]).toBe("workspace:*"); + expect(hunkManifest).not.toContain("@hunk/vcs"); + expect(hunkManifest).not.toContain("workspace:"); + expect(bunLock).toContain('"packages/hunk-vcs": {'); + expect(bunLock).toContain('"@hunk/vcs@workspace:packages/hunk-vcs"'); + expect(nixLock).toContain('"@hunk/vcs" = copyPathToStore ../packages/hunk-vcs;'); + }); + + test("resolves its structural diff helper from an isolated package copy", () => { + const consumerRoot = createTempConsumer(); + const installedPackage = join(consumerRoot, "node_modules", "@hunk", "vcs"); + cpSync(PACKAGE_ROOT, installedPackage, { recursive: true }); + writeFileSync( + join(consumerRoot, "consumer.ts"), + [ + 'import { describeDiffRange } from "@hunk/vcs/diff-target";', + 'const input = { range: "main" };', + "const description: string | undefined = describeDiffRange(input);", + "void description;", + ].join("\n"), + ); + writeFileSync( + join(consumerRoot, "tsconfig.json"), + JSON.stringify({ + compilerOptions: { + target: "ESNext", + module: "ESNext", + moduleResolution: "bundler", + strict: true, + noEmit: true, + lib: ["ESNext", "DOM"], + types: [], + }, + files: ["consumer.ts"], + }), + ); + + const result = Bun.spawnSync( + [ + process.execPath, + join(REPO_ROOT, "node_modules", "typescript", "bin", "tsc"), + "-p", + join(consumerRoot, "tsconfig.json"), + ], + { cwd: consumerRoot, stdout: "pipe", stderr: "pipe" }, + ); + expect(new TextDecoder().decode(result.stderr)).toBe(""); + expect(new TextDecoder().decode(result.stdout)).toBe(""); + expect(result.exitCode).toBe(0); + }); +}); diff --git a/scripts/source-boundaries.test.ts b/scripts/source-boundaries.test.ts index 2941c8f9c..3a38c472d 100644 --- a/scripts/source-boundaries.test.ts +++ b/scripts/source-boundaries.test.ts @@ -7,12 +7,13 @@ 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 VCS_PACKAGE_ROOT = join(REPO_ROOT, "packages", "hunk-vcs", "src"); 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 -// the seam: `extension-api-is-import-free` (.dependency-cruiser.cjs) forbids it any import at -// all, so it can never carry a renderer or a platform runtime in. Only this one file is allowed, -// not the tree — `extension-api/index.ts` is the runtime boundary and imports freely. +// the seam: `extension-api-is-import-free` (.dependency-cruiser.cjs) prevents it from carrying a +// renderer or platform runtime in. Only this one file is allowed, not the tree — +// `extension-api/index.ts` is the runtime boundary. const EXTENSION_API_TYPES_PATH = join(SRC_ROOT, "extension-api", "types.ts"); const REVIEW_PROTOCOL_PATH = join(SRC_ROOT, "session", "reviewProtocol.ts"); const REVIEW_DESCRIPTOR_PATH = join(CORE_ROOT, "reviewDescriptor.ts"); @@ -155,12 +156,28 @@ function unexpectedExternalImports( }); } -/** Find bundled provider imports that bypass the published extension barrel. */ -function privateProviderApiImports() { +/** Find bundled provider imports outside local modules, Node, 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 sourceFiles(BUNDLED_PROVIDER_ROOT).flatMap((path) => - importSpecifiers(path).some((specifier) => specifier.includes("extension-api")) - ? [repoPath(path)] - : [], + importSpecifiers(path) + // The repository-wide lightweight scanner can also match prose ending in `from "…"`; + // real module specifiers never contain whitespace. + .filter((specifier) => !/\s/.test(specifier)) + .filter( + (specifier) => + !specifier.startsWith(".") && + !specifier.startsWith("node:") && + !allowedImports.has(specifier), + ) + .map((specifier) => `${repoPath(path)} -> ${specifier}`), ); } @@ -284,9 +301,20 @@ describe("source architecture boundaries", () => { } }); - test("keeps bundled providers on their public host contract", () => { + test("keeps bundled providers on the public contract and explicit VCS helper leaves", () => { expect(forbiddenImports(BUNDLED_PROVIDER_ROOT, CORE_ROOT)).toEqual([]); - expect(privateProviderApiImports()).toEqual([]); + expect(unexpectedProviderImports()).toEqual([]); + }); + + test("keeps @hunk/vcs dependency-bottom and contained", () => { + expect(escapingImports(VCS_PACKAGE_ROOT, [VCS_PACKAGE_ROOT])).toEqual([]); + expect( + sourceFiles(VCS_PACKAGE_ROOT).flatMap((path) => + importSpecifiers(path) + .filter((specifier) => !specifier.startsWith(".") && !specifier.startsWith("node:")) + .map((specifier) => `${repoPath(path)} -> ${specifier}`), + ), + ).toEqual([]); }); }); diff --git a/tsconfig.json b/tsconfig.json index 2697d822e..74ca87e2a 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -18,6 +18,7 @@ "@hunk/session-broker-bun": ["packages/session-broker-bun/src/index.ts"], "@hunk/session-broker-core": ["packages/session-broker-core/src/index.ts"], "@hunk/session-broker-node": ["packages/session-broker-node/src/index.ts"], + "@hunk/vcs/*": ["packages/hunk-vcs/src/*"], "hunkdiff/extension": ["packages/hunk/src/extension-api/index.ts"] }, "strict": true,