From 9d43674302f783b8ed75a8835545506e16c270c1 Mon Sep 17 00:00:00 2001
From: P-Theo
Date: Thu, 10 Sep 2026 20:22:20 +0300
Subject: [PATCH] chore: update vendored anti-slop to c44ef22
Adopt the upstream snapshot in full: readable-spacing, array/reduce performance rules, Effect plugin source (unregistered), and vendored eslint-stylistic spacing helpers.
Enable the four new rules at error and apply the readable-spacing autofix to project sources (blank-line-only changes).
No dependency changes; oxlint and @oxlint/plugins stay paired at 1.78.0.
---
.oxlintrc.json | 6 +-
build.mjs | 6 +
check-compatibility.mjs | 6 +
entrypoint.test.tsx | 12 +
tools/oxlint/anti-slop/UPSTREAM.md | 13 +
tools/oxlint/anti-slop/effect/index.ts | 21 +
.../rules/no-manual-effect-error-tag.ts | 52 +
.../effect/rules/no-manual-tag-comparison.ts | 45 +
.../rules/no-manual-tagged-construction.ts | 37 +
.../rules/no-service-constructor-imports.ts | 52 +
.../effect/rules/prefer-effect-match.ts | 54 ++
.../anti-slop/effect/shared/tagged-values.ts | 97 ++
tools/oxlint/anti-slop/index.ts | 6 +
.../anti-slop/rules/no-array-filter-map.ts | 28 +
.../rules/no-known-value-widening.ts | 198 +++-
.../anti-slop/rules/no-module-mocking.ts | 17 +-
.../anti-slop/rules/no-object-parameters.ts | 99 +-
.../rules/no-reduce-accumulator-copy.ts | 109 +++
.../anti-slop/rules/no-runtime-typeof.ts | 10 +
.../rules/no-shape-in-symbol-names.ts | 9 +-
.../anti-slop/rules/no-unknown-parameters.ts | 50 +-
.../anti-slop/rules/no-unknown-returns.ts | 93 +-
.../rules/no-unknown-type-aliases.ts | 70 +-
.../rules/no-unsafe-dictionary-type.ts | 24 +-
.../rules/require-readable-spacing.ts | 47 +
...quire-safety-comment-for-type-assertion.ts | 91 +-
tools/oxlint/anti-slop/shared/array-method.ts | 94 ++
.../anti-slop/shared/dictionary-types.ts | 167 ++--
.../anti-slop/shared/function-parameters.ts | 49 +
.../oxlint/anti-slop/shared/reflect-method.ts | 15 +-
tools/oxlint/anti-slop/shared/scope.ts | 15 +
.../anti-slop/shared/type-alias-resolution.ts | 250 +++++
.../anti-slop/vendor/eslint-stylistic/LICENSE | 22 +
.../vendor/eslint-stylistic/UPSTREAM.md | 28 +
.../eslint-stylistic/padding-line-ast.ts | 51 +
.../padding-line-between-statements.ts | 906 ++++++++++++++++++
.../padding-line-options.d.ts | 87 ++
tps.test.ts | 14 +
tps.tsx | 96 ++
39 files changed, 2703 insertions(+), 343 deletions(-)
create mode 100644 tools/oxlint/anti-slop/UPSTREAM.md
create mode 100644 tools/oxlint/anti-slop/effect/index.ts
create mode 100644 tools/oxlint/anti-slop/effect/rules/no-manual-effect-error-tag.ts
create mode 100644 tools/oxlint/anti-slop/effect/rules/no-manual-tag-comparison.ts
create mode 100644 tools/oxlint/anti-slop/effect/rules/no-manual-tagged-construction.ts
create mode 100644 tools/oxlint/anti-slop/effect/rules/no-service-constructor-imports.ts
create mode 100644 tools/oxlint/anti-slop/effect/rules/prefer-effect-match.ts
create mode 100644 tools/oxlint/anti-slop/effect/shared/tagged-values.ts
create mode 100644 tools/oxlint/anti-slop/rules/no-array-filter-map.ts
create mode 100644 tools/oxlint/anti-slop/rules/no-reduce-accumulator-copy.ts
create mode 100644 tools/oxlint/anti-slop/rules/require-readable-spacing.ts
create mode 100644 tools/oxlint/anti-slop/shared/array-method.ts
create mode 100644 tools/oxlint/anti-slop/shared/function-parameters.ts
create mode 100644 tools/oxlint/anti-slop/shared/scope.ts
create mode 100644 tools/oxlint/anti-slop/shared/type-alias-resolution.ts
create mode 100644 tools/oxlint/anti-slop/vendor/eslint-stylistic/LICENSE
create mode 100644 tools/oxlint/anti-slop/vendor/eslint-stylistic/UPSTREAM.md
create mode 100644 tools/oxlint/anti-slop/vendor/eslint-stylistic/padding-line-ast.ts
create mode 100644 tools/oxlint/anti-slop/vendor/eslint-stylistic/padding-line-between-statements.ts
create mode 100644 tools/oxlint/anti-slop/vendor/eslint-stylistic/padding-line-options.d.ts
diff --git a/.oxlintrc.json b/.oxlintrc.json
index 4a230c6..8ece158 100644
--- a/.oxlintrc.json
+++ b/.oxlintrc.json
@@ -19,11 +19,13 @@
{ "name": "anti-slop", "specifier": "./tools/oxlint/anti-slop/index.ts" }
],
"rules": {
+ "anti-slop/no-array-filter-map": "error",
"anti-slop/no-chained-type-assertions": "error",
"anti-slop/no-conditional-empty-object-spread": "error",
"anti-slop/no-known-value-widening": "error",
"anti-slop/no-module-mocking": "error",
"anti-slop/no-object-parameters": "error",
+ "anti-slop/no-reduce-accumulator-copy": "error",
"anti-slop/no-reflect-apply": "error",
"anti-slop/no-reflect-get": "error",
"anti-slop/no-runtime-typeof": "error",
@@ -33,6 +35,8 @@
"anti-slop/no-unknown-type-aliases": "error",
"anti-slop/no-unsafe-dictionary-type": "error",
"anti-slop/no-widen-then-assert": "error",
- "anti-slop/require-safety-comment-for-type-assertion": "error"
+ "anti-slop/require-readable-spacing": "error",
+ "anti-slop/require-safety-comment-for-type-assertion": "error",
+ "oxc/no-accumulating-spread": "error"
}
}
diff --git a/build.mjs b/build.mjs
index 0562720..4cbb734 100644
--- a/build.mjs
+++ b/build.mjs
@@ -22,11 +22,15 @@ import { dirname, join } from "node:path"
import { fileURLToPath } from "node:url"
const root = dirname(fileURLToPath(import.meta.url))
+
const source = join(root, "tps.tsx")
+
const outDir = join(root, "dist")
+
const out = join(outDir, "tui.js")
const code = await readFile(source, "utf8")
+
const result = await transformAsync(code, {
filename: source,
configFile: false,
@@ -40,5 +44,7 @@ if (!result?.code) throw new Error("babel produced no output")
const output = `${result.code}\n`
await mkdir(outDir, { recursive: true })
+
await writeFile(out, output, "utf8")
+
console.log(`built ${out} (${Buffer.byteLength(output)} bytes)`)
diff --git a/check-compatibility.mjs b/check-compatibility.mjs
index b269feb..9c126ed 100644
--- a/check-compatibility.mjs
+++ b/check-compatibility.mjs
@@ -3,19 +3,25 @@ import { readFileSync } from "node:fs"
import { resolve } from "node:path"
const packageJson = JSON.parse(readFileSync(new URL("./package.json", import.meta.url), "utf8"))
+
const packages = ["@opencode/cli", "@opencode/plugin", "@opencode/theme"]
+
const versions = packages.map((name) => packageJson.devDependencies[name])
+
const version = versions[0]
if (!/^0\.0\.0-beta-\d{5,6}$/.test(version)) {
throw new Error(`OpenCode 2 compatibility version has an unexpected format: ${version}`)
}
+
if (!versions.every((candidate) => candidate === version)) {
throw new Error(`OpenCode 2 packages must use one exact version: ${versions.join(", ")}`)
}
const executable = resolve("node_modules", ".bin", process.platform === "win32" ? "opencode2.cmd" : "opencode2")
+
const reported = execFileSync(executable, ["--version"], { encoding: "utf8" }).trim()
+
if (reported !== `opencode2 v${version}`) {
throw new Error(`Expected opencode2 v${version}, got ${reported}`)
}
diff --git a/entrypoint.test.tsx b/entrypoint.test.tsx
index 6d56669..5e2c3db 100644
--- a/entrypoint.test.tsx
+++ b/entrypoint.test.tsx
@@ -14,6 +14,7 @@ import type { Plugin } from "@opencode/plugin/tui"
import type { TpsOptionsInput } from "./tps.tsx"
const root = fileURLToPath(new URL(".", import.meta.url))
+
const distEntry = new URL("./dist/tui.js", import.meta.url).href
// 95 bytes / 4.75 bytes-per-token = 20 estimated tokens.
@@ -26,6 +27,7 @@ beforeAll(async () => {
// — so every run tests a fresh dist/tui.js built exactly as the package
// ships, even from a clean checkout and across watch-mode reruns.
const build = spawnSync("node", ["build.mjs"], { cwd: root, encoding: "utf8" })
+
if (build.status !== 0) throw new Error(`node build.mjs failed:\n${build.stderr || build.stdout}`)
plugin = (await import(distEntry)).default
})
@@ -81,6 +83,7 @@ function start(context: FakeContext): (() => void) | void {
// a production cast.
// oxlint-disable-next-line anti-slop/no-chained-type-assertions
const setup = plugin.setup as unknown as FakeSetup
+
return setup(context)
}
@@ -109,8 +112,10 @@ function createHarness(options: TpsOptionsInput = {}): Harness {
// without leaving a live interval behind.
const handle = realSetInterval(() => {}, 60_000)
realClearInterval(handle)
+
return handle
}
+
globalThis.clearInterval = () => {
flush = undefined
}
@@ -127,12 +132,14 @@ function createHarness(options: TpsOptionsInput = {}): Harness {
const list = handlers.get(type) ?? []
list.push(handler)
handlers.set(type, list)
+
return () => handlers.delete(type)
},
},
ui: {
slot: (claim: Claim) => {
claims.push(claim)
+
return () => {}
},
},
@@ -165,6 +172,7 @@ describe("built entrypoint", () => {
test("renders the live label through text, reasoning, and tool-input streaming", async () => {
const h = createHarness()
const app = await openApp(h, "ses_test")
+
try {
await app.renderOnce()
expect(app.captureCharFrame()).not.toContain("tok")
@@ -198,6 +206,7 @@ describe("built entrypoint", () => {
test("settles exactly and freezes after completion", async () => {
const h = createHarness()
const app = await openApp(h, "ses_test")
+
try {
const t0 = Date.now()
h.emit("session.execution.started", { sessionID: "ses_test" }, t0)
@@ -236,6 +245,7 @@ describe("built entrypoint", () => {
test("resets the figure when a new prompt starts", async () => {
const h = createHarness()
const app = await openApp(h, "ses_test")
+
try {
const t0 = Date.now()
h.emit("session.execution.started", { sessionID: "ses_test" }, t0)
@@ -269,6 +279,7 @@ describe("built entrypoint", () => {
test("keeps orchestrator and sub-agent sessions independent", async () => {
const h = createHarness()
+
const app = await testRender(
() => (
@@ -278,6 +289,7 @@ describe("built entrypoint", () => {
),
{ width: 60, height: 8 },
)
+
try {
const now = Date.now()
h.emit("session.execution.started", { sessionID: "ses_sub" }, now)
diff --git a/tools/oxlint/anti-slop/UPSTREAM.md b/tools/oxlint/anti-slop/UPSTREAM.md
new file mode 100644
index 0000000..b067abc
--- /dev/null
+++ b/tools/oxlint/anti-slop/UPSTREAM.md
@@ -0,0 +1,13 @@
+# Vendored anti-slop plugin
+
+Source: [dmmulroy/anti-slop](https://github.com/dmmulroy/anti-slop), path `skills/install-anti-slop/assets/anti-slop`.
+
+- Previous baseline: `e8100a10da49858cfa8d26883d170e9cc8281988`
+- Current baseline: `c44ef22ca116d0ba62a3ff663a0bd13a3f3fa40b`
+- Adoption: complete incoming snapshot; no local rule modifications.
+
+This directory matches the upstream snapshot above except for this file. Rule policy (enabled rules and severities) lives in the repository root `.oxlintrc.json`, not here.
+
+The Effect plugin source is copied but intentionally not registered: the repository has no direct `effect` dependency, so `anti-slop-effect` is absent from `jsPlugins`. The bundled `vendor/eslint-stylistic/` code carries its own provenance and license in `vendor/eslint-stylistic/UPSTREAM.md`.
+
+To update: stage a new upstream revision beside the live tree, diff against the current baseline, apply reviewed changes, and update this record.
diff --git a/tools/oxlint/anti-slop/effect/index.ts b/tools/oxlint/anti-slop/effect/index.ts
new file mode 100644
index 0000000..55eb13d
--- /dev/null
+++ b/tools/oxlint/anti-slop/effect/index.ts
@@ -0,0 +1,21 @@
+import { eslintCompatPlugin } from "@oxlint/plugins";
+
+import { noManualEffectErrorTagRule } from "./rules/no-manual-effect-error-tag.ts";
+import { noManualTagComparisonRule } from "./rules/no-manual-tag-comparison.ts";
+import { noManualTaggedConstructionRule } from "./rules/no-manual-tagged-construction.ts";
+import { noServiceConstructorImportsRule } from "./rules/no-service-constructor-imports.ts";
+import { preferEffectMatchRule } from "./rules/prefer-effect-match.ts";
+
+/** Opt-in Oxlint rules for Effect service and Layer architecture. */
+const antiSlopEffectPlugin = eslintCompatPlugin({
+ meta: { name: "anti-slop-effect" },
+ rules: {
+ "no-manual-effect-error-tag": noManualEffectErrorTagRule,
+ "no-manual-tag-comparison": noManualTagComparisonRule,
+ "no-manual-tagged-construction": noManualTaggedConstructionRule,
+ "no-service-constructor-imports": noServiceConstructorImportsRule,
+ "prefer-effect-match": preferEffectMatchRule,
+ },
+});
+
+export default antiSlopEffectPlugin;
diff --git a/tools/oxlint/anti-slop/effect/rules/no-manual-effect-error-tag.ts b/tools/oxlint/anti-slop/effect/rules/no-manual-effect-error-tag.ts
new file mode 100644
index 0000000..daaa109
--- /dev/null
+++ b/tools/oxlint/anti-slop/effect/rules/no-manual-effect-error-tag.ts
@@ -0,0 +1,52 @@
+import { defineRule } from "@oxlint/plugins";
+
+import {
+ isInsideBroadEffectHandler,
+ isReasonTagMember,
+ isTagMember,
+ tagMemberFromComparison,
+} from "../shared/tagged-values.ts";
+
+export const noManualEffectErrorTagRule = defineRule({
+ meta: {
+ type: "problem",
+ docs: {
+ description:
+ "Use Effect tagged error handlers instead of manually branching on `_tag` in a catch handler.",
+ },
+ messages: {
+ tag: "Use Effect.catchTag or Effect.catchTags instead of manually discriminating a tagged error in a broad Effect catch handler.",
+ reason:
+ "Use Effect.catchReason or Effect.catchReasons instead of manually discriminating a tagged `reason` in a broad Effect catch handler.",
+ },
+ },
+ createOnce(context) {
+ return {
+ BinaryExpression(node) {
+ const tagMember = tagMemberFromComparison(node);
+ if (
+ tagMember === undefined ||
+ !isInsideBroadEffectHandler(node)
+ ) {
+ return;
+ }
+ context.report({
+ node,
+ messageId: isReasonTagMember(tagMember) ? "reason" : "tag",
+ });
+ },
+ SwitchStatement(node) {
+ if (
+ !isTagMember(node.discriminant) ||
+ !isInsideBroadEffectHandler(node)
+ ) {
+ return;
+ }
+ context.report({
+ node,
+ messageId: isReasonTagMember(node.discriminant) ? "reason" : "tag",
+ });
+ },
+ };
+ },
+});
diff --git a/tools/oxlint/anti-slop/effect/rules/no-manual-tag-comparison.ts b/tools/oxlint/anti-slop/effect/rules/no-manual-tag-comparison.ts
new file mode 100644
index 0000000..868d1d2
--- /dev/null
+++ b/tools/oxlint/anti-slop/effect/rules/no-manual-tag-comparison.ts
@@ -0,0 +1,45 @@
+import { defineRule } from "@oxlint/plugins";
+
+import {
+ isInsideBroadEffectHandler,
+ isTagMember,
+ tagMemberFromComparison,
+} from "../shared/tagged-values.ts";
+
+export const noManualTagComparisonRule = defineRule({
+ meta: {
+ type: "problem",
+ docs: {
+ description:
+ "Use Effect Match or Predicate helpers instead of manually branching on `_tag`.",
+ },
+ messages: {
+ manualComparison:
+ "Use Match.tag/Match.tags for tagged-value branching, or Predicate.isTagged for a simple reusable predicate.",
+ manualSwitch:
+ "Use Match.value(value).pipe(Match.tag/Match.tags/Match.tagsExhaustive) or the tagged enum `$match` helper instead of switching on `_tag`.",
+ },
+ },
+ createOnce(context) {
+ return {
+ BinaryExpression(node) {
+ if (
+ tagMemberFromComparison(node) === undefined ||
+ isInsideBroadEffectHandler(node)
+ ) {
+ return;
+ }
+ context.report({ node, messageId: "manualComparison" });
+ },
+ SwitchStatement(node) {
+ if (
+ !isTagMember(node.discriminant) ||
+ isInsideBroadEffectHandler(node)
+ ) {
+ return;
+ }
+ context.report({ node, messageId: "manualSwitch" });
+ },
+ };
+ },
+});
diff --git a/tools/oxlint/anti-slop/effect/rules/no-manual-tagged-construction.ts b/tools/oxlint/anti-slop/effect/rules/no-manual-tagged-construction.ts
new file mode 100644
index 0000000..382f166
--- /dev/null
+++ b/tools/oxlint/anti-slop/effect/rules/no-manual-tagged-construction.ts
@@ -0,0 +1,37 @@
+import { defineRule } from "@oxlint/plugins";
+
+import {
+ isMatchPatternObject,
+ isStringLiteral,
+ propertyName,
+} from "../shared/tagged-values.ts";
+
+export const noManualTaggedConstructionRule = defineRule({
+ meta: {
+ type: "problem",
+ docs: {
+ description:
+ "Construct tagged values with their existing Effect constructor instead of writing `_tag` manually.",
+ },
+ messages: {
+ manualConstruction:
+ "Use the existing Schema tagged `.make`, tagged class/error constructor, or Data.taggedEnum variant constructor instead of writing a literal `_tag` object.",
+ },
+ },
+ createOnce(context) {
+ return {
+ ObjectExpression(node) {
+ if (isMatchPatternObject(node)) return;
+ const tag = node.properties.find(
+ (property) =>
+ property.type === "Property" &&
+ propertyName(property) === "_tag" &&
+ isStringLiteral(property.value),
+ );
+ if (tag !== undefined) {
+ context.report({ node: tag, messageId: "manualConstruction" });
+ }
+ },
+ };
+ },
+});
diff --git a/tools/oxlint/anti-slop/effect/rules/no-service-constructor-imports.ts b/tools/oxlint/anti-slop/effect/rules/no-service-constructor-imports.ts
new file mode 100644
index 0000000..55cefb7
--- /dev/null
+++ b/tools/oxlint/anti-slop/effect/rules/no-service-constructor-imports.ts
@@ -0,0 +1,52 @@
+import { defineRule } from "@oxlint/plugins";
+
+import type { ESTree } from "@oxlint/plugins";
+
+const SERVICE_CONSTRUCTOR_NAME = /^make[A-Z]/u;
+const TEST_FILE = /\.(?:test|spec)\.[cm]?[jt]sx?$/u;
+
+function isProjectLocalImport(source: string): boolean {
+ return source.startsWith("./") || source.startsWith("../");
+}
+
+function getImportedName(specifier: ESTree.ImportSpecifier): string {
+ if (specifier.imported.type === "Identifier") return specifier.imported.name;
+ return specifier.imported.value;
+}
+
+/** Keep dependency-bearing Effect service constructors local to their owning capability modules. */
+export const noServiceConstructorImportsRule = defineRule({
+ meta: {
+ type: "problem",
+ docs: {
+ description:
+ "Disallow project-local make imports outside test and spec files.",
+ },
+ messages: {
+ serviceConstructorImport:
+ 'Do not import Effect service constructor "{{name}}" into runtime code. Import the owning Layer, yield the contextual service, and allow its requirements to propagate to the composition root.',
+ },
+ },
+ create(context) {
+ const isTestFile = TEST_FILE.test(context.filename.replaceAll("\\", "/"));
+
+ return {
+ ImportDeclaration(node) {
+ if (isTestFile || !isProjectLocalImport(node.source.value)) return;
+
+ for (const specifier of node.specifiers) {
+ if (specifier.type !== "ImportSpecifier") continue;
+
+ const importedName = getImportedName(specifier);
+ if (!SERVICE_CONSTRUCTOR_NAME.test(importedName)) continue;
+
+ context.report({
+ node: specifier,
+ messageId: "serviceConstructorImport",
+ data: { name: importedName },
+ });
+ }
+ },
+ };
+ },
+});
diff --git a/tools/oxlint/anti-slop/effect/rules/prefer-effect-match.ts b/tools/oxlint/anti-slop/effect/rules/prefer-effect-match.ts
new file mode 100644
index 0000000..d834540
--- /dev/null
+++ b/tools/oxlint/anti-slop/effect/rules/prefer-effect-match.ts
@@ -0,0 +1,54 @@
+import { defineRule, type ESTree } from "@oxlint/plugins";
+
+const equalityOperators = new Set(["==", "===", "!=", "!=="]);
+
+export const preferEffectMatchRule = defineRule({
+ meta: {
+ type: "problem",
+ docs: {
+ description:
+ "Use Match from Effect for chained literal ternaries over the same value.",
+ },
+ messages: {
+ preferMatch:
+ "Use Match from Effect instead of a chained literal ternary.",
+ },
+ },
+ createOnce(context) {
+ const isLiteral = (node: ESTree.Node): boolean =>
+ node.type === "Literal" ||
+ (node.type === "TemplateLiteral" && node.expressions.length === 0);
+
+ const comparedValue = (node: ESTree.Expression): string | undefined => {
+ if (
+ node.type !== "BinaryExpression" ||
+ !equalityOperators.has(node.operator)
+ ) {
+ return undefined;
+ }
+ if (isLiteral(node.left)) return context.sourceCode.getText(node.right);
+ if (isLiteral(node.right)) return context.sourceCode.getText(node.left);
+ return undefined;
+ };
+
+ return {
+ ConditionalExpression(node) {
+ if (node.parent?.type === "ConditionalExpression") return;
+ const value = comparedValue(node.test);
+ if (value === undefined) return;
+
+ let alternate = node.alternate;
+ let literalChecks = 1;
+ while (alternate.type === "ConditionalExpression") {
+ if (comparedValue(alternate.test) !== value) return;
+ literalChecks += 1;
+ alternate = alternate.alternate;
+ }
+
+ if (literalChecks > 1) {
+ context.report({ node, messageId: "preferMatch" });
+ }
+ },
+ };
+ },
+});
diff --git a/tools/oxlint/anti-slop/effect/shared/tagged-values.ts b/tools/oxlint/anti-slop/effect/shared/tagged-values.ts
new file mode 100644
index 0000000..21d10a2
--- /dev/null
+++ b/tools/oxlint/anti-slop/effect/shared/tagged-values.ts
@@ -0,0 +1,97 @@
+import type { ESTree } from "@oxlint/plugins";
+
+const equalityOperators = new Set(["==", "===", "!=", "!=="]);
+const broadEffectCatchMethods = new Set(["catch", "catchAll", "catchIf"]);
+
+export const isStringLiteral = (
+ node: ESTree.Node | null | undefined,
+): node is ESTree.StringLiteral =>
+ node?.type === "Literal" && typeof node.value === "string";
+
+export const isTagMember = (
+ node: ESTree.Node | null | undefined,
+): node is ESTree.MemberExpression =>
+ node?.type === "MemberExpression" &&
+ ((!node.computed &&
+ node.property.type === "Identifier" &&
+ node.property.name === "_tag") ||
+ (node.computed &&
+ isStringLiteral(node.property) &&
+ node.property.value === "_tag"));
+
+export const tagMemberFromComparison = (
+ node: ESTree.BinaryExpression,
+): ESTree.MemberExpression | undefined => {
+ if (!equalityOperators.has(node.operator)) return undefined;
+ if (isTagMember(node.left) && isStringLiteral(node.right)) return node.left;
+ if (isTagMember(node.right) && isStringLiteral(node.left)) return node.right;
+ return undefined;
+};
+
+const isBroadEffectCatchCall = (
+ node: ESTree.Node | null | undefined,
+): node is ESTree.CallExpression =>
+ node?.type === "CallExpression" &&
+ node.callee.type === "MemberExpression" &&
+ node.callee.object.type === "Identifier" &&
+ node.callee.object.name === "Effect" &&
+ !node.callee.computed &&
+ node.callee.property.type === "Identifier" &&
+ broadEffectCatchMethods.has(node.callee.property.name);
+
+export const isInsideBroadEffectHandler = (node: ESTree.Node): boolean => {
+ let current: ESTree.Node | null | undefined = node.parent;
+ while (current !== null && current !== undefined) {
+ if (
+ current.type === "ArrowFunctionExpression" ||
+ current.type === "FunctionExpression"
+ ) {
+ return (
+ isBroadEffectCatchCall(current.parent) &&
+ current.parent.arguments.includes(current)
+ );
+ }
+ current = current.parent;
+ }
+ return false;
+};
+
+export const isReasonTagMember = (node: ESTree.MemberExpression): boolean =>
+ node.object.type === "MemberExpression" &&
+ ((!node.object.computed &&
+ node.object.property.type === "Identifier" &&
+ node.object.property.name === "reason") ||
+ (node.object.computed &&
+ isStringLiteral(node.object.property) &&
+ node.object.property.value === "reason"));
+
+export const propertyName = (
+ property: ESTree.ObjectProperty,
+): string | undefined => {
+ if (!property.computed && property.key.type === "Identifier") {
+ return property.key.name;
+ }
+ if (
+ property.key.type === "Literal" &&
+ typeof property.key.value === "string"
+ ) {
+ return property.key.value;
+ }
+ return undefined;
+};
+
+export const isMatchPatternObject = (node: ESTree.ObjectExpression): boolean => {
+ const call = node.parent;
+ if (call?.type !== "CallExpression" || !call.arguments.includes(node)) {
+ return false;
+ }
+ const callee = call.callee;
+ return (
+ callee.type === "MemberExpression" &&
+ callee.object.type === "Identifier" &&
+ callee.object.name === "Match" &&
+ !callee.computed &&
+ callee.property.type === "Identifier" &&
+ (callee.property.name === "when" || callee.property.name === "not")
+ );
+};
diff --git a/tools/oxlint/anti-slop/index.ts b/tools/oxlint/anti-slop/index.ts
index 2b4ae22..a3876ec 100644
--- a/tools/oxlint/anti-slop/index.ts
+++ b/tools/oxlint/anti-slop/index.ts
@@ -1,5 +1,7 @@
import { eslintCompatPlugin } from "@oxlint/plugins";
+import { noArrayFilterMapRule } from "./rules/no-array-filter-map.ts";
+import { noReduceAccumulatorCopyRule } from "./rules/no-reduce-accumulator-copy.ts";
import { noChainedTypeAssertionsRule } from "./rules/no-chained-type-assertions.ts";
import { noConditionalEmptyObjectSpreadRule } from "./rules/no-conditional-empty-object-spread.ts";
import { noKnownValueWideningRule } from "./rules/no-known-value-widening.ts";
@@ -14,12 +16,15 @@ import { noUnknownReturnsRule } from "./rules/no-unknown-returns.ts";
import { noUnknownTypeAliasesRule } from "./rules/no-unknown-type-aliases.ts";
import { noUnsafeDictionaryTypeRule } from "./rules/no-unsafe-dictionary-type.ts";
import { noWidenThenAssertRule } from "./rules/no-widen-then-assert.ts";
+import { requireReadableSpacingRule } from "./rules/require-readable-spacing.ts";
import { requireSafetyCommentForTypeAssertionRule } from "./rules/require-safety-comment-for-type-assertion.ts";
/** Generic Oxlint rules that reject low-evidence and low-signal implementation patterns. */
const antiSlopPlugin = eslintCompatPlugin({
meta: { name: "anti-slop" },
rules: {
+ "no-array-filter-map": noArrayFilterMapRule,
+ "no-reduce-accumulator-copy": noReduceAccumulatorCopyRule,
"no-chained-type-assertions": noChainedTypeAssertionsRule,
"no-conditional-empty-object-spread": noConditionalEmptyObjectSpreadRule,
"no-known-value-widening": noKnownValueWideningRule,
@@ -34,6 +39,7 @@ const antiSlopPlugin = eslintCompatPlugin({
"no-unknown-returns": noUnknownReturnsRule,
"no-unknown-type-aliases": noUnknownTypeAliasesRule,
"no-widen-then-assert": noWidenThenAssertRule,
+ "require-readable-spacing": requireReadableSpacingRule,
"require-safety-comment-for-type-assertion": requireSafetyCommentForTypeAssertionRule,
},
});
diff --git a/tools/oxlint/anti-slop/rules/no-array-filter-map.ts b/tools/oxlint/anti-slop/rules/no-array-filter-map.ts
new file mode 100644
index 0000000..dc001b3
--- /dev/null
+++ b/tools/oxlint/anti-slop/rules/no-array-filter-map.ts
@@ -0,0 +1,28 @@
+import { defineRule } from "@oxlint/plugins";
+
+import { arrayMethodTarget, isKnownArrayExpression, unwrapArrayExpression } from "../shared/array-method.ts";
+
+/** Reject eager array filter/map pipelines; lazy iterator helpers remain allowed. */
+export const noArrayFilterMapRule = defineRule({
+ meta: {
+ type: "suggestion",
+ docs: { description: "Disallow adjacent array filter/map passes in favor of lazy iterator helpers or a single transformation." },
+ messages: {
+ arrayFilterMap: "Avoid consecutive array `{{first}}` and `{{second}}` passes. Prefer `.values().{{first}}(...).{{second}}(...).toArray()` where iterator helpers are supported, or a single `flatMap`/mutating reducer. Preserve callback ordering, indexes, and filtering semantics.",
+ },
+ },
+ createOnce(context) {
+ return {
+ CallExpression(node) {
+ const outer = arrayMethodTarget(node.callee);
+ if (outer === null || (outer.name !== "map" && outer.name !== "filter")) return;
+ const innerCall = unwrapArrayExpression(outer.object);
+ if (innerCall.type !== "CallExpression") return;
+ const inner = arrayMethodTarget(innerCall.callee);
+ if (inner === null || inner.name !== (outer.name === "map" ? "filter" : "map")) return;
+ if (!isKnownArrayExpression(context.sourceCode, inner.object)) return;
+ context.report({ node, messageId: "arrayFilterMap", data: { first: inner.name, second: outer.name } });
+ },
+ };
+ },
+});
diff --git a/tools/oxlint/anti-slop/rules/no-known-value-widening.ts b/tools/oxlint/anti-slop/rules/no-known-value-widening.ts
index 2a6806c..0df39ab 100644
--- a/tools/oxlint/anti-slop/rules/no-known-value-widening.ts
+++ b/tools/oxlint/anti-slop/rules/no-known-value-widening.ts
@@ -1,14 +1,21 @@
import { defineRule } from "@oxlint/plugins";
import {
+ classifyUnsafeDictionaryValue,
classifyWideningTarget,
createTypeEnvironment,
isKnownEvidenceExpression,
type TypeEnvironment,
type WideningTarget,
} from "../shared/dictionary-types.ts";
+import {
+ containsUnknownType,
+ functionParameterBindingName,
+ functionParameterTypeAnnotation,
+} from "../shared/function-parameters.ts";
+import { resolveVariable } from "../shared/scope.ts";
-import type { ESTree, Scope, SourceCode, Variable } from "@oxlint/plugins";
+import type { ESTree, SourceCode, Variable } from "@oxlint/plugins";
type FunctionExpression = ESTree.ArrowFunctionExpression | ESTree.Function;
@@ -26,19 +33,6 @@ function unwrapExpression(expression: ESTree.Expression): ESTree.Expression {
return current;
}
-function resolveVariable(
- sourceCode: SourceCode,
- identifier: ESTree.IdentifierReference,
-): Variable | null {
- let scope: Scope | null = sourceCode.getScope(identifier);
- while (scope !== null) {
- const variable = scope.set.get(identifier.name);
- if (variable !== undefined) return variable;
- scope = scope.upper;
- }
- return null;
-}
-
function variableDeclarator(variable: Variable): ESTree.VariableDeclarator | null {
if (variable.defs.length !== 1) return null;
const [definition] = variable.defs;
@@ -77,6 +71,140 @@ function hasKnownEvidence(
return hasKnownEvidence(sourceCode, declarator.init, visitedVariables);
}
+function isFunctionExpression(node: ESTree.Node): node is FunctionExpression {
+ return (
+ node.type === "ArrowFunctionExpression" ||
+ node.type === "FunctionDeclaration" ||
+ node.type === "FunctionExpression" ||
+ node.type === "TSDeclareFunction" ||
+ node.type === "TSEmptyBodyFunctionExpression"
+ );
+}
+
+function localFunctionForCall(
+ sourceCode: SourceCode,
+ callee: ESTree.Expression,
+): FunctionExpression | null {
+ const unwrapped = unwrapExpression(callee);
+ if (isFunctionExpression(unwrapped)) return unwrapped;
+ if (unwrapped.type !== "Identifier") return null;
+ const variable = resolveVariable(sourceCode, unwrapped);
+ if (variable === null || variable.defs.length !== 1) return null;
+ const [definition] = variable.defs;
+ if (definition === undefined) return null;
+ if (definition.type === "FunctionName" && isFunctionExpression(definition.node)) {
+ return definition.node;
+ }
+ if (definition.type !== "Variable" || definition.node.type !== "VariableDeclarator") {
+ return null;
+ }
+ const initializer = definition.node.init;
+ if (initializer === null) return null;
+ const unwrappedInitializer = unwrapExpression(initializer);
+ return isFunctionExpression(unwrappedInitializer) ? unwrappedInitializer : null;
+}
+
+function variableTypeAnnotation(
+ sourceCode: SourceCode,
+ variable: Variable,
+): ESTree.TSTypeAnnotation | null {
+ if (variable.defs.length !== 1) return null;
+ const [definition] = variable.defs;
+ if (definition === undefined) return null;
+ if (
+ definition.type === "Variable" &&
+ definition.node.type === "VariableDeclarator" &&
+ definition.node.id.type === "Identifier"
+ ) {
+ return definition.node.id.typeAnnotation ?? null;
+ }
+ if (definition.type !== "Parameter" || !isFunctionExpression(definition.node)) {
+ return null;
+ }
+ const parameter = definition.node.params.find(
+ (candidate) =>
+ functionParameterBindingName(candidate, sourceCode) === variable.name,
+ );
+ return parameter === undefined ? null : (functionParameterTypeAnnotation(parameter) ?? null);
+}
+
+function hasInformativeType(
+ type: ESTree.TSType,
+ environment: TypeEnvironment,
+): boolean {
+ return classifyUnsafeDictionaryValue(type, environment) === null;
+}
+
+function hasKnownCallArgumentEvidence(
+ sourceCode: SourceCode,
+ expression: ESTree.Expression,
+ environment: TypeEnvironment,
+ visitedVariables = new Set(),
+): boolean {
+ if (expression.type === "ParenthesizedExpression" || expression.type === "TSNonNullExpression") {
+ return hasKnownCallArgumentEvidence(
+ sourceCode,
+ expression.expression,
+ environment,
+ visitedVariables,
+ );
+ }
+ if (expression.type === "TSAsExpression" || expression.type === "TSTypeAssertion") {
+ return hasInformativeType(expression.typeAnnotation, environment);
+ }
+ if (expression.type === "TSSatisfiesExpression") {
+ return hasKnownCallArgumentEvidence(
+ sourceCode,
+ expression.expression,
+ environment,
+ visitedVariables,
+ );
+ }
+ if (expression.type === "CallExpression") {
+ const owner = localFunctionForCall(sourceCode, expression.callee);
+ const returnType = owner?.returnType?.typeAnnotation;
+ return returnType !== undefined && hasInformativeType(returnType, environment);
+ }
+ if (expression.type !== "Identifier") return isKnownEvidenceExpression(expression);
+ const variable = resolveVariable(sourceCode, expression);
+ if (variable === null || visitedVariables.has(variable)) return false;
+ const annotation = variableTypeAnnotation(sourceCode, variable);
+ if (annotation !== null) {
+ return hasInformativeType(annotation.typeAnnotation, environment);
+ }
+ const declarator = variableDeclarator(variable);
+ if (
+ declarator === null ||
+ declarator.init === null ||
+ !isStableConstVariable(variable, declarator)
+ ) {
+ return false;
+ }
+ visitedVariables.add(variable);
+ return hasKnownCallArgumentEvidence(
+ sourceCode,
+ declarator.init,
+ environment,
+ visitedVariables,
+ );
+}
+
+function typePredicateSubjectIndex(
+ sourceCode: SourceCode,
+ owner: FunctionExpression,
+): number | null {
+ const predicate = owner.returnType?.typeAnnotation;
+ if (predicate?.type !== "TSTypePredicate" || predicate.parameterName.type !== "Identifier") {
+ return null;
+ }
+ const predicateParameterName = predicate.parameterName.name;
+ const index = owner.params.findIndex(
+ (parameter) =>
+ functionParameterBindingName(parameter, sourceCode) === predicateParameterName,
+ );
+ return index === -1 ? null : index;
+}
+
function annotationTarget(
annotation: ESTree.TSTypeAnnotation | null | undefined,
environment: TypeEnvironment,
@@ -171,7 +299,10 @@ export const noKnownValueWideningRule = defineRule({
return {
Program(node) {
- environment = createTypeEnvironment(node);
+ environment = createTypeEnvironment(
+ node,
+ context.sourceCode.visitorKeys,
+ );
},
VariableDeclarator(node) {
if (node.init === null || node.id.type !== "Identifier") return;
@@ -209,6 +340,43 @@ export const noKnownValueWideningRule = defineRule({
`binding \`${declarator.id.name}\``,
);
},
+ CallExpression(node) {
+ if (environment === null) return;
+ const owner = localFunctionForCall(context.sourceCode, node.callee);
+ if (owner === null) return;
+ const parameterIndex = typePredicateSubjectIndex(context.sourceCode, owner);
+ if (parameterIndex === null) return;
+ const parameter = owner.params[parameterIndex];
+ const argument = node.arguments[parameterIndex];
+ if (parameter === undefined || argument === undefined || argument.type === "SpreadElement") {
+ return;
+ }
+ const parameterAnnotation = functionParameterTypeAnnotation(parameter);
+ if (
+ parameterAnnotation === null ||
+ parameterAnnotation === undefined ||
+ !containsUnknownType(parameterAnnotation.typeAnnotation)
+ ) {
+ return;
+ }
+ if (
+ !hasKnownCallArgumentEvidence(
+ context.sourceCode,
+ argument,
+ environment,
+ )
+ ) {
+ return;
+ }
+ context.report({
+ node: argument,
+ messageId: "widening",
+ data: {
+ subject: `argument for parameter \`${functionParameterBindingName(parameter, context.sourceCode)}\` of \`${functionName(context.sourceCode, owner)}\``,
+ target: "unknown",
+ },
+ });
+ },
ReturnStatement(node) {
if (node.argument === null) return;
const owner = enclosingFunction(node);
diff --git a/tools/oxlint/anti-slop/rules/no-module-mocking.ts b/tools/oxlint/anti-slop/rules/no-module-mocking.ts
index d6fb5b4..2d60d48 100644
--- a/tools/oxlint/anti-slop/rules/no-module-mocking.ts
+++ b/tools/oxlint/anti-slop/rules/no-module-mocking.ts
@@ -1,21 +1,10 @@
import { defineRule } from "@oxlint/plugins";
-import type { ESTree, Scope, SourceCode, Variable } from "@oxlint/plugins";
+import { resolveVariable } from "../shared/scope.ts";
-const moduleMockMethods = new Set(["doMock", "mock", "unstable_mockModule"]);
+import type { ESTree, SourceCode } from "@oxlint/plugins";
-function resolveVariable(
- sourceCode: SourceCode,
- identifier: ESTree.IdentifierReference,
-): Variable | null {
- let scope: Scope | null = sourceCode.getScope(identifier);
- while (scope !== null) {
- const variable = scope.set.get(identifier.name);
- if (variable !== undefined) return variable;
- scope = scope.upper;
- }
- return null;
-}
+const moduleMockMethods = new Set(["doMock", "mock", "unstable_mockModule"]);
function importedName(node: ESTree.Node): string | null {
if (node.type !== "ImportSpecifier") return null;
diff --git a/tools/oxlint/anti-slop/rules/no-object-parameters.ts b/tools/oxlint/anti-slop/rules/no-object-parameters.ts
index 29b990f..6589ebf 100644
--- a/tools/oxlint/anti-slop/rules/no-object-parameters.ts
+++ b/tools/oxlint/anti-slop/rules/no-object-parameters.ts
@@ -1,10 +1,16 @@
import { defineRule } from "@oxlint/plugins";
-import type { ESTree, SourceCode } from "@oxlint/plugins";
+import type { ESTree } from "@oxlint/plugins";
-import { lexicalTypeParameterNames } from "../shared/lexical-type-parameters.ts";
-
-type Parameter = ESTree.ParamPattern;
+import {
+ functionParameterBindingName,
+ functionParameterTypeAnnotation,
+} from "../shared/function-parameters.ts";
+import {
+ createTypeAliasEnvironment,
+ resolvedTypeMatches,
+ type TypeAliasEnvironment,
+} from "../shared/type-alias-resolution.ts";
type ParameterOwner =
| ESTree.ArrowFunctionExpression
| ESTree.Function
@@ -14,25 +20,6 @@ type ParameterOwner =
| ESTree.TSFunctionType
| ESTree.TSMethodSignature;
-function parameterAnnotation(parameter: Parameter): ESTree.TSTypeAnnotation | null | undefined {
- if (parameter.type === "TSParameterProperty") {
- return parameterAnnotation(parameter.parameter);
- }
- if (parameter.type === "RestElement") {
- return parameter.typeAnnotation ?? parameterAnnotation(parameter.argument);
- }
- if (parameter.type === "AssignmentPattern") {
- return parameter.typeAnnotation ?? parameter.left.typeAnnotation;
- }
- return parameter.typeAnnotation;
-}
-
-function parameterName(parameter: Parameter, sourceCode: SourceCode): string {
- return parameter.type === "Identifier"
- ? parameter.name
- : sourceCode.getText(parameter).replace(/\s*:\s*object\s*$/u, "");
-}
-
/** Ban the broad object type on function inputs, including local aliases to object. */
export const noObjectParametersRule = defineRule({
meta: {
@@ -47,69 +34,39 @@ export const noObjectParametersRule = defineRule({
},
},
createOnce(context) {
- const aliases = new Map();
+ let environment: TypeAliasEnvironment | null = null;
- const resolvesToObject = (
- type: ESTree.TSType,
- shadowedAliases: ReadonlySet,
- visited = new Set(),
- ): boolean => {
- if (type.type === "TSObjectKeyword") return true;
- if (type.type === "TSParenthesizedType")
- return resolvesToObject(type.typeAnnotation, shadowedAliases, visited);
- if (type.type === "TSUnionType") {
- return type.types.some((member) =>
- resolvesToObject(member, shadowedAliases, visited),
+ const resolvesToObject = (type: ESTree.TSType): boolean =>
+ environment !== null &&
+ resolvedTypeMatches(type, environment, (resolved, matches) => {
+ if (resolved.type === "TSObjectKeyword") return true;
+ if (resolved.type === "TSParenthesizedType") {
+ return matches(resolved.typeAnnotation);
+ }
+ return (
+ resolved.type === "TSUnionType" && resolved.types.some(matches)
);
- }
- if (
- type.type !== "TSTypeReference" ||
- type.typeName.type !== "Identifier" ||
- (type.typeArguments !== null &&
- type.typeArguments !== undefined &&
- type.typeArguments.params.length > 0) ||
- visited.has(type.typeName.name) ||
- shadowedAliases.has(type.typeName.name)
- ) {
- return false;
- }
- const alias = aliases.get(type.typeName.name);
- if (alias === undefined) return false;
- const nextVisited = new Set(visited);
- nextVisited.add(type.typeName.name);
- return resolvesToObject(alias, shadowedAliases, nextVisited);
- };
+ });
const checkParameters = (node: ParameterOwner) => {
- const shadowedAliases = lexicalTypeParameterNames(
- node,
- context.sourceCode.visitorKeys,
- );
for (const parameter of node.params) {
- const annotation = parameterAnnotation(parameter);
+ const annotation = functionParameterTypeAnnotation(parameter);
if (annotation === null || annotation === undefined) continue;
- if (!resolvesToObject(annotation.typeAnnotation, shadowedAliases)) continue;
+ if (!resolvesToObject(annotation.typeAnnotation)) continue;
context.report({
node: annotation.typeAnnotation,
messageId: "objectParameter",
- data: { parameter: parameterName(parameter, context.sourceCode) },
+ data: { parameter: functionParameterBindingName(parameter, context.sourceCode) },
});
}
};
return {
Program(node) {
- aliases.clear();
- for (const statement of node.body) {
- const declaration =
- statement.type === "ExportNamedDeclaration" ? statement.declaration : statement;
- if (
- declaration?.type === "TSTypeAliasDeclaration" &&
- (declaration.typeParameters === null || declaration.typeParameters === undefined)
- ) {
- aliases.set(declaration.id.name, declaration.typeAnnotation);
- }
- }
+ environment = createTypeAliasEnvironment(
+ node,
+ context.sourceCode.visitorKeys,
+ );
},
ArrowFunctionExpression: checkParameters,
FunctionDeclaration: checkParameters,
diff --git a/tools/oxlint/anti-slop/rules/no-reduce-accumulator-copy.ts b/tools/oxlint/anti-slop/rules/no-reduce-accumulator-copy.ts
new file mode 100644
index 0000000..47d8e2c
--- /dev/null
+++ b/tools/oxlint/anti-slop/rules/no-reduce-accumulator-copy.ts
@@ -0,0 +1,109 @@
+import { defineRule } from "@oxlint/plugins";
+import type { ESTree, SourceCode, Variable } from "@oxlint/plugins";
+
+import {
+ arrayMethodTarget,
+ isKnownArrayExpression,
+ resolveArrayBinding,
+ unwrapArrayExpression,
+} from "../shared/array-method.ts";
+
+function enclosingReducer(node: ESTree.Node) {
+ let parent = node.parent;
+ while (parent !== null) {
+ if (parent.type === "FunctionDeclaration") return null;
+ if (parent.type === "ArrowFunctionExpression" || parent.type === "FunctionExpression") {
+ const callback = parent;
+ let owner: ESTree.Node | null = callback.parent;
+ while (owner !== null && unwrapArrayExpression(owner) === callback) owner = owner.parent;
+ if (owner?.type !== "CallExpression") return null;
+ const method = arrayMethodTarget(owner.callee);
+ const firstArgument = owner.arguments[0];
+ if (
+ method === null || (method.name !== "reduce" && method.name !== "reduceRight") ||
+ owner.arguments.length > 2 || firstArgument === undefined ||
+ unwrapArrayExpression(firstArgument) !== callback
+ ) return null;
+ const firstParameter = callback.params[0];
+ const accumulator = firstParameter?.type === "AssignmentPattern" ? firstParameter.left : firstParameter;
+ if (accumulator?.type !== "Identifier") return null;
+ return { callback, accumulator, initialValue: owner.arguments[1] };
+ }
+ parent = parent.parent;
+ }
+ return null;
+}
+
+function referencesAccumulator(
+ sourceCode: SourceCode,
+ node: ESTree.Node,
+ accumulator: Variable,
+ visited = new Set(),
+): boolean {
+ const variable = resolveArrayBinding(sourceCode, node);
+ if (variable === null || visited.has(variable)) return false;
+ if (variable === accumulator) return true;
+ visited.add(variable);
+ if (variable.references.some(reference => reference.isWrite() && !reference.init)) return false;
+ for (const definition of variable.defs) {
+ if (
+ definition.type === "Variable" && definition.node.type === "VariableDeclarator" &&
+ definition.node.id.type === "Identifier" && definition.node.init !== null &&
+ definition.node.parent.type === "VariableDeclaration" && definition.node.parent.kind === "const"
+ ) {
+ return referencesAccumulator(sourceCode, definition.node.init, accumulator, visited);
+ }
+ }
+ return false;
+}
+
+function isGlobalCopyOwner(sourceCode: SourceCode, node: ESTree.Node, name: string): boolean {
+ node = unwrapArrayExpression(node);
+ if (node.type !== "Identifier" || node.name !== name) return false;
+ const variable = resolveArrayBinding(sourceCode, node);
+ return variable === null || variable.defs.length === 0;
+}
+
+/** Reject non-spread copies of reducer accumulators; pair with oxc/no-accumulating-spread. */
+export const noReduceAccumulatorCopyRule = defineRule({
+ meta: {
+ type: "problem",
+ docs: { description: "Disallow copying growing reducer accumulators with Object.assign, Array.from, or array copy methods." },
+ messages: {
+ accumulatorCopy: "Do not copy the reducer accumulator on every iteration; growing copies can cause quadratic work. Mutate a fresh, locally owned accumulator and return it, or use an iterator pipeline/flatMap.",
+ },
+ },
+ createOnce(context) {
+ return {
+ CallExpression(node) {
+ const method = arrayMethodTarget(node.callee);
+ if (method === null) return;
+ const reducer = enclosingReducer(node);
+ if (reducer === null) return;
+ const accumulator = context.sourceCode.getDeclaredVariables(reducer.callback).find(variable =>
+ variable.identifiers.some(identifier => identifier.start === reducer.accumulator.start),
+ );
+ if (accumulator === undefined) return;
+ const isAccumulator = (expression: ESTree.Node) =>
+ referencesAccumulator(context.sourceCode, expression, accumulator);
+ let copiesAccumulator = false;
+ if (method.name === "assign" && isGlobalCopyOwner(context.sourceCode, method.object, "Object")) {
+ const target = node.arguments[0];
+ copiesAccumulator = (
+ target !== undefined && unwrapArrayExpression(target).type === "ObjectExpression" &&
+ node.arguments.slice(1).some(isAccumulator)
+ );
+ } else if (method.name === "from" && isGlobalCopyOwner(context.sourceCode, method.object, "Array")) {
+ const source = node.arguments[0];
+ copiesAccumulator = source !== undefined && isAccumulator(source);
+ } else if (["concat", "slice", "toSpliced", "toSorted", "toReversed", "with"].includes(method.name)) {
+ const initialValue = reducer.initialValue;
+ const arrayAccumulator = initialValue !== undefined &&
+ isKnownArrayExpression(context.sourceCode, initialValue);
+ copiesAccumulator = arrayAccumulator && isAccumulator(method.object);
+ }
+ if (copiesAccumulator) context.report({ node, messageId: "accumulatorCopy" });
+ },
+ };
+ },
+});
diff --git a/tools/oxlint/anti-slop/rules/no-runtime-typeof.ts b/tools/oxlint/anti-slop/rules/no-runtime-typeof.ts
index 6a25c24..43259eb 100644
--- a/tools/oxlint/anti-slop/rules/no-runtime-typeof.ts
+++ b/tools/oxlint/anti-slop/rules/no-runtime-typeof.ts
@@ -23,6 +23,15 @@ function isInsideTypeGuard(node: ESTree.Node): boolean {
return false;
}
+/** Return whether typeof safely probes for the existence of a possibly absent binding. */
+function isExistenceProbe(node: ESTree.UnaryExpression): boolean {
+ const parent = node.parent;
+ if (parent.type !== "BinaryExpression") return false;
+ if (!["===", "!==", "==", "!="].includes(parent.operator)) return false;
+ const other = parent.left === node ? parent.right : parent.left;
+ return other.type === "Literal" && other.value === "undefined";
+}
+
/** Disallow runtime typeof checks that narrow unparsed values instead of decoding them. */
export const noRuntimeTypeofRule = defineRule({
meta: {
@@ -57,6 +66,7 @@ export const noRuntimeTypeofRule = defineRule({
option.allowInTypeGuards === true;
if (
node.operator === "typeof" &&
+ !isExistenceProbe(node) &&
(!allowInTypeGuards || !isInsideTypeGuard(node))
) {
context.report({ node, messageId: "runtimeTypeof" });
diff --git a/tools/oxlint/anti-slop/rules/no-shape-in-symbol-names.ts b/tools/oxlint/anti-slop/rules/no-shape-in-symbol-names.ts
index afc00dd..436d2a2 100644
--- a/tools/oxlint/anti-slop/rules/no-shape-in-symbol-names.ts
+++ b/tools/oxlint/anti-slop/rules/no-shape-in-symbol-names.ts
@@ -7,6 +7,13 @@ function containsForbiddenSymbolName(name: string): boolean {
return name.toLowerCase().includes(FORBIDDEN_SYMBOL_NAME);
}
+/** Return whether an identifier names a statically accessed member owned by another value. */
+function isBorrowedMemberName(node: ESTree.Node): boolean {
+ const parent = node.parent;
+ if (parent === null || parent.type !== "MemberExpression") return false;
+ return parent.property === node && parent.computed === false;
+}
+
/** Ban the case-insensitive substring "shape" in every JavaScript and TypeScript symbol name. */
export const noForbiddenTermInSymbolNamesRule = defineRule({
meta: {
@@ -22,7 +29,7 @@ export const noForbiddenTermInSymbolNamesRule = defineRule({
},
createOnce(context) {
const reportForbiddenSymbolName = (node: ESTree.Node & { name: string }) => {
- if (!containsForbiddenSymbolName(node.name)) return;
+ if (!containsForbiddenSymbolName(node.name) || isBorrowedMemberName(node)) return;
context.report({
node,
messageId: "forbiddenSymbolName",
diff --git a/tools/oxlint/anti-slop/rules/no-unknown-parameters.ts b/tools/oxlint/anti-slop/rules/no-unknown-parameters.ts
index cdc6c23..b4a1545 100644
--- a/tools/oxlint/anti-slop/rules/no-unknown-parameters.ts
+++ b/tools/oxlint/anti-slop/rules/no-unknown-parameters.ts
@@ -1,7 +1,11 @@
import { defineRule } from "@oxlint/plugins";
import type { ESTree } from "@oxlint/plugins";
-type Parameter = ESTree.ParamPattern;
+import {
+ containsUnknownType,
+ functionParameterBindingName,
+ functionParameterTypeAnnotation,
+} from "../shared/function-parameters.ts";
type ParameterOwner =
| ESTree.ArrowFunctionExpression
| ESTree.Function
@@ -11,32 +15,13 @@ type ParameterOwner =
| ESTree.TSFunctionType
| ESTree.TSMethodSignature;
-function parameterAnnotation(parameter: Parameter): ESTree.TSTypeAnnotation | null | undefined {
- if (parameter.type === "TSParameterProperty") {
- return parameterAnnotation(parameter.parameter);
- }
- if (parameter.type === "RestElement") {
- return parameter.typeAnnotation ?? parameterAnnotation(parameter.argument);
- }
- if (parameter.type === "AssignmentPattern") {
- return parameter.typeAnnotation ?? parameter.left.typeAnnotation;
- }
- return parameter.typeAnnotation;
-}
-
-function parameterName(parameter: Parameter, sourceText: string): string {
- if (parameter.type === "TSParameterProperty") {
- return parameterName(parameter.parameter, sourceText);
- }
- if (parameter.type === "AssignmentPattern") {
- return parameterName(parameter.left, sourceText);
- }
- if (parameter.type === "RestElement") {
- return parameterName(parameter.argument, sourceText);
- }
- return parameter.type === "Identifier"
- ? parameter.name
- : sourceText.replace(/\s*:\s*unknown\s*$/u, "");
+function isTypePredicateSubject(owner: ParameterOwner, parameterName: string): boolean {
+ const predicate = owner.returnType?.typeAnnotation;
+ return (
+ predicate?.type === "TSTypePredicate" &&
+ predicate.parameterName.type === "Identifier" &&
+ predicate.parameterName.name === parameterName
+ );
}
/** Disallow unknown inputs except explicitly named error-cause enrichment. */
@@ -45,7 +30,7 @@ export const noUnknownParametersRule = defineRule({
type: "problem",
docs: {
description:
- "Disallow explicitly unknown function parameters except `cause`; decode unknown input at its I/O boundary instead.",
+ "Disallow explicitly unknown function parameters except `cause` and type-predicate subjects; decode unknown input at its I/O boundary instead.",
},
messages: {
unknownParameter:
@@ -55,10 +40,11 @@ export const noUnknownParametersRule = defineRule({
createOnce(context) {
const checkParameters = (node: ParameterOwner) => {
for (const parameter of node.params) {
- const annotation = parameterAnnotation(parameter);
- if (annotation?.typeAnnotation.type !== "TSUnknownKeyword") continue;
- const name = parameterName(parameter, context.sourceCode.getText(parameter));
- if (name === "cause") continue;
+ const annotation = functionParameterTypeAnnotation(parameter);
+ if (annotation === null || annotation === undefined) continue;
+ if (!containsUnknownType(annotation.typeAnnotation)) continue;
+ const name = functionParameterBindingName(parameter, context.sourceCode);
+ if (name === "cause" || isTypePredicateSubject(node, name)) continue;
context.report({
node: annotation.typeAnnotation,
messageId: "unknownParameter",
diff --git a/tools/oxlint/anti-slop/rules/no-unknown-returns.ts b/tools/oxlint/anti-slop/rules/no-unknown-returns.ts
index 4b16d6e..e1f43f8 100644
--- a/tools/oxlint/anti-slop/rules/no-unknown-returns.ts
+++ b/tools/oxlint/anti-slop/rules/no-unknown-returns.ts
@@ -2,7 +2,11 @@ import { defineRule } from "@oxlint/plugins";
import type { ESTree } from "@oxlint/plugins";
-import { lexicalTypeParameterNames } from "../shared/lexical-type-parameters.ts";
+import {
+ createTypeAliasEnvironment,
+ resolvedTypeMatches,
+ type TypeAliasEnvironment,
+} from "../shared/type-alias-resolution.ts";
type FunctionWithReturnType =
| ESTree.ArrowFunctionExpression
@@ -13,16 +17,6 @@ type FunctionWithReturnType =
| ESTree.TSFunctionType
| ESTree.TSMethodSignature;
-function referencedAliasName(type: ESTree.TSType): string | null {
- if (type.type === "TSParenthesizedType") return referencedAliasName(type.typeAnnotation);
- if (type.type !== "TSTypeReference" || type.typeName.type !== "Identifier") return null;
- return type.typeArguments === null ||
- type.typeArguments === undefined ||
- type.typeArguments.params.length === 0
- ? type.typeName.name
- : null;
-}
-
/** Ban function contracts that return unknown instead of a parsed domain type. */
export const noUnknownReturnsRule = defineRule({
meta: {
@@ -37,68 +31,41 @@ export const noUnknownReturnsRule = defineRule({
},
},
createOnce(context) {
- const aliases = new Map();
+ let environment: TypeAliasEnvironment | null = null;
- const resolvesToUnknown = (
- type: ESTree.TSType,
- shadowedAliases: ReadonlySet,
- visited = new Set(),
- ): boolean => {
- if (type.type === "TSUnknownKeyword") return true;
- if (type.type === "TSParenthesizedType") {
- return resolvesToUnknown(type.typeAnnotation, shadowedAliases, visited);
- }
- if (type.type === "TSUnionType") {
- return type.types.some((member) =>
- resolvesToUnknown(member, shadowedAliases, visited),
- );
- }
- if (
- type.type === "TSTypeReference" &&
- type.typeName.type === "Identifier" &&
- (type.typeName.name === "Promise" || type.typeName.name === "PromiseLike")
- ) {
- const value = type.typeArguments?.params[0];
- return value !== undefined && resolvesToUnknown(value, shadowedAliases, visited);
- }
- const name = referencedAliasName(type);
- if (name === null || visited.has(name) || shadowedAliases.has(name)) return false;
- const alias = aliases.get(name);
- if (
- alias === undefined ||
- (alias.typeParameters !== null && alias.typeParameters !== undefined)
- ) {
- return false;
- }
- const nextVisited = new Set(visited);
- nextVisited.add(name);
- return resolvesToUnknown(alias.typeAnnotation, shadowedAliases, nextVisited);
- };
+ const resolvesToUnknown = (type: ESTree.TSType): boolean =>
+ environment !== null &&
+ resolvedTypeMatches(type, environment, (resolved, matches) => {
+ if (resolved.type === "TSUnknownKeyword") return true;
+ if (resolved.type === "TSParenthesizedType") {
+ return matches(resolved.typeAnnotation);
+ }
+ if (resolved.type === "TSUnionType") return resolved.types.some(matches);
+ if (
+ resolved.type !== "TSTypeReference" ||
+ resolved.typeName.type !== "Identifier" ||
+ (resolved.typeName.name !== "Promise" &&
+ resolved.typeName.name !== "PromiseLike")
+ ) {
+ return false;
+ }
+ const value = resolved.typeArguments?.params[0];
+ return value !== undefined && matches(value);
+ });
const checkReturnType = (node: FunctionWithReturnType) => {
const annotation = node.returnType;
if (annotation === null || annotation === undefined) return;
- if (
- !resolvesToUnknown(
- annotation.typeAnnotation,
- lexicalTypeParameterNames(node, context.sourceCode.visitorKeys),
- )
- ) {
- return;
- }
+ if (!resolvesToUnknown(annotation.typeAnnotation)) return;
context.report({ node: annotation.typeAnnotation, messageId: "unknownReturn" });
};
return {
Program(node) {
- aliases.clear();
- for (const statement of node.body) {
- const declaration =
- statement.type === "ExportNamedDeclaration" ? statement.declaration : statement;
- if (declaration?.type === "TSTypeAliasDeclaration") {
- aliases.set(declaration.id.name, declaration);
- }
- }
+ environment = createTypeAliasEnvironment(
+ node,
+ context.sourceCode.visitorKeys,
+ );
},
ArrowFunctionExpression: checkReturnType,
FunctionDeclaration: checkReturnType,
diff --git a/tools/oxlint/anti-slop/rules/no-unknown-type-aliases.ts b/tools/oxlint/anti-slop/rules/no-unknown-type-aliases.ts
index 3e328fd..af5f08a 100644
--- a/tools/oxlint/anti-slop/rules/no-unknown-type-aliases.ts
+++ b/tools/oxlint/anti-slop/rules/no-unknown-type-aliases.ts
@@ -2,15 +2,11 @@ import { defineRule } from "@oxlint/plugins";
import type { ESTree } from "@oxlint/plugins";
-function referencedAliasName(type: ESTree.TSType): string | null {
- if (type.type === "TSParenthesizedType") return referencedAliasName(type.typeAnnotation);
- if (type.type !== "TSTypeReference" || type.typeName.type !== "Identifier") return null;
- return type.typeArguments === null ||
- type.typeArguments === undefined ||
- type.typeArguments.params.length === 0
- ? type.typeName.name
- : null;
-}
+import {
+ createTypeAliasEnvironment,
+ resolvedTypeMatches,
+ type TypeAliasEnvironment,
+} from "../shared/type-alias-resolution.ts";
/** Ban named aliases that merely conceal TypeScript's unknown top type. */
export const noUnknownTypeAliasesRule = defineRule({
@@ -26,44 +22,32 @@ export const noUnknownTypeAliasesRule = defineRule({
},
},
createOnce(context) {
- const aliases = new Map();
+ let environment: TypeAliasEnvironment | null = null;
- const resolvesToUnknown = (type: ESTree.TSType, visited = new Set()): boolean => {
- if (type.type === "TSUnknownKeyword") return true;
- if (type.type === "TSParenthesizedType")
- return resolvesToUnknown(type.typeAnnotation, visited);
- const name = referencedAliasName(type);
- if (name === null || visited.has(name)) return false;
- const alias = aliases.get(name);
- if (
- alias === undefined ||
- (alias.typeParameters !== null && alias.typeParameters !== undefined)
- ) {
- return false;
- }
- const nextVisited = new Set(visited);
- nextVisited.add(name);
- return resolvesToUnknown(alias.typeAnnotation, nextVisited);
- };
+ const resolvesToUnknown = (type: ESTree.TSType): boolean =>
+ environment !== null &&
+ resolvedTypeMatches(type, environment, (resolved, matches) => {
+ if (resolved.type === "TSUnknownKeyword") return true;
+ if (resolved.type === "TSParenthesizedType") {
+ return matches(resolved.typeAnnotation);
+ }
+ return resolved.type === "TSUnionType" && resolved.types.some(matches);
+ });
return {
Program(node) {
- aliases.clear();
- for (const statement of node.body) {
- const declaration =
- statement.type === "ExportNamedDeclaration" ? statement.declaration : statement;
- if (declaration?.type === "TSTypeAliasDeclaration") {
- aliases.set(declaration.id.name, declaration);
- }
- }
- for (const alias of aliases.values()) {
- if (!resolvesToUnknown(alias.typeAnnotation, new Set([alias.id.name]))) continue;
- context.report({
- node: alias.id,
- messageId: "unknownAlias",
- data: { alias: alias.id.name },
- });
- }
+ environment = createTypeAliasEnvironment(
+ node,
+ context.sourceCode.visitorKeys,
+ );
+ },
+ TSTypeAliasDeclaration(node) {
+ if (!resolvesToUnknown(node.typeAnnotation)) return;
+ context.report({
+ node: node.id,
+ messageId: "unknownAlias",
+ data: { alias: node.id.name },
+ });
},
};
},
diff --git a/tools/oxlint/anti-slop/rules/no-unsafe-dictionary-type.ts b/tools/oxlint/anti-slop/rules/no-unsafe-dictionary-type.ts
index 8c45eed..8cb615a 100644
--- a/tools/oxlint/anti-slop/rules/no-unsafe-dictionary-type.ts
+++ b/tools/oxlint/anti-slop/rules/no-unsafe-dictionary-type.ts
@@ -6,6 +6,7 @@ import {
createTypeEnvironment,
type TypeEnvironment,
} from "../shared/dictionary-types.ts";
+import { visibleTypeAlias } from "../shared/type-alias-resolution.ts";
import type { ESTree } from "@oxlint/plugins";
@@ -69,10 +70,26 @@ function isInsideTypeAliasDeclaration(node: ESTree.Node): boolean {
function isPlainAliasConsumerUse(node: ESTree.TSType, environment: TypeEnvironment): boolean {
if (node.type !== "TSTypeReference" || node.typeArguments?.params.length) return false;
const name = typeReferenceName(node);
- return name !== null && environment.aliases.has(name) && !isInsideTypeAliasDeclaration(node);
+ return (
+ name !== null &&
+ visibleTypeAlias(name, node, environment.typeAliases) !== null &&
+ !isInsideTypeAliasDeclaration(node)
+ );
+}
+
+function isInsideTypeParameterConstraint(node: ESTree.TSType): boolean {
+ let child: ESTree.Node = node;
+ let parent: ESTree.Node | null = child.parent;
+ while (parent !== null && parent.type !== "Program") {
+ if (parent.type === "TSTypeParameter" && parent.constraint === child) return true;
+ child = parent;
+ parent = child.parent;
+ }
+ return false;
}
function shouldReportType(node: ESTree.TSType, environment: TypeEnvironment): boolean {
+ if (isInsideTypeParameterConstraint(node)) return false;
if (isPlainAliasConsumerUse(node, environment)) return false;
if (classifyUnsafeDictionary(node, environment) === null) return false;
let current: ESTree.Node | null = node.parent;
@@ -111,7 +128,10 @@ export const noUnsafeDictionaryTypeRule = defineRule({
return {
Program(node) {
- environment = createTypeEnvironment(node);
+ environment = createTypeEnvironment(
+ node,
+ context.sourceCode.visitorKeys,
+ );
},
TSTypeReference: reportIfUnsafe,
TSTypeLiteral: reportIfUnsafe,
diff --git a/tools/oxlint/anti-slop/rules/require-readable-spacing.ts b/tools/oxlint/anti-slop/rules/require-readable-spacing.ts
new file mode 100644
index 0000000..fc5f9ca
--- /dev/null
+++ b/tools/oxlint/anti-slop/rules/require-readable-spacing.ts
@@ -0,0 +1,47 @@
+import type { CreateRule } from "@oxlint/plugins";
+
+import createPaddingLineRule from "../vendor/eslint-stylistic/padding-line-between-statements.ts";
+
+const paddingRule = createPaddingLineRule([
+ { blankLine: "always", prev: "import", next: "*" },
+ { blankLine: "always", prev: "*", next: { selector: "Program > :not(ImportDeclaration)" } },
+ { blankLine: "always", prev: { selector: "Program > :not(ImportDeclaration)" }, next: "*" },
+ { blankLine: "always", prev: "*", next: ["function", "class", "interface", "type"] },
+ { blankLine: "always", prev: ["function", "class", "interface", "type"], next: "*" },
+ {
+ blankLine: "always",
+ prev: "*",
+ next: ["multiline-const", "multiline-let", "multiline-var", "multiline-using"],
+ },
+ {
+ blankLine: "always",
+ prev: ["multiline-const", "multiline-let", "multiline-var", "multiline-using"],
+ next: "*",
+ },
+ { blankLine: "always", prev: "*", next: ["return", "if", "switch", "try", "for", "while", "do"] },
+ { blankLine: "always", prev: "block-like", next: "*" },
+ { blankLine: "any", prev: "import", next: "import" },
+ {
+ blankLine: "any",
+ prev: {
+ selector:
+ ':matches(TSDeclareFunction, ExportNamedDeclaration[declaration.type="TSDeclareFunction"])',
+ },
+ next: {
+ selector:
+ ':matches(TSDeclareFunction, FunctionDeclaration, ExportNamedDeclaration[declaration.type="TSDeclareFunction"], ExportNamedDeclaration[declaration.type="FunctionDeclaration"])',
+ },
+ },
+]);
+
+/** Restore structural blank lines with whitespace-only fixes; keep local short bindings and overloads grouped. */
+export const requireReadableSpacingRule: CreateRule = {
+ ...paddingRule,
+ meta: {
+ ...paddingRule.meta,
+ docs: {
+ description: "Require readable spacing between declarations and logical statement groups.",
+ },
+ schema: [],
+ },
+};
diff --git a/tools/oxlint/anti-slop/rules/require-safety-comment-for-type-assertion.ts b/tools/oxlint/anti-slop/rules/require-safety-comment-for-type-assertion.ts
index f1a2ffc..bfea1a2 100644
--- a/tools/oxlint/anti-slop/rules/require-safety-comment-for-type-assertion.ts
+++ b/tools/oxlint/anti-slop/rules/require-safety-comment-for-type-assertion.ts
@@ -4,6 +4,8 @@ import type { ESTree, SourceCode } from "@oxlint/plugins";
type TypeAssertion = ESTree.TSAsExpression | ESTree.TSTypeAssertion;
+const DEFAULT_SAFETY_MARKERS = ["SAFETY"] as const;
+
const commentOwnerKinds = new Set([
"ExpressionStatement",
"PropertyDefinition",
@@ -20,17 +22,58 @@ function isConstAssertion(node: TypeAssertion): boolean {
);
}
-function hasSafetyComment(sourceCode: SourceCode, node: TypeAssertion): boolean {
+function configuredSafetyMarkers(option: unknown): readonly string[] {
+ if (typeof option !== "object" || option === null || !("markers" in option)) {
+ return DEFAULT_SAFETY_MARKERS;
+ }
+ const configured = option.markers;
+ if (!Array.isArray(configured)) return DEFAULT_SAFETY_MARKERS;
+ const markers = configured.flatMap((marker) =>
+ typeof marker === "string" && marker.trim().length > 0 ? [marker.trim()] : [],
+ );
+ return markers.length > 0 ? markers : DEFAULT_SAFETY_MARKERS;
+}
+
+function markerPattern(markers: readonly string[]): RegExp {
+ const alternation = markers
+ .map((marker) => marker.replaceAll(/[.*+?^${}()|[\]\\]/gu, String.raw`\$&`))
+ .join("|");
+ return new RegExp(
+ String.raw`(?:^|[^\p{L}\p{N}_])(?:${alternation})\s*:\s*\S`,
+ "u",
+ );
+}
+
+function hasSafetyJustificationBefore(
+ sourceCode: SourceCode,
+ owner: ESTree.Node,
+ assertion: TypeAssertion,
+ pattern: RegExp,
+): boolean {
+ return sourceCode
+ .getCommentsBefore(owner)
+ .some(
+ (comment) => comment.end <= assertion.start && pattern.test(comment.value),
+ );
+}
+
+function hasSafetyComment(
+ sourceCode: SourceCode,
+ node: TypeAssertion,
+ pattern: RegExp,
+): boolean {
let current: ESTree.Node = node;
while (true) {
- if (
- sourceCode
- .getCommentsBefore(current)
- .some((comment) => comment.end <= node.start && /\bSAFETY\s*:/u.test(comment.value))
- ) {
- return true;
+ if (hasSafetyJustificationBefore(sourceCode, current, node, pattern)) return true;
+ if (commentOwnerKinds.has(current.type)) {
+ const exportDeclaration = current.parent;
+ return (
+ exportDeclaration.type === "ExportNamedDeclaration" &&
+ exportDeclaration.declaration === current &&
+ hasSafetyJustificationBefore(sourceCode, exportDeclaration, node, pattern)
+ );
}
- if (commentOwnerKinds.has(current.type) || current.parent.type === "Program") return false;
+ if (current.parent.type === "Program") return false;
current = current.parent;
}
}
@@ -45,13 +88,39 @@ export const requireSafetyCommentForTypeAssertionRule = defineRule({
},
messages: {
missingSafetyComment:
- "This type assertion has no `SAFETY:` justification. State the checked invariant immediately before the assertion or its containing statement.",
+ "This type assertion has no `{{marker}}:` justification. State the checked invariant immediately before the assertion or its containing statement.",
},
+ schema: [
+ {
+ type: "object",
+ properties: {
+ markers: {
+ type: "array",
+ items: { type: "string", minLength: 1 },
+ minItems: 1,
+ uniqueItems: true,
+ },
+ },
+ additionalProperties: false,
+ },
+ ],
+ defaultOptions: [{ markers: ["SAFETY"] }],
},
createOnce(context) {
+ const patterns = new Map();
+
const checkAssertion = (node: TypeAssertion) => {
- if (isConstAssertion(node) || hasSafetyComment(context.sourceCode, node)) return;
- context.report({ node, messageId: "missingSafetyComment" });
+ if (isConstAssertion(node)) return;
+ const markers = configuredSafetyMarkers(context.options?.[0]);
+ const patternKey = markers.join("\u0000");
+ const pattern = patterns.get(patternKey) ?? markerPattern(markers);
+ patterns.set(patternKey, pattern);
+ if (hasSafetyComment(context.sourceCode, node, pattern)) return;
+ context.report({
+ node,
+ messageId: "missingSafetyComment",
+ data: { marker: markers[0] ?? DEFAULT_SAFETY_MARKERS[0] },
+ });
};
return {
diff --git a/tools/oxlint/anti-slop/shared/array-method.ts b/tools/oxlint/anti-slop/shared/array-method.ts
new file mode 100644
index 0000000..87eb9d8
--- /dev/null
+++ b/tools/oxlint/anti-slop/shared/array-method.ts
@@ -0,0 +1,94 @@
+import type { ESTree, Scope, SourceCode, Variable } from "@oxlint/plugins";
+
+/** Unwrap syntax-only wrappers when inspecting array methods and accumulator references. */
+export function unwrapArrayExpression(node: ESTree.Node): ESTree.Node {
+ while (
+ node.type === "ParenthesizedExpression" ||
+ node.type === "ChainExpression" ||
+ node.type === "TSAsExpression" ||
+ node.type === "TSTypeAssertion" ||
+ node.type === "TSNonNullExpression" ||
+ node.type === "TSSatisfiesExpression"
+ ) {
+ node = node.expression;
+ }
+ return node;
+}
+
+/** Resolve a local binding by scope, not by identifier spelling. */
+export function resolveArrayBinding(sourceCode: SourceCode, node: ESTree.Node): Variable | null {
+ node = unwrapArrayExpression(node);
+ if (node.type !== "Identifier") return null;
+ let scope: Scope | null = sourceCode.getScope(node);
+ while (scope !== null) {
+ const variable = scope.set.get(node.name);
+ if (variable !== undefined) return variable;
+ scope = scope.upper;
+ }
+ return null;
+}
+
+/** Read static method names, including computed string literals, without evaluating expressions. */
+export function arrayMethodTarget(
+ node: ESTree.Node,
+): { readonly name: string; readonly object: ESTree.Node } | null {
+ node = unwrapArrayExpression(node);
+ if (node.type !== "MemberExpression") return null;
+ const property = node.property;
+ if (!node.computed && property.type === "Identifier") {
+ return { name: property.name, object: node.object };
+ }
+ if (node.computed && property.type === "Literal" && typeof property.value === "string") {
+ return { name: property.value, object: node.object };
+ }
+ return null;
+}
+
+function isArrayAnnotation(type: ESTree.TSType): boolean {
+ if (type.type === "TSArrayType" || type.type === "TSTupleType") return true;
+ if (type.type === "TSParenthesizedType") return isArrayAnnotation(type.typeAnnotation);
+ if (type.type === "TSTypeOperator" && type.operator === "readonly") {
+ return isArrayAnnotation(type.typeAnnotation);
+ }
+ return (
+ type.type === "TSTypeReference" && type.typeName.type === "Identifier" &&
+ (type.typeName.name === "Array" || type.typeName.name === "ReadonlyArray")
+ );
+}
+
+/** Recognize local array evidence; unknown receivers and iterator pipelines are deliberately excluded. */
+export function isKnownArrayExpression(
+ sourceCode: SourceCode,
+ node: ESTree.Node,
+ visited = new Set(),
+): boolean {
+ node = unwrapArrayExpression(node);
+ if (node.type === "ArrayExpression") return true;
+ if (node.type === "CallExpression") {
+ const method = arrayMethodTarget(node.callee);
+ return (
+ method !== null &&
+ ["map", "filter", "flatMap", "slice", "concat", "toSorted", "toReversed", "toSpliced"].includes(method.name) &&
+ isKnownArrayExpression(sourceCode, method.object, visited)
+ );
+ }
+ if (node.type !== "Identifier") return false;
+ const variable = resolveArrayBinding(sourceCode, node);
+ if (variable === null || visited.has(variable)) return false;
+ visited.add(variable);
+ if (variable.references.some(reference => reference.isWrite() && !reference.init)) return false;
+ for (const identifier of variable.identifiers) {
+ const annotation = identifier.typeAnnotation?.typeAnnotation;
+ if (annotation !== undefined) return isArrayAnnotation(annotation);
+ }
+ for (const definition of variable.defs) {
+ if (
+ definition.type === "Variable" && definition.node.type === "VariableDeclarator" &&
+ definition.node.id.type === "Identifier" && definition.node.init !== null &&
+ definition.node.parent.type === "VariableDeclaration" && definition.node.parent.kind === "const"
+ ) {
+ return isKnownArrayExpression(sourceCode, definition.node.init, visited);
+ }
+ }
+ return false;
+}
diff --git a/tools/oxlint/anti-slop/shared/dictionary-types.ts b/tools/oxlint/anti-slop/shared/dictionary-types.ts
index 8651700..8db73ff 100644
--- a/tools/oxlint/anti-slop/shared/dictionary-types.ts
+++ b/tools/oxlint/anti-slop/shared/dictionary-types.ts
@@ -1,5 +1,12 @@
import type { ESTree } from "@oxlint/plugins";
+import {
+ createTypeAliasEnvironment,
+ hasVisibleTypeBinding,
+ visibleTypeAlias,
+ type TypeAliasEnvironment as LexicalTypeAliasEnvironment,
+} from "./type-alias-resolution.ts";
+
const BUILT_INS = new Set([
"Record",
"Readonly",
@@ -36,9 +43,8 @@ export type WideningTarget = {
};
export type TypeEnvironment = {
- readonly aliases: ReadonlyMap;
readonly interfaces: ReadonlyMap;
- readonly shadowedBuiltIns: ReadonlySet;
+ readonly typeAliases: LexicalTypeAliasEnvironment;
};
function declaredStatement(statement: ESTree.Statement): ESTree.Node | null {
@@ -48,59 +54,39 @@ function declaredStatement(statement: ESTree.Statement): ESTree.Node | null {
: statement;
}
-export function createTypeEnvironment(program: ESTree.Program): TypeEnvironment {
- const aliases = new Map();
+export function createTypeEnvironment(
+ program: ESTree.Program,
+ visitorKeys: Readonly>,
+): TypeEnvironment {
const interfaces = new Map();
- const shadowedBuiltIns = new Set();
for (const statement of program.body) {
const declaration = declaredStatement(statement);
- if (declaration?.type === "ImportDeclaration") {
- for (const specifier of declaration.specifiers) {
- if (BUILT_INS.has(specifier.local.name)) shadowedBuiltIns.add(specifier.local.name);
- }
- continue;
- }
-
- if (declaration?.type === "TSTypeAliasDeclaration") {
- const existing = aliases.get(declaration.id.name);
- if (existing === undefined) aliases.set(declaration.id.name, declaration);
- else shadowedBuiltIns.add(declaration.id.name);
- if (BUILT_INS.has(declaration.id.name)) shadowedBuiltIns.add(declaration.id.name);
- continue;
- }
-
- if (declaration?.type === "TSInterfaceDeclaration") {
- const declarations = interfaces.get(declaration.id.name) ?? [];
- declarations.push(declaration);
- interfaces.set(declaration.id.name, declarations);
- if (BUILT_INS.has(declaration.id.name)) shadowedBuiltIns.add(declaration.id.name);
- continue;
- }
-
- if (declaration?.type === "TSEnumDeclaration") {
- if (BUILT_INS.has(declaration.id.name)) shadowedBuiltIns.add(declaration.id.name);
- continue;
- }
-
- if (
- (declaration?.type === "ClassDeclaration" ||
- declaration?.type === "FunctionDeclaration") &&
- declaration.id !== null
- ) {
- if (BUILT_INS.has(declaration.id.name)) shadowedBuiltIns.add(declaration.id.name);
- }
+ if (declaration?.type !== "TSInterfaceDeclaration") continue;
+ const declarations = interfaces.get(declaration.id.name) ?? [];
+ declarations.push(declaration);
+ interfaces.set(declaration.id.name, declarations);
}
- return { aliases, interfaces, shadowedBuiltIns };
+ return {
+ interfaces,
+ typeAliases: createTypeAliasEnvironment(program, visitorKeys),
+ };
}
function typeReferenceName(type: ESTree.TSTypeReference): string | null {
return type.typeName.type === "Identifier" ? type.typeName.name : null;
}
-function isBuiltIn(name: string, environment: TypeEnvironment): boolean {
- return BUILT_INS.has(name) && !environment.shadowedBuiltIns.has(name);
+function isBuiltIn(
+ name: string,
+ use: ESTree.Node,
+ environment: TypeEnvironment,
+): boolean {
+ return (
+ BUILT_INS.has(name) &&
+ !hasVisibleTypeBinding(name, use, environment.typeAliases)
+ );
}
function isUnappliedReferenceTo(type: ESTree.TSType, name: string): boolean {
@@ -218,7 +204,7 @@ function unsafeDirectValue(
if (unwrapped.type !== "TSTypeReference") return null;
const name = typeReferenceName(unwrapped);
if (name === null) return null;
- if (TRANSPARENT_WRAPPERS.has(name) && isBuiltIn(name, environment)) {
+ if (TRANSPARENT_WRAPPERS.has(name) && isBuiltIn(name, unwrapped, environment)) {
const wrapped = unwrapped.typeArguments?.params[0];
return wrapped === undefined
? null
@@ -234,8 +220,8 @@ function unsafeDirectValue(
if (interfaceDeclarations !== undefined) {
return isEffectivelyEmptyInterface(interfaceDeclarations) ? "empty-object" : null;
}
- const alias = environment.aliases.get(name);
- if (alias === undefined || resolvingAliases.has(name)) return null;
+ const alias = visibleTypeAlias(name, unwrapped, environment.typeAliases);
+ if (alias === null || resolvingAliases.has(name)) return null;
const nextSubstitutions = aliasSubstitution(alias, unwrapped, substitutions);
if (nextSubstitutions === null) return null;
const nextResolving = new Set(resolvingAliases);
@@ -276,27 +262,30 @@ function dictionaryValueTypes(
: dictionaryValueTypes(substitution, environment, substitutions, resolvingAliases);
}
- if (TRANSPARENT_WRAPPERS.has(name) && isBuiltIn(name, environment)) {
+ if (TRANSPARENT_WRAPPERS.has(name) && isBuiltIn(name, unwrapped, environment)) {
const wrapped = unwrapped.typeArguments?.params[0];
return wrapped === undefined
? []
: dictionaryValueTypes(wrapped, environment, substitutions, resolvingAliases);
}
- if (name === "Record" && isBuiltIn(name, environment)) {
+ if (name === "Record" && isBuiltIn(name, unwrapped, environment)) {
const value = unwrapped.typeArguments?.params[1] ?? null;
return value === null ? [] : [{ type: value, substitutions }];
}
- if ((name === "Pick" || name === "Omit") && isBuiltIn(name, environment)) {
+ if (
+ (name === "Pick" || name === "Omit") &&
+ isBuiltIn(name, unwrapped, environment)
+ ) {
const source = unwrapped.typeArguments?.params[0];
return source === undefined
? []
: dictionaryValueTypes(source, environment, substitutions, resolvingAliases);
}
- const alias = environment.aliases.get(name);
- if (alias === undefined || resolvingAliases.has(name)) return [];
+ const alias = visibleTypeAlias(name, unwrapped, environment.typeAliases);
+ if (alias === null || resolvingAliases.has(name)) return [];
const nextSubstitutions = aliasSubstitution(alias, unwrapped, substitutions);
if (nextSubstitutions === null) return [];
const nextResolving = new Set(resolvingAliases);
@@ -328,15 +317,6 @@ export function classifyUnsafeDictionary(
return null;
}
-function resolvesToDictionary(
- type: ESTree.TSType,
- environment: TypeEnvironment,
- substitutions: TypeAliasEnvironment,
- resolvingAliases: ReadonlySet,
-): boolean {
- return dictionaryValueTypes(type, environment, substitutions, resolvingAliases).length > 0;
-}
-
export function classifyWideningTarget(
type: ESTree.TSType,
environment: TypeEnvironment,
@@ -355,19 +335,29 @@ export function classifyWideningTarget(
if (unwrapped.type !== "TSTypeReference") return null;
const name = typeReferenceName(unwrapped);
if (name === null) return null;
- if (TRANSPARENT_WRAPPERS.has(name) && isBuiltIn(name, environment)) {
+ if (TRANSPARENT_WRAPPERS.has(name) && isBuiltIn(name, unwrapped, environment)) {
const wrapped = unwrapped.typeArguments?.params[0];
return wrapped === undefined ? null : classifyWideningTarget(wrapped, environment);
}
- if (name === "Record" && isBuiltIn(name, environment)) return { kind: "open dictionary" };
- const alias = environment.aliases.get(name);
- if (alias === undefined) return null;
+ if (name === "Record" && isBuiltIn(name, unwrapped, environment)) {
+ return hasBroadRecordKey(unwrapped, environment, new Map())
+ ? { kind: "open dictionary" }
+ : null;
+ }
+ const alias = visibleTypeAlias(name, unwrapped, environment.typeAliases);
+ if (alias === null) return null;
if ((alias.typeParameters?.params.length ?? 0) > 0) {
const substitutions = aliasSubstitution(alias, unwrapped, new Map());
- return substitutions !== null &&
- resolvesToDictionary(alias.typeAnnotation, environment, substitutions, new Set([name]))
- ? { kind: "generic container" }
- : null;
+ const resolved =
+ substitutions === null
+ ? null
+ : classifyAliasBroadTarget(
+ alias.typeAnnotation,
+ environment,
+ substitutions,
+ new Set([name]),
+ );
+ return resolved?.kind === "open dictionary" ? { kind: "generic container" } : null;
}
const substitutions = aliasSubstitution(alias, unwrapped, new Map());
if (substitutions === null) return null;
@@ -380,10 +370,20 @@ export function classifyWideningTarget(
return resolved;
}
+function hasBroadRecordKey(
+ type: ESTree.TSTypeReference,
+ environment: TypeEnvironment,
+ substitutions: TypeAliasEnvironment,
+): boolean {
+ const key = type.typeArguments?.params[0];
+ return key === undefined || isBroadMappedKey(key, environment, substitutions);
+}
+
function isBroadMappedKey(
type: ESTree.TSType,
environment: TypeEnvironment,
substitutions: TypeAliasEnvironment,
+ visitedAliases: ReadonlySet = new Set(),
): boolean {
const unwrapped = unwrapTransparentType(type);
if (
@@ -394,8 +394,8 @@ function isBroadMappedKey(
return true;
}
if (unwrapped.type === "TSUnionType") {
- return unwrapped.types.every((member) =>
- isBroadMappedKey(member, environment, substitutions),
+ return unwrapped.types.some((member) =>
+ isBroadMappedKey(member, environment, substitutions, visitedAliases),
);
}
if (unwrapped.type !== "TSTypeReference") return false;
@@ -403,9 +403,20 @@ function isBroadMappedKey(
if (name === null) return false;
const substitution = substitutions.get(name);
if (substitution !== undefined && !isUnappliedReferenceTo(substitution, name)) {
- return isBroadMappedKey(substitution, environment, substitutions);
+ return isBroadMappedKey(substitution, environment, substitutions, visitedAliases);
}
- return name === "PropertyKey" && isBuiltIn(name, environment);
+ if (name === "PropertyKey" && isBuiltIn(name, unwrapped, environment)) return true;
+ const alias = visibleTypeAlias(name, unwrapped, environment.typeAliases);
+ if (
+ alias === null ||
+ (alias.typeParameters?.params.length ?? 0) > 0 ||
+ visitedAliases.has(name)
+ ) {
+ return false;
+ }
+ const nextVisited = new Set(visitedAliases);
+ nextVisited.add(name);
+ return isBroadMappedKey(alias.typeAnnotation, environment, substitutions, nextVisited);
}
function classifyAliasBroadTarget(
@@ -441,17 +452,19 @@ function classifyAliasBroadTarget(
resolvingAliases,
);
}
- if (TRANSPARENT_WRAPPERS.has(name) && isBuiltIn(name, environment)) {
+ if (TRANSPARENT_WRAPPERS.has(name) && isBuiltIn(name, unwrapped, environment)) {
const wrapped = unwrapped.typeArguments?.params[0];
return wrapped === undefined
? null
: classifyAliasBroadTarget(wrapped, environment, substitutions, resolvingAliases);
}
- if (name === "Record" && isBuiltIn(name, environment)) {
- return { kind: "open dictionary" };
+ if (name === "Record" && isBuiltIn(name, unwrapped, environment)) {
+ return hasBroadRecordKey(unwrapped, environment, substitutions)
+ ? { kind: "open dictionary" }
+ : null;
}
- const alias = environment.aliases.get(name);
- if (alias === undefined || resolvingAliases.has(name)) return null;
+ const alias = visibleTypeAlias(name, unwrapped, environment.typeAliases);
+ if (alias === null || resolvingAliases.has(name)) return null;
const nextSubstitutions = aliasSubstitution(alias, unwrapped, substitutions);
if (nextSubstitutions === null) return null;
const nextResolving = new Set(resolvingAliases);
diff --git a/tools/oxlint/anti-slop/shared/function-parameters.ts b/tools/oxlint/anti-slop/shared/function-parameters.ts
new file mode 100644
index 0000000..80de91d
--- /dev/null
+++ b/tools/oxlint/anti-slop/shared/function-parameters.ts
@@ -0,0 +1,49 @@
+import type { ESTree, SourceCode } from "@oxlint/plugins";
+
+export type FunctionParameter = ESTree.ParamPattern;
+
+/** Return whether a type is or contains TypeScript's absorbing unknown top type. */
+export function containsUnknownType(type: ESTree.TSType): boolean {
+ if (type.type === "TSUnknownKeyword") return true;
+ if (type.type === "TSParenthesizedType") return containsUnknownType(type.typeAnnotation);
+ return type.type === "TSUnionType" && type.types.some(containsUnknownType);
+}
+
+/** Return the TypeScript annotation attached to a function parameter or its wrapped binding. */
+export function functionParameterTypeAnnotation(
+ parameter: FunctionParameter,
+): ESTree.TSTypeAnnotation | null | undefined {
+ if (parameter.type === "TSParameterProperty") {
+ return functionParameterTypeAnnotation(parameter.parameter);
+ }
+ if (parameter.type === "RestElement") {
+ return parameter.typeAnnotation ?? functionParameterTypeAnnotation(parameter.argument);
+ }
+ if (parameter.type === "AssignmentPattern") {
+ return parameter.typeAnnotation ?? functionParameterTypeAnnotation(parameter.left);
+ }
+ return parameter.typeAnnotation;
+}
+
+/** Return only a function parameter's local binding, excluding its annotation and default value. */
+export function functionParameterBindingName(
+ parameter: FunctionParameter,
+ sourceCode: SourceCode,
+): string {
+ if (parameter.type === "TSParameterProperty") {
+ return functionParameterBindingName(parameter.parameter, sourceCode);
+ }
+ if (parameter.type === "AssignmentPattern") {
+ return functionParameterBindingName(parameter.left, sourceCode);
+ }
+ if (parameter.type === "RestElement") {
+ return functionParameterBindingName(parameter.argument, sourceCode);
+ }
+ if (parameter.type === "Identifier") return parameter.name;
+
+ const sourceText = sourceCode.getText(parameter);
+ const annotationStart = parameter.typeAnnotation?.start;
+ return annotationStart === undefined
+ ? sourceText
+ : sourceText.slice(0, annotationStart - parameter.start).trimEnd();
+}
diff --git a/tools/oxlint/anti-slop/shared/reflect-method.ts b/tools/oxlint/anti-slop/shared/reflect-method.ts
index 39bc218..43a7cfc 100644
--- a/tools/oxlint/anti-slop/shared/reflect-method.ts
+++ b/tools/oxlint/anti-slop/shared/reflect-method.ts
@@ -1,17 +1,6 @@
-import type { ESTree, Scope, SourceCode, Variable } from "@oxlint/plugins";
+import { resolveVariable } from "./scope.ts";
-function resolveVariable(
- sourceCode: SourceCode,
- identifier: ESTree.IdentifierReference,
-): Variable | null {
- let scope: Scope | null = sourceCode.getScope(identifier);
- while (scope !== null) {
- const variable = scope.set.get(identifier.name);
- if (variable !== undefined) return variable;
- scope = scope.upper;
- }
- return null;
-}
+import type { ESTree, SourceCode } from "@oxlint/plugins";
function isGlobalReflect(sourceCode: SourceCode, expression: ESTree.Expression): boolean {
if (expression.type !== "Identifier" || expression.name !== "Reflect") return false;
diff --git a/tools/oxlint/anti-slop/shared/scope.ts b/tools/oxlint/anti-slop/shared/scope.ts
new file mode 100644
index 0000000..602414a
--- /dev/null
+++ b/tools/oxlint/anti-slop/shared/scope.ts
@@ -0,0 +1,15 @@
+import type { ESTree, Scope, SourceCode, Variable } from "@oxlint/plugins";
+
+/** Resolve an identifier to its binding by walking lexical scopes upward. */
+export function resolveVariable(
+ sourceCode: SourceCode,
+ identifier: ESTree.IdentifierReference,
+): Variable | null {
+ let scope: Scope | null = sourceCode.getScope(identifier);
+ while (scope !== null) {
+ const variable = scope.set.get(identifier.name);
+ if (variable !== undefined) return variable;
+ scope = scope.upper;
+ }
+ return null;
+}
diff --git a/tools/oxlint/anti-slop/shared/type-alias-resolution.ts b/tools/oxlint/anti-slop/shared/type-alias-resolution.ts
new file mode 100644
index 0000000..4744222
--- /dev/null
+++ b/tools/oxlint/anti-slop/shared/type-alias-resolution.ts
@@ -0,0 +1,250 @@
+import type { ESTree } from "@oxlint/plugins";
+
+import { lexicalTypeParameterNames } from "./lexical-type-parameters.ts";
+
+type VisitorKeys = Readonly>;
+type TypeScope = ESTree.Node;
+
+type TypeBinding = {
+ readonly alias: ESTree.TSTypeAliasDeclaration | null;
+ readonly name: string;
+ readonly scope: TypeScope;
+};
+
+type Substitution = {
+ readonly substitutions: Substitutions;
+ readonly type: ESTree.TSType;
+};
+
+type Substitutions = ReadonlyMap;
+
+export type TypeAliasEnvironment = {
+ readonly aliases: readonly ESTree.TSTypeAliasDeclaration[];
+ readonly bindingsByName: ReadonlyMap;
+ readonly visitorKeys: VisitorKeys;
+};
+
+export type ResolvedTypeMatcher = (
+ type: ESTree.TSType,
+ matches: (child: ESTree.TSType) => boolean,
+) => boolean;
+
+const environmentsByProgram = new WeakMap();
+
+function isNode(value: unknown): value is ESTree.Node {
+ return (
+ typeof value === "object" &&
+ value !== null &&
+ "type" in value &&
+ typeof value.type === "string"
+ );
+}
+
+function enclosingTypeScope(node: ESTree.Node): TypeScope {
+ let current: ESTree.Node | null = node.parent;
+ while (current !== null) {
+ if (
+ current.type === "Program" ||
+ current.type === "BlockStatement" ||
+ current.type === "TSModuleBlock" ||
+ current.type === "StaticBlock" ||
+ current.type === "SwitchStatement"
+ ) {
+ return current;
+ }
+ current = current.parent;
+ }
+ return node;
+}
+
+function declaredTypeBinding(node: ESTree.Node): {
+ readonly alias: ESTree.TSTypeAliasDeclaration | null;
+ readonly name: string;
+} | null {
+ if (node.type === "TSTypeAliasDeclaration") {
+ return { alias: node, name: node.id.name };
+ }
+ if (
+ node.type === "TSInterfaceDeclaration" ||
+ node.type === "TSEnumDeclaration" ||
+ node.type === "ClassDeclaration" ||
+ node.type === "ClassExpression"
+ ) {
+ return node.id === null ? null : { alias: null, name: node.id.name };
+ }
+ if (
+ node.type === "ImportSpecifier" ||
+ node.type === "ImportDefaultSpecifier" ||
+ node.type === "ImportNamespaceSpecifier"
+ ) {
+ return { alias: null, name: node.local.name };
+ }
+ return null;
+}
+
+function collectTypeBindings(
+ node: ESTree.Node,
+ visitorKeys: VisitorKeys,
+ bindingsByName: Map,
+ aliases: ESTree.TSTypeAliasDeclaration[],
+): void {
+ const declared = declaredTypeBinding(node);
+ if (declared !== null) {
+ const bindings = bindingsByName.get(declared.name) ?? [];
+ bindings.push({ ...declared, scope: enclosingTypeScope(node) });
+ bindingsByName.set(declared.name, bindings);
+ if (declared.alias !== null) aliases.push(declared.alias);
+ }
+
+ // SAFETY: Oxlint's visitor keys identify only ESTree child-node properties.
+ const fields = node as unknown as Readonly>;
+ for (const key of visitorKeys[node.type] ?? []) {
+ const value = fields[key];
+ if (isNode(value)) {
+ collectTypeBindings(value, visitorKeys, bindingsByName, aliases);
+ continue;
+ }
+ if (!Array.isArray(value)) continue;
+ for (const child of value) {
+ if (isNode(child)) {
+ collectTypeBindings(child, visitorKeys, bindingsByName, aliases);
+ }
+ }
+ }
+}
+
+/** Collect every lexical type alias and competing type binding in a program. */
+export function createTypeAliasEnvironment(
+ program: ESTree.Program,
+ visitorKeys: VisitorKeys,
+): TypeAliasEnvironment {
+ const cached = environmentsByProgram.get(program);
+ if (cached !== undefined) return cached;
+ const bindingsByName = new Map();
+ const aliases: ESTree.TSTypeAliasDeclaration[] = [];
+ collectTypeBindings(program, visitorKeys, bindingsByName, aliases);
+ const environment = { aliases, bindingsByName, visitorKeys };
+ environmentsByProgram.set(program, environment);
+ return environment;
+}
+
+function ancestorDistance(ancestor: ESTree.Node, node: ESTree.Node): number | null {
+ let current: ESTree.Node | null = node;
+ let distance = 0;
+ while (current !== null) {
+ if (current === ancestor) return distance;
+ current = current.parent;
+ distance += 1;
+ }
+ return null;
+}
+
+function nearestTypeBindings(
+ name: string,
+ use: ESTree.Node,
+ environment: TypeAliasEnvironment,
+): readonly TypeBinding[] {
+ const candidates = environment.bindingsByName.get(name) ?? [];
+ let nearestDistance = Number.POSITIVE_INFINITY;
+ let nearest: TypeBinding[] = [];
+ for (const candidate of candidates) {
+ const distance = ancestorDistance(candidate.scope, use);
+ if (distance === null || distance > nearestDistance) continue;
+ if (distance === nearestDistance) {
+ nearest.push(candidate);
+ continue;
+ }
+ nearestDistance = distance;
+ nearest = [candidate];
+ }
+ return nearest;
+}
+
+/** Resolve the nearest visible alias with this name, respecting lexical shadowing. */
+export function visibleTypeAlias(
+ name: string,
+ use: ESTree.Node,
+ environment: TypeAliasEnvironment,
+): ESTree.TSTypeAliasDeclaration | null {
+ if (lexicalTypeParameterNames(use, environment.visitorKeys).has(name)) return null;
+ const bindings = nearestTypeBindings(name, use, environment);
+ return bindings.length === 1 ? (bindings[0]?.alias ?? null) : null;
+}
+
+/** Return whether a local declaration shadows a built-in type at this use. */
+export function hasVisibleTypeBinding(
+ name: string,
+ use: ESTree.Node,
+ environment: TypeAliasEnvironment,
+): boolean {
+ return (
+ lexicalTypeParameterNames(use, environment.visitorKeys).has(name) ||
+ nearestTypeBindings(name, use, environment).length > 0
+ );
+}
+
+function typeReferenceName(type: ESTree.TSTypeReference): string | null {
+ return type.typeName.type === "Identifier" ? type.typeName.name : null;
+}
+
+function aliasSubstitutions(
+ alias: ESTree.TSTypeAliasDeclaration,
+ reference: ESTree.TSTypeReference,
+ base: Substitutions,
+): Substitutions | null {
+ const parameters = alias.typeParameters?.params ?? [];
+ const arguments_ = reference.typeArguments?.params ?? [];
+ const next = new Map(base);
+ for (const [index, parameter] of parameters.entries()) {
+ const explicitArgument = arguments_[index];
+ const argument = explicitArgument ?? parameter.default;
+ if (argument === null || argument === undefined) return null;
+ const argumentSubstitutions = explicitArgument === undefined ? next : base;
+ next.set(parameter.name.name, {
+ type: argument,
+ substitutions: new Map(argumentSubstitutions),
+ });
+ }
+ return next;
+}
+
+/** Match a type after resolving visible aliases and substituting their type parameters. */
+export function resolvedTypeMatches(
+ type: ESTree.TSType,
+ environment: TypeAliasEnvironment,
+ matcher: ResolvedTypeMatcher,
+): boolean {
+ const evaluate = (
+ current: ESTree.TSType,
+ substitutions: Substitutions,
+ resolvingAliases: ReadonlySet,
+ ): boolean => {
+ if (current.type === "TSTypeReference") {
+ const name = typeReferenceName(current);
+ if (name !== null) {
+ const substitution = substitutions.get(name);
+ if (substitution !== undefined && !current.typeArguments?.params.length) {
+ return evaluate(
+ substitution.type,
+ substitution.substitutions,
+ resolvingAliases,
+ );
+ }
+ const alias = visibleTypeAlias(name, current, environment);
+ if (alias !== null && !resolvingAliases.has(alias)) {
+ const nextSubstitutions = aliasSubstitutions(alias, current, substitutions);
+ if (nextSubstitutions !== null) {
+ const nextResolving = new Set(resolvingAliases);
+ nextResolving.add(alias);
+ return evaluate(alias.typeAnnotation, nextSubstitutions, nextResolving);
+ }
+ }
+ }
+ }
+ return matcher(current, (child) =>
+ evaluate(child, substitutions, resolvingAliases),
+ );
+ };
+
+ return evaluate(type, new Map(), new Set());
+}
diff --git a/tools/oxlint/anti-slop/vendor/eslint-stylistic/LICENSE b/tools/oxlint/anti-slop/vendor/eslint-stylistic/LICENSE
new file mode 100644
index 0000000..38dbc35
--- /dev/null
+++ b/tools/oxlint/anti-slop/vendor/eslint-stylistic/LICENSE
@@ -0,0 +1,22 @@
+MIT License
+
+Copyright OpenJS Foundation and other contributors,
+Copyright (c) 2023-PRESENT ESLint Stylistic contributors
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/tools/oxlint/anti-slop/vendor/eslint-stylistic/UPSTREAM.md b/tools/oxlint/anti-slop/vendor/eslint-stylistic/UPSTREAM.md
new file mode 100644
index 0000000..3040694
--- /dev/null
+++ b/tools/oxlint/anti-slop/vendor/eslint-stylistic/UPSTREAM.md
@@ -0,0 +1,28 @@
+# Vendored padding-line-between-statements
+
+Source: [ESLint Stylistic](https://github.com/eslint-stylistic/eslint-stylistic), commit `435c3ea0fd26a5fef9042c4b36b6e165fbbf8d08`.
+
+Copied files:
+
+- `packages/eslint-plugin/rules/padding-line-between-statements/padding-line-between-statements.ts`
+- `packages/eslint-plugin/rules/padding-line-between-statements/types.d.ts` → `padding-line-options.d.ts`
+- Root `LICENSE`, retained verbatim. Both OpenJS Foundation and ESLint Stylistic notices apply.
+
+The rule is MIT-licensed. Keep `LICENSE` with every redistributed copy, including skill assets. No Stylistic, ESLint, TypeScript-ESLint, or additional parser runtime dependency is required.
+
+## Local adaptations
+
+- Replace upstream type aliases with Oxlint's ESTree, context, token/comment, and rule types. Upstream's token type includes comments; Oxlint exposes those separately.
+- Replace `AST_NODE_TYPES` enum members with identical string literals.
+- Guard indexed reads for consuming repositories with `noUncheckedIndexedAccess`. Impossible missing AST/configuration entries raise explicit invariant errors rather than introducing new non-null assertions.
+- Replace the repository-specific `createRule` factory with `createPaddingLineRule(options)`. The anti-slop wrapper supplies typed options directly; it exposes no user configuration options.
+- Implement the small required AST helper surface in `padding-line-ast.ts` using Oxlint's public source-code/token API. `isParenthesized` only needs the one-pair check used to exclude parenthesized directive strings, not the upstream general-purpose overloads.
+- Retain the upstream statement matchers, scope tracking, comment-aware insertion/removal, selector support, and diagnostic text. Upstream naming and non-null assumptions remain localized here to keep future diffs reviewable; the file is not a model for new application code.
+
+The opinionated policy lives outside this directory in `../../rules/require-readable-spacing.ts`. It adds spacing without collapsing existing blank lines. Short local bindings, consecutive imports, and adjacent overload signatures/implementation remain grouped. Spacing is syntactic, not an inference of business-logic boundaries.
+
+## Updating and verification
+
+Fetch an explicit upstream revision, compare the original rule and types against this revision, and port relevant fixes while retaining the adapters above. Update this record and preserve the license. Run `pnpm check` and `pnpm sync:skill-assets` as required by repository guidance.
+
+Focused Oxlint RuleTester cases live in `../../rules/require-readable-spacing.test.ts`; they test exact fixes, JSDoc/trailing comments, same-line statements, semicolon-free code, TypeScript exports/overloads, Effect-style generators, and upstream removal behavior. `../../rules/require-readable-spacing-cli.test.ts` verifies the exported plugin through the native Oxlint CLI on multiple files, including rejection, autofix, and repeated-fix stability. The complete upstream JS/TS test suites have not been ported; this is focused compatibility evidence, not a claim of full upstream conformance.
diff --git a/tools/oxlint/anti-slop/vendor/eslint-stylistic/padding-line-ast.ts b/tools/oxlint/anti-slop/vendor/eslint-stylistic/padding-line-ast.ts
new file mode 100644
index 0000000..10f34cc
--- /dev/null
+++ b/tools/oxlint/anti-slop/vendor/eslint-stylistic/padding-line-ast.ts
@@ -0,0 +1,51 @@
+// Local replacements for the upstream helper imports. See UPSTREAM.md.
+import type { ESTree, SourceCode, Token as SyntaxToken, Comment, Location } from "@oxlint/plugins";
+
+type Token = SyntaxToken | Comment;
+
+/** Line terminators recognized by the upstream padding matcher. */
+export const LINEBREAKS = new Set(["\r\n", "\r", "\n", "\u2028", "\u2029"]);
+
+/** Test a closing brace without treating comment text as punctuation. */
+export const isClosingBraceToken = (token: Token): boolean =>
+ token.type === "Punctuator" && token.value === "}";
+
+/** Test a semicolon without treating comment text as punctuation. */
+export const isSemicolonToken = (token: Token): boolean =>
+ token.type === "Punctuator" && token.value === ";";
+
+/** Filter the optional final semicolon when identifying block-like statements. */
+export const isNotSemicolonToken = (token: Token): boolean => !isSemicolonToken(token);
+
+/** Compare token/node boundaries, including attached comments. */
+export const isTokenOnSameLine = (left: { loc: Location }, right: { loc: Location }): boolean =>
+ left.loc.end.line === right.loc.start.line;
+
+/** Recognize declarations and expressions used by the upstream IIFE matcher. */
+export const isFunction = (node: ESTree.Node): boolean =>
+ node.type === "FunctionDeclaration" ||
+ node.type === "FunctionExpression" ||
+ node.type === "ArrowFunctionExpression";
+
+/** Preserve the upstream multiline statement heuristic. */
+export const isSingleLine = (node: ESTree.Node): boolean =>
+ node.loc.start.line === node.loc.end.line;
+
+/** Unwrap optional chaining before checking IIFE syntax. */
+export const skipChainExpression = (node: ESTree.Node): ESTree.Node =>
+ node.type === "ChainExpression" ? node.expression : node;
+
+/** Only a program or function-body expression can begin a directive prologue. */
+export const isTopLevelExpressionStatement = (
+ node: ESTree.Node,
+): node is ESTree.ExpressionStatement =>
+ node.type === "ExpressionStatement" &&
+ (node.parent.type === "Program" ||
+ (node.parent.type === "BlockStatement" && isFunction(node.parent.parent)));
+
+/** A single wrapping pair suffices to exclude a string from directive syntax. */
+export function isParenthesized(node: ESTree.Node, sourceCode: SourceCode): boolean {
+ const before = sourceCode.getTokenBefore(node);
+ const after = sourceCode.getTokenAfter(node);
+ return before?.value === "(" && after?.value === ")";
+}
diff --git a/tools/oxlint/anti-slop/vendor/eslint-stylistic/padding-line-between-statements.ts b/tools/oxlint/anti-slop/vendor/eslint-stylistic/padding-line-between-statements.ts
new file mode 100644
index 0000000..c402db1
--- /dev/null
+++ b/tools/oxlint/anti-slop/vendor/eslint-stylistic/padding-line-between-statements.ts
@@ -0,0 +1,906 @@
+// Vendored from ESLint Stylistic; see UPSTREAM.md and LICENSE in this directory.
+import type { ESTree, Context as RuleContext, SourceCode, Token as SyntaxToken, Comment, CreateRule, Location } from '@oxlint/plugins'
+type ASTNode = ESTree.Node
+type Token = SyntaxToken | Comment
+import type {
+ RuleOptions,
+ SelectorOption,
+ StatementOption,
+} from './padding-line-options.d.ts'
+import {
+ isClosingBraceToken,
+ isFunction,
+ isNotSemicolonToken,
+ isParenthesized,
+ isSemicolonToken,
+ isSingleLine,
+ isTokenOnSameLine,
+ isTopLevelExpressionStatement,
+ LINEBREAKS,
+ skipChainExpression,
+} from './padding-line-ast.ts'
+
+const CJS_EXPORT = /^(?:module\s*\.\s*)?exports(?:\s*\.|\s*\[|$)/u
+const CJS_IMPORT = /^require\(/u
+
+/**
+ * This rule is a replica of padding-line-between-statements.
+ *
+ * Ideally we would want to extend the rule support typescript specific support.
+ * But since not all the state is exposed by the eslint and eslint has frozen stylistic rules,
+ * (see - https://eslint.org/blog/2020/05/changes-to-rules-policies for details.)
+ * we are forced to re-implement the rule here.
+ *
+ * We have tried to keep the implementation as close as possible to the eslint implementation, to make
+ * patching easier for future contributors.
+ *
+ * Reference rule - https://github.com/eslint/eslint/blob/main/lib/rules/padding-line-between-statements.js
+ */
+
+type NodeTest = (
+ node: ASTNode,
+ sourceCode: SourceCode,
+) => boolean
+
+interface NodeTestObject {
+ test: NodeTest
+}
+
+const LT = `[${Array.from(LINEBREAKS).join('')}]`
+const PADDING_LINE_SEQUENCE = new RegExp(
+ String.raw`^(\s*?${LT})\s*${LT}(\s*;?)$`,
+ 'u',
+)
+
+function isSelectorOption(option: StatementOption): option is SelectorOption {
+ return typeof option === 'object' && !Array.isArray(option)
+}
+
+/**
+ * Creates tester which check if a node starts with specific keyword with the
+ * appropriate AST_NODE_TYPES.
+ * @param keyword The keyword to test.
+ * @returns the created tester.
+ * @private
+ */
+function newKeywordTester(
+ type: string | string[],
+ keyword: string,
+): NodeTestObject {
+ return {
+ test(node, sourceCode): boolean {
+ const isSameKeyword = sourceCode.getFirstToken(node)?.value === keyword
+ const isSameType = Array.isArray(type)
+ ? type.includes(node.type)
+ : type === node.type
+
+ return isSameKeyword && isSameType
+ },
+ }
+}
+
+/**
+ * Creates tester which check if a node is specific type.
+ * @param type The node type to test.
+ * @returns the created tester.
+ * @private
+ */
+function newNodeTypeTester(type: string): NodeTestObject {
+ return {
+ test: (node): boolean => node.type === type,
+ }
+}
+
+/**
+ * Checks the given node is an expression statement of IIFE.
+ * @param node The node to check.
+ * @returns `true` if the node is an expression statement of IIFE.
+ * @private
+ */
+function isIIFEStatement(node: ASTNode): boolean {
+ if (node.type === 'ExpressionStatement') {
+ let expression = skipChainExpression(node.expression)
+ if (expression.type === 'UnaryExpression')
+ expression = skipChainExpression(expression.argument)
+
+ if (expression.type === 'CallExpression') {
+ let node: ASTNode = expression.callee
+ while (node.type === 'SequenceExpression') {
+ const lastExpression = node.expressions.at(-1)
+ if (lastExpression === undefined)
+ throw new Error('Padding rule invariant: sequence expression is empty')
+ node = lastExpression
+ }
+
+ return isFunction(node)
+ }
+ }
+ return false
+}
+
+/**
+ * Checks the given node is a CommonJS require statement
+ * @param node The node to check.
+ * @returns `true` if the node is a CommonJS require statement.
+ * @private
+ */
+function isCJSRequire(node: ASTNode): boolean {
+ if (node.type === 'VariableDeclaration') {
+ const declaration = node.declarations[0]
+ if (declaration?.init) {
+ let call = declaration?.init
+ while (call.type === 'MemberExpression')
+ call = call.object
+
+ if (
+ call.type === 'CallExpression'
+ && call.callee.type === 'Identifier'
+ ) {
+ return call.callee.name === 'require'
+ }
+ }
+ }
+ return false
+}
+
+/**
+ * Checks whether the given node is a block-like statement.
+ * This checks the last token of the node is the closing brace of a block.
+ * @param sourceCode The source code to get tokens.
+ * @param node The node to check.
+ * @returns `true` if the node is a block-like statement.
+ * @private
+ */
+function isBlockLikeStatement(
+ node: ASTNode,
+ sourceCode: SourceCode,
+): boolean {
+ // do-while with a block is a block-like statement.
+ if (
+ node.type === 'DoWhileStatement'
+ && node.body.type === 'BlockStatement'
+ ) {
+ return true
+ }
+
+ /**
+ * IIFE is a block-like statement specially from
+ * JSCS#disallowPaddingNewLinesAfterBlocks.
+ */
+ if (isIIFEStatement(node))
+ return true
+
+ // Checks the last token is a closing brace of blocks.
+ const lastToken = sourceCode.getLastToken(node, isNotSemicolonToken)
+ const belongingNode
+ = lastToken && isClosingBraceToken(lastToken)
+ ? sourceCode.getNodeByRangeIndex(lastToken.range[0])
+ : null
+
+ return (
+ !!belongingNode
+ && (belongingNode.type === 'BlockStatement'
+ || belongingNode.type === 'SwitchStatement')
+ )
+}
+
+/**
+ * Check whether the given node is a directive or not.
+ * @param node The node to check.
+ * @param sourceCode The source code object to get tokens.
+ * @returns `true` if the node is a directive.
+ */
+function isDirective(
+ node: ASTNode,
+ sourceCode: SourceCode,
+): boolean {
+ return (
+ isTopLevelExpressionStatement(node)
+ && node.expression.type === 'Literal'
+ && typeof node.expression.value === 'string'
+ && !isParenthesized(node.expression, sourceCode)
+ )
+}
+
+/**
+ * Check whether the given node is a part of directive prologue or not.
+ * @param node The node to check.
+ * @param sourceCode The source code object to get tokens.
+ * @returns `true` if the node is a part of directive prologue.
+ */
+function isDirectivePrologue(
+ node: ASTNode,
+ sourceCode: SourceCode,
+): boolean {
+ if (
+ isDirective(node, sourceCode)
+ && node.parent
+ && 'body' in node.parent
+ && Array.isArray(node.parent.body)
+ ) {
+ for (const sibling of node.parent.body) {
+ if (sibling === node)
+ break
+
+ if (!isDirective(sibling, sourceCode))
+ return false
+ }
+ return true
+ }
+ return false
+}
+
+/**
+ * Checks the given node is a CommonJS export statement
+ * @param node The node to check.
+ * @returns `true` if the node is a CommonJS export statement.
+ * @private
+ */
+function isCJSExport(node: ASTNode): boolean {
+ if (node.type === 'ExpressionStatement') {
+ const expression = node.expression
+ if (expression.type === 'AssignmentExpression') {
+ let left = expression.left
+ if (left.type === 'MemberExpression') {
+ while (left.object.type === 'MemberExpression')
+ left = left.object
+
+ return (
+ left.object.type === 'Identifier'
+ && (left.object.name === 'exports'
+ || (left.object.name === 'module'
+ && left.property.type === 'Identifier'
+ && left.property.name === 'exports'))
+ )
+ }
+ }
+ }
+ return false
+}
+
+/**
+ * Check whether the given node is an expression
+ * @param node The node to check.
+ * @param sourceCode The source code object to get tokens.
+ * @returns `true` if the node is an expression
+ */
+function isExpression(
+ node: ASTNode,
+ sourceCode: SourceCode,
+): boolean {
+ return (
+ node.type === 'ExpressionStatement'
+ && !isDirectivePrologue(node, sourceCode)
+ )
+}
+
+/**
+ * Gets the actual last token.
+ *
+ * If a semicolon is semicolon-less style's semicolon, this ignores it.
+ * For example:
+ *
+ * foo()
+ * ;[1, 2, 3].forEach(bar)
+ * @param sourceCode The source code to get tokens.
+ * @param node The node to get.
+ * @returns The actual last token.
+ * @private
+ */
+function getActualLastToken(
+ node: ASTNode,
+ sourceCode: SourceCode,
+): Token | null {
+ const semiToken = sourceCode.getLastToken(node)!
+ const prevToken = sourceCode.getTokenBefore(semiToken)
+ const nextToken = sourceCode.getTokenAfter(semiToken)
+ const isSemicolonLessStyle
+ = prevToken
+ && nextToken
+ && prevToken.range[0] >= node.range[0]
+ && isSemicolonToken(semiToken)
+ && !isTokenOnSameLine(prevToken, semiToken)
+ && isTokenOnSameLine(semiToken, nextToken)
+
+ return isSemicolonLessStyle ? prevToken : semiToken
+}
+
+/**
+ * This returns the concatenation of the first 2 captured strings.
+ * @param _ Unused. Whole matched string.
+ * @param trailingSpaces The trailing spaces of the first line.
+ * @param indentSpaces The indentation spaces of the last line.
+ * @returns The concatenation of trailingSpaces and indentSpaces.
+ * @private
+ */
+function replacerToRemovePaddingLines(
+ _: string,
+ trailingSpaces: string,
+ indentSpaces: string,
+): string {
+ return trailingSpaces + indentSpaces
+}
+
+function getReportLoc(node: ASTNode, sourceCode: SourceCode): Location {
+ if (isSingleLine(node))
+ return node.loc
+
+ const line = node.loc.start.line
+ const sourceLine = sourceCode.lines[line - 1]
+ if (sourceLine === undefined)
+ throw new Error('Padding rule invariant: statement source line is missing')
+
+ return {
+ start: node.loc.start,
+ end: {
+ line,
+ column: sourceLine.length,
+ },
+ }
+}
+
+/**
+ * Check and report statements for `any` configuration.
+ * It does nothing.
+ *
+ * @private
+ */
+function verifyForAny(): void {
+ // Empty
+}
+
+/**
+ * Check and report statements for `never` configuration.
+ * This autofix removes blank lines between the given 2 statements.
+ * However, if comments exist between 2 blank lines, it does not remove those
+ * blank lines automatically.
+ * @param context The rule context to report.
+ * @param _ Unused. The previous node to check.
+ * @param nextNode The next node to check.
+ * @param paddingLines The array of token pairs that blank
+ * lines exist between the pair.
+ *
+ * @private
+ */
+function verifyForNever(
+ context: RuleContext,
+ _: ASTNode,
+ nextNode: ASTNode,
+ paddingLines: [Token, Token][],
+): void {
+ if (paddingLines.length === 0)
+ return
+
+ context.report({
+ node: nextNode,
+ messageId: 'unexpectedBlankLine',
+ loc: getReportLoc(nextNode, context.sourceCode),
+ fix(fixer) {
+ if (paddingLines.length >= 2)
+ return null
+
+ const paddingPair = paddingLines[0]
+ if (paddingPair === undefined)
+ throw new Error('Padding rule invariant: reported padding pair is missing')
+ const [prevToken, nextToken] = paddingPair
+ const start = prevToken.range[1]
+ const end = nextToken.range[0]
+ const text = context
+ .sourceCode
+ .text
+ .slice(start, end)
+ .replace(PADDING_LINE_SEQUENCE, replacerToRemovePaddingLines)
+
+ return fixer.replaceTextRange([start, end], text)
+ },
+ })
+}
+
+/**
+ * Check and report statements for `always` configuration.
+ * This autofix inserts a blank line between the given 2 statements.
+ * If the `prevNode` has trailing comments, it inserts a blank line after the
+ * trailing comments.
+ * @param context The rule context to report.
+ * @param prevNode The previous node to check.
+ * @param nextNode The next node to check.
+ * @param paddingLines The array of token pairs that blank
+ * lines exist between the pair.
+ *
+ * @private
+ */
+function verifyForAlways(
+ context: RuleContext,
+ prevNode: ASTNode,
+ nextNode: ASTNode,
+ paddingLines: [Token, Token][],
+): void {
+ if (paddingLines.length > 0)
+ return
+
+ context.report({
+ node: nextNode,
+ messageId: 'expectedBlankLine',
+ loc: getReportLoc(nextNode, context.sourceCode),
+ fix(fixer) {
+ const sourceCode = context.sourceCode
+ let prevToken = getActualLastToken(prevNode, sourceCode)!
+ const nextToken
+ = sourceCode.getFirstTokenBetween(prevToken, nextNode, {
+ includeComments: true,
+
+ /**
+ * Skip the trailing comments of the previous node.
+ * This inserts a blank line after the last trailing comment.
+ *
+ * For example:
+ *
+ * foo(); // trailing comment.
+ * // comment.
+ * bar();
+ *
+ * Get fixed to:
+ *
+ * foo(); // trailing comment.
+ *
+ * // comment.
+ * bar();
+ * @param token The token to check.
+ * @returns `true` if the token is not a trailing comment.
+ * @private
+ */
+ filter(token) {
+ if (isTokenOnSameLine(prevToken, token)) {
+ prevToken = token
+ return false
+ }
+ return true
+ },
+ })! || nextNode
+ const insertText = isTokenOnSameLine(prevToken, nextToken)
+ ? '\n\n'
+ : '\n'
+
+ return fixer.insertTextAfter(prevToken, insertText)
+ },
+ })
+}
+
+/**
+ * Types of blank lines.
+ * `any`, `never`, and `always` are defined.
+ * Those have `verify` method to check and report statements.
+ * @private
+ */
+const PaddingTypes = {
+ any: { verify: verifyForAny },
+ never: { verify: verifyForNever },
+ always: { verify: verifyForAlways },
+}
+
+const MaybeMultilineStatementType: Record = {
+ 'block-like': { test: isBlockLikeStatement },
+ 'expression': { test: isExpression },
+ 'return': newKeywordTester('ReturnStatement', 'return'),
+ 'export': newKeywordTester(
+ [
+ 'ExportAllDeclaration',
+ 'ExportDefaultDeclaration',
+ 'ExportNamedDeclaration',
+ ],
+ 'export',
+ ),
+ 'var': newKeywordTester('VariableDeclaration', 'var'),
+ 'let': newKeywordTester('VariableDeclaration', 'let'),
+ 'const': newKeywordTester('VariableDeclaration', 'const'),
+ 'using': {
+ test: node => node.type === 'VariableDeclaration'
+ && (node.kind === 'using' || node.kind === 'await using'),
+ },
+ 'type': newKeywordTester('TSTypeAliasDeclaration', 'type'),
+}
+
+/**
+ * Types of statements.
+ * Those have `test` method to check it matches to the given statement.
+ * @private
+ */
+const StatementTypes: Record = {
+ '*': { test: (): boolean => true },
+ 'exports': { test: isCJSExport },
+ 'require': { test: isCJSRequire },
+ 'directive': { test: isDirectivePrologue },
+ 'iife': { test: isIIFEStatement },
+
+ 'block': newNodeTypeTester('BlockStatement'),
+ 'empty': newNodeTypeTester('EmptyStatement'),
+ 'function': newNodeTypeTester('FunctionDeclaration'),
+ 'ts-method': newNodeTypeTester('TSMethodSignature'),
+
+ 'break': newKeywordTester('BreakStatement', 'break'),
+ 'case': newKeywordTester('SwitchCase', 'case'),
+ 'class': newKeywordTester('ClassDeclaration', 'class'),
+ 'continue': newKeywordTester('ContinueStatement', 'continue'),
+ 'debugger': newKeywordTester('DebuggerStatement', 'debugger'),
+ 'default': newKeywordTester(
+ ['SwitchCase', 'ExportDefaultDeclaration'],
+ 'default',
+ ),
+ 'do': newKeywordTester('DoWhileStatement', 'do'),
+ 'for': newKeywordTester(
+ [
+ 'ForStatement',
+ 'ForInStatement',
+ 'ForOfStatement',
+ ],
+ 'for',
+ ),
+ 'if': newKeywordTester('IfStatement', 'if'),
+ 'import': newKeywordTester('ImportDeclaration', 'import'),
+ 'switch': newKeywordTester('SwitchStatement', 'switch'),
+ 'throw': newKeywordTester('ThrowStatement', 'throw'),
+ 'try': newKeywordTester('TryStatement', 'try'),
+ 'while': newKeywordTester(
+ ['WhileStatement', 'DoWhileStatement'],
+ 'while',
+ ),
+ 'with': newKeywordTester('WithStatement', 'with'),
+
+ 'cjs-export': {
+ test: (node, sourceCode) => node.type === 'ExpressionStatement'
+ && node.expression.type === 'AssignmentExpression'
+ && CJS_EXPORT.test(sourceCode.getText(node.expression.left)),
+ },
+ 'cjs-import': {
+ test: (node, sourceCode) => node.type === 'VariableDeclaration'
+ && node.declarations.length > 0
+ && node.declarations[0]?.init != null
+ && CJS_IMPORT.test(sourceCode.getText(node.declarations[0].init)),
+ },
+
+ 'enum': newKeywordTester(
+ 'TSEnumDeclaration',
+ 'enum',
+ ),
+ 'interface': newKeywordTester(
+ 'TSInterfaceDeclaration',
+ 'interface',
+ ),
+ 'function-overload': newNodeTypeTester('TSDeclareFunction'),
+ ...Object.fromEntries(
+ Object.entries(MaybeMultilineStatementType)
+ .flatMap(([key, value]) => [
+ [key, value],
+ [
+ `singleline-${key}`,
+ {
+ ...value,
+ test: (node, sourceCode) => value.test(node, sourceCode) && isSingleLine(node),
+ },
+ ],
+ [
+ `multiline-${key}`,
+ {
+ ...value,
+ test: (node, sourceCode) => value.test(node, sourceCode) && !isSingleLine(node),
+ },
+ ],
+ ]),
+ ),
+}
+
+/** Build the vendored padding rule with caller-owned, typed policy options. */
+export default function createPaddingLineRule(options: RuleOptions): CreateRule {
+return {
+ meta: {
+ type: 'layout',
+ docs: {
+ description: 'Require or disallow padding lines between statements',
+ },
+ fixable: 'whitespace',
+ hasSuggestions: false,
+ // This is intentionally an array schema as you can pass 0..n config objects
+ schema: {
+ $defs: {
+ paddingType: {
+ type: 'string',
+ enum: Object.keys(PaddingTypes),
+ },
+ statementType: {
+ type: 'string',
+ enum: Object.keys(StatementTypes),
+ },
+ selectorOption: {
+ type: 'object',
+ properties: {
+ selector: {
+ type: 'string',
+ },
+ lineMode: {
+ type: 'string',
+ enum: ['any', 'singleline', 'multiline'],
+ },
+ },
+ required: ['selector'],
+ additionalProperties: false,
+ },
+ statementMatcher: {
+ anyOf: [
+ { $ref: '#/$defs/statementType' },
+ { $ref: '#/$defs/selectorOption' },
+ ],
+ },
+ statementOption: {
+ anyOf: [
+ { $ref: '#/$defs/statementMatcher' },
+ {
+ type: 'array',
+ items: { $ref: '#/$defs/statementMatcher' },
+ minItems: 1,
+ uniqueItems: true,
+ additionalItems: false,
+ },
+ ],
+ },
+ },
+ type: 'array',
+ additionalItems: false,
+ items: {
+ type: 'object',
+ properties: {
+ blankLine: { $ref: '#/$defs/paddingType' },
+ prev: { $ref: '#/$defs/statementOption' },
+ next: { $ref: '#/$defs/statementOption' },
+ },
+ additionalProperties: false,
+ required: ['blankLine', 'prev', 'next'],
+ },
+ },
+ messages: {
+ unexpectedBlankLine: 'Unexpected blank line before this statement.',
+ expectedBlankLine: 'Expected blank line before this statement.',
+ },
+ },
+ create(context) {
+ const sourceCode = context.sourceCode
+
+ const selectorMatchedNodes = new Map>()
+ const pendingPairs: { prevNode: ASTNode, nextNode: ASTNode }[] = []
+
+ function collectSelectorOption(option: StatementOption): void {
+ if (Array.isArray(option)) {
+ for (const item of option)
+ collectSelectorOption(item)
+ return
+ }
+
+ if (!isSelectorOption(option))
+ return
+
+ selectorMatchedNodes.set(option.selector, new Set())
+ }
+
+ for (const configure of options) {
+ collectSelectorOption(configure.prev)
+ collectSelectorOption(configure.next)
+ }
+
+ type Scope = {
+ upper: Scope
+ prevNode: ASTNode | null
+ } | null
+
+ let scopeInfo: Scope = null
+
+ /**
+ * Processes to enter to new scope.
+ * This manages the current previous statement.
+ *
+ * @private
+ */
+ function enterScope(): void {
+ scopeInfo = {
+ upper: scopeInfo,
+ prevNode: null,
+ }
+ }
+
+ /**
+ * Processes to exit from the current scope.
+ *
+ * @private
+ */
+ function exitScope(): void {
+ if (scopeInfo)
+ scopeInfo = scopeInfo.upper
+ }
+
+ /**
+ * Checks whether the given node matches the given type.
+ * @param node The statement node to check.
+ * @param type The statement type to check.
+ * @returns `true` if the statement node matched the type.
+ * @private
+ */
+ function match(node: ASTNode, type: StatementOption): boolean {
+ let innerStatementNode = node
+
+ while (innerStatementNode.type === 'LabeledStatement')
+ innerStatementNode = innerStatementNode.body
+
+ if (Array.isArray(type))
+ return type.some(match.bind(null, innerStatementNode))
+
+ if (isSelectorOption(type)) {
+ const matchedNodes = selectorMatchedNodes.get(type.selector)
+ if (!matchedNodes?.has(innerStatementNode))
+ return false
+
+ const lineMode = type.lineMode
+
+ if (lineMode === 'singleline')
+ return isSingleLine(innerStatementNode)
+ else if (lineMode === 'multiline')
+ return !isSingleLine(innerStatementNode)
+
+ return true
+ }
+ else {
+ const statementType = StatementTypes[type]
+ if (statementType === undefined)
+ throw new Error(`Padding rule invariant: unsupported statement type ${type}`)
+ return statementType.test(innerStatementNode, sourceCode)
+ }
+ }
+
+ /**
+ * Finds the last matched configure from options.
+ * @param prevNode The previous statement to match.
+ * @param nextNode The current statement to match.
+ * @returns The tester of the last matched configure.
+ * @private
+ */
+ function getPaddingType(
+ prevNode: ASTNode,
+ nextNode: ASTNode,
+ ): (typeof PaddingTypes)[keyof typeof PaddingTypes] {
+ for (let i = options.length - 1; i >= 0; --i) {
+ const configure = options[i]
+ if (configure === undefined)
+ throw new Error('Padding rule invariant: configuration entry is missing')
+ if (
+ match(prevNode, configure.prev)
+ && match(nextNode, configure.next)
+ ) {
+ return PaddingTypes[configure.blankLine]
+ }
+ }
+ return PaddingTypes.any
+ }
+
+ /**
+ * Gets padding line sequences between the given 2 statements.
+ * Comments are separators of the padding line sequences.
+ * @param prevNode The previous statement to count.
+ * @param nextNode The current statement to count.
+ * @returns The array of token pairs.
+ * @private
+ */
+ function getPaddingLineSequences(
+ prevNode: ASTNode,
+ nextNode: ASTNode,
+ ): [Token, Token][] {
+ const pairs: [Token, Token][] = []
+ let prevToken: Token = getActualLastToken(prevNode, sourceCode)!
+
+ if (nextNode.loc.start.line - prevToken.loc.end.line >= 2) {
+ do {
+ const token: Token = sourceCode.getTokenAfter(prevToken, {
+ includeComments: true,
+ })!
+
+ if (token.loc.start.line - prevToken.loc.end.line >= 2)
+ pairs.push([prevToken, token])
+
+ prevToken = token
+ } while (prevToken.range[0] < nextNode.range[0])
+ }
+
+ return pairs
+ }
+
+ /**
+ * Verify padding lines between the given node and the previous node.
+ * @param node The node to verify.
+ *
+ * @private
+ */
+ function verify(node: ASTNode): void {
+ if (
+ !node.parent
+ || ![
+ 'BlockStatement',
+ 'Program',
+ 'StaticBlock',
+ 'SwitchCase',
+ 'SwitchStatement',
+ 'TSInterfaceBody',
+ 'TSModuleBlock',
+ 'TSTypeLiteral',
+ ].includes(node.parent.type)
+ ) {
+ return
+ }
+
+ // Save this node as the current previous statement.
+ const prevNode = scopeInfo!.prevNode
+
+ // Verify.
+ if (prevNode)
+ pendingPairs.push({ prevNode, nextNode: node })
+
+ scopeInfo!.prevNode = node
+ }
+
+ function verifyPendingPairs(): void {
+ for (const { prevNode, nextNode } of pendingPairs) {
+ const type = getPaddingType(prevNode, nextNode)
+ const paddingLines = getPaddingLineSequences(prevNode, nextNode)
+
+ type.verify(context, prevNode, nextNode, paddingLines)
+ }
+ }
+
+ /**
+ * Verify padding lines between the given node and the previous node.
+ * Then process to enter to new scope.
+ * @param node The node to verify.
+ *
+ * @private
+ */
+ function verifyThenEnterScope(node: ASTNode): void {
+ verify(node)
+ enterScope()
+ }
+
+ const selectorMatchListeners = Object.fromEntries(
+ Array.from(selectorMatchedNodes.keys(), selector => [
+ selector,
+ (node: ASTNode): void => {
+ selectorMatchedNodes.get(selector)?.add(node)
+ },
+ ]),
+ )
+
+ return {
+ 'Program': enterScope,
+ 'Program:exit': () => {
+ verifyPendingPairs()
+ exitScope()
+ },
+ 'BlockStatement': enterScope,
+ 'BlockStatement:exit': exitScope,
+ 'SwitchStatement': enterScope,
+ 'SwitchStatement:exit': exitScope,
+ 'SwitchCase': verifyThenEnterScope,
+ 'SwitchCase:exit': exitScope,
+ 'StaticBlock': enterScope,
+ 'StaticBlock:exit': exitScope,
+
+ 'TSInterfaceBody': enterScope,
+ 'TSInterfaceBody:exit': exitScope,
+ 'TSModuleBlock': enterScope,
+ 'TSModuleBlock:exit': exitScope,
+ 'TSTypeLiteral': enterScope,
+ 'TSTypeLiteral:exit': exitScope,
+ 'TSDeclareFunction': verifyThenEnterScope,
+ 'TSDeclareFunction:exit': exitScope,
+ 'TSMethodSignature': verifyThenEnterScope,
+ 'TSMethodSignature:exit': exitScope,
+
+ ':statement': verify,
+ ...selectorMatchListeners,
+ }
+ },
+}
+}
diff --git a/tools/oxlint/anti-slop/vendor/eslint-stylistic/padding-line-options.d.ts b/tools/oxlint/anti-slop/vendor/eslint-stylistic/padding-line-options.d.ts
new file mode 100644
index 0000000..45068ec
--- /dev/null
+++ b/tools/oxlint/anti-slop/vendor/eslint-stylistic/padding-line-options.d.ts
@@ -0,0 +1,87 @@
+/* GENERATED, DO NOT EDIT DIRECTLY */
+
+/* @checksum: 3QCTtOH6rJM5_AGJ58rGpeEaBEfaJz17MSCxWB4X_PU */
+
+export type PaddingType = 'any' | 'never' | 'always'
+export type StatementOption =
+ | StatementMatcher
+ | [StatementMatcher, ...StatementMatcher[]]
+export type StatementMatcher =
+ | StatementType
+ | SelectorOption
+export type StatementType =
+ | '*'
+ | 'exports'
+ | 'require'
+ | 'directive'
+ | 'iife'
+ | 'block'
+ | 'empty'
+ | 'function'
+ | 'ts-method'
+ | 'break'
+ | 'case'
+ | 'class'
+ | 'continue'
+ | 'debugger'
+ | 'default'
+ | 'do'
+ | 'for'
+ | 'if'
+ | 'import'
+ | 'switch'
+ | 'throw'
+ | 'try'
+ | 'while'
+ | 'with'
+ | 'cjs-export'
+ | 'cjs-import'
+ | 'enum'
+ | 'interface'
+ | 'function-overload'
+ | 'block-like'
+ | 'singleline-block-like'
+ | 'multiline-block-like'
+ | 'expression'
+ | 'singleline-expression'
+ | 'multiline-expression'
+ | 'return'
+ | 'singleline-return'
+ | 'multiline-return'
+ | 'export'
+ | 'singleline-export'
+ | 'multiline-export'
+ | 'var'
+ | 'singleline-var'
+ | 'multiline-var'
+ | 'let'
+ | 'singleline-let'
+ | 'multiline-let'
+ | 'const'
+ | 'singleline-const'
+ | 'multiline-const'
+ | 'using'
+ | 'singleline-using'
+ | 'multiline-using'
+ | 'type'
+ | 'singleline-type'
+ | 'multiline-type'
+export type PaddingLineBetweenStatementsSchema0 = {
+ blankLine: PaddingType
+ prev: StatementOption
+ next: StatementOption
+}[]
+
+export interface SelectorOption {
+ selector: string
+ lineMode?: 'any' | 'singleline' | 'multiline'
+}
+
+export type PaddingLineBetweenStatementsRuleOptions
+ = PaddingLineBetweenStatementsSchema0
+
+export type RuleOptions
+ = PaddingLineBetweenStatementsRuleOptions
+export type MessageIds =
+ | 'unexpectedBlankLine'
+ | 'expectedBlankLine'
diff --git a/tps.test.ts b/tps.test.ts
index e916187..a0eeceb 100644
--- a/tps.test.ts
+++ b/tps.test.ts
@@ -18,6 +18,7 @@ const DELTA = "a".repeat(50)
describe("TpsTracker", () => {
test("estimates live tokens from accumulated utf-8 bytes", () => {
const tracker = new TpsTracker()
+
for (let i = 0; i < 10; i += 1) tracker.push("s", "€", 1000 + i * 10)
const value = tracker.value("s", 1090)
expect(value?.tokens).toBe(7) // 30 bytes / 4.75
@@ -416,11 +417,13 @@ describe("TpsTracker", () => {
test("caps how many finished runs it remembers, keeping the recent ones", () => {
const tracker = new TpsTracker()
+
// 65 sessions, each one run: the first started is the first forgotten.
for (let i = 0; i < 65; i += 1) {
tracker.push(`s${i}`, DELTA, i * 10)
tracker.finish(`s${i}`, i * 10 + 100)
}
+
expect(tracker.value("s0", 10_000)).toBeNull()
expect(tracker.value("s1", 10_000)?.frozen).toBe(true)
expect(tracker.value("s64", 10_000)?.frozen).toBe(true)
@@ -429,16 +432,19 @@ describe("TpsTracker", () => {
test("never evicts a streaming run to stay under the cap", () => {
const tracker = new TpsTracker()
tracker.push("live", DELTA, 0) // oldest entry, still running
+
for (let i = 0; i < 100; i += 1) {
tracker.push(`s${i}`, DELTA, 1000 + i * 10)
tracker.finish(`s${i}`, 1000 + i * 10 + 10)
}
+
expect(tracker.value("live", 1000)?.frozen).toBe(false)
expect(tracker.hasRunning(1000)).toBe(true)
})
test("re-applies the session cap when an oversized running set finishes", () => {
const tracker = new TpsTracker()
+
for (let i = 0; i < 65; i += 1) tracker.push(`s${i}`, DELTA, i)
expect(tracker.value("s0", 100)).not.toBeNull()
tracker.finish("s0", 100)
@@ -550,8 +556,10 @@ function createHarness(options: TpsOptionsInput = {}) {
// without leaving a live interval behind.
const handle = realSetInterval(() => {}, 60_000)
realClearInterval(handle)
+
return handle
}
+
globalThis.clearInterval = () => {
timer.cleared += 1
timer.callback = undefined
@@ -569,6 +577,7 @@ function createHarness(options: TpsOptionsInput = {}) {
const list = handlers.get(type) ?? []
list.push(handler)
handlers.set(type, list)
+
return () => handlers.delete(type)
},
},
@@ -579,6 +588,7 @@ function createHarness(options: TpsOptionsInput = {}) {
// neither, and the timer assertions fail loudly if that ever changes.
const started = setupWithFakeContext(ctx)
const cleanup = started instanceof Function ? started : () => {}
+
return {
timer,
subscribed: (type: string) => handlers.has(type),
@@ -601,6 +611,7 @@ function createHarness(options: TpsOptionsInput = {}) {
*/
function debugDirs(): string[] {
const own = `${DEBUG_DIR_PREFIX}${process.pid}`
+
return readdirSync(tmpdir())
.filter((entry) => entry === own || entry.startsWith(`${own}-`))
.map((entry) => join(tmpdir(), entry))
@@ -613,6 +624,7 @@ describe("plugin setup", () => {
test("subscribes to the events the tracker needs", () => {
const h = createHarness()
+
for (const type of [
"session.execution.started",
"session.text.delta",
@@ -636,6 +648,7 @@ describe("plugin setup", () => {
]) {
expect(h.subscribed(type)).toBe(true)
}
+
h.cleanup()
h.restore()
})
@@ -744,6 +757,7 @@ describe("plugin setup", () => {
describe("isEnvEnabled", () => {
test("accepts only explicit truthy spellings", () => {
for (const value of ["1", "true", "TRUE", " true "]) expect(isEnvEnabled(value)).toBe(true)
+
// A shell script exporting TPS_DEBUG=0 must not start writing to disk.
for (const value of [undefined, "", "0", "false", "no", "off"]) expect(isEnvEnabled(value)).toBe(false)
})
diff --git a/tps.tsx b/tps.tsx
index 7429f4d..165a8df 100644
--- a/tps.tsx
+++ b/tps.tsx
@@ -24,10 +24,13 @@ const debugState = { enabled: false, file: "" }
function isOwnPrivateDir(path: string): boolean {
try {
const stats = lstatSync(path) // lstat, not stat: a planted symlink must not pass
+
if (!stats.isDirectory()) return false
const uid = process.getuid?.()
+
// Windows has no uid and a per-user temp directory, so there is nothing to check.
if (uid === undefined) return true
+
return stats.uid === uid && (stats.mode & 0o777) === 0o700
} catch {
return false
@@ -41,18 +44,23 @@ function isOwnPrivateDir(path: string): boolean {
*/
function debugDir(): string {
const preferred = join(tmpdir(), `${DEBUG_DIR_PREFIX}${process.pid}`)
+
try {
mkdirSync(preferred, { mode: 0o700 })
+
return preferred
} catch {
if (isOwnPrivateDir(preferred)) return preferred
+
return mkdtempSync(`${preferred}-`)
}
}
function configureDebug(enabled: boolean): void {
debugState.enabled = enabled
+
if (!enabled || debugState.file) return
+
try {
debugState.file = join(debugDir(), "tps.log")
} catch {
@@ -64,15 +72,18 @@ function configureDebug(enabled: boolean): void {
export function isEnvEnabled(value: string | undefined): boolean {
if (value === undefined) return false
const normalized = value.trim().toLowerCase()
+
return normalized === "1" || normalized === "true"
}
function mark(line: string): void {
if (!debugState.enabled) return
+
try {
const safeLine = line.replace(/\p{Cc}/gu, (character) =>
`\\u${character.charCodeAt(0).toString(16).padStart(4, "0")}`,
)
+
appendFileSync(debugState.file, `${new Date().toISOString()} ${safeLine}\n`)
} catch {
// debug only; never break the host
@@ -92,9 +103,13 @@ export const DEFAULT_CONFIG: TpsConfig = {
// The frozen final average stays visible until the next prompt starts a new run.
const BYTES_PER_TOKEN_MIN = 1
+
const BYTES_PER_TOKEN_MAX = 16
+
const LIVE_WINDOW_MS = 5_000
+
const LIVE_STALE_MS = 1_500
+
const LIVE_MIN_DURATION_MS = 250
function estimateTokens(bytes: number, bytesPerToken: number): number {
@@ -103,7 +118,9 @@ function estimateTokens(bytes: number, bytesPerToken: number): number {
function formatTps(value: number): string {
if (value < 10) return value.toFixed(2)
+
if (value < 100) return value.toFixed(1)
+
return Math.round(value).toString()
}
@@ -176,6 +193,7 @@ export class TpsTracker {
private state(sessionID: string): RunState {
let st = this.runs.get(sessionID)
+
if (!st) {
st = {
phase: "ended",
@@ -189,6 +207,7 @@ export class TpsTracker {
}
this.runs.set(sessionID, st)
}
+
return st
}
@@ -212,8 +231,10 @@ export class TpsTracker {
private evictStale(): void {
if (this.runs.size <= MAX_TRACKED_RUNS) return
+
for (const [sessionID, st] of this.runs) {
if (this.runs.size <= MAX_TRACKED_RUNS) return
+
if (st.phase === "running") continue
this.dropSession(sessionID)
}
@@ -221,12 +242,18 @@ export class TpsTracker {
private ensureStep(sessionID: string, assistantMessageID: string, now: number, replace = false): StepState | null {
const st = this.state(sessionID)
+
if (st.settledSteps.has(assistantMessageID) || (st.phase === "ended" && st.frozen !== null)) return null
+
if (st.phase !== "running") this.beginRun(sessionID)
const running = this.state(sessionID)
+
if (running.activeStep?.assistantMessageID === assistantMessageID) return running.activeStep
+
if (running.activeStep && !replace) return null
+
if (running.activeStep) this.settleActiveStep(running, undefined)
+
const step: StepState = {
assistantMessageID,
startedAt: now,
@@ -236,24 +263,30 @@ export class TpsTracker {
blocks: new Map(),
samples: [],
}
+
running.activeStep = step
running.frozen = null
+
return step
}
beginStep(sessionID: string, assistantMessageID: string, now = Date.now()): void {
const st = this.state(sessionID)
+
if (st.phase !== "running") {
if (st.settledSteps.has(assistantMessageID)) return
this.beginRun(sessionID)
}
+
if (st.activeStep?.assistantMessageID === assistantMessageID) return
this.ensureStep(sessionID, assistantMessageID, now, true)
}
beginBlock(sessionID: string, assistantMessageID: string, blockID: string, now: number): void {
const step = this.ensureStep(sessionID, assistantMessageID, now)
+
if (!step) return
+
if (!step.blocks.has(blockID)) step.blocks.set(blockID, { streamedBytes: 0, finalBytes: null })
}
@@ -266,18 +299,22 @@ export class TpsTracker {
): void {
if (!delta) return
const step = this.ensureStep(sessionID, assistantMessageID, now)
+
if (!step) return
let block = step.blocks.get(blockID)
+
if (!block) {
block = { streamedBytes: 0, finalBytes: null }
step.blocks.set(blockID, block)
}
+
if (block.finalBytes !== null) return
const bytes = Buffer.byteLength(delta, "utf8")
block.streamedBytes += bytes
step.observableBytes += bytes
step.samples.push({ bytes, timestamp: now })
const oldest = now - LIVE_WINDOW_MS
+
while (step.samples[0] && step.samples[0].timestamp < oldest) step.samples.shift()
}
@@ -290,12 +327,15 @@ export class TpsTracker {
): void {
const st = this.runs.get(sessionID)
const step = st?.activeStep
+
if (!step || step.assistantMessageID !== assistantMessageID) return
let block = step.blocks.get(blockID)
+
if (!block) {
block = { streamedBytes: 0, finalBytes: null }
step.blocks.set(blockID, block)
}
+
if (block.finalBytes !== null) return
block.finalBytes = Buffer.byteLength(text, "utf8")
step.observableBytes += block.finalBytes - block.streamedBytes
@@ -311,22 +351,27 @@ export class TpsTracker {
markStreamed(sessionID: string, assistantMessageID: string, now: number): void {
const st = this.runs.get(sessionID)
const step = st?.activeStep
+
if (!step || step.assistantMessageID !== assistantMessageID) return
step.streamedAt = now
}
private settleActiveStep(st: RunState, generatedTokens: number | undefined): void {
const step = st.activeStep
+
if (!step) return
const exact = generatedTokens !== undefined && Number.isFinite(generatedTokens) && generatedTokens >= 0
st.settledTokens += exact ? generatedTokens : estimateTokens(step.observableBytes, this.config.bytesPerToken)
+
if (!exact) {
st.tokensEstimated = true
st.partial = true
}
+
// `session.step.streamed` is the exact stream end; the last content boundary
// remains the fallback for hosts that do not publish it.
const end = step.streamedAt ?? step.lastBoundaryAt
+
if (end !== null) st.settledDurationMs += Math.max(0, end - step.startedAt)
st.settledSteps.add(step.assistantMessageID)
st.activeStep = null
@@ -334,20 +379,26 @@ export class TpsTracker {
finishStep(sessionID: string, assistantMessageID: string, generatedTokens: number | undefined, _now: number): void {
const st = this.runs.get(sessionID)
+
if (st?.activeStep?.assistantMessageID !== assistantMessageID) return
this.settleActiveStep(st, generatedTokens)
}
finish(sessionID: string, _now: number): void {
const st = this.runs.get(sessionID)
+
if (!st || st.phase === "ended") return
+
if (st.activeStep) this.settleActiveStep(st, undefined)
st.phase = "ended"
const tokens = st.settledTokens
+
if (tokens <= 0) {
this.evictStale()
+
return
}
+
const tps = st.settledDurationMs > 0 ? tokens / (st.settledDurationMs / 1000) : null
st.frozen = { tps, tokens, tokensEstimated: st.tokensEstimated, partial: st.partial }
mark(`finish sid=${sessionID} tokens=${tokens} observedMs=${st.settledDurationMs} tps=${tps?.toFixed(1) ?? "n/a"}`)
@@ -365,39 +416,49 @@ export class TpsTracker {
hasRunning(now = Date.now()): boolean {
for (const st of this.runs.values()) {
const last = st.activeStep?.samples.at(-1)
+
if (st.phase === "running" && last && now < last.timestamp + LIVE_STALE_MS) return true
}
+
return false
}
private liveTps(step: StepState, now: number): number | null {
const last = step.samples.at(-1)
+
if (!last) return null
const effectiveNow = Math.min(now, last.timestamp + LIVE_STALE_MS)
const oldest = effectiveNow - LIVE_WINDOW_MS
const samples = step.samples.filter((sample) => sample.timestamp >= oldest)
const first = samples[0]
+
if (!first) return null
const bytes = samples.reduce((total, sample) => total + sample.bytes, 0)
const durationMs = Math.max(effectiveNow - first.timestamp, LIVE_MIN_DURATION_MS)
+
return estimateTokens(bytes, this.config.bytesPerToken) / (durationMs / 1000)
}
value(sessionID: string, now: number): TpsValue | null {
const st = this.runs.get(sessionID)
+
if (!st) return null
+
if (st.frozen)
return {
...st.frozen,
frozen: true,
tpsEstimated: true,
}
+
if (st.phase !== "running") return null
const active = st.activeStep
const activeTokens = active ? estimateTokens(active.observableBytes, this.config.bytesPerToken) : 0
const tokens = st.settledTokens + activeTokens
+
if (tokens <= 0) return null
const settledTps = st.settledDurationMs > 0 ? st.settledTokens / (st.settledDurationMs / 1000) : null
+
return {
tps: active ? (this.liveTps(active, now) ?? settledTps) : settledTps,
tokens,
@@ -417,6 +478,7 @@ export class TpsTracker {
// falls back to the default rather than propagating NaN into the arithmetic.
const DISPLAY_MODES = ["both", "tokens", "tps"] as const
+
export type DisplayMode = (typeof DISPLAY_MODES)[number]
/**
@@ -456,6 +518,7 @@ function isDisplayMode(value: OptionValue | undefined): value is DisplayMode {
function clampNumber(value: OptionValue | undefined, fallback: number, min: number, max: number): number {
if (!isFiniteNumber(value)) return fallback
+
return Math.min(Math.max(value, min), max)
}
@@ -471,8 +534,11 @@ export function resolveOptions(raw: TpsOptionsInput): TpsOptions {
export function formatLabel(value: TpsValue, display: DisplayMode): string {
const tokens = `${value.tokensEstimated ? "~" : ""}${value.tokens} tok`
const tps = value.tps === null ? null : `~${formatTps(value.tps)} t/s`
+
if (display === "tokens") return tokens
+
if (display === "tps") return tps ?? "— t/s"
+
return tps === null ? tokens : `${tokens} · ${tps}`
}
@@ -483,24 +549,33 @@ export function formatLabel(value: TpsValue, display: DisplayMode): string {
// `data.listen` signature) rather than restated structurally: handlers are
// contravariant, so hand-written shapes keep typechecking after a field rename.
type PluginContext = Parameters[0]
+
type AnyEvent = Parameters[0]>[0]["details"]
+
type EventOf = Extract
type DeltaEvent = EventOf<"session.text.delta" | "session.reasoning.delta" | "session.tool.input.delta">
+
type BlockStartedEvent = EventOf<
"session.text.started" | "session.reasoning.started" | "session.tool.input.started"
>
+
type BlockEndedEvent = EventOf<"session.text.ended" | "session.reasoning.ended" | "session.tool.input.ended">
+
type FinishEvent = EventOf<
"session.execution.succeeded" | "session.execution.failed" | "session.execution.interrupted" | "session.idle"
>
+
type StepStartedEvent = EventOf<"session.step.started">
+
type StepStreamedEvent = EventOf<"session.step.streamed">
+
type StepFinishedEvent = EventOf<"session.step.ended" | "session.step.failed">
function blockID(e: DeltaEvent | BlockStartedEvent | BlockEndedEvent): string {
if (e.type === "session.tool.input.delta" || e.type === "session.tool.input.started" || e.type === "session.tool.input.ended")
return `tool:${e.data.id}`
+
return `${e.type.startsWith("session.text.") ? "text" : "reasoning"}:${e.data.ordinal}`
}
@@ -528,10 +603,13 @@ const definition: Plugin.Definition = {
const isNewEvent = (e: AnyEvent): boolean => {
if (seenEventIDs.has(e.id)) return false
seenEventIDs.add(e.id)
+
if (seenEventIDs.size > 4_096) {
const oldest = seenEventIDs.values().next().value
+
if (oldest !== undefined) seenEventIDs.delete(oldest)
}
+
return true
}
@@ -548,15 +626,19 @@ const definition: Plugin.Definition = {
// A superseded generation stops ticking even if its cleanup never ran.
if (!isActive()) {
stopTimer()
+
return
}
+
const running = tracker.hasRunning(Date.now())
+
// The observable live rate decays only through a short stale tail. Opaque
// provider work after that is not charged to a numerator we cannot see.
if (dirty || running) {
dirty = false
setVersion((v) => v + 1)
}
+
if (!running) stopTimer()
}
@@ -568,6 +650,7 @@ const definition: Plugin.Definition = {
const touch = () => {
dirty = true
+
if (timer !== undefined) return
timer = setInterval(flush, Math.round(1000 / options.refreshHz))
timer.unref?.()
@@ -578,33 +661,40 @@ const definition: Plugin.Definition = {
tracker.push(e.data.sessionID, e.data.delta, e.created, e.data.assistantMessageID, blockID(e))
touch()
}
+
const onBlockStarted = (e: BlockStartedEvent) => {
if (!isActive() || !isNewEvent(e)) return
tracker.beginBlock(e.data.sessionID, e.data.assistantMessageID, blockID(e), e.created)
}
+
const onBlockEnded = (e: BlockEndedEvent) => {
if (!isActive() || !isNewEvent(e)) return
tracker.finishBlock(e.data.sessionID, e.data.assistantMessageID, blockID(e), e.data.text, e.created)
touch()
}
+
const onFinish = (e: FinishEvent) => {
if (!isActive() || !isNewEvent(e)) return
tracker.finish(e.data.sessionID, e.created)
touch()
}
+
const onStepStarted = (e: StepStartedEvent) => {
if (!isActive() || !isNewEvent(e)) return
tracker.beginStep(e.data.sessionID, e.data.assistantMessageID, e.created)
touch()
}
+
const onStepStreamed = (e: StepStreamedEvent) => {
if (!isActive() || !isNewEvent(e)) return
tracker.markStreamed(e.data.sessionID, e.data.assistantMessageID, e.created)
touch()
}
+
const onStepFinished = (e: StepFinishedEvent) => {
if (!isActive() || !isNewEvent(e)) return
const tokens = e.data.tokens
+
const generatedTokens =
tokens !== undefined &&
Number.isFinite(tokens.output) &&
@@ -613,6 +703,7 @@ const definition: Plugin.Definition = {
tokens.reasoning >= 0
? tokens.output + tokens.reasoning
: undefined
+
tracker.finishStep(
e.data.sessionID,
e.data.assistantMessageID,
@@ -657,11 +748,15 @@ const definition: Plugin.Definition = {
render: (input) => {
const label = createMemo(() => {
version()
+
if (!isActive()) return null
const v = tracker.value(input.sessionID, Date.now())
+
if (!v) return null
+
return formatLabel(v, options.display)
})
+
return (
{(text: () => string) => (
@@ -678,6 +773,7 @@ const definition: Plugin.Definition = {
for (const unsub of unsubs) unsub()
unslot()
stopTimer()
+
if (gen.active === mine)
setGen((d) => {
d.active = 0