canicode 0.10.4 → 0.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +9 -2
- package/dist/cli/index.js +559 -141
- package/dist/cli/index.js.map +1 -1
- package/dist/index.d.ts +254 -19
- package/dist/index.js +256 -73
- package/dist/index.js.map +1 -1
- package/dist/mcp/server.js +242 -81
- package/dist/mcp/server.js.map +1 -1
- package/docs/CUSTOMIZATION.md +39 -3
- package/package.json +1 -1
- package/skills/canicode-gotchas/SKILL.md +17 -14
- package/skills/canicode-roundtrip/SKILL.md +12 -11
- package/skills/canicode-roundtrip/helpers.js +1 -1
- package/skills/cursor/canicode/SKILL.md +76 -0
- package/skills/cursor/canicode-gotchas/SKILL.md +199 -0
- package/skills/cursor/canicode-roundtrip/SKILL.md +618 -0
- package/skills/cursor/canicode-roundtrip/helpers.js +523 -0
package/dist/cli/index.js
CHANGED
|
@@ -1777,6 +1777,41 @@ var RULE_ID_CATEGORY = {
|
|
|
1777
1777
|
"non-semantic-name": "semantic",
|
|
1778
1778
|
"inconsistent-naming-convention": "semantic"
|
|
1779
1779
|
};
|
|
1780
|
+
var RULE_PURPOSE = {
|
|
1781
|
+
// Pixel Critical
|
|
1782
|
+
"no-auto-layout": "violation",
|
|
1783
|
+
"absolute-position-in-auto-layout": "violation",
|
|
1784
|
+
"non-layout-container": "violation",
|
|
1785
|
+
// Responsive Critical
|
|
1786
|
+
"fixed-size-in-auto-layout": "violation",
|
|
1787
|
+
// #403: missing-size-constraint reframed as info-collection. The rule
|
|
1788
|
+
// fires on width chains whose intent is structurally undecidable
|
|
1789
|
+
// (FILL container with no chain-bound ancestor; FIXED component or
|
|
1790
|
+
// instance root). The gotcha question is the primary signal; the
|
|
1791
|
+
// -1 score is just enough to keep the rule visible in
|
|
1792
|
+
// diversity scoring without driving grade swings on its own.
|
|
1793
|
+
"missing-size-constraint": "info-collection",
|
|
1794
|
+
// Code Quality
|
|
1795
|
+
"missing-component": "violation",
|
|
1796
|
+
"detached-instance": "violation",
|
|
1797
|
+
"variant-structure-mismatch": "violation",
|
|
1798
|
+
"deep-nesting": "violation",
|
|
1799
|
+
// Token Management
|
|
1800
|
+
"raw-value": "violation",
|
|
1801
|
+
"irregular-spacing": "violation",
|
|
1802
|
+
// Interaction — gotcha-primary: Figma cannot encode "what happens on
|
|
1803
|
+
// click" or "which states exist" in a way downstream code generation can
|
|
1804
|
+
// consume. Rules fire to trigger the annotation, not to flag a violation.
|
|
1805
|
+
"missing-interaction-state": "info-collection",
|
|
1806
|
+
"missing-prototype": "info-collection",
|
|
1807
|
+
// Semantic
|
|
1808
|
+
"non-standard-naming": "violation",
|
|
1809
|
+
"non-semantic-name": "violation",
|
|
1810
|
+
"inconsistent-naming-convention": "violation"
|
|
1811
|
+
};
|
|
1812
|
+
function getRulePurpose(ruleId) {
|
|
1813
|
+
return RULE_PURPOSE[ruleId] ?? "violation";
|
|
1814
|
+
}
|
|
1780
1815
|
var RULE_CONFIGS = {
|
|
1781
1816
|
// ── Pixel Critical ──
|
|
1782
1817
|
"no-auto-layout": {
|
|
@@ -1804,8 +1839,12 @@ var RULE_CONFIGS = {
|
|
|
1804
1839
|
enabled: true
|
|
1805
1840
|
},
|
|
1806
1841
|
"missing-size-constraint": {
|
|
1807
|
-
|
|
1808
|
-
|
|
1842
|
+
// #403: severity downgraded `risk → missing-info` and score from
|
|
1843
|
+
// -8 → -1 to match the new info-collection purpose. Keeping the
|
|
1844
|
+
// rule enabled (not disabled) so its gotchas still surface in the
|
|
1845
|
+
// survey — see RULE_PURPOSE entry above for the full rationale.
|
|
1846
|
+
severity: "missing-info",
|
|
1847
|
+
score: -1,
|
|
1809
1848
|
enabled: true
|
|
1810
1849
|
},
|
|
1811
1850
|
// ── Code Quality ──
|
|
@@ -1852,17 +1891,22 @@ var RULE_CONFIGS = {
|
|
|
1852
1891
|
}
|
|
1853
1892
|
},
|
|
1854
1893
|
// ── Interaction ──
|
|
1894
|
+
// #406: both rules are `info-collection` — primary output is the gotcha
|
|
1895
|
+
// annotation, not the score. Severity is `missing-info` so they surface in
|
|
1896
|
+
// the gotcha survey (see `generateGotchaSurvey`) even though the penalty
|
|
1897
|
+
// is minimal. Score stays at -1 so re-enabling `missing-prototype` on
|
|
1898
|
+
// fixtures that lack `interactionDestinations` (#139) cannot swing grades.
|
|
1855
1899
|
"missing-interaction-state": {
|
|
1856
|
-
severity: "
|
|
1900
|
+
severity: "missing-info",
|
|
1857
1901
|
score: -1,
|
|
1858
1902
|
// uncalibrated: no metric to validate score (#210), kept at -1 to preserve category visibility
|
|
1859
1903
|
enabled: true
|
|
1860
1904
|
},
|
|
1861
1905
|
"missing-prototype": {
|
|
1862
1906
|
severity: "missing-info",
|
|
1863
|
-
score: -
|
|
1864
|
-
|
|
1865
|
-
|
|
1907
|
+
score: -1,
|
|
1908
|
+
// #406: info-collection — annotation is primary output; score kept minimal so #139 fixtures don't skew calibration
|
|
1909
|
+
enabled: true
|
|
1866
1910
|
},
|
|
1867
1911
|
// ── Semantic ──
|
|
1868
1912
|
"non-standard-naming": {
|
|
@@ -1930,7 +1974,11 @@ function getConfigsWithPreset(preset) {
|
|
|
1930
1974
|
}
|
|
1931
1975
|
var RULE_ANNOTATION_PROPERTIES = {
|
|
1932
1976
|
"missing-size-constraint": {
|
|
1933
|
-
|
|
1977
|
+
// #403: width-only — the redesigned rule does not evaluate the
|
|
1978
|
+
// height axis (deferred follow-up). Emitting a `height` annotation
|
|
1979
|
+
// here would mark properties the rule never inspected and confuse
|
|
1980
|
+
// downstream Dev Mode hints.
|
|
1981
|
+
default: [{ type: "width" }]
|
|
1934
1982
|
},
|
|
1935
1983
|
"irregular-spacing": {
|
|
1936
1984
|
bySubType: {
|
|
@@ -1982,6 +2030,14 @@ var RuleRegistry = class {
|
|
|
1982
2030
|
register(rule) {
|
|
1983
2031
|
this.rules.set(rule.definition.id, rule);
|
|
1984
2032
|
}
|
|
2033
|
+
/**
|
|
2034
|
+
* Remove a rule by ID. Primarily used by tests that register a
|
|
2035
|
+
* throwaway rule and need to restore the registry afterwards. Returns
|
|
2036
|
+
* `true` if the rule was present.
|
|
2037
|
+
*/
|
|
2038
|
+
unregister(id) {
|
|
2039
|
+
return this.rules.delete(id);
|
|
2040
|
+
}
|
|
1985
2041
|
/**
|
|
1986
2042
|
* Get a rule by ID
|
|
1987
2043
|
*/
|
|
@@ -2033,6 +2089,58 @@ function defineRule(rule) {
|
|
|
2033
2089
|
ruleRegistry.register(rule);
|
|
2034
2090
|
return rule;
|
|
2035
2091
|
}
|
|
2092
|
+
var CategorySchema = z.enum([
|
|
2093
|
+
"pixel-critical",
|
|
2094
|
+
"responsive-critical",
|
|
2095
|
+
"code-quality",
|
|
2096
|
+
"token-management",
|
|
2097
|
+
"semantic",
|
|
2098
|
+
"interaction"
|
|
2099
|
+
]);
|
|
2100
|
+
var CATEGORIES = CategorySchema.options;
|
|
2101
|
+
var CATEGORY_LABELS = {
|
|
2102
|
+
"pixel-critical": "Pixel Critical",
|
|
2103
|
+
"responsive-critical": "Responsive Critical",
|
|
2104
|
+
"code-quality": "Code Quality",
|
|
2105
|
+
"token-management": "Token Management",
|
|
2106
|
+
"semantic": "Semantic",
|
|
2107
|
+
"interaction": "Interaction"
|
|
2108
|
+
};
|
|
2109
|
+
var SeveritySchema = z.enum([
|
|
2110
|
+
"blocking",
|
|
2111
|
+
"risk",
|
|
2112
|
+
"missing-info",
|
|
2113
|
+
"suggestion"
|
|
2114
|
+
]);
|
|
2115
|
+
|
|
2116
|
+
// src/core/contracts/rule.ts
|
|
2117
|
+
z.object({
|
|
2118
|
+
id: z.string(),
|
|
2119
|
+
name: z.string(),
|
|
2120
|
+
category: CategorySchema,
|
|
2121
|
+
why: z.string(),
|
|
2122
|
+
impact: z.string(),
|
|
2123
|
+
fix: z.string()
|
|
2124
|
+
});
|
|
2125
|
+
z.object({
|
|
2126
|
+
severity: SeveritySchema,
|
|
2127
|
+
score: z.number().int().max(0),
|
|
2128
|
+
depthWeight: z.number().min(1).max(2).optional(),
|
|
2129
|
+
enabled: z.boolean().default(true),
|
|
2130
|
+
options: z.record(z.string(), z.unknown()).optional()
|
|
2131
|
+
});
|
|
2132
|
+
function getAnalysisState(context, key, init) {
|
|
2133
|
+
if (context.analysisState.has(key)) {
|
|
2134
|
+
return context.analysisState.get(key);
|
|
2135
|
+
}
|
|
2136
|
+
const value = init();
|
|
2137
|
+
context.analysisState.set(key, value);
|
|
2138
|
+
return value;
|
|
2139
|
+
}
|
|
2140
|
+
var DEPTH_WEIGHT_CATEGORIES = ["pixel-critical", "responsive-critical"];
|
|
2141
|
+
function supportsDepthWeight(category) {
|
|
2142
|
+
return DEPTH_WEIGHT_CATEGORIES.includes(category);
|
|
2143
|
+
}
|
|
2036
2144
|
|
|
2037
2145
|
// src/core/rules/node-semantics.ts
|
|
2038
2146
|
function isContainerNode(node) {
|
|
@@ -2041,9 +2149,6 @@ function isContainerNode(node) {
|
|
|
2041
2149
|
function hasAutoLayout(node) {
|
|
2042
2150
|
return node.layoutMode !== void 0 && node.layoutMode !== "NONE";
|
|
2043
2151
|
}
|
|
2044
|
-
function hasTextContent(node) {
|
|
2045
|
-
return node.type === "TEXT" || (node.children?.some((c) => c.type === "TEXT") ?? false);
|
|
2046
|
-
}
|
|
2047
2152
|
function hasOverlappingBounds(a, b) {
|
|
2048
2153
|
const boxA = a.absoluteBoundingBox;
|
|
2049
2154
|
const boxB = b.absoluteBoundingBox;
|
|
@@ -2184,12 +2289,6 @@ function isAbsolutePositionExempt(node) {
|
|
|
2184
2289
|
if (isExcludedName(node.name)) return true;
|
|
2185
2290
|
return false;
|
|
2186
2291
|
}
|
|
2187
|
-
function isSizeConstraintExempt(node, context) {
|
|
2188
|
-
if (node.maxWidth !== void 0) return true;
|
|
2189
|
-
if (context.parent?.maxWidth !== void 0) return true;
|
|
2190
|
-
if (context.depth <= 1) return true;
|
|
2191
|
-
return false;
|
|
2192
|
-
}
|
|
2193
2292
|
function isFixedSizeExempt(node) {
|
|
2194
2293
|
if (isVisualOnlyNode(node)) return true;
|
|
2195
2294
|
if (isExcludedName(node.name)) return true;
|
|
@@ -2256,21 +2355,21 @@ var fixedSizeMsg = {
|
|
|
2256
2355
|
})
|
|
2257
2356
|
};
|
|
2258
2357
|
var missingSizeConstraintMsg = {
|
|
2259
|
-
|
|
2260
|
-
message: `"${name}" uses FILL width (currently ${currentWidth})
|
|
2261
|
-
suggestion: `
|
|
2358
|
+
pageContainerUnbound: (name, currentWidth) => ({
|
|
2359
|
+
message: `Container "${name}" uses FILL width (currently ${currentWidth}) and no ancestor defines a width bound`,
|
|
2360
|
+
suggestion: `Decide whether this area should stretch with the screen, or set min/max-width here so the responsive behavior is explicit`
|
|
2262
2361
|
}),
|
|
2263
|
-
|
|
2264
|
-
message: `"${name}"
|
|
2265
|
-
suggestion: `
|
|
2362
|
+
pageInstanceFixed: (name, currentWidth) => ({
|
|
2363
|
+
message: `Instance "${name}" has fixed width (${currentWidth}) inside an Auto Layout parent`,
|
|
2364
|
+
suggestion: `Confirm whether this fixed width is intentional \u2014 if not, set the instance to FILL so it follows the parent's layout`
|
|
2266
2365
|
}),
|
|
2267
|
-
|
|
2268
|
-
message: `"${name}"
|
|
2269
|
-
suggestion: `
|
|
2366
|
+
componentFixedByDesign: (name, currentWidth) => ({
|
|
2367
|
+
message: `Component "${name}" has fixed width (${currentWidth}) at its root`,
|
|
2368
|
+
suggestion: `Confirm whether this component is intentionally non-responsive \u2014 otherwise switch root sizing to FILL or set min/max bounds`
|
|
2270
2369
|
}),
|
|
2271
|
-
|
|
2272
|
-
message: `"${name}"
|
|
2273
|
-
suggestion: `
|
|
2370
|
+
componentFixedByOverride: (name, currentWidth) => ({
|
|
2371
|
+
message: `Instance "${name}" overrides root width to fixed (${currentWidth}); the original component may be FILL`,
|
|
2372
|
+
suggestion: `Confirm whether the fixed-width override is intentional \u2014 if not, restore root sizing to inherit from the component definition`
|
|
2274
2373
|
})
|
|
2275
2374
|
};
|
|
2276
2375
|
var nonLayoutContainerMsg = {
|
|
@@ -2562,44 +2661,97 @@ var missingSizeConstraintDef = {
|
|
|
2562
2661
|
id: "missing-size-constraint",
|
|
2563
2662
|
name: "Missing Size Constraint",
|
|
2564
2663
|
category: "responsive-critical",
|
|
2565
|
-
why: "
|
|
2566
|
-
impact: "
|
|
2567
|
-
fix: "
|
|
2664
|
+
why: "Width sizing without explicit bounds (FIXED root or FILL chained to a bounded ancestor) is structurally indistinguishable from missing information \u2014 AI cannot tell whether the designer intended responsive stretching, a fixed cap, or simply forgot to set min/max",
|
|
2665
|
+
impact: "Generated code either guesses hard-coded widths or omits responsive constraints; either way the runtime layout drifts from the design intent at viewports the designer never explicitly considered",
|
|
2666
|
+
fix: "Answer the gotcha to declare intent (responsive vs. fixed-by-design vs. instance override), then encode the answer in Figma sizing \u2014 FILL/HUG with min/max for responsive bounds, FIXED only when intentionally non-responsive"
|
|
2568
2667
|
};
|
|
2569
|
-
var
|
|
2570
|
-
|
|
2571
|
-
|
|
2572
|
-
|
|
2573
|
-
|
|
2574
|
-
|
|
2575
|
-
|
|
2576
|
-
|
|
2577
|
-
|
|
2578
|
-
|
|
2579
|
-
|
|
2580
|
-
|
|
2581
|
-
|
|
2582
|
-
|
|
2583
|
-
|
|
2584
|
-
|
|
2585
|
-
|
|
2586
|
-
|
|
2587
|
-
|
|
2588
|
-
|
|
2589
|
-
|
|
2668
|
+
var CHAIN_BOUND_KEY = "missing-size-constraint:chain-bound";
|
|
2669
|
+
function getChainBoundCache(context) {
|
|
2670
|
+
return getAnalysisState(context, CHAIN_BOUND_KEY, () => /* @__PURE__ */ new Map());
|
|
2671
|
+
}
|
|
2672
|
+
function establishesOwnWidthBound(node) {
|
|
2673
|
+
if (node.layoutSizingHorizontal === "FIXED") return true;
|
|
2674
|
+
if (node.minWidth !== void 0 || node.maxWidth !== void 0) return true;
|
|
2675
|
+
return false;
|
|
2676
|
+
}
|
|
2677
|
+
function recordChainBound(context, node) {
|
|
2678
|
+
const cache = getChainBoundCache(context);
|
|
2679
|
+
const cached = cache.get(node.id);
|
|
2680
|
+
if (cached !== void 0) return cached;
|
|
2681
|
+
const own = establishesOwnWidthBound(node);
|
|
2682
|
+
const parent = context.parent;
|
|
2683
|
+
const inherited = parent ? cache.get(parent.id) ?? false : false;
|
|
2684
|
+
const result = own || inherited;
|
|
2685
|
+
cache.set(node.id, result);
|
|
2686
|
+
return result;
|
|
2687
|
+
}
|
|
2688
|
+
function parentChainBound(context) {
|
|
2689
|
+
if (!context.parent) return false;
|
|
2690
|
+
return getChainBoundCache(context).get(context.parent.id) ?? false;
|
|
2691
|
+
}
|
|
2692
|
+
var PAGE_CONTAINER_FRAME_TYPES = /* @__PURE__ */ new Set(["FRAME", "SECTION"]);
|
|
2693
|
+
function formatWidth(node) {
|
|
2694
|
+
return node.absoluteBoundingBox ? `${node.absoluteBoundingBox.width}px` : "unknown";
|
|
2695
|
+
}
|
|
2696
|
+
function buildViolation(subType, node, context, msg) {
|
|
2697
|
+
return {
|
|
2698
|
+
ruleId: missingSizeConstraintDef.id,
|
|
2699
|
+
subType,
|
|
2700
|
+
nodeId: node.id,
|
|
2701
|
+
nodePath: context.path.join(" > "),
|
|
2702
|
+
...msg
|
|
2703
|
+
};
|
|
2704
|
+
}
|
|
2705
|
+
function checkComponentScopeRoot(node, context) {
|
|
2706
|
+
if (context.depth !== 0) return null;
|
|
2707
|
+
if (node.layoutSizingHorizontal !== "FIXED") return null;
|
|
2708
|
+
const currentWidth = formatWidth(node);
|
|
2709
|
+
if (context.rootNodeType === "INSTANCE") {
|
|
2710
|
+
return buildViolation(
|
|
2711
|
+
"component-fixed-by-override",
|
|
2712
|
+
node,
|
|
2713
|
+
context,
|
|
2714
|
+
missingSizeConstraintMsg.componentFixedByOverride(node.name, currentWidth)
|
|
2715
|
+
);
|
|
2590
2716
|
}
|
|
2591
|
-
|
|
2592
|
-
|
|
2593
|
-
|
|
2594
|
-
|
|
2595
|
-
|
|
2596
|
-
|
|
2597
|
-
|
|
2598
|
-
|
|
2599
|
-
|
|
2600
|
-
|
|
2717
|
+
return buildViolation(
|
|
2718
|
+
"component-fixed-by-design",
|
|
2719
|
+
node,
|
|
2720
|
+
context,
|
|
2721
|
+
missingSizeConstraintMsg.componentFixedByDesign(node.name, currentWidth)
|
|
2722
|
+
);
|
|
2723
|
+
}
|
|
2724
|
+
function checkPageInstanceFixed(node, context) {
|
|
2725
|
+
if (node.type !== "INSTANCE") return null;
|
|
2726
|
+
if (node.layoutSizingHorizontal !== "FIXED") return null;
|
|
2727
|
+
if (!context.parent || !hasAutoLayout(context.parent)) return null;
|
|
2728
|
+
const currentWidth = formatWidth(node);
|
|
2729
|
+
return buildViolation(
|
|
2730
|
+
"page-instance-fixed",
|
|
2731
|
+
node,
|
|
2732
|
+
context,
|
|
2733
|
+
missingSizeConstraintMsg.pageInstanceFixed(node.name, currentWidth)
|
|
2734
|
+
);
|
|
2735
|
+
}
|
|
2736
|
+
function checkPageContainerUnbound(node, context) {
|
|
2737
|
+
if (!PAGE_CONTAINER_FRAME_TYPES.has(node.type)) return null;
|
|
2738
|
+
if (node.layoutSizingHorizontal !== "FILL") return null;
|
|
2739
|
+
if (parentChainBound(context)) return null;
|
|
2740
|
+
const currentWidth = formatWidth(node);
|
|
2741
|
+
return buildViolation(
|
|
2742
|
+
"page-container-unbound",
|
|
2743
|
+
node,
|
|
2744
|
+
context,
|
|
2745
|
+
missingSizeConstraintMsg.pageContainerUnbound(node.name, currentWidth)
|
|
2746
|
+
);
|
|
2747
|
+
}
|
|
2748
|
+
var missingSizeConstraintCheck = (node, context) => {
|
|
2749
|
+
recordChainBound(context, node);
|
|
2750
|
+
if (context.ancestorTypes.includes("INSTANCE")) return null;
|
|
2751
|
+
if (context.scope === "component") {
|
|
2752
|
+
return checkComponentScopeRoot(node, context);
|
|
2601
2753
|
}
|
|
2602
|
-
return
|
|
2754
|
+
return checkPageInstanceFixed(node, context) ?? checkPageContainerUnbound(node, context);
|
|
2603
2755
|
};
|
|
2604
2756
|
defineRule({
|
|
2605
2757
|
definition: missingSizeConstraintDef,
|
|
@@ -2803,58 +2955,6 @@ defineRule({
|
|
|
2803
2955
|
definition: irregularSpacingDef,
|
|
2804
2956
|
check: irregularSpacingCheck
|
|
2805
2957
|
});
|
|
2806
|
-
var CategorySchema = z.enum([
|
|
2807
|
-
"pixel-critical",
|
|
2808
|
-
"responsive-critical",
|
|
2809
|
-
"code-quality",
|
|
2810
|
-
"token-management",
|
|
2811
|
-
"semantic",
|
|
2812
|
-
"interaction"
|
|
2813
|
-
]);
|
|
2814
|
-
var CATEGORIES = CategorySchema.options;
|
|
2815
|
-
var CATEGORY_LABELS = {
|
|
2816
|
-
"pixel-critical": "Pixel Critical",
|
|
2817
|
-
"responsive-critical": "Responsive Critical",
|
|
2818
|
-
"code-quality": "Code Quality",
|
|
2819
|
-
"token-management": "Token Management",
|
|
2820
|
-
"semantic": "Semantic",
|
|
2821
|
-
"interaction": "Interaction"
|
|
2822
|
-
};
|
|
2823
|
-
var SeveritySchema = z.enum([
|
|
2824
|
-
"blocking",
|
|
2825
|
-
"risk",
|
|
2826
|
-
"missing-info",
|
|
2827
|
-
"suggestion"
|
|
2828
|
-
]);
|
|
2829
|
-
|
|
2830
|
-
// src/core/contracts/rule.ts
|
|
2831
|
-
z.object({
|
|
2832
|
-
id: z.string(),
|
|
2833
|
-
name: z.string(),
|
|
2834
|
-
category: CategorySchema,
|
|
2835
|
-
why: z.string(),
|
|
2836
|
-
impact: z.string(),
|
|
2837
|
-
fix: z.string()
|
|
2838
|
-
});
|
|
2839
|
-
z.object({
|
|
2840
|
-
severity: SeveritySchema,
|
|
2841
|
-
score: z.number().int().max(0),
|
|
2842
|
-
depthWeight: z.number().min(1).max(2).optional(),
|
|
2843
|
-
enabled: z.boolean().default(true),
|
|
2844
|
-
options: z.record(z.string(), z.unknown()).optional()
|
|
2845
|
-
});
|
|
2846
|
-
function getAnalysisState(context, key, init) {
|
|
2847
|
-
if (context.analysisState.has(key)) {
|
|
2848
|
-
return context.analysisState.get(key);
|
|
2849
|
-
}
|
|
2850
|
-
const value = init();
|
|
2851
|
-
context.analysisState.set(key, value);
|
|
2852
|
-
return value;
|
|
2853
|
-
}
|
|
2854
|
-
var DEPTH_WEIGHT_CATEGORIES = ["pixel-critical", "responsive-critical"];
|
|
2855
|
-
function supportsDepthWeight(category) {
|
|
2856
|
-
return DEPTH_WEIGHT_CATEGORIES.includes(category);
|
|
2857
|
-
}
|
|
2858
2958
|
|
|
2859
2959
|
// src/core/rules/component/index.ts
|
|
2860
2960
|
var STYLE_COMPARE_KEYS = ["fills", "strokes", "effects", "cornerRadius", "strokeWeight", "individualStrokeWeights"];
|
|
@@ -3287,12 +3387,22 @@ function hasStateInComponentMaster(node, context, statePattern) {
|
|
|
3287
3387
|
if (!master) return false;
|
|
3288
3388
|
return hasStateInVariantProps(master, statePattern);
|
|
3289
3389
|
}
|
|
3390
|
+
var VARIANT_POSITION_NAME_RE = /^[\w ]+=[^,]+(,\s*[\w ]+=[^,]+)*$/;
|
|
3391
|
+
function hasUsablePropDefs(propDefs) {
|
|
3392
|
+
return propDefs != null && typeof propDefs === "object";
|
|
3393
|
+
}
|
|
3290
3394
|
function canDetermineVariants(node, context) {
|
|
3291
|
-
if (node.
|
|
3292
|
-
if (node.
|
|
3395
|
+
if (hasUsablePropDefs(node.componentPropertyDefinitions)) return true;
|
|
3396
|
+
if (node.type === "COMPONENT") {
|
|
3397
|
+
return !VARIANT_POSITION_NAME_RE.test(node.name);
|
|
3398
|
+
}
|
|
3293
3399
|
if (node.componentId !== void 0) {
|
|
3294
3400
|
const defs = context.file.componentDefinitions;
|
|
3295
|
-
|
|
3401
|
+
const master = defs?.[node.componentId];
|
|
3402
|
+
if (master) {
|
|
3403
|
+
if (hasUsablePropDefs(master.componentPropertyDefinitions)) return true;
|
|
3404
|
+
return !VARIANT_POSITION_NAME_RE.test(master.name);
|
|
3405
|
+
}
|
|
3296
3406
|
}
|
|
3297
3407
|
return false;
|
|
3298
3408
|
}
|
|
@@ -3418,6 +3528,11 @@ var AcknowledgmentListSchema = z.array(AcknowledgmentSchema);
|
|
|
3418
3528
|
function normalizeNodeId(id) {
|
|
3419
3529
|
return id.replace(/-/g, ":");
|
|
3420
3530
|
}
|
|
3531
|
+
var AnalysisScopeSchema = z.enum(["page", "component"]);
|
|
3532
|
+
var COMPONENT_SCOPE_ROOT_TYPES = /* @__PURE__ */ new Set(["COMPONENT", "COMPONENT_SET", "INSTANCE"]);
|
|
3533
|
+
function detectAnalysisScope(rootNode) {
|
|
3534
|
+
return COMPONENT_SCOPE_ROOT_TYPES.has(rootNode.type) ? "component" : "page";
|
|
3535
|
+
}
|
|
3421
3536
|
|
|
3422
3537
|
// src/core/engine/rule-engine.ts
|
|
3423
3538
|
function calculateMaxDepth(node, currentDepth = 0) {
|
|
@@ -3469,6 +3584,7 @@ var RuleEngine = class {
|
|
|
3469
3584
|
excludeNamePattern;
|
|
3470
3585
|
excludeNodeTypes;
|
|
3471
3586
|
acknowledgments;
|
|
3587
|
+
scopeOverride;
|
|
3472
3588
|
constructor(options = {}) {
|
|
3473
3589
|
this.configs = options.configs ?? RULE_CONFIGS;
|
|
3474
3590
|
this.enabledRuleIds = options.enabledRules ? new Set(options.enabledRules) : null;
|
|
@@ -3481,6 +3597,7 @@ var RuleEngine = class {
|
|
|
3481
3597
|
(a) => `${normalizeNodeId(a.nodeId)}::${a.ruleId}`
|
|
3482
3598
|
)
|
|
3483
3599
|
);
|
|
3600
|
+
this.scopeOverride = options.scope;
|
|
3484
3601
|
}
|
|
3485
3602
|
/**
|
|
3486
3603
|
* Analyze a Figma file and return issues
|
|
@@ -3497,6 +3614,8 @@ var RuleEngine = class {
|
|
|
3497
3614
|
}
|
|
3498
3615
|
const maxDepth = calculateMaxDepth(rootNode);
|
|
3499
3616
|
const nodeCount = countNodes(rootNode);
|
|
3617
|
+
const scope = this.scopeOverride ?? detectAnalysisScope(rootNode);
|
|
3618
|
+
const rootNodeType = rootNode.type;
|
|
3500
3619
|
const issues = [];
|
|
3501
3620
|
const failedRules = [];
|
|
3502
3621
|
const enabledRules = this.getEnabledRules();
|
|
@@ -3512,6 +3631,8 @@ var RuleEngine = class {
|
|
|
3512
3631
|
[],
|
|
3513
3632
|
0,
|
|
3514
3633
|
analysisState,
|
|
3634
|
+
scope,
|
|
3635
|
+
rootNodeType,
|
|
3515
3636
|
void 0,
|
|
3516
3637
|
void 0
|
|
3517
3638
|
);
|
|
@@ -3529,7 +3650,8 @@ var RuleEngine = class {
|
|
|
3529
3650
|
failedRules,
|
|
3530
3651
|
maxDepth,
|
|
3531
3652
|
nodeCount,
|
|
3532
|
-
analyzedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
3653
|
+
analyzedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
3654
|
+
scope
|
|
3533
3655
|
};
|
|
3534
3656
|
}
|
|
3535
3657
|
/**
|
|
@@ -3547,7 +3669,7 @@ var RuleEngine = class {
|
|
|
3547
3669
|
/**
|
|
3548
3670
|
* Recursively traverse the tree and run rules
|
|
3549
3671
|
*/
|
|
3550
|
-
traverseAndCheck(node, file, rules, maxDepth, issues, failedRules, depth, path, ancestorTypes, componentDepth, analysisState, parent, siblings) {
|
|
3672
|
+
traverseAndCheck(node, file, rules, maxDepth, issues, failedRules, depth, path, ancestorTypes, componentDepth, analysisState, scope, rootNodeType, parent, siblings) {
|
|
3551
3673
|
const nodePath = [...path, node.name];
|
|
3552
3674
|
const isComponentBoundary = node.type === "COMPONENT" || node.type === "COMPONENT_SET" || node.type === "INSTANCE";
|
|
3553
3675
|
const currentComponentDepth = isComponentBoundary ? 0 : componentDepth;
|
|
@@ -3566,7 +3688,9 @@ var RuleEngine = class {
|
|
|
3566
3688
|
path: nodePath,
|
|
3567
3689
|
ancestorTypes,
|
|
3568
3690
|
siblings,
|
|
3569
|
-
analysisState
|
|
3691
|
+
analysisState,
|
|
3692
|
+
scope,
|
|
3693
|
+
rootNodeType
|
|
3570
3694
|
};
|
|
3571
3695
|
for (const rule of rules) {
|
|
3572
3696
|
const ruleId = rule.definition.id;
|
|
@@ -3613,6 +3737,8 @@ var RuleEngine = class {
|
|
|
3613
3737
|
childAncestorTypes,
|
|
3614
3738
|
currentComponentDepth + 1,
|
|
3615
3739
|
analysisState,
|
|
3740
|
+
scope,
|
|
3741
|
+
rootNodeType,
|
|
3616
3742
|
node,
|
|
3617
3743
|
node.children
|
|
3618
3744
|
);
|
|
@@ -4046,7 +4172,7 @@ function computeApplyContext(violation, instanceContext) {
|
|
|
4046
4172
|
}
|
|
4047
4173
|
|
|
4048
4174
|
// package.json
|
|
4049
|
-
var version2 = "0.
|
|
4175
|
+
var version2 = "0.11.0";
|
|
4050
4176
|
|
|
4051
4177
|
// src/core/engine/scoring.ts
|
|
4052
4178
|
function computeTotalScorePerCategory(configs) {
|
|
@@ -4240,6 +4366,10 @@ function buildResultJson(fileName, result, scores, options) {
|
|
|
4240
4366
|
const suggestedName = issue.violation.suggestedName;
|
|
4241
4367
|
return {
|
|
4242
4368
|
ruleId: issue.violation.ruleId,
|
|
4369
|
+
detection: "rule-based",
|
|
4370
|
+
outputChannel: "score",
|
|
4371
|
+
persistenceIntent: "transient",
|
|
4372
|
+
purpose: getRulePurpose(issue.violation.ruleId),
|
|
4243
4373
|
...issue.violation.subType && { subType: issue.violation.subType },
|
|
4244
4374
|
severity: issue.config.severity,
|
|
4245
4375
|
nodeId: issue.violation.nodeId,
|
|
@@ -4262,6 +4392,7 @@ function buildResultJson(fileName, result, scores, options) {
|
|
|
4262
4392
|
fileName,
|
|
4263
4393
|
nodeCount: result.nodeCount,
|
|
4264
4394
|
maxDepth: result.maxDepth,
|
|
4395
|
+
scope: result.scope,
|
|
4265
4396
|
issueCount: result.issues.length,
|
|
4266
4397
|
acknowledgedCount: scores.summary.acknowledgedCount,
|
|
4267
4398
|
isReadyForCodeGen: isReadyForCodeGen(scores.overall.grade),
|
|
@@ -5525,10 +5656,11 @@ var AnalyzeOptionsSchema = z.object({
|
|
|
5525
5656
|
config: z.string().optional(),
|
|
5526
5657
|
noOpen: z.boolean().optional(),
|
|
5527
5658
|
json: z.boolean().optional(),
|
|
5528
|
-
acknowledgments: z.string().optional()
|
|
5659
|
+
acknowledgments: z.string().optional(),
|
|
5660
|
+
scope: z.enum(["page", "component"]).optional()
|
|
5529
5661
|
});
|
|
5530
5662
|
function registerAnalyze(cli2) {
|
|
5531
|
-
cli2.command("analyze <input>", "Analyze a Figma file or JSON fixture").option("--preset <preset>", "Analysis preset (relaxed | dev-friendly | ai-ready | strict)").option("--output <path>", "HTML report output path").option("--token <token>", "Figma API token (or use FIGMA_TOKEN env var)").option("--api", "Load via Figma REST API (requires FIGMA_TOKEN)").option("--screenshot", "Include screenshot comparison in report (requires ANTHROPIC_API_KEY)").option("--config <path>", "Path to config JSON file (override rule scores/settings)").option("--no-open", "Don't open report in browser after analysis").option("--json", "Output JSON results to stdout (same format as MCP)").option("--acknowledgments <path>", "(#371) Path to a JSON file containing [{ nodeId, ruleId }] pairs harvested from canicode-authored Figma annotations. Matching issues are flagged acknowledged and contribute half weight to density.").example(" canicode analyze https://www.figma.com/design/ABC123/MyDesign").example(" canicode analyze https://www.figma.com/design/ABC123/MyDesign --api --token YOUR_TOKEN").example(" canicode analyze ./fixtures/my-design --output report.html").example(" canicode analyze ./fixtures/my-design --config ./my-config.json").action(async (input, rawOptions) => {
|
|
5663
|
+
cli2.command("analyze <input>", "Analyze a Figma file or JSON fixture").option("--preset <preset>", "Analysis preset (relaxed | dev-friendly | ai-ready | strict)").option("--output <path>", "HTML report output path").option("--token <token>", "Figma API token (or use FIGMA_TOKEN env var)").option("--api", "Load via Figma REST API (requires FIGMA_TOKEN)").option("--screenshot", "Include screenshot comparison in report (requires ANTHROPIC_API_KEY)").option("--config <path>", "Path to config JSON file (override rule scores/settings)").option("--no-open", "Don't open report in browser after analysis").option("--json", "Output JSON results to stdout (same format as MCP)").option("--acknowledgments <path>", "(#371) Path to a JSON file containing [{ nodeId, ruleId }] pairs harvested from canicode-authored Figma annotations. Matching issues are flagged acknowledged and contribute half weight to density.").option("--scope <scope>", "(#404) Override analysis scope: `page` (screen/section \u2014 container bounds are required) or `component` (standalone reusable unit \u2014 root FILL is the design contract). Defaults to auto-detection from the root node type.").example(" canicode analyze https://www.figma.com/design/ABC123/MyDesign").example(" canicode analyze https://www.figma.com/design/ABC123/MyDesign --api --token YOUR_TOKEN").example(" canicode analyze ./fixtures/my-design --output report.html").example(" canicode analyze ./fixtures/my-design --config ./my-config.json").action(async (input, rawOptions) => {
|
|
5532
5664
|
const parseResult = AnalyzeOptionsSchema.safeParse(rawOptions);
|
|
5533
5665
|
if (!parseResult.success) {
|
|
5534
5666
|
const msg = parseResult.error.issues.map((i) => `--${i.path.join(".")}: ${i.message}`).join("\n");
|
|
@@ -5615,7 +5747,8 @@ Analyzing: ${file.name}`);
|
|
|
5615
5747
|
...effectiveNodeId && { targetNodeId: effectiveNodeId },
|
|
5616
5748
|
...excludeNodeNames && { excludeNodeNames },
|
|
5617
5749
|
...excludeNodeTypes && { excludeNodeTypes },
|
|
5618
|
-
...acknowledgments && { acknowledgments }
|
|
5750
|
+
...acknowledgments && { acknowledgments },
|
|
5751
|
+
...options.scope && { scope: options.scope }
|
|
5619
5752
|
};
|
|
5620
5753
|
const result = analyzeFile(file, analyzeOptions);
|
|
5621
5754
|
log(`Nodes: ${result.nodeCount} (max depth: ${result.maxDepth})`);
|
|
@@ -5683,11 +5816,14 @@ Report saved: ${outputPath}`);
|
|
|
5683
5816
|
}
|
|
5684
5817
|
z.object({
|
|
5685
5818
|
ruleId: z.string(),
|
|
5819
|
+
detection: z.literal("rule-based"),
|
|
5820
|
+
outputChannel: z.literal("annotation"),
|
|
5821
|
+
persistenceIntent: z.literal("durable"),
|
|
5686
5822
|
question: z.string(),
|
|
5687
5823
|
hint: z.string(),
|
|
5688
5824
|
example: z.string()
|
|
5689
5825
|
});
|
|
5690
|
-
var
|
|
5826
|
+
var GOTCHA_QUESTION_CONTENT = {
|
|
5691
5827
|
// ── Pixel Critical (blocking) ──
|
|
5692
5828
|
"no-auto-layout": {
|
|
5693
5829
|
ruleId: "no-auto-layout",
|
|
@@ -5791,6 +5927,17 @@ var GOTCHA_QUESTIONS = {
|
|
|
5791
5927
|
example: "Use PascalCase for all component layers (e.g., CardTitle, CardBody)"
|
|
5792
5928
|
}
|
|
5793
5929
|
};
|
|
5930
|
+
var GOTCHA_QUESTIONS = Object.fromEntries(
|
|
5931
|
+
Object.entries(GOTCHA_QUESTION_CONTENT).map(([ruleId, content]) => [
|
|
5932
|
+
ruleId,
|
|
5933
|
+
{
|
|
5934
|
+
...content,
|
|
5935
|
+
detection: "rule-based",
|
|
5936
|
+
outputChannel: "annotation",
|
|
5937
|
+
persistenceIntent: "durable"
|
|
5938
|
+
}
|
|
5939
|
+
])
|
|
5940
|
+
);
|
|
5794
5941
|
|
|
5795
5942
|
// src/core/gotcha/group-and-batch-questions.ts
|
|
5796
5943
|
var BATCHABLE_RULE_IDS = [
|
|
@@ -5859,9 +6006,14 @@ function pushIntoBatch(group, question) {
|
|
|
5859
6006
|
var NODE_PATH_SEPARATOR = " > ";
|
|
5860
6007
|
function generateGotchaSurvey(result, scores, options = {}) {
|
|
5861
6008
|
const grade = scores.overall.grade;
|
|
5862
|
-
const relevantIssues = result.issues.filter(
|
|
5863
|
-
|
|
5864
|
-
|
|
6009
|
+
const relevantIssues = result.issues.filter((issue) => {
|
|
6010
|
+
const severity = issue.config.severity;
|
|
6011
|
+
if (severity === "blocking" || severity === "risk") return true;
|
|
6012
|
+
if (severity === "missing-info") {
|
|
6013
|
+
return getRulePurpose(issue.violation.ruleId) === "info-collection";
|
|
6014
|
+
}
|
|
6015
|
+
return false;
|
|
6016
|
+
});
|
|
5865
6017
|
const deduped = deduplicateSiblingIssues(relevantIssues);
|
|
5866
6018
|
const sorted = stableSortBySeverity(deduped);
|
|
5867
6019
|
const mapped = sorted.map((issue) => mapToQuestion(issue, result.file)).filter((q) => q !== null);
|
|
@@ -5905,14 +6057,17 @@ function getNodeName(nodePath) {
|
|
|
5905
6057
|
function stableSortBySeverity(issues) {
|
|
5906
6058
|
const blocking = [];
|
|
5907
6059
|
const risk = [];
|
|
6060
|
+
const missingInfo = [];
|
|
5908
6061
|
for (const issue of issues) {
|
|
5909
6062
|
if (issue.config.severity === "blocking") {
|
|
5910
6063
|
blocking.push(issue);
|
|
6064
|
+
} else if (issue.config.severity === "missing-info") {
|
|
6065
|
+
missingInfo.push(issue);
|
|
5911
6066
|
} else {
|
|
5912
6067
|
risk.push(issue);
|
|
5913
6068
|
}
|
|
5914
6069
|
}
|
|
5915
|
-
return [...blocking, ...risk];
|
|
6070
|
+
return [...blocking, ...risk, ...missingInfo];
|
|
5916
6071
|
}
|
|
5917
6072
|
function mapToQuestion(issue, file) {
|
|
5918
6073
|
const ruleId = issue.violation.ruleId;
|
|
@@ -5929,6 +6084,10 @@ function mapToQuestion(issue, file) {
|
|
|
5929
6084
|
nodeId: issue.violation.nodeId,
|
|
5930
6085
|
nodeName,
|
|
5931
6086
|
ruleId,
|
|
6087
|
+
detection: template.detection,
|
|
6088
|
+
outputChannel: template.outputChannel,
|
|
6089
|
+
persistenceIntent: template.persistenceIntent,
|
|
6090
|
+
purpose: getRulePurpose(issue.violation.ruleId),
|
|
5932
6091
|
severity: issue.config.severity,
|
|
5933
6092
|
question: template.question.replace("{nodeName}", nodeName),
|
|
5934
6093
|
hint: template.hint,
|
|
@@ -6016,7 +6175,8 @@ var GotchaSurveyOptionsSchema = z.object({
|
|
|
6016
6175
|
token: z.string().optional(),
|
|
6017
6176
|
config: z.string().optional(),
|
|
6018
6177
|
targetNodeId: z.string().optional(),
|
|
6019
|
-
json: z.boolean().optional()
|
|
6178
|
+
json: z.boolean().optional(),
|
|
6179
|
+
scope: z.enum(["page", "component"]).optional()
|
|
6020
6180
|
});
|
|
6021
6181
|
async function runGotchaSurvey(input, options) {
|
|
6022
6182
|
const { file, nodeId } = await loadFile(input, options.token);
|
|
@@ -6028,7 +6188,8 @@ async function runGotchaSurvey(input, options) {
|
|
|
6028
6188
|
}
|
|
6029
6189
|
const result = analyzeFile(file, {
|
|
6030
6190
|
configs,
|
|
6031
|
-
...effectiveNodeId ? { targetNodeId: effectiveNodeId } : {}
|
|
6191
|
+
...effectiveNodeId ? { targetNodeId: effectiveNodeId } : {},
|
|
6192
|
+
...options.scope ? { scope: options.scope } : {}
|
|
6032
6193
|
});
|
|
6033
6194
|
const scores = calculateScores(result, configs);
|
|
6034
6195
|
return generateGotchaSurvey(result, scores, { designKey: computeDesignKey(input) });
|
|
@@ -6046,7 +6207,7 @@ function formatHumanSummary(survey) {
|
|
|
6046
6207
|
return lines.join("\n");
|
|
6047
6208
|
}
|
|
6048
6209
|
function registerGotchaSurvey(cli2) {
|
|
6049
|
-
cli2.command("gotcha-survey <input>", "Generate a gotcha survey from a Figma design analysis").option("--preset <preset>", "Analysis preset (relaxed | dev-friendly | ai-ready | strict)").option("--token <token>", "Figma API token (or use FIGMA_TOKEN env var)").option("--config <path>", "Path to config JSON file (override rule scores/settings)").option("--target-node-id <id>", "Scope analysis to a specific node ID").option("--json", "Output GotchaSurvey JSON to stdout (same format as MCP)").example(" canicode gotcha-survey https://www.figma.com/design/ABC123/MyDesign --json").example(" canicode gotcha-survey ./fixtures/my-design --json").action(async (input, rawOptions) => {
|
|
6210
|
+
cli2.command("gotcha-survey <input>", "Generate a gotcha survey from a Figma design analysis").option("--preset <preset>", "Analysis preset (relaxed | dev-friendly | ai-ready | strict)").option("--token <token>", "Figma API token (or use FIGMA_TOKEN env var)").option("--config <path>", "Path to config JSON file (override rule scores/settings)").option("--target-node-id <id>", "Scope analysis to a specific node ID").option("--scope <scope>", "(#404) Override analysis scope: `page` or `component`. Defaults to auto-detection from the root node type.").option("--json", "Output GotchaSurvey JSON to stdout (same format as MCP)").example(" canicode gotcha-survey https://www.figma.com/design/ABC123/MyDesign --json").example(" canicode gotcha-survey ./fixtures/my-design --json").action(async (input, rawOptions) => {
|
|
6050
6211
|
const parseResult = GotchaSurveyOptionsSchema.safeParse(rawOptions);
|
|
6051
6212
|
if (!parseResult.success) {
|
|
6052
6213
|
const msg = parseResult.error.issues.map((i) => `--${i.path.join(".")}: ${i.message}`).join("\n");
|
|
@@ -6421,6 +6582,100 @@ var SKILL_NAMES = ["canicode", "canicode-gotchas", "canicode-roundtrip"];
|
|
|
6421
6582
|
function defaultSourceDir() {
|
|
6422
6583
|
return fileURLToPath(new URL("../../skills/", import.meta.url));
|
|
6423
6584
|
}
|
|
6585
|
+
function defaultCursorBundleRoot() {
|
|
6586
|
+
return fileURLToPath(new URL("../../skills/cursor", import.meta.url));
|
|
6587
|
+
}
|
|
6588
|
+
async function copySkillTree(skillName, srcSkillDir, destSkillDir, force) {
|
|
6589
|
+
if (!existsSync(srcSkillDir)) {
|
|
6590
|
+
throw new Error(`Bundled skill directory missing: ${srcSkillDir}`);
|
|
6591
|
+
}
|
|
6592
|
+
mkdirSync(destSkillDir, { recursive: true });
|
|
6593
|
+
const ops = [];
|
|
6594
|
+
const files = listFilesRecursive(srcSkillDir);
|
|
6595
|
+
for (const relPath of files) {
|
|
6596
|
+
const src = join(srcSkillDir, relPath);
|
|
6597
|
+
const dest = join(destSkillDir, relPath);
|
|
6598
|
+
mkdirSync(dirname(dest), { recursive: true });
|
|
6599
|
+
const label = join(skillName, relPath);
|
|
6600
|
+
let action;
|
|
6601
|
+
if (!existsSync(dest)) {
|
|
6602
|
+
action = "install";
|
|
6603
|
+
} else if (force) {
|
|
6604
|
+
action = "force-overwrite";
|
|
6605
|
+
} else {
|
|
6606
|
+
action = "needs-decision";
|
|
6607
|
+
}
|
|
6608
|
+
ops.push({ src, dest, label, action });
|
|
6609
|
+
}
|
|
6610
|
+
const candidates = ops.filter((op) => op.action === "needs-decision");
|
|
6611
|
+
const decisions = candidates.length > 0 ? await promptOverwriteBatch(candidates.map((op) => ({ label: op.label, dest: op.dest }))) : /* @__PURE__ */ new Map();
|
|
6612
|
+
const installed = [];
|
|
6613
|
+
const overwritten = [];
|
|
6614
|
+
const skipped = [];
|
|
6615
|
+
for (const op of ops) {
|
|
6616
|
+
if (op.action === "install") {
|
|
6617
|
+
copyFileSync(op.src, op.dest);
|
|
6618
|
+
installed.push(op.label);
|
|
6619
|
+
} else if (op.action === "force-overwrite") {
|
|
6620
|
+
copyFileSync(op.src, op.dest);
|
|
6621
|
+
overwritten.push(op.label);
|
|
6622
|
+
} else {
|
|
6623
|
+
const decision = decisions.get(op.label) ?? "skip";
|
|
6624
|
+
if (decision === "overwrite") {
|
|
6625
|
+
copyFileSync(op.src, op.dest);
|
|
6626
|
+
overwritten.push(op.label);
|
|
6627
|
+
} else {
|
|
6628
|
+
skipped.push(op.label);
|
|
6629
|
+
}
|
|
6630
|
+
}
|
|
6631
|
+
}
|
|
6632
|
+
return { installed, overwritten, skipped };
|
|
6633
|
+
}
|
|
6634
|
+
async function copyMultipleSkillTrees(entries, force) {
|
|
6635
|
+
const ops = [];
|
|
6636
|
+
for (const { skillName, srcSkillDir, destSkillDir } of entries) {
|
|
6637
|
+
mkdirSync(destSkillDir, { recursive: true });
|
|
6638
|
+
const files = listFilesRecursive(srcSkillDir);
|
|
6639
|
+
for (const relPath of files) {
|
|
6640
|
+
const src = join(srcSkillDir, relPath);
|
|
6641
|
+
const dest = join(destSkillDir, relPath);
|
|
6642
|
+
mkdirSync(dirname(dest), { recursive: true });
|
|
6643
|
+
const label = join(skillName, relPath);
|
|
6644
|
+
let action;
|
|
6645
|
+
if (!existsSync(dest)) {
|
|
6646
|
+
action = "install";
|
|
6647
|
+
} else if (force) {
|
|
6648
|
+
action = "force-overwrite";
|
|
6649
|
+
} else {
|
|
6650
|
+
action = "needs-decision";
|
|
6651
|
+
}
|
|
6652
|
+
ops.push({ src, dest, label, action });
|
|
6653
|
+
}
|
|
6654
|
+
}
|
|
6655
|
+
const candidates = ops.filter((op) => op.action === "needs-decision");
|
|
6656
|
+
const decisions = candidates.length > 0 ? await promptOverwriteBatch(candidates.map((op) => ({ label: op.label, dest: op.dest }))) : /* @__PURE__ */ new Map();
|
|
6657
|
+
const installed = [];
|
|
6658
|
+
const overwritten = [];
|
|
6659
|
+
const skipped = [];
|
|
6660
|
+
for (const op of ops) {
|
|
6661
|
+
if (op.action === "install") {
|
|
6662
|
+
copyFileSync(op.src, op.dest);
|
|
6663
|
+
installed.push(op.label);
|
|
6664
|
+
} else if (op.action === "force-overwrite") {
|
|
6665
|
+
copyFileSync(op.src, op.dest);
|
|
6666
|
+
overwritten.push(op.label);
|
|
6667
|
+
} else {
|
|
6668
|
+
const decision = decisions.get(op.label) ?? "skip";
|
|
6669
|
+
if (decision === "overwrite") {
|
|
6670
|
+
copyFileSync(op.src, op.dest);
|
|
6671
|
+
overwritten.push(op.label);
|
|
6672
|
+
} else {
|
|
6673
|
+
skipped.push(op.label);
|
|
6674
|
+
}
|
|
6675
|
+
}
|
|
6676
|
+
}
|
|
6677
|
+
return { installed, overwritten, skipped };
|
|
6678
|
+
}
|
|
6424
6679
|
async function installSkills(rawOptions) {
|
|
6425
6680
|
const options = InstallSkillsOptionsSchema.parse(rawOptions);
|
|
6426
6681
|
const sourceDir = options.sourceDir ?? defaultSourceDir();
|
|
@@ -6486,6 +6741,76 @@ If you installed canicode from npm, please file a bug report \u2014 the tarball
|
|
|
6486
6741
|
}
|
|
6487
6742
|
return summary;
|
|
6488
6743
|
}
|
|
6744
|
+
var InstallClaudeGotchasOnlySchema = z.object({
|
|
6745
|
+
force: z.boolean(),
|
|
6746
|
+
cwd: z.string().optional(),
|
|
6747
|
+
sourceDir: z.string().optional()
|
|
6748
|
+
});
|
|
6749
|
+
async function installClaudeGotchasSkillOnly(rawOptions) {
|
|
6750
|
+
const options = InstallClaudeGotchasOnlySchema.parse(rawOptions);
|
|
6751
|
+
const sourceDir = options.sourceDir ?? defaultSourceDir();
|
|
6752
|
+
const skillName = "canicode-gotchas";
|
|
6753
|
+
const srcSkillDir = join(sourceDir, skillName);
|
|
6754
|
+
const cwd = options.cwd ?? process.cwd();
|
|
6755
|
+
const targetDir = join(cwd, ".claude", "skills");
|
|
6756
|
+
const destSkillDir = join(targetDir, skillName);
|
|
6757
|
+
if (!existsSync(sourceDir)) {
|
|
6758
|
+
throw new Error(
|
|
6759
|
+
`Bundled skills directory not found: ${sourceDir}
|
|
6760
|
+
If you are developing canicode, run 'pnpm build' first.
|
|
6761
|
+
If you installed canicode from npm, please file a bug report \u2014 the tarball is missing skills/.`
|
|
6762
|
+
);
|
|
6763
|
+
}
|
|
6764
|
+
mkdirSync(targetDir, { recursive: true });
|
|
6765
|
+
const part = await copySkillTree(skillName, srcSkillDir, destSkillDir, options.force);
|
|
6766
|
+
return {
|
|
6767
|
+
installed: part.installed,
|
|
6768
|
+
overwritten: part.overwritten,
|
|
6769
|
+
skipped: part.skipped,
|
|
6770
|
+
targetDir
|
|
6771
|
+
};
|
|
6772
|
+
}
|
|
6773
|
+
var InstallCursorBundledSchema = z.object({
|
|
6774
|
+
force: z.boolean(),
|
|
6775
|
+
cwd: z.string().optional(),
|
|
6776
|
+
/** Defaults to bundled `skills/cursor/` (build output). */
|
|
6777
|
+
sourceRoot: z.string().optional(),
|
|
6778
|
+
/**
|
|
6779
|
+
* Parent of per-skill dirs (defaults to `<cwd>/.cursor/skills`).
|
|
6780
|
+
* Tests may use a non-`.cursor` path when the runner blocks hidden directories.
|
|
6781
|
+
*/
|
|
6782
|
+
targetSkillsRoot: z.string().optional()
|
|
6783
|
+
});
|
|
6784
|
+
async function installCursorBundledSkills(rawOptions) {
|
|
6785
|
+
const options = InstallCursorBundledSchema.parse(rawOptions);
|
|
6786
|
+
const sourceRoot = options.sourceRoot ?? defaultCursorBundleRoot();
|
|
6787
|
+
const cwd = options.cwd ?? process.cwd();
|
|
6788
|
+
const targetDir = options.targetSkillsRoot ?? join(cwd, ".cursor", "skills");
|
|
6789
|
+
if (!existsSync(sourceRoot)) {
|
|
6790
|
+
throw new Error(
|
|
6791
|
+
`Bundled Cursor skills directory not found: ${sourceRoot}
|
|
6792
|
+
If you are developing canicode, run 'pnpm build' first (bundle populates skills/cursor/).
|
|
6793
|
+
If you installed canicode from npm, please file a bug report \u2014 the tarball is missing skills/cursor/.`
|
|
6794
|
+
);
|
|
6795
|
+
}
|
|
6796
|
+
mkdirSync(targetDir, { recursive: true });
|
|
6797
|
+
const skillNames = readdirSync(sourceRoot).filter((name) => statSync(join(sourceRoot, name)).isDirectory()).sort();
|
|
6798
|
+
if (skillNames.length === 0) {
|
|
6799
|
+
throw new Error(`No skill directories under: ${sourceRoot}`);
|
|
6800
|
+
}
|
|
6801
|
+
const entries = skillNames.map((skillName) => ({
|
|
6802
|
+
skillName,
|
|
6803
|
+
srcSkillDir: join(sourceRoot, skillName),
|
|
6804
|
+
destSkillDir: join(targetDir, skillName)
|
|
6805
|
+
}));
|
|
6806
|
+
const part = await copyMultipleSkillTrees(entries, options.force);
|
|
6807
|
+
return {
|
|
6808
|
+
installed: part.installed,
|
|
6809
|
+
overwritten: part.overwritten,
|
|
6810
|
+
skipped: part.skipped,
|
|
6811
|
+
targetDir
|
|
6812
|
+
};
|
|
6813
|
+
}
|
|
6489
6814
|
function listFilesRecursive(dir) {
|
|
6490
6815
|
const out = [];
|
|
6491
6816
|
const walk = (current) => {
|
|
@@ -6544,9 +6869,9 @@ async function promptOverwriteBatch(candidates) {
|
|
|
6544
6869
|
}
|
|
6545
6870
|
|
|
6546
6871
|
// src/cli/commands/init.ts
|
|
6547
|
-
function
|
|
6872
|
+
function figmaEntryInMcpFile(filePath) {
|
|
6548
6873
|
try {
|
|
6549
|
-
const raw = readFileSync(
|
|
6874
|
+
const raw = readFileSync(filePath, "utf-8");
|
|
6550
6875
|
const parsed = JSON.parse(raw);
|
|
6551
6876
|
const figma = parsed?.mcpServers?.["figma"];
|
|
6552
6877
|
return typeof figma === "object" && figma !== null;
|
|
@@ -6554,12 +6879,24 @@ function figmaMcpRegistered(cwd = process.cwd()) {
|
|
|
6554
6879
|
return false;
|
|
6555
6880
|
}
|
|
6556
6881
|
}
|
|
6882
|
+
function figmaMcpRegistered(cwd = process.cwd()) {
|
|
6883
|
+
return figmaEntryInMcpFile(join(cwd, ".mcp.json")) || figmaEntryInMcpFile(join(cwd, ".cursor", "mcp.json"));
|
|
6884
|
+
}
|
|
6557
6885
|
function formatNextSteps(opts) {
|
|
6558
6886
|
if (!opts.skillsInstalled) {
|
|
6559
6887
|
return `
|
|
6560
6888
|
Next: canicode analyze "https://www.figma.com/design/..."`;
|
|
6561
6889
|
}
|
|
6890
|
+
const cursor = opts.cursorSkillsInstalled === true;
|
|
6562
6891
|
if (opts.figmaMcpPresent) {
|
|
6892
|
+
if (cursor) {
|
|
6893
|
+
return [
|
|
6894
|
+
"",
|
|
6895
|
+
" Next:",
|
|
6896
|
+
" 1. Restart Cursor or reload MCP (so skills + MCP tools load in a fresh session)",
|
|
6897
|
+
" 2. In Agent chat, @ canicode-roundtrip with your Figma URL (or @ canicode-gotchas for survey-only)"
|
|
6898
|
+
].join("\n");
|
|
6899
|
+
}
|
|
6563
6900
|
return [
|
|
6564
6901
|
"",
|
|
6565
6902
|
" Next:",
|
|
@@ -6567,6 +6904,15 @@ function formatNextSteps(opts) {
|
|
|
6567
6904
|
" 2. Run /canicode-roundtrip <figma-url>"
|
|
6568
6905
|
].join("\n");
|
|
6569
6906
|
}
|
|
6907
|
+
if (cursor) {
|
|
6908
|
+
return [
|
|
6909
|
+
"",
|
|
6910
|
+
" Next:",
|
|
6911
|
+
" 1. Add Figma MCP to .cursor/mcp.json (see https://github.com/let-sunny/canicode/blob/main/docs/CUSTOMIZATION.md#cursor-mcp-canicode and Figma MCP docs)",
|
|
6912
|
+
" 2. Restart Cursor so Figma tools (e.g. use_figma) load",
|
|
6913
|
+
" 3. @ canicode-roundtrip with your Figma URL for full roundtrip"
|
|
6914
|
+
].join("\n");
|
|
6915
|
+
}
|
|
6570
6916
|
return [
|
|
6571
6917
|
"",
|
|
6572
6918
|
" Next:",
|
|
@@ -6581,10 +6927,12 @@ var InitOptionsSchema = z.object({
|
|
|
6581
6927
|
global: z.boolean().optional(),
|
|
6582
6928
|
// cac maps `--no-skills` to `skills: false` (mirrors `--no-telemetry`).
|
|
6583
6929
|
skills: z.boolean().optional(),
|
|
6930
|
+
/** Install `skills/cursor/*` into `.cursor/skills/` (canicode, gotchas, roundtrip — issue #407). */
|
|
6931
|
+
cursorSkills: z.boolean().optional(),
|
|
6584
6932
|
force: z.boolean().optional()
|
|
6585
6933
|
});
|
|
6586
6934
|
function registerInit(cli2) {
|
|
6587
|
-
cli2.command("init", "Set up canicode with Figma API token").option("--token <token>", "Save Figma API token and install Claude Code skills to .claude/skills/").option("--global", "Install skills to ~/.claude/skills/ instead of ./.claude/skills/").option("--no-skills", "Skip skill installation (token only)").option("--force", "Overwrite existing skill files without prompting (also for non-TTY/CI)").action(async (rawOptions) => {
|
|
6935
|
+
cli2.command("init", "Set up canicode with Figma API token").option("--token <token>", "Save Figma API token and install Claude Code skills to .claude/skills/").option("--global", "Install skills to ~/.claude/skills/ instead of ./.claude/skills/").option("--no-skills", "Skip skill installation (token only)").option("--cursor-skills", "Also install Cursor copies of canicode / canicode-gotchas / canicode-roundtrip under .cursor/skills/").option("--force", "Overwrite existing skill files without prompting (also for non-TTY/CI)").action(async (rawOptions) => {
|
|
6588
6936
|
try {
|
|
6589
6937
|
const parseResult = InitOptionsSchema.safeParse(rawOptions);
|
|
6590
6938
|
if (!parseResult.success) {
|
|
@@ -6628,9 +6976,56 @@ ${msg}`);
|
|
|
6628
6976
|
process.exitCode = 1;
|
|
6629
6977
|
skillStepOk = false;
|
|
6630
6978
|
}
|
|
6979
|
+
} else if (options.cursorSkills) {
|
|
6980
|
+
try {
|
|
6981
|
+
const summary = await installClaudeGotchasSkillOnly({
|
|
6982
|
+
force: options.force ?? false
|
|
6983
|
+
});
|
|
6984
|
+
console.log(`
|
|
6985
|
+
Gotchas store (Claude Code skills path) installed to: ${summary.targetDir}/`);
|
|
6986
|
+
console.log(` installed: ${summary.installed.length}`);
|
|
6987
|
+
console.log(` overwritten: ${summary.overwritten.length}`);
|
|
6988
|
+
console.log(` skipped: ${summary.skipped.length}`);
|
|
6989
|
+
skillSummary = {
|
|
6990
|
+
installed: summary.installed.length,
|
|
6991
|
+
overwritten: summary.overwritten.length,
|
|
6992
|
+
skipped: summary.skipped.length
|
|
6993
|
+
};
|
|
6994
|
+
} catch (skillError) {
|
|
6995
|
+
console.error(
|
|
6996
|
+
`
|
|
6997
|
+
Gotchas skill install failed: ${skillError instanceof Error ? skillError.message : String(skillError)}`
|
|
6998
|
+
);
|
|
6999
|
+
process.exitCode = 1;
|
|
7000
|
+
skillStepOk = false;
|
|
7001
|
+
}
|
|
7002
|
+
}
|
|
7003
|
+
if (options.cursorSkills && skillStepOk) {
|
|
7004
|
+
try {
|
|
7005
|
+
const cSummary = await installCursorBundledSkills({
|
|
7006
|
+
force: options.force ?? false
|
|
7007
|
+
});
|
|
7008
|
+
console.log(`
|
|
7009
|
+
Cursor skills installed to: ${cSummary.targetDir}/`);
|
|
7010
|
+
console.log(` installed: ${cSummary.installed.length}`);
|
|
7011
|
+
console.log(` overwritten: ${cSummary.overwritten.length}`);
|
|
7012
|
+
console.log(` skipped: ${cSummary.skipped.length}`);
|
|
7013
|
+
if (cSummary.skipped.length > 0) {
|
|
7014
|
+
console.log(` (Re-run with --force to overwrite skipped files.)`);
|
|
7015
|
+
}
|
|
7016
|
+
console.log(` Open a new chat and @-mention canicode, canicode-gotchas, or canicode-roundtrip if skills do not appear immediately.`);
|
|
7017
|
+
} catch (cursorError) {
|
|
7018
|
+
console.error(
|
|
7019
|
+
`
|
|
7020
|
+
Cursor skill install failed: ${cursorError instanceof Error ? cursorError.message : String(cursorError)}`
|
|
7021
|
+
);
|
|
7022
|
+
process.exitCode = 1;
|
|
7023
|
+
skillStepOk = false;
|
|
7024
|
+
}
|
|
6631
7025
|
}
|
|
6632
7026
|
trackEvent(EVENTS.CLI_INIT, {
|
|
6633
7027
|
skillsRequested: options.skills !== false,
|
|
7028
|
+
cursorSkillsRequested: options.cursorSkills === true,
|
|
6634
7029
|
skillStepOk,
|
|
6635
7030
|
target: options.global ? "global" : "project",
|
|
6636
7031
|
force: options.force ?? false,
|
|
@@ -6640,7 +7035,8 @@ ${msg}`);
|
|
|
6640
7035
|
console.log(
|
|
6641
7036
|
formatNextSteps({
|
|
6642
7037
|
figmaMcpPresent: figmaMcpRegistered(),
|
|
6643
|
-
skillsInstalled: options.skills !== false
|
|
7038
|
+
skillsInstalled: options.skills !== false,
|
|
7039
|
+
cursorSkillsInstalled: options.cursorSkills === true
|
|
6644
7040
|
})
|
|
6645
7041
|
);
|
|
6646
7042
|
}
|
|
@@ -6656,6 +7052,7 @@ ${msg}`);
|
|
|
6656
7052
|
console.log(` (canicode, canicode-gotchas, canicode-roundtrip).`);
|
|
6657
7053
|
console.log(` --global Install to ~/.claude/skills/ instead`);
|
|
6658
7054
|
console.log(` --no-skills Skip skill install (token only)`);
|
|
7055
|
+
console.log(` --cursor-skills Also install Cursor copies of all three skills (.cursor/skills/); with --no-skills, still installs .claude gotcha store + Cursor bundle`);
|
|
6659
7056
|
console.log(` --force Overwrite existing skill files without prompting
|
|
6660
7057
|
`);
|
|
6661
7058
|
console.log(`After setup:`);
|
|
@@ -6861,7 +7258,16 @@ var CalibrationConfigSchema = z.object({
|
|
|
6861
7258
|
maxConversionNodes: z.number().int().positive().default(20),
|
|
6862
7259
|
samplingStrategy: SamplingStrategySchema.default("top-issues"),
|
|
6863
7260
|
outputPath: z.string().default("logs/calibration/calibration-report.md"),
|
|
6864
|
-
runDir: z.string().optional()
|
|
7261
|
+
runDir: z.string().optional(),
|
|
7262
|
+
/**
|
|
7263
|
+
* #404: Explicit analysis scope for the calibration run. When omitted,
|
|
7264
|
+
* the orchestrator (`scripts/calibrate.ts`) injects `"page"` as the
|
|
7265
|
+
* policy default — `fixtures/done/*` are conceptually pages even though
|
|
7266
|
+
* they are stored as `COMPONENT` variants ("Platform=Desktop" etc.) and
|
|
7267
|
+
* would otherwise auto-detect as component scope. A `.scope` file in
|
|
7268
|
+
* the fixture directory overrides the default per-fixture.
|
|
7269
|
+
*/
|
|
7270
|
+
scope: AnalysisScopeSchema.optional()
|
|
6865
7271
|
});
|
|
6866
7272
|
|
|
6867
7273
|
// src/agents/analysis-agent.ts
|
|
@@ -7822,7 +8228,10 @@ function buildRuleScoresMap() {
|
|
|
7822
8228
|
async function runCalibrationAnalyze(config2) {
|
|
7823
8229
|
const parsed = CalibrationConfigSchema.parse(config2);
|
|
7824
8230
|
const { file, fileKey, nodeId } = await loadFile2(parsed.input, parsed.token);
|
|
7825
|
-
const analyzeOptions =
|
|
8231
|
+
const analyzeOptions = {
|
|
8232
|
+
...nodeId ? { targetNodeId: nodeId } : {},
|
|
8233
|
+
...parsed.scope ? { scope: parsed.scope } : {}
|
|
8234
|
+
};
|
|
7826
8235
|
const analysisResult = analyzeFile(file, analyzeOptions);
|
|
7827
8236
|
const analysisOutput = runAnalysisAgent({ analysisResult });
|
|
7828
8237
|
const ruleScores = {
|
|
@@ -7943,7 +8352,7 @@ function registerCalibrateAnalyze(cli2) {
|
|
|
7943
8352
|
cli2.command(
|
|
7944
8353
|
"calibrate-analyze <input>",
|
|
7945
8354
|
"Run calibration analysis and output JSON for conversion step"
|
|
7946
|
-
).option("--output <path>", "Output JSON path", { default: "logs/calibration/calibration-analysis.json" }).option("--run-dir <path>", "Run directory (overrides --output, writes to <run-dir>/analysis.json)").option("--token <token>", "Figma API token (or use FIGMA_TOKEN env var)").option("--target-node-id <nodeId>", "Scope analysis to a specific node").action(async (input, options) => {
|
|
8355
|
+
).option("--output <path>", "Output JSON path", { default: "logs/calibration/calibration-analysis.json" }).option("--run-dir <path>", "Run directory (overrides --output, writes to <run-dir>/analysis.json)").option("--token <token>", "Figma API token (or use FIGMA_TOKEN env var)").option("--target-node-id <nodeId>", "Scope analysis to a specific node").option("--scope <scope>", "(#404) Override analysis scope (`page` | `component`). Pass-through to the rule engine; `scripts/calibrate.ts` normally sets this to `page` for fixtures/done/* because they are conceptually pages packaged as COMPONENT variants.").action(async (input, options) => {
|
|
7947
8356
|
try {
|
|
7948
8357
|
console.log("Running calibration analysis...");
|
|
7949
8358
|
const calibConfig = {
|
|
@@ -7952,7 +8361,8 @@ function registerCalibrateAnalyze(cli2) {
|
|
|
7952
8361
|
samplingStrategy: "top-issues",
|
|
7953
8362
|
outputPath: "logs/calibration/calibration-report.md",
|
|
7954
8363
|
...options.token && { token: options.token },
|
|
7955
|
-
...options.targetNodeId && { targetNodeId: options.targetNodeId }
|
|
8364
|
+
...options.targetNodeId && { targetNodeId: options.targetNodeId },
|
|
8365
|
+
...options.scope && { scope: options.scope }
|
|
7956
8366
|
};
|
|
7957
8367
|
const { analysisOutput, ruleScores, fileKey } = await runCalibrationAnalyze(calibConfig);
|
|
7958
8368
|
const filteredSummaries = filterConversionCandidates(
|
|
@@ -7967,6 +8377,14 @@ function registerCalibrateAnalyze(cli2) {
|
|
|
7967
8377
|
analyzedAt: analysisOutput.analysisResult.analyzedAt,
|
|
7968
8378
|
nodeCount: analysisOutput.analysisResult.nodeCount,
|
|
7969
8379
|
issueCount: analysisOutput.analysisResult.issues.length,
|
|
8380
|
+
/**
|
|
8381
|
+
* #404: Resolved analysis scope for this calibration run —
|
|
8382
|
+
* surfaced in analysis.json so downstream diff/tuning agents
|
|
8383
|
+
* and post-hoc grade comparisons can see whether a run used
|
|
8384
|
+
* page or component scope (critical once #403 introduces
|
|
8385
|
+
* scope-dependent rule behavior).
|
|
8386
|
+
*/
|
|
8387
|
+
scope: analysisOutput.analysisResult.scope,
|
|
7970
8388
|
calibrationTier,
|
|
7971
8389
|
scoreReport: analysisOutput.scoreReport,
|
|
7972
8390
|
nodeIssueSummaries: filteredSummaries,
|