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<string, unknown> assertions with named shared/domain types
This commit is contained in:
Danila Poyarkov 2026-05-25 00:26:02 +03:00
parent b4dfd90da0
commit 1ed7de6056
40 changed files with 338 additions and 172 deletions

View file

@ -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<string, unknown>. 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,

View file

@ -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",

View file

@ -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<Record<string, unknown>>): 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<Record<string, unknown>> {
): JsonObject[] {
return nodes.map((node) => {
const children = graph.getChildren(node.id)
const serialized: Record<string, unknown> = { ...node }

View file

@ -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<string, number> = {
normal: 400,
@ -83,7 +84,7 @@ function normalizeStyleProps(props: Record<string, unknown>): Record<string, unk
const style = props.style
if (style === null || typeof style !== 'object' || Array.isArray(style)) return props
const source = style as Record<string, unknown>
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

View file

@ -15,6 +15,8 @@ export function isTreeNode(x: unknown): x is TreeNode {
)
}
type FunctionComponent = (props: Record<string, unknown>) => unknown
interface ReactElement {
type: unknown
props: Record<string, unknown>
@ -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<string, unknown>) => unknown)(element.props),
depth + 1
)
const component = element.type as FunctionComponent
return resolveToTree(component(element.props), depth + 1)
}
if (typeof element.type === 'string') {

View file

@ -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<Pick<SceneNode, 'width' | 'height'>>
@ -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<string, unknown>
const bRecord = b as Record<string, unknown>
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

View file

@ -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<string> {
const result = new Set<string>()
for (const [nodeId, change] of changedNodeEntries(changeMap, guidToNodeId)) {
const nc = change as Record<string, unknown>
const nc = change as JsonObject
const node = graph.getNode(nodeId)
if (!node?.componentId) continue
const comp = graph.getNode(node.componentId)

View file

@ -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<string, unknown>,
fields: SymbolOverrideFields,
props: ReturnType<typeof convertOverrideToProps>
): 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<string, unknown>)
applyVariableRadiusOverrides(ctx, fields as Record<string, unknown>, props)
const props = convertOverrideToProps(fields)
applyVariableRadiusOverrides(ctx, fields, props)
if (Object.keys(props).length > 0) patch.props = props
}

View file

@ -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
}

View file

@ -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(

View file

@ -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<T>(paints: T, assetRefToVarGuid: Map<string, GUID>): T {
if (!Array.isArray(paints)) return paints
const result = paints.map((paint: Record<string, unknown>) => {
const colorVar = paint.colorVar as Record<string, unknown> | undefined
if (!colorVar) return paint
const value = colorVar.value as Record<string, unknown> | undefined
if (!value) return paint
const alias = value.alias as Record<string, unknown> | 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

View file

@ -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<string, unknown> | undefined)
?.rectangleCornerRadiiIndependent
? (node.source.fig.rawNodeFields as JsonObject | undefined)?.rectangleCornerRadiiIndependent
: undefined
nc.rectangleCornerRadiiIndependent =
typeof rawIndependent === 'boolean' ? rawIndependent : node.independentCorners

View file

@ -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

View file

@ -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<string, unknown> }
return { type: 'json' as const, value: output as JsonObject }
}
}

View file

@ -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` }
}

View file

@ -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[]

View file

@ -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<string, unknown> {
function responsePayload(result: unknown): Record<string, unknown> {
if (result && typeof result === 'object' && !Array.isArray(result)) {
return result as Record<string, unknown>
return result as RpcJsonObject
}
return { result }
}

1
packages/mcp/src/json.ts Normal file
View file

@ -0,0 +1 @@
export type RpcJsonObject = { [key: string]: unknown }

View file

@ -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<string, unknown>)
const result = await sendToBrowser(body as Record<string, unknown>)
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)

View file

@ -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<string, unknown> | 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)

View file

@ -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<string, unknown>
const localeNs = (data[ns] ?? {}) as JsonObject
for (const key of keys) {
if (!(key in localeNs)) missing.push(`${ns}.${key}`)
}

View file

@ -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<string, unknown>[] | 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<string, unknown> | undefined
const inner = c.content as JsonObject | undefined
if (inner?.type === 'text' && typeof inner.text === 'string') {
parts.push(inner.text)
}

View file

@ -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, unknown>): string {
const inv = part.toolInvocation as Record<string, unknown> | 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<string, unknown>
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<string, unknown>
const p = part as JsonObject
if (p.type === 'text') {
parts.push(` ${p.text as string}`)
} else if (p.type === 'reasoning') {

View file

@ -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<string, unknown>
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<string, unknown>).id === 'string'
)
ids.push((item as Record<string, unknown>).id as string)
if (item && typeof item === 'object' && typeof (item as JsonObject).id === 'string')
ids.push((item as JsonObject).id as string)
}
}
return ids

View file

@ -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<string, unknown>
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

View file

@ -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)
})

View file

@ -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<Record<string, unknown>>
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<string, unknown>
}
const message = compiled.decodeMessage(inflateSync(chunks?.[1] ?? new Uint8Array()))
const nodeChanges = message.nodeChanges as Array<Record<string, unknown>>
const nodeChanges = message.nodeChanges as JsonObject[]
const textNc = expectDefined(
nodeChanges.find((nc) => nc.type === 'TEXT'),
'text node change'
)
const derivedTextData = textNc.derivedTextData as Record<string, unknown>
const derivedTextData = textNc.derivedTextData as JsonObject
const fontMetaData = expectDefined(
derivedTextData.fontMetaData as Array<Record<string, unknown>> | undefined,
derivedTextData.fontMetaData as JsonObject[] | undefined,
'font metadata'
)
expect((textNc.fontName as Record<string, unknown>).style).toBe('Semi Bold')
expect((fontMetaData[0].key as Record<string, unknown>).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<string, unknown>
}
const message = compiled.decodeMessage(inflateSync(chunks?.[1] ?? new Uint8Array()))
const nodeChanges = message.nodeChanges as Array<Record<string, unknown>>
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<Record<string, unknown>>
const nodeChanges = message.nodeChanges as JsonObject[]
const textNc = nodeChanges.find((nc) => nc.type === 'TEXT')
const derivedTextData = textNc?.derivedTextData as Record<string, unknown> | undefined
const fontMetaData = derivedTextData?.fontMetaData as Array<Record<string, unknown>> | 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<string, unknown>)?.style as string
)
const families = (fontMetaData ?? []).map((m) => (m.key as JsonObject)?.style as string)
expect(families).toContain('Bold')
expect(families).toContain('Regular')
})

View file

@ -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<string, unknown>)[k],
(b as Record<string, unknown>)[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<string, unknown>)[k],
(bNode as Record<string, unknown>)[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<string, unknown>).source as Record<string, unknown> | undefined
const bRaw = (bNode as Record<string, unknown>).source as Record<string, unknown> | undefined
const aFig = aRaw?.fig as Record<string, unknown> | undefined
const bFig = bRaw?.fig as Record<string, unknown> | undefined
const aFields = aFig?.rawNodeFields as Record<string, unknown> | undefined
const bFields = bFig?.rawNodeFields as Record<string, unknown> | 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<string, unknown>
const bObj = b as Record<string, unknown>
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<string, unknown>
const bObj = b as Record<string, unknown>
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

View file

@ -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<string, unknown> | undefined
const bVal = b as Record<string, unknown> | 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<string, unknown>)?.alias as
| Record<string, unknown>
| undefined
const bAlias = (bVal.value as Record<string, unknown>)?.alias as
| Record<string, unknown>
| 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<string, Verifier>([
'letterSpacing',
(ctx) => {
if (isIdempotent(ctx)) return JSON.stringify(ctx.a) === JSON.stringify(ctx.b)
const g1raw = ctx.b as Record<string, unknown> | 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<string, Verifier>([
'lineHeight',
(ctx) => {
if (isIdempotent(ctx)) return JSON.stringify(ctx.a) === JSON.stringify(ctx.b)
const g1raw = ctx.b as Record<string, unknown> | 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<string, Verifier>([
'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
}

View file

@ -1,8 +1,10 @@
import type { JsonObject } from '@open-pencil/core/types'
import type { VerifierContext } from '../helpers'
function asRecord(value: unknown): Record<string, unknown> | undefined {
return value && typeof value === 'object' && !Array.isArray(value)
? (value as Record<string, unknown>)
? (value as JsonObject)
: undefined
}
@ -45,8 +47,8 @@ function verifySingleFontMetadata(
i: number,
ctx: VerifierContext
): void {
const amKey = am.key as Record<string, unknown> | undefined
const bmKey = bm.key as Record<string, unknown> | 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<string, unknown>[],
bMeta: Record<string, unknown>[],
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<string, unknown>[]) ?? []
const bMeta = (bVal.fontMetaData as Record<string, unknown>[]) ?? []
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<string, unknown>[] | undefined
const bDefs = b as Record<string, unknown>[] | 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

View file

@ -40,7 +40,7 @@ describe('Fix 1: auto-layout child transforms', () => {
{ value: 100 },
graph,
blobs
) as Record<string, unknown>[]
)
// 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<string, unknown>[]
)
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<string, unknown>[]
)
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<string, unknown>[]
)
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<string, unknown>[]
)
const itemNc = expectDefined(
changes.find((nc) => nc.name === 'Item'),

View file

@ -18,7 +18,7 @@ describe('Fix 3: bordersTakeSpace serialization', () => {
strokesIncludedInLayout: true
})
const changes = toKiwi(node, graph) as Record<string, unknown>[]
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<string, unknown>[]
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<string, unknown>[]
const changes = toKiwi(node, graph)
expect(changes[0].bordersTakeSpace).toBeUndefined()
})
})

View file

@ -93,7 +93,7 @@ describe('Fix 5: font family normalization in derivedTextData', () => {
fontSize: 14
})
const changes = toKiwi(node, graph) as Record<string, unknown>[]
const changes = toKiwi(node, graph)
expect(changes[0].fontName.family).toBe('DM Sans')
})
})

View file

@ -17,7 +17,7 @@ describe('Fix 2: frameMaskDisabled is inverse of clipsContent', () => {
height: 100
})
const changes = toKiwi(node, graph) as Record<string, unknown>[]
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<string, unknown>[]
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<string, unknown>[]
const changes = toKiwi(node, graph)
expect(changes[0].frameMaskDisabled).toBe(true)
})

View file

@ -67,7 +67,7 @@ describe('Integration: auto-layout component with all fixes', () => {
{ value: 100 },
graph,
blobs
) as Record<string, unknown>[]
)
const cardNc = changes[0]
const titleNc = expectDefined(

View file

@ -22,7 +22,7 @@ describe('Fix 4: text lineHeight serialization', () => {
lineHeight: 24
})
const changes = toKiwi(node, graph) as Record<string, unknown>[]
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<string, unknown>[]
const changes = toKiwi(node, graph)
expect(changes[0].lineHeight).toBeUndefined()
})

View file

@ -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'

View file

@ -242,7 +242,7 @@ describe('Kiwi serialization', () => {
{ value: 100 },
graph,
blobs
) as Record<string, unknown>[]
)
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<string, unknown>[]
)
expect(changes[0].borderStrokeWeightsIndependent).toBeUndefined()
expect(changes[0].borderTopWeight).toBeUndefined()

View file

@ -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<string, unknown>)[k]])
(Object.keys(changes) as string[]).map((k) => [k, (node as JsonObject)[k]])
)
graph.updateNode(id, changes)
undo.push({

View file

@ -10,6 +10,11 @@ import { expectDefined } from '#tests/helpers/assert'
type AdapterTool = { execute(args: Record<string, unknown>): Promise<unknown>; description: string }
interface PageTreeToolResult {
page: unknown
children: unknown[]
}
function adapterTool(tools: Record<string, unknown>, 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)
})