Skip to content
Draft
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
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
7 changes: 5 additions & 2 deletions .changeset/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,9 @@ Select `hunkdiff` and choose the semver bump that matches the shipped CLI/packag
- `minor` for new user-facing features
- `major` for breaking changes

`package.json` intentionally lists `"."` in `workspaces` so Changesets can discover the root `hunkdiff` package. Keep that entry unless Hunk moves the publishable package out of the repository root.
The private root workspace lists `packages/*`, and Changesets discovers the sole published
`hunkdiff` package at `packages/hunk/package.json`. Private implementation workspaces remain
ignored and are never published.

For maintenance-only PRs that should not appear in release notes, create an empty changeset:

Expand All @@ -28,6 +30,7 @@ Release prep runs:
bun run release:version
```

That consumes the pending `.changeset/*.md` files, updates `CHANGELOG.md`, and bumps package versions for the release commit.
That consumes the pending `.changeset/*.md` files, updates `CHANGELOG.md`, and bumps
`packages/hunk/package.json` for the release commit.

After the tag release publishes npm packages and GitHub release assets, verify Homebrew through `Homebrew/homebrew-core`. Hunk is on Homebrew's Autobump list, so do not open manual simple version-bump PRs. Wait for the automated `hunk <version>` PR, confirm it merges, then verify `brew install hunk` resolves to the released version. Use `brew bump-formula-pr hunk --version <version>` only if Homebrew maintainers ask for a manual bump or Autobump stalls unexpectedly.
7 changes: 6 additions & 1 deletion .changeset/config.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,11 @@
"@hunk/session-broker",
"@hunk/session-broker-bun",
"@hunk/session-broker-core",
"@hunk/session-broker-node"
"@hunk/session-broker-node",
"@hunk/term-video",
"@hunk/git",
"@hunk/jj",
"@hunk/sapling",
"@hunk/vcs"
]
}
5 changes: 5 additions & 0 deletions .changeset/curly-pandas-package.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"hunkdiff": minor
---

Move Hunk into a package-first Bun workspace, statically bundle the private Git, Jujutsu, and Sapling provider packages, and add stable package-level activation controls for managed extensions.
142 changes: 87 additions & 55 deletions .dependency-cruiser.cjs
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/**
* Enforces module boundaries on the production import graph (`src/` plus `packages/`).
* Enforces module boundaries on the production import graph (`packages/hunk/src/` plus `packages/`).
*
* Each rule names one boundary of the target architecture described in
* docs/module-boundaries.md. Pre-existing violations live in
Expand All @@ -8,27 +8,27 @@
* `bun run deps:check` fails on any violation not in the baseline.
*/

// UI files allowed to couple to src/app and src/session: the composition shell, the two
// UI files allowed to couple to packages/hunk/src/app and packages/hunk/src/session: the composition shell, the two
// named session adapter hooks, and the session-navigation resolution helper those hooks
// share. Everything else in src/ui stays presentation-only.
// share. Everything else in packages/hunk/src/ui stays presentation-only.
const UI_SESSION_ADAPTERS = [
"^src/ui/App\\.tsx$",
"^src/ui/AppHost\\.tsx$",
"^src/ui/runInteractiveApp\\.tsx$",
"^src/ui/hooks/useHunkSessionBridge\\.ts$",
"^src/ui/hooks/useTerminalReview\\.ts$",
"^src/ui/lib/reviewState\\.ts$",
"^packages/hunk/src/ui/App\\.tsx$",
"^packages/hunk/src/ui/AppHost\\.tsx$",
"^packages/hunk/src/ui/runInteractiveApp\\.tsx$",
"^packages/hunk/src/ui/hooks/useHunkSessionBridge\\.ts$",
"^packages/hunk/src/ui/hooks/useTerminalReview\\.ts$",
"^packages/hunk/src/ui/lib/reviewState\\.ts$",
];

// Every way the shipped product is entered: the CLI, the highlight worker thread, the two
// published facades, and the skill generator. A module under src/ that no entry reaches,
// published facades, and the skill generator. A module under packages/hunk/src/ that no entry reaches,
// directly or transitively, is not in the product.
const PRODUCTION_ENTRY_POINTS = [
"^src/main\\.tsx$",
"^src/highlightWorkerEntry\\.ts$",
"^src/opentui/index\\.ts$",
"^src/extension-api/index\\.ts$",
"^src/hunk-review/skillDocument\\.ts$",
"^packages/hunk/src/main\\.tsx$",
"^packages/hunk/src/highlightWorkerEntry\\.ts$",
"^packages/hunk/src/opentui/index\\.ts$",
"^packages/hunk/src/extension-api/index\\.ts$",
"^packages/hunk/src/hunk-review/skillDocument\\.ts$",
];

// Modules kept alive by tests alone. The cruise excludes tests, so these look unreachable
Expand All @@ -37,12 +37,12 @@ const PRODUCTION_ENTRY_POINTS = [
// without the coverage to justify it.
const TEST_ONLY_MODULES = [
// Note-height measurement exercised by the review-conformance corpus.
"^src/core/review/noteSize\\.ts$",
"^packages/hunk/src/core/review/noteSize\\.ts$",
// The floating agent-note popover and its measurement helper. Nothing renders them since
// notes moved into the diff flow as STML cards; their unit tests are the only consumers
// left, so they are quarantined here until that call is made rather than deleted blind.
"^src/ui/components/panes/AgentCard\\.tsx$",
"^src/ui/lib/agentPopover\\.ts$",
"^packages/hunk/src/ui/components/panes/AgentCard\\.tsx$",
"^packages/hunk/src/ui/lib/agentPopover\\.ts$",
];

module.exports = {
Expand All @@ -58,75 +58,78 @@ module.exports = {
{
name: "extension-api-is-import-free",
comment:
"src/extension-api is the published contract; declaration emission publishes whatever it reaches (scripts/check-pack.ts gates the pack, this gates the graph).",
"packages/hunk/src/extension-api is the published contract; declaration emission publishes whatever it reaches (scripts/check-pack.ts gates the pack, this gates the graph).",
severity: "error",
from: { path: "^src/extension-api/" },
to: { path: "^(src|packages)/", pathNot: "^src/extension-api/" },
from: { path: "^packages/hunk/src/extension-api/" },
to: { path: "^packages/", pathNot: "^packages/hunk/src/extension-api/" },
},
{
name: "lib-is-a-leaf",
comment:
"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 dependency-free helpers usable from any tier; it may reach the import-free extension API contract and nothing else.",
severity: "error",
from: { path: "^src/lib/" },
to: { path: "^(src|packages)/", pathNot: "^src/(lib|extension-api)/" },
from: { path: "^packages/hunk/src/lib/" },
to: { path: "^packages/", pathNot: "^packages/hunk/src/(lib|extension-api)/" },
},
{
name: "core-stays-domain",
comment:
"src/core is the domain model. It may use src/lib and the extension-api contract, but never the UI, app composition, session brokering, extension host, or opentui facade above it.",
"packages/hunk/src/core is the domain model. It may use packages/hunk/src/lib and the extension-api contract, but never the UI, app composition, session brokering, extension host, or opentui facade above it.",
severity: "error",
from: { path: "^src/core/" },
to: { path: "^src/(ui|app|session|extensions|opentui)/" },
from: { path: "^packages/hunk/src/core/" },
to: { path: "^packages/hunk/src/(ui|app|session|extensions|opentui)/" },
},
{
name: "extensions-host-stays-below-surfaces",
comment:
"The extension host and bundled extensions sit below the surfaces that load them. The bundled UI tier (src/extensions/default/ui/) is exempt from the src/ui half by documented design: its dogfooding boundary is the published props contract — data, actions, theme — while rendering helpers are host code (see the sidebar module header).",
"The extension host and bundled extensions sit below the surfaces that load them. The bundled UI tier (packages/hunk/src/extensions/default/ui/) is exempt from the packages/hunk/src/ui half by documented design: its dogfooding boundary is the published props contract — data, actions, theme — while rendering helpers are host code (see the sidebar module header).",
severity: "error",
from: { path: "^src/extensions/", pathNot: "^src/extensions/default/ui/" },
to: { path: "^src/(ui|app|session|opentui)/" },
from: {
path: "^packages/hunk/src/extensions/",
pathNot: "^packages/hunk/src/extensions/default/ui/",
},
to: { path: "^packages/hunk/src/(ui|app|session|opentui)/" },
},
{
name: "bundled-ui-extensions-render-only",
comment:
"The bundled UI tier may consume src/ui rendering helpers as host code, but composition and session brokering stay out of reach — a pane gets its data and actions through the published props.",
"The bundled UI tier may consume packages/hunk/src/ui rendering helpers as host code, but composition and session brokering stay out of reach — a pane gets its data and actions through the published props.",
severity: "error",
from: { path: "^src/extensions/default/ui/" },
to: { path: "^src/(app|session|opentui)/" },
from: { path: "^packages/hunk/src/extensions/default/ui/" },
to: { path: "^packages/hunk/src/(app|session|opentui)/" },
},
{
name: "session-stays-below-app-and-ui",
comment:
"src/session brokers transport and protocol. It consumes core and packages; the app tier registers into it, not the other way round.",
"packages/hunk/src/session brokers transport and protocol. It consumes core and packages; the app tier registers into it, not the other way round.",
severity: "error",
from: { path: "^src/session/" },
to: { path: "^src/(ui|app|extensions|opentui)/" },
from: { path: "^packages/hunk/src/session/" },
to: { path: "^packages/hunk/src/(ui|app|extensions|opentui)/" },
},
{
name: "app-composes-without-ui",
comment:
"src/app wires core, extensions, and session together for startup; rendering stays in src/ui, which imports app — never the reverse.",
"packages/hunk/src/app wires core, extensions, and session together for startup; rendering stays in packages/hunk/src/ui, which imports app — never the reverse.",
severity: "error",
from: { path: "^src/app/" },
to: { path: "^src/(ui|opentui)/" },
from: { path: "^packages/hunk/src/app/" },
to: { path: "^packages/hunk/src/(ui|opentui)/" },
},
{
name: "ui-couples-to-session-via-adapters",
comment:
"Only the composition shell and the named session adapter hooks may import src/app or src/session; ordinary UI components and helpers stay presentation-only so the review surface can move to other hosts.",
"Only the composition shell and the named session adapter hooks may import packages/hunk/src/app or packages/hunk/src/session; ordinary UI components and helpers stay presentation-only so the review surface can move to other hosts.",
severity: "error",
from: { path: "^src/ui/", pathNot: UI_SESSION_ADAPTERS },
to: { path: "^src/(app|session)/" },
from: { path: "^packages/hunk/src/ui/", pathNot: UI_SESSION_ADAPTERS },
to: { path: "^packages/hunk/src/(app|session)/" },
},
{
name: "no-dead-modules",
comment:
"Every module under src/ earns its place by being reachable from an entry point. Dead files are worse than clutter: they still import, so they hold boundaries hostage and answer questions nobody asks. `orphan` only catches fully disconnected files, which misses dead code that still has dependencies — reachability catches both. A flagged module is either deleted or, if tests are its only real consumer, listed in TEST_ONLY_MODULES with a reason.",
"Every module under packages/hunk/src/ earns its place by being reachable from an entry point. Dead files are worse than clutter: they still import, so they hold boundaries hostage and answer questions nobody asks. `orphan` only catches fully disconnected files, which misses dead code that still has dependencies — reachability catches both. A flagged module is either deleted or, if tests are its only real consumer, listed in TEST_ONLY_MODULES with a reason.",
severity: "error",
from: { path: PRODUCTION_ENTRY_POINTS },
to: {
path: "^src/",
path: "^packages/hunk/src/",
pathNot: [...PRODUCTION_ENTRY_POINTS, ...TEST_ONLY_MODULES],
reachable: false,
},
Expand All @@ -137,36 +140,65 @@ module.exports = {
"core/bootstrap.ts composes the leaves: it names the changeset, the parsed input, the resolved preferences, and the detected theme mode to describe one launch. A module directory importing it back would invert that layering and rebuild the grab-bag cycle the 2026-08 phases dismantled. core/changeset/loaders.ts is the single exception — loadAppBootstrap assembles the value, so it names the shape it returns; its natural home is the app tier, and moving it there retires this exception.",
severity: "error",
from: {
path: "^src/core/(changeset|run|process|install|review|vcs|watch|patch|theme)/",
pathNot: "^src/core/changeset/loaders\\.ts$",
path: "^packages/hunk/src/core/(changeset|run|process|install|review|vcs|watch|patch|theme)/",
pathNot: "^packages/hunk/src/core/changeset/loaders\\.ts$",
},
to: { path: "^src/core/bootstrap\\.ts$" },
to: { path: "^packages/hunk/src/core/bootstrap\\.ts$" },
},
{
name: "review-reducer-is-module-internal",
comment:
"The review reducer applies actions; callers state intent instead, so surfaces cannot reach past planReviewIntent into the transition table. First of the per-module interior rules — this establishes the mechanism later phases extend to the rest of src/core (identity.ts and the other named model modules stay public by design).",
"The review reducer applies actions; callers state intent instead, so surfaces cannot reach past planReviewIntent into the transition table. First of the per-module interior rules — this establishes the mechanism later phases extend to the rest of packages/hunk/src/core (identity.ts and the other named model modules stay public by design).",
severity: "error",
from: { path: "^src/", pathNot: "^src/core/review/" },
to: { path: "^src/core/review/reducer\\.ts$" },
from: { path: "^packages/hunk/src/", pathNot: "^packages/hunk/src/core/review/" },
to: { path: "^packages/hunk/src/core/review/reducer\\.ts$" },
},
{
name: "changeset-internals-stay-in-module",
comment:
"core/changeset owns the changeset model and the pipeline that acquires one. Outsiders name the model, the loaders, and the per-file helpers they build on (model, loaders, diffFile, fileSource, fileLanguage, binary, diffPaths, hunkHeader, hunkSummary); the patch-to-model parse, the Pierre extension-table lookup, and the sidecar reader are steps inside that pipeline, reached through the loaders instead.",
severity: "error",
from: { path: "^src/", pathNot: "^src/core/changeset/" },
from: { path: "^packages/hunk/src/", pathNot: "^packages/hunk/src/core/changeset/" },
to: {
path: "^src/core/changeset/(fromPatch|fileLanguageLookup|sidecar)\\.ts$",
path: "^packages/hunk/src/core/changeset/(fromPatch|fileLanguageLookup|sidecar)\\.ts$",
},
},
{
name: "packages-stay-standalone",
comment:
"Workspace packages are standalone publishable units; they never import the app source tree.",
"Private workspaces never import application internals; providers use only hunkdiff/extension.",
severity: "error",
from: { path: "^packages/" },
to: { path: "^src/" },
from: { path: "^packages/(?!hunk/)" },
to: { path: "^packages/hunk/src/", pathNot: "^packages/hunk/src/extension-api/" },
},
{
name: "vcs-domain-is-a-leaf",
comment:
"Provider-neutral VCS infrastructure cannot depend on the app or provider implementations.",
severity: "error",
from: { path: "^packages/hunk-vcs/" },
to: {
path: "^packages/(hunk/src|hunk-git|hunk-jj|hunk-sapling)/",
pathNot: "^packages/hunk/src/extension-api/",
},
},
{
name: "providers-use-public-api-only",
comment:
"Bundled providers depend on hunkdiff/extension and shared utilities, never private app modules.",
severity: "error",
from: { path: "^packages/hunk-(git|jj|sapling)/" },
to: { path: "^packages/hunk/src/", pathNot: "^packages/hunk/src/extension-api/" },
},
{
name: "only-bundled-composition-imports-providers",
comment: "The app activates private provider packages at one static composition seam.",
severity: "error",
from: {
path: "^packages/hunk/src/",
pathNot: "^packages/hunk/src/extensions/bundledPackages\\.ts$",
},
to: { path: "^packages/hunk-(git|jj|sapling)/" },
},
],
options: {
Expand Down
3 changes: 2 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -150,7 +150,8 @@ jobs:
pkg_dir="$(mktemp -d)"
install_dir="$(mktemp -d)"
node_dir="$(dirname "$(command -v node)")"
npm pack --pack-destination "$pkg_dir" >/dev/null
bun run stage:source-npm
npm pack ./dist/source-npm/hunkdiff --ignore-scripts --pack-destination "$pkg_dir" >/dev/null
pkg="$(find "$pkg_dir" -maxdepth 1 -name 'hunkdiff-*.tgz' | head -n1)"
npm install -g --prefix "$install_dir" "$pkg"
PATH="$install_dir/bin:$node_dir:/usr/bin:/bin"
Expand Down
3 changes: 2 additions & 1 deletion .github/workflows/pr-ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -212,7 +212,8 @@ jobs:
pkg_dir="$(mktemp -d)"
install_dir="$(mktemp -d)"
node_dir="$(dirname "$(command -v node)")"
npm pack --pack-destination "$pkg_dir" >/dev/null
bun run stage:source-npm
npm pack ./dist/source-npm/hunkdiff --ignore-scripts --pack-destination "$pkg_dir" >/dev/null
pkg="$(find "$pkg_dir" -maxdepth 1 -name 'hunkdiff-*.tgz' | head -n1)"
npm install -g --prefix "$install_dir" "$pkg"
PATH="$install_dir/bin:$node_dir:/usr/bin:/bin"
Expand Down
Loading