canicode 0.12.0 → 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';
@@ -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.1";
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,19 @@ 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";
4869
5085
  function codeConnectIsSetUp(context) {
4870
5086
  return getAnalysisState(context, CODE_CONNECT_SETUP_KEY, () => {
4871
5087
  return existsSync(join(process.cwd(), "figma.config.json"));
4872
5088
  });
4873
5089
  }
5090
+ function codeConnectMappings(context) {
5091
+ return getAnalysisState(
5092
+ context,
5093
+ CODE_CONNECT_MAPPINGS_KEY,
5094
+ () => parseCodeConnectMappings(process.cwd())
5095
+ );
5096
+ }
4874
5097
  var unmappedComponentDef = {
4875
5098
  id: "unmapped-component",
4876
5099
  name: "Unmapped Component",
@@ -4883,6 +5106,12 @@ var unmappedComponentCheck = (node, context) => {
4883
5106
  if (node.type !== "COMPONENT" && node.type !== "COMPONENT_SET") return null;
4884
5107
  if (isInsideInstance(context)) return null;
4885
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
+ }
4886
5115
  return {
4887
5116
  ruleId: unmappedComponentDef.id,
4888
5117
  nodeId: node.id,
@@ -5321,10 +5550,10 @@ Provide a Figma URL or fixture path via the input parameter. Requires FIGMA_TOKE
5321
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")}`;
5322
5551
  ensureReportsDir();
5323
5552
  const reportPath = `${getReportsDir()}/report-${ts}-${file.fileKey}.html`;
5324
- await new Promise((resolve6, reject) => {
5553
+ await new Promise((resolve7, reject) => {
5325
5554
  writeFile(reportPath, html, "utf-8", (err) => {
5326
5555
  if (err) reject(err);
5327
- else resolve6();
5556
+ else resolve7();
5328
5557
  });
5329
5558
  });
5330
5559
  if (openReport === true) {
@@ -5338,11 +5567,19 @@ Provide a Figma URL or fixture path via the input parameter. Requires FIGMA_TOKE
5338
5567
  percentage: scores.overall.percentage,
5339
5568
  source: isJsonFile(input) || isFixtureDir(input) ? "fixture" : "figma"
5340
5569
  });
5570
+ const coverage = computeCodeConnectCoverage(file.components);
5571
+ const optOutHintEligible = acknowledgments === void 0;
5341
5572
  return {
5342
5573
  content: [
5343
5574
  {
5344
5575
  type: "text",
5345
- 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)
5346
5583
  }
5347
5584
  ]
5348
5585
  };
@@ -5673,16 +5910,16 @@ ${inlineTopics[selectedTopic]}` }]
5673
5910
  };
5674
5911
  }
5675
5912
  const { readFile: readFile3 } = await import('fs/promises');
5676
- const { resolve: resolve6, dirname: dirname4 } = await import('path');
5913
+ const { resolve: resolve7, dirname: dirname4 } = await import('path');
5677
5914
  const { fileURLToPath } = await import('url');
5678
5915
  try {
5679
5916
  const __dirname = dirname4(fileURLToPath(import.meta.url));
5680
- const docPath = resolve6(__dirname, "../../docs/CUSTOMIZATION.md");
5917
+ const docPath = resolve7(__dirname, "../../docs/CUSTOMIZATION.md");
5681
5918
  let content;
5682
5919
  try {
5683
5920
  content = await readFile3(docPath, "utf-8");
5684
5921
  } catch {
5685
- const altPath = resolve6(__dirname, "../docs/CUSTOMIZATION.md");
5922
+ const altPath = resolve7(__dirname, "../docs/CUSTOMIZATION.md");
5686
5923
  content = await readFile3(altPath, "utf-8");
5687
5924
  }
5688
5925
  if (selectedTopic !== "all") {