refactor: narrow lint policies and consolidate their support
Remove the disabled widening audit and its resolver. Fix Bun aliases and bounded reducer slices, reject parser failures in shared fixtures, and retain upstream attribution in NOTICE.
This commit is contained in:
parent
c274fecaa8
commit
f2bfaba287
8
bun.lock
8
bun.lock
|
|
@ -388,6 +388,12 @@
|
|||
"tools/docs": {
|
||||
"name": "@open-pencil/docs-tools",
|
||||
},
|
||||
"tools/lint": {
|
||||
"name": "@open-pencil/lint-tools",
|
||||
"devDependencies": {
|
||||
"@open-pencil/package-artifacts": "workspace:*",
|
||||
},
|
||||
},
|
||||
"tools/package-artifacts": {
|
||||
"name": "@open-pencil/package-artifacts",
|
||||
"dependencies": {
|
||||
|
|
@ -1192,6 +1198,8 @@
|
|||
|
||||
"@open-pencil/kiwi": ["@open-pencil/kiwi@workspace:packages/kiwi"],
|
||||
|
||||
"@open-pencil/lint-tools": ["@open-pencil/lint-tools@workspace:tools/lint"],
|
||||
|
||||
"@open-pencil/mcp": ["@open-pencil/mcp@workspace:packages/mcp"],
|
||||
|
||||
"@open-pencil/package-artifacts": ["@open-pencil/package-artifacts@workspace:tools/package-artifacts"],
|
||||
|
|
|
|||
14
oxlint.json
14
oxlint.json
|
|
@ -421,23 +421,11 @@
|
|||
},
|
||||
"overrides": [
|
||||
{
|
||||
"files": [
|
||||
"tools/lint/src/rules/quality/widen-then-assert.ts",
|
||||
"tools/lint/src/support/dictionary-types.ts"
|
||||
],
|
||||
"files": ["tools/lint/src/rules/quality/widen-then-assert.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",
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@
|
|||
"packages/harness",
|
||||
"packages/docs",
|
||||
"tools/docs",
|
||||
"tools/lint",
|
||||
"tools/package-artifacts",
|
||||
"tools/authoring-reference",
|
||||
"tools/package-quality",
|
||||
|
|
|
|||
|
|
@ -65,7 +65,7 @@ const scriptsAreEntrypointShims = createTextRule(
|
|||
)
|
||||
|
||||
const TOOL_LAYOUT_MESSAGE =
|
||||
'Tool files must live under tools/<domain>/src/** or tools/<domain>/tests/*.test.ts.'
|
||||
'Tool files must live under tools/<domain>/src/**, tests/**/*.test.ts, or tests/helpers/**.'
|
||||
|
||||
const strictToolsLayout = createFileRule('open-pencil/strict-tools-layout', (sourceRel) => {
|
||||
if (!sourceRel.startsWith('tools/') || !TEXT_EXTENSIONS.has(path.extname(sourceRel))) return null
|
||||
|
|
@ -74,7 +74,11 @@ const strictToolsLayout = createFileRule('open-pencil/strict-tools-layout', (sou
|
|||
if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(domain))
|
||||
return 'Tool package folders must use kebab-case domain names.'
|
||||
if (segment === 'src') return null
|
||||
if (segment === 'tests' && sourceRel.endsWith('.test.ts')) return null
|
||||
if (
|
||||
segment === 'tests' &&
|
||||
(sourceRel.endsWith('.test.ts') || sourceRel.startsWith(`tools/${domain}/tests/helpers/`))
|
||||
)
|
||||
return null
|
||||
return TOOL_LAYOUT_MESSAGE
|
||||
})
|
||||
|
||||
|
|
|
|||
28
tools/architecture/tests/tool-layout.test.ts
Normal file
28
tools/architecture/tests/tool-layout.test.ts
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
import { expect, test } from 'bun:test'
|
||||
import { join } from 'node:path'
|
||||
|
||||
import { openPencilArchitecturePlugin } from '../src/steiger-rules/index'
|
||||
|
||||
test('keeps tool test helpers beside tests without allowing arbitrary tool locations', () => {
|
||||
const rule = openPencilArchitecturePlugin.ruleDefinitions.find(
|
||||
(rule) => rule.name === 'open-pencil/strict-tools-layout'
|
||||
)
|
||||
if (!rule) throw new Error('Missing tool layout rule')
|
||||
for (const [file, valid] of [
|
||||
['tools/lint/src/plugin.ts', true],
|
||||
['tools/lint/tests/example.test.ts', true],
|
||||
['tools/lint/tests/helpers/lint.ts', true],
|
||||
['tools/lint/tests/lint.ts', false],
|
||||
['tools/lint/helpers/lint.ts', false],
|
||||
['tools/lint/plugin.ts', false]
|
||||
] as const) {
|
||||
const root = process.cwd()
|
||||
expect(
|
||||
rule.check({
|
||||
type: 'folder',
|
||||
path: root,
|
||||
children: [{ type: 'file', path: join(root, file) }]
|
||||
}).diagnostics.length
|
||||
).toBe(valid ? 0 : 1)
|
||||
}
|
||||
})
|
||||
|
|
@ -1,3 +1,12 @@
|
|||
The module-mocking, reduce-accumulator-copy, and widen-then-assert rules,
|
||||
and their array-method and scope helpers, adapt code from dmmulroy/anti-slop:
|
||||
https://github.com/dmmulroy/anti-slop
|
||||
Commit: c44ef22ca116d0ba62a3ff663a0bd13a3f3fa40b
|
||||
|
||||
These files are maintained locally for OpenPencil. Adaptations include Bun
|
||||
module-mocking aliases, bounded-slice exclusions, shared scope lookup, and
|
||||
integration with OpenPencil's Oxlint plugin. The upstream notice follows.
|
||||
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 Dillon Mulroy
|
||||
17
tools/lint/README.md
Normal file
17
tools/lint/README.md
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
# OpenPencil lint rules
|
||||
|
||||
`src/plugin.ts` registers locally owned rules; the workspace `oxlint.json` selects policy. Run `bun run --cwd tools/lint test` for rule tests and `bun run lint` for repository enforcement.
|
||||
|
||||
## Test and type policies
|
||||
|
||||
- `no-module-mocking` rejects Bun, Vitest, and Jest module registry mocking, including Bun's `mock` and `jest` exports and Vitest's `vi` and `vitest` exports. Scoped spies and injected dependencies remain supported. Detection covers direct framework calls and renamed named imports, not arbitrary alias propagation or destructuring.
|
||||
- `no-reduce-accumulator-copy` detects unbounded accumulator copies through `Object.assign`, `Array.from`, and array copy methods. It complements `oxc/no-accumulating-spread`. Literal-bounded slices such as `slice(0, 2)` and `slice(-2)` are allowed; `slice(2)` and `slice(0, -1)` still copy a potentially growing range. This is a conservative syntactic check, not a proof of quadratic runtime for every reducer.
|
||||
- `no-widen-then-assert` detects immutable local bindings widened to broad types and then asserted narrower in the same function. It intentionally avoids inferring arbitrary type aliases, imported return types, or cross-function flows.
|
||||
|
||||
The experimental known-value-widening audit is not included: its broad policy produced too many intentional-contract diagnostics to justify maintaining a separate type resolver.
|
||||
|
||||
## Maintenance
|
||||
|
||||
Upstream attribution and the full license are in [NOTICE](./NOTICE). These are adapted, locally owned rules, not an automatically synchronized vendor directory. Compare any upstream updates against the recorded commit, preserve local behavior, and add positive and negative regression cases before changing enforcement.
|
||||
|
||||
Tests share `tests/helpers/lint.ts`, which invokes the real Oxlint plugin, rejects parser/configuration failures, and cleans each fixture in `finally`. Keep test infrastructure out of runtime source helpers.
|
||||
|
|
@ -7,5 +7,8 @@
|
|||
},
|
||||
"scripts": {
|
||||
"test": "tsc --noEmit -p tsconfig.json && bun test tests"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@open-pencil/package-artifacts": "workspace:*"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,7 +3,6 @@ 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'
|
||||
|
|
@ -493,7 +492,6 @@ const plugin = {
|
|||
'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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,394 +0,0 @@
|
|||
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'
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
|
@ -26,9 +26,9 @@ function isTestFrameworkObject(
|
|||
const source = definition.parent.source.value
|
||||
const name = importedName(definition.node)
|
||||
return (
|
||||
(source === 'vitest' && name === 'vi') ||
|
||||
(source === 'vitest' && (name === 'vi' || name === 'vitest')) ||
|
||||
(source === '@jest/globals' && name === 'jest') ||
|
||||
(source === 'bun:test' && name === 'mock')
|
||||
(source === 'bun:test' && (name === 'mock' || name === 'jest'))
|
||||
)
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -70,7 +70,25 @@ function isGlobalCopyOwner(sourceCode: SourceCode, node: ESTree.Node, name: stri
|
|||
return variable === null || variable.defs.length === 0
|
||||
}
|
||||
|
||||
/** Reject non-spread copies of reducer accumulators; pair with oxc/no-accumulating-spread. */
|
||||
function numericLiteral(node: ESTree.Node | undefined): number | null {
|
||||
if (node?.type === 'Literal' && typeof node.value === 'number') return node.value
|
||||
if (node?.type === 'UnaryExpression' && node.operator === '-') {
|
||||
const value = numericLiteral(node.argument)
|
||||
return value === null ? null : -value
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function isBoundedSlice(node: ESTree.CallExpression): boolean {
|
||||
const start = numericLiteral(node.arguments[0])
|
||||
const end = numericLiteral(node.arguments[1])
|
||||
return (
|
||||
(start !== null && Number.isFinite(start) && start < 0) ||
|
||||
(end !== null && Number.isFinite(end) && end >= 0)
|
||||
)
|
||||
}
|
||||
|
||||
/** Reject unbounded copies of reducer accumulators; pair with oxc/no-accumulating-spread. */
|
||||
export const noReduceAccumulatorCopyRule = defineRule({
|
||||
meta: {
|
||||
type: 'problem',
|
||||
|
|
@ -122,7 +140,10 @@ export const noReduceAccumulatorCopyRule = defineRule({
|
|||
const initialValue = reducer.initialValue
|
||||
const arrayAccumulator =
|
||||
initialValue !== undefined && isKnownArrayExpression(context.sourceCode, initialValue)
|
||||
copiesAccumulator = arrayAccumulator && isAccumulator(method.object)
|
||||
copiesAccumulator =
|
||||
arrayAccumulator &&
|
||||
isAccumulator(method.object) &&
|
||||
!(method.name === 'slice' && isBoundedSlice(node))
|
||||
}
|
||||
if (copiesAccumulator) context.report({ node, messageId: 'accumulatorCopy' })
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { resolveVariable } from '#lint/support/scope.ts'
|
||||
import { defineRule } from '@oxlint/plugins'
|
||||
import type { ESTree, Variable } from '@oxlint/plugins'
|
||||
import type { ESTree, SourceCode, Variable } from '@oxlint/plugins'
|
||||
|
||||
type BroadTypeKind = 'top' | 'object' | 'record'
|
||||
|
||||
|
|
@ -168,26 +169,6 @@ function functionBoundary(node: ESTree.Node): ESTree.Node | null {
|
|||
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') {
|
||||
|
|
@ -199,7 +180,7 @@ function variableDeclarator(variable: Variable): ESTree.VariableDeclarator | nul
|
|||
|
||||
function knownValueEvidence(
|
||||
expression: ESTree.Expression,
|
||||
scopes: Parameters<typeof resolvedVariableForIdentifier>[0],
|
||||
sourceCode: SourceCode,
|
||||
boundary: ESTree.Node | null,
|
||||
visitedVariables: ReadonlySet<Variable>
|
||||
): KnownValueEvidence | null {
|
||||
|
|
@ -226,7 +207,7 @@ function knownValueEvidence(
|
|||
}
|
||||
|
||||
if (unwrapped.type !== 'Identifier') return null
|
||||
const variable = resolvedVariableForIdentifier(scopes, unwrapped)
|
||||
const variable = resolveVariable(sourceCode, unwrapped)
|
||||
if (variable === null || visitedVariables.has(variable)) return null
|
||||
|
||||
const annotatedIdentifier = variable.identifiers.find(
|
||||
|
|
@ -254,7 +235,7 @@ function knownValueEvidence(
|
|||
|
||||
return knownValueEvidence(
|
||||
declarator.init,
|
||||
scopes,
|
||||
sourceCode,
|
||||
boundary,
|
||||
new Set([...visitedVariables, variable])
|
||||
)
|
||||
|
|
@ -268,7 +249,7 @@ function nodeTypeAnnotation(node: ESTree.Node): ESTree.TSType | undefined {
|
|||
|
||||
function widenedBinding(
|
||||
variable: Variable,
|
||||
scopes: Parameters<typeof resolvedVariableForIdentifier>[0]
|
||||
sourceCode: SourceCode
|
||||
): {
|
||||
readonly broadKind: BroadTypeKind
|
||||
readonly evidence: KnownValueEvidence
|
||||
|
|
@ -301,7 +282,7 @@ function widenedBinding(
|
|||
initializerAssertion !== null && initializerBroadKind !== null
|
||||
? assertedExpression(initializerAssertion)
|
||||
: declarator.init
|
||||
const evidence = knownValueEvidence(originalExpression, scopes, boundary, new Set([variable]))
|
||||
const evidence = knownValueEvidence(originalExpression, sourceCode, boundary, new Set([variable]))
|
||||
return evidence === null ? null : { broadKind, evidence, declaredAt: declarator.end, boundary }
|
||||
}
|
||||
|
||||
|
|
@ -332,15 +313,13 @@ export const noWidenThenAssertRule = defineRule({
|
|||
}
|
||||
},
|
||||
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)
|
||||
const variable = resolveVariable(context.sourceCode, expression)
|
||||
if (variable === null) return
|
||||
const widened = widenedBinding(variable, scopes)
|
||||
const widened = widenedBinding(variable, context.sourceCode)
|
||||
if (
|
||||
widened === null ||
|
||||
node.start <= widened.declaredAt ||
|
||||
|
|
@ -363,9 +342,6 @@ export const noWidenThenAssertRule = defineRule({
|
|||
}
|
||||
|
||||
return {
|
||||
Program() {
|
||||
scopes = context.sourceCode.scopeManager.scopes
|
||||
},
|
||||
TSAsExpression: checkAssertion,
|
||||
TSTypeAssertion: checkAssertion
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,6 @@
|
|||
import type { ESTree, Scope, SourceCode, Variable } from '@oxlint/plugins'
|
||||
import type { ESTree, SourceCode, Variable } from '@oxlint/plugins'
|
||||
|
||||
import { resolveVariable } from './scope.ts'
|
||||
|
||||
/** Unwrap syntax-only wrappers when inspecting array methods and accumulator references. */
|
||||
export function unwrapArrayExpression(node: ESTree.Node): ESTree.Node {
|
||||
|
|
@ -19,13 +21,7 @@ export function unwrapArrayExpression(node: ESTree.Node): ESTree.Node {
|
|||
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
|
||||
return resolveVariable(sourceCode, node)
|
||||
}
|
||||
|
||||
/** Read static method names, including computed string literals, without evaluating expressions. */
|
||||
|
|
|
|||
|
|
@ -1,497 +0,0 @@
|
|||
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'
|
||||
)
|
||||
}
|
||||
|
|
@ -1,46 +0,0 @@
|
|||
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
|
||||
}
|
||||
|
|
@ -1,58 +0,0 @@
|
|||
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
|
||||
}
|
||||
|
|
@ -1,82 +0,0 @@
|
|||
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 }))
|
||||
)
|
||||
}
|
||||
|
|
@ -3,7 +3,7 @@ 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
|
||||
identifier: Extract<ESTree.Node, { type: 'Identifier' }>
|
||||
): Variable | null {
|
||||
let scope: Scope | null = sourceCode.getScope(identifier)
|
||||
while (scope !== null) {
|
||||
|
|
|
|||
|
|
@ -1,241 +0,0 @@
|
|||
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())
|
||||
}
|
||||
62
tools/lint/tests/helpers/lint.ts
Normal file
62
tools/lint/tests/helpers/lint.ts
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { dirname, join } from 'node:path'
|
||||
|
||||
import * as v from 'valibot'
|
||||
|
||||
import { resolveWorkspaceRoot } from '@open-pencil/package-artifacts'
|
||||
|
||||
const diagnosticSchema = v.object({
|
||||
code: v.optional(v.string()),
|
||||
filename: v.optional(v.string()),
|
||||
message: v.string(),
|
||||
severity: v.picklist(['error', 'warning', 'advice'])
|
||||
})
|
||||
const resultSchema = v.object({ diagnostics: v.array(diagnosticSchema) })
|
||||
export type Diagnostic = v.InferOutput<typeof diagnosticSchema>
|
||||
|
||||
export async function lint(
|
||||
source: string,
|
||||
rules: Record<string, string>,
|
||||
relativePath = 'fixture.ts'
|
||||
): Promise<Diagnostic[]> {
|
||||
const root = await resolveWorkspaceRoot(import.meta.dir)
|
||||
const directory = await mkdtemp(join(tmpdir(), 'open-pencil-lint-'))
|
||||
try {
|
||||
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: [join(root, 'lint/plugin.js')],
|
||||
rules
|
||||
})
|
||||
)
|
||||
const child = Bun.spawn(
|
||||
[join(root, 'node_modules/.bin/oxlint'), '-c', configPath, '--format', 'json', sourcePath],
|
||||
{ stdout: 'pipe', stderr: 'pipe' }
|
||||
)
|
||||
const [stdout, stderr, code] = await Promise.all([
|
||||
new Response(child.stdout).text(),
|
||||
new Response(child.stderr).text(),
|
||||
child.exited
|
||||
])
|
||||
if (code !== 0 && code !== 1) throw new Error(`Oxlint exited ${code}: ${stderr}`)
|
||||
const { diagnostics } = v.parse(resultSchema, JSON.parse(stdout))
|
||||
const parserFailure = diagnostics.find(
|
||||
(item) => item.severity === 'error' && item.code === undefined
|
||||
)
|
||||
if (parserFailure)
|
||||
throw new Error(`Oxlint could not parse the fixture: ${parserFailure.message}`)
|
||||
return diagnostics
|
||||
} finally {
|
||||
await rm(directory, { recursive: true, force: true })
|
||||
}
|
||||
}
|
||||
|
||||
export function ruleDiagnostics(diagnostics: Diagnostic[], rule: string): Diagnostic[] {
|
||||
return diagnostics.filter((diagnostic) => diagnostic.code === `open-pencil(${rule})`)
|
||||
}
|
||||
|
|
@ -1,26 +0,0 @@
|
|||
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)
|
||||
})
|
||||
})
|
||||
24
tools/lint/tests/lint-helper.test.ts
Normal file
24
tools/lint/tests/lint-helper.test.ts
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
import { expect, test } from 'bun:test'
|
||||
|
||||
import { lint, ruleDiagnostics } from './helpers/lint.ts'
|
||||
|
||||
const rule = 'no-module-mocking'
|
||||
const rules = { [`open-pencil/${rule}`]: 'error' }
|
||||
|
||||
test('rejects parser failures instead of treating them as valid examples', async () => {
|
||||
await expect(lint('const = ;', rules)).rejects.toThrow('could not parse')
|
||||
})
|
||||
|
||||
test('rejects invalid rule configuration', async () => {
|
||||
await expect(
|
||||
lint('export const value = 1', { 'open-pencil/missing-rule': 'error' })
|
||||
).rejects.toThrow()
|
||||
})
|
||||
|
||||
test('isolates concurrent fixtures and retains expected rule errors', async () => {
|
||||
const results = await Promise.all([
|
||||
lint("import { mock } from 'bun:test'; mock.module('./store')", rules),
|
||||
lint('export const value = 1', rules)
|
||||
])
|
||||
expect(results.map((result) => ruleDiagnostics(result, rule).length)).toEqual([1, 0])
|
||||
})
|
||||
|
|
@ -1,12 +1,10 @@
|
|||
import { afterEach, describe, expect, test } from 'bun:test'
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
|
||||
import { cleanupLintFixtures, lint, ruleDiagnostics } from '#lint/support/lint-test.ts'
|
||||
import { lint, ruleDiagnostics } from './helpers/lint.ts'
|
||||
|
||||
const rule = 'no-module-mocking'
|
||||
const rules = { [`open-pencil/${rule}`]: 'error' }
|
||||
|
||||
afterEach(cleanupLintFixtures)
|
||||
|
||||
describe('no-module-mocking', () => {
|
||||
test.each([
|
||||
"vi.mock('./store')",
|
||||
|
|
@ -15,6 +13,9 @@ describe('no-module-mocking', () => {
|
|||
"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 { jest } from 'bun:test'; jest.mock('./store')",
|
||||
"import { jest as testAPI } from 'bun:test'; testAPI.mock('./store')",
|
||||
"import { vitest } from 'vitest'; vitest.mock('./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)
|
||||
|
|
|
|||
|
|
@ -1,55 +1,16 @@
|
|||
import { afterEach, describe, expect, test } from 'bun:test'
|
||||
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
|
||||
import { mkdir, mkdtemp, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { dirname, join, resolve } from 'node:path'
|
||||
import { join } from 'node:path'
|
||||
|
||||
import lintPlugin from '#lint/plugin.ts'
|
||||
import { normalizedFilename } from '#lint/support/context.ts'
|
||||
import type { Diagnostic } from '#lint/support/lint-test.ts'
|
||||
|
||||
interface LintResult {
|
||||
diagnostics: Diagnostic[]
|
||||
}
|
||||
import { resolveWorkspaceRoot } from '@open-pencil/package-artifacts'
|
||||
|
||||
import { lint, ruleDiagnostics } from './helpers/lint.ts'
|
||||
|
||||
const temporaryDirectories: string[] = []
|
||||
const pluginPath = resolve(import.meta.dir, '../../../lint/plugin.js')
|
||||
const oxlintPath = resolve(import.meta.dir, '../../../node_modules/.bin/oxlint')
|
||||
|
||||
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 process = Bun.spawn([oxlintPath, '-c', configPath, '--format', 'json', sourcePath], {
|
||||
stdout: 'pipe',
|
||||
stderr: 'pipe'
|
||||
})
|
||||
const [output, errorOutput] = await Promise.all([
|
||||
new Response(process.stdout).text(),
|
||||
new Response(process.stderr).text()
|
||||
])
|
||||
const exitCode = await process.exited
|
||||
try {
|
||||
return (JSON.parse(output) as LintResult).diagnostics
|
||||
} catch {
|
||||
throw new Error(`oxlint exited ${exitCode} without JSON output.\nstderr: ${errorOutput}`)
|
||||
}
|
||||
}
|
||||
|
||||
async function runRule(ruleName: string, source: string, filename: string): Promise<number> {
|
||||
const plugin = lintPlugin
|
||||
|
|
@ -94,10 +55,6 @@ test('storage rule permits data keys but rejects storage access', async () => {
|
|||
}
|
||||
})
|
||||
|
||||
function ruleDiagnostics(diagnostics: Diagnostic[], rule: string): Diagnostic[] {
|
||||
return diagnostics.filter((diagnostic) => diagnostic.code === `open-pencil(${rule})`)
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(
|
||||
temporaryDirectories.splice(0).map((directory) => rm(directory, { recursive: true }))
|
||||
|
|
@ -215,6 +172,7 @@ describe('path support', () => {
|
|||
|
||||
describe('plugin entrypoint', () => {
|
||||
test('loads all custom rules through the compatibility entrypoint', async () => {
|
||||
const pluginPath = join(await resolveWorkspaceRoot(import.meta.dir), 'lint/plugin.js')
|
||||
const module = (await import(pluginPath)) as { default: typeof lintPlugin }
|
||||
expect(Object.keys(module.default.rules)).toContain('no-conditional-object-spreads')
|
||||
expect(Object.keys(module.default.rules).length).toBeGreaterThan(60)
|
||||
|
|
|
|||
|
|
@ -1,15 +1,15 @@
|
|||
import { afterEach, describe, expect, test } from 'bun:test'
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
|
||||
import { cleanupLintFixtures, lint, ruleDiagnostics } from '#lint/support/lint-test.ts'
|
||||
import { lint, ruleDiagnostics } from './helpers/lint.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) => acc.slice(0, -1), [])',
|
||||
'items.reduce((acc, item) => acc.slice(2), [])',
|
||||
'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), [])'
|
||||
|
|
@ -21,7 +21,10 @@ describe('no-reduce-accumulator-copy', () => {
|
|||
'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, [])'
|
||||
'items.reduce(namedReducer, [])',
|
||||
'items.reduce((acc, item) => acc.slice(0, 1), [0])',
|
||||
'items.reduce((acc, item) => acc.slice(-2), [0])',
|
||||
'items.reduce((acc, item) => acc.slice(0, 0), [0])'
|
||||
])('accepts owned mutation and unrelated copies: %s', async (source) => {
|
||||
expect(ruleDiagnostics(await lint(source, rules), rule)).toHaveLength(0)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,15 +1,14 @@
|
|||
import { afterEach, describe, expect, test } from 'bun:test'
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
|
||||
import { cleanupLintFixtures, lint, ruleDiagnostics } from '#lint/support/lint-test.ts'
|
||||
import { lint, ruleDiagnostics } from './helpers/lint.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',
|
||||
'// é 中文 😀\nconst source: User = loadUser(); const widened: object = 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) => {
|
||||
|
|
|
|||
|
|
@ -1,28 +0,0 @@
|
|||
# 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