diff --git a/README.md b/README.md index 4a6dd03..ad4b908 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,7 @@ A versioned developer toolkit that keeps agent instructions, project conventions | Shared configuration | Biome and strict TypeScript presets for common stacks | Stable package export paths | | Portable gate engine | Decision, review, duplication, structure, size, fan-out, Sentry, and advisory gates | `guard-*` command-line tools | | Repository setup | Stack detection, idempotent installation, upgrades, diagnostics, and cleanup | The `devkit` CLI | +| Oxc + anti-slop | Exact Oxlint/Oxfmt pins, 15 vendored rules, and incremental debt adoption | Opt-in managed capability | The package and agent assets use the same release tag. A prompt or skill cannot silently drift away from the installer and gate implementation that consumes it. @@ -70,9 +71,24 @@ Package mode is the default. Standalone gates fail open when the pinned global C | `devkit review` | Run the configured gate chain against a trusted checkout without committing | | `devkit reconcile` | Refresh a shared checkout after shipped work merges | | `devkit clean` | Remove the recorded installation | +| `devkit anti-slop create/check/inspect/prune` | Manage the explicit shrink-only anti-slop baseline | Run `devkit help` for the command index and `devkit help ` for authoritative options. +### Adopt anti-slop incrementally + +```bash +devkit init --anti-slop +devkit anti-slop create +devkit anti-slop check +``` + +`--anti-slop` implies the opt-in Oxc capability. Rules load beside the repository's other +Oxlint rules; their severities and scoped overrides stay in the ordinary Oxlint config. Baseline +creation is always explicit, normal checks are read-only, and pruning can only remove fixed debt. +See the [anti-slop capability guide](docs/anti-slop.md) for provenance, the complete rule matrix, +and the fingerprint contract. + ### Review a trusted checkout ```bash diff --git a/anti-slop/LICENSE b/anti-slop/LICENSE new file mode 100644 index 0000000..69239ea --- /dev/null +++ b/anti-slop/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Dillon Mulroy + +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/anti-slop/UPSTREAM.md b/anti-slop/UPSTREAM.md new file mode 100644 index 0000000..6321275 --- /dev/null +++ b/anti-slop/UPSTREAM.md @@ -0,0 +1,9 @@ +# Vendored anti-slop provenance + +- Repository: https://github.com/dmmulroy/anti-slop +- Commit: `446268e5d15baa968eaec669ff65358d36ae6259` +- Vendored: 2026-08-16 +- License: MIT (see `LICENSE`) + +`src/` is copied from the upstream production source at the pinned commit. Devkit compiles it +unchanged and installs a self-contained `@oxlint/plugins@1.78.0` runtime beside the emitted plugin. diff --git a/anti-slop/src/index.ts b/anti-slop/src/index.ts new file mode 100644 index 0000000..2b4ae22 --- /dev/null +++ b/anti-slop/src/index.ts @@ -0,0 +1,41 @@ +import { eslintCompatPlugin } 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 { noModuleMockingRule } from "./rules/no-module-mocking.ts"; +import { noObjectParametersRule } from "./rules/no-object-parameters.ts"; +import { noReflectApplyRule } from "./rules/no-reflect-apply.ts"; +import { noReflectGetRule } from "./rules/no-reflect-get.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 { 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 { 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-chained-type-assertions": noChainedTypeAssertionsRule, + "no-conditional-empty-object-spread": noConditionalEmptyObjectSpreadRule, + "no-known-value-widening": noKnownValueWideningRule, + "no-module-mocking": noModuleMockingRule, + "no-object-parameters": noObjectParametersRule, + "no-reflect-apply": noReflectApplyRule, + "no-reflect-get": noReflectGetRule, + "no-runtime-typeof": noRuntimeTypeofRule, + "no-unsafe-dictionary-type": noUnsafeDictionaryTypeRule, + "no-shape-in-symbol-names": noForbiddenTermInSymbolNamesRule, + "no-unknown-parameters": noUnknownParametersRule, + "no-unknown-returns": noUnknownReturnsRule, + "no-unknown-type-aliases": noUnknownTypeAliasesRule, + "no-widen-then-assert": noWidenThenAssertRule, + "require-safety-comment-for-type-assertion": requireSafetyCommentForTypeAssertionRule, + }, +}); + +export default antiSlopPlugin; diff --git a/anti-slop/src/rules/no-chained-type-assertions.ts b/anti-slop/src/rules/no-chained-type-assertions.ts new file mode 100644 index 0000000..0d11852 --- /dev/null +++ b/anti-slop/src/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: + "This assertion chain discards type evidence. Keep the original precise type, or parse untrusted input at its boundary before narrowing it.", + }, + }, + createOnce(context) { + const checkTypeAssertion = (node: TypeAssertionExpression) => { + if (!isOutermostAssertionInChain(node) || !isForbiddenAssertionChain(node)) return; + context.report({ node, messageId: "chained" }); + }; + + return { + TSAsExpression: checkTypeAssertion, + TSTypeAssertion: checkTypeAssertion, + }; + }, +}); diff --git a/anti-slop/src/rules/no-conditional-empty-object-spread.ts b/anti-slop/src/rules/no-conditional-empty-object-spread.ts new file mode 100644 index 0000000..ae7248d --- /dev/null +++ b/anti-slop/src/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: + "This conditional spread hides property omission behind an empty object. Build the object in separate statements and add the property only when present.", + }, + }, + createOnce(context) { + return { + SpreadElement(node) { + if (node.parent.type !== "ObjectExpression") return; + + if (isConditionalEmptyObjectSpread(node.argument)) { + context.report({ node, messageId: "avoid" }); + } + }, + }; + }, +}); diff --git a/anti-slop/src/rules/no-known-value-widening.ts b/anti-slop/src/rules/no-known-value-widening.ts new file mode 100644 index 0000000..2a6806c --- /dev/null +++ b/anti-slop/src/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 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", + ); + }, + }; + }, +}); diff --git a/anti-slop/src/rules/no-module-mocking.ts b/anti-slop/src/rules/no-module-mocking.ts new file mode 100644 index 0000000..d6fb5b4 --- /dev/null +++ b/anti-slop/src/rules/no-module-mocking.ts @@ -0,0 +1,91 @@ +import { defineRule } from "@oxlint/plugins"; + +import type { ESTree, Scope, SourceCode, Variable } from "@oxlint/plugins"; + +const moduleMockMethods = new Set(["doMock", "mock", "unstable_mockModule"]); + +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 importedName(node: ESTree.Node): string | null { + if (node.type !== "ImportSpecifier") return null; + return node.imported.type === "Identifier" ? node.imported.name : node.imported.value; +} + +function isTestFrameworkObject( + sourceCode: SourceCode, + expression: ESTree.Expression, +): expression is ESTree.IdentifierReference { + if (expression.type !== "Identifier") return false; + if ( + (expression.name === "vi" || expression.name === "jest") && + sourceCode.isGlobalReference(expression) + ) { + return true; + } + + const variable = resolveVariable(sourceCode, expression); + if (variable === null || variable.defs.length === 0) { + return expression.name === "vi" || expression.name === "jest"; + } + return variable.defs.some((definition) => { + if (definition.type !== "ImportBinding" || definition.parent?.type !== "ImportDeclaration") { + return false; + } + const source = definition.parent.source.value; + const name = importedName(definition.node); + return (source === "vitest" && name === "vi") || (source === "@jest/globals" && name === "jest"); + }); +} + +function moduleMockCall(sourceCode: SourceCode, callee: ESTree.Expression): boolean { + if (!("property" in callee) || !("object" in callee) || !("computed" in callee)) return false; + if (!isTestFrameworkObject(sourceCode, callee.object)) return false; + const property = callee.property; + const method = callee.computed + ? property.type === "Literal" && + (property.value === "doMock" || + property.value === "mock" || + property.value === "unstable_mockModule") + ? property.value + : null + : property.type === "Identifier" + ? property.name + : null; + return method !== null && moduleMockMethods.has(method); +} + +/** Ban test framework module mocking in favor of real dependency seams. */ +export const noModuleMockingRule = defineRule({ + meta: { + type: "problem", + docs: { + description: + "Disallow Vitest and Jest module mocking; tests must replace dependencies through real interfaces.", + }, + messages: { + moduleMock: + "Replace module mocking with dependency injection through a real interface, service layer, or faithful test implementation.", + }, + }, + createOnce(context) { + return { + CallExpression(node) { + if (node.callee.type === "Super" || node.callee.type === "V8IntrinsicExpression") return; + if (moduleMockCall(context.sourceCode, node.callee)) { + context.report({ node, messageId: "moduleMock" }); + } + }, + }; + }, +}); diff --git a/anti-slop/src/rules/no-object-parameters.ts b/anti-slop/src/rules/no-object-parameters.ts new file mode 100644 index 0000000..29b990f --- /dev/null +++ b/anti-slop/src/rules/no-object-parameters.ts @@ -0,0 +1,126 @@ +import { defineRule } from "@oxlint/plugins"; + +import type { ESTree, SourceCode } from "@oxlint/plugins"; + +import { lexicalTypeParameterNames } from "../shared/lexical-type-parameters.ts"; + +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}}` uses the broad `object` type. Accept a named owner type; parse external input at its boundary before calling this function.", + }, + }, + createOnce(context) { + const aliases = new Map(); + + 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), + ); + } + 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); + if (annotation === null || annotation === undefined) continue; + if (!resolvesToObject(annotation.typeAnnotation, shadowedAliases)) continue; + context.report({ + node: annotation.typeAnnotation, + messageId: "objectParameter", + data: { parameter: parameterName(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); + } + } + }, + ArrowFunctionExpression: checkParameters, + FunctionDeclaration: checkParameters, + FunctionExpression: checkParameters, + TSCallSignatureDeclaration: checkParameters, + TSConstructSignatureDeclaration: checkParameters, + TSConstructorType: checkParameters, + TSDeclareFunction: checkParameters, + TSEmptyBodyFunctionExpression: checkParameters, + TSFunctionType: checkParameters, + TSMethodSignature: checkParameters, + }; + }, +}); diff --git a/anti-slop/src/rules/no-reflect-apply.ts b/anti-slop/src/rules/no-reflect-apply.ts new file mode 100644 index 0000000..2cc3045 --- /dev/null +++ b/anti-slop/src/rules/no-reflect-apply.ts @@ -0,0 +1,28 @@ +import { defineRule } from "@oxlint/plugins"; + +import { isGlobalReflectMethodCall } from "../shared/reflect-method.ts"; + +/** Ban Reflect.apply, which bypasses ordinary typed function calls. */ +export const noReflectApplyRule = defineRule({ + meta: { + type: "problem", + docs: { + description: + "Disallow Reflect.apply; call typed functions directly or model dynamic dispatch behind an interface.", + }, + messages: { + reflectApply: + "Replace `Reflect.apply` with a typed function call. Model dynamic dispatch behind a named interface.", + }, + }, + createOnce(context) { + return { + CallExpression(node) { + if (node.callee.type === "Super" || node.callee.type === "V8IntrinsicExpression") return; + if (isGlobalReflectMethodCall(context.sourceCode, node.callee, "apply")) { + context.report({ node, messageId: "reflectApply" }); + } + }, + }; + }, +}); diff --git a/anti-slop/src/rules/no-reflect-get.ts b/anti-slop/src/rules/no-reflect-get.ts new file mode 100644 index 0000000..cf630ec --- /dev/null +++ b/anti-slop/src/rules/no-reflect-get.ts @@ -0,0 +1,28 @@ +import { defineRule } from "@oxlint/plugins"; + +import { isGlobalReflectMethodCall } from "../shared/reflect-method.ts"; + +/** Ban Reflect.get, which bypasses ordinary property access and useful type evidence. */ +export const noReflectGetRule = defineRule({ + meta: { + type: "problem", + docs: { + description: + "Disallow Reflect.get; use typed property access or parse dynamic input into a domain type.", + }, + messages: { + reflectGet: + "Replace `Reflect.get` with typed property access. Parse dynamic input into a named domain type before reading it.", + }, + }, + createOnce(context) { + return { + CallExpression(node) { + if (node.callee.type === "Super" || node.callee.type === "V8IntrinsicExpression") return; + if (isGlobalReflectMethodCall(context.sourceCode, node.callee, "get")) { + context.report({ node, messageId: "reflectGet" }); + } + }, + }; + }, +}); diff --git a/anti-slop/src/rules/no-runtime-typeof.ts b/anti-slop/src/rules/no-runtime-typeof.ts new file mode 100644 index 0000000..6a25c24 --- /dev/null +++ b/anti-slop/src/rules/no-runtime-typeof.ts @@ -0,0 +1,67 @@ +import { defineRule } from "@oxlint/plugins"; + +import type { ESTree } from "@oxlint/plugins"; + +type RuntimeFunction = ESTree.ArrowFunctionExpression | ESTree.Function; + +function isRuntimeFunction(node: ESTree.Node): node is RuntimeFunction { + return ( + node.type === "ArrowFunctionExpression" || + node.type === "FunctionDeclaration" || + node.type === "FunctionExpression" + ); +} + +function isInsideTypeGuard(node: ESTree.Node): boolean { + let current: ESTree.Node | null = node.parent; + while (current !== null && current.type !== "Program") { + if (isRuntimeFunction(current)) { + return current.returnType?.typeAnnotation.type === "TSTypePredicate"; + } + current = current.parent; + } + return false; +} + +/** 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 `typeof` check narrows a representation without establishing its contract. Parse input at its I/O boundary, then branch on the domain value.", + }, + schema: [ + { + type: "object", + properties: { + allowInTypeGuards: { type: "boolean" }, + }, + additionalProperties: false, + }, + ], + defaultOptions: [{ allowInTypeGuards: false }], + }, + createOnce(context) { + return { + UnaryExpression(node) { + const option = context.options?.[0]; + const allowInTypeGuards = + typeof option === "object" && + option !== null && + !Array.isArray(option) && + option.allowInTypeGuards === true; + if ( + node.operator === "typeof" && + (!allowInTypeGuards || !isInsideTypeGuard(node)) + ) { + context.report({ node, messageId: "runtimeTypeof" }); + } + }, + }; + }, +}); diff --git a/anti-slop/src/rules/no-shape-in-symbol-names.ts b/anti-slop/src/rules/no-shape-in-symbol-names.ts new file mode 100644 index 0000000..afc00dd --- /dev/null +++ b/anti-slop/src/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: + 'Rename symbol "{{name}}" for its domain role; "shape" describes structure rather than ownership.', + }, + }, + createOnce(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/anti-slop/src/rules/no-unknown-parameters.ts b/anti-slop/src/rules/no-unknown-parameters.ts new file mode 100644 index 0000000..cdc6c23 --- /dev/null +++ b/anti-slop/src/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}}` leaves input unparsed. Accept a named domain type; run the expected schema or parser at the I/O boundary before calling this function.", + }, + }, + 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; + 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/anti-slop/src/rules/no-unknown-returns.ts b/anti-slop/src/rules/no-unknown-returns.ts new file mode 100644 index 0000000..4b16d6e --- /dev/null +++ b/anti-slop/src/rules/no-unknown-returns.ts @@ -0,0 +1,115 @@ +import { defineRule } from "@oxlint/plugins"; + +import type { ESTree } from "@oxlint/plugins"; + +import { lexicalTypeParameterNames } from "../shared/lexical-type-parameters.ts"; + +type FunctionWithReturnType = + | ESTree.ArrowFunctionExpression + | ESTree.Function + | ESTree.TSCallSignatureDeclaration + | ESTree.TSConstructSignatureDeclaration + | ESTree.TSConstructorType + | 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: { + type: "problem", + docs: { + description: + "Disallow functions whose explicit return contract is unknown or Promise.", + }, + messages: { + unknownReturn: + "This function exposes `unknown` to its caller. Parse the value at its boundary and return a named domain type.", + }, + }, + createOnce(context) { + const aliases = new Map(); + + 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 checkReturnType = (node: FunctionWithReturnType) => { + const annotation = node.returnType; + if (annotation === null || annotation === undefined) return; + if ( + !resolvesToUnknown( + annotation.typeAnnotation, + lexicalTypeParameterNames(node, context.sourceCode.visitorKeys), + ) + ) { + 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); + } + } + }, + ArrowFunctionExpression: checkReturnType, + FunctionDeclaration: checkReturnType, + FunctionExpression: checkReturnType, + TSCallSignatureDeclaration: checkReturnType, + TSConstructSignatureDeclaration: checkReturnType, + TSConstructorType: checkReturnType, + TSDeclareFunction: checkReturnType, + TSEmptyBodyFunctionExpression: checkReturnType, + TSFunctionType: checkReturnType, + TSMethodSignature: checkReturnType, + }; + }, +}); diff --git a/anti-slop/src/rules/no-unknown-type-aliases.ts b/anti-slop/src/rules/no-unknown-type-aliases.ts new file mode 100644 index 0000000..3e328fd --- /dev/null +++ b/anti-slop/src/rules/no-unknown-type-aliases.ts @@ -0,0 +1,70 @@ +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}}` hides `unknown`. Keep `unknown` explicit at the parsing boundary or on an allowed `cause` field; otherwise use the parsed owner type.", + }, + }, + createOnce(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) { + 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 }, + }); + } + }, + }; + }, +}); diff --git a/anti-slop/src/rules/no-unsafe-dictionary-type.ts b/anti-slop/src/rules/no-unsafe-dictionary-type.ts new file mode 100644 index 0000000..8c45eed --- /dev/null +++ b/anti-slop/src/rules/no-unsafe-dictionary-type.ts @@ -0,0 +1,134 @@ +import { defineRule } from "@oxlint/plugins"; + +import { + classifyUnsafeDictionary, + classifyUnsafeDictionaryValue, + createTypeEnvironment, + type TypeEnvironment, +} from "../shared/dictionary-types.ts"; + +import type { ESTree } from "@oxlint/plugins"; + +const typeNodeKinds: ReadonlySet = new Set([ + "JSDocNonNullableType", + "JSDocNullableType", + "JSDocUnknownType", + "TSAnyKeyword", + "TSArrayType", + "TSBigIntKeyword", + "TSBooleanKeyword", + "TSConditionalType", + "TSConstructorType", + "TSFunctionType", + "TSImportType", + "TSIndexedAccessType", + "TSInferType", + "TSIntersectionType", + "TSIntrinsicKeyword", + "TSLiteralType", + "TSMappedType", + "TSNamedTupleMember", + "TSNeverKeyword", + "TSNullKeyword", + "TSNumberKeyword", + "TSObjectKeyword", + "TSParenthesizedType", + "TSStringKeyword", + "TSSymbolKeyword", + "TSTemplateLiteralType", + "TSThisType", + "TSTupleType", + "TSTypeLiteral", + "TSTypeOperator", + "TSTypePredicate", + "TSTypeQuery", + "TSTypeReference", + "TSUndefinedKeyword", + "TSUnionType", + "TSUnknownKeyword", + "TSVoidKeyword", +]); + +function isTypeNode(node: ESTree.Node): node is ESTree.TSType { + return typeNodeKinds.has(node.type); +} + +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 dictionary's {{value}} value type gives callers no concrete value contract. Use an owner/schema-derived value type; parse external payloads before insertion.", + }, + }, + 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/anti-slop/src/rules/no-widen-then-assert.ts b/anti-slop/src/rules/no-widen-then-assert.ts new file mode 100644 index 0000000..c5e07f7 --- /dev/null +++ b/anti-slop/src/rules/no-widen-then-assert.ts @@ -0,0 +1,366 @@ +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}}" 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[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, + }; + }, +}); diff --git a/anti-slop/src/rules/require-safety-comment-for-type-assertion.ts b/anti-slop/src/rules/require-safety-comment-for-type-assertion.ts new file mode 100644 index 0000000..f1a2ffc --- /dev/null +++ b/anti-slop/src/rules/require-safety-comment-for-type-assertion.ts @@ -0,0 +1,62 @@ +import { defineRule } from "@oxlint/plugins"; + +import type { ESTree, SourceCode } from "@oxlint/plugins"; + +type TypeAssertion = ESTree.TSAsExpression | ESTree.TSTypeAssertion; + +const commentOwnerKinds = new Set([ + "ExpressionStatement", + "PropertyDefinition", + "ReturnStatement", + "ThrowStatement", + "VariableDeclaration", +]); + +function isConstAssertion(node: TypeAssertion): boolean { + return ( + node.typeAnnotation.type === "TSTypeReference" && + node.typeAnnotation.typeName.type === "Identifier" && + node.typeAnnotation.typeName.name === "const" + ); +} + +function hasSafetyComment(sourceCode: SourceCode, node: TypeAssertion): 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 (commentOwnerKinds.has(current.type) || current.parent.type === "Program") return false; + current = current.parent; + } +} + +/** Require every non-const type assertion to state the invariant TypeScript cannot express. */ +export const requireSafetyCommentForTypeAssertionRule = defineRule({ + meta: { + type: "problem", + docs: { + description: + "Require a nearby SAFETY comment for every TypeScript type assertion except const assertions.", + }, + messages: { + missingSafetyComment: + "This type assertion has no `SAFETY:` justification. State the checked invariant immediately before the assertion or its containing statement.", + }, + }, + createOnce(context) { + const checkAssertion = (node: TypeAssertion) => { + if (isConstAssertion(node) || hasSafetyComment(context.sourceCode, node)) return; + context.report({ node, messageId: "missingSafetyComment" }); + }; + + return { + TSAsExpression: checkAssertion, + TSTypeAssertion: checkAssertion, + }; + }, +}); diff --git a/anti-slop/src/shared/dictionary-types.ts b/anti-slop/src/shared/dictionary-types.ts new file mode 100644 index 0000000..8651700 --- /dev/null +++ b/anti-slop/src/shared/dictionary-types.ts @@ -0,0 +1,502 @@ +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 isBroadMappedKey( + type: ESTree.TSType, + environment: TypeEnvironment, + substitutions: TypeAliasEnvironment, +): boolean { + const unwrapped = unwrapTransparentType(type); + if ( + unwrapped.type === "TSStringKeyword" || + unwrapped.type === "TSNumberKeyword" || + unwrapped.type === "TSSymbolKeyword" + ) { + return true; + } + if (unwrapped.type === "TSUnionType") { + return unwrapped.types.every((member) => + isBroadMappedKey(member, environment, substitutions), + ); + } + if (unwrapped.type !== "TSTypeReference") return false; + const name = typeReferenceName(unwrapped); + if (name === null) return false; + const substitution = substitutions.get(name); + if (substitution !== undefined && !isUnappliedReferenceTo(substitution, name)) { + return isBroadMappedKey(substitution, environment, substitutions); + } + return name === "PropertyKey" && isBuiltIn(name, environment); +} + +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 === "TSTypeLiteral") { + return unwrapped.members.some((member) => member.type === "TSIndexSignature") + ? { kind: "open dictionary" } + : null; + } + if (unwrapped.type === "TSMappedType") { + return isBroadMappedKey(unwrapped.constraint, environment, substitutions) + ? { kind: "open dictionary" } + : null; + } + if (unwrapped.type !== "TSTypeReference") return null; + const name = typeReferenceName(unwrapped); + if (name === null) return null; + const substitution = substitutions.get(name); + if (substitution !== undefined) { + return isUnappliedReferenceTo(substitution, name) + ? null + : classifyAliasBroadTarget( + substitution, + environment, + substitutions, + resolvingAliases, + ); + } + if (TRANSPARENT_WRAPPERS.has(name) && isBuiltIn(name, 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" }; + } + 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/anti-slop/src/shared/lexical-type-parameters.ts b/anti-slop/src/shared/lexical-type-parameters.ts new file mode 100644 index 0000000..7cdb18c --- /dev/null +++ b/anti-slop/src/shared/lexical-type-parameters.ts @@ -0,0 +1,61 @@ +import type { ESTree } from "@oxlint/plugins"; + +type VisitorKeys = Readonly>; + +function isNode(value: unknown): value is ESTree.Node { + return ( + typeof value === "object" && + value !== null && + "type" in value && + typeof value.type === "string" + ); +} + +function collectInferTypeParameterNames( + node: ESTree.Node, + visitorKeys: VisitorKeys, + names: Set, +): void { + if (node.type === "TSInferType") names.add(node.typeParameter.name.name); + const record = node as unknown as Readonly>; + for (const key of visitorKeys[node.type] ?? []) { + const value = record[key]; + if (isNode(value)) { + collectInferTypeParameterNames(value, visitorKeys, names); + continue; + } + if (!Array.isArray(value)) continue; + for (const child of value) { + if (isNode(child)) collectInferTypeParameterNames(child, visitorKeys, names); + } + } +} + +/** Collect type binders that are in scope at a node and can shadow module aliases. */ +export function lexicalTypeParameterNames( + node: ESTree.Node, + visitorKeys: VisitorKeys, +): ReadonlySet { + const names = new Set(); + let descendant: ESTree.Node = node; + let current: ESTree.Node | null = node; + while (current !== null && current.type !== "Program") { + if ("typeParameters" in current) { + for (const parameter of current.typeParameters?.params ?? []) { + names.add(parameter.name.name); + } + } + if ( + current.type === "TSMappedType" && + (descendant === current.nameType || descendant === current.typeAnnotation) + ) { + names.add(current.key.name); + } + if (current.type === "TSConditionalType" && descendant === current.trueType) { + collectInferTypeParameterNames(current.extendsType, visitorKeys, names); + } + descendant = current; + current = current.parent; + } + return names; +} diff --git a/anti-slop/src/shared/reflect-method.ts b/anti-slop/src/shared/reflect-method.ts new file mode 100644 index 0000000..39bc218 --- /dev/null +++ b/anti-slop/src/shared/reflect-method.ts @@ -0,0 +1,35 @@ +import type { ESTree, Scope, SourceCode, Variable } 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; +} + +function isGlobalReflect(sourceCode: SourceCode, expression: ESTree.Expression): boolean { + if (expression.type !== "Identifier" || expression.name !== "Reflect") return false; + if (sourceCode.isGlobalReference(expression)) return true; + const variable = resolveVariable(sourceCode, expression); + return variable === null || variable.defs.length === 0; +} + +/** Reports whether a call target names one method on the global Reflect object. */ +export function isGlobalReflectMethodCall( + sourceCode: SourceCode, + callee: ESTree.Expression, + methodName: string, +): boolean { + if (!("property" in callee) || !("object" in callee) || !("computed" in callee)) return false; + if (!isGlobalReflect(sourceCode, callee.object)) return false; + const property = callee.property; + return callee.computed + ? property.type === "Literal" && property.value === methodName + : property.type === "Identifier" && property.name === methodName; +} diff --git a/bun.lock b/bun.lock index 818d521..a0b30a3 100644 --- a/bun.lock +++ b/bun.lock @@ -5,6 +5,7 @@ "name": "@norvalbv/devkit", "dependencies": { "@clack/prompts": "^1.5.1", + "@oxlint/plugins": "1.78.0", "es-module-lexer": "^2.1.0", "eslint": "^10.5.0", "eslint-plugin-project-structure": "^3.14.3", @@ -193,6 +194,8 @@ "@oxlint/binding-win32-x64-msvc": ["@oxlint/binding-win32-x64-msvc@1.78.0", "", { "os": "win32", "cpu": "x64" }, "sha512-Sb5ocmLSuYeOuXd+CFOToGKp/gjXUEWDnvIGwhnh8aq8wY4TMmEnKnvbogSW7RdMZv77JSARduS7/gv+khYEjA=="], + "@oxlint/plugins": ["@oxlint/plugins@1.78.0", "", {}, "sha512-Ypt8KeRYw+4jUtlPirfcHWMrn5ms12VrrFPD+Mds477/7tJxG1Kcz2Yrg2nVcTQEUx/GdlhS+BUg1kmxNm04Ug=="], + "@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.0.3", "", { "os": "android", "cpu": "arm64" }, "sha512-454rs7jHngixp/NMxd5srYD57OnzSlZ/eFTETjORQHLwJG1lRtmNOJcBerZlfu4GjKqeq8aCCIQrMdHyhI51Hw=="], "@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.0.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-PcAhP+ynjURNyy8SKGl5DQP94aGuB/7JrXJb/t7P+hanXvQVMWzUvRRhBAcg/lNRadBhoUPqSoP4xw5tR/KBEA=="], diff --git a/cli/__tests__/apply-init.test.mts b/cli/__tests__/apply-init.test.mts index 27a129c..4700135 100644 --- a/cli/__tests__/apply-init.test.mts +++ b/cli/__tests__/apply-init.test.mts @@ -114,6 +114,22 @@ describe('selection helpers', () => { expect(selectionFromFlags(parseFlags(['--yes', '--oxc', '--no-oxc'])).oxc).toBe(false); }); + it('anti-slop is OPT-IN, implies Oxc, and yields to an explicit --no-oxc', () => { + expect(selectionFromFlags(parseFlags(['--yes'])).antiSlop).toBe(false); + expect(selectionFromFlags(parseFlags(['--yes', '--anti-slop']))).toMatchObject({ + antiSlop: true, + oxc: true, + }); + expect(selectionFromFlags(parseFlags(['--yes', '--anti-slop', '--no-oxc']))).toMatchObject({ + antiSlop: false, + oxc: false, + }); + expect(normalizeSelection({ antiSlop: true, oxc: false })).toMatchObject({ + antiSlop: true, + oxc: true, + }); + }); + it('lineGrowth is recommended-ON: default true, off with --no-line-growth', () => { expect(selectionFromFlags(parseFlags(['--yes'])).lineGrowth).toBe(true); expect(selectionFromFlags(parseFlags(['--yes', '--no-line-growth'])).lineGrowth).toBe(false); @@ -556,9 +572,12 @@ describe('detectInstalled', () => { it('falls back to on-disk detection without a components block', () => { const root = tmpRepo(); writeFileSync(join(root, 'biome.jsonc'), '{}'); + mkdirSync(join(root, '.devkit/anti-slop'), { recursive: true }); + writeFileSync(join(root, '.devkit/anti-slop/manifest.json'), '{}'); const installed = detectInstalled(root); expect(installed.has('biome')).toBe(true); expect(installed.has('tsconfig')).toBe(false); + expect(installed.has('antiSlop')).toBe(true); }); }); diff --git a/cli/__tests__/clean.test.mts b/cli/__tests__/clean.test.mts index 088539b..06f331f 100644 --- a/cli/__tests__/clean.test.mts +++ b/cli/__tests__/clean.test.mts @@ -53,6 +53,20 @@ describe('clean (package mode)', () => { expect(existsSync(join(root, 'guard.config.json'))).toBe(true); }); + it('removes Oxc before anti-slop so a config collision cannot strand a partial clean', () => { + const root = tmpRepo(); + expect( + devkit(root, 'init', '--stack', 'generic', '--yes', '--anti-slop', '--no-husky').status, + ).toBe(0); + writeFileSync(join(root, 'oxlint.config.ts'), 'export default {};\n'); + + const result = devkit(root, 'clean', '--yes'); + + expect(result.status).toBe(0); + expect(existsSync(join(root, '.devkit'))).toBe(false); + expect(existsSync(join(root, 'oxlint.config.ts'))).toBe(true); + }); + it('exposes Codex-only overlay remnants when their ownership records are gone', () => { const root = tmpRepo(); execFileSync('git', ['init'], { cwd: root, stdio: 'ignore' }); diff --git a/cli/commands/clean.mts b/cli/commands/clean.mts index c5ed394..4b72b60 100644 --- a/cli/commands/clean.mts +++ b/cli/commands/clean.mts @@ -22,6 +22,7 @@ import { resolveExistingAgentProviders, SUPPORTED_AGENT_PROVIDERS, } from '../lib/install/agent-assets/agent-providers.mts'; +import { removeAntiSlopCapability } from '../lib/install/anti-slop/lifecycle.mts'; import { pruneDevkitCacheGitignore } from '../lib/install/gitignore-cache.mts'; import { removeHookRegistrations, removeHookScripts } from '../lib/install/install-hooks.mts'; import { removeSearchCode } from '../lib/install/install-search-code.mts'; @@ -38,6 +39,7 @@ interface DevkitComponents { searchSteering?: boolean; fallow?: boolean; oxc?: boolean; + antiSlop?: boolean; searchCode?: boolean; guards?: string[]; agentTargets?: string[]; @@ -348,6 +350,8 @@ function cleanPackage(cwd: string, cfg: DevkitConfig, dryRun: boolean): void { // capability but a later config write failed (or an older config lost the component key). if (cfg.components?.oxc || existsSync(join(cwd, '.devkit', 'oxc', 'manifest.json'))) removeOxcCapability(cwd, dryRun); + if (cfg.components?.antiSlop || existsSync(join(cwd, '.devkit', 'anti-slop', 'manifest.json'))) + removeAntiSlopCapability(cwd, dryRun); // Regenerated gate caches: init adds these .gitignore lines on every package/standalone install // (the gate engine writes them regardless of components), so reverse them unconditionally. pruneDevkitCacheGitignore(cwd, dryRun); diff --git a/cli/commands/doctor.mts b/cli/commands/doctor.mts index eef10e7..07f1607 100644 --- a/cli/commands/doctor.mts +++ b/cli/commands/doctor.mts @@ -28,6 +28,10 @@ import { resolveExistingAgentProviders, SUPPORTED_AGENT_PROVIDERS, } from '../lib/install/agent-assets/agent-providers.mts'; +import { + checkAntiSlopCapability, + syncAntiSlopCapability, +} from '../lib/install/anti-slop/lifecycle.mts'; import { selectedHookAssets } from '../lib/install/hook-registration-ledger/selection.mts'; import { checkOxcCapability, syncOxcCapability } from '../lib/install/oxc/lifecycle.mts'; import { cmpSemver, fetchLatestTag } from './update.mts'; @@ -173,6 +177,7 @@ function selectionFlags(sel: Partial): string[] { ['adhd', '--adhd'], ['priorArtGate', '--prior-art-gate'], ['oxc', '--oxc'], + ['antiSlop', '--anti-slop'], ] as const) if (sel[id]) flags.push(flag); if (!sel.guards?.length) flags.push('--no-guards'); @@ -216,6 +221,9 @@ function applyFix( const needsOxcSync = Boolean(sel.oxc) && results.some((r) => OXC_CHECKS.has(r.name) && r.fixable && r.status !== 'OK'); + const needsAntiSlopSync = + Boolean(sel.antiSlop) && + results.some((r) => r.name.startsWith('anti-slop') && r.fixable && r.status !== 'OK'); const needsInit = results.some( (r) => r.fixable && @@ -253,7 +261,9 @@ function applyFix( stdio: 'inherit', }); } - if (needsOxcSync) syncOxcCapability(cwd); + if (needsAntiSlopSync) syncAntiSlopCapability(cwd); + if (needsOxcSync && !needsAntiSlopSync) + syncOxcCapability(cwd, { antiSlop: sel.antiSlop === true }); const skills = results.find((r) => r.name === 'skills'); if (skills?.fixable && skills.status !== 'OK') { execFileSync(process.execPath, [join(packageDir(), 'cli', `index${SELF_EXT}`), 'sync-skills'], { @@ -359,6 +369,7 @@ async function collectResults( if (sel.adhd) results.push(checkAdhdSkill(cwd)); if (sel.searchSteering) results.push(checkSearchToolBins()); if (sel.oxc) results.push(...checkOxcCapability(cwd)); + if (sel.antiSlop) results.push(...checkAntiSlopCapability(cwd)); if (surfaces.length) results.push(checkRegistrations(cwd, hooks.components, surfaces)); if (sel.guards?.includes('fanout') || sel.guards?.includes('size')) results.push(checkBaselines(cwd)); diff --git a/cli/commands/init.mts b/cli/commands/init.mts index af3a4d8..7f4b123 100644 --- a/cli/commands/init.mts +++ b/cli/commands/init.mts @@ -49,7 +49,8 @@ import { installSelfHostHook, isDevkitRepo, selfHostSelection } from '../lib/hus import { ADHD_SKILL_DIR, syncAdhdSkill } from '../lib/install/adhd-skill.mts'; import { installAgentSurfaces as syncSurfaces } from '../lib/install/agent-assets/agent-surfaces.mts'; import { resolveAssetConflicts } from '../lib/install/agent-assets/asset-conflict-picker.mts'; -import { parseFlags, selectionFromFlags } from '../lib/install/flags/init-flags.mts'; +import * as antiSlopLifecycle from '../lib/install/anti-slop/lifecycle.mts'; +import * as initFlags from '../lib/install/flags/init-flags.mts'; import { reviewPlanFromFlags } from '../lib/install/flags/review-profile.mts'; import { ensureDevkitCacheGitignore } from '../lib/install/gitignore-cache.mts'; import { @@ -117,6 +118,7 @@ interface RecordedComponents { structure?: boolean; fallow?: boolean; oxc?: boolean; + antiSlop?: boolean; searchCode?: boolean; lineGrowth?: boolean; adhd?: boolean; @@ -194,7 +196,7 @@ type HookSelectionInput = Selection & { structureCmd?: string }; // Which components are currently wired? Read the recorded set first (authoritative), then // fall back to on-disk detection for a pre-wizard repo with no `components` block. -function detectInstalled(cwd: string) { +export function detectInstalled(cwd: string) { const cfg = readJson(join(cwd, '.devkit', 'config.json')) as DevkitConfig | null; const installed = new Set(); const recorded = cfg?.components; @@ -212,6 +214,7 @@ function detectInstalled(cwd: string) { if (existsSync(join(cwd, 'tsconfig.json'))) installed.add('tsconfig'); if (existsSync(join(cwd, 'eslint.config.mjs'))) installed.add('structure'); if (existsSync(join(cwd, '.devkit', 'oxc', 'manifest.json'))) installed.add('oxc'); + if (existsSync(join(cwd, '.devkit', 'anti-slop', 'manifest.json'))) installed.add('antiSlop'); const { gitRoot } = detectGitRoot(cwd); if (existsSync(join(gitRoot, '.devkit', 'skills-manifest.json'))) installed.add('skills'); if (existsSync(join(gitRoot, '.devkit', 'agents-manifest.json'))) installed.add('agents'); @@ -682,7 +685,6 @@ function removeStructure(cwd: string, prevConfig: DevkitConfig | null, dryRun: b } } -// Reason: flat removal dispatch: one `if (remove.includes(id)) removeX()` per component, ordered so guards (line-level) precede husky (block-level); high branch COUNT mirrors the component list, each branch a single delegated call // fallow-ignore-next-line complexity function applyRemovals( cwd: string, @@ -703,10 +705,10 @@ function applyRemovals( if (remove.includes('skills')) removeSkills(gitRoot, dryRun); if (remove.includes('agents')) removeAgents(gitRoot, dryRun); // Agent-hook scripts + registrations are exact-reconciled by installAgentSurfaces before this - // removal pass. Re-removing them here would also delete a decisions-owned hook that survives a - // general agentHooks deselection. + // Avoid deleting a decisions-owned hook that survives a general agentHooks deselection. if (remove.includes('structure')) removeStructure(cwd, prevConfig, dryRun); if (remove.includes('oxc')) oxcLifecycle.removeOxcCapability(cwd, dryRun); + if (remove.includes('antiSlop')) antiSlopLifecycle.removeAntiSlopCapability(cwd, dryRun); if (remove.includes('husky')) removeHusky(gitRoot, pkgRel, dryRun); } @@ -752,6 +754,7 @@ function applyOverlay(cwd: string, plan: InitPlan, pkgRel: string, devkitRef: st searchSteering: false, // never wired in overlay (no resolvable bin without the package) fallow: fallowWired, oxc: false, + antiSlop: false, adhd: Boolean(selection.adhd), priorArtGate: Boolean(selection.priorArtGate), agentTargets: [...(selection.agentTargets ?? AGENT_TARGETS)], @@ -838,11 +841,7 @@ export async function applyInit(cwd: string, plan: InitPlan) { selection.structure && STRUCTURE_STACKS.has(stack) && (!standalone || CONFIG_DRIVEN_STRUCTURE.has(stack)); - // The stack-resolved structure-lint command, joined to the deterministic orchestrator via - // `--structure` (so a structure violation lands in the SAME aggregated report as the guards). - // Config-driven stacks run devkit's own `guard-structure` bin (no consumer eslint dep — the - // orchestrator resolves it as a sibling module); electron keeps its consumer-side `bunx eslint - // src`. Undefined when structure is off → no `--structure` arg emitted. + // Resolve the structure command once so hook generation and the recorded selection agree. const structureCmd = isStructure ? structureCmdFor(stack) : undefined; const devkitPkg = readJson(join(packageDir(), 'package.json')) as { version?: string; @@ -971,7 +970,9 @@ export async function applyInit(cwd: string, plan: InitPlan) { installSearchCode(cwd, dryRun); } - if (selection.oxc && !selfHost) oxcLifecycle.syncOxcCapability(cwd, { dryRun }); + if (selection.oxc && !selection.antiSlop && !selfHost) + oxcLifecycle.syncOxcCapability(cwd, { dryRun, antiSlop: false }); + if (selection.antiSlop && !selfHost) antiSlopLifecycle.syncAntiSlopCapability(cwd, { dryRun }); // The vendored i-have-adhd skill, into devkit's own tree rather than the agent skills dirs — so it // no longer depends on the `skills` component. Called unconditionally: a false selection reclaims a @@ -996,6 +997,7 @@ export async function applyInit(cwd: string, plan: InitPlan) { structure: isStructure, fallow: Boolean(selection.fallow), oxc: Boolean(selection.oxc && !selfHost), + antiSlop: Boolean(selection.antiSlop && !selfHost), searchCode: Boolean(selection.searchCode), lineGrowth: Boolean(selection.lineGrowth), // Always written, including `false` — an ABSENT key is what marks a repo as never-offered, so @@ -1075,7 +1077,7 @@ export const meta = { // Reason: flat CLI dispatch: resolves one `selection` via three converging paths (interactive wizard / --yes flags / non-TTY) then hands a fully-resolved plan to applyInit; the branches ARE the resolution-mode fork, each path linear with no shared nesting // fallow-ignore-next-line complexity export default async function run(args: string[], cwd: string) { - const flags = parseFlags(args); + const flags = initFlags.parseFlags(args); const detectedStack = flags.stack ?? detectStack(cwd); // Mode: --overlay / --standalone seed it; the wizard asks (so the interactive flow exposes it). const detectedMode = flags.overlay ? 'overlay' : flags.standalone ? 'standalone' : 'package'; @@ -1110,7 +1112,6 @@ export default async function run(args: string[], cwd: string) { return 0; } - // Self-host is package-name detected and deterministic, bypassing wizard/flags to preserve its bespoke config. const selfHost = isDevkitRepo(cwd); if (selfHost) { mode = 'self-host'; @@ -1127,14 +1128,14 @@ export default async function run(args: string[], cwd: string) { }); if (!result) return 0; // cancelled — nothing written ({ mode, stack, remove, review } = result); - // The wizard returns a complete selection after overlay constraints fill package-only fields. selection = result.selection as Selection; } else { - selection = selectionFromFlags(flags); + selection = initFlags.selectionFromFlags(flags); + selection = initFlags.recoverInterruptedCapabilitySelection(cwd, flags, selection); } - // Resolve overlay invariants before consumers validate or record them (Husky is always effective). oxcLifecycle.warnIfOxcUnavailable(mode, flags.oxc); + antiSlopLifecycle.warnIfAntiSlopUnavailable(mode, flags.antiSlop); if (mode === 'overlay') selection = applyOverlayConstraints(selection); if (!selfHost && !interactive) { const reviewPlan = reviewPlanFromFlags(flags, selection); @@ -1182,6 +1183,5 @@ export default async function run(args: string[], cwd: string) { return 0; } -// parseFlags/selectionFromFlags re-exported for existing test importers; they live in -// cli/lib/install/flags/init-flags.mts now. -export { detectInstalled, parseFlags, selectionFromFlags }; +// Re-export flag helpers for existing test importers; their implementation lives under install/flags. +export { parseFlags, selectionFromFlags } from '../lib/install/flags/init-flags.mts'; diff --git a/cli/commands/oxc/anti-slop.mts b/cli/commands/oxc/anti-slop.mts new file mode 100644 index 0000000..f87e734 --- /dev/null +++ b/cli/commands/oxc/anti-slop.mts @@ -0,0 +1,205 @@ +/** `devkit anti-slop` — explicit, deterministic shrink-only baseline operations. */ + +import { existsSync } from 'node:fs'; +import { join } from 'node:path'; +import { withLock } from '../../lib/atomic-write.mts'; +import { + type AntiSlopBaseline, + baselineFromGroups, + compareBaseline, + pruneBaseline, + readBaseline, + writeBaseline, +} from '../../lib/install/anti-slop/baseline.mts'; +import { + ANTI_SLOP_BASELINE_LOCK_REL, + ANTI_SLOP_BASELINE_REL, +} from '../../lib/install/anti-slop/constants.mts'; +import type { FindingGroup } from '../../lib/install/anti-slop/diagnostics.mts'; +import { + collectAntiSlopGroups, + resolveAntiSlopScope, +} from '../../lib/install/anti-slop/runner.mts'; + +export const meta = { + name: 'anti-slop', + summary: 'Check vendored anti-slop rules with an explicit shrink-only baseline.', + help: `devkit anti-slop — baseline-aware checks for Devkit's vendored Oxlint plugin. + +Usage: + devkit anti-slop create [--force] [paths...] Explicitly snapshot current findings + devkit anti-slop check [paths...] Fail on new error-severity findings (read-only) + devkit anti-slop inspect [--json] Inspect baseline debt without linting + devkit anti-slop prune [paths...] Remove fixed debt; never add findings + +Configure per-rule off/warn/error and scoped overrides in the repository Oxlint config. Paths default +to the repository root. Check and inspect never write. Create refuses an existing baseline unless +--force is explicit; prune refuses to write while new error-severity findings exist.`, +}; + +function baselineOrExplain(cwd: string): AntiSlopBaseline | null { + const baseline = readBaseline(cwd); + if (!baseline) { + console.error( + `anti-slop: ${ANTI_SLOP_BASELINE_REL} is missing; run \`devkit anti-slop create\` explicitly`, + ); + } + return baseline; +} + +function count(groups: readonly { count: number }[]): number { + return groups.reduce((sum, group) => sum + group.count, 0); +} + +function capabilityReady(cwd: string): boolean { + if (existsSync(join(cwd, '.devkit', 'anti-slop', 'manifest.json'))) return true; + console.error('anti-slop: not installed — run `devkit init --anti-slop`'); + return false; +} + +function printNew(groups: Array): void { + for (const group of groups) { + const tag = group.severity === 'error' ? 'ERROR' : 'WARN'; + console.log( + `${tag} ${group.ruleId} ${group.file}:${group.line}:${group.column} (+${group.additionalCount})`, + ); + console.log(` ${group.diagnostic}`); + } +} + +function create(cwd: string, args: string[], force: boolean): number { + if (!capabilityReady(cwd)) return 2; + return withLock(join(cwd, ANTI_SLOP_BASELINE_LOCK_REL), () => { + const path = join(cwd, ANTI_SLOP_BASELINE_REL); + if (existsSync(path) && !force) { + console.error( + `anti-slop: ${ANTI_SLOP_BASELINE_REL} already exists; use prune, or --force to replace it explicitly`, + ); + return 2; + } + const existing = existsSync(path) && args.length > 0 ? readBaseline(cwd) : null; + const groups = collectAntiSlopGroups(cwd, args); + const next = baselineFromGroups(groups); + if (existing) { + const scope = resolveAntiSlopScope(cwd, args); + next.entries = [ + ...existing.entries.filter((entry) => !scope.includes(entry.file)), + ...next.entries, + ].sort((a, b) => a.fingerprint.localeCompare(b.fingerprint)); + } + writeBaseline(cwd, next); + console.log( + `anti-slop: created ${ANTI_SLOP_BASELINE_REL} with ${count(next.entries)} finding(s) in ${next.entries.length} fingerprint(s)`, + ); + return 0; + }); +} + +function check(cwd: string, args: string[]): number { + const baseline = baselineOrExplain(cwd); + if (!baseline) return 2; + const scope = resolveAntiSlopScope(cwd, args); + const selected: AntiSlopBaseline = { + ...baseline, + entries: baseline.entries.filter((entry) => scope.includes(entry.file)), + }; + const comparison = compareBaseline(selected, collectAntiSlopGroups(cwd, args)); + printNew(comparison.newGroups); + const errors = comparison.newGroups.filter((group) => group.severity === 'error'); + const warnings = comparison.newGroups.filter((group) => group.severity === 'warning'); + if (errors.length > 0) { + console.error( + `anti-slop: FAIL — ${errors.reduce((sum, group) => sum + group.additionalCount, 0)} new error finding(s); baseline unchanged`, + ); + return 1; + } + console.log( + `anti-slop: PASS — ${comparison.currentCount} current finding(s), ${comparison.resolvedCount} ready to prune${warnings.length ? `, ${warnings.length} warning fingerprint(s)` : ''}`, + ); + return 0; +} + +function inspect(cwd: string, json: boolean): number { + const baseline = baselineOrExplain(cwd); + if (!baseline) return 2; + if (json) { + console.log(JSON.stringify(baseline, null, 2)); + return 0; + } + const perRule = new Map(); + for (const entry of baseline.entries) + perRule.set(entry.ruleId, (perRule.get(entry.ruleId) ?? 0) + entry.count); + console.log( + `anti-slop baseline: ${baseline.entries.reduce((sum, entry) => sum + entry.count, 0)} finding(s), ${baseline.entries.length} fingerprint(s)`, + ); + for (const [rule, findings] of [...perRule].sort(([a], [b]) => a.localeCompare(b))) { + console.log(` ${String(findings).padStart(5)} ${rule}`); + } + return 0; +} + +function prune(cwd: string, args: string[]): number { + if (!capabilityReady(cwd)) return 2; + return withLock(join(cwd, ANTI_SLOP_BASELINE_LOCK_REL), () => { + const baseline = baselineOrExplain(cwd); + if (!baseline) return 2; + const scope = resolveAntiSlopScope(cwd, args); + const groups = collectAntiSlopGroups(cwd, args); + const selected: AntiSlopBaseline = { + ...baseline, + entries: baseline.entries.filter((entry) => scope.includes(entry.file)), + }; + const comparison = compareBaseline(selected, groups); + printNew(comparison.newGroups); + if (comparison.newGroups.some((group) => group.severity === 'error')) { + console.error('anti-slop: prune refused — new error finding(s) exist; baseline unchanged'); + return 1; + } + const pruned = pruneBaseline(selected, groups); + const next: AntiSlopBaseline = { + ...baseline, + entries: [ + ...baseline.entries.filter((entry) => !scope.includes(entry.file)), + ...pruned.entries, + ].sort((a, b) => a.fingerprint.localeCompare(b.fingerprint)), + }; + writeBaseline(cwd, next); + console.log( + `anti-slop: pruned ${comparison.resolvedCount} fixed finding(s); ${next.entries.reduce((sum, entry) => sum + entry.count, 0)} remain`, + ); + return 0; + }); +} + +export default function run(args: string[], cwd: string): number { + const [operation, ...rest] = args; + const separator = rest.indexOf('--'); + const options = separator >= 0 ? rest.slice(0, separator) : rest; + const trailingPaths = separator >= 0 ? rest.slice(separator + 1) : []; + const force = options.includes('--force'); + const json = options.includes('--json'); + const paths = [ + ...options.filter((arg) => arg !== '--force' && arg !== '--json'), + ...(separator >= 0 ? ['--', ...trailingPaths] : []), + ]; + if (force && operation !== 'create') { + console.error('anti-slop: --force is accepted only by create'); + return 2; + } + if (json && operation !== 'inspect') { + console.error('anti-slop: --json is accepted only by inspect'); + return 2; + } + if (operation === 'create') return create(cwd, paths, force); + if (operation === 'check') return check(cwd, paths); + if (operation === 'inspect') { + if (paths.length > 0 || force) { + console.error('anti-slop inspect accepts only --json'); + return 2; + } + return inspect(cwd, json); + } + if (operation === 'prune') return prune(cwd, paths); + console.error('devkit anti-slop: expected create, check, inspect, or prune'); + return 2; +} diff --git a/cli/commands/upgrade.mts b/cli/commands/upgrade.mts index 18f4435..fb129d0 100644 --- a/cli/commands/upgrade.mts +++ b/cli/commands/upgrade.mts @@ -228,7 +228,7 @@ export default async function upgrade(args: string[], cwd: string): Promise = { upgrade: () => import('./commands/upgrade.mts'), move: () => import('./commands/move.mts'), oxc: () => import('./commands/oxc/oxc.mts'), + 'anti-slop': () => import('./commands/oxc/anti-slop.mts'), reconcile: () => import('./commands/reconcile.mts'), ship: () => import('./commands/ship.mts'), review: () => import('./commands/review.mts'), diff --git a/cli/lib/components.mts b/cli/lib/components.mts index be5a98e..e8b0eac 100644 --- a/cli/lib/components.mts +++ b/cli/lib/components.mts @@ -178,6 +178,8 @@ export interface Selection { fallow: boolean; /** Pinned Oxlint/Oxfmt runtime plus repository-local Oxc configuration. Opt-in. */ oxc: boolean; + /** Vendored anti-slop Oxlint plugin plus explicit shrink-only baseline workflow. Opt-in. */ + antiSlop: boolean; searchCode: boolean; /** * The per-file line-growth block: when on, `maxLines` is written into guard.config.json so the @@ -216,6 +218,7 @@ export const RECORDED_COMPONENT_IDS = [ 'adhd', 'priorArtGate', 'oxc', + 'antiSlop', ] as const satisfies readonly (keyof Selection)[]; /** The `Selection` keys that are plain on/off components (excludes the guards/agentTargets arrays). */ @@ -244,6 +247,8 @@ export function defaultSelection(): Selection { fallow: false, // Toolchain migration is incremental: capability arrives only when explicitly selected. oxc: false, + // Policy-heavy rules and their debt baseline must never arrive without an explicit choice. + antiSlop: false, searchCode: false, // Recommended-on: a fresh repo has no giants (or they're grandfathered by init's freeze), so the // cap is pure upside. Deselectable in the wizard / via --no-line-growth. @@ -276,6 +281,7 @@ export function applyOverlayConstraints(sel: Selection): Selection { searchSteering: false, searchCode: false, oxc: false, + antiSlop: false, husky: true, }; } @@ -283,7 +289,7 @@ export function applyOverlayConstraints(sel: Selection): Selection { /** Normalise a (possibly partial) selection to a full one — missing keys take recommended defaults. */ export function normalizeSelection(partial: Partial = {}): Selection { const base = defaultSelection(); - return { + const normalized = { ...base, ...partial, agentTargets: Array.isArray(partial.agentTargets) @@ -291,6 +297,10 @@ export function normalizeSelection(partial: Partial = {}): Selection : base.agentTargets, guards: partial.guards ? partial.guards.filter((g) => GUARD_IDS.includes(g)) : base.guards, }; + // The plugin is executed by the pinned Oxc capability; an impossible anti-slop-without-Oxc + // recording self-heals to the only runnable selection. + if (normalized.antiSlop) normalized.oxc = true; + return normalized; } /** @@ -401,6 +411,14 @@ export const OPTIONAL_COMPONENTS: OptionalComponent[] = [ flag: '--oxc', since: '0.52.0', }, + { + id: 'antiSlop', + kind: 'tool', + label: 'anti-slop', + hint: '15 vendored Oxlint rules + explicit shrink-only baseline (includes Oxc)', + flag: '--anti-slop', + since: '0.52.0', + }, ]; /** diff --git a/cli/lib/help/init-help.mts b/cli/lib/help/init-help.mts index 3d8edca..88a4200 100644 --- a/cli/lib/help/init-help.mts +++ b/cli/lib/help/init-help.mts @@ -10,7 +10,7 @@ Usage: --force Overwrite existing devkit-managed files, AND adopt/overwrite a consumer's own same-named skill/agent/hook collisions (default: preserve them). --no- Skip a component: --no-biome --no-tsconfig --no-skills --no-husky - --no-structure --no-guards --no-fallow --no-adhd --no-oxc. + --no-structure --no-guards --no-fallow --no-adhd --no-oxc --no-anti-slop. --guards Only these guards (subset of size,fanout,dup,clone,decisions, qavis-advisory,review,sentry; review + sentry are opt-in, off by default). --review Enable \`devkit review\` with an explicit local gate profile. @@ -23,6 +23,8 @@ Usage: --fallow Also install the optional fallow code-health layer (off by default). --oxc Activate Devkit's pinned Oxlint/Oxfmt runtime and repository configs (off by default; package/standalone only). Use \`devkit oxc lint|fmt\`. + --anti-slop Install 15 vendored anti-slop rules and its explicit shrink-only baseline + workflow (implies --oxc; package/standalone only; baseline creation is manual). --search-code Opt this repo in to the semantic search index (off by default). --adhd Sync the i-have-adhd SKILL — an ADHD-friendly output style — and keep it ALWAYS ON via a SessionStart hook (off by default; diff --git a/cli/lib/install/anti-slop/baseline.mts b/cli/lib/install/anti-slop/baseline.mts new file mode 100644 index 0000000..108dd27 --- /dev/null +++ b/cli/lib/install/anti-slop/baseline.mts @@ -0,0 +1,138 @@ +/** Deterministic, explicit, shrink-only anti-slop baseline model. */ + +import { createHash } from 'node:crypto'; +import { existsSync, readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { writeFileAtomic } from '../../atomic-write.mts'; +import { ANTI_SLOP_BASELINE_REL, ANTI_SLOP_UPSTREAM } from './constants.mts'; +import type { FindingGroup } from './diagnostics.mts'; + +export interface BaselineEntry { + fingerprint: string; + ruleId: string; + file: string; + diagnostic: string; + context: string; + count: number; +} + +export interface AntiSlopBaseline { + schemaVersion: 1; + upstreamCommit: string; + entries: BaselineEntry[]; +} + +export interface BaselineComparison { + newGroups: Array; + currentCount: number; + debtCount: number; + resolvedCount: number; +} + +function expectedFingerprint(entry: Omit): string { + return createHash('sha256') + .update(JSON.stringify([entry.ruleId, entry.file, entry.diagnostic, entry.context])) + .digest('hex'); +} + +function validateEntry(value: unknown): value is BaselineEntry { + if (!value || typeof value !== 'object') return false; + const entry = value as Partial; + if ( + typeof entry.fingerprint !== 'string' || + typeof entry.ruleId !== 'string' || + typeof entry.file !== 'string' || + typeof entry.diagnostic !== 'string' || + typeof entry.context !== 'string' || + !Number.isSafeInteger(entry.count) || + (entry.count ?? 0) < 1 + ) { + return false; + } + return ( + expectedFingerprint({ + ruleId: entry.ruleId, + file: entry.file, + diagnostic: entry.diagnostic, + context: entry.context, + }) === entry.fingerprint + ); +} + +export function baselineFromGroups(groups: readonly FindingGroup[]): AntiSlopBaseline { + return { + schemaVersion: 1, + upstreamCommit: ANTI_SLOP_UPSTREAM, + entries: [...groups] + .sort((a, b) => a.fingerprint.localeCompare(b.fingerprint)) + .map(({ severity: _severity, line: _line, column: _column, ...entry }) => entry), + }; +} + +export function readBaseline(cwd: string): AntiSlopBaseline | null { + const path = join(cwd, ANTI_SLOP_BASELINE_REL); + if (!existsSync(path)) return null; + let value: unknown; + try { + value = JSON.parse(readFileSync(path, 'utf8')); + } catch (error: unknown) { + throw new Error( + `invalid ${ANTI_SLOP_BASELINE_REL}: ${error instanceof Error ? error.message : String(error)}`, + ); + } + const baseline = value as Partial; + if ( + baseline.schemaVersion !== 1 || + baseline.upstreamCommit !== ANTI_SLOP_UPSTREAM || + !Array.isArray(baseline.entries) || + !baseline.entries.every(validateEntry) + ) { + throw new Error( + `invalid or stale ${ANTI_SLOP_BASELINE_REL}; inspect it, then explicitly recreate it`, + ); + } + const sorted = [...baseline.entries].sort((a, b) => a.fingerprint.localeCompare(b.fingerprint)); + if (new Set(sorted.map((entry) => entry.fingerprint)).size !== sorted.length) { + throw new Error(`invalid ${ANTI_SLOP_BASELINE_REL}: duplicate fingerprints`); + } + return { schemaVersion: 1, upstreamCommit: baseline.upstreamCommit, entries: sorted }; +} + +export function writeBaseline(cwd: string, baseline: AntiSlopBaseline): void { + writeFileAtomic(join(cwd, ANTI_SLOP_BASELINE_REL), `${JSON.stringify(baseline, null, 2)}\n`); +} + +export function compareBaseline( + baseline: AntiSlopBaseline, + groups: readonly FindingGroup[], +): BaselineComparison { + const allowed = new Map(baseline.entries.map((entry) => [entry.fingerprint, entry.count])); + const current = new Map(groups.map((group) => [group.fingerprint, group.count])); + return { + newGroups: groups.flatMap((group) => { + const additionalCount = Math.max(0, group.count - (allowed.get(group.fingerprint) ?? 0)); + return additionalCount > 0 ? [{ ...group, additionalCount }] : []; + }), + currentCount: groups.reduce((sum, group) => sum + group.count, 0), + debtCount: baseline.entries.reduce((sum, entry) => sum + entry.count, 0), + resolvedCount: baseline.entries.reduce( + (sum, entry) => sum + Math.max(0, entry.count - (current.get(entry.fingerprint) ?? 0)), + 0, + ), + }; +} + +/** Return only still-present baseline debt; never add an unbaselined current finding. */ +export function pruneBaseline( + baseline: AntiSlopBaseline, + groups: readonly FindingGroup[], +): AntiSlopBaseline { + const current = new Map(groups.map((group) => [group.fingerprint, group.count])); + return { + ...baseline, + entries: baseline.entries.flatMap((entry) => { + const count = Math.min(entry.count, current.get(entry.fingerprint) ?? 0); + return count > 0 ? [{ ...entry, count }] : []; + }), + }; +} diff --git a/cli/lib/install/anti-slop/baseline.test.mts b/cli/lib/install/anti-slop/baseline.test.mts new file mode 100644 index 0000000..636badd --- /dev/null +++ b/cli/lib/install/anti-slop/baseline.test.mts @@ -0,0 +1,46 @@ +import { describe, expect, it } from 'vitest'; +import { baselineFromGroups, compareBaseline, pruneBaseline } from './baseline.mts'; +import type { FindingGroup } from './diagnostics.mts'; + +const group = ( + fingerprint: string, + count: number, + severity: 'error' | 'warning' = 'error', +): FindingGroup => ({ + fingerprint, + ruleId: `anti-slop/rule-${fingerprint}`, + file: 'src/file.ts', + diagnostic: `diagnostic ${fingerprint}`, + context: `context ${fingerprint}`, + severity, + line: 1, + column: 1, + count, +}); + +describe('anti-slop shrink-only baseline', () => { + it('allows existing debt but surfaces only counts above the baseline', () => { + const baseline = baselineFromGroups([group('a', 2), group('b', 1)]); + const compared = compareBaseline(baseline, [group('a', 3), group('c', 1, 'warning')]); + + expect(compared.newGroups).toEqual([ + expect.objectContaining({ fingerprint: 'a', additionalCount: 1 }), + expect.objectContaining({ fingerprint: 'c', additionalCount: 1, severity: 'warning' }), + ]); + expect(compared.resolvedCount).toBe(1); + }); + + it('prunes absent/decreased debt and never adds a current unbaselined finding', () => { + const baseline = baselineFromGroups([group('a', 3), group('b', 1)]); + const next = pruneBaseline(baseline, [group('a', 2), group('c', 5)]); + + expect(next.entries).toEqual([expect.objectContaining({ fingerprint: 'a', count: 2 })]); + }); + + it('serializes deterministically in fingerprint order', () => { + const baseline = baselineFromGroups([group('b', 1), group('a', 1)]); + expect(baseline.entries.map((entry) => entry.fingerprint)).toEqual(['a', 'b']); + expect(baseline.entries[0]).not.toHaveProperty('severity'); + expect(baseline.entries[0]).not.toHaveProperty('line'); + }); +}); diff --git a/cli/lib/install/anti-slop/constants.mts b/cli/lib/install/anti-slop/constants.mts new file mode 100644 index 0000000..834afb4 --- /dev/null +++ b/cli/lib/install/anti-slop/constants.mts @@ -0,0 +1,67 @@ +/** Pinned upstream identity and the complete Devkit-managed anti-slop rule surface. */ + +export const ANTI_SLOP_UPSTREAM = '446268e5d15baa968eaec669ff65358d36ae6259'; +export const ANTI_SLOP_PLUGIN_API_VERSION = '1.78.0'; +export const ANTI_SLOP_MANAGED_REL = '.devkit/anti-slop'; +export const ANTI_SLOP_MANIFEST_REL = `${ANTI_SLOP_MANAGED_REL}/manifest.json`; +export const ANTI_SLOP_CONFIG_REL = `${ANTI_SLOP_MANAGED_REL}/oxlint.json`; +export const ANTI_SLOP_BASELINE_REL = '.anti-slop-baseline.json'; +export const ANTI_SLOP_LOCK_REL = '.devkit/anti-slop.lock'; +export const ANTI_SLOP_BASELINE_LOCK_REL = '.devkit/anti-slop-baseline.lock'; + +export const ANTI_SLOP_RULE_NAMES = [ + 'no-chained-type-assertions', + 'no-conditional-empty-object-spread', + 'no-known-value-widening', + 'no-module-mocking', + 'no-object-parameters', + 'no-reflect-apply', + 'no-reflect-get', + 'no-runtime-typeof', + 'no-shape-in-symbol-names', + 'no-unknown-parameters', + 'no-unknown-returns', + 'no-unknown-type-aliases', + 'no-unsafe-dictionary-type', + 'no-widen-then-assert', + 'require-safety-comment-for-type-assertion', +] as const; + +export const ANTI_SLOP_RULE_IDS = ANTI_SLOP_RULE_NAMES.map((name) => `anti-slop/${name}`); + +export const ANTI_SLOP_IGNORE_PATTERNS = [ + '.agent/**', + '.agents/**', + '.claude/**', + '.codex/**', + '.continue/**', + '.cursor/**', + '.devkit/anti-slop/**', + '.gemini/**', + '.opencode/**', + '.pi/**', + '.roo/**', + '.windsurf/**', +]; + +const ANTI_SLOP_CONFIG_DISABLE_PATTERNS = ANTI_SLOP_IGNORE_PATTERNS.flatMap((pattern) => + pattern === '.devkit/anti-slop/**' ? ['.devkit/anti-slop/plugin/**'] : [pattern], +); + +/** Render the config fragment inherited by Devkit's managed Oxlint base. */ +export function renderAntiSlopConfig(pluginEntry: string): string { + return `${JSON.stringify( + { + jsPlugins: [{ name: 'anti-slop', specifier: pluginEntry }], + overrides: [ + { + files: ANTI_SLOP_CONFIG_DISABLE_PATTERNS, + rules: Object.fromEntries(ANTI_SLOP_RULE_IDS.map((id) => [id, 'off'])), + }, + ], + rules: Object.fromEntries(ANTI_SLOP_RULE_IDS.map((id) => [id, 'error'])), + }, + null, + 2, + )}\n`; +} diff --git a/cli/lib/install/anti-slop/diagnostics.mts b/cli/lib/install/anti-slop/diagnostics.mts new file mode 100644 index 0000000..5ae16a5 --- /dev/null +++ b/cli/lib/install/anti-slop/diagnostics.mts @@ -0,0 +1,124 @@ +/** Normalize Oxlint JSON diagnostics into checkout-independent anti-slop findings. */ + +import { createHash } from 'node:crypto'; +import { readFileSync } from 'node:fs'; +import { isAbsolute, relative, resolve, sep } from 'node:path'; + +const RULE_CODE = /^anti-slop\(([^)]+)\)$/u; + +interface RawSpan { + line?: number; + column?: number; +} + +interface RawDiagnostic { + message?: unknown; + code?: unknown; + severity?: unknown; + filename?: unknown; + labels?: Array<{ span?: RawSpan }>; +} + +interface RawPayload { + diagnostics?: RawDiagnostic[]; +} + +export interface AntiSlopFinding { + fingerprint: string; + ruleId: string; + file: string; + diagnostic: string; + context: string; + severity: 'error' | 'warning'; + line: number; + column: number; +} + +export interface FindingGroup extends AntiSlopFinding { + count: number; +} + +const normalizeText = (value: string): string => value.trim().replace(/\s+/gu, ' '); + +function ruleId(code: unknown): string | null { + if (typeof code !== 'string') return null; + const match = RULE_CODE.exec(code); + return match?.[1] ? `anti-slop/${match[1]}` : null; +} + +function repositoryFile(cwd: string, filename: unknown): { absolute: string; relative: string } { + if (typeof filename !== 'string' || !filename) throw new Error('diagnostic has no filename'); + const absolute = resolve(cwd, filename); + const rel = relative(cwd, absolute); + if (!rel || rel === '..' || rel.startsWith(`..${sep}`) || isAbsolute(rel)) { + throw new Error(`diagnostic path escapes repository: ${filename}`); + } + return { absolute, relative: rel.split(sep).join('/') }; +} + +function sourceContext(path: string, line: number): string { + const lines = readFileSync(path, 'utf8').replace(/\r\n?/gu, '\n').split('\n'); + return normalizeText(lines[Math.max(0, line - 1)] ?? ''); +} + +function fingerprintFor(parts: { + ruleId: string; + file: string; + diagnostic: string; + context: string; +}): string { + return createHash('sha256') + .update(JSON.stringify([parts.ruleId, parts.file, parts.diagnostic, parts.context])) + .digest('hex'); +} + +/** Parse only namespaced anti-slop diagnostics; other Oxlint rules remain outside this gate. */ +export function parseAntiSlopFindings(cwd: string, json: string): AntiSlopFinding[] { + let payload: RawPayload; + try { + payload = JSON.parse(json) as RawPayload; + } catch (error: unknown) { + throw new Error( + `Oxlint did not return JSON: ${error instanceof Error ? error.message : String(error)}`, + ); + } + if (!Array.isArray(payload.diagnostics)) throw new Error('Oxlint JSON has no diagnostics array'); + const findings: AntiSlopFinding[] = []; + for (const diagnostic of payload.diagnostics) { + const id = ruleId(diagnostic.code); + if (!id) continue; + const location = repositoryFile(cwd, diagnostic.filename); + const span = diagnostic.labels?.[0]?.span; + const line = typeof span?.line === 'number' && span.line > 0 ? span.line : 1; + const column = typeof span?.column === 'number' && span.column > 0 ? span.column : 1; + const message = + typeof diagnostic.message === 'string' ? normalizeText(diagnostic.message) : 'diagnostic'; + const context = sourceContext(location.absolute, line); + const stable = { ruleId: id, file: location.relative, diagnostic: message, context }; + findings.push({ + ...stable, + fingerprint: fingerprintFor(stable), + severity: diagnostic.severity === 'warning' ? 'warning' : 'error', + line, + column, + }); + } + return findings.sort( + (a, b) => a.fingerprint.localeCompare(b.fingerprint) || a.line - b.line || a.column - b.column, + ); +} + +/** Group indistinguishable repeated diagnostics so an added copy still exceeds baseline debt. */ +export function groupFindings(findings: readonly AntiSlopFinding[]): FindingGroup[] { + const groups = new Map(); + for (const finding of findings) { + const current = groups.get(finding.fingerprint); + if (!current) { + groups.set(finding.fingerprint, { ...finding, count: 1 }); + continue; + } + current.count += 1; + if (finding.severity === 'error') current.severity = 'error'; + } + return [...groups.values()].sort((a, b) => a.fingerprint.localeCompare(b.fingerprint)); +} diff --git a/cli/lib/install/anti-slop/diagnostics.test.mts b/cli/lib/install/anti-slop/diagnostics.test.mts new file mode 100644 index 0000000..1d21cd6 --- /dev/null +++ b/cli/lib/install/anti-slop/diagnostics.test.mts @@ -0,0 +1,75 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, describe, expect, it } from 'vitest'; +import { groupFindings, parseAntiSlopFindings } from './diagnostics.mts'; + +const roots: string[] = []; + +function root(source: string): string { + const cwd = mkdtempSync(join(tmpdir(), 'anti-slop-fingerprint-')); + roots.push(cwd); + mkdirSync(join(cwd, 'src')); + writeFileSync(join(cwd, 'src', 'sample.ts'), source); + return cwd; +} + +function payload(filename: string, line: number, severity = 'error'): string { + return JSON.stringify({ + diagnostics: [ + { + message: ' Parameter value uses broad object. ', + code: 'anti-slop(no-object-parameters)', + severity, + filename, + labels: [{ span: { line, column: 3 } }], + }, + { + message: 'native finding', + code: 'eslint(no-debugger)', + severity: 'error', + filename, + }, + ], + }); +} + +afterEach(() => { + for (const cwd of roots.splice(0)) rmSync(cwd, { recursive: true, force: true }); +}); + +describe('anti-slop fingerprints', () => { + it('is stable across checkout roots, line movement, CRLF, and diagnostic whitespace', () => { + const first = root('function save(value: object) {}\n'); + const second = root('\r\nfunction save(value: object) {}\r\n'); + const a = parseAntiSlopFindings(first, payload('src/sample.ts', 1))[0]; + const b = parseAntiSlopFindings(second, payload(join(second, 'src', 'sample.ts'), 2))[0]; + + expect(a).toMatchObject({ + ruleId: 'anti-slop/no-object-parameters', + file: 'src/sample.ts', + diagnostic: 'Parameter value uses broad object.', + context: 'function save(value: object) {}', + }); + expect(b?.context).toBe('function save(value: object) {}'); + expect(b?.fingerprint).toBe(a?.fingerprint); + }); + + it('filters other rules and groups identical occurrences with error taking precedence', () => { + const cwd = root('function save(value: object) {}\n'); + const findings = parseAntiSlopFindings(cwd, payload('src/sample.ts', 1, 'warning')); + expect(findings).toHaveLength(1); + const one = findings[0]; + if (!one) throw new Error('expected one anti-slop finding'); + const groups = groupFindings([one, { ...one, severity: 'error', column: 20 }]); + expect(groups).toHaveLength(1); + expect(groups[0]).toMatchObject({ count: 2, severity: 'error' }); + }); + + it('rejects diagnostics outside the repository', () => { + const cwd = root('function save(value: object) {}\n'); + expect(() => parseAntiSlopFindings(cwd, payload('/tmp/outside.ts', 1))).toThrow( + 'diagnostic path escapes repository', + ); + }); +}); diff --git a/cli/lib/install/anti-slop/lifecycle.mts b/cli/lib/install/anti-slop/lifecycle.mts new file mode 100644 index 0000000..86eb548 --- /dev/null +++ b/cli/lib/install/anti-slop/lifecycle.mts @@ -0,0 +1,479 @@ +/** Install, verify, and remove Devkit's pinned self-contained anti-slop plugin. */ + +import { spawnSync } from 'node:child_process'; +import { createHash } from 'node:crypto'; +import { + cpSync, + existsSync, + mkdirSync, + readdirSync, + readFileSync, + renameSync, + rmSync, +} from 'node:fs'; +import { createRequire } from 'node:module'; +import { dirname, join, relative } from 'node:path'; +import { withLock, writeFileAtomic } from '../../atomic-write.mts'; +import { type CheckResult, check } from '../../doctor/check-result.mts'; +import { packageDir } from '../../fs-helpers.mts'; +import { + assertOxcCapabilityReady, + oxcBaseCapabilityIssue, + syncOxcCapability, +} from '../oxc/lifecycle.mts'; +import { resolveOxcRuntime } from '../oxc/runtime.mts'; +import { + ANTI_SLOP_CONFIG_REL, + ANTI_SLOP_LOCK_REL, + ANTI_SLOP_MANAGED_REL, + ANTI_SLOP_MANIFEST_REL, + ANTI_SLOP_PLUGIN_API_VERSION, + ANTI_SLOP_RULE_IDS, + ANTI_SLOP_UPSTREAM, + renderAntiSlopConfig, +} from './constants.mts'; + +interface AntiSlopManifest { + schemaVersion: 1; + upstreamCommit: string; + pluginApiVersion: string; + ruleIds: string[]; + pluginDigest: string; + configDigest: string; + probeDigest: string; + probeConfigDigest: string; +} + +interface SyncOptions { + dryRun?: boolean; +} + +interface ManagedReplacement { + manifest: AntiSlopManifest; + commit(): void; + rollback(): void; +} + +interface CapabilityHealth { + manifest: AntiSlopManifest | null; + rulesComplete: boolean; + bytesOk: boolean; + baseIntegrated: boolean; + baseDetail: string; + runtimeIntegrated: boolean; + runtimeDetail: string; +} + +const PROBE_REL = `${ANTI_SLOP_MANAGED_REL}/probe.ts`; +const PROBE_RULE = 'anti-slop/no-object-parameters'; +const PROBE_RULE_CODE = 'anti-slop(no-object-parameters)'; +const BASE_PROBE_CODE = 'eslint(no-undef)'; +const BASE_PROBE_GLOBAL = '__DEVKIT_OXC_BASE_1_78_0_MANAGED_PROBE__'; +const PROBE_SOURCE = `function devkitManagedProbe(value: object) { void ${BASE_PROBE_GLOBAL}; return value; }\n`; +const PROBE_CONFIG_SOURCE = `${JSON.stringify( + { extends: ['../oxc/oxlint.base.json'], rules: { [PROBE_RULE]: 'off' } }, + null, + 2, +)}\n`; +const PROBE_MAX_OUTPUT = 2 * 1024 * 1024; +const PLUGIN_MODULE = /\.(?:m?js|ts)$/u; + +const digest = (content: string | Buffer): string => + createHash('sha256').update(content).digest('hex'); + +function treeDigest(root: string): string { + const hash = createHash('sha256'); + const files = readdirSync(root, { recursive: true, withFileTypes: true }) + .filter((entry) => entry.isFile()) + .map((entry) => join(entry.parentPath, entry.name)) + .sort((a, b) => relative(root, a).localeCompare(relative(root, b))); + for (const file of files) { + hash.update(relative(root, file).split('\\').join('/')); + hash.update('\0'); + hash.update(readFileSync(file)); + hash.update('\0'); + } + return hash.digest('hex'); +} + +function pluginSource(): { root: string; entry: string } { + const root = join(packageDir(), 'anti-slop', 'src'); + if (existsSync(join(root, 'index.mjs'))) return { root, entry: './plugin/index.mjs' }; + if (existsSync(join(root, 'index.js'))) return { root, entry: './plugin/index.js' }; + if (existsSync(join(root, 'index.ts'))) return { root, entry: './plugin/index.ts' }; + throw new Error('bundled anti-slop plugin entry is missing'); +} + +function pluginApiSource(): string { + const entry = createRequire(import.meta.url).resolve('@oxlint/plugins'); + const manifest = JSON.parse(readFileSync(join(dirname(entry), 'package.json'), 'utf8')) as { + version?: string; + }; + if (manifest.version !== ANTI_SLOP_PLUGIN_API_VERSION) { + throw new Error( + `@oxlint/plugins ${manifest.version ?? 'unknown'} != pinned ${ANTI_SLOP_PLUGIN_API_VERSION}`, + ); + } + return dirname(entry); +} + +function makePluginApiTrackable(plugin: string, apiSource: string): void { + const files = readdirSync(plugin, { recursive: true, withFileTypes: true }).filter( + (entry) => entry.isFile() && PLUGIN_MODULE.test(entry.name), + ); + for (const entry of files) { + const path = join(entry.parentPath, entry.name); + const source = readFileSync(path, 'utf8'); + const rewritten = source.replaceAll('@oxlint/plugins', '#oxlint-plugins'); + if (rewritten !== source) writeFileAtomic(path, rewritten); + } + writeFileAtomic( + join(plugin, 'package.json'), + `${JSON.stringify( + { + private: true, + type: 'module', + imports: { '#oxlint-plugins': './oxlint-plugins-api/index.js' }, + }, + null, + 2, + )}\n`, + ); + cpSync(apiSource, join(plugin, 'oxlint-plugins-api'), { recursive: true }); +} + +function readManifest(cwd: string): AntiSlopManifest | null { + const path = join(cwd, ANTI_SLOP_MANIFEST_REL); + if (!existsSync(path)) return null; + try { + const value = JSON.parse(readFileSync(path, 'utf8')) as Partial; + return value.schemaVersion === 1 && + value.upstreamCommit === ANTI_SLOP_UPSTREAM && + value.pluginApiVersion === ANTI_SLOP_PLUGIN_API_VERSION && + Array.isArray(value.ruleIds) && + value.ruleIds.every((id) => typeof id === 'string') && + typeof value.pluginDigest === 'string' && + typeof value.configDigest === 'string' && + typeof value.probeDigest === 'string' && + typeof value.probeConfigDigest === 'string' + ? (value as AntiSlopManifest) + : null; + } catch { + return null; + } +} + +/** Explain why an explicit request cannot activate in a non-repository mode. */ +export function warnIfAntiSlopUnavailable(mode: string, requested: boolean): void { + if (!requested || (mode !== 'overlay' && mode !== 'self-host')) return; + console.warn( + `devkit init --${mode}: --anti-slop is unavailable because it requires the tracked Oxc capability; skipping it.`, + ); +} + +function syncUnlocked(cwd: string, dryRun: boolean): ManagedReplacement | null { + const source = pluginSource(); + const apiSource = pluginApiSource(); + const config = renderAntiSlopConfig(source.entry); + if (dryRun) { + console.log( + ` [dry-run] sync ${ANTI_SLOP_MANAGED_REL}/ (15 rules; upstream ${ANTI_SLOP_UPSTREAM.slice(0, 12)})`, + ); + return null; + } + const managed = join(cwd, ANTI_SLOP_MANAGED_REL); + const staging = `${managed}.staging-${process.pid}`; + const previous = `${managed}.previous`; + rmSync(staging, { recursive: true, force: true }); + if (!existsSync(managed) && existsSync(previous)) renameSync(previous, managed); + else rmSync(previous, { recursive: true, force: true }); + let movedPrevious = false; + try { + const plugin = join(staging, 'plugin'); + mkdirSync(plugin, { recursive: true }); + cpSync(source.root, plugin, { recursive: true }); + makePluginApiTrackable(plugin, apiSource); + cpSync(join(packageDir(), 'anti-slop', 'LICENSE'), join(staging, 'LICENSE')); + writeFileAtomic(join(staging, 'oxlint.json'), config); + writeFileAtomic(join(staging, 'probe.ts'), PROBE_SOURCE); + writeFileAtomic(join(staging, '.oxlintrc.json'), PROBE_CONFIG_SOURCE); + const manifest: AntiSlopManifest = { + schemaVersion: 1, + upstreamCommit: ANTI_SLOP_UPSTREAM, + pluginApiVersion: ANTI_SLOP_PLUGIN_API_VERSION, + ruleIds: [...ANTI_SLOP_RULE_IDS], + pluginDigest: treeDigest(plugin), + configDigest: digest(config), + probeDigest: digest(PROBE_SOURCE), + probeConfigDigest: digest(PROBE_CONFIG_SOURCE), + }; + writeFileAtomic(join(staging, 'manifest.json'), `${JSON.stringify(manifest, null, 2)}\n`); + if (existsSync(managed)) { + renameSync(managed, previous); + movedPrevious = true; + } + renameSync(staging, managed); + return { + manifest, + commit: () => rmSync(previous, { recursive: true, force: true }), + rollback: () => { + rmSync(managed, { recursive: true, force: true }); + if (movedPrevious && existsSync(previous)) renameSync(previous, managed); + }, + }; + } catch (error) { + rmSync(staging, { recursive: true, force: true }); + if (movedPrevious && !existsSync(managed) && existsSync(previous)) + renameSync(previous, managed); + throw error; + } +} + +/** Install/upgrade the managed plugin without fetching or changing a consumer dependency stack. */ +export function syncAntiSlopCapability(cwd: string, { dryRun = false }: SyncOptions = {}): void { + if (dryRun) { + assertOxcCapabilityReady(cwd); + syncUnlocked(cwd, true); + syncOxcCapability(cwd, { dryRun: true, antiSlop: true }); + return; + } + mkdirSync(join(cwd, '.devkit'), { recursive: true }); + withLock(join(cwd, ANTI_SLOP_LOCK_REL), () => { + assertOxcCapabilityReady(cwd); + const replacement = syncUnlocked(cwd, false); + if (!replacement) throw new Error('anti-slop managed replacement was not prepared'); + try { + syncOxcCapability(cwd, { antiSlop: true }); + replacement.commit(); + console.log( + ` ✓ anti-slop: ${replacement.manifest.ruleIds.length} rules @ ${replacement.manifest.upstreamCommit.slice(0, 12)}`, + ); + } catch (error) { + replacement.rollback(); + try { + syncOxcCapability(cwd, { + antiSlop: + existsSync(join(cwd, ANTI_SLOP_MANIFEST_REL)) && + existsSync(join(cwd, ANTI_SLOP_CONFIG_REL)), + }); + } catch { + // Preserve the original sync failure; doctor can repair any residual managed Oxc drift. + } + throw error; + } + }); +} + +/** Serialize readers with managed-tree replacement; callers keep the lock through their Oxc run. */ +export function withAntiSlopCapabilityLock(cwd: string, action: () => T): T { + return withLock(join(cwd, ANTI_SLOP_LOCK_REL), action); +} + +function probeIntegration(cwd: string): { ok: boolean; detail: string } { + let runtime: ReturnType; + try { + runtime = resolveOxcRuntime('lint'); + } catch (error: unknown) { + return { ok: false, detail: error instanceof Error ? error.message : String(error) }; + } + const result = spawnSync( + process.execPath, + [ + runtime.binPath, + '--format', + 'json', + '--no-ignore', + '--disable-nested-config', + '--deny', + PROBE_RULE, + '--deny', + 'no-undef', + PROBE_REL, + ], + { cwd, encoding: 'utf8', maxBuffer: PROBE_MAX_OUTPUT, timeout: 10_000 }, + ); + if (result.status === null) { + return { + ok: false, + detail: + result.error?.message ?? + (result.signal ? `probe terminated by ${result.signal}` : 'probe failed'), + }; + } + if (result.status !== 0 && result.status !== 1) { + return { + ok: false, + detail: `integration probe rejected the managed rule: ${result.stderr.trim().split('\n')[0] || `Oxlint exit ${result.status}`}`, + }; + } + try { + const payload = JSON.parse(result.stdout) as { diagnostics?: Array<{ code?: unknown }> }; + const codes = new Set(payload.diagnostics?.map((diagnostic) => diagnostic.code)); + if (codes.has(BASE_PROBE_CODE)) { + return { + ok: false, + detail: 'consumer config does not load the managed Oxlint base', + }; + } + if (!codes.has(PROBE_RULE_CODE)) { + return { + ok: false, + detail: `consumer config does not register the managed ${PROBE_RULE} rule`, + }; + } + return { + ok: true, + detail: `consumer config loads the managed base and registers ${PROBE_RULE}`, + }; + } catch (error: unknown) { + return { + ok: false, + detail: `integration probe returned invalid JSON: ${error instanceof Error ? error.message : String(error)}`, + }; + } +} + +function capabilityHealth(cwd: string): CapabilityHealth { + const manifest = readManifest(cwd); + if (!manifest) { + return { + manifest: null, + rulesComplete: false, + bytesOk: false, + baseIntegrated: false, + baseDetail: 'managed Oxc manifest is missing or invalid', + runtimeIntegrated: false, + runtimeDetail: 'managed manifest is missing or invalid', + }; + } + const plugin = join(cwd, ANTI_SLOP_MANAGED_REL, 'plugin'); + const config = join(cwd, ANTI_SLOP_CONFIG_REL); + const probe = join(cwd, PROBE_REL); + const probeConfig = join(cwd, ANTI_SLOP_MANAGED_REL, '.oxlintrc.json'); + const rulesComplete = + manifest.ruleIds.length === ANTI_SLOP_RULE_IDS.length && + ANTI_SLOP_RULE_IDS.every((id) => manifest.ruleIds.includes(id)); + const bytesOk = + existsSync(plugin) && + existsSync(config) && + existsSync(probe) && + existsSync(probeConfig) && + treeDigest(plugin) === manifest.pluginDigest && + digest(readFileSync(config)) === manifest.configDigest && + digest(readFileSync(probe)) === manifest.probeDigest && + digest(readFileSync(probeConfig)) === manifest.probeConfigDigest; + const baseIssue = oxcBaseCapabilityIssue(cwd); + const baseIntegrated = baseIssue === null; + const runtime = + rulesComplete && bytesOk && baseIntegrated + ? probeIntegration(cwd) + : { + ok: false, + detail: + !rulesComplete || !bytesOk + ? 'managed rule/plugin integrity failed before runtime probe' + : (baseIssue ?? 'managed Oxlint base integration failed'), + }; + return { + manifest, + rulesComplete, + bytesOk, + baseIntegrated, + baseDetail: baseIssue ?? 'managed Oxlint base is current', + runtimeIntegrated: runtime.ok, + runtimeDetail: runtime.detail, + }; +} + +/** Return why baseline operations must fail closed, or null when the full runtime chain is proved. */ +export function antiSlopCapabilityIssue(cwd: string): string | null { + const health = capabilityHealth(cwd); + if (!health.manifest) return 'managed manifest is missing or invalid'; + if (!health.rulesComplete) return 'managed rule registry is incomplete'; + if (!health.bytesOk) return 'managed plugin/config/probe bytes changed'; + if (!health.baseIntegrated) return health.baseDetail; + return health.runtimeIntegrated ? null : health.runtimeDetail; +} + +/** Check provenance, all managed bytes, rule completeness, and Oxc config integration. */ +export function checkAntiSlopCapability(cwd: string): CheckResult[] { + if (!existsSync(join(cwd, '.devkit'))) { + return [ + check( + 'anti-slop manifest', + 'MISSING', + ANTI_SLOP_MANIFEST_REL, + 'run `devkit doctor --fix`', + true, + ), + ]; + } + return withAntiSlopCapabilityLock(cwd, () => checkAntiSlopCapabilityUnlocked(cwd)); +} + +function checkAntiSlopCapabilityUnlocked(cwd: string): CheckResult[] { + const health = capabilityHealth(cwd); + const manifest = health.manifest; + if (!manifest) { + return [ + check( + 'anti-slop manifest', + 'MISSING', + ANTI_SLOP_MANIFEST_REL, + 'run `devkit doctor --fix`', + true, + ), + ]; + } + return [ + health.rulesComplete + ? check( + 'anti-slop rules', + 'OK', + `${manifest.ruleIds.length} namespaced rules @ ${manifest.upstreamCommit.slice(0, 12)}`, + ) + : check( + 'anti-slop rules', + 'DRIFT', + 'managed rule registry is incomplete', + 'run `devkit doctor --fix`', + true, + ), + health.bytesOk + ? check( + 'anti-slop plugin', + 'OK', + `self-contained @oxlint/plugins@${manifest.pluginApiVersion}`, + ) + : check( + 'anti-slop plugin', + 'DRIFT', + 'managed plugin/config bytes changed', + 'run `devkit doctor --fix`', + true, + ), + health.runtimeIntegrated + ? check('anti-slop Oxc integration', 'OK', health.runtimeDetail) + : check( + 'anti-slop Oxc integration', + 'DRIFT', + health.runtimeDetail, + 'add "./.devkit/oxc/oxlint.base.json" to the consumer config extends array', + ), + ]; +} + +/** Remove only managed plugin bytes. The repository baseline is consumer debt data and is kept. */ +export function removeAntiSlopCapability(cwd: string, dryRun = false): void { + const managed = join(cwd, ANTI_SLOP_MANAGED_REL); + if (!existsSync(managed)) return; + if (dryRun) { + console.log(` [dry-run] remove ${ANTI_SLOP_MANAGED_REL}/ (keep baseline)`); + return; + } + withLock(join(cwd, ANTI_SLOP_LOCK_REL), () => { + if (existsSync(join(cwd, '.devkit', 'oxc'))) syncOxcCapability(cwd, { antiSlop: false }); + rmSync(managed, { recursive: true, force: true }); + }); + console.log(` ✓ removed ${ANTI_SLOP_MANAGED_REL}/ (kept baseline)`); +} diff --git a/cli/lib/install/anti-slop/lifecycle.test.mts b/cli/lib/install/anti-slop/lifecycle.test.mts new file mode 100644 index 0000000..dfe5c3c --- /dev/null +++ b/cli/lib/install/anti-slop/lifecycle.test.mts @@ -0,0 +1,122 @@ +import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { syncOxcCapability } from '../oxc/lifecycle.mts'; +import { + checkAntiSlopCapability, + removeAntiSlopCapability, + syncAntiSlopCapability, +} from './lifecycle.mts'; + +const roots: string[] = []; + +function root(): string { + const cwd = mkdtempSync(join(tmpdir(), 'devkit-anti-slop-lifecycle-')); + roots.push(cwd); + return cwd; +} + +beforeEach(() => vi.spyOn(console, 'log').mockImplementation(() => {})); +afterEach(() => { + vi.restoreAllMocks(); + for (const cwd of roots.splice(0)) rmSync(cwd, { recursive: true, force: true }); +}); + +describe('anti-slop capability lifecycle', () => { + it('installs all rules self-contained and integrates through the managed Oxc base', () => { + const cwd = root(); + syncAntiSlopCapability(cwd); + syncOxcCapability(cwd, { antiSlop: true }); + + const manifest = JSON.parse(readFileSync(join(cwd, '.devkit/anti-slop/manifest.json'), 'utf8')); + expect(manifest).toMatchObject({ + schemaVersion: 1, + upstreamCommit: '446268e5d15baa968eaec669ff65358d36ae6259', + pluginApiVersion: '1.78.0', + }); + expect(manifest.ruleIds).toHaveLength(15); + expect(existsSync(join(cwd, '.devkit/anti-slop/plugin/oxlint-plugins-api/index.js'))).toBe( + true, + ); + expect(readFileSync(join(cwd, '.devkit/anti-slop/plugin/package.json'), 'utf8')).toContain( + '"#oxlint-plugins"', + ); + expect(existsSync(join(cwd, '.devkit/anti-slop/probe.ts'))).toBe(true); + expect(readFileSync(join(cwd, '.devkit/oxc/oxlint.base.json'), 'utf8')).toContain( + '../anti-slop/oxlint.json', + ); + expect(checkAntiSlopCapability(cwd).every((result) => result.status === 'OK')).toBe(true); + }); + + it('repairs managed drift without touching a repository baseline', () => { + const cwd = root(); + syncAntiSlopCapability(cwd); + syncOxcCapability(cwd, { antiSlop: true }); + writeFileSync(join(cwd, '.anti-slop-baseline.json'), '{"consumer":"data"}\n'); + writeFileSync(join(cwd, '.devkit/anti-slop/oxlint.json'), '{}\n'); + expect(checkAntiSlopCapability(cwd).some((result) => result.status === 'DRIFT')).toBe(true); + + syncAntiSlopCapability(cwd); + expect(checkAntiSlopCapability(cwd).every((result) => result.status === 'OK')).toBe(true); + removeAntiSlopCapability(cwd); + + expect(readFileSync(join(cwd, '.anti-slop-baseline.json'), 'utf8')).toBe( + '{"consumer":"data"}\n', + ); + expect(existsSync(join(cwd, '.devkit/anti-slop'))).toBe(false); + const base = JSON.parse(readFileSync(join(cwd, '.devkit/oxc/oxlint.base.json'), 'utf8')) as { + extends?: string[]; + }; + expect(base.extends ?? []).not.toContain('../anti-slop/oxlint.json'); + }); + + it('reports a preserved consumer config that does not compose the managed base', () => { + const cwd = root(); + writeFileSync(join(cwd, '.oxlintrc.json'), '{ "rules": {} }\n'); + syncAntiSlopCapability(cwd); + + expect(checkAntiSlopCapability(cwd)).toContainEqual( + expect.objectContaining({ name: 'anti-slop Oxc integration', status: 'DRIFT' }), + ); + writeFileSync( + join(cwd, '.oxlintrc.json'), + '{ "extends": ["./.devkit/anti-slop/oxlint.json"], "rules": {} }\n', + ); + expect(checkAntiSlopCapability(cwd)).toContainEqual( + expect.objectContaining({ + name: 'anti-slop Oxc integration', + status: 'DRIFT', + detail: 'consumer config does not load the managed Oxlint base', + }), + ); + writeFileSync( + join(cwd, '.oxlintrc.json'), + '{ "extends": ["./.devkit/oxc/oxlint.base.json"], "rules": {} }\n', + ); + expect(checkAntiSlopCapability(cwd).every((result) => result.status === 'OK')).toBe(true); + + writeFileSync( + join(cwd, 'local-plugin.mjs'), + `const rule = { create(context) { return { ClassDeclaration(node) { context.report({ message: 'local', node }); } }; } }; +export default { meta: { name: 'local' }, rules: { classes: rule } }; +`, + ); + writeFileSync( + join(cwd, '.oxlintrc.json'), + '{ "extends": ["./.devkit/oxc/oxlint.base.json"], "jsPlugins": ["./local-plugin.mjs"], "rules": { "local/classes": "error" } }\n', + ); + expect(checkAntiSlopCapability(cwd).every((result) => result.status === 'OK')).toBe(true); + }); + + it('preflights Oxc collisions before publishing anti-slop managed state', () => { + const cwd = root(); + writeFileSync(join(cwd, '.oxlintrc.json'), '{}\n'); + writeFileSync(join(cwd, 'oxlint.config.ts'), 'export default {};\n'); + + expect(() => syncAntiSlopCapability(cwd)).toThrow('multiple Oxc configs'); + + expect(existsSync(join(cwd, '.devkit/anti-slop'))).toBe(false); + expect(existsSync(join(cwd, '.devkit/oxc'))).toBe(false); + }); +}); diff --git a/cli/lib/install/anti-slop/removal-lock.test.mts b/cli/lib/install/anti-slop/removal-lock.test.mts new file mode 100644 index 0000000..1229daa --- /dev/null +++ b/cli/lib/install/anti-slop/removal-lock.test.mts @@ -0,0 +1,54 @@ +import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { ANTI_SLOP_LOCK_REL } from './constants.mts'; + +const spies = vi.hoisted(() => ({ syncOxcCapability: vi.fn() })); +vi.mock('../oxc/lifecycle.mts', () => ({ + assertOxcCapabilityReady: vi.fn(), + oxcBaseCapabilityIssue: vi.fn(() => null), + syncOxcCapability: spies.syncOxcCapability, +})); + +import { removeAntiSlopCapability } from './lifecycle.mts'; + +const roots: string[] = []; +afterEach(() => { + spies.syncOxcCapability.mockReset(); + for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }); +}); + +describe('anti-slop removal locking', () => { + it('holds the capability lock through the paired Oxc base rewrite', () => { + const cwd = mkdtempSync(join(tmpdir(), 'anti-slop-remove-lock-')); + roots.push(cwd); + mkdirSync(join(cwd, '.devkit/anti-slop'), { recursive: true }); + mkdirSync(join(cwd, '.devkit/oxc'), { recursive: true }); + writeFileSync(join(cwd, '.devkit/oxc/manifest.json'), '{}\n'); + spies.syncOxcCapability.mockImplementation(() => { + expect(existsSync(join(cwd, ANTI_SLOP_LOCK_REL))).toBe(true); + }); + + removeAntiSlopCapability(cwd); + + expect(spies.syncOxcCapability).toHaveBeenCalledWith(cwd, { antiSlop: false }); + expect(existsSync(join(cwd, '.devkit/anti-slop'))).toBe(false); + expect(existsSync(join(cwd, ANTI_SLOP_LOCK_REL))).toBe(false); + }); + + it('keeps managed plugin bytes when the Oxc base cannot be unwired', () => { + const cwd = mkdtempSync(join(tmpdir(), 'anti-slop-remove-failure-')); + roots.push(cwd); + mkdirSync(join(cwd, '.devkit/anti-slop'), { recursive: true }); + mkdirSync(join(cwd, '.devkit/oxc'), { recursive: true }); + spies.syncOxcCapability.mockImplementation(() => { + throw new Error('runtime unavailable'); + }); + + expect(() => removeAntiSlopCapability(cwd)).toThrow('runtime unavailable'); + + expect(existsSync(join(cwd, '.devkit/anti-slop'))).toBe(true); + expect(existsSync(join(cwd, ANTI_SLOP_LOCK_REL))).toBe(false); + }); +}); diff --git a/cli/lib/install/anti-slop/runner.mts b/cli/lib/install/anti-slop/runner.mts new file mode 100644 index 0000000..bf02a73 --- /dev/null +++ b/cli/lib/install/anti-slop/runner.mts @@ -0,0 +1,106 @@ +/** Execute pinned Oxlint and expose only normalized anti-slop findings to the baseline gate. */ + +import { spawnSync } from 'node:child_process'; +import { existsSync, realpathSync, statSync } from 'node:fs'; +import { isAbsolute, relative, resolve, sep } from 'node:path'; +import { resolveOxcRuntime } from '../oxc/runtime.mts'; +import { ANTI_SLOP_IGNORE_PATTERNS } from './constants.mts'; +import { type FindingGroup, groupFindings, parseAntiSlopFindings } from './diagnostics.mts'; +import { antiSlopCapabilityIssue, withAntiSlopCapabilityLock } from './lifecycle.mts'; + +const MAX_OUTPUT = 64 * 1024 * 1024; + +export interface AntiSlopScope { + paths: string[]; + includes(file: string): boolean; +} + +/** Resolve literal existing repository paths and expose their baseline-entry membership. */ +export function resolveAntiSlopScope(cwd: string, args: readonly string[]): AntiSlopScope { + const separator = args.indexOf('--'); + const options = separator >= 0 ? args.slice(0, separator) : args; + const paths = args.filter((arg) => arg !== '--'); + const option = options.find((arg) => arg.startsWith('-')); + if (option) { + throw new Error( + `anti-slop operations accept repository paths, not Oxlint option ${option}; configure rules in .oxlintrc.json`, + ); + } + const requested = paths.length > 0 ? paths : ['.']; + const lintArguments = paths.length > 0 ? [...args] : ['.']; + const repository = realpathSync(cwd); + const scopes = requested.map((path) => { + const absolute = resolve(cwd, path); + let target: string; + try { + target = realpathSync(absolute); + } catch { + throw new Error(`anti-slop path does not exist: ${path}`); + } + const targetRel = relative(repository, target); + if (targetRel === '..' || targetRel.startsWith(`..${sep}`) || isAbsolute(targetRel)) { + throw new Error(`anti-slop path escapes repository: ${path}`); + } + const lexical = relative(resolve(cwd), absolute); + if (lexical === '..' || lexical.startsWith(`..${sep}`) || isAbsolute(lexical)) { + throw new Error(`anti-slop path escapes repository: ${path}`); + } + return { file: lexical.split(sep).join('/'), directory: statSync(absolute).isDirectory() }; + }); + return { + paths: lintArguments, + includes(file: string): boolean { + return scopes.some((scope) => + scope.directory ? !scope.file || file.startsWith(`${scope.file}/`) : file === scope.file, + ); + }, + }; +} + +/** Run the installed capability under the repository's combined Oxlint config. */ +export function collectAntiSlopGroups(cwd: string, args: readonly string[]): FindingGroup[] { + if (!existsSync(resolve(cwd, '.devkit'))) { + throw new Error('anti-slop is not installed — run `devkit init --anti-slop`'); + } + return withAntiSlopCapabilityLock(cwd, () => collectAntiSlopGroupsUnlocked(cwd, args)); +} + +function collectAntiSlopGroupsUnlocked(cwd: string, args: readonly string[]): FindingGroup[] { + const issue = antiSlopCapabilityIssue(cwd); + if (issue) { + throw new Error( + `anti-slop capability is not fully integrated (${issue}); refusing an incomplete baseline`, + ); + } + const scope = resolveAntiSlopScope(cwd, args); + const runtime = resolveOxcRuntime('lint'); + const result = spawnSync( + process.execPath, + [ + runtime.binPath, + '--format', + 'json', + '--no-error-on-unmatched-pattern', + '--disable-nested-config', + ...ANTI_SLOP_IGNORE_PATTERNS.flatMap((pattern) => ['--ignore-pattern', pattern]), + ...scope.paths, + ], + { cwd, encoding: 'utf8', maxBuffer: MAX_OUTPUT }, + ); + if (result.status === null) { + throw new Error( + `Oxlint failed: ${result.error?.message ?? (result.signal ? `signal ${result.signal}` : 'unknown error')}`, + ); + } + if (result.status !== 0 && result.status !== 1) { + throw new Error( + `Oxlint exited ${result.status}: ${result.stderr.trim().split('\n')[0] || 'no detail'}`, + ); + } + if (!result.stdout.trim()) { + throw new Error( + `Oxlint returned no diagnostics JSON: ${result.stderr.trim().split('\n')[0] || 'no detail'}`, + ); + } + return groupFindings(parseAntiSlopFindings(cwd, result.stdout)); +} diff --git a/cli/lib/install/anti-slop/runner.test.mts b/cli/lib/install/anti-slop/runner.test.mts new file mode 100644 index 0000000..c99ed64 --- /dev/null +++ b/cli/lib/install/anti-slop/runner.test.mts @@ -0,0 +1,42 @@ +import { mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, describe, expect, it } from 'vitest'; +import { resolveAntiSlopScope } from './runner.mts'; + +const roots: string[] = []; + +function root(): string { + const cwd = mkdtempSync(join(tmpdir(), 'anti-slop-scope-')); + roots.push(cwd); + mkdirSync(join(cwd, 'src', 'nested'), { recursive: true }); + writeFileSync(join(cwd, 'src', 'one.ts'), 'export const one = 1;\n'); + writeFileSync(join(cwd, 'src', 'nested', 'two.ts'), 'export const two = 2;\n'); + writeFileSync(join(cwd, '-bad.ts'), 'export const bad = 3;\n'); + return cwd; +} + +afterEach(() => { + for (const cwd of roots.splice(0)) rmSync(cwd, { recursive: true, force: true }); +}); + +describe('anti-slop path scope', () => { + it('matches literal repository files/directories and rejects missing or external paths', () => { + const cwd = root(); + const directory = resolveAntiSlopScope(cwd, ['src']); + const file = resolveAntiSlopScope(cwd, ['src/one.ts']); + symlinkSync(join(cwd, 'src', 'one.ts'), join(cwd, 'link.ts')); + const link = resolveAntiSlopScope(cwd, ['link.ts']); + + expect(directory.includes('src/one.ts')).toBe(true); + expect(directory.includes('src/nested/two.ts')).toBe(true); + expect(directory.includes('outside.ts')).toBe(false); + expect(file.includes('src/one.ts')).toBe(true); + expect(file.includes('src/nested/two.ts')).toBe(false); + expect(link.includes('link.ts')).toBe(true); + expect(link.includes('src/one.ts')).toBe(false); + expect(resolveAntiSlopScope(cwd, ['--', '-bad.ts']).includes('-bad.ts')).toBe(true); + expect(() => resolveAntiSlopScope(cwd, ['missing.ts'])).toThrow('does not exist'); + expect(() => resolveAntiSlopScope(cwd, ['/tmp'])).toThrow('escapes repository'); + }); +}); diff --git a/cli/lib/install/anti-slop/vendored-source.test.mts b/cli/lib/install/anti-slop/vendored-source.test.mts new file mode 100644 index 0000000..f89c11a --- /dev/null +++ b/cli/lib/install/anti-slop/vendored-source.test.mts @@ -0,0 +1,32 @@ +import { createHash } from 'node:crypto'; +import { readdirSync, readFileSync } from 'node:fs'; +import { join, relative } from 'node:path'; +import { describe, expect, it } from 'vitest'; +import { ANTI_SLOP_RULE_IDS } from './constants.mts'; + +const PINNED_PRODUCTION_DIGEST = 'b92b5ec53886d609ba77cdd14578b9c5a813a22c8bfc7e86cd3a4e85ee4091b7'; + +function sourceDigest(): { digest: string; files: string[] } { + const root = join(import.meta.dirname, '../../../../anti-slop/src'); + const files = readdirSync(root, { recursive: true, withFileTypes: true }) + .filter((entry) => entry.isFile()) + .map((entry) => join(entry.parentPath, entry.name)) + .sort((a, b) => relative(root, a).localeCompare(relative(root, b))); + const hash = createHash('sha256'); + for (const file of files) { + hash.update(relative(root, file).split('\\').join('/')); + hash.update('\0'); + hash.update(readFileSync(file)); + hash.update('\0'); + } + return { digest: hash.digest('hex'), files }; +} + +describe('vendored anti-slop source', () => { + it('matches the reviewed upstream production tree and complete rule registry', () => { + const source = sourceDigest(); + expect(source.files).toHaveLength(19); + expect(source.digest).toBe(PINNED_PRODUCTION_DIGEST); + expect(ANTI_SLOP_RULE_IDS).toHaveLength(15); + }); +}); diff --git a/cli/lib/install/flags/init-flags.mts b/cli/lib/install/flags/init-flags.mts index 6bbcde1..cbab1d3 100644 --- a/cli/lib/install/flags/init-flags.mts +++ b/cli/lib/install/flags/init-flags.mts @@ -4,7 +4,10 @@ * the apply layer); review-policy flags stay in review-profile.mts and compose in here. */ +import { existsSync } from 'node:fs'; +import { join } from 'node:path'; import { AGENT_TARGETS, defaultSelection, GUARD_IDS, type Selection } from '../../components.mts'; +import { readJson } from '../../fs-helpers.mts'; import { parseReviewFlags, type ReviewFlagValues } from './review-profile.mts'; export interface InitFlags extends ReviewFlagValues { @@ -15,6 +18,7 @@ export interface InitFlags extends ReviewFlagValues { removeDeselected: boolean; fallow: boolean; oxc: boolean; + antiSlop: boolean; searchSteering: boolean; agentHooks: boolean; searchCode: boolean; @@ -39,6 +43,7 @@ export function parseFlags(args: string[]): InitFlags { removeDeselected: false, fallow: false, oxc: false, + antiSlop: false, searchSteering: false, agentHooks: false, searchCode: false, @@ -60,6 +65,7 @@ export function parseFlags(args: string[]): InitFlags { else if (a === '--remove-deselected') flags.removeDeselected = true; else if (a === '--fallow') flags.fallow = true; else if (a === '--oxc') flags.oxc = true; + else if (a === '--anti-slop') flags.antiSlop = true; else if (a === '--search-steering') flags.searchSteering = true; else if (a === '--agent-hooks') flags.agentHooks = true; else if (a === '--search-code') flags.searchCode = true; @@ -96,7 +102,11 @@ export function selectionFromFlags(flags: InitFlags): Selection { if (flags.no.has('line-growth')) sel.lineGrowth = false; // fallow + the agent-hook components are OPT-IN: off unless their flag is passed (and --no-* keeps off). sel.fallow = flags.fallow && !flags.no.has('fallow'); - sel.oxc = flags.oxc && !flags.no.has('oxc'); + sel.antiSlop = flags.antiSlop && !flags.no.has('anti-slop') && !flags.no.has('oxc'); + sel.oxc = (flags.oxc || sel.antiSlop) && !flags.no.has('oxc'); + if (flags.antiSlop && flags.no.has('oxc')) { + console.warn(' ! anti-slop skipped: --no-oxc disables its required runtime'); + } sel.searchSteering = flags.searchSteering && !flags.no.has('search-steering'); sel.agentHooks = flags.agentHooks && !flags.no.has('agent-hooks'); sel.searchCode = flags.searchCode && !flags.no.has('search-code'); @@ -116,3 +126,28 @@ export function selectionFromFlags(flags: InitFlags): Selection { } return sel; } + +/** Recover managed capabilities published before an interrupted init wrote its component record. */ +export function recoverInterruptedCapabilitySelection( + cwd: string, + flags: Pick, + selection: Selection, +): Selection { + const recorded = readJson(join(cwd, '.devkit', 'config.json')) as { + components?: unknown; + } | null; + if (recorded?.components) return selection; + + if (existsSync(join(cwd, '.devkit', 'oxc', 'manifest.json')) && !flags.no.has('oxc')) { + selection.oxc = true; + } + if ( + existsSync(join(cwd, '.devkit', 'anti-slop', 'manifest.json')) && + !flags.no.has('anti-slop') && + !flags.no.has('oxc') + ) { + selection.antiSlop = true; + selection.oxc = true; + } + return selection; +} diff --git a/cli/lib/install/oxc/lifecycle.mts b/cli/lib/install/oxc/lifecycle.mts index 9c913b3..e8c284d 100644 --- a/cli/lib/install/oxc/lifecycle.mts +++ b/cli/lib/install/oxc/lifecycle.mts @@ -33,12 +33,14 @@ interface ConfigOwnership { interface OxcManifest { schemaVersion: 1; pins: { oxlint: string; oxfmt: string }; + antiSlop: boolean; baseDigest: string; configs: { oxlint: ConfigOwnership; oxfmt: ConfigOwnership }; } interface SyncOptions { dryRun?: boolean; + antiSlop?: boolean; } /** Explain why an explicitly requested capability cannot activate in a non-repository mode. */ @@ -52,7 +54,13 @@ export function warnIfOxcUnavailable(mode: string, requested: boolean): void { const digest = (content: string | Buffer): string => createHash('sha256').update(content).digest('hex'); const fileDigest = (path: string): string => digest(readFileSync(path)); -const baseSource = (): string => join(packageDir(), 'oxc', 'oxlint.base.json'); +function baseContent(antiSlop: boolean): string { + const source = readFileSync(join(packageDir(), 'oxc', 'oxlint.base.json'), 'utf8'); + if (!antiSlop) return source; + const parsed = JSON.parse(source) as Record; + parsed.extends = ['../anti-slop/oxlint.json']; + return `${JSON.stringify(parsed, null, 2)}\n`; +} function isOwnership(value: unknown): value is ConfigOwnership { if (!value || typeof value !== 'object') return false; @@ -67,14 +75,14 @@ function readManifest(cwd: string): OxcManifest | null { const path = join(cwd, MANIFEST_REL); if (!existsSync(path)) return null; try { - const value = JSON.parse(readFileSync(path, 'utf8')) as OxcManifest; + const value = JSON.parse(readFileSync(path, 'utf8')) as Partial; return value.schemaVersion === 1 && typeof value.pins?.oxlint === 'string' && typeof value.pins?.oxfmt === 'string' && typeof value.baseDigest === 'string' && isOwnership(value.configs?.oxlint) && isOwnership(value.configs?.oxfmt) - ? value + ? ({ ...value, antiSlop: value.antiSlop === true } as OxcManifest) : null; } catch { return null; @@ -94,6 +102,28 @@ function assertNoConfigCollisions(cwd: string): void { } } +/** Read-only preflight used before a dependent capability publishes managed state. */ +export function assertOxcCapabilityReady(cwd: string): void { + const lint = probeOxcRuntime('lint'); + const fmt = probeOxcRuntime('fmt'); + if (!lint.ok || !fmt.ok || !lint.runtime || !fmt.runtime) { + throw new Error(`bundled Oxc runtime unavailable: ${lint.detail}; ${fmt.detail}`); + } + assertNoConfigCollisions(cwd); +} + +/** Require the managed base bytes and recorded digest to match the current selected capabilities. */ +export function oxcBaseCapabilityIssue(cwd: string): string | null { + const manifest = readManifest(cwd); + if (!manifest) return 'managed Oxc manifest is missing or invalid'; + const expected = digest(baseContent(manifest.antiSlop)); + if (manifest.baseDigest !== expected) return 'managed Oxlint base manifest digest is stale'; + const path = join(cwd, BASE_REL); + if (!existsSync(path) || fileDigest(path) !== expected) + return 'managed Oxlint base is missing or drifted'; + return null; +} + function ownershipFor( cwd: string, names: string[], @@ -111,7 +141,7 @@ function ownershipFor( return { path: starterPath, createdDigest: digest(starter) }; } -function syncOxcCapabilityUnlocked(cwd: string, dryRun: boolean): void { +function syncOxcCapabilityUnlocked(cwd: string, dryRun: boolean, antiSlop: boolean): void { const previous = readManifest(cwd); const lint = probeOxcRuntime('lint'); const fmt = probeOxcRuntime('fmt'); @@ -121,7 +151,7 @@ function syncOxcCapabilityUnlocked(cwd: string, dryRun: boolean): void { // Validate both tools before creating either starter: a formatter collision must not leave a // half-installed linter config (and vice versa). assertNoConfigCollisions(cwd); - const base = readFileSync(baseSource(), 'utf8'); + const base = baseContent(antiSlop); if (!dryRun) { mkdirSync(join(cwd, '.devkit', 'oxc'), { recursive: true }); writeFileAtomic(join(cwd, BASE_REL), base); @@ -151,6 +181,7 @@ function syncOxcCapabilityUnlocked(cwd: string, dryRun: boolean): void { const manifest: OxcManifest = { schemaVersion: 1, pins: { oxlint: lint.runtime.expectedVersion, oxfmt: fmt.runtime.expectedVersion }, + antiSlop, baseDigest: digest(base), configs: { oxlint, oxfmt }, }; @@ -176,13 +207,16 @@ function syncOxcCapabilityUnlocked(cwd: string, dryRun: boolean): void { } /** Install or upgrade managed base/provenance while preserving every existing root config byte. */ -export function syncOxcCapability(cwd: string, { dryRun = false }: SyncOptions = {}): void { +export function syncOxcCapability( + cwd: string, + { dryRun = false, antiSlop = false }: SyncOptions = {}, +): void { if (dryRun) { - syncOxcCapabilityUnlocked(cwd, true); + syncOxcCapabilityUnlocked(cwd, true, antiSlop); return; } mkdirSync(join(cwd, '.devkit'), { recursive: true }); - withLock(join(cwd, LOCK_REL), () => syncOxcCapabilityUnlocked(cwd, false)); + withLock(join(cwd, LOCK_REL), () => syncOxcCapabilityUnlocked(cwd, false, antiSlop)); } function parseJsonConfig(cwd: string, ownership: ConfigOwnership): string | null { @@ -261,8 +295,7 @@ export function checkOxcCapability(cwd: string): CheckResult[] { 'reinstall the pinned @norvalbv/devkit package with optional platform dependencies', ); const basePath = join(cwd, BASE_REL); - const desiredBase = readFileSync(baseSource()); - const baseCurrent = existsSync(basePath) && fileDigest(basePath) === digest(desiredBase); + const baseCurrent = oxcBaseCapabilityIssue(cwd) === null; const base = baseCurrent ? check('Oxlint base', 'OK', BASE_REL) : check( diff --git a/cli/lib/install/oxc/lifecycle.test.mts b/cli/lib/install/oxc/lifecycle.test.mts index ced354f..28362ed 100644 --- a/cli/lib/install/oxc/lifecycle.test.mts +++ b/cli/lib/install/oxc/lifecycle.test.mts @@ -83,6 +83,27 @@ describe('Oxc capability lifecycle', () => { expect(checkOxcCapability(root).every((result) => result.status === 'OK')).toBe(true); }); + it('unwires anti-slop from the managed base from explicit selection, not stale files', () => { + const root = tempRoot(); + mkdirSync(join(root, '.devkit/anti-slop'), { recursive: true }); + writeFileSync(join(root, '.devkit/anti-slop/manifest.json'), '{}\n'); + writeFileSync(join(root, '.devkit/anti-slop/oxlint.json'), '{}\n'); + + syncOxcCapability(root, { antiSlop: true }); + expect(readFileSync(join(root, '.devkit/oxc/oxlint.base.json'), 'utf8')).toContain( + '../anti-slop/oxlint.json', + ); + + syncOxcCapability(root, { antiSlop: false }); + expect(readFileSync(join(root, '.devkit/oxc/oxlint.base.json'), 'utf8')).not.toContain( + '../anti-slop/oxlint.json', + ); + expect(JSON.parse(readFileSync(join(root, '.devkit/oxc/manifest.json'), 'utf8'))).toMatchObject( + { antiSlop: false }, + ); + expect(checkOxcCapability(root).every((result) => result.status === 'OK')).toBe(true); + }); + it('serializes lifecycle ownership updates with the Oxc manifest lock', async () => { const root = tempRoot(); syncOxcCapability(root); diff --git a/cli/lib/install/upgrade-offers.mts b/cli/lib/install/upgrade-offers.mts index 058c9a3..7ff148a 100644 --- a/cli/lib/install/upgrade-offers.mts +++ b/cli/lib/install/upgrade-offers.mts @@ -149,6 +149,7 @@ export async function offerOptionalComponents( } const chosen = new Set(picked as string[]); for (const c of unoffered) sel[c.id] = chosen.has(c.id); + if (sel.antiSlop) sel.oxc = true; console.log( chosen.size ? ` ✓ added: ${[...chosen].join(', ')}` diff --git a/cli/lib/wizard.mts b/cli/lib/wizard.mts index 6056787..d2b61fd 100644 --- a/cli/lib/wizard.mts +++ b/cli/lib/wizard.mts @@ -99,6 +99,12 @@ const OXC_OPTION = { hint: 'pinned Oxlint/Oxfmt runtime + repository config (off by default)', }; +const ANTI_SLOP_OPTION = { + id: 'antiSlop', + label: 'anti-slop rules', + hint: '15 vendored Oxlint rules + explicit shrink-only baseline (includes Oxc)', +}; + // prior-art gate: same opt-in shape as adhd, and kept out of COMPONENTS for the same reason — it // denies harness tool calls (deny-once per session), so it only ever arrives because someone ticked // this box. The id is the camelCase Selection key so installedOptional seeding matches on re-runs. @@ -235,7 +241,7 @@ export async function runWizard({ ], initialValues: [ ...choices.filter((c) => c.recommended).map((c) => c.id), - ...installedOptional.filter((id) => id !== 'oxc'), + ...installedOptional.filter((id) => id !== 'oxc' && id !== 'antiSlop'), ], required: false, }); @@ -259,6 +265,7 @@ export async function runWizard({ componentOption(ADHD_OPTION), componentOption(PRIOR_ART_GATE_OPTION), componentOption(OXC_OPTION), + componentOption(ANTI_SLOP_OPTION), ], initialValues: [ ...componentChoices.filter((c) => c.recommended).map((c) => c.id), @@ -273,7 +280,8 @@ export async function runWizard({ selection.searchCode = chosen.has('search-code'); selection.adhd = chosen.has('adhd'); selection.priorArtGate = chosen.has('priorArtGate'); - selection.oxc = chosen.has('oxc'); + selection.antiSlop = chosen.has('antiSlop'); + selection.oxc = chosen.has('oxc') || selection.antiSlop; if (!structAvail) selection.structure = false; } @@ -439,6 +447,7 @@ function summarize( lines.push(`${selection.adhd ? '✓' : '·'} ${ADHD_OPTION.label}`); lines.push(`${selection.priorArtGate ? '✓' : '·'} ${PRIOR_ART_GATE_OPTION.label}`); lines.push(`${selection.oxc ? '✓' : '·'} ${OXC_OPTION.label}`); + lines.push(`${selection.antiSlop ? '✓' : '·'} ${ANTI_SLOP_OPTION.label}`); lines.push(`${selection.lineGrowth ? '✓' : '·'} line-growth block`); if (AGENT_SURFACE_COMPONENTS.some((id) => selection[id])) { lines.push(` agent surface(s): ${(selection.agentTargets ?? AGENT_TARGETS).join(', ')}`); diff --git a/dist/README.md b/dist/README.md index 4a6dd03..ad4b908 100644 --- a/dist/README.md +++ b/dist/README.md @@ -12,6 +12,7 @@ A versioned developer toolkit that keeps agent instructions, project conventions | Shared configuration | Biome and strict TypeScript presets for common stacks | Stable package export paths | | Portable gate engine | Decision, review, duplication, structure, size, fan-out, Sentry, and advisory gates | `guard-*` command-line tools | | Repository setup | Stack detection, idempotent installation, upgrades, diagnostics, and cleanup | The `devkit` CLI | +| Oxc + anti-slop | Exact Oxlint/Oxfmt pins, 15 vendored rules, and incremental debt adoption | Opt-in managed capability | The package and agent assets use the same release tag. A prompt or skill cannot silently drift away from the installer and gate implementation that consumes it. @@ -70,9 +71,24 @@ Package mode is the default. Standalone gates fail open when the pinned global C | `devkit review` | Run the configured gate chain against a trusted checkout without committing | | `devkit reconcile` | Refresh a shared checkout after shipped work merges | | `devkit clean` | Remove the recorded installation | +| `devkit anti-slop create/check/inspect/prune` | Manage the explicit shrink-only anti-slop baseline | Run `devkit help` for the command index and `devkit help ` for authoritative options. +### Adopt anti-slop incrementally + +```bash +devkit init --anti-slop +devkit anti-slop create +devkit anti-slop check +``` + +`--anti-slop` implies the opt-in Oxc capability. Rules load beside the repository's other +Oxlint rules; their severities and scoped overrides stay in the ordinary Oxlint config. Baseline +creation is always explicit, normal checks are read-only, and pruning can only remove fixed debt. +See the [anti-slop capability guide](docs/anti-slop.md) for provenance, the complete rule matrix, +and the fingerprint contract. + ### Review a trusted checkout ```bash diff --git a/dist/anti-slop/LICENSE b/dist/anti-slop/LICENSE new file mode 100644 index 0000000..69239ea --- /dev/null +++ b/dist/anti-slop/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Dillon Mulroy + +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/dist/anti-slop/UPSTREAM.md b/dist/anti-slop/UPSTREAM.md new file mode 100644 index 0000000..6321275 --- /dev/null +++ b/dist/anti-slop/UPSTREAM.md @@ -0,0 +1,9 @@ +# Vendored anti-slop provenance + +- Repository: https://github.com/dmmulroy/anti-slop +- Commit: `446268e5d15baa968eaec669ff65358d36ae6259` +- Vendored: 2026-08-16 +- License: MIT (see `LICENSE`) + +`src/` is copied from the upstream production source at the pinned commit. Devkit compiles it +unchanged and installs a self-contained `@oxlint/plugins@1.78.0` runtime beside the emitted plugin. diff --git a/dist/anti-slop/src/index.js b/dist/anti-slop/src/index.js new file mode 100644 index 0000000..e991a80 --- /dev/null +++ b/dist/anti-slop/src/index.js @@ -0,0 +1,38 @@ +import { eslintCompatPlugin } from "@oxlint/plugins"; +import { noChainedTypeAssertionsRule } from "./rules/no-chained-type-assertions.js"; +import { noConditionalEmptyObjectSpreadRule } from "./rules/no-conditional-empty-object-spread.js"; +import { noKnownValueWideningRule } from "./rules/no-known-value-widening.js"; +import { noModuleMockingRule } from "./rules/no-module-mocking.js"; +import { noObjectParametersRule } from "./rules/no-object-parameters.js"; +import { noReflectApplyRule } from "./rules/no-reflect-apply.js"; +import { noReflectGetRule } from "./rules/no-reflect-get.js"; +import { noRuntimeTypeofRule } from "./rules/no-runtime-typeof.js"; +import { noForbiddenTermInSymbolNamesRule } from "./rules/no-shape-in-symbol-names.js"; +import { noUnknownParametersRule } from "./rules/no-unknown-parameters.js"; +import { noUnknownReturnsRule } from "./rules/no-unknown-returns.js"; +import { noUnknownTypeAliasesRule } from "./rules/no-unknown-type-aliases.js"; +import { noUnsafeDictionaryTypeRule } from "./rules/no-unsafe-dictionary-type.js"; +import { noWidenThenAssertRule } from "./rules/no-widen-then-assert.js"; +import { requireSafetyCommentForTypeAssertionRule } from "./rules/require-safety-comment-for-type-assertion.js"; +/** Generic Oxlint rules that reject low-evidence and low-signal implementation patterns. */ +const antiSlopPlugin = eslintCompatPlugin({ + meta: { name: "anti-slop" }, + rules: { + "no-chained-type-assertions": noChainedTypeAssertionsRule, + "no-conditional-empty-object-spread": noConditionalEmptyObjectSpreadRule, + "no-known-value-widening": noKnownValueWideningRule, + "no-module-mocking": noModuleMockingRule, + "no-object-parameters": noObjectParametersRule, + "no-reflect-apply": noReflectApplyRule, + "no-reflect-get": noReflectGetRule, + "no-runtime-typeof": noRuntimeTypeofRule, + "no-unsafe-dictionary-type": noUnsafeDictionaryTypeRule, + "no-shape-in-symbol-names": noForbiddenTermInSymbolNamesRule, + "no-unknown-parameters": noUnknownParametersRule, + "no-unknown-returns": noUnknownReturnsRule, + "no-unknown-type-aliases": noUnknownTypeAliasesRule, + "no-widen-then-assert": noWidenThenAssertRule, + "require-safety-comment-for-type-assertion": requireSafetyCommentForTypeAssertionRule, + }, +}); +export default antiSlopPlugin; diff --git a/dist/anti-slop/src/rules/no-chained-type-assertions.js b/dist/anti-slop/src/rules/no-chained-type-assertions.js new file mode 100644 index 0000000..4f43003 --- /dev/null +++ b/dist/anti-slop/src/rules/no-chained-type-assertions.js @@ -0,0 +1,60 @@ +import { defineRule } from "@oxlint/plugins"; +function isTypeAssertionExpression(node) { + return node.type === "TSAsExpression" || node.type === "TSTypeAssertion"; +} +function unwrapParenthesizedExpression(expression) { + let current = expression; + while (current.type === "ParenthesizedExpression") { + current = current.expression; + } + return current; +} +function isConstAssertion(node) { + const { typeAnnotation } = node; + return (typeAnnotation.type === "TSTypeReference" && + typeAnnotation.typeName.type === "Identifier" && + typeAnnotation.typeName.name === "const"); +} +function isOutermostAssertionInChain(node) { + let current = 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) { + let assertionCount = 0; + let hasNonConstAssertion = false; + let current = 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: "This assertion chain discards type evidence. Keep the original precise type, or parse untrusted input at its boundary before narrowing it.", + }, + }, + createOnce(context) { + const checkTypeAssertion = (node) => { + if (!isOutermostAssertionInChain(node) || !isForbiddenAssertionChain(node)) + return; + context.report({ node, messageId: "chained" }); + }; + return { + TSAsExpression: checkTypeAssertion, + TSTypeAssertion: checkTypeAssertion, + }; + }, +}); diff --git a/dist/anti-slop/src/rules/no-conditional-empty-object-spread.js b/dist/anti-slop/src/rules/no-conditional-empty-object-spread.js new file mode 100644 index 0000000..de1008c --- /dev/null +++ b/dist/anti-slop/src/rules/no-conditional-empty-object-spread.js @@ -0,0 +1,40 @@ +import { defineRule } from "@oxlint/plugins"; +function unwrapParentheses(node) { + let current = node; + while (current.type === "ParenthesizedExpression") { + current = current.expression; + } + return current; +} +function isEmptyObjectExpression(node) { + return node.type === "ObjectExpression" && node.properties.length === 0; +} +function isConditionalEmptyObjectSpread(node) { + 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: "This conditional spread hides property omission behind an empty object. Build the object in separate statements and add the property only when present.", + }, + }, + createOnce(context) { + return { + SpreadElement(node) { + if (node.parent.type !== "ObjectExpression") + return; + if (isConditionalEmptyObjectSpread(node.argument)) { + context.report({ node, messageId: "avoid" }); + } + }, + }; + }, +}); diff --git a/dist/anti-slop/src/rules/no-known-value-widening.js b/dist/anti-slop/src/rules/no-known-value-widening.js new file mode 100644 index 0000000..cda454c --- /dev/null +++ b/dist/anti-slop/src/rules/no-known-value-widening.js @@ -0,0 +1,183 @@ +import { defineRule } from "@oxlint/plugins"; +import { classifyWideningTarget, createTypeEnvironment, isKnownEvidenceExpression, } from "../shared/dictionary-types.js"; +function unwrapExpression(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, identifier) { + let scope = 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) { + 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, declarator) { + return (declarator.parent.type === "VariableDeclaration" && + declarator.parent.kind === "const" && + variable.references.every((reference) => reference.init || !reference.isWrite())); +} +function hasKnownEvidence(sourceCode, expression, visitedVariables = new Set()) { + 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, environment) { + return annotation === null || annotation === undefined + ? null + : classifyWideningTarget(annotation.typeAnnotation, environment); +} +function enclosingFunction(node) { + let current = 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, key) { + 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, owner) { + 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) { + const unwrapped = unwrapExpression(expression); + return unwrapped.type === "ObjectExpression" && unwrapped.properties.length === 0; +} +function isDictionaryAccumulatorTarget(destination) { + return destination.kind === "open dictionary" || destination.kind === "generic container"; +} +function hasParentAssertion(node) { + 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 = null; + const reportFlow = (expression, destination, subject) => { + 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) => 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/dist/anti-slop/src/rules/no-module-mocking.js b/dist/anti-slop/src/rules/no-module-mocking.js new file mode 100644 index 0000000..bcb3ab3 --- /dev/null +++ b/dist/anti-slop/src/rules/no-module-mocking.js @@ -0,0 +1,78 @@ +import { defineRule } from "@oxlint/plugins"; +const moduleMockMethods = new Set(["doMock", "mock", "unstable_mockModule"]); +function resolveVariable(sourceCode, identifier) { + let scope = sourceCode.getScope(identifier); + while (scope !== null) { + const variable = scope.set.get(identifier.name); + if (variable !== undefined) + return variable; + scope = scope.upper; + } + return null; +} +function importedName(node) { + if (node.type !== "ImportSpecifier") + return null; + return node.imported.type === "Identifier" ? node.imported.name : node.imported.value; +} +function isTestFrameworkObject(sourceCode, expression) { + if (expression.type !== "Identifier") + return false; + if ((expression.name === "vi" || expression.name === "jest") && + sourceCode.isGlobalReference(expression)) { + return true; + } + const variable = resolveVariable(sourceCode, expression); + if (variable === null || variable.defs.length === 0) { + return expression.name === "vi" || expression.name === "jest"; + } + return variable.defs.some((definition) => { + if (definition.type !== "ImportBinding" || definition.parent?.type !== "ImportDeclaration") { + return false; + } + const source = definition.parent.source.value; + const name = importedName(definition.node); + return (source === "vitest" && name === "vi") || (source === "@jest/globals" && name === "jest"); + }); +} +function moduleMockCall(sourceCode, callee) { + if (!("property" in callee) || !("object" in callee) || !("computed" in callee)) + return false; + if (!isTestFrameworkObject(sourceCode, callee.object)) + return false; + const property = callee.property; + const method = callee.computed + ? property.type === "Literal" && + (property.value === "doMock" || + property.value === "mock" || + property.value === "unstable_mockModule") + ? property.value + : null + : property.type === "Identifier" + ? property.name + : null; + return method !== null && moduleMockMethods.has(method); +} +/** Ban test framework module mocking in favor of real dependency seams. */ +export const noModuleMockingRule = defineRule({ + meta: { + type: "problem", + docs: { + description: "Disallow Vitest and Jest module mocking; tests must replace dependencies through real interfaces.", + }, + messages: { + moduleMock: "Replace module mocking with dependency injection through a real interface, service layer, or faithful test implementation.", + }, + }, + createOnce(context) { + return { + CallExpression(node) { + if (node.callee.type === "Super" || node.callee.type === "V8IntrinsicExpression") + return; + if (moduleMockCall(context.sourceCode, node.callee)) { + context.report({ node, messageId: "moduleMock" }); + } + }, + }; + }, +}); diff --git a/dist/anti-slop/src/rules/no-object-parameters.js b/dist/anti-slop/src/rules/no-object-parameters.js new file mode 100644 index 0000000..06b93fb --- /dev/null +++ b/dist/anti-slop/src/rules/no-object-parameters.js @@ -0,0 +1,95 @@ +import { defineRule } from "@oxlint/plugins"; +import { lexicalTypeParameterNames } from "../shared/lexical-type-parameters.js"; +function parameterAnnotation(parameter) { + 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, sourceCode) { + 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}}` uses the broad `object` type. Accept a named owner type; parse external input at its boundary before calling this function.", + }, + }, + createOnce(context) { + const aliases = new Map(); + const resolvesToObject = (type, shadowedAliases, visited = new Set()) => { + 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)); + } + 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) => { + const shadowedAliases = lexicalTypeParameterNames(node, context.sourceCode.visitorKeys); + for (const parameter of node.params) { + const annotation = parameterAnnotation(parameter); + if (annotation === null || annotation === undefined) + continue; + if (!resolvesToObject(annotation.typeAnnotation, shadowedAliases)) + continue; + context.report({ + node: annotation.typeAnnotation, + messageId: "objectParameter", + data: { parameter: parameterName(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); + } + } + }, + ArrowFunctionExpression: checkParameters, + FunctionDeclaration: checkParameters, + FunctionExpression: checkParameters, + TSCallSignatureDeclaration: checkParameters, + TSConstructSignatureDeclaration: checkParameters, + TSConstructorType: checkParameters, + TSDeclareFunction: checkParameters, + TSEmptyBodyFunctionExpression: checkParameters, + TSFunctionType: checkParameters, + TSMethodSignature: checkParameters, + }; + }, +}); diff --git a/dist/anti-slop/src/rules/no-reflect-apply.js b/dist/anti-slop/src/rules/no-reflect-apply.js new file mode 100644 index 0000000..845511d --- /dev/null +++ b/dist/anti-slop/src/rules/no-reflect-apply.js @@ -0,0 +1,25 @@ +import { defineRule } from "@oxlint/plugins"; +import { isGlobalReflectMethodCall } from "../shared/reflect-method.js"; +/** Ban Reflect.apply, which bypasses ordinary typed function calls. */ +export const noReflectApplyRule = defineRule({ + meta: { + type: "problem", + docs: { + description: "Disallow Reflect.apply; call typed functions directly or model dynamic dispatch behind an interface.", + }, + messages: { + reflectApply: "Replace `Reflect.apply` with a typed function call. Model dynamic dispatch behind a named interface.", + }, + }, + createOnce(context) { + return { + CallExpression(node) { + if (node.callee.type === "Super" || node.callee.type === "V8IntrinsicExpression") + return; + if (isGlobalReflectMethodCall(context.sourceCode, node.callee, "apply")) { + context.report({ node, messageId: "reflectApply" }); + } + }, + }; + }, +}); diff --git a/dist/anti-slop/src/rules/no-reflect-get.js b/dist/anti-slop/src/rules/no-reflect-get.js new file mode 100644 index 0000000..6ae5e14 --- /dev/null +++ b/dist/anti-slop/src/rules/no-reflect-get.js @@ -0,0 +1,25 @@ +import { defineRule } from "@oxlint/plugins"; +import { isGlobalReflectMethodCall } from "../shared/reflect-method.js"; +/** Ban Reflect.get, which bypasses ordinary property access and useful type evidence. */ +export const noReflectGetRule = defineRule({ + meta: { + type: "problem", + docs: { + description: "Disallow Reflect.get; use typed property access or parse dynamic input into a domain type.", + }, + messages: { + reflectGet: "Replace `Reflect.get` with typed property access. Parse dynamic input into a named domain type before reading it.", + }, + }, + createOnce(context) { + return { + CallExpression(node) { + if (node.callee.type === "Super" || node.callee.type === "V8IntrinsicExpression") + return; + if (isGlobalReflectMethodCall(context.sourceCode, node.callee, "get")) { + context.report({ node, messageId: "reflectGet" }); + } + }, + }; + }, +}); diff --git a/dist/anti-slop/src/rules/no-runtime-typeof.js b/dist/anti-slop/src/rules/no-runtime-typeof.js new file mode 100644 index 0000000..7ff9641 --- /dev/null +++ b/dist/anti-slop/src/rules/no-runtime-typeof.js @@ -0,0 +1,53 @@ +import { defineRule } from "@oxlint/plugins"; +function isRuntimeFunction(node) { + return (node.type === "ArrowFunctionExpression" || + node.type === "FunctionDeclaration" || + node.type === "FunctionExpression"); +} +function isInsideTypeGuard(node) { + let current = node.parent; + while (current !== null && current.type !== "Program") { + if (isRuntimeFunction(current)) { + return current.returnType?.typeAnnotation.type === "TSTypePredicate"; + } + current = current.parent; + } + return false; +} +/** 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 `typeof` check narrows a representation without establishing its contract. Parse input at its I/O boundary, then branch on the domain value.", + }, + schema: [ + { + type: "object", + properties: { + allowInTypeGuards: { type: "boolean" }, + }, + additionalProperties: false, + }, + ], + defaultOptions: [{ allowInTypeGuards: false }], + }, + createOnce(context) { + return { + UnaryExpression(node) { + const option = context.options?.[0]; + const allowInTypeGuards = typeof option === "object" && + option !== null && + !Array.isArray(option) && + option.allowInTypeGuards === true; + if (node.operator === "typeof" && + (!allowInTypeGuards || !isInsideTypeGuard(node))) { + context.report({ node, messageId: "runtimeTypeof" }); + } + }, + }; + }, +}); diff --git a/dist/anti-slop/src/rules/no-shape-in-symbol-names.js b/dist/anti-slop/src/rules/no-shape-in-symbol-names.js new file mode 100644 index 0000000..05fb509 --- /dev/null +++ b/dist/anti-slop/src/rules/no-shape-in-symbol-names.js @@ -0,0 +1,33 @@ +import { defineRule } from "@oxlint/plugins"; +const FORBIDDEN_SYMBOL_NAME = "shape"; +function containsForbiddenSymbolName(name) { + 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: 'Rename symbol "{{name}}" for its domain role; "shape" describes structure rather than ownership.', + }, + }, + createOnce(context) { + const reportForbiddenSymbolName = (node) => { + if (!containsForbiddenSymbolName(node.name)) + return; + context.report({ + node, + messageId: "forbiddenSymbolName", + data: { name: node.name }, + }); + }; + return { + Identifier: reportForbiddenSymbolName, + PrivateIdentifier: reportForbiddenSymbolName, + JSXIdentifier: reportForbiddenSymbolName, + }; + }, +}); diff --git a/dist/anti-slop/src/rules/no-unknown-parameters.js b/dist/anti-slop/src/rules/no-unknown-parameters.js new file mode 100644 index 0000000..ec85c00 --- /dev/null +++ b/dist/anti-slop/src/rules/no-unknown-parameters.js @@ -0,0 +1,68 @@ +import { defineRule } from "@oxlint/plugins"; +function parameterAnnotation(parameter) { + 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, sourceText) { + 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}}` leaves input unparsed. Accept a named domain type; run the expected schema or parser at the I/O boundary before calling this function.", + }, + }, + createOnce(context) { + const checkParameters = (node) => { + 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/dist/anti-slop/src/rules/no-unknown-returns.js b/dist/anti-slop/src/rules/no-unknown-returns.js new file mode 100644 index 0000000..cb4172b --- /dev/null +++ b/dist/anti-slop/src/rules/no-unknown-returns.js @@ -0,0 +1,85 @@ +import { defineRule } from "@oxlint/plugins"; +import { lexicalTypeParameterNames } from "../shared/lexical-type-parameters.js"; +function referencedAliasName(type) { + 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: { + type: "problem", + docs: { + description: "Disallow functions whose explicit return contract is unknown or Promise.", + }, + messages: { + unknownReturn: "This function exposes `unknown` to its caller. Parse the value at its boundary and return a named domain type.", + }, + }, + createOnce(context) { + const aliases = new Map(); + const resolvesToUnknown = (type, shadowedAliases, visited = new Set()) => { + 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 checkReturnType = (node) => { + const annotation = node.returnType; + if (annotation === null || annotation === undefined) + return; + if (!resolvesToUnknown(annotation.typeAnnotation, lexicalTypeParameterNames(node, context.sourceCode.visitorKeys))) { + 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); + } + } + }, + ArrowFunctionExpression: checkReturnType, + FunctionDeclaration: checkReturnType, + FunctionExpression: checkReturnType, + TSCallSignatureDeclaration: checkReturnType, + TSConstructSignatureDeclaration: checkReturnType, + TSConstructorType: checkReturnType, + TSDeclareFunction: checkReturnType, + TSEmptyBodyFunctionExpression: checkReturnType, + TSFunctionType: checkReturnType, + TSMethodSignature: checkReturnType, + }; + }, +}); diff --git a/dist/anti-slop/src/rules/no-unknown-type-aliases.js b/dist/anti-slop/src/rules/no-unknown-type-aliases.js new file mode 100644 index 0000000..6089f7f --- /dev/null +++ b/dist/anti-slop/src/rules/no-unknown-type-aliases.js @@ -0,0 +1,64 @@ +import { defineRule } from "@oxlint/plugins"; +function referencedAliasName(type) { + 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}}` hides `unknown`. Keep `unknown` explicit at the parsing boundary or on an allowed `cause` field; otherwise use the parsed owner type.", + }, + }, + createOnce(context) { + const aliases = new Map(); + const resolvesToUnknown = (type, visited = new Set()) => { + 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) { + 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 }, + }); + } + }, + }; + }, +}); diff --git a/dist/anti-slop/src/rules/no-unsafe-dictionary-type.js b/dist/anti-slop/src/rules/no-unsafe-dictionary-type.js new file mode 100644 index 0000000..e8cb251 --- /dev/null +++ b/dist/anti-slop/src/rules/no-unsafe-dictionary-type.js @@ -0,0 +1,118 @@ +import { defineRule } from "@oxlint/plugins"; +import { classifyUnsafeDictionary, classifyUnsafeDictionaryValue, createTypeEnvironment, } from "../shared/dictionary-types.js"; +const typeNodeKinds = new Set([ + "JSDocNonNullableType", + "JSDocNullableType", + "JSDocUnknownType", + "TSAnyKeyword", + "TSArrayType", + "TSBigIntKeyword", + "TSBooleanKeyword", + "TSConditionalType", + "TSConstructorType", + "TSFunctionType", + "TSImportType", + "TSIndexedAccessType", + "TSInferType", + "TSIntersectionType", + "TSIntrinsicKeyword", + "TSLiteralType", + "TSMappedType", + "TSNamedTupleMember", + "TSNeverKeyword", + "TSNullKeyword", + "TSNumberKeyword", + "TSObjectKeyword", + "TSParenthesizedType", + "TSStringKeyword", + "TSSymbolKeyword", + "TSTemplateLiteralType", + "TSThisType", + "TSTupleType", + "TSTypeLiteral", + "TSTypeOperator", + "TSTypePredicate", + "TSTypeQuery", + "TSTypeReference", + "TSUndefinedKeyword", + "TSUnionType", + "TSUnknownKeyword", + "TSVoidKeyword", +]); +function isTypeNode(node) { + return typeNodeKinds.has(node.type); +} +function typeReferenceName(type) { + return type.typeName.type === "Identifier" ? type.typeName.name : null; +} +function isInsideTypeAliasDeclaration(node) { + let current = node.parent; + while (current !== null && current.type !== "Program") { + if (current.type === "TSTypeAliasDeclaration") + return true; + current = current.parent; + } + return false; +} +function isPlainAliasConsumerUse(node, environment) { + 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, environment) { + if (isPlainAliasConsumerUse(node, environment)) + return false; + if (classifyUnsafeDictionary(node, environment) === null) + return false; + let current = 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 dictionary's {{value}} value type gives callers no concrete value contract. Use an owner/schema-derived value type; parse external payloads before insertion.", + }, + }, + createOnce(context) { + let environment = null; + const report = (node, value) => { + context.report({ node, messageId: "unsafeDictionary", data: { value } }); + }; + const reportIfUnsafe = (node) => { + 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/dist/anti-slop/src/rules/no-widen-then-assert.js b/dist/anti-slop/src/rules/no-widen-then-assert.js new file mode 100644 index 0000000..e04180b --- /dev/null +++ b/dist/anti-slop/src/rules/no-widen-then-assert.js @@ -0,0 +1,270 @@ +import { defineRule } from "@oxlint/plugins"; +const functionBoundaryTypes = new Set([ + "ArrowFunctionExpression", + "FunctionDeclaration", + "FunctionExpression", + "TSDeclareFunction", + "TSEmptyBodyFunctionExpression", +]); +function unwrapExpressionParentheses(expression) { + let current = expression; + while (current.type === "ParenthesizedExpression") + current = current.expression; + return current; +} +function unwrapTypeParentheses(type) { + let current = type; + while (current.type === "TSParenthesizedType") + current = current.typeAnnotation; + return current; +} +function typeReferenceName(type) { + return type.typeName.type === "Identifier" ? type.typeName.name : null; +} +function isUnknownOrAnyType(type) { + const unwrapped = unwrapTypeParentheses(type); + return unwrapped.type === "TSUnknownKeyword" || unwrapped.type === "TSAnyKeyword"; +} +function isBroadRecordKeyType(type) { + 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) { + 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) { + 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) { + return unwrapExpressionParentheses(node.expression); +} +function assertionFromExpression(expression) { + const unwrapped = unwrapExpressionParentheses(expression); + return unwrapped.type === "TSAsExpression" || unwrapped.type === "TSTypeAssertion" + ? unwrapped + : null; +} +function normalizedTypeText(sourceText, type) { + return sourceText.slice(type.start, type.end).replaceAll(/\s+/gu, ""); +} +function typesHaveSameSyntax(sourceText, left, right) { + return (left !== null && + normalizedTypeText(sourceText, unwrapTypeParentheses(left)) === + normalizedTypeText(sourceText, unwrapTypeParentheses(right))); +} +function isDefinitelyObjectType(type) { + 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) { + 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) { + 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, identifier) { + 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) { + for (const definition of variable.defs) { + if (definition.type === "Variable" && definition.node.type === "VariableDeclarator") { + return definition.node; + } + } + return null; +} +function knownValueEvidence(expression, scopes, boundary, visitedVariables) { + 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, scopes) { + 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, broadKind, evidence, assertedType) { + 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 = []; + const checkAssertion = (node) => { + 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, + }; + }, +}); diff --git a/dist/anti-slop/src/rules/require-safety-comment-for-type-assertion.js b/dist/anti-slop/src/rules/require-safety-comment-for-type-assertion.js new file mode 100644 index 0000000..4fd8805 --- /dev/null +++ b/dist/anti-slop/src/rules/require-safety-comment-for-type-assertion.js @@ -0,0 +1,49 @@ +import { defineRule } from "@oxlint/plugins"; +const commentOwnerKinds = new Set([ + "ExpressionStatement", + "PropertyDefinition", + "ReturnStatement", + "ThrowStatement", + "VariableDeclaration", +]); +function isConstAssertion(node) { + return (node.typeAnnotation.type === "TSTypeReference" && + node.typeAnnotation.typeName.type === "Identifier" && + node.typeAnnotation.typeName.name === "const"); +} +function hasSafetyComment(sourceCode, node) { + let current = node; + while (true) { + if (sourceCode + .getCommentsBefore(current) + .some((comment) => comment.end <= node.start && /\bSAFETY\s*:/u.test(comment.value))) { + return true; + } + if (commentOwnerKinds.has(current.type) || current.parent.type === "Program") + return false; + current = current.parent; + } +} +/** Require every non-const type assertion to state the invariant TypeScript cannot express. */ +export const requireSafetyCommentForTypeAssertionRule = defineRule({ + meta: { + type: "problem", + docs: { + description: "Require a nearby SAFETY comment for every TypeScript type assertion except const assertions.", + }, + messages: { + missingSafetyComment: "This type assertion has no `SAFETY:` justification. State the checked invariant immediately before the assertion or its containing statement.", + }, + }, + createOnce(context) { + const checkAssertion = (node) => { + if (isConstAssertion(node) || hasSafetyComment(context.sourceCode, node)) + return; + context.report({ node, messageId: "missingSafetyComment" }); + }; + return { + TSAsExpression: checkAssertion, + TSTypeAssertion: checkAssertion, + }; + }, +}); diff --git a/dist/anti-slop/src/shared/dictionary-types.js b/dist/anti-slop/src/shared/dictionary-types.js new file mode 100644 index 0000000..c7994aa --- /dev/null +++ b/dist/anti-slop/src/shared/dictionary-types.js @@ -0,0 +1,388 @@ +const BUILT_INS = new Set([ + "Record", + "Readonly", + "Partial", + "Required", + "Pick", + "Omit", + "PropertyKey", + "NonNullable", +]); +const TRANSPARENT_WRAPPERS = new Set(["Readonly", "Partial", "Required", "NonNullable"]); +function declaredStatement(statement) { + return statement.type === "ExportNamedDeclaration" || + statement.type === "ExportDefaultDeclaration" + ? (statement.declaration ?? null) + : statement; +} +export function createTypeEnvironment(program) { + 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) { + return type.typeName.type === "Identifier" ? type.typeName.name : null; +} +function isBuiltIn(name, environment) { + return BUILT_INS.has(name) && !environment.shadowedBuiltIns.has(name); +} +function isUnappliedReferenceTo(type, name) { + 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) { + let current = type; + while (current.type === "TSParenthesizedType" || + (current.type === "TSTypeOperator" && current.operator === "readonly")) { + current = current.typeAnnotation; + } + return current; +} +function isNeverType(type) { + return unwrapTransparentType(type).type === "TSNeverKeyword"; +} +function isEffectivelyEmptyMember(member) { + return (member.type === "TSPropertySignature" && + member.optional === true && + member.typeAnnotation !== null && + member.typeAnnotation !== undefined && + isNeverType(member.typeAnnotation.typeAnnotation)); +} +function isEffectivelyEmptyTypeLiteral(type) { + return type.members.length === 0 || type.members.every(isEffectivelyEmptyMember); +} +function isEffectivelyEmptyInterface(declarations) { + 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, base, resolving = new Set()) { + 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, type, base) { + 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, environment, substitutions, resolvingAliases) { + 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, environment, substitutions, resolvingAliases) { + const unwrapped = unwrapTransparentType(type); + if (unwrapped.type === "TSTypeLiteral") { + return unwrapped.members.flatMap((member) => 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, environment) { + const unsafeValue = unsafeDirectValue(valueType, environment, new Map(), new Set()); + return unsafeValue === null ? null : { kind: "unsafe-dictionary", unsafeValue }; +} +export function classifyUnsafeDictionary(type, environment) { + 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, environment, substitutions, resolvingAliases) { + return dictionaryValueTypes(type, environment, substitutions, resolvingAliases).length > 0; +} +export function classifyWideningTarget(type, environment) { + 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 isBroadMappedKey(type, environment, substitutions) { + const unwrapped = unwrapTransparentType(type); + if (unwrapped.type === "TSStringKeyword" || + unwrapped.type === "TSNumberKeyword" || + unwrapped.type === "TSSymbolKeyword") { + return true; + } + if (unwrapped.type === "TSUnionType") { + return unwrapped.types.every((member) => isBroadMappedKey(member, environment, substitutions)); + } + if (unwrapped.type !== "TSTypeReference") + return false; + const name = typeReferenceName(unwrapped); + if (name === null) + return false; + const substitution = substitutions.get(name); + if (substitution !== undefined && !isUnappliedReferenceTo(substitution, name)) { + return isBroadMappedKey(substitution, environment, substitutions); + } + return name === "PropertyKey" && isBuiltIn(name, environment); +} +function classifyAliasBroadTarget(type, environment, substitutions, resolvingAliases) { + 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" } + : null; + } + if (unwrapped.type === "TSMappedType") { + return isBroadMappedKey(unwrapped.constraint, environment, substitutions) + ? { kind: "open dictionary" } + : null; + } + if (unwrapped.type !== "TSTypeReference") + return null; + const name = typeReferenceName(unwrapped); + if (name === null) + return null; + const substitution = substitutions.get(name); + if (substitution !== undefined) { + return isUnappliedReferenceTo(substitution, name) + ? null + : classifyAliasBroadTarget(substitution, environment, substitutions, resolvingAliases); + } + if (TRANSPARENT_WRAPPERS.has(name) && isBuiltIn(name, 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" }; + } + 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) { + 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) { + 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/dist/anti-slop/src/shared/lexical-type-parameters.js b/dist/anti-slop/src/shared/lexical-type-parameters.js new file mode 100644 index 0000000..1fa69a5 --- /dev/null +++ b/dist/anti-slop/src/shared/lexical-type-parameters.js @@ -0,0 +1,47 @@ +function isNode(value) { + return (typeof value === "object" && + value !== null && + "type" in value && + typeof value.type === "string"); +} +function collectInferTypeParameterNames(node, visitorKeys, names) { + if (node.type === "TSInferType") + names.add(node.typeParameter.name.name); + const record = node; + for (const key of visitorKeys[node.type] ?? []) { + const value = record[key]; + if (isNode(value)) { + collectInferTypeParameterNames(value, visitorKeys, names); + continue; + } + if (!Array.isArray(value)) + continue; + for (const child of value) { + if (isNode(child)) + collectInferTypeParameterNames(child, visitorKeys, names); + } + } +} +/** Collect type binders that are in scope at a node and can shadow module aliases. */ +export function lexicalTypeParameterNames(node, visitorKeys) { + const names = new Set(); + let descendant = node; + let current = node; + while (current !== null && current.type !== "Program") { + if ("typeParameters" in current) { + for (const parameter of current.typeParameters?.params ?? []) { + names.add(parameter.name.name); + } + } + if (current.type === "TSMappedType" && + (descendant === current.nameType || descendant === current.typeAnnotation)) { + names.add(current.key.name); + } + if (current.type === "TSConditionalType" && descendant === current.trueType) { + collectInferTypeParameterNames(current.extendsType, visitorKeys, names); + } + descendant = current; + current = current.parent; + } + return names; +} diff --git a/dist/anti-slop/src/shared/reflect-method.js b/dist/anti-slop/src/shared/reflect-method.js new file mode 100644 index 0000000..50fa3e7 --- /dev/null +++ b/dist/anti-slop/src/shared/reflect-method.js @@ -0,0 +1,29 @@ +function resolveVariable(sourceCode, identifier) { + let scope = sourceCode.getScope(identifier); + while (scope !== null) { + const variable = scope.set.get(identifier.name); + if (variable !== undefined) + return variable; + scope = scope.upper; + } + return null; +} +function isGlobalReflect(sourceCode, expression) { + if (expression.type !== "Identifier" || expression.name !== "Reflect") + return false; + if (sourceCode.isGlobalReference(expression)) + return true; + const variable = resolveVariable(sourceCode, expression); + return variable === null || variable.defs.length === 0; +} +/** Reports whether a call target names one method on the global Reflect object. */ +export function isGlobalReflectMethodCall(sourceCode, callee, methodName) { + if (!("property" in callee) || !("object" in callee) || !("computed" in callee)) + return false; + if (!isGlobalReflect(sourceCode, callee.object)) + return false; + const property = callee.property; + return callee.computed + ? property.type === "Literal" && property.value === methodName + : property.type === "Identifier" && property.name === methodName; +} diff --git a/dist/cli/commands/clean.mjs b/dist/cli/commands/clean.mjs index 4b28ffa..9d4946b 100644 --- a/dist/cli/commands/clean.mjs +++ b/dist/cli/commands/clean.mjs @@ -18,6 +18,7 @@ import { isTracked, trackedPathPredicate } from "../lib/git-tracked.mjs"; import { removeCommitMsgBlock } from "../lib/husky/commit-msg-block.mjs"; import { removeGuardBlock } from "../lib/husky/husky-block.mjs"; import { resolveExistingAgentProviders, SUPPORTED_AGENT_PROVIDERS, } from "../lib/install/agent-assets/agent-providers.mjs"; +import { removeAntiSlopCapability } from "../lib/install/anti-slop/lifecycle.mjs"; import { pruneDevkitCacheGitignore } from "../lib/install/gitignore-cache.mjs"; import { removeHookRegistrations, removeHookScripts } from "../lib/install/install-hooks.mjs"; import { removeSearchCode } from "../lib/install/install-search-code.mjs"; @@ -310,6 +311,8 @@ function cleanPackage(cwd, cfg, dryRun) { // capability but a later config write failed (or an older config lost the component key). if (cfg.components?.oxc || existsSync(join(cwd, '.devkit', 'oxc', 'manifest.json'))) removeOxcCapability(cwd, dryRun); + if (cfg.components?.antiSlop || existsSync(join(cwd, '.devkit', 'anti-slop', 'manifest.json'))) + removeAntiSlopCapability(cwd, dryRun); // Regenerated gate caches: init adds these .gitignore lines on every package/standalone install // (the gate engine writes them regardless of components), so reverse them unconditionally. pruneDevkitCacheGitignore(cwd, dryRun); diff --git a/dist/cli/commands/doctor.mjs b/dist/cli/commands/doctor.mjs index 3f985df..bd979b5 100644 --- a/dist/cli/commands/doctor.mjs +++ b/dist/cli/commands/doctor.mjs @@ -19,6 +19,7 @@ import { checkCommitMsgHook, commitMsgGuards } from "../lib/husky/commit-msg-blo import { extractGuardBlock, QAVIS_ADVISORY_ID } from "../lib/husky/husky-block.mjs"; import { checkAdhdSkill } from "../lib/install/adhd-skill.mjs"; import { resolveExistingAgentProviders, SUPPORTED_AGENT_PROVIDERS, } from "../lib/install/agent-assets/agent-providers.mjs"; +import { checkAntiSlopCapability, syncAntiSlopCapability, } from "../lib/install/anti-slop/lifecycle.mjs"; import { selectedHookAssets } from "../lib/install/hook-registration-ledger/selection.mjs"; import { checkOxcCapability, syncOxcCapability } from "../lib/install/oxc/lifecycle.mjs"; import { cmpSemver, fetchLatestTag } from "./update.mjs"; @@ -119,6 +120,7 @@ function selectionFlags(sel) { ['adhd', '--adhd'], ['priorArtGate', '--prior-art-gate'], ['oxc', '--oxc'], + ['antiSlop', '--anti-slop'], ]) if (sel[id]) flags.push(flag); @@ -156,6 +158,8 @@ function applyFix(cwd, results, sel, stack, standalone) { ]); const needsOxcSync = Boolean(sel.oxc) && results.some((r) => OXC_CHECKS.has(r.name) && r.fixable && r.status !== 'OK'); + const needsAntiSlopSync = Boolean(sel.antiSlop) && + results.some((r) => r.name.startsWith('anti-slop') && r.fixable && r.status !== 'OK'); const needsInit = results.some((r) => r.fixable && r.status === 'MISSING' && !OXC_CHECKS.has(r.name) && @@ -186,8 +190,10 @@ function applyFix(cwd, results, sel, stack, standalone) { stdio: 'inherit', }); } - if (needsOxcSync) - syncOxcCapability(cwd); + if (needsAntiSlopSync) + syncAntiSlopCapability(cwd); + if (needsOxcSync && !needsAntiSlopSync) + syncOxcCapability(cwd, { antiSlop: sel.antiSlop === true }); const skills = results.find((r) => r.name === 'skills'); if (skills?.fixable && skills.status !== 'OK') { execFileSync(process.execPath, [join(packageDir(), 'cli', `index${SELF_EXT}`), 'sync-skills'], { @@ -287,6 +293,8 @@ async function collectResults(cwd, cfg, configResult) { results.push(checkSearchToolBins()); if (sel.oxc) results.push(...checkOxcCapability(cwd)); + if (sel.antiSlop) + results.push(...checkAntiSlopCapability(cwd)); if (surfaces.length) results.push(checkRegistrations(cwd, hooks.components, surfaces)); if (sel.guards?.includes('fanout') || sel.guards?.includes('size')) diff --git a/dist/cli/commands/init.mjs b/dist/cli/commands/init.mjs index 4f6180a..77498f7 100644 --- a/dist/cli/commands/init.mjs +++ b/dist/cli/commands/init.mjs @@ -31,7 +31,8 @@ import { installSelfHostHook, isDevkitRepo, selfHostSelection } from "../lib/hus import { ADHD_SKILL_DIR, syncAdhdSkill } from "../lib/install/adhd-skill.mjs"; import { installAgentSurfaces as syncSurfaces } from "../lib/install/agent-assets/agent-surfaces.mjs"; import { resolveAssetConflicts } from "../lib/install/agent-assets/asset-conflict-picker.mjs"; -import { parseFlags, selectionFromFlags } from "../lib/install/flags/init-flags.mjs"; +import * as antiSlopLifecycle from "../lib/install/anti-slop/lifecycle.mjs"; +import * as initFlags from "../lib/install/flags/init-flags.mjs"; import { reviewPlanFromFlags } from "../lib/install/flags/review-profile.mjs"; import { ensureDevkitCacheGitignore } from "../lib/install/gitignore-cache.mjs"; import { ensureFallowGitignore, installFallow, saveFallowBaselines, wireFallowHooks, } from "../lib/install/install-fallow.mjs"; @@ -77,7 +78,7 @@ const BIOME_SCRIPTS = ['lint', 'format']; const SCANROOTS_RE = /("scanRoots"\s*:\s*)\[[^\]]*\]/; // Which components are currently wired? Read the recorded set first (authoritative), then // fall back to on-disk detection for a pre-wizard repo with no `components` block. -function detectInstalled(cwd) { +export function detectInstalled(cwd) { const cfg = readJson(join(cwd, '.devkit', 'config.json')); const installed = new Set(); const recorded = cfg?.components; @@ -102,6 +103,8 @@ function detectInstalled(cwd) { installed.add('structure'); if (existsSync(join(cwd, '.devkit', 'oxc', 'manifest.json'))) installed.add('oxc'); + if (existsSync(join(cwd, '.devkit', 'anti-slop', 'manifest.json'))) + installed.add('antiSlop'); const { gitRoot } = detectGitRoot(cwd); if (existsSync(join(gitRoot, '.devkit', 'skills-manifest.json'))) installed.add('skills'); @@ -551,7 +554,6 @@ function removeStructure(cwd, prevConfig, dryRun) { console.log(` ${dryRun ? '[dry-run]' : '✓'} package.json: -${pkgRemoved.join(', -')}`); } } -// Reason: flat removal dispatch: one `if (remove.includes(id)) removeX()` per component, ordered so guards (line-level) precede husky (block-level); high branch COUNT mirrors the component list, each branch a single delegated call // fallow-ignore-next-line complexity function applyRemovals(cwd, remove, prevConfig, gitRoot, pkgRel, dryRun) { if (!remove.length) @@ -571,12 +573,13 @@ function applyRemovals(cwd, remove, prevConfig, gitRoot, pkgRel, dryRun) { if (remove.includes('agents')) removeAgents(gitRoot, dryRun); // Agent-hook scripts + registrations are exact-reconciled by installAgentSurfaces before this - // removal pass. Re-removing them here would also delete a decisions-owned hook that survives a - // general agentHooks deselection. + // Avoid deleting a decisions-owned hook that survives a general agentHooks deselection. if (remove.includes('structure')) removeStructure(cwd, prevConfig, dryRun); if (remove.includes('oxc')) oxcLifecycle.removeOxcCapability(cwd, dryRun); + if (remove.includes('antiSlop')) + antiSlopLifecycle.removeAntiSlopCapability(cwd, dryRun); if (remove.includes('husky')) removeHusky(gitRoot, pkgRel, dryRun); } @@ -614,6 +617,7 @@ function applyOverlay(cwd, plan, pkgRel, devkitRef) { searchSteering: false, // never wired in overlay (no resolvable bin without the package) fallow: fallowWired, oxc: false, + antiSlop: false, adhd: Boolean(selection.adhd), priorArtGate: Boolean(selection.priorArtGate), agentTargets: [...(selection.agentTargets ?? AGENT_TARGETS)], @@ -671,11 +675,7 @@ export async function applyInit(cwd, plan) { const isStructure = selection.structure && STRUCTURE_STACKS.has(stack) && (!standalone || CONFIG_DRIVEN_STRUCTURE.has(stack)); - // The stack-resolved structure-lint command, joined to the deterministic orchestrator via - // `--structure` (so a structure violation lands in the SAME aggregated report as the guards). - // Config-driven stacks run devkit's own `guard-structure` bin (no consumer eslint dep — the - // orchestrator resolves it as a sibling module); electron keeps its consumer-side `bunx eslint - // src`. Undefined when structure is off → no `--structure` arg emitted. + // Resolve the structure command once so hook generation and the recorded selection agree. const structureCmd = isStructure ? structureCmdFor(stack) : undefined; const devkitPkg = readJson(join(packageDir(), 'package.json')); const devkitRef = plan.devkitRef ?? (devkitPkg ? `v${devkitPkg.version}` : 'main'); @@ -789,8 +789,10 @@ export async function applyInit(cwd, plan) { console.log('8b. search-code (opt-in semantic search)'); installSearchCode(cwd, dryRun); } - if (selection.oxc && !selfHost) - oxcLifecycle.syncOxcCapability(cwd, { dryRun }); + if (selection.oxc && !selection.antiSlop && !selfHost) + oxcLifecycle.syncOxcCapability(cwd, { dryRun, antiSlop: false }); + if (selection.antiSlop && !selfHost) + antiSlopLifecycle.syncAntiSlopCapability(cwd, { dryRun }); // The vendored i-have-adhd skill, into devkit's own tree rather than the agent skills dirs — so it // no longer depends on the `skills` component. Called unconditionally: a false selection reclaims a // previously-installed copy, and syncSurfaces above has already reclaimed the `.claude/skills/` @@ -813,6 +815,7 @@ export async function applyInit(cwd, plan) { structure: isStructure, fallow: Boolean(selection.fallow), oxc: Boolean(selection.oxc && !selfHost), + antiSlop: Boolean(selection.antiSlop && !selfHost), searchCode: Boolean(selection.searchCode), lineGrowth: Boolean(selection.lineGrowth), // Always written, including `false` — an ABSENT key is what marks a repo as never-offered, so @@ -883,7 +886,7 @@ export const meta = { // Reason: flat CLI dispatch: resolves one `selection` via three converging paths (interactive wizard / --yes flags / non-TTY) then hands a fully-resolved plan to applyInit; the branches ARE the resolution-mode fork, each path linear with no shared nesting // fallow-ignore-next-line complexity export default async function run(args, cwd) { - const flags = parseFlags(args); + const flags = initFlags.parseFlags(args); const detectedStack = flags.stack ?? detectStack(cwd); // Mode: --overlay / --standalone seed it; the wizard asks (so the interactive flow exposes it). const detectedMode = flags.overlay ? 'overlay' : flags.standalone ? 'standalone' : 'package'; @@ -911,7 +914,6 @@ export default async function run(args, cwd) { await runStructureBaselines(cwd, stack, flags.dryRun); return 0; } - // Self-host is package-name detected and deterministic, bypassing wizard/flags to preserve its bespoke config. const selfHost = isDevkitRepo(cwd); if (selfHost) { mode = 'self-host'; @@ -930,14 +932,14 @@ export default async function run(args, cwd) { if (!result) return 0; // cancelled — nothing written ({ mode, stack, remove, review } = result); - // The wizard returns a complete selection after overlay constraints fill package-only fields. selection = result.selection; } else { - selection = selectionFromFlags(flags); + selection = initFlags.selectionFromFlags(flags); + selection = initFlags.recoverInterruptedCapabilitySelection(cwd, flags, selection); } - // Resolve overlay invariants before consumers validate or record them (Husky is always effective). oxcLifecycle.warnIfOxcUnavailable(mode, flags.oxc); + antiSlopLifecycle.warnIfAntiSlopUnavailable(mode, flags.antiSlop); if (mode === 'overlay') selection = applyOverlayConstraints(selection); if (!selfHost && !interactive) { @@ -984,6 +986,5 @@ export default async function run(args, cwd) { outro('Done — run `devkit doctor` to verify.'); return 0; } -// parseFlags/selectionFromFlags re-exported for existing test importers; they live in -// cli/lib/install/flags/init-flags.mts now. -export { detectInstalled, parseFlags, selectionFromFlags }; +// Re-export flag helpers for existing test importers; their implementation lives under install/flags. +export { parseFlags, selectionFromFlags } from "../lib/install/flags/init-flags.mjs"; diff --git a/dist/cli/commands/oxc/anti-slop.mjs b/dist/cli/commands/oxc/anti-slop.mjs new file mode 100644 index 0000000..57ef175 --- /dev/null +++ b/dist/cli/commands/oxc/anti-slop.mjs @@ -0,0 +1,173 @@ +/** `devkit anti-slop` — explicit, deterministic shrink-only baseline operations. */ +import { existsSync } from 'node:fs'; +import { join } from 'node:path'; +import { withLock } from "../../lib/atomic-write.mjs"; +import { baselineFromGroups, compareBaseline, pruneBaseline, readBaseline, writeBaseline, } from "../../lib/install/anti-slop/baseline.mjs"; +import { ANTI_SLOP_BASELINE_LOCK_REL, ANTI_SLOP_BASELINE_REL, } from "../../lib/install/anti-slop/constants.mjs"; +import { collectAntiSlopGroups, resolveAntiSlopScope, } from "../../lib/install/anti-slop/runner.mjs"; +export const meta = { + name: 'anti-slop', + summary: 'Check vendored anti-slop rules with an explicit shrink-only baseline.', + help: `devkit anti-slop — baseline-aware checks for Devkit's vendored Oxlint plugin. + +Usage: + devkit anti-slop create [--force] [paths...] Explicitly snapshot current findings + devkit anti-slop check [paths...] Fail on new error-severity findings (read-only) + devkit anti-slop inspect [--json] Inspect baseline debt without linting + devkit anti-slop prune [paths...] Remove fixed debt; never add findings + +Configure per-rule off/warn/error and scoped overrides in the repository Oxlint config. Paths default +to the repository root. Check and inspect never write. Create refuses an existing baseline unless +--force is explicit; prune refuses to write while new error-severity findings exist.`, +}; +function baselineOrExplain(cwd) { + const baseline = readBaseline(cwd); + if (!baseline) { + console.error(`anti-slop: ${ANTI_SLOP_BASELINE_REL} is missing; run \`devkit anti-slop create\` explicitly`); + } + return baseline; +} +function count(groups) { + return groups.reduce((sum, group) => sum + group.count, 0); +} +function capabilityReady(cwd) { + if (existsSync(join(cwd, '.devkit', 'anti-slop', 'manifest.json'))) + return true; + console.error('anti-slop: not installed — run `devkit init --anti-slop`'); + return false; +} +function printNew(groups) { + for (const group of groups) { + const tag = group.severity === 'error' ? 'ERROR' : 'WARN'; + console.log(`${tag} ${group.ruleId} ${group.file}:${group.line}:${group.column} (+${group.additionalCount})`); + console.log(` ${group.diagnostic}`); + } +} +function create(cwd, args, force) { + if (!capabilityReady(cwd)) + return 2; + return withLock(join(cwd, ANTI_SLOP_BASELINE_LOCK_REL), () => { + const path = join(cwd, ANTI_SLOP_BASELINE_REL); + if (existsSync(path) && !force) { + console.error(`anti-slop: ${ANTI_SLOP_BASELINE_REL} already exists; use prune, or --force to replace it explicitly`); + return 2; + } + const existing = existsSync(path) && args.length > 0 ? readBaseline(cwd) : null; + const groups = collectAntiSlopGroups(cwd, args); + const next = baselineFromGroups(groups); + if (existing) { + const scope = resolveAntiSlopScope(cwd, args); + next.entries = [ + ...existing.entries.filter((entry) => !scope.includes(entry.file)), + ...next.entries, + ].sort((a, b) => a.fingerprint.localeCompare(b.fingerprint)); + } + writeBaseline(cwd, next); + console.log(`anti-slop: created ${ANTI_SLOP_BASELINE_REL} with ${count(next.entries)} finding(s) in ${next.entries.length} fingerprint(s)`); + return 0; + }); +} +function check(cwd, args) { + const baseline = baselineOrExplain(cwd); + if (!baseline) + return 2; + const scope = resolveAntiSlopScope(cwd, args); + const selected = { + ...baseline, + entries: baseline.entries.filter((entry) => scope.includes(entry.file)), + }; + const comparison = compareBaseline(selected, collectAntiSlopGroups(cwd, args)); + printNew(comparison.newGroups); + const errors = comparison.newGroups.filter((group) => group.severity === 'error'); + const warnings = comparison.newGroups.filter((group) => group.severity === 'warning'); + if (errors.length > 0) { + console.error(`anti-slop: FAIL — ${errors.reduce((sum, group) => sum + group.additionalCount, 0)} new error finding(s); baseline unchanged`); + return 1; + } + console.log(`anti-slop: PASS — ${comparison.currentCount} current finding(s), ${comparison.resolvedCount} ready to prune${warnings.length ? `, ${warnings.length} warning fingerprint(s)` : ''}`); + return 0; +} +function inspect(cwd, json) { + const baseline = baselineOrExplain(cwd); + if (!baseline) + return 2; + if (json) { + console.log(JSON.stringify(baseline, null, 2)); + return 0; + } + const perRule = new Map(); + for (const entry of baseline.entries) + perRule.set(entry.ruleId, (perRule.get(entry.ruleId) ?? 0) + entry.count); + console.log(`anti-slop baseline: ${baseline.entries.reduce((sum, entry) => sum + entry.count, 0)} finding(s), ${baseline.entries.length} fingerprint(s)`); + for (const [rule, findings] of [...perRule].sort(([a], [b]) => a.localeCompare(b))) { + console.log(` ${String(findings).padStart(5)} ${rule}`); + } + return 0; +} +function prune(cwd, args) { + if (!capabilityReady(cwd)) + return 2; + return withLock(join(cwd, ANTI_SLOP_BASELINE_LOCK_REL), () => { + const baseline = baselineOrExplain(cwd); + if (!baseline) + return 2; + const scope = resolveAntiSlopScope(cwd, args); + const groups = collectAntiSlopGroups(cwd, args); + const selected = { + ...baseline, + entries: baseline.entries.filter((entry) => scope.includes(entry.file)), + }; + const comparison = compareBaseline(selected, groups); + printNew(comparison.newGroups); + if (comparison.newGroups.some((group) => group.severity === 'error')) { + console.error('anti-slop: prune refused — new error finding(s) exist; baseline unchanged'); + return 1; + } + const pruned = pruneBaseline(selected, groups); + const next = { + ...baseline, + entries: [ + ...baseline.entries.filter((entry) => !scope.includes(entry.file)), + ...pruned.entries, + ].sort((a, b) => a.fingerprint.localeCompare(b.fingerprint)), + }; + writeBaseline(cwd, next); + console.log(`anti-slop: pruned ${comparison.resolvedCount} fixed finding(s); ${next.entries.reduce((sum, entry) => sum + entry.count, 0)} remain`); + return 0; + }); +} +export default function run(args, cwd) { + const [operation, ...rest] = args; + const separator = rest.indexOf('--'); + const options = separator >= 0 ? rest.slice(0, separator) : rest; + const trailingPaths = separator >= 0 ? rest.slice(separator + 1) : []; + const force = options.includes('--force'); + const json = options.includes('--json'); + const paths = [ + ...options.filter((arg) => arg !== '--force' && arg !== '--json'), + ...(separator >= 0 ? ['--', ...trailingPaths] : []), + ]; + if (force && operation !== 'create') { + console.error('anti-slop: --force is accepted only by create'); + return 2; + } + if (json && operation !== 'inspect') { + console.error('anti-slop: --json is accepted only by inspect'); + return 2; + } + if (operation === 'create') + return create(cwd, paths, force); + if (operation === 'check') + return check(cwd, paths); + if (operation === 'inspect') { + if (paths.length > 0 || force) { + console.error('anti-slop inspect accepts only --json'); + return 2; + } + return inspect(cwd, json); + } + if (operation === 'prune') + return prune(cwd, paths); + console.error('devkit anti-slop: expected create, check, inspect, or prune'); + return 2; +} diff --git a/dist/cli/commands/upgrade.mjs b/dist/cli/commands/upgrade.mjs index 1703d34..efd4dfe 100644 --- a/dist/cli/commands/upgrade.mjs +++ b/dist/cli/commands/upgrade.mjs @@ -180,7 +180,7 @@ export default async function upgrade(args, cwd) { // `undecided` pass-through: applyOverlay writes its own components block, so without it an // overlay upgrade records a decline nobody made and the offer never fires again. const undecidedOverlay = await offerOptionalComponents(cfg.components, sel, dryRun, { - unavailable: ['oxc'], + unavailable: ['oxc', 'antiSlop'], }); await applyInit(cwd, { stack, diff --git a/dist/cli/index.mjs b/dist/cli/index.mjs index 86d0734..d3ba6f9 100644 --- a/dist/cli/index.mjs +++ b/dist/cli/index.mjs @@ -38,6 +38,7 @@ const COMMANDS = { upgrade: () => import("./commands/upgrade.mjs"), move: () => import("./commands/move.mjs"), oxc: () => import("./commands/oxc/oxc.mjs"), + 'anti-slop': () => import("./commands/oxc/anti-slop.mjs"), reconcile: () => import("./commands/reconcile.mjs"), ship: () => import("./commands/ship.mjs"), review: () => import("./commands/review.mjs"), diff --git a/dist/cli/lib/components.mjs b/dist/cli/lib/components.mjs index 1549526..04ad0e3 100644 --- a/dist/cli/lib/components.mjs +++ b/dist/cli/lib/components.mjs @@ -144,6 +144,7 @@ export const RECORDED_COMPONENT_IDS = [ 'adhd', 'priorArtGate', 'oxc', + 'antiSlop', ]; /** * The all-recommended selection: every component on, every guard on. This is the EXACT @@ -166,6 +167,8 @@ export function defaultSelection() { fallow: false, // Toolchain migration is incremental: capability arrives only when explicitly selected. oxc: false, + // Policy-heavy rules and their debt baseline must never arrive without an explicit choice. + antiSlop: false, searchCode: false, // Recommended-on: a fresh repo has no giants (or they're grandfathered by init's freeze), so the // cap is pure upside. Deselectable in the wizard / via --no-line-growth. @@ -197,13 +200,14 @@ export function applyOverlayConstraints(sel) { searchSteering: false, searchCode: false, oxc: false, + antiSlop: false, husky: true, }; } /** Normalise a (possibly partial) selection to a full one — missing keys take recommended defaults. */ export function normalizeSelection(partial = {}) { const base = defaultSelection(); - return { + const normalized = { ...base, ...partial, agentTargets: Array.isArray(partial.agentTargets) @@ -211,6 +215,11 @@ export function normalizeSelection(partial = {}) { : base.agentTargets, guards: partial.guards ? partial.guards.filter((g) => GUARD_IDS.includes(g)) : base.guards, }; + // The plugin is executed by the pinned Oxc capability; an impossible anti-slop-without-Oxc + // recording self-heals to the only runnable selection. + if (normalized.antiSlop) + normalized.oxc = true; + return normalized; } /** * Bundled gates absent from a RECORDED selection, split by recommend-status. `normalizeSelection` @@ -295,6 +304,14 @@ export const OPTIONAL_COMPONENTS = [ flag: '--oxc', since: '0.52.0', }, + { + id: 'antiSlop', + kind: 'tool', + label: 'anti-slop', + hint: '15 vendored Oxlint rules + explicit shrink-only baseline (includes Oxc)', + flag: '--anti-slop', + since: '0.52.0', + }, ]; /** * The optional components this repo has never been ASKED about — an ABSENT recorded key, not a diff --git a/dist/cli/lib/help/init-help.mjs b/dist/cli/lib/help/init-help.mjs index 3d8edca..88a4200 100644 --- a/dist/cli/lib/help/init-help.mjs +++ b/dist/cli/lib/help/init-help.mjs @@ -10,7 +10,7 @@ Usage: --force Overwrite existing devkit-managed files, AND adopt/overwrite a consumer's own same-named skill/agent/hook collisions (default: preserve them). --no- Skip a component: --no-biome --no-tsconfig --no-skills --no-husky - --no-structure --no-guards --no-fallow --no-adhd --no-oxc. + --no-structure --no-guards --no-fallow --no-adhd --no-oxc --no-anti-slop. --guards Only these guards (subset of size,fanout,dup,clone,decisions, qavis-advisory,review,sentry; review + sentry are opt-in, off by default). --review Enable \`devkit review\` with an explicit local gate profile. @@ -23,6 +23,8 @@ Usage: --fallow Also install the optional fallow code-health layer (off by default). --oxc Activate Devkit's pinned Oxlint/Oxfmt runtime and repository configs (off by default; package/standalone only). Use \`devkit oxc lint|fmt\`. + --anti-slop Install 15 vendored anti-slop rules and its explicit shrink-only baseline + workflow (implies --oxc; package/standalone only; baseline creation is manual). --search-code Opt this repo in to the semantic search index (off by default). --adhd Sync the i-have-adhd SKILL — an ADHD-friendly output style — and keep it ALWAYS ON via a SessionStart hook (off by default; diff --git a/dist/cli/lib/install/anti-slop/baseline.mjs b/dist/cli/lib/install/anti-slop/baseline.mjs new file mode 100644 index 0000000..3db54d7 --- /dev/null +++ b/dist/cli/lib/install/anti-slop/baseline.mjs @@ -0,0 +1,91 @@ +/** Deterministic, explicit, shrink-only anti-slop baseline model. */ +import { createHash } from 'node:crypto'; +import { existsSync, readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { writeFileAtomic } from "../../atomic-write.mjs"; +import { ANTI_SLOP_BASELINE_REL, ANTI_SLOP_UPSTREAM } from "./constants.mjs"; +function expectedFingerprint(entry) { + return createHash('sha256') + .update(JSON.stringify([entry.ruleId, entry.file, entry.diagnostic, entry.context])) + .digest('hex'); +} +function validateEntry(value) { + if (!value || typeof value !== 'object') + return false; + const entry = value; + if (typeof entry.fingerprint !== 'string' || + typeof entry.ruleId !== 'string' || + typeof entry.file !== 'string' || + typeof entry.diagnostic !== 'string' || + typeof entry.context !== 'string' || + !Number.isSafeInteger(entry.count) || + (entry.count ?? 0) < 1) { + return false; + } + return (expectedFingerprint({ + ruleId: entry.ruleId, + file: entry.file, + diagnostic: entry.diagnostic, + context: entry.context, + }) === entry.fingerprint); +} +export function baselineFromGroups(groups) { + return { + schemaVersion: 1, + upstreamCommit: ANTI_SLOP_UPSTREAM, + entries: [...groups] + .sort((a, b) => a.fingerprint.localeCompare(b.fingerprint)) + .map(({ severity: _severity, line: _line, column: _column, ...entry }) => entry), + }; +} +export function readBaseline(cwd) { + const path = join(cwd, ANTI_SLOP_BASELINE_REL); + if (!existsSync(path)) + return null; + let value; + try { + value = JSON.parse(readFileSync(path, 'utf8')); + } + catch (error) { + throw new Error(`invalid ${ANTI_SLOP_BASELINE_REL}: ${error instanceof Error ? error.message : String(error)}`); + } + const baseline = value; + if (baseline.schemaVersion !== 1 || + baseline.upstreamCommit !== ANTI_SLOP_UPSTREAM || + !Array.isArray(baseline.entries) || + !baseline.entries.every(validateEntry)) { + throw new Error(`invalid or stale ${ANTI_SLOP_BASELINE_REL}; inspect it, then explicitly recreate it`); + } + const sorted = [...baseline.entries].sort((a, b) => a.fingerprint.localeCompare(b.fingerprint)); + if (new Set(sorted.map((entry) => entry.fingerprint)).size !== sorted.length) { + throw new Error(`invalid ${ANTI_SLOP_BASELINE_REL}: duplicate fingerprints`); + } + return { schemaVersion: 1, upstreamCommit: baseline.upstreamCommit, entries: sorted }; +} +export function writeBaseline(cwd, baseline) { + writeFileAtomic(join(cwd, ANTI_SLOP_BASELINE_REL), `${JSON.stringify(baseline, null, 2)}\n`); +} +export function compareBaseline(baseline, groups) { + const allowed = new Map(baseline.entries.map((entry) => [entry.fingerprint, entry.count])); + const current = new Map(groups.map((group) => [group.fingerprint, group.count])); + return { + newGroups: groups.flatMap((group) => { + const additionalCount = Math.max(0, group.count - (allowed.get(group.fingerprint) ?? 0)); + return additionalCount > 0 ? [{ ...group, additionalCount }] : []; + }), + currentCount: groups.reduce((sum, group) => sum + group.count, 0), + debtCount: baseline.entries.reduce((sum, entry) => sum + entry.count, 0), + resolvedCount: baseline.entries.reduce((sum, entry) => sum + Math.max(0, entry.count - (current.get(entry.fingerprint) ?? 0)), 0), + }; +} +/** Return only still-present baseline debt; never add an unbaselined current finding. */ +export function pruneBaseline(baseline, groups) { + const current = new Map(groups.map((group) => [group.fingerprint, group.count])); + return { + ...baseline, + entries: baseline.entries.flatMap((entry) => { + const count = Math.min(entry.count, current.get(entry.fingerprint) ?? 0); + return count > 0 ? [{ ...entry, count }] : []; + }), + }; +} diff --git a/dist/cli/lib/install/anti-slop/constants.mjs b/dist/cli/lib/install/anti-slop/constants.mjs new file mode 100644 index 0000000..46025cd --- /dev/null +++ b/dist/cli/lib/install/anti-slop/constants.mjs @@ -0,0 +1,55 @@ +/** Pinned upstream identity and the complete Devkit-managed anti-slop rule surface. */ +export const ANTI_SLOP_UPSTREAM = '446268e5d15baa968eaec669ff65358d36ae6259'; +export const ANTI_SLOP_PLUGIN_API_VERSION = '1.78.0'; +export const ANTI_SLOP_MANAGED_REL = '.devkit/anti-slop'; +export const ANTI_SLOP_MANIFEST_REL = `${ANTI_SLOP_MANAGED_REL}/manifest.json`; +export const ANTI_SLOP_CONFIG_REL = `${ANTI_SLOP_MANAGED_REL}/oxlint.json`; +export const ANTI_SLOP_BASELINE_REL = '.anti-slop-baseline.json'; +export const ANTI_SLOP_LOCK_REL = '.devkit/anti-slop.lock'; +export const ANTI_SLOP_BASELINE_LOCK_REL = '.devkit/anti-slop-baseline.lock'; +export const ANTI_SLOP_RULE_NAMES = [ + 'no-chained-type-assertions', + 'no-conditional-empty-object-spread', + 'no-known-value-widening', + 'no-module-mocking', + 'no-object-parameters', + 'no-reflect-apply', + 'no-reflect-get', + 'no-runtime-typeof', + 'no-shape-in-symbol-names', + 'no-unknown-parameters', + 'no-unknown-returns', + 'no-unknown-type-aliases', + 'no-unsafe-dictionary-type', + 'no-widen-then-assert', + 'require-safety-comment-for-type-assertion', +]; +export const ANTI_SLOP_RULE_IDS = ANTI_SLOP_RULE_NAMES.map((name) => `anti-slop/${name}`); +export const ANTI_SLOP_IGNORE_PATTERNS = [ + '.agent/**', + '.agents/**', + '.claude/**', + '.codex/**', + '.continue/**', + '.cursor/**', + '.devkit/anti-slop/**', + '.gemini/**', + '.opencode/**', + '.pi/**', + '.roo/**', + '.windsurf/**', +]; +const ANTI_SLOP_CONFIG_DISABLE_PATTERNS = ANTI_SLOP_IGNORE_PATTERNS.flatMap((pattern) => pattern === '.devkit/anti-slop/**' ? ['.devkit/anti-slop/plugin/**'] : [pattern]); +/** Render the config fragment inherited by Devkit's managed Oxlint base. */ +export function renderAntiSlopConfig(pluginEntry) { + return `${JSON.stringify({ + jsPlugins: [{ name: 'anti-slop', specifier: pluginEntry }], + overrides: [ + { + files: ANTI_SLOP_CONFIG_DISABLE_PATTERNS, + rules: Object.fromEntries(ANTI_SLOP_RULE_IDS.map((id) => [id, 'off'])), + }, + ], + rules: Object.fromEntries(ANTI_SLOP_RULE_IDS.map((id) => [id, 'error'])), + }, null, 2)}\n`; +} diff --git a/dist/cli/lib/install/anti-slop/diagnostics.mjs b/dist/cli/lib/install/anti-slop/diagnostics.mjs new file mode 100644 index 0000000..589d71d --- /dev/null +++ b/dist/cli/lib/install/anti-slop/diagnostics.mjs @@ -0,0 +1,79 @@ +/** Normalize Oxlint JSON diagnostics into checkout-independent anti-slop findings. */ +import { createHash } from 'node:crypto'; +import { readFileSync } from 'node:fs'; +import { isAbsolute, relative, resolve, sep } from 'node:path'; +const RULE_CODE = /^anti-slop\(([^)]+)\)$/u; +const normalizeText = (value) => value.trim().replace(/\s+/gu, ' '); +function ruleId(code) { + if (typeof code !== 'string') + return null; + const match = RULE_CODE.exec(code); + return match?.[1] ? `anti-slop/${match[1]}` : null; +} +function repositoryFile(cwd, filename) { + if (typeof filename !== 'string' || !filename) + throw new Error('diagnostic has no filename'); + const absolute = resolve(cwd, filename); + const rel = relative(cwd, absolute); + if (!rel || rel === '..' || rel.startsWith(`..${sep}`) || isAbsolute(rel)) { + throw new Error(`diagnostic path escapes repository: ${filename}`); + } + return { absolute, relative: rel.split(sep).join('/') }; +} +function sourceContext(path, line) { + const lines = readFileSync(path, 'utf8').replace(/\r\n?/gu, '\n').split('\n'); + return normalizeText(lines[Math.max(0, line - 1)] ?? ''); +} +function fingerprintFor(parts) { + return createHash('sha256') + .update(JSON.stringify([parts.ruleId, parts.file, parts.diagnostic, parts.context])) + .digest('hex'); +} +/** Parse only namespaced anti-slop diagnostics; other Oxlint rules remain outside this gate. */ +export function parseAntiSlopFindings(cwd, json) { + let payload; + try { + payload = JSON.parse(json); + } + catch (error) { + throw new Error(`Oxlint did not return JSON: ${error instanceof Error ? error.message : String(error)}`); + } + if (!Array.isArray(payload.diagnostics)) + throw new Error('Oxlint JSON has no diagnostics array'); + const findings = []; + for (const diagnostic of payload.diagnostics) { + const id = ruleId(diagnostic.code); + if (!id) + continue; + const location = repositoryFile(cwd, diagnostic.filename); + const span = diagnostic.labels?.[0]?.span; + const line = typeof span?.line === 'number' && span.line > 0 ? span.line : 1; + const column = typeof span?.column === 'number' && span.column > 0 ? span.column : 1; + const message = typeof diagnostic.message === 'string' ? normalizeText(diagnostic.message) : 'diagnostic'; + const context = sourceContext(location.absolute, line); + const stable = { ruleId: id, file: location.relative, diagnostic: message, context }; + findings.push({ + ...stable, + fingerprint: fingerprintFor(stable), + severity: diagnostic.severity === 'warning' ? 'warning' : 'error', + line, + column, + }); + } + return findings.sort((a, b) => a.fingerprint.localeCompare(b.fingerprint) || a.line - b.line || a.column - b.column); +} +/** Group indistinguishable repeated diagnostics so an added copy still exceeds baseline debt. */ +export function groupFindings(findings) { + const groups = new Map(); + for (const finding of findings) { + const current = groups.get(finding.fingerprint); + if (!current) { + groups.set(finding.fingerprint, { ...finding, count: 1 }); + continue; + } + current.count += 1; + if (finding.severity === 'error') + current.severity = 'error'; + } + return [...groups.values()].sort((a, b) => a.fingerprint.localeCompare(b.fingerprint)); +} diff --git a/dist/cli/lib/install/anti-slop/lifecycle.mjs b/dist/cli/lib/install/anti-slop/lifecycle.mjs new file mode 100644 index 0000000..f883f1e --- /dev/null +++ b/dist/cli/lib/install/anti-slop/lifecycle.mjs @@ -0,0 +1,360 @@ +/** Install, verify, and remove Devkit's pinned self-contained anti-slop plugin. */ +import { spawnSync } from 'node:child_process'; +import { createHash } from 'node:crypto'; +import { cpSync, existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, } from 'node:fs'; +import { createRequire } from 'node:module'; +import { dirname, join, relative } from 'node:path'; +import { withLock, writeFileAtomic } from "../../atomic-write.mjs"; +import { check } from "../../doctor/check-result.mjs"; +import { packageDir } from "../../fs-helpers.mjs"; +import { assertOxcCapabilityReady, oxcBaseCapabilityIssue, syncOxcCapability, } from "../oxc/lifecycle.mjs"; +import { resolveOxcRuntime } from "../oxc/runtime.mjs"; +import { ANTI_SLOP_CONFIG_REL, ANTI_SLOP_LOCK_REL, ANTI_SLOP_MANAGED_REL, ANTI_SLOP_MANIFEST_REL, ANTI_SLOP_PLUGIN_API_VERSION, ANTI_SLOP_RULE_IDS, ANTI_SLOP_UPSTREAM, renderAntiSlopConfig, } from "./constants.mjs"; +const PROBE_REL = `${ANTI_SLOP_MANAGED_REL}/probe.ts`; +const PROBE_RULE = 'anti-slop/no-object-parameters'; +const PROBE_RULE_CODE = 'anti-slop(no-object-parameters)'; +const BASE_PROBE_CODE = 'eslint(no-undef)'; +const BASE_PROBE_GLOBAL = '__DEVKIT_OXC_BASE_1_78_0_MANAGED_PROBE__'; +const PROBE_SOURCE = `function devkitManagedProbe(value: object) { void ${BASE_PROBE_GLOBAL}; return value; }\n`; +const PROBE_CONFIG_SOURCE = `${JSON.stringify({ extends: ['../oxc/oxlint.base.json'], rules: { [PROBE_RULE]: 'off' } }, null, 2)}\n`; +const PROBE_MAX_OUTPUT = 2 * 1024 * 1024; +const PLUGIN_MODULE = /\.(?:m?js|ts)$/u; +const digest = (content) => createHash('sha256').update(content).digest('hex'); +function treeDigest(root) { + const hash = createHash('sha256'); + const files = readdirSync(root, { recursive: true, withFileTypes: true }) + .filter((entry) => entry.isFile()) + .map((entry) => join(entry.parentPath, entry.name)) + .sort((a, b) => relative(root, a).localeCompare(relative(root, b))); + for (const file of files) { + hash.update(relative(root, file).split('\\').join('/')); + hash.update('\0'); + hash.update(readFileSync(file)); + hash.update('\0'); + } + return hash.digest('hex'); +} +function pluginSource() { + const root = join(packageDir(), 'anti-slop', 'src'); + if (existsSync(join(root, 'index.mjs'))) + return { root, entry: './plugin/index.mjs' }; + if (existsSync(join(root, 'index.js'))) + return { root, entry: './plugin/index.js' }; + if (existsSync(join(root, 'index.ts'))) + return { root, entry: './plugin/index.ts' }; + throw new Error('bundled anti-slop plugin entry is missing'); +} +function pluginApiSource() { + const entry = createRequire(import.meta.url).resolve('@oxlint/plugins'); + const manifest = JSON.parse(readFileSync(join(dirname(entry), 'package.json'), 'utf8')); + if (manifest.version !== ANTI_SLOP_PLUGIN_API_VERSION) { + throw new Error(`@oxlint/plugins ${manifest.version ?? 'unknown'} != pinned ${ANTI_SLOP_PLUGIN_API_VERSION}`); + } + return dirname(entry); +} +function makePluginApiTrackable(plugin, apiSource) { + const files = readdirSync(plugin, { recursive: true, withFileTypes: true }).filter((entry) => entry.isFile() && PLUGIN_MODULE.test(entry.name)); + for (const entry of files) { + const path = join(entry.parentPath, entry.name); + const source = readFileSync(path, 'utf8'); + const rewritten = source.replaceAll('@oxlint/plugins', '#oxlint-plugins'); + if (rewritten !== source) + writeFileAtomic(path, rewritten); + } + writeFileAtomic(join(plugin, 'package.json'), `${JSON.stringify({ + private: true, + type: 'module', + imports: { '#oxlint-plugins': './oxlint-plugins-api/index.js' }, + }, null, 2)}\n`); + cpSync(apiSource, join(plugin, 'oxlint-plugins-api'), { recursive: true }); +} +function readManifest(cwd) { + const path = join(cwd, ANTI_SLOP_MANIFEST_REL); + if (!existsSync(path)) + return null; + try { + const value = JSON.parse(readFileSync(path, 'utf8')); + return value.schemaVersion === 1 && + value.upstreamCommit === ANTI_SLOP_UPSTREAM && + value.pluginApiVersion === ANTI_SLOP_PLUGIN_API_VERSION && + Array.isArray(value.ruleIds) && + value.ruleIds.every((id) => typeof id === 'string') && + typeof value.pluginDigest === 'string' && + typeof value.configDigest === 'string' && + typeof value.probeDigest === 'string' && + typeof value.probeConfigDigest === 'string' + ? value + : null; + } + catch { + return null; + } +} +/** Explain why an explicit request cannot activate in a non-repository mode. */ +export function warnIfAntiSlopUnavailable(mode, requested) { + if (!requested || (mode !== 'overlay' && mode !== 'self-host')) + return; + console.warn(`devkit init --${mode}: --anti-slop is unavailable because it requires the tracked Oxc capability; skipping it.`); +} +function syncUnlocked(cwd, dryRun) { + const source = pluginSource(); + const apiSource = pluginApiSource(); + const config = renderAntiSlopConfig(source.entry); + if (dryRun) { + console.log(` [dry-run] sync ${ANTI_SLOP_MANAGED_REL}/ (15 rules; upstream ${ANTI_SLOP_UPSTREAM.slice(0, 12)})`); + return null; + } + const managed = join(cwd, ANTI_SLOP_MANAGED_REL); + const staging = `${managed}.staging-${process.pid}`; + const previous = `${managed}.previous`; + rmSync(staging, { recursive: true, force: true }); + if (!existsSync(managed) && existsSync(previous)) + renameSync(previous, managed); + else + rmSync(previous, { recursive: true, force: true }); + let movedPrevious = false; + try { + const plugin = join(staging, 'plugin'); + mkdirSync(plugin, { recursive: true }); + cpSync(source.root, plugin, { recursive: true }); + makePluginApiTrackable(plugin, apiSource); + cpSync(join(packageDir(), 'anti-slop', 'LICENSE'), join(staging, 'LICENSE')); + writeFileAtomic(join(staging, 'oxlint.json'), config); + writeFileAtomic(join(staging, 'probe.ts'), PROBE_SOURCE); + writeFileAtomic(join(staging, '.oxlintrc.json'), PROBE_CONFIG_SOURCE); + const manifest = { + schemaVersion: 1, + upstreamCommit: ANTI_SLOP_UPSTREAM, + pluginApiVersion: ANTI_SLOP_PLUGIN_API_VERSION, + ruleIds: [...ANTI_SLOP_RULE_IDS], + pluginDigest: treeDigest(plugin), + configDigest: digest(config), + probeDigest: digest(PROBE_SOURCE), + probeConfigDigest: digest(PROBE_CONFIG_SOURCE), + }; + writeFileAtomic(join(staging, 'manifest.json'), `${JSON.stringify(manifest, null, 2)}\n`); + if (existsSync(managed)) { + renameSync(managed, previous); + movedPrevious = true; + } + renameSync(staging, managed); + return { + manifest, + commit: () => rmSync(previous, { recursive: true, force: true }), + rollback: () => { + rmSync(managed, { recursive: true, force: true }); + if (movedPrevious && existsSync(previous)) + renameSync(previous, managed); + }, + }; + } + catch (error) { + rmSync(staging, { recursive: true, force: true }); + if (movedPrevious && !existsSync(managed) && existsSync(previous)) + renameSync(previous, managed); + throw error; + } +} +/** Install/upgrade the managed plugin without fetching or changing a consumer dependency stack. */ +export function syncAntiSlopCapability(cwd, { dryRun = false } = {}) { + if (dryRun) { + assertOxcCapabilityReady(cwd); + syncUnlocked(cwd, true); + syncOxcCapability(cwd, { dryRun: true, antiSlop: true }); + return; + } + mkdirSync(join(cwd, '.devkit'), { recursive: true }); + withLock(join(cwd, ANTI_SLOP_LOCK_REL), () => { + assertOxcCapabilityReady(cwd); + const replacement = syncUnlocked(cwd, false); + if (!replacement) + throw new Error('anti-slop managed replacement was not prepared'); + try { + syncOxcCapability(cwd, { antiSlop: true }); + replacement.commit(); + console.log(` ✓ anti-slop: ${replacement.manifest.ruleIds.length} rules @ ${replacement.manifest.upstreamCommit.slice(0, 12)}`); + } + catch (error) { + replacement.rollback(); + try { + syncOxcCapability(cwd, { + antiSlop: existsSync(join(cwd, ANTI_SLOP_MANIFEST_REL)) && + existsSync(join(cwd, ANTI_SLOP_CONFIG_REL)), + }); + } + catch { + // Preserve the original sync failure; doctor can repair any residual managed Oxc drift. + } + throw error; + } + }); +} +/** Serialize readers with managed-tree replacement; callers keep the lock through their Oxc run. */ +export function withAntiSlopCapabilityLock(cwd, action) { + return withLock(join(cwd, ANTI_SLOP_LOCK_REL), action); +} +function probeIntegration(cwd) { + let runtime; + try { + runtime = resolveOxcRuntime('lint'); + } + catch (error) { + return { ok: false, detail: error instanceof Error ? error.message : String(error) }; + } + const result = spawnSync(process.execPath, [ + runtime.binPath, + '--format', + 'json', + '--no-ignore', + '--disable-nested-config', + '--deny', + PROBE_RULE, + '--deny', + 'no-undef', + PROBE_REL, + ], { cwd, encoding: 'utf8', maxBuffer: PROBE_MAX_OUTPUT, timeout: 10_000 }); + if (result.status === null) { + return { + ok: false, + detail: result.error?.message ?? + (result.signal ? `probe terminated by ${result.signal}` : 'probe failed'), + }; + } + if (result.status !== 0 && result.status !== 1) { + return { + ok: false, + detail: `integration probe rejected the managed rule: ${result.stderr.trim().split('\n')[0] || `Oxlint exit ${result.status}`}`, + }; + } + try { + const payload = JSON.parse(result.stdout); + const codes = new Set(payload.diagnostics?.map((diagnostic) => diagnostic.code)); + if (codes.has(BASE_PROBE_CODE)) { + return { + ok: false, + detail: 'consumer config does not load the managed Oxlint base', + }; + } + if (!codes.has(PROBE_RULE_CODE)) { + return { + ok: false, + detail: `consumer config does not register the managed ${PROBE_RULE} rule`, + }; + } + return { + ok: true, + detail: `consumer config loads the managed base and registers ${PROBE_RULE}`, + }; + } + catch (error) { + return { + ok: false, + detail: `integration probe returned invalid JSON: ${error instanceof Error ? error.message : String(error)}`, + }; + } +} +function capabilityHealth(cwd) { + const manifest = readManifest(cwd); + if (!manifest) { + return { + manifest: null, + rulesComplete: false, + bytesOk: false, + baseIntegrated: false, + baseDetail: 'managed Oxc manifest is missing or invalid', + runtimeIntegrated: false, + runtimeDetail: 'managed manifest is missing or invalid', + }; + } + const plugin = join(cwd, ANTI_SLOP_MANAGED_REL, 'plugin'); + const config = join(cwd, ANTI_SLOP_CONFIG_REL); + const probe = join(cwd, PROBE_REL); + const probeConfig = join(cwd, ANTI_SLOP_MANAGED_REL, '.oxlintrc.json'); + const rulesComplete = manifest.ruleIds.length === ANTI_SLOP_RULE_IDS.length && + ANTI_SLOP_RULE_IDS.every((id) => manifest.ruleIds.includes(id)); + const bytesOk = existsSync(plugin) && + existsSync(config) && + existsSync(probe) && + existsSync(probeConfig) && + treeDigest(plugin) === manifest.pluginDigest && + digest(readFileSync(config)) === manifest.configDigest && + digest(readFileSync(probe)) === manifest.probeDigest && + digest(readFileSync(probeConfig)) === manifest.probeConfigDigest; + const baseIssue = oxcBaseCapabilityIssue(cwd); + const baseIntegrated = baseIssue === null; + const runtime = rulesComplete && bytesOk && baseIntegrated + ? probeIntegration(cwd) + : { + ok: false, + detail: !rulesComplete || !bytesOk + ? 'managed rule/plugin integrity failed before runtime probe' + : (baseIssue ?? 'managed Oxlint base integration failed'), + }; + return { + manifest, + rulesComplete, + bytesOk, + baseIntegrated, + baseDetail: baseIssue ?? 'managed Oxlint base is current', + runtimeIntegrated: runtime.ok, + runtimeDetail: runtime.detail, + }; +} +/** Return why baseline operations must fail closed, or null when the full runtime chain is proved. */ +export function antiSlopCapabilityIssue(cwd) { + const health = capabilityHealth(cwd); + if (!health.manifest) + return 'managed manifest is missing or invalid'; + if (!health.rulesComplete) + return 'managed rule registry is incomplete'; + if (!health.bytesOk) + return 'managed plugin/config/probe bytes changed'; + if (!health.baseIntegrated) + return health.baseDetail; + return health.runtimeIntegrated ? null : health.runtimeDetail; +} +/** Check provenance, all managed bytes, rule completeness, and Oxc config integration. */ +export function checkAntiSlopCapability(cwd) { + if (!existsSync(join(cwd, '.devkit'))) { + return [ + check('anti-slop manifest', 'MISSING', ANTI_SLOP_MANIFEST_REL, 'run `devkit doctor --fix`', true), + ]; + } + return withAntiSlopCapabilityLock(cwd, () => checkAntiSlopCapabilityUnlocked(cwd)); +} +function checkAntiSlopCapabilityUnlocked(cwd) { + const health = capabilityHealth(cwd); + const manifest = health.manifest; + if (!manifest) { + return [ + check('anti-slop manifest', 'MISSING', ANTI_SLOP_MANIFEST_REL, 'run `devkit doctor --fix`', true), + ]; + } + return [ + health.rulesComplete + ? check('anti-slop rules', 'OK', `${manifest.ruleIds.length} namespaced rules @ ${manifest.upstreamCommit.slice(0, 12)}`) + : check('anti-slop rules', 'DRIFT', 'managed rule registry is incomplete', 'run `devkit doctor --fix`', true), + health.bytesOk + ? check('anti-slop plugin', 'OK', `self-contained @oxlint/plugins@${manifest.pluginApiVersion}`) + : check('anti-slop plugin', 'DRIFT', 'managed plugin/config bytes changed', 'run `devkit doctor --fix`', true), + health.runtimeIntegrated + ? check('anti-slop Oxc integration', 'OK', health.runtimeDetail) + : check('anti-slop Oxc integration', 'DRIFT', health.runtimeDetail, 'add "./.devkit/oxc/oxlint.base.json" to the consumer config extends array'), + ]; +} +/** Remove only managed plugin bytes. The repository baseline is consumer debt data and is kept. */ +export function removeAntiSlopCapability(cwd, dryRun = false) { + const managed = join(cwd, ANTI_SLOP_MANAGED_REL); + if (!existsSync(managed)) + return; + if (dryRun) { + console.log(` [dry-run] remove ${ANTI_SLOP_MANAGED_REL}/ (keep baseline)`); + return; + } + withLock(join(cwd, ANTI_SLOP_LOCK_REL), () => { + if (existsSync(join(cwd, '.devkit', 'oxc'))) + syncOxcCapability(cwd, { antiSlop: false }); + rmSync(managed, { recursive: true, force: true }); + }); + console.log(` ✓ removed ${ANTI_SLOP_MANAGED_REL}/ (kept baseline)`); +} diff --git a/dist/cli/lib/install/anti-slop/runner.mjs b/dist/cli/lib/install/anti-slop/runner.mjs new file mode 100644 index 0000000..78c83c7 --- /dev/null +++ b/dist/cli/lib/install/anti-slop/runner.mjs @@ -0,0 +1,81 @@ +/** Execute pinned Oxlint and expose only normalized anti-slop findings to the baseline gate. */ +import { spawnSync } from 'node:child_process'; +import { existsSync, realpathSync, statSync } from 'node:fs'; +import { isAbsolute, relative, resolve, sep } from 'node:path'; +import { resolveOxcRuntime } from "../oxc/runtime.mjs"; +import { ANTI_SLOP_IGNORE_PATTERNS } from "./constants.mjs"; +import { groupFindings, parseAntiSlopFindings } from "./diagnostics.mjs"; +import { antiSlopCapabilityIssue, withAntiSlopCapabilityLock } from "./lifecycle.mjs"; +const MAX_OUTPUT = 64 * 1024 * 1024; +/** Resolve literal existing repository paths and expose their baseline-entry membership. */ +export function resolveAntiSlopScope(cwd, args) { + const separator = args.indexOf('--'); + const options = separator >= 0 ? args.slice(0, separator) : args; + const paths = args.filter((arg) => arg !== '--'); + const option = options.find((arg) => arg.startsWith('-')); + if (option) { + throw new Error(`anti-slop operations accept repository paths, not Oxlint option ${option}; configure rules in .oxlintrc.json`); + } + const requested = paths.length > 0 ? paths : ['.']; + const lintArguments = paths.length > 0 ? [...args] : ['.']; + const repository = realpathSync(cwd); + const scopes = requested.map((path) => { + const absolute = resolve(cwd, path); + let target; + try { + target = realpathSync(absolute); + } + catch { + throw new Error(`anti-slop path does not exist: ${path}`); + } + const targetRel = relative(repository, target); + if (targetRel === '..' || targetRel.startsWith(`..${sep}`) || isAbsolute(targetRel)) { + throw new Error(`anti-slop path escapes repository: ${path}`); + } + const lexical = relative(resolve(cwd), absolute); + if (lexical === '..' || lexical.startsWith(`..${sep}`) || isAbsolute(lexical)) { + throw new Error(`anti-slop path escapes repository: ${path}`); + } + return { file: lexical.split(sep).join('/'), directory: statSync(absolute).isDirectory() }; + }); + return { + paths: lintArguments, + includes(file) { + return scopes.some((scope) => scope.directory ? !scope.file || file.startsWith(`${scope.file}/`) : file === scope.file); + }, + }; +} +/** Run the installed capability under the repository's combined Oxlint config. */ +export function collectAntiSlopGroups(cwd, args) { + if (!existsSync(resolve(cwd, '.devkit'))) { + throw new Error('anti-slop is not installed — run `devkit init --anti-slop`'); + } + return withAntiSlopCapabilityLock(cwd, () => collectAntiSlopGroupsUnlocked(cwd, args)); +} +function collectAntiSlopGroupsUnlocked(cwd, args) { + const issue = antiSlopCapabilityIssue(cwd); + if (issue) { + throw new Error(`anti-slop capability is not fully integrated (${issue}); refusing an incomplete baseline`); + } + const scope = resolveAntiSlopScope(cwd, args); + const runtime = resolveOxcRuntime('lint'); + const result = spawnSync(process.execPath, [ + runtime.binPath, + '--format', + 'json', + '--no-error-on-unmatched-pattern', + '--disable-nested-config', + ...ANTI_SLOP_IGNORE_PATTERNS.flatMap((pattern) => ['--ignore-pattern', pattern]), + ...scope.paths, + ], { cwd, encoding: 'utf8', maxBuffer: MAX_OUTPUT }); + if (result.status === null) { + throw new Error(`Oxlint failed: ${result.error?.message ?? (result.signal ? `signal ${result.signal}` : 'unknown error')}`); + } + if (result.status !== 0 && result.status !== 1) { + throw new Error(`Oxlint exited ${result.status}: ${result.stderr.trim().split('\n')[0] || 'no detail'}`); + } + if (!result.stdout.trim()) { + throw new Error(`Oxlint returned no diagnostics JSON: ${result.stderr.trim().split('\n')[0] || 'no detail'}`); + } + return groupFindings(parseAntiSlopFindings(cwd, result.stdout)); +} diff --git a/dist/cli/lib/install/flags/init-flags.mjs b/dist/cli/lib/install/flags/init-flags.mjs index e16399a..e51d89c 100644 --- a/dist/cli/lib/install/flags/init-flags.mjs +++ b/dist/cli/lib/install/flags/init-flags.mjs @@ -3,7 +3,10 @@ * resolution for the --yes / non-TTY path. Extracted from cli/commands/init.mts (which retains * the apply layer); review-policy flags stay in review-profile.mts and compose in here. */ +import { existsSync } from 'node:fs'; +import { join } from 'node:path'; import { AGENT_TARGETS, defaultSelection, GUARD_IDS } from "../../components.mjs"; +import { readJson } from "../../fs-helpers.mjs"; import { parseReviewFlags } from "./review-profile.mjs"; export function parseFlags(args) { const flags = { @@ -14,6 +17,7 @@ export function parseFlags(args) { removeDeselected: false, fallow: false, oxc: false, + antiSlop: false, searchSteering: false, agentHooks: false, searchCode: false, @@ -41,6 +45,8 @@ export function parseFlags(args) { flags.fallow = true; else if (a === '--oxc') flags.oxc = true; + else if (a === '--anti-slop') + flags.antiSlop = true; else if (a === '--search-steering') flags.searchSteering = true; else if (a === '--agent-hooks') @@ -92,7 +98,11 @@ export function selectionFromFlags(flags) { sel.lineGrowth = false; // fallow + the agent-hook components are OPT-IN: off unless their flag is passed (and --no-* keeps off). sel.fallow = flags.fallow && !flags.no.has('fallow'); - sel.oxc = flags.oxc && !flags.no.has('oxc'); + sel.antiSlop = flags.antiSlop && !flags.no.has('anti-slop') && !flags.no.has('oxc'); + sel.oxc = (flags.oxc || sel.antiSlop) && !flags.no.has('oxc'); + if (flags.antiSlop && flags.no.has('oxc')) { + console.warn(' ! anti-slop skipped: --no-oxc disables its required runtime'); + } sel.searchSteering = flags.searchSteering && !flags.no.has('search-steering'); sel.agentHooks = flags.agentHooks && !flags.no.has('agent-hooks'); sel.searchCode = flags.searchCode && !flags.no.has('search-code'); @@ -110,3 +120,19 @@ export function selectionFromFlags(flags) { } return sel; } +/** Recover managed capabilities published before an interrupted init wrote its component record. */ +export function recoverInterruptedCapabilitySelection(cwd, flags, selection) { + const recorded = readJson(join(cwd, '.devkit', 'config.json')); + if (recorded?.components) + return selection; + if (existsSync(join(cwd, '.devkit', 'oxc', 'manifest.json')) && !flags.no.has('oxc')) { + selection.oxc = true; + } + if (existsSync(join(cwd, '.devkit', 'anti-slop', 'manifest.json')) && + !flags.no.has('anti-slop') && + !flags.no.has('oxc')) { + selection.antiSlop = true; + selection.oxc = true; + } + return selection; +} diff --git a/dist/cli/lib/install/oxc/lifecycle.mjs b/dist/cli/lib/install/oxc/lifecycle.mjs index c6beb03..1d05ce8 100644 --- a/dist/cli/lib/install/oxc/lifecycle.mjs +++ b/dist/cli/lib/install/oxc/lifecycle.mjs @@ -26,7 +26,14 @@ export function warnIfOxcUnavailable(mode, requested) { } const digest = (content) => createHash('sha256').update(content).digest('hex'); const fileDigest = (path) => digest(readFileSync(path)); -const baseSource = () => join(packageDir(), 'oxc', 'oxlint.base.json'); +function baseContent(antiSlop) { + const source = readFileSync(join(packageDir(), 'oxc', 'oxlint.base.json'), 'utf8'); + if (!antiSlop) + return source; + const parsed = JSON.parse(source); + parsed.extends = ['../anti-slop/oxlint.json']; + return `${JSON.stringify(parsed, null, 2)}\n`; +} function isOwnership(value) { if (!value || typeof value !== 'object') return false; @@ -46,7 +53,7 @@ function readManifest(cwd) { typeof value.baseDigest === 'string' && isOwnership(value.configs?.oxlint) && isOwnership(value.configs?.oxfmt) - ? value + ? { ...value, antiSlop: value.antiSlop === true } : null; } catch { @@ -64,6 +71,28 @@ function assertNoConfigCollisions(cwd) { } } } +/** Read-only preflight used before a dependent capability publishes managed state. */ +export function assertOxcCapabilityReady(cwd) { + const lint = probeOxcRuntime('lint'); + const fmt = probeOxcRuntime('fmt'); + if (!lint.ok || !fmt.ok || !lint.runtime || !fmt.runtime) { + throw new Error(`bundled Oxc runtime unavailable: ${lint.detail}; ${fmt.detail}`); + } + assertNoConfigCollisions(cwd); +} +/** Require the managed base bytes and recorded digest to match the current selected capabilities. */ +export function oxcBaseCapabilityIssue(cwd) { + const manifest = readManifest(cwd); + if (!manifest) + return 'managed Oxc manifest is missing or invalid'; + const expected = digest(baseContent(manifest.antiSlop)); + if (manifest.baseDigest !== expected) + return 'managed Oxlint base manifest digest is stale'; + const path = join(cwd, BASE_REL); + if (!existsSync(path) || fileDigest(path) !== expected) + return 'managed Oxlint base is missing or drifted'; + return null; +} function ownershipFor(cwd, names, starterPath, starter, previous, dryRun) { const found = candidates(cwd, names); if (found.length === 1) { @@ -74,7 +103,7 @@ function ownershipFor(cwd, names, starterPath, starter, previous, dryRun) { writeFileAtomic(join(cwd, starterPath), starter); return { path: starterPath, createdDigest: digest(starter) }; } -function syncOxcCapabilityUnlocked(cwd, dryRun) { +function syncOxcCapabilityUnlocked(cwd, dryRun, antiSlop) { const previous = readManifest(cwd); const lint = probeOxcRuntime('lint'); const fmt = probeOxcRuntime('fmt'); @@ -84,7 +113,7 @@ function syncOxcCapabilityUnlocked(cwd, dryRun) { // Validate both tools before creating either starter: a formatter collision must not leave a // half-installed linter config (and vice versa). assertNoConfigCollisions(cwd); - const base = readFileSync(baseSource(), 'utf8'); + const base = baseContent(antiSlop); if (!dryRun) { mkdirSync(join(cwd, '.devkit', 'oxc'), { recursive: true }); writeFileAtomic(join(cwd, BASE_REL), base); @@ -102,6 +131,7 @@ function syncOxcCapabilityUnlocked(cwd, dryRun) { const manifest = { schemaVersion: 1, pins: { oxlint: lint.runtime.expectedVersion, oxfmt: fmt.runtime.expectedVersion }, + antiSlop, baseDigest: digest(base), configs: { oxlint, oxfmt }, }; @@ -126,13 +156,13 @@ function syncOxcCapabilityUnlocked(cwd, dryRun) { } } /** Install or upgrade managed base/provenance while preserving every existing root config byte. */ -export function syncOxcCapability(cwd, { dryRun = false } = {}) { +export function syncOxcCapability(cwd, { dryRun = false, antiSlop = false } = {}) { if (dryRun) { - syncOxcCapabilityUnlocked(cwd, true); + syncOxcCapabilityUnlocked(cwd, true, antiSlop); return; } mkdirSync(join(cwd, '.devkit'), { recursive: true }); - withLock(join(cwd, LOCK_REL), () => syncOxcCapabilityUnlocked(cwd, false)); + withLock(join(cwd, LOCK_REL), () => syncOxcCapabilityUnlocked(cwd, false, antiSlop)); } function parseJsonConfig(cwd, ownership) { if (!ownership.path.endsWith('.json')) @@ -188,8 +218,7 @@ export function checkOxcCapability(cwd) { ? check('Oxc runtime', 'OK', `${lint.detail}; ${fmt.detail}`) : check('Oxc runtime', 'DRIFT', `${lint.detail}; ${fmt.detail}`, 'reinstall the pinned @norvalbv/devkit package with optional platform dependencies'); const basePath = join(cwd, BASE_REL); - const desiredBase = readFileSync(baseSource()); - const baseCurrent = existsSync(basePath) && fileDigest(basePath) === digest(desiredBase); + const baseCurrent = oxcBaseCapabilityIssue(cwd) === null; const base = baseCurrent ? check('Oxlint base', 'OK', BASE_REL) : check('Oxlint base', existsSync(basePath) ? 'DRIFT' : 'MISSING', BASE_REL, 'run `devkit doctor --fix`', true); diff --git a/dist/cli/lib/install/upgrade-offers.mjs b/dist/cli/lib/install/upgrade-offers.mjs index bdc776c..71c58f1 100644 --- a/dist/cli/lib/install/upgrade-offers.mjs +++ b/dist/cli/lib/install/upgrade-offers.mjs @@ -120,6 +120,8 @@ export async function offerOptionalComponents(recorded, sel, dryRun, { unavailab const chosen = new Set(picked); for (const c of unoffered) sel[c.id] = chosen.has(c.id); + if (sel.antiSlop) + sel.oxc = true; console.log(chosen.size ? ` ✓ added: ${[...chosen].join(', ')}` : ' • none selected (recorded — this will not be offered again)'); diff --git a/dist/cli/lib/wizard.mjs b/dist/cli/lib/wizard.mjs index 026fb5a..ce38e36 100644 --- a/dist/cli/lib/wizard.mjs +++ b/dist/cli/lib/wizard.mjs @@ -66,6 +66,11 @@ const OXC_OPTION = { label: 'Oxc toolchain', hint: 'pinned Oxlint/Oxfmt runtime + repository config (off by default)', }; +const ANTI_SLOP_OPTION = { + id: 'antiSlop', + label: 'anti-slop rules', + hint: '15 vendored Oxlint rules + explicit shrink-only baseline (includes Oxc)', +}; // prior-art gate: same opt-in shape as adhd, and kept out of COMPONENTS for the same reason — it // denies harness tool calls (deny-once per session), so it only ever arrives because someone ticked // this box. The id is the camelCase Selection key so installedOptional seeding matches on re-runs. @@ -157,7 +162,7 @@ export async function runWizard({ detectedStack, detectedMode = 'package', struc ], initialValues: [ ...choices.filter((c) => c.recommended).map((c) => c.id), - ...installedOptional.filter((id) => id !== 'oxc'), + ...installedOptional.filter((id) => id !== 'oxc' && id !== 'antiSlop'), ], required: false, }); @@ -184,6 +189,7 @@ export async function runWizard({ detectedStack, detectedMode = 'package', struc componentOption(ADHD_OPTION), componentOption(PRIOR_ART_GATE_OPTION), componentOption(OXC_OPTION), + componentOption(ANTI_SLOP_OPTION), ], initialValues: [ ...componentChoices.filter((c) => c.recommended).map((c) => c.id), @@ -200,7 +206,8 @@ export async function runWizard({ detectedStack, detectedMode = 'package', struc selection.searchCode = chosen.has('search-code'); selection.adhd = chosen.has('adhd'); selection.priorArtGate = chosen.has('priorArtGate'); - selection.oxc = chosen.has('oxc'); + selection.antiSlop = chosen.has('antiSlop'); + selection.oxc = chosen.has('oxc') || selection.antiSlop; if (!structAvail) selection.structure = false; } @@ -349,6 +356,7 @@ function summarize(mode, selection, structureAvailable, deselected) { lines.push(`${selection.adhd ? '✓' : '·'} ${ADHD_OPTION.label}`); lines.push(`${selection.priorArtGate ? '✓' : '·'} ${PRIOR_ART_GATE_OPTION.label}`); lines.push(`${selection.oxc ? '✓' : '·'} ${OXC_OPTION.label}`); + lines.push(`${selection.antiSlop ? '✓' : '·'} ${ANTI_SLOP_OPTION.label}`); lines.push(`${selection.lineGrowth ? '✓' : '·'} line-growth block`); if (AGENT_SURFACE_COMPONENTS.some((id) => selection[id])) { lines.push(` agent surface(s): ${(selection.agentTargets ?? AGENT_TARGETS).join(', ')}`); diff --git a/dist/oxc/oxlint.base.json b/dist/oxc/oxlint.base.json index 90d8894..2c64772 100644 --- a/dist/oxc/oxlint.base.json +++ b/dist/oxc/oxlint.base.json @@ -1,3 +1,11 @@ { + "overrides": [ + { + "files": [".devkit/anti-slop/probe.ts"], + "globals": { + "__DEVKIT_OXC_BASE_1_78_0_MANAGED_PROBE__": "readonly" + } + } + ], "rules": {} } diff --git a/dist/package.json b/dist/package.json index d4ac663..0bb1108 100644 --- a/dist/package.json +++ b/dist/package.json @@ -84,9 +84,10 @@ }, "dependencies": { "@clack/prompts": "^1.5.1", + "@oxlint/plugins": "1.78.0", + "es-module-lexer": "^2.1.0", "eslint": "^10.5.0", "eslint-plugin-project-structure": "^3.14.3", - "es-module-lexer": "^2.1.0", "mdast-util-from-markdown": "^2.0.3", "oxfmt": "0.63.0", "oxlint": "1.78.0", diff --git a/docs/anti-slop.md b/docs/anti-slop.md new file mode 100644 index 0000000..a8ee4f3 --- /dev/null +++ b/docs/anti-slop.md @@ -0,0 +1,135 @@ +# Anti-slop capability + +Devkit vendors the complete [dmmulroy/anti-slop](https://github.com/dmmulroy/anti-slop) +production ruleset at commit **446268e5d15baa968eaec669ff65358d36ae6259**. It is an opt-in +capability on top of Devkit's exact Oxlint 1.78.0 and @oxlint/plugins 1.78.0 pins: + +~~~bash +devkit init --anti-slop # implies --oxc; package and standalone modes +~~~ + +Install and upgrade never fetch anti-slop or add a plugin dependency to the consumer. Devkit copies +its reviewed plugin and the pinned plugin API into **.devkit/anti-slop/**, records provenance and +digests in **.devkit/anti-slop/manifest.json**, then composes the managed fragment through +**.devkit/oxc/oxlint.base.json**. The repository's existing Oxlint config therefore continues to +own native rules, other JS plugins, per-rule severity, and overrides. An inherited override disables +anti-slop diagnostics for Devkit/agent managed directories, so an ordinary composed `oxlint .` does +not lint the vendored plugin as consumer debt. The plugin API is stored under a capability-specific +**plugin/oxlint-plugins-api/** path (not commonly ignored `node_modules` or `vendor`), so a normal +`git add -A` and clone preserve the complete offline capability. + +## Incremental adoption + +Baseline creation is deliberately never implicit: + +~~~bash +devkit anti-slop create [paths...] # refuses an existing baseline +devkit anti-slop create --force [paths...] # explicit replacement +devkit anti-slop check [paths...] # read-only; CI/agent-loop gate +devkit anti-slop inspect [--json] # read-only debt inventory +devkit anti-slop prune [paths...] # shrink existing debt only +~~~ + +Paths are literal existing files/directories inside the repository and default to the repository +root. Path-scoped prune and forced create preserve every baseline entry outside that scope; only an +unscoped `create --force` replaces the complete debt record. The committed baseline is +**.anti-slop-baseline.json**. Check never edits it: existing entries are allowed, a new +error-severity finding fails, and a new warning-severity finding is reported without failing. +Prune refuses to write while a new error exists, removes fixed fingerprints, and reduces the +count when one of several identical occurrences is fixed. It cannot add a current finding. +Devkit clean deliberately keeps the baseline because it is the repository's debt record, not +replaceable Devkit state. + +Occurrences with the same rule, repository-relative file, normalized diagnostic, and normalized +source line are intentionally fungible and share one counted fingerprint. The ratchet prevents that +debt class from growing; it does not assign provenance to otherwise indistinguishable occurrences. +This keeps unrelated line-number shifts from reclassifying an adopted repository's existing debt. + +Each fingerprint is SHA-256 over exactly: + +1. the namespaced rule id; +2. the POSIX repository-relative file path; +3. the whitespace-normalized diagnostic; +4. the whitespace/line-ending-normalized reported source line. + +Absolute checkout paths and line numbers are excluded. Identical fingerprints carry a count, so a +third copy of an already-baselined two-copy pattern is still new. The file contains no timestamp and +entries sort by fingerprint, making identical repositories byte-for-byte deterministic. + +## Rule configuration and overrides + +All rules default to error. Ordinary Oxlint precedence applies, so a repository can change one +rule without copying the managed stack: + +~~~json +{ + "extends": ["./.devkit/oxc/oxlint.base.json"], + "rules": { + "anti-slop/no-runtime-typeof": [ + "warn", + { "allowInTypeGuards": true } + ], + "anti-slop/no-module-mocking": "off" + }, + "overrides": [ + { + "files": ["scripts/**/*.ts"], + "rules": { + "anti-slop/no-object-parameters": "off" + } + } + ] +} +~~~ + +Devkit Oxc lint runs these rules in the repository-root configuration alongside native and other +custom Oxlint rules. Baseline operations deliberately disable nested config discovery so the real +scan uses the same full root-to-managed-plugin chain that Devkit verifies with a sentinel. Put +path-specific policy in the root config's `overrides`; a nested Oxlint config cannot shadow or +silently replace the proved chain. Use Devkit anti-slop check for the baseline-aware adoption gate. +Extending the anti-slop fragment directly is also rejected: the sentinel independently proves the +repository root to managed Oxc base link and the managed base to anti-slop plugin link. + +## Vendored rules and parity + +| Rule | Representative behavior proved by the packed fixture | +| --- | --- | +| anti-slop/no-chained-type-assertions | chained assertion from object to User | +| anti-slop/no-conditional-empty-object-spread | conditional object spread using an empty object | +| anti-slop/no-known-value-widening | known object assigned to Record of string to T | +| anti-slop/no-module-mocking | Vitest module mock | +| anti-slop/no-object-parameters | function parameter typed object | +| anti-slop/no-reflect-apply | global Reflect.apply | +| anti-slop/no-reflect-get | global Reflect.get | +| anti-slop/no-runtime-typeof | ad-hoc runtime typeof guard | +| anti-slop/no-shape-in-symbol-names | interface name containing Shape | +| anti-slop/no-unknown-parameters | function parameter typed unknown | +| anti-slop/no-unknown-returns | function return typed unknown | +| anti-slop/no-unknown-type-aliases | alias that conceals unknown | +| anti-slop/no-unsafe-dictionary-type | Record of string to unknown | +| anti-slop/no-widen-then-assert | known local widened to unknown, then asserted back | +| anti-slop/require-safety-comment-for-type-assertion | non-const assertion without SAFETY justification | + +The packed E2E fixture asserts that all 15 namespaced diagnostics load from the emitted package, +that off, warn, error, and scoped overrides work, and that create to new violation to fix to prune +preserves the shrink-only contract. + +## Intentional differences and limits + +- The rule implementations are unchanged from the pinned upstream source. Devkit changes delivery, + namespacing/config ownership, and adds the baseline wrapper. +- Oxc's JS-plugin API is alpha. The exact Oxlint/plugin API pair and packed 15-rule fixture are the + compatibility boundary; updating either pin requires refreshing that evidence. +- Like upstream, these are syntax/scope rules, not TypeScript type-aware rules. They do not replace + TypeScript check with no emit. +- Oxc does not run custom JS rules over unsupported custom file formats. The capability targets + JavaScript, TypeScript, and their supported JSX variants. +- Baseline paths must resolve inside the repository. External files are rejected rather than + producing machine-specific fingerprints. +- A consumer-authored Oxlint config that does not extend Devkit's managed base will not load the + plugin. Devkit doctor reports missing integration; the supported fix is to retain + **./.devkit/oxc/oxlint.base.json** in that config's extends chain. + +See [the Oxc migration decision](decisions/oxc-toolchain-migration.md) for why Biome formatting, +ESLint filesystem topology, and TypeScript checking remain separate until their own parity +conditions are met. diff --git a/docs/decisions/oxc-toolchain-migration.md b/docs/decisions/oxc-toolchain-migration.md index 99e6ed1..01e7854 100644 --- a/docs/decisions/oxc-toolchain-migration.md +++ b/docs/decisions/oxc-toolchain-migration.md @@ -24,3 +24,4 @@ created: 2026-08-15 - 2026-08-16 — sc-1678 measured the direct filesystem walker at 64.3% lower median clean-tree wall time, 75.5% lower CPU, and 31.8% lower process-tree peak RSS than the packaged guard-structure ESLint chain on a 481-file fixture under Node 24.19 (placement-violation lane: 66.9%/76.0%/31.7% lower). Retain ESLint: the prototype has no independent-modules import-wall parser/resolver, drops directory-only violations such as an illegal empty directory, and cannot ingest Electron's six hand-written folder trees because its guard config has no structure representation. It therefore fails the Target's coverage-parity condition despite the material speed win. Evidence: docs/benchmarks/experiments/2026-08-16-topology-guard/. - 2026-08-16 — sc-1680 retains TypeScript 6.0.3 tsc as the no-emit authority after a pinned Oxlint 1.78.0 + tsgolint 7.0.2001 shadow used 80.3% less median CPU and 12.1% less summed process-tree RSS on the same 236-file Devkit manifest under A0-matched fresh-fixture/cache conditions. Clean diagnostics and two injected TS2322 findings matched exactly, but tsgolint executes TypeScript 7.0.2 semantics while Devkit still edits/builds with TypeScript 6; broad Oxlint directory inputs also included config-excluded tests. The candidate remains shadow-only until editor/build and tsgolint share TypeScript 7 semantics, config-owned selection and project-reference parity are proven. - 2026-08-16 — sc-1679 proves Oxfmt 0.63.0 over Devkit's exact 558-file Biome formatting scope. The corrected migration changes seven TypeScript files with formatter-only hunks, is byte-idempotent on pass two, and keeps JSON/JSONC/package ordering stable through explicit overrides. Ten paired samples show the direct pinned binary cuts full-scope median CPU 72.6% (0.8709s to 0.2382s), wall 37.8% (0.1997s to 0.1242s), and process-tree RSS 24.1% (143.7 to 109.1 MiB); a one-file single-thread check-mode proxy cuts CPU 8.1% but increases wall/RSS. Devkit adopts direct Oxfmt for its own formatting, CI, and staged self-host path, retains Biome for lint and consumer configs/hooks, and keeps devkit oxc fmt out of the hot staged path because Node-wrapper startup measured 0.1113s CPU and 137.0 MiB there. +- 2026-08-16 — sc-1676 vendors anti-slop commit 446268e5d15baa968eaec669ff65358d36ae6259 with @oxlint/plugins@1.78.0 into the managed Oxc configuration and adds an explicit, deterministic create/check/inspect/prune baseline lifecycle. Normal checks stay read-only and reject only unbaselined error-severity findings; prune can only delete absent debt or reduce duplicate counts. Rule severity and scoped overrides remain native Oxlint config so anti-slop composes with other Oxc rules. diff --git a/e2e/anti-slop.e2e.test.mts b/e2e/anti-slop.e2e.test.mts new file mode 100644 index 0000000..2b3ef3a --- /dev/null +++ b/e2e/anti-slop.e2e.test.mts @@ -0,0 +1,353 @@ +import { spawnSync } from 'node:child_process'; +import { + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + renameSync, + rmSync, + writeFileSync, +} from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterAll, describe, expect, it } from 'vitest'; +import { type Fixture, makeFixture, out } from './lib/harness.mts'; + +const created: Fixture[] = []; +const cloneParents: string[] = []; +afterAll(() => { + for (const fixture of created) fixture.cleanup(); + for (const parent of cloneParents) rmSync(parent, { recursive: true, force: true }); +}); + +async function fixture(): Promise { + const value = await makeFixture('devkit-anti-slop-e2e-'); + created.push(value); + return value; +} + +const INITIAL = readFileSync( + new URL('./fixtures/anti-slop/all-rules.ts', import.meta.url), + 'utf8', +); +const INIT_ARGS = [ + 'init', + '--stack', + 'generic', + '--yes', + '--standalone', + '--anti-slop', + '--no-biome', + '--no-tsconfig', + '--no-skills', + '--no-agents', + '--no-husky', + '--no-structure', + '--no-guards', + '--no-line-growth', +]; + +function baseline(root: string) { + return JSON.parse(readFileSync(join(root, '.anti-slop-baseline.json'), 'utf8')); +} + +describe('e2e: packed anti-slop capability', () => { + it('refuses a baseline when a preserved consumer config omits the managed base', async () => { + const fx = await fixture(); + writeFileSync( + join(fx.repoDir, '.oxlintrc.jsonc'), + '{\n // "extends": ["./.devkit/oxc/oxlint.base.json"],\n "rules": {}\n}\n', + ); + expect(fx.run('devkit', INIT_ARGS).status).toBe(0); + writeFileSync( + join(fx.repoDir, '.devkit/.oxlintrc.json'), + '{ "extends": ["./oxc/oxlint.base.json"] }\n', + ); + writeFileSync(join(fx.repoDir, 'bad.ts'), 'function bad(value: object) { return value; }\n'); + + const create = fx.run('devkit', ['anti-slop', 'create', 'bad.ts']); + + expect(create.status, out(create)).not.toBe(0); + expect(out(create)).toContain('refusing an incomplete baseline'); + expect(existsSync(join(fx.repoDir, '.anti-slop-baseline.json'))).toBe(false); + expect(fx.run('devkit', ['doctor']).status).not.toBe(0); + }); + + it('requires the repository config to compose the managed Oxc base', async () => { + const fx = await fixture(); + expect(fx.run('devkit', INIT_ARGS).status).toBe(0); + writeFileSync( + join(fx.repoDir, '.oxlintrc.json'), + '{ "extends": ["./.devkit/anti-slop/oxlint.json"] }\n', + ); + writeFileSync(join(fx.repoDir, 'bad.ts'), 'function bad(value: object) { return value; }\n'); + + const create = fx.run('devkit', ['anti-slop', 'create', 'bad.ts']); + + expect(create.status, out(create)).not.toBe(0); + expect(out(create)).toContain('does not load the managed Oxlint base'); + expect(existsSync(join(fx.repoDir, '.anti-slop-baseline.json'))).toBe(false); + }); + + it('recovers an installed capability when init was interrupted before its config write', async () => { + const fx = await fixture(); + expect(fx.run('devkit', INIT_ARGS).status).toBe(0); + renameSync( + join(fx.repoDir, '.devkit/config.json'), + join(fx.repoDir, '.devkit/config.interrupted.json'), + ); + + const retry = fx.run( + 'devkit', + INIT_ARGS.filter((argument) => argument !== '--anti-slop'), + ); + + expect(retry.status, out(retry)).toBe(0); + const recovered = JSON.parse(readFileSync(join(fx.repoDir, '.devkit/config.json'), 'utf8')) as { + components: { antiSlop: boolean; oxc: boolean }; + }; + expect(recovered.components).toMatchObject({ antiSlop: true, oxc: true }); + expect(fx.run('devkit', ['doctor']).status).toBe(0); + }); + + it('deselects anti-slop without leaving a dangling managed Oxc base pointer', async () => { + const fx = await fixture(); + expect(fx.run('devkit', INIT_ARGS).status).toBe(0); + const deselect = fx.run('devkit', [ + ...INIT_ARGS.filter((argument) => argument !== '--anti-slop'), + '--oxc', + '--no-anti-slop', + '--remove-deselected', + ]); + + expect(deselect.status, out(deselect)).toBe(0); + expect(existsSync(join(fx.repoDir, '.devkit/anti-slop'))).toBe(false); + expect(readFileSync(join(fx.repoDir, '.devkit/oxc/oxlint.base.json'), 'utf8')).not.toContain( + '../anti-slop/oxlint.json', + ); + expect(fx.run('devkit', ['doctor']).status).toBe(0); + }); + + it('survives an ordinary package-mode git add and clone without reinstalling managed state', async () => { + const fx = await fixture(); + writeFileSync(join(fx.repoDir, '.gitignore'), 'node_modules\nvendor/\n'); + const packageArgs = INIT_ARGS.filter((argument) => argument !== '--standalone'); + expect(fx.run('devkit', packageArgs).status).toBe(0); + writeFileSync(join(fx.repoDir, 'legacy.ts'), INITIAL); + expect(fx.run('devkit', ['anti-slop', 'create', 'legacy.ts']).status).toBe(0); + expect(fx.git('add', '-A').status).toBe(0); + expect( + fx.git( + 'ls-files', + '.devkit/anti-slop/plugin/oxlint-plugins-api/index.js', + ).stdout.trim(), + ).toBe('.devkit/anti-slop/plugin/oxlint-plugins-api/index.js'); + expect(fx.git('ls-files', '.devkit/anti-slop/plugin/node_modules/**').stdout.trim()).toBe(''); + expect(fx.git('commit', '-qm', 'fixture').status).toBe(0); + + const parent = mkdtempSync(join(tmpdir(), 'devkit-anti-slop-clone-')); + cloneParents.push(parent); + const checkout = join(parent, 'checkout'); + const cloned = spawnSync('git', ['clone', '-q', fx.repoDir, checkout], { + encoding: 'utf8', + env: fx.env, + }); + expect(cloned.status, out(cloned)).toBe(0); + expect(existsSync(join(checkout, 'node_modules'))).toBe(false); + + const doctor = spawnSync('devkit', ['doctor'], { + cwd: checkout, + encoding: 'utf8', + env: fx.env, + }); + expect(doctor.status, out(doctor)).toBe(0); + const checked = spawnSync('devkit', ['anti-slop', 'check', 'legacy.ts'], { + cwd: checkout, + encoding: 'utf8', + env: fx.env, + }); + expect(checked.status, out(checked)).toBe(0); + }); + + it('vendors all rules and enforces an explicit deterministic shrink-only adoption flow', async () => { + const fx = await fixture(); + const init = fx.run('devkit', INIT_ARGS); + expect(init.status, out(init)).toBe(0); + expect(existsSync(join(fx.repoDir, '.devkit/anti-slop/manifest.json'))).toBe(true); + expect(existsSync(join(fx.repoDir, '.devkit/oxc/manifest.json'))).toBe(true); + const ordinaryProbe = fx.run('devkit', [ + 'oxc', + 'lint', + '--format', + 'json', + '.devkit/anti-slop/probe.ts', + ]); + expect([0, 1], out(ordinaryProbe)).toContain(ordinaryProbe.status); + expect( + JSON.parse(ordinaryProbe.stdout).diagnostics.filter((diagnostic: { code?: string }) => + diagnostic.code?.startsWith('anti-slop('), + ), + ).toEqual([]); + const composed = fx.run('devkit', [ + 'oxc', + 'lint', + '--format', + 'json', + '--disable-nested-config', + '.', + ]); + expect([0, 1], out(composed)).toContain(composed.status); + const composedPayload = JSON.parse(composed.stdout) as { + diagnostics: Array<{ code?: string; filename?: string }>; + }; + expect( + composedPayload.diagnostics.filter( + (diagnostic) => + diagnostic.code?.startsWith('anti-slop(') && + diagnostic.filename?.includes('.devkit/anti-slop/'), + ), + ).toEqual([ + expect.objectContaining({ + code: 'anti-slop(no-object-parameters)', + filename: '.devkit/anti-slop/probe.ts', + }), + ]); + + writeFileSync(join(fx.repoDir, 'legacy.ts'), INITIAL); + writeFileSync(join(fx.repoDir, 'held.ts'), INITIAL); + writeFileSync(join(fx.repoDir, '.devkit/oxc/oxlint.base.json'), '{ "rules": {} }\n'); + const disconnected = fx.run('devkit', ['anti-slop', 'create', 'legacy.ts']); + expect(disconnected.status, out(disconnected)).not.toBe(0); + expect(out(disconnected)).toContain('managed Oxlint base is missing or drifted'); + expect(existsSync(join(fx.repoDir, '.anti-slop-baseline.json'))).toBe(false); + fx.run('devkit', ['doctor', '--fix']); + expect(fx.run('devkit', ['doctor']).status).toBe(0); + const missing = fx.run('devkit', ['anti-slop', 'check', 'legacy.ts']); + expect(missing.status, out(missing)).toBe(2); + expect(existsSync(join(fx.repoDir, '.anti-slop-baseline.json'))).toBe(false); + + const create = fx.run('devkit', ['anti-slop', 'create', 'legacy.ts', 'held.ts']); + expect(create.status, out(create)).toBe(0); + const originalBaseline = readFileSync( + join(fx.repoDir, '.anti-slop-baseline.json'), + 'utf8', + ); + const initial = baseline(fx.repoDir); + expect(new Set(initial.entries.map((entry: { ruleId: string }) => entry.ruleId)).size).toBe(15); + const heldEntries = initial.entries.filter((entry: { file: string }) => entry.file === 'held.ts'); + const scopedForce = fx.run('devkit', [ + 'anti-slop', + 'create', + '--force', + 'legacy.ts', + ]); + expect(scopedForce.status, out(scopedForce)).toBe(0); + expect( + baseline(fx.repoDir).entries.filter((entry: { file: string }) => entry.file === 'held.ts'), + ).toEqual(heldEntries); + expect(readFileSync(join(fx.repoDir, '.anti-slop-baseline.json'), 'utf8')).toBe( + originalBaseline, + ); + expect(fx.run('devkit', ['anti-slop', 'check', 'legacy.ts', 'held.ts']).status).toBe(0); + const scopedExisting = fx.run('devkit', ['anti-slop', 'check', 'legacy.ts']); + expect(scopedExisting.status, out(scopedExisting)).toBe(0); + expect(out(scopedExisting)).toContain('0 ready to prune'); + + mkdirSync(join(fx.repoDir, 'sub')); + writeFileSync(join(fx.repoDir, 'sub/.oxlintrc.json'), '{}\n'); + writeFileSync( + join(fx.repoDir, 'sub/bad.ts'), + 'function shadowed(value: object) { return value; }\n', + ); + const nestedShadow = fx.run('devkit', ['anti-slop', 'check', 'sub/bad.ts']); + expect(nestedShadow.status, out(nestedShadow)).toBe(1); + expect(out(nestedShadow)).toContain('anti-slop/no-object-parameters'); + writeFileSync(join(fx.repoDir, 'sub/bad.ts'), 'export const fixedShadow = true;\n'); + + const second = await fixture(); + expect(second.run('devkit', INIT_ARGS).status).toBe(0); + writeFileSync(join(second.repoDir, 'legacy.ts'), INITIAL); + writeFileSync(join(second.repoDir, 'held.ts'), INITIAL); + expect(second.run('devkit', ['anti-slop', 'create']).status).toBe(0); + expect(readFileSync(join(second.repoDir, '.anti-slop-baseline.json'), 'utf8')).toBe( + originalBaseline, + ); + + writeFileSync(join(fx.repoDir, 'new.ts'), 'function newer(value: object) { return value; }\n'); + const introduced = fx.run('devkit', [ + 'anti-slop', + 'check', + 'legacy.ts', + 'held.ts', + 'new.ts', + ]); + expect(introduced.status, out(introduced)).toBe(1); + expect(out(introduced)).toContain('anti-slop/no-object-parameters'); + expect(readFileSync(join(fx.repoDir, '.anti-slop-baseline.json'), 'utf8')).toBe( + originalBaseline, + ); + + writeFileSync( + join(fx.repoDir, 'new.ts'), + 'interface RecordValue { id: string }\nfunction newer(value: RecordValue) { return value; }\n', + ); + expect( + fx.run('devkit', ['anti-slop', 'check', 'legacy.ts', 'held.ts', 'new.ts']).status, + ).toBe(0); + + writeFileSync( + join(fx.repoDir, '.oxlintrc.json'), + `${JSON.stringify({ + extends: ['./.devkit/oxc/oxlint.base.json'], + rules: { + 'anti-slop/no-object-parameters': 'warn', + 'anti-slop/no-reflect-get': 'off', + }, + })}\n`, + ); + writeFileSync( + join(fx.repoDir, 'controls.ts'), + 'function controlled(value: object) { return Reflect.get(value, "id"); }\n', + ); + const warning = fx.run('devkit', ['anti-slop', 'check', 'controls.ts']); + expect(warning.status, out(warning)).toBe(0); + expect(out(warning)).toContain('WARN anti-slop/no-object-parameters'); + expect(out(warning)).not.toContain('anti-slop/no-reflect-get'); + + writeFileSync( + join(fx.repoDir, '.oxlintrc.json'), + `${JSON.stringify({ + extends: ['./.devkit/oxc/oxlint.base.json'], + rules: { + 'anti-slop/no-object-parameters': 'error', + 'anti-slop/no-reflect-get': 'off', + }, + overrides: [ + { + files: ['controls.ts'], + rules: { 'anti-slop/no-object-parameters': 'off' }, + }, + ], + })}\n`, + ); + const scoped = fx.run('devkit', ['anti-slop', 'check', 'controls.ts']); + expect(scoped.status, out(scoped)).toBe(0); + + writeFileSync(join(fx.repoDir, 'legacy.ts'), 'export const fixed = true;\n'); + const prune = fx.run('devkit', ['anti-slop', 'prune', 'legacy.ts', 'new.ts']); + expect(prune.status, out(prune)).toBe(0); + expect( + baseline(fx.repoDir).entries.every((entry: { file: string }) => entry.file === 'held.ts'), + ).toBe(true); + expect(fx.run('devkit', ['anti-slop', 'check', 'held.ts']).status).toBe(0); + writeFileSync(join(fx.repoDir, 'held.ts'), 'export const alsoFixed = true;\n'); + expect(fx.run('devkit', ['anti-slop', 'prune', 'held.ts']).status).toBe(0); + expect(baseline(fx.repoDir).entries).toEqual([]); + expect(fx.run('devkit', ['anti-slop', 'inspect']).status).toBe(0); + expect(fx.run('devkit', ['doctor']).status).toBe(0); + + expect(fx.run('devkit', ['clean', '--yes']).status).toBe(0); + expect(existsSync(join(fx.repoDir, '.devkit/anti-slop'))).toBe(false); + expect(existsSync(join(fx.repoDir, '.anti-slop-baseline.json'))).toBe(true); + }); +}); diff --git a/e2e/fixtures/anti-slop/all-rules.ts b/e2e/fixtures/anti-slop/all-rules.ts new file mode 100644 index 0000000..a3cd418 --- /dev/null +++ b/e2e/fixtures/anti-slop/all-rules.ts @@ -0,0 +1,48 @@ +type User = { id: string }; +type Handler = () => void; +type UserId = string & { readonly userId: unique symbol }; + +declare const input: unknown; +declare const operation: (...args: unknown[]) => unknown; +declare const owner: object; +declare const args: unknown[]; +declare const key: string; +declare const startHandler: Handler; +declare const value: string; + +const chained = input as object as User; +const options = { ...(value ? { value } : {}) }; +const handlers: Record = { start: startHandler }; +vi.mock('./store'); +function save(record: object) { + return record; +} +Reflect.apply(operation, owner, args); +Reflect.get(owner, key); +if (typeof input === 'string') console.log(input); +interface UserShape { + id: string; +} +function handle(payload: unknown) { + return payload; +} +function loadUser(): unknown { + return input; +} +type ExternalValue = unknown; +type Metadata = Record; +declare const loaded: User; +const stored: unknown = loaded; +const restored = stored as User; +const userId = value as UserId; + +void [ + chained, + options, + handlers, + save, + handle, + loadUser, + restored, + userId, +]; diff --git a/e2e/oxc.e2e.test.mts b/e2e/oxc.e2e.test.mts index 575b68c..3ecb730 100644 --- a/e2e/oxc.e2e.test.mts +++ b/e2e/oxc.e2e.test.mts @@ -108,7 +108,9 @@ export default { meta: { name: 'local' }, rules: { 'max-classes': rule } }; writeFileSync(managedBase, '{ "rules": { "no-debugger": "warn" } }\n'); const upgrade = fx.run('devkit', ['upgrade']); expect(upgrade.status, out(upgrade)).toBe(0); - expect(readFileSync(managedBase, 'utf8')).toBe('{\n "rules": {}\n}\n'); + expect(readFileSync(managedBase, 'utf8')).toBe( + readFileSync(new URL('../oxc/oxlint.base.json', import.meta.url), 'utf8'), + ); expect(readFileSync(join(fx.repoDir, '.oxlintrc.json'), 'utf8')).toBe(customizedConfig); writeFileSync(join(fx.repoDir, 'format-target.ts'), 'const value={answer:42}\n'); diff --git a/oxc/oxlint.base.json b/oxc/oxlint.base.json index 90d8894..2c64772 100644 --- a/oxc/oxlint.base.json +++ b/oxc/oxlint.base.json @@ -1,3 +1,11 @@ { + "overrides": [ + { + "files": [".devkit/anti-slop/probe.ts"], + "globals": { + "__DEVKIT_OXC_BASE_1_78_0_MANAGED_PROBE__": "readonly" + } + } + ], "rules": {} } diff --git a/package.json b/package.json index d4ac663..0bb1108 100644 --- a/package.json +++ b/package.json @@ -84,9 +84,10 @@ }, "dependencies": { "@clack/prompts": "^1.5.1", + "@oxlint/plugins": "1.78.0", + "es-module-lexer": "^2.1.0", "eslint": "^10.5.0", "eslint-plugin-project-structure": "^3.14.3", - "es-module-lexer": "^2.1.0", "mdast-util-from-markdown": "^2.0.3", "oxfmt": "0.63.0", "oxlint": "1.78.0", diff --git a/scripts/copy-dist-assets.mjs b/scripts/copy-dist-assets.mjs index 8e771e7..a7ed174 100644 --- a/scripts/copy-dist-assets.mjs +++ b/scripts/copy-dist-assets.mjs @@ -9,7 +9,7 @@ * * Run by `bun run build` after tsc. Idempotent. */ -import { cpSync, existsSync, readdirSync } from 'node:fs'; +import { cpSync, existsSync, readdirSync, rmSync } from 'node:fs'; import { dirname, join, relative } from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -21,10 +21,26 @@ if (!existsSync(join(dist, 'cli')) || !existsSync(join(dist, 'gate-engine'))) { } // Whole root asset dirs + files consumed via packageDir() / the exports map. -const ROOT_DIRS = ['biome', 'tsconfig', 'oxc', 'templates', 'skills', 'agents', 'agents-hooks']; +const ROOT_DIRS = [ + 'biome', + 'tsconfig', + 'oxc', + 'templates', + 'skills', + 'agents', + 'agents-hooks', +]; const ROOT_FILES = ['package.json', 'README.md']; for (const d of ROOT_DIRS) cpSync(join(root, d), join(dist, d), { recursive: true }); for (const f of ROOT_FILES) if (existsSync(join(root, f))) cpSync(join(root, f), join(dist, f)); +for (const f of ['LICENSE', 'UPSTREAM.md']) + cpSync(join(root, 'anti-slop', f), join(dist, 'anti-slop', f)); +for (const entry of readdirSync(join(dist, 'anti-slop', 'src'), { + recursive: true, + withFileTypes: true, +})) { + if (entry.isFile() && entry.name.endsWith('.ts')) rmSync(join(entry.parentPath, entry.name)); +} // Non-TS files that live UNDER cli/ or gate-engine/ (the .sh ship scripts, config .json) — mirror // each to its dist/ path. tsc never emits these. Skip tests + eval (dev-only, not shipped-run). diff --git a/tsconfig.build.json b/tsconfig.build.json index 04f323c..b1efe9e 100644 --- a/tsconfig.build.json +++ b/tsconfig.build.json @@ -18,6 +18,6 @@ "declarationMap": false, "sourceMap": false }, - "include": ["cli/**/*.mts", "gate-engine/**/*.mts"], + "include": ["anti-slop/**/*.ts", "cli/**/*.mts", "gate-engine/**/*.mts"], "exclude": ["node_modules", "templates", "dist", "**/*.test.mts", "**/__tests__/**", "**/eval/**"] } diff --git a/tsconfig.json b/tsconfig.json index 5e48673..def0332 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -10,7 +10,7 @@ "types": ["node"], "strict": true }, - "include": ["cli/**/*.mts", "gate-engine/**/*.mts"], + "include": ["anti-slop/**/*.ts", "cli/**/*.mts", "gate-engine/**/*.mts"], "//": "Typecheck shipped source only. Tests are validated by running them (vitest); templates import consumer-only peer deps (see their @ts-nocheck). Phase A: strict OFF (rename pass); Phase B flips strict/noImplicitAny true. tsconfig.build.json handles emit.", "exclude": ["node_modules", "templates", "dist", "**/*.test.mts", "**/__tests__/**", "**/eval/**"] }