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/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
- severity: "risk",
1808
- score: -8,
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: "suggestion",
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: -3,
1864
- enabled: false
1865
- // disabled: interactionDestinations data missing from fixtures (#139)
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
- default: [{ type: "width" }, { type: "height" }]
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
- maxWidth: (name, currentWidth) => ({
2260
- message: `"${name}" uses FILL width (currently ${currentWidth}) without max-width`,
2261
- suggestion: `Add maxWidth to prevent stretching on large screens`
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
- minWidth: (name, currentWidth) => ({
2264
- message: `"${name}" uses FILL width (currently ${currentWidth}) without min-width`,
2265
- suggestion: `Add minWidth to prevent collapsing on small screens`
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
- wrap: (name) => ({
2268
- message: `"${name}" is in a wrap container without min-width`,
2269
- suggestion: `Add minWidth to control when wrapping occurs`
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
- grid: (name) => ({
2272
- message: `"${name}" is in a grid layout without size constraints`,
2273
- suggestion: `Add min/max-width for proper column sizing`
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: "Without min/max-width, AI has no bounds \u2014 generated code may collapse or stretch indefinitely",
2566
- impact: "Content becomes unreadable or invisible at extreme screen sizes",
2567
- fix: "Set min-width and/or max-width so AI can generate proper size constraints"
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 missingSizeConstraintCheck = (node, context) => {
2570
- if (!isContainerNode(node) && !hasTextContent(node)) return null;
2571
- if (!context.parent || !hasAutoLayout(context.parent)) return null;
2572
- const nodePath = context.path.join(" > ");
2573
- if (context.parent.layoutWrap === "WRAP" && node.layoutSizingHorizontal === "FILL" && node.minWidth === void 0) {
2574
- return {
2575
- ruleId: missingSizeConstraintDef.id,
2576
- subType: "wrap",
2577
- nodeId: node.id,
2578
- nodePath,
2579
- ...missingSizeConstraintMsg.wrap(node.name)
2580
- };
2581
- }
2582
- if (context.parent.layoutMode === "GRID" && node.layoutSizingHorizontal === "FILL" && node.minWidth === void 0 && node.maxWidth === void 0) {
2583
- return {
2584
- ruleId: missingSizeConstraintDef.id,
2585
- subType: "grid",
2586
- nodeId: node.id,
2587
- nodePath,
2588
- ...missingSizeConstraintMsg.grid(node.name)
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
- if (node.layoutSizingHorizontal === "FILL") {
2592
- if (isSizeConstraintExempt(node, context)) return null;
2593
- const currentWidth = node.absoluteBoundingBox ? `${node.absoluteBoundingBox.width}px` : "unknown";
2594
- return {
2595
- ruleId: missingSizeConstraintDef.id,
2596
- subType: "max-width",
2597
- nodeId: node.id,
2598
- nodePath,
2599
- ...missingSizeConstraintMsg.maxWidth(node.name, currentWidth)
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 null;
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.type === "COMPONENT") return true;
3292
- if (node.componentPropertyDefinitions !== void 0) return true;
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
- if (defs && defs[node.componentId] !== void 0) return true;
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.10.4";
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 GOTCHA_QUESTIONS = {
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
- (issue) => issue.config.severity === "blocking" || issue.config.severity === "risk"
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 figmaMcpRegistered(cwd = process.cwd()) {
6872
+ function figmaEntryInMcpFile(filePath) {
6548
6873
  try {
6549
- const raw = readFileSync(join(cwd, ".mcp.json"), "utf-8");
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 = nodeId ? { targetNodeId: nodeId } : {};
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,