feat(lint): reject low-evidence test and type patterns
Prevent module registry mocking, repeated reducer accumulator copies, and local widen-then-assert flows. Keep known-value widening available for audits without enabling its noisy global policy, and record upstream provenance for the adapted rules.
This commit is contained in:
parent
9d4fe4e421
commit
4317a352a0
3
bun.lock
3
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=="],
|
||||
|
|
|
|||
23
oxlint.json
23
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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
394
tools/lint/src/rules/quality/known-value-widening.ts
Normal file
394
tools/lint/src/rules/quality/known-value-widening.ts
Normal file
|
|
@ -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<Variable>()
|
||||
): boolean {
|
||||
if (isKnownEvidenceExpression(expression)) return true
|
||||
const unwrapped = unwrapExpression(expression)
|
||||
if (unwrapped.type !== 'Identifier') return false
|
||||
const variable = resolveVariable(sourceCode, unwrapped)
|
||||
if (variable === null || visitedVariables.has(variable)) return false
|
||||
const declarator = variableDeclarator(variable)
|
||||
if (
|
||||
declarator === null ||
|
||||
declarator.init === null ||
|
||||
!isStableConstVariable(variable, declarator)
|
||||
) {
|
||||
return false
|
||||
}
|
||||
visitedVariables.add(variable)
|
||||
return hasKnownEvidence(sourceCode, declarator.init, visitedVariables)
|
||||
}
|
||||
|
||||
function 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<Variable>()
|
||||
): 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'
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
72
tools/lint/src/rules/quality/module-mocking.ts
Normal file
72
tools/lint/src/rules/quality/module-mocking.ts
Normal file
|
|
@ -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' })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
131
tools/lint/src/rules/quality/reduce-accumulator-copy.ts
Normal file
131
tools/lint/src/rules/quality/reduce-accumulator-copy.ts
Normal file
|
|
@ -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<Variable>()
|
||||
): 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' })
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
373
tools/lint/src/rules/quality/widen-then-assert.ts
Normal file
373
tools/lint/src/rules/quality/widen-then-assert.ts
Normal file
|
|
@ -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<typeof resolvedVariableForIdentifier>[0],
|
||||
boundary: ESTree.Node | null,
|
||||
visitedVariables: ReadonlySet<Variable>
|
||||
): KnownValueEvidence | null {
|
||||
const unwrapped = unwrapExpressionParentheses(expression)
|
||||
|
||||
if (unwrapped.type === 'TSAsExpression' || unwrapped.type === 'TSTypeAssertion') {
|
||||
if (broadTypeKind(unwrapped.typeAnnotation) !== null) return null
|
||||
return { type: unwrapped.typeAnnotation }
|
||||
}
|
||||
|
||||
if (unwrapped.type === 'Literal' || unwrapped.type === 'TemplateLiteral') {
|
||||
return { type: null }
|
||||
}
|
||||
|
||||
if (
|
||||
unwrapped.type === 'ArrayExpression' ||
|
||||
unwrapped.type === 'ArrowFunctionExpression' ||
|
||||
unwrapped.type === 'ClassExpression' ||
|
||||
unwrapped.type === 'FunctionExpression' ||
|
||||
unwrapped.type === 'NewExpression' ||
|
||||
(unwrapped.type === 'ObjectExpression' && 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<typeof resolvedVariableForIdentifier>[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<typeof resolvedVariableForIdentifier>[0] = []
|
||||
|
||||
const checkAssertion = (node: ESTree.TSAsExpression | ESTree.TSTypeAssertion) => {
|
||||
const expression = assertedExpression(node)
|
||||
if (expression.type !== 'Identifier') return
|
||||
|
||||
const variable = resolvedVariableForIdentifier(scopes, expression)
|
||||
if (variable === null) return
|
||||
const widened = widenedBinding(variable, scopes)
|
||||
if (
|
||||
widened === null ||
|
||||
node.start <= widened.declaredAt ||
|
||||
functionBoundary(node) !== widened.boundary ||
|
||||
!assertionIsNarrower(
|
||||
context.sourceCode.text,
|
||||
widened.broadKind,
|
||||
widened.evidence,
|
||||
node.typeAnnotation
|
||||
)
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
context.report({
|
||||
node,
|
||||
messageId: 'widenThenAssert',
|
||||
data: { name: expression.name }
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
Program() {
|
||||
scopes = context.sourceCode.scopeManager.scopes
|
||||
},
|
||||
TSAsExpression: checkAssertion,
|
||||
TSTypeAssertion: checkAssertion
|
||||
}
|
||||
}
|
||||
})
|
||||
107
tools/lint/src/support/array-method.ts
Normal file
107
tools/lint/src/support/array-method.ts
Normal file
|
|
@ -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<Variable>()
|
||||
): 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
|
||||
}
|
||||
497
tools/lint/src/support/dictionary-types.ts
Normal file
497
tools/lint/src/support/dictionary-types.ts
Normal file
|
|
@ -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<string, ESTree.TSType>
|
||||
|
||||
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<string, readonly ESTree.TSInterfaceDeclaration[]>
|
||||
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<Record<string, readonly string[]>>
|
||||
): TypeEnvironment {
|
||||
const interfaces = new Map<string, ESTree.TSInterfaceDeclaration[]>()
|
||||
|
||||
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<string> = 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<string>
|
||||
): 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<string>
|
||||
): 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<string> = 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<string>
|
||||
): 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'
|
||||
)
|
||||
}
|
||||
46
tools/lint/src/support/function-parameters.ts
Normal file
46
tools/lint/src/support/function-parameters.ts
Normal file
|
|
@ -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
|
||||
}
|
||||
58
tools/lint/src/support/lexical-type-parameters.ts
Normal file
58
tools/lint/src/support/lexical-type-parameters.ts
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
import type { ESTree } from '@oxlint/plugins'
|
||||
|
||||
type VisitorKeys = Readonly<Record<string, readonly string[]>>
|
||||
|
||||
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<string>
|
||||
): void {
|
||||
if (node.type === 'TSInferType') names.add(node.typeParameter.name.name)
|
||||
const record = node as unknown as Readonly<Record<string, unknown>>
|
||||
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<string> {
|
||||
const names = new Set<string>()
|
||||
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
|
||||
}
|
||||
82
tools/lint/src/support/lint-test.ts
Normal file
82
tools/lint/src/support/lint-test.ts
Normal file
|
|
@ -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<string, string>,
|
||||
relativePath = 'fixture.ts'
|
||||
): Promise<Diagnostic[]> {
|
||||
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<void> {
|
||||
await Promise.all(
|
||||
temporaryDirectories.splice(0).map((directory) => rm(directory, { recursive: true }))
|
||||
)
|
||||
}
|
||||
15
tools/lint/src/support/scope.ts
Normal file
15
tools/lint/src/support/scope.ts
Normal file
|
|
@ -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
|
||||
}
|
||||
241
tools/lint/src/support/type-alias-resolution.ts
Normal file
241
tools/lint/src/support/type-alias-resolution.ts
Normal file
|
|
@ -0,0 +1,241 @@
|
|||
import type { ESTree } from '@oxlint/plugins'
|
||||
|
||||
import { lexicalTypeParameterNames } from './lexical-type-parameters.ts'
|
||||
|
||||
type VisitorKeys = Readonly<Record<string, readonly string[]>>
|
||||
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<string, Substitution>
|
||||
|
||||
export type TypeAliasEnvironment = {
|
||||
readonly aliases: readonly ESTree.TSTypeAliasDeclaration[]
|
||||
readonly bindingsByName: ReadonlyMap<string, readonly TypeBinding[]>
|
||||
readonly visitorKeys: VisitorKeys
|
||||
}
|
||||
|
||||
export type ResolvedTypeMatcher = (
|
||||
type: ESTree.TSType,
|
||||
matches: (child: ESTree.TSType) => boolean
|
||||
) => boolean
|
||||
|
||||
const environmentsByProgram = new WeakMap<ESTree.Program, TypeAliasEnvironment>()
|
||||
|
||||
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<string, TypeBinding[]>,
|
||||
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<Record<string, unknown>>
|
||||
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<string, TypeBinding[]>()
|
||||
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<ESTree.TSTypeAliasDeclaration>
|
||||
): 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())
|
||||
}
|
||||
26
tools/lint/tests/known-value-widening.test.ts
Normal file
26
tools/lint/tests/known-value-widening.test.ts
Normal file
|
|
@ -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<string, Handler> = { 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<string, Handler>',
|
||||
'declare const input: unknown; const value: unknown = input',
|
||||
'const accumulator: Record<string, Handler> = {}'
|
||||
])('accepts preserved inference and genuine boundaries: %s', async (source) => {
|
||||
expect(ruleDiagnostics(await lint(source, rules), rule)).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
32
tools/lint/tests/module-mocking.test.ts
Normal file
32
tools/lint/tests/module-mocking.test.ts
Normal file
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
@ -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[]
|
||||
|
|
|
|||
28
tools/lint/tests/reduce-accumulator-copy.test.ts
Normal file
28
tools/lint/tests/reduce-accumulator-copy.test.ts
Normal file
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
28
tools/lint/tests/widen-then-assert.test.ts
Normal file
28
tools/lint/tests/widen-then-assert.test.ts
Normal file
|
|
@ -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<string, unknown> = source; const result = widened as Record<string, string>'
|
||||
])('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<string, unknown> = {}; const result = empty as Record<string, string>',
|
||||
'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)
|
||||
})
|
||||
})
|
||||
21
tools/lint/upstream/anti-slop-license.txt
Normal file
21
tools/lint/upstream/anti-slop-license.txt
Normal file
|
|
@ -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.
|
||||
28
tools/lint/upstream/anti-slop.md
Normal file
28
tools/lint/upstream/anti-slop.md
Normal file
|
|
@ -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.
|
||||
Loading…
Reference in a new issue