canicode 0.11.5 → 0.12.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.
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env node
2
- import { writeFile, existsSync, statSync, mkdirSync, chmodSync, readFileSync, writeFileSync, copyFileSync } from 'fs';
3
- import { join, resolve, basename, dirname } from 'path';
2
+ import { writeFile, existsSync, statSync, mkdirSync, readFileSync, chmodSync, writeFileSync, readdirSync, copyFileSync } from 'fs';
3
+ import { join, resolve, basename, dirname, isAbsolute, sep } from 'path';
4
4
  import pixelmatch from 'pixelmatch';
5
5
  import { PNG } from 'pngjs';
6
6
  import { createRequire } from 'module';
@@ -302,7 +302,14 @@ var SeveritySchema = z.enum([
302
302
  "blocking",
303
303
  "risk",
304
304
  "missing-info",
305
- "suggestion"
305
+ "suggestion",
306
+ /**
307
+ * `note` is the zero-impact tier (#519): findings render in the report but
308
+ * never move the grade. Used for annotation-primary rules whose value is the
309
+ * nudge, not the score (e.g. unmapped Code Connect components, info-collection
310
+ * rules whose answers belong in figma-implement-design context, not in linting).
311
+ */
312
+ "note"
306
313
  ]);
307
314
 
308
315
  // src/core/contracts/rule.ts
@@ -348,6 +355,7 @@ var RULE_ID_CATEGORY = {
348
355
  "detached-instance": "code-quality",
349
356
  "variant-structure-mismatch": "code-quality",
350
357
  "deep-nesting": "code-quality",
358
+ "unmapped-component": "code-quality",
351
359
  // Token Management
352
360
  "raw-value": "token-management",
353
361
  "irregular-spacing": "token-management",
@@ -378,6 +386,12 @@ var RULE_PURPOSE = {
378
386
  "detached-instance": "violation",
379
387
  "variant-structure-mismatch": "violation",
380
388
  "deep-nesting": "violation",
389
+ // #520: unmapped-component is annotation-primary. Fires only when the
390
+ // user has Code Connect set up at all (figma.config.json present in cwd).
391
+ // The gotcha drives the user to /canicode-roundtrip for actual mapping
392
+ // registration via the Figma MCP tools — analyze itself does not parse
393
+ // mapping declarations (deferred to v1.5).
394
+ "unmapped-component": "info-collection",
381
395
  // Token Management
382
396
  "raw-value": "violation",
383
397
  "irregular-spacing": "violation",
@@ -421,12 +435,12 @@ var RULE_CONFIGS = {
421
435
  enabled: true
422
436
  },
423
437
  "missing-size-constraint": {
424
- // #403: severity downgraded `risk missing-info` and score from
425
- // -8 -1 to match the new info-collection purpose. Keeping the
426
- // rule enabled (not disabled) so its gotchas still surface in the
427
- // survey see RULE_PURPOSE entry above for the full rationale.
428
- severity: "missing-info",
429
- score: -1,
438
+ // #403 → #519: info-collection rule. Score is 0 (severity `note`):
439
+ // its value is the gotcha annotation, not the grade impact. Survey-
440
+ // generator includes this rule via the `purpose === "info-collection"`
441
+ // branch so the gotcha keeps surfacing.
442
+ severity: "note",
443
+ score: 0,
430
444
  enabled: true
431
445
  },
432
446
  // ── Code Quality ──
@@ -458,6 +472,16 @@ var RULE_CONFIGS = {
458
472
  maxDepth: 5
459
473
  }
460
474
  },
475
+ "unmapped-component": {
476
+ // #520 / #519: zero-impact tier. Fires per main component when Code
477
+ // Connect is set up in the consuming repo (figma.config.json at cwd).
478
+ // Score is 0 because the rule's value is the gotcha + roundtrip handoff,
479
+ // not the grade signal — designers who deliberately do not map (e.g.
480
+ // marketing-only banners) are not punished.
481
+ severity: "note",
482
+ score: 0,
483
+ enabled: true
484
+ },
461
485
  // ── Token Management ──
462
486
  "raw-value": {
463
487
  severity: "missing-info",
@@ -479,15 +503,15 @@ var RULE_CONFIGS = {
479
503
  // is minimal. Score stays at -1 so re-enabling `missing-prototype` on
480
504
  // fixtures that lack `interactionDestinations` (#139) cannot swing grades.
481
505
  "missing-interaction-state": {
482
- severity: "missing-info",
483
- score: -1,
484
- // uncalibrated: no metric to validate score (#210), kept at -1 to preserve category visibility
506
+ severity: "note",
507
+ // #519: info-collection rule, zero-score tier
508
+ score: 0,
485
509
  enabled: true
486
510
  },
487
511
  "missing-prototype": {
488
- severity: "missing-info",
489
- score: -1,
490
- // #406: info-collection — annotation is primary output; score kept minimal so #139 fixtures don't skew calibration
512
+ severity: "note",
513
+ // #519: info-collection — annotation is primary output, no grade impact
514
+ score: 0,
491
515
  enabled: true
492
516
  },
493
517
  // ── Semantic ──
@@ -671,11 +695,31 @@ function defineRule(rule) {
671
695
  ruleRegistry.register(rule);
672
696
  return rule;
673
697
  }
674
- var AcknowledgmentIntentSchema = z.object({
698
+ var PropertyAcknowledgmentIntentSchema = z.object({
699
+ kind: z.literal("property").default("property"),
675
700
  field: z.string(),
676
701
  value: z.unknown(),
677
702
  scope: z.enum(["instance", "definition"])
678
703
  });
704
+ var RuleOptOutAcknowledgmentIntentSchema = z.object({
705
+ kind: z.literal("rule-opt-out"),
706
+ ruleId: z.string()
707
+ }).strict();
708
+ var AcknowledgmentIntentSchema = z.preprocess((raw) => {
709
+ if (raw && typeof raw === "object" && !Array.isArray(raw)) {
710
+ const obj = raw;
711
+ if (obj["kind"] === void 0) {
712
+ return { ...obj, kind: "property" };
713
+ }
714
+ }
715
+ return raw;
716
+ }, z.discriminatedUnion("kind", [
717
+ PropertyAcknowledgmentIntentSchema,
718
+ RuleOptOutAcknowledgmentIntentSchema
719
+ ]));
720
+ function isRuleOptOutIntent(intent) {
721
+ return intent !== void 0 && intent.kind === "rule-opt-out";
722
+ }
679
723
  var AcknowledgmentSceneWriteOutcomeSchema = z.object({
680
724
  result: z.enum([
681
725
  "succeeded",
@@ -753,6 +797,7 @@ var RuleEngine = class {
753
797
  excludeNamePattern;
754
798
  excludeNodeTypes;
755
799
  acknowledgments;
800
+ acknowledgmentsByKey;
756
801
  scopeOverride;
757
802
  constructor(options = {}) {
758
803
  this.configs = options.configs ?? RULE_CONFIGS;
@@ -761,10 +806,15 @@ var RuleEngine = class {
761
806
  this.targetNodeId = options.targetNodeId;
762
807
  this.excludeNamePattern = options.excludeNodeNames && options.excludeNodeNames.length > 0 ? new RegExp(`\\b(${options.excludeNodeNames.join("|")})\\b`, "i") : null;
763
808
  this.excludeNodeTypes = options.excludeNodeTypes && options.excludeNodeTypes.length > 0 ? new Set(options.excludeNodeTypes) : null;
809
+ const ackList = options.acknowledgments ?? [];
764
810
  this.acknowledgments = new Set(
765
- (options.acknowledgments ?? []).map(
766
- (a) => `${normalizeNodeId(a.nodeId)}::${a.ruleId}`
767
- )
811
+ ackList.map((a) => `${normalizeNodeId(a.nodeId)}::${a.ruleId}`)
812
+ );
813
+ this.acknowledgmentsByKey = new Map(
814
+ ackList.map((a) => [
815
+ `${normalizeNodeId(a.nodeId)}::${a.ruleId}`,
816
+ a
817
+ ])
768
818
  );
769
819
  this.scopeOverride = options.scope;
770
820
  }
@@ -848,6 +898,7 @@ var RuleEngine = class {
848
898
  if (this.excludeNamePattern && this.excludeNamePattern.test(node.name)) {
849
899
  return;
850
900
  }
901
+ const acknowledgmentsByKey = this.acknowledgmentsByKey;
851
902
  const context = {
852
903
  file,
853
904
  parent,
@@ -859,7 +910,8 @@ var RuleEngine = class {
859
910
  siblings,
860
911
  analysisState,
861
912
  scope,
862
- rootNodeType
913
+ rootNodeType,
914
+ findAcknowledgment: (nodeId, ruleId) => acknowledgmentsByKey.get(`${normalizeNodeId(nodeId)}::${ruleId}`)
863
915
  };
864
916
  for (const rule of rules) {
865
917
  const ruleId = rule.definition.id;
@@ -1023,6 +1075,32 @@ var FigmaClient = class _FigmaClient {
1023
1075
  const buffer = await response.arrayBuffer();
1024
1076
  return Buffer.from(buffer).toString("base64");
1025
1077
  }
1078
+ /**
1079
+ * Get the components a file has published to a team library.
1080
+ *
1081
+ * `GET /v1/files/:file_key/components` returns only components that have
1082
+ * been pushed via the Publish Library action — local-but-unpublished
1083
+ * components are absent. This is the authoritative way to detect whether
1084
+ * a Figma component is mappable via Code Connect (#532): `add_code_connect_map`
1085
+ * requires a published component and otherwise fails with "Published
1086
+ * component not found."
1087
+ */
1088
+ async getPublishedComponents(fileKey) {
1089
+ const url = `${FIGMA_API_BASE}/files/${fileKey}/components`;
1090
+ const response = await fetch(url, {
1091
+ headers: { "X-Figma-Token": this.token }
1092
+ });
1093
+ if (!response.ok) {
1094
+ const error = await response.json().catch(() => ({}));
1095
+ throw new FigmaClientError(
1096
+ `Failed to fetch published components: ${response.status} ${response.statusText}`,
1097
+ response.status,
1098
+ error
1099
+ );
1100
+ }
1101
+ const data = await response.json();
1102
+ return data.meta?.components ?? [];
1103
+ }
1026
1104
  async getFileNodes(fileKey, nodeIds) {
1027
1105
  const ids = nodeIds.join(",");
1028
1106
  const url = `${FIGMA_API_BASE}/files/${fileKey}/nodes?ids=${encodeURIComponent(ids)}`;
@@ -1805,6 +1883,7 @@ var STRATEGY_BY_RULE = {
1805
1883
  // Strategy C — annotation only
1806
1884
  "absolute-position-in-auto-layout": "annotation",
1807
1885
  "variant-structure-mismatch": "annotation",
1886
+ "unmapped-component": "annotation",
1808
1887
  // Strategy D — auto-fix lower-severity issues from analyze output
1809
1888
  "non-standard-naming": "auto-fix",
1810
1889
  "inconsistent-naming-convention": "auto-fix",
@@ -1839,6 +1918,7 @@ function resolveTargetProperty(ruleId, subType) {
1839
1918
  case "raw-value":
1840
1919
  case "missing-interaction-state":
1841
1920
  case "missing-prototype":
1921
+ case "unmapped-component":
1842
1922
  return void 0;
1843
1923
  }
1844
1924
  }
@@ -1863,7 +1943,7 @@ function computeApplyContext(violation, instanceContext) {
1863
1943
  }
1864
1944
 
1865
1945
  // package.json
1866
- var version = "0.11.5";
1946
+ var version = "0.12.1";
1867
1947
 
1868
1948
  // src/core/engine/scoring.ts
1869
1949
  var GRADE_ORDER = ["S", "A+", "A", "B+", "B", "C+", "C", "D", "F"];
@@ -1970,6 +2050,7 @@ function calculateScores(result, configs) {
1970
2050
  risk: 0,
1971
2051
  missingInfo: 0,
1972
2052
  suggestion: 0,
2053
+ note: 0,
1973
2054
  nodeCount,
1974
2055
  acknowledgedCount: 0
1975
2056
  };
@@ -1987,6 +2068,9 @@ function calculateScores(result, configs) {
1987
2068
  case "suggestion":
1988
2069
  summary.suggestion++;
1989
2070
  break;
2071
+ case "note":
2072
+ summary.note++;
2073
+ break;
1990
2074
  }
1991
2075
  if (issue.acknowledged === true) summary.acknowledgedCount++;
1992
2076
  }
@@ -2018,7 +2102,8 @@ function initializeCategoryScores() {
2018
2102
  blocking: 0,
2019
2103
  risk: 0,
2020
2104
  "missing-info": 0,
2021
- suggestion: 0
2105
+ suggestion: 0,
2106
+ note: 0
2022
2107
  }
2023
2108
  };
2024
2109
  }
@@ -2039,6 +2124,7 @@ function formatScoreSummary(report) {
2039
2124
  lines.push(` Risk: ${report.summary.risk}`);
2040
2125
  lines.push(` Missing Info: ${report.summary.missingInfo}`);
2041
2126
  lines.push(` Suggestion: ${report.summary.suggestion}`);
2127
+ lines.push(` Note: ${report.summary.note}`);
2042
2128
  if (report.summary.acknowledgedCount > 0) {
2043
2129
  const unaddressed = report.summary.totalIssues - report.summary.acknowledgedCount;
2044
2130
  lines.push(
@@ -2049,6 +2135,19 @@ function formatScoreSummary(report) {
2049
2135
  }
2050
2136
  return lines.join("\n");
2051
2137
  }
2138
+ function formatCodeConnectCoverageLine(coverage) {
2139
+ const { mapped, total } = coverage;
2140
+ const pct = total === 0 ? 0 : Math.round(mapped / total * 100);
2141
+ return `Code Connect coverage: ${mapped}/${total} components (${pct}%) mapped`;
2142
+ }
2143
+ var ROUNDTRIP_OPT_OUT_HINT = "Some components may carry roundtrip-recorded opt-outs that this standalone analyze cannot see (Figma REST annotations field is in private beta). Run /canicode-roundtrip to apply opt-outs.";
2144
+ function formatRoundtripOptOutHintLine(issues, acknowledgmentsProvided) {
2145
+ const hasUnmapped = issues.some(
2146
+ (issue) => issue.violation.ruleId === "unmapped-component"
2147
+ );
2148
+ if (!hasUnmapped) return null;
2149
+ return ROUNDTRIP_OPT_OUT_HINT;
2150
+ }
2052
2151
  function buildResultJson(fileName, result, scores, options) {
2053
2152
  const issuesByRule = {};
2054
2153
  for (const issue of result.issues) {
@@ -2078,6 +2177,14 @@ function buildResultJson(fileName, result, scores, options) {
2078
2177
  ...issue.acknowledged === true ? { acknowledged: true } : {}
2079
2178
  };
2080
2179
  });
2180
+ const optOutHint = options?.roundtripOptOutHintEligible ? formatRoundtripOptOutHintLine(result.issues) : null;
2181
+ const summaryParts = [formatScoreSummary(scores)];
2182
+ if (options?.codeConnectCoverage) {
2183
+ summaryParts.push(formatCodeConnectCoverageLine(options.codeConnectCoverage));
2184
+ }
2185
+ if (optOutHint) {
2186
+ summaryParts.push(optOutHint);
2187
+ }
2081
2188
  const json = {
2082
2189
  version,
2083
2190
  analyzedAt: result.analyzedAt,
@@ -2097,13 +2204,153 @@ function buildResultJson(fileName, result, scores, options) {
2097
2204
  },
2098
2205
  issuesByRule,
2099
2206
  issues,
2100
- summary: formatScoreSummary(scores)
2207
+ summary: summaryParts.join("\n\n")
2101
2208
  };
2209
+ if (options?.codeConnectCoverage) {
2210
+ json["codeConnectCoverage"] = options.codeConnectCoverage;
2211
+ }
2212
+ if (optOutHint) {
2213
+ json["roundtripOptOutHint"] = optOutHint;
2214
+ }
2102
2215
  if (result.failedRules.length > 0) {
2103
2216
  json["failedRules"] = result.failedRules;
2104
2217
  }
2105
2218
  return json;
2106
2219
  }
2220
+ var FIGMA_CONFIG_FILENAME = "figma.config.json";
2221
+ var FIGMA_CONNECT_FILE_GLOB = /\.figma\.(tsx?|jsx?)$/;
2222
+ var NODE_ID_QUERY_RE = /[?&]node-id=([0-9A-Za-z%:\-_]+)/;
2223
+ function parseCodeConnectMappings(cwd) {
2224
+ const configPath = join(cwd, FIGMA_CONFIG_FILENAME);
2225
+ if (!existsSync(configPath)) {
2226
+ return {
2227
+ mappedNodeIds: /* @__PURE__ */ new Set(),
2228
+ scannedFiles: [],
2229
+ skipReason: "no-config",
2230
+ skippedReason: `${FIGMA_CONFIG_FILENAME} not found at ${cwd}`
2231
+ };
2232
+ }
2233
+ let config2;
2234
+ try {
2235
+ config2 = JSON.parse(readFileSync(configPath, "utf-8"));
2236
+ } catch (err) {
2237
+ return {
2238
+ mappedNodeIds: /* @__PURE__ */ new Set(),
2239
+ scannedFiles: [],
2240
+ skipReason: "malformed-config",
2241
+ skippedReason: `malformed ${FIGMA_CONFIG_FILENAME}: ${err.message}`
2242
+ };
2243
+ }
2244
+ const includes = config2.codeConnect?.include ?? config2.include ?? [];
2245
+ if (includes.length === 0) {
2246
+ return {
2247
+ mappedNodeIds: /* @__PURE__ */ new Set(),
2248
+ scannedFiles: [],
2249
+ skipReason: "no-includes",
2250
+ skippedReason: `${FIGMA_CONFIG_FILENAME} has no codeConnect.include paths`
2251
+ };
2252
+ }
2253
+ const candidateFiles = /* @__PURE__ */ new Set();
2254
+ for (const includePattern of includes) {
2255
+ for (const file of resolveInclude(cwd, includePattern)) {
2256
+ candidateFiles.add(file);
2257
+ }
2258
+ }
2259
+ const mappedNodeIds = /* @__PURE__ */ new Set();
2260
+ const scannedFiles = [];
2261
+ for (const file of candidateFiles) {
2262
+ scannedFiles.push(file);
2263
+ let contents;
2264
+ try {
2265
+ contents = readFileSync(file, "utf-8");
2266
+ } catch {
2267
+ continue;
2268
+ }
2269
+ for (const nodeId of extractNodeIdsFromSource(contents)) {
2270
+ mappedNodeIds.add(nodeId);
2271
+ }
2272
+ }
2273
+ return { mappedNodeIds, scannedFiles };
2274
+ }
2275
+ function resolveInclude(cwd, includePattern) {
2276
+ const results = [];
2277
+ const absolute = isAbsolute(includePattern) ? includePattern : resolve(cwd, includePattern);
2278
+ const segments = absolute.split(sep);
2279
+ let firstGlobIdx = segments.findIndex((s) => /[*?{[]/.test(s));
2280
+ if (firstGlobIdx === -1) {
2281
+ if (existsSync(absolute)) {
2282
+ const stat = statSync(absolute);
2283
+ if (stat.isFile() && FIGMA_CONNECT_FILE_GLOB.test(absolute)) {
2284
+ results.push(absolute);
2285
+ } else if (stat.isDirectory()) {
2286
+ walkDir(absolute, results);
2287
+ }
2288
+ }
2289
+ return results;
2290
+ }
2291
+ const rootSegments = segments.slice(0, firstGlobIdx);
2292
+ const root = rootSegments.length === 0 ? sep : rootSegments.join(sep);
2293
+ if (!existsSync(root)) return results;
2294
+ const rootStat = statSync(root);
2295
+ if (!rootStat.isDirectory()) return results;
2296
+ walkDir(root, results);
2297
+ const prefix = rootSegments.join(sep) + sep;
2298
+ return results.filter((f) => f.startsWith(prefix) || rootSegments.length === 0);
2299
+ }
2300
+ function walkDir(dir, out) {
2301
+ let entries;
2302
+ try {
2303
+ entries = readdirSync(dir);
2304
+ } catch {
2305
+ return;
2306
+ }
2307
+ for (const entry of entries) {
2308
+ if (entry === "node_modules" || entry.startsWith(".")) continue;
2309
+ const full = join(dir, entry);
2310
+ let stat;
2311
+ try {
2312
+ stat = statSync(full);
2313
+ } catch {
2314
+ continue;
2315
+ }
2316
+ if (stat.isDirectory()) {
2317
+ walkDir(full, out);
2318
+ } else if (stat.isFile() && FIGMA_CONNECT_FILE_GLOB.test(full)) {
2319
+ out.push(full);
2320
+ }
2321
+ }
2322
+ }
2323
+ function extractNodeIdsFromSource(source) {
2324
+ const nodeIds = /* @__PURE__ */ new Set();
2325
+ const re = new RegExp(NODE_ID_QUERY_RE, "g");
2326
+ let match;
2327
+ while ((match = re.exec(source)) !== null) {
2328
+ const raw = match[1];
2329
+ if (!raw) continue;
2330
+ const decoded = safeDecode(raw);
2331
+ nodeIds.add(decoded.replace(/-/g, ":"));
2332
+ }
2333
+ return nodeIds;
2334
+ }
2335
+ function safeDecode(raw) {
2336
+ try {
2337
+ return decodeURIComponent(raw);
2338
+ } catch {
2339
+ return raw;
2340
+ }
2341
+ }
2342
+
2343
+ // src/core/rules/component/code-connect-coverage.ts
2344
+ function computeCodeConnectCoverage(components, cwd = process.cwd()) {
2345
+ const result = parseCodeConnectMappings(cwd);
2346
+ if (result.skipReason === "no-config") return void 0;
2347
+ const componentNodeIds = Object.keys(components);
2348
+ let mapped = 0;
2349
+ for (const nodeId of componentNodeIds) {
2350
+ if (result.mappedNodeIds.has(nodeId)) mapped++;
2351
+ }
2352
+ return { mapped, total: componentNodeIds.length };
2353
+ }
2107
2354
  z.object({
2108
2355
  ruleId: z.string(),
2109
2356
  detection: z.literal("rule-based"),
@@ -2165,6 +2412,12 @@ var GOTCHA_QUESTION_CONTENT = {
2165
2412
  hint: "Describe which variant has the correct structure, or if they should all match",
2166
2413
  example: "Default variant is canonical \u2014 other variants should toggle child visibility instead of adding/removing elements"
2167
2414
  },
2415
+ "unmapped-component": {
2416
+ ruleId: "unmapped-component",
2417
+ question: '"{nodeName}" has no Code Connect mapping yet. Should we register one so figma-implement-design reuses your code?',
2418
+ hint: "Skip if this component is intentionally unmapped (e.g. marketing-only banner). Otherwise run /canicode-roundtrip on the component to walk through registration.",
2419
+ example: "Yes \u2014 map to src/components/Button.tsx so future screens reuse the existing implementation"
2420
+ },
2168
2421
  "deep-nesting": {
2169
2422
  ruleId: "deep-nesting",
2170
2423
  question: '"{nodeName}" is deeply nested. Can some intermediate layers be flattened or extracted?',
@@ -2310,10 +2563,7 @@ function generateGotchaSurvey(result, scores, options = {}) {
2310
2563
  const relevantIssues = result.issues.filter((issue) => {
2311
2564
  const severity = issue.config.severity;
2312
2565
  if (severity === "blocking" || severity === "risk") return true;
2313
- if (severity === "missing-info") {
2314
- return getRulePurpose(issue.violation.ruleId) === "info-collection";
2315
- }
2316
- return false;
2566
+ return getRulePurpose(issue.violation.ruleId) === "info-collection";
2317
2567
  });
2318
2568
  const deduped = deduplicateSiblingIssues(relevantIssues);
2319
2569
  const sorted = stableSortBySeverity(deduped);
@@ -2494,7 +2744,8 @@ function severityDot(sev) {
2494
2744
  blocking: "sev-blocking",
2495
2745
  risk: "sev-risk",
2496
2746
  "missing-info": "sev-missing",
2497
- suggestion: "sev-suggestion"
2747
+ suggestion: "sev-suggestion",
2748
+ note: "sev-note"
2498
2749
  };
2499
2750
  return map[sev];
2500
2751
  }
@@ -2503,7 +2754,8 @@ function severityBadge(sev) {
2503
2754
  blocking: "sev-blocking",
2504
2755
  risk: "sev-risk",
2505
2756
  "missing-info": "sev-missing",
2506
- suggestion: "sev-suggestion"
2757
+ suggestion: "sev-suggestion",
2758
+ note: "sev-note"
2507
2759
  };
2508
2760
  return map[sev];
2509
2761
  }
@@ -2561,6 +2813,7 @@ ${CATEGORIES.map((cat) => {
2561
2813
  ${renderSummaryDot("sev-risk", scores.summary.risk, "Risk")}
2562
2814
  ${renderSummaryDot("sev-missing", scores.summary.missingInfo, "Missing Info")}
2563
2815
  ${renderSummaryDot("sev-suggestion", scores.summary.suggestion, "Suggestion")}
2816
+ ${renderSummaryDot("sev-note", scores.summary.note, "Note")}
2564
2817
  <div class="rpt-summary-total">
2565
2818
  <span class="rpt-summary-count">${scores.summary.totalIssues}</span>
2566
2819
  <span class="rpt-summary-label">Total</span>
@@ -2775,7 +3028,7 @@ function groupIssuesByRule(issues) {
2775
3028
  group.issues.push(issue);
2776
3029
  group.totalScore += issue.calculatedScore;
2777
3030
  }
2778
- const SEVERITY_RANK = { blocking: 0, risk: 1, "missing-info": 2, suggestion: 3 };
3031
+ const SEVERITY_RANK = { blocking: 0, risk: 1, "missing-info": 2, suggestion: 3, note: 4 };
2779
3032
  return [...byRule.values()].sort((a, b) => {
2780
3033
  const sevDiff = (SEVERITY_RANK[a.severity] ?? 4) - (SEVERITY_RANK[b.severity] ?? 4);
2781
3034
  return sevDiff !== 0 ? sevDiff : a.totalScore - b.totalScore;
@@ -2843,6 +3096,7 @@ body {
2843
3096
  .sev-risk { background: var(--amber); }
2844
3097
  .sev-missing { background: #a1a1aa; }
2845
3098
  .sev-suggestion { background: var(--green); }
3099
+ .sev-note { background: #d4d4d8; }
2846
3100
 
2847
3101
  /* ---- Print ---- */
2848
3102
  @media print {
@@ -3239,6 +3493,7 @@ body {
3239
3493
  .rpt-issue-score.sev-risk { background: var(--amber-bg); color: #d97706; border-color: rgba(245,158,11,0.2); }
3240
3494
  .rpt-issue-score.sev-missing { background: #f4f4f5; color: #71717a; border-color: rgba(113,113,122,0.2); }
3241
3495
  .rpt-issue-score.sev-suggestion { background: var(--green-bg); color: #16a34a; border-color: rgba(34,197,94,0.2); }
3496
+ .rpt-issue-score.sev-note { background: #f4f4f5; color: #71717a; border-color: rgba(113,113,122,0.2); }
3242
3497
 
3243
3498
  .rpt-issue-body {
3244
3499
  padding: 12px;
@@ -3644,6 +3899,8 @@ var EVENTS = {
3644
3899
  // CLI
3645
3900
  CLI_COMMAND: `${EVENT_PREFIX}cli_command`,
3646
3901
  CLI_INIT: `${EVENT_PREFIX}cli_init`,
3902
+ CLI_CONFIG_SET_TOKEN: `${EVENT_PREFIX}cli_config_set_token`,
3903
+ CLI_DOCTOR: `${EVENT_PREFIX}cli_doctor`,
3647
3904
  // Roundtrip (ADR-012)
3648
3905
  // Wiring point for the roundtrip helper's `telemetry` callback. No Node-side
3649
3906
  // orchestrator reads this yet — the helper ships in a sandbox-pure IIFE that
@@ -4061,6 +4318,10 @@ var missingComponentMsg = {
4061
4318
  suggestion: `Create a new variant for this style combination`
4062
4319
  })
4063
4320
  };
4321
+ var unmappedComponentMsg = (componentName) => ({
4322
+ message: `"${componentName}" has no Code Connect mapping`,
4323
+ suggestion: `Run /canicode-roundtrip on this component to register a mapping so figma-implement-design reuses your code instead of regenerating markup. Skip if intentionally unmapped.`
4324
+ });
4064
4325
  var detachedInstanceMsg = (name, componentName) => ({
4065
4326
  message: `"${name}" may be a detached instance of component "${componentName}"`,
4066
4327
  suggestion: `Restore as an instance of "${componentName}" or create a new variant`
@@ -4612,8 +4873,6 @@ defineRule({
4612
4873
  definition: irregularSpacingDef,
4613
4874
  check: irregularSpacingCheck
4614
4875
  });
4615
-
4616
- // src/core/rules/component/index.ts
4617
4876
  var STYLE_COMPARE_KEYS = ["fills", "strokes", "effects", "cornerRadius", "strokeWeight", "individualStrokeWeights"];
4618
4877
  function detectStyleOverrides(master, instance) {
4619
4878
  const overrides = [];
@@ -4821,6 +5080,49 @@ defineRule({
4821
5080
  definition: variantStructureMismatchDef,
4822
5081
  check: variantStructureMismatchCheck
4823
5082
  });
5083
+ var CODE_CONNECT_SETUP_KEY = "unmapped-component:setup-detected";
5084
+ var CODE_CONNECT_MAPPINGS_KEY = "unmapped-component:mappings";
5085
+ function codeConnectIsSetUp(context) {
5086
+ return getAnalysisState(context, CODE_CONNECT_SETUP_KEY, () => {
5087
+ return existsSync(join(process.cwd(), "figma.config.json"));
5088
+ });
5089
+ }
5090
+ function codeConnectMappings(context) {
5091
+ return getAnalysisState(
5092
+ context,
5093
+ CODE_CONNECT_MAPPINGS_KEY,
5094
+ () => parseCodeConnectMappings(process.cwd())
5095
+ );
5096
+ }
5097
+ var unmappedComponentDef = {
5098
+ id: "unmapped-component",
5099
+ name: "Unmapped Component",
5100
+ category: "code-quality",
5101
+ why: "Without a Code Connect mapping, figma-implement-design regenerates the same markup every time this component appears in a screen \u2014 wasting tokens and risking drift.",
5102
+ impact: "Future roundtrips on screens containing this component cannot reuse your existing code; they regenerate markup that may not match the canonical implementation.",
5103
+ fix: "Run /canicode-roundtrip on this component to register a mapping. Figma's get_code_connect_map will skip if a mapping already exists."
5104
+ };
5105
+ var unmappedComponentCheck = (node, context) => {
5106
+ if (node.type !== "COMPONENT" && node.type !== "COMPONENT_SET") return null;
5107
+ if (isInsideInstance(context)) return null;
5108
+ if (!codeConnectIsSetUp(context)) return null;
5109
+ const mappings = codeConnectMappings(context);
5110
+ if (mappings.mappedNodeIds.has(node.id)) return null;
5111
+ const ack = context.findAcknowledgment(node.id, unmappedComponentDef.id);
5112
+ if (ack && isRuleOptOutIntent(ack.intent) && ack.intent.ruleId === unmappedComponentDef.id) {
5113
+ return null;
5114
+ }
5115
+ return {
5116
+ ruleId: unmappedComponentDef.id,
5117
+ nodeId: node.id,
5118
+ nodePath: context.path.join(" > "),
5119
+ ...unmappedComponentMsg(node.name)
5120
+ };
5121
+ };
5122
+ defineRule({
5123
+ definition: unmappedComponentDef,
5124
+ check: unmappedComponentCheck
5125
+ });
4824
5126
 
4825
5127
  // src/core/rules/naming/index.ts
4826
5128
  function capitalize(s) {
@@ -5211,9 +5513,9 @@ Provide a Figma URL or fixture path via the input parameter. Requires FIGMA_TOKE
5211
5513
  preset: z.enum(["relaxed", "dev-friendly", "ai-ready", "strict"]).optional().describe("Analysis preset"),
5212
5514
  targetNodeId: z.string().optional().describe("Scope analysis to a specific node ID"),
5213
5515
  configPath: z.string().optional().describe("Path to config JSON file for rule overrides"),
5214
- 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."),
5215
- acknowledgments: z.array(AcknowledgmentSchema).optional().describe("(#371 / ADR-019) Pre-resolved acknowledgments from canicode-authored Figma annotations (e.g. via readCanicodeAcknowledgments in a use_figma batch). Each entry includes nodeId and ruleId; newer annotations may also carry intent, sceneWriteOutcome, and codegenDirective from a canicode-json fenced block (#444). Matching issues are flagged acknowledged and contribute half weight to the density score."),
5216
- scope: z.enum(["page", "component"]).optional().describe("(#404) Override analysis scope \u2014 `page` (screen/section where container bounds are required) or `component` (standalone reusable unit where root FILL is the design contract). Defaults to auto-detection from the root node type: `COMPONENT` / `COMPONENT_SET` / `INSTANCE` roots resolve to `component`, everything else to `page`."),
5516
+ 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. The HTML file is always written to disk regardless."),
5517
+ acknowledgments: z.array(AcknowledgmentSchema).optional().describe("Pre-resolved acknowledgments from canicode-authored Figma annotations (e.g. via readCanicodeAcknowledgments in a use_figma batch). Each entry includes nodeId and ruleId; newer annotations may also carry intent, sceneWriteOutcome, and codegenDirective from a canicode-json fenced block. Matching issues are flagged acknowledged and contribute half weight to the density score."),
5518
+ scope: z.enum(["page", "component"]).optional().describe("Override analysis scope \u2014 `page` (screen/section where container bounds are required) or `component` (standalone reusable unit where root FILL is the design contract). Defaults to auto-detection from the root node type: `COMPONENT` / `COMPONENT_SET` / `INSTANCE` roots resolve to `component`, everything else to `page`."),
5217
5519
  codegenReadyMinGrade: z.enum(["S", "A+", "A", "B+", "B", "C+", "C", "D", "F"]).optional().describe("Minimum grade for code-gen readiness. Overrides the codegenReadyMinGrade field in configPath. Default: A")
5218
5520
  },
5219
5521
  {
@@ -5248,10 +5550,10 @@ Provide a Figma URL or fixture path via the input parameter. Requires FIGMA_TOKE
5248
5550
  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")}`;
5249
5551
  ensureReportsDir();
5250
5552
  const reportPath = `${getReportsDir()}/report-${ts}-${file.fileKey}.html`;
5251
- await new Promise((resolve6, reject) => {
5553
+ await new Promise((resolve7, reject) => {
5252
5554
  writeFile(reportPath, html, "utf-8", (err) => {
5253
5555
  if (err) reject(err);
5254
- else resolve6();
5556
+ else resolve7();
5255
5557
  });
5256
5558
  });
5257
5559
  if (openReport === true) {
@@ -5265,11 +5567,19 @@ Provide a Figma URL or fixture path via the input parameter. Requires FIGMA_TOKE
5265
5567
  percentage: scores.overall.percentage,
5266
5568
  source: isJsonFile(input) || isFixtureDir(input) ? "fixture" : "figma"
5267
5569
  });
5570
+ const coverage = computeCodeConnectCoverage(file.components);
5571
+ const optOutHintEligible = acknowledgments === void 0;
5268
5572
  return {
5269
5573
  content: [
5270
5574
  {
5271
5575
  type: "text",
5272
- text: JSON.stringify(buildResultJson(file.name, result, scores, { fileKey: file.fileKey, designKey: computeDesignKey(input), ...effectiveMinGrade ? { codegenReadyMinGrade: effectiveMinGrade } : {} }), null, 2)
5576
+ text: JSON.stringify(buildResultJson(file.name, result, scores, {
5577
+ fileKey: file.fileKey,
5578
+ designKey: computeDesignKey(input),
5579
+ ...effectiveMinGrade ? { codegenReadyMinGrade: effectiveMinGrade } : {},
5580
+ ...coverage ? { codeConnectCoverage: coverage } : {},
5581
+ roundtripOptOutHintEligible: optOutHintEligible
5582
+ }), null, 2)
5273
5583
  }
5274
5584
  ]
5275
5585
  };
@@ -5307,7 +5617,7 @@ Provide a Figma URL or fixture path via the input parameter. Requires FIGMA_TOKE
5307
5617
  preset: z.enum(["relaxed", "dev-friendly", "ai-ready", "strict"]).optional().describe("Analysis preset"),
5308
5618
  targetNodeId: z.string().optional().describe("Scope analysis to a specific node ID"),
5309
5619
  configPath: z.string().optional().describe("Path to config JSON file for rule overrides"),
5310
- scope: z.enum(["page", "component"]).optional().describe("(#404) Override analysis scope \u2014 `page` or `component`. Defaults to auto-detection from the root node type."),
5620
+ scope: z.enum(["page", "component"]).optional().describe("Override analysis scope \u2014 `page` or `component`. Defaults to auto-detection from the root node type."),
5311
5621
  codegenReadyMinGrade: z.enum(["S", "A+", "A", "B+", "B", "C+", "C", "D", "F"]).optional().describe("Minimum grade for code-gen readiness. Overrides the codegenReadyMinGrade field in configPath. Default: A")
5312
5622
  },
5313
5623
  {
@@ -5480,7 +5790,7 @@ Get your token: Figma \u2192 Settings \u2192 Security \u2192 Personal access tok
5480
5790
  claude mcp add canicode -- npx --yes --package=canicode canicode-mcp
5481
5791
  \`\`\`
5482
5792
 
5483
- 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)
5793
+ 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 \`--\`.
5484
5794
 
5485
5795
  ## CLI only (no MCP server)
5486
5796
 
@@ -5600,16 +5910,16 @@ ${inlineTopics[selectedTopic]}` }]
5600
5910
  };
5601
5911
  }
5602
5912
  const { readFile: readFile3 } = await import('fs/promises');
5603
- const { resolve: resolve6, dirname: dirname4 } = await import('path');
5913
+ const { resolve: resolve7, dirname: dirname4 } = await import('path');
5604
5914
  const { fileURLToPath } = await import('url');
5605
5915
  try {
5606
5916
  const __dirname = dirname4(fileURLToPath(import.meta.url));
5607
- const docPath = resolve6(__dirname, "../../docs/CUSTOMIZATION.md");
5917
+ const docPath = resolve7(__dirname, "../../docs/CUSTOMIZATION.md");
5608
5918
  let content;
5609
5919
  try {
5610
5920
  content = await readFile3(docPath, "utf-8");
5611
5921
  } catch {
5612
- const altPath = resolve6(__dirname, "../docs/CUSTOMIZATION.md");
5922
+ const altPath = resolve7(__dirname, "../docs/CUSTOMIZATION.md");
5613
5923
  content = await readFile3(altPath, "utf-8");
5614
5924
  }
5615
5925
  if (selectedTopic !== "all") {