Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 21 additions & 10 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -91,20 +91,31 @@ Each file exports a function returning `TypedFlatConfigItem[]`. Key configs:

### Type Generation

Everything a generator writes lives in `src/generated/` (plus the two `.d.ts`
files that have to sit next to the code they augment, and
`src/cli/constants-generated.ts`). Nothing in `src/generated/` is hand-edited,
and both root lint configs ignore it.

`pnpm gen` runs eight generators in `scripts/`: `typegen.ts`
(`src/typegen.d.ts` - ESLint rule types and config names), `typegen-oxlint.ts`
(`src/oxlint/` equivalents), the two `typegen-defaults*.ts` (default rule
(`src/oxlint/typegen.d.ts`, plus `src/generated/oxlint-native.ts` and
`src/generated/oxlint-capabilities.ts` - the native and jsPlugin capability sets
the oxlint resolver reads), the two `typegen-defaults*.ts` (default rule
severities, used by the redundancy check), `versiongen.ts`
(`src/cli/constants-generated.ts`), `stylisticgen.ts`
(`src/rules/stylistic-generated.ts` - `@stylistic` rule names),
`typeawaregen.ts` (`src/rules/type-aware-generated.ts` - the
`requiresTypeChecking` snapshot the type-aware split reads) and
`gen-package-extensions.ts` (the `packageExtensions` block of
`pnpm-workspace.yaml`). Run it after modifying configs.

`typegen.ts` and `typeawaregen.ts` share one list of config modules
(`src/cli/constants-generated.ts`), `stylistic-gen.ts`
(`src/generated/stylistic.ts` - `@stylistic` rule names), `type-aware-gen.ts`
(`src/generated/type-aware.ts` - the `requiresTypeChecking` snapshot the
type-aware split reads) and `gen-package-extensions.ts` (the `packageExtensions`
block of `pnpm-workspace.yaml`). Run it after modifying configs.

`typegen-oxlint.ts` boots the real ESLint factory to learn which rules the
preset actually enables, so it must stay deterministic: pass explicit values for
anything the factory would otherwise sniff from the host (see `nodeMajor`), or
the committed output changes depending on who ran it.

`typegen.ts` and `type-aware-gen.ts` share one list of config modules
(`scripts/config-factories.ts`); add new modules there or they escape both
generators. Both `gen-package-extensions.ts` and `typeawaregen.ts` take
generators. Both `gen-package-extensions.ts` and `type-aware-gen.ts` take
`--check` (`nr check:extensions`, `nr check:type-aware`), which CI runs _before_
the build - `pnpm gen` would otherwise repair a stale file in place and the
drift would never be reported.
Expand Down
63 changes: 43 additions & 20 deletions docs/oxlint.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,8 +82,10 @@ Then run both linters:

### How rules are split

The split is driven by an explicit per-rule mapping (`oxlintRuleMapping`,
exported from `@isentinel/eslint-config/oxlint`):
The split is driven by one internal resolver backed by generated native and
jsPlugin capability metadata. `oxlintRuleMapping`, exported from
`@isentinel/eslint-config/oxlint`, remains as a generated compatibility and
audit view:

| Target | Meaning |
| --------------- | ----------------------------------------------------------- |
Expand All @@ -92,26 +94,47 @@ exported from `@isentinel/eslint-config/oxlint`):
| `js-plugin` | The original ESLint plugin runs inside oxlint as a jsPlugin |
| stays in ESLint | Not in the mapping; documented in `staysInEslint` |

A rule you name in `options.rules` is resolved against that table, then against
oxlint's own rule list:

1. **Mapped** — the target above decides the emitted name (`ts/no-shadow` →
`no-shadow`, `jest/valid-title` → `jest-js/valid-title`).
2. **Unmapped, but oxlint has it natively** — the native rule wins
(`no-inner-declarations`, `complexity`, `jsx-a11y/alt-text`). This is what
makes a `categories` rule overridable: categories only ever enable native
rules, so an entry routed to a jsPlugin instead would configure a different
rule and leave the native one on its defaults.
3. **Unmapped and not native** — the rule runs through its ESLint plugin as a
jsPlugin (`eslint-js`, `import-js`, ...).

The native rule list is generated from oxlint's own schema
(`src/rules/oxlint-native-generated.ts`, refreshed by `pnpm gen`). Fragments you
pass as extra arguments are **not** translated — their rule names reach oxlint
verbatim, which is the escape hatch when you want to name an oxlint rule
A rule you name in `options.rules` is resolved in this order: an alternate
native equivalent, an explicit ESLint-only exception, an implementation
preference, a generated native capability, then a safe generated jsPlugin
capability. Unknown custom rules retain the standalone fallback behavior.
Examples include `ts/no-shadow` → `no-shadow`, `jest/valid-title` →
`jest-js/valid-title`, and unmapped native rules such as `no-inner-declarations`
staying native. Preferring the native rule wherever one exists is what keeps a
`categories` entry overridable: categories only ever enable native rules, so an
entry routed to a jsPlugin instead would configure a different rule and leave
the native one on its defaults.

Jest and React prefer their jsPlugins; Vitest and Unicorn prefer native
implementations. `id-length`, `no-loop-func`, and `unicorn/no-lonely-if` are
exact jsPlugin preferences, the last because its JS implementation currently has
better autofix parity. Whenever a preferred jsPlugin also has a native
implementation, the generated config disables the native rule at the same scope
so categories cannot activate a duplicate. This suppression remains in
native-only mode even though the JS rule itself stays in ESLint.

The generated capability data in `src/generated/` is rule names only: which
rules oxlint implements natively (from `oxlint --rules`), which rules the
installed plugins expose, and which of each need type information. Everything
else a rule carries — categories, fix status, documentation links — lives in
`src/oxlint/typegen.d.ts` as JSDoc, where an editor can show it, rather than
shipping as runtime data. Oxlint names, plugin aliases and package specifiers
are derived from the rule name at lookup time. `pnpm gen` refreshes all of it,
so a dependency upgrade produces a reviewable diff of what changed hands.

A preset-only `"off"` entry does not load a jsPlugin merely because a capability
exists. It is still emitted when the rule is native: `categories` can only ever
switch native rules back on, so a rule the preset deliberately disables has to
reach the config as an explicit `"off"` or opting into a category would silently
revive it. Explicit user disables remain authoritative in both cases.

Fragments passed as extra arguments are **not** translated — their rule names
reach Oxlint verbatim, which is the escape hatch when naming an Oxlint rule
directly.

With `oxlint: true`, the ESLint factory drops every mapped rule; everything else
With `oxlint: true`, the ESLint factory drops every rule the resolver hands to
oxlint — not every rule in the exported view, which is a sample of the preset
and would leave a rule enabled in both engines if it missed one. Everything else
keeps running in ESLint, notably:

- **JSON / YAML / TOML / Markdown / package.json / pnpm rules** — oxlint only
Expand Down
2 changes: 1 addition & 1 deletion eslint.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { isentinel } from "./src/index.ts";
export default isentinel(
{
name: "project/options",
ignores: ["fixtures", "_fixtures", "**/*-generated.ts"],
ignores: ["fixtures", "_fixtures", "src/generated", "**/*-generated.ts"],
namedConfigs: true,
oxlint: "native",
pnpm: true,
Expand Down
2 changes: 1 addition & 1 deletion oxlint.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { isentinel } from "./src/oxlint/index.ts";
export default isentinel(
{
name: "project/options",
ignores: ["fixtures", "_fixtures", "**/*-generated.ts"],
ignores: ["fixtures", "_fixtures", "src/generated", "**/*-generated.ts"],
// Experiment: oxlint runs its native rules only; everything a jsPlugin
// would run stays in ESLint (`oxlint: "native"` there).
jsPlugins: false,
Expand Down
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -57,9 +57,9 @@
"build:inspector": "pnpm build && pnpm dlx @eslint/config-inspector build --config eslint-inspector.config.ts --out-dir ./.eslint-config-inspector",
"check:extensions": "node scripts/check-package-extensions.ts && node scripts/gen-package-extensions.ts --check",
"check:published": "node scripts/check-published-extensions.ts",
"check:type-aware": "node scripts/typeawaregen.ts --check",
"check:type-aware": "node scripts/type-aware-gen.ts --check",
"dev": "npx @eslint/config-inspector --config eslint-inspector.config.ts",
"gen": "node scripts/typegen.ts && node scripts/typegen-oxlint.ts && node scripts/typegen-defaults.ts && node scripts/typegen-defaults-oxlint.ts && node scripts/versiongen.ts && node scripts/stylisticgen.ts && node scripts/typeawaregen.ts && node scripts/gen-package-extensions.ts",
"gen": "node scripts/typegen.ts && node scripts/typegen-oxlint.ts && node scripts/typegen-defaults.ts && node scripts/typegen-defaults-oxlint.ts && node scripts/versiongen.ts && node scripts/stylistic-gen.ts && node scripts/type-aware-gen.ts && node scripts/gen-package-extensions.ts",
"postgen": "echo 'Generation complete!'",
"lint": "isentinel-lint",
"oxlint": "oxlint",
Expand Down
23 changes: 16 additions & 7 deletions scripts/audit-native-parity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,14 @@ import { fileURLToPath } from "node:url";

import { isentinel as eslintIsentinel } from "../src/eslint/index.ts";
import type { OptionsConfig } from "../src/eslint/types.ts";
import { effectivePresetRuleNames } from "../src/generated/oxlint-capabilities.ts";
import { isRecord } from "../src/guards.ts";
import { isentinel as oxlintIsentinel } from "../src/oxlint/index.ts";
import {
isTsCoreCounterpartRule,
oxlintRuleMapping,
resolveOxlintRule,
translateRuleToOxlint,
} from "../src/rules/oxlint-mapping.ts";
} from "../src/oxlint/routing.ts";

/**
* Factory options for the ESLint side; `ignores` is dropped to fit the factory
Expand Down Expand Up @@ -45,8 +46,8 @@ function isOxlintDiagnostic(value: unknown): value is OxlintDiagnostic {
}

// Empirically diff oxlint's native (Rust) rule implementations against the real
// ESLint plugin rule they replace in hybrid mode, for every rule mapped
// "native" in src/rules/oxlint-mapping.ts. Runs the full oxlint factory config
// ESLint plugin rule they replace in hybrid mode, for every effective preset
// rule the resolver owns natively. Runs the full oxlint factory config
// and the full ESLint factory config (type-aware parsing disabled, since native
// rules never need type information) over a corpus, then compares per-rule
// (file, line) hit sets. Identical hits are native-parity safe; divergent hits
Expand Down Expand Up @@ -125,9 +126,11 @@ function normalizeOxlintCode(code: string): string {
* @returns The sorted native rule names.
*/
function nativeRules(): Array<string> {
return Object.entries(oxlintRuleMapping)
.filter(([rule, target]) => target === "native" && !isTsCoreCounterpartRule(rule))
.map(([rule]) => rule)
return [...effectivePresetRuleNames]
.filter((rule) => {
const route = resolveOxlintRule(rule);
return route.kind === "native" && !route.typeAware && !isTsCoreCounterpartRule(rule);
})
.sort();
}

Expand Down Expand Up @@ -166,7 +169,13 @@ function runOxlint(
options: Parameters<typeof oxlintIsentinel>[0],
targets: Array<string>,
): Map<string, Array<Hit>> {
// The scratch directory holds no tsconfig, so a type-aware run would fail
// in tsgolint before producing a single diagnostic. The audit only covers
// rules that need no type information (see `nativeRules`), so turning it
// off costs no coverage — it mirrors the `typeAware: false` the ESLint side
// already uses.
const config = oxlintIsentinel(options);
config.options = { ...config.options, typeAware: false };
fs.writeFileSync(path.join(scratch, ".oxlintrc.json"), JSON.stringify(config, undefined, "\t"));

const result = spawnSync(
Expand Down
15 changes: 13 additions & 2 deletions scripts/config-factories.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,9 +45,20 @@ import type { Awaitable, TypedFlatConfigItem } from "../src/types.ts";
* Composed at module scope, not behind thunks: each generator consumes it once,
* immediately, and a thunk per entry buys nothing.
*/
/**
* Node major version every generator targets.
*
* `nodeMajor` otherwise defaults to the running Node version, which would make
* committed generated output depend on who ran `pnpm gen`. The ceiling keeps
* every version-gated rule in view, the safe direction for a list of rules the
* preset can name. Any generator that boots the factory must pass this, or its
* output will disagree with the configs below.
*/
export const GENERATOR_NODE_MAJOR = Number.MAX_SAFE_INTEGER;

export const PRESET_CONFIGS: Array<Awaitable<Array<TypedFlatConfigItem>>> = [
comments(),
e18e(),
e18e({ nodeMajor: GENERATOR_NODE_MAJOR }),
eslintPlugin(),
flawless(),
gitignore(),
Expand All @@ -70,7 +81,7 @@ export const PRESET_CONFIGS: Array<Awaitable<Array<TypedFlatConfigItem>>> = [
sonarjs({ isInEditor: false }),
spelling(),
stylistic(),
test({ jest: true, vitest: true }),
test({ jest: { extended: true }, vitest: true }),
toml(),
typescript({ erasableOnly: true }),
unicorn(),
Expand Down
4 changes: 2 additions & 2 deletions scripts/stylisticgen.ts → scripts/stylistic-gen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,6 @@ const plugin = await interopDefault(import("@stylistic/eslint-plugin"));
const names = Object.keys(plugin.rules).sort();

await fs.writeFile(
new URL("../src/rules/stylistic-generated.ts", import.meta.url),
`export const stylisticRuleNames: ReadonlySet<string> = new Set(${JSON.stringify(names, null, 2)});\n`,
new URL("../src/generated/stylistic.ts", import.meta.url),
`// Generated by scripts/stylistic-gen.ts — do not edit.\nexport const stylisticRuleNames: ReadonlySet<string> = new Set(${JSON.stringify(names, null, 2)});\n`,
);
5 changes: 3 additions & 2 deletions scripts/typeawaregen.ts → scripts/type-aware-gen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ import { scanTypeAwareRules } from "./type-aware-shared.ts";
* ship that rule into the pass that has no TypeScript program.
*/

const OUTPUT = new URL("../src/rules/type-aware-generated.ts", import.meta.url);
const OUTPUT = new URL("../src/generated/type-aware.ts", import.meta.url);

/**
* Renders the snapshot, or reports the drift when checking.
Expand All @@ -35,6 +35,7 @@ async function main(): Promise<number> {
const checking = process.argv.includes("--check");
const { prefixes, ruleIds } = await scanTypeAwareRules();
const desired = [
"// Generated by scripts/type-aware-gen.ts — do not edit.",
`export const typeAwarePrefixes: ReadonlySet<string> = new Set(${JSON.stringify(prefixes, null, 2)});`,
"",
`export const typeAwareRuleIds: ReadonlySet<string> = new Set(${JSON.stringify(ruleIds, null, 2)});`,
Expand All @@ -56,7 +57,7 @@ async function main(): Promise<number> {

if (checking) {
console.error(
"[type-aware] src/rules/type-aware-generated.ts is out of sync with the " +
"[type-aware] src/generated/type-aware.ts is out of sync with the " +
"installed plugins. Run `pnpm gen` and commit the result.",
);
return 1;
Expand Down
Loading