@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
package/src/lib/args.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { parseArgs } from "node:util";
2
+ import { parseAdvisoryLimit } from "./advisory.js";
2
3
 
3
4
  // ---------------------------------------------------------------------------
4
5
  // init flags
@@ -102,6 +103,12 @@ export interface GenFlags {
102
103
  list: boolean;
103
104
  /** Suppress the advisory anti-pattern (verify-as-teacher) pass. */
104
105
  noAntipatterns: boolean;
106
+ /**
107
+ * How many advisory lines TEXT output prints before truncating
108
+ * (`DEFAULT_ADVISORY_LIMIT`, or Infinity for `--limit all`). It never applies to
109
+ * a structured payload, which carries every finding.
110
+ */
111
+ limit: number;
105
112
  }
106
113
 
107
114
  export function parseGenArgs(argv: string[]): GenFlags {
@@ -112,6 +119,7 @@ export function parseGenArgs(argv: string[]): GenFlags {
112
119
  "baseline": { type: "string" },
113
120
  "list": { type: "boolean", default: false },
114
121
  "no-antipatterns": { type: "boolean", default: false },
122
+ "limit": { type: "string" },
115
123
  },
116
124
  strict: true,
117
125
  allowPositionals: true,
@@ -128,6 +136,9 @@ export function parseGenArgs(argv: string[]): GenFlags {
128
136
  baseline: (baselineRaw as "default" | "fresh" | undefined) ?? "default",
129
137
  list: !!values.list,
130
138
  noAntipatterns: !!values["no-antipatterns"],
139
+ // Throws on a bad value; the command layer reports it and exits 2, exactly as
140
+ // it does for --baseline above.
141
+ limit: parseAdvisoryLimit(values.limit as string | undefined),
131
142
  };
132
143
  }
133
144
 
@@ -242,6 +253,18 @@ export interface VerifyFlags {
242
253
  templates: boolean;
243
254
  /** Run the codegen-drift gate (regenerate-to-temp and diff committed output). */
244
255
  codegen: boolean;
256
+ /**
257
+ * Run the docs-drift gate: regenerate the `meta docs` surfaces into a temp dir and diff
258
+ * them against the committed docs tree.
259
+ *
260
+ * A SEPARATE subverb rather than part of `--codegen`, because they check different
261
+ * trees under different ownership rules: `--codegen` regenerates `outDir`/`targets` and
262
+ * must respect the hand edits `meta gen` preserves, while the docs tree has no merge,
263
+ * no manifest, and lives in a directory (`./docs` by default) full of hand-written
264
+ * files it does not own. Folding them together would have to pick one of those rules
265
+ * for both.
266
+ */
267
+ docs: boolean;
245
268
  /**
246
269
  * Replay the committed migration chain into an empty throwaway database and assert
247
270
  * it applies (#313). Needs no `--db`: the engine is local and disposable (PGlite
@@ -255,7 +278,7 @@ export interface VerifyFlags {
255
278
  * read as that flag's opposite rather than as a replay depth.
256
279
  */
257
280
  replaySnapshot: boolean;
258
- /** Whether ANY explicit subverb flag (--templates/--db/--codegen/--replay*) was passed. */
281
+ /** Whether ANY explicit subverb flag (--templates/--db/--codegen/--docs/--replay*) was passed. */
259
282
  anyExplicit: boolean;
260
283
  /** Suppress the advisory anti-pattern (verify-as-teacher) pass. */
261
284
  noAntipatterns: boolean;
@@ -273,6 +296,14 @@ export interface VerifyFlags {
273
296
  * restores the legacy open-attr load (today's behavior). Default false (strict).
274
297
  */
275
298
  lax: boolean;
299
+ /**
300
+ * How many advisory lines TEXT output prints per SECTION before truncating
301
+ * (`DEFAULT_ADVISORY_LIMIT`, or Infinity for `--limit all`). Per-section, never
302
+ * shared across them — the reason the two caps were separate in the first place
303
+ * is that one shared budget lets the authoring lint push every gate warning off
304
+ * the end. It never applies to a structured payload, which carries everything.
305
+ */
306
+ limit: number;
276
307
  }
277
308
 
278
309
  export function parseVerifyArgs(argv: string[]): VerifyFlags {
@@ -286,6 +317,7 @@ export function parseVerifyArgs(argv: string[]): VerifyFlags {
286
317
  "skip-schema": { type: "boolean", default: false },
287
318
  templates: { type: "boolean", default: false },
288
319
  codegen: { type: "boolean", default: false },
320
+ docs: { type: "boolean", default: false },
289
321
  replay: { type: "boolean", default: false },
290
322
  "replay-snapshot": { type: "boolean", default: false },
291
323
  "no-antipatterns": { type: "boolean", default: false },
@@ -293,6 +325,7 @@ export function parseVerifyArgs(argv: string[]): VerifyFlags {
293
325
  lax: { type: "boolean", default: false },
294
326
  "d1": { type: "string" },
295
327
  "remote": { type: "boolean", default: false },
328
+ "limit": { type: "string" },
296
329
  },
297
330
  strict: true,
298
331
  allowPositionals: false,
@@ -317,6 +350,7 @@ export function parseVerifyArgs(argv: string[]): VerifyFlags {
317
350
 
318
351
  const templates = !!values.templates;
319
352
  const codegen = !!values.codegen;
353
+ const docs = !!values.docs;
320
354
  const replay = !!values.replay;
321
355
  const replaySnapshot = !!values["replay-snapshot"];
322
356
  // --db is itself an explicit subverb selector: passing a connection URL means
@@ -325,7 +359,7 @@ export function parseVerifyArgs(argv: string[]): VerifyFlags {
325
359
  // must be listed here or `meta verify --replay` would ALSO run the template gate
326
360
  // as the bare-verify default.
327
361
  const anyExplicit =
328
- templates || codegen || values.db !== undefined || dialect === "d1" || replay || replaySnapshot;
362
+ templates || codegen || docs || values.db !== undefined || dialect === "d1" || replay || replaySnapshot;
329
363
 
330
364
  return {
331
365
  prompts: values.prompts,
@@ -335,6 +369,7 @@ export function parseVerifyArgs(argv: string[]): VerifyFlags {
335
369
  skipSchema: !!values["skip-schema"],
336
370
  templates,
337
371
  codegen,
372
+ docs,
338
373
  replay,
339
374
  replaySnapshot,
340
375
  anyExplicit,
@@ -343,6 +378,9 @@ export function parseVerifyArgs(argv: string[]): VerifyFlags {
343
378
  lax: !!values.lax,
344
379
  d1: values.d1 as string | undefined,
345
380
  remote: !!values.remote,
381
+ // Throws on a bad value; the command layer reports it and exits 2, exactly as
382
+ // it does for --dialect and --allow above.
383
+ limit: parseAdvisoryLimit(values.limit as string | undefined),
346
384
  };
347
385
  }
348
386
 
@@ -0,0 +1,222 @@
1
+ // `meta verify --docs` — the docs-drift gate.
2
+ //
3
+ // It runs `meta docs` into a throwaway temp directory and diffs what that produces
4
+ // against the committed docs tree. Same shape as `--codegen`, pointed at `docs.outDir`,
5
+ // and it exists because nothing checked the docs tree at all: `--codegen` regenerates only
6
+ // the config's `outDir`/`targets`, so a model could move and every committed page keep
7
+ // describing the previous one indefinitely, with every gate green.
8
+ //
9
+ // IT CALLS THE DOCS COMMAND, NOT A REIMPLEMENTATION OF IT. The gate and the door must not
10
+ // be two answers to "what are the docs" — that is the defect this whole surface is meant to
11
+ // prevent, and it would be embarrassing to introduce it in the checker.
12
+ //
13
+ // TWO DELIBERATE DIFFERENCES FROM `--codegen`, both in the direction of not convicting the
14
+ // innocent:
15
+ //
16
+ // 1. NO HAND-EDIT PRESERVATION, so a byte difference IS drift. Docs pages are read, never
17
+ // imported: there is no three-way merge, nothing records what was written, and the
18
+ // documented workflow never invites an edit inside one. `--codegen` needs
19
+ // `.gen-state/.hashes.json` to tell a preserved hand edit from stale output; here
20
+ // there is no such offer to honour.
21
+ //
22
+ // 2. IT REPORTS A FILE AS EXTRA ONLY WHERE IT OWNS THE DIRECTORY. `docs.outDir`
23
+ // defaults to `./docs`, which in a real repository is full of hand-written
24
+ // documentation — this repository's own `docs/` holds a hundred such files. Its
25
+ // `api/` subtree is not ours either: on a multi-port project those pages are written
26
+ // by the OTHER port's docs command (`mvn metaobjects:docs`, `metaobjects docs`,
27
+ // `dotnet meta docs`), which this gate never runs, so a fresh run here legitimately
28
+ // emits none of them. `--codegen` can convict a committed-but-not-regenerated file
29
+ // because `.gen-state` proves the generator wrote it; over the docs root there is no
30
+ // such manifest, and convicting anyway is precisely the jurisdiction mistake
31
+ // `--codegen`'s orphan branch was corrected for in 0.24.3.
32
+ //
33
+ // `agent/` IS ours, and there the ownership question has an answer on disk: the Node
34
+ // `meta docs` command is the only thing that writes that directory (its name is not
35
+ // configurable), and every page it writes opens with the `@generated` marker. That
36
+ // marker is the proof `--codegen` has to consult `.gen-state` for, so BOTH conditions
37
+ // are required — under `agent/` AND carrying the marker. A hand-written note dropped
38
+ // in `agent/` carries no marker and is left alone, exactly as one in the docs root is.
39
+ //
40
+ // It matters because the schema page is SKIPPED rather than failed when the expected
41
+ // schema cannot be built or no dialect is declared — so without this, a committed
42
+ // `agent/schema.md` describing the previous schema passed the gate on exactly the
43
+ // change it most needs to flag.
44
+ //
45
+ // The residual cost is stated rather than hidden: outside `agent/`, a page for an
46
+ // entity that was DELETED stays committed and this gate stays green.
47
+
48
+ import { mkdtempSync, rmSync, existsSync, readFileSync, readdirSync, lstatSync } from "node:fs";
49
+ import { tmpdir } from "node:os";
50
+ import { join, relative, resolve, isAbsolute, sep } from "node:path";
51
+ import { docsCommand } from "../commands/docs.js";
52
+
53
+ export interface DocsDriftResult {
54
+ /** True when every page a fresh `meta docs` would write is committed and identical. */
55
+ clean: boolean;
56
+ /** Docs-dir-relative paths that differ — changed, missing, or (under `agent/`) committed
57
+ * when a fresh run no longer emits them. Sorted. */
58
+ driftedFiles: string[];
59
+ /** Human-readable, one line per file. */
60
+ lines: string[];
61
+ /** The denominator both the passing and the failing report divide by: the pages a fresh
62
+ * run produced, plus any committed page under `agent/` it no longer emits. */
63
+ checked: number;
64
+ /** Set when the gate could not run at all. */
65
+ error?: string;
66
+ }
67
+
68
+ /**
69
+ * Recursively list files under `dir`, relative to it.
70
+ *
71
+ * `lstatSync`, NOT `statSync`, and symlinks are SKIPPED. This walks the committed docs
72
+ * tree as well as the fresh one now, and a real repository's `docs/` may hold a symlink to
73
+ * a build output that is absent on CI — `statSync` follows it and throws `ENOENT`, which
74
+ * the caller reports as "regeneration failed", blaming the fresh run for a dangling link
75
+ * in the committed tree. A directory symlink would also let the walk recurse without
76
+ * bound. Nothing MetaObjects writes is a symlink, so skipping them cannot hide a page of
77
+ * ours; a symlinked page is somebody else's file, which this gate does not judge anyway.
78
+ */
79
+ function listFiles(dir: string): string[] {
80
+ if (!existsSync(dir)) return [];
81
+ const out: string[] = [];
82
+ const walk = (d: string): void => {
83
+ for (const entry of readdirSync(d)) {
84
+ const full = join(d, entry);
85
+ const st = lstatSync(full);
86
+ if (st.isSymbolicLink()) continue;
87
+ if (st.isDirectory()) walk(full);
88
+ else out.push(relative(dir, full));
89
+ }
90
+ };
91
+ walk(dir);
92
+ return out;
93
+ }
94
+
95
+ /**
96
+ * Docs-root-relative prefixes the Node `meta docs` command owns outright — the only
97
+ * places a committed page that a fresh run does not emit is drift rather than somebody
98
+ * else's file. `agent` is fixed (it is not a configurable subdirectory), which is what
99
+ * makes the claim checkable; `api` deliberately is NOT here, because its subdirectory IS
100
+ * configurable and on a multi-port project another port's docs command writes it.
101
+ */
102
+ const OWNED_PREFIXES = ["agent/"] as const;
103
+
104
+ /** The marker every generated docs page opens with — the on-disk ownership proof. */
105
+ const GENERATED_MARKER = "@generated";
106
+
107
+ /**
108
+ * True when this committed file is one WE wrote: under a directory this command owns, and
109
+ * carrying the generated marker in its opening lines. Both halves are load-bearing — the
110
+ * prefix keeps another port's `api/` pages out of jurisdiction, the marker keeps a
111
+ * hand-written note inside `agent/` out of it.
112
+ */
113
+ function isOurs(docsDir: string, rel: string): boolean {
114
+ const normalized = rel.split(sep).join("/");
115
+ if (!OWNED_PREFIXES.some((p) => normalized.startsWith(p))) return false;
116
+ try {
117
+ return readFileSync(join(docsDir, rel), "utf8")
118
+ .split("\n", 3)
119
+ .some((line) => line.includes(GENERATED_MARKER));
120
+ } catch {
121
+ // Unreadable is not proof of ownership, and this gate never convicts without it.
122
+ return false;
123
+ }
124
+ }
125
+
126
+ export interface ComputeDocsDriftArgs {
127
+ /** Absolute project root — what `meta docs` would be pointed at. */
128
+ projectRoot: string;
129
+ /** The committed docs directory, already resolved (the project's `docs.outDir`). */
130
+ docsDir: string;
131
+ /** Process cwd to hand the docs command, for parity with a direct invocation. */
132
+ cwd: string;
133
+ }
134
+
135
+ /**
136
+ * Regenerate the docs into a temp tree and diff it against the committed one.
137
+ *
138
+ * A non-zero exit from the docs command is returned as `error` rather than as drift: a run
139
+ * that could not produce the pages has not shown that the committed ones are wrong, and
140
+ * reporting it as drift would send a reader to edit files that are probably fine.
141
+ */
142
+ export async function computeDocsDrift(args: ComputeDocsDriftArgs): Promise<DocsDriftResult> {
143
+ const root = isAbsolute(args.projectRoot) ? args.projectRoot : resolve(args.projectRoot);
144
+ const docsDir = isAbsolute(args.docsDir) ? args.docsDir : resolve(root, args.docsDir);
145
+
146
+ const tempRoot = mkdtempSync(join(tmpdir(), "meta-verify-docs-"));
147
+ try {
148
+ // NO `<project-root>` POSITIONAL, deliberately. Passing one is not the same command:
149
+ // an explicit path PINS the source set to that directory's own sources (#327), while a
150
+ // bare run discovers the project by walking up. The committed pages were produced by
151
+ // whatever the user actually ran, and a bare run is what `meta docs` means by default —
152
+ // so the gate reproduces that and takes its project root from `cwd`, the way the docs
153
+ // command itself does. A gate that resolved a different source set would report pages
154
+ // as drifted that a plain regeneration would reproduce exactly.
155
+ //
156
+ // `--out` IS passed, because the whole point is to write somewhere else; everything
157
+ // else — layout, surfaces, api surfaces, base URL — still resolves from the project's
158
+ // own config.
159
+ const exit = await docsCommand(["--out", tempRoot], args.cwd, { silent: true });
160
+ if (exit !== 0) {
161
+ return {
162
+ clean: false,
163
+ driftedFiles: [],
164
+ lines: [],
165
+ checked: 0,
166
+ error:
167
+ `verify --docs: 'meta docs' exited ${exit}, so the committed pages could not be ` +
168
+ `compared against a fresh run. Fix that first — the error is above.`,
169
+ };
170
+ }
171
+
172
+ const fresh = listFiles(tempRoot).sort();
173
+ if (fresh.length === 0) {
174
+ return {
175
+ clean: false,
176
+ driftedFiles: [],
177
+ lines: [],
178
+ checked: 0,
179
+ error:
180
+ "verify --docs: a fresh 'meta docs' produced no pages, so there is nothing to " +
181
+ "compare. Check that this project declares metadata the docs surfaces cover.",
182
+ };
183
+ }
184
+
185
+ const driftedFiles: string[] = [];
186
+ const lines: string[] = [];
187
+ for (const rel of fresh) {
188
+ const committedPath = join(docsDir, rel);
189
+ if (!existsSync(committedPath)) {
190
+ driftedFiles.push(rel);
191
+ lines.push(`+ ${rel} (a fresh 'meta docs' emits it; not committed)`);
192
+ continue;
193
+ }
194
+ const a = readFileSync(committedPath, "utf8");
195
+ const b = readFileSync(join(tempRoot, rel), "utf8");
196
+ if (a !== b) {
197
+ driftedFiles.push(rel);
198
+ lines.push(`~ ${rel} (committed content differs from a fresh 'meta docs')`);
199
+ }
200
+ }
201
+ // Committed-but-not-regenerated, inside the directories this command owns. Counted
202
+ // into `checked` as well as `driftedFiles` so the failing line and the passing line
203
+ // keep dividing by the same set — the `verify --templates` mistake, where a red run
204
+ // and a green run reported different denominators for the same project.
205
+ const freshSet = new Set(fresh);
206
+ const orphans = listFiles(docsDir)
207
+ .filter((rel) => !freshSet.has(rel) && isOurs(docsDir, rel))
208
+ .sort();
209
+ for (const rel of orphans) {
210
+ driftedFiles.push(rel);
211
+ lines.push(`- ${rel} (committed; a fresh 'meta docs' no longer emits it)`);
212
+ }
213
+ return {
214
+ clean: driftedFiles.length === 0,
215
+ driftedFiles: driftedFiles.sort(),
216
+ lines,
217
+ checked: fresh.length + orphans.length,
218
+ };
219
+ } finally {
220
+ rmSync(tempRoot, { recursive: true, force: true });
221
+ }
222
+ }
package/src/lib/format.ts CHANGED
@@ -21,3 +21,17 @@ export function resolveFormat(flag: string | undefined, isTTY: boolean): OutputF
21
21
  export function toonEncode(value: unknown): string {
22
22
  return encode(value);
23
23
  }
24
+
25
+ /**
26
+ * Put ONE machine-readable document on stdout in the active structured format.
27
+ *
28
+ * Text format writes nothing here — its human rendering is the caller's job, and a
29
+ * command in text mode has already printed it. Callers in a structured format must
30
+ * keep every prose line off stdout (route narration to stderr): a document with a
31
+ * sentence in front of it breaks `| jq` outright. `meta migrate`'s
32
+ * `emitStructuredError` is the same split, made command-locally before this existed.
33
+ */
34
+ export function emitStructured(payload: unknown, fmt: OutputFormat): void {
35
+ if (fmt === "json") console.log(JSON.stringify(payload, null, 2));
36
+ else if (fmt === "toon") console.log(toonEncode(payload));
37
+ }
package/src/lib/output.ts CHANGED
@@ -5,6 +5,7 @@
5
5
 
6
6
  import type { Dialect } from "./kysely.js";
7
7
  import { toonEncode } from "./format.js";
8
+ import { skippedSection, type AdvisoryFindingRow, type AdvisorySection } from "./advisory.js";
8
9
 
9
10
  export interface FormatOptions {
10
11
  isTTY: boolean;
@@ -29,6 +30,17 @@ export interface GenResultShape {
29
30
  dialect: Dialect | undefined;
30
31
  dryRun: boolean;
31
32
  warnings: string[];
33
+ /**
34
+ * The advisory anti-pattern pass, in full. Optional on the TYPE only so the
35
+ * pure formatter tests can build a result without one; `genResultToData` turns
36
+ * an absent section into an explicit "skipped" with a reason, because a payload
37
+ * that omits the advisory is precisely the defect this field closes — a run
38
+ * carrying hundreds of findings read as "all green" to an agent parsing stdout.
39
+ *
40
+ * NEVER capped here. The text renderer truncates for a human's terminal; the
41
+ * structured payload carries every finding.
42
+ */
43
+ antiPatterns?: AdvisorySection<AdvisoryFindingRow>;
32
44
  }
33
45
 
34
46
  const GEN_GLYPHS: Record<GenFileStatus, string> = {
@@ -179,7 +191,10 @@ export function formatMigrateResult(result: MigrateResultShape, _opts: FormatOpt
179
191
  // ---------------------------------------------------------------------------
180
192
 
181
193
  export function genResultToData(result: GenResultShape): {
182
- gen: { file: string; status: GenFileStatus }[]; summary: string; help: string[];
194
+ gen: { file: string; status: GenFileStatus }[];
195
+ summary: string;
196
+ help: string[];
197
+ antiPatterns: AdvisorySection<AdvisoryFindingRow>;
183
198
  } {
184
199
  const counts = result.files.reduce<Record<GenFileStatus, number>>(
185
200
  (a, f) => ((a[f.status] = (a[f.status] ?? 0) + 1), a),
@@ -198,7 +213,23 @@ export function genResultToData(result: GenResultShape): {
198
213
  const help = result.files.length === 0
199
214
  ? ["author entities in this project's metadata sources then re-run `meta gen`"]
200
215
  : ["typecheck the generated code with `npx tsc`", "create your database tables with `meta migrate --from-db --db <url> --dialect <sqlite|postgres> --slug init --apply`"];
201
- return { gen: result.files.map((f) => ({ file: f.path, status: f.status })), summary, help };
216
+ // An absent section is STATED, never omitted: a reader must be able to tell
217
+ // "the scan found nothing" from "the scan never ran".
218
+ const antiPatterns = result.antiPatterns
219
+ ?? skippedSection<AdvisoryFindingRow>("the advisory anti-pattern pass did not run for this result");
220
+ // One more next step when there is something to act on — the whole reason the
221
+ // findings are in the payload is that somebody can now act on all of them.
222
+ if (antiPatterns.total > 0) {
223
+ help.push(
224
+ `${antiPatterns.total} authored site(s) hand-roll what MetaObjects can model — see antiPatterns.rows[] and run \`meta types <construct>\``,
225
+ );
226
+ }
227
+ return {
228
+ gen: result.files.map((f) => ({ file: f.path, status: f.status })),
229
+ summary,
230
+ help,
231
+ antiPatterns,
232
+ };
202
233
  }
203
234
 
204
235
  export function formatGenResultToon(result: GenResultShape): string {