@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 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` | error | bare `localStorage` / `sessionStorage` — the accessor itself throws on Safari ITP and in-app webviews |
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` | error | `JSON.parse` with no enclosing `try`/`catch` — a corrupt entry breaks that path permanently |
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 reserved for crash-class rules (`core/R*` — a visitor-facing
69
- throw or silent production breakage); `warn` is for conventions and style choices
70
- (`core/C*`). A consumer's `levels` ceiling on an `core/R*` rule is NOT the tool for an existing
71
- backlog — fix the call sites or allowlist the reviewed exceptions with reasons.
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
 
@@ -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
+ }[]>;
@@ -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
- /** `error` — 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`). */
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
- /** `error` — `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. */
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
- /** `error` — 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`). */
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
- /** `error` — `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. */
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: 'error',
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: 'error',
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',
@@ -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. */