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.
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +32 -15
- package/dist/cli/index.js +769 -127
- package/dist/cli/index.js.map +1 -1
- package/dist/index.d.ts +138 -28
- package/dist/index.js +323 -29
- package/dist/index.js.map +1 -1
- package/dist/mcp/server.js +353 -43
- package/dist/mcp/server.js.map +1 -1
- package/docs/CUSTOMIZATION.md +10 -9
- package/package.json +1 -1
- package/skills/canicode-roundtrip/SKILL.md +142 -4
- package/skills/canicode-roundtrip/helpers-bootstrap.js +1 -1
- package/skills/canicode-roundtrip/helpers-installer.js +2 -2
- package/skills/canicode-roundtrip/helpers.js +41 -1
- package/skills/cursor/canicode-roundtrip/SKILL.md +142 -4
- package/skills/cursor/canicode-roundtrip/helpers-bootstrap.js +1 -1
- package/skills/cursor/canicode-roundtrip/helpers-installer.js +2 -2
- package/skills/cursor/canicode-roundtrip/helpers.js +41 -1
package/dist/mcp/server.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { writeFile, existsSync, statSync, mkdirSync,
|
|
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:
|
|
425
|
-
//
|
|
426
|
-
//
|
|
427
|
-
//
|
|
428
|
-
severity: "
|
|
429
|
-
score:
|
|
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: "
|
|
483
|
-
|
|
484
|
-
|
|
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: "
|
|
489
|
-
|
|
490
|
-
|
|
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
|
|
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
|
-
(
|
|
766
|
-
|
|
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.
|
|
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:
|
|
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
|
-
|
|
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
|
|
5215
|
-
acknowledgments: z.array(AcknowledgmentSchema).optional().describe("
|
|
5216
|
-
scope: z.enum(["page", "component"]).optional().describe("
|
|
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((
|
|
5553
|
+
await new Promise((resolve7, reject) => {
|
|
5252
5554
|
writeFile(reportPath, html, "utf-8", (err) => {
|
|
5253
5555
|
if (err) reject(err);
|
|
5254
|
-
else
|
|
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, {
|
|
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("
|
|
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 \`--\`.
|
|
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:
|
|
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 =
|
|
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 =
|
|
5922
|
+
const altPath = resolve7(__dirname, "../docs/CUSTOMIZATION.md");
|
|
5613
5923
|
content = await readFile3(altPath, "utf-8");
|
|
5614
5924
|
}
|
|
5615
5925
|
if (selectedTopic !== "all") {
|