openpencil/packages/core/src/rpc/commands.ts
Danila Poyarkov f3eac5ac8b Lint all packages: fix 193 errors across core, mcp, acp
Extend lint coverage from src/ to all packages (178 files, 140 rules).

Core (37 files):
- Remove 98 unnecessary conditions (dead ?./?? on non-nullish values)
- Remove 38 unnecessary type assertions (! and as casts)
- Convert 12 patterns to optional chain (a && a.b → a?.b)
- Fix 10 consistent-type-imports (merge duplicates, inline type keyword)
- Add parentheses to 16 nested ternaries
- Convert 4 indexed for-loops to for-of
- Merge 4 duplicate import statements

MCP (3 files):
- Replace typeof undefined check with 'in' operator
- Capture graph in local const to avoid ! assertions

ACP (1 file):
- Fix 5 TypeScript errors (type predicates, object shapes, dead code)
- Remove unnecessary template expression

Vendored kiwi-schema gets broader rule overrides to avoid touching it.
Core complexity stays as warning (24 functions over limit).
2026-03-09 13:18:08 +03:00

625 lines
17 KiB
TypeScript

import { colorToHex, colorDistance as colorDist } from '../color'
import type { Color } from '../types'
import type { SceneGraph, SceneNode, Variable } from '../scene-graph'
export interface RpcCommand<A = unknown, R = unknown> {
name: string
execute: (graph: SceneGraph, args: A) => R
}
/** Walk descendants. Callback returns `false` to stop traversal. */
function walkNodes(graph: SceneGraph, rootId: string, fn: (node: SceneNode) => boolean): boolean {
const node = graph.getNode(rootId)
if (!node) return true
if (!fn(node)) return false
for (const childId of node.childIds) {
if (!walkNodes(graph, childId, fn)) return false
}
return true
}
function countNodes(graph: SceneGraph, pageId: string): number {
let count = 0
const page = graph.getNode(pageId)
if (page) for (const cid of page.childIds) walkNodes(graph, cid, () => { count++; return true })
return count
}
// ── info ──
export interface InfoResult {
pages: number
totalNodes: number
types: Record<string, number>
fonts: string[]
pageCounts: Record<string, number>
}
export const infoCommand: RpcCommand<void, InfoResult> = {
name: 'info',
execute: (graph) => {
const pages = graph.getPages()
let totalNodes = 0
const types: Record<string, number> = {}
const fonts = new Set<string>()
const pageCounts: Record<string, number> = {}
for (const page of pages) {
let pageCount = 0
for (const cid of page.childIds) {
walkNodes(graph, cid, (node) => {
totalNodes++
pageCount++
types[node.type] = (types[node.type] ?? 0) + 1
if (node.fontFamily) fonts.add(node.fontFamily)
return true
})
}
pageCounts[page.name] = pageCount
}
return { pages: pages.length, totalNodes, types, fonts: [...fonts].sort(), pageCounts }
}
}
// ── pages ──
export interface PageItem {
id: string
name: string
nodes: number
}
export const pagesCommand: RpcCommand<void, PageItem[]> = {
name: 'pages',
execute: (graph) => {
return graph.getPages().map((p) => ({ id: p.id, name: p.name, nodes: countNodes(graph, p.id) }))
}
}
// ── tree ──
export interface TreeArgs {
page?: string
depth?: number
}
export interface TreeNodeResult {
id: string
name: string
type: string
x: number
y: number
width: number
height: number
children?: TreeNodeResult[]
}
function buildTreeNode(graph: SceneGraph, id: string, depth: number, maxDepth: number): TreeNodeResult | null {
const node = graph.getNode(id)
if (!node) return null
const result: TreeNodeResult = {
id: node.id,
name: node.name,
type: node.type,
x: Math.round(node.x),
y: Math.round(node.y),
width: Math.round(node.width),
height: Math.round(node.height)
}
if (node.childIds.length > 0 && depth < maxDepth) {
result.children = node.childIds
.map((cid) => buildTreeNode(graph, cid, depth + 1, maxDepth))
.filter((n): n is TreeNodeResult => n !== null)
}
return result
}
export interface TreeResult {
page: { id: string; name: string; type: string }
children: TreeNodeResult[]
}
export const treeCommand: RpcCommand<TreeArgs, TreeResult | { error: string }> = {
name: 'tree',
execute: (graph, args) => {
const pages = graph.getPages()
const maxDepth = args.depth ?? Infinity
const page = args.page ? pages.find((p) => p.name === args.page) : pages[0]
if (!page) return { error: `Page "${args.page}" not found. Available: ${pages.map((p) => p.name).join(', ')}` }
return {
page: { id: page.id, name: page.name, type: page.type },
children: page.childIds
.map((cid) => buildTreeNode(graph, cid, 0, maxDepth))
.filter((n): n is TreeNodeResult => n !== null)
}
}
}
// ── find ──
export interface FindArgs {
name?: string
type?: string
page?: string
limit?: number
}
export interface FindNodeResult {
id: string
name: string
type: string
width: number
height: number
}
export const findCommand: RpcCommand<FindArgs, FindNodeResult[]> = {
name: 'find',
execute: (graph, args) => {
const pages = graph.getPages()
const max = args.limit ?? 100
const namePattern = args.name?.toLowerCase()
const typeFilter = args.type?.toUpperCase()
const results: FindNodeResult[] = []
const searchPage = (page: SceneNode) => {
for (const cid of page.childIds) {
const cont = walkNodes(graph, cid, (node) => {
if (results.length >= max) return false
const matchesName = !namePattern || node.name.toLowerCase().includes(namePattern)
const matchesType = !typeFilter || node.type === typeFilter
if (matchesName && matchesType) {
results.push({
id: node.id, name: node.name, type: node.type,
width: Math.round(node.width), height: Math.round(node.height)
})
}
return true
})
if (!cont) break
}
}
if (args.page) {
const page = pages.find((p) => p.name === args.page)
if (page) searchPage(page)
} else {
for (const page of pages) searchPage(page)
}
return results
}
}
// ── node ──
export interface NodeArgs {
id: string
}
export interface NodeResult {
id: string
name: string
type: string
x: number
y: number
width: number
height: number
visible: boolean
locked: boolean
opacity: number
rotation: number
fills: unknown[]
strokes: unknown[]
effects: unknown[]
cornerRadius: number
blendMode: string
layoutMode: string
fontFamily: string
fontSize: number
fontWeight: number
text: string | null
parent: { id: string; name: string; type: string } | null
children: number
boundVariables: Record<string, string>
}
export const nodeCommand: RpcCommand<NodeArgs, NodeResult | { error: string }> = {
name: 'node',
execute: (graph, args) => {
const node = graph.getNode(args.id)
if (!node) return { error: `Node "${args.id}" not found` }
const parent = node.parentId ? graph.getNode(node.parentId) : undefined
const boundVars: Record<string, string> = {}
for (const [field, varId] of Object.entries(node.boundVariables)) {
const variable = graph.variables.get(varId)
boundVars[field] = variable?.name ?? varId
}
return {
id: node.id,
name: node.name,
type: node.type,
x: Math.round(node.x),
y: Math.round(node.y),
width: Math.round(node.width),
height: Math.round(node.height),
visible: node.visible,
locked: node.locked,
opacity: node.opacity,
rotation: node.rotation,
fills: node.fills,
strokes: node.strokes,
effects: node.effects,
cornerRadius: node.cornerRadius,
blendMode: node.blendMode,
layoutMode: node.layoutMode,
fontFamily: node.fontFamily,
fontSize: node.fontSize,
fontWeight: node.fontWeight,
text: node.text.length ? (node.text.length > 200 ? node.text.slice(0, 200) + '…' : node.text) : null,
parent: parent ? { id: parent.id, name: parent.name, type: parent.type } : null,
children: node.childIds.length,
boundVariables: boundVars
}
}
}
// ── variables ──
export interface VariablesArgs {
collection?: string
type?: string
}
function formatVariableValue(variable: Variable, graph: SceneGraph): string {
const modeId = graph.getActiveModeId(variable.collectionId)
const raw = variable.valuesByMode[modeId]
if (typeof raw === 'object' && 'aliasId' in raw) {
const alias = graph.variables.get(raw.aliasId)
return alias ? `${alias.name}` : `${raw.aliasId}`
}
if (typeof raw === 'object' && 'r' in raw) {
return colorToHex(raw).toLowerCase()
}
return String(raw)
}
export interface VariablesResult {
collections: Array<{
id: string
name: string
modes: string[]
variables: Array<{
id: string
name: string
type: string
value: string
}>
}>
totalVariables: number
totalCollections: number
}
export const variablesCommand: RpcCommand<VariablesArgs, VariablesResult> = {
name: 'variables',
execute: (graph, args) => {
const typeFilter = args.type?.toUpperCase()
const collFilter = args.collection?.toLowerCase()
const result: VariablesResult = {
collections: [],
totalVariables: graph.variables.size,
totalCollections: graph.variableCollections.size
}
for (const coll of graph.variableCollections.values()) {
if (collFilter && !coll.name.toLowerCase().includes(collFilter)) continue
const collVars = graph.getVariablesForCollection(coll.id)
.filter((v) => !typeFilter || v.type === typeFilter)
if (collVars.length === 0) continue
result.collections.push({
id: coll.id,
name: coll.name,
modes: coll.modes.map((m) => m.name),
variables: collVars.map((v) => ({
id: v.id,
name: v.name,
type: v.type,
value: formatVariableValue(v, graph)
}))
})
}
return result
}
}
// ── analyze colors ──
export interface AnalyzeColorsArgs {
threshold?: number
similar?: boolean
}
interface ColorInfo {
hex: string
color: Color
count: number
variableName: string | null
}
interface ColorCluster {
colors: ColorInfo[]
suggestedHex: string
totalCount: number
}
function clusterColors(colors: ColorInfo[], threshold: number): ColorCluster[] {
const clusters: ColorCluster[] = []
const used = new Set<string>()
const sorted = [...colors].sort((a, b) => b.count - a.count)
for (const color of sorted) {
if (used.has(color.hex)) continue
const cluster: ColorCluster = { colors: [color], suggestedHex: color.hex, totalCount: color.count }
used.add(color.hex)
for (const other of sorted) {
if (used.has(other.hex)) continue
if (colorDist(color.color, other.color) <= threshold) {
cluster.colors.push(other)
cluster.totalCount += other.count
used.add(other.hex)
}
}
if (cluster.colors.length > 1) clusters.push(cluster)
}
return clusters.sort((a, b) => b.colors.length - a.colors.length)
}
function collectColors(graph: SceneGraph): { colors: ColorInfo[]; totalNodes: number } {
const colorMap = new Map<string, ColorInfo>()
let totalNodes = 0
const addColor = (c: Color, variableName: string | null) => {
const hex = colorToHex(c).toLowerCase()
const existing = colorMap.get(hex)
if (existing) {
existing.count++
if (variableName && !existing.variableName) existing.variableName = variableName
} else {
colorMap.set(hex, { hex, color: c, count: 1, variableName })
}
}
for (const node of graph.getAllNodes()) {
if (node.type === 'CANVAS') continue
totalNodes++
for (const fill of node.fills) {
if (!fill.visible || fill.type !== 'SOLID') continue
addColor(fill.color, null)
}
for (const stroke of node.strokes) {
if (!stroke.visible) continue
addColor(stroke.color, null)
}
for (const effect of node.effects) {
if (!effect.visible) continue
addColor(effect.color, null)
}
for (const [field, varId] of Object.entries(node.boundVariables)) {
if (!field.includes('fill') && !field.includes('stroke') && !field.includes('color')) continue
const variable = graph.variables.get(varId)
if (variable) {
const resolvedColor = graph.resolveColorVariable(varId)
if (resolvedColor) {
const hex = colorToHex(resolvedColor).toLowerCase()
const existing = colorMap.get(hex)
if (existing) existing.variableName = variable.name
}
}
}
}
return { colors: [...colorMap.values()], totalNodes }
}
export interface AnalyzeColorsResult {
colors: ColorInfo[]
totalNodes: number
clusters: ColorCluster[]
}
export const analyzeColorsCommand: RpcCommand<AnalyzeColorsArgs, AnalyzeColorsResult> = {
name: 'analyze_colors',
execute: (graph, args) => {
const { colors, totalNodes } = collectColors(graph)
const clusters = args.similar ? clusterColors(colors.filter((c) => !c.variableName), args.threshold ?? 15) : []
return { colors: colors.sort((a, b) => b.count - a.count), totalNodes, clusters }
}
}
// ── analyze typography ──
export interface AnalyzeTypographyArgs {}
export interface TypographyStyle {
family: string
size: number
weight: number
lineHeight: string
count: number
}
export interface AnalyzeTypographyResult {
styles: TypographyStyle[]
totalTextNodes: number
}
export const analyzeTypographyCommand: RpcCommand<AnalyzeTypographyArgs, AnalyzeTypographyResult> = {
name: 'analyze_typography',
execute: (graph) => {
const styleMap = new Map<string, TypographyStyle>()
let totalTextNodes = 0
for (const node of graph.getAllNodes()) {
if (node.type !== 'TEXT') continue
totalTextNodes++
const lh = node.lineHeight === null ? 'auto' : `${node.lineHeight}px`
const key = `${node.fontFamily}|${node.fontSize}|${node.fontWeight}|${lh}`
const existing = styleMap.get(key)
if (existing) { existing.count++ } else {
styleMap.set(key, { family: node.fontFamily, size: node.fontSize, weight: node.fontWeight, lineHeight: lh, count: 1 })
}
}
return { styles: [...styleMap.values()].sort((a, b) => b.count - a.count), totalTextNodes }
}
}
// ── analyze spacing ──
export interface SpacingValue {
value: number
count: number
}
export interface AnalyzeSpacingResult {
gaps: SpacingValue[]
paddings: SpacingValue[]
totalNodes: number
}
export const analyzeSpacingCommand: RpcCommand<void, AnalyzeSpacingResult> = {
name: 'analyze_spacing',
execute: (graph) => {
const gapMap = new Map<number, number>()
const paddingMap = new Map<number, number>()
let totalNodes = 0
for (const node of graph.getAllNodes()) {
if (node.type === 'CANVAS' || node.layoutMode === 'NONE') continue
totalNodes++
if (node.itemSpacing > 0) gapMap.set(node.itemSpacing, (gapMap.get(node.itemSpacing) ?? 0) + 1)
if (node.counterAxisSpacing > 0) gapMap.set(node.counterAxisSpacing, (gapMap.get(node.counterAxisSpacing) ?? 0) + 1)
for (const pad of [node.paddingTop, node.paddingRight, node.paddingBottom, node.paddingLeft]) {
if (pad > 0) paddingMap.set(pad, (paddingMap.get(pad) ?? 0) + 1)
}
}
const toValues = (map: Map<number, number>) =>
[...map.entries()].map(([value, count]) => ({ value, count })).sort((a, b) => b.count - a.count)
return { gaps: toValues(gapMap), paddings: toValues(paddingMap), totalNodes }
}
}
// ── analyze clusters ──
export interface AnalyzeClustersArgs {
limit?: number
minSize?: number
minCount?: number
}
interface ClusterNode {
id: string
name: string
type: string
width: number
height: number
childCount: number
}
export interface AnalyzeClustersResult {
clusters: Array<{
signature: string
nodes: ClusterNode[]
}>
totalNodes: number
}
function buildSignature(graph: SceneGraph, node: SceneNode): string {
const childTypes = new Map<string, number>()
for (const childId of node.childIds) {
const child = graph.getNode(childId)
if (!child) continue
childTypes.set(child.type, (childTypes.get(child.type) ?? 0) + 1)
}
const childPart = [...childTypes.entries()].sort((a, b) => a[0].localeCompare(b[0])).map(([t, c]) => `${t}:${c}`).join(',')
const w = Math.round(node.width / 10) * 10
const h = Math.round(node.height / 10) * 10
return `${node.type}:${w}x${h}|${childPart}`
}
export const analyzeClustersCommand: RpcCommand<AnalyzeClustersArgs, AnalyzeClustersResult> = {
name: 'analyze_clusters',
execute: (graph, args) => {
const minSize = args.minSize ?? 30
const minCount = args.minCount ?? 2
const limit = args.limit ?? 20
const sigMap = new Map<string, ClusterNode[]>()
let totalNodes = 0
for (const node of graph.getAllNodes()) {
if (node.type === 'CANVAS') continue
totalNodes++
if (node.width < minSize || node.height < minSize) continue
if (node.childIds.length === 0) continue
const sig = buildSignature(graph, node)
const arr = sigMap.get(sig) ?? []
arr.push({
id: node.id, name: node.name, type: node.type,
width: Math.round(node.width), height: Math.round(node.height), childCount: node.childIds.length
})
sigMap.set(sig, arr)
}
const clusters = [...sigMap.entries()]
.filter(([, nodes]) => nodes.length >= minCount)
.map(([signature, nodes]) => ({ signature, nodes }))
.sort((a, b) => b.nodes.length - a.nodes.length)
.slice(0, limit)
return { clusters, totalNodes }
}
}
// ── registry ──
export const ALL_RPC_COMMANDS = [
infoCommand,
pagesCommand,
treeCommand,
findCommand,
nodeCommand,
variablesCommand,
analyzeColorsCommand,
analyzeTypographyCommand,
analyzeSpacingCommand,
analyzeClustersCommand
] as RpcCommand[]
export function executeRpcCommand(graph: SceneGraph, name: string, args: unknown): unknown {
const cmd = ALL_RPC_COMMANDS.find((c) => c.name === name)
if (!cmd) throw new Error(`Unknown command: ${name}`)
return cmd.execute(graph, args as never)
}