diff --git a/.agents/skills/install-anti-slop/SKILL.md b/.agents/skills/install-anti-slop/SKILL.md new file mode 100644 index 000000000..e365b4d94 --- /dev/null +++ b/.agents/skills/install-anti-slop/SKILL.md @@ -0,0 +1,70 @@ +--- +name: install-anti-slop +description: Install and configure the anti-slop Oxlint plugin in a local TypeScript or JavaScript repository. Use whenever a user asks to add anti-slop lint rules, copy the anti-slop plugin, configure opinionated Oxlint rules, or migrate an existing local anti-slop setup. +--- + +# Install anti-slop + +Install the bundled Oxlint plugin into the current repository and integrate it with the repository's existing lint setup. Preserve unrelated work and adapt to the project's package manager and configuration style. + +## Procedure + +1. Inspect the repository before changing it: + - Read its agent instructions. + - Check `git status` and preserve unrelated changes. + - Identify the package manager from `packageManager` and lockfiles. + - Find Oxlint configuration (`oxlint.config.*`, `.oxlintrc*`, or a Vite+ config). + - Check whether anti-slop files or rules already exist. Do not overwrite them without reviewing the diff. + +2. Copy the bundled plugin from this skill. Run from the target repository: + + ```bash + node /scripts/install.mjs + ``` + + This creates `tools/oxlint/anti-slop/`. Pass another relative destination as the first argument when the repository has an established tooling layout. The script refuses to replace an existing destination; only use `--force` after backing up and reviewing existing files. + +3. Install current compatible dependencies rather than trusting versions remembered by the agent: + - Query `npm view oxlint version` and `npm view @oxlint/plugins version`. + - Install the same current version of both packages with the repository's package manager. + - `oxlint` is a development dependency. The copied source imports `@oxlint/plugins`, so install it as a development dependency for a local-only plugin. + - Do not replace the package manager or rewrite unrelated dependency ranges. + +4. Register the plugin and enable all rules. For `oxlint.config.ts` or `.oxlintrc.json`, add: + + ```ts + jsPlugins: [ + { name: "anti-slop", specifier: "./tools/oxlint/anti-slop/index.ts" }, + ], + ``` + + For Vite+, add that same entry to `lint.jsPlugins`. Merge it with existing entries instead of replacing them. + + Enable these rules at `"error"`: + + ```json + { + "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-object-parameters": "error", + "anti-slop/no-runtime-typeof": "error", + "anti-slop/no-shape-in-symbol-names": "error", + "anti-slop/no-unknown-parameters": "error", + "anti-slop/no-unknown-type-aliases": "error", + "anti-slop/no-unsafe-dictionary-type": "error", + "anti-slop/no-widen-then-assert": "error" + } + ``` + +5. Run the repository's lint command and typecheck. If findings appear, report them and fix them only when the user asked for migration/cleanup. Do not suppress rules, weaken rule severity, add unsafe casts, or mechanically launder types to make lint pass. + +6. Review the final diff and clearly report: + - copied path, + - dependency versions installed, + - configuration changed, + - checks run and any remaining findings. + +## Migration guidance + +When replacing an older local copy, compare its rules and diagnostics before overwriting. Keep project-specific rules in their own plugin; anti-slop is intentionally generic. Prefer inference, `as const`, `satisfies`, named owner contracts, and boundary parsing when resolving findings. diff --git a/.agents/skills/install-anti-slop/assets/anti-slop/index.ts b/.agents/skills/install-anti-slop/assets/anti-slop/index.ts new file mode 100644 index 000000000..0a4c10f82 --- /dev/null +++ b/.agents/skills/install-anti-slop/assets/anti-slop/index.ts @@ -0,0 +1,31 @@ +import { definePlugin } from "@oxlint/plugins"; + +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"; +import { noObjectParametersRule } from "./rules/no-object-parameters.ts"; +import { noRuntimeTypeofRule } from "./rules/no-runtime-typeof.ts"; +import { noForbiddenTermInSymbolNamesRule } from "./rules/no-shape-in-symbol-names.ts"; +import { noUnknownParametersRule } from "./rules/no-unknown-parameters.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"; + +/** Generic Oxlint rules that reject low-evidence and low-signal implementation patterns. */ +const antiSlopPlugin = definePlugin({ + meta: { name: "anti-slop" }, + rules: { + "no-chained-type-assertions": noChainedTypeAssertionsRule, + "no-conditional-empty-object-spread": noConditionalEmptyObjectSpreadRule, + "no-known-value-widening": noKnownValueWideningRule, + "no-object-parameters": noObjectParametersRule, + "no-runtime-typeof": noRuntimeTypeofRule, + "no-unsafe-dictionary-type": noUnsafeDictionaryTypeRule, + "no-shape-in-symbol-names": noForbiddenTermInSymbolNamesRule, + "no-unknown-parameters": noUnknownParametersRule, + "no-unknown-type-aliases": noUnknownTypeAliasesRule, + "no-widen-then-assert": noWidenThenAssertRule, + }, +}); + +export default antiSlopPlugin; diff --git a/.agents/skills/install-anti-slop/assets/anti-slop/rules/no-chained-type-assertions.ts b/.agents/skills/install-anti-slop/assets/anti-slop/rules/no-chained-type-assertions.ts new file mode 100644 index 000000000..beb661dfe --- /dev/null +++ b/.agents/skills/install-anti-slop/assets/anti-slop/rules/no-chained-type-assertions.ts @@ -0,0 +1,77 @@ +import { defineRule } from "@oxlint/plugins"; +import type { ESTree } from "@oxlint/plugins"; + +type TypeAssertionExpression = ESTree.TSAsExpression | ESTree.TSTypeAssertion; + +function isTypeAssertionExpression(node: ESTree.Node): node is TypeAssertionExpression { + return node.type === "TSAsExpression" || node.type === "TSTypeAssertion"; +} + +function unwrapParenthesizedExpression(expression: ESTree.Expression): ESTree.Expression { + let current = expression; + while (current.type === "ParenthesizedExpression") { + current = current.expression; + } + return current; +} + +function isConstAssertion(node: TypeAssertionExpression): boolean { + const { typeAnnotation } = node; + return ( + typeAnnotation.type === "TSTypeReference" && + typeAnnotation.typeName.type === "Identifier" && + typeAnnotation.typeName.name === "const" + ); +} + +function isOutermostAssertionInChain(node: TypeAssertionExpression): boolean { + let current: ESTree.Expression = node; + let parent = node.parent; + + while (parent.type === "ParenthesizedExpression" && parent.expression === current) { + current = parent; + parent = parent.parent; + } + + return !isTypeAssertionExpression(parent) || parent.expression !== current; +} + +function isForbiddenAssertionChain(node: TypeAssertionExpression): boolean { + let assertionCount = 0; + let hasNonConstAssertion = false; + let current: ESTree.Expression = node; + + while (isTypeAssertionExpression(current)) { + assertionCount += 1; + hasNonConstAssertion ||= !isConstAssertion(current); + current = unwrapParenthesizedExpression(current.expression); + } + + return assertionCount > 1 && hasNonConstAssertion; +} + +/** Disallow nested TypeScript type assertions, while permitting chains made only of const assertions. */ +export const noChainedTypeAssertionsRule = defineRule({ + meta: { + type: "problem", + docs: { + description: + "Disallow chained TypeScript as and angle-bracket assertions, including parenthesized chains.", + }, + messages: { + chained: + "Chained type assertions discard existing type evidence and fabricate the target type without parsing. Preserve the value's original precise type, or parse genuinely unknown input at its boundary before using it.", + }, + }, + create(context) { + const checkTypeAssertion = (node: TypeAssertionExpression) => { + if (!isOutermostAssertionInChain(node) || !isForbiddenAssertionChain(node)) return; + context.report({ node, messageId: "chained" }); + }; + + return { + TSAsExpression: checkTypeAssertion, + TSTypeAssertion: checkTypeAssertion, + }; + }, +}); diff --git a/.agents/skills/install-anti-slop/assets/anti-slop/rules/no-conditional-empty-object-spread.ts b/.agents/skills/install-anti-slop/assets/anti-slop/rules/no-conditional-empty-object-spread.ts new file mode 100644 index 000000000..d1a14495d --- /dev/null +++ b/.agents/skills/install-anti-slop/assets/anti-slop/rules/no-conditional-empty-object-spread.ts @@ -0,0 +1,49 @@ +import { defineRule } from "@oxlint/plugins"; +import type { ESTree } from "@oxlint/plugins"; + +function unwrapParentheses(node: ESTree.Expression): ESTree.Expression { + let current = node; + while (current.type === "ParenthesizedExpression") { + current = current.expression; + } + return current; +} + +function isEmptyObjectExpression(node: ESTree.Expression): boolean { + return node.type === "ObjectExpression" && node.properties.length === 0; +} + +function isConditionalEmptyObjectSpread(node: ESTree.Expression): boolean { + const conditional = unwrapParentheses(node); + return ( + conditional.type === "ConditionalExpression" && + (isEmptyObjectExpression(conditional.consequent) || + isEmptyObjectExpression(conditional.alternate)) + ); +} + +/** Ban conditional empty-object spreads without changing their omission semantics. */ +export const noConditionalEmptyObjectSpreadRule = defineRule({ + meta: { + type: "suggestion", + docs: { + description: + "Disallow object spreads that conditionally spread an empty object to omit fields.", + }, + messages: { + avoid: + "Do not use conditional empty-object spreads. Prefer a direct property or build the object in separate statements.", + }, + }, + create(context) { + return { + SpreadElement(node) { + if (node.parent.type !== "ObjectExpression") return; + + if (isConditionalEmptyObjectSpread(node.argument)) { + context.report({ node, messageId: "avoid" }); + } + }, + }; + }, +}); diff --git a/.agents/skills/install-anti-slop/assets/anti-slop/rules/no-known-value-widening.ts b/.agents/skills/install-anti-slop/assets/anti-slop/rules/no-known-value-widening.ts new file mode 100644 index 000000000..c71e119fa --- /dev/null +++ b/.agents/skills/install-anti-slop/assets/anti-slop/rules/no-known-value-widening.ts @@ -0,0 +1,247 @@ +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(), +): 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 known initializer supplying {{subject}} carries established type evidence, but the explicit {{target}} target type discards it. Preserve inference, use `satisfies`, or introduce/use a named owner contract; parse genuinely external data once at its boundary.", + }, + }, + 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", + ); + }, + }; + }, +}); diff --git a/.agents/skills/install-anti-slop/assets/anti-slop/rules/no-object-parameters.ts b/.agents/skills/install-anti-slop/assets/anti-slop/rules/no-object-parameters.ts new file mode 100644 index 000000000..729750364 --- /dev/null +++ b/.agents/skills/install-anti-slop/assets/anti-slop/rules/no-object-parameters.ts @@ -0,0 +1,112 @@ +import { defineRule } from "@oxlint/plugins"; + +import type { ESTree, SourceCode } from "@oxlint/plugins"; + +type Parameter = ESTree.ParamPattern; +type ParameterOwner = + | ESTree.ArrowFunctionExpression + | ESTree.Function + | ESTree.TSCallSignatureDeclaration + | ESTree.TSConstructSignatureDeclaration + | ESTree.TSConstructorType + | 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: { + type: "problem", + docs: { + description: + "Disallow object function parameters; inputs must use an owner-provided type and be parsed at their boundary.", + }, + messages: { + objectParameter: + "Parameter `{{parameter}}` accepts the broad `object` type. Use the expected owner type or decode the external input at its boundary.", + }, + }, + create(context) { + const aliases = new Map(); + + const resolvesToObject = (type: ESTree.TSType, visited = new Set()): boolean => { + if (type.type === "TSObjectKeyword") return true; + if (type.type === "TSParenthesizedType") + return resolvesToObject(type.typeAnnotation, visited); + if (type.type === "TSUnionType") { + return type.types.some((member) => resolvesToObject(member, visited)); + } + if ( + type.type !== "TSTypeReference" || + type.typeName.type !== "Identifier" || + (type.typeArguments !== null && + type.typeArguments !== undefined && + type.typeArguments.params.length > 0) || + visited.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, nextVisited); + }; + + const checkParameters = (node: ParameterOwner) => { + for (const parameter of node.params) { + const annotation = parameterAnnotation(parameter); + if (annotation === null || annotation === undefined) continue; + if (!resolvesToObject(annotation.typeAnnotation)) continue; + context.report({ + node: annotation.typeAnnotation, + messageId: "objectParameter", + data: { parameter: parameterName(parameter, context.sourceCode) }, + }); + } + }; + + return { + Program(node) { + 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); + } + } + }, + ArrowFunctionExpression: checkParameters, + FunctionDeclaration: checkParameters, + FunctionExpression: checkParameters, + TSCallSignatureDeclaration: checkParameters, + TSConstructSignatureDeclaration: checkParameters, + TSConstructorType: checkParameters, + TSDeclareFunction: checkParameters, + TSEmptyBodyFunctionExpression: checkParameters, + TSFunctionType: checkParameters, + TSMethodSignature: checkParameters, + }; + }, +}); diff --git a/.agents/skills/install-anti-slop/assets/anti-slop/rules/no-runtime-typeof.ts b/.agents/skills/install-anti-slop/assets/anti-slop/rules/no-runtime-typeof.ts new file mode 100644 index 000000000..858becf24 --- /dev/null +++ b/.agents/skills/install-anti-slop/assets/anti-slop/rules/no-runtime-typeof.ts @@ -0,0 +1,25 @@ +import { defineRule } from "@oxlint/plugins"; + +/** Disallow runtime typeof checks that narrow unparsed values instead of decoding them. */ +export const noRuntimeTypeofRule = defineRule({ + meta: { + type: "problem", + docs: { + description: + "Disallow runtime typeof checks; external values must be decoded into meaningful types at their I/O boundary.", + }, + messages: { + runtimeTypeof: + "A runtime `typeof` check only narrows an unparsed representation; it does not establish the expected contract. Parse the value into a strongly typed domain type at the earliest possible point, as close as possible to the I/O boundary where the data originated.", + }, + }, + create(context) { + return { + UnaryExpression(node) { + if (node.operator === "typeof") { + context.report({ node, messageId: "runtimeTypeof" }); + } + }, + }; + }, +}); diff --git a/.agents/skills/install-anti-slop/assets/anti-slop/rules/no-shape-in-symbol-names.ts b/.agents/skills/install-anti-slop/assets/anti-slop/rules/no-shape-in-symbol-names.ts new file mode 100644 index 000000000..3c18a75f0 --- /dev/null +++ b/.agents/skills/install-anti-slop/assets/anti-slop/rules/no-shape-in-symbol-names.ts @@ -0,0 +1,39 @@ +import { defineRule } from "@oxlint/plugins"; +import type { ESTree } from "@oxlint/plugins"; + +const FORBIDDEN_SYMBOL_NAME = "shape"; + +function containsForbiddenSymbolName(name: string): boolean { + return name.toLowerCase().includes(FORBIDDEN_SYMBOL_NAME); +} + +/** Ban the case-insensitive substring "shape" in every JavaScript and TypeScript symbol name. */ +export const noForbiddenTermInSymbolNamesRule = defineRule({ + meta: { + type: "problem", + docs: { + description: + 'Disallow the case-insensitive substring "shape" in JavaScript, TypeScript, private, and JSX symbol names.', + }, + messages: { + forbiddenSymbolName: + 'Do not use the case-insensitive substring "shape" in symbol names (found "{{name}}").', + }, + }, + create(context) { + const reportForbiddenSymbolName = (node: ESTree.Node & { name: string }) => { + if (!containsForbiddenSymbolName(node.name)) return; + context.report({ + node, + messageId: "forbiddenSymbolName", + data: { name: node.name }, + }); + }; + + return { + Identifier: reportForbiddenSymbolName, + PrivateIdentifier: reportForbiddenSymbolName, + JSXIdentifier: reportForbiddenSymbolName, + }; + }, +}); diff --git a/.agents/skills/install-anti-slop/assets/anti-slop/rules/no-unknown-parameters.ts b/.agents/skills/install-anti-slop/assets/anti-slop/rules/no-unknown-parameters.ts new file mode 100644 index 000000000..8b7588bc0 --- /dev/null +++ b/.agents/skills/install-anti-slop/assets/anti-slop/rules/no-unknown-parameters.ts @@ -0,0 +1,83 @@ +import { defineRule } from "@oxlint/plugins"; +import type { ESTree } from "@oxlint/plugins"; + +type Parameter = ESTree.ParamPattern; +type ParameterOwner = + | ESTree.ArrowFunctionExpression + | ESTree.Function + | ESTree.TSCallSignatureDeclaration + | ESTree.TSConstructSignatureDeclaration + | ESTree.TSConstructorType + | 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, ""); +} + +/** Disallow unknown inputs except explicitly named error-cause enrichment. */ +export const noUnknownParametersRule = defineRule({ + meta: { + type: "problem", + docs: { + description: + "Disallow explicitly unknown function parameters except `cause`; decode unknown input at its I/O boundary instead.", + }, + messages: { + unknownParameter: + "Parameter `{{parameter}}` accepts `unknown` without establishing its contract. Define the expected schema or parser so the value becomes a strongly typed domain type at the earliest possible point, as close as possible to the I/O boundary where the data originated.", + }, + }, + create(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; + context.report({ + node: annotation.typeAnnotation, + messageId: "unknownParameter", + data: { parameter: name }, + }); + } + }; + + return { + ArrowFunctionExpression: checkParameters, + FunctionDeclaration: checkParameters, + FunctionExpression: checkParameters, + TSCallSignatureDeclaration: checkParameters, + TSConstructSignatureDeclaration: checkParameters, + TSConstructorType: checkParameters, + TSDeclareFunction: checkParameters, + TSEmptyBodyFunctionExpression: checkParameters, + TSFunctionType: checkParameters, + TSMethodSignature: checkParameters, + }; + }, +}); diff --git a/.agents/skills/install-anti-slop/assets/anti-slop/rules/no-unknown-type-aliases.ts b/.agents/skills/install-anti-slop/assets/anti-slop/rules/no-unknown-type-aliases.ts new file mode 100644 index 000000000..c7c035d21 --- /dev/null +++ b/.agents/skills/install-anti-slop/assets/anti-slop/rules/no-unknown-type-aliases.ts @@ -0,0 +1,69 @@ +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; +} + +/** Ban named aliases that merely conceal TypeScript's unknown top type. */ +export const noUnknownTypeAliasesRule = defineRule({ + meta: { + type: "problem", + docs: { + description: + "Disallow type aliases whose resolved type is unknown; unknown must remain visible at an allowed boundary.", + }, + messages: { + unknownAlias: + "Type alias `{{alias}}` only renames `unknown`. Keep `unknown` explicit on an allowed `cause` field or replace it with the parsed owner type.", + }, + }, + create(context) { + const aliases = new Map(); + + 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); + }; + + return { + Program(node) { + 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 }, + }); + } + }, + }; + }, +}); diff --git a/.agents/skills/install-anti-slop/assets/anti-slop/rules/no-unsafe-dictionary-type.ts b/.agents/skills/install-anti-slop/assets/anti-slop/rules/no-unsafe-dictionary-type.ts new file mode 100644 index 000000000..afc0eb8d3 --- /dev/null +++ b/.agents/skills/install-anti-slop/assets/anti-slop/rules/no-unsafe-dictionary-type.ts @@ -0,0 +1,94 @@ +import { defineRule } from "@oxlint/plugins"; + +import { + classifyUnsafeDictionary, + classifyUnsafeDictionaryValue, + createTypeEnvironment, + type TypeEnvironment, +} from "../shared/dictionary-types.ts"; + +import type { ESTree } from "@oxlint/plugins"; + +function isTypeNode(node: ESTree.Node): node is ESTree.TSType { + return node.type.startsWith("TS") && node.type !== "TSTypeAnnotation"; +} + +function typeReferenceName(type: ESTree.TSTypeReference): string | null { + return type.typeName.type === "Identifier" ? type.typeName.name : null; +} + +function isInsideTypeAliasDeclaration(node: ESTree.Node): boolean { + let current: ESTree.Node | null = node.parent; + while (current !== null && current.type !== "Program") { + if (current.type === "TSTypeAliasDeclaration") return true; + current = current.parent; + } + return false; +} + +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); +} + +function shouldReportType(node: ESTree.TSType, environment: TypeEnvironment): boolean { + if (isPlainAliasConsumerUse(node, environment)) return false; + if (classifyUnsafeDictionary(node, environment) === null) return false; + let current: ESTree.Node | null = node.parent; + while (current !== null && current.type !== "Program") { + if (isTypeNode(current) && classifyUnsafeDictionary(current, environment) !== null) + return false; + current = current.parent; + } + return true; +} + +/** Disallow object-dictionary contracts whose direct value type is an unsafe escape hatch. */ +export const noUnsafeDictionaryTypeRule = defineRule({ + meta: { + type: "problem", + docs: { + description: + "Disallow object-dictionary contracts whose direct value type is unknown, any, object, {}, or a union/alias containing one of those escape hatches.", + }, + messages: { + unsafeDictionary: + "This object dictionary's direct value type is an unsafe {{value}} escape hatch. Replace it with a concrete owner/schema-derived value type and parse external data at its boundary.", + }, + }, + createOnce(context) { + let environment: TypeEnvironment | null = null; + const report = (node: ESTree.Node, value: string) => { + context.report({ node, messageId: "unsafeDictionary", data: { value } }); + }; + const reportIfUnsafe = (node: ESTree.TSType) => { + if (environment === null || !shouldReportType(node, environment)) return; + const unsafe = classifyUnsafeDictionary(node, environment); + if (unsafe === null) return; + report(node, unsafe.unsafeValue); + }; + + return { + Program(node) { + environment = createTypeEnvironment(node); + }, + TSTypeReference: reportIfUnsafe, + TSTypeLiteral: reportIfUnsafe, + TSMappedType: reportIfUnsafe, + TSIndexSignature(node) { + if ( + environment === null || + node.typeAnnotation === null || + node.parent.type === "TSTypeLiteral" + ) + return; + const unsafe = classifyUnsafeDictionaryValue( + node.typeAnnotation.typeAnnotation, + environment, + ); + if (unsafe !== null) report(node, unsafe.unsafeValue); + }, + }; + }, +}); diff --git a/.agents/skills/install-anti-slop/assets/anti-slop/rules/no-widen-then-assert.ts b/.agents/skills/install-anti-slop/assets/anti-slop/rules/no-widen-then-assert.ts new file mode 100644 index 000000000..876a2c226 --- /dev/null +++ b/.agents/skills/install-anti-slop/assets/anti-slop/rules/no-widen-then-assert.ts @@ -0,0 +1,363 @@ +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[0], + boundary: ESTree.Node | null, + visitedVariables: ReadonlySet, +): 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[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}}" erases established type evidence by widening the value, then reconstructs that evidence with a type assertion. Preserve the precise type end-to-end; if the input is genuinely unknown, parse it once at the boundary instead.', + }, + }, + create(context) { + const scopes = context.sourceCode.scopeManager.scopes; + + 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 { + TSAsExpression: checkAssertion, + TSTypeAssertion: checkAssertion, + }; + }, +}); diff --git a/.agents/skills/install-anti-slop/assets/anti-slop/shared/dictionary-types.ts b/.agents/skills/install-anti-slop/assets/anti-slop/shared/dictionary-types.ts new file mode 100644 index 000000000..0533e6289 --- /dev/null +++ b/.agents/skills/install-anti-slop/assets/anti-slop/shared/dictionary-types.ts @@ -0,0 +1,448 @@ +import type { ESTree } from "@oxlint/plugins"; + +const BUILT_INS = new Set([ + "Record", + "Readonly", + "Partial", + "Required", + "Pick", + "Omit", + "PropertyKey", + "NonNullable", +]); +const TRANSPARENT_WRAPPERS = new Set(["Readonly", "Partial", "Required", "NonNullable"]); + +type TypeAliasEnvironment = ReadonlyMap; + +type ResolvedType = { + readonly type: ESTree.TSType; + readonly substitutions: TypeAliasEnvironment; +}; + +export type UnsafeDictionary = { + readonly kind: "unsafe-dictionary"; + readonly unsafeValue: "any" | "empty-object" | "object" | "union" | "unknown"; +}; + +export type WideningTargetKind = + | "anonymous object" + | "generic container" + | "object" + | "open dictionary" + | "unknown"; + +export type WideningTarget = { + readonly kind: WideningTargetKind; +}; + +export type TypeEnvironment = { + readonly aliases: ReadonlyMap; + readonly interfaces: ReadonlyMap; + readonly shadowedBuiltIns: ReadonlySet; +}; + +function declaredStatement(statement: ESTree.Statement): ESTree.Node | null { + return statement.type === "ExportNamedDeclaration" || + statement.type === "ExportDefaultDeclaration" + ? (statement.declaration ?? null) + : statement; +} + +export function createTypeEnvironment(program: ESTree.Program): TypeEnvironment { + const aliases = new Map(); + 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); + } + } + + return { aliases, interfaces, shadowedBuiltIns }; +} + +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 isUnappliedReferenceTo(type: ESTree.TSType, name: string): boolean { + const unwrapped = unwrapTransparentType(type); + return ( + unwrapped.type === "TSTypeReference" && + typeReferenceName(unwrapped) === name && + (unwrapped.typeArguments === null || + unwrapped.typeArguments === undefined || + unwrapped.typeArguments.params.length === 0) + ); +} + +function unwrapTransparentType(type: ESTree.TSType): ESTree.TSType { + let current = type; + while ( + current.type === "TSParenthesizedType" || + (current.type === "TSTypeOperator" && current.operator === "readonly") + ) { + current = current.typeAnnotation; + } + return current; +} + +function isNeverType(type: ESTree.TSType): boolean { + return unwrapTransparentType(type).type === "TSNeverKeyword"; +} + +function isEffectivelyEmptyMember(member: ESTree.TSSignature): boolean { + return ( + member.type === "TSPropertySignature" && + member.optional === true && + member.typeAnnotation !== null && + member.typeAnnotation !== undefined && + isNeverType(member.typeAnnotation.typeAnnotation) + ); +} + +function isEffectivelyEmptyTypeLiteral(type: ESTree.TSTypeLiteral): boolean { + return type.members.length === 0 || type.members.every(isEffectivelyEmptyMember); +} + +function isEffectivelyEmptyInterface( + declarations: readonly ESTree.TSInterfaceDeclaration[], +): boolean { + if (declarations.length !== 1) return false; + const [type] = declarations; + return ( + type !== undefined && + type.extends.length === 0 && + (type.body.body.length === 0 || type.body.body.every(isEffectivelyEmptyMember)) + ); +} + +function resolvedSubstitutionArgument( + type: ESTree.TSType, + base: TypeAliasEnvironment, + resolving: ReadonlySet = new Set(), +): ESTree.TSType { + const unwrapped = unwrapTransparentType(type); + if (unwrapped.type !== "TSTypeReference") return type; + const name = typeReferenceName(unwrapped); + if (name === null || resolving.has(name)) return type; + const substitution = base.get(name); + if (substitution === undefined) return type; + const nextResolving = new Set(resolving); + nextResolving.add(name); + return resolvedSubstitutionArgument(substitution, base, nextResolving); +} + +function aliasSubstitution( + alias: ESTree.TSTypeAliasDeclaration, + type: ESTree.TSTypeReference, + base: TypeAliasEnvironment, +): TypeAliasEnvironment | null { + const parameters = alias.typeParameters?.params ?? []; + const arguments_ = type.typeArguments?.params ?? []; + const next = new Map(base); + for (const [index, parameter] of parameters.entries()) { + const argument = arguments_[index] ?? parameter.default; + if (argument === null || argument === undefined) return null; + next.set(parameter.name.name, resolvedSubstitutionArgument(argument, next)); + } + return next; +} + +function unsafeDirectValue( + type: ESTree.TSType, + environment: TypeEnvironment, + substitutions: TypeAliasEnvironment, + resolvingAliases: ReadonlySet, +): UnsafeDictionary["unsafeValue"] | null { + const unwrapped = unwrapTransparentType(type); + if (unwrapped.type === "TSUnknownKeyword") return "unknown"; + if (unwrapped.type === "TSAnyKeyword") return "any"; + if (unwrapped.type === "TSObjectKeyword") return "object"; + if (unwrapped.type === "TSTypeLiteral" && isEffectivelyEmptyTypeLiteral(unwrapped)) + return "empty-object"; + if (unwrapped.type === "TSUnionType") { + return unwrapped.types.some( + (member) => unsafeDirectValue(member, environment, substitutions, resolvingAliases) !== null, + ) + ? "union" + : null; + } + if (unwrapped.type === "TSIntersectionType") { + const unsafeMembers = unwrapped.types.map((member) => + unsafeDirectValue(member, environment, substitutions, resolvingAliases), + ); + if (unsafeMembers.includes("any")) return "any"; + return unsafeMembers.length > 0 && unsafeMembers.every((member) => member !== null) + ? unsafeMembers[0] + : null; + } + if (unwrapped.type !== "TSTypeReference") return null; + const name = typeReferenceName(unwrapped); + if (name === null) return null; + if (TRANSPARENT_WRAPPERS.has(name) && isBuiltIn(name, environment)) { + const wrapped = unwrapped.typeArguments?.params[0]; + return wrapped === undefined + ? null + : unsafeDirectValue(wrapped, environment, substitutions, resolvingAliases); + } + const substitution = substitutions.get(name); + if (substitution !== undefined) { + return isUnappliedReferenceTo(substitution, name) + ? null + : unsafeDirectValue(substitution, environment, substitutions, resolvingAliases); + } + const interfaceDeclarations = environment.interfaces.get(name); + if (interfaceDeclarations !== undefined) { + return isEffectivelyEmptyInterface(interfaceDeclarations) ? "empty-object" : null; + } + const alias = environment.aliases.get(name); + if (alias === undefined || resolvingAliases.has(name)) return null; + const nextSubstitutions = aliasSubstitution(alias, unwrapped, substitutions); + if (nextSubstitutions === null) return null; + const nextResolving = new Set(resolvingAliases); + nextResolving.add(name); + return unsafeDirectValue(alias.typeAnnotation, environment, nextSubstitutions, nextResolving); +} + +function dictionaryValueTypes( + type: ESTree.TSType, + environment: TypeEnvironment, + substitutions: TypeAliasEnvironment, + resolvingAliases: ReadonlySet, +): readonly ResolvedType[] { + const unwrapped = unwrapTransparentType(type); + + if (unwrapped.type === "TSTypeLiteral") { + return unwrapped.members.flatMap((member): readonly ResolvedType[] => + member.type === "TSIndexSignature" && member.typeAnnotation !== null + ? [{ type: member.typeAnnotation.typeAnnotation, substitutions }] + : [], + ); + } + + if (unwrapped.type === "TSMappedType") { + return unwrapped.typeAnnotation === null + ? [] + : [{ type: unwrapped.typeAnnotation, substitutions }]; + } + + if (unwrapped.type !== "TSTypeReference") return []; + const name = typeReferenceName(unwrapped); + if (name === null) return []; + + const substitution = substitutions.get(name); + if (substitution !== undefined) { + return isUnappliedReferenceTo(substitution, name) + ? [] + : dictionaryValueTypes(substitution, environment, substitutions, resolvingAliases); + } + + if (TRANSPARENT_WRAPPERS.has(name) && isBuiltIn(name, environment)) { + const wrapped = unwrapped.typeArguments?.params[0]; + return wrapped === undefined + ? [] + : dictionaryValueTypes(wrapped, environment, substitutions, resolvingAliases); + } + + if (name === "Record" && isBuiltIn(name, environment)) { + const value = unwrapped.typeArguments?.params[1] ?? null; + return value === null ? [] : [{ type: value, substitutions }]; + } + + if ((name === "Pick" || name === "Omit") && isBuiltIn(name, 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 nextSubstitutions = aliasSubstitution(alias, unwrapped, substitutions); + if (nextSubstitutions === null) return []; + const nextResolving = new Set(resolvingAliases); + nextResolving.add(name); + return dictionaryValueTypes(alias.typeAnnotation, environment, nextSubstitutions, nextResolving); +} + +export function classifyUnsafeDictionaryValue( + valueType: ESTree.TSType, + environment: TypeEnvironment, +): UnsafeDictionary | null { + const unsafeValue = unsafeDirectValue(valueType, environment, new Map(), new Set()); + return unsafeValue === null ? null : { kind: "unsafe-dictionary", unsafeValue }; +} + +export function classifyUnsafeDictionary( + type: ESTree.TSType, + environment: TypeEnvironment, +): UnsafeDictionary | null { + for (const valueType of dictionaryValueTypes(type, environment, new Map(), new Set())) { + const unsafeValue = unsafeDirectValue( + valueType.type, + environment, + valueType.substitutions, + new Set(), + ); + if (unsafeValue !== null) return { kind: "unsafe-dictionary", unsafeValue }; + } + 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, +): WideningTarget | null { + const unwrapped = unwrapTransparentType(type); + if (unwrapped.type === "TSUnknownKeyword") return { kind: "unknown" }; + if (unwrapped.type === "TSObjectKeyword") return { kind: "object" }; + if (unwrapped.type === "TSTypeLiteral") { + return unwrapped.members.some((member) => member.type === "TSIndexSignature") + ? { kind: "open dictionary" } + : unwrapped.members.length > 0 + ? { kind: "anonymous object" } + : null; + } + if (unwrapped.type === "TSMappedType") return { kind: "open dictionary" }; + if (unwrapped.type !== "TSTypeReference") return null; + const name = typeReferenceName(unwrapped); + if (name === null) return null; + if (TRANSPARENT_WRAPPERS.has(name) && isBuiltIn(name, 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 ((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 substitutions = aliasSubstitution(alias, unwrapped, new Map()); + if (substitutions === null) return null; + const resolved = classifyAliasBroadTarget( + alias.typeAnnotation, + environment, + substitutions, + new Set([name]), + ); + return resolved; +} + +function classifyAliasBroadTarget( + type: ESTree.TSType, + environment: TypeEnvironment, + substitutions: TypeAliasEnvironment, + resolvingAliases: ReadonlySet, +): WideningTarget | null { + const unwrapped = unwrapTransparentType(type); + if (unwrapped.type === "TSUnknownKeyword") return { kind: "unknown" }; + if (unwrapped.type === "TSObjectKeyword") return { kind: "object" }; + if (unwrapped.type !== "TSTypeReference") return null; + const name = typeReferenceName(unwrapped); + if (name === null) return null; + const substitution = substitutions.get(name); + if (substitution !== undefined) { + return classifyAliasBroadTarget(substitution, environment, substitutions, resolvingAliases); + } + const alias = environment.aliases.get(name); + if (alias === undefined || resolvingAliases.has(name)) return null; + const nextSubstitutions = aliasSubstitution(alias, unwrapped, substitutions); + if (nextSubstitutions === null) return null; + const nextResolving = new Set(resolvingAliases); + nextResolving.add(name); + return classifyAliasBroadTarget( + alias.typeAnnotation, + environment, + nextSubstitutions, + nextResolving, + ); +} + +export function isPopulatedObjectExpression(expression: ESTree.Expression): boolean { + let current = expression; + while ( + current.type === "ParenthesizedExpression" || + current.type === "TSAsExpression" || + current.type === "TSTypeAssertion" || + current.type === "TSNonNullExpression" + ) { + current = current.expression; + } + return current.type === "ObjectExpression" && current.properties.length > 0; +} + +export function isKnownEvidenceExpression(expression: ESTree.Expression): boolean { + let current = expression; + while ( + current.type === "ParenthesizedExpression" || + current.type === "TSAsExpression" || + current.type === "TSTypeAssertion" || + current.type === "TSNonNullExpression" || + current.type === "TSSatisfiesExpression" + ) { + current = current.expression; + } + if (current.type === "ObjectExpression") return true; + return ( + current.type === "ArrayExpression" || + current.type === "ArrowFunctionExpression" || + current.type === "ClassExpression" || + current.type === "FunctionExpression" || + current.type === "NewExpression" || + current.type === "Literal" || + current.type === "TemplateLiteral" || + current.type === "UnaryExpression" + ); +} diff --git a/.agents/skills/install-anti-slop/scripts/install.mjs b/.agents/skills/install-anti-slop/scripts/install.mjs new file mode 100644 index 000000000..6a66663fd --- /dev/null +++ b/.agents/skills/install-anti-slop/scripts/install.mjs @@ -0,0 +1,21 @@ +#!/usr/bin/env node +import { cpSync, existsSync, mkdirSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const skillRoot = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const source = resolve(skillRoot, "assets/anti-slop"); +const arguments_ = process.argv.slice(2); +const targetArgument = arguments_.find((argument) => !argument.startsWith("--")); +const target = resolve(process.cwd(), targetArgument ?? "tools/oxlint/anti-slop"); +const force = arguments_.includes("--force"); + +if (existsSync(target) && !force) { + console.error(`Refusing to overwrite ${target}. Re-run with --force only after reviewing the existing files.`); + process.exit(1); +} + +mkdirSync(dirname(target), { recursive: true }); +cpSync(source, target, { recursive: true, force }); +console.log(`Copied the anti-slop plugin to ${target}`); +console.log(`Configure Oxlint with: ${target}/index.ts`); diff --git a/.env.example b/.env.example index 3be3f9fb2..12fac543c 100644 --- a/.env.example +++ b/.env.example @@ -125,10 +125,6 @@ GOOGLE_CLIENT_SECRET="" # knowing before a call. https://perplexity.ai/settings/api # PERPLEXITY_API_KEY="" -# LinkDAPI via RapidAPI — LinkedIn profiles: name, title, employer, tenure. -# https://rapidapi.com/linkdapi/api/linkdapi-best-unofficial-linkedin-api -# RAPIDAPI_KEY="" - # GitHub — raises the rate limit when matching contacts to GitHub profiles. # Any classic token with no scopes will do. # GITHUB_TOKEN="" diff --git a/.githooks/pre-push b/.githooks/pre-push index d803088c9..fa0e86d07 100755 --- a/.githooks/pre-push +++ b/.githooks/pre-push @@ -21,7 +21,7 @@ fail() { exit 1 } -for task in check-types lint test; do +for task in check-types lint lint:slop test; do echo "pre-push: bun run $task" bun run "$task" || fail "$task" done diff --git a/.github/.release-please-manifest.json b/.github/.release-please-manifest.json index e4ce57a06..b57776035 100644 --- a/.github/.release-please-manifest.json +++ b/.github/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "1.13.0" + ".": "1.14.0" } diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6723eb1e0..bf7714763 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -64,4 +64,6 @@ jobs: - run: bun run lint + - run: bun run lint:slop + - run: bun run test diff --git a/.github/workflows/pr-title.yml b/.github/workflows/pr-title.yml index 023627beb..c289adbe8 100644 --- a/.github/workflows/pr-title.yml +++ b/.github/workflows/pr-title.yml @@ -61,10 +61,24 @@ jobs: body=$(printf '%s\n\n\n' \ "$(printf '%s\n' "$body" | grep -v '^