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:
parent
2f8dfd883a
commit
f4b3453fbd
|
|
@ -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",
|
||||
|
|
|
|||
8
packages/core/src/color/analysis.ts
Normal file
8
packages/core/src/color/analysis.ts
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
import type { Color } from '#core/types'
|
||||
|
||||
export interface ColorUsageEntry {
|
||||
hex: string
|
||||
color: Color
|
||||
count: number
|
||||
variableName: string | null
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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<Record<string, NodeType>> = {
|
|||
h6: 'TEXT'
|
||||
}
|
||||
|
||||
interface RenderOptions {
|
||||
x?: number
|
||||
y?: number
|
||||
parentId?: string
|
||||
}
|
||||
|
||||
export interface RenderResult {
|
||||
id: string
|
||||
name: string
|
||||
|
|
|
|||
5
packages/core/src/design-jsx/types.ts
Normal file
5
packages/core/src/design-jsx/types.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
export interface RenderOptions {
|
||||
x?: number
|
||||
y?: number
|
||||
parentId?: string
|
||||
}
|
||||
|
|
@ -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[]
|
||||
|
|
|
|||
|
|
@ -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<string, ColorEntry>, color: Color, variableName: string | null) {
|
||||
const hex = colorToHex(color)
|
||||
|
|
|
|||
|
|
@ -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<typeof setTimeout>
|
||||
}
|
||||
|
||||
type BrowserRpcBridgeOptions = {
|
||||
authToken: string | null
|
||||
onConnectionChange: () => void
|
||||
|
|
|
|||
5
packages/mcp/src/rpc-types.ts
Normal file
5
packages/mcp/src/rpc-types.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
export type PendingRequest = {
|
||||
resolve: (value: unknown) => void
|
||||
reject: (error: Error) => void
|
||||
timer: ReturnType<typeof setTimeout>
|
||||
}
|
||||
|
|
@ -1,10 +1,6 @@
|
|||
import { WebSocket } from 'ws'
|
||||
|
||||
type PendingRequest = {
|
||||
resolve: (value: unknown) => void
|
||||
reject: (error: Error) => void
|
||||
timer: ReturnType<typeof setTimeout>
|
||||
}
|
||||
import type { PendingRequest } from '#mcp/rpc-types'
|
||||
|
||||
type StdioRpcBridgeOptions = {
|
||||
wsUrl: string
|
||||
|
|
|
|||
1
packages/vue/src/shared/input/drag-original.ts
Normal file
1
packages/vue/src/shared/input/drag-original.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export type DragOriginal = { x: number; y: number; parentId: string }
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
123
scripts/type-shapes.ts
Normal file
123
scripts/type-shapes.ts
Normal 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.')
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
5
src/app/document/export/types.ts
Normal file
5
src/app/document/export/types.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
export type ExportOptions = {
|
||||
scale?: number
|
||||
quality?: number
|
||||
jsxFormat?: 'openpencil' | 'tailwind'
|
||||
}
|
||||
|
|
@ -1,11 +1,11 @@
|
|||
import type { ViewportSize } from '@/app/document/io/types'
|
||||
|
||||
export function yieldToUI(): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
requestAnimationFrame(() => resolve())
|
||||
})
|
||||
}
|
||||
|
||||
type ViewportSize = { width: number; height: number }
|
||||
|
||||
type ViewportEditor = {
|
||||
zoomToFit: () => void
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
1
src/app/document/io/types.ts
Normal file
1
src/app/document/io/types.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export type ViewportSize = { width: number; height: number }
|
||||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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<void>
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
Loading…
Reference in a new issue