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: `
`, where style arrives inside an object. The // `style` property itself is forbidden in object literals — the carrier is then caught where it // is assembled. // // ⚠ Except when its value is a plain string, and that is a narrowing with a reason rather than an // exemption: React refuses a string `style` outright ("expects a mapping … not a string"), so // such a property cannot carry styling anywhere — while `{ style: 'unit' }` is how `Intl` // formats a size in megabytes, and the words of a unit belong to `Intl` and not to us. Every // carrier that CAN style — an object, a variable, a template — is still caught. { selector: "Property[key.name='style'][value.type!='Literal']", message: inlineStyle }, { selector: "Property[key.value='style'][value.type!='Literal']", 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|valueLabel|aria-label|aria-description|aria-valuetext)$/'; // The shapes a written-out word takes inside `{…}`. A bare literal was the only one the gate knew, // and the hole it left was found by the acceptance of S3.7 with a live specimen (`Showcase.tsx`: // `title={book?.title ?? 'TextMachine'}`): a word behind `??` or behind a ternary is as printed as // one standing on its own. // // ⚠ Carriers are listed rather than caught by descent (`JSXExpressionContainer Literal`), and that // is not timidity: `{text('bank.tab')}` is a literal inside an expression container too, and a // descending selector would flag every call of the catalogue — the very mechanism this rule exists // to enforce. Nesting deeper than one carrier goes through a named variable, where a computed // value is a deliberate step rather than an inline one (the same rule the colour gate applies). const writtenWord = [ `Literal[value=${letter}]`, `TemplateLiteral > TemplateElement[value.raw=${letter}]`, ...['ConditionalExpression', 'LogicalExpression'].flatMap((carrier) => [ `${carrier} > Literal[value=${letter}]`, `${carrier} > TemplateLiteral > TemplateElement[value.raw=${letter}]`, ]), ]; const noBareText = [ { selector: `JSXText[value=${letter}]`, message: catalogueOnly }, { selector: `JSXAttribute[name.name=${readableAttributes}] > Literal[value=${letter}]`, message: catalogueOnly, }, // Inside a readable attribute, and — the second half of the same hole — as the CHILD of an // element: `

{'a word'}

` prints one exactly as `

a word

` does, and only the second was // caught (by the `JSXText` rule). ...['JSXAttribute[name.name=' + readableAttributes + ']', 'JSXElement', 'JSXFragment'].flatMap( (host) => writtenWord.map((shape) => ({ selector: `${host} > JSXExpressionContainer > ${shape}`, 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, );