@brighterly/lib-fe-codestyle 0.4.1 → 0.5.2

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.
@@ -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
+ };
@@ -80,6 +80,26 @@ const uiKitReuseFirst = {
80
80
  return out;
81
81
  },
82
82
  };
83
+ /**
84
+ * Blank everything outside `<style>` bodies, line count preserved. Colour tokens are a
85
+ * stylesheet concern: an SVG `fill="#E2E8F0"` in an inline-icon template has no place to
86
+ * put a `$token`, so flagging it prescribes an impossible fix (74 of 81 .vue findings
87
+ * fleet-wide were exactly that, 2026-10-02).
88
+ */
89
+ const onlyStyleBlocks = (source) => {
90
+ let out = '';
91
+ let last = 0;
92
+ const re = /<style\b[^>]*>([\s\S]*?)<\/style>/g;
93
+ let m;
94
+ while ((m = re.exec(source)) !== null) {
95
+ const body = m[1] ?? '';
96
+ const bodyStart = m.index + m[0].length - body.length - '</style>'.length;
97
+ out += source.slice(last, bodyStart).replace(/[^\n]/g, ' ');
98
+ out += body;
99
+ last = bodyStart + body.length;
100
+ }
101
+ return out + source.slice(last).replace(/[^\n]/g, ' ');
102
+ };
83
103
  const scssColourTokens = {
84
104
  id: 'core/C3',
85
105
  title: 'literal colour in SCSS instead of a design token',
@@ -96,7 +116,8 @@ const scssColourTokens = {
96
116
  const palette = setting({ settings }, 'scssTokensFile', 'app/assets/scss/_colors.scss');
97
117
  if (file === palette)
98
118
  return [];
99
- return eachLine(code, (line, n) => {
119
+ const scanned = file.endsWith('.vue') ? onlyStyleBlocks(code) : code;
120
+ return eachLine(scanned, (line, n) => {
100
121
  // `rgba($token, .5)` is correct usage — only a NUMERIC first argument is a literal.
101
122
  const hex = /#[0-9a-fA-F]{3,8}\b/.exec(line);
102
123
  const numericRgb = /\brgba?\(\s*\d[^)]*\)/.exec(line);
@@ -122,12 +143,19 @@ const dateFormatEnum = {
122
143
  return [];
123
144
  // A date pattern: only Y/M/D/H/m/s and separators, and carrying a real token.
124
145
  const FORMAT = /(['"`])((?=[^'"`]*(?:YYYY|DD|MM|HH|mm|ss))[YMDHhmsAaZ\s:/.,-]{5,})\1/;
146
+ // Every letter RUN must be a real dayjs token: 'ADHD / ADD' passes the char class
147
+ // (A, D and H are all tokens) and the DD lookahead, but neither 'ADHD' nor 'ADD'
148
+ // is a token — it is an answer label, not a format (found live in cs-app, 2026-10-02).
149
+ const TOKENS = new Set(['YYYY', 'YY', 'Y', 'MMMM', 'MMM', 'MM', 'M', 'DD', 'D', 'Do', 'dddd', 'ddd', 'dd', 'd', 'HH', 'H', 'hh', 'h', 'mm', 'm', 'ss', 's', 'SSS', 'A', 'a', 'Z', 'ZZ']);
150
+ const isFormat = (s) => (s.match(/[A-Za-z]+/g) ?? []).every(run => TOKENS.has(run));
125
151
  return eachLine(code, (line, n) => {
126
- if (/\.toISOString\s*\(/.test(line)) {
127
- return { file, line: n, message: `toISOString() is always UTC and always carries a time — it shifts the calendar day; format through ${dateModule}` };
152
+ // Only the day-extraction misuse shifts the calendar day. A bare toISOString()
153
+ // (an API timestamp) and the epoch duration trick (.slice(14, 19)) are correct uses.
154
+ if (/\.toISOString\s*\(\s*\)\s*(?:\.split\s*\(\s*['"`]T['"`]\s*\)|\.(?:slice|substring)\s*\(\s*0\s*,\s*10\s*\))/.test(line)) {
155
+ return { file, line: n, message: `toISOString() is always UTC — extracting its date part shifts the calendar day for any visitor west of Greenwich; format through ${dateModule}` };
128
156
  }
129
157
  const m = FORMAT.exec(line);
130
- if (!m)
158
+ if (!m || !isFormat(m[2] ?? ''))
131
159
  return null;
132
160
  return { file, line: n, message: `hand-rolled date format ${m[1]}${m[2]}${m[1]} — use a member of ${dateModule}` };
133
161
  });
@@ -33,16 +33,40 @@ export const commonFixtures = {
33
33
  bad: 'window.addEventListener("resize", onResize)',
34
34
  good: 'useEventListener(window, "resize", onResize)',
35
35
  },
36
- 'core/C3': {
37
- file: 'app/assets/scss/components/Card.scss',
38
- bad: '.card { color: #1f2937; }',
39
- good: '.card { color: $slate-800; }',
40
- },
41
- 'core/C4': {
42
- file: 'app/a.ts',
43
- bad: "const d = dayjs().format('YYYY-MM-DD')",
44
- good: 'const d = dayjs().format(DateFormat.DefaultDate)',
45
- },
36
+ 'core/C3': [
37
+ {
38
+ file: 'app/assets/scss/components/Card.scss',
39
+ bad: '.card { color: #1f2937; }',
40
+ good: '.card { color: $slate-800; }',
41
+ },
42
+ // In a .vue file only the <style> block is a stylesheet: an SVG fill in the
43
+ // template has no place to put a $token, so it must stay quiet.
44
+ {
45
+ file: 'app/components/UI/Icons/IconLeaf.vue',
46
+ bad: '<template><svg><path fill="currentColor" /></svg></template>\n<style scoped lang="scss">\n.icon { color: #e2e8f0; }\n</style>',
47
+ good: '<template><svg><path fill="#E2E8F0" /></svg></template>\n<style scoped lang="scss">\n.icon { color: $slate-200; }\n</style>',
48
+ },
49
+ ],
50
+ 'core/C4': [
51
+ {
52
+ file: 'app/a.ts',
53
+ bad: "const d = dayjs().format('YYYY-MM-DD')",
54
+ good: 'const d = dayjs().format(DateFormat.DefaultDate)',
55
+ },
56
+ // The day-extraction misuse fires; a bare toISOString() (an API timestamp) and the
57
+ // epoch duration trick do not.
58
+ {
59
+ file: 'app/b.ts',
60
+ bad: "const day = new Date().toISOString().split('T')[0]",
61
+ good: 'const generatedAt = dayjs().toISOString()\nconst duration = new Date(s * 1000).toISOString().slice(14, 19)',
62
+ },
63
+ // Every letter run must be a real dayjs token — an answer label that happens to fit
64
+ // the char class ('ADHD / ADD' carries a DD) is not a format string.
65
+ {
66
+ file: 'app/c.ts',
67
+ good: "const option = { code: 'ADHD_ADD', label: 'ADHD / ADD' }",
68
+ },
69
+ ],
46
70
  'core/C6': {
47
71
  file: 'app/assets/scss/components/Card.scss',
48
72
  bad: '.card { font-size: 14px; }',
@@ -42,7 +42,7 @@ export declare const sharedRuleIds: {
42
42
  readonly 'core/C2': "core/C2";
43
43
  /** `warn` — a literal colour (`#hex`, numeric `rgba()`) in SCSS/Vue instead of a palette token. Hardcoded colours survive theme and palette changes silently. Fix: a token from `settings.scssTokensFile`. */
44
44
  readonly 'core/C3': "core/C3";
45
- /** `warn` — a hand-rolled date format string (`'YYYY-MM-DD'`) or `toISOString()`. The date's shape becomes an accident instead of a contract. Fix: the shared enum (`settings.dateFormatModule`). */
45
+ /** `warn` — a hand-rolled date format string (`'YYYY-MM-DD'`) or a `toISOString()` whose date part is extracted (`.split('T')[0]`, `.slice(0, 10)`). The date's shape becomes an accident instead of a contract. Fix: the shared enum (`settings.dateFormatModule`). */
46
46
  readonly 'core/C4': "core/C4";
47
47
  /** `warn` — a type file outside `types/<entity>/{enums,interfaces,types}.ts` (root under `settings.typesRoot`). Root-level or mixed-kind type files accrete unrelated entities. `.d.ts` ambient files are exempt. */
48
48
  readonly 'core/C5': "core/C5";
@@ -42,7 +42,7 @@ export const sharedRuleIds = {
42
42
  'core/C2': 'core/C2',
43
43
  /** `warn` — a literal colour (`#hex`, numeric `rgba()`) in SCSS/Vue instead of a palette token. Hardcoded colours survive theme and palette changes silently. Fix: a token from `settings.scssTokensFile`. */
44
44
  'core/C3': 'core/C3',
45
- /** `warn` — a hand-rolled date format string (`'YYYY-MM-DD'`) or `toISOString()`. The date's shape becomes an accident instead of a contract. Fix: the shared enum (`settings.dateFormatModule`). */
45
+ /** `warn` — a hand-rolled date format string (`'YYYY-MM-DD'`) or a `toISOString()` whose date part is extracted (`.split('T')[0]`, `.slice(0, 10)`). The date's shape becomes an accident instead of a contract. Fix: the shared enum (`settings.dateFormatModule`). */
46
46
  'core/C4': 'core/C4',
47
47
  /** `warn` — a type file outside `types/<entity>/{enums,interfaces,types}.ts` (root under `settings.typesRoot`). Root-level or mixed-kind type files accrete unrelated entities. `.d.ts` ambient files are exempt. */
48
48
  'core/C5': 'core/C5',
@@ -47,6 +47,9 @@ const scssInStyleBlock = {
47
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',
48
48
  fix: 'move the rules to a dedicated .scss file and reference it with <style scoped lang="scss" src="…" />',
49
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/**'],
50
53
  check: ({ file, source }) => {
51
54
  const out = [];
52
55
  const re = /<style\b[^>]*>([\s\S]*?)<\/style>/g;
@@ -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. */