canicode 0.11.0 → 0.11.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,14 @@
1
+ // canicode-roundtrip helpers installer (auto-generated — see scripts/bundle-roundtrip-cache.ts)
2
+ // Prepend to the FIRST use_figma batch of a roundtrip session. Caches the helpers source on
3
+ // figma.root via setSharedPluginData so subsequent batches can prepend the much smaller
4
+ // helpers-bootstrap.js instead of re-pasting ~31KB every call (#424, ADR-020).
5
+ var __CANICODE_HELPERS_SRC__ = "var CanICodeRoundtrip = (function (exports) {\n 'use strict';\n\n // src/core/roundtrip/annotations.ts\n function stripAnnotations(annotations) {\n const input = annotations ?? [];\n const out = [];\n for (const a of input) {\n const hasLM = typeof a.labelMarkdown === \"string\" && a.labelMarkdown.length > 0;\n const hasLabel = typeof a.label === \"string\" && a.label.length > 0;\n if (!hasLM && !hasLabel) continue;\n const base = hasLM ? { labelMarkdown: a.labelMarkdown } : { label: a.label };\n if (a.categoryId) base.categoryId = a.categoryId;\n if (Array.isArray(a.properties) && a.properties.length > 0) {\n base.properties = a.properties;\n }\n out.push(base);\n }\n return out;\n }\n async function ensureCanicodeCategories() {\n const api = figma.annotations;\n const existing = await api.getAnnotationCategoriesAsync();\n const byLabel = new Map(existing.map((c) => [c.label, c.id]));\n async function ensure(label, color) {\n const cached = byLabel.get(label);\n if (cached) return cached;\n const created = await api.addAnnotationCategoryAsync({ label, color });\n byLabel.set(label, created.id);\n return created.id;\n }\n const result = {\n gotcha: await ensure(\"canicode:gotcha\", \"blue\"),\n flag: await ensure(\"canicode:flag\", \"green\"),\n fallback: await ensure(\"canicode:fallback\", \"yellow\")\n };\n const legacyAutoFix = byLabel.get(\"canicode:auto-fix\");\n if (legacyAutoFix) result.legacyAutoFix = legacyAutoFix;\n return result;\n }\n function upsertCanicodeAnnotation(node, input) {\n if (!node || !(\"annotations\" in node)) return false;\n const { ruleId, markdown, categoryId, properties } = input;\n const legacyPrefix = `**[canicode] ${ruleId}**`;\n const footer = `\\u2014 *${ruleId}*`;\n let bodyText = markdown;\n if (bodyText.startsWith(legacyPrefix)) {\n bodyText = bodyText.slice(legacyPrefix.length).replace(/^\\s*\\n+/, \"\");\n }\n const trimmed = bodyText.replace(/\\s+$/, \"\");\n const body = trimmed.endsWith(footer) ? trimmed : `${trimmed}\n\n${footer}`;\n const existing = stripAnnotations(node.annotations);\n const entry = { labelMarkdown: body };\n if (categoryId) entry.categoryId = categoryId;\n if (properties && properties.length > 0) entry.properties = properties;\n const matchesRuleId = (text) => {\n if (typeof text !== \"string\") return false;\n return text.startsWith(legacyPrefix) || text.includes(footer);\n };\n const idx = existing.findIndex(\n (a) => matchesRuleId(a.labelMarkdown) || matchesRuleId(a.label)\n );\n if (idx >= 0) existing[idx] = entry;\n else existing.push(entry);\n try {\n node.annotations = existing;\n return true;\n } catch (e) {\n const msg = String(e?.message ?? e);\n const isNodeTypeReject = /invalid property .+ for a .+ node/i.test(msg);\n if (!entry.properties || !isNodeTypeReject) throw e;\n delete entry.properties;\n if (idx >= 0) existing[idx] = entry;\n node.annotations = existing;\n return true;\n }\n }\n\n // src/core/roundtrip/annotation-payload.ts\n var CANICODE_JSON_FENCE = \"```canicode-json\";\n function formatIntentValueForDisplay(value) {\n if (value === void 0) return \"undefined\";\n if (value === null) return \"null\";\n if (typeof value === \"object\") {\n try {\n return `\\`${JSON.stringify(value)}\\``;\n } catch {\n return String(value);\n }\n }\n return `\\`${String(value)}\\``;\n }\n function buildCodegenDirective(args) {\n const { sceneNodeId, intent } = args;\n const val = intent.value === void 0 ? \"undefined\" : JSON.stringify(intent.value);\n return `When generating code for node ${sceneNodeId}, set ${intent.field} to ${val} (user intent, scope: ${intent.scope}). Prefer this over the current Figma scene value when they disagree.`;\n }\n function sceneOutcomeToAck(result, reason) {\n return reason !== void 0 ? { result, reason } : { result };\n }\n function buildOutcomeHumanLine(args) {\n if (args.skippedDefinitionDueToAdr012) {\n const adrHint = \" Canicode skipped writing the source component without `allowDefinitionWrite: true` (ADR-012 safer default). The instance-level change did not apply as intended in the scene.\";\n if (args.reason === \"silent-ignore\") {\n return \"**Scene write outcome:** The write ran, but the property value did not change on this instance (silent-ignore).\" + adrHint;\n }\n return \"**Scene write outcome:** Figma rejected an instance-level change\" + (args.errorMessage ? `: ${args.errorMessage}` : \"\") + \".\" + adrHint;\n }\n if (args.reason === \"silent-ignore\") {\n return \"**Scene write outcome:** The write ran, but the property value did not change on this instance (silent-ignore). No source definition was available to escalate.\";\n }\n if (args.reason === \"override-error\") {\n return \"**Scene write outcome:** Figma rejected an instance-level change\" + (args.errorMessage ? `: ${args.errorMessage}` : \"\") + \". No source definition was available to escalate.\";\n }\n return \"**Scene write outcome:** Could not apply automatically\" + (args.errorMessage ? `: ${args.errorMessage}` : \"\") + \".\";\n }\n function buildAdr012PropagationParagraph(args) {\n const { componentName, replicaCount } = args;\n const fanOutHint = typeof replicaCount === \"number\" && replicaCount >= 2 ? ` This batched question covers ${replicaCount} instance scenes \\u2014 changing **${componentName}** at the definition still affects every inheriting instance, not just one row in the batch.` : \"\";\n return `Canicode's safer default (ADR-012) is to skip writing the source component **${componentName}** without explicit opt-in, because that write propagates to every non-overridden instance of **${componentName}** in the file.${fanOutHint} Prefer a manual override on **this** instance when you only need a local fix. Use \\`allowDefinitionWrite: true\\` only when you intend to change **${componentName}** for all inheriting instances \\u2014 it is not a neutral shortcut for a single-instance tweak.`;\n }\n function buildDefinitionWriteSkippedBody(args) {\n const {\n ruleId,\n sceneNodeId,\n componentName,\n reason,\n errorMessage,\n replicaCount,\n intent\n } = args;\n const ackIntent = intent ? {\n field: intent.field,\n value: intent.value,\n scope: intent.scope\n } : void 0;\n const sceneWriteOutcome = sceneOutcomeToAck(\"user-declined-propagation\", \"adr-012-opt-in-disabled\");\n const codegenDirective = intent !== void 0 ? buildCodegenDirective({ sceneNodeId, intent }) : void 0;\n const jsonBlock = {\n v: 1,\n ruleId,\n nodeId: sceneNodeId,\n ...ackIntent ? { intent: ackIntent } : {},\n sceneWriteOutcome,\n ...codegenDirective ? { codegenDirective } : {}\n };\n const userAnswerLine = intent !== void 0 ? `**User answered:** ${formatIntentValueForDisplay(intent.value)} for **${intent.field}** (scope: ${intent.scope}).` : null;\n const outcomeLine = buildOutcomeHumanLine({\n reason,\n ...errorMessage !== void 0 ? { errorMessage } : {},\n skippedDefinitionDueToAdr012: true\n });\n const adrBlock = buildAdr012PropagationParagraph({\n componentName,\n ...replicaCount !== void 0 ? { replicaCount } : {}\n });\n const proseParts = [userAnswerLine, outcomeLine, adrBlock].filter(\n (p) => p !== null\n );\n const prose = proseParts.join(\"\\n\\n\");\n return appendJsonFenceAndFooter(prose, jsonBlock, ruleId);\n }\n function buildNoDefinitionFallbackBody(args) {\n const { ruleId, sceneNodeId, reason, errorMessage, intent } = args;\n const ackIntent = intent ? { field: intent.field, value: intent.value, scope: intent.scope } : void 0;\n const outcomeResult = reason === \"silent-ignore\" ? \"silent-ignored\" : reason === \"override-error\" ? \"api-rejected\" : \"api-rejected\";\n const sceneWriteOutcome = sceneOutcomeToAck(\n outcomeResult,\n reason === \"silent-ignore\" ? \"silent-ignore-no-definition\" : \"no-definition-escalation\"\n );\n const codegenDirective = intent !== void 0 ? buildCodegenDirective({ sceneNodeId, intent }) : void 0;\n const jsonBlock = {\n v: 1,\n ruleId,\n nodeId: sceneNodeId,\n ...ackIntent ? { intent: ackIntent } : {},\n sceneWriteOutcome,\n ...codegenDirective ? { codegenDirective } : {}\n };\n const userAnswerLine = intent !== void 0 ? `**User answered:** ${formatIntentValueForDisplay(intent.value)} for **${intent.field}** (scope: ${intent.scope}).` : null;\n const outcomeLine = buildOutcomeHumanLine({\n reason,\n ...errorMessage !== void 0 ? { errorMessage } : {},\n skippedDefinitionDueToAdr012: false\n });\n const prose = [userAnswerLine, outcomeLine].filter((p) => p !== null).join(\"\\n\\n\");\n return appendJsonFenceAndFooter(prose, jsonBlock, ruleId);\n }\n function buildDefinitionTierFailureBody(args) {\n const { ruleId, sceneNodeId, intent, kind, errorMessage } = args;\n const sceneWriteOutcome = sceneOutcomeToAck(\n kind === \"read-only-library\" ? \"api-rejected\" : \"api-rejected\",\n kind === \"read-only-library\" ? \"definition-read-only\" : \"definition-write-failed\"\n );\n const codegenDirective = intent !== void 0 ? buildCodegenDirective({ sceneNodeId, intent }) : void 0;\n const jsonBlock = {\n v: 1,\n ruleId,\n nodeId: sceneNodeId,\n ...intent ? {\n intent: {\n field: intent.field,\n value: intent.value,\n scope: intent.scope\n }\n } : {},\n sceneWriteOutcome,\n ...codegenDirective ? { codegenDirective } : {}\n };\n const human = kind === \"read-only-library\" ? \"source component lives in an external library and is read-only from this file \\u2014 apply the fix in the library file itself.\" : `could not apply at source definition: ${errorMessage}`;\n const userAnswerLine = intent !== void 0 ? `**User answered:** ${formatIntentValueForDisplay(intent.value)} for **${intent.field}** (scope: ${intent.scope}).` : null;\n const outcomeLine = `**Scene write outcome:** ${human}`;\n const prose = [userAnswerLine, outcomeLine].filter((p) => p !== null).join(\"\\n\\n\");\n return appendJsonFenceAndFooter(prose, jsonBlock, ruleId);\n }\n function appendJsonFenceAndFooter(prose, jsonBlock, ruleId) {\n const footer = `\\u2014 *${ruleId}*`;\n const hasIntent = jsonBlock.intent !== void 0;\n if (!hasIntent) {\n return `${prose}\n\n${footer}`;\n }\n const jsonText = JSON.stringify(jsonBlock, null, 0);\n return `${prose}\n\n${CANICODE_JSON_FENCE}\n${jsonText}\n\\`\\`\\`\n\n${footer}`;\n }\n var FENCED_JSON_RE = new RegExp(\n `${CANICODE_JSON_FENCE.replace(/[.*+?^${}()|[\\]\\\\]/g, \"\\\\$&\")}\\\\s*([\\\\s\\\\S]*?)\\\\s*\\`\\`\\``,\n \"m\"\n );\n function parseCanicodeJsonPayloadFromMarkdown(text) {\n const m = FENCED_JSON_RE.exec(text);\n if (!m?.[1]) return void 0;\n try {\n const raw = JSON.parse(m[1].trim());\n if (!raw || typeof raw !== \"object\") return void 0;\n const o = raw;\n if (o.v !== 1 || typeof o.ruleId !== \"string\") return void 0;\n return raw;\n } catch {\n return void 0;\n }\n }\n\n // src/core/roundtrip/apply-with-instance-fallback.ts\n var DEFINITION_WRITE_SKIPPED_EVENT = \"cic_roundtrip_definition_write_skipped\";\n function categoryIdForAnnotate(categories, kind, roundtripIntent) {\n if (kind === \"adr012-definition-skipped\") {\n return categories.fallback;\n }\n if (roundtripIntent !== void 0) {\n return categories.gotcha;\n }\n return categories.flag;\n }\n function resolveSourceComponentName(definition, question) {\n if (definition && typeof definition.name === \"string\" && definition.name) {\n return definition.name;\n }\n const ic = question.instanceContext;\n if (ic && typeof ic.sourceComponentName === \"string\" && ic.sourceComponentName) {\n return ic.sourceComponentName;\n }\n return \"the source component\";\n }\n async function routeToDefinitionOrAnnotate(definition, writeFn, ctx) {\n if (definition && !ctx.allowDefinitionWrite && ctx.reason !== \"non-override-error\") {\n const componentName = resolveSourceComponentName(definition, ctx.question);\n const replicaCount = typeof ctx.question.replicas === \"number\" && Number.isInteger(ctx.question.replicas) ? ctx.question.replicas : void 0;\n if (ctx.categories) {\n upsertCanicodeAnnotation(ctx.scene, {\n ruleId: ctx.question.ruleId,\n markdown: buildDefinitionWriteSkippedBody({\n ruleId: ctx.question.ruleId,\n sceneNodeId: ctx.scene.id,\n componentName,\n reason: ctx.reason,\n ...ctx.errorMessage !== void 0 ? { errorMessage: ctx.errorMessage } : {},\n ...replicaCount !== void 0 ? { replicaCount } : {},\n ...ctx.roundtripIntent !== void 0 ? { intent: ctx.roundtripIntent } : {}\n }),\n categoryId: categoryIdForAnnotate(\n ctx.categories,\n \"adr012-definition-skipped\",\n ctx.roundtripIntent\n )\n });\n }\n ctx.telemetry?.(DEFINITION_WRITE_SKIPPED_EVENT, {\n ruleId: ctx.question.ruleId,\n reason: ctx.reason\n });\n return {\n icon: \"\\u{1F4DD}\",\n label: \"definition write skipped (opt-in disabled)\"\n };\n }\n if (!definition) {\n if (ctx.categories) {\n const markdown = buildNoDefinitionFallbackBody({\n ruleId: ctx.question.ruleId,\n sceneNodeId: ctx.scene.id,\n reason: ctx.reason,\n ...ctx.errorMessage !== void 0 ? { errorMessage: ctx.errorMessage } : {},\n ...ctx.roundtripIntent !== void 0 ? { intent: ctx.roundtripIntent } : {}\n });\n upsertCanicodeAnnotation(ctx.scene, {\n ruleId: ctx.question.ruleId,\n markdown,\n categoryId: categoryIdForAnnotate(\n ctx.categories,\n \"other-failure\",\n ctx.roundtripIntent\n )\n });\n }\n return ctx.reason === \"silent-ignore\" ? { icon: \"\\u{1F4DD}\", label: \"silent-ignore, annotated\" } : { icon: \"\\u{1F4DD}\", label: `error: ${ctx.errorMessage ?? \"\"}` };\n }\n try {\n await writeFn(definition);\n return {\n icon: \"\\u{1F310}\",\n label: ctx.reason === \"silent-ignore\" ? \"source definition (silent-ignore fallback)\" : \"source definition\"\n };\n } catch (defErr) {\n const defMsg = String(defErr?.message ?? defErr);\n const isRemoteReadOnly = definition.remote === true || /read-only/i.test(defMsg);\n if (ctx.categories) {\n upsertCanicodeAnnotation(ctx.scene, {\n ruleId: ctx.question.ruleId,\n markdown: buildDefinitionTierFailureBody({\n ruleId: ctx.question.ruleId,\n sceneNodeId: ctx.scene.id,\n ...ctx.roundtripIntent !== void 0 ? { intent: ctx.roundtripIntent } : {},\n kind: isRemoteReadOnly ? \"read-only-library\" : \"definition-error\",\n errorMessage: defMsg\n }),\n categoryId: categoryIdForAnnotate(\n ctx.categories,\n \"other-failure\",\n ctx.roundtripIntent\n )\n });\n }\n return {\n icon: \"\\u{1F4DD}\",\n label: isRemoteReadOnly ? \"external library (read-only)\" : `definition error: ${defMsg}`\n };\n }\n }\n async function applyWithInstanceFallback(question, writeFn, context = {}) {\n const { categories, allowDefinitionWrite = false, telemetry, roundtripIntent } = context;\n const scene = await figma.getNodeByIdAsync(question.nodeId);\n if (!scene) return { icon: \"\\u{1F4DD}\", label: \"missing node\" };\n const definition = question.sourceChildId ? await figma.getNodeByIdAsync(question.sourceChildId) : null;\n try {\n const changed = await writeFn(scene);\n if (changed === false) {\n return routeToDefinitionOrAnnotate(definition, writeFn, {\n question,\n scene,\n categories,\n reason: \"silent-ignore\",\n allowDefinitionWrite,\n telemetry,\n ...roundtripIntent !== void 0 ? { roundtripIntent } : {}\n });\n }\n return { icon: \"\\u2705\", label: \"instance/scene\" };\n } catch (e) {\n const msg = String(e?.message ?? e);\n const looksLikeInstanceOverride = /cannot be overridden/i.test(msg) || /override/i.test(msg);\n if (!looksLikeInstanceOverride) {\n return routeToDefinitionOrAnnotate(null, writeFn, {\n question,\n scene,\n categories,\n reason: \"non-override-error\",\n errorMessage: msg,\n allowDefinitionWrite,\n telemetry,\n ...roundtripIntent !== void 0 ? { roundtripIntent } : {}\n });\n }\n return routeToDefinitionOrAnnotate(definition, writeFn, {\n question,\n scene,\n categories,\n reason: \"override-error\",\n errorMessage: msg,\n allowDefinitionWrite,\n telemetry,\n ...roundtripIntent !== void 0 ? { roundtripIntent } : {}\n });\n }\n }\n\n // src/core/roundtrip/apply-property-mod.ts\n async function resolveVariableByName(name) {\n const locals = await figma.variables.getLocalVariablesAsync();\n return locals.find((v) => v.name === name) ?? null;\n }\n function parseValue(raw) {\n if (raw && typeof raw === \"object\" && \"variable\" in raw) {\n const v = raw;\n const parsed = { kind: \"binding\", name: v.variable };\n if (\"fallback\" in v) parsed.fallback = v.fallback;\n return parsed;\n }\n if (raw && typeof raw === \"object\" && \"fallback\" in raw) {\n return { kind: \"scalar\", scalar: raw.fallback };\n }\n return { kind: \"scalar\", scalar: raw };\n }\n function isPaintProp(prop) {\n return prop === \"fills\" || prop === \"strokes\";\n }\n function applyPropertyBinding(target, prop, variable) {\n if (isPaintProp(prop)) {\n const current = target[prop];\n if (current === figma.mixed || !Array.isArray(current)) return false;\n const paints = current;\n const bound = paints.map(\n (paint) => figma.variables.setBoundVariableForPaint(paint, \"color\", variable)\n );\n target[prop] = bound;\n return true;\n }\n target.setBoundVariable(prop, variable);\n return true;\n }\n function buildRoundtripIntentFromPropertyAnswer(question, answerValue) {\n const raw = question.targetProperty;\n if (raw === void 0) return void 0;\n const props = Array.isArray(raw) ? raw : [raw];\n if (props.length === 0) return void 0;\n if (props.length === 1) {\n const prop = props[0];\n const perProp = answerValue && typeof answerValue === \"object\" && !(\"variable\" in answerValue) && !Array.isArray(answerValue) ? answerValue[prop] : answerValue;\n const parsed = parseValueForIntent(perProp);\n if (parsed === void 0) return void 0;\n return { field: prop, value: parsed, scope: \"instance\" };\n }\n const obj = answerValue && typeof answerValue === \"object\" && !(\"variable\" in answerValue) && !Array.isArray(answerValue) ? answerValue : void 0;\n const picked = {};\n for (const p of props) {\n if (obj && p in obj && obj[p] !== void 0) picked[p] = obj[p];\n }\n if (Object.keys(picked).length === 0) return void 0;\n return {\n field: props.join(\", \"),\n value: picked,\n scope: \"instance\"\n };\n }\n function parseValueForIntent(raw) {\n if (raw && typeof raw === \"object\" && \"variable\" in raw) {\n return { variable: raw.variable };\n }\n if (raw && typeof raw === \"object\" && \"fallback\" in raw) {\n return raw.fallback;\n }\n return raw;\n }\n function applyPropertyScalar(target, prop, scalar) {\n const rec = target;\n const before = rec[prop];\n rec[prop] = scalar;\n if (rec[prop] === before && before !== scalar) return false;\n return true;\n }\n async function applyPropertyMod(question, answerValue, context = {}) {\n const roundtripIntent = buildRoundtripIntentFromPropertyAnswer(\n question,\n answerValue\n );\n const props = Array.isArray(question.targetProperty) ? question.targetProperty : question.targetProperty !== void 0 ? [question.targetProperty] : [];\n return applyWithInstanceFallback(\n question,\n async (target) => {\n if (!target) return void 0;\n let changed = void 0;\n for (const prop of props) {\n if (!(prop in target)) continue;\n const perProp = answerValue && typeof answerValue === \"object\" && !(\"variable\" in answerValue) && !Array.isArray(answerValue) ? answerValue[prop] : answerValue;\n const parsed = parseValue(perProp);\n if (parsed.kind === \"binding\") {\n const variable = await resolveVariableByName(parsed.name);\n if (variable) {\n applyPropertyBinding(target, prop, variable);\n continue;\n }\n if (parsed.fallback !== void 0) {\n if (!applyPropertyScalar(target, prop, parsed.fallback)) {\n changed = false;\n }\n }\n continue;\n }\n if (parsed.scalar === void 0) continue;\n if (!applyPropertyScalar(target, prop, parsed.scalar)) {\n changed = false;\n }\n }\n return changed;\n },\n {\n ...context,\n ...roundtripIntent !== void 0 ? { roundtripIntent } : {}\n }\n );\n }\n\n // src/core/roundtrip/probe-definition-writability.ts\n async function probeDefinitionWritability(questions) {\n const verdict = /* @__PURE__ */ new Map();\n const unwritableNames = [];\n const seenName = /* @__PURE__ */ new Set();\n for (const q of questions) {\n const id = q.sourceChildId;\n if (!id) continue;\n if (verdict.has(id)) continue;\n const node = await figma.getNodeByIdAsync(id);\n const writability = resolveWritability(node);\n const isUnwritable = writability.isUnwritable;\n verdict.set(id, isUnwritable ? \"unwritable\" : \"writable\");\n if (isUnwritable) {\n const name = typeof writability.componentName === \"string\" && writability.componentName || typeof node?.name === \"string\" && node.name || q.instanceContext?.sourceComponentName || id;\n if (!seenName.has(name)) {\n seenName.add(name);\n unwritableNames.push(name);\n }\n }\n }\n const totalCount = verdict.size;\n let unwritableCount = 0;\n for (const v of verdict.values()) if (v === \"unwritable\") unwritableCount++;\n return {\n totalCount,\n unwritableCount,\n unwritableSourceNames: unwritableNames,\n allUnwritable: totalCount > 0 && unwritableCount === totalCount,\n partiallyUnwritable: unwritableCount > 0 && unwritableCount < totalCount\n };\n }\n function resolveWritability(node) {\n if (node === null) return { isUnwritable: true };\n if (\"remote\" in node && typeof node.remote === \"boolean\") {\n return { isUnwritable: node.remote === true };\n }\n const containing = findContainingComponent(node);\n if (!containing) {\n return { isUnwritable: false };\n }\n const isUnwritable = \"remote\" in containing && containing.remote === true;\n return {\n isUnwritable,\n ...isUnwritable && typeof containing.name === \"string\" ? { componentName: containing.name } : {}\n };\n }\n function findContainingComponent(node) {\n let cur = node;\n for (let i = 0; i < 100 && cur; i++) {\n if (cur.type === \"COMPONENT\" || cur.type === \"COMPONENT_SET\") return cur;\n cur = cur.parent ?? null;\n }\n return null;\n }\n\n // src/core/roundtrip/read-acknowledgments.ts\n var FOOTER_RE = /—\\s+\\*([A-Za-z0-9-]+)\\*\\s*$/;\n var LEGACY_PREFIX_RE = /^\\*\\*\\[canicode\\]\\s+([A-Za-z0-9-]+)\\*\\*/;\n function extractAcknowledgmentsFromNode(node, canicodeCategoryIds) {\n if (!node || !(\"annotations\" in node)) return [];\n const annotations = node.annotations ?? [];\n if (annotations.length === 0) return [];\n const out = [];\n for (const a of annotations) {\n const text = (typeof a.labelMarkdown === \"string\" && a.labelMarkdown.length > 0 ? a.labelMarkdown : \"\") || (typeof a.label === \"string\" && a.label.length > 0 ? a.label : \"\");\n if (!text) continue;\n if (canicodeCategoryIds) {\n if (!a.categoryId || !canicodeCategoryIds.has(a.categoryId)) continue;\n }\n const ruleId = extractRuleId(text);\n if (!ruleId) continue;\n const payload = parseCanicodeJsonPayloadFromMarkdown(text);\n const payloadAligned = payload && payload.ruleId === ruleId;\n out.push({\n nodeId: node.id,\n ruleId,\n ...payloadAligned && payload.intent ? { intent: payload.intent } : {},\n ...payloadAligned && payload.sceneWriteOutcome ? { sceneWriteOutcome: payload.sceneWriteOutcome } : {},\n ...payloadAligned && payload.codegenDirective ? { codegenDirective: payload.codegenDirective } : {}\n });\n }\n return out;\n }\n function extractRuleId(text) {\n const footer = FOOTER_RE.exec(text);\n if (footer) return footer[1] ?? null;\n const legacy = LEGACY_PREFIX_RE.exec(text);\n if (legacy) return legacy[1] ?? null;\n return null;\n }\n async function readCanicodeAcknowledgments(rootNodeId, categories) {\n const root = await figma.getNodeByIdAsync(rootNodeId);\n if (!root) return [];\n const canicodeCategoryIds = categories ? new Set(\n [\n categories.gotcha,\n categories.flag,\n categories.fallback,\n categories.legacyAutoFix\n ].filter((id) => typeof id === \"string\" && id.length > 0)\n ) : void 0;\n const out = [];\n walk(root, canicodeCategoryIds, out);\n return out;\n }\n function safeChildren(node) {\n try {\n const c = node.children;\n return Array.isArray(c) ? c : [];\n } catch {\n return [];\n }\n }\n function walk(node, canicodeCategoryIds, out) {\n try {\n const local = extractAcknowledgmentsFromNode(node, canicodeCategoryIds);\n for (const a of local) out.push(a);\n } catch {\n }\n for (const child of safeChildren(node)) {\n if (child && typeof child === \"object\") walk(child, canicodeCategoryIds, out);\n }\n }\n\n // src/core/roundtrip/compute-roundtrip-tally.ts\n function computeRoundtripTally(args) {\n const { stepFourReport, reanalyzeResponse } = args;\n const { resolved, annotated, definitionWritten, skipped } = stepFourReport;\n const { issueCount, acknowledgedCount } = reanalyzeResponse;\n if (acknowledgedCount > issueCount) {\n throw new Error(\n `computeRoundtripTally: reanalyzeResponse.acknowledgedCount (${acknowledgedCount}) cannot exceed issueCount (${issueCount}). Acknowledged issues are a subset of remaining issues.`\n );\n }\n return {\n X: resolved,\n Y: annotated,\n Z: definitionWritten,\n W: skipped,\n N: resolved + annotated + definitionWritten + skipped,\n V: issueCount,\n V_ack: acknowledgedCount,\n V_open: issueCount - acknowledgedCount\n };\n }\n\n // src/core/roundtrip/apply-auto-fix.ts\n function pickNodeName(issue, resolved) {\n if (resolved && typeof resolved.name === \"string\" && resolved.name.length > 0) {\n return resolved.name;\n }\n if (typeof issue.nodePath === \"string\" && issue.nodePath.length > 0) {\n const segments = issue.nodePath.split(/\\s*[›>/]\\s*/);\n const tail = segments[segments.length - 1];\n if (tail && tail.length > 0) return tail;\n }\n return issue.nodeId;\n }\n function mapInstanceFallbackIcon(result) {\n if (result.icon === \"\\u2705\") return \"\\u{1F527}\";\n return result.icon;\n }\n async function applyAutoFix(issue, context) {\n const { categories } = context;\n const ruleId = issue.ruleId;\n if (issue.targetProperty === \"name\" && typeof issue.suggestedName === \"string\") {\n const suggestedName = issue.suggestedName;\n const question = {\n nodeId: issue.nodeId,\n ruleId,\n ...issue.sourceChildId ? { sourceChildId: issue.sourceChildId } : {}\n };\n const result = await applyWithInstanceFallback(\n question,\n (target) => {\n if (target) {\n target.name = suggestedName;\n }\n },\n {\n categories,\n ...context.allowDefinitionWrite !== void 0 ? { allowDefinitionWrite: context.allowDefinitionWrite } : {},\n ...context.telemetry !== void 0 ? { telemetry: context.telemetry } : {}\n }\n );\n const sceneAfter = await figma.getNodeByIdAsync(issue.nodeId);\n return {\n outcome: mapInstanceFallbackIcon(result),\n nodeId: issue.nodeId,\n nodeName: pickNodeName(issue, sceneAfter),\n ruleId,\n label: result.label\n };\n }\n const scene = await figma.getNodeByIdAsync(issue.nodeId);\n const markdown = issue.message ?? `Auto-flagged: ${ruleId}`;\n if (scene) {\n upsertCanicodeAnnotation(scene, {\n ruleId,\n markdown,\n categoryId: categories.flag,\n ...issue.annotationProperties && issue.annotationProperties.length > 0 ? { properties: issue.annotationProperties } : {}\n });\n }\n return {\n outcome: \"\\u{1F4DD}\",\n nodeId: issue.nodeId,\n nodeName: pickNodeName(issue, scene),\n ruleId,\n label: scene ? `annotation added to canicode:flag \\u2014 ${ruleId}` : `missing node (annotation skipped) \\u2014 ${ruleId}`\n };\n }\n async function applyAutoFixes(issues, context) {\n const out = [];\n for (const issue of issues) {\n if (issue.applyStrategy !== \"auto-fix\") {\n out.push({\n outcome: \"\\u23ED\\uFE0F\",\n nodeId: issue.nodeId,\n nodeName: pickNodeName(issue, null),\n ruleId: issue.ruleId,\n label: `skipped \\u2014 applyStrategy is ${issue.applyStrategy ?? \"absent\"}`\n });\n continue;\n }\n out.push(await applyAutoFix(issue, context));\n }\n return out;\n }\n\n // src/core/roundtrip/remove-canicode-annotations.ts\n var LEGACY_CANICODE_PREFIX = \"**[canicode]\";\n function isCanicodeAnnotation(annotation, categories) {\n const canicodeIds = new Set(\n [\n categories.gotcha,\n categories.flag,\n categories.fallback,\n categories.legacyAutoFix\n ].filter((id) => Boolean(id))\n );\n if (annotation.categoryId && canicodeIds.has(annotation.categoryId)) {\n return true;\n }\n if (annotation.labelMarkdown?.startsWith(LEGACY_CANICODE_PREFIX)) {\n return true;\n }\n return false;\n }\n function removeCanicodeAnnotations(annotations, categories) {\n return annotations.filter((a) => !isCanicodeAnnotation(a, categories));\n }\n\n exports.applyAutoFix = applyAutoFix;\n exports.applyAutoFixes = applyAutoFixes;\n exports.applyPropertyMod = applyPropertyMod;\n exports.applyWithInstanceFallback = applyWithInstanceFallback;\n exports.computeRoundtripTally = computeRoundtripTally;\n exports.ensureCanicodeCategories = ensureCanicodeCategories;\n exports.extractAcknowledgmentsFromNode = extractAcknowledgmentsFromNode;\n exports.isCanicodeAnnotation = isCanicodeAnnotation;\n exports.probeDefinitionWritability = probeDefinitionWritability;\n exports.readCanicodeAcknowledgments = readCanicodeAcknowledgments;\n exports.removeCanicodeAnnotations = removeCanicodeAnnotations;\n exports.resolveVariableByName = resolveVariableByName;\n exports.stripAnnotations = stripAnnotations;\n exports.upsertCanicodeAnnotation = upsertCanicodeAnnotation;\n\n return exports;\n\n})({});\n";
6
+ var __CANICODE_HELPERS_VERSION__ = "0.11.2";
7
+ (0, eval)(__CANICODE_HELPERS_SRC__);
8
+ try {
9
+ figma.root.setSharedPluginData("canicode", "helpersSrc", __CANICODE_HELPERS_SRC__);
10
+ figma.root.setSharedPluginData("canicode", "helpersVersion", __CANICODE_HELPERS_VERSION__);
11
+ globalThis.__canicodeInstallResult = { cachePersisted: true };
12
+ } catch (err) {
13
+ globalThis.__canicodeInstallResult = { cachePersisted: false, reason: String((err && err.message) || err) };
14
+ }
@@ -78,8 +78,189 @@ ${footer}`;
78
78
  }
79
79
  }
80
80
 
81
+ // src/core/roundtrip/annotation-payload.ts
82
+ var CANICODE_JSON_FENCE = "```canicode-json";
83
+ function formatIntentValueForDisplay(value) {
84
+ if (value === void 0) return "undefined";
85
+ if (value === null) return "null";
86
+ if (typeof value === "object") {
87
+ try {
88
+ return `\`${JSON.stringify(value)}\``;
89
+ } catch {
90
+ return String(value);
91
+ }
92
+ }
93
+ return `\`${String(value)}\``;
94
+ }
95
+ function buildCodegenDirective(args) {
96
+ const { sceneNodeId, intent } = args;
97
+ const val = intent.value === void 0 ? "undefined" : JSON.stringify(intent.value);
98
+ return `When generating code for node ${sceneNodeId}, set ${intent.field} to ${val} (user intent, scope: ${intent.scope}). Prefer this over the current Figma scene value when they disagree.`;
99
+ }
100
+ function sceneOutcomeToAck(result, reason) {
101
+ return reason !== void 0 ? { result, reason } : { result };
102
+ }
103
+ function buildOutcomeHumanLine(args) {
104
+ if (args.skippedDefinitionDueToAdr012) {
105
+ const adrHint = " Canicode skipped writing the source component without `allowDefinitionWrite: true` (ADR-012 safer default). The instance-level change did not apply as intended in the scene.";
106
+ if (args.reason === "silent-ignore") {
107
+ return "**Scene write outcome:** The write ran, but the property value did not change on this instance (silent-ignore)." + adrHint;
108
+ }
109
+ return "**Scene write outcome:** Figma rejected an instance-level change" + (args.errorMessage ? `: ${args.errorMessage}` : "") + "." + adrHint;
110
+ }
111
+ if (args.reason === "silent-ignore") {
112
+ return "**Scene write outcome:** The write ran, but the property value did not change on this instance (silent-ignore). No source definition was available to escalate.";
113
+ }
114
+ if (args.reason === "override-error") {
115
+ return "**Scene write outcome:** Figma rejected an instance-level change" + (args.errorMessage ? `: ${args.errorMessage}` : "") + ". No source definition was available to escalate.";
116
+ }
117
+ return "**Scene write outcome:** Could not apply automatically" + (args.errorMessage ? `: ${args.errorMessage}` : "") + ".";
118
+ }
119
+ function buildAdr012PropagationParagraph(args) {
120
+ const { componentName, replicaCount } = args;
121
+ const fanOutHint = typeof replicaCount === "number" && replicaCount >= 2 ? ` This batched question covers ${replicaCount} instance scenes \u2014 changing **${componentName}** at the definition still affects every inheriting instance, not just one row in the batch.` : "";
122
+ return `Canicode's safer default (ADR-012) is to skip writing the source component **${componentName}** without explicit opt-in, because that write propagates to every non-overridden instance of **${componentName}** in the file.${fanOutHint} Prefer a manual override on **this** instance when you only need a local fix. Use \`allowDefinitionWrite: true\` only when you intend to change **${componentName}** for all inheriting instances \u2014 it is not a neutral shortcut for a single-instance tweak.`;
123
+ }
124
+ function buildDefinitionWriteSkippedBody(args) {
125
+ const {
126
+ ruleId,
127
+ sceneNodeId,
128
+ componentName,
129
+ reason,
130
+ errorMessage,
131
+ replicaCount,
132
+ intent
133
+ } = args;
134
+ const ackIntent = intent ? {
135
+ field: intent.field,
136
+ value: intent.value,
137
+ scope: intent.scope
138
+ } : void 0;
139
+ const sceneWriteOutcome = sceneOutcomeToAck("user-declined-propagation", "adr-012-opt-in-disabled");
140
+ const codegenDirective = intent !== void 0 ? buildCodegenDirective({ sceneNodeId, intent }) : void 0;
141
+ const jsonBlock = {
142
+ v: 1,
143
+ ruleId,
144
+ nodeId: sceneNodeId,
145
+ ...ackIntent ? { intent: ackIntent } : {},
146
+ sceneWriteOutcome,
147
+ ...codegenDirective ? { codegenDirective } : {}
148
+ };
149
+ const userAnswerLine = intent !== void 0 ? `**User answered:** ${formatIntentValueForDisplay(intent.value)} for **${intent.field}** (scope: ${intent.scope}).` : null;
150
+ const outcomeLine = buildOutcomeHumanLine({
151
+ reason,
152
+ ...errorMessage !== void 0 ? { errorMessage } : {},
153
+ skippedDefinitionDueToAdr012: true
154
+ });
155
+ const adrBlock = buildAdr012PropagationParagraph({
156
+ componentName,
157
+ ...replicaCount !== void 0 ? { replicaCount } : {}
158
+ });
159
+ const proseParts = [userAnswerLine, outcomeLine, adrBlock].filter(
160
+ (p) => p !== null
161
+ );
162
+ const prose = proseParts.join("\n\n");
163
+ return appendJsonFenceAndFooter(prose, jsonBlock, ruleId);
164
+ }
165
+ function buildNoDefinitionFallbackBody(args) {
166
+ const { ruleId, sceneNodeId, reason, errorMessage, intent } = args;
167
+ const ackIntent = intent ? { field: intent.field, value: intent.value, scope: intent.scope } : void 0;
168
+ const outcomeResult = reason === "silent-ignore" ? "silent-ignored" : reason === "override-error" ? "api-rejected" : "api-rejected";
169
+ const sceneWriteOutcome = sceneOutcomeToAck(
170
+ outcomeResult,
171
+ reason === "silent-ignore" ? "silent-ignore-no-definition" : "no-definition-escalation"
172
+ );
173
+ const codegenDirective = intent !== void 0 ? buildCodegenDirective({ sceneNodeId, intent }) : void 0;
174
+ const jsonBlock = {
175
+ v: 1,
176
+ ruleId,
177
+ nodeId: sceneNodeId,
178
+ ...ackIntent ? { intent: ackIntent } : {},
179
+ sceneWriteOutcome,
180
+ ...codegenDirective ? { codegenDirective } : {}
181
+ };
182
+ const userAnswerLine = intent !== void 0 ? `**User answered:** ${formatIntentValueForDisplay(intent.value)} for **${intent.field}** (scope: ${intent.scope}).` : null;
183
+ const outcomeLine = buildOutcomeHumanLine({
184
+ reason,
185
+ ...errorMessage !== void 0 ? { errorMessage } : {},
186
+ skippedDefinitionDueToAdr012: false
187
+ });
188
+ const prose = [userAnswerLine, outcomeLine].filter((p) => p !== null).join("\n\n");
189
+ return appendJsonFenceAndFooter(prose, jsonBlock, ruleId);
190
+ }
191
+ function buildDefinitionTierFailureBody(args) {
192
+ const { ruleId, sceneNodeId, intent, kind, errorMessage } = args;
193
+ const sceneWriteOutcome = sceneOutcomeToAck(
194
+ kind === "read-only-library" ? "api-rejected" : "api-rejected",
195
+ kind === "read-only-library" ? "definition-read-only" : "definition-write-failed"
196
+ );
197
+ const codegenDirective = intent !== void 0 ? buildCodegenDirective({ sceneNodeId, intent }) : void 0;
198
+ const jsonBlock = {
199
+ v: 1,
200
+ ruleId,
201
+ nodeId: sceneNodeId,
202
+ ...intent ? {
203
+ intent: {
204
+ field: intent.field,
205
+ value: intent.value,
206
+ scope: intent.scope
207
+ }
208
+ } : {},
209
+ sceneWriteOutcome,
210
+ ...codegenDirective ? { codegenDirective } : {}
211
+ };
212
+ const human = kind === "read-only-library" ? "source component lives in an external library and is read-only from this file \u2014 apply the fix in the library file itself." : `could not apply at source definition: ${errorMessage}`;
213
+ const userAnswerLine = intent !== void 0 ? `**User answered:** ${formatIntentValueForDisplay(intent.value)} for **${intent.field}** (scope: ${intent.scope}).` : null;
214
+ const outcomeLine = `**Scene write outcome:** ${human}`;
215
+ const prose = [userAnswerLine, outcomeLine].filter((p) => p !== null).join("\n\n");
216
+ return appendJsonFenceAndFooter(prose, jsonBlock, ruleId);
217
+ }
218
+ function appendJsonFenceAndFooter(prose, jsonBlock, ruleId) {
219
+ const footer = `\u2014 *${ruleId}*`;
220
+ const hasIntent = jsonBlock.intent !== void 0;
221
+ if (!hasIntent) {
222
+ return `${prose}
223
+
224
+ ${footer}`;
225
+ }
226
+ const jsonText = JSON.stringify(jsonBlock, null, 0);
227
+ return `${prose}
228
+
229
+ ${CANICODE_JSON_FENCE}
230
+ ${jsonText}
231
+ \`\`\`
232
+
233
+ ${footer}`;
234
+ }
235
+ var FENCED_JSON_RE = new RegExp(
236
+ `${CANICODE_JSON_FENCE.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\s*([\\s\\S]*?)\\s*\`\`\``,
237
+ "m"
238
+ );
239
+ function parseCanicodeJsonPayloadFromMarkdown(text) {
240
+ const m = FENCED_JSON_RE.exec(text);
241
+ if (!m?.[1]) return void 0;
242
+ try {
243
+ const raw = JSON.parse(m[1].trim());
244
+ if (!raw || typeof raw !== "object") return void 0;
245
+ const o = raw;
246
+ if (o.v !== 1 || typeof o.ruleId !== "string") return void 0;
247
+ return raw;
248
+ } catch {
249
+ return void 0;
250
+ }
251
+ }
252
+
81
253
  // src/core/roundtrip/apply-with-instance-fallback.ts
82
254
  var DEFINITION_WRITE_SKIPPED_EVENT = "cic_roundtrip_definition_write_skipped";
255
+ function categoryIdForAnnotate(categories, kind, roundtripIntent) {
256
+ if (kind === "adr012-definition-skipped") {
257
+ return categories.fallback;
258
+ }
259
+ if (roundtripIntent !== void 0) {
260
+ return categories.gotcha;
261
+ }
262
+ return categories.flag;
263
+ }
83
264
  function resolveSourceComponentName(definition, question) {
84
265
  if (definition && typeof definition.name === "string" && definition.name) {
85
266
  return definition.name;
@@ -93,11 +274,24 @@ ${footer}`;
93
274
  async function routeToDefinitionOrAnnotate(definition, writeFn, ctx) {
94
275
  if (definition && !ctx.allowDefinitionWrite && ctx.reason !== "non-override-error") {
95
276
  const componentName = resolveSourceComponentName(definition, ctx.question);
277
+ const replicaCount = typeof ctx.question.replicas === "number" && Number.isInteger(ctx.question.replicas) ? ctx.question.replicas : void 0;
96
278
  if (ctx.categories) {
97
279
  upsertCanicodeAnnotation(ctx.scene, {
98
280
  ruleId: ctx.question.ruleId,
99
- markdown: `The fix below could not be applied on this instance child \u2014 the property silently ignored the write or the override was rejected. Apply it on the source component **${componentName}** so every instance picks it up. Re-run with \`allowDefinitionWrite: true\` to let canicode propagate automatically.`,
100
- categoryId: ctx.categories.fallback
281
+ markdown: buildDefinitionWriteSkippedBody({
282
+ ruleId: ctx.question.ruleId,
283
+ sceneNodeId: ctx.scene.id,
284
+ componentName,
285
+ reason: ctx.reason,
286
+ ...ctx.errorMessage !== void 0 ? { errorMessage: ctx.errorMessage } : {},
287
+ ...replicaCount !== void 0 ? { replicaCount } : {},
288
+ ...ctx.roundtripIntent !== void 0 ? { intent: ctx.roundtripIntent } : {}
289
+ }),
290
+ categoryId: categoryIdForAnnotate(
291
+ ctx.categories,
292
+ "adr012-definition-skipped",
293
+ ctx.roundtripIntent
294
+ )
101
295
  });
102
296
  }
103
297
  ctx.telemetry?.(DEFINITION_WRITE_SKIPPED_EVENT, {
@@ -111,11 +305,21 @@ ${footer}`;
111
305
  }
112
306
  if (!definition) {
113
307
  if (ctx.categories) {
114
- const markdown = ctx.reason === "silent-ignore" ? "write accepted but value unchanged; no definition available" : ctx.reason === "override-error" ? `could not apply automatically: ${ctx.errorMessage ?? ""}` : `could not apply automatically: ${ctx.errorMessage ?? ""}`;
308
+ const markdown = buildNoDefinitionFallbackBody({
309
+ ruleId: ctx.question.ruleId,
310
+ sceneNodeId: ctx.scene.id,
311
+ reason: ctx.reason,
312
+ ...ctx.errorMessage !== void 0 ? { errorMessage: ctx.errorMessage } : {},
313
+ ...ctx.roundtripIntent !== void 0 ? { intent: ctx.roundtripIntent } : {}
314
+ });
115
315
  upsertCanicodeAnnotation(ctx.scene, {
116
316
  ruleId: ctx.question.ruleId,
117
317
  markdown,
118
- categoryId: ctx.categories.fallback
318
+ categoryId: categoryIdForAnnotate(
319
+ ctx.categories,
320
+ "other-failure",
321
+ ctx.roundtripIntent
322
+ )
119
323
  });
120
324
  }
121
325
  return ctx.reason === "silent-ignore" ? { icon: "\u{1F4DD}", label: "silent-ignore, annotated" } : { icon: "\u{1F4DD}", label: `error: ${ctx.errorMessage ?? ""}` };
@@ -132,8 +336,18 @@ ${footer}`;
132
336
  if (ctx.categories) {
133
337
  upsertCanicodeAnnotation(ctx.scene, {
134
338
  ruleId: ctx.question.ruleId,
135
- markdown: isRemoteReadOnly ? "source component lives in an external library and is read-only from this file \u2014 apply the fix in the library file itself." : `could not apply at source definition: ${defMsg}`,
136
- categoryId: ctx.categories.fallback
339
+ markdown: buildDefinitionTierFailureBody({
340
+ ruleId: ctx.question.ruleId,
341
+ sceneNodeId: ctx.scene.id,
342
+ ...ctx.roundtripIntent !== void 0 ? { intent: ctx.roundtripIntent } : {},
343
+ kind: isRemoteReadOnly ? "read-only-library" : "definition-error",
344
+ errorMessage: defMsg
345
+ }),
346
+ categoryId: categoryIdForAnnotate(
347
+ ctx.categories,
348
+ "other-failure",
349
+ ctx.roundtripIntent
350
+ )
137
351
  });
138
352
  }
139
353
  return {
@@ -143,7 +357,7 @@ ${footer}`;
143
357
  }
144
358
  }
145
359
  async function applyWithInstanceFallback(question, writeFn, context = {}) {
146
- const { categories, allowDefinitionWrite = false, telemetry } = context;
360
+ const { categories, allowDefinitionWrite = false, telemetry, roundtripIntent } = context;
147
361
  const scene = await figma.getNodeByIdAsync(question.nodeId);
148
362
  if (!scene) return { icon: "\u{1F4DD}", label: "missing node" };
149
363
  const definition = question.sourceChildId ? await figma.getNodeByIdAsync(question.sourceChildId) : null;
@@ -156,7 +370,8 @@ ${footer}`;
156
370
  categories,
157
371
  reason: "silent-ignore",
158
372
  allowDefinitionWrite,
159
- telemetry
373
+ telemetry,
374
+ ...roundtripIntent !== void 0 ? { roundtripIntent } : {}
160
375
  });
161
376
  }
162
377
  return { icon: "\u2705", label: "instance/scene" };
@@ -171,7 +386,8 @@ ${footer}`;
171
386
  reason: "non-override-error",
172
387
  errorMessage: msg,
173
388
  allowDefinitionWrite,
174
- telemetry
389
+ telemetry,
390
+ ...roundtripIntent !== void 0 ? { roundtripIntent } : {}
175
391
  });
176
392
  }
177
393
  return routeToDefinitionOrAnnotate(definition, writeFn, {
@@ -181,7 +397,8 @@ ${footer}`;
181
397
  reason: "override-error",
182
398
  errorMessage: msg,
183
399
  allowDefinitionWrite,
184
- telemetry
400
+ telemetry,
401
+ ...roundtripIntent !== void 0 ? { roundtripIntent } : {}
185
402
  });
186
403
  }
187
404
  }
@@ -220,6 +437,39 @@ ${footer}`;
220
437
  target.setBoundVariable(prop, variable);
221
438
  return true;
222
439
  }
440
+ function buildRoundtripIntentFromPropertyAnswer(question, answerValue) {
441
+ const raw = question.targetProperty;
442
+ if (raw === void 0) return void 0;
443
+ const props = Array.isArray(raw) ? raw : [raw];
444
+ if (props.length === 0) return void 0;
445
+ if (props.length === 1) {
446
+ const prop = props[0];
447
+ const perProp = answerValue && typeof answerValue === "object" && !("variable" in answerValue) && !Array.isArray(answerValue) ? answerValue[prop] : answerValue;
448
+ const parsed = parseValueForIntent(perProp);
449
+ if (parsed === void 0) return void 0;
450
+ return { field: prop, value: parsed, scope: "instance" };
451
+ }
452
+ const obj = answerValue && typeof answerValue === "object" && !("variable" in answerValue) && !Array.isArray(answerValue) ? answerValue : void 0;
453
+ const picked = {};
454
+ for (const p of props) {
455
+ if (obj && p in obj && obj[p] !== void 0) picked[p] = obj[p];
456
+ }
457
+ if (Object.keys(picked).length === 0) return void 0;
458
+ return {
459
+ field: props.join(", "),
460
+ value: picked,
461
+ scope: "instance"
462
+ };
463
+ }
464
+ function parseValueForIntent(raw) {
465
+ if (raw && typeof raw === "object" && "variable" in raw) {
466
+ return { variable: raw.variable };
467
+ }
468
+ if (raw && typeof raw === "object" && "fallback" in raw) {
469
+ return raw.fallback;
470
+ }
471
+ return raw;
472
+ }
223
473
  function applyPropertyScalar(target, prop, scalar) {
224
474
  const rec = target;
225
475
  const before = rec[prop];
@@ -228,6 +478,10 @@ ${footer}`;
228
478
  return true;
229
479
  }
230
480
  async function applyPropertyMod(question, answerValue, context = {}) {
481
+ const roundtripIntent = buildRoundtripIntentFromPropertyAnswer(
482
+ question,
483
+ answerValue
484
+ );
231
485
  const props = Array.isArray(question.targetProperty) ? question.targetProperty : question.targetProperty !== void 0 ? [question.targetProperty] : [];
232
486
  return applyWithInstanceFallback(
233
487
  question,
@@ -258,7 +512,10 @@ ${footer}`;
258
512
  }
259
513
  return changed;
260
514
  },
261
- context
515
+ {
516
+ ...context,
517
+ ...roundtripIntent !== void 0 ? { roundtripIntent } : {}
518
+ }
262
519
  );
263
520
  }
264
521
 
@@ -334,7 +591,15 @@ ${footer}`;
334
591
  }
335
592
  const ruleId = extractRuleId(text);
336
593
  if (!ruleId) continue;
337
- out.push({ nodeId: node.id, ruleId });
594
+ const payload = parseCanicodeJsonPayloadFromMarkdown(text);
595
+ const payloadAligned = payload && payload.ruleId === ruleId;
596
+ out.push({
597
+ nodeId: node.id,
598
+ ruleId,
599
+ ...payloadAligned && payload.intent ? { intent: payload.intent } : {},
600
+ ...payloadAligned && payload.sceneWriteOutcome ? { sceneWriteOutcome: payload.sceneWriteOutcome } : {},
601
+ ...payloadAligned && payload.codegenDirective ? { codegenDirective: payload.codegenDirective } : {}
602
+ });
338
603
  }
339
604
  return out;
340
605
  }
@@ -360,17 +625,22 @@ ${footer}`;
360
625
  walk(root, canicodeCategoryIds, out);
361
626
  return out;
362
627
  }
628
+ function safeChildren(node) {
629
+ try {
630
+ const c = node.children;
631
+ return Array.isArray(c) ? c : [];
632
+ } catch {
633
+ return [];
634
+ }
635
+ }
363
636
  function walk(node, canicodeCategoryIds, out) {
364
637
  try {
365
638
  const local = extractAcknowledgmentsFromNode(node, canicodeCategoryIds);
366
639
  for (const a of local) out.push(a);
367
640
  } catch {
368
641
  }
369
- const children = node.children;
370
- if (Array.isArray(children)) {
371
- for (const child of children) {
372
- if (child && typeof child === "object") walk(child, canicodeCategoryIds, out);
373
- }
642
+ for (const child of safeChildren(node)) {
643
+ if (child && typeof child === "object") walk(child, canicodeCategoryIds, out);
374
644
  }
375
645
  }
376
646
 
@@ -13,6 +13,12 @@ This skill works with either channel — the CLI or the canicode MCP server. Bot
13
13
  - A **saved fixture** (from `canicode calibrate-save-fixture`)
14
14
  - A **FIGMA_TOKEN** for live Figma URLs
15
15
 
16
+ ### Step 0: Verify canicode MCP tools are loaded (optional fast path)
17
+
18
+ Before shelling out to `npx canicode analyze …`, check whether the **`analyze` MCP tool** is available in **this** session — not only whether `.mcp.json` lists `canicode`. New MCP registrations usually need a **restart or MCP reload** before tools appear.
19
+
20
+ If you must use the CLI fallback, say so out loud: the user may have added `claude mcp add canicode …` but not restarted yet (#433). After restart/reload, `analyze` via MCP avoids the `npx` spawn. The fallback is valid — silence makes users think the MCP install failed.
21
+
16
22
  ## How to Analyze
17
23
 
18
24
  ### From a Figma URL