@ultimat3/cli 2.0.0 → 4.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 (75) hide show
  1. package/CLAUDE.md +109 -13
  2. package/README.md +1 -0
  3. package/package.json +24 -24
  4. package/src/budgets.ts +31 -8
  5. package/src/cmd-db-branch.ts +6 -2
  6. package/src/cmd-db.ts +138 -10
  7. package/src/cmd-deploy.ts +42 -14
  8. package/src/cmd-dev.ts +9 -2
  9. package/src/cmd-docs.ts +7 -3
  10. package/src/cmd-doctor.ts +16 -7
  11. package/src/cmd-fix.ts +15 -3
  12. package/src/cmd-generate.ts +29 -4
  13. package/src/cmd-help.ts +25 -4
  14. package/src/cmd-i18n.ts +8 -5
  15. package/src/cmd-jobs.ts +6 -5
  16. package/src/cmd-mcp.ts +16 -12
  17. package/src/cmd-new.ts +10 -14
  18. package/src/cmd-planned.ts +13 -0
  19. package/src/cmd-policy.ts +8 -6
  20. package/src/cmd-registries.ts +7 -6
  21. package/src/cmd-routes.ts +27 -4
  22. package/src/cmd-secrets.ts +6 -6
  23. package/src/cmd-test.ts +14 -3
  24. package/src/cmd-verify.ts +80 -10
  25. package/src/command.ts +10 -2
  26. package/src/db-branch.ts +18 -0
  27. package/src/db-generate.ts +38 -6
  28. package/src/db-seed.ts +294 -0
  29. package/src/dev-assets.ts +22 -3
  30. package/src/dev-cache.ts +9 -9
  31. package/src/dev-render.ts +6 -1
  32. package/src/dev-roles.ts +5 -3
  33. package/src/dev-runtime.ts +2 -2
  34. package/src/dev-storage.ts +6 -4
  35. package/src/dev-traces.ts +26 -4
  36. package/src/dispatch.ts +33 -4
  37. package/src/drift.ts +41 -1
  38. package/src/error-catalog.ts +1 -0
  39. package/src/error-codes.ts +11 -0
  40. package/src/error-contract.ts +31 -4
  41. package/src/exec.ts +42 -8
  42. package/src/fix-command.ts +9 -2
  43. package/src/fix-imports.ts +118 -0
  44. package/src/fix-scan.ts +251 -0
  45. package/src/flag-number.ts +11 -0
  46. package/src/flag-reads.ts +114 -0
  47. package/src/i18n-audit.ts +2 -1
  48. package/src/index.ts +19 -5
  49. package/src/jobs-drain.ts +6 -1
  50. package/src/mcp-errors.ts +13 -0
  51. package/src/mcp-host.ts +4 -2
  52. package/src/messages.ts +15 -0
  53. package/src/metrics-endpoint.ts +60 -13
  54. package/src/otlp-export.ts +14 -0
  55. package/src/parse.ts +6 -1
  56. package/src/seo-meta.ts +105 -0
  57. package/src/serve.ts +15 -3
  58. package/src/shell-quote.ts +15 -0
  59. package/src/templates/action.ts +39 -7
  60. package/src/templates/backfill.ts +3 -1
  61. package/src/templates/index.ts +10 -1
  62. package/src/templates/job.ts +6 -2
  63. package/src/templates/query.ts +6 -1
  64. package/src/templates/route.ts +18 -9
  65. package/src/templates/scaffold-api.ts +100 -0
  66. package/src/templates/scaffold-app.ts +8 -48
  67. package/src/templates/scaffold-container.ts +44 -9
  68. package/src/templates/scaffold-helm-templates.ts +327 -0
  69. package/src/templates/scaffold-helm.ts +144 -0
  70. package/src/templates/scaffold-repo.ts +25 -8
  71. package/src/test-shards.ts +1 -10
  72. package/src/test-workers.ts +4 -1
  73. package/src/ts-scan.ts +25 -176
  74. package/src/tsconfig-references.ts +27 -2
  75. package/src/verify-step.ts +5 -0
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-test.ts CHANGED
@@ -9,9 +9,10 @@ import { readIntFlag } from './flag-number';
9
9
  import type { CommandResult } from './output';
10
10
  import type { ParsedArgs } from './parse';
11
11
  import { flagString } from './parse';
12
+ import { quoteArg } from './shell-quote';
12
13
  import { discoverTests, missingSelection, readSample, readType, sampleFiles } from './test-select';
13
- import { quoteArg, runShards } from './test-shards';
14
- import { defaultWorkers } from './test-workers';
14
+ import { runShards } from './test-shards';
15
+ import { defaultWorkers, WORKER_CEILING, WORKER_FLOOR, WORKER_OVERSUBSCRIBE } from './test-workers';
15
16
  import type { TestType } from './verify-tests';
16
17
  import { TEST_TYPES } from './verify-tests';
17
18
 
@@ -25,6 +26,12 @@ const readIndex = (args: ParsedArgs, name: string, min: number): number | undefi
25
26
  name,
26
27
  command: 'test',
27
28
  min,
29
+ // The ceiling the summary already claimed and the reader never enforced: `--workers 5000` was
30
+ // accepted, `planShards` clamps only to the file count, and `runParallel` `Promise.all`s them —
31
+ // one Bun process per test FILE, each with the framework module graph and a cloned database.
32
+ // `--worker` is an index into that split, so the same bound holds it (the exact upper index is
33
+ // `workers - 1`, refused a line below by the check that knows the real width).
34
+ max: WORKER_CEILING,
28
35
  example: `x test --${name} ${Math.max(min, 1)}`,
29
36
  });
30
37
 
@@ -54,7 +61,11 @@ export const testCommand: CliCommand = {
54
61
  usage: `x test [${TEST_TYPES.join('|')}] [--filter text] [--sample N] [--workers N] [--worker I] [--json]`,
55
62
  positionalChoices: TEST_TYPES,
56
63
  flags: [
57
- { name: 'workers', type: 'string', summary: 'process count (default: CPUs - 1, max 8)' },
64
+ {
65
+ name: 'workers',
66
+ type: 'string',
67
+ summary: `process count (default: ${WORKER_OVERSUBSCRIBE}x CPUs, min ${WORKER_FLOOR}, max ${WORKER_CEILING})`,
68
+ },
58
69
  {
59
70
  name: 'worker',
60
71
  type: 'string',
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,15 @@ 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';
37
+ import { WORKER_CEILING, WORKER_FLOOR, WORKER_OVERSUBSCRIBE } from './test-workers';
34
38
  import {
35
39
  floorProblemFindings,
36
40
  floorRequires,
@@ -44,6 +48,9 @@ import { TEST_STEPS } from './verify-tests';
44
48
  import { checkFileSizes, checkPackageShape, hasWorkspacePackages } from './workspace-checks';
45
49
 
46
50
  /** The whole contract, in cost order. Every check the framework knows how to make lives here. */
51
+ /** The one file that makes the `roadmap` step answerable, and therefore what `applies` reads. */
52
+ const ROADMAP_FILE = join('docs', 'idea', '14-roadmap.md');
53
+
47
54
  export const VERIFY_STEPS: readonly VerifyStep[] = [
48
55
  {
49
56
  name: 'typecheck',
@@ -111,8 +118,21 @@ export const VERIFY_STEPS: readonly VerifyStep[] = [
111
118
  summary: 'every X_* code has a runnable fix and a docs page',
112
119
  // The fix-line half runs anywhere source does. The docs half needs a reference page to check
113
120
  // against, and which file that is belongs to the host repo — hence `hostFindings`.
114
- run: async (ctx) =>
115
- 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
+ },
116
136
  },
117
137
  ...TEST_STEPS,
118
138
  {
@@ -186,6 +206,30 @@ export const VERIFY_STEPS: readonly VerifyStep[] = [
186
206
  ]);
187
207
  },
188
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
+ },
189
233
  {
190
234
  name: 'manifest',
191
235
  summary: 'the files an agent reads: generated facts, hand-written conventions, the env example',
@@ -224,8 +268,11 @@ export const VERIFY_STEPS: readonly VerifyStep[] = [
224
268
  name: 'roadmap',
225
269
  summary: "every roadmap milestone's status marker matches what is actually on disk",
226
270
  // A generated app ships no `docs/idea/14-roadmap.md` — only the framework monorepo does, so
227
- // only a host that registers this check has anything for the step to verify.
228
- applies: async (ctx) => ctx.hostChecks?.roadmap !== undefined,
271
+ // the FILE is what decides. It keyed on `ctx.hostChecks?.roadmap` until `As of 2026-08`, which
272
+ // is a fact about the CALL: a caller of the exported `runVerify(VERIFY_STEPS, ctx)` passing no
273
+ // `hostChecks`, in a repo whose committed `x.verify.json` names `roadmap`, got
274
+ // `X_VERIFY_SUITE_VANISHED` — whose `fix:` is the command that had just failed.
275
+ applies: async (ctx) => existsSync(join(ctx.root, ROADMAP_FILE)),
229
276
  run: async (ctx) => fromFindings(await hostFindings(ctx, 'roadmap')),
230
277
  },
231
278
  ];
@@ -377,6 +424,18 @@ function findingOf(error: unknown, step: string): Finding {
377
424
  };
378
425
  }
379
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
+
380
439
  export const verifyCommand: CliCommand = {
381
440
  spec: {
382
441
  name: 'verify',
@@ -390,7 +449,7 @@ export const verifyCommand: CliCommand = {
390
449
  {
391
450
  name: 'workers',
392
451
  type: 'string',
393
- summary: 'test processes per parallel step (default: CPUs - 1, max 8)',
452
+ summary: `test processes per parallel step (default: ${WORKER_OVERSUBSCRIBE}x CPUs, min ${WORKER_FLOOR}, max ${WORKER_CEILING})`,
394
453
  },
395
454
  ],
396
455
  },
@@ -405,13 +464,24 @@ export const verifyCommand: CliCommand = {
405
464
  },
406
465
  };
407
466
 
408
- /** `x test --workers` refuses the same values for the same reason — and now through the same
409
- * reader, so the claim is enforced rather than asserted in a comment. */
410
- const readWorkers = (args: ParsedArgs): number | undefined =>
467
+ /**
468
+ * Both bounds are the constants the flag summary already names, so `x help verify` and the reader
469
+ * cannot disagree. Exported for the test that pins them: the command's `run` reaches this only
470
+ * after the whole gate would have started.
471
+ *
472
+ * `max` is the ceiling. Without it `--workers 5000` parsed, `planShards` clamped only to the file
473
+ * count, and `runParallel` `Promise.all`ed one Bun process per test file. `min` is `WORKER_FLOOR`,
474
+ * the same number `defaultWorkers` will not go below — the gate spreads or it does not shard, and
475
+ * `--workers 1` was a serial run the summary said was impossible. `x test --workers 1` stays legal
476
+ * and is deliberately NOT this reader: `runShards` clamps the width to the file count, so a
477
+ * one-file corpus makes `X_TEST_SHARD_FAILED`'s own `fix:` say `--workers 1`.
478
+ */
479
+ export const readWorkers = (args: ParsedArgs): number | undefined =>
411
480
  readIntFlag(args, {
412
481
  name: 'workers',
413
482
  command: 'verify',
414
- min: 1,
483
+ min: WORKER_FLOOR,
484
+ max: WORKER_CEILING,
415
485
  example: 'x verify --workers 4',
416
486
  });
417
487
 
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/db-branch.ts CHANGED
@@ -48,6 +48,24 @@ export function isBranchName(value: string): boolean {
48
48
  }
49
49
  }
50
50
 
51
+ /**
52
+ * The database a connection URL names. `url.split('/').at(-1)` took the query string with it, so
53
+ * a refusal built from it named `postly?sslmode=require_branch_x` — a database that does not exist
54
+ * — in a message whose whole point is that a reader can check it. `pathname` is the one part that
55
+ * IS the database, and it arrives percent-encoded, which `pg_database` does not.
56
+ *
57
+ * Falls back rather than throwing: the caller is already reporting a failure, and a second throw
58
+ * from the reporter replaces a checkable refusal with a stack trace.
59
+ */
60
+ export function databaseNameOf(url: string, fallback = 'postgres'): string {
61
+ try {
62
+ const name = decodeURIComponent(new URL(url).pathname.replace(/^\//, ''));
63
+ return name === '' ? fallback : name;
64
+ } catch {
65
+ return fallback;
66
+ }
67
+ }
68
+
51
69
  /** Where a branch's app answers once something serves it — the preview half of the design. */
52
70
  export const previewUrl = (branch: string, port: number): string =>
53
71
  `http://${branch}.localhost:${port}`;
@@ -14,7 +14,7 @@ import {
14
14
  } from '@ultimat3/db';
15
15
  import { describeEntities } from '@ultimat3/entity';
16
16
  import { loadApp } from './app-load';
17
- import { writeSchemaHash } from './drift';
17
+ import { reconcileSchemaHash, writeSchemaHash } from './drift';
18
18
  import { hashFileName, MIGRATIONS_DIR, readMigrations, snapshotFileName } from './migrations';
19
19
  import type { Finding } from './output';
20
20
 
@@ -24,11 +24,20 @@ export interface GenerateMigrationOptions {
24
24
  readonly allowDestructive?: boolean | undefined;
25
25
  }
26
26
 
27
+ /**
28
+ * What this run actually did — four different things, and `--json` has to tell them apart. A
29
+ * `hash-recorded` run wrote a file and generated no migration; reporting it as `generated` would
30
+ * claim a migration nobody can apply, and reporting it as `unchanged` would hide the one write.
31
+ */
32
+ export type GenerateOutcome = 'generated' | 'hash-recorded' | 'unchanged' | 'blocked';
33
+
27
34
  export interface GeneratedFiles {
28
- /** Absent when the entities and the migrations already agree — nothing was written. */
35
+ readonly outcome: GenerateOutcome;
36
+ /** Absent unless `outcome` is `generated` — the other three write no migration. */
29
37
  readonly migration?: GeneratedMigration | undefined;
30
38
  /** App-root-relative paths written, in write order. Empty when there was nothing to write. */
31
39
  readonly files: readonly string[];
40
+ /** What the source hashes to, whenever this run was in a position to record it. */
32
41
  readonly schemaHash?: string | undefined;
33
42
  /** Modules that would not load. Non-empty means nothing was generated. */
34
43
  readonly findings: readonly Finding[];
@@ -72,7 +81,7 @@ export async function generateAppMigration(
72
81
  options: GenerateMigrationOptions,
73
82
  ): Promise<GeneratedFiles> {
74
83
  const app = await loadApp(root);
75
- if (app.findings.length > 0) return { files: [], findings: app.findings };
84
+ if (app.findings.length > 0) return { outcome: 'blocked', files: [], findings: app.findings };
76
85
 
77
86
  const migrations = await readMigrations(root);
78
87
  const current = declaredSchema(migrations);
@@ -90,9 +99,31 @@ export async function generateAppMigration(
90
99
  name: options.name,
91
100
  ...(options.allowDestructive === true ? { allowDestructive: true } : {}),
92
101
  });
93
- // An empty diff writes nothing. A migration with no statement still takes a ledger row, a
94
- // checksum and a place in the apply order — a permanent record that nothing changed.
95
- if (migration.up.trim().length === 0) return { files: [], findings: [] };
102
+ // An empty diff writes no MIGRATION — one with no statement still takes a ledger row, a checksum
103
+ // and a place in the apply order, a permanent record that nothing changed. It re-records the
104
+ // sidecar instead, and that is what makes `X_DB_DRIFT`'s `fix:` a real instruction: the hash
105
+ // covers every non-test file under `packages/db/src`, so editing a seed or a helper moves it with
106
+ // no DDL behind it, and the command the error names used to write nothing at all.
107
+ //
108
+ // Nothing is masked, because of what has already been proved above: `loadApp` reported no
109
+ // findings, so the registry is whole rather than short; `declaredSchema` returned a real snapshot
110
+ // rather than `undefined`, so the diff had something to run against; and the emptiness is the
111
+ // generator's OWN verdict — the same call, the same classifier — as the written path. A DDL
112
+ // change that reaches here is a `generateMigration` that missed it, and the sidecar was never the
113
+ // thing that caught that: an author following the fix simply stayed red with nothing left to run.
114
+ if (migration.up.trim().length === 0) {
115
+ const newest = migrations[migrations.length - 1];
116
+ // No migration to record against, which in this branch means no entity is declared either —
117
+ // a registry against zero migrations is `create table` for all of it, never an empty diff.
118
+ if (newest === undefined) return { outcome: 'unchanged', files: [], findings: [] };
119
+ const reconciled = await reconcileSchemaHash(root, newest.id);
120
+ return {
121
+ outcome: reconciled.written ? 'hash-recorded' : 'unchanged',
122
+ schemaHash: reconciled.hash,
123
+ files: reconciled.written ? [`${MIGRATIONS_DIR}/${hashFileName(newest.id)}`] : [],
124
+ findings: [],
125
+ };
126
+ }
96
127
 
97
128
  const dir = join(root, MIGRATIONS_DIR);
98
129
  const sql = `${migration.id}.sql`;
@@ -104,6 +135,7 @@ export async function generateAppMigration(
104
135
  const schemaHash = await writeSchemaHash(root, migration.id);
105
136
 
106
137
  return {
138
+ outcome: 'generated',
107
139
  migration,
108
140
  schemaHash,
109
141
  files: [sql, snapshot, hashFileName(migration.id)].map((file) => `${MIGRATIONS_DIR}/${file}`),