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/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';
|
|
@@ -10,6 +10,7 @@ import { randomUUID } from 'crypto';
|
|
|
10
10
|
import { homedir } from 'os';
|
|
11
11
|
import { z } from 'zod';
|
|
12
12
|
import { readFile, writeFile } from 'fs/promises';
|
|
13
|
+
import * as readline from 'readline/promises';
|
|
13
14
|
import { createInterface } from 'readline/promises';
|
|
14
15
|
import { pathToFileURL, fileURLToPath } from 'url';
|
|
15
16
|
|
|
@@ -131,6 +132,32 @@ var init_figma_client = __esm({
|
|
|
131
132
|
const buffer = await response.arrayBuffer();
|
|
132
133
|
return Buffer.from(buffer).toString("base64");
|
|
133
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
|
+
}
|
|
134
161
|
async getFileNodes(fileKey, nodeIds) {
|
|
135
162
|
const ids = nodeIds.join(",");
|
|
136
163
|
const url = `${FIGMA_API_BASE}/files/${fileKey}/nodes?ids=${encodeURIComponent(ids)}`;
|
|
@@ -1371,6 +1398,8 @@ var EVENTS = {
|
|
|
1371
1398
|
// CLI
|
|
1372
1399
|
CLI_COMMAND: `${EVENT_PREFIX}cli_command`,
|
|
1373
1400
|
CLI_INIT: `${EVENT_PREFIX}cli_init`,
|
|
1401
|
+
CLI_CONFIG_SET_TOKEN: `${EVENT_PREFIX}cli_config_set_token`,
|
|
1402
|
+
CLI_DOCTOR: `${EVENT_PREFIX}cli_doctor`,
|
|
1374
1403
|
// Roundtrip (ADR-012)
|
|
1375
1404
|
// Wiring point for the roundtrip helper's `telemetry` callback. No Node-side
|
|
1376
1405
|
// orchestrator reads this yet — the helper ships in a sandbox-pure IIFE that
|
|
@@ -1547,12 +1576,14 @@ CANICODE SETUP GUIDE
|
|
|
1547
1576
|
npm install -g canicode
|
|
1548
1577
|
|
|
1549
1578
|
Setup:
|
|
1550
|
-
canicode init
|
|
1579
|
+
canicode init (interactive prompt; TTY)
|
|
1580
|
+
canicode init --token figd_xxxxxxxxxxxxx (non-TTY / CI)
|
|
1581
|
+
FIGMA_TOKEN=figd_xxx canicode init (env-driven)
|
|
1551
1582
|
(saves token + installs Claude Code skills into ./.claude/skills/)
|
|
1552
1583
|
|
|
1553
1584
|
Skills only (no token yet):
|
|
1554
1585
|
canicode init --cursor-skills
|
|
1555
|
-
(installs Claude skills + Cursor copies; run init --token \u2026 before live Figma REST URLs)
|
|
1586
|
+
(installs Claude skills + Cursor copies; run init or init --token \u2026 before live Figma REST URLs)
|
|
1556
1587
|
|
|
1557
1588
|
Use:
|
|
1558
1589
|
canicode analyze "https://www.figma.com/design/ABC123/MyDesign?node-id=1-234"
|
|
@@ -1562,7 +1593,7 @@ CANICODE SETUP GUIDE
|
|
|
1562
1593
|
--preset strict|relaxed|dev-friendly|ai-ready
|
|
1563
1594
|
--config ./my-config.json
|
|
1564
1595
|
--no-open Don't open report in browser
|
|
1565
|
-
--api No-op for Figma URLs (REST always); same flag as gotcha-survey
|
|
1596
|
+
--api No-op for Figma URLs (REST always); same flag as gotcha-survey
|
|
1566
1597
|
|
|
1567
1598
|
Output:
|
|
1568
1599
|
~/.canicode/reports/report-YYYY-MM-DD-HH-mm-<filekey>.html
|
|
@@ -1574,7 +1605,8 @@ CANICODE SETUP GUIDE
|
|
|
1574
1605
|
(Same token safety as above \u2014 env var or interactive prompt, not chat.)
|
|
1575
1606
|
|
|
1576
1607
|
Setup:
|
|
1577
|
-
canicode init
|
|
1608
|
+
canicode init (interactive prompt; TTY)
|
|
1609
|
+
canicode init --token figd_xxxxxxxxxxxxx (non-TTY / CI)
|
|
1578
1610
|
(installs three skills into ./.claude/skills/ alongside the token)
|
|
1579
1611
|
|
|
1580
1612
|
Installed skills:
|
|
@@ -1599,7 +1631,8 @@ CANICODE SETUP GUIDE
|
|
|
1599
1631
|
(Same token safety as above \u2014 env var or interactive prompt, not chat.)
|
|
1600
1632
|
|
|
1601
1633
|
Setup:
|
|
1602
|
-
canicode init --
|
|
1634
|
+
canicode init --cursor-skills (interactive prompt; TTY)
|
|
1635
|
+
canicode init --token figd_xxxxxxxxxxxxx --cursor-skills (non-TTY / CI)
|
|
1603
1636
|
(installs Cursor copies of the three skills into ./.cursor/skills/)
|
|
1604
1637
|
|
|
1605
1638
|
Installed skills:
|
|
@@ -1629,6 +1662,14 @@ CANICODE SETUP GUIDE
|
|
|
1629
1662
|
See also: docs/CUSTOMIZATION.md#cursor-mcp-canicode (Figma MCP required for roundtrip
|
|
1630
1663
|
writes; analyze-only works without it).
|
|
1631
1664
|
|
|
1665
|
+
\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501
|
|
1666
|
+
MANAGE CONFIG
|
|
1667
|
+
\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501
|
|
1668
|
+
|
|
1669
|
+
canicode config set-token Rotate Figma token (no skill reinstall)
|
|
1670
|
+
canicode config show Print masked token + config + reports paths
|
|
1671
|
+
canicode config path Print absolute config path (script-friendly)
|
|
1672
|
+
|
|
1632
1673
|
\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501
|
|
1633
1674
|
TOKEN PRIORITY
|
|
1634
1675
|
\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501
|
|
@@ -1821,6 +1862,7 @@ var RULE_ID_CATEGORY = {
|
|
|
1821
1862
|
"detached-instance": "code-quality",
|
|
1822
1863
|
"variant-structure-mismatch": "code-quality",
|
|
1823
1864
|
"deep-nesting": "code-quality",
|
|
1865
|
+
"unmapped-component": "code-quality",
|
|
1824
1866
|
// Token Management
|
|
1825
1867
|
"raw-value": "token-management",
|
|
1826
1868
|
"irregular-spacing": "token-management",
|
|
@@ -1851,6 +1893,12 @@ var RULE_PURPOSE = {
|
|
|
1851
1893
|
"detached-instance": "violation",
|
|
1852
1894
|
"variant-structure-mismatch": "violation",
|
|
1853
1895
|
"deep-nesting": "violation",
|
|
1896
|
+
// #520: unmapped-component is annotation-primary. Fires only when the
|
|
1897
|
+
// user has Code Connect set up at all (figma.config.json present in cwd).
|
|
1898
|
+
// The gotcha drives the user to /canicode-roundtrip for actual mapping
|
|
1899
|
+
// registration via the Figma MCP tools — analyze itself does not parse
|
|
1900
|
+
// mapping declarations (deferred to v1.5).
|
|
1901
|
+
"unmapped-component": "info-collection",
|
|
1854
1902
|
// Token Management
|
|
1855
1903
|
"raw-value": "violation",
|
|
1856
1904
|
"irregular-spacing": "violation",
|
|
@@ -1894,12 +1942,12 @@ var RULE_CONFIGS = {
|
|
|
1894
1942
|
enabled: true
|
|
1895
1943
|
},
|
|
1896
1944
|
"missing-size-constraint": {
|
|
1897
|
-
// #403:
|
|
1898
|
-
//
|
|
1899
|
-
//
|
|
1900
|
-
//
|
|
1901
|
-
severity: "
|
|
1902
|
-
score:
|
|
1945
|
+
// #403 → #519: info-collection rule. Score is 0 (severity `note`):
|
|
1946
|
+
// its value is the gotcha annotation, not the grade impact. Survey-
|
|
1947
|
+
// generator includes this rule via the `purpose === "info-collection"`
|
|
1948
|
+
// branch so the gotcha keeps surfacing.
|
|
1949
|
+
severity: "note",
|
|
1950
|
+
score: 0,
|
|
1903
1951
|
enabled: true
|
|
1904
1952
|
},
|
|
1905
1953
|
// ── Code Quality ──
|
|
@@ -1931,6 +1979,16 @@ var RULE_CONFIGS = {
|
|
|
1931
1979
|
maxDepth: 5
|
|
1932
1980
|
}
|
|
1933
1981
|
},
|
|
1982
|
+
"unmapped-component": {
|
|
1983
|
+
// #520 / #519: zero-impact tier. Fires per main component when Code
|
|
1984
|
+
// Connect is set up in the consuming repo (figma.config.json at cwd).
|
|
1985
|
+
// Score is 0 because the rule's value is the gotcha + roundtrip handoff,
|
|
1986
|
+
// not the grade signal — designers who deliberately do not map (e.g.
|
|
1987
|
+
// marketing-only banners) are not punished.
|
|
1988
|
+
severity: "note",
|
|
1989
|
+
score: 0,
|
|
1990
|
+
enabled: true
|
|
1991
|
+
},
|
|
1934
1992
|
// ── Token Management ──
|
|
1935
1993
|
"raw-value": {
|
|
1936
1994
|
severity: "missing-info",
|
|
@@ -1952,15 +2010,15 @@ var RULE_CONFIGS = {
|
|
|
1952
2010
|
// is minimal. Score stays at -1 so re-enabling `missing-prototype` on
|
|
1953
2011
|
// fixtures that lack `interactionDestinations` (#139) cannot swing grades.
|
|
1954
2012
|
"missing-interaction-state": {
|
|
1955
|
-
severity: "
|
|
1956
|
-
|
|
1957
|
-
|
|
2013
|
+
severity: "note",
|
|
2014
|
+
// #519: info-collection rule, zero-score tier
|
|
2015
|
+
score: 0,
|
|
1958
2016
|
enabled: true
|
|
1959
2017
|
},
|
|
1960
2018
|
"missing-prototype": {
|
|
1961
|
-
severity: "
|
|
1962
|
-
|
|
1963
|
-
|
|
2019
|
+
severity: "note",
|
|
2020
|
+
// #519: info-collection — annotation is primary output, no grade impact
|
|
2021
|
+
score: 0,
|
|
1964
2022
|
enabled: true
|
|
1965
2023
|
},
|
|
1966
2024
|
// ── Semantic ──
|
|
@@ -2165,7 +2223,14 @@ var SeveritySchema = z.enum([
|
|
|
2165
2223
|
"blocking",
|
|
2166
2224
|
"risk",
|
|
2167
2225
|
"missing-info",
|
|
2168
|
-
"suggestion"
|
|
2226
|
+
"suggestion",
|
|
2227
|
+
/**
|
|
2228
|
+
* `note` is the zero-impact tier (#519): findings render in the report but
|
|
2229
|
+
* never move the grade. Used for annotation-primary rules whose value is the
|
|
2230
|
+
* nudge, not the score (e.g. unmapped Code Connect components, info-collection
|
|
2231
|
+
* rules whose answers belong in figma-implement-design context, not in linting).
|
|
2232
|
+
*/
|
|
2233
|
+
"note"
|
|
2169
2234
|
]);
|
|
2170
2235
|
|
|
2171
2236
|
// src/core/contracts/rule.ts
|
|
@@ -2459,6 +2524,10 @@ var missingComponentMsg = {
|
|
|
2459
2524
|
suggestion: `Create a new variant for this style combination`
|
|
2460
2525
|
})
|
|
2461
2526
|
};
|
|
2527
|
+
var unmappedComponentMsg = (componentName) => ({
|
|
2528
|
+
message: `"${componentName}" has no Code Connect mapping`,
|
|
2529
|
+
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.`
|
|
2530
|
+
});
|
|
2462
2531
|
var detachedInstanceMsg = (name, componentName) => ({
|
|
2463
2532
|
message: `"${name}" may be a detached instance of component "${componentName}"`,
|
|
2464
2533
|
suggestion: `Restore as an instance of "${componentName}" or create a new variant`
|
|
@@ -3010,6 +3079,174 @@ defineRule({
|
|
|
3010
3079
|
definition: irregularSpacingDef,
|
|
3011
3080
|
check: irregularSpacingCheck
|
|
3012
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
|
+
}
|
|
3013
3250
|
|
|
3014
3251
|
// src/core/rules/component/index.ts
|
|
3015
3252
|
var STYLE_COMPARE_KEYS = ["fills", "strokes", "effects", "cornerRadius", "strokeWeight", "individualStrokeWeights"];
|
|
@@ -3219,6 +3456,49 @@ defineRule({
|
|
|
3219
3456
|
definition: variantStructureMismatchDef,
|
|
3220
3457
|
check: variantStructureMismatchCheck
|
|
3221
3458
|
});
|
|
3459
|
+
var CODE_CONNECT_SETUP_KEY = "unmapped-component:setup-detected";
|
|
3460
|
+
var CODE_CONNECT_MAPPINGS_KEY = "unmapped-component:mappings";
|
|
3461
|
+
function codeConnectIsSetUp(context) {
|
|
3462
|
+
return getAnalysisState(context, CODE_CONNECT_SETUP_KEY, () => {
|
|
3463
|
+
return existsSync(join(process.cwd(), "figma.config.json"));
|
|
3464
|
+
});
|
|
3465
|
+
}
|
|
3466
|
+
function codeConnectMappings(context) {
|
|
3467
|
+
return getAnalysisState(
|
|
3468
|
+
context,
|
|
3469
|
+
CODE_CONNECT_MAPPINGS_KEY,
|
|
3470
|
+
() => parseCodeConnectMappings(process.cwd())
|
|
3471
|
+
);
|
|
3472
|
+
}
|
|
3473
|
+
var unmappedComponentDef = {
|
|
3474
|
+
id: "unmapped-component",
|
|
3475
|
+
name: "Unmapped Component",
|
|
3476
|
+
category: "code-quality",
|
|
3477
|
+
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.",
|
|
3478
|
+
impact: "Future roundtrips on screens containing this component cannot reuse your existing code; they regenerate markup that may not match the canonical implementation.",
|
|
3479
|
+
fix: "Run /canicode-roundtrip on this component to register a mapping. Figma's get_code_connect_map will skip if a mapping already exists."
|
|
3480
|
+
};
|
|
3481
|
+
var unmappedComponentCheck = (node, context) => {
|
|
3482
|
+
if (node.type !== "COMPONENT" && node.type !== "COMPONENT_SET") return null;
|
|
3483
|
+
if (isInsideInstance(context)) return null;
|
|
3484
|
+
if (!codeConnectIsSetUp(context)) return null;
|
|
3485
|
+
const mappings = codeConnectMappings(context);
|
|
3486
|
+
if (mappings.mappedNodeIds.has(node.id)) return null;
|
|
3487
|
+
const ack = context.findAcknowledgment(node.id, unmappedComponentDef.id);
|
|
3488
|
+
if (ack && isRuleOptOutIntent(ack.intent) && ack.intent.ruleId === unmappedComponentDef.id) {
|
|
3489
|
+
return null;
|
|
3490
|
+
}
|
|
3491
|
+
return {
|
|
3492
|
+
ruleId: unmappedComponentDef.id,
|
|
3493
|
+
nodeId: node.id,
|
|
3494
|
+
nodePath: context.path.join(" > "),
|
|
3495
|
+
...unmappedComponentMsg(node.name)
|
|
3496
|
+
};
|
|
3497
|
+
};
|
|
3498
|
+
defineRule({
|
|
3499
|
+
definition: unmappedComponentDef,
|
|
3500
|
+
check: unmappedComponentCheck
|
|
3501
|
+
});
|
|
3222
3502
|
|
|
3223
3503
|
// src/core/rules/naming/index.ts
|
|
3224
3504
|
function capitalize(s) {
|
|
@@ -3575,32 +3855,6 @@ defineRule({
|
|
|
3575
3855
|
definition: missingPrototypeDef,
|
|
3576
3856
|
check: missingPrototypeCheck
|
|
3577
3857
|
});
|
|
3578
|
-
var AcknowledgmentIntentSchema = z.object({
|
|
3579
|
-
field: z.string(),
|
|
3580
|
-
value: z.unknown(),
|
|
3581
|
-
scope: z.enum(["instance", "definition"])
|
|
3582
|
-
});
|
|
3583
|
-
var AcknowledgmentSceneWriteOutcomeSchema = z.object({
|
|
3584
|
-
result: z.enum([
|
|
3585
|
-
"succeeded",
|
|
3586
|
-
"silent-ignored",
|
|
3587
|
-
"api-rejected",
|
|
3588
|
-
"user-declined-propagation",
|
|
3589
|
-
"unknown"
|
|
3590
|
-
]),
|
|
3591
|
-
reason: z.string().optional()
|
|
3592
|
-
});
|
|
3593
|
-
var AcknowledgmentSchema = z.object({
|
|
3594
|
-
nodeId: z.string(),
|
|
3595
|
-
ruleId: z.string(),
|
|
3596
|
-
intent: AcknowledgmentIntentSchema.optional(),
|
|
3597
|
-
sceneWriteOutcome: AcknowledgmentSceneWriteOutcomeSchema.optional(),
|
|
3598
|
-
codegenDirective: z.string().optional()
|
|
3599
|
-
});
|
|
3600
|
-
var AcknowledgmentListSchema = z.array(AcknowledgmentSchema);
|
|
3601
|
-
function normalizeNodeId(id) {
|
|
3602
|
-
return id.replace(/-/g, ":");
|
|
3603
|
-
}
|
|
3604
3858
|
var AnalysisScopeSchema = z.enum(["page", "component"]);
|
|
3605
3859
|
var COMPONENT_SCOPE_ROOT_TYPES = /* @__PURE__ */ new Set(["COMPONENT", "COMPONENT_SET", "INSTANCE"]);
|
|
3606
3860
|
function detectAnalysisScope(rootNode) {
|
|
@@ -3657,6 +3911,7 @@ var RuleEngine = class {
|
|
|
3657
3911
|
excludeNamePattern;
|
|
3658
3912
|
excludeNodeTypes;
|
|
3659
3913
|
acknowledgments;
|
|
3914
|
+
acknowledgmentsByKey;
|
|
3660
3915
|
scopeOverride;
|
|
3661
3916
|
constructor(options = {}) {
|
|
3662
3917
|
this.configs = options.configs ?? RULE_CONFIGS;
|
|
@@ -3665,10 +3920,15 @@ var RuleEngine = class {
|
|
|
3665
3920
|
this.targetNodeId = options.targetNodeId;
|
|
3666
3921
|
this.excludeNamePattern = options.excludeNodeNames && options.excludeNodeNames.length > 0 ? new RegExp(`\\b(${options.excludeNodeNames.join("|")})\\b`, "i") : null;
|
|
3667
3922
|
this.excludeNodeTypes = options.excludeNodeTypes && options.excludeNodeTypes.length > 0 ? new Set(options.excludeNodeTypes) : null;
|
|
3923
|
+
const ackList = options.acknowledgments ?? [];
|
|
3668
3924
|
this.acknowledgments = new Set(
|
|
3669
|
-
(
|
|
3670
|
-
|
|
3671
|
-
|
|
3925
|
+
ackList.map((a) => `${normalizeNodeId(a.nodeId)}::${a.ruleId}`)
|
|
3926
|
+
);
|
|
3927
|
+
this.acknowledgmentsByKey = new Map(
|
|
3928
|
+
ackList.map((a) => [
|
|
3929
|
+
`${normalizeNodeId(a.nodeId)}::${a.ruleId}`,
|
|
3930
|
+
a
|
|
3931
|
+
])
|
|
3672
3932
|
);
|
|
3673
3933
|
this.scopeOverride = options.scope;
|
|
3674
3934
|
}
|
|
@@ -3752,6 +4012,7 @@ var RuleEngine = class {
|
|
|
3752
4012
|
if (this.excludeNamePattern && this.excludeNamePattern.test(node.name)) {
|
|
3753
4013
|
return;
|
|
3754
4014
|
}
|
|
4015
|
+
const acknowledgmentsByKey = this.acknowledgmentsByKey;
|
|
3755
4016
|
const context = {
|
|
3756
4017
|
file,
|
|
3757
4018
|
parent,
|
|
@@ -3763,7 +4024,8 @@ var RuleEngine = class {
|
|
|
3763
4024
|
siblings,
|
|
3764
4025
|
analysisState,
|
|
3765
4026
|
scope,
|
|
3766
|
-
rootNodeType
|
|
4027
|
+
rootNodeType,
|
|
4028
|
+
findAcknowledgment: (nodeId, ruleId) => acknowledgmentsByKey.get(`${normalizeNodeId(nodeId)}::${ruleId}`)
|
|
3767
4029
|
};
|
|
3768
4030
|
for (const rule of rules) {
|
|
3769
4031
|
const ruleId = rule.definition.id;
|
|
@@ -4187,6 +4449,7 @@ var STRATEGY_BY_RULE = {
|
|
|
4187
4449
|
// Strategy C — annotation only
|
|
4188
4450
|
"absolute-position-in-auto-layout": "annotation",
|
|
4189
4451
|
"variant-structure-mismatch": "annotation",
|
|
4452
|
+
"unmapped-component": "annotation",
|
|
4190
4453
|
// Strategy D — auto-fix lower-severity issues from analyze output
|
|
4191
4454
|
"non-standard-naming": "auto-fix",
|
|
4192
4455
|
"inconsistent-naming-convention": "auto-fix",
|
|
@@ -4221,6 +4484,7 @@ function resolveTargetProperty(ruleId, subType) {
|
|
|
4221
4484
|
case "raw-value":
|
|
4222
4485
|
case "missing-interaction-state":
|
|
4223
4486
|
case "missing-prototype":
|
|
4487
|
+
case "unmapped-component":
|
|
4224
4488
|
return void 0;
|
|
4225
4489
|
}
|
|
4226
4490
|
}
|
|
@@ -4245,7 +4509,7 @@ function computeApplyContext(violation, instanceContext) {
|
|
|
4245
4509
|
}
|
|
4246
4510
|
|
|
4247
4511
|
// package.json
|
|
4248
|
-
var version2 = "0.
|
|
4512
|
+
var version2 = "0.12.1";
|
|
4249
4513
|
|
|
4250
4514
|
// src/core/engine/scoring.ts
|
|
4251
4515
|
var GRADE_ORDER = ["S", "A+", "A", "B+", "B", "C+", "C", "D", "F"];
|
|
@@ -4352,6 +4616,7 @@ function calculateScores(result, configs) {
|
|
|
4352
4616
|
risk: 0,
|
|
4353
4617
|
missingInfo: 0,
|
|
4354
4618
|
suggestion: 0,
|
|
4619
|
+
note: 0,
|
|
4355
4620
|
nodeCount,
|
|
4356
4621
|
acknowledgedCount: 0
|
|
4357
4622
|
};
|
|
@@ -4369,6 +4634,9 @@ function calculateScores(result, configs) {
|
|
|
4369
4634
|
case "suggestion":
|
|
4370
4635
|
summary.suggestion++;
|
|
4371
4636
|
break;
|
|
4637
|
+
case "note":
|
|
4638
|
+
summary.note++;
|
|
4639
|
+
break;
|
|
4372
4640
|
}
|
|
4373
4641
|
if (issue.acknowledged === true) summary.acknowledgedCount++;
|
|
4374
4642
|
}
|
|
@@ -4400,7 +4668,8 @@ function initializeCategoryScores() {
|
|
|
4400
4668
|
blocking: 0,
|
|
4401
4669
|
risk: 0,
|
|
4402
4670
|
"missing-info": 0,
|
|
4403
|
-
suggestion: 0
|
|
4671
|
+
suggestion: 0,
|
|
4672
|
+
note: 0
|
|
4404
4673
|
}
|
|
4405
4674
|
};
|
|
4406
4675
|
}
|
|
@@ -4421,6 +4690,7 @@ function formatScoreSummary(report) {
|
|
|
4421
4690
|
lines.push(` Risk: ${report.summary.risk}`);
|
|
4422
4691
|
lines.push(` Missing Info: ${report.summary.missingInfo}`);
|
|
4423
4692
|
lines.push(` Suggestion: ${report.summary.suggestion}`);
|
|
4693
|
+
lines.push(` Note: ${report.summary.note}`);
|
|
4424
4694
|
if (report.summary.acknowledgedCount > 0) {
|
|
4425
4695
|
const unaddressed = report.summary.totalIssues - report.summary.acknowledgedCount;
|
|
4426
4696
|
lines.push(
|
|
@@ -4431,6 +4701,19 @@ function formatScoreSummary(report) {
|
|
|
4431
4701
|
}
|
|
4432
4702
|
return lines.join("\n");
|
|
4433
4703
|
}
|
|
4704
|
+
function formatCodeConnectCoverageLine(coverage) {
|
|
4705
|
+
const { mapped, total } = coverage;
|
|
4706
|
+
const pct = total === 0 ? 0 : Math.round(mapped / total * 100);
|
|
4707
|
+
return `Code Connect coverage: ${mapped}/${total} components (${pct}%) mapped`;
|
|
4708
|
+
}
|
|
4709
|
+
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.";
|
|
4710
|
+
function formatRoundtripOptOutHintLine(issues, acknowledgmentsProvided) {
|
|
4711
|
+
const hasUnmapped = issues.some(
|
|
4712
|
+
(issue) => issue.violation.ruleId === "unmapped-component"
|
|
4713
|
+
);
|
|
4714
|
+
if (!hasUnmapped) return null;
|
|
4715
|
+
return ROUNDTRIP_OPT_OUT_HINT;
|
|
4716
|
+
}
|
|
4434
4717
|
function buildResultJson(fileName, result, scores, options) {
|
|
4435
4718
|
const issuesByRule = {};
|
|
4436
4719
|
for (const issue of result.issues) {
|
|
@@ -4460,6 +4743,14 @@ function buildResultJson(fileName, result, scores, options) {
|
|
|
4460
4743
|
...issue.acknowledged === true ? { acknowledged: true } : {}
|
|
4461
4744
|
};
|
|
4462
4745
|
});
|
|
4746
|
+
const optOutHint = options?.roundtripOptOutHintEligible ? formatRoundtripOptOutHintLine(result.issues) : null;
|
|
4747
|
+
const summaryParts = [formatScoreSummary(scores)];
|
|
4748
|
+
if (options?.codeConnectCoverage) {
|
|
4749
|
+
summaryParts.push(formatCodeConnectCoverageLine(options.codeConnectCoverage));
|
|
4750
|
+
}
|
|
4751
|
+
if (optOutHint) {
|
|
4752
|
+
summaryParts.push(optOutHint);
|
|
4753
|
+
}
|
|
4463
4754
|
const json = {
|
|
4464
4755
|
version: version2,
|
|
4465
4756
|
analyzedAt: result.analyzedAt,
|
|
@@ -4479,13 +4770,31 @@ function buildResultJson(fileName, result, scores, options) {
|
|
|
4479
4770
|
},
|
|
4480
4771
|
issuesByRule,
|
|
4481
4772
|
issues,
|
|
4482
|
-
summary:
|
|
4773
|
+
summary: summaryParts.join("\n\n")
|
|
4483
4774
|
};
|
|
4775
|
+
if (options?.codeConnectCoverage) {
|
|
4776
|
+
json["codeConnectCoverage"] = options.codeConnectCoverage;
|
|
4777
|
+
}
|
|
4778
|
+
if (optOutHint) {
|
|
4779
|
+
json["roundtripOptOutHint"] = optOutHint;
|
|
4780
|
+
}
|
|
4484
4781
|
if (result.failedRules.length > 0) {
|
|
4485
4782
|
json["failedRules"] = result.failedRules;
|
|
4486
4783
|
}
|
|
4487
4784
|
return json;
|
|
4488
4785
|
}
|
|
4786
|
+
|
|
4787
|
+
// src/core/rules/component/code-connect-coverage.ts
|
|
4788
|
+
function computeCodeConnectCoverage(components, cwd = process.cwd()) {
|
|
4789
|
+
const result = parseCodeConnectMappings(cwd);
|
|
4790
|
+
if (result.skipReason === "no-config") return void 0;
|
|
4791
|
+
const componentNodeIds = Object.keys(components);
|
|
4792
|
+
let mapped = 0;
|
|
4793
|
+
for (const nodeId of componentNodeIds) {
|
|
4794
|
+
if (result.mappedNodeIds.has(nodeId)) mapped++;
|
|
4795
|
+
}
|
|
4796
|
+
return { mapped, total: componentNodeIds.length };
|
|
4797
|
+
}
|
|
4489
4798
|
function isFigmaUrl2(input) {
|
|
4490
4799
|
return input.includes("figma.com/");
|
|
4491
4800
|
}
|
|
@@ -4571,7 +4880,8 @@ function severityDot(sev) {
|
|
|
4571
4880
|
blocking: "sev-blocking",
|
|
4572
4881
|
risk: "sev-risk",
|
|
4573
4882
|
"missing-info": "sev-missing",
|
|
4574
|
-
suggestion: "sev-suggestion"
|
|
4883
|
+
suggestion: "sev-suggestion",
|
|
4884
|
+
note: "sev-note"
|
|
4575
4885
|
};
|
|
4576
4886
|
return map[sev];
|
|
4577
4887
|
}
|
|
@@ -4580,7 +4890,8 @@ function severityBadge(sev) {
|
|
|
4580
4890
|
blocking: "sev-blocking",
|
|
4581
4891
|
risk: "sev-risk",
|
|
4582
4892
|
"missing-info": "sev-missing",
|
|
4583
|
-
suggestion: "sev-suggestion"
|
|
4893
|
+
suggestion: "sev-suggestion",
|
|
4894
|
+
note: "sev-note"
|
|
4584
4895
|
};
|
|
4585
4896
|
return map[sev];
|
|
4586
4897
|
}
|
|
@@ -4638,6 +4949,7 @@ ${CATEGORIES.map((cat) => {
|
|
|
4638
4949
|
${renderSummaryDot("sev-risk", scores.summary.risk, "Risk")}
|
|
4639
4950
|
${renderSummaryDot("sev-missing", scores.summary.missingInfo, "Missing Info")}
|
|
4640
4951
|
${renderSummaryDot("sev-suggestion", scores.summary.suggestion, "Suggestion")}
|
|
4952
|
+
${renderSummaryDot("sev-note", scores.summary.note, "Note")}
|
|
4641
4953
|
<div class="rpt-summary-total">
|
|
4642
4954
|
<span class="rpt-summary-count">${scores.summary.totalIssues}</span>
|
|
4643
4955
|
<span class="rpt-summary-label">Total</span>
|
|
@@ -4852,7 +5164,7 @@ function groupIssuesByRule(issues) {
|
|
|
4852
5164
|
group.issues.push(issue);
|
|
4853
5165
|
group.totalScore += issue.calculatedScore;
|
|
4854
5166
|
}
|
|
4855
|
-
const SEVERITY_RANK = { blocking: 0, risk: 1, "missing-info": 2, suggestion: 3 };
|
|
5167
|
+
const SEVERITY_RANK = { blocking: 0, risk: 1, "missing-info": 2, suggestion: 3, note: 4 };
|
|
4856
5168
|
return [...byRule.values()].sort((a, b) => {
|
|
4857
5169
|
const sevDiff = (SEVERITY_RANK[a.severity] ?? 4) - (SEVERITY_RANK[b.severity] ?? 4);
|
|
4858
5170
|
return sevDiff !== 0 ? sevDiff : a.totalScore - b.totalScore;
|
|
@@ -4920,6 +5232,7 @@ body {
|
|
|
4920
5232
|
.sev-risk { background: var(--amber); }
|
|
4921
5233
|
.sev-missing { background: #a1a1aa; }
|
|
4922
5234
|
.sev-suggestion { background: var(--green); }
|
|
5235
|
+
.sev-note { background: #d4d4d8; }
|
|
4923
5236
|
|
|
4924
5237
|
/* ---- Print ---- */
|
|
4925
5238
|
@media print {
|
|
@@ -5316,6 +5629,7 @@ body {
|
|
|
5316
5629
|
.rpt-issue-score.sev-risk { background: var(--amber-bg); color: #d97706; border-color: rgba(245,158,11,0.2); }
|
|
5317
5630
|
.rpt-issue-score.sev-missing { background: #f4f4f5; color: #71717a; border-color: rgba(113,113,122,0.2); }
|
|
5318
5631
|
.rpt-issue-score.sev-suggestion { background: var(--green-bg); color: #16a34a; border-color: rgba(34,197,94,0.2); }
|
|
5632
|
+
.rpt-issue-score.sev-note { background: #f4f4f5; color: #71717a; border-color: rgba(113,113,122,0.2); }
|
|
5319
5633
|
|
|
5320
5634
|
.rpt-issue-body {
|
|
5321
5635
|
padding: 12px;
|
|
@@ -5741,8 +6055,8 @@ var AnalyzeOptionsSchema = z.object({
|
|
|
5741
6055
|
function registerAnalyze(cli2) {
|
|
5742
6056
|
cli2.command("analyze <input>", "Analyze a Figma file or JSON fixture").option("--preset <preset>", "Analysis preset (relaxed | dev-friendly | ai-ready | strict)").option("--output <path>", "HTML report output path").option("--token <token>", "Figma API token (or use FIGMA_TOKEN env var)").option(
|
|
5743
6057
|
"--api",
|
|
5744
|
-
"No-op for Figma URL inputs (file data is always fetched via REST). Accepted for CLI parity with `gotcha-survey
|
|
5745
|
-
).option("--screenshot", "Include screenshot comparison in report (requires ANTHROPIC_API_KEY)").option("--config <path>", "Path to config JSON file (override rule scores/settings)").option("--no-open", "Don't open report in browser after analysis").option("--json", "Output JSON results to stdout (same format as MCP)").option("--acknowledgments <path>", "
|
|
6058
|
+
"No-op for Figma URL inputs (file data is always fetched via REST). Accepted for CLI parity with `gotcha-survey`."
|
|
6059
|
+
).option("--screenshot", "Include screenshot comparison in report (requires ANTHROPIC_API_KEY)").option("--config <path>", "Path to config JSON file (override rule scores/settings)").option("--no-open", "Don't open report in browser after analysis").option("--json", "Output JSON results to stdout (same format as MCP)").option("--acknowledgments <path>", "Path to JSON acknowledgments from canicode Figma annotations (nodeId, ruleId; optional intent / sceneWriteOutcome / codegenDirective). Matching issues are flagged acknowledged and contribute half weight to density.").option("--scope <scope>", "Override analysis scope: `page` (screen/section \u2014 container bounds are required) or `component` (standalone reusable unit \u2014 root FILL is the design contract). Defaults to auto-detection from the root node type.").option("--ready-min-grade <grade>", "Minimum grade for code-gen readiness (S | A+ | A | B+ | B | C+ | C | D | F). Overrides configPath codegenReadyMinGrade. Default: A").example(" canicode analyze https://www.figma.com/design/ABC123/MyDesign").example(" canicode analyze ./fixtures/my-design --output report.html").example(" canicode analyze ./fixtures/my-design --config ./my-config.json").action(async (input, rawOptions) => {
|
|
5746
6060
|
const parseResult = AnalyzeOptionsSchema.safeParse(rawOptions);
|
|
5747
6061
|
if (!parseResult.success) {
|
|
5748
6062
|
const msg = parseResult.error.issues.map((i) => `--${i.path.join(".")}: ${i.message}`).join("\n");
|
|
@@ -5839,8 +6153,17 @@ Analyzing: ${file.name}`);
|
|
|
5839
6153
|
const result = analyzeFile(file, analyzeOptions);
|
|
5840
6154
|
log(`Nodes: ${result.nodeCount} (max depth: ${result.maxDepth})`);
|
|
5841
6155
|
const scores = calculateScores(result, configs);
|
|
6156
|
+
const coverage = computeCodeConnectCoverage(file.components);
|
|
6157
|
+
const optOutHintEligible = acknowledgments === void 0;
|
|
6158
|
+
const optOutHint = optOutHintEligible ? formatRoundtripOptOutHintLine(result.issues, false) : null;
|
|
5842
6159
|
if (options.json) {
|
|
5843
|
-
console.log(JSON.stringify(buildResultJson(file.name, result, scores, {
|
|
6160
|
+
console.log(JSON.stringify(buildResultJson(file.name, result, scores, {
|
|
6161
|
+
fileKey: file.fileKey,
|
|
6162
|
+
designKey: computeDesignKey(input),
|
|
6163
|
+
...effectiveMinGrade ? { codegenReadyMinGrade: effectiveMinGrade } : {},
|
|
6164
|
+
...coverage ? { codeConnectCoverage: coverage } : {},
|
|
6165
|
+
roundtripOptOutHintEligible: optOutHintEligible
|
|
6166
|
+
}), null, 2));
|
|
5844
6167
|
if (scores.overall.grade === "F") {
|
|
5845
6168
|
process.exitCode = 1;
|
|
5846
6169
|
}
|
|
@@ -5848,6 +6171,14 @@ Analyzing: ${file.name}`);
|
|
|
5848
6171
|
}
|
|
5849
6172
|
console.log("\n" + "=".repeat(50));
|
|
5850
6173
|
console.log(formatScoreSummary(scores));
|
|
6174
|
+
if (coverage) {
|
|
6175
|
+
console.log("");
|
|
6176
|
+
console.log(formatCodeConnectCoverageLine(coverage));
|
|
6177
|
+
}
|
|
6178
|
+
if (optOutHint) {
|
|
6179
|
+
console.log("");
|
|
6180
|
+
console.log(optOutHint);
|
|
6181
|
+
}
|
|
5851
6182
|
console.log("=".repeat(50));
|
|
5852
6183
|
const now = /* @__PURE__ */ new Date();
|
|
5853
6184
|
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")}`;
|
|
@@ -5961,6 +6292,12 @@ var GOTCHA_QUESTION_CONTENT = {
|
|
|
5961
6292
|
hint: "Describe which variant has the correct structure, or if they should all match",
|
|
5962
6293
|
example: "Default variant is canonical \u2014 other variants should toggle child visibility instead of adding/removing elements"
|
|
5963
6294
|
},
|
|
6295
|
+
"unmapped-component": {
|
|
6296
|
+
ruleId: "unmapped-component",
|
|
6297
|
+
question: '"{nodeName}" has no Code Connect mapping yet. Should we register one so figma-implement-design reuses your code?',
|
|
6298
|
+
hint: "Skip if this component is intentionally unmapped (e.g. marketing-only banner). Otherwise run /canicode-roundtrip on the component to walk through registration.",
|
|
6299
|
+
example: "Yes \u2014 map to src/components/Button.tsx so future screens reuse the existing implementation"
|
|
6300
|
+
},
|
|
5964
6301
|
"deep-nesting": {
|
|
5965
6302
|
ruleId: "deep-nesting",
|
|
5966
6303
|
question: '"{nodeName}" is deeply nested. Can some intermediate layers be flattened or extracted?',
|
|
@@ -6106,10 +6443,7 @@ function generateGotchaSurvey(result, scores, options = {}) {
|
|
|
6106
6443
|
const relevantIssues = result.issues.filter((issue) => {
|
|
6107
6444
|
const severity = issue.config.severity;
|
|
6108
6445
|
if (severity === "blocking" || severity === "risk") return true;
|
|
6109
|
-
|
|
6110
|
-
return getRulePurpose(issue.violation.ruleId) === "info-collection";
|
|
6111
|
-
}
|
|
6112
|
-
return false;
|
|
6446
|
+
return getRulePurpose(issue.violation.ruleId) === "info-collection";
|
|
6113
6447
|
});
|
|
6114
6448
|
const deduped = deduplicateSiblingIssues(relevantIssues);
|
|
6115
6449
|
const sorted = stableSortBySeverity(deduped);
|
|
@@ -6322,8 +6656,8 @@ function formatHumanSummary(survey) {
|
|
|
6322
6656
|
function registerGotchaSurvey(cli2) {
|
|
6323
6657
|
cli2.command("gotcha-survey <input>", "Generate a gotcha survey from a Figma design analysis").option("--preset <preset>", "Analysis preset (relaxed | dev-friendly | ai-ready | strict)").option("--token <token>", "Figma API token (or use FIGMA_TOKEN env var)").option(
|
|
6324
6658
|
"--api",
|
|
6325
|
-
"No-op for Figma URL inputs (file data is always fetched via REST). Accepted for CLI parity with `analyze
|
|
6326
|
-
).option("--config <path>", "Path to config JSON file (override rule scores/settings)").option("--target-node-id <id>", "Scope analysis to a specific node ID").option("--scope <scope>", "
|
|
6659
|
+
"No-op for Figma URL inputs (file data is always fetched via REST). Accepted for CLI parity with `analyze`."
|
|
6660
|
+
).option("--config <path>", "Path to config JSON file (override rule scores/settings)").option("--target-node-id <id>", "Scope analysis to a specific node ID").option("--scope <scope>", "Override analysis scope: `page` or `component`. Defaults to auto-detection from the root node type.").option("--json", "Output GotchaSurvey JSON to stdout (same format as MCP)").option("--ready-min-grade <grade>", "Minimum grade for code-gen readiness (S | A+ | A | B+ | B | C+ | C | D | F). Overrides configPath codegenReadyMinGrade. Default: A").example(" canicode gotcha-survey https://www.figma.com/design/ABC123/MyDesign --json").example(" canicode gotcha-survey ./fixtures/my-design --json").action(async (input, rawOptions) => {
|
|
6327
6661
|
const parseResult = GotchaSurveyOptionsSchema.safeParse(rawOptions);
|
|
6328
6662
|
if (!parseResult.success) {
|
|
6329
6663
|
const msg = parseResult.error.issues.map((i) => `--${i.path.join(".")}: ${i.message}`).join("\n");
|
|
@@ -6699,8 +7033,8 @@ function renderUpsertedFile(args) {
|
|
|
6699
7033
|
}
|
|
6700
7034
|
let working = currentContent;
|
|
6701
7035
|
if (state === "missing-heading") {
|
|
6702
|
-
const
|
|
6703
|
-
working = `${working}${
|
|
7036
|
+
const sep2 = working.endsWith("\n\n") ? "" : working.endsWith("\n") ? "\n" : "\n\n";
|
|
7037
|
+
working = `${working}${sep2}${COLLECTED_GOTCHAS_HEADING}
|
|
6704
7038
|
`;
|
|
6705
7039
|
}
|
|
6706
7040
|
const plan = findOrAppendSection(working, designKey);
|
|
@@ -6748,7 +7082,7 @@ var UpsertOptionsSchema = z.object({
|
|
|
6748
7082
|
});
|
|
6749
7083
|
var USER_MESSAGES = {
|
|
6750
7084
|
missing: "Gotchas SKILL.md not found at the given path. Run `canicode init` first, then re-invoke this skill.",
|
|
6751
|
-
clobbered: "Your gotchas SKILL.md is missing the canicode YAML frontmatter
|
|
7085
|
+
clobbered: "Your gotchas SKILL.md is missing the canicode YAML frontmatter. Run `canicode init --force` to restore the workflow, then re-run this survey."
|
|
6752
7086
|
};
|
|
6753
7087
|
async function readStdin() {
|
|
6754
7088
|
const chunks = [];
|
|
@@ -7189,6 +7523,47 @@ async function promptOverwriteBatch(candidates) {
|
|
|
7189
7523
|
}
|
|
7190
7524
|
return decisions;
|
|
7191
7525
|
}
|
|
7526
|
+
var NonInteractiveError = class extends Error {
|
|
7527
|
+
constructor(message = "Interactive prompt requires a TTY") {
|
|
7528
|
+
super(message);
|
|
7529
|
+
this.name = "NonInteractiveError";
|
|
7530
|
+
}
|
|
7531
|
+
};
|
|
7532
|
+
async function promptForFigmaToken(opts = {}) {
|
|
7533
|
+
const isTTY = opts.isTTY ?? process.stdin.isTTY ?? false;
|
|
7534
|
+
if (!isTTY) {
|
|
7535
|
+
throw new NonInteractiveError();
|
|
7536
|
+
}
|
|
7537
|
+
const input = opts.input ?? process.stdin;
|
|
7538
|
+
const output = opts.output ?? process.stdout;
|
|
7539
|
+
const maxAttempts = opts.maxAttempts ?? 3;
|
|
7540
|
+
const rl = readline.createInterface({ input, output });
|
|
7541
|
+
try {
|
|
7542
|
+
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
7543
|
+
const answer = (await rl.question("Figma token: ")).trim();
|
|
7544
|
+
if (answer.length > 0) {
|
|
7545
|
+
return answer;
|
|
7546
|
+
}
|
|
7547
|
+
if (attempt < maxAttempts) {
|
|
7548
|
+
output.write("Token cannot be empty. Try again.\n");
|
|
7549
|
+
}
|
|
7550
|
+
}
|
|
7551
|
+
throw new Error(`No token provided after ${maxAttempts} attempts`);
|
|
7552
|
+
} finally {
|
|
7553
|
+
rl.close();
|
|
7554
|
+
}
|
|
7555
|
+
}
|
|
7556
|
+
function maskFigmaToken(token) {
|
|
7557
|
+
if (!token) return "(empty)";
|
|
7558
|
+
const BULLETS = "\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022";
|
|
7559
|
+
if (token.startsWith("figd_") && token.length > 9) {
|
|
7560
|
+
return `figd_${BULLETS}${token.slice(-4)}`;
|
|
7561
|
+
}
|
|
7562
|
+
if (token.length >= 4) {
|
|
7563
|
+
return `${BULLETS}${token.slice(-4)}`;
|
|
7564
|
+
}
|
|
7565
|
+
return "\u2022".repeat(token.length);
|
|
7566
|
+
}
|
|
7192
7567
|
|
|
7193
7568
|
// src/cli/commands/init.ts
|
|
7194
7569
|
function figmaEntryInMcpFile(filePath) {
|
|
@@ -7217,7 +7592,7 @@ function formatNextSteps(opts) {
|
|
|
7217
7592
|
" Next:",
|
|
7218
7593
|
" 1. Restart Cursor or reload MCP (so skills + MCP tools load in a fresh session)",
|
|
7219
7594
|
" 2. In Agent chat, @ canicode-roundtrip with your Figma URL (or @ canicode-gotchas for survey-only)",
|
|
7220
|
-
" \u2022 Optional \u2014 faster canicode MCP than `npx`: add `canicode-mcp` per https://github.com/let-sunny/canicode/blob/main/docs/CUSTOMIZATION.md#cursor-mcp-canicode (project `.cursor/mcp.json`), then reload MCP; otherwise skills keep using `npx canicode \u2026
|
|
7595
|
+
" \u2022 Optional \u2014 faster canicode MCP than `npx`: add `canicode-mcp` per https://github.com/let-sunny/canicode/blob/main/docs/CUSTOMIZATION.md#cursor-mcp-canicode (project `.cursor/mcp.json`), then reload MCP; otherwise skills keep using `npx canicode \u2026`."
|
|
7221
7596
|
].join("\n");
|
|
7222
7597
|
}
|
|
7223
7598
|
return [
|
|
@@ -7225,7 +7600,7 @@ function formatNextSteps(opts) {
|
|
|
7225
7600
|
" Next:",
|
|
7226
7601
|
" 1. Restart Claude Code (the newly installed skills only load on a fresh session)",
|
|
7227
7602
|
" 2. Run /canicode-roundtrip <figma-url>",
|
|
7228
|
-
" \u2022 Optional \u2014 faster canicode MCP than `npx`: `claude mcp add canicode -- npx --yes --package=canicode canicode-mcp`, then restart Claude Code so `analyze` / `gotcha-survey` tools load \u2014 otherwise skills shell out to `npx canicode \u2026
|
|
7603
|
+
" \u2022 Optional \u2014 faster canicode MCP than `npx`: `claude mcp add canicode -- npx --yes --package=canicode canicode-mcp`, then restart Claude Code so `analyze` / `gotcha-survey` tools load \u2014 otherwise skills shell out to `npx canicode \u2026`."
|
|
7229
7604
|
].join("\n");
|
|
7230
7605
|
}
|
|
7231
7606
|
if (cursor) {
|
|
@@ -7235,7 +7610,7 @@ function formatNextSteps(opts) {
|
|
|
7235
7610
|
" 1. Add Figma MCP to .cursor/mcp.json (see https://github.com/let-sunny/canicode/blob/main/docs/CUSTOMIZATION.md#cursor-mcp-canicode and Figma MCP docs)",
|
|
7236
7611
|
" 2. Restart Cursor so Figma tools (e.g. use_figma) load",
|
|
7237
7612
|
" 3. @ canicode-roundtrip with your Figma URL for full roundtrip",
|
|
7238
|
-
" \u2022 Optional \u2014 faster canicode MCP than `npx`: add `canicode-mcp` per the Customization guide (`#cursor-mcp-canicode`), then reload MCP; otherwise skills keep using `npx canicode \u2026
|
|
7613
|
+
" \u2022 Optional \u2014 faster canicode MCP than `npx`: add `canicode-mcp` per the Customization guide (`#cursor-mcp-canicode`), then reload MCP; otherwise skills keep using `npx canicode \u2026`."
|
|
7239
7614
|
].join("\n");
|
|
7240
7615
|
}
|
|
7241
7616
|
return [
|
|
@@ -7245,7 +7620,7 @@ function formatNextSteps(opts) {
|
|
|
7245
7620
|
" claude mcp add -s project -t http figma https://mcp.figma.com/mcp",
|
|
7246
7621
|
" 2. Restart Claude Code (so the new skills + Figma MCP tools both load)",
|
|
7247
7622
|
" 3. Run /canicode-roundtrip <figma-url>",
|
|
7248
|
-
" \u2022 Optional \u2014 faster canicode MCP than `npx`: `claude mcp add canicode -- npx --yes --package=canicode canicode-mcp`, then restart Claude Code so MCP tools load \u2014 otherwise skills shell out to `npx canicode \u2026
|
|
7623
|
+
" \u2022 Optional \u2014 faster canicode MCP than `npx`: `claude mcp add canicode -- npx --yes --package=canicode canicode-mcp`, then restart Claude Code so MCP tools load \u2014 otherwise skills shell out to `npx canicode \u2026`."
|
|
7249
7624
|
].join("\n");
|
|
7250
7625
|
}
|
|
7251
7626
|
var InitOptionsSchema = z.object({
|
|
@@ -7258,6 +7633,56 @@ var InitOptionsSchema = z.object({
|
|
|
7258
7633
|
function wantsSkillInstallWithoutToken(options) {
|
|
7259
7634
|
return options.cursorSkills === true;
|
|
7260
7635
|
}
|
|
7636
|
+
async function runFullInit(token, options, interactive) {
|
|
7637
|
+
initAiready(token);
|
|
7638
|
+
console.log(` Config saved: ${getConfigPath()}`);
|
|
7639
|
+
console.log(` Reports will be saved to: ${getReportsDir()}/`);
|
|
7640
|
+
const { skillStepOk, skillSummary } = await runInitSkillInstallSteps(options);
|
|
7641
|
+
trackEvent(EVENTS.CLI_INIT, {
|
|
7642
|
+
skillsRequested: true,
|
|
7643
|
+
cursorSkillsRequested: options.cursorSkills === true,
|
|
7644
|
+
skillStepOk,
|
|
7645
|
+
target: options.global ? "global" : "project",
|
|
7646
|
+
force: options.force ?? false,
|
|
7647
|
+
interactive,
|
|
7648
|
+
...skillSummary ?? {}
|
|
7649
|
+
});
|
|
7650
|
+
if (skillStepOk) {
|
|
7651
|
+
console.log(
|
|
7652
|
+
formatNextSteps({
|
|
7653
|
+
figmaMcpPresent: figmaMcpRegistered(),
|
|
7654
|
+
skillsInstalled: true,
|
|
7655
|
+
cursorSkillsInstalled: options.cursorSkills === true
|
|
7656
|
+
})
|
|
7657
|
+
);
|
|
7658
|
+
}
|
|
7659
|
+
}
|
|
7660
|
+
function printSetupGuide() {
|
|
7661
|
+
console.log(`CANICODE SETUP
|
|
7662
|
+
`);
|
|
7663
|
+
console.log(
|
|
7664
|
+
` Never paste your token into Claude/Cursor chat \u2014 use FIGMA_TOKEN=\u2026 npx canicode init or this prompt only.
|
|
7665
|
+
`
|
|
7666
|
+
);
|
|
7667
|
+
console.log(` canicode init (interactive prompt; TTY only)`);
|
|
7668
|
+
console.log(` canicode init --token YOUR_FIGMA_TOKEN (CI / non-TTY)`);
|
|
7669
|
+
console.log(` Get token: figma.com > Settings > Personal access tokens
|
|
7670
|
+
`);
|
|
7671
|
+
console.log(`Skills:`);
|
|
7672
|
+
console.log(` --token (or the interactive prompt) also installs three Claude Code skills`);
|
|
7673
|
+
console.log(` into ./.claude/skills/ (canicode, canicode-gotchas, canicode-roundtrip).`);
|
|
7674
|
+
console.log(` --global Install to ~/.claude/skills/ instead`);
|
|
7675
|
+
console.log(` --cursor-skills Install Claude skills under .claude/skills/ plus Cursor copies under .cursor/skills/ (no --token yet \u2014 add --token when ready for REST analyze)`);
|
|
7676
|
+
console.log(` --force Overwrite existing skill files without prompting
|
|
7677
|
+
`);
|
|
7678
|
+
console.log(`Manage saved config (no skill reinstall):`);
|
|
7679
|
+
console.log(` canicode config set-token Rotate saved Figma token`);
|
|
7680
|
+
console.log(` canicode config show Print masked token + paths`);
|
|
7681
|
+
console.log(` canicode config path Print absolute config path
|
|
7682
|
+
`);
|
|
7683
|
+
console.log(`After setup:`);
|
|
7684
|
+
console.log(` canicode analyze "https://www.figma.com/design/..."`);
|
|
7685
|
+
}
|
|
7261
7686
|
async function runInitSkillInstallSteps(options) {
|
|
7262
7687
|
let skillStepOk = true;
|
|
7263
7688
|
let skillSummary;
|
|
@@ -7331,27 +7756,7 @@ ${msg}`);
|
|
|
7331
7756
|
}
|
|
7332
7757
|
const options = parseResult.data;
|
|
7333
7758
|
if (options.token) {
|
|
7334
|
-
|
|
7335
|
-
console.log(` Config saved: ${getConfigPath()}`);
|
|
7336
|
-
console.log(` Reports will be saved to: ${getReportsDir()}/`);
|
|
7337
|
-
const { skillStepOk, skillSummary } = await runInitSkillInstallSteps(options);
|
|
7338
|
-
trackEvent(EVENTS.CLI_INIT, {
|
|
7339
|
-
skillsRequested: true,
|
|
7340
|
-
cursorSkillsRequested: options.cursorSkills === true,
|
|
7341
|
-
skillStepOk,
|
|
7342
|
-
target: options.global ? "global" : "project",
|
|
7343
|
-
force: options.force ?? false,
|
|
7344
|
-
...skillSummary ?? {}
|
|
7345
|
-
});
|
|
7346
|
-
if (skillStepOk) {
|
|
7347
|
-
console.log(
|
|
7348
|
-
formatNextSteps({
|
|
7349
|
-
figmaMcpPresent: figmaMcpRegistered(),
|
|
7350
|
-
skillsInstalled: true,
|
|
7351
|
-
cursorSkillsInstalled: options.cursorSkills === true
|
|
7352
|
-
})
|
|
7353
|
-
);
|
|
7354
|
-
}
|
|
7759
|
+
await runFullInit(options.token, options, false);
|
|
7355
7760
|
return;
|
|
7356
7761
|
}
|
|
7357
7762
|
if (wantsSkillInstallWithoutToken(options)) {
|
|
@@ -7379,24 +7784,16 @@ ${msg}`);
|
|
|
7379
7784
|
}
|
|
7380
7785
|
return;
|
|
7381
7786
|
}
|
|
7382
|
-
|
|
7383
|
-
|
|
7384
|
-
|
|
7385
|
-
|
|
7386
|
-
|
|
7387
|
-
|
|
7388
|
-
|
|
7389
|
-
|
|
7390
|
-
|
|
7391
|
-
|
|
7392
|
-
console.log(` --token also installs three Claude Code skills into ./.claude/skills/`);
|
|
7393
|
-
console.log(` (canicode, canicode-gotchas, canicode-roundtrip).`);
|
|
7394
|
-
console.log(` --global Install to ~/.claude/skills/ instead`);
|
|
7395
|
-
console.log(` --cursor-skills Install Claude skills under .claude/skills/ plus Cursor copies under .cursor/skills/ (no --token yet \u2014 add --token when ready for REST analyze)`);
|
|
7396
|
-
console.log(` --force Overwrite existing skill files without prompting
|
|
7397
|
-
`);
|
|
7398
|
-
console.log(`After setup:`);
|
|
7399
|
-
console.log(` canicode analyze "https://www.figma.com/design/..."`);
|
|
7787
|
+
try {
|
|
7788
|
+
const token = await promptForFigmaToken();
|
|
7789
|
+
await runFullInit(token, options, true);
|
|
7790
|
+
return;
|
|
7791
|
+
} catch (promptError) {
|
|
7792
|
+
if (!(promptError instanceof NonInteractiveError)) {
|
|
7793
|
+
throw promptError;
|
|
7794
|
+
}
|
|
7795
|
+
}
|
|
7796
|
+
printSetupGuide();
|
|
7400
7797
|
} catch (error) {
|
|
7401
7798
|
console.error(
|
|
7402
7799
|
"\nError:",
|
|
@@ -7408,28 +7805,85 @@ ${msg}`);
|
|
|
7408
7805
|
}
|
|
7409
7806
|
|
|
7410
7807
|
// src/cli/commands/config.ts
|
|
7808
|
+
var VALID_ACTIONS = ["set-token", "show", "path"];
|
|
7809
|
+
function isConfigAction(value) {
|
|
7810
|
+
return value !== void 0 && VALID_ACTIONS.includes(value);
|
|
7811
|
+
}
|
|
7812
|
+
function printConfigShow() {
|
|
7813
|
+
const cfg = readConfig();
|
|
7814
|
+
const envToken = process.env["FIGMA_TOKEN"];
|
|
7815
|
+
const effectiveToken = getFigmaToken();
|
|
7816
|
+
const tokenSource = envToken ? " (env: FIGMA_TOKEN)" : "";
|
|
7817
|
+
console.log("CANICODE CONFIG\n");
|
|
7818
|
+
console.log(` Config path: ${getConfigPath()}`);
|
|
7819
|
+
console.log(` Reports dir: ${getReportsDir()}`);
|
|
7820
|
+
console.log(` Figma token: ${maskFigmaToken(effectiveToken)}${tokenSource}`);
|
|
7821
|
+
console.log(` Telemetry: ${cfg.telemetry !== false ? "enabled" : "disabled"}`);
|
|
7822
|
+
console.log(`
|
|
7823
|
+
Options:`);
|
|
7824
|
+
console.log(` canicode config set-token Update saved Figma token (no skill reinstall)`);
|
|
7825
|
+
console.log(` canicode config show Show current configuration`);
|
|
7826
|
+
console.log(` canicode config path Print absolute config path`);
|
|
7827
|
+
console.log(` canicode config --no-telemetry Opt out of anonymous telemetry`);
|
|
7828
|
+
console.log(` canicode config --telemetry Opt back in`);
|
|
7829
|
+
}
|
|
7830
|
+
async function handleSetToken(options) {
|
|
7831
|
+
let token = options.token;
|
|
7832
|
+
const usedFlag = Boolean(token);
|
|
7833
|
+
if (!token) {
|
|
7834
|
+
try {
|
|
7835
|
+
token = await promptForFigmaToken();
|
|
7836
|
+
} catch (err) {
|
|
7837
|
+
if (err instanceof NonInteractiveError) {
|
|
7838
|
+
console.error(
|
|
7839
|
+
"Run with --token <token> or set FIGMA_TOKEN=\u2026 (interactive prompt requires a TTY)."
|
|
7840
|
+
);
|
|
7841
|
+
process.exitCode = 1;
|
|
7842
|
+
return;
|
|
7843
|
+
}
|
|
7844
|
+
throw err;
|
|
7845
|
+
}
|
|
7846
|
+
}
|
|
7847
|
+
setFigmaToken(token);
|
|
7848
|
+
console.log(`Token saved: ${getConfigPath()}`);
|
|
7849
|
+
trackEvent(EVENTS.CLI_CONFIG_SET_TOKEN, { interactive: !usedFlag });
|
|
7850
|
+
}
|
|
7411
7851
|
function registerConfig(cli2) {
|
|
7412
|
-
cli2.command("config", "Manage canicode configuration").option("--telemetry", "Enable anonymous telemetry").option("--no-telemetry", "Disable anonymous telemetry").action((options) => {
|
|
7852
|
+
cli2.command("config [action]", "Manage canicode configuration (actions: set-token, show, path)").option("--telemetry", "Enable anonymous telemetry").option("--no-telemetry", "Disable anonymous telemetry").option("--token <token>", "For `config set-token`: set token non-interactively (CI / non-TTY)").action(async (action, options) => {
|
|
7413
7853
|
try {
|
|
7414
|
-
if (
|
|
7854
|
+
if (action !== void 0 && !isConfigAction(action)) {
|
|
7855
|
+
console.error(
|
|
7856
|
+
`Unknown config action: ${action}. Available: ${VALID_ACTIONS.join(", ")}`
|
|
7857
|
+
);
|
|
7858
|
+
process.exitCode = 1;
|
|
7859
|
+
return;
|
|
7860
|
+
}
|
|
7861
|
+
if (action === "set-token") {
|
|
7862
|
+
await handleSetToken(options);
|
|
7863
|
+
return;
|
|
7864
|
+
}
|
|
7865
|
+
if (action === "path") {
|
|
7866
|
+
console.log(getConfigPath());
|
|
7867
|
+
return;
|
|
7868
|
+
}
|
|
7869
|
+
if (action === "show") {
|
|
7870
|
+
printConfigShow();
|
|
7871
|
+
return;
|
|
7872
|
+
}
|
|
7873
|
+
const argv = process.argv.slice(2);
|
|
7874
|
+
const flippedOff = argv.includes("--no-telemetry");
|
|
7875
|
+
const flippedOn = argv.includes("--telemetry");
|
|
7876
|
+
if (flippedOff) {
|
|
7415
7877
|
setTelemetryEnabled(false);
|
|
7416
7878
|
console.log("Telemetry disabled. No analytics data will be sent.");
|
|
7417
7879
|
return;
|
|
7418
7880
|
}
|
|
7419
|
-
if (
|
|
7881
|
+
if (flippedOn) {
|
|
7420
7882
|
setTelemetryEnabled(true);
|
|
7421
7883
|
console.log("Telemetry enabled. Only anonymous usage events are tracked \u2014 no design data.");
|
|
7422
7884
|
return;
|
|
7423
7885
|
}
|
|
7424
|
-
|
|
7425
|
-
console.log("CANICODE CONFIG\n");
|
|
7426
|
-
console.log(` Config path: ${getConfigPath()}`);
|
|
7427
|
-
console.log(` Figma token: ${cfg.figmaToken ? "set" : "not set"}`);
|
|
7428
|
-
console.log(` Telemetry: ${cfg.telemetry !== false ? "enabled" : "disabled"}`);
|
|
7429
|
-
console.log(`
|
|
7430
|
-
Options:`);
|
|
7431
|
-
console.log(` canicode config --no-telemetry Opt out of anonymous telemetry`);
|
|
7432
|
-
console.log(` canicode config --telemetry Opt back in`);
|
|
7886
|
+
printConfigShow();
|
|
7433
7887
|
} catch (error) {
|
|
7434
7888
|
console.error(
|
|
7435
7889
|
"\nError:",
|
|
@@ -7440,6 +7894,189 @@ Options:`);
|
|
|
7440
7894
|
});
|
|
7441
7895
|
}
|
|
7442
7896
|
|
|
7897
|
+
// src/cli/commands/doctor.ts
|
|
7898
|
+
init_figma_client();
|
|
7899
|
+
var CODE_CONNECT_PKG = "@figma/code-connect";
|
|
7900
|
+
var CODE_CONNECT_DOCS = "https://www.figma.com/code-connect-docs/";
|
|
7901
|
+
function readPackageJson(cwd) {
|
|
7902
|
+
const pkgPath = join(cwd, "package.json");
|
|
7903
|
+
if (!existsSync(pkgPath)) return void 0;
|
|
7904
|
+
try {
|
|
7905
|
+
return JSON.parse(readFileSync(pkgPath, "utf-8"));
|
|
7906
|
+
} catch {
|
|
7907
|
+
return void 0;
|
|
7908
|
+
}
|
|
7909
|
+
}
|
|
7910
|
+
function findCodeConnectVersion(pkg3) {
|
|
7911
|
+
if (!pkg3) return void 0;
|
|
7912
|
+
return pkg3.dependencies?.[CODE_CONNECT_PKG] ?? pkg3.devDependencies?.[CODE_CONNECT_PKG];
|
|
7913
|
+
}
|
|
7914
|
+
function runCodeConnectChecks(cwd) {
|
|
7915
|
+
const pkg3 = readPackageJson(cwd);
|
|
7916
|
+
const ccVersion = findCodeConnectVersion(pkg3);
|
|
7917
|
+
const figmaConfigExists = existsSync(join(cwd, "figma.config.json"));
|
|
7918
|
+
const results = [];
|
|
7919
|
+
if (ccVersion) {
|
|
7920
|
+
results.push({
|
|
7921
|
+
name: `${CODE_CONNECT_PKG} installed`,
|
|
7922
|
+
pass: true,
|
|
7923
|
+
detail: ccVersion
|
|
7924
|
+
});
|
|
7925
|
+
} else {
|
|
7926
|
+
results.push({
|
|
7927
|
+
name: `${CODE_CONNECT_PKG} not installed`,
|
|
7928
|
+
pass: false,
|
|
7929
|
+
remediation: pkg3 ? `pnpm add -D ${CODE_CONNECT_PKG} (or npm/yarn equivalent)` : `No package.json found at ${cwd} \u2014 run from your project root, or initialise one first.`
|
|
7930
|
+
});
|
|
7931
|
+
}
|
|
7932
|
+
if (figmaConfigExists) {
|
|
7933
|
+
results.push({
|
|
7934
|
+
name: "figma.config.json found at repo root",
|
|
7935
|
+
pass: true
|
|
7936
|
+
});
|
|
7937
|
+
} else {
|
|
7938
|
+
results.push({
|
|
7939
|
+
name: "figma.config.json not found at repo root",
|
|
7940
|
+
pass: false,
|
|
7941
|
+
remediation: `see ${CODE_CONNECT_DOCS}`
|
|
7942
|
+
});
|
|
7943
|
+
}
|
|
7944
|
+
return results;
|
|
7945
|
+
}
|
|
7946
|
+
var PUBLISH_CHECK_NAME = "Figma component published in a library";
|
|
7947
|
+
async function runFigmaPublishCheck(input) {
|
|
7948
|
+
const { figmaUrl, token, fetchPublishedComponents } = input;
|
|
7949
|
+
let parsed;
|
|
7950
|
+
try {
|
|
7951
|
+
parsed = parseFigmaUrl(figmaUrl);
|
|
7952
|
+
} catch (err) {
|
|
7953
|
+
const message = err instanceof FigmaUrlParseError ? err.message : String(err);
|
|
7954
|
+
return {
|
|
7955
|
+
name: PUBLISH_CHECK_NAME,
|
|
7956
|
+
pass: false,
|
|
7957
|
+
inconclusive: true,
|
|
7958
|
+
detail: `could not parse URL: ${message}`,
|
|
7959
|
+
remediation: "Pass a valid Figma design URL (figma.com/design/<file>?node-id=<id>)."
|
|
7960
|
+
};
|
|
7961
|
+
}
|
|
7962
|
+
if (!parsed.nodeId) {
|
|
7963
|
+
return {
|
|
7964
|
+
name: PUBLISH_CHECK_NAME,
|
|
7965
|
+
pass: false,
|
|
7966
|
+
inconclusive: true,
|
|
7967
|
+
detail: "URL is missing a node-id",
|
|
7968
|
+
remediation: "Code Connect mapping is per-component \u2014 invoke with a URL that targets a specific node (?node-id=\u2026)."
|
|
7969
|
+
};
|
|
7970
|
+
}
|
|
7971
|
+
if (!token) {
|
|
7972
|
+
return {
|
|
7973
|
+
name: PUBLISH_CHECK_NAME,
|
|
7974
|
+
pass: false,
|
|
7975
|
+
inconclusive: true,
|
|
7976
|
+
detail: "FIGMA_TOKEN not configured \u2014 skipping publish-status check",
|
|
7977
|
+
remediation: "Set FIGMA_TOKEN (env var) or run `canicode config set-token` so doctor can verify this prereq inline."
|
|
7978
|
+
};
|
|
7979
|
+
}
|
|
7980
|
+
if (!fetchPublishedComponents) {
|
|
7981
|
+
return {
|
|
7982
|
+
name: PUBLISH_CHECK_NAME,
|
|
7983
|
+
pass: false,
|
|
7984
|
+
inconclusive: true,
|
|
7985
|
+
detail: "no fetcher wired",
|
|
7986
|
+
remediation: "internal: doctor was called without a Figma client"
|
|
7987
|
+
};
|
|
7988
|
+
}
|
|
7989
|
+
let components;
|
|
7990
|
+
try {
|
|
7991
|
+
components = await fetchPublishedComponents(parsed.fileKey);
|
|
7992
|
+
} catch (err) {
|
|
7993
|
+
const status = err instanceof FigmaClientError ? err.statusCode : void 0;
|
|
7994
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
7995
|
+
return {
|
|
7996
|
+
name: PUBLISH_CHECK_NAME,
|
|
7997
|
+
pass: false,
|
|
7998
|
+
inconclusive: true,
|
|
7999
|
+
detail: `Figma API call failed${status ? ` (HTTP ${status})` : ""}: ${message}`,
|
|
8000
|
+
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."
|
|
8001
|
+
};
|
|
8002
|
+
}
|
|
8003
|
+
const canonicalNodeId = parsed.nodeId.replace(/-/g, ":");
|
|
8004
|
+
const match = components.find(
|
|
8005
|
+
(c) => c.node_id === canonicalNodeId || c.node_id === parsed.nodeId
|
|
8006
|
+
);
|
|
8007
|
+
if (match) {
|
|
8008
|
+
return {
|
|
8009
|
+
name: PUBLISH_CHECK_NAME,
|
|
8010
|
+
pass: true,
|
|
8011
|
+
detail: `${match.name} (${match.node_id})`
|
|
8012
|
+
};
|
|
8013
|
+
}
|
|
8014
|
+
return {
|
|
8015
|
+
name: PUBLISH_CHECK_NAME,
|
|
8016
|
+
pass: false,
|
|
8017
|
+
detail: `node ${canonicalNodeId} is not in the published-components list for file ${parsed.fileKey}`,
|
|
8018
|
+
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.'"
|
|
8019
|
+
};
|
|
8020
|
+
}
|
|
8021
|
+
function formatDoctorReport(results) {
|
|
8022
|
+
const lines = ["Code Connect"];
|
|
8023
|
+
for (const result of results) {
|
|
8024
|
+
const icon = result.pass ? "\u2705" : result.inconclusive ? "\u26A0\uFE0F" : "\u274C";
|
|
8025
|
+
const detail = result.detail ? ` (${result.detail})` : "";
|
|
8026
|
+
lines.push(` ${icon} ${result.name}${detail}`);
|
|
8027
|
+
if (!result.pass && result.remediation) {
|
|
8028
|
+
lines.push(` \u2192 ${result.remediation}`);
|
|
8029
|
+
}
|
|
8030
|
+
}
|
|
8031
|
+
lines.push("");
|
|
8032
|
+
const blocking = results.filter((r) => !r.pass && !r.inconclusive).length;
|
|
8033
|
+
const inconclusive = results.filter((r) => !r.pass && r.inconclusive).length;
|
|
8034
|
+
if (blocking === 0 && inconclusive === 0) {
|
|
8035
|
+
lines.push("All checks passed.");
|
|
8036
|
+
} else if (blocking === 0) {
|
|
8037
|
+
lines.push(
|
|
8038
|
+
"Blocking checks passed; some checks were skipped (\u26A0\uFE0F) and could not be verified."
|
|
8039
|
+
);
|
|
8040
|
+
} else {
|
|
8041
|
+
lines.push("Some checks failed. Fix the items above before running the Code Connect flow.");
|
|
8042
|
+
}
|
|
8043
|
+
return lines.join("\n");
|
|
8044
|
+
}
|
|
8045
|
+
function registerDoctor(cli2) {
|
|
8046
|
+
cli2.command(
|
|
8047
|
+
"doctor",
|
|
8048
|
+
"Diagnose Code Connect prerequisites (`@figma/code-connect`, `figma.config.json`)"
|
|
8049
|
+
).option(
|
|
8050
|
+
"--figma-url <url>",
|
|
8051
|
+
"Optionally check that the target Figma component is published in a library (requires FIGMA_TOKEN)"
|
|
8052
|
+
).action(async (options) => {
|
|
8053
|
+
const cwd = process.cwd();
|
|
8054
|
+
const results = runCodeConnectChecks(cwd);
|
|
8055
|
+
if (options.figmaUrl) {
|
|
8056
|
+
const token = getFigmaToken();
|
|
8057
|
+
const client = token ? new FigmaClient({ token }) : void 0;
|
|
8058
|
+
const publishCheck = await runFigmaPublishCheck({
|
|
8059
|
+
figmaUrl: options.figmaUrl,
|
|
8060
|
+
token,
|
|
8061
|
+
fetchPublishedComponents: client ? (fileKey) => client.getPublishedComponents(fileKey) : void 0
|
|
8062
|
+
});
|
|
8063
|
+
results.push(publishCheck);
|
|
8064
|
+
}
|
|
8065
|
+
console.log(formatDoctorReport(results));
|
|
8066
|
+
const passed = results.filter((r) => r.pass).length;
|
|
8067
|
+
const inconclusive = results.filter((r) => !r.pass && r.inconclusive).length;
|
|
8068
|
+
const failed = results.length - passed - inconclusive;
|
|
8069
|
+
trackEvent(EVENTS.CLI_DOCTOR, {
|
|
8070
|
+
passed,
|
|
8071
|
+
failed,
|
|
8072
|
+
total: results.length
|
|
8073
|
+
});
|
|
8074
|
+
if (failed > 0) {
|
|
8075
|
+
process.exitCode = 1;
|
|
8076
|
+
}
|
|
8077
|
+
});
|
|
8078
|
+
}
|
|
8079
|
+
|
|
7443
8080
|
// src/cli/commands/list-rules.ts
|
|
7444
8081
|
function registerListRules(cli2) {
|
|
7445
8082
|
cli2.command("list-rules", "List all analysis rules with scores and severity").option("--config <path>", "Apply config overrides to show effective scores").option("--json", "Output as JSON").action(async (options) => {
|
|
@@ -10566,8 +11203,8 @@ ${msg}`);
|
|
|
10566
11203
|
if (vectorDir && existsSync(vectorDir)) {
|
|
10567
11204
|
const vecOutputDir = resolve(outputDir, "vectors");
|
|
10568
11205
|
mkdirSync(vecOutputDir, { recursive: true });
|
|
10569
|
-
const { readdirSync:
|
|
10570
|
-
const vecFiles =
|
|
11206
|
+
const { readdirSync: readdirSync5, copyFileSync: copyFileSync3 } = await import('fs');
|
|
11207
|
+
const vecFiles = readdirSync5(vectorDir).filter((f) => f.endsWith(".svg") || f === "mapping.json");
|
|
10571
11208
|
for (const f of vecFiles) {
|
|
10572
11209
|
copyFileSync3(resolve(vectorDir, f), resolve(vecOutputDir, f));
|
|
10573
11210
|
}
|
|
@@ -10577,8 +11214,8 @@ ${msg}`);
|
|
|
10577
11214
|
if (imageDir && existsSync(imageDir)) {
|
|
10578
11215
|
const imgOutputDir = resolve(outputDir, "images");
|
|
10579
11216
|
mkdirSync(imgOutputDir, { recursive: true });
|
|
10580
|
-
const { readdirSync:
|
|
10581
|
-
const imgFiles =
|
|
11217
|
+
const { readdirSync: readdirSync5, copyFileSync: copyFileSync3 } = await import('fs');
|
|
11218
|
+
const imgFiles = readdirSync5(imageDir).filter((f) => f.endsWith(".png") || f.endsWith(".jpg") || f.endsWith(".json"));
|
|
10582
11219
|
for (const f of imgFiles) {
|
|
10583
11220
|
copyFileSync3(resolve(imageDir, f), resolve(imgOutputDir, f));
|
|
10584
11221
|
}
|
|
@@ -10786,6 +11423,7 @@ registerDesignTree(cli);
|
|
|
10786
11423
|
registerVisualCompare(cli);
|
|
10787
11424
|
registerInit(cli);
|
|
10788
11425
|
registerConfig(cli);
|
|
11426
|
+
registerDoctor(cli);
|
|
10789
11427
|
registerListRules(cli);
|
|
10790
11428
|
registerRoundtripTally(cli);
|
|
10791
11429
|
registerCalibrateAnalyze(cli);
|
|
@@ -10815,7 +11453,11 @@ cli.help((sections) => {
|
|
|
10815
11453
|
sections.push(
|
|
10816
11454
|
{
|
|
10817
11455
|
title: "\nSetup",
|
|
10818
|
-
body:
|
|
11456
|
+
body: [
|
|
11457
|
+
` canicode init Interactive setup (prompts for token)`,
|
|
11458
|
+
` canicode init --token <token> Non-interactive setup (CI / non-TTY)`,
|
|
11459
|
+
` canicode config set-token Rotate token without reinstalling skills`
|
|
11460
|
+
].join("\n")
|
|
10819
11461
|
},
|
|
10820
11462
|
{
|
|
10821
11463
|
title: "\nData source",
|