@brighterly/lib-fe-codestyle 0.3.0 → 0.5.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.
- package/README.md +13 -6
- package/dist/invariants/ast.d.ts +82 -0
- package/dist/invariants/ast.js +270 -0
- package/dist/invariants/rules/index.d.ts +2 -2
- package/dist/invariants/rules/index.js +2 -2
- package/dist/invariants/rules/regression.js +10 -2
- package/dist/invariants/run.d.ts +2 -5
- package/dist/invariants/run.js +241 -52
- package/dist/invariants/self-test.d.ts +11 -3
- package/dist/invariants/self-test.js +41 -14
- package/dist/invariants/types.d.ts +32 -4
- package/dist/invariants/util.d.ts +33 -8
- package/dist/invariants/util.js +190 -11
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -5,6 +5,10 @@ cs-app, app-tutor, app-admin.
|
|
|
5
5
|
|
|
6
6
|
It holds the **mechanisms**. Domain rules stay in each app.
|
|
7
7
|
|
|
8
|
+
**New here?** [docs/how-it-works.md](docs/how-it-works.md) — how a check reads real
|
|
9
|
+
code, what the gate can and cannot cover, what is already enforced, how to extend it,
|
|
10
|
+
and how to run it.
|
|
11
|
+
|
|
8
12
|
## Subpaths
|
|
9
13
|
|
|
10
14
|
| Import | What it is |
|
|
@@ -53,9 +57,9 @@ exported as data.
|
|
|
53
57
|
|
|
54
58
|
| Rule | Level | Checks |
|
|
55
59
|
|---|---|---|
|
|
56
|
-
| `core/R1` |
|
|
60
|
+
| `core/R1` | warn* | bare `localStorage` / `sessionStorage` — the accessor itself throws on Safari ITP and in-app webviews |
|
|
57
61
|
| `core/R2` | error | SCSS rules inline in a Vue `<style>` block instead of a referenced `.scss` file |
|
|
58
|
-
| `core/R3` |
|
|
62
|
+
| `core/R3` | warn* | `JSON.parse` with no enclosing `try`/`catch` — a corrupt entry breaks that path permanently |
|
|
59
63
|
| `core/R4` | error | `@font-face` declaring a font *stack* — an invalid descriptor, so the browser drops the face silently |
|
|
60
64
|
| `core/C1` | warn | hand-rolled browser API with a VueUse equivalent |
|
|
61
65
|
| `core/C2` | warn | a component duplicating an existing UI-kit component name |
|
|
@@ -65,10 +69,12 @@ exported as data.
|
|
|
65
69
|
| `core/C6` | warn | a raw `font-family` / `font-size` / `font-weight` declaration instead of the app's typography source |
|
|
66
70
|
| `core/C7` | warn | `<script>` attribute order differing from the app's canonical (`setup lang` vs `lang setup`) |
|
|
67
71
|
|
|
68
|
-
Level discipline: `error` is
|
|
69
|
-
|
|
70
|
-
(
|
|
71
|
-
|
|
72
|
+
Level discipline: `error` is for crash-class rules (`core/R*`); `warn` is for
|
|
73
|
+
conventions (`core/C*`). `core/R1`/`core/R3` (marked *) ship at `warn` **as an interim
|
|
74
|
+
state** (2026-09-25): their fleet backlog is real code nobody has bandwidth to migrate
|
|
75
|
+
and re-verify right now. Their target level is `error` — promote once the backlog
|
|
76
|
+
clears (a breaking-style bump). `core/R2`/`core/R4` stay `error`. A consumer's `levels`
|
|
77
|
+
ceiling is still never the tool for a backlog on an `error` rule — fix or allowlist.
|
|
72
78
|
Underscore-prefixed SCSS partials (`_extends.scss`, `_colors-*.scss`, …) are declaration
|
|
73
79
|
files by convention in every Brighterly app and are exempt from `core/C3`/`core/C6` wholesale.
|
|
74
80
|
|
|
@@ -108,6 +114,7 @@ export const settings = {
|
|
|
108
114
|
typesRoot: 'app/types', // core/C5 (default shown)
|
|
109
115
|
typographySource: 'the font() mixin from @scss/_mixins.scss', // core/C6 — cs-app points this at its AppText component instead
|
|
110
116
|
scriptAttrOrder: 'setup-first', // core/C7 — or 'lang-first' (default 'setup-first')
|
|
117
|
+
selfTestFile: 'invariants/self-test.mts', // its ignore-strings are fixture DATA — skipped by IG0/IG1
|
|
111
118
|
}
|
|
112
119
|
```
|
|
113
120
|
|
package/dist/invariants/ast.d.ts
CHANGED
|
@@ -32,6 +32,43 @@ export declare const findStaticImports: (file: string, source: string) => Promis
|
|
|
32
32
|
specifier: string;
|
|
33
33
|
line: number;
|
|
34
34
|
}[]>;
|
|
35
|
+
export interface InlineLiteral {
|
|
36
|
+
line: number;
|
|
37
|
+
/** What the literal was fed to — the rule picks the fix per kind. */
|
|
38
|
+
kind: 'copy' | 'test-id' | 'option' | 'tag';
|
|
39
|
+
/** Source text of the literal, for the message. */
|
|
40
|
+
text: string;
|
|
41
|
+
/** The sink method (copy / test-id), `method.key` for an option, `tag` for a tag. */
|
|
42
|
+
via: string;
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Every place a file addresses the app through an inline literal instead of the
|
|
46
|
+
* contract: a string, no-substitution template or RegExp literal fed to a
|
|
47
|
+
* copy sink, a test-id sink, a copy-carrying option (`getByRole` `name`,
|
|
48
|
+
* `filter` / `locator` `hasText` / `hasNotText`), or a test `tag`. Array
|
|
49
|
+
* arguments (`toHaveText(['a', 'b'])`, `tag: [...]`) are unwrapped. An
|
|
50
|
+
* identifier, a member expression (`copy.x`, `StepName.Grade`) and a template
|
|
51
|
+
* WITH an interpolation are the allowed forms. Facts only — the rule phrases the
|
|
52
|
+
* finding. Regex cannot do this honestly: the literal may sit inside nested
|
|
53
|
+
* parentheses or a multi-line options object, and a commented-out call must
|
|
54
|
+
* stay silent.
|
|
55
|
+
*
|
|
56
|
+
* Deliberately broad on `tag:` — it fires in ANY object literal, so
|
|
57
|
+
* `const opts = { tag: '@x' }; test('t', opts, …)` is caught; a non-Playwright
|
|
58
|
+
* `{ tag: 'h1' }` inside a test tree is the accepted rare false positive.
|
|
59
|
+
*/
|
|
60
|
+
export declare const findInlineLiterals: (file: string, source: string) => Promise<InlineLiteral[]>;
|
|
61
|
+
/**
|
|
62
|
+
* `let` / `var` declarations that outlive a single test — at module scope or inside a
|
|
63
|
+
* `describe` callback. A declaration inside a test body, a `test.step` callback or
|
|
64
|
+
* a helper function is per-test and ignored; `const` and `using` are ignored everywhere.
|
|
65
|
+
*/
|
|
66
|
+
export declare const findSharedMutables: (file: string, source: string) => Promise<{
|
|
67
|
+
line: number;
|
|
68
|
+
kind: "let" | "var";
|
|
69
|
+
name: string;
|
|
70
|
+
scope: "module" | "describe";
|
|
71
|
+
}[]>;
|
|
35
72
|
/** One attribute or binding, normalised so a rule need not know the node types. */
|
|
36
73
|
export interface TemplateAttr {
|
|
37
74
|
/** Attribute name without the `:` / `v-bind:` prefix — `data-qa`, `class`, `title`. */
|
|
@@ -70,3 +107,48 @@ export declare const elementsByTag: (roots: TemplateEl[], tag: string) => Templa
|
|
|
70
107
|
export declare const allElements: (roots: TemplateEl[]) => TemplateEl[];
|
|
71
108
|
/** An element's attribute by name, static or bound. */
|
|
72
109
|
export declare const attrOf: (el: TemplateEl, name: string) => TemplateAttr | undefined;
|
|
110
|
+
export interface OptionLiteral {
|
|
111
|
+
/** The `id` property's line, or the object's when it has none. */
|
|
112
|
+
line: number;
|
|
113
|
+
/** The `value` label when it is a literal — for the message. */
|
|
114
|
+
value: string | null;
|
|
115
|
+
/** `missing`: no `id`; `computed`: an `id` that is not a string literal; `literal` otherwise. */
|
|
116
|
+
kind: 'missing' | 'computed' | 'literal';
|
|
117
|
+
/** The literal's cooked value, or the expression's source when computed. */
|
|
118
|
+
id: string | null;
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* Every object literal shaped like a `SelectOption` — it has both a `value` and a `selected`
|
|
122
|
+
* property — with what its `id` is, so a rule can judge the id without re-parsing the file. An
|
|
123
|
+
* object built from a spread is skipped: its id may well come from the spread, and reporting it
|
|
124
|
+
* would be a guess.
|
|
125
|
+
*/
|
|
126
|
+
export declare const findOptionLiterals: (file: string, source: string) => Promise<OptionLiteral[]>;
|
|
127
|
+
/**
|
|
128
|
+
* Module- or describe-scope `const` holding a mutable container — `{}`, `[]`, `new Map()` … — that
|
|
129
|
+
* nothing freezes: the `let` of shared state under a different keyword. `as const` and
|
|
130
|
+
* `Object.freeze(…)` clear it.
|
|
131
|
+
*/
|
|
132
|
+
export declare const findSharedContainers: (file: string, source: string) => Promise<{
|
|
133
|
+
line: number;
|
|
134
|
+
name: string;
|
|
135
|
+
scope: "module" | "describe";
|
|
136
|
+
}[]>;
|
|
137
|
+
/**
|
|
138
|
+
* Calls to the callees `accept` names whose promise is not taken up where it is made: not under
|
|
139
|
+
* `await`, not returned, not the expression body of an arrow (which hands it to whoever calls the
|
|
140
|
+
* arrow — `booking.submitted(() => funnel.bookFirstSlot(…))` relies on exactly that). A bare
|
|
141
|
+
* statement drops it; an assignment or an argument parks it for a later that usually never comes.
|
|
142
|
+
*/
|
|
143
|
+
export declare const findUnawaitedCalls: (file: string, source: string, accept: (callee: string) => boolean) => Promise<{
|
|
144
|
+
line: number;
|
|
145
|
+
callee: string;
|
|
146
|
+
how: "dropped" | "assigned" | "passed";
|
|
147
|
+
}[]>;
|
|
148
|
+
/**
|
|
149
|
+
* `test(…)` calls (also `.only` / `.fixme`) declared inside a loop or a `forEach` / `map` callback:
|
|
150
|
+
* generated tests, which a textual count of `test(` sees as one.
|
|
151
|
+
*/
|
|
152
|
+
export declare const findGeneratedTests: (file: string, source: string) => Promise<{
|
|
153
|
+
line: number;
|
|
154
|
+
}[]>;
|
package/dist/invariants/ast.js
CHANGED
|
@@ -107,6 +107,119 @@ export const findStaticImports = async (file, source) => {
|
|
|
107
107
|
}
|
|
108
108
|
return out;
|
|
109
109
|
};
|
|
110
|
+
/** Locator / matcher methods whose first argument is user-visible copy. */
|
|
111
|
+
const COPY_SINKS = new Set(['getByText', 'getByLabel', 'getByPlaceholder', 'getByTitle', 'getByAltText', 'toHaveText', 'toContainText', 'toHaveTitle', 'toHaveAccessibleName', 'toHaveAccessibleDescription', 'toHaveAccessibleErrorMessage']);
|
|
112
|
+
/** Methods whose first argument is a test id — a different fix (the enum / qa name), so a different kind. */
|
|
113
|
+
const ID_SINKS = new Set(['getByTestId']);
|
|
114
|
+
/**
|
|
115
|
+
* Option keys that carry copy, per method. A Map, not an object: a plain object
|
|
116
|
+
* resolves prototype members, so `toLocaleString(locale, { timeZone })` would
|
|
117
|
+
* look up a Function as its key list and crash the whole run.
|
|
118
|
+
*/
|
|
119
|
+
const COPY_OPTION_KEYS = new Map([
|
|
120
|
+
['getByRole', ['name']],
|
|
121
|
+
['filter', ['hasText', 'hasNotText']],
|
|
122
|
+
['locator', ['hasText', 'hasNotText']],
|
|
123
|
+
]);
|
|
124
|
+
/**
|
|
125
|
+
* Every place a file addresses the app through an inline literal instead of the
|
|
126
|
+
* contract: a string, no-substitution template or RegExp literal fed to a
|
|
127
|
+
* copy sink, a test-id sink, a copy-carrying option (`getByRole` `name`,
|
|
128
|
+
* `filter` / `locator` `hasText` / `hasNotText`), or a test `tag`. Array
|
|
129
|
+
* arguments (`toHaveText(['a', 'b'])`, `tag: [...]`) are unwrapped. An
|
|
130
|
+
* identifier, a member expression (`copy.x`, `StepName.Grade`) and a template
|
|
131
|
+
* WITH an interpolation are the allowed forms. Facts only — the rule phrases the
|
|
132
|
+
* finding. Regex cannot do this honestly: the literal may sit inside nested
|
|
133
|
+
* parentheses or a multi-line options object, and a commented-out call must
|
|
134
|
+
* stay silent.
|
|
135
|
+
*
|
|
136
|
+
* Deliberately broad on `tag:` — it fires in ANY object literal, so
|
|
137
|
+
* `const opts = { tag: '@x' }; test('t', opts, …)` is caught; a non-Playwright
|
|
138
|
+
* `{ tag: 'h1' }` inside a test tree is the accepted rare false positive.
|
|
139
|
+
*/
|
|
140
|
+
export const findInlineLiterals = async (file, source) => {
|
|
141
|
+
const t = await ts();
|
|
142
|
+
const out = [];
|
|
143
|
+
for (const script of scriptsOf(file, source)) {
|
|
144
|
+
const sf = t.createSourceFile(file, script.code, t.ScriptTarget.Latest, true);
|
|
145
|
+
const lineAt = (node) => sf.getLineAndCharacterOfPosition(node.getStart(sf)).line + 1 + script.lineOffset;
|
|
146
|
+
const isLiteral = (n) => t.isStringLiteral(n) || t.isNoSubstitutionTemplateLiteral(n) || t.isRegularExpressionLiteral(n);
|
|
147
|
+
// `name`, `'name'` and `"name"` are the same key; `getText` would keep the quotes.
|
|
148
|
+
const keyOf = (n) => (t.isIdentifier(n) || t.isStringLiteral(n) ? n.text : n.getText(sf));
|
|
149
|
+
const unwrap = (n) => (t.isArrayLiteralExpression(n) ? [...n.elements] : [n]);
|
|
150
|
+
const visit = (node) => {
|
|
151
|
+
if (t.isCallExpression(node) && t.isPropertyAccessExpression(node.expression)) {
|
|
152
|
+
const method = node.expression.name.text;
|
|
153
|
+
const [first] = node.arguments;
|
|
154
|
+
const sinkKind = COPY_SINKS.has(method) ? 'copy' : ID_SINKS.has(method) ? 'test-id' : null;
|
|
155
|
+
if (sinkKind && first) {
|
|
156
|
+
for (const v of unwrap(first))
|
|
157
|
+
if (isLiteral(v))
|
|
158
|
+
out.push({ line: lineAt(v), kind: sinkKind, text: v.getText(sf), via: method });
|
|
159
|
+
}
|
|
160
|
+
const keys = COPY_OPTION_KEYS.get(method);
|
|
161
|
+
if (keys) {
|
|
162
|
+
for (const arg of node.arguments) {
|
|
163
|
+
if (!t.isObjectLiteralExpression(arg))
|
|
164
|
+
continue;
|
|
165
|
+
for (const p of arg.properties) {
|
|
166
|
+
if (t.isPropertyAssignment(p) && keys.includes(keyOf(p.name)) && isLiteral(p.initializer)) {
|
|
167
|
+
out.push({ line: lineAt(p.initializer), kind: 'option', text: p.initializer.getText(sf), via: `${method}.${keyOf(p.name)}` });
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
if (t.isPropertyAssignment(node) && keyOf(node.name) === 'tag') {
|
|
174
|
+
for (const v of unwrap(node.initializer)) {
|
|
175
|
+
// `.text` is the cooked value: '@smoke', "@smoke" and `@smoke` all pass; /@smoke/ does not.
|
|
176
|
+
if (isLiteral(v) && v.text !== '@smoke')
|
|
177
|
+
out.push({ line: lineAt(v), kind: 'tag', text: v.getText(sf), via: 'tag' });
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
t.forEachChild(node, visit);
|
|
181
|
+
};
|
|
182
|
+
visit(sf);
|
|
183
|
+
}
|
|
184
|
+
return out;
|
|
185
|
+
};
|
|
186
|
+
/**
|
|
187
|
+
* `let` / `var` declarations that outlive a single test — at module scope or inside a
|
|
188
|
+
* `describe` callback. A declaration inside a test body, a `test.step` callback or
|
|
189
|
+
* a helper function is per-test and ignored; `const` and `using` are ignored everywhere.
|
|
190
|
+
*/
|
|
191
|
+
export const findSharedMutables = async (file, source) => {
|
|
192
|
+
const t = await ts();
|
|
193
|
+
const out = [];
|
|
194
|
+
for (const script of scriptsOf(file, source)) {
|
|
195
|
+
const sf = t.createSourceFile(file, script.code, t.ScriptTarget.Latest, true);
|
|
196
|
+
const isFn = (n) => t.isArrowFunction(n) || t.isFunctionExpression(n) || t.isFunctionDeclaration(n) || t.isMethodDeclaration(n);
|
|
197
|
+
/** 'module' when no function encloses the statement, 'describe' when the nearest one is a describe callback, else null. */
|
|
198
|
+
const sharedScope = (st) => {
|
|
199
|
+
let p = st.parent;
|
|
200
|
+
while (p && !isFn(p))
|
|
201
|
+
p = p.parent;
|
|
202
|
+
if (!p)
|
|
203
|
+
return 'module';
|
|
204
|
+
const call = p.parent;
|
|
205
|
+
return call && t.isCallExpression(call) && /\bdescribe\b/.test(call.expression.getText(sf)) ? 'describe' : null;
|
|
206
|
+
};
|
|
207
|
+
const visit = (node) => {
|
|
208
|
+
if (t.isVariableStatement(node) && !(node.declarationList.flags & (t.NodeFlags.Const | t.NodeFlags.Using))) {
|
|
209
|
+
const scope = sharedScope(node);
|
|
210
|
+
if (scope) {
|
|
211
|
+
const kind = node.declarationList.flags & t.NodeFlags.Let ? 'let' : 'var';
|
|
212
|
+
for (const d of node.declarationList.declarations) {
|
|
213
|
+
out.push({ line: sf.getLineAndCharacterOfPosition(d.getStart(sf)).line + 1 + script.lineOffset, kind, name: d.name.getText(sf), scope });
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
t.forEachChild(node, visit);
|
|
218
|
+
};
|
|
219
|
+
visit(sf);
|
|
220
|
+
}
|
|
221
|
+
return out;
|
|
222
|
+
};
|
|
110
223
|
// ── Vue template AST ───────────────────────────────────────────────────────
|
|
111
224
|
let sfcModule = null;
|
|
112
225
|
const sfc = async () => {
|
|
@@ -193,3 +306,160 @@ export const allElements = (roots) => {
|
|
|
193
306
|
};
|
|
194
307
|
/** An element's attribute by name, static or bound. */
|
|
195
308
|
export const attrOf = (el, name) => el.attrs.find(a => a.name === name);
|
|
309
|
+
/**
|
|
310
|
+
* Every object literal shaped like a `SelectOption` — it has both a `value` and a `selected`
|
|
311
|
+
* property — with what its `id` is, so a rule can judge the id without re-parsing the file. An
|
|
312
|
+
* object built from a spread is skipped: its id may well come from the spread, and reporting it
|
|
313
|
+
* would be a guess.
|
|
314
|
+
*/
|
|
315
|
+
export const findOptionLiterals = async (file, source) => {
|
|
316
|
+
const t = await ts();
|
|
317
|
+
const out = [];
|
|
318
|
+
for (const script of scriptsOf(file, source)) {
|
|
319
|
+
const sf = t.createSourceFile(file, script.code, t.ScriptTarget.Latest, true);
|
|
320
|
+
const lineAt = (node) => sf.getLineAndCharacterOfPosition(node.getStart(sf)).line + 1 + script.lineOffset;
|
|
321
|
+
const keyOf = (n) => (t.isIdentifier(n) || t.isStringLiteral(n) ? n.text : null);
|
|
322
|
+
const isText = (n) => t.isStringLiteral(n) || t.isNoSubstitutionTemplateLiteral(n);
|
|
323
|
+
const visit = (node) => {
|
|
324
|
+
if (t.isObjectLiteralExpression(node) && !node.properties.some(p => t.isSpreadAssignment(p))) {
|
|
325
|
+
const named = new Map();
|
|
326
|
+
for (const p of node.properties) {
|
|
327
|
+
const key = t.isPropertyAssignment(p) || t.isShorthandPropertyAssignment(p) ? keyOf(p.name) : null;
|
|
328
|
+
if (key !== null)
|
|
329
|
+
named.set(key, p);
|
|
330
|
+
}
|
|
331
|
+
if (named.has('value') && named.has('selected')) {
|
|
332
|
+
const valueProp = named.get('value');
|
|
333
|
+
const value = valueProp && t.isPropertyAssignment(valueProp) && isText(valueProp.initializer) ? valueProp.initializer.text : null;
|
|
334
|
+
const idProp = named.get('id');
|
|
335
|
+
if (!idProp)
|
|
336
|
+
out.push({ line: lineAt(node), value, kind: 'missing', id: null });
|
|
337
|
+
else if (t.isPropertyAssignment(idProp) && isText(idProp.initializer))
|
|
338
|
+
out.push({ line: lineAt(idProp), value, kind: 'literal', id: idProp.initializer.text });
|
|
339
|
+
else
|
|
340
|
+
out.push({ line: lineAt(idProp), value, kind: 'computed', id: idProp.getText(sf) });
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
t.forEachChild(node, visit);
|
|
344
|
+
};
|
|
345
|
+
visit(sf);
|
|
346
|
+
}
|
|
347
|
+
return out;
|
|
348
|
+
};
|
|
349
|
+
/**
|
|
350
|
+
* Module- or describe-scope `const` holding a mutable container — `{}`, `[]`, `new Map()` … — that
|
|
351
|
+
* nothing freezes: the `let` of shared state under a different keyword. `as const` and
|
|
352
|
+
* `Object.freeze(…)` clear it.
|
|
353
|
+
*/
|
|
354
|
+
export const findSharedContainers = async (file, source) => {
|
|
355
|
+
const t = await ts();
|
|
356
|
+
const out = [];
|
|
357
|
+
for (const script of scriptsOf(file, source)) {
|
|
358
|
+
const sf = t.createSourceFile(file, script.code, t.ScriptTarget.Latest, true);
|
|
359
|
+
const lineAt = (node) => sf.getLineAndCharacterOfPosition(node.getStart(sf)).line + 1 + script.lineOffset;
|
|
360
|
+
const isDescribeCall = (n) => n !== undefined && t.isCallExpression(n) && /(?:^|\.)describe$/.test(n.expression.getText(sf));
|
|
361
|
+
const scopeOf = (statement) => {
|
|
362
|
+
const parent = statement.parent;
|
|
363
|
+
if (t.isSourceFile(parent))
|
|
364
|
+
return 'module';
|
|
365
|
+
const fn = t.isBlock(parent) ? parent.parent : undefined;
|
|
366
|
+
if (fn !== undefined && (t.isArrowFunction(fn) || t.isFunctionExpression(fn)) && isDescribeCall(fn.parent))
|
|
367
|
+
return 'describe';
|
|
368
|
+
return null;
|
|
369
|
+
};
|
|
370
|
+
const isContainer = (init) => {
|
|
371
|
+
if (t.isObjectLiteralExpression(init) || t.isArrayLiteralExpression(init))
|
|
372
|
+
return true;
|
|
373
|
+
return t.isNewExpression(init) && /^(?:Map|Set|WeakMap|WeakSet|Array)$/.test(init.expression.getText(sf));
|
|
374
|
+
};
|
|
375
|
+
const isAsConst = (n) => t.isAsExpression(n) && t.isTypeReferenceNode(n.type) && n.type.typeName.getText(sf) === 'const';
|
|
376
|
+
const isFrozen = (init) => isAsConst(init) || (t.isCallExpression(init) && init.expression.getText(sf) === 'Object.freeze');
|
|
377
|
+
/** `[] as string[]`, `({}) satisfies X`, `<T>[]` — the wrapper changes the type, not the mutability. */
|
|
378
|
+
const unwrap = (init) => {
|
|
379
|
+
let e = init;
|
|
380
|
+
for (;;) {
|
|
381
|
+
if (t.isParenthesizedExpression(e) || t.isSatisfiesExpression(e) || t.isTypeAssertionExpression(e) || (t.isAsExpression(e) && !isAsConst(e)))
|
|
382
|
+
e = e.expression;
|
|
383
|
+
else
|
|
384
|
+
return e;
|
|
385
|
+
}
|
|
386
|
+
};
|
|
387
|
+
const visit = (node) => {
|
|
388
|
+
if (t.isVariableStatement(node) && (node.declarationList.flags & t.NodeFlags.Const) !== 0) {
|
|
389
|
+
const scope = scopeOf(node);
|
|
390
|
+
if (scope !== null) {
|
|
391
|
+
for (const decl of node.declarationList.declarations) {
|
|
392
|
+
const init = decl.initializer;
|
|
393
|
+
if (init !== undefined && !isFrozen(init) && isContainer(unwrap(init)))
|
|
394
|
+
out.push({ line: lineAt(decl), name: decl.name.getText(sf), scope });
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
t.forEachChild(node, visit);
|
|
399
|
+
};
|
|
400
|
+
visit(sf);
|
|
401
|
+
}
|
|
402
|
+
return out;
|
|
403
|
+
};
|
|
404
|
+
/**
|
|
405
|
+
* Calls to the callees `accept` names whose promise is not taken up where it is made: not under
|
|
406
|
+
* `await`, not returned, not the expression body of an arrow (which hands it to whoever calls the
|
|
407
|
+
* arrow — `booking.submitted(() => funnel.bookFirstSlot(…))` relies on exactly that). A bare
|
|
408
|
+
* statement drops it; an assignment or an argument parks it for a later that usually never comes.
|
|
409
|
+
*/
|
|
410
|
+
export const findUnawaitedCalls = async (file, source, accept) => {
|
|
411
|
+
const t = await ts();
|
|
412
|
+
const out = [];
|
|
413
|
+
for (const script of scriptsOf(file, source)) {
|
|
414
|
+
const sf = t.createSourceFile(file, script.code, t.ScriptTarget.Latest, true);
|
|
415
|
+
const lineAt = (node) => sf.getLineAndCharacterOfPosition(node.getStart(sf)).line + 1 + script.lineOffset;
|
|
416
|
+
const visit = (node) => {
|
|
417
|
+
if (t.isCallExpression(node) && accept(node.expression.getText(sf))) {
|
|
418
|
+
let outer = node;
|
|
419
|
+
while (t.isParenthesizedExpression(outer.parent))
|
|
420
|
+
outer = outer.parent;
|
|
421
|
+
const parent = outer.parent;
|
|
422
|
+
const takenUp = t.isAwaitExpression(parent) || t.isReturnStatement(parent) || (t.isArrowFunction(parent) && parent.body === outer);
|
|
423
|
+
if (!takenUp) {
|
|
424
|
+
const how = t.isExpressionStatement(parent) || t.isVoidExpression(parent)
|
|
425
|
+
? 'dropped'
|
|
426
|
+
: t.isVariableDeclaration(parent) || (t.isBinaryExpression(parent) && parent.operatorToken.kind === t.SyntaxKind.EqualsToken)
|
|
427
|
+
? 'assigned'
|
|
428
|
+
: 'passed';
|
|
429
|
+
out.push({ line: lineAt(node), callee: node.expression.getText(sf), how });
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
t.forEachChild(node, visit);
|
|
433
|
+
};
|
|
434
|
+
visit(sf);
|
|
435
|
+
}
|
|
436
|
+
return out;
|
|
437
|
+
};
|
|
438
|
+
/**
|
|
439
|
+
* `test(…)` calls (also `.only` / `.fixme`) declared inside a loop or a `forEach` / `map` callback:
|
|
440
|
+
* generated tests, which a textual count of `test(` sees as one.
|
|
441
|
+
*/
|
|
442
|
+
export const findGeneratedTests = async (file, source) => {
|
|
443
|
+
const t = await ts();
|
|
444
|
+
const out = [];
|
|
445
|
+
for (const script of scriptsOf(file, source)) {
|
|
446
|
+
const sf = t.createSourceFile(file, script.code, t.ScriptTarget.Latest, true);
|
|
447
|
+
const lineAt = (node) => sf.getLineAndCharacterOfPosition(node.getStart(sf)).line + 1 + script.lineOffset;
|
|
448
|
+
const isLoop = (n) => t.isForStatement(n) || t.isForOfStatement(n) || t.isForInStatement(n) || t.isWhileStatement(n) || t.isDoStatement(n);
|
|
449
|
+
const isIteratorCallback = (n) => (t.isArrowFunction(n) || t.isFunctionExpression(n)) && t.isCallExpression(n.parent) && t.isPropertyAccessExpression(n.parent.expression) && /^(?:forEach|map|flatMap|each)$/.test(n.parent.expression.name.text);
|
|
450
|
+
const generated = (call) => {
|
|
451
|
+
for (let p = call.parent; p !== undefined && !t.isSourceFile(p); p = p.parent) {
|
|
452
|
+
if (isLoop(p) || isIteratorCallback(p))
|
|
453
|
+
return true;
|
|
454
|
+
}
|
|
455
|
+
return false;
|
|
456
|
+
};
|
|
457
|
+
const visit = (node) => {
|
|
458
|
+
if (t.isCallExpression(node) && /^test(?:\.(?:only|fixme))?$/.test(node.expression.getText(sf)) && generated(node))
|
|
459
|
+
out.push({ line: lineAt(node) });
|
|
460
|
+
t.forEachChild(node, visit);
|
|
461
|
+
};
|
|
462
|
+
visit(sf);
|
|
463
|
+
}
|
|
464
|
+
return out;
|
|
465
|
+
};
|
|
@@ -28,11 +28,11 @@ export declare const commonRules: Rule[];
|
|
|
28
28
|
* instead. A test pins this map to `commonRules`, so it cannot drift.
|
|
29
29
|
*/
|
|
30
30
|
export declare const sharedRuleIds: {
|
|
31
|
-
/** `
|
|
31
|
+
/** `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
32
|
readonly 'core/R1': "core/R1";
|
|
33
33
|
/** `error` — SCSS rules written inline in a Vue `<style>` block. Inline styles drift from the design tokens invisibly. Fix: a dedicated `.scss` file referenced via `<style scoped lang="scss" src="…" />`. */
|
|
34
34
|
readonly 'core/R2': "core/R2";
|
|
35
|
-
/** `
|
|
35
|
+
/** `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
36
|
readonly 'core/R3': "core/R3";
|
|
37
37
|
/** `error` — `@font-face` declaring a font *stack*. Inside `@font-face`, `font-family` names the face being defined; a comma-separated stack is invalid and the browser drops the face silently. Fix: single family name inside, the stack on the selectors that use it. */
|
|
38
38
|
readonly 'core/R4': "core/R4";
|
|
@@ -28,11 +28,11 @@ export const commonRules = [...rRules, ...cRules];
|
|
|
28
28
|
* instead. A test pins this map to `commonRules`, so it cannot drift.
|
|
29
29
|
*/
|
|
30
30
|
export const sharedRuleIds = {
|
|
31
|
-
/** `
|
|
31
|
+
/** `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
32
|
'core/R1': 'core/R1',
|
|
33
33
|
/** `error` — SCSS rules written inline in a Vue `<style>` block. Inline styles drift from the design tokens invisibly. Fix: a dedicated `.scss` file referenced via `<style scoped lang="scss" src="…" />`. */
|
|
34
34
|
'core/R2': 'core/R2',
|
|
35
|
-
/** `
|
|
35
|
+
/** `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
36
|
'core/R3': 'core/R3',
|
|
37
37
|
/** `error` — `@font-face` declaring a font *stack*. Inside `@font-face`, `font-family` names the face being defined; a comma-separated stack is invalid and the browser drops the face silently. Fix: single family name inside, the stack on the selectors that use it. */
|
|
38
38
|
'core/R4': 'core/R4',
|
|
@@ -14,7 +14,11 @@ import { eachLine, setting } from './shared.js';
|
|
|
14
14
|
const bareStorage = {
|
|
15
15
|
id: 'core/R1',
|
|
16
16
|
title: 'bare localStorage / sessionStorage access',
|
|
17
|
-
level:
|
|
17
|
+
// Target level is `error` (crash-class). Shipped at `warn` since 2026-09-25: the fleet
|
|
18
|
+
// backlog (cs-app ~42 sites, main-app ~28) is real code the team has no bandwidth to
|
|
19
|
+
// migrate and re-verify right now, and a rushed storage migration is its own risk.
|
|
20
|
+
// Promote back to `error` once the backlog is cleared — that is a major-style bump.
|
|
21
|
+
level: 'warn',
|
|
18
22
|
why: 'the bare API throws SecurityError on Safari ITP, Firefox strict mode and in-app webviews (Instagram/Facebook/TikTok) — the `if (localStorage)` guard does not help, the property accessor itself throws',
|
|
19
23
|
fix: 'use the app\'s safe storage wrapper instead of the bare API',
|
|
20
24
|
scope: ['app/**/*.{ts,vue}'],
|
|
@@ -43,6 +47,9 @@ const scssInStyleBlock = {
|
|
|
43
47
|
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',
|
|
44
48
|
fix: 'move the rules to a dedicated .scss file and reference it with <style scoped lang="scss" src="…" />',
|
|
45
49
|
scope: ['app/**/*.vue'],
|
|
50
|
+
// Dev harness pages are dev tooling, not product surfaces — every app that has them
|
|
51
|
+
// (tutor room-preview, cs-app preview) was allowlisting the same thing (2026-09-25).
|
|
52
|
+
exclude: ['app/pages/dev/**'],
|
|
46
53
|
check: ({ file, source }) => {
|
|
47
54
|
const out = [];
|
|
48
55
|
const re = /<style\b[^>]*>([\s\S]*?)<\/style>/g;
|
|
@@ -68,7 +75,8 @@ const scssInStyleBlock = {
|
|
|
68
75
|
const unguardedJsonParse = {
|
|
69
76
|
id: 'core/R3',
|
|
70
77
|
title: 'unguarded JSON.parse',
|
|
71
|
-
level
|
|
78
|
+
// Target level is `error` — see core/R1 above; same interim decision (2026-09-25).
|
|
79
|
+
level: 'warn',
|
|
72
80
|
async: true,
|
|
73
81
|
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',
|
|
74
82
|
fix: 'parse through the app\'s guarded helper, or wrap the call in try/catch',
|
package/dist/invariants/run.d.ts
CHANGED
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
* 2 — the validator itself failed
|
|
14
14
|
*/
|
|
15
15
|
import type { Allowlists, Levels, LinterOwnedRule, RetiredRules, Rule, Unmaintained } from './types.js';
|
|
16
|
+
import type { Fixture } from './self-test.js';
|
|
16
17
|
export interface RunOptions {
|
|
17
18
|
/** Repo root. Passed, never derived — import.meta.url here resolves into node_modules. */
|
|
18
19
|
root: string;
|
|
@@ -39,11 +40,7 @@ export interface RunOptions {
|
|
|
39
40
|
/** Config file a dangling allowlist/levels key is reported against. */
|
|
40
41
|
configFile?: string;
|
|
41
42
|
/** Fixtures for --self-test, keyed by rule id. Omit to skip the self-test entirely. */
|
|
42
|
-
fixtures?: Record<string,
|
|
43
|
-
file: string;
|
|
44
|
-
bad: string;
|
|
45
|
-
good: string;
|
|
46
|
-
}>;
|
|
43
|
+
fixtures?: Record<string, Fixture>;
|
|
47
44
|
/** Repo-specific self-test assertions; each returns failure messages. */
|
|
48
45
|
extraChecks?: (() => string[] | Promise<string[]>)[];
|
|
49
46
|
/** Rule ids exempt from the fixture requirement — registry rules a fixture cannot express. */
|