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:
parent
1ed7de6056
commit
2f8dfd883a
103
lint/plugin.js
103
lint/plugin.js
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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[]
|
||||
|
|
|
|||
Loading…
Reference in a new issue