@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.
@@ -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 read = createReader(root);
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
- // The vitest half (R3, R9, R19) resolves real modules, so it costs a vitest
56
- // boot. A full run includes it; --staged and --rule= skip it, because the
57
- // pre-commit hook runs `pnpm test` right after and a single-rule run is a
58
- // dev loop, not a gate.
59
- vitest: !has('--staged') && !has('--no-vitest') && !valueOf('rule'),
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, '&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
+ // ---------------------------------------------------------------------------
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 execSync('git diff --staged --name-only --diff-filter=ACMR', { cwd: root, encoding: 'utf8' })
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
- // A full run — no --staged, no --rule= — verifies the rules before trusting them.
140
- const isFullRun = !opts.staged && !opts.rule;
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
- const allFiles = UNMAINTAINED_PATHS.length
323
+ let allFiles = UNMAINTAINED_PATHS.length
189
324
  ? scanned.filter((f) => !matchesAny(f, UNMAINTAINED_PATHS))
190
325
  : scanned;
191
- const unmaintainedCount = scanned.length - allFiles.length;
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 staged = execSync('git diff --staged --name-only --diff-filter=ACMR', { cwd: root, encoding: 'utf8' })
200
- .split('\n').map(s => s.trim()).filter((s) => Boolean(s))
201
- .filter(f => existsSync(resolve(root, f)));
202
- candidates = staged.filter(f => !matchesAny(f, UNMAINTAINED_PATHS));
203
- stagedExcluded = staged.length - candidates.length;
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
- const src = read(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);
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 (!candidates.some(f => matchesAny(f, rule.scope)))
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 invariants/config.mts`));
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('R3/R9/R19')} module-resolving specs failed`);
532
- console.log(` ${c.cyan('invariants/specs')}`);
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
- console.log(c.dim(' suppress a reviewed line with // invariant-ignore: <RULE_ID> — <reason>'));
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
- export interface Fixture {
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: string;
18
- good: string;
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 fx = fixtures[rule.id];
36
- if (!fx) {
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
- if (!matchesAny(fx.file, rule.scope)) {
41
- failures.push(`${rule.id}: fixture path ${fx.file} is outside the rule's own scope (${rule.scope.join(', ')})`);
42
- continue;
43
- }
44
- const bad = await runRule(rule, fx.file, fx.bad);
45
- const good = await runRule(rule, fx.file, fx.good);
46
- if (bad.length === 0) {
47
- failures.push(`${rule.id}: violating fixture produced no finding — the rule is a no-op`);
48
- }
49
- if (good.length > 0) {
50
- failures.push(`${rule.id}: clean fixture produced ${good.length} false positive(s): ${good.map(g => g.message).join('; ')}`);
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)