From 2f8dfd883a4edd8fc69f401717a150856c04f78f Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Mon, 25 May 2026 00:47:33 +0300 Subject: [PATCH] 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 --- lint/plugin.js | 103 ++++++++++++++++++ oxlint.json | 1 + packages/core/src/geometry.ts | 11 +- .../src/kiwi/fig/instance-overrides/types.ts | 14 +-- 4 files changed, 114 insertions(+), 15 deletions(-) diff --git a/lint/plugin.js b/lint/plugin.js index 40747a82a..5a87367a7 100644 --- a/lint/plugin.js +++ b/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, diff --git a/oxlint.json b/oxlint.json index 68871cd96..bb4022c85 100644 --- a/oxlint.json +++ b/oxlint.json @@ -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", diff --git a/packages/core/src/geometry.ts b/packages/core/src/geometry.ts index c3823ada0..8ca104033 100644 --- a/packages/core/src/geometry.ts +++ b/packages/core/src/geometry.ts @@ -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 diff --git a/packages/core/src/kiwi/fig/instance-overrides/types.ts b/packages/core/src/kiwi/fig/instance-overrides/types.ts index 5e79e1d3c..43b698019 100644 --- a/packages/core/src/kiwi/fig/instance-overrides/types.ts +++ b/packages/core/src/kiwi/fig/instance-overrides/types.ts @@ -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[]