diff --git a/bun.lock b/bun.lock index 37a26e26f..837e64f73 100644 --- a/bun.lock +++ b/bun.lock @@ -92,6 +92,7 @@ "@iconify-json/lucide": "^1.2.123", "@modelcontextprotocol/sdk": "^1.30.0", "@open-pencil/mcp": "workspace:*", + "@oxlint/plugins": "1.57.0", "@playwright/test": "^1.62.1", "@storybook/addon-a11y": "^10.5.8", "@storybook/addon-docs": "^10.5.8", @@ -1367,6 +1368,8 @@ "@oxlint/binding-win32-x64-msvc": ["@oxlint/binding-win32-x64-msvc@1.57.0", "", { "os": "win32", "cpu": "x64" }, "sha512-6PuxhYgth8TuW0+ABPOIkGdBYw+qYGxgIdXPHSVpiCDm+hqTTWCmC739St1Xni0DJBt8HnSHTG67i1y6gr8qrA=="], + "@oxlint/plugins": ["@oxlint/plugins@1.57.0", "", {}, "sha512-4mAGdfUZNQSZwUbHs0xoUcKjByrdBSJ9iaCI3STn/d1ZVRKhW/82oCA6rdfSzO4vxrWH3/l+qYWh/tyrwPSpag=="], + "@pkgjs/parseargs": ["@pkgjs/parseargs@0.11.0", "", {}, "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg=="], "@pkgr/core": ["@pkgr/core@0.1.2", "", {}, "sha512-fdDH1LSGfZdTH2sxdpVMw31BanV28K/Gry0cVFxaNP77neJSkd82mM8ErPNYs9e+0O7SdHBLTDzDgwUuy18RnQ=="], diff --git a/oxlint.json b/oxlint.json index 80fa0fc7b..4f1a2ff85 100644 --- a/oxlint.json +++ b/oxlint.json @@ -414,9 +414,30 @@ "typescript/restrict-plus-operands": "error", "unicorn/prefer-includes": "error", "no-loop-func": "error", - "typescript/no-dynamic-delete": "error" + "typescript/no-dynamic-delete": "error", + "open-pencil/no-module-mocking": "error", + "open-pencil/no-reduce-accumulator-copy": "error", + "open-pencil/no-widen-then-assert": "error" }, "overrides": [ + { + "files": [ + "tools/lint/src/rules/quality/widen-then-assert.ts", + "tools/lint/src/support/dictionary-types.ts" + ], + "rules": { + "complexity": "off" + } + }, + { + "files": [ + "tools/lint/src/support/lexical-type-parameters.ts", + "tools/lint/src/support/type-alias-resolution.ts" + ], + "rules": { + "open-pencil/no-broad-double-cast": "off" + } + }, { "files": [ "tools/lint/src/**/*.ts", diff --git a/package.json b/package.json index 4665b1a52..1577ebd6a 100644 --- a/package.json +++ b/package.json @@ -160,6 +160,7 @@ "@iconify-json/lucide": "^1.2.123", "@modelcontextprotocol/sdk": "^1.30.0", "@open-pencil/mcp": "workspace:*", + "@oxlint/plugins": "1.57.0", "@playwright/test": "^1.62.1", "@storybook/addon-a11y": "^10.5.8", "@storybook/addon-docs": "^10.5.8", diff --git a/tools/lint/src/plugin.ts b/tools/lint/src/plugin.ts index c907668c7..a5299480c 100644 --- a/tools/lint/src/plugin.ts +++ b/tools/lint/src/plugin.ts @@ -3,6 +3,10 @@ import { noFlatKiwiModules, noMixedCaseAcronymIdentifiers } from '#lint/rules/policy.ts' +import { noKnownValueWideningRule } from '#lint/rules/quality/known-value-widening.ts' +import { noModuleMockingRule } from '#lint/rules/quality/module-mocking.ts' +import { noReduceAccumulatorCopyRule } from '#lint/rules/quality/reduce-accumulator-copy.ts' +import { noWidenThenAssertRule } from '#lint/rules/quality/widen-then-assert.ts' import { normalizedFilename } from '#lint/support/context.ts' import type { RuleDefinition } from '#lint/support/types.ts' import type { TSESTree } from '@typescript-eslint/utils' @@ -487,6 +491,10 @@ const plugin = { 'no-bun-globals-in-cli': noBunGlobalsInCli, 'no-top-level-prefixed-test-files': noTopLevelPrefixedTestFiles, 'no-conditional-object-spreads': noConditionalObjectSpreads, + 'no-module-mocking': noModuleMockingRule, + 'no-reduce-accumulator-copy': noReduceAccumulatorCopyRule, + 'no-known-value-widening': noKnownValueWideningRule, + 'no-widen-then-assert': noWidenThenAssertRule, 'no-sibling-domain-prefixed-files': noSiblingDomainPrefixedFiles } } diff --git a/tools/lint/src/rules/quality/known-value-widening.ts b/tools/lint/src/rules/quality/known-value-widening.ts new file mode 100644 index 000000000..6d6b417cd --- /dev/null +++ b/tools/lint/src/rules/quality/known-value-widening.ts @@ -0,0 +1,394 @@ +import { + classifyUnsafeDictionaryValue, + classifyWideningTarget, + createTypeEnvironment, + isKnownEvidenceExpression, + type TypeEnvironment, + type WideningTarget +} from '#lint/support/dictionary-types.ts' +import { + containsUnknownType, + functionParameterBindingName, + functionParameterTypeAnnotation +} from '#lint/support/function-parameters.ts' +import { resolveVariable } from '#lint/support/scope.ts' +import { defineRule } from '@oxlint/plugins' +import type { ESTree, 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 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 isFunctionExpression(node: ESTree.Node): node is FunctionExpression { + return ( + node.type === 'ArrowFunctionExpression' || + node.type === 'FunctionDeclaration' || + node.type === 'FunctionExpression' || + node.type === 'TSDeclareFunction' || + node.type === 'TSEmptyBodyFunctionExpression' + ) +} + +function localFunctionForCall( + sourceCode: SourceCode, + callee: ESTree.Expression +): FunctionExpression | null { + const unwrapped = unwrapExpression(callee) + if (isFunctionExpression(unwrapped)) return unwrapped + if (unwrapped.type !== 'Identifier') return null + const variable = resolveVariable(sourceCode, unwrapped) + if (variable === null || variable.defs.length !== 1) return null + const [definition] = variable.defs + if (definition === undefined) return null + if (definition.type === 'FunctionName' && isFunctionExpression(definition.node)) { + return definition.node + } + if (definition.type !== 'Variable' || definition.node.type !== 'VariableDeclarator') { + return null + } + const initializer = definition.node.init + if (initializer === null) return null + const unwrappedInitializer = unwrapExpression(initializer) + return isFunctionExpression(unwrappedInitializer) ? unwrappedInitializer : null +} + +function variableTypeAnnotation( + sourceCode: SourceCode, + variable: Variable +): ESTree.TSTypeAnnotation | null { + if (variable.defs.length !== 1) return null + const [definition] = variable.defs + if (definition === undefined) return null + if ( + definition.type === 'Variable' && + definition.node.type === 'VariableDeclarator' && + definition.node.id.type === 'Identifier' + ) { + return definition.node.id.typeAnnotation ?? null + } + if (definition.type !== 'Parameter' || !isFunctionExpression(definition.node)) { + return null + } + const parameter = definition.node.params.find( + (candidate) => functionParameterBindingName(candidate, sourceCode) === variable.name + ) + return parameter === undefined ? null : (functionParameterTypeAnnotation(parameter) ?? null) +} + +function hasInformativeType(type: ESTree.TSType, environment: TypeEnvironment): boolean { + return classifyUnsafeDictionaryValue(type, environment) === null +} + +function hasKnownCallArgumentEvidence( + sourceCode: SourceCode, + expression: ESTree.Expression, + environment: TypeEnvironment, + visitedVariables = new Set() +): boolean { + if (expression.type === 'ParenthesizedExpression' || expression.type === 'TSNonNullExpression') { + return hasKnownCallArgumentEvidence( + sourceCode, + expression.expression, + environment, + visitedVariables + ) + } + if (expression.type === 'TSAsExpression' || expression.type === 'TSTypeAssertion') { + return hasInformativeType(expression.typeAnnotation, environment) + } + if (expression.type === 'TSSatisfiesExpression') { + return hasKnownCallArgumentEvidence( + sourceCode, + expression.expression, + environment, + visitedVariables + ) + } + if (expression.type === 'CallExpression') { + const owner = localFunctionForCall(sourceCode, expression.callee) + const returnType = owner?.returnType?.typeAnnotation + return returnType !== undefined && hasInformativeType(returnType, environment) + } + if (expression.type !== 'Identifier') return isKnownEvidenceExpression(expression) + const variable = resolveVariable(sourceCode, expression) + if (variable === null || visitedVariables.has(variable)) return false + const annotation = variableTypeAnnotation(sourceCode, variable) + if (annotation !== null) { + return hasInformativeType(annotation.typeAnnotation, environment) + } + const declarator = variableDeclarator(variable) + if ( + declarator === null || + declarator.init === null || + !isStableConstVariable(variable, declarator) + ) { + return false + } + visitedVariables.add(variable) + return hasKnownCallArgumentEvidence(sourceCode, declarator.init, environment, visitedVariables) +} + +function typePredicateSubjectIndex( + sourceCode: SourceCode, + owner: FunctionExpression +): number | null { + const predicate = owner.returnType?.typeAnnotation + if (predicate?.type !== 'TSTypePredicate' || predicate.parameterName.type !== 'Identifier') { + return null + } + const predicateParameterName = predicate.parameterName.name + const index = owner.params.findIndex( + (parameter) => functionParameterBindingName(parameter, sourceCode) === predicateParameterName + ) + return index === -1 ? null : index +} + +function annotationTarget( + annotation: ESTree.TSTypeAnnotation | null | undefined, + environment: TypeEnvironment +): 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, context.sourceCode.visitorKeys) + }, + 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}\`` + ) + }, + CallExpression(node) { + if (environment === null) return + const owner = localFunctionForCall(context.sourceCode, node.callee) + if (owner === null) return + const parameterIndex = typePredicateSubjectIndex(context.sourceCode, owner) + if (parameterIndex === null) return + const parameter = owner.params[parameterIndex] + const argument = node.arguments[parameterIndex] + if ( + parameter === undefined || + argument === undefined || + argument.type === 'SpreadElement' + ) { + return + } + const parameterAnnotation = functionParameterTypeAnnotation(parameter) + if ( + parameterAnnotation === null || + parameterAnnotation === undefined || + !containsUnknownType(parameterAnnotation.typeAnnotation) + ) { + return + } + if (!hasKnownCallArgumentEvidence(context.sourceCode, argument, environment)) { + return + } + context.report({ + node: argument, + messageId: 'widening', + data: { + subject: `argument for parameter \`${functionParameterBindingName(parameter, context.sourceCode)}\` of \`${functionName(context.sourceCode, owner)}\``, + target: 'unknown' + } + }) + }, + ReturnStatement(node) { + if (node.argument === null) return + const owner = enclosingFunction(node) + 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/tools/lint/src/rules/quality/module-mocking.ts b/tools/lint/src/rules/quality/module-mocking.ts new file mode 100644 index 000000000..65d87f751 --- /dev/null +++ b/tools/lint/src/rules/quality/module-mocking.ts @@ -0,0 +1,72 @@ +import { resolveVariable } from '#lint/support/scope.ts' +import { defineRule } from '@oxlint/plugins' +import type { ESTree, SourceCode } from '@oxlint/plugins' + +const moduleMockMethods = new Set(['doMock', 'mock', 'module', 'unstable_mockModule']) + +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 + const globalNames = new Set(['jest', 'mock', 'vi']) + if (globalNames.has(expression.name) && sourceCode.isGlobalReference(expression)) return true + + const variable = resolveVariable(sourceCode, expression) + if (variable === null || variable.defs.length === 0) return globalNames.has(expression.name) + 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') || + (source === 'bun:test' && name === 'mock') + ) + }) +} + +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 + let method: string | null = null + if (callee.computed && property.type === 'Literal' && typeof property.value === 'string') { + method = property.value + } else if (!callee.computed && property.type === 'Identifier') { + method = property.name + } + 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 Bun, Vitest, and Jest module mocking; tests must replace dependencies through real interfaces.' + }, + messages: { + moduleMock: + 'Replace module registry mocking with dependency injection through a real interface, service layer, or faithful test implementation. Bun mock.restore() does not undo mock.module().' + } + }, + 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/tools/lint/src/rules/quality/reduce-accumulator-copy.ts b/tools/lint/src/rules/quality/reduce-accumulator-copy.ts new file mode 100644 index 000000000..5103a79e7 --- /dev/null +++ b/tools/lint/src/rules/quality/reduce-accumulator-copy.ts @@ -0,0 +1,131 @@ +import { + arrayMethodTarget, + isKnownArrayExpression, + resolveArrayBinding, + unwrapArrayExpression +} from '#lint/support/array-method.ts' +import { defineRule } from '@oxlint/plugins' +import type { ESTree, SourceCode, Variable } from '@oxlint/plugins' + +function enclosingReducer(node: ESTree.Node) { + let parent = node.parent + while (parent !== null) { + if (parent.type === 'FunctionDeclaration') return null + if (parent.type === 'ArrowFunctionExpression' || parent.type === 'FunctionExpression') { + const callback = parent + let owner: ESTree.Node | null = callback.parent + while (owner !== null && unwrapArrayExpression(owner) === callback) owner = owner.parent + if (owner?.type !== 'CallExpression') return null + const method = arrayMethodTarget(owner.callee) + const firstArgument = owner.arguments[0] + if ( + method === null || + (method.name !== 'reduce' && method.name !== 'reduceRight') || + owner.arguments.length > 2 || + firstArgument === undefined || + unwrapArrayExpression(firstArgument) !== callback + ) + return null + const firstParameter = callback.params[0] + const accumulator = + firstParameter?.type === 'AssignmentPattern' ? firstParameter.left : firstParameter + if (accumulator?.type !== 'Identifier') return null + return { callback, accumulator, initialValue: owner.arguments[1] } + } + parent = parent.parent + } + return null +} + +function referencesAccumulator( + sourceCode: SourceCode, + node: ESTree.Node, + accumulator: Variable, + visited = new Set() +): boolean { + const variable = resolveArrayBinding(sourceCode, node) + if (variable === null || visited.has(variable)) return false + if (variable === accumulator) return true + visited.add(variable) + if (variable.references.some((reference) => reference.isWrite() && !reference.init)) return false + for (const definition of variable.defs) { + if ( + definition.type === 'Variable' && + definition.node.type === 'VariableDeclarator' && + definition.node.id.type === 'Identifier' && + definition.node.init !== null && + definition.node.parent.type === 'VariableDeclaration' && + definition.node.parent.kind === 'const' + ) { + return referencesAccumulator(sourceCode, definition.node.init, accumulator, visited) + } + } + return false +} + +function isGlobalCopyOwner(sourceCode: SourceCode, node: ESTree.Node, name: string): boolean { + node = unwrapArrayExpression(node) + if (node.type !== 'Identifier' || node.name !== name) return false + const variable = resolveArrayBinding(sourceCode, node) + return variable === null || variable.defs.length === 0 +} + +/** Reject non-spread copies of reducer accumulators; pair with oxc/no-accumulating-spread. */ +export const noReduceAccumulatorCopyRule = defineRule({ + meta: { + type: 'problem', + docs: { + description: + 'Disallow copying growing reducer accumulators with Object.assign, Array.from, or array copy methods.' + }, + messages: { + accumulatorCopy: + 'Do not copy the reducer accumulator on every iteration; growing copies can cause quadratic work. Mutate a fresh, locally owned accumulator and return it, or use an iterator pipeline/flatMap.' + } + }, + createOnce(context) { + return { + CallExpression(node) { + const method = arrayMethodTarget(node.callee) + if (method === null) return + const reducer = enclosingReducer(node) + if (reducer === null) return + const accumulator = context.sourceCode + .getDeclaredVariables(reducer.callback) + .find((variable) => + variable.identifiers.some( + (identifier) => identifier.start === reducer.accumulator.start + ) + ) + if (accumulator === undefined) return + const isAccumulator = (expression: ESTree.Node) => + referencesAccumulator(context.sourceCode, expression, accumulator) + let copiesAccumulator = false + if ( + method.name === 'assign' && + isGlobalCopyOwner(context.sourceCode, method.object, 'Object') + ) { + const target = node.arguments[0] + copiesAccumulator = + target !== undefined && + unwrapArrayExpression(target).type === 'ObjectExpression' && + node.arguments.slice(1).some(isAccumulator) + } else if ( + method.name === 'from' && + isGlobalCopyOwner(context.sourceCode, method.object, 'Array') + ) { + const source = node.arguments[0] + copiesAccumulator = source !== undefined && isAccumulator(source) + } else if ( + ['concat', 'slice', 'toSpliced', 'toSorted', 'toReversed', 'with'].includes(method.name) + ) { + const initialValue = reducer.initialValue + const arrayAccumulator = + initialValue !== undefined && isKnownArrayExpression(context.sourceCode, initialValue) + copiesAccumulator = arrayAccumulator && isAccumulator(method.object) + } + if (copiesAccumulator) context.report({ node, messageId: 'accumulatorCopy' }) + } + } + } +}) diff --git a/tools/lint/src/rules/quality/widen-then-assert.ts b/tools/lint/src/rules/quality/widen-then-assert.ts new file mode 100644 index 000000000..49dafdb13 --- /dev/null +++ b/tools/lint/src/rules/quality/widen-then-assert.ts @@ -0,0 +1,373 @@ +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' && unwrapped.properties.length > 0) + ) { + 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 nodeTypeAnnotation(node: ESTree.Node): ESTree.TSType | undefined { + if (!('typeAnnotation' in node)) return undefined + const annotation = node.typeAnnotation + return annotation?.type === 'TSTypeAnnotation' ? annotation.typeAnnotation : undefined +} + +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) + const declaredType = declarator === null ? undefined : nodeTypeAnnotation(declarator.id) + 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 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/tools/lint/src/support/array-method.ts b/tools/lint/src/support/array-method.ts new file mode 100644 index 000000000..466ef7f98 --- /dev/null +++ b/tools/lint/src/support/array-method.ts @@ -0,0 +1,107 @@ +import type { ESTree, Scope, SourceCode, Variable } from '@oxlint/plugins' + +/** Unwrap syntax-only wrappers when inspecting array methods and accumulator references. */ +export function unwrapArrayExpression(node: ESTree.Node): ESTree.Node { + while ( + node.type === 'ParenthesizedExpression' || + node.type === 'ChainExpression' || + node.type === 'TSAsExpression' || + node.type === 'TSTypeAssertion' || + node.type === 'TSNonNullExpression' || + node.type === 'TSSatisfiesExpression' + ) { + node = node.expression + } + return node +} + +/** Resolve a local binding by scope, not by identifier spelling. */ +export function resolveArrayBinding(sourceCode: SourceCode, node: ESTree.Node): Variable | null { + node = unwrapArrayExpression(node) + if (node.type !== 'Identifier') return null + let scope: Scope | null = sourceCode.getScope(node) + while (scope !== null) { + const variable = scope.set.get(node.name) + if (variable !== undefined) return variable + scope = scope.upper + } + return null +} + +/** Read static method names, including computed string literals, without evaluating expressions. */ +export function arrayMethodTarget( + node: ESTree.Node +): { readonly name: string; readonly object: ESTree.Node } | null { + node = unwrapArrayExpression(node) + if (node.type !== 'MemberExpression') return null + const property = node.property + if (!node.computed && property.type === 'Identifier') { + return { name: property.name, object: node.object } + } + if (node.computed && property.type === 'Literal' && typeof property.value === 'string') { + return { name: property.value, object: node.object } + } + return null +} + +function isArrayAnnotation(type: ESTree.TSType): boolean { + if (type.type === 'TSArrayType' || type.type === 'TSTupleType') return true + if (type.type === 'TSParenthesizedType') return isArrayAnnotation(type.typeAnnotation) + if (type.type === 'TSTypeOperator' && type.operator === 'readonly') { + return isArrayAnnotation(type.typeAnnotation) + } + return ( + type.type === 'TSTypeReference' && + type.typeName.type === 'Identifier' && + (type.typeName.name === 'Array' || type.typeName.name === 'ReadonlyArray') + ) +} + +/** Recognize local array evidence; unknown receivers and iterator pipelines are deliberately excluded. */ +export function isKnownArrayExpression( + sourceCode: SourceCode, + node: ESTree.Node, + visited = new Set() +): boolean { + node = unwrapArrayExpression(node) + if (node.type === 'ArrayExpression') return true + if (node.type === 'CallExpression') { + const method = arrayMethodTarget(node.callee) + return ( + method !== null && + [ + 'map', + 'filter', + 'flatMap', + 'slice', + 'concat', + 'toSorted', + 'toReversed', + 'toSpliced' + ].includes(method.name) && + isKnownArrayExpression(sourceCode, method.object, visited) + ) + } + if (node.type !== 'Identifier') return false + const variable = resolveArrayBinding(sourceCode, node) + if (variable === null || visited.has(variable)) return false + visited.add(variable) + if (variable.references.some((reference) => reference.isWrite() && !reference.init)) return false + for (const identifier of variable.identifiers) { + const annotation = identifier.typeAnnotation?.typeAnnotation + if (annotation !== undefined) return isArrayAnnotation(annotation) + } + for (const definition of variable.defs) { + if ( + definition.type === 'Variable' && + definition.node.type === 'VariableDeclarator' && + definition.node.id.type === 'Identifier' && + definition.node.init !== null && + definition.node.parent.type === 'VariableDeclaration' && + definition.node.parent.kind === 'const' + ) { + return isKnownArrayExpression(sourceCode, definition.node.init, visited) + } + } + return false +} diff --git a/tools/lint/src/support/dictionary-types.ts b/tools/lint/src/support/dictionary-types.ts new file mode 100644 index 000000000..527e82a86 --- /dev/null +++ b/tools/lint/src/support/dictionary-types.ts @@ -0,0 +1,497 @@ +import type { ESTree } from '@oxlint/plugins' + +import { + createTypeAliasEnvironment, + hasVisibleTypeBinding, + visibleTypeAlias, + type TypeAliasEnvironment as LexicalTypeAliasEnvironment +} from './type-alias-resolution.ts' + +const BUILT_INS = new Set([ + 'Record', + 'Readonly', + '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 interfaces: ReadonlyMap + readonly typeAliases: LexicalTypeAliasEnvironment +} + +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, + visitorKeys: Readonly> +): TypeEnvironment { + const interfaces = new Map() + + for (const statement of program.body) { + const declaration = declaredStatement(statement) + if (declaration?.type !== 'TSInterfaceDeclaration') continue + const declarations = interfaces.get(declaration.id.name) ?? [] + declarations.push(declaration) + interfaces.set(declaration.id.name, declarations) + } + + return { + interfaces, + typeAliases: createTypeAliasEnvironment(program, visitorKeys) + } +} + +function typeReferenceName(type: ESTree.TSTypeReference): string | null { + return type.typeName.type === 'Identifier' ? type.typeName.name : null +} + +function isBuiltIn(name: string, use: ESTree.Node, environment: TypeEnvironment): boolean { + return BUILT_INS.has(name) && !hasVisibleTypeBinding(name, use, environment.typeAliases) +} + +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, unwrapped, 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 = visibleTypeAlias(name, unwrapped, environment.typeAliases) + if (alias === null || resolvingAliases.has(name)) return null + const nextSubstitutions = aliasSubstitution(alias, unwrapped, substitutions) + if (nextSubstitutions === null) return null + const nextResolving = new Set(resolvingAliases) + 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, unwrapped, environment)) { + const wrapped = unwrapped.typeArguments?.params[0] + return wrapped === undefined + ? [] + : dictionaryValueTypes(wrapped, environment, substitutions, resolvingAliases) + } + + if (name === 'Record' && isBuiltIn(name, unwrapped, environment)) { + const value = unwrapped.typeArguments?.params[1] ?? null + return value === null ? [] : [{ type: value, substitutions }] + } + + if ((name === 'Pick' || name === 'Omit') && isBuiltIn(name, unwrapped, environment)) { + const source = unwrapped.typeArguments?.params[0] + return source === undefined + ? [] + : dictionaryValueTypes(source, environment, substitutions, resolvingAliases) + } + + const alias = visibleTypeAlias(name, unwrapped, environment.typeAliases) + if (alias === null || resolvingAliases.has(name)) return [] + const nextSubstitutions = aliasSubstitution(alias, unwrapped, substitutions) + if (nextSubstitutions === null) return [] + const nextResolving = new Set(resolvingAliases) + 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 +} + +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') { + if (unwrapped.members.some((member) => member.type === 'TSIndexSignature')) { + return { kind: 'open dictionary' } + } + return 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, unwrapped, environment)) { + const wrapped = unwrapped.typeArguments?.params[0] + return wrapped === undefined ? null : classifyWideningTarget(wrapped, environment) + } + if (name === 'Record' && isBuiltIn(name, unwrapped, environment)) { + return hasBroadRecordKey(unwrapped, environment, new Map()) ? { kind: 'open dictionary' } : null + } + const alias = visibleTypeAlias(name, unwrapped, environment.typeAliases) + if (alias === null) return null + if ((alias.typeParameters?.params.length ?? 0) > 0) { + const substitutions = aliasSubstitution(alias, unwrapped, new Map()) + const resolved = + substitutions === null + ? null + : classifyAliasBroadTarget( + alias.typeAnnotation, + environment, + substitutions, + new Set([name]) + ) + return resolved?.kind === 'open dictionary' ? { kind: 'generic container' } : null + } + const substitutions = aliasSubstitution(alias, unwrapped, new Map()) + if (substitutions === null) return null + const resolved = classifyAliasBroadTarget( + alias.typeAnnotation, + environment, + substitutions, + new Set([name]) + ) + return resolved +} + +function hasBroadRecordKey( + type: ESTree.TSTypeReference, + environment: TypeEnvironment, + substitutions: TypeAliasEnvironment +): boolean { + const key = type.typeArguments?.params[0] + return key === undefined || isBroadMappedKey(key, environment, substitutions) +} + +function isBroadMappedKey( + type: ESTree.TSType, + environment: TypeEnvironment, + substitutions: TypeAliasEnvironment, + visitedAliases: ReadonlySet = new Set() +): boolean { + const unwrapped = unwrapTransparentType(type) + if ( + unwrapped.type === 'TSStringKeyword' || + unwrapped.type === 'TSNumberKeyword' || + unwrapped.type === 'TSSymbolKeyword' + ) { + return true + } + if (unwrapped.type === 'TSUnionType') { + return unwrapped.types.some((member) => + isBroadMappedKey(member, environment, substitutions, visitedAliases) + ) + } + 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, visitedAliases) + } + if (name === 'PropertyKey' && isBuiltIn(name, unwrapped, environment)) return true + const alias = visibleTypeAlias(name, unwrapped, environment.typeAliases) + if ( + alias === null || + (alias.typeParameters?.params.length ?? 0) > 0 || + visitedAliases.has(name) + ) { + return false + } + const nextVisited = new Set(visitedAliases) + nextVisited.add(name) + return isBroadMappedKey(alias.typeAnnotation, environment, substitutions, nextVisited) +} + +function classifyAliasBroadTarget( + 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, unwrapped, environment)) { + const wrapped = unwrapped.typeArguments?.params[0] + return wrapped === undefined + ? null + : classifyAliasBroadTarget(wrapped, environment, substitutions, resolvingAliases) + } + if (name === 'Record' && isBuiltIn(name, unwrapped, environment)) { + return hasBroadRecordKey(unwrapped, environment, substitutions) + ? { kind: 'open dictionary' } + : null + } + const alias = visibleTypeAlias(name, unwrapped, environment.typeAliases) + if (alias === null || resolvingAliases.has(name)) return null + const nextSubstitutions = aliasSubstitution(alias, unwrapped, substitutions) + if (nextSubstitutions === null) return null + const nextResolving = new Set(resolvingAliases) + 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/tools/lint/src/support/function-parameters.ts b/tools/lint/src/support/function-parameters.ts new file mode 100644 index 000000000..f8e4a77ab --- /dev/null +++ b/tools/lint/src/support/function-parameters.ts @@ -0,0 +1,46 @@ +import type { ESTree, SourceCode } from '@oxlint/plugins' + +export type FunctionParameter = ESTree.ParamPattern + +/** Return whether a type is or contains TypeScript's absorbing unknown top type. */ +export function containsUnknownType(type: ESTree.TSType): boolean { + if (type.type === 'TSUnknownKeyword') return true + if (type.type === 'TSParenthesizedType') return containsUnknownType(type.typeAnnotation) + return type.type === 'TSUnionType' && type.types.some(containsUnknownType) +} + +/** Return the TypeScript annotation attached to a function parameter or its wrapped binding. */ +export function functionParameterTypeAnnotation( + parameter: FunctionParameter +): ESTree.TSTypeAnnotation | null | undefined { + if (parameter.type === 'TSParameterProperty') { + return functionParameterTypeAnnotation(parameter.parameter) + } + if (parameter.type === 'RestElement') { + return parameter.typeAnnotation ?? functionParameterTypeAnnotation(parameter.argument) + } + if (parameter.type === 'AssignmentPattern') { + return parameter.typeAnnotation ?? functionParameterTypeAnnotation(parameter.left) + } + return parameter.typeAnnotation +} + +/** Return only a function parameter's local binding, excluding its annotation and default value. */ +export function functionParameterBindingName( + parameter: FunctionParameter, + sourceCode: SourceCode +): string { + if (parameter.type === 'TSParameterProperty') { + return functionParameterBindingName(parameter.parameter, sourceCode) + } + if (parameter.type === 'AssignmentPattern') { + return functionParameterBindingName(parameter.left, sourceCode) + } + if (parameter.type === 'RestElement') { + return functionParameterBindingName(parameter.argument, sourceCode) + } + if (parameter.type === 'Identifier') return parameter.name + + const sourceText = sourceCode.getText(parameter) + return sourceText +} diff --git a/tools/lint/src/support/lexical-type-parameters.ts b/tools/lint/src/support/lexical-type-parameters.ts new file mode 100644 index 000000000..334b73bf4 --- /dev/null +++ b/tools/lint/src/support/lexical-type-parameters.ts @@ -0,0 +1,58 @@ +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/tools/lint/src/support/lint-test.ts b/tools/lint/src/support/lint-test.ts new file mode 100644 index 000000000..e42ee17a7 --- /dev/null +++ b/tools/lint/src/support/lint-test.ts @@ -0,0 +1,82 @@ +import { spawn } from 'node:child_process' +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { dirname, join, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' + +export interface Diagnostic { + code: string + filename: string + message: string +} + +interface LintResult { + diagnostics: Diagnostic[] +} + +const currentDirectory = dirname(fileURLToPath(import.meta.url)) +const temporaryDirectories: string[] = [] +const pluginPath = resolve(currentDirectory, '../../../../lint/plugin.js') +const oxlintPath = resolve(currentDirectory, '../../../../node_modules/.bin/oxlint') + +async function runOxlint( + arguments_: string[] +): Promise<{ stdout: string; stderr: string; code: number }> { + return await new Promise((resolveProcess, reject) => { + const child = spawn(oxlintPath, arguments_) + let stdout = '' + let stderr = '' + child.stdout.setEncoding('utf8').on('data', (chunk: string) => { + stdout += chunk + }) + child.stderr.setEncoding('utf8').on('data', (chunk: string) => { + stderr += chunk + }) + child.on('error', reject) + child.on('close', (code) => resolveProcess({ stdout, stderr, code: code ?? 1 })) + }) +} + +export async function lint( + source: string, + rules: Record, + relativePath = 'fixture.ts' +): Promise { + const directory = await mkdtemp(join(tmpdir(), 'open-pencil-lint-')) + temporaryDirectories.push(directory) + const sourcePath = join(directory, relativePath) + const configPath = join(directory, 'oxlint.json') + await mkdir(dirname(sourcePath), { recursive: true }) + await writeFile(sourcePath, source) + await writeFile( + configPath, + JSON.stringify({ + plugins: ['typescript', 'vue'], + jsPlugins: [pluginPath], + rules + }) + ) + + const { stdout, stderr, code } = await runOxlint([ + '-c', + configPath, + '--format', + 'json', + sourcePath + ]) + try { + return (JSON.parse(stdout) as LintResult).diagnostics + } catch { + throw new Error(`oxlint exited ${code} without JSON output.\nstderr: ${stderr}`) + } +} + +export function ruleDiagnostics(diagnostics: Diagnostic[], rule: string): Diagnostic[] { + return diagnostics.filter((diagnostic) => diagnostic.code === `open-pencil(${rule})`) +} + +export async function cleanupLintFixtures(): Promise { + await Promise.all( + temporaryDirectories.splice(0).map((directory) => rm(directory, { recursive: true })) + ) +} diff --git a/tools/lint/src/support/scope.ts b/tools/lint/src/support/scope.ts new file mode 100644 index 000000000..b42415f69 --- /dev/null +++ b/tools/lint/src/support/scope.ts @@ -0,0 +1,15 @@ +import type { ESTree, Scope, SourceCode, Variable } from '@oxlint/plugins' + +/** Resolve an identifier to its binding by walking lexical scopes upward. */ +export function resolveVariable( + sourceCode: SourceCode, + identifier: ESTree.IdentifierReference +): Variable | null { + let scope: Scope | null = sourceCode.getScope(identifier) + while (scope !== null) { + const variable = scope.set.get(identifier.name) + if (variable !== undefined) return variable + scope = scope.upper + } + return null +} diff --git a/tools/lint/src/support/type-alias-resolution.ts b/tools/lint/src/support/type-alias-resolution.ts new file mode 100644 index 000000000..e8f579815 --- /dev/null +++ b/tools/lint/src/support/type-alias-resolution.ts @@ -0,0 +1,241 @@ +import type { ESTree } from '@oxlint/plugins' + +import { lexicalTypeParameterNames } from './lexical-type-parameters.ts' + +type VisitorKeys = Readonly> +type TypeScope = ESTree.Node + +type TypeBinding = { + readonly alias: ESTree.TSTypeAliasDeclaration | null + readonly name: string + readonly scope: TypeScope +} + +type Substitution = { + readonly substitutions: Substitutions + readonly type: ESTree.TSType +} + +type Substitutions = ReadonlyMap + +export type TypeAliasEnvironment = { + readonly aliases: readonly ESTree.TSTypeAliasDeclaration[] + readonly bindingsByName: ReadonlyMap + readonly visitorKeys: VisitorKeys +} + +export type ResolvedTypeMatcher = ( + type: ESTree.TSType, + matches: (child: ESTree.TSType) => boolean +) => boolean + +const environmentsByProgram = new WeakMap() + +function isNode(value: unknown): value is ESTree.Node { + return ( + typeof value === 'object' && value !== null && 'type' in value && typeof value.type === 'string' + ) +} + +function enclosingTypeScope(node: ESTree.Node): TypeScope { + let current: ESTree.Node | null = node.parent + while (current !== null) { + if ( + current.type === 'Program' || + current.type === 'BlockStatement' || + current.type === 'TSModuleBlock' || + current.type === 'StaticBlock' || + current.type === 'SwitchStatement' + ) { + return current + } + current = current.parent + } + return node +} + +function declaredTypeBinding(node: ESTree.Node): { + readonly alias: ESTree.TSTypeAliasDeclaration | null + readonly name: string +} | null { + if (node.type === 'TSTypeAliasDeclaration') { + return { alias: node, name: node.id.name } + } + if ( + node.type === 'TSInterfaceDeclaration' || + node.type === 'TSEnumDeclaration' || + node.type === 'ClassDeclaration' || + node.type === 'ClassExpression' + ) { + return node.id === null ? null : { alias: null, name: node.id.name } + } + if ( + node.type === 'ImportSpecifier' || + node.type === 'ImportDefaultSpecifier' || + node.type === 'ImportNamespaceSpecifier' + ) { + return { alias: null, name: node.local.name } + } + return null +} + +function collectTypeBindings( + node: ESTree.Node, + visitorKeys: VisitorKeys, + bindingsByName: Map, + aliases: ESTree.TSTypeAliasDeclaration[] +): void { + const declared = declaredTypeBinding(node) + if (declared !== null) { + const bindings = bindingsByName.get(declared.name) ?? [] + bindings.push({ ...declared, scope: enclosingTypeScope(node) }) + bindingsByName.set(declared.name, bindings) + if (declared.alias !== null) aliases.push(declared.alias) + } + + // SAFETY: Oxlint's visitor keys identify only ESTree child-node properties. + const fields = node as unknown as Readonly> + for (const key of visitorKeys[node.type] ?? []) { + const value = fields[key] + if (isNode(value)) { + collectTypeBindings(value, visitorKeys, bindingsByName, aliases) + continue + } + if (!Array.isArray(value)) continue + for (const child of value) { + if (isNode(child)) { + collectTypeBindings(child, visitorKeys, bindingsByName, aliases) + } + } + } +} + +/** Collect every lexical type alias and competing type binding in a program. */ +export function createTypeAliasEnvironment( + program: ESTree.Program, + visitorKeys: VisitorKeys +): TypeAliasEnvironment { + const cached = environmentsByProgram.get(program) + if (cached !== undefined) return cached + const bindingsByName = new Map() + const aliases: ESTree.TSTypeAliasDeclaration[] = [] + collectTypeBindings(program, visitorKeys, bindingsByName, aliases) + const environment = { aliases, bindingsByName, visitorKeys } + environmentsByProgram.set(program, environment) + return environment +} + +function ancestorDistance(ancestor: ESTree.Node, node: ESTree.Node): number | null { + let current: ESTree.Node | null = node + let distance = 0 + while (current !== null) { + if (current === ancestor) return distance + current = current.parent + distance += 1 + } + return null +} + +function nearestTypeBindings( + name: string, + use: ESTree.Node, + environment: TypeAliasEnvironment +): readonly TypeBinding[] { + const candidates = environment.bindingsByName.get(name) ?? [] + let nearestDistance = Number.POSITIVE_INFINITY + let nearest: TypeBinding[] = [] + for (const candidate of candidates) { + const distance = ancestorDistance(candidate.scope, use) + if (distance === null || distance > nearestDistance) continue + if (distance === nearestDistance) { + nearest.push(candidate) + continue + } + nearestDistance = distance + nearest = [candidate] + } + return nearest +} + +/** Resolve the nearest visible alias with this name, respecting lexical shadowing. */ +export function visibleTypeAlias( + name: string, + use: ESTree.Node, + environment: TypeAliasEnvironment +): ESTree.TSTypeAliasDeclaration | null { + if (lexicalTypeParameterNames(use, environment.visitorKeys).has(name)) return null + const bindings = nearestTypeBindings(name, use, environment) + return bindings.length === 1 ? (bindings[0]?.alias ?? null) : null +} + +/** Return whether a local declaration shadows a built-in type at this use. */ +export function hasVisibleTypeBinding( + name: string, + use: ESTree.Node, + environment: TypeAliasEnvironment +): boolean { + return ( + lexicalTypeParameterNames(use, environment.visitorKeys).has(name) || + nearestTypeBindings(name, use, environment).length > 0 + ) +} + +function typeReferenceName(type: ESTree.TSTypeReference): string | null { + return type.typeName.type === 'Identifier' ? type.typeName.name : null +} + +function aliasSubstitutions( + alias: ESTree.TSTypeAliasDeclaration, + reference: ESTree.TSTypeReference, + base: Substitutions +): Substitutions | null { + const parameters = alias.typeParameters?.params ?? [] + const arguments_ = reference.typeArguments?.params ?? [] + const next = new Map(base) + for (const [index, parameter] of parameters.entries()) { + const explicitArgument = arguments_[index] + const argument = explicitArgument ?? parameter.default + if (argument === null || argument === undefined) return null + const argumentSubstitutions = explicitArgument === undefined ? next : base + next.set(parameter.name.name, { + type: argument, + substitutions: new Map(argumentSubstitutions) + }) + } + return next +} + +/** Match a type after resolving visible aliases and substituting their type parameters. */ +export function resolvedTypeMatches( + type: ESTree.TSType, + environment: TypeAliasEnvironment, + matcher: ResolvedTypeMatcher +): boolean { + const evaluate = ( + current: ESTree.TSType, + substitutions: Substitutions, + resolvingAliases: ReadonlySet + ): boolean => { + if (current.type === 'TSTypeReference') { + const name = typeReferenceName(current) + if (name !== null) { + const substitution = substitutions.get(name) + if (substitution !== undefined && !current.typeArguments?.params.length) { + return evaluate(substitution.type, substitution.substitutions, resolvingAliases) + } + const alias = visibleTypeAlias(name, current, environment) + if (alias !== null && !resolvingAliases.has(alias)) { + const nextSubstitutions = aliasSubstitutions(alias, current, substitutions) + if (nextSubstitutions !== null) { + const nextResolving = new Set(resolvingAliases) + nextResolving.add(alias) + return evaluate(alias.typeAnnotation, nextSubstitutions, nextResolving) + } + } + } + } + return matcher(current, (child) => evaluate(child, substitutions, resolvingAliases)) + } + + return evaluate(type, new Map(), new Set()) +} diff --git a/tools/lint/tests/known-value-widening.test.ts b/tools/lint/tests/known-value-widening.test.ts new file mode 100644 index 000000000..fcd58dce3 --- /dev/null +++ b/tools/lint/tests/known-value-widening.test.ts @@ -0,0 +1,26 @@ +import { afterEach, describe, expect, test } from 'bun:test' + +import { cleanupLintFixtures, lint, ruleDiagnostics } from '#lint/support/lint-test.ts' + +const rule = 'no-known-value-widening' +const rules = { [`open-pencil/${rule}`]: 'error' } + +afterEach(cleanupLintFixtures) + +describe('no-known-value-widening', () => { + test.each([ + 'const handlers: Record = { start: startHandler }', + 'const value: unknown = { id: "1" }', + 'function result(): object { return { id: "1" } }' + ])('identifies discarded local type evidence during audits: %s', async (source) => { + expect(ruleDiagnostics(await lint(source, rules), rule)).toHaveLength(1) + }) + + test.each([ + 'const handlers = { start: startHandler } satisfies Record', + 'declare const input: unknown; const value: unknown = input', + 'const accumulator: Record = {}' + ])('accepts preserved inference and genuine boundaries: %s', async (source) => { + expect(ruleDiagnostics(await lint(source, rules), rule)).toHaveLength(0) + }) +}) diff --git a/tools/lint/tests/module-mocking.test.ts b/tools/lint/tests/module-mocking.test.ts new file mode 100644 index 000000000..2e96cc319 --- /dev/null +++ b/tools/lint/tests/module-mocking.test.ts @@ -0,0 +1,32 @@ +import { afterEach, describe, expect, test } from 'bun:test' + +import { cleanupLintFixtures, lint, ruleDiagnostics } from '#lint/support/lint-test.ts' + +const rule = 'no-module-mocking' +const rules = { [`open-pencil/${rule}`]: 'error' } + +afterEach(cleanupLintFixtures) + +describe('no-module-mocking', () => { + test.each([ + "vi.mock('./store')", + "jest['doMock']('./store')", + "jest.unstable_mockModule('./store')", + "import { vi as testAPI } from 'vitest'; testAPI.mock('./store')", + "import { jest } from '@jest/globals'; jest.mock('./store')", + "import { mock } from 'bun:test'; mock.module('./store', () => ({}))", + "import { mock as bunMock } from 'bun:test'; bunMock['module']('./store', () => ({}))" + ])('rejects module registry mocking: %s', async (source) => { + expect(ruleDiagnostics(await lint(source, rules), rule)).toHaveLength(1) + }) + + test.each([ + "vi.spyOn(store, 'save')", + 'const vi = { mock() {} }; vi.mock()', + 'function test(jest: { mock(): void }) { jest.mock() }', + "import { vi as localVi } from './helpers'; localVi.mock('./store')", + 'const mock = { module() {} }; mock.module()' + ])('accepts scoped replacements and shadowed framework names: %s', async (source) => { + expect(ruleDiagnostics(await lint(source, rules), rule)).toHaveLength(0) + }) +}) diff --git a/tools/lint/tests/plugin.test.ts b/tools/lint/tests/plugin.test.ts index 828ff3e01..dd59c646c 100644 --- a/tools/lint/tests/plugin.test.ts +++ b/tools/lint/tests/plugin.test.ts @@ -5,12 +5,7 @@ import { dirname, join, resolve } from 'node:path' import lintPlugin from '#lint/plugin.ts' import { normalizedFilename } from '#lint/support/context.ts' - -interface Diagnostic { - code: string - filename: string - message: string -} +import type { Diagnostic } from '#lint/support/lint-test.ts' interface LintResult { diagnostics: Diagnostic[] diff --git a/tools/lint/tests/reduce-accumulator-copy.test.ts b/tools/lint/tests/reduce-accumulator-copy.test.ts new file mode 100644 index 000000000..0dcea3806 --- /dev/null +++ b/tools/lint/tests/reduce-accumulator-copy.test.ts @@ -0,0 +1,28 @@ +import { afterEach, describe, expect, test } from 'bun:test' + +import { cleanupLintFixtures, lint, ruleDiagnostics } from '#lint/support/lint-test.ts' + +const rule = 'no-reduce-accumulator-copy' +const rules = { [`open-pencil/${rule}`]: 'error' } + +afterEach(cleanupLintFixtures) + +describe('no-reduce-accumulator-copy', () => { + test.each([ + 'items.reduce((acc, item) => acc.concat([item]), [])', + 'items.reduce((acc, item) => { const next = acc.slice(); next.push(item); return next }, [])', + 'items.reduce((acc, item) => Object.assign({}, acc, { [item.id]: item }), {})', + 'items.reduce((acc, item) => Array.from(acc), [])' + ])('rejects copying a growing accumulator: %s', async (source) => { + expect(ruleDiagnostics(await lint(source, rules), rule)).toHaveLength(1) + }) + + test.each([ + 'items.reduce((acc, item) => { acc.push(item); return acc }, [])', + 'items.reduce((acc, item) => Object.assign(acc, { [item.id]: item }), {})', + 'items.reduce((acc, item) => item.slice(), [])', + 'items.reduce(namedReducer, [])' + ])('accepts owned mutation and unrelated copies: %s', async (source) => { + expect(ruleDiagnostics(await lint(source, rules), rule)).toHaveLength(0) + }) +}) diff --git a/tools/lint/tests/widen-then-assert.test.ts b/tools/lint/tests/widen-then-assert.test.ts new file mode 100644 index 000000000..4c882a0e0 --- /dev/null +++ b/tools/lint/tests/widen-then-assert.test.ts @@ -0,0 +1,28 @@ +import { afterEach, describe, expect, test } from 'bun:test' + +import { cleanupLintFixtures, lint, ruleDiagnostics } from '#lint/support/lint-test.ts' + +const rule = 'no-widen-then-assert' +const rules = { [`open-pencil/${rule}`]: 'error' } + +afterEach(cleanupLintFixtures) + +describe('no-widen-then-assert', () => { + test.each([ + 'const source: User = loadUser(); const widened: unknown = source; const result = widened as User', + 'const source = { id: "1" }; const widened = source as object; const result = widened as { id: string }', + 'const source = { id: "1" }; const widened: Record = source; const result = widened as Record' + ])('rejects erasing and recreating local type evidence: %s', async (source) => { + expect(ruleDiagnostics(await lint(source, rules), rule)).toHaveLength(1) + }) + + test.each([ + 'declare const input: unknown; const result = input as User', + 'const empty: Record = {}; const result = empty as Record', + 'const source: User = loadUser(); const result = source', + 'let widened: unknown = loadUser(); widened = loadOther(); const result = widened as User', + 'function parse(input: unknown) { return input as User }' + ])('accepts genuine boundaries and flows that retain evidence: %s', async (source) => { + expect(ruleDiagnostics(await lint(source, rules), rule)).toHaveLength(0) + }) +}) diff --git a/tools/lint/upstream/anti-slop-license.txt b/tools/lint/upstream/anti-slop-license.txt new file mode 100644 index 000000000..69239ead1 --- /dev/null +++ b/tools/lint/upstream/anti-slop-license.txt @@ -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/tools/lint/upstream/anti-slop.md b/tools/lint/upstream/anti-slop.md new file mode 100644 index 000000000..57e18b0c8 --- /dev/null +++ b/tools/lint/upstream/anti-slop.md @@ -0,0 +1,28 @@ +# Externally inspired lint rules + +The following rules and their support helpers are adapted from [`dmmulroy/anti-slop`](https://github.com/dmmulroy/anti-slop), retrieved at commit `c44ef22ca116d0ba62a3ff663a0bd13a3f3fa40b`: + +- `src/rules/quality/known-value-widening.ts` +- `src/rules/quality/module-mocking.ts` +- `src/rules/quality/reduce-accumulator-copy.ts` +- `src/rules/quality/widen-then-assert.ts` +- `src/support/{array-method,dictionary-types,function-parameters,lexical-type-parameters,scope,type-alias-resolution}.ts` + +Upstream is MIT licensed. See `anti-slop-license.txt` in this directory for the license text. Local changes include OpenPencil plugin registration, Bun `mock.module` support, compatibility with the repository's TypeScript/Oxlint versions, and focused Bun tests. + +## Maintenance + +Treat these files as locally owned policy. When updating from upstream: + +1. Compare against the recorded commit instead of replacing the directory wholesale. +2. Review semantic and false-positive changes rule by rule. +3. Preserve OpenPencil-specific behavior and test cases. +4. Update the commit above and run `bun run --cwd tools/lint test` plus `bun run lint`. + +## Enabled rules + +- `no-module-mocking` +- `no-reduce-accumulator-copy` +- `no-widen-then-assert` + +`no-known-value-widening` is registered for audit use but intentionally not enabled. A repository-wide audit at the recorded revision produced 261 diagnostics: 117 anonymous-object targets, 114 open-dictionary targets, and 30 explicit `unknown` targets. Many are intentional return contracts, mutable dictionaries, serialization boundaries, and type-predicate calls, so enabling the upstream policy globally would create substantial noise. It may be reconsidered as narrower OpenPencil-specific rules.