canicode 0.10.5 → 0.11.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli/index.js CHANGED
@@ -1377,7 +1377,9 @@ var EVENTS = {
1377
1377
  // cannot import `core/monitoring` directly, so the event fires through a
1378
1378
  // caller-supplied callback. Define the typed name here so a future consumer
1379
1379
  // has a single place to wire it up.
1380
- ROUNDTRIP_DEFINITION_WRITE_SKIPPED: `${EVENT_PREFIX}roundtrip_definition_write_skipped`
1380
+ ROUNDTRIP_DEFINITION_WRITE_SKIPPED: `${EVENT_PREFIX}roundtrip_definition_write_skipped`,
1381
+ /** CLI `canicode roundtrip-tally` completed successfully. */
1382
+ ROUNDTRIP_TALLY: `${EVENT_PREFIX}roundtrip_tally`
1381
1383
  };
1382
1384
 
1383
1385
  // src/core/monitoring/capture.ts
@@ -1529,6 +1531,14 @@ function printDocsSetup() {
1529
1531
  console.log(`
1530
1532
  CANICODE SETUP GUIDE
1531
1533
 
1534
+ Skills at a glance: canicode-gotchas = survey answers saved locally (memo-only).
1535
+ canicode-roundtrip = same flow plus writes to Figma via use_figma (canvas).
1536
+
1537
+ Token safety: Do NOT paste your Figma token into Claude, Cursor, or other
1538
+ agent chats \u2014 transcripts can retain it. Use:
1539
+ FIGMA_TOKEN=figd_\u2026 npx canicode init
1540
+ or run \`npx canicode init\` and enter the token only at the CLI prompt.
1541
+
1532
1542
  \u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501
1533
1543
  1. CLI (REST API)
1534
1544
  \u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501
@@ -1556,6 +1566,8 @@ CANICODE SETUP GUIDE
1556
1566
  2. CLAUDE CODE SKILLS (requires FIGMA_TOKEN)
1557
1567
  \u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501
1558
1568
 
1569
+ (Same token safety as above \u2014 env var or interactive prompt, not chat.)
1570
+
1559
1571
  Setup:
1560
1572
  canicode init --token figd_xxxxxxxxxxxxx
1561
1573
  (installs three skills into ./.claude/skills/ alongside the token)
@@ -1575,6 +1587,35 @@ CANICODE SETUP GUIDE
1575
1587
  /canicode-gotchas <url> Run a gotcha survey
1576
1588
  /canicode-roundtrip <url> Analyze, fix gotchas in Figma, re-analyze
1577
1589
 
1590
+ \u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501
1591
+ 3. CURSOR SKILLS (requires FIGMA_TOKEN)
1592
+ \u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501
1593
+
1594
+ (Same token safety as above \u2014 env var or interactive prompt, not chat.)
1595
+
1596
+ Setup:
1597
+ canicode init --token figd_xxxxxxxxxxxxx --cursor-skills
1598
+ (installs Cursor copies of the three skills into ./.cursor/skills/)
1599
+
1600
+ Installed skills:
1601
+ canicode Lightweight CLI wrapper
1602
+ canicode-gotchas Standalone gotcha survey
1603
+ canicode-roundtrip Full analyze -> gotcha -> apply roundtrip
1604
+
1605
+ Flags:
1606
+ --cursor-skills Install Cursor copies of all three skills into .cursor/skills/
1607
+ --no-skills Skip Claude Code skills (with --cursor-skills, still installs
1608
+ the Cursor bundle plus the shared gotchas answer file)
1609
+ --force Overwrite existing skill files without prompting
1610
+
1611
+ Use (in Cursor Agent chat):
1612
+ @canicode <figma-url>
1613
+ @canicode-gotchas <figma-url> Run a gotcha survey
1614
+ @canicode-roundtrip <figma-url> Analyze, fix gotchas in Figma, re-analyze
1615
+
1616
+ See also: docs/CUSTOMIZATION.md#cursor-mcp-canicode (Figma MCP required for roundtrip
1617
+ writes; analyze-only works without it).
1618
+
1578
1619
  \u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501
1579
1620
  TOKEN PRIORITY
1580
1621
  \u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501
@@ -1590,6 +1631,7 @@ CANICODE SETUP GUIDE
1590
1631
  CI/CD, automation -> CLI + FIGMA_TOKEN env var
1591
1632
  Claude Code (full) -> canicode MCP server + FIGMA_TOKEN
1592
1633
  Claude Code (light) -> /canicode skill + FIGMA_TOKEN
1634
+ Cursor Agent -> canicode init --cursor-skills + Figma MCP
1593
1635
  In Figma -> Figma Plugin
1594
1636
  Browser -> Web App (GitHub Pages)
1595
1637
  Quick trial, offline -> CLI + JSON fixtures
@@ -1777,6 +1819,41 @@ var RULE_ID_CATEGORY = {
1777
1819
  "non-semantic-name": "semantic",
1778
1820
  "inconsistent-naming-convention": "semantic"
1779
1821
  };
1822
+ var RULE_PURPOSE = {
1823
+ // Pixel Critical
1824
+ "no-auto-layout": "violation",
1825
+ "absolute-position-in-auto-layout": "violation",
1826
+ "non-layout-container": "violation",
1827
+ // Responsive Critical
1828
+ "fixed-size-in-auto-layout": "violation",
1829
+ // #403: missing-size-constraint reframed as info-collection. The rule
1830
+ // fires on width chains whose intent is structurally undecidable
1831
+ // (FILL container with no chain-bound ancestor; FIXED component or
1832
+ // instance root). The gotcha question is the primary signal; the
1833
+ // -1 score is just enough to keep the rule visible in
1834
+ // diversity scoring without driving grade swings on its own.
1835
+ "missing-size-constraint": "info-collection",
1836
+ // Code Quality
1837
+ "missing-component": "violation",
1838
+ "detached-instance": "violation",
1839
+ "variant-structure-mismatch": "violation",
1840
+ "deep-nesting": "violation",
1841
+ // Token Management
1842
+ "raw-value": "violation",
1843
+ "irregular-spacing": "violation",
1844
+ // Interaction — gotcha-primary: Figma cannot encode "what happens on
1845
+ // click" or "which states exist" in a way downstream code generation can
1846
+ // consume. Rules fire to trigger the annotation, not to flag a violation.
1847
+ "missing-interaction-state": "info-collection",
1848
+ "missing-prototype": "info-collection",
1849
+ // Semantic
1850
+ "non-standard-naming": "violation",
1851
+ "non-semantic-name": "violation",
1852
+ "inconsistent-naming-convention": "violation"
1853
+ };
1854
+ function getRulePurpose(ruleId) {
1855
+ return RULE_PURPOSE[ruleId] ?? "violation";
1856
+ }
1780
1857
  var RULE_CONFIGS = {
1781
1858
  // ── Pixel Critical ──
1782
1859
  "no-auto-layout": {
@@ -1804,8 +1881,12 @@ var RULE_CONFIGS = {
1804
1881
  enabled: true
1805
1882
  },
1806
1883
  "missing-size-constraint": {
1807
- severity: "risk",
1808
- score: -8,
1884
+ // #403: severity downgraded `risk → missing-info` and score from
1885
+ // -8 → -1 to match the new info-collection purpose. Keeping the
1886
+ // rule enabled (not disabled) so its gotchas still surface in the
1887
+ // survey — see RULE_PURPOSE entry above for the full rationale.
1888
+ severity: "missing-info",
1889
+ score: -1,
1809
1890
  enabled: true
1810
1891
  },
1811
1892
  // ── Code Quality ──
@@ -1852,17 +1933,22 @@ var RULE_CONFIGS = {
1852
1933
  }
1853
1934
  },
1854
1935
  // ── Interaction ──
1936
+ // #406: both rules are `info-collection` — primary output is the gotcha
1937
+ // annotation, not the score. Severity is `missing-info` so they surface in
1938
+ // the gotcha survey (see `generateGotchaSurvey`) even though the penalty
1939
+ // is minimal. Score stays at -1 so re-enabling `missing-prototype` on
1940
+ // fixtures that lack `interactionDestinations` (#139) cannot swing grades.
1855
1941
  "missing-interaction-state": {
1856
- severity: "suggestion",
1942
+ severity: "missing-info",
1857
1943
  score: -1,
1858
1944
  // uncalibrated: no metric to validate score (#210), kept at -1 to preserve category visibility
1859
1945
  enabled: true
1860
1946
  },
1861
1947
  "missing-prototype": {
1862
1948
  severity: "missing-info",
1863
- score: -3,
1864
- enabled: false
1865
- // disabled: interactionDestinations data missing from fixtures (#139)
1949
+ score: -1,
1950
+ // #406: info-collection — annotation is primary output; score kept minimal so #139 fixtures don't skew calibration
1951
+ enabled: true
1866
1952
  },
1867
1953
  // ── Semantic ──
1868
1954
  "non-standard-naming": {
@@ -1930,7 +2016,11 @@ function getConfigsWithPreset(preset) {
1930
2016
  }
1931
2017
  var RULE_ANNOTATION_PROPERTIES = {
1932
2018
  "missing-size-constraint": {
1933
- default: [{ type: "width" }, { type: "height" }]
2019
+ // #403: width-only the redesigned rule does not evaluate the
2020
+ // height axis (deferred follow-up). Emitting a `height` annotation
2021
+ // here would mark properties the rule never inspected and confuse
2022
+ // downstream Dev Mode hints.
2023
+ default: [{ type: "width" }]
1934
2024
  },
1935
2025
  "irregular-spacing": {
1936
2026
  bySubType: {
@@ -1982,6 +2072,14 @@ var RuleRegistry = class {
1982
2072
  register(rule) {
1983
2073
  this.rules.set(rule.definition.id, rule);
1984
2074
  }
2075
+ /**
2076
+ * Remove a rule by ID. Primarily used by tests that register a
2077
+ * throwaway rule and need to restore the registry afterwards. Returns
2078
+ * `true` if the rule was present.
2079
+ */
2080
+ unregister(id) {
2081
+ return this.rules.delete(id);
2082
+ }
1985
2083
  /**
1986
2084
  * Get a rule by ID
1987
2085
  */
@@ -2033,6 +2131,58 @@ function defineRule(rule) {
2033
2131
  ruleRegistry.register(rule);
2034
2132
  return rule;
2035
2133
  }
2134
+ var CategorySchema = z.enum([
2135
+ "pixel-critical",
2136
+ "responsive-critical",
2137
+ "code-quality",
2138
+ "token-management",
2139
+ "semantic",
2140
+ "interaction"
2141
+ ]);
2142
+ var CATEGORIES = CategorySchema.options;
2143
+ var CATEGORY_LABELS = {
2144
+ "pixel-critical": "Pixel Critical",
2145
+ "responsive-critical": "Responsive Critical",
2146
+ "code-quality": "Code Quality",
2147
+ "token-management": "Token Management",
2148
+ "semantic": "Semantic",
2149
+ "interaction": "Interaction"
2150
+ };
2151
+ var SeveritySchema = z.enum([
2152
+ "blocking",
2153
+ "risk",
2154
+ "missing-info",
2155
+ "suggestion"
2156
+ ]);
2157
+
2158
+ // src/core/contracts/rule.ts
2159
+ z.object({
2160
+ id: z.string(),
2161
+ name: z.string(),
2162
+ category: CategorySchema,
2163
+ why: z.string(),
2164
+ impact: z.string(),
2165
+ fix: z.string()
2166
+ });
2167
+ z.object({
2168
+ severity: SeveritySchema,
2169
+ score: z.number().int().max(0),
2170
+ depthWeight: z.number().min(1).max(2).optional(),
2171
+ enabled: z.boolean().default(true),
2172
+ options: z.record(z.string(), z.unknown()).optional()
2173
+ });
2174
+ function getAnalysisState(context, key, init) {
2175
+ if (context.analysisState.has(key)) {
2176
+ return context.analysisState.get(key);
2177
+ }
2178
+ const value = init();
2179
+ context.analysisState.set(key, value);
2180
+ return value;
2181
+ }
2182
+ var DEPTH_WEIGHT_CATEGORIES = ["pixel-critical", "responsive-critical"];
2183
+ function supportsDepthWeight(category) {
2184
+ return DEPTH_WEIGHT_CATEGORIES.includes(category);
2185
+ }
2036
2186
 
2037
2187
  // src/core/rules/node-semantics.ts
2038
2188
  function isContainerNode(node) {
@@ -2041,9 +2191,6 @@ function isContainerNode(node) {
2041
2191
  function hasAutoLayout(node) {
2042
2192
  return node.layoutMode !== void 0 && node.layoutMode !== "NONE";
2043
2193
  }
2044
- function hasTextContent(node) {
2045
- return node.type === "TEXT" || (node.children?.some((c) => c.type === "TEXT") ?? false);
2046
- }
2047
2194
  function hasOverlappingBounds(a, b) {
2048
2195
  const boxA = a.absoluteBoundingBox;
2049
2196
  const boxB = b.absoluteBoundingBox;
@@ -2184,12 +2331,6 @@ function isAbsolutePositionExempt(node) {
2184
2331
  if (isExcludedName(node.name)) return true;
2185
2332
  return false;
2186
2333
  }
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
2334
  function isFixedSizeExempt(node) {
2194
2335
  if (isVisualOnlyNode(node)) return true;
2195
2336
  if (isExcludedName(node.name)) return true;
@@ -2256,21 +2397,21 @@ var fixedSizeMsg = {
2256
2397
  })
2257
2398
  };
2258
2399
  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`
2400
+ pageContainerUnbound: (name, currentWidth) => ({
2401
+ message: `Container "${name}" uses FILL width (currently ${currentWidth}) and no ancestor defines a width bound`,
2402
+ suggestion: `Decide whether this area should stretch with the screen, or set min/max-width here so the responsive behavior is explicit`
2262
2403
  }),
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`
2404
+ pageInstanceFixed: (name, currentWidth) => ({
2405
+ message: `Instance "${name}" has fixed width (${currentWidth}) inside an Auto Layout parent`,
2406
+ suggestion: `Confirm whether this fixed width is intentional \u2014 if not, set the instance to FILL so it follows the parent's layout`
2266
2407
  }),
2267
- wrap: (name) => ({
2268
- message: `"${name}" is in a wrap container without min-width`,
2269
- suggestion: `Add minWidth to control when wrapping occurs`
2408
+ componentFixedByDesign: (name, currentWidth) => ({
2409
+ message: `Component "${name}" has fixed width (${currentWidth}) at its root`,
2410
+ suggestion: `Confirm whether this component is intentionally non-responsive \u2014 otherwise switch root sizing to FILL or set min/max bounds`
2270
2411
  }),
2271
- grid: (name) => ({
2272
- message: `"${name}" is in a grid layout without size constraints`,
2273
- suggestion: `Add min/max-width for proper column sizing`
2412
+ componentFixedByOverride: (name, currentWidth) => ({
2413
+ message: `Instance "${name}" overrides root width to fixed (${currentWidth}); the original component may be FILL`,
2414
+ suggestion: `Confirm whether the fixed-width override is intentional \u2014 if not, restore root sizing to inherit from the component definition`
2274
2415
  })
2275
2416
  };
2276
2417
  var nonLayoutContainerMsg = {
@@ -2562,44 +2703,97 @@ var missingSizeConstraintDef = {
2562
2703
  id: "missing-size-constraint",
2563
2704
  name: "Missing Size Constraint",
2564
2705
  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"
2706
+ 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",
2707
+ 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",
2708
+ 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
2709
  };
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
- };
2710
+ var CHAIN_BOUND_KEY = "missing-size-constraint:chain-bound";
2711
+ function getChainBoundCache(context) {
2712
+ return getAnalysisState(context, CHAIN_BOUND_KEY, () => /* @__PURE__ */ new Map());
2713
+ }
2714
+ function establishesOwnWidthBound(node) {
2715
+ if (node.layoutSizingHorizontal === "FIXED") return true;
2716
+ if (node.minWidth !== void 0 || node.maxWidth !== void 0) return true;
2717
+ return false;
2718
+ }
2719
+ function recordChainBound(context, node) {
2720
+ const cache = getChainBoundCache(context);
2721
+ const cached = cache.get(node.id);
2722
+ if (cached !== void 0) return cached;
2723
+ const own = establishesOwnWidthBound(node);
2724
+ const parent = context.parent;
2725
+ const inherited = parent ? cache.get(parent.id) ?? false : false;
2726
+ const result = own || inherited;
2727
+ cache.set(node.id, result);
2728
+ return result;
2729
+ }
2730
+ function parentChainBound(context) {
2731
+ if (!context.parent) return false;
2732
+ return getChainBoundCache(context).get(context.parent.id) ?? false;
2733
+ }
2734
+ var PAGE_CONTAINER_FRAME_TYPES = /* @__PURE__ */ new Set(["FRAME", "SECTION"]);
2735
+ function formatWidth(node) {
2736
+ return node.absoluteBoundingBox ? `${node.absoluteBoundingBox.width}px` : "unknown";
2737
+ }
2738
+ function buildViolation(subType, node, context, msg) {
2739
+ return {
2740
+ ruleId: missingSizeConstraintDef.id,
2741
+ subType,
2742
+ nodeId: node.id,
2743
+ nodePath: context.path.join(" > "),
2744
+ ...msg
2745
+ };
2746
+ }
2747
+ function checkComponentScopeRoot(node, context) {
2748
+ if (context.depth !== 0) return null;
2749
+ if (node.layoutSizingHorizontal !== "FIXED") return null;
2750
+ const currentWidth = formatWidth(node);
2751
+ if (context.rootNodeType === "INSTANCE") {
2752
+ return buildViolation(
2753
+ "component-fixed-by-override",
2754
+ node,
2755
+ context,
2756
+ missingSizeConstraintMsg.componentFixedByOverride(node.name, currentWidth)
2757
+ );
2590
2758
  }
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
- };
2759
+ return buildViolation(
2760
+ "component-fixed-by-design",
2761
+ node,
2762
+ context,
2763
+ missingSizeConstraintMsg.componentFixedByDesign(node.name, currentWidth)
2764
+ );
2765
+ }
2766
+ function checkPageInstanceFixed(node, context) {
2767
+ if (node.type !== "INSTANCE") return null;
2768
+ if (node.layoutSizingHorizontal !== "FIXED") return null;
2769
+ if (!context.parent || !hasAutoLayout(context.parent)) return null;
2770
+ const currentWidth = formatWidth(node);
2771
+ return buildViolation(
2772
+ "page-instance-fixed",
2773
+ node,
2774
+ context,
2775
+ missingSizeConstraintMsg.pageInstanceFixed(node.name, currentWidth)
2776
+ );
2777
+ }
2778
+ function checkPageContainerUnbound(node, context) {
2779
+ if (!PAGE_CONTAINER_FRAME_TYPES.has(node.type)) return null;
2780
+ if (node.layoutSizingHorizontal !== "FILL") return null;
2781
+ if (parentChainBound(context)) return null;
2782
+ const currentWidth = formatWidth(node);
2783
+ return buildViolation(
2784
+ "page-container-unbound",
2785
+ node,
2786
+ context,
2787
+ missingSizeConstraintMsg.pageContainerUnbound(node.name, currentWidth)
2788
+ );
2789
+ }
2790
+ var missingSizeConstraintCheck = (node, context) => {
2791
+ recordChainBound(context, node);
2792
+ if (context.ancestorTypes.includes("INSTANCE")) return null;
2793
+ if (context.scope === "component") {
2794
+ return checkComponentScopeRoot(node, context);
2601
2795
  }
2602
- return null;
2796
+ return checkPageInstanceFixed(node, context) ?? checkPageContainerUnbound(node, context);
2603
2797
  };
2604
2798
  defineRule({
2605
2799
  definition: missingSizeConstraintDef,
@@ -2803,58 +2997,6 @@ defineRule({
2803
2997
  definition: irregularSpacingDef,
2804
2998
  check: irregularSpacingCheck
2805
2999
  });
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
3000
 
2859
3001
  // src/core/rules/component/index.ts
2860
3002
  var STYLE_COMPARE_KEYS = ["fills", "strokes", "effects", "cornerRadius", "strokeWeight", "individualStrokeWeights"];
@@ -3420,14 +3562,37 @@ defineRule({
3420
3562
  definition: missingPrototypeDef,
3421
3563
  check: missingPrototypeCheck
3422
3564
  });
3565
+ var AcknowledgmentIntentSchema = z.object({
3566
+ field: z.string(),
3567
+ value: z.unknown(),
3568
+ scope: z.enum(["instance", "definition"])
3569
+ });
3570
+ var AcknowledgmentSceneWriteOutcomeSchema = z.object({
3571
+ result: z.enum([
3572
+ "succeeded",
3573
+ "silent-ignored",
3574
+ "api-rejected",
3575
+ "user-declined-propagation",
3576
+ "unknown"
3577
+ ]),
3578
+ reason: z.string().optional()
3579
+ });
3423
3580
  var AcknowledgmentSchema = z.object({
3424
3581
  nodeId: z.string(),
3425
- ruleId: z.string()
3582
+ ruleId: z.string(),
3583
+ intent: AcknowledgmentIntentSchema.optional(),
3584
+ sceneWriteOutcome: AcknowledgmentSceneWriteOutcomeSchema.optional(),
3585
+ codegenDirective: z.string().optional()
3426
3586
  });
3427
3587
  var AcknowledgmentListSchema = z.array(AcknowledgmentSchema);
3428
3588
  function normalizeNodeId(id) {
3429
3589
  return id.replace(/-/g, ":");
3430
3590
  }
3591
+ var AnalysisScopeSchema = z.enum(["page", "component"]);
3592
+ var COMPONENT_SCOPE_ROOT_TYPES = /* @__PURE__ */ new Set(["COMPONENT", "COMPONENT_SET", "INSTANCE"]);
3593
+ function detectAnalysisScope(rootNode) {
3594
+ return COMPONENT_SCOPE_ROOT_TYPES.has(rootNode.type) ? "component" : "page";
3595
+ }
3431
3596
 
3432
3597
  // src/core/engine/rule-engine.ts
3433
3598
  function calculateMaxDepth(node, currentDepth = 0) {
@@ -3479,6 +3644,7 @@ var RuleEngine = class {
3479
3644
  excludeNamePattern;
3480
3645
  excludeNodeTypes;
3481
3646
  acknowledgments;
3647
+ scopeOverride;
3482
3648
  constructor(options = {}) {
3483
3649
  this.configs = options.configs ?? RULE_CONFIGS;
3484
3650
  this.enabledRuleIds = options.enabledRules ? new Set(options.enabledRules) : null;
@@ -3491,6 +3657,7 @@ var RuleEngine = class {
3491
3657
  (a) => `${normalizeNodeId(a.nodeId)}::${a.ruleId}`
3492
3658
  )
3493
3659
  );
3660
+ this.scopeOverride = options.scope;
3494
3661
  }
3495
3662
  /**
3496
3663
  * Analyze a Figma file and return issues
@@ -3507,6 +3674,8 @@ var RuleEngine = class {
3507
3674
  }
3508
3675
  const maxDepth = calculateMaxDepth(rootNode);
3509
3676
  const nodeCount = countNodes(rootNode);
3677
+ const scope = this.scopeOverride ?? detectAnalysisScope(rootNode);
3678
+ const rootNodeType = rootNode.type;
3510
3679
  const issues = [];
3511
3680
  const failedRules = [];
3512
3681
  const enabledRules = this.getEnabledRules();
@@ -3522,6 +3691,8 @@ var RuleEngine = class {
3522
3691
  [],
3523
3692
  0,
3524
3693
  analysisState,
3694
+ scope,
3695
+ rootNodeType,
3525
3696
  void 0,
3526
3697
  void 0
3527
3698
  );
@@ -3539,7 +3710,8 @@ var RuleEngine = class {
3539
3710
  failedRules,
3540
3711
  maxDepth,
3541
3712
  nodeCount,
3542
- analyzedAt: (/* @__PURE__ */ new Date()).toISOString()
3713
+ analyzedAt: (/* @__PURE__ */ new Date()).toISOString(),
3714
+ scope
3543
3715
  };
3544
3716
  }
3545
3717
  /**
@@ -3557,7 +3729,7 @@ var RuleEngine = class {
3557
3729
  /**
3558
3730
  * Recursively traverse the tree and run rules
3559
3731
  */
3560
- traverseAndCheck(node, file, rules, maxDepth, issues, failedRules, depth, path, ancestorTypes, componentDepth, analysisState, parent, siblings) {
3732
+ traverseAndCheck(node, file, rules, maxDepth, issues, failedRules, depth, path, ancestorTypes, componentDepth, analysisState, scope, rootNodeType, parent, siblings) {
3561
3733
  const nodePath = [...path, node.name];
3562
3734
  const isComponentBoundary = node.type === "COMPONENT" || node.type === "COMPONENT_SET" || node.type === "INSTANCE";
3563
3735
  const currentComponentDepth = isComponentBoundary ? 0 : componentDepth;
@@ -3576,7 +3748,9 @@ var RuleEngine = class {
3576
3748
  path: nodePath,
3577
3749
  ancestorTypes,
3578
3750
  siblings,
3579
- analysisState
3751
+ analysisState,
3752
+ scope,
3753
+ rootNodeType
3580
3754
  };
3581
3755
  for (const rule of rules) {
3582
3756
  const ruleId = rule.definition.id;
@@ -3623,6 +3797,8 @@ var RuleEngine = class {
3623
3797
  childAncestorTypes,
3624
3798
  currentComponentDepth + 1,
3625
3799
  analysisState,
3800
+ scope,
3801
+ rootNodeType,
3626
3802
  node,
3627
3803
  node.children
3628
3804
  );
@@ -4056,7 +4232,7 @@ function computeApplyContext(violation, instanceContext) {
4056
4232
  }
4057
4233
 
4058
4234
  // package.json
4059
- var version2 = "0.10.5";
4235
+ var version2 = "0.11.1";
4060
4236
 
4061
4237
  // src/core/engine/scoring.ts
4062
4238
  function computeTotalScorePerCategory(configs) {
@@ -4250,6 +4426,10 @@ function buildResultJson(fileName, result, scores, options) {
4250
4426
  const suggestedName = issue.violation.suggestedName;
4251
4427
  return {
4252
4428
  ruleId: issue.violation.ruleId,
4429
+ detection: "rule-based",
4430
+ outputChannel: "score",
4431
+ persistenceIntent: "transient",
4432
+ purpose: getRulePurpose(issue.violation.ruleId),
4253
4433
  ...issue.violation.subType && { subType: issue.violation.subType },
4254
4434
  severity: issue.config.severity,
4255
4435
  nodeId: issue.violation.nodeId,
@@ -4272,6 +4452,7 @@ function buildResultJson(fileName, result, scores, options) {
4272
4452
  fileName,
4273
4453
  nodeCount: result.nodeCount,
4274
4454
  maxDepth: result.maxDepth,
4455
+ scope: result.scope,
4275
4456
  issueCount: result.issues.length,
4276
4457
  acknowledgedCount: scores.summary.acknowledgedCount,
4277
4458
  isReadyForCodeGen: isReadyForCodeGen(scores.overall.grade),
@@ -5535,10 +5716,11 @@ var AnalyzeOptionsSchema = z.object({
5535
5716
  config: z.string().optional(),
5536
5717
  noOpen: z.boolean().optional(),
5537
5718
  json: z.boolean().optional(),
5538
- acknowledgments: z.string().optional()
5719
+ acknowledgments: z.string().optional(),
5720
+ scope: z.enum(["page", "component"]).optional()
5539
5721
  });
5540
5722
  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) => {
5723
+ 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 / ADR-019) Path to JSON acknowledgments from canicode Figma annotations (nodeId, ruleId; optional intent / sceneWriteOutcome / codegenDirective per #444). 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
5724
  const parseResult = AnalyzeOptionsSchema.safeParse(rawOptions);
5543
5725
  if (!parseResult.success) {
5544
5726
  const msg = parseResult.error.issues.map((i) => `--${i.path.join(".")}: ${i.message}`).join("\n");
@@ -5625,7 +5807,8 @@ Analyzing: ${file.name}`);
5625
5807
  ...effectiveNodeId && { targetNodeId: effectiveNodeId },
5626
5808
  ...excludeNodeNames && { excludeNodeNames },
5627
5809
  ...excludeNodeTypes && { excludeNodeTypes },
5628
- ...acknowledgments && { acknowledgments }
5810
+ ...acknowledgments && { acknowledgments },
5811
+ ...options.scope && { scope: options.scope }
5629
5812
  };
5630
5813
  const result = analyzeFile(file, analyzeOptions);
5631
5814
  log(`Nodes: ${result.nodeCount} (max depth: ${result.maxDepth})`);
@@ -5693,11 +5876,14 @@ Report saved: ${outputPath}`);
5693
5876
  }
5694
5877
  z.object({
5695
5878
  ruleId: z.string(),
5879
+ detection: z.literal("rule-based"),
5880
+ outputChannel: z.literal("annotation"),
5881
+ persistenceIntent: z.literal("durable"),
5696
5882
  question: z.string(),
5697
5883
  hint: z.string(),
5698
5884
  example: z.string()
5699
5885
  });
5700
- var GOTCHA_QUESTIONS = {
5886
+ var GOTCHA_QUESTION_CONTENT = {
5701
5887
  // ── Pixel Critical (blocking) ──
5702
5888
  "no-auto-layout": {
5703
5889
  ruleId: "no-auto-layout",
@@ -5801,6 +5987,17 @@ var GOTCHA_QUESTIONS = {
5801
5987
  example: "Use PascalCase for all component layers (e.g., CardTitle, CardBody)"
5802
5988
  }
5803
5989
  };
5990
+ var GOTCHA_QUESTIONS = Object.fromEntries(
5991
+ Object.entries(GOTCHA_QUESTION_CONTENT).map(([ruleId, content]) => [
5992
+ ruleId,
5993
+ {
5994
+ ...content,
5995
+ detection: "rule-based",
5996
+ outputChannel: "annotation",
5997
+ persistenceIntent: "durable"
5998
+ }
5999
+ ])
6000
+ );
5804
6001
 
5805
6002
  // src/core/gotcha/group-and-batch-questions.ts
5806
6003
  var BATCHABLE_RULE_IDS = [
@@ -5869,9 +6066,14 @@ function pushIntoBatch(group, question) {
5869
6066
  var NODE_PATH_SEPARATOR = " > ";
5870
6067
  function generateGotchaSurvey(result, scores, options = {}) {
5871
6068
  const grade = scores.overall.grade;
5872
- const relevantIssues = result.issues.filter(
5873
- (issue) => issue.config.severity === "blocking" || issue.config.severity === "risk"
5874
- );
6069
+ const relevantIssues = result.issues.filter((issue) => {
6070
+ const severity = issue.config.severity;
6071
+ if (severity === "blocking" || severity === "risk") return true;
6072
+ if (severity === "missing-info") {
6073
+ return getRulePurpose(issue.violation.ruleId) === "info-collection";
6074
+ }
6075
+ return false;
6076
+ });
5875
6077
  const deduped = deduplicateSiblingIssues(relevantIssues);
5876
6078
  const sorted = stableSortBySeverity(deduped);
5877
6079
  const mapped = sorted.map((issue) => mapToQuestion(issue, result.file)).filter((q) => q !== null);
@@ -5915,14 +6117,17 @@ function getNodeName(nodePath) {
5915
6117
  function stableSortBySeverity(issues) {
5916
6118
  const blocking = [];
5917
6119
  const risk = [];
6120
+ const missingInfo = [];
5918
6121
  for (const issue of issues) {
5919
6122
  if (issue.config.severity === "blocking") {
5920
6123
  blocking.push(issue);
6124
+ } else if (issue.config.severity === "missing-info") {
6125
+ missingInfo.push(issue);
5921
6126
  } else {
5922
6127
  risk.push(issue);
5923
6128
  }
5924
6129
  }
5925
- return [...blocking, ...risk];
6130
+ return [...blocking, ...risk, ...missingInfo];
5926
6131
  }
5927
6132
  function mapToQuestion(issue, file) {
5928
6133
  const ruleId = issue.violation.ruleId;
@@ -5939,6 +6144,10 @@ function mapToQuestion(issue, file) {
5939
6144
  nodeId: issue.violation.nodeId,
5940
6145
  nodeName,
5941
6146
  ruleId,
6147
+ detection: template.detection,
6148
+ outputChannel: template.outputChannel,
6149
+ persistenceIntent: template.persistenceIntent,
6150
+ purpose: getRulePurpose(issue.violation.ruleId),
5942
6151
  severity: issue.config.severity,
5943
6152
  question: template.question.replace("{nodeName}", nodeName),
5944
6153
  hint: template.hint,
@@ -6026,7 +6235,8 @@ var GotchaSurveyOptionsSchema = z.object({
6026
6235
  token: z.string().optional(),
6027
6236
  config: z.string().optional(),
6028
6237
  targetNodeId: z.string().optional(),
6029
- json: z.boolean().optional()
6238
+ json: z.boolean().optional(),
6239
+ scope: z.enum(["page", "component"]).optional()
6030
6240
  });
6031
6241
  async function runGotchaSurvey(input, options) {
6032
6242
  const { file, nodeId } = await loadFile(input, options.token);
@@ -6038,7 +6248,8 @@ async function runGotchaSurvey(input, options) {
6038
6248
  }
6039
6249
  const result = analyzeFile(file, {
6040
6250
  configs,
6041
- ...effectiveNodeId ? { targetNodeId: effectiveNodeId } : {}
6251
+ ...effectiveNodeId ? { targetNodeId: effectiveNodeId } : {},
6252
+ ...options.scope ? { scope: options.scope } : {}
6042
6253
  });
6043
6254
  const scores = calculateScores(result, configs);
6044
6255
  return generateGotchaSurvey(result, scores, { designKey: computeDesignKey(input) });
@@ -6056,7 +6267,7 @@ function formatHumanSummary(survey) {
6056
6267
  return lines.join("\n");
6057
6268
  }
6058
6269
  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) => {
6270
+ 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
6271
  const parseResult = GotchaSurveyOptionsSchema.safeParse(rawOptions);
6061
6272
  if (!parseResult.success) {
6062
6273
  const msg = parseResult.error.issues.map((i) => `--${i.path.join(".")}: ${i.message}`).join("\n");
@@ -6109,6 +6320,221 @@ ${msg}`);
6109
6320
  }
6110
6321
  });
6111
6322
  }
6323
+ var DetectionSchema = z.literal("rule-based");
6324
+ var OutputChannelSchema = z.enum(["score", "annotation"]);
6325
+ var PersistenceIntentSchema = z.enum(["transient", "durable"]);
6326
+ var RulePurposeSchema = z.enum(["violation", "info-collection"]);
6327
+
6328
+ // src/core/contracts/gotcha-survey.ts
6329
+ var GradeSchema = z.enum(["S", "A+", "A", "B+", "B", "C+", "C", "D", "F"]);
6330
+ var InstanceContextSchema = z.object({
6331
+ parentInstanceNodeId: z.string(),
6332
+ sourceNodeId: z.string(),
6333
+ sourceComponentId: z.string().optional(),
6334
+ sourceComponentName: z.string().optional()
6335
+ });
6336
+ var RuleApplyStrategySchema = z.enum([
6337
+ "property-mod",
6338
+ "structural-mod",
6339
+ "annotation",
6340
+ "auto-fix"
6341
+ ]);
6342
+ var TargetPropertySchema = z.union([z.string(), z.array(z.string())]);
6343
+ var AnnotationPropertySchema = z.object({ type: z.string() });
6344
+ var GotchaDetectionSchema = DetectionSchema;
6345
+ var GotchaOutputChannelSchema = OutputChannelSchema.extract([
6346
+ "annotation"
6347
+ ]);
6348
+ var GotchaPersistenceIntentSchema = PersistenceIntentSchema.extract([
6349
+ "durable"
6350
+ ]);
6351
+ var GotchaSurveyQuestionSchema = z.object({
6352
+ nodeId: z.string(),
6353
+ nodeName: z.string(),
6354
+ ruleId: z.string(),
6355
+ detection: GotchaDetectionSchema,
6356
+ outputChannel: GotchaOutputChannelSchema,
6357
+ persistenceIntent: GotchaPersistenceIntentSchema,
6358
+ /**
6359
+ * #406: Classifies the triggering rule as `violation` (score-primary,
6360
+ * gotcha secondary) or `info-collection` (annotation-primary, score
6361
+ * minimal). Consumers use this to prioritize answers that collect durable
6362
+ * implementation context over answers that merely describe how to fix a
6363
+ * violation the rule will stop firing for.
6364
+ */
6365
+ purpose: RulePurposeSchema,
6366
+ severity: SeveritySchema,
6367
+ question: z.string(),
6368
+ hint: z.string(),
6369
+ example: z.string(),
6370
+ instanceContext: InstanceContextSchema.optional(),
6371
+ applyStrategy: RuleApplyStrategySchema,
6372
+ targetProperty: TargetPropertySchema.optional(),
6373
+ annotationProperties: z.array(AnnotationPropertySchema).optional(),
6374
+ suggestedName: z.string().optional(),
6375
+ isInstanceChild: z.boolean(),
6376
+ sourceChildId: z.string().optional(),
6377
+ // #356: when this question collapses N instance-child issues that share the
6378
+ // same `(sourceComponentId, sourceNodeId, ruleId)` tuple, `replicas` is the
6379
+ // total instance count (>=2) and `replicaNodeIds` lists every instance scene
6380
+ // node id OTHER than the kept `nodeId`. Apply step iterates
6381
+ // `[nodeId, ...replicaNodeIds]` so the same answer lands on every replica.
6382
+ // Single-instance questions omit both fields.
6383
+ replicas: z.number().int().min(2).optional(),
6384
+ replicaNodeIds: z.array(z.string()).optional()
6385
+ });
6386
+ var SurveyQuestionBatchSchema = z.object({
6387
+ ruleId: z.string(),
6388
+ /**
6389
+ * `true` when every member shares an answer-shape uniformly applicable to
6390
+ * all of them (e.g. one `min-width` value covers all FILL children).
6391
+ * The SKILL renders one shared prompt for `batchable: true` batches with
6392
+ * `questions.length >= 2`; everything else falls through to the
6393
+ * single-question template.
6394
+ */
6395
+ batchable: z.boolean(),
6396
+ questions: z.array(GotchaSurveyQuestionSchema),
6397
+ /**
6398
+ * Sum of `max(question.replicas, 1)` across `questions`. Counts the
6399
+ * actual Figma scene fan-out so the SKILL can render `N instances`
6400
+ * accurately even when one batch member already collapses multiple
6401
+ * replicas via the #356 source-component dedupe.
6402
+ */
6403
+ totalScenes: z.number().int().min(1)
6404
+ });
6405
+ var SurveyQuestionGroupSchema = z.object({
6406
+ /**
6407
+ * Shared `instanceContext` for the group, or `null` for the trailing
6408
+ * non-instance group. The SKILL emits the verbose "Instance note" header
6409
+ * once per non-null group instead of once per question (#370).
6410
+ */
6411
+ instanceContext: InstanceContextSchema.nullable(),
6412
+ batches: z.array(SurveyQuestionBatchSchema)
6413
+ });
6414
+ var GroupedSurveySchema = z.object({
6415
+ groups: z.array(SurveyQuestionGroupSchema)
6416
+ });
6417
+ var GotchaSurveySchema = z.object({
6418
+ designGrade: GradeSchema,
6419
+ isReadyForCodeGen: z.boolean(),
6420
+ questions: z.array(GotchaSurveyQuestionSchema),
6421
+ groupedQuestions: GroupedSurveySchema,
6422
+ /**
6423
+ * #384 — canonical identifier for this design across canicode runs.
6424
+ * Computed by `computeDesignKey(input)` (`<fileKey>#<nodeId>` for Figma
6425
+ * URLs, absolute path for fixtures). The `canicode-gotchas` SKILL reads
6426
+ * this directly when upserting the per-design section, so the SKILL.md
6427
+ * prose no longer parses URLs (per ADR-016).
6428
+ */
6429
+ designKey: z.string()
6430
+ });
6431
+ var AnswersMapSchema = z.record(
6432
+ z.string(),
6433
+ z.union([
6434
+ z.object({ answer: z.string() }),
6435
+ z.object({ skipped: z.literal(true) })
6436
+ ])
6437
+ );
6438
+ var RenderGotchaSectionInputSchema = z.object({
6439
+ questions: z.array(GotchaSurveyQuestionSchema),
6440
+ answers: AnswersMapSchema,
6441
+ designName: z.string(),
6442
+ figmaUrl: z.string(),
6443
+ designKey: z.string(),
6444
+ designGrade: z.string(),
6445
+ analyzedAt: z.string(),
6446
+ /** Local date for the section header (`YYYY-MM-DD`). */
6447
+ today: z.string()
6448
+ });
6449
+ function isSkippedAnswer(nodeId, answers) {
6450
+ const v = answers[nodeId];
6451
+ if (v === void 0) return true;
6452
+ if ("skipped" in v && v.skipped === true) return true;
6453
+ if ("answer" in v) return false;
6454
+ return true;
6455
+ }
6456
+ function skippedCountsByRule(skippedQs) {
6457
+ const m = /* @__PURE__ */ new Map();
6458
+ for (const q of skippedQs) {
6459
+ m.set(q.ruleId, (m.get(q.ruleId) ?? 0) + 1);
6460
+ }
6461
+ return m;
6462
+ }
6463
+ function renderSkippedCompact(skippedQs) {
6464
+ const n = skippedQs.length;
6465
+ const counts = skippedCountsByRule(skippedQs);
6466
+ const lines = [`#### Skipped (${n})`, ""];
6467
+ const sortedRules = [...counts.keys()].sort((a, b) => a.localeCompare(b));
6468
+ for (const ruleId of sortedRules) {
6469
+ const c = counts.get(ruleId) ?? 0;
6470
+ lines.push(`- \`${ruleId}\` \xD7 ${c}`);
6471
+ }
6472
+ lines.push("");
6473
+ return lines.join("\n");
6474
+ }
6475
+ function renderInstanceContextBullet(q) {
6476
+ const ic = q.instanceContext;
6477
+ if (!ic) return null;
6478
+ let componentPart = "";
6479
+ if (ic.sourceComponentName !== void 0 && ic.sourceComponentId !== void 0) {
6480
+ componentPart = `, component \`${ic.sourceComponentName}\` / \`${ic.sourceComponentId}\``;
6481
+ } else if (ic.sourceComponentName !== void 0) {
6482
+ componentPart = `, component \`${ic.sourceComponentName}\``;
6483
+ } else if (ic.sourceComponentId !== void 0) {
6484
+ componentPart = `, component \`${ic.sourceComponentId}\``;
6485
+ }
6486
+ return `- **Instance context**: parent instance \`${ic.parentInstanceNodeId}\`, source node \`${ic.sourceNodeId}\`${componentPart} \u2014 roundtrip apply uses this to write on the source definition when instance overrides fail.`;
6487
+ }
6488
+ function renderGotchaSection(raw) {
6489
+ const input = RenderGotchaSectionInputSchema.parse(raw);
6490
+ const header = [
6491
+ `## #{{SECTION_NUMBER}} \u2014 ${input.designName} \u2014 ${input.today}`,
6492
+ "",
6493
+ `- **Figma URL**: ${input.figmaUrl}`,
6494
+ `- **Design key**: ${input.designKey}`,
6495
+ `- **Grade**: ${input.designGrade}`,
6496
+ `- **Analyzed at**: ${input.analyzedAt}`,
6497
+ "",
6498
+ "### Gotchas",
6499
+ ""
6500
+ ].join("\n");
6501
+ const answered = [];
6502
+ const skippedList = [];
6503
+ for (const q of input.questions) {
6504
+ if (isSkippedAnswer(q.nodeId, input.answers)) skippedList.push(q);
6505
+ else answered.push(q);
6506
+ }
6507
+ const blocks = [];
6508
+ for (const q of answered) {
6509
+ const v = input.answers[q.nodeId];
6510
+ if (v === void 0 || !("answer" in v)) {
6511
+ throw new Error(
6512
+ `renderGotchaSection: expected answer for nodeId ${q.nodeId} (answered set)`
6513
+ );
6514
+ }
6515
+ const answerLine = v.answer;
6516
+ const lines = [
6517
+ `#### ${q.ruleId} \u2014 ${q.nodeName}`,
6518
+ "",
6519
+ `- **Severity**: ${q.severity}`,
6520
+ `- **Node ID**: ${q.nodeId}`
6521
+ ];
6522
+ const icBullet = renderInstanceContextBullet(q);
6523
+ if (icBullet !== null) {
6524
+ lines.push(icBullet);
6525
+ }
6526
+ lines.push(
6527
+ `- **Question**: ${q.question}`,
6528
+ `- **Answer**: ${answerLine}`,
6529
+ ""
6530
+ );
6531
+ blocks.push(lines.join("\n"));
6532
+ }
6533
+ if (skippedList.length > 0) {
6534
+ blocks.push(renderSkippedCompact(skippedList));
6535
+ }
6536
+ return `${header}${blocks.join("")}`.replace(/\s+$/, "") + "\n";
6537
+ }
6112
6538
  z.enum([
6113
6539
  "missing",
6114
6540
  "valid",
@@ -6225,10 +6651,26 @@ function ensureTrailingNewline(s) {
6225
6651
  }
6226
6652
 
6227
6653
  // src/cli/commands/upsert-gotcha-section.ts
6654
+ var AnswerSchema = z.union([
6655
+ z.object({ answer: z.string() }),
6656
+ z.object({ skipped: z.literal(true) })
6657
+ ]);
6658
+ var UpsertJsonPayloadSchema = z.object({
6659
+ survey: GotchaSurveySchema.pick({
6660
+ designKey: true,
6661
+ designGrade: true,
6662
+ questions: true
6663
+ }),
6664
+ answers: z.record(z.string(), AnswerSchema),
6665
+ designName: z.string(),
6666
+ figmaUrl: z.string(),
6667
+ analyzedAt: z.string(),
6668
+ today: z.string()
6669
+ });
6228
6670
  var UpsertOptionsSchema = z.object({
6229
6671
  file: z.string().min(1, "--file is required"),
6230
6672
  designKey: z.string().min(1, "--design-key is required"),
6231
- section: z.string().min(1, "--section is required (use '-' to read stdin)")
6673
+ input: z.string().min(1, "--input is required (use '--input=-' to read stdin)")
6232
6674
  });
6233
6675
  var USER_MESSAGES = {
6234
6676
  missing: "Gotchas SKILL.md not found at the given path. Run `canicode init` first, then re-invoke this skill.",
@@ -6241,8 +6683,38 @@ async function readStdin() {
6241
6683
  }
6242
6684
  return Buffer.concat(chunks).toString("utf-8");
6243
6685
  }
6686
+ function parseUpsertPayload(rawJson) {
6687
+ let parsed;
6688
+ try {
6689
+ parsed = JSON.parse(rawJson);
6690
+ } catch {
6691
+ throw new Error("Invalid JSON in --input");
6692
+ }
6693
+ const result = UpsertJsonPayloadSchema.safeParse(parsed);
6694
+ if (!result.success) {
6695
+ const msg = result.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`).join("; ");
6696
+ throw new Error(`Invalid upsert payload: ${msg}`);
6697
+ }
6698
+ return result.data;
6699
+ }
6244
6700
  async function runUpsertGotchaSection(options) {
6245
- const sectionMarkdown = options.section === "-" ? await readStdin() : options.section;
6701
+ const rawJson = options.input === "-" ? await readStdin() : readFileSync(options.input, "utf-8");
6702
+ const payload = parseUpsertPayload(rawJson);
6703
+ if (payload.survey.designKey !== options.designKey) {
6704
+ throw new Error(
6705
+ `--design-key (${options.designKey}) does not match survey.designKey (${payload.survey.designKey})`
6706
+ );
6707
+ }
6708
+ const sectionMarkdown = renderGotchaSection({
6709
+ questions: payload.survey.questions,
6710
+ answers: payload.answers,
6711
+ designName: payload.designName,
6712
+ figmaUrl: payload.figmaUrl,
6713
+ designKey: payload.survey.designKey,
6714
+ designGrade: payload.survey.designGrade,
6715
+ analyzedAt: payload.analyzedAt,
6716
+ today: payload.today
6717
+ });
6246
6718
  const currentContent = existsSync(options.file) ? readFileSync(options.file, "utf-8") : null;
6247
6719
  const { state, newContent, plan } = renderUpsertedFile({
6248
6720
  currentContent,
@@ -6255,7 +6727,8 @@ async function runUpsertGotchaSection(options) {
6255
6727
  action: null,
6256
6728
  sectionNumber: null,
6257
6729
  wrote: false,
6258
- userMessage: USER_MESSAGES[state] ?? null
6730
+ userMessage: USER_MESSAGES[state] ?? null,
6731
+ designKey: payload.survey.designKey
6259
6732
  };
6260
6733
  }
6261
6734
  writeFileSync(options.file, newContent, "utf-8");
@@ -6264,7 +6737,8 @@ async function runUpsertGotchaSection(options) {
6264
6737
  action: plan?.action ?? null,
6265
6738
  sectionNumber: plan?.sectionNumber ?? null,
6266
6739
  wrote: true,
6267
- userMessage: null
6740
+ userMessage: null,
6741
+ designKey: payload.survey.designKey
6268
6742
  };
6269
6743
  }
6270
6744
  function registerUpsertGotchaSection(cli2) {
@@ -6275,8 +6749,8 @@ function registerUpsertGotchaSection(cli2) {
6275
6749
  "--design-key <key>",
6276
6750
  "Canonical design key from gotcha-survey's response"
6277
6751
  ).option(
6278
- "--section <markdown>",
6279
- "Already-rendered per-design section markdown. Use '-' to read from stdin."
6752
+ "--input <path>",
6753
+ "JSON payload path, or '--input=-' to read JSON from stdin (cac parses a bare '-' as a flag, so the '=' form is required)."
6280
6754
  ).action(async (rawOptions) => {
6281
6755
  const parseResult = UpsertOptionsSchema.safeParse(rawOptions);
6282
6756
  if (!parseResult.success) {
@@ -6431,6 +6905,100 @@ var SKILL_NAMES = ["canicode", "canicode-gotchas", "canicode-roundtrip"];
6431
6905
  function defaultSourceDir() {
6432
6906
  return fileURLToPath(new URL("../../skills/", import.meta.url));
6433
6907
  }
6908
+ function defaultCursorBundleRoot() {
6909
+ return fileURLToPath(new URL("../../skills/cursor", import.meta.url));
6910
+ }
6911
+ async function copySkillTree(skillName, srcSkillDir, destSkillDir, force) {
6912
+ if (!existsSync(srcSkillDir)) {
6913
+ throw new Error(`Bundled skill directory missing: ${srcSkillDir}`);
6914
+ }
6915
+ mkdirSync(destSkillDir, { recursive: true });
6916
+ const ops = [];
6917
+ const files = listFilesRecursive(srcSkillDir);
6918
+ for (const relPath of files) {
6919
+ const src = join(srcSkillDir, relPath);
6920
+ const dest = join(destSkillDir, relPath);
6921
+ mkdirSync(dirname(dest), { recursive: true });
6922
+ const label = join(skillName, relPath);
6923
+ let action;
6924
+ if (!existsSync(dest)) {
6925
+ action = "install";
6926
+ } else if (force) {
6927
+ action = "force-overwrite";
6928
+ } else {
6929
+ action = "needs-decision";
6930
+ }
6931
+ ops.push({ src, dest, label, action });
6932
+ }
6933
+ const candidates = ops.filter((op) => op.action === "needs-decision");
6934
+ const decisions = candidates.length > 0 ? await promptOverwriteBatch(candidates.map((op) => ({ label: op.label, dest: op.dest }))) : /* @__PURE__ */ new Map();
6935
+ const installed = [];
6936
+ const overwritten = [];
6937
+ const skipped = [];
6938
+ for (const op of ops) {
6939
+ if (op.action === "install") {
6940
+ copyFileSync(op.src, op.dest);
6941
+ installed.push(op.label);
6942
+ } else if (op.action === "force-overwrite") {
6943
+ copyFileSync(op.src, op.dest);
6944
+ overwritten.push(op.label);
6945
+ } else {
6946
+ const decision = decisions.get(op.label) ?? "skip";
6947
+ if (decision === "overwrite") {
6948
+ copyFileSync(op.src, op.dest);
6949
+ overwritten.push(op.label);
6950
+ } else {
6951
+ skipped.push(op.label);
6952
+ }
6953
+ }
6954
+ }
6955
+ return { installed, overwritten, skipped };
6956
+ }
6957
+ async function copyMultipleSkillTrees(entries, force) {
6958
+ const ops = [];
6959
+ for (const { skillName, srcSkillDir, destSkillDir } of entries) {
6960
+ mkdirSync(destSkillDir, { recursive: true });
6961
+ const files = listFilesRecursive(srcSkillDir);
6962
+ for (const relPath of files) {
6963
+ const src = join(srcSkillDir, relPath);
6964
+ const dest = join(destSkillDir, relPath);
6965
+ mkdirSync(dirname(dest), { recursive: true });
6966
+ const label = join(skillName, relPath);
6967
+ let action;
6968
+ if (!existsSync(dest)) {
6969
+ action = "install";
6970
+ } else if (force) {
6971
+ action = "force-overwrite";
6972
+ } else {
6973
+ action = "needs-decision";
6974
+ }
6975
+ ops.push({ src, dest, label, action });
6976
+ }
6977
+ }
6978
+ const candidates = ops.filter((op) => op.action === "needs-decision");
6979
+ const decisions = candidates.length > 0 ? await promptOverwriteBatch(candidates.map((op) => ({ label: op.label, dest: op.dest }))) : /* @__PURE__ */ new Map();
6980
+ const installed = [];
6981
+ const overwritten = [];
6982
+ const skipped = [];
6983
+ for (const op of ops) {
6984
+ if (op.action === "install") {
6985
+ copyFileSync(op.src, op.dest);
6986
+ installed.push(op.label);
6987
+ } else if (op.action === "force-overwrite") {
6988
+ copyFileSync(op.src, op.dest);
6989
+ overwritten.push(op.label);
6990
+ } else {
6991
+ const decision = decisions.get(op.label) ?? "skip";
6992
+ if (decision === "overwrite") {
6993
+ copyFileSync(op.src, op.dest);
6994
+ overwritten.push(op.label);
6995
+ } else {
6996
+ skipped.push(op.label);
6997
+ }
6998
+ }
6999
+ }
7000
+ return { installed, overwritten, skipped };
7001
+ }
6434
7002
  async function installSkills(rawOptions) {
6435
7003
  const options = InstallSkillsOptionsSchema.parse(rawOptions);
6436
7004
  const sourceDir = options.sourceDir ?? defaultSourceDir();
@@ -6496,6 +7064,76 @@ If you installed canicode from npm, please file a bug report \u2014 the tarball
6496
7064
  }
6497
7065
  return summary;
6498
7066
  }
7067
+ var InstallClaudeGotchasOnlySchema = z.object({
7068
+ force: z.boolean(),
7069
+ cwd: z.string().optional(),
7070
+ sourceDir: z.string().optional()
7071
+ });
7072
+ async function installClaudeGotchasSkillOnly(rawOptions) {
7073
+ const options = InstallClaudeGotchasOnlySchema.parse(rawOptions);
7074
+ const sourceDir = options.sourceDir ?? defaultSourceDir();
7075
+ const skillName = "canicode-gotchas";
7076
+ const srcSkillDir = join(sourceDir, skillName);
7077
+ const cwd = options.cwd ?? process.cwd();
7078
+ const targetDir = join(cwd, ".claude", "skills");
7079
+ const destSkillDir = join(targetDir, skillName);
7080
+ if (!existsSync(sourceDir)) {
7081
+ throw new Error(
7082
+ `Bundled skills directory not found: ${sourceDir}
7083
+ If you are developing canicode, run 'pnpm build' first.
7084
+ If you installed canicode from npm, please file a bug report \u2014 the tarball is missing skills/.`
7085
+ );
7086
+ }
7087
+ mkdirSync(targetDir, { recursive: true });
7088
+ const part = await copySkillTree(skillName, srcSkillDir, destSkillDir, options.force);
7089
+ return {
7090
+ installed: part.installed,
7091
+ overwritten: part.overwritten,
7092
+ skipped: part.skipped,
7093
+ targetDir
7094
+ };
7095
+ }
7096
+ var InstallCursorBundledSchema = z.object({
7097
+ force: z.boolean(),
7098
+ cwd: z.string().optional(),
7099
+ /** Defaults to bundled `skills/cursor/` (build output). */
7100
+ sourceRoot: z.string().optional(),
7101
+ /**
7102
+ * Parent of per-skill dirs (defaults to `<cwd>/.cursor/skills`).
7103
+ * Tests may use a non-`.cursor` path when the runner blocks hidden directories.
7104
+ */
7105
+ targetSkillsRoot: z.string().optional()
7106
+ });
7107
+ async function installCursorBundledSkills(rawOptions) {
7108
+ const options = InstallCursorBundledSchema.parse(rawOptions);
7109
+ const sourceRoot = options.sourceRoot ?? defaultCursorBundleRoot();
7110
+ const cwd = options.cwd ?? process.cwd();
7111
+ const targetDir = options.targetSkillsRoot ?? join(cwd, ".cursor", "skills");
7112
+ if (!existsSync(sourceRoot)) {
7113
+ throw new Error(
7114
+ `Bundled Cursor skills directory not found: ${sourceRoot}
7115
+ If you are developing canicode, run 'pnpm build' first (bundle populates skills/cursor/).
7116
+ If you installed canicode from npm, please file a bug report \u2014 the tarball is missing skills/cursor/.`
7117
+ );
7118
+ }
7119
+ mkdirSync(targetDir, { recursive: true });
7120
+ const skillNames = readdirSync(sourceRoot).filter((name) => statSync(join(sourceRoot, name)).isDirectory()).sort();
7121
+ if (skillNames.length === 0) {
7122
+ throw new Error(`No skill directories under: ${sourceRoot}`);
7123
+ }
7124
+ const entries = skillNames.map((skillName) => ({
7125
+ skillName,
7126
+ srcSkillDir: join(sourceRoot, skillName),
7127
+ destSkillDir: join(targetDir, skillName)
7128
+ }));
7129
+ const part = await copyMultipleSkillTrees(entries, options.force);
7130
+ return {
7131
+ installed: part.installed,
7132
+ overwritten: part.overwritten,
7133
+ skipped: part.skipped,
7134
+ targetDir
7135
+ };
7136
+ }
6499
7137
  function listFilesRecursive(dir) {
6500
7138
  const out = [];
6501
7139
  const walk = (current) => {
@@ -6554,9 +7192,9 @@ async function promptOverwriteBatch(candidates) {
6554
7192
  }
6555
7193
 
6556
7194
  // src/cli/commands/init.ts
6557
- function figmaMcpRegistered(cwd = process.cwd()) {
7195
+ function figmaEntryInMcpFile(filePath) {
6558
7196
  try {
6559
- const raw = readFileSync(join(cwd, ".mcp.json"), "utf-8");
7197
+ const raw = readFileSync(filePath, "utf-8");
6560
7198
  const parsed = JSON.parse(raw);
6561
7199
  const figma = parsed?.mcpServers?.["figma"];
6562
7200
  return typeof figma === "object" && figma !== null;
@@ -6564,17 +7202,41 @@ function figmaMcpRegistered(cwd = process.cwd()) {
6564
7202
  return false;
6565
7203
  }
6566
7204
  }
7205
+ function figmaMcpRegistered(cwd = process.cwd()) {
7206
+ return figmaEntryInMcpFile(join(cwd, ".mcp.json")) || figmaEntryInMcpFile(join(cwd, ".cursor", "mcp.json"));
7207
+ }
6567
7208
  function formatNextSteps(opts) {
6568
7209
  if (!opts.skillsInstalled) {
6569
7210
  return `
6570
7211
  Next: canicode analyze "https://www.figma.com/design/..."`;
6571
7212
  }
7213
+ const cursor = opts.cursorSkillsInstalled === true;
6572
7214
  if (opts.figmaMcpPresent) {
7215
+ if (cursor) {
7216
+ return [
7217
+ "",
7218
+ " Next:",
7219
+ " 1. Restart Cursor or reload MCP (so skills + MCP tools load in a fresh session)",
7220
+ " 2. In Agent chat, @ canicode-roundtrip with your Figma URL (or @ canicode-gotchas for survey-only)",
7221
+ " \u2022 Optional \u2014 faster canicode MCP than `npx`: add `canicode-mcp` per https://github.com/let-sunny/canicode/blob/main/docs/CUSTOMIZATION.md#cursor-mcp-canicode (project `.cursor/mcp.json`), then reload MCP; otherwise skills keep using `npx canicode \u2026` (#433)."
7222
+ ].join("\n");
7223
+ }
6573
7224
  return [
6574
7225
  "",
6575
7226
  " Next:",
6576
7227
  " 1. Restart Claude Code (the newly installed skills only load on a fresh session)",
6577
- " 2. Run /canicode-roundtrip <figma-url>"
7228
+ " 2. Run /canicode-roundtrip <figma-url>",
7229
+ " \u2022 Optional \u2014 faster canicode MCP than `npx`: `claude mcp add canicode -- npx --yes --package=canicode canicode-mcp`, then restart Claude Code so `analyze` / `gotcha-survey` tools load \u2014 otherwise skills shell out to `npx canicode \u2026` (#433)."
7230
+ ].join("\n");
7231
+ }
7232
+ if (cursor) {
7233
+ return [
7234
+ "",
7235
+ " Next:",
7236
+ " 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)",
7237
+ " 2. Restart Cursor so Figma tools (e.g. use_figma) load",
7238
+ " 3. @ canicode-roundtrip with your Figma URL for full roundtrip",
7239
+ " \u2022 Optional \u2014 faster canicode MCP than `npx`: add `canicode-mcp` per the Customization guide (`#cursor-mcp-canicode`), then reload MCP; otherwise skills keep using `npx canicode \u2026` (#433)."
6578
7240
  ].join("\n");
6579
7241
  }
6580
7242
  return [
@@ -6583,18 +7245,29 @@ function formatNextSteps(opts) {
6583
7245
  " 1. Install Figma MCP:",
6584
7246
  " claude mcp add -s project -t http figma https://mcp.figma.com/mcp",
6585
7247
  " 2. Restart Claude Code (so the new skills + Figma MCP tools both load)",
6586
- " 3. Run /canicode-roundtrip <figma-url>"
7248
+ " 3. Run /canicode-roundtrip <figma-url>",
7249
+ " \u2022 Optional \u2014 faster canicode MCP than `npx`: `claude mcp add canicode -- npx --yes --package=canicode canicode-mcp`, then restart Claude Code so MCP tools load \u2014 otherwise skills shell out to `npx canicode \u2026` (#433)."
6587
7250
  ].join("\n");
6588
7251
  }
6589
7252
  var InitOptionsSchema = z.object({
6590
7253
  token: z.string().optional(),
6591
7254
  global: z.boolean().optional(),
6592
- // cac maps `--no-skills` to `skills: false` (mirrors `--no-telemetry`).
7255
+ // Declared positively as `--skills`; mri's built-in `--no-` prefix handling
7256
+ // still maps `--no-skills` to `skills: false`. Declaring the option
7257
+ // positively avoids cac's `(default: true)` artifact on negated flags.
6593
7258
  skills: z.boolean().optional(),
7259
+ /** Install `skills/cursor/*` into `.cursor/skills/` (canicode, gotchas, roundtrip — issue #407). */
7260
+ cursorSkills: z.boolean().optional(),
6594
7261
  force: z.boolean().optional()
6595
7262
  });
6596
7263
  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) => {
7264
+ cli2.command(
7265
+ "init",
7266
+ "Set up canicode with Figma API token (never paste a token into agent chat \u2014 use FIGMA_TOKEN=\u2026 or the interactive prompt)"
7267
+ ).option(
7268
+ "--token <token>",
7269
+ "Save Figma API token (use env/CLI only \u2014 not agent chat) and install Claude Code skills to .claude/skills/"
7270
+ ).option("--global", "Install skills to ~/.claude/skills/ instead of ./.claude/skills/").option("--skills", "Install Claude Code skills into .claude/skills/ (default: on \u2014 pass --no-skills to opt out)").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
7271
  try {
6599
7272
  const parseResult = InitOptionsSchema.safeParse(rawOptions);
6600
7273
  if (!parseResult.success) {
@@ -6638,9 +7311,56 @@ ${msg}`);
6638
7311
  process.exitCode = 1;
6639
7312
  skillStepOk = false;
6640
7313
  }
7314
+ } else if (options.cursorSkills) {
7315
+ try {
7316
+ const summary = await installClaudeGotchasSkillOnly({
7317
+ force: options.force ?? false
7318
+ });
7319
+ console.log(`
7320
+ Gotchas store (Claude Code skills path) installed to: ${summary.targetDir}/`);
7321
+ console.log(` installed: ${summary.installed.length}`);
7322
+ console.log(` overwritten: ${summary.overwritten.length}`);
7323
+ console.log(` skipped: ${summary.skipped.length}`);
7324
+ skillSummary = {
7325
+ installed: summary.installed.length,
7326
+ overwritten: summary.overwritten.length,
7327
+ skipped: summary.skipped.length
7328
+ };
7329
+ } catch (skillError) {
7330
+ console.error(
7331
+ `
7332
+ Gotchas skill install failed: ${skillError instanceof Error ? skillError.message : String(skillError)}`
7333
+ );
7334
+ process.exitCode = 1;
7335
+ skillStepOk = false;
7336
+ }
7337
+ }
7338
+ if (options.cursorSkills && skillStepOk) {
7339
+ try {
7340
+ const cSummary = await installCursorBundledSkills({
7341
+ force: options.force ?? false
7342
+ });
7343
+ console.log(`
7344
+ Cursor skills installed to: ${cSummary.targetDir}/`);
7345
+ console.log(` installed: ${cSummary.installed.length}`);
7346
+ console.log(` overwritten: ${cSummary.overwritten.length}`);
7347
+ console.log(` skipped: ${cSummary.skipped.length}`);
7348
+ if (cSummary.skipped.length > 0) {
7349
+ console.log(` (Re-run with --force to overwrite skipped files.)`);
7350
+ }
7351
+ console.log(` Open a new chat and @-mention canicode, canicode-gotchas, or canicode-roundtrip if skills do not appear immediately.`);
7352
+ } catch (cursorError) {
7353
+ console.error(
7354
+ `
7355
+ Cursor skill install failed: ${cursorError instanceof Error ? cursorError.message : String(cursorError)}`
7356
+ );
7357
+ process.exitCode = 1;
7358
+ skillStepOk = false;
7359
+ }
6641
7360
  }
6642
7361
  trackEvent(EVENTS.CLI_INIT, {
6643
7362
  skillsRequested: options.skills !== false,
7363
+ cursorSkillsRequested: options.cursorSkills === true,
6644
7364
  skillStepOk,
6645
7365
  target: options.global ? "global" : "project",
6646
7366
  force: options.force ?? false,
@@ -6650,7 +7370,8 @@ ${msg}`);
6650
7370
  console.log(
6651
7371
  formatNextSteps({
6652
7372
  figmaMcpPresent: figmaMcpRegistered(),
6653
- skillsInstalled: options.skills !== false
7373
+ skillsInstalled: options.skills !== false,
7374
+ cursorSkillsInstalled: options.cursorSkills === true
6654
7375
  })
6655
7376
  );
6656
7377
  }
@@ -6658,6 +7379,10 @@ ${msg}`);
6658
7379
  }
6659
7380
  console.log(`CANICODE SETUP
6660
7381
  `);
7382
+ console.log(
7383
+ ` Never paste your token into Claude/Cursor chat \u2014 use FIGMA_TOKEN=\u2026 npx canicode init or this prompt only.
7384
+ `
7385
+ );
6661
7386
  console.log(` canicode init --token YOUR_FIGMA_TOKEN`);
6662
7387
  console.log(` Get token: figma.com > Settings > Personal access tokens
6663
7388
  `);
@@ -6666,6 +7391,7 @@ ${msg}`);
6666
7391
  console.log(` (canicode, canicode-gotchas, canicode-roundtrip).`);
6667
7392
  console.log(` --global Install to ~/.claude/skills/ instead`);
6668
7393
  console.log(` --no-skills Skip skill install (token only)`);
7394
+ 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
7395
  console.log(` --force Overwrite existing skill files without prompting
6670
7396
  `);
6671
7397
  console.log(`After setup:`);
@@ -6764,6 +7490,164 @@ function registerListRules(cli2) {
6764
7490
  }
6765
7491
  });
6766
7492
  }
7493
+ var StepFourReportSchema = z.object({
7494
+ /** ✅ — Strategy A property write (or A's auto-fix branch) succeeded. */
7495
+ resolved: z.number().int().min(0),
7496
+ /** 📝 — Strategy C wrote a Figma annotation (or A/B fell back to one). */
7497
+ annotated: z.number().int().min(0),
7498
+ /**
7499
+ * 🌐 — `applyWithInstanceFallback` propagated the write up to the
7500
+ * source COMPONENT definition (only counted when
7501
+ * `allowDefinitionWrite: true`).
7502
+ */
7503
+ definitionWritten: z.number().int().min(0),
7504
+ /**
7505
+ * ⏭️ — User said "skip", "n/a", or otherwise declined a per-question
7506
+ * confirmation (Strategy B opt-out, etc.).
7507
+ */
7508
+ skipped: z.number().int().min(0)
7509
+ });
7510
+ var ReanalyzeForTallySchema = z.object({
7511
+ /**
7512
+ * Total remaining issues from the re-analyze. Maps to the existing
7513
+ * `issueCount` (analyze JSON) / `questions.length` (gotcha-survey JSON)
7514
+ * field — both downstream channels populate it.
7515
+ */
7516
+ issueCount: z.number().int().min(0),
7517
+ /**
7518
+ * Issues the re-analyze flagged with `acknowledged: true` because they
7519
+ * matched a canicode-authored Figma annotation harvested in Step 5a.
7520
+ * From the analyze response's top-level `acknowledgedCount` (#371).
7521
+ */
7522
+ acknowledgedCount: z.number().int().min(0)
7523
+ });
7524
+ z.object({
7525
+ /** ✅ resolved (passthrough from `stepFourReport.resolved`). */
7526
+ X: z.number().int().min(0),
7527
+ /** 📝 annotated. */
7528
+ Y: z.number().int().min(0),
7529
+ /** 🌐 definition writes propagated. */
7530
+ Z: z.number().int().min(0),
7531
+ /** ⏭️ skipped. */
7532
+ W: z.number().int().min(0),
7533
+ /** `X + Y + Z + W` — questions the SKILL acted on in Step 4. */
7534
+ N: z.number().int().min(0),
7535
+ /** `reanalyzeResponse.issueCount` — total remaining after re-analyze. */
7536
+ V: z.number().int().min(0),
7537
+ /**
7538
+ * `reanalyzeResponse.acknowledgedCount` — the slice of `V` that carries
7539
+ * a canicode annotation (counted at half weight by the density score
7540
+ * per #371, but still surfaced as remaining).
7541
+ */
7542
+ V_ack: z.number().int().min(0),
7543
+ /** `V - V_ack` — issues with no annotation; the user's follow-up backlog. */
7544
+ V_open: z.number().int().min(0)
7545
+ });
7546
+
7547
+ // src/core/roundtrip/compute-roundtrip-tally.ts
7548
+ function computeRoundtripTally(args) {
7549
+ const { stepFourReport, reanalyzeResponse } = args;
7550
+ const { resolved, annotated, definitionWritten, skipped } = stepFourReport;
7551
+ const { issueCount, acknowledgedCount } = reanalyzeResponse;
7552
+ if (acknowledgedCount > issueCount) {
7553
+ throw new Error(
7554
+ `computeRoundtripTally: reanalyzeResponse.acknowledgedCount (${acknowledgedCount}) cannot exceed issueCount (${issueCount}). Acknowledged issues are a subset of remaining issues.`
7555
+ );
7556
+ }
7557
+ return {
7558
+ X: resolved,
7559
+ Y: annotated,
7560
+ Z: definitionWritten,
7561
+ W: skipped,
7562
+ N: resolved + annotated + definitionWritten + skipped,
7563
+ V: issueCount,
7564
+ V_ack: acknowledgedCount,
7565
+ V_open: issueCount - acknowledgedCount
7566
+ };
7567
+ }
7568
+
7569
+ // src/cli/commands/roundtrip-tally.ts
7570
+ var RoundtripTallyCliOptionsSchema = z.object({
7571
+ analyze: z.string().min(1),
7572
+ step4: z.string().min(1)
7573
+ });
7574
+ function parseJsonFile(label, path, raw) {
7575
+ try {
7576
+ return JSON.parse(raw);
7577
+ } catch (err) {
7578
+ throw new Error(
7579
+ `roundtrip-tally: ${label} is not valid JSON (${path})`,
7580
+ { cause: err }
7581
+ );
7582
+ }
7583
+ }
7584
+ async function readUtf8File(label, path) {
7585
+ try {
7586
+ return await readFile(path, "utf-8");
7587
+ } catch (err) {
7588
+ const detail = err instanceof Error ? err.message : String(err);
7589
+ throw new Error(`roundtrip-tally: cannot read ${label} (${path}): ${detail}`, {
7590
+ cause: err
7591
+ });
7592
+ }
7593
+ }
7594
+ async function computeRoundtripTallyFromSavedFiles(args) {
7595
+ const [analyzeRaw, step4Raw] = await Promise.all([
7596
+ readUtf8File("--analyze", args.analyzePath),
7597
+ readUtf8File("--step4", args.step4Path)
7598
+ ]);
7599
+ const analyzeParsed = parseJsonFile("--analyze", args.analyzePath, analyzeRaw);
7600
+ const step4Parsed = parseJsonFile("--step4", args.step4Path, step4Raw);
7601
+ const reanalyzeResult = ReanalyzeForTallySchema.safeParse(analyzeParsed);
7602
+ if (!reanalyzeResult.success) {
7603
+ const msg = reanalyzeResult.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`).join("; ");
7604
+ throw new Error(
7605
+ `roundtrip-tally: --analyze must include issueCount and acknowledgedCount (${args.analyzePath}): ${msg}`
7606
+ );
7607
+ }
7608
+ const stepFourResult = StepFourReportSchema.safeParse(step4Parsed);
7609
+ if (!stepFourResult.success) {
7610
+ const msg = stepFourResult.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`).join("; ");
7611
+ throw new Error(
7612
+ `roundtrip-tally: --step4 must match StepFourReport (${args.step4Path}): ${msg}`
7613
+ );
7614
+ }
7615
+ return computeRoundtripTally({
7616
+ stepFourReport: stepFourResult.data,
7617
+ reanalyzeResponse: reanalyzeResult.data
7618
+ });
7619
+ }
7620
+ function registerRoundtripTally(cli2) {
7621
+ cli2.command(
7622
+ "roundtrip-tally",
7623
+ "Print the Step 5 roundtrip tally from re-analyze JSON and Step 4 outcome counts"
7624
+ ).option("--analyze <path>", "Path to re-analyze JSON (`canicode analyze --json` output)").option("--step4 <path>", "Path to Step 4 structured counts (resolved / annotated / definitionWritten / skipped)").example(" canicode roundtrip-tally --analyze ./reanalyze.json --step4 ./step4-report.json").action(async (rawOptions) => {
7625
+ const parsed = RoundtripTallyCliOptionsSchema.safeParse(rawOptions);
7626
+ if (!parsed.success) {
7627
+ const msg = parsed.error.issues.map((i) => `--${i.path.join(".")}: ${i.message}`).join("\n");
7628
+ console.error(`
7629
+ roundtrip-tally requires --analyze and --step4:
7630
+ ${msg}`);
7631
+ process.exit(1);
7632
+ }
7633
+ try {
7634
+ const tally = await computeRoundtripTallyFromSavedFiles({
7635
+ analyzePath: parsed.data.analyze,
7636
+ step4Path: parsed.data.step4
7637
+ });
7638
+ console.log(JSON.stringify(tally, null, 2));
7639
+ trackEvent(EVENTS.ROUNDTRIP_TALLY);
7640
+ } catch (err) {
7641
+ if (err instanceof Error) {
7642
+ trackError(err, { command: "roundtrip-tally" });
7643
+ console.error(err.message);
7644
+ } else {
7645
+ console.error(err);
7646
+ }
7647
+ process.exit(1);
7648
+ }
7649
+ });
7650
+ }
6767
7651
 
6768
7652
  // src/cli/internal-commands.ts
6769
7653
  var INTERNAL_COMMANDS = [
@@ -6871,7 +7755,16 @@ var CalibrationConfigSchema = z.object({
6871
7755
  maxConversionNodes: z.number().int().positive().default(20),
6872
7756
  samplingStrategy: SamplingStrategySchema.default("top-issues"),
6873
7757
  outputPath: z.string().default("logs/calibration/calibration-report.md"),
6874
- runDir: z.string().optional()
7758
+ runDir: z.string().optional(),
7759
+ /**
7760
+ * #404: Explicit analysis scope for the calibration run. When omitted,
7761
+ * the orchestrator (`scripts/calibrate.ts`) injects `"page"` as the
7762
+ * policy default — `fixtures/done/*` are conceptually pages even though
7763
+ * they are stored as `COMPONENT` variants ("Platform=Desktop" etc.) and
7764
+ * would otherwise auto-detect as component scope. A `.scope` file in
7765
+ * the fixture directory overrides the default per-fixture.
7766
+ */
7767
+ scope: AnalysisScopeSchema.optional()
6875
7768
  });
6876
7769
 
6877
7770
  // src/agents/analysis-agent.ts
@@ -7832,7 +8725,10 @@ function buildRuleScoresMap() {
7832
8725
  async function runCalibrationAnalyze(config2) {
7833
8726
  const parsed = CalibrationConfigSchema.parse(config2);
7834
8727
  const { file, fileKey, nodeId } = await loadFile2(parsed.input, parsed.token);
7835
- const analyzeOptions = nodeId ? { targetNodeId: nodeId } : {};
8728
+ const analyzeOptions = {
8729
+ ...nodeId ? { targetNodeId: nodeId } : {},
8730
+ ...parsed.scope ? { scope: parsed.scope } : {}
8731
+ };
7836
8732
  const analysisResult = analyzeFile(file, analyzeOptions);
7837
8733
  const analysisOutput = runAnalysisAgent({ analysisResult });
7838
8734
  const ruleScores = {
@@ -7953,7 +8849,7 @@ function registerCalibrateAnalyze(cli2) {
7953
8849
  cli2.command(
7954
8850
  "calibrate-analyze <input>",
7955
8851
  "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) => {
8852
+ ).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
8853
  try {
7958
8854
  console.log("Running calibration analysis...");
7959
8855
  const calibConfig = {
@@ -7962,7 +8858,8 @@ function registerCalibrateAnalyze(cli2) {
7962
8858
  samplingStrategy: "top-issues",
7963
8859
  outputPath: "logs/calibration/calibration-report.md",
7964
8860
  ...options.token && { token: options.token },
7965
- ...options.targetNodeId && { targetNodeId: options.targetNodeId }
8861
+ ...options.targetNodeId && { targetNodeId: options.targetNodeId },
8862
+ ...options.scope && { scope: options.scope }
7966
8863
  };
7967
8864
  const { analysisOutput, ruleScores, fileKey } = await runCalibrationAnalyze(calibConfig);
7968
8865
  const filteredSummaries = filterConversionCandidates(
@@ -7977,6 +8874,14 @@ function registerCalibrateAnalyze(cli2) {
7977
8874
  analyzedAt: analysisOutput.analysisResult.analyzedAt,
7978
8875
  nodeCount: analysisOutput.analysisResult.nodeCount,
7979
8876
  issueCount: analysisOutput.analysisResult.issues.length,
8877
+ /**
8878
+ * #404: Resolved analysis scope for this calibration run —
8879
+ * surfaced in analysis.json so downstream diff/tuning agents
8880
+ * and post-hoc grade comparisons can see whether a run used
8881
+ * page or component scope (critical once #403 introduces
8882
+ * scope-dependent rule behavior).
8883
+ */
8884
+ scope: analysisOutput.analysisResult.scope,
7980
8885
  calibrationTier,
7981
8886
  scoreReport: analysisOutput.scoreReport,
7982
8887
  nodeIssueSummaries: filteredSummaries,
@@ -8021,9 +8926,9 @@ function registerCalibrateEvaluate(cli2) {
8021
8926
  if (!existsSync(conversionPath)) {
8022
8927
  throw new Error(`Conversion file not found: ${conversionPath}`);
8023
8928
  }
8024
- const { readFile: readFile4 } = await import('fs/promises');
8025
- const analysisData = JSON.parse(await readFile4(analysisPath, "utf-8"));
8026
- const conversionData = JSON.parse(await readFile4(conversionPath, "utf-8"));
8929
+ const { readFile: readFile5 } = await import('fs/promises');
8930
+ const analysisData = JSON.parse(await readFile5(analysisPath, "utf-8"));
8931
+ const conversionData = JSON.parse(await readFile5(conversionPath, "utf-8"));
8027
8932
  let fixtureName;
8028
8933
  if (options.runDir) {
8029
8934
  const dirName = resolve(options.runDir).split(/[/\\]/).pop() ?? "";
@@ -9881,6 +10786,7 @@ registerVisualCompare(cli);
9881
10786
  registerInit(cli);
9882
10787
  registerConfig(cli);
9883
10788
  registerListRules(cli);
10789
+ registerRoundtripTally(cli);
9884
10790
  registerCalibrateAnalyze(cli);
9885
10791
  registerCalibrateEvaluate(cli);
9886
10792
  registerCalibrateGapReport(cli);
@@ -9904,6 +10810,7 @@ cli.help((sections) => {
9904
10810
  section.body = section.body.split("\n").filter((line) => !INTERNAL_COMMANDS.some((cmd) => line.includes(cmd))).join("\n");
9905
10811
  }
9906
10812
  }
10813
+ sections.splice(1, 0, { body: ` ${pkg2.description}` });
9907
10814
  sections.push(
9908
10815
  {
9909
10816
  title: "\nSetup",
@@ -9926,7 +10833,8 @@ cli.help((sections) => {
9926
10833
  ` $ canicode analyze "https://www.figma.com/design/..." --api`,
9927
10834
  ` $ canicode analyze "https://www.figma.com/design/..." --preset strict`,
9928
10835
  ` $ canicode analyze "https://www.figma.com/design/..." --config ./my-config.json`,
9929
- ` $ canicode gotcha-survey "https://www.figma.com/design/..." --json`
10836
+ ` $ canicode gotcha-survey "https://www.figma.com/design/..." --json`,
10837
+ ` $ canicode roundtrip-tally --analyze ./reanalyze.json --step4 ./step4.json`
9930
10838
  ].join("\n")
9931
10839
  },
9932
10840
  {