@brighterly/lib-fe-codestyle 0.3.0 → 0.5.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.
- package/README.md +13 -6
- package/dist/invariants/ast.d.ts +82 -0
- package/dist/invariants/ast.js +270 -0
- package/dist/invariants/rules/index.d.ts +2 -2
- package/dist/invariants/rules/index.js +2 -2
- package/dist/invariants/rules/regression.js +10 -2
- package/dist/invariants/run.d.ts +2 -5
- package/dist/invariants/run.js +241 -52
- package/dist/invariants/self-test.d.ts +11 -3
- package/dist/invariants/self-test.js +41 -14
- package/dist/invariants/types.d.ts +32 -4
- package/dist/invariants/util.d.ts +33 -8
- package/dist/invariants/util.js +190 -11
- package/package.json +1 -1
package/dist/invariants/run.js
CHANGED
|
@@ -13,9 +13,9 @@
|
|
|
13
13
|
* 2 — the validator itself failed
|
|
14
14
|
*/
|
|
15
15
|
import { execSync, spawnSync } from 'child_process';
|
|
16
|
-
import { existsSync } from 'fs';
|
|
16
|
+
import { existsSync, readFileSync } from 'fs';
|
|
17
17
|
import { resolve } from 'path';
|
|
18
|
-
import { walk, matchesAny, createReader, parseIgnores, stripCodeComments, findDanglingRuleRefs, resolveFindingLevel, knownRuleIds, levelOverrideOf, } from './util.js';
|
|
18
|
+
import { walk, matchesAny, createReader, parseIgnores, stripCodeComments, findDanglingRuleRefs, findForbiddenSuppressions, resolveFindingLevel, knownRuleIds, levelOverrideOf, toRelative, } from './util.js';
|
|
19
19
|
import { runSelfTest } from './self-test.js';
|
|
20
20
|
export const run = async (options) => {
|
|
21
21
|
const { root, rules, linterOwnedRules, scanRoots, ruleSources, vitestProject, specDir, readmePath, fixtures, extraChecks, selfTestSkip } = options;
|
|
@@ -23,7 +23,7 @@ export const run = async (options) => {
|
|
|
23
23
|
const settings = options.config.settings ?? {};
|
|
24
24
|
const configFile = options.configFile ?? 'invariants/config.mts';
|
|
25
25
|
const exit = options.exit ?? process.exit;
|
|
26
|
-
const
|
|
26
|
+
const baseRead = createReader(root);
|
|
27
27
|
// Rule ids are allocated across the shared pack AND every consuming app. Nothing
|
|
28
28
|
// namespaces them, so two sources CAN pick the same number — and a duplicate id is
|
|
29
29
|
// silently destructive: allowlists, `levels` and `invariant-ignore` all dereference an
|
|
@@ -51,13 +51,63 @@ export const run = async (options) => {
|
|
|
51
51
|
staged: has('--staged'),
|
|
52
52
|
json: has('--json'),
|
|
53
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'),
|
|
54
58
|
selfTest: has('--self-test'),
|
|
55
|
-
//
|
|
56
|
-
//
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
vitest
|
|
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'),
|
|
60
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));
|
|
61
111
|
// ---------------------------------------------------------------------------
|
|
62
112
|
// --list — the rule inventory as markdown, generated from the rules themselves
|
|
63
113
|
// so invariants/README.md cannot drift from what actually runs
|
|
@@ -95,6 +145,10 @@ export const run = async (options) => {
|
|
|
95
145
|
for (const s of specTitles()) {
|
|
96
146
|
lines.push(`| **${s.id}** | ${s.suite} | ${s.assertions.map(a => `• ${a}`).join('<br>')} |`);
|
|
97
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
|
+
}
|
|
98
152
|
lines.push('');
|
|
99
153
|
lines.push('### Vector 3 — built-in ESLint rules (configured, not implemented)');
|
|
100
154
|
lines.push('');
|
|
@@ -115,6 +169,47 @@ export const run = async (options) => {
|
|
|
115
169
|
return exit(0);
|
|
116
170
|
}
|
|
117
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, '<');
|
|
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
|
+
// ---------------------------------------------------------------------------
|
|
118
213
|
// self-test — proves each rule still fires on a known violation
|
|
119
214
|
// ---------------------------------------------------------------------------
|
|
120
215
|
// A rule that has silently stopped matching reports nothing — which is
|
|
@@ -125,19 +220,59 @@ export const run = async (options) => {
|
|
|
125
220
|
// Skipped only where it would be noise rather than signal: --rule= is a dev loop,
|
|
126
221
|
// and --staged is the pre-commit path, where it runs only if the commit actually
|
|
127
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
|
+
})();
|
|
128
256
|
const stagedTouchesRules = () => {
|
|
129
257
|
if (!opts.staged)
|
|
130
258
|
return false;
|
|
131
259
|
try {
|
|
132
|
-
return
|
|
133
|
-
.split('\n').some(f => ruleSources.some(p => f.trim().startsWith(p)));
|
|
260
|
+
return stagedChanges().present.some(f => ruleSources.some(p => f.startsWith(p)));
|
|
134
261
|
}
|
|
135
262
|
catch {
|
|
136
263
|
return false;
|
|
137
264
|
}
|
|
138
265
|
};
|
|
139
|
-
|
|
140
|
-
|
|
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;
|
|
141
276
|
const explicitSelfTest = has('--self-test');
|
|
142
277
|
if (explicitSelfTest && !fixtures) {
|
|
143
278
|
console.error('validate-invariants: --self-test needs fixtures; pass `fixtures` to run()');
|
|
@@ -185,50 +320,86 @@ export const run = async (options) => {
|
|
|
185
320
|
// the candidate set would keep an excluded file reachable as some other rule's
|
|
186
321
|
// answer — the exclusion has to be invisible everywhere or it is not one.
|
|
187
322
|
const UNMAINTAINED_PATHS = Object.keys(unmaintained.paths);
|
|
188
|
-
|
|
323
|
+
let allFiles = UNMAINTAINED_PATHS.length
|
|
189
324
|
? scanned.filter((f) => !matchesAny(f, UNMAINTAINED_PATHS))
|
|
190
325
|
: scanned;
|
|
191
|
-
|
|
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
|
+
}
|
|
192
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
|
+
}
|
|
193
345
|
// In --staged mode the exclusion count must describe THIS COMMIT, not the repo: a hook
|
|
194
346
|
// that prints "not checked: 35 file(s)" while the developer staged three of them reads
|
|
195
347
|
// as background noise. Staging only excluded files is the case that has to be loud.
|
|
196
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;
|
|
197
352
|
if (opts.staged) {
|
|
198
353
|
try {
|
|
199
|
-
const
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
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;
|
|
204
359
|
}
|
|
205
360
|
catch (e) {
|
|
206
361
|
console.error(`validate-invariants: could not read the staged file list — ${e.message}`);
|
|
207
362
|
return exit(2);
|
|
208
363
|
}
|
|
209
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
|
+
}
|
|
210
384
|
// ---------------------------------------------------------------------------
|
|
211
385
|
// run
|
|
212
386
|
// ---------------------------------------------------------------------------
|
|
213
|
-
const activeRules = opts.rule ? rules.filter(r => r.id === opts.rule) : rules;
|
|
214
|
-
if (opts.rule && activeRules.length === 0) {
|
|
215
|
-
console.error(`validate-invariants: unknown rule "${opts.rule}". Known: ${rules.map(r => r.id).join(', ')}`);
|
|
216
|
-
return exit(2);
|
|
217
|
-
}
|
|
218
387
|
const findings = []; // { ruleId, level, file, line, message }
|
|
219
388
|
const ignoreProblems = [];
|
|
220
389
|
// one comment-strip per file, shared by every rule
|
|
221
390
|
const strippedCache = new Map();
|
|
222
391
|
const strippedOf = (file, source) => {
|
|
223
392
|
if (!strippedCache.has(file))
|
|
224
|
-
strippedCache.set(file, stripCodeComments(source));
|
|
393
|
+
strippedCache.set(file, stripCodeComments(source, file));
|
|
225
394
|
return strippedCache.get(file);
|
|
226
395
|
};
|
|
227
396
|
// one ignore-parse per file, shared by every rule
|
|
228
397
|
const ignoreCache = new Map();
|
|
229
398
|
const ignoresFor = (file) => {
|
|
230
399
|
if (!ignoreCache.has(file)) {
|
|
231
|
-
|
|
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);
|
|
232
403
|
ignoreCache.set(file, src === null ? { suppressions: new Map(), unreasoned: [], comments: [] } : parseIgnores(src));
|
|
233
404
|
}
|
|
234
405
|
return ignoreCache.get(file);
|
|
@@ -242,11 +413,16 @@ export const run = async (options) => {
|
|
|
242
413
|
// `once` rules read their own inputs — run them a single time if any file
|
|
243
414
|
// they depend on is in scope, instead of once per matching file.
|
|
244
415
|
if (rule.once) {
|
|
245
|
-
if (
|
|
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)))
|
|
246
422
|
continue;
|
|
247
423
|
let raw;
|
|
248
424
|
try {
|
|
249
|
-
raw = (await rule.check({ file: rule.scope[0], source: '', code: '', read, allFiles, settings })) || [];
|
|
425
|
+
raw = (await rule.check({ file: rule.scope[0], source: '', code: '', read, allFiles, scanned: rule.seesUnmaintained ? scanned : undefined, settings })) || [];
|
|
250
426
|
}
|
|
251
427
|
catch (e) {
|
|
252
428
|
console.error(`validate-invariants: rule ${rule.id} crashed — ${e.message}`);
|
|
@@ -254,7 +430,7 @@ export const run = async (options) => {
|
|
|
254
430
|
}
|
|
255
431
|
for (const v of raw) {
|
|
256
432
|
const { suppressions } = ignoresFor(v.file);
|
|
257
|
-
if (suppressions.get(v.line)?.has(rule.id))
|
|
433
|
+
if (!rule.noIgnore && suppressions.get(v.line)?.has(rule.id))
|
|
258
434
|
continue;
|
|
259
435
|
const resolved = resolveFindingLevel(rule.level, override, v.level);
|
|
260
436
|
if (resolved === 'off')
|
|
@@ -279,7 +455,7 @@ export const run = async (options) => {
|
|
|
279
455
|
// Always await, never dispatch on rule.async: a Promise-returning check whose author
|
|
280
456
|
// forgot the flag was iterated AS a Promise — zero findings, exit 2, and nothing
|
|
281
457
|
// pointing at the cause. Awaiting a sync value costs nothing.
|
|
282
|
-
raw = (await rule.check({ file, source, code, read, allFiles, settings })) ?? [];
|
|
458
|
+
raw = (await rule.check({ file, source, code, read, allFiles, scanned: rule.seesUnmaintained ? scanned : undefined, settings })) ?? [];
|
|
283
459
|
}
|
|
284
460
|
catch (e) {
|
|
285
461
|
console.error(`validate-invariants: rule ${rule.id} crashed on ${file} — ${e.message}`);
|
|
@@ -287,7 +463,7 @@ export const run = async (options) => {
|
|
|
287
463
|
}
|
|
288
464
|
const { suppressions } = ignoresFor(file);
|
|
289
465
|
for (const v of raw) {
|
|
290
|
-
if (suppressions.get(v.line)?.has(rule.id))
|
|
466
|
+
if (!rule.noIgnore && suppressions.get(v.line)?.has(rule.id))
|
|
291
467
|
continue;
|
|
292
468
|
const resolved = resolveFindingLevel(rule.level, override, v.level);
|
|
293
469
|
if (resolved === 'off')
|
|
@@ -302,6 +478,9 @@ export const run = async (options) => {
|
|
|
302
478
|
// to close. Honours `allowlists.IG0` so self-test fixture strings can opt out.
|
|
303
479
|
const ignoreScanRe = /\.(m?ts|vue|mjs|js)$/;
|
|
304
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 })) : []));
|
|
305
484
|
for (const file of candidates) {
|
|
306
485
|
if (!ignoreScanRe.test(file))
|
|
307
486
|
continue;
|
|
@@ -317,6 +496,15 @@ export const run = async (options) => {
|
|
|
317
496
|
});
|
|
318
497
|
}
|
|
319
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
|
+
}
|
|
320
508
|
findings.push(...ignoreProblems);
|
|
321
509
|
// IG1 — references to rule IDs that no longer exist. Three sinks dereference an
|
|
322
510
|
// ID (allowlists, levels, invariant-ignore) and none of them used to check it
|
|
@@ -329,25 +517,14 @@ export const run = async (options) => {
|
|
|
329
517
|
specs: specTitles().map(sp => sp.id),
|
|
330
518
|
linter: linterOwnedRules.map(r => r.id),
|
|
331
519
|
// The meta rules are addressable too, so they must count as live.
|
|
332
|
-
meta: ['IG0', 'IG1'],
|
|
520
|
+
meta: ['IG0', 'IG1', 'IG2'],
|
|
333
521
|
});
|
|
334
|
-
const ignores =
|
|
335
|
-
const ig1Allowed = allowlists.IG1 ?? {};
|
|
336
|
-
for (const file of candidates) {
|
|
337
|
-
if (!ignoreScanRe.test(file))
|
|
338
|
-
continue;
|
|
339
|
-
if (ig1Allowed[file])
|
|
340
|
-
continue;
|
|
341
|
-
const src = read(file);
|
|
342
|
-
if (src === null || !src.includes('invariant-ignore'))
|
|
343
|
-
continue;
|
|
344
|
-
for (const c of ignoresFor(file).comments)
|
|
345
|
-
ignores.push({ file, ...c });
|
|
346
|
-
}
|
|
522
|
+
const ignores = ignoreCommentsExcept(allowlists.IG1 ?? {});
|
|
347
523
|
for (const v of findDanglingRuleRefs({
|
|
348
524
|
live, retired, allowlists, levels, ignores,
|
|
349
525
|
configFile,
|
|
350
526
|
configSource: read(configFile) ?? undefined,
|
|
527
|
+
pathExists: p => read(p) !== null,
|
|
351
528
|
})) {
|
|
352
529
|
findings.push({ ruleId: 'IG1', level: 'error', ...v });
|
|
353
530
|
}
|
|
@@ -459,6 +636,11 @@ export const run = async (options) => {
|
|
|
459
636
|
why: 'the entry is inert but reads like active protection — a deleted or renumbered ID silently voids every suppression and allowlist naming it',
|
|
460
637
|
fix: 'drop the dead entry, or point it at a live rule ID — pnpm validate:invariants --list prints them',
|
|
461
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
|
+
},
|
|
462
644
|
};
|
|
463
645
|
/** How many findings of one rule to print in full before summarising the rest. */
|
|
464
646
|
const PER_RULE_LIMIT = 6;
|
|
@@ -501,25 +683,28 @@ export const run = async (options) => {
|
|
|
501
683
|
}
|
|
502
684
|
};
|
|
503
685
|
const engines = [
|
|
504
|
-
`${activeRules.length} rule${activeRules.length === 1 ? '' : 's'}`,
|
|
686
|
+
`${activeRules.length} rule${activeRules.length === 1 ? '' : 's'}${opts.family ? ` (family ${opts.family})` : ''}`,
|
|
505
687
|
opts.vitest ? `${specTitles().length} module-resolving specs` : null,
|
|
506
688
|
].filter(Boolean).join(c.dim(' + '));
|
|
507
689
|
console.log();
|
|
508
690
|
console.log(c.bold(' validate-invariants') + c.dim(` — ${engines} over ${candidates.length} file(s)${opts.staged ? ', staged only' : ''}`));
|
|
509
691
|
// Printed on EVERY run, not behind a flag: a silent exclusion reads as coverage.
|
|
510
692
|
{
|
|
511
|
-
const funnelKeys = Object.keys(unmaintained.funnels);
|
|
512
|
-
const fileCount = opts.staged ? stagedExcluded : unmaintainedCount;
|
|
693
|
+
const funnelKeys = opts.stdin || opts.family ? [] : Object.keys(unmaintained.funnels);
|
|
694
|
+
const fileCount = opts.staged ? stagedExcluded : opts.stdin ? 0 : unmaintainedCount;
|
|
513
695
|
const parts = [
|
|
514
696
|
fileCount > 0 ? (opts.staged ? `${fileCount} of your staged file(s)` : `${fileCount} file(s)`) : '',
|
|
515
697
|
funnelKeys.length > 0 ? `funnel${funnelKeys.length > 1 ? 's' : ''} ${funnelKeys.join(', ')}` : '',
|
|
516
698
|
].filter(Boolean).join(' + ');
|
|
517
699
|
if (parts)
|
|
518
|
-
console.log(c.dim(` not checked: ${parts} — unmaintained in
|
|
700
|
+
console.log(c.dim(` not checked: ${parts} — unmaintained in ${configFile}`));
|
|
519
701
|
// Staged nothing BUT excluded files: the PASS below covers zero of this commit's work.
|
|
520
702
|
if (opts.staged && stagedExcluded > 0 && candidates.length === 0) {
|
|
521
703
|
console.log(c.dim(' every staged file is excluded — this run checked nothing'));
|
|
522
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
|
+
}
|
|
523
708
|
}
|
|
524
709
|
console.log();
|
|
525
710
|
if (errors.length)
|
|
@@ -528,8 +713,8 @@ export const run = async (options) => {
|
|
|
528
713
|
printGroup(warns);
|
|
529
714
|
if (vitestResult && !vitestResult.ok) {
|
|
530
715
|
console.log(c.dim('─'.repeat(WIDTH)));
|
|
531
|
-
console.log(`${LABEL.error('ERROR')} ${c.bold(
|
|
532
|
-
console.log(` ${c.cyan(
|
|
716
|
+
console.log(`${LABEL.error('ERROR')} ${c.bold(specDir)} module-resolving specs failed`);
|
|
717
|
+
console.log(` ${c.cyan(specDir)}`);
|
|
533
718
|
console.log();
|
|
534
719
|
console.log(vitestResult.output.split('\n').map(l => c.dim(` ${l}`)).join('\n'));
|
|
535
720
|
console.log();
|
|
@@ -551,7 +736,11 @@ export const run = async (options) => {
|
|
|
551
736
|
return exit(0);
|
|
552
737
|
}
|
|
553
738
|
console.log(`${c.red('FAIL')} ${totals}`);
|
|
554
|
-
|
|
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>'));
|
|
555
744
|
console.log();
|
|
556
745
|
return exit(1);
|
|
557
746
|
};
|
|
@@ -12,11 +12,19 @@
|
|
|
12
12
|
* boundary removes.
|
|
13
13
|
*/
|
|
14
14
|
import type { Rule } from './types.js';
|
|
15
|
-
|
|
15
|
+
/**
|
|
16
|
+
* One fixture case. `bad` must produce >=1 finding, `good` must produce none.
|
|
17
|
+
* `excluded: true` asserts the FILE is matched by the rule's own `exclude` list —
|
|
18
|
+
* the case never runs the check; it proves the exclusion glob is right.
|
|
19
|
+
*/
|
|
20
|
+
export interface FixtureCase {
|
|
16
21
|
file: string;
|
|
17
|
-
bad
|
|
18
|
-
good
|
|
22
|
+
bad?: string;
|
|
23
|
+
good?: string;
|
|
24
|
+
excluded?: true;
|
|
19
25
|
}
|
|
26
|
+
/** A rule may declare one case or an array (multi-check rules need one `bad` per check). */
|
|
27
|
+
export type Fixture = FixtureCase | FixtureCase[];
|
|
20
28
|
export interface SelfTestInput {
|
|
21
29
|
rules: Rule[];
|
|
22
30
|
fixtures: Record<string, Fixture>;
|
|
@@ -17,7 +17,7 @@ const runRule = async (rule, file, source, extra = {}) => {
|
|
|
17
17
|
const ctx = {
|
|
18
18
|
file,
|
|
19
19
|
source,
|
|
20
|
-
code: stripCodeComments(source),
|
|
20
|
+
code: stripCodeComments(source, file),
|
|
21
21
|
read: () => null,
|
|
22
22
|
allFiles: [],
|
|
23
23
|
settings: {},
|
|
@@ -32,23 +32,50 @@ export const runSelfTest = async (input) => {
|
|
|
32
32
|
for (const rule of rules) {
|
|
33
33
|
if (skip.includes(rule.id))
|
|
34
34
|
continue;
|
|
35
|
-
const
|
|
36
|
-
if (!
|
|
35
|
+
const declared = fixtures[rule.id];
|
|
36
|
+
if (!declared) {
|
|
37
37
|
failures.push(`${rule.id}: no self-test fixture — every rule needs one`);
|
|
38
38
|
continue;
|
|
39
39
|
}
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
40
|
+
const cases = Array.isArray(declared) ? declared : [declared];
|
|
41
|
+
// Declared, not fired: a dead snippet reports once, on its own line. `misplaced`
|
|
42
|
+
// mutes the two floors — an out-of-scope path already has its own message and
|
|
43
|
+
// the floors would only tell the author to add cases they already declared.
|
|
44
|
+
let declaredBad = false;
|
|
45
|
+
let declaredGood = false;
|
|
46
|
+
let misplaced = false;
|
|
47
|
+
for (const fx of cases) {
|
|
48
|
+
if (fx.excluded) {
|
|
49
|
+
if (!matchesAny(fx.file, rule.scope))
|
|
50
|
+
failures.push(`${rule.id}: ${fx.file} is declared excluded but is not in the rule's scope (${rule.scope.join(', ')}) — the exclusion is doing no work`);
|
|
51
|
+
if (!rule.exclude || !matchesAny(fx.file, rule.exclude))
|
|
52
|
+
failures.push(`${rule.id}: ${fx.file} is declared excluded but the rule's exclude list (${(rule.exclude ?? []).join(', ') || 'none'}) does not match it`);
|
|
53
|
+
if (fx.bad !== undefined)
|
|
54
|
+
failures.push(`${rule.id}: ${fx.file} is excluded — the runner never checks it, so a 'bad' snippet proves nothing; drop it`);
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
if (!matchesAny(fx.file, rule.scope)) {
|
|
58
|
+
failures.push(`${rule.id}: fixture path ${fx.file} is outside the rule's own scope (${rule.scope.join(', ')})`);
|
|
59
|
+
misplaced = true;
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
62
|
+
if (fx.bad !== undefined) {
|
|
63
|
+
declaredBad = true;
|
|
64
|
+
const bad = await runRule(rule, fx.file, fx.bad);
|
|
65
|
+
if (bad.length === 0)
|
|
66
|
+
failures.push(`${rule.id}: violating fixture at ${fx.file} produced no finding — the check is a no-op:\n${fx.bad}`);
|
|
67
|
+
}
|
|
68
|
+
if (fx.good !== undefined) {
|
|
69
|
+
declaredGood = true;
|
|
70
|
+
const good = await runRule(rule, fx.file, fx.good);
|
|
71
|
+
if (good.length > 0)
|
|
72
|
+
failures.push(`${rule.id}: clean fixture at ${fx.file} produced ${good.length} false positive(s): ${good.map(g => g.message).join('; ')}`);
|
|
73
|
+
}
|
|
51
74
|
}
|
|
75
|
+
if (!misplaced && !declaredGood)
|
|
76
|
+
failures.push(`${rule.id}: no case proves the rule stays quiet — at least one 'good' snippet is required`);
|
|
77
|
+
if (!misplaced && !declaredBad)
|
|
78
|
+
failures.push(`${rule.id}: no case declares a 'bad' snippet — an 'excluded' case never runs the check, and a 'good'-only fixture cannot prove the rule still matches`);
|
|
52
79
|
checked.push(rule.id);
|
|
53
80
|
}
|
|
54
81
|
for (const check of extraChecks)
|