canicode 0.8.0 → 0.8.2
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/dist/cli/index.js +3165 -2932
- package/dist/cli/index.js.map +1 -1
- package/dist/index.d.ts +7 -2
- package/dist/index.js +22 -2
- package/dist/index.js.map +1 -1
- package/dist/mcp/server.js +2062 -1820
- package/dist/mcp/server.js.map +1 -1
- package/package.json +1 -1
package/dist/mcp/server.js
CHANGED
|
@@ -1361,2006 +1361,2253 @@ function parseDesignData(data, fileKey, fileName) {
|
|
|
1361
1361
|
);
|
|
1362
1362
|
}
|
|
1363
1363
|
|
|
1364
|
-
//
|
|
1365
|
-
var
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
"
|
|
1369
|
-
|
|
1364
|
+
// package.json
|
|
1365
|
+
var version = "0.8.2";
|
|
1366
|
+
var AnalysisNodeTypeSchema = z.enum([
|
|
1367
|
+
"DOCUMENT",
|
|
1368
|
+
"CANVAS",
|
|
1369
|
+
"FRAME",
|
|
1370
|
+
"GROUP",
|
|
1371
|
+
"SECTION",
|
|
1372
|
+
"COMPONENT",
|
|
1373
|
+
"COMPONENT_SET",
|
|
1374
|
+
"INSTANCE",
|
|
1375
|
+
"RECTANGLE",
|
|
1376
|
+
"ELLIPSE",
|
|
1377
|
+
"VECTOR",
|
|
1378
|
+
"TEXT",
|
|
1379
|
+
"LINE",
|
|
1380
|
+
"BOOLEAN_OPERATION",
|
|
1381
|
+
"STAR",
|
|
1382
|
+
"REGULAR_POLYGON",
|
|
1383
|
+
"SLICE",
|
|
1384
|
+
"STICKY",
|
|
1385
|
+
"SHAPE_WITH_TEXT",
|
|
1386
|
+
"CONNECTOR",
|
|
1387
|
+
"WIDGET",
|
|
1388
|
+
"EMBED",
|
|
1389
|
+
"LINK_UNFURL",
|
|
1390
|
+
"TABLE",
|
|
1391
|
+
"TABLE_CELL"
|
|
1392
|
+
]);
|
|
1393
|
+
var LayoutModeSchema = z.enum(["NONE", "HORIZONTAL", "VERTICAL"]);
|
|
1394
|
+
var LayoutAlignSchema = z.enum(["MIN", "CENTER", "MAX", "STRETCH", "INHERIT"]);
|
|
1395
|
+
var LayoutPositioningSchema = z.enum(["AUTO", "ABSOLUTE"]);
|
|
1396
|
+
var BaseAnalysisNodeSchema = z.object({
|
|
1397
|
+
// Basic identification
|
|
1398
|
+
id: z.string(),
|
|
1399
|
+
name: z.string(),
|
|
1400
|
+
type: AnalysisNodeTypeSchema,
|
|
1401
|
+
visible: z.boolean().default(true),
|
|
1402
|
+
// Layout analysis
|
|
1403
|
+
layoutMode: LayoutModeSchema.optional(),
|
|
1404
|
+
layoutAlign: LayoutAlignSchema.optional(),
|
|
1405
|
+
layoutPositioning: LayoutPositioningSchema.optional(),
|
|
1406
|
+
layoutSizingHorizontal: z.enum(["FIXED", "HUG", "FILL"]).optional(),
|
|
1407
|
+
layoutSizingVertical: z.enum(["FIXED", "HUG", "FILL"]).optional(),
|
|
1408
|
+
primaryAxisAlignItems: z.string().optional(),
|
|
1409
|
+
counterAxisAlignItems: z.string().optional(),
|
|
1410
|
+
itemSpacing: z.number().optional(),
|
|
1411
|
+
paddingLeft: z.number().optional(),
|
|
1412
|
+
paddingRight: z.number().optional(),
|
|
1413
|
+
paddingTop: z.number().optional(),
|
|
1414
|
+
paddingBottom: z.number().optional(),
|
|
1415
|
+
// Size/position analysis
|
|
1416
|
+
absoluteBoundingBox: z.object({
|
|
1417
|
+
x: z.number(),
|
|
1418
|
+
y: z.number(),
|
|
1419
|
+
width: z.number(),
|
|
1420
|
+
height: z.number()
|
|
1421
|
+
}).nullable().optional(),
|
|
1422
|
+
// Component analysis
|
|
1423
|
+
componentId: z.string().optional(),
|
|
1424
|
+
componentPropertyDefinitions: z.record(z.string(), z.unknown()).optional(),
|
|
1425
|
+
componentProperties: z.record(z.string(), z.unknown()).optional(),
|
|
1426
|
+
// Style/token analysis
|
|
1427
|
+
styles: z.record(z.string(), z.string()).optional(),
|
|
1428
|
+
fills: z.array(z.unknown()).optional(),
|
|
1429
|
+
strokes: z.array(z.unknown()).optional(),
|
|
1430
|
+
effects: z.array(z.unknown()).optional(),
|
|
1431
|
+
// Variable binding analysis (design tokens)
|
|
1432
|
+
boundVariables: z.record(z.string(), z.unknown()).optional(),
|
|
1433
|
+
// Text analysis
|
|
1434
|
+
characters: z.string().optional(),
|
|
1435
|
+
style: z.record(z.string(), z.unknown()).optional(),
|
|
1436
|
+
// Handoff analysis
|
|
1437
|
+
devStatus: z.object({
|
|
1438
|
+
type: z.enum(["NONE", "READY_FOR_DEV", "COMPLETED"]),
|
|
1439
|
+
description: z.string().optional()
|
|
1440
|
+
}).optional(),
|
|
1441
|
+
// Naming analysis metadata
|
|
1442
|
+
isAsset: z.boolean().optional()
|
|
1443
|
+
});
|
|
1444
|
+
var AnalysisNodeSchema = BaseAnalysisNodeSchema.extend({
|
|
1445
|
+
children: z.lazy(() => AnalysisNodeSchema.array().optional())
|
|
1446
|
+
});
|
|
1447
|
+
z.object({
|
|
1448
|
+
fileKey: z.string(),
|
|
1449
|
+
name: z.string(),
|
|
1450
|
+
lastModified: z.string(),
|
|
1451
|
+
version: z.string(),
|
|
1452
|
+
document: AnalysisNodeSchema,
|
|
1453
|
+
components: z.record(
|
|
1454
|
+
z.string(),
|
|
1455
|
+
z.object({
|
|
1456
|
+
key: z.string(),
|
|
1457
|
+
name: z.string(),
|
|
1458
|
+
description: z.string()
|
|
1459
|
+
})
|
|
1460
|
+
),
|
|
1461
|
+
styles: z.record(
|
|
1462
|
+
z.string(),
|
|
1463
|
+
z.object({
|
|
1464
|
+
key: z.string(),
|
|
1465
|
+
name: z.string(),
|
|
1466
|
+
styleType: z.string()
|
|
1467
|
+
})
|
|
1468
|
+
)
|
|
1469
|
+
});
|
|
1470
|
+
var IssueSchema = z.object({
|
|
1471
|
+
nodeId: z.string(),
|
|
1472
|
+
nodePath: z.string(),
|
|
1473
|
+
figmaDeepLink: z.string().url(),
|
|
1474
|
+
ruleId: z.string(),
|
|
1475
|
+
message: z.string(),
|
|
1476
|
+
severity: SeveritySchema
|
|
1477
|
+
});
|
|
1478
|
+
var CategoryScoreSchema = z.object({
|
|
1479
|
+
category: CategorySchema,
|
|
1480
|
+
score: z.number().min(0).max(100),
|
|
1481
|
+
maxScore: z.number().min(0).max(100),
|
|
1482
|
+
issueCount: z.object({
|
|
1483
|
+
error: z.number().int().min(0),
|
|
1484
|
+
warning: z.number().int().min(0),
|
|
1485
|
+
info: z.number().int().min(0)
|
|
1486
|
+
})
|
|
1487
|
+
});
|
|
1488
|
+
var ReportMetadataSchema = z.object({
|
|
1489
|
+
fileKey: z.string(),
|
|
1490
|
+
fileName: z.string(),
|
|
1491
|
+
analyzedAt: z.string().datetime(),
|
|
1492
|
+
version: z.string()
|
|
1493
|
+
});
|
|
1494
|
+
z.object({
|
|
1495
|
+
metadata: ReportMetadataSchema,
|
|
1496
|
+
totalScore: z.number().min(0).max(100),
|
|
1497
|
+
categoryScores: z.array(CategoryScoreSchema),
|
|
1498
|
+
issues: z.array(IssueSchema),
|
|
1499
|
+
summary: z.object({
|
|
1500
|
+
totalNodes: z.number().int().min(0),
|
|
1501
|
+
analyzedNodes: z.number().int().min(0),
|
|
1502
|
+
errorCount: z.number().int().min(0),
|
|
1503
|
+
warningCount: z.number().int().min(0),
|
|
1504
|
+
infoCount: z.number().int().min(0)
|
|
1505
|
+
})
|
|
1506
|
+
});
|
|
1507
|
+
|
|
1508
|
+
// src/core/rules/excluded-names.ts
|
|
1509
|
+
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;
|
|
1510
|
+
function isExcludedName(name) {
|
|
1511
|
+
return EXCLUDED_NAME_PATTERN.test(name);
|
|
1512
|
+
}
|
|
1513
|
+
|
|
1514
|
+
// src/core/rules/layout/index.ts
|
|
1515
|
+
function isContainerNode(node) {
|
|
1516
|
+
return node.type === "FRAME" || node.type === "GROUP" || node.type === "COMPONENT";
|
|
1517
|
+
}
|
|
1518
|
+
function hasAutoLayout(node) {
|
|
1519
|
+
return node.layoutMode !== void 0 && node.layoutMode !== "NONE";
|
|
1520
|
+
}
|
|
1521
|
+
function hasTextContent(node) {
|
|
1522
|
+
return node.type === "TEXT" || (node.children?.some((c) => c.type === "TEXT") ?? false);
|
|
1523
|
+
}
|
|
1524
|
+
var noAutoLayoutDef = {
|
|
1525
|
+
id: "no-auto-layout",
|
|
1526
|
+
name: "No Auto Layout",
|
|
1527
|
+
category: "layout",
|
|
1528
|
+
why: "Frames without Auto Layout require manual positioning for every element",
|
|
1529
|
+
impact: "Layout breaks on content changes, harder to maintain and scale",
|
|
1530
|
+
fix: "Apply Auto Layout to the frame with appropriate direction and spacing"
|
|
1370
1531
|
};
|
|
1371
|
-
var
|
|
1372
|
-
|
|
1373
|
-
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
|
|
1532
|
+
var noAutoLayoutCheck = (node, context) => {
|
|
1533
|
+
if (node.type !== "FRAME") return null;
|
|
1534
|
+
if (hasAutoLayout(node)) return null;
|
|
1535
|
+
if (!node.children || node.children.length === 0) return null;
|
|
1536
|
+
return {
|
|
1537
|
+
ruleId: noAutoLayoutDef.id,
|
|
1538
|
+
nodeId: node.id,
|
|
1539
|
+
nodePath: context.path.join(" > "),
|
|
1540
|
+
message: `Frame "${node.name}" has no Auto Layout`
|
|
1541
|
+
};
|
|
1378
1542
|
};
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
"
|
|
1385
|
-
"
|
|
1543
|
+
defineRule({
|
|
1544
|
+
definition: noAutoLayoutDef,
|
|
1545
|
+
check: noAutoLayoutCheck
|
|
1546
|
+
});
|
|
1547
|
+
var absolutePositionInAutoLayoutDef = {
|
|
1548
|
+
id: "absolute-position-in-auto-layout",
|
|
1549
|
+
name: "Absolute Position in Auto Layout",
|
|
1550
|
+
category: "layout",
|
|
1551
|
+
why: "Absolute positioning inside Auto Layout breaks the automatic flow",
|
|
1552
|
+
impact: "Element will not respond to sibling changes, may overlap unexpectedly",
|
|
1553
|
+
fix: "Remove absolute positioning or use proper Auto Layout alignment"
|
|
1386
1554
|
};
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
if (
|
|
1392
|
-
|
|
1393
|
-
|
|
1394
|
-
|
|
1395
|
-
if (percentage >= 75) return "B";
|
|
1396
|
-
if (percentage >= 70) return "C+";
|
|
1397
|
-
if (percentage >= 65) return "C";
|
|
1398
|
-
if (percentage >= 50) return "D";
|
|
1399
|
-
return "F";
|
|
1400
|
-
}
|
|
1401
|
-
function clamp(value, min, max) {
|
|
1402
|
-
return Math.max(min, Math.min(max, value));
|
|
1555
|
+
function isSmallRelativeToParent(node, parent) {
|
|
1556
|
+
const nodeBB = node.absoluteBoundingBox;
|
|
1557
|
+
const parentBB = parent.absoluteBoundingBox;
|
|
1558
|
+
if (!nodeBB || !parentBB) return false;
|
|
1559
|
+
if (parentBB.width === 0 || parentBB.height === 0) return false;
|
|
1560
|
+
const widthRatio = nodeBB.width / parentBB.width;
|
|
1561
|
+
const heightRatio = nodeBB.height / parentBB.height;
|
|
1562
|
+
return widthRatio < 0.25 && heightRatio < 0.25;
|
|
1403
1563
|
}
|
|
1404
|
-
|
|
1405
|
-
|
|
1406
|
-
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
|
|
1410
|
-
|
|
1411
|
-
|
|
1412
|
-
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
categoryScores[category].weightedIssueCount += SEVERITY_DENSITY_WEIGHT[severity];
|
|
1418
|
-
uniqueRulesPerCategory.get(category).add(ruleId);
|
|
1419
|
-
}
|
|
1420
|
-
for (const category of CATEGORIES) {
|
|
1421
|
-
const catScore = categoryScores[category];
|
|
1422
|
-
const uniqueRules = uniqueRulesPerCategory.get(category);
|
|
1423
|
-
const totalRules = TOTAL_RULES_PER_CATEGORY[category];
|
|
1424
|
-
catScore.uniqueRuleCount = uniqueRules.size;
|
|
1425
|
-
let densityScore = 100;
|
|
1426
|
-
if (nodeCount > 0 && catScore.issueCount > 0) {
|
|
1427
|
-
const density = catScore.weightedIssueCount / nodeCount;
|
|
1428
|
-
densityScore = clamp(Math.round(100 - density * 100), 0, 100);
|
|
1429
|
-
}
|
|
1430
|
-
catScore.densityScore = densityScore;
|
|
1431
|
-
let diversityScore = 100;
|
|
1432
|
-
if (catScore.issueCount > 0) {
|
|
1433
|
-
const diversityRatio = uniqueRules.size / totalRules;
|
|
1434
|
-
diversityScore = clamp(Math.round((1 - diversityRatio) * 100), 0, 100);
|
|
1435
|
-
}
|
|
1436
|
-
catScore.diversityScore = diversityScore;
|
|
1437
|
-
const combinedScore = densityScore * DENSITY_WEIGHT + diversityScore * DIVERSITY_WEIGHT;
|
|
1438
|
-
catScore.percentage = catScore.issueCount > 0 ? clamp(Math.round(combinedScore), SCORE_FLOOR, 100) : 100;
|
|
1439
|
-
catScore.score = catScore.percentage;
|
|
1440
|
-
catScore.maxScore = 100;
|
|
1441
|
-
}
|
|
1442
|
-
let totalWeight = 0;
|
|
1443
|
-
let weightedSum = 0;
|
|
1444
|
-
for (const category of CATEGORIES) {
|
|
1445
|
-
const weight = CATEGORY_WEIGHT[category];
|
|
1446
|
-
weightedSum += categoryScores[category].percentage * weight;
|
|
1447
|
-
totalWeight += weight;
|
|
1448
|
-
}
|
|
1449
|
-
const overallPercentage = totalWeight > 0 ? Math.round(weightedSum / totalWeight) : 100;
|
|
1450
|
-
const summary = {
|
|
1451
|
-
totalIssues: result.issues.length,
|
|
1452
|
-
blocking: 0,
|
|
1453
|
-
risk: 0,
|
|
1454
|
-
missingInfo: 0,
|
|
1455
|
-
suggestion: 0,
|
|
1456
|
-
nodeCount
|
|
1564
|
+
var absolutePositionInAutoLayoutCheck = (node, context) => {
|
|
1565
|
+
if (!context.parent) return null;
|
|
1566
|
+
if (!hasAutoLayout(context.parent)) return null;
|
|
1567
|
+
if (node.layoutPositioning !== "ABSOLUTE") return null;
|
|
1568
|
+
if (node.type === "VECTOR" || node.type === "BOOLEAN_OPERATION" || node.type === "LINE" || node.type === "ELLIPSE" || node.type === "STAR" || node.type === "REGULAR_POLYGON") return null;
|
|
1569
|
+
if (isExcludedName(node.name)) return null;
|
|
1570
|
+
if (isSmallRelativeToParent(node, context.parent)) return null;
|
|
1571
|
+
if (context.parent.type === "COMPONENT") return null;
|
|
1572
|
+
return {
|
|
1573
|
+
ruleId: absolutePositionInAutoLayoutDef.id,
|
|
1574
|
+
nodeId: node.id,
|
|
1575
|
+
nodePath: context.path.join(" > "),
|
|
1576
|
+
message: `"${node.name}" uses absolute positioning inside Auto Layout parent "${context.parent.name}". If intentional (badge, overlay, close button), rename to badge-*, overlay-*, close-* to suppress this warning.`
|
|
1457
1577
|
};
|
|
1458
|
-
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
|
|
1462
|
-
|
|
1463
|
-
|
|
1464
|
-
|
|
1465
|
-
|
|
1466
|
-
|
|
1467
|
-
|
|
1468
|
-
|
|
1469
|
-
|
|
1470
|
-
|
|
1471
|
-
|
|
1472
|
-
|
|
1578
|
+
};
|
|
1579
|
+
defineRule({
|
|
1580
|
+
definition: absolutePositionInAutoLayoutDef,
|
|
1581
|
+
check: absolutePositionInAutoLayoutCheck
|
|
1582
|
+
});
|
|
1583
|
+
var fixedWidthInResponsiveContextDef = {
|
|
1584
|
+
id: "fixed-width-in-responsive-context",
|
|
1585
|
+
name: "Fixed Width in Responsive Context",
|
|
1586
|
+
category: "layout",
|
|
1587
|
+
why: "Fixed width inside Auto Layout prevents responsive behavior",
|
|
1588
|
+
impact: "Content will not adapt to container size changes",
|
|
1589
|
+
fix: "Use 'Fill' or 'Hug' instead of fixed width"
|
|
1590
|
+
};
|
|
1591
|
+
var fixedWidthInResponsiveContextCheck = (node, context) => {
|
|
1592
|
+
if (!context.parent) return null;
|
|
1593
|
+
if (!hasAutoLayout(context.parent)) return null;
|
|
1594
|
+
if (!isContainerNode(node)) return null;
|
|
1595
|
+
if (node.layoutSizingHorizontal) {
|
|
1596
|
+
if (node.layoutSizingHorizontal !== "FIXED") return null;
|
|
1597
|
+
} else {
|
|
1598
|
+
if (node.layoutAlign === "STRETCH") return null;
|
|
1599
|
+
if (!node.absoluteBoundingBox) return null;
|
|
1600
|
+
if (node.layoutAlign !== "INHERIT") return null;
|
|
1473
1601
|
}
|
|
1602
|
+
if (isExcludedName(node.name)) return null;
|
|
1474
1603
|
return {
|
|
1475
|
-
|
|
1476
|
-
|
|
1477
|
-
|
|
1478
|
-
|
|
1479
|
-
grade: calculateGrade(overallPercentage)
|
|
1480
|
-
},
|
|
1481
|
-
byCategory: categoryScores,
|
|
1482
|
-
summary
|
|
1604
|
+
ruleId: fixedWidthInResponsiveContextDef.id,
|
|
1605
|
+
nodeId: node.id,
|
|
1606
|
+
nodePath: context.path.join(" > "),
|
|
1607
|
+
message: `"${node.name}" has fixed width inside Auto Layout`
|
|
1483
1608
|
};
|
|
1484
|
-
}
|
|
1485
|
-
|
|
1486
|
-
|
|
1487
|
-
|
|
1488
|
-
scores[category] = {
|
|
1489
|
-
category,
|
|
1490
|
-
score: 100,
|
|
1491
|
-
maxScore: 100,
|
|
1492
|
-
percentage: 100,
|
|
1493
|
-
issueCount: 0,
|
|
1494
|
-
uniqueRuleCount: 0,
|
|
1495
|
-
weightedIssueCount: 0,
|
|
1496
|
-
densityScore: 100,
|
|
1497
|
-
diversityScore: 100,
|
|
1498
|
-
bySeverity: {
|
|
1499
|
-
blocking: 0,
|
|
1500
|
-
risk: 0,
|
|
1501
|
-
"missing-info": 0,
|
|
1502
|
-
suggestion: 0
|
|
1503
|
-
}
|
|
1504
|
-
};
|
|
1505
|
-
}
|
|
1506
|
-
return scores;
|
|
1507
|
-
}
|
|
1508
|
-
function formatScoreSummary(report) {
|
|
1509
|
-
const lines = [];
|
|
1510
|
-
lines.push(`Overall: ${report.overall.grade} (${report.overall.percentage}%)`);
|
|
1511
|
-
lines.push("");
|
|
1512
|
-
lines.push("By Category:");
|
|
1513
|
-
for (const category of CATEGORIES) {
|
|
1514
|
-
const cat = report.byCategory[category];
|
|
1515
|
-
lines.push(` ${category}: ${cat.percentage}% (${cat.issueCount} issues, ${cat.uniqueRuleCount} rules)`);
|
|
1516
|
-
}
|
|
1517
|
-
lines.push("");
|
|
1518
|
-
lines.push("Issues:");
|
|
1519
|
-
lines.push(` Blocking: ${report.summary.blocking}`);
|
|
1520
|
-
lines.push(` Risk: ${report.summary.risk}`);
|
|
1521
|
-
lines.push(` Missing Info: ${report.summary.missingInfo}`);
|
|
1522
|
-
lines.push(` Suggestion: ${report.summary.suggestion}`);
|
|
1523
|
-
lines.push(` Total: ${report.summary.totalIssues}`);
|
|
1524
|
-
return lines.join("\n");
|
|
1525
|
-
}
|
|
1526
|
-
|
|
1527
|
-
// src/core/ui-constants.ts
|
|
1528
|
-
var GAUGE_R = 54;
|
|
1529
|
-
var GAUGE_C = Math.round(2 * Math.PI * GAUGE_R);
|
|
1530
|
-
var CATEGORY_DESCRIPTIONS = {
|
|
1531
|
-
layout: "Auto Layout, responsive constraints, nesting depth, absolute positioning",
|
|
1532
|
-
token: "Design token binding for colors, fonts, shadows, spacing grid",
|
|
1533
|
-
component: "Component reuse, detached instances, variant coverage",
|
|
1534
|
-
naming: "Semantic layer names, naming conventions, default names",
|
|
1535
|
-
"ai-readability": "Structure clarity for AI code generation, z-index, empty frames",
|
|
1536
|
-
"handoff-risk": "Hardcoded values, text truncation, image placeholders, dev status"
|
|
1537
|
-
};
|
|
1538
|
-
var SEVERITY_ORDER = [
|
|
1539
|
-
"blocking",
|
|
1540
|
-
"risk",
|
|
1541
|
-
"missing-info",
|
|
1542
|
-
"suggestion"
|
|
1543
|
-
];
|
|
1544
|
-
|
|
1545
|
-
// src/core/ui-helpers.ts
|
|
1546
|
-
function gaugeColor(pct) {
|
|
1547
|
-
if (pct >= 75) return "#22c55e";
|
|
1548
|
-
if (pct >= 50) return "#f59e0b";
|
|
1549
|
-
return "#ef4444";
|
|
1550
|
-
}
|
|
1551
|
-
function escapeHtml(text) {
|
|
1552
|
-
return text.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
1553
|
-
}
|
|
1554
|
-
function severityDot(sev) {
|
|
1555
|
-
const map = {
|
|
1556
|
-
blocking: "bg-red-500",
|
|
1557
|
-
risk: "bg-amber-500",
|
|
1558
|
-
"missing-info": "bg-zinc-400",
|
|
1559
|
-
suggestion: "bg-green-500"
|
|
1560
|
-
};
|
|
1561
|
-
return map[sev];
|
|
1562
|
-
}
|
|
1563
|
-
function severityBadge(sev) {
|
|
1564
|
-
const map = {
|
|
1565
|
-
blocking: "bg-red-500/10 text-red-600 border-red-500/20",
|
|
1566
|
-
risk: "bg-amber-500/10 text-amber-600 border-amber-500/20",
|
|
1567
|
-
"missing-info": "bg-zinc-500/10 text-zinc-600 border-zinc-500/20",
|
|
1568
|
-
suggestion: "bg-green-500/10 text-green-600 border-green-500/20"
|
|
1569
|
-
};
|
|
1570
|
-
return map[sev];
|
|
1571
|
-
}
|
|
1572
|
-
function scoreBadgeStyle(pct) {
|
|
1573
|
-
if (pct >= 75) return "bg-green-500/10 text-green-700 border-green-500/20";
|
|
1574
|
-
if (pct >= 50) return "bg-amber-500/10 text-amber-700 border-amber-500/20";
|
|
1575
|
-
return "bg-red-500/10 text-red-700 border-red-500/20";
|
|
1576
|
-
}
|
|
1577
|
-
function renderGaugeSvg(pct, size, strokeW, grade) {
|
|
1578
|
-
const offset = GAUGE_C * (1 - pct / 100);
|
|
1579
|
-
const color = gaugeColor(pct);
|
|
1580
|
-
if (grade) {
|
|
1581
|
-
return `<svg width="${size}" height="${size}" viewBox="0 0 120 120" class="gauge-svg block">
|
|
1582
|
-
<circle cx="60" cy="60" r="${GAUGE_R}" fill="none" stroke-width="${strokeW}" stroke="#e4e4e7" class="stroke-border" />
|
|
1583
|
-
<circle cx="60" cy="60" r="${GAUGE_R}" fill="none" stroke="${color}" stroke-width="${strokeW}" stroke-linecap="round" stroke-dasharray="${GAUGE_C}" stroke-dashoffset="${offset}" transform="rotate(-90 60 60)" class="gauge-fill" />
|
|
1584
|
-
<text x="60" y="60" text-anchor="middle" dominant-baseline="central" fill="currentColor" font-size="48" font-weight="700" font-family="Inter,-apple-system,sans-serif" class="font-sans">${escapeHtml(grade)}</text>
|
|
1585
|
-
</svg>`;
|
|
1586
|
-
}
|
|
1587
|
-
return `<svg width="${size}" height="${size}" viewBox="0 0 120 120" class="gauge-svg block">
|
|
1588
|
-
<circle cx="60" cy="60" r="${GAUGE_R}" fill="none" stroke-width="${strokeW}" stroke="#e4e4e7" class="stroke-border" />
|
|
1589
|
-
<circle cx="60" cy="60" r="${GAUGE_R}" fill="none" stroke="${color}" stroke-width="${strokeW}" stroke-linecap="round" stroke-dasharray="${GAUGE_C}" stroke-dashoffset="${offset}" transform="rotate(-90 60 60)" class="gauge-fill" />
|
|
1590
|
-
<text x="60" y="62" text-anchor="middle" dominant-baseline="central" fill="currentColor" font-size="28" font-weight="700" font-family="Inter,-apple-system,sans-serif" class="font-sans">${pct}</text>
|
|
1591
|
-
</svg>`;
|
|
1592
|
-
}
|
|
1593
|
-
|
|
1594
|
-
// src/core/report-html/index.ts
|
|
1595
|
-
function generateHtmlReport(file, result, scores, options) {
|
|
1596
|
-
const screenshotMap = new Map(
|
|
1597
|
-
(options?.nodeScreenshots ?? []).map((ns) => [ns.nodeId, ns])
|
|
1598
|
-
);
|
|
1599
|
-
const figmaToken = options?.figmaToken;
|
|
1600
|
-
const quickWins = getQuickWins(result.issues, 5);
|
|
1601
|
-
const issuesByCategory = groupIssuesByCategory(result.issues);
|
|
1602
|
-
return `<!DOCTYPE html>
|
|
1603
|
-
<html lang="en" class="antialiased">
|
|
1604
|
-
<head>
|
|
1605
|
-
<meta charset="UTF-8">
|
|
1606
|
-
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
1607
|
-
<title>CanICode Report \u2014 ${esc(file.name)}</title>
|
|
1608
|
-
<script src="https://cdn.tailwindcss.com"></script>
|
|
1609
|
-
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
1610
|
-
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
1611
|
-
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
|
|
1612
|
-
<script>
|
|
1613
|
-
tailwind.config = {
|
|
1614
|
-
theme: {
|
|
1615
|
-
extend: {
|
|
1616
|
-
fontFamily: { sans: ['Inter', 'ui-sans-serif', 'system-ui', '-apple-system', 'sans-serif'] },
|
|
1617
|
-
colors: {
|
|
1618
|
-
border: 'hsl(240 5.9% 90%)',
|
|
1619
|
-
ring: 'hsl(240 5.9% 10%)',
|
|
1620
|
-
background: 'hsl(0 0% 100%)',
|
|
1621
|
-
foreground: 'hsl(240 10% 3.9%)',
|
|
1622
|
-
muted: { DEFAULT: 'hsl(240 4.8% 95.9%)', foreground: 'hsl(240 3.8% 46.1%)' },
|
|
1623
|
-
card: { DEFAULT: 'hsl(0 0% 100%)', foreground: 'hsl(240 10% 3.9%)' },
|
|
1624
|
-
},
|
|
1625
|
-
borderRadius: { lg: '0.5rem', md: 'calc(0.5rem - 2px)', sm: 'calc(0.5rem - 4px)' },
|
|
1626
|
-
}
|
|
1627
|
-
}
|
|
1628
|
-
}
|
|
1629
|
-
</script>
|
|
1630
|
-
<style>
|
|
1631
|
-
details summary::-webkit-details-marker { display: none; }
|
|
1632
|
-
details summary::marker { content: ""; }
|
|
1633
|
-
details summary { list-style: none; }
|
|
1634
|
-
.gauge-fill { transition: stroke-dashoffset 0.8s cubic-bezier(0.4,0,0.2,1); }
|
|
1635
|
-
@media print {
|
|
1636
|
-
.no-print { display: none !important; }
|
|
1637
|
-
.topbar-print { position: static !important; background: white !important; color: hsl(240 10% 3.9%) !important; }
|
|
1638
|
-
}
|
|
1639
|
-
</style>
|
|
1640
|
-
</head>
|
|
1641
|
-
<body class="bg-muted font-sans text-foreground min-h-screen">
|
|
1642
|
-
|
|
1643
|
-
<!-- Top Bar -->
|
|
1644
|
-
<header class="topbar-print sticky top-0 z-50 bg-zinc-950 text-white border-b border-zinc-800">
|
|
1645
|
-
<div class="max-w-[960px] mx-auto px-6 py-3 flex items-center gap-4">
|
|
1646
|
-
<span class="font-semibold text-sm tracking-tight">CanICode</span>
|
|
1647
|
-
<span class="text-zinc-400 text-sm truncate">${esc(file.name)}</span>
|
|
1648
|
-
<span class="ml-auto text-zinc-500 text-xs no-print">${(/* @__PURE__ */ new Date()).toLocaleDateString()}</span>
|
|
1649
|
-
</div>
|
|
1650
|
-
</header>
|
|
1651
|
-
|
|
1652
|
-
<main class="max-w-[960px] mx-auto px-6 pb-16">
|
|
1653
|
-
|
|
1654
|
-
<!-- Overall Score -->
|
|
1655
|
-
<section class="flex flex-col items-center pt-12 pb-6">
|
|
1656
|
-
${renderGaugeSvg(scores.overall.percentage, 200, 10, scores.overall.grade)}
|
|
1657
|
-
<div class="mt-3 text-center">
|
|
1658
|
-
<span class="text-lg font-semibold">${scores.overall.percentage}</span>
|
|
1659
|
-
<span class="text-muted-foreground text-sm ml-1">/ 100</span>
|
|
1660
|
-
</div>
|
|
1661
|
-
<p class="text-muted-foreground text-sm mt-1">Overall Score</p>
|
|
1662
|
-
</section>
|
|
1663
|
-
|
|
1664
|
-
<!-- Category Gauges -->
|
|
1665
|
-
<section class="bg-card border border-border rounded-lg shadow-sm p-6 mb-6">
|
|
1666
|
-
<div class="grid grid-cols-3 sm:grid-cols-6 gap-4">
|
|
1667
|
-
${CATEGORIES.map((cat) => {
|
|
1668
|
-
const cs = scores.byCategory[cat];
|
|
1669
|
-
const desc = CATEGORY_DESCRIPTIONS[cat];
|
|
1670
|
-
return ` <a href="#cat-${cat}" class="flex flex-col items-center group relative cursor-pointer no-underline text-foreground hover:opacity-80 transition-opacity">
|
|
1671
|
-
${renderGaugeSvg(cs.percentage, 100, 7)}
|
|
1672
|
-
<span class="text-xs font-medium mt-2.5 text-center leading-tight">${CATEGORY_LABELS[cat]}</span>
|
|
1673
|
-
<span class="text-[11px] text-muted-foreground">${cs.issueCount} issues</span>
|
|
1674
|
-
<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">
|
|
1675
|
-
${esc(desc)}
|
|
1676
|
-
<div class="absolute top-full left-1/2 -translate-x-1/2 border-4 border-transparent border-t-zinc-900"></div>
|
|
1677
|
-
</div>
|
|
1678
|
-
</a>`;
|
|
1679
|
-
}).join("\n")}
|
|
1680
|
-
</div>
|
|
1681
|
-
</section>
|
|
1682
|
-
|
|
1683
|
-
<!-- Issue Summary -->
|
|
1684
|
-
<section class="bg-card border border-border rounded-lg shadow-sm p-4 mb-6">
|
|
1685
|
-
<div class="flex flex-wrap items-center justify-center gap-6">
|
|
1686
|
-
${renderSummaryDot("bg-red-500", scores.summary.blocking, "Blocking")}
|
|
1687
|
-
${renderSummaryDot("bg-amber-500", scores.summary.risk, "Risk")}
|
|
1688
|
-
${renderSummaryDot("bg-zinc-400", scores.summary.missingInfo, "Missing Info")}
|
|
1689
|
-
${renderSummaryDot("bg-green-500", scores.summary.suggestion, "Suggestion")}
|
|
1690
|
-
<div class="border-l border-border pl-6 flex items-center gap-2">
|
|
1691
|
-
<span class="text-xl font-bold tracking-tight">${scores.summary.totalIssues}</span>
|
|
1692
|
-
<span class="text-sm text-muted-foreground">Total</span>
|
|
1693
|
-
</div>
|
|
1694
|
-
</div>
|
|
1695
|
-
</section>
|
|
1696
|
-
|
|
1697
|
-
${quickWins.length > 0 ? renderOpportunities(quickWins, file.fileKey) : ""}
|
|
1698
|
-
|
|
1699
|
-
<!-- Categories -->
|
|
1700
|
-
<div class="space-y-3">
|
|
1701
|
-
${CATEGORIES.map((cat) => renderCategory(cat, scores, issuesByCategory.get(cat) ?? [], file.fileKey, screenshotMap, figmaToken)).join("\n")}
|
|
1702
|
-
</div>
|
|
1703
|
-
|
|
1704
|
-
<!-- Footer -->
|
|
1705
|
-
<footer class="mt-12 pt-6 border-t border-border text-center">
|
|
1706
|
-
<p class="text-sm text-muted-foreground">Generated by <span class="font-semibold text-foreground">CanICode</span></p>
|
|
1707
|
-
<p class="text-xs text-muted-foreground/60 mt-1">${(/* @__PURE__ */ new Date()).toLocaleString()} \xB7 ${result.nodeCount} nodes \xB7 Max depth ${result.maxDepth}</p>
|
|
1708
|
-
</footer>
|
|
1709
|
-
|
|
1710
|
-
</main>
|
|
1711
|
-
|
|
1712
|
-
${figmaToken ? ` <script>
|
|
1713
|
-
const FIGMA_TOKEN = '${figmaToken}';
|
|
1714
|
-
async function postComment(btn) {
|
|
1715
|
-
const fileKey = btn.dataset.fileKey;
|
|
1716
|
-
const nodeId = btn.dataset.nodeId.replace(/-/g, ':');
|
|
1717
|
-
const rule = btn.dataset.rule;
|
|
1718
|
-
const message = btn.dataset.message;
|
|
1719
|
-
const path = btn.dataset.path;
|
|
1720
|
-
const fix = btn.dataset.fix;
|
|
1721
|
-
const why = btn.dataset.why;
|
|
1722
|
-
const impact = btn.dataset.impact;
|
|
1723
|
-
|
|
1724
|
-
const commentBody = '[CanICode] ' + rule + '\\n\\nFix: ' + fix + '\\nWhy: ' + why + '\\nImpact: ' + impact + '\\n\\n' + message + '\\nNode: ' + path;
|
|
1725
|
-
|
|
1726
|
-
btn.disabled = true;
|
|
1727
|
-
btn.textContent = 'Sending...';
|
|
1728
|
-
|
|
1729
|
-
try {
|
|
1730
|
-
const res = await fetch('https://api.figma.com/v1/files/' + fileKey + '/comments', {
|
|
1731
|
-
method: 'POST',
|
|
1732
|
-
headers: { 'X-FIGMA-TOKEN': FIGMA_TOKEN, 'Content-Type': 'application/json' },
|
|
1733
|
-
body: JSON.stringify({ message: commentBody, client_meta: { node_id: nodeId, node_offset: { x: 0, y: 0 } } }),
|
|
1734
|
-
});
|
|
1735
|
-
if (!res.ok) throw new Error(await res.text());
|
|
1736
|
-
btn.textContent = 'Sent \\u2713';
|
|
1737
|
-
btn.classList.remove('hover:bg-muted');
|
|
1738
|
-
btn.classList.add('text-green-600', 'border-green-500/30');
|
|
1739
|
-
} catch (e) {
|
|
1740
|
-
btn.textContent = 'Failed \\u2717';
|
|
1741
|
-
btn.classList.remove('hover:bg-muted');
|
|
1742
|
-
btn.classList.add('text-red-600', 'border-red-500/30');
|
|
1743
|
-
btn.disabled = false;
|
|
1744
|
-
console.error('Failed to post Figma comment:', e);
|
|
1745
|
-
}
|
|
1746
|
-
}
|
|
1747
|
-
</script>` : ""}
|
|
1748
|
-
</body>
|
|
1749
|
-
</html>`;
|
|
1750
|
-
}
|
|
1751
|
-
function renderSummaryDot(dotClass, count, label) {
|
|
1752
|
-
return `<div class="flex items-center gap-2">
|
|
1753
|
-
<span class="w-2.5 h-2.5 rounded-full ${dotClass}"></span>
|
|
1754
|
-
<span class="text-lg font-bold tracking-tight">${count}</span>
|
|
1755
|
-
<span class="text-sm text-muted-foreground">${label}</span>
|
|
1756
|
-
</div>`;
|
|
1757
|
-
}
|
|
1758
|
-
function renderOpportunities(issues, fileKey) {
|
|
1759
|
-
const maxAbs = issues.reduce((m, i) => Math.max(m, Math.abs(i.calculatedScore)), 1);
|
|
1760
|
-
return `
|
|
1761
|
-
<!-- Opportunities -->
|
|
1762
|
-
<section class="bg-card border border-border rounded-lg shadow-sm mb-6 overflow-hidden">
|
|
1763
|
-
<div class="px-6 py-4 border-b border-border">
|
|
1764
|
-
<h2 class="text-sm font-semibold flex items-center gap-2">
|
|
1765
|
-
<span class="w-2 h-2 rounded-full bg-red-500"></span>
|
|
1766
|
-
Opportunities
|
|
1767
|
-
</h2>
|
|
1768
|
-
<p class="text-xs text-muted-foreground mt-1">Top blocking issues \u2014 fix these first for the biggest improvement.</p>
|
|
1769
|
-
</div>
|
|
1770
|
-
<div class="divide-y divide-border">
|
|
1771
|
-
${issues.map((issue) => {
|
|
1772
|
-
const def = issue.rule.definition;
|
|
1773
|
-
const link = buildFigmaDeepLink(fileKey, issue.violation.nodeId);
|
|
1774
|
-
const barW = Math.round(Math.abs(issue.calculatedScore) / maxAbs * 100);
|
|
1775
|
-
return ` <div class="px-6 py-3 flex items-center gap-4 hover:bg-muted/50 transition-colors">
|
|
1776
|
-
<div class="flex-1 min-w-0">
|
|
1777
|
-
<div class="text-sm font-medium truncate">${esc(def.name)}</div>
|
|
1778
|
-
<div class="text-xs text-muted-foreground truncate mt-0.5">${esc(issue.violation.message)}</div>
|
|
1779
|
-
</div>
|
|
1780
|
-
<div class="w-32 flex items-center gap-2 shrink-0">
|
|
1781
|
-
<div class="flex-1 h-1.5 bg-muted rounded-full overflow-hidden">
|
|
1782
|
-
<div class="h-full bg-red-500 rounded-full" style="width:${barW}%"></div>
|
|
1783
|
-
</div>
|
|
1784
|
-
<span class="text-xs font-medium text-red-600 w-12 text-right">${issue.calculatedScore}</span>
|
|
1785
|
-
</div>
|
|
1786
|
-
<a href="${link}" target="_blank" rel="noopener" class="text-xs text-muted-foreground hover:text-foreground shrink-0 no-print">Figma \u2192</a>
|
|
1787
|
-
</div>`;
|
|
1788
|
-
}).join("\n")}
|
|
1789
|
-
</div>
|
|
1790
|
-
</section>`;
|
|
1791
|
-
}
|
|
1792
|
-
function renderCategory(cat, scores, issues, fileKey, screenshotMap, figmaToken) {
|
|
1793
|
-
const cs = scores.byCategory[cat];
|
|
1794
|
-
const hasProblems = issues.some((i) => i.config.severity === "blocking" || i.config.severity === "risk");
|
|
1795
|
-
const bySeverity = /* @__PURE__ */ new Map();
|
|
1796
|
-
for (const sev of SEVERITY_ORDER) bySeverity.set(sev, []);
|
|
1797
|
-
for (const issue of issues) bySeverity.get(issue.config.severity)?.push(issue);
|
|
1798
|
-
return `
|
|
1799
|
-
<details id="cat-${cat}" class="bg-card border border-border rounded-lg shadow-sm overflow-hidden group"${hasProblems ? " open" : ""}>
|
|
1800
|
-
<summary class="px-5 py-3.5 flex items-center gap-3 cursor-pointer hover:bg-muted/50 transition-colors select-none">
|
|
1801
|
-
<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>
|
|
1802
|
-
<div class="flex-1 min-w-0">
|
|
1803
|
-
<div class="text-sm font-semibold">${CATEGORY_LABELS[cat]}</div>
|
|
1804
|
-
<div class="text-xs text-muted-foreground">${esc(CATEGORY_DESCRIPTIONS[cat])}</div>
|
|
1805
|
-
</div>
|
|
1806
|
-
<span class="text-xs text-muted-foreground">${cs.issueCount} issues</span>
|
|
1807
|
-
<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>
|
|
1808
|
-
</summary>
|
|
1809
|
-
<div class="border-t border-border">
|
|
1810
|
-
${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")}
|
|
1811
|
-
</div>
|
|
1812
|
-
</details>`;
|
|
1813
|
-
}
|
|
1814
|
-
function renderSeverityGroup(sev, issues, fileKey, screenshotMap, figmaToken) {
|
|
1815
|
-
return ` <div class="px-5 py-3">
|
|
1816
|
-
<div class="flex items-center gap-2 mb-2">
|
|
1817
|
-
<span class="w-2 h-2 rounded-full ${severityDot(sev)}"></span>
|
|
1818
|
-
<span class="text-xs font-semibold uppercase tracking-wider">${SEVERITY_LABELS[sev]}</span>
|
|
1819
|
-
<span class="text-xs text-muted-foreground ml-auto">${issues.length}</span>
|
|
1820
|
-
</div>
|
|
1821
|
-
<div class="space-y-1">
|
|
1822
|
-
${issues.map((issue) => renderIssueRow(issue, fileKey, screenshotMap, figmaToken)).join("\n")}
|
|
1823
|
-
</div>
|
|
1824
|
-
</div>`;
|
|
1825
|
-
}
|
|
1826
|
-
function renderIssueRow(issue, fileKey, screenshotMap, figmaToken) {
|
|
1827
|
-
const sev = issue.config.severity;
|
|
1828
|
-
const def = issue.rule.definition;
|
|
1829
|
-
const link = buildFigmaDeepLink(fileKey, issue.violation.nodeId);
|
|
1830
|
-
const screenshot = screenshotMap.get(issue.violation.nodeId);
|
|
1831
|
-
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>` : "";
|
|
1832
|
-
return ` <details class="border border-border rounded-md overflow-hidden">
|
|
1833
|
-
<summary class="flex items-center gap-2.5 px-3 py-2 text-sm cursor-pointer hover:bg-muted/50 transition-colors">
|
|
1834
|
-
<span class="w-1.5 h-1.5 rounded-full ${severityDot(sev)} shrink-0"></span>
|
|
1835
|
-
<span class="font-medium shrink-0">${esc(def.name)}</span>
|
|
1836
|
-
<span class="text-muted-foreground truncate text-xs flex-1">${esc(issue.violation.message)}</span>
|
|
1837
|
-
<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>
|
|
1838
|
-
</summary>
|
|
1839
|
-
<div class="px-3 py-3 bg-muted/30 border-t border-border text-sm space-y-2">
|
|
1840
|
-
<div class="font-mono text-xs text-muted-foreground break-all">${esc(issue.violation.nodePath)}</div>
|
|
1841
|
-
<div class="text-muted-foreground leading-relaxed space-y-1">
|
|
1842
|
-
<p><span class="font-medium text-foreground">Why:</span> ${esc(def.why)}</p>
|
|
1843
|
-
<p><span class="font-medium text-foreground">Impact:</span> ${esc(def.impact)}</p>
|
|
1844
|
-
<p><span class="font-medium text-foreground">Fix:</span> ${esc(def.fix)}</p>
|
|
1845
|
-
</div>${screenshotHtml}
|
|
1846
|
-
<div class="flex items-center gap-2 mt-1 no-print">
|
|
1847
|
-
<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 ? `
|
|
1848
|
-
<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>` : ""}
|
|
1849
|
-
</div>
|
|
1850
|
-
</div>
|
|
1851
|
-
</details>`;
|
|
1852
|
-
}
|
|
1853
|
-
function getQuickWins(issues, limit) {
|
|
1854
|
-
return issues.filter((issue) => issue.config.severity === "blocking").sort((a, b) => a.calculatedScore - b.calculatedScore).slice(0, limit);
|
|
1855
|
-
}
|
|
1856
|
-
function groupIssuesByCategory(issues) {
|
|
1857
|
-
const grouped = /* @__PURE__ */ new Map();
|
|
1858
|
-
for (const category of CATEGORIES) grouped.set(category, []);
|
|
1859
|
-
for (const issue of issues) grouped.get(issue.rule.definition.category).push(issue);
|
|
1860
|
-
return grouped;
|
|
1861
|
-
}
|
|
1862
|
-
var esc = escapeHtml;
|
|
1863
|
-
var RuleOverrideSchema = z.object({
|
|
1864
|
-
score: z.number().int().max(0).optional(),
|
|
1865
|
-
severity: SeveritySchema.optional(),
|
|
1866
|
-
enabled: z.boolean().optional()
|
|
1867
|
-
});
|
|
1868
|
-
var ConfigFileSchema = z.object({
|
|
1869
|
-
excludeNodeTypes: z.array(z.string()).optional(),
|
|
1870
|
-
excludeNodeNames: z.array(z.string()).optional(),
|
|
1871
|
-
gridBase: z.number().int().positive().optional(),
|
|
1872
|
-
colorTolerance: z.number().int().positive().optional(),
|
|
1873
|
-
rules: z.record(z.string(), RuleOverrideSchema).optional()
|
|
1874
|
-
});
|
|
1875
|
-
async function loadConfigFile(filePath) {
|
|
1876
|
-
const absPath = resolve(filePath);
|
|
1877
|
-
const raw = await readFile(absPath, "utf-8");
|
|
1878
|
-
const parsed = JSON.parse(raw);
|
|
1879
|
-
return ConfigFileSchema.parse(parsed);
|
|
1880
|
-
}
|
|
1881
|
-
function mergeConfigs(base, overrides) {
|
|
1882
|
-
const merged = { ...base };
|
|
1883
|
-
if (overrides.gridBase !== void 0) {
|
|
1884
|
-
for (const [id, config2] of Object.entries(merged)) {
|
|
1885
|
-
if (config2.options && "gridBase" in config2.options) {
|
|
1886
|
-
merged[id] = {
|
|
1887
|
-
...config2,
|
|
1888
|
-
options: { ...config2.options, gridBase: overrides.gridBase }
|
|
1889
|
-
};
|
|
1890
|
-
}
|
|
1891
|
-
}
|
|
1892
|
-
}
|
|
1893
|
-
if (overrides.colorTolerance !== void 0) {
|
|
1894
|
-
for (const [id, config2] of Object.entries(merged)) {
|
|
1895
|
-
if (config2.options && "tolerance" in config2.options) {
|
|
1896
|
-
merged[id] = {
|
|
1897
|
-
...config2,
|
|
1898
|
-
options: { ...config2.options, tolerance: overrides.colorTolerance }
|
|
1899
|
-
};
|
|
1900
|
-
}
|
|
1901
|
-
}
|
|
1902
|
-
}
|
|
1903
|
-
if (overrides.rules) {
|
|
1904
|
-
for (const [ruleId, override] of Object.entries(overrides.rules)) {
|
|
1905
|
-
const existing = merged[ruleId];
|
|
1906
|
-
if (existing) {
|
|
1907
|
-
merged[ruleId] = {
|
|
1908
|
-
...existing,
|
|
1909
|
-
...override.score !== void 0 && { score: override.score },
|
|
1910
|
-
...override.severity !== void 0 && { severity: override.severity },
|
|
1911
|
-
...override.enabled !== void 0 && { enabled: override.enabled }
|
|
1912
|
-
};
|
|
1913
|
-
}
|
|
1914
|
-
}
|
|
1915
|
-
}
|
|
1916
|
-
return merged;
|
|
1917
|
-
}
|
|
1918
|
-
var MatchConditionSchema = z.object({
|
|
1919
|
-
// Node type conditions
|
|
1920
|
-
type: z.array(z.string()).optional(),
|
|
1921
|
-
notType: z.array(z.string()).optional(),
|
|
1922
|
-
// Name conditions (case-insensitive, substring match)
|
|
1923
|
-
nameContains: z.string().optional(),
|
|
1924
|
-
nameNotContains: z.string().optional(),
|
|
1925
|
-
namePattern: z.string().optional(),
|
|
1926
|
-
// Size conditions
|
|
1927
|
-
minWidth: z.number().optional(),
|
|
1928
|
-
maxWidth: z.number().optional(),
|
|
1929
|
-
minHeight: z.number().optional(),
|
|
1930
|
-
maxHeight: z.number().optional(),
|
|
1931
|
-
// Layout conditions
|
|
1932
|
-
hasAutoLayout: z.boolean().optional(),
|
|
1933
|
-
hasChildren: z.boolean().optional(),
|
|
1934
|
-
minChildren: z.number().optional(),
|
|
1935
|
-
maxChildren: z.number().optional(),
|
|
1936
|
-
// Component conditions
|
|
1937
|
-
isComponent: z.boolean().optional(),
|
|
1938
|
-
isInstance: z.boolean().optional(),
|
|
1939
|
-
hasComponentId: z.boolean().optional(),
|
|
1940
|
-
// Visibility
|
|
1941
|
-
isVisible: z.boolean().optional(),
|
|
1942
|
-
// Fill/style conditions
|
|
1943
|
-
hasFills: z.boolean().optional(),
|
|
1944
|
-
hasStrokes: z.boolean().optional(),
|
|
1945
|
-
hasEffects: z.boolean().optional(),
|
|
1946
|
-
// Depth condition
|
|
1947
|
-
minDepth: z.number().optional(),
|
|
1948
|
-
maxDepth: z.number().optional()
|
|
1949
|
-
});
|
|
1950
|
-
var CustomRuleSchema = z.object({
|
|
1951
|
-
id: z.string(),
|
|
1952
|
-
category: CategorySchema,
|
|
1953
|
-
severity: SeveritySchema,
|
|
1954
|
-
score: z.number().int().max(0),
|
|
1955
|
-
match: MatchConditionSchema,
|
|
1956
|
-
message: z.string().optional(),
|
|
1957
|
-
why: z.string(),
|
|
1958
|
-
impact: z.string(),
|
|
1959
|
-
fix: z.string(),
|
|
1960
|
-
// Backward compat: silently ignore the old prompt field
|
|
1961
|
-
prompt: z.string().optional()
|
|
1609
|
+
};
|
|
1610
|
+
defineRule({
|
|
1611
|
+
definition: fixedWidthInResponsiveContextDef,
|
|
1612
|
+
check: fixedWidthInResponsiveContextCheck
|
|
1962
1613
|
});
|
|
1963
|
-
var
|
|
1964
|
-
|
|
1965
|
-
|
|
1966
|
-
|
|
1967
|
-
|
|
1968
|
-
|
|
1969
|
-
|
|
1970
|
-
const customRules = CustomRulesFileSchema.parse(parsed);
|
|
1971
|
-
const rules = [];
|
|
1972
|
-
const configs = {};
|
|
1973
|
-
for (const cr of customRules) {
|
|
1974
|
-
if (!cr.match) continue;
|
|
1975
|
-
rules.push(toRule(cr));
|
|
1976
|
-
configs[cr.id] = {
|
|
1977
|
-
severity: cr.severity,
|
|
1978
|
-
score: cr.score,
|
|
1979
|
-
enabled: true
|
|
1980
|
-
};
|
|
1981
|
-
}
|
|
1982
|
-
return { rules, configs };
|
|
1983
|
-
}
|
|
1984
|
-
function toRule(cr) {
|
|
1985
|
-
return {
|
|
1986
|
-
definition: {
|
|
1987
|
-
id: cr.id,
|
|
1988
|
-
name: cr.id.replace(/-/g, " ").replace(/\b\w/g, (c) => c.toUpperCase()),
|
|
1989
|
-
category: cr.category,
|
|
1990
|
-
why: cr.why,
|
|
1991
|
-
impact: cr.impact,
|
|
1992
|
-
fix: cr.fix
|
|
1993
|
-
},
|
|
1994
|
-
check: createPatternCheck(cr)
|
|
1995
|
-
};
|
|
1996
|
-
}
|
|
1997
|
-
function createPatternCheck(cr) {
|
|
1998
|
-
return (node, context) => {
|
|
1999
|
-
if (node.type === "DOCUMENT" || node.type === "CANVAS") return null;
|
|
2000
|
-
const match = cr.match;
|
|
2001
|
-
if (match.type && !match.type.includes(node.type)) return null;
|
|
2002
|
-
if (match.notType && match.notType.includes(node.type)) return null;
|
|
2003
|
-
if (match.nameContains !== void 0 && !node.name.toLowerCase().includes(match.nameContains.toLowerCase())) return null;
|
|
2004
|
-
if (match.nameNotContains !== void 0 && node.name.toLowerCase().includes(match.nameNotContains.toLowerCase())) return null;
|
|
2005
|
-
if (match.namePattern !== void 0 && !new RegExp(match.namePattern, "i").test(node.name)) return null;
|
|
2006
|
-
const bbox = node.absoluteBoundingBox;
|
|
2007
|
-
if (match.minWidth !== void 0 && (!bbox || bbox.width < match.minWidth)) return null;
|
|
2008
|
-
if (match.maxWidth !== void 0 && (!bbox || bbox.width > match.maxWidth)) return null;
|
|
2009
|
-
if (match.minHeight !== void 0 && (!bbox || bbox.height < match.minHeight)) return null;
|
|
2010
|
-
if (match.maxHeight !== void 0 && (!bbox || bbox.height > match.maxHeight)) return null;
|
|
2011
|
-
if (match.hasAutoLayout === true && !node.layoutMode) return null;
|
|
2012
|
-
if (match.hasAutoLayout === false && node.layoutMode) return null;
|
|
2013
|
-
if (match.hasChildren === true && (!node.children || node.children.length === 0)) return null;
|
|
2014
|
-
if (match.hasChildren === false && node.children && node.children.length > 0) return null;
|
|
2015
|
-
if (match.minChildren !== void 0 && (!node.children || node.children.length < match.minChildren)) return null;
|
|
2016
|
-
if (match.maxChildren !== void 0 && node.children && node.children.length > match.maxChildren) return null;
|
|
2017
|
-
if (match.isComponent === true && node.type !== "COMPONENT" && node.type !== "COMPONENT_SET") return null;
|
|
2018
|
-
if (match.isComponent === false && (node.type === "COMPONENT" || node.type === "COMPONENT_SET")) return null;
|
|
2019
|
-
if (match.isInstance === true && node.type !== "INSTANCE") return null;
|
|
2020
|
-
if (match.isInstance === false && node.type === "INSTANCE") return null;
|
|
2021
|
-
if (match.hasComponentId === true && !node.componentId) return null;
|
|
2022
|
-
if (match.hasComponentId === false && node.componentId) return null;
|
|
2023
|
-
if (match.isVisible === true && !node.visible) return null;
|
|
2024
|
-
if (match.isVisible === false && node.visible) return null;
|
|
2025
|
-
if (match.hasFills === true && (!node.fills || node.fills.length === 0)) return null;
|
|
2026
|
-
if (match.hasFills === false && node.fills && node.fills.length > 0) return null;
|
|
2027
|
-
if (match.hasStrokes === true && (!node.strokes || node.strokes.length === 0)) return null;
|
|
2028
|
-
if (match.hasStrokes === false && node.strokes && node.strokes.length > 0) return null;
|
|
2029
|
-
if (match.hasEffects === true && (!node.effects || node.effects.length === 0)) return null;
|
|
2030
|
-
if (match.hasEffects === false && node.effects && node.effects.length > 0) return null;
|
|
2031
|
-
if (match.minDepth !== void 0 && context.depth < match.minDepth) return null;
|
|
2032
|
-
if (match.maxDepth !== void 0 && context.depth > match.maxDepth) return null;
|
|
2033
|
-
const msg = (cr.message ?? `"${node.name}" matched custom rule "${cr.id}"`).replace(/\{name\}/g, node.name).replace(/\{type\}/g, node.type);
|
|
2034
|
-
return {
|
|
2035
|
-
ruleId: cr.id,
|
|
2036
|
-
nodeId: node.id,
|
|
2037
|
-
nodePath: context.path.join(" > "),
|
|
2038
|
-
message: msg
|
|
2039
|
-
};
|
|
2040
|
-
};
|
|
2041
|
-
}
|
|
2042
|
-
|
|
2043
|
-
// src/core/monitoring/events.ts
|
|
2044
|
-
var EVENT_PREFIX = "cic_";
|
|
2045
|
-
var EVENTS = {
|
|
2046
|
-
// Analysis
|
|
2047
|
-
ANALYSIS_STARTED: `${EVENT_PREFIX}analysis_started`,
|
|
2048
|
-
ANALYSIS_COMPLETED: `${EVENT_PREFIX}analysis_completed`,
|
|
2049
|
-
ANALYSIS_FAILED: `${EVENT_PREFIX}analysis_failed`,
|
|
2050
|
-
// Report
|
|
2051
|
-
REPORT_GENERATED: `${EVENT_PREFIX}report_generated`,
|
|
2052
|
-
COMMENT_POSTED: `${EVENT_PREFIX}comment_posted`,
|
|
2053
|
-
COMMENT_FAILED: `${EVENT_PREFIX}comment_failed`,
|
|
2054
|
-
// MCP
|
|
2055
|
-
MCP_TOOL_CALLED: `${EVENT_PREFIX}mcp_tool_called`,
|
|
2056
|
-
// CLI
|
|
2057
|
-
CLI_COMMAND: `${EVENT_PREFIX}cli_command`,
|
|
2058
|
-
CLI_INIT: `${EVENT_PREFIX}cli_init`
|
|
1614
|
+
var missingResponsiveBehaviorDef = {
|
|
1615
|
+
id: "missing-responsive-behavior",
|
|
1616
|
+
name: "Missing Responsive Behavior",
|
|
1617
|
+
category: "layout",
|
|
1618
|
+
why: "Elements without constraints won't adapt to different screen sizes",
|
|
1619
|
+
impact: "Layout will break or look wrong on different devices",
|
|
1620
|
+
fix: "Set appropriate constraints (left/right, top/bottom, scale, etc.)"
|
|
2059
1621
|
};
|
|
2060
|
-
|
|
2061
|
-
|
|
2062
|
-
|
|
2063
|
-
|
|
2064
|
-
|
|
2065
|
-
|
|
2066
|
-
|
|
2067
|
-
|
|
2068
|
-
|
|
2069
|
-
|
|
2070
|
-
|
|
2071
|
-
const r = Math.random() * 16 | 0;
|
|
2072
|
-
const v = c === "x" ? r : r & 3 | 8;
|
|
2073
|
-
return v.toString(16);
|
|
2074
|
-
});
|
|
2075
|
-
}
|
|
2076
|
-
function parseSentryDsn(dsn) {
|
|
2077
|
-
try {
|
|
2078
|
-
const url = new URL(dsn);
|
|
2079
|
-
const key = url.username;
|
|
2080
|
-
const projectId = url.pathname.slice(1);
|
|
2081
|
-
const host = url.protocol + "//" + url.host;
|
|
2082
|
-
if (!key || !projectId) return null;
|
|
2083
|
-
return { key, host, projectId };
|
|
2084
|
-
} catch {
|
|
2085
|
-
return null;
|
|
1622
|
+
var missingResponsiveBehaviorCheck = (node, context) => {
|
|
1623
|
+
if (!isContainerNode(node)) return null;
|
|
1624
|
+
if (context.parent && hasAutoLayout(context.parent)) return null;
|
|
1625
|
+
if (context.depth < 2) return null;
|
|
1626
|
+
if (!hasAutoLayout(node) && !node.layoutAlign) {
|
|
1627
|
+
return {
|
|
1628
|
+
ruleId: missingResponsiveBehaviorDef.id,
|
|
1629
|
+
nodeId: node.id,
|
|
1630
|
+
nodePath: context.path.join(" > "),
|
|
1631
|
+
message: `"${node.name}" has no responsive behavior configured`
|
|
1632
|
+
};
|
|
2086
1633
|
}
|
|
2087
|
-
|
|
2088
|
-
|
|
2089
|
-
|
|
2090
|
-
|
|
2091
|
-
|
|
2092
|
-
|
|
2093
|
-
|
|
2094
|
-
|
|
2095
|
-
|
|
2096
|
-
|
|
2097
|
-
|
|
2098
|
-
|
|
2099
|
-
|
|
2100
|
-
|
|
1634
|
+
return null;
|
|
1635
|
+
};
|
|
1636
|
+
defineRule({
|
|
1637
|
+
definition: missingResponsiveBehaviorDef,
|
|
1638
|
+
check: missingResponsiveBehaviorCheck
|
|
1639
|
+
});
|
|
1640
|
+
var groupUsageDef = {
|
|
1641
|
+
id: "group-usage",
|
|
1642
|
+
name: "Group Usage",
|
|
1643
|
+
category: "layout",
|
|
1644
|
+
why: "Groups don't support Auto Layout and have limited layout control",
|
|
1645
|
+
impact: "Harder to maintain consistent spacing and alignment",
|
|
1646
|
+
fix: "Convert Group to Frame and apply Auto Layout"
|
|
1647
|
+
};
|
|
1648
|
+
var groupUsageCheck = (node, context) => {
|
|
1649
|
+
if (node.type !== "GROUP") return null;
|
|
1650
|
+
return {
|
|
1651
|
+
ruleId: groupUsageDef.id,
|
|
1652
|
+
nodeId: node.id,
|
|
1653
|
+
nodePath: context.path.join(" > "),
|
|
1654
|
+
message: `"${node.name}" is a Group - consider converting to Frame with Auto Layout`
|
|
2101
1655
|
};
|
|
2102
|
-
}
|
|
2103
|
-
|
|
2104
|
-
|
|
2105
|
-
|
|
2106
|
-
|
|
2107
|
-
|
|
2108
|
-
|
|
2109
|
-
|
|
2110
|
-
|
|
2111
|
-
|
|
2112
|
-
|
|
2113
|
-
|
|
2114
|
-
|
|
2115
|
-
|
|
2116
|
-
|
|
2117
|
-
|
|
2118
|
-
|
|
2119
|
-
|
|
2120
|
-
}
|
|
2121
|
-
|
|
2122
|
-
|
|
2123
|
-
|
|
2124
|
-
|
|
2125
|
-
|
|
2126
|
-
|
|
2127
|
-
|
|
2128
|
-
|
|
2129
|
-
|
|
2130
|
-
|
|
2131
|
-
|
|
2132
|
-
|
|
2133
|
-
|
|
2134
|
-
|
|
2135
|
-
|
|
2136
|
-
|
|
2137
|
-
|
|
2138
|
-
|
|
2139
|
-
|
|
2140
|
-
|
|
2141
|
-
fetch(`${parsed.host}/api/${parsed.projectId}/envelope/`, {
|
|
2142
|
-
method: "POST",
|
|
2143
|
-
headers: {
|
|
2144
|
-
"Content-Type": "application/x-sentry-envelope",
|
|
2145
|
-
"X-Sentry-Auth": `Sentry sentry_version=7, sentry_key=${parsed.key}`
|
|
2146
|
-
},
|
|
2147
|
-
body: envelope
|
|
2148
|
-
}).catch(() => {
|
|
2149
|
-
});
|
|
2150
|
-
} catch {
|
|
2151
|
-
}
|
|
2152
|
-
}
|
|
2153
|
-
}
|
|
2154
|
-
captureEvent("cic_error", { error: error.message, ...context });
|
|
2155
|
-
}
|
|
2156
|
-
function shutdownCapture() {
|
|
2157
|
-
monitoringEnabled = false;
|
|
2158
|
-
posthogApiKey = void 0;
|
|
2159
|
-
sentryDsn = void 0;
|
|
2160
|
-
distinctId = "anonymous";
|
|
2161
|
-
environment = "unknown";
|
|
2162
|
-
version = "unknown";
|
|
2163
|
-
commonProps = {};
|
|
2164
|
-
}
|
|
2165
|
-
|
|
2166
|
-
// src/core/monitoring/index.ts
|
|
2167
|
-
function initMonitoring(config2) {
|
|
2168
|
-
initCapture(config2);
|
|
2169
|
-
}
|
|
2170
|
-
function trackEvent(event, properties) {
|
|
2171
|
-
try {
|
|
2172
|
-
captureEvent(event, properties);
|
|
2173
|
-
} catch {
|
|
2174
|
-
}
|
|
2175
|
-
}
|
|
2176
|
-
function trackError(error, context) {
|
|
2177
|
-
try {
|
|
2178
|
-
captureError(error, context);
|
|
2179
|
-
} catch {
|
|
2180
|
-
}
|
|
2181
|
-
}
|
|
2182
|
-
function shutdownMonitoring() {
|
|
2183
|
-
try {
|
|
2184
|
-
shutdownCapture();
|
|
2185
|
-
} catch {
|
|
1656
|
+
};
|
|
1657
|
+
defineRule({
|
|
1658
|
+
definition: groupUsageDef,
|
|
1659
|
+
check: groupUsageCheck
|
|
1660
|
+
});
|
|
1661
|
+
var fixedSizeInAutoLayoutDef = {
|
|
1662
|
+
id: "fixed-size-in-auto-layout",
|
|
1663
|
+
name: "Fixed Size in Auto Layout",
|
|
1664
|
+
category: "layout",
|
|
1665
|
+
why: "Fixed sizes inside Auto Layout limit flexibility",
|
|
1666
|
+
impact: "Element won't adapt to content or container changes",
|
|
1667
|
+
fix: "Consider using 'Hug' for content-driven sizing"
|
|
1668
|
+
};
|
|
1669
|
+
var fixedSizeInAutoLayoutCheck = (node, context) => {
|
|
1670
|
+
if (!context.parent) return null;
|
|
1671
|
+
if (!hasAutoLayout(context.parent)) return null;
|
|
1672
|
+
if (!isContainerNode(node)) return null;
|
|
1673
|
+
if (!node.absoluteBoundingBox) return null;
|
|
1674
|
+
const { width, height } = node.absoluteBoundingBox;
|
|
1675
|
+
if (width <= 48 && height <= 48) return null;
|
|
1676
|
+
return null;
|
|
1677
|
+
};
|
|
1678
|
+
defineRule({
|
|
1679
|
+
definition: fixedSizeInAutoLayoutDef,
|
|
1680
|
+
check: fixedSizeInAutoLayoutCheck
|
|
1681
|
+
});
|
|
1682
|
+
var missingMinWidthDef = {
|
|
1683
|
+
id: "missing-min-width",
|
|
1684
|
+
name: "Missing Min Width",
|
|
1685
|
+
category: "layout",
|
|
1686
|
+
why: "Without min-width, containers can collapse to unusable sizes",
|
|
1687
|
+
impact: "Text truncation or layout collapse on narrow screens",
|
|
1688
|
+
fix: "Set a minimum width constraint on the container"
|
|
1689
|
+
};
|
|
1690
|
+
var missingMinWidthCheck = (node, context) => {
|
|
1691
|
+
if (!isContainerNode(node) && !hasTextContent(node)) return null;
|
|
1692
|
+
if (node.absoluteBoundingBox) {
|
|
1693
|
+
const { width, height } = node.absoluteBoundingBox;
|
|
1694
|
+
if (width <= 48 && height <= 24) return null;
|
|
2186
1695
|
}
|
|
2187
|
-
|
|
2188
|
-
|
|
2189
|
-
|
|
2190
|
-
|
|
2191
|
-
|
|
2192
|
-
|
|
2193
|
-
|
|
2194
|
-
var
|
|
2195
|
-
|
|
2196
|
-
|
|
2197
|
-
}
|
|
2198
|
-
|
|
2199
|
-
// src/core/rules/layout/index.ts
|
|
2200
|
-
function isContainerNode(node) {
|
|
2201
|
-
return node.type === "FRAME" || node.type === "GROUP" || node.type === "COMPONENT";
|
|
2202
|
-
}
|
|
2203
|
-
function hasAutoLayout(node) {
|
|
2204
|
-
return node.layoutMode !== void 0 && node.layoutMode !== "NONE";
|
|
2205
|
-
}
|
|
2206
|
-
function hasTextContent(node) {
|
|
2207
|
-
return node.type === "TEXT" || (node.children?.some((c) => c.type === "TEXT") ?? false);
|
|
2208
|
-
}
|
|
2209
|
-
var noAutoLayoutDef = {
|
|
2210
|
-
id: "no-auto-layout",
|
|
2211
|
-
name: "No Auto Layout",
|
|
1696
|
+
if (!context.parent || !hasAutoLayout(context.parent)) return null;
|
|
1697
|
+
return null;
|
|
1698
|
+
};
|
|
1699
|
+
defineRule({
|
|
1700
|
+
definition: missingMinWidthDef,
|
|
1701
|
+
check: missingMinWidthCheck
|
|
1702
|
+
});
|
|
1703
|
+
var missingMaxWidthDef = {
|
|
1704
|
+
id: "missing-max-width",
|
|
1705
|
+
name: "Missing Max Width",
|
|
2212
1706
|
category: "layout",
|
|
2213
|
-
why: "
|
|
2214
|
-
impact: "
|
|
2215
|
-
fix: "
|
|
1707
|
+
why: "Without max-width, content can stretch too wide on large screens",
|
|
1708
|
+
impact: "Poor readability and layout on wide screens",
|
|
1709
|
+
fix: "Set a maximum width constraint, especially for text containers"
|
|
2216
1710
|
};
|
|
2217
|
-
var
|
|
2218
|
-
if (node
|
|
2219
|
-
if (
|
|
2220
|
-
|
|
1711
|
+
var missingMaxWidthCheck = (node, _context) => {
|
|
1712
|
+
if (!isContainerNode(node) && !hasTextContent(node)) return null;
|
|
1713
|
+
if (node.absoluteBoundingBox) {
|
|
1714
|
+
const { width } = node.absoluteBoundingBox;
|
|
1715
|
+
if (width <= 200) return null;
|
|
1716
|
+
}
|
|
1717
|
+
return null;
|
|
1718
|
+
};
|
|
1719
|
+
defineRule({
|
|
1720
|
+
definition: missingMaxWidthDef,
|
|
1721
|
+
check: missingMaxWidthCheck
|
|
1722
|
+
});
|
|
1723
|
+
var deepNestingDef = {
|
|
1724
|
+
id: "deep-nesting",
|
|
1725
|
+
name: "Deep Nesting",
|
|
1726
|
+
category: "layout",
|
|
1727
|
+
why: "Deep nesting makes the structure hard to understand and maintain",
|
|
1728
|
+
impact: "Increases complexity, harder to debug layout issues",
|
|
1729
|
+
fix: "Flatten the structure by removing unnecessary wrapper frames"
|
|
1730
|
+
};
|
|
1731
|
+
var deepNestingCheck = (node, context, options) => {
|
|
1732
|
+
const maxDepth = options?.["maxDepth"] ?? getRuleOption("deep-nesting", "maxDepth", 5);
|
|
1733
|
+
if (context.depth < maxDepth) return null;
|
|
1734
|
+
if (!isContainerNode(node)) return null;
|
|
2221
1735
|
return {
|
|
2222
|
-
ruleId:
|
|
1736
|
+
ruleId: deepNestingDef.id,
|
|
2223
1737
|
nodeId: node.id,
|
|
2224
1738
|
nodePath: context.path.join(" > "),
|
|
2225
|
-
message: `
|
|
1739
|
+
message: `"${node.name}" is nested ${context.depth} levels deep (max: ${maxDepth})`
|
|
2226
1740
|
};
|
|
2227
1741
|
};
|
|
2228
1742
|
defineRule({
|
|
2229
|
-
definition:
|
|
2230
|
-
check:
|
|
1743
|
+
definition: deepNestingDef,
|
|
1744
|
+
check: deepNestingCheck
|
|
2231
1745
|
});
|
|
2232
|
-
var
|
|
2233
|
-
id: "
|
|
2234
|
-
name: "
|
|
1746
|
+
var overflowHiddenAbuseDef = {
|
|
1747
|
+
id: "overflow-hidden-abuse",
|
|
1748
|
+
name: "Overflow Hidden Abuse",
|
|
2235
1749
|
category: "layout",
|
|
2236
|
-
why: "
|
|
2237
|
-
impact: "
|
|
2238
|
-
fix: "
|
|
1750
|
+
why: "Using clip content to hide layout problems masks underlying issues",
|
|
1751
|
+
impact: "Content may be unintentionally cut off, problems harder to diagnose",
|
|
1752
|
+
fix: "Fix the underlying layout issue instead of hiding overflow"
|
|
2239
1753
|
};
|
|
2240
|
-
|
|
2241
|
-
|
|
2242
|
-
|
|
2243
|
-
|
|
2244
|
-
|
|
2245
|
-
|
|
2246
|
-
|
|
2247
|
-
|
|
1754
|
+
var overflowHiddenAbuseCheck = (_node, _context) => {
|
|
1755
|
+
return null;
|
|
1756
|
+
};
|
|
1757
|
+
defineRule({
|
|
1758
|
+
definition: overflowHiddenAbuseDef,
|
|
1759
|
+
check: overflowHiddenAbuseCheck
|
|
1760
|
+
});
|
|
1761
|
+
var inconsistentSiblingLayoutDirectionDef = {
|
|
1762
|
+
id: "inconsistent-sibling-layout-direction",
|
|
1763
|
+
name: "Inconsistent Sibling Layout Direction",
|
|
1764
|
+
category: "layout",
|
|
1765
|
+
why: "Sibling containers with mixed layout directions without clear reason create confusion",
|
|
1766
|
+
impact: "Harder to understand and maintain the design structure",
|
|
1767
|
+
fix: "Use consistent layout direction for similar sibling elements"
|
|
1768
|
+
};
|
|
1769
|
+
var inconsistentSiblingLayoutDirectionCheck = (node, context) => {
|
|
1770
|
+
if (!isContainerNode(node)) return null;
|
|
1771
|
+
if (!context.siblings || context.siblings.length < 2) return null;
|
|
1772
|
+
const siblingContainers = context.siblings.filter(
|
|
1773
|
+
(s) => isContainerNode(s) && s.id !== node.id
|
|
1774
|
+
);
|
|
1775
|
+
if (siblingContainers.length === 0) return null;
|
|
1776
|
+
const myDirection = node.layoutMode;
|
|
1777
|
+
if (!myDirection || myDirection === "NONE") return null;
|
|
1778
|
+
const siblingDirections = siblingContainers.map((s) => s.layoutMode).filter((d) => d && d !== "NONE");
|
|
1779
|
+
if (siblingDirections.length === 0) return null;
|
|
1780
|
+
const allSameSiblingDirection = siblingDirections.every(
|
|
1781
|
+
(d) => d === siblingDirections[0]
|
|
1782
|
+
);
|
|
1783
|
+
if (allSameSiblingDirection && siblingDirections[0] !== myDirection) {
|
|
1784
|
+
if (context.parent?.layoutMode === "HORIZONTAL" && myDirection === "VERTICAL") {
|
|
1785
|
+
return null;
|
|
1786
|
+
}
|
|
1787
|
+
return {
|
|
1788
|
+
ruleId: inconsistentSiblingLayoutDirectionDef.id,
|
|
1789
|
+
nodeId: node.id,
|
|
1790
|
+
nodePath: context.path.join(" > "),
|
|
1791
|
+
message: `"${node.name}" has ${myDirection} layout while siblings use ${siblingDirections[0]}`
|
|
1792
|
+
};
|
|
1793
|
+
}
|
|
1794
|
+
return null;
|
|
1795
|
+
};
|
|
1796
|
+
defineRule({
|
|
1797
|
+
definition: inconsistentSiblingLayoutDirectionDef,
|
|
1798
|
+
check: inconsistentSiblingLayoutDirectionCheck
|
|
1799
|
+
});
|
|
1800
|
+
|
|
1801
|
+
// src/core/rules/token/index.ts
|
|
1802
|
+
function hasStyleReference(node, styleType) {
|
|
1803
|
+
return node.styles !== void 0 && styleType in node.styles;
|
|
2248
1804
|
}
|
|
2249
|
-
|
|
2250
|
-
|
|
2251
|
-
|
|
2252
|
-
|
|
2253
|
-
|
|
2254
|
-
|
|
2255
|
-
|
|
2256
|
-
|
|
1805
|
+
function hasBoundVariable(node, key) {
|
|
1806
|
+
return node.boundVariables !== void 0 && key in node.boundVariables;
|
|
1807
|
+
}
|
|
1808
|
+
function isOnGrid(value, gridBase) {
|
|
1809
|
+
return value % gridBase === 0;
|
|
1810
|
+
}
|
|
1811
|
+
var rawColorDef = {
|
|
1812
|
+
id: "raw-color",
|
|
1813
|
+
name: "Raw Color",
|
|
1814
|
+
category: "token",
|
|
1815
|
+
why: "Raw hex colors are not connected to the design system",
|
|
1816
|
+
impact: "Color changes require manual updates across the entire design",
|
|
1817
|
+
fix: "Use a color style or variable instead of raw hex values"
|
|
1818
|
+
};
|
|
1819
|
+
var rawColorCheck = (node, context) => {
|
|
1820
|
+
if (!node.fills || !Array.isArray(node.fills)) return null;
|
|
1821
|
+
if (node.fills.length === 0) return null;
|
|
1822
|
+
if (hasStyleReference(node, "fill")) return null;
|
|
1823
|
+
if (hasBoundVariable(node, "fills")) return null;
|
|
1824
|
+
for (const fill of node.fills) {
|
|
1825
|
+
const fillObj = fill;
|
|
1826
|
+
if (fillObj["type"] === "SOLID" && fillObj["color"]) {
|
|
1827
|
+
return {
|
|
1828
|
+
ruleId: rawColorDef.id,
|
|
1829
|
+
nodeId: node.id,
|
|
1830
|
+
nodePath: context.path.join(" > "),
|
|
1831
|
+
message: `"${node.name}" uses raw color without style or variable`
|
|
1832
|
+
};
|
|
1833
|
+
}
|
|
1834
|
+
}
|
|
1835
|
+
return null;
|
|
1836
|
+
};
|
|
1837
|
+
defineRule({
|
|
1838
|
+
definition: rawColorDef,
|
|
1839
|
+
check: rawColorCheck
|
|
1840
|
+
});
|
|
1841
|
+
var rawFontDef = {
|
|
1842
|
+
id: "raw-font",
|
|
1843
|
+
name: "Raw Font",
|
|
1844
|
+
category: "token",
|
|
1845
|
+
why: "Text without text styles is disconnected from the type system",
|
|
1846
|
+
impact: "Typography changes require manual updates across the design",
|
|
1847
|
+
fix: "Apply a text style to maintain consistency"
|
|
1848
|
+
};
|
|
1849
|
+
var rawFontCheck = (node, context) => {
|
|
1850
|
+
if (node.type !== "TEXT") return null;
|
|
1851
|
+
if (hasStyleReference(node, "text")) return null;
|
|
1852
|
+
if (hasBoundVariable(node, "fontFamily") || hasBoundVariable(node, "fontSize")) {
|
|
1853
|
+
return null;
|
|
1854
|
+
}
|
|
2257
1855
|
return {
|
|
2258
|
-
ruleId:
|
|
1856
|
+
ruleId: rawFontDef.id,
|
|
2259
1857
|
nodeId: node.id,
|
|
2260
1858
|
nodePath: context.path.join(" > "),
|
|
2261
|
-
message: `"${node.name}"
|
|
1859
|
+
message: `"${node.name}" has no text style applied`
|
|
2262
1860
|
};
|
|
2263
1861
|
};
|
|
2264
1862
|
defineRule({
|
|
2265
|
-
definition:
|
|
2266
|
-
check:
|
|
1863
|
+
definition: rawFontDef,
|
|
1864
|
+
check: rawFontCheck
|
|
2267
1865
|
});
|
|
2268
|
-
var
|
|
2269
|
-
id: "
|
|
2270
|
-
name: "
|
|
2271
|
-
category: "
|
|
2272
|
-
why: "
|
|
2273
|
-
impact: "
|
|
2274
|
-
fix: "Use
|
|
1866
|
+
var inconsistentSpacingDef = {
|
|
1867
|
+
id: "inconsistent-spacing",
|
|
1868
|
+
name: "Inconsistent Spacing",
|
|
1869
|
+
category: "token",
|
|
1870
|
+
why: "Spacing values outside the grid system break visual consistency",
|
|
1871
|
+
impact: "Inconsistent visual rhythm and harder to maintain",
|
|
1872
|
+
fix: "Use spacing values from the design system grid (e.g., 8pt increments)"
|
|
2275
1873
|
};
|
|
2276
|
-
var
|
|
2277
|
-
|
|
2278
|
-
|
|
2279
|
-
|
|
2280
|
-
|
|
2281
|
-
|
|
2282
|
-
|
|
2283
|
-
|
|
2284
|
-
|
|
2285
|
-
if (
|
|
1874
|
+
var inconsistentSpacingCheck = (node, context, options) => {
|
|
1875
|
+
const gridBase = options?.["gridBase"] ?? getRuleOption("inconsistent-spacing", "gridBase", 4);
|
|
1876
|
+
const paddings = [
|
|
1877
|
+
node.paddingLeft,
|
|
1878
|
+
node.paddingRight,
|
|
1879
|
+
node.paddingTop,
|
|
1880
|
+
node.paddingBottom
|
|
1881
|
+
].filter((p) => p !== void 0 && p > 0);
|
|
1882
|
+
for (const padding of paddings) {
|
|
1883
|
+
if (!isOnGrid(padding, gridBase)) {
|
|
1884
|
+
return {
|
|
1885
|
+
ruleId: inconsistentSpacingDef.id,
|
|
1886
|
+
nodeId: node.id,
|
|
1887
|
+
nodePath: context.path.join(" > "),
|
|
1888
|
+
message: `"${node.name}" has padding ${padding}px not on ${gridBase}pt grid`
|
|
1889
|
+
};
|
|
1890
|
+
}
|
|
1891
|
+
}
|
|
1892
|
+
if (node.itemSpacing !== void 0 && node.itemSpacing > 0) {
|
|
1893
|
+
if (!isOnGrid(node.itemSpacing, gridBase)) {
|
|
1894
|
+
return {
|
|
1895
|
+
ruleId: inconsistentSpacingDef.id,
|
|
1896
|
+
nodeId: node.id,
|
|
1897
|
+
nodePath: context.path.join(" > "),
|
|
1898
|
+
message: `"${node.name}" has item spacing ${node.itemSpacing}px not on ${gridBase}pt grid`
|
|
1899
|
+
};
|
|
1900
|
+
}
|
|
1901
|
+
}
|
|
1902
|
+
return null;
|
|
1903
|
+
};
|
|
1904
|
+
defineRule({
|
|
1905
|
+
definition: inconsistentSpacingDef,
|
|
1906
|
+
check: inconsistentSpacingCheck
|
|
1907
|
+
});
|
|
1908
|
+
var magicNumberSpacingDef = {
|
|
1909
|
+
id: "magic-number-spacing",
|
|
1910
|
+
name: "Magic Number Spacing",
|
|
1911
|
+
category: "token",
|
|
1912
|
+
why: "Arbitrary spacing values make the system harder to understand",
|
|
1913
|
+
impact: "Unpredictable spacing, harder to create consistent layouts",
|
|
1914
|
+
fix: "Round spacing to the nearest grid value or use spacing tokens"
|
|
1915
|
+
};
|
|
1916
|
+
var magicNumberSpacingCheck = (node, context, options) => {
|
|
1917
|
+
const gridBase = options?.["gridBase"] ?? getRuleOption("magic-number-spacing", "gridBase", 4);
|
|
1918
|
+
const allSpacings = [
|
|
1919
|
+
node.paddingLeft,
|
|
1920
|
+
node.paddingRight,
|
|
1921
|
+
node.paddingTop,
|
|
1922
|
+
node.paddingBottom,
|
|
1923
|
+
node.itemSpacing
|
|
1924
|
+
].filter((s) => s !== void 0 && s > 0);
|
|
1925
|
+
for (const spacing of allSpacings) {
|
|
1926
|
+
const commonValues = [1, 2, 4];
|
|
1927
|
+
if (!isOnGrid(spacing, gridBase) && !commonValues.includes(spacing)) {
|
|
1928
|
+
if (spacing % 2 !== 0 && spacing > 4) {
|
|
1929
|
+
return {
|
|
1930
|
+
ruleId: magicNumberSpacingDef.id,
|
|
1931
|
+
nodeId: node.id,
|
|
1932
|
+
nodePath: context.path.join(" > "),
|
|
1933
|
+
message: `"${node.name}" uses magic number spacing: ${spacing}px`
|
|
1934
|
+
};
|
|
1935
|
+
}
|
|
1936
|
+
}
|
|
1937
|
+
}
|
|
1938
|
+
return null;
|
|
1939
|
+
};
|
|
1940
|
+
defineRule({
|
|
1941
|
+
definition: magicNumberSpacingDef,
|
|
1942
|
+
check: magicNumberSpacingCheck
|
|
1943
|
+
});
|
|
1944
|
+
var rawShadowDef = {
|
|
1945
|
+
id: "raw-shadow",
|
|
1946
|
+
name: "Raw Shadow",
|
|
1947
|
+
category: "token",
|
|
1948
|
+
why: "Shadow effects without styles are disconnected from the design system",
|
|
1949
|
+
impact: "Shadow changes require manual updates across the design",
|
|
1950
|
+
fix: "Create and apply an effect style for shadows"
|
|
1951
|
+
};
|
|
1952
|
+
var rawShadowCheck = (node, context) => {
|
|
1953
|
+
if (!node.effects || !Array.isArray(node.effects)) return null;
|
|
1954
|
+
if (node.effects.length === 0) return null;
|
|
1955
|
+
if (hasStyleReference(node, "effect")) return null;
|
|
1956
|
+
for (const effect of node.effects) {
|
|
1957
|
+
const effectObj = effect;
|
|
1958
|
+
if (effectObj["type"] === "DROP_SHADOW" || effectObj["type"] === "INNER_SHADOW") {
|
|
1959
|
+
return {
|
|
1960
|
+
ruleId: rawShadowDef.id,
|
|
1961
|
+
nodeId: node.id,
|
|
1962
|
+
nodePath: context.path.join(" > "),
|
|
1963
|
+
message: `"${node.name}" has shadow effect without effect style`
|
|
1964
|
+
};
|
|
1965
|
+
}
|
|
2286
1966
|
}
|
|
2287
|
-
|
|
2288
|
-
return {
|
|
2289
|
-
ruleId: fixedWidthInResponsiveContextDef.id,
|
|
2290
|
-
nodeId: node.id,
|
|
2291
|
-
nodePath: context.path.join(" > "),
|
|
2292
|
-
message: `"${node.name}" has fixed width inside Auto Layout`
|
|
2293
|
-
};
|
|
1967
|
+
return null;
|
|
2294
1968
|
};
|
|
2295
1969
|
defineRule({
|
|
2296
|
-
definition:
|
|
2297
|
-
check:
|
|
1970
|
+
definition: rawShadowDef,
|
|
1971
|
+
check: rawShadowCheck
|
|
2298
1972
|
});
|
|
2299
|
-
var
|
|
2300
|
-
id: "
|
|
2301
|
-
name: "
|
|
2302
|
-
category: "
|
|
2303
|
-
why: "
|
|
2304
|
-
impact: "
|
|
2305
|
-
fix: "
|
|
1973
|
+
var rawOpacityDef = {
|
|
1974
|
+
id: "raw-opacity",
|
|
1975
|
+
name: "Raw Opacity",
|
|
1976
|
+
category: "token",
|
|
1977
|
+
why: "Hardcoded opacity values are not connected to design tokens",
|
|
1978
|
+
impact: "Opacity changes require manual updates",
|
|
1979
|
+
fix: "Use opacity variables or consider if opacity is truly needed"
|
|
2306
1980
|
};
|
|
2307
|
-
var
|
|
2308
|
-
if (
|
|
2309
|
-
if (context.parent && hasAutoLayout(context.parent)) return null;
|
|
2310
|
-
if (context.depth < 2) return null;
|
|
2311
|
-
if (!hasAutoLayout(node) && !node.layoutAlign) {
|
|
2312
|
-
return {
|
|
2313
|
-
ruleId: missingResponsiveBehaviorDef.id,
|
|
2314
|
-
nodeId: node.id,
|
|
2315
|
-
nodePath: context.path.join(" > "),
|
|
2316
|
-
message: `"${node.name}" has no responsive behavior configured`
|
|
2317
|
-
};
|
|
2318
|
-
}
|
|
1981
|
+
var rawOpacityCheck = (node, _context) => {
|
|
1982
|
+
if (hasBoundVariable(node, "opacity")) return null;
|
|
2319
1983
|
return null;
|
|
2320
1984
|
};
|
|
2321
1985
|
defineRule({
|
|
2322
|
-
definition:
|
|
2323
|
-
check:
|
|
1986
|
+
definition: rawOpacityDef,
|
|
1987
|
+
check: rawOpacityCheck
|
|
2324
1988
|
});
|
|
2325
|
-
var
|
|
2326
|
-
id: "
|
|
2327
|
-
name: "
|
|
2328
|
-
category: "
|
|
2329
|
-
why: "
|
|
2330
|
-
impact: "
|
|
2331
|
-
fix: "
|
|
1989
|
+
var multipleFillColorsDef = {
|
|
1990
|
+
id: "multiple-fill-colors",
|
|
1991
|
+
name: "Multiple Fill Colors",
|
|
1992
|
+
category: "token",
|
|
1993
|
+
why: "Similar but slightly different colors indicate inconsistent token usage",
|
|
1994
|
+
impact: "Visual inconsistency and harder to maintain brand colors",
|
|
1995
|
+
fix: "Consolidate to a single color token or style"
|
|
2332
1996
|
};
|
|
2333
|
-
var
|
|
2334
|
-
|
|
2335
|
-
return {
|
|
2336
|
-
ruleId: groupUsageDef.id,
|
|
2337
|
-
nodeId: node.id,
|
|
2338
|
-
nodePath: context.path.join(" > "),
|
|
2339
|
-
message: `"${node.name}" is a Group - consider converting to Frame with Auto Layout`
|
|
2340
|
-
};
|
|
1997
|
+
var multipleFillColorsCheck = (_node, _context, _options) => {
|
|
1998
|
+
return null;
|
|
2341
1999
|
};
|
|
2342
2000
|
defineRule({
|
|
2343
|
-
definition:
|
|
2344
|
-
check:
|
|
2001
|
+
definition: multipleFillColorsDef,
|
|
2002
|
+
check: multipleFillColorsCheck
|
|
2345
2003
|
});
|
|
2346
|
-
|
|
2347
|
-
|
|
2348
|
-
|
|
2349
|
-
|
|
2350
|
-
|
|
2351
|
-
|
|
2352
|
-
|
|
2004
|
+
|
|
2005
|
+
// src/core/rules/component/index.ts
|
|
2006
|
+
function isComponentInstance(node) {
|
|
2007
|
+
return node.type === "INSTANCE";
|
|
2008
|
+
}
|
|
2009
|
+
function isComponent(node) {
|
|
2010
|
+
return node.type === "COMPONENT" || node.type === "COMPONENT_SET";
|
|
2011
|
+
}
|
|
2012
|
+
function collectFrameNames(node, names = /* @__PURE__ */ new Map()) {
|
|
2013
|
+
if (node.type === "FRAME" && node.name) {
|
|
2014
|
+
const existing = names.get(node.name) ?? [];
|
|
2015
|
+
existing.push(node.id);
|
|
2016
|
+
names.set(node.name, existing);
|
|
2017
|
+
}
|
|
2018
|
+
if (node.children) {
|
|
2019
|
+
for (const child of node.children) {
|
|
2020
|
+
collectFrameNames(child, names);
|
|
2021
|
+
}
|
|
2022
|
+
}
|
|
2023
|
+
return names;
|
|
2024
|
+
}
|
|
2025
|
+
var missingComponentDef = {
|
|
2026
|
+
id: "missing-component",
|
|
2027
|
+
name: "Missing Component",
|
|
2028
|
+
category: "component",
|
|
2029
|
+
why: "Repeated identical structures should be componentized",
|
|
2030
|
+
impact: "Changes require manual updates in multiple places",
|
|
2031
|
+
fix: "Create a component from the repeated structure"
|
|
2353
2032
|
};
|
|
2354
|
-
var
|
|
2355
|
-
if (
|
|
2356
|
-
|
|
2357
|
-
|
|
2358
|
-
|
|
2359
|
-
|
|
2360
|
-
|
|
2033
|
+
var missingComponentCheck = (node, context, options) => {
|
|
2034
|
+
if (node.type !== "FRAME") return null;
|
|
2035
|
+
const minRepetitions = options?.["minRepetitions"] ?? getRuleOption("missing-component", "minRepetitions", 3);
|
|
2036
|
+
const frameNames = collectFrameNames(context.file.document);
|
|
2037
|
+
const sameNameFrames = frameNames.get(node.name);
|
|
2038
|
+
if (sameNameFrames && sameNameFrames.length >= minRepetitions) {
|
|
2039
|
+
if (sameNameFrames[0] === node.id) {
|
|
2040
|
+
return {
|
|
2041
|
+
ruleId: missingComponentDef.id,
|
|
2042
|
+
nodeId: node.id,
|
|
2043
|
+
nodePath: context.path.join(" > "),
|
|
2044
|
+
message: `"${node.name}" appears ${sameNameFrames.length} times - consider making it a component`
|
|
2045
|
+
};
|
|
2046
|
+
}
|
|
2047
|
+
}
|
|
2361
2048
|
return null;
|
|
2362
2049
|
};
|
|
2363
2050
|
defineRule({
|
|
2364
|
-
definition:
|
|
2365
|
-
check:
|
|
2051
|
+
definition: missingComponentDef,
|
|
2052
|
+
check: missingComponentCheck
|
|
2366
2053
|
});
|
|
2367
|
-
var
|
|
2368
|
-
id: "
|
|
2369
|
-
name: "
|
|
2370
|
-
category: "
|
|
2371
|
-
why: "
|
|
2372
|
-
impact: "
|
|
2373
|
-
fix: "
|
|
2054
|
+
var detachedInstanceDef = {
|
|
2055
|
+
id: "detached-instance",
|
|
2056
|
+
name: "Detached Instance",
|
|
2057
|
+
category: "component",
|
|
2058
|
+
why: "Detached instances lose their connection to the source component",
|
|
2059
|
+
impact: "Updates to the component won't propagate to this instance",
|
|
2060
|
+
fix: "Reset the instance or create a new variant if customization is needed"
|
|
2374
2061
|
};
|
|
2375
|
-
var
|
|
2376
|
-
if (
|
|
2377
|
-
|
|
2378
|
-
|
|
2379
|
-
|
|
2062
|
+
var detachedInstanceCheck = (node, context) => {
|
|
2063
|
+
if (node.type !== "FRAME") return null;
|
|
2064
|
+
const components = context.file.components;
|
|
2065
|
+
const nodeName = node.name.toLowerCase();
|
|
2066
|
+
for (const [, component] of Object.entries(components)) {
|
|
2067
|
+
if (nodeName.includes(component.name.toLowerCase())) {
|
|
2068
|
+
return {
|
|
2069
|
+
ruleId: detachedInstanceDef.id,
|
|
2070
|
+
nodeId: node.id,
|
|
2071
|
+
nodePath: context.path.join(" > "),
|
|
2072
|
+
message: `"${node.name}" may be a detached instance of component "${component.name}"`
|
|
2073
|
+
};
|
|
2074
|
+
}
|
|
2380
2075
|
}
|
|
2381
|
-
if (!context.parent || !hasAutoLayout(context.parent)) return null;
|
|
2382
2076
|
return null;
|
|
2383
2077
|
};
|
|
2384
2078
|
defineRule({
|
|
2385
|
-
definition:
|
|
2386
|
-
check:
|
|
2079
|
+
definition: detachedInstanceDef,
|
|
2080
|
+
check: detachedInstanceCheck
|
|
2387
2081
|
});
|
|
2388
|
-
var
|
|
2389
|
-
id: "
|
|
2390
|
-
name: "
|
|
2391
|
-
category: "
|
|
2392
|
-
why: "
|
|
2393
|
-
impact: "
|
|
2394
|
-
fix: "
|
|
2082
|
+
var nestedInstanceOverrideDef = {
|
|
2083
|
+
id: "nested-instance-override",
|
|
2084
|
+
name: "Nested Instance Override",
|
|
2085
|
+
category: "component",
|
|
2086
|
+
why: "Excessive overrides in instances make components harder to maintain",
|
|
2087
|
+
impact: "Component updates may not work as expected",
|
|
2088
|
+
fix: "Create a variant or new component for significantly different use cases"
|
|
2395
2089
|
};
|
|
2396
|
-
var
|
|
2397
|
-
if (!
|
|
2398
|
-
if (node.
|
|
2399
|
-
|
|
2400
|
-
|
|
2090
|
+
var nestedInstanceOverrideCheck = (node, context) => {
|
|
2091
|
+
if (!isComponentInstance(node)) return null;
|
|
2092
|
+
if (!node.componentProperties) return null;
|
|
2093
|
+
const overrideCount = Object.keys(node.componentProperties).length;
|
|
2094
|
+
if (overrideCount > 5) {
|
|
2095
|
+
return {
|
|
2096
|
+
ruleId: nestedInstanceOverrideDef.id,
|
|
2097
|
+
nodeId: node.id,
|
|
2098
|
+
nodePath: context.path.join(" > "),
|
|
2099
|
+
message: `"${node.name}" has ${overrideCount} property overrides - consider creating a variant`
|
|
2100
|
+
};
|
|
2401
2101
|
}
|
|
2402
2102
|
return null;
|
|
2403
2103
|
};
|
|
2404
2104
|
defineRule({
|
|
2405
|
-
definition:
|
|
2406
|
-
check:
|
|
2105
|
+
definition: nestedInstanceOverrideDef,
|
|
2106
|
+
check: nestedInstanceOverrideCheck
|
|
2407
2107
|
});
|
|
2408
|
-
var
|
|
2409
|
-
id: "
|
|
2410
|
-
name: "
|
|
2411
|
-
category: "
|
|
2412
|
-
why: "
|
|
2413
|
-
impact: "
|
|
2414
|
-
fix: "
|
|
2108
|
+
var variantNotUsedDef = {
|
|
2109
|
+
id: "variant-not-used",
|
|
2110
|
+
name: "Variant Not Used",
|
|
2111
|
+
category: "component",
|
|
2112
|
+
why: "Using instances but not leveraging variants defeats their purpose",
|
|
2113
|
+
impact: "Manual changes instead of using designed variants",
|
|
2114
|
+
fix: "Use the appropriate variant instead of overriding the default"
|
|
2415
2115
|
};
|
|
2416
|
-
var
|
|
2417
|
-
|
|
2418
|
-
if (context.depth < maxDepth) return null;
|
|
2419
|
-
if (!isContainerNode(node)) return null;
|
|
2420
|
-
return {
|
|
2421
|
-
ruleId: deepNestingDef.id,
|
|
2422
|
-
nodeId: node.id,
|
|
2423
|
-
nodePath: context.path.join(" > "),
|
|
2424
|
-
message: `"${node.name}" is nested ${context.depth} levels deep (max: ${maxDepth})`
|
|
2425
|
-
};
|
|
2116
|
+
var variantNotUsedCheck = (_node, _context) => {
|
|
2117
|
+
return null;
|
|
2426
2118
|
};
|
|
2427
2119
|
defineRule({
|
|
2428
|
-
definition:
|
|
2429
|
-
check:
|
|
2120
|
+
definition: variantNotUsedDef,
|
|
2121
|
+
check: variantNotUsedCheck
|
|
2430
2122
|
});
|
|
2431
|
-
var
|
|
2432
|
-
id: "
|
|
2433
|
-
name: "
|
|
2434
|
-
category: "
|
|
2435
|
-
why: "
|
|
2436
|
-
impact: "
|
|
2437
|
-
fix: "
|
|
2123
|
+
var componentPropertyUnusedDef = {
|
|
2124
|
+
id: "component-property-unused",
|
|
2125
|
+
name: "Component Property Unused",
|
|
2126
|
+
category: "component",
|
|
2127
|
+
why: "Component properties should be utilized to expose customization",
|
|
2128
|
+
impact: "Hardcoded values that should be configurable",
|
|
2129
|
+
fix: "Connect the value to a component property"
|
|
2438
2130
|
};
|
|
2439
|
-
var
|
|
2131
|
+
var componentPropertyUnusedCheck = (node, _context) => {
|
|
2132
|
+
if (!isComponent(node)) return null;
|
|
2133
|
+
if (!node.componentPropertyDefinitions) return null;
|
|
2134
|
+
const definedProps = Object.keys(node.componentPropertyDefinitions);
|
|
2135
|
+
if (definedProps.length === 0) return null;
|
|
2440
2136
|
return null;
|
|
2441
2137
|
};
|
|
2442
2138
|
defineRule({
|
|
2443
|
-
definition:
|
|
2444
|
-
check:
|
|
2139
|
+
definition: componentPropertyUnusedDef,
|
|
2140
|
+
check: componentPropertyUnusedCheck
|
|
2445
2141
|
});
|
|
2446
|
-
var
|
|
2447
|
-
id: "
|
|
2448
|
-
name: "
|
|
2449
|
-
category: "
|
|
2450
|
-
why: "
|
|
2451
|
-
impact: "
|
|
2452
|
-
fix: "
|
|
2142
|
+
var singleUseComponentDef = {
|
|
2143
|
+
id: "single-use-component",
|
|
2144
|
+
name: "Single Use Component",
|
|
2145
|
+
category: "component",
|
|
2146
|
+
why: "Components used only once add complexity without reuse benefit",
|
|
2147
|
+
impact: "Unnecessary abstraction increases maintenance overhead",
|
|
2148
|
+
fix: "Consider inlining if this component won't be reused"
|
|
2453
2149
|
};
|
|
2454
|
-
var
|
|
2455
|
-
if (!
|
|
2456
|
-
|
|
2457
|
-
|
|
2458
|
-
(
|
|
2459
|
-
|
|
2460
|
-
|
|
2461
|
-
|
|
2462
|
-
|
|
2463
|
-
|
|
2464
|
-
|
|
2465
|
-
const allSameSiblingDirection = siblingDirections.every(
|
|
2466
|
-
(d) => d === siblingDirections[0]
|
|
2467
|
-
);
|
|
2468
|
-
if (allSameSiblingDirection && siblingDirections[0] !== myDirection) {
|
|
2469
|
-
if (context.parent?.layoutMode === "HORIZONTAL" && myDirection === "VERTICAL") {
|
|
2470
|
-
return null;
|
|
2150
|
+
var singleUseComponentCheck = (node, context) => {
|
|
2151
|
+
if (!isComponent(node)) return null;
|
|
2152
|
+
let instanceCount = 0;
|
|
2153
|
+
function countInstances(n) {
|
|
2154
|
+
if (n.type === "INSTANCE" && n.componentId === node.id) {
|
|
2155
|
+
instanceCount++;
|
|
2156
|
+
}
|
|
2157
|
+
if (n.children) {
|
|
2158
|
+
for (const child of n.children) {
|
|
2159
|
+
countInstances(child);
|
|
2160
|
+
}
|
|
2471
2161
|
}
|
|
2162
|
+
}
|
|
2163
|
+
countInstances(context.file.document);
|
|
2164
|
+
if (instanceCount === 1) {
|
|
2472
2165
|
return {
|
|
2473
|
-
ruleId:
|
|
2166
|
+
ruleId: singleUseComponentDef.id,
|
|
2474
2167
|
nodeId: node.id,
|
|
2475
2168
|
nodePath: context.path.join(" > "),
|
|
2476
|
-
message: `"${node.name}"
|
|
2169
|
+
message: `Component "${node.name}" is only used once`
|
|
2477
2170
|
};
|
|
2478
2171
|
}
|
|
2479
2172
|
return null;
|
|
2480
2173
|
};
|
|
2481
2174
|
defineRule({
|
|
2482
|
-
definition:
|
|
2483
|
-
check:
|
|
2175
|
+
definition: singleUseComponentDef,
|
|
2176
|
+
check: singleUseComponentCheck
|
|
2484
2177
|
});
|
|
2485
2178
|
|
|
2486
|
-
// src/core/rules/
|
|
2487
|
-
|
|
2488
|
-
|
|
2179
|
+
// src/core/rules/naming/index.ts
|
|
2180
|
+
var DEFAULT_NAME_PATTERNS = [
|
|
2181
|
+
/^Frame\s*\d*$/i,
|
|
2182
|
+
/^Group\s*\d*$/i,
|
|
2183
|
+
/^Rectangle\s*\d*$/i,
|
|
2184
|
+
/^Ellipse\s*\d*$/i,
|
|
2185
|
+
/^Vector\s*\d*$/i,
|
|
2186
|
+
/^Line\s*\d*$/i,
|
|
2187
|
+
/^Text\s*\d*$/i,
|
|
2188
|
+
/^Image\s*\d*$/i,
|
|
2189
|
+
/^Component\s*\d*$/i,
|
|
2190
|
+
/^Instance\s*\d*$/i
|
|
2191
|
+
];
|
|
2192
|
+
var NON_SEMANTIC_NAMES = [
|
|
2193
|
+
"rectangle",
|
|
2194
|
+
"ellipse",
|
|
2195
|
+
"vector",
|
|
2196
|
+
"line",
|
|
2197
|
+
"polygon",
|
|
2198
|
+
"star",
|
|
2199
|
+
"path",
|
|
2200
|
+
"shape",
|
|
2201
|
+
"image",
|
|
2202
|
+
"fill",
|
|
2203
|
+
"stroke"
|
|
2204
|
+
];
|
|
2205
|
+
function isDefaultName(name) {
|
|
2206
|
+
return DEFAULT_NAME_PATTERNS.some((pattern) => pattern.test(name));
|
|
2489
2207
|
}
|
|
2490
|
-
function
|
|
2491
|
-
|
|
2208
|
+
function isNonSemanticName(name) {
|
|
2209
|
+
const normalized = name.toLowerCase().trim();
|
|
2210
|
+
return NON_SEMANTIC_NAMES.includes(normalized);
|
|
2492
2211
|
}
|
|
2493
|
-
function
|
|
2494
|
-
return
|
|
2212
|
+
function hasNumericSuffix(name) {
|
|
2213
|
+
return /\s+\d+$/.test(name);
|
|
2495
2214
|
}
|
|
2496
|
-
|
|
2497
|
-
|
|
2498
|
-
name
|
|
2499
|
-
|
|
2500
|
-
|
|
2501
|
-
|
|
2502
|
-
|
|
2503
|
-
};
|
|
2504
|
-
var rawColorCheck = (node, context) => {
|
|
2505
|
-
if (!node.fills || !Array.isArray(node.fills)) return null;
|
|
2506
|
-
if (node.fills.length === 0) return null;
|
|
2507
|
-
if (hasStyleReference(node, "fill")) return null;
|
|
2508
|
-
if (hasBoundVariable(node, "fills")) return null;
|
|
2509
|
-
for (const fill of node.fills) {
|
|
2510
|
-
const fillObj = fill;
|
|
2511
|
-
if (fillObj["type"] === "SOLID" && fillObj["color"]) {
|
|
2512
|
-
return {
|
|
2513
|
-
ruleId: rawColorDef.id,
|
|
2514
|
-
nodeId: node.id,
|
|
2515
|
-
nodePath: context.path.join(" > "),
|
|
2516
|
-
message: `"${node.name}" uses raw color without style or variable`
|
|
2517
|
-
};
|
|
2518
|
-
}
|
|
2519
|
-
}
|
|
2215
|
+
function detectNamingConvention(name) {
|
|
2216
|
+
if (/^[a-z]+(-[a-z]+)*$/.test(name)) return "kebab-case";
|
|
2217
|
+
if (/^[a-z]+(_[a-z]+)*$/.test(name)) return "snake_case";
|
|
2218
|
+
if (/^[a-z]+([A-Z][a-z]*)*$/.test(name)) return "camelCase";
|
|
2219
|
+
if (/^[A-Z][a-z]+([A-Z][a-z]*)*$/.test(name)) return "PascalCase";
|
|
2220
|
+
if (/^[A-Z]+(_[A-Z]+)*$/.test(name)) return "SCREAMING_SNAKE_CASE";
|
|
2221
|
+
if (/\s/.test(name)) return "Title Case";
|
|
2520
2222
|
return null;
|
|
2223
|
+
}
|
|
2224
|
+
var defaultNameDef = {
|
|
2225
|
+
id: "default-name",
|
|
2226
|
+
name: "Default Name",
|
|
2227
|
+
category: "naming",
|
|
2228
|
+
why: "Default names like 'Frame 123' provide no context about the element's purpose",
|
|
2229
|
+
impact: "Designers and developers cannot understand the structure",
|
|
2230
|
+
fix: "Rename with a descriptive, semantic name (e.g., 'Header', 'ProductCard')"
|
|
2521
2231
|
};
|
|
2522
|
-
|
|
2523
|
-
|
|
2524
|
-
|
|
2525
|
-
|
|
2526
|
-
var rawFontDef = {
|
|
2527
|
-
id: "raw-font",
|
|
2528
|
-
name: "Raw Font",
|
|
2529
|
-
category: "token",
|
|
2530
|
-
why: "Text without text styles is disconnected from the type system",
|
|
2531
|
-
impact: "Typography changes require manual updates across the design",
|
|
2532
|
-
fix: "Apply a text style to maintain consistency"
|
|
2533
|
-
};
|
|
2534
|
-
var rawFontCheck = (node, context) => {
|
|
2535
|
-
if (node.type !== "TEXT") return null;
|
|
2536
|
-
if (hasStyleReference(node, "text")) return null;
|
|
2537
|
-
if (hasBoundVariable(node, "fontFamily") || hasBoundVariable(node, "fontSize")) {
|
|
2538
|
-
return null;
|
|
2539
|
-
}
|
|
2232
|
+
var defaultNameCheck = (node, context) => {
|
|
2233
|
+
if (!node.name) return null;
|
|
2234
|
+
if (isExcludedName(node.name)) return null;
|
|
2235
|
+
if (!isDefaultName(node.name)) return null;
|
|
2540
2236
|
return {
|
|
2541
|
-
ruleId:
|
|
2237
|
+
ruleId: defaultNameDef.id,
|
|
2542
2238
|
nodeId: node.id,
|
|
2543
2239
|
nodePath: context.path.join(" > "),
|
|
2544
|
-
message: `"${node.name}"
|
|
2240
|
+
message: `"${node.name}" is a default name - provide a meaningful name`
|
|
2545
2241
|
};
|
|
2546
2242
|
};
|
|
2547
2243
|
defineRule({
|
|
2548
|
-
definition:
|
|
2549
|
-
check:
|
|
2550
|
-
});
|
|
2551
|
-
var inconsistentSpacingDef = {
|
|
2552
|
-
id: "inconsistent-spacing",
|
|
2553
|
-
name: "Inconsistent Spacing",
|
|
2554
|
-
category: "token",
|
|
2555
|
-
why: "Spacing values outside the grid system break visual consistency",
|
|
2556
|
-
impact: "Inconsistent visual rhythm and harder to maintain",
|
|
2557
|
-
fix: "Use spacing values from the design system grid (e.g., 8pt increments)"
|
|
2558
|
-
};
|
|
2559
|
-
var inconsistentSpacingCheck = (node, context, options) => {
|
|
2560
|
-
const gridBase = options?.["gridBase"] ?? getRuleOption("inconsistent-spacing", "gridBase", 4);
|
|
2561
|
-
const paddings = [
|
|
2562
|
-
node.paddingLeft,
|
|
2563
|
-
node.paddingRight,
|
|
2564
|
-
node.paddingTop,
|
|
2565
|
-
node.paddingBottom
|
|
2566
|
-
].filter((p) => p !== void 0 && p > 0);
|
|
2567
|
-
for (const padding of paddings) {
|
|
2568
|
-
if (!isOnGrid(padding, gridBase)) {
|
|
2569
|
-
return {
|
|
2570
|
-
ruleId: inconsistentSpacingDef.id,
|
|
2571
|
-
nodeId: node.id,
|
|
2572
|
-
nodePath: context.path.join(" > "),
|
|
2573
|
-
message: `"${node.name}" has padding ${padding}px not on ${gridBase}pt grid`
|
|
2574
|
-
};
|
|
2575
|
-
}
|
|
2576
|
-
}
|
|
2577
|
-
if (node.itemSpacing !== void 0 && node.itemSpacing > 0) {
|
|
2578
|
-
if (!isOnGrid(node.itemSpacing, gridBase)) {
|
|
2579
|
-
return {
|
|
2580
|
-
ruleId: inconsistentSpacingDef.id,
|
|
2581
|
-
nodeId: node.id,
|
|
2582
|
-
nodePath: context.path.join(" > "),
|
|
2583
|
-
message: `"${node.name}" has item spacing ${node.itemSpacing}px not on ${gridBase}pt grid`
|
|
2584
|
-
};
|
|
2585
|
-
}
|
|
2586
|
-
}
|
|
2587
|
-
return null;
|
|
2588
|
-
};
|
|
2589
|
-
defineRule({
|
|
2590
|
-
definition: inconsistentSpacingDef,
|
|
2591
|
-
check: inconsistentSpacingCheck
|
|
2244
|
+
definition: defaultNameDef,
|
|
2245
|
+
check: defaultNameCheck
|
|
2592
2246
|
});
|
|
2593
|
-
var
|
|
2594
|
-
id: "
|
|
2595
|
-
name: "
|
|
2596
|
-
category: "
|
|
2597
|
-
why: "
|
|
2598
|
-
impact: "
|
|
2599
|
-
fix: "
|
|
2247
|
+
var nonSemanticNameDef = {
|
|
2248
|
+
id: "non-semantic-name",
|
|
2249
|
+
name: "Non-Semantic Name",
|
|
2250
|
+
category: "naming",
|
|
2251
|
+
why: "Names like 'Rectangle' describe shape, not purpose",
|
|
2252
|
+
impact: "Structure is hard to understand without context",
|
|
2253
|
+
fix: "Use names that describe what the element represents (e.g., 'Divider', 'Avatar')"
|
|
2600
2254
|
};
|
|
2601
|
-
var
|
|
2602
|
-
|
|
2603
|
-
|
|
2604
|
-
|
|
2605
|
-
|
|
2606
|
-
|
|
2607
|
-
node.
|
|
2608
|
-
node.itemSpacing
|
|
2609
|
-
].filter((s) => s !== void 0 && s > 0);
|
|
2610
|
-
for (const spacing of allSpacings) {
|
|
2611
|
-
const commonValues = [1, 2, 4];
|
|
2612
|
-
if (!isOnGrid(spacing, gridBase) && !commonValues.includes(spacing)) {
|
|
2613
|
-
if (spacing % 2 !== 0 && spacing > 4) {
|
|
2614
|
-
return {
|
|
2615
|
-
ruleId: magicNumberSpacingDef.id,
|
|
2616
|
-
nodeId: node.id,
|
|
2617
|
-
nodePath: context.path.join(" > "),
|
|
2618
|
-
message: `"${node.name}" uses magic number spacing: ${spacing}px`
|
|
2619
|
-
};
|
|
2620
|
-
}
|
|
2621
|
-
}
|
|
2255
|
+
var nonSemanticNameCheck = (node, context) => {
|
|
2256
|
+
if (!node.name) return null;
|
|
2257
|
+
if (isExcludedName(node.name)) return null;
|
|
2258
|
+
if (!isNonSemanticName(node.name)) return null;
|
|
2259
|
+
if (!node.children || node.children.length === 0) {
|
|
2260
|
+
const shapeTypes = ["RECTANGLE", "ELLIPSE", "VECTOR", "LINE", "STAR", "REGULAR_POLYGON"];
|
|
2261
|
+
if (shapeTypes.includes(node.type)) return null;
|
|
2622
2262
|
}
|
|
2623
|
-
return
|
|
2263
|
+
return {
|
|
2264
|
+
ruleId: nonSemanticNameDef.id,
|
|
2265
|
+
nodeId: node.id,
|
|
2266
|
+
nodePath: context.path.join(" > "),
|
|
2267
|
+
message: `"${node.name}" is a non-semantic name - describe its purpose`
|
|
2268
|
+
};
|
|
2624
2269
|
};
|
|
2625
2270
|
defineRule({
|
|
2626
|
-
definition:
|
|
2627
|
-
check:
|
|
2271
|
+
definition: nonSemanticNameDef,
|
|
2272
|
+
check: nonSemanticNameCheck
|
|
2628
2273
|
});
|
|
2629
|
-
var
|
|
2630
|
-
id: "
|
|
2631
|
-
name: "
|
|
2632
|
-
category: "
|
|
2633
|
-
why: "
|
|
2634
|
-
impact: "
|
|
2635
|
-
fix: "
|
|
2274
|
+
var inconsistentNamingConventionDef = {
|
|
2275
|
+
id: "inconsistent-naming-convention",
|
|
2276
|
+
name: "Inconsistent Naming Convention",
|
|
2277
|
+
category: "naming",
|
|
2278
|
+
why: "Mixed naming conventions at the same level create confusion",
|
|
2279
|
+
impact: "Harder to navigate and maintain the design",
|
|
2280
|
+
fix: "Use a consistent naming convention for sibling elements"
|
|
2636
2281
|
};
|
|
2637
|
-
var
|
|
2638
|
-
if (!
|
|
2639
|
-
|
|
2640
|
-
|
|
2641
|
-
|
|
2642
|
-
const
|
|
2643
|
-
if (
|
|
2644
|
-
|
|
2645
|
-
ruleId: rawShadowDef.id,
|
|
2646
|
-
nodeId: node.id,
|
|
2647
|
-
nodePath: context.path.join(" > "),
|
|
2648
|
-
message: `"${node.name}" has shadow effect without effect style`
|
|
2649
|
-
};
|
|
2282
|
+
var inconsistentNamingConventionCheck = (node, context) => {
|
|
2283
|
+
if (!context.siblings || context.siblings.length < 2) return null;
|
|
2284
|
+
const conventions = /* @__PURE__ */ new Map();
|
|
2285
|
+
for (const sibling of context.siblings) {
|
|
2286
|
+
if (!sibling.name) continue;
|
|
2287
|
+
const convention = detectNamingConvention(sibling.name);
|
|
2288
|
+
if (convention) {
|
|
2289
|
+
conventions.set(convention, (conventions.get(convention) ?? 0) + 1);
|
|
2650
2290
|
}
|
|
2651
2291
|
}
|
|
2292
|
+
if (conventions.size < 2) return null;
|
|
2293
|
+
let dominantConvention = "";
|
|
2294
|
+
let maxCount = 0;
|
|
2295
|
+
for (const [convention, count] of conventions) {
|
|
2296
|
+
if (count > maxCount) {
|
|
2297
|
+
maxCount = count;
|
|
2298
|
+
dominantConvention = convention;
|
|
2299
|
+
}
|
|
2300
|
+
}
|
|
2301
|
+
const nodeConvention = detectNamingConvention(node.name);
|
|
2302
|
+
if (nodeConvention && nodeConvention !== dominantConvention && maxCount >= 2) {
|
|
2303
|
+
return {
|
|
2304
|
+
ruleId: inconsistentNamingConventionDef.id,
|
|
2305
|
+
nodeId: node.id,
|
|
2306
|
+
nodePath: context.path.join(" > "),
|
|
2307
|
+
message: `"${node.name}" uses ${nodeConvention} while siblings use ${dominantConvention}`
|
|
2308
|
+
};
|
|
2309
|
+
}
|
|
2652
2310
|
return null;
|
|
2653
2311
|
};
|
|
2654
2312
|
defineRule({
|
|
2655
|
-
definition:
|
|
2656
|
-
check:
|
|
2313
|
+
definition: inconsistentNamingConventionDef,
|
|
2314
|
+
check: inconsistentNamingConventionCheck
|
|
2657
2315
|
});
|
|
2658
|
-
var
|
|
2659
|
-
id: "
|
|
2660
|
-
name: "
|
|
2661
|
-
category: "
|
|
2662
|
-
why: "
|
|
2663
|
-
impact: "
|
|
2664
|
-
fix: "
|
|
2316
|
+
var numericSuffixNameDef = {
|
|
2317
|
+
id: "numeric-suffix-name",
|
|
2318
|
+
name: "Numeric Suffix Name",
|
|
2319
|
+
category: "naming",
|
|
2320
|
+
why: "Names with numeric suffixes often indicate copy-paste duplication",
|
|
2321
|
+
impact: "Suggests the element might need componentization",
|
|
2322
|
+
fix: "Remove the suffix or create a component if duplicated"
|
|
2665
2323
|
};
|
|
2666
|
-
var
|
|
2667
|
-
if (
|
|
2668
|
-
return null;
|
|
2324
|
+
var numericSuffixNameCheck = (node, context) => {
|
|
2325
|
+
if (!node.name) return null;
|
|
2326
|
+
if (isExcludedName(node.name)) return null;
|
|
2327
|
+
if (isDefaultName(node.name)) return null;
|
|
2328
|
+
if (!hasNumericSuffix(node.name)) return null;
|
|
2329
|
+
return {
|
|
2330
|
+
ruleId: numericSuffixNameDef.id,
|
|
2331
|
+
nodeId: node.id,
|
|
2332
|
+
nodePath: context.path.join(" > "),
|
|
2333
|
+
message: `"${node.name}" has a numeric suffix - consider renaming`
|
|
2334
|
+
};
|
|
2669
2335
|
};
|
|
2670
2336
|
defineRule({
|
|
2671
|
-
definition:
|
|
2672
|
-
check:
|
|
2337
|
+
definition: numericSuffixNameDef,
|
|
2338
|
+
check: numericSuffixNameCheck
|
|
2673
2339
|
});
|
|
2674
|
-
var
|
|
2675
|
-
id: "
|
|
2676
|
-
name: "
|
|
2677
|
-
category: "
|
|
2678
|
-
why: "
|
|
2679
|
-
impact: "
|
|
2680
|
-
fix: "
|
|
2340
|
+
var tooLongNameDef = {
|
|
2341
|
+
id: "too-long-name",
|
|
2342
|
+
name: "Too Long Name",
|
|
2343
|
+
category: "naming",
|
|
2344
|
+
why: "Very long names are hard to read and use in code",
|
|
2345
|
+
impact: "Clutters the layer panel and makes selectors unwieldy",
|
|
2346
|
+
fix: "Shorten the name while keeping it descriptive"
|
|
2681
2347
|
};
|
|
2682
|
-
var
|
|
2683
|
-
return null;
|
|
2348
|
+
var tooLongNameCheck = (node, context, options) => {
|
|
2349
|
+
if (!node.name) return null;
|
|
2350
|
+
const maxLength = options?.["maxLength"] ?? getRuleOption("too-long-name", "maxLength", 50);
|
|
2351
|
+
if (node.name.length <= maxLength) return null;
|
|
2352
|
+
return {
|
|
2353
|
+
ruleId: tooLongNameDef.id,
|
|
2354
|
+
nodeId: node.id,
|
|
2355
|
+
nodePath: context.path.join(" > "),
|
|
2356
|
+
message: `"${node.name.substring(0, 30)}..." is ${node.name.length} chars (max: ${maxLength})`
|
|
2357
|
+
};
|
|
2684
2358
|
};
|
|
2685
2359
|
defineRule({
|
|
2686
|
-
definition:
|
|
2687
|
-
check:
|
|
2360
|
+
definition: tooLongNameDef,
|
|
2361
|
+
check: tooLongNameCheck
|
|
2688
2362
|
});
|
|
2689
2363
|
|
|
2690
|
-
// src/core/rules/
|
|
2691
|
-
function
|
|
2692
|
-
return node.
|
|
2364
|
+
// src/core/rules/ai-readability/index.ts
|
|
2365
|
+
function hasAutoLayout2(node) {
|
|
2366
|
+
return node.layoutMode !== void 0 && node.layoutMode !== "NONE";
|
|
2693
2367
|
}
|
|
2694
|
-
function
|
|
2695
|
-
return node.type === "
|
|
2368
|
+
function isContainerNode2(node) {
|
|
2369
|
+
return node.type === "FRAME" || node.type === "GROUP" || node.type === "COMPONENT";
|
|
2696
2370
|
}
|
|
2697
|
-
function
|
|
2698
|
-
|
|
2699
|
-
|
|
2700
|
-
|
|
2701
|
-
|
|
2702
|
-
}
|
|
2703
|
-
if (node.children) {
|
|
2704
|
-
for (const child of node.children) {
|
|
2705
|
-
collectFrameNames(child, names);
|
|
2706
|
-
}
|
|
2707
|
-
}
|
|
2708
|
-
return names;
|
|
2371
|
+
function hasOverlappingBounds(a, b) {
|
|
2372
|
+
const boxA = a.absoluteBoundingBox;
|
|
2373
|
+
const boxB = b.absoluteBoundingBox;
|
|
2374
|
+
if (!boxA || !boxB) return false;
|
|
2375
|
+
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);
|
|
2709
2376
|
}
|
|
2710
|
-
var
|
|
2711
|
-
id: "
|
|
2712
|
-
name: "
|
|
2713
|
-
category: "
|
|
2714
|
-
why: "
|
|
2715
|
-
impact: "
|
|
2716
|
-
fix: "
|
|
2377
|
+
var ambiguousStructureDef = {
|
|
2378
|
+
id: "ambiguous-structure",
|
|
2379
|
+
name: "Ambiguous Structure",
|
|
2380
|
+
category: "ai-readability",
|
|
2381
|
+
why: "Overlapping nodes without Auto Layout create ambiguous visual hierarchy",
|
|
2382
|
+
impact: "AI cannot reliably determine the reading order or structure",
|
|
2383
|
+
fix: "Use Auto Layout to create clear, explicit structure"
|
|
2717
2384
|
};
|
|
2718
|
-
var
|
|
2719
|
-
if (node
|
|
2720
|
-
|
|
2721
|
-
|
|
2722
|
-
|
|
2723
|
-
|
|
2724
|
-
|
|
2725
|
-
|
|
2726
|
-
|
|
2727
|
-
|
|
2728
|
-
|
|
2729
|
-
|
|
2730
|
-
|
|
2385
|
+
var ambiguousStructureCheck = (node, context) => {
|
|
2386
|
+
if (!isContainerNode2(node)) return null;
|
|
2387
|
+
if (hasAutoLayout2(node)) return null;
|
|
2388
|
+
if (!node.children || node.children.length < 2) return null;
|
|
2389
|
+
for (let i = 0; i < node.children.length; i++) {
|
|
2390
|
+
for (let j = i + 1; j < node.children.length; j++) {
|
|
2391
|
+
const childA = node.children[i];
|
|
2392
|
+
const childB = node.children[j];
|
|
2393
|
+
if (childA && childB && hasOverlappingBounds(childA, childB)) {
|
|
2394
|
+
if (childA.visible !== false && childB.visible !== false) {
|
|
2395
|
+
return {
|
|
2396
|
+
ruleId: ambiguousStructureDef.id,
|
|
2397
|
+
nodeId: node.id,
|
|
2398
|
+
nodePath: context.path.join(" > "),
|
|
2399
|
+
message: `"${node.name}" has overlapping children without Auto Layout`
|
|
2400
|
+
};
|
|
2401
|
+
}
|
|
2402
|
+
}
|
|
2731
2403
|
}
|
|
2732
2404
|
}
|
|
2733
2405
|
return null;
|
|
2734
2406
|
};
|
|
2735
2407
|
defineRule({
|
|
2736
|
-
definition:
|
|
2737
|
-
check:
|
|
2408
|
+
definition: ambiguousStructureDef,
|
|
2409
|
+
check: ambiguousStructureCheck
|
|
2738
2410
|
});
|
|
2739
|
-
var
|
|
2740
|
-
id: "
|
|
2741
|
-
name: "
|
|
2742
|
-
category: "
|
|
2743
|
-
why: "
|
|
2744
|
-
impact: "
|
|
2745
|
-
fix: "
|
|
2411
|
+
var zIndexDependentLayoutDef = {
|
|
2412
|
+
id: "z-index-dependent-layout",
|
|
2413
|
+
name: "Z-Index Dependent Layout",
|
|
2414
|
+
category: "ai-readability",
|
|
2415
|
+
why: "Using overlapping layers to create visual layout is hard to interpret",
|
|
2416
|
+
impact: "Code generation may misinterpret the intended layout",
|
|
2417
|
+
fix: "Restructure using Auto Layout to express the visual relationship explicitly"
|
|
2746
2418
|
};
|
|
2747
|
-
var
|
|
2748
|
-
if (node
|
|
2749
|
-
|
|
2750
|
-
|
|
2751
|
-
for (
|
|
2752
|
-
|
|
2753
|
-
|
|
2754
|
-
|
|
2755
|
-
|
|
2756
|
-
|
|
2757
|
-
|
|
2758
|
-
|
|
2419
|
+
var zIndexDependentLayoutCheck = (node, context) => {
|
|
2420
|
+
if (!isContainerNode2(node)) return null;
|
|
2421
|
+
if (!node.children || node.children.length < 2) return null;
|
|
2422
|
+
let significantOverlapCount = 0;
|
|
2423
|
+
for (let i = 0; i < node.children.length; i++) {
|
|
2424
|
+
for (let j = i + 1; j < node.children.length; j++) {
|
|
2425
|
+
const childA = node.children[i];
|
|
2426
|
+
const childB = node.children[j];
|
|
2427
|
+
if (!childA || !childB) continue;
|
|
2428
|
+
if (childA.visible === false || childB.visible === false) continue;
|
|
2429
|
+
const boxA = childA.absoluteBoundingBox;
|
|
2430
|
+
const boxB = childB.absoluteBoundingBox;
|
|
2431
|
+
if (!boxA || !boxB) continue;
|
|
2432
|
+
if (hasOverlappingBounds(childA, childB)) {
|
|
2433
|
+
const overlapX = Math.min(boxA.x + boxA.width, boxB.x + boxB.width) - Math.max(boxA.x, boxB.x);
|
|
2434
|
+
const overlapY = Math.min(boxA.y + boxA.height, boxB.y + boxB.height) - Math.max(boxA.y, boxB.y);
|
|
2435
|
+
if (overlapX > 0 && overlapY > 0) {
|
|
2436
|
+
const overlapArea = overlapX * overlapY;
|
|
2437
|
+
const smallerArea = Math.min(
|
|
2438
|
+
boxA.width * boxA.height,
|
|
2439
|
+
boxB.width * boxB.height
|
|
2440
|
+
);
|
|
2441
|
+
if (overlapArea > smallerArea * 0.2) {
|
|
2442
|
+
significantOverlapCount++;
|
|
2443
|
+
}
|
|
2444
|
+
}
|
|
2445
|
+
}
|
|
2759
2446
|
}
|
|
2760
2447
|
}
|
|
2761
|
-
|
|
2762
|
-
};
|
|
2763
|
-
defineRule({
|
|
2764
|
-
definition: detachedInstanceDef,
|
|
2765
|
-
check: detachedInstanceCheck
|
|
2766
|
-
});
|
|
2767
|
-
var nestedInstanceOverrideDef = {
|
|
2768
|
-
id: "nested-instance-override",
|
|
2769
|
-
name: "Nested Instance Override",
|
|
2770
|
-
category: "component",
|
|
2771
|
-
why: "Excessive overrides in instances make components harder to maintain",
|
|
2772
|
-
impact: "Component updates may not work as expected",
|
|
2773
|
-
fix: "Create a variant or new component for significantly different use cases"
|
|
2774
|
-
};
|
|
2775
|
-
var nestedInstanceOverrideCheck = (node, context) => {
|
|
2776
|
-
if (!isComponentInstance(node)) return null;
|
|
2777
|
-
if (!node.componentProperties) return null;
|
|
2778
|
-
const overrideCount = Object.keys(node.componentProperties).length;
|
|
2779
|
-
if (overrideCount > 5) {
|
|
2448
|
+
if (significantOverlapCount > 0) {
|
|
2780
2449
|
return {
|
|
2781
|
-
ruleId:
|
|
2450
|
+
ruleId: zIndexDependentLayoutDef.id,
|
|
2782
2451
|
nodeId: node.id,
|
|
2783
2452
|
nodePath: context.path.join(" > "),
|
|
2784
|
-
message: `"${node.name}"
|
|
2453
|
+
message: `"${node.name}" uses layer stacking for layout (${significantOverlapCount} overlaps)`
|
|
2785
2454
|
};
|
|
2786
2455
|
}
|
|
2787
2456
|
return null;
|
|
2788
2457
|
};
|
|
2789
2458
|
defineRule({
|
|
2790
|
-
definition:
|
|
2791
|
-
check:
|
|
2459
|
+
definition: zIndexDependentLayoutDef,
|
|
2460
|
+
check: zIndexDependentLayoutCheck
|
|
2792
2461
|
});
|
|
2793
|
-
var
|
|
2794
|
-
id: "
|
|
2795
|
-
name: "
|
|
2796
|
-
category: "
|
|
2797
|
-
why: "
|
|
2798
|
-
impact: "
|
|
2799
|
-
fix: "
|
|
2462
|
+
var missingLayoutHintDef = {
|
|
2463
|
+
id: "missing-layout-hint",
|
|
2464
|
+
name: "Missing Layout Hint",
|
|
2465
|
+
category: "ai-readability",
|
|
2466
|
+
why: "Complex nesting without Auto Layout makes structure unpredictable",
|
|
2467
|
+
impact: "AI may generate incorrect code due to ambiguous relationships",
|
|
2468
|
+
fix: "Add Auto Layout or simplify the nesting structure"
|
|
2800
2469
|
};
|
|
2801
|
-
var
|
|
2470
|
+
var missingLayoutHintCheck = (node, context) => {
|
|
2471
|
+
if (!isContainerNode2(node)) return null;
|
|
2472
|
+
if (hasAutoLayout2(node)) return null;
|
|
2473
|
+
if (!node.children || node.children.length === 0) return null;
|
|
2474
|
+
const nestedContainers = node.children.filter((c) => isContainerNode2(c));
|
|
2475
|
+
if (nestedContainers.length >= 2) {
|
|
2476
|
+
const withoutLayout = nestedContainers.filter((c) => !hasAutoLayout2(c));
|
|
2477
|
+
if (withoutLayout.length >= 2) {
|
|
2478
|
+
return {
|
|
2479
|
+
ruleId: missingLayoutHintDef.id,
|
|
2480
|
+
nodeId: node.id,
|
|
2481
|
+
nodePath: context.path.join(" > "),
|
|
2482
|
+
message: `"${node.name}" has ${withoutLayout.length} nested containers without layout hints`
|
|
2483
|
+
};
|
|
2484
|
+
}
|
|
2485
|
+
}
|
|
2802
2486
|
return null;
|
|
2803
2487
|
};
|
|
2804
2488
|
defineRule({
|
|
2805
|
-
definition:
|
|
2806
|
-
check:
|
|
2489
|
+
definition: missingLayoutHintDef,
|
|
2490
|
+
check: missingLayoutHintCheck
|
|
2807
2491
|
});
|
|
2808
|
-
var
|
|
2809
|
-
id: "
|
|
2810
|
-
name: "
|
|
2811
|
-
category: "
|
|
2812
|
-
why: "
|
|
2813
|
-
impact: "
|
|
2814
|
-
fix: "
|
|
2492
|
+
var invisibleLayerDef = {
|
|
2493
|
+
id: "invisible-layer",
|
|
2494
|
+
name: "Invisible Layer",
|
|
2495
|
+
category: "ai-readability",
|
|
2496
|
+
why: "Hidden layers add noise and may confuse analysis tools",
|
|
2497
|
+
impact: "Exported code may include unnecessary elements",
|
|
2498
|
+
fix: "Delete hidden layers or move them to a separate 'archive' page"
|
|
2815
2499
|
};
|
|
2816
|
-
var
|
|
2817
|
-
if (
|
|
2818
|
-
if (
|
|
2819
|
-
|
|
2820
|
-
|
|
2821
|
-
|
|
2500
|
+
var invisibleLayerCheck = (node, context) => {
|
|
2501
|
+
if (node.visible !== false) return null;
|
|
2502
|
+
if (context.parent?.visible === false) return null;
|
|
2503
|
+
return {
|
|
2504
|
+
ruleId: invisibleLayerDef.id,
|
|
2505
|
+
nodeId: node.id,
|
|
2506
|
+
nodePath: context.path.join(" > "),
|
|
2507
|
+
message: `"${node.name}" is hidden - consider removing if not needed`
|
|
2508
|
+
};
|
|
2822
2509
|
};
|
|
2823
2510
|
defineRule({
|
|
2824
|
-
definition:
|
|
2825
|
-
check:
|
|
2511
|
+
definition: invisibleLayerDef,
|
|
2512
|
+
check: invisibleLayerCheck
|
|
2826
2513
|
});
|
|
2827
|
-
var
|
|
2828
|
-
id: "
|
|
2829
|
-
name: "
|
|
2830
|
-
category: "
|
|
2831
|
-
why: "
|
|
2832
|
-
impact: "
|
|
2833
|
-
fix: "
|
|
2514
|
+
var emptyFrameDef = {
|
|
2515
|
+
id: "empty-frame",
|
|
2516
|
+
name: "Empty Frame",
|
|
2517
|
+
category: "ai-readability",
|
|
2518
|
+
why: "Empty frames add noise and may indicate incomplete design",
|
|
2519
|
+
impact: "Generates unnecessary wrapper elements in code",
|
|
2520
|
+
fix: "Remove the frame or add content"
|
|
2834
2521
|
};
|
|
2835
|
-
var
|
|
2836
|
-
if (
|
|
2837
|
-
|
|
2838
|
-
|
|
2839
|
-
|
|
2840
|
-
|
|
2841
|
-
|
|
2842
|
-
|
|
2843
|
-
|
|
2844
|
-
|
|
2845
|
-
|
|
2522
|
+
var emptyFrameCheck = (node, context) => {
|
|
2523
|
+
if (node.type !== "FRAME") return null;
|
|
2524
|
+
if (node.children && node.children.length > 0) return null;
|
|
2525
|
+
if (node.absoluteBoundingBox) {
|
|
2526
|
+
const { width, height } = node.absoluteBoundingBox;
|
|
2527
|
+
if (width <= 48 && height <= 48) return null;
|
|
2528
|
+
}
|
|
2529
|
+
return {
|
|
2530
|
+
ruleId: emptyFrameDef.id,
|
|
2531
|
+
nodeId: node.id,
|
|
2532
|
+
nodePath: context.path.join(" > "),
|
|
2533
|
+
message: `"${node.name}" is an empty frame`
|
|
2534
|
+
};
|
|
2535
|
+
};
|
|
2536
|
+
defineRule({
|
|
2537
|
+
definition: emptyFrameDef,
|
|
2538
|
+
check: emptyFrameCheck
|
|
2539
|
+
});
|
|
2540
|
+
|
|
2541
|
+
// src/core/rules/handoff-risk/index.ts
|
|
2542
|
+
function hasAutoLayout3(node) {
|
|
2543
|
+
return node.layoutMode !== void 0 && node.layoutMode !== "NONE";
|
|
2544
|
+
}
|
|
2545
|
+
function isContainerNode3(node) {
|
|
2546
|
+
return node.type === "FRAME" || node.type === "GROUP" || node.type === "COMPONENT";
|
|
2547
|
+
}
|
|
2548
|
+
function isTextNode(node) {
|
|
2549
|
+
return node.type === "TEXT";
|
|
2550
|
+
}
|
|
2551
|
+
function isImageNode(node) {
|
|
2552
|
+
if (node.type === "RECTANGLE" && node.fills) {
|
|
2553
|
+
for (const fill of node.fills) {
|
|
2554
|
+
const fillObj = fill;
|
|
2555
|
+
if (fillObj["type"] === "IMAGE") return true;
|
|
2846
2556
|
}
|
|
2847
2557
|
}
|
|
2848
|
-
|
|
2849
|
-
|
|
2558
|
+
return false;
|
|
2559
|
+
}
|
|
2560
|
+
var hardcodeRiskDef = {
|
|
2561
|
+
id: "hardcode-risk",
|
|
2562
|
+
name: "Hardcode Risk",
|
|
2563
|
+
category: "handoff-risk",
|
|
2564
|
+
why: "Absolute positioning with fixed values creates inflexible layouts",
|
|
2565
|
+
impact: "Layout will break when content changes or on different screens",
|
|
2566
|
+
fix: "Use Auto Layout with relative positioning"
|
|
2567
|
+
};
|
|
2568
|
+
var hardcodeRiskCheck = (node, context) => {
|
|
2569
|
+
if (!isContainerNode3(node)) return null;
|
|
2570
|
+
if (node.layoutPositioning !== "ABSOLUTE") return null;
|
|
2571
|
+
if (context.parent && hasAutoLayout3(context.parent)) {
|
|
2850
2572
|
return {
|
|
2851
|
-
ruleId:
|
|
2573
|
+
ruleId: hardcodeRiskDef.id,
|
|
2852
2574
|
nodeId: node.id,
|
|
2853
2575
|
nodePath: context.path.join(" > "),
|
|
2854
|
-
message: `
|
|
2576
|
+
message: `"${node.name}" uses absolute positioning with fixed values`
|
|
2855
2577
|
};
|
|
2856
2578
|
}
|
|
2857
2579
|
return null;
|
|
2858
2580
|
};
|
|
2859
2581
|
defineRule({
|
|
2860
|
-
definition:
|
|
2861
|
-
check:
|
|
2582
|
+
definition: hardcodeRiskDef,
|
|
2583
|
+
check: hardcodeRiskCheck
|
|
2862
2584
|
});
|
|
2863
|
-
|
|
2864
|
-
|
|
2865
|
-
|
|
2866
|
-
|
|
2867
|
-
|
|
2868
|
-
|
|
2869
|
-
|
|
2870
|
-
|
|
2871
|
-
|
|
2872
|
-
|
|
2873
|
-
|
|
2874
|
-
|
|
2875
|
-
|
|
2876
|
-
|
|
2877
|
-
|
|
2878
|
-
|
|
2879
|
-
|
|
2880
|
-
|
|
2881
|
-
|
|
2882
|
-
|
|
2883
|
-
|
|
2884
|
-
|
|
2885
|
-
|
|
2886
|
-
|
|
2887
|
-
|
|
2888
|
-
|
|
2889
|
-
|
|
2890
|
-
|
|
2891
|
-
|
|
2892
|
-
|
|
2893
|
-
|
|
2894
|
-
|
|
2895
|
-
|
|
2896
|
-
|
|
2897
|
-
|
|
2898
|
-
|
|
2899
|
-
}
|
|
2900
|
-
|
|
2901
|
-
if (
|
|
2902
|
-
if (
|
|
2903
|
-
|
|
2904
|
-
|
|
2905
|
-
|
|
2906
|
-
|
|
2585
|
+
var textTruncationUnhandledDef = {
|
|
2586
|
+
id: "text-truncation-unhandled",
|
|
2587
|
+
name: "Text Truncation Unhandled",
|
|
2588
|
+
category: "handoff-risk",
|
|
2589
|
+
why: "Text nodes without truncation handling may overflow",
|
|
2590
|
+
impact: "Long text will break the layout",
|
|
2591
|
+
fix: "Set text truncation (ellipsis) or ensure container can grow"
|
|
2592
|
+
};
|
|
2593
|
+
var textTruncationUnhandledCheck = (node, context) => {
|
|
2594
|
+
if (!isTextNode(node)) return null;
|
|
2595
|
+
if (!context.parent) return null;
|
|
2596
|
+
if (!hasAutoLayout3(context.parent)) return null;
|
|
2597
|
+
if (node.absoluteBoundingBox) {
|
|
2598
|
+
const { width } = node.absoluteBoundingBox;
|
|
2599
|
+
if (node.characters && node.characters.length > 50 && width < 300) {
|
|
2600
|
+
return {
|
|
2601
|
+
ruleId: textTruncationUnhandledDef.id,
|
|
2602
|
+
nodeId: node.id,
|
|
2603
|
+
nodePath: context.path.join(" > "),
|
|
2604
|
+
message: `"${node.name}" may need text truncation handling`
|
|
2605
|
+
};
|
|
2606
|
+
}
|
|
2607
|
+
}
|
|
2608
|
+
return null;
|
|
2609
|
+
};
|
|
2610
|
+
defineRule({
|
|
2611
|
+
definition: textTruncationUnhandledDef,
|
|
2612
|
+
check: textTruncationUnhandledCheck
|
|
2613
|
+
});
|
|
2614
|
+
var imageNoPlaceholderDef = {
|
|
2615
|
+
id: "image-no-placeholder",
|
|
2616
|
+
name: "Image No Placeholder",
|
|
2617
|
+
category: "handoff-risk",
|
|
2618
|
+
why: "Images without placeholder state may cause layout shifts",
|
|
2619
|
+
impact: "Poor user experience during image loading",
|
|
2620
|
+
fix: "Define a placeholder state or background color"
|
|
2621
|
+
};
|
|
2622
|
+
var imageNoPlaceholderCheck = (node, context) => {
|
|
2623
|
+
if (!isImageNode(node)) return null;
|
|
2624
|
+
if (node.fills && Array.isArray(node.fills) && node.fills.length === 1) {
|
|
2625
|
+
const fill = node.fills[0];
|
|
2626
|
+
if (fill["type"] === "IMAGE") {
|
|
2627
|
+
return {
|
|
2628
|
+
ruleId: imageNoPlaceholderDef.id,
|
|
2629
|
+
nodeId: node.id,
|
|
2630
|
+
nodePath: context.path.join(" > "),
|
|
2631
|
+
message: `"${node.name}" image has no placeholder fill`
|
|
2632
|
+
};
|
|
2633
|
+
}
|
|
2634
|
+
}
|
|
2907
2635
|
return null;
|
|
2908
|
-
}
|
|
2909
|
-
var defaultNameDef = {
|
|
2910
|
-
id: "default-name",
|
|
2911
|
-
name: "Default Name",
|
|
2912
|
-
category: "naming",
|
|
2913
|
-
why: "Default names like 'Frame 123' provide no context about the element's purpose",
|
|
2914
|
-
impact: "Designers and developers cannot understand the structure",
|
|
2915
|
-
fix: "Rename with a descriptive, semantic name (e.g., 'Header', 'ProductCard')"
|
|
2916
2636
|
};
|
|
2917
|
-
|
|
2918
|
-
|
|
2919
|
-
|
|
2920
|
-
|
|
2921
|
-
|
|
2922
|
-
|
|
2923
|
-
|
|
2924
|
-
|
|
2925
|
-
|
|
2926
|
-
|
|
2637
|
+
defineRule({
|
|
2638
|
+
definition: imageNoPlaceholderDef,
|
|
2639
|
+
check: imageNoPlaceholderCheck
|
|
2640
|
+
});
|
|
2641
|
+
var prototypeLinkInDesignDef = {
|
|
2642
|
+
id: "prototype-link-in-design",
|
|
2643
|
+
name: "Prototype Link in Design",
|
|
2644
|
+
category: "handoff-risk",
|
|
2645
|
+
why: "Prototype connections may affect how the design is interpreted",
|
|
2646
|
+
impact: "Developers may misunderstand which elements should be interactive",
|
|
2647
|
+
fix: "Document interactions separately or use clear naming"
|
|
2648
|
+
};
|
|
2649
|
+
var prototypeLinkInDesignCheck = (_node, _context) => {
|
|
2650
|
+
return null;
|
|
2927
2651
|
};
|
|
2928
2652
|
defineRule({
|
|
2929
|
-
definition:
|
|
2930
|
-
check:
|
|
2653
|
+
definition: prototypeLinkInDesignDef,
|
|
2654
|
+
check: prototypeLinkInDesignCheck
|
|
2931
2655
|
});
|
|
2932
|
-
var
|
|
2933
|
-
id: "
|
|
2934
|
-
name: "
|
|
2935
|
-
category: "
|
|
2936
|
-
why: "
|
|
2937
|
-
impact: "
|
|
2938
|
-
fix: "
|
|
2656
|
+
var noDevStatusDef = {
|
|
2657
|
+
id: "no-dev-status",
|
|
2658
|
+
name: "No Dev Status",
|
|
2659
|
+
category: "handoff-risk",
|
|
2660
|
+
why: "Without dev status, developers cannot know if a design is ready",
|
|
2661
|
+
impact: "May implement designs that are still in progress",
|
|
2662
|
+
fix: "Mark frames as 'Ready for Dev' or 'Completed' when appropriate"
|
|
2939
2663
|
};
|
|
2940
|
-
var
|
|
2941
|
-
if (
|
|
2942
|
-
if (
|
|
2943
|
-
if (
|
|
2944
|
-
if (!node.children || node.children.length === 0) {
|
|
2945
|
-
const shapeTypes = ["RECTANGLE", "ELLIPSE", "VECTOR", "LINE", "STAR", "REGULAR_POLYGON"];
|
|
2946
|
-
if (shapeTypes.includes(node.type)) return null;
|
|
2947
|
-
}
|
|
2664
|
+
var noDevStatusCheck = (node, context) => {
|
|
2665
|
+
if (node.type !== "FRAME") return null;
|
|
2666
|
+
if (context.depth > 1) return null;
|
|
2667
|
+
if (node.devStatus) return null;
|
|
2948
2668
|
return {
|
|
2949
|
-
ruleId:
|
|
2669
|
+
ruleId: noDevStatusDef.id,
|
|
2950
2670
|
nodeId: node.id,
|
|
2951
2671
|
nodePath: context.path.join(" > "),
|
|
2952
|
-
message: `"${node.name}"
|
|
2672
|
+
message: `"${node.name}" has no dev status set`
|
|
2953
2673
|
};
|
|
2954
2674
|
};
|
|
2955
2675
|
defineRule({
|
|
2956
|
-
definition:
|
|
2957
|
-
check:
|
|
2676
|
+
definition: noDevStatusDef,
|
|
2677
|
+
check: noDevStatusCheck
|
|
2958
2678
|
});
|
|
2959
|
-
var
|
|
2960
|
-
|
|
2961
|
-
|
|
2962
|
-
|
|
2963
|
-
|
|
2964
|
-
|
|
2965
|
-
|
|
2679
|
+
var SamplingStrategySchema = z.enum(["all", "top-issues", "random"]);
|
|
2680
|
+
z.enum([
|
|
2681
|
+
"pending",
|
|
2682
|
+
"analyzing",
|
|
2683
|
+
"converting",
|
|
2684
|
+
"evaluating",
|
|
2685
|
+
"tuning",
|
|
2686
|
+
"completed",
|
|
2687
|
+
"failed"
|
|
2688
|
+
]);
|
|
2689
|
+
z.object({
|
|
2690
|
+
input: z.string(),
|
|
2691
|
+
token: z.string().optional(),
|
|
2692
|
+
targetNodeId: z.string().optional(),
|
|
2693
|
+
maxConversionNodes: z.number().int().positive().default(20),
|
|
2694
|
+
samplingStrategy: SamplingStrategySchema.default("top-issues"),
|
|
2695
|
+
outputPath: z.string().default("logs/calibration/calibration-report.md")
|
|
2696
|
+
});
|
|
2697
|
+
z.object({
|
|
2698
|
+
nodeId: z.string(),
|
|
2699
|
+
nodePath: z.string(),
|
|
2700
|
+
totalScore: z.number(),
|
|
2701
|
+
issueCount: z.number(),
|
|
2702
|
+
flaggedRuleIds: z.array(z.string()),
|
|
2703
|
+
severities: z.array(z.string())
|
|
2704
|
+
});
|
|
2705
|
+
var DifficultySchema = z.enum(["easy", "moderate", "hard", "failed"]);
|
|
2706
|
+
var RuleRelatedStruggleSchema = z.object({
|
|
2707
|
+
ruleId: z.string(),
|
|
2708
|
+
description: z.string(),
|
|
2709
|
+
actualImpact: DifficultySchema
|
|
2710
|
+
});
|
|
2711
|
+
var UncoveredStruggleSchema = z.object({
|
|
2712
|
+
description: z.string(),
|
|
2713
|
+
suggestedCategory: z.string(),
|
|
2714
|
+
estimatedImpact: DifficultySchema
|
|
2715
|
+
});
|
|
2716
|
+
z.object({
|
|
2717
|
+
nodeId: z.string(),
|
|
2718
|
+
nodePath: z.string(),
|
|
2719
|
+
generatedCode: z.string(),
|
|
2720
|
+
difficulty: DifficultySchema,
|
|
2721
|
+
notes: z.string(),
|
|
2722
|
+
ruleRelatedStruggles: z.array(RuleRelatedStruggleSchema),
|
|
2723
|
+
uncoveredStruggles: z.array(UncoveredStruggleSchema),
|
|
2724
|
+
durationMs: z.number()
|
|
2725
|
+
});
|
|
2726
|
+
var MismatchTypeSchema = z.enum([
|
|
2727
|
+
"overscored",
|
|
2728
|
+
"underscored",
|
|
2729
|
+
"missing-rule",
|
|
2730
|
+
"validated"
|
|
2731
|
+
]);
|
|
2732
|
+
z.object({
|
|
2733
|
+
type: MismatchTypeSchema,
|
|
2734
|
+
nodeId: z.string(),
|
|
2735
|
+
nodePath: z.string(),
|
|
2736
|
+
ruleId: z.string().optional(),
|
|
2737
|
+
currentScore: z.number().optional(),
|
|
2738
|
+
currentSeverity: SeveritySchema.optional(),
|
|
2739
|
+
actualDifficulty: DifficultySchema,
|
|
2740
|
+
reasoning: z.string()
|
|
2741
|
+
});
|
|
2742
|
+
var ConfidenceSchema = z.enum(["high", "medium", "low"]);
|
|
2743
|
+
z.object({
|
|
2744
|
+
ruleId: z.string(),
|
|
2745
|
+
currentScore: z.number(),
|
|
2746
|
+
proposedScore: z.number(),
|
|
2747
|
+
currentSeverity: SeveritySchema,
|
|
2748
|
+
proposedSeverity: SeveritySchema.optional(),
|
|
2749
|
+
reasoning: z.string(),
|
|
2750
|
+
confidence: ConfidenceSchema,
|
|
2751
|
+
supportingCases: z.number()
|
|
2752
|
+
});
|
|
2753
|
+
z.object({
|
|
2754
|
+
suggestedId: z.string(),
|
|
2755
|
+
category: z.string(),
|
|
2756
|
+
description: z.string(),
|
|
2757
|
+
suggestedSeverity: SeveritySchema,
|
|
2758
|
+
suggestedScore: z.number(),
|
|
2759
|
+
reasoning: z.string(),
|
|
2760
|
+
supportingCases: z.number()
|
|
2761
|
+
});
|
|
2762
|
+
|
|
2763
|
+
// src/core/engine/scoring.ts
|
|
2764
|
+
var SEVERITY_DENSITY_WEIGHT = {
|
|
2765
|
+
blocking: 3,
|
|
2766
|
+
risk: 2,
|
|
2767
|
+
"missing-info": 1,
|
|
2768
|
+
suggestion: 0.5
|
|
2966
2769
|
};
|
|
2967
|
-
var
|
|
2968
|
-
|
|
2969
|
-
|
|
2970
|
-
|
|
2971
|
-
|
|
2972
|
-
|
|
2973
|
-
|
|
2974
|
-
|
|
2770
|
+
var TOTAL_RULES_PER_CATEGORY = {
|
|
2771
|
+
layout: 11,
|
|
2772
|
+
token: 7,
|
|
2773
|
+
component: 6,
|
|
2774
|
+
naming: 5,
|
|
2775
|
+
"ai-readability": 5,
|
|
2776
|
+
"handoff-risk": 5
|
|
2777
|
+
};
|
|
2778
|
+
var CATEGORY_WEIGHT = {
|
|
2779
|
+
layout: 1,
|
|
2780
|
+
token: 1,
|
|
2781
|
+
component: 1,
|
|
2782
|
+
naming: 1,
|
|
2783
|
+
"ai-readability": 1,
|
|
2784
|
+
"handoff-risk": 1
|
|
2785
|
+
};
|
|
2786
|
+
var DENSITY_WEIGHT = 0.7;
|
|
2787
|
+
var DIVERSITY_WEIGHT = 0.3;
|
|
2788
|
+
var SCORE_FLOOR = 5;
|
|
2789
|
+
function calculateGrade(percentage) {
|
|
2790
|
+
if (percentage >= 95) return "S";
|
|
2791
|
+
if (percentage >= 90) return "A+";
|
|
2792
|
+
if (percentage >= 85) return "A";
|
|
2793
|
+
if (percentage >= 80) return "B+";
|
|
2794
|
+
if (percentage >= 75) return "B";
|
|
2795
|
+
if (percentage >= 70) return "C+";
|
|
2796
|
+
if (percentage >= 65) return "C";
|
|
2797
|
+
if (percentage >= 50) return "D";
|
|
2798
|
+
return "F";
|
|
2799
|
+
}
|
|
2800
|
+
function clamp(value, min, max) {
|
|
2801
|
+
return Math.max(min, Math.min(max, value));
|
|
2802
|
+
}
|
|
2803
|
+
function calculateScores(result) {
|
|
2804
|
+
const categoryScores = initializeCategoryScores();
|
|
2805
|
+
const nodeCount = result.nodeCount;
|
|
2806
|
+
const uniqueRulesPerCategory = /* @__PURE__ */ new Map();
|
|
2807
|
+
for (const category of CATEGORIES) {
|
|
2808
|
+
uniqueRulesPerCategory.set(category, /* @__PURE__ */ new Set());
|
|
2809
|
+
}
|
|
2810
|
+
for (const issue of result.issues) {
|
|
2811
|
+
const category = issue.rule.definition.category;
|
|
2812
|
+
const severity = issue.config.severity;
|
|
2813
|
+
const ruleId = issue.rule.definition.id;
|
|
2814
|
+
categoryScores[category].issueCount++;
|
|
2815
|
+
categoryScores[category].bySeverity[severity]++;
|
|
2816
|
+
categoryScores[category].weightedIssueCount += SEVERITY_DENSITY_WEIGHT[severity];
|
|
2817
|
+
uniqueRulesPerCategory.get(category).add(ruleId);
|
|
2818
|
+
}
|
|
2819
|
+
for (const category of CATEGORIES) {
|
|
2820
|
+
const catScore = categoryScores[category];
|
|
2821
|
+
const uniqueRules = uniqueRulesPerCategory.get(category);
|
|
2822
|
+
const totalRules = TOTAL_RULES_PER_CATEGORY[category];
|
|
2823
|
+
catScore.uniqueRuleCount = uniqueRules.size;
|
|
2824
|
+
let densityScore = 100;
|
|
2825
|
+
if (nodeCount > 0 && catScore.issueCount > 0) {
|
|
2826
|
+
const density = catScore.weightedIssueCount / nodeCount;
|
|
2827
|
+
densityScore = clamp(Math.round(100 - density * 100), 0, 100);
|
|
2828
|
+
}
|
|
2829
|
+
catScore.densityScore = densityScore;
|
|
2830
|
+
let diversityScore = 100;
|
|
2831
|
+
if (catScore.issueCount > 0) {
|
|
2832
|
+
const diversityRatio = uniqueRules.size / totalRules;
|
|
2833
|
+
diversityScore = clamp(Math.round((1 - diversityRatio) * 100), 0, 100);
|
|
2834
|
+
}
|
|
2835
|
+
catScore.diversityScore = diversityScore;
|
|
2836
|
+
const combinedScore = densityScore * DENSITY_WEIGHT + diversityScore * DIVERSITY_WEIGHT;
|
|
2837
|
+
catScore.percentage = catScore.issueCount > 0 ? clamp(Math.round(combinedScore), SCORE_FLOOR, 100) : 100;
|
|
2838
|
+
catScore.score = catScore.percentage;
|
|
2839
|
+
catScore.maxScore = 100;
|
|
2840
|
+
}
|
|
2841
|
+
let totalWeight = 0;
|
|
2842
|
+
let weightedSum = 0;
|
|
2843
|
+
for (const category of CATEGORIES) {
|
|
2844
|
+
const weight = CATEGORY_WEIGHT[category];
|
|
2845
|
+
weightedSum += categoryScores[category].percentage * weight;
|
|
2846
|
+
totalWeight += weight;
|
|
2847
|
+
}
|
|
2848
|
+
const overallPercentage = totalWeight > 0 ? Math.round(weightedSum / totalWeight) : 100;
|
|
2849
|
+
const summary = {
|
|
2850
|
+
totalIssues: result.issues.length,
|
|
2851
|
+
blocking: 0,
|
|
2852
|
+
risk: 0,
|
|
2853
|
+
missingInfo: 0,
|
|
2854
|
+
suggestion: 0,
|
|
2855
|
+
nodeCount
|
|
2856
|
+
};
|
|
2857
|
+
for (const issue of result.issues) {
|
|
2858
|
+
switch (issue.config.severity) {
|
|
2859
|
+
case "blocking":
|
|
2860
|
+
summary.blocking++;
|
|
2861
|
+
break;
|
|
2862
|
+
case "risk":
|
|
2863
|
+
summary.risk++;
|
|
2864
|
+
break;
|
|
2865
|
+
case "missing-info":
|
|
2866
|
+
summary.missingInfo++;
|
|
2867
|
+
break;
|
|
2868
|
+
case "suggestion":
|
|
2869
|
+
summary.suggestion++;
|
|
2870
|
+
break;
|
|
2975
2871
|
}
|
|
2976
2872
|
}
|
|
2977
|
-
|
|
2978
|
-
|
|
2979
|
-
|
|
2980
|
-
|
|
2981
|
-
|
|
2982
|
-
|
|
2983
|
-
|
|
2984
|
-
|
|
2873
|
+
return {
|
|
2874
|
+
overall: {
|
|
2875
|
+
score: overallPercentage,
|
|
2876
|
+
maxScore: 100,
|
|
2877
|
+
percentage: overallPercentage,
|
|
2878
|
+
grade: calculateGrade(overallPercentage)
|
|
2879
|
+
},
|
|
2880
|
+
byCategory: categoryScores,
|
|
2881
|
+
summary
|
|
2882
|
+
};
|
|
2883
|
+
}
|
|
2884
|
+
function initializeCategoryScores() {
|
|
2885
|
+
const scores = {};
|
|
2886
|
+
for (const category of CATEGORIES) {
|
|
2887
|
+
scores[category] = {
|
|
2888
|
+
category,
|
|
2889
|
+
score: 100,
|
|
2890
|
+
maxScore: 100,
|
|
2891
|
+
percentage: 100,
|
|
2892
|
+
issueCount: 0,
|
|
2893
|
+
uniqueRuleCount: 0,
|
|
2894
|
+
weightedIssueCount: 0,
|
|
2895
|
+
densityScore: 100,
|
|
2896
|
+
diversityScore: 100,
|
|
2897
|
+
bySeverity: {
|
|
2898
|
+
blocking: 0,
|
|
2899
|
+
risk: 0,
|
|
2900
|
+
"missing-info": 0,
|
|
2901
|
+
suggestion: 0
|
|
2902
|
+
}
|
|
2903
|
+
};
|
|
2904
|
+
}
|
|
2905
|
+
return scores;
|
|
2906
|
+
}
|
|
2907
|
+
function formatScoreSummary(report) {
|
|
2908
|
+
const lines = [];
|
|
2909
|
+
lines.push(`Overall: ${report.overall.grade} (${report.overall.percentage}%)`);
|
|
2910
|
+
lines.push("");
|
|
2911
|
+
lines.push("By Category:");
|
|
2912
|
+
for (const category of CATEGORIES) {
|
|
2913
|
+
const cat = report.byCategory[category];
|
|
2914
|
+
lines.push(` ${category}: ${cat.percentage}% (${cat.issueCount} issues, ${cat.uniqueRuleCount} rules)`);
|
|
2985
2915
|
}
|
|
2986
|
-
|
|
2987
|
-
|
|
2988
|
-
|
|
2989
|
-
|
|
2990
|
-
|
|
2991
|
-
|
|
2992
|
-
|
|
2993
|
-
|
|
2916
|
+
lines.push("");
|
|
2917
|
+
lines.push("Issues:");
|
|
2918
|
+
lines.push(` Blocking: ${report.summary.blocking}`);
|
|
2919
|
+
lines.push(` Risk: ${report.summary.risk}`);
|
|
2920
|
+
lines.push(` Missing Info: ${report.summary.missingInfo}`);
|
|
2921
|
+
lines.push(` Suggestion: ${report.summary.suggestion}`);
|
|
2922
|
+
lines.push(` Total: ${report.summary.totalIssues}`);
|
|
2923
|
+
return lines.join("\n");
|
|
2924
|
+
}
|
|
2925
|
+
function buildResultJson(fileName, result, scores) {
|
|
2926
|
+
const issuesByRule = {};
|
|
2927
|
+
for (const issue of result.issues) {
|
|
2928
|
+
const id = issue.violation.ruleId;
|
|
2929
|
+
issuesByRule[id] = (issuesByRule[id] ?? 0) + 1;
|
|
2994
2930
|
}
|
|
2995
|
-
return null;
|
|
2996
|
-
};
|
|
2997
|
-
defineRule({
|
|
2998
|
-
definition: inconsistentNamingConventionDef,
|
|
2999
|
-
check: inconsistentNamingConventionCheck
|
|
3000
|
-
});
|
|
3001
|
-
var numericSuffixNameDef = {
|
|
3002
|
-
id: "numeric-suffix-name",
|
|
3003
|
-
name: "Numeric Suffix Name",
|
|
3004
|
-
category: "naming",
|
|
3005
|
-
why: "Names with numeric suffixes often indicate copy-paste duplication",
|
|
3006
|
-
impact: "Suggests the element might need componentization",
|
|
3007
|
-
fix: "Remove the suffix or create a component if duplicated"
|
|
3008
|
-
};
|
|
3009
|
-
var numericSuffixNameCheck = (node, context) => {
|
|
3010
|
-
if (!node.name) return null;
|
|
3011
|
-
if (isExcludedName(node.name)) return null;
|
|
3012
|
-
if (isDefaultName(node.name)) return null;
|
|
3013
|
-
if (!hasNumericSuffix(node.name)) return null;
|
|
3014
2931
|
return {
|
|
3015
|
-
|
|
3016
|
-
|
|
3017
|
-
|
|
3018
|
-
|
|
2932
|
+
version,
|
|
2933
|
+
fileName,
|
|
2934
|
+
nodeCount: result.nodeCount,
|
|
2935
|
+
maxDepth: result.maxDepth,
|
|
2936
|
+
issueCount: result.issues.length,
|
|
2937
|
+
scores: {
|
|
2938
|
+
overall: scores.overall,
|
|
2939
|
+
categories: scores.byCategory
|
|
2940
|
+
},
|
|
2941
|
+
issuesByRule,
|
|
2942
|
+
summary: formatScoreSummary(scores)
|
|
3019
2943
|
};
|
|
2944
|
+
}
|
|
2945
|
+
|
|
2946
|
+
// src/core/ui-constants.ts
|
|
2947
|
+
var GAUGE_R = 54;
|
|
2948
|
+
var GAUGE_C = Math.round(2 * Math.PI * GAUGE_R);
|
|
2949
|
+
var CATEGORY_DESCRIPTIONS = {
|
|
2950
|
+
layout: "Auto Layout, responsive constraints, nesting depth, absolute positioning",
|
|
2951
|
+
token: "Design token binding for colors, fonts, shadows, spacing grid",
|
|
2952
|
+
component: "Component reuse, detached instances, variant coverage",
|
|
2953
|
+
naming: "Semantic layer names, naming conventions, default names",
|
|
2954
|
+
"ai-readability": "Structure clarity for AI code generation, z-index, empty frames",
|
|
2955
|
+
"handoff-risk": "Hardcoded values, text truncation, image placeholders, dev status"
|
|
3020
2956
|
};
|
|
3021
|
-
|
|
3022
|
-
|
|
3023
|
-
|
|
3024
|
-
|
|
3025
|
-
|
|
3026
|
-
|
|
3027
|
-
|
|
3028
|
-
|
|
3029
|
-
|
|
3030
|
-
|
|
3031
|
-
|
|
3032
|
-
|
|
3033
|
-
|
|
3034
|
-
|
|
3035
|
-
|
|
3036
|
-
|
|
3037
|
-
|
|
3038
|
-
|
|
3039
|
-
|
|
3040
|
-
|
|
3041
|
-
|
|
2957
|
+
var SEVERITY_ORDER = [
|
|
2958
|
+
"blocking",
|
|
2959
|
+
"risk",
|
|
2960
|
+
"missing-info",
|
|
2961
|
+
"suggestion"
|
|
2962
|
+
];
|
|
2963
|
+
|
|
2964
|
+
// src/core/ui-helpers.ts
|
|
2965
|
+
function gaugeColor(pct) {
|
|
2966
|
+
if (pct >= 75) return "#22c55e";
|
|
2967
|
+
if (pct >= 50) return "#f59e0b";
|
|
2968
|
+
return "#ef4444";
|
|
2969
|
+
}
|
|
2970
|
+
function escapeHtml(text) {
|
|
2971
|
+
return text.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
2972
|
+
}
|
|
2973
|
+
function severityDot(sev) {
|
|
2974
|
+
const map = {
|
|
2975
|
+
blocking: "bg-red-500",
|
|
2976
|
+
risk: "bg-amber-500",
|
|
2977
|
+
"missing-info": "bg-zinc-400",
|
|
2978
|
+
suggestion: "bg-green-500"
|
|
3042
2979
|
};
|
|
3043
|
-
|
|
3044
|
-
|
|
3045
|
-
|
|
3046
|
-
|
|
3047
|
-
|
|
2980
|
+
return map[sev];
|
|
2981
|
+
}
|
|
2982
|
+
function severityBadge(sev) {
|
|
2983
|
+
const map = {
|
|
2984
|
+
blocking: "bg-red-500/10 text-red-600 border-red-500/20",
|
|
2985
|
+
risk: "bg-amber-500/10 text-amber-600 border-amber-500/20",
|
|
2986
|
+
"missing-info": "bg-zinc-500/10 text-zinc-600 border-zinc-500/20",
|
|
2987
|
+
suggestion: "bg-green-500/10 text-green-600 border-green-500/20"
|
|
2988
|
+
};
|
|
2989
|
+
return map[sev];
|
|
2990
|
+
}
|
|
2991
|
+
function scoreBadgeStyle(pct) {
|
|
2992
|
+
if (pct >= 75) return "bg-green-500/10 text-green-700 border-green-500/20";
|
|
2993
|
+
if (pct >= 50) return "bg-amber-500/10 text-amber-700 border-amber-500/20";
|
|
2994
|
+
return "bg-red-500/10 text-red-700 border-red-500/20";
|
|
2995
|
+
}
|
|
2996
|
+
function renderGaugeSvg(pct, size, strokeW, grade) {
|
|
2997
|
+
const offset = GAUGE_C * (1 - pct / 100);
|
|
2998
|
+
const color = gaugeColor(pct);
|
|
2999
|
+
if (grade) {
|
|
3000
|
+
return `<svg width="${size}" height="${size}" viewBox="0 0 120 120" class="gauge-svg block">
|
|
3001
|
+
<circle cx="60" cy="60" r="${GAUGE_R}" fill="none" stroke-width="${strokeW}" stroke="#e4e4e7" class="stroke-border" />
|
|
3002
|
+
<circle cx="60" cy="60" r="${GAUGE_R}" fill="none" stroke="${color}" stroke-width="${strokeW}" stroke-linecap="round" stroke-dasharray="${GAUGE_C}" stroke-dashoffset="${offset}" transform="rotate(-90 60 60)" class="gauge-fill" />
|
|
3003
|
+
<text x="60" y="60" text-anchor="middle" dominant-baseline="central" fill="currentColor" font-size="48" font-weight="700" font-family="Inter,-apple-system,sans-serif" class="font-sans">${escapeHtml(grade)}</text>
|
|
3004
|
+
</svg>`;
|
|
3005
|
+
}
|
|
3006
|
+
return `<svg width="${size}" height="${size}" viewBox="0 0 120 120" class="gauge-svg block">
|
|
3007
|
+
<circle cx="60" cy="60" r="${GAUGE_R}" fill="none" stroke-width="${strokeW}" stroke="#e4e4e7" class="stroke-border" />
|
|
3008
|
+
<circle cx="60" cy="60" r="${GAUGE_R}" fill="none" stroke="${color}" stroke-width="${strokeW}" stroke-linecap="round" stroke-dasharray="${GAUGE_C}" stroke-dashoffset="${offset}" transform="rotate(-90 60 60)" class="gauge-fill" />
|
|
3009
|
+
<text x="60" y="62" text-anchor="middle" dominant-baseline="central" fill="currentColor" font-size="28" font-weight="700" font-family="Inter,-apple-system,sans-serif" class="font-sans">${pct}</text>
|
|
3010
|
+
</svg>`;
|
|
3011
|
+
}
|
|
3012
|
+
|
|
3013
|
+
// src/core/report-html/index.ts
|
|
3014
|
+
function generateHtmlReport(file, result, scores, options) {
|
|
3015
|
+
const screenshotMap = new Map(
|
|
3016
|
+
(options?.nodeScreenshots ?? []).map((ns) => [ns.nodeId, ns])
|
|
3017
|
+
);
|
|
3018
|
+
const figmaToken = options?.figmaToken;
|
|
3019
|
+
const quickWins = getQuickWins(result.issues, 5);
|
|
3020
|
+
const issuesByCategory = groupIssuesByCategory(result.issues);
|
|
3021
|
+
return `<!DOCTYPE html>
|
|
3022
|
+
<html lang="en" class="antialiased">
|
|
3023
|
+
<head>
|
|
3024
|
+
<meta charset="UTF-8">
|
|
3025
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
3026
|
+
<title>CanICode Report \u2014 ${esc(file.name)}</title>
|
|
3027
|
+
<script src="https://cdn.tailwindcss.com"></script>
|
|
3028
|
+
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
3029
|
+
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
3030
|
+
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
|
|
3031
|
+
<script>
|
|
3032
|
+
tailwind.config = {
|
|
3033
|
+
theme: {
|
|
3034
|
+
extend: {
|
|
3035
|
+
fontFamily: { sans: ['Inter', 'ui-sans-serif', 'system-ui', '-apple-system', 'sans-serif'] },
|
|
3036
|
+
colors: {
|
|
3037
|
+
border: 'hsl(240 5.9% 90%)',
|
|
3038
|
+
ring: 'hsl(240 5.9% 10%)',
|
|
3039
|
+
background: 'hsl(0 0% 100%)',
|
|
3040
|
+
foreground: 'hsl(240 10% 3.9%)',
|
|
3041
|
+
muted: { DEFAULT: 'hsl(240 4.8% 95.9%)', foreground: 'hsl(240 3.8% 46.1%)' },
|
|
3042
|
+
card: { DEFAULT: 'hsl(0 0% 100%)', foreground: 'hsl(240 10% 3.9%)' },
|
|
3043
|
+
},
|
|
3044
|
+
borderRadius: { lg: '0.5rem', md: 'calc(0.5rem - 2px)', sm: 'calc(0.5rem - 4px)' },
|
|
3045
|
+
}
|
|
3046
|
+
}
|
|
3047
|
+
}
|
|
3048
|
+
</script>
|
|
3049
|
+
<style>
|
|
3050
|
+
details summary::-webkit-details-marker { display: none; }
|
|
3051
|
+
details summary::marker { content: ""; }
|
|
3052
|
+
details summary { list-style: none; }
|
|
3053
|
+
.gauge-fill { transition: stroke-dashoffset 0.8s cubic-bezier(0.4,0,0.2,1); }
|
|
3054
|
+
@media print {
|
|
3055
|
+
.no-print { display: none !important; }
|
|
3056
|
+
.topbar-print { position: static !important; background: white !important; color: hsl(240 10% 3.9%) !important; }
|
|
3057
|
+
}
|
|
3058
|
+
</style>
|
|
3059
|
+
</head>
|
|
3060
|
+
<body class="bg-muted font-sans text-foreground min-h-screen">
|
|
3061
|
+
|
|
3062
|
+
<!-- Top Bar -->
|
|
3063
|
+
<header class="topbar-print sticky top-0 z-50 bg-zinc-950 text-white border-b border-zinc-800">
|
|
3064
|
+
<div class="max-w-[960px] mx-auto px-6 py-3 flex items-center gap-4">
|
|
3065
|
+
<span class="font-semibold text-sm tracking-tight">CanICode</span>
|
|
3066
|
+
<span class="text-zinc-400 text-sm truncate">${esc(file.name)}</span>
|
|
3067
|
+
<span class="ml-auto text-zinc-500 text-xs no-print">${(/* @__PURE__ */ new Date()).toLocaleDateString()}</span>
|
|
3068
|
+
</div>
|
|
3069
|
+
</header>
|
|
3070
|
+
|
|
3071
|
+
<main class="max-w-[960px] mx-auto px-6 pb-16">
|
|
3072
|
+
|
|
3073
|
+
<!-- Overall Score -->
|
|
3074
|
+
<section class="flex flex-col items-center pt-12 pb-6">
|
|
3075
|
+
${renderGaugeSvg(scores.overall.percentage, 200, 10, scores.overall.grade)}
|
|
3076
|
+
<div class="mt-3 text-center">
|
|
3077
|
+
<span class="text-lg font-semibold">${scores.overall.percentage}</span>
|
|
3078
|
+
<span class="text-muted-foreground text-sm ml-1">/ 100</span>
|
|
3079
|
+
</div>
|
|
3080
|
+
<p class="text-muted-foreground text-sm mt-1">Overall Score</p>
|
|
3081
|
+
</section>
|
|
3082
|
+
|
|
3083
|
+
<!-- Category Gauges -->
|
|
3084
|
+
<section class="bg-card border border-border rounded-lg shadow-sm p-6 mb-6">
|
|
3085
|
+
<div class="grid grid-cols-3 sm:grid-cols-6 gap-4">
|
|
3086
|
+
${CATEGORIES.map((cat) => {
|
|
3087
|
+
const cs = scores.byCategory[cat];
|
|
3088
|
+
const desc = CATEGORY_DESCRIPTIONS[cat];
|
|
3089
|
+
return ` <a href="#cat-${cat}" class="flex flex-col items-center group relative cursor-pointer no-underline text-foreground hover:opacity-80 transition-opacity">
|
|
3090
|
+
${renderGaugeSvg(cs.percentage, 100, 7)}
|
|
3091
|
+
<span class="text-xs font-medium mt-2.5 text-center leading-tight">${CATEGORY_LABELS[cat]}</span>
|
|
3092
|
+
<span class="text-[11px] text-muted-foreground">${cs.issueCount} issues</span>
|
|
3093
|
+
<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">
|
|
3094
|
+
${esc(desc)}
|
|
3095
|
+
<div class="absolute top-full left-1/2 -translate-x-1/2 border-4 border-transparent border-t-zinc-900"></div>
|
|
3096
|
+
</div>
|
|
3097
|
+
</a>`;
|
|
3098
|
+
}).join("\n")}
|
|
3099
|
+
</div>
|
|
3100
|
+
</section>
|
|
3101
|
+
|
|
3102
|
+
<!-- Issue Summary -->
|
|
3103
|
+
<section class="bg-card border border-border rounded-lg shadow-sm p-4 mb-6">
|
|
3104
|
+
<div class="flex flex-wrap items-center justify-center gap-6">
|
|
3105
|
+
${renderSummaryDot("bg-red-500", scores.summary.blocking, "Blocking")}
|
|
3106
|
+
${renderSummaryDot("bg-amber-500", scores.summary.risk, "Risk")}
|
|
3107
|
+
${renderSummaryDot("bg-zinc-400", scores.summary.missingInfo, "Missing Info")}
|
|
3108
|
+
${renderSummaryDot("bg-green-500", scores.summary.suggestion, "Suggestion")}
|
|
3109
|
+
<div class="border-l border-border pl-6 flex items-center gap-2">
|
|
3110
|
+
<span class="text-xl font-bold tracking-tight">${scores.summary.totalIssues}</span>
|
|
3111
|
+
<span class="text-sm text-muted-foreground">Total</span>
|
|
3112
|
+
</div>
|
|
3113
|
+
</div>
|
|
3114
|
+
</section>
|
|
3048
3115
|
|
|
3049
|
-
|
|
3050
|
-
|
|
3051
|
-
|
|
3116
|
+
${quickWins.length > 0 ? renderOpportunities(quickWins, file.fileKey) : ""}
|
|
3117
|
+
|
|
3118
|
+
<!-- Categories -->
|
|
3119
|
+
<div class="space-y-3">
|
|
3120
|
+
${CATEGORIES.map((cat) => renderCategory(cat, scores, issuesByCategory.get(cat) ?? [], file.fileKey, screenshotMap, figmaToken)).join("\n")}
|
|
3121
|
+
</div>
|
|
3122
|
+
|
|
3123
|
+
<!-- Footer -->
|
|
3124
|
+
<footer class="mt-12 pt-6 border-t border-border text-center">
|
|
3125
|
+
<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>
|
|
3126
|
+
<p class="text-xs text-muted-foreground/60 mt-1">${(/* @__PURE__ */ new Date()).toLocaleString()} \xB7 ${result.nodeCount} nodes \xB7 Max depth ${result.maxDepth}</p>
|
|
3127
|
+
</footer>
|
|
3128
|
+
|
|
3129
|
+
</main>
|
|
3130
|
+
|
|
3131
|
+
${figmaToken ? ` <script>
|
|
3132
|
+
const FIGMA_TOKEN = '${figmaToken}';
|
|
3133
|
+
async function postComment(btn) {
|
|
3134
|
+
const fileKey = btn.dataset.fileKey;
|
|
3135
|
+
const nodeId = btn.dataset.nodeId.replace(/-/g, ':');
|
|
3136
|
+
const rule = btn.dataset.rule;
|
|
3137
|
+
const message = btn.dataset.message;
|
|
3138
|
+
const path = btn.dataset.path;
|
|
3139
|
+
const fix = btn.dataset.fix;
|
|
3140
|
+
const why = btn.dataset.why;
|
|
3141
|
+
const impact = btn.dataset.impact;
|
|
3142
|
+
|
|
3143
|
+
const commentBody = '[CanICode] ' + rule + '\\n\\nFix: ' + fix + '\\nWhy: ' + why + '\\nImpact: ' + impact + '\\n\\n' + message + '\\nNode: ' + path;
|
|
3144
|
+
|
|
3145
|
+
btn.disabled = true;
|
|
3146
|
+
btn.textContent = 'Sending...';
|
|
3147
|
+
|
|
3148
|
+
try {
|
|
3149
|
+
const res = await fetch('https://api.figma.com/v1/files/' + fileKey + '/comments', {
|
|
3150
|
+
method: 'POST',
|
|
3151
|
+
headers: { 'X-FIGMA-TOKEN': FIGMA_TOKEN, 'Content-Type': 'application/json' },
|
|
3152
|
+
body: JSON.stringify({ message: commentBody, client_meta: { node_id: nodeId, node_offset: { x: 0, y: 0 } } }),
|
|
3153
|
+
});
|
|
3154
|
+
if (!res.ok) throw new Error(await res.text());
|
|
3155
|
+
btn.textContent = 'Sent \\u2713';
|
|
3156
|
+
btn.classList.remove('hover:bg-muted');
|
|
3157
|
+
btn.classList.add('text-green-600', 'border-green-500/30');
|
|
3158
|
+
} catch (e) {
|
|
3159
|
+
btn.textContent = 'Failed \\u2717';
|
|
3160
|
+
btn.classList.remove('hover:bg-muted');
|
|
3161
|
+
btn.classList.add('text-red-600', 'border-red-500/30');
|
|
3162
|
+
btn.disabled = false;
|
|
3163
|
+
console.error('Failed to post Figma comment:', e);
|
|
3164
|
+
}
|
|
3165
|
+
}
|
|
3166
|
+
</script>` : ""}
|
|
3167
|
+
</body>
|
|
3168
|
+
</html>`;
|
|
3052
3169
|
}
|
|
3053
|
-
function
|
|
3054
|
-
return
|
|
3170
|
+
function renderSummaryDot(dotClass, count, label) {
|
|
3171
|
+
return `<div class="flex items-center gap-2">
|
|
3172
|
+
<span class="w-2.5 h-2.5 rounded-full ${dotClass}"></span>
|
|
3173
|
+
<span class="text-lg font-bold tracking-tight">${count}</span>
|
|
3174
|
+
<span class="text-sm text-muted-foreground">${label}</span>
|
|
3175
|
+
</div>`;
|
|
3055
3176
|
}
|
|
3056
|
-
function
|
|
3057
|
-
const
|
|
3058
|
-
|
|
3059
|
-
|
|
3060
|
-
|
|
3177
|
+
function renderOpportunities(issues, fileKey) {
|
|
3178
|
+
const maxAbs = issues.reduce((m, i) => Math.max(m, Math.abs(i.calculatedScore)), 1);
|
|
3179
|
+
return `
|
|
3180
|
+
<!-- Opportunities -->
|
|
3181
|
+
<section class="bg-card border border-border rounded-lg shadow-sm mb-6 overflow-hidden">
|
|
3182
|
+
<div class="px-6 py-4 border-b border-border">
|
|
3183
|
+
<h2 class="text-sm font-semibold flex items-center gap-2">
|
|
3184
|
+
<span class="w-2 h-2 rounded-full bg-red-500"></span>
|
|
3185
|
+
Opportunities
|
|
3186
|
+
</h2>
|
|
3187
|
+
<p class="text-xs text-muted-foreground mt-1">Top blocking issues \u2014 fix these first for the biggest improvement.</p>
|
|
3188
|
+
</div>
|
|
3189
|
+
<div class="divide-y divide-border">
|
|
3190
|
+
${issues.map((issue) => {
|
|
3191
|
+
const def = issue.rule.definition;
|
|
3192
|
+
const link = buildFigmaDeepLink(fileKey, issue.violation.nodeId);
|
|
3193
|
+
const barW = Math.round(Math.abs(issue.calculatedScore) / maxAbs * 100);
|
|
3194
|
+
return ` <div class="px-6 py-3 flex items-center gap-4 hover:bg-muted/50 transition-colors">
|
|
3195
|
+
<div class="flex-1 min-w-0">
|
|
3196
|
+
<div class="text-sm font-medium truncate">${esc(def.name)}</div>
|
|
3197
|
+
<div class="text-xs text-muted-foreground truncate mt-0.5">${esc(issue.violation.message)}</div>
|
|
3198
|
+
</div>
|
|
3199
|
+
<div class="w-32 flex items-center gap-2 shrink-0">
|
|
3200
|
+
<div class="flex-1 h-1.5 bg-muted rounded-full overflow-hidden">
|
|
3201
|
+
<div class="h-full bg-red-500 rounded-full" style="width:${barW}%"></div>
|
|
3202
|
+
</div>
|
|
3203
|
+
<span class="text-xs font-medium text-red-600 w-12 text-right">${issue.calculatedScore}</span>
|
|
3204
|
+
</div>
|
|
3205
|
+
<a href="${link}" target="_blank" rel="noopener" class="text-xs text-muted-foreground hover:text-foreground shrink-0 no-print">Figma \u2192</a>
|
|
3206
|
+
</div>`;
|
|
3207
|
+
}).join("\n")}
|
|
3208
|
+
</div>
|
|
3209
|
+
</section>`;
|
|
3061
3210
|
}
|
|
3062
|
-
|
|
3063
|
-
|
|
3064
|
-
|
|
3065
|
-
|
|
3066
|
-
|
|
3067
|
-
|
|
3068
|
-
|
|
3069
|
-
}
|
|
3070
|
-
|
|
3071
|
-
|
|
3072
|
-
|
|
3073
|
-
|
|
3074
|
-
|
|
3075
|
-
|
|
3076
|
-
|
|
3077
|
-
|
|
3078
|
-
|
|
3079
|
-
|
|
3080
|
-
|
|
3081
|
-
|
|
3082
|
-
|
|
3083
|
-
|
|
3084
|
-
|
|
3085
|
-
|
|
3086
|
-
|
|
3087
|
-
|
|
3088
|
-
|
|
3089
|
-
|
|
3090
|
-
|
|
3091
|
-
|
|
3092
|
-
|
|
3093
|
-
|
|
3094
|
-
|
|
3211
|
+
function renderCategory(cat, scores, issues, fileKey, screenshotMap, figmaToken) {
|
|
3212
|
+
const cs = scores.byCategory[cat];
|
|
3213
|
+
const hasProblems = issues.some((i) => i.config.severity === "blocking" || i.config.severity === "risk");
|
|
3214
|
+
const bySeverity = /* @__PURE__ */ new Map();
|
|
3215
|
+
for (const sev of SEVERITY_ORDER) bySeverity.set(sev, []);
|
|
3216
|
+
for (const issue of issues) bySeverity.get(issue.config.severity)?.push(issue);
|
|
3217
|
+
return `
|
|
3218
|
+
<details id="cat-${cat}" class="bg-card border border-border rounded-lg shadow-sm overflow-hidden group"${hasProblems ? " open" : ""}>
|
|
3219
|
+
<summary class="px-5 py-3.5 flex items-center gap-3 cursor-pointer hover:bg-muted/50 transition-colors select-none">
|
|
3220
|
+
<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>
|
|
3221
|
+
<div class="flex-1 min-w-0">
|
|
3222
|
+
<div class="text-sm font-semibold">${CATEGORY_LABELS[cat]}</div>
|
|
3223
|
+
<div class="text-xs text-muted-foreground">${esc(CATEGORY_DESCRIPTIONS[cat])}</div>
|
|
3224
|
+
</div>
|
|
3225
|
+
<span class="text-xs text-muted-foreground">${cs.issueCount} issues</span>
|
|
3226
|
+
<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>
|
|
3227
|
+
</summary>
|
|
3228
|
+
<div class="border-t border-border">
|
|
3229
|
+
${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")}
|
|
3230
|
+
</div>
|
|
3231
|
+
</details>`;
|
|
3232
|
+
}
|
|
3233
|
+
function renderSeverityGroup(sev, issues, fileKey, screenshotMap, figmaToken) {
|
|
3234
|
+
return ` <div class="px-5 py-3">
|
|
3235
|
+
<div class="flex items-center gap-2 mb-2">
|
|
3236
|
+
<span class="w-2 h-2 rounded-full ${severityDot(sev)}"></span>
|
|
3237
|
+
<span class="text-xs font-semibold uppercase tracking-wider">${SEVERITY_LABELS[sev]}</span>
|
|
3238
|
+
<span class="text-xs text-muted-foreground ml-auto">${issues.length}</span>
|
|
3239
|
+
</div>
|
|
3240
|
+
<div class="space-y-1">
|
|
3241
|
+
${issues.map((issue) => renderIssueRow(issue, fileKey, screenshotMap, figmaToken)).join("\n")}
|
|
3242
|
+
</div>
|
|
3243
|
+
</div>`;
|
|
3244
|
+
}
|
|
3245
|
+
function renderIssueRow(issue, fileKey, screenshotMap, figmaToken) {
|
|
3246
|
+
const sev = issue.config.severity;
|
|
3247
|
+
const def = issue.rule.definition;
|
|
3248
|
+
const link = buildFigmaDeepLink(fileKey, issue.violation.nodeId);
|
|
3249
|
+
const screenshot = screenshotMap.get(issue.violation.nodeId);
|
|
3250
|
+
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>` : "";
|
|
3251
|
+
return ` <details class="border border-border rounded-md overflow-hidden">
|
|
3252
|
+
<summary class="flex items-center gap-2.5 px-3 py-2 text-sm cursor-pointer hover:bg-muted/50 transition-colors">
|
|
3253
|
+
<span class="w-1.5 h-1.5 rounded-full ${severityDot(sev)} shrink-0"></span>
|
|
3254
|
+
<span class="font-medium shrink-0">${esc(def.name)}</span>
|
|
3255
|
+
<span class="text-muted-foreground truncate text-xs flex-1">${esc(issue.violation.message)}</span>
|
|
3256
|
+
<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>
|
|
3257
|
+
</summary>
|
|
3258
|
+
<div class="px-3 py-3 bg-muted/30 border-t border-border text-sm space-y-2">
|
|
3259
|
+
<div class="font-mono text-xs text-muted-foreground break-all">${esc(issue.violation.nodePath)}</div>
|
|
3260
|
+
<div class="text-muted-foreground leading-relaxed space-y-1">
|
|
3261
|
+
<p><span class="font-medium text-foreground">Why:</span> ${esc(def.why)}</p>
|
|
3262
|
+
<p><span class="font-medium text-foreground">Impact:</span> ${esc(def.impact)}</p>
|
|
3263
|
+
<p><span class="font-medium text-foreground">Fix:</span> ${esc(def.fix)}</p>
|
|
3264
|
+
</div>${screenshotHtml}
|
|
3265
|
+
<div class="flex items-center gap-2 mt-1 no-print">
|
|
3266
|
+
<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 ? `
|
|
3267
|
+
<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>` : ""}
|
|
3268
|
+
</div>
|
|
3269
|
+
</div>
|
|
3270
|
+
</details>`;
|
|
3271
|
+
}
|
|
3272
|
+
function getQuickWins(issues, limit) {
|
|
3273
|
+
return issues.filter((issue) => issue.config.severity === "blocking").sort((a, b) => a.calculatedScore - b.calculatedScore).slice(0, limit);
|
|
3274
|
+
}
|
|
3275
|
+
function groupIssuesByCategory(issues) {
|
|
3276
|
+
const grouped = /* @__PURE__ */ new Map();
|
|
3277
|
+
for (const category of CATEGORIES) grouped.set(category, []);
|
|
3278
|
+
for (const issue of issues) grouped.get(issue.rule.definition.category).push(issue);
|
|
3279
|
+
return grouped;
|
|
3280
|
+
}
|
|
3281
|
+
var esc = escapeHtml;
|
|
3282
|
+
var RuleOverrideSchema = z.object({
|
|
3283
|
+
score: z.number().int().max(0).optional(),
|
|
3284
|
+
severity: SeveritySchema.optional(),
|
|
3285
|
+
enabled: z.boolean().optional()
|
|
3095
3286
|
});
|
|
3096
|
-
var
|
|
3097
|
-
|
|
3098
|
-
|
|
3099
|
-
|
|
3100
|
-
|
|
3101
|
-
|
|
3102
|
-
|
|
3103
|
-
|
|
3104
|
-
|
|
3105
|
-
|
|
3106
|
-
|
|
3107
|
-
|
|
3108
|
-
|
|
3109
|
-
|
|
3110
|
-
|
|
3111
|
-
|
|
3112
|
-
|
|
3113
|
-
if (
|
|
3114
|
-
|
|
3115
|
-
|
|
3116
|
-
|
|
3117
|
-
|
|
3118
|
-
const overlapX = Math.min(boxA.x + boxA.width, boxB.x + boxB.width) - Math.max(boxA.x, boxB.x);
|
|
3119
|
-
const overlapY = Math.min(boxA.y + boxA.height, boxB.y + boxB.height) - Math.max(boxA.y, boxB.y);
|
|
3120
|
-
if (overlapX > 0 && overlapY > 0) {
|
|
3121
|
-
const overlapArea = overlapX * overlapY;
|
|
3122
|
-
const smallerArea = Math.min(
|
|
3123
|
-
boxA.width * boxA.height,
|
|
3124
|
-
boxB.width * boxB.height
|
|
3125
|
-
);
|
|
3126
|
-
if (overlapArea > smallerArea * 0.2) {
|
|
3127
|
-
significantOverlapCount++;
|
|
3128
|
-
}
|
|
3129
|
-
}
|
|
3287
|
+
var ConfigFileSchema = z.object({
|
|
3288
|
+
excludeNodeTypes: z.array(z.string()).optional(),
|
|
3289
|
+
excludeNodeNames: z.array(z.string()).optional(),
|
|
3290
|
+
gridBase: z.number().int().positive().optional(),
|
|
3291
|
+
colorTolerance: z.number().int().positive().optional(),
|
|
3292
|
+
rules: z.record(z.string(), RuleOverrideSchema).optional()
|
|
3293
|
+
});
|
|
3294
|
+
async function loadConfigFile(filePath) {
|
|
3295
|
+
const absPath = resolve(filePath);
|
|
3296
|
+
const raw = await readFile(absPath, "utf-8");
|
|
3297
|
+
const parsed = JSON.parse(raw);
|
|
3298
|
+
return ConfigFileSchema.parse(parsed);
|
|
3299
|
+
}
|
|
3300
|
+
function mergeConfigs(base, overrides) {
|
|
3301
|
+
const merged = { ...base };
|
|
3302
|
+
if (overrides.gridBase !== void 0) {
|
|
3303
|
+
for (const [id, config2] of Object.entries(merged)) {
|
|
3304
|
+
if (config2.options && "gridBase" in config2.options) {
|
|
3305
|
+
merged[id] = {
|
|
3306
|
+
...config2,
|
|
3307
|
+
options: { ...config2.options, gridBase: overrides.gridBase }
|
|
3308
|
+
};
|
|
3130
3309
|
}
|
|
3131
3310
|
}
|
|
3132
3311
|
}
|
|
3133
|
-
if (
|
|
3134
|
-
|
|
3135
|
-
|
|
3136
|
-
|
|
3137
|
-
|
|
3138
|
-
|
|
3139
|
-
|
|
3312
|
+
if (overrides.colorTolerance !== void 0) {
|
|
3313
|
+
for (const [id, config2] of Object.entries(merged)) {
|
|
3314
|
+
if (config2.options && "tolerance" in config2.options) {
|
|
3315
|
+
merged[id] = {
|
|
3316
|
+
...config2,
|
|
3317
|
+
options: { ...config2.options, tolerance: overrides.colorTolerance }
|
|
3318
|
+
};
|
|
3319
|
+
}
|
|
3320
|
+
}
|
|
3140
3321
|
}
|
|
3141
|
-
|
|
3142
|
-
|
|
3143
|
-
|
|
3144
|
-
|
|
3145
|
-
|
|
3146
|
-
|
|
3147
|
-
|
|
3148
|
-
|
|
3149
|
-
|
|
3150
|
-
|
|
3151
|
-
|
|
3152
|
-
impact: "AI may generate incorrect code due to ambiguous relationships",
|
|
3153
|
-
fix: "Add Auto Layout or simplify the nesting structure"
|
|
3154
|
-
};
|
|
3155
|
-
var missingLayoutHintCheck = (node, context) => {
|
|
3156
|
-
if (!isContainerNode2(node)) return null;
|
|
3157
|
-
if (hasAutoLayout2(node)) return null;
|
|
3158
|
-
if (!node.children || node.children.length === 0) return null;
|
|
3159
|
-
const nestedContainers = node.children.filter((c) => isContainerNode2(c));
|
|
3160
|
-
if (nestedContainers.length >= 2) {
|
|
3161
|
-
const withoutLayout = nestedContainers.filter((c) => !hasAutoLayout2(c));
|
|
3162
|
-
if (withoutLayout.length >= 2) {
|
|
3163
|
-
return {
|
|
3164
|
-
ruleId: missingLayoutHintDef.id,
|
|
3165
|
-
nodeId: node.id,
|
|
3166
|
-
nodePath: context.path.join(" > "),
|
|
3167
|
-
message: `"${node.name}" has ${withoutLayout.length} nested containers without layout hints`
|
|
3168
|
-
};
|
|
3322
|
+
if (overrides.rules) {
|
|
3323
|
+
for (const [ruleId, override] of Object.entries(overrides.rules)) {
|
|
3324
|
+
const existing = merged[ruleId];
|
|
3325
|
+
if (existing) {
|
|
3326
|
+
merged[ruleId] = {
|
|
3327
|
+
...existing,
|
|
3328
|
+
...override.score !== void 0 && { score: override.score },
|
|
3329
|
+
...override.severity !== void 0 && { severity: override.severity },
|
|
3330
|
+
...override.enabled !== void 0 && { enabled: override.enabled }
|
|
3331
|
+
};
|
|
3332
|
+
}
|
|
3169
3333
|
}
|
|
3170
3334
|
}
|
|
3171
|
-
return
|
|
3172
|
-
}
|
|
3173
|
-
|
|
3174
|
-
|
|
3175
|
-
|
|
3335
|
+
return merged;
|
|
3336
|
+
}
|
|
3337
|
+
var MatchConditionSchema = z.object({
|
|
3338
|
+
// Node type conditions
|
|
3339
|
+
type: z.array(z.string()).optional(),
|
|
3340
|
+
notType: z.array(z.string()).optional(),
|
|
3341
|
+
// Name conditions (case-insensitive, substring match)
|
|
3342
|
+
nameContains: z.string().optional(),
|
|
3343
|
+
nameNotContains: z.string().optional(),
|
|
3344
|
+
namePattern: z.string().optional(),
|
|
3345
|
+
// Size conditions
|
|
3346
|
+
minWidth: z.number().optional(),
|
|
3347
|
+
maxWidth: z.number().optional(),
|
|
3348
|
+
minHeight: z.number().optional(),
|
|
3349
|
+
maxHeight: z.number().optional(),
|
|
3350
|
+
// Layout conditions
|
|
3351
|
+
hasAutoLayout: z.boolean().optional(),
|
|
3352
|
+
hasChildren: z.boolean().optional(),
|
|
3353
|
+
minChildren: z.number().optional(),
|
|
3354
|
+
maxChildren: z.number().optional(),
|
|
3355
|
+
// Component conditions
|
|
3356
|
+
isComponent: z.boolean().optional(),
|
|
3357
|
+
isInstance: z.boolean().optional(),
|
|
3358
|
+
hasComponentId: z.boolean().optional(),
|
|
3359
|
+
// Visibility
|
|
3360
|
+
isVisible: z.boolean().optional(),
|
|
3361
|
+
// Fill/style conditions
|
|
3362
|
+
hasFills: z.boolean().optional(),
|
|
3363
|
+
hasStrokes: z.boolean().optional(),
|
|
3364
|
+
hasEffects: z.boolean().optional(),
|
|
3365
|
+
// Depth condition
|
|
3366
|
+
minDepth: z.number().optional(),
|
|
3367
|
+
maxDepth: z.number().optional()
|
|
3176
3368
|
});
|
|
3177
|
-
var
|
|
3178
|
-
id:
|
|
3179
|
-
|
|
3180
|
-
|
|
3181
|
-
|
|
3182
|
-
|
|
3183
|
-
|
|
3184
|
-
|
|
3185
|
-
|
|
3186
|
-
|
|
3187
|
-
|
|
3188
|
-
|
|
3189
|
-
ruleId: invisibleLayerDef.id,
|
|
3190
|
-
nodeId: node.id,
|
|
3191
|
-
nodePath: context.path.join(" > "),
|
|
3192
|
-
message: `"${node.name}" is hidden - consider removing if not needed`
|
|
3193
|
-
};
|
|
3194
|
-
};
|
|
3195
|
-
defineRule({
|
|
3196
|
-
definition: invisibleLayerDef,
|
|
3197
|
-
check: invisibleLayerCheck
|
|
3369
|
+
var CustomRuleSchema = z.object({
|
|
3370
|
+
id: z.string(),
|
|
3371
|
+
category: CategorySchema,
|
|
3372
|
+
severity: SeveritySchema,
|
|
3373
|
+
score: z.number().int().max(0),
|
|
3374
|
+
match: MatchConditionSchema,
|
|
3375
|
+
message: z.string().optional(),
|
|
3376
|
+
why: z.string(),
|
|
3377
|
+
impact: z.string(),
|
|
3378
|
+
fix: z.string(),
|
|
3379
|
+
// Backward compat: silently ignore the old prompt field
|
|
3380
|
+
prompt: z.string().optional()
|
|
3198
3381
|
});
|
|
3199
|
-
var
|
|
3200
|
-
|
|
3201
|
-
|
|
3202
|
-
|
|
3203
|
-
|
|
3204
|
-
|
|
3205
|
-
|
|
3206
|
-
|
|
3207
|
-
|
|
3208
|
-
|
|
3209
|
-
|
|
3210
|
-
|
|
3211
|
-
|
|
3212
|
-
|
|
3382
|
+
var CustomRulesFileSchema = z.array(CustomRuleSchema);
|
|
3383
|
+
|
|
3384
|
+
// src/core/rules/custom/custom-rule-loader.ts
|
|
3385
|
+
async function loadCustomRules(filePath) {
|
|
3386
|
+
const absPath = resolve(filePath);
|
|
3387
|
+
const raw = await readFile(absPath, "utf-8");
|
|
3388
|
+
const parsed = JSON.parse(raw);
|
|
3389
|
+
const customRules = CustomRulesFileSchema.parse(parsed);
|
|
3390
|
+
const rules = [];
|
|
3391
|
+
const configs = {};
|
|
3392
|
+
for (const cr of customRules) {
|
|
3393
|
+
if (!cr.match) continue;
|
|
3394
|
+
rules.push(toRule(cr));
|
|
3395
|
+
configs[cr.id] = {
|
|
3396
|
+
severity: cr.severity,
|
|
3397
|
+
score: cr.score,
|
|
3398
|
+
enabled: true
|
|
3399
|
+
};
|
|
3213
3400
|
}
|
|
3401
|
+
return { rules, configs };
|
|
3402
|
+
}
|
|
3403
|
+
function toRule(cr) {
|
|
3214
3404
|
return {
|
|
3215
|
-
|
|
3216
|
-
|
|
3217
|
-
|
|
3218
|
-
|
|
3405
|
+
definition: {
|
|
3406
|
+
id: cr.id,
|
|
3407
|
+
name: cr.id.replace(/-/g, " ").replace(/\b\w/g, (c) => c.toUpperCase()),
|
|
3408
|
+
category: cr.category,
|
|
3409
|
+
why: cr.why,
|
|
3410
|
+
impact: cr.impact,
|
|
3411
|
+
fix: cr.fix
|
|
3412
|
+
},
|
|
3413
|
+
check: createPatternCheck(cr)
|
|
3414
|
+
};
|
|
3415
|
+
}
|
|
3416
|
+
function createPatternCheck(cr) {
|
|
3417
|
+
return (node, context) => {
|
|
3418
|
+
if (node.type === "DOCUMENT" || node.type === "CANVAS") return null;
|
|
3419
|
+
const match = cr.match;
|
|
3420
|
+
if (match.type && !match.type.includes(node.type)) return null;
|
|
3421
|
+
if (match.notType && match.notType.includes(node.type)) return null;
|
|
3422
|
+
if (match.nameContains !== void 0 && !node.name.toLowerCase().includes(match.nameContains.toLowerCase())) return null;
|
|
3423
|
+
if (match.nameNotContains !== void 0 && node.name.toLowerCase().includes(match.nameNotContains.toLowerCase())) return null;
|
|
3424
|
+
if (match.namePattern !== void 0 && !new RegExp(match.namePattern, "i").test(node.name)) return null;
|
|
3425
|
+
const bbox = node.absoluteBoundingBox;
|
|
3426
|
+
if (match.minWidth !== void 0 && (!bbox || bbox.width < match.minWidth)) return null;
|
|
3427
|
+
if (match.maxWidth !== void 0 && (!bbox || bbox.width > match.maxWidth)) return null;
|
|
3428
|
+
if (match.minHeight !== void 0 && (!bbox || bbox.height < match.minHeight)) return null;
|
|
3429
|
+
if (match.maxHeight !== void 0 && (!bbox || bbox.height > match.maxHeight)) return null;
|
|
3430
|
+
if (match.hasAutoLayout === true && !node.layoutMode) return null;
|
|
3431
|
+
if (match.hasAutoLayout === false && node.layoutMode) return null;
|
|
3432
|
+
if (match.hasChildren === true && (!node.children || node.children.length === 0)) return null;
|
|
3433
|
+
if (match.hasChildren === false && node.children && node.children.length > 0) return null;
|
|
3434
|
+
if (match.minChildren !== void 0 && (!node.children || node.children.length < match.minChildren)) return null;
|
|
3435
|
+
if (match.maxChildren !== void 0 && node.children && node.children.length > match.maxChildren) return null;
|
|
3436
|
+
if (match.isComponent === true && node.type !== "COMPONENT" && node.type !== "COMPONENT_SET") return null;
|
|
3437
|
+
if (match.isComponent === false && (node.type === "COMPONENT" || node.type === "COMPONENT_SET")) return null;
|
|
3438
|
+
if (match.isInstance === true && node.type !== "INSTANCE") return null;
|
|
3439
|
+
if (match.isInstance === false && node.type === "INSTANCE") return null;
|
|
3440
|
+
if (match.hasComponentId === true && !node.componentId) return null;
|
|
3441
|
+
if (match.hasComponentId === false && node.componentId) return null;
|
|
3442
|
+
if (match.isVisible === true && !node.visible) return null;
|
|
3443
|
+
if (match.isVisible === false && node.visible) return null;
|
|
3444
|
+
if (match.hasFills === true && (!node.fills || node.fills.length === 0)) return null;
|
|
3445
|
+
if (match.hasFills === false && node.fills && node.fills.length > 0) return null;
|
|
3446
|
+
if (match.hasStrokes === true && (!node.strokes || node.strokes.length === 0)) return null;
|
|
3447
|
+
if (match.hasStrokes === false && node.strokes && node.strokes.length > 0) return null;
|
|
3448
|
+
if (match.hasEffects === true && (!node.effects || node.effects.length === 0)) return null;
|
|
3449
|
+
if (match.hasEffects === false && node.effects && node.effects.length > 0) return null;
|
|
3450
|
+
if (match.minDepth !== void 0 && context.depth < match.minDepth) return null;
|
|
3451
|
+
if (match.maxDepth !== void 0 && context.depth > match.maxDepth) return null;
|
|
3452
|
+
const msg = (cr.message ?? `"${node.name}" matched custom rule "${cr.id}"`).replace(/\{name\}/g, node.name).replace(/\{type\}/g, node.type);
|
|
3453
|
+
return {
|
|
3454
|
+
ruleId: cr.id,
|
|
3455
|
+
nodeId: node.id,
|
|
3456
|
+
nodePath: context.path.join(" > "),
|
|
3457
|
+
message: msg
|
|
3458
|
+
};
|
|
3219
3459
|
};
|
|
3460
|
+
}
|
|
3461
|
+
|
|
3462
|
+
// src/core/monitoring/events.ts
|
|
3463
|
+
var EVENT_PREFIX = "cic_";
|
|
3464
|
+
var EVENTS = {
|
|
3465
|
+
// Analysis
|
|
3466
|
+
ANALYSIS_STARTED: `${EVENT_PREFIX}analysis_started`,
|
|
3467
|
+
ANALYSIS_COMPLETED: `${EVENT_PREFIX}analysis_completed`,
|
|
3468
|
+
ANALYSIS_FAILED: `${EVENT_PREFIX}analysis_failed`,
|
|
3469
|
+
// Report
|
|
3470
|
+
REPORT_GENERATED: `${EVENT_PREFIX}report_generated`,
|
|
3471
|
+
COMMENT_POSTED: `${EVENT_PREFIX}comment_posted`,
|
|
3472
|
+
COMMENT_FAILED: `${EVENT_PREFIX}comment_failed`,
|
|
3473
|
+
// MCP
|
|
3474
|
+
MCP_TOOL_CALLED: `${EVENT_PREFIX}mcp_tool_called`,
|
|
3475
|
+
// CLI
|
|
3476
|
+
CLI_COMMAND: `${EVENT_PREFIX}cli_command`,
|
|
3477
|
+
CLI_INIT: `${EVENT_PREFIX}cli_init`
|
|
3220
3478
|
};
|
|
3221
|
-
defineRule({
|
|
3222
|
-
definition: emptyFrameDef,
|
|
3223
|
-
check: emptyFrameCheck
|
|
3224
|
-
});
|
|
3225
3479
|
|
|
3226
|
-
// src/core/
|
|
3227
|
-
|
|
3228
|
-
|
|
3480
|
+
// src/core/monitoring/capture.ts
|
|
3481
|
+
var monitoringEnabled = false;
|
|
3482
|
+
var posthogApiKey;
|
|
3483
|
+
var sentryDsn;
|
|
3484
|
+
var distinctId = "anonymous";
|
|
3485
|
+
var environment = "unknown";
|
|
3486
|
+
var version2 = "unknown";
|
|
3487
|
+
var commonProps = {};
|
|
3488
|
+
function uuid4() {
|
|
3489
|
+
return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (c) => {
|
|
3490
|
+
const r = Math.random() * 16 | 0;
|
|
3491
|
+
const v = c === "x" ? r : r & 3 | 8;
|
|
3492
|
+
return v.toString(16);
|
|
3493
|
+
});
|
|
3229
3494
|
}
|
|
3230
|
-
function
|
|
3231
|
-
|
|
3495
|
+
function parseSentryDsn(dsn) {
|
|
3496
|
+
try {
|
|
3497
|
+
const url = new URL(dsn);
|
|
3498
|
+
const key = url.username;
|
|
3499
|
+
const projectId = url.pathname.slice(1);
|
|
3500
|
+
const host = url.protocol + "//" + url.host;
|
|
3501
|
+
if (!key || !projectId) return null;
|
|
3502
|
+
return { key, host, projectId };
|
|
3503
|
+
} catch {
|
|
3504
|
+
return null;
|
|
3505
|
+
}
|
|
3232
3506
|
}
|
|
3233
|
-
function
|
|
3234
|
-
|
|
3507
|
+
function initCapture(config2) {
|
|
3508
|
+
if (config2.enabled === false) return;
|
|
3509
|
+
if (!config2.posthogApiKey && !config2.sentryDsn) return;
|
|
3510
|
+
monitoringEnabled = true;
|
|
3511
|
+
posthogApiKey = config2.posthogApiKey;
|
|
3512
|
+
sentryDsn = config2.sentryDsn;
|
|
3513
|
+
distinctId = config2.distinctId ?? "anonymous";
|
|
3514
|
+
environment = config2.environment ?? "unknown";
|
|
3515
|
+
version2 = config2.version ?? "unknown";
|
|
3516
|
+
commonProps = {
|
|
3517
|
+
_sdk: "canicode",
|
|
3518
|
+
_sdk_version: version2,
|
|
3519
|
+
_env: environment
|
|
3520
|
+
};
|
|
3235
3521
|
}
|
|
3236
|
-
function
|
|
3237
|
-
if (
|
|
3238
|
-
|
|
3239
|
-
|
|
3240
|
-
|
|
3522
|
+
function captureEvent(event, properties) {
|
|
3523
|
+
if (!monitoringEnabled || !posthogApiKey) return;
|
|
3524
|
+
try {
|
|
3525
|
+
fetch("https://us.i.posthog.com/i/v0/e/", {
|
|
3526
|
+
method: "POST",
|
|
3527
|
+
headers: { "Content-Type": "application/json" },
|
|
3528
|
+
body: JSON.stringify({
|
|
3529
|
+
api_key: posthogApiKey,
|
|
3530
|
+
event,
|
|
3531
|
+
distinct_id: distinctId,
|
|
3532
|
+
properties: { ...commonProps, ...properties },
|
|
3533
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString()
|
|
3534
|
+
})
|
|
3535
|
+
}).catch(() => {
|
|
3536
|
+
});
|
|
3537
|
+
} catch {
|
|
3538
|
+
}
|
|
3539
|
+
}
|
|
3540
|
+
function captureError(error, context) {
|
|
3541
|
+
if (!monitoringEnabled) return;
|
|
3542
|
+
if (sentryDsn) {
|
|
3543
|
+
const parsed = parseSentryDsn(sentryDsn);
|
|
3544
|
+
if (parsed) {
|
|
3545
|
+
try {
|
|
3546
|
+
const eventId = uuid4();
|
|
3547
|
+
const envelope = [
|
|
3548
|
+
JSON.stringify({ event_id: eventId, sent_at: (/* @__PURE__ */ new Date()).toISOString(), dsn: sentryDsn }),
|
|
3549
|
+
JSON.stringify({ type: "event", content_type: "application/json" }),
|
|
3550
|
+
JSON.stringify({
|
|
3551
|
+
event_id: eventId,
|
|
3552
|
+
exception: { values: [{ type: error.name, value: error.message }] },
|
|
3553
|
+
platform: "node",
|
|
3554
|
+
environment,
|
|
3555
|
+
release: `canicode@${version2}`,
|
|
3556
|
+
timestamp: Date.now() / 1e3,
|
|
3557
|
+
extra: context
|
|
3558
|
+
})
|
|
3559
|
+
].join("\n");
|
|
3560
|
+
fetch(`${parsed.host}/api/${parsed.projectId}/envelope/`, {
|
|
3561
|
+
method: "POST",
|
|
3562
|
+
headers: {
|
|
3563
|
+
"Content-Type": "application/x-sentry-envelope",
|
|
3564
|
+
"X-Sentry-Auth": `Sentry sentry_version=7, sentry_key=${parsed.key}`
|
|
3565
|
+
},
|
|
3566
|
+
body: envelope
|
|
3567
|
+
}).catch(() => {
|
|
3568
|
+
});
|
|
3569
|
+
} catch {
|
|
3570
|
+
}
|
|
3241
3571
|
}
|
|
3242
3572
|
}
|
|
3243
|
-
|
|
3573
|
+
captureEvent("cic_error", { error: error.message, ...context });
|
|
3244
3574
|
}
|
|
3245
|
-
|
|
3246
|
-
|
|
3247
|
-
|
|
3248
|
-
|
|
3249
|
-
|
|
3250
|
-
|
|
3251
|
-
|
|
3252
|
-
};
|
|
3253
|
-
|
|
3254
|
-
|
|
3255
|
-
|
|
3256
|
-
|
|
3257
|
-
|
|
3258
|
-
|
|
3259
|
-
|
|
3260
|
-
|
|
3261
|
-
|
|
3262
|
-
|
|
3575
|
+
function shutdownCapture() {
|
|
3576
|
+
monitoringEnabled = false;
|
|
3577
|
+
posthogApiKey = void 0;
|
|
3578
|
+
sentryDsn = void 0;
|
|
3579
|
+
distinctId = "anonymous";
|
|
3580
|
+
environment = "unknown";
|
|
3581
|
+
version2 = "unknown";
|
|
3582
|
+
commonProps = {};
|
|
3583
|
+
}
|
|
3584
|
+
|
|
3585
|
+
// src/core/monitoring/index.ts
|
|
3586
|
+
function initMonitoring(config2) {
|
|
3587
|
+
initCapture(config2);
|
|
3588
|
+
}
|
|
3589
|
+
function trackEvent(event, properties) {
|
|
3590
|
+
try {
|
|
3591
|
+
captureEvent(event, properties);
|
|
3592
|
+
} catch {
|
|
3263
3593
|
}
|
|
3264
|
-
|
|
3265
|
-
|
|
3266
|
-
|
|
3267
|
-
|
|
3268
|
-
|
|
3269
|
-
});
|
|
3270
|
-
var textTruncationUnhandledDef = {
|
|
3271
|
-
id: "text-truncation-unhandled",
|
|
3272
|
-
name: "Text Truncation Unhandled",
|
|
3273
|
-
category: "handoff-risk",
|
|
3274
|
-
why: "Text nodes without truncation handling may overflow",
|
|
3275
|
-
impact: "Long text will break the layout",
|
|
3276
|
-
fix: "Set text truncation (ellipsis) or ensure container can grow"
|
|
3277
|
-
};
|
|
3278
|
-
var textTruncationUnhandledCheck = (node, context) => {
|
|
3279
|
-
if (!isTextNode(node)) return null;
|
|
3280
|
-
if (!context.parent) return null;
|
|
3281
|
-
if (!hasAutoLayout3(context.parent)) return null;
|
|
3282
|
-
if (node.absoluteBoundingBox) {
|
|
3283
|
-
const { width } = node.absoluteBoundingBox;
|
|
3284
|
-
if (node.characters && node.characters.length > 50 && width < 300) {
|
|
3285
|
-
return {
|
|
3286
|
-
ruleId: textTruncationUnhandledDef.id,
|
|
3287
|
-
nodeId: node.id,
|
|
3288
|
-
nodePath: context.path.join(" > "),
|
|
3289
|
-
message: `"${node.name}" may need text truncation handling`
|
|
3290
|
-
};
|
|
3291
|
-
}
|
|
3594
|
+
}
|
|
3595
|
+
function trackError(error, context) {
|
|
3596
|
+
try {
|
|
3597
|
+
captureError(error, context);
|
|
3598
|
+
} catch {
|
|
3292
3599
|
}
|
|
3293
|
-
|
|
3294
|
-
|
|
3295
|
-
|
|
3296
|
-
|
|
3297
|
-
|
|
3298
|
-
});
|
|
3299
|
-
var imageNoPlaceholderDef = {
|
|
3300
|
-
id: "image-no-placeholder",
|
|
3301
|
-
name: "Image No Placeholder",
|
|
3302
|
-
category: "handoff-risk",
|
|
3303
|
-
why: "Images without placeholder state may cause layout shifts",
|
|
3304
|
-
impact: "Poor user experience during image loading",
|
|
3305
|
-
fix: "Define a placeholder state or background color"
|
|
3306
|
-
};
|
|
3307
|
-
var imageNoPlaceholderCheck = (node, context) => {
|
|
3308
|
-
if (!isImageNode(node)) return null;
|
|
3309
|
-
if (node.fills && Array.isArray(node.fills) && node.fills.length === 1) {
|
|
3310
|
-
const fill = node.fills[0];
|
|
3311
|
-
if (fill["type"] === "IMAGE") {
|
|
3312
|
-
return {
|
|
3313
|
-
ruleId: imageNoPlaceholderDef.id,
|
|
3314
|
-
nodeId: node.id,
|
|
3315
|
-
nodePath: context.path.join(" > "),
|
|
3316
|
-
message: `"${node.name}" image has no placeholder fill`
|
|
3317
|
-
};
|
|
3318
|
-
}
|
|
3600
|
+
}
|
|
3601
|
+
function shutdownMonitoring() {
|
|
3602
|
+
try {
|
|
3603
|
+
shutdownCapture();
|
|
3604
|
+
} catch {
|
|
3319
3605
|
}
|
|
3320
|
-
|
|
3321
|
-
|
|
3322
|
-
|
|
3323
|
-
|
|
3324
|
-
|
|
3325
|
-
});
|
|
3326
|
-
var prototypeLinkInDesignDef = {
|
|
3327
|
-
id: "prototype-link-in-design",
|
|
3328
|
-
name: "Prototype Link in Design",
|
|
3329
|
-
category: "handoff-risk",
|
|
3330
|
-
why: "Prototype connections may affect how the design is interpreted",
|
|
3331
|
-
impact: "Developers may misunderstand which elements should be interactive",
|
|
3332
|
-
fix: "Document interactions separately or use clear naming"
|
|
3333
|
-
};
|
|
3334
|
-
var prototypeLinkInDesignCheck = (_node, _context) => {
|
|
3335
|
-
return null;
|
|
3336
|
-
};
|
|
3337
|
-
defineRule({
|
|
3338
|
-
definition: prototypeLinkInDesignDef,
|
|
3339
|
-
check: prototypeLinkInDesignCheck
|
|
3340
|
-
});
|
|
3341
|
-
var noDevStatusDef = {
|
|
3342
|
-
id: "no-dev-status",
|
|
3343
|
-
name: "No Dev Status",
|
|
3344
|
-
category: "handoff-risk",
|
|
3345
|
-
why: "Without dev status, developers cannot know if a design is ready",
|
|
3346
|
-
impact: "May implement designs that are still in progress",
|
|
3347
|
-
fix: "Mark frames as 'Ready for Dev' or 'Completed' when appropriate"
|
|
3348
|
-
};
|
|
3349
|
-
var noDevStatusCheck = (node, context) => {
|
|
3350
|
-
if (node.type !== "FRAME") return null;
|
|
3351
|
-
if (context.depth > 1) return null;
|
|
3352
|
-
if (node.devStatus) return null;
|
|
3353
|
-
return {
|
|
3354
|
-
ruleId: noDevStatusDef.id,
|
|
3355
|
-
nodeId: node.id,
|
|
3356
|
-
nodePath: context.path.join(" > "),
|
|
3357
|
-
message: `"${node.name}" has no dev status set`
|
|
3358
|
-
};
|
|
3359
|
-
};
|
|
3360
|
-
defineRule({
|
|
3361
|
-
definition: noDevStatusDef,
|
|
3362
|
-
check: noDevStatusCheck
|
|
3363
|
-
});
|
|
3606
|
+
}
|
|
3607
|
+
|
|
3608
|
+
// src/core/monitoring/keys.ts
|
|
3609
|
+
var POSTHOG_API_KEY = "phc_rBFeG140KqJLpUnlpYDEFgdMM6JozZeqQsf9twXf5Dq" ;
|
|
3610
|
+
var SENTRY_DSN = "https://80a836a8300b25f17ef5bbf23afb5b3a@o4511080656207872.ingest.us.sentry.io/4511080661319680" ;
|
|
3364
3611
|
|
|
3365
3612
|
// src/mcp/server.ts
|
|
3366
3613
|
config();
|
|
@@ -3445,17 +3692,16 @@ IMPORTANT \u2014 Before calling this tool, check which data source is available:
|
|
|
3445
3692
|
...effectiveNodeId ? { targetNodeId: effectiveNodeId } : {}
|
|
3446
3693
|
});
|
|
3447
3694
|
const scores = calculateScores(result);
|
|
3448
|
-
const summary = formatScoreSummary(scores);
|
|
3449
3695
|
const figmaToken = token ?? process.env["FIGMA_TOKEN"];
|
|
3450
3696
|
const html = generateHtmlReport(file, result, scores, { figmaToken });
|
|
3451
3697
|
const now = /* @__PURE__ */ new Date();
|
|
3452
3698
|
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")}`;
|
|
3453
3699
|
ensureReportsDir();
|
|
3454
3700
|
const reportPath = `${getReportsDir()}/report-${ts}-${file.fileKey}.html`;
|
|
3455
|
-
await new Promise((
|
|
3701
|
+
await new Promise((resolve6, reject) => {
|
|
3456
3702
|
writeFile(reportPath, html, "utf-8", (err) => {
|
|
3457
3703
|
if (err) reject(err);
|
|
3458
|
-
else
|
|
3704
|
+
else resolve6();
|
|
3459
3705
|
});
|
|
3460
3706
|
});
|
|
3461
3707
|
const openCmd = process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open";
|
|
@@ -3467,31 +3713,11 @@ IMPORTANT \u2014 Before calling this tool, check which data source is available:
|
|
|
3467
3713
|
percentage: scores.overall.percentage,
|
|
3468
3714
|
source: designData ? "mcp-data" : "url"
|
|
3469
3715
|
});
|
|
3470
|
-
const issuesByRule = {};
|
|
3471
|
-
for (const issue of result.issues) {
|
|
3472
|
-
const id = issue.violation.ruleId;
|
|
3473
|
-
issuesByRule[id] = (issuesByRule[id] ?? 0) + 1;
|
|
3474
|
-
}
|
|
3475
3716
|
return {
|
|
3476
3717
|
content: [
|
|
3477
3718
|
{
|
|
3478
3719
|
type: "text",
|
|
3479
|
-
text: JSON.stringify(
|
|
3480
|
-
{
|
|
3481
|
-
fileName: file.name,
|
|
3482
|
-
nodeCount: result.nodeCount,
|
|
3483
|
-
maxDepth: result.maxDepth,
|
|
3484
|
-
issueCount: result.issues.length,
|
|
3485
|
-
scores: {
|
|
3486
|
-
overall: scores.overall,
|
|
3487
|
-
categories: scores.byCategory
|
|
3488
|
-
},
|
|
3489
|
-
issuesByRule,
|
|
3490
|
-
summary
|
|
3491
|
-
},
|
|
3492
|
-
null,
|
|
3493
|
-
2
|
|
3494
|
-
)
|
|
3720
|
+
text: JSON.stringify(buildResultJson(file.name, result, scores), null, 2)
|
|
3495
3721
|
}
|
|
3496
3722
|
]
|
|
3497
3723
|
};
|
|
@@ -3548,6 +3774,20 @@ server.tool(
|
|
|
3548
3774
|
};
|
|
3549
3775
|
}
|
|
3550
3776
|
);
|
|
3777
|
+
server.tool(
|
|
3778
|
+
"version",
|
|
3779
|
+
"Get the current canicode version. Use this when the user asks what version is installed.",
|
|
3780
|
+
{},
|
|
3781
|
+
{
|
|
3782
|
+
readOnlyHint: true,
|
|
3783
|
+
destructiveHint: false,
|
|
3784
|
+
openWorldHint: false,
|
|
3785
|
+
title: "Get Version"
|
|
3786
|
+
},
|
|
3787
|
+
async () => ({
|
|
3788
|
+
content: [{ type: "text", text: `canicode v${pkg.version}` }]
|
|
3789
|
+
})
|
|
3790
|
+
);
|
|
3551
3791
|
server.tool(
|
|
3552
3792
|
"docs",
|
|
3553
3793
|
`Get the customization guide for CanICode.
|
|
@@ -3570,16 +3810,16 @@ Use this when the user asks about customization, configuration, rule settings, o
|
|
|
3570
3810
|
},
|
|
3571
3811
|
async ({ topic }) => {
|
|
3572
3812
|
const { readFile: readFile4 } = await import('fs/promises');
|
|
3573
|
-
const { resolve:
|
|
3813
|
+
const { resolve: resolve6, dirname: dirname2 } = await import('path');
|
|
3574
3814
|
const { fileURLToPath } = await import('url');
|
|
3575
3815
|
try {
|
|
3576
|
-
const __dirname =
|
|
3577
|
-
const docPath =
|
|
3816
|
+
const __dirname = dirname2(fileURLToPath(import.meta.url));
|
|
3817
|
+
const docPath = resolve6(__dirname, "../../docs/CUSTOMIZATION.md");
|
|
3578
3818
|
let content;
|
|
3579
3819
|
try {
|
|
3580
3820
|
content = await readFile4(docPath, "utf-8");
|
|
3581
3821
|
} catch {
|
|
3582
|
-
const altPath =
|
|
3822
|
+
const altPath = resolve6(__dirname, "../docs/CUSTOMIZATION.md");
|
|
3583
3823
|
content = await readFile4(altPath, "utf-8");
|
|
3584
3824
|
}
|
|
3585
3825
|
if (topic && topic !== "all") {
|
|
@@ -3598,7 +3838,9 @@ Use this when the user asks about customization, configuration, rule settings, o
|
|
|
3598
3838
|
}
|
|
3599
3839
|
}
|
|
3600
3840
|
return {
|
|
3601
|
-
content: [{ type: "text", text:
|
|
3841
|
+
content: [{ type: "text", text: `canicode v${pkg.version}
|
|
3842
|
+
|
|
3843
|
+
${content}` }]
|
|
3602
3844
|
};
|
|
3603
3845
|
} catch {
|
|
3604
3846
|
return {
|