@ultimat3/cli 6.0.0 → 8.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (91) hide show
  1. package/CLAUDE.md +65 -5
  2. package/README.md +8 -3
  3. package/package.json +25 -24
  4. package/src/affected.ts +320 -0
  5. package/src/app-boundaries.ts +55 -5
  6. package/src/bin.ts +6 -3
  7. package/src/browser-launcher.ts +109 -0
  8. package/src/ci-log.ts +0 -0
  9. package/src/ci-runs.ts +179 -0
  10. package/src/cmd-affected.ts +109 -0
  11. package/src/cmd-build.ts +29 -3
  12. package/src/cmd-ci.ts +273 -0
  13. package/src/cmd-db-backfill.ts +240 -0
  14. package/src/cmd-db-branch.ts +3 -2
  15. package/src/cmd-db.ts +35 -156
  16. package/src/cmd-deploy.ts +37 -3
  17. package/src/cmd-dev.ts +7 -1
  18. package/src/cmd-errors.ts +2 -3
  19. package/src/cmd-fix.ts +3 -3
  20. package/src/cmd-i18n.ts +67 -5
  21. package/src/cmd-jobs.ts +27 -4
  22. package/src/cmd-mcp.ts +18 -9
  23. package/src/cmd-new.ts +91 -4
  24. package/src/cmd-policy.ts +3 -2
  25. package/src/cmd-pr.ts +359 -0
  26. package/src/cmd-registries.ts +3 -2
  27. package/src/cmd-shot.ts +382 -0
  28. package/src/cmd-tasks.ts +9 -4
  29. package/src/cmd-test.ts +96 -7
  30. package/src/cmd-verify.ts +47 -6
  31. package/src/dev-cache.ts +1 -1
  32. package/src/dev-lock.ts +124 -12
  33. package/src/dev-queue.ts +12 -7
  34. package/src/dev-replicator.ts +3 -7
  35. package/src/dev-roles-fixture.ts +1 -1
  36. package/src/dev-roles.ts +40 -8
  37. package/src/dev-runtime.ts +96 -4
  38. package/src/dev-sync.ts +9 -4
  39. package/src/dispatch.ts +35 -5
  40. package/src/drift.ts +52 -7
  41. package/src/error-codes.ts +21 -0
  42. package/src/framework-scope.ts +57 -5
  43. package/src/generate-kinds.ts +19 -1
  44. package/src/gh-target.ts +118 -0
  45. package/src/gh.ts +204 -0
  46. package/src/i18n-registration.ts +67 -4
  47. package/src/index.ts +38 -1
  48. package/src/island-bundle.ts +62 -3
  49. package/src/island-solid-production.ts +129 -0
  50. package/src/island-styles.ts +41 -0
  51. package/src/jobs-report.ts +10 -13
  52. package/src/mcp-errors.ts +12 -0
  53. package/src/messages.ts +76 -0
  54. package/src/output.ts +22 -2
  55. package/src/parse.ts +81 -37
  56. package/src/pr-threads.ts +291 -0
  57. package/src/prerender.ts +52 -10
  58. package/src/realtime-browser-probe-fixture.ts +9 -0
  59. package/src/registry.ts +8 -0
  60. package/src/runtime-overrides.ts +11 -3
  61. package/src/shot-settle.ts +57 -0
  62. package/src/shot-verdict.ts +360 -0
  63. package/src/static-report.ts +219 -0
  64. package/src/sync-authenticator.ts +86 -14
  65. package/src/templates/guard-bare-error.ts +122 -0
  66. package/src/templates/guard-raw-colour.ts +138 -0
  67. package/src/templates/guard-untranslated-string.ts +138 -0
  68. package/src/templates/guard-unzoned-date.ts +142 -0
  69. package/src/templates/index.ts +4 -0
  70. package/src/templates/island-fixture.ts +76 -0
  71. package/src/templates/island.ts +130 -18
  72. package/src/templates/resource-form-island.ts +279 -0
  73. package/src/templates/resource.ts +20 -41
  74. package/src/templates/route.ts +15 -2
  75. package/src/templates/scaffold-app.ts +13 -78
  76. package/src/templates/scaffold-container.ts +30 -4
  77. package/src/templates/scaffold-db-package.ts +46 -7
  78. package/src/templates/scaffold-docs.ts +24 -13
  79. package/src/templates/scaffold-entries.ts +131 -0
  80. package/src/templates/scaffold-guards.ts +26 -0
  81. package/src/templates/scaffold-mcp-package.ts +35 -2
  82. package/src/templates/scaffold-package-shape.ts +7 -2
  83. package/src/templates/scaffold-repo.ts +37 -6
  84. package/src/test-select.ts +4 -3
  85. package/src/test-shards.ts +19 -3
  86. package/src/verify-checks.ts +11 -1
  87. package/src/verify-run.ts +25 -3
  88. package/src/verify-step.ts +11 -2
  89. package/src/verify-tests.ts +11 -3
  90. package/src/workspace-graph.ts +241 -0
  91. package/src/write-line.ts +23 -5
package/CLAUDE.md CHANGED
@@ -6,10 +6,12 @@ Tier 5. May import tiers 0–4. Nothing imports this except `create-ultimate`.
6
6
  |---|---|
7
7
  | Entry | `src/bin.ts` (`#!/usr/bin/env bun`) — argv, stdout, exit code only |
8
8
  | stdout | `write-line.ts`'s `writeLine` — synchronous fd 1, never `process.stdout.write`, which truncates at the 64KB pipe buffer when `process.exit` follows. Exported, because `create-ultimate`'s entry point needs the same one |
9
+ | stderr | `write-line.ts`'s `writeErrorLine` — the same loop on fd 2, for a line that is not the command's answer. A `CommandResult` declaring `stream: 'stderr'` is routed there by `dispatch.ts`'s `sinkFor`, and `x mcp serve --transport stdio` is the one case: its fd 1 carries JSON-RPC frames, so the `✓ mcp stdio serving 13 tools` line rendered after the loop was a malformed frame. Neither renderer carries `stream`, exactly like `hold` |
10
+ | Boot logs under `--json` | `dispatch.ts` calls core's `setLogStream('stderr')` when `args.json` is set, once, for all thirty commands. `x db migrate --json` printed the boot logger's `ultimate migrate applied` and then the command's own object, so `json.load` raised on the second document. A server's stdout stays its log stream; this is the CLI process only |
9
11
  | Numeric flags | `flag-number.ts` — one reader for `--port` / `--workers` / `--shard`. A bare `Number.parseInt` accepts `4abc` and answers `NaN`, which turned three checks into ones that cannot fail |
10
12
  | Shell quoting | `shell-quote.ts`'s `quoteArg` — every value the CLI pastes into a `fix:` or a reproduce line, `exec.ts`'s missing-program refusal and `test-shards.ts`'s reproduce command both. A name holding a space or a `;` interpolated bare is an instruction that runs something else |
11
13
  | Missing positionals | `MissingPositionalError`, never `BadFlagError` (names a flag that does not exist) and never `UnknownCommandError` (says a known command form is not one). Its `example` is a REAL invocation — `x g route <name>` in a shell is a redirect |
12
- | Bare subcommands | `CommandSpec.defaultSubcommand`, **declared**. The parser answered `subcommands[0]` until 1.2.0, so `x db` ran `gen` — the migration GENERATOR — because it sorted first, and `x mcp` started a server. A command with no defensible default declares none and `MissingSubcommandError` refuses the bare form; `parse.test.ts` pins the set at exactly `db` and `mcp`. Its fix is `x help <command>`, never `x <command> --help`: the subcommand is resolved *after* the flag loop, so the latter throws the same error again — a fix line that reproduced its own failure |
14
+ | Bare subcommands | `CommandSpec.defaultSubcommand`, **declared**. The parser answered `subcommands[0]` until 1.2.0, so `x db` ran `gen` — the migration GENERATOR — because it sorted first, and `x mcp` started a server. A command with no defensible default declares none and `MissingSubcommandError` refuses the bare form; `parse.test.ts` pins the set at exactly `db` and `mcp`. Its fix is `x help <command>`. Both forms answer now: `--help` is read off the flag loop and `readSubcommand` is SKIPPED when it is set, so `x db --help`, `x mcp --help` and `x pr --help` print usage instead of exiting 1 with this same refusal — which is what they did on every command taking a subcommand until 2026-08 (`parse.test.ts` pins it across the shipped registry) |
13
15
  | Closed flag values | a flag whose values are a closed set is READ through a function that refuses the rest — `cmd-build.ts`'s `readTarget`, `cmd-deploy.ts`'s `readMethod`, `cmd-routes.ts`'s `readSurfaceFilter`, `cmd-mcp.ts`'s `isTransport`. `=== 'helm' ? 'helm' : 'compose'` made `x deploy --method helmm` a COMPOSE deploy reporting `method: "compose"`, and `--surface App` reported `0 routes` and exit 0 — a typo and an empty table rendering identically. The set is the framework's own where one exists (`SURFACES` from `@ultimat3/render`), never a list restated here |
14
16
  | App root | `CommandSpec.requiresApp`, **enforced by `dispatch.ts`** before `target.run` — the field's doc said so for 17 commands and nothing read it, so the promise was kept only by each command remembering to call `requireAppRoot` itself. Those 17 calls stay (they hand the command its root, and name subcommands the dispatcher cannot see), but the DECLARATION is what decides, ahead of any check a command makes about its own arguments; `--help` is exempt, because `target` is the help command by then |
15
17
  | Result helpers | `command.ts`'s `ok()` / `failed()` write `ok` **after** the `extra` spread: the function's name is the verdict and nothing a caller passes can overturn it. Spread last, `failed('verify', '1 of 19 steps failed', { ok: true })` answered `ok: true` and `exitCodeFor` exited **0** on it — a green CI over a red command (`command.test.ts`) |
@@ -67,6 +69,17 @@ and the `tsc -b` that proves it took. Private packages are exempt (a generated a
67
69
  private), and a root that declares **no** `references` array is not judged at all: project
68
70
  references are opt-in, and a scaffolded app builds through `extends` + `include`.
69
71
 
72
+ `workspace-graph.ts` is `package-shape`'s fifth rule: **every cross-workspace import is declared
73
+ in the importing workspace's own manifest**. Without it a scaffolded repo's dependency graph exists
74
+ only inside `tsc` — imports resolve through the root `tsconfig.json` `paths`, so affected-package
75
+ detection, `bun --filter` ordering and "what breaks if I change this" all read manifests and all
76
+ answer too small a set (issue #239, found in a real app where a change reaching five packages
77
+ reported one). `X_WORKSPACE_DEP_UNDECLARED` names the manifest and the exact line to add. Shipped
78
+ source only: a test file's import is not judged, because `packages/*` here declares no
79
+ `devDependencies` by design and the root's hoist is what resolves them. A manifest the scan cannot
80
+ read is its own finding rather than a silent skip — a skipped workspace is a hiding place for the
81
+ very edge the rule is looking for.
82
+
70
83
  `app-agents-md.ts` is why the `manifest` step declares no `applies` at all. The drift half needs
71
84
  a committed `x.manifest.json` to compare against, but `AGENTS.md` is required of every repo the
72
85
  gate runs in — so the step always has a question to answer, and gating both halves on the file
@@ -139,6 +152,38 @@ emits a file `defineCatalogs` rejects at the app's first boot. `merge: 'json'` u
139
152
  (`json-merge.ts`) for the same reason: `x new` and `x g resource` both contribute under `app`, and
140
153
  a shallow spread keeps one of them.
141
154
 
155
+ ## Three commands that reach outside the process, and none of them is a gate step
156
+
157
+ `x shot`, `x pr` and `x ci` exist because of the one line in the root `CLAUDE.md` that shapes this
158
+ whole package: **the primary developer is an AI agent.** An agent cannot open a browser, cannot look
159
+ at a running dev server and cannot read the GitHub web UI. It can read a file, and it can run a
160
+ command that prints. These three turn each of those into a file and a print.
161
+
162
+ They are also the only three commands that need something the process does not have — a browser, a
163
+ network, a GitHub token — which is why **none of them is a step of `x verify`**, and why that is not
164
+ an oversight to be corrected later. A gate that needs a browser goes red for reasons unrelated to the
165
+ change, and CI does not install one.
166
+
167
+ | | Reaches for | Never |
168
+ |---|---|---|
169
+ | `x shot <route>` | `x dev` on a scratch port, plus the app's own `puppeteer-core` through `@ultimat3/scraping` | the static build — `--target static` prerenders `site/` only, so an `app/` route would photograph the landing page |
170
+ | `x pr review\|resolve\|reply` | `gh api graphql`, through the injected `Runner` | `gh pr view --comments`, which shows *issue* comments and not the line-anchored threads that carry the findings |
171
+ | `x ci` | `gh run view --log-failed`, one call | a per-job log fetch — the run and all its jobs come back together |
172
+
173
+ **`verdict.json` names its own blind spots, and that is the design.** `x shot` reports what it could
174
+ not observe alongside what it did. A capture tool that silently omits what it cannot see is worse
175
+ than one that says so, because the omission reads as a clean result.
176
+
177
+ **`x shot` reuses a running `x dev` rather than booting a second one.** Embedded Postgres is
178
+ single-writer, so a second boot is `X_DEV_ALREADY_RUNNING` and no picture is ever taken. A reused
179
+ server's `stop()` deliberately does not clear the other process's lock.
180
+
181
+ **`gh` is invoked through `ctx.runner`, never `Bun.spawn` directly** — that is what lets every test
182
+ supply a reply table and assert the exact argv with no network and no `gh` installed. `GhOptions.fix`
183
+ is a **required** field, so shelling out to GitHub without stating a remedy is a type error rather
184
+ than a review comment. A GraphQL response is untrusted input and is parsed against a schema, never
185
+ cast: a `null` where an id was expected would otherwise become a mutation against `undefined`.
186
+
142
187
  ## The `errors` step enforces the error contract
143
188
 
144
189
  | File | Job |
@@ -790,6 +835,18 @@ refuses). A guard returning `[1n]` is `X_GUARD_FINDING_INVALID`, per candidate,
790
835
  entry costs its own line and not the real findings beside it. The mechanism whose job is producing
791
836
  structured failures handing back a stack trace is the one outcome it exists to prevent.
792
837
 
838
+ **`x new` ships four guards, `As of 2026-08-22`.** The scaffolded `AGENTS.md` states nine
839
+ non-negotiables, and five of them used to be prose — each proven green on `x verify`: a hardcoded
840
+ JSX string beside a `t()` call, `color: #ff0000` in a stylesheet whose own scaffolded header called
841
+ it "a lint failure", `toLocaleDateString('en-US')` with no `timeZone`, `t.number` money, and a bare
842
+ `throw new Error` in a repo. Four of the five are now guards the scaffold writes —
843
+ `guard-raw-colour`, `guard-unzoned-date`, `guard-bare-error`, `guard-untranslated-string` — so the
844
+ rule is a build error the day the app is created rather than a sentence an agent may skip. The
845
+ fifth, money-as-float, has **no static signature**; the scaffolded `AGENTS.md` row now points at the
846
+ `MoneyInput` type error that already fires, because shipping a guard that cannot work is worse than
847
+ naming the mechanism that does. Their codes are app codes derived from the guard name, so none of
848
+ them appears in `wiki/Error-Codes.md` or the manifest.
849
+
793
850
  `x g guard <name>` writes `guards/<name>.ts` and its test, and nothing else — no index, no
794
851
  registry row, no manifest entry. The emitted rule is the class of failure a guard exists for: a
795
852
  migration that adds a `NOT NULL` column with no `DEFAULT` applies cleanly to an empty local
@@ -816,10 +873,13 @@ typechecked there by default; an app whose tsconfig names an explicit `include`
816
873
  ## Two generators that scaffold something other than a primitive
817
874
 
818
875
  `x g island <name> [--at <dir>]` writes a **client entry point**, not a component: the filename is
819
- how the bundler discovers it and `mount` is how the hydration runtime calls it, so those two are
820
- what `templates/island.test.ts` pins and everything else in the file is example code. `--at` takes
821
- the directory directly rather than deriving one, because the caller that cannot guess is
822
- `X_ISLAND_INVALID` — its cause already holds the exact path a page's `src` resolved to, so its
876
+ how the bundler discovers it and `mount` is how the hydration runtime calls it, so the filename,
877
+ the `mount` export and that `mount` RENDERS are what `templates/island.test.ts` pins — it builds
878
+ the emitted entry with `buildIslands` and drives it with `mountIsland`, so a template that
879
+ typechecks and does not mount is a failing test. It runs the mutation too, rather than describing
880
+ it: the same island with `{count()}` replaced by `{0}` must fail the assertion the live one passes.
881
+ `--at` takes the directory directly rather than deriving one, because the caller that cannot guess
882
+ is `X_ISLAND_INVALID` — its cause already holds the exact path a page's `src` resolved to, so its
823
883
  `fix:` hands that path straight back.
824
884
 
825
885
  `x g admin:page <name> --permission <perm> [--at <dir>]` writes an ordinary TSX component and **no
package/README.md CHANGED
@@ -53,13 +53,18 @@ x verify --json
53
53
  ## `x verify` steps
54
54
 
55
55
  `typecheck lint boundaries filesize package-shape errors unit contract live job e2e eval drift
56
- contract-diff budgets manifest roadmap`
56
+ contract-diff budgets seo i18n manifest roadmap`
57
57
 
58
- Seventeen, in cost order, defined once as `VERIFY_STEP_NAMES` (`verify-step.ts`) — the summary
58
+ Nineteen, in cost order, defined once as `VERIFY_STEP_NAMES` (`verify-step.ts`) — the summary
59
59
  count above is projected from that list, and the framework repo's own gate (`bun run verify`)
60
60
  runs exactly it. A step with nothing to check here reports as skipped, never as
61
61
  passed. Never bails early: an agent fixing three things needs all three findings from one run.
62
- There is no `--only` and no `--skip`; the exit code is non-zero if any step fails.
62
+
63
+ `--only <step>` runs one step, for an iteration loop — it prints `NOT A GATE RUN` in the human
64
+ summary **and** in `--json` (`data.notAGateRun`), and it writes no floor file. **The gate is this
65
+ command with no flag**, which is what "one command means shippable" means. There is no `--skip`:
66
+ a knob that removes a step from a run that still calls itself the gate is the one thing this
67
+ command must not offer. The exit code is non-zero if any step fails.
63
68
 
64
69
  A committed `x.verify.json` is the floor, `As of 2026-08`: it names the steps this repo has already
65
70
  proved it can run, and a step it names that reports nothing is `X_VERIFY_SUITE_VANISHED` rather
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/cli",
3
- "version": "6.0.0",
3
+ "version": "8.0.0",
4
4
  "description": "The `x` binary: new, dev, build, verify, generate, db, mcp, doctor, deploy",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -37,29 +37,30 @@
37
37
  },
38
38
  "dependencies": {
39
39
  "@babel/core": "^7.28.4",
40
- "@ultimat3/action": "6.0.0",
41
- "@ultimat3/admin": "6.0.0",
42
- "@ultimat3/ai": "6.0.0",
43
- "@ultimat3/cache": "6.0.0",
44
- "@ultimat3/core": "6.0.0",
45
- "@ultimat3/db": "6.0.0",
46
- "@ultimat3/entity": "6.0.0",
47
- "@ultimat3/http": "6.0.0",
48
- "@ultimat3/i18n": "6.0.0",
49
- "@ultimat3/jobs": "6.0.0",
50
- "@ultimat3/mail": "6.0.0",
51
- "@ultimat3/manifest": "6.0.0",
52
- "@ultimat3/mcp": "6.0.0",
53
- "@ultimat3/policy": "6.0.0",
54
- "@ultimat3/pwa": "6.0.0",
55
- "@ultimat3/query": "6.0.0",
56
- "@ultimat3/realtime": "6.0.0",
57
- "@ultimat3/render": "6.0.0",
58
- "@ultimat3/schema": "6.0.0",
59
- "@ultimat3/seo": "6.0.0",
60
- "@ultimat3/storage": "6.0.0",
61
- "@ultimat3/testing": "6.0.0",
62
- "@ultimat3/time": "6.0.0",
40
+ "@ultimat3/action": "8.0.0",
41
+ "@ultimat3/admin": "8.0.0",
42
+ "@ultimat3/ai": "8.0.0",
43
+ "@ultimat3/cache": "8.0.0",
44
+ "@ultimat3/core": "8.0.0",
45
+ "@ultimat3/db": "8.0.0",
46
+ "@ultimat3/entity": "8.0.0",
47
+ "@ultimat3/http": "8.0.0",
48
+ "@ultimat3/i18n": "8.0.0",
49
+ "@ultimat3/jobs": "8.0.0",
50
+ "@ultimat3/mail": "8.0.0",
51
+ "@ultimat3/manifest": "8.0.0",
52
+ "@ultimat3/mcp": "8.0.0",
53
+ "@ultimat3/policy": "8.0.0",
54
+ "@ultimat3/pwa": "8.0.0",
55
+ "@ultimat3/query": "8.0.0",
56
+ "@ultimat3/realtime": "8.0.0",
57
+ "@ultimat3/render": "8.0.0",
58
+ "@ultimat3/schema": "8.0.0",
59
+ "@ultimat3/scraping": "8.0.0",
60
+ "@ultimat3/seo": "8.0.0",
61
+ "@ultimat3/storage": "8.0.0",
62
+ "@ultimat3/testing": "8.0.0",
63
+ "@ultimat3/time": "8.0.0",
63
64
  "babel-preset-solid": "^1.9.15"
64
65
  }
65
66
  }
@@ -0,0 +1,320 @@
1
+ // What a diff touches: the changed-file list read out of git, and the workspaces that list forces
2
+ // a re-test of — closed TRANSITIVELY over the workspace graph, because A → B → C means an edit in
3
+ // C breaks A and a single pass over an unordered list only ever reaches B.
4
+ //
5
+ // The diff defaults to a REF, never the working tree. Several agents share one checkout here (root
6
+ // `CLAUDE.md`, the "Note": no worktrees, "run them as a team in this same checkout"), so a
7
+ // working-tree diff returns every other agent's uncommitted work and the "affected" set silently
8
+ // widens to nearly the whole monorepo — the command stops narrowing anything and nobody can tell.
9
+ // A ref diff is stable under that concurrency; `--dirty` opts back in, which is right for one
10
+ // developer iterating alone and wrong as a default here.
11
+
12
+ // `join`/`relative` are `node:`-only by necessity: Bun exposes no path primitive, and a workspace
13
+ // directory is checkout-relative while a caller's scan yields paths relative to its own root.
14
+ import { join, relative } from 'node:path';
15
+ import { singleLine, UltimateError } from '@ultimat3/core';
16
+ import { docsFor } from './error-codes';
17
+ import { BadFlagError } from './errors';
18
+ import type { ExecResult, Runner } from './exec';
19
+ import { execOutput } from './exec';
20
+ import type { JsonValue } from './output';
21
+ import type { ParsedArgs } from './parse';
22
+ import { flagBool, flagString } from './parse';
23
+ import type { WorkspaceNode } from './workspace-graph';
24
+ import { readWorkspaceGraph } from './workspace-graph';
25
+
26
+ /** The branch a change is measured against when `--base` says nothing. */
27
+ export const DEFAULT_BASE = 'main';
28
+
29
+ /**
30
+ * Root files that belong to no workspace and change what every workspace compiles to: a compiler
31
+ * option, a lint rule, the root manifest, the resolved dependency tree, the test preload, the app
32
+ * config. A "scoped" run that skipped them reports green over packages the edit just broke.
33
+ *
34
+ * Matched on the WHOLE path — `packages/cli/package.json` is the cli workspace's own file and
35
+ * reaches only cli's dependents, `package.json` at the root reaches everything.
36
+ */
37
+ export const ROOT_WIDE_FILES: readonly string[] = [
38
+ 'app.config.ts',
39
+ 'biome.json',
40
+ 'bun.lock',
41
+ 'bunfig.toml',
42
+ 'package.json',
43
+ 'tsconfig.json',
44
+ ];
45
+
46
+ /**
47
+ * A file with no compilation unit behind it. A doc or a plan re-checks nothing, so it maps to no
48
+ * workspace at all rather than to the one it happens to sit inside — `packages/cli/README.md` is
49
+ * not a reason to run `packages/cli`'s tests.
50
+ */
51
+ const isDoc = (path: string): boolean => path.endsWith('.md');
52
+
53
+ const owns = (node: WorkspaceNode, path: string): boolean =>
54
+ path === node.dir || path.startsWith(`${node.dir}/`);
55
+
56
+ /**
57
+ * The workspace a path belongs to, longest directory first: nested workspaces exist (a scaffolded
58
+ * app's `apps/web` inside its own root), and the shorter prefix would swallow the inner one.
59
+ */
60
+ export function owningWorkspace(
61
+ graph: readonly WorkspaceNode[],
62
+ path: string,
63
+ ): WorkspaceNode | undefined {
64
+ let best: WorkspaceNode | undefined;
65
+ for (const node of graph) {
66
+ if (!owns(node, path)) continue;
67
+ if (best === undefined || node.dir.length > best.dir.length) best = node;
68
+ }
69
+ return best;
70
+ }
71
+
72
+ /**
73
+ * Every workspace that depends on one of `seeds`, however many edges away.
74
+ *
75
+ * A queue with a growing cursor, not one pass over `seeds`: with `A → B → C`, one pass answers
76
+ * `{ C, B }` and leaves A untested while the change that broke it is in the diff — a green
77
+ * checkmark on a broken repo, which is the whole reason this command exists rather than each agent
78
+ * inventing its own scoping. `reached` doubles as the cycle guard.
79
+ */
80
+ function withDependents(
81
+ graph: readonly WorkspaceNode[],
82
+ seeds: ReadonlySet<string>,
83
+ ): ReadonlySet<string> {
84
+ const dependents = new Map<string, string[]>();
85
+ for (const node of graph) {
86
+ for (const dependency of node.dependencies) {
87
+ const known = dependents.get(dependency);
88
+ if (known === undefined) dependents.set(dependency, [node.name]);
89
+ else known.push(node.name);
90
+ }
91
+ }
92
+ const reached = new Set(seeds);
93
+ const queue = [...reached];
94
+ for (let cursor = 0; cursor < queue.length; cursor += 1) {
95
+ const name = queue[cursor];
96
+ if (name === undefined) continue;
97
+ for (const dependent of dependents.get(name) ?? []) {
98
+ if (reached.has(dependent)) continue;
99
+ reached.add(dependent);
100
+ queue.push(dependent);
101
+ }
102
+ }
103
+ return reached;
104
+ }
105
+
106
+ export interface AffectedPlan {
107
+ /** Every path the diff reported, verbatim and in git's order. */
108
+ readonly changed: readonly string[];
109
+ /** The subset with no compilation unit behind it, named so an empty answer explains itself. */
110
+ readonly ignored: readonly string[];
111
+ /** The root files that forced every workspace in, empty when none did. */
112
+ readonly rootWide: readonly string[];
113
+ readonly workspaces: readonly WorkspaceNode[];
114
+ }
115
+
116
+ const byName = (a: WorkspaceNode, b: WorkspaceNode): number => (a.name > b.name ? 1 : -1);
117
+
118
+ /**
119
+ * Pure: the graph and the file list in, the plan out. No git, no disk — so the transitive rule is
120
+ * testable without a checkout whose state would decide the verdict.
121
+ */
122
+ export function planAffected(
123
+ graph: readonly WorkspaceNode[],
124
+ changed: readonly string[],
125
+ ): AffectedPlan {
126
+ const ignored = changed.filter(isDoc);
127
+ const considered = changed.filter((path) => !isDoc(path));
128
+ const rootWide = considered.filter((path) => ROOT_WIDE_FILES.includes(path));
129
+ if (rootWide.length > 0) {
130
+ return { changed, ignored, rootWide, workspaces: [...graph].sort(byName) };
131
+ }
132
+ const seeds = new Set<string>();
133
+ for (const path of considered) {
134
+ const node = owningWorkspace(graph, path);
135
+ if (node !== undefined) seeds.add(node.name);
136
+ }
137
+ const reached = withDependents(graph, seeds);
138
+ return {
139
+ changed,
140
+ ignored,
141
+ rootWide,
142
+ workspaces: graph.filter((node) => reached.has(node.name)).sort(byName),
143
+ };
144
+ }
145
+
146
+ /**
147
+ * How the calling command spells this scoping, so a `fix:` re-runs the invocation that actually
148
+ * failed. `x test --base main` is refused by `x test` itself — without `--affected` the flag
149
+ * narrows nothing — so a fix line that dropped it would reproduce its own failure, verbatim.
150
+ */
151
+ const invocationOf = (command: string): string =>
152
+ command === 'affected' ? 'x affected' : `x ${command} --affected`;
153
+
154
+ export interface AffectedSelection {
155
+ readonly base: string;
156
+ readonly dirty: boolean;
157
+ }
158
+
159
+ /**
160
+ * One reader for `--base` and `--dirty`, shared by `x affected` and `x test --affected`: two
161
+ * readers would be two answers to "what is this diff measured against", and the second command's
162
+ * scoping is only trustworthy if it is the first command's.
163
+ */
164
+ export function readAffectedSelection(args: ParsedArgs, command: string): AffectedSelection {
165
+ const base = flagString(args, 'base') ?? DEFAULT_BASE;
166
+ if (base.trim().length === 0) {
167
+ throw new BadFlagError({
168
+ flag: 'base',
169
+ command,
170
+ reason: 'needs a git ref and got an empty value',
171
+ fix: `${invocationOf(command)} --base ${DEFAULT_BASE} --json`,
172
+ });
173
+ }
174
+ return { base, dirty: flagBool(args, 'dirty') };
175
+ }
176
+
177
+ const git = (runner: Runner, cwd: string, args: readonly string[]): Promise<ExecResult> =>
178
+ runner(['git', ...args], { cwd });
179
+
180
+ /** NUL-delimited, so a path holding a space, a quote or a newline survives the read intact. */
181
+ const paths = (stdout: string): readonly string[] =>
182
+ stdout.split('\0').filter((path) => path.length > 0);
183
+
184
+ /**
185
+ * The checkout's own root, which is what every path git prints is relative to — so it is also the
186
+ * root the workspace graph has to be read from, or a `packages/cli/...` path would be matched
187
+ * against dirs resolved somewhere else.
188
+ */
189
+ export async function gitRoot(runner: Runner, cwd: string, command: string): Promise<string> {
190
+ const result = await git(runner, cwd, ['rev-parse', '--show-toplevel']);
191
+ if (result.ok) return result.stdout.trim();
192
+ throw new UltimateError({
193
+ code: 'X_CLI_UNEXPECTED',
194
+ cause: `x ${command} reads its diff from git and "git rev-parse --show-toplevel" exited ${result.code} in ${cwd}: ${singleLine(execOutput(result))}`,
195
+ fix: `run x ${command} from inside a git checkout — confirm with: git rev-parse --show-toplevel`,
196
+ docs: docsFor('X_CLI_UNEXPECTED'),
197
+ });
198
+ }
199
+
200
+ export interface ChangedFilesOptions {
201
+ readonly cwd: string;
202
+ readonly command: string;
203
+ readonly selection: AffectedSelection;
204
+ }
205
+
206
+ /**
207
+ * `<base>...HEAD` — three dots, so the answer is "what this branch changed since it forked",
208
+ * never "how this branch differs from a base that has moved on underneath it". A two-dot diff
209
+ * reports someone else's merged commits as this branch's work.
210
+ *
211
+ * `--dirty` unions the working tree on top: tracked edits against HEAD, plus untracked files that
212
+ * are not ignored. Both halves are needed — a brand-new file is invisible to `git diff`.
213
+ */
214
+ export async function changedFiles(
215
+ runner: Runner,
216
+ options: ChangedFilesOptions,
217
+ ): Promise<readonly string[]> {
218
+ const { base, dirty } = options.selection;
219
+ const resolved = await git(runner, options.cwd, [
220
+ 'rev-parse',
221
+ '--verify',
222
+ '--quiet',
223
+ `${base}^{commit}`,
224
+ ]);
225
+ if (!resolved.ok) {
226
+ throw new BadFlagError({
227
+ flag: 'base',
228
+ command: options.command,
229
+ reason: `git resolves no commit named "${base}" in this checkout`,
230
+ fix: `git fetch --no-tags origin ${base}:${base}, then re-run: ${invocationOf(options.command)} --base ${base} --json`,
231
+ });
232
+ }
233
+ const runs = [
234
+ await git(runner, options.cwd, ['diff', '--name-only', '-z', `${base}...HEAD`]),
235
+ ...(dirty
236
+ ? [
237
+ await git(runner, options.cwd, ['diff', '--name-only', '-z', 'HEAD']),
238
+ await git(runner, options.cwd, ['ls-files', '-z', '--others', '--exclude-standard']),
239
+ ]
240
+ : []),
241
+ ];
242
+ const failed = runs.find((run) => !run.ok);
243
+ if (failed !== undefined) {
244
+ throw new UltimateError({
245
+ code: 'X_CLI_UNEXPECTED',
246
+ cause: `"${failed.command.join(' ')}" exited ${failed.code} in ${options.cwd}: ${singleLine(execOutput(failed))}`,
247
+ fix: `run it yourself to see why: ${failed.command.join(' ')}`,
248
+ docs: docsFor('X_CLI_UNEXPECTED'),
249
+ });
250
+ }
251
+ return [...new Set(runs.flatMap((run) => paths(run.stdout)))].sort();
252
+ }
253
+
254
+ /**
255
+ * One resolved answer, in the two shapes its two callers need: the `plan` (`x affected` reports
256
+ * it) and the `prefixes` (`x test --affected` selects with them). `plan.workspaces` holds dirs
257
+ * relative to the CHECKOUT; `prefixes` is the same set relative to the directory the caller scans,
258
+ * which is what its own paths are relative to.
259
+ */
260
+ export interface AffectedScope {
261
+ readonly selection: AffectedSelection;
262
+ /** The checkout root git reported every path against. */
263
+ readonly root: string;
264
+ readonly plan: AffectedPlan;
265
+ readonly prefixes: readonly string[];
266
+ }
267
+
268
+ /**
269
+ * An empty prefix means the scan root IS an affected workspace, so every path it yields is in
270
+ * scope; a prefix starting with `..` is a workspace outside that root, which has no file there to
271
+ * select and must not be allowed to collapse into a match-everything empty string.
272
+ */
273
+ const scopePrefixes = (root: string, cwd: string, dirs: readonly string[]): readonly string[] =>
274
+ dirs
275
+ .map((dir) => relative(cwd, join(root, dir)).split('\\').join('/'))
276
+ .filter((path) => !path.startsWith('..'));
277
+
278
+ export const inScope = (path: string, prefixes: readonly string[]): boolean =>
279
+ prefixes.some((prefix) => prefix === '' || path === prefix || path.startsWith(`${prefix}/`));
280
+
281
+ export interface AffectedScopeOptions {
282
+ readonly runner: Runner;
283
+ /** The directory the caller scans, which the prefixes come back relative to. */
284
+ readonly cwd: string;
285
+ readonly args: ParsedArgs;
286
+ readonly command: string;
287
+ }
288
+
289
+ /**
290
+ * The whole answer, resolved once: the diff, the graph, the closure and the prefixes. `x affected`
291
+ * and `x test --affected` both come through here, so the second can never scope a run differently
292
+ * from what the first reports — an invented scoping that misses a transitive dependent is a green
293
+ * checkmark on a broken repo, and two implementations is how one of them gets it wrong.
294
+ */
295
+ export async function affectedScope(options: AffectedScopeOptions): Promise<AffectedScope> {
296
+ const selection = readAffectedSelection(options.args, options.command);
297
+ const root = await gitRoot(options.runner, options.cwd, options.command);
298
+ const plan = planAffected(
299
+ await readWorkspaceGraph(root),
300
+ await changedFiles(options.runner, { cwd: root, command: options.command, selection }),
301
+ );
302
+ return {
303
+ selection,
304
+ root,
305
+ plan,
306
+ prefixes: scopePrefixes(
307
+ root,
308
+ options.cwd,
309
+ plan.workspaces.map((workspace) => workspace.dir),
310
+ ),
311
+ };
312
+ }
313
+
314
+ /** The scope as `--json` carries it, from whichever command narrowed by it. */
315
+ export const affectedScopeJson = (scope: AffectedScope): JsonValue => ({
316
+ base: scope.selection.base,
317
+ dirty: scope.selection.dirty,
318
+ changed: scope.plan.changed.length,
319
+ workspaces: scope.plan.workspaces.map((workspace) => workspace.dir),
320
+ });
@@ -15,8 +15,9 @@ import { join as joinPath } from 'node:path';
15
15
  // The POSIX variants resolve specifiers against import-graph keys, which are POSIX on every host.
16
16
  import { dirname, join, normalize, relative } from 'node:path/posix';
17
17
  import type { BoundaryRule, ImportGraph } from '@ultimat3/render';
18
- import { checkSurfaceBoundary, importGraph } from '@ultimat3/render';
18
+ import { checkSurfaceBoundary, importGraph, SURFACES } from '@ultimat3/render';
19
19
  import type { Finding } from './output';
20
+ import { quoteArg } from './shell-quote';
20
21
 
21
22
  export const BOUNDARY_CODES = [
22
23
  'X_BOUNDARY_SITE_TO_APP',
@@ -132,8 +133,57 @@ const surfaceFindings = (graph: ImportGraph): readonly Finding[] =>
132
133
  };
133
134
  });
134
135
 
135
- /** `apps/web/app/posts/service.ts` → `posts`: the primitive a generator would be told to make. */
136
- const subjectOf = (path: string, fallback: string): string => path.split('/').at(-2) ?? fallback;
136
+ /**
137
+ * The surface names, from `@ultimat3/render`'s own list rather than a copy: a resource can never
138
+ * be called one, because the directory carrying that name is the surface itself.
139
+ */
140
+ const SURFACE_NAMES: ReadonlySet<string> = new Set<string>(SURFACES);
141
+
142
+ /**
143
+ * `apps/web/app/posts/service.ts` → `posts`: the primitive a generator would be told to make.
144
+ *
145
+ * `undefined` at a surface ROOT, where the directory above the file is the surface. The old
146
+ * answer there was the surface's own name, so `X_BOUNDARY_ROUTE_TO_DB` on `apps/web/site/page.tsx`
147
+ * said `x g query site` — a runnable line that generates seven files and a `sites` table for a
148
+ * landing page whose only problem is one import. A fix that does the wrong thing successfully is
149
+ * worse than one that refuses, so the caller names the file and asks for a name instead.
150
+ */
151
+ function subjectOf(path: string): string | undefined {
152
+ const parent = path.split('/').at(-2);
153
+ return parent === undefined || SURFACE_NAMES.has(parent) ? undefined : parent;
154
+ }
155
+
156
+ /**
157
+ * Control characters, `\u00xx`-escaped. The path is a value read off the repository being scanned,
158
+ * and it rides in a `#` comment: a directory holding a NEWLINE ends that comment, so everything
159
+ * after it is a second command in a line whose whole purpose is to be pasted into a shell.
160
+ * Escaped rather than deleted, because the comment still has to name the file the reader owns.
161
+ */
162
+ const commentSafe = (path: string): string =>
163
+ [...path]
164
+ .map((char) => {
165
+ const code = char.codePointAt(0) ?? 0;
166
+ return code < 0x20 || code === 0x7f ? `\\u${code.toString(16).padStart(4, '0')}` : char;
167
+ })
168
+ .join('');
169
+
170
+ /**
171
+ * `x g query posts` where the path names a resource, `x g query <name>` where it names a surface.
172
+ * The placeholder form is the shape `MissingPositionalError` already hands out (`x g route
173
+ * <name>`) and the one the `errors` step leaves unjudged in that slot — an open positional, where
174
+ * a reader substituting a word makes the line run. The path rides in the `#` comment because a
175
+ * `fix:` is copied on its own, and `Finding.at` is a field an agent pasting one line never sees.
176
+ *
177
+ * BOTH halves come from the scanned path, so both are hostile: the subject is an ARGUMENT and goes
178
+ * through `quoteArg` — a directory named `posts; id` emitted a fix that ran `id` — and the comment
179
+ * goes through `commentSafe`. `<name>` alone stays literal: it is a placeholder a reader replaces,
180
+ * and `'<name>'` would be pasted as a resource actually called that.
181
+ */
182
+ const generate = (kind: 'query' | 'action', path: string, then: string): string => {
183
+ const subject = subjectOf(path);
184
+ const named = subject === undefined ? '<name>' : quoteArg(subject);
185
+ return `x g ${kind} ${named} # ${then} ${commentSafe(path)}`;
186
+ };
137
187
 
138
188
  /**
139
189
  * Both fixes are one runnable line, with the rest of the instruction behind a `#` — a fix a
@@ -147,7 +197,7 @@ function layerFindings(scanned: readonly ScannedFile[]): readonly Finding[] {
147
197
  findings.push({
148
198
  code: 'X_BOUNDARY_ROUTE_TO_DB',
149
199
  cause: `route imports the database ("${specifier}") — routes call actions and queries`,
150
- fix: `x g query ${subjectOf(file.path, 'rows')} # then call it from the route`,
200
+ fix: generate('query', file.path, 'then call it from'),
151
201
  docs: docs('X_BOUNDARY_ROUTE_TO_DB'),
152
202
  at: file.path,
153
203
  });
@@ -156,7 +206,7 @@ function layerFindings(scanned: readonly ScannedFile[]): readonly Finding[] {
156
206
  findings.push({
157
207
  code: 'X_BOUNDARY_SERVICE_TO_HTTP',
158
208
  cause: `service imports HTTP ("${specifier}") — a service that knows about requests cannot be reused by a job`,
159
- fix: `x g action ${subjectOf(file.path, 'service')} # read the request there, pass the service plain values`,
209
+ fix: generate('action', file.path, 'read the request there and pass plain values to'),
160
210
  docs: docs('X_BOUNDARY_SERVICE_TO_HTTP'),
161
211
  at: file.path,
162
212
  });
package/src/bin.ts CHANGED
@@ -3,9 +3,11 @@
3
3
  // dispatch.ts, so the whole CLI is testable without spawning a process.
4
4
 
5
5
  import { dispatch } from './dispatch';
6
- // The write itself is `write-line.ts`: `create-ultimate`'s entry point needs the identical one,
7
- // and a second copy of a note about pipe truncation is a second copy that drifts.
8
- import { writeLine } from './write-line';
6
+ // The writes themselves are `write-line.ts`: `create-ultimate`'s entry point needs the identical
7
+ // one, and a second copy of a note about pipe truncation is a second copy that drifts. Two sinks,
8
+ // because fd 1 is not always this process's to write on — `x mcp serve --transport stdio` hands it
9
+ // to the protocol, and `dispatch` addresses that result to the second.
10
+ import { writeErrorLine, writeLine } from './write-line';
9
11
 
10
12
  const code = await dispatch({
11
13
  argv: Bun.argv.slice(2),
@@ -13,6 +15,7 @@ const code = await dispatch({
13
15
  env: Bun.env,
14
16
  bunVersion: Bun.version,
15
17
  write: writeLine,
18
+ writeError: writeErrorLine,
16
19
  });
17
20
 
18
21
  process.exit(code);