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.
367 lines
12 KiB
TypeScript
367 lines
12 KiB
TypeScript
import { defineRule } from "@oxlint/plugins";
|
|
import type { ESTree, Variable } from "@oxlint/plugins";
|
|
|
|
type BroadTypeKind = "top" | "object" | "record";
|
|
|
|
type KnownValueEvidence = {
|
|
readonly type: ESTree.TSType | null;
|
|
};
|
|
|
|
const functionBoundaryTypes = new Set([
|
|
"ArrowFunctionExpression",
|
|
"FunctionDeclaration",
|
|
"FunctionExpression",
|
|
"TSDeclareFunction",
|
|
"TSEmptyBodyFunctionExpression",
|
|
]);
|
|
|
|
function unwrapExpressionParentheses(expression: ESTree.Expression): ESTree.Expression {
|
|
let current = expression;
|
|
while (current.type === "ParenthesizedExpression") current = current.expression;
|
|
return current;
|
|
}
|
|
|
|
function unwrapTypeParentheses(type: ESTree.TSType): ESTree.TSType {
|
|
let current = type;
|
|
while (current.type === "TSParenthesizedType") current = current.typeAnnotation;
|
|
return current;
|
|
}
|
|
|
|
function typeReferenceName(type: ESTree.TSTypeReference): string | null {
|
|
return type.typeName.type === "Identifier" ? type.typeName.name : null;
|
|
}
|
|
|
|
function isUnknownOrAnyType(type: ESTree.TSType): boolean {
|
|
const unwrapped = unwrapTypeParentheses(type);
|
|
return unwrapped.type === "TSUnknownKeyword" || unwrapped.type === "TSAnyKeyword";
|
|
}
|
|
|
|
function isBroadRecordKeyType(type: ESTree.TSType): boolean {
|
|
const unwrapped = unwrapTypeParentheses(type);
|
|
if (
|
|
unwrapped.type === "TSStringKeyword" ||
|
|
unwrapped.type === "TSNumberKeyword" ||
|
|
unwrapped.type === "TSSymbolKeyword"
|
|
) {
|
|
return true;
|
|
}
|
|
if (unwrapped.type === "TSUnionType") return unwrapped.types.every(isBroadRecordKeyType);
|
|
return unwrapped.type === "TSTypeReference" && typeReferenceName(unwrapped) === "PropertyKey";
|
|
}
|
|
|
|
function isBroadRecordType(type: ESTree.TSType): boolean {
|
|
const unwrapped = unwrapTypeParentheses(type);
|
|
|
|
if (unwrapped.type === "TSTypeReference") {
|
|
if (typeReferenceName(unwrapped) === "Readonly") {
|
|
const [inner] = unwrapped.typeArguments?.params ?? [];
|
|
return inner !== undefined && isBroadRecordType(inner);
|
|
}
|
|
|
|
if (typeReferenceName(unwrapped) !== "Record") return false;
|
|
const parameters = unwrapped.typeArguments?.params ?? [];
|
|
return (
|
|
parameters.length === 2 &&
|
|
parameters[0] !== undefined &&
|
|
parameters[1] !== undefined &&
|
|
isBroadRecordKeyType(parameters[0]) &&
|
|
isUnknownOrAnyType(parameters[1])
|
|
);
|
|
}
|
|
|
|
if (unwrapped.type !== "TSTypeLiteral" || unwrapped.members.length !== 1) return false;
|
|
const [member] = unwrapped.members;
|
|
const [parameter] = member?.type === "TSIndexSignature" ? member.parameters : [];
|
|
return (
|
|
member?.type === "TSIndexSignature" &&
|
|
member.parameters.length === 1 &&
|
|
parameter !== undefined &&
|
|
isBroadRecordKeyType(parameter.typeAnnotation.typeAnnotation) &&
|
|
isUnknownOrAnyType(member.typeAnnotation.typeAnnotation)
|
|
);
|
|
}
|
|
|
|
function broadTypeKind(type: ESTree.TSType): BroadTypeKind | null {
|
|
const unwrapped = unwrapTypeParentheses(type);
|
|
if (unwrapped.type === "TSUnknownKeyword" || unwrapped.type === "TSAnyKeyword") return "top";
|
|
if (unwrapped.type === "TSObjectKeyword") return "object";
|
|
return isBroadRecordType(unwrapped) ? "record" : null;
|
|
}
|
|
|
|
function assertedExpression(
|
|
node: ESTree.TSAsExpression | ESTree.TSTypeAssertion,
|
|
): ESTree.Expression {
|
|
return unwrapExpressionParentheses(node.expression);
|
|
}
|
|
|
|
function assertionFromExpression(
|
|
expression: ESTree.Expression,
|
|
): ESTree.TSAsExpression | ESTree.TSTypeAssertion | null {
|
|
const unwrapped = unwrapExpressionParentheses(expression);
|
|
return unwrapped.type === "TSAsExpression" || unwrapped.type === "TSTypeAssertion"
|
|
? unwrapped
|
|
: null;
|
|
}
|
|
|
|
function normalizedTypeText(sourceText: string, type: ESTree.TSType): string {
|
|
return sourceText.slice(type.start, type.end).replaceAll(/\s+/gu, "");
|
|
}
|
|
|
|
function typesHaveSameSyntax(
|
|
sourceText: string,
|
|
left: ESTree.TSType | null,
|
|
right: ESTree.TSType,
|
|
): boolean {
|
|
return (
|
|
left !== null &&
|
|
normalizedTypeText(sourceText, unwrapTypeParentheses(left)) ===
|
|
normalizedTypeText(sourceText, unwrapTypeParentheses(right))
|
|
);
|
|
}
|
|
|
|
function isDefinitelyObjectType(type: ESTree.TSType): boolean {
|
|
const unwrapped = unwrapTypeParentheses(type);
|
|
switch (unwrapped.type) {
|
|
case "TSArrayType":
|
|
case "TSConstructorType":
|
|
case "TSFunctionType":
|
|
case "TSMappedType":
|
|
case "TSObjectKeyword":
|
|
case "TSTupleType":
|
|
return true;
|
|
case "TSTypeLiteral":
|
|
return unwrapped.members.length > 0;
|
|
case "TSIntersectionType":
|
|
return unwrapped.types.every(isDefinitelyObjectType);
|
|
case "TSTypeOperator":
|
|
return unwrapped.operator === "readonly" && isDefinitelyObjectType(unwrapped.typeAnnotation);
|
|
default:
|
|
return false;
|
|
}
|
|
}
|
|
|
|
function isDefinitelyNarrowerRecordType(type: ESTree.TSType): boolean {
|
|
const unwrapped = unwrapTypeParentheses(type);
|
|
if (unwrapped.type === "TSTypeLiteral") {
|
|
return unwrapped.members.some((member) => member.type !== "TSIndexSignature");
|
|
}
|
|
|
|
if (unwrapped.type !== "TSTypeReference") return false;
|
|
if (typeReferenceName(unwrapped) === "Readonly") {
|
|
const [inner] = unwrapped.typeArguments?.params ?? [];
|
|
return inner !== undefined && isDefinitelyNarrowerRecordType(inner);
|
|
}
|
|
if (typeReferenceName(unwrapped) !== "Record") return false;
|
|
|
|
const parameters = unwrapped.typeArguments?.params ?? [];
|
|
return (
|
|
parameters.length === 2 && parameters[1] !== undefined && !isUnknownOrAnyType(parameters[1])
|
|
);
|
|
}
|
|
|
|
function functionBoundary(node: ESTree.Node): ESTree.Node | null {
|
|
let current = node.parent;
|
|
while (current !== null && current.type !== "Program") {
|
|
if (functionBoundaryTypes.has(current.type)) return current;
|
|
current = current.parent;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function resolvedVariableForIdentifier(
|
|
scopes: readonly {
|
|
readonly references: readonly {
|
|
readonly identifier: ESTree.Node;
|
|
readonly resolved: Variable | null;
|
|
}[];
|
|
}[],
|
|
identifier: ESTree.IdentifierReference,
|
|
): Variable | null {
|
|
for (const scope of scopes) {
|
|
const reference = scope.references.find(
|
|
(candidate) =>
|
|
candidate.identifier.start === identifier.start &&
|
|
candidate.identifier.end === identifier.end,
|
|
);
|
|
if (reference !== undefined) return reference.resolved;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function variableDeclarator(variable: Variable): ESTree.VariableDeclarator | null {
|
|
for (const definition of variable.defs) {
|
|
if (definition.type === "Variable" && definition.node.type === "VariableDeclarator") {
|
|
return definition.node;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function knownValueEvidence(
|
|
expression: ESTree.Expression,
|
|
scopes: Parameters<typeof resolvedVariableForIdentifier>[0],
|
|
boundary: ESTree.Node | null,
|
|
visitedVariables: ReadonlySet<Variable>,
|
|
): KnownValueEvidence | null {
|
|
const unwrapped = unwrapExpressionParentheses(expression);
|
|
|
|
if (unwrapped.type === "TSAsExpression" || unwrapped.type === "TSTypeAssertion") {
|
|
if (broadTypeKind(unwrapped.typeAnnotation) !== null) return null;
|
|
return { type: unwrapped.typeAnnotation };
|
|
}
|
|
|
|
if (unwrapped.type === "Literal" || unwrapped.type === "TemplateLiteral") {
|
|
return { type: null };
|
|
}
|
|
|
|
if (
|
|
unwrapped.type === "ArrayExpression" ||
|
|
unwrapped.type === "ArrowFunctionExpression" ||
|
|
unwrapped.type === "ClassExpression" ||
|
|
unwrapped.type === "FunctionExpression" ||
|
|
unwrapped.type === "NewExpression" ||
|
|
unwrapped.type === "ObjectExpression"
|
|
) {
|
|
return { type: null };
|
|
}
|
|
|
|
if (unwrapped.type !== "Identifier") return null;
|
|
const variable = resolvedVariableForIdentifier(scopes, unwrapped);
|
|
if (variable === null || visitedVariables.has(variable)) return null;
|
|
|
|
const annotatedIdentifier = variable.identifiers.find(
|
|
(identifier) => identifier.typeAnnotation !== null && identifier.typeAnnotation !== undefined,
|
|
);
|
|
const annotation = annotatedIdentifier?.typeAnnotation?.typeAnnotation;
|
|
if (annotation !== undefined && annotatedIdentifier !== undefined) {
|
|
if (functionBoundary(annotatedIdentifier) !== boundary || broadTypeKind(annotation) !== null) {
|
|
return null;
|
|
}
|
|
return { type: annotation };
|
|
}
|
|
|
|
const declarator = variableDeclarator(variable);
|
|
if (
|
|
declarator === null ||
|
|
declarator.parent.type !== "VariableDeclaration" ||
|
|
declarator.parent.kind !== "const" ||
|
|
declarator.init === null ||
|
|
variable.references.some((reference) => reference.isWrite() && !reference.init) ||
|
|
functionBoundary(declarator) !== boundary
|
|
) {
|
|
return null;
|
|
}
|
|
|
|
return knownValueEvidence(
|
|
declarator.init,
|
|
scopes,
|
|
boundary,
|
|
new Set([...visitedVariables, variable]),
|
|
);
|
|
}
|
|
|
|
function widenedBinding(
|
|
variable: Variable,
|
|
scopes: Parameters<typeof resolvedVariableForIdentifier>[0],
|
|
): {
|
|
readonly broadKind: BroadTypeKind;
|
|
readonly evidence: KnownValueEvidence;
|
|
readonly declaredAt: number;
|
|
readonly boundary: ESTree.Node | null;
|
|
} | null {
|
|
const declarator = variableDeclarator(variable);
|
|
if (
|
|
declarator === null ||
|
|
declarator.parent.type !== "VariableDeclaration" ||
|
|
declarator.parent.kind !== "const" ||
|
|
declarator.id.type !== "Identifier" ||
|
|
declarator.init === null ||
|
|
variable.references.some((reference) => reference.isWrite() && !reference.init)
|
|
) {
|
|
return null;
|
|
}
|
|
|
|
const boundary = functionBoundary(declarator);
|
|
const declaredType = declarator.id.typeAnnotation?.typeAnnotation;
|
|
const initializerAssertion = assertionFromExpression(declarator.init);
|
|
const initializerBroadKind =
|
|
initializerAssertion === null ? null : broadTypeKind(initializerAssertion.typeAnnotation);
|
|
const declaredBroadKind = declaredType === undefined ? null : broadTypeKind(declaredType);
|
|
const broadKind = declaredBroadKind ?? initializerBroadKind;
|
|
if (broadKind === null) return null;
|
|
|
|
const originalExpression =
|
|
initializerAssertion !== null && initializerBroadKind !== null
|
|
? assertedExpression(initializerAssertion)
|
|
: declarator.init;
|
|
const evidence = knownValueEvidence(originalExpression, scopes, boundary, new Set([variable]));
|
|
return evidence === null ? null : { broadKind, evidence, declaredAt: declarator.end, boundary };
|
|
}
|
|
|
|
function assertionIsNarrower(
|
|
sourceText: string,
|
|
broadKind: BroadTypeKind,
|
|
evidence: KnownValueEvidence,
|
|
assertedType: ESTree.TSType,
|
|
): boolean {
|
|
if (broadTypeKind(assertedType) !== null) return false;
|
|
if (broadKind === "top") return true;
|
|
if (typesHaveSameSyntax(sourceText, evidence.type, assertedType)) return true;
|
|
if (broadKind === "object") return isDefinitelyObjectType(assertedType);
|
|
return isDefinitelyNarrowerRecordType(assertedType);
|
|
}
|
|
|
|
/** Detect immutable local bindings that erase a known type and are later asserted back to a narrower type. */
|
|
export const noWidenThenAssertRule = defineRule({
|
|
meta: {
|
|
type: "problem",
|
|
docs: {
|
|
description:
|
|
"Disallow local const flows that explicitly widen a known value before asserting the widened binding to a narrower type.",
|
|
},
|
|
messages: {
|
|
widenThenAssert:
|
|
'Binding "{{name}}" discards type evidence and later recreates it with an assertion. Keep the precise type from initialization through use; parse boundary input once.',
|
|
},
|
|
},
|
|
createOnce(context) {
|
|
let scopes: Parameters<typeof resolvedVariableForIdentifier>[0] = [];
|
|
|
|
const checkAssertion = (node: ESTree.TSAsExpression | ESTree.TSTypeAssertion) => {
|
|
const expression = assertedExpression(node);
|
|
if (expression.type !== "Identifier") return;
|
|
|
|
const variable = resolvedVariableForIdentifier(scopes, expression);
|
|
if (variable === null) return;
|
|
const widened = widenedBinding(variable, scopes);
|
|
if (
|
|
widened === null ||
|
|
node.start <= widened.declaredAt ||
|
|
functionBoundary(node) !== widened.boundary ||
|
|
!assertionIsNarrower(
|
|
context.sourceCode.text,
|
|
widened.broadKind,
|
|
widened.evidence,
|
|
node.typeAnnotation,
|
|
)
|
|
) {
|
|
return;
|
|
}
|
|
|
|
context.report({
|
|
node,
|
|
messageId: "widenThenAssert",
|
|
data: { name: expression.name },
|
|
});
|
|
};
|
|
|
|
return {
|
|
Program() {
|
|
scopes = context.sourceCode.scopeManager.scopes;
|
|
},
|
|
TSAsExpression: checkAssertion,
|
|
TSTypeAssertion: checkAssertion,
|
|
};
|
|
},
|
|
});
|