@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
@@ -10,21 +10,22 @@ import { join, resolve as resolvePath } from "node:path";
10
10
  import { parseVerifyArgs } from "../lib/args.js";
11
11
  import { log } from "../lib/log.js";
12
12
  import { warnIfAgentContextStale } from "../lib/agent-context-staleness.js";
13
+ import { warnIfManifestIgnored } from "../lib/manifest-ignored-check.js";
13
14
  import { scanSourceForAntiPatterns } from "../lib/anti-patterns.js";
14
15
  import { FileProvider } from "../lib/file-provider.js";
15
16
  import { derivePayloadFieldTree } from "../lib/payload-field-tree.js";
16
- import { loadMetaobjectsConfig } from "../lib/load-metaobjects-config.js";
17
+ import { loadMemoryOptionsFrom, loadMetaobjectsConfig, resolveGenConfigDir } from "../lib/load-metaobjects-config.js";
17
18
  import { computeCodegenDrift } from "../lib/codegen-drift.js";
18
19
  import { checkRequirements, summariseRequirements } from "../lib/requirement-check.js";
19
- import { checkVerifiedBy } from "../lib/verified-by-scan.js";
20
20
  import { resolveD1Config, resolveMigrateConfig } from "../lib/config.js";
21
21
  import { buildWranglerExecuteArgs, defaultWranglerRunner, isWranglerLocalD1StatePath, } from "../lib/wrangler.js";
22
22
  import { buildProjectionViews } from "@metaobjectsdev/codegen-ts";
23
- import { buildKyselyFromUrl } from "../lib/kysely.js";
23
+ import { buildKyselyFromUrl, inferDialect } from "../lib/kysely.js";
24
24
  import { tokensToAllowOptions, describeChange } from "../lib/allow.js";
25
- import { computeDrift, computeDriftFromActual, collectUnmanagedNames, introspect, diff, readSnapshot, snapshotPath, introspectD1, findWranglerConfig, parseWranglerConfig, resolveD1Binding, } from "@metaobjectsdev/migrate-ts";
26
- import { loadMemory } from "@metaobjectsdev/sdk";
27
- import { TYPE_TEMPLATE, TEMPLATE_SUBTYPE_PROMPT, TEMPLATE_SUBTYPE_OUTPUT, TEMPLATE_ATTR_PAYLOAD_REF, TEMPLATE_ATTR_TEXT_REF, TEMPLATE_ATTR_REQUIRED_SLOTS, TEMPLATE_ATTR_REQUIRED_TAGS, TEMPLATE_ATTR_KIND, TEMPLATE_KIND_EMAIL, TEMPLATE_KIND_DEFAULT, TEMPLATE_ATTR_SUBJECT_REF, TEMPLATE_ATTR_HTML_BODY_REF, TEMPLATE_ATTR_TEXT_BODY_REF, } from "@metaobjectsdev/metadata";
25
+ import { computeDrift, computeDriftFromActual, collectUnmanagedNames, excludeFromSnapshot, scopedDiffInputs, scopeExpectedSchema, buildExpectedSchemaWithProvenance, applyPending, openReplayEngine, verifyReplay, introspect, diff, readSnapshot, snapshotPath, introspectD1, findWranglerConfig, parseWranglerConfig, resolveD1Binding, } from "@metaobjectsdev/migrate-ts";
26
+ import { loadMemory, resolveCollection } from "@metaobjectsdev/sdk";
27
+ import { migrateScopeMismatch, outOfScopeNote } from "../lib/migrate-scope.js";
28
+ import { TYPE_TEMPLATE, TEMPLATE_SUBTYPE_PROMPT, TEMPLATE_SUBTYPE_OUTPUT, TEMPLATE_ATTR_PAYLOAD_REF, TEMPLATE_ATTR_TEXT_REF, TEMPLATE_ATTR_REQUIRED_SLOTS, TEMPLATE_ATTR_REQUIRED_TAGS, TEMPLATE_ATTR_KIND, TEMPLATE_KIND_EMAIL, TEMPLATE_KIND_DEFAULT, TEMPLATE_ATTR_SUBJECT_REF, TEMPLATE_ATTR_HTML_BODY_REF, TEMPLATE_ATTR_TEXT_BODY_REF, REQUIREMENT_STATUSES, } from "@metaobjectsdev/metadata";
28
29
  import { verify, ERR_REQUIRED_SLOT_UNUSED, ERR_PARTIAL_UNRESOLVED } from "@metaobjectsdev/render";
29
30
  const DEFAULT_PROMPTS_DIR = "prompts";
30
31
  // Loader error code (from @metaobjectsdev/metadata's ERROR_CODES) raised by the
@@ -33,9 +34,14 @@ const DEFAULT_PROMPTS_DIR = "prompts";
33
34
  const ERR_UNKNOWN_ATTR = "ERR_UNKNOWN_ATTR";
34
35
  /**
35
36
  * A no-flags MigrateFlags, so `resolveMigrateConfig` yields exactly what `meta migrate`
36
- * would use with nothing passed on the command line — config value, else default. verify
37
- * consumes only `outDir` from the result (#292); the other fields exist to satisfy the
38
- * shared shape, and reading any of them here would be reaching into migrate's decisions.
37
+ * would use with nothing passed on the command line — config value, else default.
38
+ *
39
+ * verify consumes `outDir` (#292) and for the replay gate ONLY `dialect`. The #292
40
+ * restriction that reading anything else "would be reaching into migrate's decisions"
41
+ * was written about the DRIFT gate, whose dialect comes from the live `--db` URL. The
42
+ * replay gate has no `--db` at all, and the dialect a committed chain was EMITTED for
43
+ * is a migrate decision by definition, so migrate's own resolution is the only correct
44
+ * source for it. Everything else here exists to satisfy the shared shape.
39
45
  */
40
46
  const EMPTY_MIGRATE_FLAGS = {
41
47
  db: undefined, dialect: undefined, format: undefined, outDir: undefined, slug: undefined,
@@ -63,8 +69,6 @@ wranglerRunner) {
63
69
  log.error(err.message);
64
70
  return 2;
65
71
  }
66
- // Advisory: nudge to refresh the .claude/skills docs if they predate this CLI.
67
- warnIfAgentContextStale(cwd);
68
72
  // ADR-0021 D2 — explicit verify subverbs. Each flag selects one drift mode;
69
73
  // any combination runs each and the overall exit code is the MAX (non-zero on
70
74
  // any drift). A bare `verify` (no explicit subverb) keeps its documented
@@ -76,8 +80,58 @@ wranglerRunner) {
76
80
  const runCodegen = flags.codegen;
77
81
  if (!flags.anyExplicit) {
78
82
  log.info("meta verify — running --templates (default). Explicit subverbs: " +
79
- "--templates (prompt drift), --db/--dialect d1 (schema drift), --codegen (codegen drift).");
83
+ "--templates (prompt drift), --db/--dialect d1 (schema drift), --codegen (codegen drift), " +
84
+ "--replay/--replay-snapshot (the committed migration chain replays from empty).");
85
+ }
86
+ // Where the metadata lives is `resolveCollection`'s decision, not a hardcoded
87
+ // directory. It also carries the per-command `migrate.scope` the schema gate below
88
+ // honours — `verify --db` and `migrate` govern the identical object set — and the
89
+ // top-level `scope` `runCodegenVerify` (a nested function below) threads into
90
+ // `computeCodegenDrift`. Explicitly typed (unlike the `let collection;` pattern
91
+ // elsewhere in this codebase): a nested function body is OUTSIDE the control-flow
92
+ // narrowing TS performs on a same-scope `let x;` reassignment, so a bare
93
+ // `let collection;` type-checked clean until this task added exactly that nested
94
+ // reference — the reader who removes the annotation next reintroduces TS7034.
95
+ let collection;
96
+ try {
97
+ collection = await resolveCollection(cwd);
98
+ }
99
+ catch (err) {
100
+ log.error(err.message);
101
+ return 2;
80
102
  }
103
+ // The project root is whichever directory `resolveCollection` decided the
104
+ // metadata belongs to (design §4.6.1: "Per-port generator config is then read
105
+ // from that same directory"). The line this draws, applied throughout this
106
+ // command: anything named BY the metadata or by `.metaobjects/config.json`
107
+ // resolves against `projectRoot` — that file's operational block, the migrations
108
+ // `outDir` and `wranglerConfigPath` it carries, the `prompts/` a `@textRef`
109
+ // resolves in. Identical paths for a run from the project root.
110
+ //
111
+ // The two advisory passes — the agent-context staleness nudge and the
112
+ // anti-pattern scan — are rooted here too, matching `meta gen`. Both commands
113
+ // describe them as the same pass, and scanning two different trees for it made
114
+ // that false: a `verify` run from a subdirectory scanned only that subtree and
115
+ // found no agent-context manifest at all, so the nudge silently never fired.
116
+ const projectRoot = collection.configDir;
117
+ // The one thing NOT named by the metadata or its config: `metaobjects.config.ts`
118
+ // is this TypeScript package's own answer to a different question (design §4.6),
119
+ // so it gets its own nearest-ancestor walk and everything IT names follows —
120
+ // `outDir`/`targets` for `--codegen`. (It also carried `verify.testFiles` until
121
+ // 0.24.0 retired the `@verifiedBy` scan; both are gone.) In a
122
+ // Maven- or pip-rooted monorepo the collection is declared at the repo root while
123
+ // the TS config sits in the app; reading the second from the first made
124
+ // `--codegen` report "no config" for a package that has one (#326). Identical to
125
+ // `projectRoot` whenever the two files sit together, which is every `meta init`
126
+ // project.
127
+ const genConfigDir = resolveGenConfigDir(cwd, collection.configDir);
128
+ // Advisory: nudge to refresh the .claude/skills docs if they predate this CLI.
129
+ warnIfAgentContextStale(projectRoot);
130
+ // Advisory: the committed hash manifest is what makes hand-edit detection work on a
131
+ // machine that did not generate the output. Silent unless it is ignored. Keyed on
132
+ // projectRoot, not cwd, for the same reason its neighbour is — the manifest belongs to
133
+ // whichever directory `resolveCollection` decided the metadata lives in.
134
+ warnIfManifestIgnored(projectRoot);
81
135
  // Best-effort load of metaobjects.config.ts. Two consumers:
82
136
  // 1) consumer-supplied providers (e.g. a `template.toolcall` subtype) threaded
83
137
  // into loadMemory — verify doesn't REQUIRE codegen config for templates/db;
@@ -86,27 +140,27 @@ wranglerRunner) {
86
140
  // error (it can't diff without knowing where the committed output lives).
87
141
  let forgeConfig;
88
142
  try {
89
- forgeConfig = await loadMetaobjectsConfig(cwd);
143
+ forgeConfig = await loadMetaobjectsConfig(genConfigDir);
90
144
  }
91
145
  catch {
92
146
  forgeConfig = undefined;
93
147
  }
94
- const configProviders = forgeConfig?.providers;
148
+ // Both of the gen config's contributions to the load, together — see
149
+ // `loadMemoryOptionsFrom`. Threading `providers` and forgetting `libraries` is how
150
+ // a shipped library became unloadable through the CLI (#333).
151
+ const configLoadOptions = loadMemoryOptionsFrom(forgeConfig);
95
152
  // ADR-0023 strict-by-default (#96): verify loads strict unless --lax is passed,
96
153
  // so an undeclared/typo'd own @attr fails verify (matching Java's Maven goal).
97
154
  let root;
98
155
  try {
99
- root = await loadMemory(cwd, {
100
- ...(configProviders !== undefined ? { providers: configProviders } : {}),
156
+ root = await loadMemory(collection.configDir, {
157
+ files: collection.files,
158
+ ...configLoadOptions,
101
159
  strict: !flags.lax,
102
160
  });
103
161
  }
104
162
  catch (err) {
105
163
  const msg = err.message;
106
- if (msg.includes("ENOENT") || msg.includes("no such") || msg.includes("cannot read")) {
107
- log.error(`no metaobjects/ found in ${cwd}; run 'meta init' to scaffold`);
108
- return 2;
109
- }
110
164
  log.error(`failed to load metadata: ${msg}`);
111
165
  // Strict-load rejection (ADR-0023): give the author the three exits — register
112
166
  // the attr on a provider, stash it in the `attr.properties` bag, or pass --lax.
@@ -119,7 +173,12 @@ wranglerRunner) {
119
173
  }
120
174
  return 1;
121
175
  }
122
- const promptsDir = join(cwd, flags.prompts ?? DEFAULT_PROMPTS_DIR);
176
+ // The schema gate governs exactly the objects `meta migrate` governs — ONE
177
+ // declaration (`migrate.scope`), not a second key: a drift gate that fails on
178
+ // tables migrate deliberately does not own is incoherent. Undefined ⇒ everything
179
+ // loaded, which is every project that declares no scope.
180
+ const schemaScope = collection.inMigrateScope;
181
+ const promptsDir = join(projectRoot, flags.prompts ?? DEFAULT_PROMPTS_DIR);
123
182
  const provider = new FileProvider(promptsDir);
124
183
  // Exit-code composition: the overall result is the MAX across every selected
125
184
  // subverb so ANY kind of drift fails CI. Each gate only runs when its mode is
@@ -131,36 +190,211 @@ wranglerRunner) {
131
190
  // are checked on every `meta verify`. Opt-in by DECLARATION — a model with no
132
191
  // requirement nodes is silent, not in drift.
133
192
  const requirementExit = runRequirementVerify();
193
+ // #313 — BOTH replay flags select this gate. `--replay-snapshot` implies
194
+ // `--replay`'s work, so a broken chain must fail under it even when `--replay`
195
+ // was not passed; naming only `flags.replay` here is how `--replay-snapshot`
196
+ // would parse cleanly and do nothing at all.
197
+ const replayExit = flags.replay || flags.replaySnapshot ? await runReplayVerify() : 0;
134
198
  // Advisory verify-as-teacher pass: surface hand-rolled work the metadata could
135
199
  // model. Warnings ONLY — never changes the exit code (bias to under-flagging).
136
200
  // Suppressed with --no-antipatterns or META_NO_ANTIPATTERNS=1 for the rare
137
201
  // noisy project (both opt-outs work on `meta verify` and `meta gen`).
138
202
  if (!flags.noAntipatterns && process.env.META_NO_ANTIPATTERNS !== "1")
139
203
  runAntiPatternAdvisory();
140
- return Math.max(templateExit, schemaExit, codegenExit, requirementExit);
204
+ return Math.max(templateExit, schemaExit, codegenExit, requirementExit, replayExit);
205
+ // -- replay (#313) ---------------------------------------------------------
206
+ /**
207
+ * Replay the committed migration chain into an EMPTY throwaway database and assert
208
+ * it applies. `--replay-snapshot` additionally asserts the result equals the
209
+ * committed snapshot.
210
+ *
211
+ * This exists because `meta migrate` could write a chain that cannot be replayed —
212
+ * a bare `DROP TABLE "x"` for an object no migration ever created — and nothing
213
+ * noticed until someone tried to provision a fresh database, which for the reporter
214
+ * was three months later. The two tiers are separate because a project adopted via
215
+ * `migrate baseline --from-db` passes the first trivially and CANNOT pass the second
216
+ * by construction: its snapshot is the whole introspected database and its chain is
217
+ * empty.
218
+ *
219
+ * Exit codes follow verify's convention: a chain that fails to apply, or a snapshot
220
+ * mismatch, is drift → 1; an engine that will not start is operational → 2.
221
+ */
222
+ async function runReplayVerify() {
223
+ // Resolve the migrations directory and the chain's dialect through MIGRATE's own
224
+ // precedence, never a second derivation — verify must not look somewhere migrate
225
+ // does not write, nor assume a dialect the chain was not emitted for.
226
+ const migrateConfig = await resolveMigrateConfig(EMPTY_MIGRATE_FLAGS, projectRoot);
227
+ if (migrateConfig.format === "flyway") {
228
+ log.error(`meta verify --replay is not supported with --migration-format flyway — run 'flyway migrate' against a scratch database to replay`);
229
+ return 2;
230
+ }
231
+ // --dialect wins; else migrate's resolved dialect; else refuse. There is no --db
232
+ // to infer from, so guessing would replay a postgres chain through sqlite.
233
+ const dialect = flags.dialect ?? migrateConfig.dialect;
234
+ if (dialect === undefined) {
235
+ log.error(`meta verify --replay: no dialect — pass --dialect <postgres|sqlite>, or set migrate.dialect in .metaobjects/config.json`);
236
+ return 2;
237
+ }
238
+ if (dialect === "d1") {
239
+ log.error(`meta verify --replay is not supported for dialect 'd1' — use 'wrangler d1 migrations apply' against a scratch database to replay committed migrations`);
240
+ return 2;
241
+ }
242
+ const dir = resolvePath(projectRoot, migrateConfig.outDir);
243
+ let engine;
244
+ try {
245
+ engine = await openReplayEngine(dialect);
246
+ }
247
+ catch (err) {
248
+ // A missing optional driver lands here, and its message already carries the
249
+ // install hint. Operational, not drift.
250
+ log.error(`meta verify --replay: ${err.message}`);
251
+ return 2;
252
+ }
253
+ try {
254
+ let applied;
255
+ try {
256
+ applied = await applyPending(engine.db, dir, { dryRun: false, dialect });
257
+ }
258
+ catch (err) {
259
+ log.error(`meta verify --replay: ${err.message}`);
260
+ log.error(`meta verify --replay: the committed chain does not apply to an empty database. ` +
261
+ `Applied migrations are immutable, so fix this with a NEW migration that creates the ` +
262
+ `missing object — not by editing a committed up.sql.`);
263
+ return 1;
264
+ }
265
+ // Not a silent pass. `discoverMigrations` returns [] for a missing directory, so
266
+ // a run over an empty chain would otherwise "succeed" having proved nothing —
267
+ // and a gate that is quiet when it checked nothing cannot be told from one that
268
+ // passed. Every migration is pending against a fresh engine, so an empty
269
+ // `pending` means the directory held none.
270
+ //
271
+ // This return is for TIER 1 ONLY: an empty chain trivially "applies" (there is
272
+ // nothing that could fail), so tier 1 is done. Tier 2 is NOT done — its job is
273
+ // "does the replay reproduce the committed snapshot?", and a wrong
274
+ // `migrate.outDir` or a project adopted via `migrate baseline --from-db` (whose
275
+ // own chain is empty by construction) both look identical to this point. Return
276
+ // ONLY when `--replay-snapshot` was not requested; otherwise fall through so an
277
+ // empty replay is still compared against a snapshot that may record dozens of
278
+ // tables, rather than reporting success having compared nothing.
279
+ if (applied.pending.length === 0) {
280
+ log.info(`meta verify --replay: no committed migrations — nothing to replay`);
281
+ if (!flags.replaySnapshot)
282
+ return 0;
283
+ }
284
+ else {
285
+ log.info(`meta verify --replay — the committed chain applies to an empty ${dialect} database ` +
286
+ `(${applied.applied.length} migration(s)).`);
287
+ }
288
+ if (!flags.replaySnapshot)
289
+ return 0;
290
+ return await runReplaySnapshotTier(engine, dialect, dir);
291
+ }
292
+ finally {
293
+ await engine.dispose();
294
+ }
295
+ }
296
+ /**
297
+ * The second tier: the replayed schema must EQUAL the committed snapshot.
298
+ *
299
+ * This is the 2026-05-31 §8 integrity aid, finally wired — `verifyReplay` has been
300
+ * built and exported with no CLI caller since then. What it catches that tier 1
301
+ * cannot is hand-edited structural DDL: a committed up.sql someone changed so the
302
+ * chain still applies but no longer produces the schema the snapshot records.
303
+ *
304
+ * It does NOT support a project adopted via `migrate baseline --from-db`, and does
305
+ * not try to detect one. The only candidate signal (`BASELINE_NAME`/`recordBaseline`)
306
+ * has no production caller and would live in the TARGET database's ledger, while
307
+ * this runs against a fresh engine with no ledger at all. So the failure message
308
+ * names baseline adoption as the first thing to rule out.
309
+ */
310
+ async function runReplaySnapshotTier(engine, dialect, dir) {
311
+ // Fails OPEN on a missing snapshot: a project that has never generated one
312
+ // offline is not in an error state, and an unreadable/unparseable file is
313
+ // migrate's error to raise with its own message, not a drift verdict. It still
314
+ // SAYS so — silence here would be indistinguishable from a pass.
315
+ let snapshot;
316
+ try {
317
+ snapshot = await readSnapshot(snapshotPath(dir, dialect));
318
+ }
319
+ catch {
320
+ log.info(`meta verify --replay-snapshot: the committed snapshot could not be read — nothing to compare`);
321
+ return 0;
322
+ }
323
+ if (snapshot === null) {
324
+ log.info(`meta verify --replay-snapshot: no committed snapshot — nothing to compare`);
325
+ return 0;
326
+ }
327
+ // A scoped project carries the OTHER owner's tables into its snapshot on purpose
328
+ // and its chain never creates them, so they must leave the comparison. The
329
+ // committed snapshot alone cannot be scoped — `scopeExpectedSchema` decides on a
330
+ // qualified-name → metadata-FQN provenance map the snapshot does not carry — so
331
+ // the expected side is rebuilt from metadata purely to derive that decision.
332
+ //
333
+ // Only for a project that actually declares `migrate.scope`. An unscoped project
334
+ // passes no `governed` and gets the comparison exactly as it was.
335
+ let governed;
336
+ if (schemaScope !== undefined) {
337
+ const viewStrategy = forgeConfig?.columnNamingStrategy ?? "snake_case";
338
+ const built = buildExpectedSchemaWithProvenance(root, {
339
+ dialect,
340
+ columnNamingStrategy: viewStrategy,
341
+ views: buildProjectionViews(root, { dialect, columnNamingStrategy: viewStrategy }),
342
+ });
343
+ governed = scopeExpectedSchema(built, schemaScope);
344
+ }
345
+ // `verifyReplay` calls `applyPending` itself. That is NOT a second replay: the
346
+ // first one recorded every migration in this engine's ledger, so the call finds
347
+ // nothing pending and returns immediately.
348
+ const result = await verifyReplay({
349
+ db: engine.db,
350
+ dialect,
351
+ migrationsDir: dir,
352
+ snapshot,
353
+ ...(governed !== undefined ? { governed } : {}),
354
+ });
355
+ if (result.ok) {
356
+ log.info(`meta verify --replay-snapshot — the replayed chain reproduces the committed snapshot.`);
357
+ return 0;
358
+ }
359
+ log.error(`meta verify --replay-snapshot: the replayed chain does not reproduce the committed snapshot. ` +
360
+ `If this project was adopted with 'migrate baseline --from-db', its chain does not build the ` +
361
+ `schema and this tier does not apply — use --replay instead.`);
362
+ for (const line of summarizeDrift([...result.drift, ...result.unmanaged]))
363
+ log.error(` ${line}`);
364
+ return 1;
365
+ }
141
366
  // -- requirements (#290) ---------------------------------------------------
142
367
  function runRequirementVerify() {
143
- // `@verifiedBy` resolution needs the project on disk, so it is a separate
144
- // scan; its diagnostics carry the same severities and share this reporter.
145
- // `verify.testFiles` lets a project name its own test-file conventions. What counts
146
- // as a test is project-specific, and the built-in patterns are a convenience, not an
147
- // authority see the verified-by-scan header.
148
- const diags = [
149
- ...checkRequirements(root),
150
- ...checkVerifiedBy(root, cwd, forgeConfig?.verify?.testFiles),
151
- ];
368
+ // No test-corpus scan runs here any more. `@verifiedBy` asked the author to
369
+ // name a test and checked only that the NAME occurred somewhere in the test
370
+ // sources an audit of one real 19-name ledger found 4 names that did not
371
+ // verify their claim (a comment, a DI key, a test of a different claim, a
372
+ // test of the output where the claim was about the source text) while verify
373
+ // reported zero errors throughout. Existence was never proof, and no lexical
374
+ // rule reaches the semantic cases. FR-038 retires the attribute rather than
375
+ // narrowing it; the replacement generates the test FROM the requirement, so
376
+ // the link is structural instead of a string the author picks.
377
+ const diags = [...checkRequirements(root)];
152
378
  // Printed on EVERY run, clean or not — a gate that says nothing when it
153
379
  // passes cannot be told apart from a gate that checked nothing, and the
154
380
  // recorded-gap counts are the whole reason to keep a ledger.
155
381
  const s = summariseRequirements(root);
156
382
  if (s !== undefined) {
157
- const order = ["planned", "live", "partial", "abandoned", "superseded"];
383
+ const order = [...REQUIREMENT_STATUSES];
158
384
  const parts = order
159
385
  .filter((k) => (s.byStatus[k] ?? 0) > 0)
160
386
  .map((k) => `${s.byStatus[k]} ${k}`);
387
+ // The file count is the DENOMINATOR'S PROVENANCE, and it is here because
388
+ // `entitiesTotal` is only ever computed over what actually loaded. A spine that
389
+ // covers half an estate reports the covered half as fully claimed — an adopter
390
+ // found `76/76` while two of their four metadata trees were not in `sources` at
391
+ // all, which is why nothing had ever flagged the templates living in them. No
392
+ // check can see a tree it was never pointed at, so the honest fix is to publish
393
+ // what the count was taken over and let a wrong number be noticeable.
161
394
  log.info(`meta verify — requirements: ${s.total} entries (${s.functional} functional, ` +
162
395
  `${s.architectural} architectural) — ${parts.join(", ")}; ` +
163
- `${s.entitiesClaimed}/${s.entitiesTotal} entities claimed.`);
396
+ `${s.entitiesClaimed}/${s.entitiesTotal} entities claimed, ` +
397
+ `counted over ${collection.files.length} metadata file(s).`);
164
398
  if (s.undecided > 0) {
165
399
  log.info(`meta verify — requirements: ${s.undecided} recorded gap(s) with no @disposition. ` +
166
400
  `These are known problems nobody has ruled on — set 'accepted' or 'deferred' to close the question.`);
@@ -188,7 +422,7 @@ wranglerRunner) {
188
422
  function runAntiPatternAdvisory() {
189
423
  let findings;
190
424
  try {
191
- findings = scanSourceForAntiPatterns(cwd);
425
+ findings = scanSourceForAntiPatterns(projectRoot);
192
426
  }
193
427
  catch {
194
428
  return; // never let an advisory scan break verify
@@ -316,6 +550,25 @@ wranglerRunner) {
316
550
  const usingD1 = flags.dialect === "d1";
317
551
  if ((flags.db === undefined && !usingD1) || flags.skipSchema)
318
552
  return 0;
553
+ // A `migrate.scope` matching nothing it could govern is refused, not tolerated —
554
+ // it would make this gate compare zero objects and report "in sync" (see
555
+ // `migrateScopeMismatch`). Checked HERE rather than beside the other collection
556
+ // work at the top of `verifyCommand`, because `migrate.scope` governs only the
557
+ // schema gate: a stale pattern must not fail a `--templates` run that never
558
+ // consults it.
559
+ const scopeMismatch = migrateScopeMismatch(collection, () => {
560
+ const dialect = usingD1 ? "d1" : (flags.dialect ?? inferDialect(flags.db));
561
+ const viewStrategy = forgeConfig?.columnNamingStrategy ?? "snake_case";
562
+ return buildExpectedSchemaWithProvenance(root, {
563
+ dialect,
564
+ columnNamingStrategy: viewStrategy,
565
+ views: buildProjectionViews(root, { dialect, columnNamingStrategy: viewStrategy }),
566
+ }).provenance;
567
+ });
568
+ if (scopeMismatch !== undefined) {
569
+ log.error(`verify: ${scopeMismatch}`);
570
+ return 2;
571
+ }
319
572
  if (usingD1 && flags.db !== undefined) {
320
573
  log.error(`verify: --db is not used for dialect 'd1' — wrangler.toml owns the connection; pass --d1 <binding> instead`);
321
574
  return 2;
@@ -361,14 +614,18 @@ wranglerRunner) {
361
614
  // `actual` this drift comparison uses, and re-introspecting for it would both
362
615
  // cost a second round trip and open a window where the two could disagree.
363
616
  actual = await introspect(kysely.db, kysely.dialect);
364
- driftResult = await computeDriftFromActual(actual, kysely.dialect, root, { allow, views: expectedViews });
617
+ driftResult = await computeDriftFromActual(actual, kysely.dialect, root, {
618
+ allow,
619
+ views: expectedViews,
620
+ ...(schemaScope !== undefined ? { inScope: schemaScope } : {}),
621
+ });
365
622
  }
366
623
  catch (err) {
367
624
  log.error(`verify: failed to introspect ${kysely.displayUrl}: ${err.message}`);
368
625
  return 1;
369
626
  }
370
627
  const snapshotDrift = driftResult.changes.length === 0
371
- ? await checkCommittedSnapshot(actual, kysely.dialect, kysely.displayUrl)
628
+ ? await checkCommittedSnapshot(actual, kysely.dialect, kysely.displayUrl, driftResult)
372
629
  : [];
373
630
  return reportSchemaDrift(driftResult, [...ledgerDrift, ...snapshotDrift], kysely.displayUrl);
374
631
  }
@@ -392,12 +649,12 @@ wranglerRunner) {
392
649
  // computeDriftFromActual and the SAME reportSchemaDrift the sqlite/postgres
393
650
  // path uses — no forked reporting/exit-code logic.
394
651
  async function runD1SchemaVerify(ledgerDrift) {
395
- const d1Config = await resolveD1Config({ d1Binding: flags.d1, remote: flags.remote }, cwd);
652
+ const d1Config = await resolveD1Config({ d1Binding: flags.d1, remote: flags.remote }, projectRoot);
396
653
  const wranglerConfigPath = d1Config.wranglerConfigPath
397
- ? resolvePath(cwd, d1Config.wranglerConfigPath)
398
- : findWranglerConfig(cwd);
654
+ ? resolvePath(projectRoot, d1Config.wranglerConfigPath)
655
+ : findWranglerConfig(projectRoot);
399
656
  if (wranglerConfigPath === undefined && d1Config.binding === undefined) {
400
- log.error(`verify: no wrangler.toml found in ${cwd} or parents; pass --d1 <binding> to bypass`);
657
+ log.error(`verify: no wrangler.toml found in ${projectRoot} or parents; pass --d1 <binding> to bypass`);
401
658
  return 2;
402
659
  }
403
660
  let binding;
@@ -423,7 +680,7 @@ wranglerRunner) {
423
680
  command: sql,
424
681
  configPath: wranglerConfigPath,
425
682
  });
426
- const { stdout } = await activeWranglerRunner(wranglerArgs, cwd);
683
+ const { stdout } = await activeWranglerRunner(wranglerArgs, projectRoot);
427
684
  return stdout;
428
685
  };
429
686
  let actual;
@@ -439,7 +696,11 @@ wranglerRunner) {
439
696
  const expectedViews = buildProjectionViews(root, { dialect: "d1", columnNamingStrategy: viewStrategy });
440
697
  let driftResult;
441
698
  try {
442
- driftResult = await computeDriftFromActual(actual, "d1", root, { allow, views: expectedViews });
699
+ driftResult = await computeDriftFromActual(actual, "d1", root, {
700
+ allow,
701
+ views: expectedViews,
702
+ ...(schemaScope !== undefined ? { inScope: schemaScope } : {}),
703
+ });
443
704
  }
444
705
  catch (err) {
445
706
  log.error(`verify: ${err.message}`);
@@ -478,15 +739,15 @@ wranglerRunner) {
478
739
  // Fails OPEN when there is no snapshot on disk (a project that has never generated one
479
740
  // offline is not in an error state) and when the file cannot be read or parsed (that is
480
741
  // migrate's error to raise, with its own message, not a drift verdict).
481
- async function checkCommittedSnapshot(actual, dialect, displayUrl) {
742
+ async function checkCommittedSnapshot(actual, dialect, displayUrl, governed) {
482
743
  if (dialect === "d1")
483
744
  return []; // d1 keeps migrations Wrangler-native; no offline snapshot
484
745
  // Resolve the migrations dir through migrate's OWN precedence (flag > config >
485
746
  // default) rather than re-deriving it, so verify can never look somewhere migrate
486
747
  // does not write. Only `outDir` is consumed; the rest of the resolved config is
487
748
  // migrate's business.
488
- const migrateConfig = await resolveMigrateConfig(EMPTY_MIGRATE_FLAGS, cwd);
489
- const dir = resolvePath(cwd, migrateConfig.outDir);
749
+ const migrateConfig = await resolveMigrateConfig(EMPTY_MIGRATE_FLAGS, projectRoot);
750
+ const dir = resolvePath(projectRoot, migrateConfig.outDir);
490
751
  let snapshot;
491
752
  try {
492
753
  snapshot = await readSnapshot(snapshotPath(dir, dialect));
@@ -496,11 +757,28 @@ wranglerRunner) {
496
757
  }
497
758
  if (snapshot === null)
498
759
  return [];
760
+ // Out-of-scope objects leave BOTH sides of this comparison, and the schema pin
761
+ // comes from the scope decision the DRIFT comparison already made — one door
762
+ // (migrate-ts's `excludeFromSnapshot` + `scopedDiffInputs`), not a fifth
763
+ // hand-rolled copy of the three-part contract. `unmanagedNames` suppresses the
764
+ // actual side only, which is right for the metadata↔DB diff (its expected side
765
+ // is already scoped) but not here: the committed snapshot IS the expected side,
766
+ // and a snapshot written before the scope was declared still carries the other
767
+ // owner's tables. Re-deriving the pin from the snapshot is what left an empty
768
+ // (never-migrated) snapshot reaching `diff`'s whole-database fallback.
499
769
  const result = await diff({
500
- expected: snapshot,
770
+ ...scopedDiffInputs(excludeFromSnapshot(snapshot, governed), collectUnmanagedNames(root)),
501
771
  actual,
502
772
  allow: {},
503
- unmanagedNames: collectUnmanagedNames(root),
773
+ // #297 — the SAME pipeline `meta migrate` runs, or this gate answers a different
774
+ // question than the one it reports on. `DiffArgs.dialect` is optional, so omitting
775
+ // it was silently accepted: views fell through to comparing our emitted body
776
+ // against the deparser's (never equal, so permanent drift on Postgres), CHECK
777
+ // constraints were skipped entirely, and SQLite type canonicalization no-opped.
778
+ //
779
+ // Note `unmanagedNames` is NOT restated here: `scopedDiffInputs` above supplies it
780
+ // MERGED with the out-of-scope set, and a second key would silently drop that half.
781
+ dialect,
504
782
  });
505
783
  if (result.changes.length === 0)
506
784
  return [];
@@ -519,6 +797,12 @@ wranglerRunner) {
519
797
  if (externalDeclared.length > 0) {
520
798
  log.info(`meta verify — ${externalDeclared.length} object(s) external (declared @unmanaged, managed elsewhere): ${externalDeclared.join(", ")}`);
521
799
  }
800
+ // Same reasoning for the per-command scope: an object `migrate.scope` excluded
801
+ // was NOT checked, and silence would misreport it as checked-and-clean. Shared
802
+ // wording with `meta migrate` — one declaration, one sentence about it.
803
+ if (driftResult.outOfScope.length > 0) {
804
+ log.info(outOfScopeNote("verify", driftResult.outOfScope));
805
+ }
522
806
  const changes = driftResult.changes;
523
807
  if (changes.length === 0 && ledgerDrift.length === 0) {
524
808
  log.info(`meta verify — schema in sync with ${displayUrl}.`);
@@ -548,9 +832,13 @@ wranglerRunner) {
548
832
  "Run 'meta init' to scaffold one, or run without --codegen.");
549
833
  return 2;
550
834
  }
835
+ // The identical predicate `meta gen` applies (Task 12b / design §7 open
836
+ // question 3) — a `gen` that committed under a narrowed scope and a
837
+ // `verify --codegen` that regenerates unscoped would disagree about which
838
+ // files should exist, reporting every out-of-scope entity as drift.
551
839
  let result;
552
840
  try {
553
- result = await computeCodegenDrift(forgeConfig, root, cwd);
841
+ result = await computeCodegenDrift(forgeConfig, root, genConfigDir, collection.inScope);
554
842
  }
555
843
  catch (err) {
556
844
  log.error(`verify --codegen: regeneration failed: ${err.message}`);