@metaobjectsdev/cli 1.0.9-rc.2 → 1.0.9-rc.4

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 (59) hide show
  1. package/dist/src/commands/eject.d.ts +3 -0
  2. package/dist/src/commands/eject.d.ts.map +1 -1
  3. package/dist/src/commands/eject.js +10 -2
  4. package/dist/src/commands/eject.js.map +1 -1
  5. package/dist/src/commands/gen.d.ts +0 -5
  6. package/dist/src/commands/gen.d.ts.map +1 -1
  7. package/dist/src/commands/gen.js +18 -2
  8. package/dist/src/commands/gen.js.map +1 -1
  9. package/dist/src/commands/migrate.d.ts.map +1 -1
  10. package/dist/src/commands/migrate.js +5 -1
  11. package/dist/src/commands/migrate.js.map +1 -1
  12. package/dist/src/commands/verify.d.ts +13 -1
  13. package/dist/src/commands/verify.d.ts.map +1 -1
  14. package/dist/src/commands/verify.js +64 -11
  15. package/dist/src/commands/verify.js.map +1 -1
  16. package/dist/src/index.d.ts.map +1 -1
  17. package/dist/src/index.js +4 -0
  18. package/dist/src/index.js.map +1 -1
  19. package/dist/src/lib/args.d.ts +12 -0
  20. package/dist/src/lib/args.d.ts.map +1 -1
  21. package/dist/src/lib/args.js +6 -0
  22. package/dist/src/lib/args.js.map +1 -1
  23. package/dist/src/lib/catalog-listing.d.ts +10 -0
  24. package/dist/src/lib/catalog-listing.d.ts.map +1 -1
  25. package/dist/src/lib/catalog-listing.js +43 -7
  26. package/dist/src/lib/catalog-listing.js.map +1 -1
  27. package/dist/src/lib/codegen-drift.d.ts +10 -0
  28. package/dist/src/lib/codegen-drift.d.ts.map +1 -1
  29. package/dist/src/lib/codegen-drift.js +8 -0
  30. package/dist/src/lib/codegen-drift.js.map +1 -1
  31. package/dist/src/lib/config.d.ts +8 -0
  32. package/dist/src/lib/config.d.ts.map +1 -1
  33. package/dist/src/lib/config.js +11 -0
  34. package/dist/src/lib/config.js.map +1 -1
  35. package/dist/src/lib/gen-migrate-advice.d.ts +17 -0
  36. package/dist/src/lib/gen-migrate-advice.d.ts.map +1 -0
  37. package/dist/src/lib/gen-migrate-advice.js +105 -0
  38. package/dist/src/lib/gen-migrate-advice.js.map +1 -0
  39. package/dist/src/lib/install-set.d.ts +5 -1
  40. package/dist/src/lib/install-set.d.ts.map +1 -1
  41. package/dist/src/lib/install-set.js +9 -1
  42. package/dist/src/lib/install-set.js.map +1 -1
  43. package/dist/src/lib/output.d.ts +5 -0
  44. package/dist/src/lib/output.d.ts.map +1 -1
  45. package/dist/src/lib/output.js +1 -1
  46. package/dist/src/lib/output.js.map +1 -1
  47. package/package.json +10 -10
  48. package/src/commands/eject.ts +12 -2
  49. package/src/commands/gen.ts +20 -2
  50. package/src/commands/migrate.ts +5 -0
  51. package/src/commands/verify.ts +66 -10
  52. package/src/index.ts +4 -0
  53. package/src/lib/args.ts +16 -0
  54. package/src/lib/catalog-listing.ts +66 -9
  55. package/src/lib/codegen-drift.ts +17 -0
  56. package/src/lib/config.ts +14 -0
  57. package/src/lib/gen-migrate-advice.ts +129 -0
  58. package/src/lib/install-set.ts +11 -1
  59. package/src/lib/output.ts +6 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@metaobjectsdev/cli",
3
- "version": "1.0.9-rc.2",
3
+ "version": "1.0.9-rc.4",
4
4
  "description": "CLI for MetaObjects: scaffold, codegen, migrate, and drift-detection commands.",
5
5
  "type": "module",
6
6
  "main": "./dist/src/index.js",
@@ -51,15 +51,15 @@
51
51
  "@biomejs/js-api": "^0.7.0",
52
52
  "@biomejs/wasm-nodejs": "^1.9.4",
53
53
  "@libsql/kysely-libsql": "^0.4.0",
54
- "@metaobjectsdev/codegen-ts": "1.0.9-rc.2",
55
- "@metaobjectsdev/codegen-ts-react": "1.0.9-rc.2",
56
- "@metaobjectsdev/codegen-ts-tanstack": "1.0.9-rc.2",
57
- "@metaobjectsdev/docs-site": "1.0.9-rc.2",
58
- "@metaobjectsdev/metadata": "1.0.9-rc.2",
59
- "@metaobjectsdev/migrate-ts": "1.0.9-rc.2",
60
- "@metaobjectsdev/render": "1.0.9-rc.2",
61
- "@metaobjectsdev/runtime-ts": "1.0.9-rc.2",
62
- "@metaobjectsdev/sdk": "1.0.9-rc.2",
54
+ "@metaobjectsdev/codegen-ts": "1.0.9-rc.4",
55
+ "@metaobjectsdev/codegen-ts-react": "1.0.9-rc.4",
56
+ "@metaobjectsdev/codegen-ts-tanstack": "1.0.9-rc.4",
57
+ "@metaobjectsdev/docs-site": "1.0.9-rc.4",
58
+ "@metaobjectsdev/metadata": "1.0.9-rc.4",
59
+ "@metaobjectsdev/migrate-ts": "1.0.9-rc.4",
60
+ "@metaobjectsdev/render": "1.0.9-rc.4",
61
+ "@metaobjectsdev/runtime-ts": "1.0.9-rc.4",
62
+ "@metaobjectsdev/sdk": "1.0.9-rc.4",
63
63
  "@toon-format/toon": "^2.3.0",
64
64
  "jiti": "^2.4.0"
65
65
  },
@@ -142,6 +142,9 @@ export interface EjectResult {
142
142
  packageName: string;
143
143
  /** Advisory lines about packages the ejected file imports but the project lacks. */
144
144
  dependencyNotes: string[];
145
+ /** Every `@metaobjectsdev/*` package the ejected file imports (declared or not) — what
146
+ * the consolidated install summary folds in, so it covers each per-file line. */
147
+ templatePackages: string[];
145
148
  status: "created" | "preserved" | "replaced";
146
149
  /**
147
150
  * How the file ALREADY on disk compares to the reference template this CLI ships.
@@ -231,6 +234,7 @@ export async function ejectGenerator(opts: EjectOptions): Promise<EjectResult> {
231
234
  exportName,
232
235
  packageName: source.packageName,
233
236
  dependencyNotes: notes,
237
+ templatePackages: requiredPackages(templateSource),
234
238
  };
235
239
 
236
240
  const existing = (await fileExists(abs)) ? await readFile(abs, "utf8") : undefined;
@@ -570,6 +574,7 @@ export async function ejectCommand(
570
574
  const catalog = composeCatalog();
571
575
  const rows: EjectedRow[] = [];
572
576
  const libraries: LibraryEjectResult[] = [];
577
+ const templatePackages = new Set<string>();
573
578
  try {
574
579
  for (const name of flags.names) {
575
580
  if (isLibraryName(name)) {
@@ -579,6 +584,7 @@ export async function ejectCommand(
579
584
  continue;
580
585
  }
581
586
  const result = await ejectGenerator({ cwd, name, force: flags.force });
587
+ for (const p of result.templatePackages) templatePackages.add(p);
582
588
  rows.push({
583
589
  name,
584
590
  path: result.path,
@@ -595,12 +601,16 @@ export async function ejectCommand(
595
601
 
596
602
  // ONE install set for the whole call, not one per name: ejecting `hooks` and `grid`
597
603
  // needs @metaobjectsdev/codegen-ts-tanstack once, and an adopter handed the same
598
- // package twice reasonably wonders which line to run.
604
+ // package twice reasonably wonders which line to run. It folds in what the ejected
605
+ // FILES import as well as what the catalog says the generators need: the reference
606
+ // templates import `@metaobjectsdev/metadata` at gen time, which no catalog entry
607
+ // names, so a summary built from the catalog alone printed a line that omitted a
608
+ // package each per-file note had just asked for.
599
609
  const entries = flags.names
600
610
  .filter((n) => !isLibraryName(n))
601
611
  .map((n) => catalog[n])
602
612
  .filter((e) => e !== undefined);
603
- const install = installSetFor(entries);
613
+ const install = installSetFor(entries, [...templatePackages]);
604
614
  const configKeys = [...new Set(entries.flatMap((e) => e.configKeys ?? []))].sort();
605
615
 
606
616
  if (fmt === "text") {
@@ -1,6 +1,7 @@
1
1
  import { relative } from "node:path";
2
2
  import { parseGenArgs } from "../lib/args.js";
3
- import { resolveGenConfig } from "../lib/config.js";
3
+ import { resolveGenConfig, resolveMigrateDefaults } from "../lib/config.js";
4
+ import { genMigrateAdvice } from "../lib/gen-migrate-advice.js";
4
5
  import { loadMemoryOptionsFrom, loadMetaobjectsConfig, resolveGenCollection, resolveGenConfigDir } from "../lib/load-metaobjects-config.js";
5
6
  import { collectionLoadOptions } from "../lib/collection-load-options.js";
6
7
  import { formatGenResult, formatGenResultToon, type GenFileEntry, type GenFileStatus } from "../lib/output.js";
@@ -35,6 +36,9 @@ import { composeCatalog } from "../lib/catalog.js";
35
36
  */
36
37
 
37
38
 
39
+ /** Write outcomes that change a file on disk — the ones that can carry a schema change. */
40
+ const CHANGED_STATUSES: ReadonlySet<WriteStatus> = new Set<WriteStatus>(["new", "overwrite", "merged", "conflict", "removed"]);
41
+
38
42
  export function mapStatus(s: WriteStatus): GenFileStatus {
39
43
  switch (s) {
40
44
  case "new": return "new";
@@ -232,6 +236,17 @@ export async function genCommand(args: string[], cwd: string, fmt: OutputFormat
232
236
  const antiPatterns = runAntiPatternScan(
233
237
  projectRoot, cliConfig.dryRun, flags.noAntipatterns, forgeConfig.verify?.antiPatternIgnore);
234
238
 
239
+ const migrateDefaults = await resolveMigrateDefaults(projectRoot);
240
+ const migrateAdvice = await genMigrateAdvice({
241
+ metadata,
242
+ dialect: forgeConfig.dialect,
243
+ migrateDialect: migrateDefaults.dialect,
244
+ columnNamingStrategy: forgeConfig.columnNamingStrategy,
245
+ projectRoot,
246
+ migrateOutDir: migrateDefaults.outDir,
247
+ changedFiles: result.files.filter((f) => CHANGED_STATUSES.has(f.status)).map((f) => f.path),
248
+ });
249
+
235
250
  const genResult = {
236
251
  files,
237
252
  outDir: targetDirs.length > 1 ? targetDirs.join(", ") : forgeConfig.outDir,
@@ -240,6 +255,7 @@ export async function genCommand(args: string[], cwd: string, fmt: OutputFormat
240
255
  warnings: [],
241
256
  antiPatterns,
242
257
  generatorCount: forgeConfig.generators?.length ?? 0,
258
+ ...(migrateAdvice !== undefined ? { migrateAdvice } : {}),
243
259
  };
244
260
  const output =
245
261
  fmt === "toon" ? formatGenResultToon(genResult)
@@ -412,7 +428,9 @@ async function listCatalogCommand(
412
428
  // codegen config it was moved out of.
413
429
  libraries: genCollection.libraries,
414
430
  },
415
- probe: { metadata, scope: genCollection.inScope },
431
+ // The collection's own files, exactly as `meta gen` supplies them below —
432
+ // `shared-model` cannot be counted without them.
433
+ probe: { metadata, scope: genCollection.inScope, sourceFiles: genCollection.ownFiles },
416
434
  };
417
435
  } else {
418
436
  // No probe: still report `wired` / `owned` when a project happens to be here, since
@@ -19,6 +19,7 @@ import {
19
19
  isBlockedChangesError,
20
20
  isPrimaryKeyChangeError,
21
21
  isDeclaredRenameError,
22
+ isDuplicateSqlNameError,
22
23
  buildExpectedSchemaWithProvenance,
23
24
  scopeExpectedSchema,
24
25
  scopedDiffInputs,
@@ -314,6 +315,8 @@ function refuseEngineError(err: Error, hint: string, fmt: OutputFormat): number
314
315
 
315
316
  const PK_CHANGE_HINT =
316
317
  "align the primary key manually, or reconcile the metadata identity to match the live table";
318
+ const DUPLICATE_SQL_NAME_HINT =
319
+ "rename one of the colliding declarations; an index's database name is its identity.secondary / index.lookup name, not prefixed with its table";
317
320
  const DECLARED_RENAME_HINT =
318
321
  "fix or remove the --rename-table / --rename-column flag; a table's name is its name in the metadata (the new name when it is renamed too)";
319
322
 
@@ -1032,6 +1035,8 @@ export async function migrateCommand(
1032
1035
  // AlreadyEmittedError: sub-function already called emitStructuredError — just
1033
1036
  // propagate the exit code without double-emitting.
1034
1037
  if (err instanceof AlreadyEmittedError) return err.exitCode;
1038
+ // A metadata defect, not a tool failure: two declarations generate one database name.
1039
+ if (isDuplicateSqlNameError(err)) return refuseEngineError(err, DUPLICATE_SQL_NAME_HINT, fmt);
1035
1040
  // Unexpected error: emit structured error on stdout in the active format, then exit 1.
1036
1041
  const msg = (err as Error).message ?? String(err);
1037
1042
  log.error(`migrate: unexpected error: ${msg}`);
@@ -416,12 +416,12 @@ export async function verifyCommand(
416
416
 
417
417
  // Naming a gate runs ONLY the gates named (ADR-0021 D2), so `verify --codegen --db <url>`
418
418
  // exits 0 without ever looking at the prompt templates. A green partial run must not read
419
- // as a full one: say which gates did not run and how to select each. A bare `verify` has
420
- // already said so in its default note above.
421
- if (flags.anyExplicit) {
422
- const note = notRunNote(gates);
423
- if (note !== undefined) say(note);
424
- }
419
+ // as a full one: say which gates did not run and how to select each. The BARE run says so
420
+ // too, at the END where the verdict is read: its opening note scrolls past, and on a model
421
+ // with no templates the only other line was "nothing to check" over a green exit — easy to
422
+ // wire into CI believing it guards everything. Never changes the exit code.
423
+ const note = notRunNote(gates, { bare: !flags.anyExplicit });
424
+ if (note !== undefined) say(note);
425
425
 
426
426
  if (structured) {
427
427
  emitStructured(
@@ -937,7 +937,9 @@ export async function verifyCommand(
937
937
  // ADR-0039: effective children — resolve rather than rely on root being unextended.
938
938
  const templates = root.children().filter((c) => c.type === TYPE_TEMPLATE);
939
939
  if (templates.length === 0) {
940
- say("meta verify — no template.* nodes found; nothing to check.");
940
+ // Names the GATE that had nothing to check — "nothing to check" alone read as a
941
+ // verdict on the whole project.
942
+ say("meta verify — no template.* nodes found; the template gate had nothing to check.");
941
943
  return 0;
942
944
  }
943
945
 
@@ -1450,13 +1452,19 @@ export async function verifyCommand(
1450
1452
  return 2;
1451
1453
  }
1452
1454
 
1455
+ // Hand edits are not drift, but they must never be INVISIBLE: a validator weakened
1456
+ // inside a generated file used to pass this gate with "every file's generated
1457
+ // contribution is current" and nothing else. Reported on every run; fails the build
1458
+ // only under --forbid-hand-edits.
1459
+ const handEditExit = reportHandEdits(result.handEdited);
1460
+
1453
1461
  if (result.clean) {
1454
1462
  // Says what it CHECKED, not more. Since 0.24.3 this gate asks whether each file's
1455
1463
  // GENERATED contribution is current — a hand edit `meta gen` preserves is exempt —
1456
1464
  // so "output is in sync with the metadata" claimed a stronger property than it had
1457
1465
  // verified, and did so over a file whose committed content contradicted the model.
1458
1466
  say("meta verify — every file's generated contribution is current (no codegen drift).");
1459
- return 0;
1467
+ return handEditExit;
1460
1468
  }
1461
1469
 
1462
1470
  log.error(
@@ -1481,6 +1489,23 @@ export async function verifyCommand(
1481
1489
  return 1;
1482
1490
  }
1483
1491
 
1492
+ /** Print the hand-edit notice (or failure, under --forbid-hand-edits); return its exit code. */
1493
+ function reportHandEdits(handEdited: readonly string[]): number {
1494
+ if (handEdited.length === 0) return 0;
1495
+ const [header, ...rest] = handEditNotice(handEdited, flags.forbidHandEdits);
1496
+ const paths = rest.slice(0, handEdited.length);
1497
+ const footer = rest.slice(handEdited.length);
1498
+ if (flags.forbidHandEdits) {
1499
+ // A failure lists every path: truncating the list a build failed on hides the cause.
1500
+ for (const line of [header!, ...paths, ...footer]) log.error(line);
1501
+ return 1;
1502
+ }
1503
+ log.warn(header!);
1504
+ warnCapped(paths, flags.limit, { structured });
1505
+ for (const line of footer) log.warn(line);
1506
+ return 0;
1507
+ }
1508
+
1484
1509
  // -- docs drift -------------------------------------------------------------
1485
1510
  // Gated on --docs. Runs `meta docs` into a temp dir and diffs the committed docs
1486
1511
  // tree. See lib/docs-drift.ts for the deliberate differences from --codegen: a byte
@@ -1683,6 +1708,30 @@ interface VerifyGateRow {
1683
1708
  ok: boolean;
1684
1709
  }
1685
1710
 
1711
+ /**
1712
+ * The hand-edit report for `verify --codegen`: a header with the count, one indented line
1713
+ * per path, then how to see an edit. Exported for its test.
1714
+ *
1715
+ * The recipe regenerates rather than diffing against `.gen-state`, because the snapshot
1716
+ * BODIES are gitignored — on a fresh clone or a CI runner only the hashes exist, which
1717
+ * prove a file was edited but cannot show the edit. Deleting a generated file and
1718
+ * re-running `meta gen` re-emits its pristine generation; git then shows the difference.
1719
+ */
1720
+ export function handEditNotice(paths: readonly string[], forbidden: boolean): string[] {
1721
+ const header = forbidden
1722
+ ? `meta verify — ${paths.length} generated file(s) carry hand edits, and --forbid-hand-edits is set:`
1723
+ : `meta verify — ${paths.length} generated file(s) carry hand edits ` +
1724
+ `(notice — 'meta gen' preserves them by design; does not fail the build — ` +
1725
+ `pass --forbid-hand-edits to make it):`;
1726
+ return [
1727
+ header,
1728
+ ...paths.map((p) => ` ${p}`),
1729
+ "To see an edit: delete the file and run 'meta gen' (it re-emits the pristine generation), " +
1730
+ "then 'git diff -R -- <file>' shows the edit and 'git checkout -- <file>' restores it" +
1731
+ (forbidden ? "; to drop the edit, commit the regenerated file instead." : "."),
1732
+ ];
1733
+ }
1734
+
1686
1735
  /** The flag that selects each gate, for the "not run" note. `requirements` always runs. */
1687
1736
  const GATE_SELECTOR: Record<string, string> = {
1688
1737
  templates: "--templates",
@@ -1697,14 +1746,21 @@ const GATE_SELECTOR: Record<string, string> = {
1697
1746
  * One line naming every gate this run did NOT run, each with the flag that selects it —
1698
1747
  * undefined when every gate ran. Exported for its test.
1699
1748
  */
1700
- export function notRunNote(gates: readonly VerifyGateRow[]): string | undefined {
1749
+ export function notRunNote(
1750
+ gates: readonly VerifyGateRow[],
1751
+ opts: { bare?: boolean } = {},
1752
+ ): string | undefined {
1701
1753
  const skipped = gates.filter((g) => !g.ran);
1702
1754
  if (skipped.length === 0) return undefined;
1703
1755
  const named = skipped.map((g) => {
1704
1756
  const flag = GATE_SELECTOR[g.gate];
1705
1757
  return flag !== undefined ? `${g.gate} (${flag})` : g.gate;
1706
1758
  });
1707
- return `meta verify — not run: ${named.join(", ")}`;
1759
+ const tail = opts.bare === true
1760
+ ? " — a bare 'meta verify' runs only the template gate (plus the requirement ledger); " +
1761
+ "name each gate to run it"
1762
+ : "";
1763
+ return `meta verify — not run: ${named.join(", ")}${tail}`;
1708
1764
  }
1709
1765
 
1710
1766
  /**
package/src/index.ts CHANGED
@@ -270,6 +270,10 @@ FLAGS:
270
270
  An <Entity>.names.ts artifact is the exception: it must match a
271
271
  fresh regen exactly — a hand edit in it is itself the defect.
272
272
  Needs metaobjects.config.ts; exit 2 if absent.
273
+ Every generated file that carries a hand edit is still LISTED
274
+ (a notice, exit code unchanged), so an edit is never invisible.
275
+ --forbid-hand-edits With --codegen: fail (exit 1) on ANY hand edit inside generated
276
+ code, for teams that want generated files untouchable.
273
277
  --docs Docs drift — run 'meta docs' to a temp dir and diff docs.outDir
274
278
  Needs metaobjects.config.ts; exit 2 if absent. A page the
275
279
  project GIT-IGNORES is exempt (docs.outDir is a directory, not
package/src/lib/args.ts CHANGED
@@ -279,6 +279,14 @@ export interface VerifyFlags {
279
279
  templates: boolean;
280
280
  /** Run the codegen-drift gate: is the GENERATED contribution of each file current? */
281
281
  codegen: boolean;
282
+ /**
283
+ * With `--codegen`: fail the build on any hand edit inside a generated file, for teams
284
+ * that want generated code untouchable. Off by default, because hand edits are the
285
+ * documented workflow (`meta gen` three-way-merges them) — without it they are
286
+ * reported as a notice and never change the exit code. Refused without `--codegen`:
287
+ * it modifies that gate and would otherwise parse cleanly and do nothing.
288
+ */
289
+ forbidHandEdits: boolean;
282
290
  /**
283
291
  * Run the docs-drift gate: regenerate the `meta docs` surfaces into a temp dir and diff
284
292
  * them against the committed docs tree.
@@ -370,6 +378,7 @@ export const VERIFY_OPTIONS = {
370
378
  "skip-schema": { type: "boolean", default: false },
371
379
  templates: { type: "boolean", default: false },
372
380
  codegen: { type: "boolean", default: false },
381
+ "forbid-hand-edits": { type: "boolean", default: false },
373
382
  docs: { type: "boolean", default: false },
374
383
  deps: { type: "boolean", default: false },
375
384
  replay: { type: "boolean", default: false },
@@ -427,6 +436,12 @@ export function parseVerifyArgs(argv: string[]): VerifyFlags {
427
436
 
428
437
  const templates = !!values.templates;
429
438
  const codegen = !!values.codegen;
439
+ const forbidHandEdits = !!values["forbid-hand-edits"];
440
+ if (forbidHandEdits && !codegen) {
441
+ throw new Error(
442
+ "--forbid-hand-edits only applies to the codegen gate. Run: meta verify --codegen --forbid-hand-edits",
443
+ );
444
+ }
430
445
  const docs = !!values.docs;
431
446
  const deps = !!values.deps;
432
447
  const replay = !!values.replay;
@@ -448,6 +463,7 @@ export function parseVerifyArgs(argv: string[]): VerifyFlags {
448
463
  skipSchema: !!values["skip-schema"],
449
464
  templates,
450
465
  codegen,
466
+ forbidHandEdits,
451
467
  docs,
452
468
  deps,
453
469
  replay,
@@ -77,6 +77,9 @@ export interface GeneratorCatalogRow {
77
77
  wouldEmit: number | null;
78
78
  /** Why `wouldEmit` is null despite --probe. Absent when it is a number. */
79
79
  probeError?: string;
80
+ /** Config keys this project leaves unset that the generator needs before `meta gen`
81
+ * can run it. `wouldEmit` was counted with a placeholder for each. Absent when none. */
82
+ needsConfig?: string[];
80
83
  };
81
84
  }
82
85
 
@@ -164,7 +167,17 @@ export interface CatalogProject {
164
167
  export interface CatalogListingOpts {
165
168
  project?: CatalogProject;
166
169
  /** Run every generator in memory and report a real file count. Needs `project` + `metadata`. */
167
- probe?: { metadata: MetaData; scope?: (fqn: string) => boolean };
170
+ probe?: {
171
+ metadata: MetaData;
172
+ scope?: (fqn: string) => boolean;
173
+ /**
174
+ * The collection's OWN source files — the same list `meta gen` threads onto
175
+ * `ctx.sourceFiles`. `shared-model` loads them standalone to build its artifact, so
176
+ * without them the probe asked it to publish nothing and reported the refusal as
177
+ * "probe failed" on every fresh project, for a generator that would in fact run.
178
+ */
179
+ sourceFiles?: readonly string[];
180
+ };
168
181
  }
169
182
 
170
183
  /**
@@ -180,21 +193,46 @@ async function probeOne(
180
193
  project: CatalogProject,
181
194
  metadata: MetaData,
182
195
  scope: ((fqn: string) => boolean) | undefined,
183
- ): Promise<{ count: number } | { error: string }> {
184
- try {
196
+ sourceFiles: readonly string[] | undefined,
197
+ ): Promise<{ count: number; needsConfig?: string[] } | { error: string }> {
198
+ const count = async (config: MetaobjectsGenConfig): Promise<number> => {
185
199
  const result = await runGen({
186
- config: { ...project.config, generators: [entry.factory()] },
200
+ config: { ...config, generators: [entry.factory()] },
187
201
  metadata,
188
202
  projectRoot: project.projectRoot,
189
203
  dryRun: true,
190
204
  ...(scope !== undefined ? { scope } : {}),
205
+ ...(sourceFiles !== undefined ? { sourceFiles } : {}),
191
206
  });
192
- return { count: result.files.length };
207
+ return result.files.length;
208
+ };
209
+ try {
210
+ return { count: await count(project.config) };
193
211
  } catch (err) {
212
+ // A generator that emits `import { db } from …` (the Fastify `routes`, `queries`)
213
+ // refuses to run while `dbImport` is unset — which it is in a freshly initialised
214
+ // project, exactly when `--probe` is being asked "what would this emit?". The path
215
+ // changes one import line, never the file count, so count with a placeholder and
216
+ // report the key as needed rather than dropping the count: the listing used to show
217
+ // `routes … [fastify]` beside `routes-hono … [hono, would emit 3]` with no reason.
218
+ // Only a retry that SUCCEEDS with the key supplied attributes the failure to it.
219
+ if (project.config.dbImport === undefined) {
220
+ try {
221
+ return {
222
+ count: await count({ ...project.config, dbImport: PROBE_DB_IMPORT_PLACEHOLDER }),
223
+ needsConfig: ["dbImport"],
224
+ };
225
+ } catch {
226
+ // Fall through: the original error is the one worth reporting.
227
+ }
228
+ }
194
229
  return { error: (err as Error).message };
195
230
  }
196
231
  }
197
232
 
233
+ /** Stand-in `dbImport` for a probe. Never written anywhere — `dryRun` touches no file. */
234
+ const PROBE_DB_IMPORT_PLACEHOLDER = "./db";
235
+
198
236
  // ---------------------------------------------------------------------------
199
237
  // the listing
200
238
  // ---------------------------------------------------------------------------
@@ -241,10 +279,19 @@ export async function buildCatalogListing(opts: CatalogListingOpts = {}): Promis
241
279
 
242
280
  let wouldEmit: number | null = null;
243
281
  let probeError: string | undefined;
282
+ let needsConfig: string[] | undefined;
244
283
  if (opts.probe !== undefined) {
245
- const outcome = await probeOne(entry, project, opts.probe.metadata, opts.probe.scope);
246
- if ("count" in outcome) wouldEmit = outcome.count;
247
- else probeError = outcome.error;
284
+ const outcome = await probeOne(
285
+ entry,
286
+ project,
287
+ opts.probe.metadata,
288
+ opts.probe.scope,
289
+ opts.probe.sourceFiles,
290
+ );
291
+ if ("count" in outcome) {
292
+ wouldEmit = outcome.count;
293
+ needsConfig = outcome.needsConfig;
294
+ } else probeError = outcome.error;
248
295
  }
249
296
 
250
297
  row.project = {
@@ -252,6 +299,7 @@ export async function buildCatalogListing(opts: CatalogListingOpts = {}): Promis
252
299
  frameworkDetected,
253
300
  wouldEmit,
254
301
  ...(probeError !== undefined ? { probeError } : {}),
302
+ ...(needsConfig !== undefined ? { needsConfig } : {}),
255
303
  };
256
304
  }
257
305
 
@@ -361,7 +409,16 @@ export function renderCatalogText(rows: CatalogRow[], probed: boolean): string {
361
409
  if (r.project?.wired) marks.push("WIRED");
362
410
  if (r.source.owned) marks.push("owned");
363
411
  if (probed && r.project?.wouldEmit !== null && r.project?.wouldEmit !== undefined) {
364
- marks.push(`would emit ${r.project.wouldEmit}`);
412
+ const needs = r.project.needsConfig;
413
+ marks.push(
414
+ needs !== undefined && needs.length > 0
415
+ ? `would emit ${r.project.wouldEmit} once ${needs.join(", ")} is set`
416
+ : `would emit ${r.project.wouldEmit}`,
417
+ );
418
+ } else if (probed && r.project?.probeError !== undefined) {
419
+ // Say that the probe could not count it, not nothing: a missing count read as
420
+ // "emits nothing" beside a sibling that showed one.
421
+ marks.push("probe failed — see --format json");
365
422
  }
366
423
  const suffix = marks.length > 0 ? ` [${marks.join(", ")}]` : "";
367
424
  lines.push(` ${r.name.padEnd(width)} — ${r.description}${suffix}`);
@@ -68,6 +68,16 @@ export interface CodegenDriftResult {
68
68
  * to end. Two doors asking one question must not answer it twice.
69
69
  */
70
70
  handEditedNames: string[];
71
+ /**
72
+ * Project-relative paths of generated files that carry a PRESERVED hand edit, sorted:
73
+ * the generated contribution is current (a fresh regen hashes to what we recorded
74
+ * writing) but the committed file differs from it. Not drift — `meta gen` keeps these
75
+ * edits by design — so they never set `clean` to false. Reported so the edits are
76
+ * VISIBLE: a validator weakened inside a generated file used to pass this gate in
77
+ * silence. The command decides whether they fail the build (`--forbid-hand-edits`).
78
+ * Never overlaps `driftedFiles`.
79
+ */
80
+ handEdited: string[];
71
81
  /** Set when the gate could not run (e.g. no outDir to compare against). */
72
82
  error?: string;
73
83
  }
@@ -138,6 +148,7 @@ export async function computeCodegenDrift(
138
148
  driftedFiles: [],
139
149
  lines: [],
140
150
  handEditedNames: [],
151
+ handEdited: [],
141
152
  error:
142
153
  "verify --codegen: no outDir configured — cannot locate the committed " +
143
154
  "generated output to diff against. Set 'outDir' (and/or per-target " +
@@ -207,6 +218,7 @@ export async function computeCodegenDrift(
207
218
  const driftedFiles = new Set<string>();
208
219
  const lines: string[] = [];
209
220
  const handEditedNames = new Set<string>();
221
+ const handEdited = new Set<string>();
210
222
 
211
223
  // Whether we have any record of what we wrote here. With records, the orphan
212
224
  // branch below can scope itself to our own output; with none, it cannot tell
@@ -286,6 +298,10 @@ export async function computeCodegenDrift(
286
298
  // nothing is proven, so the old byte verdict stands.
287
299
  driftedFiles.add(relKey);
288
300
  lines.push(`~ ${relKey} (committed content differs from a fresh regen)`);
301
+ } else {
302
+ // The generated contribution is current and the bytes still differ: a
303
+ // hand edit `meta gen` preserved. Not drift, but never silent either.
304
+ handEdited.add(relKey);
289
305
  }
290
306
  }
291
307
  }
@@ -298,6 +314,7 @@ export async function computeCodegenDrift(
298
314
  driftedFiles: sorted,
299
315
  lines,
300
316
  handEditedNames: [...handEditedNames].sort(),
317
+ handEdited: [...handEdited].sort(),
301
318
  };
302
319
  } finally {
303
320
  rmSync(tempRoot, { recursive: true, force: true });
package/src/lib/config.ts CHANGED
@@ -82,6 +82,20 @@ async function tryLoadConfig(metaRoot: string): Promise<Config | undefined> {
82
82
  // Public resolvers
83
83
  // ---------------------------------------------------------------------------
84
84
 
85
+ /**
86
+ * The migrations directory and dialect `meta migrate` would use with no flags — what
87
+ * `meta gen` needs to word its "now migrate" next step and find the committed snapshot.
88
+ */
89
+ export async function resolveMigrateDefaults(
90
+ metaRoot: string,
91
+ ): Promise<{ outDir: string; dialect: Dialect | undefined }> {
92
+ const config = await tryLoadConfig(metaRoot);
93
+ return {
94
+ outDir: config?.migrate?.outDir ?? MIGRATE_DEFAULTS.outDir,
95
+ dialect: config?.migrate?.dialect ?? MIGRATE_DEFAULTS.dialect,
96
+ };
97
+ }
98
+
85
99
  export function resolveGenConfig(flags: GenFlags): ResolvedGenConfig {
86
100
  return { dryRun: flags.dryRun, entities: flags.entities };
87
101
  }