@brighterly/lib-fe-codestyle 0.4.1 → 0.5.2

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,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
- 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
@@ -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 (!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)))
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 invariants/config.mts`));
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('R3/R9/R19')} module-resolving specs failed`);
535
- 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)}`);
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
- 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>'));
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
- 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)
@@ -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 {