canicode 0.11.0 → 0.11.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/README.md +18 -6
- package/dist/cli/index.js +670 -188
- package/dist/cli/index.js.map +1 -1
- package/dist/index.d.ts +41 -21
- package/dist/index.js +45 -9
- package/dist/index.js.map +1 -1
- package/dist/mcp/server.js +45 -8
- package/dist/mcp/server.js.map +1 -1
- package/docs/CUSTOMIZATION.md +62 -3
- package/package.json +2 -2
- package/skills/canicode/SKILL.md +6 -0
- package/skills/canicode-gotchas/SKILL.md +91 -66
- package/skills/canicode-roundtrip/SKILL.md +74 -248
- package/skills/canicode-roundtrip/canicode-roundtrip-helpers.d.ts +54 -0
- package/skills/canicode-roundtrip/helpers-bootstrap.js +21 -0
- package/skills/canicode-roundtrip/helpers-installer.js +14 -0
- package/skills/canicode-roundtrip/helpers.js +287 -17
- package/skills/cursor/canicode/SKILL.md +6 -0
- package/skills/cursor/canicode-gotchas/SKILL.md +91 -66
- package/skills/cursor/canicode-roundtrip/SKILL.md +74 -248
- package/skills/cursor/canicode-roundtrip/canicode-roundtrip-helpers.d.ts +54 -0
- package/skills/cursor/canicode-roundtrip/helpers-bootstrap.js +21 -0
- package/skills/cursor/canicode-roundtrip/helpers-installer.js +14 -0
- package/skills/cursor/canicode-roundtrip/helpers.js +287 -17
package/dist/cli/index.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { existsSync, mkdirSync, writeFileSync, readFileSync, statSync,
|
|
2
|
+
import { existsSync, mkdirSync, writeFileSync, readFileSync, statSync, readdirSync, renameSync, chmodSync, copyFileSync } from 'fs';
|
|
3
3
|
import { join, resolve, dirname, basename, relative } from 'path';
|
|
4
4
|
import pixelmatch from 'pixelmatch';
|
|
5
5
|
import { PNG } from 'pngjs';
|
|
@@ -1377,7 +1377,9 @@ var EVENTS = {
|
|
|
1377
1377
|
// cannot import `core/monitoring` directly, so the event fires through a
|
|
1378
1378
|
// caller-supplied callback. Define the typed name here so a future consumer
|
|
1379
1379
|
// has a single place to wire it up.
|
|
1380
|
-
ROUNDTRIP_DEFINITION_WRITE_SKIPPED: `${EVENT_PREFIX}roundtrip_definition_write_skipped
|
|
1380
|
+
ROUNDTRIP_DEFINITION_WRITE_SKIPPED: `${EVENT_PREFIX}roundtrip_definition_write_skipped`,
|
|
1381
|
+
/** CLI `canicode roundtrip-tally` completed successfully. */
|
|
1382
|
+
ROUNDTRIP_TALLY: `${EVENT_PREFIX}roundtrip_tally`
|
|
1381
1383
|
};
|
|
1382
1384
|
|
|
1383
1385
|
// src/core/monitoring/capture.ts
|
|
@@ -1529,6 +1531,14 @@ function printDocsSetup() {
|
|
|
1529
1531
|
console.log(`
|
|
1530
1532
|
CANICODE SETUP GUIDE
|
|
1531
1533
|
|
|
1534
|
+
Skills at a glance: canicode-gotchas = survey answers saved locally (memo-only).
|
|
1535
|
+
canicode-roundtrip = same flow plus writes to Figma via use_figma (canvas).
|
|
1536
|
+
|
|
1537
|
+
Token safety: Do NOT paste your Figma token into Claude, Cursor, or other
|
|
1538
|
+
agent chats \u2014 transcripts can retain it. Use:
|
|
1539
|
+
FIGMA_TOKEN=figd_\u2026 npx canicode init
|
|
1540
|
+
or run \`npx canicode init\` and enter the token only at the CLI prompt.
|
|
1541
|
+
|
|
1532
1542
|
\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
|
|
1533
1543
|
1. CLI (REST API)
|
|
1534
1544
|
\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
|
|
@@ -1538,7 +1548,11 @@ CANICODE SETUP GUIDE
|
|
|
1538
1548
|
|
|
1539
1549
|
Setup:
|
|
1540
1550
|
canicode init --token figd_xxxxxxxxxxxxx
|
|
1541
|
-
(saves token + installs
|
|
1551
|
+
(saves token + installs Claude Code skills into ./.claude/skills/)
|
|
1552
|
+
|
|
1553
|
+
Skills only (no token yet):
|
|
1554
|
+
canicode init --cursor-skills
|
|
1555
|
+
(installs Claude skills + Cursor copies; run init --token \u2026 before live Figma REST URLs)
|
|
1542
1556
|
|
|
1543
1557
|
Use:
|
|
1544
1558
|
canicode analyze "https://www.figma.com/design/ABC123/MyDesign?node-id=1-234"
|
|
@@ -1548,6 +1562,7 @@ CANICODE SETUP GUIDE
|
|
|
1548
1562
|
--preset strict|relaxed|dev-friendly|ai-ready
|
|
1549
1563
|
--config ./my-config.json
|
|
1550
1564
|
--no-open Don't open report in browser
|
|
1565
|
+
--api No-op for Figma URLs (REST always); same flag as gotcha-survey (#461)
|
|
1551
1566
|
|
|
1552
1567
|
Output:
|
|
1553
1568
|
~/.canicode/reports/report-YYYY-MM-DD-HH-mm-<filekey>.html
|
|
@@ -1556,6 +1571,8 @@ CANICODE SETUP GUIDE
|
|
|
1556
1571
|
2. CLAUDE CODE SKILLS (requires FIGMA_TOKEN)
|
|
1557
1572
|
\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
|
|
1558
1573
|
|
|
1574
|
+
(Same token safety as above \u2014 env var or interactive prompt, not chat.)
|
|
1575
|
+
|
|
1559
1576
|
Setup:
|
|
1560
1577
|
canicode init --token figd_xxxxxxxxxxxxx
|
|
1561
1578
|
(installs three skills into ./.claude/skills/ alongside the token)
|
|
@@ -1567,7 +1584,7 @@ CANICODE SETUP GUIDE
|
|
|
1567
1584
|
|
|
1568
1585
|
Flags:
|
|
1569
1586
|
--global Install to ~/.claude/skills/ instead of ./.claude/skills/
|
|
1570
|
-
--
|
|
1587
|
+
--cursor-skills Also install Cursor copies (see \xA73)
|
|
1571
1588
|
--force Overwrite existing skill files without prompting
|
|
1572
1589
|
|
|
1573
1590
|
Use (in Claude Code):
|
|
@@ -1575,6 +1592,43 @@ CANICODE SETUP GUIDE
|
|
|
1575
1592
|
/canicode-gotchas <url> Run a gotcha survey
|
|
1576
1593
|
/canicode-roundtrip <url> Analyze, fix gotchas in Figma, re-analyze
|
|
1577
1594
|
|
|
1595
|
+
\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
|
|
1596
|
+
3. CURSOR SKILLS (requires FIGMA_TOKEN)
|
|
1597
|
+
\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
|
|
1598
|
+
|
|
1599
|
+
(Same token safety as above \u2014 env var or interactive prompt, not chat.)
|
|
1600
|
+
|
|
1601
|
+
Setup:
|
|
1602
|
+
canicode init --token figd_xxxxxxxxxxxxx --cursor-skills
|
|
1603
|
+
(installs Cursor copies of the three skills into ./.cursor/skills/)
|
|
1604
|
+
|
|
1605
|
+
Installed skills:
|
|
1606
|
+
canicode Lightweight CLI wrapper
|
|
1607
|
+
canicode-gotchas Standalone gotcha survey
|
|
1608
|
+
canicode-roundtrip Full analyze -> gotcha -> apply roundtrip
|
|
1609
|
+
|
|
1610
|
+
Flags:
|
|
1611
|
+
--cursor-skills Install Cursor copies of all three skills into .cursor/skills/
|
|
1612
|
+
(with --token, runs after full Claude skill install; without token,
|
|
1613
|
+
installs Claude skills under .claude/skills/ first, then Cursor copies)
|
|
1614
|
+
--force Overwrite existing skill files without prompting
|
|
1615
|
+
|
|
1616
|
+
Use (in Cursor Agent chat):
|
|
1617
|
+
@canicode <figma-url>
|
|
1618
|
+
@canicode-gotchas <figma-url> Run a gotcha survey
|
|
1619
|
+
@canicode-roundtrip <figma-url> Analyze, fix gotchas in Figma, re-analyze
|
|
1620
|
+
|
|
1621
|
+
Invocation: docs default to @-skills for Agent chat. If your team uses slash
|
|
1622
|
+
commands or Cursor rules instead, use those \u2014 capability is the same once MCP
|
|
1623
|
+
+ skills load.
|
|
1624
|
+
|
|
1625
|
+
MCP files: Cursor reads .cursor/mcp.json (or ~/.cursor/mcp.json), not the
|
|
1626
|
+
repo-root .mcp.json used by Claude Code \u2014 duplicate Figma + canicode entries
|
|
1627
|
+
if you use both hosts (see docs/CUSTOMIZATION.md#cursor-mcp-canicode).
|
|
1628
|
+
|
|
1629
|
+
See also: docs/CUSTOMIZATION.md#cursor-mcp-canicode (Figma MCP required for roundtrip
|
|
1630
|
+
writes; analyze-only works without it).
|
|
1631
|
+
|
|
1578
1632
|
\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
|
|
1579
1633
|
TOKEN PRIORITY
|
|
1580
1634
|
\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
|
|
@@ -1590,6 +1644,7 @@ CANICODE SETUP GUIDE
|
|
|
1590
1644
|
CI/CD, automation -> CLI + FIGMA_TOKEN env var
|
|
1591
1645
|
Claude Code (full) -> canicode MCP server + FIGMA_TOKEN
|
|
1592
1646
|
Claude Code (light) -> /canicode skill + FIGMA_TOKEN
|
|
1647
|
+
Cursor Agent -> canicode init --cursor-skills + Figma MCP
|
|
1593
1648
|
In Figma -> Figma Plugin
|
|
1594
1649
|
Browser -> Web App (GitHub Pages)
|
|
1595
1650
|
Quick trial, offline -> CLI + JSON fixtures
|
|
@@ -3520,9 +3575,27 @@ defineRule({
|
|
|
3520
3575
|
definition: missingPrototypeDef,
|
|
3521
3576
|
check: missingPrototypeCheck
|
|
3522
3577
|
});
|
|
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
|
+
});
|
|
3523
3593
|
var AcknowledgmentSchema = z.object({
|
|
3524
3594
|
nodeId: z.string(),
|
|
3525
|
-
ruleId: z.string()
|
|
3595
|
+
ruleId: z.string(),
|
|
3596
|
+
intent: AcknowledgmentIntentSchema.optional(),
|
|
3597
|
+
sceneWriteOutcome: AcknowledgmentSceneWriteOutcomeSchema.optional(),
|
|
3598
|
+
codegenDirective: z.string().optional()
|
|
3526
3599
|
});
|
|
3527
3600
|
var AcknowledgmentListSchema = z.array(AcknowledgmentSchema);
|
|
3528
3601
|
function normalizeNodeId(id) {
|
|
@@ -4172,7 +4245,7 @@ function computeApplyContext(violation, instanceContext) {
|
|
|
4172
4245
|
}
|
|
4173
4246
|
|
|
4174
4247
|
// package.json
|
|
4175
|
-
var version2 = "0.11.
|
|
4248
|
+
var version2 = "0.11.2";
|
|
4176
4249
|
|
|
4177
4250
|
// src/core/engine/scoring.ts
|
|
4178
4251
|
function computeTotalScorePerCategory(configs) {
|
|
@@ -5651,6 +5724,7 @@ var AnalyzeOptionsSchema = z.object({
|
|
|
5651
5724
|
preset: z.enum(["relaxed", "dev-friendly", "ai-ready", "strict"]).optional(),
|
|
5652
5725
|
output: z.string().optional(),
|
|
5653
5726
|
token: z.string().optional(),
|
|
5727
|
+
/** Accepted for CLI parity; Figma URL loads always use the REST API today (#461). */
|
|
5654
5728
|
api: z.boolean().optional(),
|
|
5655
5729
|
screenshot: z.boolean().optional(),
|
|
5656
5730
|
config: z.string().optional(),
|
|
@@ -5660,7 +5734,10 @@ var AnalyzeOptionsSchema = z.object({
|
|
|
5660
5734
|
scope: z.enum(["page", "component"]).optional()
|
|
5661
5735
|
});
|
|
5662
5736
|
function registerAnalyze(cli2) {
|
|
5663
|
-
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(
|
|
5737
|
+
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(
|
|
5738
|
+
"--api",
|
|
5739
|
+
"No-op for Figma URL inputs (file data is always fetched via REST). Accepted for CLI parity with `gotcha-survey` (#461)."
|
|
5740
|
+
).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>", "(#371 / ADR-019) Path to JSON acknowledgments from canicode Figma annotations (nodeId, ruleId; optional intent / sceneWriteOutcome / codegenDirective per #444). Matching issues are flagged acknowledged and contribute half weight to density.").option("--scope <scope>", "(#404) 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.").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) => {
|
|
5664
5741
|
const parseResult = AnalyzeOptionsSchema.safeParse(rawOptions);
|
|
5665
5742
|
if (!parseResult.success) {
|
|
5666
5743
|
const msg = parseResult.error.issues.map((i) => `--${i.path.join(".")}: ${i.message}`).join("\n");
|
|
@@ -5670,6 +5747,7 @@ ${msg}`);
|
|
|
5670
5747
|
process.exit(1);
|
|
5671
5748
|
}
|
|
5672
5749
|
const options = parseResult.data;
|
|
5750
|
+
void options.api;
|
|
5673
5751
|
const analysisStart = Date.now();
|
|
5674
5752
|
trackEvent(EVENTS.ANALYSIS_STARTED, { source: isJsonFile(input) || isFixtureDir(input) ? "fixture" : "figma" });
|
|
5675
5753
|
const log = options.json ? console.error.bind(console) : console.log.bind(console);
|
|
@@ -5946,7 +6024,13 @@ var BATCHABLE_RULE_IDS = [
|
|
|
5946
6024
|
"no-auto-layout",
|
|
5947
6025
|
"fixed-size-in-auto-layout"
|
|
5948
6026
|
];
|
|
6027
|
+
var OPT_IN_BATCHABLE_RULE_IDS = [
|
|
6028
|
+
"missing-prototype"
|
|
6029
|
+
];
|
|
5949
6030
|
var BATCHABLE_SET = new Set(BATCHABLE_RULE_IDS);
|
|
6031
|
+
var OPT_IN_BATCHABLE_SET = new Set(
|
|
6032
|
+
OPT_IN_BATCHABLE_RULE_IDS
|
|
6033
|
+
);
|
|
5950
6034
|
var NO_SOURCE_SENTINEL = "_no-source";
|
|
5951
6035
|
function groupAndBatchSurveyQuestions(questions) {
|
|
5952
6036
|
if (questions.length === 0) {
|
|
@@ -5987,20 +6071,25 @@ function sourceComponentKey(question) {
|
|
|
5987
6071
|
}
|
|
5988
6072
|
function pushIntoBatch(group, question) {
|
|
5989
6073
|
const sceneWeight = Math.max(question.replicas ?? 1, 1);
|
|
5990
|
-
const
|
|
6074
|
+
const batchMode = resolveBatchMode(question.ruleId);
|
|
5991
6075
|
const last = group.batches.at(-1);
|
|
5992
|
-
if (last !== void 0 && last.ruleId === question.ruleId &&
|
|
6076
|
+
if (last !== void 0 && last.ruleId === question.ruleId && batchMode !== "none" && last.batchMode === batchMode) {
|
|
5993
6077
|
last.questions.push(question);
|
|
5994
6078
|
last.totalScenes += sceneWeight;
|
|
5995
6079
|
return;
|
|
5996
6080
|
}
|
|
5997
6081
|
group.batches.push({
|
|
5998
6082
|
ruleId: question.ruleId,
|
|
5999
|
-
|
|
6083
|
+
batchMode,
|
|
6000
6084
|
questions: [question],
|
|
6001
6085
|
totalScenes: sceneWeight
|
|
6002
6086
|
});
|
|
6003
6087
|
}
|
|
6088
|
+
function resolveBatchMode(ruleId) {
|
|
6089
|
+
if (BATCHABLE_SET.has(ruleId)) return "safe";
|
|
6090
|
+
if (OPT_IN_BATCHABLE_SET.has(ruleId)) return "opt-in";
|
|
6091
|
+
return "none";
|
|
6092
|
+
}
|
|
6004
6093
|
|
|
6005
6094
|
// src/core/gotcha/survey-generator.ts
|
|
6006
6095
|
var NODE_PATH_SEPARATOR = " > ";
|
|
@@ -6019,12 +6108,18 @@ function generateGotchaSurvey(result, scores, options = {}) {
|
|
|
6019
6108
|
const mapped = sorted.map((issue) => mapToQuestion(issue, result.file)).filter((q) => q !== null);
|
|
6020
6109
|
const questions = deduplicateBySourceComponent(mapped);
|
|
6021
6110
|
const groupedQuestions = groupAndBatchSurveyQuestions(questions);
|
|
6111
|
+
const PROPAGATION_CANDIDATE_THRESHOLD = 3;
|
|
6112
|
+
const propagationCandidates = questions.filter(
|
|
6113
|
+
(q) => q.isInstanceChild
|
|
6114
|
+
).length;
|
|
6115
|
+
const suggestedDefaultApply = propagationCandidates >= PROPAGATION_CANDIDATE_THRESHOLD;
|
|
6022
6116
|
return {
|
|
6023
6117
|
designGrade: grade,
|
|
6024
6118
|
isReadyForCodeGen: isReadyForCodeGen(grade),
|
|
6025
6119
|
questions,
|
|
6026
6120
|
groupedQuestions,
|
|
6027
|
-
designKey: options.designKey ?? ""
|
|
6121
|
+
designKey: options.designKey ?? "",
|
|
6122
|
+
suggestedDefaultApply
|
|
6028
6123
|
};
|
|
6029
6124
|
}
|
|
6030
6125
|
function deduplicateSiblingIssues(issues) {
|
|
@@ -6173,12 +6268,15 @@ function findNodeById2(node, id) {
|
|
|
6173
6268
|
var GotchaSurveyOptionsSchema = z.object({
|
|
6174
6269
|
preset: z.enum(["relaxed", "dev-friendly", "ai-ready", "strict"]).optional(),
|
|
6175
6270
|
token: z.string().optional(),
|
|
6271
|
+
/** Accepted for CLI parity with `analyze`; Figma URL loads always use REST (#461). */
|
|
6272
|
+
api: z.boolean().optional(),
|
|
6176
6273
|
config: z.string().optional(),
|
|
6177
6274
|
targetNodeId: z.string().optional(),
|
|
6178
6275
|
json: z.boolean().optional(),
|
|
6179
6276
|
scope: z.enum(["page", "component"]).optional()
|
|
6180
6277
|
});
|
|
6181
6278
|
async function runGotchaSurvey(input, options) {
|
|
6279
|
+
void options.api;
|
|
6182
6280
|
const { file, nodeId } = await loadFile(input, options.token);
|
|
6183
6281
|
const effectiveNodeId = options.targetNodeId ?? nodeId;
|
|
6184
6282
|
let configs = options.preset ? { ...getConfigsWithPreset(options.preset) } : { ...RULE_CONFIGS };
|
|
@@ -6207,7 +6305,10 @@ function formatHumanSummary(survey) {
|
|
|
6207
6305
|
return lines.join("\n");
|
|
6208
6306
|
}
|
|
6209
6307
|
function registerGotchaSurvey(cli2) {
|
|
6210
|
-
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(
|
|
6308
|
+
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(
|
|
6309
|
+
"--api",
|
|
6310
|
+
"No-op for Figma URL inputs (file data is always fetched via REST). Accepted for CLI parity with `analyze` (#461)."
|
|
6311
|
+
).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>", "(#404) 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)").example(" canicode gotcha-survey https://www.figma.com/design/ABC123/MyDesign --json").example(" canicode gotcha-survey ./fixtures/my-design --json").action(async (input, rawOptions) => {
|
|
6211
6312
|
const parseResult = GotchaSurveyOptionsSchema.safeParse(rawOptions);
|
|
6212
6313
|
if (!parseResult.success) {
|
|
6213
6314
|
const msg = parseResult.error.issues.map((i) => `--${i.path.join(".")}: ${i.message}`).join("\n");
|
|
@@ -6260,6 +6361,239 @@ ${msg}`);
|
|
|
6260
6361
|
}
|
|
6261
6362
|
});
|
|
6262
6363
|
}
|
|
6364
|
+
var DetectionSchema = z.literal("rule-based");
|
|
6365
|
+
var OutputChannelSchema = z.enum(["score", "annotation"]);
|
|
6366
|
+
var PersistenceIntentSchema = z.enum(["transient", "durable"]);
|
|
6367
|
+
var RulePurposeSchema = z.enum(["violation", "info-collection"]);
|
|
6368
|
+
|
|
6369
|
+
// src/core/contracts/gotcha-survey.ts
|
|
6370
|
+
var GradeSchema = z.enum(["S", "A+", "A", "B+", "B", "C+", "C", "D", "F"]);
|
|
6371
|
+
var InstanceContextSchema = z.object({
|
|
6372
|
+
parentInstanceNodeId: z.string(),
|
|
6373
|
+
sourceNodeId: z.string(),
|
|
6374
|
+
sourceComponentId: z.string().optional(),
|
|
6375
|
+
sourceComponentName: z.string().optional()
|
|
6376
|
+
});
|
|
6377
|
+
var RuleApplyStrategySchema = z.enum([
|
|
6378
|
+
"property-mod",
|
|
6379
|
+
"structural-mod",
|
|
6380
|
+
"annotation",
|
|
6381
|
+
"auto-fix"
|
|
6382
|
+
]);
|
|
6383
|
+
var TargetPropertySchema = z.union([z.string(), z.array(z.string())]);
|
|
6384
|
+
var AnnotationPropertySchema = z.object({ type: z.string() });
|
|
6385
|
+
var GotchaDetectionSchema = DetectionSchema;
|
|
6386
|
+
var GotchaOutputChannelSchema = OutputChannelSchema.extract([
|
|
6387
|
+
"annotation"
|
|
6388
|
+
]);
|
|
6389
|
+
var GotchaPersistenceIntentSchema = PersistenceIntentSchema.extract([
|
|
6390
|
+
"durable"
|
|
6391
|
+
]);
|
|
6392
|
+
var GotchaSurveyQuestionSchema = z.object({
|
|
6393
|
+
nodeId: z.string(),
|
|
6394
|
+
nodeName: z.string(),
|
|
6395
|
+
ruleId: z.string(),
|
|
6396
|
+
detection: GotchaDetectionSchema,
|
|
6397
|
+
outputChannel: GotchaOutputChannelSchema,
|
|
6398
|
+
persistenceIntent: GotchaPersistenceIntentSchema,
|
|
6399
|
+
/**
|
|
6400
|
+
* #406: Classifies the triggering rule as `violation` (score-primary,
|
|
6401
|
+
* gotcha secondary) or `info-collection` (annotation-primary, score
|
|
6402
|
+
* minimal). Consumers use this to prioritize answers that collect durable
|
|
6403
|
+
* implementation context over answers that merely describe how to fix a
|
|
6404
|
+
* violation the rule will stop firing for.
|
|
6405
|
+
*/
|
|
6406
|
+
purpose: RulePurposeSchema,
|
|
6407
|
+
severity: SeveritySchema,
|
|
6408
|
+
question: z.string(),
|
|
6409
|
+
hint: z.string(),
|
|
6410
|
+
example: z.string(),
|
|
6411
|
+
instanceContext: InstanceContextSchema.optional(),
|
|
6412
|
+
applyStrategy: RuleApplyStrategySchema,
|
|
6413
|
+
targetProperty: TargetPropertySchema.optional(),
|
|
6414
|
+
annotationProperties: z.array(AnnotationPropertySchema).optional(),
|
|
6415
|
+
suggestedName: z.string().optional(),
|
|
6416
|
+
isInstanceChild: z.boolean(),
|
|
6417
|
+
sourceChildId: z.string().optional(),
|
|
6418
|
+
// #356: when this question collapses N instance-child issues that share the
|
|
6419
|
+
// same `(sourceComponentId, sourceNodeId, ruleId)` tuple, `replicas` is the
|
|
6420
|
+
// total instance count (>=2) and `replicaNodeIds` lists every instance scene
|
|
6421
|
+
// node id OTHER than the kept `nodeId`. Apply step iterates
|
|
6422
|
+
// `[nodeId, ...replicaNodeIds]` so the same answer lands on every replica.
|
|
6423
|
+
// Single-instance questions omit both fields.
|
|
6424
|
+
replicas: z.number().int().min(2).optional(),
|
|
6425
|
+
replicaNodeIds: z.array(z.string()).optional()
|
|
6426
|
+
});
|
|
6427
|
+
var SurveyQuestionBatchSchema = z.object({
|
|
6428
|
+
ruleId: z.string(),
|
|
6429
|
+
/**
|
|
6430
|
+
* Rendering mode for this batch (see `BatchMode` in
|
|
6431
|
+
* `src/core/gotcha/group-and-batch-questions.ts` — the authoritative
|
|
6432
|
+
* whitelists `BATCHABLE_RULE_IDS` and `OPT_IN_BATCHABLE_RULE_IDS` live
|
|
6433
|
+
* there):
|
|
6434
|
+
* - `"safe"` — one answer uniformly applies to every member (#369).
|
|
6435
|
+
* - `"opt-in"` — one shared answer is a suggested default; the user may
|
|
6436
|
+
* reply `split` for per-node override (#426).
|
|
6437
|
+
* - `"none"` — single-member batch, renders the standard per-question
|
|
6438
|
+
* template.
|
|
6439
|
+
*/
|
|
6440
|
+
batchMode: z.enum(["safe", "opt-in", "none"]),
|
|
6441
|
+
questions: z.array(GotchaSurveyQuestionSchema),
|
|
6442
|
+
/**
|
|
6443
|
+
* Sum of `max(question.replicas, 1)` across `questions`. Counts the
|
|
6444
|
+
* actual Figma scene fan-out so the SKILL can render `N instances`
|
|
6445
|
+
* accurately even when one batch member already collapses multiple
|
|
6446
|
+
* replicas via the #356 source-component dedupe.
|
|
6447
|
+
*/
|
|
6448
|
+
totalScenes: z.number().int().min(1)
|
|
6449
|
+
});
|
|
6450
|
+
var SurveyQuestionGroupSchema = z.object({
|
|
6451
|
+
/**
|
|
6452
|
+
* Shared `instanceContext` for the group, or `null` for the trailing
|
|
6453
|
+
* non-instance group. The SKILL emits the verbose "Instance note" header
|
|
6454
|
+
* once per non-null group instead of once per question (#370).
|
|
6455
|
+
*/
|
|
6456
|
+
instanceContext: InstanceContextSchema.nullable(),
|
|
6457
|
+
batches: z.array(SurveyQuestionBatchSchema)
|
|
6458
|
+
});
|
|
6459
|
+
var GroupedSurveySchema = z.object({
|
|
6460
|
+
groups: z.array(SurveyQuestionGroupSchema)
|
|
6461
|
+
});
|
|
6462
|
+
var GotchaSurveySchema = z.object({
|
|
6463
|
+
designGrade: GradeSchema,
|
|
6464
|
+
isReadyForCodeGen: z.boolean(),
|
|
6465
|
+
questions: z.array(GotchaSurveyQuestionSchema),
|
|
6466
|
+
groupedQuestions: GroupedSurveySchema,
|
|
6467
|
+
/**
|
|
6468
|
+
* #384 — canonical identifier for this design across canicode runs.
|
|
6469
|
+
* Computed by `computeDesignKey(input)` (`<fileKey>#<nodeId>` for Figma
|
|
6470
|
+
* URLs, absolute path for fixtures). The `canicode-gotchas` SKILL reads
|
|
6471
|
+
* this directly when upserting the per-design section, so the SKILL.md
|
|
6472
|
+
* prose no longer parses URLs (per ADR-016).
|
|
6473
|
+
*/
|
|
6474
|
+
designKey: z.string(),
|
|
6475
|
+
/**
|
|
6476
|
+
* #428 — threshold hint for the `allowDefinitionWrite` picker in the
|
|
6477
|
+
* `canicode-roundtrip` skill. `true` when `propagationCandidates >= 3`
|
|
6478
|
+
* (i.e. three or more questions target instance children that could
|
|
6479
|
+
* benefit from definition-level writes). When `false`, the skill silently
|
|
6480
|
+
* uses the annotation default (ADR-012) without surfacing the picker —
|
|
6481
|
+
* the opt-in flow is over-engineered for tiny surveys.
|
|
6482
|
+
*
|
|
6483
|
+
* Computed server-side from `questions` so the skill doesn't have to
|
|
6484
|
+
* count `isInstanceChild` manually; the skill may still override this
|
|
6485
|
+
* hint when it has additional context (e.g. all candidates are
|
|
6486
|
+
* read-only per probe result).
|
|
6487
|
+
*/
|
|
6488
|
+
suggestedDefaultApply: z.boolean()
|
|
6489
|
+
});
|
|
6490
|
+
var AnswersMapSchema = z.record(
|
|
6491
|
+
z.string(),
|
|
6492
|
+
z.union([
|
|
6493
|
+
z.object({ answer: z.string() }),
|
|
6494
|
+
z.object({ skipped: z.literal(true) })
|
|
6495
|
+
])
|
|
6496
|
+
);
|
|
6497
|
+
var RenderGotchaSectionInputSchema = z.object({
|
|
6498
|
+
questions: z.array(GotchaSurveyQuestionSchema),
|
|
6499
|
+
answers: AnswersMapSchema,
|
|
6500
|
+
designName: z.string(),
|
|
6501
|
+
figmaUrl: z.string(),
|
|
6502
|
+
designKey: z.string(),
|
|
6503
|
+
designGrade: z.string(),
|
|
6504
|
+
analyzedAt: z.string(),
|
|
6505
|
+
/** Local date for the section header (`YYYY-MM-DD`). */
|
|
6506
|
+
today: z.string()
|
|
6507
|
+
});
|
|
6508
|
+
function isSkippedAnswer(nodeId, answers) {
|
|
6509
|
+
const v = answers[nodeId];
|
|
6510
|
+
if (v === void 0) return true;
|
|
6511
|
+
if ("skipped" in v && v.skipped === true) return true;
|
|
6512
|
+
if ("answer" in v) return false;
|
|
6513
|
+
return true;
|
|
6514
|
+
}
|
|
6515
|
+
function skippedCountsByRule(skippedQs) {
|
|
6516
|
+
const m = /* @__PURE__ */ new Map();
|
|
6517
|
+
for (const q of skippedQs) {
|
|
6518
|
+
m.set(q.ruleId, (m.get(q.ruleId) ?? 0) + 1);
|
|
6519
|
+
}
|
|
6520
|
+
return m;
|
|
6521
|
+
}
|
|
6522
|
+
function renderSkippedCompact(skippedQs) {
|
|
6523
|
+
const n = skippedQs.length;
|
|
6524
|
+
const counts = skippedCountsByRule(skippedQs);
|
|
6525
|
+
const lines = [`#### Skipped (${n})`, ""];
|
|
6526
|
+
const sortedRules = [...counts.keys()].sort((a, b) => a.localeCompare(b));
|
|
6527
|
+
for (const ruleId of sortedRules) {
|
|
6528
|
+
const c = counts.get(ruleId) ?? 0;
|
|
6529
|
+
lines.push(`- \`${ruleId}\` \xD7 ${c}`);
|
|
6530
|
+
}
|
|
6531
|
+
lines.push("");
|
|
6532
|
+
return lines.join("\n");
|
|
6533
|
+
}
|
|
6534
|
+
function renderInstanceContextBullet(q) {
|
|
6535
|
+
const ic = q.instanceContext;
|
|
6536
|
+
if (!ic) return null;
|
|
6537
|
+
let componentPart = "";
|
|
6538
|
+
if (ic.sourceComponentName !== void 0 && ic.sourceComponentId !== void 0) {
|
|
6539
|
+
componentPart = `, component \`${ic.sourceComponentName}\` / \`${ic.sourceComponentId}\``;
|
|
6540
|
+
} else if (ic.sourceComponentName !== void 0) {
|
|
6541
|
+
componentPart = `, component \`${ic.sourceComponentName}\``;
|
|
6542
|
+
} else if (ic.sourceComponentId !== void 0) {
|
|
6543
|
+
componentPart = `, component \`${ic.sourceComponentId}\``;
|
|
6544
|
+
}
|
|
6545
|
+
return `- **Instance context**: parent instance \`${ic.parentInstanceNodeId}\`, source node \`${ic.sourceNodeId}\`${componentPart} \u2014 roundtrip apply uses this to write on the source definition when instance overrides fail.`;
|
|
6546
|
+
}
|
|
6547
|
+
function renderGotchaSection(raw) {
|
|
6548
|
+
const input = RenderGotchaSectionInputSchema.parse(raw);
|
|
6549
|
+
const header = [
|
|
6550
|
+
`## #{{SECTION_NUMBER}} \u2014 ${input.designName} \u2014 ${input.today}`,
|
|
6551
|
+
"",
|
|
6552
|
+
`- **Figma URL**: ${input.figmaUrl}`,
|
|
6553
|
+
`- **Design key**: ${input.designKey}`,
|
|
6554
|
+
`- **Grade**: ${input.designGrade}`,
|
|
6555
|
+
`- **Analyzed at**: ${input.analyzedAt}`,
|
|
6556
|
+
"",
|
|
6557
|
+
"### Gotchas",
|
|
6558
|
+
""
|
|
6559
|
+
].join("\n");
|
|
6560
|
+
const answered = [];
|
|
6561
|
+
const skippedList = [];
|
|
6562
|
+
for (const q of input.questions) {
|
|
6563
|
+
if (isSkippedAnswer(q.nodeId, input.answers)) skippedList.push(q);
|
|
6564
|
+
else answered.push(q);
|
|
6565
|
+
}
|
|
6566
|
+
const blocks = [];
|
|
6567
|
+
for (const q of answered) {
|
|
6568
|
+
const v = input.answers[q.nodeId];
|
|
6569
|
+
if (v === void 0 || !("answer" in v)) {
|
|
6570
|
+
throw new Error(
|
|
6571
|
+
`renderGotchaSection: expected answer for nodeId ${q.nodeId} (answered set)`
|
|
6572
|
+
);
|
|
6573
|
+
}
|
|
6574
|
+
const answerLine = v.answer;
|
|
6575
|
+
const lines = [
|
|
6576
|
+
`#### ${q.ruleId} \u2014 ${q.nodeName}`,
|
|
6577
|
+
"",
|
|
6578
|
+
`- **Severity**: ${q.severity}`,
|
|
6579
|
+
`- **Node ID**: ${q.nodeId}`
|
|
6580
|
+
];
|
|
6581
|
+
const icBullet = renderInstanceContextBullet(q);
|
|
6582
|
+
if (icBullet !== null) {
|
|
6583
|
+
lines.push(icBullet);
|
|
6584
|
+
}
|
|
6585
|
+
lines.push(
|
|
6586
|
+
`- **Question**: ${q.question}`,
|
|
6587
|
+
`- **Answer**: ${answerLine}`,
|
|
6588
|
+
""
|
|
6589
|
+
);
|
|
6590
|
+
blocks.push(lines.join("\n"));
|
|
6591
|
+
}
|
|
6592
|
+
if (skippedList.length > 0) {
|
|
6593
|
+
blocks.push(renderSkippedCompact(skippedList));
|
|
6594
|
+
}
|
|
6595
|
+
return `${header}${blocks.join("")}`.replace(/\s+$/, "") + "\n";
|
|
6596
|
+
}
|
|
6263
6597
|
z.enum([
|
|
6264
6598
|
"missing",
|
|
6265
6599
|
"valid",
|
|
@@ -6376,10 +6710,26 @@ function ensureTrailingNewline(s) {
|
|
|
6376
6710
|
}
|
|
6377
6711
|
|
|
6378
6712
|
// src/cli/commands/upsert-gotcha-section.ts
|
|
6713
|
+
var AnswerSchema = z.union([
|
|
6714
|
+
z.object({ answer: z.string() }),
|
|
6715
|
+
z.object({ skipped: z.literal(true) })
|
|
6716
|
+
]);
|
|
6717
|
+
var UpsertJsonPayloadSchema = z.object({
|
|
6718
|
+
survey: GotchaSurveySchema.pick({
|
|
6719
|
+
designKey: true,
|
|
6720
|
+
designGrade: true,
|
|
6721
|
+
questions: true
|
|
6722
|
+
}),
|
|
6723
|
+
answers: z.record(z.string(), AnswerSchema),
|
|
6724
|
+
designName: z.string(),
|
|
6725
|
+
figmaUrl: z.string(),
|
|
6726
|
+
analyzedAt: z.string(),
|
|
6727
|
+
today: z.string()
|
|
6728
|
+
});
|
|
6379
6729
|
var UpsertOptionsSchema = z.object({
|
|
6380
6730
|
file: z.string().min(1, "--file is required"),
|
|
6381
6731
|
designKey: z.string().min(1, "--design-key is required"),
|
|
6382
|
-
|
|
6732
|
+
input: z.string().min(1, "--input is required (use '--input=-' to read stdin)")
|
|
6383
6733
|
});
|
|
6384
6734
|
var USER_MESSAGES = {
|
|
6385
6735
|
missing: "Gotchas SKILL.md not found at the given path. Run `canicode init` first, then re-invoke this skill.",
|
|
@@ -6392,8 +6742,38 @@ async function readStdin() {
|
|
|
6392
6742
|
}
|
|
6393
6743
|
return Buffer.concat(chunks).toString("utf-8");
|
|
6394
6744
|
}
|
|
6745
|
+
function parseUpsertPayload(rawJson) {
|
|
6746
|
+
let parsed;
|
|
6747
|
+
try {
|
|
6748
|
+
parsed = JSON.parse(rawJson);
|
|
6749
|
+
} catch {
|
|
6750
|
+
throw new Error("Invalid JSON in --input");
|
|
6751
|
+
}
|
|
6752
|
+
const result = UpsertJsonPayloadSchema.safeParse(parsed);
|
|
6753
|
+
if (!result.success) {
|
|
6754
|
+
const msg = result.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`).join("; ");
|
|
6755
|
+
throw new Error(`Invalid upsert payload: ${msg}`);
|
|
6756
|
+
}
|
|
6757
|
+
return result.data;
|
|
6758
|
+
}
|
|
6395
6759
|
async function runUpsertGotchaSection(options) {
|
|
6396
|
-
const
|
|
6760
|
+
const rawJson = options.input === "-" ? await readStdin() : readFileSync(options.input, "utf-8");
|
|
6761
|
+
const payload = parseUpsertPayload(rawJson);
|
|
6762
|
+
if (payload.survey.designKey !== options.designKey) {
|
|
6763
|
+
throw new Error(
|
|
6764
|
+
`--design-key (${options.designKey}) does not match survey.designKey (${payload.survey.designKey})`
|
|
6765
|
+
);
|
|
6766
|
+
}
|
|
6767
|
+
const sectionMarkdown = renderGotchaSection({
|
|
6768
|
+
questions: payload.survey.questions,
|
|
6769
|
+
answers: payload.answers,
|
|
6770
|
+
designName: payload.designName,
|
|
6771
|
+
figmaUrl: payload.figmaUrl,
|
|
6772
|
+
designKey: payload.survey.designKey,
|
|
6773
|
+
designGrade: payload.survey.designGrade,
|
|
6774
|
+
analyzedAt: payload.analyzedAt,
|
|
6775
|
+
today: payload.today
|
|
6776
|
+
});
|
|
6397
6777
|
const currentContent = existsSync(options.file) ? readFileSync(options.file, "utf-8") : null;
|
|
6398
6778
|
const { state, newContent, plan } = renderUpsertedFile({
|
|
6399
6779
|
currentContent,
|
|
@@ -6406,7 +6786,8 @@ async function runUpsertGotchaSection(options) {
|
|
|
6406
6786
|
action: null,
|
|
6407
6787
|
sectionNumber: null,
|
|
6408
6788
|
wrote: false,
|
|
6409
|
-
userMessage: USER_MESSAGES[state] ?? null
|
|
6789
|
+
userMessage: USER_MESSAGES[state] ?? null,
|
|
6790
|
+
designKey: payload.survey.designKey
|
|
6410
6791
|
};
|
|
6411
6792
|
}
|
|
6412
6793
|
writeFileSync(options.file, newContent, "utf-8");
|
|
@@ -6415,7 +6796,8 @@ async function runUpsertGotchaSection(options) {
|
|
|
6415
6796
|
action: plan?.action ?? null,
|
|
6416
6797
|
sectionNumber: plan?.sectionNumber ?? null,
|
|
6417
6798
|
wrote: true,
|
|
6418
|
-
userMessage: null
|
|
6799
|
+
userMessage: null,
|
|
6800
|
+
designKey: payload.survey.designKey
|
|
6419
6801
|
};
|
|
6420
6802
|
}
|
|
6421
6803
|
function registerUpsertGotchaSection(cli2) {
|
|
@@ -6426,8 +6808,8 @@ function registerUpsertGotchaSection(cli2) {
|
|
|
6426
6808
|
"--design-key <key>",
|
|
6427
6809
|
"Canonical design key from gotcha-survey's response"
|
|
6428
6810
|
).option(
|
|
6429
|
-
"--
|
|
6430
|
-
"
|
|
6811
|
+
"--input <path>",
|
|
6812
|
+
"JSON payload path, or '--input=-' to read JSON from stdin (cac parses a bare '-' as a flag, so the '=' form is required)."
|
|
6431
6813
|
).action(async (rawOptions) => {
|
|
6432
6814
|
const parseResult = UpsertOptionsSchema.safeParse(rawOptions);
|
|
6433
6815
|
if (!parseResult.success) {
|
|
@@ -6585,52 +6967,6 @@ function defaultSourceDir() {
|
|
|
6585
6967
|
function defaultCursorBundleRoot() {
|
|
6586
6968
|
return fileURLToPath(new URL("../../skills/cursor", import.meta.url));
|
|
6587
6969
|
}
|
|
6588
|
-
async function copySkillTree(skillName, srcSkillDir, destSkillDir, force) {
|
|
6589
|
-
if (!existsSync(srcSkillDir)) {
|
|
6590
|
-
throw new Error(`Bundled skill directory missing: ${srcSkillDir}`);
|
|
6591
|
-
}
|
|
6592
|
-
mkdirSync(destSkillDir, { recursive: true });
|
|
6593
|
-
const ops = [];
|
|
6594
|
-
const files = listFilesRecursive(srcSkillDir);
|
|
6595
|
-
for (const relPath of files) {
|
|
6596
|
-
const src = join(srcSkillDir, relPath);
|
|
6597
|
-
const dest = join(destSkillDir, relPath);
|
|
6598
|
-
mkdirSync(dirname(dest), { recursive: true });
|
|
6599
|
-
const label = join(skillName, relPath);
|
|
6600
|
-
let action;
|
|
6601
|
-
if (!existsSync(dest)) {
|
|
6602
|
-
action = "install";
|
|
6603
|
-
} else if (force) {
|
|
6604
|
-
action = "force-overwrite";
|
|
6605
|
-
} else {
|
|
6606
|
-
action = "needs-decision";
|
|
6607
|
-
}
|
|
6608
|
-
ops.push({ src, dest, label, action });
|
|
6609
|
-
}
|
|
6610
|
-
const candidates = ops.filter((op) => op.action === "needs-decision");
|
|
6611
|
-
const decisions = candidates.length > 0 ? await promptOverwriteBatch(candidates.map((op) => ({ label: op.label, dest: op.dest }))) : /* @__PURE__ */ new Map();
|
|
6612
|
-
const installed = [];
|
|
6613
|
-
const overwritten = [];
|
|
6614
|
-
const skipped = [];
|
|
6615
|
-
for (const op of ops) {
|
|
6616
|
-
if (op.action === "install") {
|
|
6617
|
-
copyFileSync(op.src, op.dest);
|
|
6618
|
-
installed.push(op.label);
|
|
6619
|
-
} else if (op.action === "force-overwrite") {
|
|
6620
|
-
copyFileSync(op.src, op.dest);
|
|
6621
|
-
overwritten.push(op.label);
|
|
6622
|
-
} else {
|
|
6623
|
-
const decision = decisions.get(op.label) ?? "skip";
|
|
6624
|
-
if (decision === "overwrite") {
|
|
6625
|
-
copyFileSync(op.src, op.dest);
|
|
6626
|
-
overwritten.push(op.label);
|
|
6627
|
-
} else {
|
|
6628
|
-
skipped.push(op.label);
|
|
6629
|
-
}
|
|
6630
|
-
}
|
|
6631
|
-
}
|
|
6632
|
-
return { installed, overwritten, skipped };
|
|
6633
|
-
}
|
|
6634
6970
|
async function copyMultipleSkillTrees(entries, force) {
|
|
6635
6971
|
const ops = [];
|
|
6636
6972
|
for (const { skillName, srcSkillDir, destSkillDir } of entries) {
|
|
@@ -6741,35 +7077,6 @@ If you installed canicode from npm, please file a bug report \u2014 the tarball
|
|
|
6741
7077
|
}
|
|
6742
7078
|
return summary;
|
|
6743
7079
|
}
|
|
6744
|
-
var InstallClaudeGotchasOnlySchema = z.object({
|
|
6745
|
-
force: z.boolean(),
|
|
6746
|
-
cwd: z.string().optional(),
|
|
6747
|
-
sourceDir: z.string().optional()
|
|
6748
|
-
});
|
|
6749
|
-
async function installClaudeGotchasSkillOnly(rawOptions) {
|
|
6750
|
-
const options = InstallClaudeGotchasOnlySchema.parse(rawOptions);
|
|
6751
|
-
const sourceDir = options.sourceDir ?? defaultSourceDir();
|
|
6752
|
-
const skillName = "canicode-gotchas";
|
|
6753
|
-
const srcSkillDir = join(sourceDir, skillName);
|
|
6754
|
-
const cwd = options.cwd ?? process.cwd();
|
|
6755
|
-
const targetDir = join(cwd, ".claude", "skills");
|
|
6756
|
-
const destSkillDir = join(targetDir, skillName);
|
|
6757
|
-
if (!existsSync(sourceDir)) {
|
|
6758
|
-
throw new Error(
|
|
6759
|
-
`Bundled skills directory not found: ${sourceDir}
|
|
6760
|
-
If you are developing canicode, run 'pnpm build' first.
|
|
6761
|
-
If you installed canicode from npm, please file a bug report \u2014 the tarball is missing skills/.`
|
|
6762
|
-
);
|
|
6763
|
-
}
|
|
6764
|
-
mkdirSync(targetDir, { recursive: true });
|
|
6765
|
-
const part = await copySkillTree(skillName, srcSkillDir, destSkillDir, options.force);
|
|
6766
|
-
return {
|
|
6767
|
-
installed: part.installed,
|
|
6768
|
-
overwritten: part.overwritten,
|
|
6769
|
-
skipped: part.skipped,
|
|
6770
|
-
targetDir
|
|
6771
|
-
};
|
|
6772
|
-
}
|
|
6773
7080
|
var InstallCursorBundledSchema = z.object({
|
|
6774
7081
|
force: z.boolean(),
|
|
6775
7082
|
cwd: z.string().optional(),
|
|
@@ -6894,14 +7201,16 @@ function formatNextSteps(opts) {
|
|
|
6894
7201
|
"",
|
|
6895
7202
|
" Next:",
|
|
6896
7203
|
" 1. Restart Cursor or reload MCP (so skills + MCP tools load in a fresh session)",
|
|
6897
|
-
" 2. In Agent chat, @ canicode-roundtrip with your Figma URL (or @ canicode-gotchas for survey-only)"
|
|
7204
|
+
" 2. In Agent chat, @ canicode-roundtrip with your Figma URL (or @ canicode-gotchas for survey-only)",
|
|
7205
|
+
" \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` (#433)."
|
|
6898
7206
|
].join("\n");
|
|
6899
7207
|
}
|
|
6900
7208
|
return [
|
|
6901
7209
|
"",
|
|
6902
7210
|
" Next:",
|
|
6903
7211
|
" 1. Restart Claude Code (the newly installed skills only load on a fresh session)",
|
|
6904
|
-
" 2. Run /canicode-roundtrip <figma-url>"
|
|
7212
|
+
" 2. Run /canicode-roundtrip <figma-url>",
|
|
7213
|
+
" \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` (#433)."
|
|
6905
7214
|
].join("\n");
|
|
6906
7215
|
}
|
|
6907
7216
|
if (cursor) {
|
|
@@ -6910,7 +7219,8 @@ function formatNextSteps(opts) {
|
|
|
6910
7219
|
" Next:",
|
|
6911
7220
|
" 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)",
|
|
6912
7221
|
" 2. Restart Cursor so Figma tools (e.g. use_figma) load",
|
|
6913
|
-
" 3. @ canicode-roundtrip with your Figma URL for full roundtrip"
|
|
7222
|
+
" 3. @ canicode-roundtrip with your Figma URL for full roundtrip",
|
|
7223
|
+
" \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` (#433)."
|
|
6914
7224
|
].join("\n");
|
|
6915
7225
|
}
|
|
6916
7226
|
return [
|
|
@@ -6919,20 +7229,82 @@ function formatNextSteps(opts) {
|
|
|
6919
7229
|
" 1. Install Figma MCP:",
|
|
6920
7230
|
" claude mcp add -s project -t http figma https://mcp.figma.com/mcp",
|
|
6921
7231
|
" 2. Restart Claude Code (so the new skills + Figma MCP tools both load)",
|
|
6922
|
-
" 3. Run /canicode-roundtrip <figma-url>"
|
|
7232
|
+
" 3. Run /canicode-roundtrip <figma-url>",
|
|
7233
|
+
" \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` (#433)."
|
|
6923
7234
|
].join("\n");
|
|
6924
7235
|
}
|
|
6925
7236
|
var InitOptionsSchema = z.object({
|
|
6926
7237
|
token: z.string().optional(),
|
|
6927
7238
|
global: z.boolean().optional(),
|
|
6928
|
-
// cac maps `--no-skills` to `skills: false` (mirrors `--no-telemetry`).
|
|
6929
|
-
skills: z.boolean().optional(),
|
|
6930
7239
|
/** Install `skills/cursor/*` into `.cursor/skills/` (canicode, gotchas, roundtrip — issue #407). */
|
|
6931
7240
|
cursorSkills: z.boolean().optional(),
|
|
6932
7241
|
force: z.boolean().optional()
|
|
6933
7242
|
});
|
|
7243
|
+
function wantsSkillInstallWithoutToken(options) {
|
|
7244
|
+
return options.cursorSkills === true;
|
|
7245
|
+
}
|
|
7246
|
+
async function runInitSkillInstallSteps(options) {
|
|
7247
|
+
let skillStepOk = true;
|
|
7248
|
+
let skillSummary;
|
|
7249
|
+
try {
|
|
7250
|
+
const summary = await installSkills({
|
|
7251
|
+
target: options.global ? "global" : "project",
|
|
7252
|
+
force: options.force ?? false
|
|
7253
|
+
});
|
|
7254
|
+
console.log(`
|
|
7255
|
+
Skills installed to: ${summary.targetDir}/`);
|
|
7256
|
+
console.log(` installed: ${summary.installed.length}`);
|
|
7257
|
+
console.log(` overwritten: ${summary.overwritten.length}`);
|
|
7258
|
+
console.log(` skipped: ${summary.skipped.length}`);
|
|
7259
|
+
if (summary.skipped.length > 0) {
|
|
7260
|
+
console.log(` (Re-run with --force to overwrite skipped files.)`);
|
|
7261
|
+
}
|
|
7262
|
+
skillSummary = {
|
|
7263
|
+
installed: summary.installed.length,
|
|
7264
|
+
overwritten: summary.overwritten.length,
|
|
7265
|
+
skipped: summary.skipped.length
|
|
7266
|
+
};
|
|
7267
|
+
} catch (skillError) {
|
|
7268
|
+
console.error(
|
|
7269
|
+
`
|
|
7270
|
+
Skill install failed: ${skillError instanceof Error ? skillError.message : String(skillError)}`
|
|
7271
|
+
);
|
|
7272
|
+
process.exitCode = 1;
|
|
7273
|
+
skillStepOk = false;
|
|
7274
|
+
}
|
|
7275
|
+
if (options.cursorSkills && skillStepOk) {
|
|
7276
|
+
try {
|
|
7277
|
+
const cSummary = await installCursorBundledSkills({
|
|
7278
|
+
force: options.force ?? false
|
|
7279
|
+
});
|
|
7280
|
+
console.log(`
|
|
7281
|
+
Cursor skills installed to: ${cSummary.targetDir}/`);
|
|
7282
|
+
console.log(` installed: ${cSummary.installed.length}`);
|
|
7283
|
+
console.log(` overwritten: ${cSummary.overwritten.length}`);
|
|
7284
|
+
console.log(` skipped: ${cSummary.skipped.length}`);
|
|
7285
|
+
if (cSummary.skipped.length > 0) {
|
|
7286
|
+
console.log(` (Re-run with --force to overwrite skipped files.)`);
|
|
7287
|
+
}
|
|
7288
|
+
console.log(` Open a new chat and @-mention canicode, canicode-gotchas, or canicode-roundtrip if skills do not appear immediately.`);
|
|
7289
|
+
} catch (cursorError) {
|
|
7290
|
+
console.error(
|
|
7291
|
+
`
|
|
7292
|
+
Cursor skill install failed: ${cursorError instanceof Error ? cursorError.message : String(cursorError)}`
|
|
7293
|
+
);
|
|
7294
|
+
process.exitCode = 1;
|
|
7295
|
+
skillStepOk = false;
|
|
7296
|
+
}
|
|
7297
|
+
}
|
|
7298
|
+
return skillSummary !== void 0 ? { skillStepOk, skillSummary } : { skillStepOk };
|
|
7299
|
+
}
|
|
6934
7300
|
function registerInit(cli2) {
|
|
6935
|
-
cli2.command(
|
|
7301
|
+
cli2.command(
|
|
7302
|
+
"init",
|
|
7303
|
+
"Set up canicode with Figma API token (never paste a token into agent chat \u2014 use FIGMA_TOKEN=\u2026 or the interactive prompt)"
|
|
7304
|
+
).option(
|
|
7305
|
+
"--token <token>",
|
|
7306
|
+
"Save Figma API token (use env/CLI only \u2014 not agent chat) and install Claude Code skills to .claude/skills/"
|
|
7307
|
+
).option("--global", "Install skills to ~/.claude/skills/ instead of ./.claude/skills/").option("--cursor-skills", "Also install Cursor copies of canicode / canicode-gotchas / canicode-roundtrip under .cursor/skills/ (with `--token`, runs after Claude skills; without token, installs Claude skills + Cursor bundle)").option("--force", "Overwrite existing skill files without prompting (also for non-TTY/CI)").action(async (rawOptions) => {
|
|
6936
7308
|
try {
|
|
6937
7309
|
const parseResult = InitOptionsSchema.safeParse(rawOptions);
|
|
6938
7310
|
if (!parseResult.success) {
|
|
@@ -6947,103 +7319,57 @@ ${msg}`);
|
|
|
6947
7319
|
initAiready(options.token);
|
|
6948
7320
|
console.log(` Config saved: ${getConfigPath()}`);
|
|
6949
7321
|
console.log(` Reports will be saved to: ${getReportsDir()}/`);
|
|
6950
|
-
|
|
6951
|
-
|
|
6952
|
-
|
|
6953
|
-
|
|
6954
|
-
|
|
6955
|
-
|
|
6956
|
-
|
|
6957
|
-
|
|
6958
|
-
|
|
6959
|
-
|
|
6960
|
-
|
|
6961
|
-
|
|
6962
|
-
|
|
6963
|
-
|
|
6964
|
-
|
|
6965
|
-
}
|
|
6966
|
-
|
|
6967
|
-
installed: summary.installed.length,
|
|
6968
|
-
overwritten: summary.overwritten.length,
|
|
6969
|
-
skipped: summary.skipped.length
|
|
6970
|
-
};
|
|
6971
|
-
} catch (skillError) {
|
|
6972
|
-
console.error(
|
|
6973
|
-
`
|
|
6974
|
-
Skill install failed: ${skillError instanceof Error ? skillError.message : String(skillError)}`
|
|
6975
|
-
);
|
|
6976
|
-
process.exitCode = 1;
|
|
6977
|
-
skillStepOk = false;
|
|
6978
|
-
}
|
|
6979
|
-
} else if (options.cursorSkills) {
|
|
6980
|
-
try {
|
|
6981
|
-
const summary = await installClaudeGotchasSkillOnly({
|
|
6982
|
-
force: options.force ?? false
|
|
6983
|
-
});
|
|
6984
|
-
console.log(`
|
|
6985
|
-
Gotchas store (Claude Code skills path) installed to: ${summary.targetDir}/`);
|
|
6986
|
-
console.log(` installed: ${summary.installed.length}`);
|
|
6987
|
-
console.log(` overwritten: ${summary.overwritten.length}`);
|
|
6988
|
-
console.log(` skipped: ${summary.skipped.length}`);
|
|
6989
|
-
skillSummary = {
|
|
6990
|
-
installed: summary.installed.length,
|
|
6991
|
-
overwritten: summary.overwritten.length,
|
|
6992
|
-
skipped: summary.skipped.length
|
|
6993
|
-
};
|
|
6994
|
-
} catch (skillError) {
|
|
6995
|
-
console.error(
|
|
6996
|
-
`
|
|
6997
|
-
Gotchas skill install failed: ${skillError instanceof Error ? skillError.message : String(skillError)}`
|
|
6998
|
-
);
|
|
6999
|
-
process.exitCode = 1;
|
|
7000
|
-
skillStepOk = false;
|
|
7001
|
-
}
|
|
7002
|
-
}
|
|
7003
|
-
if (options.cursorSkills && skillStepOk) {
|
|
7004
|
-
try {
|
|
7005
|
-
const cSummary = await installCursorBundledSkills({
|
|
7006
|
-
force: options.force ?? false
|
|
7007
|
-
});
|
|
7008
|
-
console.log(`
|
|
7009
|
-
Cursor skills installed to: ${cSummary.targetDir}/`);
|
|
7010
|
-
console.log(` installed: ${cSummary.installed.length}`);
|
|
7011
|
-
console.log(` overwritten: ${cSummary.overwritten.length}`);
|
|
7012
|
-
console.log(` skipped: ${cSummary.skipped.length}`);
|
|
7013
|
-
if (cSummary.skipped.length > 0) {
|
|
7014
|
-
console.log(` (Re-run with --force to overwrite skipped files.)`);
|
|
7015
|
-
}
|
|
7016
|
-
console.log(` Open a new chat and @-mention canicode, canicode-gotchas, or canicode-roundtrip if skills do not appear immediately.`);
|
|
7017
|
-
} catch (cursorError) {
|
|
7018
|
-
console.error(
|
|
7019
|
-
`
|
|
7020
|
-
Cursor skill install failed: ${cursorError instanceof Error ? cursorError.message : String(cursorError)}`
|
|
7021
|
-
);
|
|
7022
|
-
process.exitCode = 1;
|
|
7023
|
-
skillStepOk = false;
|
|
7024
|
-
}
|
|
7322
|
+
const { skillStepOk, skillSummary } = await runInitSkillInstallSteps(options);
|
|
7323
|
+
trackEvent(EVENTS.CLI_INIT, {
|
|
7324
|
+
skillsRequested: true,
|
|
7325
|
+
cursorSkillsRequested: options.cursorSkills === true,
|
|
7326
|
+
skillStepOk,
|
|
7327
|
+
target: options.global ? "global" : "project",
|
|
7328
|
+
force: options.force ?? false,
|
|
7329
|
+
...skillSummary ?? {}
|
|
7330
|
+
});
|
|
7331
|
+
if (skillStepOk) {
|
|
7332
|
+
console.log(
|
|
7333
|
+
formatNextSteps({
|
|
7334
|
+
figmaMcpPresent: figmaMcpRegistered(),
|
|
7335
|
+
skillsInstalled: true,
|
|
7336
|
+
cursorSkillsInstalled: options.cursorSkills === true
|
|
7337
|
+
})
|
|
7338
|
+
);
|
|
7025
7339
|
}
|
|
7340
|
+
return;
|
|
7341
|
+
}
|
|
7342
|
+
if (wantsSkillInstallWithoutToken(options)) {
|
|
7343
|
+
const { skillStepOk, skillSummary } = await runInitSkillInstallSteps(options);
|
|
7026
7344
|
trackEvent(EVENTS.CLI_INIT, {
|
|
7027
|
-
skillsRequested:
|
|
7345
|
+
skillsRequested: true,
|
|
7028
7346
|
cursorSkillsRequested: options.cursorSkills === true,
|
|
7029
7347
|
skillStepOk,
|
|
7030
7348
|
target: options.global ? "global" : "project",
|
|
7031
7349
|
force: options.force ?? false,
|
|
7350
|
+
skillOnlyInit: true,
|
|
7032
7351
|
...skillSummary ?? {}
|
|
7033
7352
|
});
|
|
7034
7353
|
if (skillStepOk) {
|
|
7035
7354
|
console.log(
|
|
7036
7355
|
formatNextSteps({
|
|
7037
7356
|
figmaMcpPresent: figmaMcpRegistered(),
|
|
7038
|
-
skillsInstalled:
|
|
7357
|
+
skillsInstalled: true,
|
|
7039
7358
|
cursorSkillsInstalled: options.cursorSkills === true
|
|
7040
7359
|
})
|
|
7041
7360
|
);
|
|
7361
|
+
console.log(
|
|
7362
|
+
"\n Figma token not saved \u2014 run `canicode init --token \u2026` when you need REST analyze or MCP against live files."
|
|
7363
|
+
);
|
|
7042
7364
|
}
|
|
7043
7365
|
return;
|
|
7044
7366
|
}
|
|
7045
7367
|
console.log(`CANICODE SETUP
|
|
7046
7368
|
`);
|
|
7369
|
+
console.log(
|
|
7370
|
+
` Never paste your token into Claude/Cursor chat \u2014 use FIGMA_TOKEN=\u2026 npx canicode init or this prompt only.
|
|
7371
|
+
`
|
|
7372
|
+
);
|
|
7047
7373
|
console.log(` canicode init --token YOUR_FIGMA_TOKEN`);
|
|
7048
7374
|
console.log(` Get token: figma.com > Settings > Personal access tokens
|
|
7049
7375
|
`);
|
|
@@ -7051,8 +7377,7 @@ ${msg}`);
|
|
|
7051
7377
|
console.log(` --token also installs three Claude Code skills into ./.claude/skills/`);
|
|
7052
7378
|
console.log(` (canicode, canicode-gotchas, canicode-roundtrip).`);
|
|
7053
7379
|
console.log(` --global Install to ~/.claude/skills/ instead`);
|
|
7054
|
-
console.log(` --
|
|
7055
|
-
console.log(` --cursor-skills Also install Cursor copies of all three skills (.cursor/skills/); with --no-skills, still installs .claude gotcha store + Cursor bundle`);
|
|
7380
|
+
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)`);
|
|
7056
7381
|
console.log(` --force Overwrite existing skill files without prompting
|
|
7057
7382
|
`);
|
|
7058
7383
|
console.log(`After setup:`);
|
|
@@ -7151,6 +7476,164 @@ function registerListRules(cli2) {
|
|
|
7151
7476
|
}
|
|
7152
7477
|
});
|
|
7153
7478
|
}
|
|
7479
|
+
var StepFourReportSchema = z.object({
|
|
7480
|
+
/** ✅ — Strategy A property write (or A's auto-fix branch) succeeded. */
|
|
7481
|
+
resolved: z.number().int().min(0),
|
|
7482
|
+
/** 📝 — Strategy C wrote a Figma annotation (or A/B fell back to one). */
|
|
7483
|
+
annotated: z.number().int().min(0),
|
|
7484
|
+
/**
|
|
7485
|
+
* 🌐 — `applyWithInstanceFallback` propagated the write up to the
|
|
7486
|
+
* source COMPONENT definition (only counted when
|
|
7487
|
+
* `allowDefinitionWrite: true`).
|
|
7488
|
+
*/
|
|
7489
|
+
definitionWritten: z.number().int().min(0),
|
|
7490
|
+
/**
|
|
7491
|
+
* ⏭️ — User said "skip", "n/a", or otherwise declined a per-question
|
|
7492
|
+
* confirmation (Strategy B opt-out, etc.).
|
|
7493
|
+
*/
|
|
7494
|
+
skipped: z.number().int().min(0)
|
|
7495
|
+
});
|
|
7496
|
+
var ReanalyzeForTallySchema = z.object({
|
|
7497
|
+
/**
|
|
7498
|
+
* Total remaining issues from the re-analyze. Maps to the existing
|
|
7499
|
+
* `issueCount` (analyze JSON) / `questions.length` (gotcha-survey JSON)
|
|
7500
|
+
* field — both downstream channels populate it.
|
|
7501
|
+
*/
|
|
7502
|
+
issueCount: z.number().int().min(0),
|
|
7503
|
+
/**
|
|
7504
|
+
* Issues the re-analyze flagged with `acknowledged: true` because they
|
|
7505
|
+
* matched a canicode-authored Figma annotation harvested in Step 5a.
|
|
7506
|
+
* From the analyze response's top-level `acknowledgedCount` (#371).
|
|
7507
|
+
*/
|
|
7508
|
+
acknowledgedCount: z.number().int().min(0)
|
|
7509
|
+
});
|
|
7510
|
+
z.object({
|
|
7511
|
+
/** ✅ resolved (passthrough from `stepFourReport.resolved`). */
|
|
7512
|
+
X: z.number().int().min(0),
|
|
7513
|
+
/** 📝 annotated. */
|
|
7514
|
+
Y: z.number().int().min(0),
|
|
7515
|
+
/** 🌐 definition writes propagated. */
|
|
7516
|
+
Z: z.number().int().min(0),
|
|
7517
|
+
/** ⏭️ skipped. */
|
|
7518
|
+
W: z.number().int().min(0),
|
|
7519
|
+
/** `X + Y + Z + W` — questions the SKILL acted on in Step 4. */
|
|
7520
|
+
N: z.number().int().min(0),
|
|
7521
|
+
/** `reanalyzeResponse.issueCount` — total remaining after re-analyze. */
|
|
7522
|
+
V: z.number().int().min(0),
|
|
7523
|
+
/**
|
|
7524
|
+
* `reanalyzeResponse.acknowledgedCount` — the slice of `V` that carries
|
|
7525
|
+
* a canicode annotation (counted at half weight by the density score
|
|
7526
|
+
* per #371, but still surfaced as remaining).
|
|
7527
|
+
*/
|
|
7528
|
+
V_ack: z.number().int().min(0),
|
|
7529
|
+
/** `V - V_ack` — issues with no annotation; the user's follow-up backlog. */
|
|
7530
|
+
V_open: z.number().int().min(0)
|
|
7531
|
+
});
|
|
7532
|
+
|
|
7533
|
+
// src/core/roundtrip/compute-roundtrip-tally.ts
|
|
7534
|
+
function computeRoundtripTally(args) {
|
|
7535
|
+
const { stepFourReport, reanalyzeResponse } = args;
|
|
7536
|
+
const { resolved, annotated, definitionWritten, skipped } = stepFourReport;
|
|
7537
|
+
const { issueCount, acknowledgedCount } = reanalyzeResponse;
|
|
7538
|
+
if (acknowledgedCount > issueCount) {
|
|
7539
|
+
throw new Error(
|
|
7540
|
+
`computeRoundtripTally: reanalyzeResponse.acknowledgedCount (${acknowledgedCount}) cannot exceed issueCount (${issueCount}). Acknowledged issues are a subset of remaining issues.`
|
|
7541
|
+
);
|
|
7542
|
+
}
|
|
7543
|
+
return {
|
|
7544
|
+
X: resolved,
|
|
7545
|
+
Y: annotated,
|
|
7546
|
+
Z: definitionWritten,
|
|
7547
|
+
W: skipped,
|
|
7548
|
+
N: resolved + annotated + definitionWritten + skipped,
|
|
7549
|
+
V: issueCount,
|
|
7550
|
+
V_ack: acknowledgedCount,
|
|
7551
|
+
V_open: issueCount - acknowledgedCount
|
|
7552
|
+
};
|
|
7553
|
+
}
|
|
7554
|
+
|
|
7555
|
+
// src/cli/commands/roundtrip-tally.ts
|
|
7556
|
+
var RoundtripTallyCliOptionsSchema = z.object({
|
|
7557
|
+
analyze: z.string().min(1),
|
|
7558
|
+
step4: z.string().min(1)
|
|
7559
|
+
});
|
|
7560
|
+
function parseJsonFile(label, path, raw) {
|
|
7561
|
+
try {
|
|
7562
|
+
return JSON.parse(raw);
|
|
7563
|
+
} catch (err) {
|
|
7564
|
+
throw new Error(
|
|
7565
|
+
`roundtrip-tally: ${label} is not valid JSON (${path})`,
|
|
7566
|
+
{ cause: err }
|
|
7567
|
+
);
|
|
7568
|
+
}
|
|
7569
|
+
}
|
|
7570
|
+
async function readUtf8File(label, path) {
|
|
7571
|
+
try {
|
|
7572
|
+
return await readFile(path, "utf-8");
|
|
7573
|
+
} catch (err) {
|
|
7574
|
+
const detail = err instanceof Error ? err.message : String(err);
|
|
7575
|
+
throw new Error(`roundtrip-tally: cannot read ${label} (${path}): ${detail}`, {
|
|
7576
|
+
cause: err
|
|
7577
|
+
});
|
|
7578
|
+
}
|
|
7579
|
+
}
|
|
7580
|
+
async function computeRoundtripTallyFromSavedFiles(args) {
|
|
7581
|
+
const [analyzeRaw, step4Raw] = await Promise.all([
|
|
7582
|
+
readUtf8File("--analyze", args.analyzePath),
|
|
7583
|
+
readUtf8File("--step4", args.step4Path)
|
|
7584
|
+
]);
|
|
7585
|
+
const analyzeParsed = parseJsonFile("--analyze", args.analyzePath, analyzeRaw);
|
|
7586
|
+
const step4Parsed = parseJsonFile("--step4", args.step4Path, step4Raw);
|
|
7587
|
+
const reanalyzeResult = ReanalyzeForTallySchema.safeParse(analyzeParsed);
|
|
7588
|
+
if (!reanalyzeResult.success) {
|
|
7589
|
+
const msg = reanalyzeResult.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`).join("; ");
|
|
7590
|
+
throw new Error(
|
|
7591
|
+
`roundtrip-tally: --analyze must include issueCount and acknowledgedCount (${args.analyzePath}): ${msg}`
|
|
7592
|
+
);
|
|
7593
|
+
}
|
|
7594
|
+
const stepFourResult = StepFourReportSchema.safeParse(step4Parsed);
|
|
7595
|
+
if (!stepFourResult.success) {
|
|
7596
|
+
const msg = stepFourResult.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`).join("; ");
|
|
7597
|
+
throw new Error(
|
|
7598
|
+
`roundtrip-tally: --step4 must match StepFourReport (${args.step4Path}): ${msg}`
|
|
7599
|
+
);
|
|
7600
|
+
}
|
|
7601
|
+
return computeRoundtripTally({
|
|
7602
|
+
stepFourReport: stepFourResult.data,
|
|
7603
|
+
reanalyzeResponse: reanalyzeResult.data
|
|
7604
|
+
});
|
|
7605
|
+
}
|
|
7606
|
+
function registerRoundtripTally(cli2) {
|
|
7607
|
+
cli2.command(
|
|
7608
|
+
"roundtrip-tally",
|
|
7609
|
+
"Print the Step 5 roundtrip tally from re-analyze JSON and Step 4 outcome counts"
|
|
7610
|
+
).option("--analyze <path>", "Path to re-analyze JSON (`canicode analyze --json` output)").option("--step4 <path>", "Path to Step 4 structured counts (resolved / annotated / definitionWritten / skipped)").example(" canicode roundtrip-tally --analyze ./reanalyze.json --step4 ./step4-report.json").action(async (rawOptions) => {
|
|
7611
|
+
const parsed = RoundtripTallyCliOptionsSchema.safeParse(rawOptions);
|
|
7612
|
+
if (!parsed.success) {
|
|
7613
|
+
const msg = parsed.error.issues.map((i) => `--${i.path.join(".")}: ${i.message}`).join("\n");
|
|
7614
|
+
console.error(`
|
|
7615
|
+
roundtrip-tally requires --analyze and --step4:
|
|
7616
|
+
${msg}`);
|
|
7617
|
+
process.exit(1);
|
|
7618
|
+
}
|
|
7619
|
+
try {
|
|
7620
|
+
const tally = await computeRoundtripTallyFromSavedFiles({
|
|
7621
|
+
analyzePath: parsed.data.analyze,
|
|
7622
|
+
step4Path: parsed.data.step4
|
|
7623
|
+
});
|
|
7624
|
+
console.log(JSON.stringify(tally, null, 2));
|
|
7625
|
+
trackEvent(EVENTS.ROUNDTRIP_TALLY);
|
|
7626
|
+
} catch (err) {
|
|
7627
|
+
if (err instanceof Error) {
|
|
7628
|
+
trackError(err, { command: "roundtrip-tally" });
|
|
7629
|
+
console.error(err.message);
|
|
7630
|
+
} else {
|
|
7631
|
+
console.error(err);
|
|
7632
|
+
}
|
|
7633
|
+
process.exit(1);
|
|
7634
|
+
}
|
|
7635
|
+
});
|
|
7636
|
+
}
|
|
7154
7637
|
|
|
7155
7638
|
// src/cli/internal-commands.ts
|
|
7156
7639
|
var INTERNAL_COMMANDS = [
|
|
@@ -8429,9 +8912,9 @@ function registerCalibrateEvaluate(cli2) {
|
|
|
8429
8912
|
if (!existsSync(conversionPath)) {
|
|
8430
8913
|
throw new Error(`Conversion file not found: ${conversionPath}`);
|
|
8431
8914
|
}
|
|
8432
|
-
const { readFile:
|
|
8433
|
-
const analysisData = JSON.parse(await
|
|
8434
|
-
const conversionData = JSON.parse(await
|
|
8915
|
+
const { readFile: readFile5 } = await import('fs/promises');
|
|
8916
|
+
const analysisData = JSON.parse(await readFile5(analysisPath, "utf-8"));
|
|
8917
|
+
const conversionData = JSON.parse(await readFile5(conversionPath, "utf-8"));
|
|
8435
8918
|
let fixtureName;
|
|
8436
8919
|
if (options.runDir) {
|
|
8437
8920
|
const dirName = resolve(options.runDir).split(/[/\\]/).pop() ?? "";
|
|
@@ -10289,6 +10772,7 @@ registerVisualCompare(cli);
|
|
|
10289
10772
|
registerInit(cli);
|
|
10290
10773
|
registerConfig(cli);
|
|
10291
10774
|
registerListRules(cli);
|
|
10775
|
+
registerRoundtripTally(cli);
|
|
10292
10776
|
registerCalibrateAnalyze(cli);
|
|
10293
10777
|
registerCalibrateEvaluate(cli);
|
|
10294
10778
|
registerCalibrateGapReport(cli);
|
|
@@ -10312,6 +10796,7 @@ cli.help((sections) => {
|
|
|
10312
10796
|
section.body = section.body.split("\n").filter((line) => !INTERNAL_COMMANDS.some((cmd) => line.includes(cmd))).join("\n");
|
|
10313
10797
|
}
|
|
10314
10798
|
}
|
|
10799
|
+
sections.splice(1, 0, { body: ` ${pkg2.description}` });
|
|
10315
10800
|
sections.push(
|
|
10316
10801
|
{
|
|
10317
10802
|
title: "\nSetup",
|
|
@@ -10319,10 +10804,7 @@ cli.help((sections) => {
|
|
|
10319
10804
|
},
|
|
10320
10805
|
{
|
|
10321
10806
|
title: "\nData source",
|
|
10322
|
-
body:
|
|
10323
|
-
` --api Load via Figma REST API (needs FIGMA_TOKEN)`,
|
|
10324
|
-
` --token <token> Figma API token (or use FIGMA_TOKEN env var)`
|
|
10325
|
-
].join("\n")
|
|
10807
|
+
body: ` --token <token> Figma API token (or use FIGMA_TOKEN env var)`
|
|
10326
10808
|
},
|
|
10327
10809
|
{
|
|
10328
10810
|
title: "\nCustomization",
|
|
@@ -10331,10 +10813,10 @@ cli.help((sections) => {
|
|
|
10331
10813
|
{
|
|
10332
10814
|
title: "\nExamples",
|
|
10333
10815
|
body: [
|
|
10334
|
-
` $ canicode analyze "https://www.figma.com/design/..." --api`,
|
|
10335
10816
|
` $ canicode analyze "https://www.figma.com/design/..." --preset strict`,
|
|
10336
10817
|
` $ canicode analyze "https://www.figma.com/design/..." --config ./my-config.json`,
|
|
10337
|
-
` $ canicode gotcha-survey "https://www.figma.com/design/..." --json
|
|
10818
|
+
` $ canicode gotcha-survey "https://www.figma.com/design/..." --json`,
|
|
10819
|
+
` $ canicode roundtrip-tally --analyze ./reanalyze.json --step4 ./step4.json`
|
|
10338
10820
|
].join("\n")
|
|
10339
10821
|
},
|
|
10340
10822
|
{
|