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/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"];
@@ -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.10.5";
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 GOTCHA_QUESTIONS = {
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
- (issue) => issue.config.severity === "blocking" || issue.config.severity === "risk"
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 figmaMcpRegistered(cwd = process.cwd()) {
6872
+ function figmaEntryInMcpFile(filePath) {
6558
6873
  try {
6559
- const raw = readFileSync(join(cwd, ".mcp.json"), "utf-8");
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 = nodeId ? { targetNodeId: nodeId } : {};
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,