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
9 changes: 9 additions & 0 deletions .changeset/library-rule-audit.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
"@stll/oxlint-config": major
"@stll/oxlint-plugin": patch
"@stll/test-kit": patch
---

`library()` enables more bug-shaped rules outside oxlint's default correctness category: `eqeqeq` (with `== null` allowed), `no-var`, `prefer-const`, `no-case-declarations`, `default-case-last`, `array-callback-return`, `no-self-compare`, `no-unneeded-ternary`, `no-useless-concat`, `no-useless-constructor`, `preserve-caught-error`, `no-implied-eval`, `no-new-func`, `no-extend-native`, `no-proto`, TypeScript `ban-ts-comment`, `no-require-imports`, `no-empty-object-type`, `no-unsafe-function-type`, `no-unnecessary-type-constraint`, `no-confusing-non-null-assertion`, `no-deprecated`, `no-unnecessary-type-assertion`, `no-unnecessary-template-expression`, `no-unnecessary-boolean-literal-compare`, `restrict-plus-operands`, `no-unsafe-enum-comparison`, `no-mixed-enums`, `prefer-promise-reject-errors`, `use-unknown-in-catch-callback-variable`, `prefer-includes`, `prefer-string-starts-ends-with` and `prefer-find`, unicorn `prefer-node-protocol`, `prefer-array-flat-map`, `no-instanceof-builtins`, `no-accessor-recursion` and `require-post-message-target-origin`, `oxc/misrefactored-assign-op`, import `no-duplicates`, `no-self-import`, `no-absolute-path` and `no-empty-named-blocks`, and `promise/no-multiple-resolved`. Files selected with `react.files` now also get React's correctness rules (keys, duplicate props, state mutation, removed APIs), except `exhaustive-deps` and `jsx-no-undef`, which the compiler rules and TypeScript already cover.

The test override now matches nested `__tests__/` and `__test__/` folders and `.mts`, `.cts`, `.mjs` and `.cjs` test files. Its globs were anchored at the config directory, so tests inside packages did not get it.
15 changes: 8 additions & 7 deletions oxlint.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,15 +25,10 @@ export default library({
overrides: [
{
// Oxlint presents plugin AST nodes as intentionally untyped values.
// Type-aware any-flow and condition rules cannot add safety inside the
// visitor implementation; the passive fixtures prove rule behavior.
// Type-aware condition rules cannot add safety inside the visitor
// implementation; the passive fixtures prove rule behavior.
files: ["packages/oxlint-plugin/src/**/*.ts"],
rules: {
"typescript/no-unsafe-assignment": "off",
"typescript/no-unsafe-member-access": "off",
"typescript/no-unsafe-call": "off",
"typescript/no-unsafe-return": "off",
"typescript/no-unsafe-argument": "off",
"typescript/strict-boolean-expressions": "off",
"typescript/no-unnecessary-condition": "off",
},
Expand All @@ -52,6 +47,12 @@ export default library({
"typescript/no-confusing-void-expression": "off",
"unicorn/no-useless-spread": "off",
"no-sparse-arrays": "off",
// Fixtures spell out the import, URL and rejection shapes the
// test-reliability rules detect, including unidiomatic ones.
"unicorn/prefer-node-protocol": "off",
"import/no-duplicates": "off",
"typescript/no-unnecessary-template-expression": "off",
"typescript/prefer-promise-reject-errors": "off",
},
},
{
Expand Down
2 changes: 1 addition & 1 deletion packages/oxlint-config/src/cloud-setup-claude.ts
Original file line number Diff line number Diff line change
Expand Up @@ -257,7 +257,7 @@ const parseSettings = (text: string) => {
return settings;
} catch (error) {
if (error instanceof SyntaxError)
throw new Error("settings must contain valid JSON");
throw new Error("settings must contain valid JSON", { cause: error });
throw error;
}
};
Expand Down
1 change: 1 addition & 0 deletions packages/oxlint-config/src/cloud-setup-cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ const readClaudeSettings = async (destination: string) => {
} catch (error) {
throw new Error(
`cannot safely merge ${cloudClaudeSettingsPath}: ${error instanceof Error ? error.message : String(error)}; ${cloudSessionStartFix}`,
{ cause: error },
);
}
};
Expand Down
2 changes: 1 addition & 1 deletion packages/oxlint-config/src/cloud-setup-toolchains.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,8 @@ import {
cloudExtraTools,
isCloudPackageTool,
parseCloudRustNames,
type parseCloudSetup,
} from "./cloud-setup-schema";
import type { parseCloudSetup } from "./cloud-setup-schema";
import type { RustException } from "./toolchain-rust";

type ResolveCloudToolchainsOptions = {
Expand Down
111 changes: 88 additions & 23 deletions packages/oxlint-config/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,27 @@ export const libraryRules = {
"no-loop-func": "error",
"no-nested-ternary": "error",
"no-void": ["error", { allowAsStatement: true }],
// `== null` stays allowed as the one idiomatic nullish check.
eqeqeq: ["error", "always", { null: "ignore" }],
"no-var": "error",
"prefer-const": "error",
// Bug-shaped constructs outside oxlint's default correctness category.
// Switch fallthrough is left to `noFallthroughCasesInSwitch` in the shared
// tsconfig; oxlint's rule misreads commented empty cases.
"no-case-declarations": "error",
"default-case-last": "error",
"array-callback-return": "error",
"no-self-compare": "error",
"no-unneeded-ternary": "error",
"no-useless-concat": "error",
"no-useless-constructor": "error",
// Rethrowing a new error must keep the original as `cause`.
"preserve-caught-error": "error",
// String-evaluated code and prototype tampering.
"no-implied-eval": "error",
"no-new-func": "error",
"no-extend-native": "error",
"no-proto": "error",
// Callback expressions should stay lexically scoped; avoids accidental `this` rebinding.
"prefer-arrow-callback": "error",
"stella-lowercase/stella-lowercase": "error",
Expand Down Expand Up @@ -117,7 +138,28 @@ export const libraryRules = {
{ ignorePrimitives: { string: true, boolean: true } },
],
"typescript/only-throw-error": "error",
// Rejections follow the same Error-only contract as `throw`.
"typescript/prefer-promise-reject-errors": "error",
"typescript/use-unknown-in-catch-callback-variable": "error",
"typescript/return-await": ["error", "error-handling-correctness-only"],
// Bare `@ts-ignore`/`@ts-nocheck` hide type errors; `@ts-expect-error`
// needs a description and fails once the error it covers is gone.
"typescript/ban-ts-comment": "error",
"typescript/no-require-imports": "error",
"typescript/no-empty-object-type": "error",
"typescript/no-unsafe-function-type": "error",
"typescript/no-unnecessary-type-constraint": "error",
"typescript/no-confusing-non-null-assertion": "error",
"typescript/no-deprecated": "error",
"typescript/no-unnecessary-type-assertion": "error",
"typescript/no-unnecessary-template-expression": "error",
"typescript/no-unnecessary-boolean-literal-compare": "error",
"typescript/restrict-plus-operands": "error",
"typescript/no-unsafe-enum-comparison": "error",
"typescript/no-mixed-enums": "error",
"typescript/prefer-includes": "error",
"typescript/prefer-string-starts-ends-with": "error",
"typescript/prefer-find": "error",

"unicorn/no-useless-undefined": "off",
"unicorn/prefer-array-find": "error",
Expand All @@ -130,18 +172,35 @@ export const libraryRules = {
"unicorn/no-nested-ternary": "off",
// `Array.from({ length }).fill(obj)` shares one reference across every slot.
"unicorn/no-array-fill-with-reference-type": "error",
"unicorn/prefer-node-protocol": "error",
"unicorn/prefer-array-flat-map": "error",
// `instanceof Array`/`String` fails across realms and for primitives.
"unicorn/no-instanceof-builtins": "error",
"unicorn/no-accessor-recursion": "error",
"unicorn/require-post-message-target-origin": "error",

"oxc/misrefactored-assign-op": "error",

"import/no-cycle": "error",
"import/no-duplicates": "error",
"import/no-self-import": "error",
"import/no-absolute-path": "error",
"import/no-empty-named-blocks": "error",
"import/consistent-type-specifier-style": "off",
// Keep a blank line after the import block so it stays visually separate from module body.
"import/newline-after-import": "error",

"promise/always-return": "error",
"promise/no-return-in-finally": "error",
"promise/no-multiple-resolved": "error",
"promise/prefer-await-to-then": "off",
"promise/prefer-await-to-callbacks": "off",
"promise/avoid-new": "off",

// None of these are on in `library()` itself (oxlint enables only the
// correctness category). They stay listed because consumers spread
// `libraryRules` over broader presets such as Ultracite, where these
// opinionated rules are on and would otherwise apply.
"sort-keys": "off",
"no-plusplus": "off",
"no-inline-comments": "off",
Expand All @@ -163,36 +222,42 @@ export const libraryRules = {

// Applied only to the consumer-selected React files.
export const reactRules = {
// Enabling the `react` plugin turns on its whole correctness-category
// rule set at "warn" by default, not just the React Compiler categories.
// Only the compiler categories are deliberate additions here, so the rest
// are turned off to keep this package's blast radius scoped; lift any of
// these independently if a future change wants them.
// Enabling the `react` plugin turns on its whole correctness category.
// These two duplicate other checks: the compiler's dependency rules
// replace `exhaustive-deps`, and TypeScript reports undefined JSX names.
"react/exhaustive-deps": "off",
"react/forward-ref-uses-ref": "off",
"react/jsx-key": "off",
"react/jsx-no-duplicate-props": "off",
"react/jsx-no-undef": "off",
"react/jsx-props-no-spread-multi": "off",
"react/no-children-prop": "off",
"react/no-danger-with-children": "off",
"react/no-did-mount-set-state": "off",
"react/no-did-update-set-state": "off",
"react/no-direct-mutation-state": "off",
"react/no-find-dom-node": "off",
"react/no-is-mounted": "off",
"react/no-render-return-value": "off",
"react/no-string-refs": "off",
"react/no-this-in-sfc": "off",
"react/no-unsafe": "off",
"react/no-will-update-set-state": "off",
"react/void-dom-elements-no-children": "off",
// The rest of the category reports real bugs (missing keys, duplicate
// props, mutated state, removed React APIs), so it stays on as errors.
"react/forward-ref-uses-ref": "error",
"react/jsx-key": "error",
"react/jsx-no-duplicate-props": "error",
"react/jsx-props-no-spread-multi": "error",
"react/no-children-prop": "error",
"react/no-danger-with-children": "error",
"react/no-did-mount-set-state": "error",
"react/no-did-update-set-state": "error",
"react/no-direct-mutation-state": "error",
"react/no-find-dom-node": "error",
"react/no-is-mounted": "error",
"react/no-render-return-value": "error",
"react/no-string-refs": "error",
"react/no-this-in-sfc": "error",
"react/no-unsafe": "error",
"react/no-will-update-set-state": "error",
"react/void-dom-elements-no-children": "error",
...reactCompilerRules,
} satisfies Rules;

export const libraryOverrides = [
{
files: ["**/*.{test,spec}.{ts,tsx,js,jsx}", "__test__/**", "__tests__/**"],
// Override globs are anchored at the config directory, so test folders
// need a leading `**/` to match inside packages.
files: [
"**/*.{test,spec}.{ts,tsx,mts,cts,js,jsx,mjs,cjs}",
"**/__test__/**",
"**/__tests__/**",
],
rules: {
"no-console": "off",
"require-await": "off",
Expand Down
71 changes: 71 additions & 0 deletions packages/oxlint-config/src/library-overrides.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
/// <reference types="bun-types" />

import { expect, test } from "bun:test";
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { fileURLToPath } from "node:url";

import { library } from "./index";

const oxlintBin = fileURLToPath(
new URL("./bin/oxlint", import.meta.resolve("oxlint/package.json")),
);

test("test overrides reach nested test folders and every module extension", async () => {
const directory = await mkdtemp(join(tmpdir(), "library-overrides-"));
try {
const source = "export const size = (value?: string) => value!.length;\n";
const files = [
"packages/core/src/size.ts",
"packages/core/__tests__/size.ts",
"packages/core/__test__/size.ts",
"packages/core/src/size.test.mts",
"packages/core/src/size.spec.cts",
];
for (const file of files) {
await mkdir(join(directory, file, ".."), { recursive: true });
await writeFile(join(directory, file), source);
}
const config = library({
options: { typeAware: false, denyWarnings: false },
});
config.jsPlugins = [];
const rules = { ...config.rules };
delete rules["stella-lowercase/stella-lowercase"];
delete rules["no-raw-colors/no-raw-colors"];
config.rules = rules;
await writeFile(join(directory, "oxlint.json"), JSON.stringify(config));

const lint = Bun.spawn(
[oxlintBin, "-c", "oxlint.json", "--format", "json", "."],
{ cwd: directory, stdout: "pipe", stderr: "pipe" },
);
const [stdout, exitCode] = await Promise.all([
new Response(lint.stdout).text(),
lint.exited,
]);
const result: unknown = JSON.parse(stdout);
if (
typeof result !== "object" ||
result === null ||
!("diagnostics" in result) ||
!Array.isArray(result.diagnostics)
)
throw new Error(`Unexpected oxlint output: ${stdout}`);
const flagged: unknown[] = result.diagnostics.map((diagnostic: unknown) =>
typeof diagnostic === "object" &&
diagnostic !== null &&
"code" in diagnostic &&
"filename" in diagnostic
? `${String(diagnostic.code)} ${String(diagnostic.filename)}`
: diagnostic,
);
expect(flagged).toEqual([
"typescript(no-non-null-assertion) packages/core/src/size.ts",
]);
expect(exitCode).toBe(1);
} finally {
await rm(directory, { recursive: true, force: true });
}
}, 15_000);
2 changes: 1 addition & 1 deletion packages/oxlint-config/src/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ const isAstNode = (node: unknown): node is AstNode =>
typeof node === "object" &&
node !== null &&
"type" in node &&
typeof (node as { type: unknown }).type === "string";
typeof node.type === "string";

const isStringLiteral = (node: unknown): node is AstNode & { value: string } =>
isAstNode(node) &&
Expand Down
4 changes: 2 additions & 2 deletions packages/oxlint-config/src/publish-artifact-archive.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -151,8 +151,8 @@ test("malformed packed JavaScript retains its import parser cause and member pat
readPackedSyntaxFiles(archive),
(error: unknown) =>
error instanceof SyntaxError &&
/Packed runtime import syntax package\/dist\/chunks\/hidden\.mjs:/.test(
error.message,
error.message.includes(
"Packed runtime import syntax package/dist/chunks/hidden.mjs:",
) &&
error.cause instanceof SyntaxError,
);
Expand Down
2 changes: 2 additions & 0 deletions packages/oxlint-config/src/publish-artifact-syntax.ts
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,7 @@ export const assertPackedArtifactSyntax = ({
} catch (error) {
throw new Error(
`Packed JavaScript ${file} exceeds its declared syntax target: ${String(error)}`,
{ cause: error },
);
}
}
Expand Down Expand Up @@ -215,6 +216,7 @@ export const checkPackedJavaScriptWithNode = async ({
} catch (error) {
throw new Error(
`Packed JavaScript ${file} is incompatible with consumer Node ${node}: ${String(error)}`,
{ cause: error },
);
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -124,7 +124,7 @@ export const declarationOnlyDts = ({
async configResolved(config) {
if (
realpathSync(config.root) !== root ||
config.build.write === false ||
!config.build.write ||
config.build.watch
)
throw new Error(
Expand Down
2 changes: 1 addition & 1 deletion packages/oxlint-config/src/toolchain-changed.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1130,7 +1130,7 @@ const parseSnapshot = (snapshot: GitSnapshot): ParsedSnapshot => {
} else if (name === ".npmrc") {
for (const line of text.split(/\r?\n/)) {
if (!/^\s*(?:use-node-version|node-version)\s*=/i.test(line)) continue;
if (/\$\{/.test(line))
if (line.includes("${"))
throw new Error("Unclassifiable npm Node selector");
const selector =
/^\s*(?:use-node-version|node-version)\s*=\s*(.*?)\s*$/i.exec(
Expand Down
2 changes: 1 addition & 1 deletion packages/oxlint-config/src/toolchain-rust.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -562,7 +562,7 @@ test("Docker RUN and ENV instructions retain Rust pin enforcement", () => {
const instructions = [
(pin: string) => `RUN rustup default ${pin}`,
(pin: string) => `RUN --mount=type=cache,target=/cache cargo +${pin} test`,
(pin: string) => ["RUN rustup toolchain add " + "\\", pin].join("\n"),
(pin: string) => ["RUN rustup toolchain add \\", pin].join("\n"),
(pin: string) => `RUN ["rustup", "default", "${pin}"]`,
(pin: string) => `RUN ["sh", "-c", "rustup default ${pin}"]`,
(pin: string) => `RUN [ -f Cargo.toml ] && cargo +${pin} test`,
Expand Down
4 changes: 2 additions & 2 deletions packages/oxlint-config/src/toolchain-workflow-trust.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,15 +104,15 @@ const interpreterCommands = [
`${interpreter} --import=./scripts/register.mjs --test`,
`${interpreter} --loader=./scripts/loader.mjs --test`,
`${interpreter} --input-type=module`,
`${interpreter}`,
interpreter,
`${interpreter} --version --require=./scripts/register.cjs`,
]),
...["python", "python2", "python3", "python3.13"].flatMap((interpreter) => [
`${interpreter} -m unittest discover`,
`${interpreter} -m pytest`,
`${interpreter} -c 'import unittest; unittest.main(module=None)'`,
`${interpreter} -v`,
`${interpreter}`,
interpreter,
`${interpreter} -`,
`${interpreter} --version -m pytest`,
]),
Expand Down
1 change: 1 addition & 0 deletions packages/oxlint-config/src/typecheck-probe.ts
Original file line number Diff line number Diff line change
Expand Up @@ -201,6 +201,7 @@ export const runTypecheckProbe = async ({
controller.signal.throwIfAborted();
let onAbort: (() => void) | undefined;
const aborted = new Promise<never>((_done, reject) => {
// oxlint-disable-next-line typescript/prefer-promise-reject-errors -- the abort reason is forwarded as given
onAbort = () => reject(controller.signal.reason);
controller.signal.addEventListener("abort", onAbort, { once: true });
});
Expand Down
Loading
Loading