From 1ed7de6056b59c66dc296dc03abfb65dfea43071 Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Mon, 25 May 2026 00:26:02 +0300 Subject: [PATCH] chore(lint): reject broad unknown type assertions - Add AST-based lint checks for broad unknown object assertions and local JsonObject aliases - Centralize JsonObject in core and package-local MCP RPC JSON typing - Replace baseline Record assertions with named shared/domain types --- lint/plugin.js | 106 ++++++++++++++++++ oxlint.json | 2 + packages/core/src/clipboard/openpencil.ts | 5 +- .../core/src/design-jsx/props-overrides.ts | 3 +- packages/core/src/design-jsx/tree.ts | 8 +- packages/core/src/editor/text/session.ts | 5 +- .../src/kiwi/fig/instance-overrides/index.ts | 3 +- .../fig/instance-overrides/symbol/patches.ts | 28 ++--- .../src/kiwi/fig/instance-overrides/types.ts | 8 +- .../core/src/kiwi/fig/node-change/convert.ts | 20 +++- .../src/kiwi/fig/node-change/export-node.ts | 58 ++++++---- .../src/kiwi/fig/node-change/serialize.ts | 6 +- packages/core/src/kiwi/fig/parse/core.ts | 6 +- packages/core/src/tools/ai-adapter.ts | 3 +- packages/core/src/tools/stock-photo/apply.ts | 6 +- packages/core/src/types.ts | 5 + packages/mcp/src/browser-rpc.ts | 4 +- packages/mcp/src/json.ts | 1 + packages/mcp/src/server.ts | 6 +- packages/mcp/src/tool/registration.ts | 3 +- scripts/check-locales.ts | 4 +- src/app/ai/acp/map-update.ts | 8 +- src/app/ai/debug/index.ts | 7 +- src/app/automation/bridge/tool-handlers.ts | 11 +- src/components/ChatPanel.vue | 3 +- tests/engine/figma/api/serialization.test.ts | 6 +- tests/engine/io/fig/export/text.test.ts | 25 ++--- .../io/fig/roundtrip/exhaustive.test.ts | 33 +++--- tests/engine/io/fig/roundtrip/helpers.ts | 42 +++---- .../io/fig/roundtrip/raw-verifiers/helpers.ts | 22 ++-- .../auto-layout/transforms.test.ts | 10 +- .../borders/take-space.test.ts | 6 +- .../kiwi/serialize-fixes/font/family.test.ts | 2 +- .../kiwi/serialize-fixes/frame/mask.test.ts | 6 +- .../kiwi/serialize-fixes/integration.test.ts | 2 +- .../kiwi/serialize-fixes/line/height.test.ts | 4 +- tests/engine/mcp/tools.test.ts | 19 ++-- .../scene-graph/individual-strokes.test.ts | 4 +- .../engine/scene-graph/undo/mutation.test.ts | 3 +- tests/engine/tools/ai-adapter.test.ts | 7 +- 40 files changed, 338 insertions(+), 172 deletions(-) create mode 100644 packages/mcp/src/json.ts diff --git a/lint/plugin.js b/lint/plugin.js index f7404e38b..40747a82a 100644 --- a/lint/plugin.js +++ b/lint/plugin.js @@ -1609,7 +1609,111 @@ const noSiblingDomainPrefixedFiles = createProgramFilenameRule({ } }) +function typeParameterNodes(node) { + return node?.typeParameters?.params ?? node?.typeArguments?.params ?? [] +} + +function isRecordStringUnknownType(node) { + if (node?.type !== 'TSTypeReference') return false + if (node.typeName?.type !== 'Identifier' || node.typeName.name !== 'Record') return false + const params = typeParameterNodes(node) + return params[0]?.type === 'TSStringKeyword' && params[1]?.type === 'TSUnknownKeyword' +} + +function hasAstChild(node, predicate, seen = new WeakSet()) { + if (!node || typeof node !== 'object') return false + if (seen.has(node)) return false + seen.add(node) + if (predicate(node)) return true + + for (const [key, value] of Object.entries(node)) { + if (key === 'parent' || key === 'range' || key === 'loc') continue + if (Array.isArray(value)) { + if (value.some((child) => hasAstChild(child, predicate, seen))) return true + } else if (value && typeof value === 'object' && hasAstChild(value, predicate, seen)) { + return true + } + } + return false +} + +function containsRecordStringUnknownType(node) { + if (isRecordStringUnknownType(node)) return true + if (node?.type === 'TSArrayType') return isRecordStringUnknownType(node.elementType) + if (node?.type === 'TSUnionType') return node.types?.some(containsRecordStringUnknownType) ?? false + return false +} + +function isUnknownArrayType(node) { + return node?.type === 'TSArrayType' && node.elementType?.type === 'TSUnknownKeyword' +} + +function hasInlineUnknownArrayProperty(node) { + if (node?.type !== 'TSTypeLiteral') return false + return (node.members ?? []).some((member) => { + const typeNode = member.typeAnnotation?.typeAnnotation + return isUnknownArrayType(typeNode) + }) +} + +function containsInlineUnknownObjectType(node) { + return hasAstChild(node, hasInlineUnknownArrayProperty) +} + +const noBroadUnknownTypeAssertions = { + meta: { + docs: { + description: + 'Disallow broad unknown object type assertions. Add a named domain type or a type guard instead.' + } + }, + create(context) { + function check(node) { + if (containsRecordStringUnknownType(node.typeAnnotation)) { + context.report({ + node, + message: + 'Do not cast to Record. Add a named domain type or a type guard.' + }) + return + } + if (containsInlineUnknownObjectType(node.typeAnnotation)) { + context.report({ + node, + message: 'Do not cast to an inline unknown object shape. Add a named domain type.' + }) + } + } + + return { + TSAsExpression: check, + TSTypeAssertion: check + } + } +} + +const noLocalJsonObjectAliases = { + meta: { + docs: { + description: 'Disallow local JsonObject aliases — import the shared type instead' + } + }, + create(context) { + return { + TSTypeAliasDeclaration(node) { + if (node.id?.name !== 'JsonObject') return + if (!isRecordStringUnknownType(node.typeAnnotation)) return + context.report({ + node, + message: 'Import JsonObject from @open-pencil/core/types instead of declaring a local alias.' + }) + } + } + } +} + const noFlatKiwiModules = createProgramFilenameRule({ + description: 'Disallow flat top-level Kiwi modules — group code under Kiwi subdomains', check(file) { const marker = '/packages/core/src/kiwi/' @@ -1664,6 +1768,8 @@ const plugin = { 'no-direct-storage-access': noDirectStorageAccess, 'no-broad-double-cast': noBroadDoubleCast, 'no-unknown-record-double-cast': noUnknownRecordDoubleCast, + 'no-broad-unknown-type-assertions': noBroadUnknownTypeAssertions, + 'no-local-json-object-aliases': noLocalJsonObjectAliases, '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 cda2b9241..68871cd96 100644 --- a/oxlint.json +++ b/oxlint.json @@ -174,6 +174,8 @@ "open-pencil/no-deep-parent-relative-imports": "error", "open-pencil/no-broad-double-cast": "error", "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-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/clipboard/openpencil.ts b/packages/core/src/clipboard/openpencil.ts index adf760d08..86db448b6 100644 --- a/packages/core/src/clipboard/openpencil.ts +++ b/packages/core/src/clipboard/openpencil.ts @@ -1,6 +1,7 @@ import { deflateSync, inflateSync } from 'fflate' import type { SceneGraph, SceneNode } from '#core/scene-graph' +import type { JsonObject } from '#core/types' // --- Internal copy/paste (OpenPencil ↔ OpenPencil) --- @@ -40,7 +41,7 @@ export function parseOpenPencilClipboard(html: string): OpenPencilClipboardData return null } -function restoreTextPictures(nodes: Array>): void { +function restoreTextPictures(nodes: JsonObject[]): void { for (const node of nodes) { if (typeof node.textPicture === 'string') { node.textPicture = Uint8Array.fromBase64(node.textPicture) @@ -92,7 +93,7 @@ function collectNodeTree( nodes: SceneNode[], graph: SceneGraph, textPictureBuilder?: TextPictureBuilder -): Array> { +): JsonObject[] { return nodes.map((node) => { const children = graph.getChildren(node.id) const serialized: Record = { ...node } diff --git a/packages/core/src/design-jsx/props-overrides.ts b/packages/core/src/design-jsx/props-overrides.ts index 9073d44b4..4a1a3f70d 100644 --- a/packages/core/src/design-jsx/props-overrides.ts +++ b/packages/core/src/design-jsx/props-overrides.ts @@ -1,6 +1,7 @@ import { colorToFill, parseColor } from '#core/color' import { TRANSPARENT } from '#core/constants' import type { GridTrack, LayoutMode, SceneNode, Stroke } from '#core/scene-graph' +import type { JsonObject } from '#core/types' const WEIGHT_MAP: Record = { normal: 400, @@ -83,7 +84,7 @@ function normalizeStyleProps(props: Record): Record + const source = style as JsonObject const normalized = { ...props } const copyIfUnset = (from: string, to: string, convert?: (value: unknown) => unknown): void => { if (normalized[to] !== undefined || source[from] === undefined) return diff --git a/packages/core/src/design-jsx/tree.ts b/packages/core/src/design-jsx/tree.ts index db0a5d1de..7aa64cc9a 100644 --- a/packages/core/src/design-jsx/tree.ts +++ b/packages/core/src/design-jsx/tree.ts @@ -15,6 +15,8 @@ export function isTreeNode(x: unknown): x is TreeNode { ) } +type FunctionComponent = (props: Record) => unknown + interface ReactElement { type: unknown props: Record @@ -35,10 +37,8 @@ export function resolveToTree(element: unknown, depth = 0): TreeNode | null { if (!isReactElement(element)) return null if (typeof element.type === 'function') { - return resolveToTree( - (element.type as (p: Record) => unknown)(element.props), - depth + 1 - ) + const component = element.type as FunctionComponent + return resolveToTree(component(element.props), depth + 1) } if (typeof element.type === 'string') { diff --git a/packages/core/src/editor/text/session.ts b/packages/core/src/editor/text/session.ts index 70bc71a1b..6e34355fd 100644 --- a/packages/core/src/editor/text/session.ts +++ b/packages/core/src/editor/text/session.ts @@ -2,6 +2,7 @@ import type { Paragraph } from 'canvaskit-wasm' import type { SceneNode, StyleRun } from '#core/scene-graph' import { copyStyleRuns } from '#core/scene-graph/copy' +import type { JsonObject } from '#core/types' export type TextEditSizeSnapshot = Partial> @@ -103,8 +104,8 @@ function deepEqual(a: unknown, b: unknown): boolean { if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length) return false return a.every((item, index) => deepEqual(item, b[index])) } - const aRecord = a as Record - const bRecord = b as Record + const aRecord = a as JsonObject + const bRecord = b as JsonObject const aKeys = Object.keys(aRecord) const bKeys = Object.keys(bRecord) if (aKeys.length !== bKeys.length) return false diff --git a/packages/core/src/kiwi/fig/instance-overrides/index.ts b/packages/core/src/kiwi/fig/instance-overrides/index.ts index 9914d2b23..8c853d09f 100644 --- a/packages/core/src/kiwi/fig/instance-overrides/index.ts +++ b/packages/core/src/kiwi/fig/instance-overrides/index.ts @@ -13,6 +13,7 @@ export type { import { guidToString } from '#core/kiwi/fig/node-change/convert' import type { SceneGraph, SceneNode } from '#core/scene-graph' import { copyFills, copyStyleRuns } from '#core/scene-graph/copy' +import type { JsonObject } from '#core/types' import { applyComponentProperties } from './component-props' import { applyConstraintScaling } from './constraints' @@ -44,7 +45,7 @@ function buildKiwiPropertyNodes( ): Set { const result = new Set() for (const [nodeId, change] of changedNodeEntries(changeMap, guidToNodeId)) { - const nc = change as Record + const nc = change as JsonObject const node = graph.getNode(nodeId) if (!node?.componentId) continue const comp = graph.getNode(node.componentId) diff --git a/packages/core/src/kiwi/fig/instance-overrides/symbol/patches.ts b/packages/core/src/kiwi/fig/instance-overrides/symbol/patches.ts index ecedf7a4d..61895bc06 100644 --- a/packages/core/src/kiwi/fig/instance-overrides/symbol/patches.ts +++ b/packages/core/src/kiwi/fig/instance-overrides/symbol/patches.ts @@ -1,6 +1,10 @@ import type { GUID } from '#core/kiwi/fig/codec' import type { OverridePatch } from '#core/kiwi/fig/instance-overrides/patches' -import type { OverrideContext, SymbolOverride } from '#core/kiwi/fig/instance-overrides/types' +import type { + OverrideContext, + SymbolOverride, + SymbolOverrideFields +} from '#core/kiwi/fig/instance-overrides/types' import { guidToString, VARIABLE_BINDING_FIELDS_INVERSE } from '#core/kiwi/fig/node-change/convert' import { applyStyleRefsToFields } from '#core/kiwi/fig/node-change/style-refs' @@ -61,21 +65,16 @@ function resolveFloatVariable( function applyVariableRadiusOverrides( ctx: OverrideContext, - fields: Record, + fields: SymbolOverrideFields, props: ReturnType ): void { - const entries = (fields.variableConsumptionMap as { entries?: unknown[] } | undefined)?.entries + const entries = fields.variableConsumptionMap?.entries if (!entries?.length) return const assetRefs = buildAssetRefMap(ctx) for (const entry of entries) { - if (!entry || typeof entry !== 'object') continue - const variableEntry = entry as { - variableField?: string - variableData?: { value?: { alias?: AliasRef } } - } - const variableField = variableEntry.variableField + const variableField = entry.variableField if (!variableField || !VARIABLE_RADIUS_FIELDS.has(variableField)) continue - const alias = variableEntry.variableData?.value?.alias + const alias = entry.variableData?.value?.alias const id = alias ? resolveAliasId(alias, assetRefs) : undefined const value = id ? resolveFloatVariable(ctx, id, assetRefs) : undefined if (typeof value !== 'number') continue @@ -98,11 +97,14 @@ export function patchFromSymbolOverride( patch.swapComponentId = ctx.guidToNodeId.get(swapGuid) } - const { guidPath: _, overriddenSymbolID: _s, componentPropAssignments: _c, ...fields } = ov + const fields: SymbolOverrideFields = { ...ov } + delete fields.guidPath + delete fields.overriddenSymbolID + delete fields.componentPropAssignments if (Object.keys(fields).length > 0) { applyStyleRefsToFields(ctx.changeMap, fields) - const props = convertOverrideToProps(fields as Record) - applyVariableRadiusOverrides(ctx, fields as Record, props) + const props = convertOverrideToProps(fields) + applyVariableRadiusOverrides(ctx, fields, props) if (Object.keys(props).length > 0) patch.props = props } diff --git a/packages/core/src/kiwi/fig/instance-overrides/types.ts b/packages/core/src/kiwi/fig/instance-overrides/types.ts index 5a82d2938..5e79e1d3c 100644 --- a/packages/core/src/kiwi/fig/instance-overrides/types.ts +++ b/packages/core/src/kiwi/fig/instance-overrides/types.ts @@ -1,4 +1,4 @@ -import type { GUID, NodeChange } from '#core/kiwi/fig/codec' +import type { GUID, NodeChange, VariableConsumptionEntry } from '#core/kiwi/fig/codec' import type { SceneGraph } from '#core/scene-graph' import type { Matrix, Vector } from '#core/types' @@ -8,6 +8,12 @@ export interface SymbolOverride { guidPath?: { guids?: GUID[] } overriddenSymbolID?: GUID componentPropAssignments?: ComponentPropAssignment[] + variableConsumptionMap?: { entries?: VariableConsumptionEntry[] } + [key: string]: unknown +} + +export interface SymbolOverrideFields { + variableConsumptionMap?: { entries?: VariableConsumptionEntry[] } [key: string]: unknown } diff --git a/packages/core/src/kiwi/fig/node-change/convert.ts b/packages/core/src/kiwi/fig/node-change/convert.ts index cbcb871b3..c4106846e 100644 --- a/packages/core/src/kiwi/fig/node-change/convert.ts +++ b/packages/core/src/kiwi/fig/node-change/convert.ts @@ -543,10 +543,20 @@ function componentPropValueToString(value: unknown): string { return propValue.guidValue ? guidToString(propValue.guidValue) : '' } +interface RawComponentPropDef { + id?: GUID + name?: string + type?: string + initialValue?: unknown +} + +interface RawSymbolData { + symbolOverrides?: unknown[] + uniformScaleFactor?: number +} + function extractComponentPropertyDefs(nc: NodeChange): ComponentPropertyDefinition[] { - const defs = nc.componentPropDefs as - | Array<{ id?: GUID; name?: string; type?: string; initialValue?: unknown }> - | undefined + const defs = nc.componentPropDefs as RawComponentPropDef[] | undefined if (!defs?.length) return [] const result: ComponentPropertyDefinition[] = [] for (const def of defs) { @@ -852,9 +862,7 @@ function extractFigmaSymbolMetadata( | 'derivedSymbolDataLayoutVersion' | 'uniformScaleFactor' > { - const sd = nc.symbolData as - | { symbolOverrides?: unknown[]; uniformScaleFactor?: number } - | undefined + const sd = nc.symbolData as RawSymbolData | undefined return { symbolOverrides: preserveFigmaPayloadBlobs(sd?.symbolOverrides ?? [], blobs) as unknown[], componentPropAssignments: preserveFigmaPayloadBlobs( diff --git a/packages/core/src/kiwi/fig/node-change/export-node.ts b/packages/core/src/kiwi/fig/node-change/export-node.ts index 4f3fa25bd..e87f0b820 100644 --- a/packages/core/src/kiwi/fig/node-change/export-node.ts +++ b/packages/core/src/kiwi/fig/node-change/export-node.ts @@ -141,11 +141,36 @@ const SUPPORTED_VARIABLE_DATA_TYPES = new Set([ 'PROP_REF' ]) -function isSupportedVariableMapEntry(value: unknown): boolean { - if (!value || typeof value !== 'object') return false - const entry = value as { - variableData?: { dataType?: string; value?: { propRefValue?: unknown } } +interface FigmaPayloadVariableMap { + entries?: unknown[] +} + +interface FigmaPayloadVariableMapEntry { + variableData?: { dataType?: string; value?: { propRefValue?: unknown } } +} + +interface ColorVarCarrier { + colorVar?: { + value?: { + alias?: { + guid?: GUID + assetRef?: { key: string; version?: string } + } + } } +} + +function isFigmaPayloadVariableMap(value: unknown): value is FigmaPayloadVariableMap { + return !!value && typeof value === 'object' && !Array.isArray(value) && 'entries' in value +} + +function isFigmaPayloadVariableMapEntry(value: unknown): value is FigmaPayloadVariableMapEntry { + return !!value && typeof value === 'object' && !Array.isArray(value) +} + +function isSupportedVariableMapEntry(value: unknown): boolean { + if (!isFigmaPayloadVariableMapEntry(value)) return false + const entry = value const dataType = entry.variableData?.dataType return ( (typeof dataType === 'string' && SUPPORTED_VARIABLE_DATA_TYPES.has(dataType)) || @@ -154,10 +179,8 @@ function isSupportedVariableMapEntry(value: unknown): boolean { } function isPropRefVariableMapEntry(value: unknown): boolean { - if (!value || typeof value !== 'object') return false - const entry = value as { - variableData?: { dataType?: string; value?: { propRefValue?: unknown } } - } + if (!isFigmaPayloadVariableMapEntry(value)) return false + const entry = value return entry.variableData?.dataType === 'PROP_REF' || !!entry.variableData?.value?.propRefValue } @@ -167,8 +190,8 @@ function materializeSafeVariableMap( options: MaterializeFigmaPayloadOptions, predicate: (value: unknown) => boolean ): unknown { - if (!value || typeof value !== 'object' || Array.isArray(value)) return undefined - const entries = (value as { entries?: unknown[] }).entries?.filter(predicate) ?? [] + if (!isFigmaPayloadVariableMap(value)) return undefined + const entries = value.entries?.filter(predicate) ?? [] if (entries.length === 0) return undefined return { entries: entries.map((entry) => materializeFigmaPayload(entry, blobs, options)) } } @@ -362,16 +385,13 @@ function applyRawFigmaNodeFields( */ function convertColorVarAssetRefs(paints: T, assetRefToVarGuid: Map): T { if (!Array.isArray(paints)) return paints - const result = paints.map((paint: Record) => { - const colorVar = paint.colorVar as Record | undefined - if (!colorVar) return paint - const value = colorVar.value as Record | undefined - if (!value) return paint - const alias = value.alias as Record | undefined - if (!alias) return paint - // If alias already has guid, nothing to convert + const result = paints.map((paint: ColorVarCarrier) => { + const colorVar = paint.colorVar + const value = colorVar?.value + const alias = value?.alias + if (!colorVar || !value || !alias) return paint if (alias.guid) return paint - const assetRef = alias.assetRef as { key: string; version?: string } | undefined + const assetRef = alias.assetRef if (!assetRef?.key) return paint // Look up by key@version first, then by key alone const lookupKey = assetRef.version ? `${assetRef.key}@${assetRef.version}` : assetRef.key diff --git a/packages/core/src/kiwi/fig/node-change/serialize.ts b/packages/core/src/kiwi/fig/node-change/serialize.ts index d37b72703..969993bf9 100644 --- a/packages/core/src/kiwi/fig/node-change/serialize.ts +++ b/packages/core/src/kiwi/fig/node-change/serialize.ts @@ -4,6 +4,7 @@ import { buildDerivedTextData as buildSharedDerivedTextData } from '#core/text/d import { normalizeFontFamily, weightToFigmaStyle, weightToStyle } from '#core/text/fonts' import { getGlyphOutlineMetricsSync } from '#core/text/opentype' import { encodeVectorNetworkBlob, buildStyleOverrideTable } from '#core/vector' + export { buildFigKiwi, decompressFigKiwiData, @@ -15,7 +16,7 @@ export { buildFontDigestMap } from './font/digests' import type { NodeChange, Paint, VariableConsumptionEntry } from '#core/kiwi/fig/codec' import type { SceneGraph, SceneNode } from '#core/scene-graph' -import type { Color, GUID, Matrix } from '#core/types' +import type { Color, GUID, JsonObject, Matrix } from '#core/types' import { guidToString, stringToGuid, VARIABLE_BINDING_FIELDS } from './convert' import { @@ -234,8 +235,7 @@ function serializeCornerRadii(node: SceneNode, nc: KiwiNodeChange): void { // the raw Figma data. Figma may emit per-corner radii without setting the // independent flag (preserve rectangleCornerRadiiIndependent). const rawIndependent = node.source.id - ? (node.source.fig.rawNodeFields as Record | undefined) - ?.rectangleCornerRadiiIndependent + ? (node.source.fig.rawNodeFields as JsonObject | undefined)?.rectangleCornerRadiiIndependent : undefined nc.rectangleCornerRadiiIndependent = typeof rawIndependent === 'boolean' ? rawIndependent : node.independentCorners diff --git a/packages/core/src/kiwi/fig/parse/core.ts b/packages/core/src/kiwi/fig/parse/core.ts index dbb9188da..2144fa601 100644 --- a/packages/core/src/kiwi/fig/parse/core.ts +++ b/packages/core/src/kiwi/fig/parse/core.ts @@ -40,6 +40,10 @@ interface FigKiwiPayload { version: number } +interface CompiledKiwiSchema { + decodeMessage(data: Uint8Array): unknown +} + export function parseFigKiwiContainer(data: Uint8Array): FigKiwiPayload | null { const header = new TextDecoder().decode(data.slice(0, 8)) if (header !== 'fig-kiwi') return null @@ -124,7 +128,7 @@ export function parseFigBuffer(buffer: ArrayBuffer): FigParseResult { const schemaBytes = inflateSync(payload.schemaDeflated) const schema = decodeBinarySchema(new ByteBuffer(schemaBytes)) - const compiled = compileSchema(schema) as { decodeMessage(data: Uint8Array): unknown } + const compiled = compileSchema(schema) as CompiledKiwiSchema const message = compiled.decodeMessage(payload.dataRaw) as FigmaMessage const nodeChanges = message.nodeChanges diff --git a/packages/core/src/tools/ai-adapter.ts b/packages/core/src/tools/ai-adapter.ts index 7539857a5..ef639e25f 100644 --- a/packages/core/src/tools/ai-adapter.ts +++ b/packages/core/src/tools/ai-adapter.ts @@ -10,6 +10,7 @@ import type { ToolSet, tool as createTool } from 'ai' import type * as valibot from 'valibot' import type { FigmaAPI } from '#core/figma-api' +import type { JsonObject } from '#core/types' import type { ToolDef, ParamDef, ParamType } from './schema' @@ -194,7 +195,7 @@ export function toolsToAI( value: [{ type: 'media' as const, mediaType: r.mimeType, data: r.base64 }] } } - return { type: 'json' as const, value: output as Record } + return { type: 'json' as const, value: output as JsonObject } } } diff --git a/packages/core/src/tools/stock-photo/apply.ts b/packages/core/src/tools/stock-photo/apply.ts index 0d85f859b..816ad8599 100644 --- a/packages/core/src/tools/stock-photo/apply.ts +++ b/packages/core/src/tools/stock-photo/apply.ts @@ -9,6 +9,10 @@ export interface PhotoRequest { orientation?: 'landscape' | 'portrait' | 'square' } +interface NodeWithChildren { + children: unknown[] +} + export interface PhotoResult { id: string photo?: { @@ -29,7 +33,7 @@ export async function applyPhoto( const node = figma.getNodeById(req.id) if (!node) return { id: req.id, error: 'Not found' } - const children = 'children' in node ? (node as { children: unknown[] }).children : [] + const children = 'children' in node ? (node as NodeWithChildren).children : [] if (children.length > 0) { return { id: req.id, error: `"${node.name}" has children — use a leaf shape` } } diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 14ea3f5c4..dd35b6dae 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -30,3 +30,8 @@ export interface Rect { width: number height: number } + +export type JsonPrimitive = string | number | boolean | null +export type JsonValue = JsonPrimitive | JsonObject | JsonArray +export type JsonObject = { [key: string]: unknown } +export type JsonArray = unknown[] diff --git a/packages/mcp/src/browser-rpc.ts b/packages/mcp/src/browser-rpc.ts index beed79403..5b8e9135f 100644 --- a/packages/mcp/src/browser-rpc.ts +++ b/packages/mcp/src/browser-rpc.ts @@ -2,6 +2,8 @@ import { randomUUID } from 'node:crypto' import type { WebSocket } from 'ws' +import type { RpcJsonObject } from '#mcp/json' + const RPC_TIMEOUT = 30_000 const APP_NOT_CONNECTED_MESSAGE = @@ -34,7 +36,7 @@ function stripEnvelope(msg: BrowserMessage): Record { function responsePayload(result: unknown): Record { if (result && typeof result === 'object' && !Array.isArray(result)) { - return result as Record + return result as RpcJsonObject } return { result } } diff --git a/packages/mcp/src/json.ts b/packages/mcp/src/json.ts new file mode 100644 index 000000000..62dab9200 --- /dev/null +++ b/packages/mcp/src/json.ts @@ -0,0 +1 @@ +export type RpcJsonObject = { [key: string]: unknown } diff --git a/packages/mcp/src/server.ts b/packages/mcp/src/server.ts index 3bde1625b..e5c400863 100644 --- a/packages/mcp/src/server.ts +++ b/packages/mcp/src/server.ts @@ -5,6 +5,8 @@ import { resolveCommand } from 'package-manager-detector/commands' import { detect, getUserAgent } from 'package-manager-detector/detect' import { WebSocketServer } from 'ws' +import type { RpcJsonObject } from '#mcp/json' + import packageJson from '../package.json' with { type: 'json' } import { bearerToken, isAuthorized, mcpRequestToken } from './auth' import { createBrowserRpcBridge } from './browser-rpc' @@ -130,8 +132,8 @@ export function startServer(options: ServerOptions = {}) { return c.json({ error: 'Invalid request body' }, 400) } try { - body = preprocessRpc(body as Record) - const result = await sendToBrowser(body as Record) + body = preprocessRpc(body as RpcJsonObject) + const result = await sendToBrowser(body as RpcJsonObject) return c.json(result) } catch (e) { const msg = e instanceof Error ? e.message : String(e) diff --git a/packages/mcp/src/tool/registration.ts b/packages/mcp/src/tool/registration.ts index d82083af8..582b44ca9 100644 --- a/packages/mcp/src/tool/registration.ts +++ b/packages/mcp/src/tool/registration.ts @@ -5,6 +5,7 @@ import { z } from 'zod' import { ALL_TOOLS, CODEGEN_PROMPT } from '@open-pencil/core/tools' +import type { RpcJsonObject } from '#mcp/json' import { fail, ok } from '#mcp/result' import { resolveSafePath, writeToolOutput } from './output' @@ -37,7 +38,7 @@ export function registerTools(mcpServer: McpServer, options: RegisterToolsOption const result = await sendRpc({ command: 'tool', args: { name: def.name, args } }) const res = result as { ok?: boolean; result?: unknown; error?: string } if (res.ok === false) return fail(new Error(res.error)) - const r = res.result as Record | undefined + const r = res.result as RpcJsonObject | undefined const filePath = typeof args.path === 'string' ? args.path : null if (r && filePath && resolvedRoot) { const written = await writeToolOutput(def.name, r, filePath, resolvedRoot) diff --git a/scripts/check-locales.ts b/scripts/check-locales.ts index 1fb21cb7f..09cd2ca38 100644 --- a/scripts/check-locales.ts +++ b/scripts/check-locales.ts @@ -2,6 +2,8 @@ import { readFileSync, readdirSync } from 'node:fs' import { join } from 'node:path' +import type { JsonObject } from '@open-pencil/core/types' + const MESSAGES_PATH = 'packages/vue/src/i18n/messages.ts' const LOCALES_DIR = 'packages/vue/src/locales' @@ -23,7 +25,7 @@ for (const file of localeFiles) { const extra: string[] = [] for (const [ns, keys] of enKeys) { - const localeNs = (data[ns] ?? {}) as Record + const localeNs = (data[ns] ?? {}) as JsonObject for (const key of keys) { if (!(key in localeNs)) missing.push(`${ns}.${key}`) } diff --git a/src/app/ai/acp/map-update.ts b/src/app/ai/acp/map-update.ts index 3460054b6..7ac987a99 100644 --- a/src/app/ai/acp/map-update.ts +++ b/src/app/ai/acp/map-update.ts @@ -1,6 +1,8 @@ import type { SessionUpdate } from '@agentclientprotocol/sdk' import type { UIMessageChunk } from 'ai' +import type { JsonObject } from '@open-pencil/core/types' + export interface MapResult { chunks: UIMessageChunk[] textStarted: boolean @@ -86,14 +88,12 @@ export function mapUpdate(update: SessionUpdate, textId: string, textStarted: bo return { chunks, textStarted } } -export function textFromContent( - content: Record[] | undefined -): string | undefined { +export function textFromContent(content: JsonObject[] | undefined): string | undefined { if (!content) return undefined const parts: string[] = [] for (const c of content) { if (c.type !== 'content') continue - const inner = c.content as Record | undefined + const inner = c.content as JsonObject | undefined if (inner?.type === 'text' && typeof inner.text === 'string') { parts.push(inner.text) } diff --git a/src/app/ai/debug/index.ts b/src/app/ai/debug/index.ts index 824b04c28..41a54e3a4 100644 --- a/src/app/ai/debug/index.ts +++ b/src/app/ai/debug/index.ts @@ -2,6 +2,7 @@ import type { UIMessage } from 'ai' import { buildDebugLog } from '@open-pencil/core/tools' import type { ToolDebugLog, ToolLogEntry } from '@open-pencil/core/tools' +import type { JsonObject } from '@open-pencil/core/types' import { getStepUsages, getToolLogEntries } from '@/app/ai/tools' @@ -154,7 +155,7 @@ export function formatDiagnostics(log: ToolDebugLog): string { } function formatToolPart(part: Record): string { - const inv = part.toolInvocation as Record | undefined + const inv = part.toolInvocation as JsonObject | undefined if (inv) { const lines = [` [tool] ${String(inv.toolName)} (${String(inv.state)})`] if (inv.args) lines.push(` args: ${JSON.stringify(inv.args)}`) @@ -181,7 +182,7 @@ function formatMessageStats(messages: UIMessage[]): string { if (msg.role === 'user') userMessages++ else if (msg.role === 'assistant') assistantMessages++ for (const part of msg.parts) { - const p = part as Record + const p = part as JsonObject if (p.type === 'text') { totalTextLength += typeof p.text === 'string' ? p.text.length : 0 } else if ( @@ -244,7 +245,7 @@ export function serializeChatLog(messages: UIMessage[]): string { const parts: string[] = [] for (const part of msg.parts) { - const p = part as Record + const p = part as JsonObject if (p.type === 'text') { parts.push(` ${p.text as string}`) } else if (p.type === 'reasoning') { diff --git a/src/app/automation/bridge/tool-handlers.ts b/src/app/automation/bridge/tool-handlers.ts index aa4efd060..5a3587cda 100644 --- a/src/app/automation/bridge/tool-handlers.ts +++ b/src/app/automation/bridge/tool-handlers.ts @@ -2,6 +2,7 @@ import { renderTreeNode } from '@open-pencil/core/design-jsx' import type { FigmaAPI } from '@open-pencil/core/figma-api' import { computeAllLayouts } from '@open-pencil/core/layout' import { ALL_TOOLS } from '@open-pencil/core/tools' +import type { JsonObject } from '@open-pencil/core/types' import type { EditorStore } from '@/app/editor/active-store' import { ensureGraphFonts } from '@/app/editor/fonts' @@ -60,18 +61,14 @@ export function createAutomationToolHandler(makeFigma: FigmaFactory) { function extractNodeIds(result: unknown): string[] { if (!result || typeof result !== 'object') return [] - const obj = result as Record + const obj = result as JsonObject if (typeof obj.deleted === 'string') return [] const ids: string[] = [] if (typeof obj.id === 'string') ids.push(obj.id) if (Array.isArray(obj.results)) { for (const item of obj.results) { - if ( - item && - typeof item === 'object' && - typeof (item as Record).id === 'string' - ) - ids.push((item as Record).id as string) + if (item && typeof item === 'object' && typeof (item as JsonObject).id === 'string') + ids.push((item as JsonObject).id as string) } } return ids diff --git a/src/components/ChatPanel.vue b/src/components/ChatPanel.vue index 4a95ff36f..8e5b3aee3 100644 --- a/src/components/ChatPanel.vue +++ b/src/components/ChatPanel.vue @@ -18,6 +18,7 @@ import { useI18n } from '@open-pencil/vue' import type { Chat } from '@ai-sdk/vue' import type { UIMessage } from 'ai' +import type { JsonObject } from '@open-pencil/core/types' const IS_DEV = import.meta.env.DEV @@ -43,7 +44,7 @@ const isThinking = computed(() => { if (last.role !== 'assistant') return true const parts = last.parts if (parts.length === 0) return true - const lastPart = parts[parts.length - 1] as Record + const lastPart = parts[parts.length - 1] as JsonObject if (lastPart.type === 'step-start') return true if ('toolCallId' in lastPart && lastPart.state === 'output-available') return true if ('toolCallId' in lastPart && lastPart.state === 'output-error') return true diff --git a/tests/engine/figma/api/serialization.test.ts b/tests/engine/figma/api/serialization.test.ts index 7f1a158e7..a4d077b98 100644 --- a/tests/engine/figma/api/serialization.test.ts +++ b/tests/engine/figma/api/serialization.test.ts @@ -2,6 +2,10 @@ import { describe, expect, test } from 'bun:test' import { createAPI } from './helpers' +interface SerializedNodeWithChildren { + children?: unknown[] +} + describe('serialization', () => { test('toJSON returns clean object', () => { const api = createAPI() @@ -20,7 +24,7 @@ describe('serialization', () => { const frame = api.createFrame() const rect = api.createRectangle() frame.appendChild(rect) - const json = frame.toJSON() as { children?: unknown[] } + const json = frame.toJSON() as SerializedNodeWithChildren expect(json.children).toBeDefined() expect(json.children?.length).toBe(1) }) diff --git a/tests/engine/io/fig/export/text.test.ts b/tests/engine/io/fig/export/text.test.ts index b7f2ad519..bdd663c27 100644 --- a/tests/engine/io/fig/export/text.test.ts +++ b/tests/engine/io/fig/export/text.test.ts @@ -1,6 +1,7 @@ import { describe, expect, setDefaultTimeout, test } from 'bun:test' import { exportFigFile, initCodec, parseFigFile, SceneGraph } from '@open-pencil/core' +import type { JsonObject } from '@open-pencil/core/types' import { expectDefined } from '#tests/helpers/assert' import { parseFixture } from '#tests/helpers/fig-fixtures' @@ -95,7 +96,7 @@ describe('text node export', () => { const dataRaw = inflateSync(chunks?.[1] ?? new Uint8Array()) const message = compiled.decodeMessage(dataRaw) - const nodeChanges = message.nodeChanges as Array> + const nodeChanges = message.nodeChanges as JsonObject[] const textNc = nodeChanges.find((nc) => nc.type === 'TEXT') expect(textNc).toBeDefined() @@ -149,19 +150,19 @@ describe('text node export', () => { decodeMessage(data: Uint8Array): Record } const message = compiled.decodeMessage(inflateSync(chunks?.[1] ?? new Uint8Array())) - const nodeChanges = message.nodeChanges as Array> + const nodeChanges = message.nodeChanges as JsonObject[] const textNc = expectDefined( nodeChanges.find((nc) => nc.type === 'TEXT'), 'text node change' ) - const derivedTextData = textNc.derivedTextData as Record + const derivedTextData = textNc.derivedTextData as JsonObject const fontMetaData = expectDefined( - derivedTextData.fontMetaData as Array> | undefined, + derivedTextData.fontMetaData as JsonObject[] | undefined, 'font metadata' ) - expect((textNc.fontName as Record).style).toBe('Semi Bold') - expect((fontMetaData[0].key as Record).style).toBe('Semi Bold') + expect((textNc.fontName as JsonObject).style).toBe('Semi Bold') + expect((fontMetaData[0].key as JsonObject).style).toBe('Semi Bold') }) test('auto-layout text children export height auto-resize for Figma rendering', async () => { @@ -200,7 +201,7 @@ describe('text node export', () => { decodeMessage(data: Uint8Array): Record } const message = compiled.decodeMessage(inflateSync(chunks?.[1] ?? new Uint8Array())) - const nodeChanges = message.nodeChanges as Array> + const nodeChanges = message.nodeChanges as JsonObject[] const textNc = expectDefined( nodeChanges.find((nc) => nc.type === 'TEXT'), 'text node change' @@ -249,16 +250,14 @@ describe('text node export', () => { const dataRaw = inflateSync(chunks?.[1] ?? new Uint8Array()) const message = compiled.decodeMessage(dataRaw) - const nodeChanges = message.nodeChanges as Array> + const nodeChanges = message.nodeChanges as JsonObject[] const textNc = nodeChanges.find((nc) => nc.type === 'TEXT') - const derivedTextData = textNc?.derivedTextData as Record | undefined - const fontMetaData = derivedTextData?.fontMetaData as Array> | undefined + const derivedTextData = textNc?.derivedTextData as JsonObject | undefined + const fontMetaData = derivedTextData?.fontMetaData as JsonObject[] | undefined expect(fontMetaData?.length).toBe(2) - const families = (fontMetaData ?? []).map( - (m) => (m.key as Record)?.style as string - ) + const families = (fontMetaData ?? []).map((m) => (m.key as JsonObject)?.style as string) expect(families).toContain('Bold') expect(families).toContain('Regular') }) diff --git a/tests/engine/io/fig/roundtrip/exhaustive.test.ts b/tests/engine/io/fig/roundtrip/exhaustive.test.ts index 65cb89fe9..eeedb0fa4 100644 --- a/tests/engine/io/fig/roundtrip/exhaustive.test.ts +++ b/tests/engine/io/fig/roundtrip/exhaustive.test.ts @@ -11,6 +11,7 @@ import { type SceneGraph, type SceneNode } from '@open-pencil/core' +import type { JsonObject } from '@open-pencil/core/types' import { type Mismatch, @@ -169,8 +170,8 @@ function deepCompare( for (const k of allKeys) { if (depth === 0 && SKIP_KEYS.has(k)) continue deepCompare( - (a as Record)[k], - (b as Record)[k], + (a as JsonObject)[k], + (b as JsonObject)[k], key ? `${key}.${k}` : k, path, opts, @@ -240,13 +241,7 @@ function compareSceneProps( for (const k of new Set([...Object.keys(aNode as object), ...Object.keys(bNode as object)])) { if (SKIP_KEYS.has(k)) continue if (k === 'source') continue - deepCompare( - (aNode as Record)[k], - (bNode as Record)[k], - k, - p, - opts - ) + deepCompare((aNode as JsonObject)[k], (bNode as JsonObject)[k], k, p, opts) } } if (errors.length > 0) throw new Error(`${label} scene props:\n${summarize(errors)}`) @@ -278,12 +273,12 @@ function compareRawNodeFields( for (const [p, aNode] of aNodes) { const bNode = bNodes.get(p) if (!bNode) continue - const aRaw = (aNode as Record).source as Record | undefined - const bRaw = (bNode as Record).source as Record | undefined - const aFig = aRaw?.fig as Record | undefined - const bFig = bRaw?.fig as Record | undefined - const aFields = aFig?.rawNodeFields as Record | undefined - const bFields = bFig?.rawNodeFields as Record | undefined + const aRaw = (aNode as JsonObject).source as JsonObject | undefined + const bRaw = (bNode as JsonObject).source as JsonObject | undefined + const aFig = aRaw?.fig as JsonObject | undefined + const bFig = bRaw?.fig as JsonObject | undefined + const aFields = aFig?.rawNodeFields as JsonObject | undefined + const bFields = bFig?.rawNodeFields as JsonObject | undefined if (!aFields && !bFields) continue deepCompareRaw(aFields, bFields, '', p, opts) } @@ -319,8 +314,8 @@ function deepCompareRaw( b !== null && !Array.isArray(a) ) { - const aObj = a as Record - const bObj = b as Record + const aObj = a as JsonObject + const bObj = b as JsonObject const allKeys = new Set([...Object.keys(aObj), ...Object.keys(bObj)]) for (const k of allKeys) { const vfn2 = opts.verifiers.get(k) @@ -386,8 +381,8 @@ function deepCompareRaw( return } - const aObj = a as Record - const bObj = b as Record + const aObj = a as JsonObject + const bObj = b as JsonObject const allKeys = new Set([...Object.keys(aObj), ...Object.keys(bObj)]) for (const k of allKeys) { const fullKey = key ? `${key}.${k}` : k diff --git a/tests/engine/io/fig/roundtrip/helpers.ts b/tests/engine/io/fig/roundtrip/helpers.ts index b08d96cb4..198eab600 100644 --- a/tests/engine/io/fig/roundtrip/helpers.ts +++ b/tests/engine/io/fig/roundtrip/helpers.ts @@ -1,4 +1,5 @@ import type { SceneGraph, SceneNode } from '@open-pencil/core' +import type { JsonObject } from '@open-pencil/core/types' import { verifyComponentPropDefs, verifyDerivedTextData } from './raw-verifiers/helpers' @@ -193,17 +194,20 @@ function verifyVariableConsumption( verifyBEntries(aEntries, bEntries, ctx) } +interface VariableConsumptionMapShape { + entries?: Array<{ + variableData?: { value?: { alias?: { guid?: unknown; assetRef?: unknown } } } + variableField?: string + }> +} + function verifyVarAlias(a: unknown, b: unknown): boolean { - const aVal = a as Record | undefined - const bVal = b as Record | undefined + const aVal = a as JsonObject | undefined + const bVal = b as JsonObject | undefined if (!aVal && !bVal) return true if (!aVal || !bVal) return false - const aAlias = (aVal.value as Record)?.alias as - | Record - | undefined - const bAlias = (bVal.value as Record)?.alias as - | Record - | undefined + const aAlias = (aVal.value as JsonObject)?.alias as JsonObject | undefined + const bAlias = (bVal.value as JsonObject)?.alias as JsonObject | undefined const aGuid = aAlias?.guid const bGuid = bAlias?.guid const aRef = aAlias?.assetRef @@ -229,7 +233,7 @@ export const RAW_VERIFIERS = new Map([ 'letterSpacing', (ctx) => { if (isIdempotent(ctx)) return JSON.stringify(ctx.a) === JSON.stringify(ctx.b) - const g1raw = ctx.b as Record | undefined + const g1raw = ctx.b as JsonObject | undefined const node = ctx.aNodes.get(ctx.path) if (!node || node.fontSize == null) return true const expected = node.letterSpacing @@ -248,7 +252,7 @@ export const RAW_VERIFIERS = new Map([ 'lineHeight', (ctx) => { if (isIdempotent(ctx)) return JSON.stringify(ctx.a) === JSON.stringify(ctx.b) - const g1raw = ctx.b as Record | undefined + const g1raw = ctx.b as JsonObject | undefined const node = ctx.aNodes.get(ctx.path) if (!node || node.lineHeight == null) return true const expected = node.lineHeight @@ -260,22 +264,8 @@ export const RAW_VERIFIERS = new Map([ 'variableConsumptionMap', (ctx) => { if (isIdempotent(ctx)) return JSON.stringify(ctx.a) === JSON.stringify(ctx.b) - const ga = ctx.a as - | { - entries?: Array<{ - variableData?: { value?: { alias?: { guid?: unknown; assetRef?: unknown } } } - variableField?: string - }> - } - | undefined - const gb = ctx.b as - | { - entries?: Array<{ - variableData?: { value?: { alias?: { guid?: unknown; assetRef?: unknown } } } - variableField?: string - }> - } - | undefined + const ga = ctx.a as VariableConsumptionMapShape | undefined + const gb = ctx.b as VariableConsumptionMapShape | undefined verifyVariableConsumption(ga, gb, ctx) return true } diff --git a/tests/engine/io/fig/roundtrip/raw-verifiers/helpers.ts b/tests/engine/io/fig/roundtrip/raw-verifiers/helpers.ts index c0c610008..aeb92e739 100644 --- a/tests/engine/io/fig/roundtrip/raw-verifiers/helpers.ts +++ b/tests/engine/io/fig/roundtrip/raw-verifiers/helpers.ts @@ -1,8 +1,10 @@ +import type { JsonObject } from '@open-pencil/core/types' + import type { VerifierContext } from '../helpers' function asRecord(value: unknown): Record | undefined { return value && typeof value === 'object' && !Array.isArray(value) - ? (value as Record) + ? (value as JsonObject) : undefined } @@ -45,8 +47,8 @@ function verifySingleFontMetadata( i: number, ctx: VerifierContext ): void { - const amKey = am.key as Record | undefined - const bmKey = bm.key as Record | undefined + const amKey = am.key as JsonObject | undefined + const bmKey = bm.key as JsonObject | undefined if (amKey?.family !== bmKey?.family) { ctx.errors.push({ path: ctx.path, @@ -79,11 +81,7 @@ function verifySingleFontMetadata( verifyFontDigest(am.fontDigest, bm.fontDigest, i, ctx) } -function verifyFontMetadata( - aMeta: Record[], - bMeta: Record[], - ctx: VerifierContext -): void { +function verifyFontMetadata(aMeta: JsonObject[], bMeta: JsonObject[], ctx: VerifierContext): void { for (let i = 0; i < aMeta.length; i++) { verifySingleFontMetadata(aMeta[i], bMeta[i], i, ctx) } @@ -140,8 +138,8 @@ export function verifyDerivedTextData(ctx: VerifierContext): boolean { }) } - const aMeta = (aVal.fontMetaData as Record[]) ?? [] - const bMeta = (bVal.fontMetaData as Record[]) ?? [] + const aMeta = (aVal.fontMetaData as JsonObject[]) ?? [] + const bMeta = (bVal.fontMetaData as JsonObject[]) ?? [] if (aMeta.length !== bMeta.length) { ctx.errors.push({ path: ctx.path, @@ -208,8 +206,8 @@ function verifySingleComponentPropDef( } export function verifyComponentPropDefs(a: unknown, b: unknown): boolean { - const aDefs = a as Record[] | undefined - const bDefs = b as Record[] | undefined + const aDefs = a as JsonObject[] | undefined + const bDefs = b as JsonObject[] | undefined if (!aDefs && !bDefs) return true if (!aDefs || !bDefs) return false if (aDefs.length !== bDefs.length) return false diff --git a/tests/engine/kiwi/serialize-fixes/auto-layout/transforms.test.ts b/tests/engine/kiwi/serialize-fixes/auto-layout/transforms.test.ts index a98c7e48f..102e8865a 100644 --- a/tests/engine/kiwi/serialize-fixes/auto-layout/transforms.test.ts +++ b/tests/engine/kiwi/serialize-fixes/auto-layout/transforms.test.ts @@ -40,7 +40,7 @@ describe('Fix 1: auto-layout child transforms', () => { { value: 100 }, graph, blobs - ) as Record[] + ) // changes[0] = parent, changes[1] = child const childNc = expectDefined( @@ -81,7 +81,7 @@ describe('Fix 1: auto-layout child transforms', () => { { value: 100 }, graph, blobs - ) as Record[] + ) const absNc = expectDefined( changes.find((nc) => nc.name === 'AbsChild'), @@ -119,7 +119,7 @@ describe('Fix 1: auto-layout child transforms', () => { { value: 100 }, graph, blobs - ) as Record[] + ) const childNc = expectDefined( changes.find((nc) => nc.name === 'Child'), @@ -167,7 +167,7 @@ describe('Fix 1: auto-layout child transforms', () => { { value: 100 }, graph, blobs - ) as Record[] + ) const parentNc = expectDefined( changes.find((nc) => nc.name === 'Checked List'), @@ -215,7 +215,7 @@ describe('Fix 1: auto-layout child transforms', () => { { value: 100 }, graph, blobs - ) as Record[] + ) const itemNc = expectDefined( changes.find((nc) => nc.name === 'Item'), diff --git a/tests/engine/kiwi/serialize-fixes/borders/take-space.test.ts b/tests/engine/kiwi/serialize-fixes/borders/take-space.test.ts index e06883018..2cccc2a2b 100644 --- a/tests/engine/kiwi/serialize-fixes/borders/take-space.test.ts +++ b/tests/engine/kiwi/serialize-fixes/borders/take-space.test.ts @@ -18,7 +18,7 @@ describe('Fix 3: bordersTakeSpace serialization', () => { strokesIncludedInLayout: true }) - const changes = toKiwi(node, graph) as Record[] + const changes = toKiwi(node, graph) expect(changes[0].bordersTakeSpace).toBe(true) }) @@ -35,7 +35,7 @@ describe('Fix 3: bordersTakeSpace serialization', () => { strokesIncludedInLayout: false }) - const changes = toKiwi(node, graph) as Record[] + const changes = toKiwi(node, graph) expect(changes[0].bordersTakeSpace).toBe(false) }) @@ -50,7 +50,7 @@ describe('Fix 3: bordersTakeSpace serialization', () => { strokesIncludedInLayout: true }) - const changes = toKiwi(node, graph) as Record[] + const changes = toKiwi(node, graph) expect(changes[0].bordersTakeSpace).toBeUndefined() }) }) diff --git a/tests/engine/kiwi/serialize-fixes/font/family.test.ts b/tests/engine/kiwi/serialize-fixes/font/family.test.ts index 0f9159a39..997cb451b 100644 --- a/tests/engine/kiwi/serialize-fixes/font/family.test.ts +++ b/tests/engine/kiwi/serialize-fixes/font/family.test.ts @@ -93,7 +93,7 @@ describe('Fix 5: font family normalization in derivedTextData', () => { fontSize: 14 }) - const changes = toKiwi(node, graph) as Record[] + const changes = toKiwi(node, graph) expect(changes[0].fontName.family).toBe('DM Sans') }) }) diff --git a/tests/engine/kiwi/serialize-fixes/frame/mask.test.ts b/tests/engine/kiwi/serialize-fixes/frame/mask.test.ts index 2732fecbc..2d1f62302 100644 --- a/tests/engine/kiwi/serialize-fixes/frame/mask.test.ts +++ b/tests/engine/kiwi/serialize-fixes/frame/mask.test.ts @@ -17,7 +17,7 @@ describe('Fix 2: frameMaskDisabled is inverse of clipsContent', () => { height: 100 }) - const changes = toKiwi(node, graph) as Record[] + const changes = toKiwi(node, graph) expect(changes[0].frameMaskDisabled).toBe(true) }) @@ -32,7 +32,7 @@ describe('Fix 2: frameMaskDisabled is inverse of clipsContent', () => { clipsContent: true }) - const changes = toKiwi(node, graph) as Record[] + const changes = toKiwi(node, graph) expect(changes[0].frameMaskDisabled).toBe(false) }) @@ -47,7 +47,7 @@ describe('Fix 2: frameMaskDisabled is inverse of clipsContent', () => { clipsContent: false }) - const changes = toKiwi(node, graph) as Record[] + const changes = toKiwi(node, graph) expect(changes[0].frameMaskDisabled).toBe(true) }) diff --git a/tests/engine/kiwi/serialize-fixes/integration.test.ts b/tests/engine/kiwi/serialize-fixes/integration.test.ts index d4570b3d2..d167780be 100644 --- a/tests/engine/kiwi/serialize-fixes/integration.test.ts +++ b/tests/engine/kiwi/serialize-fixes/integration.test.ts @@ -67,7 +67,7 @@ describe('Integration: auto-layout component with all fixes', () => { { value: 100 }, graph, blobs - ) as Record[] + ) const cardNc = changes[0] const titleNc = expectDefined( diff --git a/tests/engine/kiwi/serialize-fixes/line/height.test.ts b/tests/engine/kiwi/serialize-fixes/line/height.test.ts index a438493dc..82d9f9a43 100644 --- a/tests/engine/kiwi/serialize-fixes/line/height.test.ts +++ b/tests/engine/kiwi/serialize-fixes/line/height.test.ts @@ -22,7 +22,7 @@ describe('Fix 4: text lineHeight serialization', () => { lineHeight: 24 }) - const changes = toKiwi(node, graph) as Record[] + const changes = toKiwi(node, graph) expect(changes[0].lineHeight).toEqual({ value: 24, units: 'PIXELS' }) }) @@ -40,7 +40,7 @@ describe('Fix 4: text lineHeight serialization', () => { fontSize: 16 }) - const changes = toKiwi(node, graph) as Record[] + const changes = toKiwi(node, graph) expect(changes[0].lineHeight).toBeUndefined() }) diff --git a/tests/engine/mcp/tools.test.ts b/tests/engine/mcp/tools.test.ts index 9cb5b3adb..7fb088509 100644 --- a/tests/engine/mcp/tools.test.ts +++ b/tests/engine/mcp/tools.test.ts @@ -4,6 +4,16 @@ import { ALL_TOOLS, FigmaAPI, SceneGraph, computeAllLayouts, parseFigFile } from import { expectDefined } from '#tests/helpers/assert' +interface PageTreeResult { + page: string + children: PageTreeNode[] +} + +interface PageTreeNode { + id: string + children?: PageTreeNode[] +} + describe('MCP tool execution', () => { function setup() { const graph = new SceneGraph() @@ -40,10 +50,7 @@ describe('MCP tool execution', () => { test('get_page_tree returns page hierarchy', () => { const { api } = setup() - const result = findTool('get_page_tree').execute(api, {}) as { - page: string - children: unknown[] - } + const result = findTool('get_page_tree').execute(api, {}) as PageTreeResult expect(result.page).toBe('Page 1') expect(result.children).toBeArray() }) @@ -131,9 +138,7 @@ describe('MCP tool execution', () => { parent_id: parent.id }) - const tree = findTool('get_page_tree').execute(api, {}) as { - children: { id: string; children?: unknown[] }[] - } + const tree = findTool('get_page_tree').execute(api, {}) as PageTreeResult const parentNode = expectDefined( tree.children.find((c: { id: string }) => c.id === parent.id), 'parent tree node' diff --git a/tests/engine/scene-graph/individual-strokes.test.ts b/tests/engine/scene-graph/individual-strokes.test.ts index 3fdf7a811..8c2c6b523 100644 --- a/tests/engine/scene-graph/individual-strokes.test.ts +++ b/tests/engine/scene-graph/individual-strokes.test.ts @@ -242,7 +242,7 @@ describe('Kiwi serialization', () => { { value: 100 }, graph, blobs - ) as Record[] + ) expect(changes[0].borderStrokeWeightsIndependent).toBe(true) expect(changes[0].borderTopWeight).toBe(1) @@ -263,7 +263,7 @@ describe('Kiwi serialization', () => { { value: 100 }, graph, blobs - ) as Record[] + ) expect(changes[0].borderStrokeWeightsIndependent).toBeUndefined() expect(changes[0].borderTopWeight).toBeUndefined() diff --git a/tests/engine/scene-graph/undo/mutation.test.ts b/tests/engine/scene-graph/undo/mutation.test.ts index 55efde6e0..4a152702e 100644 --- a/tests/engine/scene-graph/undo/mutation.test.ts +++ b/tests/engine/scene-graph/undo/mutation.test.ts @@ -1,6 +1,7 @@ import { describe, test, expect } from 'bun:test' import { SceneGraph, UndoManager } from '@open-pencil/core' +import type { JsonObject } from '@open-pencil/core/types' import { getNodeOrThrow } from '#tests/helpers/assert' @@ -21,7 +22,7 @@ describe('SceneGraph + UndoManager — updateNode undo integration', () => { ) { const node = getNodeOrThrow(graph, id) const previous = Object.fromEntries( - (Object.keys(changes) as string[]).map((k) => [k, (node as Record)[k]]) + (Object.keys(changes) as string[]).map((k) => [k, (node as JsonObject)[k]]) ) graph.updateNode(id, changes) undo.push({ diff --git a/tests/engine/tools/ai-adapter.test.ts b/tests/engine/tools/ai-adapter.test.ts index 0249dbc99..f9cbc8478 100644 --- a/tests/engine/tools/ai-adapter.test.ts +++ b/tests/engine/tools/ai-adapter.test.ts @@ -10,6 +10,11 @@ import { expectDefined } from '#tests/helpers/assert' type AdapterTool = { execute(args: Record): Promise; description: string } +interface PageTreeToolResult { + page: unknown + children: unknown[] +} + function adapterTool(tools: Record, name: string): AdapterTool { return tools[name] as AdapterTool } @@ -93,7 +98,7 @@ describe('AI adapter', () => { frame.appendChild(rect) const getTree = adapterTool(tools, 'get_page_tree') - const result = (await getTree.execute({})) as { page: unknown; children: unknown[] } + const result = (await getTree.execute({})) as PageTreeToolResult expect(result.page).toBeTruthy() expect(result.children.length).toBeGreaterThan(0) })