## Summary
- report a bare underscore parameter when no args ignore pattern is configured
- preserve the optimized underscore-prefix behavior for explicit argsIgnorePattern configuration
- update focused rule coverage and generated documentation
Closes#25626
The `-W, --warn` help text says "Deny the rule or category (emit a
warning)", copied from the `-D, --deny` entry.
With that wording, `--warn` and `--deny` look like they do the same
thing in `--help`, but `-W` reports a warning and `-D` reports an error.
Evidence why I think this is not intentional, and instead a typo:
- The other two entries start with their own flag's verb ("Allow the
rule...", "Deny the rule..."). Only the `-W` entry starts with its
sibling's verb, in a sentence identical to the `-D` one except the
parenthetical.
- These flags mirror rustc and clippy's `-A`/`-W`/`-D`, where warn and
deny are two different levels and deny specifically means "make it an
error".
- In this same file, `-W` maps to `AllowWarnDeny::Warn` and `-D` maps to
`AllowWarnDeny::Deny` (`into_tuple`). The internal name for what `-W`
does is warn, not deny.
What changed:
- The `-W` help text is now "Warn on the rule or category (emit a
warning)"
- Updated the two website_linter snapshots, so the generated CLI docs
page picks up the fix
I used Claude Code to help find and fix this.
Co-authored-by: Cameron <cameron.clark@hey.com>
The generated config reference only described the JSON schema's `string[]` form. Clarify that `oxlint.config.ts` accepts imported `OxlintConfig[]`, add an example and public JSDoc, and regenerate the derived artifacts.
Closes#20238.
Validated with the website generator suite, targeted TypeScript checks, and the relevant Oxlint tests. The full `just ready` run reached an unrelated parallel suppression-fixture cleanup race; the failing test passes in isolation.
AI-assisted.
Closes the migration tracked in #23043.
The new builder API (methods on the AST types themselves) has been in place for a while, all Oxc crates are migrated, and every crate which uses `AstBuilder` already enables `disable_old_builder` in its tests. The module docs said "after a few weeks, we will remove `AstBuilder`'s own methods entirely" — this does that.
## What's removed
- `crates/oxc_ast/src/generated/ast_builder.rs` and `builder/methods.rs` — `AstBuilder`'s own node and primitive constructors.
- `AstBuilderGenerator`, which produced the former.
- The `disable_old_builder` Cargo feature, plus the dev-dependency hacks it required across 33 manifests. Eight crates depended on `oxc_ast` *purely* to turn the feature on; those deps and their `cargo-shear` ignore entries are gone too.
## One thing worth reviewing
`AstBuilderGenerator` owned the `#[builder(default)]` / `#[builder(skip)]` attr registration that `BuilderMethodsGenerator` only *reads* — its doc comment said as much. Deleting it naively would have silently dropped `skip`/`default` handling for the 12 AST types using those attrs, so `attrs()` and `parse_attr()` move to `BuilderMethodsGenerator`.
The evidence this was faithful: after regenerating, `builder_methods.rs` and every other generated file are **byte-identical**. The only generated change in the diff is the deletion.
## Breaking changes
- `AstBuilder`'s own methods are gone. Use the AST type methods: `Statement::new_expression_statement(span, expr, &builder)` instead of `builder.statement_expression(span, expr)`.
- `AstBuilder` is no longer `Copy`/`Clone`, and its `allocator` field is private — use the `allocator` method from `GetAllocator`.
- `AstBuilder` and `NONE` are no longer re-exported from the crate root; import them from `oxc_ast::builder`. (The `disable_old_builder` feature already gated these off, so nothing in-tree used the root path.)
- The `disable_old_builder` feature no longer exists — remove it from any manifest that sets it.
A migration note covering all of the above is in `crates/oxc_ast/src/builder/mod.rs`.
---
Implemented with AI assistance (Claude Code); design, review, and verification driven by the author per the repo AI-usage policy.
## Summary
- auto-discover `oxfmt.config.mts` and `oxlint.config.mts`
- watch `.mts` configs in the language server and reject `.ts`/`.mts` conflicts
- add focused CLI and LSP coverage
Fixes#24310
Root config's `ignorePatterns` are now resolved relative to the directory containing the config file, instead of CWD.
Also fixes a latent issue: a `--config <path>` containing `..` silently disabled all `ignorePatterns` (the unnormalized parent dir never prefix-matches lint target paths). Explicit config paths are now lexically normalized, same as Oxfmt.
### Behavior change
The new anchoring applies in all cases, including `--config` / LSP `configPath`. This intentionally differs from ESLint, which resolves alternate-config ignore patterns relative to CWD:
- `extends`, `overrides[].files`, and JS plugin specifiers are already config-file-relative, including via `--config`
- rooting only `ignorePatterns` at CWD would give one file two coordinate systems
- Wrappers like Vite+ pass `--config <workspace-root config>` as an implementation detail while CWD stays at the invocation directory, so "`--config` means CWD-relative" does not hold in practice
- #24276
Impact should be small: bare names (`dist`, `*.min.js`) and `**/`-prefixed patterns are unaffected by the anchor.
The updated `config_ignore_patterns/with_oxlintrc` snapshot shows the `--config` case.
## Summary
This PR fixes two issues in `unicorn/prefer-array-find`:
- Oxlint reports a false positive for rest destructuring from
`filter()`, where `find()` is not an equivalent replacement.
- The rule docs are missing supported cases that the implementation
already detects, including array destructuring and `findLast()`-style
patterns. The upstream [eslint-plugin-unicorn
docs](https://github.com/sindresorhus/eslint-plugin-unicorn/blob/main/docs/rules/prefer-array-find.md)
cover these cases, so this brings Oxlint's docs closer to the equivalent
rule.
## False positive
On this line, Oxlint currently reports a false positive diagnostic from
`unicorn/prefer-array-find`:
```js
const [element, ...restElements] = myArray.filter(isEven);
```
That diagnostic is incorrect because the code keeps both the first
matching element and the remaining matching elements. Replacing it with
`find()` would drop `restElements`, so it is not an equivalent
transformation.
The fix is to only report array destructuring when there is no rest
element. This still reports valid cases like:
```js
const [element] = myArray.filter(isEven);
```
## Docs mismatch
Oxlint already detects more than the docs describe. In addition to
`filter(...)[0]` and `.shift()`, the implementation handles simple array
destructuring and last-match cases using `.at(-1)` or `.pop()`.
This PR updates the docs to include those supported patterns and their
`find()` / `findLast()` replacements, matching the upstream
[eslint-plugin-unicorn
docs](https://github.com/sindresorhus/eslint-plugin-unicorn/blob/main/docs/rules/prefer-array-find.md)
more closely for the cases Oxlint currently implements.
## Upstream parity mismatch still exists
[`eslint-plugin-unicorn`](https://github.com/sindresorhus/eslint-plugin-unicorn/blob/main/docs/rules/prefer-array-find.md)
also documents and detects `.filter(fn).slice(-1)[0]`-style last-element
patterns. Oxlint does not currently detect that pattern, so this PR does
not document it. That can be handled as a separate parity follow-up.
## Tests
- `cargo fmt -p oxc_linter`
- `cargo test -p oxc_linter prefer_array_find -- --nocapture`
- Manual local repro with `oxlint -D unicorn/prefer-array-find`
## AI assistance
AI assistance was used to prepare this change. I have reviewed the code
and remain responsible for the submission.
Co-authored-by: Cameron <cameron.clark@hey.com>
Part of #23043.
We don't enable `oxc_ast`'s `disable_old_builder` Cargo feature in any of our published crates, or crates that are depended on by published crates.
Tasks are private to the repo, so enable this feature on them, to ensure no usage of legacy `AstBuilder` methods sneak in during the transition period before we delete these methods.
This adds more short descriptions to rules that lacked them, completes
all the rules in the following plugins:
- Oxc
- JSDoc
- Vue
- Nextjs
- Promise
- jsx-a11y
About 200 rules still lack short descriptions, so I'll do those in
subsequent PRs to avoid needing review on any massive sets.
---------
Co-authored-by: Cameron <cameron.clark@hey.com>
It sounds stupid to create a snapshot from an already existing file.
Works on https://github.com/oxc-project/oxc/issues/22955 showed they will always match.
The PR changes the tests so it just checks if the existing file matches the output, without snapshots.
Adds verified config schema generation for
`typescript/no-restricted-types`.
Refs #22955.
Signed-off-by: Alexander S. <sysix@sysix-coding.de>
Co-authored-by: Alexander S. <sysix@sysix-coding.de>
This adds short description fields using a codemod script and then
reviewing the result (by myself and with Claude to find any
possibly-weird ones and flag them for me) and then cleaning up some
overly-long or weirdly-formatted rule short descriptions (by myself).
This _does not_ cover all rules, as the codemod was not built to handle
the shared jest/vitest rules or "What it does" sections that were any
more complex than a single sentence. But it handles >75% of them. I'll
work on the remainder in the next few days after we get this part
merged.
I've also made modifications/edits to various rule descriptions and
short descriptions when I noticed as part of this work that they were
overly verbose, inconsistent with the description format of most other
rules, or grammatically incorrect.
<details>
<summary>Codemod script for reference</summary>
```js
#!/usr/bin/env node
// Insert a `short_description = "..."` argument into the
// `declare_oxc_lint!` macro for every rule whose inline-doc "What it does"
// section is a single sentence.
//
// Notes:
// - Only handles rules with INLINE `///` doc comments. Shared-docs rules
// (vitest/jest rules pulling from rules/shared) are intentionally skipped.
// - Markdown links `[text](url)` are flattened to `text`.
// - The insertion goes right after `version = "...",` so `short_description`
// sits at the end of the macro arguments. Indentation mirrors `version`.
// - `declare_oxc_lint!` must accept the `short_description = "..."` argument
// (see crates/oxc_macros/src/declare_oxc_lint.rs); otherwise the generated
// code will not compile.
//
// Usage: node insert_short_descriptions.mjs # dry-run, prints stats + sample diffs
// node insert_short_descriptions.mjs --write # actually modify files
// node insert_short_descriptions.mjs --show <substring> # preview generated insertions
import { readFileSync, writeFileSync, readdirSync, statSync } from "node:fs";
import { join, relative } from "node:path";
const RULES_DIR = "~/code/oxc/crates/oxc_linter/src/rules";
const SHARED_DIR = join(RULES_DIR, "shared");
function walk(dir) {
const out = [];
for (const entry of readdirSync(dir)) {
const p = join(dir, entry);
const s = statSync(p);
if (s.isDirectory()) out.push(...walk(p));
else if (entry.endsWith(".rs")) out.push(p);
}
return out;
}
// --- extraction (inline-only) --------------------------------------------
function ruleDocLines(source) {
const out = [];
for (const line of source.split("\n")) {
const m = line.match(/^\s*\/\/\/ ?(.*)$/);
if (m) out.push(m[1]);
}
return out;
}
function extractWhatItDoesFromMarkdown(lines) {
let start = -1;
for (let i = 0; i < lines.length; i++) {
if (/^\s*###\s+What it does\s*$/i.test(lines[i])) {
start = i + 1;
break;
}
}
if (start === -1) return null;
const body = [];
for (let i = start; i < lines.length; i++) {
if (/^\s*###\s+/.test(lines[i])) break;
body.push(lines[i]);
}
while (body.length && body[0].trim() === "") body.shift();
while (body.length && body[body.length - 1].trim() === "") body.pop();
return body;
}
// A "What it does" body that contains a markdown list or a URL can't be
// faithfully reduced to a one-line sentence — flattening it produces a run-on
// string (the whole list inlined) or a description that leads with a raw URL.
// Skip these rather than emit garbage.
function hasListOrUrl(bodyLines) {
let inFence = false;
for (const line of bodyLines) {
if (/^\s*```/.test(line)) { inFence = !inFence; continue; }
if (inFence) continue;
if (/^\s*([-*+]|\d+\.)\s+/.test(line)) return true; // markdown list item
if (/https?:\/\//.test(line)) return true; // bare/inline URL
}
return false;
}
function countSentences(bodyLines) {
const kept = [];
let inFence = false;
for (const line of bodyLines) {
if (/^\s*```/.test(line)) { inFence = !inFence; continue; }
if (!inFence) kept.push(line);
}
let text = kept.join(" ").trim();
if (!text) return 0;
text = text.replace(/`[^`]*`/g, "X");
text = text.replace(/\b(e\.g|i\.e|etc|vs|cf|approx|Mr|Mrs|Ms|Dr|Jr|Sr)\./gi, "$1");
text = text.replace(/(\d)\.(\d)/g, "$1$2");
const matches = text.match(/[.!?]+(?=\s|$)/g);
return matches ? matches.length : 0;
}
// --- build short_description ---------------------------------------------
function bodyToShortDescription(bodyLines) {
const kept = [];
let inFence = false;
for (const line of bodyLines) {
if (/^\s*```/.test(line)) { inFence = !inFence; continue; }
if (!inFence) kept.push(line);
}
let text = kept.join(" ").replace(/\s+/g, " ").trim();
let prev;
do {
prev = text;
text = text.replace(/\[([^\]]*)\]\(([^)]*)\)/g, "$1");
} while (text !== prev);
return text;
}
function escapeForRustString(s) {
return s.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
}
// --- macro surgery -------------------------------------------------------
function findMacroSpan(source) {
const idx = source.indexOf("declare_oxc_lint!");
if (idx === -1) return null;
let i = idx + "declare_oxc_lint!".length;
while (i < source.length && /\s/.test(source[i])) i++;
if (source[i] !== "(") return null;
const open = i;
let depth = 1;
i++;
while (i < source.length && depth > 0) {
const c = source[i];
if (c === '"') {
i++;
while (i < source.length && source[i] !== '"') {
if (source[i] === "\\") i++;
i++;
}
} else if (c === "/" && source[i + 1] === "/") {
while (i < source.length && source[i] !== "\n") i++;
} else if (c === "(") {
depth++;
} else if (c === ")") {
depth--;
if (depth === 0) return { open, close: i };
}
i++;
}
return null;
}
function insertShortDescription(source, sentence) {
const span = findMacroSpan(source);
if (!span) return null;
const inner = source.slice(span.open + 1, span.close);
// Don't touch rules that already have the field.
if (/\bshort_description\s*=/.test(inner)) return source;
// Match the last `version = "..."` assignment. Last-match wins so we skip
// any stray mentions in doc comments. The trailing comma (if any) is
// detected separately below — folding `\s*,?` into this regex would let
// `\s*` swallow the following newline when there is no comma, misplacing the
// insertion point.
const versionRe = /\bversion\s*=\s*"(?:[^"\\]|\\.)*"/g;
let lastMatch = null;
let vm;
while ((vm = versionRe.exec(inner)) !== null) lastMatch = vm;
if (!lastMatch) return null;
const matchStartAbs = span.open + 1 + lastMatch.index;
let matchEndAbs = matchStartAbs + lastMatch[0].length;
// Consume an optional trailing comma that sits on the same line (after only
// horizontal whitespace), leaving any newline untouched.
let j = matchEndAbs;
while (source[j] === " " || source[j] === "\t") j++;
const hasTrailingComma = source[j] === ",";
if (hasTrailingComma) matchEndAbs = j + 1;
let lineStart = matchStartAbs;
while (lineStart > 0 && source[lineStart - 1] !== "\n") lineStart--;
const indent = source.slice(lineStart, matchStartAbs);
const isOnOwnLine = /^\s*$/.test(indent);
const escaped = escapeForRustString(sentence);
let insertion;
if (isOnOwnLine) {
const prefix = hasTrailingComma ? "" : ",";
insertion = `${prefix}\n${indent}short_description = "${escaped}",`;
} else {
const prefix = hasTrailingComma ? " " : ", ";
insertion = `${prefix}short_description = "${escaped}",`;
}
return source.slice(0, matchEndAbs) + insertion + source.slice(matchEndAbs);
}
// --- main ----------------------------------------------------------------
const args = process.argv.slice(2);
const flag = (n) => args.includes(n);
const showIdx = args.indexOf("--show");
const showNeedle = showIdx >= 0 ? args[showIdx + 1] : null;
const writeMode = flag("--write");
const files = walk(RULES_DIR).sort();
let processed = 0;
let skippedShared = 0;
let skippedMulti = 0;
let skippedListOrUrl = 0;
let skippedMissing = 0;
let skippedAlreadyHas = 0;
let skippedNoVersion = 0;
let changed = 0;
const samples = [];
for (const file of files) {
if (file.startsWith(SHARED_DIR + "/")) continue;
const src = readFileSync(file, "utf8");
if (!/declare_oxc_lint!/.test(src)) continue;
processed++;
const docLines = ruleDocLines(src);
const body = extractWhatItDoesFromMarkdown(docLines);
if (body === null) { skippedShared++; continue; }
if (body.length === 0) { skippedMissing++; continue; }
if (hasListOrUrl(body)) { skippedListOrUrl++; continue; }
const n = countSentences(body);
if (n !== 1) { skippedMulti++; continue; }
const sentence = bodyToShortDescription(body);
if (!sentence) { skippedMissing++; continue; }
if (/\bshort_description\s*=/.test(src)) { skippedAlreadyHas++; continue; }
const updated = insertShortDescription(src, sentence);
if (updated === null) { skippedNoVersion++; continue; }
if (updated === src) { skippedAlreadyHas++; continue; }
if (showNeedle && file.includes(showNeedle)) {
samples.push({ file, sentence, updated });
} else if (samples.length < 3 && !showNeedle) {
samples.push({ file, sentence, updated });
}
if (writeMode) writeFileSync(file, updated);
changed++;
}
console.log(`Rule files scanned: ${processed}`);
console.log(`Would insert short_description: ${changed}`);
console.log(`Skipped (shared-docs / no inline): ${skippedShared}`);
console.log(`Skipped (multi-sentence / none): ${skippedMulti}`);
console.log(`Skipped (list or URL body): ${skippedListOrUrl}`);
console.log(`Skipped (empty/missing body): ${skippedMissing}`);
console.log(`Skipped (already has field): ${skippedAlreadyHas}`);
console.log(`Skipped (no version= found): ${skippedNoVersion}`);
console.log(writeMode ? `\nWROTE ${changed} files.` : `\nDry run. Pass --write to apply.`);
if (samples.length) {
console.log("\nSamples:\n");
for (const s of samples) {
console.log(`--- ${relative(RULES_DIR, s.file)} ---`);
console.log(` short_description = "${escapeForRustString(s.sentence)}"`);
const span = findMacroSpan(s.updated);
if (span) {
const chunk = s.updated.slice(span.open - 20, span.close + 2);
console.log("");
for (const line of chunk.split("\n")) console.log(" " + line);
}
console.log("");
}
}
```
</details>
---------
Co-authored-by: Cameron Clark <cameron.clark@hey.com>
- Used `DefaultRuleConfig` to parse the `serde_json` value
- Moved the 25 cap, to the `Deserializer`
- added schema validation + deny unknown fields
- removed test cases where the upstream rule auto-generated options for every rule
issue #22955
There are three rules that use the same config and default value:
- `react/no-did-mount-set-state`
- `react/no-did-update-set-state`
- `react/no-will-update-set-state`
this PR also moves that config into a shared `SetStateInFunctionConfig`
type to avoid duplication, similar to the `AlwaysNever` enum
The upstream rule has an option we do not to support:
```
/** Whether to not error when running with a tsconfig that has strictNullChecks turned. */
allowRuleToRunWithoutStrictNullChecksIKnowWhatIAmDoing?: boolean;
```
https://typescript-eslint.io/rules/no-unnecessary-condition/
The upstream rules does support one more option, but I guess we do not want this:
```
/** Unless this is set to `true`, the rule will error on every file whose `tsconfig.json` does _not_ have the `strictNullChecks` compiler option (or `strict`) set to `true`. */
allowRuleToRunWithoutStrictNullChecksIKnowWhatIAmDoing?: boolean;
```
https://typescript-eslint.io/rules/prefer-nullish-coalescing
Another little difference: The upstream rule marks as `true | IgnorePrimitivesOptions`, we support the `false` value too.