@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,57 @@
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 { matchesAny, stripCodeComments } from './util.js';
15
+ /** Run one rule against a synthetic single-file context. */
16
+ const runRule = async (rule, file, source, extra = {}) => {
17
+ const ctx = {
18
+ file,
19
+ source,
20
+ code: stripCodeComments(source),
21
+ read: () => null,
22
+ allFiles: [],
23
+ settings: {},
24
+ ...extra,
25
+ };
26
+ return (await rule.check(ctx)) ?? [];
27
+ };
28
+ export const runSelfTest = async (input) => {
29
+ const { rules, fixtures, skip = [], extraChecks = [] } = input;
30
+ const failures = [];
31
+ const checked = [];
32
+ for (const rule of rules) {
33
+ if (skip.includes(rule.id))
34
+ continue;
35
+ const fx = fixtures[rule.id];
36
+ if (!fx) {
37
+ failures.push(`${rule.id}: no self-test fixture — every rule needs one`);
38
+ continue;
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('; ')}`);
51
+ }
52
+ checked.push(rule.id);
53
+ }
54
+ for (const check of extraChecks)
55
+ failures.push(...(await check()));
56
+ return { ok: failures.length === 0, failures, checked };
57
+ };
@@ -0,0 +1,192 @@
1
+ /**
2
+ * The contracts every rule and every config entry must satisfy.
3
+ *
4
+ * These exist so that extending this directory fails at typecheck instead of
5
+ * failing silently: a rule with a typo'd field, a manifest entry naming a
6
+ * mechanism that has no resolver, a level that is not a real level. Those were
7
+ * all silently ignored while this was plain JavaScript.
8
+ */
9
+ /** `error` counts toward the exit code · `warn` reports · `off` skips entirely. */
10
+ export type Level = 'error' | 'warn' | 'off';
11
+ /**
12
+ * Rule ID: the family letter says where the rule came from.
13
+ * R — regression: a CLAUDE.md "What NOT to do" scar
14
+ * B — business: the funnel-data contract, confirmed by PMM
15
+ * C — convention: a preference we chose, advisory by default
16
+ * L — linter: covered by a built-in ESLint rule, configured elsewhere
17
+ * IDs are load-bearing (`--rule=`, `invariant-ignore:`, allowlists, levels) —
18
+ * retire one, never recycle it.
19
+ */
20
+ /** App-local ids are bare; shared-pack ids carry the underscore prefix (`_R1`). */
21
+ export type RuleId = `${'' | '_'}${'R' | 'B' | 'C' | 'L'}${number}`;
22
+ /**
23
+ * Meta rules. They police the escape hatch rather than the app, so they have no
24
+ * family letter — but they are addressable exactly like any other rule
25
+ * (`allowlists`, `levels`, `invariant-ignore`), so they belong in the ID space.
26
+ * IG0 — an `invariant-ignore` with no reason
27
+ * IG1 — a reference to a rule ID that no longer exists
28
+ */
29
+ export type MetaRuleId = 'IG0' | 'IG1';
30
+ /** Anything addressable in config or a suppression comment. */
31
+ export type AnyRuleId = RuleId | MetaRuleId;
32
+ /** One violation. `line` is 1-based. */
33
+ export interface Finding {
34
+ file: string;
35
+ line: number;
36
+ message: string;
37
+ /**
38
+ * Severity for THIS finding, when it differs from the rest of the rule's.
39
+ * Omit to inherit the rule's. A `levels` override in config.mts is a ceiling
40
+ * over this — see resolveFindingLevel in util.mts.
41
+ */
42
+ level?: Level;
43
+ }
44
+ /** A finding after the runner has attributed it to a rule and a level. */
45
+ export interface ReportedFinding extends Finding {
46
+ ruleId: AnyRuleId;
47
+ level: Level;
48
+ }
49
+ /**
50
+ * Every settings key a shared rule reads, documented for hover. Annotate the app's
51
+ * `settings` const with this to get completion and the per-key contract in the IDE:
52
+ *
53
+ * export const settings: SharedRuleSettings & Record<string, string> = { ... }
54
+ *
55
+ * All keys are optional: a missing one degrades the rule's MESSAGE (or falls back to
56
+ * the documented default), never its firing.
57
+ */
58
+ export interface SharedRuleSettings {
59
+ /** `_R1`/`_R3` fix messages: how this app names its safe storage wrapper, e.g. 'safeLocalStorage from @helpers/safe-local-storage'. */
60
+ safeStorageModule?: string;
61
+ /** `_R3` fix message: the guarded parse helper, e.g. 'parseStoredJson() from @helpers/safe-local-storage'. */
62
+ safeJsonParse?: string;
63
+ /** `_C3`: the palette file that legitimately DEFINES colour literals, e.g. 'app/assets/scss/_colors.scss'. */
64
+ scssTokensFile?: string;
65
+ /** `_C2`: root of the UI-kit tree whose component names must not be duplicated. Default 'app/components/UI'. */
66
+ uiKitRoot?: string;
67
+ /** `_C4` fix message: the shared date-format enum, e.g. 'DateFormat from @ts-types/date/enums'. */
68
+ dateFormatModule?: string;
69
+ /** `_C5`: where entity type folders live. Default 'app/types'. */
70
+ typesRoot?: string;
71
+ /** `_C6` fix message: this app's typography source — a mixin in some apps ('the font() mixin from @scss/_mixins.scss'), a component in others ('the AppText component'). */
72
+ typographySource?: string;
73
+ /** `_C7`: which `<script>` attribute order is canonical in this app. Default 'setup-first' (`<script setup lang="ts">`). */
74
+ scriptAttrOrder?: 'setup-first' | 'lang-first';
75
+ }
76
+ /** What a rule's `check` receives. */
77
+ export interface RuleContext {
78
+ /** Repo-relative path of the file under inspection. */
79
+ file: string;
80
+ /** Raw file contents. */
81
+ source: string;
82
+ /** Same contents with comment bodies blanked, line numbers preserved. */
83
+ code: string;
84
+ /** Read any other repo-relative file (cached). Returns null if unreadable. */
85
+ read: (rel: string) => string | null;
86
+ /** Every scanned file in the repo — for rules that resolve references. */
87
+ allFiles: string[];
88
+ /**
89
+ * App-specific paths a SHARED rule needs, supplied by `config.settings`. This is how a
90
+ * portable rule learns where this app keeps its SCSS tokens or its UI-kit root, without
91
+ * the rule becoming a factory. The known keys are documented on `SharedRuleSettings`.
92
+ */
93
+ settings: Readonly<SharedRuleSettings & Record<string, string>>;
94
+ }
95
+ /** A `once: true` rule reads its own inputs, so it gets no per-file fields. */
96
+ export type OnceRuleContext = Pick<RuleContext, 'read' | 'allFiles'> & Partial<RuleContext>;
97
+ interface BaseRule {
98
+ id: RuleId;
99
+ /** Short description — appears in the report and the README inventory. */
100
+ title: string;
101
+ level: Level;
102
+ /** The silent-failure mode. A rule without a why gets deleted by future-you. */
103
+ why: string;
104
+ /** What to do instead. */
105
+ fix: string;
106
+ /** Glob patterns this rule applies to. */
107
+ scope: string[];
108
+ /** Glob patterns to skip within scope. */
109
+ exclude?: string[];
110
+ /** Set when `check` returns a promise, so the runner awaits it. */
111
+ async?: boolean;
112
+ }
113
+ /** Runs once per matching file. */
114
+ export interface FileRule extends BaseRule {
115
+ once?: false;
116
+ check: (ctx: RuleContext) => Finding[] | Promise<Finding[]>;
117
+ }
118
+ /**
119
+ * Runs once per invocation if any file in `scope` is in the candidate set, for
120
+ * rules that read several files themselves (registry and graph checks).
121
+ */
122
+ export interface OnceRule extends BaseRule {
123
+ once: true;
124
+ check: (ctx: OnceRuleContext) => Finding[] | Promise<Finding[]>;
125
+ }
126
+ /**
127
+ * The two shapes are separate so a per-file rule cannot accidentally be handed a
128
+ * context without `source`, and a `once` rule cannot claim to receive one.
129
+ */
130
+ export type Rule = FileRule | OnceRule;
131
+ /** Per-rule, per-file exemptions. The value is the reason — required. */
132
+ export type Allowlists = Partial<Record<AnyRuleId, Record<string, string>>>;
133
+ /**
134
+ * Severity overrides, keyed by rule ID. The reason is MANDATORY: once four repos share a
135
+ * rule pack, a bare `levels.X = 'off'` is how a shared rule gets silenced invisibly.
136
+ * Read it through `levelOverrideOf` rather than indexing directly.
137
+ */
138
+ export type Levels = Partial<Record<AnyRuleId, {
139
+ level: Level;
140
+ reason: string;
141
+ }>>;
142
+ /**
143
+ * Retired rule IDs and why. A numeric gap records only that *something* existed;
144
+ * this records what, so a stale reference gets a useful message instead of
145
+ * "unknown rule". Never delete an entry — that is the point of it.
146
+ */
147
+ export type RetiredRules = Record<string, string>;
148
+ /** Everything rule IG0/IG1 needs, passed in so the check stays pure and testable. */
149
+ export interface DanglingRefInput {
150
+ /** IDs of rules currently loaded. */
151
+ live: Set<string>;
152
+ retired: RetiredRules;
153
+ allowlists: Allowlists;
154
+ levels: Levels;
155
+ /** Every `invariant-ignore` comment found in the scanned set. */
156
+ ignores: {
157
+ file: string;
158
+ line: number;
159
+ ids: string[];
160
+ }[];
161
+ /** Where allowlists/levels live, for the finding location. */
162
+ configFile?: string;
163
+ /** Contents of `configFile`, so a stale key can be reported on its own line. */
164
+ configSource?: string;
165
+ }
166
+ /** An invariant a built-in ESLint rule already covers. */
167
+ export interface LinterOwnedRule {
168
+ id: RuleId;
169
+ eslintRule: string;
170
+ level: Level;
171
+ title: string;
172
+ why: string;
173
+ }
174
+ /**
175
+ * Areas the validator deliberately does not look at.
176
+ *
177
+ * Not an allowlist: an allowlist exempts one FILE from one RULE, and the file is
178
+ * still part of the funnel model. This removes a whole subtree from the scanned
179
+ * set and a whole funnel from `query.funnels()`, so no rule — present or future —
180
+ * can see it. That is the right shape for a design system in flux, and the wrong
181
+ * shape for anything we intend to keep correct.
182
+ *
183
+ * Every entry carries a reason, and the CLI prints the totals on every run: an
184
+ * exclusion nobody can see is how a validator quietly stops covering the repo.
185
+ */
186
+ export interface Unmaintained {
187
+ /** Glob patterns dropped from the scanned file set → reason. */
188
+ paths: Record<string, string>;
189
+ /** WRAP_STEPS keys hidden from `query.funnels()` → reason. */
190
+ funnels: Record<string, string>;
191
+ }
192
+ export {};
@@ -0,0 +1,9 @@
1
+ /**
2
+ * The contracts every rule and every config entry must satisfy.
3
+ *
4
+ * These exist so that extending this directory fails at typecheck instead of
5
+ * failing silently: a rule with a typo'd field, a manifest entry naming a
6
+ * mechanism that has no resolver, a level that is not a real level. Those were
7
+ * all silently ignored while this was plain JavaScript.
8
+ */
9
+ export {};
@@ -0,0 +1,113 @@
1
+ /**
2
+ * Shared plumbing for validate-invariants: file walking, glob matching,
3
+ * a read cache, and the `invariant-ignore` escape hatch.
4
+ *
5
+ * No dependencies — this runs in pre-commit, so it must not need an install.
6
+ */
7
+ import type { DanglingRefInput, Finding, Level, Levels } from './types.js';
8
+ /**
9
+ * Minimal glob → RegExp. Supports `**`, `*`, `?` and `{a,b}`.
10
+ * Deliberately not a full glob implementation: rule scopes are simple by
11
+ * convention, and a dependency here would break the pre-commit hook.
12
+ */
13
+ export declare const globToRegExp: (glob: string) => RegExp;
14
+ export declare const matchesAny: (path: string, globs: string[]) => boolean;
15
+ /** Recursively list repo-relative file paths under `dir`, POSIX separators. */
16
+ export declare const walk: (root: string, dir: string) => string[];
17
+ export declare const createReader: (root: string) => ((rel: string) => string | null);
18
+ export declare const toRelative: (root: string, p: string) => string;
19
+ /**
20
+ * Parse every `invariant-ignore:` comment in a file.
21
+ * Returns { suppressions: Map<lineNumber, Set<ruleId>>, unreasoned: [{ line, ids }] }
22
+ *
23
+ * A comment suppresses violations on its own line and the line below, so both
24
+ * `// invariant-ignore: R1 — reason` above the code and a trailing comment work.
25
+ * The reason is mandatory: an ignore without one is itself reported (rule IG0),
26
+ * otherwise the escape hatch silently becomes a way to delete rules.
27
+ */
28
+ export declare const parseIgnores: (source: string) => {
29
+ suppressions: Map<number, Set<string>>;
30
+ unreasoned: {
31
+ line: number;
32
+ ids: string[];
33
+ }[];
34
+ comments: {
35
+ line: number;
36
+ ids: string[];
37
+ }[];
38
+ };
39
+ /**
40
+ * Blank out comment bodies while preserving every line number and offset, so a
41
+ * rule that greps for an identifier does not fire on prose. Handles `//`,
42
+ * block comments and HTML comments.
43
+ *
44
+ * Known limitation: a `//` inside a string literal (a URL) blanks the rest of
45
+ * that line. Harmless for identifier scans; do not use this for parsing.
46
+ */
47
+ export declare const stripCodeComments: (source: string) => string;
48
+ /** Line number (1-based) of a character offset. */
49
+ export declare const lineOf: (source: string, index: number) => number;
50
+ /**
51
+ * Return the source of the object literal that starts at the first `{` after
52
+ * `fromIndex`, balanced on braces. Used by project-level rules that read a
53
+ * config map out of a real file instead of a fixture.
54
+ */
55
+ export declare const sliceObjectLiteral: (source: string, fromIndex: number) => string;
56
+ /** Keys of a `'string-literal': value` object literal. */
57
+ export declare const stringKeysOf: (literal: string) => Set<string>;
58
+ /**
59
+ * Every reference to a rule ID that no longer names a live rule.
60
+ *
61
+ * Three sinks dereference an ID and none of them used to validate it: an
62
+ * allowlist key, a `levels` key, and an `invariant-ignore` comment. A stale
63
+ * entry in any of them is inert and silent — which is what made "retire an ID,
64
+ * never recycle it" a discipline rather than a check. This is the check.
65
+ *
66
+ * Pure by design: the caller supplies the live set and the config, so the
67
+ * self-test can exercise it without touching the repo.
68
+ */
69
+ export declare const findDanglingRuleRefs: (input: DanglingRefInput) => Finding[];
70
+ /**
71
+ * Severity of one finding, from three inputs that used to be one.
72
+ *
73
+ * ruleLevel — the rule's own default
74
+ * override — `levels[id]` in config.mts, set by whoever runs the gate
75
+ * finding — `Finding.level`, set by the rule for THIS finding
76
+ *
77
+ * Rules:
78
+ * 1. A finding with no level of its own takes the rule's effective level, and
79
+ * an override may raise or lower that — the pre-existing behaviour, which
80
+ * is how a `warn` rule gets promoted to blocking in one config line.
81
+ * 2. A finding may set its own level, so one rule can bundle a blocking check
82
+ * with an advisory one. R12 is why: an explicit <template> before the
83
+ * forwarding loop is a silent Vue override bug, while a missing :data-qa is
84
+ * a Playwright-selector gap with a 27-file backlog. One shared severity
85
+ * forced the dangerous check down to the backlog's level.
86
+ * 3. **An override is a CEILING over rule 2.** It can only lower a finding's
87
+ * self-declared level, never raise it, so an operator silencing a noisy rule
88
+ * cannot be defeated by a finding that promotes itself.
89
+ */
90
+ export declare const resolveFindingLevel: (ruleLevel: Level, override: Level | undefined, finding: Level | undefined) => Level;
91
+ /**
92
+ * Every rule ID that currently exists, across all three vectors.
93
+ *
94
+ * IG1 needs this, and getting it from `rules` alone is wrong: R3/R9/R19 are
95
+ * implemented as vitest specs and L1 is owned by ESLint, so a file-scan-only
96
+ * live set reports a perfectly valid `invariant-ignore: R3` as dangling. The
97
+ * bug is invisible in a unit test that supplies its own live set — it only
98
+ * shows up against the real repo.
99
+ */
100
+ export declare const knownRuleIds: (parts: {
101
+ fileScan: string[];
102
+ specs: string[];
103
+ linter: string[];
104
+ meta?: string[];
105
+ }) => Set<string>;
106
+ /**
107
+ * The severity override for a rule, or undefined.
108
+ *
109
+ * The only place that knows `Levels` holds `{ level, reason }` rather than a bare level.
110
+ * The reason is mandatory in the type so a shared rule cannot be silenced invisibly; this
111
+ * accessor keeps every caller from having to care.
112
+ */
113
+ export declare const levelOverrideOf: (levels: Levels, id: string) => Level | undefined;
@@ -0,0 +1,269 @@
1
+ /**
2
+ * Shared plumbing for validate-invariants: file walking, glob matching,
3
+ * a read cache, and the `invariant-ignore` escape hatch.
4
+ *
5
+ * No dependencies — this runs in pre-commit, so it must not need an install.
6
+ */
7
+ import { readFileSync, readdirSync } from 'node:fs';
8
+ import { resolve, relative, sep } from 'node:path';
9
+ const SKIP_DIRS = new Set(['node_modules', '.git', '.nuxt', '.output', 'dist', 'coverage', 'test-results']);
10
+ /**
11
+ * Minimal glob → RegExp. Supports `**`, `*`, `?` and `{a,b}`.
12
+ * Deliberately not a full glob implementation: rule scopes are simple by
13
+ * convention, and a dependency here would break the pre-commit hook.
14
+ */
15
+ export const globToRegExp = (glob) => {
16
+ let out = '^';
17
+ for (let i = 0; i < glob.length; i++) {
18
+ const c = glob[i];
19
+ if (c === '*') {
20
+ if (glob[i + 1] === '*') {
21
+ // `**/` swallows the slash so `a/**/*.ts` also matches `a/x.ts`
22
+ if (glob[i + 2] === '/') {
23
+ out += '(?:.*/)?';
24
+ i += 2;
25
+ }
26
+ else {
27
+ out += '.*';
28
+ i += 1;
29
+ }
30
+ }
31
+ else {
32
+ out += '[^/]*';
33
+ }
34
+ }
35
+ else if (c === '?') {
36
+ out += '[^/]';
37
+ }
38
+ else if (c === '{') {
39
+ const end = glob.indexOf('}', i);
40
+ // Without this, `i = end` sets i to -1, the loop increments it back to 0 and
41
+ // re-parses the glob forever — the validator would hang in the pre-commit hook
42
+ // instead of naming the bad scope.
43
+ if (end === -1)
44
+ throw new Error(`invalid glob (unterminated '{'): ${glob}`);
45
+ const alts = glob.slice(i + 1, end).split(',').map(a => a.replace(/[.+^$()|[\]\\]/g, '\\$&'));
46
+ out += `(?:${alts.join('|')})`;
47
+ i = end;
48
+ }
49
+ else if ('.+^$()|[]\\'.includes(c)) {
50
+ out += `\\${c}`;
51
+ }
52
+ else {
53
+ out += c;
54
+ }
55
+ }
56
+ return new RegExp(`${out}$`);
57
+ };
58
+ export const matchesAny = (path, globs) => globs.some(g => globToRegExp(g).test(path));
59
+ /** Recursively list repo-relative file paths under `dir`, POSIX separators. */
60
+ export const walk = (root, dir) => {
61
+ const out = [];
62
+ const abs = resolve(root, dir);
63
+ let entries;
64
+ try {
65
+ entries = readdirSync(abs, { withFileTypes: true });
66
+ }
67
+ catch {
68
+ return out;
69
+ }
70
+ for (const e of entries) {
71
+ if (e.name.startsWith('.') && e.name !== '.github')
72
+ continue;
73
+ if (SKIP_DIRS.has(e.name))
74
+ continue;
75
+ const child = `${dir}/${e.name}`;
76
+ if (e.isDirectory())
77
+ out.push(...walk(root, child));
78
+ else if (e.isFile())
79
+ out.push(child);
80
+ }
81
+ return out;
82
+ };
83
+ export const createReader = (root) => {
84
+ const cache = new Map();
85
+ return (rel) => {
86
+ if (!cache.has(rel)) {
87
+ try {
88
+ cache.set(rel, readFileSync(resolve(root, rel), 'utf8'));
89
+ }
90
+ catch {
91
+ cache.set(rel, null);
92
+ }
93
+ }
94
+ return cache.get(rel) ?? null;
95
+ };
96
+ };
97
+ export const toRelative = (root, p) => relative(root, resolve(root, p)).split(sep).join('/');
98
+ // `_?` — shared-pack rules carry an underscore prefix (`_R1`); app-local rules are bare (`R1`).
99
+ const IGNORE_RE = /invariant-ignore:\s*(_?[A-Z]{1,3}\d{1,3}(?:\s*,\s*_?[A-Z]{1,3}\d{1,3})*)\s*(?:—|–|--|-|:)?\s*(.*)$/;
100
+ /**
101
+ * Parse every `invariant-ignore:` comment in a file.
102
+ * Returns { suppressions: Map<lineNumber, Set<ruleId>>, unreasoned: [{ line, ids }] }
103
+ *
104
+ * A comment suppresses violations on its own line and the line below, so both
105
+ * `// invariant-ignore: R1 — reason` above the code and a trailing comment work.
106
+ * The reason is mandatory: an ignore without one is itself reported (rule IG0),
107
+ * otherwise the escape hatch silently becomes a way to delete rules.
108
+ */
109
+ export const parseIgnores = (source) => {
110
+ const suppressions = new Map();
111
+ const unreasoned = [];
112
+ const comments = [];
113
+ const lines = source.split('\n');
114
+ lines.forEach((line, idx) => {
115
+ const m = line.match(IGNORE_RE);
116
+ if (!m)
117
+ return;
118
+ const ids = (m[1] ?? '').split(',').map(s => s.trim());
119
+ const reason = (m[2] || '').trim();
120
+ // Every comment is recorded here, reasoned or not: IG1 must be able to see a
121
+ // reference to a dead rule even when IG0 is already reporting the missing
122
+ // reason. Two ADJACENT comments merge in `suppressions`, so that map cannot
123
+ // be used to recover per-comment ids.
124
+ comments.push({ line: idx + 1, ids });
125
+ if (!reason) {
126
+ unreasoned.push({ line: idx + 1, ids });
127
+ return;
128
+ }
129
+ for (const target of [idx + 1, idx + 2]) {
130
+ if (!suppressions.has(target))
131
+ suppressions.set(target, new Set());
132
+ ids.forEach(id => suppressions.get(target).add(id));
133
+ }
134
+ });
135
+ return { suppressions, unreasoned, comments };
136
+ };
137
+ /**
138
+ * Blank out comment bodies while preserving every line number and offset, so a
139
+ * rule that greps for an identifier does not fire on prose. Handles `//`,
140
+ * block comments and HTML comments.
141
+ *
142
+ * Known limitation: a `//` inside a string literal (a URL) blanks the rest of
143
+ * that line. Harmless for identifier scans; do not use this for parsing.
144
+ */
145
+ export const stripCodeComments = (source) => {
146
+ const blank = (m) => m.replace(/[^\n]/g, ' ');
147
+ return source
148
+ .replace(/<!--[\s\S]*?-->/g, blank)
149
+ .replace(/\/\*[\s\S]*?\*\//g, blank)
150
+ .replace(/(^|[^:])\/\/[^\n]*/g, (m, pre) => pre + blank(m.slice(pre.length)));
151
+ };
152
+ /** Line number (1-based) of a character offset. */
153
+ export const lineOf = (source, index) => source.slice(0, index).split('\n').length;
154
+ /**
155
+ * Return the source of the object literal that starts at the first `{` after
156
+ * `fromIndex`, balanced on braces. Used by project-level rules that read a
157
+ * config map out of a real file instead of a fixture.
158
+ */
159
+ export const sliceObjectLiteral = (source, fromIndex) => {
160
+ const start = source.indexOf('{', fromIndex);
161
+ if (start === -1)
162
+ return '';
163
+ let depth = 0;
164
+ for (let i = start; i < source.length; i++) {
165
+ if (source[i] === '{')
166
+ depth++;
167
+ else if (source[i] === '}') {
168
+ depth--;
169
+ if (depth === 0)
170
+ return source.slice(start, i + 1);
171
+ }
172
+ }
173
+ return source.slice(start);
174
+ };
175
+ /** Keys of a `'string-literal': value` object literal. */
176
+ export const stringKeysOf = (literal) => new Set([...literal.matchAll(/^\s*'([a-z0-9-]+)'\s*:/gm)].flatMap(m => (m[1] ? [m[1]] : [])));
177
+ /**
178
+ * Every reference to a rule ID that no longer names a live rule.
179
+ *
180
+ * Three sinks dereference an ID and none of them used to validate it: an
181
+ * allowlist key, a `levels` key, and an `invariant-ignore` comment. A stale
182
+ * entry in any of them is inert and silent — which is what made "retire an ID,
183
+ * never recycle it" a discipline rather than a check. This is the check.
184
+ *
185
+ * Pure by design: the caller supplies the live set and the config, so the
186
+ * self-test can exercise it without touching the repo.
187
+ */
188
+ export const findDanglingRuleRefs = (input) => {
189
+ const { live, retired, allowlists, levels, ignores } = input;
190
+ const configFile = input.configFile ?? 'invariants/config.mts';
191
+ const out = [];
192
+ // Line of `<id>:` inside the given config block, so the finding points at the
193
+ // stale entry rather than at line 1 of the file.
194
+ const lineOfKey = (block, id) => {
195
+ const src = input.configSource;
196
+ if (!src)
197
+ return 1;
198
+ const start = src.indexOf(block);
199
+ if (start === -1)
200
+ return 1;
201
+ const rel = src.slice(start).search(new RegExp(`^\\s*${id}\\s*:`, 'm'));
202
+ return rel === -1 ? 1 : lineOf(src, start + rel);
203
+ };
204
+ const explain = (id) => retired[id] ? `${id} was retired — ${retired[id]}` : `${id} does not name any rule`;
205
+ for (const id of Object.keys(allowlists)) {
206
+ if (live.has(id))
207
+ continue;
208
+ out.push({ file: configFile, line: lineOfKey('allowlists', id), message: `allowlists names "${id}", but ${explain(id)} — the exemption is inert; drop it` });
209
+ }
210
+ for (const id of Object.keys(levels)) {
211
+ if (live.has(id))
212
+ continue;
213
+ out.push({ file: configFile, line: lineOfKey('levels', id), message: `levels names "${id}", but ${explain(id)} — the severity override does nothing; drop it` });
214
+ }
215
+ for (const { file, line, ids } of ignores) {
216
+ for (const id of ids) {
217
+ if (live.has(id))
218
+ continue;
219
+ out.push({ file, line, message: `invariant-ignore names "${id}", but ${explain(id)} — this suppression is inert; drop it or name a live rule` });
220
+ }
221
+ }
222
+ return out;
223
+ };
224
+ const LEVEL_RANK = { off: 0, warn: 1, error: 2 };
225
+ /**
226
+ * Severity of one finding, from three inputs that used to be one.
227
+ *
228
+ * ruleLevel — the rule's own default
229
+ * override — `levels[id]` in config.mts, set by whoever runs the gate
230
+ * finding — `Finding.level`, set by the rule for THIS finding
231
+ *
232
+ * Rules:
233
+ * 1. A finding with no level of its own takes the rule's effective level, and
234
+ * an override may raise or lower that — the pre-existing behaviour, which
235
+ * is how a `warn` rule gets promoted to blocking in one config line.
236
+ * 2. A finding may set its own level, so one rule can bundle a blocking check
237
+ * with an advisory one. R12 is why: an explicit <template> before the
238
+ * forwarding loop is a silent Vue override bug, while a missing :data-qa is
239
+ * a Playwright-selector gap with a 27-file backlog. One shared severity
240
+ * forced the dangerous check down to the backlog's level.
241
+ * 3. **An override is a CEILING over rule 2.** It can only lower a finding's
242
+ * self-declared level, never raise it, so an operator silencing a noisy rule
243
+ * cannot be defeated by a finding that promotes itself.
244
+ */
245
+ export const resolveFindingLevel = (ruleLevel, override, finding) => {
246
+ const effective = override ?? ruleLevel;
247
+ const claimed = finding ?? effective;
248
+ if (override === undefined)
249
+ return claimed;
250
+ return LEVEL_RANK[claimed] <= LEVEL_RANK[override] ? claimed : override;
251
+ };
252
+ /**
253
+ * Every rule ID that currently exists, across all three vectors.
254
+ *
255
+ * IG1 needs this, and getting it from `rules` alone is wrong: R3/R9/R19 are
256
+ * implemented as vitest specs and L1 is owned by ESLint, so a file-scan-only
257
+ * live set reports a perfectly valid `invariant-ignore: R3` as dangling. The
258
+ * bug is invisible in a unit test that supplies its own live set — it only
259
+ * shows up against the real repo.
260
+ */
261
+ export const knownRuleIds = (parts) => new Set([...parts.fileScan, ...parts.specs, ...parts.linter, ...(parts.meta ?? [])].filter(id => id !== '—'));
262
+ /**
263
+ * The severity override for a rule, or undefined.
264
+ *
265
+ * The only place that knows `Levels` holds `{ level, reason }` rather than a bare level.
266
+ * The reason is mandatory in the type so a shared rule cannot be silenced invisibly; this
267
+ * accessor keeps every caller from having to care.
268
+ */
269
+ export const levelOverrideOf = (levels, id) => levels[id]?.level;
@@ -0,0 +1,36 @@
1
+ /**
2
+ * The aggregating runner behind `pnpm validate`.
3
+ *
4
+ * Why this exists rather than `a && b && c`: pnpm offers exactly one of the two properties
5
+ * a gate needs, never both. Default bail stops at the first failure and exits 1 — correct
6
+ * exit code, incomplete feedback, so you fix lint, push, and only then discover typecheck.
7
+ * `--no-bail` runs everything but **exits 0**, which is unusable as a gate (the same
8
+ * footgun the invariants CLI records as "dropped 2026-08-19: --warn-only, always exits 0").
9
+ *
10
+ * So: run every step, report all of them, and fail if any failed. One push, one run, every
11
+ * problem visible — locally as much as in CI.
12
+ */
13
+ export interface ValidateStep {
14
+ /** Short label for the summary and the CI group. */
15
+ name: string;
16
+ /** Shell command to run. */
17
+ run: string;
18
+ }
19
+ export interface ValidateResult {
20
+ name: string;
21
+ ok: boolean;
22
+ ms: number;
23
+ }
24
+ export interface ValidateOptions {
25
+ /** Defaults to a real child_process spawn. Injectable for tests. */
26
+ exec?: (cmd: string) => {
27
+ ok: boolean;
28
+ };
29
+ /** Defaults to process.stdout.write. */
30
+ write?: (s: string) => void;
31
+ /** Defaults to process.exit. Injectable so tests can assert the code. */
32
+ exit?: (code: number) => never;
33
+ /** Emit ::group:: markers. Defaults to Boolean(process.env.GITHUB_ACTIONS). */
34
+ ci?: boolean;
35
+ }
36
+ export declare const validate: (steps: ValidateStep[], options?: ValidateOptions) => Promise<ValidateResult[]>;