@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.
Files changed (36) hide show
  1. package/README.md +224 -0
  2. package/dist/invariants/ast.d.ts +72 -0
  3. package/dist/invariants/ast.js +195 -0
  4. package/dist/invariants/index.d.ts +7 -0
  5. package/dist/invariants/index.js +5 -0
  6. package/dist/invariants/rules/convention.d.ts +18 -0
  7. package/dist/invariants/rules/convention.js +226 -0
  8. package/dist/invariants/rules/fixtures.d.ts +17 -0
  9. package/dist/invariants/rules/fixtures.js +65 -0
  10. package/dist/invariants/rules/index.d.ts +55 -0
  11. package/dist/invariants/rules/index.js +53 -0
  12. package/dist/invariants/rules/linter.d.ts +10 -0
  13. package/dist/invariants/rules/linter.js +9 -0
  14. package/dist/invariants/rules/regression.d.ts +13 -0
  15. package/dist/invariants/rules/regression.js +109 -0
  16. package/dist/invariants/rules/shared.d.ts +18 -0
  17. package/dist/invariants/rules/shared.js +25 -0
  18. package/dist/invariants/run.d.ts +56 -0
  19. package/dist/invariants/run.js +553 -0
  20. package/dist/invariants/self-test.d.ts +38 -0
  21. package/dist/invariants/self-test.js +57 -0
  22. package/dist/invariants/types.d.ts +192 -0
  23. package/dist/invariants/types.js +9 -0
  24. package/dist/invariants/util.d.ts +113 -0
  25. package/dist/invariants/util.js +269 -0
  26. package/dist/validate/index.d.ts +36 -0
  27. package/dist/validate/index.js +41 -0
  28. package/eslint/index.mjs +187 -0
  29. package/eslint/rules/async-components-after-macros.mjs +63 -0
  30. package/eslint/rules/no-import-group-labels.mjs +72 -0
  31. package/eslint/rules/on-prefixed-callbacks.mjs +53 -0
  32. package/eslint/rules/optional-callback-chaining.mjs +78 -0
  33. package/eslint/rules/padding-after-macros.mjs +70 -0
  34. package/eslint/rules/ref-prefix-prop-mirrors.mjs +58 -0
  35. package/eslint/rules/require-component-name.mjs +68 -0
  36. package/package.json +64 -0
package/README.md ADDED
@@ -0,0 +1,224 @@
1
+ # `@brighterly/lib-fe-codestyle`
2
+
3
+ Shared, deterministic code-review machinery for the Brighterly Nuxt apps — land-app,
4
+ cs-app, app-tutor, app-admin.
5
+
6
+ It holds the **mechanisms**. Domain rules stay in each app.
7
+
8
+ ## Subpaths
9
+
10
+ | Import | What it is |
11
+ |---|---|
12
+ | `@brighterly/lib-fe-codestyle/invariants` | the invariants engine — `run(options)`, the rule contract, AST helpers, util |
13
+ | `@brighterly/lib-fe-codestyle/invariants/rules` | the common rule pack, portable across all apps |
14
+ | `@brighterly/lib-fe-codestyle/eslint` | the shared flat config, its seven local rules, and the maps behind them |
15
+ | `@brighterly/lib-fe-codestyle/validate` | the aggregating runner behind `pnpm validate` |
16
+
17
+ `exports` **is** the public surface. Anything not listed is private — a linked package
18
+ resolves deep paths that break the moment it is published.
19
+
20
+ ## The one design rule: export data, never factories
21
+
22
+ Consumers compose. There is no `defineBrighterlyEslint({...})`, and there never will be —
23
+ the four apps' configs are ~95% identical and the variance is mostly drift, so a factory
24
+ would parameterise accidental difference.
25
+
26
+ ```js
27
+ // an app that needs nothing extra
28
+ import brighterly from '@brighterly/lib-fe-codestyle/eslint'
29
+ export default [...brighterly]
30
+ ```
31
+
32
+ ```js
33
+ // an app extending a map — plain object spread, no API to learn
34
+ import brighterly, { checkFileFolders } from '@brighterly/lib-fe-codestyle/eslint'
35
+
36
+ export default [
37
+ ...brighterly,
38
+ { rules: { 'check-file/folder-naming-convention': ['error', {
39
+ ...checkFileFolders,
40
+ 'app/components/pages/**/': 'KEBAB_CASE',
41
+ }] } },
42
+ ]
43
+ ```
44
+
45
+ ESLint **replaces** rule options rather than merging them, so appending an override without
46
+ spreading the shared map silently discards every shared entry. That is why the maps are
47
+ exported as data.
48
+
49
+
50
+ ## What the rule pack ships
51
+
52
+ ### Invariants — `@brighterly/lib-fe-codestyle/invariants/rules`
53
+
54
+ | Rule | Level | Checks |
55
+ |---|---|---|
56
+ | `_R1` | error | bare `localStorage` / `sessionStorage` — the accessor itself throws on Safari ITP and in-app webviews |
57
+ | `_R2` | error | SCSS rules inline in a Vue `<style>` block instead of a referenced `.scss` file |
58
+ | `_R3` | error | `JSON.parse` with no enclosing `try`/`catch` — a corrupt entry breaks that path permanently |
59
+ | `_R4` | error | `@font-face` declaring a font *stack* — an invalid descriptor, so the browser drops the face silently |
60
+ | `_C1` | warn | hand-rolled browser API with a VueUse equivalent |
61
+ | `_C2` | warn | a component duplicating an existing UI-kit component name |
62
+ | `_C3` | warn | a literal colour in SCSS instead of a design token |
63
+ | `_C4` | warn | a hand-rolled date format string, or `toISOString()` |
64
+ | `_C5` | warn | a type file outside the `types/<entity>/{enums,interfaces,types}.ts` structure |
65
+ | `_C6` | warn | a raw `font-family` / `font-size` / `font-weight` declaration instead of the app's typography source |
66
+ | `_C7` | warn | `<script>` attribute order differing from the app's canonical (`setup lang` vs `lang setup`) |
67
+
68
+ `_R*` were ported from land-app, where each was a production scar (as `R1`, `R7`, `R18`,
69
+ `R28` — renumbered sequentially when the prefix landed, pre-publish, while no released
70
+ version pinned the old tokens). `_C*` are conventions with
71
+ no incident behind them *yet* — they ship at `warn` so a consuming app can measure what they
72
+ find before anyone decides to block on them. Promoting one to `error` is a **major** bump.
73
+
74
+ Fixtures ship with the rules (`commonFixtures`), so `--self-test` works in any consumer
75
+ without it having to write fixtures for rules it did not author.
76
+
77
+ ### ESLint — `@brighterly/lib-fe-codestyle/eslint`
78
+
79
+ Seven local rules: `padding-after-macros`, `async-components-after-macros`,
80
+ `no-import-group-labels`, `ref-prefix-prop-mirrors`, `require-component-name`,
81
+ `on-prefixed-callbacks`, and `optional-callback-chaining` (new — an optional callback
82
+ invoked without `?.` throws for the first caller that omits it).
83
+
84
+ Plus the shared flat config: restricted syntax, restricted imports, the types-organization
85
+ blocks, and the `check-file` naming maps — all exported as data so a consumer can spread
86
+ and extend them.
87
+
88
+ ## App-specific paths: `settings`, not parameters
89
+
90
+ A shared rule must not hardcode a consuming app's paths. Each app declares them once:
91
+
92
+ ```ts
93
+ // invariants/config.mts
94
+ export const settings = {
95
+ safeStorageModule: 'safeLocalStorage from @helpers/safe-local-storage',
96
+ safeJsonParse: 'parseStoredJson() from @helpers/safe-local-storage',
97
+ scssTokensFile: 'app/assets/scss/_colors.scss',
98
+ uiKitRoot: 'app/components/UI',
99
+ dateFormatModule: 'DateFormat from @ts-types/date/enums',
100
+ typesRoot: 'app/types', // _C5 (default shown)
101
+ typographySource: 'the font() mixin from @scss/_mixins.scss', // _C6 — cs-app points this at its AppText component instead
102
+ scriptAttrOrder: 'setup-first', // _C7 — or 'lang-first' (default 'setup-first')
103
+ }
104
+ ```
105
+
106
+ A missing key degrades the rule's *message*, never its firing. A rule that went quiet
107
+ because a path was unset would be the exact silent failure this package exists to prevent.
108
+ Two keys shape behaviour rather than the message — `typesRoot` (where `_C5` looks) and
109
+ `scriptAttrOrder` (which direction `_C7` flags) — and both fall back to the documented
110
+ default above, so leaving them unset still never silences the rule.
111
+
112
+ ## What consumers see in the IDE
113
+
114
+ Three hover surfaces, so "what rules do we have and what do they look for" never
115
+ requires opening this repo:
116
+
117
+ - **`sharedRuleIds`** (from `…/invariants/rules`) — the catalog: one constant per rule,
118
+ each JSDoc'd with level, what it checks, and the fix. Use them as `allowlists` /
119
+ `levels` keys (`[sharedRuleIds._C6]: 'off'`) and hover for the contract. A test pins
120
+ the catalog to the shipped rules, so it cannot drift.
121
+ - **`SharedRuleSettings`** (from `…/invariants`) — annotate the app's `settings` const
122
+ with it to get completion plus the per-key contract, and a type error on an invalid
123
+ `scriptAttrOrder` value.
124
+ - **ESLint squiggles** — every local rule ships `meta.docs.description`, which is what
125
+ the editor shows on hover over a violation.
126
+
127
+ PhpStorm / WebStorm notes: the IDE runs the **project's own ESLint binary** against the
128
+ flat `eslint.config.mjs`, so rules defined inside this package just work — nothing to
129
+ install or configure beyond the default "Automatic ESLint configuration". Two real
130
+ behaviours to know: the ESLint daemon caches the resolved config, so after upgrading
131
+ this package run *ESLint: Restart Service* (or reopen the project) to pick up new
132
+ rules; and the invariants engine is **not** an IDE inspection — its findings appear in
133
+ the terminal (`pnpm validate:invariants`) and the pre-commit hook only.
134
+
135
+ ## Wiring a consumer
136
+
137
+ 1. `pnpm add -D @brighterly/lib-fe-codestyle` — plus, if not already direct devDeps,
138
+ `@vue/compiler-sfc` and `typescript`. Both are **peers, not dependencies**, on
139
+ purpose: the validator must parse your files with the *same* compiler instance your
140
+ build uses, so the package dedupes onto the app's copy instead of carrying its own.
141
+ Every Brighterly Nuxt app already has both in the store — the devDep line adds a
142
+ symlink, not a download.
143
+ 2. An `invariants/` directory with `cli.mts` (a ~20-line composition root — copy
144
+ land-app's), `config.mts` (allowlists, levels, retired, unmaintained, `settings` —
145
+ see above), your app-local rules, and `specs/` for checks needing real module
146
+ resolution.
147
+ 3. A missing peer is loud, never silent: pnpm warns at install (and auto-installs by
148
+ default), and the engine's lazy `import()` throws on the first AST rule otherwise.
149
+ 4. Arm the pre-commit hook from install, not by hand: keep the hook script in a tracked
150
+ directory and add `git config core.hooksPath <that-dir> || true` to `postinstall`
151
+ (the `|| true` keeps .git-less installs — Docker builds — green). Setup stays
152
+ `pnpm i && pnpm dev`; a hand-made `.git/hooks` symlink dies with every fresh clone.
153
+
154
+ An app with a pre-extraction fork of the engine (app-funnels) deletes its copied
155
+ `ast/util/run/self-test` plumbing and keeps only domain rules + config; its existing
156
+ compiler-sfc devDep already satisfies the peer.
157
+
158
+ ## Why `src/` is compiled but `eslint/` is not
159
+
160
+ Node refuses to strip types inside `node_modules`
161
+ (`ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING`), so the TypeScript half ships as `dist/`.
162
+ The ESLint config and its seven rules are already plain `.mjs` and ship as-is.
163
+
164
+ ## Rule IDs
165
+
166
+ Shared rules carry an **underscore prefix**: `_R1`, `_C3`. App-local rules stay bare:
167
+ `R1`, `B2`. The prefix marks provenance — an `_`-id is shipped by this package, never
168
+ defined, renumbered or recycled in a consumer — and it keeps the two number lines
169
+ independent: every app allocates its own `R1, R2, …` naturally, with no risk of
170
+ colliding with a rule the package ships later. `run()` still exits 2 on a duplicate,
171
+ so a collision is loud, not silent.
172
+
173
+ Within the package each family is numbered sequentially with no gaps (`_R1`–`_R4`,
174
+ `_C1`–`_C7`) — enforced by `test/invariants/rule-ids.test.ts`. The family letter keeps
175
+ meaning provenance — `R` regression scar, `B` business, `C` convention, `L`
176
+ linter-owned — not topic. The underscore does **not** mean private (everything here is
177
+ the public surface); it means "owned by the library, hands off the numbering."
178
+
179
+ ## Versioning is about blocking power
180
+
181
+ One question decides the bump: **can this change turn a green consumer red, or break
182
+ something a consumer wrote against a rule id?** Yes → major. Adds signal without
183
+ blocking → minor. Everything else → patch.
184
+
185
+ | Change | Bump | Why |
186
+ |---|---|---|
187
+ | New invariant rule at `error` | **major** | can turn a green repo red on install |
188
+ | Promoting a rule `warn` → `error` | **major** | same — new blocking power |
189
+ | Widening a rule's `scope`, narrowing its `exclude`, or making its match stricter | **major** | existing code that passed now fails |
190
+ | Renaming, renumbering or removing a rule id | **major** | breaks every `invariant-ignore:` comment, `allowlists`/`levels` key and doc that names the token — `IG1` starts firing in every consumer |
191
+ | New ESLint rule at `error`, or adding a selector to `restrictedSyntax` / `restrictedImports` / the `check-file` maps | **major** | the shared config is spread into every app's lint gate |
192
+ | Engine behavior change (`run()` exit codes, finding shape, config contract) | **major** | consumers' composition roots are written against it |
193
+ | New invariant rule at `warn` | minor | new signal, blocks nothing |
194
+ | New ESLint rule at `warn` | minor | same |
195
+ | New export (a map, a helper, a settings key a rule *optionally* reads) | minor | additive surface |
196
+ | Demoting a rule `error` → `warn`, or narrowing its scope | patch | strictly relaxing |
197
+ | Bug fix that removes false positives, message/`why`/`fix` wording, fixture improvements | patch | nothing new fires |
198
+
199
+ Two edges worth naming: a bug fix that removes **false negatives** (the rule now
200
+ catches what it always claimed to) is still **major** — correctness of intent does not
201
+ change what happens in a consumer's CI. And a new `settings` key a rule *requires* is
202
+ major; one it merely reads for a better message is minor (a missing key degrades the
203
+ message, never the firing — see above).
204
+
205
+ ## Publishing
206
+
207
+ The package is published **publicly** to npmjs.org under the `@brighterly` org — the
208
+ same channel as `@brighterly/lib-lumee-ui`, so consumers need no registry config or
209
+ token. `publishConfig.access: "public"` in `package.json` makes the scoped publish
210
+ work without a paid org plan.
211
+
212
+ ```bash
213
+ npm login # must be a member of the @brighterly org
214
+ npm version <patch|minor|major> # per the blocking-power table above
215
+ npm publish # prepublishOnly rebuilds dist and runs the tests first
216
+ git push && git push --tags
217
+ ```
218
+
219
+ `dist/` is gitignored; `prepublishOnly` (`clean → build → test`) guarantees the
220
+ tarball is built from the current sources, never a stale artifact.
221
+
222
+ ## Design
223
+
224
+ `land-app/docs/superpowers/specs/2026-09-22-fe-codestyle-design.md`
@@ -0,0 +1,72 @@
1
+ /**
2
+ * Real ASTs for the rules that regex cannot express honestly.
3
+ *
4
+ * Two parsers, both direct devDependencies, both loaded lazily so a `--rule=R2`
5
+ * run pays for neither:
6
+ * typescript 102 ms load · 153 ms for all 423 app .ts files
7
+ * @vue/compiler-sfc 92 ms load · 156 ms for all 398 app .vue files (template AST)
8
+ *
9
+ * compiler-sfc was already in the pnpm store transitively (vue-router, vue-gtm,
10
+ * eslint-processor-vue-blocks); making it explicit added a symlink, not a
11
+ * download. It exists because four checks in R12/R16 could not be expressed
12
+ * honestly on lines: a file-wide `data-qa` grep passed when the attribute sat on
13
+ * an inner <div>, a nested child component's `<template #body>` tripped the
14
+ * ordering check, `class="bold indigo"` slipped past a `class="indigo"` regex,
15
+ * and a multi-line `:title` binding hid a <br> from a per-line scan.
16
+ */
17
+ /**
18
+ * Every `JSON.parse(...)` call with a flag for whether a real TryStatement
19
+ * encloses it — an ancestor walk, not a guess about nearby lines.
20
+ */
21
+ export declare const findJsonParseCalls: (file: string, source: string) => Promise<{
22
+ line: number;
23
+ guarded: boolean;
24
+ }[]>;
25
+ /**
26
+ * Every static import that survives compilation: default, named, namespace and bare side-effect
27
+ * imports plus `export … from` re-exports, in .ts files and in every script block of a .vue file.
28
+ * Only `import type` / `export type` declarations are dropped — with `verbatimModuleSyntax` (the Nuxt
29
+ * default) an inline `import { type X } from 'pkg'` still loads the module, so it counts.
30
+ */
31
+ export declare const findStaticImports: (file: string, source: string) => Promise<{
32
+ specifier: string;
33
+ line: number;
34
+ }[]>;
35
+ /** One attribute or binding, normalised so a rule need not know the node types. */
36
+ export interface TemplateAttr {
37
+ /** Attribute name without the `:` / `v-bind:` prefix — `data-qa`, `class`, `title`. */
38
+ name: string;
39
+ /** Static value, or the raw expression source for a binding. Null for a bare attribute. */
40
+ value: string | null;
41
+ /** True for `:title` / `v-bind:title`, false for `title`. */
42
+ bound: boolean;
43
+ line: number;
44
+ }
45
+ /**
46
+ * An element in a Vue template, flattened to what invariant rules actually ask
47
+ * about. Deliberately not a re-export of compiler-sfc's node types: rules stay
48
+ * readable, and a compiler upgrade changes this file only.
49
+ */
50
+ export interface TemplateEl {
51
+ tag: string;
52
+ line: number;
53
+ attrs: TemplateAttr[];
54
+ /** Element and <template> children, in document order. Text nodes dropped. */
55
+ children: TemplateEl[];
56
+ /** For `<template #title>` / `#[slotName]` — the slot name or expression. */
57
+ slot: string | null;
58
+ /** True when this element carries a `v-for`. */
59
+ vFor: boolean;
60
+ }
61
+ /**
62
+ * Root elements of a .vue file's <template>. Empty array when the file has no
63
+ * template or the template does not parse — a rule must treat that as "nothing
64
+ * to say", never as "compliant".
65
+ */
66
+ export declare const templateElements: (file: string, source: string) => Promise<TemplateEl[]>;
67
+ /** Every element with the given tag, at any depth, in document order. */
68
+ export declare const elementsByTag: (roots: TemplateEl[], tag: string) => TemplateEl[];
69
+ /** Every element at any depth, in document order. */
70
+ export declare const allElements: (roots: TemplateEl[]) => TemplateEl[];
71
+ /** An element's attribute by name, static or bound. */
72
+ export declare const attrOf: (el: TemplateEl, name: string) => TemplateAttr | undefined;
@@ -0,0 +1,195 @@
1
+ /**
2
+ * Real ASTs for the rules that regex cannot express honestly.
3
+ *
4
+ * Two parsers, both direct devDependencies, both loaded lazily so a `--rule=R2`
5
+ * run pays for neither:
6
+ * typescript 102 ms load · 153 ms for all 423 app .ts files
7
+ * @vue/compiler-sfc 92 ms load · 156 ms for all 398 app .vue files (template AST)
8
+ *
9
+ * compiler-sfc was already in the pnpm store transitively (vue-router, vue-gtm,
10
+ * eslint-processor-vue-blocks); making it explicit added a symlink, not a
11
+ * download. It exists because four checks in R12/R16 could not be expressed
12
+ * honestly on lines: a file-wide `data-qa` grep passed when the attribute sat on
13
+ * an inner <div>, a nested child component's `<template #body>` tripped the
14
+ * ordering check, `class="bold indigo"` slipped past a `class="indigo"` regex,
15
+ * and a multi-line `:title` binding hid a <br> from a per-line scan.
16
+ */
17
+ let tsModule = null;
18
+ const ts = async () => {
19
+ if (!tsModule)
20
+ tsModule = (await import('typescript')).default;
21
+ return tsModule;
22
+ };
23
+ /**
24
+ * EVERY script body of a .vue file, so the same AST rules work on SFCs.
25
+ *
26
+ * Plural on purpose: an SFC may carry both `<script>` and `<script setup>`. A
27
+ * non-global match returned only the first, so anything in the second block was
28
+ * invisible — and a rule that silently stops matching looks exactly like a clean
29
+ * file, which is the failure mode this directory exists to prevent.
30
+ *
31
+ * The offset is computed from the match geometry (`index` + the opening tag's
32
+ * length), not by searching for the body inside the match: a body that happens to
33
+ * repeat its own opening tag would resolve to the wrong position.
34
+ */
35
+ const scriptsOf = (file, source) => {
36
+ if (!file.endsWith('.vue'))
37
+ return [{ code: source, lineOffset: 0 }];
38
+ const out = [];
39
+ const re = /<script[^>]*>([\s\S]*?)<\/script>/g;
40
+ let m;
41
+ while ((m = re.exec(source)) !== null) {
42
+ if (m[1] === undefined)
43
+ continue;
44
+ const bodyStart = m.index + m[0].length - m[1].length - '</script>'.length;
45
+ out.push({ code: m[1], lineOffset: source.slice(0, bodyStart).split('\n').length - 1 });
46
+ }
47
+ return out;
48
+ };
49
+ /**
50
+ * Every `JSON.parse(...)` call with a flag for whether a real TryStatement
51
+ * encloses it — an ancestor walk, not a guess about nearby lines.
52
+ */
53
+ export const findJsonParseCalls = async (file, source) => {
54
+ const t = await ts();
55
+ const out = [];
56
+ for (const script of scriptsOf(file, source)) {
57
+ const sf = t.createSourceFile(file, script.code, t.ScriptTarget.Latest, true);
58
+ const visit = (node) => {
59
+ if (t.isCallExpression(node)) {
60
+ const callee = node.expression;
61
+ const isJsonParse = t.isPropertyAccessExpression(callee)
62
+ && callee.name.text === 'parse'
63
+ && callee.expression.getText(sf) === 'JSON';
64
+ if (isJsonParse) {
65
+ let parent = node.parent;
66
+ let guarded = false;
67
+ while (parent) {
68
+ if (t.isTryStatement(parent)) {
69
+ guarded = true;
70
+ break;
71
+ }
72
+ parent = parent.parent;
73
+ }
74
+ out.push({
75
+ line: sf.getLineAndCharacterOfPosition(node.getStart(sf)).line + 1 + script.lineOffset,
76
+ guarded,
77
+ });
78
+ }
79
+ }
80
+ t.forEachChild(node, visit);
81
+ };
82
+ visit(sf);
83
+ }
84
+ return out;
85
+ };
86
+ /**
87
+ * Every static import that survives compilation: default, named, namespace and bare side-effect
88
+ * imports plus `export … from` re-exports, in .ts files and in every script block of a .vue file.
89
+ * Only `import type` / `export type` declarations are dropped — with `verbatimModuleSyntax` (the Nuxt
90
+ * default) an inline `import { type X } from 'pkg'` still loads the module, so it counts.
91
+ */
92
+ export const findStaticImports = async (file, source) => {
93
+ const t = await ts();
94
+ const out = [];
95
+ for (const script of scriptsOf(file, source)) {
96
+ const sf = t.createSourceFile(file, script.code, t.ScriptTarget.Latest, true);
97
+ for (const node of sf.statements) {
98
+ const isImport = t.isImportDeclaration(node) && !node.importClause?.isTypeOnly;
99
+ const isReExport = t.isExportDeclaration(node) && node.moduleSpecifier !== undefined && !node.isTypeOnly;
100
+ if (!isImport && !isReExport)
101
+ continue;
102
+ const specifier = node.moduleSpecifier;
103
+ if (specifier === undefined || !t.isStringLiteral(specifier))
104
+ continue;
105
+ out.push({ specifier: specifier.text, line: sf.getLineAndCharacterOfPosition(node.getStart(sf)).line + 1 + script.lineOffset });
106
+ }
107
+ }
108
+ return out;
109
+ };
110
+ // ── Vue template AST ───────────────────────────────────────────────────────
111
+ let sfcModule = null;
112
+ const sfc = async () => {
113
+ if (!sfcModule)
114
+ sfcModule = await import('@vue/compiler-sfc');
115
+ return sfcModule;
116
+ };
117
+ const ELEMENT = 1;
118
+ const ATTRIBUTE = 6;
119
+ const DIRECTIVE = 7;
120
+ const normalise = (node) => {
121
+ if (!node || node.type !== ELEMENT)
122
+ return null;
123
+ const attrs = [];
124
+ let slot = null;
125
+ let vFor = false;
126
+ for (const p of node.props ?? []) {
127
+ const line = p.loc?.start?.line ?? node.loc?.start?.line ?? 1;
128
+ if (p.type === ATTRIBUTE) {
129
+ attrs.push({ name: p.name ?? '', value: p.value?.content ?? null, bound: false, line });
130
+ continue;
131
+ }
132
+ if (p.type !== DIRECTIVE)
133
+ continue;
134
+ if (p.name === 'for') {
135
+ vFor = true;
136
+ continue;
137
+ }
138
+ if (p.name === 'slot') {
139
+ // `#title` → arg.content 'title'; `#[slotName]` → dynamic, keep the source
140
+ slot = p.arg ? String(p.arg.content ?? p.arg.loc?.source ?? '') : 'default';
141
+ continue;
142
+ }
143
+ if (p.name === 'bind' && p.arg) {
144
+ attrs.push({ name: String(p.arg.content ?? ''), value: p.exp?.content ?? p.exp?.loc?.source ?? null, bound: true, line });
145
+ }
146
+ }
147
+ return {
148
+ tag: node.tag ?? '',
149
+ line: node.loc?.start?.line ?? 1,
150
+ attrs,
151
+ children: (node.children ?? []).map(c => normalise(c)).filter((c) => c !== null),
152
+ slot,
153
+ vFor,
154
+ };
155
+ };
156
+ /**
157
+ * Root elements of a .vue file's <template>. Empty array when the file has no
158
+ * template or the template does not parse — a rule must treat that as "nothing
159
+ * to say", never as "compliant".
160
+ */
161
+ export const templateElements = async (file, source) => {
162
+ if (!file.endsWith('.vue'))
163
+ return [];
164
+ const { parse } = await sfc();
165
+ let ast;
166
+ try {
167
+ ast = parse(source, { filename: file }).descriptor.template?.ast;
168
+ }
169
+ catch {
170
+ return [];
171
+ }
172
+ if (!ast)
173
+ return [];
174
+ return (ast.children ?? []).map(c => normalise(c)).filter((c) => c !== null);
175
+ };
176
+ /** Every element with the given tag, at any depth, in document order. */
177
+ export const elementsByTag = (roots, tag) => {
178
+ const out = [];
179
+ const visit = (el) => {
180
+ if (el.tag === tag)
181
+ out.push(el);
182
+ el.children.forEach(visit);
183
+ };
184
+ roots.forEach(visit);
185
+ return out;
186
+ };
187
+ /** Every element at any depth, in document order. */
188
+ export const allElements = (roots) => {
189
+ const out = [];
190
+ const visit = (el) => { out.push(el); el.children.forEach(visit); };
191
+ roots.forEach(visit);
192
+ return out;
193
+ };
194
+ /** An element's attribute by name, static or bound. */
195
+ export const attrOf = (el, name) => el.attrs.find(a => a.name === name);
@@ -0,0 +1,7 @@
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';
@@ -0,0 +1,5 @@
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';
@@ -0,0 +1,18 @@
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 type { Rule } from '../types.js';
18
+ export declare const cRules: Rule[];