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:
Danila Poyarkov 2026-09-11 11:57:28 +03:00
parent 9d4fe4e421
commit 4317a352a0
22 changed files with 2214 additions and 7 deletions

View file

@ -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=="],

View file

@ -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",

View file

@ -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",

View file

@ -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
}
}

View 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'
)
}
}
}
})

View 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' })
}
}
}
}
})

View 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' })
}
}
}
})

View 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
}
}
})

View 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
}

View 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'
)
}

View 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
}

View 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
}

View 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 }))
)
}

View 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
}

View 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())
}

View 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)
})
})

View 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)
})
})

View file

@ -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[]

View 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)
})
})

View 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)
})
})

View 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.

View 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.