@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
package/src/index.ts CHANGED
@@ -1,13 +1,24 @@
1
1
  import { resolve } from "node:path";
2
2
  import { log } from "./lib/log.js";
3
3
  import { cliVersion } from "./lib/version.js";
4
- import { resolveFormat, isValidFormat, VALID_FORMATS } from "./lib/format.js";
4
+ import { resolveFormat, isValidFormat, VALID_FORMATS, type OutputFormat } from "./lib/format.js";
5
5
  import { resolveCollection } from "@metaobjectsdev/sdk";
6
6
  export { defineConfig } from "@metaobjectsdev/codegen-ts";
7
7
  export type { MetaobjectsGenConfig } from "@metaobjectsdev/codegen-ts";
8
8
 
9
9
  const VERSION = cliVersion();
10
10
 
11
+ /**
12
+ * The commands that actually honor the global `--format`. Everything else prints
13
+ * text whatever is passed.
14
+ *
15
+ * This list exists because the flag was advertised globally and threaded to two
16
+ * commands: `meta verify --format json` parsed, validated, exited 0 and printed
17
+ * human text. It is named ONCE and used by both the warning below and the help
18
+ * text above, so the two cannot drift apart.
19
+ */
20
+ const FORMAT_AWARE_COMMANDS: readonly string[] = ["gen", "verify", "migrate", "types"];
21
+
11
22
  const HELP_TEXT = `meta — MetaObjects CLI (v${VERSION})
12
23
 
13
24
  USAGE:
@@ -19,10 +30,12 @@ COMMANDS:
19
30
  init --config-only Write only .metaobjects/config.json — for a Maven- or pip-rooted project
20
31
  agent-docs Scaffold only the agent-context (.metaobjects/ + .claude/skills/) — canonical redirect target for all language ports
21
32
  gen [<entity>...] Codegen TS targets from your declared metadata
33
+ eject <generator> Copy a reference generator into codegen/generators/ to own it (any time after init)
34
+ eject --list List every ejectable generator name, grouped by package
22
35
  types [query] Search the metadata vocabulary (types, subtypes, @attrs) by name or description
23
36
  export Flatten loaded metadata to one canonical JSON artifact
24
37
  docs [<project-root>] --out <dir> Generate neutral metadata documentation (entity + template pages; --site for HTML site)
25
- verify Drift gate — subverbs: --templates / --db / --codegen (bare = --templates)
38
+ verify Drift gate — subverbs: --templates / --db / --codegen / --docs (bare = --templates)
26
39
  upgrade Rewrite retired metadata vocabulary (previews; --apply writes)
27
40
  prompt-snapshot Snapshot rendered template.* output; --check gates drift
28
41
  migrate Diff metadata vs live DB; emit migration SQL files
@@ -31,10 +44,21 @@ COMMANDS:
31
44
 
32
45
  GLOBAL OPTIONS:
33
46
  --cwd <path>, -C <path> Run as if launched from <path> (default: current directory)
34
- --format <toon|json|text> Output format (default: toon on non-TTY, text on TTY)
47
+ --format <toon|json|text> Output format (default: toon on non-TTY, text on TTY).
48
+ Honored by ${FORMAT_AWARE_COMMANDS.join(", ")}; every other
49
+ command prints text and says so if you pass it.
50
+ \`types\` is the one exception to the default: it prints TEXT
51
+ unless you ask for a format, on a TTY or not, because its
52
+ text output is already the terse agent-facing rendering.
53
+ A structured payload is never truncated — --limit is a
54
+ TEXT-only display cap.
35
55
 
36
56
  GEN FLAGS:
37
57
  --dry-run Compute and print, don't write
58
+ --no-antipatterns Suppress the advisory "hand-rolled what MetaObjects can model" pass
59
+ --limit <n|all> How many advisory lines TEXT output prints before it truncates
60
+ (default 20). Never applies to --format toon/json, which
61
+ carry every finding.
38
62
  <entity> [<entity>] Positional filter on entity names
39
63
  (outDir, dialect, dbImport, extStyle are read from metaobjects.config.ts)
40
64
 
@@ -53,6 +77,11 @@ VERIFY FLAGS (ADR-0021 D2 — explicit subverbs; combine any; exit 1 on ANY drif
53
77
  --templates Template/prompt {{field}}↔payload drift (the bare-verify default)
54
78
  --codegen Codegen drift — regenerate to a temp dir and diff the committed
55
79
  output (config outDir/targets). Needs metaobjects.config.ts; exit 2 if absent.
80
+ --docs Docs drift — run 'meta docs' into a temp dir and diff the committed
81
+ docs tree (docs.outDir). Reports a page that changed or that a fresh
82
+ run would emit; never reports an extra file, because docs.outDir
83
+ holds hand-written documentation MetaObjects did not write.
84
+ Needs metaobjects.config.ts; exit 2 if absent.
56
85
  --db <url> Schema drift — live DB URL enables the schema-drift gate.
57
86
  Supports: file:, libsql:, postgres:, postgresql:. Omit to skip.
58
87
  D1 has no URL — use --dialect d1 / --d1 <binding> instead.
@@ -66,6 +95,10 @@ VERIFY FLAGS (ADR-0021 D2 — explicit subverbs; combine any; exit 1 on ANY drif
66
95
  the ONLY way to verify the actual deployed D1 database
67
96
  --no-antipatterns Suppress the advisory "you hand-rolled what MetaObjects can
68
97
  model" pass (aggregate/currency/enum hints; warnings only)
98
+ --no-requirement-lint Suppress the advisory requirement AUTHORING lint (not the gate)
99
+ --limit <n|all> How many advisory lines TEXT output prints per section before
100
+ it truncates (default 20). Never applies to --format
101
+ toon/json, which carry every finding and every diagnostic.
69
102
 
70
103
  PROMPT-SNAPSHOT FLAGS:
71
104
  --check Compare against committed snapshots; exit 1 on drift (CI gate)
@@ -103,6 +136,8 @@ USAGE:
103
136
  FLAGS:
104
137
  --dry-run Compute and print, don't write
105
138
  --no-antipatterns Suppress the advisory "hand-rolled what MetaObjects can model" pass
139
+ --limit <n|all> Advisory lines TEXT output prints before truncating (default 20)
140
+ --format <toon|json|text> Output format (global flag; default toon off-TTY)
106
141
  <entity> [<entity>] Positional filter on entity names
107
142
  --help, -h Print this help
108
143
 
@@ -112,7 +147,30 @@ and points you at the construct that models them (origin.aggregate / field.curre
112
147
  field.enum). Warnings only — it never fails the build. Opt out with --no-antipatterns
113
148
  or META_NO_ANTIPATTERNS=1.
114
149
 
150
+ Text output caps that list at --limit lines (default 20) and says how many it held
151
+ back. --format toon / --format json carry EVERY finding in the payload's
152
+ antiPatterns block, uncapped — --limit is a display cap for a terminal, never a
153
+ limit on the machine-readable result.
154
+
115
155
  NOTE: outDir, dialect, dbImport, extStyle are read from metaobjects.config.ts
156
+ `,
157
+ eject: `meta eject — copy a reference generator into your repo so you own it
158
+
159
+ USAGE:
160
+ meta eject <name> Copy generator <name> into codegen/generators/<name>.ts
161
+ meta eject --list List every ejectable generator name, grouped by package
162
+
163
+ FLAGS:
164
+ --list List ejectable generators instead of copying one
165
+ --force Overwrite an already-ejected file (default: never clobber)
166
+ --help, -h Print this help
167
+
168
+ \`meta init\` copies five generators (entity, queries, routes, barrel, names) into
169
+ codegen/generators/ automatically (ADR-0034 scaffold-and-own). \`meta eject\` is
170
+ the same operation for ANY generator — one you skipped at init time, a UI-tier
171
+ generator like form/hooks/grid, or one a package gains later. It prints the
172
+ import line to paste into metaobjects.config.ts, and it never overwrites a
173
+ file you already own unless you pass --force.
116
174
  `,
117
175
  verify: `meta verify — drift gate (templates / DB schema / codegen / migration replay)
118
176
 
@@ -123,6 +181,8 @@ FLAGS:
123
181
  --templates Template/prompt {{field}}↔payload drift (default when bare)
124
182
  --codegen Codegen drift — regenerate to temp dir and diff committed output
125
183
  Needs metaobjects.config.ts; exit 2 if absent.
184
+ --docs Docs drift — run 'meta docs' to a temp dir and diff docs.outDir
185
+ Needs metaobjects.config.ts; exit 2 if absent.
126
186
  --db <url> Schema drift — live DB URL enables the schema-drift gate.
127
187
  Supports: file:, libsql:, postgres:, postgresql:
128
188
  D1 has no URL — use --dialect d1 / --d1 <binding> instead.
@@ -146,6 +206,14 @@ FLAGS:
146
206
  the ONLY way to verify the actual deployed D1 database
147
207
  --no-antipatterns Suppress the advisory "hand-rolled what MetaObjects can model" pass
148
208
  --no-requirement-lint Suppress the advisory requirement AUTHORING lint (not the gate)
209
+ --limit <n|all> Advisory lines TEXT output prints PER SECTION before truncating
210
+ (default 20; per-section so the authoring lint can never push
211
+ the gate's own warnings off the end)
212
+ --format <toon|json|text> Output format (global flag; default toon off-TTY).
213
+ Emits one machine-readable document on stdout — every gate's
214
+ verdict, every advisory finding, every requirement diagnostic,
215
+ UNCAPPED — and moves narration to stderr so the payload parses.
216
+ What it does NOT carry is listed in its own notRepresented[].
149
217
  --help, -h Print this help
150
218
 
151
219
  A bare 'meta verify' also runs an ADVISORY anti-pattern pass: it scans your authored
@@ -186,6 +254,11 @@ FLAGS:
186
254
  --api Emit the markdown api surface (generated SDK reference)
187
255
  --requirements Emit the declared requirement ledger (requirements.md + .toon).
188
256
  Emits nothing when the project declares no requirement.* node.
257
+ --agent Emit the agent surface — agent/schema.md (the physical schema),
258
+ agent/ui.md (the generated forms and grids) and
259
+ agent/requirements.md (the ledger plus a claimed-node index).
260
+ Needs metaobjects.config.ts (the dialect and physical names come
261
+ from it); each page is skipped when its tier has nothing to describe.
189
262
  --metamodel Document the built-in metamodel vocabulary (no metadata needed)
190
263
  --site Generate the browsable HTML documentation site (<out>/site/)
191
264
  --scaffold-site Copy the site's templates + assets into codegen/docs-site/ to own (theme) them
@@ -284,9 +357,41 @@ export async function run(argv: string[]): Promise<number> {
284
357
  return 2;
285
358
  }
286
359
  const fmt = resolveFormat(formatFlag, process.stdout.isTTY ?? false);
360
+ // The flag as PASSED, before the TTY-aware default is applied. `meta types` takes this
361
+ // rather than `fmt`: its default is text whether or not stdout is a terminal, so that a
362
+ // pipe does not silently change what every existing scripted caller reads. See
363
+ // commands/types.ts for the reasoning; `gen` sets the same default on its own parameter.
364
+ const explicitFormat: OutputFormat | undefined =
365
+ formatFlag !== undefined && isValidFormat(formatFlag) ? formatFlag : undefined;
287
366
 
288
367
  const [cmd, ...rest] = cleaned;
289
368
 
369
+ // A bare `--json` / `--toon` / `--text` is the spelling an adopter reaches for
370
+ // when they want structured output, and every command's `parseArgs` runs
371
+ // `strict: true`, so today it dies as `Unknown option '--json'` — a message that
372
+ // names the flag that failed and not the flag that works. It is REJECTED rather
373
+ // than aliased, deliberately: `--format` is validated once, globally, for all
374
+ // three values, while an alias would have to be added to every command's own
375
+ // option table — the per-command scattering that let `--format` be honored by
376
+ // two commands and silently ignored by the rest. One spelling, and a refusal
377
+ // that names it. Exit 2, the same usage-error code the unknown option produced.
378
+ const formatAlias = cleaned.find((a) => a === "--json" || a === "--toon" || a === "--text");
379
+ if (formatAlias !== undefined) {
380
+ log.error(
381
+ `${formatAlias} is not a flag. Use \`--format ${formatAlias.slice(2)}\` — one global spelling for all three formats.`,
382
+ );
383
+ return 2;
384
+ }
385
+
386
+ // A `--format` a command does not honor is a no-op, and a silent no-op is the
387
+ // defect this release is fixing one level down: the adopter who guessed the right
388
+ // flag still lost, because nothing said the command ignores it. Say it.
389
+ if (formatFlag !== undefined && cmd !== undefined && !FORMAT_AWARE_COMMANDS.includes(cmd)) {
390
+ log.warn(
391
+ `--format is honored by: ${FORMAT_AWARE_COMMANDS.join(", ")}. \`meta ${cmd}\` ignores it and prints text.`,
392
+ );
393
+ }
394
+
290
395
  // Intercept per-subcommand --help / -h before dispatching (mirrors migrate's own pattern).
291
396
  if (cmd !== undefined && cmd !== "--help" && cmd !== "-h" && cmd !== "--version" && cmd !== "-v") {
292
397
  if (rest.includes("--help") || rest.includes("-h")) {
@@ -374,13 +479,17 @@ export async function run(argv: string[]): Promise<number> {
374
479
  const { genCommand } = await import("./commands/gen.js");
375
480
  return genCommand(rest, cwd, fmt);
376
481
  }
482
+ case "eject": {
483
+ const { ejectCommand } = await import("./commands/eject.js");
484
+ return ejectCommand(rest, cwd);
485
+ }
377
486
  case "export": {
378
487
  const { exportCommand } = await import("./commands/export.js");
379
488
  return exportCommand(rest, cwd);
380
489
  }
381
490
  case "types": {
382
491
  const { typesCommand } = await import("./commands/types.js");
383
- return typesCommand(rest);
492
+ return typesCommand(rest, explicitFormat);
384
493
  }
385
494
  case "docs": {
386
495
  const { docsCommand } = await import("./commands/docs.js");
@@ -388,7 +497,7 @@ export async function run(argv: string[]): Promise<number> {
388
497
  }
389
498
  case "verify": {
390
499
  const { verifyCommand } = await import("./commands/verify.js");
391
- return verifyCommand(rest, cwd);
500
+ return verifyCommand(rest, cwd, undefined, fmt);
392
501
  }
393
502
  case "upgrade": {
394
503
  const { upgradeCommand } = await import("./commands/upgrade.js");
@@ -0,0 +1,150 @@
1
+ // Advisory reporting — the ONE display cap, the `--limit` parse, and the shapes
2
+ // advisory findings ride in when the caller asked for a structured format.
3
+ //
4
+ // Why this module exists: the advisory passes (`scanSourceForAntiPatterns` and
5
+ // the requirement diagnostics) found the work and then hid it. Three unshared
6
+ // `const CAP = 10 | 20` literals truncated three sections independently, and the
7
+ // findings never reached the structured payload at all — so `meta gen --format
8
+ // json` on a run with hundreds of findings emitted a clean-looking document while
9
+ // the findings went to stderr as text. An agent reading that payload is not being
10
+ // careless when it reports "all green"; the payload was empty.
11
+ //
12
+ // Two rules follow, and they are the whole point of the module:
13
+ //
14
+ // 1. TEXT output caps, because a cap exists to spare a human's terminal. The cap
15
+ // is ONE constant, adjustable with `--limit`, so raising it cannot miss a
16
+ // section.
17
+ // 2. STRUCTURED output NEVER caps. A machine has no terminal to spare, and a
18
+ // truncated machine payload is the defect this module was written to fix.
19
+ //
20
+ // Discipline inherited from `anti-patterns.ts`: ADVISORY ONLY. Nothing here may
21
+ // reach an exit code.
22
+
23
+ import { log } from "./log.js";
24
+ import type { AntiPatternFinding } from "./anti-patterns.js";
25
+
26
+ /**
27
+ * How many advisory lines TEXT output prints before it truncates.
28
+ *
29
+ * One value for every section (anti-patterns, the requirement gate's warnings,
30
+ * the requirement authoring lint). It is 20 rather than 10 deliberately: folding
31
+ * two literals into one must not take lines AWAY from a reader, and information
32
+ * loss is the direction of the defect being fixed. Raise it per-run with
33
+ * `--limit <n>`; `--limit all` removes it.
34
+ */
35
+ export const DEFAULT_ADVISORY_LIMIT = 20;
36
+
37
+ /** `--limit all` — the spelling that removes the text cap entirely. */
38
+ export const ADVISORY_LIMIT_ALL = "all";
39
+
40
+ /**
41
+ * Parse `--limit <n|all>` into a line budget. `all` (and any value ≥ the number of
42
+ * findings) means "print everything". Absent → the default cap.
43
+ *
44
+ * Throws on a value that is not a positive integer or `all`; the caller turns that
45
+ * into the usual exit-2 usage error, the same way `--dialect` and `--baseline` do.
46
+ */
47
+ export function parseAdvisoryLimit(raw: string | undefined): number {
48
+ if (raw === undefined) return DEFAULT_ADVISORY_LIMIT;
49
+ if (raw === ADVISORY_LIMIT_ALL) return Number.POSITIVE_INFINITY;
50
+ // Reject "10.5", "1e3", "-1", "0" and "" — a limit is a count of lines.
51
+ if (!/^\d+$/.test(raw) || Number(raw) === 0) {
52
+ throw new Error(
53
+ `invalid --limit '${raw}'; expected a positive integer or '${ADVISORY_LIMIT_ALL}'`,
54
+ );
55
+ }
56
+ return Number(raw);
57
+ }
58
+
59
+ // ---------------------------------------------------------------------------
60
+ // structured shapes
61
+ // ---------------------------------------------------------------------------
62
+
63
+ /** One anti-pattern finding as it appears in a structured payload. */
64
+ export interface AdvisoryFindingRow {
65
+ file: string;
66
+ line: number;
67
+ rule: string;
68
+ /** The MetaObjects construct that replaces the hand-rolled site. */
69
+ construct: string;
70
+ message: string;
71
+ }
72
+
73
+ /** One requirement diagnostic as it appears in a structured payload. */
74
+ export interface AdvisoryDiagnosticRow {
75
+ code: string;
76
+ /** The dotted child-name path, or "" for a diagnostic whose subject is not a
77
+ * requirement (object coverage names the entity in its message instead). */
78
+ path: string;
79
+ severity: string;
80
+ /** "gate" — can fail the build; "lint" — advisory authoring warning, never can. */
81
+ source: "gate" | "lint";
82
+ message: string;
83
+ }
84
+
85
+ /**
86
+ * One advisory section's FULL result. `total` always equals the row count: a
87
+ * reader must never have to decide whether a list was truncated, which is exactly
88
+ * what "…and 229 more" forced on the adopter who reported this.
89
+ */
90
+ export interface AdvisorySection<Row> {
91
+ /** "ran" — the pass executed. "skipped" — it did not, and `note` says why. */
92
+ status: "ran" | "skipped";
93
+ /** Present only when skipped. Stated rather than left to inference. */
94
+ note?: string;
95
+ total: number;
96
+ rows: Row[];
97
+ }
98
+
99
+ /** A section that did not run, carrying the reason. */
100
+ export function skippedSection<Row>(note: string): AdvisorySection<Row> {
101
+ return { status: "skipped", note, total: 0, rows: [] };
102
+ }
103
+
104
+ /** A section that ran, carrying EVERY row (never capped — see the header). */
105
+ export function ranSection<Row>(rows: Row[]): AdvisorySection<Row> {
106
+ return { status: "ran", total: rows.length, rows };
107
+ }
108
+
109
+ /** Project the scanner's findings into payload rows. `snippet` is deliberately
110
+ * dropped: it is a copy of the reader's own source line, and `file`+`line`
111
+ * already address it. */
112
+ export function antiPatternRows(findings: readonly AntiPatternFinding[]): AdvisoryFindingRow[] {
113
+ return findings.map((f) => ({
114
+ file: f.file,
115
+ line: f.line,
116
+ rule: f.rule,
117
+ construct: f.construct,
118
+ message: f.message,
119
+ }));
120
+ }
121
+
122
+ // ---------------------------------------------------------------------------
123
+ // text output
124
+ // ---------------------------------------------------------------------------
125
+
126
+ /**
127
+ * Print a capped run of advisory lines to stderr.
128
+ *
129
+ * THE single truncation site. The tail line is the part that matters: it names
130
+ * how to reach what was withheld, which is what the three hand-rolled copies of
131
+ * this loop never did — an adopter tried `--help | grep`, an invented env var and
132
+ * `--json` before giving up on 96% of the report.
133
+ *
134
+ * @param structured true when the caller is ALSO emitting a machine-readable
135
+ * payload on stdout; the tail then points at it, because that copy is complete.
136
+ */
137
+ export function warnCapped(
138
+ lines: readonly string[],
139
+ limit: number,
140
+ opts: { structured: boolean },
141
+ ): void {
142
+ for (const line of lines.slice(0, limit)) log.warn(line);
143
+ const withheld = lines.length - Math.min(lines.length, limit);
144
+ if (withheld <= 0) return;
145
+ log.warn(
146
+ opts.structured
147
+ ? ` …and ${withheld} more — every finding is in the --format payload on stdout.`
148
+ : ` …and ${withheld} more. Raise the cap with --limit <n>, or --limit ${ADVISORY_LIMIT_ALL}.`,
149
+ );
150
+ }
@@ -28,14 +28,148 @@ export interface AntiPatternFinding {
28
28
  }
29
29
 
30
30
  // Directory segments and file patterns that are never authored app source.
31
+ //
32
+ // `migration` (SINGULAR) sits beside `migrations` because Flyway's own convention
33
+ // is `src/main/resources/db/migration` — the exact directory THIS repo's Flyway
34
+ // output adapter documents as its target (`migrate-ts/src/write-migration-flyway.ts`,
35
+ // `WriteMigrationFlywayOptions.dir`). Without it MetaObjects emitted migrations into
36
+ // a directory its own advisory scanner did not ignore, then flagged the files it had
37
+ // just written as hand-rolled anti-patterns.
38
+ //
39
+ // Nothing else is added here on speculation. A directory list is a guess about
40
+ // someone else's repository — the same failure `@verifiedBy`'s closed pattern list
41
+ // shipped with and 0.23.1 had to correct — so the real coverage lives in
42
+ // IMMUTABLE_MIGRATION_FILE below (a name, wherever it sits) and in the
43
+ // project-declared `ignore` globs.
31
44
  const IGNORE_SEGMENTS = new Set([
32
45
  "node_modules", ".git", "dist", "build", "out", ".next", "coverage",
33
- ".metaobjects", "generated", "migrations",
46
+ ".metaobjects", "generated", "migrations", "migration",
34
47
  ]);
35
48
  const SCAN_EXT = /\.(?:ts|tsx|js|sql)$/;
36
49
  const SKIP_FILE = /(?:\.d\.ts$|\.test\.|\.spec\.)/;
37
50
  const GENERATED_MARKER = "@generated";
38
51
 
52
+ // --- immutable migration files ------------------------------------------------
53
+ //
54
+ // WHY THESE ARE EXCLUDED BY NAME, NOT ONLY BY DIRECTORY. A historical migration
55
+ // that a runner has already applied CANNOT be edited: Flyway checksums each
56
+ // versioned migration and refuses to start against a database whose recorded
57
+ // checksum no longer matches, so "fix the CHECK constraint in V001__baseline.sql"
58
+ // is not advice — it is a request to break every environment that has run it. A
59
+ // finding nobody can act on trains the reader to skip the whole advisory section,
60
+ // which is precisely how this advisory came to be ignored in an adopter estate
61
+ // where it had 233 real hits. Under-flagging is the standing bias (module header);
62
+ // nagging about the unfixable is the worst way to spend the reader's attention.
63
+ //
64
+ // A migration is identifiable by its own name wherever it lives, which is what
65
+ // makes this rule better than another directory guess. Each pattern below names
66
+ // the evidence it rests on; every one is either emitted by this repo itself or by
67
+ // a tool whose naming is documented and unmistakable. Patterns that are merely
68
+ // plausible are deliberately absent — a false exclusion silences a real finding,
69
+ // which is the same failure pointed the other way. An unusual layout is the
70
+ // project's to declare (`AntiPatternScanOptions.ignore`), not ours to guess.
71
+ const IMMUTABLE_MIGRATION_FILE: readonly RegExp[] = [
72
+ // Flyway versioned (V) and undo (U). Source of truth for the version grammar is
73
+ // `VERSIONED_RE` in `migrate-ts/src/write-migration-flyway.ts` — restated rather
74
+ // than imported because that constant is module-private, and exporting it is a
75
+ // change to another package. Two deliberate differences from it: it matches `V`
76
+ // ONLY (matching `U` there would let the undo files it emits bump the version
77
+ // counter), whereas an undo file is every bit as un-editable as its up half; and
78
+ // the multi-part form `V1.1__` / `V1_0__` is carried over verbatim, because
79
+ // Flyway pads versions for comparison and both spellings are legal.
80
+ /^[VU]\d+(?:[._]\d+)*__/,
81
+ // Flyway REPEATABLE (`R__`) is deliberately NOT here, and the reason is the rule's
82
+ // name. Flyway re-applies a repeatable migration whenever its checksum changes, so
83
+ // editing one is the sanctioned workflow rather than a forbidden act — which makes a
84
+ // finding on it ACTIONABLE, and this list exists precisely to drop findings that are
85
+ // not. Excluding it would silence a real one. Repeatable scripts live in Flyway's
86
+ // `db/migration`, which IGNORE_SEGMENTS covers, so the practical effect is limited to
87
+ // a stray `R__` outside that tree — where scanning it is the correct answer.
88
+ // A 10+ digit leading number is a clock, not a count: epoch milliseconds (13,
89
+ // node-pg-migrate) or YYYYMMDDHHMMSS (14, Rails / Knex / Sequelize, and this
90
+ // repo's own default writer — see the directory rule below). Ten is the floor
91
+ // because that is epoch seconds; a four-digit lead would also match an ordinary
92
+ // `2024_q1_revenue.sql`, which is exactly the false exclusion to avoid.
93
+ /^\d{10,}[-_]/,
94
+ // A ZERO-PADDED sequence: `0000_lush_rockslide.sql` (Drizzle Kit, whose default
95
+ // `out` is `./drizzle` and so is caught by no directory name), `0001_init.sql`
96
+ // (this repo's own D1 adapter — `write-migration-d1.ts` pads to 4), and
97
+ // `000001_create_users.up.sql` (golang-migrate). The leading zero is what makes
98
+ // this safe: sequence numbers are padded so they sort, ordinary numbers are not,
99
+ // so a year or quarter prefix can never match.
100
+ /^0\d+[-_]/,
101
+ // The up/down halves of a migration pair. `up.sql` / `down.sql` is what this
102
+ // repo's own default writer emits (`migrate-ts/src/write-migration.ts`); the
103
+ // `<name>.up.sql` / `<name>.down.sql` suffix form is golang-migrate's. Kept to
104
+ // `.sql` on purpose — `up.ts` and `down.ts` are not migration names.
105
+ /^(?:up|down)\.sql$/i,
106
+ /\.(?:up|down)\.sql$/i,
107
+ ];
108
+
109
+ // A per-migration DIRECTORY named by a timestamp. This repo's default writer
110
+ // creates `<YYYYMMDDHHMMSS>-<slug>/` holding `up.sql` + `down.sql`
111
+ // (`migrate-ts/src/write-migration.ts`), and Prisma creates
112
+ // `<YYYYMMDDHHMMSS>_<slug>/` holding `migration.sql`. Both are already covered
113
+ // when they sit under the default `migrations` root; this catches them under a
114
+ // project-chosen `migrate.outDir`, and it is risk-free — nobody names a source
115
+ // directory with a fourteen-digit timestamp.
116
+ const IMMUTABLE_MIGRATION_DIR = /^\d{10,}[-_]/;
117
+
118
+ function isImmutableMigrationFile(basename: string): boolean {
119
+ return IMMUTABLE_MIGRATION_FILE.some((re) => re.test(basename));
120
+ }
121
+
122
+ // --- project-declared ignore globs --------------------------------------------
123
+
124
+ /** Options for {@link scanSourceForAntiPatterns}. */
125
+ export interface AntiPatternScanOptions {
126
+ /**
127
+ * Extra path globs the scan skips entirely — the project's escape hatch for a
128
+ * layout the built-ins cannot know about (a vendored SQL archive, a migration
129
+ * tool with a house naming convention, a directory of read-only reference DDL).
130
+ *
131
+ * The built-in exclusions above are a CONVENIENCE, not an authority; the
132
+ * `@verifiedBy` fix in 0.23.1 established the shape — built-in defaults for the
133
+ * conventions that demonstrably exist, PLUS a project-declared list, because a
134
+ * closed list of guesses about someone else's repository will always be wrong
135
+ * somewhere. Declared globs ADD to the built-ins; they never replace them.
136
+ *
137
+ * Matched against forward-slash paths relative to the scan root: `**` spans
138
+ * separators, `*` does not, `?` is one non-separator character. A glob matching
139
+ * a directory path prunes that whole subtree.
140
+ *
141
+ * ```ts
142
+ * scanSourceForAntiPatterns(root, { ignore: ["drizzle/**", "db/legacy"] })
143
+ * ```
144
+ */
145
+ readonly ignore?: readonly string[];
146
+ }
147
+
148
+ /**
149
+ * A glob as permissive as the ones adopters actually write, anchored at the scan
150
+ * root and matched against forward-slash relative paths. Deliberately small:
151
+ * anything richer belongs to a glob library, and pulling one in for a knob this
152
+ * narrow is not worth the dependency. (Same grammar the retired `@verifiedBy`
153
+ * scan used for `verify.testFiles`, kept identical so a project that learned one
154
+ * spelling does not have to learn a second.)
155
+ */
156
+ function globToRegExp(glob: string): RegExp {
157
+ let out = "";
158
+ for (let i = 0; i < glob.length; i++) {
159
+ const c = glob[i]!;
160
+ if (c === "*") {
161
+ if (glob[i + 1] === "*") {
162
+ // `**/` may match zero segments, so `**/*.sql` matches a root-level file.
163
+ if (glob[i + 2] === "/") { out += "(?:.*/)?"; i += 2; } else { out += ".*"; i += 1; }
164
+ } else out += "[^/]*";
165
+ continue;
166
+ }
167
+ if (c === "?") { out += "[^/]"; continue; }
168
+ out += c.replace(/[.+^${}()|[\]\\]/g, "\\$&");
169
+ }
170
+ return new RegExp(`^${out}$`);
171
+ }
172
+
39
173
  // --- rules: each tests one source line, returns a finding shape or null -------
40
174
 
41
175
  const MONEY_WORD = /\b(?:price|amount|cost|total|subtotal|fee|balance|cents|dollars)\w*/i;
@@ -86,7 +220,11 @@ function findingFor(file: string, lineNo: number, raw: string, isSql: boolean):
86
220
  };
87
221
  }
88
222
 
89
- function walk(dir: string, root: string, acc: AntiPatternFinding[]): void {
223
+ function relPosix(root: string, abs: string): string {
224
+ return relative(root, abs).split(sep).join("/");
225
+ }
226
+
227
+ function walk(dir: string, root: string, ignore: readonly RegExp[], acc: AntiPatternFinding[]): void {
90
228
  let entries;
91
229
  try {
92
230
  entries = readdirSync(dir, { withFileTypes: true });
@@ -96,12 +234,25 @@ function walk(dir: string, root: string, acc: AntiPatternFinding[]): void {
96
234
  for (const e of entries) {
97
235
  if (e.isDirectory()) {
98
236
  if (IGNORE_SEGMENTS.has(e.name) || e.name.startsWith(".")) continue;
99
- walk(join(dir, e.name), root, acc);
237
+ // A per-migration directory is excluded by its own name, wherever it sits.
238
+ if (IMMUTABLE_MIGRATION_DIR.test(e.name)) continue;
239
+ const dirRel = relPosix(root, join(dir, e.name));
240
+ if (ignore.some((re) => re.test(dirRel))) continue;
241
+ walk(join(dir, e.name), root, ignore, acc);
100
242
  continue;
101
243
  }
102
244
  if (!e.isFile()) continue;
103
245
  if (!SCAN_EXT.test(e.name) || SKIP_FILE.test(e.name)) continue;
246
+ // Filename shape wins over location, deliberately: a migration is identifiable
247
+ // by its own name, and a project that keeps its migrations somewhere we cannot
248
+ // guess should not be nagged about files it may not edit. The cost is bounded —
249
+ // these name shapes are not ones an ordinary query or schema file wears.
250
+ if (isImmutableMigrationFile(e.name)) continue;
104
251
  const abs = join(dir, e.name);
252
+ // Checked before the read: a declared ignore is a decision not to look at the
253
+ // file at all, so it should not cost an I/O either.
254
+ const rel = relPosix(root, abs);
255
+ if (ignore.some((re) => re.test(rel))) continue;
105
256
  let text: string;
106
257
  try {
107
258
  if (statSync(abs).size > 512 * 1024) continue; // skip very large files
@@ -111,7 +262,6 @@ function walk(dir: string, root: string, acc: AntiPatternFinding[]): void {
111
262
  }
112
263
  // Skip MetaObjects-generated output — it legitimately contains AVG/CHECK etc.
113
264
  if (text.slice(0, 600).includes(GENERATED_MARKER)) continue;
114
- const rel = relative(root, abs).split(sep).join("/");
115
265
  const isSql = e.name.endsWith(".sql");
116
266
  const lines = text.split("\n");
117
267
  for (let i = 0; i < lines.length; i++) {
@@ -123,12 +273,17 @@ function walk(dir: string, root: string, acc: AntiPatternFinding[]): void {
123
273
 
124
274
  /**
125
275
  * Scan authored source under `cwd` for the high-precision anti-patterns above.
126
- * Returns every finding (advisory). Generated output, dependencies, build dirs,
127
- * and test files are skipped.
276
+ * Returns every finding (advisory this never influences an exit code).
277
+ * Generated output, dependencies, build dirs, test files, immutable migration
278
+ * files, and anything matched by `options.ignore` are skipped.
128
279
  */
129
- export function scanSourceForAntiPatterns(cwd: string): AntiPatternFinding[] {
280
+ export function scanSourceForAntiPatterns(
281
+ cwd: string,
282
+ options?: AntiPatternScanOptions,
283
+ ): AntiPatternFinding[] {
130
284
  const acc: AntiPatternFinding[] = [];
131
- walk(cwd, cwd, acc);
285
+ const ignore = (options?.ignore ?? []).map(globToRegExp);
286
+ walk(cwd, cwd, ignore, acc);
132
287
  acc.sort((a, b) => a.file.localeCompare(b.file) || a.line - b.line);
133
288
  return acc;
134
289
  }