textmachine/frontend/eslint.config.js

311 lines
14 KiB
JavaScript

import comments from '@eslint-community/eslint-plugin-eslint-comments/configs';
import js from '@eslint/js';
import prettier from 'eslint-config-prettier';
import reactHooks from 'eslint-plugin-react-hooks';
import reactRefresh from 'eslint-plugin-react-refresh';
import globals from 'globals';
import tseslint from 'typescript-eslint';
// The "one place for colour and size" gate, the TSX half (STACK_DECISIONS §3).
// The CSS half is in stylelint.config.js.
const colorLiteral =
'Colour lives only in src/tokens/tokens.css. In code it is var(--color-...) out of a .module.css.';
const inlineStyle =
'An inline style is forbidden: sizes and colours come from a .module.css built on tokens. ' +
"The one exception is passing a CSS variable: style={{ '--progress': value }}.";
const network = 'The network lives in src/api/ and nowhere else — the event stream included.';
// All four lengths are listed: #RGB, #RGBA, #RRGGBB, #RRGGBBAA. Catching "3..8 in a row" with one
// pattern does not work — a word boundary does not fire inside a longer sequence.
const hexLengths = [3, 4, 6, 8];
// Colour attributes of SVG and icons. Listing the colour NAMES is pointless (there are 148 of them
// plus system keywords such as Highlight), so the list is the other way round — an allowing one,
// with exactly the values the CSS half of the gate permits.
const colorAttributes = '/^(fill|stroke|color|stopColor|floodColor|lightingColor)$/';
const allowedColorValue = String.raw`/^(none|currentColor|inherit|transparent|var\(--)/`;
const globalCss = {
group: ['**/*.css', '!**/*.module.css'],
message: 'Global CSS is imported only by src/main.tsx. Screens and primitives take *.module.css.',
};
const mockFixtures = {
group: ['**/mock/**'],
message:
'Fixtures are imported by src/api/ alone. A screen calls src/api/ and does not know where the data comes from.',
};
// Two primitive libraries would mean two focus scopes and two portal managers in one application
// (STACK_DECISIONS §2), so there is one, and it is visible from exactly one place.
const primitives = {
group: ['react-aria-components'],
message:
'Primitives live in src/ui/. The shell and the screens use our wrappers, not the library.',
};
// The same three seams, but for a DYNAMIC import: `no-restricted-imports` parses only an
// `import … from` declaration, while `await import('../mock/book')` is an expression, and all three
// went past the seam. The keys match the pattern names above so that a narrow exemption is lifted
// by the name of the seam rather than by rewriting a selector. The `?inline` suffix is caught
// neither here nor in the static half — both halves behave the same, and that is deliberate.
const dynamicSeam = {
globalCss: {
selector: String.raw`ImportExpression > Literal[value=/\.css$/][value!=/\.module\.css$/]`,
message: globalCss.message,
},
mockFixtures: {
selector: String.raw`ImportExpression > Literal[value=/(^|\/)mock\//]`,
message: mockFixtures.message,
},
primitives: {
selector: "ImportExpression > Literal[value='react-aria-components']",
message: primitives.message,
},
};
const seams = (...names) => names.map((name) => dynamicSeam[name]);
const tokenGate = [
...hexLengths.map((n) => ({
selector: `Literal[value=/#[0-9a-fA-F]{${n}}\\b/]`,
message: colorLiteral,
})),
{
selector: 'Literal[value=/\\b(rgba?|hsla?|hwb|oklch|oklab|lab|lch|color|light-dark)\\(/]',
message: colorLiteral,
},
{ selector: 'TemplateElement[value.raw=/#[0-9a-fA-F]{3,8}\\b/]', message: colorLiteral },
// The ATTRIBUTE is forbidden rather than a list of spellings: the list caught three cases out of
// eleven — a spread, a hoisted variable, a ternary and a factory went past silently. Exactly one
// form is allowed: an object literal right in the attribute, whose properties are checked below.
{
selector:
"JSXAttribute[name.name='style']:not(:has(JSXExpressionContainer > ObjectExpression))",
message: inlineStyle,
},
// Inside the allowed literal: an identifier key is an ordinary property (color, width), while a
// CSS variable is syntactically bound to be a string key — so only it gets through.
{
selector:
"JSXAttribute[name.name='style'] > JSXExpressionContainer > ObjectExpression > Property[key.type='Identifier']",
message: inlineStyle,
},
{
selector:
"JSXAttribute[name.name='style'] > JSXExpressionContainer > ObjectExpression > Property[key.type='Literal'][key.value!=/^--/]",
message: inlineStyle,
},
{
selector:
"JSXAttribute[name.name='style'] > JSXExpressionContainer > ObjectExpression > SpreadElement",
message: inlineStyle,
},
// The sanctioned exception let ANY value through, a literal colour included: hex and the colour
// functions were caught by the general rule, but `'red'` and `Highlight` passed. The value of a
// CSS variable has to be either a number or a reference to a token.
// The name of the variable is a direct child of Property too, hence `--` among the allowed ones:
// without it the rule caught its own key and failed on a legal style={{ '--progress': x }}.
{
selector: `JSXAttribute[name.name='style'] > JSXExpressionContainer > ObjectExpression > Property[key.value=/^--/] > Literal[value!=${String.raw`/^(--|var\(--|[0-9.]+$)/`}]`,
message: inlineStyle,
},
// Carriers of a literal the value check cannot see through: `cond ? 'red' : 'blue'` and
// `` `${x}px` `` put exactly what the line above forbids into the variable. An identifier
// (`'--progress': value`) stays allowed — otherwise the exception loses its point; a computed
// value goes through a named variable, where it is a deliberate step rather than an inline one.
...['ConditionalExpression', 'TemplateLiteral'].map((node) => ({
selector: `JSXAttribute[name.name='style'] > JSXExpressionContainer > ObjectExpression > Property[key.value=/^--/] > ${node}`,
message: inlineStyle,
})),
// The leftover hole from §5.4: `<div {...props}/>`, where style arrives inside an object. The
// `style` property itself is forbidden in object literals — the carrier is then caught where it
// is assembled.
{ selector: "Property[key.name='style']", message: inlineStyle },
{ selector: "Property[key.value='style']", message: inlineStyle },
// Named colours in the markup: forbidden in CSS, they used to pass in TSX — the asymmetry of Ф-9.
{
selector: `JSXAttribute[name.name=${colorAttributes}] > Literal[value!=${allowedColorValue}]`,
message: colorLiteral,
},
{
selector: `JSXAttribute[name.name=${colorAttributes}] > JSXExpressionContainer > Literal[value!=${allowedColorValue}]`,
message: colorLiteral,
},
{
selector: `JSXAttribute[name.name=${colorAttributes}] > JSXExpressionContainer > TemplateLiteral`,
message: colorLiteral,
},
];
// Interface wording lives in the message catalogue and reaches the screen by key. The gate is the
// same shape as the colour one: the literal is caught where it is WRITTEN, not where it is
// rendered — a string that has reached the markup is already a string no translation file can find.
//
// The rule is about the PLACE, not about the alphabet: a word written into the markup is a defect
// whatever language it is in, and a gate that knew only Cyrillic would wave an English one through
// and grow an exception the day a term is left untranslated. The alphabet is checked in exactly one
// other place and for a DIFFERENT rule — "the code of this zone is written in English"
// (src/i18n/catalogue.test.ts), which lives outside the AST because comments do.
const catalogueOnly =
'Text of the interface lives in src/i18n/ru.ts and reaches the screen through useText()/text().';
// A letter of either alphabet. Punctuation standing between two keys — an arrow, a middle dot — is
// not text to translate and stays in the markup. Written as a class rather than as `\p{L}`: the
// selector's regex is built without the unicode flag, where `\p` is merely the letter p.
const letter = String.raw`/[A-Za-z\u0400-\u04FF]/`;
// The attributes a person READS. Deliberately a list and not "every attribute": `className`, `id`,
// `data-*` and `lang` are strings too, and none of them is text.
// Includes the props our own primitives take text in (`label`, `empty`, `waiting`, `description`,
// `hint`, `caption`) — a word handed to a component is as printed as a word between its tags.
const readableAttributes =
'/^(title|placeholder|alt|label|empty|waiting|description|hint|caption|aria-label|aria-description)$/';
const noBareText = [
{ selector: `JSXText[value=${letter}]`, message: catalogueOnly },
{
selector: `JSXAttribute[name.name=${readableAttributes}] > Literal[value=${letter}]`,
message: catalogueOnly,
},
{
selector: `JSXAttribute[name.name=${readableAttributes}] > JSXExpressionContainer > Literal[value=${letter}]`,
message: catalogueOnly,
},
{
selector: `JSXAttribute[name.name=${readableAttributes}] > JSXExpressionContainer > TemplateLiteral > TemplateElement[value.raw=${letter}]`,
message: catalogueOnly,
},
];
export default tseslint.config(
// public/mockServiceWorker.js is written by `msw init` — vendor output, not our source.
{ ignores: ['dist/**', '.shots/**', '.tooling/**', 'public/mockServiceWorker.js'] },
js.configs.recommended,
// With access to types: catches "a promise was thrown and never awaited", the commonest real bug
// of a React application. Everything from S3 on (loading, SSE progress, requests) is asynchronous,
// and without this net it would pile up by the third session. A run over our src takes about 4s.
tseslint.configs.recommendedTypeChecked,
{
languageOptions: {
parserOptions: { projectService: true, tsconfigRootDir: import.meta.dirname },
},
},
// The configs are outside tsconfig — they need no types.
{ files: ['**/*.js', '**/*.mjs'], extends: [tseslint.configs.disableTypeChecked] },
// `.flat` on purpose: the top-level key of the same name is still the old format.
reactHooks.configs.flat['recommended-latest'],
reactRefresh.configs.vite,
comments.recommended,
{
languageOptions: {
globals: { ...globals.browser, ...globals.node },
},
rules: {
'no-restricted-syntax': [
'error',
...tokenGate,
...noBareText,
...seams('globalCss', 'mockFixtures', 'primitives'),
],
// A disable has to name its rule and explain itself. ⚠ This is NOT full symmetry with the CSS
// half: there `reportDisables` makes even a named disable an error, while here a named one
// with a reason passes and lifts the colour gate on that line. ESLint has no such lever, and
// there is no point lying about the symmetry: the TSX half keeps a manual line of defence —
// a disable is visible in the diff and has to carry a written reason.
// `noInlineConfig` is not taken: it forbids the narrow suppressions React itself recommends
// (react-hooks/exhaustive-deps).
'@eslint-community/eslint-comments/no-unlimited-disable': 'error',
'@eslint-community/eslint-comments/require-description': ['error', { ignore: [] }],
// There are exactly two files of global style; everything else is CSS Modules, which scope
// themselves. Plus the data seam: the mocks are seen by src/api/ alone, and on the day of
// HTTP one folder is thrown away. Plus the primitives seam: the component library is visible
// from src/ui/ only.
'no-restricted-imports': ['error', { patterns: [globalCss, mockFixtures, primitives] }],
// The network is behind the seam too: otherwise a request spreads across the screens and
// cannot be pulled out again. ALL transports are listed rather than fetch alone: live
// progress on the ratified stack arrives over EventSource (STACK_DECISIONS §5), and that is
// exactly the one most naturally written right inside a screen — that is, past the seam.
'no-restricted-globals': [
'error',
...['fetch', 'EventSource', 'WebSocket', 'XMLHttpRequest'].map((name) => ({
name,
message: network,
})),
],
// The same ban through a carrier object: `window.fetch(...)` walks around no-restricted-globals.
'no-restricted-properties': [
'error',
...['window', 'globalThis', 'self'].flatMap((object) =>
['fetch', 'EventSource', 'WebSocket', 'XMLHttpRequest'].map((property) => ({
object,
property,
message: network,
})),
),
{ object: 'navigator', property: 'sendBeacon', message: network },
],
},
},
{
// The assembly point of global style: the reset, the tokens and the fonts are attached here
// and nowhere else.
files: ['src/main.tsx'],
rules: {
'no-restricted-imports': 'off',
'no-restricted-syntax': [
'error',
...tokenGate,
...noBareText,
...seams('mockFixtures', 'primitives'),
],
},
},
{
// The contract test has to name the measured colours outright — otherwise it has nothing to
// check against. The colour gate is lifted, the seams stay: this test has no need to break them.
files: ['src/tokens/*.test.ts'],
rules: {
'no-restricted-syntax': [
'error',
...noBareText,
...seams('globalCss', 'mockFixtures', 'primitives'),
],
},
},
{
// The one place the primitives library is legal. Every other ban stays in force.
files: ['src/ui/**'],
rules: {
'no-restricted-imports': ['error', { patterns: [globalCss, mockFixtures] }],
'no-restricted-syntax': [
'error',
...tokenGate,
...noBareText,
...seams('globalCss', 'mockFixtures'),
],
},
},
{
// The one place where both the fixtures and the network are legal.
files: ['src/api/**'],
rules: {
'no-restricted-imports': 'off',
'no-restricted-globals': 'off',
'no-restricted-properties': 'off',
'no-restricted-syntax': [
'error',
...tokenGate,
...noBareText,
...seams('globalCss', 'primitives'),
],
},
},
{
// The two legal homes of Russian text, and both hold DATA. The message catalogue: the words of
// the interface, which a translator edits as a file. The fixtures: the prose of a book, the
// chapter headings, the bank terms — they stand in for a real book and are not the interface.
files: ['src/i18n/ru.ts', 'src/mock/**'],
rules: {
'no-restricted-syntax': ['error', ...tokenGate, ...seams('globalCss', 'primitives')],
},
},
prettier,
);