@ultimat3/cli 3.0.0 → 4.1.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 (54) hide show
  1. package/CLAUDE.md +69 -10
  2. package/package.json +24 -24
  3. package/src/budgets.ts +8 -5
  4. package/src/cmd-deploy.ts +42 -14
  5. package/src/cmd-docs.ts +7 -3
  6. package/src/cmd-errors.ts +5 -4
  7. package/src/cmd-fix.ts +15 -3
  8. package/src/cmd-generate.ts +29 -4
  9. package/src/cmd-help.ts +25 -4
  10. package/src/cmd-i18n.ts +8 -5
  11. package/src/cmd-jobs.ts +6 -5
  12. package/src/cmd-mcp.ts +16 -12
  13. package/src/cmd-new.ts +9 -13
  14. package/src/cmd-planned.ts +13 -0
  15. package/src/cmd-policy.ts +8 -6
  16. package/src/cmd-registries.ts +7 -6
  17. package/src/cmd-routes.ts +27 -4
  18. package/src/cmd-secrets.ts +6 -6
  19. package/src/cmd-verify.ts +55 -3
  20. package/src/command.ts +10 -2
  21. package/src/dev-cache.ts +9 -9
  22. package/src/dev-render.ts +6 -1
  23. package/src/dev-runtime.ts +2 -2
  24. package/src/dispatch.ts +33 -4
  25. package/src/error-codes.ts +5 -0
  26. package/src/error-contract.ts +31 -4
  27. package/src/fix-command.ts +9 -2
  28. package/src/fix-imports.ts +118 -0
  29. package/src/fix-scan.ts +251 -0
  30. package/src/flag-reads.ts +114 -0
  31. package/src/i18n-audit.ts +2 -1
  32. package/src/index.ts +8 -1
  33. package/src/jobs-drain.ts +6 -1
  34. package/src/mcp-errors.ts +5 -0
  35. package/src/mcp-host.ts +4 -2
  36. package/src/messages.ts +3 -0
  37. package/src/otlp-export.ts +14 -0
  38. package/src/output.ts +8 -5
  39. package/src/parse.ts +6 -1
  40. package/src/seo-meta.ts +105 -0
  41. package/src/templates/action.ts +39 -7
  42. package/src/templates/backfill.ts +3 -1
  43. package/src/templates/index.ts +10 -1
  44. package/src/templates/job.ts +6 -2
  45. package/src/templates/query.ts +6 -1
  46. package/src/templates/route.ts +18 -9
  47. package/src/templates/scaffold-api.ts +100 -0
  48. package/src/templates/scaffold-app.ts +8 -48
  49. package/src/templates/scaffold-container.ts +44 -9
  50. package/src/templates/scaffold-helm-templates.ts +327 -0
  51. package/src/templates/scaffold-helm.ts +144 -0
  52. package/src/templates/scaffold-repo.ts +25 -8
  53. package/src/ts-scan.ts +12 -174
  54. package/src/verify-step.ts +5 -0
package/src/cmd-new.ts CHANGED
@@ -7,6 +7,7 @@ import { chmod } from 'node:fs/promises';
7
7
  import { isAbsolute, join, resolve } from 'node:path';
8
8
  import { dedupe } from './cmd-generate';
9
9
  import type { CliCommand, CommandContext } from './command';
10
+ import { MissingPositionalError } from './errors';
10
11
  import { msg } from './messages';
11
12
  import type { CommandResult } from './output';
12
13
  import { flagBool, flagString } from './parse';
@@ -25,7 +26,7 @@ export function planNewApp(options: NewAppOptions): readonly GeneratedFile[] {
25
26
  const app = names(options.name);
26
27
  const files: GeneratedFile[] = [
27
28
  ...repoFiles(app, loadVersion(), options.example),
28
- ...appFiles(app),
29
+ ...appFiles(app, options.example),
29
30
  ];
30
31
  if (options.example) {
31
32
  files.push(...resourceFiles('post', { surfaceDir: 'apps/web/app', feature: 'post' }));
@@ -82,20 +83,15 @@ export const newCommand: CliCommand = {
82
83
  },
83
84
  async run(ctx: CommandContext): Promise<CommandResult> {
84
85
  const raw = ctx.args.positionals[0];
86
+ // The class, not a hand-built finding with the same code: `MissingPositionalError` is what
87
+ // names the missing POSITIONAL, and a finding assembled here is a second, unenforced copy of
88
+ // a cause the class already writes — one that said "x new needs a name" and not what a name is.
85
89
  if (raw === undefined) {
86
- return {
87
- ok: false,
90
+ throw new MissingPositionalError({
88
91
  command: 'new',
89
- summary: msg('cli.usage'),
90
- findings: [
91
- {
92
- code: 'X_CLI_BAD_FLAG',
93
- cause: 'x new needs a name',
94
- fix: 'x new myapp',
95
- docs: 'https://ultimate.dev/errors/X_CLI_BAD_FLAG',
96
- },
97
- ],
98
- };
92
+ positional: 'name',
93
+ example: 'x new myapp',
94
+ });
99
95
  }
100
96
  const app = names(raw);
101
97
  const target = resolve(parentDir(ctx.cwd, flagString(ctx.args, 'dir')), app.kebab);
@@ -91,6 +91,19 @@ export const PLANNED_COMMANDS: readonly PlannedCommand[] = [
91
91
  },
92
92
  ];
93
93
 
94
+ /**
95
+ * The planned command a resolved command NAME belongs to, if any. Exported for `dispatch.ts`'s
96
+ * parse-failure branch: the parser refuses an undeclared flag before any `run` is reached, and a
97
+ * planned command declares only the four global flags — so `x logs tail --follow` answered
98
+ * X_CLI_BAD_FLAG listing "known: json, help, cwd, verbose", a flag set belonging to a command that
99
+ * does not exist yet, while `x logs tail` answered the honest X_NOT_IMPLEMENTED one invocation away.
100
+ *
101
+ * Takes the name `commandFor` resolved, never the raw word: aliases are that function's business
102
+ * and a second matcher here would be a second answer to "which command did they type".
103
+ */
104
+ export const plannedCommandFor = (name: string | undefined): PlannedCommand | undefined =>
105
+ PLANNED_COMMANDS.find((planned) => planned.name === name);
106
+
94
107
  export interface PlannedSubcommand {
95
108
  readonly command: string;
96
109
  readonly subcommand: string;
package/src/cmd-policy.ts CHANGED
@@ -5,7 +5,7 @@
5
5
  import { loadApp } from './app-load';
6
6
  import { requireAppRoot } from './app-root';
7
7
  import type { CliCommand, CommandContext } from './command';
8
- import { BadFlagError, DeclarationUnknownError } from './errors';
8
+ import { DeclarationUnknownError, MissingPositionalError } from './errors';
9
9
  import { msg } from './messages';
10
10
  import type { CommandResult, Finding, JsonValue } from './output';
11
11
  import { nearest } from './parse';
@@ -74,11 +74,13 @@ function declarationLines(declaration: DeclarationExplanation): readonly string[
74
74
  function requireSubject(ctx: CommandContext): string {
75
75
  const name = ctx.args.positionals[0];
76
76
  if (name === undefined) {
77
- throw new BadFlagError({
78
- flag: 'subject',
79
- command: 'policy',
80
- reason: 'x policy explain <subject> needs a permission, action, query or route path',
81
- fix: 'x policy list --json',
77
+ // Never `BadFlagError`: its cause read `--subject on "x policy"`, and the next thing an agent
78
+ // typed was `x policy explain --subject posts:read`, which is a second X_CLI_BAD_FLAG for a
79
+ // flag this command does not declare. The positional is what is missing, so it is what is named.
80
+ throw new MissingPositionalError({
81
+ command: 'policy explain',
82
+ positional: 'subject',
83
+ example: 'x policy list --json',
82
84
  });
83
85
  }
84
86
  return name;
@@ -13,7 +13,7 @@ import { describeQueries, getQuery } from '@ultimat3/query';
13
13
  import { loadApp } from './app-load';
14
14
  import { requireAppRoot } from './app-root';
15
15
  import type { CliCommand, CommandContext } from './command';
16
- import { BadFlagError, DeclarationUnknownError } from './errors';
16
+ import { DeclarationUnknownError, MissingPositionalError } from './errors';
17
17
  import { msg } from './messages';
18
18
  import type { CommandResult, Finding, JsonValue } from './output';
19
19
  import type { CommandSpec } from './parse';
@@ -143,11 +143,12 @@ function describeResult<D extends { readonly name: string }, Raw extends { descr
143
143
  ): CommandResult {
144
144
  const name = ctx.args.positionals[0];
145
145
  if (name === undefined) {
146
- throw new BadFlagError({
147
- flag: 'name',
148
- command: kind.kind,
149
- reason: `x ${kind.kind} describe <name> needs a name`,
150
- fix: `x ${kind.kind} list --json`,
146
+ // A positional, so `MissingPositionalError` — `--name on "x actions"` named a flag no registry
147
+ // command declares, and reading it as one is a second refusal for the first one's advice.
148
+ throw new MissingPositionalError({
149
+ command: `${kind.kind} describe`,
150
+ positional: 'name',
151
+ example: `x ${kind.kind} list --json`,
151
152
  });
152
153
  }
153
154
  const raw = kind.find(name);
package/src/cmd-routes.ts CHANGED
@@ -4,11 +4,12 @@
4
4
  // The rows are `@ultimat3/render`'s own `describeRoutes()`: the CLI prints the route table, it
5
5
  // does not keep a second one.
6
6
 
7
- import type { RouteDescriptor } from '@ultimat3/render';
8
- import { describeRoutes } from '@ultimat3/render';
7
+ import type { RouteDescriptor, Surface } from '@ultimat3/render';
8
+ import { describeRoutes, SURFACES } from '@ultimat3/render';
9
9
  import { loadApp } from './app-load';
10
10
  import { requireAppRoot } from './app-root';
11
11
  import type { CliCommand, CommandContext } from './command';
12
+ import { BadFlagError } from './errors';
12
13
  import { msg } from './messages';
13
14
  import type { CommandResult, JsonValue } from './output';
14
15
  import { flagString } from './parse';
@@ -43,18 +44,40 @@ const routeJson = (routes: readonly RouteDescriptor[]): JsonValue =>
43
44
  budget: { js: route.budgetJs, lcp: route.budgetLcp },
44
45
  }));
45
46
 
47
+ /**
48
+ * A closed set, because the filter was a bare `===`: `x routes --surface App` and `--surface pages`
49
+ * matched no row and reported `0 routes` with exit 0, which is the same output an app with no
50
+ * routes gives — so a typo and an empty route table are indistinguishable, and only one of them is
51
+ * a bug the caller can see. `SURFACES` is `@ultimat3/render`'s own declaration of what a surface
52
+ * is; a list restated here would be a second answer to it (`x g --surface` is `generate-kinds.ts`'s
53
+ * narrower question — which surface to SCAFFOLD onto — and takes site|app alone).
54
+ */
55
+ export function readSurfaceFilter(raw: string | undefined): Surface | undefined {
56
+ const surfaces: readonly string[] = SURFACES;
57
+ if (raw === undefined) return undefined;
58
+ if (surfaces.includes(raw)) return raw as Surface;
59
+ throw new BadFlagError({
60
+ flag: 'surface',
61
+ command: 'routes',
62
+ reason: `"${raw}" is not a surface (known: ${SURFACES.join(', ')})`,
63
+ fix: 'x routes --surface app --json',
64
+ });
65
+ }
66
+
46
67
  export const routesCommand: CliCommand = {
47
68
  spec: {
48
69
  name: 'routes',
49
70
  summary: 'the route table: path, surface, render mode, hydrate, offline',
50
- usage: 'x routes [--surface site|app] [--json]',
71
+ usage: 'x routes [--surface site|app|api|shared] [--json]',
51
72
  requiresApp: true,
52
73
  flags: [{ name: 'surface', type: 'string', summary: 'filter by surface' }],
53
74
  },
54
75
  async run(ctx: CommandContext): Promise<CommandResult> {
55
76
  const root = requireAppRoot('routes', ctx.cwd).dir;
77
+ // Read before the app is loaded: a typo must not cost a boot to report, the rule `x mcp`'s
78
+ // `--transport` already follows.
79
+ const surface = readSurfaceFilter(flagString(ctx.args, 'surface'));
56
80
  const { findings } = await loadApp(root);
57
- const surface = flagString(ctx.args, 'surface');
58
81
  const routes = describeRoutes().filter(
59
82
  (route) => surface === undefined || route.surface === surface,
60
83
  );
@@ -32,7 +32,7 @@ import { ENV_SCHEMA_EXPORT, loadEnvSchema } from './app-env';
32
32
  import { requireAppRoot } from './app-root';
33
33
  import type { CliCommand, CommandContext } from './command';
34
34
  import {
35
- BadFlagError,
35
+ MissingPositionalError,
36
36
  SecretsEditFailedError,
37
37
  SecretsEditorMissingError,
38
38
  SecretsExistsError,
@@ -210,11 +210,11 @@ async function edit(ctx: CommandContext, io: SecretsIo): Promise<CommandResult>
210
210
  async function set(ctx: CommandContext, io: SecretsIo): Promise<CommandResult> {
211
211
  const name = ctx.args.positionals[0];
212
212
  if (name === undefined) {
213
- throw new BadFlagError({
214
- flag: 'name',
215
- command: 'secrets',
216
- reason: 'x secrets set <NAME> needs the environment variable name to seal the value under',
217
- fix: 'printf %s "$TOKEN" | x secrets set STRIPE_KEY --json',
213
+ // The name is the positional this command seals under, never a `--name` flag it does not have.
214
+ throw new MissingPositionalError({
215
+ command: 'secrets set',
216
+ positional: 'NAME',
217
+ example: 'printf %s "$TOKEN" | x secrets set STRIPE_KEY --json',
218
218
  });
219
219
  }
220
220
  const { root, key } = open(ctx, 'set');
package/src/cmd-verify.ts CHANGED
@@ -13,6 +13,8 @@ import {
13
13
  MANIFEST_FILENAME,
14
14
  verifyContract,
15
15
  } from '@ultimat3/manifest';
16
+ import type { MetaIssue } from '@ultimat3/seo';
17
+ import { validateMeta } from '@ultimat3/seo';
16
18
  import { checkAgentsMd } from './app-agents-md';
17
19
  import { checkAppBoundaries } from './app-boundaries';
18
20
  import { envExampleFindings } from './app-env';
@@ -24,13 +26,14 @@ import type { CliCommand, CommandContext } from './command';
24
26
  import { checkDestructiveMigrations } from './db-destructive';
25
27
  import { checkDocumentStyles, documentSurfaces } from './document-styles';
26
28
  import { checkSourceDrift } from './drift';
27
- import { checkErrorFixes } from './error-contract';
29
+ import { checkErrorFixReport } from './error-contract';
28
30
  import { readIntFlag } from './flag-number';
29
31
  import { guardFindings } from './guards';
30
32
  import { msg } from './messages';
31
33
  import type { CommandResult, Finding, StepResult } from './output';
32
34
  import { findingFrom } from './output';
33
35
  import type { ParsedArgs } from './parse';
36
+ import { scanSiteMeta } from './seo-meta';
34
37
  import { WORKER_CEILING, WORKER_FLOOR, WORKER_OVERSUBSCRIBE } from './test-workers';
35
38
  import {
36
39
  floorProblemFindings,
@@ -115,8 +118,21 @@ export const VERIFY_STEPS: readonly VerifyStep[] = [
115
118
  summary: 'every X_* code has a runnable fix and a docs page',
116
119
  // The fix-line half runs anywhere source does. The docs half needs a reference page to check
117
120
  // against, and which file that is belongs to the host repo — hence `hostFindings`.
118
- run: async (ctx) =>
119
- fromFindings([...(await checkErrorFixes(ctx.root)), ...(await hostFindings(ctx, 'errors'))]),
121
+ //
122
+ // The coverage line rides in `output`, which `--json` carries verbatim: a scan without a
123
+ // parser cannot read every fix, and a step that reports only findings claims a completeness
124
+ // it does not have. "checked 412, could not read 27" is what a reader can act on.
125
+ async run(ctx) {
126
+ const report = await checkErrorFixReport(ctx.root);
127
+ const findings = [...report.findings, ...(await hostFindings(ctx, 'errors'))];
128
+ return {
129
+ ...fromFindings(findings),
130
+ output: msg('cli.verify.fixCoverage', {
131
+ checked: report.checked,
132
+ unreadable: report.unreadable,
133
+ }),
134
+ };
135
+ },
120
136
  },
121
137
  ...TEST_STEPS,
122
138
  {
@@ -190,6 +206,30 @@ export const VERIFY_STEPS: readonly VerifyStep[] = [
190
206
  ]);
191
207
  },
192
208
  },
209
+ {
210
+ name: 'seo',
211
+ summary: 'every indexable site/ route has a title and a description a search result can render',
212
+ // The SEO checkers shipped in `@ultimat3/seo` with no caller anywhere — `validateMeta` and its
213
+ // asserts were reachable only by an app that called them itself, which is what
214
+ // `packages/seo/src/errors.ts`'s own header said. This is the caller.
215
+ //
216
+ // Its own step rather than a rider on `budgets`: that step asks what a document WEIGHS and
217
+ // this one asks what it SAYS, and a missing `<title>` reported under `budgets` would hand the
218
+ // reader a fix for the wrong question (axiom 4). It costs no second app load — `loadApp`
219
+ // imports each module once per process, so this runs on the registries `budgets` just filled.
220
+ applies: async (ctx) => existsSync(join(ctx.root, APP_CONFIG_FILE)),
221
+ async run(ctx) {
222
+ const scan = await scanSiteMeta(ctx.root);
223
+ // No `baseUrl`: an app declares no base URL anywhere (`packages/core/src/config.ts` has no
224
+ // such key), so canonical checks are skipped rather than run against an origin this file
225
+ // invented. `seo-meta.ts` spells out why that is the honest half.
226
+ const report = validateMeta(scan.records);
227
+ return {
228
+ ok: scan.findings.length === 0 && report.ok,
229
+ findings: [...scan.findings, ...report.issues.map(seoFinding)],
230
+ };
231
+ },
232
+ },
193
233
  {
194
234
  name: 'manifest',
195
235
  summary: 'the files an agent reads: generated facts, hand-written conventions, the env example',
@@ -384,6 +424,18 @@ function findingOf(error: unknown, step: string): Finding {
384
424
  };
385
425
  }
386
426
 
427
+ /**
428
+ * One `MetaIssue` as the gate reports it. `at` is the route FILE and never the URL: every seo error
429
+ * already names the file in its cause, and `at` is what an agent opens.
430
+ */
431
+ const seoFinding = (issue: MetaIssue): Finding => ({
432
+ code: issue.code,
433
+ cause: issue.cause,
434
+ fix: issue.fix,
435
+ docs: `https://ultimate.dev/errors/${issue.code}`,
436
+ at: issue.file,
437
+ });
438
+
387
439
  export const verifyCommand: CliCommand = {
388
440
  spec: {
389
441
  name: 'verify',
package/src/command.ts CHANGED
@@ -20,14 +20,22 @@ export interface CliCommand {
20
20
  run(ctx: CommandContext): Promise<CommandResult>;
21
21
  }
22
22
 
23
+ /**
24
+ * `ok` is written AFTER the spread in both helpers, and that order is the whole contract: the
25
+ * function's NAME is the verdict, and `extra` may carry every other field. Spread last, a caller
26
+ * passing `{ ok: true }` to `failed()` got a result `exitCodeFor` exits 0 on while its own summary
27
+ * says it failed — a green CI over a red command. `command` and `summary` stay before the spread
28
+ * on purpose: those are arguments a caller may legitimately refine, and only the verdict is the
29
+ * helper's to keep.
30
+ */
23
31
  export const ok = (
24
32
  command: string,
25
33
  summary: string,
26
34
  extra: Partial<CommandResult> = {},
27
- ): CommandResult => ({ ok: true, command, summary, ...extra });
35
+ ): CommandResult => ({ command, summary, ...extra, ok: true });
28
36
 
29
37
  export const failed = (
30
38
  command: string,
31
39
  summary: string,
32
40
  extra: Partial<CommandResult> = {},
33
- ): CommandResult => ({ ok: false, command, summary, ...extra });
41
+ ): CommandResult => ({ command, summary, ...extra, ok: false });
package/src/dev-cache.ts CHANGED
@@ -79,25 +79,25 @@ export function startCacheTiers(options: CacheTiersOptions): () => Promise<void>
79
79
  registerInvalidationBroadcast(async (wireTags) => {
80
80
  await options.transport.publish(CACHE_INVALIDATE_SUBJECT, JSON.stringify(wireTags));
81
81
  });
82
- let subscription: TransportSubscription | undefined;
83
- // Not awaited: the boot must not block on a subscribe, and a bus that refuses one is a process
84
- // that misses peer invalidations, never a process that fails to start.
85
- void options.transport
82
+ // Not awaited HERE: the boot must not block on a subscribe, and a bus that refuses one is a
83
+ // process that misses peer invalidations, never a process that fails to start. The PROMISE is
84
+ // held rather than a handle assigned inside a `.then`, because the release ran first whenever
85
+ // `stop()` beat the round trip — a NATS bus plus a boot that throws in `bootRoles`, or a test
86
+ // that boots and stops immediately — and the subscription that landed afterwards was live with
87
+ // nobody left holding it. `mcp-host.ts`'s lazy `started` is the same shape.
88
+ const subscribing: Promise<TransportSubscription | undefined> = options.transport
86
89
  .subscribe(CACHE_INVALIDATE_SUBJECT, (payload: string) => {
87
90
  void applyBroadcast(payload);
88
91
  })
89
- .then((handle) => {
90
- subscription = handle;
91
- })
92
92
  .catch((error: unknown) => {
93
93
  logger.warn('cache.broadcast.subscribe-failed', { error: messageOf(error) });
94
+ return undefined;
94
95
  });
95
96
 
96
97
  // `resetTiers()` drops the registry AND the broadcast in one call: this boot is the only thing
97
98
  // that registers either, and a tier left behind would purge for a process that has stopped.
98
99
  return async () => {
99
- subscription?.unsubscribe();
100
- subscription = undefined;
100
+ (await subscribing)?.unsubscribe();
101
101
  resetTiers();
102
102
  };
103
103
  }
package/src/dev-render.ts CHANGED
@@ -24,6 +24,7 @@ import {
24
24
  createIsrController,
25
25
  headFromMeta,
26
26
  hydrateRuntime,
27
+ isrKey,
27
28
  metaContextFor,
28
29
  renderComponent,
29
30
  renderHead,
@@ -185,7 +186,11 @@ async function resultFor(
185
186
  return { status: 200, headers: staticHeaders(contentHash(body), options.buildId), body };
186
187
  }
187
188
  case 'isr': {
188
- const served = await isr.serve(url.pathname, () =>
189
+ // `isrKey(url)`, never `url.pathname`: the query is part of what was rendered — this
190
+ // route's own `meta` reads `data.url` — so two URLs differing only in their query are two
191
+ // documents. Keyed on the pathname alone, the first render answered every later query
192
+ // string (#171). Render owns the derivation so no second caller can invent another.
193
+ const served = await isr.serve(isrKey(url), () =>
189
194
  documentFrom(entry, request, data, options),
190
195
  );
191
196
  return served.result;
@@ -6,7 +6,7 @@
6
6
  import { mkdirSync } from 'node:fs';
7
7
  import type { PurgeDriver } from '@ultimat3/cache';
8
8
  import { isNoopPurgeDriver, selectPurgeDriver } from '@ultimat3/cache';
9
- import { isLocal, resolveEnvironment } from '@ultimat3/core';
9
+ import { isLocal, renderThrowable, resolveEnvironment } from '@ultimat3/core';
10
10
  import type { EventBus, JobDriver, OutboxStore } from '@ultimat3/jobs';
11
11
  import type { MailDriver } from '@ultimat3/mail';
12
12
  import {
@@ -167,7 +167,7 @@ export function startStorage(services: DevServices, env: Env, override?: Storage
167
167
  try {
168
168
  mkdirSync(root, { recursive: true });
169
169
  } catch (cause) {
170
- const detail = cause instanceof Error ? cause.message : String(cause);
170
+ const detail = renderThrowable(cause);
171
171
  throw new StorageUnwritableError(
172
172
  `the embedded storage disk needs ${root} and it could not be created: ${detail}`,
173
173
  `mount a writable volume at ${root}, or set S3_ENDPOINT and S3_BUCKET to use object storage instead`,
package/src/dispatch.ts CHANGED
@@ -3,10 +3,11 @@
3
3
  // path as results, so a failure is machine-readable exactly like a success.
4
4
 
5
5
  import { isAbsolute, resolve } from 'node:path';
6
- import { requireBunVersion } from './app-root';
6
+ import { requireAppRoot, requireBunVersion } from './app-root';
7
7
  import { createHelpCommand } from './cmd-help';
8
+ import { plannedCommandFor } from './cmd-planned';
8
9
  import type { CommandContext } from './command';
9
- import { UnknownCommandError } from './errors';
10
+ import { CliNotImplementedError, UnknownCommandError } from './errors';
10
11
  import type { Runner } from './exec';
11
12
  import { exec } from './exec';
12
13
  import type { CommandResult } from './output';
@@ -42,14 +43,33 @@ const errorResult = (command: string, error: unknown): CommandResult => ({
42
43
  * end without terminating the test runner.
43
44
  */
44
45
  export async function dispatch(options: DispatchOptions): Promise<number> {
45
- let args: ParsedArgs;
46
+ // Its own branch, ahead of the parse: an unsupported Bun is a fact about the environment and
47
+ // outranks anything argv says, including the planned pre-empt below.
46
48
  try {
47
49
  requireBunVersion(options.bunVersion);
50
+ } catch (error) {
51
+ options.write(render(errorResult('x', error), wantsJson(options.argv)));
52
+ return 1;
53
+ }
54
+
55
+ let args: ParsedArgs;
56
+ try {
48
57
  args = parseArgs(options.argv, SPECS);
49
58
  } catch (error) {
59
+ // A PLANNED command is not built, so every invocation of one must say that and nothing else.
60
+ // The parser refuses an undeclared flag before any `run` is reached and a planned command
61
+ // declares only the four globals, so `x logs tail --follow` reported X_CLI_BAD_FLAG — a flag
62
+ // list for a command that does not exist yet — while `x logs tail` reported the honest
63
+ // X_NOT_IMPLEMENTED with a runnable fix. Substituted here rather than in `parse.ts`, which is
64
+ // pure and knows nothing about what a command means; the precedent is the help swap below.
65
+ const planned = plannedCommandFor(commandFor(options.argv[0] ?? '')?.spec.name);
66
+ const failure =
67
+ planned === undefined
68
+ ? error
69
+ : new CliNotImplementedError({ feature: `x ${planned.name}`, fix: planned.fix });
50
70
  // `wantsJson`, not `includes('--json')`: a typo'd flag or a typo'd command is exactly the case
51
71
  // an agent hits while always passing `-j`, and the short form rendered prose it then parsed.
52
- const result = errorResult('x', error);
72
+ const result = errorResult(planned?.name ?? 'x', failure);
53
73
  options.write(render(result, wantsJson(options.argv)));
54
74
  return 1;
55
75
  }
@@ -85,6 +105,15 @@ export async function dispatch(options: DispatchOptions): Promise<number> {
85
105
  };
86
106
 
87
107
  try {
108
+ // The reader `CommandSpec.requiresApp` never had. Its doc said "the dispatcher enforces it" and
109
+ // `dispatch` did not read the field at all: the guarantee held only because all 17 declaring
110
+ // commands happen to call `requireAppRoot` themselves, so a new command that declares it and
111
+ // forgets the call ran outside an app with no refusal. Each of those 17 calls stays — they are
112
+ // what hands a command the root it works in, and several name a subcommand this cannot see
113
+ // (`env init`, `secrets set`) — but the DECLARATION is now what decides, ahead of any check a
114
+ // command makes about its own arguments. `--help` is exempt because `target` is then the help
115
+ // command, which declares nothing: usage for a command must be readable from anywhere.
116
+ if (target.spec.requiresApp === true) requireAppRoot(target.spec.name, ctx.cwd);
88
117
  const result = await target.run(ctx);
89
118
  options.write(render(result, args.json, args.flags.get('verbose') === true));
90
119
  // `x dev` and `x mcp serve --transport http` are still listening here: report first, so the
@@ -86,6 +86,10 @@ export const CLI_OWNED_ERROR_CODES = [
86
86
  'X_GUARD_INVALID',
87
87
  'X_GUARD_FAILED',
88
88
  'X_GUARD_FINDING_INVALID',
89
+ // The CLI's own declarations, held to each other. A flag the parser accepts and no code reads
90
+ // is a promise in `x help` with nothing behind it — `x deploy --critical` said "forces clients
91
+ // to reload" and reached no reader outside the plan JSON it was written into.
92
+ 'X_CLI_FLAG_UNREAD',
89
93
  // The two halves of `x secrets edit` that belong to the terminal rather than to the envelope.
90
94
  // `@ultimat3/core` owns every X_SECRETS_* code about the file and the key; an editor is the
91
95
  // CLI's problem alone, and core would have no `fix:` to offer for one.
@@ -179,6 +183,7 @@ export const CLI_ERROR_TITLES: Readonly<Record<CliOwnedErrorCode, string>> = {
179
183
  X_GUARD_INVALID: 'a file in guards/ exports no usable guard',
180
184
  X_GUARD_FAILED: 'an app guard threw instead of returning findings',
181
185
  X_GUARD_FINDING_INVALID: "an app guard's finding breaks the error contract",
186
+ X_CLI_FLAG_UNREAD: 'a command declares a flag no code reads',
182
187
  X_SECRETS_EDITOR_MISSING: 'no $EDITOR to open the decrypted secrets in',
183
188
  X_SECRETS_EDIT_FAILED: 'the editor exited non-zero, so nothing was resealed',
184
189
  };
@@ -8,10 +8,12 @@
8
8
  import { join } from 'node:path';
9
9
  import { docsFor } from './error-codes';
10
10
  import { citedCommandProblem, loadCommandCatalog } from './fix-command';
11
+ import { createHelperResolver } from './fix-imports';
12
+ import { scanFixSites } from './fix-scan';
11
13
  import type { Finding } from './output';
12
14
  import { eachSourceFile, isGenerated, isTest } from './source-files';
13
15
  import type { CodeSite, FixSite } from './ts-scan';
14
- import { isCodeRegistry, scanBorrowedCodes, scanCodes, scanFixes } from './ts-scan';
16
+ import { isCodeRegistry, scanBorrowedCodes, scanCodes } from './ts-scan';
15
17
 
16
18
  /** Advice, not instruction. The list is the one in `docs/architecture/04-error-contract.md`. */
17
19
  export const BANNED_PHRASES: readonly RegExp[] = [
@@ -77,12 +79,33 @@ const fixFinding = (site: FixSite, problem: string): Finding => ({
77
79
  * The catalog is loaded ONCE per run rather than per fix line: it is a dynamic import (see
78
80
  * `fix-command.ts` for the cycle it breaks) and this walks every shipped source file.
79
81
  */
80
- export async function checkErrorFixes(root: string): Promise<readonly Finding[]> {
82
+ export interface ErrorFixReport {
83
+ readonly findings: readonly Finding[];
84
+ /** Fix literals actually read, and held to both rules. */
85
+ readonly checked: number;
86
+ /**
87
+ * Fix arguments at a known builder that hold no single literal — a parameter passed through, a
88
+ * concatenation, a table lookup. The step prints it, because a gate that says "checked 412,
89
+ * could not read 27" is honest and one that says nothing is the false green this check exists to
90
+ * close. It does NOT cover a builder imported from another PACKAGE: `candidatePaths` resolves
91
+ * relative specifiers only, and that gap is 3 call sites across this repo, measured 2026-08.
92
+ */
93
+ readonly unreadable: number;
94
+ }
95
+
96
+ export async function checkErrorFixReport(root: string): Promise<ErrorFixReport> {
81
97
  const findings: Finding[] = [];
82
98
  const catalog = await loadCommandCatalog();
99
+ const imports = createHelperResolver(root);
100
+ let checked = 0;
101
+ let unreadable = 0;
83
102
  for await (const path of eachSourceFile(root)) {
84
103
  if (isTest(path) || isGenerated(path)) continue;
85
- for (const site of scanFixes(await Bun.file(join(root, path)).text(), path)) {
104
+ const source = await Bun.file(join(root, path)).text();
105
+ const scan = scanFixSites(source, path, await imports(path, source));
106
+ checked += scan.sites.length;
107
+ unreadable += scan.unreadable;
108
+ for (const site of scan.sites) {
86
109
  // The interpolation-blanked form for both rules: `x ${name}` names no command this can
87
110
  // resolve, and reading `<value>` as one would be a finding nobody can act on.
88
111
  const fix = staticFix(site.fix);
@@ -90,9 +113,13 @@ export async function checkErrorFixes(root: string): Promise<readonly Finding[]>
90
113
  if (problem !== undefined) findings.push(fixFinding(site, problem));
91
114
  }
92
115
  }
93
- return findings;
116
+ return { findings, checked, unreadable };
94
117
  }
95
118
 
119
+ /** The findings alone, for every caller that reports no coverage line. */
120
+ export const checkErrorFixes = async (root: string): Promise<readonly Finding[]> =>
121
+ (await checkErrorFixReport(root)).findings;
122
+
96
123
  /**
97
124
  * A code is documented when the reference page names it. Deliberately not "owns a table row": the
98
125
  * page legitimately groups near-identical codes onto one row, and a rule that forbade that would
@@ -28,8 +28,15 @@ import { GLOBAL_FLAGS } from './parse';
28
28
  // in `@ultimat3/mcp` — is `X_CLI_UNKNOWN_COMMAND` when run and resolved clean while a placeholder
29
29
  // was invisible to the reader. Second and fourth slots are open positionals (`x new my-app`,
30
30
  // `x db branch drop <name>`), where a placeholder is exactly right.
31
- const CITATION =
32
- /(?:^|[\s;|&("'`])x\s+([a-z][a-z\d-]*)(?:\s+([a-z][a-z\d-]*))?(?:\s+([a-z][a-z\d-]*|<[^>]*>))?/g;
31
+ // A `:` is part of a word only when a letter follows it, which is what separates the shipped
32
+ // positional `admin:page` from prose that ends a citation with a colon (`x verify: the gate`).
33
+ // Read without it, `x g admin:page` cites `x g admin` — a positional the CLI does not ship —
34
+ // and the one documented invocation of the admin-page generator was a standing false finding.
35
+ const WORD = String.raw`[a-z][a-z\d-]*(?::[a-z][a-z\d-]*)?`;
36
+ const CITATION = new RegExp(
37
+ String.raw`(?:^|[\s;|&("'\x60])x\s+(${WORD})(?:\s+(${WORD}))?(?:\s+(${WORD}|<[^>]*>))?`,
38
+ 'g',
39
+ );
33
40
 
34
41
  /**
35
42
  * A long flag, `--` stripped. `--no-<name>` is the parser's negation of a boolean, so it resolves