@brighterly/lib-fe-codestyle 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +224 -0
- package/dist/invariants/ast.d.ts +72 -0
- package/dist/invariants/ast.js +195 -0
- package/dist/invariants/index.d.ts +7 -0
- package/dist/invariants/index.js +5 -0
- package/dist/invariants/rules/convention.d.ts +18 -0
- package/dist/invariants/rules/convention.js +226 -0
- package/dist/invariants/rules/fixtures.d.ts +17 -0
- package/dist/invariants/rules/fixtures.js +65 -0
- package/dist/invariants/rules/index.d.ts +55 -0
- package/dist/invariants/rules/index.js +53 -0
- package/dist/invariants/rules/linter.d.ts +10 -0
- package/dist/invariants/rules/linter.js +9 -0
- package/dist/invariants/rules/regression.d.ts +13 -0
- package/dist/invariants/rules/regression.js +109 -0
- package/dist/invariants/rules/shared.d.ts +18 -0
- package/dist/invariants/rules/shared.js +25 -0
- package/dist/invariants/run.d.ts +56 -0
- package/dist/invariants/run.js +553 -0
- package/dist/invariants/self-test.d.ts +38 -0
- package/dist/invariants/self-test.js +57 -0
- package/dist/invariants/types.d.ts +192 -0
- package/dist/invariants/types.js +9 -0
- package/dist/invariants/util.d.ts +113 -0
- package/dist/invariants/util.js +269 -0
- package/dist/validate/index.d.ts +36 -0
- package/dist/validate/index.js +41 -0
- package/eslint/index.mjs +187 -0
- package/eslint/rules/async-components-after-macros.mjs +63 -0
- package/eslint/rules/no-import-group-labels.mjs +72 -0
- package/eslint/rules/on-prefixed-callbacks.mjs +53 -0
- package/eslint/rules/optional-callback-chaining.mjs +78 -0
- package/eslint/rules/padding-after-macros.mjs +70 -0
- package/eslint/rules/ref-prefix-prop-mirrors.mjs +58 -0
- package/eslint/rules/require-component-name.mjs +68 -0
- package/package.json +64 -0
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,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[];
|