@brighterly/lib-fe-codestyle 1.0.0 → 1.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +234 -178
- package/dist/cli.d.ts +8 -0
- package/dist/cli.js +181 -0
- package/dist/config/define-config.d.ts +11 -0
- package/dist/config/define-config.js +33 -0
- package/dist/config/load-config.d.ts +15 -0
- package/dist/config/load-config.js +52 -0
- package/dist/config/schema.d.ts +58 -0
- package/dist/config/schema.js +139 -0
- package/dist/engine/docs.d.ts +14 -0
- package/dist/engine/docs.js +72 -0
- package/dist/engine/meta.d.ts +32 -0
- package/dist/engine/meta.js +47 -0
- package/dist/engine/migrate.d.ts +22 -0
- package/dist/engine/migrate.js +175 -0
- package/dist/engine/overrides.d.ts +36 -0
- package/dist/engine/overrides.js +92 -0
- package/dist/engine/registry.d.ts +35 -0
- package/dist/engine/registry.js +100 -0
- package/dist/engine/report.d.ts +23 -0
- package/dist/engine/report.js +87 -0
- package/dist/engine/run.d.ts +57 -0
- package/dist/engine/run.js +353 -0
- package/dist/engine/self-test.d.ts +33 -0
- package/dist/engine/self-test.js +141 -0
- package/dist/engine/types.d.ts +232 -0
- package/dist/{invariants → engine}/types.js +2 -1
- package/dist/{invariants → engine}/util.d.ts +31 -39
- package/dist/{invariants → engine}/util.js +59 -86
- package/dist/index.d.ts +16 -0
- package/dist/index.js +14 -0
- package/dist/rules/convention.d.ts +20 -0
- package/dist/{invariants/rules → rules}/convention.js +89 -15
- package/dist/rules/index.d.ts +28 -0
- package/dist/{invariants/rules → rules}/index.js +40 -32
- package/dist/{invariants/rules → rules}/regression.d.ts +1 -1
- package/dist/{invariants/rules → rules}/regression.js +26 -6
- package/dist/{invariants/rules → rules}/shared.d.ts +1 -1
- package/dist/validate/index.d.ts +1 -1
- package/dist/validate/index.js +1 -1
- package/package.json +8 -13
- package/dist/invariants/index.d.ts +0 -7
- package/dist/invariants/index.js +0 -5
- package/dist/invariants/rules/convention.d.ts +0 -18
- package/dist/invariants/rules/fixtures.d.ts +0 -17
- package/dist/invariants/rules/fixtures.js +0 -89
- package/dist/invariants/rules/index.d.ts +0 -55
- package/dist/invariants/rules/linter.d.ts +0 -10
- package/dist/invariants/rules/linter.js +0 -9
- package/dist/invariants/run.d.ts +0 -53
- package/dist/invariants/run.js +0 -746
- package/dist/invariants/self-test.d.ts +0 -46
- package/dist/invariants/self-test.js +0 -84
- package/dist/invariants/types.d.ts +0 -222
- /package/dist/{invariants → engine}/ast.d.ts +0 -0
- /package/dist/{invariants → engine}/ast.js +0 -0
- /package/dist/{invariants/rules → rules}/shared.js +0 -0
|
@@ -0,0 +1,232 @@
|
|
|
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 = 'off' | 'warn' | 'error';
|
|
11
|
+
/**
|
|
12
|
+
* A rule's name — a plain kebab-case word (`no-bare-storage`), not a family-numbered id.
|
|
13
|
+
* Names are load-bearing (`--only=`, `codestyle-ignore:`, overrides): rename one only
|
|
14
|
+
* through the `renamed` registry, never by editing it in place.
|
|
15
|
+
*/
|
|
16
|
+
export type RuleName = string;
|
|
17
|
+
/** One violation. `line` is 1-based. */
|
|
18
|
+
export interface Finding {
|
|
19
|
+
file: string;
|
|
20
|
+
line: number;
|
|
21
|
+
message: string;
|
|
22
|
+
/**
|
|
23
|
+
* Severity for THIS finding, when it differs from the rest of the rule's.
|
|
24
|
+
* Omit to inherit the rule's. A `levels` override in config.mts is a ceiling
|
|
25
|
+
* over this — see resolveFindingLevel in util.mts.
|
|
26
|
+
*/
|
|
27
|
+
level?: Level;
|
|
28
|
+
/**
|
|
29
|
+
* What the finding is ABOUT, when that differs from the file it is reported against —
|
|
30
|
+
* a View checked through the route registry. Overrides match on `subject ?? file`, so
|
|
31
|
+
* an exemption is keyed by the thing checked, not by where the finding lands.
|
|
32
|
+
*/
|
|
33
|
+
subject?: string;
|
|
34
|
+
}
|
|
35
|
+
/** A finding after the runner has attributed it to a rule and a level. */
|
|
36
|
+
export interface ReportedFinding extends Finding {
|
|
37
|
+
rule: RuleName;
|
|
38
|
+
level: Level;
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* One override as it applies to one rule: the files it covers, the level it sets, the
|
|
42
|
+
* mandatory reason, and the `until YYYY-MM-DD` parsed out of that reason (null when it has
|
|
43
|
+
* none). The engine resolves them; a rule only reads them, to validate its own entries.
|
|
44
|
+
*/
|
|
45
|
+
export interface EffectiveOverride {
|
|
46
|
+
files: string[];
|
|
47
|
+
level: Level;
|
|
48
|
+
reason: string;
|
|
49
|
+
until: string | null;
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Every settings key a shared rule reads, documented for hover. Annotate the app's
|
|
53
|
+
* `settings` const with this to get completion and the per-key contract in the IDE:
|
|
54
|
+
*
|
|
55
|
+
* export const settings: SharedRuleSettings & Record<string, string> = { ... }
|
|
56
|
+
*
|
|
57
|
+
* All keys are optional: a missing one degrades the rule's MESSAGE (or falls back to
|
|
58
|
+
* the documented default), never its firing.
|
|
59
|
+
*/
|
|
60
|
+
export interface SharedRuleSettings {
|
|
61
|
+
/** `no-bare-storage`/`guarded-json-parse` fix messages: how this app names its safe storage wrapper, e.g. 'safeLocalStorage from @helpers/safe-local-storage'. */
|
|
62
|
+
safeStorageModule?: string;
|
|
63
|
+
/** `no-bare-storage`: repo-relative path of the wrapper FILE itself — the one place bare storage access belongs, auto-exempted so no app has to allowlist its own wrapper. E.g. 'app/utils/helpers/safe-local-storage.ts'. */
|
|
64
|
+
safeStorageFile?: string;
|
|
65
|
+
/** `guarded-json-parse` fix message: the guarded parse helper, e.g. 'parseStoredJson() from @helpers/safe-local-storage'. */
|
|
66
|
+
safeJsonParse?: string;
|
|
67
|
+
/** `scss-tokens`: the palette file that legitimately DEFINES colour literals, e.g. 'app/assets/scss/_colors.scss'. */
|
|
68
|
+
scssTokensFile?: string;
|
|
69
|
+
/** `ui-kit-unique-names`: root of the UI-kit tree whose component names must not be duplicated. Default 'app/components/UI'. */
|
|
70
|
+
uiKitRoot?: string;
|
|
71
|
+
/** `date-format-enum` fix message: the shared date-format enum, e.g. 'DateFormat from @ts-types/date/enums'. */
|
|
72
|
+
dateFormatModule?: string;
|
|
73
|
+
/** `types-layout`: where entity type folders live. Default 'app/types'. */
|
|
74
|
+
typesRoot?: string;
|
|
75
|
+
/** `typography-source` 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'). */
|
|
76
|
+
typographySource?: string;
|
|
77
|
+
/** `script-attr-order`: which `<script>` attribute order is canonical in this app. Default 'setup-first' (`<script setup lang="ts">`). */
|
|
78
|
+
scriptAttrOrder?: 'setup-first' | 'lang-first';
|
|
79
|
+
}
|
|
80
|
+
/** What a rule's `check` receives. */
|
|
81
|
+
export interface RuleContext {
|
|
82
|
+
/** Repo-relative path of the file under inspection. */
|
|
83
|
+
file: string;
|
|
84
|
+
/** Raw file contents. */
|
|
85
|
+
source: string;
|
|
86
|
+
/** Same contents with comment bodies blanked, line numbers preserved. */
|
|
87
|
+
code: string;
|
|
88
|
+
/** Read any other repo-relative file (cached). Returns null if unreadable. */
|
|
89
|
+
read: (rel: string) => string | null;
|
|
90
|
+
/** Every scanned file in the repo — for rules that resolve references. */
|
|
91
|
+
allFiles: string[];
|
|
92
|
+
/**
|
|
93
|
+
* App-specific paths a SHARED rule needs, supplied by `config.settings`. This is how a
|
|
94
|
+
* portable rule learns where this app keeps its SCSS tokens or its UI-kit root, without
|
|
95
|
+
* the rule becoming a factory. The known keys are documented on `SharedRuleSettings`.
|
|
96
|
+
*/
|
|
97
|
+
settings: Readonly<SharedRuleSettings & Record<string, string>>;
|
|
98
|
+
/**
|
|
99
|
+
* The effective overrides for THIS rule, so a rule can validate its own entries (one naming
|
|
100
|
+
* a View that is not registered) without importing the consumer's config.
|
|
101
|
+
*/
|
|
102
|
+
overrides: EffectiveOverride[];
|
|
103
|
+
/** Today as an ISO date (`YYYY-MM-DD`): the runner sets the real one, the world harness the fixture's. */
|
|
104
|
+
today: string;
|
|
105
|
+
}
|
|
106
|
+
/** A `once: true` rule reads its own inputs, so it gets no per-file fields. */
|
|
107
|
+
export type OnceRuleContext = Pick<RuleContext, 'read' | 'allFiles' | 'overrides' | 'today'> & Partial<RuleContext>;
|
|
108
|
+
interface BaseRule {
|
|
109
|
+
name: RuleName;
|
|
110
|
+
/** Short description — appears in the report and the README inventory. */
|
|
111
|
+
title: string;
|
|
112
|
+
level: Level;
|
|
113
|
+
/** The silent-failure mode. A rule without a why gets deleted by future-you. */
|
|
114
|
+
why: string;
|
|
115
|
+
/** What to do instead. */
|
|
116
|
+
fix: string;
|
|
117
|
+
/** Glob patterns this rule applies to. */
|
|
118
|
+
scope: string[];
|
|
119
|
+
/** Glob patterns to skip within scope. */
|
|
120
|
+
exclude?: string[];
|
|
121
|
+
/**
|
|
122
|
+
* The rule cannot be suppressed with `invariant-ignore`; a comment naming it is
|
|
123
|
+
* itself a finding (IG2). For rules whose author is an AI: exceptions are made by
|
|
124
|
+
* a human in the rulebook (rule + fixture), never by the agent in the file under
|
|
125
|
+
* check — an AI writes a plausible reason for free.
|
|
126
|
+
* Honoured by the runner: the suppression lookup skips such rules, and the IG2
|
|
127
|
+
* block reports the attempt.
|
|
128
|
+
*/
|
|
129
|
+
noIgnore?: boolean;
|
|
130
|
+
/** Set when `check` returns a promise, so the runner awaits it. */
|
|
131
|
+
async?: boolean;
|
|
132
|
+
/**
|
|
133
|
+
* Selection labels — `codestyle check --tags=e2e` runs every rule carrying `e2e`. Replaces
|
|
134
|
+
* the family letter as the way a gate picks its rules. Package rules carry none.
|
|
135
|
+
*/
|
|
136
|
+
tags?: string[];
|
|
137
|
+
/**
|
|
138
|
+
* An override of this rule without an `until YYYY-MM-DD` in its reason is an error: every
|
|
139
|
+
* exemption is a debt with a date (a coverage gap, not a permanent pass).
|
|
140
|
+
*/
|
|
141
|
+
exemptionsRequireUntil?: boolean;
|
|
142
|
+
}
|
|
143
|
+
/** Runs once per matching file. */
|
|
144
|
+
export interface FileRule extends BaseRule {
|
|
145
|
+
once?: false;
|
|
146
|
+
check: (ctx: RuleContext) => Finding[] | Promise<Finding[]>;
|
|
147
|
+
}
|
|
148
|
+
/**
|
|
149
|
+
* Runs once per invocation if any file in `scope` is in the candidate set, for
|
|
150
|
+
* rules that read several files themselves (registry and graph checks).
|
|
151
|
+
*/
|
|
152
|
+
export interface OnceRule extends BaseRule {
|
|
153
|
+
once: true;
|
|
154
|
+
check: (ctx: OnceRuleContext) => Finding[] | Promise<Finding[]>;
|
|
155
|
+
}
|
|
156
|
+
/**
|
|
157
|
+
* One single-file fixture case. `bad` must produce >=1 finding, `good` must produce none.
|
|
158
|
+
* `excluded: true` asserts the FILE is matched by the rule's own `exclude` list — the case
|
|
159
|
+
* never runs the check; it proves the exclusion glob is right.
|
|
160
|
+
*/
|
|
161
|
+
export interface FixtureCase {
|
|
162
|
+
file: string;
|
|
163
|
+
bad?: string;
|
|
164
|
+
good?: string;
|
|
165
|
+
excluded?: true;
|
|
166
|
+
}
|
|
167
|
+
/**
|
|
168
|
+
* A multi-file fixture for a registry rule: a virtual repo fed through `read`/`allFiles`,
|
|
169
|
+
* and whether the rule must report on it. `match` narrows "a finding" to one whose message
|
|
170
|
+
* matches; `today` pins the date an `until` comparison sees.
|
|
171
|
+
*/
|
|
172
|
+
export interface WorldFixture {
|
|
173
|
+
files: Record<string, string>;
|
|
174
|
+
expect: 'finding' | 'silence';
|
|
175
|
+
match?: RegExp;
|
|
176
|
+
today?: string;
|
|
177
|
+
}
|
|
178
|
+
/**
|
|
179
|
+
* How a rule proves it still fires. Exactly one form is mandatory — a rule that has silently
|
|
180
|
+
* stopped matching finds nothing, which is indistinguishable from a clean codebase.
|
|
181
|
+
* fixtures — single-file cases (the 0.5.x grammar)
|
|
182
|
+
* world — a virtual repo, for rules that read several files
|
|
183
|
+
* verify — a programmatic proof returning failure messages
|
|
184
|
+
*/
|
|
185
|
+
export type Proof = {
|
|
186
|
+
fixtures: FixtureCase | FixtureCase[];
|
|
187
|
+
} | {
|
|
188
|
+
world: WorldFixture | WorldFixture[];
|
|
189
|
+
} | {
|
|
190
|
+
verify: () => string[] | Promise<string[]>;
|
|
191
|
+
};
|
|
192
|
+
/**
|
|
193
|
+
* The two shapes are separate so a per-file rule cannot accidentally be handed a context
|
|
194
|
+
* without `source`, and a `once` rule cannot claim to receive one. Either way a rule object
|
|
195
|
+
* always carries one proof.
|
|
196
|
+
*/
|
|
197
|
+
export type Rule = (FileRule | OnceRule) & Proof;
|
|
198
|
+
/**
|
|
199
|
+
* A rule whose check lives elsewhere — an ESLint rule or a test — declared so its name stays
|
|
200
|
+
* live (no IG1 on a suppression naming it), appears in the inventory with its owner, and
|
|
201
|
+
* produces no findings here.
|
|
202
|
+
*/
|
|
203
|
+
export interface ExternalRule {
|
|
204
|
+
name: RuleName;
|
|
205
|
+
title: string;
|
|
206
|
+
why: string;
|
|
207
|
+
owner: 'eslint' | 'test';
|
|
208
|
+
/** The ESLint rule id or the test file that owns the check. */
|
|
209
|
+
ref: string;
|
|
210
|
+
/** Selection labels, so a tag-selected doc (`codestyle docs`) can list the declaration. Never selected for a run. */
|
|
211
|
+
tags?: string[];
|
|
212
|
+
}
|
|
213
|
+
/** Anything a consumer may list as a rule. */
|
|
214
|
+
export type AnyRule = Rule | ExternalRule;
|
|
215
|
+
/** An external declaration has `owner` set to one of the two owners; `defineConfig` rejects any other `owner`. */
|
|
216
|
+
export declare const isExternal: (r: AnyRule) => r is ExternalRule;
|
|
217
|
+
/** Everything rule IG1 needs, passed in so the check stays pure and testable. */
|
|
218
|
+
export interface DanglingRefInput {
|
|
219
|
+
/** Names of rules currently loaded. */
|
|
220
|
+
live: Set<string>;
|
|
221
|
+
/** Old name → new name, so a stale reference says where the rule went. */
|
|
222
|
+
renamed?: Record<string, string>;
|
|
223
|
+
/** Every `codestyle-ignore` comment found in the scanned set. */
|
|
224
|
+
ignores: {
|
|
225
|
+
file: string;
|
|
226
|
+
line: number;
|
|
227
|
+
ids: string[];
|
|
228
|
+
}[];
|
|
229
|
+
/** The consumer's config file, for findings about it. */
|
|
230
|
+
configFile?: string;
|
|
231
|
+
}
|
|
232
|
+
export {};
|
|
@@ -6,4 +6,5 @@
|
|
|
6
6
|
* mechanism that has no resolver, a level that is not a real level. Those were
|
|
7
7
|
* all silently ignored while this was plain JavaScript.
|
|
8
8
|
*/
|
|
9
|
-
|
|
9
|
+
/** An external declaration has `owner` set to one of the two owners; `defineConfig` rejects any other `owner`. */
|
|
10
|
+
export const isExternal = (r) => 'owner' in r && (r.owner === 'eslint' || r.owner === 'test');
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Shared plumbing for validate-invariants: file walking, glob matching,
|
|
3
|
-
* a read cache, and the `
|
|
3
|
+
* a read cache, and the `codestyle-ignore` escape hatch.
|
|
4
4
|
*
|
|
5
5
|
* No dependencies — this runs in pre-commit, so it must not need an install.
|
|
6
6
|
*/
|
|
7
|
-
import type { DanglingRefInput, Finding, Level
|
|
7
|
+
import type { DanglingRefInput, Finding, Level } from './types.js';
|
|
8
8
|
/**
|
|
9
9
|
* Minimal glob → RegExp. Supports `**`, `*`, `?` and `{a,b}`.
|
|
10
10
|
* Deliberately not a full glob implementation: rule scopes are simple by
|
|
@@ -12,16 +12,19 @@ import type { DanglingRefInput, Finding, Level, Levels, MetaRuleId } from './typ
|
|
|
12
12
|
*/
|
|
13
13
|
export declare const globToRegExp: (glob: string) => RegExp;
|
|
14
14
|
export declare const matchesAny: (path: string, globs: string[]) => boolean;
|
|
15
|
-
/**
|
|
16
|
-
|
|
15
|
+
/**
|
|
16
|
+
* Recursively list repo-relative file paths under `dir` (`''` = the root), POSIX separators.
|
|
17
|
+
* `skip` prunes an entry — a directory is not descended into — by its repo-relative path.
|
|
18
|
+
*/
|
|
19
|
+
export declare const walk: (root: string, dir: string, skip?: (rel: string) => boolean) => string[];
|
|
17
20
|
export declare const createReader: (root: string) => ((rel: string) => string | null);
|
|
18
21
|
export declare const toRelative: (root: string, p: string) => string;
|
|
19
22
|
/**
|
|
20
|
-
* Parse every `invariant-ignore:` comment in a file.
|
|
21
|
-
*
|
|
23
|
+
* Parse every `codestyle-ignore:` (or legacy `invariant-ignore:`) comment in a file.
|
|
24
|
+
* `legacy` says whether the file used the old spelling, so the runner can warn once.
|
|
22
25
|
*
|
|
23
26
|
* A comment suppresses violations on its own line and the line below, so both
|
|
24
|
-
* `//
|
|
27
|
+
* `// codestyle-ignore: no-bare-storage — reason` above the code and a trailing comment work.
|
|
25
28
|
* The reason is mandatory: an ignore without one is itself reported (rule IG0),
|
|
26
29
|
* otherwise the escape hatch silently becomes a way to delete rules.
|
|
27
30
|
*/
|
|
@@ -35,6 +38,7 @@ export declare const parseIgnores: (source: string) => {
|
|
|
35
38
|
line: number;
|
|
36
39
|
ids: string[];
|
|
37
40
|
}[];
|
|
41
|
+
legacy: boolean;
|
|
38
42
|
};
|
|
39
43
|
/**
|
|
40
44
|
* Blanks comment bodies in place: every line and every non-comment character stays where it was,
|
|
@@ -64,19 +68,18 @@ export declare const sliceObjectLiteral: (source: string, fromIndex: number) =>
|
|
|
64
68
|
/** Keys of a `'string-literal': value` object literal. */
|
|
65
69
|
export declare const stringKeysOf: (literal: string) => Set<string>;
|
|
66
70
|
/**
|
|
67
|
-
* Every
|
|
71
|
+
* Every `codestyle-ignore` comment naming a rule that is not live.
|
|
68
72
|
*
|
|
69
|
-
*
|
|
70
|
-
*
|
|
71
|
-
*
|
|
72
|
-
* never recycle it" a discipline rather than a check. This is the check.
|
|
73
|
+
* A stale suppression is inert and silent — which is what made "retire a name, never
|
|
74
|
+
* recycle it" a discipline rather than a check. This is the check. A renamed rule says
|
|
75
|
+
* where it went.
|
|
73
76
|
*
|
|
74
|
-
* Pure by design: the caller supplies the live set
|
|
75
|
-
*
|
|
77
|
+
* Pure by design: the caller supplies the live set, so the self-test can exercise it
|
|
78
|
+
* without touching the repo.
|
|
76
79
|
*/
|
|
77
80
|
export declare const findDanglingRuleRefs: (input: DanglingRefInput) => Finding[];
|
|
78
81
|
/**
|
|
79
|
-
* `
|
|
82
|
+
* `codestyle-ignore` comments that name a rule declared `noIgnore` (IG2). The
|
|
80
83
|
* runner never honours such a comment; reporting it makes the attempt visible so a
|
|
81
84
|
* reviewer sees the intent and an AI author learns the rule cannot be argued with
|
|
82
85
|
* in the file under check.
|
|
@@ -108,31 +111,20 @@ export declare const findForbiddenSuppressions: (ignores: {
|
|
|
108
111
|
*/
|
|
109
112
|
export declare const resolveFindingLevel: (ruleLevel: Level, override: Level | undefined, finding: Level | undefined) => Level;
|
|
110
113
|
/**
|
|
111
|
-
*
|
|
112
|
-
*
|
|
113
|
-
* IG1 needs this, and getting it from `rules` alone is wrong: R3/R9/R19 are
|
|
114
|
-
* implemented as vitest specs and L1 is owned by ESLint, so a file-scan-only
|
|
115
|
-
* live set reports a perfectly valid `invariant-ignore: R3` as dangling. The
|
|
116
|
-
* bug is invisible in a unit test that supplies its own live set — it only
|
|
117
|
-
* shows up against the real repo.
|
|
118
|
-
*/
|
|
119
|
-
export declare const knownRuleIds: (parts: {
|
|
120
|
-
fileScan: string[];
|
|
121
|
-
specs: string[];
|
|
122
|
-
linter: string[];
|
|
123
|
-
meta?: MetaRuleId[];
|
|
124
|
-
}) => Set<string>;
|
|
125
|
-
/**
|
|
126
|
-
* The severity override for a rule, or undefined.
|
|
127
|
-
*
|
|
128
|
-
* The only place that knows `Levels` holds `{ level, reason }` rather than a bare level.
|
|
129
|
-
* The reason is mandatory in the type so a shared rule cannot be silenced invisibly; this
|
|
130
|
-
* accessor keeps every caller from having to care.
|
|
131
|
-
*/
|
|
132
|
-
export declare const levelOverrideOf: (levels: Levels, id: string) => Level | undefined;
|
|
133
|
-
/**
|
|
134
|
-
* The `until YYYY-MM-DD` an allowlist reason may carry. A rule may require one — a coverage gap is
|
|
114
|
+
* The `until YYYY-MM-DD` an override's reason may carry. A rule may require one — a coverage gap is
|
|
135
115
|
* a debt with a date, not a permanent exemption — and report the entry once the date has passed.
|
|
136
116
|
* ISO dates compare as strings, so the caller needs no Date arithmetic.
|
|
137
117
|
*/
|
|
138
118
|
export declare const exemptionExpiry: (reason: string) => string | null;
|
|
119
|
+
/** A real calendar date in `YYYY-MM-DD`: `2026-13-40` has the right shape and no such day (UTC round-trip). */
|
|
120
|
+
export declare const isRealDate: (iso: string) => boolean;
|
|
121
|
+
/**
|
|
122
|
+
* The token after `until` when the author clearly meant a date but did not write a real
|
|
123
|
+
* `YYYY-MM-DD` one (`until 2026-1-5`, `until 05/01/2026`, `until 2026-10-311`) — otherwise null.
|
|
124
|
+
* `exemptionExpiry` cannot see such a token, so without this check the exemption would silently
|
|
125
|
+
* never expire. Only a digit-led token counts: prose (`until the redesign lands`) is a reason
|
|
126
|
+
* with no expiry. Trailing sentence punctuation (`until 2026-10-31.`) is not part of the token.
|
|
127
|
+
*/
|
|
128
|
+
export declare const malformedUntil: (reason: string) => string | null;
|
|
129
|
+
/** Today as `YYYY-MM-DD` (UTC) — the one place the engine reads the clock for a date. */
|
|
130
|
+
export declare const todayUtc: () => string;
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Shared plumbing for validate-invariants: file walking, glob matching,
|
|
3
|
-
* a read cache, and the `
|
|
3
|
+
* a read cache, and the `codestyle-ignore` escape hatch.
|
|
4
4
|
*
|
|
5
5
|
* No dependencies — this runs in pre-commit, so it must not need an install.
|
|
6
6
|
*/
|
|
@@ -56,8 +56,11 @@ export const globToRegExp = (glob) => {
|
|
|
56
56
|
return new RegExp(`${out}$`);
|
|
57
57
|
};
|
|
58
58
|
export const matchesAny = (path, globs) => globs.some(g => globToRegExp(g).test(path));
|
|
59
|
-
/**
|
|
60
|
-
|
|
59
|
+
/**
|
|
60
|
+
* Recursively list repo-relative file paths under `dir` (`''` = the root), POSIX separators.
|
|
61
|
+
* `skip` prunes an entry — a directory is not descended into — by its repo-relative path.
|
|
62
|
+
*/
|
|
63
|
+
export const walk = (root, dir, skip = () => false) => {
|
|
61
64
|
const out = [];
|
|
62
65
|
const abs = resolve(root, dir);
|
|
63
66
|
let entries;
|
|
@@ -68,13 +71,13 @@ export const walk = (root, dir) => {
|
|
|
68
71
|
return out;
|
|
69
72
|
}
|
|
70
73
|
for (const e of entries) {
|
|
71
|
-
if (e.name.startsWith('.') && e.name !== '.github')
|
|
72
|
-
continue;
|
|
73
74
|
if (SKIP_DIRS.has(e.name))
|
|
74
75
|
continue;
|
|
75
|
-
const child = `${dir}/${e.name}
|
|
76
|
+
const child = dir ? `${dir}/${e.name}` : e.name;
|
|
77
|
+
if (skip(child))
|
|
78
|
+
continue;
|
|
76
79
|
if (e.isDirectory())
|
|
77
|
-
out.push(...walk(root, child));
|
|
80
|
+
out.push(...walk(root, child, skip));
|
|
78
81
|
else if (e.isFile())
|
|
79
82
|
out.push(child);
|
|
80
83
|
}
|
|
@@ -95,14 +98,17 @@ export const createReader = (root) => {
|
|
|
95
98
|
};
|
|
96
99
|
};
|
|
97
100
|
export const toRelative = (root, p) => relative(root, resolve(root, p)).split(sep).join('/');
|
|
98
|
-
//
|
|
99
|
-
|
|
101
|
+
// A rule name is kebab-case (`no-bare-storage`) or a family-numbered app id (`R1`). A 0.5.x
|
|
102
|
+
// package id (`core/` + family + number) still parses, so IG1 can say what it was renamed to.
|
|
103
|
+
const RULE_TOKEN = String.raw `(?:(?:core\/)?[A-Z]{1,3}\d{1,3}|[a-z][a-z0-9]*(?:-[a-z0-9]+)*)`;
|
|
104
|
+
// `codestyle-ignore:` is the 1.0 spelling; `invariant-ignore:` is accepted through 1.x as an alias.
|
|
105
|
+
const IGNORE_RE = new RegExp(String.raw `(codestyle|invariant)-ignore:\s*(${RULE_TOKEN}(?:\s*,\s*${RULE_TOKEN})*)\s*(?:—|–|--|-|:)?\s*(.*)$`);
|
|
100
106
|
/**
|
|
101
|
-
* Parse every `invariant-ignore:` comment in a file.
|
|
102
|
-
*
|
|
107
|
+
* Parse every `codestyle-ignore:` (or legacy `invariant-ignore:`) comment in a file.
|
|
108
|
+
* `legacy` says whether the file used the old spelling, so the runner can warn once.
|
|
103
109
|
*
|
|
104
110
|
* A comment suppresses violations on its own line and the line below, so both
|
|
105
|
-
* `//
|
|
111
|
+
* `// codestyle-ignore: no-bare-storage — reason` above the code and a trailing comment work.
|
|
106
112
|
* The reason is mandatory: an ignore without one is itself reported (rule IG0),
|
|
107
113
|
* otherwise the escape hatch silently becomes a way to delete rules.
|
|
108
114
|
*/
|
|
@@ -110,13 +116,16 @@ export const parseIgnores = (source) => {
|
|
|
110
116
|
const suppressions = new Map();
|
|
111
117
|
const unreasoned = [];
|
|
112
118
|
const comments = [];
|
|
119
|
+
let legacy = false;
|
|
113
120
|
const lines = source.split('\n');
|
|
114
121
|
lines.forEach((line, idx) => {
|
|
115
122
|
const m = line.match(IGNORE_RE);
|
|
116
123
|
if (!m)
|
|
117
124
|
return;
|
|
118
|
-
|
|
119
|
-
|
|
125
|
+
if (m[1] === 'invariant')
|
|
126
|
+
legacy = true;
|
|
127
|
+
const ids = (m[2] ?? '').split(',').map(s => s.trim());
|
|
128
|
+
const reason = (m[3] || '').trim();
|
|
120
129
|
// Every comment is recorded here, reasoned or not: IG1 must be able to see a
|
|
121
130
|
// reference to a dead rule even when IG0 is already reporting the missing
|
|
122
131
|
// reason. Two ADJACENT comments merge in `suppressions`, so that map cannot
|
|
@@ -132,7 +141,7 @@ export const parseIgnores = (source) => {
|
|
|
132
141
|
ids.forEach(id => suppressions.get(target).add(id));
|
|
133
142
|
}
|
|
134
143
|
});
|
|
135
|
-
return { suppressions, unreasoned, comments };
|
|
144
|
+
return { suppressions, unreasoned, comments, legacy };
|
|
136
145
|
};
|
|
137
146
|
/**
|
|
138
147
|
* Blanks comment bodies in place: every line and every non-comment character stays where it was,
|
|
@@ -322,71 +331,30 @@ export const sliceObjectLiteral = (source, fromIndex) => {
|
|
|
322
331
|
/** Keys of a `'string-literal': value` object literal. */
|
|
323
332
|
export const stringKeysOf = (literal) => new Set([...literal.matchAll(/^\s*'([a-z0-9-]+)'\s*:/gm)].flatMap(m => (m[1] ? [m[1]] : [])));
|
|
324
333
|
/**
|
|
325
|
-
* Every
|
|
334
|
+
* Every `codestyle-ignore` comment naming a rule that is not live.
|
|
326
335
|
*
|
|
327
|
-
*
|
|
328
|
-
*
|
|
329
|
-
*
|
|
330
|
-
* never recycle it" a discipline rather than a check. This is the check.
|
|
336
|
+
* A stale suppression is inert and silent — which is what made "retire a name, never
|
|
337
|
+
* recycle it" a discipline rather than a check. This is the check. A renamed rule says
|
|
338
|
+
* where it went.
|
|
331
339
|
*
|
|
332
|
-
* Pure by design: the caller supplies the live set
|
|
333
|
-
*
|
|
340
|
+
* Pure by design: the caller supplies the live set, so the self-test can exercise it
|
|
341
|
+
* without touching the repo.
|
|
334
342
|
*/
|
|
335
343
|
export const findDanglingRuleRefs = (input) => {
|
|
336
|
-
const { live,
|
|
337
|
-
const
|
|
344
|
+
const { live, renamed = {}, ignores } = input;
|
|
345
|
+
const explain = (id) => Object.hasOwn(renamed, id) ? `"${id}" was renamed to "${renamed[id]}"` : `${id} does not name any rule`;
|
|
338
346
|
const out = [];
|
|
339
|
-
// Line of `<id>:` inside the given config block, so the finding points at the
|
|
340
|
-
// stale entry rather than at line 1 of the file.
|
|
341
|
-
const lineOfKey = (block, id) => {
|
|
342
|
-
const src = input.configSource;
|
|
343
|
-
if (!src)
|
|
344
|
-
return 1;
|
|
345
|
-
const start = src.indexOf(block);
|
|
346
|
-
if (start === -1)
|
|
347
|
-
return 1;
|
|
348
|
-
// Ids may contain `/` (core/R1) and appear as quoted keys — escape and allow quotes.
|
|
349
|
-
const escaped = id.replace(/[.*+?^${}()|[\]\\/]/g, '\\$&');
|
|
350
|
-
const rel = src.slice(start).search(new RegExp(`^\\s*(?:'|")?${escaped}(?:'|")?\\s*:`, 'm'));
|
|
351
|
-
return rel === -1 ? 1 : lineOf(src, start + rel);
|
|
352
|
-
};
|
|
353
|
-
const explain = (id) => retired[id] ? `${id} was retired — ${retired[id]}` : `${id} does not name any rule`;
|
|
354
|
-
for (const id of Object.keys(allowlists)) {
|
|
355
|
-
if (live.has(id))
|
|
356
|
-
continue;
|
|
357
|
-
out.push({ file: configFile, line: lineOfKey('allowlists', id), message: `allowlists names "${id}", but ${explain(id)} — the exemption is inert; drop it` });
|
|
358
|
-
}
|
|
359
|
-
for (const id of Object.keys(levels)) {
|
|
360
|
-
if (live.has(id))
|
|
361
|
-
continue;
|
|
362
|
-
out.push({ file: configFile, line: lineOfKey('levels', id), message: `levels names "${id}", but ${explain(id)} — the severity override does nothing; drop it` });
|
|
363
|
-
}
|
|
364
|
-
// Allowlist PATHS had no staleness guard: an entry whose file is gone (or renamed)
|
|
365
|
-
// is inert and silent. Found live in app-funnels — a dead BtnAuthGoogle.vue entry
|
|
366
|
-
// survived a fork divergence (2026-09-25). Checked only when the caller can answer
|
|
367
|
-
// existence, so the self-test can still exercise this function without a filesystem.
|
|
368
|
-
if (input.pathExists) {
|
|
369
|
-
for (const [id, entries] of Object.entries(allowlists)) {
|
|
370
|
-
if (!live.has(id))
|
|
371
|
-
continue; // the dead-id finding above already covers the whole entry
|
|
372
|
-
for (const path of Object.keys(entries ?? {})) {
|
|
373
|
-
if (input.pathExists(path))
|
|
374
|
-
continue;
|
|
375
|
-
out.push({ file: configFile, line: lineOfKey('allowlists', path), message: `allowlists["${id}"] names "${path}", but no such file exists — the exemption is inert; drop it or fix the path` });
|
|
376
|
-
}
|
|
377
|
-
}
|
|
378
|
-
}
|
|
379
347
|
for (const { file, line, ids } of ignores) {
|
|
380
348
|
for (const id of ids) {
|
|
381
349
|
if (live.has(id))
|
|
382
350
|
continue;
|
|
383
|
-
out.push({ file, line, message: `
|
|
351
|
+
out.push({ file, line, message: `codestyle-ignore names "${id}", but ${explain(id)} — this suppression is inert; drop it or name a live rule` });
|
|
384
352
|
}
|
|
385
353
|
}
|
|
386
354
|
return out;
|
|
387
355
|
};
|
|
388
356
|
/**
|
|
389
|
-
* `
|
|
357
|
+
* `codestyle-ignore` comments that name a rule declared `noIgnore` (IG2). The
|
|
390
358
|
* runner never honours such a comment; reporting it makes the attempt visible so a
|
|
391
359
|
* reviewer sees the intent and an AI author learns the rule cannot be argued with
|
|
392
360
|
* in the file under check.
|
|
@@ -394,7 +362,7 @@ export const findDanglingRuleRefs = (input) => {
|
|
|
394
362
|
export const findForbiddenSuppressions = (ignores, noIgnoreIds) => ignores.flatMap(({ file, line, ids }) => ids.filter(id => noIgnoreIds.has(id)).map(id => ({
|
|
395
363
|
file,
|
|
396
364
|
line,
|
|
397
|
-
message: `
|
|
365
|
+
message: `codestyle-ignore names "${id}", a noIgnore rule — this suppression is inert and the attempt is reported`,
|
|
398
366
|
})));
|
|
399
367
|
const LEVEL_RANK = { off: 0, warn: 1, error: 2 };
|
|
400
368
|
/**
|
|
@@ -425,26 +393,31 @@ export const resolveFindingLevel = (ruleLevel, override, finding) => {
|
|
|
425
393
|
return LEVEL_RANK[claimed] <= LEVEL_RANK[override] ? claimed : override;
|
|
426
394
|
};
|
|
427
395
|
/**
|
|
428
|
-
*
|
|
429
|
-
*
|
|
430
|
-
* IG1 needs this, and getting it from `rules` alone is wrong: R3/R9/R19 are
|
|
431
|
-
* implemented as vitest specs and L1 is owned by ESLint, so a file-scan-only
|
|
432
|
-
* live set reports a perfectly valid `invariant-ignore: R3` as dangling. The
|
|
433
|
-
* bug is invisible in a unit test that supplies its own live set — it only
|
|
434
|
-
* shows up against the real repo.
|
|
435
|
-
*/
|
|
436
|
-
export const knownRuleIds = (parts) => new Set([...parts.fileScan, ...parts.specs, ...parts.linter, ...(parts.meta ?? [])].filter(id => id !== '—'));
|
|
437
|
-
/**
|
|
438
|
-
* The severity override for a rule, or undefined.
|
|
439
|
-
*
|
|
440
|
-
* The only place that knows `Levels` holds `{ level, reason }` rather than a bare level.
|
|
441
|
-
* The reason is mandatory in the type so a shared rule cannot be silenced invisibly; this
|
|
442
|
-
* accessor keeps every caller from having to care.
|
|
443
|
-
*/
|
|
444
|
-
export const levelOverrideOf = (levels, id) => levels[id]?.level;
|
|
445
|
-
/**
|
|
446
|
-
* The `until YYYY-MM-DD` an allowlist reason may carry. A rule may require one — a coverage gap is
|
|
396
|
+
* The `until YYYY-MM-DD` an override's reason may carry. A rule may require one — a coverage gap is
|
|
447
397
|
* a debt with a date, not a permanent exemption — and report the entry once the date has passed.
|
|
448
398
|
* ISO dates compare as strings, so the caller needs no Date arithmetic.
|
|
449
399
|
*/
|
|
450
400
|
export const exemptionExpiry = (reason) => reason.match(/\buntil\s+(\d{4}-\d{2}-\d{2})\b/)?.[1] ?? null;
|
|
401
|
+
/** A real calendar date in `YYYY-MM-DD`: `2026-13-40` has the right shape and no such day (UTC round-trip). */
|
|
402
|
+
export const isRealDate = (iso) => {
|
|
403
|
+
if (!/^\d{4}-\d{2}-\d{2}$/.test(iso))
|
|
404
|
+
return false;
|
|
405
|
+
const [y, m, d] = iso.split('-').map(Number);
|
|
406
|
+
const date = new Date(Date.UTC(y, m - 1, d));
|
|
407
|
+
return date.getUTCFullYear() === y && date.getUTCMonth() === m - 1 && date.getUTCDate() === d;
|
|
408
|
+
};
|
|
409
|
+
/**
|
|
410
|
+
* The token after `until` when the author clearly meant a date but did not write a real
|
|
411
|
+
* `YYYY-MM-DD` one (`until 2026-1-5`, `until 05/01/2026`, `until 2026-10-311`) — otherwise null.
|
|
412
|
+
* `exemptionExpiry` cannot see such a token, so without this check the exemption would silently
|
|
413
|
+
* never expire. Only a digit-led token counts: prose (`until the redesign lands`) is a reason
|
|
414
|
+
* with no expiry. Trailing sentence punctuation (`until 2026-10-31.`) is not part of the token.
|
|
415
|
+
*/
|
|
416
|
+
export const malformedUntil = (reason) => {
|
|
417
|
+
const tok = reason.match(/\buntil\s+(\d\S*)/)?.[1]?.replace(/[.,;:!?)\]]+$/, '');
|
|
418
|
+
if (tok === undefined)
|
|
419
|
+
return null;
|
|
420
|
+
return isRealDate(tok) ? null : tok;
|
|
421
|
+
};
|
|
422
|
+
/** Today as `YYYY-MM-DD` (UTC) — the one place the engine reads the clock for a date. */
|
|
423
|
+
export const todayUtc = () => new Date().toISOString().slice(0, 10);
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The public surface of `@brighterly/lib-fe-codestyle`, in three groups:
|
|
3
|
+
* - config: `defineConfig` (and `ConfigError`) plus the types of `codestyle.config.ts`;
|
|
4
|
+
* - contract: the types a custom rule is written against — `Rule`, its contexts, its proofs;
|
|
5
|
+
* - helpers: the text and AST utilities custom rules share, and the package's rule names.
|
|
6
|
+
* Everything else — the runner, the registry, overrides, self-test, docs, migrate — is reached
|
|
7
|
+
* through the `codestyle` binary, never imported.
|
|
8
|
+
*/
|
|
9
|
+
export { ConfigError, defineConfig } from './config/define-config.js';
|
|
10
|
+
export type { CodestyleConfig, DocsEntry, OverrideEntry, ResolvedConfig, Step } from './config/schema.js';
|
|
11
|
+
export type { AnyRule, EffectiveOverride, ExternalRule, FileRule, Finding, FixtureCase, Level, OnceRule, OnceRuleContext, Proof, Rule, RuleContext, RuleName, SharedRuleSettings, WorldFixture, } from './engine/types.js';
|
|
12
|
+
export { eachLine, packageRuleNames, setting } from './rules/index.js';
|
|
13
|
+
export type { LineFn, PackageRuleName } from './rules/index.js';
|
|
14
|
+
export { exemptionExpiry, globToRegExp, lineOf, matchesAny, sliceObjectLiteral, stringKeysOf, stripCodeComments, walk, } from './engine/util.js';
|
|
15
|
+
export { allElements, attrOf, elementsByTag, findGeneratedTests, findInlineLiterals, findJsonParseCalls, findOptionLiterals, findSharedContainers, findSharedMutables, findStaticImports, findUnawaitedCalls, templateElements, } from './engine/ast.js';
|
|
16
|
+
export type { InlineLiteral, OptionLiteral, TemplateAttr, TemplateEl } from './engine/ast.js';
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The public surface of `@brighterly/lib-fe-codestyle`, in three groups:
|
|
3
|
+
* - config: `defineConfig` (and `ConfigError`) plus the types of `codestyle.config.ts`;
|
|
4
|
+
* - contract: the types a custom rule is written against — `Rule`, its contexts, its proofs;
|
|
5
|
+
* - helpers: the text and AST utilities custom rules share, and the package's rule names.
|
|
6
|
+
* Everything else — the runner, the registry, overrides, self-test, docs, migrate — is reached
|
|
7
|
+
* through the `codestyle` binary, never imported.
|
|
8
|
+
*/
|
|
9
|
+
// ── config ────────────────────────────────────────────────────────────────
|
|
10
|
+
export { ConfigError, defineConfig } from './config/define-config.js';
|
|
11
|
+
// ── helpers ───────────────────────────────────────────────────────────────
|
|
12
|
+
export { eachLine, packageRuleNames, setting } from './rules/index.js';
|
|
13
|
+
export { exemptionExpiry, globToRegExp, lineOf, matchesAny, sliceObjectLiteral, stringKeysOf, stripCodeComments, walk, } from './engine/util.js';
|
|
14
|
+
export { allElements, attrOf, elementsByTag, findGeneratedTests, findInlineLiterals, findJsonParseCalls, findOptionLiterals, findSharedContainers, findSharedMutables, findStaticImports, findUnawaitedCalls, templateElements, } from './engine/ast.js';
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Convention rules — preferences we chose, with no production incident behind them.
|
|
3
|
+
* Advisory by default (`warn`): nudged, not policed, and droppable by team consensus.
|
|
4
|
+
*
|
|
5
|
+
* `vueuse-first` was land-app's `C1`, and reads the same everywhere. `ui-kit-unique-names`,
|
|
6
|
+
* `scss-tokens` and `date-format-enum` are new, and came out of a real code review rather
|
|
7
|
+
* than an outage: a developer shipped a component that duplicated an existing UI-kit one,
|
|
8
|
+
* with colours that were not design tokens. Review caught it; nothing automated did,
|
|
9
|
+
* because the conventions existed only in reviewers\' heads.
|
|
10
|
+
*
|
|
11
|
+
* `types-layout`, `typography-source` and `script-attr-order` (2026-09-23) come from the
|
|
12
|
+
* org's Notion frontend-conventions doc and from mining 400 PR review comments across the
|
|
13
|
+
* four apps: entity-folder type organization, typography through the app's declared source,
|
|
14
|
+
* and one canonical <script> attribute order.
|
|
15
|
+
*
|
|
16
|
+
* They ship at `warn` so each consuming app can measure what they find before anyone
|
|
17
|
+
* decides to block on them. Promoting one to `error` is a major version bump.
|
|
18
|
+
*/
|
|
19
|
+
import type { Rule } from '../engine/types.js';
|
|
20
|
+
export declare const cRules: Rule[];
|