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:
parent
b4dfd90da0
commit
1ed7de6056
106
lint/plugin.js
106
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<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,
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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') {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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` }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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[]
|
||||
|
|
|
|||
|
|
@ -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
1
packages/mcp/src/json.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export type RpcJsonObject = { [key: string]: unknown }
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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}`)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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') {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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'),
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
|
|
|
|||
Loading…
Reference in a new issue