canicode 0.10.3 → 0.10.4

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "canicode",
3
- "version": "0.10.3",
3
+ "version": "0.10.4",
4
4
  "mcpName": "io.github.let-sunny/canicode",
5
5
  "description": "Lint Figma designs for AI code-gen and roundtrip the answers back into the file. CLI + MCP server.",
6
6
  "type": "module",
@@ -27,6 +27,7 @@
27
27
  "lint": "tsc --noEmit",
28
28
  "build:plugin": "bash scripts/build-plugin.sh",
29
29
  "sync-docs": "tsx scripts/sync-rule-docs.ts",
30
+ "check:skill-determinism": "tsx scripts/check-skill-determinism.ts",
30
31
  "clean": "rm -rf dist skills"
31
32
  },
32
33
  "files": [
@@ -9,7 +9,7 @@ Run a gotcha survey on a Figma design to identify implementation pitfalls, colle
9
9
 
10
10
  ## Prerequisites
11
11
 
12
- - **canicode MCP server** (preferred): `claude mcp add canicode -e FIGMA_TOKEN=figd_xxx -- npx -y -p canicode canicode-mcp`
12
+ - **canicode MCP server** (preferred): `claude mcp add canicode -- npx --yes --package=canicode canicode-mcp` — long-form flags only; the short-form `-y -p` collides with `claude mcp add`'s parser (#366). The MCP server reads `FIGMA_TOKEN` from `~/.canicode/config.json` or the host environment, so do **not** pass `-e FIGMA_TOKEN=…` here (#364).
13
13
  - **Without canicode MCP** (fallback): the `canicode gotcha-survey --json` CLI produces the same response shape — no MCP installation required.
14
14
  - **FIGMA_TOKEN** configured for live Figma URLs
15
15
 
@@ -43,21 +43,50 @@ If `isReadyForCodeGen` is `true` or `questions` is empty:
43
43
 
44
44
  ### Step 3: Present questions to the user
45
45
 
46
- For each question in the `questions` array, present it to the user one at a time:
46
+ The survey response carries a pre-computed `groupedQuestions.groups[].batches[]` shape so the SKILL never has to sort, partition, or maintain a batchable-rule whitelist in prose. The sort key, `_no-source` sentinel, and batchable-rule list all live in `core/gotcha/group-and-batch-questions.ts` with vitest coverage (per ADR-016). Iterate over it:
47
47
 
48
- ```
49
- **[{severity}] {ruleId}** — node: {nodeName}
48
+ For every `batch` in `groupedQuestions.groups.flatMap((g) => g.batches)`:
50
49
 
51
- {question}
50
+ - **Single-question batch (`batch.questions.length === 1`)** — render the standard prompt for `batch.questions[0]`:
52
51
 
53
- > Hint: {hint}
54
- > Example: {example}
55
- ```
52
+ ```
53
+ **[{severity}] {ruleId}** — node: {nodeName}
54
+
55
+ {question}
56
+
57
+ > Hint: {hint}
58
+ > Example: {example}
59
+ ```
60
+
61
+ - **Batch of N ≥ 2 with `batch.batchable === true`** (#369) — render one shared prompt covering every member:
62
+
63
+ ```
64
+ **[{severity}] {ruleId}** — {batch.questions.length} instances:
65
+ - {nodeName₁}
66
+ - {nodeName₂}
67
+ - …
68
+
69
+ {sharedQuestionPrompt}
70
+
71
+ Reply with one answer to apply to all {batch.questions.length}, or **split** to answer each individually.
72
+
73
+ > Hint: {hint}
74
+ > Example: {example}
75
+ ```
76
+
77
+ Where `sharedQuestionPrompt` reuses the rule's `question` text with the per-node noun replaced by the rule's plural noun (e.g. "These layers all use FILL sizing without min/max constraints. What size boundaries should they share?" instead of repeating the singular phrasing N times).
56
78
 
57
- Wait for the user's answer before moving to the next question. The user may:
58
- - Answer the question directly
59
- - Say "skip" to skip a question
60
- - Say "n/a" if the question is not applicable
79
+ - **Any batch with `batch.batchable === false`** is always rendered as a single-question prompt — the helper guarantees `questions.length === 1` for those (identity-typed answers like `non-semantic-name`, structural-mod rules).
80
+
81
+ Wait for the user's answer before moving to the next batch. The user may:
82
+ - Answer the question / batch directly
83
+ - Say **split** (batch only) to fall back to per-question prompting for that batch
84
+ - Say **skip** to skip the question / the entire batch
85
+ - Say **n/a** if the question / the entire batch is not applicable
86
+
87
+ When applying the batched answer, expand back to per-question records in Step 4 — the gotcha section format stores one record per `nodeId`.
88
+
89
+ > The `groupedQuestions.groups[].instanceContext` field exists for the `canicode-roundtrip` SKILL's "Instance note" hoist (#370). This skill ignores it — every record gets its own `Instance context` bullet in Step 4 anyway.
61
90
 
62
91
  ### Step 4: Upsert the gotcha section
63
92
 
@@ -69,26 +98,35 @@ After collecting all answers, **upsert** this design's section into the `# Colle
69
98
 
70
99
  This file goes in the **user's project** (current working directory), NOT in the canicode repo. The Workflow region above **must never be modified** — only the `# Collected Gotchas` region below is touched.
71
100
 
72
- #### Step 4a: Compute `designKey`
101
+ #### Step 4a: Use the `designKey` from the survey response
102
+
103
+ `designKey` uniquely identifies the design so re-running on the same URL replaces the existing section in place. The survey response carries it on `survey.designKey` — read it directly. Do **not** parse the input URL in prose.
104
+
105
+ The `core/contracts/design-key.ts` helper (`computeDesignKey`) handles every shape with vitest coverage so the SKILL stays ADR-016-compliant:
73
106
 
74
- `designKey` uniquely identifies the design so re-running on the same URL replaces the existing section in place. Parse it from the survey input:
107
+ - **Figma URL** `<fileKey>#<nodeId>` with `-` → `:` normalization on the nodeId. Example: `https://figma.com/design/abc123XYZ/My-File?node-id=42-100&t=ref` `designKey = "abc123XYZ#42:100"`. Trailing query parameters (`?t=...`, `?mode=...`) are dropped.
108
+ - **Figma URL without `node-id`** → just `<fileKey>` (file-level key).
109
+ - **Fixture path / JSON file** → absolute path.
75
110
 
76
- - **Figma URL** — extract `fileKey` and `nodeId` from the URL and join them as `<fileKey>#<nodeId>`. Example: `https://figma.com/design/abc123XYZ/My-File?node-id=42-100` `designKey = "abc123XYZ#42:100"` (convert `-` to `:` in nodeId, the same normalization the Figma MCP uses). Drop any other query-string parameters — only `node-id` matters for the key.
77
- - **Fixture path** — use the absolute path, e.g. `/Users/me/project/fixtures/simple.json`.
111
+ #### Step 4b: Upsert via the canicode CLI
78
112
 
79
- Do **not** use the raw survey input URL as the key: trailing query parameters (`?t=...`, `?mode=...`) break string matching on re-runs.
113
+ File-state detection (4-way: missing / valid / missing-heading / clobbered) and section walking (find existing `## #NNN — ...` by `Design key` substring, otherwise compute the next monotonic zero-padded NNN) are deterministic markdown operations and live in `core/gotcha/upsert-gotcha-section.ts` with vitest coverage — the SKILL never re-implements them in prose (per ADR-016).
114
+
115
+ Render the per-design section markdown using the **Output Template** below with the literal string `{{SECTION_NUMBER}}` in the header (the CLI substitutes the right NNN for you — preserves it on replace, computes the next monotonic value on append). Then invoke:
116
+
117
+ ```bash
118
+ npx canicode upsert-gotcha-section \
119
+ --file .claude/skills/canicode-gotchas/SKILL.md \
120
+ --design-key "<designKey from Step 4a>" \
121
+ --section - # then pipe the rendered section markdown through stdin
122
+ ```
80
123
 
81
- #### Step 4b: Read the existing file and locate the target section
124
+ The CLI prints a JSON result `{ state, action, sectionNumber, wrote, userMessage }`:
82
125
 
83
- 1. Read `.claude/skills/canicode-gotchas/SKILL.md` if it exists.
84
- 2. Detect the file's state using the two structural markers that uniquely identify each case the YAML frontmatter (present on every `canicode init` install) and the `# Collected Gotchas` heading (present on every post-#340 install):
85
- - **File missing** → tell the user to run `canicode init` first, then re-invoke this skill. Stop here.
86
- - **File has YAML frontmatter AND a `# Collected Gotchas` heading** (the default shipped shape since #340) proceed to step 3 below.
87
- - **File has YAML frontmatter but no `# Collected Gotchas` heading** (an older workflow install, or a user-edited workflow that dropped the trailing heading) → preserve everything above unchanged and append a new `# Collected Gotchas` heading at the bottom, then proceed to step 3.
88
- - **File missing the YAML frontmatter** (a pre-#340 single-design clobber — the old overwrite rewrote the frontmatter's `description` to the per-design variant, so a well-formed canicode frontmatter is the cleanest discriminator) → **do not attempt to reconstruct the workflow inline**. Tell the user: "Your gotchas SKILL.md looks like the pre-#340 single-design format. Run `canicode init --force` to restore the workflow, then re-run this survey — your answers will land in a clean numbered section." Stop here.
89
- 3. Walk the existing `## #NNN — ...` sections under `# Collected Gotchas` and look for one whose `- **Design key**:` bullet matches the `designKey` from Step 4a. Substring match against the bullet value is sufficient.
90
- - **Found** → replace that section in place. **Preserve its `#NNN` number** so external references (downstream skills, user notes) remain stable.
91
- - **Not found** → append a new section at the bottom of the region. `#NNN = (highest existing number) + 1`, zero-padded to three digits. Never reuse a number that appeared earlier and was deleted; numbering is monotonic.
126
+ - `wrote: true` → success. `action` is `"replace"` (preserved `sectionNumber`) or `"append"` (next monotonic `sectionNumber`).
127
+ - `wrote: false` with `state: "missing"` tell the user: *"Your gotchas SKILL.md is not installed yet. Run `canicode init` first, then re-invoke this skill."* Stop here.
128
+ - `wrote: false` with `state: "clobbered"` → tell the user: *"Your gotchas SKILL.md is missing the canicode YAML frontmatter (pre-#340 single-design clobber). Run `canicode init --force` to restore the workflow, then re-run this survey — your answers will land in a clean numbered section."* Stop here.
129
+ - `wrote: true` with `state: "missing-heading"` silent recovery. The CLI injected the `# Collected Gotchas` heading and appended the section; the workflow region above is untouched.
92
130
 
93
131
  The Workflow region above must never be touched. Do NOT copy Workflow prose into the per-design section; the section only carries metadata + gotcha answers.
94
132
 
@@ -125,7 +163,7 @@ Each per-design section in the `# Collected Gotchas` region has this exact shape
125
163
  | `designName` | Figma file name or fixture name from the input |
126
164
  | `YYYY-MM-DD` | Today's date (the day you are running the survey) |
127
165
  | `figmaUrl` | The input URL or fixture path provided by the user |
128
- | `designKey` | `<fileKey>#<nodeId>` for Figma URLs, absolute path for fixtures (see Step 4a) |
166
+ | `designKey` | `survey.designKey` from the gotcha-survey response (see Step 4a) |
129
167
  | `designGrade` | `designGrade` from gotcha-survey response |
130
168
  | `analyzedAt` | Current timestamp (ISO 8601) |
131
169
  | `ruleId` | `ruleId` from each question |
@@ -11,7 +11,7 @@ Orchestrate the full design-to-code roundtrip: analyze a Figma design for readin
11
11
  ## Prerequisites
12
12
 
13
13
  - **Figma MCP server** installed (provides `get_design_context`, `get_screenshot`, `use_figma`, and other Figma tools) — REQUIRED, there is no CLI fallback for `use_figma`
14
- - **canicode MCP server** (preferred): `claude mcp add canicode -e FIGMA_TOKEN=figd_xxx -- npx -y -p canicode canicode-mcp`
14
+ - **canicode MCP server** (preferred): `claude mcp add canicode -- npx --yes --package=canicode canicode-mcp` — long-form flags only; the short-form `-y -p` collides with `claude mcp add`'s parser (#366). The MCP server reads `FIGMA_TOKEN` from `~/.canicode/config.json` or the host environment, so do **not** pass `-e FIGMA_TOKEN=…` here (#364).
15
15
  - **Without canicode MCP** (fallback): Steps 1 (analyze) and 3 (gotcha-survey) shell out to `npx canicode <command> --json` — same JSON shape as the MCP tools. Step 4 (apply to Figma) still requires Figma MCP `use_figma`.
16
16
  - **FIGMA_TOKEN** configured for live Figma URLs
17
17
  - **Figma Full seat + file edit permission** (required for `use_figma` to modify the design)
@@ -82,45 +82,91 @@ npx canicode gotcha-survey "<figma-url>" --json
82
82
 
83
83
  If `questions` is empty, skip to **Step 6**.
84
84
 
85
- For each question in the `questions` array, present it to the user one at a time.
85
+ #### Step 3a: Why the response carries a pre-grouped+batched view
86
86
 
87
- Build the message from the question fields. **If `question.instanceContext` is present**, prepend one line before the question body:
87
+ The naive "one-question-at-a-time" loop produces two well-known UX failures on real designs:
88
88
 
89
- ```
90
- _Instance note: This layer is inside an instance. Layout and size fixes may need to be applied on source component **{sourceComponentName or sourceComponentId or "unknown"}** (definition node `sourceNodeId`) and propagate to all instances you will be asked to confirm before any definition-level write._
91
- ```
89
+ - **Repeated Instance note (#370)** — when 10 consecutive questions share the same `instanceContext.sourceComponentId`, the standard "_Instance note: …source component **X**…_" paragraph prints 10 times. After the first occurrence it adds zero new information and consumes ~2 screens of vertical space.
90
+ - **Repeated identical answer (#369)** when 7 consecutive questions all carry the same `ruleId` (e.g. `missing-size-constraint`) and the user's reasonable answer would be the same for all of them (e.g. `min-width: 320px, max-width: 1200px`), the user types the same thing 7 times in a row.
92
91
 
93
- **If `question.replicas` is present (#356 dedup)**, prepend a second line noting the answer applies to N instances:
92
+ `gotcha-survey` already ships the resolution on its `groupedQuestions` field. Sort key (`(sourceComponentId ?? "_no-source", ruleId, nodeName)`), source-component grouping, and the batchable-rule whitelist (`missing-size-constraint`, `irregular-spacing`, `no-auto-layout`, `fixed-size-in-auto-layout`) all live in `core/gotcha/group-and-batch-questions.ts` with vitest coverage. Per ADR-016, do **not** re-implement the sort, partition, or whitelist in prose — iterate over `groupedQuestions.groups[].batches[]` directly.
94
93
 
95
- ```
96
- _Replicas: This question represents **{replicas} instances** of the same source-component child sharing the same rule. Your single answer will be applied to all of them in Step 4 (one annotation/write per instance scene)._
97
- ```
94
+ #### Step 3b: Prompt each group, then each batch within it
98
95
 
99
- Then the standard block:
96
+ For each `group` in `response.groupedQuestions.groups`:
100
97
 
101
- ```
102
- **[{severity}] {ruleId}**node: {nodeName}
98
+ - **`group.instanceContext === null`** — this is the trailing group of non-instance questions. Skip the header and prompt each batch directly.
99
+ - **`group.instanceContext !== null`** emit the Instance note **once** as a group header (#370):
103
100
 
104
- {question}
101
+ ```
102
+ ─────────────────────────────────────────
103
+ The next {sum of batch.questions.length} questions all target instance children of source component **{instanceContext.sourceComponentName ?? instanceContext.sourceComponentId ?? "unknown"}** (definition node `{instanceContext.sourceNodeId}`). Layout and size fixes may need to apply on the source and propagate to all instances — you will be asked to confirm before any definition-level write.
104
+ ─────────────────────────────────────────
105
+ ```
105
106
 
106
- > Hint: {hint}
107
- > Example: {example}
108
- ```
107
+ For each `batch` inside the group:
108
+
109
+ - **`batch.questions.length === 1`** — render the standard single-question block for `batch.questions[0]`:
110
+
111
+ ```
112
+ **[{severity}] {ruleId}** — node: {nodeName}
113
+
114
+ {question}
115
+
116
+ > Hint: {hint}
117
+ > Example: {example}
118
+ ```
119
+
120
+ **If `question.replicas` is present (#356 dedup)**, prepend one note above the standard block:
121
+
122
+ ```
123
+ _Replicas: This question represents **{replicas} instances** of the same source-component child sharing the same rule. Your single answer will be applied to all of them in Step 4 (one annotation/write per instance scene)._
124
+ ```
125
+
126
+ - **`batch.questions.length >= 2 && batch.batchable === true`** (#369) — render one batch prompt covering all members. Use `batch.totalScenes` (already summed across each member's `replicas`) for the Figma-scene fan-out hint:
127
+
128
+ ```
129
+ **[{severity}] {batch.ruleId}** — {batch.questions.length} instances:
130
+ - {nodeName₁}{ruleSpecificContext₁}
131
+ - {nodeName₂}{ruleSpecificContext₂}
132
+ - …
133
+
134
+ {sharedQuestionPrompt}
135
+
136
+ Reply with one answer to apply to all {batch.questions.length}, or **split** to answer each individually.
137
+
138
+ > Hint: {hint}
139
+ > Example: {example}
140
+ ```
141
+
142
+ Where:
143
+ - `sharedQuestionPrompt` is the rule's `question` text with the per-node noun replaced by the rule's plural noun (e.g. "These layers all use FILL sizing without min/max constraints. What size boundaries should they share?" instead of repeating "What size boundaries should this layer have?" N times).
144
+ - `ruleSpecificContext` is short and rule-specific: e.g. for `missing-size-constraint` show the current `width`/`height` if the question has them; for `irregular-spacing` show the current `itemSpacing`; otherwise omit.
145
+ - On `split`, fall back to the per-question loop for that batch only — keep the rest of the group's batches as-is.
146
+
147
+ When `batch.totalScenes > batch.questions.length` (at least one member carries replicas), append one note so the user knows their single answer fans out further than the listed nodes:
148
+
149
+ ```
150
+ _Replicas: your one answer will land on **{batch.totalScenes}** Figma scenes total in Step 4 (some of these {batch.questions.length} questions already represent multiple instances of the same source-component child)._
151
+ ```
152
+
153
+ - **`batch.batchable === false`** is always rendered as a single-question prompt — the helper guarantees `questions.length === 1` for those (identity-typed answers like `non-semantic-name`, structural-mod rules).
154
+
155
+ Wait for the user's answer before moving to the next batch. For each batch, the user may:
156
+ - Answer the question directly (single value covers all batch members)
157
+ - Say **split** (batch only) to fall back to per-question prompting for that batch
158
+ - Say **skip** to skip the question / the entire batch
159
+ - Say **n/a** if the question / the entire batch is not applicable
109
160
 
110
- Wait for the user's answer before moving to the next question. The user may:
111
- - Answer the question directly
112
- - Say "skip" to skip a question
113
- - Say "n/a" if the question is not applicable
161
+ When applying the batched answer, expand back to per-question records before storing — the gotcha section format and Step 4 apply loop both expect one record per `nodeId`.
114
162
 
115
- After all questions are answered, **upsert this design's gotcha section** into `.claude/skills/canicode-gotchas/SKILL.md` in the user's project. Read the existing file, then either replace the section whose `Design key` matches this run (same Figma URL fileKey+nodeId) or append a new numbered section under `# Collected Gotchas`. Never modify anything above the `# Collected Gotchas` heading — the region above it (frontmatter + workflow prose) is the skill loader contract installed by `canicode init`. See the `/canicode-gotchas` skill's "Upsert the gotcha section" step (Step 4) for the exact section format and matching rule.
163
+ After all questions are answered, **upsert this design's gotcha section** into `.claude/skills/canicode-gotchas/SKILL.md` in the user's project. Read the existing file, then either replace the section whose `Design key` matches `survey.designKey` (the canonical identifier the gotcha-survey response carries — see `/canicode-gotchas` Step 4a) or append a new numbered section under `# Collected Gotchas`. Never modify anything above the `# Collected Gotchas` heading — the region above it (frontmatter + workflow prose) is the skill loader contract installed by `canicode init`. See the `/canicode-gotchas` skill's "Upsert the gotcha section" step (Step 4) for the exact section format and matching rule.
116
164
 
117
165
  Then proceed to **Step 4** to apply answers to the Figma design.
118
166
 
119
167
  ### Step 4: Apply gotcha answers to Figma design
120
168
 
121
- Extract the `fileKey` from the Figma URL (format: `figma.com/design/:fileKey/...`).
122
-
123
- For each answered gotcha (skip questions answered with "skip" or "n/a"), branch on the pre-computed `question.applyStrategy`. The routing table, target properties, and instance-child resolution are resolved server-side by `canicode` — do NOT re-derive them from the rule id.
169
+ For each answered gotcha (skip questions answered with "skip" or "n/a"), branch on the pre-computed `question.applyStrategy`. The routing table, target properties, and instance-child resolution are resolved server-side by `canicode` — do NOT re-derive them from the rule id. The `fileKey` is not needed at this step — the bundled helpers operate on `nodeId` directly.
124
170
 
125
171
  Use the **`nodeId` from the answered question**. When `question.isInstanceChild` is `true`, treat layout and size-constraint changes as **high impact**: applying them on the source definition affects **every instance** of that component in the file. Ask for explicit user confirmation before writing to the definition node.
126
172
 
@@ -244,6 +290,12 @@ The probe is read-only and idempotent; running it before the picker adds one rou
244
290
  - `applyPropertyMod(question, answerValue, { categories, allowDefinitionWrite, telemetry })` — Strategy A entry point. Branches on `targetProperty` (single vs array) and answer shape (scalar, per-property object, `{ variable: "name" }` binding). Uses `setBoundVariableForPaint` for `fills` / `strokes` and `setBoundVariable` for scalar fields. Passes the full context through to `applyWithInstanceFallback`.
245
291
  - `resolveVariableByName(name)` — local-variable exact-name lookup; returns `null` for remote library variables not imported into this file.
246
292
  - `probeDefinitionWritability(questions)` — async pre-flight (#357). Returns `{ totalCount, unwritableCount, unwritableSourceNames, allUnwritable, partiallyUnwritable }`. Use BEFORE the Definition write picker so the picker can drop the opt-in branch when every candidate is in an external library / unresolved (saves the user a wasted "I opted in, why did I get annotations?" decision). Read-only probe, dedupes by `sourceChildId`.
293
+ - `extractAcknowledgmentsFromNode(node, canicodeCategoryIds?)` — synchronous pure helper (#371). Reads one node's annotations and returns `{ nodeId, ruleId }[]` for entries gated by canicode `categoryId` plus a recognisable `— *<ruleId>*` footer (or legacy `**[canicode] <ruleId>**` prefix). When `canicodeCategoryIds` is omitted, footer-text matching alone is sufficient (test mode).
294
+ - `readCanicodeAcknowledgments(rootNodeId, categories?)` — async tree walker (#371). Loads `rootNodeId` via `figma.getNodeByIdAsync`, recurses through `children`, and accumulates one acknowledgment per recognised entry. Used at the top of Step 5a to harvest the side channel that lets the analysis pipeline distinguish "still broken" from "the designer has a plan" — pass the result straight to `analyze({ acknowledgments })`. Errors on individual nodes are swallowed so locked / external nodes don't abort the sweep.
295
+ - `computeRoundtripTally({ stepFourReport, reanalyzeResponse })` — pure helper (#383). Takes the structured Step 4 outcome counts (`{ resolved, annotated, definitionWritten, skipped }`) plus a narrowed re-analyze view (`{ issueCount, acknowledgedCount }`) and returns `{ X, Y, Z, W, N, V, V_ack, V_open }`. Replaces the LLM-side emoji-bullet re-counting in Step 5 — render the returned object directly into the wrap-up templates. Throws when `acknowledgedCount > issueCount` (impossible state).
296
+ - `applyAutoFix(issue, { categories, allowDefinitionWrite?, telemetry? })` — Strategy D entry point (#386). Branches on `targetProperty === "name" && suggestedName` once: renames the node via `applyWithInstanceFallback` (so naming auto-fixes share the same tier-1/2/3 policy as Strategy A) or writes a `categories.flag` annotation carrying `issue.message` and `issue.annotationProperties`. Returns one `AutoFixOutcome` (`{ outcome, nodeId, nodeName, ruleId, label }`) where `outcome` is `🔧` / `🌐` / `📝` so Step 4 can bump the structured `stepFourReport` counters without parsing prose. Replaces the inline JS the SKILL used to carry (per ADR-016).
297
+ - `applyAutoFixes(issues, { categories, allowDefinitionWrite?, telemetry? })` — loop wrapper (#386). Filters `issues` to `applyStrategy === "auto-fix"` (skipped entries surface as `⏭️` outcomes for symmetry) and applies each one in sequence. Returns the full `AutoFixOutcome[]`.
298
+ - `removeCanicodeAnnotations(annotations, categories)` — pure filter. Returns `annotations` with every canicode-authored entry removed (gates on `categories.gotcha` / `flag` / `fallback` / `legacyAutoFix` plus the legacy `**[canicode]` body prefix). Use after `stripAnnotations` in the Step 5 cleanup loop — replaces the inline filter predicate the SKILL used to carry. `isCanicodeAnnotation(annotation, categories)` is the single-entry version, exported for callers that need the predicate alone.
247
299
 
248
300
  Keep each `writeFn` small so a throw does not abort unrelated writes. Experiment 08 findings informed every branch in the bundled helpers, and the batch-level confirmation contract still applies *when opting in*: if the orchestrator passes `allowDefinitionWrite: true`, it must have already collected one confirmation covering every potential definition write in the batch. Under the default, no confirmation is needed — the helper annotates the scene instead of propagating.
249
301
 
@@ -259,6 +311,7 @@ await CanICodeRoundtrip.applyPropertyMod(question, answerValue, { categories });
259
311
 
260
312
  **Replicas (#356)** — when `question.replicaNodeIds` is present, the same answer must land on every replica instance. Iterate the merged set so each scene gets its own per-node failure routing (under the ADR-012 default each replica annotates independently; with `allowDefinitionWrite: true` they share the one definition write because they share the source):
261
313
 
314
+ <!-- adr-016-ack: fan-out over an explicit small array of node IDs; the deterministic work lives inside applyPropertyMod -->
262
315
  ```javascript
263
316
  const targets = [question.nodeId, ...(question.replicaNodeIds ?? [])];
264
317
  for (const nodeId of targets) {
@@ -281,6 +334,23 @@ The name must match **the variable's `name` field exactly** — including any sl
281
334
 
282
335
  Rules with `applyStrategy === "structural-mod"`. Show the proposed change and **ask for user confirmation** before applying.
283
336
 
337
+ > **Instance-child guard (#368).** Strategy B mutations restructure the layer tree — `createComponentFromNode`, `flatten`, wrapper removal, instance-link reconnection. None of these compose safely with the Plugin API's instance-override rules: on a node where `question.isInstanceChild === true`, calling `createComponentFromNode` either throws *"Cannot create a component from a node inside an instance"* or detaches the parent instance entirely (the picked instance is replaced by a one-off frame, severing every existing override and propagation link). Restructuring deep-nested wrappers inside an instance child has the same risk surface — even when the call doesn't throw, the resulting structure cannot ride the source-component's propagation in future updates.
338
+ >
339
+ > Before showing the per-rule prompt below, check `question.isInstanceChild`. If it is true, **do not run the destructive call**. Surface this a/b prompt instead and default to **(a)**:
340
+ >
341
+ > ```
342
+ > **{ruleId}** would normally restructure **{nodeName}** here, but this node lives inside instance **{instanceContext.parentInstanceNodeId}** of source component **{instanceContext.sourceComponentName or sourceComponentId or "unknown"}** (definition node `{instanceContext.sourceNodeId}`). On instance children Plugin API restructuring either fails outright or detaches the parent instance.
343
+ >
344
+ > ❯ a) Annotate the scene with a recommendation to apply the change on the source definition (safe — picks up via canicode-gotchas in code-gen, source designer can act on it later)
345
+ > b) Detach the parent instance and attempt the restructuring on the resulting one-off frame (destructive — every existing instance override is lost and the node no longer rides the source component's propagation)
346
+ > ```
347
+ >
348
+ > On **(a)**, route to Strategy C — call `upsertCanicodeAnnotation(scene, { ruleId: question.ruleId, markdown: "**Q:** … **A:** Apply on source definition `${instanceContext.sourceNodeId}` (`${instanceContext.sourceComponentName ?? "unknown"}`) — instance-child restructuring would detach the parent instance.", categoryId: categories.gotcha })`. Reference `instanceContext.sourceComponentName` and `instanceContext.sourceNodeId` in the body so the source designer can locate the target.
349
+ >
350
+ > On **(b)**, gate behind a second confirmation that explicitly names the side effects ("This will detach instance **{parentInstanceNodeId}** — all overrides on it will be lost and it will stop receiving updates from **{sourceComponentName}**. Type the parent instance name to confirm."). Only then execute the per-rule destructive call below.
351
+ >
352
+ > The same posture as ADR-012's `allowDefinitionWrite: false` default: instance-child structural mutations are off-by-default and require explicit user opt-in *per node*, not per batch — the destructive call here doesn't have a quiet fallback the way Strategy A's `applyWithInstanceFallback` does.
353
+
284
354
  **`non-layout-container`** — Convert Group/Section to Auto Layout frame:
285
355
  - Prompt: "I'll convert **{nodeName}** to an Auto Layout frame with {direction} layout and {spacing}px gap. Proceed?"
286
356
  - If confirmed: `applyPropertyMod(question, { layoutMode: "VERTICAL", itemSpacing: 12 })`.
@@ -303,12 +373,13 @@ if (scene && scene.type === "FRAME") {
303
373
  - Prompt: "I'll reconnect **{nodeName}** to its original component. Any overrides will be preserved. Proceed?"
304
374
  - Requires finding the original component — if not identifiable, fall back to annotation.
305
375
 
306
- If the user **declines** any structural modification, add an annotation instead (same as Strategy C).
376
+ If the user **declines** any structural modification (or the instance-child guard above routes to **(a)**), add an annotation instead (same as Strategy C).
307
377
 
308
378
  #### Strategy C: Annotation — record on the design for designer reference
309
379
 
310
380
  Rules with `applyStrategy === "annotation"` cannot be auto-fixed via Plugin API. Add the gotcha answer as a Figma annotation so designers see it in Dev Mode. Use the helper — it handles the D1 mutex, D2 in-place upsert, and D4 category assignment. When `question.replicaNodeIds` is present (#356), iterate the merged set so every replica instance gets the annotation:
311
381
 
382
+ <!-- adr-016-ack: fan-out over an explicit small array of node IDs; the deterministic work lives inside upsertCanicodeAnnotation -->
312
383
  ```javascript
313
384
  const targets = [question.nodeId, ...(question.replicaNodeIds ?? [])];
314
385
  for (const nodeId of targets) {
@@ -331,39 +402,15 @@ Notes:
331
402
 
332
403
  #### Strategy D: Auto-fix lower-severity issues from analysis
333
404
 
334
- The gotcha survey covers only blocking/risk severity. Lower-severity rules appear in `analyzeResult.issues[]` without a survey question. Each issue carries the same pre-computed fields (`applyStrategy`, `targetProperty`, `annotationProperties`, `suggestedName`, `isInstanceChild`, `sourceChildId`). Loop over them:
405
+ The gotcha survey covers only blocking/risk severity. Lower-severity rules appear in `analyzeResult.issues[]` without a survey question. Each issue carries the same pre-computed fields (`applyStrategy`, `targetProperty`, `annotationProperties`, `suggestedName`, `isInstanceChild`, `sourceChildId`). The bundled helper handles the loop, the filter (`applyStrategy === "auto-fix"`), the naming-vs-annotation branch, and the per-issue outcome accumulator in one call:
335
406
 
336
407
  ```javascript
337
- for (const issue of analyzeResult.issues) {
338
- if (issue.applyStrategy !== "auto-fix") continue;
339
-
340
- // Shape an ad-hoc question-like object so the same helpers apply.
341
- const q = {
342
- nodeId: issue.nodeId,
343
- ruleId: issue.ruleId,
344
- ...(issue.sourceChildId ? { sourceChildId: issue.sourceChildId } : {}),
345
- };
346
-
347
- if (issue.targetProperty === "name" && issue.suggestedName) {
348
- // Naming rules — rename to the pre-computed suggestedName.
349
- await CanICodeRoundtrip.applyWithInstanceFallback(q, async (target) => {
350
- if (target) target.name = issue.suggestedName;
351
- }, { categories });
352
- } else {
353
- // raw-value, missing-interaction-state, missing-prototype — designer judgment; annotate.
354
- const scene = await figma.getNodeByIdAsync(issue.nodeId);
355
- CanICodeRoundtrip.upsertCanicodeAnnotation(scene, {
356
- ruleId: issue.ruleId,
357
- markdown: issue.message,
358
- categoryId: categories.flag,
359
- // Optional: surface the live value for the affected property in Dev Mode.
360
- properties: issue.annotationProperties,
361
- });
362
- }
363
- }
408
+ const outcomes = await CanICodeRoundtrip.applyAutoFixes(analyzeResult.issues, { categories });
364
409
  ```
365
410
 
366
- `suggestedName` is already capitalized for direct Plugin-API use (e.g. `"Hover"`, `"Default"`, `"Pressed"`). Do not transform it further.
411
+ `outcomes` is an array of `{ outcome, nodeId, nodeName, ruleId, label }`. `outcome` is one of `🔧` (rename succeeded), `🌐` (definition write propagated — only when `allowDefinitionWrite: true`), `📝` (annotation written, including the fallback path), or `⏭️` (issue's `applyStrategy` was not `"auto-fix"` so it was skipped). Bump the matching `stepFourReport` counter for each entry — `🔧` → `resolved`, `🌐` → `definitionWritten`, `📝` → `annotated`, `⏭️` → `skipped` — so the Step 5 tally (`CanICodeRoundtrip.computeRoundtripTally`, #383) consumes the same structured shape as Strategies A/B/C.
412
+
413
+ `suggestedName` is already capitalized for direct Plugin-API use (e.g. `"Hover"`, `"Default"`, `"Pressed"`). The helper writes it through `applyWithInstanceFallback` so locked / read-only / instance-override nodes annotate cleanly instead of aborting the batch — see the source at `src/core/roundtrip/apply-auto-fix.ts` (#386, ADR-016).
367
414
 
368
415
  #### Execution order
369
416
 
@@ -373,7 +420,7 @@ for (const issue of analyzeResult.issues) {
373
420
  3. **Batch all annotations** (Strategy C + declined structural mods) into a single `use_figma` call — use `categories.gotcha` for the category id.
374
421
  4. **Batch all auto-fixes and annotations for lower-severity issues** (Strategy D) — use `categories.flag` for annotated ones (renamed from `autoFix` per #355 — the category means "flagged for designer attention", not "fixed"), `categories.fallback` is reserved for errors surfaced by `applyWithInstanceFallback` itself.
375
422
 
376
- After applying, report what was done:
423
+ After applying, **emit a structured `stepFourReport`** alongside the human-readable per-question lines. Step 5 reads from this object — it does **not** re-parse the per-question lines (per ADR-016). Increment each counter as Strategy A/B/C/D complete:
377
424
 
378
425
  ```
379
426
  Applied {N} changes to the Figma design:
@@ -385,29 +432,73 @@ Applied {N} changes to the Figma design:
385
432
  - 📝 {nodeName}: annotation added to canicode:gotcha (absolute-position-in-auto-layout)
386
433
  - 🔧 {nodeName}: auto-fixed to "Hover" (non-standard-naming)
387
434
  - 📝 {nodeName}: annotation added to canicode:flag — raw color needs token binding (raw-value)
435
+
436
+ stepFourReport = {
437
+ resolved: <count of ✅ + 🔧 + 🔗 lines>, // scene writes, auto-fix renames, variable bindings
438
+ annotated: <count of 📝 lines>, // including ⏭️ declines that fell back to annotation
439
+ definitionWritten: <count of 🌐 lines>, // only non-zero with allowDefinitionWrite: true
440
+ skipped: <count of ⏭️ lines + Step 3 skip/n/a> // user-declined questions
441
+ }
388
442
  ```
389
443
 
444
+ Hold `stepFourReport` in scope through Step 5 — it is the input to `CanICodeRoundtrip.computeRoundtripTally` below.
445
+
390
446
  ### Step 5: Re-analyze and report what the roundtrip addressed
391
447
 
392
- Run `analyze` again on the same Figma URL:
448
+ #### Step 5a: Harvest canicode-authored annotations as acknowledgments (#371)
393
449
 
450
+ Before re-running `analyze`, collect every `(nodeId, ruleId)` pair that Step 4 wrote as a Figma annotation. The REST API does not expose annotations, so this side channel is the only way the analysis pipeline learns that a roundtrip-touched issue is "the designer has a plan" rather than "still broken". Without it the grade and issue count look identical to the pre-roundtrip state — `32 → 32` — even when every gotcha has been captured per ADR-012.
451
+
452
+ Run a short `use_figma` batch that walks the same subtree the original `analyze` covered (`targetNodeId` if you used one, else `figma.root.id`), reads canicode-categorised annotations, and serialises the result:
453
+
454
+ ```javascript
455
+ // Inside a use_figma batch:
456
+ const categories = await CanICodeRoundtrip.ensureCanicodeCategories();
457
+ const acknowledgments = await CanICodeRoundtrip.readCanicodeAcknowledgments(
458
+ targetNodeId ?? figma.root.id,
459
+ categories
460
+ );
461
+ return { events: [], acknowledgments };
394
462
  ```
395
- analyze({ input: "<figma-url>" })
463
+
464
+ `readCanicodeAcknowledgments` walks `node.children` recursively, gates on the `canicode:gotcha` / `canicode:flag` / `canicode:fallback` (and legacy `canicode:auto-fix`) category ids, and extracts the ruleId from the annotation footer (`— *<ruleId>*`) or the legacy `**[canicode] <ruleId>**` prefix. The categoryId guard keeps user-authored notes that happen to end in italic kebab-case from being mistaken for canicode acknowledgments.
465
+
466
+ #### Step 5b: Re-analyze with acknowledgments
467
+
468
+ Pass the harvested array straight into `analyze` so the engine flags matching issues as `acknowledged: true` and the density score gives them half weight:
469
+
470
+ ```
471
+ analyze({ input: "<figma-url>", acknowledgments })
472
+ ```
473
+
474
+ **Without canicode MCP** — the CLI accepts the same input via `--acknowledgments <path>` (JSON file containing the array). Write the array to a temp file from the `use_figma` return, then:
475
+
476
+ ```bash
477
+ npx canicode analyze "<figma-url>" --json --acknowledgments /tmp/canicode-acks.json
396
478
  ```
397
479
 
398
- Under ADR-012's annotate-by-default policy, most instance-child gotchas route to 📝 annotations and do **not** move the numeric grade — so the headline for this step is the **issues-delta** (what the roundtrip captured), not a grade comparison. Grade is kept as a footnote so the Row 8 regression guardrail still applies.
480
+ The response now carries:
481
+ - `acknowledgedCount` (top level) — how many issues matched an acknowledgment.
482
+ - `issues[i].acknowledged: true` (per matched issue) — survives into the report and downstream skills.
483
+ - `summary` text — when `acknowledgedCount > 0`, the Total line reads `Total: N (A acknowledged via canicode annotations / N-A unaddressed)`.
399
484
 
400
- **Tally inputs** — derive the counts from the data you already have:
401
- - `X` (✅ resolved): count of ✅ + 🔧 + 🔗 markers from the Step 4 report block you just emitted (scene/instance-child writes, auto-fix renames, and variable bindings all successfully landed the value).
402
- - `Y` (📝 annotated): count of 📝 markers from Step 4 — gotcha answers captured as Figma annotations for code-gen reference.
403
- - `Z` (🌐 definition writes): count of 🌐 markers from Step 4 — only non-zero when the orchestrator opted in with `allowDefinitionWrite: true` (helper context option, not a CLI flag).
404
- - `W` (⏭️ skipped): count of ⏭️ markers from Step 4 plus any Step 3 questions the user answered with `skip` or `n/a`.
405
- - `V` (remaining): `issues.length` from the re-analyze response — unresolved gotchas plus non-actionable rules still flagged by the design.
406
- - `N` (addressed) = `X + Y + Z + W`.
485
+ Under ADR-012's annotate-by-default policy, most instance-child gotchas route to 📝 annotations and do **not** move the numeric grade but the half-weight density now produces a small visible movement when annotations are recognised. The headline for this step remains the **issues-delta** (what the roundtrip captured); grade movement is a secondary signal.
407
486
 
408
- If Step 4 produced no report block (e.g. user skipped every question, or no gotcha survey ran), all four counts are zero that is a legitimate outcome; report the breakdown with zeros rather than treating it as an error.
487
+ **Tally** — call `CanICodeRoundtrip.computeRoundtripTally` with the structured `stepFourReport` you assembled in Step 4 and the re-analyze response from Step 5b. The helper handles every count derivation (`N = X + Y + Z + W`, `V_open = V - V_ack`) and validates that `acknowledgedCount` cannot exceed `issueCount`. Render the returned `{ X, Y, Z, W, N, V, V_ack, V_open }` straight into the templates below — do **not** re-derive any of these from the Step 4 prose:
409
488
 
410
- **All gotcha issues resolved** (`V == 0`, i.e. re-analyze surfaces no remaining issues — note this is independent of grade since ADR-012 annotations do not move the score):
489
+ ```javascript
490
+ const tally = CanICodeRoundtrip.computeRoundtripTally({
491
+ stepFourReport, // the object emitted at the end of Step 4
492
+ reanalyzeResponse: { // narrowed view of the re-analyze response
493
+ issueCount: response.issueCount,
494
+ acknowledgedCount: response.acknowledgedCount,
495
+ },
496
+ });
497
+ ```
498
+
499
+ If Step 4 produced no `stepFourReport` (e.g. user skipped every question, or no gotcha survey ran), pass an all-zero object — `tally.N === 0`, `tally.V_open === tally.V`, and the templates below render the breakdown with zeros rather than treating it as an error. (Skipping Step 5a and passing no `acknowledgments` argument is also valid in this case — the response simply has `acknowledgedCount: 0`.)
500
+
501
+ **All gotcha issues resolved** (`V == 0`, i.e. re-analyze surfaces no remaining issues — note this is mostly independent of grade since ADR-012 annotations only move the score by the half-weight reduction enabled in Step 5b):
411
502
  - Tell the user (fill in the counts from the tally above):
412
503
 
413
504
  ```
@@ -421,18 +512,16 @@ If Step 4 produced no report block (e.g. user skipped every question, or no gotc
421
512
 
422
513
  Grade: {oldGrade} → {newGrade}. Ready for code generation.
423
514
  ```
424
- - Clean up canicode annotations on fixed nodes via `use_figma`. Filter by **categoryId** (the durable canicode-side identifier — the body no longer carries a `[canicode]` prefix per #353). Include `legacyAutoFix` if `ensureCanicodeCategories` returned it, so pre-#355 `canicode:auto-fix` entries get swept too. The trailing `— *<ruleId>*` footer is kept as a secondary marker for legacy `[canicode]`-prefix entries that may exist on files that have not been re-roundtripped yet:
515
+ - Clean up canicode annotations on fixed nodes via `use_figma`. Use the bundled `removeCanicodeAnnotations` helper — it gates on **categoryId** (the durable canicode-side identifier — the body no longer carries a `[canicode]` prefix per #353), includes `legacyAutoFix` if `ensureCanicodeCategories` returned it (pre-#355 `canicode:auto-fix` sweep), and also matches the legacy `**[canicode]` body prefix as a secondary marker for entries on files that have not been re-roundtripped yet. The match logic lives in `src/core/roundtrip/remove-canicode-annotations.ts` with vitest coverage so prose stays ADR-016-compliant:
516
+ <!-- adr-016-ack: fan-out over an explicit small array of node IDs; the deterministic work lives inside removeCanicodeAnnotations -->
425
517
  ```javascript
426
- const canicodeIds = new Set(
427
- [categories.gotcha, categories.flag, categories.fallback, categories.legacyAutoFix].filter(Boolean)
428
- );
429
518
  const nodeIds = ["id1", "id2"]; // nodes that now pass
430
519
  for (const id of nodeIds) {
431
520
  const node = await figma.getNodeByIdAsync(id);
432
521
  if (node && "annotations" in node) {
433
- node.annotations = CanICodeRoundtrip.stripAnnotations(node.annotations).filter(
434
- a => !(a.categoryId && canicodeIds.has(a.categoryId)) &&
435
- !a.labelMarkdown?.startsWith("**[canicode]")
522
+ node.annotations = CanICodeRoundtrip.removeCanicodeAnnotations(
523
+ CanICodeRoundtrip.stripAnnotations(node.annotations),
524
+ categories,
436
525
  );
437
526
  }
438
527
  }
@@ -440,7 +529,7 @@ for (const id of nodeIds) {
440
529
  - Proceed to **Step 6**.
441
530
 
442
531
  **Some issues remain** (`V > 0`):
443
- - Show the same breakdown and ask whether to proceed:
532
+ - Show the same breakdown and ask whether to proceed. When `V_ack > 0`, expand the remaining line into the acknowledged/unaddressed split surfaced by the re-analyze (#371) so the user can see how much of `V` is "captured for code-gen" vs "still on the user's plate":
444
533
 
445
534
  ```
446
535
  Roundtrip complete — N issues addressed:
@@ -449,10 +538,14 @@ for (const id of nodeIds) {
449
538
  🌐 Z definition writes propagated (only when allowDefinitionWrite: true)
450
539
  ⏭️ W skipped (user declined or "skip")
451
540
 
452
- V issues remaining (unresolved gotchas + non-actionable rules)
541
+ V issues remaining
542
+ ↳ V_ack acknowledged via canicode annotations (carried into code-gen)
543
+ ↳ V_open unaddressed (no annotation — your follow-up backlog)
453
544
 
454
545
  Grade: {oldGrade} → {newGrade}. Proceed to code generation with remaining context?
455
546
  ```
547
+
548
+ When `V_ack == 0` (re-analyze returned `acknowledgedCount: 0`), keep the single `V issues remaining (unresolved gotchas + non-actionable rules)` line.
456
549
  - If yes → proceed to **Step 6** with remaining gotcha context.
457
550
  - If no → stop and emit the **Stop wrap-up** below; do **not** restate the grade as the lead.
458
551
 
@@ -466,11 +559,15 @@ Stopped — N issues addressed, V remaining for manual follow-up:
466
559
  📝 Y annotated on Figma (carried into code-gen via canicode-gotchas)
467
560
  🌐 Z definition writes propagated
468
561
  ⏭️ W skipped
562
+
563
+ V remaining
564
+ ↳ V_ack acknowledged via canicode annotations
565
+ ↳ V_open unaddressed
469
566
 
470
567
  Grade: {oldGrade} → {newGrade}.
471
568
  ```
472
569
 
473
- Anti-pattern (do **not** lead with a grade-only sentence like "Grade: C → C+. Most size-constraint gotchas are now annotations…"). Lead with the delta block; mention grade once, on its own footnote line, plain prose only.
570
+ When `V_ack == 0`, drop the `↳` lines and leave a single `V remaining` row. Anti-pattern (do **not** lead with a grade-only sentence like "Grade: C → C+. Most size-constraint gotchas are now annotations…"). Lead with the delta block; mention grade once, on its own footnote line, plain prose only.
474
571
 
475
572
  ### Step 6: Implement with Figma MCP
476
573
 
@@ -481,7 +578,7 @@ Follow the **figma-implement-design** skill workflow to generate code from the F
481
578
  - Gotchas with severity **blocking** MUST be addressed — the design cannot be implemented correctly without this information
482
579
  - Gotchas with severity **risk** SHOULD be addressed — they indicate potential issues that will surface later
483
580
  - Reference the specific node IDs from gotcha answers to locate the affected elements in the design
484
- - Pass the Figma URL (or `designKey` = `<fileKey>#<nodeId>`) to `figma-implement-design` so it can grep the matching `## #NNN — …` section in `.claude/skills/canicode-gotchas/SKILL.md` instead of reading the whole accumulated file
581
+ - Pass the Figma URL or `survey.designKey` to `figma-implement-design` so it can grep the matching `## #NNN — …` section in `.claude/skills/canicode-gotchas/SKILL.md` instead of reading the whole accumulated file
485
582
 
486
583
  **If all issues were resolved in Steps 4-5**, no additional gotcha context is needed — the design speaks for itself.
487
584
 
@@ -497,11 +594,15 @@ Roundtrip complete — N issues addressed, code generated:
497
594
  ⏭️ W skipped
498
595
 
499
596
  V issues remaining
597
+ ↳ V_ack acknowledged via canicode annotations
598
+ ↳ V_open unaddressed
500
599
 
501
600
  Grade: {oldGrade} → {newGrade}.
502
601
  Code: <files generated / next-step pointer from figma-implement-design>
503
602
  ```
504
603
 
604
+ (Drop the `↳` lines when `V_ack == 0`.)
605
+
505
606
  ## Edge Cases
506
607
 
507
608
  - **No canicode MCP server**: Fall back to `npx canicode analyze --json` and `npx canicode gotcha-survey --json` — both CLI commands return the same shape as the MCP tools. The Figma MCP is still required for `use_figma` in Step 4; there is no CLI fallback for Figma design edits.