Repository navigation
Expand file tree
/
Copy patheslint.config.js
More file actions
executable file
·298 lines (293 loc) · 13 KB
/
Copy patheslint.config.js
File metadata and controls
executable file
·298 lines (293 loc) · 13 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
import globals from "globals";
import eslint from "@eslint/js";
import tseslint from "typescript-eslint";
import react from "eslint-plugin-react";
import prettierConfig from "eslint-config-prettier";
import reactHooks from "eslint-plugin-react-hooks";
import jsxA11y from "eslint-plugin-jsx-a11y";
import { defineConfig } from "eslint/config";
// src/test/ is excluded from coverage, and unlike the other three exclude
// patterns its contents actually execute. Nothing but the runner's own
// structure keeps a module with real logic from being placed there and imported
// by the app, which would put executing application code outside the measured
// set with every guard still green.
//
// Declared once here and composed into each block below, because
// no-restricted-imports is configured per rule and not per pattern. A later
// block naming the rule replaces the earlier configuration outright for the
// files it matches, so a second block that forgot this group would silently
// unrestrict it for exactly those files.
const TEST_SCAFFOLDING_IMPORT = {
group: ["**/test/**"],
message:
"src/test/ holds test scaffolding and is excluded from coverage. Importing it from application code moves executing code outside the coverage gate.",
};
// The four attributes whose string value a reader perceives. `aria-sort` and
// `aria-live` carry strings in that layer too and are deliberately outside the
// set, because both take a value the standard defines and assistive technology
// matches on. Translating either would break the feature, not localize it.
//
// no-restricted-syntax is configured per rule and not per selector, exactly as
// no-restricted-imports is above, so each selector set is declared once here and
// composed per block below. A block that named the rule and forgot a set would
// silently unrestrict it for every file that block matches.
const READER_FACING_ATTRIBUTE = {
selector:
'JSXAttribute[name.name=/^(aria-label|title|placeholder|alt)$/]:matches([value.type="Literal"], [value.expression.type="Literal"], [value.expression.type="TemplateLiteral"][value.expression.expressions.length=0])',
message:
"src/components/ renders any collection for any reader, so a string here is a claim about which reader. Add the entry to the catalogs and read it off DataTableLabels, PaginationLabels or SearchInputLabels.",
};
// Every way a file asks the platform for a locale: a namespace construction, and
// a call to one of the six value-level helpers that read a locale from the
// machine when called with no argument. A fifth surface resolving a locale of its
// own reintroduces the defect the locale layer closed, and does it invisibly on a
// machine whose own preference is the base tag.
//
// Matched on the tree, because matching the text would flag two things that are
// not call sites: a namespace named inside a comment, and a type annotation
// naming the same constructor.
const LOCALE_CALL_SITE = [
{
selector:
':matches(NewExpression, CallExpression)[callee.object.name="Intl"]',
message:
"src/i18n/format.ts is the one module that builds a platform locale object, so its caches hold one instance per resolved tag. Reach for collatorFor, numberFormatFor or pluralRulesFor instead.",
},
{
selector:
"CallExpression[callee.property.name=/^(localeCompare|toLocaleString|toLocaleDateString|toLocaleTimeString|toLocaleLowerCase|toLocaleUpperCase)$/]",
message:
"This reads a locale from the machine rather than the one the application resolved, and builds a formatter per call. Take a collator or a formatter from src/i18n/format.ts instead.",
},
];
// A branch choosing between two glyph components on the reading direction is how
// the mirror rule gets undone in JavaScript instead of in CSS. It is
// invisible to the stylelint rules that hold the stylesheets, because it is not
// CSS, and invisible to the literal rule above, because a glyph component is
// neither a text child nor a string.
const DIRECTION_BRANCH = [
{
selector:
"ConditionalExpression[consequent.type=/^JSX(Element|Fragment)$/][alternate.type=/^JSX(Element|Fragment)$/]",
message:
"Two glyph components chosen on a condition are a prop, a branch and a coverage line for what one transform: scaleX(-1) under an attribute selector already does. Mirror in the stylesheet.",
},
{
// Both sides, by shape. esquery cannot scope :has() to a named property,
// so an else-if chain and a return that is not first in its block are out
// of reach. That gap is recorded in GUARD-DISPOSITION.md and left open
// instead of closed with a looser selector, because the guarded render this
// used to flag is the false positive that gets a rule deleted.
selector:
"IfStatement" +
":matches([consequent.argument.type=/^JSX(Element|Fragment)$/], [consequent.body.0.argument.type=/^JSX(Element|Fragment)$/])" +
":matches([alternate.argument.type=/^JSX(Element|Fragment)$/], [alternate.body.0.argument.type=/^JSX(Element|Fragment)$/])",
message:
"Two glyph components returned from two branches are a prop, a branch and a coverage line for what one transform: scaleX(-1) under an attribute selector already does. Mirror in the stylesheet.",
},
];
export default defineConfig([
// Build output and test-runner output, not authored source. `eslint .` walks
// the working tree, so without this the gate reports parse errors for an
// emitted bundle, a coverage report, and the page resources the visual spec
// archives under test-results/.
{
ignores: [
"dist/",
"lib/",
"coverage/",
"test-results/",
"playwright-report/",
"junit/",
"src/__screenshots__/",
".vitest-attachments/",
".stryker-tmp/",
"reports/",
],
},
{
files: ["**/*.{js,mjs,cjs,jsx,mjsx,ts,tsx,mtsx}"],
languageOptions: { globals: globals.browser },
},
eslint.configs.recommended,
// Type-aware, which puts the promise rules in play. A floating promise and a
// promise passed where a void callback is expected are both invisible without
// types, and this tree is built on a request seam, effects and a debounce.
// projectService reads the tsconfig this repository already has, so the linted
// set and the typechecked set are the same set by construction, with no second
// list kept here.
tseslint.configs.recommendedTypeChecked,
{
languageOptions: {
parserOptions: {
projectService: true,
tsconfigRootDir: import.meta.dirname,
},
},
},
{
...react.configs.flat.recommended,
languageOptions: {
...react.configs.flat.recommended.languageOptions,
},
settings: {
react: {
version: "detect",
},
},
rules: {
// Spread again, because the key above replaces the recommended rules
// wholesale instead of merging with them, which silently disabled all 22.
...react.configs.flat.recommended.rules,
"react/react-in-jsx-scope": "off",
},
},
// The plugin's own flat config, so the shape tracks the installed major
// instead of the one this file was written against.
// Note it enables the React Compiler rule family, not just the two classic
// hook rules.
reactHooks.configs.flat.recommended,
// The axe sweeps assert what a rendered tree does; this asserts what the JSX
// says, the half that has no test to render it. A control that no case mounts
// is invisible to both sweeps and visible here.
//
// recommended, not strict. Strict turns on rules whose correct answer depends
// on the surrounding markup, which makes them noisy in a tree this small
// without making them more careful.
jsxA11y.flatConfigs.recommended,
// Test files are exempt from the scaffolding restriction because importing
// that scaffolding is what it is for.
{
files: ["src/**/*.{ts,tsx}"],
ignores: ["src/**/*.test.{ts,tsx}", "src/**/*.test-d.ts", "src/test/**"],
rules: {
"no-restricted-imports": [
"error",
{ patterns: [TEST_SCAFFOLDING_IMPORT] },
],
},
},
// The shared components take every rendered string as a prop, the one
// property that lets them show something other than cities. A component
// reaching the locale layer directly would weld the table to this app's copy
// and to this app's idea of a catalog, and it would do so invisibly: the tree
// still renders, the tests still pass, and the layer rule is gone. Cheaper to
// fail here than to catch in review every time.
{
files: ["src/components/**/*.{ts,tsx}"],
ignores: ["src/**/*.test.{ts,tsx}", "src/**/*.test-d.ts"],
rules: {
"no-restricted-imports": [
"error",
{
patterns: [
TEST_SCAFFOLDING_IMPORT,
{
group: ["**/i18n/**", "**/useLocale*"],
message:
"src/components/ takes its strings as props so that it stays reusable. Pass them in through the labels prop instead of reaching for a catalog here.",
},
{
// The other direction of the same layer rule. A domain type here
// welds the generic table to one dataset and defeats the type
// parameter, and it does so invisibly, with the tree still
// rendering and every test still passing.
group: ["**/api/**", "**/data/**"],
message:
"src/components/ renders any row type, so it may not name one. Pass the shape through Column<T>, getRowId and the labels prop instead of importing a domain type here.",
},
],
},
],
},
},
// The formatter module is exempt because it is the module the rule names. The
// test globs are exempt because a test asserting a formatted string has to
// compute its expectation through the platform. The French group separator is
// a narrow no-break space, and a typed literal fails on a difference no
// terminal renders. src/test/ stays covered, because it holds executing
// helpers and not assertions, and the sweep it drives is shipped code's reach.
{
files: ["src/**/*.{ts,tsx}"],
ignores: [
"src/i18n/format.ts",
"src/**/*.test.{ts,tsx}",
"src/**/*.test-d.ts",
],
rules: {
"no-restricted-syntax": ["error", ...LOCALE_CALL_SITE],
},
},
// The same layer rule one level down from the imports above. A hardcoded
// sentence needs no import, so the two import rules cannot see it. ignoreProps
// is deliberate, because the attribute half is narrower than every prop and is
// handled by the selector beside it.
{
files: ["src/components/**/*.{ts,tsx}"],
ignores: ["src/**/*.test.{ts,tsx}", "src/**/*.test-d.ts"],
rules: {
"react/jsx-no-literals": [
"error",
{ noStrings: true, ignoreProps: true, allowedStrings: [] },
],
"no-restricted-syntax": [
"error",
READER_FACING_ATTRIBUTE,
...LOCALE_CALL_SITE,
],
},
},
// The component holding the four glyphs that mean a direction. All three
// selector sets, because no-restricted-syntax is configured per rule. This
// block replaces the two above outright for this file, and naming only the
// direction set would unrestrict the other two for exactly the component most
// likely to regain a literal.
{
files: ["src/components/DataTable/Pagination.tsx"],
rules: {
"no-restricted-syntax": [
"error",
READER_FACING_ATTRIBUTE,
...LOCALE_CALL_SITE,
...DIRECTION_BRANCH,
],
},
},
// userEvent's direct entry points open a session of their own with a no-op
// clock advance, so under a faked clock they wait on a timer that never fires.
// The matching check, that a session in a file that fakes a clock is bound to
// it, stays in src/toolchain.test.ts, because a selector cannot read the file.
{
files: ["src/**/*.test.{ts,tsx}"],
rules: {
"no-restricted-syntax": [
"error",
{
selector:
'CallExpression[callee.type="MemberExpression"][callee.object.name="userEvent"]:not([callee.property.name="setup"])',
message:
"userEvent's direct entry points open their own session, which cannot be bound to a fake clock. Use userEvent.setup() and call the method on the session it returns.",
},
],
},
},
// The type-aware rules need declarations to reason about, and the plain
// JavaScript in this tree has none: a build script reading an untyped CSV
// parser and a flat config importing a plugin that ships no types. Every
// value there is `any`, so the rules report the absence of types instead of a
// defect. Syntax and correctness rules still apply.
{
files: ["**/*.{js,mjs,cjs}"],
extends: [tseslint.configs.disableTypeChecked],
},
// Recommended as of the next major, enabled ahead of it. A symptom error
// that drops the one it was raised from costs the reader the stack that
// says where the failure started, and the data module already carries the
// original as a cause at every boundary it wraps.
{
rules: {
"preserve-caught-error": "error",
},
},
prettierConfig, // must go last
]);