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
This commit is contained in:
Danila Poyarkov 2026-05-25 00:55:29 +03:00
parent 2f8dfd883a
commit f4b3453fbd
23 changed files with 171 additions and 72 deletions

View file

@ -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/", "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": "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)", "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:i18n": "bun scripts/check-locales.ts",
"check:packages": "bun scripts/check-package-metadata.ts", "check:packages": "bun scripts/check-package-metadata.ts",
"check:arch": "steiger .", "check:arch": "steiger .",
@ -27,6 +27,7 @@
"figma:debug": "open -a Figma --args --remote-debugging-port=9222", "figma:debug": "open -a Figma --args --remote-debugging-port=9222",
"test:unit": "bun test ./tests/engine", "test:unit": "bun test ./tests/engine",
"test:coverage": "bun test --coverage ./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: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", "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", "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",

View file

@ -0,0 +1,8 @@
import type { Color } from '#core/types'
export interface ColorUsageEntry {
hex: string
color: Color
count: number
variableName: string | null
}

View file

@ -1,5 +1,6 @@
import { transform } from 'sucrase' import { transform } from 'sucrase'
import type { RenderOptions as RenderJSXOptions } from '#core/design-jsx/types'
import type { SceneGraph } from '#core/scene-graph' import type { SceneGraph } from '#core/scene-graph'
import * as React from './mini-react' 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 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. * Render a JSX string into the scene graph.
* Works in both Node/Bun and the browser. * Works in both Node/Bun and the browser.

View file

@ -1,4 +1,5 @@
import { parseColor } from '#core/color' import { parseColor } from '#core/color'
import type { RenderOptions } from '#core/design-jsx/types'
import { fetchIcons } from '#core/icons' import { fetchIcons } from '#core/icons'
import { createIconFromPaths } from '#core/icons/render' import { createIconFromPaths } from '#core/icons/render'
import { computeAllLayouts } from '#core/layout' import { computeAllLayouts } from '#core/layout'
@ -39,12 +40,6 @@ const TYPE_MAP: Partial<Record<string, NodeType>> = {
h6: 'TEXT' h6: 'TEXT'
} }
interface RenderOptions {
x?: number
y?: number
parentId?: string
}
export interface RenderResult { export interface RenderResult {
id: string id: string
name: string name: string

View file

@ -0,0 +1,5 @@
export interface RenderOptions {
x?: number
y?: number
parentId?: string
}

View file

@ -1,6 +1,7 @@
import { orderBy, sortBy } from 'es-toolkit/array' import { orderBy, sortBy } from 'es-toolkit/array'
import { colorToHex, colorDistance as colorDist } from '#core/color' 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 { SceneGraph, SceneNode } from '#core/scene-graph'
import type { Color } from '#core/types' import type { Color } from '#core/types'
@ -13,12 +14,7 @@ export interface AnalyzeColorsArgs {
similar?: boolean similar?: boolean
} }
interface ColorInfo { type ColorInfo = ColorUsageEntry
hex: string
color: Color
count: number
variableName: string | null
}
interface ColorCluster { interface ColorCluster {
colors: ColorInfo[] colors: ColorInfo[]

View file

@ -1,15 +1,11 @@
import { orderBy } from 'es-toolkit/array' import { orderBy } from 'es-toolkit/array'
import { colorDistance, colorToHex } from '#core/color' import { colorDistance, colorToHex } from '#core/color'
import type { ColorUsageEntry } from '#core/color/analysis'
import { defineTool } from '#core/tools/schema' import { defineTool } from '#core/tools/schema'
import type { Color } from '#core/types' import type { Color } from '#core/types'
interface ColorEntry { type ColorEntry = ColorUsageEntry
hex: string
color: Color
count: number
variableName: string | null
}
function trackColor(colorMap: Map<string, ColorEntry>, color: Color, variableName: string | null) { function trackColor(colorMap: Map<string, ColorEntry>, color: Color, variableName: string | null) {
const hex = colorToHex(color) const hex = colorToHex(color)

View file

@ -3,18 +3,13 @@ import { randomUUID } from 'node:crypto'
import type { WebSocket } from 'ws' import type { WebSocket } from 'ws'
import type { RpcJsonObject } from '#mcp/json' import type { RpcJsonObject } from '#mcp/json'
import type { PendingRequest } from '#mcp/rpc-types'
const RPC_TIMEOUT = 30_000 const RPC_TIMEOUT = 30_000
const APP_NOT_CONNECTED_MESSAGE = 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.' '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<typeof setTimeout>
}
type BrowserRpcBridgeOptions = { type BrowserRpcBridgeOptions = {
authToken: string | null authToken: string | null
onConnectionChange: () => void onConnectionChange: () => void

View file

@ -0,0 +1,5 @@
export type PendingRequest = {
resolve: (value: unknown) => void
reject: (error: Error) => void
timer: ReturnType<typeof setTimeout>
}

View file

@ -1,10 +1,6 @@
import { WebSocket } from 'ws' import { WebSocket } from 'ws'
type PendingRequest = { import type { PendingRequest } from '#mcp/rpc-types'
resolve: (value: unknown) => void
reject: (error: Error) => void
timer: ReturnType<typeof setTimeout>
}
type StdioRpcBridgeOptions = { type StdioRpcBridgeOptions = {
wsUrl: string wsUrl: string

View file

@ -0,0 +1 @@
export type DragOriginal = { x: number; y: number; parentId: string }

View file

@ -1,10 +1,9 @@
import type { Editor } from '@open-pencil/core/editor' import type { Editor } from '@open-pencil/core/editor'
import { useI18n } from '#vue/i18n/useI18n.js' import { useI18n } from '#vue/i18n/useI18n.js'
import type { DragOriginal } from '#vue/shared/input/drag-original'
import type { DragState } from '#vue/shared/input/types' import type { DragState } from '#vue/shared/input/types'
type DragOriginal = { x: number; y: number; parentId: string }
export function duplicateAndDrag( export function duplicateAndDrag(
cx: number, cx: number,
cy: number, cy: number,

View file

@ -1,10 +1,9 @@
import type { Editor } from '@open-pencil/core/editor' 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 { duplicateAndDrag } from '#vue/shared/input/duplicate-drag'
import type { DragState } from '#vue/shared/input/types' import type { DragState } from '#vue/shared/input/types'
type MoveOriginal = { x: number; y: number; parentId: string }
export function selectionIsLocked(editor: Editor) { export function selectionIsLocked(editor: Editor) {
return [...editor.state.selectedIds].every((id) => editor.graph.getNode(id)?.locked) return [...editor.state.selectedIds].every((id) => editor.graph.getNode(id)?.locked)
} }

123
scripts/type-shapes.ts Normal file
View file

@ -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<ts.TypeElement>) {
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<string, ShapeLocation[]>()
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.')

View file

@ -9,12 +9,7 @@ import {
getExportOptions, getExportOptions,
saveExportedFile saveExportedFile
} from '@/app/document/export/files' } from '@/app/document/export/files'
import type { ExportOptions } from '@/app/document/export/types'
type ExportOptions = {
scale?: number
quality?: number
jsxFormat?: 'openpencil' | 'tailwind'
}
type DownloadBlob = (data: Uint8Array, filename: string, mime: string) => void type DownloadBlob = (data: Uint8Array, filename: string, mime: string) => void

View file

@ -8,14 +8,9 @@ import type {
import { renderNodesToImage } from '@open-pencil/core/io/formats/raster' import { renderNodesToImage } from '@open-pencil/core/io/formats/raster'
import type { SceneGraph } from '@open-pencil/core/scene-graph' import type { SceneGraph } from '@open-pencil/core/scene-graph'
import type { ExportOptions } from '@/app/document/export/types'
import { isTauri } from '@/app/tauri/env' import { isTauri } from '@/app/tauri/env'
type ExportOptions = {
scale?: number
quality?: number
jsxFormat?: 'openpencil' | 'tailwind'
}
type ExportData = string | ArrayBuffer | Uint8Array type ExportData = string | ArrayBuffer | Uint8Array
type DownloadBlob = (data: Uint8Array, filename: string, mime: string) => void type DownloadBlob = (data: Uint8Array, filename: string, mime: string) => void

View file

@ -0,0 +1,5 @@
export type ExportOptions = {
scale?: number
quality?: number
jsxFormat?: 'openpencil' | 'tailwind'
}

View file

@ -1,11 +1,11 @@
import type { ViewportSize } from '@/app/document/io/types'
export function yieldToUI(): Promise<void> { export function yieldToUI(): Promise<void> {
return new Promise((resolve) => { return new Promise((resolve) => {
requestAnimationFrame(() => resolve()) requestAnimationFrame(() => resolve())
}) })
} }
type ViewportSize = { width: number; height: number }
type ViewportEditor = { type ViewportEditor = {
zoomToFit: () => void zoomToFit: () => void
} }

View file

@ -4,6 +4,7 @@ import { prefetchFigmaSchema } from '@open-pencil/core/kiwi'
import { createDocumentViewportActions, downloadBlob } from '@/app/document/io/browser' import { createDocumentViewportActions, downloadBlob } from '@/app/document/io/browser'
import { createOpenActions, createReloadActions } from '@/app/document/io/read' import { createOpenActions, createReloadActions } from '@/app/document/io/read'
import { createDocumentSourceActions, createDocumentSourceState } from '@/app/document/io/source' import { createDocumentSourceActions, createDocumentSourceState } from '@/app/document/io/source'
import type { ViewportSize } from '@/app/document/io/types'
import { createFileWatcher } from '@/app/document/io/watch' import { createFileWatcher } from '@/app/document/io/watch'
type DocumentIOState = EditorState & { type DocumentIOState = EditorState & {
@ -12,11 +13,6 @@ type DocumentIOState = EditorState & {
autosaveEnabled: boolean autosaveEnabled: boolean
} }
type ViewportSize = {
width: number
height: number
}
export function createDocumentIOActions( export function createDocumentIOActions(
editor: Editor, editor: Editor,
state: DocumentIOState, state: DocumentIOState,

View file

@ -0,0 +1 @@
export type ViewportSize = { width: number; height: number }

View file

@ -6,6 +6,7 @@ import type { SceneGraph } from '@open-pencil/core/scene-graph'
import { createDocumentExportActions } from '@/app/document/export' import { createDocumentExportActions } from '@/app/document/export'
import { createDocumentIOActions } from '@/app/document/io' import { createDocumentIOActions } from '@/app/document/io'
import type { ViewportSize } from '@/app/document/io/types'
import { createFlashActions } from '@/app/editor/flash' import { createFlashActions } from '@/app/editor/flash'
import { createMobileClipboardActions } from '@/app/editor/mobile-clipboard' import { createMobileClipboardActions } from '@/app/editor/mobile-clipboard'
import { createPenActions } from '@/app/editor/pen' 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 type { AppEditorState } from '@/app/editor/session/types'
import { createVectorEditActions } from '@/app/editor/vector-edit' import { createVectorEditActions } from '@/app/editor/vector-edit'
type ViewportSize = { width: number; height: number }
export function defineEditorStoreAccessors(store: object, editor: Editor) { export function defineEditorStoreAccessors(store: object, editor: Editor) {
Object.defineProperties(store, { Object.defineProperties(store, {
graph: { graph: {

View file

@ -10,12 +10,9 @@ import {
predownloadFallbackFonts, predownloadFallbackFonts,
requestLocalFontAccess requestLocalFontAccess
} from '@/app/editor/fonts' } from '@/app/editor/fonts'
import type { DownloadedFontCacheSummary } from '@/app/editor/fonts/cache'
export interface FontCacheSummary { type FontCacheSummary = DownloadedFontCacheSummary
count: number
byteLength: number
updatedAt: number | null
}
export interface FontSettingsActions { export interface FontSettingsActions {
clearDownloadedFontCache: () => Promise<void> clearDownloadedFontCache: () => Promise<void>

View file

@ -1,6 +1,6 @@
import { useClipboard } from '@vueuse/core' import { useClipboard } from '@vueuse/core'
import { computed, inject, provide, proxyRefs } from 'vue' 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 { useRouter } from 'vue-router'
import IconFilePlus from '~icons/lucide/file-plus' import IconFilePlus from '~icons/lucide/file-plus'
import IconFolderOpen from '~icons/lucide/folder-open' 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 { toolIcons } from '@/app/editor/icons'
import { openFileDialog } from '@/app/shell/menu/use' import { openFileDialog } from '@/app/shell/menu/use'
import { toast } from '@/app/shell/ui' import { toast } from '@/app/shell/ui'
import type { ToolbarActionItem } from '@/components/Toolbar/types'
interface MenuAction { type MenuAction = ToolbarActionItem
icon: Component
label: string
action: () => void
}
function createMobileHudContext() { function createMobileHudContext() {
const router = useRouter() const router = useRouter()