@metaobjectsdev/cli 0.24.5 → 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 (64) 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 +1 -1
  7. package/dist/src/commands/eject.js +3 -3
  8. package/dist/src/commands/eject.js.map +1 -1
  9. package/dist/src/commands/gen.d.ts.map +1 -1
  10. package/dist/src/commands/gen.js +43 -20
  11. package/dist/src/commands/gen.js.map +1 -1
  12. package/dist/src/commands/init.d.ts +4 -0
  13. package/dist/src/commands/init.d.ts.map +1 -1
  14. package/dist/src/commands/init.js +31 -6
  15. package/dist/src/commands/init.js.map +1 -1
  16. package/dist/src/commands/types.d.ts +2 -1
  17. package/dist/src/commands/types.d.ts.map +1 -1
  18. package/dist/src/commands/types.js +165 -28
  19. package/dist/src/commands/types.js.map +1 -1
  20. package/dist/src/commands/verify.d.ts +9 -1
  21. package/dist/src/commands/verify.d.ts.map +1 -1
  22. package/dist/src/commands/verify.js +291 -50
  23. package/dist/src/commands/verify.js.map +1 -1
  24. package/dist/src/index.d.ts.map +1 -1
  25. package/dist/src/index.js +82 -5
  26. package/dist/src/index.js.map +1 -1
  27. package/dist/src/lib/advisory.d.ts +77 -0
  28. package/dist/src/lib/advisory.d.ts.map +1 -0
  29. package/dist/src/lib/advisory.js +97 -0
  30. package/dist/src/lib/advisory.js.map +1 -0
  31. package/dist/src/lib/anti-patterns.d.ts +27 -3
  32. package/dist/src/lib/anti-patterns.d.ts.map +1 -1
  33. package/dist/src/lib/anti-patterns.js +145 -8
  34. package/dist/src/lib/anti-patterns.js.map +1 -1
  35. package/dist/src/lib/args.d.ts +27 -1
  36. package/dist/src/lib/args.d.ts.map +1 -1
  37. package/dist/src/lib/args.js +13 -1
  38. package/dist/src/lib/args.js.map +1 -1
  39. package/dist/src/lib/docs-drift.d.ts +31 -0
  40. package/dist/src/lib/docs-drift.d.ts.map +1 -0
  41. package/dist/src/lib/docs-drift.js +195 -0
  42. package/dist/src/lib/docs-drift.js.map +1 -0
  43. package/dist/src/lib/format.d.ts +10 -0
  44. package/dist/src/lib/format.d.ts.map +1 -1
  45. package/dist/src/lib/format.js +15 -0
  46. package/dist/src/lib/format.js.map +1 -1
  47. package/dist/src/lib/output.d.ts +13 -0
  48. package/dist/src/lib/output.d.ts.map +1 -1
  49. package/dist/src/lib/output.js +16 -1
  50. package/dist/src/lib/output.js.map +1 -1
  51. package/package.json +10 -10
  52. package/src/commands/docs.ts +194 -8
  53. package/src/commands/eject.ts +3 -3
  54. package/src/commands/gen.ts +54 -19
  55. package/src/commands/init.ts +35 -6
  56. package/src/commands/types.ts +185 -34
  57. package/src/commands/verify.ts +350 -46
  58. package/src/index.ts +91 -6
  59. package/src/lib/advisory.ts +150 -0
  60. package/src/lib/anti-patterns.ts +163 -8
  61. package/src/lib/args.ts +40 -2
  62. package/src/lib/docs-drift.ts +222 -0
  63. package/src/lib/format.ts +14 -0
  64. package/src/lib/output.ts +33 -2
@@ -9,6 +9,8 @@
9
9
  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
+ import { emitStructured } from "../lib/format.js";
13
+ import { antiPatternRows, ranSection, skippedSection, warnCapped, } from "../lib/advisory.js";
12
14
  import { warnIfAgentContextStale } from "../lib/agent-context-staleness.js";
13
15
  import { warnIfManifestIgnored } from "../lib/manifest-ignored-check.js";
14
16
  import { scanSourceForAntiPatterns } from "../lib/anti-patterns.js";
@@ -16,11 +18,12 @@ import { FileProvider } from "../lib/file-provider.js";
16
18
  import { derivePayloadFieldTree } from "../lib/payload-field-tree.js";
17
19
  import { loadMemoryOptionsFrom, loadMetaobjectsConfig, resolveGenCollection, resolveGenConfigDir } from "../lib/load-metaobjects-config.js";
18
20
  import { computeCodegenDrift } from "../lib/codegen-drift.js";
21
+ import { computeDocsDrift } from "../lib/docs-drift.js";
19
22
  import { checkRequirements, summariseRequirements, scanRequirements, } from "../lib/requirement-check.js";
20
23
  import { lintRequirements } from "../lib/requirement-lint.js";
21
24
  import { resolveD1Config, resolveMigrateConfig } from "../lib/config.js";
22
25
  import { buildWranglerExecuteArgs, defaultWranglerRunner, isWranglerLocalD1StatePath, } from "../lib/wrangler.js";
23
- import { buildProjectionViews } from "@metaobjectsdev/codegen-ts";
26
+ import { buildProjectionViews, resolveDocsConfig } from "@metaobjectsdev/codegen-ts";
24
27
  import { buildKyselyFromUrl, inferDialect } from "../lib/kysely.js";
25
28
  import { tokensToAllowOptions, describeChange } from "../lib/allow.js";
26
29
  import { computeDrift, computeDriftFromActual, collectUnmanagedNames, excludeFromSnapshot, scopedDiffInputs, scopeExpectedSchema, buildExpectedSchemaWithProvenance, applyPending, openReplayEngine, verifyReplay, introspect, diff, readSnapshot, snapshotPath, introspectD1, findWranglerConfig, parseWranglerConfig, resolveD1Binding, } from "@metaobjectsdev/migrate-ts";
@@ -60,14 +63,33 @@ function attrAsStringArray(attr) {
60
63
  }
61
64
  export async function verifyCommand(args, cwd,
62
65
  /** Injectable wrangler runner (D1 path only) — tests pass a mock; production uses the default. */
63
- wranglerRunner) {
66
+ wranglerRunner,
67
+ /**
68
+ * Output format, threaded from the global `--format` flag (4th position, matching
69
+ * `migrateCommand`). It used to be passed to `gen` and `migrate` ONLY, so
70
+ * `meta verify --format json` was accepted, validated, exited 0 — and printed
71
+ * human text. An adopter who guessed the right flag lost silently.
72
+ */
73
+ fmt = "text") {
64
74
  const activeWranglerRunner = wranglerRunner ?? defaultWranglerRunner;
75
+ // In a structured format stdout carries exactly ONE document, so every narration
76
+ // line moves to stderr (`say` below) instead of being printed into the payload or
77
+ // dropped. Same split `meta migrate` makes for its out-of-scope note.
78
+ const structured = fmt !== "text";
79
+ const say = (msg) => { if (structured)
80
+ log.warn(msg);
81
+ else
82
+ log.info(msg); };
65
83
  let flags;
66
84
  try {
67
85
  flags = parseVerifyArgs(args);
68
86
  }
69
87
  catch (err) {
70
- log.error(err.message);
88
+ const msg = err.message;
89
+ log.error(msg);
90
+ // A structured caller gets a structured refusal. Exiting 2 with an EMPTY stdout
91
+ // is the same silence this command is being fixed for, one level up.
92
+ emitStructured({ error: msg, hint: "run `meta verify --help` for the accepted flags" }, fmt);
71
93
  return 2;
72
94
  }
73
95
  // ADR-0021 D2 — explicit verify subverbs. Each flag selects one drift mode;
@@ -79,9 +101,11 @@ wranglerRunner) {
79
101
  // The schema gate is selected by the presence of --db, or --dialect d1 (#225 —
80
102
  // D1 has no URL connection); that check lives inside runSchemaVerify.
81
103
  const runCodegen = flags.codegen;
104
+ const runDocs = flags.docs;
82
105
  if (!flags.anyExplicit) {
83
- log.info("meta verify — running --templates (default). Explicit subverbs: " +
106
+ say("meta verify — running --templates (default). Explicit subverbs: " +
84
107
  "--templates (prompt drift), --db/--dialect d1 (schema drift), --codegen (codegen drift), " +
108
+ "--docs (docs drift), " +
85
109
  "--replay/--replay-snapshot (the committed migration chain replays from empty).");
86
110
  }
87
111
  // Where the metadata lives is `resolveCollection`'s decision, not a hardcoded
@@ -98,7 +122,9 @@ wranglerRunner) {
98
122
  collection = await resolveCollection(cwd);
99
123
  }
100
124
  catch (err) {
101
- log.error(err.message);
125
+ const msg = err.message;
126
+ log.error(msg);
127
+ emitStructured({ error: msg, hint: "declare metadata `sources` in .metaobjects/config.json, or run `meta init`" }, fmt);
102
128
  return 2;
103
129
  }
104
130
  // The project root is whichever directory `resolveCollection` decided the
@@ -163,15 +189,40 @@ wranglerRunner) {
163
189
  catch (err) {
164
190
  const msg = err.message;
165
191
  log.error(`failed to load metadata: ${msg}`);
166
- // Strict-load rejection (ADR-0023): give the author the three exits register
167
- // the attr on a provider, stash it in the `attr.properties` bag, or pass --lax.
192
+ // Strict-load rejection (ADR-0023). Two different failures reach here and they need
193
+ // different advice:
194
+ //
195
+ // a TYPO'd or genuinely undeclared attr — the three exits below are right;
196
+ // a RETIRED attr — the loader already knows the exits and attaches them as
197
+ // ADR-0009 `suggestions[]`, so we print those and say nothing of our own.
198
+ //
199
+ // The distinction is not cosmetic. The middle generic exit — the `attr.properties`
200
+ // bag — is EXEMPT from the strict-attr check by subtype, so it loads. Offer it for a
201
+ // retired attribute and the author gets a green `meta verify` over a value that now
202
+ // reaches nothing: a correct, loud failure converted into a quiet, wrong pass. That is
203
+ // strictly worse than the error it replaced.
168
204
  const code = err.code;
169
- if (code === ERR_UNKNOWN_ATTR || msg.includes("Unknown attribute")) {
170
- log.error("meta verify is strict (ADR-0023): every authored @attr must be declared. " +
171
- "Fix: register the attr on a metadata provider, OR move arbitrary " +
172
- "author-supplied properties into an `attr.properties` bag, OR re-run " +
173
- "with `meta verify --lax` to keep the legacy open-attr load.");
205
+ const suggestions = err.suggestions;
206
+ const strictHint = "meta verify is strict (ADR-0023): every authored @attr must be declared. " +
207
+ "Fix: register the attr on a metadata provider, OR move arbitrary " +
208
+ "author-supplied properties into an `attr.properties` bag, OR re-run " +
209
+ "with `meta verify --lax` to keep the legacy open-attr load.";
210
+ const isStrictAttr = code === ERR_UNKNOWN_ATTR || msg.includes("Unknown attribute");
211
+ if (suggestions !== undefined && suggestions.length > 0) {
212
+ for (const s of suggestions)
213
+ log.error(` ${s}`);
214
+ }
215
+ else if (isStrictAttr) {
216
+ log.error(strictHint);
174
217
  }
218
+ // The same refusal, machine-readable. No gate ran, so there is no verdict to
219
+ // report — only why there is none.
220
+ emitStructured({
221
+ error: `failed to load metadata: ${msg}`,
222
+ hint: suggestions !== undefined && suggestions.length > 0
223
+ ? suggestions.join(" ")
224
+ : isStrictAttr ? strictHint : "fix the metadata error above and re-run",
225
+ }, fmt);
175
226
  return 1;
176
227
  }
177
228
  // The schema gate governs exactly the objects `meta migrate` governs — ONE
@@ -181,12 +232,29 @@ wranglerRunner) {
181
232
  const schemaScope = collection.inMigrateScope;
182
233
  const promptsDir = join(projectRoot, flags.prompts ?? DEFAULT_PROMPTS_DIR);
183
234
  const provider = new FileProvider(promptsDir);
235
+ // The two advisory sections, captured as the gates run so the structured payload
236
+ // can carry them IN FULL. They were previously formatted straight to stderr and
237
+ // existed nowhere else, which is why 96% of a 239-finding report was unreachable
238
+ // by any flag, env var or format.
239
+ let requirementSection = skippedSection("the requirement pass did not run");
240
+ let antiPatternSection = skippedSection("the advisory anti-pattern pass did not run");
241
+ // The ledger counts `meta verify` prints on every run. Undefined for a project
242
+ // declaring no requirement.* node at all (opt-in by declaration) — the payload
243
+ // then omits the block rather than reporting zeroes that would read as an empty
244
+ // ledger instead of no ledger.
245
+ let requirementCounts;
246
+ // Whether the schema gate is selected. `runSchemaVerify` makes this decision
247
+ // internally (it is selected by --db or --dialect d1, not by a subverb flag);
248
+ // naming it here lets the structured payload report "ran" from the SAME
249
+ // expression rather than a second guess at it.
250
+ const ranSchemaGate = (flags.db !== undefined || flags.dialect === "d1") && !flags.skipSchema;
184
251
  // Exit-code composition: the overall result is the MAX across every selected
185
252
  // subverb so ANY kind of drift fails CI. Each gate only runs when its mode is
186
253
  // selected; an unselected gate contributes 0.
187
254
  const templateExit = runTemplates ? runTemplateVerify() : 0;
188
255
  const schemaExit = await runSchemaVerify();
189
256
  const codegenExit = runCodegen ? await runCodegenVerify() : 0;
257
+ const docsExit = runDocs ? await runDocsVerify() : 0;
190
258
  // Requirements have no subverb: `requirement.*` nodes are metadata, so they
191
259
  // are checked on every `meta verify`. Opt-in by DECLARATION — a model with no
192
260
  // requirement nodes is silent, not in drift.
@@ -200,9 +268,29 @@ wranglerRunner) {
200
268
  // model. Warnings ONLY — never changes the exit code (bias to under-flagging).
201
269
  // Suppressed with --no-antipatterns or META_NO_ANTIPATTERNS=1 for the rare
202
270
  // noisy project (both opt-outs work on `meta verify` and `meta gen`).
203
- if (!flags.noAntipatterns && process.env.META_NO_ANTIPATTERNS !== "1")
204
- runAntiPatternAdvisory();
205
- return Math.max(templateExit, schemaExit, codegenExit, requirementExit, replayExit);
271
+ runAntiPatternAdvisory();
272
+ const exitCode = Math.max(templateExit, schemaExit, codegenExit, docsExit, requirementExit, replayExit);
273
+ if (structured) {
274
+ emitStructured(buildVerifyPayload({
275
+ gates: [
276
+ { gate: "templates", ran: runTemplates, ok: templateExit === 0 },
277
+ // The schema gate decides internally whether it is selected (--db, or
278
+ // --dialect d1), so "ran" is read off the same signals rather than
279
+ // restated: a payload that claims a gate ran when it did not is the
280
+ // failure mode this whole change exists to remove.
281
+ { gate: "schema", ran: ranSchemaGate, ok: schemaExit === 0 },
282
+ { gate: "codegen", ran: runCodegen, ok: codegenExit === 0 },
283
+ { gate: "docs", ran: runDocs, ok: docsExit === 0 },
284
+ { gate: "requirements", ran: true, ok: requirementExit === 0 },
285
+ { gate: "replay", ran: flags.replay || flags.replaySnapshot, ok: replayExit === 0 },
286
+ ],
287
+ exitCode,
288
+ requirements: requirementSection,
289
+ requirementCounts,
290
+ antiPatterns: antiPatternSection,
291
+ }), fmt);
292
+ }
293
+ return exitCode;
206
294
  // -- replay (#313) ---------------------------------------------------------
207
295
  /**
208
296
  * Replay the committed migration chain into an EMPTY throwaway database and assert
@@ -278,12 +366,12 @@ wranglerRunner) {
278
366
  // empty replay is still compared against a snapshot that may record dozens of
279
367
  // tables, rather than reporting success having compared nothing.
280
368
  if (applied.pending.length === 0) {
281
- log.info(`meta verify --replay: no committed migrations — nothing to replay`);
369
+ say(`meta verify --replay: no committed migrations — nothing to replay`);
282
370
  if (!flags.replaySnapshot)
283
371
  return 0;
284
372
  }
285
373
  else {
286
- log.info(`meta verify --replay — the committed chain applies to an empty ${dialect} database ` +
374
+ say(`meta verify --replay — the committed chain applies to an empty ${dialect} database ` +
287
375
  `(${applied.applied.length} migration(s)).`);
288
376
  }
289
377
  if (!flags.replaySnapshot)
@@ -318,11 +406,11 @@ wranglerRunner) {
318
406
  snapshot = await readSnapshot(snapshotPath(dir, dialect));
319
407
  }
320
408
  catch {
321
- log.info(`meta verify --replay-snapshot: the committed snapshot could not be read — nothing to compare`);
409
+ say(`meta verify --replay-snapshot: the committed snapshot could not be read — nothing to compare`);
322
410
  return 0;
323
411
  }
324
412
  if (snapshot === null) {
325
- log.info(`meta verify --replay-snapshot: no committed snapshot — nothing to compare`);
413
+ say(`meta verify --replay-snapshot: no committed snapshot — nothing to compare`);
326
414
  return 0;
327
415
  }
328
416
  // A scoped project carries the OTHER owner's tables into its snapshot on purpose
@@ -354,7 +442,7 @@ wranglerRunner) {
354
442
  ...(governed !== undefined ? { governed } : {}),
355
443
  });
356
444
  if (result.ok) {
357
- log.info(`meta verify --replay-snapshot — the replayed chain reproduces the committed snapshot.`);
445
+ say(`meta verify --replay-snapshot — the replayed chain reproduces the committed snapshot.`);
358
446
  return 0;
359
447
  }
360
448
  log.error(`meta verify --replay-snapshot: the replayed chain does not reproduce the committed snapshot. ` +
@@ -396,31 +484,45 @@ wranglerRunner) {
396
484
  // all, which is why nothing had ever flagged the templates living in them. No
397
485
  // check can see a tree it was never pointed at, so the honest fix is to publish
398
486
  // what the count was taken over and let a wrong number be noticeable.
399
- log.info(`meta verify — requirements: ${s.total} entries (${s.functional} functional, ` +
487
+ say(`meta verify — requirements: ${s.total} entries (${s.functional} functional, ` +
400
488
  `${s.architectural} architectural) — ${parts.join(", ")}; ` +
401
489
  `${s.entitiesClaimed}/${s.entitiesTotal} entities claimed, ` +
402
490
  `counted over ${collection.files.length} metadata file(s).`);
403
491
  if (s.undecided > 0) {
404
- log.info(`meta verify — requirements: ${s.undecided} recorded gap(s) with no @disposition. ` +
492
+ say(`meta verify — requirements: ${s.undecided} recorded gap(s) with no @disposition. ` +
405
493
  `These are known problems nobody has ruled on — set 'accepted' or 'deferred' to close the question.`);
406
494
  }
495
+ // The same counts, machine-readable. `metadataFiles` travels with them for the
496
+ // reason the text line carries it: the claimed/total ratio is only ever taken
497
+ // over what actually loaded, so a reader needs the denominator's provenance to
498
+ // notice a spine that covers half an estate.
499
+ requirementCounts = {
500
+ total: s.total,
501
+ functional: s.functional,
502
+ architectural: s.architectural,
503
+ byStatus: order
504
+ .filter((k) => (s.byStatus[k] ?? 0) > 0)
505
+ .map((k) => ({ status: k, count: s.byStatus[k] ?? 0 })),
506
+ undecided: s.undecided,
507
+ entitiesClaimed: s.entitiesClaimed,
508
+ entitiesTotal: s.entitiesTotal,
509
+ metadataFiles: collection.files.length,
510
+ };
407
511
  }
408
512
  const errors = diags.filter((d) => d.severity === "error");
409
513
  const warns = diags.filter((d) => d.severity === "warn");
410
- const CAP = 20;
411
- const fmt = (d) => ` ${d.code}${d.path !== undefined ? ` [${d.path}]` : ""}: ${d.message}`;
412
- /** Print a capped run of warnings. Capped per SECTION, never across them: a
413
- * ledger of a few hundred entries can produce hundreds of prose findings, and a
414
- * shared cap would let the advisory lint push every gate warning off the end. */
415
- const warnCapped = (ds) => {
416
- for (const d of ds.slice(0, CAP))
417
- log.warn(fmt(d));
418
- if (ds.length > CAP)
419
- log.warn(` …and ${ds.length - CAP} more.`);
420
- };
514
+ // Named `fmtDiag`, not `fmt`: `fmt` is this command's OUTPUT FORMAT parameter,
515
+ // and a shadow of it inside the one function that must not confuse the two is
516
+ // how a structured run quietly reverts to text.
517
+ const fmtDiag = (d) => ` ${d.code}${d.path !== undefined ? ` [${d.path}]` : ""}: ${d.message}`;
518
+ // Capped per SECTION, never across them: a ledger of a few hundred entries can
519
+ // produce hundreds of prose findings, and a shared budget would let the advisory
520
+ // lint push every gate warning off the end. The cap VALUE is now one shared
521
+ // constant (`--limit`), so raising it cannot miss a section — errors stay
522
+ // uncapped, as they always were.
421
523
  for (const d of errors)
422
- log.error(fmt(d));
423
- warnCapped(warns);
524
+ log.error(fmtDiag(d));
525
+ warnCapped(warns.map(fmtDiag), flags.limit, { structured });
424
526
  // -- the authoring lint: its own section, its own cap ----------------------
425
527
  // Separate from the gate above because it makes a different claim. The gate
426
528
  // says the ledger DISAGREES WITH THE MODEL; the lint says it agrees but
@@ -446,8 +548,15 @@ wranglerRunner) {
446
548
  if (lint.length > 0) {
447
549
  log.warn(`meta verify — requirements: ${lint.length} authoring warning(s) ` +
448
550
  `(advisory — does not fail the build):`);
449
- warnCapped(lint);
551
+ warnCapped(lint.map(fmtDiag), flags.limit, { structured });
450
552
  }
553
+ // Everything this pass found, uncapped, for the structured payload — gate
554
+ // diagnostics and lint findings in one list, each row saying which it is, so a
555
+ // reader can tell what can fail a build from what never can.
556
+ requirementSection = ranSection([
557
+ ...diags.map((d) => toDiagnosticRow(d, "gate")),
558
+ ...lint.map((d) => toDiagnosticRow(d, "lint")),
559
+ ]);
451
560
  if (errors.length > 0) {
452
561
  log.error(`meta verify — requirements: ${errors.length} error(s).`);
453
562
  return 1;
@@ -455,30 +564,41 @@ wranglerRunner) {
455
564
  return 0;
456
565
  }
457
566
  // -- verify-as-teacher (advisory) ------------------------------------------
567
+ // Records its result EITHER WAY — a skip carries its reason rather than looking
568
+ // like a clean scan. Warnings only; nothing here reaches the exit code (the
569
+ // scanner's own header: bias to under-flagging, never a non-zero exit).
458
570
  function runAntiPatternAdvisory() {
571
+ if (flags.noAntipatterns) {
572
+ antiPatternSection = skippedSection("suppressed by --no-antipatterns");
573
+ return;
574
+ }
575
+ if (process.env.META_NO_ANTIPATTERNS === "1") {
576
+ antiPatternSection = skippedSection("suppressed by META_NO_ANTIPATTERNS=1");
577
+ return;
578
+ }
459
579
  let findings;
460
580
  try {
461
- findings = scanSourceForAntiPatterns(projectRoot);
581
+ const ignore = forgeConfig?.verify?.antiPatternIgnore;
582
+ findings = scanSourceForAntiPatterns(projectRoot, ignore !== undefined ? { ignore } : undefined);
462
583
  }
463
- catch {
464
- return; // never let an advisory scan break verify
584
+ catch (err) {
585
+ // Never let an advisory scan break verify — and never report it as clean.
586
+ antiPatternSection = skippedSection(`the scan failed: ${err.message}`);
587
+ return;
465
588
  }
589
+ antiPatternSection = ranSection(antiPatternRows(findings));
466
590
  if (findings.length === 0)
467
591
  return;
468
- const CAP = 10;
469
592
  log.warn(`meta verify — ${findings.length} place(s) hand-roll what MetaObjects can model ` +
470
593
  `(advisory — does not fail the build):`);
471
- for (const f of findings.slice(0, CAP))
472
- log.warn(` ${f.message}`);
473
- if (findings.length > CAP)
474
- log.warn(` …and ${findings.length - CAP} more.`);
594
+ warnCapped(findings.map((f) => ` ${f.message}`), flags.limit, { structured });
475
595
  }
476
596
  // -- template (prompt / output) drift --------------------------------------
477
597
  function runTemplateVerify() {
478
598
  // ADR-0039: effective children — resolve rather than rely on root being unextended.
479
599
  const templates = root.children().filter((c) => c.type === TYPE_TEMPLATE);
480
600
  if (templates.length === 0) {
481
- log.info("meta verify — no template.* nodes found; nothing to check.");
601
+ say("meta verify — no template.* nodes found; nothing to check.");
482
602
  return 0;
483
603
  }
484
604
  let errorCount = 0;
@@ -589,7 +709,7 @@ wranglerRunner) {
589
709
  log.error(`meta verify — ${errorCount} drift error(s) across ${checkedTemplates} template(s).`);
590
710
  return 1;
591
711
  }
592
- log.info(`meta verify — ${checkedTemplates} template(s) clean${warnCount > 0 ? ` (${warnCount} warning(s))` : ""}.`);
712
+ say(`meta verify — ${checkedTemplates} template(s) clean${warnCount > 0 ? ` (${warnCount} warning(s))` : ""}.`);
593
713
  return 0;
594
714
  }
595
715
  // -- schema drift (live DB) ------------------------------------------------
@@ -852,17 +972,17 @@ wranglerRunner) {
852
972
  // annotate them as external (declared) rather than let them vanish silently.
853
973
  const externalDeclared = collectUnmanagedNames(root);
854
974
  if (externalDeclared.length > 0) {
855
- log.info(`meta verify — ${externalDeclared.length} object(s) external (declared @unmanaged, managed elsewhere): ${externalDeclared.join(", ")}`);
975
+ say(`meta verify — ${externalDeclared.length} object(s) external (declared @unmanaged, managed elsewhere): ${externalDeclared.join(", ")}`);
856
976
  }
857
977
  // Same reasoning for the per-command scope: an object `migrate.scope` excluded
858
978
  // was NOT checked, and silence would misreport it as checked-and-clean. Shared
859
979
  // wording with `meta migrate` — one declaration, one sentence about it.
860
980
  if (driftResult.outOfScope.length > 0) {
861
- log.info(outOfScopeNote("verify", driftResult.outOfScope));
981
+ say(outOfScopeNote("verify", driftResult.outOfScope));
862
982
  }
863
983
  const changes = driftResult.changes;
864
984
  if (changes.length === 0 && ledgerDrift.length === 0) {
865
- log.info(`meta verify — schema in sync with ${displayUrl}.`);
985
+ say(`meta verify — schema in sync with ${displayUrl}.`);
866
986
  return 0;
867
987
  }
868
988
  // The header is conditional: #292's snapshot findings arrive through `ledgerDrift`
@@ -914,6 +1034,11 @@ wranglerRunner) {
914
1034
  }
915
1035
  catch (err) {
916
1036
  log.error(`verify --codegen: failed to load this package's metadata: ${err.message}`);
1037
+ // The second door onto the same strict load. It carried no remedy at all, which
1038
+ // meant a retirement diagnosed here told the author what broke and nothing about
1039
+ // how to fix it — the half-true rule this file's sibling comment warns about.
1040
+ for (const s of err.suggestions ?? [])
1041
+ log.error(` ${s}`);
917
1042
  return 2;
918
1043
  }
919
1044
  }
@@ -930,7 +1055,7 @@ wranglerRunner) {
930
1055
  return 2;
931
1056
  }
932
1057
  if (result.clean) {
933
- log.info("meta verify — generated output is in sync with the metadata (no codegen drift).");
1058
+ say("meta verify — generated output is in sync with the metadata (no codegen drift).");
934
1059
  return 0;
935
1060
  }
936
1061
  log.error(`meta verify — codegen drift (${result.driftedFiles.length} file(s) differ from a fresh regen):`);
@@ -939,6 +1064,57 @@ wranglerRunner) {
939
1064
  log.error("Run 'meta gen' to regenerate, then commit the result.");
940
1065
  return 1;
941
1066
  }
1067
+ // -- docs drift -------------------------------------------------------------
1068
+ // Gated on --docs. Runs `meta docs` into a temp dir and diffs the committed docs
1069
+ // tree. See lib/docs-drift.ts for the two deliberate differences from --codegen
1070
+ // (a byte difference IS drift here, and a committed file a regen would not emit is
1071
+ // never reported — `docs.outDir` is full of files MetaObjects did not write).
1072
+ //
1073
+ // It needs a config for the same reason `--codegen` does: `docs.outDir` says which
1074
+ // tree to compare against, and the `agent` surface will not even materialise
1075
+ // without one.
1076
+ async function runDocsVerify() {
1077
+ if (forgeConfig === undefined) {
1078
+ log.error("verify --docs: no metaobjects.config.ts found (or it is invalid) — " +
1079
+ "cannot locate the committed docs tree to diff against. " +
1080
+ "Run 'meta init' to scaffold one, or run without --docs.");
1081
+ return 2;
1082
+ }
1083
+ // Resolved through the SAME resolver `meta docs` uses, so a project that moved its
1084
+ // docs elsewhere is checked where its docs actually are. `outputLayout` is the
1085
+ // documented fallback for `docs.layout`.
1086
+ const docsCfg = resolveDocsConfig(forgeConfig.docs, {}, forgeConfig.outputLayout ?? "flat");
1087
+ let result;
1088
+ try {
1089
+ result = await computeDocsDrift({
1090
+ projectRoot: genConfigDir,
1091
+ docsDir: resolvePath(genConfigDir, docsCfg.outDir),
1092
+ cwd: genConfigDir,
1093
+ });
1094
+ }
1095
+ catch (err) {
1096
+ log.error(`verify --docs: regeneration failed: ${err.message}`);
1097
+ return 1;
1098
+ }
1099
+ if (result.error !== undefined) {
1100
+ log.error(result.error);
1101
+ return 2;
1102
+ }
1103
+ if (result.clean) {
1104
+ // The denominator is what was actually COMPARED, so the passing line and the
1105
+ // failing line below count the same set. A gate whose two halves divide by
1106
+ // different numbers is how `verify --templates` came to report seven templates
1107
+ // vanishing between a red run and a green one.
1108
+ say(`meta verify — ${result.checked} docs page(s) match a fresh 'meta docs' (no docs drift).`);
1109
+ return 0;
1110
+ }
1111
+ log.error(`meta verify — docs drift (${result.driftedFiles.length} of ${result.checked} page(s) ` +
1112
+ `differ from a fresh 'meta docs'):`);
1113
+ for (const line of result.lines)
1114
+ log.error(` ${line}`);
1115
+ log.error("Run 'meta docs' to regenerate, then commit the result.");
1116
+ return 1;
1117
+ }
942
1118
  }
943
1119
  /**
944
1120
  * Migration-history ledger reconciliation hook (Unit 3 — not yet built).
@@ -982,4 +1158,69 @@ function summarizeDrift(changes) {
982
1158
  return `${p.glyph} ${p.noun} ${describeChange(c)}`;
983
1159
  });
984
1160
  }
1161
+ /** Project a requirement diagnostic into a payload row. */
1162
+ function toDiagnosticRow(d, source) {
1163
+ return {
1164
+ code: d.code,
1165
+ // "" rather than omitted: a tabular encoder (TOON) needs every row to carry the
1166
+ // same keys, and an absent column would silently shift the rest.
1167
+ path: d.path ?? "",
1168
+ severity: d.severity,
1169
+ source,
1170
+ message: d.message,
1171
+ };
1172
+ }
1173
+ /**
1174
+ * What `--format json|toon` puts on stdout.
1175
+ *
1176
+ * The rule this shape is built to keep: **anything not represented here is
1177
+ * STATED, not silently dropped.** A half-payload that looks complete is the very
1178
+ * defect being fixed — an agent read a run carrying 233 advisory findings as "all
1179
+ * green across the board", because the findings were on stderr as text and the
1180
+ * payload mentioned none of them.
1181
+ *
1182
+ * So: every gate's verdict, every advisory finding (uncapped), and an explicit
1183
+ * `notRepresented` list naming what stays on stderr as text.
1184
+ */
1185
+ function buildVerifyPayload(input) {
1186
+ const ran = input.gates.filter((g) => g.ran);
1187
+ const failed = ran.filter((g) => !g.ok);
1188
+ const parts = [
1189
+ failed.length === 0
1190
+ ? `${ran.length} gate(s) ran, all clean`
1191
+ : `${failed.length} of ${ran.length} gate(s) failed (${failed.map((g) => g.gate).join(", ")})`,
1192
+ ];
1193
+ if (input.antiPatterns.status === "ran" && input.antiPatterns.total > 0) {
1194
+ parts.push(`${input.antiPatterns.total} advisory anti-pattern finding(s)`);
1195
+ }
1196
+ if (input.requirements.total > 0) {
1197
+ parts.push(`${input.requirements.total} requirement diagnostic(s)`);
1198
+ }
1199
+ const help = [];
1200
+ if (failed.length > 0) {
1201
+ help.push(`the failing gate's drift DETAIL is printed as text on stderr — this payload carries the verdict only`);
1202
+ }
1203
+ if (input.antiPatterns.total > 0) {
1204
+ help.push(`${input.antiPatterns.total} authored site(s) hand-roll what MetaObjects can model — see antiPatterns.rows[] and run \`meta types <construct>\``);
1205
+ }
1206
+ if (failed.length === 0 && input.antiPatterns.total === 0 && input.requirements.total === 0) {
1207
+ help.push("no drift and nothing advisory to answer — nothing to do");
1208
+ }
1209
+ return {
1210
+ verify: input.gates,
1211
+ exitCode: input.exitCode,
1212
+ summary: parts.join("; "),
1213
+ help,
1214
+ antiPatterns: input.antiPatterns,
1215
+ requirements: input.requirements,
1216
+ ...(input.requirementCounts !== undefined ? { requirementCounts: input.requirementCounts } : {}),
1217
+ // The honest boundary. Everything named here is REACHABLE — it is printed as
1218
+ // text on stderr — but it is not in this document, and a reader must not have
1219
+ // to discover that by its absence.
1220
+ notRepresented: [
1221
+ "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",
1222
+ "the loader's own warnings and the agent-context/manifest advisories — printed as text on stderr",
1223
+ ],
1224
+ };
1225
+ }
985
1226
  //# sourceMappingURL=verify.js.map