@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.
- package/README.md +2 -1
- package/dist/src/commands/docs.d.ts +14 -1
- package/dist/src/commands/docs.d.ts.map +1 -1
- package/dist/src/commands/docs.js +153 -8
- package/dist/src/commands/docs.js.map +1 -1
- package/dist/src/commands/eject.d.ts +38 -0
- package/dist/src/commands/eject.d.ts.map +1 -0
- package/dist/src/commands/eject.js +233 -0
- package/dist/src/commands/eject.js.map +1 -0
- package/dist/src/commands/gen.d.ts.map +1 -1
- package/dist/src/commands/gen.js +43 -20
- package/dist/src/commands/gen.js.map +1 -1
- package/dist/src/commands/init.d.ts +15 -1
- package/dist/src/commands/init.d.ts.map +1 -1
- package/dist/src/commands/init.js +208 -20
- package/dist/src/commands/init.js.map +1 -1
- package/dist/src/commands/types.d.ts +2 -1
- package/dist/src/commands/types.d.ts.map +1 -1
- package/dist/src/commands/types.js +165 -28
- package/dist/src/commands/types.js.map +1 -1
- package/dist/src/commands/verify.d.ts +9 -1
- package/dist/src/commands/verify.d.ts.map +1 -1
- package/dist/src/commands/verify.js +299 -51
- package/dist/src/commands/verify.js.map +1 -1
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/index.js +105 -4
- package/dist/src/index.js.map +1 -1
- package/dist/src/lib/advisory.d.ts +77 -0
- package/dist/src/lib/advisory.d.ts.map +1 -0
- package/dist/src/lib/advisory.js +97 -0
- package/dist/src/lib/advisory.js.map +1 -0
- package/dist/src/lib/anti-patterns.d.ts +27 -3
- package/dist/src/lib/anti-patterns.d.ts.map +1 -1
- package/dist/src/lib/anti-patterns.js +145 -8
- package/dist/src/lib/anti-patterns.js.map +1 -1
- package/dist/src/lib/args.d.ts +35 -1
- package/dist/src/lib/args.d.ts.map +1 -1
- package/dist/src/lib/args.js +32 -1
- package/dist/src/lib/args.js.map +1 -1
- package/dist/src/lib/detect-stack.d.ts.map +1 -1
- package/dist/src/lib/detect-stack.js +4 -13
- package/dist/src/lib/detect-stack.js.map +1 -1
- package/dist/src/lib/docs-drift.d.ts +31 -0
- package/dist/src/lib/docs-drift.d.ts.map +1 -0
- package/dist/src/lib/docs-drift.js +195 -0
- package/dist/src/lib/docs-drift.js.map +1 -0
- package/dist/src/lib/format.d.ts +10 -0
- package/dist/src/lib/format.d.ts.map +1 -1
- package/dist/src/lib/format.js +15 -0
- package/dist/src/lib/format.js.map +1 -1
- package/dist/src/lib/output.d.ts +13 -0
- package/dist/src/lib/output.d.ts.map +1 -1
- package/dist/src/lib/output.js +16 -1
- package/dist/src/lib/output.js.map +1 -1
- package/dist/src/lib/package-manifest.d.ts +27 -0
- package/dist/src/lib/package-manifest.d.ts.map +1 -0
- package/dist/src/lib/package-manifest.js +52 -0
- package/dist/src/lib/package-manifest.js.map +1 -0
- package/package.json +11 -11
- package/src/commands/docs.ts +194 -8
- package/src/commands/eject.ts +282 -0
- package/src/commands/gen.ts +54 -19
- package/src/commands/init.ts +217 -19
- package/src/commands/types.ts +185 -34
- package/src/commands/verify.ts +358 -47
- package/src/index.ts +114 -5
- package/src/lib/advisory.ts +150 -0
- package/src/lib/anti-patterns.ts +163 -8
- package/src/lib/args.ts +74 -2
- package/src/lib/detect-stack.ts +4 -11
- package/src/lib/docs-drift.ts +222 -0
- package/src/lib/format.ts +14 -0
- package/src/lib/output.ts +33 -2
- 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
|
+
}
|
package/src/lib/anti-patterns.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
|
127
|
-
*
|
|
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(
|
|
280
|
+
export function scanSourceForAntiPatterns(
|
|
281
|
+
cwd: string,
|
|
282
|
+
options?: AntiPatternScanOptions,
|
|
283
|
+
): AntiPatternFinding[] {
|
|
130
284
|
const acc: AntiPatternFinding[] = [];
|
|
131
|
-
|
|
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
|
}
|