@brighterly/lib-fe-codestyle 1.0.0 → 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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/engine/docs.d.ts +14 -0
  11. package/dist/engine/docs.js +72 -0
  12. package/dist/engine/meta.d.ts +32 -0
  13. package/dist/engine/meta.js +47 -0
  14. package/dist/engine/migrate.d.ts +22 -0
  15. package/dist/engine/migrate.js +175 -0
  16. package/dist/engine/overrides.d.ts +36 -0
  17. package/dist/engine/overrides.js +92 -0
  18. package/dist/engine/registry.d.ts +35 -0
  19. package/dist/engine/registry.js +100 -0
  20. package/dist/engine/report.d.ts +23 -0
  21. package/dist/engine/report.js +87 -0
  22. package/dist/engine/run.d.ts +57 -0
  23. package/dist/engine/run.js +353 -0
  24. package/dist/engine/self-test.d.ts +33 -0
  25. package/dist/engine/self-test.js +141 -0
  26. package/dist/engine/types.d.ts +232 -0
  27. package/dist/{invariants → engine}/types.js +2 -1
  28. package/dist/{invariants → engine}/util.d.ts +31 -39
  29. package/dist/{invariants → engine}/util.js +59 -86
  30. package/dist/index.d.ts +16 -0
  31. package/dist/index.js +14 -0
  32. package/dist/rules/convention.d.ts +20 -0
  33. package/dist/{invariants/rules → rules}/convention.js +89 -15
  34. package/dist/rules/index.d.ts +28 -0
  35. package/dist/{invariants/rules → rules}/index.js +40 -32
  36. package/dist/{invariants/rules → rules}/regression.d.ts +1 -1
  37. package/dist/{invariants/rules → rules}/regression.js +26 -6
  38. package/dist/{invariants/rules → rules}/shared.d.ts +1 -1
  39. package/dist/validate/index.d.ts +1 -1
  40. package/dist/validate/index.js +1 -1
  41. package/package.json +8 -13
  42. package/dist/invariants/index.d.ts +0 -7
  43. package/dist/invariants/index.js +0 -5
  44. package/dist/invariants/rules/convention.d.ts +0 -18
  45. package/dist/invariants/rules/fixtures.d.ts +0 -17
  46. package/dist/invariants/rules/fixtures.js +0 -89
  47. package/dist/invariants/rules/index.d.ts +0 -55
  48. package/dist/invariants/rules/linter.d.ts +0 -10
  49. package/dist/invariants/rules/linter.js +0 -9
  50. package/dist/invariants/run.d.ts +0 -53
  51. package/dist/invariants/run.js +0 -746
  52. package/dist/invariants/self-test.d.ts +0 -46
  53. package/dist/invariants/self-test.js +0 -84
  54. package/dist/invariants/types.d.ts +0 -222
  55. /package/dist/{invariants → engine}/ast.d.ts +0 -0
  56. /package/dist/{invariants → engine}/ast.js +0 -0
  57. /package/dist/{invariants/rules → rules}/shared.js +0 -0
@@ -2,27 +2,34 @@
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
  */
17
19
  import { eachLine, setting } from './shared.js';
18
20
  const vueUseFirst = {
19
- id: 'core/C1',
21
+ name: 'vueuse-first',
20
22
  title: 'hand-rolled browser API with a VueUse equivalent',
21
23
  level: 'warn',
22
24
  why: 'VueUse handles the lifecycle, SSR and error paths we otherwise re-implement (and forget to tear down) per call site',
23
25
  fix: 'use the VueUse composable named in the message, imported explicitly from @vueuse/core',
24
26
  scope: ['app/**/*.{ts,vue}'],
25
27
  exclude: ['app/tests/**', 'app/test/**'],
28
+ fixtures: {
29
+ file: 'app/a.ts',
30
+ bad: 'window.addEventListener("resize", onResize)',
31
+ good: 'useEventListener(window, "resize", onResize)',
32
+ },
26
33
  check: ({ file, code }) => {
27
34
  const map = [
28
35
  [/new\s+IntersectionObserver\s*\(/, 'useIntersectionObserver'],
@@ -47,13 +54,25 @@ const vueUseFirst = {
47
54
  },
48
55
  };
49
56
  const uiKitReuseFirst = {
50
- id: 'core/C2',
57
+ name: 'ui-kit-unique-names',
51
58
  title: 'component duplicates an existing UI-kit component name',
52
59
  level: 'warn',
53
60
  once: true,
54
61
  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
62
  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
63
  scope: ['app/components/**/*.vue'],
64
+ // A `once` rule over the whole file list: a single-file case cannot express it.
65
+ world: [
66
+ {
67
+ files: { 'app/components/UI/Buttons/AppButton.vue': '<template />', 'app/components/Pages/Quiz/AppButton.vue': '<template />' },
68
+ expect: 'finding',
69
+ match: /duplicates the UI-kit component/,
70
+ },
71
+ {
72
+ files: { 'app/components/UI/Buttons/AppButton.vue': '<template />', 'app/components/Pages/Quiz/QuizButton.vue': '<template />' },
73
+ expect: 'silence',
74
+ },
75
+ ],
57
76
  check: ({ allFiles, settings }) => {
58
77
  const files = allFiles ?? [];
59
78
  const uiKitRoot = settings?.uiKitRoot ?? 'app/components/UI';
@@ -101,12 +120,26 @@ const onlyStyleBlocks = (source) => {
101
120
  return out + source.slice(last).replace(/[^\n]/g, ' ');
102
121
  };
103
122
  const scssColourTokens = {
104
- id: 'core/C3',
123
+ name: 'scss-tokens',
105
124
  title: 'literal colour in SCSS instead of a design token',
106
125
  level: 'warn',
107
126
  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
127
  fix: 'use a token variable from the palette file; if the colour is genuinely new, add it to the palette first',
109
128
  scope: ['app/assets/scss/**/*.scss', 'app/components/**/*.vue'],
129
+ fixtures: [
130
+ {
131
+ file: 'app/assets/scss/components/Card.scss',
132
+ bad: '.card { color: #1f2937; }',
133
+ good: '.card { color: $slate-800; }',
134
+ },
135
+ // In a .vue file only the <style> block is a stylesheet: an SVG fill in the
136
+ // template has no place to put a $token, so it must stay quiet.
137
+ {
138
+ file: 'app/components/UI/Icons/IconLeaf.vue',
139
+ bad: '<template><svg><path fill="currentColor" /></svg></template>\n<style scoped lang="scss">\n.icon { color: #e2e8f0; }\n</style>',
140
+ good: '<template><svg><path fill="#E2E8F0" /></svg></template>\n<style scoped lang="scss">\n.icon { color: $slate-200; }\n</style>',
141
+ },
142
+ ],
110
143
  check: ({ file, code, settings }) => {
111
144
  // Underscore-prefixed partials are DECLARATION files by SCSS convention in every
112
145
  // Brighterly app (_colors, _vars, _extends, _mixins, brand variants, …) — they are
@@ -129,13 +162,33 @@ const scssColourTokens = {
129
162
  },
130
163
  };
131
164
  const dateFormatEnum = {
132
- id: 'core/C4',
165
+ name: 'date-format-enum',
133
166
  title: 'hand-rolled date format string instead of the shared enum',
134
167
  level: 'warn',
135
168
  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
169
  fix: 'format through the shared DateFormat enum; never hand-roll a format string or use toISOString()',
137
170
  scope: ['app/**/*.{ts,vue}'],
138
171
  exclude: ['app/tests/**', 'app/test/**'],
172
+ fixtures: [
173
+ {
174
+ file: 'app/a.ts',
175
+ bad: "const d = dayjs().format('YYYY-MM-DD')",
176
+ good: 'const d = dayjs().format(DateFormat.DefaultDate)',
177
+ },
178
+ // The day-extraction misuse fires; a bare toISOString() (an API timestamp) and the
179
+ // epoch duration trick do not.
180
+ {
181
+ file: 'app/b.ts',
182
+ bad: "const day = new Date().toISOString().split('T')[0]",
183
+ good: 'const generatedAt = dayjs().toISOString()\nconst duration = new Date(s * 1000).toISOString().slice(14, 19)',
184
+ },
185
+ // Every letter run must be a real dayjs token — an answer label that happens to fit
186
+ // the char class ('ADHD / ADD' carries a DD) is not a format string.
187
+ {
188
+ file: 'app/c.ts',
189
+ good: "const option = { code: 'ADHD_ADD', label: 'ADHD / ADD' }",
190
+ },
191
+ ],
139
192
  check: ({ file, code, settings }) => {
140
193
  const dateModule = setting({ settings }, 'dateFormatModule', 'the shared DateFormat enum');
141
194
  // The module that DEFINES the formats is the one place the literals belong.
@@ -162,13 +215,21 @@ const dateFormatEnum = {
162
215
  },
163
216
  };
164
217
  const typesOrganization = {
165
- id: 'core/C5',
218
+ name: 'types-layout',
166
219
  title: 'type file outside the entity-folder structure',
167
220
  level: 'warn',
168
221
  once: true,
169
222
  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
223
  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
224
  scope: ['app/types/**/*.ts'],
225
+ // A `once` rule over the whole file list: a single-file case cannot express it.
226
+ world: [
227
+ { files: { 'app/types/global-types.ts': 'export type A = 1' }, expect: 'finding', match: /directly under app\/types/ },
228
+ {
229
+ files: { 'app/types/quiz/enums.ts': '', 'app/types/quiz/interfaces.ts': '', 'app/types/quiz/types.ts': '', 'app/types/globals.d.ts': '' },
230
+ expect: 'silence',
231
+ },
232
+ ],
172
233
  check: ({ allFiles, settings }) => {
173
234
  const files = allFiles ?? [];
174
235
  const root = settings?.typesRoot ?? 'app/types';
@@ -194,12 +255,17 @@ const typesOrganization = {
194
255
  },
195
256
  };
196
257
  const typographySource = {
197
- id: 'core/C6',
258
+ name: 'typography-source',
198
259
  title: 'raw font declaration instead of the app\'s typography source',
199
260
  level: 'warn',
200
261
  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
262
  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
263
  scope: ['app/assets/scss/**/*.scss', 'app/components/**/*.vue'],
264
+ fixtures: {
265
+ file: 'app/assets/scss/components/Card.scss',
266
+ bad: '.card { font-size: 14px; }',
267
+ good: '.card { @include font(14px, 20px); color: $slate-800; }',
268
+ },
203
269
  check: ({ file, code, settings }) => {
204
270
  const source = setting({ settings }, 'typographySource', 'the app\'s shared typography mixins or text components');
205
271
  // The files that DEFINE the typography are the one place raw declarations belong:
@@ -207,7 +273,7 @@ const typographySource = {
207
273
  // Brighterly apps, decided 2026-09-24), plus non-underscore fonts/typography files.
208
274
  if (/\/(?:_[^/]*|fonts|typography)\.scss$/.test(file))
209
275
  return [];
210
- // @font-face declares a face, not a usage — core/R4 polices those blocks.
276
+ // @font-face declares a face, not a usage — font-face-single-family polices those blocks.
211
277
  const stripped = code.replace(/@font-face\s*\{[^}]*\}/g, m => m.replace(/[^\n]/g, ' '));
212
278
  return eachLine(stripped, (line, n) => {
213
279
  const m = /\bfont(?:-family|-size|-weight)?\s*:/.exec(line);
@@ -218,12 +284,20 @@ const typographySource = {
218
284
  },
219
285
  };
220
286
  const scriptAttrOrder = {
221
- id: 'core/C7',
287
+ name: 'script-attr-order',
222
288
  title: 'script tag attribute order differs from the app canonical',
223
289
  level: 'warn',
224
290
  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
291
  fix: 'use the app\'s canonical order everywhere — settings.scriptAttrOrder: \'setup-first\' (default) or \'lang-first\'',
226
292
  scope: ['app/**/*.vue'],
293
+ fixtures: {
294
+ // Two script tags in OPPOSITE orders, so the bad fixture violates whichever
295
+ // direction the consuming app declares in settings.scriptAttrOrder. The good
296
+ // fixture carries no lang attribute, so it is clean under either canonical.
297
+ file: 'app/components/A.vue',
298
+ bad: '<script lang="ts" setup>\n</script>\n<script setup lang="ts">\n</script>',
299
+ good: '<template><div /></template>\n<script setup>\nconst a = 1\n</script>',
300
+ },
227
301
  check: ({ file, source, settings }) => {
228
302
  const canonical = settings?.scriptAttrOrder === 'lang-first' ? 'lang-first' : 'setup-first';
229
303
  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.1",
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[];