@brighterly/lib-fe-codestyle 0.4.1 → 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.
@@ -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
+ };
@@ -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. */