@metaobjectsdev/cli 0.23.2 → 0.24.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 (89) hide show
  1. package/README.md +78 -0
  2. package/dist/src/commands/docs.d.ts.map +1 -1
  3. package/dist/src/commands/docs.js +148 -50
  4. package/dist/src/commands/docs.js.map +1 -1
  5. package/dist/src/commands/export.d.ts.map +1 -1
  6. package/dist/src/commands/export.js +28 -6
  7. package/dist/src/commands/export.js.map +1 -1
  8. package/dist/src/commands/gen.d.ts.map +1 -1
  9. package/dist/src/commands/gen.js +61 -22
  10. package/dist/src/commands/gen.js.map +1 -1
  11. package/dist/src/commands/init.d.ts +8 -0
  12. package/dist/src/commands/init.d.ts.map +1 -1
  13. package/dist/src/commands/init.js +142 -52
  14. package/dist/src/commands/init.js.map +1 -1
  15. package/dist/src/commands/migrate.d.ts +9 -2
  16. package/dist/src/commands/migrate.d.ts.map +1 -1
  17. package/dist/src/commands/migrate.js +399 -73
  18. package/dist/src/commands/migrate.js.map +1 -1
  19. package/dist/src/commands/prompt-snapshot.d.ts.map +1 -1
  20. package/dist/src/commands/prompt-snapshot.js +40 -18
  21. package/dist/src/commands/prompt-snapshot.js.map +1 -1
  22. package/dist/src/commands/upgrade.d.ts +2 -0
  23. package/dist/src/commands/upgrade.d.ts.map +1 -0
  24. package/dist/src/commands/upgrade.js +139 -0
  25. package/dist/src/commands/upgrade.js.map +1 -0
  26. package/dist/src/commands/verify.d.ts.map +1 -1
  27. package/dist/src/commands/verify.js +336 -48
  28. package/dist/src/commands/verify.js.map +1 -1
  29. package/dist/src/index.d.ts.map +1 -1
  30. package/dist/src/index.js +46 -15
  31. package/dist/src/index.js.map +1 -1
  32. package/dist/src/lib/allow.d.ts.map +1 -1
  33. package/dist/src/lib/allow.js +4 -0
  34. package/dist/src/lib/allow.js.map +1 -1
  35. package/dist/src/lib/args.d.ts +16 -2
  36. package/dist/src/lib/args.d.ts.map +1 -1
  37. package/dist/src/lib/args.js +19 -3
  38. package/dist/src/lib/args.js.map +1 -1
  39. package/dist/src/lib/codegen-drift.d.ts +8 -1
  40. package/dist/src/lib/codegen-drift.d.ts.map +1 -1
  41. package/dist/src/lib/codegen-drift.js +9 -1
  42. package/dist/src/lib/codegen-drift.js.map +1 -1
  43. package/dist/src/lib/detect-stack.d.ts +1 -1
  44. package/dist/src/lib/detect-stack.d.ts.map +1 -1
  45. package/dist/src/lib/detect-stack.js +22 -37
  46. package/dist/src/lib/detect-stack.js.map +1 -1
  47. package/dist/src/lib/load-metaobjects-config.d.ts +48 -1
  48. package/dist/src/lib/load-metaobjects-config.d.ts.map +1 -1
  49. package/dist/src/lib/load-metaobjects-config.js +80 -1
  50. package/dist/src/lib/load-metaobjects-config.js.map +1 -1
  51. package/dist/src/lib/manifest-ignored-check.d.ts +12 -0
  52. package/dist/src/lib/manifest-ignored-check.d.ts.map +1 -0
  53. package/dist/src/lib/manifest-ignored-check.js +74 -0
  54. package/dist/src/lib/manifest-ignored-check.js.map +1 -0
  55. package/dist/src/lib/migrate-scope.d.ts +49 -0
  56. package/dist/src/lib/migrate-scope.d.ts.map +1 -0
  57. package/dist/src/lib/migrate-scope.js +92 -0
  58. package/dist/src/lib/migrate-scope.js.map +1 -0
  59. package/dist/src/lib/output.d.ts +1 -1
  60. package/dist/src/lib/output.d.ts.map +1 -1
  61. package/dist/src/lib/output.js +9 -3
  62. package/dist/src/lib/output.js.map +1 -1
  63. package/dist/src/lib/requirement-check.d.ts.map +1 -1
  64. package/dist/src/lib/requirement-check.js +10 -57
  65. package/dist/src/lib/requirement-check.js.map +1 -1
  66. package/package.json +11 -11
  67. package/src/commands/docs.ts +158 -49
  68. package/src/commands/export.ts +30 -6
  69. package/src/commands/gen.ts +62 -22
  70. package/src/commands/init.ts +161 -58
  71. package/src/commands/migrate.ts +423 -75
  72. package/src/commands/prompt-snapshot.ts +43 -18
  73. package/src/commands/upgrade.ts +156 -0
  74. package/src/commands/verify.ts +373 -48
  75. package/src/index.ts +46 -15
  76. package/src/lib/allow.ts +4 -0
  77. package/src/lib/args.ts +35 -4
  78. package/src/lib/codegen-drift.ts +9 -0
  79. package/src/lib/detect-stack.ts +20 -33
  80. package/src/lib/load-metaobjects-config.ts +84 -2
  81. package/src/lib/manifest-ignored-check.ts +75 -0
  82. package/src/lib/migrate-scope.ts +102 -0
  83. package/src/lib/output.ts +8 -4
  84. package/src/lib/requirement-check.ts +10 -58
  85. package/dist/src/lib/verified-by-scan.d.ts +0 -20
  86. package/dist/src/lib/verified-by-scan.d.ts.map +0 -1
  87. package/dist/src/lib/verified-by-scan.js +0 -344
  88. package/dist/src/lib/verified-by-scan.js.map +0 -1
  89. package/src/lib/verified-by-scan.ts +0 -375
@@ -11,13 +11,13 @@ import { join, resolve as resolvePath } from "node:path";
11
11
  import { parseVerifyArgs, type MigrateFlags } from "../lib/args.js";
12
12
  import { log } from "../lib/log.js";
13
13
  import { warnIfAgentContextStale } from "../lib/agent-context-staleness.js";
14
+ import { warnIfManifestIgnored } from "../lib/manifest-ignored-check.js";
14
15
  import { scanSourceForAntiPatterns } from "../lib/anti-patterns.js";
15
16
  import { FileProvider } from "../lib/file-provider.js";
16
17
  import { derivePayloadFieldTree } from "../lib/payload-field-tree.js";
17
- import { loadMetaobjectsConfig } from "../lib/load-metaobjects-config.js";
18
+ import { loadMemoryOptionsFrom, loadMetaobjectsConfig, resolveGenConfigDir } from "../lib/load-metaobjects-config.js";
18
19
  import { computeCodegenDrift } from "../lib/codegen-drift.js";
19
20
  import { checkRequirements, summariseRequirements } from "../lib/requirement-check.js";
20
- import { checkVerifiedBy } from "../lib/verified-by-scan.js";
21
21
  import { resolveD1Config, resolveMigrateConfig } from "../lib/config.js";
22
22
  import {
23
23
  buildWranglerExecuteArgs,
@@ -27,12 +27,22 @@ import {
27
27
  } from "../lib/wrangler.js";
28
28
  import type { MetaobjectsGenConfig } from "@metaobjectsdev/codegen-ts";
29
29
  import { buildProjectionViews } from "@metaobjectsdev/codegen-ts";
30
- import { buildKyselyFromUrl, type Dialect } from "../lib/kysely.js";
30
+ import { buildKyselyFromUrl, inferDialect, type Dialect } from "../lib/kysely.js";
31
31
  import { tokensToAllowOptions, describeChange } from "../lib/allow.js";
32
32
  import {
33
33
  computeDrift,
34
34
  computeDriftFromActual,
35
35
  collectUnmanagedNames,
36
+ excludeFromSnapshot,
37
+ scopedDiffInputs,
38
+ scopeExpectedSchema,
39
+ buildExpectedSchemaWithProvenance,
40
+ type GovernedScope,
41
+ applyPending,
42
+ type ApplyPendingResult,
43
+ openReplayEngine,
44
+ type ReplayEngine,
45
+ verifyReplay,
36
46
  introspect,
37
47
  diff,
38
48
  readSnapshot,
@@ -45,9 +55,10 @@ import {
45
55
  type Change,
46
56
  type D1Binding,
47
57
  type D1Runner,
48
- type DiffResult,
58
+ type DriftResult,
49
59
  } from "@metaobjectsdev/migrate-ts";
50
- import { loadMemory } from "@metaobjectsdev/sdk";
60
+ import { loadMemory, resolveCollection } from "@metaobjectsdev/sdk";
61
+ import { migrateScopeMismatch, outOfScopeNote } from "../lib/migrate-scope.js";
51
62
  import {
52
63
  TYPE_TEMPLATE,
53
64
  TEMPLATE_SUBTYPE_PROMPT,
@@ -62,6 +73,7 @@ import {
62
73
  TEMPLATE_ATTR_SUBJECT_REF,
63
74
  TEMPLATE_ATTR_HTML_BODY_REF,
64
75
  TEMPLATE_ATTR_TEXT_BODY_REF,
76
+ REQUIREMENT_STATUSES,
65
77
  } from "@metaobjectsdev/metadata";
66
78
  import { verify, ERR_REQUIRED_SLOT_UNUSED, ERR_PARTIAL_UNRESOLVED } from "@metaobjectsdev/render";
67
79
 
@@ -74,9 +86,14 @@ const ERR_UNKNOWN_ATTR = "ERR_UNKNOWN_ATTR";
74
86
 
75
87
  /**
76
88
  * A no-flags MigrateFlags, so `resolveMigrateConfig` yields exactly what `meta migrate`
77
- * would use with nothing passed on the command line — config value, else default. verify
78
- * consumes only `outDir` from the result (#292); the other fields exist to satisfy the
79
- * shared shape, and reading any of them here would be reaching into migrate's decisions.
89
+ * would use with nothing passed on the command line — config value, else default.
90
+ *
91
+ * verify consumes `outDir` (#292) and for the replay gate ONLY `dialect`. The #292
92
+ * restriction that reading anything else "would be reaching into migrate's decisions"
93
+ * was written about the DRIFT gate, whose dialect comes from the live `--db` URL. The
94
+ * replay gate has no `--db` at all, and the dialect a committed chain was EMITTED for
95
+ * is a migrate decision by definition, so migrate's own resolution is the only correct
96
+ * source for it. Everything else here exists to satisfy the shared shape.
80
97
  */
81
98
  const EMPTY_MIGRATE_FLAGS = {
82
99
  db: undefined, dialect: undefined, format: undefined, outDir: undefined, slug: undefined,
@@ -107,9 +124,6 @@ export async function verifyCommand(
107
124
  return 2;
108
125
  }
109
126
 
110
- // Advisory: nudge to refresh the .claude/skills docs if they predate this CLI.
111
- warnIfAgentContextStale(cwd);
112
-
113
127
  // ADR-0021 D2 — explicit verify subverbs. Each flag selects one drift mode;
114
128
  // any combination runs each and the overall exit code is the MAX (non-zero on
115
129
  // any drift). A bare `verify` (no explicit subverb) keeps its documented
@@ -122,10 +136,63 @@ export async function verifyCommand(
122
136
  if (!flags.anyExplicit) {
123
137
  log.info(
124
138
  "meta verify — running --templates (default). Explicit subverbs: " +
125
- "--templates (prompt drift), --db/--dialect d1 (schema drift), --codegen (codegen drift).",
139
+ "--templates (prompt drift), --db/--dialect d1 (schema drift), --codegen (codegen drift), " +
140
+ "--replay/--replay-snapshot (the committed migration chain replays from empty).",
126
141
  );
127
142
  }
128
143
 
144
+ // Where the metadata lives is `resolveCollection`'s decision, not a hardcoded
145
+ // directory. It also carries the per-command `migrate.scope` the schema gate below
146
+ // honours — `verify --db` and `migrate` govern the identical object set — and the
147
+ // top-level `scope` `runCodegenVerify` (a nested function below) threads into
148
+ // `computeCodegenDrift`. Explicitly typed (unlike the `let collection;` pattern
149
+ // elsewhere in this codebase): a nested function body is OUTSIDE the control-flow
150
+ // narrowing TS performs on a same-scope `let x;` reassignment, so a bare
151
+ // `let collection;` type-checked clean until this task added exactly that nested
152
+ // reference — the reader who removes the annotation next reintroduces TS7034.
153
+ let collection: Awaited<ReturnType<typeof resolveCollection>>;
154
+ try {
155
+ collection = await resolveCollection(cwd);
156
+ } catch (err) {
157
+ log.error((err as Error).message);
158
+ return 2;
159
+ }
160
+
161
+ // The project root is whichever directory `resolveCollection` decided the
162
+ // metadata belongs to (design §4.6.1: "Per-port generator config is then read
163
+ // from that same directory"). The line this draws, applied throughout this
164
+ // command: anything named BY the metadata or by `.metaobjects/config.json`
165
+ // resolves against `projectRoot` — that file's operational block, the migrations
166
+ // `outDir` and `wranglerConfigPath` it carries, the `prompts/` a `@textRef`
167
+ // resolves in. Identical paths for a run from the project root.
168
+ //
169
+ // The two advisory passes — the agent-context staleness nudge and the
170
+ // anti-pattern scan — are rooted here too, matching `meta gen`. Both commands
171
+ // describe them as the same pass, and scanning two different trees for it made
172
+ // that false: a `verify` run from a subdirectory scanned only that subtree and
173
+ // found no agent-context manifest at all, so the nudge silently never fired.
174
+ const projectRoot = collection.configDir;
175
+
176
+ // The one thing NOT named by the metadata or its config: `metaobjects.config.ts`
177
+ // is this TypeScript package's own answer to a different question (design §4.6),
178
+ // so it gets its own nearest-ancestor walk and everything IT names follows —
179
+ // `outDir`/`targets` for `--codegen`. (It also carried `verify.testFiles` until
180
+ // 0.24.0 retired the `@verifiedBy` scan; both are gone.) In a
181
+ // Maven- or pip-rooted monorepo the collection is declared at the repo root while
182
+ // the TS config sits in the app; reading the second from the first made
183
+ // `--codegen` report "no config" for a package that has one (#326). Identical to
184
+ // `projectRoot` whenever the two files sit together, which is every `meta init`
185
+ // project.
186
+ const genConfigDir = resolveGenConfigDir(cwd, collection.configDir);
187
+
188
+ // Advisory: nudge to refresh the .claude/skills docs if they predate this CLI.
189
+ warnIfAgentContextStale(projectRoot);
190
+ // Advisory: the committed hash manifest is what makes hand-edit detection work on a
191
+ // machine that did not generate the output. Silent unless it is ignored. Keyed on
192
+ // projectRoot, not cwd, for the same reason its neighbour is — the manifest belongs to
193
+ // whichever directory `resolveCollection` decided the metadata lives in.
194
+ warnIfManifestIgnored(projectRoot);
195
+
129
196
  // Best-effort load of metaobjects.config.ts. Two consumers:
130
197
  // 1) consumer-supplied providers (e.g. a `template.toolcall` subtype) threaded
131
198
  // into loadMemory — verify doesn't REQUIRE codegen config for templates/db;
@@ -134,26 +201,26 @@ export async function verifyCommand(
134
201
  // error (it can't diff without knowing where the committed output lives).
135
202
  let forgeConfig: MetaobjectsGenConfig | undefined;
136
203
  try {
137
- forgeConfig = await loadMetaobjectsConfig(cwd);
204
+ forgeConfig = await loadMetaobjectsConfig(genConfigDir);
138
205
  } catch {
139
206
  forgeConfig = undefined;
140
207
  }
141
- const configProviders = forgeConfig?.providers;
208
+ // Both of the gen config's contributions to the load, together — see
209
+ // `loadMemoryOptionsFrom`. Threading `providers` and forgetting `libraries` is how
210
+ // a shipped library became unloadable through the CLI (#333).
211
+ const configLoadOptions = loadMemoryOptionsFrom(forgeConfig);
142
212
 
143
213
  // ADR-0023 strict-by-default (#96): verify loads strict unless --lax is passed,
144
214
  // so an undeclared/typo'd own @attr fails verify (matching Java's Maven goal).
145
215
  let root: Awaited<ReturnType<typeof loadMemory>>;
146
216
  try {
147
- root = await loadMemory(cwd, {
148
- ...(configProviders !== undefined ? { providers: configProviders } : {}),
217
+ root = await loadMemory(collection.configDir, {
218
+ files: collection.files,
219
+ ...configLoadOptions,
149
220
  strict: !flags.lax,
150
221
  });
151
222
  } catch (err) {
152
223
  const msg = (err as Error).message;
153
- if (msg.includes("ENOENT") || msg.includes("no such") || msg.includes("cannot read")) {
154
- log.error(`no metaobjects/ found in ${cwd}; run 'meta init' to scaffold`);
155
- return 2;
156
- }
157
224
  log.error(`failed to load metadata: ${msg}`);
158
225
  // Strict-load rejection (ADR-0023): give the author the three exits — register
159
226
  // the attr on a provider, stash it in the `attr.properties` bag, or pass --lax.
@@ -169,7 +236,13 @@ export async function verifyCommand(
169
236
  return 1;
170
237
  }
171
238
 
172
- const promptsDir = join(cwd, flags.prompts ?? DEFAULT_PROMPTS_DIR);
239
+ // The schema gate governs exactly the objects `meta migrate` governs — ONE
240
+ // declaration (`migrate.scope`), not a second key: a drift gate that fails on
241
+ // tables migrate deliberately does not own is incoherent. Undefined ⇒ everything
242
+ // loaded, which is every project that declares no scope.
243
+ const schemaScope = collection.inMigrateScope;
244
+
245
+ const promptsDir = join(projectRoot, flags.prompts ?? DEFAULT_PROMPTS_DIR);
173
246
  const provider = new FileProvider(promptsDir);
174
247
 
175
248
  // Exit-code composition: the overall result is the MAX across every selected
@@ -182,6 +255,11 @@ export async function verifyCommand(
182
255
  // are checked on every `meta verify`. Opt-in by DECLARATION — a model with no
183
256
  // requirement nodes is silent, not in drift.
184
257
  const requirementExit = runRequirementVerify();
258
+ // #313 — BOTH replay flags select this gate. `--replay-snapshot` implies
259
+ // `--replay`'s work, so a broken chain must fail under it even when `--replay`
260
+ // was not passed; naming only `flags.replay` here is how `--replay-snapshot`
261
+ // would parse cleanly and do nothing at all.
262
+ const replayExit = flags.replay || flags.replaySnapshot ? await runReplayVerify() : 0;
185
263
 
186
264
  // Advisory verify-as-teacher pass: surface hand-rolled work the metadata could
187
265
  // model. Warnings ONLY — never changes the exit code (bias to under-flagging).
@@ -189,33 +267,223 @@ export async function verifyCommand(
189
267
  // noisy project (both opt-outs work on `meta verify` and `meta gen`).
190
268
  if (!flags.noAntipatterns && process.env.META_NO_ANTIPATTERNS !== "1") runAntiPatternAdvisory();
191
269
 
192
- return Math.max(templateExit, schemaExit, codegenExit, requirementExit);
270
+ return Math.max(templateExit, schemaExit, codegenExit, requirementExit, replayExit);
271
+
272
+ // -- replay (#313) ---------------------------------------------------------
273
+ /**
274
+ * Replay the committed migration chain into an EMPTY throwaway database and assert
275
+ * it applies. `--replay-snapshot` additionally asserts the result equals the
276
+ * committed snapshot.
277
+ *
278
+ * This exists because `meta migrate` could write a chain that cannot be replayed —
279
+ * a bare `DROP TABLE "x"` for an object no migration ever created — and nothing
280
+ * noticed until someone tried to provision a fresh database, which for the reporter
281
+ * was three months later. The two tiers are separate because a project adopted via
282
+ * `migrate baseline --from-db` passes the first trivially and CANNOT pass the second
283
+ * by construction: its snapshot is the whole introspected database and its chain is
284
+ * empty.
285
+ *
286
+ * Exit codes follow verify's convention: a chain that fails to apply, or a snapshot
287
+ * mismatch, is drift → 1; an engine that will not start is operational → 2.
288
+ */
289
+ async function runReplayVerify(): Promise<number> {
290
+ // Resolve the migrations directory and the chain's dialect through MIGRATE's own
291
+ // precedence, never a second derivation — verify must not look somewhere migrate
292
+ // does not write, nor assume a dialect the chain was not emitted for.
293
+ const migrateConfig = await resolveMigrateConfig(EMPTY_MIGRATE_FLAGS, projectRoot);
294
+
295
+ if (migrateConfig.format === "flyway") {
296
+ log.error(
297
+ `meta verify --replay is not supported with --migration-format flyway — run 'flyway migrate' against a scratch database to replay`,
298
+ );
299
+ return 2;
300
+ }
301
+
302
+ // --dialect wins; else migrate's resolved dialect; else refuse. There is no --db
303
+ // to infer from, so guessing would replay a postgres chain through sqlite.
304
+ const dialect: Dialect | undefined = flags.dialect ?? migrateConfig.dialect;
305
+ if (dialect === undefined) {
306
+ log.error(
307
+ `meta verify --replay: no dialect — pass --dialect <postgres|sqlite>, or set migrate.dialect in .metaobjects/config.json`,
308
+ );
309
+ return 2;
310
+ }
311
+ if (dialect === "d1") {
312
+ log.error(
313
+ `meta verify --replay is not supported for dialect 'd1' — use 'wrangler d1 migrations apply' against a scratch database to replay committed migrations`,
314
+ );
315
+ return 2;
316
+ }
317
+
318
+ const dir = resolvePath(projectRoot, migrateConfig.outDir);
319
+
320
+ let engine: ReplayEngine;
321
+ try {
322
+ engine = await openReplayEngine(dialect);
323
+ } catch (err) {
324
+ // A missing optional driver lands here, and its message already carries the
325
+ // install hint. Operational, not drift.
326
+ log.error(`meta verify --replay: ${(err as Error).message}`);
327
+ return 2;
328
+ }
329
+
330
+ try {
331
+ let applied: ApplyPendingResult;
332
+ try {
333
+ applied = await applyPending(engine.db, dir, { dryRun: false, dialect });
334
+ } catch (err) {
335
+ log.error(`meta verify --replay: ${(err as Error).message}`);
336
+ log.error(
337
+ `meta verify --replay: the committed chain does not apply to an empty database. ` +
338
+ `Applied migrations are immutable, so fix this with a NEW migration that creates the ` +
339
+ `missing object — not by editing a committed up.sql.`,
340
+ );
341
+ return 1;
342
+ }
343
+
344
+ // Not a silent pass. `discoverMigrations` returns [] for a missing directory, so
345
+ // a run over an empty chain would otherwise "succeed" having proved nothing —
346
+ // and a gate that is quiet when it checked nothing cannot be told from one that
347
+ // passed. Every migration is pending against a fresh engine, so an empty
348
+ // `pending` means the directory held none.
349
+ //
350
+ // This return is for TIER 1 ONLY: an empty chain trivially "applies" (there is
351
+ // nothing that could fail), so tier 1 is done. Tier 2 is NOT done — its job is
352
+ // "does the replay reproduce the committed snapshot?", and a wrong
353
+ // `migrate.outDir` or a project adopted via `migrate baseline --from-db` (whose
354
+ // own chain is empty by construction) both look identical to this point. Return
355
+ // ONLY when `--replay-snapshot` was not requested; otherwise fall through so an
356
+ // empty replay is still compared against a snapshot that may record dozens of
357
+ // tables, rather than reporting success having compared nothing.
358
+ if (applied.pending.length === 0) {
359
+ log.info(`meta verify --replay: no committed migrations — nothing to replay`);
360
+ if (!flags.replaySnapshot) return 0;
361
+ } else {
362
+ log.info(
363
+ `meta verify --replay — the committed chain applies to an empty ${dialect} database ` +
364
+ `(${applied.applied.length} migration(s)).`,
365
+ );
366
+ }
367
+
368
+ if (!flags.replaySnapshot) return 0;
369
+ return await runReplaySnapshotTier(engine, dialect, dir);
370
+ } finally {
371
+ await engine.dispose();
372
+ }
373
+ }
374
+
375
+ /**
376
+ * The second tier: the replayed schema must EQUAL the committed snapshot.
377
+ *
378
+ * This is the 2026-05-31 §8 integrity aid, finally wired — `verifyReplay` has been
379
+ * built and exported with no CLI caller since then. What it catches that tier 1
380
+ * cannot is hand-edited structural DDL: a committed up.sql someone changed so the
381
+ * chain still applies but no longer produces the schema the snapshot records.
382
+ *
383
+ * It does NOT support a project adopted via `migrate baseline --from-db`, and does
384
+ * not try to detect one. The only candidate signal (`BASELINE_NAME`/`recordBaseline`)
385
+ * has no production caller and would live in the TARGET database's ledger, while
386
+ * this runs against a fresh engine with no ledger at all. So the failure message
387
+ * names baseline adoption as the first thing to rule out.
388
+ */
389
+ async function runReplaySnapshotTier(
390
+ engine: ReplayEngine,
391
+ dialect: Extract<Dialect, "postgres" | "sqlite">,
392
+ dir: string,
393
+ ): Promise<number> {
394
+ // Fails OPEN on a missing snapshot: a project that has never generated one
395
+ // offline is not in an error state, and an unreadable/unparseable file is
396
+ // migrate's error to raise with its own message, not a drift verdict. It still
397
+ // SAYS so — silence here would be indistinguishable from a pass.
398
+ let snapshot: SchemaSnapshot | null;
399
+ try {
400
+ snapshot = await readSnapshot(snapshotPath(dir, dialect));
401
+ } catch {
402
+ log.info(`meta verify --replay-snapshot: the committed snapshot could not be read — nothing to compare`);
403
+ return 0;
404
+ }
405
+ if (snapshot === null) {
406
+ log.info(`meta verify --replay-snapshot: no committed snapshot — nothing to compare`);
407
+ return 0;
408
+ }
409
+
410
+ // A scoped project carries the OTHER owner's tables into its snapshot on purpose
411
+ // and its chain never creates them, so they must leave the comparison. The
412
+ // committed snapshot alone cannot be scoped — `scopeExpectedSchema` decides on a
413
+ // qualified-name → metadata-FQN provenance map the snapshot does not carry — so
414
+ // the expected side is rebuilt from metadata purely to derive that decision.
415
+ //
416
+ // Only for a project that actually declares `migrate.scope`. An unscoped project
417
+ // passes no `governed` and gets the comparison exactly as it was.
418
+ let governed: GovernedScope | undefined;
419
+ if (schemaScope !== undefined) {
420
+ const viewStrategy = forgeConfig?.columnNamingStrategy ?? "snake_case";
421
+ const built = buildExpectedSchemaWithProvenance(root, {
422
+ dialect,
423
+ columnNamingStrategy: viewStrategy,
424
+ views: buildProjectionViews(root, { dialect, columnNamingStrategy: viewStrategy }),
425
+ });
426
+ governed = scopeExpectedSchema(built, schemaScope);
427
+ }
428
+
429
+ // `verifyReplay` calls `applyPending` itself. That is NOT a second replay: the
430
+ // first one recorded every migration in this engine's ledger, so the call finds
431
+ // nothing pending and returns immediately.
432
+ const result = await verifyReplay({
433
+ db: engine.db,
434
+ dialect,
435
+ migrationsDir: dir,
436
+ snapshot,
437
+ ...(governed !== undefined ? { governed } : {}),
438
+ });
439
+ if (result.ok) {
440
+ log.info(`meta verify --replay-snapshot — the replayed chain reproduces the committed snapshot.`);
441
+ return 0;
442
+ }
443
+
444
+ log.error(
445
+ `meta verify --replay-snapshot: the replayed chain does not reproduce the committed snapshot. ` +
446
+ `If this project was adopted with 'migrate baseline --from-db', its chain does not build the ` +
447
+ `schema and this tier does not apply — use --replay instead.`,
448
+ );
449
+ for (const line of summarizeDrift([...result.drift, ...result.unmanaged])) log.error(` ${line}`);
450
+ return 1;
451
+ }
193
452
 
194
453
  // -- requirements (#290) ---------------------------------------------------
195
454
  function runRequirementVerify(): number {
196
- // `@verifiedBy` resolution needs the project on disk, so it is a separate
197
- // scan; its diagnostics carry the same severities and share this reporter.
198
- // `verify.testFiles` lets a project name its own test-file conventions. What counts
199
- // as a test is project-specific, and the built-in patterns are a convenience, not an
200
- // authority see the verified-by-scan header.
201
- const diags = [
202
- ...checkRequirements(root),
203
- ...checkVerifiedBy(root, cwd, forgeConfig?.verify?.testFiles),
204
- ];
455
+ // No test-corpus scan runs here any more. `@verifiedBy` asked the author to
456
+ // name a test and checked only that the NAME occurred somewhere in the test
457
+ // sources an audit of one real 19-name ledger found 4 names that did not
458
+ // verify their claim (a comment, a DI key, a test of a different claim, a
459
+ // test of the output where the claim was about the source text) while verify
460
+ // reported zero errors throughout. Existence was never proof, and no lexical
461
+ // rule reaches the semantic cases. FR-038 retires the attribute rather than
462
+ // narrowing it; the replacement generates the test FROM the requirement, so
463
+ // the link is structural instead of a string the author picks.
464
+ const diags = [...checkRequirements(root)];
205
465
 
206
466
  // Printed on EVERY run, clean or not — a gate that says nothing when it
207
467
  // passes cannot be told apart from a gate that checked nothing, and the
208
468
  // recorded-gap counts are the whole reason to keep a ledger.
209
469
  const s = summariseRequirements(root);
210
470
  if (s !== undefined) {
211
- const order = ["planned", "live", "partial", "abandoned", "superseded"];
471
+ const order = [...REQUIREMENT_STATUSES];
212
472
  const parts = order
213
473
  .filter((k) => (s.byStatus[k] ?? 0) > 0)
214
474
  .map((k) => `${s.byStatus[k]} ${k}`);
475
+ // The file count is the DENOMINATOR'S PROVENANCE, and it is here because
476
+ // `entitiesTotal` is only ever computed over what actually loaded. A spine that
477
+ // covers half an estate reports the covered half as fully claimed — an adopter
478
+ // found `76/76` while two of their four metadata trees were not in `sources` at
479
+ // all, which is why nothing had ever flagged the templates living in them. No
480
+ // check can see a tree it was never pointed at, so the honest fix is to publish
481
+ // what the count was taken over and let a wrong number be noticeable.
215
482
  log.info(
216
483
  `meta verify — requirements: ${s.total} entries (${s.functional} functional, ` +
217
484
  `${s.architectural} architectural) — ${parts.join(", ")}; ` +
218
- `${s.entitiesClaimed}/${s.entitiesTotal} entities claimed.`,
485
+ `${s.entitiesClaimed}/${s.entitiesTotal} entities claimed, ` +
486
+ `counted over ${collection.files.length} metadata file(s).`,
219
487
  );
220
488
  if (s.undecided > 0) {
221
489
  log.info(
@@ -245,7 +513,7 @@ export async function verifyCommand(
245
513
  function runAntiPatternAdvisory(): void {
246
514
  let findings;
247
515
  try {
248
- findings = scanSourceForAntiPatterns(cwd);
516
+ findings = scanSourceForAntiPatterns(projectRoot);
249
517
  } catch {
250
518
  return; // never let an advisory scan break verify
251
519
  }
@@ -378,6 +646,26 @@ export async function verifyCommand(
378
646
  const usingD1 = flags.dialect === "d1";
379
647
  if ((flags.db === undefined && !usingD1) || flags.skipSchema) return 0;
380
648
 
649
+ // A `migrate.scope` matching nothing it could govern is refused, not tolerated —
650
+ // it would make this gate compare zero objects and report "in sync" (see
651
+ // `migrateScopeMismatch`). Checked HERE rather than beside the other collection
652
+ // work at the top of `verifyCommand`, because `migrate.scope` governs only the
653
+ // schema gate: a stale pattern must not fail a `--templates` run that never
654
+ // consults it.
655
+ const scopeMismatch = migrateScopeMismatch(collection, () => {
656
+ const dialect: Dialect = usingD1 ? "d1" : (flags.dialect ?? inferDialect(flags.db as string));
657
+ const viewStrategy = forgeConfig?.columnNamingStrategy ?? "snake_case";
658
+ return buildExpectedSchemaWithProvenance(root, {
659
+ dialect,
660
+ columnNamingStrategy: viewStrategy,
661
+ views: buildProjectionViews(root, { dialect, columnNamingStrategy: viewStrategy }),
662
+ }).provenance;
663
+ });
664
+ if (scopeMismatch !== undefined) {
665
+ log.error(`verify: ${scopeMismatch}`);
666
+ return 2;
667
+ }
668
+
381
669
  if (usingD1 && flags.db !== undefined) {
382
670
  log.error(`verify: --db is not used for dialect 'd1' — wrangler.toml owns the connection; pass --d1 <binding> instead`);
383
671
  return 2;
@@ -428,7 +716,11 @@ export async function verifyCommand(
428
716
  // `actual` this drift comparison uses, and re-introspecting for it would both
429
717
  // cost a second round trip and open a window where the two could disagree.
430
718
  actual = await introspect(kysely.db, kysely.dialect);
431
- driftResult = await computeDriftFromActual(actual, kysely.dialect, root, { allow, views: expectedViews });
719
+ driftResult = await computeDriftFromActual(actual, kysely.dialect, root, {
720
+ allow,
721
+ views: expectedViews,
722
+ ...(schemaScope !== undefined ? { inScope: schemaScope } : {}),
723
+ });
432
724
  } catch (err) {
433
725
  log.error(`verify: failed to introspect ${kysely.displayUrl}: ${(err as Error).message}`);
434
726
  return 1;
@@ -436,7 +728,7 @@ export async function verifyCommand(
436
728
 
437
729
  const snapshotDrift =
438
730
  driftResult.changes.length === 0
439
- ? await checkCommittedSnapshot(actual, kysely.dialect, kysely.displayUrl)
731
+ ? await checkCommittedSnapshot(actual, kysely.dialect, kysely.displayUrl, driftResult)
440
732
  : [];
441
733
 
442
734
  return reportSchemaDrift(driftResult, [...ledgerDrift, ...snapshotDrift], kysely.displayUrl);
@@ -460,14 +752,14 @@ export async function verifyCommand(
460
752
  // computeDriftFromActual and the SAME reportSchemaDrift the sqlite/postgres
461
753
  // path uses — no forked reporting/exit-code logic.
462
754
  async function runD1SchemaVerify(ledgerDrift: string[]): Promise<number> {
463
- const d1Config = await resolveD1Config({ d1Binding: flags.d1, remote: flags.remote }, cwd);
755
+ const d1Config = await resolveD1Config({ d1Binding: flags.d1, remote: flags.remote }, projectRoot);
464
756
 
465
757
  const wranglerConfigPath = d1Config.wranglerConfigPath
466
- ? resolvePath(cwd, d1Config.wranglerConfigPath)
467
- : findWranglerConfig(cwd);
758
+ ? resolvePath(projectRoot, d1Config.wranglerConfigPath)
759
+ : findWranglerConfig(projectRoot);
468
760
 
469
761
  if (wranglerConfigPath === undefined && d1Config.binding === undefined) {
470
- log.error(`verify: no wrangler.toml found in ${cwd} or parents; pass --d1 <binding> to bypass`);
762
+ log.error(`verify: no wrangler.toml found in ${projectRoot} or parents; pass --d1 <binding> to bypass`);
471
763
  return 2;
472
764
  }
473
765
 
@@ -493,7 +785,7 @@ export async function verifyCommand(
493
785
  command: sql,
494
786
  configPath: wranglerConfigPath,
495
787
  });
496
- const { stdout } = await activeWranglerRunner(wranglerArgs, cwd);
788
+ const { stdout } = await activeWranglerRunner(wranglerArgs, projectRoot);
497
789
  return stdout;
498
790
  };
499
791
 
@@ -510,7 +802,11 @@ export async function verifyCommand(
510
802
  const expectedViews = buildProjectionViews(root, { dialect: "d1", columnNamingStrategy: viewStrategy });
511
803
  let driftResult;
512
804
  try {
513
- driftResult = await computeDriftFromActual(actual, "d1", root, { allow, views: expectedViews });
805
+ driftResult = await computeDriftFromActual(actual, "d1", root, {
806
+ allow,
807
+ views: expectedViews,
808
+ ...(schemaScope !== undefined ? { inScope: schemaScope } : {}),
809
+ });
514
810
  } catch (err) {
515
811
  log.error(`verify: ${(err as Error).message}`);
516
812
  return 1;
@@ -554,14 +850,15 @@ export async function verifyCommand(
554
850
  actual: SchemaSnapshot,
555
851
  dialect: Dialect,
556
852
  displayUrl: string,
853
+ governed: GovernedScope,
557
854
  ): Promise<string[]> {
558
855
  if (dialect === "d1") return []; // d1 keeps migrations Wrangler-native; no offline snapshot
559
856
  // Resolve the migrations dir through migrate's OWN precedence (flag > config >
560
857
  // default) rather than re-deriving it, so verify can never look somewhere migrate
561
858
  // does not write. Only `outDir` is consumed; the rest of the resolved config is
562
859
  // migrate's business.
563
- const migrateConfig = await resolveMigrateConfig(EMPTY_MIGRATE_FLAGS, cwd);
564
- const dir = resolvePath(cwd, migrateConfig.outDir);
860
+ const migrateConfig = await resolveMigrateConfig(EMPTY_MIGRATE_FLAGS, projectRoot);
861
+ const dir = resolvePath(projectRoot, migrateConfig.outDir);
565
862
  let snapshot: SchemaSnapshot | null;
566
863
  try {
567
864
  snapshot = await readSnapshot(snapshotPath(dir, dialect));
@@ -570,11 +867,28 @@ export async function verifyCommand(
570
867
  }
571
868
  if (snapshot === null) return [];
572
869
 
870
+ // Out-of-scope objects leave BOTH sides of this comparison, and the schema pin
871
+ // comes from the scope decision the DRIFT comparison already made — one door
872
+ // (migrate-ts's `excludeFromSnapshot` + `scopedDiffInputs`), not a fifth
873
+ // hand-rolled copy of the three-part contract. `unmanagedNames` suppresses the
874
+ // actual side only, which is right for the metadata↔DB diff (its expected side
875
+ // is already scoped) but not here: the committed snapshot IS the expected side,
876
+ // and a snapshot written before the scope was declared still carries the other
877
+ // owner's tables. Re-deriving the pin from the snapshot is what left an empty
878
+ // (never-migrated) snapshot reaching `diff`'s whole-database fallback.
573
879
  const result = await diff({
574
- expected: snapshot,
880
+ ...scopedDiffInputs(excludeFromSnapshot(snapshot, governed), collectUnmanagedNames(root)),
575
881
  actual,
576
882
  allow: {},
577
- unmanagedNames: collectUnmanagedNames(root),
883
+ // #297 — the SAME pipeline `meta migrate` runs, or this gate answers a different
884
+ // question than the one it reports on. `DiffArgs.dialect` is optional, so omitting
885
+ // it was silently accepted: views fell through to comparing our emitted body
886
+ // against the deparser's (never equal, so permanent drift on Postgres), CHECK
887
+ // constraints were skipped entirely, and SQLite type canonicalization no-opped.
888
+ //
889
+ // Note `unmanagedNames` is NOT restated here: `scopedDiffInputs` above supplies it
890
+ // MERGED with the out-of-scope set, and a second key would silently drop that half.
891
+ dialect,
578
892
  });
579
893
  if (result.changes.length === 0) return [];
580
894
 
@@ -586,7 +900,7 @@ export async function verifyCommand(
586
900
  ];
587
901
  }
588
902
 
589
- function reportSchemaDrift(driftResult: DiffResult, ledgerDrift: string[], displayUrl: string): number {
903
+ function reportSchemaDrift(driftResult: DriftResult, ledgerDrift: string[], displayUrl: string): number {
590
904
  // #208 §8 — make declared-external objects visible: they are excluded from the
591
905
  // drift comparison (computeDrift/computeDriftFromActual thread them out), so
592
906
  // annotate them as external (declared) rather than let them vanish silently.
@@ -597,6 +911,13 @@ export async function verifyCommand(
597
911
  );
598
912
  }
599
913
 
914
+ // Same reasoning for the per-command scope: an object `migrate.scope` excluded
915
+ // was NOT checked, and silence would misreport it as checked-and-clean. Shared
916
+ // wording with `meta migrate` — one declaration, one sentence about it.
917
+ if (driftResult.outOfScope.length > 0) {
918
+ log.info(outOfScopeNote("verify", driftResult.outOfScope));
919
+ }
920
+
600
921
  const changes = driftResult.changes;
601
922
  if (changes.length === 0 && ledgerDrift.length === 0) {
602
923
  log.info(`meta verify — schema in sync with ${displayUrl}.`);
@@ -629,9 +950,13 @@ export async function verifyCommand(
629
950
  return 2;
630
951
  }
631
952
 
953
+ // The identical predicate `meta gen` applies (Task 12b / design §7 open
954
+ // question 3) — a `gen` that committed under a narrowed scope and a
955
+ // `verify --codegen` that regenerates unscoped would disagree about which
956
+ // files should exist, reporting every out-of-scope entity as drift.
632
957
  let result;
633
958
  try {
634
- result = await computeCodegenDrift(forgeConfig, root, cwd);
959
+ result = await computeCodegenDrift(forgeConfig, root, genConfigDir, collection.inScope);
635
960
  } catch (err) {
636
961
  log.error(`verify --codegen: regeneration failed: ${(err as Error).message}`);
637
962
  return 1;