Files
TREK/client/scripts/lib/eslint-rules.mjs
T
Maurice c266a5e1a7 chore(client): freeze typed promise, hook, size, store and global rules in the ESLint baseline
The client lint ran untyped with two react-hooks rules, so nothing saw a
floating promise, an async handler where a void one is expected, a whole-store
Zustand subscription or a window event bus, all of which client/CLAUDE.md
forbids. eslint.config.mjs now lints with the project service and adds, as
warnings: no-floating-promises, no-misused-promises (void returns in JSX
attributes included), the rest of react-hooks v7's recommended set,
complexity 20, max-depth 4, max-params 5 and max-lines-per-function (120 in a
.ts function, 300 in a .tsx one, whose body also holds its markup),
no-console and no-non-null-assertion. Three local rules in
scripts/lib/eslint-rules.mjs cover what no stock rule sees: an empty
.catch() callback, use*Store() without a selector (the message points to
useShallow, since an object selector without it loops on Zustand 5), and
window.__* globals and window.dispatchEvent with an event of our own.
scripts/eslint-baseline.json takes today's count of each new rule, so the
existing code is frozen, not changed, and each new instance fails
lint:warnings. The CI step gets the 8 GB heap typed linting needs.
2026-10-10 21:32:23 +02:00

184 lines
6.2 KiB
JavaScript

/*
* The client's own ESLint rules, as the local plugin `trek` in eslint.config.mjs.
*
* Each one finds a smell client/CLAUDE.md forbids and that no stock rule sees
* on its own. They run as warnings, so lint:warnings
* (scripts/eslint-ratchet.mjs) holds what exists today in
* scripts/eslint-baseline.json and fails on each new one.
*/
/** The expression under any `as`, `!`, `satisfies` or type assertion, which do not change the value. */
function unwrap(node) {
let current = node;
while (
current &&
(current.type === 'TSAsExpression' ||
current.type === 'TSNonNullExpression' ||
current.type === 'TSSatisfiesExpression' ||
current.type === 'TSTypeAssertion')
)
current = current.expression;
return current;
}
const isWindow = (node) => {
const target = unwrap(node);
return (
(target?.type === 'Identifier' && (target.name === 'window' || target.name === 'globalThis')) ||
(target?.type === 'MemberExpression' &&
!target.computed &&
target.property.name === 'window' &&
unwrap(target.object)?.type === 'Identifier' &&
unwrap(target.object).name === 'globalThis')
);
};
const propertyName = (member) =>
!member.computed && member.property.type === 'Identifier'
? member.property.name
: member.property.type === 'Literal' && typeof member.property.value === 'string'
? member.property.value
: null;
/**
* `.catch(() => {})`, `.catch(() => undefined)`, `.catch(() => null)`: the
* failure disappears without a trace. A write that fails needs a rollback and
* a toast; a read that may fail says why it may, in code that handles it.
*/
/** @type {import('eslint').Rule.RuleModule} */
const noSwallowedCatch = {
meta: {
type: 'problem',
schema: [],
messages: {
swallowed:
'This .catch() swallows the error. Roll back and tell the user (toast), or handle the failure on purpose ' +
'and say why in the handler; never .catch(() => {}).',
},
},
create(context) {
const empty = (fn) => {
if (fn?.type !== 'ArrowFunctionExpression' && fn?.type !== 'FunctionExpression') return false;
const body = fn.body;
if (body.type === 'BlockStatement') return body.body.length === 0;
return (
(body.type === 'Identifier' && body.name === 'undefined') ||
(body.type === 'Literal' && body.value === null) ||
(body.type === 'UnaryExpression' && body.operator === 'void')
);
};
return {
CallExpression(node) {
const callee = node.callee;
if (callee.type !== 'MemberExpression' || propertyName(callee) !== 'catch') return;
if (node.arguments.length === 1 && empty(node.arguments[0])) context.report({ node, messageId: 'swallowed' });
},
};
},
};
/**
* `useTripStore()` with no selector subscribes the component to the whole
* store, so it re-renders on every change anywhere in it. And on Zustand 5 the
* obvious repair, a selector that builds an object, loops forever without
* useShallow, because each call returns a new object.
*/
/** @type {import('eslint').Rule.RuleModule} */
const storeWithoutSelector = {
meta: {
type: 'problem',
schema: [],
messages: {
whole:
'{{name}}() without a selector subscribes to the whole store and re-renders on every change in it. ' +
'Select what you read: {{name}}(s => s.field), or for several fields {{name}}(useShallow(s => ({ a: s.a, b: s.b }))). ' +
'On Zustand 5 an object selector without useShallow loops forever. In an effect or handler use {{name}}.getState().',
},
},
create(context) {
return {
CallExpression(node) {
if (
node.callee.type === 'Identifier' &&
/^use[A-Z]\w*Store$/.test(node.callee.name) &&
node.arguments.length === 0
)
context.report({ node, messageId: 'whole', data: { name: node.callee.name } });
},
};
},
};
/** Events the browser itself fires on window; dispatching one of these is simulating the platform, not a bus. */
const PLATFORM_EVENTS = new Set([
'resize',
'scroll',
'storage',
'online',
'offline',
'focus',
'blur',
'popstate',
'hashchange',
'visibilitychange',
'load',
'beforeunload',
'pagehide',
'pageshow',
'message',
'keydown',
'keyup',
]);
/**
* Global mutable state and event buses on window: `window.__dragData`,
* `window.__addToast` and any other `window.__*`, and window.dispatchEvent
* with an event of the app's own. Nothing types them, nothing finds their
* listeners, and a test has to fake the global to reach them.
*/
/** @type {import('eslint').Rule.RuleModule} */
const noWindowGlobals = {
meta: {
type: 'problem',
schema: [],
messages: {
global:
'window.{{name}} is global mutable state. Keep it in a module (a store, a small session module) and import it.',
bus:
'window.dispatchEvent with an event of our own is an untyped global bus. Call a store action, or bump a ' +
'version counter in a slice that the listener selects.',
},
},
create(context) {
const ownEvent = (arg) => {
const event = unwrap(arg);
if (event?.type !== 'NewExpression' || event.callee.type !== 'Identifier') return false;
if (event.callee.name === 'CustomEvent') return true;
if (event.callee.name !== 'Event') return false;
const name = event.arguments[0];
return !(name?.type === 'Literal' && typeof name.value === 'string' && PLATFORM_EVENTS.has(name.value));
};
return {
MemberExpression(node) {
const name = propertyName(node);
if (name?.startsWith('__') && isWindow(node.object))
context.report({ node, messageId: 'global', data: { name } });
},
CallExpression(node) {
const callee = unwrap(node.callee);
if (callee?.type !== 'MemberExpression' || propertyName(callee) !== 'dispatchEvent') return;
if (isWindow(callee.object) && ownEvent(node.arguments[0])) context.report({ node, messageId: 'bus' });
},
};
},
};
export default {
meta: { name: 'trek' },
rules: {
'no-swallowed-catch': noSwallowedCatch,
'store-without-selector': storeWithoutSelector,
'no-window-globals': noWindowGlobals,
},
};