@brighterly/lib-fe-codestyle 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +224 -0
- package/dist/invariants/ast.d.ts +72 -0
- package/dist/invariants/ast.js +195 -0
- package/dist/invariants/index.d.ts +7 -0
- package/dist/invariants/index.js +5 -0
- package/dist/invariants/rules/convention.d.ts +18 -0
- package/dist/invariants/rules/convention.js +226 -0
- package/dist/invariants/rules/fixtures.d.ts +17 -0
- package/dist/invariants/rules/fixtures.js +65 -0
- package/dist/invariants/rules/index.d.ts +55 -0
- package/dist/invariants/rules/index.js +53 -0
- package/dist/invariants/rules/linter.d.ts +10 -0
- package/dist/invariants/rules/linter.js +9 -0
- package/dist/invariants/rules/regression.d.ts +13 -0
- package/dist/invariants/rules/regression.js +109 -0
- package/dist/invariants/rules/shared.d.ts +18 -0
- package/dist/invariants/rules/shared.js +25 -0
- package/dist/invariants/run.d.ts +56 -0
- package/dist/invariants/run.js +553 -0
- package/dist/invariants/self-test.d.ts +38 -0
- package/dist/invariants/self-test.js +57 -0
- package/dist/invariants/types.d.ts +192 -0
- package/dist/invariants/types.js +9 -0
- package/dist/invariants/util.d.ts +113 -0
- package/dist/invariants/util.js +269 -0
- package/dist/validate/index.d.ts +36 -0
- package/dist/validate/index.js +41 -0
- package/eslint/index.mjs +187 -0
- package/eslint/rules/async-components-after-macros.mjs +63 -0
- package/eslint/rules/no-import-group-labels.mjs +72 -0
- package/eslint/rules/on-prefixed-callbacks.mjs +53 -0
- package/eslint/rules/optional-callback-chaining.mjs +78 -0
- package/eslint/rules/padding-after-macros.mjs +70 -0
- package/eslint/rules/ref-prefix-prop-mirrors.mjs +58 -0
- package/eslint/rules/require-component-name.mjs +68 -0
- package/package.json +64 -0
|
@@ -0,0 +1,226 @@
|
|
|
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
|
+
* `_C1` was land-app's `C1`, and reads the same everywhere. `_C2`-`_C4` are new, and came out of a
|
|
6
|
+
* real code review rather than an outage: a developer shipped a component that duplicated
|
|
7
|
+
* an existing UI-kit one, with colours that were not design tokens. Review caught it;
|
|
8
|
+
* nothing automated did, because the conventions existed only in reviewers\' heads.
|
|
9
|
+
*
|
|
10
|
+
* `_C5`-`_C7` (2026-09-23) come from the org's Notion frontend-conventions doc and from
|
|
11
|
+
* mining 400 PR review comments across the four apps: entity-folder type organization,
|
|
12
|
+
* typography through the app's declared source, and one canonical <script> attribute order.
|
|
13
|
+
*
|
|
14
|
+
* They ship at `warn` so each consuming app can measure what they find before anyone
|
|
15
|
+
* decides to block on them. Promoting one to `error` is a major version bump.
|
|
16
|
+
*/
|
|
17
|
+
import { eachLine, setting } from './shared.js';
|
|
18
|
+
const vueUseFirst = {
|
|
19
|
+
id: '_C1',
|
|
20
|
+
title: 'hand-rolled browser API with a VueUse equivalent',
|
|
21
|
+
level: 'warn',
|
|
22
|
+
why: 'VueUse handles the lifecycle, SSR and error paths we otherwise re-implement (and forget to tear down) per call site',
|
|
23
|
+
fix: 'use the VueUse composable named in the message, imported explicitly from @vueuse/core',
|
|
24
|
+
scope: ['app/**/*.{ts,vue}'],
|
|
25
|
+
exclude: ['app/tests/**', 'app/test/**'],
|
|
26
|
+
check: ({ file, code }) => {
|
|
27
|
+
const map = [
|
|
28
|
+
[/new\s+IntersectionObserver\s*\(/, 'useIntersectionObserver'],
|
|
29
|
+
[/new\s+ResizeObserver\s*\(/, 'useResizeObserver'],
|
|
30
|
+
[/\bdocument\s*\.\s*title\s*=/, 'useTitle'],
|
|
31
|
+
[/\bwindow\s*\.\s*matchMedia\s*\(/, 'useMediaQuery'],
|
|
32
|
+
[/\b(?:window|document)\s*\.\s*addEventListener\s*\(/, 'useEventListener'],
|
|
33
|
+
[/\bnavigator\s*\.\s*clipboard\b/, 'useClipboard'],
|
|
34
|
+
// Nuxt does not auto-import VueUse's `useStorage` — Nitro owns that name.
|
|
35
|
+
[/\buseStorage\s*\(/, 'useLocalStorage / useSessionStorage (explicitly imported from @vueuse/core)'],
|
|
36
|
+
[/\bwindow\s*\.\s*inner(?:Width|Height)\b/, 'useWindowSize'],
|
|
37
|
+
[/\b(?:window\s*\.\s*)?setInterval\s*\(/, 'useIntervalFn'],
|
|
38
|
+
[/\b(?:window\s*\.\s*)?setTimeout\s*\(/, 'useTimeoutFn'],
|
|
39
|
+
];
|
|
40
|
+
return eachLine(code, (line, n) => {
|
|
41
|
+
for (const [re, fn] of map) {
|
|
42
|
+
if (re.test(line))
|
|
43
|
+
return { file, line: n, message: `hand-rolled browser API — use ${fn} from @vueuse/core` };
|
|
44
|
+
}
|
|
45
|
+
return null;
|
|
46
|
+
});
|
|
47
|
+
},
|
|
48
|
+
};
|
|
49
|
+
const uiKitReuseFirst = {
|
|
50
|
+
id: '_C2',
|
|
51
|
+
title: 'component duplicates an existing UI-kit component name',
|
|
52
|
+
level: 'warn',
|
|
53
|
+
once: true,
|
|
54
|
+
why: 'a second component with the same name as a UI-kit one splits the design system in two — the new one drifts on colours, spacing and a11y, and nothing fails. Reviewers catch it by memory or not at all',
|
|
55
|
+
fix: 'import the UI-kit component, or extend it. A new one is justified only when the existing cannot express the case without a breaking prop change — say so in the PR body',
|
|
56
|
+
scope: ['app/components/**/*.vue'],
|
|
57
|
+
check: ({ allFiles, settings }) => {
|
|
58
|
+
const files = allFiles ?? [];
|
|
59
|
+
const uiKitRoot = settings?.uiKitRoot ?? 'app/components/UI';
|
|
60
|
+
const basename = (f) => f.slice(f.lastIndexOf('/') + 1).replace(/\.vue$/, '');
|
|
61
|
+
const kit = new Map();
|
|
62
|
+
for (const f of files) {
|
|
63
|
+
if (!f.startsWith(`${uiKitRoot}/`) || !f.endsWith('.vue'))
|
|
64
|
+
continue;
|
|
65
|
+
kit.set(basename(f).toLowerCase(), f);
|
|
66
|
+
}
|
|
67
|
+
if (kit.size === 0)
|
|
68
|
+
return [];
|
|
69
|
+
const out = [];
|
|
70
|
+
for (const f of files) {
|
|
71
|
+
if (!f.endsWith('.vue') || f.startsWith(`${uiKitRoot}/`))
|
|
72
|
+
continue;
|
|
73
|
+
if (!f.startsWith('app/components/'))
|
|
74
|
+
continue;
|
|
75
|
+
const hit = kit.get(basename(f).toLowerCase());
|
|
76
|
+
if (!hit)
|
|
77
|
+
continue;
|
|
78
|
+
out.push({ file: f, line: 1, message: `duplicates the UI-kit component of the same name (${hit}) — import or extend it instead of re-creating it` });
|
|
79
|
+
}
|
|
80
|
+
return out;
|
|
81
|
+
},
|
|
82
|
+
};
|
|
83
|
+
const scssColourTokens = {
|
|
84
|
+
id: '_C3',
|
|
85
|
+
title: 'literal colour in SCSS instead of a design token',
|
|
86
|
+
level: 'warn',
|
|
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',
|
|
88
|
+
fix: 'use a token variable from the palette file; if the colour is genuinely new, add it to the palette first',
|
|
89
|
+
scope: ['app/assets/scss/**/*.scss', 'app/components/**/*.vue'],
|
|
90
|
+
check: ({ file, code, settings }) => {
|
|
91
|
+
// The palette files DEFINE the literals — they are the one place they belong.
|
|
92
|
+
const palette = setting({ settings }, 'scssTokensFile', 'app/assets/scss/_colors.scss');
|
|
93
|
+
const paletteFiles = [palette, 'app/assets/scss/_vars.scss', 'app/assets/scss/_colors.scss'];
|
|
94
|
+
if (paletteFiles.some(p => file === p))
|
|
95
|
+
return [];
|
|
96
|
+
if (/\/_(?:colors|vars|palette|tokens)\.scss$/.test(file))
|
|
97
|
+
return [];
|
|
98
|
+
return eachLine(code, (line, n) => {
|
|
99
|
+
// `rgba($token, .5)` is correct usage — only a NUMERIC first argument is a literal.
|
|
100
|
+
const hex = /#[0-9a-fA-F]{3,8}\b/.exec(line);
|
|
101
|
+
const numericRgb = /\brgba?\(\s*\d[^)]*\)/.exec(line);
|
|
102
|
+
if (!hex && !numericRgb)
|
|
103
|
+
return null;
|
|
104
|
+
const what = hex ? hex[0] : (numericRgb?.[0] ?? '').replace(/\s+/g, ' ');
|
|
105
|
+
return { file, line: n, message: `literal colour ${what} — use a token from ${palette}` };
|
|
106
|
+
});
|
|
107
|
+
},
|
|
108
|
+
};
|
|
109
|
+
const dateFormatEnum = {
|
|
110
|
+
id: '_C4',
|
|
111
|
+
title: 'hand-rolled date format string instead of the shared enum',
|
|
112
|
+
level: 'warn',
|
|
113
|
+
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',
|
|
114
|
+
fix: 'format through the shared DateFormat enum; never hand-roll a format string or use toISOString()',
|
|
115
|
+
scope: ['app/**/*.{ts,vue}'],
|
|
116
|
+
exclude: ['app/tests/**', 'app/test/**'],
|
|
117
|
+
check: ({ file, code, settings }) => {
|
|
118
|
+
const dateModule = setting({ settings }, 'dateFormatModule', 'the shared DateFormat enum');
|
|
119
|
+
// The module that DEFINES the formats is the one place the literals belong.
|
|
120
|
+
if (/date[-/]?format|\/date\.ts$|enums\.ts$/i.test(file))
|
|
121
|
+
return [];
|
|
122
|
+
// A date pattern: only Y/M/D/H/m/s and separators, and carrying a real token.
|
|
123
|
+
const FORMAT = /(['"`])((?=[^'"`]*(?:YYYY|DD|MM|HH|mm|ss))[YMDHhmsAaZ\s:/.,-]{5,})\1/;
|
|
124
|
+
return eachLine(code, (line, n) => {
|
|
125
|
+
if (/\.toISOString\s*\(/.test(line)) {
|
|
126
|
+
return { file, line: n, message: `toISOString() is always UTC and always carries a time — it shifts the calendar day; format through ${dateModule}` };
|
|
127
|
+
}
|
|
128
|
+
const m = FORMAT.exec(line);
|
|
129
|
+
if (!m)
|
|
130
|
+
return null;
|
|
131
|
+
return { file, line: n, message: `hand-rolled date format ${m[1]}${m[2]}${m[1]} — use a member of ${dateModule}` };
|
|
132
|
+
});
|
|
133
|
+
},
|
|
134
|
+
};
|
|
135
|
+
const typesOrganization = {
|
|
136
|
+
id: '_C5',
|
|
137
|
+
title: 'type file outside the entity-folder structure',
|
|
138
|
+
level: 'warn',
|
|
139
|
+
once: true,
|
|
140
|
+
why: 'types grouped by business entity are findable and own their imports; a root-level types.ts or a mixed-kind file accretes unrelated entities until nobody can say what depends on it (Notion: Types Organization, §1/§5)',
|
|
141
|
+
fix: 'move every type into <typesRoot>/<entity>/{enums,interfaces,types}.ts — enums in enums.ts, interfaces in interfaces.ts, aliases/unions in types.ts',
|
|
142
|
+
scope: ['app/types/**/*.ts'],
|
|
143
|
+
check: ({ allFiles, settings }) => {
|
|
144
|
+
const files = allFiles ?? [];
|
|
145
|
+
const root = settings?.typesRoot ?? 'app/types';
|
|
146
|
+
const kindFiles = new Set(['enums.ts', 'interfaces.ts', 'types.ts']);
|
|
147
|
+
const out = [];
|
|
148
|
+
for (const f of files) {
|
|
149
|
+
if (!f.startsWith(`${root}/`) || !f.endsWith('.ts'))
|
|
150
|
+
continue;
|
|
151
|
+
// Ambient declaration files augment globals; they are not entity types.
|
|
152
|
+
if (f.endsWith('.d.ts'))
|
|
153
|
+
continue;
|
|
154
|
+
const parts = f.slice(root.length + 1).split('/');
|
|
155
|
+
if (parts.length === 1) {
|
|
156
|
+
out.push({ file: f, line: 1, message: `type file directly under ${root} — every type belongs to ${root}/<entity>/{enums,interfaces,types}.ts` });
|
|
157
|
+
continue;
|
|
158
|
+
}
|
|
159
|
+
const base = parts[parts.length - 1] ?? '';
|
|
160
|
+
if (!kindFiles.has(base)) {
|
|
161
|
+
out.push({ file: f, line: 1, message: `${base} is not a kind file — split an entity's types into enums.ts / interfaces.ts / types.ts (a sub-entity gets its own folder)` });
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
return out;
|
|
165
|
+
},
|
|
166
|
+
};
|
|
167
|
+
const typographySource = {
|
|
168
|
+
id: '_C6',
|
|
169
|
+
title: 'raw font declaration instead of the app\'s typography source',
|
|
170
|
+
level: 'warn',
|
|
171
|
+
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 та стилі)',
|
|
172
|
+
fix: 'go through the app\'s typography source (see settings.typographySource — a mixin in some apps, a Text/Heading component in others); if the style is genuinely new, add it there first',
|
|
173
|
+
scope: ['app/assets/scss/**/*.scss', 'app/components/**/*.vue'],
|
|
174
|
+
check: ({ file, code, settings }) => {
|
|
175
|
+
const source = setting({ settings }, 'typographySource', 'the app\'s shared typography mixins or text components');
|
|
176
|
+
// The files that DEFINE the typography are the one place raw declarations belong.
|
|
177
|
+
if (/\/_?(?:mixins|vars|fonts|typography|colors)\.scss$/.test(file))
|
|
178
|
+
return [];
|
|
179
|
+
// @font-face declares a face, not a usage — _R4 polices those blocks.
|
|
180
|
+
const stripped = code.replace(/@font-face\s*\{[^}]*\}/g, m => m.replace(/[^\n]/g, ' '));
|
|
181
|
+
return eachLine(stripped, (line, n) => {
|
|
182
|
+
const m = /\bfont(?:-family|-size|-weight)?\s*:/.exec(line);
|
|
183
|
+
if (!m)
|
|
184
|
+
return null;
|
|
185
|
+
return { file, line: n, message: `raw ${m[0].replace(/\s*:$/, '')} declaration — use ${source}` };
|
|
186
|
+
});
|
|
187
|
+
},
|
|
188
|
+
};
|
|
189
|
+
const scriptAttrOrder = {
|
|
190
|
+
id: '_C7',
|
|
191
|
+
title: 'script tag attribute order differs from the app canonical',
|
|
192
|
+
level: 'warn',
|
|
193
|
+
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',
|
|
194
|
+
fix: 'use the app\'s canonical order everywhere — settings.scriptAttrOrder: \'setup-first\' (default) or \'lang-first\'',
|
|
195
|
+
scope: ['app/**/*.vue'],
|
|
196
|
+
check: ({ file, source, settings }) => {
|
|
197
|
+
const canonical = settings?.scriptAttrOrder === 'lang-first' ? 'lang-first' : 'setup-first';
|
|
198
|
+
const spell = (order) => (order === 'lang-first' ? '<script lang="ts" setup>' : '<script setup lang="ts">');
|
|
199
|
+
const out = [];
|
|
200
|
+
for (const m of source.matchAll(/<script\b[^>]*>/g)) {
|
|
201
|
+
const tag = m[0];
|
|
202
|
+
const setupIdx = tag.search(/\bsetup\b/);
|
|
203
|
+
const langIdx = tag.search(/\blang\s*=/);
|
|
204
|
+
if (setupIdx === -1 || langIdx === -1)
|
|
205
|
+
continue;
|
|
206
|
+
const actual = setupIdx < langIdx ? 'setup-first' : 'lang-first';
|
|
207
|
+
if (actual === canonical)
|
|
208
|
+
continue;
|
|
209
|
+
out.push({
|
|
210
|
+
file,
|
|
211
|
+
line: source.slice(0, m.index).split('\n').length,
|
|
212
|
+
message: `${spell(actual)} — this app's canonical order is ${spell(canonical)}`,
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
return out;
|
|
216
|
+
},
|
|
217
|
+
};
|
|
218
|
+
export const cRules = [
|
|
219
|
+
vueUseFirst,
|
|
220
|
+
uiKitReuseFirst,
|
|
221
|
+
scssColourTokens,
|
|
222
|
+
dateFormatEnum,
|
|
223
|
+
typesOrganization,
|
|
224
|
+
typographySource,
|
|
225
|
+
scriptAttrOrder,
|
|
226
|
+
];
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fixtures for the common rule pack — shipped, not test-only.
|
|
3
|
+
*
|
|
4
|
+
* Fixtures travel with their rules. A consuming app runs `--self-test` over the rules it
|
|
5
|
+
* actually loads, so if the pack shipped rules without fixtures every consumer would report
|
|
6
|
+
* "no self-test fixture" for rules it did not write and cannot fix.
|
|
7
|
+
*
|
|
8
|
+
* `bad` must produce at least one finding, `good` none.
|
|
9
|
+
*/
|
|
10
|
+
import type { Fixture } from '../self-test.js';
|
|
11
|
+
export declare const commonFixtures: Record<string, Fixture>;
|
|
12
|
+
/**
|
|
13
|
+
* `_C2` and `_C5` are `once` rules over the whole file list — a single-file fixture
|
|
14
|
+
* cannot express them, so they are exempt from the fixture requirement and covered by
|
|
15
|
+
* their own tests instead.
|
|
16
|
+
*/
|
|
17
|
+
export declare const commonSelfTestSkip: string[];
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fixtures for the common rule pack — shipped, not test-only.
|
|
3
|
+
*
|
|
4
|
+
* Fixtures travel with their rules. A consuming app runs `--self-test` over the rules it
|
|
5
|
+
* actually loads, so if the pack shipped rules without fixtures every consumer would report
|
|
6
|
+
* "no self-test fixture" for rules it did not write and cannot fix.
|
|
7
|
+
*
|
|
8
|
+
* `bad` must produce at least one finding, `good` none.
|
|
9
|
+
*/
|
|
10
|
+
export const commonFixtures = {
|
|
11
|
+
'_R1': {
|
|
12
|
+
file: 'app/a.ts',
|
|
13
|
+
bad: 'const t = localStorage.getItem("k")',
|
|
14
|
+
good: 'const t = safeLocalStorage.getItem("k")',
|
|
15
|
+
},
|
|
16
|
+
'_R2': {
|
|
17
|
+
file: 'app/components/A.vue',
|
|
18
|
+
bad: '<template><div /></template>\n<style scoped lang="scss">.a { color: red; }</style>',
|
|
19
|
+
good: '<template><div /></template>\n<style scoped lang="scss" src="@scss/components/A.scss" />',
|
|
20
|
+
},
|
|
21
|
+
'_R3': {
|
|
22
|
+
file: 'app/a.ts',
|
|
23
|
+
bad: 'const v = JSON.parse(raw)',
|
|
24
|
+
good: 'let v = null\ntry { v = JSON.parse(raw) } catch { v = null }',
|
|
25
|
+
},
|
|
26
|
+
'_R4': {
|
|
27
|
+
file: 'app/assets/scss/fonts.scss',
|
|
28
|
+
bad: '@font-face { font-family: Rubik, sans-serif; src: url(a.woff2); }',
|
|
29
|
+
good: '@font-face { font-family: Rubik; src: url(a.woff2); }',
|
|
30
|
+
},
|
|
31
|
+
'_C1': {
|
|
32
|
+
file: 'app/a.ts',
|
|
33
|
+
bad: 'window.addEventListener("resize", onResize)',
|
|
34
|
+
good: 'useEventListener(window, "resize", onResize)',
|
|
35
|
+
},
|
|
36
|
+
'_C3': {
|
|
37
|
+
file: 'app/assets/scss/components/Card.scss',
|
|
38
|
+
bad: '.card { color: #1f2937; }',
|
|
39
|
+
good: '.card { color: $slate-800; }',
|
|
40
|
+
},
|
|
41
|
+
'_C4': {
|
|
42
|
+
file: 'app/a.ts',
|
|
43
|
+
bad: "const d = dayjs().format('YYYY-MM-DD')",
|
|
44
|
+
good: 'const d = dayjs().format(DateFormat.DefaultDate)',
|
|
45
|
+
},
|
|
46
|
+
'_C6': {
|
|
47
|
+
file: 'app/assets/scss/components/Card.scss',
|
|
48
|
+
bad: '.card { font-size: 14px; }',
|
|
49
|
+
good: '.card { @include font(14px, 20px); color: $slate-800; }',
|
|
50
|
+
},
|
|
51
|
+
'_C7': {
|
|
52
|
+
// Two script tags in OPPOSITE orders, so the bad fixture violates whichever
|
|
53
|
+
// direction the consuming app declares in settings.scriptAttrOrder. The good
|
|
54
|
+
// fixture carries no lang attribute, so it is clean under either canonical.
|
|
55
|
+
file: 'app/components/A.vue',
|
|
56
|
+
bad: '<script lang="ts" setup>\n</script>\n<script setup lang="ts">\n</script>',
|
|
57
|
+
good: '<template><div /></template>\n<script setup>\nconst a = 1\n</script>',
|
|
58
|
+
},
|
|
59
|
+
};
|
|
60
|
+
/**
|
|
61
|
+
* `_C2` and `_C5` are `once` rules over the whole file list — a single-file fixture
|
|
62
|
+
* cannot express them, so they are exempt from the fixture requirement and covered by
|
|
63
|
+
* their own tests instead.
|
|
64
|
+
*/
|
|
65
|
+
export const commonSelfTestSkip = ['_C2', '_C5'];
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One rule list, assembled from the family files — the same shape each consuming app uses
|
|
3
|
+
* for its own rules.
|
|
4
|
+
*
|
|
5
|
+
* The family letter says where a rule came from and who owns changing it:
|
|
6
|
+
* R — regression: it broke production at least once → a code decision
|
|
7
|
+
* B — business: a data contract → NOT shipped here; every app's
|
|
8
|
+
* contract is its own domain
|
|
9
|
+
* C — convention: a preference we chose → team consensus
|
|
10
|
+
* L — linter: a built-in ESLint rule → eslint.config.mjs
|
|
11
|
+
*
|
|
12
|
+
* Shared rule ids carry the underscore prefix (`_R1`); app-local ids stay bare (`R1`).
|
|
13
|
+
* The prefix marks provenance and keeps the two number lines independent — and `run()`
|
|
14
|
+
* still fails fast on a duplicate rather than letting an allowlist silently suppress
|
|
15
|
+
* the wrong rule.
|
|
16
|
+
*/
|
|
17
|
+
import type { Rule } from '../types.js';
|
|
18
|
+
export { linterOwnedRules } from './linter.js';
|
|
19
|
+
export { commonFixtures, commonSelfTestSkip } from './fixtures.js';
|
|
20
|
+
export { eachLine, setting } from './shared.js';
|
|
21
|
+
export type { LineFn } from './shared.js';
|
|
22
|
+
/** Every shared rule, in report order. Hover `sharedRuleIds` members for what each checks. */
|
|
23
|
+
export declare const commonRules: Rule[];
|
|
24
|
+
/**
|
|
25
|
+
* The hoverable catalog: every rule this pack ships, id → id. Use these constants for
|
|
26
|
+
* `allowlists` / `levels` keys and `--rule=` values when you want the contract in the
|
|
27
|
+
* IDE — hover any member for what the rule checks, at which level, and what to do
|
|
28
|
+
* instead. A test pins this map to `commonRules`, so it cannot drift.
|
|
29
|
+
*/
|
|
30
|
+
export declare const sharedRuleIds: {
|
|
31
|
+
/** `error` — 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 _R1: "_R1";
|
|
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 _R2: "_R2";
|
|
35
|
+
/** `error` — `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 _R3: "_R3";
|
|
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 _R4: "_R4";
|
|
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 _C1: "_C1";
|
|
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 _C2: "_C2";
|
|
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 _C3: "_C3";
|
|
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 _C4: "_C4";
|
|
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 _C5: "_C5";
|
|
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 _C6: "_C6";
|
|
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 _C7: "_C7";
|
|
53
|
+
};
|
|
54
|
+
/** Union of every id the shared pack ships — for typing app config keys. */
|
|
55
|
+
export type SharedRuleId = keyof typeof sharedRuleIds;
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One rule list, assembled from the family files — the same shape each consuming app uses
|
|
3
|
+
* for its own rules.
|
|
4
|
+
*
|
|
5
|
+
* The family letter says where a rule came from and who owns changing it:
|
|
6
|
+
* R — regression: it broke production at least once → a code decision
|
|
7
|
+
* B — business: a data contract → NOT shipped here; every app's
|
|
8
|
+
* contract is its own domain
|
|
9
|
+
* C — convention: a preference we chose → team consensus
|
|
10
|
+
* L — linter: a built-in ESLint rule → eslint.config.mjs
|
|
11
|
+
*
|
|
12
|
+
* Shared rule ids carry the underscore prefix (`_R1`); app-local ids stay bare (`R1`).
|
|
13
|
+
* The prefix marks provenance and keeps the two number lines independent — and `run()`
|
|
14
|
+
* still fails fast on a duplicate rather than letting an allowlist silently suppress
|
|
15
|
+
* the wrong rule.
|
|
16
|
+
*/
|
|
17
|
+
import { rRules } from './regression.js';
|
|
18
|
+
import { cRules } from './convention.js';
|
|
19
|
+
export { linterOwnedRules } from './linter.js';
|
|
20
|
+
export { commonFixtures, commonSelfTestSkip } from './fixtures.js';
|
|
21
|
+
export { eachLine, setting } from './shared.js';
|
|
22
|
+
/** Every shared rule, in report order. Hover `sharedRuleIds` members for what each checks. */
|
|
23
|
+
export const commonRules = [...rRules, ...cRules];
|
|
24
|
+
/**
|
|
25
|
+
* The hoverable catalog: every rule this pack ships, id → id. Use these constants for
|
|
26
|
+
* `allowlists` / `levels` keys and `--rule=` values when you want the contract in the
|
|
27
|
+
* IDE — hover any member for what the rule checks, at which level, and what to do
|
|
28
|
+
* instead. A test pins this map to `commonRules`, so it cannot drift.
|
|
29
|
+
*/
|
|
30
|
+
export const sharedRuleIds = {
|
|
31
|
+
/** `error` — 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
|
+
_R1: '_R1',
|
|
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
|
+
_R2: '_R2',
|
|
35
|
+
/** `error` — `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
|
+
_R3: '_R3',
|
|
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
|
+
_R4: '_R4',
|
|
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
|
+
_C1: '_C1',
|
|
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
|
+
_C2: '_C2',
|
|
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
|
+
_C3: '_C3',
|
|
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
|
+
_C4: '_C4',
|
|
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
|
+
_C5: '_C5',
|
|
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
|
+
_C6: '_C6',
|
|
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
|
+
_C7: '_C7',
|
|
53
|
+
};
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Linter rules — invariants a built-in ESLint rule already covers better than we could.
|
|
3
|
+
*
|
|
4
|
+
* Declared here, configured in each app\'s `eslint.config.mjs`, so the generated inventory
|
|
5
|
+
* is complete and nobody re-implements one by accident. Empty today: a declaration here
|
|
6
|
+
* asserts the rule IS configured, and claiming coverage the linter does not provide is the
|
|
7
|
+
* exact failure this directory exists to prevent.
|
|
8
|
+
*/
|
|
9
|
+
import type { LinterOwnedRule } from '../types.js';
|
|
10
|
+
export declare const linterOwnedRules: LinterOwnedRule[];
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Linter rules — invariants a built-in ESLint rule already covers better than we could.
|
|
3
|
+
*
|
|
4
|
+
* Declared here, configured in each app\'s `eslint.config.mjs`, so the generated inventory
|
|
5
|
+
* is complete and nobody re-implements one by accident. Empty today: a declaration here
|
|
6
|
+
* asserts the rule IS configured, and claiming coverage the linter does not provide is the
|
|
7
|
+
* exact failure this directory exists to prevent.
|
|
8
|
+
*/
|
|
9
|
+
export const linterOwnedRules = [];
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Regression rules — each one broke production at least once, and SILENTLY. No test failed,
|
|
3
|
+
* no type error, no lint warning.
|
|
4
|
+
*
|
|
5
|
+
* Ported from land-app, where they were scars first. They are here rather than there
|
|
6
|
+
* because each reads identically in every Brighterly Nuxt app: none names a funnel, a store
|
|
7
|
+
* or a route. App-specific landmarks arrive through `RuleContext.settings`.
|
|
8
|
+
*
|
|
9
|
+
* Changing one is a code decision — but a code decision for FOUR repos now, so making a
|
|
10
|
+
* rule stricter is a major version bump.
|
|
11
|
+
*/
|
|
12
|
+
import type { Rule } from '../types.js';
|
|
13
|
+
export declare const rRules: Rule[];
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Regression rules — each one broke production at least once, and SILENTLY. No test failed,
|
|
3
|
+
* no type error, no lint warning.
|
|
4
|
+
*
|
|
5
|
+
* Ported from land-app, where they were scars first. They are here rather than there
|
|
6
|
+
* because each reads identically in every Brighterly Nuxt app: none names a funnel, a store
|
|
7
|
+
* or a route. App-specific landmarks arrive through `RuleContext.settings`.
|
|
8
|
+
*
|
|
9
|
+
* Changing one is a code decision — but a code decision for FOUR repos now, so making a
|
|
10
|
+
* rule stricter is a major version bump.
|
|
11
|
+
*/
|
|
12
|
+
import { findJsonParseCalls } from '../ast.js';
|
|
13
|
+
import { eachLine, setting } from './shared.js';
|
|
14
|
+
const bareStorage = {
|
|
15
|
+
id: '_R1',
|
|
16
|
+
title: 'bare localStorage / sessionStorage access',
|
|
17
|
+
level: 'error',
|
|
18
|
+
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
|
+
fix: 'use the app\'s safe storage wrapper instead of the bare API',
|
|
20
|
+
scope: ['app/**/*.{ts,vue}'],
|
|
21
|
+
exclude: ['app/tests/**', 'app/test/**'],
|
|
22
|
+
check: ({ file, code, settings }) => {
|
|
23
|
+
const wrapper = setting({ settings }, 'safeStorageModule', 'the safe storage helper');
|
|
24
|
+
return eachLine(code, (line, n) => {
|
|
25
|
+
if (/\b(?:window\s*\.\s*)?(?:local|session)Storage\s*[.[]/.test(line)) {
|
|
26
|
+
return { file, line: n, message: `bare ${line.match(/(local|session)Storage/)?.[0] ?? 'storage'} access — use ${wrapper}` };
|
|
27
|
+
}
|
|
28
|
+
if (/if\s*\(\s*(?:window\s*\.\s*)?(?:local|session)Storage\s*\)/.test(line)) {
|
|
29
|
+
return { file, line: n, message: 'storage existence guard — the accessor itself throws, the guard does not help' };
|
|
30
|
+
}
|
|
31
|
+
return null;
|
|
32
|
+
});
|
|
33
|
+
},
|
|
34
|
+
};
|
|
35
|
+
const scssInStyleBlock = {
|
|
36
|
+
id: '_R2',
|
|
37
|
+
title: 'SCSS rules inside a Vue <style> block',
|
|
38
|
+
level: 'error',
|
|
39
|
+
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',
|
|
40
|
+
fix: 'move the rules to a dedicated .scss file and reference it with <style scoped lang="scss" src="…" />',
|
|
41
|
+
scope: ['app/**/*.vue'],
|
|
42
|
+
check: ({ file, source }) => {
|
|
43
|
+
const out = [];
|
|
44
|
+
const re = /<style\b[^>]*>([\s\S]*?)<\/style>/g;
|
|
45
|
+
let m;
|
|
46
|
+
while ((m = re.exec(source)) !== null) {
|
|
47
|
+
const body = (m[1] ?? '').trim();
|
|
48
|
+
if (body.length === 0)
|
|
49
|
+
continue;
|
|
50
|
+
// `@forward`/`@use`/`@import`-only blocks declare no rules of their own — the
|
|
51
|
+
// canonical way to pull a third-party library's styles in unscoped.
|
|
52
|
+
const withoutAtRules = body.replace(/@(?:use|forward|import)[^;]*;/g, '').trim();
|
|
53
|
+
if (withoutAtRules.length === 0)
|
|
54
|
+
continue;
|
|
55
|
+
out.push({
|
|
56
|
+
file,
|
|
57
|
+
line: source.slice(0, m.index).split('\n').length,
|
|
58
|
+
message: 'SCSS rules declared inline — move them to a .scss file and use <style src="…" />',
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
return out;
|
|
62
|
+
},
|
|
63
|
+
};
|
|
64
|
+
const unguardedJsonParse = {
|
|
65
|
+
id: '_R3',
|
|
66
|
+
title: 'unguarded JSON.parse',
|
|
67
|
+
level: 'error',
|
|
68
|
+
async: true,
|
|
69
|
+
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',
|
|
70
|
+
fix: 'parse through the app\'s guarded helper, or wrap the call in try/catch',
|
|
71
|
+
scope: ['app/**/*.{ts,vue}'],
|
|
72
|
+
exclude: ['app/tests/**', 'app/test/**'],
|
|
73
|
+
check: async ({ file, source, settings }) => {
|
|
74
|
+
const helper = setting({ settings }, 'safeJsonParse', 'a guarded parse helper');
|
|
75
|
+
const calls = await findJsonParseCalls(file, source);
|
|
76
|
+
return calls
|
|
77
|
+
.filter(c => !c.guarded)
|
|
78
|
+
.map(c => ({ file, line: c.line, message: `JSON.parse with no enclosing try/catch — use ${helper}` }));
|
|
79
|
+
},
|
|
80
|
+
};
|
|
81
|
+
const fontFaceStack = {
|
|
82
|
+
id: '_R4',
|
|
83
|
+
title: '@font-face with a font stack instead of one family name',
|
|
84
|
+
level: 'error',
|
|
85
|
+
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',
|
|
86
|
+
fix: 'keep font-family inside @font-face to the single family name; the fallback stack belongs on the selectors that use the font',
|
|
87
|
+
scope: ['app/assets/scss/**/*.scss'],
|
|
88
|
+
check: ({ file, code }) => {
|
|
89
|
+
const out = [];
|
|
90
|
+
for (const m of code.matchAll(/@font-face\s*\{([^}]*)\}/g)) {
|
|
91
|
+
const family = m[1]?.match(/font-family\s*:\s*([^;]+);/)?.[1] ?? '';
|
|
92
|
+
const outsideQuotes = family.replace(/(['"])(?:(?!\1).)*\1/g, '');
|
|
93
|
+
if (outsideQuotes.includes(',')) {
|
|
94
|
+
out.push({
|
|
95
|
+
file,
|
|
96
|
+
line: code.slice(0, m.index).split('\n').length,
|
|
97
|
+
message: `@font-face declares font-family: ${family.trim()} — a stack, so the face is dropped`,
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
return out;
|
|
102
|
+
},
|
|
103
|
+
};
|
|
104
|
+
export const rRules = [
|
|
105
|
+
bareStorage,
|
|
106
|
+
scssInStyleBlock,
|
|
107
|
+
unguardedJsonParse,
|
|
108
|
+
fontFaceStack,
|
|
109
|
+
];
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Building blocks for the common rule pack.
|
|
3
|
+
*
|
|
4
|
+
* A shared rule must not hardcode a consuming app's paths. Anything app-specific arrives
|
|
5
|
+
* through `RuleContext.settings`, which each app fills from `config.settings` — that is why
|
|
6
|
+
* `settings` exists at all, and why the pack needs no factory.
|
|
7
|
+
*/
|
|
8
|
+
import type { Finding, RuleContext } from '../types.js';
|
|
9
|
+
export type LineFn = (line: string, n: number, lines: string[]) => Finding | Finding[] | null;
|
|
10
|
+
export declare const eachLine: (source: string, fn: LineFn) => Finding[];
|
|
11
|
+
/**
|
|
12
|
+
* A setting, or a readable fallback when the app has not declared it.
|
|
13
|
+
*
|
|
14
|
+
* Never throws: a missing setting must degrade the MESSAGE, never silence the rule. A rule
|
|
15
|
+
* that stops firing because a path is unset is exactly the silent failure this repo exists
|
|
16
|
+
* to prevent.
|
|
17
|
+
*/
|
|
18
|
+
export declare const setting: (ctx: Pick<RuleContext, "settings">, key: string, fallback: string) => string;
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Building blocks for the common rule pack.
|
|
3
|
+
*
|
|
4
|
+
* A shared rule must not hardcode a consuming app's paths. Anything app-specific arrives
|
|
5
|
+
* through `RuleContext.settings`, which each app fills from `config.settings` — that is why
|
|
6
|
+
* `settings` exists at all, and why the pack needs no factory.
|
|
7
|
+
*/
|
|
8
|
+
export const eachLine = (source, fn) => {
|
|
9
|
+
const lines = source.split('\n');
|
|
10
|
+
const out = [];
|
|
11
|
+
lines.forEach((line, i) => {
|
|
12
|
+
const r = fn(line, i + 1, lines);
|
|
13
|
+
if (r)
|
|
14
|
+
out.push(...(Array.isArray(r) ? r : [r]));
|
|
15
|
+
});
|
|
16
|
+
return out;
|
|
17
|
+
};
|
|
18
|
+
/**
|
|
19
|
+
* A setting, or a readable fallback when the app has not declared it.
|
|
20
|
+
*
|
|
21
|
+
* Never throws: a missing setting must degrade the MESSAGE, never silence the rule. A rule
|
|
22
|
+
* that stops firing because a path is unset is exactly the silent failure this repo exists
|
|
23
|
+
* to prevent.
|
|
24
|
+
*/
|
|
25
|
+
export const setting = (ctx, key, fallback) => ctx.settings?.[key] ?? fallback;
|