canicode 0.10.5 → 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 +546 -138
- package/dist/cli/index.js.map +1 -1
- package/dist/index.d.ts +254 -19
- package/dist/index.js +243 -70
- package/dist/index.js.map +1 -1
- package/dist/mcp/server.js +229 -78
- 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"];
|
|
@@ -3428,6 +3528,11 @@ var AcknowledgmentListSchema = z.array(AcknowledgmentSchema);
|
|
|
3428
3528
|
function normalizeNodeId(id) {
|
|
3429
3529
|
return id.replace(/-/g, ":");
|
|
3430
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
|
+
}
|
|
3431
3536
|
|
|
3432
3537
|
// src/core/engine/rule-engine.ts
|
|
3433
3538
|
function calculateMaxDepth(node, currentDepth = 0) {
|
|
@@ -3479,6 +3584,7 @@ var RuleEngine = class {
|
|
|
3479
3584
|
excludeNamePattern;
|
|
3480
3585
|
excludeNodeTypes;
|
|
3481
3586
|
acknowledgments;
|
|
3587
|
+
scopeOverride;
|
|
3482
3588
|
constructor(options = {}) {
|
|
3483
3589
|
this.configs = options.configs ?? RULE_CONFIGS;
|
|
3484
3590
|
this.enabledRuleIds = options.enabledRules ? new Set(options.enabledRules) : null;
|
|
@@ -3491,6 +3597,7 @@ var RuleEngine = class {
|
|
|
3491
3597
|
(a) => `${normalizeNodeId(a.nodeId)}::${a.ruleId}`
|
|
3492
3598
|
)
|
|
3493
3599
|
);
|
|
3600
|
+
this.scopeOverride = options.scope;
|
|
3494
3601
|
}
|
|
3495
3602
|
/**
|
|
3496
3603
|
* Analyze a Figma file and return issues
|
|
@@ -3507,6 +3614,8 @@ var RuleEngine = class {
|
|
|
3507
3614
|
}
|
|
3508
3615
|
const maxDepth = calculateMaxDepth(rootNode);
|
|
3509
3616
|
const nodeCount = countNodes(rootNode);
|
|
3617
|
+
const scope = this.scopeOverride ?? detectAnalysisScope(rootNode);
|
|
3618
|
+
const rootNodeType = rootNode.type;
|
|
3510
3619
|
const issues = [];
|
|
3511
3620
|
const failedRules = [];
|
|
3512
3621
|
const enabledRules = this.getEnabledRules();
|
|
@@ -3522,6 +3631,8 @@ var RuleEngine = class {
|
|
|
3522
3631
|
[],
|
|
3523
3632
|
0,
|
|
3524
3633
|
analysisState,
|
|
3634
|
+
scope,
|
|
3635
|
+
rootNodeType,
|
|
3525
3636
|
void 0,
|
|
3526
3637
|
void 0
|
|
3527
3638
|
);
|
|
@@ -3539,7 +3650,8 @@ var RuleEngine = class {
|
|
|
3539
3650
|
failedRules,
|
|
3540
3651
|
maxDepth,
|
|
3541
3652
|
nodeCount,
|
|
3542
|
-
analyzedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
3653
|
+
analyzedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
3654
|
+
scope
|
|
3543
3655
|
};
|
|
3544
3656
|
}
|
|
3545
3657
|
/**
|
|
@@ -3557,7 +3669,7 @@ var RuleEngine = class {
|
|
|
3557
3669
|
/**
|
|
3558
3670
|
* Recursively traverse the tree and run rules
|
|
3559
3671
|
*/
|
|
3560
|
-
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) {
|
|
3561
3673
|
const nodePath = [...path, node.name];
|
|
3562
3674
|
const isComponentBoundary = node.type === "COMPONENT" || node.type === "COMPONENT_SET" || node.type === "INSTANCE";
|
|
3563
3675
|
const currentComponentDepth = isComponentBoundary ? 0 : componentDepth;
|
|
@@ -3576,7 +3688,9 @@ var RuleEngine = class {
|
|
|
3576
3688
|
path: nodePath,
|
|
3577
3689
|
ancestorTypes,
|
|
3578
3690
|
siblings,
|
|
3579
|
-
analysisState
|
|
3691
|
+
analysisState,
|
|
3692
|
+
scope,
|
|
3693
|
+
rootNodeType
|
|
3580
3694
|
};
|
|
3581
3695
|
for (const rule of rules) {
|
|
3582
3696
|
const ruleId = rule.definition.id;
|
|
@@ -3623,6 +3737,8 @@ var RuleEngine = class {
|
|
|
3623
3737
|
childAncestorTypes,
|
|
3624
3738
|
currentComponentDepth + 1,
|
|
3625
3739
|
analysisState,
|
|
3740
|
+
scope,
|
|
3741
|
+
rootNodeType,
|
|
3626
3742
|
node,
|
|
3627
3743
|
node.children
|
|
3628
3744
|
);
|
|
@@ -4056,7 +4172,7 @@ function computeApplyContext(violation, instanceContext) {
|
|
|
4056
4172
|
}
|
|
4057
4173
|
|
|
4058
4174
|
// package.json
|
|
4059
|
-
var version2 = "0.
|
|
4175
|
+
var version2 = "0.11.0";
|
|
4060
4176
|
|
|
4061
4177
|
// src/core/engine/scoring.ts
|
|
4062
4178
|
function computeTotalScorePerCategory(configs) {
|
|
@@ -4250,6 +4366,10 @@ function buildResultJson(fileName, result, scores, options) {
|
|
|
4250
4366
|
const suggestedName = issue.violation.suggestedName;
|
|
4251
4367
|
return {
|
|
4252
4368
|
ruleId: issue.violation.ruleId,
|
|
4369
|
+
detection: "rule-based",
|
|
4370
|
+
outputChannel: "score",
|
|
4371
|
+
persistenceIntent: "transient",
|
|
4372
|
+
purpose: getRulePurpose(issue.violation.ruleId),
|
|
4253
4373
|
...issue.violation.subType && { subType: issue.violation.subType },
|
|
4254
4374
|
severity: issue.config.severity,
|
|
4255
4375
|
nodeId: issue.violation.nodeId,
|
|
@@ -4272,6 +4392,7 @@ function buildResultJson(fileName, result, scores, options) {
|
|
|
4272
4392
|
fileName,
|
|
4273
4393
|
nodeCount: result.nodeCount,
|
|
4274
4394
|
maxDepth: result.maxDepth,
|
|
4395
|
+
scope: result.scope,
|
|
4275
4396
|
issueCount: result.issues.length,
|
|
4276
4397
|
acknowledgedCount: scores.summary.acknowledgedCount,
|
|
4277
4398
|
isReadyForCodeGen: isReadyForCodeGen(scores.overall.grade),
|
|
@@ -5535,10 +5656,11 @@ var AnalyzeOptionsSchema = z.object({
|
|
|
5535
5656
|
config: z.string().optional(),
|
|
5536
5657
|
noOpen: z.boolean().optional(),
|
|
5537
5658
|
json: z.boolean().optional(),
|
|
5538
|
-
acknowledgments: z.string().optional()
|
|
5659
|
+
acknowledgments: z.string().optional(),
|
|
5660
|
+
scope: z.enum(["page", "component"]).optional()
|
|
5539
5661
|
});
|
|
5540
5662
|
function registerAnalyze(cli2) {
|
|
5541
|
-
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) => {
|
|
5542
5664
|
const parseResult = AnalyzeOptionsSchema.safeParse(rawOptions);
|
|
5543
5665
|
if (!parseResult.success) {
|
|
5544
5666
|
const msg = parseResult.error.issues.map((i) => `--${i.path.join(".")}: ${i.message}`).join("\n");
|
|
@@ -5625,7 +5747,8 @@ Analyzing: ${file.name}`);
|
|
|
5625
5747
|
...effectiveNodeId && { targetNodeId: effectiveNodeId },
|
|
5626
5748
|
...excludeNodeNames && { excludeNodeNames },
|
|
5627
5749
|
...excludeNodeTypes && { excludeNodeTypes },
|
|
5628
|
-
...acknowledgments && { acknowledgments }
|
|
5750
|
+
...acknowledgments && { acknowledgments },
|
|
5751
|
+
...options.scope && { scope: options.scope }
|
|
5629
5752
|
};
|
|
5630
5753
|
const result = analyzeFile(file, analyzeOptions);
|
|
5631
5754
|
log(`Nodes: ${result.nodeCount} (max depth: ${result.maxDepth})`);
|
|
@@ -5693,11 +5816,14 @@ Report saved: ${outputPath}`);
|
|
|
5693
5816
|
}
|
|
5694
5817
|
z.object({
|
|
5695
5818
|
ruleId: z.string(),
|
|
5819
|
+
detection: z.literal("rule-based"),
|
|
5820
|
+
outputChannel: z.literal("annotation"),
|
|
5821
|
+
persistenceIntent: z.literal("durable"),
|
|
5696
5822
|
question: z.string(),
|
|
5697
5823
|
hint: z.string(),
|
|
5698
5824
|
example: z.string()
|
|
5699
5825
|
});
|
|
5700
|
-
var
|
|
5826
|
+
var GOTCHA_QUESTION_CONTENT = {
|
|
5701
5827
|
// ── Pixel Critical (blocking) ──
|
|
5702
5828
|
"no-auto-layout": {
|
|
5703
5829
|
ruleId: "no-auto-layout",
|
|
@@ -5801,6 +5927,17 @@ var GOTCHA_QUESTIONS = {
|
|
|
5801
5927
|
example: "Use PascalCase for all component layers (e.g., CardTitle, CardBody)"
|
|
5802
5928
|
}
|
|
5803
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
|
+
);
|
|
5804
5941
|
|
|
5805
5942
|
// src/core/gotcha/group-and-batch-questions.ts
|
|
5806
5943
|
var BATCHABLE_RULE_IDS = [
|
|
@@ -5869,9 +6006,14 @@ function pushIntoBatch(group, question) {
|
|
|
5869
6006
|
var NODE_PATH_SEPARATOR = " > ";
|
|
5870
6007
|
function generateGotchaSurvey(result, scores, options = {}) {
|
|
5871
6008
|
const grade = scores.overall.grade;
|
|
5872
|
-
const relevantIssues = result.issues.filter(
|
|
5873
|
-
|
|
5874
|
-
|
|
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
|
+
});
|
|
5875
6017
|
const deduped = deduplicateSiblingIssues(relevantIssues);
|
|
5876
6018
|
const sorted = stableSortBySeverity(deduped);
|
|
5877
6019
|
const mapped = sorted.map((issue) => mapToQuestion(issue, result.file)).filter((q) => q !== null);
|
|
@@ -5915,14 +6057,17 @@ function getNodeName(nodePath) {
|
|
|
5915
6057
|
function stableSortBySeverity(issues) {
|
|
5916
6058
|
const blocking = [];
|
|
5917
6059
|
const risk = [];
|
|
6060
|
+
const missingInfo = [];
|
|
5918
6061
|
for (const issue of issues) {
|
|
5919
6062
|
if (issue.config.severity === "blocking") {
|
|
5920
6063
|
blocking.push(issue);
|
|
6064
|
+
} else if (issue.config.severity === "missing-info") {
|
|
6065
|
+
missingInfo.push(issue);
|
|
5921
6066
|
} else {
|
|
5922
6067
|
risk.push(issue);
|
|
5923
6068
|
}
|
|
5924
6069
|
}
|
|
5925
|
-
return [...blocking, ...risk];
|
|
6070
|
+
return [...blocking, ...risk, ...missingInfo];
|
|
5926
6071
|
}
|
|
5927
6072
|
function mapToQuestion(issue, file) {
|
|
5928
6073
|
const ruleId = issue.violation.ruleId;
|
|
@@ -5939,6 +6084,10 @@ function mapToQuestion(issue, file) {
|
|
|
5939
6084
|
nodeId: issue.violation.nodeId,
|
|
5940
6085
|
nodeName,
|
|
5941
6086
|
ruleId,
|
|
6087
|
+
detection: template.detection,
|
|
6088
|
+
outputChannel: template.outputChannel,
|
|
6089
|
+
persistenceIntent: template.persistenceIntent,
|
|
6090
|
+
purpose: getRulePurpose(issue.violation.ruleId),
|
|
5942
6091
|
severity: issue.config.severity,
|
|
5943
6092
|
question: template.question.replace("{nodeName}", nodeName),
|
|
5944
6093
|
hint: template.hint,
|
|
@@ -6026,7 +6175,8 @@ var GotchaSurveyOptionsSchema = z.object({
|
|
|
6026
6175
|
token: z.string().optional(),
|
|
6027
6176
|
config: z.string().optional(),
|
|
6028
6177
|
targetNodeId: z.string().optional(),
|
|
6029
|
-
json: z.boolean().optional()
|
|
6178
|
+
json: z.boolean().optional(),
|
|
6179
|
+
scope: z.enum(["page", "component"]).optional()
|
|
6030
6180
|
});
|
|
6031
6181
|
async function runGotchaSurvey(input, options) {
|
|
6032
6182
|
const { file, nodeId } = await loadFile(input, options.token);
|
|
@@ -6038,7 +6188,8 @@ async function runGotchaSurvey(input, options) {
|
|
|
6038
6188
|
}
|
|
6039
6189
|
const result = analyzeFile(file, {
|
|
6040
6190
|
configs,
|
|
6041
|
-
...effectiveNodeId ? { targetNodeId: effectiveNodeId } : {}
|
|
6191
|
+
...effectiveNodeId ? { targetNodeId: effectiveNodeId } : {},
|
|
6192
|
+
...options.scope ? { scope: options.scope } : {}
|
|
6042
6193
|
});
|
|
6043
6194
|
const scores = calculateScores(result, configs);
|
|
6044
6195
|
return generateGotchaSurvey(result, scores, { designKey: computeDesignKey(input) });
|
|
@@ -6056,7 +6207,7 @@ function formatHumanSummary(survey) {
|
|
|
6056
6207
|
return lines.join("\n");
|
|
6057
6208
|
}
|
|
6058
6209
|
function registerGotchaSurvey(cli2) {
|
|
6059
|
-
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) => {
|
|
6060
6211
|
const parseResult = GotchaSurveyOptionsSchema.safeParse(rawOptions);
|
|
6061
6212
|
if (!parseResult.success) {
|
|
6062
6213
|
const msg = parseResult.error.issues.map((i) => `--${i.path.join(".")}: ${i.message}`).join("\n");
|
|
@@ -6431,6 +6582,100 @@ var SKILL_NAMES = ["canicode", "canicode-gotchas", "canicode-roundtrip"];
|
|
|
6431
6582
|
function defaultSourceDir() {
|
|
6432
6583
|
return fileURLToPath(new URL("../../skills/", import.meta.url));
|
|
6433
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
|
+
}
|
|
6434
6679
|
async function installSkills(rawOptions) {
|
|
6435
6680
|
const options = InstallSkillsOptionsSchema.parse(rawOptions);
|
|
6436
6681
|
const sourceDir = options.sourceDir ?? defaultSourceDir();
|
|
@@ -6496,6 +6741,76 @@ If you installed canicode from npm, please file a bug report \u2014 the tarball
|
|
|
6496
6741
|
}
|
|
6497
6742
|
return summary;
|
|
6498
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
|
+
}
|
|
6499
6814
|
function listFilesRecursive(dir) {
|
|
6500
6815
|
const out = [];
|
|
6501
6816
|
const walk = (current) => {
|
|
@@ -6554,9 +6869,9 @@ async function promptOverwriteBatch(candidates) {
|
|
|
6554
6869
|
}
|
|
6555
6870
|
|
|
6556
6871
|
// src/cli/commands/init.ts
|
|
6557
|
-
function
|
|
6872
|
+
function figmaEntryInMcpFile(filePath) {
|
|
6558
6873
|
try {
|
|
6559
|
-
const raw = readFileSync(
|
|
6874
|
+
const raw = readFileSync(filePath, "utf-8");
|
|
6560
6875
|
const parsed = JSON.parse(raw);
|
|
6561
6876
|
const figma = parsed?.mcpServers?.["figma"];
|
|
6562
6877
|
return typeof figma === "object" && figma !== null;
|
|
@@ -6564,12 +6879,24 @@ function figmaMcpRegistered(cwd = process.cwd()) {
|
|
|
6564
6879
|
return false;
|
|
6565
6880
|
}
|
|
6566
6881
|
}
|
|
6882
|
+
function figmaMcpRegistered(cwd = process.cwd()) {
|
|
6883
|
+
return figmaEntryInMcpFile(join(cwd, ".mcp.json")) || figmaEntryInMcpFile(join(cwd, ".cursor", "mcp.json"));
|
|
6884
|
+
}
|
|
6567
6885
|
function formatNextSteps(opts) {
|
|
6568
6886
|
if (!opts.skillsInstalled) {
|
|
6569
6887
|
return `
|
|
6570
6888
|
Next: canicode analyze "https://www.figma.com/design/..."`;
|
|
6571
6889
|
}
|
|
6890
|
+
const cursor = opts.cursorSkillsInstalled === true;
|
|
6572
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
|
+
}
|
|
6573
6900
|
return [
|
|
6574
6901
|
"",
|
|
6575
6902
|
" Next:",
|
|
@@ -6577,6 +6904,15 @@ function formatNextSteps(opts) {
|
|
|
6577
6904
|
" 2. Run /canicode-roundtrip <figma-url>"
|
|
6578
6905
|
].join("\n");
|
|
6579
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
|
+
}
|
|
6580
6916
|
return [
|
|
6581
6917
|
"",
|
|
6582
6918
|
" Next:",
|
|
@@ -6591,10 +6927,12 @@ var InitOptionsSchema = z.object({
|
|
|
6591
6927
|
global: z.boolean().optional(),
|
|
6592
6928
|
// cac maps `--no-skills` to `skills: false` (mirrors `--no-telemetry`).
|
|
6593
6929
|
skills: z.boolean().optional(),
|
|
6930
|
+
/** Install `skills/cursor/*` into `.cursor/skills/` (canicode, gotchas, roundtrip — issue #407). */
|
|
6931
|
+
cursorSkills: z.boolean().optional(),
|
|
6594
6932
|
force: z.boolean().optional()
|
|
6595
6933
|
});
|
|
6596
6934
|
function registerInit(cli2) {
|
|
6597
|
-
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) => {
|
|
6598
6936
|
try {
|
|
6599
6937
|
const parseResult = InitOptionsSchema.safeParse(rawOptions);
|
|
6600
6938
|
if (!parseResult.success) {
|
|
@@ -6638,9 +6976,56 @@ ${msg}`);
|
|
|
6638
6976
|
process.exitCode = 1;
|
|
6639
6977
|
skillStepOk = false;
|
|
6640
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
|
+
}
|
|
6641
7025
|
}
|
|
6642
7026
|
trackEvent(EVENTS.CLI_INIT, {
|
|
6643
7027
|
skillsRequested: options.skills !== false,
|
|
7028
|
+
cursorSkillsRequested: options.cursorSkills === true,
|
|
6644
7029
|
skillStepOk,
|
|
6645
7030
|
target: options.global ? "global" : "project",
|
|
6646
7031
|
force: options.force ?? false,
|
|
@@ -6650,7 +7035,8 @@ ${msg}`);
|
|
|
6650
7035
|
console.log(
|
|
6651
7036
|
formatNextSteps({
|
|
6652
7037
|
figmaMcpPresent: figmaMcpRegistered(),
|
|
6653
|
-
skillsInstalled: options.skills !== false
|
|
7038
|
+
skillsInstalled: options.skills !== false,
|
|
7039
|
+
cursorSkillsInstalled: options.cursorSkills === true
|
|
6654
7040
|
})
|
|
6655
7041
|
);
|
|
6656
7042
|
}
|
|
@@ -6666,6 +7052,7 @@ ${msg}`);
|
|
|
6666
7052
|
console.log(` (canicode, canicode-gotchas, canicode-roundtrip).`);
|
|
6667
7053
|
console.log(` --global Install to ~/.claude/skills/ instead`);
|
|
6668
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`);
|
|
6669
7056
|
console.log(` --force Overwrite existing skill files without prompting
|
|
6670
7057
|
`);
|
|
6671
7058
|
console.log(`After setup:`);
|
|
@@ -6871,7 +7258,16 @@ var CalibrationConfigSchema = z.object({
|
|
|
6871
7258
|
maxConversionNodes: z.number().int().positive().default(20),
|
|
6872
7259
|
samplingStrategy: SamplingStrategySchema.default("top-issues"),
|
|
6873
7260
|
outputPath: z.string().default("logs/calibration/calibration-report.md"),
|
|
6874
|
-
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()
|
|
6875
7271
|
});
|
|
6876
7272
|
|
|
6877
7273
|
// src/agents/analysis-agent.ts
|
|
@@ -7832,7 +8228,10 @@ function buildRuleScoresMap() {
|
|
|
7832
8228
|
async function runCalibrationAnalyze(config2) {
|
|
7833
8229
|
const parsed = CalibrationConfigSchema.parse(config2);
|
|
7834
8230
|
const { file, fileKey, nodeId } = await loadFile2(parsed.input, parsed.token);
|
|
7835
|
-
const analyzeOptions =
|
|
8231
|
+
const analyzeOptions = {
|
|
8232
|
+
...nodeId ? { targetNodeId: nodeId } : {},
|
|
8233
|
+
...parsed.scope ? { scope: parsed.scope } : {}
|
|
8234
|
+
};
|
|
7836
8235
|
const analysisResult = analyzeFile(file, analyzeOptions);
|
|
7837
8236
|
const analysisOutput = runAnalysisAgent({ analysisResult });
|
|
7838
8237
|
const ruleScores = {
|
|
@@ -7953,7 +8352,7 @@ function registerCalibrateAnalyze(cli2) {
|
|
|
7953
8352
|
cli2.command(
|
|
7954
8353
|
"calibrate-analyze <input>",
|
|
7955
8354
|
"Run calibration analysis and output JSON for conversion step"
|
|
7956
|
-
).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) => {
|
|
7957
8356
|
try {
|
|
7958
8357
|
console.log("Running calibration analysis...");
|
|
7959
8358
|
const calibConfig = {
|
|
@@ -7962,7 +8361,8 @@ function registerCalibrateAnalyze(cli2) {
|
|
|
7962
8361
|
samplingStrategy: "top-issues",
|
|
7963
8362
|
outputPath: "logs/calibration/calibration-report.md",
|
|
7964
8363
|
...options.token && { token: options.token },
|
|
7965
|
-
...options.targetNodeId && { targetNodeId: options.targetNodeId }
|
|
8364
|
+
...options.targetNodeId && { targetNodeId: options.targetNodeId },
|
|
8365
|
+
...options.scope && { scope: options.scope }
|
|
7966
8366
|
};
|
|
7967
8367
|
const { analysisOutput, ruleScores, fileKey } = await runCalibrationAnalyze(calibConfig);
|
|
7968
8368
|
const filteredSummaries = filterConversionCandidates(
|
|
@@ -7977,6 +8377,14 @@ function registerCalibrateAnalyze(cli2) {
|
|
|
7977
8377
|
analyzedAt: analysisOutput.analysisResult.analyzedAt,
|
|
7978
8378
|
nodeCount: analysisOutput.analysisResult.nodeCount,
|
|
7979
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,
|
|
7980
8388
|
calibrationTier,
|
|
7981
8389
|
scoreReport: analysisOutput.scoreReport,
|
|
7982
8390
|
nodeIssueSummaries: filteredSummaries,
|