@aurelienbbn/oxlint-plugin-core

中文文档
0.4.1发布于 2026/10/14MITTypeScriptES Module构建来源已验证近 30 天下载 2361

Custom oxlint rules for TypeScript projects.

原始使用文档
文档目录9 节
  1. 🧯 A caught error is handled or explained
  2. 🧪 A test proves behavior, deterministically
  3. 📜 Public contracts are named
  4. 🧱 Workspace members meet only through their exports
  5. 💬 Comments describe code, not its history
  6. ↕️ Blocks, declaration runs and exits stand apart
  7. ⚠️ Migration
  8. Registered contract inventory
  9. Credited concepts

@aurelienbbn/oxlint-plugin-core

npm downloads CI license node

18 oxlint rules for any TypeScript codebase: swallowed errors, flaky or hollow tests, test code in production, anonymous public contracts, workspace members that leak into each other, cramped statements.

pnpm add -D @aurelienbbn/oxlint-plugin-core oxlint   # oxlint >=1.82.0 <2.0.0
{
  "jsPlugins": ["@aurelienbbn/oxlint-plugin-core"],
  "rules": {
    "core/no-discarded-caught-error": "error",
    "core/no-weak-test-assertions": ["error", { "assertionHelpers": ["assertValid"] }],
    "core/no-test-sleeps": "error"
  }
}

No preset, one kind of autofix. Enable each rule by name; every fix but the padding rules' blank lines needs a decision a tool can't make.

Rules by job, options, and the decision each fix needs
JobRules
🧯 errorsno-discarded-caught-error, no-error-message-matching
🧪 testsno-weak-test-assertions, no-test-sleeps, no-stubbed-subject, no-ambient-nondeterminism-in-tests, no-vitest-mocking, no-vitest-in-source, no-test-logic-in-production
📜 contractsno-exported-anonymous-object-return, no-multi-positional-parameters, no-mutable-exported-state, no-reexport-only-modules, no-let
🧱 workspaceworkspace-boundaries
💬 commentsno-dead-comments
↕️ layoutpadding-line-between-statements (autofix), padding-before-exit (autofix, deprecated)
RuleOptionDefault
no-discarded-caught-errorlogOnlyCallees["console", "logger", "log"]
reasonMarker"REASON"
includeTestFilesfalse (test files exempt)
no-ambient-nondeterminism-in-testsallowClockfalse; true waives clock reads only
no-weak-test-assertionsassertionHelpers[]
no-vitest-mockingforbidSpiesfalse (vi.fn, vi.spyOn allowed)
no-reexport-only-modulesallow["index.ts"]
no-multi-positional-parametersexemptFunctionNames, exemptFileBasenames[], []
padding-line-between-statementsESLint's { blankLine, prev, next } listthe configuration shown under ↕️ below
workspace-boundariesapps (member directories that are deployables)["apps/**"]
forbidden ({ from, to, message? } member directory pairs)[]
RuleThe decision its fix needs
no-ambient-nondeterminism-in-teststhe scenario's locale, frozen instant, or seed
no-dead-commentsa bare TODO: track it or delete it
no-discarded-caught-errorrethrow, branch on the error, or record a reason
no-error-message-matchingthe stable discriminator (class, tag, code) depends on the error source
no-exported-anonymous-object-returnthe schema/type name and where it's exported
no-letrestructure the value construction, not swap a keyword
no-multi-positional-parametersupdate callers, pick property names
no-mutable-exported-statea factory, an immutable contract, or a dependency boundary
no-reexport-only-modulesreroute every consumer to the owning module
no-stubbed-subjectwhich collaborator to replace instead
no-test-logic-in-productionan injected dependency or a public contract
no-test-sleepsa condition to poll or a fake-timer schedule
no-vitest-in-sourcethe right test utility or dependency seam
no-vitest-mockingthe dependency boundary and the fake
no-weak-test-assertionswhich observable outcome each test asserts
workspace-boundarieswhich member owns the shared code, and which subpath it exports

🧯 A caught error is handled or explained

try {
  await sync();
} catch (error) {
  console.error(error);
  return [];
} // ❌ only logged
promise.catch(() => null); // ❌ discarded
promise.catch(handleFailure); // ✅ handed to a function
try {
  access(path);
  return true;
} catch {
  // REASON: a missing file is the negative answer here
  return false; // ✅ reasoned
}

return err.message === "Not found"; // ❌ no-error-message-matching
return error instanceof TimeoutError; // ✅ class, tag, or code
no-discarded-caught-error and no-error-message-matching details

no-discarded-caught-error covers catch clauses, inline .catch(fn), and an inline second argument of .then, when the handler never throws (nor returns Promise.reject(...)) and the binding is absent or only reaches console.* / logger.* / log.*.

try {
  return await load(id);
} catch (error) {
  throw new LoadError({ id, cause: error });
} // ✅ rethrown
try {
  return await load();
} catch (error) {
  if (isNotFound(error)) return undefined;
  throw error;
} // ✅ inspected
CaseBehavior
counts as handlingpassing the error to any non-log function, returning it, testing it
escape hatchREASON: <≥ 3 words> inside the handler; promise callbacks also directly above the statement. Placeholders (todo, ignored, this is fine…) don't count
catch {} (literally empty)⏭️ left to eslint/no-empty
Effect.catch(...)⏭️ capitalized receivers are namespaces, skipped
schema builders with instance .catch(fn)⚠️ need a reason or a disable comment

no-error-message-matching: .message compared with a string literal, fed to includes / startsWith / endsWith / match / search / indexOf / RegExp#test (also after toLowerCase()-style calls), or used as a switch discriminant. String(error), error.toString(), `${error}` on a caught binding count too. Test files exempt. Error-shaped = catch binding, rejection-handler parameter, or a name like e, err, error, cause, reason, ex, exception, *Error, *Err, *Exception.

🧪 A test proves behavior, deterministically

no-weak-test-assertions judges per test: one strong assertion clears it.

❌ Weak✅ Strong
expect(load()).toBeDefined()expect(load()).toEqual({ id: 1 })
expect(() => run()).not.toThrow()expect(() => run()).toThrow("boom")
toHaveBeenCalledWith(expect.any(Object))toHaveBeenCalledWith(expect.any(String), 30)
await new Promise((r) => setTimeout(r, 500)); // ❌ no-test-sleeps
await vi.waitFor(() => expect(state()).toBe("done")); // ✅ poll the condition

// cart.test.ts
vi.spyOn(cart, "total").mockReturnValue(30); // ❌ no-stubbed-subject
vi.spyOn(pricing, "rate").mockReturnValue(2); // ✅ another module

export const send = (mail) => (process.env.NODE_ENV === "test" ? undefined : deliver(mail)); // ❌ no-test-logic-in-production

Projects using Vitest in-source testing turn no-test-logic-in-production off for those files.

Every weak shape, sleep shape, nondeterminism source, and test-only marker

Weak assertions

❌ Weak✅ Strong
.toBeTruthy()an exact value
expect(true).toBe(true) (literal vs literal)expect(3).toBe(add(1, 2)) (literal vs computed)
expect(result).toEqual(result) (value vs itself)expect(render()).toMatchSnapshot()
expect(fn()).toBe(5) after fn.mockReturnValue(5)fc.assert(fc.property(…))
toEqual(expect.anything())toEqual({ id: expect.any(String), total: 30 })
bare toHaveBeenCalled() / toBeCalled().not.toHaveBeenCalled() ("never called" is exact)
toBeInstanceOf(Object)toEqual(expect.any(Order))
expect(typeof x).toBe("string")

Wildcards = expect.anything(), expect.any(Object | Function | Array | String | Number | Boolean), expect.objectContaining({}), expect.arrayContaining([]). Bare toThrow() isn't counted: vitest/require-to-throw-message owns it, pinned for test files by @aurelienbbn/oxlint-config.

Sleeps (*.test.* / *.spec.*)

await new Promise((r) => setTimeout(r, 500)); // ❌ timer-only executor
import { setTimeout as sleep } from "node:timers/promises"; // ❌ also `scheduler`
await sleep(250); // ❌ sleep / delay / wait / pause + numeric literal

Files calling vi.useFakeTimers() skip the first and last shape: their timers aren't wall-clock.

Ambient nondeterminism (concept: "Eradicating Non-Determinism in Tests", Martin Fowler)

❌ Fires onUnless
toLocaleString / toLocaleDateString / toLocaleTimeString without a locale—
localeCompare without its 2nd argument—
Intl.*Format constructed without a locale—
Math.random()—
new Date() (no arg), Date.now(), performance.now()file calls vi.useFakeTimers / vi.setSystemTime, or allowClock: true

Shadowed Date / Math / Intl stay silent.

Stubbed subject (<stem>.test.* / <stem>.spec.*)

import * as cart from "./cart"; // also ./cart.js, ../cart/index
vi.spyOn(cart, "total"); // ✅ pass-through spy

Namespace or default import of the same-stem relative module; canned behavior via mockReturnValue, mockResolvedValue, mockRejectedValue, mockImplementation (and …Once), chained or on the spy's const. Package imports stay silent.

Test logic in production (concept: "Test Logic in Production", G. Meszaros, xUnit Test Patterns). Silent in test files, Vitest config and setup files, /test-utils/, /testing/ (the paths no-vitest-in-source allows).

export const send = (mail) => (process.env.NODE_ENV === "test" ? undefined : deliver(mail)); // ❌ also import.meta.env.MODE === "test"
export const retries = process.env.VITEST ? 0 : 3; // ❌ also VITEST_WORKER_ID, JEST_WORKER_ID, import.meta.vitest
export const __testing__ = { parse }; // ❌ name marks it test-only
/** Visible only for unit tests. */
export const parse = (input) => input; // ❌ comment marks it test-only

Test-only names: __test__ / __tests__ / __testing__, _internal(s), internals, testOnly*, *ForTest / *ForTests / *ForTesting. Comments like "exported for testing" or "visible only for tests" directly above the export.

Vitest APIs: no-vitest-in-source reports importing vitest from non-test source; no-vitest-mocking reports module replacement via Vitest mock APIs, plus vi.fn / vi.spyOn with forbidSpies: true.

📜 Public contracts are named

export const getUser = () => ({ id: user.id }); // ❌ no-exported-anonymous-object-return
export const load: Load = () => ({ id: user.id }); // ✅ named contract

const loadUser = (userId: UserId, includePosts: boolean) => userId; // ❌ no-multi-positional-parameters
export function loadUser(input: { userId: UserId; includePosts: boolean }) {} // ✅ object input

export let currentUserId = undefined; // ❌ no-mutable-exported-state
export * from "./user.js"; // (whole file)                    // ❌ no-reexport-only-modules, unless listed in `allow`
let total = 0; // ❌ no-let (also `var`)

🧱 Workspace members meet only through their exports

workspace-boundaries reads the members of the nearest pnpm-workspace.yaml (or package.json workspaces) and judges every import, export … from, and import() between two members:

// packages/kernel/src/tick.ts
import { boot } from "@game/client"; // ❌ packages never import apps (members under `apps`)
import { arrow } from "../../ranger/src/arrow.ts"; // ❌ relative path into another member
import { body } from "@game/physics/src/body.ts"; // ❌ not opened by @game/physics `exports`
import { body } from "@game/physics"; // ✅ an exported subpath
{
  "core/workspace-boundaries": [
    "error",
    {
      "apps": ["apps/**", "infra/**"],
      "forbidden": [
        { "from": "packages/features/*", "to": "packages/features/*", "message": "features meet through the kernel" },
        { "from": "packages/kernel", "to": "packages/adapters/**" }
      ]
    }
  ]
}

A manifest without subpath exports opens only its root; a subpath mapped to null stays closed; * matches at least one character, as in Node. Type-only imports and re-exports are judged like value imports: a type dependency is still a dependency. require() and import x = require() are not read. Files outside every member (root configs, scripts) are never judged. Directory patterns: * within one segment, ** across segments. Members are read once per lint process: restart an editor's language server after adding one. Cycles stay with import/no-cycle, dead files with Knip.

💬 Comments describe code, not its history

// Updated to use the new pricing API    ❌ change narration (line comments)
// TODO: rename this contract            ❌ untracked
// TODO(#123): rename this contract      ✅ tracked (also CART-42 or a link)
/** Updated when the cache expires */    ✅ docblock describing behavior
Every no-dead-comments shape
❌ Fires✅ Stays silent
// NEW: / // Updated: / // Fixed: labels// New customers receive a welcome discount
// Previously this… / // Formerly: / // Was:// Previously seen cursors are skipped
// Added … as requested / … for the review// TODO CART-42: tighten the types or a link
line comments only: // Updated to… / // Changed from… / // Now returns… / // No longer supports…

Also closing-brace labels and placeholder scaffolding; narration counts only when anchored at the comment start.

↕️ Blocks, declaration runs and exits stand apart

padding-line-between-statements takes ESLint's options (a list of { blankLine, prev, next }, the last match wins) and its statement types. A comment block above a statement belongs to it: the blank line goes above the comments, and a comment on the line a statement ends on stays with that statement. Autofix inserts or removes the blank lines.

function start(runtime) {
  const config = load();
  // Planning runs need no config.
  if (runtime.planning) {
    return;
  }
  runtime.apply(config); // ❌ twice: above the comment, and below the block
}

function start(runtime) {
  const config = load();

  // Planning runs need no config.
  if (runtime.planning) {
    return;
  }

  runtime.apply(config); // ✅
}

Enabled without options, it applies this configuration; any options replace it whole:

{
  "core/padding-line-between-statements": [
    "error",
    { "blankLine": "always", "prev": "*", "next": ["block-like", "multiline-expression"] },
    { "blankLine": "always", "prev": ["block-like", "multiline-expression"], "next": "*" },
    { "blankLine": "always", "prev": ["const", "let"], "next": "*" },
    { "blankLine": "any", "prev": ["const", "let"], "next": ["const", "let"] },
    { "blankLine": "always", "prev": "*", "next": ["return", "throw"] }
  ]
}
OverlapStatus
padding-before-exit⚠️ deprecated: the default configuration covers it, and { "blankLine": "always", "prev": "*", "next": ["return", "throw"] } alone reproduces it. Turn it off when you enable padding-line-between-statements.
effect/padding-after-dependencies✅ complementary: it also separates yield* service dependencies from a const of logic below them, which a declaration run leaves free. Both rules insert the same single blank line.

⚠️ Migration

Now reportsExampleFix (no autofix)
aliased exportconst read = () => ({ id: 1 }); export { read as load };named return type, same as a direct export
aliased Vitestspec.each(rows)("exists", row => check(row).toBeDefined()) when spec / check alias test / expectassert the observable value, or register a real assertion helper
Contract: what each rule does and doesn't resolve
TopicContract
architectural policynamed exported object contracts and object-style parameters are policy, not proof of a bug
public-return analysis✅ direct exports, same-file export lists/aliases, const forwarding aliases, default identifiers, explicit overload signatures · ⏭️ nested helper returns stay private · ❌ external re-exports and package entrypoint traversal not resolved
callback arityexternally imposed callback arities exempt
no-letoptional: deliberate local mutation can be clearer and faster
mutable exportsforward exports handled without conflating local declarations
weak assertionsjudged per test; recognizes real expect chains and meaningful snapshot/property assertions; arbitrary matcher-named methods aren't proof
Vitest recognitionnamed aliases, namespace imports, unshadowed globals, each, conditional modifiers. Local shadows stay outside the Vitest rules. Fast-check assertions support imported aliases
assertionHelperstrusts calls with that local name: the helper's behavioral contract is on you. Cross-module wrapper inference unsupported

Registered contract inventory

Generated from package exports by pnpm catalog. Rule-specific options and limitations are described above and in the source tests.

Rule/checkTrigger or review scope
no-ambient-nondeterminism-in-testsDisallow test files from depending on the host locale, the real clock, or Math.random(): locale-sensitive formatting without a locale, new Date() / Date.now() / performance.now() without fake timers, and unseeded randomness.
no-dead-commentsDisallow dead comments: closing-brace labels, placeholder scaffolding, untracked TODOs, and comments narrating a change instead of the code.
no-discarded-caught-errorRequire a catch clause or inline promise rejection handler that never rethrows and ignores or only logs the error to carry a reasoned marker comment.
no-error-message-matchingDisallow branching on the text of an error message through string comparison, substring or regex matching, or a switch.
no-exported-anonymous-object-returnRequire named object return contracts for functions exported directly, by local alias, or as a default identifier.
no-letDisallow let and var declarations in favor of const and expression-oriented code.
no-multi-positional-parametersRequire object inputs instead of multiple positional parameters for functions.
no-mutable-exported-stateDisallow exporting mutable module state.
no-reexport-only-modulesDisallow modules that only re-export other modules, except allowed entrypoint barrels.
no-stubbed-subjectDisallow a <stem>.test file from replacing behavior of its own <stem> module through vi.spyOn(...) followed by a canned return value or implementation.
no-test-logic-in-productionDisallow production files from branching on a test runner or test mode and from exporting members named or commented as existing only for tests.
no-test-sleepsDisallow wall-clock sleeps in test files: setTimeout-backed promises, node:timers/promises sleeps, and awaited sleep helpers with a fixed duration.
no-vitest-in-sourceDisallow importing vitest from non-test source files.
no-vitest-mockingDisallow Vitest mocking APIs in favor of deterministic test doubles.
no-weak-test-assertionsDisallow individual tests whose only assertions check existence, a bare call, a typeof or Object instance, wildcard-only matcher arguments, a value against itself or a literal against a literal, or a local mock's own return value.
padding-before-exitRequire a blank line before a return or throw statement that follows another statement in the same block, switch case, or program body.
padding-line-between-statementsRequire or disallow blank lines between statements, with ESLint's options and a comment block counted as part of the statement below it; defaults to padding blocks, multiline expressions, declaration runs, and exits.
workspace-boundariesKeep workspace members apart: packages never import apps, members enter each other only through package.json exports and never by relative path, and configured member pairs never import each other.

Credited concepts

版本与发布记录

@aurelienbbn/oxlint-plugin-core 共发布 4 个版本,首次发布于 2026/09/25;过去 12 个月发布了 4 个版本。最新版本附带 npm provenance 构建来源证明,可追溯到公开的 CI 构建记录。

版本发布日期距今
0.4.1latest2026/10/1424 天前
0.3.02026/10/011 个月前
0.2.02026/09/251 个月前
0.1.02026/09/251 个月前

关于 @aurelienbbn/oxlint-plugin-core 的常见问题

@aurelienbbn/oxlint-plugin-core 是什么?

Custom oxlint rules for TypeScript projects.

如何安装 @aurelienbbn/oxlint-plugin-core?

在项目目录运行 npm install @aurelienbbn/oxlint-plugin-core,或使用 pnpm add / yarn add / bun add。它还需要宿主项目提供 peer 依赖:oxlint(>=1.82.0 <2.0.0)。npm 7 及以上与 pnpm 8 及以上默认会自动安装必需的 peer 依赖,Yarn 需要手动安装。

@aurelienbbn/oxlint-plugin-core 支持 TypeScript 吗?

支持。包内自带 TypeScript 类型声明(package.json 声明了 types 入口),安装后即可获得类型提示,无需再安装 @types 包。

@aurelienbbn/oxlint-plugin-core 支持 ESM 还是 CommonJS?

以 ES Module 形式发布,推荐使用 import 引入。在 CommonJS 项目中,Node.js 20.19+ / 22.12+ 可以直接 require() 不含顶层 await 的 ES Module,更早的版本需要改用动态 import()。

@aurelienbbn/oxlint-plugin-core 需要什么版本的 Node.js?

package.json 的 engines 字段要求 Node.js ^22.19.0 || ^24.11.0。版本不符时 npm 默认只给出警告,开启 engine-strict 后会拒绝安装。

@aurelienbbn/oxlint-plugin-core 可以商用吗?

@aurelienbbn/oxlint-plugin-core 使用 MIT 许可。宽松许可:允许商业使用、修改和再分发,通常只需在分发时保留原版权与许可声明。以上为概括,不构成法律意见,请以许可原文为准。

@aurelienbbn/oxlint-plugin-core 的下载量有多少?

根据 npm 官方下载统计,@aurelienbbn/oxlint-plugin-core 在 2026/10/07 – 2026/11/05 这 30 天内被下载 2,361 次,日均约 79 次。下载量会把 CI 构建、镜像同步和作为其他包依赖的安装一并计入,适合横向比较热度,不能直接等同于使用人数。

@aurelienbbn/oxlint-plugin-core 还在维护吗?

最新版本 0.4.1 发布于 2026/10/14(24 天前);至今共发布 4 个版本,其中过去 12 个月发布了 4 个;首次发布于 2026/09/25。发布频率只是参考信号:功能稳定的工具可能很少需要更新,建议结合源码仓库的 issue 和提交记录判断。