canicode 0.8.0 → 0.8.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli/index.js +2753 -2544
- package/dist/cli/index.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/mcp/server.js +1182 -952
- package/dist/mcp/server.js.map +1 -1
- package/package.json +1 -1
package/dist/mcp/server.js
CHANGED
|
@@ -1591,765 +1591,310 @@ function renderGaugeSvg(pct, size, strokeW, grade) {
|
|
|
1591
1591
|
</svg>`;
|
|
1592
1592
|
}
|
|
1593
1593
|
|
|
1594
|
-
//
|
|
1595
|
-
|
|
1596
|
-
|
|
1597
|
-
|
|
1598
|
-
|
|
1599
|
-
|
|
1600
|
-
|
|
1601
|
-
|
|
1602
|
-
|
|
1603
|
-
|
|
1604
|
-
|
|
1605
|
-
|
|
1606
|
-
|
|
1607
|
-
|
|
1608
|
-
|
|
1609
|
-
|
|
1610
|
-
|
|
1611
|
-
|
|
1612
|
-
|
|
1613
|
-
|
|
1614
|
-
|
|
1615
|
-
|
|
1616
|
-
|
|
1617
|
-
|
|
1618
|
-
|
|
1619
|
-
|
|
1620
|
-
|
|
1621
|
-
|
|
1622
|
-
|
|
1623
|
-
|
|
1624
|
-
|
|
1625
|
-
|
|
1626
|
-
|
|
1627
|
-
|
|
1628
|
-
|
|
1629
|
-
|
|
1630
|
-
|
|
1631
|
-
|
|
1632
|
-
|
|
1633
|
-
|
|
1634
|
-
|
|
1635
|
-
|
|
1636
|
-
|
|
1637
|
-
|
|
1638
|
-
|
|
1639
|
-
|
|
1640
|
-
|
|
1641
|
-
|
|
1642
|
-
|
|
1643
|
-
|
|
1644
|
-
|
|
1645
|
-
|
|
1646
|
-
|
|
1647
|
-
|
|
1648
|
-
|
|
1649
|
-
|
|
1650
|
-
|
|
1651
|
-
|
|
1652
|
-
|
|
1653
|
-
|
|
1654
|
-
|
|
1655
|
-
|
|
1656
|
-
|
|
1657
|
-
|
|
1658
|
-
|
|
1659
|
-
|
|
1660
|
-
|
|
1661
|
-
|
|
1662
|
-
|
|
1663
|
-
|
|
1664
|
-
|
|
1665
|
-
|
|
1666
|
-
|
|
1667
|
-
|
|
1668
|
-
|
|
1669
|
-
|
|
1670
|
-
|
|
1671
|
-
|
|
1672
|
-
|
|
1673
|
-
|
|
1674
|
-
|
|
1675
|
-
|
|
1676
|
-
|
|
1677
|
-
|
|
1678
|
-
|
|
1679
|
-
|
|
1680
|
-
|
|
1681
|
-
|
|
1682
|
-
|
|
1683
|
-
|
|
1684
|
-
|
|
1685
|
-
|
|
1686
|
-
|
|
1687
|
-
|
|
1688
|
-
|
|
1689
|
-
|
|
1690
|
-
|
|
1691
|
-
|
|
1692
|
-
|
|
1693
|
-
|
|
1694
|
-
|
|
1695
|
-
|
|
1696
|
-
|
|
1697
|
-
|
|
1698
|
-
|
|
1699
|
-
|
|
1700
|
-
|
|
1701
|
-
|
|
1702
|
-
|
|
1703
|
-
|
|
1704
|
-
|
|
1705
|
-
|
|
1706
|
-
|
|
1707
|
-
|
|
1708
|
-
|
|
1709
|
-
|
|
1710
|
-
|
|
1711
|
-
|
|
1712
|
-
|
|
1713
|
-
|
|
1714
|
-
|
|
1715
|
-
|
|
1716
|
-
|
|
1717
|
-
|
|
1718
|
-
|
|
1719
|
-
|
|
1720
|
-
|
|
1721
|
-
|
|
1722
|
-
|
|
1723
|
-
|
|
1724
|
-
|
|
1725
|
-
|
|
1726
|
-
|
|
1727
|
-
|
|
1594
|
+
// package.json
|
|
1595
|
+
var version = "0.8.1";
|
|
1596
|
+
var AnalysisNodeTypeSchema = z.enum([
|
|
1597
|
+
"DOCUMENT",
|
|
1598
|
+
"CANVAS",
|
|
1599
|
+
"FRAME",
|
|
1600
|
+
"GROUP",
|
|
1601
|
+
"SECTION",
|
|
1602
|
+
"COMPONENT",
|
|
1603
|
+
"COMPONENT_SET",
|
|
1604
|
+
"INSTANCE",
|
|
1605
|
+
"RECTANGLE",
|
|
1606
|
+
"ELLIPSE",
|
|
1607
|
+
"VECTOR",
|
|
1608
|
+
"TEXT",
|
|
1609
|
+
"LINE",
|
|
1610
|
+
"BOOLEAN_OPERATION",
|
|
1611
|
+
"STAR",
|
|
1612
|
+
"REGULAR_POLYGON",
|
|
1613
|
+
"SLICE",
|
|
1614
|
+
"STICKY",
|
|
1615
|
+
"SHAPE_WITH_TEXT",
|
|
1616
|
+
"CONNECTOR",
|
|
1617
|
+
"WIDGET",
|
|
1618
|
+
"EMBED",
|
|
1619
|
+
"LINK_UNFURL",
|
|
1620
|
+
"TABLE",
|
|
1621
|
+
"TABLE_CELL"
|
|
1622
|
+
]);
|
|
1623
|
+
var LayoutModeSchema = z.enum(["NONE", "HORIZONTAL", "VERTICAL"]);
|
|
1624
|
+
var LayoutAlignSchema = z.enum(["MIN", "CENTER", "MAX", "STRETCH", "INHERIT"]);
|
|
1625
|
+
var LayoutPositioningSchema = z.enum(["AUTO", "ABSOLUTE"]);
|
|
1626
|
+
var BaseAnalysisNodeSchema = z.object({
|
|
1627
|
+
// Basic identification
|
|
1628
|
+
id: z.string(),
|
|
1629
|
+
name: z.string(),
|
|
1630
|
+
type: AnalysisNodeTypeSchema,
|
|
1631
|
+
visible: z.boolean().default(true),
|
|
1632
|
+
// Layout analysis
|
|
1633
|
+
layoutMode: LayoutModeSchema.optional(),
|
|
1634
|
+
layoutAlign: LayoutAlignSchema.optional(),
|
|
1635
|
+
layoutPositioning: LayoutPositioningSchema.optional(),
|
|
1636
|
+
layoutSizingHorizontal: z.enum(["FIXED", "HUG", "FILL"]).optional(),
|
|
1637
|
+
layoutSizingVertical: z.enum(["FIXED", "HUG", "FILL"]).optional(),
|
|
1638
|
+
primaryAxisAlignItems: z.string().optional(),
|
|
1639
|
+
counterAxisAlignItems: z.string().optional(),
|
|
1640
|
+
itemSpacing: z.number().optional(),
|
|
1641
|
+
paddingLeft: z.number().optional(),
|
|
1642
|
+
paddingRight: z.number().optional(),
|
|
1643
|
+
paddingTop: z.number().optional(),
|
|
1644
|
+
paddingBottom: z.number().optional(),
|
|
1645
|
+
// Size/position analysis
|
|
1646
|
+
absoluteBoundingBox: z.object({
|
|
1647
|
+
x: z.number(),
|
|
1648
|
+
y: z.number(),
|
|
1649
|
+
width: z.number(),
|
|
1650
|
+
height: z.number()
|
|
1651
|
+
}).nullable().optional(),
|
|
1652
|
+
// Component analysis
|
|
1653
|
+
componentId: z.string().optional(),
|
|
1654
|
+
componentPropertyDefinitions: z.record(z.string(), z.unknown()).optional(),
|
|
1655
|
+
componentProperties: z.record(z.string(), z.unknown()).optional(),
|
|
1656
|
+
// Style/token analysis
|
|
1657
|
+
styles: z.record(z.string(), z.string()).optional(),
|
|
1658
|
+
fills: z.array(z.unknown()).optional(),
|
|
1659
|
+
strokes: z.array(z.unknown()).optional(),
|
|
1660
|
+
effects: z.array(z.unknown()).optional(),
|
|
1661
|
+
// Variable binding analysis (design tokens)
|
|
1662
|
+
boundVariables: z.record(z.string(), z.unknown()).optional(),
|
|
1663
|
+
// Text analysis
|
|
1664
|
+
characters: z.string().optional(),
|
|
1665
|
+
style: z.record(z.string(), z.unknown()).optional(),
|
|
1666
|
+
// Handoff analysis
|
|
1667
|
+
devStatus: z.object({
|
|
1668
|
+
type: z.enum(["NONE", "READY_FOR_DEV", "COMPLETED"]),
|
|
1669
|
+
description: z.string().optional()
|
|
1670
|
+
}).optional(),
|
|
1671
|
+
// Naming analysis metadata
|
|
1672
|
+
isAsset: z.boolean().optional()
|
|
1673
|
+
});
|
|
1674
|
+
var AnalysisNodeSchema = BaseAnalysisNodeSchema.extend({
|
|
1675
|
+
children: z.lazy(() => AnalysisNodeSchema.array().optional())
|
|
1676
|
+
});
|
|
1677
|
+
z.object({
|
|
1678
|
+
fileKey: z.string(),
|
|
1679
|
+
name: z.string(),
|
|
1680
|
+
lastModified: z.string(),
|
|
1681
|
+
version: z.string(),
|
|
1682
|
+
document: AnalysisNodeSchema,
|
|
1683
|
+
components: z.record(
|
|
1684
|
+
z.string(),
|
|
1685
|
+
z.object({
|
|
1686
|
+
key: z.string(),
|
|
1687
|
+
name: z.string(),
|
|
1688
|
+
description: z.string()
|
|
1689
|
+
})
|
|
1690
|
+
),
|
|
1691
|
+
styles: z.record(
|
|
1692
|
+
z.string(),
|
|
1693
|
+
z.object({
|
|
1694
|
+
key: z.string(),
|
|
1695
|
+
name: z.string(),
|
|
1696
|
+
styleType: z.string()
|
|
1697
|
+
})
|
|
1698
|
+
)
|
|
1699
|
+
});
|
|
1700
|
+
var IssueSchema = z.object({
|
|
1701
|
+
nodeId: z.string(),
|
|
1702
|
+
nodePath: z.string(),
|
|
1703
|
+
figmaDeepLink: z.string().url(),
|
|
1704
|
+
ruleId: z.string(),
|
|
1705
|
+
message: z.string(),
|
|
1706
|
+
severity: SeveritySchema
|
|
1707
|
+
});
|
|
1708
|
+
var CategoryScoreSchema = z.object({
|
|
1709
|
+
category: CategorySchema,
|
|
1710
|
+
score: z.number().min(0).max(100),
|
|
1711
|
+
maxScore: z.number().min(0).max(100),
|
|
1712
|
+
issueCount: z.object({
|
|
1713
|
+
error: z.number().int().min(0),
|
|
1714
|
+
warning: z.number().int().min(0),
|
|
1715
|
+
info: z.number().int().min(0)
|
|
1716
|
+
})
|
|
1717
|
+
});
|
|
1718
|
+
var ReportMetadataSchema = z.object({
|
|
1719
|
+
fileKey: z.string(),
|
|
1720
|
+
fileName: z.string(),
|
|
1721
|
+
analyzedAt: z.string().datetime(),
|
|
1722
|
+
version: z.string()
|
|
1723
|
+
});
|
|
1724
|
+
z.object({
|
|
1725
|
+
metadata: ReportMetadataSchema,
|
|
1726
|
+
totalScore: z.number().min(0).max(100),
|
|
1727
|
+
categoryScores: z.array(CategoryScoreSchema),
|
|
1728
|
+
issues: z.array(IssueSchema),
|
|
1729
|
+
summary: z.object({
|
|
1730
|
+
totalNodes: z.number().int().min(0),
|
|
1731
|
+
analyzedNodes: z.number().int().min(0),
|
|
1732
|
+
errorCount: z.number().int().min(0),
|
|
1733
|
+
warningCount: z.number().int().min(0),
|
|
1734
|
+
infoCount: z.number().int().min(0)
|
|
1735
|
+
})
|
|
1736
|
+
});
|
|
1728
1737
|
|
|
1729
|
-
|
|
1730
|
-
|
|
1731
|
-
|
|
1732
|
-
|
|
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>`;
|
|
1738
|
+
// src/core/rules/excluded-names.ts
|
|
1739
|
+
var EXCLUDED_NAME_PATTERN = /(badge|close|dismiss|overlay|float|fab|dot|indicator|corner|decoration|tag|status|notification|icon|ico|image|asset|filter|dim|dimmed|bg|background|logo|avatar|divider|separator|nav|navigation|gnb|header|footer|sidebar|toolbar|modal|dialog|popup|toast|tooltip|dropdown|menu|sticky|spinner|loader|cursor|cta|chatbot|thumb|thumbnail|tabbar|tab-bar|statusbar|status-bar)/i;
|
|
1740
|
+
function isExcludedName(name) {
|
|
1741
|
+
return EXCLUDED_NAME_PATTERN.test(name);
|
|
1757
1742
|
}
|
|
1758
|
-
|
|
1759
|
-
|
|
1760
|
-
|
|
1761
|
-
|
|
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>`;
|
|
1743
|
+
|
|
1744
|
+
// src/core/rules/layout/index.ts
|
|
1745
|
+
function isContainerNode(node) {
|
|
1746
|
+
return node.type === "FRAME" || node.type === "GROUP" || node.type === "COMPONENT";
|
|
1791
1747
|
}
|
|
1792
|
-
function
|
|
1793
|
-
|
|
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>`;
|
|
1748
|
+
function hasAutoLayout(node) {
|
|
1749
|
+
return node.layoutMode !== void 0 && node.layoutMode !== "NONE";
|
|
1813
1750
|
}
|
|
1814
|
-
function
|
|
1815
|
-
return
|
|
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>`;
|
|
1751
|
+
function hasTextContent(node) {
|
|
1752
|
+
return node.type === "TEXT" || (node.children?.some((c) => c.type === "TEXT") ?? false);
|
|
1825
1753
|
}
|
|
1826
|
-
|
|
1827
|
-
|
|
1828
|
-
|
|
1829
|
-
|
|
1830
|
-
|
|
1831
|
-
|
|
1832
|
-
|
|
1833
|
-
|
|
1834
|
-
|
|
1835
|
-
|
|
1836
|
-
|
|
1837
|
-
|
|
1838
|
-
|
|
1839
|
-
|
|
1840
|
-
|
|
1841
|
-
|
|
1842
|
-
|
|
1843
|
-
|
|
1844
|
-
|
|
1845
|
-
|
|
1846
|
-
|
|
1847
|
-
|
|
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()
|
|
1754
|
+
var noAutoLayoutDef = {
|
|
1755
|
+
id: "no-auto-layout",
|
|
1756
|
+
name: "No Auto Layout",
|
|
1757
|
+
category: "layout",
|
|
1758
|
+
why: "Frames without Auto Layout require manual positioning for every element",
|
|
1759
|
+
impact: "Layout breaks on content changes, harder to maintain and scale",
|
|
1760
|
+
fix: "Apply Auto Layout to the frame with appropriate direction and spacing"
|
|
1761
|
+
};
|
|
1762
|
+
var noAutoLayoutCheck = (node, context) => {
|
|
1763
|
+
if (node.type !== "FRAME") return null;
|
|
1764
|
+
if (hasAutoLayout(node)) return null;
|
|
1765
|
+
if (!node.children || node.children.length === 0) return null;
|
|
1766
|
+
return {
|
|
1767
|
+
ruleId: noAutoLayoutDef.id,
|
|
1768
|
+
nodeId: node.id,
|
|
1769
|
+
nodePath: context.path.join(" > "),
|
|
1770
|
+
message: `Frame "${node.name}" has no Auto Layout`
|
|
1771
|
+
};
|
|
1772
|
+
};
|
|
1773
|
+
defineRule({
|
|
1774
|
+
definition: noAutoLayoutDef,
|
|
1775
|
+
check: noAutoLayoutCheck
|
|
1874
1776
|
});
|
|
1875
|
-
|
|
1876
|
-
|
|
1877
|
-
|
|
1878
|
-
|
|
1879
|
-
|
|
1880
|
-
|
|
1881
|
-
|
|
1882
|
-
|
|
1883
|
-
|
|
1884
|
-
|
|
1885
|
-
|
|
1886
|
-
|
|
1887
|
-
|
|
1888
|
-
|
|
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;
|
|
1777
|
+
var absolutePositionInAutoLayoutDef = {
|
|
1778
|
+
id: "absolute-position-in-auto-layout",
|
|
1779
|
+
name: "Absolute Position in Auto Layout",
|
|
1780
|
+
category: "layout",
|
|
1781
|
+
why: "Absolute positioning inside Auto Layout breaks the automatic flow",
|
|
1782
|
+
impact: "Element will not respond to sibling changes, may overlap unexpectedly",
|
|
1783
|
+
fix: "Remove absolute positioning or use proper Auto Layout alignment"
|
|
1784
|
+
};
|
|
1785
|
+
function isSmallRelativeToParent(node, parent) {
|
|
1786
|
+
const nodeBB = node.absoluteBoundingBox;
|
|
1787
|
+
const parentBB = parent.absoluteBoundingBox;
|
|
1788
|
+
if (!nodeBB || !parentBB) return false;
|
|
1789
|
+
if (parentBB.width === 0 || parentBB.height === 0) return false;
|
|
1790
|
+
const widthRatio = nodeBB.width / parentBB.width;
|
|
1791
|
+
const heightRatio = nodeBB.height / parentBB.height;
|
|
1792
|
+
return widthRatio < 0.25 && heightRatio < 0.25;
|
|
1917
1793
|
}
|
|
1918
|
-
var
|
|
1919
|
-
|
|
1920
|
-
|
|
1921
|
-
|
|
1922
|
-
|
|
1923
|
-
|
|
1924
|
-
|
|
1925
|
-
|
|
1926
|
-
|
|
1927
|
-
|
|
1928
|
-
|
|
1929
|
-
|
|
1930
|
-
|
|
1931
|
-
|
|
1932
|
-
|
|
1933
|
-
|
|
1934
|
-
|
|
1935
|
-
|
|
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()
|
|
1794
|
+
var absolutePositionInAutoLayoutCheck = (node, context) => {
|
|
1795
|
+
if (!context.parent) return null;
|
|
1796
|
+
if (!hasAutoLayout(context.parent)) return null;
|
|
1797
|
+
if (node.layoutPositioning !== "ABSOLUTE") return null;
|
|
1798
|
+
if (node.type === "VECTOR" || node.type === "BOOLEAN_OPERATION" || node.type === "LINE" || node.type === "ELLIPSE" || node.type === "STAR" || node.type === "REGULAR_POLYGON") return null;
|
|
1799
|
+
if (isExcludedName(node.name)) return null;
|
|
1800
|
+
if (isSmallRelativeToParent(node, context.parent)) return null;
|
|
1801
|
+
if (context.parent.type === "COMPONENT") return null;
|
|
1802
|
+
return {
|
|
1803
|
+
ruleId: absolutePositionInAutoLayoutDef.id,
|
|
1804
|
+
nodeId: node.id,
|
|
1805
|
+
nodePath: context.path.join(" > "),
|
|
1806
|
+
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.`
|
|
1807
|
+
};
|
|
1808
|
+
};
|
|
1809
|
+
defineRule({
|
|
1810
|
+
definition: absolutePositionInAutoLayoutDef,
|
|
1811
|
+
check: absolutePositionInAutoLayoutCheck
|
|
1962
1812
|
});
|
|
1963
|
-
var
|
|
1964
|
-
|
|
1965
|
-
|
|
1966
|
-
|
|
1967
|
-
|
|
1968
|
-
|
|
1969
|
-
|
|
1970
|
-
|
|
1971
|
-
|
|
1972
|
-
|
|
1973
|
-
|
|
1974
|
-
|
|
1975
|
-
|
|
1976
|
-
|
|
1977
|
-
|
|
1978
|
-
|
|
1979
|
-
|
|
1980
|
-
|
|
1813
|
+
var fixedWidthInResponsiveContextDef = {
|
|
1814
|
+
id: "fixed-width-in-responsive-context",
|
|
1815
|
+
name: "Fixed Width in Responsive Context",
|
|
1816
|
+
category: "layout",
|
|
1817
|
+
why: "Fixed width inside Auto Layout prevents responsive behavior",
|
|
1818
|
+
impact: "Content will not adapt to container size changes",
|
|
1819
|
+
fix: "Use 'Fill' or 'Hug' instead of fixed width"
|
|
1820
|
+
};
|
|
1821
|
+
var fixedWidthInResponsiveContextCheck = (node, context) => {
|
|
1822
|
+
if (!context.parent) return null;
|
|
1823
|
+
if (!hasAutoLayout(context.parent)) return null;
|
|
1824
|
+
if (!isContainerNode(node)) return null;
|
|
1825
|
+
if (node.layoutSizingHorizontal) {
|
|
1826
|
+
if (node.layoutSizingHorizontal !== "FIXED") return null;
|
|
1827
|
+
} else {
|
|
1828
|
+
if (node.layoutAlign === "STRETCH") return null;
|
|
1829
|
+
if (!node.absoluteBoundingBox) return null;
|
|
1830
|
+
if (node.layoutAlign !== "INHERIT") return null;
|
|
1981
1831
|
}
|
|
1982
|
-
|
|
1983
|
-
}
|
|
1984
|
-
function toRule(cr) {
|
|
1832
|
+
if (isExcludedName(node.name)) return null;
|
|
1985
1833
|
return {
|
|
1986
|
-
|
|
1987
|
-
|
|
1988
|
-
|
|
1989
|
-
|
|
1990
|
-
why: cr.why,
|
|
1991
|
-
impact: cr.impact,
|
|
1992
|
-
fix: cr.fix
|
|
1993
|
-
},
|
|
1994
|
-
check: createPatternCheck(cr)
|
|
1834
|
+
ruleId: fixedWidthInResponsiveContextDef.id,
|
|
1835
|
+
nodeId: node.id,
|
|
1836
|
+
nodePath: context.path.join(" > "),
|
|
1837
|
+
message: `"${node.name}" has fixed width inside Auto Layout`
|
|
1995
1838
|
};
|
|
1996
|
-
}
|
|
1997
|
-
|
|
1998
|
-
|
|
1999
|
-
|
|
2000
|
-
|
|
2001
|
-
|
|
2002
|
-
|
|
2003
|
-
|
|
2004
|
-
|
|
2005
|
-
|
|
2006
|
-
|
|
2007
|
-
|
|
2008
|
-
|
|
2009
|
-
|
|
2010
|
-
|
|
2011
|
-
|
|
2012
|
-
|
|
2013
|
-
|
|
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);
|
|
1839
|
+
};
|
|
1840
|
+
defineRule({
|
|
1841
|
+
definition: fixedWidthInResponsiveContextDef,
|
|
1842
|
+
check: fixedWidthInResponsiveContextCheck
|
|
1843
|
+
});
|
|
1844
|
+
var missingResponsiveBehaviorDef = {
|
|
1845
|
+
id: "missing-responsive-behavior",
|
|
1846
|
+
name: "Missing Responsive Behavior",
|
|
1847
|
+
category: "layout",
|
|
1848
|
+
why: "Elements without constraints won't adapt to different screen sizes",
|
|
1849
|
+
impact: "Layout will break or look wrong on different devices",
|
|
1850
|
+
fix: "Set appropriate constraints (left/right, top/bottom, scale, etc.)"
|
|
1851
|
+
};
|
|
1852
|
+
var missingResponsiveBehaviorCheck = (node, context) => {
|
|
1853
|
+
if (!isContainerNode(node)) return null;
|
|
1854
|
+
if (context.parent && hasAutoLayout(context.parent)) return null;
|
|
1855
|
+
if (context.depth < 2) return null;
|
|
1856
|
+
if (!hasAutoLayout(node) && !node.layoutAlign) {
|
|
2034
1857
|
return {
|
|
2035
|
-
ruleId:
|
|
1858
|
+
ruleId: missingResponsiveBehaviorDef.id,
|
|
2036
1859
|
nodeId: node.id,
|
|
2037
1860
|
nodePath: context.path.join(" > "),
|
|
2038
|
-
message:
|
|
1861
|
+
message: `"${node.name}" has no responsive behavior configured`
|
|
2039
1862
|
};
|
|
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`
|
|
2059
|
-
};
|
|
2060
|
-
|
|
2061
|
-
// src/core/monitoring/capture.ts
|
|
2062
|
-
var monitoringEnabled = false;
|
|
2063
|
-
var posthogApiKey;
|
|
2064
|
-
var sentryDsn;
|
|
2065
|
-
var distinctId = "anonymous";
|
|
2066
|
-
var environment = "unknown";
|
|
2067
|
-
var version = "unknown";
|
|
2068
|
-
var commonProps = {};
|
|
2069
|
-
function uuid4() {
|
|
2070
|
-
return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (c) => {
|
|
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;
|
|
2086
|
-
}
|
|
2087
|
-
}
|
|
2088
|
-
function initCapture(config2) {
|
|
2089
|
-
if (config2.enabled === false) return;
|
|
2090
|
-
if (!config2.posthogApiKey && !config2.sentryDsn) return;
|
|
2091
|
-
monitoringEnabled = true;
|
|
2092
|
-
posthogApiKey = config2.posthogApiKey;
|
|
2093
|
-
sentryDsn = config2.sentryDsn;
|
|
2094
|
-
distinctId = config2.distinctId ?? "anonymous";
|
|
2095
|
-
environment = config2.environment ?? "unknown";
|
|
2096
|
-
version = config2.version ?? "unknown";
|
|
2097
|
-
commonProps = {
|
|
2098
|
-
_sdk: "canicode",
|
|
2099
|
-
_sdk_version: version,
|
|
2100
|
-
_env: environment
|
|
2101
|
-
};
|
|
2102
|
-
}
|
|
2103
|
-
function captureEvent(event, properties) {
|
|
2104
|
-
if (!monitoringEnabled || !posthogApiKey) return;
|
|
2105
|
-
try {
|
|
2106
|
-
fetch("https://us.i.posthog.com/i/v0/e/", {
|
|
2107
|
-
method: "POST",
|
|
2108
|
-
headers: { "Content-Type": "application/json" },
|
|
2109
|
-
body: JSON.stringify({
|
|
2110
|
-
api_key: posthogApiKey,
|
|
2111
|
-
event,
|
|
2112
|
-
distinct_id: distinctId,
|
|
2113
|
-
properties: { ...commonProps, ...properties },
|
|
2114
|
-
timestamp: (/* @__PURE__ */ new Date()).toISOString()
|
|
2115
|
-
})
|
|
2116
|
-
}).catch(() => {
|
|
2117
|
-
});
|
|
2118
|
-
} catch {
|
|
2119
|
-
}
|
|
2120
|
-
}
|
|
2121
|
-
function captureError(error, context) {
|
|
2122
|
-
if (!monitoringEnabled) return;
|
|
2123
|
-
if (sentryDsn) {
|
|
2124
|
-
const parsed = parseSentryDsn(sentryDsn);
|
|
2125
|
-
if (parsed) {
|
|
2126
|
-
try {
|
|
2127
|
-
const eventId = uuid4();
|
|
2128
|
-
const envelope = [
|
|
2129
|
-
JSON.stringify({ event_id: eventId, sent_at: (/* @__PURE__ */ new Date()).toISOString(), dsn: sentryDsn }),
|
|
2130
|
-
JSON.stringify({ type: "event", content_type: "application/json" }),
|
|
2131
|
-
JSON.stringify({
|
|
2132
|
-
event_id: eventId,
|
|
2133
|
-
exception: { values: [{ type: error.name, value: error.message }] },
|
|
2134
|
-
platform: "node",
|
|
2135
|
-
environment,
|
|
2136
|
-
release: `canicode@${version}`,
|
|
2137
|
-
timestamp: Date.now() / 1e3,
|
|
2138
|
-
extra: context
|
|
2139
|
-
})
|
|
2140
|
-
].join("\n");
|
|
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 {
|
|
2186
1863
|
}
|
|
2187
|
-
|
|
2188
|
-
|
|
2189
|
-
|
|
2190
|
-
|
|
2191
|
-
|
|
2192
|
-
|
|
2193
|
-
|
|
2194
|
-
|
|
2195
|
-
|
|
2196
|
-
return EXCLUDED_NAME_PATTERN.test(name);
|
|
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",
|
|
1864
|
+
return null;
|
|
1865
|
+
};
|
|
1866
|
+
defineRule({
|
|
1867
|
+
definition: missingResponsiveBehaviorDef,
|
|
1868
|
+
check: missingResponsiveBehaviorCheck
|
|
1869
|
+
});
|
|
1870
|
+
var groupUsageDef = {
|
|
1871
|
+
id: "group-usage",
|
|
1872
|
+
name: "Group Usage",
|
|
2212
1873
|
category: "layout",
|
|
2213
|
-
why: "
|
|
2214
|
-
impact: "
|
|
2215
|
-
fix: "
|
|
1874
|
+
why: "Groups don't support Auto Layout and have limited layout control",
|
|
1875
|
+
impact: "Harder to maintain consistent spacing and alignment",
|
|
1876
|
+
fix: "Convert Group to Frame and apply Auto Layout"
|
|
2216
1877
|
};
|
|
2217
|
-
var
|
|
2218
|
-
if (node.type !== "
|
|
2219
|
-
if (hasAutoLayout(node)) return null;
|
|
2220
|
-
if (!node.children || node.children.length === 0) return null;
|
|
1878
|
+
var groupUsageCheck = (node, context) => {
|
|
1879
|
+
if (node.type !== "GROUP") return null;
|
|
2221
1880
|
return {
|
|
2222
|
-
ruleId:
|
|
1881
|
+
ruleId: groupUsageDef.id,
|
|
2223
1882
|
nodeId: node.id,
|
|
2224
1883
|
nodePath: context.path.join(" > "),
|
|
2225
|
-
message: `
|
|
1884
|
+
message: `"${node.name}" is a Group - consider converting to Frame with Auto Layout`
|
|
2226
1885
|
};
|
|
2227
1886
|
};
|
|
2228
1887
|
defineRule({
|
|
2229
|
-
definition:
|
|
2230
|
-
check:
|
|
1888
|
+
definition: groupUsageDef,
|
|
1889
|
+
check: groupUsageCheck
|
|
2231
1890
|
});
|
|
2232
|
-
var
|
|
2233
|
-
id: "
|
|
2234
|
-
name: "
|
|
1891
|
+
var fixedSizeInAutoLayoutDef = {
|
|
1892
|
+
id: "fixed-size-in-auto-layout",
|
|
1893
|
+
name: "Fixed Size in Auto Layout",
|
|
2235
1894
|
category: "layout",
|
|
2236
|
-
why: "
|
|
2237
|
-
impact: "Element
|
|
2238
|
-
fix: "
|
|
2239
|
-
};
|
|
2240
|
-
function isSmallRelativeToParent(node, parent) {
|
|
2241
|
-
const nodeBB = node.absoluteBoundingBox;
|
|
2242
|
-
const parentBB = parent.absoluteBoundingBox;
|
|
2243
|
-
if (!nodeBB || !parentBB) return false;
|
|
2244
|
-
if (parentBB.width === 0 || parentBB.height === 0) return false;
|
|
2245
|
-
const widthRatio = nodeBB.width / parentBB.width;
|
|
2246
|
-
const heightRatio = nodeBB.height / parentBB.height;
|
|
2247
|
-
return widthRatio < 0.25 && heightRatio < 0.25;
|
|
2248
|
-
}
|
|
2249
|
-
var absolutePositionInAutoLayoutCheck = (node, context) => {
|
|
2250
|
-
if (!context.parent) return null;
|
|
2251
|
-
if (!hasAutoLayout(context.parent)) return null;
|
|
2252
|
-
if (node.layoutPositioning !== "ABSOLUTE") return null;
|
|
2253
|
-
if (node.type === "VECTOR" || node.type === "BOOLEAN_OPERATION" || node.type === "LINE" || node.type === "ELLIPSE" || node.type === "STAR" || node.type === "REGULAR_POLYGON") return null;
|
|
2254
|
-
if (isExcludedName(node.name)) return null;
|
|
2255
|
-
if (isSmallRelativeToParent(node, context.parent)) return null;
|
|
2256
|
-
if (context.parent.type === "COMPONENT") return null;
|
|
2257
|
-
return {
|
|
2258
|
-
ruleId: absolutePositionInAutoLayoutDef.id,
|
|
2259
|
-
nodeId: node.id,
|
|
2260
|
-
nodePath: context.path.join(" > "),
|
|
2261
|
-
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.`
|
|
2262
|
-
};
|
|
2263
|
-
};
|
|
2264
|
-
defineRule({
|
|
2265
|
-
definition: absolutePositionInAutoLayoutDef,
|
|
2266
|
-
check: absolutePositionInAutoLayoutCheck
|
|
2267
|
-
});
|
|
2268
|
-
var fixedWidthInResponsiveContextDef = {
|
|
2269
|
-
id: "fixed-width-in-responsive-context",
|
|
2270
|
-
name: "Fixed Width in Responsive Context",
|
|
2271
|
-
category: "layout",
|
|
2272
|
-
why: "Fixed width inside Auto Layout prevents responsive behavior",
|
|
2273
|
-
impact: "Content will not adapt to container size changes",
|
|
2274
|
-
fix: "Use 'Fill' or 'Hug' instead of fixed width"
|
|
2275
|
-
};
|
|
2276
|
-
var fixedWidthInResponsiveContextCheck = (node, context) => {
|
|
2277
|
-
if (!context.parent) return null;
|
|
2278
|
-
if (!hasAutoLayout(context.parent)) return null;
|
|
2279
|
-
if (!isContainerNode(node)) return null;
|
|
2280
|
-
if (node.layoutSizingHorizontal) {
|
|
2281
|
-
if (node.layoutSizingHorizontal !== "FIXED") return null;
|
|
2282
|
-
} else {
|
|
2283
|
-
if (node.layoutAlign === "STRETCH") return null;
|
|
2284
|
-
if (!node.absoluteBoundingBox) return null;
|
|
2285
|
-
if (node.layoutAlign !== "INHERIT") return null;
|
|
2286
|
-
}
|
|
2287
|
-
if (isExcludedName(node.name)) return null;
|
|
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
|
-
};
|
|
2294
|
-
};
|
|
2295
|
-
defineRule({
|
|
2296
|
-
definition: fixedWidthInResponsiveContextDef,
|
|
2297
|
-
check: fixedWidthInResponsiveContextCheck
|
|
2298
|
-
});
|
|
2299
|
-
var missingResponsiveBehaviorDef = {
|
|
2300
|
-
id: "missing-responsive-behavior",
|
|
2301
|
-
name: "Missing Responsive Behavior",
|
|
2302
|
-
category: "layout",
|
|
2303
|
-
why: "Elements without constraints won't adapt to different screen sizes",
|
|
2304
|
-
impact: "Layout will break or look wrong on different devices",
|
|
2305
|
-
fix: "Set appropriate constraints (left/right, top/bottom, scale, etc.)"
|
|
2306
|
-
};
|
|
2307
|
-
var missingResponsiveBehaviorCheck = (node, context) => {
|
|
2308
|
-
if (!isContainerNode(node)) return null;
|
|
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
|
-
}
|
|
2319
|
-
return null;
|
|
2320
|
-
};
|
|
2321
|
-
defineRule({
|
|
2322
|
-
definition: missingResponsiveBehaviorDef,
|
|
2323
|
-
check: missingResponsiveBehaviorCheck
|
|
2324
|
-
});
|
|
2325
|
-
var groupUsageDef = {
|
|
2326
|
-
id: "group-usage",
|
|
2327
|
-
name: "Group Usage",
|
|
2328
|
-
category: "layout",
|
|
2329
|
-
why: "Groups don't support Auto Layout and have limited layout control",
|
|
2330
|
-
impact: "Harder to maintain consistent spacing and alignment",
|
|
2331
|
-
fix: "Convert Group to Frame and apply Auto Layout"
|
|
2332
|
-
};
|
|
2333
|
-
var groupUsageCheck = (node, context) => {
|
|
2334
|
-
if (node.type !== "GROUP") return null;
|
|
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
|
-
};
|
|
2341
|
-
};
|
|
2342
|
-
defineRule({
|
|
2343
|
-
definition: groupUsageDef,
|
|
2344
|
-
check: groupUsageCheck
|
|
2345
|
-
});
|
|
2346
|
-
var fixedSizeInAutoLayoutDef = {
|
|
2347
|
-
id: "fixed-size-in-auto-layout",
|
|
2348
|
-
name: "Fixed Size in Auto Layout",
|
|
2349
|
-
category: "layout",
|
|
2350
|
-
why: "Fixed sizes inside Auto Layout limit flexibility",
|
|
2351
|
-
impact: "Element won't adapt to content or container changes",
|
|
2352
|
-
fix: "Consider using 'Hug' for content-driven sizing"
|
|
1895
|
+
why: "Fixed sizes inside Auto Layout limit flexibility",
|
|
1896
|
+
impact: "Element won't adapt to content or container changes",
|
|
1897
|
+
fix: "Consider using 'Hug' for content-driven sizing"
|
|
2353
1898
|
};
|
|
2354
1899
|
var fixedSizeInAutoLayoutCheck = (node, context) => {
|
|
2355
1900
|
if (!context.parent) return null;
|
|
@@ -3130,237 +2675,919 @@ var zIndexDependentLayoutCheck = (node, context) => {
|
|
|
3130
2675
|
}
|
|
3131
2676
|
}
|
|
3132
2677
|
}
|
|
3133
|
-
if (significantOverlapCount > 0) {
|
|
3134
|
-
return {
|
|
3135
|
-
ruleId: zIndexDependentLayoutDef.id,
|
|
3136
|
-
nodeId: node.id,
|
|
3137
|
-
nodePath: context.path.join(" > "),
|
|
3138
|
-
message: `"${node.name}" uses layer stacking for layout (${significantOverlapCount} overlaps)`
|
|
3139
|
-
};
|
|
2678
|
+
if (significantOverlapCount > 0) {
|
|
2679
|
+
return {
|
|
2680
|
+
ruleId: zIndexDependentLayoutDef.id,
|
|
2681
|
+
nodeId: node.id,
|
|
2682
|
+
nodePath: context.path.join(" > "),
|
|
2683
|
+
message: `"${node.name}" uses layer stacking for layout (${significantOverlapCount} overlaps)`
|
|
2684
|
+
};
|
|
2685
|
+
}
|
|
2686
|
+
return null;
|
|
2687
|
+
};
|
|
2688
|
+
defineRule({
|
|
2689
|
+
definition: zIndexDependentLayoutDef,
|
|
2690
|
+
check: zIndexDependentLayoutCheck
|
|
2691
|
+
});
|
|
2692
|
+
var missingLayoutHintDef = {
|
|
2693
|
+
id: "missing-layout-hint",
|
|
2694
|
+
name: "Missing Layout Hint",
|
|
2695
|
+
category: "ai-readability",
|
|
2696
|
+
why: "Complex nesting without Auto Layout makes structure unpredictable",
|
|
2697
|
+
impact: "AI may generate incorrect code due to ambiguous relationships",
|
|
2698
|
+
fix: "Add Auto Layout or simplify the nesting structure"
|
|
2699
|
+
};
|
|
2700
|
+
var missingLayoutHintCheck = (node, context) => {
|
|
2701
|
+
if (!isContainerNode2(node)) return null;
|
|
2702
|
+
if (hasAutoLayout2(node)) return null;
|
|
2703
|
+
if (!node.children || node.children.length === 0) return null;
|
|
2704
|
+
const nestedContainers = node.children.filter((c) => isContainerNode2(c));
|
|
2705
|
+
if (nestedContainers.length >= 2) {
|
|
2706
|
+
const withoutLayout = nestedContainers.filter((c) => !hasAutoLayout2(c));
|
|
2707
|
+
if (withoutLayout.length >= 2) {
|
|
2708
|
+
return {
|
|
2709
|
+
ruleId: missingLayoutHintDef.id,
|
|
2710
|
+
nodeId: node.id,
|
|
2711
|
+
nodePath: context.path.join(" > "),
|
|
2712
|
+
message: `"${node.name}" has ${withoutLayout.length} nested containers without layout hints`
|
|
2713
|
+
};
|
|
2714
|
+
}
|
|
2715
|
+
}
|
|
2716
|
+
return null;
|
|
2717
|
+
};
|
|
2718
|
+
defineRule({
|
|
2719
|
+
definition: missingLayoutHintDef,
|
|
2720
|
+
check: missingLayoutHintCheck
|
|
2721
|
+
});
|
|
2722
|
+
var invisibleLayerDef = {
|
|
2723
|
+
id: "invisible-layer",
|
|
2724
|
+
name: "Invisible Layer",
|
|
2725
|
+
category: "ai-readability",
|
|
2726
|
+
why: "Hidden layers add noise and may confuse analysis tools",
|
|
2727
|
+
impact: "Exported code may include unnecessary elements",
|
|
2728
|
+
fix: "Delete hidden layers or move them to a separate 'archive' page"
|
|
2729
|
+
};
|
|
2730
|
+
var invisibleLayerCheck = (node, context) => {
|
|
2731
|
+
if (node.visible !== false) return null;
|
|
2732
|
+
if (context.parent?.visible === false) return null;
|
|
2733
|
+
return {
|
|
2734
|
+
ruleId: invisibleLayerDef.id,
|
|
2735
|
+
nodeId: node.id,
|
|
2736
|
+
nodePath: context.path.join(" > "),
|
|
2737
|
+
message: `"${node.name}" is hidden - consider removing if not needed`
|
|
2738
|
+
};
|
|
2739
|
+
};
|
|
2740
|
+
defineRule({
|
|
2741
|
+
definition: invisibleLayerDef,
|
|
2742
|
+
check: invisibleLayerCheck
|
|
2743
|
+
});
|
|
2744
|
+
var emptyFrameDef = {
|
|
2745
|
+
id: "empty-frame",
|
|
2746
|
+
name: "Empty Frame",
|
|
2747
|
+
category: "ai-readability",
|
|
2748
|
+
why: "Empty frames add noise and may indicate incomplete design",
|
|
2749
|
+
impact: "Generates unnecessary wrapper elements in code",
|
|
2750
|
+
fix: "Remove the frame or add content"
|
|
2751
|
+
};
|
|
2752
|
+
var emptyFrameCheck = (node, context) => {
|
|
2753
|
+
if (node.type !== "FRAME") return null;
|
|
2754
|
+
if (node.children && node.children.length > 0) return null;
|
|
2755
|
+
if (node.absoluteBoundingBox) {
|
|
2756
|
+
const { width, height } = node.absoluteBoundingBox;
|
|
2757
|
+
if (width <= 48 && height <= 48) return null;
|
|
2758
|
+
}
|
|
2759
|
+
return {
|
|
2760
|
+
ruleId: emptyFrameDef.id,
|
|
2761
|
+
nodeId: node.id,
|
|
2762
|
+
nodePath: context.path.join(" > "),
|
|
2763
|
+
message: `"${node.name}" is an empty frame`
|
|
2764
|
+
};
|
|
2765
|
+
};
|
|
2766
|
+
defineRule({
|
|
2767
|
+
definition: emptyFrameDef,
|
|
2768
|
+
check: emptyFrameCheck
|
|
2769
|
+
});
|
|
2770
|
+
|
|
2771
|
+
// src/core/rules/handoff-risk/index.ts
|
|
2772
|
+
function hasAutoLayout3(node) {
|
|
2773
|
+
return node.layoutMode !== void 0 && node.layoutMode !== "NONE";
|
|
2774
|
+
}
|
|
2775
|
+
function isContainerNode3(node) {
|
|
2776
|
+
return node.type === "FRAME" || node.type === "GROUP" || node.type === "COMPONENT";
|
|
2777
|
+
}
|
|
2778
|
+
function isTextNode(node) {
|
|
2779
|
+
return node.type === "TEXT";
|
|
2780
|
+
}
|
|
2781
|
+
function isImageNode(node) {
|
|
2782
|
+
if (node.type === "RECTANGLE" && node.fills) {
|
|
2783
|
+
for (const fill of node.fills) {
|
|
2784
|
+
const fillObj = fill;
|
|
2785
|
+
if (fillObj["type"] === "IMAGE") return true;
|
|
2786
|
+
}
|
|
2787
|
+
}
|
|
2788
|
+
return false;
|
|
2789
|
+
}
|
|
2790
|
+
var hardcodeRiskDef = {
|
|
2791
|
+
id: "hardcode-risk",
|
|
2792
|
+
name: "Hardcode Risk",
|
|
2793
|
+
category: "handoff-risk",
|
|
2794
|
+
why: "Absolute positioning with fixed values creates inflexible layouts",
|
|
2795
|
+
impact: "Layout will break when content changes or on different screens",
|
|
2796
|
+
fix: "Use Auto Layout with relative positioning"
|
|
2797
|
+
};
|
|
2798
|
+
var hardcodeRiskCheck = (node, context) => {
|
|
2799
|
+
if (!isContainerNode3(node)) return null;
|
|
2800
|
+
if (node.layoutPositioning !== "ABSOLUTE") return null;
|
|
2801
|
+
if (context.parent && hasAutoLayout3(context.parent)) {
|
|
2802
|
+
return {
|
|
2803
|
+
ruleId: hardcodeRiskDef.id,
|
|
2804
|
+
nodeId: node.id,
|
|
2805
|
+
nodePath: context.path.join(" > "),
|
|
2806
|
+
message: `"${node.name}" uses absolute positioning with fixed values`
|
|
2807
|
+
};
|
|
2808
|
+
}
|
|
2809
|
+
return null;
|
|
2810
|
+
};
|
|
2811
|
+
defineRule({
|
|
2812
|
+
definition: hardcodeRiskDef,
|
|
2813
|
+
check: hardcodeRiskCheck
|
|
2814
|
+
});
|
|
2815
|
+
var textTruncationUnhandledDef = {
|
|
2816
|
+
id: "text-truncation-unhandled",
|
|
2817
|
+
name: "Text Truncation Unhandled",
|
|
2818
|
+
category: "handoff-risk",
|
|
2819
|
+
why: "Text nodes without truncation handling may overflow",
|
|
2820
|
+
impact: "Long text will break the layout",
|
|
2821
|
+
fix: "Set text truncation (ellipsis) or ensure container can grow"
|
|
2822
|
+
};
|
|
2823
|
+
var textTruncationUnhandledCheck = (node, context) => {
|
|
2824
|
+
if (!isTextNode(node)) return null;
|
|
2825
|
+
if (!context.parent) return null;
|
|
2826
|
+
if (!hasAutoLayout3(context.parent)) return null;
|
|
2827
|
+
if (node.absoluteBoundingBox) {
|
|
2828
|
+
const { width } = node.absoluteBoundingBox;
|
|
2829
|
+
if (node.characters && node.characters.length > 50 && width < 300) {
|
|
2830
|
+
return {
|
|
2831
|
+
ruleId: textTruncationUnhandledDef.id,
|
|
2832
|
+
nodeId: node.id,
|
|
2833
|
+
nodePath: context.path.join(" > "),
|
|
2834
|
+
message: `"${node.name}" may need text truncation handling`
|
|
2835
|
+
};
|
|
2836
|
+
}
|
|
2837
|
+
}
|
|
2838
|
+
return null;
|
|
2839
|
+
};
|
|
2840
|
+
defineRule({
|
|
2841
|
+
definition: textTruncationUnhandledDef,
|
|
2842
|
+
check: textTruncationUnhandledCheck
|
|
2843
|
+
});
|
|
2844
|
+
var imageNoPlaceholderDef = {
|
|
2845
|
+
id: "image-no-placeholder",
|
|
2846
|
+
name: "Image No Placeholder",
|
|
2847
|
+
category: "handoff-risk",
|
|
2848
|
+
why: "Images without placeholder state may cause layout shifts",
|
|
2849
|
+
impact: "Poor user experience during image loading",
|
|
2850
|
+
fix: "Define a placeholder state or background color"
|
|
2851
|
+
};
|
|
2852
|
+
var imageNoPlaceholderCheck = (node, context) => {
|
|
2853
|
+
if (!isImageNode(node)) return null;
|
|
2854
|
+
if (node.fills && Array.isArray(node.fills) && node.fills.length === 1) {
|
|
2855
|
+
const fill = node.fills[0];
|
|
2856
|
+
if (fill["type"] === "IMAGE") {
|
|
2857
|
+
return {
|
|
2858
|
+
ruleId: imageNoPlaceholderDef.id,
|
|
2859
|
+
nodeId: node.id,
|
|
2860
|
+
nodePath: context.path.join(" > "),
|
|
2861
|
+
message: `"${node.name}" image has no placeholder fill`
|
|
2862
|
+
};
|
|
2863
|
+
}
|
|
2864
|
+
}
|
|
2865
|
+
return null;
|
|
2866
|
+
};
|
|
2867
|
+
defineRule({
|
|
2868
|
+
definition: imageNoPlaceholderDef,
|
|
2869
|
+
check: imageNoPlaceholderCheck
|
|
2870
|
+
});
|
|
2871
|
+
var prototypeLinkInDesignDef = {
|
|
2872
|
+
id: "prototype-link-in-design",
|
|
2873
|
+
name: "Prototype Link in Design",
|
|
2874
|
+
category: "handoff-risk",
|
|
2875
|
+
why: "Prototype connections may affect how the design is interpreted",
|
|
2876
|
+
impact: "Developers may misunderstand which elements should be interactive",
|
|
2877
|
+
fix: "Document interactions separately or use clear naming"
|
|
2878
|
+
};
|
|
2879
|
+
var prototypeLinkInDesignCheck = (_node, _context) => {
|
|
2880
|
+
return null;
|
|
2881
|
+
};
|
|
2882
|
+
defineRule({
|
|
2883
|
+
definition: prototypeLinkInDesignDef,
|
|
2884
|
+
check: prototypeLinkInDesignCheck
|
|
2885
|
+
});
|
|
2886
|
+
var noDevStatusDef = {
|
|
2887
|
+
id: "no-dev-status",
|
|
2888
|
+
name: "No Dev Status",
|
|
2889
|
+
category: "handoff-risk",
|
|
2890
|
+
why: "Without dev status, developers cannot know if a design is ready",
|
|
2891
|
+
impact: "May implement designs that are still in progress",
|
|
2892
|
+
fix: "Mark frames as 'Ready for Dev' or 'Completed' when appropriate"
|
|
2893
|
+
};
|
|
2894
|
+
var noDevStatusCheck = (node, context) => {
|
|
2895
|
+
if (node.type !== "FRAME") return null;
|
|
2896
|
+
if (context.depth > 1) return null;
|
|
2897
|
+
if (node.devStatus) return null;
|
|
2898
|
+
return {
|
|
2899
|
+
ruleId: noDevStatusDef.id,
|
|
2900
|
+
nodeId: node.id,
|
|
2901
|
+
nodePath: context.path.join(" > "),
|
|
2902
|
+
message: `"${node.name}" has no dev status set`
|
|
2903
|
+
};
|
|
2904
|
+
};
|
|
2905
|
+
defineRule({
|
|
2906
|
+
definition: noDevStatusDef,
|
|
2907
|
+
check: noDevStatusCheck
|
|
2908
|
+
});
|
|
2909
|
+
var SamplingStrategySchema = z.enum(["all", "top-issues", "random"]);
|
|
2910
|
+
z.enum([
|
|
2911
|
+
"pending",
|
|
2912
|
+
"analyzing",
|
|
2913
|
+
"converting",
|
|
2914
|
+
"evaluating",
|
|
2915
|
+
"tuning",
|
|
2916
|
+
"completed",
|
|
2917
|
+
"failed"
|
|
2918
|
+
]);
|
|
2919
|
+
z.object({
|
|
2920
|
+
input: z.string(),
|
|
2921
|
+
token: z.string().optional(),
|
|
2922
|
+
targetNodeId: z.string().optional(),
|
|
2923
|
+
maxConversionNodes: z.number().int().positive().default(20),
|
|
2924
|
+
samplingStrategy: SamplingStrategySchema.default("top-issues"),
|
|
2925
|
+
outputPath: z.string().default("logs/calibration/calibration-report.md")
|
|
2926
|
+
});
|
|
2927
|
+
z.object({
|
|
2928
|
+
nodeId: z.string(),
|
|
2929
|
+
nodePath: z.string(),
|
|
2930
|
+
totalScore: z.number(),
|
|
2931
|
+
issueCount: z.number(),
|
|
2932
|
+
flaggedRuleIds: z.array(z.string()),
|
|
2933
|
+
severities: z.array(z.string())
|
|
2934
|
+
});
|
|
2935
|
+
var DifficultySchema = z.enum(["easy", "moderate", "hard", "failed"]);
|
|
2936
|
+
var RuleRelatedStruggleSchema = z.object({
|
|
2937
|
+
ruleId: z.string(),
|
|
2938
|
+
description: z.string(),
|
|
2939
|
+
actualImpact: DifficultySchema
|
|
2940
|
+
});
|
|
2941
|
+
var UncoveredStruggleSchema = z.object({
|
|
2942
|
+
description: z.string(),
|
|
2943
|
+
suggestedCategory: z.string(),
|
|
2944
|
+
estimatedImpact: DifficultySchema
|
|
2945
|
+
});
|
|
2946
|
+
z.object({
|
|
2947
|
+
nodeId: z.string(),
|
|
2948
|
+
nodePath: z.string(),
|
|
2949
|
+
generatedCode: z.string(),
|
|
2950
|
+
difficulty: DifficultySchema,
|
|
2951
|
+
notes: z.string(),
|
|
2952
|
+
ruleRelatedStruggles: z.array(RuleRelatedStruggleSchema),
|
|
2953
|
+
uncoveredStruggles: z.array(UncoveredStruggleSchema),
|
|
2954
|
+
durationMs: z.number()
|
|
2955
|
+
});
|
|
2956
|
+
var MismatchTypeSchema = z.enum([
|
|
2957
|
+
"overscored",
|
|
2958
|
+
"underscored",
|
|
2959
|
+
"missing-rule",
|
|
2960
|
+
"validated"
|
|
2961
|
+
]);
|
|
2962
|
+
z.object({
|
|
2963
|
+
type: MismatchTypeSchema,
|
|
2964
|
+
nodeId: z.string(),
|
|
2965
|
+
nodePath: z.string(),
|
|
2966
|
+
ruleId: z.string().optional(),
|
|
2967
|
+
currentScore: z.number().optional(),
|
|
2968
|
+
currentSeverity: SeveritySchema.optional(),
|
|
2969
|
+
actualDifficulty: DifficultySchema,
|
|
2970
|
+
reasoning: z.string()
|
|
2971
|
+
});
|
|
2972
|
+
var ConfidenceSchema = z.enum(["high", "medium", "low"]);
|
|
2973
|
+
z.object({
|
|
2974
|
+
ruleId: z.string(),
|
|
2975
|
+
currentScore: z.number(),
|
|
2976
|
+
proposedScore: z.number(),
|
|
2977
|
+
currentSeverity: SeveritySchema,
|
|
2978
|
+
proposedSeverity: SeveritySchema.optional(),
|
|
2979
|
+
reasoning: z.string(),
|
|
2980
|
+
confidence: ConfidenceSchema,
|
|
2981
|
+
supportingCases: z.number()
|
|
2982
|
+
});
|
|
2983
|
+
z.object({
|
|
2984
|
+
suggestedId: z.string(),
|
|
2985
|
+
category: z.string(),
|
|
2986
|
+
description: z.string(),
|
|
2987
|
+
suggestedSeverity: SeveritySchema,
|
|
2988
|
+
suggestedScore: z.number(),
|
|
2989
|
+
reasoning: z.string(),
|
|
2990
|
+
supportingCases: z.number()
|
|
2991
|
+
});
|
|
2992
|
+
|
|
2993
|
+
// src/core/report-html/index.ts
|
|
2994
|
+
function generateHtmlReport(file, result, scores, options) {
|
|
2995
|
+
const screenshotMap = new Map(
|
|
2996
|
+
(options?.nodeScreenshots ?? []).map((ns) => [ns.nodeId, ns])
|
|
2997
|
+
);
|
|
2998
|
+
const figmaToken = options?.figmaToken;
|
|
2999
|
+
const quickWins = getQuickWins(result.issues, 5);
|
|
3000
|
+
const issuesByCategory = groupIssuesByCategory(result.issues);
|
|
3001
|
+
return `<!DOCTYPE html>
|
|
3002
|
+
<html lang="en" class="antialiased">
|
|
3003
|
+
<head>
|
|
3004
|
+
<meta charset="UTF-8">
|
|
3005
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
3006
|
+
<title>CanICode Report \u2014 ${esc(file.name)}</title>
|
|
3007
|
+
<script src="https://cdn.tailwindcss.com"></script>
|
|
3008
|
+
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
3009
|
+
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
3010
|
+
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
|
|
3011
|
+
<script>
|
|
3012
|
+
tailwind.config = {
|
|
3013
|
+
theme: {
|
|
3014
|
+
extend: {
|
|
3015
|
+
fontFamily: { sans: ['Inter', 'ui-sans-serif', 'system-ui', '-apple-system', 'sans-serif'] },
|
|
3016
|
+
colors: {
|
|
3017
|
+
border: 'hsl(240 5.9% 90%)',
|
|
3018
|
+
ring: 'hsl(240 5.9% 10%)',
|
|
3019
|
+
background: 'hsl(0 0% 100%)',
|
|
3020
|
+
foreground: 'hsl(240 10% 3.9%)',
|
|
3021
|
+
muted: { DEFAULT: 'hsl(240 4.8% 95.9%)', foreground: 'hsl(240 3.8% 46.1%)' },
|
|
3022
|
+
card: { DEFAULT: 'hsl(0 0% 100%)', foreground: 'hsl(240 10% 3.9%)' },
|
|
3023
|
+
},
|
|
3024
|
+
borderRadius: { lg: '0.5rem', md: 'calc(0.5rem - 2px)', sm: 'calc(0.5rem - 4px)' },
|
|
3025
|
+
}
|
|
3026
|
+
}
|
|
3027
|
+
}
|
|
3028
|
+
</script>
|
|
3029
|
+
<style>
|
|
3030
|
+
details summary::-webkit-details-marker { display: none; }
|
|
3031
|
+
details summary::marker { content: ""; }
|
|
3032
|
+
details summary { list-style: none; }
|
|
3033
|
+
.gauge-fill { transition: stroke-dashoffset 0.8s cubic-bezier(0.4,0,0.2,1); }
|
|
3034
|
+
@media print {
|
|
3035
|
+
.no-print { display: none !important; }
|
|
3036
|
+
.topbar-print { position: static !important; background: white !important; color: hsl(240 10% 3.9%) !important; }
|
|
3037
|
+
}
|
|
3038
|
+
</style>
|
|
3039
|
+
</head>
|
|
3040
|
+
<body class="bg-muted font-sans text-foreground min-h-screen">
|
|
3041
|
+
|
|
3042
|
+
<!-- Top Bar -->
|
|
3043
|
+
<header class="topbar-print sticky top-0 z-50 bg-zinc-950 text-white border-b border-zinc-800">
|
|
3044
|
+
<div class="max-w-[960px] mx-auto px-6 py-3 flex items-center gap-4">
|
|
3045
|
+
<span class="font-semibold text-sm tracking-tight">CanICode</span>
|
|
3046
|
+
<span class="text-zinc-400 text-sm truncate">${esc(file.name)}</span>
|
|
3047
|
+
<span class="ml-auto text-zinc-500 text-xs no-print">${(/* @__PURE__ */ new Date()).toLocaleDateString()}</span>
|
|
3048
|
+
</div>
|
|
3049
|
+
</header>
|
|
3050
|
+
|
|
3051
|
+
<main class="max-w-[960px] mx-auto px-6 pb-16">
|
|
3052
|
+
|
|
3053
|
+
<!-- Overall Score -->
|
|
3054
|
+
<section class="flex flex-col items-center pt-12 pb-6">
|
|
3055
|
+
${renderGaugeSvg(scores.overall.percentage, 200, 10, scores.overall.grade)}
|
|
3056
|
+
<div class="mt-3 text-center">
|
|
3057
|
+
<span class="text-lg font-semibold">${scores.overall.percentage}</span>
|
|
3058
|
+
<span class="text-muted-foreground text-sm ml-1">/ 100</span>
|
|
3059
|
+
</div>
|
|
3060
|
+
<p class="text-muted-foreground text-sm mt-1">Overall Score</p>
|
|
3061
|
+
</section>
|
|
3062
|
+
|
|
3063
|
+
<!-- Category Gauges -->
|
|
3064
|
+
<section class="bg-card border border-border rounded-lg shadow-sm p-6 mb-6">
|
|
3065
|
+
<div class="grid grid-cols-3 sm:grid-cols-6 gap-4">
|
|
3066
|
+
${CATEGORIES.map((cat) => {
|
|
3067
|
+
const cs = scores.byCategory[cat];
|
|
3068
|
+
const desc = CATEGORY_DESCRIPTIONS[cat];
|
|
3069
|
+
return ` <a href="#cat-${cat}" class="flex flex-col items-center group relative cursor-pointer no-underline text-foreground hover:opacity-80 transition-opacity">
|
|
3070
|
+
${renderGaugeSvg(cs.percentage, 100, 7)}
|
|
3071
|
+
<span class="text-xs font-medium mt-2.5 text-center leading-tight">${CATEGORY_LABELS[cat]}</span>
|
|
3072
|
+
<span class="text-[11px] text-muted-foreground">${cs.issueCount} issues</span>
|
|
3073
|
+
<div class="absolute bottom-full mb-2 left-1/2 -translate-x-1/2 hidden group-hover:block bg-zinc-900 text-white text-xs px-3 py-2 rounded-md whitespace-nowrap z-10 shadow-lg pointer-events-none">
|
|
3074
|
+
${esc(desc)}
|
|
3075
|
+
<div class="absolute top-full left-1/2 -translate-x-1/2 border-4 border-transparent border-t-zinc-900"></div>
|
|
3076
|
+
</div>
|
|
3077
|
+
</a>`;
|
|
3078
|
+
}).join("\n")}
|
|
3079
|
+
</div>
|
|
3080
|
+
</section>
|
|
3081
|
+
|
|
3082
|
+
<!-- Issue Summary -->
|
|
3083
|
+
<section class="bg-card border border-border rounded-lg shadow-sm p-4 mb-6">
|
|
3084
|
+
<div class="flex flex-wrap items-center justify-center gap-6">
|
|
3085
|
+
${renderSummaryDot("bg-red-500", scores.summary.blocking, "Blocking")}
|
|
3086
|
+
${renderSummaryDot("bg-amber-500", scores.summary.risk, "Risk")}
|
|
3087
|
+
${renderSummaryDot("bg-zinc-400", scores.summary.missingInfo, "Missing Info")}
|
|
3088
|
+
${renderSummaryDot("bg-green-500", scores.summary.suggestion, "Suggestion")}
|
|
3089
|
+
<div class="border-l border-border pl-6 flex items-center gap-2">
|
|
3090
|
+
<span class="text-xl font-bold tracking-tight">${scores.summary.totalIssues}</span>
|
|
3091
|
+
<span class="text-sm text-muted-foreground">Total</span>
|
|
3092
|
+
</div>
|
|
3093
|
+
</div>
|
|
3094
|
+
</section>
|
|
3095
|
+
|
|
3096
|
+
${quickWins.length > 0 ? renderOpportunities(quickWins, file.fileKey) : ""}
|
|
3097
|
+
|
|
3098
|
+
<!-- Categories -->
|
|
3099
|
+
<div class="space-y-3">
|
|
3100
|
+
${CATEGORIES.map((cat) => renderCategory(cat, scores, issuesByCategory.get(cat) ?? [], file.fileKey, screenshotMap, figmaToken)).join("\n")}
|
|
3101
|
+
</div>
|
|
3102
|
+
|
|
3103
|
+
<!-- Footer -->
|
|
3104
|
+
<footer class="mt-12 pt-6 border-t border-border text-center">
|
|
3105
|
+
<p class="text-sm text-muted-foreground">Generated by <span class="font-semibold text-foreground">CanICode</span> <span class="text-muted-foreground/60">v${version}</span></p>
|
|
3106
|
+
<p class="text-xs text-muted-foreground/60 mt-1">${(/* @__PURE__ */ new Date()).toLocaleString()} \xB7 ${result.nodeCount} nodes \xB7 Max depth ${result.maxDepth}</p>
|
|
3107
|
+
</footer>
|
|
3108
|
+
|
|
3109
|
+
</main>
|
|
3110
|
+
|
|
3111
|
+
${figmaToken ? ` <script>
|
|
3112
|
+
const FIGMA_TOKEN = '${figmaToken}';
|
|
3113
|
+
async function postComment(btn) {
|
|
3114
|
+
const fileKey = btn.dataset.fileKey;
|
|
3115
|
+
const nodeId = btn.dataset.nodeId.replace(/-/g, ':');
|
|
3116
|
+
const rule = btn.dataset.rule;
|
|
3117
|
+
const message = btn.dataset.message;
|
|
3118
|
+
const path = btn.dataset.path;
|
|
3119
|
+
const fix = btn.dataset.fix;
|
|
3120
|
+
const why = btn.dataset.why;
|
|
3121
|
+
const impact = btn.dataset.impact;
|
|
3122
|
+
|
|
3123
|
+
const commentBody = '[CanICode] ' + rule + '\\n\\nFix: ' + fix + '\\nWhy: ' + why + '\\nImpact: ' + impact + '\\n\\n' + message + '\\nNode: ' + path;
|
|
3124
|
+
|
|
3125
|
+
btn.disabled = true;
|
|
3126
|
+
btn.textContent = 'Sending...';
|
|
3127
|
+
|
|
3128
|
+
try {
|
|
3129
|
+
const res = await fetch('https://api.figma.com/v1/files/' + fileKey + '/comments', {
|
|
3130
|
+
method: 'POST',
|
|
3131
|
+
headers: { 'X-FIGMA-TOKEN': FIGMA_TOKEN, 'Content-Type': 'application/json' },
|
|
3132
|
+
body: JSON.stringify({ message: commentBody, client_meta: { node_id: nodeId, node_offset: { x: 0, y: 0 } } }),
|
|
3133
|
+
});
|
|
3134
|
+
if (!res.ok) throw new Error(await res.text());
|
|
3135
|
+
btn.textContent = 'Sent \\u2713';
|
|
3136
|
+
btn.classList.remove('hover:bg-muted');
|
|
3137
|
+
btn.classList.add('text-green-600', 'border-green-500/30');
|
|
3138
|
+
} catch (e) {
|
|
3139
|
+
btn.textContent = 'Failed \\u2717';
|
|
3140
|
+
btn.classList.remove('hover:bg-muted');
|
|
3141
|
+
btn.classList.add('text-red-600', 'border-red-500/30');
|
|
3142
|
+
btn.disabled = false;
|
|
3143
|
+
console.error('Failed to post Figma comment:', e);
|
|
3144
|
+
}
|
|
3145
|
+
}
|
|
3146
|
+
</script>` : ""}
|
|
3147
|
+
</body>
|
|
3148
|
+
</html>`;
|
|
3149
|
+
}
|
|
3150
|
+
function renderSummaryDot(dotClass, count, label) {
|
|
3151
|
+
return `<div class="flex items-center gap-2">
|
|
3152
|
+
<span class="w-2.5 h-2.5 rounded-full ${dotClass}"></span>
|
|
3153
|
+
<span class="text-lg font-bold tracking-tight">${count}</span>
|
|
3154
|
+
<span class="text-sm text-muted-foreground">${label}</span>
|
|
3155
|
+
</div>`;
|
|
3156
|
+
}
|
|
3157
|
+
function renderOpportunities(issues, fileKey) {
|
|
3158
|
+
const maxAbs = issues.reduce((m, i) => Math.max(m, Math.abs(i.calculatedScore)), 1);
|
|
3159
|
+
return `
|
|
3160
|
+
<!-- Opportunities -->
|
|
3161
|
+
<section class="bg-card border border-border rounded-lg shadow-sm mb-6 overflow-hidden">
|
|
3162
|
+
<div class="px-6 py-4 border-b border-border">
|
|
3163
|
+
<h2 class="text-sm font-semibold flex items-center gap-2">
|
|
3164
|
+
<span class="w-2 h-2 rounded-full bg-red-500"></span>
|
|
3165
|
+
Opportunities
|
|
3166
|
+
</h2>
|
|
3167
|
+
<p class="text-xs text-muted-foreground mt-1">Top blocking issues \u2014 fix these first for the biggest improvement.</p>
|
|
3168
|
+
</div>
|
|
3169
|
+
<div class="divide-y divide-border">
|
|
3170
|
+
${issues.map((issue) => {
|
|
3171
|
+
const def = issue.rule.definition;
|
|
3172
|
+
const link = buildFigmaDeepLink(fileKey, issue.violation.nodeId);
|
|
3173
|
+
const barW = Math.round(Math.abs(issue.calculatedScore) / maxAbs * 100);
|
|
3174
|
+
return ` <div class="px-6 py-3 flex items-center gap-4 hover:bg-muted/50 transition-colors">
|
|
3175
|
+
<div class="flex-1 min-w-0">
|
|
3176
|
+
<div class="text-sm font-medium truncate">${esc(def.name)}</div>
|
|
3177
|
+
<div class="text-xs text-muted-foreground truncate mt-0.5">${esc(issue.violation.message)}</div>
|
|
3178
|
+
</div>
|
|
3179
|
+
<div class="w-32 flex items-center gap-2 shrink-0">
|
|
3180
|
+
<div class="flex-1 h-1.5 bg-muted rounded-full overflow-hidden">
|
|
3181
|
+
<div class="h-full bg-red-500 rounded-full" style="width:${barW}%"></div>
|
|
3182
|
+
</div>
|
|
3183
|
+
<span class="text-xs font-medium text-red-600 w-12 text-right">${issue.calculatedScore}</span>
|
|
3184
|
+
</div>
|
|
3185
|
+
<a href="${link}" target="_blank" rel="noopener" class="text-xs text-muted-foreground hover:text-foreground shrink-0 no-print">Figma \u2192</a>
|
|
3186
|
+
</div>`;
|
|
3187
|
+
}).join("\n")}
|
|
3188
|
+
</div>
|
|
3189
|
+
</section>`;
|
|
3190
|
+
}
|
|
3191
|
+
function renderCategory(cat, scores, issues, fileKey, screenshotMap, figmaToken) {
|
|
3192
|
+
const cs = scores.byCategory[cat];
|
|
3193
|
+
const hasProblems = issues.some((i) => i.config.severity === "blocking" || i.config.severity === "risk");
|
|
3194
|
+
const bySeverity = /* @__PURE__ */ new Map();
|
|
3195
|
+
for (const sev of SEVERITY_ORDER) bySeverity.set(sev, []);
|
|
3196
|
+
for (const issue of issues) bySeverity.get(issue.config.severity)?.push(issue);
|
|
3197
|
+
return `
|
|
3198
|
+
<details id="cat-${cat}" class="bg-card border border-border rounded-lg shadow-sm overflow-hidden group"${hasProblems ? " open" : ""}>
|
|
3199
|
+
<summary class="px-5 py-3.5 flex items-center gap-3 cursor-pointer hover:bg-muted/50 transition-colors select-none">
|
|
3200
|
+
<span class="inline-flex items-center justify-center w-10 h-6 rounded-md text-xs font-bold border ${scoreBadgeStyle(cs.percentage)}">${cs.percentage}</span>
|
|
3201
|
+
<div class="flex-1 min-w-0">
|
|
3202
|
+
<div class="text-sm font-semibold">${CATEGORY_LABELS[cat]}</div>
|
|
3203
|
+
<div class="text-xs text-muted-foreground">${esc(CATEGORY_DESCRIPTIONS[cat])}</div>
|
|
3204
|
+
</div>
|
|
3205
|
+
<span class="text-xs text-muted-foreground">${cs.issueCount} issues</span>
|
|
3206
|
+
<svg class="w-4 h-4 text-muted-foreground transition-transform group-open:rotate-180 shrink-0 no-print" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path d="M19 9l-7 7-7-7"/></svg>
|
|
3207
|
+
</summary>
|
|
3208
|
+
<div class="border-t border-border">
|
|
3209
|
+
${issues.length === 0 ? ' <div class="px-5 py-4 text-sm text-green-600 font-medium">No issues found</div>' : SEVERITY_ORDER.filter((sev) => (bySeverity.get(sev)?.length ?? 0) > 0).map((sev) => renderSeverityGroup(sev, bySeverity.get(sev) ?? [], fileKey, screenshotMap, figmaToken)).join("\n")}
|
|
3210
|
+
</div>
|
|
3211
|
+
</details>`;
|
|
3212
|
+
}
|
|
3213
|
+
function renderSeverityGroup(sev, issues, fileKey, screenshotMap, figmaToken) {
|
|
3214
|
+
return ` <div class="px-5 py-3">
|
|
3215
|
+
<div class="flex items-center gap-2 mb-2">
|
|
3216
|
+
<span class="w-2 h-2 rounded-full ${severityDot(sev)}"></span>
|
|
3217
|
+
<span class="text-xs font-semibold uppercase tracking-wider">${SEVERITY_LABELS[sev]}</span>
|
|
3218
|
+
<span class="text-xs text-muted-foreground ml-auto">${issues.length}</span>
|
|
3219
|
+
</div>
|
|
3220
|
+
<div class="space-y-1">
|
|
3221
|
+
${issues.map((issue) => renderIssueRow(issue, fileKey, screenshotMap, figmaToken)).join("\n")}
|
|
3222
|
+
</div>
|
|
3223
|
+
</div>`;
|
|
3224
|
+
}
|
|
3225
|
+
function renderIssueRow(issue, fileKey, screenshotMap, figmaToken) {
|
|
3226
|
+
const sev = issue.config.severity;
|
|
3227
|
+
const def = issue.rule.definition;
|
|
3228
|
+
const link = buildFigmaDeepLink(fileKey, issue.violation.nodeId);
|
|
3229
|
+
const screenshot = screenshotMap.get(issue.violation.nodeId);
|
|
3230
|
+
const screenshotHtml = screenshot ? `<div class="mt-3"><a href="${link}" target="_blank" rel="noopener"><img src="data:image/png;base64,${screenshot.screenshotBase64}" alt="${esc(screenshot.nodePath)}" class="max-w-[240px] border border-border rounded-md"></a></div>` : "";
|
|
3231
|
+
return ` <details class="border border-border rounded-md overflow-hidden">
|
|
3232
|
+
<summary class="flex items-center gap-2.5 px-3 py-2 text-sm cursor-pointer hover:bg-muted/50 transition-colors">
|
|
3233
|
+
<span class="w-1.5 h-1.5 rounded-full ${severityDot(sev)} shrink-0"></span>
|
|
3234
|
+
<span class="font-medium shrink-0">${esc(def.name)}</span>
|
|
3235
|
+
<span class="text-muted-foreground truncate text-xs flex-1">${esc(issue.violation.message)}</span>
|
|
3236
|
+
<span class="inline-flex items-center px-1.5 py-0.5 text-[10px] font-medium rounded border ${severityBadge(sev)} shrink-0">${issue.calculatedScore}</span>
|
|
3237
|
+
</summary>
|
|
3238
|
+
<div class="px-3 py-3 bg-muted/30 border-t border-border text-sm space-y-2">
|
|
3239
|
+
<div class="font-mono text-xs text-muted-foreground break-all">${esc(issue.violation.nodePath)}</div>
|
|
3240
|
+
<div class="text-muted-foreground leading-relaxed space-y-1">
|
|
3241
|
+
<p><span class="font-medium text-foreground">Why:</span> ${esc(def.why)}</p>
|
|
3242
|
+
<p><span class="font-medium text-foreground">Impact:</span> ${esc(def.impact)}</p>
|
|
3243
|
+
<p><span class="font-medium text-foreground">Fix:</span> ${esc(def.fix)}</p>
|
|
3244
|
+
</div>${screenshotHtml}
|
|
3245
|
+
<div class="flex items-center gap-2 mt-1 no-print">
|
|
3246
|
+
<a href="${link}" target="_blank" rel="noopener" class="inline-flex items-center gap-1 px-2.5 py-1 text-xs font-medium border border-border rounded-md hover:bg-muted transition-colors">Open in Figma <span>\u2192</span></a>${figmaToken ? `
|
|
3247
|
+
<button onclick="postComment(this)" data-file-key="${esc(fileKey)}" data-node-id="${esc(issue.violation.nodeId)}" data-rule="${esc(def.name)}" data-message="${esc(issue.violation.message)}" data-path="${esc(issue.violation.nodePath)}" data-fix="${esc(def.fix)}" data-why="${esc(def.why)}" data-impact="${esc(def.impact)}" class="inline-flex items-center gap-1 px-2.5 py-1 text-xs font-medium border border-border rounded-md hover:bg-muted transition-colors cursor-pointer">Comment on Figma</button>` : ""}
|
|
3248
|
+
</div>
|
|
3249
|
+
</div>
|
|
3250
|
+
</details>`;
|
|
3251
|
+
}
|
|
3252
|
+
function getQuickWins(issues, limit) {
|
|
3253
|
+
return issues.filter((issue) => issue.config.severity === "blocking").sort((a, b) => a.calculatedScore - b.calculatedScore).slice(0, limit);
|
|
3254
|
+
}
|
|
3255
|
+
function groupIssuesByCategory(issues) {
|
|
3256
|
+
const grouped = /* @__PURE__ */ new Map();
|
|
3257
|
+
for (const category of CATEGORIES) grouped.set(category, []);
|
|
3258
|
+
for (const issue of issues) grouped.get(issue.rule.definition.category).push(issue);
|
|
3259
|
+
return grouped;
|
|
3260
|
+
}
|
|
3261
|
+
var esc = escapeHtml;
|
|
3262
|
+
var RuleOverrideSchema = z.object({
|
|
3263
|
+
score: z.number().int().max(0).optional(),
|
|
3264
|
+
severity: SeveritySchema.optional(),
|
|
3265
|
+
enabled: z.boolean().optional()
|
|
3266
|
+
});
|
|
3267
|
+
var ConfigFileSchema = z.object({
|
|
3268
|
+
excludeNodeTypes: z.array(z.string()).optional(),
|
|
3269
|
+
excludeNodeNames: z.array(z.string()).optional(),
|
|
3270
|
+
gridBase: z.number().int().positive().optional(),
|
|
3271
|
+
colorTolerance: z.number().int().positive().optional(),
|
|
3272
|
+
rules: z.record(z.string(), RuleOverrideSchema).optional()
|
|
3273
|
+
});
|
|
3274
|
+
async function loadConfigFile(filePath) {
|
|
3275
|
+
const absPath = resolve(filePath);
|
|
3276
|
+
const raw = await readFile(absPath, "utf-8");
|
|
3277
|
+
const parsed = JSON.parse(raw);
|
|
3278
|
+
return ConfigFileSchema.parse(parsed);
|
|
3279
|
+
}
|
|
3280
|
+
function mergeConfigs(base, overrides) {
|
|
3281
|
+
const merged = { ...base };
|
|
3282
|
+
if (overrides.gridBase !== void 0) {
|
|
3283
|
+
for (const [id, config2] of Object.entries(merged)) {
|
|
3284
|
+
if (config2.options && "gridBase" in config2.options) {
|
|
3285
|
+
merged[id] = {
|
|
3286
|
+
...config2,
|
|
3287
|
+
options: { ...config2.options, gridBase: overrides.gridBase }
|
|
3288
|
+
};
|
|
3289
|
+
}
|
|
3290
|
+
}
|
|
3291
|
+
}
|
|
3292
|
+
if (overrides.colorTolerance !== void 0) {
|
|
3293
|
+
for (const [id, config2] of Object.entries(merged)) {
|
|
3294
|
+
if (config2.options && "tolerance" in config2.options) {
|
|
3295
|
+
merged[id] = {
|
|
3296
|
+
...config2,
|
|
3297
|
+
options: { ...config2.options, tolerance: overrides.colorTolerance }
|
|
3298
|
+
};
|
|
3299
|
+
}
|
|
3300
|
+
}
|
|
3140
3301
|
}
|
|
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
|
-
};
|
|
3302
|
+
if (overrides.rules) {
|
|
3303
|
+
for (const [ruleId, override] of Object.entries(overrides.rules)) {
|
|
3304
|
+
const existing = merged[ruleId];
|
|
3305
|
+
if (existing) {
|
|
3306
|
+
merged[ruleId] = {
|
|
3307
|
+
...existing,
|
|
3308
|
+
...override.score !== void 0 && { score: override.score },
|
|
3309
|
+
...override.severity !== void 0 && { severity: override.severity },
|
|
3310
|
+
...override.enabled !== void 0 && { enabled: override.enabled }
|
|
3311
|
+
};
|
|
3312
|
+
}
|
|
3169
3313
|
}
|
|
3170
3314
|
}
|
|
3171
|
-
return
|
|
3172
|
-
}
|
|
3173
|
-
|
|
3174
|
-
|
|
3175
|
-
|
|
3315
|
+
return merged;
|
|
3316
|
+
}
|
|
3317
|
+
var MatchConditionSchema = z.object({
|
|
3318
|
+
// Node type conditions
|
|
3319
|
+
type: z.array(z.string()).optional(),
|
|
3320
|
+
notType: z.array(z.string()).optional(),
|
|
3321
|
+
// Name conditions (case-insensitive, substring match)
|
|
3322
|
+
nameContains: z.string().optional(),
|
|
3323
|
+
nameNotContains: z.string().optional(),
|
|
3324
|
+
namePattern: z.string().optional(),
|
|
3325
|
+
// Size conditions
|
|
3326
|
+
minWidth: z.number().optional(),
|
|
3327
|
+
maxWidth: z.number().optional(),
|
|
3328
|
+
minHeight: z.number().optional(),
|
|
3329
|
+
maxHeight: z.number().optional(),
|
|
3330
|
+
// Layout conditions
|
|
3331
|
+
hasAutoLayout: z.boolean().optional(),
|
|
3332
|
+
hasChildren: z.boolean().optional(),
|
|
3333
|
+
minChildren: z.number().optional(),
|
|
3334
|
+
maxChildren: z.number().optional(),
|
|
3335
|
+
// Component conditions
|
|
3336
|
+
isComponent: z.boolean().optional(),
|
|
3337
|
+
isInstance: z.boolean().optional(),
|
|
3338
|
+
hasComponentId: z.boolean().optional(),
|
|
3339
|
+
// Visibility
|
|
3340
|
+
isVisible: z.boolean().optional(),
|
|
3341
|
+
// Fill/style conditions
|
|
3342
|
+
hasFills: z.boolean().optional(),
|
|
3343
|
+
hasStrokes: z.boolean().optional(),
|
|
3344
|
+
hasEffects: z.boolean().optional(),
|
|
3345
|
+
// Depth condition
|
|
3346
|
+
minDepth: z.number().optional(),
|
|
3347
|
+
maxDepth: z.number().optional()
|
|
3176
3348
|
});
|
|
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
|
|
3349
|
+
var CustomRuleSchema = z.object({
|
|
3350
|
+
id: z.string(),
|
|
3351
|
+
category: CategorySchema,
|
|
3352
|
+
severity: SeveritySchema,
|
|
3353
|
+
score: z.number().int().max(0),
|
|
3354
|
+
match: MatchConditionSchema,
|
|
3355
|
+
message: z.string().optional(),
|
|
3356
|
+
why: z.string(),
|
|
3357
|
+
impact: z.string(),
|
|
3358
|
+
fix: z.string(),
|
|
3359
|
+
// Backward compat: silently ignore the old prompt field
|
|
3360
|
+
prompt: z.string().optional()
|
|
3198
3361
|
});
|
|
3199
|
-
var
|
|
3200
|
-
|
|
3201
|
-
|
|
3202
|
-
|
|
3203
|
-
|
|
3204
|
-
|
|
3205
|
-
|
|
3206
|
-
|
|
3207
|
-
|
|
3208
|
-
|
|
3209
|
-
|
|
3210
|
-
|
|
3211
|
-
|
|
3212
|
-
|
|
3362
|
+
var CustomRulesFileSchema = z.array(CustomRuleSchema);
|
|
3363
|
+
|
|
3364
|
+
// src/core/rules/custom/custom-rule-loader.ts
|
|
3365
|
+
async function loadCustomRules(filePath) {
|
|
3366
|
+
const absPath = resolve(filePath);
|
|
3367
|
+
const raw = await readFile(absPath, "utf-8");
|
|
3368
|
+
const parsed = JSON.parse(raw);
|
|
3369
|
+
const customRules = CustomRulesFileSchema.parse(parsed);
|
|
3370
|
+
const rules = [];
|
|
3371
|
+
const configs = {};
|
|
3372
|
+
for (const cr of customRules) {
|
|
3373
|
+
if (!cr.match) continue;
|
|
3374
|
+
rules.push(toRule(cr));
|
|
3375
|
+
configs[cr.id] = {
|
|
3376
|
+
severity: cr.severity,
|
|
3377
|
+
score: cr.score,
|
|
3378
|
+
enabled: true
|
|
3379
|
+
};
|
|
3213
3380
|
}
|
|
3381
|
+
return { rules, configs };
|
|
3382
|
+
}
|
|
3383
|
+
function toRule(cr) {
|
|
3214
3384
|
return {
|
|
3215
|
-
|
|
3216
|
-
|
|
3217
|
-
|
|
3218
|
-
|
|
3385
|
+
definition: {
|
|
3386
|
+
id: cr.id,
|
|
3387
|
+
name: cr.id.replace(/-/g, " ").replace(/\b\w/g, (c) => c.toUpperCase()),
|
|
3388
|
+
category: cr.category,
|
|
3389
|
+
why: cr.why,
|
|
3390
|
+
impact: cr.impact,
|
|
3391
|
+
fix: cr.fix
|
|
3392
|
+
},
|
|
3393
|
+
check: createPatternCheck(cr)
|
|
3394
|
+
};
|
|
3395
|
+
}
|
|
3396
|
+
function createPatternCheck(cr) {
|
|
3397
|
+
return (node, context) => {
|
|
3398
|
+
if (node.type === "DOCUMENT" || node.type === "CANVAS") return null;
|
|
3399
|
+
const match = cr.match;
|
|
3400
|
+
if (match.type && !match.type.includes(node.type)) return null;
|
|
3401
|
+
if (match.notType && match.notType.includes(node.type)) return null;
|
|
3402
|
+
if (match.nameContains !== void 0 && !node.name.toLowerCase().includes(match.nameContains.toLowerCase())) return null;
|
|
3403
|
+
if (match.nameNotContains !== void 0 && node.name.toLowerCase().includes(match.nameNotContains.toLowerCase())) return null;
|
|
3404
|
+
if (match.namePattern !== void 0 && !new RegExp(match.namePattern, "i").test(node.name)) return null;
|
|
3405
|
+
const bbox = node.absoluteBoundingBox;
|
|
3406
|
+
if (match.minWidth !== void 0 && (!bbox || bbox.width < match.minWidth)) return null;
|
|
3407
|
+
if (match.maxWidth !== void 0 && (!bbox || bbox.width > match.maxWidth)) return null;
|
|
3408
|
+
if (match.minHeight !== void 0 && (!bbox || bbox.height < match.minHeight)) return null;
|
|
3409
|
+
if (match.maxHeight !== void 0 && (!bbox || bbox.height > match.maxHeight)) return null;
|
|
3410
|
+
if (match.hasAutoLayout === true && !node.layoutMode) return null;
|
|
3411
|
+
if (match.hasAutoLayout === false && node.layoutMode) return null;
|
|
3412
|
+
if (match.hasChildren === true && (!node.children || node.children.length === 0)) return null;
|
|
3413
|
+
if (match.hasChildren === false && node.children && node.children.length > 0) return null;
|
|
3414
|
+
if (match.minChildren !== void 0 && (!node.children || node.children.length < match.minChildren)) return null;
|
|
3415
|
+
if (match.maxChildren !== void 0 && node.children && node.children.length > match.maxChildren) return null;
|
|
3416
|
+
if (match.isComponent === true && node.type !== "COMPONENT" && node.type !== "COMPONENT_SET") return null;
|
|
3417
|
+
if (match.isComponent === false && (node.type === "COMPONENT" || node.type === "COMPONENT_SET")) return null;
|
|
3418
|
+
if (match.isInstance === true && node.type !== "INSTANCE") return null;
|
|
3419
|
+
if (match.isInstance === false && node.type === "INSTANCE") return null;
|
|
3420
|
+
if (match.hasComponentId === true && !node.componentId) return null;
|
|
3421
|
+
if (match.hasComponentId === false && node.componentId) return null;
|
|
3422
|
+
if (match.isVisible === true && !node.visible) return null;
|
|
3423
|
+
if (match.isVisible === false && node.visible) return null;
|
|
3424
|
+
if (match.hasFills === true && (!node.fills || node.fills.length === 0)) return null;
|
|
3425
|
+
if (match.hasFills === false && node.fills && node.fills.length > 0) return null;
|
|
3426
|
+
if (match.hasStrokes === true && (!node.strokes || node.strokes.length === 0)) return null;
|
|
3427
|
+
if (match.hasStrokes === false && node.strokes && node.strokes.length > 0) return null;
|
|
3428
|
+
if (match.hasEffects === true && (!node.effects || node.effects.length === 0)) return null;
|
|
3429
|
+
if (match.hasEffects === false && node.effects && node.effects.length > 0) return null;
|
|
3430
|
+
if (match.minDepth !== void 0 && context.depth < match.minDepth) return null;
|
|
3431
|
+
if (match.maxDepth !== void 0 && context.depth > match.maxDepth) return null;
|
|
3432
|
+
const msg = (cr.message ?? `"${node.name}" matched custom rule "${cr.id}"`).replace(/\{name\}/g, node.name).replace(/\{type\}/g, node.type);
|
|
3433
|
+
return {
|
|
3434
|
+
ruleId: cr.id,
|
|
3435
|
+
nodeId: node.id,
|
|
3436
|
+
nodePath: context.path.join(" > "),
|
|
3437
|
+
message: msg
|
|
3438
|
+
};
|
|
3219
3439
|
};
|
|
3440
|
+
}
|
|
3441
|
+
|
|
3442
|
+
// src/core/monitoring/events.ts
|
|
3443
|
+
var EVENT_PREFIX = "cic_";
|
|
3444
|
+
var EVENTS = {
|
|
3445
|
+
// Analysis
|
|
3446
|
+
ANALYSIS_STARTED: `${EVENT_PREFIX}analysis_started`,
|
|
3447
|
+
ANALYSIS_COMPLETED: `${EVENT_PREFIX}analysis_completed`,
|
|
3448
|
+
ANALYSIS_FAILED: `${EVENT_PREFIX}analysis_failed`,
|
|
3449
|
+
// Report
|
|
3450
|
+
REPORT_GENERATED: `${EVENT_PREFIX}report_generated`,
|
|
3451
|
+
COMMENT_POSTED: `${EVENT_PREFIX}comment_posted`,
|
|
3452
|
+
COMMENT_FAILED: `${EVENT_PREFIX}comment_failed`,
|
|
3453
|
+
// MCP
|
|
3454
|
+
MCP_TOOL_CALLED: `${EVENT_PREFIX}mcp_tool_called`,
|
|
3455
|
+
// CLI
|
|
3456
|
+
CLI_COMMAND: `${EVENT_PREFIX}cli_command`,
|
|
3457
|
+
CLI_INIT: `${EVENT_PREFIX}cli_init`
|
|
3220
3458
|
};
|
|
3221
|
-
defineRule({
|
|
3222
|
-
definition: emptyFrameDef,
|
|
3223
|
-
check: emptyFrameCheck
|
|
3224
|
-
});
|
|
3225
3459
|
|
|
3226
|
-
// src/core/
|
|
3227
|
-
|
|
3228
|
-
|
|
3460
|
+
// src/core/monitoring/capture.ts
|
|
3461
|
+
var monitoringEnabled = false;
|
|
3462
|
+
var posthogApiKey;
|
|
3463
|
+
var sentryDsn;
|
|
3464
|
+
var distinctId = "anonymous";
|
|
3465
|
+
var environment = "unknown";
|
|
3466
|
+
var version2 = "unknown";
|
|
3467
|
+
var commonProps = {};
|
|
3468
|
+
function uuid4() {
|
|
3469
|
+
return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (c) => {
|
|
3470
|
+
const r = Math.random() * 16 | 0;
|
|
3471
|
+
const v = c === "x" ? r : r & 3 | 8;
|
|
3472
|
+
return v.toString(16);
|
|
3473
|
+
});
|
|
3229
3474
|
}
|
|
3230
|
-
function
|
|
3231
|
-
|
|
3475
|
+
function parseSentryDsn(dsn) {
|
|
3476
|
+
try {
|
|
3477
|
+
const url = new URL(dsn);
|
|
3478
|
+
const key = url.username;
|
|
3479
|
+
const projectId = url.pathname.slice(1);
|
|
3480
|
+
const host = url.protocol + "//" + url.host;
|
|
3481
|
+
if (!key || !projectId) return null;
|
|
3482
|
+
return { key, host, projectId };
|
|
3483
|
+
} catch {
|
|
3484
|
+
return null;
|
|
3485
|
+
}
|
|
3232
3486
|
}
|
|
3233
|
-
function
|
|
3234
|
-
|
|
3487
|
+
function initCapture(config2) {
|
|
3488
|
+
if (config2.enabled === false) return;
|
|
3489
|
+
if (!config2.posthogApiKey && !config2.sentryDsn) return;
|
|
3490
|
+
monitoringEnabled = true;
|
|
3491
|
+
posthogApiKey = config2.posthogApiKey;
|
|
3492
|
+
sentryDsn = config2.sentryDsn;
|
|
3493
|
+
distinctId = config2.distinctId ?? "anonymous";
|
|
3494
|
+
environment = config2.environment ?? "unknown";
|
|
3495
|
+
version2 = config2.version ?? "unknown";
|
|
3496
|
+
commonProps = {
|
|
3497
|
+
_sdk: "canicode",
|
|
3498
|
+
_sdk_version: version2,
|
|
3499
|
+
_env: environment
|
|
3500
|
+
};
|
|
3235
3501
|
}
|
|
3236
|
-
function
|
|
3237
|
-
if (
|
|
3238
|
-
|
|
3239
|
-
|
|
3240
|
-
|
|
3502
|
+
function captureEvent(event, properties) {
|
|
3503
|
+
if (!monitoringEnabled || !posthogApiKey) return;
|
|
3504
|
+
try {
|
|
3505
|
+
fetch("https://us.i.posthog.com/i/v0/e/", {
|
|
3506
|
+
method: "POST",
|
|
3507
|
+
headers: { "Content-Type": "application/json" },
|
|
3508
|
+
body: JSON.stringify({
|
|
3509
|
+
api_key: posthogApiKey,
|
|
3510
|
+
event,
|
|
3511
|
+
distinct_id: distinctId,
|
|
3512
|
+
properties: { ...commonProps, ...properties },
|
|
3513
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString()
|
|
3514
|
+
})
|
|
3515
|
+
}).catch(() => {
|
|
3516
|
+
});
|
|
3517
|
+
} catch {
|
|
3518
|
+
}
|
|
3519
|
+
}
|
|
3520
|
+
function captureError(error, context) {
|
|
3521
|
+
if (!monitoringEnabled) return;
|
|
3522
|
+
if (sentryDsn) {
|
|
3523
|
+
const parsed = parseSentryDsn(sentryDsn);
|
|
3524
|
+
if (parsed) {
|
|
3525
|
+
try {
|
|
3526
|
+
const eventId = uuid4();
|
|
3527
|
+
const envelope = [
|
|
3528
|
+
JSON.stringify({ event_id: eventId, sent_at: (/* @__PURE__ */ new Date()).toISOString(), dsn: sentryDsn }),
|
|
3529
|
+
JSON.stringify({ type: "event", content_type: "application/json" }),
|
|
3530
|
+
JSON.stringify({
|
|
3531
|
+
event_id: eventId,
|
|
3532
|
+
exception: { values: [{ type: error.name, value: error.message }] },
|
|
3533
|
+
platform: "node",
|
|
3534
|
+
environment,
|
|
3535
|
+
release: `canicode@${version2}`,
|
|
3536
|
+
timestamp: Date.now() / 1e3,
|
|
3537
|
+
extra: context
|
|
3538
|
+
})
|
|
3539
|
+
].join("\n");
|
|
3540
|
+
fetch(`${parsed.host}/api/${parsed.projectId}/envelope/`, {
|
|
3541
|
+
method: "POST",
|
|
3542
|
+
headers: {
|
|
3543
|
+
"Content-Type": "application/x-sentry-envelope",
|
|
3544
|
+
"X-Sentry-Auth": `Sentry sentry_version=7, sentry_key=${parsed.key}`
|
|
3545
|
+
},
|
|
3546
|
+
body: envelope
|
|
3547
|
+
}).catch(() => {
|
|
3548
|
+
});
|
|
3549
|
+
} catch {
|
|
3550
|
+
}
|
|
3241
3551
|
}
|
|
3242
3552
|
}
|
|
3243
|
-
|
|
3553
|
+
captureEvent("cic_error", { error: error.message, ...context });
|
|
3244
3554
|
}
|
|
3245
|
-
|
|
3246
|
-
|
|
3247
|
-
|
|
3248
|
-
|
|
3249
|
-
|
|
3250
|
-
|
|
3251
|
-
|
|
3252
|
-
};
|
|
3253
|
-
|
|
3254
|
-
|
|
3255
|
-
|
|
3256
|
-
|
|
3257
|
-
|
|
3258
|
-
|
|
3259
|
-
|
|
3260
|
-
|
|
3261
|
-
|
|
3262
|
-
|
|
3555
|
+
function shutdownCapture() {
|
|
3556
|
+
monitoringEnabled = false;
|
|
3557
|
+
posthogApiKey = void 0;
|
|
3558
|
+
sentryDsn = void 0;
|
|
3559
|
+
distinctId = "anonymous";
|
|
3560
|
+
environment = "unknown";
|
|
3561
|
+
version2 = "unknown";
|
|
3562
|
+
commonProps = {};
|
|
3563
|
+
}
|
|
3564
|
+
|
|
3565
|
+
// src/core/monitoring/index.ts
|
|
3566
|
+
function initMonitoring(config2) {
|
|
3567
|
+
initCapture(config2);
|
|
3568
|
+
}
|
|
3569
|
+
function trackEvent(event, properties) {
|
|
3570
|
+
try {
|
|
3571
|
+
captureEvent(event, properties);
|
|
3572
|
+
} catch {
|
|
3263
3573
|
}
|
|
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
|
-
}
|
|
3574
|
+
}
|
|
3575
|
+
function trackError(error, context) {
|
|
3576
|
+
try {
|
|
3577
|
+
captureError(error, context);
|
|
3578
|
+
} catch {
|
|
3292
3579
|
}
|
|
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
|
-
}
|
|
3580
|
+
}
|
|
3581
|
+
function shutdownMonitoring() {
|
|
3582
|
+
try {
|
|
3583
|
+
shutdownCapture();
|
|
3584
|
+
} catch {
|
|
3319
3585
|
}
|
|
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
|
-
});
|
|
3586
|
+
}
|
|
3587
|
+
|
|
3588
|
+
// src/core/monitoring/keys.ts
|
|
3589
|
+
var POSTHOG_API_KEY = "phc_rBFeG140KqJLpUnlpYDEFgdMM6JozZeqQsf9twXf5Dq" ;
|
|
3590
|
+
var SENTRY_DSN = "https://80a836a8300b25f17ef5bbf23afb5b3a@o4511080656207872.ingest.us.sentry.io/4511080661319680" ;
|
|
3364
3591
|
|
|
3365
3592
|
// src/mcp/server.ts
|
|
3366
3593
|
config();
|
|
@@ -3452,10 +3679,10 @@ IMPORTANT \u2014 Before calling this tool, check which data source is available:
|
|
|
3452
3679
|
const ts = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-${String(now.getDate()).padStart(2, "0")}-${String(now.getHours()).padStart(2, "0")}-${String(now.getMinutes()).padStart(2, "0")}`;
|
|
3453
3680
|
ensureReportsDir();
|
|
3454
3681
|
const reportPath = `${getReportsDir()}/report-${ts}-${file.fileKey}.html`;
|
|
3455
|
-
await new Promise((
|
|
3682
|
+
await new Promise((resolve6, reject) => {
|
|
3456
3683
|
writeFile(reportPath, html, "utf-8", (err) => {
|
|
3457
3684
|
if (err) reject(err);
|
|
3458
|
-
else
|
|
3685
|
+
else resolve6();
|
|
3459
3686
|
});
|
|
3460
3687
|
});
|
|
3461
3688
|
const openCmd = process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open";
|
|
@@ -3478,6 +3705,7 @@ IMPORTANT \u2014 Before calling this tool, check which data source is available:
|
|
|
3478
3705
|
type: "text",
|
|
3479
3706
|
text: JSON.stringify(
|
|
3480
3707
|
{
|
|
3708
|
+
version: pkg.version,
|
|
3481
3709
|
fileName: file.name,
|
|
3482
3710
|
nodeCount: result.nodeCount,
|
|
3483
3711
|
maxDepth: result.maxDepth,
|
|
@@ -3570,16 +3798,16 @@ Use this when the user asks about customization, configuration, rule settings, o
|
|
|
3570
3798
|
},
|
|
3571
3799
|
async ({ topic }) => {
|
|
3572
3800
|
const { readFile: readFile4 } = await import('fs/promises');
|
|
3573
|
-
const { resolve:
|
|
3801
|
+
const { resolve: resolve6, dirname: dirname2 } = await import('path');
|
|
3574
3802
|
const { fileURLToPath } = await import('url');
|
|
3575
3803
|
try {
|
|
3576
|
-
const __dirname =
|
|
3577
|
-
const docPath =
|
|
3804
|
+
const __dirname = dirname2(fileURLToPath(import.meta.url));
|
|
3805
|
+
const docPath = resolve6(__dirname, "../../docs/CUSTOMIZATION.md");
|
|
3578
3806
|
let content;
|
|
3579
3807
|
try {
|
|
3580
3808
|
content = await readFile4(docPath, "utf-8");
|
|
3581
3809
|
} catch {
|
|
3582
|
-
const altPath =
|
|
3810
|
+
const altPath = resolve6(__dirname, "../docs/CUSTOMIZATION.md");
|
|
3583
3811
|
content = await readFile4(altPath, "utf-8");
|
|
3584
3812
|
}
|
|
3585
3813
|
if (topic && topic !== "all") {
|
|
@@ -3598,7 +3826,9 @@ Use this when the user asks about customization, configuration, rule settings, o
|
|
|
3598
3826
|
}
|
|
3599
3827
|
}
|
|
3600
3828
|
return {
|
|
3601
|
-
content: [{ type: "text", text:
|
|
3829
|
+
content: [{ type: "text", text: `canicode v${pkg.version}
|
|
3830
|
+
|
|
3831
|
+
${content}` }]
|
|
3602
3832
|
};
|
|
3603
3833
|
} catch {
|
|
3604
3834
|
return {
|