-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy patheslint.shared.ts
More file actions
421 lines (390 loc) · 37.2 KB
/
Copy patheslint.shared.ts
File metadata and controls
421 lines (390 loc) · 37.2 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
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
import js from "@eslint/js";
import json from "@eslint/json";
import markdown from "@eslint/markdown";
import prettierRecommended from "eslint-plugin-prettier/recommended";
import depend from "eslint-plugin-depend";
import yml from "eslint-plugin-yml";
import exadevRecommendedTypeChecked from "@exadev/eslint-config";
import globals from "globals";
import { builtinModules } from "node:module";
import tseslint from "typescript-eslint";
/**
* The lint configuration every package in this workspace shares, as a function rather than a static array.
*
* A static array would not work here: the real per-package variation is structural, not cosmetic. Which TSConfig programs a package runs, whether it is Worker-isomorphic, and what its barrel policy is are all genuinely different between packages, and each one has to reach the parser or rule wiring. Those are the parameters below.
*
* What is NOT parameterised is the rule set itself. Before this file existed the thirteen package configs had drifted into three incompatible tiers — four packages ran no type-aware linting at all, two hand-inlined their own approximation of it, and seven used the shared preset — so a rule added "everywhere" reached seven packages and a Worker-isomorphism guard could be silently absent from a package that needed it. Every package now gets the same rules; only the wiring differs.
*/
/**
* Node's own builtin module list, reduced to base specifiers.
*
* The ban list this replaces was eighteen names written out by hand, which left most of Node's surface unguarded: a bare `import dns from 'dns'` in a Worker-isomorphic package passed the guard, as did `cluster`, `tls`, `vm`, `v8`, `repl`, and the rest. Deriving the list from `builtinModules` closes that gap permanently and keeps it closed as Node adds modules.
*
* `_`-prefixed entries are deprecated internals nobody imports deliberately, and `node:`-prefixed entries are covered by the separate `node:*` group pattern below — some of them (`node:test`) exist only in prefixed form and have no bare spelling to ban. Subpaths collapse to their base (`fs/promises` to `fs`) because the pattern below matches subpaths through its own suffix group.
*/
const nodeBuiltinBaseModules: readonly string[] = [
...new Set(
builtinModules
.filter((name) => !name.startsWith("_") && !name.startsWith("node:"))
.map((name) =>
name.includes("/") ? name.slice(0, name.indexOf("/")) : name,
),
),
].sort();
/**
* Matches a bare Node builtin specifier and any subpath of one.
*
* `regex`, not `group`. `no-restricted-imports` matches `group` entries through the `ignore` package, i.e. gitignore semantics over path segments, so a `group: ['util']` entry also matches this workspace's own relative `./util/base64` and `../util` imports — a false positive several packages hit and worked around inconsistently, some by switching to a regex and some by leaving the bug in place. The regex is tested against the raw specifier, which keeps its `./` prefix, so `^util$` matches `import 'util'` and never `import './util/base64'`.
*/
const bareNodeBuiltinPattern = `^(${nodeBuiltinBaseModules.join("|")})(/.*)?$`;
const isomorphicNodeImportMessage =
"This is a Worker-isomorphic library: node:* imports are banned in runtime src. Use a Web API or an isomorphic helper.";
const isomorphicBareBuiltinMessage =
"This is a Worker-isomorphic library: bare Node builtin imports are banned in runtime src. Use a Web API or an isomorphic helper.";
const isomorphicBufferMessage =
"Buffer is Node-only; this Worker-isomorphic library uses Uint8Array.";
/**
* Linting for the data and prose files that sit beside the code — JSON, Markdown, and YAML — defined once and used by both the workspace root's config and every package's.
*
* Defined here rather than only at the root, because a root config cannot reach these files usefully. `eslint .` from the workspace root has to walk the whole tree to find them, and the tree contains a 400 MB turbo cache, every package's node_modules, and this workspace's binary document fixtures; several of these plugins also ship with no `files` restriction at all, which makes every path a lint target. The result exhausted a 4 GB heap rather than finishing. Running the identical rules from inside each package instead keeps every run scoped to one small directory, and turbo runs the thirteen concurrently.
*
* One definition, thirteen call sites — which is the property that matters. It is not thirteen copies of a decision.
*/
/**
* `@eslint/json`'s own language option, which ESLint's core `LanguageOptions` type does not model — it declares no index signature, so writing the key inline is an excess property. Held in a bag typed for what it is (a language's own options, whose shape belongs to the plugin) rather than asserted past the core type.
*/
const jsoncLanguageOptions: Record<string, unknown> = {
allowTrailingCommas: true,
};
export const dataFileLintConfig: ReturnType<typeof tseslint.config> =
tseslint.config(
// Plain JSON: no comments, no trailing commas.
{
files: ["**/*.json"],
ignores: ["**/tsconfig*.json", "**/turbo.json"],
plugins: { json },
language: "json/json",
extends: [json.configs.recommended],
},
// JSONC, for the three families that genuinely carry comments — established by grep, not assumed. turbo.json documents its own pipeline inline; every tsconfig*.json carries explanatory comments (TypeScript has always permitted them); wrangler.jsonc says so in its extension. Parsed as plain JSON, every one of those comments is a syntax error.
{
files: ["**/*.jsonc", "**/tsconfig*.json", "**/turbo.json"],
plugins: { json },
language: "json/jsonc",
// A trailing comma is part of what JSONC is, and every consumer of these particular files accepts one: TypeScript in a tsconfig, turbo in its own pipeline file, wrangler's jsonc-parser in a Worker config. Prettier writes one, so the language has to admit it or the two disagree permanently and each of these files fails to parse instead of being checked.
languageOptions: jsoncLanguageOptions,
extends: [json.configs.recommended],
},
// Markdown. The recommended set is structural — no empty links, no duplicate H1, no reversed link syntax — rather than stylistic, so it does not argue with how the prose is written.
...markdown.configs.recommended,
{
// Off: this workspace's prose uses square brackets for things that are not reference links, and the rule cannot tell the difference. A GFM task list is written `- [ ] item`, and the READMEs cite specifications by bracketed short name (`[MS-CFB]`, `[MS-OLEDS]`). Both are correct as written; every report from this rule here was one of the two.
files: ["**/*.md"],
rules: { "markdown/no-missing-label-refs": "off" },
},
{
// Off: `no-reversed-media-syntax` hangs indefinitely (a ReDoS, not merely a slow pass) against real prose in this workspace's own READMEs — reproduced directly against @eslint/markdown 8.0.3, the latest published version at the time of writing, with no fixed release available to upgrade to. Isolating every markdown/* rule to run alone against the same file narrowed the hang to this one rule specifically; every other rule in the recommended set completes instantly against identical content. This workspace's own prose convention (one continuous line per paragraph or table cell, however long, rather than hard-wrapped — see the repo's "never hard wrap" convention) is exactly the shape of input that triggers it, so the rule is a live landmine for any README here, not merely the one that first surfaced it. Re-enable once a released fix exists upstream.
files: ["**/*.md"],
rules: { "markdown/no-reversed-media-syntax": "off" },
},
// YAML. Each entry gets an explicit `files`: two of the three the plugin ships set none, which would make every path in the repository a YAML lint target.
...yml.configs["flat/recommended"].map((config) => ({
...config,
files: ["**/*.{yml,yaml}"],
})),
{
// A GitHub workflow trigger with no filters is a bare key — `pull_request:` means "every pull request", `workflow_dispatch:` means "manually runnable". The value is genuinely absent, which is what the schema expects, so this rule reports correct YAML as a defect. Scoped to .github/ rather than off outright, since an accidentally empty value elsewhere usually is a mistake.
files: [".github/**/*.{yml,yaml}"],
rules: { "yml/no-empty-mapping-value": "off" },
},
// Flags a dependency a native API or a lighter package now covers.
//
// The plugin and the one rule are registered directly rather than by spreading the shipped `flat/recommended`. That preset sets no `files`, so it would apply to every path in the package, and its `configs` is typed as possibly-undefined — naming the rule states exactly what is switched on and needs no narrowing of a third-party shape.
{
files: ["**/package.json"],
plugins: { depend },
rules: {
// lint-staged is allowed deliberately. The rule suggests a lighter alternative, which is reasonable advice in general, but this workspace's pre-commit hook is built around it, and swapping a working tool for a lint opinion is not a dependency decision worth making here.
"depend/ban-dependencies": ["error", { allowed: ["lint-staged"] }],
},
},
);
/** Build output, dependencies, coverage reports, and the smoke suite — which imports from `../dist`, a build artefact deliberately outside every package's TSConfig program because it tests built output rather than source. */
const alwaysIgnored: readonly string[] = [
"dist",
"coverage",
"node_modules",
"test",
// Stryker's own output: a mutation HTML report, and (for a package with `incremental: true` in its stryker.config.ts) an incremental result cache that can run to several megabytes of generated JSON — large enough on its own to slow a lint pass, and its escaped byte content has produced real lone-surrogate reports from json/no-unsafe-values with nothing for a contributor to fix. `.stryker-tmp` is Stryker's own sandbox working directory (tempDirName in stryker.shared.ts), left behind by an interrupted run rather than cleaned up.
"reports",
".stryker-tmp",
// AGENTS.md and CLAUDE.md are symlinks to README.md in every package, so linting all three lints one file three times.
"AGENTS.md",
"CLAUDE.md",
// Written by semantic-release on every release; reformatting it would fight the release pipeline.
"CHANGELOG.md",
// Vendored third-party corpora (the CommonMark and GFM specs, the WHATWG entity table), each carrying its own NOTICE.md recording source and licence, kept byte-for-byte as published.
"assets",
// Generated: examples by GENERATE_EXAMPLES, schemas by document-schema.js's own build step.
"examples",
"schemas",
];
/** `exadev/barrel-policy`'s own modes, plus `off` for a package whose file layout the rule cannot describe. */
export type BarrelPolicy = "banned" | "single" | "siblings" | "off";
/** One `no-restricted-imports` pattern entry: a gitignore-semantics `group`, or an anchored `regex` tested against the raw specifier. */
export interface RestrictedImportPattern {
readonly group?: readonly string[];
readonly regex?: string;
readonly message: string;
}
export interface PackageLintOptions {
/** Always `import.meta.dirname` from the calling package's own `eslint.config.ts`. Pins the TSConfig root so the parser is not confused by another package's tsconfig elsewhere in the tree, which matters because lint-staged runs eslint at commit time. */
readonly tsconfigRootDir: string;
/**
* The TSConfig programs to resolve linted files against, relative to the package.
*
* Defaults to the dual-program layout every library package here uses: `tsconfig.json` is the web-only gate (lib ES2024+WebWorker, `types: []`, tests excluded) that makes the isomorphism constraint a type-level fact rather than only a lint rule, and `tsconfig.node.json` covers tests, test-support, and config files under Node types.
*
* `project`, not `projectService`, and that is load-bearing for the dual layout: the project service only ever assigns a file to its nearest `tsconfig.json`, so files that exist solely in `tsconfig.node.json` end up unaffiliated and every type-aware rule crashes on them. Listing both programs explicitly routes each file to the one that includes it. A package with a single program covering everything (the CLI and MCP server) passes just that one.
*/
readonly projects?: readonly string[];
/** Appended to the always-ignored set above, for paths only this package has — a `scripts/` directory that imports from `../dist`, a generated router tree, a test-report directory. */
readonly additionalIgnores?: readonly string[];
/**
* Whether this package is Worker-isomorphic, i.e. its published `src/` must run unchanged in a Cloudflare Worker or a browser.
*
* True for every foundation and format-codec package; false for the CLI, the MCP server, and the web UI, which legitimately target Node or a browser rather than needing portability between them. When true, runtime `src/` is barred from importing `node:*` or a bare Node builtin and from using the `Buffer` global. Test files and test-support are exempt: they are not published and read fixtures off disk.
*/
readonly isomorphic?: boolean;
/** Defaults to `single` — every published package here exposes exactly one barrel at `src/index.ts`, named in its `exports` map. */
readonly barrelPolicy?: BarrelPolicy;
/**
* Whether to put Node's globals in scope for every linted file. Defaults to true.
*
* True suits every library package here, including the Worker-isomorphic ones: what enforces their portability is the import ban and the web-only TSConfig program, not the absence of ambient global types. The web UI passes false and scopes browser, worker, and Node globals to the layers that actually have them — handing that app Node globals everywhere would let a `process.env` read in browser code lint clean.
*/
readonly nodeGlobals?: boolean;
/**
* Extra `no-restricted-imports` patterns, merged into the same rule the isomorphism guard writes.
*
* They have to be merged rather than declared in the calling package, because flat config REPLACES a same-key rule instead of merging it: a package that declared its own `no-restricted-imports` over the same files would silently drop the Node-builtin ban and keep passing. markdown-codec is the case this exists for — it bans every third-party markdown library over exactly the files the isomorphism guard covers.
*/
readonly additionalRestrictedImportPatterns?: readonly RestrictedImportPattern[];
/**
* Runtime `src/` paths that are exempt from the isomorphism guard, on top of the test files and test-support it always exempts.
*
* For an executed entry point rather than an importable one: `documents.js`'s `src/bin.ts` is a launcher that spawns `npx`/`pnpm`/`yarn`/`bunx`, so it is Node-only by definition. It is never imported into the isomorphic surface, so exempting it leaves that surface pure — and the package's own `tsconfig.node.json` already routes it to the Node program, so the two agree.
*/
readonly isomorphicExemptions?: readonly string[];
/**
* Whether `no-non-null-assertion` is enforced. Defaults to `'error'`.
*
* `strictTypeChecked` turns this on, and it is the single largest source of violations in this workspace by an order of magnitude — 2,395 sites, of which `documents.js` and `pdf-codec` hold 91% between them. Clearing one is not mechanical: a `!` marks a place where the code asserts a value is present, and removing it honestly means deciding what the absence means and handling it at the right boundary, not substituting a sentinel.
*
* So a package carrying more than can be worked through carefully sets `'off'` here, in its own config where the debt is visible rather than buried in this file, and is tracked for burn-down. Every other package enforces it.
*/
readonly nonNullAssertion?: "error" | "off";
/**
* Whether `exadev/prefer-readonly-array-param` and `exadev/prefer-readonly-object-param` are enforced. Defaults to `'error'`.
*
* Both rules mark every array/tuple or "flat" object parameter readonly unconditionally, by design (their own doc comments state this explicitly), with no check for whether the function body goes on to mutate that parameter in place — a `.push`/`.pop`/`.splice` on an array parameter, or a property assignment on an object parameter, both compile cleanly today and both stop compiling the moment the parameter's own type gains a `readonly`. That is deliberate upstream: the rules exist to turn a silent in-place mutation of a caller's data into a visible, forced compile error at the one spot the mutation happens, not to detect and skip it.
*
* That trade only pays off where the flagged parameter is genuinely foreign to the function — data a caller handed in that the function has no business mutating. It actively breaks a different, equally common shape this workspace's own binary/format-codec packages lean on constantly: a local accumulator (a bounds tracker, a glyph/operand stack, a byte cursor) built and owned entirely by the function that receives it, where in-place mutation via a parameter *is* the algorithm, not a bug the type system should be catching. Running the rules' own autofix against this workspace surfaced the difference empirically rather than theoretically: 443 real compile errors across 18 of this workspace's 22 published packages (`TS2540`/`TS2542`/`TS2551`/`TS2339` from array/object mutation methods and property assignments no longer existing on the now-readonly type, `TS4104`/`TS2345`/`TS2322` from the resulting readonly value then failing to satisfy a mutable field or parameter elsewhere) — not a handful of stray exceptions, but the majority shape of how this workspace's lower-level packages are actually written.
*
* Telling the two shapes apart correctly, parameter by parameter, is a real design review across roughly eighteen packages — deciding for each flagged site whether the array/object is foreign data to leave exactly as-is, or owned local state to thread through as a small wrapper object instead (object parameters are outside both rules' own scope, by their own design comments, which is what makes that the correct shape for owned mutable state rather than a workaround) — not something a bump's own autofix pass can safely decide by itself. So a package carrying this debt sets `'off'` here, in its own config where it is visible, and is tracked for burn-down (ExaDev/documents.js#1275); every package clean of it enforces both rules.
*/
readonly preferReadonlyParams?: "error" | "off";
/**
* Whether `@typescript-eslint/no-magic-numbers` is enforced. Defaults to `'off'` — the one rule in this file whose default itself is `'off'` rather than `'error'`, because unlike every other deviation here it is not a per-package debt but a workspace-wide one: measured directly against a current build, 30,748 sites across 864 files in every one of this workspace's 22 published packages, from the two smallest (document-operations: 8, excel-number-format: 69) to the largest (documents.js: 4,247, pdf-codec: 6,040). The rule's own configuration (`ignore: [-1, 0, 1, 2]`, `ignoreArrayIndexes`, `ignoreEnums`, `ignoreReadonlyClassProperties`, `ignoreDefaultValues`) already exempts every case that can be exempted mechanically; every one of the 30,748 remaining sites is a literal that needs an actual name someone chose because they understood what it means — a format code, a byte offset, a sector size, a boundary value in a test fixture — which is exactly why it cannot be satisfied by an automated pass the way the two rules above sometimes can be. A plain top-level `const NAME = value` fully satisfies the rule (confirmed directly: only a literal used inline, e.g. inside an array literal or a call argument, is ever flagged), so the fix is mechanical *type*-wise but not mechanical *content*-wise — there is no way to give 30,748 numbers correct names without reading what each one means.
*
* Enable it per-package once that package's own literals have real names (ExaDev/documents.js#1275 tracks the burn-down, alongside the two rules above).
*/
readonly magicNumbers?: "error" | "off";
/**
* Whether ESLint core's `max-lines` (800, real lines of code, blank lines and comments both excluded from the count) is enforced. Defaults to `'off'`, for the same reason `magicNumbers` above defaults off rather than per-package: measured directly, 93 files across every packaged codec and the conversion engine itself exceed it today, from a handful of files in the smaller packages up to several files over 2,000 real lines each. Splitting a file properly — extracting the genuinely separate concerns a file this size usually holds, rather than cutting it at an arbitrary line count — is a real per-file design decision (which exports move where, which tests follow which module, whether a extracted piece needs its own barrel entry), not something an automated pass can decide safely at this scale either.
*
* Enable it per-package once that package's own oversized files are actually split (ExaDev/documents.js#1275 tracks the burn-down, alongside the two rules above).
*/
readonly maxLines?: "error" | "off";
/**
* Rule names to disable outright for this package, defaulting to none.
*
* Exists for one reason: \@exadev/eslint-config was bumped straight from 2.1.2 to 2.12.1 (ExaDev/documents.js#1275), a roughly ten-minor-version gap this workspace had never linted against incrementally, and it enabled well over a dozen rules across that gap this workspace has real, pre-existing violations of — 781 sites across every one of the 22 published packages at the time of the bump, measured directly: `@typescript-eslint/strict-void-return` (239), `method-signature-style` (133), `consistent-return` (119), `no-use-before-define` (60), `promise-function-async` (55), `no-shadow` (45), `tsdoc/syntax` (39), `strict-boolean-expressions` (38), `switch-exhaustiveness-check` (31), `consistent-type-exports` (5), `prefer-readonly` (4), `exadev/no-object-assign` (3), `exadev/no-mutable-union-array-param` (3), `require-array-sort-compare` (3), `jsdoc/escape-inline-tags` (2), `jsdoc/no-multi-asterisks` (1), `exadev/prefer-numeric-sort-compare` (1). None of these is the kind of debt `nonNullAssertion`/`preferReadonlyParams`/`magicNumbers`/`maxLines` above are: each is its own rule, with its own real fix at each site, and grouping them behind named booleans the way those four get would mean growing this interface by a dozen-plus fields for a one-time migration rather than a standing per-package axis of variation. A plain rule-name list says the same thing without that growth, and is exactly as visible: every package that needs one lists its own rule names here, in its own config, same as every other exception in this file.
*
* Not a general-purpose escape hatch — add a name here only as part of documenting a specific measured violation count from this migration (ExaDev/documents.js#1275), the same evidentiary bar every other exception in this file meets, never to silence an ordinary new finding.
*/
readonly newRuleDebt?: readonly string[];
}
export function packageLintConfig(
options: PackageLintOptions,
): ReturnType<typeof tseslint.config> {
const {
tsconfigRootDir,
projects = ["./tsconfig.json", "./tsconfig.node.json"],
additionalIgnores = [],
isomorphic = false,
barrelPolicy = "single",
nodeGlobals = true,
additionalRestrictedImportPatterns = [],
isomorphicExemptions = [],
nonNullAssertion = "error",
preferReadonlyParams = "error",
magicNumbers = "off",
maxLines = "off",
newRuleDebt = [],
} = options;
const typeScriptFiles = ["**/*.ts", "**/*.tsx"];
const runtimeSrcExemptions = [
"src/**/*.test.ts",
"src/test-support/**",
...isomorphicExemptions,
];
const restrictedImportPatterns: readonly RestrictedImportPattern[] = [
...additionalRestrictedImportPatterns,
...(isomorphic
? [
{
group: ["node:*", "node:*/**"],
message: isomorphicNodeImportMessage,
},
{
regex: bareNodeBuiltinPattern,
message: isomorphicBareBuiltinMessage,
},
]
: []),
];
return tseslint.config(
{ ignores: [...alwaysIgnored, ...additionalIgnores] },
{
// Every TypeScript rule below is scoped to TypeScript files. Without this the parser project and the JS/TS rule sets apply to the JSON, Markdown, and YAML files too — the TS parser cannot read them, and a core rule like no-irregular-whitespace crashes outright on a JSON AST rather than reporting anything.
files: typeScriptFiles,
languageOptions: {
parserOptions: { project: [...projects], tsconfigRootDir },
...(nodeGlobals ? { globals: { ...globals.node } } : {}),
},
extends: [
js.configs.recommended,
// Bundles typescript-eslint's recommendedTypeChecked and stylisticTypeChecked (recommendedTypeChecked already subsumes plain recommended outright), the exadev/* rules, linterOptions.noInlineConfig, consistent-type-assertions banning every type assertion, and ban-ts-comment banning @ts-expect-error alongside the preset's @ts-ignore/@ts-nocheck bans — the last two relaxed automatically in test files. See @exadev/eslint-config's own README for the full set.
...exadevRecommendedTypeChecked,
// The strict tier on top of the preset's recommended one. What it actually adds here, measured across all thirteen packages against a current build: 3,298 violations, of which no-non-null-assertion is 2,395 and restrict-template-expressions 689 — leaving 214 genuine findings the two deviations below do not touch. Those 214 are real (confusing void expressions, conditions that are always truthy, deprecated API use, misused spreads) and are fixed rather than configured away.
...tseslint.configs.strictTypeChecked,
],
rules: {
"@typescript-eslint/consistent-type-imports": [
"error",
{ fixStyle: "inline-type-imports" },
],
// Deviation from strictTypeChecked, which sets every allow* to false. `allowNumber: true` accounts for all 689 reports the strict tier adds for this rule, and every one is a number interpolated into a message or an identifier — page counts, byte offsets, sector indices, error strings naming a size. A number has one unambiguous string form, so interpolating it loses nothing and demanding an explicit String() around each would be noise.
//
// `allowAny` deliberately stays false, which is the half of this rule that catches real defects: interpolating an `any` is how "[object Object]" and "undefined" reach a user-visible message.
"@typescript-eslint/restrict-template-expressions": [
"error",
{ allowNumber: true },
],
// Deviation from the default, which treats a `default` clause as covering nothing and still demands every union member be named. This workspace deliberately uses `default:` for the structurally-uninteresting remainder of a discriminated union — containsSymbol in document-compute.js is the clearest case, where the three symbol-free leaves carry nothing to recurse into and naming them individually would add three string-literal case labels a mutation can flip to an identical no-op, since no consumer can distinguish the case returning `false` from the case simply not matching. The rule's own option exists for exactly that style, and it narrows nothing: a switch with no `default` is still checked for missing cases, so a genuine gap is still reported.
"@typescript-eslint/switch-exhaustiveness-check": [
"error",
{ considerDefaultExhaustiveForUnions: true },
],
"@typescript-eslint/no-non-null-assertion": nonNullAssertion,
"exadev/prefer-readonly-array-param": preferReadonlyParams,
"exadev/prefer-readonly-object-param": preferReadonlyParams,
// Deviation from @exadev/eslint-config's own options for this rule, which is why every one of them is restated here: flat config REPLACES a same-key rule rather than merging it, so passing an options object drops the base's `ignore`/`ignoreArrayIndexes`/`ignoreEnums`/`ignoreReadonlyClassProperties`/`ignoreDefaultValues` unless they are repeated. If the base config changes its own defaults, this block has to follow; it is a copy, not an extension.
//
// `ignoreNumericLiteralTypes` is the addition, and it is narrower than it first looks. It exempts numeric literals in TYPE position, which is the declaration itself: `type BlockHeadingLevel = 1 | 2 | 3 | 4 | 5 | 6`. Those cannot be replaced by named constants, because a type alias needs types rather than values, and spelling it `typeof HEADING_1 | typeof HEADING_2 | ...` is contortion rather than clarity for a bounded domain the checker already enforces. markdown-codec's HtmlBlockType is the clearest instance: its numbering deliberately mirrors the CommonMark spec so the field reads against the spec text.
//
// It does NOT exempt the use sites, and an earlier version of this comment wrongly claimed a member of such a union can never be named. It can: `const COMMENT = 2` infers the literal type `2`, so it satisfies the union in array construction and in switch-case matching alike, verified by compiling both under --strict. So a runtime array enumerating the union, or a switch over it, is ordinary naming work and stays reported. Only the declaration is exempt.
"@typescript-eslint/no-magic-numbers": [
magicNumbers,
{
ignore: [-1, 0, 1, 2],
ignoreArrayIndexes: true,
ignoreEnums: true,
ignoreReadonlyClassProperties: true,
ignoreDefaultValues: true,
ignoreNumericLiteralTypes: true,
},
],
"max-lines": maxLines,
...Object.fromEntries(newRuleDebt.map((rule) => [rule, "off"])),
// Deviation from strictTypeChecked, which reports every string spread. Spreading a string is how you iterate it by code point — `[...text]` splits on code points where `text.split('')` splits on UTF-16 code units and so tears every astral character in half. This workspace parses real-world documents full of them (emoji, CJK extensions, mathematical alphanumerics), and the sites reporting here are named `codePoints` precisely because that is what they are computing.
//
// Only `string` is allowed. Every other case the rule catches — spreading a Map, a class instance, a Promise, an array into an object — stays an error, and those are the ones that are actually bugs.
"@typescript-eslint/no-misused-spread": [
"error",
{ allow: ["string"] },
],
// `only-allowed-literals` rather than strictTypeChecked's own `never`. The rule's default rejects `while (true)`, which this workspace uses for exactly the loops it is meant for: a Dijkstra main loop over a priority queue and two predecessor-chain walks, each terminating on an internal `break` whose condition cannot be lifted into the header without duplicating it. Rewriting them as `while (queue.length > 0)` would either change the semantics or need a second copy of the exit test.
//
// Only literal `true` is exempted, so a condition that is constant because of a genuine type mistake — an always-truthy object, a comparison the types already decide — still reports.
"@typescript-eslint/no-unnecessary-condition": [
"error",
{ allowConstantLoopConditions: "only-allowed-literals" },
],
// Off. Every pair it reports is a live-view editor property where the getter returns `T | undefined` (the underlying XML attribute may be absent) and the setter takes `T` (you can only assign a real value). TypeScript has supported divergent accessor types since 4.3 precisely for this, and the asymmetry is the honest description of the API.
//
// Making them agree would mean widening each setter to `T | undefined` and giving it a documented "clear the property" behaviour — a genuine improvement, since there is currently no way to unset a font family or colour, but a feature addition to a published editor surface with its own tests to write. Worth doing on its own; not something to smuggle into a tooling change.
"@typescript-eslint/related-getter-setter-pairs": "off",
"exadev/barrel-policy":
barrelPolicy === "off" ? "off" : ["error", { mode: barrelPolicy }],
},
},
{
// The config files themselves call `tseslint.config()`, which typescript-eslint deprecated in favour of ESLint core's `defineConfig()`. Migrating is blocked upstream rather than by choice: `defineConfig`'s stricter `Plugin` type rejects eslint-plugin-react-hooks@7, whose `configs.flat` is a nested record of configs where ESLint's own index signature admits only a config or an array of them. The web UI's config registers that plugin, so `defineConfig` there fails `tsc` outright, and the only way through is a type assertion this workspace bans.
//
// Scoped to the config files alone, so a deprecated API anywhere in real source still reports. Revisit when eslint-plugin-react-hooks' types satisfy ESLint's `Plugin`.
files: ["eslint.config.ts"],
rules: { "@typescript-eslint/no-deprecated": "off" },
},
{
// A no-op arrow standing in for a callback prop a given test case never exercises is the ordinary way to write that, and flagging each one only pushes authors to pad it with a meaningless body. Scoped to tests: production code has no legitimate empty function body.
//
// The CLI and the MCP server each carried this already, scoped to `**/*.test.ts` — a glob that silently misses `.test.tsx`, so twelve such stand-ins in one Ink component test were reported as errors while the identical pattern in a `.ts` test was not. Stated once here, over both extensions.
files: [
"**/*.test.{ts,tsx}",
"**/*.spec.{ts,tsx}",
// test-support is test code by every property that matters here: it exists only to build fixtures for the suites, and every package's tsdown entry list already excludes it from the published build. It missed the test glob only by filename.
"**/test-support/**",
],
rules: {
"@typescript-eslint/no-empty-function": [
"error",
{ allow: ["arrowFunctions", "asyncFunctions"] },
],
// Off for the same reason, and measured rather than assumed: across six packages surveyed, 8,018 of 10,159 reported sites were in test files, and sampling them found almost entirely values whose arbitrariness is the point. A sentinel payload (`new Uint8Array([9, 9])`), a heading level in `[1, 2, 3, 4, 5, 6]` where the literal is what the spec says, a count handed to a fixture builder (`spreadsheet(2, 50)`): each is the case the test is about, and giving it a name puts an indirection between the reader and the thing being pinned. The rule guards against an unexplained number in code someone has to maintain; a fixture's numbers are the specification, not a maintenance hazard. A test constant that does carry meaning, a time boundary or a size threshold, is still worth naming, and nothing here stops that.
"@typescript-eslint/no-magic-numbers": "off",
},
},
...(restrictedImportPatterns.length > 0
? tseslint.config({
// The static half of the isomorphism guarantee. The workerd suite (pnpm test:workers) proves the same property dynamically, but only over the paths a test actually exercises; this catches an offending import at lint time on every file, before any test runs. Any package-specific bans are folded into the same rule here rather than declared separately, since a second no-restricted-imports over these files would replace this one outright.
files: ["src/**/*.ts"],
ignores: [...runtimeSrcExemptions],
rules: {
"no-restricted-imports": [
"error",
{ patterns: [...restrictedImportPatterns] },
],
},
})
: []),
...(isomorphic
? tseslint.config({
// Its own config object rather than a key alongside no-restricted-imports above, because that block also carries package-specific import bans and this ban is strictly about isomorphism. Different rule key, so there is nothing for flat config to replace either way.
files: ["src/**/*.ts"],
ignores: [...runtimeSrcExemptions],
rules: {
// Each restriction is a separate option element after the severity, not wrapped in an inner array — see the rule's own arrayOfGlobals schema. Only Buffer is banned; a typeof-process check stays legitimate, since the import ban above covers the real Node surface.
"no-restricted-globals": [
"error",
{ name: "Buffer", message: isomorphicBufferMessage },
],
},
})
: []),
...dataFileLintConfig,
// LAST, and that ordering is the whole contract: this bundles eslint-config-prettier, which turns OFF every stylistic rule that would otherwise fight the formatter. Placed earlier, a later config could re-enable one and the two would disagree forever, each "fixing" the other's output.
prettierRecommended,
);
}