Vendor the anti-slop Oxlint plugin at tools/oxlint/anti-slop and register it in oxlint.config.ts, with Oxlint's own rule categories disabled so ESLint stays the general-purpose linter. Add scripts/anti-slop.mjs (bun run deslop) mirroring the React Doctor batch interface: next-batch, check-batch, active, release, top, file. Batch handoff directories now double as file claims shared across clones via ~/.openchamber/maintenance-claims, so concurrent maintenance batches from either pipeline never select the same file. Harden both scheduled maintenance flows: stop on a dirty worktree, stop on NO BATCH AVAILABLE, validate per package instead of workspace-wide, and pin react-doctor to 0.9.12. The anti-slop task command documents concrete good and bad fixes and forbids laundering types to satisfy a rule.
248 lines
7.6 KiB
TypeScript
248 lines
7.6 KiB
TypeScript
import { defineRule } from "@oxlint/plugins";
|
|
|
|
import {
|
|
classifyWideningTarget,
|
|
createTypeEnvironment,
|
|
isKnownEvidenceExpression,
|
|
type TypeEnvironment,
|
|
type WideningTarget,
|
|
} from "../shared/dictionary-types.ts";
|
|
|
|
import type { ESTree, Scope, SourceCode, Variable } from "@oxlint/plugins";
|
|
|
|
type FunctionExpression = ESTree.ArrowFunctionExpression | ESTree.Function;
|
|
|
|
function unwrapExpression(expression: ESTree.Expression): ESTree.Expression {
|
|
let current = expression;
|
|
while (
|
|
current.type === "ParenthesizedExpression" ||
|
|
current.type === "TSAsExpression" ||
|
|
current.type === "TSSatisfiesExpression" ||
|
|
current.type === "TSTypeAssertion" ||
|
|
current.type === "TSNonNullExpression"
|
|
) {
|
|
current = current.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;
|
|
return definition?.type === "Variable" && definition.node.type === "VariableDeclarator"
|
|
? definition.node
|
|
: null;
|
|
}
|
|
|
|
function isStableConstVariable(variable: Variable, declarator: ESTree.VariableDeclarator): boolean {
|
|
return (
|
|
declarator.parent.type === "VariableDeclaration" &&
|
|
declarator.parent.kind === "const" &&
|
|
variable.references.every((reference) => reference.init || !reference.isWrite())
|
|
);
|
|
}
|
|
|
|
function hasKnownEvidence(
|
|
sourceCode: SourceCode,
|
|
expression: ESTree.Expression,
|
|
visitedVariables = new Set<Variable>(),
|
|
): boolean {
|
|
if (isKnownEvidenceExpression(expression)) return true;
|
|
const unwrapped = unwrapExpression(expression);
|
|
if (unwrapped.type !== "Identifier") return false;
|
|
const variable = resolveVariable(sourceCode, unwrapped);
|
|
if (variable === null || visitedVariables.has(variable)) return false;
|
|
const declarator = variableDeclarator(variable);
|
|
if (
|
|
declarator === null ||
|
|
declarator.init === null ||
|
|
!isStableConstVariable(variable, declarator)
|
|
) {
|
|
return false;
|
|
}
|
|
visitedVariables.add(variable);
|
|
return hasKnownEvidence(sourceCode, declarator.init, visitedVariables);
|
|
}
|
|
|
|
function annotationTarget(
|
|
annotation: ESTree.TSTypeAnnotation | null | undefined,
|
|
environment: TypeEnvironment,
|
|
): WideningTarget | null {
|
|
return annotation === null || annotation === undefined
|
|
? null
|
|
: classifyWideningTarget(annotation.typeAnnotation, environment);
|
|
}
|
|
|
|
function enclosingFunction(node: ESTree.Node): FunctionExpression | null {
|
|
let current: ESTree.Node | null = node.parent;
|
|
while (current !== null && current.type !== "Program") {
|
|
if (
|
|
current.type === "ArrowFunctionExpression" ||
|
|
current.type === "FunctionDeclaration" ||
|
|
current.type === "FunctionExpression"
|
|
) {
|
|
return current;
|
|
}
|
|
current = current.parent;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function sourceKeyName(sourceCode: SourceCode, key: ESTree.PropertyKey): string {
|
|
if (key.type === "Identifier" || key.type === "PrivateIdentifier") return key.name;
|
|
if (key.type === "Literal") return String(key.value);
|
|
return sourceCode.getText(key);
|
|
}
|
|
|
|
function functionName(sourceCode: SourceCode, owner: FunctionExpression | null): string {
|
|
if (owner === null) return "anonymous function";
|
|
if (owner.id !== null) return owner.id.name;
|
|
const parent = owner.parent;
|
|
if (parent.type === "VariableDeclarator" && parent.id.type === "Identifier")
|
|
return parent.id.name;
|
|
if (parent.type === "MethodDefinition") return sourceKeyName(sourceCode, parent.key);
|
|
return "anonymous function";
|
|
}
|
|
|
|
function isEmptyObjectExpression(expression: ESTree.Expression): boolean {
|
|
const unwrapped = unwrapExpression(expression);
|
|
return unwrapped.type === "ObjectExpression" && unwrapped.properties.length === 0;
|
|
}
|
|
|
|
function isDictionaryAccumulatorTarget(destination: WideningTarget): boolean {
|
|
return destination.kind === "open dictionary" || destination.kind === "generic container";
|
|
}
|
|
|
|
function hasParentAssertion(node: ESTree.Node): boolean {
|
|
return node.parent?.type === "TSAsExpression" || node.parent?.type === "TSTypeAssertion";
|
|
}
|
|
|
|
/** Detect sound syntactic cases where a known value is explicitly widened and loses evidence. */
|
|
export const noKnownValueWideningRule = defineRule({
|
|
meta: {
|
|
type: "problem",
|
|
docs: {
|
|
description:
|
|
"Disallow syntactically established values from flowing into explicitly broad or anonymous target types that discard useful evidence.",
|
|
},
|
|
messages: {
|
|
widening:
|
|
"The explicit {{target}} type on {{subject}} discards known type evidence. Keep inference, validate with `satisfies`, or use a named owner contract.",
|
|
},
|
|
},
|
|
createOnce(context) {
|
|
let environment: TypeEnvironment | null = null;
|
|
|
|
const reportFlow = (
|
|
expression: ESTree.Expression,
|
|
destination: WideningTarget | null,
|
|
subject: string,
|
|
) => {
|
|
if (destination === null) return;
|
|
if (
|
|
isDictionaryAccumulatorTarget(destination) &&
|
|
isEmptyObjectExpression(expression)
|
|
) {
|
|
return;
|
|
}
|
|
if (!hasKnownEvidence(context.sourceCode, expression)) return;
|
|
context.report({
|
|
node: expression,
|
|
messageId: "widening",
|
|
data: { subject, target: destination.kind },
|
|
});
|
|
};
|
|
|
|
const targetFromAnnotation = (annotation: ESTree.TSTypeAnnotation | null | undefined) =>
|
|
environment === null ? null : annotationTarget(annotation, environment);
|
|
|
|
return {
|
|
Program(node) {
|
|
environment = createTypeEnvironment(node);
|
|
},
|
|
VariableDeclarator(node) {
|
|
if (node.init === null || node.id.type !== "Identifier") return;
|
|
reportFlow(
|
|
node.init,
|
|
targetFromAnnotation(node.id.typeAnnotation),
|
|
`binding \`${node.id.name}\``,
|
|
);
|
|
},
|
|
PropertyDefinition(node) {
|
|
if (node.value === null) return;
|
|
reportFlow(
|
|
node.value,
|
|
targetFromAnnotation(node.typeAnnotation),
|
|
`property \`${sourceKeyName(context.sourceCode, node.key)}\``,
|
|
);
|
|
},
|
|
AccessorProperty(node) {
|
|
if (node.value === null) return;
|
|
reportFlow(
|
|
node.value,
|
|
targetFromAnnotation(node.typeAnnotation),
|
|
`property \`${sourceKeyName(context.sourceCode, node.key)}\``,
|
|
);
|
|
},
|
|
AssignmentExpression(node) {
|
|
if (node.operator !== "=" || node.left.type !== "Identifier") return;
|
|
const variable = resolveVariable(context.sourceCode, node.left);
|
|
if (variable === null) return;
|
|
const declarator = variableDeclarator(variable);
|
|
if (declarator === null || declarator.id.type !== "Identifier") return;
|
|
reportFlow(
|
|
node.right,
|
|
targetFromAnnotation(declarator.id.typeAnnotation),
|
|
`binding \`${declarator.id.name}\``,
|
|
);
|
|
},
|
|
ReturnStatement(node) {
|
|
if (node.argument === null) return;
|
|
const owner = enclosingFunction(node);
|
|
reportFlow(
|
|
node.argument,
|
|
targetFromAnnotation(owner?.returnType),
|
|
`return value of \`${functionName(context.sourceCode, owner)}\``,
|
|
);
|
|
},
|
|
ArrowFunctionExpression(node) {
|
|
if (node.body.type === "BlockStatement") return;
|
|
reportFlow(
|
|
node.body,
|
|
targetFromAnnotation(node.returnType),
|
|
`return value of \`${functionName(context.sourceCode, node)}\``,
|
|
);
|
|
},
|
|
TSAsExpression(node) {
|
|
if (environment === null || hasParentAssertion(node)) return;
|
|
reportFlow(
|
|
node.expression,
|
|
classifyWideningTarget(node.typeAnnotation, environment),
|
|
"assertion",
|
|
);
|
|
},
|
|
TSTypeAssertion(node) {
|
|
if (environment === null || hasParentAssertion(node)) return;
|
|
reportFlow(
|
|
node.expression,
|
|
classifyWideningTarget(node.typeAnnotation, environment),
|
|
"assertion",
|
|
);
|
|
},
|
|
};
|
|
},
|
|
});
|