@brighterly/lib-fe-codestyle 0.2.0 → 0.4.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 +39 -32
- package/dist/invariants/rules/convention.d.ts +2 -2
- package/dist/invariants/rules/convention.js +10 -10
- package/dist/invariants/rules/fixtures.d.ts +1 -1
- package/dist/invariants/rules/fixtures.js +11 -11
- package/dist/invariants/rules/index.d.ts +14 -14
- package/dist/invariants/rules/index.js +14 -14
- package/dist/invariants/rules/regression.js +11 -6
- package/dist/invariants/run.js +4 -1
- package/dist/invariants/types.d.ts +15 -11
- package/dist/invariants/util.js +20 -3
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -5,6 +5,10 @@ cs-app, app-tutor, app-admin.
|
|
|
5
5
|
|
|
6
6
|
It holds the **mechanisms**. Domain rules stay in each app.
|
|
7
7
|
|
|
8
|
+
**New here?** [docs/how-it-works.md](docs/how-it-works.md) — how a check reads real
|
|
9
|
+
code, what the gate can and cannot cover, what is already enforced, how to extend it,
|
|
10
|
+
and how to run it.
|
|
11
|
+
|
|
8
12
|
## Subpaths
|
|
9
13
|
|
|
10
14
|
| Import | What it is |
|
|
@@ -53,28 +57,30 @@ exported as data.
|
|
|
53
57
|
|
|
54
58
|
| Rule | Level | Checks |
|
|
55
59
|
|---|---|---|
|
|
56
|
-
| `
|
|
57
|
-
| `
|
|
58
|
-
| `
|
|
59
|
-
| `
|
|
60
|
-
| `
|
|
61
|
-
| `
|
|
62
|
-
| `
|
|
63
|
-
| `
|
|
64
|
-
| `
|
|
65
|
-
| `
|
|
66
|
-
| `
|
|
67
|
-
|
|
68
|
-
Level discipline: `error` is
|
|
69
|
-
|
|
70
|
-
(
|
|
71
|
-
|
|
60
|
+
| `core/R1` | warn* | bare `localStorage` / `sessionStorage` — the accessor itself throws on Safari ITP and in-app webviews |
|
|
61
|
+
| `core/R2` | error | SCSS rules inline in a Vue `<style>` block instead of a referenced `.scss` file |
|
|
62
|
+
| `core/R3` | warn* | `JSON.parse` with no enclosing `try`/`catch` — a corrupt entry breaks that path permanently |
|
|
63
|
+
| `core/R4` | error | `@font-face` declaring a font *stack* — an invalid descriptor, so the browser drops the face silently |
|
|
64
|
+
| `core/C1` | warn | hand-rolled browser API with a VueUse equivalent |
|
|
65
|
+
| `core/C2` | warn | a component duplicating an existing UI-kit component name |
|
|
66
|
+
| `core/C3` | warn | a literal colour in SCSS instead of a design token |
|
|
67
|
+
| `core/C4` | warn | a hand-rolled date format string, or `toISOString()` |
|
|
68
|
+
| `core/C5` | warn | a type file outside the `types/<entity>/{enums,interfaces,types}.ts` structure |
|
|
69
|
+
| `core/C6` | warn | a raw `font-family` / `font-size` / `font-weight` declaration instead of the app's typography source |
|
|
70
|
+
| `core/C7` | warn | `<script>` attribute order differing from the app's canonical (`setup lang` vs `lang setup`) |
|
|
71
|
+
|
|
72
|
+
Level discipline: `error` is for crash-class rules (`core/R*`); `warn` is for
|
|
73
|
+
conventions (`core/C*`). `core/R1`/`core/R3` (marked *) ship at `warn` **as an interim
|
|
74
|
+
state** (2026-09-25): their fleet backlog is real code nobody has bandwidth to migrate
|
|
75
|
+
and re-verify right now. Their target level is `error` — promote once the backlog
|
|
76
|
+
clears (a breaking-style bump). `core/R2`/`core/R4` stay `error`. A consumer's `levels`
|
|
77
|
+
ceiling is still never the tool for a backlog on an `error` rule — fix or allowlist.
|
|
72
78
|
Underscore-prefixed SCSS partials (`_extends.scss`, `_colors-*.scss`, …) are declaration
|
|
73
|
-
files by convention in every Brighterly app and are exempt from `
|
|
79
|
+
files by convention in every Brighterly app and are exempt from `core/C3`/`core/C6` wholesale.
|
|
74
80
|
|
|
75
|
-
`
|
|
81
|
+
`core/R*` were ported from land-app, where each was a production scar (as `R1`, `R7`, `R18`,
|
|
76
82
|
`R28` — renumbered sequentially when the prefix landed, pre-publish, while no released
|
|
77
|
-
version pinned the old tokens). `
|
|
83
|
+
version pinned the old tokens). `core/C*` are conventions with
|
|
78
84
|
no incident behind them *yet* — they ship at `warn` so a consuming app can measure what they
|
|
79
85
|
find before anyone decides to block on them. Promoting one to `error` is a **major** bump.
|
|
80
86
|
|
|
@@ -100,21 +106,22 @@ A shared rule must not hardcode a consuming app's paths. Each app declares them
|
|
|
100
106
|
// invariants/config.mts
|
|
101
107
|
export const settings = {
|
|
102
108
|
safeStorageModule: 'safeLocalStorage from @helpers/safe-local-storage',
|
|
103
|
-
safeStorageFile: 'app/utils/helpers/safe-local-storage.ts', //
|
|
109
|
+
safeStorageFile: 'app/utils/helpers/safe-local-storage.ts', // core/R1 auto-exempts the wrapper itself
|
|
104
110
|
safeJsonParse: 'parseStoredJson() from @helpers/safe-local-storage',
|
|
105
111
|
scssTokensFile: 'app/assets/scss/_colors.scss',
|
|
106
112
|
uiKitRoot: 'app/components/UI',
|
|
107
113
|
dateFormatModule: 'DateFormat from @ts-types/date/enums',
|
|
108
|
-
typesRoot: 'app/types', //
|
|
109
|
-
typographySource: 'the font() mixin from @scss/_mixins.scss', //
|
|
110
|
-
scriptAttrOrder: 'setup-first', //
|
|
114
|
+
typesRoot: 'app/types', // core/C5 (default shown)
|
|
115
|
+
typographySource: 'the font() mixin from @scss/_mixins.scss', // core/C6 — cs-app points this at its AppText component instead
|
|
116
|
+
scriptAttrOrder: 'setup-first', // core/C7 — or 'lang-first' (default 'setup-first')
|
|
117
|
+
selfTestFile: 'invariants/self-test.mts', // its ignore-strings are fixture DATA — skipped by IG0/IG1
|
|
111
118
|
}
|
|
112
119
|
```
|
|
113
120
|
|
|
114
121
|
A missing key degrades the rule's *message*, never its firing. A rule that went quiet
|
|
115
122
|
because a path was unset would be the exact silent failure this package exists to prevent.
|
|
116
|
-
Two keys shape behaviour rather than the message — `typesRoot` (where `
|
|
117
|
-
`scriptAttrOrder` (which direction `
|
|
123
|
+
Two keys shape behaviour rather than the message — `typesRoot` (where `core/C5` looks) and
|
|
124
|
+
`scriptAttrOrder` (which direction `core/C7` flags) — and both fall back to the documented
|
|
118
125
|
default above, so leaving them unset still never silences the rule.
|
|
119
126
|
|
|
120
127
|
## What consumers see in the IDE
|
|
@@ -124,7 +131,7 @@ requires opening this repo:
|
|
|
124
131
|
|
|
125
132
|
- **`sharedRuleIds`** (from `…/invariants/rules`) — the catalog: one constant per rule,
|
|
126
133
|
each JSDoc'd with level, what it checks, and the fix. Use them as `allowlists` /
|
|
127
|
-
`levels` keys (`[sharedRuleIds.
|
|
134
|
+
`levels` keys (`[sharedRuleIds.core/C6]: 'off'`) and hover for the contract. A test pins
|
|
128
135
|
the catalog to the shipped rules, so it cannot drift.
|
|
129
136
|
- **`SharedRuleSettings`** (from `…/invariants`) — annotate the app's `settings` const
|
|
130
137
|
with it to get completion plus the per-key contract, and a type error on an invalid
|
|
@@ -171,18 +178,18 @@ The ESLint config and its seven rules are already plain `.mjs` and ship as-is.
|
|
|
171
178
|
|
|
172
179
|
## Rule IDs
|
|
173
180
|
|
|
174
|
-
Shared rules carry
|
|
175
|
-
`R1`, `B2`. The prefix marks provenance —
|
|
181
|
+
Shared rules carry the **`core/` prefix**: `core/R1`, `core/C3`. App-local rules stay bare:
|
|
182
|
+
`R1`, `B2`. The prefix marks provenance — a `core/`-id is shipped by this package, never
|
|
176
183
|
defined, renumbered or recycled in a consumer — and it keeps the two number lines
|
|
177
184
|
independent: every app allocates its own `R1, R2, …` naturally, with no risk of
|
|
178
185
|
colliding with a rule the package ships later. `run()` still exits 2 on a duplicate,
|
|
179
186
|
so a collision is loud, not silent.
|
|
180
187
|
|
|
181
|
-
Within the package each family is numbered sequentially with no gaps (`
|
|
182
|
-
`
|
|
188
|
+
Within the package each family is numbered sequentially with no gaps (`core/R1`–`core/R4`,
|
|
189
|
+
`core/C1`–`core/C7`) — enforced by `test/invariants/rule-ids.test.ts`. The family letter keeps
|
|
183
190
|
meaning provenance — `R` regression scar, `B` business, `C` convention, `L`
|
|
184
|
-
linter-owned — not topic.
|
|
185
|
-
the
|
|
191
|
+
linter-owned — not topic. `core/` in a report means exactly one thing: this finding
|
|
192
|
+
comes from the library, and its numbering is the library's to own.
|
|
186
193
|
|
|
187
194
|
## Versioning is about blocking power
|
|
188
195
|
|
|
@@ -2,12 +2,12 @@
|
|
|
2
2
|
* Convention rules — preferences we chose, with no production incident behind them.
|
|
3
3
|
* Advisory by default (`warn`): nudged, not policed, and droppable by team consensus.
|
|
4
4
|
*
|
|
5
|
-
* `
|
|
5
|
+
* `core/C1` was land-app's `C1`, and reads the same everywhere. `core/C2`-`core/C4` are new, and came out of a
|
|
6
6
|
* real code review rather than an outage: a developer shipped a component that duplicated
|
|
7
7
|
* an existing UI-kit one, with colours that were not design tokens. Review caught it;
|
|
8
8
|
* nothing automated did, because the conventions existed only in reviewers\' heads.
|
|
9
9
|
*
|
|
10
|
-
* `
|
|
10
|
+
* `core/C5`-`core/C7` (2026-09-23) come from the org's Notion frontend-conventions doc and from
|
|
11
11
|
* mining 400 PR review comments across the four apps: entity-folder type organization,
|
|
12
12
|
* typography through the app's declared source, and one canonical <script> attribute order.
|
|
13
13
|
*
|
|
@@ -2,12 +2,12 @@
|
|
|
2
2
|
* Convention rules — preferences we chose, with no production incident behind them.
|
|
3
3
|
* Advisory by default (`warn`): nudged, not policed, and droppable by team consensus.
|
|
4
4
|
*
|
|
5
|
-
* `
|
|
5
|
+
* `core/C1` was land-app's `C1`, and reads the same everywhere. `core/C2`-`core/C4` are new, and came out of a
|
|
6
6
|
* real code review rather than an outage: a developer shipped a component that duplicated
|
|
7
7
|
* an existing UI-kit one, with colours that were not design tokens. Review caught it;
|
|
8
8
|
* nothing automated did, because the conventions existed only in reviewers\' heads.
|
|
9
9
|
*
|
|
10
|
-
* `
|
|
10
|
+
* `core/C5`-`core/C7` (2026-09-23) come from the org's Notion frontend-conventions doc and from
|
|
11
11
|
* mining 400 PR review comments across the four apps: entity-folder type organization,
|
|
12
12
|
* typography through the app's declared source, and one canonical <script> attribute order.
|
|
13
13
|
*
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
*/
|
|
17
17
|
import { eachLine, setting } from './shared.js';
|
|
18
18
|
const vueUseFirst = {
|
|
19
|
-
id: '
|
|
19
|
+
id: 'core/C1',
|
|
20
20
|
title: 'hand-rolled browser API with a VueUse equivalent',
|
|
21
21
|
level: 'warn',
|
|
22
22
|
why: 'VueUse handles the lifecycle, SSR and error paths we otherwise re-implement (and forget to tear down) per call site',
|
|
@@ -47,7 +47,7 @@ const vueUseFirst = {
|
|
|
47
47
|
},
|
|
48
48
|
};
|
|
49
49
|
const uiKitReuseFirst = {
|
|
50
|
-
id: '
|
|
50
|
+
id: 'core/C2',
|
|
51
51
|
title: 'component duplicates an existing UI-kit component name',
|
|
52
52
|
level: 'warn',
|
|
53
53
|
once: true,
|
|
@@ -81,7 +81,7 @@ const uiKitReuseFirst = {
|
|
|
81
81
|
},
|
|
82
82
|
};
|
|
83
83
|
const scssColourTokens = {
|
|
84
|
-
id: '
|
|
84
|
+
id: 'core/C3',
|
|
85
85
|
title: 'literal colour in SCSS instead of a design token',
|
|
86
86
|
level: 'warn',
|
|
87
87
|
why: 'a hardcoded colour is invisible to the token system — it survives theme changes and palette corrections silently, and only a side-by-side screenshot shows the drift',
|
|
@@ -108,7 +108,7 @@ const scssColourTokens = {
|
|
|
108
108
|
},
|
|
109
109
|
};
|
|
110
110
|
const dateFormatEnum = {
|
|
111
|
-
id: '
|
|
111
|
+
id: 'core/C4',
|
|
112
112
|
title: 'hand-rolled date format string instead of the shared enum',
|
|
113
113
|
level: 'warn',
|
|
114
114
|
why: 'a format string written at the call site makes the date\'s shape an accident rather than a contract; `date`-validated endpoints accept several shapes, so a wrong one ships silently and surfaces later as bad data',
|
|
@@ -134,7 +134,7 @@ const dateFormatEnum = {
|
|
|
134
134
|
},
|
|
135
135
|
};
|
|
136
136
|
const typesOrganization = {
|
|
137
|
-
id: '
|
|
137
|
+
id: 'core/C5',
|
|
138
138
|
title: 'type file outside the entity-folder structure',
|
|
139
139
|
level: 'warn',
|
|
140
140
|
once: true,
|
|
@@ -166,7 +166,7 @@ const typesOrganization = {
|
|
|
166
166
|
},
|
|
167
167
|
};
|
|
168
168
|
const typographySource = {
|
|
169
|
-
id: '
|
|
169
|
+
id: 'core/C6',
|
|
170
170
|
title: 'raw font declaration instead of the app\'s typography source',
|
|
171
171
|
level: 'warn',
|
|
172
172
|
why: 'a hand-written font-family/size/weight is invisible to the UI-kit type scale — it survives font and scale changes silently, and the page drifts off-brand one declaration at a time (Notion: UI та стилі)',
|
|
@@ -179,7 +179,7 @@ const typographySource = {
|
|
|
179
179
|
// Brighterly apps, decided 2026-09-24), plus non-underscore fonts/typography files.
|
|
180
180
|
if (/\/(?:_[^/]*|fonts|typography)\.scss$/.test(file))
|
|
181
181
|
return [];
|
|
182
|
-
// @font-face declares a face, not a usage —
|
|
182
|
+
// @font-face declares a face, not a usage — core/R4 polices those blocks.
|
|
183
183
|
const stripped = code.replace(/@font-face\s*\{[^}]*\}/g, m => m.replace(/[^\n]/g, ' '));
|
|
184
184
|
return eachLine(stripped, (line, n) => {
|
|
185
185
|
const m = /\bfont(?:-family|-size|-weight)?\s*:/.exec(line);
|
|
@@ -190,7 +190,7 @@ const typographySource = {
|
|
|
190
190
|
},
|
|
191
191
|
};
|
|
192
192
|
const scriptAttrOrder = {
|
|
193
|
-
id: '
|
|
193
|
+
id: 'core/C7',
|
|
194
194
|
title: 'script tag attribute order differs from the app canonical',
|
|
195
195
|
level: 'warn',
|
|
196
196
|
why: '<script setup lang="ts"> and <script lang="ts" setup> are compiler-equivalent, so nothing ever fails — the split just grows (723 vs 384 files in one app, Sep 2026) and every diff, codemod and grep has to handle both spellings',
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
import type { Fixture } from '../self-test.js';
|
|
11
11
|
export declare const commonFixtures: Record<string, Fixture>;
|
|
12
12
|
/**
|
|
13
|
-
* `
|
|
13
|
+
* `core/C2` and `core/C5` are `once` rules over the whole file list — a single-file fixture
|
|
14
14
|
* cannot express them, so they are exempt from the fixture requirement and covered by
|
|
15
15
|
* their own tests instead.
|
|
16
16
|
*/
|
|
@@ -8,47 +8,47 @@
|
|
|
8
8
|
* `bad` must produce at least one finding, `good` none.
|
|
9
9
|
*/
|
|
10
10
|
export const commonFixtures = {
|
|
11
|
-
'
|
|
11
|
+
'core/R1': {
|
|
12
12
|
file: 'app/a.ts',
|
|
13
13
|
bad: 'const t = localStorage.getItem("k")',
|
|
14
14
|
good: 'const t = safeLocalStorage.getItem("k")',
|
|
15
15
|
},
|
|
16
|
-
'
|
|
16
|
+
'core/R2': {
|
|
17
17
|
file: 'app/components/A.vue',
|
|
18
18
|
bad: '<template><div /></template>\n<style scoped lang="scss">.a { color: red; }</style>',
|
|
19
19
|
good: '<template><div /></template>\n<style scoped lang="scss" src="@scss/components/A.scss" />',
|
|
20
20
|
},
|
|
21
|
-
'
|
|
21
|
+
'core/R3': {
|
|
22
22
|
file: 'app/a.ts',
|
|
23
23
|
bad: 'const v = JSON.parse(raw)',
|
|
24
24
|
good: 'let v = null\ntry { v = JSON.parse(raw) } catch { v = null }',
|
|
25
25
|
},
|
|
26
|
-
'
|
|
26
|
+
'core/R4': {
|
|
27
27
|
file: 'app/assets/scss/fonts.scss',
|
|
28
28
|
bad: '@font-face { font-family: Rubik, sans-serif; src: url(a.woff2); }',
|
|
29
29
|
good: '@font-face { font-family: Rubik; src: url(a.woff2); }',
|
|
30
30
|
},
|
|
31
|
-
'
|
|
31
|
+
'core/C1': {
|
|
32
32
|
file: 'app/a.ts',
|
|
33
33
|
bad: 'window.addEventListener("resize", onResize)',
|
|
34
34
|
good: 'useEventListener(window, "resize", onResize)',
|
|
35
35
|
},
|
|
36
|
-
'
|
|
36
|
+
'core/C3': {
|
|
37
37
|
file: 'app/assets/scss/components/Card.scss',
|
|
38
38
|
bad: '.card { color: #1f2937; }',
|
|
39
39
|
good: '.card { color: $slate-800; }',
|
|
40
40
|
},
|
|
41
|
-
'
|
|
41
|
+
'core/C4': {
|
|
42
42
|
file: 'app/a.ts',
|
|
43
43
|
bad: "const d = dayjs().format('YYYY-MM-DD')",
|
|
44
44
|
good: 'const d = dayjs().format(DateFormat.DefaultDate)',
|
|
45
45
|
},
|
|
46
|
-
'
|
|
46
|
+
'core/C6': {
|
|
47
47
|
file: 'app/assets/scss/components/Card.scss',
|
|
48
48
|
bad: '.card { font-size: 14px; }',
|
|
49
49
|
good: '.card { @include font(14px, 20px); color: $slate-800; }',
|
|
50
50
|
},
|
|
51
|
-
'
|
|
51
|
+
'core/C7': {
|
|
52
52
|
// Two script tags in OPPOSITE orders, so the bad fixture violates whichever
|
|
53
53
|
// direction the consuming app declares in settings.scriptAttrOrder. The good
|
|
54
54
|
// fixture carries no lang attribute, so it is clean under either canonical.
|
|
@@ -58,8 +58,8 @@ export const commonFixtures = {
|
|
|
58
58
|
},
|
|
59
59
|
};
|
|
60
60
|
/**
|
|
61
|
-
* `
|
|
61
|
+
* `core/C2` and `core/C5` are `once` rules over the whole file list — a single-file fixture
|
|
62
62
|
* cannot express them, so they are exempt from the fixture requirement and covered by
|
|
63
63
|
* their own tests instead.
|
|
64
64
|
*/
|
|
65
|
-
export const commonSelfTestSkip = ['
|
|
65
|
+
export const commonSelfTestSkip = ['core/C2', 'core/C5'];
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
* C — convention: a preference we chose → team consensus
|
|
10
10
|
* L — linter: a built-in ESLint rule → eslint.config.mjs
|
|
11
11
|
*
|
|
12
|
-
* Shared rule ids carry the
|
|
12
|
+
* Shared rule ids carry the `core/` prefix (`core/R1`); app-local ids stay bare (`R1`).
|
|
13
13
|
* The prefix marks provenance and keeps the two number lines independent — and `run()`
|
|
14
14
|
* still fails fast on a duplicate rather than letting an allowlist silently suppress
|
|
15
15
|
* the wrong rule.
|
|
@@ -28,28 +28,28 @@ export declare const commonRules: Rule[];
|
|
|
28
28
|
* instead. A test pins this map to `commonRules`, so it cannot drift.
|
|
29
29
|
*/
|
|
30
30
|
export declare const sharedRuleIds: {
|
|
31
|
-
/** `
|
|
32
|
-
readonly
|
|
31
|
+
/** `warn` (target `error`, interim 2026-09-25) — bare `localStorage`/`sessionStorage` access. The accessor itself throws SecurityError on Safari ITP, Firefox strict mode and in-app webviews; `if (localStorage)` guards throw too. Fix: the app's safe storage wrapper (`settings.safeStorageModule`). */
|
|
32
|
+
readonly 'core/R1': "core/R1";
|
|
33
33
|
/** `error` — SCSS rules written inline in a Vue `<style>` block. Inline styles drift from the design tokens invisibly. Fix: a dedicated `.scss` file referenced via `<style scoped lang="scss" src="…" />`. */
|
|
34
|
-
readonly
|
|
35
|
-
/** `
|
|
36
|
-
readonly
|
|
34
|
+
readonly 'core/R2': "core/R2";
|
|
35
|
+
/** `warn` (target `error`, interim 2026-09-25) — `JSON.parse` with no enclosing `try`/`catch`. A corrupt stored value crashes that code path permanently for the visitor. Fix: the app's guarded parse helper (`settings.safeJsonParse`) or try/catch. */
|
|
36
|
+
readonly 'core/R3': "core/R3";
|
|
37
37
|
/** `error` — `@font-face` declaring a font *stack*. Inside `@font-face`, `font-family` names the face being defined; a comma-separated stack is invalid and the browser drops the face silently. Fix: single family name inside, the stack on the selectors that use it. */
|
|
38
|
-
readonly
|
|
38
|
+
readonly 'core/R4': "core/R4";
|
|
39
39
|
/** `warn` — hand-rolled browser API with a VueUse equivalent (`new IntersectionObserver`, `addEventListener`, `setInterval`, …). VueUse owns the lifecycle/SSR/error paths. Fix: the composable named in the message, imported from `@vueuse/core`. */
|
|
40
|
-
readonly
|
|
40
|
+
readonly 'core/C1': "core/C1";
|
|
41
41
|
/** `warn` — a component whose filename duplicates a UI-kit component (`settings.uiKitRoot`). A same-name second component splits the design system in two. Fix: import or extend the UI-kit one. */
|
|
42
|
-
readonly
|
|
42
|
+
readonly 'core/C2': "core/C2";
|
|
43
43
|
/** `warn` — a literal colour (`#hex`, numeric `rgba()`) in SCSS/Vue instead of a palette token. Hardcoded colours survive theme and palette changes silently. Fix: a token from `settings.scssTokensFile`. */
|
|
44
|
-
readonly
|
|
44
|
+
readonly 'core/C3': "core/C3";
|
|
45
45
|
/** `warn` — a hand-rolled date format string (`'YYYY-MM-DD'`) or `toISOString()`. The date's shape becomes an accident instead of a contract. Fix: the shared enum (`settings.dateFormatModule`). */
|
|
46
|
-
readonly
|
|
46
|
+
readonly 'core/C4': "core/C4";
|
|
47
47
|
/** `warn` — a type file outside `types/<entity>/{enums,interfaces,types}.ts` (root under `settings.typesRoot`). Root-level or mixed-kind type files accrete unrelated entities. `.d.ts` ambient files are exempt. */
|
|
48
|
-
readonly
|
|
48
|
+
readonly 'core/C5': "core/C5";
|
|
49
49
|
/** `warn` — a raw `font-family`/`font-size`/`font-weight` declaration. Hand-written fonts drift off the UI-kit type scale silently. Fix: the app's typography source (`settings.typographySource`). */
|
|
50
|
-
readonly
|
|
50
|
+
readonly 'core/C6': "core/C6";
|
|
51
51
|
/** `warn` — `<script>` attribute order differing from the app canonical (`settings.scriptAttrOrder`, default `setup-first`). The two spellings are compiler-equivalent, so the split only ever grows. */
|
|
52
|
-
readonly
|
|
52
|
+
readonly 'core/C7': "core/C7";
|
|
53
53
|
};
|
|
54
54
|
/** Union of every id the shared pack ships — for typing app config keys. */
|
|
55
55
|
export type SharedRuleId = keyof typeof sharedRuleIds;
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
* C — convention: a preference we chose → team consensus
|
|
10
10
|
* L — linter: a built-in ESLint rule → eslint.config.mjs
|
|
11
11
|
*
|
|
12
|
-
* Shared rule ids carry the
|
|
12
|
+
* Shared rule ids carry the `core/` prefix (`core/R1`); app-local ids stay bare (`R1`).
|
|
13
13
|
* The prefix marks provenance and keeps the two number lines independent — and `run()`
|
|
14
14
|
* still fails fast on a duplicate rather than letting an allowlist silently suppress
|
|
15
15
|
* the wrong rule.
|
|
@@ -28,26 +28,26 @@ export const commonRules = [...rRules, ...cRules];
|
|
|
28
28
|
* instead. A test pins this map to `commonRules`, so it cannot drift.
|
|
29
29
|
*/
|
|
30
30
|
export const sharedRuleIds = {
|
|
31
|
-
/** `
|
|
32
|
-
|
|
31
|
+
/** `warn` (target `error`, interim 2026-09-25) — bare `localStorage`/`sessionStorage` access. The accessor itself throws SecurityError on Safari ITP, Firefox strict mode and in-app webviews; `if (localStorage)` guards throw too. Fix: the app's safe storage wrapper (`settings.safeStorageModule`). */
|
|
32
|
+
'core/R1': 'core/R1',
|
|
33
33
|
/** `error` — SCSS rules written inline in a Vue `<style>` block. Inline styles drift from the design tokens invisibly. Fix: a dedicated `.scss` file referenced via `<style scoped lang="scss" src="…" />`. */
|
|
34
|
-
|
|
35
|
-
/** `
|
|
36
|
-
|
|
34
|
+
'core/R2': 'core/R2',
|
|
35
|
+
/** `warn` (target `error`, interim 2026-09-25) — `JSON.parse` with no enclosing `try`/`catch`. A corrupt stored value crashes that code path permanently for the visitor. Fix: the app's guarded parse helper (`settings.safeJsonParse`) or try/catch. */
|
|
36
|
+
'core/R3': 'core/R3',
|
|
37
37
|
/** `error` — `@font-face` declaring a font *stack*. Inside `@font-face`, `font-family` names the face being defined; a comma-separated stack is invalid and the browser drops the face silently. Fix: single family name inside, the stack on the selectors that use it. */
|
|
38
|
-
|
|
38
|
+
'core/R4': 'core/R4',
|
|
39
39
|
/** `warn` — hand-rolled browser API with a VueUse equivalent (`new IntersectionObserver`, `addEventListener`, `setInterval`, …). VueUse owns the lifecycle/SSR/error paths. Fix: the composable named in the message, imported from `@vueuse/core`. */
|
|
40
|
-
|
|
40
|
+
'core/C1': 'core/C1',
|
|
41
41
|
/** `warn` — a component whose filename duplicates a UI-kit component (`settings.uiKitRoot`). A same-name second component splits the design system in two. Fix: import or extend the UI-kit one. */
|
|
42
|
-
|
|
42
|
+
'core/C2': 'core/C2',
|
|
43
43
|
/** `warn` — a literal colour (`#hex`, numeric `rgba()`) in SCSS/Vue instead of a palette token. Hardcoded colours survive theme and palette changes silently. Fix: a token from `settings.scssTokensFile`. */
|
|
44
|
-
|
|
44
|
+
'core/C3': 'core/C3',
|
|
45
45
|
/** `warn` — a hand-rolled date format string (`'YYYY-MM-DD'`) or `toISOString()`. The date's shape becomes an accident instead of a contract. Fix: the shared enum (`settings.dateFormatModule`). */
|
|
46
|
-
|
|
46
|
+
'core/C4': 'core/C4',
|
|
47
47
|
/** `warn` — a type file outside `types/<entity>/{enums,interfaces,types}.ts` (root under `settings.typesRoot`). Root-level or mixed-kind type files accrete unrelated entities. `.d.ts` ambient files are exempt. */
|
|
48
|
-
|
|
48
|
+
'core/C5': 'core/C5',
|
|
49
49
|
/** `warn` — a raw `font-family`/`font-size`/`font-weight` declaration. Hand-written fonts drift off the UI-kit type scale silently. Fix: the app's typography source (`settings.typographySource`). */
|
|
50
|
-
|
|
50
|
+
'core/C6': 'core/C6',
|
|
51
51
|
/** `warn` — `<script>` attribute order differing from the app canonical (`settings.scriptAttrOrder`, default `setup-first`). The two spellings are compiler-equivalent, so the split only ever grows. */
|
|
52
|
-
|
|
52
|
+
'core/C7': 'core/C7',
|
|
53
53
|
};
|
|
@@ -12,9 +12,13 @@
|
|
|
12
12
|
import { findJsonParseCalls } from '../ast.js';
|
|
13
13
|
import { eachLine, setting } from './shared.js';
|
|
14
14
|
const bareStorage = {
|
|
15
|
-
id: '
|
|
15
|
+
id: 'core/R1',
|
|
16
16
|
title: 'bare localStorage / sessionStorage access',
|
|
17
|
-
level:
|
|
17
|
+
// Target level is `error` (crash-class). Shipped at `warn` since 2026-09-25: the fleet
|
|
18
|
+
// backlog (cs-app ~42 sites, main-app ~28) is real code the team has no bandwidth to
|
|
19
|
+
// migrate and re-verify right now, and a rushed storage migration is its own risk.
|
|
20
|
+
// Promote back to `error` once the backlog is cleared — that is a major-style bump.
|
|
21
|
+
level: 'warn',
|
|
18
22
|
why: 'the bare API throws SecurityError on Safari ITP, Firefox strict mode and in-app webviews (Instagram/Facebook/TikTok) — the `if (localStorage)` guard does not help, the property accessor itself throws',
|
|
19
23
|
fix: 'use the app\'s safe storage wrapper instead of the bare API',
|
|
20
24
|
scope: ['app/**/*.{ts,vue}'],
|
|
@@ -37,7 +41,7 @@ const bareStorage = {
|
|
|
37
41
|
},
|
|
38
42
|
};
|
|
39
43
|
const scssInStyleBlock = {
|
|
40
|
-
id: '
|
|
44
|
+
id: 'core/R2',
|
|
41
45
|
title: 'SCSS rules inside a Vue <style> block',
|
|
42
46
|
level: 'error',
|
|
43
47
|
why: 'inline style blocks drift from the design tokens and are invisible to the SCSS build — the component stops sharing the system without anything failing',
|
|
@@ -66,9 +70,10 @@ const scssInStyleBlock = {
|
|
|
66
70
|
},
|
|
67
71
|
};
|
|
68
72
|
const unguardedJsonParse = {
|
|
69
|
-
id: '
|
|
73
|
+
id: 'core/R3',
|
|
70
74
|
title: 'unguarded JSON.parse',
|
|
71
|
-
level
|
|
75
|
+
// Target level is `error` — see core/R1 above; same interim decision (2026-09-25).
|
|
76
|
+
level: 'warn',
|
|
72
77
|
async: true,
|
|
73
78
|
why: 'a truncated write or a hand-edited storage entry makes JSON.parse throw on every re-entry to that code path, until the visitor clears storage — the failure is permanent for them, not transient',
|
|
74
79
|
fix: 'parse through the app\'s guarded helper, or wrap the call in try/catch',
|
|
@@ -83,7 +88,7 @@ const unguardedJsonParse = {
|
|
|
83
88
|
},
|
|
84
89
|
};
|
|
85
90
|
const fontFaceStack = {
|
|
86
|
-
id: '
|
|
91
|
+
id: 'core/R4',
|
|
87
92
|
title: '@font-face with a font stack instead of one family name',
|
|
88
93
|
level: 'error',
|
|
89
94
|
why: 'inside @font-face, font-family names the face being defined; a comma-separated stack is an invalid descriptor and the browser drops the whole face silently. Every page then renders in the fallback font, nothing errors, and only a side-by-side screenshot shows it',
|
package/dist/invariants/run.js
CHANGED
|
@@ -228,7 +228,9 @@ export const run = async (options) => {
|
|
|
228
228
|
const ignoreCache = new Map();
|
|
229
229
|
const ignoresFor = (file) => {
|
|
230
230
|
if (!ignoreCache.has(file)) {
|
|
231
|
-
|
|
231
|
+
// The consumer's self-test file holds literal `invariant-ignore:` strings as
|
|
232
|
+
// fixture DATA — parsing them would report the fixtures themselves (IG0/IG1).
|
|
233
|
+
const src = file === settings.selfTestFile ? null : read(file);
|
|
232
234
|
ignoreCache.set(file, src === null ? { suppressions: new Map(), unreasoned: [], comments: [] } : parseIgnores(src));
|
|
233
235
|
}
|
|
234
236
|
return ignoreCache.get(file);
|
|
@@ -348,6 +350,7 @@ export const run = async (options) => {
|
|
|
348
350
|
live, retired, allowlists, levels, ignores,
|
|
349
351
|
configFile,
|
|
350
352
|
configSource: read(configFile) ?? undefined,
|
|
353
|
+
pathExists: p => read(p) !== null,
|
|
351
354
|
})) {
|
|
352
355
|
findings.push({ ruleId: 'IG1', level: 'error', ...v });
|
|
353
356
|
}
|
|
@@ -17,8 +17,8 @@ export type Level = 'error' | 'warn' | 'off';
|
|
|
17
17
|
* IDs are load-bearing (`--rule=`, `invariant-ignore:`, allowlists, levels) —
|
|
18
18
|
* retire one, never recycle it.
|
|
19
19
|
*/
|
|
20
|
-
/** App-local ids are bare; shared-pack ids carry the
|
|
21
|
-
export type RuleId = `${'' | '
|
|
20
|
+
/** App-local ids are bare; shared-pack ids carry the `core/` prefix (`core/R1`). */
|
|
21
|
+
export type RuleId = `${'' | 'core/'}${'R' | 'B' | 'C' | 'L'}${number}`;
|
|
22
22
|
/**
|
|
23
23
|
* Meta rules. They police the escape hatch rather than the app, so they have no
|
|
24
24
|
* family letter — but they are addressable exactly like any other rule
|
|
@@ -56,24 +56,26 @@ export interface ReportedFinding extends Finding {
|
|
|
56
56
|
* the documented default), never its firing.
|
|
57
57
|
*/
|
|
58
58
|
export interface SharedRuleSettings {
|
|
59
|
-
/** `
|
|
59
|
+
/** `core/R1`/`core/R3` fix messages: how this app names its safe storage wrapper, e.g. 'safeLocalStorage from @helpers/safe-local-storage'. */
|
|
60
60
|
safeStorageModule?: string;
|
|
61
|
-
/** `
|
|
61
|
+
/** `core/R1`: 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'. */
|
|
62
62
|
safeStorageFile?: string;
|
|
63
|
-
/** `
|
|
63
|
+
/** `core/R3` fix message: the guarded parse helper, e.g. 'parseStoredJson() from @helpers/safe-local-storage'. */
|
|
64
64
|
safeJsonParse?: string;
|
|
65
|
-
/** `
|
|
65
|
+
/** `core/C3`: the palette file that legitimately DEFINES colour literals, e.g. 'app/assets/scss/_colors.scss'. */
|
|
66
66
|
scssTokensFile?: string;
|
|
67
|
-
/** `
|
|
67
|
+
/** `core/C2`: root of the UI-kit tree whose component names must not be duplicated. Default 'app/components/UI'. */
|
|
68
68
|
uiKitRoot?: string;
|
|
69
|
-
/** `
|
|
69
|
+
/** `core/C4` fix message: the shared date-format enum, e.g. 'DateFormat from @ts-types/date/enums'. */
|
|
70
70
|
dateFormatModule?: string;
|
|
71
|
-
/** `
|
|
71
|
+
/** `core/C5`: where entity type folders live. Default 'app/types'. */
|
|
72
72
|
typesRoot?: string;
|
|
73
|
-
/** `
|
|
73
|
+
/** `core/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'). */
|
|
74
74
|
typographySource?: string;
|
|
75
|
-
/** `
|
|
75
|
+
/** `core/C7`: which `<script>` attribute order is canonical in this app. Default 'setup-first' (`<script setup lang="ts">`). */
|
|
76
76
|
scriptAttrOrder?: 'setup-first' | 'lang-first';
|
|
77
|
+
/** The consumer file whose `invariant-ignore:` strings are self-test FIXTURE DATA, not real suppressions — the engine skips ignore-parsing it entirely, replacing per-file IG0/IG1 allowlist boilerplate. E.g. 'invariants/self-test.mts'. */
|
|
78
|
+
selfTestFile?: string;
|
|
77
79
|
}
|
|
78
80
|
/** What a rule's `check` receives. */
|
|
79
81
|
export interface RuleContext {
|
|
@@ -164,6 +166,8 @@ export interface DanglingRefInput {
|
|
|
164
166
|
configFile?: string;
|
|
165
167
|
/** Contents of `configFile`, so a stale key can be reported on its own line. */
|
|
166
168
|
configSource?: string;
|
|
169
|
+
/** Answers "does this repo-relative file exist?" — enables the allowlist-path staleness check. */
|
|
170
|
+
pathExists?: (path: string) => boolean;
|
|
167
171
|
}
|
|
168
172
|
/** An invariant a built-in ESLint rule already covers. */
|
|
169
173
|
export interface LinterOwnedRule {
|
package/dist/invariants/util.js
CHANGED
|
@@ -95,8 +95,8 @@ export const createReader = (root) => {
|
|
|
95
95
|
};
|
|
96
96
|
};
|
|
97
97
|
export const toRelative = (root, p) => relative(root, resolve(root, p)).split(sep).join('/');
|
|
98
|
-
// `
|
|
99
|
-
const IGNORE_RE = /invariant-ignore:\s*(
|
|
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*(.*)$/;
|
|
100
100
|
/**
|
|
101
101
|
* Parse every `invariant-ignore:` comment in a file.
|
|
102
102
|
* Returns { suppressions: Map<lineNumber, Set<ruleId>>, unreasoned: [{ line, ids }] }
|
|
@@ -198,7 +198,9 @@ export const findDanglingRuleRefs = (input) => {
|
|
|
198
198
|
const start = src.indexOf(block);
|
|
199
199
|
if (start === -1)
|
|
200
200
|
return 1;
|
|
201
|
-
|
|
201
|
+
// Ids may contain `/` (core/R1) and appear as quoted keys — escape and allow quotes.
|
|
202
|
+
const escaped = id.replace(/[.*+?^${}()|[\]\\/]/g, '\\$&');
|
|
203
|
+
const rel = src.slice(start).search(new RegExp(`^\\s*(?:'|")?${escaped}(?:'|")?\\s*:`, 'm'));
|
|
202
204
|
return rel === -1 ? 1 : lineOf(src, start + rel);
|
|
203
205
|
};
|
|
204
206
|
const explain = (id) => retired[id] ? `${id} was retired — ${retired[id]}` : `${id} does not name any rule`;
|
|
@@ -212,6 +214,21 @@ export const findDanglingRuleRefs = (input) => {
|
|
|
212
214
|
continue;
|
|
213
215
|
out.push({ file: configFile, line: lineOfKey('levels', id), message: `levels names "${id}", but ${explain(id)} — the severity override does nothing; drop it` });
|
|
214
216
|
}
|
|
217
|
+
// Allowlist PATHS had no staleness guard: an entry whose file is gone (or renamed)
|
|
218
|
+
// is inert and silent. Found live in app-funnels — a dead BtnAuthGoogle.vue entry
|
|
219
|
+
// survived a fork divergence (2026-09-25). Checked only when the caller can answer
|
|
220
|
+
// existence, so the self-test can still exercise this function without a filesystem.
|
|
221
|
+
if (input.pathExists) {
|
|
222
|
+
for (const [id, entries] of Object.entries(allowlists)) {
|
|
223
|
+
if (!live.has(id))
|
|
224
|
+
continue; // the dead-id finding above already covers the whole entry
|
|
225
|
+
for (const path of Object.keys(entries ?? {})) {
|
|
226
|
+
if (input.pathExists(path))
|
|
227
|
+
continue;
|
|
228
|
+
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` });
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
}
|
|
215
232
|
for (const { file, line, ids } of ignores) {
|
|
216
233
|
for (const id of ids) {
|
|
217
234
|
if (live.has(id))
|
package/package.json
CHANGED