chore(lint): catch duplicate type shapes

- Add AST-based duplicate object type shape detection
- Reuse shared variable consumption and visual bounds type shapes
- Keep duplicate type shape checks in the default lint gate
This commit is contained in:
Danila Poyarkov 2026-05-25 00:47:33 +03:00
parent 1ed7de6056
commit 2f8dfd883a
4 changed files with 114 additions and 15 deletions

View file

@ -1692,6 +1692,108 @@ const noBroadUnknownTypeAssertions = {
}
}
function typeNameText(node) {
if (!node) return 'unknown'
if (node.type === 'Identifier') return node.name
if (node.type === 'TSQualifiedName') return `${typeNameText(node.left)}.${typeNameText(node.right)}`
return node.type
}
function canonicalType(node) {
if (!node) return 'unknown'
switch (node.type) {
case 'TSStringKeyword':
return 'string'
case 'TSNumberKeyword':
return 'number'
case 'TSBooleanKeyword':
return 'boolean'
case 'TSUnknownKeyword':
return 'unknown'
case 'TSNullKeyword':
return 'null'
case 'TSUndefinedKeyword':
return 'undefined'
case 'TSLiteralType':
return `literal:${node.literal?.value ?? node.literal?.type}`
case 'TSArrayType':
return `array<${canonicalType(node.elementType)}>`
case 'TSTypeReference': {
const params = typeParameterNodes(node).map(canonicalType).join(',')
return `ref:${typeNameText(node.typeName)}<${params}>`
}
case 'TSUnionType':
return `union<${node.types.map(canonicalType).sort().join('|')}>`
case 'TSTypeLiteral':
return `object{${canonicalMembers(node.members)}}`
case 'TSParenthesizedType':
return canonicalType(node.typeAnnotation)
case 'TSFunctionType':
return 'function'
default:
return node.type
}
}
function propertyKeyName(key) {
if (key?.type === 'Identifier') return key.name
if (key?.type === 'Literal') return String(key.value)
return null
}
function canonicalMember(member) {
if (member.type === 'TSIndexSignature') {
const param = member.parameters?.[0]
const keyType = param?.typeAnnotation?.typeAnnotation
return `index:${canonicalType(keyType)}:${canonicalType(member.typeAnnotation?.typeAnnotation)}`
}
if (member.type !== 'TSPropertySignature') return null
const name = propertyKeyName(member.key)
if (!name) return null
const optional = member.optional ? '?' : ''
return `prop:${name}${optional}:${canonicalType(member.typeAnnotation?.typeAnnotation)}`
}
function canonicalMembers(members) {
return (members ?? []).map(canonicalMember).filter(Boolean).sort().join(';')
}
function namedTypeShape(node) {
if (node.type === 'TSInterfaceDeclaration') return canonicalMembers(node.body?.body)
if (node.type === 'TSTypeAliasDeclaration' && node.typeAnnotation?.type === 'TSTypeLiteral') {
return canonicalMembers(node.typeAnnotation.members)
}
return null
}
const noDuplicateTypeShapes = {
meta: {
docs: {
description: 'Disallow duplicate local object type/interface shapes in one file'
}
},
create(context) {
const seen = new Map()
return {
'TSInterfaceDeclaration, TSTypeAliasDeclaration'(node) {
const shape = namedTypeShape(node)
if (!shape) return
const memberCount = shape ? shape.split(';').filter(Boolean).length : 0
if (memberCount < 2) return
const first = seen.get(shape)
if (!first) {
seen.set(shape, node)
return
}
context.report({
node,
message: 'Duplicate object type shape. Reuse the existing named type instead of redeclaring the same members.'
})
}
}
}
}
const noLocalJsonObjectAliases = {
meta: {
docs: {
@ -1770,6 +1872,7 @@ const plugin = {
'no-unknown-record-double-cast': noUnknownRecordDoubleCast,
'no-broad-unknown-type-assertions': noBroadUnknownTypeAssertions,
'no-local-json-object-aliases': noLocalJsonObjectAliases,
'no-duplicate-type-shapes': noDuplicateTypeShapes,
'no-ts-suppression-comments': noTsSuppressionComments,
'no-function-type': noFunctionType,
'no-reflect-delete-global-this-outside-tests': noReflectDeleteGlobalThisOutsideTests,

View file

@ -176,6 +176,7 @@
"open-pencil/no-unknown-record-double-cast": "error",
"open-pencil/no-broad-unknown-type-assertions": "error",
"open-pencil/no-local-json-object-aliases": "error",
"open-pencil/no-duplicate-type-shapes": "error",
"open-pencil/no-ts-suppression-comments": "error",
"open-pencil/no-reflect-delete-global-this-outside-tests": "error",
"open-pencil/no-core-browser-globals": "error",

View file

@ -67,13 +67,15 @@ export function rotatedBBox(
return { left, right, top, bottom, centerX: (left + right) / 2, centerY: (top + bottom) / 2 }
}
interface BoundsAccumulator {
export interface VisualBounds {
minX: number
minY: number
maxX: number
maxY: number
}
type BoundsAccumulator = VisualBounds
function createBoundsAccumulator(): BoundsAccumulator {
return { minX: Infinity, minY: Infinity, maxX: -Infinity, maxY: -Infinity }
}
@ -209,13 +211,6 @@ export function computeVisualBounds(
return boundsToRect(bounds)
}
export interface VisualBounds {
minX: number
minY: number
maxX: number
maxY: number
}
export interface VisualBoundsNode {
id: string
width: number

View file

@ -4,19 +4,19 @@ import type { Matrix, Vector } from '#core/types'
import type { ProtectionMap } from './patches'
export interface SymbolOverride {
guidPath?: { guids?: GUID[] }
overriddenSymbolID?: GUID
componentPropAssignments?: ComponentPropAssignment[]
export interface VariableConsumptionMapFields {
variableConsumptionMap?: { entries?: VariableConsumptionEntry[] }
[key: string]: unknown
}
export interface SymbolOverrideFields {
variableConsumptionMap?: { entries?: VariableConsumptionEntry[] }
[key: string]: unknown
export interface SymbolOverride extends VariableConsumptionMapFields {
guidPath?: { guids?: GUID[] }
overriddenSymbolID?: GUID
componentPropAssignments?: ComponentPropAssignment[]
}
export type SymbolOverrideFields = VariableConsumptionMapFields
export interface SymbolData {
symbolID?: GUID
symbolOverrides?: SymbolOverride[]