From f4b3453fbd2a9482c7877d8062f6edba71f86ec3 Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Mon, 25 May 2026 00:55:29 +0300 Subject: [PATCH] chore(lint): check duplicate type shapes across files - Add a repo-wide TypeScript AST duplicate shape check to the quality gate - Extract shared named types for existing cross-file duplicates - Keep same-file oxlint coverage for editor feedback --- package.json | 3 +- packages/core/src/color/analysis.ts | 8 ++ packages/core/src/design-jsx/render.ts | 7 +- packages/core/src/design-jsx/renderer.ts | 7 +- packages/core/src/design-jsx/types.ts | 5 + packages/core/src/rpc/analyze-commands.ts | 8 +- packages/core/src/tools/analyze/colors.ts | 8 +- packages/mcp/src/browser-rpc.ts | 7 +- packages/mcp/src/rpc-types.ts | 5 + packages/mcp/src/stdio-bridge.ts | 6 +- .../vue/src/shared/input/drag-original.ts | 1 + .../vue/src/shared/input/duplicate-drag.ts | 3 +- packages/vue/src/shared/input/select/move.ts | 3 +- scripts/type-shapes.ts | 123 ++++++++++++++++++ src/app/document/export/create.ts | 7 +- src/app/document/export/files.ts | 7 +- src/app/document/export/types.ts | 5 + src/app/document/io/browser.ts | 4 +- src/app/document/io/create.ts | 6 +- src/app/document/io/types.ts | 1 + src/app/editor/session/modules.ts | 3 +- src/components/FontSettings/use.ts | 7 +- src/components/MobileHud/context.ts | 9 +- 23 files changed, 171 insertions(+), 72 deletions(-) create mode 100644 packages/core/src/color/analysis.ts create mode 100644 packages/core/src/design-jsx/types.ts create mode 100644 packages/mcp/src/rpc-types.ts create mode 100644 packages/vue/src/shared/input/drag-original.ts create mode 100644 scripts/type-shapes.ts create mode 100644 src/app/document/export/types.ts create mode 100644 src/app/document/io/types.ts diff --git a/package.json b/package.json index 64e1c53d6..1af457f3b 100644 --- a/package.json +++ b/package.json @@ -16,7 +16,7 @@ "lint:structure": "oxlint -c oxlint.json vite.config.ts vite/ src/ packages/core/src/ packages/vue/src/ packages/cli/src/ packages/mcp/src/ tests/ scripts/", "format": "oxfmt --write .oxfmtrc.json vite.config.ts vite/ src/ packages/core/src/ packages/cli/src/ packages/mcp/src/ packages/vue/src/ tests scripts/", "format:check": "bun run format && status=$(git status --porcelain -uall) && test -z \"$status\" || (echo \"$status\" && exit 1)", - "check": "bun run build:packages && bun run lint && tsgo --noEmit && bun run check:vue && bun run check:i18n && bun run check:packages && bun run check:arch && bun run test:dupes", + "check": "bun run build:packages && bun run lint && tsgo --noEmit && bun run check:vue && bun run check:i18n && bun run check:packages && bun run check:arch && bun run test:type-shapes && bun run test:dupes", "check:i18n": "bun scripts/check-locales.ts", "check:packages": "bun scripts/check-package-metadata.ts", "check:arch": "steiger .", @@ -27,6 +27,7 @@ "figma:debug": "open -a Figma --args --remote-debugging-port=9222", "test:unit": "bun test ./tests/engine", "test:coverage": "bun test --coverage ./tests/engine", + "test:type-shapes": "bun scripts/type-shapes.ts", "test:dupes": "jscpd packages/core/src packages/cli/src src --min-lines 5 --min-tokens 50 --format typescript --threshold 0", "test:packages": "bun scripts/check-package-metadata.ts && bun scripts/smoke-packages.ts", "build:packages": "bun --filter @open-pencil/core build && bun --filter @open-pencil/vue build && bun --filter @open-pencil/mcp build && bun --filter @open-pencil/cli build", diff --git a/packages/core/src/color/analysis.ts b/packages/core/src/color/analysis.ts new file mode 100644 index 000000000..e09c0e675 --- /dev/null +++ b/packages/core/src/color/analysis.ts @@ -0,0 +1,8 @@ +import type { Color } from '#core/types' + +export interface ColorUsageEntry { + hex: string + color: Color + count: number + variableName: string | null +} diff --git a/packages/core/src/design-jsx/render.ts b/packages/core/src/design-jsx/render.ts index eaf8cf8fe..95c975e6d 100644 --- a/packages/core/src/design-jsx/render.ts +++ b/packages/core/src/design-jsx/render.ts @@ -1,5 +1,6 @@ import { transform } from 'sucrase' +import type { RenderOptions as RenderJSXOptions } from '#core/design-jsx/types' import type { SceneGraph } from '#core/scene-graph' import * as React from './mini-react' @@ -164,12 +165,6 @@ export function buildComponent(jsxString: string): React.ComponentType { return new Function('React', code)(React) as React.ComponentType } -interface RenderJSXOptions { - x?: number - y?: number - parentId?: string -} - /** * Render a JSX string into the scene graph. * Works in both Node/Bun and the browser. diff --git a/packages/core/src/design-jsx/renderer.ts b/packages/core/src/design-jsx/renderer.ts index 4fae3683a..da6b1e929 100644 --- a/packages/core/src/design-jsx/renderer.ts +++ b/packages/core/src/design-jsx/renderer.ts @@ -1,4 +1,5 @@ import { parseColor } from '#core/color' +import type { RenderOptions } from '#core/design-jsx/types' import { fetchIcons } from '#core/icons' import { createIconFromPaths } from '#core/icons/render' import { computeAllLayouts } from '#core/layout' @@ -39,12 +40,6 @@ const TYPE_MAP: Partial> = { h6: 'TEXT' } -interface RenderOptions { - x?: number - y?: number - parentId?: string -} - export interface RenderResult { id: string name: string diff --git a/packages/core/src/design-jsx/types.ts b/packages/core/src/design-jsx/types.ts new file mode 100644 index 000000000..bca4f9e4d --- /dev/null +++ b/packages/core/src/design-jsx/types.ts @@ -0,0 +1,5 @@ +export interface RenderOptions { + x?: number + y?: number + parentId?: string +} diff --git a/packages/core/src/rpc/analyze-commands.ts b/packages/core/src/rpc/analyze-commands.ts index 96d66f937..3c624b32f 100644 --- a/packages/core/src/rpc/analyze-commands.ts +++ b/packages/core/src/rpc/analyze-commands.ts @@ -1,6 +1,7 @@ import { orderBy, sortBy } from 'es-toolkit/array' import { colorToHex, colorDistance as colorDist } from '#core/color' +import type { ColorUsageEntry } from '#core/color/analysis' import type { SceneGraph, SceneNode } from '#core/scene-graph' import type { Color } from '#core/types' @@ -13,12 +14,7 @@ export interface AnalyzeColorsArgs { similar?: boolean } -interface ColorInfo { - hex: string - color: Color - count: number - variableName: string | null -} +type ColorInfo = ColorUsageEntry interface ColorCluster { colors: ColorInfo[] diff --git a/packages/core/src/tools/analyze/colors.ts b/packages/core/src/tools/analyze/colors.ts index ece44a358..60ce0e9f8 100644 --- a/packages/core/src/tools/analyze/colors.ts +++ b/packages/core/src/tools/analyze/colors.ts @@ -1,15 +1,11 @@ import { orderBy } from 'es-toolkit/array' import { colorDistance, colorToHex } from '#core/color' +import type { ColorUsageEntry } from '#core/color/analysis' import { defineTool } from '#core/tools/schema' import type { Color } from '#core/types' -interface ColorEntry { - hex: string - color: Color - count: number - variableName: string | null -} +type ColorEntry = ColorUsageEntry function trackColor(colorMap: Map, color: Color, variableName: string | null) { const hex = colorToHex(color) diff --git a/packages/mcp/src/browser-rpc.ts b/packages/mcp/src/browser-rpc.ts index 5b8e9135f..5ea21f19b 100644 --- a/packages/mcp/src/browser-rpc.ts +++ b/packages/mcp/src/browser-rpc.ts @@ -3,18 +3,13 @@ import { randomUUID } from 'node:crypto' import type { WebSocket } from 'ws' import type { RpcJsonObject } from '#mcp/json' +import type { PendingRequest } from '#mcp/rpc-types' const RPC_TIMEOUT = 30_000 const APP_NOT_CONNECTED_MESSAGE = 'OpenPencil app is not connected. STOP and tell the user: "The OpenPencil desktop app is not running, no document is open, or the desktop app is connected to a different MCP server. Please start OpenPencil, open a document, and try again." Do NOT attempt to start the app yourself or retry automatically.' -type PendingRequest = { - resolve: (value: unknown) => void - reject: (error: Error) => void - timer: ReturnType -} - type BrowserRpcBridgeOptions = { authToken: string | null onConnectionChange: () => void diff --git a/packages/mcp/src/rpc-types.ts b/packages/mcp/src/rpc-types.ts new file mode 100644 index 000000000..442fc075e --- /dev/null +++ b/packages/mcp/src/rpc-types.ts @@ -0,0 +1,5 @@ +export type PendingRequest = { + resolve: (value: unknown) => void + reject: (error: Error) => void + timer: ReturnType +} diff --git a/packages/mcp/src/stdio-bridge.ts b/packages/mcp/src/stdio-bridge.ts index 8e0aa063d..3eac1857c 100644 --- a/packages/mcp/src/stdio-bridge.ts +++ b/packages/mcp/src/stdio-bridge.ts @@ -1,10 +1,6 @@ import { WebSocket } from 'ws' -type PendingRequest = { - resolve: (value: unknown) => void - reject: (error: Error) => void - timer: ReturnType -} +import type { PendingRequest } from '#mcp/rpc-types' type StdioRpcBridgeOptions = { wsUrl: string diff --git a/packages/vue/src/shared/input/drag-original.ts b/packages/vue/src/shared/input/drag-original.ts new file mode 100644 index 000000000..8f0afbfdc --- /dev/null +++ b/packages/vue/src/shared/input/drag-original.ts @@ -0,0 +1 @@ +export type DragOriginal = { x: number; y: number; parentId: string } diff --git a/packages/vue/src/shared/input/duplicate-drag.ts b/packages/vue/src/shared/input/duplicate-drag.ts index 18711989e..746385c03 100644 --- a/packages/vue/src/shared/input/duplicate-drag.ts +++ b/packages/vue/src/shared/input/duplicate-drag.ts @@ -1,10 +1,9 @@ import type { Editor } from '@open-pencil/core/editor' import { useI18n } from '#vue/i18n/useI18n.js' +import type { DragOriginal } from '#vue/shared/input/drag-original' import type { DragState } from '#vue/shared/input/types' -type DragOriginal = { x: number; y: number; parentId: string } - export function duplicateAndDrag( cx: number, cy: number, diff --git a/packages/vue/src/shared/input/select/move.ts b/packages/vue/src/shared/input/select/move.ts index f6e274a16..4075469e3 100644 --- a/packages/vue/src/shared/input/select/move.ts +++ b/packages/vue/src/shared/input/select/move.ts @@ -1,10 +1,9 @@ import type { Editor } from '@open-pencil/core/editor' +import type { DragOriginal as MoveOriginal } from '#vue/shared/input/drag-original' import { duplicateAndDrag } from '#vue/shared/input/duplicate-drag' import type { DragState } from '#vue/shared/input/types' -type MoveOriginal = { x: number; y: number; parentId: string } - export function selectionIsLocked(editor: Editor) { return [...editor.state.selectedIds].every((id) => editor.graph.getNode(id)?.locked) } diff --git a/scripts/type-shapes.ts b/scripts/type-shapes.ts new file mode 100644 index 000000000..c66c0cf50 --- /dev/null +++ b/scripts/type-shapes.ts @@ -0,0 +1,123 @@ +import ts from 'typescript' + +const roots = [ + 'src', + 'packages/core/src', + 'packages/vue/src', + 'packages/cli/src', + 'packages/mcp/src', + 'tests', + 'scripts' +] + +type ShapeLocation = { file: string; line: number; name: string } + +const files: string[] = [] +for (const root of roots) { + for await (const path of new Bun.Glob('**/*.{ts,tsx}').scan(root)) { + if (path.endsWith('.d.ts')) continue + files.push(`${root}/${path}`) + } +} + +function entityNameText(name: ts.EntityName): string { + return ts.isIdentifier(name) ? name.text : `${entityNameText(name.left)}.${name.right.text}` +} + +function literalText(node: ts.LiteralTypeNode) { + const literal = node.literal + if (ts.isStringLiteral(literal)) return `string:${literal.text}` + if (ts.isNumericLiteral(literal)) return `number:${literal.text}` + if (literal.kind === ts.SyntaxKind.TrueKeyword) return 'boolean:true' + if (literal.kind === ts.SyntaxKind.FalseKeyword) return 'boolean:false' + return ts.SyntaxKind[literal.kind] +} + +function typeText(node: ts.TypeNode | undefined): string { + if (!node) return 'unknown' + if (ts.isTypeReferenceNode(node)) { + return `ref:${entityNameText(node.typeName)}<${(node.typeArguments ?? []).map(typeText).join(',')}>` + } + if (ts.isArrayTypeNode(node)) return `array<${typeText(node.elementType)}>` + if (ts.isUnionTypeNode(node)) return `union<${node.types.map(typeText).sort().join('|')}>` + if (ts.isTypeLiteralNode(node)) return `object{${membersText(node.members)}}` + if (ts.isParenthesizedTypeNode(node)) return typeText(node.type) + if (ts.isLiteralTypeNode(node)) return `literal:${literalText(node)}` + return ts.SyntaxKind[node.kind] +} + +function keyText(name: ts.PropertyName): string | null { + if (ts.isIdentifier(name) || ts.isStringLiteral(name) || ts.isNumericLiteral(name)) + return name.text + return null +} + +function memberText(member: ts.TypeElement): string | null { + if (ts.isPropertySignature(member) && member.name) { + const key = keyText(member.name) + if (!key) return null + return `prop:${key}${member.questionToken ? '?' : ''}:${typeText(member.type)}` + } + if (ts.isIndexSignatureDeclaration(member)) { + return `index:${member.parameters.map((param) => typeText(param.type)).join(',')}:${typeText(member.type)}` + } + return null +} + +function membersText(members: ts.NodeArray) { + return members + .map(memberText) + .filter((member): member is string => member !== null) + .sort() + .join(';') +} + +function collectShape(node: ts.Node): { name: string; shape: string } | null { + if (ts.isInterfaceDeclaration(node)) + return { name: node.name.text, shape: membersText(node.members) } + if (ts.isTypeAliasDeclaration(node) && ts.isTypeLiteralNode(node.type)) { + return { name: node.name.text, shape: membersText(node.type.members) } + } + return null +} + +const shapes = new Map() + +for (const file of files) { + const source = await Bun.file(file).text() + const kind = file.endsWith('.tsx') ? ts.ScriptKind.TSX : ts.ScriptKind.TS + const sourceFile = ts.createSourceFile(file, source, ts.ScriptTarget.Latest, true, kind) + + function visit(node: ts.Node) { + const collected = collectShape(node) + if (collected) { + const memberCount = collected.shape.split(';').filter(Boolean).length + if (memberCount >= 2) { + const position = sourceFile.getLineAndCharacterOfPosition(node.getStart(sourceFile)) + const locations = shapes.get(collected.shape) ?? [] + locations.push({ file, line: position.line + 1, name: collected.name }) + shapes.set(collected.shape, locations) + } + } + ts.forEachChild(node, visit) + } + + visit(sourceFile) +} + +let duplicates = 0 +for (const locations of shapes.values()) { + if (locations.length < 2) continue + duplicates++ + console.error('\nDuplicate object type shape:') + for (const location of locations) { + console.error(` ${location.file}:${location.line} ${location.name}`) + } +} + +if (duplicates > 0) { + console.error(`\nFound ${duplicates} duplicate object type shape${duplicates === 1 ? '' : 's'}.`) + process.exit(1) +} + +console.log('No duplicate object type shapes found.') diff --git a/src/app/document/export/create.ts b/src/app/document/export/create.ts index c52decec2..e8e410b07 100644 --- a/src/app/document/export/create.ts +++ b/src/app/document/export/create.ts @@ -9,12 +9,7 @@ import { getExportOptions, saveExportedFile } from '@/app/document/export/files' - -type ExportOptions = { - scale?: number - quality?: number - jsxFormat?: 'openpencil' | 'tailwind' -} +import type { ExportOptions } from '@/app/document/export/types' type DownloadBlob = (data: Uint8Array, filename: string, mime: string) => void diff --git a/src/app/document/export/files.ts b/src/app/document/export/files.ts index 7cb6d1689..84e6198c0 100644 --- a/src/app/document/export/files.ts +++ b/src/app/document/export/files.ts @@ -8,14 +8,9 @@ import type { import { renderNodesToImage } from '@open-pencil/core/io/formats/raster' import type { SceneGraph } from '@open-pencil/core/scene-graph' +import type { ExportOptions } from '@/app/document/export/types' import { isTauri } from '@/app/tauri/env' -type ExportOptions = { - scale?: number - quality?: number - jsxFormat?: 'openpencil' | 'tailwind' -} - type ExportData = string | ArrayBuffer | Uint8Array type DownloadBlob = (data: Uint8Array, filename: string, mime: string) => void diff --git a/src/app/document/export/types.ts b/src/app/document/export/types.ts new file mode 100644 index 000000000..4d5c2d70b --- /dev/null +++ b/src/app/document/export/types.ts @@ -0,0 +1,5 @@ +export type ExportOptions = { + scale?: number + quality?: number + jsxFormat?: 'openpencil' | 'tailwind' +} diff --git a/src/app/document/io/browser.ts b/src/app/document/io/browser.ts index 804f73cf1..e9cdabb00 100644 --- a/src/app/document/io/browser.ts +++ b/src/app/document/io/browser.ts @@ -1,11 +1,11 @@ +import type { ViewportSize } from '@/app/document/io/types' + export function yieldToUI(): Promise { return new Promise((resolve) => { requestAnimationFrame(() => resolve()) }) } -type ViewportSize = { width: number; height: number } - type ViewportEditor = { zoomToFit: () => void } diff --git a/src/app/document/io/create.ts b/src/app/document/io/create.ts index 22b28e803..0a4e2626c 100644 --- a/src/app/document/io/create.ts +++ b/src/app/document/io/create.ts @@ -4,6 +4,7 @@ import { prefetchFigmaSchema } from '@open-pencil/core/kiwi' import { createDocumentViewportActions, downloadBlob } from '@/app/document/io/browser' import { createOpenActions, createReloadActions } from '@/app/document/io/read' import { createDocumentSourceActions, createDocumentSourceState } from '@/app/document/io/source' +import type { ViewportSize } from '@/app/document/io/types' import { createFileWatcher } from '@/app/document/io/watch' type DocumentIOState = EditorState & { @@ -12,11 +13,6 @@ type DocumentIOState = EditorState & { autosaveEnabled: boolean } -type ViewportSize = { - width: number - height: number -} - export function createDocumentIOActions( editor: Editor, state: DocumentIOState, diff --git a/src/app/document/io/types.ts b/src/app/document/io/types.ts new file mode 100644 index 000000000..e60a19b7e --- /dev/null +++ b/src/app/document/io/types.ts @@ -0,0 +1 @@ +export type ViewportSize = { width: number; height: number } diff --git a/src/app/editor/session/modules.ts b/src/app/editor/session/modules.ts index 5d45b64d8..a90bb37dd 100644 --- a/src/app/editor/session/modules.ts +++ b/src/app/editor/session/modules.ts @@ -6,6 +6,7 @@ import type { SceneGraph } from '@open-pencil/core/scene-graph' import { createDocumentExportActions } from '@/app/document/export' import { createDocumentIOActions } from '@/app/document/io' +import type { ViewportSize } from '@/app/document/io/types' import { createFlashActions } from '@/app/editor/flash' import { createMobileClipboardActions } from '@/app/editor/mobile-clipboard' import { createPenActions } from '@/app/editor/pen' @@ -13,8 +14,6 @@ import { createProfilerActions } from '@/app/editor/profiler' import type { AppEditorState } from '@/app/editor/session/types' import { createVectorEditActions } from '@/app/editor/vector-edit' -type ViewportSize = { width: number; height: number } - export function defineEditorStoreAccessors(store: object, editor: Editor) { Object.defineProperties(store, { graph: { diff --git a/src/components/FontSettings/use.ts b/src/components/FontSettings/use.ts index 12171c058..bfd604cdc 100644 --- a/src/components/FontSettings/use.ts +++ b/src/components/FontSettings/use.ts @@ -10,12 +10,9 @@ import { predownloadFallbackFonts, requestLocalFontAccess } from '@/app/editor/fonts' +import type { DownloadedFontCacheSummary } from '@/app/editor/fonts/cache' -export interface FontCacheSummary { - count: number - byteLength: number - updatedAt: number | null -} +type FontCacheSummary = DownloadedFontCacheSummary export interface FontSettingsActions { clearDownloadedFontCache: () => Promise diff --git a/src/components/MobileHud/context.ts b/src/components/MobileHud/context.ts index c471326aa..48bd480a8 100644 --- a/src/components/MobileHud/context.ts +++ b/src/components/MobileHud/context.ts @@ -1,6 +1,6 @@ import { useClipboard } from '@vueuse/core' import { computed, inject, provide, proxyRefs } from 'vue' -import type { Component, InjectionKey, ShallowUnwrapRef } from 'vue' +import type { InjectionKey, ShallowUnwrapRef } from 'vue' import { useRouter } from 'vue-router' import IconFilePlus from '~icons/lucide/file-plus' import IconFolderOpen from '~icons/lucide/folder-open' @@ -15,12 +15,9 @@ import { useEditorStore } from '@/app/editor/active-store' import { toolIcons } from '@/app/editor/icons' import { openFileDialog } from '@/app/shell/menu/use' import { toast } from '@/app/shell/ui' +import type { ToolbarActionItem } from '@/components/Toolbar/types' -interface MenuAction { - icon: Component - label: string - action: () => void -} +type MenuAction = ToolbarActionItem function createMobileHudContext() { const router = useRouter()