@brighterly/lib-fe-codestyle 1.0.0 → 1.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/README.md +234 -178
  2. package/dist/cli.d.ts +8 -0
  3. package/dist/cli.js +181 -0
  4. package/dist/config/define-config.d.ts +11 -0
  5. package/dist/config/define-config.js +33 -0
  6. package/dist/config/load-config.d.ts +15 -0
  7. package/dist/config/load-config.js +52 -0
  8. package/dist/config/schema.d.ts +58 -0
  9. package/dist/config/schema.js +139 -0
  10. package/dist/{invariants → engine}/ast.d.ts +8 -0
  11. package/dist/{invariants → engine}/ast.js +51 -0
  12. package/dist/engine/docs.d.ts +14 -0
  13. package/dist/engine/docs.js +72 -0
  14. package/dist/engine/meta.d.ts +32 -0
  15. package/dist/engine/meta.js +47 -0
  16. package/dist/engine/migrate.d.ts +22 -0
  17. package/dist/engine/migrate.js +175 -0
  18. package/dist/engine/overrides.d.ts +36 -0
  19. package/dist/engine/overrides.js +92 -0
  20. package/dist/engine/registry.d.ts +35 -0
  21. package/dist/engine/registry.js +100 -0
  22. package/dist/engine/report.d.ts +23 -0
  23. package/dist/engine/report.js +87 -0
  24. package/dist/engine/run.d.ts +57 -0
  25. package/dist/engine/run.js +353 -0
  26. package/dist/engine/self-test.d.ts +33 -0
  27. package/dist/engine/self-test.js +141 -0
  28. package/dist/engine/types.d.ts +232 -0
  29. package/dist/{invariants → engine}/types.js +2 -1
  30. package/dist/{invariants → engine}/util.d.ts +31 -39
  31. package/dist/{invariants → engine}/util.js +59 -86
  32. package/dist/index.d.ts +16 -0
  33. package/dist/index.js +14 -0
  34. package/dist/rules/convention.d.ts +20 -0
  35. package/dist/{invariants/rules → rules}/convention.js +103 -16
  36. package/dist/rules/index.d.ts +28 -0
  37. package/dist/{invariants/rules → rules}/index.js +40 -32
  38. package/dist/{invariants/rules → rules}/regression.d.ts +1 -1
  39. package/dist/{invariants/rules → rules}/regression.js +26 -6
  40. package/dist/{invariants/rules → rules}/shared.d.ts +1 -1
  41. package/dist/validate/index.d.ts +1 -1
  42. package/dist/validate/index.js +1 -1
  43. package/package.json +8 -13
  44. package/dist/invariants/index.d.ts +0 -7
  45. package/dist/invariants/index.js +0 -5
  46. package/dist/invariants/rules/convention.d.ts +0 -18
  47. package/dist/invariants/rules/fixtures.d.ts +0 -17
  48. package/dist/invariants/rules/fixtures.js +0 -89
  49. package/dist/invariants/rules/index.d.ts +0 -55
  50. package/dist/invariants/rules/linter.d.ts +0 -10
  51. package/dist/invariants/rules/linter.js +0 -9
  52. package/dist/invariants/run.d.ts +0 -53
  53. package/dist/invariants/run.js +0 -746
  54. package/dist/invariants/self-test.d.ts +0 -46
  55. package/dist/invariants/self-test.js +0 -84
  56. package/dist/invariants/types.d.ts +0 -222
  57. /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
- export {};
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 `invariant-ignore` escape hatch.
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, Levels, MetaRuleId } from './types.js';
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
- /** Recursively list repo-relative file paths under `dir`, POSIX separators. */
16
- export declare const walk: (root: string, dir: string) => string[];
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
- * Returns { suppressions: Map<lineNumber, Set<ruleId>>, unreasoned: [{ line, ids }] }
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
- * `// invariant-ignore: R1 — reason` above the code and a trailing comment work.
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 reference to a rule ID that no longer names a live rule.
71
+ * Every `codestyle-ignore` comment naming a rule that is not live.
68
72
  *
69
- * Three sinks dereference an ID and none of them used to validate it: an
70
- * allowlist key, a `levels` key, and an `invariant-ignore` comment. A stale
71
- * entry in any of them is inert and silent — which is what made "retire an ID,
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 and the config, so the
75
- * self-test can exercise it without touching the repo.
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
- * `invariant-ignore` comments that name a rule declared `noIgnore` (IG2). The
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
- * Every rule ID that currently exists, across all three vectors.
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 `invariant-ignore` escape hatch.
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
- /** Recursively list repo-relative file paths under `dir`, POSIX separators. */
60
- export const walk = (root, dir) => {
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
- // `(?:core\/)?` — shared-pack rules carry the core/ prefix (`core/R1`); app-local rules are bare (`R1`).
99
- const IGNORE_RE = /invariant-ignore:\s*((?:core\/)?[A-Z]{1,3}\d{1,3}(?:\s*,\s*(?:core\/)?[A-Z]{1,3}\d{1,3})*)\s*(?:—|–|--|-|:)?\s*(.*)$/;
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
- * Returns { suppressions: Map<lineNumber, Set<ruleId>>, unreasoned: [{ line, ids }] }
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
- * `// invariant-ignore: R1 — reason` above the code and a trailing comment work.
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
- const ids = (m[1] ?? '').split(',').map(s => s.trim());
119
- const reason = (m[2] || '').trim();
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 reference to a rule ID that no longer names a live rule.
334
+ * Every `codestyle-ignore` comment naming a rule that is not live.
326
335
  *
327
- * Three sinks dereference an ID and none of them used to validate it: an
328
- * allowlist key, a `levels` key, and an `invariant-ignore` comment. A stale
329
- * entry in any of them is inert and silent — which is what made "retire an ID,
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 and the config, so the
333
- * self-test can exercise it without touching the repo.
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, retired, allowlists, levels, ignores } = input;
337
- const configFile = input.configFile ?? 'invariants/config.mts';
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: `invariant-ignore names "${id}", but ${explain(id)} — this suppression is inert; drop it or name a live rule` });
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
- * `invariant-ignore` comments that name a rule declared `noIgnore` (IG2). The
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: `invariant-ignore names "${id}", a noIgnore rule — this suppression is inert and the attempt is reported`,
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
- * Every rule ID that currently exists, across all three vectors.
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);
@@ -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[];