canicode 0.12.0 → 0.12.2

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';
@@ -695,11 +695,31 @@ function defineRule(rule) {
695
695
  ruleRegistry.register(rule);
696
696
  return rule;
697
697
  }
698
- var AcknowledgmentIntentSchema = z.object({
698
+ var PropertyAcknowledgmentIntentSchema = z.object({
699
+ kind: z.literal("property").default("property"),
699
700
  field: z.string(),
700
701
  value: z.unknown(),
701
702
  scope: z.enum(["instance", "definition"])
702
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
+ }
703
723
  var AcknowledgmentSceneWriteOutcomeSchema = z.object({
704
724
  result: z.enum([
705
725
  "succeeded",
@@ -777,6 +797,7 @@ var RuleEngine = class {
777
797
  excludeNamePattern;
778
798
  excludeNodeTypes;
779
799
  acknowledgments;
800
+ acknowledgmentsByKey;
780
801
  scopeOverride;
781
802
  constructor(options = {}) {
782
803
  this.configs = options.configs ?? RULE_CONFIGS;
@@ -785,10 +806,15 @@ var RuleEngine = class {
785
806
  this.targetNodeId = options.targetNodeId;
786
807
  this.excludeNamePattern = options.excludeNodeNames && options.excludeNodeNames.length > 0 ? new RegExp(`\\b(${options.excludeNodeNames.join("|")})\\b`, "i") : null;
787
808
  this.excludeNodeTypes = options.excludeNodeTypes && options.excludeNodeTypes.length > 0 ? new Set(options.excludeNodeTypes) : null;
809
+ const ackList = options.acknowledgments ?? [];
788
810
  this.acknowledgments = new Set(
789
- (options.acknowledgments ?? []).map(
790
- (a) => `${normalizeNodeId(a.nodeId)}::${a.ruleId}`
791
- )
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
+ ])
792
818
  );
793
819
  this.scopeOverride = options.scope;
794
820
  }
@@ -872,6 +898,7 @@ var RuleEngine = class {
872
898
  if (this.excludeNamePattern && this.excludeNamePattern.test(node.name)) {
873
899
  return;
874
900
  }
901
+ const acknowledgmentsByKey = this.acknowledgmentsByKey;
875
902
  const context = {
876
903
  file,
877
904
  parent,
@@ -883,7 +910,8 @@ var RuleEngine = class {
883
910
  siblings,
884
911
  analysisState,
885
912
  scope,
886
- rootNodeType
913
+ rootNodeType,
914
+ findAcknowledgment: (nodeId, ruleId) => acknowledgmentsByKey.get(`${normalizeNodeId(nodeId)}::${ruleId}`)
887
915
  };
888
916
  for (const rule of rules) {
889
917
  const ruleId = rule.definition.id;
@@ -1047,6 +1075,32 @@ var FigmaClient = class _FigmaClient {
1047
1075
  const buffer = await response.arrayBuffer();
1048
1076
  return Buffer.from(buffer).toString("base64");
1049
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
+ }
1050
1104
  async getFileNodes(fileKey, nodeIds) {
1051
1105
  const ids = nodeIds.join(",");
1052
1106
  const url = `${FIGMA_API_BASE}/files/${fileKey}/nodes?ids=${encodeURIComponent(ids)}`;
@@ -1889,7 +1943,7 @@ function computeApplyContext(violation, instanceContext) {
1889
1943
  }
1890
1944
 
1891
1945
  // package.json
1892
- var version = "0.12.0";
1946
+ var version = "0.12.2";
1893
1947
 
1894
1948
  // src/core/engine/scoring.ts
1895
1949
  var GRADE_ORDER = ["S", "A+", "A", "B+", "B", "C+", "C", "D", "F"];
@@ -2081,6 +2135,19 @@ function formatScoreSummary(report) {
2081
2135
  }
2082
2136
  return lines.join("\n");
2083
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
+ }
2084
2151
  function buildResultJson(fileName, result, scores, options) {
2085
2152
  const issuesByRule = {};
2086
2153
  for (const issue of result.issues) {
@@ -2110,6 +2177,14 @@ function buildResultJson(fileName, result, scores, options) {
2110
2177
  ...issue.acknowledged === true ? { acknowledged: true } : {}
2111
2178
  };
2112
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
+ }
2113
2188
  const json = {
2114
2189
  version,
2115
2190
  analyzedAt: result.analyzedAt,
@@ -2129,13 +2204,153 @@ function buildResultJson(fileName, result, scores, options) {
2129
2204
  },
2130
2205
  issuesByRule,
2131
2206
  issues,
2132
- summary: formatScoreSummary(scores)
2207
+ summary: summaryParts.join("\n\n")
2133
2208
  };
2209
+ if (options?.codeConnectCoverage) {
2210
+ json["codeConnectCoverage"] = options.codeConnectCoverage;
2211
+ }
2212
+ if (optOutHint) {
2213
+ json["roundtripOptOutHint"] = optOutHint;
2214
+ }
2134
2215
  if (result.failedRules.length > 0) {
2135
2216
  json["failedRules"] = result.failedRules;
2136
2217
  }
2137
2218
  return json;
2138
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
+ }
2139
2354
  z.object({
2140
2355
  ruleId: z.string(),
2141
2356
  detection: z.literal("rule-based"),
@@ -4866,11 +5081,23 @@ defineRule({
4866
5081
  check: variantStructureMismatchCheck
4867
5082
  });
4868
5083
  var CODE_CONNECT_SETUP_KEY = "unmapped-component:setup-detected";
5084
+ var CODE_CONNECT_MAPPINGS_KEY = "unmapped-component:mappings";
5085
+ var SEEN_MAIN_IDS_KEY = "unmapped-component:seen-main-ids";
4869
5086
  function codeConnectIsSetUp(context) {
4870
5087
  return getAnalysisState(context, CODE_CONNECT_SETUP_KEY, () => {
4871
5088
  return existsSync(join(process.cwd(), "figma.config.json"));
4872
5089
  });
4873
5090
  }
5091
+ function codeConnectMappings(context) {
5092
+ return getAnalysisState(
5093
+ context,
5094
+ CODE_CONNECT_MAPPINGS_KEY,
5095
+ () => parseCodeConnectMappings(process.cwd())
5096
+ );
5097
+ }
5098
+ function seenMainIds(context) {
5099
+ return getAnalysisState(context, SEEN_MAIN_IDS_KEY, () => /* @__PURE__ */ new Set());
5100
+ }
4874
5101
  var unmappedComponentDef = {
4875
5102
  id: "unmapped-component",
4876
5103
  name: "Unmapped Component",
@@ -4880,14 +5107,33 @@ var unmappedComponentDef = {
4880
5107
  fix: "Run /canicode-roundtrip on this component to register a mapping. Figma's get_code_connect_map will skip if a mapping already exists."
4881
5108
  };
4882
5109
  var unmappedComponentCheck = (node, context) => {
4883
- if (node.type !== "COMPONENT" && node.type !== "COMPONENT_SET") return null;
4884
- if (isInsideInstance(context)) return null;
4885
5110
  if (!codeConnectIsSetUp(context)) return null;
5111
+ let mainId = null;
5112
+ let mainName = node.name;
5113
+ if (node.type === "COMPONENT" || node.type === "COMPONENT_SET") {
5114
+ if (isInsideInstance(context)) return null;
5115
+ mainId = node.id;
5116
+ } else if (node.type === "INSTANCE" && node.componentId) {
5117
+ mainId = node.componentId;
5118
+ const meta = context.file.components[node.componentId];
5119
+ if (meta?.name) mainName = meta.name;
5120
+ } else {
5121
+ return null;
5122
+ }
5123
+ const seen = seenMainIds(context);
5124
+ if (seen.has(mainId)) return null;
5125
+ seen.add(mainId);
5126
+ const mappings = codeConnectMappings(context);
5127
+ if (mappings.mappedNodeIds.has(mainId)) return null;
5128
+ const ack = context.findAcknowledgment(mainId, unmappedComponentDef.id);
5129
+ if (ack && isRuleOptOutIntent(ack.intent) && ack.intent.ruleId === unmappedComponentDef.id) {
5130
+ return null;
5131
+ }
4886
5132
  return {
4887
5133
  ruleId: unmappedComponentDef.id,
4888
- nodeId: node.id,
5134
+ nodeId: mainId,
4889
5135
  nodePath: context.path.join(" > "),
4890
- ...unmappedComponentMsg(node.name)
5136
+ ...unmappedComponentMsg(mainName)
4891
5137
  };
4892
5138
  };
4893
5139
  defineRule({
@@ -5321,10 +5567,10 @@ Provide a Figma URL or fixture path via the input parameter. Requires FIGMA_TOKE
5321
5567
  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")}`;
5322
5568
  ensureReportsDir();
5323
5569
  const reportPath = `${getReportsDir()}/report-${ts}-${file.fileKey}.html`;
5324
- await new Promise((resolve6, reject) => {
5570
+ await new Promise((resolve7, reject) => {
5325
5571
  writeFile(reportPath, html, "utf-8", (err) => {
5326
5572
  if (err) reject(err);
5327
- else resolve6();
5573
+ else resolve7();
5328
5574
  });
5329
5575
  });
5330
5576
  if (openReport === true) {
@@ -5338,11 +5584,19 @@ Provide a Figma URL or fixture path via the input parameter. Requires FIGMA_TOKE
5338
5584
  percentage: scores.overall.percentage,
5339
5585
  source: isJsonFile(input) || isFixtureDir(input) ? "fixture" : "figma"
5340
5586
  });
5587
+ const coverage = computeCodeConnectCoverage(file.components);
5588
+ const optOutHintEligible = acknowledgments === void 0;
5341
5589
  return {
5342
5590
  content: [
5343
5591
  {
5344
5592
  type: "text",
5345
- text: JSON.stringify(buildResultJson(file.name, result, scores, { fileKey: file.fileKey, designKey: computeDesignKey(input), ...effectiveMinGrade ? { codegenReadyMinGrade: effectiveMinGrade } : {} }), null, 2)
5593
+ text: JSON.stringify(buildResultJson(file.name, result, scores, {
5594
+ fileKey: file.fileKey,
5595
+ designKey: computeDesignKey(input),
5596
+ ...effectiveMinGrade ? { codegenReadyMinGrade: effectiveMinGrade } : {},
5597
+ ...coverage ? { codeConnectCoverage: coverage } : {},
5598
+ roundtripOptOutHintEligible: optOutHintEligible
5599
+ }), null, 2)
5346
5600
  }
5347
5601
  ]
5348
5602
  };
@@ -5673,16 +5927,16 @@ ${inlineTopics[selectedTopic]}` }]
5673
5927
  };
5674
5928
  }
5675
5929
  const { readFile: readFile3 } = await import('fs/promises');
5676
- const { resolve: resolve6, dirname: dirname4 } = await import('path');
5930
+ const { resolve: resolve7, dirname: dirname4 } = await import('path');
5677
5931
  const { fileURLToPath } = await import('url');
5678
5932
  try {
5679
5933
  const __dirname = dirname4(fileURLToPath(import.meta.url));
5680
- const docPath = resolve6(__dirname, "../../docs/CUSTOMIZATION.md");
5934
+ const docPath = resolve7(__dirname, "../../docs/CUSTOMIZATION.md");
5681
5935
  let content;
5682
5936
  try {
5683
5937
  content = await readFile3(docPath, "utf-8");
5684
5938
  } catch {
5685
- const altPath = resolve6(__dirname, "../docs/CUSTOMIZATION.md");
5939
+ const altPath = resolve7(__dirname, "../docs/CUSTOMIZATION.md");
5686
5940
  content = await readFile3(altPath, "utf-8");
5687
5941
  }
5688
5942
  if (selectedTopic !== "all") {