338 lines
16 KiB
JavaScript
338 lines
16 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.
|
|
//
|
|
// ⚠ 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: `<p>{'a word'}</p>` prints one exactly as `<p>a word</p>` 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,
|
|
);
|