canicode 0.3.0
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/LICENSE +21 -0
- package/README.md +298 -0
- package/dist/cli/index.d.ts +1 -0
- package/dist/cli/index.js +4399 -0
- package/dist/cli/index.js.map +1 -0
- package/dist/index.d.ts +1104 -0
- package/dist/index.js +3513 -0
- package/dist/index.js.map +1 -0
- package/dist/mcp/server.d.ts +1 -0
- package/dist/mcp/server.js +3009 -0
- package/dist/mcp/server.js.map +1 -0
- package/package.json +63 -0
|
@@ -0,0 +1,4399 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs';
|
|
3
|
+
import { writeFile, readFile, appendFile } from 'fs/promises';
|
|
4
|
+
import { join, resolve, dirname, basename } from 'path';
|
|
5
|
+
import { config } from 'dotenv';
|
|
6
|
+
import cac from 'cac';
|
|
7
|
+
import { z } from 'zod';
|
|
8
|
+
import { homedir } from 'os';
|
|
9
|
+
|
|
10
|
+
z.object({
|
|
11
|
+
fileKey: z.string(),
|
|
12
|
+
nodeId: z.string().optional(),
|
|
13
|
+
fileName: z.string().optional()
|
|
14
|
+
});
|
|
15
|
+
var FIGMA_URL_PATTERNS = [
|
|
16
|
+
// https://www.figma.com/design/FILE_KEY/FILE_NAME?node-id=NODE_ID
|
|
17
|
+
/figma\.com\/design\/([a-zA-Z0-9]+)(?:\/([^?]+))?(?:\?.*node-id=([^&]+))?/,
|
|
18
|
+
// https://www.figma.com/file/FILE_KEY/FILE_NAME?node-id=NODE_ID
|
|
19
|
+
/figma\.com\/file\/([a-zA-Z0-9]+)(?:\/([^?]+))?(?:\?.*node-id=([^&]+))?/,
|
|
20
|
+
// https://www.figma.com/proto/FILE_KEY/FILE_NAME?node-id=NODE_ID
|
|
21
|
+
/figma\.com\/proto\/([a-zA-Z0-9]+)(?:\/([^?]+))?(?:\?.*node-id=([^&]+))?/
|
|
22
|
+
];
|
|
23
|
+
function parseFigmaUrl(url) {
|
|
24
|
+
for (const pattern of FIGMA_URL_PATTERNS) {
|
|
25
|
+
const match = url.match(pattern);
|
|
26
|
+
if (match) {
|
|
27
|
+
const [, fileKey, fileName, nodeId] = match;
|
|
28
|
+
if (!fileKey) {
|
|
29
|
+
throw new FigmaUrlParseError(`Invalid Figma URL: missing file key`);
|
|
30
|
+
}
|
|
31
|
+
return {
|
|
32
|
+
fileKey,
|
|
33
|
+
fileName: fileName ? decodeURIComponent(fileName) : void 0,
|
|
34
|
+
nodeId: nodeId ? decodeURIComponent(nodeId) : void 0
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
throw new FigmaUrlParseError(
|
|
39
|
+
`Invalid Figma URL format. Expected: https://www.figma.com/design/FILE_KEY/...`
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
var FigmaUrlParseError = class extends Error {
|
|
43
|
+
constructor(message) {
|
|
44
|
+
super(message);
|
|
45
|
+
this.name = "FigmaUrlParseError";
|
|
46
|
+
}
|
|
47
|
+
};
|
|
48
|
+
function buildFigmaDeepLink(fileKey, nodeId) {
|
|
49
|
+
return `https://www.figma.com/design/${fileKey}?node-id=${encodeURIComponent(nodeId)}`;
|
|
50
|
+
}
|
|
51
|
+
var CategorySchema = z.enum([
|
|
52
|
+
"layout",
|
|
53
|
+
"token",
|
|
54
|
+
"component",
|
|
55
|
+
"naming",
|
|
56
|
+
"ai-readability",
|
|
57
|
+
"handoff-risk"
|
|
58
|
+
]);
|
|
59
|
+
var CATEGORIES = CategorySchema.options;
|
|
60
|
+
var CATEGORY_LABELS = {
|
|
61
|
+
layout: "Layout",
|
|
62
|
+
token: "Design Token",
|
|
63
|
+
component: "Component",
|
|
64
|
+
naming: "Naming",
|
|
65
|
+
"ai-readability": "AI Readability",
|
|
66
|
+
"handoff-risk": "Handoff Risk"
|
|
67
|
+
};
|
|
68
|
+
var SeveritySchema = z.enum([
|
|
69
|
+
"blocking",
|
|
70
|
+
"risk",
|
|
71
|
+
"missing-info",
|
|
72
|
+
"suggestion"
|
|
73
|
+
]);
|
|
74
|
+
var SEVERITY_LABELS = {
|
|
75
|
+
blocking: "Blocking",
|
|
76
|
+
risk: "Risk",
|
|
77
|
+
"missing-info": "Missing Info",
|
|
78
|
+
suggestion: "Suggestion"
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
// src/contracts/rule.ts
|
|
82
|
+
z.object({
|
|
83
|
+
id: z.string(),
|
|
84
|
+
name: z.string(),
|
|
85
|
+
category: CategorySchema,
|
|
86
|
+
why: z.string(),
|
|
87
|
+
impact: z.string(),
|
|
88
|
+
fix: z.string()
|
|
89
|
+
});
|
|
90
|
+
z.object({
|
|
91
|
+
severity: SeveritySchema,
|
|
92
|
+
score: z.number().int().max(0),
|
|
93
|
+
depthWeight: z.number().min(1).max(2).optional(),
|
|
94
|
+
enabled: z.boolean().default(true),
|
|
95
|
+
options: z.record(z.string(), z.unknown()).optional()
|
|
96
|
+
});
|
|
97
|
+
var DEPTH_WEIGHT_CATEGORIES = ["layout", "handoff-risk"];
|
|
98
|
+
function supportsDepthWeight(category) {
|
|
99
|
+
return DEPTH_WEIGHT_CATEGORIES.includes(category);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// src/rules/rule-config.ts
|
|
103
|
+
var RULE_CONFIGS = {
|
|
104
|
+
// ============================================
|
|
105
|
+
// Layout (11 rules)
|
|
106
|
+
// ============================================
|
|
107
|
+
"no-auto-layout": {
|
|
108
|
+
severity: "blocking",
|
|
109
|
+
score: -7,
|
|
110
|
+
depthWeight: 1.5,
|
|
111
|
+
enabled: true
|
|
112
|
+
},
|
|
113
|
+
"absolute-position-in-auto-layout": {
|
|
114
|
+
severity: "blocking",
|
|
115
|
+
score: -10,
|
|
116
|
+
depthWeight: 1.3,
|
|
117
|
+
enabled: true
|
|
118
|
+
},
|
|
119
|
+
"fixed-width-in-responsive-context": {
|
|
120
|
+
severity: "risk",
|
|
121
|
+
score: -4,
|
|
122
|
+
depthWeight: 1.3,
|
|
123
|
+
enabled: true
|
|
124
|
+
},
|
|
125
|
+
"missing-responsive-behavior": {
|
|
126
|
+
severity: "risk",
|
|
127
|
+
score: -4,
|
|
128
|
+
depthWeight: 1.5,
|
|
129
|
+
enabled: true
|
|
130
|
+
},
|
|
131
|
+
"group-usage": {
|
|
132
|
+
severity: "risk",
|
|
133
|
+
score: -5,
|
|
134
|
+
depthWeight: 1.2,
|
|
135
|
+
enabled: true
|
|
136
|
+
},
|
|
137
|
+
"fixed-size-in-auto-layout": {
|
|
138
|
+
severity: "risk",
|
|
139
|
+
score: -5,
|
|
140
|
+
enabled: true
|
|
141
|
+
},
|
|
142
|
+
"missing-min-width": {
|
|
143
|
+
severity: "risk",
|
|
144
|
+
score: -5,
|
|
145
|
+
enabled: true
|
|
146
|
+
},
|
|
147
|
+
"missing-max-width": {
|
|
148
|
+
severity: "risk",
|
|
149
|
+
score: -4,
|
|
150
|
+
enabled: true
|
|
151
|
+
},
|
|
152
|
+
"deep-nesting": {
|
|
153
|
+
severity: "risk",
|
|
154
|
+
score: -4,
|
|
155
|
+
enabled: true,
|
|
156
|
+
options: {
|
|
157
|
+
maxDepth: 5
|
|
158
|
+
}
|
|
159
|
+
},
|
|
160
|
+
"overflow-hidden-abuse": {
|
|
161
|
+
severity: "missing-info",
|
|
162
|
+
score: -3,
|
|
163
|
+
enabled: true
|
|
164
|
+
},
|
|
165
|
+
"inconsistent-sibling-layout-direction": {
|
|
166
|
+
severity: "missing-info",
|
|
167
|
+
score: -2,
|
|
168
|
+
enabled: true
|
|
169
|
+
},
|
|
170
|
+
// ============================================
|
|
171
|
+
// Token (7 rules)
|
|
172
|
+
// ============================================
|
|
173
|
+
"raw-color": {
|
|
174
|
+
severity: "missing-info",
|
|
175
|
+
score: -2,
|
|
176
|
+
enabled: true
|
|
177
|
+
},
|
|
178
|
+
"raw-font": {
|
|
179
|
+
severity: "blocking",
|
|
180
|
+
score: -8,
|
|
181
|
+
enabled: true
|
|
182
|
+
},
|
|
183
|
+
"inconsistent-spacing": {
|
|
184
|
+
severity: "missing-info",
|
|
185
|
+
score: -2,
|
|
186
|
+
enabled: true,
|
|
187
|
+
options: {
|
|
188
|
+
gridBase: 8
|
|
189
|
+
}
|
|
190
|
+
},
|
|
191
|
+
"magic-number-spacing": {
|
|
192
|
+
severity: "risk",
|
|
193
|
+
score: -4,
|
|
194
|
+
enabled: true,
|
|
195
|
+
options: {
|
|
196
|
+
gridBase: 8
|
|
197
|
+
}
|
|
198
|
+
},
|
|
199
|
+
"raw-shadow": {
|
|
200
|
+
severity: "missing-info",
|
|
201
|
+
score: -3,
|
|
202
|
+
enabled: true
|
|
203
|
+
},
|
|
204
|
+
"raw-opacity": {
|
|
205
|
+
severity: "missing-info",
|
|
206
|
+
score: -2,
|
|
207
|
+
enabled: true
|
|
208
|
+
},
|
|
209
|
+
"multiple-fill-colors": {
|
|
210
|
+
severity: "missing-info",
|
|
211
|
+
score: -3,
|
|
212
|
+
enabled: true,
|
|
213
|
+
options: {
|
|
214
|
+
tolerance: 10
|
|
215
|
+
}
|
|
216
|
+
},
|
|
217
|
+
// ============================================
|
|
218
|
+
// Component (6 rules)
|
|
219
|
+
// ============================================
|
|
220
|
+
"missing-component": {
|
|
221
|
+
severity: "risk",
|
|
222
|
+
score: -7,
|
|
223
|
+
enabled: true,
|
|
224
|
+
options: {
|
|
225
|
+
minRepetitions: 3
|
|
226
|
+
}
|
|
227
|
+
},
|
|
228
|
+
"detached-instance": {
|
|
229
|
+
severity: "risk",
|
|
230
|
+
score: -5,
|
|
231
|
+
enabled: true
|
|
232
|
+
},
|
|
233
|
+
"nested-instance-override": {
|
|
234
|
+
severity: "risk",
|
|
235
|
+
score: -5,
|
|
236
|
+
enabled: true
|
|
237
|
+
},
|
|
238
|
+
"variant-not-used": {
|
|
239
|
+
severity: "missing-info",
|
|
240
|
+
score: -3,
|
|
241
|
+
enabled: true
|
|
242
|
+
},
|
|
243
|
+
"component-property-unused": {
|
|
244
|
+
severity: "missing-info",
|
|
245
|
+
score: -2,
|
|
246
|
+
enabled: true
|
|
247
|
+
},
|
|
248
|
+
"single-use-component": {
|
|
249
|
+
severity: "suggestion",
|
|
250
|
+
score: -1,
|
|
251
|
+
enabled: true
|
|
252
|
+
},
|
|
253
|
+
// ============================================
|
|
254
|
+
// Naming (5 rules)
|
|
255
|
+
// ============================================
|
|
256
|
+
"default-name": {
|
|
257
|
+
severity: "missing-info",
|
|
258
|
+
score: -2,
|
|
259
|
+
enabled: true
|
|
260
|
+
},
|
|
261
|
+
"non-semantic-name": {
|
|
262
|
+
severity: "missing-info",
|
|
263
|
+
score: -2,
|
|
264
|
+
enabled: true
|
|
265
|
+
},
|
|
266
|
+
"inconsistent-naming-convention": {
|
|
267
|
+
severity: "missing-info",
|
|
268
|
+
score: -2,
|
|
269
|
+
enabled: true
|
|
270
|
+
},
|
|
271
|
+
"numeric-suffix-name": {
|
|
272
|
+
severity: "suggestion",
|
|
273
|
+
score: -1,
|
|
274
|
+
enabled: true
|
|
275
|
+
},
|
|
276
|
+
"too-long-name": {
|
|
277
|
+
severity: "suggestion",
|
|
278
|
+
score: -1,
|
|
279
|
+
enabled: true,
|
|
280
|
+
options: {
|
|
281
|
+
maxLength: 50
|
|
282
|
+
}
|
|
283
|
+
},
|
|
284
|
+
// ============================================
|
|
285
|
+
// AI Readability (5 rules)
|
|
286
|
+
// ============================================
|
|
287
|
+
"ambiguous-structure": {
|
|
288
|
+
severity: "blocking",
|
|
289
|
+
score: -10,
|
|
290
|
+
depthWeight: 1.3,
|
|
291
|
+
enabled: true
|
|
292
|
+
},
|
|
293
|
+
"z-index-dependent-layout": {
|
|
294
|
+
severity: "risk",
|
|
295
|
+
score: -5,
|
|
296
|
+
depthWeight: 1.3,
|
|
297
|
+
enabled: true
|
|
298
|
+
},
|
|
299
|
+
"missing-layout-hint": {
|
|
300
|
+
severity: "risk",
|
|
301
|
+
score: -5,
|
|
302
|
+
enabled: true
|
|
303
|
+
},
|
|
304
|
+
"invisible-layer": {
|
|
305
|
+
severity: "blocking",
|
|
306
|
+
score: -10,
|
|
307
|
+
enabled: true
|
|
308
|
+
},
|
|
309
|
+
"empty-frame": {
|
|
310
|
+
severity: "missing-info",
|
|
311
|
+
score: -2,
|
|
312
|
+
enabled: true
|
|
313
|
+
},
|
|
314
|
+
// ============================================
|
|
315
|
+
// Handoff Risk (5 rules)
|
|
316
|
+
// ============================================
|
|
317
|
+
"hardcode-risk": {
|
|
318
|
+
severity: "risk",
|
|
319
|
+
score: -5,
|
|
320
|
+
depthWeight: 1.5,
|
|
321
|
+
enabled: true
|
|
322
|
+
},
|
|
323
|
+
"text-truncation-unhandled": {
|
|
324
|
+
severity: "risk",
|
|
325
|
+
score: -5,
|
|
326
|
+
enabled: true
|
|
327
|
+
},
|
|
328
|
+
"image-no-placeholder": {
|
|
329
|
+
severity: "missing-info",
|
|
330
|
+
score: -3,
|
|
331
|
+
enabled: true
|
|
332
|
+
},
|
|
333
|
+
"prototype-link-in-design": {
|
|
334
|
+
severity: "missing-info",
|
|
335
|
+
score: -2,
|
|
336
|
+
enabled: true
|
|
337
|
+
},
|
|
338
|
+
"no-dev-status": {
|
|
339
|
+
severity: "missing-info",
|
|
340
|
+
score: -2,
|
|
341
|
+
enabled: false
|
|
342
|
+
// Disabled by default
|
|
343
|
+
}
|
|
344
|
+
};
|
|
345
|
+
function getConfigsWithPreset(preset) {
|
|
346
|
+
const configs = { ...RULE_CONFIGS };
|
|
347
|
+
switch (preset) {
|
|
348
|
+
case "relaxed":
|
|
349
|
+
for (const [id, config2] of Object.entries(configs)) {
|
|
350
|
+
if (config2.severity === "blocking") {
|
|
351
|
+
configs[id] = {
|
|
352
|
+
...config2,
|
|
353
|
+
severity: "risk",
|
|
354
|
+
score: Math.round(config2.score * 0.5)
|
|
355
|
+
};
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
break;
|
|
359
|
+
case "dev-friendly":
|
|
360
|
+
for (const [id, config2] of Object.entries(configs)) {
|
|
361
|
+
const ruleId = id;
|
|
362
|
+
if (!ruleId.includes("layout") && !ruleId.includes("handoff") && !ruleId.includes("responsive")) {
|
|
363
|
+
configs[ruleId] = { ...config2, enabled: false };
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
break;
|
|
367
|
+
case "ai-ready":
|
|
368
|
+
for (const [id, config2] of Object.entries(configs)) {
|
|
369
|
+
const ruleId = id;
|
|
370
|
+
if (ruleId.includes("ambiguous") || ruleId.includes("structure") || ruleId.includes("name")) {
|
|
371
|
+
configs[ruleId] = {
|
|
372
|
+
...config2,
|
|
373
|
+
score: Math.round(config2.score * 1.5)
|
|
374
|
+
};
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
break;
|
|
378
|
+
case "strict":
|
|
379
|
+
for (const [id, config2] of Object.entries(configs)) {
|
|
380
|
+
configs[id] = {
|
|
381
|
+
...config2,
|
|
382
|
+
enabled: true,
|
|
383
|
+
score: Math.round(config2.score * 1.5)
|
|
384
|
+
};
|
|
385
|
+
}
|
|
386
|
+
break;
|
|
387
|
+
}
|
|
388
|
+
return configs;
|
|
389
|
+
}
|
|
390
|
+
function getRuleOption(ruleId, optionKey, defaultValue) {
|
|
391
|
+
const config2 = RULE_CONFIGS[ruleId];
|
|
392
|
+
if (!config2.options) return defaultValue;
|
|
393
|
+
const value = config2.options[optionKey];
|
|
394
|
+
return value ?? defaultValue;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
// src/rules/rule-registry.ts
|
|
398
|
+
var RuleRegistry = class {
|
|
399
|
+
rules = /* @__PURE__ */ new Map();
|
|
400
|
+
/**
|
|
401
|
+
* Register a rule
|
|
402
|
+
*/
|
|
403
|
+
register(rule) {
|
|
404
|
+
this.rules.set(rule.definition.id, rule);
|
|
405
|
+
}
|
|
406
|
+
/**
|
|
407
|
+
* Get a rule by ID
|
|
408
|
+
*/
|
|
409
|
+
get(id) {
|
|
410
|
+
return this.rules.get(id);
|
|
411
|
+
}
|
|
412
|
+
/**
|
|
413
|
+
* Get all registered rules
|
|
414
|
+
*/
|
|
415
|
+
getAll() {
|
|
416
|
+
return Array.from(this.rules.values());
|
|
417
|
+
}
|
|
418
|
+
/**
|
|
419
|
+
* Get rules by category
|
|
420
|
+
*/
|
|
421
|
+
getByCategory(category) {
|
|
422
|
+
return this.getAll().filter((rule) => rule.definition.category === category);
|
|
423
|
+
}
|
|
424
|
+
/**
|
|
425
|
+
* Get enabled rules with their configs
|
|
426
|
+
*/
|
|
427
|
+
getEnabled(configs = RULE_CONFIGS) {
|
|
428
|
+
return this.getAll().filter((rule) => {
|
|
429
|
+
const config2 = configs[rule.definition.id];
|
|
430
|
+
return config2?.enabled ?? true;
|
|
431
|
+
});
|
|
432
|
+
}
|
|
433
|
+
/**
|
|
434
|
+
* Get config for a rule
|
|
435
|
+
*/
|
|
436
|
+
getConfig(id, configs = RULE_CONFIGS) {
|
|
437
|
+
return configs[id];
|
|
438
|
+
}
|
|
439
|
+
/**
|
|
440
|
+
* Check if a rule is registered
|
|
441
|
+
*/
|
|
442
|
+
has(id) {
|
|
443
|
+
return this.rules.has(id);
|
|
444
|
+
}
|
|
445
|
+
/**
|
|
446
|
+
* Get count of registered rules
|
|
447
|
+
*/
|
|
448
|
+
get size() {
|
|
449
|
+
return this.rules.size;
|
|
450
|
+
}
|
|
451
|
+
};
|
|
452
|
+
var ruleRegistry = new RuleRegistry();
|
|
453
|
+
function defineRule(rule) {
|
|
454
|
+
ruleRegistry.register(rule);
|
|
455
|
+
return rule;
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
// src/core/rule-engine.ts
|
|
459
|
+
function calculateMaxDepth(node, currentDepth = 0) {
|
|
460
|
+
if (!node.children || node.children.length === 0) {
|
|
461
|
+
return currentDepth;
|
|
462
|
+
}
|
|
463
|
+
let maxChildDepth = currentDepth;
|
|
464
|
+
for (const child of node.children) {
|
|
465
|
+
const childDepth = calculateMaxDepth(child, currentDepth + 1);
|
|
466
|
+
if (childDepth > maxChildDepth) {
|
|
467
|
+
maxChildDepth = childDepth;
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
return maxChildDepth;
|
|
471
|
+
}
|
|
472
|
+
function countNodes(node) {
|
|
473
|
+
let count = 1;
|
|
474
|
+
if (node.children) {
|
|
475
|
+
for (const child of node.children) {
|
|
476
|
+
count += countNodes(child);
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
return count;
|
|
480
|
+
}
|
|
481
|
+
function findNodeById(node, nodeId) {
|
|
482
|
+
const normalizedId = nodeId.replace(/-/g, ":");
|
|
483
|
+
if (node.id === normalizedId) {
|
|
484
|
+
return node;
|
|
485
|
+
}
|
|
486
|
+
if (node.children) {
|
|
487
|
+
for (const child of node.children) {
|
|
488
|
+
const found = findNodeById(child, nodeId);
|
|
489
|
+
if (found) return found;
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
return null;
|
|
493
|
+
}
|
|
494
|
+
function calcDepthWeight(depth, maxDepth, depthWeight) {
|
|
495
|
+
if (!depthWeight || depthWeight <= 1) return 1;
|
|
496
|
+
if (maxDepth === 0) return depthWeight;
|
|
497
|
+
const ratio = depth / maxDepth;
|
|
498
|
+
return depthWeight - (depthWeight - 1) * ratio;
|
|
499
|
+
}
|
|
500
|
+
var RuleEngine = class {
|
|
501
|
+
configs;
|
|
502
|
+
enabledRuleIds;
|
|
503
|
+
disabledRuleIds;
|
|
504
|
+
targetNodeId;
|
|
505
|
+
constructor(options = {}) {
|
|
506
|
+
this.configs = options.configs ?? RULE_CONFIGS;
|
|
507
|
+
this.enabledRuleIds = options.enabledRules ? new Set(options.enabledRules) : null;
|
|
508
|
+
this.disabledRuleIds = new Set(options.disabledRules ?? []);
|
|
509
|
+
this.targetNodeId = options.targetNodeId;
|
|
510
|
+
}
|
|
511
|
+
/**
|
|
512
|
+
* Analyze a Figma file and return issues
|
|
513
|
+
*/
|
|
514
|
+
analyze(file) {
|
|
515
|
+
let rootNode = file.document;
|
|
516
|
+
if (this.targetNodeId) {
|
|
517
|
+
const targetNode = findNodeById(file.document, this.targetNodeId);
|
|
518
|
+
if (!targetNode) {
|
|
519
|
+
throw new Error(`Node not found: ${this.targetNodeId}`);
|
|
520
|
+
}
|
|
521
|
+
rootNode = targetNode;
|
|
522
|
+
}
|
|
523
|
+
const maxDepth = calculateMaxDepth(rootNode);
|
|
524
|
+
const nodeCount = countNodes(rootNode);
|
|
525
|
+
const issues = [];
|
|
526
|
+
const enabledRules = this.getEnabledRules();
|
|
527
|
+
this.traverseAndCheck(
|
|
528
|
+
rootNode,
|
|
529
|
+
file,
|
|
530
|
+
enabledRules,
|
|
531
|
+
maxDepth,
|
|
532
|
+
issues,
|
|
533
|
+
0,
|
|
534
|
+
[],
|
|
535
|
+
void 0,
|
|
536
|
+
void 0
|
|
537
|
+
);
|
|
538
|
+
return {
|
|
539
|
+
file,
|
|
540
|
+
issues,
|
|
541
|
+
maxDepth,
|
|
542
|
+
nodeCount,
|
|
543
|
+
analyzedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
544
|
+
};
|
|
545
|
+
}
|
|
546
|
+
/**
|
|
547
|
+
* Get rules that should be run based on configuration
|
|
548
|
+
*/
|
|
549
|
+
getEnabledRules() {
|
|
550
|
+
return ruleRegistry.getAll().filter((rule) => {
|
|
551
|
+
const ruleId = rule.definition.id;
|
|
552
|
+
if (this.disabledRuleIds.has(ruleId)) return false;
|
|
553
|
+
if (this.enabledRuleIds && !this.enabledRuleIds.has(ruleId)) return false;
|
|
554
|
+
const config2 = this.configs[ruleId];
|
|
555
|
+
return config2?.enabled ?? true;
|
|
556
|
+
});
|
|
557
|
+
}
|
|
558
|
+
/**
|
|
559
|
+
* Recursively traverse the tree and run rules
|
|
560
|
+
*/
|
|
561
|
+
traverseAndCheck(node, file, rules, maxDepth, issues, depth, path, parent, siblings) {
|
|
562
|
+
const nodePath = [...path, node.name];
|
|
563
|
+
const context = {
|
|
564
|
+
file,
|
|
565
|
+
parent,
|
|
566
|
+
depth,
|
|
567
|
+
maxDepth,
|
|
568
|
+
path: nodePath,
|
|
569
|
+
siblings
|
|
570
|
+
};
|
|
571
|
+
for (const rule of rules) {
|
|
572
|
+
const ruleId = rule.definition.id;
|
|
573
|
+
const config2 = this.configs[ruleId];
|
|
574
|
+
const options = config2?.options;
|
|
575
|
+
try {
|
|
576
|
+
const violation = rule.check(node, context, options);
|
|
577
|
+
if (violation) {
|
|
578
|
+
let calculatedScore = config2.score;
|
|
579
|
+
if (supportsDepthWeight(rule.definition.category) && config2.depthWeight) {
|
|
580
|
+
const weight = calcDepthWeight(depth, maxDepth, config2.depthWeight);
|
|
581
|
+
calculatedScore = Math.round(config2.score * weight);
|
|
582
|
+
}
|
|
583
|
+
issues.push({
|
|
584
|
+
violation,
|
|
585
|
+
rule,
|
|
586
|
+
config: config2,
|
|
587
|
+
depth,
|
|
588
|
+
maxDepth,
|
|
589
|
+
calculatedScore
|
|
590
|
+
});
|
|
591
|
+
}
|
|
592
|
+
} catch (error) {
|
|
593
|
+
console.error(`Rule "${ruleId}" threw error on node "${node.name}":`, error);
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
if (node.children && node.children.length > 0) {
|
|
597
|
+
for (const child of node.children) {
|
|
598
|
+
this.traverseAndCheck(
|
|
599
|
+
child,
|
|
600
|
+
file,
|
|
601
|
+
rules,
|
|
602
|
+
maxDepth,
|
|
603
|
+
issues,
|
|
604
|
+
depth + 1,
|
|
605
|
+
nodePath,
|
|
606
|
+
node,
|
|
607
|
+
node.children
|
|
608
|
+
);
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
};
|
|
613
|
+
function createRuleEngine(options) {
|
|
614
|
+
return new RuleEngine(options);
|
|
615
|
+
}
|
|
616
|
+
function analyzeFile(file, options) {
|
|
617
|
+
const engine = createRuleEngine(options);
|
|
618
|
+
return engine.analyze(file);
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
// src/adapters/figma-client.ts
|
|
622
|
+
var FIGMA_API_BASE = "https://api.figma.com/v1";
|
|
623
|
+
var FigmaClient = class _FigmaClient {
|
|
624
|
+
token;
|
|
625
|
+
constructor(options) {
|
|
626
|
+
this.token = options.token;
|
|
627
|
+
}
|
|
628
|
+
static fromEnv() {
|
|
629
|
+
const token = process.env["FIGMA_TOKEN"];
|
|
630
|
+
if (!token) {
|
|
631
|
+
throw new FigmaClientError(
|
|
632
|
+
"FIGMA_TOKEN environment variable is not set"
|
|
633
|
+
);
|
|
634
|
+
}
|
|
635
|
+
return new _FigmaClient({ token });
|
|
636
|
+
}
|
|
637
|
+
async getFile(fileKey) {
|
|
638
|
+
const url = `${FIGMA_API_BASE}/files/${fileKey}`;
|
|
639
|
+
const response = await fetch(url, {
|
|
640
|
+
headers: {
|
|
641
|
+
"X-Figma-Token": this.token
|
|
642
|
+
}
|
|
643
|
+
});
|
|
644
|
+
if (!response.ok) {
|
|
645
|
+
const error = await response.json().catch(() => ({}));
|
|
646
|
+
throw new FigmaClientError(
|
|
647
|
+
`Failed to fetch file: ${response.status} ${response.statusText}`,
|
|
648
|
+
response.status,
|
|
649
|
+
error
|
|
650
|
+
);
|
|
651
|
+
}
|
|
652
|
+
return response.json();
|
|
653
|
+
}
|
|
654
|
+
/**
|
|
655
|
+
* Get rendered images for specific nodes
|
|
656
|
+
* Returns a map of nodeId → image URL
|
|
657
|
+
*/
|
|
658
|
+
async getNodeImages(fileKey, nodeIds, options) {
|
|
659
|
+
const format = options?.format ?? "png";
|
|
660
|
+
const scale = options?.scale ?? 2;
|
|
661
|
+
const BATCH_SIZE = 50;
|
|
662
|
+
const allImages = {};
|
|
663
|
+
for (let i = 0; i < nodeIds.length; i += BATCH_SIZE) {
|
|
664
|
+
const batch = nodeIds.slice(i, i + BATCH_SIZE);
|
|
665
|
+
const ids = batch.join(",");
|
|
666
|
+
const url = `${FIGMA_API_BASE}/images/${fileKey}?ids=${encodeURIComponent(ids)}&format=${format}&scale=${scale}`;
|
|
667
|
+
const response = await fetch(url, {
|
|
668
|
+
headers: {
|
|
669
|
+
"X-Figma-Token": this.token
|
|
670
|
+
}
|
|
671
|
+
});
|
|
672
|
+
if (!response.ok) {
|
|
673
|
+
const error = await response.json().catch(() => ({}));
|
|
674
|
+
throw new FigmaClientError(
|
|
675
|
+
`Failed to fetch images: ${response.status} ${response.statusText}`,
|
|
676
|
+
response.status,
|
|
677
|
+
error
|
|
678
|
+
);
|
|
679
|
+
}
|
|
680
|
+
const data = await response.json();
|
|
681
|
+
for (const [nodeId, imageUrl] of Object.entries(data.images)) {
|
|
682
|
+
allImages[nodeId] = imageUrl;
|
|
683
|
+
}
|
|
684
|
+
}
|
|
685
|
+
return allImages;
|
|
686
|
+
}
|
|
687
|
+
/**
|
|
688
|
+
* Download an image URL and return as base64
|
|
689
|
+
*/
|
|
690
|
+
async fetchImageAsBase64(imageUrl) {
|
|
691
|
+
const response = await fetch(imageUrl);
|
|
692
|
+
if (!response.ok) {
|
|
693
|
+
throw new FigmaClientError(
|
|
694
|
+
`Failed to download image: ${response.status}`,
|
|
695
|
+
response.status
|
|
696
|
+
);
|
|
697
|
+
}
|
|
698
|
+
const buffer = await response.arrayBuffer();
|
|
699
|
+
return Buffer.from(buffer).toString("base64");
|
|
700
|
+
}
|
|
701
|
+
async getFileNodes(fileKey, nodeIds) {
|
|
702
|
+
const ids = nodeIds.join(",");
|
|
703
|
+
const url = `${FIGMA_API_BASE}/files/${fileKey}/nodes?ids=${encodeURIComponent(ids)}`;
|
|
704
|
+
const response = await fetch(url, {
|
|
705
|
+
headers: {
|
|
706
|
+
"X-Figma-Token": this.token
|
|
707
|
+
}
|
|
708
|
+
});
|
|
709
|
+
if (!response.ok) {
|
|
710
|
+
const error = await response.json().catch(() => ({}));
|
|
711
|
+
throw new FigmaClientError(
|
|
712
|
+
`Failed to fetch nodes: ${response.status} ${response.statusText}`,
|
|
713
|
+
response.status,
|
|
714
|
+
error
|
|
715
|
+
);
|
|
716
|
+
}
|
|
717
|
+
return response.json();
|
|
718
|
+
}
|
|
719
|
+
};
|
|
720
|
+
var FigmaClientError = class extends Error {
|
|
721
|
+
constructor(message, statusCode, responseBody) {
|
|
722
|
+
super(message);
|
|
723
|
+
this.statusCode = statusCode;
|
|
724
|
+
this.responseBody = responseBody;
|
|
725
|
+
this.name = "FigmaClientError";
|
|
726
|
+
}
|
|
727
|
+
};
|
|
728
|
+
|
|
729
|
+
// src/adapters/figma-transformer.ts
|
|
730
|
+
function transformFigmaResponse(fileKey, response) {
|
|
731
|
+
return {
|
|
732
|
+
fileKey,
|
|
733
|
+
name: response.name,
|
|
734
|
+
lastModified: response.lastModified,
|
|
735
|
+
version: response.version,
|
|
736
|
+
document: transformNode(response.document),
|
|
737
|
+
components: transformComponents(response.components),
|
|
738
|
+
styles: transformStyles(response.styles)
|
|
739
|
+
};
|
|
740
|
+
}
|
|
741
|
+
function transformNode(node) {
|
|
742
|
+
const base = {
|
|
743
|
+
id: node.id,
|
|
744
|
+
name: node.name,
|
|
745
|
+
type: node.type,
|
|
746
|
+
visible: "visible" in node ? node.visible ?? true : true
|
|
747
|
+
};
|
|
748
|
+
if ("layoutMode" in node && node.layoutMode) {
|
|
749
|
+
base.layoutMode = node.layoutMode;
|
|
750
|
+
}
|
|
751
|
+
if ("layoutAlign" in node && node.layoutAlign) {
|
|
752
|
+
base.layoutAlign = node.layoutAlign;
|
|
753
|
+
}
|
|
754
|
+
if ("layoutPositioning" in node && node.layoutPositioning) {
|
|
755
|
+
base.layoutPositioning = node.layoutPositioning;
|
|
756
|
+
}
|
|
757
|
+
if ("primaryAxisAlignItems" in node) {
|
|
758
|
+
base.primaryAxisAlignItems = node.primaryAxisAlignItems;
|
|
759
|
+
}
|
|
760
|
+
if ("counterAxisAlignItems" in node) {
|
|
761
|
+
base.counterAxisAlignItems = node.counterAxisAlignItems;
|
|
762
|
+
}
|
|
763
|
+
if ("itemSpacing" in node) {
|
|
764
|
+
base.itemSpacing = node.itemSpacing;
|
|
765
|
+
}
|
|
766
|
+
if ("paddingLeft" in node) {
|
|
767
|
+
base.paddingLeft = node.paddingLeft;
|
|
768
|
+
}
|
|
769
|
+
if ("paddingRight" in node) {
|
|
770
|
+
base.paddingRight = node.paddingRight;
|
|
771
|
+
}
|
|
772
|
+
if ("paddingTop" in node) {
|
|
773
|
+
base.paddingTop = node.paddingTop;
|
|
774
|
+
}
|
|
775
|
+
if ("paddingBottom" in node) {
|
|
776
|
+
base.paddingBottom = node.paddingBottom;
|
|
777
|
+
}
|
|
778
|
+
if ("absoluteBoundingBox" in node && node.absoluteBoundingBox) {
|
|
779
|
+
base.absoluteBoundingBox = node.absoluteBoundingBox;
|
|
780
|
+
}
|
|
781
|
+
if ("componentId" in node) {
|
|
782
|
+
base.componentId = node.componentId;
|
|
783
|
+
}
|
|
784
|
+
if ("componentPropertyDefinitions" in node) {
|
|
785
|
+
base.componentPropertyDefinitions = node.componentPropertyDefinitions;
|
|
786
|
+
}
|
|
787
|
+
if ("componentProperties" in node) {
|
|
788
|
+
base.componentProperties = node.componentProperties;
|
|
789
|
+
}
|
|
790
|
+
if ("styles" in node) {
|
|
791
|
+
base.styles = node.styles;
|
|
792
|
+
}
|
|
793
|
+
if ("fills" in node) {
|
|
794
|
+
base.fills = node.fills;
|
|
795
|
+
}
|
|
796
|
+
if ("strokes" in node) {
|
|
797
|
+
base.strokes = node.strokes;
|
|
798
|
+
}
|
|
799
|
+
if ("effects" in node) {
|
|
800
|
+
base.effects = node.effects;
|
|
801
|
+
}
|
|
802
|
+
if ("boundVariables" in node && node.boundVariables) {
|
|
803
|
+
base.boundVariables = node.boundVariables;
|
|
804
|
+
}
|
|
805
|
+
if ("characters" in node) {
|
|
806
|
+
base.characters = node.characters;
|
|
807
|
+
}
|
|
808
|
+
if ("style" in node) {
|
|
809
|
+
base.style = node.style;
|
|
810
|
+
}
|
|
811
|
+
if ("devStatus" in node && node.devStatus) {
|
|
812
|
+
base.devStatus = node.devStatus;
|
|
813
|
+
}
|
|
814
|
+
if ("children" in node && Array.isArray(node.children)) {
|
|
815
|
+
base.children = node.children.map(transformNode);
|
|
816
|
+
}
|
|
817
|
+
return base;
|
|
818
|
+
}
|
|
819
|
+
function transformComponents(components) {
|
|
820
|
+
const result = {};
|
|
821
|
+
for (const [id, component] of Object.entries(components)) {
|
|
822
|
+
result[id] = {
|
|
823
|
+
key: component.key,
|
|
824
|
+
name: component.name,
|
|
825
|
+
description: component.description
|
|
826
|
+
};
|
|
827
|
+
}
|
|
828
|
+
return result;
|
|
829
|
+
}
|
|
830
|
+
function transformStyles(styles) {
|
|
831
|
+
const result = {};
|
|
832
|
+
for (const [id, style] of Object.entries(styles)) {
|
|
833
|
+
result[id] = {
|
|
834
|
+
key: style.key,
|
|
835
|
+
name: style.name,
|
|
836
|
+
styleType: style.styleType
|
|
837
|
+
};
|
|
838
|
+
}
|
|
839
|
+
return result;
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
// src/adapters/figma-file-loader.ts
|
|
843
|
+
async function loadFigmaFileFromJson(filePath) {
|
|
844
|
+
const content = await readFile(filePath, "utf-8");
|
|
845
|
+
const data = JSON.parse(content);
|
|
846
|
+
const fileKey = basename(filePath, ".json");
|
|
847
|
+
return transformFigmaResponse(fileKey, data);
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
// src/adapters/figma-mcp-adapter.ts
|
|
851
|
+
var TAG_TYPE_MAP = {
|
|
852
|
+
canvas: "CANVAS",
|
|
853
|
+
frame: "FRAME",
|
|
854
|
+
group: "GROUP",
|
|
855
|
+
section: "SECTION",
|
|
856
|
+
component: "COMPONENT",
|
|
857
|
+
"component-set": "COMPONENT_SET",
|
|
858
|
+
instance: "INSTANCE",
|
|
859
|
+
rectangle: "RECTANGLE",
|
|
860
|
+
"rounded-rectangle": "RECTANGLE",
|
|
861
|
+
ellipse: "ELLIPSE",
|
|
862
|
+
vector: "VECTOR",
|
|
863
|
+
text: "TEXT",
|
|
864
|
+
line: "LINE",
|
|
865
|
+
"boolean-operation": "BOOLEAN_OPERATION",
|
|
866
|
+
star: "STAR",
|
|
867
|
+
"regular-polygon": "REGULAR_POLYGON",
|
|
868
|
+
slice: "SLICE",
|
|
869
|
+
sticky: "STICKY",
|
|
870
|
+
table: "TABLE",
|
|
871
|
+
"table-cell": "TABLE_CELL",
|
|
872
|
+
symbol: "COMPONENT",
|
|
873
|
+
slot: "FRAME"
|
|
874
|
+
};
|
|
875
|
+
function parseXml(xml) {
|
|
876
|
+
const nodes = [];
|
|
877
|
+
const stack = [];
|
|
878
|
+
const tagRegex = /<(\/?)([\w-]+)([^>]*?)(\/?)>/g;
|
|
879
|
+
let match;
|
|
880
|
+
while ((match = tagRegex.exec(xml)) !== null) {
|
|
881
|
+
const isClosing = match[1] === "/";
|
|
882
|
+
const tagName = match[2];
|
|
883
|
+
const attrString = match[3] ?? "";
|
|
884
|
+
const isSelfClosing = match[4] === "/";
|
|
885
|
+
if (isClosing) {
|
|
886
|
+
const finished = stack.pop();
|
|
887
|
+
if (finished) {
|
|
888
|
+
const parent = stack[stack.length - 1];
|
|
889
|
+
if (parent) {
|
|
890
|
+
parent.children.push(finished);
|
|
891
|
+
} else {
|
|
892
|
+
nodes.push(finished);
|
|
893
|
+
}
|
|
894
|
+
}
|
|
895
|
+
} else {
|
|
896
|
+
const attrs = parseAttributes(attrString);
|
|
897
|
+
const node = { tag: tagName, attrs, children: [] };
|
|
898
|
+
if (isSelfClosing) {
|
|
899
|
+
const parent = stack[stack.length - 1];
|
|
900
|
+
if (parent) {
|
|
901
|
+
parent.children.push(node);
|
|
902
|
+
} else {
|
|
903
|
+
nodes.push(node);
|
|
904
|
+
}
|
|
905
|
+
} else {
|
|
906
|
+
stack.push(node);
|
|
907
|
+
}
|
|
908
|
+
}
|
|
909
|
+
}
|
|
910
|
+
while (stack.length > 0) {
|
|
911
|
+
const finished = stack.pop();
|
|
912
|
+
const parent = stack[stack.length - 1];
|
|
913
|
+
if (parent) {
|
|
914
|
+
parent.children.push(finished);
|
|
915
|
+
} else {
|
|
916
|
+
nodes.push(finished);
|
|
917
|
+
}
|
|
918
|
+
}
|
|
919
|
+
return nodes;
|
|
920
|
+
}
|
|
921
|
+
function parseAttributes(attrString) {
|
|
922
|
+
const attrs = {};
|
|
923
|
+
const attrRegex = /([\w-]+)="([^"]*)"/g;
|
|
924
|
+
let match;
|
|
925
|
+
while ((match = attrRegex.exec(attrString)) !== null) {
|
|
926
|
+
const key = match[1];
|
|
927
|
+
const value = match[2];
|
|
928
|
+
if (key && value !== void 0) {
|
|
929
|
+
attrs[key] = value;
|
|
930
|
+
}
|
|
931
|
+
}
|
|
932
|
+
return attrs;
|
|
933
|
+
}
|
|
934
|
+
function toAnalysisNode(xmlNode) {
|
|
935
|
+
const type = TAG_TYPE_MAP[xmlNode.tag] ?? "FRAME";
|
|
936
|
+
const id = xmlNode.attrs["id"] ?? "0:0";
|
|
937
|
+
const name = xmlNode.attrs["name"] ?? xmlNode.tag;
|
|
938
|
+
const hidden = xmlNode.attrs["hidden"] === "true";
|
|
939
|
+
const x = parseFloat(xmlNode.attrs["x"] ?? "0");
|
|
940
|
+
const y = parseFloat(xmlNode.attrs["y"] ?? "0");
|
|
941
|
+
const width = parseFloat(xmlNode.attrs["width"] ?? "0");
|
|
942
|
+
const height = parseFloat(xmlNode.attrs["height"] ?? "0");
|
|
943
|
+
const node = {
|
|
944
|
+
id,
|
|
945
|
+
name,
|
|
946
|
+
type,
|
|
947
|
+
visible: !hidden,
|
|
948
|
+
absoluteBoundingBox: { x, y, width, height }
|
|
949
|
+
};
|
|
950
|
+
if (xmlNode.children.length > 0) {
|
|
951
|
+
node.children = xmlNode.children.map(toAnalysisNode);
|
|
952
|
+
}
|
|
953
|
+
return node;
|
|
954
|
+
}
|
|
955
|
+
function parseMcpMetadataXml(xml, fileKey, fileName) {
|
|
956
|
+
const parsed = parseXml(xml);
|
|
957
|
+
const children = parsed.map(toAnalysisNode);
|
|
958
|
+
let document;
|
|
959
|
+
if (children.length === 1 && children[0]) {
|
|
960
|
+
document = children[0];
|
|
961
|
+
} else {
|
|
962
|
+
document = {
|
|
963
|
+
id: "0:0",
|
|
964
|
+
name: "Document",
|
|
965
|
+
type: "DOCUMENT",
|
|
966
|
+
visible: true,
|
|
967
|
+
children
|
|
968
|
+
};
|
|
969
|
+
}
|
|
970
|
+
return {
|
|
971
|
+
fileKey,
|
|
972
|
+
name: fileName ?? fileKey,
|
|
973
|
+
lastModified: (/* @__PURE__ */ new Date()).toISOString(),
|
|
974
|
+
version: "mcp",
|
|
975
|
+
document,
|
|
976
|
+
components: {},
|
|
977
|
+
styles: {}
|
|
978
|
+
};
|
|
979
|
+
}
|
|
980
|
+
var AIREADY_DIR = join(homedir(), ".canicode");
|
|
981
|
+
var CONFIG_PATH = join(AIREADY_DIR, "config.json");
|
|
982
|
+
var REPORTS_DIR = join(AIREADY_DIR, "reports");
|
|
983
|
+
function ensureDir(dir) {
|
|
984
|
+
if (!existsSync(dir)) {
|
|
985
|
+
mkdirSync(dir, { recursive: true });
|
|
986
|
+
}
|
|
987
|
+
}
|
|
988
|
+
function readConfig() {
|
|
989
|
+
if (!existsSync(CONFIG_PATH)) {
|
|
990
|
+
return {};
|
|
991
|
+
}
|
|
992
|
+
try {
|
|
993
|
+
const raw = readFileSync(CONFIG_PATH, "utf-8");
|
|
994
|
+
return JSON.parse(raw);
|
|
995
|
+
} catch {
|
|
996
|
+
return {};
|
|
997
|
+
}
|
|
998
|
+
}
|
|
999
|
+
function writeConfig(config2) {
|
|
1000
|
+
ensureDir(AIREADY_DIR);
|
|
1001
|
+
writeFileSync(CONFIG_PATH, JSON.stringify(config2, null, 2) + "\n", "utf-8");
|
|
1002
|
+
}
|
|
1003
|
+
function getFigmaToken() {
|
|
1004
|
+
return process.env["FIGMA_TOKEN"] ?? readConfig().figmaToken;
|
|
1005
|
+
}
|
|
1006
|
+
function setFigmaToken(token) {
|
|
1007
|
+
const config2 = readConfig();
|
|
1008
|
+
config2.figmaToken = token;
|
|
1009
|
+
writeConfig(config2);
|
|
1010
|
+
}
|
|
1011
|
+
function getConfigPath() {
|
|
1012
|
+
return CONFIG_PATH;
|
|
1013
|
+
}
|
|
1014
|
+
function getReportsDir() {
|
|
1015
|
+
return REPORTS_DIR;
|
|
1016
|
+
}
|
|
1017
|
+
function ensureReportsDir() {
|
|
1018
|
+
ensureDir(REPORTS_DIR);
|
|
1019
|
+
}
|
|
1020
|
+
function initAiready(token) {
|
|
1021
|
+
setFigmaToken(token);
|
|
1022
|
+
ensureReportsDir();
|
|
1023
|
+
}
|
|
1024
|
+
|
|
1025
|
+
// src/core/loader.ts
|
|
1026
|
+
function isFigmaUrl(input) {
|
|
1027
|
+
return input.includes("figma.com/");
|
|
1028
|
+
}
|
|
1029
|
+
function isJsonFile(input) {
|
|
1030
|
+
return input.endsWith(".json");
|
|
1031
|
+
}
|
|
1032
|
+
async function loadFile(input, token, mode = "auto") {
|
|
1033
|
+
if (isJsonFile(input)) {
|
|
1034
|
+
const filePath = resolve(input);
|
|
1035
|
+
if (!existsSync(filePath)) {
|
|
1036
|
+
throw new Error(`File not found: ${filePath}`);
|
|
1037
|
+
}
|
|
1038
|
+
console.log(`Loading from JSON: ${filePath}`);
|
|
1039
|
+
return { file: await loadFigmaFileFromJson(filePath) };
|
|
1040
|
+
}
|
|
1041
|
+
if (isFigmaUrl(input)) {
|
|
1042
|
+
const { fileKey, nodeId, fileName } = parseFigmaUrl(input);
|
|
1043
|
+
if (mode === "mcp") {
|
|
1044
|
+
return loadFromMcp(fileKey, nodeId, fileName);
|
|
1045
|
+
}
|
|
1046
|
+
if (mode === "api") {
|
|
1047
|
+
return loadFromApi(fileKey, nodeId, token);
|
|
1048
|
+
}
|
|
1049
|
+
try {
|
|
1050
|
+
console.log("Auto-detecting data source... trying MCP first.");
|
|
1051
|
+
return await loadFromMcp(fileKey, nodeId, fileName);
|
|
1052
|
+
} catch (mcpError) {
|
|
1053
|
+
const mcpMsg = mcpError instanceof Error ? mcpError.message : String(mcpError);
|
|
1054
|
+
console.log(`MCP unavailable (${mcpMsg}). Falling back to REST API.`);
|
|
1055
|
+
return loadFromApi(fileKey, nodeId, token);
|
|
1056
|
+
}
|
|
1057
|
+
}
|
|
1058
|
+
throw new Error(
|
|
1059
|
+
`Invalid input: ${input}. Provide a Figma URL or JSON file path.`
|
|
1060
|
+
);
|
|
1061
|
+
}
|
|
1062
|
+
async function loadFromMcp(fileKey, nodeId, fileName) {
|
|
1063
|
+
console.log(`Loading via MCP: ${fileKey} (node: ${nodeId ?? "root"})`);
|
|
1064
|
+
const file = await loadViaMcp(fileKey, nodeId ?? "0:1", fileName);
|
|
1065
|
+
return { file, nodeId };
|
|
1066
|
+
}
|
|
1067
|
+
async function loadFromApi(fileKey, nodeId, token) {
|
|
1068
|
+
console.log(`Fetching from Figma REST API: ${fileKey}`);
|
|
1069
|
+
if (nodeId) {
|
|
1070
|
+
console.log(`Target node: ${nodeId}`);
|
|
1071
|
+
}
|
|
1072
|
+
const figmaToken = token ?? getFigmaToken();
|
|
1073
|
+
if (!figmaToken) {
|
|
1074
|
+
throw new Error(
|
|
1075
|
+
"Figma token required. Run 'canicode init --token YOUR_TOKEN' or set FIGMA_TOKEN env var."
|
|
1076
|
+
);
|
|
1077
|
+
}
|
|
1078
|
+
const client = new FigmaClient({ token: figmaToken });
|
|
1079
|
+
const response = await client.getFile(fileKey);
|
|
1080
|
+
return {
|
|
1081
|
+
file: transformFigmaResponse(fileKey, response),
|
|
1082
|
+
nodeId
|
|
1083
|
+
};
|
|
1084
|
+
}
|
|
1085
|
+
async function loadViaMcp(fileKey, nodeId, fileName) {
|
|
1086
|
+
const { execSync } = await import('child_process');
|
|
1087
|
+
const result = execSync(
|
|
1088
|
+
`claude --print "Use the mcp__figma__get_metadata tool with fileKey=\\"${fileKey}\\" and nodeId=\\"${nodeId.replace(/-/g, ":")}\\" \u2014 return ONLY the raw XML output, nothing else."`,
|
|
1089
|
+
{ encoding: "utf-8", timeout: 12e4 }
|
|
1090
|
+
);
|
|
1091
|
+
const xmlStart = result.indexOf("<");
|
|
1092
|
+
const xmlEnd = result.lastIndexOf(">");
|
|
1093
|
+
if (xmlStart === -1 || xmlEnd === -1) {
|
|
1094
|
+
throw new Error("MCP did not return valid XML metadata");
|
|
1095
|
+
}
|
|
1096
|
+
const xml = result.slice(xmlStart, xmlEnd + 1);
|
|
1097
|
+
return parseMcpMetadataXml(xml, fileKey, fileName);
|
|
1098
|
+
}
|
|
1099
|
+
|
|
1100
|
+
// src/core/scoring.ts
|
|
1101
|
+
var SEVERITY_DENSITY_WEIGHT = {
|
|
1102
|
+
blocking: 3,
|
|
1103
|
+
risk: 2,
|
|
1104
|
+
"missing-info": 1,
|
|
1105
|
+
suggestion: 0.5
|
|
1106
|
+
};
|
|
1107
|
+
var TOTAL_RULES_PER_CATEGORY = {
|
|
1108
|
+
layout: 11,
|
|
1109
|
+
token: 7,
|
|
1110
|
+
component: 6,
|
|
1111
|
+
naming: 5,
|
|
1112
|
+
"ai-readability": 5,
|
|
1113
|
+
"handoff-risk": 5
|
|
1114
|
+
};
|
|
1115
|
+
var CATEGORY_WEIGHT = {
|
|
1116
|
+
layout: 1,
|
|
1117
|
+
token: 1,
|
|
1118
|
+
component: 1,
|
|
1119
|
+
naming: 1,
|
|
1120
|
+
"ai-readability": 1,
|
|
1121
|
+
"handoff-risk": 1
|
|
1122
|
+
};
|
|
1123
|
+
var DENSITY_WEIGHT = 0.7;
|
|
1124
|
+
var DIVERSITY_WEIGHT = 0.3;
|
|
1125
|
+
var SCORE_FLOOR = 5;
|
|
1126
|
+
function calculateGrade(percentage) {
|
|
1127
|
+
if (percentage >= 95) return "S";
|
|
1128
|
+
if (percentage >= 90) return "A+";
|
|
1129
|
+
if (percentage >= 85) return "A";
|
|
1130
|
+
if (percentage >= 80) return "B+";
|
|
1131
|
+
if (percentage >= 75) return "B";
|
|
1132
|
+
if (percentage >= 70) return "C+";
|
|
1133
|
+
if (percentage >= 65) return "C";
|
|
1134
|
+
if (percentage >= 50) return "D";
|
|
1135
|
+
return "F";
|
|
1136
|
+
}
|
|
1137
|
+
function clamp(value, min, max) {
|
|
1138
|
+
return Math.max(min, Math.min(max, value));
|
|
1139
|
+
}
|
|
1140
|
+
function calculateScores(result) {
|
|
1141
|
+
const categoryScores = initializeCategoryScores();
|
|
1142
|
+
const nodeCount = result.nodeCount;
|
|
1143
|
+
const uniqueRulesPerCategory = /* @__PURE__ */ new Map();
|
|
1144
|
+
for (const category of CATEGORIES) {
|
|
1145
|
+
uniqueRulesPerCategory.set(category, /* @__PURE__ */ new Set());
|
|
1146
|
+
}
|
|
1147
|
+
for (const issue of result.issues) {
|
|
1148
|
+
const category = issue.rule.definition.category;
|
|
1149
|
+
const severity = issue.config.severity;
|
|
1150
|
+
const ruleId = issue.rule.definition.id;
|
|
1151
|
+
categoryScores[category].issueCount++;
|
|
1152
|
+
categoryScores[category].bySeverity[severity]++;
|
|
1153
|
+
categoryScores[category].weightedIssueCount += SEVERITY_DENSITY_WEIGHT[severity];
|
|
1154
|
+
uniqueRulesPerCategory.get(category).add(ruleId);
|
|
1155
|
+
}
|
|
1156
|
+
for (const category of CATEGORIES) {
|
|
1157
|
+
const catScore = categoryScores[category];
|
|
1158
|
+
const uniqueRules = uniqueRulesPerCategory.get(category);
|
|
1159
|
+
const totalRules = TOTAL_RULES_PER_CATEGORY[category];
|
|
1160
|
+
catScore.uniqueRuleCount = uniqueRules.size;
|
|
1161
|
+
let densityScore = 100;
|
|
1162
|
+
if (nodeCount > 0 && catScore.issueCount > 0) {
|
|
1163
|
+
const density = catScore.weightedIssueCount / nodeCount;
|
|
1164
|
+
densityScore = clamp(Math.round(100 - density * 100), 0, 100);
|
|
1165
|
+
}
|
|
1166
|
+
catScore.densityScore = densityScore;
|
|
1167
|
+
let diversityScore = 100;
|
|
1168
|
+
if (catScore.issueCount > 0) {
|
|
1169
|
+
const diversityRatio = uniqueRules.size / totalRules;
|
|
1170
|
+
diversityScore = clamp(Math.round((1 - diversityRatio) * 100), 0, 100);
|
|
1171
|
+
}
|
|
1172
|
+
catScore.diversityScore = diversityScore;
|
|
1173
|
+
const combinedScore = densityScore * DENSITY_WEIGHT + diversityScore * DIVERSITY_WEIGHT;
|
|
1174
|
+
catScore.percentage = catScore.issueCount > 0 ? clamp(Math.round(combinedScore), SCORE_FLOOR, 100) : 100;
|
|
1175
|
+
catScore.score = catScore.percentage;
|
|
1176
|
+
catScore.maxScore = 100;
|
|
1177
|
+
}
|
|
1178
|
+
let totalWeight = 0;
|
|
1179
|
+
let weightedSum = 0;
|
|
1180
|
+
for (const category of CATEGORIES) {
|
|
1181
|
+
const weight = CATEGORY_WEIGHT[category];
|
|
1182
|
+
weightedSum += categoryScores[category].percentage * weight;
|
|
1183
|
+
totalWeight += weight;
|
|
1184
|
+
}
|
|
1185
|
+
const overallPercentage = totalWeight > 0 ? Math.round(weightedSum / totalWeight) : 100;
|
|
1186
|
+
const summary = {
|
|
1187
|
+
totalIssues: result.issues.length,
|
|
1188
|
+
blocking: 0,
|
|
1189
|
+
risk: 0,
|
|
1190
|
+
missingInfo: 0,
|
|
1191
|
+
suggestion: 0,
|
|
1192
|
+
nodeCount
|
|
1193
|
+
};
|
|
1194
|
+
for (const issue of result.issues) {
|
|
1195
|
+
switch (issue.config.severity) {
|
|
1196
|
+
case "blocking":
|
|
1197
|
+
summary.blocking++;
|
|
1198
|
+
break;
|
|
1199
|
+
case "risk":
|
|
1200
|
+
summary.risk++;
|
|
1201
|
+
break;
|
|
1202
|
+
case "missing-info":
|
|
1203
|
+
summary.missingInfo++;
|
|
1204
|
+
break;
|
|
1205
|
+
case "suggestion":
|
|
1206
|
+
summary.suggestion++;
|
|
1207
|
+
break;
|
|
1208
|
+
}
|
|
1209
|
+
}
|
|
1210
|
+
return {
|
|
1211
|
+
overall: {
|
|
1212
|
+
score: overallPercentage,
|
|
1213
|
+
maxScore: 100,
|
|
1214
|
+
percentage: overallPercentage,
|
|
1215
|
+
grade: calculateGrade(overallPercentage)
|
|
1216
|
+
},
|
|
1217
|
+
byCategory: categoryScores,
|
|
1218
|
+
summary
|
|
1219
|
+
};
|
|
1220
|
+
}
|
|
1221
|
+
function initializeCategoryScores() {
|
|
1222
|
+
const scores = {};
|
|
1223
|
+
for (const category of CATEGORIES) {
|
|
1224
|
+
scores[category] = {
|
|
1225
|
+
category,
|
|
1226
|
+
score: 100,
|
|
1227
|
+
maxScore: 100,
|
|
1228
|
+
percentage: 100,
|
|
1229
|
+
issueCount: 0,
|
|
1230
|
+
uniqueRuleCount: 0,
|
|
1231
|
+
weightedIssueCount: 0,
|
|
1232
|
+
densityScore: 100,
|
|
1233
|
+
diversityScore: 100,
|
|
1234
|
+
bySeverity: {
|
|
1235
|
+
blocking: 0,
|
|
1236
|
+
risk: 0,
|
|
1237
|
+
"missing-info": 0,
|
|
1238
|
+
suggestion: 0
|
|
1239
|
+
}
|
|
1240
|
+
};
|
|
1241
|
+
}
|
|
1242
|
+
return scores;
|
|
1243
|
+
}
|
|
1244
|
+
function formatScoreSummary(report) {
|
|
1245
|
+
const lines = [];
|
|
1246
|
+
lines.push(`Overall: ${report.overall.grade} (${report.overall.percentage}%)`);
|
|
1247
|
+
lines.push("");
|
|
1248
|
+
lines.push("By Category:");
|
|
1249
|
+
for (const category of CATEGORIES) {
|
|
1250
|
+
const cat = report.byCategory[category];
|
|
1251
|
+
lines.push(` ${category}: ${cat.percentage}% (${cat.issueCount} issues, ${cat.uniqueRuleCount} rules)`);
|
|
1252
|
+
}
|
|
1253
|
+
lines.push("");
|
|
1254
|
+
lines.push("Issues:");
|
|
1255
|
+
lines.push(` Blocking: ${report.summary.blocking}`);
|
|
1256
|
+
lines.push(` Risk: ${report.summary.risk}`);
|
|
1257
|
+
lines.push(` Missing Info: ${report.summary.missingInfo}`);
|
|
1258
|
+
lines.push(` Suggestion: ${report.summary.suggestion}`);
|
|
1259
|
+
lines.push(` Total: ${report.summary.totalIssues}`);
|
|
1260
|
+
return lines.join("\n");
|
|
1261
|
+
}
|
|
1262
|
+
var CustomRuleSchema = z.object({
|
|
1263
|
+
id: z.string(),
|
|
1264
|
+
category: CategorySchema,
|
|
1265
|
+
severity: SeveritySchema,
|
|
1266
|
+
score: z.number().int().max(0),
|
|
1267
|
+
prompt: z.string(),
|
|
1268
|
+
why: z.string(),
|
|
1269
|
+
impact: z.string(),
|
|
1270
|
+
fix: z.string()
|
|
1271
|
+
});
|
|
1272
|
+
var CustomRulesFileSchema = z.array(CustomRuleSchema);
|
|
1273
|
+
|
|
1274
|
+
// src/rules/custom/custom-rule-loader.ts
|
|
1275
|
+
async function loadCustomRules(filePath) {
|
|
1276
|
+
const absPath = resolve(filePath);
|
|
1277
|
+
const raw = await readFile(absPath, "utf-8");
|
|
1278
|
+
const parsed = JSON.parse(raw);
|
|
1279
|
+
const customRules = CustomRulesFileSchema.parse(parsed);
|
|
1280
|
+
const rules = [];
|
|
1281
|
+
const configs = {};
|
|
1282
|
+
for (const cr of customRules) {
|
|
1283
|
+
rules.push(toRule(cr));
|
|
1284
|
+
configs[cr.id] = {
|
|
1285
|
+
severity: cr.severity,
|
|
1286
|
+
score: cr.score,
|
|
1287
|
+
enabled: true
|
|
1288
|
+
};
|
|
1289
|
+
}
|
|
1290
|
+
return { rules, configs };
|
|
1291
|
+
}
|
|
1292
|
+
function toRule(cr) {
|
|
1293
|
+
return {
|
|
1294
|
+
definition: {
|
|
1295
|
+
id: cr.id,
|
|
1296
|
+
name: cr.id.replace(/-/g, " ").replace(/\b\w/g, (c) => c.toUpperCase()),
|
|
1297
|
+
category: cr.category,
|
|
1298
|
+
why: cr.why,
|
|
1299
|
+
impact: cr.impact,
|
|
1300
|
+
fix: cr.fix
|
|
1301
|
+
},
|
|
1302
|
+
check: createPromptBasedCheck()
|
|
1303
|
+
};
|
|
1304
|
+
}
|
|
1305
|
+
function createPromptBasedCheck(_cr) {
|
|
1306
|
+
return (node, _context) => {
|
|
1307
|
+
if (node.type === "DOCUMENT" || node.type === "CANVAS") return null;
|
|
1308
|
+
return null;
|
|
1309
|
+
};
|
|
1310
|
+
}
|
|
1311
|
+
var RuleOverrideSchema = z.object({
|
|
1312
|
+
score: z.number().int().max(0).optional(),
|
|
1313
|
+
severity: SeveritySchema.optional(),
|
|
1314
|
+
enabled: z.boolean().optional()
|
|
1315
|
+
});
|
|
1316
|
+
var ConfigFileSchema = z.object({
|
|
1317
|
+
excludeNodeTypes: z.array(z.string()).optional(),
|
|
1318
|
+
excludeNodeNames: z.array(z.string()).optional(),
|
|
1319
|
+
gridBase: z.number().int().positive().optional(),
|
|
1320
|
+
colorTolerance: z.number().int().positive().optional(),
|
|
1321
|
+
rules: z.record(z.string(), RuleOverrideSchema).optional()
|
|
1322
|
+
});
|
|
1323
|
+
async function loadConfigFile(filePath) {
|
|
1324
|
+
const absPath = resolve(filePath);
|
|
1325
|
+
const raw = await readFile(absPath, "utf-8");
|
|
1326
|
+
const parsed = JSON.parse(raw);
|
|
1327
|
+
return ConfigFileSchema.parse(parsed);
|
|
1328
|
+
}
|
|
1329
|
+
function mergeConfigs(base, overrides) {
|
|
1330
|
+
const merged = { ...base };
|
|
1331
|
+
if (overrides.gridBase !== void 0) {
|
|
1332
|
+
for (const [id, config2] of Object.entries(merged)) {
|
|
1333
|
+
if (config2.options && "gridBase" in config2.options) {
|
|
1334
|
+
merged[id] = {
|
|
1335
|
+
...config2,
|
|
1336
|
+
options: { ...config2.options, gridBase: overrides.gridBase }
|
|
1337
|
+
};
|
|
1338
|
+
}
|
|
1339
|
+
}
|
|
1340
|
+
}
|
|
1341
|
+
if (overrides.colorTolerance !== void 0) {
|
|
1342
|
+
for (const [id, config2] of Object.entries(merged)) {
|
|
1343
|
+
if (config2.options && "tolerance" in config2.options) {
|
|
1344
|
+
merged[id] = {
|
|
1345
|
+
...config2,
|
|
1346
|
+
options: { ...config2.options, tolerance: overrides.colorTolerance }
|
|
1347
|
+
};
|
|
1348
|
+
}
|
|
1349
|
+
}
|
|
1350
|
+
}
|
|
1351
|
+
if (overrides.rules) {
|
|
1352
|
+
for (const [ruleId, override] of Object.entries(overrides.rules)) {
|
|
1353
|
+
const existing = merged[ruleId];
|
|
1354
|
+
if (existing) {
|
|
1355
|
+
merged[ruleId] = {
|
|
1356
|
+
...existing,
|
|
1357
|
+
...override.score !== void 0 && { score: override.score },
|
|
1358
|
+
...override.severity !== void 0 && { severity: override.severity },
|
|
1359
|
+
...override.enabled !== void 0 && { enabled: override.enabled }
|
|
1360
|
+
};
|
|
1361
|
+
}
|
|
1362
|
+
}
|
|
1363
|
+
}
|
|
1364
|
+
return merged;
|
|
1365
|
+
}
|
|
1366
|
+
|
|
1367
|
+
// src/report-html/index.ts
|
|
1368
|
+
var GAUGE_R = 54;
|
|
1369
|
+
var GAUGE_C = Math.round(2 * Math.PI * GAUGE_R);
|
|
1370
|
+
var CATEGORY_DESCRIPTIONS = {
|
|
1371
|
+
layout: "Auto Layout, responsive constraints, nesting depth, absolute positioning",
|
|
1372
|
+
token: "Design token binding for colors, fonts, shadows, spacing grid",
|
|
1373
|
+
component: "Component reuse, detached instances, variant coverage",
|
|
1374
|
+
naming: "Semantic layer names, naming conventions, default names",
|
|
1375
|
+
"ai-readability": "Structure clarity for AI code generation, z-index, empty frames",
|
|
1376
|
+
"handoff-risk": "Hardcoded values, text truncation, image placeholders, dev status"
|
|
1377
|
+
};
|
|
1378
|
+
var SEVERITY_ORDER = ["blocking", "risk", "missing-info", "suggestion"];
|
|
1379
|
+
function gaugeColor(pct) {
|
|
1380
|
+
if (pct >= 75) return "#22c55e";
|
|
1381
|
+
if (pct >= 50) return "#f59e0b";
|
|
1382
|
+
return "#ef4444";
|
|
1383
|
+
}
|
|
1384
|
+
function severityBadge(sev) {
|
|
1385
|
+
const map = {
|
|
1386
|
+
blocking: "bg-red-500/10 text-red-600 border-red-500/20",
|
|
1387
|
+
risk: "bg-amber-500/10 text-amber-600 border-amber-500/20",
|
|
1388
|
+
"missing-info": "bg-zinc-500/10 text-zinc-600 border-zinc-500/20",
|
|
1389
|
+
suggestion: "bg-green-500/10 text-green-600 border-green-500/20"
|
|
1390
|
+
};
|
|
1391
|
+
return map[sev];
|
|
1392
|
+
}
|
|
1393
|
+
function scoreBadgeStyle(pct) {
|
|
1394
|
+
if (pct >= 75) return "bg-green-500/10 text-green-700 border-green-500/20";
|
|
1395
|
+
if (pct >= 50) return "bg-amber-500/10 text-amber-700 border-amber-500/20";
|
|
1396
|
+
return "bg-red-500/10 text-red-700 border-red-500/20";
|
|
1397
|
+
}
|
|
1398
|
+
function severityDot(sev) {
|
|
1399
|
+
const map = {
|
|
1400
|
+
blocking: "bg-red-500",
|
|
1401
|
+
risk: "bg-amber-500",
|
|
1402
|
+
"missing-info": "bg-zinc-400",
|
|
1403
|
+
suggestion: "bg-green-500"
|
|
1404
|
+
};
|
|
1405
|
+
return map[sev];
|
|
1406
|
+
}
|
|
1407
|
+
function generateHtmlReport(file, result, scores, options) {
|
|
1408
|
+
const screenshotMap = new Map(
|
|
1409
|
+
(options?.nodeScreenshots ?? []).map((ns) => [ns.nodeId, ns])
|
|
1410
|
+
);
|
|
1411
|
+
const figmaToken = options?.figmaToken;
|
|
1412
|
+
const quickWins = getQuickWins(result.issues, 5);
|
|
1413
|
+
const issuesByCategory = groupIssuesByCategory(result.issues);
|
|
1414
|
+
return `<!DOCTYPE html>
|
|
1415
|
+
<html lang="en" class="antialiased">
|
|
1416
|
+
<head>
|
|
1417
|
+
<meta charset="UTF-8">
|
|
1418
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
1419
|
+
<title>CanICode Report \u2014 ${esc(file.name)}</title>
|
|
1420
|
+
<script src="https://cdn.tailwindcss.com"></script>
|
|
1421
|
+
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
1422
|
+
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
1423
|
+
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
|
|
1424
|
+
<script>
|
|
1425
|
+
tailwind.config = {
|
|
1426
|
+
theme: {
|
|
1427
|
+
extend: {
|
|
1428
|
+
fontFamily: { sans: ['Inter', 'ui-sans-serif', 'system-ui', '-apple-system', 'sans-serif'] },
|
|
1429
|
+
colors: {
|
|
1430
|
+
border: 'hsl(240 5.9% 90%)',
|
|
1431
|
+
ring: 'hsl(240 5.9% 10%)',
|
|
1432
|
+
background: 'hsl(0 0% 100%)',
|
|
1433
|
+
foreground: 'hsl(240 10% 3.9%)',
|
|
1434
|
+
muted: { DEFAULT: 'hsl(240 4.8% 95.9%)', foreground: 'hsl(240 3.8% 46.1%)' },
|
|
1435
|
+
card: { DEFAULT: 'hsl(0 0% 100%)', foreground: 'hsl(240 10% 3.9%)' },
|
|
1436
|
+
},
|
|
1437
|
+
borderRadius: { lg: '0.5rem', md: 'calc(0.5rem - 2px)', sm: 'calc(0.5rem - 4px)' },
|
|
1438
|
+
}
|
|
1439
|
+
}
|
|
1440
|
+
}
|
|
1441
|
+
</script>
|
|
1442
|
+
<style>
|
|
1443
|
+
details summary::-webkit-details-marker { display: none; }
|
|
1444
|
+
details summary::marker { content: ""; }
|
|
1445
|
+
details summary { list-style: none; }
|
|
1446
|
+
.gauge-fill { transition: stroke-dashoffset 0.8s cubic-bezier(0.4,0,0.2,1); }
|
|
1447
|
+
@media print {
|
|
1448
|
+
.no-print { display: none !important; }
|
|
1449
|
+
.topbar-print { position: static !important; background: white !important; color: hsl(240 10% 3.9%) !important; }
|
|
1450
|
+
}
|
|
1451
|
+
</style>
|
|
1452
|
+
</head>
|
|
1453
|
+
<body class="bg-muted font-sans text-foreground min-h-screen">
|
|
1454
|
+
|
|
1455
|
+
<!-- Top Bar -->
|
|
1456
|
+
<header class="topbar-print sticky top-0 z-50 bg-zinc-950 text-white border-b border-zinc-800">
|
|
1457
|
+
<div class="max-w-[960px] mx-auto px-6 py-3 flex items-center gap-4">
|
|
1458
|
+
<span class="font-semibold text-sm tracking-tight">CanICode</span>
|
|
1459
|
+
<span class="text-zinc-400 text-sm truncate">${esc(file.name)}</span>
|
|
1460
|
+
<span class="ml-auto text-zinc-500 text-xs no-print">${(/* @__PURE__ */ new Date()).toLocaleDateString()}</span>
|
|
1461
|
+
</div>
|
|
1462
|
+
</header>
|
|
1463
|
+
|
|
1464
|
+
<main class="max-w-[960px] mx-auto px-6 pb-16">
|
|
1465
|
+
|
|
1466
|
+
<!-- Overall Score -->
|
|
1467
|
+
<section class="flex flex-col items-center pt-12 pb-6">
|
|
1468
|
+
${renderGaugeSvg(scores.overall.percentage, 200, 10, scores.overall.grade)}
|
|
1469
|
+
<div class="mt-3 text-center">
|
|
1470
|
+
<span class="text-lg font-semibold">${scores.overall.percentage}</span>
|
|
1471
|
+
<span class="text-muted-foreground text-sm ml-1">/ 100</span>
|
|
1472
|
+
</div>
|
|
1473
|
+
<p class="text-muted-foreground text-sm mt-1">Overall Score</p>
|
|
1474
|
+
</section>
|
|
1475
|
+
|
|
1476
|
+
<!-- Category Gauges -->
|
|
1477
|
+
<section class="bg-card border border-border rounded-lg shadow-sm p-6 mb-6">
|
|
1478
|
+
<div class="grid grid-cols-3 sm:grid-cols-6 gap-4">
|
|
1479
|
+
${CATEGORIES.map((cat) => {
|
|
1480
|
+
const cs = scores.byCategory[cat];
|
|
1481
|
+
const desc = CATEGORY_DESCRIPTIONS[cat];
|
|
1482
|
+
return ` <div class="flex flex-col items-center group relative">
|
|
1483
|
+
${renderGaugeSvg(cs.percentage, 100, 7)}
|
|
1484
|
+
<span class="text-xs font-medium mt-2.5 text-center leading-tight">${CATEGORY_LABELS[cat]}</span>
|
|
1485
|
+
<span class="text-[11px] text-muted-foreground">${cs.issueCount} issues</span>
|
|
1486
|
+
<div class="absolute bottom-full mb-2 left-1/2 -translate-x-1/2 hidden group-hover:block bg-zinc-900 text-white text-xs px-3 py-2 rounded-md whitespace-nowrap z-10 shadow-lg pointer-events-none">
|
|
1487
|
+
${esc(desc)}
|
|
1488
|
+
<div class="absolute top-full left-1/2 -translate-x-1/2 border-4 border-transparent border-t-zinc-900"></div>
|
|
1489
|
+
</div>
|
|
1490
|
+
</div>`;
|
|
1491
|
+
}).join("\n")}
|
|
1492
|
+
</div>
|
|
1493
|
+
</section>
|
|
1494
|
+
|
|
1495
|
+
<!-- Issue Summary -->
|
|
1496
|
+
<section class="bg-card border border-border rounded-lg shadow-sm p-4 mb-6">
|
|
1497
|
+
<div class="flex flex-wrap items-center justify-center gap-6">
|
|
1498
|
+
${renderSummaryDot("bg-red-500", scores.summary.blocking, "Blocking")}
|
|
1499
|
+
${renderSummaryDot("bg-amber-500", scores.summary.risk, "Risk")}
|
|
1500
|
+
${renderSummaryDot("bg-zinc-400", scores.summary.missingInfo, "Missing Info")}
|
|
1501
|
+
${renderSummaryDot("bg-green-500", scores.summary.suggestion, "Suggestion")}
|
|
1502
|
+
<div class="border-l border-border pl-6 flex items-center gap-2">
|
|
1503
|
+
<span class="text-xl font-bold tracking-tight">${scores.summary.totalIssues}</span>
|
|
1504
|
+
<span class="text-sm text-muted-foreground">Total</span>
|
|
1505
|
+
</div>
|
|
1506
|
+
</div>
|
|
1507
|
+
</section>
|
|
1508
|
+
|
|
1509
|
+
${quickWins.length > 0 ? renderOpportunities(quickWins, file.fileKey) : ""}
|
|
1510
|
+
|
|
1511
|
+
<!-- Categories -->
|
|
1512
|
+
<div class="space-y-3">
|
|
1513
|
+
${CATEGORIES.map((cat) => renderCategory(cat, scores, issuesByCategory.get(cat) ?? [], file.fileKey, screenshotMap, figmaToken)).join("\n")}
|
|
1514
|
+
</div>
|
|
1515
|
+
|
|
1516
|
+
<!-- Footer -->
|
|
1517
|
+
<footer class="mt-12 pt-6 border-t border-border text-center">
|
|
1518
|
+
<p class="text-sm text-muted-foreground">Generated by <span class="font-semibold text-foreground">CanICode</span></p>
|
|
1519
|
+
<p class="text-xs text-muted-foreground/60 mt-1">${(/* @__PURE__ */ new Date()).toLocaleString()} \xB7 ${result.nodeCount} nodes \xB7 Max depth ${result.maxDepth}</p>
|
|
1520
|
+
</footer>
|
|
1521
|
+
|
|
1522
|
+
</main>
|
|
1523
|
+
|
|
1524
|
+
${figmaToken ? ` <script>
|
|
1525
|
+
const FIGMA_TOKEN = '${figmaToken}';
|
|
1526
|
+
async function postComment(btn) {
|
|
1527
|
+
const fileKey = btn.dataset.fileKey;
|
|
1528
|
+
const nodeId = btn.dataset.nodeId.replace(/-/g, ':');
|
|
1529
|
+
const rule = btn.dataset.rule;
|
|
1530
|
+
const message = btn.dataset.message;
|
|
1531
|
+
const path = btn.dataset.path;
|
|
1532
|
+
const fix = btn.dataset.fix;
|
|
1533
|
+
const why = btn.dataset.why;
|
|
1534
|
+
const impact = btn.dataset.impact;
|
|
1535
|
+
|
|
1536
|
+
const commentBody = '[CanICode] ' + rule + '\\n\\nFix: ' + fix + '\\nWhy: ' + why + '\\nImpact: ' + impact + '\\n\\n' + message + '\\nNode: ' + path;
|
|
1537
|
+
|
|
1538
|
+
btn.disabled = true;
|
|
1539
|
+
btn.textContent = 'Sending...';
|
|
1540
|
+
|
|
1541
|
+
try {
|
|
1542
|
+
const res = await fetch('https://api.figma.com/v1/files/' + fileKey + '/comments', {
|
|
1543
|
+
method: 'POST',
|
|
1544
|
+
headers: { 'X-FIGMA-TOKEN': FIGMA_TOKEN, 'Content-Type': 'application/json' },
|
|
1545
|
+
body: JSON.stringify({ message: commentBody, client_meta: { node_id: nodeId } }),
|
|
1546
|
+
});
|
|
1547
|
+
if (!res.ok) throw new Error(await res.text());
|
|
1548
|
+
btn.textContent = 'Sent \\u2713';
|
|
1549
|
+
btn.classList.remove('hover:bg-muted');
|
|
1550
|
+
btn.classList.add('text-green-600', 'border-green-500/30');
|
|
1551
|
+
} catch (e) {
|
|
1552
|
+
btn.textContent = 'Failed \\u2717';
|
|
1553
|
+
btn.classList.remove('hover:bg-muted');
|
|
1554
|
+
btn.classList.add('text-red-600', 'border-red-500/30');
|
|
1555
|
+
btn.disabled = false;
|
|
1556
|
+
console.error('Failed to post Figma comment:', e);
|
|
1557
|
+
}
|
|
1558
|
+
}
|
|
1559
|
+
</script>` : ""}
|
|
1560
|
+
</body>
|
|
1561
|
+
</html>`;
|
|
1562
|
+
}
|
|
1563
|
+
function renderGaugeSvg(pct, size, strokeW, grade) {
|
|
1564
|
+
const offset = GAUGE_C * (1 - pct / 100);
|
|
1565
|
+
const color = gaugeColor(pct);
|
|
1566
|
+
if (grade) {
|
|
1567
|
+
return `<svg width="${size}" height="${size}" viewBox="0 0 120 120" class="block">
|
|
1568
|
+
<circle cx="60" cy="60" r="${GAUGE_R}" fill="none" stroke-width="${strokeW}" class="stroke-border" />
|
|
1569
|
+
<circle cx="60" cy="60" r="${GAUGE_R}" fill="none" stroke="${color}" stroke-width="${strokeW}" stroke-linecap="round" stroke-dasharray="${GAUGE_C}" stroke-dashoffset="${offset}" transform="rotate(-90 60 60)" class="gauge-fill" />
|
|
1570
|
+
<text x="60" y="60" text-anchor="middle" dominant-baseline="central" fill="currentColor" font-size="52" font-weight="700" class="font-sans">${esc(grade)}</text>
|
|
1571
|
+
</svg>`;
|
|
1572
|
+
}
|
|
1573
|
+
const fontSize = 32;
|
|
1574
|
+
return `<svg width="${size}" height="${size}" viewBox="0 0 120 120" class="block">
|
|
1575
|
+
<circle cx="60" cy="60" r="${GAUGE_R}" fill="none" stroke-width="${strokeW}" class="stroke-border" />
|
|
1576
|
+
<circle cx="60" cy="60" r="${GAUGE_R}" fill="none" stroke="${color}" stroke-width="${strokeW}" stroke-linecap="round" stroke-dasharray="${GAUGE_C}" stroke-dashoffset="${offset}" transform="rotate(-90 60 60)" class="gauge-fill" />
|
|
1577
|
+
<text x="60" y="62" text-anchor="middle" dominant-baseline="central" fill="currentColor" font-size="${fontSize}" font-weight="700" class="font-sans">${pct}</text>
|
|
1578
|
+
</svg>`;
|
|
1579
|
+
}
|
|
1580
|
+
function renderSummaryDot(dotClass, count, label) {
|
|
1581
|
+
return `<div class="flex items-center gap-2">
|
|
1582
|
+
<span class="w-2.5 h-2.5 rounded-full ${dotClass}"></span>
|
|
1583
|
+
<span class="text-lg font-bold tracking-tight">${count}</span>
|
|
1584
|
+
<span class="text-sm text-muted-foreground">${label}</span>
|
|
1585
|
+
</div>`;
|
|
1586
|
+
}
|
|
1587
|
+
function renderOpportunities(issues, fileKey) {
|
|
1588
|
+
const maxAbs = issues.reduce((m, i) => Math.max(m, Math.abs(i.calculatedScore)), 1);
|
|
1589
|
+
return `
|
|
1590
|
+
<!-- Opportunities -->
|
|
1591
|
+
<section class="bg-card border border-border rounded-lg shadow-sm mb-6 overflow-hidden">
|
|
1592
|
+
<div class="px-6 py-4 border-b border-border">
|
|
1593
|
+
<h2 class="text-sm font-semibold flex items-center gap-2">
|
|
1594
|
+
<span class="w-2 h-2 rounded-full bg-red-500"></span>
|
|
1595
|
+
Opportunities
|
|
1596
|
+
</h2>
|
|
1597
|
+
<p class="text-xs text-muted-foreground mt-1">Top blocking issues \u2014 fix these first for the biggest improvement.</p>
|
|
1598
|
+
</div>
|
|
1599
|
+
<div class="divide-y divide-border">
|
|
1600
|
+
${issues.map((issue) => {
|
|
1601
|
+
const def = issue.rule.definition;
|
|
1602
|
+
const link = buildFigmaDeepLink(fileKey, issue.violation.nodeId);
|
|
1603
|
+
const barW = Math.round(Math.abs(issue.calculatedScore) / maxAbs * 100);
|
|
1604
|
+
return ` <div class="px-6 py-3 flex items-center gap-4 hover:bg-muted/50 transition-colors">
|
|
1605
|
+
<div class="flex-1 min-w-0">
|
|
1606
|
+
<div class="text-sm font-medium truncate">${esc(def.name)}</div>
|
|
1607
|
+
<div class="text-xs text-muted-foreground truncate mt-0.5">${esc(issue.violation.message)}</div>
|
|
1608
|
+
</div>
|
|
1609
|
+
<div class="w-32 flex items-center gap-2 shrink-0">
|
|
1610
|
+
<div class="flex-1 h-1.5 bg-muted rounded-full overflow-hidden">
|
|
1611
|
+
<div class="h-full bg-red-500 rounded-full" style="width:${barW}%"></div>
|
|
1612
|
+
</div>
|
|
1613
|
+
<span class="text-xs font-medium text-red-600 w-12 text-right">${issue.calculatedScore}</span>
|
|
1614
|
+
</div>
|
|
1615
|
+
<a href="${link}" target="_blank" rel="noopener" class="text-xs text-muted-foreground hover:text-foreground shrink-0 no-print">Figma \u2192</a>
|
|
1616
|
+
</div>`;
|
|
1617
|
+
}).join("\n")}
|
|
1618
|
+
</div>
|
|
1619
|
+
</section>`;
|
|
1620
|
+
}
|
|
1621
|
+
function renderCategory(cat, scores, issues, fileKey, screenshotMap, figmaToken) {
|
|
1622
|
+
const cs = scores.byCategory[cat];
|
|
1623
|
+
const hasProblems = issues.some((i) => i.config.severity === "blocking" || i.config.severity === "risk");
|
|
1624
|
+
const bySeverity = /* @__PURE__ */ new Map();
|
|
1625
|
+
for (const sev of SEVERITY_ORDER) bySeverity.set(sev, []);
|
|
1626
|
+
for (const issue of issues) bySeverity.get(issue.config.severity)?.push(issue);
|
|
1627
|
+
return `
|
|
1628
|
+
<details class="bg-card border border-border rounded-lg shadow-sm overflow-hidden group"${hasProblems ? " open" : ""}>
|
|
1629
|
+
<summary class="px-5 py-3.5 flex items-center gap-3 cursor-pointer hover:bg-muted/50 transition-colors select-none">
|
|
1630
|
+
<span class="inline-flex items-center justify-center w-10 h-6 rounded-md text-xs font-bold border ${scoreBadgeStyle(cs.percentage)}">${cs.percentage}</span>
|
|
1631
|
+
<div class="flex-1 min-w-0">
|
|
1632
|
+
<div class="text-sm font-semibold">${CATEGORY_LABELS[cat]}</div>
|
|
1633
|
+
<div class="text-xs text-muted-foreground">${esc(CATEGORY_DESCRIPTIONS[cat])}</div>
|
|
1634
|
+
</div>
|
|
1635
|
+
<span class="text-xs text-muted-foreground">${cs.issueCount} issues</span>
|
|
1636
|
+
<svg class="w-4 h-4 text-muted-foreground transition-transform group-open:rotate-180 shrink-0 no-print" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path d="M19 9l-7 7-7-7"/></svg>
|
|
1637
|
+
</summary>
|
|
1638
|
+
<div class="border-t border-border">
|
|
1639
|
+
${issues.length === 0 ? ' <div class="px-5 py-4 text-sm text-green-600 font-medium">No issues found</div>' : SEVERITY_ORDER.filter((sev) => (bySeverity.get(sev)?.length ?? 0) > 0).map((sev) => renderSeverityGroup(sev, bySeverity.get(sev) ?? [], fileKey, screenshotMap, figmaToken)).join("\n")}
|
|
1640
|
+
</div>
|
|
1641
|
+
</details>`;
|
|
1642
|
+
}
|
|
1643
|
+
function renderSeverityGroup(sev, issues, fileKey, screenshotMap, figmaToken) {
|
|
1644
|
+
return ` <div class="px-5 py-3">
|
|
1645
|
+
<div class="flex items-center gap-2 mb-2">
|
|
1646
|
+
<span class="w-2 h-2 rounded-full ${severityDot(sev)}"></span>
|
|
1647
|
+
<span class="text-xs font-semibold uppercase tracking-wider">${SEVERITY_LABELS[sev]}</span>
|
|
1648
|
+
<span class="text-xs text-muted-foreground ml-auto">${issues.length}</span>
|
|
1649
|
+
</div>
|
|
1650
|
+
<div class="space-y-1">
|
|
1651
|
+
${issues.map((issue) => renderIssueRow(issue, fileKey, screenshotMap, figmaToken)).join("\n")}
|
|
1652
|
+
</div>
|
|
1653
|
+
</div>`;
|
|
1654
|
+
}
|
|
1655
|
+
function renderIssueRow(issue, fileKey, screenshotMap, figmaToken) {
|
|
1656
|
+
const sev = issue.config.severity;
|
|
1657
|
+
const def = issue.rule.definition;
|
|
1658
|
+
const link = buildFigmaDeepLink(fileKey, issue.violation.nodeId);
|
|
1659
|
+
const screenshot = screenshotMap.get(issue.violation.nodeId);
|
|
1660
|
+
const screenshotHtml = screenshot ? `<div class="mt-3"><a href="${link}" target="_blank" rel="noopener"><img src="data:image/png;base64,${screenshot.screenshotBase64}" alt="${esc(screenshot.nodePath)}" class="max-w-[240px] border border-border rounded-md"></a></div>` : "";
|
|
1661
|
+
return ` <details class="border border-border rounded-md overflow-hidden">
|
|
1662
|
+
<summary class="flex items-center gap-2.5 px-3 py-2 text-sm cursor-pointer hover:bg-muted/50 transition-colors">
|
|
1663
|
+
<span class="w-1.5 h-1.5 rounded-full ${severityDot(sev)} shrink-0"></span>
|
|
1664
|
+
<span class="font-medium shrink-0">${esc(def.name)}</span>
|
|
1665
|
+
<span class="text-muted-foreground truncate text-xs flex-1">${esc(issue.violation.message)}</span>
|
|
1666
|
+
<span class="inline-flex items-center px-1.5 py-0.5 text-[10px] font-medium rounded border ${severityBadge(sev)} shrink-0">${issue.calculatedScore}</span>
|
|
1667
|
+
</summary>
|
|
1668
|
+
<div class="px-3 py-3 bg-muted/30 border-t border-border text-sm space-y-2">
|
|
1669
|
+
<div class="font-mono text-xs text-muted-foreground break-all">${esc(issue.violation.nodePath)}</div>
|
|
1670
|
+
<div class="text-muted-foreground leading-relaxed space-y-1">
|
|
1671
|
+
<p><span class="font-medium text-foreground">Why:</span> ${esc(def.why)}</p>
|
|
1672
|
+
<p><span class="font-medium text-foreground">Impact:</span> ${esc(def.impact)}</p>
|
|
1673
|
+
<p><span class="font-medium text-foreground">Fix:</span> ${esc(def.fix)}</p>
|
|
1674
|
+
</div>${screenshotHtml}
|
|
1675
|
+
<div class="flex items-center gap-2 mt-1 no-print">
|
|
1676
|
+
<a href="${link}" target="_blank" rel="noopener" class="inline-flex items-center gap-1 px-2.5 py-1 text-xs font-medium border border-border rounded-md hover:bg-muted transition-colors">Open in Figma <span>\u2192</span></a>${figmaToken ? `
|
|
1677
|
+
<button onclick="postComment(this)" data-file-key="${esc(fileKey)}" data-node-id="${esc(issue.violation.nodeId)}" data-rule="${esc(def.name)}" data-message="${esc(issue.violation.message)}" data-path="${esc(issue.violation.nodePath)}" data-fix="${esc(def.fix)}" data-why="${esc(def.why)}" data-impact="${esc(def.impact)}" class="inline-flex items-center gap-1 px-2.5 py-1 text-xs font-medium border border-border rounded-md hover:bg-muted transition-colors cursor-pointer">Comment on Figma</button>` : ""}
|
|
1678
|
+
</div>
|
|
1679
|
+
</div>
|
|
1680
|
+
</details>`;
|
|
1681
|
+
}
|
|
1682
|
+
function getQuickWins(issues, limit) {
|
|
1683
|
+
return issues.filter((issue) => issue.config.severity === "blocking").sort((a, b) => a.calculatedScore - b.calculatedScore).slice(0, limit);
|
|
1684
|
+
}
|
|
1685
|
+
function groupIssuesByCategory(issues) {
|
|
1686
|
+
const grouped = /* @__PURE__ */ new Map();
|
|
1687
|
+
for (const category of CATEGORIES) grouped.set(category, []);
|
|
1688
|
+
for (const issue of issues) grouped.get(issue.rule.definition.category).push(issue);
|
|
1689
|
+
return grouped;
|
|
1690
|
+
}
|
|
1691
|
+
function esc(text) {
|
|
1692
|
+
return text.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
1693
|
+
}
|
|
1694
|
+
var SamplingStrategySchema = z.enum(["all", "top-issues", "random"]);
|
|
1695
|
+
z.enum([
|
|
1696
|
+
"pending",
|
|
1697
|
+
"analyzing",
|
|
1698
|
+
"converting",
|
|
1699
|
+
"evaluating",
|
|
1700
|
+
"tuning",
|
|
1701
|
+
"completed",
|
|
1702
|
+
"failed"
|
|
1703
|
+
]);
|
|
1704
|
+
var CalibrationConfigSchema = z.object({
|
|
1705
|
+
input: z.string(),
|
|
1706
|
+
token: z.string().optional(),
|
|
1707
|
+
targetNodeId: z.string().optional(),
|
|
1708
|
+
maxConversionNodes: z.number().int().positive().default(20),
|
|
1709
|
+
samplingStrategy: SamplingStrategySchema.default("top-issues"),
|
|
1710
|
+
outputPath: z.string().default("logs/calibration/calibration-report.md")
|
|
1711
|
+
});
|
|
1712
|
+
|
|
1713
|
+
// src/agents/analysis-agent.ts
|
|
1714
|
+
function buildNodeIssueSummaries(result) {
|
|
1715
|
+
const nodeMap = /* @__PURE__ */ new Map();
|
|
1716
|
+
for (const issue of result.issues) {
|
|
1717
|
+
const nodeId = issue.violation.nodeId;
|
|
1718
|
+
const existing = nodeMap.get(nodeId);
|
|
1719
|
+
if (existing) {
|
|
1720
|
+
existing.totalScore += issue.calculatedScore;
|
|
1721
|
+
existing.issueCount++;
|
|
1722
|
+
existing.ruleIds.add(issue.rule.definition.id);
|
|
1723
|
+
existing.severities.add(issue.config.severity);
|
|
1724
|
+
} else {
|
|
1725
|
+
nodeMap.set(nodeId, {
|
|
1726
|
+
nodePath: issue.violation.nodePath,
|
|
1727
|
+
totalScore: issue.calculatedScore,
|
|
1728
|
+
issueCount: 1,
|
|
1729
|
+
ruleIds: /* @__PURE__ */ new Set([issue.rule.definition.id]),
|
|
1730
|
+
severities: /* @__PURE__ */ new Set([issue.config.severity])
|
|
1731
|
+
});
|
|
1732
|
+
}
|
|
1733
|
+
}
|
|
1734
|
+
const summaries = [];
|
|
1735
|
+
for (const [nodeId, data] of nodeMap) {
|
|
1736
|
+
summaries.push({
|
|
1737
|
+
nodeId,
|
|
1738
|
+
nodePath: data.nodePath,
|
|
1739
|
+
totalScore: data.totalScore,
|
|
1740
|
+
issueCount: data.issueCount,
|
|
1741
|
+
flaggedRuleIds: [...data.ruleIds],
|
|
1742
|
+
severities: [...data.severities]
|
|
1743
|
+
});
|
|
1744
|
+
}
|
|
1745
|
+
summaries.sort((a, b) => a.totalScore - b.totalScore);
|
|
1746
|
+
return summaries;
|
|
1747
|
+
}
|
|
1748
|
+
function extractRuleScores(result) {
|
|
1749
|
+
const scores = {};
|
|
1750
|
+
for (const issue of result.issues) {
|
|
1751
|
+
const ruleId = issue.rule.definition.id;
|
|
1752
|
+
if (!scores[ruleId]) {
|
|
1753
|
+
scores[ruleId] = {
|
|
1754
|
+
score: issue.config.score,
|
|
1755
|
+
severity: issue.config.severity
|
|
1756
|
+
};
|
|
1757
|
+
}
|
|
1758
|
+
}
|
|
1759
|
+
return scores;
|
|
1760
|
+
}
|
|
1761
|
+
function runAnalysisAgent(input) {
|
|
1762
|
+
const { analysisResult } = input;
|
|
1763
|
+
const scoreReport = calculateScores(analysisResult);
|
|
1764
|
+
const nodeIssueSummaries = buildNodeIssueSummaries(analysisResult);
|
|
1765
|
+
return {
|
|
1766
|
+
analysisResult,
|
|
1767
|
+
scoreReport,
|
|
1768
|
+
nodeIssueSummaries
|
|
1769
|
+
};
|
|
1770
|
+
}
|
|
1771
|
+
|
|
1772
|
+
// src/agents/conversion-agent.ts
|
|
1773
|
+
async function runConversionAgent(input, executor) {
|
|
1774
|
+
const records = [];
|
|
1775
|
+
const skippedNodeIds = [];
|
|
1776
|
+
for (const node of input.nodes) {
|
|
1777
|
+
const startTime = Date.now();
|
|
1778
|
+
try {
|
|
1779
|
+
const result = await executor(
|
|
1780
|
+
node.nodeId,
|
|
1781
|
+
input.fileKey,
|
|
1782
|
+
node.flaggedRuleIds
|
|
1783
|
+
);
|
|
1784
|
+
const durationMs = Date.now() - startTime;
|
|
1785
|
+
records.push({
|
|
1786
|
+
nodeId: node.nodeId,
|
|
1787
|
+
nodePath: node.nodePath,
|
|
1788
|
+
generatedCode: result.generatedCode,
|
|
1789
|
+
difficulty: result.difficulty,
|
|
1790
|
+
notes: result.notes,
|
|
1791
|
+
ruleRelatedStruggles: result.ruleRelatedStruggles,
|
|
1792
|
+
uncoveredStruggles: result.uncoveredStruggles,
|
|
1793
|
+
durationMs
|
|
1794
|
+
});
|
|
1795
|
+
} catch {
|
|
1796
|
+
skippedNodeIds.push(node.nodeId);
|
|
1797
|
+
}
|
|
1798
|
+
}
|
|
1799
|
+
return { records, skippedNodeIds };
|
|
1800
|
+
}
|
|
1801
|
+
|
|
1802
|
+
// src/agents/evaluation-agent.ts
|
|
1803
|
+
var DIFFICULTY_SCORE_RANGES = {
|
|
1804
|
+
easy: { min: -3, max: 0 },
|
|
1805
|
+
moderate: { min: -7, max: -4 },
|
|
1806
|
+
hard: { min: -12, max: -8 },
|
|
1807
|
+
failed: { min: -12, max: -8 }
|
|
1808
|
+
};
|
|
1809
|
+
function scoreMatchesDifficulty(score, difficulty) {
|
|
1810
|
+
const range = DIFFICULTY_SCORE_RANGES[difficulty];
|
|
1811
|
+
return score >= range.min && score <= range.max;
|
|
1812
|
+
}
|
|
1813
|
+
function classifyFlaggedRule(currentScore, actualDifficulty) {
|
|
1814
|
+
if (scoreMatchesDifficulty(currentScore, actualDifficulty)) {
|
|
1815
|
+
return "validated";
|
|
1816
|
+
}
|
|
1817
|
+
if (actualDifficulty === "easy" && currentScore < -3 || actualDifficulty === "moderate" && currentScore < -7) {
|
|
1818
|
+
return "overscored";
|
|
1819
|
+
}
|
|
1820
|
+
return "underscored";
|
|
1821
|
+
}
|
|
1822
|
+
function buildReasoning(type, ruleId, currentScore, actualDifficulty) {
|
|
1823
|
+
const range = DIFFICULTY_SCORE_RANGES[actualDifficulty];
|
|
1824
|
+
switch (type) {
|
|
1825
|
+
case "validated":
|
|
1826
|
+
return `Rule "${ruleId}" score (${currentScore}) aligns with actual difficulty "${actualDifficulty}" (expected range: ${range.min} to ${range.max}).`;
|
|
1827
|
+
case "overscored":
|
|
1828
|
+
return `Rule "${ruleId}" score (${currentScore}) is too harsh for actual difficulty "${actualDifficulty}" (expected range: ${range.min} to ${range.max}).`;
|
|
1829
|
+
case "underscored":
|
|
1830
|
+
return `Rule "${ruleId}" score (${currentScore}) is too lenient for actual difficulty "${actualDifficulty}" (expected range: ${range.min} to ${range.max}).`;
|
|
1831
|
+
case "missing-rule":
|
|
1832
|
+
return `No rule covers this difficulty "${actualDifficulty}" (expected score range: ${range.min} to ${range.max}).`;
|
|
1833
|
+
}
|
|
1834
|
+
}
|
|
1835
|
+
function runEvaluationAgent(input) {
|
|
1836
|
+
const mismatches = [];
|
|
1837
|
+
const validatedRuleSet = /* @__PURE__ */ new Set();
|
|
1838
|
+
const nodeSummaryMap = new Map(
|
|
1839
|
+
input.nodeIssueSummaries.map((s) => [s.nodeId, s])
|
|
1840
|
+
);
|
|
1841
|
+
for (const record of input.conversionRecords) {
|
|
1842
|
+
const summary = nodeSummaryMap.get(record.nodeId);
|
|
1843
|
+
const difficulty = record.difficulty;
|
|
1844
|
+
for (const struggle of record.ruleRelatedStruggles) {
|
|
1845
|
+
const ruleInfo = input.ruleScores[struggle.ruleId];
|
|
1846
|
+
if (!ruleInfo) continue;
|
|
1847
|
+
const actualDifficulty = struggle.actualImpact;
|
|
1848
|
+
const type = classifyFlaggedRule(ruleInfo.score, actualDifficulty);
|
|
1849
|
+
if (type === "validated") {
|
|
1850
|
+
validatedRuleSet.add(struggle.ruleId);
|
|
1851
|
+
}
|
|
1852
|
+
mismatches.push({
|
|
1853
|
+
type,
|
|
1854
|
+
nodeId: record.nodeId,
|
|
1855
|
+
nodePath: record.nodePath,
|
|
1856
|
+
ruleId: struggle.ruleId,
|
|
1857
|
+
currentScore: ruleInfo.score,
|
|
1858
|
+
currentSeverity: ruleInfo.severity,
|
|
1859
|
+
actualDifficulty,
|
|
1860
|
+
reasoning: buildReasoning(type, struggle.ruleId, ruleInfo.score, actualDifficulty)
|
|
1861
|
+
});
|
|
1862
|
+
}
|
|
1863
|
+
if (summary) {
|
|
1864
|
+
const struggledRuleIds = new Set(
|
|
1865
|
+
record.ruleRelatedStruggles.map((s) => s.ruleId)
|
|
1866
|
+
);
|
|
1867
|
+
for (const ruleId of summary.flaggedRuleIds) {
|
|
1868
|
+
if (struggledRuleIds.has(ruleId)) continue;
|
|
1869
|
+
const ruleInfo = input.ruleScores[ruleId];
|
|
1870
|
+
if (!ruleInfo) continue;
|
|
1871
|
+
if (difficulty === "easy" || difficulty === "moderate") {
|
|
1872
|
+
mismatches.push({
|
|
1873
|
+
type: "overscored",
|
|
1874
|
+
nodeId: record.nodeId,
|
|
1875
|
+
nodePath: record.nodePath,
|
|
1876
|
+
ruleId,
|
|
1877
|
+
currentScore: ruleInfo.score,
|
|
1878
|
+
currentSeverity: ruleInfo.severity,
|
|
1879
|
+
actualDifficulty: "easy",
|
|
1880
|
+
reasoning: `Rule "${ruleId}" was flagged but caused no struggle during conversion (overall node difficulty: "${difficulty}").`
|
|
1881
|
+
});
|
|
1882
|
+
} else {
|
|
1883
|
+
validatedRuleSet.add(ruleId);
|
|
1884
|
+
mismatches.push({
|
|
1885
|
+
type: "validated",
|
|
1886
|
+
nodeId: record.nodeId,
|
|
1887
|
+
nodePath: record.nodePath,
|
|
1888
|
+
ruleId,
|
|
1889
|
+
currentScore: ruleInfo.score,
|
|
1890
|
+
currentSeverity: ruleInfo.severity,
|
|
1891
|
+
actualDifficulty: difficulty,
|
|
1892
|
+
reasoning: `Rule "${ruleId}" was flagged and overall conversion was "${difficulty}" \u2014 conservatively validated.`
|
|
1893
|
+
});
|
|
1894
|
+
}
|
|
1895
|
+
}
|
|
1896
|
+
}
|
|
1897
|
+
for (const uncovered of record.uncoveredStruggles) {
|
|
1898
|
+
const estimatedDifficulty = uncovered.estimatedImpact;
|
|
1899
|
+
mismatches.push({
|
|
1900
|
+
type: "missing-rule",
|
|
1901
|
+
nodeId: record.nodeId,
|
|
1902
|
+
nodePath: record.nodePath,
|
|
1903
|
+
actualDifficulty: estimatedDifficulty,
|
|
1904
|
+
reasoning: `Uncovered struggle: "${uncovered.description}" (category: ${uncovered.suggestedCategory}, impact: ${estimatedDifficulty}).`
|
|
1905
|
+
});
|
|
1906
|
+
}
|
|
1907
|
+
}
|
|
1908
|
+
return {
|
|
1909
|
+
mismatches,
|
|
1910
|
+
validatedRules: [...validatedRuleSet]
|
|
1911
|
+
};
|
|
1912
|
+
}
|
|
1913
|
+
|
|
1914
|
+
// src/agents/tuning-agent.ts
|
|
1915
|
+
var DIFFICULTY_MIDPOINT = {
|
|
1916
|
+
easy: -2,
|
|
1917
|
+
moderate: -5,
|
|
1918
|
+
hard: -10,
|
|
1919
|
+
failed: -12
|
|
1920
|
+
};
|
|
1921
|
+
var DIFFICULTY_SEVERITY = {
|
|
1922
|
+
easy: "suggestion",
|
|
1923
|
+
moderate: "risk",
|
|
1924
|
+
hard: "blocking",
|
|
1925
|
+
failed: "blocking"
|
|
1926
|
+
};
|
|
1927
|
+
function getConfidence(caseCount) {
|
|
1928
|
+
if (caseCount >= 3) return "high";
|
|
1929
|
+
if (caseCount >= 2) return "medium";
|
|
1930
|
+
return "low";
|
|
1931
|
+
}
|
|
1932
|
+
function proposedScoreFromDifficulties(difficulties) {
|
|
1933
|
+
const counts = {};
|
|
1934
|
+
for (const d of difficulties) {
|
|
1935
|
+
counts[d] = (counts[d] ?? 0) + 1;
|
|
1936
|
+
}
|
|
1937
|
+
let dominant = difficulties[0] ?? "moderate";
|
|
1938
|
+
let maxCount = 0;
|
|
1939
|
+
for (const [difficulty, count] of Object.entries(counts)) {
|
|
1940
|
+
if (count > maxCount) {
|
|
1941
|
+
maxCount = count;
|
|
1942
|
+
dominant = difficulty;
|
|
1943
|
+
}
|
|
1944
|
+
}
|
|
1945
|
+
return DIFFICULTY_MIDPOINT[dominant] ?? -5;
|
|
1946
|
+
}
|
|
1947
|
+
function proposeSeverity(currentSeverity, proposedScore) {
|
|
1948
|
+
let expectedSeverity;
|
|
1949
|
+
if (proposedScore <= -8) {
|
|
1950
|
+
expectedSeverity = "blocking";
|
|
1951
|
+
} else if (proposedScore <= -4) {
|
|
1952
|
+
expectedSeverity = "risk";
|
|
1953
|
+
} else if (proposedScore <= -2) {
|
|
1954
|
+
expectedSeverity = "missing-info";
|
|
1955
|
+
} else {
|
|
1956
|
+
expectedSeverity = "suggestion";
|
|
1957
|
+
}
|
|
1958
|
+
if (expectedSeverity !== currentSeverity) {
|
|
1959
|
+
return expectedSeverity;
|
|
1960
|
+
}
|
|
1961
|
+
return void 0;
|
|
1962
|
+
}
|
|
1963
|
+
function runTuningAgent(input) {
|
|
1964
|
+
const adjustments = [];
|
|
1965
|
+
const newRuleProposals = [];
|
|
1966
|
+
const overscoredByRule = /* @__PURE__ */ new Map();
|
|
1967
|
+
const underscoredByRule = /* @__PURE__ */ new Map();
|
|
1968
|
+
const missingRuleCases = [];
|
|
1969
|
+
for (const mismatch of input.mismatches) {
|
|
1970
|
+
switch (mismatch.type) {
|
|
1971
|
+
case "overscored": {
|
|
1972
|
+
if (!mismatch.ruleId) break;
|
|
1973
|
+
const existing = overscoredByRule.get(mismatch.ruleId);
|
|
1974
|
+
if (existing) {
|
|
1975
|
+
existing.push(mismatch);
|
|
1976
|
+
} else {
|
|
1977
|
+
overscoredByRule.set(mismatch.ruleId, [mismatch]);
|
|
1978
|
+
}
|
|
1979
|
+
break;
|
|
1980
|
+
}
|
|
1981
|
+
case "underscored": {
|
|
1982
|
+
if (!mismatch.ruleId) break;
|
|
1983
|
+
const existing = underscoredByRule.get(mismatch.ruleId);
|
|
1984
|
+
if (existing) {
|
|
1985
|
+
existing.push(mismatch);
|
|
1986
|
+
} else {
|
|
1987
|
+
underscoredByRule.set(mismatch.ruleId, [mismatch]);
|
|
1988
|
+
}
|
|
1989
|
+
break;
|
|
1990
|
+
}
|
|
1991
|
+
case "missing-rule":
|
|
1992
|
+
missingRuleCases.push(mismatch);
|
|
1993
|
+
break;
|
|
1994
|
+
}
|
|
1995
|
+
}
|
|
1996
|
+
for (const [ruleId, cases] of overscoredByRule) {
|
|
1997
|
+
const ruleInfo = input.ruleScores[ruleId];
|
|
1998
|
+
if (!ruleInfo) continue;
|
|
1999
|
+
const difficulties = cases.map((c) => c.actualDifficulty);
|
|
2000
|
+
const proposedScore = proposedScoreFromDifficulties(difficulties);
|
|
2001
|
+
const currentSeverity = ruleInfo.severity;
|
|
2002
|
+
const newSeverity = proposeSeverity(currentSeverity, proposedScore);
|
|
2003
|
+
adjustments.push({
|
|
2004
|
+
ruleId,
|
|
2005
|
+
currentScore: ruleInfo.score,
|
|
2006
|
+
proposedScore,
|
|
2007
|
+
currentSeverity,
|
|
2008
|
+
proposedSeverity: newSeverity,
|
|
2009
|
+
reasoning: `Overscored in ${cases.length} case(s). Actual difficulties: [${difficulties.join(", ")}]. Current score ${ruleInfo.score} is too harsh.`,
|
|
2010
|
+
confidence: getConfidence(cases.length),
|
|
2011
|
+
supportingCases: cases.length
|
|
2012
|
+
});
|
|
2013
|
+
}
|
|
2014
|
+
for (const [ruleId, cases] of underscoredByRule) {
|
|
2015
|
+
const ruleInfo = input.ruleScores[ruleId];
|
|
2016
|
+
if (!ruleInfo) continue;
|
|
2017
|
+
const difficulties = cases.map((c) => c.actualDifficulty);
|
|
2018
|
+
const proposedScore = proposedScoreFromDifficulties(difficulties);
|
|
2019
|
+
const currentSeverity = ruleInfo.severity;
|
|
2020
|
+
const newSeverity = proposeSeverity(currentSeverity, proposedScore);
|
|
2021
|
+
adjustments.push({
|
|
2022
|
+
ruleId,
|
|
2023
|
+
currentScore: ruleInfo.score,
|
|
2024
|
+
proposedScore,
|
|
2025
|
+
currentSeverity,
|
|
2026
|
+
proposedSeverity: newSeverity,
|
|
2027
|
+
reasoning: `Underscored in ${cases.length} case(s). Actual difficulties: [${difficulties.join(", ")}]. Current score ${ruleInfo.score} is too lenient.`,
|
|
2028
|
+
confidence: getConfidence(cases.length),
|
|
2029
|
+
supportingCases: cases.length
|
|
2030
|
+
});
|
|
2031
|
+
}
|
|
2032
|
+
const missingGrouped = /* @__PURE__ */ new Map();
|
|
2033
|
+
for (const c of missingRuleCases) {
|
|
2034
|
+
const categoryMatch = c.reasoning.match(/category:\s*([^,)]+)/);
|
|
2035
|
+
const category = categoryMatch?.[1]?.trim() ?? "unknown";
|
|
2036
|
+
const existing = missingGrouped.get(category);
|
|
2037
|
+
if (existing) {
|
|
2038
|
+
existing.push(c);
|
|
2039
|
+
} else {
|
|
2040
|
+
missingGrouped.set(category, [c]);
|
|
2041
|
+
}
|
|
2042
|
+
}
|
|
2043
|
+
for (const [category, cases] of missingGrouped) {
|
|
2044
|
+
const difficulties = cases.map((c) => c.actualDifficulty);
|
|
2045
|
+
const dominantDifficulty = getDominantDifficulty(difficulties);
|
|
2046
|
+
const descriptions = cases.map((c) => {
|
|
2047
|
+
const descMatch = c.reasoning.match(/Uncovered struggle: "([^"]+)"/);
|
|
2048
|
+
return descMatch?.[1] ?? c.reasoning;
|
|
2049
|
+
});
|
|
2050
|
+
newRuleProposals.push({
|
|
2051
|
+
suggestedId: `new-${category}-rule`,
|
|
2052
|
+
category,
|
|
2053
|
+
description: descriptions.join("; "),
|
|
2054
|
+
suggestedSeverity: DIFFICULTY_SEVERITY[dominantDifficulty] ?? "risk",
|
|
2055
|
+
suggestedScore: DIFFICULTY_MIDPOINT[dominantDifficulty] ?? -5,
|
|
2056
|
+
reasoning: `${cases.length} uncovered struggle(s) in category "${category}". Difficulties: [${difficulties.join(", ")}].`,
|
|
2057
|
+
supportingCases: cases.length
|
|
2058
|
+
});
|
|
2059
|
+
}
|
|
2060
|
+
return { adjustments, newRuleProposals };
|
|
2061
|
+
}
|
|
2062
|
+
function getDominantDifficulty(difficulties) {
|
|
2063
|
+
const counts = {};
|
|
2064
|
+
for (const d of difficulties) {
|
|
2065
|
+
counts[d] = (counts[d] ?? 0) + 1;
|
|
2066
|
+
}
|
|
2067
|
+
let dominant = difficulties[0] ?? "moderate";
|
|
2068
|
+
let maxCount = 0;
|
|
2069
|
+
for (const [difficulty, count] of Object.entries(counts)) {
|
|
2070
|
+
if (count > maxCount) {
|
|
2071
|
+
maxCount = count;
|
|
2072
|
+
dominant = difficulty;
|
|
2073
|
+
}
|
|
2074
|
+
}
|
|
2075
|
+
return dominant;
|
|
2076
|
+
}
|
|
2077
|
+
|
|
2078
|
+
// src/agents/report-generator.ts
|
|
2079
|
+
function generateCalibrationReport(data) {
|
|
2080
|
+
const lines = [];
|
|
2081
|
+
lines.push("# Calibration Report");
|
|
2082
|
+
lines.push("");
|
|
2083
|
+
lines.push(renderOverview(data));
|
|
2084
|
+
lines.push(renderCurrentScores(data));
|
|
2085
|
+
lines.push(renderAdjustmentProposals(data.adjustments));
|
|
2086
|
+
lines.push(renderNewRuleProposals(data.newRuleProposals));
|
|
2087
|
+
lines.push(renderValidatedRules(data.validatedRules));
|
|
2088
|
+
lines.push(renderMismatchDetails(data.mismatches));
|
|
2089
|
+
lines.push(renderApplicationGuide(data.adjustments));
|
|
2090
|
+
return lines.join("\n");
|
|
2091
|
+
}
|
|
2092
|
+
function renderOverview(data) {
|
|
2093
|
+
return `## Overview
|
|
2094
|
+
|
|
2095
|
+
| Metric | Value |
|
|
2096
|
+
|--------|-------|
|
|
2097
|
+
| File | ${data.fileName} (${data.fileKey}) |
|
|
2098
|
+
| Analyzed At | ${data.analyzedAt} |
|
|
2099
|
+
| Total Nodes | ${data.nodeCount} |
|
|
2100
|
+
| Total Issues | ${data.issueCount} |
|
|
2101
|
+
| Converted Nodes | ${data.convertedNodeCount} |
|
|
2102
|
+
| Skipped Nodes | ${data.skippedNodeCount} |
|
|
2103
|
+
| Overall Grade | ${data.scoreReport.overall.grade} (${data.scoreReport.overall.percentage}%) |
|
|
2104
|
+
`;
|
|
2105
|
+
}
|
|
2106
|
+
function renderCurrentScores(data) {
|
|
2107
|
+
const lines = [];
|
|
2108
|
+
lines.push("## Current Score Summary");
|
|
2109
|
+
lines.push("");
|
|
2110
|
+
lines.push("| Category | Score | Issues | Density | Diversity |");
|
|
2111
|
+
lines.push("|----------|-------|--------|---------|-----------|");
|
|
2112
|
+
for (const [category, catScore] of Object.entries(data.scoreReport.byCategory)) {
|
|
2113
|
+
lines.push(
|
|
2114
|
+
`| ${category} | ${catScore.percentage}% | ${catScore.issueCount} | ${catScore.densityScore} | ${catScore.diversityScore} |`
|
|
2115
|
+
);
|
|
2116
|
+
}
|
|
2117
|
+
lines.push("");
|
|
2118
|
+
return lines.join("\n");
|
|
2119
|
+
}
|
|
2120
|
+
function renderAdjustmentProposals(adjustments) {
|
|
2121
|
+
if (adjustments.length === 0) {
|
|
2122
|
+
return "## Score Adjustment Proposals\n\nNo adjustments proposed.\n";
|
|
2123
|
+
}
|
|
2124
|
+
const lines = [];
|
|
2125
|
+
lines.push("## Score Adjustment Proposals");
|
|
2126
|
+
lines.push("");
|
|
2127
|
+
lines.push("| Rule | Current Score | Proposed Score | Severity Change | Confidence | Cases | Reasoning |");
|
|
2128
|
+
lines.push("|------|--------------|----------------|-----------------|------------|-------|-----------|");
|
|
2129
|
+
for (const adj of adjustments) {
|
|
2130
|
+
const severityChange = adj.proposedSeverity ? `${adj.currentSeverity} -> ${adj.proposedSeverity}` : adj.currentSeverity;
|
|
2131
|
+
lines.push(
|
|
2132
|
+
`| ${adj.ruleId} | ${adj.currentScore} | ${adj.proposedScore} | ${severityChange} | ${adj.confidence} | ${adj.supportingCases} | ${adj.reasoning} |`
|
|
2133
|
+
);
|
|
2134
|
+
}
|
|
2135
|
+
lines.push("");
|
|
2136
|
+
return lines.join("\n");
|
|
2137
|
+
}
|
|
2138
|
+
function renderNewRuleProposals(proposals) {
|
|
2139
|
+
if (proposals.length === 0) {
|
|
2140
|
+
return "## New Rule Proposals\n\nNo new rules proposed.\n";
|
|
2141
|
+
}
|
|
2142
|
+
const lines = [];
|
|
2143
|
+
lines.push("## New Rule Proposals");
|
|
2144
|
+
lines.push("");
|
|
2145
|
+
for (const proposal of proposals) {
|
|
2146
|
+
lines.push(`### ${proposal.suggestedId}`);
|
|
2147
|
+
lines.push("");
|
|
2148
|
+
lines.push(`- **Category**: ${proposal.category}`);
|
|
2149
|
+
lines.push(`- **Suggested Severity**: ${proposal.suggestedSeverity}`);
|
|
2150
|
+
lines.push(`- **Suggested Score**: ${proposal.suggestedScore}`);
|
|
2151
|
+
lines.push(`- **Supporting Cases**: ${proposal.supportingCases}`);
|
|
2152
|
+
lines.push(`- **Description**: ${proposal.description}`);
|
|
2153
|
+
lines.push(`- **Reasoning**: ${proposal.reasoning}`);
|
|
2154
|
+
lines.push("");
|
|
2155
|
+
}
|
|
2156
|
+
return lines.join("\n");
|
|
2157
|
+
}
|
|
2158
|
+
function renderValidatedRules(validatedRules) {
|
|
2159
|
+
if (validatedRules.length === 0) {
|
|
2160
|
+
return "## Validated Rules\n\nNo rules were validated in this run.\n";
|
|
2161
|
+
}
|
|
2162
|
+
const lines = [];
|
|
2163
|
+
lines.push("## Validated Rules");
|
|
2164
|
+
lines.push("");
|
|
2165
|
+
lines.push("The following rules had scores that aligned with actual conversion difficulty:");
|
|
2166
|
+
lines.push("");
|
|
2167
|
+
for (const ruleId of validatedRules) {
|
|
2168
|
+
lines.push(`- \`${ruleId}\``);
|
|
2169
|
+
}
|
|
2170
|
+
lines.push("");
|
|
2171
|
+
return lines.join("\n");
|
|
2172
|
+
}
|
|
2173
|
+
function renderMismatchDetails(mismatches) {
|
|
2174
|
+
if (mismatches.length === 0) {
|
|
2175
|
+
return "## Detailed Mismatch List\n\nNo mismatches found.\n";
|
|
2176
|
+
}
|
|
2177
|
+
const lines = [];
|
|
2178
|
+
lines.push("## Detailed Mismatch List");
|
|
2179
|
+
lines.push("");
|
|
2180
|
+
const grouped = {};
|
|
2181
|
+
for (const m of mismatches) {
|
|
2182
|
+
const list = grouped[m.type];
|
|
2183
|
+
if (list) {
|
|
2184
|
+
list.push(m);
|
|
2185
|
+
} else {
|
|
2186
|
+
grouped[m.type] = [m];
|
|
2187
|
+
}
|
|
2188
|
+
}
|
|
2189
|
+
for (const [type, cases] of Object.entries(grouped)) {
|
|
2190
|
+
lines.push(`### ${type} (${cases.length})`);
|
|
2191
|
+
lines.push("");
|
|
2192
|
+
for (const c of cases) {
|
|
2193
|
+
const ruleInfo = c.ruleId ? ` | Rule: \`${c.ruleId}\`` : "";
|
|
2194
|
+
const scoreInfo = c.currentScore !== void 0 ? ` | Score: ${c.currentScore}` : "";
|
|
2195
|
+
lines.push(`- **${c.nodePath}** (${c.nodeId})${ruleInfo}${scoreInfo} | Difficulty: ${c.actualDifficulty}`);
|
|
2196
|
+
lines.push(` > ${c.reasoning}`);
|
|
2197
|
+
}
|
|
2198
|
+
lines.push("");
|
|
2199
|
+
}
|
|
2200
|
+
return lines.join("\n");
|
|
2201
|
+
}
|
|
2202
|
+
function renderApplicationGuide(adjustments) {
|
|
2203
|
+
const lines = [];
|
|
2204
|
+
lines.push("## Application Guide");
|
|
2205
|
+
lines.push("");
|
|
2206
|
+
lines.push("To apply these calibration results:");
|
|
2207
|
+
lines.push("");
|
|
2208
|
+
lines.push("1. Review each adjustment proposal above");
|
|
2209
|
+
lines.push("2. Edit `src/rules/rule-config.ts` to update scores and severities");
|
|
2210
|
+
lines.push("3. Run `pnpm test:run` to verify no tests break");
|
|
2211
|
+
lines.push("4. Re-run calibration to confirm improvements");
|
|
2212
|
+
lines.push("");
|
|
2213
|
+
if (adjustments.length > 0) {
|
|
2214
|
+
lines.push("### Suggested Changes to `rule-config.ts`");
|
|
2215
|
+
lines.push("");
|
|
2216
|
+
lines.push("```typescript");
|
|
2217
|
+
for (const adj of adjustments) {
|
|
2218
|
+
lines.push(`// ${adj.ruleId}: ${adj.currentScore} -> ${adj.proposedScore} (${adj.confidence} confidence)`);
|
|
2219
|
+
if (adj.proposedSeverity) {
|
|
2220
|
+
lines.push(`// severity: "${adj.currentSeverity}" -> "${adj.proposedSeverity}"`);
|
|
2221
|
+
}
|
|
2222
|
+
}
|
|
2223
|
+
lines.push("```");
|
|
2224
|
+
lines.push("");
|
|
2225
|
+
}
|
|
2226
|
+
return lines.join("\n");
|
|
2227
|
+
}
|
|
2228
|
+
function getTimestamp() {
|
|
2229
|
+
const now = /* @__PURE__ */ new Date();
|
|
2230
|
+
return now.toLocaleTimeString("en-US", { hour: "2-digit", minute: "2-digit", hour12: false });
|
|
2231
|
+
}
|
|
2232
|
+
function getDateTimeString() {
|
|
2233
|
+
const now = /* @__PURE__ */ new Date();
|
|
2234
|
+
const year = now.getFullYear();
|
|
2235
|
+
const month = String(now.getMonth() + 1).padStart(2, "0");
|
|
2236
|
+
const day = String(now.getDate()).padStart(2, "0");
|
|
2237
|
+
const hours = String(now.getHours()).padStart(2, "0");
|
|
2238
|
+
const minutes = String(now.getMinutes()).padStart(2, "0");
|
|
2239
|
+
return `${year}-${month}-${day}-${hours}-${minutes}`;
|
|
2240
|
+
}
|
|
2241
|
+
function extractFixtureName(fixturePath) {
|
|
2242
|
+
const fileName = fixturePath.split("/").pop() ?? fixturePath;
|
|
2243
|
+
return fileName.replace(/\.json$/, "");
|
|
2244
|
+
}
|
|
2245
|
+
var ActivityLogger = class {
|
|
2246
|
+
logPath;
|
|
2247
|
+
initialized = false;
|
|
2248
|
+
constructor(fixturePath, logDir = "logs/activity") {
|
|
2249
|
+
const dateTimeStr = getDateTimeString();
|
|
2250
|
+
const fixtureName = fixturePath ? extractFixtureName(fixturePath) : "unknown";
|
|
2251
|
+
this.logPath = resolve(logDir, `${dateTimeStr}-${fixtureName}.md`);
|
|
2252
|
+
}
|
|
2253
|
+
/**
|
|
2254
|
+
* Ensure the log directory and file header exist
|
|
2255
|
+
*/
|
|
2256
|
+
async ensureInitialized() {
|
|
2257
|
+
if (this.initialized) return;
|
|
2258
|
+
const dir = dirname(this.logPath);
|
|
2259
|
+
if (!existsSync(dir)) {
|
|
2260
|
+
mkdirSync(dir, { recursive: true });
|
|
2261
|
+
}
|
|
2262
|
+
if (!existsSync(this.logPath)) {
|
|
2263
|
+
const ts = getDateTimeString();
|
|
2264
|
+
await writeFile(this.logPath, `# Calibration Activity Log \u2014 ${ts}
|
|
2265
|
+
|
|
2266
|
+
`, "utf-8");
|
|
2267
|
+
}
|
|
2268
|
+
this.initialized = true;
|
|
2269
|
+
}
|
|
2270
|
+
/**
|
|
2271
|
+
* Log a pipeline step
|
|
2272
|
+
*/
|
|
2273
|
+
async logStep(activity) {
|
|
2274
|
+
await this.ensureInitialized();
|
|
2275
|
+
const lines = [];
|
|
2276
|
+
lines.push(`## ${getTimestamp()} \u2014 ${activity.step}`);
|
|
2277
|
+
if (activity.nodePath) {
|
|
2278
|
+
lines.push(`- Node: ${activity.nodePath}`);
|
|
2279
|
+
}
|
|
2280
|
+
lines.push(`- Result: ${activity.result}`);
|
|
2281
|
+
lines.push(`- Duration: ${activity.durationMs}ms`);
|
|
2282
|
+
lines.push("");
|
|
2283
|
+
await appendFile(this.logPath, lines.join("\n") + "\n", "utf-8");
|
|
2284
|
+
}
|
|
2285
|
+
/**
|
|
2286
|
+
* Log a summary at pipeline completion
|
|
2287
|
+
*/
|
|
2288
|
+
async logSummary(summary) {
|
|
2289
|
+
await this.ensureInitialized();
|
|
2290
|
+
const lines = [];
|
|
2291
|
+
lines.push(`## ${getTimestamp()} \u2014 Pipeline Summary`);
|
|
2292
|
+
lines.push(`- Status: ${summary.status}`);
|
|
2293
|
+
lines.push(`- Total Duration: ${summary.totalDurationMs}ms`);
|
|
2294
|
+
lines.push(`- Nodes Analyzed: ${summary.nodesAnalyzed}`);
|
|
2295
|
+
lines.push(`- Nodes Converted: ${summary.nodesConverted}`);
|
|
2296
|
+
lines.push(`- Mismatches Found: ${summary.mismatches}`);
|
|
2297
|
+
lines.push(`- Adjustments Proposed: ${summary.adjustments}`);
|
|
2298
|
+
lines.push("");
|
|
2299
|
+
lines.push("---");
|
|
2300
|
+
lines.push("");
|
|
2301
|
+
await appendFile(this.logPath, lines.join("\n") + "\n", "utf-8");
|
|
2302
|
+
}
|
|
2303
|
+
getLogPath() {
|
|
2304
|
+
return this.logPath;
|
|
2305
|
+
}
|
|
2306
|
+
};
|
|
2307
|
+
|
|
2308
|
+
// src/agents/orchestrator.ts
|
|
2309
|
+
function selectNodes(summaries, strategy, maxNodes) {
|
|
2310
|
+
if (summaries.length === 0) return [];
|
|
2311
|
+
switch (strategy) {
|
|
2312
|
+
case "all":
|
|
2313
|
+
return summaries.slice(0, maxNodes);
|
|
2314
|
+
case "random": {
|
|
2315
|
+
const shuffled = [...summaries];
|
|
2316
|
+
for (let i = shuffled.length - 1; i > 0; i--) {
|
|
2317
|
+
const j = Math.floor(Math.random() * (i + 1));
|
|
2318
|
+
const temp = shuffled[i];
|
|
2319
|
+
shuffled[i] = shuffled[j];
|
|
2320
|
+
shuffled[j] = temp;
|
|
2321
|
+
}
|
|
2322
|
+
return shuffled.slice(0, maxNodes);
|
|
2323
|
+
}
|
|
2324
|
+
case "top-issues":
|
|
2325
|
+
default:
|
|
2326
|
+
return summaries.slice(0, maxNodes);
|
|
2327
|
+
}
|
|
2328
|
+
}
|
|
2329
|
+
var EXCLUDED_NODE_TYPES = /* @__PURE__ */ new Set([
|
|
2330
|
+
"VECTOR",
|
|
2331
|
+
"BOOLEAN_OPERATION",
|
|
2332
|
+
"STAR",
|
|
2333
|
+
"REGULAR_POLYGON",
|
|
2334
|
+
"ELLIPSE",
|
|
2335
|
+
"LINE"
|
|
2336
|
+
]);
|
|
2337
|
+
function findNode(root, nodeId) {
|
|
2338
|
+
if (root.id === nodeId) return root;
|
|
2339
|
+
if (root.children) {
|
|
2340
|
+
for (const child of root.children) {
|
|
2341
|
+
const found = findNode(child, nodeId);
|
|
2342
|
+
if (found) return found;
|
|
2343
|
+
}
|
|
2344
|
+
}
|
|
2345
|
+
return null;
|
|
2346
|
+
}
|
|
2347
|
+
function hasTextDescendant(node) {
|
|
2348
|
+
if (node.type === "TEXT") return true;
|
|
2349
|
+
if (node.children) {
|
|
2350
|
+
for (const child of node.children) {
|
|
2351
|
+
if (hasTextDescendant(child)) return true;
|
|
2352
|
+
}
|
|
2353
|
+
}
|
|
2354
|
+
return false;
|
|
2355
|
+
}
|
|
2356
|
+
var MIN_WIDTH = 200;
|
|
2357
|
+
var MIN_HEIGHT = 200;
|
|
2358
|
+
var ELIGIBLE_NODE_TYPES = /* @__PURE__ */ new Set([
|
|
2359
|
+
"FRAME",
|
|
2360
|
+
"COMPONENT",
|
|
2361
|
+
"INSTANCE"
|
|
2362
|
+
]);
|
|
2363
|
+
var ICON_NAME_PATTERN = /\b(icon|ico|badge|indicator)\b/i;
|
|
2364
|
+
function filterConversionCandidates(summaries, documentRoot) {
|
|
2365
|
+
return summaries.filter((summary) => {
|
|
2366
|
+
const node = findNode(documentRoot, summary.nodeId);
|
|
2367
|
+
if (!node) return false;
|
|
2368
|
+
if (EXCLUDED_NODE_TYPES.has(node.type)) return false;
|
|
2369
|
+
if (!ELIGIBLE_NODE_TYPES.has(node.type)) return false;
|
|
2370
|
+
if (ICON_NAME_PATTERN.test(node.name)) return false;
|
|
2371
|
+
const bbox = node.absoluteBoundingBox;
|
|
2372
|
+
if (bbox && (bbox.width < MIN_WIDTH || bbox.height < MIN_HEIGHT)) return false;
|
|
2373
|
+
if (!node.children || node.children.length < 3) return false;
|
|
2374
|
+
if (!hasTextDescendant(node)) return false;
|
|
2375
|
+
return true;
|
|
2376
|
+
});
|
|
2377
|
+
}
|
|
2378
|
+
function isFigmaUrl2(input) {
|
|
2379
|
+
return input.includes("figma.com/");
|
|
2380
|
+
}
|
|
2381
|
+
function isJsonFile2(input) {
|
|
2382
|
+
return input.endsWith(".json");
|
|
2383
|
+
}
|
|
2384
|
+
async function loadFile2(input, token) {
|
|
2385
|
+
if (isJsonFile2(input)) {
|
|
2386
|
+
const filePath = resolve(input);
|
|
2387
|
+
if (!existsSync(filePath)) {
|
|
2388
|
+
throw new Error(`File not found: ${filePath}`);
|
|
2389
|
+
}
|
|
2390
|
+
const file = await loadFigmaFileFromJson(filePath);
|
|
2391
|
+
return { file, fileKey: file.fileKey, nodeId: void 0 };
|
|
2392
|
+
}
|
|
2393
|
+
if (isFigmaUrl2(input)) {
|
|
2394
|
+
const { fileKey, nodeId } = parseFigmaUrl(input);
|
|
2395
|
+
const figmaToken = token ?? process.env["FIGMA_TOKEN"];
|
|
2396
|
+
if (!figmaToken) {
|
|
2397
|
+
throw new Error(
|
|
2398
|
+
"Figma token required. Provide token or set FIGMA_TOKEN environment variable."
|
|
2399
|
+
);
|
|
2400
|
+
}
|
|
2401
|
+
const client = new FigmaClient({ token: figmaToken });
|
|
2402
|
+
const response = await client.getFile(fileKey);
|
|
2403
|
+
const file = transformFigmaResponse(fileKey, response);
|
|
2404
|
+
return { file, fileKey, nodeId };
|
|
2405
|
+
}
|
|
2406
|
+
throw new Error(
|
|
2407
|
+
`Invalid input: ${input}. Provide a Figma URL or JSON file path.`
|
|
2408
|
+
);
|
|
2409
|
+
}
|
|
2410
|
+
function buildRuleScoresMap() {
|
|
2411
|
+
const scores = {};
|
|
2412
|
+
for (const [id, config2] of Object.entries(RULE_CONFIGS)) {
|
|
2413
|
+
scores[id] = { score: config2.score, severity: config2.severity };
|
|
2414
|
+
}
|
|
2415
|
+
return scores;
|
|
2416
|
+
}
|
|
2417
|
+
async function runCalibrationAnalyze(config2) {
|
|
2418
|
+
const parsed = CalibrationConfigSchema.parse(config2);
|
|
2419
|
+
const { file, fileKey, nodeId } = await loadFile2(parsed.input, parsed.token);
|
|
2420
|
+
const analyzeOptions = nodeId ? { targetNodeId: nodeId } : {};
|
|
2421
|
+
const analysisResult = analyzeFile(file, analyzeOptions);
|
|
2422
|
+
const analysisOutput = runAnalysisAgent({ analysisResult });
|
|
2423
|
+
const ruleScores = {
|
|
2424
|
+
...buildRuleScoresMap(),
|
|
2425
|
+
...extractRuleScores(analysisResult)
|
|
2426
|
+
};
|
|
2427
|
+
return { analysisOutput, ruleScores, fileKey };
|
|
2428
|
+
}
|
|
2429
|
+
function runCalibrationEvaluate(analysisJson, conversionJson, ruleScores) {
|
|
2430
|
+
const evaluationOutput = runEvaluationAgent({
|
|
2431
|
+
nodeIssueSummaries: analysisJson.nodeIssueSummaries.map((s) => ({
|
|
2432
|
+
nodeId: s.nodeId,
|
|
2433
|
+
nodePath: s.nodePath,
|
|
2434
|
+
flaggedRuleIds: s.flaggedRuleIds
|
|
2435
|
+
})),
|
|
2436
|
+
conversionRecords: conversionJson.records,
|
|
2437
|
+
ruleScores
|
|
2438
|
+
});
|
|
2439
|
+
const tuningOutput = runTuningAgent({
|
|
2440
|
+
mismatches: evaluationOutput.mismatches,
|
|
2441
|
+
ruleScores
|
|
2442
|
+
});
|
|
2443
|
+
const report = generateCalibrationReport({
|
|
2444
|
+
fileKey: analysisJson.fileKey,
|
|
2445
|
+
fileName: analysisJson.fileName,
|
|
2446
|
+
analyzedAt: analysisJson.analyzedAt,
|
|
2447
|
+
nodeCount: analysisJson.nodeCount,
|
|
2448
|
+
issueCount: analysisJson.issueCount,
|
|
2449
|
+
convertedNodeCount: conversionJson.records.length,
|
|
2450
|
+
skippedNodeCount: conversionJson.skippedNodeIds.length,
|
|
2451
|
+
scoreReport: analysisJson.scoreReport,
|
|
2452
|
+
mismatches: evaluationOutput.mismatches,
|
|
2453
|
+
validatedRules: evaluationOutput.validatedRules,
|
|
2454
|
+
adjustments: tuningOutput.adjustments,
|
|
2455
|
+
newRuleProposals: tuningOutput.newRuleProposals
|
|
2456
|
+
});
|
|
2457
|
+
return {
|
|
2458
|
+
evaluationOutput,
|
|
2459
|
+
tuningOutput,
|
|
2460
|
+
report
|
|
2461
|
+
};
|
|
2462
|
+
}
|
|
2463
|
+
async function runCalibration(config2, executor, options) {
|
|
2464
|
+
const parsed = CalibrationConfigSchema.parse(config2);
|
|
2465
|
+
const pipelineStart = Date.now();
|
|
2466
|
+
const startedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
2467
|
+
const logger = new ActivityLogger(parsed.input) ;
|
|
2468
|
+
try {
|
|
2469
|
+
let stepStart = Date.now();
|
|
2470
|
+
const { file, fileKey, nodeId } = await loadFile2(parsed.input, parsed.token);
|
|
2471
|
+
const analyzeOptions = nodeId ? { targetNodeId: nodeId } : {};
|
|
2472
|
+
const analysisResult = analyzeFile(file, analyzeOptions);
|
|
2473
|
+
const analysisOutput = runAnalysisAgent({ analysisResult });
|
|
2474
|
+
const ruleScores = {
|
|
2475
|
+
...buildRuleScoresMap(),
|
|
2476
|
+
...extractRuleScores(analysisResult)
|
|
2477
|
+
};
|
|
2478
|
+
await logger?.logStep({
|
|
2479
|
+
step: "Analysis",
|
|
2480
|
+
result: `${analysisResult.nodeCount} nodes, ${analysisResult.issues.length} issues, grade ${analysisOutput.scoreReport.overall.grade}`,
|
|
2481
|
+
durationMs: Date.now() - stepStart
|
|
2482
|
+
});
|
|
2483
|
+
stepStart = Date.now();
|
|
2484
|
+
const candidates = filterConversionCandidates(
|
|
2485
|
+
analysisOutput.nodeIssueSummaries,
|
|
2486
|
+
analysisResult.file.document
|
|
2487
|
+
);
|
|
2488
|
+
const selectedNodes = selectNodes(
|
|
2489
|
+
candidates,
|
|
2490
|
+
parsed.samplingStrategy,
|
|
2491
|
+
parsed.maxConversionNodes
|
|
2492
|
+
);
|
|
2493
|
+
const conversionOutput = await runConversionAgent(
|
|
2494
|
+
{
|
|
2495
|
+
fileKey,
|
|
2496
|
+
nodes: selectedNodes.map((n) => ({
|
|
2497
|
+
nodeId: n.nodeId,
|
|
2498
|
+
nodePath: n.nodePath,
|
|
2499
|
+
flaggedRuleIds: n.flaggedRuleIds
|
|
2500
|
+
}))
|
|
2501
|
+
},
|
|
2502
|
+
executor
|
|
2503
|
+
);
|
|
2504
|
+
await logger?.logStep({
|
|
2505
|
+
step: "Conversion",
|
|
2506
|
+
result: `${conversionOutput.records.length} converted, ${conversionOutput.skippedNodeIds.length} skipped`,
|
|
2507
|
+
durationMs: Date.now() - stepStart
|
|
2508
|
+
});
|
|
2509
|
+
stepStart = Date.now();
|
|
2510
|
+
const evaluationOutput = runEvaluationAgent({
|
|
2511
|
+
nodeIssueSummaries: selectedNodes.map((n) => ({
|
|
2512
|
+
nodeId: n.nodeId,
|
|
2513
|
+
nodePath: n.nodePath,
|
|
2514
|
+
flaggedRuleIds: n.flaggedRuleIds
|
|
2515
|
+
})),
|
|
2516
|
+
conversionRecords: conversionOutput.records,
|
|
2517
|
+
ruleScores
|
|
2518
|
+
});
|
|
2519
|
+
await logger?.logStep({
|
|
2520
|
+
step: "Evaluation",
|
|
2521
|
+
result: `${evaluationOutput.mismatches.length} mismatches, ${evaluationOutput.validatedRules.length} validated`,
|
|
2522
|
+
durationMs: Date.now() - stepStart
|
|
2523
|
+
});
|
|
2524
|
+
stepStart = Date.now();
|
|
2525
|
+
const tuningOutput = runTuningAgent({
|
|
2526
|
+
mismatches: evaluationOutput.mismatches,
|
|
2527
|
+
ruleScores
|
|
2528
|
+
});
|
|
2529
|
+
await logger?.logStep({
|
|
2530
|
+
step: "Tuning",
|
|
2531
|
+
result: `${tuningOutput.adjustments.length} adjustments, ${tuningOutput.newRuleProposals.length} new rule proposals`,
|
|
2532
|
+
durationMs: Date.now() - stepStart
|
|
2533
|
+
});
|
|
2534
|
+
const report = generateCalibrationReport({
|
|
2535
|
+
fileKey,
|
|
2536
|
+
fileName: file.name,
|
|
2537
|
+
analyzedAt: startedAt,
|
|
2538
|
+
nodeCount: analysisResult.nodeCount,
|
|
2539
|
+
issueCount: analysisResult.issues.length,
|
|
2540
|
+
convertedNodeCount: conversionOutput.records.length,
|
|
2541
|
+
skippedNodeCount: conversionOutput.skippedNodeIds.length,
|
|
2542
|
+
scoreReport: analysisOutput.scoreReport,
|
|
2543
|
+
mismatches: evaluationOutput.mismatches,
|
|
2544
|
+
validatedRules: evaluationOutput.validatedRules,
|
|
2545
|
+
adjustments: tuningOutput.adjustments,
|
|
2546
|
+
newRuleProposals: tuningOutput.newRuleProposals
|
|
2547
|
+
});
|
|
2548
|
+
const reportPath = resolve(parsed.outputPath);
|
|
2549
|
+
const reportDir = resolve(parsed.outputPath, "..");
|
|
2550
|
+
if (!existsSync(reportDir)) {
|
|
2551
|
+
mkdirSync(reportDir, { recursive: true });
|
|
2552
|
+
}
|
|
2553
|
+
await writeFile(reportPath, report, "utf-8");
|
|
2554
|
+
await logger?.logSummary({
|
|
2555
|
+
totalDurationMs: Date.now() - pipelineStart,
|
|
2556
|
+
nodesAnalyzed: analysisResult.nodeCount,
|
|
2557
|
+
nodesConverted: conversionOutput.records.length,
|
|
2558
|
+
mismatches: evaluationOutput.mismatches.length,
|
|
2559
|
+
adjustments: tuningOutput.adjustments.length,
|
|
2560
|
+
status: "completed"
|
|
2561
|
+
});
|
|
2562
|
+
return {
|
|
2563
|
+
status: "completed",
|
|
2564
|
+
scoreReport: analysisOutput.scoreReport,
|
|
2565
|
+
nodeIssueSummaries: analysisOutput.nodeIssueSummaries,
|
|
2566
|
+
mismatches: evaluationOutput.mismatches,
|
|
2567
|
+
validatedRules: evaluationOutput.validatedRules,
|
|
2568
|
+
adjustments: tuningOutput.adjustments,
|
|
2569
|
+
newRuleProposals: tuningOutput.newRuleProposals,
|
|
2570
|
+
reportPath,
|
|
2571
|
+
logPath: logger?.getLogPath()
|
|
2572
|
+
};
|
|
2573
|
+
} catch (error) {
|
|
2574
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
2575
|
+
await logger?.logSummary({
|
|
2576
|
+
totalDurationMs: Date.now() - pipelineStart,
|
|
2577
|
+
nodesAnalyzed: 0,
|
|
2578
|
+
nodesConverted: 0,
|
|
2579
|
+
mismatches: 0,
|
|
2580
|
+
adjustments: 0,
|
|
2581
|
+
status: `failed: ${errorMessage}`
|
|
2582
|
+
});
|
|
2583
|
+
return {
|
|
2584
|
+
status: "failed",
|
|
2585
|
+
scoreReport: {
|
|
2586
|
+
overall: { score: 0, maxScore: 100, percentage: 0, grade: "F" },
|
|
2587
|
+
byCategory: {},
|
|
2588
|
+
summary: { totalIssues: 0, blocking: 0, risk: 0, missingInfo: 0, suggestion: 0, nodeCount: 0 }
|
|
2589
|
+
},
|
|
2590
|
+
nodeIssueSummaries: [],
|
|
2591
|
+
mismatches: [],
|
|
2592
|
+
validatedRules: [],
|
|
2593
|
+
adjustments: [],
|
|
2594
|
+
newRuleProposals: [],
|
|
2595
|
+
reportPath: parsed.outputPath,
|
|
2596
|
+
error: errorMessage
|
|
2597
|
+
};
|
|
2598
|
+
}
|
|
2599
|
+
}
|
|
2600
|
+
|
|
2601
|
+
// src/cli/docs.ts
|
|
2602
|
+
function printDocsIndex() {
|
|
2603
|
+
console.log(`
|
|
2604
|
+
CANICODE DOCUMENTATION
|
|
2605
|
+
|
|
2606
|
+
canicode docs setup Full setup guide (CLI, MCP, Skills)
|
|
2607
|
+
canicode docs rules Custom rules guide + example
|
|
2608
|
+
canicode docs config Config override guide + example
|
|
2609
|
+
|
|
2610
|
+
Full documentation: github.com/let-sunny/canicode#readme
|
|
2611
|
+
`.trimStart());
|
|
2612
|
+
}
|
|
2613
|
+
function printDocsSetup() {
|
|
2614
|
+
console.log(`
|
|
2615
|
+
CANICODE SETUP GUIDE
|
|
2616
|
+
|
|
2617
|
+
\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501
|
|
2618
|
+
1. CLI
|
|
2619
|
+
\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501
|
|
2620
|
+
|
|
2621
|
+
Install:
|
|
2622
|
+
npm install -g canicode
|
|
2623
|
+
|
|
2624
|
+
Setup:
|
|
2625
|
+
canicode init --token figd_xxxxxxxxxxxxx
|
|
2626
|
+
(saved to ~/.canicode/config.json, reports go to ~/.canicode/reports/)
|
|
2627
|
+
|
|
2628
|
+
Use:
|
|
2629
|
+
canicode analyze "https://www.figma.com/design/ABC123/MyDesign?node-id=1-234"
|
|
2630
|
+
(opens report in browser automatically, use --no-open to disable)
|
|
2631
|
+
|
|
2632
|
+
Data source flags:
|
|
2633
|
+
--api REST API (uses saved token)
|
|
2634
|
+
--mcp Figma MCP bridge (Claude Code only, no token needed)
|
|
2635
|
+
(none) Auto: try MCP first, fallback to API
|
|
2636
|
+
|
|
2637
|
+
Options:
|
|
2638
|
+
--preset strict|relaxed|dev-friendly|ai-ready
|
|
2639
|
+
--config ./my-config.json
|
|
2640
|
+
--custom-rules ./my-rules.json
|
|
2641
|
+
--no-open Don't open report in browser
|
|
2642
|
+
|
|
2643
|
+
Output:
|
|
2644
|
+
~/.canicode/reports/report-YYYY-MM-DD-HH-mm-<filekey>.html
|
|
2645
|
+
|
|
2646
|
+
\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501
|
|
2647
|
+
2. MCP SERVER (Claude Code integration)
|
|
2648
|
+
\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501
|
|
2649
|
+
|
|
2650
|
+
Route A \u2014 Figma MCP relay (no token needed):
|
|
2651
|
+
|
|
2652
|
+
Install (once):
|
|
2653
|
+
claude mcp add figma -- npx -y @anthropic-ai/claude-code-mcp-figma
|
|
2654
|
+
claude mcp add --transport stdio canicode npx canicode-mcp
|
|
2655
|
+
|
|
2656
|
+
Flow:
|
|
2657
|
+
Claude Code
|
|
2658
|
+
-> Figma MCP get_metadata(fileKey, nodeId) -> XML node tree
|
|
2659
|
+
-> canicode MCP analyze(designData: XML) -> analysis result
|
|
2660
|
+
|
|
2661
|
+
Route B \u2014 REST API direct (token needed):
|
|
2662
|
+
|
|
2663
|
+
Install (once):
|
|
2664
|
+
claude mcp add --transport stdio canicode npx canicode-mcp
|
|
2665
|
+
canicode init --token figd_xxxxxxxxxxxxx
|
|
2666
|
+
|
|
2667
|
+
Flow:
|
|
2668
|
+
Claude Code
|
|
2669
|
+
-> canicode MCP analyze(input: URL) -> internal REST API fetch -> result
|
|
2670
|
+
|
|
2671
|
+
Use (both routes \u2014 just ask Claude Code):
|
|
2672
|
+
"Analyze this Figma design: https://www.figma.com/design/..."
|
|
2673
|
+
|
|
2674
|
+
Route A vs B:
|
|
2675
|
+
A: No token, 2 MCP servers, Claude orchestrates 2 calls
|
|
2676
|
+
B: Token needed, 1 MCP server, canicode fetches directly
|
|
2677
|
+
|
|
2678
|
+
\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501
|
|
2679
|
+
3. CLAUDE SKILLS (lightweight)
|
|
2680
|
+
\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501
|
|
2681
|
+
|
|
2682
|
+
Install:
|
|
2683
|
+
cp -r path/to/canicode/.claude/skills/canicode .claude/skills/
|
|
2684
|
+
|
|
2685
|
+
Setup (for REST API):
|
|
2686
|
+
npx canicode init --token figd_xxxxxxxxxxxxx
|
|
2687
|
+
|
|
2688
|
+
Use (in Claude Code):
|
|
2689
|
+
/canicode analyze "https://www.figma.com/design/..."
|
|
2690
|
+
|
|
2691
|
+
Runs CLI under the hood \u2014 all flags work (--mcp, --api, --preset, etc.)
|
|
2692
|
+
|
|
2693
|
+
\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501
|
|
2694
|
+
TOKEN PRIORITY (all methods)
|
|
2695
|
+
\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501
|
|
2696
|
+
|
|
2697
|
+
1. --token flag (one-time override)
|
|
2698
|
+
2. FIGMA_TOKEN env var (CI/CD)
|
|
2699
|
+
3. ~/.canicode/config.json (canicode init)
|
|
2700
|
+
|
|
2701
|
+
\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501
|
|
2702
|
+
WHICH ONE SHOULD I USE?
|
|
2703
|
+
\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501
|
|
2704
|
+
|
|
2705
|
+
CI/CD, automation -> CLI + FIGMA_TOKEN env var
|
|
2706
|
+
Claude Code, interactive -> MCP Server (Route A)
|
|
2707
|
+
No token, Claude Code -> MCP Server (Route A)
|
|
2708
|
+
Quick trial -> Skills
|
|
2709
|
+
`.trimStart());
|
|
2710
|
+
}
|
|
2711
|
+
function printDocsRules() {
|
|
2712
|
+
console.log(`
|
|
2713
|
+
CUSTOM RULES GUIDE
|
|
2714
|
+
|
|
2715
|
+
Custom rules let you add project-specific checks beyond canicode's built-in 39 rules.
|
|
2716
|
+
|
|
2717
|
+
STRUCTURE
|
|
2718
|
+
- id: unique identifier (kebab-case)
|
|
2719
|
+
- category: layout | token | component | naming | ai-readability | handoff-risk
|
|
2720
|
+
- severity: blocking | risk | missing-info | suggestion
|
|
2721
|
+
- score: negative number (-1 to -15)
|
|
2722
|
+
- prompt: what Claude checks for (used in AI-based evaluation)
|
|
2723
|
+
- why: reason this matters
|
|
2724
|
+
- impact: consequence if ignored
|
|
2725
|
+
- fix: how to resolve
|
|
2726
|
+
|
|
2727
|
+
EXAMPLE
|
|
2728
|
+
[
|
|
2729
|
+
{
|
|
2730
|
+
"id": "icon-missing-component",
|
|
2731
|
+
"category": "component",
|
|
2732
|
+
"severity": "blocking",
|
|
2733
|
+
"score": -10,
|
|
2734
|
+
"prompt": "Check if this node is an icon (small size, vector children, no text) and is not a component or instance.",
|
|
2735
|
+
"why": "Icon nodes that are not components cannot be reused consistently.",
|
|
2736
|
+
"impact": "Developers will hardcode icons instead of using a shared component.",
|
|
2737
|
+
"fix": "Convert this icon node to a component and publish it to the library."
|
|
2738
|
+
}
|
|
2739
|
+
]
|
|
2740
|
+
|
|
2741
|
+
USAGE
|
|
2742
|
+
canicode analyze <url> --custom-rules ./my-rules.json
|
|
2743
|
+
See full example: examples/custom-rules.json
|
|
2744
|
+
`.trimStart());
|
|
2745
|
+
}
|
|
2746
|
+
function printDocsConfig() {
|
|
2747
|
+
console.log(`
|
|
2748
|
+
CONFIG GUIDE
|
|
2749
|
+
|
|
2750
|
+
Override canicode's default rule scores, severity, and filters.
|
|
2751
|
+
|
|
2752
|
+
STRUCTURE
|
|
2753
|
+
- excludeNodeTypes: node types to skip (e.g. VECTOR, BOOLEAN_OPERATION)
|
|
2754
|
+
- excludeNodeNames: name patterns to skip (e.g. icon, ico)
|
|
2755
|
+
- gridBase: spacing grid unit, default 8
|
|
2756
|
+
- colorTolerance: color diff tolerance, default 10
|
|
2757
|
+
- rules: per-rule overrides (score, severity, enabled)
|
|
2758
|
+
|
|
2759
|
+
EXAMPLE
|
|
2760
|
+
{
|
|
2761
|
+
"excludeNodeTypes": [],
|
|
2762
|
+
"excludeNodeNames": [],
|
|
2763
|
+
"gridBase": 4,
|
|
2764
|
+
"rules": {
|
|
2765
|
+
"no-auto-layout": { "score": -15, "severity": "blocking" },
|
|
2766
|
+
"raw-color": { "score": -12 },
|
|
2767
|
+
"default-name": { "enabled": false }
|
|
2768
|
+
}
|
|
2769
|
+
}
|
|
2770
|
+
|
|
2771
|
+
USAGE
|
|
2772
|
+
canicode analyze <url> --config ./my-config.json
|
|
2773
|
+
See full example: examples/config.json
|
|
2774
|
+
`.trimStart());
|
|
2775
|
+
}
|
|
2776
|
+
var DOCS_TOPICS = {
|
|
2777
|
+
setup: printDocsSetup,
|
|
2778
|
+
install: printDocsSetup,
|
|
2779
|
+
// alias
|
|
2780
|
+
rules: printDocsRules,
|
|
2781
|
+
config: printDocsConfig
|
|
2782
|
+
};
|
|
2783
|
+
function handleDocs(topic) {
|
|
2784
|
+
if (!topic) {
|
|
2785
|
+
printDocsIndex();
|
|
2786
|
+
return;
|
|
2787
|
+
}
|
|
2788
|
+
const handler = DOCS_TOPICS[topic];
|
|
2789
|
+
if (handler) {
|
|
2790
|
+
handler();
|
|
2791
|
+
} else {
|
|
2792
|
+
console.error(`Unknown docs topic: ${topic}`);
|
|
2793
|
+
console.error(`Available topics: setup, rules, config`);
|
|
2794
|
+
process.exit(1);
|
|
2795
|
+
}
|
|
2796
|
+
}
|
|
2797
|
+
|
|
2798
|
+
// src/rules/layout/index.ts
|
|
2799
|
+
function isContainerNode(node) {
|
|
2800
|
+
return node.type === "FRAME" || node.type === "GROUP" || node.type === "COMPONENT";
|
|
2801
|
+
}
|
|
2802
|
+
function hasAutoLayout(node) {
|
|
2803
|
+
return node.layoutMode !== void 0 && node.layoutMode !== "NONE";
|
|
2804
|
+
}
|
|
2805
|
+
function hasTextContent(node) {
|
|
2806
|
+
return node.type === "TEXT" || (node.children?.some((c) => c.type === "TEXT") ?? false);
|
|
2807
|
+
}
|
|
2808
|
+
var noAutoLayoutDef = {
|
|
2809
|
+
id: "no-auto-layout",
|
|
2810
|
+
name: "No Auto Layout",
|
|
2811
|
+
category: "layout",
|
|
2812
|
+
why: "Frames without Auto Layout require manual positioning for every element",
|
|
2813
|
+
impact: "Layout breaks on content changes, harder to maintain and scale",
|
|
2814
|
+
fix: "Apply Auto Layout to the frame with appropriate direction and spacing"
|
|
2815
|
+
};
|
|
2816
|
+
var noAutoLayoutCheck = (node, context) => {
|
|
2817
|
+
if (node.type !== "FRAME") return null;
|
|
2818
|
+
if (hasAutoLayout(node)) return null;
|
|
2819
|
+
if (!node.children || node.children.length === 0) return null;
|
|
2820
|
+
return {
|
|
2821
|
+
ruleId: noAutoLayoutDef.id,
|
|
2822
|
+
nodeId: node.id,
|
|
2823
|
+
nodePath: context.path.join(" > "),
|
|
2824
|
+
message: `Frame "${node.name}" has no Auto Layout`
|
|
2825
|
+
};
|
|
2826
|
+
};
|
|
2827
|
+
defineRule({
|
|
2828
|
+
definition: noAutoLayoutDef,
|
|
2829
|
+
check: noAutoLayoutCheck
|
|
2830
|
+
});
|
|
2831
|
+
var absolutePositionInAutoLayoutDef = {
|
|
2832
|
+
id: "absolute-position-in-auto-layout",
|
|
2833
|
+
name: "Absolute Position in Auto Layout",
|
|
2834
|
+
category: "layout",
|
|
2835
|
+
why: "Absolute positioning inside Auto Layout breaks the automatic flow",
|
|
2836
|
+
impact: "Element will not respond to sibling changes, may overlap unexpectedly",
|
|
2837
|
+
fix: "Remove absolute positioning or use proper Auto Layout alignment"
|
|
2838
|
+
};
|
|
2839
|
+
var INTENTIONAL_ABSOLUTE_PATTERNS = /^(badge|close|dismiss|overlay|float|fab|dot|indicator|corner|decoration|tag|status|notification|x|icon[-_ ]?(close|dismiss|x)|btn[-_ ]?(close|dismiss))/i;
|
|
2840
|
+
function isSmallRelativeToParent(node, parent) {
|
|
2841
|
+
const nodeBB = node.absoluteBoundingBox;
|
|
2842
|
+
const parentBB = parent.absoluteBoundingBox;
|
|
2843
|
+
if (!nodeBB || !parentBB) return false;
|
|
2844
|
+
if (parentBB.width === 0 || parentBB.height === 0) return false;
|
|
2845
|
+
const widthRatio = nodeBB.width / parentBB.width;
|
|
2846
|
+
const heightRatio = nodeBB.height / parentBB.height;
|
|
2847
|
+
return widthRatio < 0.25 && heightRatio < 0.25;
|
|
2848
|
+
}
|
|
2849
|
+
var absolutePositionInAutoLayoutCheck = (node, context) => {
|
|
2850
|
+
if (!context.parent) return null;
|
|
2851
|
+
if (!hasAutoLayout(context.parent)) return null;
|
|
2852
|
+
if (node.layoutPositioning !== "ABSOLUTE") return null;
|
|
2853
|
+
if (INTENTIONAL_ABSOLUTE_PATTERNS.test(node.name)) return null;
|
|
2854
|
+
if (isSmallRelativeToParent(node, context.parent)) return null;
|
|
2855
|
+
if (context.parent.type === "COMPONENT") return null;
|
|
2856
|
+
return {
|
|
2857
|
+
ruleId: absolutePositionInAutoLayoutDef.id,
|
|
2858
|
+
nodeId: node.id,
|
|
2859
|
+
nodePath: context.path.join(" > "),
|
|
2860
|
+
message: `"${node.name}" uses absolute positioning inside Auto Layout parent "${context.parent.name}". If intentional (badge, overlay, close button), rename to badge-*, overlay-*, close-* to suppress this warning.`
|
|
2861
|
+
};
|
|
2862
|
+
};
|
|
2863
|
+
defineRule({
|
|
2864
|
+
definition: absolutePositionInAutoLayoutDef,
|
|
2865
|
+
check: absolutePositionInAutoLayoutCheck
|
|
2866
|
+
});
|
|
2867
|
+
var fixedWidthInResponsiveContextDef = {
|
|
2868
|
+
id: "fixed-width-in-responsive-context",
|
|
2869
|
+
name: "Fixed Width in Responsive Context",
|
|
2870
|
+
category: "layout",
|
|
2871
|
+
why: "Fixed width inside Auto Layout prevents responsive behavior",
|
|
2872
|
+
impact: "Content will not adapt to container size changes",
|
|
2873
|
+
fix: "Use 'Fill' or 'Hug' instead of fixed width"
|
|
2874
|
+
};
|
|
2875
|
+
var fixedWidthInResponsiveContextCheck = (node, context) => {
|
|
2876
|
+
if (!context.parent) return null;
|
|
2877
|
+
if (!hasAutoLayout(context.parent)) return null;
|
|
2878
|
+
if (!isContainerNode(node)) return null;
|
|
2879
|
+
if (node.layoutAlign === "STRETCH") return null;
|
|
2880
|
+
const bbox = node.absoluteBoundingBox;
|
|
2881
|
+
if (!bbox) return null;
|
|
2882
|
+
if (node.layoutAlign !== "INHERIT") return null;
|
|
2883
|
+
return {
|
|
2884
|
+
ruleId: fixedWidthInResponsiveContextDef.id,
|
|
2885
|
+
nodeId: node.id,
|
|
2886
|
+
nodePath: context.path.join(" > "),
|
|
2887
|
+
message: `"${node.name}" has fixed width inside Auto Layout`
|
|
2888
|
+
};
|
|
2889
|
+
};
|
|
2890
|
+
defineRule({
|
|
2891
|
+
definition: fixedWidthInResponsiveContextDef,
|
|
2892
|
+
check: fixedWidthInResponsiveContextCheck
|
|
2893
|
+
});
|
|
2894
|
+
var missingResponsiveBehaviorDef = {
|
|
2895
|
+
id: "missing-responsive-behavior",
|
|
2896
|
+
name: "Missing Responsive Behavior",
|
|
2897
|
+
category: "layout",
|
|
2898
|
+
why: "Elements without constraints won't adapt to different screen sizes",
|
|
2899
|
+
impact: "Layout will break or look wrong on different devices",
|
|
2900
|
+
fix: "Set appropriate constraints (left/right, top/bottom, scale, etc.)"
|
|
2901
|
+
};
|
|
2902
|
+
var missingResponsiveBehaviorCheck = (node, context) => {
|
|
2903
|
+
if (!isContainerNode(node)) return null;
|
|
2904
|
+
if (context.parent && hasAutoLayout(context.parent)) return null;
|
|
2905
|
+
if (context.depth < 2) return null;
|
|
2906
|
+
if (!hasAutoLayout(node) && !node.layoutAlign) {
|
|
2907
|
+
return {
|
|
2908
|
+
ruleId: missingResponsiveBehaviorDef.id,
|
|
2909
|
+
nodeId: node.id,
|
|
2910
|
+
nodePath: context.path.join(" > "),
|
|
2911
|
+
message: `"${node.name}" has no responsive behavior configured`
|
|
2912
|
+
};
|
|
2913
|
+
}
|
|
2914
|
+
return null;
|
|
2915
|
+
};
|
|
2916
|
+
defineRule({
|
|
2917
|
+
definition: missingResponsiveBehaviorDef,
|
|
2918
|
+
check: missingResponsiveBehaviorCheck
|
|
2919
|
+
});
|
|
2920
|
+
var groupUsageDef = {
|
|
2921
|
+
id: "group-usage",
|
|
2922
|
+
name: "Group Usage",
|
|
2923
|
+
category: "layout",
|
|
2924
|
+
why: "Groups don't support Auto Layout and have limited layout control",
|
|
2925
|
+
impact: "Harder to maintain consistent spacing and alignment",
|
|
2926
|
+
fix: "Convert Group to Frame and apply Auto Layout"
|
|
2927
|
+
};
|
|
2928
|
+
var groupUsageCheck = (node, context) => {
|
|
2929
|
+
if (node.type !== "GROUP") return null;
|
|
2930
|
+
return {
|
|
2931
|
+
ruleId: groupUsageDef.id,
|
|
2932
|
+
nodeId: node.id,
|
|
2933
|
+
nodePath: context.path.join(" > "),
|
|
2934
|
+
message: `"${node.name}" is a Group - consider converting to Frame with Auto Layout`
|
|
2935
|
+
};
|
|
2936
|
+
};
|
|
2937
|
+
defineRule({
|
|
2938
|
+
definition: groupUsageDef,
|
|
2939
|
+
check: groupUsageCheck
|
|
2940
|
+
});
|
|
2941
|
+
var fixedSizeInAutoLayoutDef = {
|
|
2942
|
+
id: "fixed-size-in-auto-layout",
|
|
2943
|
+
name: "Fixed Size in Auto Layout",
|
|
2944
|
+
category: "layout",
|
|
2945
|
+
why: "Fixed sizes inside Auto Layout limit flexibility",
|
|
2946
|
+
impact: "Element won't adapt to content or container changes",
|
|
2947
|
+
fix: "Consider using 'Hug' for content-driven sizing"
|
|
2948
|
+
};
|
|
2949
|
+
var fixedSizeInAutoLayoutCheck = (node, context) => {
|
|
2950
|
+
if (!context.parent) return null;
|
|
2951
|
+
if (!hasAutoLayout(context.parent)) return null;
|
|
2952
|
+
if (!isContainerNode(node)) return null;
|
|
2953
|
+
if (!node.absoluteBoundingBox) return null;
|
|
2954
|
+
const { width, height } = node.absoluteBoundingBox;
|
|
2955
|
+
if (width <= 48 && height <= 48) return null;
|
|
2956
|
+
return null;
|
|
2957
|
+
};
|
|
2958
|
+
defineRule({
|
|
2959
|
+
definition: fixedSizeInAutoLayoutDef,
|
|
2960
|
+
check: fixedSizeInAutoLayoutCheck
|
|
2961
|
+
});
|
|
2962
|
+
var missingMinWidthDef = {
|
|
2963
|
+
id: "missing-min-width",
|
|
2964
|
+
name: "Missing Min Width",
|
|
2965
|
+
category: "layout",
|
|
2966
|
+
why: "Without min-width, containers can collapse to unusable sizes",
|
|
2967
|
+
impact: "Text truncation or layout collapse on narrow screens",
|
|
2968
|
+
fix: "Set a minimum width constraint on the container"
|
|
2969
|
+
};
|
|
2970
|
+
var missingMinWidthCheck = (node, context) => {
|
|
2971
|
+
if (!isContainerNode(node) && !hasTextContent(node)) return null;
|
|
2972
|
+
if (node.absoluteBoundingBox) {
|
|
2973
|
+
const { width, height } = node.absoluteBoundingBox;
|
|
2974
|
+
if (width <= 48 && height <= 24) return null;
|
|
2975
|
+
}
|
|
2976
|
+
if (!context.parent || !hasAutoLayout(context.parent)) return null;
|
|
2977
|
+
return null;
|
|
2978
|
+
};
|
|
2979
|
+
defineRule({
|
|
2980
|
+
definition: missingMinWidthDef,
|
|
2981
|
+
check: missingMinWidthCheck
|
|
2982
|
+
});
|
|
2983
|
+
var missingMaxWidthDef = {
|
|
2984
|
+
id: "missing-max-width",
|
|
2985
|
+
name: "Missing Max Width",
|
|
2986
|
+
category: "layout",
|
|
2987
|
+
why: "Without max-width, content can stretch too wide on large screens",
|
|
2988
|
+
impact: "Poor readability and layout on wide screens",
|
|
2989
|
+
fix: "Set a maximum width constraint, especially for text containers"
|
|
2990
|
+
};
|
|
2991
|
+
var missingMaxWidthCheck = (node, _context) => {
|
|
2992
|
+
if (!isContainerNode(node) && !hasTextContent(node)) return null;
|
|
2993
|
+
if (node.absoluteBoundingBox) {
|
|
2994
|
+
const { width } = node.absoluteBoundingBox;
|
|
2995
|
+
if (width <= 200) return null;
|
|
2996
|
+
}
|
|
2997
|
+
return null;
|
|
2998
|
+
};
|
|
2999
|
+
defineRule({
|
|
3000
|
+
definition: missingMaxWidthDef,
|
|
3001
|
+
check: missingMaxWidthCheck
|
|
3002
|
+
});
|
|
3003
|
+
var deepNestingDef = {
|
|
3004
|
+
id: "deep-nesting",
|
|
3005
|
+
name: "Deep Nesting",
|
|
3006
|
+
category: "layout",
|
|
3007
|
+
why: "Deep nesting makes the structure hard to understand and maintain",
|
|
3008
|
+
impact: "Increases complexity, harder to debug layout issues",
|
|
3009
|
+
fix: "Flatten the structure by removing unnecessary wrapper frames"
|
|
3010
|
+
};
|
|
3011
|
+
var deepNestingCheck = (node, context, options) => {
|
|
3012
|
+
const maxDepth = options?.["maxDepth"] ?? getRuleOption("deep-nesting", "maxDepth", 5);
|
|
3013
|
+
if (context.depth < maxDepth) return null;
|
|
3014
|
+
if (!isContainerNode(node)) return null;
|
|
3015
|
+
return {
|
|
3016
|
+
ruleId: deepNestingDef.id,
|
|
3017
|
+
nodeId: node.id,
|
|
3018
|
+
nodePath: context.path.join(" > "),
|
|
3019
|
+
message: `"${node.name}" is nested ${context.depth} levels deep (max: ${maxDepth})`
|
|
3020
|
+
};
|
|
3021
|
+
};
|
|
3022
|
+
defineRule({
|
|
3023
|
+
definition: deepNestingDef,
|
|
3024
|
+
check: deepNestingCheck
|
|
3025
|
+
});
|
|
3026
|
+
var overflowHiddenAbuseDef = {
|
|
3027
|
+
id: "overflow-hidden-abuse",
|
|
3028
|
+
name: "Overflow Hidden Abuse",
|
|
3029
|
+
category: "layout",
|
|
3030
|
+
why: "Using clip content to hide layout problems masks underlying issues",
|
|
3031
|
+
impact: "Content may be unintentionally cut off, problems harder to diagnose",
|
|
3032
|
+
fix: "Fix the underlying layout issue instead of hiding overflow"
|
|
3033
|
+
};
|
|
3034
|
+
var overflowHiddenAbuseCheck = (_node, _context) => {
|
|
3035
|
+
return null;
|
|
3036
|
+
};
|
|
3037
|
+
defineRule({
|
|
3038
|
+
definition: overflowHiddenAbuseDef,
|
|
3039
|
+
check: overflowHiddenAbuseCheck
|
|
3040
|
+
});
|
|
3041
|
+
var inconsistentSiblingLayoutDirectionDef = {
|
|
3042
|
+
id: "inconsistent-sibling-layout-direction",
|
|
3043
|
+
name: "Inconsistent Sibling Layout Direction",
|
|
3044
|
+
category: "layout",
|
|
3045
|
+
why: "Sibling containers with mixed layout directions without clear reason create confusion",
|
|
3046
|
+
impact: "Harder to understand and maintain the design structure",
|
|
3047
|
+
fix: "Use consistent layout direction for similar sibling elements"
|
|
3048
|
+
};
|
|
3049
|
+
var inconsistentSiblingLayoutDirectionCheck = (node, context) => {
|
|
3050
|
+
if (!isContainerNode(node)) return null;
|
|
3051
|
+
if (!context.siblings || context.siblings.length < 2) return null;
|
|
3052
|
+
const siblingContainers = context.siblings.filter(
|
|
3053
|
+
(s) => isContainerNode(s) && s.id !== node.id
|
|
3054
|
+
);
|
|
3055
|
+
if (siblingContainers.length === 0) return null;
|
|
3056
|
+
const myDirection = node.layoutMode;
|
|
3057
|
+
if (!myDirection || myDirection === "NONE") return null;
|
|
3058
|
+
const siblingDirections = siblingContainers.map((s) => s.layoutMode).filter((d) => d && d !== "NONE");
|
|
3059
|
+
if (siblingDirections.length === 0) return null;
|
|
3060
|
+
const allSameSiblingDirection = siblingDirections.every(
|
|
3061
|
+
(d) => d === siblingDirections[0]
|
|
3062
|
+
);
|
|
3063
|
+
if (allSameSiblingDirection && siblingDirections[0] !== myDirection) {
|
|
3064
|
+
if (context.parent?.layoutMode === "HORIZONTAL" && myDirection === "VERTICAL") {
|
|
3065
|
+
return null;
|
|
3066
|
+
}
|
|
3067
|
+
return {
|
|
3068
|
+
ruleId: inconsistentSiblingLayoutDirectionDef.id,
|
|
3069
|
+
nodeId: node.id,
|
|
3070
|
+
nodePath: context.path.join(" > "),
|
|
3071
|
+
message: `"${node.name}" has ${myDirection} layout while siblings use ${siblingDirections[0]}`
|
|
3072
|
+
};
|
|
3073
|
+
}
|
|
3074
|
+
return null;
|
|
3075
|
+
};
|
|
3076
|
+
defineRule({
|
|
3077
|
+
definition: inconsistentSiblingLayoutDirectionDef,
|
|
3078
|
+
check: inconsistentSiblingLayoutDirectionCheck
|
|
3079
|
+
});
|
|
3080
|
+
|
|
3081
|
+
// src/rules/token/index.ts
|
|
3082
|
+
function hasStyleReference(node, styleType) {
|
|
3083
|
+
return node.styles !== void 0 && styleType in node.styles;
|
|
3084
|
+
}
|
|
3085
|
+
function hasBoundVariable(node, key) {
|
|
3086
|
+
return node.boundVariables !== void 0 && key in node.boundVariables;
|
|
3087
|
+
}
|
|
3088
|
+
function isOnGrid(value, gridBase) {
|
|
3089
|
+
return value % gridBase === 0;
|
|
3090
|
+
}
|
|
3091
|
+
var rawColorDef = {
|
|
3092
|
+
id: "raw-color",
|
|
3093
|
+
name: "Raw Color",
|
|
3094
|
+
category: "token",
|
|
3095
|
+
why: "Raw hex colors are not connected to the design system",
|
|
3096
|
+
impact: "Color changes require manual updates across the entire design",
|
|
3097
|
+
fix: "Use a color style or variable instead of raw hex values"
|
|
3098
|
+
};
|
|
3099
|
+
var rawColorCheck = (node, context) => {
|
|
3100
|
+
if (!node.fills || !Array.isArray(node.fills)) return null;
|
|
3101
|
+
if (node.fills.length === 0) return null;
|
|
3102
|
+
if (hasStyleReference(node, "fill")) return null;
|
|
3103
|
+
if (hasBoundVariable(node, "fills")) return null;
|
|
3104
|
+
for (const fill of node.fills) {
|
|
3105
|
+
const fillObj = fill;
|
|
3106
|
+
if (fillObj["type"] === "SOLID" && fillObj["color"]) {
|
|
3107
|
+
return {
|
|
3108
|
+
ruleId: rawColorDef.id,
|
|
3109
|
+
nodeId: node.id,
|
|
3110
|
+
nodePath: context.path.join(" > "),
|
|
3111
|
+
message: `"${node.name}" uses raw color without style or variable`
|
|
3112
|
+
};
|
|
3113
|
+
}
|
|
3114
|
+
}
|
|
3115
|
+
return null;
|
|
3116
|
+
};
|
|
3117
|
+
defineRule({
|
|
3118
|
+
definition: rawColorDef,
|
|
3119
|
+
check: rawColorCheck
|
|
3120
|
+
});
|
|
3121
|
+
var rawFontDef = {
|
|
3122
|
+
id: "raw-font",
|
|
3123
|
+
name: "Raw Font",
|
|
3124
|
+
category: "token",
|
|
3125
|
+
why: "Text without text styles is disconnected from the type system",
|
|
3126
|
+
impact: "Typography changes require manual updates across the design",
|
|
3127
|
+
fix: "Apply a text style to maintain consistency"
|
|
3128
|
+
};
|
|
3129
|
+
var rawFontCheck = (node, context) => {
|
|
3130
|
+
if (node.type !== "TEXT") return null;
|
|
3131
|
+
if (hasStyleReference(node, "text")) return null;
|
|
3132
|
+
if (hasBoundVariable(node, "fontFamily") || hasBoundVariable(node, "fontSize")) {
|
|
3133
|
+
return null;
|
|
3134
|
+
}
|
|
3135
|
+
return {
|
|
3136
|
+
ruleId: rawFontDef.id,
|
|
3137
|
+
nodeId: node.id,
|
|
3138
|
+
nodePath: context.path.join(" > "),
|
|
3139
|
+
message: `"${node.name}" has no text style applied`
|
|
3140
|
+
};
|
|
3141
|
+
};
|
|
3142
|
+
defineRule({
|
|
3143
|
+
definition: rawFontDef,
|
|
3144
|
+
check: rawFontCheck
|
|
3145
|
+
});
|
|
3146
|
+
var inconsistentSpacingDef = {
|
|
3147
|
+
id: "inconsistent-spacing",
|
|
3148
|
+
name: "Inconsistent Spacing",
|
|
3149
|
+
category: "token",
|
|
3150
|
+
why: "Spacing values outside the grid system break visual consistency",
|
|
3151
|
+
impact: "Inconsistent visual rhythm and harder to maintain",
|
|
3152
|
+
fix: "Use spacing values from the design system grid (e.g., 8pt increments)"
|
|
3153
|
+
};
|
|
3154
|
+
var inconsistentSpacingCheck = (node, context, options) => {
|
|
3155
|
+
const gridBase = options?.["gridBase"] ?? getRuleOption("inconsistent-spacing", "gridBase", 8);
|
|
3156
|
+
const paddings = [
|
|
3157
|
+
node.paddingLeft,
|
|
3158
|
+
node.paddingRight,
|
|
3159
|
+
node.paddingTop,
|
|
3160
|
+
node.paddingBottom
|
|
3161
|
+
].filter((p) => p !== void 0 && p > 0);
|
|
3162
|
+
for (const padding of paddings) {
|
|
3163
|
+
if (!isOnGrid(padding, gridBase)) {
|
|
3164
|
+
return {
|
|
3165
|
+
ruleId: inconsistentSpacingDef.id,
|
|
3166
|
+
nodeId: node.id,
|
|
3167
|
+
nodePath: context.path.join(" > "),
|
|
3168
|
+
message: `"${node.name}" has padding ${padding}px not on ${gridBase}pt grid`
|
|
3169
|
+
};
|
|
3170
|
+
}
|
|
3171
|
+
}
|
|
3172
|
+
if (node.itemSpacing !== void 0 && node.itemSpacing > 0) {
|
|
3173
|
+
if (!isOnGrid(node.itemSpacing, gridBase)) {
|
|
3174
|
+
return {
|
|
3175
|
+
ruleId: inconsistentSpacingDef.id,
|
|
3176
|
+
nodeId: node.id,
|
|
3177
|
+
nodePath: context.path.join(" > "),
|
|
3178
|
+
message: `"${node.name}" has item spacing ${node.itemSpacing}px not on ${gridBase}pt grid`
|
|
3179
|
+
};
|
|
3180
|
+
}
|
|
3181
|
+
}
|
|
3182
|
+
return null;
|
|
3183
|
+
};
|
|
3184
|
+
defineRule({
|
|
3185
|
+
definition: inconsistentSpacingDef,
|
|
3186
|
+
check: inconsistentSpacingCheck
|
|
3187
|
+
});
|
|
3188
|
+
var magicNumberSpacingDef = {
|
|
3189
|
+
id: "magic-number-spacing",
|
|
3190
|
+
name: "Magic Number Spacing",
|
|
3191
|
+
category: "token",
|
|
3192
|
+
why: "Arbitrary spacing values make the system harder to understand",
|
|
3193
|
+
impact: "Unpredictable spacing, harder to create consistent layouts",
|
|
3194
|
+
fix: "Round spacing to the nearest grid value or use spacing tokens"
|
|
3195
|
+
};
|
|
3196
|
+
var magicNumberSpacingCheck = (node, context, options) => {
|
|
3197
|
+
const gridBase = options?.["gridBase"] ?? getRuleOption("magic-number-spacing", "gridBase", 8);
|
|
3198
|
+
const allSpacings = [
|
|
3199
|
+
node.paddingLeft,
|
|
3200
|
+
node.paddingRight,
|
|
3201
|
+
node.paddingTop,
|
|
3202
|
+
node.paddingBottom,
|
|
3203
|
+
node.itemSpacing
|
|
3204
|
+
].filter((s) => s !== void 0 && s > 0);
|
|
3205
|
+
for (const spacing of allSpacings) {
|
|
3206
|
+
const commonValues = [1, 2, 4];
|
|
3207
|
+
if (!isOnGrid(spacing, gridBase) && !commonValues.includes(spacing)) {
|
|
3208
|
+
if (spacing % 2 !== 0 && spacing > 4) {
|
|
3209
|
+
return {
|
|
3210
|
+
ruleId: magicNumberSpacingDef.id,
|
|
3211
|
+
nodeId: node.id,
|
|
3212
|
+
nodePath: context.path.join(" > "),
|
|
3213
|
+
message: `"${node.name}" uses magic number spacing: ${spacing}px`
|
|
3214
|
+
};
|
|
3215
|
+
}
|
|
3216
|
+
}
|
|
3217
|
+
}
|
|
3218
|
+
return null;
|
|
3219
|
+
};
|
|
3220
|
+
defineRule({
|
|
3221
|
+
definition: magicNumberSpacingDef,
|
|
3222
|
+
check: magicNumberSpacingCheck
|
|
3223
|
+
});
|
|
3224
|
+
var rawShadowDef = {
|
|
3225
|
+
id: "raw-shadow",
|
|
3226
|
+
name: "Raw Shadow",
|
|
3227
|
+
category: "token",
|
|
3228
|
+
why: "Shadow effects without styles are disconnected from the design system",
|
|
3229
|
+
impact: "Shadow changes require manual updates across the design",
|
|
3230
|
+
fix: "Create and apply an effect style for shadows"
|
|
3231
|
+
};
|
|
3232
|
+
var rawShadowCheck = (node, context) => {
|
|
3233
|
+
if (!node.effects || !Array.isArray(node.effects)) return null;
|
|
3234
|
+
if (node.effects.length === 0) return null;
|
|
3235
|
+
if (hasStyleReference(node, "effect")) return null;
|
|
3236
|
+
for (const effect of node.effects) {
|
|
3237
|
+
const effectObj = effect;
|
|
3238
|
+
if (effectObj["type"] === "DROP_SHADOW" || effectObj["type"] === "INNER_SHADOW") {
|
|
3239
|
+
return {
|
|
3240
|
+
ruleId: rawShadowDef.id,
|
|
3241
|
+
nodeId: node.id,
|
|
3242
|
+
nodePath: context.path.join(" > "),
|
|
3243
|
+
message: `"${node.name}" has shadow effect without effect style`
|
|
3244
|
+
};
|
|
3245
|
+
}
|
|
3246
|
+
}
|
|
3247
|
+
return null;
|
|
3248
|
+
};
|
|
3249
|
+
defineRule({
|
|
3250
|
+
definition: rawShadowDef,
|
|
3251
|
+
check: rawShadowCheck
|
|
3252
|
+
});
|
|
3253
|
+
var rawOpacityDef = {
|
|
3254
|
+
id: "raw-opacity",
|
|
3255
|
+
name: "Raw Opacity",
|
|
3256
|
+
category: "token",
|
|
3257
|
+
why: "Hardcoded opacity values are not connected to design tokens",
|
|
3258
|
+
impact: "Opacity changes require manual updates",
|
|
3259
|
+
fix: "Use opacity variables or consider if opacity is truly needed"
|
|
3260
|
+
};
|
|
3261
|
+
var rawOpacityCheck = (node, _context) => {
|
|
3262
|
+
if (hasBoundVariable(node, "opacity")) return null;
|
|
3263
|
+
return null;
|
|
3264
|
+
};
|
|
3265
|
+
defineRule({
|
|
3266
|
+
definition: rawOpacityDef,
|
|
3267
|
+
check: rawOpacityCheck
|
|
3268
|
+
});
|
|
3269
|
+
var multipleFillColorsDef = {
|
|
3270
|
+
id: "multiple-fill-colors",
|
|
3271
|
+
name: "Multiple Fill Colors",
|
|
3272
|
+
category: "token",
|
|
3273
|
+
why: "Similar but slightly different colors indicate inconsistent token usage",
|
|
3274
|
+
impact: "Visual inconsistency and harder to maintain brand colors",
|
|
3275
|
+
fix: "Consolidate to a single color token or style"
|
|
3276
|
+
};
|
|
3277
|
+
var multipleFillColorsCheck = (_node, _context, _options) => {
|
|
3278
|
+
return null;
|
|
3279
|
+
};
|
|
3280
|
+
defineRule({
|
|
3281
|
+
definition: multipleFillColorsDef,
|
|
3282
|
+
check: multipleFillColorsCheck
|
|
3283
|
+
});
|
|
3284
|
+
|
|
3285
|
+
// src/rules/component/index.ts
|
|
3286
|
+
function isComponentInstance(node) {
|
|
3287
|
+
return node.type === "INSTANCE";
|
|
3288
|
+
}
|
|
3289
|
+
function isComponent(node) {
|
|
3290
|
+
return node.type === "COMPONENT" || node.type === "COMPONENT_SET";
|
|
3291
|
+
}
|
|
3292
|
+
function collectFrameNames(node, names = /* @__PURE__ */ new Map()) {
|
|
3293
|
+
if (node.type === "FRAME" && node.name) {
|
|
3294
|
+
const existing = names.get(node.name) ?? [];
|
|
3295
|
+
existing.push(node.id);
|
|
3296
|
+
names.set(node.name, existing);
|
|
3297
|
+
}
|
|
3298
|
+
if (node.children) {
|
|
3299
|
+
for (const child of node.children) {
|
|
3300
|
+
collectFrameNames(child, names);
|
|
3301
|
+
}
|
|
3302
|
+
}
|
|
3303
|
+
return names;
|
|
3304
|
+
}
|
|
3305
|
+
var missingComponentDef = {
|
|
3306
|
+
id: "missing-component",
|
|
3307
|
+
name: "Missing Component",
|
|
3308
|
+
category: "component",
|
|
3309
|
+
why: "Repeated identical structures should be componentized",
|
|
3310
|
+
impact: "Changes require manual updates in multiple places",
|
|
3311
|
+
fix: "Create a component from the repeated structure"
|
|
3312
|
+
};
|
|
3313
|
+
var missingComponentCheck = (node, context, options) => {
|
|
3314
|
+
if (node.type !== "FRAME") return null;
|
|
3315
|
+
const minRepetitions = options?.["minRepetitions"] ?? getRuleOption("missing-component", "minRepetitions", 3);
|
|
3316
|
+
const frameNames = collectFrameNames(context.file.document);
|
|
3317
|
+
const sameNameFrames = frameNames.get(node.name);
|
|
3318
|
+
if (sameNameFrames && sameNameFrames.length >= minRepetitions) {
|
|
3319
|
+
if (sameNameFrames[0] === node.id) {
|
|
3320
|
+
return {
|
|
3321
|
+
ruleId: missingComponentDef.id,
|
|
3322
|
+
nodeId: node.id,
|
|
3323
|
+
nodePath: context.path.join(" > "),
|
|
3324
|
+
message: `"${node.name}" appears ${sameNameFrames.length} times - consider making it a component`
|
|
3325
|
+
};
|
|
3326
|
+
}
|
|
3327
|
+
}
|
|
3328
|
+
return null;
|
|
3329
|
+
};
|
|
3330
|
+
defineRule({
|
|
3331
|
+
definition: missingComponentDef,
|
|
3332
|
+
check: missingComponentCheck
|
|
3333
|
+
});
|
|
3334
|
+
var detachedInstanceDef = {
|
|
3335
|
+
id: "detached-instance",
|
|
3336
|
+
name: "Detached Instance",
|
|
3337
|
+
category: "component",
|
|
3338
|
+
why: "Detached instances lose their connection to the source component",
|
|
3339
|
+
impact: "Updates to the component won't propagate to this instance",
|
|
3340
|
+
fix: "Reset the instance or create a new variant if customization is needed"
|
|
3341
|
+
};
|
|
3342
|
+
var detachedInstanceCheck = (node, context) => {
|
|
3343
|
+
if (node.type !== "FRAME") return null;
|
|
3344
|
+
const components = context.file.components;
|
|
3345
|
+
const nodeName = node.name.toLowerCase();
|
|
3346
|
+
for (const [, component] of Object.entries(components)) {
|
|
3347
|
+
if (nodeName.includes(component.name.toLowerCase())) {
|
|
3348
|
+
return {
|
|
3349
|
+
ruleId: detachedInstanceDef.id,
|
|
3350
|
+
nodeId: node.id,
|
|
3351
|
+
nodePath: context.path.join(" > "),
|
|
3352
|
+
message: `"${node.name}" may be a detached instance of component "${component.name}"`
|
|
3353
|
+
};
|
|
3354
|
+
}
|
|
3355
|
+
}
|
|
3356
|
+
return null;
|
|
3357
|
+
};
|
|
3358
|
+
defineRule({
|
|
3359
|
+
definition: detachedInstanceDef,
|
|
3360
|
+
check: detachedInstanceCheck
|
|
3361
|
+
});
|
|
3362
|
+
var nestedInstanceOverrideDef = {
|
|
3363
|
+
id: "nested-instance-override",
|
|
3364
|
+
name: "Nested Instance Override",
|
|
3365
|
+
category: "component",
|
|
3366
|
+
why: "Excessive overrides in instances make components harder to maintain",
|
|
3367
|
+
impact: "Component updates may not work as expected",
|
|
3368
|
+
fix: "Create a variant or new component for significantly different use cases"
|
|
3369
|
+
};
|
|
3370
|
+
var nestedInstanceOverrideCheck = (node, context) => {
|
|
3371
|
+
if (!isComponentInstance(node)) return null;
|
|
3372
|
+
if (!node.componentProperties) return null;
|
|
3373
|
+
const overrideCount = Object.keys(node.componentProperties).length;
|
|
3374
|
+
if (overrideCount > 5) {
|
|
3375
|
+
return {
|
|
3376
|
+
ruleId: nestedInstanceOverrideDef.id,
|
|
3377
|
+
nodeId: node.id,
|
|
3378
|
+
nodePath: context.path.join(" > "),
|
|
3379
|
+
message: `"${node.name}" has ${overrideCount} property overrides - consider creating a variant`
|
|
3380
|
+
};
|
|
3381
|
+
}
|
|
3382
|
+
return null;
|
|
3383
|
+
};
|
|
3384
|
+
defineRule({
|
|
3385
|
+
definition: nestedInstanceOverrideDef,
|
|
3386
|
+
check: nestedInstanceOverrideCheck
|
|
3387
|
+
});
|
|
3388
|
+
var variantNotUsedDef = {
|
|
3389
|
+
id: "variant-not-used",
|
|
3390
|
+
name: "Variant Not Used",
|
|
3391
|
+
category: "component",
|
|
3392
|
+
why: "Using instances but not leveraging variants defeats their purpose",
|
|
3393
|
+
impact: "Manual changes instead of using designed variants",
|
|
3394
|
+
fix: "Use the appropriate variant instead of overriding the default"
|
|
3395
|
+
};
|
|
3396
|
+
var variantNotUsedCheck = (_node, _context) => {
|
|
3397
|
+
return null;
|
|
3398
|
+
};
|
|
3399
|
+
defineRule({
|
|
3400
|
+
definition: variantNotUsedDef,
|
|
3401
|
+
check: variantNotUsedCheck
|
|
3402
|
+
});
|
|
3403
|
+
var componentPropertyUnusedDef = {
|
|
3404
|
+
id: "component-property-unused",
|
|
3405
|
+
name: "Component Property Unused",
|
|
3406
|
+
category: "component",
|
|
3407
|
+
why: "Component properties should be utilized to expose customization",
|
|
3408
|
+
impact: "Hardcoded values that should be configurable",
|
|
3409
|
+
fix: "Connect the value to a component property"
|
|
3410
|
+
};
|
|
3411
|
+
var componentPropertyUnusedCheck = (node, _context) => {
|
|
3412
|
+
if (!isComponent(node)) return null;
|
|
3413
|
+
if (!node.componentPropertyDefinitions) return null;
|
|
3414
|
+
const definedProps = Object.keys(node.componentPropertyDefinitions);
|
|
3415
|
+
if (definedProps.length === 0) return null;
|
|
3416
|
+
return null;
|
|
3417
|
+
};
|
|
3418
|
+
defineRule({
|
|
3419
|
+
definition: componentPropertyUnusedDef,
|
|
3420
|
+
check: componentPropertyUnusedCheck
|
|
3421
|
+
});
|
|
3422
|
+
var singleUseComponentDef = {
|
|
3423
|
+
id: "single-use-component",
|
|
3424
|
+
name: "Single Use Component",
|
|
3425
|
+
category: "component",
|
|
3426
|
+
why: "Components used only once add complexity without reuse benefit",
|
|
3427
|
+
impact: "Unnecessary abstraction increases maintenance overhead",
|
|
3428
|
+
fix: "Consider inlining if this component won't be reused"
|
|
3429
|
+
};
|
|
3430
|
+
var singleUseComponentCheck = (node, context) => {
|
|
3431
|
+
if (!isComponent(node)) return null;
|
|
3432
|
+
let instanceCount = 0;
|
|
3433
|
+
function countInstances(n) {
|
|
3434
|
+
if (n.type === "INSTANCE" && n.componentId === node.id) {
|
|
3435
|
+
instanceCount++;
|
|
3436
|
+
}
|
|
3437
|
+
if (n.children) {
|
|
3438
|
+
for (const child of n.children) {
|
|
3439
|
+
countInstances(child);
|
|
3440
|
+
}
|
|
3441
|
+
}
|
|
3442
|
+
}
|
|
3443
|
+
countInstances(context.file.document);
|
|
3444
|
+
if (instanceCount === 1) {
|
|
3445
|
+
return {
|
|
3446
|
+
ruleId: singleUseComponentDef.id,
|
|
3447
|
+
nodeId: node.id,
|
|
3448
|
+
nodePath: context.path.join(" > "),
|
|
3449
|
+
message: `Component "${node.name}" is only used once`
|
|
3450
|
+
};
|
|
3451
|
+
}
|
|
3452
|
+
return null;
|
|
3453
|
+
};
|
|
3454
|
+
defineRule({
|
|
3455
|
+
definition: singleUseComponentDef,
|
|
3456
|
+
check: singleUseComponentCheck
|
|
3457
|
+
});
|
|
3458
|
+
|
|
3459
|
+
// src/rules/naming/index.ts
|
|
3460
|
+
var DEFAULT_NAME_PATTERNS = [
|
|
3461
|
+
/^Frame\s*\d*$/i,
|
|
3462
|
+
/^Group\s*\d*$/i,
|
|
3463
|
+
/^Rectangle\s*\d*$/i,
|
|
3464
|
+
/^Ellipse\s*\d*$/i,
|
|
3465
|
+
/^Vector\s*\d*$/i,
|
|
3466
|
+
/^Line\s*\d*$/i,
|
|
3467
|
+
/^Text\s*\d*$/i,
|
|
3468
|
+
/^Image\s*\d*$/i,
|
|
3469
|
+
/^Component\s*\d*$/i,
|
|
3470
|
+
/^Instance\s*\d*$/i
|
|
3471
|
+
];
|
|
3472
|
+
var NON_SEMANTIC_NAMES = [
|
|
3473
|
+
"rectangle",
|
|
3474
|
+
"ellipse",
|
|
3475
|
+
"vector",
|
|
3476
|
+
"line",
|
|
3477
|
+
"polygon",
|
|
3478
|
+
"star",
|
|
3479
|
+
"path",
|
|
3480
|
+
"shape",
|
|
3481
|
+
"image",
|
|
3482
|
+
"fill",
|
|
3483
|
+
"stroke"
|
|
3484
|
+
];
|
|
3485
|
+
function isDefaultName(name) {
|
|
3486
|
+
return DEFAULT_NAME_PATTERNS.some((pattern) => pattern.test(name));
|
|
3487
|
+
}
|
|
3488
|
+
function isNonSemanticName(name) {
|
|
3489
|
+
const normalized = name.toLowerCase().trim();
|
|
3490
|
+
return NON_SEMANTIC_NAMES.includes(normalized);
|
|
3491
|
+
}
|
|
3492
|
+
function hasNumericSuffix(name) {
|
|
3493
|
+
return /\s+\d+$/.test(name);
|
|
3494
|
+
}
|
|
3495
|
+
function detectNamingConvention(name) {
|
|
3496
|
+
if (/^[a-z]+(-[a-z]+)*$/.test(name)) return "kebab-case";
|
|
3497
|
+
if (/^[a-z]+(_[a-z]+)*$/.test(name)) return "snake_case";
|
|
3498
|
+
if (/^[a-z]+([A-Z][a-z]*)*$/.test(name)) return "camelCase";
|
|
3499
|
+
if (/^[A-Z][a-z]+([A-Z][a-z]*)*$/.test(name)) return "PascalCase";
|
|
3500
|
+
if (/^[A-Z]+(_[A-Z]+)*$/.test(name)) return "SCREAMING_SNAKE_CASE";
|
|
3501
|
+
if (/\s/.test(name)) return "Title Case";
|
|
3502
|
+
return null;
|
|
3503
|
+
}
|
|
3504
|
+
var defaultNameDef = {
|
|
3505
|
+
id: "default-name",
|
|
3506
|
+
name: "Default Name",
|
|
3507
|
+
category: "naming",
|
|
3508
|
+
why: "Default names like 'Frame 123' provide no context about the element's purpose",
|
|
3509
|
+
impact: "Designers and developers cannot understand the structure",
|
|
3510
|
+
fix: "Rename with a descriptive, semantic name (e.g., 'Header', 'ProductCard')"
|
|
3511
|
+
};
|
|
3512
|
+
var defaultNameCheck = (node, context) => {
|
|
3513
|
+
if (!node.name) return null;
|
|
3514
|
+
if (!isDefaultName(node.name)) return null;
|
|
3515
|
+
return {
|
|
3516
|
+
ruleId: defaultNameDef.id,
|
|
3517
|
+
nodeId: node.id,
|
|
3518
|
+
nodePath: context.path.join(" > "),
|
|
3519
|
+
message: `"${node.name}" is a default name - provide a meaningful name`
|
|
3520
|
+
};
|
|
3521
|
+
};
|
|
3522
|
+
defineRule({
|
|
3523
|
+
definition: defaultNameDef,
|
|
3524
|
+
check: defaultNameCheck
|
|
3525
|
+
});
|
|
3526
|
+
var nonSemanticNameDef = {
|
|
3527
|
+
id: "non-semantic-name",
|
|
3528
|
+
name: "Non-Semantic Name",
|
|
3529
|
+
category: "naming",
|
|
3530
|
+
why: "Names like 'Rectangle' describe shape, not purpose",
|
|
3531
|
+
impact: "Structure is hard to understand without context",
|
|
3532
|
+
fix: "Use names that describe what the element represents (e.g., 'Divider', 'Avatar')"
|
|
3533
|
+
};
|
|
3534
|
+
var nonSemanticNameCheck = (node, context) => {
|
|
3535
|
+
if (!node.name) return null;
|
|
3536
|
+
if (!isNonSemanticName(node.name)) return null;
|
|
3537
|
+
if (!node.children || node.children.length === 0) {
|
|
3538
|
+
const shapeTypes = ["RECTANGLE", "ELLIPSE", "VECTOR", "LINE", "STAR", "REGULAR_POLYGON"];
|
|
3539
|
+
if (shapeTypes.includes(node.type)) return null;
|
|
3540
|
+
}
|
|
3541
|
+
return {
|
|
3542
|
+
ruleId: nonSemanticNameDef.id,
|
|
3543
|
+
nodeId: node.id,
|
|
3544
|
+
nodePath: context.path.join(" > "),
|
|
3545
|
+
message: `"${node.name}" is a non-semantic name - describe its purpose`
|
|
3546
|
+
};
|
|
3547
|
+
};
|
|
3548
|
+
defineRule({
|
|
3549
|
+
definition: nonSemanticNameDef,
|
|
3550
|
+
check: nonSemanticNameCheck
|
|
3551
|
+
});
|
|
3552
|
+
var inconsistentNamingConventionDef = {
|
|
3553
|
+
id: "inconsistent-naming-convention",
|
|
3554
|
+
name: "Inconsistent Naming Convention",
|
|
3555
|
+
category: "naming",
|
|
3556
|
+
why: "Mixed naming conventions at the same level create confusion",
|
|
3557
|
+
impact: "Harder to navigate and maintain the design",
|
|
3558
|
+
fix: "Use a consistent naming convention for sibling elements"
|
|
3559
|
+
};
|
|
3560
|
+
var inconsistentNamingConventionCheck = (node, context) => {
|
|
3561
|
+
if (!context.siblings || context.siblings.length < 2) return null;
|
|
3562
|
+
const conventions = /* @__PURE__ */ new Map();
|
|
3563
|
+
for (const sibling of context.siblings) {
|
|
3564
|
+
if (!sibling.name) continue;
|
|
3565
|
+
const convention = detectNamingConvention(sibling.name);
|
|
3566
|
+
if (convention) {
|
|
3567
|
+
conventions.set(convention, (conventions.get(convention) ?? 0) + 1);
|
|
3568
|
+
}
|
|
3569
|
+
}
|
|
3570
|
+
if (conventions.size < 2) return null;
|
|
3571
|
+
let dominantConvention = "";
|
|
3572
|
+
let maxCount = 0;
|
|
3573
|
+
for (const [convention, count] of conventions) {
|
|
3574
|
+
if (count > maxCount) {
|
|
3575
|
+
maxCount = count;
|
|
3576
|
+
dominantConvention = convention;
|
|
3577
|
+
}
|
|
3578
|
+
}
|
|
3579
|
+
const nodeConvention = detectNamingConvention(node.name);
|
|
3580
|
+
if (nodeConvention && nodeConvention !== dominantConvention && maxCount >= 2) {
|
|
3581
|
+
return {
|
|
3582
|
+
ruleId: inconsistentNamingConventionDef.id,
|
|
3583
|
+
nodeId: node.id,
|
|
3584
|
+
nodePath: context.path.join(" > "),
|
|
3585
|
+
message: `"${node.name}" uses ${nodeConvention} while siblings use ${dominantConvention}`
|
|
3586
|
+
};
|
|
3587
|
+
}
|
|
3588
|
+
return null;
|
|
3589
|
+
};
|
|
3590
|
+
defineRule({
|
|
3591
|
+
definition: inconsistentNamingConventionDef,
|
|
3592
|
+
check: inconsistentNamingConventionCheck
|
|
3593
|
+
});
|
|
3594
|
+
var numericSuffixNameDef = {
|
|
3595
|
+
id: "numeric-suffix-name",
|
|
3596
|
+
name: "Numeric Suffix Name",
|
|
3597
|
+
category: "naming",
|
|
3598
|
+
why: "Names with numeric suffixes often indicate copy-paste duplication",
|
|
3599
|
+
impact: "Suggests the element might need componentization",
|
|
3600
|
+
fix: "Remove the suffix or create a component if duplicated"
|
|
3601
|
+
};
|
|
3602
|
+
var numericSuffixNameCheck = (node, context) => {
|
|
3603
|
+
if (!node.name) return null;
|
|
3604
|
+
if (isDefaultName(node.name)) return null;
|
|
3605
|
+
if (!hasNumericSuffix(node.name)) return null;
|
|
3606
|
+
return {
|
|
3607
|
+
ruleId: numericSuffixNameDef.id,
|
|
3608
|
+
nodeId: node.id,
|
|
3609
|
+
nodePath: context.path.join(" > "),
|
|
3610
|
+
message: `"${node.name}" has a numeric suffix - consider renaming`
|
|
3611
|
+
};
|
|
3612
|
+
};
|
|
3613
|
+
defineRule({
|
|
3614
|
+
definition: numericSuffixNameDef,
|
|
3615
|
+
check: numericSuffixNameCheck
|
|
3616
|
+
});
|
|
3617
|
+
var tooLongNameDef = {
|
|
3618
|
+
id: "too-long-name",
|
|
3619
|
+
name: "Too Long Name",
|
|
3620
|
+
category: "naming",
|
|
3621
|
+
why: "Very long names are hard to read and use in code",
|
|
3622
|
+
impact: "Clutters the layer panel and makes selectors unwieldy",
|
|
3623
|
+
fix: "Shorten the name while keeping it descriptive"
|
|
3624
|
+
};
|
|
3625
|
+
var tooLongNameCheck = (node, context, options) => {
|
|
3626
|
+
if (!node.name) return null;
|
|
3627
|
+
const maxLength = options?.["maxLength"] ?? getRuleOption("too-long-name", "maxLength", 50);
|
|
3628
|
+
if (node.name.length <= maxLength) return null;
|
|
3629
|
+
return {
|
|
3630
|
+
ruleId: tooLongNameDef.id,
|
|
3631
|
+
nodeId: node.id,
|
|
3632
|
+
nodePath: context.path.join(" > "),
|
|
3633
|
+
message: `"${node.name.substring(0, 30)}..." is ${node.name.length} chars (max: ${maxLength})`
|
|
3634
|
+
};
|
|
3635
|
+
};
|
|
3636
|
+
defineRule({
|
|
3637
|
+
definition: tooLongNameDef,
|
|
3638
|
+
check: tooLongNameCheck
|
|
3639
|
+
});
|
|
3640
|
+
|
|
3641
|
+
// src/rules/ai-readability/index.ts
|
|
3642
|
+
function hasAutoLayout2(node) {
|
|
3643
|
+
return node.layoutMode !== void 0 && node.layoutMode !== "NONE";
|
|
3644
|
+
}
|
|
3645
|
+
function isContainerNode2(node) {
|
|
3646
|
+
return node.type === "FRAME" || node.type === "GROUP" || node.type === "COMPONENT";
|
|
3647
|
+
}
|
|
3648
|
+
function hasOverlappingBounds(a, b) {
|
|
3649
|
+
const boxA = a.absoluteBoundingBox;
|
|
3650
|
+
const boxB = b.absoluteBoundingBox;
|
|
3651
|
+
if (!boxA || !boxB) return false;
|
|
3652
|
+
return !(boxA.x + boxA.width <= boxB.x || boxB.x + boxB.width <= boxA.x || boxA.y + boxA.height <= boxB.y || boxB.y + boxB.height <= boxA.y);
|
|
3653
|
+
}
|
|
3654
|
+
var ambiguousStructureDef = {
|
|
3655
|
+
id: "ambiguous-structure",
|
|
3656
|
+
name: "Ambiguous Structure",
|
|
3657
|
+
category: "ai-readability",
|
|
3658
|
+
why: "Overlapping nodes without Auto Layout create ambiguous visual hierarchy",
|
|
3659
|
+
impact: "AI cannot reliably determine the reading order or structure",
|
|
3660
|
+
fix: "Use Auto Layout to create clear, explicit structure"
|
|
3661
|
+
};
|
|
3662
|
+
var ambiguousStructureCheck = (node, context) => {
|
|
3663
|
+
if (!isContainerNode2(node)) return null;
|
|
3664
|
+
if (hasAutoLayout2(node)) return null;
|
|
3665
|
+
if (!node.children || node.children.length < 2) return null;
|
|
3666
|
+
for (let i = 0; i < node.children.length; i++) {
|
|
3667
|
+
for (let j = i + 1; j < node.children.length; j++) {
|
|
3668
|
+
const childA = node.children[i];
|
|
3669
|
+
const childB = node.children[j];
|
|
3670
|
+
if (childA && childB && hasOverlappingBounds(childA, childB)) {
|
|
3671
|
+
if (childA.visible !== false && childB.visible !== false) {
|
|
3672
|
+
return {
|
|
3673
|
+
ruleId: ambiguousStructureDef.id,
|
|
3674
|
+
nodeId: node.id,
|
|
3675
|
+
nodePath: context.path.join(" > "),
|
|
3676
|
+
message: `"${node.name}" has overlapping children without Auto Layout`
|
|
3677
|
+
};
|
|
3678
|
+
}
|
|
3679
|
+
}
|
|
3680
|
+
}
|
|
3681
|
+
}
|
|
3682
|
+
return null;
|
|
3683
|
+
};
|
|
3684
|
+
defineRule({
|
|
3685
|
+
definition: ambiguousStructureDef,
|
|
3686
|
+
check: ambiguousStructureCheck
|
|
3687
|
+
});
|
|
3688
|
+
var zIndexDependentLayoutDef = {
|
|
3689
|
+
id: "z-index-dependent-layout",
|
|
3690
|
+
name: "Z-Index Dependent Layout",
|
|
3691
|
+
category: "ai-readability",
|
|
3692
|
+
why: "Using overlapping layers to create visual layout is hard to interpret",
|
|
3693
|
+
impact: "Code generation may misinterpret the intended layout",
|
|
3694
|
+
fix: "Restructure using Auto Layout to express the visual relationship explicitly"
|
|
3695
|
+
};
|
|
3696
|
+
var zIndexDependentLayoutCheck = (node, context) => {
|
|
3697
|
+
if (!isContainerNode2(node)) return null;
|
|
3698
|
+
if (!node.children || node.children.length < 2) return null;
|
|
3699
|
+
let significantOverlapCount = 0;
|
|
3700
|
+
for (let i = 0; i < node.children.length; i++) {
|
|
3701
|
+
for (let j = i + 1; j < node.children.length; j++) {
|
|
3702
|
+
const childA = node.children[i];
|
|
3703
|
+
const childB = node.children[j];
|
|
3704
|
+
if (!childA || !childB) continue;
|
|
3705
|
+
if (childA.visible === false || childB.visible === false) continue;
|
|
3706
|
+
const boxA = childA.absoluteBoundingBox;
|
|
3707
|
+
const boxB = childB.absoluteBoundingBox;
|
|
3708
|
+
if (!boxA || !boxB) continue;
|
|
3709
|
+
if (hasOverlappingBounds(childA, childB)) {
|
|
3710
|
+
const overlapX = Math.min(boxA.x + boxA.width, boxB.x + boxB.width) - Math.max(boxA.x, boxB.x);
|
|
3711
|
+
const overlapY = Math.min(boxA.y + boxA.height, boxB.y + boxB.height) - Math.max(boxA.y, boxB.y);
|
|
3712
|
+
if (overlapX > 0 && overlapY > 0) {
|
|
3713
|
+
const overlapArea = overlapX * overlapY;
|
|
3714
|
+
const smallerArea = Math.min(
|
|
3715
|
+
boxA.width * boxA.height,
|
|
3716
|
+
boxB.width * boxB.height
|
|
3717
|
+
);
|
|
3718
|
+
if (overlapArea > smallerArea * 0.2) {
|
|
3719
|
+
significantOverlapCount++;
|
|
3720
|
+
}
|
|
3721
|
+
}
|
|
3722
|
+
}
|
|
3723
|
+
}
|
|
3724
|
+
}
|
|
3725
|
+
if (significantOverlapCount > 0) {
|
|
3726
|
+
return {
|
|
3727
|
+
ruleId: zIndexDependentLayoutDef.id,
|
|
3728
|
+
nodeId: node.id,
|
|
3729
|
+
nodePath: context.path.join(" > "),
|
|
3730
|
+
message: `"${node.name}" uses layer stacking for layout (${significantOverlapCount} overlaps)`
|
|
3731
|
+
};
|
|
3732
|
+
}
|
|
3733
|
+
return null;
|
|
3734
|
+
};
|
|
3735
|
+
defineRule({
|
|
3736
|
+
definition: zIndexDependentLayoutDef,
|
|
3737
|
+
check: zIndexDependentLayoutCheck
|
|
3738
|
+
});
|
|
3739
|
+
var missingLayoutHintDef = {
|
|
3740
|
+
id: "missing-layout-hint",
|
|
3741
|
+
name: "Missing Layout Hint",
|
|
3742
|
+
category: "ai-readability",
|
|
3743
|
+
why: "Complex nesting without Auto Layout makes structure unpredictable",
|
|
3744
|
+
impact: "AI may generate incorrect code due to ambiguous relationships",
|
|
3745
|
+
fix: "Add Auto Layout or simplify the nesting structure"
|
|
3746
|
+
};
|
|
3747
|
+
var missingLayoutHintCheck = (node, context) => {
|
|
3748
|
+
if (!isContainerNode2(node)) return null;
|
|
3749
|
+
if (hasAutoLayout2(node)) return null;
|
|
3750
|
+
if (!node.children || node.children.length === 0) return null;
|
|
3751
|
+
const nestedContainers = node.children.filter((c) => isContainerNode2(c));
|
|
3752
|
+
if (nestedContainers.length >= 2) {
|
|
3753
|
+
const withoutLayout = nestedContainers.filter((c) => !hasAutoLayout2(c));
|
|
3754
|
+
if (withoutLayout.length >= 2) {
|
|
3755
|
+
return {
|
|
3756
|
+
ruleId: missingLayoutHintDef.id,
|
|
3757
|
+
nodeId: node.id,
|
|
3758
|
+
nodePath: context.path.join(" > "),
|
|
3759
|
+
message: `"${node.name}" has ${withoutLayout.length} nested containers without layout hints`
|
|
3760
|
+
};
|
|
3761
|
+
}
|
|
3762
|
+
}
|
|
3763
|
+
return null;
|
|
3764
|
+
};
|
|
3765
|
+
defineRule({
|
|
3766
|
+
definition: missingLayoutHintDef,
|
|
3767
|
+
check: missingLayoutHintCheck
|
|
3768
|
+
});
|
|
3769
|
+
var invisibleLayerDef = {
|
|
3770
|
+
id: "invisible-layer",
|
|
3771
|
+
name: "Invisible Layer",
|
|
3772
|
+
category: "ai-readability",
|
|
3773
|
+
why: "Hidden layers add noise and may confuse analysis tools",
|
|
3774
|
+
impact: "Exported code may include unnecessary elements",
|
|
3775
|
+
fix: "Delete hidden layers or move them to a separate 'archive' page"
|
|
3776
|
+
};
|
|
3777
|
+
var invisibleLayerCheck = (node, context) => {
|
|
3778
|
+
if (node.visible !== false) return null;
|
|
3779
|
+
if (context.parent?.visible === false) return null;
|
|
3780
|
+
return {
|
|
3781
|
+
ruleId: invisibleLayerDef.id,
|
|
3782
|
+
nodeId: node.id,
|
|
3783
|
+
nodePath: context.path.join(" > "),
|
|
3784
|
+
message: `"${node.name}" is hidden - consider removing if not needed`
|
|
3785
|
+
};
|
|
3786
|
+
};
|
|
3787
|
+
defineRule({
|
|
3788
|
+
definition: invisibleLayerDef,
|
|
3789
|
+
check: invisibleLayerCheck
|
|
3790
|
+
});
|
|
3791
|
+
var emptyFrameDef = {
|
|
3792
|
+
id: "empty-frame",
|
|
3793
|
+
name: "Empty Frame",
|
|
3794
|
+
category: "ai-readability",
|
|
3795
|
+
why: "Empty frames add noise and may indicate incomplete design",
|
|
3796
|
+
impact: "Generates unnecessary wrapper elements in code",
|
|
3797
|
+
fix: "Remove the frame or add content"
|
|
3798
|
+
};
|
|
3799
|
+
var emptyFrameCheck = (node, context) => {
|
|
3800
|
+
if (node.type !== "FRAME") return null;
|
|
3801
|
+
if (node.children && node.children.length > 0) return null;
|
|
3802
|
+
if (node.absoluteBoundingBox) {
|
|
3803
|
+
const { width, height } = node.absoluteBoundingBox;
|
|
3804
|
+
if (width <= 48 && height <= 48) return null;
|
|
3805
|
+
}
|
|
3806
|
+
return {
|
|
3807
|
+
ruleId: emptyFrameDef.id,
|
|
3808
|
+
nodeId: node.id,
|
|
3809
|
+
nodePath: context.path.join(" > "),
|
|
3810
|
+
message: `"${node.name}" is an empty frame`
|
|
3811
|
+
};
|
|
3812
|
+
};
|
|
3813
|
+
defineRule({
|
|
3814
|
+
definition: emptyFrameDef,
|
|
3815
|
+
check: emptyFrameCheck
|
|
3816
|
+
});
|
|
3817
|
+
|
|
3818
|
+
// src/rules/handoff-risk/index.ts
|
|
3819
|
+
function hasAutoLayout3(node) {
|
|
3820
|
+
return node.layoutMode !== void 0 && node.layoutMode !== "NONE";
|
|
3821
|
+
}
|
|
3822
|
+
function isContainerNode3(node) {
|
|
3823
|
+
return node.type === "FRAME" || node.type === "GROUP" || node.type === "COMPONENT";
|
|
3824
|
+
}
|
|
3825
|
+
function isTextNode(node) {
|
|
3826
|
+
return node.type === "TEXT";
|
|
3827
|
+
}
|
|
3828
|
+
function isImageNode(node) {
|
|
3829
|
+
if (node.type === "RECTANGLE" && node.fills) {
|
|
3830
|
+
for (const fill of node.fills) {
|
|
3831
|
+
const fillObj = fill;
|
|
3832
|
+
if (fillObj["type"] === "IMAGE") return true;
|
|
3833
|
+
}
|
|
3834
|
+
}
|
|
3835
|
+
return false;
|
|
3836
|
+
}
|
|
3837
|
+
var hardcodeRiskDef = {
|
|
3838
|
+
id: "hardcode-risk",
|
|
3839
|
+
name: "Hardcode Risk",
|
|
3840
|
+
category: "handoff-risk",
|
|
3841
|
+
why: "Absolute positioning with fixed values creates inflexible layouts",
|
|
3842
|
+
impact: "Layout will break when content changes or on different screens",
|
|
3843
|
+
fix: "Use Auto Layout with relative positioning"
|
|
3844
|
+
};
|
|
3845
|
+
var hardcodeRiskCheck = (node, context) => {
|
|
3846
|
+
if (!isContainerNode3(node)) return null;
|
|
3847
|
+
if (node.layoutPositioning !== "ABSOLUTE") return null;
|
|
3848
|
+
if (context.parent && hasAutoLayout3(context.parent)) {
|
|
3849
|
+
return {
|
|
3850
|
+
ruleId: hardcodeRiskDef.id,
|
|
3851
|
+
nodeId: node.id,
|
|
3852
|
+
nodePath: context.path.join(" > "),
|
|
3853
|
+
message: `"${node.name}" uses absolute positioning with fixed values`
|
|
3854
|
+
};
|
|
3855
|
+
}
|
|
3856
|
+
return null;
|
|
3857
|
+
};
|
|
3858
|
+
defineRule({
|
|
3859
|
+
definition: hardcodeRiskDef,
|
|
3860
|
+
check: hardcodeRiskCheck
|
|
3861
|
+
});
|
|
3862
|
+
var textTruncationUnhandledDef = {
|
|
3863
|
+
id: "text-truncation-unhandled",
|
|
3864
|
+
name: "Text Truncation Unhandled",
|
|
3865
|
+
category: "handoff-risk",
|
|
3866
|
+
why: "Text nodes without truncation handling may overflow",
|
|
3867
|
+
impact: "Long text will break the layout",
|
|
3868
|
+
fix: "Set text truncation (ellipsis) or ensure container can grow"
|
|
3869
|
+
};
|
|
3870
|
+
var textTruncationUnhandledCheck = (node, context) => {
|
|
3871
|
+
if (!isTextNode(node)) return null;
|
|
3872
|
+
if (!context.parent) return null;
|
|
3873
|
+
if (!hasAutoLayout3(context.parent)) return null;
|
|
3874
|
+
if (node.absoluteBoundingBox) {
|
|
3875
|
+
const { width } = node.absoluteBoundingBox;
|
|
3876
|
+
if (node.characters && node.characters.length > 50 && width < 300) {
|
|
3877
|
+
return {
|
|
3878
|
+
ruleId: textTruncationUnhandledDef.id,
|
|
3879
|
+
nodeId: node.id,
|
|
3880
|
+
nodePath: context.path.join(" > "),
|
|
3881
|
+
message: `"${node.name}" may need text truncation handling`
|
|
3882
|
+
};
|
|
3883
|
+
}
|
|
3884
|
+
}
|
|
3885
|
+
return null;
|
|
3886
|
+
};
|
|
3887
|
+
defineRule({
|
|
3888
|
+
definition: textTruncationUnhandledDef,
|
|
3889
|
+
check: textTruncationUnhandledCheck
|
|
3890
|
+
});
|
|
3891
|
+
var imageNoPlaceholderDef = {
|
|
3892
|
+
id: "image-no-placeholder",
|
|
3893
|
+
name: "Image No Placeholder",
|
|
3894
|
+
category: "handoff-risk",
|
|
3895
|
+
why: "Images without placeholder state may cause layout shifts",
|
|
3896
|
+
impact: "Poor user experience during image loading",
|
|
3897
|
+
fix: "Define a placeholder state or background color"
|
|
3898
|
+
};
|
|
3899
|
+
var imageNoPlaceholderCheck = (node, context) => {
|
|
3900
|
+
if (!isImageNode(node)) return null;
|
|
3901
|
+
if (node.fills && Array.isArray(node.fills) && node.fills.length === 1) {
|
|
3902
|
+
const fill = node.fills[0];
|
|
3903
|
+
if (fill["type"] === "IMAGE") {
|
|
3904
|
+
return {
|
|
3905
|
+
ruleId: imageNoPlaceholderDef.id,
|
|
3906
|
+
nodeId: node.id,
|
|
3907
|
+
nodePath: context.path.join(" > "),
|
|
3908
|
+
message: `"${node.name}" image has no placeholder fill`
|
|
3909
|
+
};
|
|
3910
|
+
}
|
|
3911
|
+
}
|
|
3912
|
+
return null;
|
|
3913
|
+
};
|
|
3914
|
+
defineRule({
|
|
3915
|
+
definition: imageNoPlaceholderDef,
|
|
3916
|
+
check: imageNoPlaceholderCheck
|
|
3917
|
+
});
|
|
3918
|
+
var prototypeLinkInDesignDef = {
|
|
3919
|
+
id: "prototype-link-in-design",
|
|
3920
|
+
name: "Prototype Link in Design",
|
|
3921
|
+
category: "handoff-risk",
|
|
3922
|
+
why: "Prototype connections may affect how the design is interpreted",
|
|
3923
|
+
impact: "Developers may misunderstand which elements should be interactive",
|
|
3924
|
+
fix: "Document interactions separately or use clear naming"
|
|
3925
|
+
};
|
|
3926
|
+
var prototypeLinkInDesignCheck = (_node, _context) => {
|
|
3927
|
+
return null;
|
|
3928
|
+
};
|
|
3929
|
+
defineRule({
|
|
3930
|
+
definition: prototypeLinkInDesignDef,
|
|
3931
|
+
check: prototypeLinkInDesignCheck
|
|
3932
|
+
});
|
|
3933
|
+
var noDevStatusDef = {
|
|
3934
|
+
id: "no-dev-status",
|
|
3935
|
+
name: "No Dev Status",
|
|
3936
|
+
category: "handoff-risk",
|
|
3937
|
+
why: "Without dev status, developers cannot know if a design is ready",
|
|
3938
|
+
impact: "May implement designs that are still in progress",
|
|
3939
|
+
fix: "Mark frames as 'Ready for Dev' or 'Completed' when appropriate"
|
|
3940
|
+
};
|
|
3941
|
+
var noDevStatusCheck = (node, context) => {
|
|
3942
|
+
if (node.type !== "FRAME") return null;
|
|
3943
|
+
if (context.depth > 1) return null;
|
|
3944
|
+
if (node.devStatus) return null;
|
|
3945
|
+
return {
|
|
3946
|
+
ruleId: noDevStatusDef.id,
|
|
3947
|
+
nodeId: node.id,
|
|
3948
|
+
nodePath: context.path.join(" > "),
|
|
3949
|
+
message: `"${node.name}" has no dev status set`
|
|
3950
|
+
};
|
|
3951
|
+
};
|
|
3952
|
+
defineRule({
|
|
3953
|
+
definition: noDevStatusDef,
|
|
3954
|
+
check: noDevStatusCheck
|
|
3955
|
+
});
|
|
3956
|
+
|
|
3957
|
+
// src/cli/index.ts
|
|
3958
|
+
config();
|
|
3959
|
+
var cli = cac("canicode");
|
|
3960
|
+
var MAX_NODES_WITHOUT_SCOPE = 500;
|
|
3961
|
+
function pickRandomScope(root) {
|
|
3962
|
+
const candidates = [];
|
|
3963
|
+
function collect(node) {
|
|
3964
|
+
const isContainer = node.type === "FRAME" || node.type === "COMPONENT" || node.type === "SECTION";
|
|
3965
|
+
if (isContainer) {
|
|
3966
|
+
const size = countNodes2(node);
|
|
3967
|
+
if (size >= 50 && size <= 500) {
|
|
3968
|
+
candidates.push(node);
|
|
3969
|
+
}
|
|
3970
|
+
}
|
|
3971
|
+
if ("children" in node && node.children) {
|
|
3972
|
+
for (const child of node.children) {
|
|
3973
|
+
collect(child);
|
|
3974
|
+
}
|
|
3975
|
+
}
|
|
3976
|
+
}
|
|
3977
|
+
collect(root);
|
|
3978
|
+
if (candidates.length === 0) return null;
|
|
3979
|
+
const idx = Math.floor(Math.random() * candidates.length);
|
|
3980
|
+
return candidates[idx] ?? null;
|
|
3981
|
+
}
|
|
3982
|
+
function countNodes2(node) {
|
|
3983
|
+
let count = 1;
|
|
3984
|
+
if (node.children) {
|
|
3985
|
+
for (const child of node.children) {
|
|
3986
|
+
count += countNodes2(child);
|
|
3987
|
+
}
|
|
3988
|
+
}
|
|
3989
|
+
return count;
|
|
3990
|
+
}
|
|
3991
|
+
cli.command("analyze <input>", "Analyze a Figma file or JSON fixture").option("--preset <preset>", "Analysis preset (relaxed | dev-friendly | ai-ready | strict)").option("--output <path>", "HTML report output path").option("--token <token>", "Figma API token (or use FIGMA_TOKEN env var)").option("--mcp", "Load via Figma MCP (no FIGMA_TOKEN needed)").option("--api", "Load via Figma REST API (requires FIGMA_TOKEN)").option("--screenshot", "Include screenshot comparison in report (requires ANTHROPIC_API_KEY)").option("--custom-rules <path>", "Path to custom rules JSON file").option("--config <path>", "Path to config JSON file (override rule scores/settings)").option("--no-open", "Don't open report in browser after analysis").example(" canicode analyze https://www.figma.com/design/ABC123/MyDesign").example(" canicode analyze https://www.figma.com/design/ABC123/MyDesign --mcp").example(" canicode analyze https://www.figma.com/design/ABC123/MyDesign --api --token YOUR_TOKEN").example(" canicode analyze ./fixtures/design.json --output report.html").example(" canicode analyze ./fixtures/design.json --custom-rules ./my-rules.json").example(" canicode analyze ./fixtures/design.json --config ./my-config.json").action(async (input, options) => {
|
|
3992
|
+
try {
|
|
3993
|
+
if (options.mcp && options.api) {
|
|
3994
|
+
throw new Error("Cannot use --mcp and --api together. Choose one.");
|
|
3995
|
+
}
|
|
3996
|
+
if (!options.mcp && !options.token && !getFigmaToken() && !isJsonFile(input)) {
|
|
3997
|
+
throw new Error(
|
|
3998
|
+
"canicode is not configured. Run 'canicode init --token YOUR_TOKEN' first.\nOr use --mcp flag for Figma MCP mode (no token needed)."
|
|
3999
|
+
);
|
|
4000
|
+
}
|
|
4001
|
+
if (options.screenshot) {
|
|
4002
|
+
const anthropicKey = process.env["ANTHROPIC_API_KEY"];
|
|
4003
|
+
if (!anthropicKey) {
|
|
4004
|
+
throw new Error(
|
|
4005
|
+
"ANTHROPIC_API_KEY required for --screenshot mode. Set it in .env or environment."
|
|
4006
|
+
);
|
|
4007
|
+
}
|
|
4008
|
+
console.log("Screenshot comparison mode enabled (coming soon).\n");
|
|
4009
|
+
}
|
|
4010
|
+
const mode = options.mcp ? "mcp" : options.api ? "api" : "auto";
|
|
4011
|
+
const { file, nodeId } = await loadFile(input, options.token, mode);
|
|
4012
|
+
const totalNodes = countNodes2(file.document);
|
|
4013
|
+
let effectiveNodeId = nodeId;
|
|
4014
|
+
if (!effectiveNodeId && totalNodes > MAX_NODES_WITHOUT_SCOPE) {
|
|
4015
|
+
if (isJsonFile(input)) {
|
|
4016
|
+
const picked = pickRandomScope(file.document);
|
|
4017
|
+
if (picked) {
|
|
4018
|
+
effectiveNodeId = picked.id;
|
|
4019
|
+
console.log(`
|
|
4020
|
+
Auto-scoped to "${picked.name}" (${picked.id}, ${countNodes2(picked)} nodes) \u2014 file too large (${totalNodes} nodes) for unscoped analysis.`);
|
|
4021
|
+
} else {
|
|
4022
|
+
console.warn(`
|
|
4023
|
+
Warning: Could not find a suitable scope in fixture. Analyzing all ${totalNodes} nodes.`);
|
|
4024
|
+
}
|
|
4025
|
+
} else {
|
|
4026
|
+
throw new Error(
|
|
4027
|
+
`Too many nodes (${totalNodes}) for unscoped analysis. Max ${MAX_NODES_WITHOUT_SCOPE} nodes without a node-id scope.
|
|
4028
|
+
|
|
4029
|
+
Add ?node-id=XXX to the Figma URL to target a specific section.
|
|
4030
|
+
Example: canicode analyze "https://www.figma.com/design/.../MyDesign?node-id=1-234"`
|
|
4031
|
+
);
|
|
4032
|
+
}
|
|
4033
|
+
}
|
|
4034
|
+
if (!effectiveNodeId && totalNodes > 100) {
|
|
4035
|
+
console.warn(`
|
|
4036
|
+
Warning: Analyzing ${totalNodes} nodes without scope. Results may be noisy.`);
|
|
4037
|
+
console.warn("Tip: Add ?node-id=XXX to analyze a specific section.\n");
|
|
4038
|
+
}
|
|
4039
|
+
console.log(`
|
|
4040
|
+
Analyzing: ${file.name}`);
|
|
4041
|
+
console.log(`Nodes: ${totalNodes}`);
|
|
4042
|
+
let configs = options.preset ? { ...getConfigsWithPreset(options.preset) } : { ...RULE_CONFIGS };
|
|
4043
|
+
if (options.config) {
|
|
4044
|
+
const configFile = await loadConfigFile(options.config);
|
|
4045
|
+
configs = mergeConfigs(configs, configFile);
|
|
4046
|
+
console.log(`Config loaded: ${options.config}`);
|
|
4047
|
+
}
|
|
4048
|
+
if (options.customRules) {
|
|
4049
|
+
const { rules, configs: customConfigs } = await loadCustomRules(options.customRules);
|
|
4050
|
+
for (const rule of rules) {
|
|
4051
|
+
ruleRegistry.register(rule);
|
|
4052
|
+
}
|
|
4053
|
+
configs = { ...configs, ...customConfigs };
|
|
4054
|
+
console.log(`Custom rules loaded: ${rules.length} rules from ${options.customRules}`);
|
|
4055
|
+
}
|
|
4056
|
+
const analyzeOptions = {
|
|
4057
|
+
configs,
|
|
4058
|
+
...effectiveNodeId && { targetNodeId: effectiveNodeId }
|
|
4059
|
+
};
|
|
4060
|
+
const result = analyzeFile(file, analyzeOptions);
|
|
4061
|
+
console.log(`Nodes: ${result.nodeCount} (max depth: ${result.maxDepth})`);
|
|
4062
|
+
const scores = calculateScores(result);
|
|
4063
|
+
console.log("\n" + "=".repeat(50));
|
|
4064
|
+
console.log(formatScoreSummary(scores));
|
|
4065
|
+
console.log("=".repeat(50));
|
|
4066
|
+
const now = /* @__PURE__ */ new Date();
|
|
4067
|
+
const ts = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-${String(now.getDate()).padStart(2, "0")}-${String(now.getHours()).padStart(2, "0")}-${String(now.getMinutes()).padStart(2, "0")}`;
|
|
4068
|
+
let outputPath;
|
|
4069
|
+
if (options.output) {
|
|
4070
|
+
outputPath = resolve(options.output);
|
|
4071
|
+
const outputDir = dirname(outputPath);
|
|
4072
|
+
if (!existsSync(outputDir)) {
|
|
4073
|
+
mkdirSync(outputDir, { recursive: true });
|
|
4074
|
+
}
|
|
4075
|
+
} else {
|
|
4076
|
+
ensureReportsDir();
|
|
4077
|
+
outputPath = resolve(getReportsDir(), `report-${ts}-${file.fileKey}.html`);
|
|
4078
|
+
}
|
|
4079
|
+
const figmaToken = options.token ?? getFigmaToken();
|
|
4080
|
+
const html = generateHtmlReport(file, result, scores, { figmaToken });
|
|
4081
|
+
await writeFile(outputPath, html, "utf-8");
|
|
4082
|
+
console.log(`
|
|
4083
|
+
Report saved: ${outputPath}`);
|
|
4084
|
+
if (!options.noOpen) {
|
|
4085
|
+
const { exec } = await import('child_process');
|
|
4086
|
+
const cmd = process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open";
|
|
4087
|
+
exec(`${cmd} "${outputPath}"`);
|
|
4088
|
+
}
|
|
4089
|
+
if (scores.overall.grade === "F") {
|
|
4090
|
+
process.exit(1);
|
|
4091
|
+
}
|
|
4092
|
+
} catch (error) {
|
|
4093
|
+
console.error(
|
|
4094
|
+
"\nError:",
|
|
4095
|
+
error instanceof Error ? error.message : String(error)
|
|
4096
|
+
);
|
|
4097
|
+
process.exit(1);
|
|
4098
|
+
}
|
|
4099
|
+
});
|
|
4100
|
+
cli.command(
|
|
4101
|
+
"calibrate-analyze <input>",
|
|
4102
|
+
"Run calibration analysis and output JSON for conversion step"
|
|
4103
|
+
).option("--output <path>", "Output JSON path", { default: "logs/calibration/calibration-analysis.json" }).option("--token <token>", "Figma API token (or use FIGMA_TOKEN env var)").option("--target-node-id <nodeId>", "Scope analysis to a specific node").action(async (input, options) => {
|
|
4104
|
+
try {
|
|
4105
|
+
console.log("Running calibration analysis...");
|
|
4106
|
+
const calibConfig = {
|
|
4107
|
+
input,
|
|
4108
|
+
maxConversionNodes: 20,
|
|
4109
|
+
samplingStrategy: "top-issues",
|
|
4110
|
+
outputPath: "logs/calibration/calibration-report.md",
|
|
4111
|
+
...options.token && { token: options.token },
|
|
4112
|
+
...options.targetNodeId && { targetNodeId: options.targetNodeId }
|
|
4113
|
+
};
|
|
4114
|
+
const { analysisOutput, ruleScores, fileKey } = await runCalibrationAnalyze(calibConfig);
|
|
4115
|
+
const filteredSummaries = filterConversionCandidates(
|
|
4116
|
+
analysisOutput.nodeIssueSummaries,
|
|
4117
|
+
analysisOutput.analysisResult.file.document
|
|
4118
|
+
);
|
|
4119
|
+
const outputData = {
|
|
4120
|
+
fileKey,
|
|
4121
|
+
fileName: analysisOutput.analysisResult.file.name,
|
|
4122
|
+
analyzedAt: analysisOutput.analysisResult.analyzedAt,
|
|
4123
|
+
nodeCount: analysisOutput.analysisResult.nodeCount,
|
|
4124
|
+
issueCount: analysisOutput.analysisResult.issues.length,
|
|
4125
|
+
scoreReport: analysisOutput.scoreReport,
|
|
4126
|
+
nodeIssueSummaries: filteredSummaries,
|
|
4127
|
+
ruleScores
|
|
4128
|
+
};
|
|
4129
|
+
const outputPath = resolve(options.output ?? "logs/calibration/calibration-analysis.json");
|
|
4130
|
+
const outputDir = dirname(outputPath);
|
|
4131
|
+
if (!existsSync(outputDir)) {
|
|
4132
|
+
mkdirSync(outputDir, { recursive: true });
|
|
4133
|
+
}
|
|
4134
|
+
await writeFile(outputPath, JSON.stringify(outputData, null, 2), "utf-8");
|
|
4135
|
+
console.log(`
|
|
4136
|
+
Analysis complete.`);
|
|
4137
|
+
console.log(` Nodes: ${outputData.nodeCount}`);
|
|
4138
|
+
console.log(` Issues: ${outputData.issueCount}`);
|
|
4139
|
+
console.log(` Nodes with issues: ${outputData.nodeIssueSummaries.length}`);
|
|
4140
|
+
console.log(` Grade: ${outputData.scoreReport.overall.grade} (${outputData.scoreReport.overall.percentage}%)`);
|
|
4141
|
+
console.log(`
|
|
4142
|
+
Output saved: ${outputPath}`);
|
|
4143
|
+
} catch (error) {
|
|
4144
|
+
console.error(
|
|
4145
|
+
"\nError:",
|
|
4146
|
+
error instanceof Error ? error.message : String(error)
|
|
4147
|
+
);
|
|
4148
|
+
process.exit(1);
|
|
4149
|
+
}
|
|
4150
|
+
});
|
|
4151
|
+
cli.command(
|
|
4152
|
+
"calibrate-evaluate <analysisJson> <conversionJson>",
|
|
4153
|
+
"Evaluate conversion results and generate calibration report"
|
|
4154
|
+
).option("--output <path>", "Report output path").action(async (analysisJsonPath, conversionJsonPath, options) => {
|
|
4155
|
+
try {
|
|
4156
|
+
console.log("Running calibration evaluation...");
|
|
4157
|
+
const analysisPath = resolve(analysisJsonPath);
|
|
4158
|
+
const conversionPath = resolve(conversionJsonPath);
|
|
4159
|
+
if (!existsSync(analysisPath)) {
|
|
4160
|
+
throw new Error(`Analysis file not found: ${analysisPath}`);
|
|
4161
|
+
}
|
|
4162
|
+
if (!existsSync(conversionPath)) {
|
|
4163
|
+
throw new Error(`Conversion file not found: ${conversionPath}`);
|
|
4164
|
+
}
|
|
4165
|
+
const { readFile: readFile4 } = await import('fs/promises');
|
|
4166
|
+
const analysisData = JSON.parse(await readFile4(analysisPath, "utf-8"));
|
|
4167
|
+
const conversionData = JSON.parse(await readFile4(conversionPath, "utf-8"));
|
|
4168
|
+
const { evaluationOutput, tuningOutput, report } = runCalibrationEvaluate(
|
|
4169
|
+
analysisData,
|
|
4170
|
+
conversionData,
|
|
4171
|
+
analysisData.ruleScores
|
|
4172
|
+
);
|
|
4173
|
+
const calNow = /* @__PURE__ */ new Date();
|
|
4174
|
+
const calTs = `${calNow.getFullYear()}-${String(calNow.getMonth() + 1).padStart(2, "0")}-${String(calNow.getDate()).padStart(2, "0")}-${String(calNow.getHours()).padStart(2, "0")}-${String(calNow.getMinutes()).padStart(2, "0")}`;
|
|
4175
|
+
const defaultCalOutput = `logs/calibration/calibration-${calTs}.md`;
|
|
4176
|
+
const outputPath = resolve(options.output ?? defaultCalOutput);
|
|
4177
|
+
const calOutputDir = dirname(outputPath);
|
|
4178
|
+
if (!existsSync(calOutputDir)) {
|
|
4179
|
+
mkdirSync(calOutputDir, { recursive: true });
|
|
4180
|
+
}
|
|
4181
|
+
await writeFile(outputPath, report, "utf-8");
|
|
4182
|
+
const mismatchCounts = {
|
|
4183
|
+
overscored: 0,
|
|
4184
|
+
underscored: 0,
|
|
4185
|
+
"missing-rule": 0,
|
|
4186
|
+
validated: 0
|
|
4187
|
+
};
|
|
4188
|
+
for (const m of evaluationOutput.mismatches) {
|
|
4189
|
+
const key = m.type;
|
|
4190
|
+
mismatchCounts[key]++;
|
|
4191
|
+
}
|
|
4192
|
+
console.log(`
|
|
4193
|
+
Evaluation complete.`);
|
|
4194
|
+
console.log(` Validated: ${mismatchCounts.validated}`);
|
|
4195
|
+
console.log(` Overscored: ${mismatchCounts.overscored}`);
|
|
4196
|
+
console.log(` Underscored: ${mismatchCounts.underscored}`);
|
|
4197
|
+
console.log(` Missing rules: ${mismatchCounts["missing-rule"]}`);
|
|
4198
|
+
console.log(` Score adjustments proposed: ${tuningOutput.adjustments.length}`);
|
|
4199
|
+
console.log(` New rule proposals: ${tuningOutput.newRuleProposals.length}`);
|
|
4200
|
+
console.log(`
|
|
4201
|
+
Report saved: ${outputPath}`);
|
|
4202
|
+
} catch (error) {
|
|
4203
|
+
console.error(
|
|
4204
|
+
"\nError:",
|
|
4205
|
+
error instanceof Error ? error.message : String(error)
|
|
4206
|
+
);
|
|
4207
|
+
process.exit(1);
|
|
4208
|
+
}
|
|
4209
|
+
});
|
|
4210
|
+
cli.command(
|
|
4211
|
+
"calibrate-run <input>",
|
|
4212
|
+
"Run full calibration pipeline (analysis-only, conversion via /calibrate-loop)"
|
|
4213
|
+
).option("--output <path>", "Markdown report output path").option("--token <token>", "Figma API token (or use FIGMA_TOKEN env var)").option("--max-nodes <count>", "Max nodes to convert", { default: 5 }).option("--sampling <strategy>", "Sampling strategy (all | top-issues | random)", { default: "top-issues" }).action(async (input, options) => {
|
|
4214
|
+
try {
|
|
4215
|
+
const figmaToken = options.token ?? getFigmaToken();
|
|
4216
|
+
if (isFigmaUrl(input) && !parseFigmaUrl(input).nodeId) {
|
|
4217
|
+
console.warn("\nWarning: No node-id specified. Calibrating entire file may produce noisy results.");
|
|
4218
|
+
console.warn("Tip: Add ?node-id=XXX to target a specific section.\n");
|
|
4219
|
+
}
|
|
4220
|
+
console.log("Running calibration pipeline (analysis-only)...");
|
|
4221
|
+
console.log(` Input: ${input}`);
|
|
4222
|
+
console.log(` Max nodes: ${options.maxNodes ?? 5}`);
|
|
4223
|
+
console.log(` Sampling: ${options.sampling ?? "top-issues"}`);
|
|
4224
|
+
console.log("");
|
|
4225
|
+
const calNow = /* @__PURE__ */ new Date();
|
|
4226
|
+
const calTs = `${calNow.getFullYear()}-${String(calNow.getMonth() + 1).padStart(2, "0")}-${String(calNow.getDate()).padStart(2, "0")}-${String(calNow.getHours()).padStart(2, "0")}-${String(calNow.getMinutes()).padStart(2, "0")}`;
|
|
4227
|
+
const defaultOutput = `logs/calibration/calibration-${calTs}.md`;
|
|
4228
|
+
const executor = async (_nodeId, _fileKey, flaggedRuleIds) => ({
|
|
4229
|
+
generatedCode: `<!-- conversion skipped \u2014 use /calibrate-loop for full pipeline -->`,
|
|
4230
|
+
difficulty: "moderate",
|
|
4231
|
+
notes: "Skipped \u2014 CLI runs analysis only. Use /calibrate-loop in Claude Code for full pipeline with code conversion.",
|
|
4232
|
+
ruleRelatedStruggles: flaggedRuleIds.map((r) => ({
|
|
4233
|
+
ruleId: r,
|
|
4234
|
+
description: "Unable to assess \u2014 conversion skipped",
|
|
4235
|
+
actualImpact: "moderate"
|
|
4236
|
+
})),
|
|
4237
|
+
uncoveredStruggles: []
|
|
4238
|
+
});
|
|
4239
|
+
const result = await runCalibration(
|
|
4240
|
+
{
|
|
4241
|
+
input,
|
|
4242
|
+
maxConversionNodes: options.maxNodes ?? 5,
|
|
4243
|
+
samplingStrategy: options.sampling ?? "top-issues",
|
|
4244
|
+
outputPath: options.output ?? defaultOutput,
|
|
4245
|
+
...figmaToken && { token: figmaToken }
|
|
4246
|
+
},
|
|
4247
|
+
executor,
|
|
4248
|
+
{ enableActivityLog: true }
|
|
4249
|
+
);
|
|
4250
|
+
if (result.status === "failed") {
|
|
4251
|
+
throw new Error(result.error ?? "Calibration pipeline failed");
|
|
4252
|
+
}
|
|
4253
|
+
console.log("\nCalibration complete (analysis-only).");
|
|
4254
|
+
console.log(` Grade: ${result.scoreReport.overall.grade} (${result.scoreReport.overall.percentage}%)`);
|
|
4255
|
+
console.log(` Nodes with issues: ${result.nodeIssueSummaries.length}`);
|
|
4256
|
+
console.log(` Report: ${result.reportPath}`);
|
|
4257
|
+
if (result.logPath) {
|
|
4258
|
+
console.log(` Activity log: ${result.logPath}`);
|
|
4259
|
+
}
|
|
4260
|
+
} catch (error) {
|
|
4261
|
+
console.error(
|
|
4262
|
+
"\nError:",
|
|
4263
|
+
error instanceof Error ? error.message : String(error)
|
|
4264
|
+
);
|
|
4265
|
+
process.exit(1);
|
|
4266
|
+
}
|
|
4267
|
+
});
|
|
4268
|
+
cli.command(
|
|
4269
|
+
"save-fixture <input>",
|
|
4270
|
+
"Save Figma file data as a JSON fixture for offline analysis"
|
|
4271
|
+
).option("--output <path>", "Output JSON path (default: fixtures/<filekey>.json)").option("--mcp", "Load via Figma MCP (no FIGMA_TOKEN needed)").option("--api", "Load via Figma REST API (requires FIGMA_TOKEN)").option("--token <token>", "Figma API token (or use FIGMA_TOKEN env var)").example(" canicode save-fixture https://www.figma.com/design/ABC123/MyDesign --mcp").example(" canicode save-fixture https://www.figma.com/design/ABC123/MyDesign --api --token YOUR_TOKEN").action(async (input, options) => {
|
|
4272
|
+
try {
|
|
4273
|
+
if (options.mcp && options.api) {
|
|
4274
|
+
throw new Error("Cannot use --mcp and --api together. Choose one.");
|
|
4275
|
+
}
|
|
4276
|
+
if (isFigmaUrl(input) && !parseFigmaUrl(input).nodeId) {
|
|
4277
|
+
console.warn("\nWarning: No node-id specified. Saving entire file as fixture.");
|
|
4278
|
+
console.warn("Tip: Add ?node-id=XXX to save a specific section.\n");
|
|
4279
|
+
}
|
|
4280
|
+
const mode = options.mcp ? "mcp" : options.api ? "api" : "auto";
|
|
4281
|
+
const { file } = await loadFile(input, options.token, mode);
|
|
4282
|
+
const outputPath = resolve(
|
|
4283
|
+
options.output ?? `fixtures/${file.fileKey}.json`
|
|
4284
|
+
);
|
|
4285
|
+
const outputDir = dirname(outputPath);
|
|
4286
|
+
if (!existsSync(outputDir)) {
|
|
4287
|
+
mkdirSync(outputDir, { recursive: true });
|
|
4288
|
+
}
|
|
4289
|
+
await writeFile(outputPath, JSON.stringify(file, null, 2), "utf-8");
|
|
4290
|
+
console.log(`Fixture saved: ${outputPath}`);
|
|
4291
|
+
console.log(` File: ${file.name}`);
|
|
4292
|
+
console.log(` Nodes: ${countNodes2(file.document)}`);
|
|
4293
|
+
} catch (error) {
|
|
4294
|
+
console.error(
|
|
4295
|
+
"\nError:",
|
|
4296
|
+
error instanceof Error ? error.message : String(error)
|
|
4297
|
+
);
|
|
4298
|
+
process.exit(1);
|
|
4299
|
+
}
|
|
4300
|
+
});
|
|
4301
|
+
cli.command("init", "Set up canicode (Figma token or MCP)").option("--token <token>", "Save Figma API token to ~/.canicode/").option("--mcp", "Show Figma MCP setup instructions").action((options) => {
|
|
4302
|
+
try {
|
|
4303
|
+
if (options.token) {
|
|
4304
|
+
initAiready(options.token);
|
|
4305
|
+
console.log(` Config saved: ${getConfigPath()}`);
|
|
4306
|
+
console.log(` Reports will be saved to: ${getReportsDir()}/`);
|
|
4307
|
+
console.log(`
|
|
4308
|
+
Next: canicode analyze "https://www.figma.com/design/..."`);
|
|
4309
|
+
return;
|
|
4310
|
+
}
|
|
4311
|
+
if (options.mcp) {
|
|
4312
|
+
console.log(`MCP SETUP
|
|
4313
|
+
`);
|
|
4314
|
+
console.log(`1. Install Figma MCP in Claude Code:`);
|
|
4315
|
+
console.log(` claude mcp add figma -- npx -y @anthropic-ai/claude-code-mcp-figma
|
|
4316
|
+
`);
|
|
4317
|
+
console.log(`2. Add canicode MCP server:`);
|
|
4318
|
+
console.log(` claude mcp add --transport stdio canicode npx canicode-mcp
|
|
4319
|
+
`);
|
|
4320
|
+
console.log(`3. Set Figma token (for MCP server's REST API fallback):`);
|
|
4321
|
+
console.log(` canicode init --token YOUR_TOKEN
|
|
4322
|
+
`);
|
|
4323
|
+
console.log(`4. Use in Claude Code:`);
|
|
4324
|
+
console.log(` "Analyze this Figma design: https://www.figma.com/design/..."`);
|
|
4325
|
+
return;
|
|
4326
|
+
}
|
|
4327
|
+
console.log(`CANICODE SETUP
|
|
4328
|
+
`);
|
|
4329
|
+
console.log(`Choose your Figma data source:
|
|
4330
|
+
`);
|
|
4331
|
+
console.log(`Option 1: REST API (recommended for CI/automation)`);
|
|
4332
|
+
console.log(` canicode init --token YOUR_FIGMA_TOKEN`);
|
|
4333
|
+
console.log(` Get token: figma.com > Settings > Personal access tokens
|
|
4334
|
+
`);
|
|
4335
|
+
console.log(`Option 2: Figma MCP (recommended for Claude Code)`);
|
|
4336
|
+
console.log(` canicode init --mcp`);
|
|
4337
|
+
console.log(` No token needed for CLI \u2014 uses Claude Code's Figma MCP bridge
|
|
4338
|
+
`);
|
|
4339
|
+
console.log(`After setup:`);
|
|
4340
|
+
console.log(` canicode analyze "https://www.figma.com/design/..."`);
|
|
4341
|
+
} catch (error) {
|
|
4342
|
+
console.error(
|
|
4343
|
+
"\nError:",
|
|
4344
|
+
error instanceof Error ? error.message : String(error)
|
|
4345
|
+
);
|
|
4346
|
+
process.exit(1);
|
|
4347
|
+
}
|
|
4348
|
+
});
|
|
4349
|
+
cli.command("docs [topic]", "Show documentation (topics: setup, rules, config)").action((topic) => {
|
|
4350
|
+
handleDocs(topic);
|
|
4351
|
+
});
|
|
4352
|
+
cli.help((sections) => {
|
|
4353
|
+
sections.push(
|
|
4354
|
+
{
|
|
4355
|
+
title: "\nSetup",
|
|
4356
|
+
body: [
|
|
4357
|
+
` canicode init --token <token> Save Figma token to ~/.canicode/`,
|
|
4358
|
+
` canicode init --mcp Show MCP setup instructions`
|
|
4359
|
+
].join("\n")
|
|
4360
|
+
},
|
|
4361
|
+
{
|
|
4362
|
+
title: "\nData source",
|
|
4363
|
+
body: [
|
|
4364
|
+
` --mcp Load via Figma MCP (no token needed)`,
|
|
4365
|
+
` --api Load via Figma REST API (needs FIGMA_TOKEN)`,
|
|
4366
|
+
` (default) Auto-detect: try MCP first, then API`
|
|
4367
|
+
].join("\n")
|
|
4368
|
+
},
|
|
4369
|
+
{
|
|
4370
|
+
title: "\nCustomization",
|
|
4371
|
+
body: [
|
|
4372
|
+
` --custom-rules <path> Add custom rules (see: canicode docs rules)`,
|
|
4373
|
+
` --config <path> Override rule settings (see: canicode docs config)`
|
|
4374
|
+
].join("\n")
|
|
4375
|
+
},
|
|
4376
|
+
{
|
|
4377
|
+
title: "\nExamples",
|
|
4378
|
+
body: [
|
|
4379
|
+
` $ canicode analyze "https://www.figma.com/design/..." --mcp`,
|
|
4380
|
+
` $ canicode analyze "https://www.figma.com/design/..." --api`,
|
|
4381
|
+
` $ canicode analyze "https://www.figma.com/design/..." --preset strict`,
|
|
4382
|
+
` $ canicode analyze "https://www.figma.com/design/..." --config ./my-config.json`,
|
|
4383
|
+
` $ canicode analyze "https://www.figma.com/design/..." --custom-rules ./my-rules.json`
|
|
4384
|
+
].join("\n")
|
|
4385
|
+
},
|
|
4386
|
+
{
|
|
4387
|
+
title: "\nInstallation",
|
|
4388
|
+
body: [
|
|
4389
|
+
` CLI: npm install -g canicode`,
|
|
4390
|
+
` MCP: claude mcp add --transport stdio canicode npx canicode-mcp`,
|
|
4391
|
+
` Skills: github.com/let-sunny/canicode`
|
|
4392
|
+
].join("\n")
|
|
4393
|
+
}
|
|
4394
|
+
);
|
|
4395
|
+
});
|
|
4396
|
+
cli.version("0.1.0");
|
|
4397
|
+
cli.parse();
|
|
4398
|
+
//# sourceMappingURL=index.js.map
|
|
4399
|
+
//# sourceMappingURL=index.js.map
|