canicode 0.10.3 → 0.10.4

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.
@@ -615,6 +615,14 @@ function defineRule(rule) {
615
615
  ruleRegistry.register(rule);
616
616
  return rule;
617
617
  }
618
+ var AcknowledgmentSchema = z.object({
619
+ nodeId: z.string(),
620
+ ruleId: z.string()
621
+ });
622
+ z.array(AcknowledgmentSchema);
623
+ function normalizeNodeId(id) {
624
+ return id.replace(/-/g, ":");
625
+ }
618
626
 
619
627
  // src/core/engine/rule-engine.ts
620
628
  function calculateMaxDepth(node, currentDepth = 0) {
@@ -665,6 +673,7 @@ var RuleEngine = class {
665
673
  targetNodeId;
666
674
  excludeNamePattern;
667
675
  excludeNodeTypes;
676
+ acknowledgments;
668
677
  constructor(options = {}) {
669
678
  this.configs = options.configs ?? RULE_CONFIGS;
670
679
  this.enabledRuleIds = options.enabledRules ? new Set(options.enabledRules) : null;
@@ -672,6 +681,11 @@ var RuleEngine = class {
672
681
  this.targetNodeId = options.targetNodeId;
673
682
  this.excludeNamePattern = options.excludeNodeNames && options.excludeNodeNames.length > 0 ? new RegExp(`\\b(${options.excludeNodeNames.join("|")})\\b`, "i") : null;
674
683
  this.excludeNodeTypes = options.excludeNodeTypes && options.excludeNodeTypes.length > 0 ? new Set(options.excludeNodeTypes) : null;
684
+ this.acknowledgments = new Set(
685
+ (options.acknowledgments ?? []).map(
686
+ (a) => `${normalizeNodeId(a.nodeId)}::${a.ruleId}`
687
+ )
688
+ );
675
689
  }
676
690
  /**
677
691
  * Analyze a Figma file and return issues
@@ -706,6 +720,14 @@ var RuleEngine = class {
706
720
  void 0,
707
721
  void 0
708
722
  );
723
+ if (this.acknowledgments.size > 0) {
724
+ for (const issue of issues) {
725
+ const key = `${normalizeNodeId(issue.violation.nodeId)}::${issue.violation.ruleId}`;
726
+ if (this.acknowledgments.has(key)) {
727
+ issue.acknowledged = true;
728
+ }
729
+ }
730
+ }
709
731
  return {
710
732
  file,
711
733
  issues,
@@ -1708,8 +1730,6 @@ function resolveTargetProperty(ruleId, subType) {
1708
1730
  if (subType === "horizontal") return "layoutSizingHorizontal";
1709
1731
  return ["layoutSizingHorizontal", "layoutSizingVertical"];
1710
1732
  case "missing-size-constraint":
1711
- if (subType === "wrap") return "minWidth";
1712
- if (subType === "max-width") return "maxWidth";
1713
1733
  return ["minWidth", "maxWidth"];
1714
1734
  case "irregular-spacing":
1715
1735
  if (subType === "gap") return "itemSpacing";
@@ -1753,7 +1773,7 @@ function computeApplyContext(violation, instanceContext) {
1753
1773
  }
1754
1774
 
1755
1775
  // package.json
1756
- var version = "0.10.3";
1776
+ var version = "0.10.4";
1757
1777
 
1758
1778
  // src/core/engine/scoring.ts
1759
1779
  function computeTotalScorePerCategory(configs) {
@@ -1809,7 +1829,8 @@ function calculateScores(result, configs) {
1809
1829
  uniqueRulesPerCategory.get(category).add(ruleId);
1810
1830
  ruleScorePerCategory.get(category).set(ruleId, Math.abs(issue.config.score));
1811
1831
  const ruleCountMap = ruleIssueCountPerCategory.get(category);
1812
- ruleCountMap.set(ruleId, (ruleCountMap.get(ruleId) ?? 0) + 1);
1832
+ const weight = issue.acknowledged === true ? 0.5 : 1;
1833
+ ruleCountMap.set(ruleId, (ruleCountMap.get(ruleId) ?? 0) + weight);
1813
1834
  }
1814
1835
  for (const category of CATEGORIES) {
1815
1836
  const ruleCountMap = ruleIssueCountPerCategory.get(category);
@@ -1856,7 +1877,8 @@ function calculateScores(result, configs) {
1856
1877
  risk: 0,
1857
1878
  missingInfo: 0,
1858
1879
  suggestion: 0,
1859
- nodeCount
1880
+ nodeCount,
1881
+ acknowledgedCount: 0
1860
1882
  };
1861
1883
  for (const issue of result.issues) {
1862
1884
  switch (issue.config.severity) {
@@ -1873,6 +1895,7 @@ function calculateScores(result, configs) {
1873
1895
  summary.suggestion++;
1874
1896
  break;
1875
1897
  }
1898
+ if (issue.acknowledged === true) summary.acknowledgedCount++;
1876
1899
  }
1877
1900
  return {
1878
1901
  overall: {
@@ -1923,7 +1946,14 @@ function formatScoreSummary(report) {
1923
1946
  lines.push(` Risk: ${report.summary.risk}`);
1924
1947
  lines.push(` Missing Info: ${report.summary.missingInfo}`);
1925
1948
  lines.push(` Suggestion: ${report.summary.suggestion}`);
1926
- lines.push(` Total: ${report.summary.totalIssues}`);
1949
+ if (report.summary.acknowledgedCount > 0) {
1950
+ const unaddressed = report.summary.totalIssues - report.summary.acknowledgedCount;
1951
+ lines.push(
1952
+ ` Total: ${report.summary.totalIssues} (${report.summary.acknowledgedCount} acknowledged via canicode annotations / ${unaddressed} unaddressed)`
1953
+ );
1954
+ } else {
1955
+ lines.push(` Total: ${report.summary.totalIssues}`);
1956
+ }
1927
1957
  return lines.join("\n");
1928
1958
  }
1929
1959
  function buildResultJson(fileName, result, scores, options) {
@@ -1947,17 +1977,20 @@ function buildResultJson(fileName, result, scores, options) {
1947
1977
  ...applyContext.annotationProperties !== void 0 ? { annotationProperties: applyContext.annotationProperties } : {},
1948
1978
  ...suggestedName !== void 0 ? { suggestedName } : {},
1949
1979
  isInstanceChild: applyContext.isInstanceChild,
1950
- ...applyContext.sourceChildId !== void 0 ? { sourceChildId: applyContext.sourceChildId } : {}
1980
+ ...applyContext.sourceChildId !== void 0 ? { sourceChildId: applyContext.sourceChildId } : {},
1981
+ ...issue.acknowledged === true ? { acknowledged: true } : {}
1951
1982
  };
1952
1983
  });
1953
1984
  const json = {
1954
1985
  version,
1955
1986
  analyzedAt: result.analyzedAt,
1956
1987
  ...options?.fileKey && { fileKey: options.fileKey },
1988
+ ...options?.designKey && { designKey: options.designKey },
1957
1989
  fileName,
1958
1990
  nodeCount: result.nodeCount,
1959
1991
  maxDepth: result.maxDepth,
1960
1992
  issueCount: result.issues.length,
1993
+ acknowledgedCount: scores.summary.acknowledgedCount,
1961
1994
  isReadyForCodeGen: isReadyForCodeGen(scores.overall.grade),
1962
1995
  blockingIssueCount: scores.summary.blocking,
1963
1996
  scores: {
@@ -2084,9 +2117,72 @@ var GOTCHA_QUESTIONS = {
2084
2117
  }
2085
2118
  };
2086
2119
 
2120
+ // src/core/gotcha/group-and-batch-questions.ts
2121
+ var BATCHABLE_RULE_IDS = [
2122
+ "missing-size-constraint",
2123
+ "irregular-spacing",
2124
+ "no-auto-layout",
2125
+ "fixed-size-in-auto-layout"
2126
+ ];
2127
+ var BATCHABLE_SET = new Set(BATCHABLE_RULE_IDS);
2128
+ var NO_SOURCE_SENTINEL = "_no-source";
2129
+ function groupAndBatchSurveyQuestions(questions) {
2130
+ if (questions.length === 0) {
2131
+ return { groups: [] };
2132
+ }
2133
+ const sorted = [...questions].sort(compareQuestions);
2134
+ const groups = [];
2135
+ let currentGroup = null;
2136
+ let lastGroupKey = null;
2137
+ for (const question of sorted) {
2138
+ const groupKey = sourceComponentKey(question);
2139
+ if (currentGroup === null || groupKey !== lastGroupKey) {
2140
+ currentGroup = {
2141
+ instanceContext: question.instanceContext ?? null,
2142
+ batches: []
2143
+ };
2144
+ groups.push(currentGroup);
2145
+ lastGroupKey = groupKey;
2146
+ }
2147
+ pushIntoBatch(currentGroup, question);
2148
+ }
2149
+ return { groups };
2150
+ }
2151
+ function compareQuestions(a, b) {
2152
+ const aKey = sourceComponentKey(a);
2153
+ const bKey = sourceComponentKey(b);
2154
+ if (aKey !== bKey) {
2155
+ if (aKey === NO_SOURCE_SENTINEL) return 1;
2156
+ if (bKey === NO_SOURCE_SENTINEL) return -1;
2157
+ return aKey.localeCompare(bKey);
2158
+ }
2159
+ if (a.ruleId !== b.ruleId) return a.ruleId.localeCompare(b.ruleId);
2160
+ if (a.nodeName !== b.nodeName) return a.nodeName.localeCompare(b.nodeName);
2161
+ return a.nodeId.localeCompare(b.nodeId);
2162
+ }
2163
+ function sourceComponentKey(question) {
2164
+ return question.instanceContext?.sourceComponentId ?? NO_SOURCE_SENTINEL;
2165
+ }
2166
+ function pushIntoBatch(group, question) {
2167
+ const sceneWeight = Math.max(question.replicas ?? 1, 1);
2168
+ const isBatchable = BATCHABLE_SET.has(question.ruleId);
2169
+ const last = group.batches.at(-1);
2170
+ if (last !== void 0 && last.ruleId === question.ruleId && isBatchable && last.batchable) {
2171
+ last.questions.push(question);
2172
+ last.totalScenes += sceneWeight;
2173
+ return;
2174
+ }
2175
+ group.batches.push({
2176
+ ruleId: question.ruleId,
2177
+ batchable: isBatchable,
2178
+ questions: [question],
2179
+ totalScenes: sceneWeight
2180
+ });
2181
+ }
2182
+
2087
2183
  // src/core/gotcha/survey-generator.ts
2088
2184
  var NODE_PATH_SEPARATOR = " > ";
2089
- function generateGotchaSurvey(result, scores) {
2185
+ function generateGotchaSurvey(result, scores, options = {}) {
2090
2186
  const grade = scores.overall.grade;
2091
2187
  const relevantIssues = result.issues.filter(
2092
2188
  (issue) => issue.config.severity === "blocking" || issue.config.severity === "risk"
@@ -2095,16 +2191,23 @@ function generateGotchaSurvey(result, scores) {
2095
2191
  const sorted = stableSortBySeverity(deduped);
2096
2192
  const mapped = sorted.map((issue) => mapToQuestion(issue, result.file)).filter((q) => q !== null);
2097
2193
  const questions = deduplicateBySourceComponent(mapped);
2194
+ const groupedQuestions = groupAndBatchSurveyQuestions(questions);
2098
2195
  return {
2099
2196
  designGrade: grade,
2100
2197
  isReadyForCodeGen: isReadyForCodeGen(grade),
2101
- questions
2198
+ questions,
2199
+ groupedQuestions,
2200
+ designKey: options.designKey ?? ""
2102
2201
  };
2103
2202
  }
2104
2203
  function deduplicateSiblingIssues(issues) {
2105
2204
  const seen = /* @__PURE__ */ new Set();
2106
2205
  const result = [];
2107
2206
  for (const issue of issues) {
2207
+ if (isInstanceChildNodeId(issue.violation.nodeId)) {
2208
+ result.push(issue);
2209
+ continue;
2210
+ }
2108
2211
  const parentPath = getParentPath(issue.violation.nodePath);
2109
2212
  const key = `${parentPath}||${issue.violation.ruleId}`;
2110
2213
  if (!seen.has(key)) {
@@ -3539,6 +3642,18 @@ function shutdownMonitoring() {
3539
3642
  // src/core/monitoring/keys.ts
3540
3643
  var POSTHOG_API_KEY = "phc_rBFeG140KqJLpUnlpYDEFgdMM6JozZeqQsf9twXf5Dq" ;
3541
3644
  var SENTRY_DSN = "https://80a836a8300b25f17ef5bbf23afb5b3a@o4511080656207872.ingest.us.sentry.io/4511080661319680" ;
3645
+ function isFigmaUrl2(input) {
3646
+ return input.includes("figma.com/");
3647
+ }
3648
+ z.string();
3649
+ function computeDesignKey(input) {
3650
+ if (isFigmaUrl2(input)) {
3651
+ const { fileKey, nodeId } = parseFigmaUrl(input);
3652
+ if (!nodeId) return fileKey;
3653
+ return `${fileKey}#${nodeId.replace(/-/g, ":")}`;
3654
+ }
3655
+ return resolve(input);
3656
+ }
3542
3657
 
3543
3658
  // src/core/rules/node-semantics.ts
3544
3659
  function isContainerNode(node) {
@@ -4645,6 +4760,7 @@ var inconsistentNamingConventionCheck = (node, context) => {
4645
4760
  if (nodeConvention && nodeConvention !== dominantConvention && maxCount >= 2) {
4646
4761
  if (isCompatible(nodeConvention, dominantConvention, node.name)) return null;
4647
4762
  const suggested = convertName(node.name, dominantConvention);
4763
+ if (suggested === node.name) return null;
4648
4764
  return {
4649
4765
  ruleId: inconsistentNamingConventionDef.id,
4650
4766
  nodeId: node.id,
@@ -4882,7 +4998,9 @@ Provide a Figma URL or fixture path via the input parameter. Requires FIGMA_TOKE
4882
4998
  token: z.string().optional().describe("Figma API token (falls back to FIGMA_TOKEN env var)"),
4883
4999
  preset: z.enum(["relaxed", "dev-friendly", "ai-ready", "strict"]).optional().describe("Analysis preset"),
4884
5000
  targetNodeId: z.string().optional().describe("Scope analysis to a specific node ID"),
4885
- configPath: z.string().optional().describe("Path to config JSON file for rule overrides")
5001
+ configPath: z.string().optional().describe("Path to config JSON file for rule overrides"),
5002
+ openReport: z.boolean().optional().describe("Open the generated HTML report in the user's browser. Defaults to false \u2014 opt in only when a visible report is the explicit user request (#365). The HTML file is always written to disk regardless."),
5003
+ acknowledgments: z.array(AcknowledgmentSchema).optional().describe("(#371) Pre-resolved [{ nodeId, ruleId }] pairs harvested from canicode-authored Figma annotations (e.g. via the `readCanicodeAcknowledgments` Plugin helper inside a use_figma batch). Matching issues are flagged `acknowledged: true` and contribute half weight to the density score so re-analyze surfaces movement after a roundtrip even under ADR-012's annotate-by-default policy.")
4886
5004
  },
4887
5005
  {
4888
5006
  readOnlyHint: false,
@@ -4890,7 +5008,7 @@ Provide a Figma URL or fixture path via the input parameter. Requires FIGMA_TOKE
4890
5008
  openWorldHint: true,
4891
5009
  title: "Analyze Figma Design"
4892
5010
  },
4893
- async ({ input, token, preset, targetNodeId, configPath }) => {
5011
+ async ({ input, token, preset, targetNodeId, configPath, openReport, acknowledgments }) => {
4894
5012
  trackEvent(EVENTS.MCP_TOOL_CALLED, { tool: "analyze" });
4895
5013
  try {
4896
5014
  const { file, nodeId } = await loadFile(input, token);
@@ -4902,7 +5020,8 @@ Provide a Figma URL or fixture path via the input parameter. Requires FIGMA_TOKE
4902
5020
  }
4903
5021
  const result = analyzeFile(file, {
4904
5022
  configs,
4905
- ...effectiveNodeId ? { targetNodeId: effectiveNodeId } : {}
5023
+ ...effectiveNodeId ? { targetNodeId: effectiveNodeId } : {},
5024
+ ...acknowledgments && acknowledgments.length > 0 ? { acknowledgments } : {}
4906
5025
  });
4907
5026
  const scores = calculateScores(result, configs);
4908
5027
  const figmaToken = token ?? process.env["FIGMA_TOKEN"];
@@ -4911,14 +5030,16 @@ Provide a Figma URL or fixture path via the input parameter. Requires FIGMA_TOKE
4911
5030
  const ts = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-${String(now.getDate()).padStart(2, "0")}-${String(now.getHours()).padStart(2, "0")}-${String(now.getMinutes()).padStart(2, "0")}`;
4912
5031
  ensureReportsDir();
4913
5032
  const reportPath = `${getReportsDir()}/report-${ts}-${file.fileKey}.html`;
4914
- await new Promise((resolve5, reject) => {
5033
+ await new Promise((resolve6, reject) => {
4915
5034
  writeFile(reportPath, html, "utf-8", (err) => {
4916
5035
  if (err) reject(err);
4917
- else resolve5();
5036
+ else resolve6();
4918
5037
  });
4919
5038
  });
4920
- const openCmd = process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open";
4921
- exec(`${openCmd} "${reportPath}"`);
5039
+ if (openReport === true) {
5040
+ const openCmd = process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open";
5041
+ exec(`${openCmd} "${reportPath}"`);
5042
+ }
4922
5043
  trackEvent(EVENTS.ANALYSIS_COMPLETED, {
4923
5044
  nodeCount: result.nodeCount,
4924
5045
  issueCount: result.issues.length,
@@ -4930,7 +5051,7 @@ Provide a Figma URL or fixture path via the input parameter. Requires FIGMA_TOKE
4930
5051
  content: [
4931
5052
  {
4932
5053
  type: "text",
4933
- text: JSON.stringify(buildResultJson(file.name, result, scores, { fileKey: file.fileKey }), null, 2)
5054
+ text: JSON.stringify(buildResultJson(file.name, result, scores, { fileKey: file.fileKey, designKey: computeDesignKey(input) }), null, 2)
4934
5055
  }
4935
5056
  ]
4936
5057
  };
@@ -4990,7 +5111,7 @@ Provide a Figma URL or fixture path via the input parameter. Requires FIGMA_TOKE
4990
5111
  ...effectiveNodeId ? { targetNodeId: effectiveNodeId } : {}
4991
5112
  });
4992
5113
  const scores = calculateScores(result, configs);
4993
- const survey = generateGotchaSurvey(result, scores);
5114
+ const survey = generateGotchaSurvey(result, scores, { designKey: computeDesignKey(input) });
4994
5115
  trackEvent(EVENTS.ANALYSIS_COMPLETED, {
4995
5116
  nodeCount: result.nodeCount,
4996
5117
  issueCount: result.issues.length,
@@ -5111,10 +5232,10 @@ Get your token: Figma \u2192 Settings \u2192 Security \u2192 Personal access tok
5111
5232
 
5112
5233
  ## MCP Server (Claude Code / Cursor / Claude Desktop)
5113
5234
  \`\`\`bash
5114
- claude mcp add canicode -e FIGMA_TOKEN=figd_xxxxxxxxxxxxx -- npx -y -p canicode canicode-mcp
5235
+ claude mcp add canicode -- npx --yes --package=canicode canicode-mcp
5115
5236
  \`\`\`
5116
5237
 
5117
- Requires FIGMA_TOKEN for live Figma URL analysis.
5238
+ Requires FIGMA_TOKEN for live Figma URL analysis. The MCP server reads it from \`~/.canicode/config.json\` (set via \`canicode init --token \u2026\`) or from the host's environment, so do **not** pass \`-e FIGMA_TOKEN=\u2026\` to \`claude mcp add\` \u2014 \`@anthropic-ai/claude-code\`'s current parser rejects short-form flags placed before \`--\`. (#364, #366)
5118
5239
 
5119
5240
  ## CLI only (no MCP server)
5120
5241
 
@@ -5234,16 +5355,16 @@ ${inlineTopics[selectedTopic]}` }]
5234
5355
  };
5235
5356
  }
5236
5357
  const { readFile: readFile3 } = await import('fs/promises');
5237
- const { resolve: resolve5, dirname: dirname4 } = await import('path');
5358
+ const { resolve: resolve6, dirname: dirname4 } = await import('path');
5238
5359
  const { fileURLToPath } = await import('url');
5239
5360
  try {
5240
5361
  const __dirname = dirname4(fileURLToPath(import.meta.url));
5241
- const docPath = resolve5(__dirname, "../../docs/CUSTOMIZATION.md");
5362
+ const docPath = resolve6(__dirname, "../../docs/CUSTOMIZATION.md");
5242
5363
  let content;
5243
5364
  try {
5244
5365
  content = await readFile3(docPath, "utf-8");
5245
5366
  } catch {
5246
- const altPath = resolve5(__dirname, "../docs/CUSTOMIZATION.md");
5367
+ const altPath = resolve6(__dirname, "../docs/CUSTOMIZATION.md");
5247
5368
  content = await readFile3(altPath, "utf-8");
5248
5369
  }
5249
5370
  if (selectedTopic !== "all") {