@brighterly/lib-fe-codestyle 1.0.0 → 1.0.1

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 (57) hide show
  1. package/README.md +234 -178
  2. package/dist/cli.d.ts +8 -0
  3. package/dist/cli.js +181 -0
  4. package/dist/config/define-config.d.ts +11 -0
  5. package/dist/config/define-config.js +33 -0
  6. package/dist/config/load-config.d.ts +15 -0
  7. package/dist/config/load-config.js +52 -0
  8. package/dist/config/schema.d.ts +58 -0
  9. package/dist/config/schema.js +139 -0
  10. package/dist/engine/docs.d.ts +14 -0
  11. package/dist/engine/docs.js +72 -0
  12. package/dist/engine/meta.d.ts +32 -0
  13. package/dist/engine/meta.js +47 -0
  14. package/dist/engine/migrate.d.ts +22 -0
  15. package/dist/engine/migrate.js +175 -0
  16. package/dist/engine/overrides.d.ts +36 -0
  17. package/dist/engine/overrides.js +92 -0
  18. package/dist/engine/registry.d.ts +35 -0
  19. package/dist/engine/registry.js +100 -0
  20. package/dist/engine/report.d.ts +23 -0
  21. package/dist/engine/report.js +87 -0
  22. package/dist/engine/run.d.ts +57 -0
  23. package/dist/engine/run.js +353 -0
  24. package/dist/engine/self-test.d.ts +33 -0
  25. package/dist/engine/self-test.js +141 -0
  26. package/dist/engine/types.d.ts +232 -0
  27. package/dist/{invariants → engine}/types.js +2 -1
  28. package/dist/{invariants → engine}/util.d.ts +31 -39
  29. package/dist/{invariants → engine}/util.js +59 -86
  30. package/dist/index.d.ts +16 -0
  31. package/dist/index.js +14 -0
  32. package/dist/rules/convention.d.ts +20 -0
  33. package/dist/{invariants/rules → rules}/convention.js +89 -15
  34. package/dist/rules/index.d.ts +28 -0
  35. package/dist/{invariants/rules → rules}/index.js +40 -32
  36. package/dist/{invariants/rules → rules}/regression.d.ts +1 -1
  37. package/dist/{invariants/rules → rules}/regression.js +26 -6
  38. package/dist/{invariants/rules → rules}/shared.d.ts +1 -1
  39. package/dist/validate/index.d.ts +1 -1
  40. package/dist/validate/index.js +1 -1
  41. package/package.json +8 -13
  42. package/dist/invariants/index.d.ts +0 -7
  43. package/dist/invariants/index.js +0 -5
  44. package/dist/invariants/rules/convention.d.ts +0 -18
  45. package/dist/invariants/rules/fixtures.d.ts +0 -17
  46. package/dist/invariants/rules/fixtures.js +0 -89
  47. package/dist/invariants/rules/index.d.ts +0 -55
  48. package/dist/invariants/rules/linter.d.ts +0 -10
  49. package/dist/invariants/rules/linter.js +0 -9
  50. package/dist/invariants/run.d.ts +0 -53
  51. package/dist/invariants/run.js +0 -746
  52. package/dist/invariants/self-test.d.ts +0 -46
  53. package/dist/invariants/self-test.js +0 -84
  54. package/dist/invariants/types.d.ts +0 -222
  55. /package/dist/{invariants → engine}/ast.d.ts +0 -0
  56. /package/dist/{invariants → engine}/ast.js +0 -0
  57. /package/dist/{invariants/rules → rules}/shared.js +0 -0
@@ -1,746 +0,0 @@
1
- /**
2
- * The invariants runner — `run(options)`.
3
- *
4
- * This is land-app's `invariants/cli.mts` with four hardcoded constants lifted into
5
- * arguments (scan roots, rule sources, the spec dir/project, the README path) and the rule
6
- * list, config and fixtures injected. Nothing else was redesigned: the argv surface, the
7
- * self-test gate, the caches, the suppression grammar, severity resolution, the reporter
8
- * and the exit codes are the originals.
9
- *
10
- * Exit codes:
11
- * 0 — no error-level violations
12
- * 1 — at least one error-level violation
13
- * 2 — the validator itself failed
14
- */
15
- import { execSync, spawnSync } from 'child_process';
16
- import { existsSync, readFileSync } from 'fs';
17
- import { resolve } from 'path';
18
- import { walk, matchesAny, createReader, parseIgnores, stripCodeComments, findDanglingRuleRefs, findForbiddenSuppressions, resolveFindingLevel, knownRuleIds, levelOverrideOf, toRelative, } from './util.js';
19
- import { runSelfTest } from './self-test.js';
20
- export const run = async (options) => {
21
- const { root, rules, linterOwnedRules, scanRoots, ruleSources, vitestProject, specDir, readmePath, fixtures, extraChecks, selfTestSkip } = options;
22
- const { allowlists, levels, retired, unmaintained } = options.config;
23
- const settings = options.config.settings ?? {};
24
- const configFile = options.configFile ?? 'invariants/config.mts';
25
- const exit = options.exit ?? process.exit;
26
- const baseRead = createReader(root);
27
- // Rule ids are allocated across the shared pack AND every consuming app. Nothing
28
- // namespaces them, so two sources CAN pick the same number — and a duplicate id is
29
- // silently destructive: allowlists, `levels` and `invariant-ignore` all dereference an
30
- // id, so an exemption written for one rule would suppress the other. Fail loudly instead.
31
- {
32
- const seen = new Map();
33
- for (const r of rules)
34
- seen.set(r.id, (seen.get(r.id) ?? 0) + 1);
35
- const dupes = [...seen.entries()].filter(([, n]) => n > 1).map(([id]) => id);
36
- if (dupes.length) {
37
- console.error(`validate-invariants: duplicate rule id(s) ${dupes.join(', ')} — an allowlist or invariant-ignore naming one would silently suppress the other. Renumber the app-local rule; \`--list\` prints every id in use.`);
38
- return exit(2);
39
- }
40
- }
41
- // ---------------------------------------------------------------------------
42
- // args
43
- // ---------------------------------------------------------------------------
44
- const argv = options.argv ?? process.argv.slice(2);
45
- const has = (flag) => argv.includes(flag);
46
- const valueOf = (name) => {
47
- const hit = argv.find(a => a.startsWith(`--${name}=`));
48
- return hit ? hit.slice(name.length + 3) : null;
49
- };
50
- const opts = {
51
- staged: has('--staged'),
52
- json: has('--json'),
53
- rule: valueOf('rule'),
54
- // --family=E: every rule of one family over the whole repo. A family gate (e.g.
55
- // `pnpm validate:e2e`) runs this way, so it is one visible command in pre-commit /
56
- // CI — not a side effect of --staged (which sees only this commit's files).
57
- family: valueOf('family'),
58
- selfTest: has('--self-test'),
59
- // --stdin --file=<path>: check the content on stdin AS IF it were that file. This
60
- // is the PreToolUse hook's entry point — the file is not on disk yet.
61
- stdin: has('--stdin'),
62
- file: valueOf('file'),
63
- // The vitest half (invariants/specs) resolves real modules, so it costs a vitest
64
- // boot. A full run includes it; --staged, --rule=, --family= and --stdin skip it,
65
- // because the pre-commit hook runs `pnpm test` right after, a single-rule run is
66
- // a dev loop, and the hook is per-file.
67
- vitest: !has('--staged') && !has('--no-vitest') && !valueOf('rule') && !valueOf('family') && !has('--stdin'),
68
- };
69
- // stdin mode runs: every FileRule whose scope matches `--file` (only that family's, with
70
- // --family=), plus the IG* meta rules.
71
- // It skips: the rule self-test and the vitest half (per-file latency), and every
72
- // `once` rule (they read the repo, not the candidate) — so a PASS here is narrower
73
- // than a PASS from a full run.
74
- if (opts.stdin && !opts.file) {
75
- console.error('validate-invariants: --stdin requires --file=<repo-relative path>');
76
- return exit(2);
77
- }
78
- if (opts.file && !opts.stdin) {
79
- console.error('validate-invariants: --file= is only meaningful with --stdin');
80
- return exit(2);
81
- }
82
- if (opts.stdin && opts.staged) {
83
- console.error('validate-invariants: --stdin and --staged are exclusive');
84
- return exit(2);
85
- }
86
- if (opts.stdin && process.stdin.isTTY) {
87
- console.error('validate-invariants: --stdin expects content on stdin, not a terminal');
88
- return exit(2);
89
- }
90
- let stdinContent = null;
91
- if (opts.stdin) {
92
- try {
93
- stdinContent = readFileSync(0, 'utf8');
94
- }
95
- catch (e) {
96
- console.error(`validate-invariants: could not read stdin — ${e.message}`);
97
- return exit(2);
98
- }
99
- // Empty is a plumbing failure, not a clean file: the rules would invent findings
100
- // about content that was never sent.
101
- if (stdinContent === '') {
102
- console.error('validate-invariants: --stdin received no content');
103
- return exit(2);
104
- }
105
- }
106
- // Normalise: './x' or an absolute path under the repo matches no rule scope and no
107
- // unmaintained glob, so the run would print PASS over content it never checked.
108
- const stdinFile = opts.stdin ? toRelative(root, opts.file) : null;
109
- /** In stdin mode the candidate file reads from stdin; everything else from disk. */
110
- const read = (rel) => (stdinFile !== null && rel === stdinFile ? stdinContent : baseRead(rel));
111
- // ---------------------------------------------------------------------------
112
- // --list — the rule inventory as markdown, generated from the rules themselves
113
- // so invariants/README.md cannot drift from what actually runs
114
- // ---------------------------------------------------------------------------
115
- const specTitles = () => {
116
- const out = [];
117
- for (const file of walk(root, specDir)) {
118
- if (!file.endsWith('.test.ts'))
119
- continue;
120
- const src = read(file) || '';
121
- const suite = (src.match(/describe\('([^']+)'/) || [])[1] || file;
122
- const id = (suite.match(/^([A-Z]\d+)/) || [])[1] || '—';
123
- const assertions = [...src.matchAll(/\n\s{2}it\('([^']+)'/g)].flatMap(m => (m[1] ? [m[1]] : []));
124
- out.push({ id, suite: suite.replace(/^[A-Z]\d+\s*—\s*/, ''), file, assertions });
125
- }
126
- return out.sort((a, b) => a.id.localeCompare(b.id, undefined, { numeric: true }));
127
- };
128
- const renderInventory = () => {
129
- const byId = (a, b) => a.id.localeCompare(b.id, undefined, { numeric: true });
130
- const lines = [];
131
- lines.push('### Vector 1 — plain checks (regex, or a real AST where regex would lie)');
132
- lines.push('');
133
- lines.push('| Rule | Level | Checks | Where it looks |');
134
- lines.push('|---|---|---|---|');
135
- for (const r of [...rules].sort(byId)) {
136
- const level = levelOverrideOf(levels, r.id) ?? r.level;
137
- const scope = r.scope.map(sc => `\`${sc}\``).join(', ');
138
- lines.push(`| **${r.id}** | ${level} | ${r.title} | ${scope} |`);
139
- }
140
- lines.push('');
141
- lines.push('### Vector 2 — vitest specs (need the real modules, not their text)');
142
- lines.push('');
143
- lines.push('| Rule | Checks | Assertions |');
144
- lines.push('|---|---|---|');
145
- for (const s of specTitles()) {
146
- lines.push(`| **${s.id}** | ${s.suite} | ${s.assertions.map(a => `• ${a}`).join('<br>')} |`);
147
- }
148
- if (specTitles().some(s => s.id === '—')) {
149
- lines.push('');
150
- lines.push('_A `—` row is plumbing, not a rule: the spec guards the engine and has no rule id. `knownRuleIds()` filters exactly that placeholder out of the live set, so it is not a citable ID and must stay `—`._');
151
- }
152
- lines.push('');
153
- lines.push('### Vector 3 — built-in ESLint rules (configured, not implemented)');
154
- lines.push('');
155
- lines.push('| Rule | Level | ESLint rule | Checks |');
156
- lines.push('|---|---|---|---|');
157
- if (!linterOwnedRules.length) {
158
- lines.push('| — | — | — | _none configured — see the `retired` registry in `config.mts` for why_ |');
159
- }
160
- for (const r of linterOwnedRules) {
161
- lines.push(`| **${r.id}** | ${r.level} | \`${r.eslintRule}\` | ${r.title} |`);
162
- }
163
- lines.push('');
164
- lines.push(`_${rules.length} rules + ${specTitles().length} specs + ${linterOwnedRules.length} linter rule(s). Regenerate this section with \`pnpm validate:invariants --list\`._`);
165
- return lines.join('\n');
166
- };
167
- if (has('--list')) {
168
- console.log(renderInventory());
169
- return exit(0);
170
- }
171
- // ---------------------------------------------------------------------------
172
- // --rules-doc=<name> — an agent-facing rules file (e.g. .claude/rules/e2e.md),
173
- // rendered from the rule objects into the prose template at
174
- // invariants/rules/<name>-rules.doc.md so the text cannot drift from what is
175
- // enforced. The consumer's sync-docs script writes the output where it belongs.
176
- // ---------------------------------------------------------------------------
177
- const renderRulesDoc = (name) => {
178
- const templatePath = `invariants/rules/${name}-rules.doc.md`;
179
- const template = read(templatePath);
180
- if (template === null) {
181
- console.error(`validate-invariants: no template at ${templatePath}`);
182
- return null;
183
- }
184
- const family = name === 'e2e' ? 'E' : name.toUpperCase();
185
- const byId = (a, b) => a.id.localeCompare(b.id, undefined, { numeric: true });
186
- const own = rules.filter(r => r.id.startsWith(family)).sort(byId);
187
- const linted = linterOwnedRules.filter(l => l.eslintRule.startsWith('playwright/')).sort(byId);
188
- // Pipes break the table; a bare `<key>` is eaten by any HTML renderer of the file.
189
- const cell = (s) => s.replace(/\|/g, '\\|').replace(/</g, '&lt;');
190
- const table = [
191
- '## Rules',
192
- '',
193
- '| ID | Violation | Why | Fix |',
194
- '|---|---|---|---|',
195
- ...own.map(r => `| **${r.id}** | ${cell(r.title)} | ${cell(r.why)} | ${cell(r.fix)} |`),
196
- '',
197
- '## Playwright hygiene (eslint-plugin-playwright, declared as L-rules)',
198
- '',
199
- '| ID | ESLint rule | Why |',
200
- '|---|---|---|',
201
- ...linted.map(l => `| **${l.id}** | \`${l.eslintRule}\` | ${cell(l.why)} |`),
202
- ];
203
- return template.replace('<!-- rules -->', table.join('\n'));
204
- };
205
- if (valueOf('rules-doc')) {
206
- const doc = renderRulesDoc(valueOf('rules-doc'));
207
- if (doc === null)
208
- return exit(2);
209
- console.log(doc);
210
- return exit(0);
211
- }
212
- // ---------------------------------------------------------------------------
213
- // self-test — proves each rule still fires on a known violation
214
- // ---------------------------------------------------------------------------
215
- // A rule that has silently stopped matching reports nothing — which is
216
- // indistinguishable from a clean codebase. So the scan alone can print PASS while
217
- // a rule is dead, and R12 proved the inverse too (a passing fixture while the rule
218
- // was wrong about real code). Both halves are needed, so a FULL run does both.
219
- //
220
- // Skipped only where it would be noise rather than signal: --rule= is a dev loop,
221
- // and --staged is the pre-commit path, where it runs only if the commit actually
222
- // touches the validator's own sources.
223
- /**
224
- * What this commit stages. `present` is what `--diff-filter=ACMR` used to give — added, copied,
225
- * modified, the new side of a rename — and `gone` is what it hid: deleted paths and the old side
226
- * of a rename. A `once` rule has to fire on `gone` too: deleting the one file a registry rule
227
- * models is exactly the change it exists for, and with the file no longer a candidate it
228
- * reached CI unseen.
229
- */
230
- const stagedChanges = (() => {
231
- let cached = null;
232
- return () => {
233
- if (cached)
234
- return cached;
235
- const present = [];
236
- const gone = [];
237
- for (const line of execSync('git diff --staged --name-status -M', { cwd: root, encoding: 'utf8' }).split('\n')) {
238
- const [status = '', ...paths] = line.split('\t').map(s => s.trim());
239
- const first = paths[0];
240
- const last = paths[paths.length - 1];
241
- if (!status || !first || !last)
242
- continue;
243
- if (status.startsWith('D'))
244
- gone.push(first);
245
- else if (status.startsWith('R')) {
246
- gone.push(first);
247
- present.push(last);
248
- }
249
- else
250
- present.push(last);
251
- }
252
- cached = { present: present.filter(f => existsSync(resolve(root, f))), gone };
253
- return cached;
254
- };
255
- })();
256
- const stagedTouchesRules = () => {
257
- if (!opts.staged)
258
- return false;
259
- try {
260
- return stagedChanges().present.some(f => ruleSources.some(p => f.startsWith(p)));
261
- }
262
- catch {
263
- return false;
264
- }
265
- };
266
- if (opts.family && opts.rule) {
267
- console.error('validate-invariants: --family= and --rule= are exclusive');
268
- return exit(2);
269
- }
270
- if (opts.family && !rules.some(r => r.id.startsWith(opts.family))) {
271
- console.error(`validate-invariants: unknown family "${opts.family}". Known: ${[...new Set(rules.map(r => r.id.replace(/\d+$/, '')))].join(', ')}`);
272
- return exit(2);
273
- }
274
- // A full run — no --staged, no --rule=, no --family=, no --stdin — verifies the rules before trusting them.
275
- const isFullRun = !opts.staged && !opts.rule && !opts.stdin && !opts.family;
276
- const explicitSelfTest = has('--self-test');
277
- if (explicitSelfTest && !fixtures) {
278
- console.error('validate-invariants: --self-test needs fixtures; pass `fixtures` to run()');
279
- return exit(2);
280
- }
281
- if (fixtures && (explicitSelfTest || isFullRun || stagedTouchesRules())) {
282
- const { failures, checked } = await runSelfTest({ rules, fixtures, extraChecks, skip: selfTestSkip });
283
- // The README is the thing a developer or an agent reads to learn what is
284
- // enforced. A stale inventory there is worse than none: it states coverage we
285
- // do not have. Regenerate with `pnpm validate:invariants --list`.
286
- const readme = read(readmePath);
287
- if (readme === null) {
288
- failures.push(`${readmePath} is missing — it is the entry point for this directory`);
289
- }
290
- else {
291
- const between = readme.match(/<!-- BEGIN rule-inventory[^>]*-->\n([\s\S]*?)<!-- END rule-inventory -->/);
292
- if (!between) {
293
- failures.push(`${readmePath} has no <!-- BEGIN rule-inventory --> block`);
294
- }
295
- else if ((between[1] ?? '').trim() !== renderInventory().trim()) {
296
- failures.push(`${readmePath} rule inventory is out of date — regenerate it with \`pnpm validate:invariants --list\` and paste it between the markers`);
297
- }
298
- }
299
- console.log();
300
- const label = explicitSelfTest ? 'validate-invariants --self-test' : 'rule self-test';
301
- console.log(` ${label} — ${checked.length} rule(s) + plumbing`);
302
- if (failures.length) {
303
- for (const f of failures)
304
- console.log(` ✗ ${f}`);
305
- console.log(`\n✗ ${failures.length} self-test failure(s).`);
306
- return exit(1);
307
- }
308
- console.log(' ✅ every rule fires on its violating fixture and stays quiet on the clean one.');
309
- // Explicit --self-test is the whole job; the implicit run continues into the scan.
310
- if (explicitSelfTest)
311
- return exit(0);
312
- }
313
- // ---------------------------------------------------------------------------
314
- // file set
315
- // ---------------------------------------------------------------------------
316
- const scanned = scanRoots.flatMap((d) => walk(root, d));
317
- // Unmaintained areas come out of `allFiles`, not just `candidates`: rules resolve
318
- // each other's files through allFiles (R10 walks it to find a :next-step target,
319
- // B1 walks it for a booking wrap, query.mts walks it for Views). Filtering only
320
- // the candidate set would keep an excluded file reachable as some other rule's
321
- // answer — the exclusion has to be invisible everywhere or it is not one.
322
- const UNMAINTAINED_PATHS = Object.keys(unmaintained.paths);
323
- let allFiles = UNMAINTAINED_PATHS.length
324
- ? scanned.filter((f) => !matchesAny(f, UNMAINTAINED_PATHS))
325
- : scanned;
326
- let unmaintainedCount = scanned.length - allFiles.length;
327
- const activeRules = opts.rule
328
- ? rules.filter(r => r.id === opts.rule)
329
- : opts.family ? rules.filter(r => r.id.startsWith(opts.family)) : rules;
330
- if (opts.rule && activeRules.length === 0) {
331
- console.error(`validate-invariants: unknown rule "${opts.rule}". Known: ${rules.map(r => r.id).join(', ')}`);
332
- return exit(2);
333
- }
334
- let candidates = allFiles;
335
- // --family= narrows the candidates to what its rules can see, so the header's file count
336
- // describes the family's reach ("6 rules (family E) over 0 file(s)" while the tree does
337
- // not exist yet), not the whole repo.
338
- if (opts.family && !opts.staged && !opts.stdin) {
339
- candidates = allFiles.filter(f => activeRules.some(r => matchesAny(f, r.scope)));
340
- // …and so must the exclusion count: "not checked: 60 file(s)" under --family= would be
341
- // counting files no family rule scopes, which reads as coverage this family never had.
342
- // …and a rule that declares `seesUnmaintained` does check them, so it does not count here.
343
- unmaintainedCount = scanned.filter(f => matchesAny(f, UNMAINTAINED_PATHS) && activeRules.some(r => !r.seesUnmaintained && matchesAny(f, r.scope))).length;
344
- }
345
- // In --staged mode the exclusion count must describe THIS COMMIT, not the repo: a hook
346
- // that prints "not checked: 35 file(s)" while the developer staged three of them reads
347
- // as background noise. Staging only excluded files is the case that has to be loud.
348
- let stagedExcluded = 0;
349
- /** Every staged path — the unmaintained ones and the deleted ones included: what decides whether a `once` rule runs. */
350
- let stagedTouched = [];
351
- let stagedGone = 0;
352
- if (opts.staged) {
353
- try {
354
- const { present, gone } = stagedChanges();
355
- candidates = present.filter(f => !matchesAny(f, UNMAINTAINED_PATHS));
356
- stagedExcluded = present.length - candidates.length;
357
- stagedTouched = [...present, ...gone];
358
- stagedGone = gone.length;
359
- }
360
- catch (e) {
361
- console.error(`validate-invariants: could not read the staged file list — ${e.message}`);
362
- return exit(2);
363
- }
364
- }
365
- if (opts.stdin) {
366
- const f = stdinFile;
367
- // A skipped file must say so, in the same shape the caller asked for — a bare PASS
368
- // over an unchecked path is the failure this mode must never produce.
369
- const skip = (reason) => {
370
- if (opts.json)
371
- console.log(JSON.stringify({ ok: true, scanned: 0, errors: 0, warnings: 0, vitest: { ran: false }, findings: [], skipped: { file: f, reason } }, null, 2));
372
- else
373
- console.log(` not checked: ${f} — ${reason}`);
374
- return exit(0);
375
- };
376
- if (matchesAny(f, UNMAINTAINED_PATHS))
377
- return skip(`unmaintained in ${configFile}`);
378
- if (!activeRules.some(r => matchesAny(f, r.scope)))
379
- return skip(`no ${opts.family ? `${opts.family}-family ` : ''}rule scopes this path`);
380
- candidates = [f];
381
- if (!allFiles.includes(f))
382
- allFiles = [...allFiles, f];
383
- }
384
- // ---------------------------------------------------------------------------
385
- // run
386
- // ---------------------------------------------------------------------------
387
- const findings = []; // { ruleId, level, file, line, message }
388
- const ignoreProblems = [];
389
- // one comment-strip per file, shared by every rule
390
- const strippedCache = new Map();
391
- const strippedOf = (file, source) => {
392
- if (!strippedCache.has(file))
393
- strippedCache.set(file, stripCodeComments(source, file));
394
- return strippedCache.get(file);
395
- };
396
- // one ignore-parse per file, shared by every rule
397
- const ignoreCache = new Map();
398
- const ignoresFor = (file) => {
399
- if (!ignoreCache.has(file)) {
400
- // The consumer's self-test file holds literal `invariant-ignore:` strings as
401
- // fixture DATA — parsing them would report the fixtures themselves (IG0/IG1).
402
- const src = file === settings.selfTestFile ? null : read(file);
403
- ignoreCache.set(file, src === null ? { suppressions: new Map(), unreasoned: [], comments: [] } : parseIgnores(src));
404
- }
405
- return ignoreCache.get(file);
406
- };
407
- for (const rule of activeRules) {
408
- const override = levelOverrideOf(levels, rule.id);
409
- const level = override ?? rule.level;
410
- if (level === 'off')
411
- continue;
412
- const allowed = allowlists[rule.id] ?? {};
413
- // `once` rules read their own inputs — run them a single time if any file
414
- // they depend on is in scope, instead of once per matching file.
415
- if (rule.once) {
416
- if (opts.stdin)
417
- continue; // a once rule reads the repo itself; the hook is per-file
418
- // Staged mode asks the whole staged set: a deleted file, or one under an unmaintained glob,
419
- // is no candidate but is still a change to the tree the rule reads.
420
- const triggers = opts.staged ? stagedTouched : candidates;
421
- if (!triggers.some(f => matchesAny(f, rule.scope)))
422
- continue;
423
- let raw;
424
- try {
425
- raw = (await rule.check({ file: rule.scope[0], source: '', code: '', read, allFiles, scanned: rule.seesUnmaintained ? scanned : undefined, settings })) || [];
426
- }
427
- catch (e) {
428
- console.error(`validate-invariants: rule ${rule.id} crashed — ${e.message}`);
429
- return exit(2);
430
- }
431
- for (const v of raw) {
432
- const { suppressions } = ignoresFor(v.file);
433
- if (!rule.noIgnore && suppressions.get(v.line)?.has(rule.id))
434
- continue;
435
- const resolved = resolveFindingLevel(rule.level, override, v.level);
436
- if (resolved === 'off')
437
- continue;
438
- findings.push({ ...v, ruleId: rule.id, level: resolved });
439
- }
440
- continue;
441
- }
442
- for (const file of candidates) {
443
- if (!matchesAny(file, rule.scope))
444
- continue;
445
- if (rule.exclude && matchesAny(file, rule.exclude))
446
- continue;
447
- if (allowed[file])
448
- continue;
449
- const source = read(file);
450
- if (source === null)
451
- continue;
452
- const code = strippedOf(file, source);
453
- let raw;
454
- try {
455
- // Always await, never dispatch on rule.async: a Promise-returning check whose author
456
- // forgot the flag was iterated AS a Promise — zero findings, exit 2, and nothing
457
- // pointing at the cause. Awaiting a sync value costs nothing.
458
- raw = (await rule.check({ file, source, code, read, allFiles, scanned: rule.seesUnmaintained ? scanned : undefined, settings })) ?? [];
459
- }
460
- catch (e) {
461
- console.error(`validate-invariants: rule ${rule.id} crashed on ${file} — ${e.message}`);
462
- return exit(2);
463
- }
464
- const { suppressions } = ignoresFor(file);
465
- for (const v of raw) {
466
- if (!rule.noIgnore && suppressions.get(v.line)?.has(rule.id))
467
- continue;
468
- const resolved = resolveFindingLevel(rule.level, override, v.level);
469
- if (resolved === 'off')
470
- continue;
471
- findings.push({ ...v, ruleId: rule.id, level: resolved });
472
- }
473
- }
474
- }
475
- // IG0 — unreasoned ignore comments are themselves a finding. Scans `.mts` too:
476
- // the validator's own sources are the likeliest place to grow a suppression, and
477
- // exempting the tool from its own reason requirement is the backdoor IG0 exists
478
- // to close. Honours `allowlists.IG0` so self-test fixture strings can opt out.
479
- const ignoreScanRe = /\.(m?ts|vue|mjs|js)$/;
480
- const ig0Allowed = allowlists.IG0 ?? {};
481
- /** Every `invariant-ignore` comment in the candidate set, minus one meta rule's allowlist — IG1 and IG2 read the same comments. */
482
- const ignoreCommentsExcept = (allowed) => candidates.filter(f => ignoreScanRe.test(f) && !allowed[f])
483
- .flatMap(f => (read(f)?.includes('invariant-ignore') ? ignoresFor(f).comments.map(cm => ({ file: f, ...cm })) : []));
484
- for (const file of candidates) {
485
- if (!ignoreScanRe.test(file))
486
- continue;
487
- if (ig0Allowed[file])
488
- continue;
489
- const src = read(file);
490
- if (src === null || !src.includes('invariant-ignore'))
491
- continue;
492
- for (const u of ignoresFor(file).unreasoned) {
493
- ignoreProblems.push({
494
- ruleId: 'IG0', level: 'error', file, line: u.line,
495
- message: `invariant-ignore: ${u.ids.join(', ')} has no reason — an unexplained exemption silently deletes the rule`,
496
- });
497
- }
498
- }
499
- // IG2 — a suppression naming a `noIgnore` rule. The check above already refused to
500
- // honour it (rule.noIgnore skips the suppression lookup); report the attempt so it
501
- // cannot pass as a reviewed exemption. Honours `allowlists.IG2` for the self-test's
502
- // fixture strings.
503
- {
504
- const noIgnoreIds = new Set(rules.filter(r => r.noIgnore).map(r => r.id));
505
- for (const v of findForbiddenSuppressions(ignoreCommentsExcept(allowlists.IG2 ?? {}), noIgnoreIds))
506
- ignoreProblems.push({ ruleId: 'IG2', level: 'error', ...v });
507
- }
508
- findings.push(...ignoreProblems);
509
- // IG1 — references to rule IDs that no longer exist. Three sinks dereference an
510
- // ID (allowlists, levels, invariant-ignore) and none of them used to check it
511
- // resolved, so renumbering a rule silently voided every suppression naming it.
512
- // Runs on the FULL rule set, never the --rule= subset: a stale reference is a
513
- // property of the config, not of whichever rule is being debugged.
514
- {
515
- const live = knownRuleIds({
516
- fileScan: rules.map(r => r.id),
517
- specs: specTitles().map(sp => sp.id),
518
- linter: linterOwnedRules.map(r => r.id),
519
- // The meta rules are addressable too, so they must count as live.
520
- meta: ['IG0', 'IG1', 'IG2'],
521
- });
522
- const ignores = ignoreCommentsExcept(allowlists.IG1 ?? {});
523
- for (const v of findDanglingRuleRefs({
524
- live, retired, allowlists, levels, ignores,
525
- configFile,
526
- configSource: read(configFile) ?? undefined,
527
- pathExists: p => read(p) !== null,
528
- })) {
529
- findings.push({ ruleId: 'IG1', level: 'error', ...v });
530
- }
531
- }
532
- // ---------------------------------------------------------------------------
533
- // module-resolving half — invariants/specs/*.test.ts
534
- // ---------------------------------------------------------------------------
535
- const runVitestInvariants = () => {
536
- const bin = resolve(root, 'node_modules/.bin/vitest');
537
- const args = ['run', '--project', vitestProject, specDir, '--reporter=dot'];
538
- const r = existsSync(bin)
539
- ? spawnSync(bin, args, { cwd: root, encoding: 'utf8' })
540
- : spawnSync('npx', ['vitest', ...args], { cwd: root, encoding: 'utf8' });
541
- return { ok: r.status === 0, output: `${r.stdout || ''}${r.stderr || ''}`.trim() };
542
- };
543
- // A consumer with no specs yet (a freshly wired app) must not fail on
544
- // `vitest --project <name>` finding nothing — the spec half only runs when
545
- // the spec dir actually holds tests.
546
- const hasSpecs = walk(root, specDir).some(f => f.endsWith('.test.ts'));
547
- const vitestResult = opts.vitest && hasSpecs ? runVitestInvariants() : null;
548
- // ---------------------------------------------------------------------------
549
- // report
550
- // ---------------------------------------------------------------------------
551
- const errors = findings.filter(f => f.level === 'error');
552
- const warns = findings.filter(f => f.level === 'warn');
553
- if (opts.json) {
554
- console.log(JSON.stringify({
555
- ok: errors.length === 0 && (vitestResult ? vitestResult.ok : true),
556
- scanned: candidates.length,
557
- errors: errors.length,
558
- warnings: warns.length,
559
- vitest: vitestResult ? { ran: true, ok: vitestResult.ok } : { ran: false },
560
- findings,
561
- }, null, 2));
562
- return exit(errors.length || (vitestResult && !vitestResult.ok) ? 1 : 0);
563
- }
564
- // ---------------------------------------------------------------------------
565
- // report
566
- //
567
- // Layout is deliberate: severity first, then where, then which rule, then what
568
- // happened, then what to do about it — with a hard rule between issues so a long
569
- // run stays scannable instead of turning into a wall of text.
570
- // ---------------------------------------------------------------------------
571
- /** Colour only when a human is watching: NO_COLOR and pipes both disable it. */
572
- const useColor = Boolean(process.stdout.isTTY) && !process.env.NO_COLOR && process.env.TERM !== 'dumb';
573
- const paint = (code) => (text) => (useColor ? `\u001B[${code}m${text}\u001B[0m` : text);
574
- const c = {
575
- red: paint('31'),
576
- redBold: paint('1;31'),
577
- yellow: paint('33'),
578
- yellowBold: paint('1;33'),
579
- green: paint('32'),
580
- cyan: paint('36'),
581
- bold: paint('1'),
582
- dim: paint('2'),
583
- };
584
- const WIDTH = 78;
585
- /** Wrap prose to WIDTH with a hanging indent, so long `why` text stays readable. */
586
- const wrap = (text, indent) => {
587
- const width = WIDTH - indent;
588
- const out = [];
589
- let line = '';
590
- for (const word of text.split(/\s+/)) {
591
- if (line && (line + ' ' + word).length > width) {
592
- out.push(line);
593
- line = word;
594
- }
595
- else {
596
- line = line ? `${line} ${word}` : word;
597
- }
598
- }
599
- if (line)
600
- out.push(line);
601
- return out;
602
- };
603
- const LABEL = {
604
- error: (t) => c.redBold(t),
605
- warn: (t) => c.yellowBold(t),
606
- off: (t) => c.dim(t),
607
- };
608
- const byRule = (list) => {
609
- const m = new Map();
610
- for (const f of list) {
611
- if (!m.has(f.ruleId))
612
- m.set(f.ruleId, []);
613
- m.get(f.ruleId).push(f);
614
- }
615
- return m;
616
- };
617
- const ruleById = new Map(rules.map(r => [r.id, r]));
618
- /**
619
- * IG0 / IG1 are NOT rules. They say nothing about the app — they are properties
620
- * of the escape hatch, so they are deliberately absent from `rules`, from both
621
- * rule counts, from `--rule=` and from the README's inventory. A reader browsing
622
- * what is enforced about the funnel should never meet them.
623
- *
624
- * But a finding still has to explain itself the moment it fires, and there is no
625
- * Rule object to carry the rationale — so it lives here, next to the reporter
626
- * that prints it. This is the whole of their documentation; keep it self-contained.
627
- */
628
- const metaRuleInfo = {
629
- IG0: {
630
- title: 'unexplained suppression',
631
- why: 'an escape hatch nobody audits is a backdoor — the run prints PASS while the invariant is not enforced, and nobody can later tell a reviewed exemption from someone making the red go away',
632
- fix: 'put the reason after the ID: // invariant-ignore: R1 — the safe wrapper is the one place bare storage is allowed. Or delete the comment and fix the code.',
633
- },
634
- IG1: {
635
- title: 'reference to a rule that does not exist',
636
- why: 'the entry is inert but reads like active protection — a deleted or renumbered ID silently voids every suppression and allowlist naming it',
637
- fix: 'drop the dead entry, or point it at a live rule ID — pnpm validate:invariants --list prints them',
638
- },
639
- IG2: {
640
- title: 'suppression of a rule that cannot be suppressed',
641
- why: 'a rule marked noIgnore has an AI author in scope; a plausible reason costs an AI nothing, so the escape hatch would become the default path — the comment suppresses nothing and the attempt is reported so a reviewer sees the intent',
642
- fix: 'fix the code. If the rule is wrong, change it in the rulebook together with its fixture — exceptions are made in the rulebook by a human, not in the file under check',
643
- },
644
- };
645
- /** How many findings of one rule to print in full before summarising the rest. */
646
- const PER_RULE_LIMIT = 6;
647
- /**
648
- * One issue per block: severity, where, which rule, what happened — and the
649
- * rationale once per rule rather than once per finding, because 27 copies of the
650
- * same `why` is the wall of text this layout exists to avoid.
651
- */
652
- const printFinding = (f, rule, index, total, withRationale) => {
653
- const severity = f.level === 'error' ? LABEL.error('ERROR') : LABEL.warn('WARN ');
654
- const counter = total > 1 ? c.dim(` ${index}/${total}`) : '';
655
- console.log(c.dim('─'.repeat(WIDTH)));
656
- console.log(`${severity} ${c.bold(f.ruleId)}${counter} ${rule?.title ?? 'unattributed finding'}`);
657
- console.log(` ${c.cyan(`${f.file}:${f.line}`)}`);
658
- console.log();
659
- for (const [i, l] of wrap(f.message, 9).entries())
660
- console.log(` ${i === 0 ? c.dim('what ') : ' '} ${l}`);
661
- if (rule && withRationale) {
662
- for (const [i, l] of wrap(rule.why, 9).entries())
663
- console.log(` ${i === 0 ? c.dim('why ') : ' '} ${c.dim(l)}`);
664
- for (const [i, l] of wrap(rule.fix, 9).entries())
665
- console.log(` ${i === 0 ? c.dim('fix ') : ' '} ${c.green(l)}`);
666
- }
667
- console.log();
668
- };
669
- const printGroup = (list) => {
670
- for (const [id, items] of byRule(list)) {
671
- const rule = ruleById.get(id) ?? metaRuleInfo[id];
672
- const shown = items.slice(0, PER_RULE_LIMIT);
673
- shown.forEach((f, i) => printFinding(f, rule, i + 1, items.length, i === 0));
674
- if (items.length > shown.length) {
675
- const rest = items.length - shown.length;
676
- console.log(c.dim('─'.repeat(WIDTH)));
677
- // `--rule=` only accepts real rule IDs, so do not offer it for IG0/IG1.
678
- console.log(ruleById.has(id)
679
- ? ` ${c.dim(`+ ${rest} more ${id} finding(s) — see them all with`)} ${c.bold(`pnpm validate:invariants --rule=${id}`)}`
680
- : ` ${c.dim(`+ ${rest} more ${id} finding(s)`)}`);
681
- console.log();
682
- }
683
- }
684
- };
685
- const engines = [
686
- `${activeRules.length} rule${activeRules.length === 1 ? '' : 's'}${opts.family ? ` (family ${opts.family})` : ''}`,
687
- opts.vitest ? `${specTitles().length} module-resolving specs` : null,
688
- ].filter(Boolean).join(c.dim(' + '));
689
- console.log();
690
- console.log(c.bold(' validate-invariants') + c.dim(` — ${engines} over ${candidates.length} file(s)${opts.staged ? ', staged only' : ''}`));
691
- // Printed on EVERY run, not behind a flag: a silent exclusion reads as coverage.
692
- {
693
- const funnelKeys = opts.stdin || opts.family ? [] : Object.keys(unmaintained.funnels);
694
- const fileCount = opts.staged ? stagedExcluded : opts.stdin ? 0 : unmaintainedCount;
695
- const parts = [
696
- fileCount > 0 ? (opts.staged ? `${fileCount} of your staged file(s)` : `${fileCount} file(s)`) : '',
697
- funnelKeys.length > 0 ? `funnel${funnelKeys.length > 1 ? 's' : ''} ${funnelKeys.join(', ')}` : '',
698
- ].filter(Boolean).join(' + ');
699
- if (parts)
700
- console.log(c.dim(` not checked: ${parts} — unmaintained in ${configFile}`));
701
- // Staged nothing BUT excluded files: the PASS below covers zero of this commit's work.
702
- if (opts.staged && stagedExcluded > 0 && candidates.length === 0) {
703
- console.log(c.dim(' every staged file is excluded — this run checked nothing'));
704
- }
705
- if (opts.staged && stagedGone > 0) {
706
- console.log(c.dim(` ${stagedGone} staged deletion(s) — a once rule that scopes a deleted path still ran`));
707
- }
708
- }
709
- console.log();
710
- if (errors.length)
711
- printGroup(errors);
712
- if (warns.length)
713
- printGroup(warns);
714
- if (vitestResult && !vitestResult.ok) {
715
- console.log(c.dim('─'.repeat(WIDTH)));
716
- console.log(`${LABEL.error('ERROR')} ${c.bold(specDir)} module-resolving specs failed`);
717
- console.log(` ${c.cyan(specDir)}`);
718
- console.log();
719
- console.log(vitestResult.output.split('\n').map(l => c.dim(` ${l}`)).join('\n'));
720
- console.log();
721
- }
722
- const vitestFailed = Boolean(vitestResult && !vitestResult.ok);
723
- console.log(c.dim('─'.repeat(WIDTH)));
724
- if (errors.length === 0 && warns.length === 0 && !vitestFailed) {
725
- console.log(`${c.green('PASS')} all invariants hold`);
726
- console.log();
727
- return exit(0);
728
- }
729
- const totals = [
730
- errors.length + (vitestFailed ? 1 : 0) > 0 ? c.red(`${errors.length + (vitestFailed ? 1 : 0)} error`) : null,
731
- warns.length > 0 ? c.yellow(`${warns.length} warning`) : null,
732
- ].filter(Boolean).join(c.dim(' · '));
733
- if (errors.length === 0 && !vitestFailed) {
734
- console.log(`${c.green('PASS')} ${totals}${c.dim(' — nothing blocking')}`);
735
- console.log();
736
- return exit(0);
737
- }
738
- console.log(`${c.red('FAIL')} ${totals}`);
739
- // Never advertise the escape hatch to an author whose every finding is noIgnore or a
740
- // meta rule — that is the move IG2 exists to forbid.
741
- const suppressible = errors.some(f => { const r = ruleById.get(f.ruleId); return r !== undefined && !r.noIgnore; });
742
- if (suppressible)
743
- console.log(c.dim(' suppress a reviewed line with // invariant-ignore: <RULE_ID> — <reason>'));
744
- console.log();
745
- return exit(1);
746
- };