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.
- package/dist/cli/index.js +436 -51
- package/dist/cli/index.js.map +1 -1
- package/dist/index.d.ts +119 -27
- package/dist/index.js +259 -15
- package/dist/index.js.map +1 -1
- package/dist/mcp/server.js +273 -19
- package/dist/mcp/server.js.map +1 -1
- package/package.json +1 -1
- package/skills/canicode-roundtrip/SKILL.md +62 -8
- 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 +62 -8
- 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/cli/index.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { existsSync, mkdirSync, writeFileSync, readFileSync, statSync, readdirSync, renameSync, chmodSync, copyFileSync } from 'fs';
|
|
3
|
-
import { join, resolve, dirname, basename, relative } from 'path';
|
|
3
|
+
import { join, resolve, dirname, basename, isAbsolute, sep, relative } from 'path';
|
|
4
4
|
import pixelmatch from 'pixelmatch';
|
|
5
5
|
import { PNG } from 'pngjs';
|
|
6
6
|
import { createRequire } from 'module';
|
|
@@ -132,6 +132,32 @@ var init_figma_client = __esm({
|
|
|
132
132
|
const buffer = await response.arrayBuffer();
|
|
133
133
|
return Buffer.from(buffer).toString("base64");
|
|
134
134
|
}
|
|
135
|
+
/**
|
|
136
|
+
* Get the components a file has published to a team library.
|
|
137
|
+
*
|
|
138
|
+
* `GET /v1/files/:file_key/components` returns only components that have
|
|
139
|
+
* been pushed via the Publish Library action — local-but-unpublished
|
|
140
|
+
* components are absent. This is the authoritative way to detect whether
|
|
141
|
+
* a Figma component is mappable via Code Connect (#532): `add_code_connect_map`
|
|
142
|
+
* requires a published component and otherwise fails with "Published
|
|
143
|
+
* component not found."
|
|
144
|
+
*/
|
|
145
|
+
async getPublishedComponents(fileKey) {
|
|
146
|
+
const url = `${FIGMA_API_BASE}/files/${fileKey}/components`;
|
|
147
|
+
const response = await fetch(url, {
|
|
148
|
+
headers: { "X-Figma-Token": this.token }
|
|
149
|
+
});
|
|
150
|
+
if (!response.ok) {
|
|
151
|
+
const error = await response.json().catch(() => ({}));
|
|
152
|
+
throw new FigmaClientError(
|
|
153
|
+
`Failed to fetch published components: ${response.status} ${response.statusText}`,
|
|
154
|
+
response.status,
|
|
155
|
+
error
|
|
156
|
+
);
|
|
157
|
+
}
|
|
158
|
+
const data = await response.json();
|
|
159
|
+
return data.meta?.components ?? [];
|
|
160
|
+
}
|
|
135
161
|
async getFileNodes(fileKey, nodeIds) {
|
|
136
162
|
const ids = nodeIds.join(",");
|
|
137
163
|
const url = `${FIGMA_API_BASE}/files/${fileKey}/nodes?ids=${encodeURIComponent(ids)}`;
|
|
@@ -3053,6 +3079,176 @@ defineRule({
|
|
|
3053
3079
|
definition: irregularSpacingDef,
|
|
3054
3080
|
check: irregularSpacingCheck
|
|
3055
3081
|
});
|
|
3082
|
+
var FIGMA_CONFIG_FILENAME = "figma.config.json";
|
|
3083
|
+
var FIGMA_CONNECT_FILE_GLOB = /\.figma\.(tsx?|jsx?)$/;
|
|
3084
|
+
var NODE_ID_QUERY_RE = /[?&]node-id=([0-9A-Za-z%:\-_]+)/;
|
|
3085
|
+
function parseCodeConnectMappings(cwd) {
|
|
3086
|
+
const configPath = join(cwd, FIGMA_CONFIG_FILENAME);
|
|
3087
|
+
if (!existsSync(configPath)) {
|
|
3088
|
+
return {
|
|
3089
|
+
mappedNodeIds: /* @__PURE__ */ new Set(),
|
|
3090
|
+
scannedFiles: [],
|
|
3091
|
+
skipReason: "no-config",
|
|
3092
|
+
skippedReason: `${FIGMA_CONFIG_FILENAME} not found at ${cwd}`
|
|
3093
|
+
};
|
|
3094
|
+
}
|
|
3095
|
+
let config2;
|
|
3096
|
+
try {
|
|
3097
|
+
config2 = JSON.parse(readFileSync(configPath, "utf-8"));
|
|
3098
|
+
} catch (err) {
|
|
3099
|
+
return {
|
|
3100
|
+
mappedNodeIds: /* @__PURE__ */ new Set(),
|
|
3101
|
+
scannedFiles: [],
|
|
3102
|
+
skipReason: "malformed-config",
|
|
3103
|
+
skippedReason: `malformed ${FIGMA_CONFIG_FILENAME}: ${err.message}`
|
|
3104
|
+
};
|
|
3105
|
+
}
|
|
3106
|
+
const includes = config2.codeConnect?.include ?? config2.include ?? [];
|
|
3107
|
+
if (includes.length === 0) {
|
|
3108
|
+
return {
|
|
3109
|
+
mappedNodeIds: /* @__PURE__ */ new Set(),
|
|
3110
|
+
scannedFiles: [],
|
|
3111
|
+
skipReason: "no-includes",
|
|
3112
|
+
skippedReason: `${FIGMA_CONFIG_FILENAME} has no codeConnect.include paths`
|
|
3113
|
+
};
|
|
3114
|
+
}
|
|
3115
|
+
const candidateFiles = /* @__PURE__ */ new Set();
|
|
3116
|
+
for (const includePattern of includes) {
|
|
3117
|
+
for (const file of resolveInclude(cwd, includePattern)) {
|
|
3118
|
+
candidateFiles.add(file);
|
|
3119
|
+
}
|
|
3120
|
+
}
|
|
3121
|
+
const mappedNodeIds = /* @__PURE__ */ new Set();
|
|
3122
|
+
const scannedFiles = [];
|
|
3123
|
+
for (const file of candidateFiles) {
|
|
3124
|
+
scannedFiles.push(file);
|
|
3125
|
+
let contents;
|
|
3126
|
+
try {
|
|
3127
|
+
contents = readFileSync(file, "utf-8");
|
|
3128
|
+
} catch {
|
|
3129
|
+
continue;
|
|
3130
|
+
}
|
|
3131
|
+
for (const nodeId of extractNodeIdsFromSource(contents)) {
|
|
3132
|
+
mappedNodeIds.add(nodeId);
|
|
3133
|
+
}
|
|
3134
|
+
}
|
|
3135
|
+
return { mappedNodeIds, scannedFiles };
|
|
3136
|
+
}
|
|
3137
|
+
function resolveInclude(cwd, includePattern) {
|
|
3138
|
+
const results = [];
|
|
3139
|
+
const absolute = isAbsolute(includePattern) ? includePattern : resolve(cwd, includePattern);
|
|
3140
|
+
const segments = absolute.split(sep);
|
|
3141
|
+
let firstGlobIdx = segments.findIndex((s) => /[*?{[]/.test(s));
|
|
3142
|
+
if (firstGlobIdx === -1) {
|
|
3143
|
+
if (existsSync(absolute)) {
|
|
3144
|
+
const stat = statSync(absolute);
|
|
3145
|
+
if (stat.isFile() && FIGMA_CONNECT_FILE_GLOB.test(absolute)) {
|
|
3146
|
+
results.push(absolute);
|
|
3147
|
+
} else if (stat.isDirectory()) {
|
|
3148
|
+
walkDir(absolute, results);
|
|
3149
|
+
}
|
|
3150
|
+
}
|
|
3151
|
+
return results;
|
|
3152
|
+
}
|
|
3153
|
+
const rootSegments = segments.slice(0, firstGlobIdx);
|
|
3154
|
+
const root = rootSegments.length === 0 ? sep : rootSegments.join(sep);
|
|
3155
|
+
if (!existsSync(root)) return results;
|
|
3156
|
+
const rootStat = statSync(root);
|
|
3157
|
+
if (!rootStat.isDirectory()) return results;
|
|
3158
|
+
walkDir(root, results);
|
|
3159
|
+
const prefix = rootSegments.join(sep) + sep;
|
|
3160
|
+
return results.filter((f) => f.startsWith(prefix) || rootSegments.length === 0);
|
|
3161
|
+
}
|
|
3162
|
+
function walkDir(dir, out) {
|
|
3163
|
+
let entries;
|
|
3164
|
+
try {
|
|
3165
|
+
entries = readdirSync(dir);
|
|
3166
|
+
} catch {
|
|
3167
|
+
return;
|
|
3168
|
+
}
|
|
3169
|
+
for (const entry of entries) {
|
|
3170
|
+
if (entry === "node_modules" || entry.startsWith(".")) continue;
|
|
3171
|
+
const full = join(dir, entry);
|
|
3172
|
+
let stat;
|
|
3173
|
+
try {
|
|
3174
|
+
stat = statSync(full);
|
|
3175
|
+
} catch {
|
|
3176
|
+
continue;
|
|
3177
|
+
}
|
|
3178
|
+
if (stat.isDirectory()) {
|
|
3179
|
+
walkDir(full, out);
|
|
3180
|
+
} else if (stat.isFile() && FIGMA_CONNECT_FILE_GLOB.test(full)) {
|
|
3181
|
+
out.push(full);
|
|
3182
|
+
}
|
|
3183
|
+
}
|
|
3184
|
+
}
|
|
3185
|
+
function extractNodeIdsFromSource(source) {
|
|
3186
|
+
const nodeIds = /* @__PURE__ */ new Set();
|
|
3187
|
+
const re = new RegExp(NODE_ID_QUERY_RE, "g");
|
|
3188
|
+
let match;
|
|
3189
|
+
while ((match = re.exec(source)) !== null) {
|
|
3190
|
+
const raw = match[1];
|
|
3191
|
+
if (!raw) continue;
|
|
3192
|
+
const decoded = safeDecode(raw);
|
|
3193
|
+
nodeIds.add(decoded.replace(/-/g, ":"));
|
|
3194
|
+
}
|
|
3195
|
+
return nodeIds;
|
|
3196
|
+
}
|
|
3197
|
+
function safeDecode(raw) {
|
|
3198
|
+
try {
|
|
3199
|
+
return decodeURIComponent(raw);
|
|
3200
|
+
} catch {
|
|
3201
|
+
return raw;
|
|
3202
|
+
}
|
|
3203
|
+
}
|
|
3204
|
+
var PropertyAcknowledgmentIntentSchema = z.object({
|
|
3205
|
+
kind: z.literal("property").default("property"),
|
|
3206
|
+
field: z.string(),
|
|
3207
|
+
value: z.unknown(),
|
|
3208
|
+
scope: z.enum(["instance", "definition"])
|
|
3209
|
+
});
|
|
3210
|
+
var RuleOptOutAcknowledgmentIntentSchema = z.object({
|
|
3211
|
+
kind: z.literal("rule-opt-out"),
|
|
3212
|
+
ruleId: z.string()
|
|
3213
|
+
}).strict();
|
|
3214
|
+
var AcknowledgmentIntentSchema = z.preprocess((raw) => {
|
|
3215
|
+
if (raw && typeof raw === "object" && !Array.isArray(raw)) {
|
|
3216
|
+
const obj = raw;
|
|
3217
|
+
if (obj["kind"] === void 0) {
|
|
3218
|
+
return { ...obj, kind: "property" };
|
|
3219
|
+
}
|
|
3220
|
+
}
|
|
3221
|
+
return raw;
|
|
3222
|
+
}, z.discriminatedUnion("kind", [
|
|
3223
|
+
PropertyAcknowledgmentIntentSchema,
|
|
3224
|
+
RuleOptOutAcknowledgmentIntentSchema
|
|
3225
|
+
]));
|
|
3226
|
+
function isRuleOptOutIntent(intent) {
|
|
3227
|
+
return intent !== void 0 && intent.kind === "rule-opt-out";
|
|
3228
|
+
}
|
|
3229
|
+
var AcknowledgmentSceneWriteOutcomeSchema = z.object({
|
|
3230
|
+
result: z.enum([
|
|
3231
|
+
"succeeded",
|
|
3232
|
+
"silent-ignored",
|
|
3233
|
+
"api-rejected",
|
|
3234
|
+
"user-declined-propagation",
|
|
3235
|
+
"unknown"
|
|
3236
|
+
]),
|
|
3237
|
+
reason: z.string().optional()
|
|
3238
|
+
});
|
|
3239
|
+
var AcknowledgmentSchema = z.object({
|
|
3240
|
+
nodeId: z.string(),
|
|
3241
|
+
ruleId: z.string(),
|
|
3242
|
+
intent: AcknowledgmentIntentSchema.optional(),
|
|
3243
|
+
sceneWriteOutcome: AcknowledgmentSceneWriteOutcomeSchema.optional(),
|
|
3244
|
+
codegenDirective: z.string().optional()
|
|
3245
|
+
});
|
|
3246
|
+
var AcknowledgmentListSchema = z.array(AcknowledgmentSchema);
|
|
3247
|
+
function normalizeNodeId(id) {
|
|
3248
|
+
return id.replace(/-/g, ":");
|
|
3249
|
+
}
|
|
3250
|
+
|
|
3251
|
+
// src/core/rules/component/index.ts
|
|
3056
3252
|
var STYLE_COMPARE_KEYS = ["fills", "strokes", "effects", "cornerRadius", "strokeWeight", "individualStrokeWeights"];
|
|
3057
3253
|
function detectStyleOverrides(master, instance) {
|
|
3058
3254
|
const overrides = [];
|
|
@@ -3261,11 +3457,23 @@ defineRule({
|
|
|
3261
3457
|
check: variantStructureMismatchCheck
|
|
3262
3458
|
});
|
|
3263
3459
|
var CODE_CONNECT_SETUP_KEY = "unmapped-component:setup-detected";
|
|
3460
|
+
var CODE_CONNECT_MAPPINGS_KEY = "unmapped-component:mappings";
|
|
3461
|
+
var SEEN_MAIN_IDS_KEY = "unmapped-component:seen-main-ids";
|
|
3264
3462
|
function codeConnectIsSetUp(context) {
|
|
3265
3463
|
return getAnalysisState(context, CODE_CONNECT_SETUP_KEY, () => {
|
|
3266
3464
|
return existsSync(join(process.cwd(), "figma.config.json"));
|
|
3267
3465
|
});
|
|
3268
3466
|
}
|
|
3467
|
+
function codeConnectMappings(context) {
|
|
3468
|
+
return getAnalysisState(
|
|
3469
|
+
context,
|
|
3470
|
+
CODE_CONNECT_MAPPINGS_KEY,
|
|
3471
|
+
() => parseCodeConnectMappings(process.cwd())
|
|
3472
|
+
);
|
|
3473
|
+
}
|
|
3474
|
+
function seenMainIds(context) {
|
|
3475
|
+
return getAnalysisState(context, SEEN_MAIN_IDS_KEY, () => /* @__PURE__ */ new Set());
|
|
3476
|
+
}
|
|
3269
3477
|
var unmappedComponentDef = {
|
|
3270
3478
|
id: "unmapped-component",
|
|
3271
3479
|
name: "Unmapped Component",
|
|
@@ -3275,14 +3483,33 @@ var unmappedComponentDef = {
|
|
|
3275
3483
|
fix: "Run /canicode-roundtrip on this component to register a mapping. Figma's get_code_connect_map will skip if a mapping already exists."
|
|
3276
3484
|
};
|
|
3277
3485
|
var unmappedComponentCheck = (node, context) => {
|
|
3278
|
-
if (node.type !== "COMPONENT" && node.type !== "COMPONENT_SET") return null;
|
|
3279
|
-
if (isInsideInstance(context)) return null;
|
|
3280
3486
|
if (!codeConnectIsSetUp(context)) return null;
|
|
3487
|
+
let mainId = null;
|
|
3488
|
+
let mainName = node.name;
|
|
3489
|
+
if (node.type === "COMPONENT" || node.type === "COMPONENT_SET") {
|
|
3490
|
+
if (isInsideInstance(context)) return null;
|
|
3491
|
+
mainId = node.id;
|
|
3492
|
+
} else if (node.type === "INSTANCE" && node.componentId) {
|
|
3493
|
+
mainId = node.componentId;
|
|
3494
|
+
const meta = context.file.components[node.componentId];
|
|
3495
|
+
if (meta?.name) mainName = meta.name;
|
|
3496
|
+
} else {
|
|
3497
|
+
return null;
|
|
3498
|
+
}
|
|
3499
|
+
const seen = seenMainIds(context);
|
|
3500
|
+
if (seen.has(mainId)) return null;
|
|
3501
|
+
seen.add(mainId);
|
|
3502
|
+
const mappings = codeConnectMappings(context);
|
|
3503
|
+
if (mappings.mappedNodeIds.has(mainId)) return null;
|
|
3504
|
+
const ack = context.findAcknowledgment(mainId, unmappedComponentDef.id);
|
|
3505
|
+
if (ack && isRuleOptOutIntent(ack.intent) && ack.intent.ruleId === unmappedComponentDef.id) {
|
|
3506
|
+
return null;
|
|
3507
|
+
}
|
|
3281
3508
|
return {
|
|
3282
3509
|
ruleId: unmappedComponentDef.id,
|
|
3283
|
-
nodeId:
|
|
3510
|
+
nodeId: mainId,
|
|
3284
3511
|
nodePath: context.path.join(" > "),
|
|
3285
|
-
...unmappedComponentMsg(
|
|
3512
|
+
...unmappedComponentMsg(mainName)
|
|
3286
3513
|
};
|
|
3287
3514
|
};
|
|
3288
3515
|
defineRule({
|
|
@@ -3645,32 +3872,6 @@ defineRule({
|
|
|
3645
3872
|
definition: missingPrototypeDef,
|
|
3646
3873
|
check: missingPrototypeCheck
|
|
3647
3874
|
});
|
|
3648
|
-
var AcknowledgmentIntentSchema = z.object({
|
|
3649
|
-
field: z.string(),
|
|
3650
|
-
value: z.unknown(),
|
|
3651
|
-
scope: z.enum(["instance", "definition"])
|
|
3652
|
-
});
|
|
3653
|
-
var AcknowledgmentSceneWriteOutcomeSchema = z.object({
|
|
3654
|
-
result: z.enum([
|
|
3655
|
-
"succeeded",
|
|
3656
|
-
"silent-ignored",
|
|
3657
|
-
"api-rejected",
|
|
3658
|
-
"user-declined-propagation",
|
|
3659
|
-
"unknown"
|
|
3660
|
-
]),
|
|
3661
|
-
reason: z.string().optional()
|
|
3662
|
-
});
|
|
3663
|
-
var AcknowledgmentSchema = z.object({
|
|
3664
|
-
nodeId: z.string(),
|
|
3665
|
-
ruleId: z.string(),
|
|
3666
|
-
intent: AcknowledgmentIntentSchema.optional(),
|
|
3667
|
-
sceneWriteOutcome: AcknowledgmentSceneWriteOutcomeSchema.optional(),
|
|
3668
|
-
codegenDirective: z.string().optional()
|
|
3669
|
-
});
|
|
3670
|
-
var AcknowledgmentListSchema = z.array(AcknowledgmentSchema);
|
|
3671
|
-
function normalizeNodeId(id) {
|
|
3672
|
-
return id.replace(/-/g, ":");
|
|
3673
|
-
}
|
|
3674
3875
|
var AnalysisScopeSchema = z.enum(["page", "component"]);
|
|
3675
3876
|
var COMPONENT_SCOPE_ROOT_TYPES = /* @__PURE__ */ new Set(["COMPONENT", "COMPONENT_SET", "INSTANCE"]);
|
|
3676
3877
|
function detectAnalysisScope(rootNode) {
|
|
@@ -3727,6 +3928,7 @@ var RuleEngine = class {
|
|
|
3727
3928
|
excludeNamePattern;
|
|
3728
3929
|
excludeNodeTypes;
|
|
3729
3930
|
acknowledgments;
|
|
3931
|
+
acknowledgmentsByKey;
|
|
3730
3932
|
scopeOverride;
|
|
3731
3933
|
constructor(options = {}) {
|
|
3732
3934
|
this.configs = options.configs ?? RULE_CONFIGS;
|
|
@@ -3735,10 +3937,15 @@ var RuleEngine = class {
|
|
|
3735
3937
|
this.targetNodeId = options.targetNodeId;
|
|
3736
3938
|
this.excludeNamePattern = options.excludeNodeNames && options.excludeNodeNames.length > 0 ? new RegExp(`\\b(${options.excludeNodeNames.join("|")})\\b`, "i") : null;
|
|
3737
3939
|
this.excludeNodeTypes = options.excludeNodeTypes && options.excludeNodeTypes.length > 0 ? new Set(options.excludeNodeTypes) : null;
|
|
3940
|
+
const ackList = options.acknowledgments ?? [];
|
|
3738
3941
|
this.acknowledgments = new Set(
|
|
3739
|
-
(
|
|
3740
|
-
|
|
3741
|
-
|
|
3942
|
+
ackList.map((a) => `${normalizeNodeId(a.nodeId)}::${a.ruleId}`)
|
|
3943
|
+
);
|
|
3944
|
+
this.acknowledgmentsByKey = new Map(
|
|
3945
|
+
ackList.map((a) => [
|
|
3946
|
+
`${normalizeNodeId(a.nodeId)}::${a.ruleId}`,
|
|
3947
|
+
a
|
|
3948
|
+
])
|
|
3742
3949
|
);
|
|
3743
3950
|
this.scopeOverride = options.scope;
|
|
3744
3951
|
}
|
|
@@ -3822,6 +4029,7 @@ var RuleEngine = class {
|
|
|
3822
4029
|
if (this.excludeNamePattern && this.excludeNamePattern.test(node.name)) {
|
|
3823
4030
|
return;
|
|
3824
4031
|
}
|
|
4032
|
+
const acknowledgmentsByKey = this.acknowledgmentsByKey;
|
|
3825
4033
|
const context = {
|
|
3826
4034
|
file,
|
|
3827
4035
|
parent,
|
|
@@ -3833,7 +4041,8 @@ var RuleEngine = class {
|
|
|
3833
4041
|
siblings,
|
|
3834
4042
|
analysisState,
|
|
3835
4043
|
scope,
|
|
3836
|
-
rootNodeType
|
|
4044
|
+
rootNodeType,
|
|
4045
|
+
findAcknowledgment: (nodeId, ruleId) => acknowledgmentsByKey.get(`${normalizeNodeId(nodeId)}::${ruleId}`)
|
|
3837
4046
|
};
|
|
3838
4047
|
for (const rule of rules) {
|
|
3839
4048
|
const ruleId = rule.definition.id;
|
|
@@ -4317,7 +4526,7 @@ function computeApplyContext(violation, instanceContext) {
|
|
|
4317
4526
|
}
|
|
4318
4527
|
|
|
4319
4528
|
// package.json
|
|
4320
|
-
var version2 = "0.12.
|
|
4529
|
+
var version2 = "0.12.2";
|
|
4321
4530
|
|
|
4322
4531
|
// src/core/engine/scoring.ts
|
|
4323
4532
|
var GRADE_ORDER = ["S", "A+", "A", "B+", "B", "C+", "C", "D", "F"];
|
|
@@ -4509,6 +4718,19 @@ function formatScoreSummary(report) {
|
|
|
4509
4718
|
}
|
|
4510
4719
|
return lines.join("\n");
|
|
4511
4720
|
}
|
|
4721
|
+
function formatCodeConnectCoverageLine(coverage) {
|
|
4722
|
+
const { mapped, total } = coverage;
|
|
4723
|
+
const pct = total === 0 ? 0 : Math.round(mapped / total * 100);
|
|
4724
|
+
return `Code Connect coverage: ${mapped}/${total} components (${pct}%) mapped`;
|
|
4725
|
+
}
|
|
4726
|
+
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.";
|
|
4727
|
+
function formatRoundtripOptOutHintLine(issues, acknowledgmentsProvided) {
|
|
4728
|
+
const hasUnmapped = issues.some(
|
|
4729
|
+
(issue) => issue.violation.ruleId === "unmapped-component"
|
|
4730
|
+
);
|
|
4731
|
+
if (!hasUnmapped) return null;
|
|
4732
|
+
return ROUNDTRIP_OPT_OUT_HINT;
|
|
4733
|
+
}
|
|
4512
4734
|
function buildResultJson(fileName, result, scores, options) {
|
|
4513
4735
|
const issuesByRule = {};
|
|
4514
4736
|
for (const issue of result.issues) {
|
|
@@ -4538,6 +4760,14 @@ function buildResultJson(fileName, result, scores, options) {
|
|
|
4538
4760
|
...issue.acknowledged === true ? { acknowledged: true } : {}
|
|
4539
4761
|
};
|
|
4540
4762
|
});
|
|
4763
|
+
const optOutHint = options?.roundtripOptOutHintEligible ? formatRoundtripOptOutHintLine(result.issues) : null;
|
|
4764
|
+
const summaryParts = [formatScoreSummary(scores)];
|
|
4765
|
+
if (options?.codeConnectCoverage) {
|
|
4766
|
+
summaryParts.push(formatCodeConnectCoverageLine(options.codeConnectCoverage));
|
|
4767
|
+
}
|
|
4768
|
+
if (optOutHint) {
|
|
4769
|
+
summaryParts.push(optOutHint);
|
|
4770
|
+
}
|
|
4541
4771
|
const json = {
|
|
4542
4772
|
version: version2,
|
|
4543
4773
|
analyzedAt: result.analyzedAt,
|
|
@@ -4557,13 +4787,31 @@ function buildResultJson(fileName, result, scores, options) {
|
|
|
4557
4787
|
},
|
|
4558
4788
|
issuesByRule,
|
|
4559
4789
|
issues,
|
|
4560
|
-
summary:
|
|
4790
|
+
summary: summaryParts.join("\n\n")
|
|
4561
4791
|
};
|
|
4792
|
+
if (options?.codeConnectCoverage) {
|
|
4793
|
+
json["codeConnectCoverage"] = options.codeConnectCoverage;
|
|
4794
|
+
}
|
|
4795
|
+
if (optOutHint) {
|
|
4796
|
+
json["roundtripOptOutHint"] = optOutHint;
|
|
4797
|
+
}
|
|
4562
4798
|
if (result.failedRules.length > 0) {
|
|
4563
4799
|
json["failedRules"] = result.failedRules;
|
|
4564
4800
|
}
|
|
4565
4801
|
return json;
|
|
4566
4802
|
}
|
|
4803
|
+
|
|
4804
|
+
// src/core/rules/component/code-connect-coverage.ts
|
|
4805
|
+
function computeCodeConnectCoverage(components, cwd = process.cwd()) {
|
|
4806
|
+
const result = parseCodeConnectMappings(cwd);
|
|
4807
|
+
if (result.skipReason === "no-config") return void 0;
|
|
4808
|
+
const componentNodeIds = Object.keys(components);
|
|
4809
|
+
let mapped = 0;
|
|
4810
|
+
for (const nodeId of componentNodeIds) {
|
|
4811
|
+
if (result.mappedNodeIds.has(nodeId)) mapped++;
|
|
4812
|
+
}
|
|
4813
|
+
return { mapped, total: componentNodeIds.length };
|
|
4814
|
+
}
|
|
4567
4815
|
function isFigmaUrl2(input) {
|
|
4568
4816
|
return input.includes("figma.com/");
|
|
4569
4817
|
}
|
|
@@ -5922,8 +6170,17 @@ Analyzing: ${file.name}`);
|
|
|
5922
6170
|
const result = analyzeFile(file, analyzeOptions);
|
|
5923
6171
|
log(`Nodes: ${result.nodeCount} (max depth: ${result.maxDepth})`);
|
|
5924
6172
|
const scores = calculateScores(result, configs);
|
|
6173
|
+
const coverage = computeCodeConnectCoverage(file.components);
|
|
6174
|
+
const optOutHintEligible = acknowledgments === void 0;
|
|
6175
|
+
const optOutHint = optOutHintEligible ? formatRoundtripOptOutHintLine(result.issues, false) : null;
|
|
5925
6176
|
if (options.json) {
|
|
5926
|
-
console.log(JSON.stringify(buildResultJson(file.name, result, scores, {
|
|
6177
|
+
console.log(JSON.stringify(buildResultJson(file.name, result, scores, {
|
|
6178
|
+
fileKey: file.fileKey,
|
|
6179
|
+
designKey: computeDesignKey(input),
|
|
6180
|
+
...effectiveMinGrade ? { codegenReadyMinGrade: effectiveMinGrade } : {},
|
|
6181
|
+
...coverage ? { codeConnectCoverage: coverage } : {},
|
|
6182
|
+
roundtripOptOutHintEligible: optOutHintEligible
|
|
6183
|
+
}), null, 2));
|
|
5927
6184
|
if (scores.overall.grade === "F") {
|
|
5928
6185
|
process.exitCode = 1;
|
|
5929
6186
|
}
|
|
@@ -5931,6 +6188,14 @@ Analyzing: ${file.name}`);
|
|
|
5931
6188
|
}
|
|
5932
6189
|
console.log("\n" + "=".repeat(50));
|
|
5933
6190
|
console.log(formatScoreSummary(scores));
|
|
6191
|
+
if (coverage) {
|
|
6192
|
+
console.log("");
|
|
6193
|
+
console.log(formatCodeConnectCoverageLine(coverage));
|
|
6194
|
+
}
|
|
6195
|
+
if (optOutHint) {
|
|
6196
|
+
console.log("");
|
|
6197
|
+
console.log(optOutHint);
|
|
6198
|
+
}
|
|
5934
6199
|
console.log("=".repeat(50));
|
|
5935
6200
|
const now = /* @__PURE__ */ new Date();
|
|
5936
6201
|
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")}`;
|
|
@@ -6785,8 +7050,8 @@ function renderUpsertedFile(args) {
|
|
|
6785
7050
|
}
|
|
6786
7051
|
let working = currentContent;
|
|
6787
7052
|
if (state === "missing-heading") {
|
|
6788
|
-
const
|
|
6789
|
-
working = `${working}${
|
|
7053
|
+
const sep2 = working.endsWith("\n\n") ? "" : working.endsWith("\n") ? "\n" : "\n\n";
|
|
7054
|
+
working = `${working}${sep2}${COLLECTED_GOTCHAS_HEADING}
|
|
6790
7055
|
`;
|
|
6791
7056
|
}
|
|
6792
7057
|
const plan = findOrAppendSection(working, designKey);
|
|
@@ -7645,6 +7910,9 @@ function registerConfig(cli2) {
|
|
|
7645
7910
|
}
|
|
7646
7911
|
});
|
|
7647
7912
|
}
|
|
7913
|
+
|
|
7914
|
+
// src/cli/commands/doctor.ts
|
|
7915
|
+
init_figma_client();
|
|
7648
7916
|
var CODE_CONNECT_PKG = "@figma/code-connect";
|
|
7649
7917
|
var CODE_CONNECT_DOCS = "https://www.figma.com/code-connect-docs/";
|
|
7650
7918
|
function readPackageJson(cwd) {
|
|
@@ -7692,10 +7960,102 @@ function runCodeConnectChecks(cwd) {
|
|
|
7692
7960
|
}
|
|
7693
7961
|
return results;
|
|
7694
7962
|
}
|
|
7963
|
+
var PUBLISH_CHECK_NAME = "Figma component published in a library";
|
|
7964
|
+
async function runFigmaPublishCheck(input) {
|
|
7965
|
+
const { figmaUrl, token, fetchPublishedComponents, fetchNodeType } = input;
|
|
7966
|
+
let parsed;
|
|
7967
|
+
try {
|
|
7968
|
+
parsed = parseFigmaUrl(figmaUrl);
|
|
7969
|
+
} catch (err) {
|
|
7970
|
+
const message = err instanceof FigmaUrlParseError ? err.message : String(err);
|
|
7971
|
+
return {
|
|
7972
|
+
name: PUBLISH_CHECK_NAME,
|
|
7973
|
+
pass: false,
|
|
7974
|
+
inconclusive: true,
|
|
7975
|
+
detail: `could not parse URL: ${message}`,
|
|
7976
|
+
remediation: "Pass a valid Figma design URL (figma.com/design/<file>?node-id=<id>)."
|
|
7977
|
+
};
|
|
7978
|
+
}
|
|
7979
|
+
if (!parsed.nodeId) {
|
|
7980
|
+
return {
|
|
7981
|
+
name: PUBLISH_CHECK_NAME,
|
|
7982
|
+
pass: false,
|
|
7983
|
+
inconclusive: true,
|
|
7984
|
+
detail: "URL is missing a node-id",
|
|
7985
|
+
remediation: "Code Connect mapping is per-component \u2014 invoke with a URL that targets a specific node (?node-id=\u2026)."
|
|
7986
|
+
};
|
|
7987
|
+
}
|
|
7988
|
+
if (!token) {
|
|
7989
|
+
return {
|
|
7990
|
+
name: PUBLISH_CHECK_NAME,
|
|
7991
|
+
pass: false,
|
|
7992
|
+
inconclusive: true,
|
|
7993
|
+
detail: "FIGMA_TOKEN not configured \u2014 skipping publish-status check",
|
|
7994
|
+
remediation: "Set FIGMA_TOKEN (env var) or run `canicode config set-token` so doctor can verify this prereq inline."
|
|
7995
|
+
};
|
|
7996
|
+
}
|
|
7997
|
+
if (!fetchPublishedComponents) {
|
|
7998
|
+
return {
|
|
7999
|
+
name: PUBLISH_CHECK_NAME,
|
|
8000
|
+
pass: false,
|
|
8001
|
+
inconclusive: true,
|
|
8002
|
+
detail: "no fetcher wired",
|
|
8003
|
+
remediation: "internal: doctor was called without a Figma client"
|
|
8004
|
+
};
|
|
8005
|
+
}
|
|
8006
|
+
let components;
|
|
8007
|
+
try {
|
|
8008
|
+
components = await fetchPublishedComponents(parsed.fileKey);
|
|
8009
|
+
} catch (err) {
|
|
8010
|
+
const status = err instanceof FigmaClientError ? err.statusCode : void 0;
|
|
8011
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
8012
|
+
return {
|
|
8013
|
+
name: PUBLISH_CHECK_NAME,
|
|
8014
|
+
pass: false,
|
|
8015
|
+
inconclusive: true,
|
|
8016
|
+
detail: `Figma API call failed${status ? ` (HTTP ${status})` : ""}: ${message}`,
|
|
8017
|
+
remediation: "Step 7d will rely on the API as the authority; if your token / network is OK, the canicode-roundtrip step itself will surface the publish error inline."
|
|
8018
|
+
};
|
|
8019
|
+
}
|
|
8020
|
+
const canonicalNodeId = parsed.nodeId.replace(/-/g, ":");
|
|
8021
|
+
const match = components.find(
|
|
8022
|
+
(c) => c.node_id === canonicalNodeId || c.node_id === parsed.nodeId
|
|
8023
|
+
);
|
|
8024
|
+
if (match) {
|
|
8025
|
+
return {
|
|
8026
|
+
name: PUBLISH_CHECK_NAME,
|
|
8027
|
+
pass: true,
|
|
8028
|
+
detail: `${match.name} (${match.node_id})`
|
|
8029
|
+
};
|
|
8030
|
+
}
|
|
8031
|
+
if (fetchNodeType) {
|
|
8032
|
+
let nodeType;
|
|
8033
|
+
try {
|
|
8034
|
+
nodeType = await fetchNodeType(parsed.fileKey, canonicalNodeId);
|
|
8035
|
+
} catch {
|
|
8036
|
+
nodeType = void 0;
|
|
8037
|
+
}
|
|
8038
|
+
if (nodeType && nodeType !== "COMPONENT" && nodeType !== "COMPONENT_SET") {
|
|
8039
|
+
return {
|
|
8040
|
+
name: PUBLISH_CHECK_NAME,
|
|
8041
|
+
pass: false,
|
|
8042
|
+
inconclusive: true,
|
|
8043
|
+
detail: `node ${canonicalNodeId} is type ${nodeType} \u2014 Code Connect mapping is per-component`,
|
|
8044
|
+
remediation: "Step 7 (Code Connect close-out) skips on screen-level scope anyway. To verify a specific component, re-invoke doctor with that component's URL."
|
|
8045
|
+
};
|
|
8046
|
+
}
|
|
8047
|
+
}
|
|
8048
|
+
return {
|
|
8049
|
+
name: PUBLISH_CHECK_NAME,
|
|
8050
|
+
pass: false,
|
|
8051
|
+
detail: `node ${canonicalNodeId} is not in the published-components list for file ${parsed.fileKey}`,
|
|
8052
|
+
remediation: "Open the file in Figma \u2192 Assets panel \u2192 Publish library and include this component. Without publishing, `add_code_connect_map` fails with 'Published component not found.'"
|
|
8053
|
+
};
|
|
8054
|
+
}
|
|
7695
8055
|
function formatDoctorReport(results) {
|
|
7696
8056
|
const lines = ["Code Connect"];
|
|
7697
8057
|
for (const result of results) {
|
|
7698
|
-
const icon = result.pass ? "\u2705" : "\u274C";
|
|
8058
|
+
const icon = result.pass ? "\u2705" : result.inconclusive ? "\u26A0\uFE0F" : "\u274C";
|
|
7699
8059
|
const detail = result.detail ? ` (${result.detail})` : "";
|
|
7700
8060
|
lines.push(` ${icon} ${result.name}${detail}`);
|
|
7701
8061
|
if (!result.pass && result.remediation) {
|
|
@@ -7703,22 +8063,47 @@ function formatDoctorReport(results) {
|
|
|
7703
8063
|
}
|
|
7704
8064
|
}
|
|
7705
8065
|
lines.push("");
|
|
7706
|
-
const
|
|
7707
|
-
|
|
7708
|
-
|
|
7709
|
-
|
|
8066
|
+
const blocking = results.filter((r) => !r.pass && !r.inconclusive).length;
|
|
8067
|
+
const inconclusive = results.filter((r) => !r.pass && r.inconclusive).length;
|
|
8068
|
+
if (blocking === 0 && inconclusive === 0) {
|
|
8069
|
+
lines.push("All checks passed.");
|
|
8070
|
+
} else if (blocking === 0) {
|
|
8071
|
+
lines.push(
|
|
8072
|
+
"Blocking checks passed; some checks were skipped (\u26A0\uFE0F) and could not be verified."
|
|
8073
|
+
);
|
|
8074
|
+
} else {
|
|
8075
|
+
lines.push("Some checks failed. Fix the items above before running the Code Connect flow.");
|
|
8076
|
+
}
|
|
7710
8077
|
return lines.join("\n");
|
|
7711
8078
|
}
|
|
7712
8079
|
function registerDoctor(cli2) {
|
|
7713
8080
|
cli2.command(
|
|
7714
8081
|
"doctor",
|
|
7715
8082
|
"Diagnose Code Connect prerequisites (`@figma/code-connect`, `figma.config.json`)"
|
|
7716
|
-
).
|
|
8083
|
+
).option(
|
|
8084
|
+
"--figma-url <url>",
|
|
8085
|
+
"Optionally check that the target Figma component is published in a library (requires FIGMA_TOKEN)"
|
|
8086
|
+
).action(async (options) => {
|
|
7717
8087
|
const cwd = process.cwd();
|
|
7718
8088
|
const results = runCodeConnectChecks(cwd);
|
|
8089
|
+
if (options.figmaUrl) {
|
|
8090
|
+
const token = getFigmaToken();
|
|
8091
|
+
const client = token ? new FigmaClient({ token }) : void 0;
|
|
8092
|
+
const publishCheck = await runFigmaPublishCheck({
|
|
8093
|
+
figmaUrl: options.figmaUrl,
|
|
8094
|
+
token,
|
|
8095
|
+
fetchPublishedComponents: client ? (fileKey) => client.getPublishedComponents(fileKey) : void 0,
|
|
8096
|
+
fetchNodeType: client ? async (fileKey, nodeId) => {
|
|
8097
|
+
const response = await client.getFileNodes(fileKey, [nodeId]);
|
|
8098
|
+
return response.nodes[nodeId]?.document.type;
|
|
8099
|
+
} : void 0
|
|
8100
|
+
});
|
|
8101
|
+
results.push(publishCheck);
|
|
8102
|
+
}
|
|
7719
8103
|
console.log(formatDoctorReport(results));
|
|
7720
8104
|
const passed = results.filter((r) => r.pass).length;
|
|
7721
|
-
const
|
|
8105
|
+
const inconclusive = results.filter((r) => !r.pass && r.inconclusive).length;
|
|
8106
|
+
const failed = results.length - passed - inconclusive;
|
|
7722
8107
|
trackEvent(EVENTS.CLI_DOCTOR, {
|
|
7723
8108
|
passed,
|
|
7724
8109
|
failed,
|
|
@@ -10856,8 +11241,8 @@ ${msg}`);
|
|
|
10856
11241
|
if (vectorDir && existsSync(vectorDir)) {
|
|
10857
11242
|
const vecOutputDir = resolve(outputDir, "vectors");
|
|
10858
11243
|
mkdirSync(vecOutputDir, { recursive: true });
|
|
10859
|
-
const { readdirSync:
|
|
10860
|
-
const vecFiles =
|
|
11244
|
+
const { readdirSync: readdirSync5, copyFileSync: copyFileSync3 } = await import('fs');
|
|
11245
|
+
const vecFiles = readdirSync5(vectorDir).filter((f) => f.endsWith(".svg") || f === "mapping.json");
|
|
10861
11246
|
for (const f of vecFiles) {
|
|
10862
11247
|
copyFileSync3(resolve(vectorDir, f), resolve(vecOutputDir, f));
|
|
10863
11248
|
}
|
|
@@ -10867,8 +11252,8 @@ ${msg}`);
|
|
|
10867
11252
|
if (imageDir && existsSync(imageDir)) {
|
|
10868
11253
|
const imgOutputDir = resolve(outputDir, "images");
|
|
10869
11254
|
mkdirSync(imgOutputDir, { recursive: true });
|
|
10870
|
-
const { readdirSync:
|
|
10871
|
-
const imgFiles =
|
|
11255
|
+
const { readdirSync: readdirSync5, copyFileSync: copyFileSync3 } = await import('fs');
|
|
11256
|
+
const imgFiles = readdirSync5(imageDir).filter((f) => f.endsWith(".png") || f.endsWith(".jpg") || f.endsWith(".json"));
|
|
10872
11257
|
for (const f of imgFiles) {
|
|
10873
11258
|
copyFileSync3(resolve(imageDir, f), resolve(imgOutputDir, f));
|
|
10874
11259
|
}
|