@brighterly/lib-fe-codestyle 0.4.1 → 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/dist/invariants/ast.d.ts +82 -0
- package/dist/invariants/ast.js +270 -0
- package/dist/invariants/rules/regression.js +3 -0
- package/dist/invariants/run.d.ts +2 -5
- package/dist/invariants/run.js +237 -51
- package/dist/invariants/self-test.d.ts +11 -3
- package/dist/invariants/self-test.js +41 -14
- package/dist/invariants/types.d.ts +28 -4
- package/dist/invariants/util.d.ts +33 -8
- package/dist/invariants/util.js +175 -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,43 +320,77 @@ 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
|
|
@@ -244,11 +413,16 @@ export const run = async (options) => {
|
|
|
244
413
|
// `once` rules read their own inputs — run them a single time if any file
|
|
245
414
|
// they depend on is in scope, instead of once per matching file.
|
|
246
415
|
if (rule.once) {
|
|
247
|
-
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)))
|
|
248
422
|
continue;
|
|
249
423
|
let raw;
|
|
250
424
|
try {
|
|
251
|
-
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 })) || [];
|
|
252
426
|
}
|
|
253
427
|
catch (e) {
|
|
254
428
|
console.error(`validate-invariants: rule ${rule.id} crashed — ${e.message}`);
|
|
@@ -256,7 +430,7 @@ export const run = async (options) => {
|
|
|
256
430
|
}
|
|
257
431
|
for (const v of raw) {
|
|
258
432
|
const { suppressions } = ignoresFor(v.file);
|
|
259
|
-
if (suppressions.get(v.line)?.has(rule.id))
|
|
433
|
+
if (!rule.noIgnore && suppressions.get(v.line)?.has(rule.id))
|
|
260
434
|
continue;
|
|
261
435
|
const resolved = resolveFindingLevel(rule.level, override, v.level);
|
|
262
436
|
if (resolved === 'off')
|
|
@@ -281,7 +455,7 @@ export const run = async (options) => {
|
|
|
281
455
|
// Always await, never dispatch on rule.async: a Promise-returning check whose author
|
|
282
456
|
// forgot the flag was iterated AS a Promise — zero findings, exit 2, and nothing
|
|
283
457
|
// pointing at the cause. Awaiting a sync value costs nothing.
|
|
284
|
-
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 })) ?? [];
|
|
285
459
|
}
|
|
286
460
|
catch (e) {
|
|
287
461
|
console.error(`validate-invariants: rule ${rule.id} crashed on ${file} — ${e.message}`);
|
|
@@ -289,7 +463,7 @@ export const run = async (options) => {
|
|
|
289
463
|
}
|
|
290
464
|
const { suppressions } = ignoresFor(file);
|
|
291
465
|
for (const v of raw) {
|
|
292
|
-
if (suppressions.get(v.line)?.has(rule.id))
|
|
466
|
+
if (!rule.noIgnore && suppressions.get(v.line)?.has(rule.id))
|
|
293
467
|
continue;
|
|
294
468
|
const resolved = resolveFindingLevel(rule.level, override, v.level);
|
|
295
469
|
if (resolved === 'off')
|
|
@@ -304,6 +478,9 @@ export const run = async (options) => {
|
|
|
304
478
|
// to close. Honours `allowlists.IG0` so self-test fixture strings can opt out.
|
|
305
479
|
const ignoreScanRe = /\.(m?ts|vue|mjs|js)$/;
|
|
306
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 })) : []));
|
|
307
484
|
for (const file of candidates) {
|
|
308
485
|
if (!ignoreScanRe.test(file))
|
|
309
486
|
continue;
|
|
@@ -319,6 +496,15 @@ export const run = async (options) => {
|
|
|
319
496
|
});
|
|
320
497
|
}
|
|
321
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
|
+
}
|
|
322
508
|
findings.push(...ignoreProblems);
|
|
323
509
|
// IG1 — references to rule IDs that no longer exist. Three sinks dereference an
|
|
324
510
|
// ID (allowlists, levels, invariant-ignore) and none of them used to check it
|
|
@@ -331,21 +517,9 @@ export const run = async (options) => {
|
|
|
331
517
|
specs: specTitles().map(sp => sp.id),
|
|
332
518
|
linter: linterOwnedRules.map(r => r.id),
|
|
333
519
|
// The meta rules are addressable too, so they must count as live.
|
|
334
|
-
meta: ['IG0', 'IG1'],
|
|
520
|
+
meta: ['IG0', 'IG1', 'IG2'],
|
|
335
521
|
});
|
|
336
|
-
const ignores =
|
|
337
|
-
const ig1Allowed = allowlists.IG1 ?? {};
|
|
338
|
-
for (const file of candidates) {
|
|
339
|
-
if (!ignoreScanRe.test(file))
|
|
340
|
-
continue;
|
|
341
|
-
if (ig1Allowed[file])
|
|
342
|
-
continue;
|
|
343
|
-
const src = read(file);
|
|
344
|
-
if (src === null || !src.includes('invariant-ignore'))
|
|
345
|
-
continue;
|
|
346
|
-
for (const c of ignoresFor(file).comments)
|
|
347
|
-
ignores.push({ file, ...c });
|
|
348
|
-
}
|
|
522
|
+
const ignores = ignoreCommentsExcept(allowlists.IG1 ?? {});
|
|
349
523
|
for (const v of findDanglingRuleRefs({
|
|
350
524
|
live, retired, allowlists, levels, ignores,
|
|
351
525
|
configFile,
|
|
@@ -462,6 +636,11 @@ export const run = async (options) => {
|
|
|
462
636
|
why: 'the entry is inert but reads like active protection — a deleted or renumbered ID silently voids every suppression and allowlist naming it',
|
|
463
637
|
fix: 'drop the dead entry, or point it at a live rule ID — pnpm validate:invariants --list prints them',
|
|
464
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
|
+
},
|
|
465
644
|
};
|
|
466
645
|
/** How many findings of one rule to print in full before summarising the rest. */
|
|
467
646
|
const PER_RULE_LIMIT = 6;
|
|
@@ -504,25 +683,28 @@ export const run = async (options) => {
|
|
|
504
683
|
}
|
|
505
684
|
};
|
|
506
685
|
const engines = [
|
|
507
|
-
`${activeRules.length} rule${activeRules.length === 1 ? '' : 's'}`,
|
|
686
|
+
`${activeRules.length} rule${activeRules.length === 1 ? '' : 's'}${opts.family ? ` (family ${opts.family})` : ''}`,
|
|
508
687
|
opts.vitest ? `${specTitles().length} module-resolving specs` : null,
|
|
509
688
|
].filter(Boolean).join(c.dim(' + '));
|
|
510
689
|
console.log();
|
|
511
690
|
console.log(c.bold(' validate-invariants') + c.dim(` — ${engines} over ${candidates.length} file(s)${opts.staged ? ', staged only' : ''}`));
|
|
512
691
|
// Printed on EVERY run, not behind a flag: a silent exclusion reads as coverage.
|
|
513
692
|
{
|
|
514
|
-
const funnelKeys = Object.keys(unmaintained.funnels);
|
|
515
|
-
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;
|
|
516
695
|
const parts = [
|
|
517
696
|
fileCount > 0 ? (opts.staged ? `${fileCount} of your staged file(s)` : `${fileCount} file(s)`) : '',
|
|
518
697
|
funnelKeys.length > 0 ? `funnel${funnelKeys.length > 1 ? 's' : ''} ${funnelKeys.join(', ')}` : '',
|
|
519
698
|
].filter(Boolean).join(' + ');
|
|
520
699
|
if (parts)
|
|
521
|
-
console.log(c.dim(` not checked: ${parts} — unmaintained in
|
|
700
|
+
console.log(c.dim(` not checked: ${parts} — unmaintained in ${configFile}`));
|
|
522
701
|
// Staged nothing BUT excluded files: the PASS below covers zero of this commit's work.
|
|
523
702
|
if (opts.staged && stagedExcluded > 0 && candidates.length === 0) {
|
|
524
703
|
console.log(c.dim(' every staged file is excluded — this run checked nothing'));
|
|
525
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
|
+
}
|
|
526
708
|
}
|
|
527
709
|
console.log();
|
|
528
710
|
if (errors.length)
|
|
@@ -531,8 +713,8 @@ export const run = async (options) => {
|
|
|
531
713
|
printGroup(warns);
|
|
532
714
|
if (vitestResult && !vitestResult.ok) {
|
|
533
715
|
console.log(c.dim('─'.repeat(WIDTH)));
|
|
534
|
-
console.log(`${LABEL.error('ERROR')} ${c.bold(
|
|
535
|
-
console.log(` ${c.cyan(
|
|
716
|
+
console.log(`${LABEL.error('ERROR')} ${c.bold(specDir)} module-resolving specs failed`);
|
|
717
|
+
console.log(` ${c.cyan(specDir)}`);
|
|
536
718
|
console.log();
|
|
537
719
|
console.log(vitestResult.output.split('\n').map(l => c.dim(` ${l}`)).join('\n'));
|
|
538
720
|
console.log();
|
|
@@ -554,7 +736,11 @@ export const run = async (options) => {
|
|
|
554
736
|
return exit(0);
|
|
555
737
|
}
|
|
556
738
|
console.log(`${c.red('FAIL')} ${totals}`);
|
|
557
|
-
|
|
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>'));
|
|
558
744
|
console.log();
|
|
559
745
|
return exit(1);
|
|
560
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)
|
|
@@ -14,19 +14,21 @@ export type Level = 'error' | 'warn' | 'off';
|
|
|
14
14
|
* B — business: the funnel-data contract, confirmed by PMM
|
|
15
15
|
* C — convention: a preference we chose, advisory by default
|
|
16
16
|
* L — linter: covered by a built-in ESLint rule, configured elsewhere
|
|
17
|
+
* E — e2e: end-to-end test authoring standards
|
|
17
18
|
* IDs are load-bearing (`--rule=`, `invariant-ignore:`, allowlists, levels) —
|
|
18
19
|
* retire one, never recycle it.
|
|
19
20
|
*/
|
|
20
21
|
/** App-local ids are bare; shared-pack ids carry the `core/` prefix (`core/R1`). */
|
|
21
|
-
export type RuleId = `${'' | 'core/'}${'R' | 'B' | 'C' | 'L'}${number}`;
|
|
22
|
+
export type RuleId = `${'' | 'core/'}${'R' | 'B' | 'C' | 'L' | 'E'}${number}`;
|
|
22
23
|
/**
|
|
23
24
|
* Meta rules. They police the escape hatch rather than the app, so they have no
|
|
24
25
|
* family letter — but they are addressable exactly like any other rule
|
|
25
26
|
* (`allowlists`, `levels`, `invariant-ignore`), so they belong in the ID space.
|
|
26
27
|
* IG0 — an `invariant-ignore` with no reason
|
|
27
28
|
* IG1 — a reference to a rule ID that no longer exists
|
|
29
|
+
* IG2 — an `invariant-ignore` naming a rule declared `noIgnore`
|
|
28
30
|
*/
|
|
29
|
-
export type MetaRuleId = 'IG0' | 'IG1';
|
|
31
|
+
export type MetaRuleId = 'IG0' | 'IG1' | 'IG2';
|
|
30
32
|
/** Anything addressable in config or a suppression comment. */
|
|
31
33
|
export type AnyRuleId = RuleId | MetaRuleId;
|
|
32
34
|
/** One violation. `line` is 1-based. */
|
|
@@ -87,8 +89,14 @@ export interface RuleContext {
|
|
|
87
89
|
code: string;
|
|
88
90
|
/** Read any other repo-relative file (cached). Returns null if unreadable. */
|
|
89
91
|
read: (rel: string) => string | null;
|
|
90
|
-
/** Every scanned file in the repo — for rules that resolve references. */
|
|
92
|
+
/** Every scanned file in the repo minus the `unmaintained` paths — for rules that resolve references. */
|
|
91
93
|
allFiles: string[];
|
|
94
|
+
/**
|
|
95
|
+
* Every scanned file INCLUDING the `unmaintained` paths. Undefined unless the rule declares
|
|
96
|
+
* `seesUnmaintained` — the exclusion is meant to be invisible to a rule, and this is the one
|
|
97
|
+
* declared way through it.
|
|
98
|
+
*/
|
|
99
|
+
scanned?: string[];
|
|
92
100
|
/**
|
|
93
101
|
* App-specific paths a SHARED rule needs, supplied by `config.settings`. This is how a
|
|
94
102
|
* portable rule learns where this app keeps its SCSS tokens or its UI-kit root, without
|
|
@@ -97,7 +105,7 @@ export interface RuleContext {
|
|
|
97
105
|
settings: Readonly<SharedRuleSettings & Record<string, string>>;
|
|
98
106
|
}
|
|
99
107
|
/** A `once: true` rule reads its own inputs, so it gets no per-file fields. */
|
|
100
|
-
export type OnceRuleContext = Pick<RuleContext, 'read' | 'allFiles'> & Partial<RuleContext>;
|
|
108
|
+
export type OnceRuleContext = Pick<RuleContext, 'read' | 'allFiles' | 'scanned'> & Partial<RuleContext>;
|
|
101
109
|
interface BaseRule {
|
|
102
110
|
id: RuleId;
|
|
103
111
|
/** Short description — appears in the report and the README inventory. */
|
|
@@ -111,8 +119,24 @@ interface BaseRule {
|
|
|
111
119
|
scope: string[];
|
|
112
120
|
/** Glob patterns to skip within scope. */
|
|
113
121
|
exclude?: string[];
|
|
122
|
+
/**
|
|
123
|
+
* The rule cannot be suppressed with `invariant-ignore`; a comment naming it is
|
|
124
|
+
* itself a finding (IG2). For rules whose author is an AI: exceptions are made by
|
|
125
|
+
* a human in the rulebook (rule + fixture), never by the agent in the file under
|
|
126
|
+
* check — an AI writes a plausible reason for free.
|
|
127
|
+
* Honoured by the runner: the suppression lookup skips such rules, and the IG2
|
|
128
|
+
* block reports the attempt.
|
|
129
|
+
*/
|
|
130
|
+
noIgnore?: boolean;
|
|
114
131
|
/** Set when `check` returns a promise, so the runner awaits it. */
|
|
115
132
|
async?: boolean;
|
|
133
|
+
/**
|
|
134
|
+
* Hand this rule `scanned` — every file, the `unmaintained` paths included — where every other
|
|
135
|
+
* rule gets only `allFiles`. The exclusion exists so rules that model the component contract
|
|
136
|
+
* never meet a tree that does not follow it; a rule asking a different question may opt out,
|
|
137
|
+
* and says so here.
|
|
138
|
+
*/
|
|
139
|
+
seesUnmaintained?: boolean;
|
|
116
140
|
}
|
|
117
141
|
/** Runs once per matching file. */
|
|
118
142
|
export interface FileRule extends BaseRule {
|