@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
@@ -2,28 +2,46 @@
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
- * `core/C1` was land-app's `C1`, and reads the same everywhere. `core/C2`-`core/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.
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.
9
10
  *
10
- * `core/C5`-`core/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.
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.
13
15
  *
14
16
  * They ship at `warn` so each consuming app can measure what they find before anyone
15
17
  * decides to block on them. Promoting one to `error` is a major version bump.
16
18
  */
19
+ import { componentScopeLines } from '../engine/ast.js';
17
20
  import { eachLine, setting } from './shared.js';
18
21
  const vueUseFirst = {
19
- id: 'core/C1',
22
+ name: 'vueuse-first',
20
23
  title: 'hand-rolled browser API with a VueUse equivalent',
21
24
  level: 'warn',
22
25
  why: 'VueUse handles the lifecycle, SSR and error paths we otherwise re-implement (and forget to tear down) per call site',
23
26
  fix: 'use the VueUse composable named in the message, imported explicitly from @vueuse/core',
24
27
  scope: ['app/**/*.{ts,vue}'],
25
28
  exclude: ['app/tests/**', 'app/test/**'],
26
- check: ({ file, code }) => {
29
+ async: true,
30
+ // Only lines inside a component lifetime count (`<script setup>`, a `use…` composable, a `setup()`
31
+ // method). A store action, a plugin or a plain helper has no effect scope, so the VueUse composable
32
+ // the message recommends would leak there — those lines are silent by construction.
33
+ fixtures: [
34
+ { file: 'app/composables/a.ts', bad: 'export const useA = () => {\n window.addEventListener("resize", onResize)\n}', good: 'export const useA = () => {\n useEventListener(window, "resize", onResize)\n}' },
35
+ { file: 'app/composables/b.ts', bad: 'export function useB() {\n const run = () => setTimeout(tick, 10)\n return { run }\n}' },
36
+ { file: 'app/components/A.vue', bad: '<script setup lang="ts">\nsetTimeout(tick, 10)\n</script>', good: '<script setup lang="ts">\nuseTimeoutFn(tick, 10)\n</script>' },
37
+ { file: 'app/components/B.vue', bad: '<script lang="ts">\nexport default defineComponent({\n setup() {\n setInterval(tick, 10)\n },\n})\n</script>' },
38
+ { file: 'app/stores/quiz/actions.ts', good: 'export const actions = {\n setLazyStep(payload) {\n setTimeout(() => setStep(payload), 300)\n },\n}' },
39
+ { file: 'app/composables/tools/scroll.ts', good: 'export const scrollIntoViewIfHidden = (el) => {\n new IntersectionObserver(cb).observe(el)\n}' },
40
+ { file: 'app/composables/tools/sdk.ts', good: 'const init = () => {\n load().catch(() => setTimeout(init, 800))\n}' },
41
+ { file: 'app/plugins/axios.client.ts', good: 'export default defineNuxtPlugin(() => {\n setTimeout(refresh, 1000)\n})' },
42
+ ],
43
+ check: async ({ file, source, code }) => {
44
+ const scoped = await componentScopeLines(file, source);
27
45
  const map = [
28
46
  [/new\s+IntersectionObserver\s*\(/, 'useIntersectionObserver'],
29
47
  [/new\s+ResizeObserver\s*\(/, 'useResizeObserver'],
@@ -38,6 +56,8 @@ const vueUseFirst = {
38
56
  [/\b(?:window\s*\.\s*)?setTimeout\s*\(/, 'useTimeoutFn'],
39
57
  ];
40
58
  return eachLine(code, (line, n) => {
59
+ if (!scoped.has(n))
60
+ return null;
41
61
  for (const [re, fn] of map) {
42
62
  if (re.test(line))
43
63
  return { file, line: n, message: `hand-rolled browser API — use ${fn} from @vueuse/core` };
@@ -47,13 +67,25 @@ const vueUseFirst = {
47
67
  },
48
68
  };
49
69
  const uiKitReuseFirst = {
50
- id: 'core/C2',
70
+ name: 'ui-kit-unique-names',
51
71
  title: 'component duplicates an existing UI-kit component name',
52
72
  level: 'warn',
53
73
  once: true,
54
74
  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
75
  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
76
  scope: ['app/components/**/*.vue'],
77
+ // A `once` rule over the whole file list: a single-file case cannot express it.
78
+ world: [
79
+ {
80
+ files: { 'app/components/UI/Buttons/AppButton.vue': '<template />', 'app/components/Pages/Quiz/AppButton.vue': '<template />' },
81
+ expect: 'finding',
82
+ match: /duplicates the UI-kit component/,
83
+ },
84
+ {
85
+ files: { 'app/components/UI/Buttons/AppButton.vue': '<template />', 'app/components/Pages/Quiz/QuizButton.vue': '<template />' },
86
+ expect: 'silence',
87
+ },
88
+ ],
57
89
  check: ({ allFiles, settings }) => {
58
90
  const files = allFiles ?? [];
59
91
  const uiKitRoot = settings?.uiKitRoot ?? 'app/components/UI';
@@ -101,12 +133,26 @@ const onlyStyleBlocks = (source) => {
101
133
  return out + source.slice(last).replace(/[^\n]/g, ' ');
102
134
  };
103
135
  const scssColourTokens = {
104
- id: 'core/C3',
136
+ name: 'scss-tokens',
105
137
  title: 'literal colour in SCSS instead of a design token',
106
138
  level: 'warn',
107
139
  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
140
  fix: 'use a token variable from the palette file; if the colour is genuinely new, add it to the palette first',
109
141
  scope: ['app/assets/scss/**/*.scss', 'app/components/**/*.vue'],
142
+ fixtures: [
143
+ {
144
+ file: 'app/assets/scss/components/Card.scss',
145
+ bad: '.card { color: #1f2937; }',
146
+ good: '.card { color: $slate-800; }',
147
+ },
148
+ // In a .vue file only the <style> block is a stylesheet: an SVG fill in the
149
+ // template has no place to put a $token, so it must stay quiet.
150
+ {
151
+ file: 'app/components/UI/Icons/IconLeaf.vue',
152
+ bad: '<template><svg><path fill="currentColor" /></svg></template>\n<style scoped lang="scss">\n.icon { color: #e2e8f0; }\n</style>',
153
+ good: '<template><svg><path fill="#E2E8F0" /></svg></template>\n<style scoped lang="scss">\n.icon { color: $slate-200; }\n</style>',
154
+ },
155
+ ],
110
156
  check: ({ file, code, settings }) => {
111
157
  // Underscore-prefixed partials are DECLARATION files by SCSS convention in every
112
158
  // Brighterly app (_colors, _vars, _extends, _mixins, brand variants, …) — they are
@@ -129,13 +175,33 @@ const scssColourTokens = {
129
175
  },
130
176
  };
131
177
  const dateFormatEnum = {
132
- id: 'core/C4',
178
+ name: 'date-format-enum',
133
179
  title: 'hand-rolled date format string instead of the shared enum',
134
180
  level: 'warn',
135
181
  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',
136
182
  fix: 'format through the shared DateFormat enum; never hand-roll a format string or use toISOString()',
137
183
  scope: ['app/**/*.{ts,vue}'],
138
184
  exclude: ['app/tests/**', 'app/test/**'],
185
+ fixtures: [
186
+ {
187
+ file: 'app/a.ts',
188
+ bad: "const d = dayjs().format('YYYY-MM-DD')",
189
+ good: 'const d = dayjs().format(DateFormat.DefaultDate)',
190
+ },
191
+ // The day-extraction misuse fires; a bare toISOString() (an API timestamp) and the
192
+ // epoch duration trick do not.
193
+ {
194
+ file: 'app/b.ts',
195
+ bad: "const day = new Date().toISOString().split('T')[0]",
196
+ good: 'const generatedAt = dayjs().toISOString()\nconst duration = new Date(s * 1000).toISOString().slice(14, 19)',
197
+ },
198
+ // Every letter run must be a real dayjs token — an answer label that happens to fit
199
+ // the char class ('ADHD / ADD' carries a DD) is not a format string.
200
+ {
201
+ file: 'app/c.ts',
202
+ good: "const option = { code: 'ADHD_ADD', label: 'ADHD / ADD' }",
203
+ },
204
+ ],
139
205
  check: ({ file, code, settings }) => {
140
206
  const dateModule = setting({ settings }, 'dateFormatModule', 'the shared DateFormat enum');
141
207
  // The module that DEFINES the formats is the one place the literals belong.
@@ -162,13 +228,21 @@ const dateFormatEnum = {
162
228
  },
163
229
  };
164
230
  const typesOrganization = {
165
- id: 'core/C5',
231
+ name: 'types-layout',
166
232
  title: 'type file outside the entity-folder structure',
167
233
  level: 'warn',
168
234
  once: true,
169
235
  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)',
170
236
  fix: 'move every type into <typesRoot>/<entity>/{enums,interfaces,types}.ts — enums in enums.ts, interfaces in interfaces.ts, aliases/unions in types.ts',
171
237
  scope: ['app/types/**/*.ts'],
238
+ // A `once` rule over the whole file list: a single-file case cannot express it.
239
+ world: [
240
+ { files: { 'app/types/global-types.ts': 'export type A = 1' }, expect: 'finding', match: /directly under app\/types/ },
241
+ {
242
+ files: { 'app/types/quiz/enums.ts': '', 'app/types/quiz/interfaces.ts': '', 'app/types/quiz/types.ts': '', 'app/types/globals.d.ts': '' },
243
+ expect: 'silence',
244
+ },
245
+ ],
172
246
  check: ({ allFiles, settings }) => {
173
247
  const files = allFiles ?? [];
174
248
  const root = settings?.typesRoot ?? 'app/types';
@@ -194,12 +268,17 @@ const typesOrganization = {
194
268
  },
195
269
  };
196
270
  const typographySource = {
197
- id: 'core/C6',
271
+ name: 'typography-source',
198
272
  title: 'raw font declaration instead of the app\'s typography source',
199
273
  level: 'warn',
200
274
  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 та стилі)',
201
275
  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',
202
276
  scope: ['app/assets/scss/**/*.scss', 'app/components/**/*.vue'],
277
+ fixtures: {
278
+ file: 'app/assets/scss/components/Card.scss',
279
+ bad: '.card { font-size: 14px; }',
280
+ good: '.card { @include font(14px, 20px); color: $slate-800; }',
281
+ },
203
282
  check: ({ file, code, settings }) => {
204
283
  const source = setting({ settings }, 'typographySource', 'the app\'s shared typography mixins or text components');
205
284
  // The files that DEFINE the typography are the one place raw declarations belong:
@@ -207,7 +286,7 @@ const typographySource = {
207
286
  // Brighterly apps, decided 2026-09-24), plus non-underscore fonts/typography files.
208
287
  if (/\/(?:_[^/]*|fonts|typography)\.scss$/.test(file))
209
288
  return [];
210
- // @font-face declares a face, not a usage — core/R4 polices those blocks.
289
+ // @font-face declares a face, not a usage — font-face-single-family polices those blocks.
211
290
  const stripped = code.replace(/@font-face\s*\{[^}]*\}/g, m => m.replace(/[^\n]/g, ' '));
212
291
  return eachLine(stripped, (line, n) => {
213
292
  const m = /\bfont(?:-family|-size|-weight)?\s*:/.exec(line);
@@ -218,12 +297,20 @@ const typographySource = {
218
297
  },
219
298
  };
220
299
  const scriptAttrOrder = {
221
- id: 'core/C7',
300
+ name: 'script-attr-order',
222
301
  title: 'script tag attribute order differs from the app canonical',
223
302
  level: 'warn',
224
303
  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',
225
304
  fix: 'use the app\'s canonical order everywhere — settings.scriptAttrOrder: \'setup-first\' (default) or \'lang-first\'',
226
305
  scope: ['app/**/*.vue'],
306
+ fixtures: {
307
+ // Two script tags in OPPOSITE orders, so the bad fixture violates whichever
308
+ // direction the consuming app declares in settings.scriptAttrOrder. The good
309
+ // fixture carries no lang attribute, so it is clean under either canonical.
310
+ file: 'app/components/A.vue',
311
+ bad: '<script lang="ts" setup>\n</script>\n<script setup lang="ts">\n</script>',
312
+ good: '<template><div /></template>\n<script setup>\nconst a = 1\n</script>',
313
+ },
227
314
  check: ({ file, source, settings }) => {
228
315
  const canonical = settings?.scriptAttrOrder === 'lang-first' ? 'lang-first' : 'setup-first';
229
316
  const spell = (order) => (order === 'lang-first' ? '<script lang="ts" setup>' : '<script setup lang="ts">');
@@ -0,0 +1,28 @@
1
+ /**
2
+ * The package rule pack — eleven rules by plain name, each carrying its own proof.
3
+ *
4
+ * Every rule reads identically in every Brighterly Nuxt app: none names a funnel, a store or
5
+ * a route. App-specific landmarks arrive through `RuleContext.settings`. Two files by origin:
6
+ * regression.ts — it broke production at least once → a code decision
7
+ * convention.ts — a preference we chose → team consensus
8
+ *
9
+ * App rules live in the app's own config and must not reuse a package name: `run()` fails
10
+ * fast on a duplicate rather than letting a suppression silently cover the wrong rule.
11
+ */
12
+ import type { Rule } from '../engine/types.js';
13
+ export { eachLine, setting } from './shared.js';
14
+ export type { LineFn } from './shared.js';
15
+ /**
16
+ * Every package rule name, in report order. Hover a member for what the rule checks, at
17
+ * which level, and what to do instead. A test pins this list to `packageRules`.
18
+ */
19
+ export declare const packageRuleNames: readonly ["no-bare-storage", "inline-scss", "guarded-json-parse", "font-face-single-family", "vueuse-first", "ui-kit-unique-names", "scss-tokens", "date-format-enum", "types-layout", "typography-source", "script-attr-order"];
20
+ /** Union of every name the package ships — for typing app config keys. */
21
+ export type PackageRuleName = typeof packageRuleNames[number];
22
+ /** Every package rule, in report order (the order of `packageRuleNames`). */
23
+ export declare const packageRules: Rule[];
24
+ /**
25
+ * Every 0.5.x id → its 1.0 name. Never delete an entry: a suppression or config key still
26
+ * naming an old id is reported with the new name instead of "does not name any rule".
27
+ */
28
+ export declare const renamedRules: Record<string, PackageRuleName>;
@@ -1,53 +1,61 @@
1
1
  /**
2
- * One rule list, assembled from the family files — the same shape each consuming app uses
3
- * for its own rules.
2
+ * The package rule pack — eleven rules by plain name, each carrying its own proof.
4
3
  *
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
4
+ * Every rule reads identically in every Brighterly Nuxt app: none names a funnel, a store or
5
+ * a route. App-specific landmarks arrive through `RuleContext.settings`. Two files by origin:
6
+ * regression.ts — it broke production at least once → a code decision
7
+ * convention.ts — a preference we chose → team consensus
11
8
  *
12
- * Shared rule ids carry the `core/` prefix (`core/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.
9
+ * App rules live in the app's own config and must not reuse a package name: `run()` fails
10
+ * fast on a duplicate rather than letting a suppression silently cover the wrong rule.
16
11
  */
17
12
  import { rRules } from './regression.js';
18
13
  import { cRules } from './convention.js';
19
- export { linterOwnedRules } from './linter.js';
20
- export { commonFixtures, commonSelfTestSkip } from './fixtures.js';
21
14
  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
15
  /**
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.
16
+ * Every package rule name, in report order. Hover a member for what the rule checks, at
17
+ * which level, and what to do instead. A test pins this list to `packageRules`.
29
18
  */
30
- export const sharedRuleIds = {
19
+ export const packageRuleNames = [
31
20
  /** `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',
21
+ 'no-bare-storage',
33
22
  /** `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
- 'core/R2': 'core/R2',
23
+ 'inline-scss',
35
24
  /** `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',
25
+ 'guarded-json-parse',
37
26
  /** `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
- 'core/R4': 'core/R4',
27
+ 'font-face-single-family',
39
28
  /** `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
- 'core/C1': 'core/C1',
29
+ 'vueuse-first',
41
30
  /** `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
- 'core/C2': 'core/C2',
31
+ 'ui-kit-unique-names',
43
32
  /** `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
- 'core/C3': 'core/C3',
33
+ 'scss-tokens',
45
34
  /** `warn` — a hand-rolled date format string (`'YYYY-MM-DD'`) or a `toISOString()` whose date part is extracted (`.split('T')[0]`, `.slice(0, 10)`). The date's shape becomes an accident instead of a contract. Fix: the shared enum (`settings.dateFormatModule`). */
46
- 'core/C4': 'core/C4',
35
+ 'date-format-enum',
47
36
  /** `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
- 'core/C5': 'core/C5',
37
+ 'types-layout',
49
38
  /** `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
- 'core/C6': 'core/C6',
39
+ 'typography-source',
51
40
  /** `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
- 'core/C7': 'core/C7',
41
+ 'script-attr-order',
42
+ ];
43
+ /** Every package rule, in report order (the order of `packageRuleNames`). */
44
+ export const packageRules = [...rRules, ...cRules];
45
+ /**
46
+ * Every 0.5.x id → its 1.0 name. Never delete an entry: a suppression or config key still
47
+ * naming an old id is reported with the new name instead of "does not name any rule".
48
+ */
49
+ export const renamedRules = {
50
+ 'core/R1': 'no-bare-storage',
51
+ 'core/R2': 'inline-scss',
52
+ 'core/R3': 'guarded-json-parse',
53
+ 'core/R4': 'font-face-single-family',
54
+ 'core/C1': 'vueuse-first',
55
+ 'core/C2': 'ui-kit-unique-names',
56
+ 'core/C3': 'scss-tokens',
57
+ 'core/C4': 'date-format-enum',
58
+ 'core/C5': 'types-layout',
59
+ 'core/C6': 'typography-source',
60
+ 'core/C7': 'script-attr-order',
53
61
  };
@@ -9,5 +9,5 @@
9
9
  * Changing one is a code decision — but a code decision for FOUR repos now, so making a
10
10
  * rule stricter is a major version bump.
11
11
  */
12
- import type { Rule } from '../types.js';
12
+ import type { Rule } from '../engine/types.js';
13
13
  export declare const rRules: Rule[];
@@ -9,10 +9,10 @@
9
9
  * Changing one is a code decision — but a code decision for FOUR repos now, so making a
10
10
  * rule stricter is a major version bump.
11
11
  */
12
- import { findJsonParseCalls } from '../ast.js';
12
+ import { findJsonParseCalls } from '../engine/ast.js';
13
13
  import { eachLine, setting } from './shared.js';
14
14
  const bareStorage = {
15
- id: 'core/R1',
15
+ name: 'no-bare-storage',
16
16
  title: 'bare localStorage / sessionStorage access',
17
17
  // Target level is `error` (crash-class). Shipped at `warn` since 2026-09-25: the fleet
18
18
  // backlog (cs-app ~42 sites, main-app ~28) is real code the team has no bandwidth to
@@ -23,6 +23,11 @@ const bareStorage = {
23
23
  fix: 'use the app\'s safe storage wrapper instead of the bare API',
24
24
  scope: ['app/**/*.{ts,vue}'],
25
25
  exclude: ['app/tests/**', 'app/test/**'],
26
+ fixtures: {
27
+ file: 'app/a.ts',
28
+ bad: 'const t = localStorage.getItem("k")',
29
+ good: 'const t = safeLocalStorage.getItem("k")',
30
+ },
26
31
  check: ({ file, code, settings }) => {
27
32
  // The wrapper itself is the one place bare access belongs — settings-driven, so no
28
33
  // consumer has to allowlist its own wrapper.
@@ -41,7 +46,7 @@ const bareStorage = {
41
46
  },
42
47
  };
43
48
  const scssInStyleBlock = {
44
- id: 'core/R2',
49
+ name: 'inline-scss',
45
50
  title: 'SCSS rules inside a Vue <style> block',
46
51
  level: 'error',
47
52
  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',
@@ -50,6 +55,11 @@ const scssInStyleBlock = {
50
55
  // Dev harness pages are dev tooling, not product surfaces — every app that has them
51
56
  // (tutor room-preview, cs-app preview) was allowlisting the same thing (2026-09-25).
52
57
  exclude: ['app/pages/dev/**'],
58
+ fixtures: {
59
+ file: 'app/components/A.vue',
60
+ bad: '<template><div /></template>\n<style scoped lang="scss">.a { color: red; }</style>',
61
+ good: '<template><div /></template>\n<style scoped lang="scss" src="@scss/components/A.scss" />',
62
+ },
53
63
  check: ({ file, source }) => {
54
64
  const out = [];
55
65
  const re = /<style\b[^>]*>([\s\S]*?)<\/style>/g;
@@ -73,15 +83,20 @@ const scssInStyleBlock = {
73
83
  },
74
84
  };
75
85
  const unguardedJsonParse = {
76
- id: 'core/R3',
86
+ name: 'guarded-json-parse',
77
87
  title: 'unguarded JSON.parse',
78
- // Target level is `error` — see core/R1 above; same interim decision (2026-09-25).
88
+ // Target level is `error` — see no-bare-storage above; same interim decision (2026-09-25).
79
89
  level: 'warn',
80
90
  async: true,
81
91
  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',
82
92
  fix: 'parse through the app\'s guarded helper, or wrap the call in try/catch',
83
93
  scope: ['app/**/*.{ts,vue}'],
84
94
  exclude: ['app/tests/**', 'app/test/**'],
95
+ fixtures: {
96
+ file: 'app/a.ts',
97
+ bad: 'const v = JSON.parse(raw)',
98
+ good: 'let v = null\ntry { v = JSON.parse(raw) } catch { v = null }',
99
+ },
85
100
  check: async ({ file, source, settings }) => {
86
101
  const helper = setting({ settings }, 'safeJsonParse', 'a guarded parse helper');
87
102
  const calls = await findJsonParseCalls(file, source);
@@ -91,12 +106,17 @@ const unguardedJsonParse = {
91
106
  },
92
107
  };
93
108
  const fontFaceStack = {
94
- id: 'core/R4',
109
+ name: 'font-face-single-family',
95
110
  title: '@font-face with a font stack instead of one family name',
96
111
  level: 'error',
97
112
  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',
98
113
  fix: 'keep font-family inside @font-face to the single family name; the fallback stack belongs on the selectors that use the font',
99
114
  scope: ['app/assets/scss/**/*.scss'],
115
+ fixtures: {
116
+ file: 'app/assets/scss/fonts.scss',
117
+ bad: '@font-face { font-family: Rubik, sans-serif; src: url(a.woff2); }',
118
+ good: '@font-face { font-family: Rubik; src: url(a.woff2); }',
119
+ },
100
120
  check: ({ file, code }) => {
101
121
  const out = [];
102
122
  for (const m of code.matchAll(/@font-face\s*\{([^}]*)\}/g)) {
@@ -5,7 +5,7 @@
5
5
  * through `RuleContext.settings`, which each app fills from `config.settings` — that is why
6
6
  * `settings` exists at all, and why the pack needs no factory.
7
7
  */
8
- import type { Finding, RuleContext } from '../types.js';
8
+ import type { Finding, RuleContext } from '../engine/types.js';
9
9
  export type LineFn = (line: string, n: number, lines: string[]) => Finding | Finding[] | null;
10
10
  export declare const eachLine: (source: string, fn: LineFn) => Finding[];
11
11
  /**
@@ -28,7 +28,7 @@ export interface ValidateOptions {
28
28
  };
29
29
  /** Defaults to process.stdout.write. */
30
30
  write?: (s: string) => void;
31
- /** Defaults to process.exit. Injectable so tests can assert the code. */
31
+ /** Defaults to setting `process.exitCode` (never `process.exit()` after output). Injectable so tests can assert the code. */
32
32
  exit?: (code: number) => never;
33
33
  /** Emit ::group:: markers. Defaults to Boolean(process.env.GITHUB_ACTIONS). */
34
34
  ci?: boolean;
@@ -16,7 +16,7 @@ const NAME_WIDTH = 12;
16
16
  export const validate = async (steps, options = {}) => {
17
17
  const exec = options.exec ?? defaultExec;
18
18
  const write = options.write ?? ((s) => { process.stdout.write(s); });
19
- const exit = options.exit ?? process.exit;
19
+ const exit = options.exit ?? ((code) => { process.exitCode = code; });
20
20
  const ci = options.ci ?? Boolean(process.env.GITHUB_ACTIONS);
21
21
  const results = [];
22
22
  for (const step of steps) {
package/package.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@brighterly/lib-fe-codestyle",
3
- "version": "1.0.0",
3
+ "version": "1.0.2",
4
4
  "type": "module",
5
5
  "license": "UNLICENSED",
6
6
  "description": "Shared invariant-validator engine, rule pack and ESLint config for Brighterly Nuxt frontend apps",
7
7
  "repository": {
8
8
  "type": "git",
9
- "url": "https://github.com/brighterly/lib-fe-codestyle.git"
9
+ "url": "git+https://github.com/brighterly/lib-fe-codestyle.git"
10
10
  },
11
11
  "publishConfig": {
12
12
  "access": "public"
@@ -20,20 +20,15 @@
20
20
  "README.md"
21
21
  ],
22
22
  "exports": {
23
- "./invariants": {
24
- "types": "./dist/invariants/index.d.ts",
25
- "default": "./dist/invariants/index.js"
26
- },
27
- "./invariants/rules": {
28
- "types": "./dist/invariants/rules/index.d.ts",
29
- "default": "./dist/invariants/rules/index.js"
30
- },
31
- "./validate": {
32
- "types": "./dist/validate/index.d.ts",
33
- "default": "./dist/validate/index.js"
23
+ ".": {
24
+ "types": "./dist/index.d.ts",
25
+ "default": "./dist/index.js"
34
26
  },
35
27
  "./eslint": "./eslint/index.mjs"
36
28
  },
29
+ "bin": {
30
+ "codestyle": "dist/cli.js"
31
+ },
37
32
  "scripts": {
38
33
  "build": "tsc -p tsconfig.build.json",
39
34
  "clean": "rm -rf dist",
@@ -1,7 +0,0 @@
1
- export { run } from './run.js';
2
- export type { RunOptions } from './run.js';
3
- export { runSelfTest } from './self-test.js';
4
- export type { Fixture, SelfTestInput, SelfTestResult } from './self-test.js';
5
- export * from './types.js';
6
- export * from './util.js';
7
- export * from './ast.js';
@@ -1,5 +0,0 @@
1
- export { run } from './run.js';
2
- export { runSelfTest } from './self-test.js';
3
- export * from './types.js';
4
- export * from './util.js';
5
- export * from './ast.js';
@@ -1,18 +0,0 @@
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
- * `core/C1` was land-app's `C1`, and reads the same everywhere. `core/C2`-`core/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
- * `core/C5`-`core/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 type { Rule } from '../types.js';
18
- export declare const cRules: Rule[];
@@ -1,17 +0,0 @@
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
- * `core/C2` and `core/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[];