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:
Danila Poyarkov 2026-09-14 10:10:27 +03:00
parent c274fecaa8
commit f2bfaba287
28 changed files with 220 additions and 1496 deletions

View file

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

View file

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

View file

@ -16,6 +16,7 @@
"packages/harness",
"packages/docs",
"tools/docs",
"tools/lint",
"tools/package-artifacts",
"tools/authoring-reference",
"tools/package-quality",

View file

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

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

View file

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

View file

@ -7,5 +7,8 @@
},
"scripts": {
"test": "tsc --noEmit -p tsconfig.json && bun test tests"
},
"devDependencies": {
"@open-pencil/package-artifacts": "workspace:*"
}
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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. */

View file

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

View file

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

View file

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

View file

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

View file

@ -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) {

View file

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

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

View file

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

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

View file

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

View file

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

View file

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

View file

@ -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) => {

View file

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