canicode 0.9.1 → 0.10.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.
@@ -510,6 +510,44 @@ function getConfigsWithPreset(preset) {
510
510
  }
511
511
  return configs;
512
512
  }
513
+ var RULE_ANNOTATION_PROPERTIES = {
514
+ "missing-size-constraint": {
515
+ default: [{ type: "width" }, { type: "height" }]
516
+ },
517
+ "irregular-spacing": {
518
+ bySubType: {
519
+ gap: [{ type: "itemSpacing" }],
520
+ padding: [{ type: "padding" }]
521
+ }
522
+ },
523
+ "fixed-size-in-auto-layout": {
524
+ default: [{ type: "width" }, { type: "height" }, { type: "layoutMode" }]
525
+ },
526
+ "raw-value": {
527
+ bySubType: {
528
+ color: [{ type: "fills" }],
529
+ font: [
530
+ { type: "fontSize" },
531
+ { type: "fontFamily" },
532
+ { type: "fontWeight" },
533
+ { type: "lineHeight" }
534
+ ],
535
+ spacing: [{ type: "itemSpacing" }, { type: "padding" }]
536
+ }
537
+ },
538
+ "absolute-position-in-auto-layout": {
539
+ default: [{ type: "layoutMode" }]
540
+ }
541
+ };
542
+ function getAnnotationProperties(ruleId, subType) {
543
+ const entry = RULE_ANNOTATION_PROPERTIES[ruleId];
544
+ if (!entry) return void 0;
545
+ if (subType !== void 0 && entry.bySubType) {
546
+ const match = entry.bySubType[subType];
547
+ if (match) return match;
548
+ }
549
+ return entry.default;
550
+ }
513
551
  function getRuleOption(ruleId, optionKey, defaultValue) {
514
552
  const config2 = RULE_CONFIGS[ruleId];
515
553
  if (!config2.options) return defaultValue;
@@ -1625,8 +1663,97 @@ async function loadFromApi(fileKey, nodeId, token) {
1625
1663
  return { file, nodeId };
1626
1664
  }
1627
1665
 
1666
+ // src/core/adapters/instance-id-parser.ts
1667
+ function isInstanceChildNodeId(nodeId) {
1668
+ return nodeId.startsWith("I") && nodeId.includes(";");
1669
+ }
1670
+ function parseInstanceChildNodeId(nodeId) {
1671
+ if (!isInstanceChildNodeId(nodeId)) return null;
1672
+ const segments = nodeId.split(";");
1673
+ if (segments.length < 2) return null;
1674
+ const parentInstanceId = segments[0].replace(/^I/, "");
1675
+ const sourceNodeId = segments[segments.length - 1];
1676
+ if (!parentInstanceId || !sourceNodeId) return null;
1677
+ return { parentInstanceId, sourceNodeId };
1678
+ }
1679
+
1680
+ // src/core/gotcha/apply-context.ts
1681
+ var STRATEGY_BY_RULE = {
1682
+ // Strategy A — property modification
1683
+ "no-auto-layout": "property-mod",
1684
+ "fixed-size-in-auto-layout": "property-mod",
1685
+ "missing-size-constraint": "property-mod",
1686
+ "irregular-spacing": "property-mod",
1687
+ "non-semantic-name": "property-mod",
1688
+ // Strategy B — structural modification (needs user confirmation)
1689
+ "non-layout-container": "structural-mod",
1690
+ "deep-nesting": "structural-mod",
1691
+ "missing-component": "structural-mod",
1692
+ "detached-instance": "structural-mod",
1693
+ // Strategy C — annotation only
1694
+ "absolute-position-in-auto-layout": "annotation",
1695
+ "variant-structure-mismatch": "annotation",
1696
+ // Strategy D — auto-fix lower-severity issues from analyze output
1697
+ "non-standard-naming": "auto-fix",
1698
+ "inconsistent-naming-convention": "auto-fix",
1699
+ "raw-value": "auto-fix",
1700
+ "missing-interaction-state": "auto-fix",
1701
+ "missing-prototype": "auto-fix"
1702
+ };
1703
+ function resolveTargetProperty(ruleId, subType) {
1704
+ switch (ruleId) {
1705
+ case "no-auto-layout":
1706
+ return ["layoutMode", "itemSpacing"];
1707
+ case "fixed-size-in-auto-layout":
1708
+ if (subType === "horizontal") return "layoutSizingHorizontal";
1709
+ return ["layoutSizingHorizontal", "layoutSizingVertical"];
1710
+ case "missing-size-constraint":
1711
+ if (subType === "wrap") return "minWidth";
1712
+ if (subType === "max-width") return "maxWidth";
1713
+ return ["minWidth", "maxWidth"];
1714
+ case "irregular-spacing":
1715
+ if (subType === "gap") return "itemSpacing";
1716
+ return ["paddingTop", "paddingRight", "paddingBottom", "paddingLeft"];
1717
+ case "non-semantic-name":
1718
+ return "name";
1719
+ case "non-layout-container":
1720
+ return "layoutMode";
1721
+ case "non-standard-naming":
1722
+ case "inconsistent-naming-convention":
1723
+ return "name";
1724
+ case "deep-nesting":
1725
+ case "missing-component":
1726
+ case "detached-instance":
1727
+ case "absolute-position-in-auto-layout":
1728
+ case "variant-structure-mismatch":
1729
+ case "raw-value":
1730
+ case "missing-interaction-state":
1731
+ case "missing-prototype":
1732
+ return void 0;
1733
+ }
1734
+ }
1735
+ function computeApplyContext(violation, instanceContext) {
1736
+ const ruleId = violation.ruleId;
1737
+ const applyStrategy = STRATEGY_BY_RULE[ruleId] ?? "annotation";
1738
+ const targetProperty = resolveTargetProperty(ruleId, violation.subType);
1739
+ const annotationProperties = getAnnotationProperties(
1740
+ ruleId,
1741
+ violation.subType
1742
+ );
1743
+ const parsed = parseInstanceChildNodeId(violation.nodeId);
1744
+ const isInstanceChild = parsed !== null || isInstanceChildNodeId(violation.nodeId);
1745
+ const sourceChildId = instanceContext?.sourceNodeId ?? parsed?.sourceNodeId;
1746
+ return {
1747
+ applyStrategy,
1748
+ ...targetProperty !== void 0 ? { targetProperty } : {},
1749
+ ...annotationProperties !== void 0 ? { annotationProperties } : {},
1750
+ isInstanceChild,
1751
+ ...sourceChildId !== void 0 ? { sourceChildId } : {}
1752
+ };
1753
+ }
1754
+
1628
1755
  // package.json
1629
- var version = "0.9.1";
1756
+ var version = "0.10.1";
1630
1757
 
1631
1758
  // src/core/engine/scoring.ts
1632
1759
  function computeTotalScorePerCategory(configs) {
@@ -1655,6 +1782,9 @@ function calculateGrade(percentage) {
1655
1782
  if (percentage >= 50) return "D";
1656
1783
  return "F";
1657
1784
  }
1785
+ function isReadyForCodeGen(grade) {
1786
+ return grade === "S" || grade === "A+" || grade === "A";
1787
+ }
1658
1788
  function clamp(value, min, max) {
1659
1789
  return Math.max(min, Math.min(max, value));
1660
1790
  }
@@ -1802,14 +1932,24 @@ function buildResultJson(fileName, result, scores, options) {
1802
1932
  const id = issue.violation.ruleId;
1803
1933
  issuesByRule[id] = (issuesByRule[id] ?? 0) + 1;
1804
1934
  }
1805
- const issues = result.issues.map((issue) => ({
1806
- ruleId: issue.violation.ruleId,
1807
- ...issue.violation.subType && { subType: issue.violation.subType },
1808
- severity: issue.config.severity,
1809
- nodeId: issue.violation.nodeId,
1810
- nodePath: issue.violation.nodePath,
1811
- message: issue.violation.message
1812
- }));
1935
+ const issues = result.issues.map((issue) => {
1936
+ const applyContext = computeApplyContext(issue.violation);
1937
+ const suggestedName = issue.violation.suggestedName;
1938
+ return {
1939
+ ruleId: issue.violation.ruleId,
1940
+ ...issue.violation.subType && { subType: issue.violation.subType },
1941
+ severity: issue.config.severity,
1942
+ nodeId: issue.violation.nodeId,
1943
+ nodePath: issue.violation.nodePath,
1944
+ message: issue.violation.message,
1945
+ applyStrategy: applyContext.applyStrategy,
1946
+ ...applyContext.targetProperty !== void 0 ? { targetProperty: applyContext.targetProperty } : {},
1947
+ ...applyContext.annotationProperties !== void 0 ? { annotationProperties: applyContext.annotationProperties } : {},
1948
+ ...suggestedName !== void 0 ? { suggestedName } : {},
1949
+ isInstanceChild: applyContext.isInstanceChild,
1950
+ ...applyContext.sourceChildId !== void 0 ? { sourceChildId: applyContext.sourceChildId } : {}
1951
+ };
1952
+ });
1813
1953
  const json = {
1814
1954
  version,
1815
1955
  analyzedAt: result.analyzedAt,
@@ -1818,6 +1958,8 @@ function buildResultJson(fileName, result, scores, options) {
1818
1958
  nodeCount: result.nodeCount,
1819
1959
  maxDepth: result.maxDepth,
1820
1960
  issueCount: result.issues.length,
1961
+ isReadyForCodeGen: isReadyForCodeGen(scores.overall.grade),
1962
+ blockingIssueCount: scores.summary.blocking,
1821
1963
  scores: {
1822
1964
  overall: scores.overall,
1823
1965
  categories: scores.byCategory
@@ -1831,6 +1973,219 @@ function buildResultJson(fileName, result, scores, options) {
1831
1973
  }
1832
1974
  return json;
1833
1975
  }
1976
+ z.object({
1977
+ ruleId: z.string(),
1978
+ question: z.string(),
1979
+ hint: z.string(),
1980
+ example: z.string()
1981
+ });
1982
+ var GOTCHA_QUESTIONS = {
1983
+ // ── Pixel Critical (blocking) ──
1984
+ "no-auto-layout": {
1985
+ ruleId: "no-auto-layout",
1986
+ question: 'Frame "{nodeName}" has no Auto Layout. How should this area be laid out?',
1987
+ hint: "Describe the flex direction, gap, and alignment",
1988
+ example: "Vertical flex, gap 16px, items centered"
1989
+ },
1990
+ "absolute-position-in-auto-layout": {
1991
+ ruleId: "absolute-position-in-auto-layout",
1992
+ question: '"{nodeName}" uses absolute positioning inside an Auto Layout parent. Is this an intentional overlay, or should it flow with the layout?',
1993
+ hint: "Specify if this is a badge/overlay, or should be part of the normal flow",
1994
+ example: "This is a notification badge \u2014 position absolute, top-right corner"
1995
+ },
1996
+ "non-layout-container": {
1997
+ ruleId: "non-layout-container",
1998
+ question: '"{nodeName}" is a Group/Section used as a layout container. What layout structure should it have?',
1999
+ hint: "Describe the intended layout: flex direction, wrap, gap",
2000
+ example: "Horizontal flex, gap 12px, wrap on mobile"
2001
+ },
2002
+ // ── Responsive Critical (risk) ──
2003
+ "fixed-size-in-auto-layout": {
2004
+ ruleId: "fixed-size-in-auto-layout",
2005
+ question: '"{nodeName}" has a fixed size inside Auto Layout. Should it be responsive?',
2006
+ hint: "Specify which axis should be flexible (width, height, or both)",
2007
+ example: "Width should FILL the parent, height can stay fixed"
2008
+ },
2009
+ "missing-size-constraint": {
2010
+ ruleId: "missing-size-constraint",
2011
+ question: '"{nodeName}" uses FILL sizing without min/max constraints. What are the size boundaries?',
2012
+ hint: "Provide min-width, max-width, or both",
2013
+ example: "min-width 320px, max-width 1200px"
2014
+ },
2015
+ // ── Code Quality (risk) ──
2016
+ "missing-component": {
2017
+ ruleId: "missing-component",
2018
+ question: '"{nodeName}" appears to be a repeated structure. Should it be a reusable component?',
2019
+ hint: "Describe if this should be extracted as a component and what props it needs",
2020
+ example: "Yes, extract as ProductCard component with title, image, and price props"
2021
+ },
2022
+ "detached-instance": {
2023
+ ruleId: "detached-instance",
2024
+ question: '"{nodeName}" looks like a detached component instance. Should it use the original component or is it a new variant?',
2025
+ hint: "Specify whether to restore the component link or create a new variant",
2026
+ example: "This is a new variant \u2014 create a 'compact' variant of the original component"
2027
+ },
2028
+ "variant-structure-mismatch": {
2029
+ ruleId: "variant-structure-mismatch",
2030
+ question: '"{nodeName}" has variants with different child structures. Which structure is the canonical one?',
2031
+ hint: "Describe which variant has the correct structure, or if they should all match",
2032
+ example: "Default variant is canonical \u2014 other variants should toggle child visibility instead of adding/removing elements"
2033
+ },
2034
+ "deep-nesting": {
2035
+ ruleId: "deep-nesting",
2036
+ question: '"{nodeName}" is deeply nested. Can some intermediate layers be flattened or extracted?',
2037
+ hint: "Identify which wrapper layers are unnecessary or should become sub-components",
2038
+ example: "The inner wrapper is just for spacing \u2014 flatten it and use padding instead"
2039
+ },
2040
+ // ── Token Management ──
2041
+ "raw-value": {
2042
+ ruleId: "raw-value",
2043
+ question: '"{nodeName}" uses raw values without design tokens. What tokens should be used?',
2044
+ hint: "Specify the token names or variable references for colors, fonts, spacing, etc.",
2045
+ example: "Use $color-primary for the fill, $font-body for the text style"
2046
+ },
2047
+ "irregular-spacing": {
2048
+ ruleId: "irregular-spacing",
2049
+ question: '"{nodeName}" has spacing values that are off the design grid. What should the correct spacing be?',
2050
+ hint: "Provide the intended spacing value aligned to the grid system",
2051
+ example: "Gap should be 16px (4pt grid), not 15px"
2052
+ },
2053
+ // ── Interaction ──
2054
+ "missing-interaction-state": {
2055
+ ruleId: "missing-interaction-state",
2056
+ question: '"{nodeName}" appears interactive but is missing state variants. What interaction states are needed?',
2057
+ hint: "List the needed states: Hover, Active, Disabled, Focus",
2058
+ example: "Needs Hover (darken 10%) and Disabled (opacity 50%, no pointer events)"
2059
+ },
2060
+ "missing-prototype": {
2061
+ ruleId: "missing-prototype",
2062
+ question: '"{nodeName}" looks interactive but has no prototype interaction. What should happen on click/interaction?',
2063
+ hint: "Describe the interaction behavior: navigation, overlay, state change, etc.",
2064
+ example: "On click, navigate to the product detail page"
2065
+ },
2066
+ // ── Semantic ──
2067
+ "non-standard-naming": {
2068
+ ruleId: "non-standard-naming",
2069
+ question: '"{nodeName}" uses non-standard state names. What naming convention should be followed?',
2070
+ hint: "Specify the expected state name format (e.g., Hover, Disabled, Active)",
2071
+ example: 'Use "Hover" instead of "hover_v1", "Disabled" instead of "off"'
2072
+ },
2073
+ "non-semantic-name": {
2074
+ ruleId: "non-semantic-name",
2075
+ question: '"{nodeName}" has a non-semantic name. What is the purpose of this element?',
2076
+ hint: "Provide a descriptive name that reflects the element's role in the UI",
2077
+ example: 'Rename "Frame 12" to "HeroSection" or "ProductGrid"'
2078
+ },
2079
+ "inconsistent-naming-convention": {
2080
+ ruleId: "inconsistent-naming-convention",
2081
+ question: '"{nodeName}" uses a different naming convention than its siblings. Which convention should be used?',
2082
+ hint: "Choose one: camelCase, kebab-case, PascalCase, or Title Case",
2083
+ example: "Use PascalCase for all component layers (e.g., CardTitle, CardBody)"
2084
+ }
2085
+ };
2086
+
2087
+ // src/core/gotcha/survey-generator.ts
2088
+ var NODE_PATH_SEPARATOR = " > ";
2089
+ function generateGotchaSurvey(result, scores) {
2090
+ const grade = scores.overall.grade;
2091
+ const relevantIssues = result.issues.filter(
2092
+ (issue) => issue.config.severity === "blocking" || issue.config.severity === "risk"
2093
+ );
2094
+ const deduped = deduplicateSiblingIssues(relevantIssues);
2095
+ const sorted = stableSortBySeverity(deduped);
2096
+ const questions = sorted.map((issue) => mapToQuestion(issue, result.file)).filter((q) => q !== null);
2097
+ return {
2098
+ designGrade: grade,
2099
+ isReadyForCodeGen: isReadyForCodeGen(grade),
2100
+ questions
2101
+ };
2102
+ }
2103
+ function deduplicateSiblingIssues(issues) {
2104
+ const seen = /* @__PURE__ */ new Set();
2105
+ const result = [];
2106
+ for (const issue of issues) {
2107
+ const parentPath = getParentPath(issue.violation.nodePath);
2108
+ const key = `${parentPath}||${issue.violation.ruleId}`;
2109
+ if (!seen.has(key)) {
2110
+ seen.add(key);
2111
+ result.push(issue);
2112
+ }
2113
+ }
2114
+ return result;
2115
+ }
2116
+ function getParentPath(nodePath) {
2117
+ const lastSep = nodePath.lastIndexOf(NODE_PATH_SEPARATOR);
2118
+ if (lastSep === -1) return "";
2119
+ return nodePath.slice(0, lastSep);
2120
+ }
2121
+ function getNodeName(nodePath) {
2122
+ const lastSep = nodePath.lastIndexOf(NODE_PATH_SEPARATOR);
2123
+ if (lastSep === -1) return nodePath;
2124
+ return nodePath.slice(lastSep + NODE_PATH_SEPARATOR.length);
2125
+ }
2126
+ function stableSortBySeverity(issues) {
2127
+ const blocking = [];
2128
+ const risk = [];
2129
+ for (const issue of issues) {
2130
+ if (issue.config.severity === "blocking") {
2131
+ blocking.push(issue);
2132
+ } else {
2133
+ risk.push(issue);
2134
+ }
2135
+ }
2136
+ return [...blocking, ...risk];
2137
+ }
2138
+ function mapToQuestion(issue, file) {
2139
+ const ruleId = issue.violation.ruleId;
2140
+ const template = GOTCHA_QUESTIONS[ruleId];
2141
+ if (!template) return null;
2142
+ const nodeName = getNodeName(issue.violation.nodePath);
2143
+ const instanceContext = buildInstanceContext(issue.violation.nodeId, file);
2144
+ const applyContext = computeApplyContext(
2145
+ issue.violation,
2146
+ instanceContext ?? void 0
2147
+ );
2148
+ const suggestedName = issue.violation.suggestedName;
2149
+ return {
2150
+ nodeId: issue.violation.nodeId,
2151
+ nodeName,
2152
+ ruleId,
2153
+ severity: issue.config.severity,
2154
+ question: template.question.replace("{nodeName}", nodeName),
2155
+ hint: template.hint,
2156
+ example: template.example,
2157
+ ...instanceContext ? { instanceContext } : {},
2158
+ applyStrategy: applyContext.applyStrategy,
2159
+ ...applyContext.targetProperty !== void 0 ? { targetProperty: applyContext.targetProperty } : {},
2160
+ ...applyContext.annotationProperties !== void 0 ? { annotationProperties: applyContext.annotationProperties } : {},
2161
+ ...suggestedName !== void 0 ? { suggestedName } : {},
2162
+ isInstanceChild: applyContext.isInstanceChild,
2163
+ ...applyContext.sourceChildId !== void 0 ? { sourceChildId: applyContext.sourceChildId } : {}
2164
+ };
2165
+ }
2166
+ function buildInstanceContext(nodeId, file) {
2167
+ const parts = parseInstanceChildNodeId(nodeId);
2168
+ if (!parts) return null;
2169
+ const parentInstance = findNodeById2(file.document, parts.parentInstanceId);
2170
+ const componentId = parentInstance?.componentId;
2171
+ const componentName = componentId ? file.components[componentId]?.name : void 0;
2172
+ return {
2173
+ parentInstanceNodeId: parts.parentInstanceId,
2174
+ sourceNodeId: parts.sourceNodeId,
2175
+ ...componentId ? { sourceComponentId: componentId } : {},
2176
+ ...componentName ? { sourceComponentName: componentName } : {}
2177
+ };
2178
+ }
2179
+ function findNodeById2(node, id) {
2180
+ if (node.id === id) return node;
2181
+ if (node.children) {
2182
+ for (const child of node.children) {
2183
+ const found = findNodeById2(child, id);
2184
+ if (found) return found;
2185
+ }
2186
+ }
2187
+ return null;
2188
+ }
1834
2189
 
1835
2190
  // src/core/ui-constants.ts
1836
2191
  var GAUGE_R = 54;
@@ -2998,7 +3353,14 @@ var EVENTS = {
2998
3353
  MCP_TOOL_CALLED: `${EVENT_PREFIX}mcp_tool_called`,
2999
3354
  // CLI
3000
3355
  CLI_COMMAND: `${EVENT_PREFIX}cli_command`,
3001
- CLI_INIT: `${EVENT_PREFIX}cli_init`
3356
+ CLI_INIT: `${EVENT_PREFIX}cli_init`,
3357
+ // Roundtrip (ADR-012)
3358
+ // Wiring point for the roundtrip helper's `telemetry` callback. No Node-side
3359
+ // orchestrator reads this yet — the helper ships in a sandbox-pure IIFE that
3360
+ // cannot import `core/monitoring` directly, so the event fires through a
3361
+ // caller-supplied callback. Define the typed name here so a future consumer
3362
+ // has a single place to wire it up.
3363
+ ROUNDTRIP_DEFINITION_WRITE_SKIPPED: `${EVENT_PREFIX}roundtrip_definition_write_skipped`
3002
3364
  };
3003
3365
 
3004
3366
  // src/core/monitoring/capture.ts
@@ -4113,6 +4475,10 @@ defineRule({
4113
4475
  });
4114
4476
 
4115
4477
  // src/core/rules/naming/index.ts
4478
+ function capitalize(s) {
4479
+ if (!s) return s;
4480
+ return s.charAt(0).toUpperCase() + s.slice(1);
4481
+ }
4116
4482
  function detectNamingConvention(name) {
4117
4483
  if (/^[a-z]+(-[a-z]+)*$/.test(name)) return "kebab-case";
4118
4484
  if (/^[a-z]+(_[a-z]+)*$/.test(name)) return "snake_case";
@@ -4238,6 +4604,7 @@ var inconsistentNamingConventionCheck = (node, context) => {
4238
4604
  ruleId: inconsistentNamingConventionDef.id,
4239
4605
  nodeId: node.id,
4240
4606
  nodePath: context.path.join(" > "),
4607
+ suggestedName: suggested,
4241
4608
  ...inconsistentNamingMsg(node.name, nodeConvention, dominantConvention, suggested)
4242
4609
  };
4243
4610
  }
@@ -4275,6 +4642,7 @@ var nonStandardNamingCheck = (node, context) => {
4275
4642
  subType: "state-name",
4276
4643
  nodeId: node.id,
4277
4644
  nodePath: context.path.join(" > "),
4645
+ suggestedName: capitalize(suggestion),
4278
4646
  ...nonStandardNamingMsg.stateName(node.name, opt, suggestion)
4279
4647
  };
4280
4648
  }
@@ -4531,6 +4899,78 @@ Provide a Figma URL or fixture path via the input parameter. Requires FIGMA_TOKE
4531
4899
  }
4532
4900
  }
4533
4901
  );
4902
+ server.tool(
4903
+ "gotcha-survey",
4904
+ `Generate a gotcha survey from a Figma design analysis.
4905
+
4906
+ Analyzes the design and returns a GotchaSurvey JSON with designGrade, isReadyForCodeGen, and questions[].
4907
+ When isReadyForCodeGen is true, questions will be empty (no survey needed).
4908
+
4909
+ Provide a Figma URL or fixture path via the input parameter. Requires FIGMA_TOKEN env var or the token parameter for live Figma URLs.`,
4910
+ {
4911
+ input: z.string().describe("Figma URL or local fixture path. Requires FIGMA_TOKEN for live URLs."),
4912
+ token: z.string().optional().describe("Figma API token (falls back to FIGMA_TOKEN env var)"),
4913
+ preset: z.enum(["relaxed", "dev-friendly", "ai-ready", "strict"]).optional().describe("Analysis preset"),
4914
+ targetNodeId: z.string().optional().describe("Scope analysis to a specific node ID"),
4915
+ configPath: z.string().optional().describe("Path to config JSON file for rule overrides")
4916
+ },
4917
+ {
4918
+ readOnlyHint: false,
4919
+ destructiveHint: false,
4920
+ openWorldHint: true,
4921
+ title: "Gotcha Survey"
4922
+ },
4923
+ async ({ input, token, preset, targetNodeId, configPath }) => {
4924
+ trackEvent(EVENTS.MCP_TOOL_CALLED, { tool: "gotcha-survey" });
4925
+ try {
4926
+ const { file, nodeId } = await loadFile(input, token);
4927
+ const effectiveNodeId = targetNodeId ?? nodeId;
4928
+ let configs = preset ? { ...getConfigsWithPreset(preset) } : { ...RULE_CONFIGS };
4929
+ if (configPath) {
4930
+ const configFile = await loadConfigFile(configPath);
4931
+ configs = mergeConfigs(configs, configFile);
4932
+ }
4933
+ const result = analyzeFile(file, {
4934
+ configs,
4935
+ ...effectiveNodeId ? { targetNodeId: effectiveNodeId } : {}
4936
+ });
4937
+ const scores = calculateScores(result, configs);
4938
+ const survey = generateGotchaSurvey(result, scores);
4939
+ trackEvent(EVENTS.ANALYSIS_COMPLETED, {
4940
+ nodeCount: result.nodeCount,
4941
+ issueCount: result.issues.length,
4942
+ grade: scores.overall.grade,
4943
+ percentage: scores.overall.percentage,
4944
+ source: isJsonFile(input) || isFixtureDir(input) ? "fixture" : "figma"
4945
+ });
4946
+ return {
4947
+ content: [
4948
+ {
4949
+ type: "text",
4950
+ text: JSON.stringify(survey, null, 2)
4951
+ }
4952
+ ]
4953
+ };
4954
+ } catch (error) {
4955
+ trackError(
4956
+ error instanceof Error ? error : new Error(String(error)),
4957
+ { tool: "gotcha-survey" }
4958
+ );
4959
+ trackEvent(EVENTS.ANALYSIS_FAILED, {
4960
+ error: error instanceof Error ? error.message : String(error)
4961
+ });
4962
+ return {
4963
+ content: [
4964
+ {
4965
+ type: "text",
4966
+ text: `Error: ${error instanceof Error ? error.message : String(error)}`
4967
+ }
4968
+ ],
4969
+ isError: true
4970
+ };
4971
+ }
4972
+ }
4973
+ );
4534
4974
  server.tool(
4535
4975
  "list-rules",
4536
4976
  "List all available analysis rules with their current configuration",
@@ -4584,6 +5024,7 @@ server.tool(
4584
5024
 
4585
5025
  Available topics:
4586
5026
  - setup: Installation and token configuration
5027
+ - scoring: Scoring model formula (density+diversity, severity weights, grades)
4587
5028
  - rules: All rule IDs with default scores and severity
4588
5029
  - config: Config overrides (scores, severity, node exclusions, thresholds)
4589
5030
  - visual-compare: Pixel-level comparison between Figma and AI-generated code
@@ -4592,7 +5033,7 @@ Available topics:
4592
5033
 
4593
5034
  Use this when the user asks about how to use canicode, configuration, rules, visual comparison, or any feature.`,
4594
5035
  {
4595
- topic: z.enum(["all", "setup", "rules", "config", "visual-compare", "design-tree"]).optional().describe("Topic to retrieve. Default: all")
5036
+ topic: z.enum(["all", "setup", "scoring", "rules", "config", "visual-compare", "design-tree"]).optional().describe("Topic to retrieve. Default: all")
4596
5037
  },
4597
5038
  {
4598
5039
  readOnlyHint: true,
@@ -4618,7 +5059,18 @@ Get your token: Figma \u2192 Settings \u2192 Security \u2192 Personal access tok
4618
5059
  claude mcp add canicode -e FIGMA_TOKEN=figd_xxxxxxxxxxxxx -- npx -y -p canicode canicode-mcp
4619
5060
  \`\`\`
4620
5061
 
4621
- Requires FIGMA_TOKEN for live Figma URL analysis.`,
5062
+ Requires FIGMA_TOKEN for live Figma URL analysis.
5063
+
5064
+ ## CLI only (no MCP server)
5065
+
5066
+ User-facing MCP tools have CLI equivalents with the same JSON shape, so skills and scripts can run without installing the canicode MCP server:
5067
+
5068
+ \`\`\`bash
5069
+ npx canicode analyze <input>
5070
+ npx canicode gotcha-survey <input> --json
5071
+ \`\`\`
5072
+
5073
+ The CLI spawns per call (slower than the long-running MCP server) but needs no extra setup beyond \`FIGMA_TOKEN\`.`,
4622
5074
  "visual-compare": `# Visual Compare
4623
5075
 
4624
5076
  Pixel-level comparison between Figma design and AI-generated code.
@@ -4664,6 +5116,36 @@ Viewport and device pixel ratio are auto-inferred from the Figma PNG dimensions
4664
5116
  ## Requirements
4665
5117
  - npx playwright install chromium
4666
5118
  - Figma API token with read access`,
5119
+ "scoring": `# Scoring Model
5120
+
5121
+ Score = density (70%) + diversity (30%), averaged across 6 categories.
5122
+
5123
+ ## Severity weights
5124
+
5125
+ | Severity | Weight |
5126
+ |----------|--------|
5127
+ | blocking | 3.0x |
5128
+ | risk | 2.0x |
5129
+ | missing-info | 1.0x |
5130
+ | suggestion | 0.5x |
5131
+
5132
+ ## Grades
5133
+
5134
+ | Grade | Min score |
5135
+ |-------|-----------|
5136
+ | S | 95 |
5137
+ | A+ | 90 |
5138
+ | A | 85 |
5139
+ | B+ | 80 |
5140
+ | B | 75 |
5141
+ | C+ | 70 |
5142
+ | C | 65 |
5143
+ | D | 50 |
5144
+ | F | <50 |
5145
+
5146
+ Floor: 5% minimum.
5147
+
5148
+ Full documentation: https://github.com/let-sunny/canicode/wiki/Scoring-Model`,
4667
5149
  "design-tree": `# Design Tree
4668
5150
 
4669
5151
  Generate a DOM-like design tree from a Figma fixture. Converts the node tree to a concise text format with inline CSS styles \u2014 50-100x smaller than raw JSON.