canicode 0.10.2 → 0.10.3
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/README.md +1 -1
- package/dist/cli/index.js +57 -2
- package/dist/cli/index.js.map +1 -1
- package/dist/index.d.ts +5 -1
- package/dist/index.js +20 -2
- package/dist/index.js.map +1 -1
- package/dist/mcp/server.js +57 -2
- package/dist/mcp/server.js.map +1 -1
- package/package.json +2 -2
- package/skills/canicode-roundtrip/SKILL.md +122 -19
- package/skills/canicode-roundtrip/helpers.js +54 -10
package/package.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "canicode",
|
|
3
|
-
"version": "0.10.
|
|
3
|
+
"version": "0.10.3",
|
|
4
4
|
"mcpName": "io.github.let-sunny/canicode",
|
|
5
|
-
"description": "
|
|
5
|
+
"description": "Lint Figma designs for AI code-gen and roundtrip the answers back into the file. CLI + MCP server.",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"main": "./dist/index.js",
|
|
8
8
|
"types": "./dist/index.d.ts",
|
|
@@ -90,6 +90,12 @@ Build the message from the question fields. **If `question.instanceContext` is p
|
|
|
90
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
91
|
```
|
|
92
92
|
|
|
93
|
+
**If `question.replicas` is present (#356 dedup)**, prepend a second line noting the answer applies to N instances:
|
|
94
|
+
|
|
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
|
+
```
|
|
98
|
+
|
|
93
99
|
Then the standard block:
|
|
94
100
|
|
|
95
101
|
```
|
|
@@ -131,6 +137,8 @@ Every gotcha-survey question (and every entry in `analyzeResult.issues[]`) carri
|
|
|
131
137
|
| `isInstanceChild` | `boolean` | Whether the `nodeId` targets a node inside an INSTANCE subtree. |
|
|
132
138
|
| `sourceChildId` | `string` \| (absent) | Definition node id inside the source component. Use directly with `figma.getNodeByIdAsync`. |
|
|
133
139
|
| `instanceContext` | object \| (absent) | Survey questions only. `{ parentInstanceNodeId, sourceNodeId, sourceComponentId?, sourceComponentName? }` for the Step 3 user-facing note. |
|
|
140
|
+
| `replicas` | `number` \| (absent) | Survey questions only (#356). Total instance count when this one question represents N instance-child issues sharing the same `(sourceComponentId, sourceNodeId, ruleId)` tuple. Absent for single-instance questions. |
|
|
141
|
+
| `replicaNodeIds` | `string[]` \| (absent) | Survey questions only (#356). All OTHER instance scene node ids the answer should land on. The apply step iterates `[nodeId, ...replicaNodeIds]`. Absent when `replicas` is absent. |
|
|
134
142
|
|
|
135
143
|
#### Instance-child property overridability (Plugin API)
|
|
136
144
|
|
|
@@ -177,6 +185,51 @@ The helper walks the tiers in order; variable binding is an alternative writeFn
|
|
|
177
185
|
|
|
178
186
|
**Confirmation is a batch-level concern — and only needed when opting in.** A `use_figma` call runs one JavaScript batch and cannot pause mid-batch for user input. Under the ADR-012 default (`allowDefinitionWrite: false`), no propagation happens, so no confirmation is required — override-errors annotate and move on. The orchestrator sets `allowDefinitionWrite: true` only after enumerating the likely propagation set to the user up-front and collecting **one confirmation for the whole batch** that names the source component(s) and the affected instance set. When describing impact, note that the write reaches every **non-overridden** instance — any instance with a local override for the same property keeps its override. The helper below never prompts — it assumes that if the flag is on, confirmation already happened.
|
|
179
187
|
|
|
188
|
+
**Pre-flight writability probe (#357).** Before showing the user the Definition write picker, call `CanICodeRoundtrip.probeDefinitionWritability(questions)` inside a small `use_figma` batch. The probe loads every distinct `sourceChildId` once and classifies it as writable or unwritable using the same detection as the runtime fallback (Experiment 10 `remote === true` and Experiment 11 unresolved-`null`). The result decides which version of the picker to show:
|
|
189
|
+
|
|
190
|
+
```javascript
|
|
191
|
+
// Inside a use_figma batch:
|
|
192
|
+
const probe = await CanICodeRoundtrip.probeDefinitionWritability(questions);
|
|
193
|
+
return { events: [], probe };
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
Branches on `probe`:
|
|
197
|
+
|
|
198
|
+
- **`allUnwritable === true`** — every candidate source is in an external library (or unresolved). Opting in is structurally a no-op; every write would throw "Cannot write to internal and read-only node" and fall through to scene annotation anyway. Show the user a single-option picker:
|
|
199
|
+
|
|
200
|
+
```
|
|
201
|
+
Definition write policy
|
|
202
|
+
|
|
203
|
+
This file's source components live in an external library and are
|
|
204
|
+
read-only from here ({unwritableSourceNames.join(", ")}). Tier 2
|
|
205
|
+
propagation cannot fire — every "opt-in" write would fall through
|
|
206
|
+
to a scene annotation regardless.
|
|
207
|
+
|
|
208
|
+
❯ 1. Annotate only (only viable option for this file)
|
|
209
|
+
2. Cancel — duplicate the library locally first to enable propagation
|
|
210
|
+
```
|
|
211
|
+
|
|
212
|
+
Skip the opt-in branch entirely and call the helpers with the default `allowDefinitionWrite: false`.
|
|
213
|
+
|
|
214
|
+
- **`partiallyUnwritable === true`** — some sources are local, some remote. Surface the split:
|
|
215
|
+
|
|
216
|
+
```
|
|
217
|
+
Definition write policy
|
|
218
|
+
|
|
219
|
+
{unwritableCount} of {totalCount} source components are remote
|
|
220
|
+
(read-only) and will fall through to annotation; the remaining
|
|
221
|
+
{totalCount - unwritableCount} are local and will propagate.
|
|
222
|
+
Remote sources: {unwritableSourceNames.join(", ")}.
|
|
223
|
+
|
|
224
|
+
Continue with allowDefinitionWrite: true?
|
|
225
|
+
```
|
|
226
|
+
|
|
227
|
+
When confirmed, propagate to the local sources and let the helper's runtime fallback annotate the remote ones — the existing Experiment-10 retry path absorbs them without aborting the batch.
|
|
228
|
+
|
|
229
|
+
- **`allUnwritable === false && partiallyUnwritable === false`** (the all-local / no-candidates case) — show the existing batch-level picker prose. No probe-driven adjustment needed.
|
|
230
|
+
|
|
231
|
+
The probe is read-only and idempotent; running it before the picker adds one round-trip but saves the user a confusing "I opted in, why did I get annotations?" moment that #342 surfaced live on Simple Design System (Community).
|
|
232
|
+
|
|
180
233
|
**Shared helpers (bundled)** — the deterministic helpers live in TypeScript at `src/core/roundtrip/*.ts` and are bundled to a single IIFE at `.claude/skills/canicode-roundtrip/helpers.js`. `use_figma` only accepts a self-contained JS string, so the source of truth is TypeScript (with vitest coverage) and the bundle is the delivery artifact.
|
|
181
234
|
|
|
182
235
|
**Usage in a roundtrip session:**
|
|
@@ -185,11 +238,12 @@ The helper walks the tiers in order; variable binding is an alternative writeFn
|
|
|
185
238
|
2. Prepend its contents verbatim at the top of every `use_figma` batch body — it registers a single global `CanICodeRoundtrip`.
|
|
186
239
|
3. Reference exposed globals as `CanICodeRoundtrip.*`:
|
|
187
240
|
- `stripAnnotations(annotations)` — normalizes the D1 label/labelMarkdown mutex on readback.
|
|
188
|
-
- `ensureCanicodeCategories()` — returns `{ gotcha,
|
|
241
|
+
- `ensureCanicodeCategories()` — returns `{ gotcha, flag, fallback }` category id map (D4); idempotent, safe to call at the top of every batch. May also include `legacyAutoFix` when the file already carries the pre-#355 `canicode:auto-fix` category from earlier roundtrips — read-only on the canicode side, used only by Step 5 cleanup to sweep old annotations.
|
|
189
242
|
- `upsertCanicodeAnnotation(node, { ruleId, markdown, categoryId, properties })` — idempotent annotation upsert. Handles D1 mutex, D2 in-place replace by ruleId prefix, and the D3 `properties` node-type retry.
|
|
190
243
|
- `applyWithInstanceFallback(question, writeFn, { categories, allowDefinitionWrite, telemetry })` — three-tier write policy with silent-ignore detection. `allowDefinitionWrite` defaults to `false` per ADR-012 — override-errors and silent-ignores annotate the scene naming the source component instead of writing the definition. Set `true` only after a batch-level confirmation. `telemetry` is an optional `(event, props) => void` callback fired when a definition write is skipped (wiring point for future Node-side opt-in usage data). The `writeFn` may return `false` to signal "write accepted but value unchanged" so the helper can route to the next tier.
|
|
191
244
|
- `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`.
|
|
192
245
|
- `resolveVariableByName(name)` — local-variable exact-name lookup; returns `null` for remote library variables not imported into this file.
|
|
246
|
+
- `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`.
|
|
193
247
|
|
|
194
248
|
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.
|
|
195
249
|
|
|
@@ -203,6 +257,15 @@ Rules with `applyStrategy === "property-mod"`. Call the bundled helper — it br
|
|
|
203
257
|
await CanICodeRoundtrip.applyPropertyMod(question, answerValue, { categories });
|
|
204
258
|
```
|
|
205
259
|
|
|
260
|
+
**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
|
+
|
|
262
|
+
```javascript
|
|
263
|
+
const targets = [question.nodeId, ...(question.replicaNodeIds ?? [])];
|
|
264
|
+
for (const nodeId of targets) {
|
|
265
|
+
await CanICodeRoundtrip.applyPropertyMod({ ...question, nodeId }, answerValue, { categories });
|
|
266
|
+
}
|
|
267
|
+
```
|
|
268
|
+
|
|
206
269
|
Answer shape guide (LLM judgment — the user's answer is prose; parse accordingly):
|
|
207
270
|
- **`non-semantic-name`**: string — the new node name.
|
|
208
271
|
- **`irregular-spacing`**: number for gap (subType `gap`), or `{ paddingTop, paddingRight, paddingBottom, paddingLeft }` for padding.
|
|
@@ -244,22 +307,25 @@ If the user **declines** any structural modification, add an annotation instead
|
|
|
244
307
|
|
|
245
308
|
#### Strategy C: Annotation — record on the design for designer reference
|
|
246
309
|
|
|
247
|
-
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.
|
|
310
|
+
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:
|
|
248
311
|
|
|
249
312
|
```javascript
|
|
250
|
-
const
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
313
|
+
const targets = [question.nodeId, ...(question.replicaNodeIds ?? [])];
|
|
314
|
+
for (const nodeId of targets) {
|
|
315
|
+
const scene = await figma.getNodeByIdAsync(nodeId);
|
|
316
|
+
CanICodeRoundtrip.upsertCanicodeAnnotation(scene, {
|
|
317
|
+
ruleId: question.ruleId,
|
|
318
|
+
markdown: `**Q:** ${question.question}\n**A:** ${answer}`,
|
|
319
|
+
categoryId: categories.gotcha,
|
|
320
|
+
// Optional: surface live property values in Dev Mode alongside the note.
|
|
321
|
+
// Only include types the node supports (FRAME vs TEXT — see matrix above).
|
|
322
|
+
properties: question.annotationProperties,
|
|
323
|
+
});
|
|
324
|
+
}
|
|
259
325
|
```
|
|
260
326
|
|
|
261
327
|
Notes:
|
|
262
|
-
- `upsertCanicodeAnnotation`
|
|
328
|
+
- `upsertCanicodeAnnotation` writes the recommendation directly as the body and appends an italic `— *<ruleId>*` footer. The footer is the dedup marker — reruns replace the existing entry in place. The category badge (`canicode:gotcha` / `canicode:flag` / `canicode:fallback`) above the body already brands the annotation, so the body no longer leads with `**[canicode] <ruleId>**` (#353). Pre-#353 entries are still recognised on rerun and replaced with the new format.
|
|
263
329
|
- `label` and `labelMarkdown` are mutually exclusive on write, but Figma returns both on readback. Never spread `scene.annotations` directly; always call `CanICodeRoundtrip.upsertCanicodeAnnotation` (or `CanICodeRoundtrip.stripAnnotations` if you truly need the normalized array).
|
|
264
330
|
- Prefer annotating the **scene** instance child so designers see the note where they work; mention in the markdown if the fix belongs on the source component but could not be applied (library/external).
|
|
265
331
|
|
|
@@ -289,7 +355,7 @@ for (const issue of analyzeResult.issues) {
|
|
|
289
355
|
CanICodeRoundtrip.upsertCanicodeAnnotation(scene, {
|
|
290
356
|
ruleId: issue.ruleId,
|
|
291
357
|
markdown: issue.message,
|
|
292
|
-
categoryId: categories.
|
|
358
|
+
categoryId: categories.flag,
|
|
293
359
|
// Optional: surface the live value for the affected property in Dev Mode.
|
|
294
360
|
properties: issue.annotationProperties,
|
|
295
361
|
});
|
|
@@ -305,7 +371,7 @@ for (const issue of analyzeResult.issues) {
|
|
|
305
371
|
1. **Batch all property modifications** (Strategy A) into a single `use_figma` call for efficiency. Pass `{ categories }` to `applyWithInstanceFallback` so fallbacks land in the correct category.
|
|
306
372
|
2. **Present structural modifications** (Strategy B) one by one, apply confirmed ones.
|
|
307
373
|
3. **Batch all annotations** (Strategy C + declined structural mods) into a single `use_figma` call — use `categories.gotcha` for the category id.
|
|
308
|
-
4. **Batch all auto-fixes and annotations for lower-severity issues** (Strategy D) — use `categories.
|
|
374
|
+
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.
|
|
309
375
|
|
|
310
376
|
After applying, report what was done:
|
|
311
377
|
|
|
@@ -318,7 +384,7 @@ Applied {N} changes to the Figma design:
|
|
|
318
384
|
- ⏭️ {nodeName}: declined by user, added annotation (deep-nesting)
|
|
319
385
|
- 📝 {nodeName}: annotation added to canicode:gotcha (absolute-position-in-auto-layout)
|
|
320
386
|
- 🔧 {nodeName}: auto-fixed to "Hover" (non-standard-naming)
|
|
321
|
-
- 📝 {nodeName}: annotation added to canicode:
|
|
387
|
+
- 📝 {nodeName}: annotation added to canicode:flag — raw color needs token binding (raw-value)
|
|
322
388
|
```
|
|
323
389
|
|
|
324
390
|
### Step 5: Re-analyze and report what the roundtrip addressed
|
|
@@ -355,14 +421,18 @@ If Step 4 produced no report block (e.g. user skipped every question, or no gotc
|
|
|
355
421
|
|
|
356
422
|
Grade: {oldGrade} → {newGrade}. Ready for code generation.
|
|
357
423
|
```
|
|
358
|
-
- Clean up canicode annotations
|
|
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:
|
|
359
425
|
```javascript
|
|
426
|
+
const canicodeIds = new Set(
|
|
427
|
+
[categories.gotcha, categories.flag, categories.fallback, categories.legacyAutoFix].filter(Boolean)
|
|
428
|
+
);
|
|
360
429
|
const nodeIds = ["id1", "id2"]; // nodes that now pass
|
|
361
430
|
for (const id of nodeIds) {
|
|
362
431
|
const node = await figma.getNodeByIdAsync(id);
|
|
363
432
|
if (node && "annotations" in node) {
|
|
364
433
|
node.annotations = CanICodeRoundtrip.stripAnnotations(node.annotations).filter(
|
|
365
|
-
a => !a.
|
|
434
|
+
a => !(a.categoryId && canicodeIds.has(a.categoryId)) &&
|
|
435
|
+
!a.labelMarkdown?.startsWith("**[canicode]")
|
|
366
436
|
);
|
|
367
437
|
}
|
|
368
438
|
}
|
|
@@ -384,7 +454,23 @@ for (const id of nodeIds) {
|
|
|
384
454
|
Grade: {oldGrade} → {newGrade}. Proceed to code generation with remaining context?
|
|
385
455
|
```
|
|
386
456
|
- If yes → proceed to **Step 6** with remaining gotcha context.
|
|
387
|
-
- If no → stop and
|
|
457
|
+
- If no → stop and emit the **Stop wrap-up** below; do **not** restate the grade as the lead.
|
|
458
|
+
|
|
459
|
+
#### Wrap-up message rubric (Stop branch)
|
|
460
|
+
|
|
461
|
+
When the user picks **Stop** here, the closing message is the *last thing the user sees of canicode* in this session. Keep the issues-delta as the headline (`✅ X / 📝 Y / 🌐 Z / ⏭️ W / V remaining`) — grade movement, if any, belongs as a footnote line **after** the delta, not as the lead bullet. Reason: the value canicode delivers under the ADR-012 default is the annotation count carried into code-gen, not score movement (per [#341](https://github.com/let-sunny/canicode/issues/341), [#352](https://github.com/let-sunny/canicode/issues/352)).
|
|
462
|
+
|
|
463
|
+
```
|
|
464
|
+
Stopped — N issues addressed, V remaining for manual follow-up:
|
|
465
|
+
✅ X resolved
|
|
466
|
+
📝 Y annotated on Figma (carried into code-gen via canicode-gotchas)
|
|
467
|
+
🌐 Z definition writes propagated
|
|
468
|
+
⏭️ W skipped
|
|
469
|
+
|
|
470
|
+
Grade: {oldGrade} → {newGrade}.
|
|
471
|
+
```
|
|
472
|
+
|
|
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.
|
|
388
474
|
|
|
389
475
|
### Step 6: Implement with Figma MCP
|
|
390
476
|
|
|
@@ -399,6 +485,23 @@ Follow the **figma-implement-design** skill workflow to generate code from the F
|
|
|
399
485
|
|
|
400
486
|
**If all issues were resolved in Steps 4-5**, no additional gotcha context is needed — the design speaks for itself.
|
|
401
487
|
|
|
488
|
+
#### Wrap-up message rubric (post-handoff)
|
|
489
|
+
|
|
490
|
+
After `figma-implement-design` returns, summarise the roundtrip in the same shape as the Step 5 / Stop wrap-up — issues-delta first, grade as a footnote, then the code-gen outcome. Do **not** lead with grade movement (per [#352](https://github.com/let-sunny/canicode/issues/352)):
|
|
491
|
+
|
|
492
|
+
```
|
|
493
|
+
Roundtrip complete — N issues addressed, code generated:
|
|
494
|
+
✅ X resolved
|
|
495
|
+
📝 Y annotated on Figma (referenced during code-gen)
|
|
496
|
+
🌐 Z definition writes propagated
|
|
497
|
+
⏭️ W skipped
|
|
498
|
+
—
|
|
499
|
+
V issues remaining
|
|
500
|
+
|
|
501
|
+
Grade: {oldGrade} → {newGrade}.
|
|
502
|
+
Code: <files generated / next-step pointer from figma-implement-design>
|
|
503
|
+
```
|
|
504
|
+
|
|
402
505
|
## Edge Cases
|
|
403
506
|
|
|
404
507
|
- **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.
|
|
@@ -410,4 +513,4 @@ Follow the **figma-implement-design** skill workflow to generate code from the F
|
|
|
410
513
|
- **use_figma call fails for a node**: Report the error for that specific node, continue with other nodes. Failed property modifications become annotations so the context is not lost.
|
|
411
514
|
- **Re-analyze shows new issues**: Only address issues from the original gotcha survey. New issues may appear due to structural changes — report them but do not re-enter the gotcha loop.
|
|
412
515
|
- **Very large design (many gotchas)**: The gotcha survey already deduplicates sibling nodes and filters to blocking/risk severity only. If there are still many questions, ask the user if they want to focus on blocking issues only.
|
|
413
|
-
- **External library components**: Applies only when the orchestrator has set `allowDefinitionWrite: true`. Experiment 10's observed case is `getMainComponentAsync()` resolving with `mainComponent.remote === true` — writes then throw *"Cannot write to internal and read-only node"*. The `mainComponent === null` case is documented in the Plugin API but was not reproduced live in Experiment 10; Experiment 11 (#309) unit-test-covers the helper's routing for that branch (override-error + no `sourceChildId` → annotate with `could not apply automatically:` markdown — see ADR-011 Verification), so the code path is regression-locked while live Figma reproduction remains a manual fixture-seeding follow-up. Under the default (`allowDefinitionWrite: false`), the definition write never fires and this throw cannot surface.
|
|
516
|
+
- **External library components**: Applies only when the orchestrator has set `allowDefinitionWrite: true`. Experiment 10's observed case is `getMainComponentAsync()` resolving with `mainComponent.remote === true` — writes then throw *"Cannot write to internal and read-only node"*. The `mainComponent === null` case is documented in the Plugin API but was not reproduced live in Experiment 10; Experiment 11 (#309) unit-test-covers the helper's routing for that branch (override-error + no `sourceChildId` → annotate with `could not apply automatically:` markdown — see ADR-011 Verification), so the code path is regression-locked while live Figma reproduction remains a manual fixture-seeding follow-up. Under the default (`allowDefinitionWrite: false`), the definition write never fires and this throw cannot surface. **The pre-flight `probeDefinitionWritability` (#357) detects both branches up-front** so the Definition write picker can drop the opt-in option entirely when every candidate is unwritable, saving the user a wasted decision before the runtime fallback kicks in.
|
|
@@ -29,28 +29,39 @@ var CanICodeRoundtrip = (function (exports) {
|
|
|
29
29
|
byLabel.set(label, created.id);
|
|
30
30
|
return created.id;
|
|
31
31
|
}
|
|
32
|
-
|
|
32
|
+
const result = {
|
|
33
33
|
gotcha: await ensure("canicode:gotcha", "blue"),
|
|
34
|
-
|
|
34
|
+
flag: await ensure("canicode:flag", "green"),
|
|
35
35
|
fallback: await ensure("canicode:fallback", "yellow")
|
|
36
36
|
};
|
|
37
|
+
const legacyAutoFix = byLabel.get("canicode:auto-fix");
|
|
38
|
+
if (legacyAutoFix) result.legacyAutoFix = legacyAutoFix;
|
|
39
|
+
return result;
|
|
37
40
|
}
|
|
38
41
|
function upsertCanicodeAnnotation(node, input) {
|
|
39
42
|
if (!node || !("annotations" in node)) return false;
|
|
40
43
|
const { ruleId, markdown, categoryId, properties } = input;
|
|
41
|
-
const
|
|
42
|
-
const
|
|
44
|
+
const legacyPrefix = `**[canicode] ${ruleId}**`;
|
|
45
|
+
const footer = `\u2014 *${ruleId}*`;
|
|
46
|
+
let bodyText = markdown;
|
|
47
|
+
if (bodyText.startsWith(legacyPrefix)) {
|
|
48
|
+
bodyText = bodyText.slice(legacyPrefix.length).replace(/^\s*\n+/, "");
|
|
49
|
+
}
|
|
50
|
+
const trimmed = bodyText.replace(/\s+$/, "");
|
|
51
|
+
const body = trimmed.endsWith(footer) ? trimmed : `${trimmed}
|
|
43
52
|
|
|
44
|
-
${
|
|
53
|
+
${footer}`;
|
|
45
54
|
const existing = stripAnnotations(node.annotations);
|
|
46
55
|
const entry = { labelMarkdown: body };
|
|
47
56
|
if (categoryId) entry.categoryId = categoryId;
|
|
48
57
|
if (properties && properties.length > 0) entry.properties = properties;
|
|
49
|
-
const
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
58
|
+
const matchesRuleId = (text) => {
|
|
59
|
+
if (typeof text !== "string") return false;
|
|
60
|
+
return text.startsWith(legacyPrefix) || text.includes(footer);
|
|
61
|
+
};
|
|
62
|
+
const idx = existing.findIndex(
|
|
63
|
+
(a) => matchesRuleId(a.labelMarkdown) || matchesRuleId(a.label)
|
|
64
|
+
);
|
|
54
65
|
if (idx >= 0) existing[idx] = entry;
|
|
55
66
|
else existing.push(entry);
|
|
56
67
|
try {
|
|
@@ -251,9 +262,42 @@ ${markdown}`;
|
|
|
251
262
|
);
|
|
252
263
|
}
|
|
253
264
|
|
|
265
|
+
// src/core/roundtrip/probe-definition-writability.ts
|
|
266
|
+
async function probeDefinitionWritability(questions) {
|
|
267
|
+
const verdict = /* @__PURE__ */ new Map();
|
|
268
|
+
const unwritableNames = [];
|
|
269
|
+
const seenName = /* @__PURE__ */ new Set();
|
|
270
|
+
for (const q of questions) {
|
|
271
|
+
const id = q.sourceChildId;
|
|
272
|
+
if (!id) continue;
|
|
273
|
+
if (verdict.has(id)) continue;
|
|
274
|
+
const node = await figma.getNodeByIdAsync(id);
|
|
275
|
+
const isUnwritable = node === null || node.remote === true;
|
|
276
|
+
verdict.set(id, isUnwritable ? "unwritable" : "writable");
|
|
277
|
+
if (isUnwritable) {
|
|
278
|
+
const name = typeof node?.name === "string" && node.name || q.instanceContext?.sourceComponentName || id;
|
|
279
|
+
if (!seenName.has(name)) {
|
|
280
|
+
seenName.add(name);
|
|
281
|
+
unwritableNames.push(name);
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
const totalCount = verdict.size;
|
|
286
|
+
let unwritableCount = 0;
|
|
287
|
+
for (const v of verdict.values()) if (v === "unwritable") unwritableCount++;
|
|
288
|
+
return {
|
|
289
|
+
totalCount,
|
|
290
|
+
unwritableCount,
|
|
291
|
+
unwritableSourceNames: unwritableNames,
|
|
292
|
+
allUnwritable: totalCount > 0 && unwritableCount === totalCount,
|
|
293
|
+
partiallyUnwritable: unwritableCount > 0 && unwritableCount < totalCount
|
|
294
|
+
};
|
|
295
|
+
}
|
|
296
|
+
|
|
254
297
|
exports.applyPropertyMod = applyPropertyMod;
|
|
255
298
|
exports.applyWithInstanceFallback = applyWithInstanceFallback;
|
|
256
299
|
exports.ensureCanicodeCategories = ensureCanicodeCategories;
|
|
300
|
+
exports.probeDefinitionWritability = probeDefinitionWritability;
|
|
257
301
|
exports.resolveVariableByName = resolveVariableByName;
|
|
258
302
|
exports.stripAnnotations = stripAnnotations;
|
|
259
303
|
exports.upsertCanicodeAnnotation = upsertCanicodeAnnotation;
|