Defines Hunk's current import boundaries and preserves the migration record that established them. The boundaries are enforced by
dependency-cruiser over the production import
graph (packages/hunk/src/ plus packages/, tests excluded):
bun run deps:check— fails CI on any boundary violation not in the baseline.bun run deps:baseline— regenerates.dependency-cruiser-known-violations.jsonafter fixing a violation. The baseline is shrink-only: entries leave when the underlying edge is fixed, and nothing is ever added. New code must respect the boundaries from day one.- Rules live in
.dependency-cruiser.cjs; each rule's comment states the boundary it protects.
scripts/quality/source-boundaries.test.ts remains the deeper, hand-authored gate for the review seam
(browser-safe closure, Node-debt tombstones). The dependency-cruiser rules are the coarse
tier-level complement, with real module resolution instead of regex import scanning.
The product source follows these tiers, bottom to top. A tier imports only lower tiers unless an explicit rule below names a facade or adapter exception.
packages/hunk/src/extension-api published contract; imports nothing
packages/hunk-vcs/ private dependency-bottom VCS helpers; explicit leaf exports only
packages/hunk/src/lib small compatibility helpers
packages/hunk/src/core product model, non-rendering runtime primitives, and policy
packages/hunk/src/extensions extension host and bundled registration adapters
packages/hunk/src/session daemon/broker transport and protocol
packages/hunk/src/app CLI/startup/session composition; no rendering
packages/hunk/src/ui terminal surface and named app/session adapters
packages/hunk/src/opentui published facade re-exporting selected ui/core pieces
packages/hunk/src/main.tsx final CLI composition and entrypoint
Other workspaces are bounded units rather than one tier in that stack:
packages/hunk-{git,jj,sapling}implement private bundled providers throughhunkdiff/extensionand explicit@hunk/vcs/*leaves.packages/session-broker{,-core,-bun,-node}form a runtime-neutral broker stack plus listener adapters. They do not import Hunk source internals.packages/term-videoowns terminal capture tooling and does not import Hunk source internals.
Current core/ modules are changeset/, history/, install/, patch/, process/, review/,
run/, theme/, vcs/, and watch/. Its root contains bootstrap.ts, liveComments.ts,
reviewDescriptor.ts, and reviewDigest.ts plus tests.
Intentional exceptions, allowed by the rules:
packages/hunk/src/opentuiimportspackages/hunk/src/uiinternals: it is a packaging facade whose job is re-export.packages/hunk/src/hunk-reviewimportspackages/hunk/src/session/agent: the skill document is generated from the agent surface by design.- Tests are excluded: they are colocated and free to reach across boundaries.
Tier rules say which trees may reach each other; interior rules say which files in a tree
outsiders may name. A module's interior is enforced the same way as a tier — one rule per
protected file, from everything outside the module's directory, to the file — so an
accidental reach-in fails bun run deps:check instead of quietly becoming API.
Two supporting rules keep the interiors honest:
no-dead-modulesflags any module underpackages/hunk/src/that no entry point reaches (main.tsx,highlightWorkerEntry.ts, theopentuiandextension-apifacades, and the skill generator). It usesreachable: falserather thanorphan, which only catches fully disconnected files and so misses dead code that still imports. A hit is deleted, or — when tests are its only genuine consumer — listed in the rule'sTEST_ONLY_MODULESallowlist with the reason. That allowlist is shrink-only, like the baseline.core-leaves-stay-below-bootstrapfreezes the cycle fix below:core/bootstrap.tsdescribes one composed launch, so the core module paths named by the rule may not import it back. (Through phase 3 this rule wascore-leaves-never-reimport-typesand guardedcore/types.ts; phase 4 melted that shell and repointed the rule at what replaced it.)
The following dated phases explain why the current rules exist. Paths and inventories describe the repository at that phase unless a later note updates them.
Phase 0 (2026-08-17) established the mechanism: it deleted core/review/address.ts (a
speculative primitive with no consumers), added the two rules above, and froze the first
interior — core/review/reducer.ts is importable only from within core/review/, because
callers state intent and planReviewIntent owns the transition. Later phases extend the same
pattern across packages/hunk/src/core as its subdirectories take shape; the review model's named modules
(document, identity, geometry, state, …) stay public by design.
Phase 1 (2026-08-17) grouped the changeset model and its acquisition pipeline — twelve loose
files at core/* root — into core/changeset/, with the surface split enforced by
changeset-internals-stay-in-module:
- Public:
model(theChangeset/DiffFile/SidecarContextshapes),loaders(every input source, plus the app bootstrap built around one),diffFile,fileSource,fileLanguage,binary,diffPaths,hunkHeader,hunkSummary. - Interior:
fromPatchturns patch text into the model and is reached throughloaders;fileLanguageLookupis the only reader/writer of Pierre's process-global extension table and the import that drags in the diff engine;sidecarreads--agent-contextas one step of acquiring a changeset.
The renames are path-only — changeset.ts → changeset/model.ts, changesetLoaders.ts →
changeset/loaders.ts, changesetFromPatch.ts → changeset/fromPatch.ts, the rest keep their
basenames — and no exported symbol changed. core/types.ts still re-exports the changeset and
sidecar shapes for legacy import sites (since phase 4: those sites name changeset/model
directly); it names changeset/model, which is public, so the interior rule needs no
exception. core/patch/ stayed where it is: it was already coherent.
Phase 2 (2026-08-17) grouped how a run is asked for into core/run/: the command
inputs (commandInputs), the layered config resolver (config), the app command catalog
(commandCatalog), the invocation errors (errors — "a failure Hunk raises because of how it
was invoked"), launch-scoped experimental features (experimental), XDG/app paths (paths),
tab-width validation (tabWidth), reload eligibility (inputReload), and the CLI version
(version). The move is path-only; no exported symbol changed.
Every one of the nine is public: each has production importers outside the module, so this
phase adds no run-internals-stay-in-module rule. Its value is the grouping plus
extending the freeze: core-leaves-never-reimport-types now names
core/run/commandInputs.ts in place of the old root path. Only commandInputs is a
types-leaf — config, experimental, and inputReload import core/types legally, since
core/types re-exports from commandInputs and never the other way round. (Since phase 4
there is no shell to import: those three name commandInputs directly, and config declares
the config-owned shapes itself.)
commandInputs also absorbed the CLI-input half that was still declared in core/types.ts:
HelpCommandInput, PagerCommandInput, DaemonServeCommandInput, the whole
Session*CommandInput family with SessionSelectorInput / SessionCommandOutput /
SessionCommentApplyItemInput, MarkupRenderCommandInput, MarkupGuideCommandInput, the
Extension*CommandInput family, and ParsedCliInput. Two small aliases came with them because
those inputs name them and the leaf may not import core/types back:
SessionCommentListType and the ReviewNoteSource it unions over. core/types.ts re-exports
all of them, so no import site changed at the time; phase 4 moved the import sites onto
commandInputs and deleted the re-exports.
Phase 3 (2026-08-17) grouped the process and terminal a run lives in into core/process/:
TTY capability detection and runtime CLI-input resolution (terminal), the external pager and
its plain-text fallback (pager), SIGTSTP/SIGINT job control (jobControl), ordered session
teardown (shutdown), .hunk/VCS project-root discovery (projectRoot), the atomically
written app-state file (appStateFile), the version-check notice built on it (updateNotice),
and the startup-notice shape every tier reports through (startupNotice). The move is
path-only; no exported symbol changed, and core/types.ts now re-exports StartupNotice from
process/startupNotice (a re-export nothing ever imported, deleted unused in phase 4).
All eight are public — each has production importers outside the module — so this phase adds
no process-internals-stay-in-module rule; the grouping is its value. The audiences are worth
naming, because they are why these files never belonged at core/* root together with the review
model: terminal, jobControl, shutdown, and updateNotice serve the interactive surface;
pager serves the CLI entry and the startup plan; projectRoot and appStateFile serve the
extension host and config resolution.
After this phase packages/hunk/src/core/ root holds only types.ts, reviewDigest.ts, and liveComments.ts
beside the seven subdirectories. Phase 4 melts what is left of core/types.ts.
Phase 4 (2026-08-17) melted that shell. core/types.ts had stopped declaring most of what it
exported: 147 files imported it, almost all for names phases 1–3 had already moved elsewhere,
and the re-export list was the only thing holding those import sites to a module that no longer
owned the answer. A grab-bag that re-exports is still a grab-bag — every importer binds to it,
so nothing downstream reveals which module it actually depends on.
Every import site was retargeted at the declaring module (mixed statements split one target per
module), the re-exports were deleted, and the file was renamed core/types.ts →
core/bootstrap.ts for what is genuinely left: AppBootstrap and ReloadContext, the contract
a composed launch hands the interactive shell. The stragglers it still declared went to the
module that owns their behaviour, one home each:
TerminalThemeMode→core/theme/detection.ts, which probes the terminal for it and had been re-exporting the name from the shell.ExtensionsConfig,UserKeyBinding,PersistedViewPreferences→core/run/config.ts, which resolves[extensions],[keybindings], and the persisted view options.UserNoteLineTarget→core/liveComments.ts, besideDiffSideandCommentTargetInput: it is the line a user note hangs on, and every consumer reaches it through note code.
Deleting the re-exports made one hidden dependency visible: core/review/annotations.ts names
AgentAnnotation, which is declared in packages/hunk/src/extension-api/types.ts because it is
simultaneously an internal model type and part of the published contract. Routing that through
core/types.ts had disguised it as a core-local import, and scripts/quality/source-boundaries.test.ts
("keeps the review model contained in core") caught it the moment the disguise came off. The
allowance is now explicit and narrow — that one file, not the tree — and it cannot widen the
seam, since extension-api-is-import-free forbids extension-api/types.ts any import at all.
Fan-in tells the story: 147 importing files became 28 (13 outside tests) — the review stream,
the diff renderer, and the session surfaces never needed the bootstrap contract, only the
changeset and command-input models they now name. core/bootstrap.ts imports downward into
changeset/model, run/commandInputs, run/config, process/startupNotice,
theme/detection, and vcs/types, and core-leaves-stay-below-bootstrap forbids the reverse
edge from every module directory. One exception is carved out and named in the rule:
core/changeset/loaders.ts returns an AppBootstrap from loadAppBootstrap, so it names the
shape it assembles; that function is composition living in the domain tier, and moving it to
packages/hunk/src/app retires the exception.
packages/hunk/src/core/ root now holds bootstrap.ts, reviewDigest.ts, and liveComments.ts beside the
eight module directories.
Phase 5 (2026-08-18) grouped how this binary was installed and how it gets replaced into
core/install/: install-source detection (installSource — which channel owns the executable),
per-channel release lookup (latestRelease), and the hunk update execution (selfUpdate).
These arrived with the self-update feature as core/process/ files because the startup update
notice lived there, but they are one feature family about the install lifecycle, not about the
process a run lives in. process/updateNotice stays where phase 3 put it — it is a
startup-notice producer built on appStateFile — and consumes core/install for detection and
release lookup. The move is path-only; no exported symbol changed.
This snapshot records the migration baseline; it is not a count of current main. At that point the graph contained 331 production modules, 1322 internal edges, zero boundary violations and zero import cycles — the baseline is empty. (The edge count grew from 1282 in phase 4: import sites that used to funnel through one re-export shell now name the modules they actually depend on, so the same dependencies are finally visible in the graph.) The initial audit (2026-08-16) found 28 violations in five clusters and 5 file-level cycles; all were repaid in the same change series that introduced the rules:
- Cycles. Each cycle was a type-only back-edge from a lower module into a grab-bag above
it. The cuts:
core/types.tsgave its changeset model tocore/changeset.ts(since phase 1,core/changeset/model.ts) and its command-input model tocore/commandInputs.ts(since phase 2,core/run/commandInputs.ts; re-exported fromcore/typesso import sites kept working, until phase 4 melted that shell intocore/bootstrap.tsand moved the sites onto the declaring modules); the diff row model moved toui/diff/diffRowModel.ts; the worker's compact encoder was retyped structurally (HighlightedHastLines);HunkSessionBrokerClientmoved beside the client class it aliases;CopySelectedRowRangemoved intoui/lib/diffSpatial.ts;extensions/notifications.tsnow importsExtensionNotifyTypefrom its declaring module. packages/hunk/src/core/cli.ts→packages/hunk/src/app/cli.ts. CLI parsing that registers every tier's command surface (includinghunk session *fromsession/agent/surface.ts) is composition, not domain — moving it made the core→session edges legal app→session edges.packages/hunk/src/session/app/→packages/hunk/src/app/session/. The mounted-review registration, bridge, and reload-authorization modules compose the app process with the session broker, and nothing insidepackages/hunk/src/sessionimported them — they were app-tier code homed on the wrong side. Moving the directory removed every session→app edge at once.packages/hunk/src/lib/reviewDigest.ts→packages/hunk/src/core/reviewDigest.ts. The Node digest implementation is review-semantic and platform-bound; core root (Node-full, outside the platform-freecore/review/seam) is its tier.ui/lib/reviewState.tsresolves session-daemon navigation for the adapter hooks and is now a named entry in the adapter allowlist rather than an accidental reach-in.- The bundled sidebar's
packages/hunk/src/uiimports are documented design, not debt. Its module header defines the dogfooding boundary as the published props contract (data, actions, theme); rendering helpers are host code. The rules now encode exactly that:packages/hunk/src/extensions/default/ui/may consumepackages/hunk/src/ui, and still may never touchpackages/hunk/src/app/packages/hunk/src/session.
The tier rules now hold with no exceptions. Two follow-ups are worth doing next:
- Give
packages/hunk/src/corean interior. Done (phases 0–4, see Module interiors). Every group is a module directory —review/,vcs/,theme/,watch/,patch/,changeset/,history/,install/,run/,process/— andcore/*root containsbootstrap.ts,reviewDigest.ts,reviewDescriptor.ts, andliveComments.ts, with no grab-bag re-export shell. What remains is per-file public surfaces for the modules that never got one:changesethaschangeset-internals-stay-in-moduleandreviewhasreview-reducer-is-module-internal, whilerun,process,theme,vcs,watch, andpatchare still public in full because every file in them has an outside importer today. Two named follow-ups: moveloadAppBootstrapout ofcore/changeset/loaders.tsintopackages/hunk/src/app(it is composition, and it is the one exceptioncore-leaves-stay-below-bootstraphas to carve out), and splitcore/run/config.ts, whose readers reach it for three unrelated reasons — the resolvedHunkConfigResolution, the persisted view preferences, and the extension/keybinding tables. The review seam's named modules (document,geometry,state, …) stay public; their helpers become internal. - Tighten the adapter allowlist.
ui-couples-to-session-via-adaptersnames its current files inUI_SESSION_ADAPTERSinside.dependency-cruiser.cjs. As session coupling consolidates intouseTerminalReview/useHunkSessionBridge, shrink that list rather than copying a count here.