@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.
- package/README.md +224 -0
- package/dist/invariants/ast.d.ts +72 -0
- package/dist/invariants/ast.js +195 -0
- package/dist/invariants/index.d.ts +7 -0
- package/dist/invariants/index.js +5 -0
- package/dist/invariants/rules/convention.d.ts +18 -0
- package/dist/invariants/rules/convention.js +226 -0
- package/dist/invariants/rules/fixtures.d.ts +17 -0
- package/dist/invariants/rules/fixtures.js +65 -0
- package/dist/invariants/rules/index.d.ts +55 -0
- package/dist/invariants/rules/index.js +53 -0
- package/dist/invariants/rules/linter.d.ts +10 -0
- package/dist/invariants/rules/linter.js +9 -0
- package/dist/invariants/rules/regression.d.ts +13 -0
- package/dist/invariants/rules/regression.js +109 -0
- package/dist/invariants/rules/shared.d.ts +18 -0
- package/dist/invariants/rules/shared.js +25 -0
- package/dist/invariants/run.d.ts +56 -0
- package/dist/invariants/run.js +553 -0
- package/dist/invariants/self-test.d.ts +38 -0
- package/dist/invariants/self-test.js +57 -0
- package/dist/invariants/types.d.ts +192 -0
- package/dist/invariants/types.js +9 -0
- package/dist/invariants/util.d.ts +113 -0
- package/dist/invariants/util.js +269 -0
- package/dist/validate/index.d.ts +36 -0
- package/dist/validate/index.js +41 -0
- package/eslint/index.mjs +187 -0
- package/eslint/rules/async-components-after-macros.mjs +63 -0
- package/eslint/rules/no-import-group-labels.mjs +72 -0
- package/eslint/rules/on-prefixed-callbacks.mjs +53 -0
- package/eslint/rules/optional-callback-chaining.mjs +78 -0
- package/eslint/rules/padding-after-macros.mjs +70 -0
- package/eslint/rules/ref-prefix-prop-mirrors.mjs +58 -0
- package/eslint/rules/require-component-name.mjs +68 -0
- 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[]>;
|