@sister.software/oxlint-config 9.3.0 → 10.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,72 @@
1
+ /**
2
+ * @copyright Sister Software
3
+ * @license AGPL-3.0
4
+ * @author Teffen Ellis, et al.
5
+ * @file A no-unnamed-threshold rule, authored as an oxlint JS plugin (ESLint v9-compatible API). It
6
+ * flags a numeric literal used as a threshold — an operand of a comparison — and nothing else.
7
+ * Numbers inside array and object literals are left alone, because a bounding box or a codepoint
8
+ * table is data rather than a tuning knob. That distinction is why this exists instead of
9
+ * `no-magic-numbers`, which cannot express it.
10
+ */
11
+ /** Operators whose operands read as thresholds. */
12
+ const COMPARISON_OPERATORS = new Set(["<", ">", "<=", ">=", "===", "!==", "==", "!="]);
13
+ /** Numbers so conventional that naming them costs more clarity than it buys. */
14
+ const DEFAULT_IGNORE = [-1, 0, 1, 2, 0.5, 10, 100, 1000];
15
+ /** Radix-prefixed literals: `0x1f`, `0b1010`, `0o777`. */
16
+ const RADIX_PREFIXED = /^0[xXbBoO]/;
17
+ export const noUnnamedThresholdRule = {
18
+ meta: {
19
+ name: "no-unnamed-threshold",
20
+ type: "suggestion",
21
+ schema: [{ type: "object", additionalProperties: true }],
22
+ },
23
+ create(context) {
24
+ const options = (context.options[0] ?? {});
25
+ const ignore = new Set(options.ignore ?? DEFAULT_IGNORE);
26
+ const allowHex = options.allowHex ?? true;
27
+ function check(operand) {
28
+ if (!operand)
29
+ return;
30
+ let literal = operand;
31
+ let sign = 1;
32
+ // `x < -273.15` parses as a unary minus wrapping the literal.
33
+ if (operand.type === "UnaryExpression") {
34
+ if (operand.operator !== "-" && operand.operator !== "+")
35
+ return;
36
+ if (!operand.argument)
37
+ return;
38
+ sign = operand.operator === "-" ? -1 : 1;
39
+ literal = operand.argument;
40
+ }
41
+ // oxlint's AST uses ESTree `Literal` in some positions and Babel-style `NumericLiteral` in
42
+ // others, so both are accepted.
43
+ if (literal.type !== "Literal" && literal.type !== "NumericLiteral")
44
+ return;
45
+ if (typeof literal.value !== "number")
46
+ return;
47
+ const raw = literal.raw ?? String(literal.value);
48
+ // `cp >= 0x3040` already reads as a codepoint boundary; a name adds nothing.
49
+ if (allowHex && RADIX_PREFIXED.test(raw))
50
+ return;
51
+ if (ignore.has(sign * literal.value))
52
+ return;
53
+ // `raw` is the literal's own text, so a negated value reads as `273.15` without this.
54
+ const shown = sign === -1 ? `-${raw}` : raw;
55
+ context.report({
56
+ node: literal,
57
+ message: `Unnamed threshold \`${shown}\` — extract it to a documented named constant so a reader can tell ` +
58
+ `what it means and where the value came from.`,
59
+ });
60
+ }
61
+ return {
62
+ BinaryExpression(node) {
63
+ if (!node.operator || !COMPARISON_OPERATORS.has(node.operator))
64
+ return;
65
+ check(node.left);
66
+ check(node.right);
67
+ },
68
+ };
69
+ },
70
+ };
71
+ export default noUnnamedThresholdRule;
72
+ //# sourceMappingURL=threshold-plugin.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"threshold-plugin.js","sourceRoot":"","sources":["../src/threshold-plugin.ts"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sister.software/oxlint-config",
3
- "version": "9.3.0",
3
+ "version": "10.0.0",
4
4
  "description": "Sister Software's oxlint config",
5
5
  "license": "AGPL-3.0",
6
6
  "author": "teffen@sister.software",
@@ -40,7 +40,7 @@
40
40
  "typescript": "^6.0.3"
41
41
  },
42
42
  "peerDependencies": {
43
- "oxlint": "^1.71.0"
43
+ "oxlint": ">=1.75.0 <2"
44
44
  },
45
45
  "engines": {
46
46
  "node": ">=24.0"
@@ -0,0 +1,114 @@
1
+ /**
2
+ * @copyright Sister Software
3
+ * @license AGPL-3.0
4
+ * @author Teffen Ellis, et al.
5
+ * @file A require-constant-doc rule, authored as an oxlint JS plugin (ESLint v9-compatible API). A
6
+ * module-level constant that is exported or SCREAMING_CASE is either public surface or a tuning
7
+ * knob, and in both cases a reader needs to know what the value means and where it came from. A
8
+ * documented table also answers for every number inside it, which is why data-heavy files satisfy
9
+ * this rule without extracting a constant per row.
10
+ */
11
+
12
+ import type { Rule } from "./plugin-types.js"
13
+
14
+ /** Names in SCREAMING_SNAKE_CASE, the convention for a tuning knob. */
15
+ const SCREAMING_CASE = /^[A-Z][A-Z0-9_]*$/
16
+
17
+ /** Initializers that make the binding a function rather than a constant value. */
18
+ const FUNCTION_INITIALIZERS = new Set(["ArrowFunctionExpression", "FunctionExpression"])
19
+
20
+ /** Which module-level constants the rule applies to. */
21
+ export type ConstantDocScope = "exported" | "screaming" | "exported-or-screaming"
22
+
23
+ /** Options accepted by {@link requireConstantDocRule}. */
24
+ export interface ConstantDocOptions {
25
+ /** Defaults to `"exported-or-screaming"`. */
26
+ scope?: ConstantDocScope
27
+ /**
28
+ * Export names a framework requires and gives meaning to, which a JSDoc block cannot improve on — Pastel's
29
+ * `description` (whose value IS the help text), a route module's `loader`, and so on.
30
+ */
31
+ ignoreNames?: string[]
32
+ }
33
+
34
+ export const requireConstantDocRule: Rule = {
35
+ meta: {
36
+ name: "require-constant-doc",
37
+ type: "suggestion",
38
+ schema: [{ type: "object", additionalProperties: true }],
39
+ },
40
+ create(context) {
41
+ const options = (context.options[0] ?? {}) as ConstantDocOptions
42
+ const scope = options.scope ?? "exported-or-screaming"
43
+ const ignoreNames = new Set(options.ignoreNames)
44
+ const sourceCode = context.sourceCode ?? context.getSourceCode!()
45
+ const text = sourceCode.getText()
46
+ const comments = sourceCode.getAllComments()
47
+
48
+ /**
49
+ * True when a JSDoc block documents the declaration starting at `start`.
50
+ *
51
+ * Attachment requires the comment to be directly above it — whitespace spanning a single newline. A blank line in
52
+ * between means the comment belongs to something else, which is what separates a declaration's own JSDoc from the
53
+ * file header block that precedes the first declaration in every file.
54
+ */
55
+ function hasJSDocBefore(start: number): boolean {
56
+ for (const comment of comments) {
57
+ if (comment.range[1] > start) continue
58
+
59
+ if (comment.type !== "Block" || !comment.value.startsWith("*")) continue
60
+
61
+ const gap = text.slice(comment.range[1], start)
62
+
63
+ if (/^\s*$/.test(gap) && (gap.match(/\n/g) ?? []).length <= 1) return true
64
+ }
65
+
66
+ return false
67
+ }
68
+
69
+ function inScope(exported: boolean, screaming: boolean): boolean {
70
+ if (scope === "exported") return exported
71
+
72
+ if (scope === "screaming") return screaming
73
+
74
+ return exported || screaming
75
+ }
76
+
77
+ return {
78
+ VariableDeclaration(node) {
79
+ if (node.kind !== "const") return
80
+
81
+ const exported = node.parent?.type === "ExportNamedDeclaration"
82
+ const container = exported ? node.parent! : node
83
+
84
+ // Module level only: the declaration (or its export wrapper) sits directly in Program.
85
+ if (container.parent?.type !== "Program") return
86
+
87
+ if (hasJSDocBefore(container.range[0])) return
88
+
89
+ for (const declarator of node.declarations ?? []) {
90
+ const id = declarator.id
91
+
92
+ if (id?.type !== "Identifier" || !id.name) continue
93
+
94
+ if (ignoreNames.has(id.name)) continue
95
+
96
+ const initializer = declarator.init
97
+
98
+ if (initializer && FUNCTION_INITIALIZERS.has(initializer.type)) continue
99
+
100
+ if (!inScope(exported, SCREAMING_CASE.test(id.name))) continue
101
+
102
+ context.report({
103
+ node: declarator,
104
+ message:
105
+ `\`${id.name}\` is ${exported ? "exported" : "a named constant"} but undocumented — add a ` +
106
+ `JSDoc block saying what the value means and where it came from.`,
107
+ })
108
+ }
109
+ },
110
+ }
111
+ },
112
+ }
113
+
114
+ export default requireConstantDocRule
@@ -67,7 +67,7 @@ export const headerRule: Rule = {
67
67
  const existing = parseInnerLines(leading)
68
68
  const missing = headerLines.filter((line) => !existing.includes(line))
69
69
 
70
- if (missing.length === 0) return
70
+ if (!missing.length) return
71
71
 
72
72
  const preserved = existing.filter((line) => !HEADER_TAG_PATTERN.test(line))
73
73
  const block = buildBlock(preserved)
package/src/index.ts CHANGED
@@ -12,6 +12,76 @@ export * from "./restrictions.js"
12
12
  /** An oxlint configuration object, as consumed by `oxlint.config.ts` / `.oxlintrc.json`. */
13
13
  export type OxlintConfig = Record<string, unknown>
14
14
 
15
+ /** Numeric ceilings for the legibility-guardrail rules. Each is a hard ceiling, not a target. */
16
+ export interface OxlintConfigLimits {
17
+ /** Maximum block nesting depth. */
18
+ maxDepth: number
19
+ /** Maximum parameters on a single function. */
20
+ maxParams: number
21
+ /** Maximum statements in a single function body. */
22
+ maxStatements: number
23
+ /** Maximum lines in a single function body, blank lines and comments excluded. */
24
+ maxLinesPerFunction: number
25
+ /** Maximum lines in a single file, blank lines and comments excluded. */
26
+ maxLines: number
27
+ /** Maximum nested callback depth. */
28
+ maxNestedCallbacks: number
29
+ /** Maximum nested call-expression depth, e.g. `a(b(c(d())))`. */
30
+ maxNestedCalls: number
31
+ /** Maximum cyclomatic complexity of a single function. */
32
+ complexity: number
33
+ }
34
+
35
+ /**
36
+ * Calibrated against the mailwoman corpus (1,048 non-test source files). Each value sits just past the knee in that
37
+ * repo's distribution, so considered human code stays silent and runaway generation does not. See the design spec for
38
+ * the full sweep.
39
+ *
40
+ * The four SIZE ceilings are deliberately looser than the knee. Adopting v10 surfaced 172 pre-existing violations, and
41
+ * splitting that many functions across a parser — with no accuracy gate available to verify the result — is a larger
42
+ * risk than the legibility it buys. They are set at the p90 of the measured overage instead, so the worst decile had to
43
+ * be fixed at adoption while the body was grandfathered:
44
+ *
45
+ * max-statements n=88 median= 73 p90= 115 max= 272
46
+ * complexity n=32 median= 49 p90= 84 max= 173
47
+ * max-lines-per-function n=26 median=283 p90= 829 max=1329
48
+ * max-params n=15 median= 7 p90= 8 max= 10
49
+ *
50
+ * RATCHET THESE DOWN as the grandfathered functions are split. They exist to stop new code drifting, and every step
51
+ * toward the knee makes them do more of that job.
52
+ */
53
+ export const DefaultLimits: OxlintConfigLimits = {
54
+ maxDepth: 5,
55
+ maxParams: 8,
56
+ maxStatements: 115,
57
+ maxLinesPerFunction: 830,
58
+ maxLines: 750,
59
+ maxNestedCallbacks: 4,
60
+ maxNestedCalls: 4,
61
+ complexity: 85,
62
+ }
63
+
64
+ /** Globs treated as test files, where the size and named-constant rules are switched off. */
65
+ export const DefaultTestFilePatterns = [
66
+ "**/*.test.ts",
67
+ "**/*.test.tsx",
68
+ "**/test/**",
69
+ "**/fixtures/**",
70
+ "**/*.bench.ts",
71
+ // Storybook stories are fixtures in the same sense: each export is a rendered case, and its NAME is
72
+ // the label shown in the sidebar. A JSDoc block on `export const Default: Story = {}` says nothing
73
+ // the name does not.
74
+ "**/*.stories.ts",
75
+ "**/*.stories.tsx",
76
+ ]
77
+
78
+ /**
79
+ * Globs whose contents are emitted by a generator, not written by hand. The size ceilings are meaningless there — the
80
+ * file is as long as its input is wide, and no reviewer reads it top to bottom — but every correctness rule still
81
+ * applies, because generated code ships.
82
+ */
83
+ export const DefaultGeneratedFilePatterns = ["**/*.gen.ts", "**/*.gen.tsx", "**/*.generated.ts", "**/generated/**"]
84
+
15
85
  /** Options for {@link createOxlintConfig}. */
16
86
  export interface OxlintConfigOptions {
17
87
  /** The package namespace whose runtime boundaries are enforced, e.g. `@sister.software`. */
@@ -35,13 +105,31 @@ export interface OxlintConfigOptions {
35
105
  * through blessed helpers, which disable `sister-software/no-process-globals`.
36
106
  */
37
107
  restrictProcessGlobals?: boolean
108
+ /**
109
+ * Flag numeric literals used as comparison thresholds (off by default). Pass an object to change the ignore list or
110
+ * to stop exempting radix-prefixed literals.
111
+ */
112
+ unnamedThresholds?: boolean | { ignore?: number[]; allowHex?: boolean }
113
+ /**
114
+ * Require a JSDoc block on module-level constants (off by default). `scope` selects which ones: `"exported"`,
115
+ * `"screaming"`, or the default `"exported-or-screaming"`.
116
+ */
117
+ constantDocs?: boolean | { scope?: "exported" | "screaming" | "exported-or-screaming" }
118
+ /** Rewrite explicit length comparisons to truthiness in boolean positions (on by default). */
119
+ lengthTruthiness?: boolean
120
+ /** Override individual legibility ceilings. Unspecified keys keep their calibrated default. */
121
+ limits?: Partial<OxlintConfigLimits>
122
+ /** Replace the globs treated as test files. */
123
+ testFilePatterns?: string[]
124
+ /** Replace the globs treated as generated files, where only the size ceilings are switched off. */
125
+ generatedFilePatterns?: string[]
38
126
  /** Override the default ignore patterns. */
39
127
  ignorePatterns?: string[]
40
128
  /** Extra config deep-merged last; an escape hatch for per-repo tweaks. */
41
129
  overrides?: OxlintConfig
42
130
  }
43
131
 
44
- /** Default ignore patterns for generated/build output. */
132
+ /** Default ignore patterns for generated/build output and vendored tooling. */
45
133
  export const DefaultIgnorePatterns = [
46
134
  "**/out",
47
135
  "**/dist",
@@ -49,6 +137,9 @@ export const DefaultIgnorePatterns = [
49
137
  "**/node_modules",
50
138
  "**/coverage",
51
139
  "**/storybook-static",
140
+ // Yarn 4 vendors its own release bundle and plugin code here. It is third-party, minified, and
141
+ // not ours to lint — Tier 2's `no-abusive-eslint-disable` fires on it otherwise.
142
+ "**/.yarn/**",
52
143
  ]
53
144
 
54
145
  /**
@@ -76,18 +167,25 @@ export function createOxlintConfig(options: OxlintConfigOptions = {}): OxlintCon
76
167
  padding = true,
77
168
  braces = true,
78
169
  restrictProcessGlobals = false,
170
+ unnamedThresholds = false,
171
+ constantDocs = false,
172
+ lengthTruthiness = true,
173
+ limits: limitOverrides = {},
174
+ testFilePatterns = DefaultTestFilePatterns,
175
+ generatedFilePatterns = DefaultGeneratedFilePatterns,
79
176
  ignorePatterns = DefaultIgnorePatterns,
80
177
  overrides = {},
81
178
  } = options
82
179
 
83
- const plugins = ["typescript", "unicorn", "oxc", ...(react ? ["react"] : [])]
180
+ const limits: OxlintConfigLimits = { ...DefaultLimits, ...limitOverrides }
181
+
182
+ const plugins = ["typescript", "unicorn", "oxc", "import", "promise", "vitest", ...(react ? ["react"] : [])]
84
183
 
85
184
  const rules: Record<string, unknown> = {
86
185
  // JavaScript
87
186
  eqeqeq: ["error", "always", { null: "ignore" }],
88
187
  "prefer-const": "warn",
89
188
  "object-shorthand": ["warn", "always"],
90
- "no-shadow": "off",
91
189
  "no-undef": "off",
92
190
  "no-unused-vars": [
93
191
  "warn",
@@ -113,6 +211,191 @@ export function createOxlintConfig(options: OxlintConfigOptions = {}): OxlintCon
113
211
  "typescript/no-non-null-assertion": "off",
114
212
  "typescript/no-var-requires": "off",
115
213
  "typescript/no-require-imports": "off",
214
+
215
+ // Tier 1 — legibility guardrails. Thresholds are ceilings past the knee of the calibration
216
+ // corpus's distribution: they stay silent on considered code and fire on runaway generation.
217
+ "max-depth": ["error", { max: limits.maxDepth }],
218
+ "max-params": ["error", { max: limits.maxParams }],
219
+ "max-statements": ["error", { max: limits.maxStatements }],
220
+ "max-lines-per-function": ["error", { max: limits.maxLinesPerFunction, skipBlankLines: true, skipComments: true }],
221
+ "max-lines": ["error", { max: limits.maxLines, skipBlankLines: true, skipComments: true }],
222
+ "max-nested-callbacks": ["error", { max: limits.maxNestedCallbacks }],
223
+ "unicorn/max-nested-calls": ["error", { max: limits.maxNestedCalls }],
224
+ complexity: ["error", limits.complexity],
225
+ "unicorn/no-array-reduce": "error",
226
+ "unicorn/no-unreadable-array-destructuring": "error",
227
+
228
+ // Tier 2 — defect classes that `correctness` does not cover. Every rule here corresponds to a
229
+ // way working-looking code is wrong at runtime.
230
+ "no-shadow": "error",
231
+ "no-promise-executor-return": "error",
232
+ "no-useless-assignment": "error",
233
+ "no-unreachable-loop": "error",
234
+ "no-unmodified-loop-condition": "error",
235
+ "no-loop-func": "error",
236
+ // `.sort()` and `.reverse()` mutate in place; on a shared or cached array that is a bug at a
237
+ // distance. The fixes are `toSorted()` / `toReversed()`.
238
+ "unicorn/no-array-sort": "error",
239
+ "unicorn/no-array-reverse": "error",
240
+ "unicorn/no-immediate-mutation": "error",
241
+ "unicorn/no-array-method-this-argument": "error",
242
+ "unicorn/no-typeof-undefined": "error",
243
+ "unicorn/no-useless-promise-resolve-reject": "error",
244
+ "unicorn/prefer-type-error": "error",
245
+ // Global `isNaN` coerces its argument; `Number.isNaN` does not.
246
+ "unicorn/prefer-number-properties": "error",
247
+ "unicorn/no-abusive-eslint-disable": "error",
248
+ // Off despite the name: it fires on `.map(x => ({ ...x, field }))`, which is O(n·k) overall and the
249
+ // ordinary way to add a field. The quadratic accumulation worth catching is `acc = { ...acc, x }`
250
+ // inside a reduce, which this does not distinguish. 14 sites, 14 false positives.
251
+ "oxc/no-map-spread": "off",
252
+ "oxc/bad-bitwise-operator": "error",
253
+ "oxc/branches-sharing-code": "error",
254
+ "typescript/no-dynamic-delete": "error",
255
+ "typescript/prefer-ts-expect-error": "error",
256
+ // A cycle here is not a style issue: it leaves bindings unevaluated at import time, which
257
+ // surfaces as a base class that is `undefined` at class-definition time.
258
+ "import/no-cycle": "error",
259
+ // `ignoreLastCallback` keeps the rule pointed at CHAINS, where a missing return silently feeds
260
+ // undefined to the next link. A terminal `.then(…)` doing side effects has nothing downstream
261
+ // to starve, and rewriting those adds a return whose value no one reads.
262
+ "promise/always-return": ["error", { ignoreLastCallback: true }],
263
+ // Off: it cannot see that a ternary settles exactly once. `cb((err) => (err ? reject(err) : resolve()))`
264
+ // is the standard way to bridge a node-style callback to a promise, and the rule flagged every
265
+ // instance of it on the calibration corpus — 6 sites, 6 false positives, no real double-settle.
266
+ "promise/no-multiple-resolved": "off",
267
+
268
+ // Tier 3 — test discipline. `expect-expect` is the one that matters most: it catches a test
269
+ // that runs, passes, and asserts nothing.
270
+ "vitest/expect-expect": "error",
271
+ // vitest's `expect(value, message)` takes an optional assertion message as a second argument —
272
+ // the rule's default of one would flag the API's own signature.
273
+ "vitest/valid-expect": ["error", { maxArgs: 2 }],
274
+ "vitest/valid-title": "error",
275
+ "vitest/valid-describe-callback": "error",
276
+ // Off: the dominant shape it flags is a parameterized assertion helper, where the conditional IS
277
+ // the contract — `expectProposal(out, { kind, body, minConfidence? })` asserts only what the
278
+ // caller specified. 33 sites on the calibration corpus, none a hidden never-running assertion.
279
+ "vitest/no-conditional-expect": "off",
280
+ "vitest/no-conditional-tests": "error",
281
+ "vitest/no-disabled-tests": "error",
282
+ "vitest/no-commented-out-tests": "error",
283
+ "vitest/no-alias-methods": "error",
284
+ "vitest/prefer-to-be": "error",
285
+ "vitest/prefer-to-have-length": "error",
286
+ "vitest/prefer-to-contain": "error",
287
+ "vitest/require-to-throw-message": "error",
288
+ // Playwright names its e2e specs `*.spec.ts`; vitest unit tests are `*.test.ts`. A repo running
289
+ // both has two legitimate conventions, so the rule is scoped to the vitest ones.
290
+ "vitest/consistent-test-filename": ["error", { allTestPattern: String.raw`.*\.test\.[tj]sx?$` }],
291
+ // Enabling a plugin also activates its `correctness`-category rules, so a rule this tier turned
292
+ // down must be switched off explicitly rather than merely left out of the list above.
293
+ "vitest/require-mock-type-parameters": "off",
294
+ "vitest/no-conditional-in-test": "off",
295
+
296
+ // Tier 4 — mechanical hygiene. All autofixable, none requiring judgment.
297
+ // Literal form.
298
+ "unicorn/numeric-separators-style": "error",
299
+ "unicorn/no-zero-fractions": "error",
300
+ "unicorn/text-encoding-identifier-case": "error",
301
+ "unicorn/escape-case": "error",
302
+ "unicorn/no-hex-escape": "error",
303
+ // Import discipline.
304
+ // `disallowTypeAnnotations: false` keeps the valuable half — a type-only import must be written
305
+ // `import type` — while allowing `typeof import("…")` in an annotation. That form is how a
306
+ // guarded dynamic import is typed: the module is optional and loaded at runtime, and the inline
307
+ // annotation is what says so. `import type` would erase to nothing and read as a hard dep.
308
+ "typescript/consistent-type-imports": ["error", { disallowTypeAnnotations: false }],
309
+ "typescript/no-import-type-side-effects": "error",
310
+ "unicorn/prefer-export-from": "error",
311
+ "import/no-duplicates": "error",
312
+ "import/first": "error",
313
+ "import/newline-after-import": "error",
314
+ // Modern API preference.
315
+ "unicorn/prefer-string-replace-all": "error",
316
+ // `caught` is permitted alongside `error`: when a catch sits inside a scope that already binds
317
+ // `error` (a React component's error state, say), no-shadow requires a different name and this
318
+ // rule would otherwise demand the shadowing one. The two rules are in direct conflict without it.
319
+ "unicorn/catch-error-name": ["error", { ignore: ["caught"] }],
320
+ "unicorn/prefer-at": "error",
321
+ "unicorn/prefer-global-this": "error",
322
+ "unicorn/consistent-existence-index-check": "error",
323
+ "unicorn/new-for-builtins": "error",
324
+ "unicorn/prefer-array-find": "error",
325
+ "unicorn/prefer-structured-clone": "error",
326
+ "unicorn/prefer-negative-index": "error",
327
+ "unicorn/prefer-math-min-max": "error",
328
+ "unicorn/no-useless-collection-argument": "error",
329
+ "unicorn/throw-new-error": "error",
330
+ // Two rules that look mechanical but are not type-safe, so they stay off:
331
+ //
332
+ // `unicorn/prefer-code-point` rewrites `charCodeAt` to `codePointAt`, which returns
333
+ // `number | undefined`. It exists for surrogate-pair correctness, but on the ASCII arithmetic
334
+ // where it usually fires it buys nothing and forces an undefined branch at every site.
335
+ //
336
+ // `unicorn/no-useless-undefined` drops an explicitly-passed `undefined` argument. oxlint has no
337
+ // type information, so it cannot tell an optional parameter from a required one and will
338
+ // silently turn `f(a, undefined)` into a call that no longer type-checks.
339
+ //
340
+ // `unicorn/prefer-string-raw` rewrites a string literal to a String.raw template. That is
341
+ // runtime-identical but not type-identical: the literal type is lost, so any template-literal
342
+ // type built from the value collapses. It widens types silently, which is worse than the
343
+ // escaped backslashes it removes.
344
+ "unicorn/prefer-code-point": "off",
345
+ "unicorn/no-useless-undefined": "off",
346
+ "unicorn/prefer-string-raw": "off",
347
+ //
348
+ // `unicorn/prefer-math-trunc` is the one that is not merely type-unsafe but semantically wrong.
349
+ // `x | 0` and `x >>> 0` are int32/uint32 coercion, and the wrapping is the point — every site on
350
+ // the calibration corpus was a hash function, a PRNG, or a seeded evaluation harness.
351
+ // `Math.trunc` does not wrap, so taking its suggestion silently changes what those produce.
352
+ "unicorn/prefer-math-trunc": "off",
353
+ //
354
+ // `unicorn/prefer-number-coercion` rewrites `Number.parseInt(x, 10)` to
355
+ // `Math.trunc(Number(x))`. parseInt parses a numeric PREFIX; Number is strict, so
356
+ // `parseInt("12px", 10)` is 12 where `Number("12px")` is NaN. On the calibration corpus the
357
+ // inputs included CLI options and an HTTP status of uncertain type — exactly where the
358
+ // difference bites. It also reintroduces Math.trunc, disabled just above.
359
+ "unicorn/prefer-number-coercion": "off",
360
+ //
361
+ // These two are not unsafe — they are unsatisfiable. oxfmt reverts both fixes on its next run:
362
+ // it lowercases hex digits, and it strips the parentheses unicorn/no-nested-ternary adds. Lint
363
+ // and format are both CI gates, so a rule the formatter undoes can never go green. Neither
364
+ // behaviour is configurable in oxfmt today.
365
+ "unicorn/number-literal-case": "off",
366
+ "unicorn/no-nested-ternary": "off",
367
+ //
368
+ // `unicorn/explicit-length-check` enforces `x.length > 0`, the opposite of the house
369
+ // convention. `sister-software/prefer-length-truthiness` enforces ours.
370
+ "unicorn/explicit-length-check": "off",
371
+ //
372
+ // Core `no-duplicate-imports` is not TypeScript-aware: it counts a value import and an
373
+ // `import type` from the same module as a duplicate, which is the split
374
+ // `typescript/consistent-type-imports` exists to create. On the calibration corpus it reported
375
+ // 71 sites where the TS-aware `import/no-duplicates` reported 1, and that 1 was real.
376
+ "no-duplicate-imports": "off",
377
+
378
+ // TS style.
379
+ "typescript/consistent-type-definitions": "error",
380
+ "typescript/consistent-indexed-object-style": "error",
381
+ "typescript/prefer-for-of": "error",
382
+ "typescript/no-inferrable-types": "error",
383
+ // Small structural.
384
+ "unicorn/prefer-ternary": "error",
385
+ "unicorn/prefer-logical-operator-over-ternary": "error",
386
+ "unicorn/no-lonely-if": "error",
387
+ "unicorn/no-console-spaces": "error",
388
+ "unicorn/no-static-only-class": "error",
389
+ "no-useless-return": "error",
390
+ }
391
+
392
+ if (react) {
393
+ rules["react-hooks/rules-of-hooks"] = "error"
394
+ rules["react/no-unstable-nested-components"] = "error"
395
+ rules["react/no-object-type-as-default-prop"] = "error"
396
+ rules["react/jsx-no-constructed-context-values"] = "error"
397
+ // The automatic JSX runtime made this obsolete; the rule predates it.
398
+ rules["react/react-in-jsx-scope"] = "off"
116
399
  }
117
400
 
118
401
  if (headers) {
@@ -136,15 +419,68 @@ export function createOxlintConfig(options: OxlintConfigOptions = {}): OxlintCon
136
419
  rules["sister-software/no-process-globals"] = "error"
137
420
  }
138
421
 
422
+ if (unnamedThresholds) {
423
+ // Error severity: an unnamed threshold is a legibility defect, not a style preference.
424
+ rules["sister-software/no-unnamed-threshold"] = [
425
+ "error",
426
+ typeof unnamedThresholds === "object" ? unnamedThresholds : {},
427
+ ]
428
+ }
429
+
430
+ if (constantDocs) {
431
+ // Error severity: an undocumented public constant or tuning knob is a legibility defect.
432
+ rules["sister-software/require-constant-doc"] = ["error", typeof constantDocs === "object" ? constantDocs : {}]
433
+ }
434
+
435
+ if (lengthTruthiness) {
436
+ rules["sister-software/prefer-length-truthiness"] = "error"
437
+ }
438
+
439
+ // Rules switched off inside test files. Table-driven test bodies are legitimately long, and
440
+ // expected values are legitimately unnamed numbers. oxlint validates override entries against the
441
+ // registered rule set, so an entry may only name a rule this config actually turned on.
442
+ const testFileRules: Record<string, unknown> = {
443
+ "max-lines-per-function": "off",
444
+ "max-statements": "off",
445
+ "max-lines": "off",
446
+ }
447
+
448
+ if (react) {
449
+ // A Storybook `render` IS a component — React calls it as one — but it is not NAMED like one, so
450
+ // the hook rules read it as a plain function. Test files that render hooks go through a
451
+ // testing-library wrapper for the same reason.
452
+ testFileRules["react-hooks/rules-of-hooks"] = "off"
453
+ }
454
+
455
+ if (unnamedThresholds) {
456
+ testFileRules["sister-software/no-unnamed-threshold"] = "off"
457
+ }
458
+
459
+ if (constantDocs) {
460
+ testFileRules["sister-software/require-constant-doc"] = "off"
461
+ }
462
+
463
+ /** Generated files: size ceilings only. Everything else still applies — generated code ships. */
464
+ const generatedFileRules: Record<string, unknown> = {
465
+ "max-lines": "off",
466
+ "max-lines-per-function": "off",
467
+ "max-statements": "off",
468
+ complexity: "off",
469
+ }
470
+
139
471
  return {
140
472
  plugins,
141
- ...(headers || padding || braces || restrictProcessGlobals
473
+ ...(headers || padding || braces || restrictProcessGlobals || unnamedThresholds || constantDocs || lengthTruthiness
142
474
  ? { jsPlugins: ["@sister.software/oxlint-config/plugin"] }
143
475
  : {}),
144
476
  categories: { correctness: "error" },
145
477
  ignorePatterns,
146
478
  rules,
147
- overrides: createRuntimeOverrides(packageNamespace),
479
+ overrides: [
480
+ ...createRuntimeOverrides(packageNamespace),
481
+ { files: testFilePatterns, rules: testFileRules },
482
+ { files: generatedFilePatterns, rules: generatedFileRules },
483
+ ],
148
484
  ...overrides,
149
485
  }
150
486
  }