@metaobjectsdev/cli 0.23.2 → 0.24.1
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 +157 -59
- 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 +66 -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 +163 -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 +360 -48
- package/dist/src/commands/verify.js.map +1 -1
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/index.js +55 -19
- 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 +79 -1
- package/dist/src/lib/load-metaobjects-config.d.ts.map +1 -1
- package/dist/src/lib/load-metaobjects-config.js +123 -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 +174 -61
- package/src/commands/export.ts +30 -6
- package/src/commands/gen.ts +68 -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 +179 -0
- package/src/commands/verify.ts +397 -48
- package/src/index.ts +55 -19
- 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 +129 -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
|
@@ -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, resolveGenCollection, 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 {
|
|
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.
|
|
37
|
-
*
|
|
38
|
-
*
|
|
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).");
|
|
80
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;
|
|
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(
|
|
143
|
+
forgeConfig = await loadMetaobjectsConfig(genConfigDir);
|
|
90
144
|
}
|
|
91
145
|
catch {
|
|
92
146
|
forgeConfig = undefined;
|
|
93
147
|
}
|
|
94
|
-
|
|
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(
|
|
100
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
144
|
-
//
|
|
145
|
-
//
|
|
146
|
-
//
|
|
147
|
-
//
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
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 = [
|
|
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(
|
|
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, {
|
|
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 },
|
|
652
|
+
const d1Config = await resolveD1Config({ d1Binding: flags.d1, remote: flags.remote }, projectRoot);
|
|
396
653
|
const wranglerConfigPath = d1Config.wranglerConfigPath
|
|
397
|
-
? resolvePath(
|
|
398
|
-
: findWranglerConfig(
|
|
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 ${
|
|
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,
|
|
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, {
|
|
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,
|
|
489
|
-
const dir = resolvePath(
|
|
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
|
-
|
|
770
|
+
...scopedDiffInputs(excludeFromSnapshot(snapshot, governed), collectUnmanagedNames(root)),
|
|
501
771
|
actual,
|
|
502
772
|
allow: {},
|
|
503
|
-
|
|
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,37 @@ 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.
|
|
839
|
+
//
|
|
840
|
+
// The same argument governs the SOURCE SET (#340), and it is the reason this
|
|
841
|
+
// resolves its own collection instead of reusing the outer one: `gen` in a
|
|
842
|
+
// sub-project generates from that package's own sources, so a `--codegen` gate
|
|
843
|
+
// that regenerated from the ancestor's wider set would report every file the
|
|
844
|
+
// ancestor contributes as drift — turning the #340 fix into a broken gate. It is
|
|
845
|
+
// re-resolved rather than hoisted because `verify`'s subverbs COMPOSE: `--db` and
|
|
846
|
+
// `--templates` are answering a question about the whole declared collection, and
|
|
847
|
+
// narrowing the outer `root` would silently change what they check.
|
|
848
|
+
const genCollection = await resolveGenCollection(collection, genConfigDir);
|
|
849
|
+
let codegenRoot = root;
|
|
850
|
+
if (genCollection !== collection) {
|
|
851
|
+
try {
|
|
852
|
+
codegenRoot = await loadMemory(genCollection.configDir, {
|
|
853
|
+
files: genCollection.files,
|
|
854
|
+
...configLoadOptions,
|
|
855
|
+
strict: !flags.lax,
|
|
856
|
+
});
|
|
857
|
+
}
|
|
858
|
+
catch (err) {
|
|
859
|
+
log.error(`verify --codegen: failed to load this package's metadata: ${err.message}`);
|
|
860
|
+
return 2;
|
|
861
|
+
}
|
|
862
|
+
}
|
|
551
863
|
let result;
|
|
552
864
|
try {
|
|
553
|
-
result = await computeCodegenDrift(forgeConfig,
|
|
865
|
+
result = await computeCodegenDrift(forgeConfig, codegenRoot, genConfigDir, genCollection.inScope);
|
|
554
866
|
}
|
|
555
867
|
catch (err) {
|
|
556
868
|
log.error(`verify --codegen: regeneration failed: ${err.message}`);
|