@brighterly/lib-fe-codestyle 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. package/README.md +224 -0
  2. package/dist/invariants/ast.d.ts +72 -0
  3. package/dist/invariants/ast.js +195 -0
  4. package/dist/invariants/index.d.ts +7 -0
  5. package/dist/invariants/index.js +5 -0
  6. package/dist/invariants/rules/convention.d.ts +18 -0
  7. package/dist/invariants/rules/convention.js +226 -0
  8. package/dist/invariants/rules/fixtures.d.ts +17 -0
  9. package/dist/invariants/rules/fixtures.js +65 -0
  10. package/dist/invariants/rules/index.d.ts +55 -0
  11. package/dist/invariants/rules/index.js +53 -0
  12. package/dist/invariants/rules/linter.d.ts +10 -0
  13. package/dist/invariants/rules/linter.js +9 -0
  14. package/dist/invariants/rules/regression.d.ts +13 -0
  15. package/dist/invariants/rules/regression.js +109 -0
  16. package/dist/invariants/rules/shared.d.ts +18 -0
  17. package/dist/invariants/rules/shared.js +25 -0
  18. package/dist/invariants/run.d.ts +56 -0
  19. package/dist/invariants/run.js +553 -0
  20. package/dist/invariants/self-test.d.ts +38 -0
  21. package/dist/invariants/self-test.js +57 -0
  22. package/dist/invariants/types.d.ts +192 -0
  23. package/dist/invariants/types.js +9 -0
  24. package/dist/invariants/util.d.ts +113 -0
  25. package/dist/invariants/util.js +269 -0
  26. package/dist/validate/index.d.ts +36 -0
  27. package/dist/validate/index.js +41 -0
  28. package/eslint/index.mjs +187 -0
  29. package/eslint/rules/async-components-after-macros.mjs +63 -0
  30. package/eslint/rules/no-import-group-labels.mjs +72 -0
  31. package/eslint/rules/on-prefixed-callbacks.mjs +53 -0
  32. package/eslint/rules/optional-callback-chaining.mjs +78 -0
  33. package/eslint/rules/padding-after-macros.mjs +70 -0
  34. package/eslint/rules/ref-prefix-prop-mirrors.mjs +58 -0
  35. package/eslint/rules/require-component-name.mjs +68 -0
  36. package/package.json +64 -0
@@ -0,0 +1,56 @@
1
+ /**
2
+ * The invariants runner — `run(options)`.
3
+ *
4
+ * This is land-app's `invariants/cli.mts` with four hardcoded constants lifted into
5
+ * arguments (scan roots, rule sources, the spec dir/project, the README path) and the rule
6
+ * list, config and fixtures injected. Nothing else was redesigned: the argv surface, the
7
+ * self-test gate, the caches, the suppression grammar, severity resolution, the reporter
8
+ * and the exit codes are the originals.
9
+ *
10
+ * Exit codes:
11
+ * 0 — no error-level violations
12
+ * 1 — at least one error-level violation
13
+ * 2 — the validator itself failed
14
+ */
15
+ import type { Allowlists, Levels, LinterOwnedRule, RetiredRules, Rule, Unmaintained } from './types.js';
16
+ export interface RunOptions {
17
+ /** Repo root. Passed, never derived — import.meta.url here resolves into node_modules. */
18
+ root: string;
19
+ rules: Rule[];
20
+ linterOwnedRules: LinterOwnedRule[];
21
+ config: {
22
+ allowlists: Allowlists;
23
+ levels: Levels;
24
+ retired: RetiredRules;
25
+ unmaintained: Unmaintained;
26
+ /** App-specific paths shared rules read from RuleContext.settings. */
27
+ settings?: Readonly<Record<string, string>>;
28
+ };
29
+ /** Directories walked for the candidate file set. */
30
+ scanRoots: string[];
31
+ /** Path prefixes whose change triggers the self-test in --staged mode. */
32
+ ruleSources: string[];
33
+ /** Vitest project name for the module-resolving half. */
34
+ vitestProject: string;
35
+ /** Directory holding the vector-2 specs. */
36
+ specDir: string;
37
+ /** README whose generated inventory must not drift. */
38
+ readmePath: string;
39
+ /** Config file a dangling allowlist/levels key is reported against. */
40
+ configFile?: string;
41
+ /** Fixtures for --self-test, keyed by rule id. Omit to skip the self-test entirely. */
42
+ fixtures?: Record<string, {
43
+ file: string;
44
+ bad: string;
45
+ good: string;
46
+ }>;
47
+ /** Repo-specific self-test assertions; each returns failure messages. */
48
+ extraChecks?: (() => string[] | Promise<string[]>)[];
49
+ /** Rule ids exempt from the fixture requirement — registry rules a fixture cannot express. */
50
+ selfTestSkip?: string[];
51
+ /** Defaults to process.argv.slice(2). Injectable so tests need no argv games. */
52
+ argv?: string[];
53
+ /** Defaults to process.exit. Injectable so tests can assert the code. */
54
+ exit?: (code: number) => never;
55
+ }
56
+ export declare const run: (options: RunOptions) => Promise<void>;
@@ -0,0 +1,553 @@
1
+ /**
2
+ * The invariants runner — `run(options)`.
3
+ *
4
+ * This is land-app's `invariants/cli.mts` with four hardcoded constants lifted into
5
+ * arguments (scan roots, rule sources, the spec dir/project, the README path) and the rule
6
+ * list, config and fixtures injected. Nothing else was redesigned: the argv surface, the
7
+ * self-test gate, the caches, the suppression grammar, severity resolution, the reporter
8
+ * and the exit codes are the originals.
9
+ *
10
+ * Exit codes:
11
+ * 0 — no error-level violations
12
+ * 1 — at least one error-level violation
13
+ * 2 — the validator itself failed
14
+ */
15
+ import { execSync, spawnSync } from 'child_process';
16
+ import { existsSync } from 'fs';
17
+ import { resolve } from 'path';
18
+ import { walk, matchesAny, createReader, parseIgnores, stripCodeComments, findDanglingRuleRefs, resolveFindingLevel, knownRuleIds, levelOverrideOf, } from './util.js';
19
+ import { runSelfTest } from './self-test.js';
20
+ export const run = async (options) => {
21
+ const { root, rules, linterOwnedRules, scanRoots, ruleSources, vitestProject, specDir, readmePath, fixtures, extraChecks, selfTestSkip } = options;
22
+ const { allowlists, levels, retired, unmaintained } = options.config;
23
+ const settings = options.config.settings ?? {};
24
+ const configFile = options.configFile ?? 'invariants/config.mts';
25
+ const exit = options.exit ?? process.exit;
26
+ const read = createReader(root);
27
+ // Rule ids are allocated across the shared pack AND every consuming app. Nothing
28
+ // namespaces them, so two sources CAN pick the same number — and a duplicate id is
29
+ // silently destructive: allowlists, `levels` and `invariant-ignore` all dereference an
30
+ // id, so an exemption written for one rule would suppress the other. Fail loudly instead.
31
+ {
32
+ const seen = new Map();
33
+ for (const r of rules)
34
+ seen.set(r.id, (seen.get(r.id) ?? 0) + 1);
35
+ const dupes = [...seen.entries()].filter(([, n]) => n > 1).map(([id]) => id);
36
+ if (dupes.length) {
37
+ console.error(`validate-invariants: duplicate rule id(s) ${dupes.join(', ')} — an allowlist or invariant-ignore naming one would silently suppress the other. Renumber the app-local rule; \`--list\` prints every id in use.`);
38
+ return exit(2);
39
+ }
40
+ }
41
+ // ---------------------------------------------------------------------------
42
+ // args
43
+ // ---------------------------------------------------------------------------
44
+ const argv = options.argv ?? process.argv.slice(2);
45
+ const has = (flag) => argv.includes(flag);
46
+ const valueOf = (name) => {
47
+ const hit = argv.find(a => a.startsWith(`--${name}=`));
48
+ return hit ? hit.slice(name.length + 3) : null;
49
+ };
50
+ const opts = {
51
+ staged: has('--staged'),
52
+ json: has('--json'),
53
+ rule: valueOf('rule'),
54
+ 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'),
60
+ };
61
+ // ---------------------------------------------------------------------------
62
+ // --list — the rule inventory as markdown, generated from the rules themselves
63
+ // so invariants/README.md cannot drift from what actually runs
64
+ // ---------------------------------------------------------------------------
65
+ const specTitles = () => {
66
+ const out = [];
67
+ for (const file of walk(root, specDir)) {
68
+ if (!file.endsWith('.test.ts'))
69
+ continue;
70
+ const src = read(file) || '';
71
+ const suite = (src.match(/describe\('([^']+)'/) || [])[1] || file;
72
+ const id = (suite.match(/^([A-Z]\d+)/) || [])[1] || '—';
73
+ const assertions = [...src.matchAll(/\n\s{2}it\('([^']+)'/g)].flatMap(m => (m[1] ? [m[1]] : []));
74
+ out.push({ id, suite: suite.replace(/^[A-Z]\d+\s*—\s*/, ''), file, assertions });
75
+ }
76
+ return out.sort((a, b) => a.id.localeCompare(b.id, undefined, { numeric: true }));
77
+ };
78
+ const renderInventory = () => {
79
+ const byId = (a, b) => a.id.localeCompare(b.id, undefined, { numeric: true });
80
+ const lines = [];
81
+ lines.push('### Vector 1 — plain checks (regex, or a real AST where regex would lie)');
82
+ lines.push('');
83
+ lines.push('| Rule | Level | Checks | Where it looks |');
84
+ lines.push('|---|---|---|---|');
85
+ for (const r of [...rules].sort(byId)) {
86
+ const level = levelOverrideOf(levels, r.id) ?? r.level;
87
+ const scope = r.scope.map(sc => `\`${sc}\``).join(', ');
88
+ lines.push(`| **${r.id}** | ${level} | ${r.title} | ${scope} |`);
89
+ }
90
+ lines.push('');
91
+ lines.push('### Vector 2 — vitest specs (need the real modules, not their text)');
92
+ lines.push('');
93
+ lines.push('| Rule | Checks | Assertions |');
94
+ lines.push('|---|---|---|');
95
+ for (const s of specTitles()) {
96
+ lines.push(`| **${s.id}** | ${s.suite} | ${s.assertions.map(a => `• ${a}`).join('<br>')} |`);
97
+ }
98
+ lines.push('');
99
+ lines.push('### Vector 3 — built-in ESLint rules (configured, not implemented)');
100
+ lines.push('');
101
+ lines.push('| Rule | Level | ESLint rule | Checks |');
102
+ lines.push('|---|---|---|---|');
103
+ if (!linterOwnedRules.length) {
104
+ lines.push('| — | — | — | _none configured — see the `retired` registry in `config.mts` for why_ |');
105
+ }
106
+ for (const r of linterOwnedRules) {
107
+ lines.push(`| **${r.id}** | ${r.level} | \`${r.eslintRule}\` | ${r.title} |`);
108
+ }
109
+ lines.push('');
110
+ lines.push(`_${rules.length} rules + ${specTitles().length} specs + ${linterOwnedRules.length} linter rule(s). Regenerate this section with \`pnpm validate:invariants --list\`._`);
111
+ return lines.join('\n');
112
+ };
113
+ if (has('--list')) {
114
+ console.log(renderInventory());
115
+ return exit(0);
116
+ }
117
+ // ---------------------------------------------------------------------------
118
+ // self-test — proves each rule still fires on a known violation
119
+ // ---------------------------------------------------------------------------
120
+ // A rule that has silently stopped matching reports nothing — which is
121
+ // indistinguishable from a clean codebase. So the scan alone can print PASS while
122
+ // a rule is dead, and R12 proved the inverse too (a passing fixture while the rule
123
+ // was wrong about real code). Both halves are needed, so a FULL run does both.
124
+ //
125
+ // Skipped only where it would be noise rather than signal: --rule= is a dev loop,
126
+ // and --staged is the pre-commit path, where it runs only if the commit actually
127
+ // touches the validator's own sources.
128
+ const stagedTouchesRules = () => {
129
+ if (!opts.staged)
130
+ return false;
131
+ 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)));
134
+ }
135
+ catch {
136
+ return false;
137
+ }
138
+ };
139
+ // A full run — no --staged, no --rule= — verifies the rules before trusting them.
140
+ const isFullRun = !opts.staged && !opts.rule;
141
+ const explicitSelfTest = has('--self-test');
142
+ if (explicitSelfTest && !fixtures) {
143
+ console.error('validate-invariants: --self-test needs fixtures; pass `fixtures` to run()');
144
+ return exit(2);
145
+ }
146
+ if (fixtures && (explicitSelfTest || isFullRun || stagedTouchesRules())) {
147
+ const { failures, checked } = await runSelfTest({ rules, fixtures, extraChecks, skip: selfTestSkip });
148
+ // The README is the thing a developer or an agent reads to learn what is
149
+ // enforced. A stale inventory there is worse than none: it states coverage we
150
+ // do not have. Regenerate with `pnpm validate:invariants --list`.
151
+ const readme = read(readmePath);
152
+ if (readme === null) {
153
+ failures.push(`${readmePath} is missing — it is the entry point for this directory`);
154
+ }
155
+ else {
156
+ const between = readme.match(/<!-- BEGIN rule-inventory[^>]*-->\n([\s\S]*?)<!-- END rule-inventory -->/);
157
+ if (!between) {
158
+ failures.push(`${readmePath} has no <!-- BEGIN rule-inventory --> block`);
159
+ }
160
+ else if ((between[1] ?? '').trim() !== renderInventory().trim()) {
161
+ failures.push(`${readmePath} rule inventory is out of date — regenerate it with \`pnpm validate:invariants --list\` and paste it between the markers`);
162
+ }
163
+ }
164
+ console.log();
165
+ const label = explicitSelfTest ? 'validate-invariants --self-test' : 'rule self-test';
166
+ console.log(` ${label} — ${checked.length} rule(s) + plumbing`);
167
+ if (failures.length) {
168
+ for (const f of failures)
169
+ console.log(` ✗ ${f}`);
170
+ console.log(`\n✗ ${failures.length} self-test failure(s).`);
171
+ return exit(1);
172
+ }
173
+ console.log(' ✅ every rule fires on its violating fixture and stays quiet on the clean one.');
174
+ // Explicit --self-test is the whole job; the implicit run continues into the scan.
175
+ if (explicitSelfTest)
176
+ return exit(0);
177
+ }
178
+ // ---------------------------------------------------------------------------
179
+ // file set
180
+ // ---------------------------------------------------------------------------
181
+ const scanned = scanRoots.flatMap((d) => walk(root, d));
182
+ // Unmaintained areas come out of `allFiles`, not just `candidates`: rules resolve
183
+ // each other's files through allFiles (R10 walks it to find a :next-step target,
184
+ // B1 walks it for a booking wrap, query.mts walks it for Views). Filtering only
185
+ // the candidate set would keep an excluded file reachable as some other rule's
186
+ // answer — the exclusion has to be invisible everywhere or it is not one.
187
+ const UNMAINTAINED_PATHS = Object.keys(unmaintained.paths);
188
+ const allFiles = UNMAINTAINED_PATHS.length
189
+ ? scanned.filter((f) => !matchesAny(f, UNMAINTAINED_PATHS))
190
+ : scanned;
191
+ const unmaintainedCount = scanned.length - allFiles.length;
192
+ let candidates = allFiles;
193
+ // In --staged mode the exclusion count must describe THIS COMMIT, not the repo: a hook
194
+ // that prints "not checked: 35 file(s)" while the developer staged three of them reads
195
+ // as background noise. Staging only excluded files is the case that has to be loud.
196
+ let stagedExcluded = 0;
197
+ if (opts.staged) {
198
+ 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;
204
+ }
205
+ catch (e) {
206
+ console.error(`validate-invariants: could not read the staged file list — ${e.message}`);
207
+ return exit(2);
208
+ }
209
+ }
210
+ // ---------------------------------------------------------------------------
211
+ // run
212
+ // ---------------------------------------------------------------------------
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
+ const findings = []; // { ruleId, level, file, line, message }
219
+ const ignoreProblems = [];
220
+ // one comment-strip per file, shared by every rule
221
+ const strippedCache = new Map();
222
+ const strippedOf = (file, source) => {
223
+ if (!strippedCache.has(file))
224
+ strippedCache.set(file, stripCodeComments(source));
225
+ return strippedCache.get(file);
226
+ };
227
+ // one ignore-parse per file, shared by every rule
228
+ const ignoreCache = new Map();
229
+ const ignoresFor = (file) => {
230
+ if (!ignoreCache.has(file)) {
231
+ const src = read(file);
232
+ ignoreCache.set(file, src === null ? { suppressions: new Map(), unreasoned: [], comments: [] } : parseIgnores(src));
233
+ }
234
+ return ignoreCache.get(file);
235
+ };
236
+ for (const rule of activeRules) {
237
+ const override = levelOverrideOf(levels, rule.id);
238
+ const level = override ?? rule.level;
239
+ if (level === 'off')
240
+ continue;
241
+ const allowed = allowlists[rule.id] ?? {};
242
+ // `once` rules read their own inputs — run them a single time if any file
243
+ // they depend on is in scope, instead of once per matching file.
244
+ if (rule.once) {
245
+ if (!candidates.some(f => matchesAny(f, rule.scope)))
246
+ continue;
247
+ let raw;
248
+ try {
249
+ raw = (await rule.check({ file: rule.scope[0], source: '', code: '', read, allFiles, settings })) || [];
250
+ }
251
+ catch (e) {
252
+ console.error(`validate-invariants: rule ${rule.id} crashed — ${e.message}`);
253
+ return exit(2);
254
+ }
255
+ for (const v of raw) {
256
+ const { suppressions } = ignoresFor(v.file);
257
+ if (suppressions.get(v.line)?.has(rule.id))
258
+ continue;
259
+ const resolved = resolveFindingLevel(rule.level, override, v.level);
260
+ if (resolved === 'off')
261
+ continue;
262
+ findings.push({ ...v, ruleId: rule.id, level: resolved });
263
+ }
264
+ continue;
265
+ }
266
+ for (const file of candidates) {
267
+ if (!matchesAny(file, rule.scope))
268
+ continue;
269
+ if (rule.exclude && matchesAny(file, rule.exclude))
270
+ continue;
271
+ if (allowed[file])
272
+ continue;
273
+ const source = read(file);
274
+ if (source === null)
275
+ continue;
276
+ const code = strippedOf(file, source);
277
+ let raw;
278
+ try {
279
+ // Always await, never dispatch on rule.async: a Promise-returning check whose author
280
+ // forgot the flag was iterated AS a Promise — zero findings, exit 2, and nothing
281
+ // pointing at the cause. Awaiting a sync value costs nothing.
282
+ raw = (await rule.check({ file, source, code, read, allFiles, settings })) ?? [];
283
+ }
284
+ catch (e) {
285
+ console.error(`validate-invariants: rule ${rule.id} crashed on ${file} — ${e.message}`);
286
+ return exit(2);
287
+ }
288
+ const { suppressions } = ignoresFor(file);
289
+ for (const v of raw) {
290
+ if (suppressions.get(v.line)?.has(rule.id))
291
+ continue;
292
+ const resolved = resolveFindingLevel(rule.level, override, v.level);
293
+ if (resolved === 'off')
294
+ continue;
295
+ findings.push({ ...v, ruleId: rule.id, level: resolved });
296
+ }
297
+ }
298
+ }
299
+ // IG0 — unreasoned ignore comments are themselves a finding. Scans `.mts` too:
300
+ // the validator's own sources are the likeliest place to grow a suppression, and
301
+ // exempting the tool from its own reason requirement is the backdoor IG0 exists
302
+ // to close. Honours `allowlists.IG0` so self-test fixture strings can opt out.
303
+ const ignoreScanRe = /\.(m?ts|vue|mjs|js)$/;
304
+ const ig0Allowed = allowlists.IG0 ?? {};
305
+ for (const file of candidates) {
306
+ if (!ignoreScanRe.test(file))
307
+ continue;
308
+ if (ig0Allowed[file])
309
+ continue;
310
+ const src = read(file);
311
+ if (src === null || !src.includes('invariant-ignore'))
312
+ continue;
313
+ for (const u of ignoresFor(file).unreasoned) {
314
+ ignoreProblems.push({
315
+ ruleId: 'IG0', level: 'error', file, line: u.line,
316
+ message: `invariant-ignore: ${u.ids.join(', ')} has no reason — an unexplained exemption silently deletes the rule`,
317
+ });
318
+ }
319
+ }
320
+ findings.push(...ignoreProblems);
321
+ // IG1 — references to rule IDs that no longer exist. Three sinks dereference an
322
+ // ID (allowlists, levels, invariant-ignore) and none of them used to check it
323
+ // resolved, so renumbering a rule silently voided every suppression naming it.
324
+ // Runs on the FULL rule set, never the --rule= subset: a stale reference is a
325
+ // property of the config, not of whichever rule is being debugged.
326
+ {
327
+ const live = knownRuleIds({
328
+ fileScan: rules.map(r => r.id),
329
+ specs: specTitles().map(sp => sp.id),
330
+ linter: linterOwnedRules.map(r => r.id),
331
+ // The meta rules are addressable too, so they must count as live.
332
+ meta: ['IG0', 'IG1'],
333
+ });
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
+ }
347
+ for (const v of findDanglingRuleRefs({
348
+ live, retired, allowlists, levels, ignores,
349
+ configFile,
350
+ configSource: read(configFile) ?? undefined,
351
+ })) {
352
+ findings.push({ ruleId: 'IG1', level: 'error', ...v });
353
+ }
354
+ }
355
+ // ---------------------------------------------------------------------------
356
+ // module-resolving half — invariants/specs/*.test.ts
357
+ // ---------------------------------------------------------------------------
358
+ const runVitestInvariants = () => {
359
+ const bin = resolve(root, 'node_modules/.bin/vitest');
360
+ const args = ['run', '--project', vitestProject, specDir, '--reporter=dot'];
361
+ const r = existsSync(bin)
362
+ ? spawnSync(bin, args, { cwd: root, encoding: 'utf8' })
363
+ : spawnSync('npx', ['vitest', ...args], { cwd: root, encoding: 'utf8' });
364
+ return { ok: r.status === 0, output: `${r.stdout || ''}${r.stderr || ''}`.trim() };
365
+ };
366
+ const vitestResult = opts.vitest ? runVitestInvariants() : null;
367
+ // ---------------------------------------------------------------------------
368
+ // report
369
+ // ---------------------------------------------------------------------------
370
+ const errors = findings.filter(f => f.level === 'error');
371
+ const warns = findings.filter(f => f.level === 'warn');
372
+ if (opts.json) {
373
+ console.log(JSON.stringify({
374
+ ok: errors.length === 0 && (vitestResult ? vitestResult.ok : true),
375
+ scanned: candidates.length,
376
+ errors: errors.length,
377
+ warnings: warns.length,
378
+ vitest: vitestResult ? { ran: true, ok: vitestResult.ok } : { ran: false },
379
+ findings,
380
+ }, null, 2));
381
+ return exit(errors.length || (vitestResult && !vitestResult.ok) ? 1 : 0);
382
+ }
383
+ // ---------------------------------------------------------------------------
384
+ // report
385
+ //
386
+ // Layout is deliberate: severity first, then where, then which rule, then what
387
+ // happened, then what to do about it — with a hard rule between issues so a long
388
+ // run stays scannable instead of turning into a wall of text.
389
+ // ---------------------------------------------------------------------------
390
+ /** Colour only when a human is watching: NO_COLOR and pipes both disable it. */
391
+ const useColor = Boolean(process.stdout.isTTY) && !process.env.NO_COLOR && process.env.TERM !== 'dumb';
392
+ const paint = (code) => (text) => (useColor ? `\u001B[${code}m${text}\u001B[0m` : text);
393
+ const c = {
394
+ red: paint('31'),
395
+ redBold: paint('1;31'),
396
+ yellow: paint('33'),
397
+ yellowBold: paint('1;33'),
398
+ green: paint('32'),
399
+ cyan: paint('36'),
400
+ bold: paint('1'),
401
+ dim: paint('2'),
402
+ };
403
+ const WIDTH = 78;
404
+ /** Wrap prose to WIDTH with a hanging indent, so long `why` text stays readable. */
405
+ const wrap = (text, indent) => {
406
+ const width = WIDTH - indent;
407
+ const out = [];
408
+ let line = '';
409
+ for (const word of text.split(/\s+/)) {
410
+ if (line && (line + ' ' + word).length > width) {
411
+ out.push(line);
412
+ line = word;
413
+ }
414
+ else {
415
+ line = line ? `${line} ${word}` : word;
416
+ }
417
+ }
418
+ if (line)
419
+ out.push(line);
420
+ return out;
421
+ };
422
+ const LABEL = {
423
+ error: (t) => c.redBold(t),
424
+ warn: (t) => c.yellowBold(t),
425
+ off: (t) => c.dim(t),
426
+ };
427
+ const byRule = (list) => {
428
+ const m = new Map();
429
+ for (const f of list) {
430
+ if (!m.has(f.ruleId))
431
+ m.set(f.ruleId, []);
432
+ m.get(f.ruleId).push(f);
433
+ }
434
+ return m;
435
+ };
436
+ const ruleById = new Map(rules.map(r => [r.id, r]));
437
+ /**
438
+ * IG0 / IG1 are NOT rules. They say nothing about the app — they are properties
439
+ * of the escape hatch, so they are deliberately absent from `rules`, from both
440
+ * rule counts, from `--rule=` and from the README's inventory. A reader browsing
441
+ * what is enforced about the funnel should never meet them.
442
+ *
443
+ * But a finding still has to explain itself the moment it fires, and there is no
444
+ * Rule object to carry the rationale — so it lives here, next to the reporter
445
+ * that prints it. This is the whole of their documentation; keep it self-contained.
446
+ */
447
+ const metaRuleInfo = {
448
+ IG0: {
449
+ title: 'unexplained suppression',
450
+ why: 'an escape hatch nobody audits is a backdoor — the run prints PASS while the invariant is not enforced, and nobody can later tell a reviewed exemption from someone making the red go away',
451
+ fix: 'put the reason after the ID: // invariant-ignore: R1 — the safe wrapper is the one place bare storage is allowed. Or delete the comment and fix the code.',
452
+ },
453
+ IG1: {
454
+ title: 'reference to a rule that does not exist',
455
+ why: 'the entry is inert but reads like active protection — a deleted or renumbered ID silently voids every suppression and allowlist naming it',
456
+ fix: 'drop the dead entry, or point it at a live rule ID — pnpm validate:invariants --list prints them',
457
+ },
458
+ };
459
+ /** How many findings of one rule to print in full before summarising the rest. */
460
+ const PER_RULE_LIMIT = 6;
461
+ /**
462
+ * One issue per block: severity, where, which rule, what happened — and the
463
+ * rationale once per rule rather than once per finding, because 27 copies of the
464
+ * same `why` is the wall of text this layout exists to avoid.
465
+ */
466
+ const printFinding = (f, rule, index, total, withRationale) => {
467
+ const severity = f.level === 'error' ? LABEL.error('ERROR') : LABEL.warn('WARN ');
468
+ const counter = total > 1 ? c.dim(` ${index}/${total}`) : '';
469
+ console.log(c.dim('─'.repeat(WIDTH)));
470
+ console.log(`${severity} ${c.bold(f.ruleId)}${counter} ${rule?.title ?? 'unattributed finding'}`);
471
+ console.log(` ${c.cyan(`${f.file}:${f.line}`)}`);
472
+ console.log();
473
+ for (const [i, l] of wrap(f.message, 9).entries())
474
+ console.log(` ${i === 0 ? c.dim('what ') : ' '} ${l}`);
475
+ if (rule && withRationale) {
476
+ for (const [i, l] of wrap(rule.why, 9).entries())
477
+ console.log(` ${i === 0 ? c.dim('why ') : ' '} ${c.dim(l)}`);
478
+ for (const [i, l] of wrap(rule.fix, 9).entries())
479
+ console.log(` ${i === 0 ? c.dim('fix ') : ' '} ${c.green(l)}`);
480
+ }
481
+ console.log();
482
+ };
483
+ const printGroup = (list) => {
484
+ for (const [id, items] of byRule(list)) {
485
+ const rule = ruleById.get(id) ?? metaRuleInfo[id];
486
+ const shown = items.slice(0, PER_RULE_LIMIT);
487
+ shown.forEach((f, i) => printFinding(f, rule, i + 1, items.length, i === 0));
488
+ if (items.length > shown.length) {
489
+ const rest = items.length - shown.length;
490
+ console.log(c.dim('─'.repeat(WIDTH)));
491
+ // `--rule=` only accepts real rule IDs, so do not offer it for IG0/IG1.
492
+ console.log(ruleById.has(id)
493
+ ? ` ${c.dim(`+ ${rest} more ${id} finding(s) — see them all with`)} ${c.bold(`pnpm validate:invariants --rule=${id}`)}`
494
+ : ` ${c.dim(`+ ${rest} more ${id} finding(s)`)}`);
495
+ console.log();
496
+ }
497
+ }
498
+ };
499
+ const engines = [
500
+ `${activeRules.length} rule${activeRules.length === 1 ? '' : 's'}`,
501
+ opts.vitest ? `${specTitles().length} module-resolving specs` : null,
502
+ ].filter(Boolean).join(c.dim(' + '));
503
+ console.log();
504
+ console.log(c.bold(' validate-invariants') + c.dim(` — ${engines} over ${candidates.length} file(s)${opts.staged ? ', staged only' : ''}`));
505
+ // Printed on EVERY run, not behind a flag: a silent exclusion reads as coverage.
506
+ {
507
+ const funnelKeys = Object.keys(unmaintained.funnels);
508
+ const fileCount = opts.staged ? stagedExcluded : unmaintainedCount;
509
+ const parts = [
510
+ fileCount > 0 ? (opts.staged ? `${fileCount} of your staged file(s)` : `${fileCount} file(s)`) : '',
511
+ funnelKeys.length > 0 ? `funnel${funnelKeys.length > 1 ? 's' : ''} ${funnelKeys.join(', ')}` : '',
512
+ ].filter(Boolean).join(' + ');
513
+ if (parts)
514
+ console.log(c.dim(` not checked: ${parts} — unmaintained in invariants/config.mts`));
515
+ // Staged nothing BUT excluded files: the PASS below covers zero of this commit's work.
516
+ if (opts.staged && stagedExcluded > 0 && candidates.length === 0) {
517
+ console.log(c.dim(' every staged file is excluded — this run checked nothing'));
518
+ }
519
+ }
520
+ console.log();
521
+ if (errors.length)
522
+ printGroup(errors);
523
+ if (warns.length)
524
+ printGroup(warns);
525
+ if (vitestResult && !vitestResult.ok) {
526
+ console.log(c.dim('─'.repeat(WIDTH)));
527
+ console.log(`${LABEL.error('ERROR')} ${c.bold('R3/R9/R19')} module-resolving specs failed`);
528
+ console.log(` ${c.cyan('invariants/specs')}`);
529
+ console.log();
530
+ console.log(vitestResult.output.split('\n').map(l => c.dim(` ${l}`)).join('\n'));
531
+ console.log();
532
+ }
533
+ const vitestFailed = Boolean(vitestResult && !vitestResult.ok);
534
+ console.log(c.dim('─'.repeat(WIDTH)));
535
+ if (errors.length === 0 && warns.length === 0 && !vitestFailed) {
536
+ console.log(`${c.green('PASS')} all invariants hold`);
537
+ console.log();
538
+ return exit(0);
539
+ }
540
+ const totals = [
541
+ errors.length + (vitestFailed ? 1 : 0) > 0 ? c.red(`${errors.length + (vitestFailed ? 1 : 0)} error`) : null,
542
+ warns.length > 0 ? c.yellow(`${warns.length} warning`) : null,
543
+ ].filter(Boolean).join(c.dim(' · '));
544
+ if (errors.length === 0 && !vitestFailed) {
545
+ console.log(`${c.green('PASS')} ${totals}${c.dim(' — nothing blocking')}`);
546
+ console.log();
547
+ return exit(0);
548
+ }
549
+ console.log(`${c.red('FAIL')} ${totals}`);
550
+ console.log(c.dim(' suppress a reviewed line with // invariant-ignore: <RULE_ID> — <reason>'));
551
+ console.log();
552
+ return exit(1);
553
+ };
@@ -0,0 +1,38 @@
1
+ /**
2
+ * The generic half of the self-test: prove every rule still fires on a violating fixture
3
+ * and stays quiet on a clean one.
4
+ *
5
+ * This exists because a rule that has silently stopped matching finds nothing — which is
6
+ * indistinguishable from a clean codebase. The scan alone can print PASS while a rule is
7
+ * dead.
8
+ *
9
+ * Only the harness lives here. The FIXTURES map and any repo-specific assertions stay with
10
+ * the app that owns them and arrive through `fixtures` and `extraChecks` — the original
11
+ * imported the app's funnel queries directly, which is exactly the coupling the package
12
+ * boundary removes.
13
+ */
14
+ import type { Rule } from './types.js';
15
+ export interface Fixture {
16
+ file: string;
17
+ bad: string;
18
+ good: string;
19
+ }
20
+ export interface SelfTestInput {
21
+ rules: Rule[];
22
+ fixtures: Record<string, Fixture>;
23
+ /**
24
+ * Rule ids exempt from the fixture requirement — registry and graph rules that read real
25
+ * repo files rather than a synthetic source, so a fixture cannot express them.
26
+ */
27
+ skip?: string[];
28
+ /** Repo-specific assertions; each returns failure messages. May be async — the
29
+ * domain cases they express usually need to await a rule's own check(). */
30
+ extraChecks?: (() => string[] | Promise<string[]>)[];
31
+ }
32
+ export interface SelfTestResult {
33
+ ok: boolean;
34
+ failures: string[];
35
+ /** Rule ids that were actually exercised against a fixture. */
36
+ checked: string[];
37
+ }
38
+ export declare const runSelfTest: (input: SelfTestInput) => Promise<SelfTestResult>;