@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.
- package/README.md +78 -0
- package/dist/src/commands/docs.d.ts.map +1 -1
- package/dist/src/commands/docs.js +148 -50
- package/dist/src/commands/docs.js.map +1 -1
- package/dist/src/commands/export.d.ts.map +1 -1
- package/dist/src/commands/export.js +28 -6
- package/dist/src/commands/export.js.map +1 -1
- package/dist/src/commands/gen.d.ts.map +1 -1
- package/dist/src/commands/gen.js +61 -22
- package/dist/src/commands/gen.js.map +1 -1
- package/dist/src/commands/init.d.ts +8 -0
- package/dist/src/commands/init.d.ts.map +1 -1
- package/dist/src/commands/init.js +142 -52
- package/dist/src/commands/init.js.map +1 -1
- package/dist/src/commands/migrate.d.ts +9 -2
- package/dist/src/commands/migrate.d.ts.map +1 -1
- package/dist/src/commands/migrate.js +399 -73
- package/dist/src/commands/migrate.js.map +1 -1
- package/dist/src/commands/prompt-snapshot.d.ts.map +1 -1
- package/dist/src/commands/prompt-snapshot.js +40 -18
- package/dist/src/commands/prompt-snapshot.js.map +1 -1
- package/dist/src/commands/upgrade.d.ts +2 -0
- package/dist/src/commands/upgrade.d.ts.map +1 -0
- package/dist/src/commands/upgrade.js +139 -0
- package/dist/src/commands/upgrade.js.map +1 -0
- package/dist/src/commands/verify.d.ts.map +1 -1
- package/dist/src/commands/verify.js +336 -48
- package/dist/src/commands/verify.js.map +1 -1
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/index.js +46 -15
- package/dist/src/index.js.map +1 -1
- package/dist/src/lib/allow.d.ts.map +1 -1
- package/dist/src/lib/allow.js +4 -0
- package/dist/src/lib/allow.js.map +1 -1
- package/dist/src/lib/args.d.ts +16 -2
- package/dist/src/lib/args.d.ts.map +1 -1
- package/dist/src/lib/args.js +19 -3
- package/dist/src/lib/args.js.map +1 -1
- package/dist/src/lib/codegen-drift.d.ts +8 -1
- package/dist/src/lib/codegen-drift.d.ts.map +1 -1
- package/dist/src/lib/codegen-drift.js +9 -1
- package/dist/src/lib/codegen-drift.js.map +1 -1
- package/dist/src/lib/detect-stack.d.ts +1 -1
- package/dist/src/lib/detect-stack.d.ts.map +1 -1
- package/dist/src/lib/detect-stack.js +22 -37
- package/dist/src/lib/detect-stack.js.map +1 -1
- package/dist/src/lib/load-metaobjects-config.d.ts +48 -1
- package/dist/src/lib/load-metaobjects-config.d.ts.map +1 -1
- package/dist/src/lib/load-metaobjects-config.js +80 -1
- package/dist/src/lib/load-metaobjects-config.js.map +1 -1
- package/dist/src/lib/manifest-ignored-check.d.ts +12 -0
- package/dist/src/lib/manifest-ignored-check.d.ts.map +1 -0
- package/dist/src/lib/manifest-ignored-check.js +74 -0
- package/dist/src/lib/manifest-ignored-check.js.map +1 -0
- package/dist/src/lib/migrate-scope.d.ts +49 -0
- package/dist/src/lib/migrate-scope.d.ts.map +1 -0
- package/dist/src/lib/migrate-scope.js +92 -0
- package/dist/src/lib/migrate-scope.js.map +1 -0
- package/dist/src/lib/output.d.ts +1 -1
- package/dist/src/lib/output.d.ts.map +1 -1
- package/dist/src/lib/output.js +9 -3
- package/dist/src/lib/output.js.map +1 -1
- package/dist/src/lib/requirement-check.d.ts.map +1 -1
- package/dist/src/lib/requirement-check.js +10 -57
- package/dist/src/lib/requirement-check.js.map +1 -1
- package/package.json +11 -11
- package/src/commands/docs.ts +158 -49
- package/src/commands/export.ts +30 -6
- package/src/commands/gen.ts +62 -22
- package/src/commands/init.ts +161 -58
- package/src/commands/migrate.ts +423 -75
- package/src/commands/prompt-snapshot.ts +43 -18
- package/src/commands/upgrade.ts +156 -0
- package/src/commands/verify.ts +373 -48
- package/src/index.ts +46 -15
- package/src/lib/allow.ts +4 -0
- package/src/lib/args.ts +35 -4
- package/src/lib/codegen-drift.ts +9 -0
- package/src/lib/detect-stack.ts +20 -33
- package/src/lib/load-metaobjects-config.ts +84 -2
- package/src/lib/manifest-ignored-check.ts +75 -0
- package/src/lib/migrate-scope.ts +102 -0
- package/src/lib/output.ts +8 -4
- package/src/lib/requirement-check.ts +10 -58
- package/dist/src/lib/verified-by-scan.d.ts +0 -20
- package/dist/src/lib/verified-by-scan.d.ts.map +0 -1
- package/dist/src/lib/verified-by-scan.js +0 -344
- package/dist/src/lib/verified-by-scan.js.map +0 -1
- package/src/lib/verified-by-scan.ts +0 -375
package/src/commands/verify.ts
CHANGED
|
@@ -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
|
|
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.
|
|
78
|
-
*
|
|
79
|
-
*
|
|
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(
|
|
204
|
+
forgeConfig = await loadMetaobjectsConfig(genConfigDir);
|
|
138
205
|
} catch {
|
|
139
206
|
forgeConfig = undefined;
|
|
140
207
|
}
|
|
141
|
-
|
|
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(
|
|
148
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
197
|
-
//
|
|
198
|
-
//
|
|
199
|
-
//
|
|
200
|
-
//
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
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 = [
|
|
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(
|
|
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, {
|
|
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 },
|
|
755
|
+
const d1Config = await resolveD1Config({ d1Binding: flags.d1, remote: flags.remote }, projectRoot);
|
|
464
756
|
|
|
465
757
|
const wranglerConfigPath = d1Config.wranglerConfigPath
|
|
466
|
-
? resolvePath(
|
|
467
|
-
: findWranglerConfig(
|
|
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 ${
|
|
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,
|
|
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, {
|
|
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,
|
|
564
|
-
const dir = resolvePath(
|
|
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
|
-
|
|
880
|
+
...scopedDiffInputs(excludeFromSnapshot(snapshot, governed), collectUnmanagedNames(root)),
|
|
575
881
|
actual,
|
|
576
882
|
allow: {},
|
|
577
|
-
|
|
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:
|
|
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,
|
|
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;
|