canicode 0.7.1 → 0.8.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -0
- package/dist/cli/index.js +2753 -2530
- package/dist/cli/index.js.map +1 -1
- package/dist/index.d.ts +15 -2
- package/dist/index.js +255 -2
- package/dist/index.js.map +1 -1
- package/dist/mcp/server.js +1569 -1078
- package/dist/mcp/server.js.map +1 -1
- package/package.json +1 -1
package/dist/mcp/server.js
CHANGED
|
@@ -959,6 +959,211 @@ async function loadFromApi(fileKey, nodeId, token) {
|
|
|
959
959
|
};
|
|
960
960
|
}
|
|
961
961
|
|
|
962
|
+
// src/core/adapters/tailwind-parser.ts
|
|
963
|
+
var SPACING_SCALE = {
|
|
964
|
+
"0": 0,
|
|
965
|
+
"px": 1,
|
|
966
|
+
"0.5": 2,
|
|
967
|
+
"1": 4,
|
|
968
|
+
"1.5": 6,
|
|
969
|
+
"2": 8,
|
|
970
|
+
"2.5": 10,
|
|
971
|
+
"3": 12,
|
|
972
|
+
"3.5": 14,
|
|
973
|
+
"4": 16,
|
|
974
|
+
"5": 20,
|
|
975
|
+
"6": 24,
|
|
976
|
+
"7": 28,
|
|
977
|
+
"8": 32,
|
|
978
|
+
"9": 36,
|
|
979
|
+
"10": 40,
|
|
980
|
+
"11": 44,
|
|
981
|
+
"12": 48,
|
|
982
|
+
"14": 56,
|
|
983
|
+
"16": 64,
|
|
984
|
+
"20": 80,
|
|
985
|
+
"24": 96,
|
|
986
|
+
"28": 112,
|
|
987
|
+
"32": 128,
|
|
988
|
+
"36": 144,
|
|
989
|
+
"40": 160,
|
|
990
|
+
"44": 176,
|
|
991
|
+
"48": 192,
|
|
992
|
+
"52": 208,
|
|
993
|
+
"56": 224,
|
|
994
|
+
"60": 240,
|
|
995
|
+
"64": 256,
|
|
996
|
+
"72": 288,
|
|
997
|
+
"80": 320,
|
|
998
|
+
"96": 384
|
|
999
|
+
};
|
|
1000
|
+
function resolveSpacing(value) {
|
|
1001
|
+
const arbMatch = value.match(/^\[(\d+(?:\.\d+)?)px\]$/);
|
|
1002
|
+
if (arbMatch?.[1]) return parseFloat(arbMatch[1]);
|
|
1003
|
+
return SPACING_SCALE[value];
|
|
1004
|
+
}
|
|
1005
|
+
function extractStylesFromClasses(classes) {
|
|
1006
|
+
const styles = {};
|
|
1007
|
+
const tokens = classes.split(/\s+/);
|
|
1008
|
+
for (const token of tokens) {
|
|
1009
|
+
if (token === "flex" || token === "flex-row") {
|
|
1010
|
+
styles.layoutMode = "HORIZONTAL";
|
|
1011
|
+
} else if (token === "flex-col") {
|
|
1012
|
+
styles.layoutMode = "VERTICAL";
|
|
1013
|
+
} else if (token === "absolute") {
|
|
1014
|
+
styles.layoutPositioning = "ABSOLUTE";
|
|
1015
|
+
} else if (token === "relative" || token === "static") {
|
|
1016
|
+
styles.layoutPositioning = "AUTO";
|
|
1017
|
+
} else if (token === "w-full") {
|
|
1018
|
+
styles.layoutSizingHorizontal = "FILL";
|
|
1019
|
+
} else if (token === "h-full") {
|
|
1020
|
+
styles.layoutSizingVertical = "FILL";
|
|
1021
|
+
} else if (token === "w-fit") {
|
|
1022
|
+
styles.layoutSizingHorizontal = "HUG";
|
|
1023
|
+
} else if (token === "h-fit") {
|
|
1024
|
+
styles.layoutSizingVertical = "HUG";
|
|
1025
|
+
} else if (token.match(/^w-\[.+\]$/) || token.match(/^w-\d/)) {
|
|
1026
|
+
styles.layoutSizingHorizontal = "FIXED";
|
|
1027
|
+
} else if (token.match(/^h-\[.+\]$/) || token.match(/^h-\d/)) {
|
|
1028
|
+
styles.layoutSizingVertical = "FIXED";
|
|
1029
|
+
} else if (token.startsWith("gap-")) {
|
|
1030
|
+
const val = token.slice(4);
|
|
1031
|
+
const px = resolveSpacing(val);
|
|
1032
|
+
if (px !== void 0) styles.itemSpacing = px;
|
|
1033
|
+
} else if (token.startsWith("p-") && !token.startsWith("pl-") && !token.startsWith("pr-") && !token.startsWith("pt-") && !token.startsWith("pb-") && !token.startsWith("px-") && !token.startsWith("py-")) {
|
|
1034
|
+
const val = token.slice(2);
|
|
1035
|
+
const px = resolveSpacing(val);
|
|
1036
|
+
if (px !== void 0) {
|
|
1037
|
+
styles.paddingLeft = px;
|
|
1038
|
+
styles.paddingRight = px;
|
|
1039
|
+
styles.paddingTop = px;
|
|
1040
|
+
styles.paddingBottom = px;
|
|
1041
|
+
}
|
|
1042
|
+
} else if (token.startsWith("px-")) {
|
|
1043
|
+
const px = resolveSpacing(token.slice(3));
|
|
1044
|
+
if (px !== void 0) {
|
|
1045
|
+
styles.paddingLeft = px;
|
|
1046
|
+
styles.paddingRight = px;
|
|
1047
|
+
}
|
|
1048
|
+
} else if (token.startsWith("py-")) {
|
|
1049
|
+
const px = resolveSpacing(token.slice(3));
|
|
1050
|
+
if (px !== void 0) {
|
|
1051
|
+
styles.paddingTop = px;
|
|
1052
|
+
styles.paddingBottom = px;
|
|
1053
|
+
}
|
|
1054
|
+
} else if (token.startsWith("pl-")) {
|
|
1055
|
+
const px = resolveSpacing(token.slice(3));
|
|
1056
|
+
if (px !== void 0) styles.paddingLeft = px;
|
|
1057
|
+
} else if (token.startsWith("pr-")) {
|
|
1058
|
+
const px = resolveSpacing(token.slice(3));
|
|
1059
|
+
if (px !== void 0) styles.paddingRight = px;
|
|
1060
|
+
} else if (token.startsWith("pt-")) {
|
|
1061
|
+
const px = resolveSpacing(token.slice(3));
|
|
1062
|
+
if (px !== void 0) styles.paddingTop = px;
|
|
1063
|
+
} else if (token.startsWith("pb-")) {
|
|
1064
|
+
const px = resolveSpacing(token.slice(3));
|
|
1065
|
+
if (px !== void 0) styles.paddingBottom = px;
|
|
1066
|
+
} else if (token.startsWith("bg-[")) {
|
|
1067
|
+
const colorMatch = token.match(/^bg-\[(.+)\]$/);
|
|
1068
|
+
if (colorMatch?.[1]) {
|
|
1069
|
+
const color = colorMatch[1];
|
|
1070
|
+
if (!styles.fills) styles.fills = [];
|
|
1071
|
+
if (color.startsWith("var(")) {
|
|
1072
|
+
styles.fills.push({ type: "SOLID", color: {}, boundVariable: color });
|
|
1073
|
+
} else {
|
|
1074
|
+
styles.fills.push({ type: "SOLID", color: parseHexColor(color) });
|
|
1075
|
+
}
|
|
1076
|
+
}
|
|
1077
|
+
} else if (token === "shadow" || token.startsWith("shadow-")) {
|
|
1078
|
+
if (!styles.effects) styles.effects = [];
|
|
1079
|
+
styles.effects.push({ type: "DROP_SHADOW" });
|
|
1080
|
+
}
|
|
1081
|
+
}
|
|
1082
|
+
return styles;
|
|
1083
|
+
}
|
|
1084
|
+
function parseHexColor(hex) {
|
|
1085
|
+
const clean = hex.replace("#", "");
|
|
1086
|
+
if (clean.length === 6 || clean.length === 8) {
|
|
1087
|
+
return {
|
|
1088
|
+
r: parseInt(clean.slice(0, 2), 16) / 255,
|
|
1089
|
+
g: parseInt(clean.slice(2, 4), 16) / 255,
|
|
1090
|
+
b: parseInt(clean.slice(4, 6), 16) / 255,
|
|
1091
|
+
a: clean.length === 8 ? parseInt(clean.slice(6, 8), 16) / 255 : 1
|
|
1092
|
+
};
|
|
1093
|
+
}
|
|
1094
|
+
return { r: 0, g: 0, b: 0, a: 1 };
|
|
1095
|
+
}
|
|
1096
|
+
function extractRootClasses(code) {
|
|
1097
|
+
const match = code.match(/className="([^"]+)"/);
|
|
1098
|
+
return match?.[1] ?? "";
|
|
1099
|
+
}
|
|
1100
|
+
function extractAllClasses(code) {
|
|
1101
|
+
const classes = [];
|
|
1102
|
+
const regex = /className="([^"]+)"/g;
|
|
1103
|
+
let match;
|
|
1104
|
+
while ((match = regex.exec(code)) !== null) {
|
|
1105
|
+
if (match[1]) classes.push(match[1]);
|
|
1106
|
+
}
|
|
1107
|
+
return classes;
|
|
1108
|
+
}
|
|
1109
|
+
function parseDesignContextCode(code) {
|
|
1110
|
+
const rootClasses = extractRootClasses(code);
|
|
1111
|
+
const rootStyles = extractStylesFromClasses(rootClasses);
|
|
1112
|
+
const allClasses = extractAllClasses(code);
|
|
1113
|
+
const aggregatedFills = [...rootStyles.fills ?? []];
|
|
1114
|
+
const aggregatedEffects = [...rootStyles.effects ?? []];
|
|
1115
|
+
for (const cls of allClasses) {
|
|
1116
|
+
const styles = extractStylesFromClasses(cls);
|
|
1117
|
+
if (styles.fills) {
|
|
1118
|
+
for (const fill of styles.fills) aggregatedFills.push(fill);
|
|
1119
|
+
}
|
|
1120
|
+
if (styles.effects) {
|
|
1121
|
+
for (const effect of styles.effects) aggregatedEffects.push(effect);
|
|
1122
|
+
}
|
|
1123
|
+
}
|
|
1124
|
+
return {
|
|
1125
|
+
...rootStyles,
|
|
1126
|
+
...aggregatedFills.length > 0 ? { fills: aggregatedFills } : {},
|
|
1127
|
+
...aggregatedEffects.length > 0 ? { effects: aggregatedEffects } : {}
|
|
1128
|
+
};
|
|
1129
|
+
}
|
|
1130
|
+
function parseCodeHeader(code) {
|
|
1131
|
+
const headerMatch = code.match(/\/\*\s*(.+?)\s*\*\//);
|
|
1132
|
+
if (!headerMatch?.[1]) return {};
|
|
1133
|
+
const header = headerMatch[1];
|
|
1134
|
+
const result = {};
|
|
1135
|
+
const nameParts = header.split(" \u2014 ");
|
|
1136
|
+
if (nameParts[0]) result.name = nameParts[0].trim();
|
|
1137
|
+
const dimMatch = header.match(/(\d+)x(\d+)/);
|
|
1138
|
+
if (dimMatch?.[1] && dimMatch[2]) {
|
|
1139
|
+
result.width = parseInt(dimMatch[1]);
|
|
1140
|
+
result.height = parseInt(dimMatch[2]);
|
|
1141
|
+
}
|
|
1142
|
+
const typeMatch = header.match(/\b(COMPONENT|FRAME|INSTANCE|GROUP|SECTION)\b/);
|
|
1143
|
+
if (typeMatch?.[1]) result.type = typeMatch[1];
|
|
1144
|
+
if (header.includes("no auto-layout")) {
|
|
1145
|
+
result.hasAutoLayout = false;
|
|
1146
|
+
} else if (header.includes("auto-layout")) {
|
|
1147
|
+
result.hasAutoLayout = true;
|
|
1148
|
+
if (header.includes("vertical auto-layout")) result.layoutDirection = "VERTICAL";
|
|
1149
|
+
else if (header.includes("horizontal auto-layout")) result.layoutDirection = "HORIZONTAL";
|
|
1150
|
+
}
|
|
1151
|
+
return result;
|
|
1152
|
+
}
|
|
1153
|
+
function enrichNodeWithStyles(node, styles) {
|
|
1154
|
+
if (styles.layoutMode && !node.layoutMode) node.layoutMode = styles.layoutMode;
|
|
1155
|
+
if (styles.layoutPositioning && !node.layoutPositioning) node.layoutPositioning = styles.layoutPositioning;
|
|
1156
|
+
if (styles.layoutSizingHorizontal && !node.layoutSizingHorizontal) node.layoutSizingHorizontal = styles.layoutSizingHorizontal;
|
|
1157
|
+
if (styles.layoutSizingVertical && !node.layoutSizingVertical) node.layoutSizingVertical = styles.layoutSizingVertical;
|
|
1158
|
+
if (styles.itemSpacing !== void 0 && node.itemSpacing === void 0) node.itemSpacing = styles.itemSpacing;
|
|
1159
|
+
if (styles.paddingLeft !== void 0 && node.paddingLeft === void 0) node.paddingLeft = styles.paddingLeft;
|
|
1160
|
+
if (styles.paddingRight !== void 0 && node.paddingRight === void 0) node.paddingRight = styles.paddingRight;
|
|
1161
|
+
if (styles.paddingTop !== void 0 && node.paddingTop === void 0) node.paddingTop = styles.paddingTop;
|
|
1162
|
+
if (styles.paddingBottom !== void 0 && node.paddingBottom === void 0) node.paddingBottom = styles.paddingBottom;
|
|
1163
|
+
if (styles.fills && !node.fills) node.fills = styles.fills;
|
|
1164
|
+
if (styles.effects && !node.effects) node.effects = styles.effects;
|
|
1165
|
+
}
|
|
1166
|
+
|
|
962
1167
|
// src/core/adapters/figma-mcp-adapter.ts
|
|
963
1168
|
var TAG_TYPE_MAP = {
|
|
964
1169
|
canvas: "CANVAS",
|
|
@@ -1089,6 +1294,54 @@ function parseMcpMetadataXml(xml, fileKey, fileName) {
|
|
|
1089
1294
|
styles: {}
|
|
1090
1295
|
};
|
|
1091
1296
|
}
|
|
1297
|
+
function enrichWithDesignContext(file, designContextCode, targetNodeId) {
|
|
1298
|
+
const header = parseCodeHeader(designContextCode);
|
|
1299
|
+
const styles = parseDesignContextCode(designContextCode);
|
|
1300
|
+
if (header.hasAutoLayout === true && header.layoutDirection) {
|
|
1301
|
+
if (!styles.layoutMode) styles.layoutMode = header.layoutDirection;
|
|
1302
|
+
} else if (header.hasAutoLayout === false) {
|
|
1303
|
+
delete styles.layoutMode;
|
|
1304
|
+
}
|
|
1305
|
+
if (targetNodeId) {
|
|
1306
|
+
const node = findNodeById2(file.document, targetNodeId);
|
|
1307
|
+
if (node) {
|
|
1308
|
+
enrichNodeWithStyles(node, styles);
|
|
1309
|
+
enrichChildrenFromCode(node, designContextCode);
|
|
1310
|
+
return;
|
|
1311
|
+
}
|
|
1312
|
+
}
|
|
1313
|
+
enrichNodeWithStyles(file.document, styles);
|
|
1314
|
+
enrichChildrenFromCode(file.document, designContextCode);
|
|
1315
|
+
}
|
|
1316
|
+
function enrichChildrenFromCode(parent, code) {
|
|
1317
|
+
if (!parent.children) return;
|
|
1318
|
+
for (const child of parent.children) {
|
|
1319
|
+
const escapedName = child.name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
1320
|
+
const commentPattern = new RegExp(
|
|
1321
|
+
`\\{/\\*\\s*${escapedName}(?:\\s*\u2014[^*]*)?\\s*\\*/\\}\\s*\\n\\s*<\\w+[^>]*className="([^"]*)"`
|
|
1322
|
+
);
|
|
1323
|
+
const match = code.match(commentPattern);
|
|
1324
|
+
if (match?.[1]) {
|
|
1325
|
+
const childStyles = parseDesignContextCode(
|
|
1326
|
+
`<div className="${match[1]}">`
|
|
1327
|
+
);
|
|
1328
|
+
enrichNodeWithStyles(child, childStyles);
|
|
1329
|
+
}
|
|
1330
|
+
if (child.children) {
|
|
1331
|
+
enrichChildrenFromCode(child, code);
|
|
1332
|
+
}
|
|
1333
|
+
}
|
|
1334
|
+
}
|
|
1335
|
+
function findNodeById2(node, id) {
|
|
1336
|
+
if (node.id === id) return node;
|
|
1337
|
+
if (node.children) {
|
|
1338
|
+
for (const child of node.children) {
|
|
1339
|
+
const found = findNodeById2(child, id);
|
|
1340
|
+
if (found) return found;
|
|
1341
|
+
}
|
|
1342
|
+
}
|
|
1343
|
+
return void 0;
|
|
1344
|
+
}
|
|
1092
1345
|
|
|
1093
1346
|
// src/core/engine/design-data-parser.ts
|
|
1094
1347
|
function parseDesignData(data, fileKey, fileName) {
|
|
@@ -1338,617 +1591,162 @@ function renderGaugeSvg(pct, size, strokeW, grade) {
|
|
|
1338
1591
|
</svg>`;
|
|
1339
1592
|
}
|
|
1340
1593
|
|
|
1341
|
-
//
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
|
|
1370
|
-
|
|
1371
|
-
|
|
1372
|
-
|
|
1373
|
-
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
|
|
1392
|
-
|
|
1393
|
-
|
|
1394
|
-
|
|
1395
|
-
|
|
1396
|
-
|
|
1397
|
-
|
|
1398
|
-
|
|
1399
|
-
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
|
|
1405
|
-
|
|
1406
|
-
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
|
|
1410
|
-
|
|
1411
|
-
|
|
1412
|
-
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
|
|
1420
|
-
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
|
|
1424
|
-
|
|
1425
|
-
|
|
1426
|
-
|
|
1427
|
-
|
|
1428
|
-
|
|
1429
|
-
|
|
1430
|
-
|
|
1431
|
-
|
|
1432
|
-
|
|
1433
|
-
|
|
1434
|
-
|
|
1435
|
-
|
|
1436
|
-
|
|
1437
|
-
|
|
1438
|
-
|
|
1439
|
-
|
|
1440
|
-
|
|
1441
|
-
|
|
1442
|
-
|
|
1443
|
-
|
|
1444
|
-
|
|
1445
|
-
|
|
1446
|
-
|
|
1447
|
-
|
|
1448
|
-
|
|
1449
|
-
|
|
1450
|
-
|
|
1451
|
-
|
|
1452
|
-
|
|
1453
|
-
|
|
1454
|
-
|
|
1455
|
-
|
|
1456
|
-
|
|
1457
|
-
|
|
1458
|
-
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
|
|
1462
|
-
|
|
1463
|
-
|
|
1464
|
-
|
|
1465
|
-
|
|
1466
|
-
|
|
1467
|
-
|
|
1468
|
-
|
|
1469
|
-
|
|
1470
|
-
|
|
1471
|
-
|
|
1594
|
+
// package.json
|
|
1595
|
+
var version = "0.8.1";
|
|
1596
|
+
var AnalysisNodeTypeSchema = z.enum([
|
|
1597
|
+
"DOCUMENT",
|
|
1598
|
+
"CANVAS",
|
|
1599
|
+
"FRAME",
|
|
1600
|
+
"GROUP",
|
|
1601
|
+
"SECTION",
|
|
1602
|
+
"COMPONENT",
|
|
1603
|
+
"COMPONENT_SET",
|
|
1604
|
+
"INSTANCE",
|
|
1605
|
+
"RECTANGLE",
|
|
1606
|
+
"ELLIPSE",
|
|
1607
|
+
"VECTOR",
|
|
1608
|
+
"TEXT",
|
|
1609
|
+
"LINE",
|
|
1610
|
+
"BOOLEAN_OPERATION",
|
|
1611
|
+
"STAR",
|
|
1612
|
+
"REGULAR_POLYGON",
|
|
1613
|
+
"SLICE",
|
|
1614
|
+
"STICKY",
|
|
1615
|
+
"SHAPE_WITH_TEXT",
|
|
1616
|
+
"CONNECTOR",
|
|
1617
|
+
"WIDGET",
|
|
1618
|
+
"EMBED",
|
|
1619
|
+
"LINK_UNFURL",
|
|
1620
|
+
"TABLE",
|
|
1621
|
+
"TABLE_CELL"
|
|
1622
|
+
]);
|
|
1623
|
+
var LayoutModeSchema = z.enum(["NONE", "HORIZONTAL", "VERTICAL"]);
|
|
1624
|
+
var LayoutAlignSchema = z.enum(["MIN", "CENTER", "MAX", "STRETCH", "INHERIT"]);
|
|
1625
|
+
var LayoutPositioningSchema = z.enum(["AUTO", "ABSOLUTE"]);
|
|
1626
|
+
var BaseAnalysisNodeSchema = z.object({
|
|
1627
|
+
// Basic identification
|
|
1628
|
+
id: z.string(),
|
|
1629
|
+
name: z.string(),
|
|
1630
|
+
type: AnalysisNodeTypeSchema,
|
|
1631
|
+
visible: z.boolean().default(true),
|
|
1632
|
+
// Layout analysis
|
|
1633
|
+
layoutMode: LayoutModeSchema.optional(),
|
|
1634
|
+
layoutAlign: LayoutAlignSchema.optional(),
|
|
1635
|
+
layoutPositioning: LayoutPositioningSchema.optional(),
|
|
1636
|
+
layoutSizingHorizontal: z.enum(["FIXED", "HUG", "FILL"]).optional(),
|
|
1637
|
+
layoutSizingVertical: z.enum(["FIXED", "HUG", "FILL"]).optional(),
|
|
1638
|
+
primaryAxisAlignItems: z.string().optional(),
|
|
1639
|
+
counterAxisAlignItems: z.string().optional(),
|
|
1640
|
+
itemSpacing: z.number().optional(),
|
|
1641
|
+
paddingLeft: z.number().optional(),
|
|
1642
|
+
paddingRight: z.number().optional(),
|
|
1643
|
+
paddingTop: z.number().optional(),
|
|
1644
|
+
paddingBottom: z.number().optional(),
|
|
1645
|
+
// Size/position analysis
|
|
1646
|
+
absoluteBoundingBox: z.object({
|
|
1647
|
+
x: z.number(),
|
|
1648
|
+
y: z.number(),
|
|
1649
|
+
width: z.number(),
|
|
1650
|
+
height: z.number()
|
|
1651
|
+
}).nullable().optional(),
|
|
1652
|
+
// Component analysis
|
|
1653
|
+
componentId: z.string().optional(),
|
|
1654
|
+
componentPropertyDefinitions: z.record(z.string(), z.unknown()).optional(),
|
|
1655
|
+
componentProperties: z.record(z.string(), z.unknown()).optional(),
|
|
1656
|
+
// Style/token analysis
|
|
1657
|
+
styles: z.record(z.string(), z.string()).optional(),
|
|
1658
|
+
fills: z.array(z.unknown()).optional(),
|
|
1659
|
+
strokes: z.array(z.unknown()).optional(),
|
|
1660
|
+
effects: z.array(z.unknown()).optional(),
|
|
1661
|
+
// Variable binding analysis (design tokens)
|
|
1662
|
+
boundVariables: z.record(z.string(), z.unknown()).optional(),
|
|
1663
|
+
// Text analysis
|
|
1664
|
+
characters: z.string().optional(),
|
|
1665
|
+
style: z.record(z.string(), z.unknown()).optional(),
|
|
1666
|
+
// Handoff analysis
|
|
1667
|
+
devStatus: z.object({
|
|
1668
|
+
type: z.enum(["NONE", "READY_FOR_DEV", "COMPLETED"]),
|
|
1669
|
+
description: z.string().optional()
|
|
1670
|
+
}).optional(),
|
|
1671
|
+
// Naming analysis metadata
|
|
1672
|
+
isAsset: z.boolean().optional()
|
|
1673
|
+
});
|
|
1674
|
+
var AnalysisNodeSchema = BaseAnalysisNodeSchema.extend({
|
|
1675
|
+
children: z.lazy(() => AnalysisNodeSchema.array().optional())
|
|
1676
|
+
});
|
|
1677
|
+
z.object({
|
|
1678
|
+
fileKey: z.string(),
|
|
1679
|
+
name: z.string(),
|
|
1680
|
+
lastModified: z.string(),
|
|
1681
|
+
version: z.string(),
|
|
1682
|
+
document: AnalysisNodeSchema,
|
|
1683
|
+
components: z.record(
|
|
1684
|
+
z.string(),
|
|
1685
|
+
z.object({
|
|
1686
|
+
key: z.string(),
|
|
1687
|
+
name: z.string(),
|
|
1688
|
+
description: z.string()
|
|
1689
|
+
})
|
|
1690
|
+
),
|
|
1691
|
+
styles: z.record(
|
|
1692
|
+
z.string(),
|
|
1693
|
+
z.object({
|
|
1694
|
+
key: z.string(),
|
|
1695
|
+
name: z.string(),
|
|
1696
|
+
styleType: z.string()
|
|
1697
|
+
})
|
|
1698
|
+
)
|
|
1699
|
+
});
|
|
1700
|
+
var IssueSchema = z.object({
|
|
1701
|
+
nodeId: z.string(),
|
|
1702
|
+
nodePath: z.string(),
|
|
1703
|
+
figmaDeepLink: z.string().url(),
|
|
1704
|
+
ruleId: z.string(),
|
|
1705
|
+
message: z.string(),
|
|
1706
|
+
severity: SeveritySchema
|
|
1707
|
+
});
|
|
1708
|
+
var CategoryScoreSchema = z.object({
|
|
1709
|
+
category: CategorySchema,
|
|
1710
|
+
score: z.number().min(0).max(100),
|
|
1711
|
+
maxScore: z.number().min(0).max(100),
|
|
1712
|
+
issueCount: z.object({
|
|
1713
|
+
error: z.number().int().min(0),
|
|
1714
|
+
warning: z.number().int().min(0),
|
|
1715
|
+
info: z.number().int().min(0)
|
|
1716
|
+
})
|
|
1717
|
+
});
|
|
1718
|
+
var ReportMetadataSchema = z.object({
|
|
1719
|
+
fileKey: z.string(),
|
|
1720
|
+
fileName: z.string(),
|
|
1721
|
+
analyzedAt: z.string().datetime(),
|
|
1722
|
+
version: z.string()
|
|
1723
|
+
});
|
|
1724
|
+
z.object({
|
|
1725
|
+
metadata: ReportMetadataSchema,
|
|
1726
|
+
totalScore: z.number().min(0).max(100),
|
|
1727
|
+
categoryScores: z.array(CategoryScoreSchema),
|
|
1728
|
+
issues: z.array(IssueSchema),
|
|
1729
|
+
summary: z.object({
|
|
1730
|
+
totalNodes: z.number().int().min(0),
|
|
1731
|
+
analyzedNodes: z.number().int().min(0),
|
|
1732
|
+
errorCount: z.number().int().min(0),
|
|
1733
|
+
warningCount: z.number().int().min(0),
|
|
1734
|
+
infoCount: z.number().int().min(0)
|
|
1735
|
+
})
|
|
1736
|
+
});
|
|
1472
1737
|
|
|
1473
|
-
|
|
1474
|
-
|
|
1738
|
+
// src/core/rules/excluded-names.ts
|
|
1739
|
+
var EXCLUDED_NAME_PATTERN = /(badge|close|dismiss|overlay|float|fab|dot|indicator|corner|decoration|tag|status|notification|icon|ico|image|asset|filter|dim|dimmed|bg|background|logo|avatar|divider|separator|nav|navigation|gnb|header|footer|sidebar|toolbar|modal|dialog|popup|toast|tooltip|dropdown|menu|sticky|spinner|loader|cursor|cta|chatbot|thumb|thumbnail|tabbar|tab-bar|statusbar|status-bar)/i;
|
|
1740
|
+
function isExcludedName(name) {
|
|
1741
|
+
return EXCLUDED_NAME_PATTERN.test(name);
|
|
1742
|
+
}
|
|
1475
1743
|
|
|
1476
|
-
|
|
1477
|
-
|
|
1478
|
-
|
|
1479
|
-
headers: { 'X-FIGMA-TOKEN': FIGMA_TOKEN, 'Content-Type': 'application/json' },
|
|
1480
|
-
body: JSON.stringify({ message: commentBody, client_meta: { node_id: nodeId, node_offset: { x: 0, y: 0 } } }),
|
|
1481
|
-
});
|
|
1482
|
-
if (!res.ok) throw new Error(await res.text());
|
|
1483
|
-
btn.textContent = 'Sent \\u2713';
|
|
1484
|
-
btn.classList.remove('hover:bg-muted');
|
|
1485
|
-
btn.classList.add('text-green-600', 'border-green-500/30');
|
|
1486
|
-
} catch (e) {
|
|
1487
|
-
btn.textContent = 'Failed \\u2717';
|
|
1488
|
-
btn.classList.remove('hover:bg-muted');
|
|
1489
|
-
btn.classList.add('text-red-600', 'border-red-500/30');
|
|
1490
|
-
btn.disabled = false;
|
|
1491
|
-
console.error('Failed to post Figma comment:', e);
|
|
1492
|
-
}
|
|
1493
|
-
}
|
|
1494
|
-
</script>` : ""}
|
|
1495
|
-
</body>
|
|
1496
|
-
</html>`;
|
|
1744
|
+
// src/core/rules/layout/index.ts
|
|
1745
|
+
function isContainerNode(node) {
|
|
1746
|
+
return node.type === "FRAME" || node.type === "GROUP" || node.type === "COMPONENT";
|
|
1497
1747
|
}
|
|
1498
|
-
function
|
|
1499
|
-
return
|
|
1500
|
-
<span class="w-2.5 h-2.5 rounded-full ${dotClass}"></span>
|
|
1501
|
-
<span class="text-lg font-bold tracking-tight">${count}</span>
|
|
1502
|
-
<span class="text-sm text-muted-foreground">${label}</span>
|
|
1503
|
-
</div>`;
|
|
1504
|
-
}
|
|
1505
|
-
function renderOpportunities(issues, fileKey) {
|
|
1506
|
-
const maxAbs = issues.reduce((m, i) => Math.max(m, Math.abs(i.calculatedScore)), 1);
|
|
1507
|
-
return `
|
|
1508
|
-
<!-- Opportunities -->
|
|
1509
|
-
<section class="bg-card border border-border rounded-lg shadow-sm mb-6 overflow-hidden">
|
|
1510
|
-
<div class="px-6 py-4 border-b border-border">
|
|
1511
|
-
<h2 class="text-sm font-semibold flex items-center gap-2">
|
|
1512
|
-
<span class="w-2 h-2 rounded-full bg-red-500"></span>
|
|
1513
|
-
Opportunities
|
|
1514
|
-
</h2>
|
|
1515
|
-
<p class="text-xs text-muted-foreground mt-1">Top blocking issues \u2014 fix these first for the biggest improvement.</p>
|
|
1516
|
-
</div>
|
|
1517
|
-
<div class="divide-y divide-border">
|
|
1518
|
-
${issues.map((issue) => {
|
|
1519
|
-
const def = issue.rule.definition;
|
|
1520
|
-
const link = buildFigmaDeepLink(fileKey, issue.violation.nodeId);
|
|
1521
|
-
const barW = Math.round(Math.abs(issue.calculatedScore) / maxAbs * 100);
|
|
1522
|
-
return ` <div class="px-6 py-3 flex items-center gap-4 hover:bg-muted/50 transition-colors">
|
|
1523
|
-
<div class="flex-1 min-w-0">
|
|
1524
|
-
<div class="text-sm font-medium truncate">${esc(def.name)}</div>
|
|
1525
|
-
<div class="text-xs text-muted-foreground truncate mt-0.5">${esc(issue.violation.message)}</div>
|
|
1526
|
-
</div>
|
|
1527
|
-
<div class="w-32 flex items-center gap-2 shrink-0">
|
|
1528
|
-
<div class="flex-1 h-1.5 bg-muted rounded-full overflow-hidden">
|
|
1529
|
-
<div class="h-full bg-red-500 rounded-full" style="width:${barW}%"></div>
|
|
1530
|
-
</div>
|
|
1531
|
-
<span class="text-xs font-medium text-red-600 w-12 text-right">${issue.calculatedScore}</span>
|
|
1532
|
-
</div>
|
|
1533
|
-
<a href="${link}" target="_blank" rel="noopener" class="text-xs text-muted-foreground hover:text-foreground shrink-0 no-print">Figma \u2192</a>
|
|
1534
|
-
</div>`;
|
|
1535
|
-
}).join("\n")}
|
|
1536
|
-
</div>
|
|
1537
|
-
</section>`;
|
|
1538
|
-
}
|
|
1539
|
-
function renderCategory(cat, scores, issues, fileKey, screenshotMap, figmaToken) {
|
|
1540
|
-
const cs = scores.byCategory[cat];
|
|
1541
|
-
const hasProblems = issues.some((i) => i.config.severity === "blocking" || i.config.severity === "risk");
|
|
1542
|
-
const bySeverity = /* @__PURE__ */ new Map();
|
|
1543
|
-
for (const sev of SEVERITY_ORDER) bySeverity.set(sev, []);
|
|
1544
|
-
for (const issue of issues) bySeverity.get(issue.config.severity)?.push(issue);
|
|
1545
|
-
return `
|
|
1546
|
-
<details id="cat-${cat}" class="bg-card border border-border rounded-lg shadow-sm overflow-hidden group"${hasProblems ? " open" : ""}>
|
|
1547
|
-
<summary class="px-5 py-3.5 flex items-center gap-3 cursor-pointer hover:bg-muted/50 transition-colors select-none">
|
|
1548
|
-
<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>
|
|
1549
|
-
<div class="flex-1 min-w-0">
|
|
1550
|
-
<div class="text-sm font-semibold">${CATEGORY_LABELS[cat]}</div>
|
|
1551
|
-
<div class="text-xs text-muted-foreground">${esc(CATEGORY_DESCRIPTIONS[cat])}</div>
|
|
1552
|
-
</div>
|
|
1553
|
-
<span class="text-xs text-muted-foreground">${cs.issueCount} issues</span>
|
|
1554
|
-
<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>
|
|
1555
|
-
</summary>
|
|
1556
|
-
<div class="border-t border-border">
|
|
1557
|
-
${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")}
|
|
1558
|
-
</div>
|
|
1559
|
-
</details>`;
|
|
1560
|
-
}
|
|
1561
|
-
function renderSeverityGroup(sev, issues, fileKey, screenshotMap, figmaToken) {
|
|
1562
|
-
return ` <div class="px-5 py-3">
|
|
1563
|
-
<div class="flex items-center gap-2 mb-2">
|
|
1564
|
-
<span class="w-2 h-2 rounded-full ${severityDot(sev)}"></span>
|
|
1565
|
-
<span class="text-xs font-semibold uppercase tracking-wider">${SEVERITY_LABELS[sev]}</span>
|
|
1566
|
-
<span class="text-xs text-muted-foreground ml-auto">${issues.length}</span>
|
|
1567
|
-
</div>
|
|
1568
|
-
<div class="space-y-1">
|
|
1569
|
-
${issues.map((issue) => renderIssueRow(issue, fileKey, screenshotMap, figmaToken)).join("\n")}
|
|
1570
|
-
</div>
|
|
1571
|
-
</div>`;
|
|
1572
|
-
}
|
|
1573
|
-
function renderIssueRow(issue, fileKey, screenshotMap, figmaToken) {
|
|
1574
|
-
const sev = issue.config.severity;
|
|
1575
|
-
const def = issue.rule.definition;
|
|
1576
|
-
const link = buildFigmaDeepLink(fileKey, issue.violation.nodeId);
|
|
1577
|
-
const screenshot = screenshotMap.get(issue.violation.nodeId);
|
|
1578
|
-
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>` : "";
|
|
1579
|
-
return ` <details class="border border-border rounded-md overflow-hidden">
|
|
1580
|
-
<summary class="flex items-center gap-2.5 px-3 py-2 text-sm cursor-pointer hover:bg-muted/50 transition-colors">
|
|
1581
|
-
<span class="w-1.5 h-1.5 rounded-full ${severityDot(sev)} shrink-0"></span>
|
|
1582
|
-
<span class="font-medium shrink-0">${esc(def.name)}</span>
|
|
1583
|
-
<span class="text-muted-foreground truncate text-xs flex-1">${esc(issue.violation.message)}</span>
|
|
1584
|
-
<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>
|
|
1585
|
-
</summary>
|
|
1586
|
-
<div class="px-3 py-3 bg-muted/30 border-t border-border text-sm space-y-2">
|
|
1587
|
-
<div class="font-mono text-xs text-muted-foreground break-all">${esc(issue.violation.nodePath)}</div>
|
|
1588
|
-
<div class="text-muted-foreground leading-relaxed space-y-1">
|
|
1589
|
-
<p><span class="font-medium text-foreground">Why:</span> ${esc(def.why)}</p>
|
|
1590
|
-
<p><span class="font-medium text-foreground">Impact:</span> ${esc(def.impact)}</p>
|
|
1591
|
-
<p><span class="font-medium text-foreground">Fix:</span> ${esc(def.fix)}</p>
|
|
1592
|
-
</div>${screenshotHtml}
|
|
1593
|
-
<div class="flex items-center gap-2 mt-1 no-print">
|
|
1594
|
-
<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 ? `
|
|
1595
|
-
<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>` : ""}
|
|
1596
|
-
</div>
|
|
1597
|
-
</div>
|
|
1598
|
-
</details>`;
|
|
1599
|
-
}
|
|
1600
|
-
function getQuickWins(issues, limit) {
|
|
1601
|
-
return issues.filter((issue) => issue.config.severity === "blocking").sort((a, b) => a.calculatedScore - b.calculatedScore).slice(0, limit);
|
|
1602
|
-
}
|
|
1603
|
-
function groupIssuesByCategory(issues) {
|
|
1604
|
-
const grouped = /* @__PURE__ */ new Map();
|
|
1605
|
-
for (const category of CATEGORIES) grouped.set(category, []);
|
|
1606
|
-
for (const issue of issues) grouped.get(issue.rule.definition.category).push(issue);
|
|
1607
|
-
return grouped;
|
|
1608
|
-
}
|
|
1609
|
-
var esc = escapeHtml;
|
|
1610
|
-
var RuleOverrideSchema = z.object({
|
|
1611
|
-
score: z.number().int().max(0).optional(),
|
|
1612
|
-
severity: SeveritySchema.optional(),
|
|
1613
|
-
enabled: z.boolean().optional()
|
|
1614
|
-
});
|
|
1615
|
-
var ConfigFileSchema = z.object({
|
|
1616
|
-
excludeNodeTypes: z.array(z.string()).optional(),
|
|
1617
|
-
excludeNodeNames: z.array(z.string()).optional(),
|
|
1618
|
-
gridBase: z.number().int().positive().optional(),
|
|
1619
|
-
colorTolerance: z.number().int().positive().optional(),
|
|
1620
|
-
rules: z.record(z.string(), RuleOverrideSchema).optional()
|
|
1621
|
-
});
|
|
1622
|
-
async function loadConfigFile(filePath) {
|
|
1623
|
-
const absPath = resolve(filePath);
|
|
1624
|
-
const raw = await readFile(absPath, "utf-8");
|
|
1625
|
-
const parsed = JSON.parse(raw);
|
|
1626
|
-
return ConfigFileSchema.parse(parsed);
|
|
1627
|
-
}
|
|
1628
|
-
function mergeConfigs(base, overrides) {
|
|
1629
|
-
const merged = { ...base };
|
|
1630
|
-
if (overrides.gridBase !== void 0) {
|
|
1631
|
-
for (const [id, config2] of Object.entries(merged)) {
|
|
1632
|
-
if (config2.options && "gridBase" in config2.options) {
|
|
1633
|
-
merged[id] = {
|
|
1634
|
-
...config2,
|
|
1635
|
-
options: { ...config2.options, gridBase: overrides.gridBase }
|
|
1636
|
-
};
|
|
1637
|
-
}
|
|
1638
|
-
}
|
|
1639
|
-
}
|
|
1640
|
-
if (overrides.colorTolerance !== void 0) {
|
|
1641
|
-
for (const [id, config2] of Object.entries(merged)) {
|
|
1642
|
-
if (config2.options && "tolerance" in config2.options) {
|
|
1643
|
-
merged[id] = {
|
|
1644
|
-
...config2,
|
|
1645
|
-
options: { ...config2.options, tolerance: overrides.colorTolerance }
|
|
1646
|
-
};
|
|
1647
|
-
}
|
|
1648
|
-
}
|
|
1649
|
-
}
|
|
1650
|
-
if (overrides.rules) {
|
|
1651
|
-
for (const [ruleId, override] of Object.entries(overrides.rules)) {
|
|
1652
|
-
const existing = merged[ruleId];
|
|
1653
|
-
if (existing) {
|
|
1654
|
-
merged[ruleId] = {
|
|
1655
|
-
...existing,
|
|
1656
|
-
...override.score !== void 0 && { score: override.score },
|
|
1657
|
-
...override.severity !== void 0 && { severity: override.severity },
|
|
1658
|
-
...override.enabled !== void 0 && { enabled: override.enabled }
|
|
1659
|
-
};
|
|
1660
|
-
}
|
|
1661
|
-
}
|
|
1662
|
-
}
|
|
1663
|
-
return merged;
|
|
1664
|
-
}
|
|
1665
|
-
var MatchConditionSchema = z.object({
|
|
1666
|
-
// Node type conditions
|
|
1667
|
-
type: z.array(z.string()).optional(),
|
|
1668
|
-
notType: z.array(z.string()).optional(),
|
|
1669
|
-
// Name conditions (case-insensitive, substring match)
|
|
1670
|
-
nameContains: z.string().optional(),
|
|
1671
|
-
nameNotContains: z.string().optional(),
|
|
1672
|
-
namePattern: z.string().optional(),
|
|
1673
|
-
// Size conditions
|
|
1674
|
-
minWidth: z.number().optional(),
|
|
1675
|
-
maxWidth: z.number().optional(),
|
|
1676
|
-
minHeight: z.number().optional(),
|
|
1677
|
-
maxHeight: z.number().optional(),
|
|
1678
|
-
// Layout conditions
|
|
1679
|
-
hasAutoLayout: z.boolean().optional(),
|
|
1680
|
-
hasChildren: z.boolean().optional(),
|
|
1681
|
-
minChildren: z.number().optional(),
|
|
1682
|
-
maxChildren: z.number().optional(),
|
|
1683
|
-
// Component conditions
|
|
1684
|
-
isComponent: z.boolean().optional(),
|
|
1685
|
-
isInstance: z.boolean().optional(),
|
|
1686
|
-
hasComponentId: z.boolean().optional(),
|
|
1687
|
-
// Visibility
|
|
1688
|
-
isVisible: z.boolean().optional(),
|
|
1689
|
-
// Fill/style conditions
|
|
1690
|
-
hasFills: z.boolean().optional(),
|
|
1691
|
-
hasStrokes: z.boolean().optional(),
|
|
1692
|
-
hasEffects: z.boolean().optional(),
|
|
1693
|
-
// Depth condition
|
|
1694
|
-
minDepth: z.number().optional(),
|
|
1695
|
-
maxDepth: z.number().optional()
|
|
1696
|
-
});
|
|
1697
|
-
var CustomRuleSchema = z.object({
|
|
1698
|
-
id: z.string(),
|
|
1699
|
-
category: CategorySchema,
|
|
1700
|
-
severity: SeveritySchema,
|
|
1701
|
-
score: z.number().int().max(0),
|
|
1702
|
-
match: MatchConditionSchema,
|
|
1703
|
-
message: z.string().optional(),
|
|
1704
|
-
why: z.string(),
|
|
1705
|
-
impact: z.string(),
|
|
1706
|
-
fix: z.string(),
|
|
1707
|
-
// Backward compat: silently ignore the old prompt field
|
|
1708
|
-
prompt: z.string().optional()
|
|
1709
|
-
});
|
|
1710
|
-
var CustomRulesFileSchema = z.array(CustomRuleSchema);
|
|
1711
|
-
|
|
1712
|
-
// src/core/rules/custom/custom-rule-loader.ts
|
|
1713
|
-
async function loadCustomRules(filePath) {
|
|
1714
|
-
const absPath = resolve(filePath);
|
|
1715
|
-
const raw = await readFile(absPath, "utf-8");
|
|
1716
|
-
const parsed = JSON.parse(raw);
|
|
1717
|
-
const customRules = CustomRulesFileSchema.parse(parsed);
|
|
1718
|
-
const rules = [];
|
|
1719
|
-
const configs = {};
|
|
1720
|
-
for (const cr of customRules) {
|
|
1721
|
-
if (!cr.match) continue;
|
|
1722
|
-
rules.push(toRule(cr));
|
|
1723
|
-
configs[cr.id] = {
|
|
1724
|
-
severity: cr.severity,
|
|
1725
|
-
score: cr.score,
|
|
1726
|
-
enabled: true
|
|
1727
|
-
};
|
|
1728
|
-
}
|
|
1729
|
-
return { rules, configs };
|
|
1730
|
-
}
|
|
1731
|
-
function toRule(cr) {
|
|
1732
|
-
return {
|
|
1733
|
-
definition: {
|
|
1734
|
-
id: cr.id,
|
|
1735
|
-
name: cr.id.replace(/-/g, " ").replace(/\b\w/g, (c) => c.toUpperCase()),
|
|
1736
|
-
category: cr.category,
|
|
1737
|
-
why: cr.why,
|
|
1738
|
-
impact: cr.impact,
|
|
1739
|
-
fix: cr.fix
|
|
1740
|
-
},
|
|
1741
|
-
check: createPatternCheck(cr)
|
|
1742
|
-
};
|
|
1743
|
-
}
|
|
1744
|
-
function createPatternCheck(cr) {
|
|
1745
|
-
return (node, context) => {
|
|
1746
|
-
if (node.type === "DOCUMENT" || node.type === "CANVAS") return null;
|
|
1747
|
-
const match = cr.match;
|
|
1748
|
-
if (match.type && !match.type.includes(node.type)) return null;
|
|
1749
|
-
if (match.notType && match.notType.includes(node.type)) return null;
|
|
1750
|
-
if (match.nameContains !== void 0 && !node.name.toLowerCase().includes(match.nameContains.toLowerCase())) return null;
|
|
1751
|
-
if (match.nameNotContains !== void 0 && node.name.toLowerCase().includes(match.nameNotContains.toLowerCase())) return null;
|
|
1752
|
-
if (match.namePattern !== void 0 && !new RegExp(match.namePattern, "i").test(node.name)) return null;
|
|
1753
|
-
const bbox = node.absoluteBoundingBox;
|
|
1754
|
-
if (match.minWidth !== void 0 && (!bbox || bbox.width < match.minWidth)) return null;
|
|
1755
|
-
if (match.maxWidth !== void 0 && (!bbox || bbox.width > match.maxWidth)) return null;
|
|
1756
|
-
if (match.minHeight !== void 0 && (!bbox || bbox.height < match.minHeight)) return null;
|
|
1757
|
-
if (match.maxHeight !== void 0 && (!bbox || bbox.height > match.maxHeight)) return null;
|
|
1758
|
-
if (match.hasAutoLayout === true && !node.layoutMode) return null;
|
|
1759
|
-
if (match.hasAutoLayout === false && node.layoutMode) return null;
|
|
1760
|
-
if (match.hasChildren === true && (!node.children || node.children.length === 0)) return null;
|
|
1761
|
-
if (match.hasChildren === false && node.children && node.children.length > 0) return null;
|
|
1762
|
-
if (match.minChildren !== void 0 && (!node.children || node.children.length < match.minChildren)) return null;
|
|
1763
|
-
if (match.maxChildren !== void 0 && node.children && node.children.length > match.maxChildren) return null;
|
|
1764
|
-
if (match.isComponent === true && node.type !== "COMPONENT" && node.type !== "COMPONENT_SET") return null;
|
|
1765
|
-
if (match.isComponent === false && (node.type === "COMPONENT" || node.type === "COMPONENT_SET")) return null;
|
|
1766
|
-
if (match.isInstance === true && node.type !== "INSTANCE") return null;
|
|
1767
|
-
if (match.isInstance === false && node.type === "INSTANCE") return null;
|
|
1768
|
-
if (match.hasComponentId === true && !node.componentId) return null;
|
|
1769
|
-
if (match.hasComponentId === false && node.componentId) return null;
|
|
1770
|
-
if (match.isVisible === true && !node.visible) return null;
|
|
1771
|
-
if (match.isVisible === false && node.visible) return null;
|
|
1772
|
-
if (match.hasFills === true && (!node.fills || node.fills.length === 0)) return null;
|
|
1773
|
-
if (match.hasFills === false && node.fills && node.fills.length > 0) return null;
|
|
1774
|
-
if (match.hasStrokes === true && (!node.strokes || node.strokes.length === 0)) return null;
|
|
1775
|
-
if (match.hasStrokes === false && node.strokes && node.strokes.length > 0) return null;
|
|
1776
|
-
if (match.hasEffects === true && (!node.effects || node.effects.length === 0)) return null;
|
|
1777
|
-
if (match.hasEffects === false && node.effects && node.effects.length > 0) return null;
|
|
1778
|
-
if (match.minDepth !== void 0 && context.depth < match.minDepth) return null;
|
|
1779
|
-
if (match.maxDepth !== void 0 && context.depth > match.maxDepth) return null;
|
|
1780
|
-
const msg = (cr.message ?? `"${node.name}" matched custom rule "${cr.id}"`).replace(/\{name\}/g, node.name).replace(/\{type\}/g, node.type);
|
|
1781
|
-
return {
|
|
1782
|
-
ruleId: cr.id,
|
|
1783
|
-
nodeId: node.id,
|
|
1784
|
-
nodePath: context.path.join(" > "),
|
|
1785
|
-
message: msg
|
|
1786
|
-
};
|
|
1787
|
-
};
|
|
1788
|
-
}
|
|
1789
|
-
|
|
1790
|
-
// src/core/monitoring/events.ts
|
|
1791
|
-
var EVENT_PREFIX = "cic_";
|
|
1792
|
-
var EVENTS = {
|
|
1793
|
-
// Analysis
|
|
1794
|
-
ANALYSIS_STARTED: `${EVENT_PREFIX}analysis_started`,
|
|
1795
|
-
ANALYSIS_COMPLETED: `${EVENT_PREFIX}analysis_completed`,
|
|
1796
|
-
ANALYSIS_FAILED: `${EVENT_PREFIX}analysis_failed`,
|
|
1797
|
-
// Report
|
|
1798
|
-
REPORT_GENERATED: `${EVENT_PREFIX}report_generated`,
|
|
1799
|
-
COMMENT_POSTED: `${EVENT_PREFIX}comment_posted`,
|
|
1800
|
-
COMMENT_FAILED: `${EVENT_PREFIX}comment_failed`,
|
|
1801
|
-
// MCP
|
|
1802
|
-
MCP_TOOL_CALLED: `${EVENT_PREFIX}mcp_tool_called`,
|
|
1803
|
-
// CLI
|
|
1804
|
-
CLI_COMMAND: `${EVENT_PREFIX}cli_command`,
|
|
1805
|
-
CLI_INIT: `${EVENT_PREFIX}cli_init`
|
|
1806
|
-
};
|
|
1807
|
-
|
|
1808
|
-
// src/core/monitoring/capture.ts
|
|
1809
|
-
var monitoringEnabled = false;
|
|
1810
|
-
var posthogApiKey;
|
|
1811
|
-
var sentryDsn;
|
|
1812
|
-
var distinctId = "anonymous";
|
|
1813
|
-
var environment = "unknown";
|
|
1814
|
-
var version = "unknown";
|
|
1815
|
-
var commonProps = {};
|
|
1816
|
-
function uuid4() {
|
|
1817
|
-
return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (c) => {
|
|
1818
|
-
const r = Math.random() * 16 | 0;
|
|
1819
|
-
const v = c === "x" ? r : r & 3 | 8;
|
|
1820
|
-
return v.toString(16);
|
|
1821
|
-
});
|
|
1822
|
-
}
|
|
1823
|
-
function parseSentryDsn(dsn) {
|
|
1824
|
-
try {
|
|
1825
|
-
const url = new URL(dsn);
|
|
1826
|
-
const key = url.username;
|
|
1827
|
-
const projectId = url.pathname.slice(1);
|
|
1828
|
-
const host = url.protocol + "//" + url.host;
|
|
1829
|
-
if (!key || !projectId) return null;
|
|
1830
|
-
return { key, host, projectId };
|
|
1831
|
-
} catch {
|
|
1832
|
-
return null;
|
|
1833
|
-
}
|
|
1834
|
-
}
|
|
1835
|
-
function initCapture(config2) {
|
|
1836
|
-
if (config2.enabled === false) return;
|
|
1837
|
-
if (!config2.posthogApiKey && !config2.sentryDsn) return;
|
|
1838
|
-
monitoringEnabled = true;
|
|
1839
|
-
posthogApiKey = config2.posthogApiKey;
|
|
1840
|
-
sentryDsn = config2.sentryDsn;
|
|
1841
|
-
distinctId = config2.distinctId ?? "anonymous";
|
|
1842
|
-
environment = config2.environment ?? "unknown";
|
|
1843
|
-
version = config2.version ?? "unknown";
|
|
1844
|
-
commonProps = {
|
|
1845
|
-
_sdk: "canicode",
|
|
1846
|
-
_sdk_version: version,
|
|
1847
|
-
_env: environment
|
|
1848
|
-
};
|
|
1849
|
-
}
|
|
1850
|
-
function captureEvent(event, properties) {
|
|
1851
|
-
if (!monitoringEnabled || !posthogApiKey) return;
|
|
1852
|
-
try {
|
|
1853
|
-
fetch("https://us.i.posthog.com/i/v0/e/", {
|
|
1854
|
-
method: "POST",
|
|
1855
|
-
headers: { "Content-Type": "application/json" },
|
|
1856
|
-
body: JSON.stringify({
|
|
1857
|
-
api_key: posthogApiKey,
|
|
1858
|
-
event,
|
|
1859
|
-
distinct_id: distinctId,
|
|
1860
|
-
properties: { ...commonProps, ...properties },
|
|
1861
|
-
timestamp: (/* @__PURE__ */ new Date()).toISOString()
|
|
1862
|
-
})
|
|
1863
|
-
}).catch(() => {
|
|
1864
|
-
});
|
|
1865
|
-
} catch {
|
|
1866
|
-
}
|
|
1867
|
-
}
|
|
1868
|
-
function captureError(error, context) {
|
|
1869
|
-
if (!monitoringEnabled) return;
|
|
1870
|
-
if (sentryDsn) {
|
|
1871
|
-
const parsed = parseSentryDsn(sentryDsn);
|
|
1872
|
-
if (parsed) {
|
|
1873
|
-
try {
|
|
1874
|
-
const eventId = uuid4();
|
|
1875
|
-
const envelope = [
|
|
1876
|
-
JSON.stringify({ event_id: eventId, sent_at: (/* @__PURE__ */ new Date()).toISOString(), dsn: sentryDsn }),
|
|
1877
|
-
JSON.stringify({ type: "event", content_type: "application/json" }),
|
|
1878
|
-
JSON.stringify({
|
|
1879
|
-
event_id: eventId,
|
|
1880
|
-
exception: { values: [{ type: error.name, value: error.message }] },
|
|
1881
|
-
platform: "node",
|
|
1882
|
-
environment,
|
|
1883
|
-
release: `canicode@${version}`,
|
|
1884
|
-
timestamp: Date.now() / 1e3,
|
|
1885
|
-
extra: context
|
|
1886
|
-
})
|
|
1887
|
-
].join("\n");
|
|
1888
|
-
fetch(`${parsed.host}/api/${parsed.projectId}/envelope/`, {
|
|
1889
|
-
method: "POST",
|
|
1890
|
-
headers: {
|
|
1891
|
-
"Content-Type": "application/x-sentry-envelope",
|
|
1892
|
-
"X-Sentry-Auth": `Sentry sentry_version=7, sentry_key=${parsed.key}`
|
|
1893
|
-
},
|
|
1894
|
-
body: envelope
|
|
1895
|
-
}).catch(() => {
|
|
1896
|
-
});
|
|
1897
|
-
} catch {
|
|
1898
|
-
}
|
|
1899
|
-
}
|
|
1900
|
-
}
|
|
1901
|
-
captureEvent("cic_error", { error: error.message, ...context });
|
|
1902
|
-
}
|
|
1903
|
-
function shutdownCapture() {
|
|
1904
|
-
monitoringEnabled = false;
|
|
1905
|
-
posthogApiKey = void 0;
|
|
1906
|
-
sentryDsn = void 0;
|
|
1907
|
-
distinctId = "anonymous";
|
|
1908
|
-
environment = "unknown";
|
|
1909
|
-
version = "unknown";
|
|
1910
|
-
commonProps = {};
|
|
1911
|
-
}
|
|
1912
|
-
|
|
1913
|
-
// src/core/monitoring/index.ts
|
|
1914
|
-
function initMonitoring(config2) {
|
|
1915
|
-
initCapture(config2);
|
|
1916
|
-
}
|
|
1917
|
-
function trackEvent(event, properties) {
|
|
1918
|
-
try {
|
|
1919
|
-
captureEvent(event, properties);
|
|
1920
|
-
} catch {
|
|
1921
|
-
}
|
|
1922
|
-
}
|
|
1923
|
-
function trackError(error, context) {
|
|
1924
|
-
try {
|
|
1925
|
-
captureError(error, context);
|
|
1926
|
-
} catch {
|
|
1927
|
-
}
|
|
1928
|
-
}
|
|
1929
|
-
function shutdownMonitoring() {
|
|
1930
|
-
try {
|
|
1931
|
-
shutdownCapture();
|
|
1932
|
-
} catch {
|
|
1933
|
-
}
|
|
1934
|
-
}
|
|
1935
|
-
|
|
1936
|
-
// src/core/monitoring/keys.ts
|
|
1937
|
-
var POSTHOG_API_KEY = "phc_rBFeG140KqJLpUnlpYDEFgdMM6JozZeqQsf9twXf5Dq" ;
|
|
1938
|
-
var SENTRY_DSN = "https://80a836a8300b25f17ef5bbf23afb5b3a@o4511080656207872.ingest.us.sentry.io/4511080661319680" ;
|
|
1939
|
-
|
|
1940
|
-
// src/core/rules/excluded-names.ts
|
|
1941
|
-
var EXCLUDED_NAME_PATTERN = /(badge|close|dismiss|overlay|float|fab|dot|indicator|corner|decoration|tag|status|notification|icon|ico|image|asset|filter|dim|dimmed|bg|background|logo|avatar|divider|separator|nav|navigation|gnb|header|footer|sidebar|toolbar|modal|dialog|popup|toast|tooltip|dropdown|menu|sticky|spinner|loader|cursor|cta|chatbot|thumb|thumbnail|tabbar|tab-bar|statusbar|status-bar)/i;
|
|
1942
|
-
function isExcludedName(name) {
|
|
1943
|
-
return EXCLUDED_NAME_PATTERN.test(name);
|
|
1944
|
-
}
|
|
1945
|
-
|
|
1946
|
-
// src/core/rules/layout/index.ts
|
|
1947
|
-
function isContainerNode(node) {
|
|
1948
|
-
return node.type === "FRAME" || node.type === "GROUP" || node.type === "COMPONENT";
|
|
1949
|
-
}
|
|
1950
|
-
function hasAutoLayout(node) {
|
|
1951
|
-
return node.layoutMode !== void 0 && node.layoutMode !== "NONE";
|
|
1748
|
+
function hasAutoLayout(node) {
|
|
1749
|
+
return node.layoutMode !== void 0 && node.layoutMode !== "NONE";
|
|
1952
1750
|
}
|
|
1953
1751
|
function hasTextContent(node) {
|
|
1954
1752
|
return node.type === "TEXT" || (node.children?.some((c) => c.type === "TEXT") ?? false);
|
|
@@ -2568,546 +2366,1228 @@ var componentPropertyUnusedCheck = (node, _context) => {
|
|
|
2568
2366
|
return null;
|
|
2569
2367
|
};
|
|
2570
2368
|
defineRule({
|
|
2571
|
-
definition: componentPropertyUnusedDef,
|
|
2572
|
-
check: componentPropertyUnusedCheck
|
|
2369
|
+
definition: componentPropertyUnusedDef,
|
|
2370
|
+
check: componentPropertyUnusedCheck
|
|
2371
|
+
});
|
|
2372
|
+
var singleUseComponentDef = {
|
|
2373
|
+
id: "single-use-component",
|
|
2374
|
+
name: "Single Use Component",
|
|
2375
|
+
category: "component",
|
|
2376
|
+
why: "Components used only once add complexity without reuse benefit",
|
|
2377
|
+
impact: "Unnecessary abstraction increases maintenance overhead",
|
|
2378
|
+
fix: "Consider inlining if this component won't be reused"
|
|
2379
|
+
};
|
|
2380
|
+
var singleUseComponentCheck = (node, context) => {
|
|
2381
|
+
if (!isComponent(node)) return null;
|
|
2382
|
+
let instanceCount = 0;
|
|
2383
|
+
function countInstances(n) {
|
|
2384
|
+
if (n.type === "INSTANCE" && n.componentId === node.id) {
|
|
2385
|
+
instanceCount++;
|
|
2386
|
+
}
|
|
2387
|
+
if (n.children) {
|
|
2388
|
+
for (const child of n.children) {
|
|
2389
|
+
countInstances(child);
|
|
2390
|
+
}
|
|
2391
|
+
}
|
|
2392
|
+
}
|
|
2393
|
+
countInstances(context.file.document);
|
|
2394
|
+
if (instanceCount === 1) {
|
|
2395
|
+
return {
|
|
2396
|
+
ruleId: singleUseComponentDef.id,
|
|
2397
|
+
nodeId: node.id,
|
|
2398
|
+
nodePath: context.path.join(" > "),
|
|
2399
|
+
message: `Component "${node.name}" is only used once`
|
|
2400
|
+
};
|
|
2401
|
+
}
|
|
2402
|
+
return null;
|
|
2403
|
+
};
|
|
2404
|
+
defineRule({
|
|
2405
|
+
definition: singleUseComponentDef,
|
|
2406
|
+
check: singleUseComponentCheck
|
|
2407
|
+
});
|
|
2408
|
+
|
|
2409
|
+
// src/core/rules/naming/index.ts
|
|
2410
|
+
var DEFAULT_NAME_PATTERNS = [
|
|
2411
|
+
/^Frame\s*\d*$/i,
|
|
2412
|
+
/^Group\s*\d*$/i,
|
|
2413
|
+
/^Rectangle\s*\d*$/i,
|
|
2414
|
+
/^Ellipse\s*\d*$/i,
|
|
2415
|
+
/^Vector\s*\d*$/i,
|
|
2416
|
+
/^Line\s*\d*$/i,
|
|
2417
|
+
/^Text\s*\d*$/i,
|
|
2418
|
+
/^Image\s*\d*$/i,
|
|
2419
|
+
/^Component\s*\d*$/i,
|
|
2420
|
+
/^Instance\s*\d*$/i
|
|
2421
|
+
];
|
|
2422
|
+
var NON_SEMANTIC_NAMES = [
|
|
2423
|
+
"rectangle",
|
|
2424
|
+
"ellipse",
|
|
2425
|
+
"vector",
|
|
2426
|
+
"line",
|
|
2427
|
+
"polygon",
|
|
2428
|
+
"star",
|
|
2429
|
+
"path",
|
|
2430
|
+
"shape",
|
|
2431
|
+
"image",
|
|
2432
|
+
"fill",
|
|
2433
|
+
"stroke"
|
|
2434
|
+
];
|
|
2435
|
+
function isDefaultName(name) {
|
|
2436
|
+
return DEFAULT_NAME_PATTERNS.some((pattern) => pattern.test(name));
|
|
2437
|
+
}
|
|
2438
|
+
function isNonSemanticName(name) {
|
|
2439
|
+
const normalized = name.toLowerCase().trim();
|
|
2440
|
+
return NON_SEMANTIC_NAMES.includes(normalized);
|
|
2441
|
+
}
|
|
2442
|
+
function hasNumericSuffix(name) {
|
|
2443
|
+
return /\s+\d+$/.test(name);
|
|
2444
|
+
}
|
|
2445
|
+
function detectNamingConvention(name) {
|
|
2446
|
+
if (/^[a-z]+(-[a-z]+)*$/.test(name)) return "kebab-case";
|
|
2447
|
+
if (/^[a-z]+(_[a-z]+)*$/.test(name)) return "snake_case";
|
|
2448
|
+
if (/^[a-z]+([A-Z][a-z]*)*$/.test(name)) return "camelCase";
|
|
2449
|
+
if (/^[A-Z][a-z]+([A-Z][a-z]*)*$/.test(name)) return "PascalCase";
|
|
2450
|
+
if (/^[A-Z]+(_[A-Z]+)*$/.test(name)) return "SCREAMING_SNAKE_CASE";
|
|
2451
|
+
if (/\s/.test(name)) return "Title Case";
|
|
2452
|
+
return null;
|
|
2453
|
+
}
|
|
2454
|
+
var defaultNameDef = {
|
|
2455
|
+
id: "default-name",
|
|
2456
|
+
name: "Default Name",
|
|
2457
|
+
category: "naming",
|
|
2458
|
+
why: "Default names like 'Frame 123' provide no context about the element's purpose",
|
|
2459
|
+
impact: "Designers and developers cannot understand the structure",
|
|
2460
|
+
fix: "Rename with a descriptive, semantic name (e.g., 'Header', 'ProductCard')"
|
|
2461
|
+
};
|
|
2462
|
+
var defaultNameCheck = (node, context) => {
|
|
2463
|
+
if (!node.name) return null;
|
|
2464
|
+
if (isExcludedName(node.name)) return null;
|
|
2465
|
+
if (!isDefaultName(node.name)) return null;
|
|
2466
|
+
return {
|
|
2467
|
+
ruleId: defaultNameDef.id,
|
|
2468
|
+
nodeId: node.id,
|
|
2469
|
+
nodePath: context.path.join(" > "),
|
|
2470
|
+
message: `"${node.name}" is a default name - provide a meaningful name`
|
|
2471
|
+
};
|
|
2472
|
+
};
|
|
2473
|
+
defineRule({
|
|
2474
|
+
definition: defaultNameDef,
|
|
2475
|
+
check: defaultNameCheck
|
|
2476
|
+
});
|
|
2477
|
+
var nonSemanticNameDef = {
|
|
2478
|
+
id: "non-semantic-name",
|
|
2479
|
+
name: "Non-Semantic Name",
|
|
2480
|
+
category: "naming",
|
|
2481
|
+
why: "Names like 'Rectangle' describe shape, not purpose",
|
|
2482
|
+
impact: "Structure is hard to understand without context",
|
|
2483
|
+
fix: "Use names that describe what the element represents (e.g., 'Divider', 'Avatar')"
|
|
2484
|
+
};
|
|
2485
|
+
var nonSemanticNameCheck = (node, context) => {
|
|
2486
|
+
if (!node.name) return null;
|
|
2487
|
+
if (isExcludedName(node.name)) return null;
|
|
2488
|
+
if (!isNonSemanticName(node.name)) return null;
|
|
2489
|
+
if (!node.children || node.children.length === 0) {
|
|
2490
|
+
const shapeTypes = ["RECTANGLE", "ELLIPSE", "VECTOR", "LINE", "STAR", "REGULAR_POLYGON"];
|
|
2491
|
+
if (shapeTypes.includes(node.type)) return null;
|
|
2492
|
+
}
|
|
2493
|
+
return {
|
|
2494
|
+
ruleId: nonSemanticNameDef.id,
|
|
2495
|
+
nodeId: node.id,
|
|
2496
|
+
nodePath: context.path.join(" > "),
|
|
2497
|
+
message: `"${node.name}" is a non-semantic name - describe its purpose`
|
|
2498
|
+
};
|
|
2499
|
+
};
|
|
2500
|
+
defineRule({
|
|
2501
|
+
definition: nonSemanticNameDef,
|
|
2502
|
+
check: nonSemanticNameCheck
|
|
2503
|
+
});
|
|
2504
|
+
var inconsistentNamingConventionDef = {
|
|
2505
|
+
id: "inconsistent-naming-convention",
|
|
2506
|
+
name: "Inconsistent Naming Convention",
|
|
2507
|
+
category: "naming",
|
|
2508
|
+
why: "Mixed naming conventions at the same level create confusion",
|
|
2509
|
+
impact: "Harder to navigate and maintain the design",
|
|
2510
|
+
fix: "Use a consistent naming convention for sibling elements"
|
|
2511
|
+
};
|
|
2512
|
+
var inconsistentNamingConventionCheck = (node, context) => {
|
|
2513
|
+
if (!context.siblings || context.siblings.length < 2) return null;
|
|
2514
|
+
const conventions = /* @__PURE__ */ new Map();
|
|
2515
|
+
for (const sibling of context.siblings) {
|
|
2516
|
+
if (!sibling.name) continue;
|
|
2517
|
+
const convention = detectNamingConvention(sibling.name);
|
|
2518
|
+
if (convention) {
|
|
2519
|
+
conventions.set(convention, (conventions.get(convention) ?? 0) + 1);
|
|
2520
|
+
}
|
|
2521
|
+
}
|
|
2522
|
+
if (conventions.size < 2) return null;
|
|
2523
|
+
let dominantConvention = "";
|
|
2524
|
+
let maxCount = 0;
|
|
2525
|
+
for (const [convention, count] of conventions) {
|
|
2526
|
+
if (count > maxCount) {
|
|
2527
|
+
maxCount = count;
|
|
2528
|
+
dominantConvention = convention;
|
|
2529
|
+
}
|
|
2530
|
+
}
|
|
2531
|
+
const nodeConvention = detectNamingConvention(node.name);
|
|
2532
|
+
if (nodeConvention && nodeConvention !== dominantConvention && maxCount >= 2) {
|
|
2533
|
+
return {
|
|
2534
|
+
ruleId: inconsistentNamingConventionDef.id,
|
|
2535
|
+
nodeId: node.id,
|
|
2536
|
+
nodePath: context.path.join(" > "),
|
|
2537
|
+
message: `"${node.name}" uses ${nodeConvention} while siblings use ${dominantConvention}`
|
|
2538
|
+
};
|
|
2539
|
+
}
|
|
2540
|
+
return null;
|
|
2541
|
+
};
|
|
2542
|
+
defineRule({
|
|
2543
|
+
definition: inconsistentNamingConventionDef,
|
|
2544
|
+
check: inconsistentNamingConventionCheck
|
|
2545
|
+
});
|
|
2546
|
+
var numericSuffixNameDef = {
|
|
2547
|
+
id: "numeric-suffix-name",
|
|
2548
|
+
name: "Numeric Suffix Name",
|
|
2549
|
+
category: "naming",
|
|
2550
|
+
why: "Names with numeric suffixes often indicate copy-paste duplication",
|
|
2551
|
+
impact: "Suggests the element might need componentization",
|
|
2552
|
+
fix: "Remove the suffix or create a component if duplicated"
|
|
2553
|
+
};
|
|
2554
|
+
var numericSuffixNameCheck = (node, context) => {
|
|
2555
|
+
if (!node.name) return null;
|
|
2556
|
+
if (isExcludedName(node.name)) return null;
|
|
2557
|
+
if (isDefaultName(node.name)) return null;
|
|
2558
|
+
if (!hasNumericSuffix(node.name)) return null;
|
|
2559
|
+
return {
|
|
2560
|
+
ruleId: numericSuffixNameDef.id,
|
|
2561
|
+
nodeId: node.id,
|
|
2562
|
+
nodePath: context.path.join(" > "),
|
|
2563
|
+
message: `"${node.name}" has a numeric suffix - consider renaming`
|
|
2564
|
+
};
|
|
2565
|
+
};
|
|
2566
|
+
defineRule({
|
|
2567
|
+
definition: numericSuffixNameDef,
|
|
2568
|
+
check: numericSuffixNameCheck
|
|
2569
|
+
});
|
|
2570
|
+
var tooLongNameDef = {
|
|
2571
|
+
id: "too-long-name",
|
|
2572
|
+
name: "Too Long Name",
|
|
2573
|
+
category: "naming",
|
|
2574
|
+
why: "Very long names are hard to read and use in code",
|
|
2575
|
+
impact: "Clutters the layer panel and makes selectors unwieldy",
|
|
2576
|
+
fix: "Shorten the name while keeping it descriptive"
|
|
2577
|
+
};
|
|
2578
|
+
var tooLongNameCheck = (node, context, options) => {
|
|
2579
|
+
if (!node.name) return null;
|
|
2580
|
+
const maxLength = options?.["maxLength"] ?? getRuleOption("too-long-name", "maxLength", 50);
|
|
2581
|
+
if (node.name.length <= maxLength) return null;
|
|
2582
|
+
return {
|
|
2583
|
+
ruleId: tooLongNameDef.id,
|
|
2584
|
+
nodeId: node.id,
|
|
2585
|
+
nodePath: context.path.join(" > "),
|
|
2586
|
+
message: `"${node.name.substring(0, 30)}..." is ${node.name.length} chars (max: ${maxLength})`
|
|
2587
|
+
};
|
|
2588
|
+
};
|
|
2589
|
+
defineRule({
|
|
2590
|
+
definition: tooLongNameDef,
|
|
2591
|
+
check: tooLongNameCheck
|
|
2592
|
+
});
|
|
2593
|
+
|
|
2594
|
+
// src/core/rules/ai-readability/index.ts
|
|
2595
|
+
function hasAutoLayout2(node) {
|
|
2596
|
+
return node.layoutMode !== void 0 && node.layoutMode !== "NONE";
|
|
2597
|
+
}
|
|
2598
|
+
function isContainerNode2(node) {
|
|
2599
|
+
return node.type === "FRAME" || node.type === "GROUP" || node.type === "COMPONENT";
|
|
2600
|
+
}
|
|
2601
|
+
function hasOverlappingBounds(a, b) {
|
|
2602
|
+
const boxA = a.absoluteBoundingBox;
|
|
2603
|
+
const boxB = b.absoluteBoundingBox;
|
|
2604
|
+
if (!boxA || !boxB) return false;
|
|
2605
|
+
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);
|
|
2606
|
+
}
|
|
2607
|
+
var ambiguousStructureDef = {
|
|
2608
|
+
id: "ambiguous-structure",
|
|
2609
|
+
name: "Ambiguous Structure",
|
|
2610
|
+
category: "ai-readability",
|
|
2611
|
+
why: "Overlapping nodes without Auto Layout create ambiguous visual hierarchy",
|
|
2612
|
+
impact: "AI cannot reliably determine the reading order or structure",
|
|
2613
|
+
fix: "Use Auto Layout to create clear, explicit structure"
|
|
2614
|
+
};
|
|
2615
|
+
var ambiguousStructureCheck = (node, context) => {
|
|
2616
|
+
if (!isContainerNode2(node)) return null;
|
|
2617
|
+
if (hasAutoLayout2(node)) return null;
|
|
2618
|
+
if (!node.children || node.children.length < 2) return null;
|
|
2619
|
+
for (let i = 0; i < node.children.length; i++) {
|
|
2620
|
+
for (let j = i + 1; j < node.children.length; j++) {
|
|
2621
|
+
const childA = node.children[i];
|
|
2622
|
+
const childB = node.children[j];
|
|
2623
|
+
if (childA && childB && hasOverlappingBounds(childA, childB)) {
|
|
2624
|
+
if (childA.visible !== false && childB.visible !== false) {
|
|
2625
|
+
return {
|
|
2626
|
+
ruleId: ambiguousStructureDef.id,
|
|
2627
|
+
nodeId: node.id,
|
|
2628
|
+
nodePath: context.path.join(" > "),
|
|
2629
|
+
message: `"${node.name}" has overlapping children without Auto Layout`
|
|
2630
|
+
};
|
|
2631
|
+
}
|
|
2632
|
+
}
|
|
2633
|
+
}
|
|
2634
|
+
}
|
|
2635
|
+
return null;
|
|
2636
|
+
};
|
|
2637
|
+
defineRule({
|
|
2638
|
+
definition: ambiguousStructureDef,
|
|
2639
|
+
check: ambiguousStructureCheck
|
|
2573
2640
|
});
|
|
2574
|
-
var
|
|
2575
|
-
id: "
|
|
2576
|
-
name: "
|
|
2577
|
-
category: "
|
|
2578
|
-
why: "
|
|
2579
|
-
impact: "
|
|
2580
|
-
fix: "
|
|
2641
|
+
var zIndexDependentLayoutDef = {
|
|
2642
|
+
id: "z-index-dependent-layout",
|
|
2643
|
+
name: "Z-Index Dependent Layout",
|
|
2644
|
+
category: "ai-readability",
|
|
2645
|
+
why: "Using overlapping layers to create visual layout is hard to interpret",
|
|
2646
|
+
impact: "Code generation may misinterpret the intended layout",
|
|
2647
|
+
fix: "Restructure using Auto Layout to express the visual relationship explicitly"
|
|
2581
2648
|
};
|
|
2582
|
-
var
|
|
2583
|
-
if (!
|
|
2584
|
-
|
|
2585
|
-
|
|
2586
|
-
|
|
2587
|
-
|
|
2588
|
-
|
|
2589
|
-
|
|
2590
|
-
|
|
2591
|
-
|
|
2649
|
+
var zIndexDependentLayoutCheck = (node, context) => {
|
|
2650
|
+
if (!isContainerNode2(node)) return null;
|
|
2651
|
+
if (!node.children || node.children.length < 2) return null;
|
|
2652
|
+
let significantOverlapCount = 0;
|
|
2653
|
+
for (let i = 0; i < node.children.length; i++) {
|
|
2654
|
+
for (let j = i + 1; j < node.children.length; j++) {
|
|
2655
|
+
const childA = node.children[i];
|
|
2656
|
+
const childB = node.children[j];
|
|
2657
|
+
if (!childA || !childB) continue;
|
|
2658
|
+
if (childA.visible === false || childB.visible === false) continue;
|
|
2659
|
+
const boxA = childA.absoluteBoundingBox;
|
|
2660
|
+
const boxB = childB.absoluteBoundingBox;
|
|
2661
|
+
if (!boxA || !boxB) continue;
|
|
2662
|
+
if (hasOverlappingBounds(childA, childB)) {
|
|
2663
|
+
const overlapX = Math.min(boxA.x + boxA.width, boxB.x + boxB.width) - Math.max(boxA.x, boxB.x);
|
|
2664
|
+
const overlapY = Math.min(boxA.y + boxA.height, boxB.y + boxB.height) - Math.max(boxA.y, boxB.y);
|
|
2665
|
+
if (overlapX > 0 && overlapY > 0) {
|
|
2666
|
+
const overlapArea = overlapX * overlapY;
|
|
2667
|
+
const smallerArea = Math.min(
|
|
2668
|
+
boxA.width * boxA.height,
|
|
2669
|
+
boxB.width * boxB.height
|
|
2670
|
+
);
|
|
2671
|
+
if (overlapArea > smallerArea * 0.2) {
|
|
2672
|
+
significantOverlapCount++;
|
|
2673
|
+
}
|
|
2674
|
+
}
|
|
2592
2675
|
}
|
|
2593
2676
|
}
|
|
2594
2677
|
}
|
|
2595
|
-
|
|
2596
|
-
if (instanceCount === 1) {
|
|
2678
|
+
if (significantOverlapCount > 0) {
|
|
2597
2679
|
return {
|
|
2598
|
-
ruleId:
|
|
2680
|
+
ruleId: zIndexDependentLayoutDef.id,
|
|
2599
2681
|
nodeId: node.id,
|
|
2600
2682
|
nodePath: context.path.join(" > "),
|
|
2601
|
-
message: `
|
|
2683
|
+
message: `"${node.name}" uses layer stacking for layout (${significantOverlapCount} overlaps)`
|
|
2602
2684
|
};
|
|
2603
2685
|
}
|
|
2604
2686
|
return null;
|
|
2605
2687
|
};
|
|
2606
2688
|
defineRule({
|
|
2607
|
-
definition:
|
|
2608
|
-
check:
|
|
2689
|
+
definition: zIndexDependentLayoutDef,
|
|
2690
|
+
check: zIndexDependentLayoutCheck
|
|
2609
2691
|
});
|
|
2610
|
-
|
|
2611
|
-
|
|
2612
|
-
|
|
2613
|
-
|
|
2614
|
-
|
|
2615
|
-
|
|
2616
|
-
|
|
2617
|
-
|
|
2618
|
-
|
|
2619
|
-
|
|
2620
|
-
|
|
2621
|
-
|
|
2622
|
-
|
|
2623
|
-
|
|
2624
|
-
|
|
2625
|
-
|
|
2626
|
-
|
|
2627
|
-
|
|
2628
|
-
|
|
2629
|
-
|
|
2630
|
-
|
|
2631
|
-
|
|
2632
|
-
|
|
2633
|
-
|
|
2634
|
-
"fill",
|
|
2635
|
-
"stroke"
|
|
2636
|
-
];
|
|
2637
|
-
function isDefaultName(name) {
|
|
2638
|
-
return DEFAULT_NAME_PATTERNS.some((pattern) => pattern.test(name));
|
|
2639
|
-
}
|
|
2640
|
-
function isNonSemanticName(name) {
|
|
2641
|
-
const normalized = name.toLowerCase().trim();
|
|
2642
|
-
return NON_SEMANTIC_NAMES.includes(normalized);
|
|
2643
|
-
}
|
|
2644
|
-
function hasNumericSuffix(name) {
|
|
2645
|
-
return /\s+\d+$/.test(name);
|
|
2646
|
-
}
|
|
2647
|
-
function detectNamingConvention(name) {
|
|
2648
|
-
if (/^[a-z]+(-[a-z]+)*$/.test(name)) return "kebab-case";
|
|
2649
|
-
if (/^[a-z]+(_[a-z]+)*$/.test(name)) return "snake_case";
|
|
2650
|
-
if (/^[a-z]+([A-Z][a-z]*)*$/.test(name)) return "camelCase";
|
|
2651
|
-
if (/^[A-Z][a-z]+([A-Z][a-z]*)*$/.test(name)) return "PascalCase";
|
|
2652
|
-
if (/^[A-Z]+(_[A-Z]+)*$/.test(name)) return "SCREAMING_SNAKE_CASE";
|
|
2653
|
-
if (/\s/.test(name)) return "Title Case";
|
|
2692
|
+
var missingLayoutHintDef = {
|
|
2693
|
+
id: "missing-layout-hint",
|
|
2694
|
+
name: "Missing Layout Hint",
|
|
2695
|
+
category: "ai-readability",
|
|
2696
|
+
why: "Complex nesting without Auto Layout makes structure unpredictable",
|
|
2697
|
+
impact: "AI may generate incorrect code due to ambiguous relationships",
|
|
2698
|
+
fix: "Add Auto Layout or simplify the nesting structure"
|
|
2699
|
+
};
|
|
2700
|
+
var missingLayoutHintCheck = (node, context) => {
|
|
2701
|
+
if (!isContainerNode2(node)) return null;
|
|
2702
|
+
if (hasAutoLayout2(node)) return null;
|
|
2703
|
+
if (!node.children || node.children.length === 0) return null;
|
|
2704
|
+
const nestedContainers = node.children.filter((c) => isContainerNode2(c));
|
|
2705
|
+
if (nestedContainers.length >= 2) {
|
|
2706
|
+
const withoutLayout = nestedContainers.filter((c) => !hasAutoLayout2(c));
|
|
2707
|
+
if (withoutLayout.length >= 2) {
|
|
2708
|
+
return {
|
|
2709
|
+
ruleId: missingLayoutHintDef.id,
|
|
2710
|
+
nodeId: node.id,
|
|
2711
|
+
nodePath: context.path.join(" > "),
|
|
2712
|
+
message: `"${node.name}" has ${withoutLayout.length} nested containers without layout hints`
|
|
2713
|
+
};
|
|
2714
|
+
}
|
|
2715
|
+
}
|
|
2654
2716
|
return null;
|
|
2655
|
-
}
|
|
2656
|
-
var defaultNameDef = {
|
|
2657
|
-
id: "default-name",
|
|
2658
|
-
name: "Default Name",
|
|
2659
|
-
category: "naming",
|
|
2660
|
-
why: "Default names like 'Frame 123' provide no context about the element's purpose",
|
|
2661
|
-
impact: "Designers and developers cannot understand the structure",
|
|
2662
|
-
fix: "Rename with a descriptive, semantic name (e.g., 'Header', 'ProductCard')"
|
|
2663
2717
|
};
|
|
2664
|
-
|
|
2665
|
-
|
|
2666
|
-
|
|
2667
|
-
|
|
2718
|
+
defineRule({
|
|
2719
|
+
definition: missingLayoutHintDef,
|
|
2720
|
+
check: missingLayoutHintCheck
|
|
2721
|
+
});
|
|
2722
|
+
var invisibleLayerDef = {
|
|
2723
|
+
id: "invisible-layer",
|
|
2724
|
+
name: "Invisible Layer",
|
|
2725
|
+
category: "ai-readability",
|
|
2726
|
+
why: "Hidden layers add noise and may confuse analysis tools",
|
|
2727
|
+
impact: "Exported code may include unnecessary elements",
|
|
2728
|
+
fix: "Delete hidden layers or move them to a separate 'archive' page"
|
|
2729
|
+
};
|
|
2730
|
+
var invisibleLayerCheck = (node, context) => {
|
|
2731
|
+
if (node.visible !== false) return null;
|
|
2732
|
+
if (context.parent?.visible === false) return null;
|
|
2668
2733
|
return {
|
|
2669
|
-
ruleId:
|
|
2734
|
+
ruleId: invisibleLayerDef.id,
|
|
2670
2735
|
nodeId: node.id,
|
|
2671
2736
|
nodePath: context.path.join(" > "),
|
|
2672
|
-
message: `"${node.name}" is
|
|
2737
|
+
message: `"${node.name}" is hidden - consider removing if not needed`
|
|
2673
2738
|
};
|
|
2674
2739
|
};
|
|
2675
2740
|
defineRule({
|
|
2676
|
-
definition:
|
|
2677
|
-
check:
|
|
2741
|
+
definition: invisibleLayerDef,
|
|
2742
|
+
check: invisibleLayerCheck
|
|
2678
2743
|
});
|
|
2679
|
-
var
|
|
2680
|
-
id: "
|
|
2681
|
-
name: "
|
|
2682
|
-
category: "
|
|
2683
|
-
why: "
|
|
2684
|
-
impact: "
|
|
2685
|
-
fix: "
|
|
2744
|
+
var emptyFrameDef = {
|
|
2745
|
+
id: "empty-frame",
|
|
2746
|
+
name: "Empty Frame",
|
|
2747
|
+
category: "ai-readability",
|
|
2748
|
+
why: "Empty frames add noise and may indicate incomplete design",
|
|
2749
|
+
impact: "Generates unnecessary wrapper elements in code",
|
|
2750
|
+
fix: "Remove the frame or add content"
|
|
2686
2751
|
};
|
|
2687
|
-
var
|
|
2688
|
-
if (
|
|
2689
|
-
if (
|
|
2690
|
-
if (
|
|
2691
|
-
|
|
2692
|
-
|
|
2693
|
-
if (shapeTypes.includes(node.type)) return null;
|
|
2752
|
+
var emptyFrameCheck = (node, context) => {
|
|
2753
|
+
if (node.type !== "FRAME") return null;
|
|
2754
|
+
if (node.children && node.children.length > 0) return null;
|
|
2755
|
+
if (node.absoluteBoundingBox) {
|
|
2756
|
+
const { width, height } = node.absoluteBoundingBox;
|
|
2757
|
+
if (width <= 48 && height <= 48) return null;
|
|
2694
2758
|
}
|
|
2695
2759
|
return {
|
|
2696
|
-
ruleId:
|
|
2760
|
+
ruleId: emptyFrameDef.id,
|
|
2697
2761
|
nodeId: node.id,
|
|
2698
2762
|
nodePath: context.path.join(" > "),
|
|
2699
|
-
message: `"${node.name}" is
|
|
2763
|
+
message: `"${node.name}" is an empty frame`
|
|
2700
2764
|
};
|
|
2701
2765
|
};
|
|
2702
2766
|
defineRule({
|
|
2703
|
-
definition:
|
|
2704
|
-
check:
|
|
2767
|
+
definition: emptyFrameDef,
|
|
2768
|
+
check: emptyFrameCheck
|
|
2769
|
+
});
|
|
2770
|
+
|
|
2771
|
+
// src/core/rules/handoff-risk/index.ts
|
|
2772
|
+
function hasAutoLayout3(node) {
|
|
2773
|
+
return node.layoutMode !== void 0 && node.layoutMode !== "NONE";
|
|
2774
|
+
}
|
|
2775
|
+
function isContainerNode3(node) {
|
|
2776
|
+
return node.type === "FRAME" || node.type === "GROUP" || node.type === "COMPONENT";
|
|
2777
|
+
}
|
|
2778
|
+
function isTextNode(node) {
|
|
2779
|
+
return node.type === "TEXT";
|
|
2780
|
+
}
|
|
2781
|
+
function isImageNode(node) {
|
|
2782
|
+
if (node.type === "RECTANGLE" && node.fills) {
|
|
2783
|
+
for (const fill of node.fills) {
|
|
2784
|
+
const fillObj = fill;
|
|
2785
|
+
if (fillObj["type"] === "IMAGE") return true;
|
|
2786
|
+
}
|
|
2787
|
+
}
|
|
2788
|
+
return false;
|
|
2789
|
+
}
|
|
2790
|
+
var hardcodeRiskDef = {
|
|
2791
|
+
id: "hardcode-risk",
|
|
2792
|
+
name: "Hardcode Risk",
|
|
2793
|
+
category: "handoff-risk",
|
|
2794
|
+
why: "Absolute positioning with fixed values creates inflexible layouts",
|
|
2795
|
+
impact: "Layout will break when content changes or on different screens",
|
|
2796
|
+
fix: "Use Auto Layout with relative positioning"
|
|
2797
|
+
};
|
|
2798
|
+
var hardcodeRiskCheck = (node, context) => {
|
|
2799
|
+
if (!isContainerNode3(node)) return null;
|
|
2800
|
+
if (node.layoutPositioning !== "ABSOLUTE") return null;
|
|
2801
|
+
if (context.parent && hasAutoLayout3(context.parent)) {
|
|
2802
|
+
return {
|
|
2803
|
+
ruleId: hardcodeRiskDef.id,
|
|
2804
|
+
nodeId: node.id,
|
|
2805
|
+
nodePath: context.path.join(" > "),
|
|
2806
|
+
message: `"${node.name}" uses absolute positioning with fixed values`
|
|
2807
|
+
};
|
|
2808
|
+
}
|
|
2809
|
+
return null;
|
|
2810
|
+
};
|
|
2811
|
+
defineRule({
|
|
2812
|
+
definition: hardcodeRiskDef,
|
|
2813
|
+
check: hardcodeRiskCheck
|
|
2705
2814
|
});
|
|
2706
|
-
var
|
|
2707
|
-
id: "
|
|
2708
|
-
name: "
|
|
2709
|
-
category: "
|
|
2710
|
-
why: "
|
|
2711
|
-
impact: "
|
|
2712
|
-
fix: "
|
|
2815
|
+
var textTruncationUnhandledDef = {
|
|
2816
|
+
id: "text-truncation-unhandled",
|
|
2817
|
+
name: "Text Truncation Unhandled",
|
|
2818
|
+
category: "handoff-risk",
|
|
2819
|
+
why: "Text nodes without truncation handling may overflow",
|
|
2820
|
+
impact: "Long text will break the layout",
|
|
2821
|
+
fix: "Set text truncation (ellipsis) or ensure container can grow"
|
|
2713
2822
|
};
|
|
2714
|
-
var
|
|
2715
|
-
if (!
|
|
2716
|
-
|
|
2717
|
-
|
|
2718
|
-
|
|
2719
|
-
const
|
|
2720
|
-
if (
|
|
2721
|
-
|
|
2823
|
+
var textTruncationUnhandledCheck = (node, context) => {
|
|
2824
|
+
if (!isTextNode(node)) return null;
|
|
2825
|
+
if (!context.parent) return null;
|
|
2826
|
+
if (!hasAutoLayout3(context.parent)) return null;
|
|
2827
|
+
if (node.absoluteBoundingBox) {
|
|
2828
|
+
const { width } = node.absoluteBoundingBox;
|
|
2829
|
+
if (node.characters && node.characters.length > 50 && width < 300) {
|
|
2830
|
+
return {
|
|
2831
|
+
ruleId: textTruncationUnhandledDef.id,
|
|
2832
|
+
nodeId: node.id,
|
|
2833
|
+
nodePath: context.path.join(" > "),
|
|
2834
|
+
message: `"${node.name}" may need text truncation handling`
|
|
2835
|
+
};
|
|
2722
2836
|
}
|
|
2723
2837
|
}
|
|
2724
|
-
|
|
2725
|
-
|
|
2726
|
-
|
|
2727
|
-
|
|
2728
|
-
|
|
2729
|
-
|
|
2730
|
-
|
|
2838
|
+
return null;
|
|
2839
|
+
};
|
|
2840
|
+
defineRule({
|
|
2841
|
+
definition: textTruncationUnhandledDef,
|
|
2842
|
+
check: textTruncationUnhandledCheck
|
|
2843
|
+
});
|
|
2844
|
+
var imageNoPlaceholderDef = {
|
|
2845
|
+
id: "image-no-placeholder",
|
|
2846
|
+
name: "Image No Placeholder",
|
|
2847
|
+
category: "handoff-risk",
|
|
2848
|
+
why: "Images without placeholder state may cause layout shifts",
|
|
2849
|
+
impact: "Poor user experience during image loading",
|
|
2850
|
+
fix: "Define a placeholder state or background color"
|
|
2851
|
+
};
|
|
2852
|
+
var imageNoPlaceholderCheck = (node, context) => {
|
|
2853
|
+
if (!isImageNode(node)) return null;
|
|
2854
|
+
if (node.fills && Array.isArray(node.fills) && node.fills.length === 1) {
|
|
2855
|
+
const fill = node.fills[0];
|
|
2856
|
+
if (fill["type"] === "IMAGE") {
|
|
2857
|
+
return {
|
|
2858
|
+
ruleId: imageNoPlaceholderDef.id,
|
|
2859
|
+
nodeId: node.id,
|
|
2860
|
+
nodePath: context.path.join(" > "),
|
|
2861
|
+
message: `"${node.name}" image has no placeholder fill`
|
|
2862
|
+
};
|
|
2731
2863
|
}
|
|
2732
2864
|
}
|
|
2733
|
-
const nodeConvention = detectNamingConvention(node.name);
|
|
2734
|
-
if (nodeConvention && nodeConvention !== dominantConvention && maxCount >= 2) {
|
|
2735
|
-
return {
|
|
2736
|
-
ruleId: inconsistentNamingConventionDef.id,
|
|
2737
|
-
nodeId: node.id,
|
|
2738
|
-
nodePath: context.path.join(" > "),
|
|
2739
|
-
message: `"${node.name}" uses ${nodeConvention} while siblings use ${dominantConvention}`
|
|
2740
|
-
};
|
|
2741
|
-
}
|
|
2742
2865
|
return null;
|
|
2743
2866
|
};
|
|
2744
2867
|
defineRule({
|
|
2745
|
-
definition:
|
|
2746
|
-
check:
|
|
2868
|
+
definition: imageNoPlaceholderDef,
|
|
2869
|
+
check: imageNoPlaceholderCheck
|
|
2747
2870
|
});
|
|
2748
|
-
var
|
|
2749
|
-
id: "
|
|
2750
|
-
name: "
|
|
2751
|
-
category: "
|
|
2752
|
-
why: "
|
|
2753
|
-
impact: "
|
|
2754
|
-
fix: "
|
|
2871
|
+
var prototypeLinkInDesignDef = {
|
|
2872
|
+
id: "prototype-link-in-design",
|
|
2873
|
+
name: "Prototype Link in Design",
|
|
2874
|
+
category: "handoff-risk",
|
|
2875
|
+
why: "Prototype connections may affect how the design is interpreted",
|
|
2876
|
+
impact: "Developers may misunderstand which elements should be interactive",
|
|
2877
|
+
fix: "Document interactions separately or use clear naming"
|
|
2755
2878
|
};
|
|
2756
|
-
var
|
|
2757
|
-
|
|
2758
|
-
if (isExcludedName(node.name)) return null;
|
|
2759
|
-
if (isDefaultName(node.name)) return null;
|
|
2760
|
-
if (!hasNumericSuffix(node.name)) return null;
|
|
2761
|
-
return {
|
|
2762
|
-
ruleId: numericSuffixNameDef.id,
|
|
2763
|
-
nodeId: node.id,
|
|
2764
|
-
nodePath: context.path.join(" > "),
|
|
2765
|
-
message: `"${node.name}" has a numeric suffix - consider renaming`
|
|
2766
|
-
};
|
|
2879
|
+
var prototypeLinkInDesignCheck = (_node, _context) => {
|
|
2880
|
+
return null;
|
|
2767
2881
|
};
|
|
2768
2882
|
defineRule({
|
|
2769
|
-
definition:
|
|
2770
|
-
check:
|
|
2883
|
+
definition: prototypeLinkInDesignDef,
|
|
2884
|
+
check: prototypeLinkInDesignCheck
|
|
2771
2885
|
});
|
|
2772
|
-
var
|
|
2773
|
-
id: "
|
|
2774
|
-
name: "
|
|
2775
|
-
category: "
|
|
2776
|
-
why: "
|
|
2777
|
-
impact: "
|
|
2778
|
-
fix: "
|
|
2886
|
+
var noDevStatusDef = {
|
|
2887
|
+
id: "no-dev-status",
|
|
2888
|
+
name: "No Dev Status",
|
|
2889
|
+
category: "handoff-risk",
|
|
2890
|
+
why: "Without dev status, developers cannot know if a design is ready",
|
|
2891
|
+
impact: "May implement designs that are still in progress",
|
|
2892
|
+
fix: "Mark frames as 'Ready for Dev' or 'Completed' when appropriate"
|
|
2779
2893
|
};
|
|
2780
|
-
var
|
|
2781
|
-
if (
|
|
2782
|
-
|
|
2783
|
-
if (node.
|
|
2894
|
+
var noDevStatusCheck = (node, context) => {
|
|
2895
|
+
if (node.type !== "FRAME") return null;
|
|
2896
|
+
if (context.depth > 1) return null;
|
|
2897
|
+
if (node.devStatus) return null;
|
|
2784
2898
|
return {
|
|
2785
|
-
ruleId:
|
|
2899
|
+
ruleId: noDevStatusDef.id,
|
|
2786
2900
|
nodeId: node.id,
|
|
2787
2901
|
nodePath: context.path.join(" > "),
|
|
2788
|
-
message: `"${node.name
|
|
2902
|
+
message: `"${node.name}" has no dev status set`
|
|
2789
2903
|
};
|
|
2790
2904
|
};
|
|
2791
2905
|
defineRule({
|
|
2792
|
-
definition:
|
|
2793
|
-
check:
|
|
2906
|
+
definition: noDevStatusDef,
|
|
2907
|
+
check: noDevStatusCheck
|
|
2908
|
+
});
|
|
2909
|
+
var SamplingStrategySchema = z.enum(["all", "top-issues", "random"]);
|
|
2910
|
+
z.enum([
|
|
2911
|
+
"pending",
|
|
2912
|
+
"analyzing",
|
|
2913
|
+
"converting",
|
|
2914
|
+
"evaluating",
|
|
2915
|
+
"tuning",
|
|
2916
|
+
"completed",
|
|
2917
|
+
"failed"
|
|
2918
|
+
]);
|
|
2919
|
+
z.object({
|
|
2920
|
+
input: z.string(),
|
|
2921
|
+
token: z.string().optional(),
|
|
2922
|
+
targetNodeId: z.string().optional(),
|
|
2923
|
+
maxConversionNodes: z.number().int().positive().default(20),
|
|
2924
|
+
samplingStrategy: SamplingStrategySchema.default("top-issues"),
|
|
2925
|
+
outputPath: z.string().default("logs/calibration/calibration-report.md")
|
|
2926
|
+
});
|
|
2927
|
+
z.object({
|
|
2928
|
+
nodeId: z.string(),
|
|
2929
|
+
nodePath: z.string(),
|
|
2930
|
+
totalScore: z.number(),
|
|
2931
|
+
issueCount: z.number(),
|
|
2932
|
+
flaggedRuleIds: z.array(z.string()),
|
|
2933
|
+
severities: z.array(z.string())
|
|
2934
|
+
});
|
|
2935
|
+
var DifficultySchema = z.enum(["easy", "moderate", "hard", "failed"]);
|
|
2936
|
+
var RuleRelatedStruggleSchema = z.object({
|
|
2937
|
+
ruleId: z.string(),
|
|
2938
|
+
description: z.string(),
|
|
2939
|
+
actualImpact: DifficultySchema
|
|
2940
|
+
});
|
|
2941
|
+
var UncoveredStruggleSchema = z.object({
|
|
2942
|
+
description: z.string(),
|
|
2943
|
+
suggestedCategory: z.string(),
|
|
2944
|
+
estimatedImpact: DifficultySchema
|
|
2945
|
+
});
|
|
2946
|
+
z.object({
|
|
2947
|
+
nodeId: z.string(),
|
|
2948
|
+
nodePath: z.string(),
|
|
2949
|
+
generatedCode: z.string(),
|
|
2950
|
+
difficulty: DifficultySchema,
|
|
2951
|
+
notes: z.string(),
|
|
2952
|
+
ruleRelatedStruggles: z.array(RuleRelatedStruggleSchema),
|
|
2953
|
+
uncoveredStruggles: z.array(UncoveredStruggleSchema),
|
|
2954
|
+
durationMs: z.number()
|
|
2955
|
+
});
|
|
2956
|
+
var MismatchTypeSchema = z.enum([
|
|
2957
|
+
"overscored",
|
|
2958
|
+
"underscored",
|
|
2959
|
+
"missing-rule",
|
|
2960
|
+
"validated"
|
|
2961
|
+
]);
|
|
2962
|
+
z.object({
|
|
2963
|
+
type: MismatchTypeSchema,
|
|
2964
|
+
nodeId: z.string(),
|
|
2965
|
+
nodePath: z.string(),
|
|
2966
|
+
ruleId: z.string().optional(),
|
|
2967
|
+
currentScore: z.number().optional(),
|
|
2968
|
+
currentSeverity: SeveritySchema.optional(),
|
|
2969
|
+
actualDifficulty: DifficultySchema,
|
|
2970
|
+
reasoning: z.string()
|
|
2971
|
+
});
|
|
2972
|
+
var ConfidenceSchema = z.enum(["high", "medium", "low"]);
|
|
2973
|
+
z.object({
|
|
2974
|
+
ruleId: z.string(),
|
|
2975
|
+
currentScore: z.number(),
|
|
2976
|
+
proposedScore: z.number(),
|
|
2977
|
+
currentSeverity: SeveritySchema,
|
|
2978
|
+
proposedSeverity: SeveritySchema.optional(),
|
|
2979
|
+
reasoning: z.string(),
|
|
2980
|
+
confidence: ConfidenceSchema,
|
|
2981
|
+
supportingCases: z.number()
|
|
2794
2982
|
});
|
|
2983
|
+
z.object({
|
|
2984
|
+
suggestedId: z.string(),
|
|
2985
|
+
category: z.string(),
|
|
2986
|
+
description: z.string(),
|
|
2987
|
+
suggestedSeverity: SeveritySchema,
|
|
2988
|
+
suggestedScore: z.number(),
|
|
2989
|
+
reasoning: z.string(),
|
|
2990
|
+
supportingCases: z.number()
|
|
2991
|
+
});
|
|
2992
|
+
|
|
2993
|
+
// src/core/report-html/index.ts
|
|
2994
|
+
function generateHtmlReport(file, result, scores, options) {
|
|
2995
|
+
const screenshotMap = new Map(
|
|
2996
|
+
(options?.nodeScreenshots ?? []).map((ns) => [ns.nodeId, ns])
|
|
2997
|
+
);
|
|
2998
|
+
const figmaToken = options?.figmaToken;
|
|
2999
|
+
const quickWins = getQuickWins(result.issues, 5);
|
|
3000
|
+
const issuesByCategory = groupIssuesByCategory(result.issues);
|
|
3001
|
+
return `<!DOCTYPE html>
|
|
3002
|
+
<html lang="en" class="antialiased">
|
|
3003
|
+
<head>
|
|
3004
|
+
<meta charset="UTF-8">
|
|
3005
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
3006
|
+
<title>CanICode Report \u2014 ${esc(file.name)}</title>
|
|
3007
|
+
<script src="https://cdn.tailwindcss.com"></script>
|
|
3008
|
+
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
3009
|
+
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
3010
|
+
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
|
|
3011
|
+
<script>
|
|
3012
|
+
tailwind.config = {
|
|
3013
|
+
theme: {
|
|
3014
|
+
extend: {
|
|
3015
|
+
fontFamily: { sans: ['Inter', 'ui-sans-serif', 'system-ui', '-apple-system', 'sans-serif'] },
|
|
3016
|
+
colors: {
|
|
3017
|
+
border: 'hsl(240 5.9% 90%)',
|
|
3018
|
+
ring: 'hsl(240 5.9% 10%)',
|
|
3019
|
+
background: 'hsl(0 0% 100%)',
|
|
3020
|
+
foreground: 'hsl(240 10% 3.9%)',
|
|
3021
|
+
muted: { DEFAULT: 'hsl(240 4.8% 95.9%)', foreground: 'hsl(240 3.8% 46.1%)' },
|
|
3022
|
+
card: { DEFAULT: 'hsl(0 0% 100%)', foreground: 'hsl(240 10% 3.9%)' },
|
|
3023
|
+
},
|
|
3024
|
+
borderRadius: { lg: '0.5rem', md: 'calc(0.5rem - 2px)', sm: 'calc(0.5rem - 4px)' },
|
|
3025
|
+
}
|
|
3026
|
+
}
|
|
3027
|
+
}
|
|
3028
|
+
</script>
|
|
3029
|
+
<style>
|
|
3030
|
+
details summary::-webkit-details-marker { display: none; }
|
|
3031
|
+
details summary::marker { content: ""; }
|
|
3032
|
+
details summary { list-style: none; }
|
|
3033
|
+
.gauge-fill { transition: stroke-dashoffset 0.8s cubic-bezier(0.4,0,0.2,1); }
|
|
3034
|
+
@media print {
|
|
3035
|
+
.no-print { display: none !important; }
|
|
3036
|
+
.topbar-print { position: static !important; background: white !important; color: hsl(240 10% 3.9%) !important; }
|
|
3037
|
+
}
|
|
3038
|
+
</style>
|
|
3039
|
+
</head>
|
|
3040
|
+
<body class="bg-muted font-sans text-foreground min-h-screen">
|
|
3041
|
+
|
|
3042
|
+
<!-- Top Bar -->
|
|
3043
|
+
<header class="topbar-print sticky top-0 z-50 bg-zinc-950 text-white border-b border-zinc-800">
|
|
3044
|
+
<div class="max-w-[960px] mx-auto px-6 py-3 flex items-center gap-4">
|
|
3045
|
+
<span class="font-semibold text-sm tracking-tight">CanICode</span>
|
|
3046
|
+
<span class="text-zinc-400 text-sm truncate">${esc(file.name)}</span>
|
|
3047
|
+
<span class="ml-auto text-zinc-500 text-xs no-print">${(/* @__PURE__ */ new Date()).toLocaleDateString()}</span>
|
|
3048
|
+
</div>
|
|
3049
|
+
</header>
|
|
3050
|
+
|
|
3051
|
+
<main class="max-w-[960px] mx-auto px-6 pb-16">
|
|
3052
|
+
|
|
3053
|
+
<!-- Overall Score -->
|
|
3054
|
+
<section class="flex flex-col items-center pt-12 pb-6">
|
|
3055
|
+
${renderGaugeSvg(scores.overall.percentage, 200, 10, scores.overall.grade)}
|
|
3056
|
+
<div class="mt-3 text-center">
|
|
3057
|
+
<span class="text-lg font-semibold">${scores.overall.percentage}</span>
|
|
3058
|
+
<span class="text-muted-foreground text-sm ml-1">/ 100</span>
|
|
3059
|
+
</div>
|
|
3060
|
+
<p class="text-muted-foreground text-sm mt-1">Overall Score</p>
|
|
3061
|
+
</section>
|
|
3062
|
+
|
|
3063
|
+
<!-- Category Gauges -->
|
|
3064
|
+
<section class="bg-card border border-border rounded-lg shadow-sm p-6 mb-6">
|
|
3065
|
+
<div class="grid grid-cols-3 sm:grid-cols-6 gap-4">
|
|
3066
|
+
${CATEGORIES.map((cat) => {
|
|
3067
|
+
const cs = scores.byCategory[cat];
|
|
3068
|
+
const desc = CATEGORY_DESCRIPTIONS[cat];
|
|
3069
|
+
return ` <a href="#cat-${cat}" class="flex flex-col items-center group relative cursor-pointer no-underline text-foreground hover:opacity-80 transition-opacity">
|
|
3070
|
+
${renderGaugeSvg(cs.percentage, 100, 7)}
|
|
3071
|
+
<span class="text-xs font-medium mt-2.5 text-center leading-tight">${CATEGORY_LABELS[cat]}</span>
|
|
3072
|
+
<span class="text-[11px] text-muted-foreground">${cs.issueCount} issues</span>
|
|
3073
|
+
<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">
|
|
3074
|
+
${esc(desc)}
|
|
3075
|
+
<div class="absolute top-full left-1/2 -translate-x-1/2 border-4 border-transparent border-t-zinc-900"></div>
|
|
3076
|
+
</div>
|
|
3077
|
+
</a>`;
|
|
3078
|
+
}).join("\n")}
|
|
3079
|
+
</div>
|
|
3080
|
+
</section>
|
|
3081
|
+
|
|
3082
|
+
<!-- Issue Summary -->
|
|
3083
|
+
<section class="bg-card border border-border rounded-lg shadow-sm p-4 mb-6">
|
|
3084
|
+
<div class="flex flex-wrap items-center justify-center gap-6">
|
|
3085
|
+
${renderSummaryDot("bg-red-500", scores.summary.blocking, "Blocking")}
|
|
3086
|
+
${renderSummaryDot("bg-amber-500", scores.summary.risk, "Risk")}
|
|
3087
|
+
${renderSummaryDot("bg-zinc-400", scores.summary.missingInfo, "Missing Info")}
|
|
3088
|
+
${renderSummaryDot("bg-green-500", scores.summary.suggestion, "Suggestion")}
|
|
3089
|
+
<div class="border-l border-border pl-6 flex items-center gap-2">
|
|
3090
|
+
<span class="text-xl font-bold tracking-tight">${scores.summary.totalIssues}</span>
|
|
3091
|
+
<span class="text-sm text-muted-foreground">Total</span>
|
|
3092
|
+
</div>
|
|
3093
|
+
</div>
|
|
3094
|
+
</section>
|
|
2795
3095
|
|
|
2796
|
-
|
|
2797
|
-
|
|
2798
|
-
|
|
3096
|
+
${quickWins.length > 0 ? renderOpportunities(quickWins, file.fileKey) : ""}
|
|
3097
|
+
|
|
3098
|
+
<!-- Categories -->
|
|
3099
|
+
<div class="space-y-3">
|
|
3100
|
+
${CATEGORIES.map((cat) => renderCategory(cat, scores, issuesByCategory.get(cat) ?? [], file.fileKey, screenshotMap, figmaToken)).join("\n")}
|
|
3101
|
+
</div>
|
|
3102
|
+
|
|
3103
|
+
<!-- Footer -->
|
|
3104
|
+
<footer class="mt-12 pt-6 border-t border-border text-center">
|
|
3105
|
+
<p class="text-sm text-muted-foreground">Generated by <span class="font-semibold text-foreground">CanICode</span> <span class="text-muted-foreground/60">v${version}</span></p>
|
|
3106
|
+
<p class="text-xs text-muted-foreground/60 mt-1">${(/* @__PURE__ */ new Date()).toLocaleString()} \xB7 ${result.nodeCount} nodes \xB7 Max depth ${result.maxDepth}</p>
|
|
3107
|
+
</footer>
|
|
3108
|
+
|
|
3109
|
+
</main>
|
|
3110
|
+
|
|
3111
|
+
${figmaToken ? ` <script>
|
|
3112
|
+
const FIGMA_TOKEN = '${figmaToken}';
|
|
3113
|
+
async function postComment(btn) {
|
|
3114
|
+
const fileKey = btn.dataset.fileKey;
|
|
3115
|
+
const nodeId = btn.dataset.nodeId.replace(/-/g, ':');
|
|
3116
|
+
const rule = btn.dataset.rule;
|
|
3117
|
+
const message = btn.dataset.message;
|
|
3118
|
+
const path = btn.dataset.path;
|
|
3119
|
+
const fix = btn.dataset.fix;
|
|
3120
|
+
const why = btn.dataset.why;
|
|
3121
|
+
const impact = btn.dataset.impact;
|
|
3122
|
+
|
|
3123
|
+
const commentBody = '[CanICode] ' + rule + '\\n\\nFix: ' + fix + '\\nWhy: ' + why + '\\nImpact: ' + impact + '\\n\\n' + message + '\\nNode: ' + path;
|
|
3124
|
+
|
|
3125
|
+
btn.disabled = true;
|
|
3126
|
+
btn.textContent = 'Sending...';
|
|
3127
|
+
|
|
3128
|
+
try {
|
|
3129
|
+
const res = await fetch('https://api.figma.com/v1/files/' + fileKey + '/comments', {
|
|
3130
|
+
method: 'POST',
|
|
3131
|
+
headers: { 'X-FIGMA-TOKEN': FIGMA_TOKEN, 'Content-Type': 'application/json' },
|
|
3132
|
+
body: JSON.stringify({ message: commentBody, client_meta: { node_id: nodeId, node_offset: { x: 0, y: 0 } } }),
|
|
3133
|
+
});
|
|
3134
|
+
if (!res.ok) throw new Error(await res.text());
|
|
3135
|
+
btn.textContent = 'Sent \\u2713';
|
|
3136
|
+
btn.classList.remove('hover:bg-muted');
|
|
3137
|
+
btn.classList.add('text-green-600', 'border-green-500/30');
|
|
3138
|
+
} catch (e) {
|
|
3139
|
+
btn.textContent = 'Failed \\u2717';
|
|
3140
|
+
btn.classList.remove('hover:bg-muted');
|
|
3141
|
+
btn.classList.add('text-red-600', 'border-red-500/30');
|
|
3142
|
+
btn.disabled = false;
|
|
3143
|
+
console.error('Failed to post Figma comment:', e);
|
|
3144
|
+
}
|
|
3145
|
+
}
|
|
3146
|
+
</script>` : ""}
|
|
3147
|
+
</body>
|
|
3148
|
+
</html>`;
|
|
2799
3149
|
}
|
|
2800
|
-
function
|
|
2801
|
-
return
|
|
3150
|
+
function renderSummaryDot(dotClass, count, label) {
|
|
3151
|
+
return `<div class="flex items-center gap-2">
|
|
3152
|
+
<span class="w-2.5 h-2.5 rounded-full ${dotClass}"></span>
|
|
3153
|
+
<span class="text-lg font-bold tracking-tight">${count}</span>
|
|
3154
|
+
<span class="text-sm text-muted-foreground">${label}</span>
|
|
3155
|
+
</div>`;
|
|
2802
3156
|
}
|
|
2803
|
-
function
|
|
2804
|
-
const
|
|
2805
|
-
|
|
2806
|
-
|
|
2807
|
-
|
|
3157
|
+
function renderOpportunities(issues, fileKey) {
|
|
3158
|
+
const maxAbs = issues.reduce((m, i) => Math.max(m, Math.abs(i.calculatedScore)), 1);
|
|
3159
|
+
return `
|
|
3160
|
+
<!-- Opportunities -->
|
|
3161
|
+
<section class="bg-card border border-border rounded-lg shadow-sm mb-6 overflow-hidden">
|
|
3162
|
+
<div class="px-6 py-4 border-b border-border">
|
|
3163
|
+
<h2 class="text-sm font-semibold flex items-center gap-2">
|
|
3164
|
+
<span class="w-2 h-2 rounded-full bg-red-500"></span>
|
|
3165
|
+
Opportunities
|
|
3166
|
+
</h2>
|
|
3167
|
+
<p class="text-xs text-muted-foreground mt-1">Top blocking issues \u2014 fix these first for the biggest improvement.</p>
|
|
3168
|
+
</div>
|
|
3169
|
+
<div class="divide-y divide-border">
|
|
3170
|
+
${issues.map((issue) => {
|
|
3171
|
+
const def = issue.rule.definition;
|
|
3172
|
+
const link = buildFigmaDeepLink(fileKey, issue.violation.nodeId);
|
|
3173
|
+
const barW = Math.round(Math.abs(issue.calculatedScore) / maxAbs * 100);
|
|
3174
|
+
return ` <div class="px-6 py-3 flex items-center gap-4 hover:bg-muted/50 transition-colors">
|
|
3175
|
+
<div class="flex-1 min-w-0">
|
|
3176
|
+
<div class="text-sm font-medium truncate">${esc(def.name)}</div>
|
|
3177
|
+
<div class="text-xs text-muted-foreground truncate mt-0.5">${esc(issue.violation.message)}</div>
|
|
3178
|
+
</div>
|
|
3179
|
+
<div class="w-32 flex items-center gap-2 shrink-0">
|
|
3180
|
+
<div class="flex-1 h-1.5 bg-muted rounded-full overflow-hidden">
|
|
3181
|
+
<div class="h-full bg-red-500 rounded-full" style="width:${barW}%"></div>
|
|
3182
|
+
</div>
|
|
3183
|
+
<span class="text-xs font-medium text-red-600 w-12 text-right">${issue.calculatedScore}</span>
|
|
3184
|
+
</div>
|
|
3185
|
+
<a href="${link}" target="_blank" rel="noopener" class="text-xs text-muted-foreground hover:text-foreground shrink-0 no-print">Figma \u2192</a>
|
|
3186
|
+
</div>`;
|
|
3187
|
+
}).join("\n")}
|
|
3188
|
+
</div>
|
|
3189
|
+
</section>`;
|
|
2808
3190
|
}
|
|
2809
|
-
|
|
2810
|
-
|
|
2811
|
-
|
|
2812
|
-
|
|
2813
|
-
|
|
2814
|
-
|
|
2815
|
-
|
|
2816
|
-
}
|
|
2817
|
-
|
|
2818
|
-
|
|
2819
|
-
|
|
2820
|
-
|
|
2821
|
-
|
|
2822
|
-
|
|
2823
|
-
|
|
2824
|
-
|
|
2825
|
-
|
|
2826
|
-
|
|
2827
|
-
|
|
2828
|
-
|
|
2829
|
-
|
|
2830
|
-
|
|
2831
|
-
|
|
2832
|
-
|
|
2833
|
-
|
|
2834
|
-
|
|
2835
|
-
|
|
2836
|
-
|
|
2837
|
-
|
|
2838
|
-
|
|
2839
|
-
|
|
2840
|
-
|
|
2841
|
-
|
|
3191
|
+
function renderCategory(cat, scores, issues, fileKey, screenshotMap, figmaToken) {
|
|
3192
|
+
const cs = scores.byCategory[cat];
|
|
3193
|
+
const hasProblems = issues.some((i) => i.config.severity === "blocking" || i.config.severity === "risk");
|
|
3194
|
+
const bySeverity = /* @__PURE__ */ new Map();
|
|
3195
|
+
for (const sev of SEVERITY_ORDER) bySeverity.set(sev, []);
|
|
3196
|
+
for (const issue of issues) bySeverity.get(issue.config.severity)?.push(issue);
|
|
3197
|
+
return `
|
|
3198
|
+
<details id="cat-${cat}" class="bg-card border border-border rounded-lg shadow-sm overflow-hidden group"${hasProblems ? " open" : ""}>
|
|
3199
|
+
<summary class="px-5 py-3.5 flex items-center gap-3 cursor-pointer hover:bg-muted/50 transition-colors select-none">
|
|
3200
|
+
<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>
|
|
3201
|
+
<div class="flex-1 min-w-0">
|
|
3202
|
+
<div class="text-sm font-semibold">${CATEGORY_LABELS[cat]}</div>
|
|
3203
|
+
<div class="text-xs text-muted-foreground">${esc(CATEGORY_DESCRIPTIONS[cat])}</div>
|
|
3204
|
+
</div>
|
|
3205
|
+
<span class="text-xs text-muted-foreground">${cs.issueCount} issues</span>
|
|
3206
|
+
<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>
|
|
3207
|
+
</summary>
|
|
3208
|
+
<div class="border-t border-border">
|
|
3209
|
+
${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")}
|
|
3210
|
+
</div>
|
|
3211
|
+
</details>`;
|
|
3212
|
+
}
|
|
3213
|
+
function renderSeverityGroup(sev, issues, fileKey, screenshotMap, figmaToken) {
|
|
3214
|
+
return ` <div class="px-5 py-3">
|
|
3215
|
+
<div class="flex items-center gap-2 mb-2">
|
|
3216
|
+
<span class="w-2 h-2 rounded-full ${severityDot(sev)}"></span>
|
|
3217
|
+
<span class="text-xs font-semibold uppercase tracking-wider">${SEVERITY_LABELS[sev]}</span>
|
|
3218
|
+
<span class="text-xs text-muted-foreground ml-auto">${issues.length}</span>
|
|
3219
|
+
</div>
|
|
3220
|
+
<div class="space-y-1">
|
|
3221
|
+
${issues.map((issue) => renderIssueRow(issue, fileKey, screenshotMap, figmaToken)).join("\n")}
|
|
3222
|
+
</div>
|
|
3223
|
+
</div>`;
|
|
3224
|
+
}
|
|
3225
|
+
function renderIssueRow(issue, fileKey, screenshotMap, figmaToken) {
|
|
3226
|
+
const sev = issue.config.severity;
|
|
3227
|
+
const def = issue.rule.definition;
|
|
3228
|
+
const link = buildFigmaDeepLink(fileKey, issue.violation.nodeId);
|
|
3229
|
+
const screenshot = screenshotMap.get(issue.violation.nodeId);
|
|
3230
|
+
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>` : "";
|
|
3231
|
+
return ` <details class="border border-border rounded-md overflow-hidden">
|
|
3232
|
+
<summary class="flex items-center gap-2.5 px-3 py-2 text-sm cursor-pointer hover:bg-muted/50 transition-colors">
|
|
3233
|
+
<span class="w-1.5 h-1.5 rounded-full ${severityDot(sev)} shrink-0"></span>
|
|
3234
|
+
<span class="font-medium shrink-0">${esc(def.name)}</span>
|
|
3235
|
+
<span class="text-muted-foreground truncate text-xs flex-1">${esc(issue.violation.message)}</span>
|
|
3236
|
+
<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>
|
|
3237
|
+
</summary>
|
|
3238
|
+
<div class="px-3 py-3 bg-muted/30 border-t border-border text-sm space-y-2">
|
|
3239
|
+
<div class="font-mono text-xs text-muted-foreground break-all">${esc(issue.violation.nodePath)}</div>
|
|
3240
|
+
<div class="text-muted-foreground leading-relaxed space-y-1">
|
|
3241
|
+
<p><span class="font-medium text-foreground">Why:</span> ${esc(def.why)}</p>
|
|
3242
|
+
<p><span class="font-medium text-foreground">Impact:</span> ${esc(def.impact)}</p>
|
|
3243
|
+
<p><span class="font-medium text-foreground">Fix:</span> ${esc(def.fix)}</p>
|
|
3244
|
+
</div>${screenshotHtml}
|
|
3245
|
+
<div class="flex items-center gap-2 mt-1 no-print">
|
|
3246
|
+
<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 ? `
|
|
3247
|
+
<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>` : ""}
|
|
3248
|
+
</div>
|
|
3249
|
+
</div>
|
|
3250
|
+
</details>`;
|
|
3251
|
+
}
|
|
3252
|
+
function getQuickWins(issues, limit) {
|
|
3253
|
+
return issues.filter((issue) => issue.config.severity === "blocking").sort((a, b) => a.calculatedScore - b.calculatedScore).slice(0, limit);
|
|
3254
|
+
}
|
|
3255
|
+
function groupIssuesByCategory(issues) {
|
|
3256
|
+
const grouped = /* @__PURE__ */ new Map();
|
|
3257
|
+
for (const category of CATEGORIES) grouped.set(category, []);
|
|
3258
|
+
for (const issue of issues) grouped.get(issue.rule.definition.category).push(issue);
|
|
3259
|
+
return grouped;
|
|
3260
|
+
}
|
|
3261
|
+
var esc = escapeHtml;
|
|
3262
|
+
var RuleOverrideSchema = z.object({
|
|
3263
|
+
score: z.number().int().max(0).optional(),
|
|
3264
|
+
severity: SeveritySchema.optional(),
|
|
3265
|
+
enabled: z.boolean().optional()
|
|
2842
3266
|
});
|
|
2843
|
-
var
|
|
2844
|
-
|
|
2845
|
-
|
|
2846
|
-
|
|
2847
|
-
|
|
2848
|
-
|
|
2849
|
-
|
|
2850
|
-
|
|
2851
|
-
|
|
2852
|
-
|
|
2853
|
-
|
|
2854
|
-
|
|
2855
|
-
|
|
2856
|
-
|
|
2857
|
-
|
|
2858
|
-
|
|
2859
|
-
|
|
2860
|
-
if (
|
|
2861
|
-
|
|
2862
|
-
|
|
2863
|
-
|
|
2864
|
-
|
|
2865
|
-
const overlapX = Math.min(boxA.x + boxA.width, boxB.x + boxB.width) - Math.max(boxA.x, boxB.x);
|
|
2866
|
-
const overlapY = Math.min(boxA.y + boxA.height, boxB.y + boxB.height) - Math.max(boxA.y, boxB.y);
|
|
2867
|
-
if (overlapX > 0 && overlapY > 0) {
|
|
2868
|
-
const overlapArea = overlapX * overlapY;
|
|
2869
|
-
const smallerArea = Math.min(
|
|
2870
|
-
boxA.width * boxA.height,
|
|
2871
|
-
boxB.width * boxB.height
|
|
2872
|
-
);
|
|
2873
|
-
if (overlapArea > smallerArea * 0.2) {
|
|
2874
|
-
significantOverlapCount++;
|
|
2875
|
-
}
|
|
2876
|
-
}
|
|
3267
|
+
var ConfigFileSchema = z.object({
|
|
3268
|
+
excludeNodeTypes: z.array(z.string()).optional(),
|
|
3269
|
+
excludeNodeNames: z.array(z.string()).optional(),
|
|
3270
|
+
gridBase: z.number().int().positive().optional(),
|
|
3271
|
+
colorTolerance: z.number().int().positive().optional(),
|
|
3272
|
+
rules: z.record(z.string(), RuleOverrideSchema).optional()
|
|
3273
|
+
});
|
|
3274
|
+
async function loadConfigFile(filePath) {
|
|
3275
|
+
const absPath = resolve(filePath);
|
|
3276
|
+
const raw = await readFile(absPath, "utf-8");
|
|
3277
|
+
const parsed = JSON.parse(raw);
|
|
3278
|
+
return ConfigFileSchema.parse(parsed);
|
|
3279
|
+
}
|
|
3280
|
+
function mergeConfigs(base, overrides) {
|
|
3281
|
+
const merged = { ...base };
|
|
3282
|
+
if (overrides.gridBase !== void 0) {
|
|
3283
|
+
for (const [id, config2] of Object.entries(merged)) {
|
|
3284
|
+
if (config2.options && "gridBase" in config2.options) {
|
|
3285
|
+
merged[id] = {
|
|
3286
|
+
...config2,
|
|
3287
|
+
options: { ...config2.options, gridBase: overrides.gridBase }
|
|
3288
|
+
};
|
|
2877
3289
|
}
|
|
2878
3290
|
}
|
|
2879
3291
|
}
|
|
2880
|
-
if (
|
|
2881
|
-
|
|
2882
|
-
|
|
2883
|
-
|
|
2884
|
-
|
|
2885
|
-
|
|
2886
|
-
|
|
3292
|
+
if (overrides.colorTolerance !== void 0) {
|
|
3293
|
+
for (const [id, config2] of Object.entries(merged)) {
|
|
3294
|
+
if (config2.options && "tolerance" in config2.options) {
|
|
3295
|
+
merged[id] = {
|
|
3296
|
+
...config2,
|
|
3297
|
+
options: { ...config2.options, tolerance: overrides.colorTolerance }
|
|
3298
|
+
};
|
|
3299
|
+
}
|
|
3300
|
+
}
|
|
2887
3301
|
}
|
|
2888
|
-
|
|
2889
|
-
|
|
2890
|
-
|
|
2891
|
-
|
|
2892
|
-
|
|
2893
|
-
|
|
2894
|
-
|
|
2895
|
-
|
|
2896
|
-
|
|
2897
|
-
|
|
2898
|
-
|
|
2899
|
-
impact: "AI may generate incorrect code due to ambiguous relationships",
|
|
2900
|
-
fix: "Add Auto Layout or simplify the nesting structure"
|
|
2901
|
-
};
|
|
2902
|
-
var missingLayoutHintCheck = (node, context) => {
|
|
2903
|
-
if (!isContainerNode2(node)) return null;
|
|
2904
|
-
if (hasAutoLayout2(node)) return null;
|
|
2905
|
-
if (!node.children || node.children.length === 0) return null;
|
|
2906
|
-
const nestedContainers = node.children.filter((c) => isContainerNode2(c));
|
|
2907
|
-
if (nestedContainers.length >= 2) {
|
|
2908
|
-
const withoutLayout = nestedContainers.filter((c) => !hasAutoLayout2(c));
|
|
2909
|
-
if (withoutLayout.length >= 2) {
|
|
2910
|
-
return {
|
|
2911
|
-
ruleId: missingLayoutHintDef.id,
|
|
2912
|
-
nodeId: node.id,
|
|
2913
|
-
nodePath: context.path.join(" > "),
|
|
2914
|
-
message: `"${node.name}" has ${withoutLayout.length} nested containers without layout hints`
|
|
2915
|
-
};
|
|
3302
|
+
if (overrides.rules) {
|
|
3303
|
+
for (const [ruleId, override] of Object.entries(overrides.rules)) {
|
|
3304
|
+
const existing = merged[ruleId];
|
|
3305
|
+
if (existing) {
|
|
3306
|
+
merged[ruleId] = {
|
|
3307
|
+
...existing,
|
|
3308
|
+
...override.score !== void 0 && { score: override.score },
|
|
3309
|
+
...override.severity !== void 0 && { severity: override.severity },
|
|
3310
|
+
...override.enabled !== void 0 && { enabled: override.enabled }
|
|
3311
|
+
};
|
|
3312
|
+
}
|
|
2916
3313
|
}
|
|
2917
3314
|
}
|
|
2918
|
-
return
|
|
2919
|
-
}
|
|
2920
|
-
|
|
2921
|
-
|
|
2922
|
-
|
|
3315
|
+
return merged;
|
|
3316
|
+
}
|
|
3317
|
+
var MatchConditionSchema = z.object({
|
|
3318
|
+
// Node type conditions
|
|
3319
|
+
type: z.array(z.string()).optional(),
|
|
3320
|
+
notType: z.array(z.string()).optional(),
|
|
3321
|
+
// Name conditions (case-insensitive, substring match)
|
|
3322
|
+
nameContains: z.string().optional(),
|
|
3323
|
+
nameNotContains: z.string().optional(),
|
|
3324
|
+
namePattern: z.string().optional(),
|
|
3325
|
+
// Size conditions
|
|
3326
|
+
minWidth: z.number().optional(),
|
|
3327
|
+
maxWidth: z.number().optional(),
|
|
3328
|
+
minHeight: z.number().optional(),
|
|
3329
|
+
maxHeight: z.number().optional(),
|
|
3330
|
+
// Layout conditions
|
|
3331
|
+
hasAutoLayout: z.boolean().optional(),
|
|
3332
|
+
hasChildren: z.boolean().optional(),
|
|
3333
|
+
minChildren: z.number().optional(),
|
|
3334
|
+
maxChildren: z.number().optional(),
|
|
3335
|
+
// Component conditions
|
|
3336
|
+
isComponent: z.boolean().optional(),
|
|
3337
|
+
isInstance: z.boolean().optional(),
|
|
3338
|
+
hasComponentId: z.boolean().optional(),
|
|
3339
|
+
// Visibility
|
|
3340
|
+
isVisible: z.boolean().optional(),
|
|
3341
|
+
// Fill/style conditions
|
|
3342
|
+
hasFills: z.boolean().optional(),
|
|
3343
|
+
hasStrokes: z.boolean().optional(),
|
|
3344
|
+
hasEffects: z.boolean().optional(),
|
|
3345
|
+
// Depth condition
|
|
3346
|
+
minDepth: z.number().optional(),
|
|
3347
|
+
maxDepth: z.number().optional()
|
|
2923
3348
|
});
|
|
2924
|
-
var
|
|
2925
|
-
id:
|
|
2926
|
-
|
|
2927
|
-
|
|
2928
|
-
|
|
2929
|
-
|
|
2930
|
-
|
|
2931
|
-
|
|
2932
|
-
|
|
2933
|
-
|
|
2934
|
-
|
|
2935
|
-
|
|
2936
|
-
ruleId: invisibleLayerDef.id,
|
|
2937
|
-
nodeId: node.id,
|
|
2938
|
-
nodePath: context.path.join(" > "),
|
|
2939
|
-
message: `"${node.name}" is hidden - consider removing if not needed`
|
|
2940
|
-
};
|
|
2941
|
-
};
|
|
2942
|
-
defineRule({
|
|
2943
|
-
definition: invisibleLayerDef,
|
|
2944
|
-
check: invisibleLayerCheck
|
|
3349
|
+
var CustomRuleSchema = z.object({
|
|
3350
|
+
id: z.string(),
|
|
3351
|
+
category: CategorySchema,
|
|
3352
|
+
severity: SeveritySchema,
|
|
3353
|
+
score: z.number().int().max(0),
|
|
3354
|
+
match: MatchConditionSchema,
|
|
3355
|
+
message: z.string().optional(),
|
|
3356
|
+
why: z.string(),
|
|
3357
|
+
impact: z.string(),
|
|
3358
|
+
fix: z.string(),
|
|
3359
|
+
// Backward compat: silently ignore the old prompt field
|
|
3360
|
+
prompt: z.string().optional()
|
|
2945
3361
|
});
|
|
2946
|
-
var
|
|
2947
|
-
|
|
2948
|
-
|
|
2949
|
-
|
|
2950
|
-
|
|
2951
|
-
|
|
2952
|
-
|
|
2953
|
-
|
|
2954
|
-
|
|
2955
|
-
|
|
2956
|
-
|
|
2957
|
-
|
|
2958
|
-
|
|
2959
|
-
|
|
3362
|
+
var CustomRulesFileSchema = z.array(CustomRuleSchema);
|
|
3363
|
+
|
|
3364
|
+
// src/core/rules/custom/custom-rule-loader.ts
|
|
3365
|
+
async function loadCustomRules(filePath) {
|
|
3366
|
+
const absPath = resolve(filePath);
|
|
3367
|
+
const raw = await readFile(absPath, "utf-8");
|
|
3368
|
+
const parsed = JSON.parse(raw);
|
|
3369
|
+
const customRules = CustomRulesFileSchema.parse(parsed);
|
|
3370
|
+
const rules = [];
|
|
3371
|
+
const configs = {};
|
|
3372
|
+
for (const cr of customRules) {
|
|
3373
|
+
if (!cr.match) continue;
|
|
3374
|
+
rules.push(toRule(cr));
|
|
3375
|
+
configs[cr.id] = {
|
|
3376
|
+
severity: cr.severity,
|
|
3377
|
+
score: cr.score,
|
|
3378
|
+
enabled: true
|
|
3379
|
+
};
|
|
2960
3380
|
}
|
|
3381
|
+
return { rules, configs };
|
|
3382
|
+
}
|
|
3383
|
+
function toRule(cr) {
|
|
2961
3384
|
return {
|
|
2962
|
-
|
|
2963
|
-
|
|
2964
|
-
|
|
2965
|
-
|
|
3385
|
+
definition: {
|
|
3386
|
+
id: cr.id,
|
|
3387
|
+
name: cr.id.replace(/-/g, " ").replace(/\b\w/g, (c) => c.toUpperCase()),
|
|
3388
|
+
category: cr.category,
|
|
3389
|
+
why: cr.why,
|
|
3390
|
+
impact: cr.impact,
|
|
3391
|
+
fix: cr.fix
|
|
3392
|
+
},
|
|
3393
|
+
check: createPatternCheck(cr)
|
|
3394
|
+
};
|
|
3395
|
+
}
|
|
3396
|
+
function createPatternCheck(cr) {
|
|
3397
|
+
return (node, context) => {
|
|
3398
|
+
if (node.type === "DOCUMENT" || node.type === "CANVAS") return null;
|
|
3399
|
+
const match = cr.match;
|
|
3400
|
+
if (match.type && !match.type.includes(node.type)) return null;
|
|
3401
|
+
if (match.notType && match.notType.includes(node.type)) return null;
|
|
3402
|
+
if (match.nameContains !== void 0 && !node.name.toLowerCase().includes(match.nameContains.toLowerCase())) return null;
|
|
3403
|
+
if (match.nameNotContains !== void 0 && node.name.toLowerCase().includes(match.nameNotContains.toLowerCase())) return null;
|
|
3404
|
+
if (match.namePattern !== void 0 && !new RegExp(match.namePattern, "i").test(node.name)) return null;
|
|
3405
|
+
const bbox = node.absoluteBoundingBox;
|
|
3406
|
+
if (match.minWidth !== void 0 && (!bbox || bbox.width < match.minWidth)) return null;
|
|
3407
|
+
if (match.maxWidth !== void 0 && (!bbox || bbox.width > match.maxWidth)) return null;
|
|
3408
|
+
if (match.minHeight !== void 0 && (!bbox || bbox.height < match.minHeight)) return null;
|
|
3409
|
+
if (match.maxHeight !== void 0 && (!bbox || bbox.height > match.maxHeight)) return null;
|
|
3410
|
+
if (match.hasAutoLayout === true && !node.layoutMode) return null;
|
|
3411
|
+
if (match.hasAutoLayout === false && node.layoutMode) return null;
|
|
3412
|
+
if (match.hasChildren === true && (!node.children || node.children.length === 0)) return null;
|
|
3413
|
+
if (match.hasChildren === false && node.children && node.children.length > 0) return null;
|
|
3414
|
+
if (match.minChildren !== void 0 && (!node.children || node.children.length < match.minChildren)) return null;
|
|
3415
|
+
if (match.maxChildren !== void 0 && node.children && node.children.length > match.maxChildren) return null;
|
|
3416
|
+
if (match.isComponent === true && node.type !== "COMPONENT" && node.type !== "COMPONENT_SET") return null;
|
|
3417
|
+
if (match.isComponent === false && (node.type === "COMPONENT" || node.type === "COMPONENT_SET")) return null;
|
|
3418
|
+
if (match.isInstance === true && node.type !== "INSTANCE") return null;
|
|
3419
|
+
if (match.isInstance === false && node.type === "INSTANCE") return null;
|
|
3420
|
+
if (match.hasComponentId === true && !node.componentId) return null;
|
|
3421
|
+
if (match.hasComponentId === false && node.componentId) return null;
|
|
3422
|
+
if (match.isVisible === true && !node.visible) return null;
|
|
3423
|
+
if (match.isVisible === false && node.visible) return null;
|
|
3424
|
+
if (match.hasFills === true && (!node.fills || node.fills.length === 0)) return null;
|
|
3425
|
+
if (match.hasFills === false && node.fills && node.fills.length > 0) return null;
|
|
3426
|
+
if (match.hasStrokes === true && (!node.strokes || node.strokes.length === 0)) return null;
|
|
3427
|
+
if (match.hasStrokes === false && node.strokes && node.strokes.length > 0) return null;
|
|
3428
|
+
if (match.hasEffects === true && (!node.effects || node.effects.length === 0)) return null;
|
|
3429
|
+
if (match.hasEffects === false && node.effects && node.effects.length > 0) return null;
|
|
3430
|
+
if (match.minDepth !== void 0 && context.depth < match.minDepth) return null;
|
|
3431
|
+
if (match.maxDepth !== void 0 && context.depth > match.maxDepth) return null;
|
|
3432
|
+
const msg = (cr.message ?? `"${node.name}" matched custom rule "${cr.id}"`).replace(/\{name\}/g, node.name).replace(/\{type\}/g, node.type);
|
|
3433
|
+
return {
|
|
3434
|
+
ruleId: cr.id,
|
|
3435
|
+
nodeId: node.id,
|
|
3436
|
+
nodePath: context.path.join(" > "),
|
|
3437
|
+
message: msg
|
|
3438
|
+
};
|
|
2966
3439
|
};
|
|
3440
|
+
}
|
|
3441
|
+
|
|
3442
|
+
// src/core/monitoring/events.ts
|
|
3443
|
+
var EVENT_PREFIX = "cic_";
|
|
3444
|
+
var EVENTS = {
|
|
3445
|
+
// Analysis
|
|
3446
|
+
ANALYSIS_STARTED: `${EVENT_PREFIX}analysis_started`,
|
|
3447
|
+
ANALYSIS_COMPLETED: `${EVENT_PREFIX}analysis_completed`,
|
|
3448
|
+
ANALYSIS_FAILED: `${EVENT_PREFIX}analysis_failed`,
|
|
3449
|
+
// Report
|
|
3450
|
+
REPORT_GENERATED: `${EVENT_PREFIX}report_generated`,
|
|
3451
|
+
COMMENT_POSTED: `${EVENT_PREFIX}comment_posted`,
|
|
3452
|
+
COMMENT_FAILED: `${EVENT_PREFIX}comment_failed`,
|
|
3453
|
+
// MCP
|
|
3454
|
+
MCP_TOOL_CALLED: `${EVENT_PREFIX}mcp_tool_called`,
|
|
3455
|
+
// CLI
|
|
3456
|
+
CLI_COMMAND: `${EVENT_PREFIX}cli_command`,
|
|
3457
|
+
CLI_INIT: `${EVENT_PREFIX}cli_init`
|
|
2967
3458
|
};
|
|
2968
|
-
defineRule({
|
|
2969
|
-
definition: emptyFrameDef,
|
|
2970
|
-
check: emptyFrameCheck
|
|
2971
|
-
});
|
|
2972
3459
|
|
|
2973
|
-
// src/core/
|
|
2974
|
-
|
|
2975
|
-
|
|
3460
|
+
// src/core/monitoring/capture.ts
|
|
3461
|
+
var monitoringEnabled = false;
|
|
3462
|
+
var posthogApiKey;
|
|
3463
|
+
var sentryDsn;
|
|
3464
|
+
var distinctId = "anonymous";
|
|
3465
|
+
var environment = "unknown";
|
|
3466
|
+
var version2 = "unknown";
|
|
3467
|
+
var commonProps = {};
|
|
3468
|
+
function uuid4() {
|
|
3469
|
+
return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (c) => {
|
|
3470
|
+
const r = Math.random() * 16 | 0;
|
|
3471
|
+
const v = c === "x" ? r : r & 3 | 8;
|
|
3472
|
+
return v.toString(16);
|
|
3473
|
+
});
|
|
2976
3474
|
}
|
|
2977
|
-
function
|
|
2978
|
-
|
|
3475
|
+
function parseSentryDsn(dsn) {
|
|
3476
|
+
try {
|
|
3477
|
+
const url = new URL(dsn);
|
|
3478
|
+
const key = url.username;
|
|
3479
|
+
const projectId = url.pathname.slice(1);
|
|
3480
|
+
const host = url.protocol + "//" + url.host;
|
|
3481
|
+
if (!key || !projectId) return null;
|
|
3482
|
+
return { key, host, projectId };
|
|
3483
|
+
} catch {
|
|
3484
|
+
return null;
|
|
3485
|
+
}
|
|
2979
3486
|
}
|
|
2980
|
-
function
|
|
2981
|
-
|
|
3487
|
+
function initCapture(config2) {
|
|
3488
|
+
if (config2.enabled === false) return;
|
|
3489
|
+
if (!config2.posthogApiKey && !config2.sentryDsn) return;
|
|
3490
|
+
monitoringEnabled = true;
|
|
3491
|
+
posthogApiKey = config2.posthogApiKey;
|
|
3492
|
+
sentryDsn = config2.sentryDsn;
|
|
3493
|
+
distinctId = config2.distinctId ?? "anonymous";
|
|
3494
|
+
environment = config2.environment ?? "unknown";
|
|
3495
|
+
version2 = config2.version ?? "unknown";
|
|
3496
|
+
commonProps = {
|
|
3497
|
+
_sdk: "canicode",
|
|
3498
|
+
_sdk_version: version2,
|
|
3499
|
+
_env: environment
|
|
3500
|
+
};
|
|
2982
3501
|
}
|
|
2983
|
-
function
|
|
2984
|
-
if (
|
|
2985
|
-
|
|
2986
|
-
|
|
2987
|
-
|
|
3502
|
+
function captureEvent(event, properties) {
|
|
3503
|
+
if (!monitoringEnabled || !posthogApiKey) return;
|
|
3504
|
+
try {
|
|
3505
|
+
fetch("https://us.i.posthog.com/i/v0/e/", {
|
|
3506
|
+
method: "POST",
|
|
3507
|
+
headers: { "Content-Type": "application/json" },
|
|
3508
|
+
body: JSON.stringify({
|
|
3509
|
+
api_key: posthogApiKey,
|
|
3510
|
+
event,
|
|
3511
|
+
distinct_id: distinctId,
|
|
3512
|
+
properties: { ...commonProps, ...properties },
|
|
3513
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString()
|
|
3514
|
+
})
|
|
3515
|
+
}).catch(() => {
|
|
3516
|
+
});
|
|
3517
|
+
} catch {
|
|
3518
|
+
}
|
|
3519
|
+
}
|
|
3520
|
+
function captureError(error, context) {
|
|
3521
|
+
if (!monitoringEnabled) return;
|
|
3522
|
+
if (sentryDsn) {
|
|
3523
|
+
const parsed = parseSentryDsn(sentryDsn);
|
|
3524
|
+
if (parsed) {
|
|
3525
|
+
try {
|
|
3526
|
+
const eventId = uuid4();
|
|
3527
|
+
const envelope = [
|
|
3528
|
+
JSON.stringify({ event_id: eventId, sent_at: (/* @__PURE__ */ new Date()).toISOString(), dsn: sentryDsn }),
|
|
3529
|
+
JSON.stringify({ type: "event", content_type: "application/json" }),
|
|
3530
|
+
JSON.stringify({
|
|
3531
|
+
event_id: eventId,
|
|
3532
|
+
exception: { values: [{ type: error.name, value: error.message }] },
|
|
3533
|
+
platform: "node",
|
|
3534
|
+
environment,
|
|
3535
|
+
release: `canicode@${version2}`,
|
|
3536
|
+
timestamp: Date.now() / 1e3,
|
|
3537
|
+
extra: context
|
|
3538
|
+
})
|
|
3539
|
+
].join("\n");
|
|
3540
|
+
fetch(`${parsed.host}/api/${parsed.projectId}/envelope/`, {
|
|
3541
|
+
method: "POST",
|
|
3542
|
+
headers: {
|
|
3543
|
+
"Content-Type": "application/x-sentry-envelope",
|
|
3544
|
+
"X-Sentry-Auth": `Sentry sentry_version=7, sentry_key=${parsed.key}`
|
|
3545
|
+
},
|
|
3546
|
+
body: envelope
|
|
3547
|
+
}).catch(() => {
|
|
3548
|
+
});
|
|
3549
|
+
} catch {
|
|
3550
|
+
}
|
|
2988
3551
|
}
|
|
2989
3552
|
}
|
|
2990
|
-
|
|
3553
|
+
captureEvent("cic_error", { error: error.message, ...context });
|
|
2991
3554
|
}
|
|
2992
|
-
|
|
2993
|
-
|
|
2994
|
-
|
|
2995
|
-
|
|
2996
|
-
|
|
2997
|
-
|
|
2998
|
-
|
|
2999
|
-
};
|
|
3000
|
-
|
|
3001
|
-
|
|
3002
|
-
|
|
3003
|
-
|
|
3004
|
-
|
|
3005
|
-
|
|
3006
|
-
|
|
3007
|
-
|
|
3008
|
-
|
|
3009
|
-
|
|
3555
|
+
function shutdownCapture() {
|
|
3556
|
+
monitoringEnabled = false;
|
|
3557
|
+
posthogApiKey = void 0;
|
|
3558
|
+
sentryDsn = void 0;
|
|
3559
|
+
distinctId = "anonymous";
|
|
3560
|
+
environment = "unknown";
|
|
3561
|
+
version2 = "unknown";
|
|
3562
|
+
commonProps = {};
|
|
3563
|
+
}
|
|
3564
|
+
|
|
3565
|
+
// src/core/monitoring/index.ts
|
|
3566
|
+
function initMonitoring(config2) {
|
|
3567
|
+
initCapture(config2);
|
|
3568
|
+
}
|
|
3569
|
+
function trackEvent(event, properties) {
|
|
3570
|
+
try {
|
|
3571
|
+
captureEvent(event, properties);
|
|
3572
|
+
} catch {
|
|
3010
3573
|
}
|
|
3011
|
-
|
|
3012
|
-
|
|
3013
|
-
|
|
3014
|
-
|
|
3015
|
-
|
|
3016
|
-
});
|
|
3017
|
-
var textTruncationUnhandledDef = {
|
|
3018
|
-
id: "text-truncation-unhandled",
|
|
3019
|
-
name: "Text Truncation Unhandled",
|
|
3020
|
-
category: "handoff-risk",
|
|
3021
|
-
why: "Text nodes without truncation handling may overflow",
|
|
3022
|
-
impact: "Long text will break the layout",
|
|
3023
|
-
fix: "Set text truncation (ellipsis) or ensure container can grow"
|
|
3024
|
-
};
|
|
3025
|
-
var textTruncationUnhandledCheck = (node, context) => {
|
|
3026
|
-
if (!isTextNode(node)) return null;
|
|
3027
|
-
if (!context.parent) return null;
|
|
3028
|
-
if (!hasAutoLayout3(context.parent)) return null;
|
|
3029
|
-
if (node.absoluteBoundingBox) {
|
|
3030
|
-
const { width } = node.absoluteBoundingBox;
|
|
3031
|
-
if (node.characters && node.characters.length > 50 && width < 300) {
|
|
3032
|
-
return {
|
|
3033
|
-
ruleId: textTruncationUnhandledDef.id,
|
|
3034
|
-
nodeId: node.id,
|
|
3035
|
-
nodePath: context.path.join(" > "),
|
|
3036
|
-
message: `"${node.name}" may need text truncation handling`
|
|
3037
|
-
};
|
|
3038
|
-
}
|
|
3574
|
+
}
|
|
3575
|
+
function trackError(error, context) {
|
|
3576
|
+
try {
|
|
3577
|
+
captureError(error, context);
|
|
3578
|
+
} catch {
|
|
3039
3579
|
}
|
|
3040
|
-
|
|
3041
|
-
|
|
3042
|
-
|
|
3043
|
-
|
|
3044
|
-
|
|
3045
|
-
});
|
|
3046
|
-
var imageNoPlaceholderDef = {
|
|
3047
|
-
id: "image-no-placeholder",
|
|
3048
|
-
name: "Image No Placeholder",
|
|
3049
|
-
category: "handoff-risk",
|
|
3050
|
-
why: "Images without placeholder state may cause layout shifts",
|
|
3051
|
-
impact: "Poor user experience during image loading",
|
|
3052
|
-
fix: "Define a placeholder state or background color"
|
|
3053
|
-
};
|
|
3054
|
-
var imageNoPlaceholderCheck = (node, context) => {
|
|
3055
|
-
if (!isImageNode(node)) return null;
|
|
3056
|
-
if (node.fills && Array.isArray(node.fills) && node.fills.length === 1) {
|
|
3057
|
-
const fill = node.fills[0];
|
|
3058
|
-
if (fill["type"] === "IMAGE") {
|
|
3059
|
-
return {
|
|
3060
|
-
ruleId: imageNoPlaceholderDef.id,
|
|
3061
|
-
nodeId: node.id,
|
|
3062
|
-
nodePath: context.path.join(" > "),
|
|
3063
|
-
message: `"${node.name}" image has no placeholder fill`
|
|
3064
|
-
};
|
|
3065
|
-
}
|
|
3580
|
+
}
|
|
3581
|
+
function shutdownMonitoring() {
|
|
3582
|
+
try {
|
|
3583
|
+
shutdownCapture();
|
|
3584
|
+
} catch {
|
|
3066
3585
|
}
|
|
3067
|
-
|
|
3068
|
-
|
|
3069
|
-
|
|
3070
|
-
|
|
3071
|
-
|
|
3072
|
-
});
|
|
3073
|
-
var prototypeLinkInDesignDef = {
|
|
3074
|
-
id: "prototype-link-in-design",
|
|
3075
|
-
name: "Prototype Link in Design",
|
|
3076
|
-
category: "handoff-risk",
|
|
3077
|
-
why: "Prototype connections may affect how the design is interpreted",
|
|
3078
|
-
impact: "Developers may misunderstand which elements should be interactive",
|
|
3079
|
-
fix: "Document interactions separately or use clear naming"
|
|
3080
|
-
};
|
|
3081
|
-
var prototypeLinkInDesignCheck = (_node, _context) => {
|
|
3082
|
-
return null;
|
|
3083
|
-
};
|
|
3084
|
-
defineRule({
|
|
3085
|
-
definition: prototypeLinkInDesignDef,
|
|
3086
|
-
check: prototypeLinkInDesignCheck
|
|
3087
|
-
});
|
|
3088
|
-
var noDevStatusDef = {
|
|
3089
|
-
id: "no-dev-status",
|
|
3090
|
-
name: "No Dev Status",
|
|
3091
|
-
category: "handoff-risk",
|
|
3092
|
-
why: "Without dev status, developers cannot know if a design is ready",
|
|
3093
|
-
impact: "May implement designs that are still in progress",
|
|
3094
|
-
fix: "Mark frames as 'Ready for Dev' or 'Completed' when appropriate"
|
|
3095
|
-
};
|
|
3096
|
-
var noDevStatusCheck = (node, context) => {
|
|
3097
|
-
if (node.type !== "FRAME") return null;
|
|
3098
|
-
if (context.depth > 1) return null;
|
|
3099
|
-
if (node.devStatus) return null;
|
|
3100
|
-
return {
|
|
3101
|
-
ruleId: noDevStatusDef.id,
|
|
3102
|
-
nodeId: node.id,
|
|
3103
|
-
nodePath: context.path.join(" > "),
|
|
3104
|
-
message: `"${node.name}" has no dev status set`
|
|
3105
|
-
};
|
|
3106
|
-
};
|
|
3107
|
-
defineRule({
|
|
3108
|
-
definition: noDevStatusDef,
|
|
3109
|
-
check: noDevStatusCheck
|
|
3110
|
-
});
|
|
3586
|
+
}
|
|
3587
|
+
|
|
3588
|
+
// src/core/monitoring/keys.ts
|
|
3589
|
+
var POSTHOG_API_KEY = "phc_rBFeG140KqJLpUnlpYDEFgdMM6JozZeqQsf9twXf5Dq" ;
|
|
3590
|
+
var SENTRY_DSN = "https://80a836a8300b25f17ef5bbf23afb5b3a@o4511080656207872.ingest.us.sentry.io/4511080661319680" ;
|
|
3111
3591
|
|
|
3112
3592
|
// src/mcp/server.ts
|
|
3113
3593
|
config();
|
|
@@ -3127,16 +3607,21 @@ Two ways to provide design data:
|
|
|
3127
3607
|
|
|
3128
3608
|
Typical flow with Figma MCP (recommended, no token needed):
|
|
3129
3609
|
Step 1: Call the official Figma MCP's get_metadata tool to get the node tree
|
|
3130
|
-
Step 2:
|
|
3610
|
+
Step 2: Call the official Figma MCP's get_design_context tool on the same node to get style data
|
|
3611
|
+
Step 3: Pass get_metadata result as designData and get_design_context code as designContext to this tool
|
|
3612
|
+
|
|
3613
|
+
The designContext parameter enriches analysis with style information (colors, layout, spacing, effects)
|
|
3614
|
+
that get_metadata alone cannot provide. Without it, token and layout rules may not fire.
|
|
3131
3615
|
|
|
3132
3616
|
IMPORTANT \u2014 Before calling this tool, check which data source is available:
|
|
3133
|
-
- If the official Figma MCP (https://mcp.figma.com/mcp) is connected: use get_metadata \u2192 designData flow. No token needed.
|
|
3617
|
+
- If the official Figma MCP (https://mcp.figma.com/mcp) is connected: use get_metadata + get_design_context \u2192 designData + designContext flow. No token needed.
|
|
3134
3618
|
- If Figma MCP is NOT connected: use the input parameter with a Figma URL. This requires a FIGMA_TOKEN.
|
|
3135
3619
|
Tell the user: "The official Figma MCP server is not connected. To use without a token, set it up:
|
|
3136
3620
|
claude mcp add -s project -t http figma https://mcp.figma.com/mcp
|
|
3137
3621
|
Otherwise, provide a Figma API token via FIGMA_TOKEN env var or the token parameter."`,
|
|
3138
3622
|
{
|
|
3139
3623
|
designData: z.string().optional().describe("Figma node data from Figma MCP get_metadata (XML or JSON). Pass this instead of input when using Figma MCP."),
|
|
3624
|
+
designContext: z.string().optional().describe("Code output from Figma MCP get_design_context. Enriches designData with style info (colors, layout, spacing, effects). Highly recommended alongside designData."),
|
|
3140
3625
|
input: z.string().optional().describe("Figma URL. Used when designData is not provided. Requires FIGMA_TOKEN."),
|
|
3141
3626
|
fileKey: z.string().optional().describe("Figma file key (used with designData to generate deep links)"),
|
|
3142
3627
|
fileName: z.string().optional().describe("Figma file name (used with designData for display)"),
|
|
@@ -3152,13 +3637,16 @@ IMPORTANT \u2014 Before calling this tool, check which data source is available:
|
|
|
3152
3637
|
openWorldHint: true,
|
|
3153
3638
|
title: "Analyze Figma Design"
|
|
3154
3639
|
},
|
|
3155
|
-
async ({ designData, input, fileKey, fileName, token, preset, targetNodeId, configPath, customRulesPath }) => {
|
|
3640
|
+
async ({ designData, designContext, input, fileKey, fileName, token, preset, targetNodeId, configPath, customRulesPath }) => {
|
|
3156
3641
|
trackEvent(EVENTS.MCP_TOOL_CALLED, { tool: "analyze" });
|
|
3157
3642
|
try {
|
|
3158
3643
|
let file;
|
|
3159
3644
|
let nodeId;
|
|
3160
3645
|
if (designData) {
|
|
3161
3646
|
file = parseDesignData(designData, fileKey ?? "unknown", fileName);
|
|
3647
|
+
if (designContext) {
|
|
3648
|
+
enrichWithDesignContext(file, designContext, targetNodeId);
|
|
3649
|
+
}
|
|
3162
3650
|
} else if (input) {
|
|
3163
3651
|
const loaded = await loadFile(input, token);
|
|
3164
3652
|
file = loaded.file;
|
|
@@ -3191,10 +3679,10 @@ IMPORTANT \u2014 Before calling this tool, check which data source is available:
|
|
|
3191
3679
|
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")}`;
|
|
3192
3680
|
ensureReportsDir();
|
|
3193
3681
|
const reportPath = `${getReportsDir()}/report-${ts}-${file.fileKey}.html`;
|
|
3194
|
-
await new Promise((
|
|
3682
|
+
await new Promise((resolve6, reject) => {
|
|
3195
3683
|
writeFile(reportPath, html, "utf-8", (err) => {
|
|
3196
3684
|
if (err) reject(err);
|
|
3197
|
-
else
|
|
3685
|
+
else resolve6();
|
|
3198
3686
|
});
|
|
3199
3687
|
});
|
|
3200
3688
|
const openCmd = process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open";
|
|
@@ -3217,6 +3705,7 @@ IMPORTANT \u2014 Before calling this tool, check which data source is available:
|
|
|
3217
3705
|
type: "text",
|
|
3218
3706
|
text: JSON.stringify(
|
|
3219
3707
|
{
|
|
3708
|
+
version: pkg.version,
|
|
3220
3709
|
fileName: file.name,
|
|
3221
3710
|
nodeCount: result.nodeCount,
|
|
3222
3711
|
maxDepth: result.maxDepth,
|
|
@@ -3309,16 +3798,16 @@ Use this when the user asks about customization, configuration, rule settings, o
|
|
|
3309
3798
|
},
|
|
3310
3799
|
async ({ topic }) => {
|
|
3311
3800
|
const { readFile: readFile4 } = await import('fs/promises');
|
|
3312
|
-
const { resolve:
|
|
3801
|
+
const { resolve: resolve6, dirname: dirname2 } = await import('path');
|
|
3313
3802
|
const { fileURLToPath } = await import('url');
|
|
3314
3803
|
try {
|
|
3315
|
-
const __dirname =
|
|
3316
|
-
const docPath =
|
|
3804
|
+
const __dirname = dirname2(fileURLToPath(import.meta.url));
|
|
3805
|
+
const docPath = resolve6(__dirname, "../../docs/CUSTOMIZATION.md");
|
|
3317
3806
|
let content;
|
|
3318
3807
|
try {
|
|
3319
3808
|
content = await readFile4(docPath, "utf-8");
|
|
3320
3809
|
} catch {
|
|
3321
|
-
const altPath =
|
|
3810
|
+
const altPath = resolve6(__dirname, "../docs/CUSTOMIZATION.md");
|
|
3322
3811
|
content = await readFile4(altPath, "utf-8");
|
|
3323
3812
|
}
|
|
3324
3813
|
if (topic && topic !== "all") {
|
|
@@ -3337,7 +3826,9 @@ Use this when the user asks about customization, configuration, rule settings, o
|
|
|
3337
3826
|
}
|
|
3338
3827
|
}
|
|
3339
3828
|
return {
|
|
3340
|
-
content: [{ type: "text", text:
|
|
3829
|
+
content: [{ type: "text", text: `canicode v${pkg.version}
|
|
3830
|
+
|
|
3831
|
+
${content}` }]
|
|
3341
3832
|
};
|
|
3342
3833
|
} catch {
|
|
3343
3834
|
return {
|