canicode 0.10.5 → 0.11.1
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 +23 -4
- package/dist/cli/index.js +1061 -153
- package/dist/cli/index.js.map +1 -1
- package/dist/index.d.ts +273 -35
- package/dist/index.js +262 -71
- package/dist/index.js.map +1 -1
- package/dist/mcp/server.js +251 -80
- package/dist/mcp/server.js.map +1 -1
- package/docs/CUSTOMIZATION.md +62 -3
- package/package.json +1 -1
- package/skills/canicode/SKILL.md +6 -0
- package/skills/canicode-gotchas/SKILL.md +54 -72
- package/skills/canicode-roundtrip/SKILL.md +47 -267
- package/skills/canicode-roundtrip/helpers.js +287 -17
- package/skills/cursor/canicode/SKILL.md +82 -0
- package/skills/cursor/canicode-gotchas/SKILL.md +178 -0
- package/skills/cursor/canicode-roundtrip/SKILL.md +397 -0
- package/skills/cursor/canicode-roundtrip/helpers.js +793 -0
|
@@ -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:
|
|
100
|
-
|
|
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 =
|
|
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:
|
|
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:
|
|
136
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
370
|
-
|
|
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
|
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: canicode
|
|
3
|
+
description: Analyze Figma designs for development-friendliness and AI-friendliness scores
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# CanICode -- Figma Design Analysis
|
|
7
|
+
|
|
8
|
+
Analyze Figma design files to score how development-friendly and AI-friendly they are. Produces actionable reports with specific issues and fix suggestions.
|
|
9
|
+
|
|
10
|
+
## Prerequisites
|
|
11
|
+
|
|
12
|
+
This skill works with either channel — the CLI or the canicode MCP server. Both return the same analysis; pick whichever is already set up. Requires either:
|
|
13
|
+
- A **saved fixture** (from `canicode calibrate-save-fixture`)
|
|
14
|
+
- A **FIGMA_TOKEN** for live Figma URLs
|
|
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
|
+
|
|
22
|
+
## How to Analyze
|
|
23
|
+
|
|
24
|
+
### From a Figma URL
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
npx canicode analyze "https://www.figma.com/design/ABC123/MyDesign?node-id=1-234" --token YOUR_TOKEN
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
Or if FIGMA_TOKEN is set in environment:
|
|
31
|
+
```bash
|
|
32
|
+
npx canicode analyze "https://www.figma.com/design/ABC123/MyDesign?node-id=1-234"
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
### From a saved fixture
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
npx canicode analyze fixtures/my-design
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
### Save a fixture for offline analysis
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
npx canicode calibrate-save-fixture "https://www.figma.com/design/ABC123/MyDesign?node-id=1-234" --output fixtures/my-design
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
## Analysis Options
|
|
48
|
+
|
|
49
|
+
### Presets
|
|
50
|
+
- `--preset relaxed` — Downgrades blocking to risk, reduces scores by 50%
|
|
51
|
+
- `--preset dev-friendly` — Enables only pixel-critical and responsive-critical rules, disables the rest
|
|
52
|
+
- `--preset ai-ready` — Sets pixel-critical and token-management rule scores to 150% of defaults
|
|
53
|
+
- `--preset strict` — Increases all scores by 150%
|
|
54
|
+
|
|
55
|
+
### Config overrides
|
|
56
|
+
```bash
|
|
57
|
+
npx canicode analyze <input> --config ./my-config.json
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
### JSON output
|
|
61
|
+
```bash
|
|
62
|
+
npx canicode analyze <input> --json
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
### Via MCP (when `canicode-mcp` is installed)
|
|
66
|
+
|
|
67
|
+
If the user has the canicode MCP server installed, prefer the MCP tool — it avoids the `npx` spawn overhead and reuses a warm Figma client:
|
|
68
|
+
|
|
69
|
+
```
|
|
70
|
+
analyze({ input: "<figma-url-or-fixture-path>" })
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
Options mirror the CLI: `preset`, `token`, `config`, `targetNodeId`, `json`. The `json` response field matches `npx canicode analyze --json` byte-for-byte, so downstream code can parse either source.
|
|
74
|
+
|
|
75
|
+
## What It Reports
|
|
76
|
+
|
|
77
|
+
16 rules across 6 categories: Pixel Critical, Responsive Critical, Code Quality, Token Management, Interaction, Semantic.
|
|
78
|
+
|
|
79
|
+
Each issue includes:
|
|
80
|
+
- Rule ID and severity (blocking / risk / missing-info / suggestion)
|
|
81
|
+
- Affected node with Figma deep link
|
|
82
|
+
- Why it matters, impact, and how to fix
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: canicode-gotchas
|
|
3
|
+
description: Gotcha survey (Claude Code or Cursor) — Q&A workflow; answers accumulate in .claude/skills/canicode-gotchas/SKILL.md for figma-implement-design
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# CanICode Gotchas — Design Gotcha Survey
|
|
7
|
+
|
|
8
|
+
**Channel contrast:** **`canicode-gotchas`** (**this skill**) persists answers **only** in **local** `.claude/skills/canicode-gotchas/SKILL.md` — **memo-only**, no Plugin write to Figma. **`canicode-roundtrip`** writes to the **canvas**. Use gotchas when you want Q&A captured for code-gen context without mutating the file.
|
|
9
|
+
|
|
10
|
+
Run a gotcha survey on a Figma design to collect implementation context that Figma cannot encode natively, capture developer/designer answers, and upsert them into **`.claude/skills/canicode-gotchas/SKILL.md`** so downstream `figma-implement-design` runs have annotation-ready context. In this model, rules do rule-based best-practice detection, and gotcha is the annotation output from that detection. Some gotchas come from violation rules (what is wrong and how to resolve it); others come from info-collection rules (neutral context Figma cannot represent, like interaction intent/state).
|
|
11
|
+
|
|
12
|
+
**Install location:** The workflow prose may live under `.claude/skills/canicode-gotchas/SKILL.md` (default `canicode init`) or be copied to `.cursor/skills/canicode-gotchas/SKILL.md` (`canicode init --cursor-skills`). The **authoritative gotcha store** is always **`.claude/skills/canicode-gotchas/SKILL.md`** — the CLI `upsert-gotcha-section` writes there only. In the `.claude` copy, this file has two regions: the **Workflow** below (installed by `canicode init`, never overwritten manually) and the **Collected Gotchas** region at the bottom (one numbered section per design, replaced in place on re-runs).
|
|
13
|
+
|
|
14
|
+
## Prerequisites
|
|
15
|
+
|
|
16
|
+
- **canicode MCP** (recommended): Register the server with your host — **Claude Code:** `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); do **not** pass `-e FIGMA_TOKEN=…` here (#364). **Cursor / other hosts:** add `canicode-mcp` to your MCP config — see [Customization guide](https://github.com/let-sunny/canicode/blob/main/docs/CUSTOMIZATION.md#cursor-mcp-canicode) (`~/.cursor/mcp.json` or project `.cursor/mcp.json`). The MCP server reads `FIGMA_TOKEN` from `~/.canicode/config.json` or the environment.
|
|
17
|
+
- **Without canicode MCP** (fallback): `npx canicode gotcha-survey "<input>" --json` — same JSON shape as the MCP tool.
|
|
18
|
+
- **FIGMA_TOKEN** configured for live Figma URLs.
|
|
19
|
+
- **Gotcha destination on disk:** `.claude/skills/canicode-gotchas/SKILL.md` must exist before upsert — run `npx canicode init --token …` (add `--cursor-skills` if you also want the workflow file under `.cursor/skills/`).
|
|
20
|
+
|
|
21
|
+
## Workflow
|
|
22
|
+
|
|
23
|
+
### Step 0: Verify canicode MCP tools are loaded (optional fast path)
|
|
24
|
+
|
|
25
|
+
Before Step 1, verify that `gotcha-survey` is callable in **this** session — not merely listed in `.mcp.json`. Newly registered MCP servers usually need a **host restart or MCP reload** before tools appear (same pattern as `/canicode-roundtrip` Step 0 for the Figma MCP).
|
|
26
|
+
|
|
27
|
+
When you fall back to `npx canicode gotcha-survey … --json`, tell the user explicitly: the canicode MCP may not be loaded yet. They should register it (`claude mcp add canicode -- npx --yes --package=canicode canicode-mcp`, or the Cursor/`mcp.json` equivalent in the Customization guide) and **restart the IDE or reload MCP** — then the next session can use the MCP tool without spawning `npx`. The CLI fallback is correct behavior; silence makes users think registration failed (#433).
|
|
28
|
+
|
|
29
|
+
### Step 1: Run the gotcha survey
|
|
30
|
+
|
|
31
|
+
If the `gotcha-survey` MCP tool is available, call it with the user's Figma URL:
|
|
32
|
+
|
|
33
|
+
```
|
|
34
|
+
gotcha-survey({ input: "<figma-url-or-fixture-path>" })
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
**Without canicode MCP** — shell out to the CLI. The `--json` output parses identically:
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
npx canicode gotcha-survey "<figma-url-or-fixture-path>" --json
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
Either channel returns:
|
|
44
|
+
- `designGrade`: overall grade (S, A+, A, B+, B, C+, C, D, F)
|
|
45
|
+
- `isReadyForCodeGen`: whether the design can be implemented without gotchas
|
|
46
|
+
- `questions`: array of gotcha questions (may be empty)
|
|
47
|
+
|
|
48
|
+
### Step 2: Check if survey is needed
|
|
49
|
+
|
|
50
|
+
If `isReadyForCodeGen` is `true` or `questions` is empty:
|
|
51
|
+
- Tell the user: "This design scored **{designGrade}** and is ready for code generation — no gotchas to resolve."
|
|
52
|
+
- Do NOT write to `.claude/skills/canicode-gotchas/SKILL.md`.
|
|
53
|
+
- Stop here.
|
|
54
|
+
|
|
55
|
+
### Step 3: Present questions to the user
|
|
56
|
+
|
|
57
|
+
The survey response carries a pre-computed `groupedQuestions.groups[].batches[]` shape so this 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:
|
|
58
|
+
|
|
59
|
+
For every `batch` in `groupedQuestions.groups.flatMap((g) => g.batches)`:
|
|
60
|
+
|
|
61
|
+
- **Single-question batch (`batch.questions.length === 1`)** — render the standard prompt for `batch.questions[0]`:
|
|
62
|
+
|
|
63
|
+
```
|
|
64
|
+
**[{severity}] {ruleId}** — node: {nodeName}
|
|
65
|
+
|
|
66
|
+
{question}
|
|
67
|
+
|
|
68
|
+
> Hint: {hint}
|
|
69
|
+
> Example: {example}
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
- **Batch of N ≥ 2 with `batch.batchable === true`** (#369) — render one shared prompt covering every member:
|
|
73
|
+
|
|
74
|
+
```
|
|
75
|
+
**[{severity}] {ruleId}** — {batch.questions.length} instances:
|
|
76
|
+
- {nodeName₁}
|
|
77
|
+
- {nodeName₂}
|
|
78
|
+
- …
|
|
79
|
+
|
|
80
|
+
{sharedQuestionPrompt}
|
|
81
|
+
|
|
82
|
+
Reply with one answer to apply to all {batch.questions.length}, or **split** to answer each individually.
|
|
83
|
+
|
|
84
|
+
> Hint: {hint}
|
|
85
|
+
> Example: {example}
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
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).
|
|
89
|
+
|
|
90
|
+
- **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).
|
|
91
|
+
|
|
92
|
+
Wait for the user's answer before moving to the next batch. The user may:
|
|
93
|
+
- Answer the question / batch directly
|
|
94
|
+
- Say **split** (batch only) to fall back to per-question prompting for that batch
|
|
95
|
+
- Say **skip** to skip the question / the entire batch
|
|
96
|
+
- Say **n/a** if the question / the entire batch is not applicable
|
|
97
|
+
|
|
98
|
+
When applying the batched answer, expand back to per-question records in Step 4 — the gotcha section format stores one record per `nodeId`.
|
|
99
|
+
|
|
100
|
+
> 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.
|
|
101
|
+
|
|
102
|
+
### Step 4: Upsert the gotcha section
|
|
103
|
+
|
|
104
|
+
After collecting all answers, **upsert** this design's section into the `# Collected Gotchas` region at the bottom of:
|
|
105
|
+
|
|
106
|
+
```
|
|
107
|
+
.claude/skills/canicode-gotchas/SKILL.md
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
That path is in the **user's project** (current working directory), NOT in the canicode repo. If you are following this workflow from a copy under `.cursor/skills/`, still upsert into **`.claude/skills/...`** only — never write gotcha answers into the `.cursor` copy. The Workflow region in the `.claude` file **must never be modified manually** — only the `# Collected Gotchas` region is touched (via the CLI below).
|
|
111
|
+
|
|
112
|
+
#### Step 4a: Use the `designKey` from the survey response
|
|
113
|
+
|
|
114
|
+
`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.
|
|
115
|
+
|
|
116
|
+
The `core/contracts/design-key.ts` helper (`computeDesignKey`) handles every shape with vitest coverage so this workflow stays ADR-016-compliant:
|
|
117
|
+
|
|
118
|
+
- **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.
|
|
119
|
+
- **Figma URL without `node-id`** → just `<fileKey>` (file-level key).
|
|
120
|
+
- **Fixture path / JSON file** → absolute path.
|
|
121
|
+
|
|
122
|
+
#### Step 4b: Upsert via the canicode CLI
|
|
123
|
+
|
|
124
|
+
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 — do not re-implement them in prose (per ADR-016).
|
|
125
|
+
|
|
126
|
+
Build **one JSON object** on stdin for `upsert-gotcha-section`. The CLI renders the section markdown from `survey` + `answers` via `renderGotchaSection` in TypeScript (#439) — severity, rule text, node ids, and instance context come **verbatim** from `gotcha-survey --json`; the skill must not paste LLM-authored section prose.
|
|
127
|
+
|
|
128
|
+
Payload shape:
|
|
129
|
+
|
|
130
|
+
```json
|
|
131
|
+
{
|
|
132
|
+
"survey": {
|
|
133
|
+
"designKey": "<same as Step 4a>",
|
|
134
|
+
"designGrade": "<from gotcha-survey>",
|
|
135
|
+
"questions": "<full questions[] array from gotcha-survey — preserve order>"
|
|
136
|
+
},
|
|
137
|
+
"answers": {
|
|
138
|
+
"<nodeId>": { "answer": "…" }
|
|
139
|
+
},
|
|
140
|
+
"designName": "<Figma file name or fixture label>",
|
|
141
|
+
"figmaUrl": "<the user's input URL or path>",
|
|
142
|
+
"analyzedAt": "<ISO 8601 timestamp when you upsert>",
|
|
143
|
+
"today": "<YYYY-MM-DD local date for the section title>"
|
|
144
|
+
}
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
For skipped / n/a: use `{ "skipped": true }` for that `nodeId`, or omit the key. Skipped questions do **not** get per-question rows; `renderGotchaSection` appends a compact **`#### Skipped (N)`** block listing each `ruleId` with a count (`ruleId` lines sorted lexically — see `src/core/gotcha/render-gotcha-section.ts`).
|
|
148
|
+
|
|
149
|
+
Invoke (cac requires `--input=-`, not `--input -`, so the stdin sentinel survives parsing — #420):
|
|
150
|
+
|
|
151
|
+
```bash
|
|
152
|
+
npx canicode upsert-gotcha-section \
|
|
153
|
+
--file .claude/skills/canicode-gotchas/SKILL.md \
|
|
154
|
+
--design-key "<designKey from Step 4a>" \
|
|
155
|
+
--input=-
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
Pipe the JSON object on stdin. `--design-key` must equal `survey.designKey` (the CLI validates the match).
|
|
159
|
+
|
|
160
|
+
The CLI prints JSON `{ state, action, sectionNumber, wrote, userMessage, designKey }`:
|
|
161
|
+
|
|
162
|
+
- `wrote: true` → success. `action` is `"replace"` (preserved `sectionNumber`) or `"append"` (next monotonic `sectionNumber`).
|
|
163
|
+
- `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.
|
|
164
|
+
- `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.
|
|
165
|
+
- `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.
|
|
166
|
+
|
|
167
|
+
The Workflow region above must never be touched.
|
|
168
|
+
|
|
169
|
+
## Edge Cases
|
|
170
|
+
|
|
171
|
+
- **No questions returned**: The design is ready for code generation. Inform the user and stop (Step 2). Do not touch `.claude/skills/canicode-gotchas/SKILL.md`.
|
|
172
|
+
- **Re-run on the same design**: Replace that design's section in place (matched by `Design key`) — preserve the original `#NNN` number. Do NOT append a duplicate.
|
|
173
|
+
- **Re-run on a different design**: Append a new section with the next `#NNN`. Prior designs' sections are untouched.
|
|
174
|
+
- **Workflow region**: Never modified. If you notice the Workflow region has been edited by the user, leave their edits alone — only the `# Collected Gotchas` region is under skill control.
|
|
175
|
+
- **Pre-#340 clobbered file** (the YAML frontmatter was rewritten to a per-design variant, so the canonical `canicode-gotchas` frontmatter is missing): tell the user to run `canicode init --force` to restore the workflow, then re-run the survey. The prior single-design content cannot be automatically migrated into a `## #001` section — the user re-runs and gets a clean section.
|
|
176
|
+
- **MCP tool not available**: Fall back to `npx canicode gotcha-survey <input> --json` — the CLI returns the same `GotchaSurvey` shape. If the CLI is also unavailable (e.g. no node runtime), tell the user to install the canicode MCP server or the `canicode` npm package (see Prerequisites).
|
|
177
|
+
- **Partial answers**: If the user stops mid-survey, upsert the section with answers collected so far. Remaining questions count toward **`#### Skipped (N)`** (omit keys or `{ "skipped": true }`).
|
|
178
|
+
- **Manual section deletion**: If the user deletes a middle section by hand, do not renumber existing sections. The next new section still gets `(highest existing number) + 1`; numeric gaps are acceptable (same pattern as `.claude/docs/ADR.md`).
|