@metaobjectsdev/cli 0.24.4 → 0.25.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 (74) hide show
  1. package/README.md +2 -1
  2. package/dist/src/commands/docs.d.ts +14 -1
  3. package/dist/src/commands/docs.d.ts.map +1 -1
  4. package/dist/src/commands/docs.js +153 -8
  5. package/dist/src/commands/docs.js.map +1 -1
  6. package/dist/src/commands/eject.d.ts +38 -0
  7. package/dist/src/commands/eject.d.ts.map +1 -0
  8. package/dist/src/commands/eject.js +233 -0
  9. package/dist/src/commands/eject.js.map +1 -0
  10. package/dist/src/commands/gen.d.ts.map +1 -1
  11. package/dist/src/commands/gen.js +43 -20
  12. package/dist/src/commands/gen.js.map +1 -1
  13. package/dist/src/commands/init.d.ts +15 -1
  14. package/dist/src/commands/init.d.ts.map +1 -1
  15. package/dist/src/commands/init.js +208 -20
  16. package/dist/src/commands/init.js.map +1 -1
  17. package/dist/src/commands/types.d.ts +2 -1
  18. package/dist/src/commands/types.d.ts.map +1 -1
  19. package/dist/src/commands/types.js +165 -28
  20. package/dist/src/commands/types.js.map +1 -1
  21. package/dist/src/commands/verify.d.ts +9 -1
  22. package/dist/src/commands/verify.d.ts.map +1 -1
  23. package/dist/src/commands/verify.js +299 -51
  24. package/dist/src/commands/verify.js.map +1 -1
  25. package/dist/src/index.d.ts.map +1 -1
  26. package/dist/src/index.js +105 -4
  27. package/dist/src/index.js.map +1 -1
  28. package/dist/src/lib/advisory.d.ts +77 -0
  29. package/dist/src/lib/advisory.d.ts.map +1 -0
  30. package/dist/src/lib/advisory.js +97 -0
  31. package/dist/src/lib/advisory.js.map +1 -0
  32. package/dist/src/lib/anti-patterns.d.ts +27 -3
  33. package/dist/src/lib/anti-patterns.d.ts.map +1 -1
  34. package/dist/src/lib/anti-patterns.js +145 -8
  35. package/dist/src/lib/anti-patterns.js.map +1 -1
  36. package/dist/src/lib/args.d.ts +35 -1
  37. package/dist/src/lib/args.d.ts.map +1 -1
  38. package/dist/src/lib/args.js +32 -1
  39. package/dist/src/lib/args.js.map +1 -1
  40. package/dist/src/lib/detect-stack.d.ts.map +1 -1
  41. package/dist/src/lib/detect-stack.js +4 -13
  42. package/dist/src/lib/detect-stack.js.map +1 -1
  43. package/dist/src/lib/docs-drift.d.ts +31 -0
  44. package/dist/src/lib/docs-drift.d.ts.map +1 -0
  45. package/dist/src/lib/docs-drift.js +195 -0
  46. package/dist/src/lib/docs-drift.js.map +1 -0
  47. package/dist/src/lib/format.d.ts +10 -0
  48. package/dist/src/lib/format.d.ts.map +1 -1
  49. package/dist/src/lib/format.js +15 -0
  50. package/dist/src/lib/format.js.map +1 -1
  51. package/dist/src/lib/output.d.ts +13 -0
  52. package/dist/src/lib/output.d.ts.map +1 -1
  53. package/dist/src/lib/output.js +16 -1
  54. package/dist/src/lib/output.js.map +1 -1
  55. package/dist/src/lib/package-manifest.d.ts +27 -0
  56. package/dist/src/lib/package-manifest.d.ts.map +1 -0
  57. package/dist/src/lib/package-manifest.js +52 -0
  58. package/dist/src/lib/package-manifest.js.map +1 -0
  59. package/package.json +11 -11
  60. package/src/commands/docs.ts +194 -8
  61. package/src/commands/eject.ts +282 -0
  62. package/src/commands/gen.ts +54 -19
  63. package/src/commands/init.ts +217 -19
  64. package/src/commands/types.ts +185 -34
  65. package/src/commands/verify.ts +358 -47
  66. package/src/index.ts +114 -5
  67. package/src/lib/advisory.ts +150 -0
  68. package/src/lib/anti-patterns.ts +163 -8
  69. package/src/lib/args.ts +74 -2
  70. package/src/lib/detect-stack.ts +4 -11
  71. package/src/lib/docs-drift.ts +222 -0
  72. package/src/lib/format.ts +14 -0
  73. package/src/lib/output.ts +33 -2
  74. package/src/lib/package-manifest.ts +58 -0
@@ -10,6 +10,11 @@
10
10
  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
+ import { emitStructured, type OutputFormat } from "../lib/format.js";
14
+ import {
15
+ antiPatternRows, ranSection, skippedSection, warnCapped,
16
+ type AdvisoryDiagnosticRow, type AdvisoryFindingRow, type AdvisorySection,
17
+ } from "../lib/advisory.js";
13
18
  import { warnIfAgentContextStale } from "../lib/agent-context-staleness.js";
14
19
  import { warnIfManifestIgnored } from "../lib/manifest-ignored-check.js";
15
20
  import { scanSourceForAntiPatterns } from "../lib/anti-patterns.js";
@@ -17,6 +22,7 @@ import { FileProvider } from "../lib/file-provider.js";
17
22
  import { derivePayloadFieldTree } from "../lib/payload-field-tree.js";
18
23
  import { loadMemoryOptionsFrom, loadMetaobjectsConfig, resolveGenCollection, resolveGenConfigDir } from "../lib/load-metaobjects-config.js";
19
24
  import { computeCodegenDrift } from "../lib/codegen-drift.js";
25
+ import { computeDocsDrift } from "../lib/docs-drift.js";
20
26
  import {
21
27
  checkRequirements, summariseRequirements, scanRequirements, type Diagnostic,
22
28
  } from "../lib/requirement-check.js";
@@ -29,7 +35,7 @@ import {
29
35
  type WranglerRunner,
30
36
  } from "../lib/wrangler.js";
31
37
  import type { MetaobjectsGenConfig } from "@metaobjectsdev/codegen-ts";
32
- import { buildProjectionViews } from "@metaobjectsdev/codegen-ts";
38
+ import { buildProjectionViews, resolveDocsConfig } from "@metaobjectsdev/codegen-ts";
33
39
  import { buildKyselyFromUrl, inferDialect, type Dialect } from "../lib/kysely.js";
34
40
  import { tokensToAllowOptions, describeChange } from "../lib/allow.js";
35
41
  import {
@@ -117,13 +123,29 @@ export async function verifyCommand(
117
123
  cwd: string,
118
124
  /** Injectable wrangler runner (D1 path only) — tests pass a mock; production uses the default. */
119
125
  wranglerRunner?: WranglerRunner,
126
+ /**
127
+ * Output format, threaded from the global `--format` flag (4th position, matching
128
+ * `migrateCommand`). It used to be passed to `gen` and `migrate` ONLY, so
129
+ * `meta verify --format json` was accepted, validated, exited 0 — and printed
130
+ * human text. An adopter who guessed the right flag lost silently.
131
+ */
132
+ fmt: OutputFormat = "text",
120
133
  ): Promise<number> {
121
134
  const activeWranglerRunner = wranglerRunner ?? defaultWranglerRunner;
135
+ // In a structured format stdout carries exactly ONE document, so every narration
136
+ // line moves to stderr (`say` below) instead of being printed into the payload or
137
+ // dropped. Same split `meta migrate` makes for its out-of-scope note.
138
+ const structured = fmt !== "text";
139
+ const say = (msg: string): void => { if (structured) log.warn(msg); else log.info(msg); };
122
140
  let flags: ReturnType<typeof parseVerifyArgs>;
123
141
  try {
124
142
  flags = parseVerifyArgs(args);
125
143
  } catch (err) {
126
- log.error((err as Error).message);
144
+ const msg = (err as Error).message;
145
+ log.error(msg);
146
+ // A structured caller gets a structured refusal. Exiting 2 with an EMPTY stdout
147
+ // is the same silence this command is being fixed for, one level up.
148
+ emitStructured({ error: msg, hint: "run `meta verify --help` for the accepted flags" }, fmt);
127
149
  return 2;
128
150
  }
129
151
 
@@ -136,10 +158,12 @@ export async function verifyCommand(
136
158
  // The schema gate is selected by the presence of --db, or --dialect d1 (#225 —
137
159
  // D1 has no URL connection); that check lives inside runSchemaVerify.
138
160
  const runCodegen = flags.codegen;
161
+ const runDocs = flags.docs;
139
162
  if (!flags.anyExplicit) {
140
- log.info(
163
+ say(
141
164
  "meta verify — running --templates (default). Explicit subverbs: " +
142
165
  "--templates (prompt drift), --db/--dialect d1 (schema drift), --codegen (codegen drift), " +
166
+ "--docs (docs drift), " +
143
167
  "--replay/--replay-snapshot (the committed migration chain replays from empty).",
144
168
  );
145
169
  }
@@ -157,7 +181,9 @@ export async function verifyCommand(
157
181
  try {
158
182
  collection = await resolveCollection(cwd);
159
183
  } catch (err) {
160
- log.error((err as Error).message);
184
+ const msg = (err as Error).message;
185
+ log.error(msg);
186
+ emitStructured({ error: msg, hint: "declare metadata `sources` in .metaobjects/config.json, or run `meta init`" }, fmt);
161
187
  return 2;
162
188
  }
163
189
 
@@ -225,17 +251,39 @@ export async function verifyCommand(
225
251
  } catch (err) {
226
252
  const msg = (err as Error).message;
227
253
  log.error(`failed to load metadata: ${msg}`);
228
- // Strict-load rejection (ADR-0023): give the author the three exits register
229
- // the attr on a provider, stash it in the `attr.properties` bag, or pass --lax.
254
+ // Strict-load rejection (ADR-0023). Two different failures reach here and they need
255
+ // different advice:
256
+ //
257
+ // a TYPO'd or genuinely undeclared attr — the three exits below are right;
258
+ // a RETIRED attr — the loader already knows the exits and attaches them as
259
+ // ADR-0009 `suggestions[]`, so we print those and say nothing of our own.
260
+ //
261
+ // The distinction is not cosmetic. The middle generic exit — the `attr.properties`
262
+ // bag — is EXEMPT from the strict-attr check by subtype, so it loads. Offer it for a
263
+ // retired attribute and the author gets a green `meta verify` over a value that now
264
+ // reaches nothing: a correct, loud failure converted into a quiet, wrong pass. That is
265
+ // strictly worse than the error it replaced.
230
266
  const code = (err as { code?: string }).code;
231
- if (code === ERR_UNKNOWN_ATTR || msg.includes("Unknown attribute")) {
232
- log.error(
233
- "meta verify is strict (ADR-0023): every authored @attr must be declared. " +
234
- "Fix: register the attr on a metadata provider, OR move arbitrary " +
235
- "author-supplied properties into an `attr.properties` bag, OR re-run " +
236
- "with `meta verify --lax` to keep the legacy open-attr load.",
237
- );
267
+ const suggestions = (err as { suggestions?: string[] }).suggestions;
268
+ const strictHint =
269
+ "meta verify is strict (ADR-0023): every authored @attr must be declared. " +
270
+ "Fix: register the attr on a metadata provider, OR move arbitrary " +
271
+ "author-supplied properties into an `attr.properties` bag, OR re-run " +
272
+ "with `meta verify --lax` to keep the legacy open-attr load.";
273
+ const isStrictAttr = code === ERR_UNKNOWN_ATTR || msg.includes("Unknown attribute");
274
+ if (suggestions !== undefined && suggestions.length > 0) {
275
+ for (const s of suggestions) log.error(` ${s}`);
276
+ } else if (isStrictAttr) {
277
+ log.error(strictHint);
238
278
  }
279
+ // The same refusal, machine-readable. No gate ran, so there is no verdict to
280
+ // report — only why there is none.
281
+ emitStructured({
282
+ error: `failed to load metadata: ${msg}`,
283
+ hint: suggestions !== undefined && suggestions.length > 0
284
+ ? suggestions.join(" ")
285
+ : isStrictAttr ? strictHint : "fix the metadata error above and re-run",
286
+ }, fmt);
239
287
  return 1;
240
288
  }
241
289
 
@@ -248,12 +296,33 @@ export async function verifyCommand(
248
296
  const promptsDir = join(projectRoot, flags.prompts ?? DEFAULT_PROMPTS_DIR);
249
297
  const provider = new FileProvider(promptsDir);
250
298
 
299
+ // The two advisory sections, captured as the gates run so the structured payload
300
+ // can carry them IN FULL. They were previously formatted straight to stderr and
301
+ // existed nowhere else, which is why 96% of a 239-finding report was unreachable
302
+ // by any flag, env var or format.
303
+ let requirementSection: AdvisorySection<AdvisoryDiagnosticRow> =
304
+ skippedSection("the requirement pass did not run");
305
+ let antiPatternSection: AdvisorySection<AdvisoryFindingRow> =
306
+ skippedSection("the advisory anti-pattern pass did not run");
307
+ // The ledger counts `meta verify` prints on every run. Undefined for a project
308
+ // declaring no requirement.* node at all (opt-in by declaration) — the payload
309
+ // then omits the block rather than reporting zeroes that would read as an empty
310
+ // ledger instead of no ledger.
311
+ let requirementCounts: RequirementCounts | undefined;
312
+
313
+ // Whether the schema gate is selected. `runSchemaVerify` makes this decision
314
+ // internally (it is selected by --db or --dialect d1, not by a subverb flag);
315
+ // naming it here lets the structured payload report "ran" from the SAME
316
+ // expression rather than a second guess at it.
317
+ const ranSchemaGate = (flags.db !== undefined || flags.dialect === "d1") && !flags.skipSchema;
318
+
251
319
  // Exit-code composition: the overall result is the MAX across every selected
252
320
  // subverb so ANY kind of drift fails CI. Each gate only runs when its mode is
253
321
  // selected; an unselected gate contributes 0.
254
322
  const templateExit = runTemplates ? runTemplateVerify() : 0;
255
323
  const schemaExit = await runSchemaVerify();
256
324
  const codegenExit = runCodegen ? await runCodegenVerify() : 0;
325
+ const docsExit = runDocs ? await runDocsVerify() : 0;
257
326
  // Requirements have no subverb: `requirement.*` nodes are metadata, so they
258
327
  // are checked on every `meta verify`. Opt-in by DECLARATION — a model with no
259
328
  // requirement nodes is silent, not in drift.
@@ -268,9 +337,42 @@ export async function verifyCommand(
268
337
  // model. Warnings ONLY — never changes the exit code (bias to under-flagging).
269
338
  // Suppressed with --no-antipatterns or META_NO_ANTIPATTERNS=1 for the rare
270
339
  // noisy project (both opt-outs work on `meta verify` and `meta gen`).
271
- if (!flags.noAntipatterns && process.env.META_NO_ANTIPATTERNS !== "1") runAntiPatternAdvisory();
340
+ runAntiPatternAdvisory();
341
+
342
+ const exitCode = Math.max(
343
+ templateExit,
344
+ schemaExit,
345
+ codegenExit,
346
+ docsExit,
347
+ requirementExit,
348
+ replayExit,
349
+ );
350
+
351
+ if (structured) {
352
+ emitStructured(
353
+ buildVerifyPayload({
354
+ gates: [
355
+ { gate: "templates", ran: runTemplates, ok: templateExit === 0 },
356
+ // The schema gate decides internally whether it is selected (--db, or
357
+ // --dialect d1), so "ran" is read off the same signals rather than
358
+ // restated: a payload that claims a gate ran when it did not is the
359
+ // failure mode this whole change exists to remove.
360
+ { gate: "schema", ran: ranSchemaGate, ok: schemaExit === 0 },
361
+ { gate: "codegen", ran: runCodegen, ok: codegenExit === 0 },
362
+ { gate: "docs", ran: runDocs, ok: docsExit === 0 },
363
+ { gate: "requirements", ran: true, ok: requirementExit === 0 },
364
+ { gate: "replay", ran: flags.replay || flags.replaySnapshot, ok: replayExit === 0 },
365
+ ],
366
+ exitCode,
367
+ requirements: requirementSection,
368
+ requirementCounts,
369
+ antiPatterns: antiPatternSection,
370
+ }),
371
+ fmt,
372
+ );
373
+ }
272
374
 
273
- return Math.max(templateExit, schemaExit, codegenExit, requirementExit, replayExit);
375
+ return exitCode;
274
376
 
275
377
  // -- replay (#313) ---------------------------------------------------------
276
378
  /**
@@ -359,10 +461,10 @@ export async function verifyCommand(
359
461
  // empty replay is still compared against a snapshot that may record dozens of
360
462
  // tables, rather than reporting success having compared nothing.
361
463
  if (applied.pending.length === 0) {
362
- log.info(`meta verify --replay: no committed migrations — nothing to replay`);
464
+ say(`meta verify --replay: no committed migrations — nothing to replay`);
363
465
  if (!flags.replaySnapshot) return 0;
364
466
  } else {
365
- log.info(
467
+ say(
366
468
  `meta verify --replay — the committed chain applies to an empty ${dialect} database ` +
367
469
  `(${applied.applied.length} migration(s)).`,
368
470
  );
@@ -402,11 +504,11 @@ export async function verifyCommand(
402
504
  try {
403
505
  snapshot = await readSnapshot(snapshotPath(dir, dialect));
404
506
  } catch {
405
- log.info(`meta verify --replay-snapshot: the committed snapshot could not be read — nothing to compare`);
507
+ say(`meta verify --replay-snapshot: the committed snapshot could not be read — nothing to compare`);
406
508
  return 0;
407
509
  }
408
510
  if (snapshot === null) {
409
- log.info(`meta verify --replay-snapshot: no committed snapshot — nothing to compare`);
511
+ say(`meta verify --replay-snapshot: no committed snapshot — nothing to compare`);
410
512
  return 0;
411
513
  }
412
514
 
@@ -440,7 +542,7 @@ export async function verifyCommand(
440
542
  ...(governed !== undefined ? { governed } : {}),
441
543
  });
442
544
  if (result.ok) {
443
- log.info(`meta verify --replay-snapshot — the replayed chain reproduces the committed snapshot.`);
545
+ say(`meta verify --replay-snapshot — the replayed chain reproduces the committed snapshot.`);
444
546
  return 0;
445
547
  }
446
548
 
@@ -486,34 +588,50 @@ export async function verifyCommand(
486
588
  // all, which is why nothing had ever flagged the templates living in them. No
487
589
  // check can see a tree it was never pointed at, so the honest fix is to publish
488
590
  // what the count was taken over and let a wrong number be noticeable.
489
- log.info(
591
+ say(
490
592
  `meta verify — requirements: ${s.total} entries (${s.functional} functional, ` +
491
593
  `${s.architectural} architectural) — ${parts.join(", ")}; ` +
492
594
  `${s.entitiesClaimed}/${s.entitiesTotal} entities claimed, ` +
493
595
  `counted over ${collection.files.length} metadata file(s).`,
494
596
  );
495
597
  if (s.undecided > 0) {
496
- log.info(
598
+ say(
497
599
  `meta verify — requirements: ${s.undecided} recorded gap(s) with no @disposition. ` +
498
600
  `These are known problems nobody has ruled on — set 'accepted' or 'deferred' to close the question.`,
499
601
  );
500
602
  }
603
+ // The same counts, machine-readable. `metadataFiles` travels with them for the
604
+ // reason the text line carries it: the claimed/total ratio is only ever taken
605
+ // over what actually loaded, so a reader needs the denominator's provenance to
606
+ // notice a spine that covers half an estate.
607
+ requirementCounts = {
608
+ total: s.total,
609
+ functional: s.functional,
610
+ architectural: s.architectural,
611
+ byStatus: order
612
+ .filter((k) => (s.byStatus[k] ?? 0) > 0)
613
+ .map((k) => ({ status: k, count: s.byStatus[k] ?? 0 })),
614
+ undecided: s.undecided,
615
+ entitiesClaimed: s.entitiesClaimed,
616
+ entitiesTotal: s.entitiesTotal,
617
+ metadataFiles: collection.files.length,
618
+ };
501
619
  }
502
620
 
503
621
  const errors = diags.filter((d) => d.severity === "error");
504
622
  const warns = diags.filter((d) => d.severity === "warn");
505
- const CAP = 20;
506
- const fmt = (d: Diagnostic): string =>
623
+ // Named `fmtDiag`, not `fmt`: `fmt` is this command's OUTPUT FORMAT parameter,
624
+ // and a shadow of it inside the one function that must not confuse the two is
625
+ // how a structured run quietly reverts to text.
626
+ const fmtDiag = (d: Diagnostic): string =>
507
627
  ` ${d.code}${d.path !== undefined ? ` [${d.path}]` : ""}: ${d.message}`;
508
- /** Print a capped run of warnings. Capped per SECTION, never across them: a
509
- * ledger of a few hundred entries can produce hundreds of prose findings, and a
510
- * shared cap would let the advisory lint push every gate warning off the end. */
511
- const warnCapped = (ds: readonly Diagnostic[]): void => {
512
- for (const d of ds.slice(0, CAP)) log.warn(fmt(d));
513
- if (ds.length > CAP) log.warn(` …and ${ds.length - CAP} more.`);
514
- };
515
- for (const d of errors) log.error(fmt(d));
516
- warnCapped(warns);
628
+ // Capped per SECTION, never across them: a ledger of a few hundred entries can
629
+ // produce hundreds of prose findings, and a shared budget would let the advisory
630
+ // lint push every gate warning off the end. The cap VALUE is now one shared
631
+ // constant (`--limit`), so raising it cannot miss a section — errors stay
632
+ // uncapped, as they always were.
633
+ for (const d of errors) log.error(fmtDiag(d));
634
+ warnCapped(warns.map(fmtDiag), flags.limit, { structured });
517
635
 
518
636
  // -- the authoring lint: its own section, its own cap ----------------------
519
637
  // Separate from the gate above because it makes a different claim. The gate
@@ -542,9 +660,17 @@ export async function verifyCommand(
542
660
  `meta verify — requirements: ${lint.length} authoring warning(s) ` +
543
661
  `(advisory — does not fail the build):`,
544
662
  );
545
- warnCapped(lint);
663
+ warnCapped(lint.map(fmtDiag), flags.limit, { structured });
546
664
  }
547
665
 
666
+ // Everything this pass found, uncapped, for the structured payload — gate
667
+ // diagnostics and lint findings in one list, each row saying which it is, so a
668
+ // reader can tell what can fail a build from what never can.
669
+ requirementSection = ranSection<AdvisoryDiagnosticRow>([
670
+ ...diags.map((d) => toDiagnosticRow(d, "gate")),
671
+ ...lint.map((d) => toDiagnosticRow(d, "lint")),
672
+ ]);
673
+
548
674
  if (errors.length > 0) {
549
675
  log.error(`meta verify — requirements: ${errors.length} error(s).`);
550
676
  return 1;
@@ -553,21 +679,34 @@ export async function verifyCommand(
553
679
  }
554
680
 
555
681
  // -- verify-as-teacher (advisory) ------------------------------------------
682
+ // Records its result EITHER WAY — a skip carries its reason rather than looking
683
+ // like a clean scan. Warnings only; nothing here reaches the exit code (the
684
+ // scanner's own header: bias to under-flagging, never a non-zero exit).
556
685
  function runAntiPatternAdvisory(): void {
686
+ if (flags.noAntipatterns) {
687
+ antiPatternSection = skippedSection("suppressed by --no-antipatterns");
688
+ return;
689
+ }
690
+ if (process.env.META_NO_ANTIPATTERNS === "1") {
691
+ antiPatternSection = skippedSection("suppressed by META_NO_ANTIPATTERNS=1");
692
+ return;
693
+ }
557
694
  let findings;
558
695
  try {
559
- findings = scanSourceForAntiPatterns(projectRoot);
560
- } catch {
561
- return; // never let an advisory scan break verify
696
+ const ignore = forgeConfig?.verify?.antiPatternIgnore;
697
+ findings = scanSourceForAntiPatterns(projectRoot, ignore !== undefined ? { ignore } : undefined);
698
+ } catch (err) {
699
+ // Never let an advisory scan break verify — and never report it as clean.
700
+ antiPatternSection = skippedSection(`the scan failed: ${(err as Error).message}`);
701
+ return;
562
702
  }
703
+ antiPatternSection = ranSection(antiPatternRows(findings));
563
704
  if (findings.length === 0) return;
564
- const CAP = 10;
565
705
  log.warn(
566
706
  `meta verify — ${findings.length} place(s) hand-roll what MetaObjects can model ` +
567
707
  `(advisory — does not fail the build):`,
568
708
  );
569
- for (const f of findings.slice(0, CAP)) log.warn(` ${f.message}`);
570
- if (findings.length > CAP) log.warn(` …and ${findings.length - CAP} more.`);
709
+ warnCapped(findings.map((f) => ` ${f.message}`), flags.limit, { structured });
571
710
  }
572
711
 
573
712
  // -- template (prompt / output) drift --------------------------------------
@@ -575,7 +714,7 @@ export async function verifyCommand(
575
714
  // ADR-0039: effective children — resolve rather than rely on root being unextended.
576
715
  const templates = root.children().filter((c) => c.type === TYPE_TEMPLATE);
577
716
  if (templates.length === 0) {
578
- log.info("meta verify — no template.* nodes found; nothing to check.");
717
+ say("meta verify — no template.* nodes found; nothing to check.");
579
718
  return 0;
580
719
  }
581
720
 
@@ -688,7 +827,7 @@ export async function verifyCommand(
688
827
  );
689
828
  return 1;
690
829
  }
691
- log.info(
830
+ say(
692
831
  `meta verify — ${checkedTemplates} template(s) clean${warnCount > 0 ? ` (${warnCount} warning(s))` : ""}.`,
693
832
  );
694
833
  return 0;
@@ -949,9 +1088,16 @@ export async function verifyCommand(
949
1088
  if (result.changes.length === 0) return [];
950
1089
 
951
1090
  return [
1091
+ // `meta migrate --from-db` is NOT the repair: it writes a snapshot only when it has
1092
+ // changes to EMIT, so on a database that already matches the metadata it reports
1093
+ // "no schema changes / nothing to do", writes nothing, and leaves the stale snapshot
1094
+ // exactly as it was — so this gate fails again, identically, with the user having
1095
+ // been told everything is in sync. `baseline --from-db` rewrites it unconditionally,
1096
+ // which is the whole point of the subcommand.
952
1097
  `the committed schema snapshot disagrees with ${displayUrl} ` +
953
1098
  `(${result.changes.length} difference(s)) — the next 'meta migrate' would emit DDL from it ` +
954
- `and fail at apply. Re-derive it with 'meta migrate --from-db --db <url> --dialect ${dialect}'.`,
1099
+ `and fail at apply. Re-derive it with ` +
1100
+ `'meta migrate baseline --from-db --db <url> --dialect ${dialect}'.`,
955
1101
  ...summarizeDrift(result.changes),
956
1102
  ];
957
1103
  }
@@ -962,7 +1108,7 @@ export async function verifyCommand(
962
1108
  // annotate them as external (declared) rather than let them vanish silently.
963
1109
  const externalDeclared = collectUnmanagedNames(root);
964
1110
  if (externalDeclared.length > 0) {
965
- log.info(
1111
+ say(
966
1112
  `meta verify — ${externalDeclared.length} object(s) external (declared @unmanaged, managed elsewhere): ${externalDeclared.join(", ")}`,
967
1113
  );
968
1114
  }
@@ -971,12 +1117,12 @@ export async function verifyCommand(
971
1117
  // was NOT checked, and silence would misreport it as checked-and-clean. Shared
972
1118
  // wording with `meta migrate` — one declaration, one sentence about it.
973
1119
  if (driftResult.outOfScope.length > 0) {
974
- log.info(outOfScopeNote("verify", driftResult.outOfScope));
1120
+ say(outOfScopeNote("verify", driftResult.outOfScope));
975
1121
  }
976
1122
 
977
1123
  const changes = driftResult.changes;
978
1124
  if (changes.length === 0 && ledgerDrift.length === 0) {
979
- log.info(`meta verify — schema in sync with ${displayUrl}.`);
1125
+ say(`meta verify — schema in sync with ${displayUrl}.`);
980
1126
  return 0;
981
1127
  }
982
1128
 
@@ -1030,6 +1176,10 @@ export async function verifyCommand(
1030
1176
  });
1031
1177
  } catch (err) {
1032
1178
  log.error(`verify --codegen: failed to load this package's metadata: ${(err as Error).message}`);
1179
+ // The second door onto the same strict load. It carried no remedy at all, which
1180
+ // meant a retirement diagnosed here told the author what broke and nothing about
1181
+ // how to fix it — the half-true rule this file's sibling comment warns about.
1182
+ for (const s of (err as { suggestions?: string[] }).suggestions ?? []) log.error(` ${s}`);
1033
1183
  return 2;
1034
1184
  }
1035
1185
  }
@@ -1048,7 +1198,7 @@ export async function verifyCommand(
1048
1198
  }
1049
1199
 
1050
1200
  if (result.clean) {
1051
- log.info("meta verify — generated output is in sync with the metadata (no codegen drift).");
1201
+ say("meta verify — generated output is in sync with the metadata (no codegen drift).");
1052
1202
  return 0;
1053
1203
  }
1054
1204
 
@@ -1059,6 +1209,64 @@ export async function verifyCommand(
1059
1209
  log.error("Run 'meta gen' to regenerate, then commit the result.");
1060
1210
  return 1;
1061
1211
  }
1212
+
1213
+ // -- docs drift -------------------------------------------------------------
1214
+ // Gated on --docs. Runs `meta docs` into a temp dir and diffs the committed docs
1215
+ // tree. See lib/docs-drift.ts for the two deliberate differences from --codegen
1216
+ // (a byte difference IS drift here, and a committed file a regen would not emit is
1217
+ // never reported — `docs.outDir` is full of files MetaObjects did not write).
1218
+ //
1219
+ // It needs a config for the same reason `--codegen` does: `docs.outDir` says which
1220
+ // tree to compare against, and the `agent` surface will not even materialise
1221
+ // without one.
1222
+ async function runDocsVerify(): Promise<number> {
1223
+ if (forgeConfig === undefined) {
1224
+ log.error(
1225
+ "verify --docs: no metaobjects.config.ts found (or it is invalid) — " +
1226
+ "cannot locate the committed docs tree to diff against. " +
1227
+ "Run 'meta init' to scaffold one, or run without --docs.",
1228
+ );
1229
+ return 2;
1230
+ }
1231
+
1232
+ // Resolved through the SAME resolver `meta docs` uses, so a project that moved its
1233
+ // docs elsewhere is checked where its docs actually are. `outputLayout` is the
1234
+ // documented fallback for `docs.layout`.
1235
+ const docsCfg = resolveDocsConfig(forgeConfig.docs, {}, forgeConfig.outputLayout ?? "flat");
1236
+ let result;
1237
+ try {
1238
+ result = await computeDocsDrift({
1239
+ projectRoot: genConfigDir,
1240
+ docsDir: resolvePath(genConfigDir, docsCfg.outDir),
1241
+ cwd: genConfigDir,
1242
+ });
1243
+ } catch (err) {
1244
+ log.error(`verify --docs: regeneration failed: ${(err as Error).message}`);
1245
+ return 1;
1246
+ }
1247
+
1248
+ if (result.error !== undefined) {
1249
+ log.error(result.error);
1250
+ return 2;
1251
+ }
1252
+
1253
+ if (result.clean) {
1254
+ // The denominator is what was actually COMPARED, so the passing line and the
1255
+ // failing line below count the same set. A gate whose two halves divide by
1256
+ // different numbers is how `verify --templates` came to report seven templates
1257
+ // vanishing between a red run and a green one.
1258
+ say(`meta verify — ${result.checked} docs page(s) match a fresh 'meta docs' (no docs drift).`);
1259
+ return 0;
1260
+ }
1261
+
1262
+ log.error(
1263
+ `meta verify — docs drift (${result.driftedFiles.length} of ${result.checked} page(s) ` +
1264
+ `differ from a fresh 'meta docs'):`,
1265
+ );
1266
+ for (const line of result.lines) log.error(` ${line}`);
1267
+ log.error("Run 'meta docs' to regenerate, then commit the result.");
1268
+ return 1;
1269
+ }
1062
1270
  }
1063
1271
 
1064
1272
  /**
@@ -1104,3 +1312,106 @@ function summarizeDrift(changes: Change[]): string[] {
1104
1312
  return `${p.glyph} ${p.noun} ${describeChange(c)}`;
1105
1313
  });
1106
1314
  }
1315
+
1316
+ // ---------------------------------------------------------------------------
1317
+ // structured output (--format toon|json)
1318
+ // ---------------------------------------------------------------------------
1319
+
1320
+ /** The ledger counts `meta verify` prints on every run, machine-readable. */
1321
+ interface RequirementCounts {
1322
+ total: number;
1323
+ functional: number;
1324
+ architectural: number;
1325
+ byStatus: { status: string; count: number }[];
1326
+ undecided: number;
1327
+ entitiesClaimed: number;
1328
+ entitiesTotal: number;
1329
+ /** How many metadata files the two entity counts were taken over. */
1330
+ metadataFiles: number;
1331
+ }
1332
+
1333
+ /** One gate's verdict. `ran: false` is NOT a pass — it is "not selected". */
1334
+ interface VerifyGateRow {
1335
+ gate: string;
1336
+ ran: boolean;
1337
+ ok: boolean;
1338
+ }
1339
+
1340
+ /** Project a requirement diagnostic into a payload row. */
1341
+ function toDiagnosticRow(d: Diagnostic, source: "gate" | "lint"): AdvisoryDiagnosticRow {
1342
+ return {
1343
+ code: d.code,
1344
+ // "" rather than omitted: a tabular encoder (TOON) needs every row to carry the
1345
+ // same keys, and an absent column would silently shift the rest.
1346
+ path: d.path ?? "",
1347
+ severity: d.severity,
1348
+ source,
1349
+ message: d.message,
1350
+ };
1351
+ }
1352
+
1353
+ /**
1354
+ * What `--format json|toon` puts on stdout.
1355
+ *
1356
+ * The rule this shape is built to keep: **anything not represented here is
1357
+ * STATED, not silently dropped.** A half-payload that looks complete is the very
1358
+ * defect being fixed — an agent read a run carrying 233 advisory findings as "all
1359
+ * green across the board", because the findings were on stderr as text and the
1360
+ * payload mentioned none of them.
1361
+ *
1362
+ * So: every gate's verdict, every advisory finding (uncapped), and an explicit
1363
+ * `notRepresented` list naming what stays on stderr as text.
1364
+ */
1365
+ function buildVerifyPayload(input: {
1366
+ gates: VerifyGateRow[];
1367
+ exitCode: number;
1368
+ requirements: AdvisorySection<AdvisoryDiagnosticRow>;
1369
+ requirementCounts: RequirementCounts | undefined;
1370
+ antiPatterns: AdvisorySection<AdvisoryFindingRow>;
1371
+ }): Record<string, unknown> {
1372
+ const ran = input.gates.filter((g) => g.ran);
1373
+ const failed = ran.filter((g) => !g.ok);
1374
+ const parts: string[] = [
1375
+ failed.length === 0
1376
+ ? `${ran.length} gate(s) ran, all clean`
1377
+ : `${failed.length} of ${ran.length} gate(s) failed (${failed.map((g) => g.gate).join(", ")})`,
1378
+ ];
1379
+ if (input.antiPatterns.status === "ran" && input.antiPatterns.total > 0) {
1380
+ parts.push(`${input.antiPatterns.total} advisory anti-pattern finding(s)`);
1381
+ }
1382
+ if (input.requirements.total > 0) {
1383
+ parts.push(`${input.requirements.total} requirement diagnostic(s)`);
1384
+ }
1385
+
1386
+ const help: string[] = [];
1387
+ if (failed.length > 0) {
1388
+ help.push(
1389
+ `the failing gate's drift DETAIL is printed as text on stderr — this payload carries the verdict only`,
1390
+ );
1391
+ }
1392
+ if (input.antiPatterns.total > 0) {
1393
+ help.push(
1394
+ `${input.antiPatterns.total} authored site(s) hand-roll what MetaObjects can model — see antiPatterns.rows[] and run \`meta types <construct>\``,
1395
+ );
1396
+ }
1397
+ if (failed.length === 0 && input.antiPatterns.total === 0 && input.requirements.total === 0) {
1398
+ help.push("no drift and nothing advisory to answer — nothing to do");
1399
+ }
1400
+
1401
+ return {
1402
+ verify: input.gates,
1403
+ exitCode: input.exitCode,
1404
+ summary: parts.join("; "),
1405
+ help,
1406
+ antiPatterns: input.antiPatterns,
1407
+ requirements: input.requirements,
1408
+ ...(input.requirementCounts !== undefined ? { requirementCounts: input.requirementCounts } : {}),
1409
+ // The honest boundary. Everything named here is REACHABLE — it is printed as
1410
+ // text on stderr — but it is not in this document, and a reader must not have
1411
+ // to discover that by its absence.
1412
+ notRepresented: [
1413
+ "per-gate drift detail (which template variable drifted, which schema change, which generated file differs, which migration failed to replay) — printed as text on stderr; this payload carries each gate's pass/fail verdict",
1414
+ "the loader's own warnings and the agent-context/manifest advisories — printed as text on stderr",
1415
+ ],
1416
+ };
1417
+ }