canicode 0.10.3 → 0.10.4
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 +35 -30
- package/dist/cli/index.js +345 -21
- package/dist/cli/index.js.map +1 -1
- package/dist/index.d.ts +257 -3
- package/dist/index.js +83 -9
- package/dist/index.js.map +1 -1
- package/dist/mcp/server.js +144 -23
- package/dist/mcp/server.js.map +1 -1
- package/package.json +2 -1
- package/skills/canicode-gotchas/SKILL.md +66 -28
- package/skills/canicode-roundtrip/SKILL.md +180 -79
- package/skills/canicode-roundtrip/helpers.js +218 -2
package/README.md
CHANGED
|
@@ -72,7 +72,7 @@ npx canicode init --token figd_xxxxxxxxxxxxx
|
|
|
72
72
|
npx canicode analyze "https://www.figma.com/design/ABC123/MyDesign?node-id=1-234"
|
|
73
73
|
|
|
74
74
|
# MCP Server — works with Claude Code, Cursor, Claude Desktop
|
|
75
|
-
claude mcp add canicode -- npx
|
|
75
|
+
claude mcp add canicode -- npx --yes --package=canicode canicode-mcp
|
|
76
76
|
```
|
|
77
77
|
|
|
78
78
|
<details>
|
|
@@ -107,68 +107,73 @@ Each issue is classified: **Blocking** > **Risk** > **Missing Info** > **Suggest
|
|
|
107
107
|
|
|
108
108
|
---
|
|
109
109
|
|
|
110
|
-
## Installation
|
|
110
|
+
## Installation — pick one
|
|
111
|
+
|
|
112
|
+
Each row below is a **complete** install. Don't run more than one — they cover overlapping use cases. (#367)
|
|
113
|
+
|
|
114
|
+
| If you use… | Install |
|
|
115
|
+
|-------------|---------|
|
|
116
|
+
| **Claude Code** (recommended for the roundtrip workflow) | `npx canicode init --token figd_xxxxxxxxxxxxx` — saves the token AND drops `/canicode`, `/canicode-gotchas`, `/canicode-roundtrip` skills into `./.claude/skills/`. The skills already know how to call canicode via `npx canicode …`, no MCP install needed. |
|
|
117
|
+
| **Cursor / Claude Desktop / other MCP host** | `claude mcp add canicode -- npx --yes --package=canicode canicode-mcp` — registers the MCP server. |
|
|
118
|
+
| **Just the CLI** (CI, scripts) | Nothing. `npx canicode analyze "<figma-url>"` works directly. Run `canicode init --token …` once if you want the token persisted to `~/.canicode/config.json`. |
|
|
119
|
+
|
|
120
|
+
> **Get your token:** Figma → Settings → Security → Personal access tokens → Generate new token
|
|
121
|
+
|
|
122
|
+
> **Roundtrip prerequisite:** the `/canicode-roundtrip` skill calls the Figma MCP server to read and write the design. Install it once with `claude mcp add -s project -t http figma https://mcp.figma.com/mcp` and restart Claude Code so the new MCP tools load.
|
|
111
123
|
|
|
112
124
|
<details>
|
|
113
|
-
<summary><strong>
|
|
125
|
+
<summary><strong>Claude Code Skills</strong> — install details</summary>
|
|
114
126
|
|
|
115
127
|
```bash
|
|
116
|
-
npx canicode
|
|
128
|
+
npx canicode init --token figd_xxxxxxxxxxxxx
|
|
117
129
|
```
|
|
118
130
|
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
> **Get your token:** Figma → Settings → Security → Personal access tokens → Generate new token
|
|
131
|
+
Drops three skills into `./.claude/skills/`:
|
|
122
132
|
|
|
123
|
-
|
|
133
|
+
- **canicode** — lightweight CLI wrapper (use `/canicode <figma-url>`)
|
|
134
|
+
- **canicode-gotchas** — standalone gotcha survey (use `/canicode-gotchas <figma-url>`)
|
|
135
|
+
- **canicode-roundtrip** — full analyze → gotcha → apply roundtrip (use `/canicode-roundtrip <figma-url>`)
|
|
124
136
|
|
|
125
|
-
|
|
126
|
-
|------|---------------------|---------------------------|
|
|
127
|
-
| View, Collab | 6 req/month | 6 req/month |
|
|
128
|
-
| Dev, Full | 6 req/month | 10–20 req/min |
|
|
137
|
+
The skills shell out to `npx canicode …` for analyze / gotcha-survey when the canicode MCP server is not installed — both paths produce the same JSON shape. The Figma MCP server is still required for the apply step (Step 4 in `/canicode-roundtrip`); see the prereq note above.
|
|
129
138
|
|
|
130
|
-
|
|
139
|
+
Flags: `--global` installs into `~/.claude/skills/` instead. `--no-skills` skips skill install (token only). `--force` overwrites existing skill files without prompting. Run `canicode docs setup` for the full setup guide.
|
|
131
140
|
|
|
132
141
|
</details>
|
|
133
142
|
|
|
134
143
|
<details>
|
|
135
|
-
<summary><strong>MCP Server</strong>
|
|
144
|
+
<summary><strong>MCP Server</strong> — install details</summary>
|
|
136
145
|
|
|
137
146
|
```bash
|
|
138
|
-
claude mcp add canicode -- npx
|
|
139
|
-
claude mcp add -s project -t http figma https://mcp.figma.com/mcp
|
|
147
|
+
claude mcp add canicode -- npx --yes --package=canicode canicode-mcp
|
|
140
148
|
```
|
|
141
149
|
|
|
142
150
|
Then ask: *"Analyze this Figma design: https://www.figma.com/design/..."*
|
|
143
151
|
|
|
144
|
-
canicode's rule engine analyzes the design data — the AI assistant just orchestrates the calls.
|
|
152
|
+
canicode's rule engine analyzes the design data — the AI assistant just orchestrates the calls. The MCP server reads `FIGMA_TOKEN` from `~/.canicode/config.json` (set via `canicode init --token …`) or from the host's environment, so passing `-e FIGMA_TOKEN=…` to `claude mcp add` is **not** required and the current parser rejects it anyway (#364).
|
|
145
153
|
|
|
146
|
-
|
|
147
|
-
```bash
|
|
148
|
-
claude mcp add canicode -e FIGMA_TOKEN=figd_xxxxxxxxxxxxx -- npx -y -p canicode canicode-mcp
|
|
149
|
-
```
|
|
154
|
+
If you genuinely need a per-server token without using `canicode init`, export it on the calling shell instead: `export FIGMA_TOKEN=figd_xxxxxxxxxxxxx`.
|
|
150
155
|
|
|
151
156
|
For Cursor / Claude Desktop config, see [`docs/CUSTOMIZATION.md`](docs/CUSTOMIZATION.md).
|
|
152
157
|
|
|
153
158
|
</details>
|
|
154
159
|
|
|
155
|
-
|
|
156
160
|
<details>
|
|
157
|
-
<summary><strong>
|
|
161
|
+
<summary><strong>CLI</strong> — install details</summary>
|
|
158
162
|
|
|
159
163
|
```bash
|
|
160
|
-
npx canicode
|
|
164
|
+
npx canicode analyze "https://www.figma.com/design/ABC123/MyDesign?node-id=1-234"
|
|
161
165
|
```
|
|
162
166
|
|
|
163
|
-
|
|
167
|
+
Setup: `npx canicode init --token figd_xxxxxxxxxxxxx` saves the token (and installs the Claude Code skills as a bonus — pass `--no-skills` to skip).
|
|
164
168
|
|
|
165
|
-
|
|
166
|
-
- **canicode-gotchas** — standalone gotcha survey (use `/canicode-gotchas <figma-url>`)
|
|
167
|
-
- **canicode-roundtrip** — full analyze → gotcha → apply roundtrip (use `/canicode-roundtrip <figma-url>`)
|
|
169
|
+
**Figma API Rate Limits** — Rate limits depend on **where the file lives**, not just your plan.
|
|
168
170
|
|
|
169
|
-
|
|
171
|
+
| Seat | File in Starter plan | File in Pro/Org/Enterprise |
|
|
172
|
+
|------|---------------------|---------------------------|
|
|
173
|
+
| View, Collab | 6 req/month | 6 req/month |
|
|
174
|
+
| Dev, Full | 6 req/month | 10–20 req/min |
|
|
170
175
|
|
|
171
|
-
|
|
176
|
+
Hitting 429 errors? Make sure the file is in a paid workspace. Or save a fixture once and analyze locally. [Full details](https://developers.figma.com/docs/rest-api/rate-limits/)
|
|
172
177
|
|
|
173
178
|
</details>
|
|
174
179
|
|
package/dist/cli/index.js
CHANGED
|
@@ -9,7 +9,7 @@ import cac from 'cac';
|
|
|
9
9
|
import { randomUUID } from 'crypto';
|
|
10
10
|
import { homedir } from 'os';
|
|
11
11
|
import { z } from 'zod';
|
|
12
|
-
import {
|
|
12
|
+
import { readFile, writeFile } from 'fs/promises';
|
|
13
13
|
import { createInterface } from 'readline/promises';
|
|
14
14
|
import { pathToFileURL, fileURLToPath } from 'url';
|
|
15
15
|
|
|
@@ -3191,6 +3191,7 @@ var inconsistentNamingConventionCheck = (node, context) => {
|
|
|
3191
3191
|
if (nodeConvention && nodeConvention !== dominantConvention && maxCount >= 2) {
|
|
3192
3192
|
if (isCompatible(nodeConvention, dominantConvention, node.name)) return null;
|
|
3193
3193
|
const suggested = convertName(node.name, dominantConvention);
|
|
3194
|
+
if (suggested === node.name) return null;
|
|
3194
3195
|
return {
|
|
3195
3196
|
ruleId: inconsistentNamingConventionDef.id,
|
|
3196
3197
|
nodeId: node.id,
|
|
@@ -3409,6 +3410,14 @@ defineRule({
|
|
|
3409
3410
|
definition: missingPrototypeDef,
|
|
3410
3411
|
check: missingPrototypeCheck
|
|
3411
3412
|
});
|
|
3413
|
+
var AcknowledgmentSchema = z.object({
|
|
3414
|
+
nodeId: z.string(),
|
|
3415
|
+
ruleId: z.string()
|
|
3416
|
+
});
|
|
3417
|
+
var AcknowledgmentListSchema = z.array(AcknowledgmentSchema);
|
|
3418
|
+
function normalizeNodeId(id) {
|
|
3419
|
+
return id.replace(/-/g, ":");
|
|
3420
|
+
}
|
|
3412
3421
|
|
|
3413
3422
|
// src/core/engine/rule-engine.ts
|
|
3414
3423
|
function calculateMaxDepth(node, currentDepth = 0) {
|
|
@@ -3459,6 +3468,7 @@ var RuleEngine = class {
|
|
|
3459
3468
|
targetNodeId;
|
|
3460
3469
|
excludeNamePattern;
|
|
3461
3470
|
excludeNodeTypes;
|
|
3471
|
+
acknowledgments;
|
|
3462
3472
|
constructor(options = {}) {
|
|
3463
3473
|
this.configs = options.configs ?? RULE_CONFIGS;
|
|
3464
3474
|
this.enabledRuleIds = options.enabledRules ? new Set(options.enabledRules) : null;
|
|
@@ -3466,6 +3476,11 @@ var RuleEngine = class {
|
|
|
3466
3476
|
this.targetNodeId = options.targetNodeId;
|
|
3467
3477
|
this.excludeNamePattern = options.excludeNodeNames && options.excludeNodeNames.length > 0 ? new RegExp(`\\b(${options.excludeNodeNames.join("|")})\\b`, "i") : null;
|
|
3468
3478
|
this.excludeNodeTypes = options.excludeNodeTypes && options.excludeNodeTypes.length > 0 ? new Set(options.excludeNodeTypes) : null;
|
|
3479
|
+
this.acknowledgments = new Set(
|
|
3480
|
+
(options.acknowledgments ?? []).map(
|
|
3481
|
+
(a) => `${normalizeNodeId(a.nodeId)}::${a.ruleId}`
|
|
3482
|
+
)
|
|
3483
|
+
);
|
|
3469
3484
|
}
|
|
3470
3485
|
/**
|
|
3471
3486
|
* Analyze a Figma file and return issues
|
|
@@ -3500,6 +3515,14 @@ var RuleEngine = class {
|
|
|
3500
3515
|
void 0,
|
|
3501
3516
|
void 0
|
|
3502
3517
|
);
|
|
3518
|
+
if (this.acknowledgments.size > 0) {
|
|
3519
|
+
for (const issue of issues) {
|
|
3520
|
+
const key = `${normalizeNodeId(issue.violation.nodeId)}::${issue.violation.ruleId}`;
|
|
3521
|
+
if (this.acknowledgments.has(key)) {
|
|
3522
|
+
issue.acknowledged = true;
|
|
3523
|
+
}
|
|
3524
|
+
}
|
|
3525
|
+
}
|
|
3503
3526
|
return {
|
|
3504
3527
|
file,
|
|
3505
3528
|
issues,
|
|
@@ -3980,8 +4003,6 @@ function resolveTargetProperty(ruleId, subType) {
|
|
|
3980
4003
|
if (subType === "horizontal") return "layoutSizingHorizontal";
|
|
3981
4004
|
return ["layoutSizingHorizontal", "layoutSizingVertical"];
|
|
3982
4005
|
case "missing-size-constraint":
|
|
3983
|
-
if (subType === "wrap") return "minWidth";
|
|
3984
|
-
if (subType === "max-width") return "maxWidth";
|
|
3985
4006
|
return ["minWidth", "maxWidth"];
|
|
3986
4007
|
case "irregular-spacing":
|
|
3987
4008
|
if (subType === "gap") return "itemSpacing";
|
|
@@ -4025,7 +4046,7 @@ function computeApplyContext(violation, instanceContext) {
|
|
|
4025
4046
|
}
|
|
4026
4047
|
|
|
4027
4048
|
// package.json
|
|
4028
|
-
var version2 = "0.10.
|
|
4049
|
+
var version2 = "0.10.4";
|
|
4029
4050
|
|
|
4030
4051
|
// src/core/engine/scoring.ts
|
|
4031
4052
|
function computeTotalScorePerCategory(configs) {
|
|
@@ -4081,7 +4102,8 @@ function calculateScores(result, configs) {
|
|
|
4081
4102
|
uniqueRulesPerCategory.get(category).add(ruleId);
|
|
4082
4103
|
ruleScorePerCategory.get(category).set(ruleId, Math.abs(issue.config.score));
|
|
4083
4104
|
const ruleCountMap = ruleIssueCountPerCategory.get(category);
|
|
4084
|
-
|
|
4105
|
+
const weight = issue.acknowledged === true ? 0.5 : 1;
|
|
4106
|
+
ruleCountMap.set(ruleId, (ruleCountMap.get(ruleId) ?? 0) + weight);
|
|
4085
4107
|
}
|
|
4086
4108
|
for (const category of CATEGORIES) {
|
|
4087
4109
|
const ruleCountMap = ruleIssueCountPerCategory.get(category);
|
|
@@ -4128,7 +4150,8 @@ function calculateScores(result, configs) {
|
|
|
4128
4150
|
risk: 0,
|
|
4129
4151
|
missingInfo: 0,
|
|
4130
4152
|
suggestion: 0,
|
|
4131
|
-
nodeCount
|
|
4153
|
+
nodeCount,
|
|
4154
|
+
acknowledgedCount: 0
|
|
4132
4155
|
};
|
|
4133
4156
|
for (const issue of result.issues) {
|
|
4134
4157
|
switch (issue.config.severity) {
|
|
@@ -4145,6 +4168,7 @@ function calculateScores(result, configs) {
|
|
|
4145
4168
|
summary.suggestion++;
|
|
4146
4169
|
break;
|
|
4147
4170
|
}
|
|
4171
|
+
if (issue.acknowledged === true) summary.acknowledgedCount++;
|
|
4148
4172
|
}
|
|
4149
4173
|
return {
|
|
4150
4174
|
overall: {
|
|
@@ -4195,7 +4219,14 @@ function formatScoreSummary(report) {
|
|
|
4195
4219
|
lines.push(` Risk: ${report.summary.risk}`);
|
|
4196
4220
|
lines.push(` Missing Info: ${report.summary.missingInfo}`);
|
|
4197
4221
|
lines.push(` Suggestion: ${report.summary.suggestion}`);
|
|
4198
|
-
|
|
4222
|
+
if (report.summary.acknowledgedCount > 0) {
|
|
4223
|
+
const unaddressed = report.summary.totalIssues - report.summary.acknowledgedCount;
|
|
4224
|
+
lines.push(
|
|
4225
|
+
` Total: ${report.summary.totalIssues} (${report.summary.acknowledgedCount} acknowledged via canicode annotations / ${unaddressed} unaddressed)`
|
|
4226
|
+
);
|
|
4227
|
+
} else {
|
|
4228
|
+
lines.push(` Total: ${report.summary.totalIssues}`);
|
|
4229
|
+
}
|
|
4199
4230
|
return lines.join("\n");
|
|
4200
4231
|
}
|
|
4201
4232
|
function buildResultJson(fileName, result, scores, options) {
|
|
@@ -4219,17 +4250,20 @@ function buildResultJson(fileName, result, scores, options) {
|
|
|
4219
4250
|
...applyContext.annotationProperties !== void 0 ? { annotationProperties: applyContext.annotationProperties } : {},
|
|
4220
4251
|
...suggestedName !== void 0 ? { suggestedName } : {},
|
|
4221
4252
|
isInstanceChild: applyContext.isInstanceChild,
|
|
4222
|
-
...applyContext.sourceChildId !== void 0 ? { sourceChildId: applyContext.sourceChildId } : {}
|
|
4253
|
+
...applyContext.sourceChildId !== void 0 ? { sourceChildId: applyContext.sourceChildId } : {},
|
|
4254
|
+
...issue.acknowledged === true ? { acknowledged: true } : {}
|
|
4223
4255
|
};
|
|
4224
4256
|
});
|
|
4225
4257
|
const json = {
|
|
4226
4258
|
version: version2,
|
|
4227
4259
|
analyzedAt: result.analyzedAt,
|
|
4228
4260
|
...options?.fileKey && { fileKey: options.fileKey },
|
|
4261
|
+
...options?.designKey && { designKey: options.designKey },
|
|
4229
4262
|
fileName,
|
|
4230
4263
|
nodeCount: result.nodeCount,
|
|
4231
4264
|
maxDepth: result.maxDepth,
|
|
4232
4265
|
issueCount: result.issues.length,
|
|
4266
|
+
acknowledgedCount: scores.summary.acknowledgedCount,
|
|
4233
4267
|
isReadyForCodeGen: isReadyForCodeGen(scores.overall.grade),
|
|
4234
4268
|
blockingIssueCount: scores.summary.blocking,
|
|
4235
4269
|
scores: {
|
|
@@ -4245,6 +4279,18 @@ function buildResultJson(fileName, result, scores, options) {
|
|
|
4245
4279
|
}
|
|
4246
4280
|
return json;
|
|
4247
4281
|
}
|
|
4282
|
+
function isFigmaUrl2(input) {
|
|
4283
|
+
return input.includes("figma.com/");
|
|
4284
|
+
}
|
|
4285
|
+
z.string();
|
|
4286
|
+
function computeDesignKey(input) {
|
|
4287
|
+
if (isFigmaUrl2(input)) {
|
|
4288
|
+
const { fileKey, nodeId } = parseFigmaUrl(input);
|
|
4289
|
+
if (!nodeId) return fileKey;
|
|
4290
|
+
return `${fileKey}#${nodeId.replace(/-/g, ":")}`;
|
|
4291
|
+
}
|
|
4292
|
+
return resolve(input);
|
|
4293
|
+
}
|
|
4248
4294
|
var VALID_RULE_IDS = new Set(Object.keys(RULE_CONFIGS));
|
|
4249
4295
|
var RuleOverrideSchema = z.object({
|
|
4250
4296
|
score: z.number().int().max(0).optional(),
|
|
@@ -5478,10 +5524,11 @@ var AnalyzeOptionsSchema = z.object({
|
|
|
5478
5524
|
screenshot: z.boolean().optional(),
|
|
5479
5525
|
config: z.string().optional(),
|
|
5480
5526
|
noOpen: z.boolean().optional(),
|
|
5481
|
-
json: z.boolean().optional()
|
|
5527
|
+
json: z.boolean().optional(),
|
|
5528
|
+
acknowledgments: z.string().optional()
|
|
5482
5529
|
});
|
|
5483
5530
|
function registerAnalyze(cli2) {
|
|
5484
|
-
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("--api", "Load via Figma REST API (requires FIGMA_TOKEN)").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)").example(" canicode analyze https://www.figma.com/design/ABC123/MyDesign").example(" canicode analyze https://www.figma.com/design/ABC123/MyDesign --api --token YOUR_TOKEN").example(" canicode analyze ./fixtures/my-design --output report.html").example(" canicode analyze ./fixtures/my-design --config ./my-config.json").action(async (input, rawOptions) => {
|
|
5531
|
+
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("--api", "Load via Figma REST API (requires FIGMA_TOKEN)").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) Path to a JSON file containing [{ nodeId, ruleId }] pairs harvested from canicode-authored Figma annotations. Matching issues are flagged acknowledged and contribute half weight to density.").example(" canicode analyze https://www.figma.com/design/ABC123/MyDesign").example(" canicode analyze https://www.figma.com/design/ABC123/MyDesign --api --token YOUR_TOKEN").example(" canicode analyze ./fixtures/my-design --output report.html").example(" canicode analyze ./fixtures/my-design --config ./my-config.json").action(async (input, rawOptions) => {
|
|
5485
5532
|
const parseResult = AnalyzeOptionsSchema.safeParse(rawOptions);
|
|
5486
5533
|
if (!parseResult.success) {
|
|
5487
5534
|
const msg = parseResult.error.issues.map((i) => `--${i.path.join(".")}: ${i.message}`).join("\n");
|
|
@@ -5550,17 +5597,31 @@ Analyzing: ${file.name}`);
|
|
|
5550
5597
|
excludeNodeTypes = configFile.excludeNodeTypes;
|
|
5551
5598
|
log(`Config loaded: ${options.config}`);
|
|
5552
5599
|
}
|
|
5600
|
+
let acknowledgments;
|
|
5601
|
+
if (options.acknowledgments) {
|
|
5602
|
+
const ackPath = resolve(options.acknowledgments);
|
|
5603
|
+
const raw = await readFile(ackPath, "utf-8");
|
|
5604
|
+
const parsed = AcknowledgmentListSchema.safeParse(JSON.parse(raw));
|
|
5605
|
+
if (!parsed.success) {
|
|
5606
|
+
throw new Error(
|
|
5607
|
+
`Invalid --acknowledgments file at ${ackPath}: ${parsed.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`).join("; ")}`
|
|
5608
|
+
);
|
|
5609
|
+
}
|
|
5610
|
+
acknowledgments = parsed.data;
|
|
5611
|
+
log(`Acknowledgments loaded: ${acknowledgments.length} entries from ${ackPath}`);
|
|
5612
|
+
}
|
|
5553
5613
|
const analyzeOptions = {
|
|
5554
5614
|
configs,
|
|
5555
5615
|
...effectiveNodeId && { targetNodeId: effectiveNodeId },
|
|
5556
5616
|
...excludeNodeNames && { excludeNodeNames },
|
|
5557
|
-
...excludeNodeTypes && { excludeNodeTypes }
|
|
5617
|
+
...excludeNodeTypes && { excludeNodeTypes },
|
|
5618
|
+
...acknowledgments && { acknowledgments }
|
|
5558
5619
|
};
|
|
5559
5620
|
const result = analyzeFile(file, analyzeOptions);
|
|
5560
5621
|
log(`Nodes: ${result.nodeCount} (max depth: ${result.maxDepth})`);
|
|
5561
5622
|
const scores = calculateScores(result, configs);
|
|
5562
5623
|
if (options.json) {
|
|
5563
|
-
console.log(JSON.stringify(buildResultJson(file.name, result, scores, { fileKey: file.fileKey }), null, 2));
|
|
5624
|
+
console.log(JSON.stringify(buildResultJson(file.name, result, scores, { fileKey: file.fileKey, designKey: computeDesignKey(input) }), null, 2));
|
|
5564
5625
|
if (scores.overall.grade === "F") {
|
|
5565
5626
|
process.exitCode = 1;
|
|
5566
5627
|
}
|
|
@@ -5731,9 +5792,72 @@ var GOTCHA_QUESTIONS = {
|
|
|
5731
5792
|
}
|
|
5732
5793
|
};
|
|
5733
5794
|
|
|
5795
|
+
// src/core/gotcha/group-and-batch-questions.ts
|
|
5796
|
+
var BATCHABLE_RULE_IDS = [
|
|
5797
|
+
"missing-size-constraint",
|
|
5798
|
+
"irregular-spacing",
|
|
5799
|
+
"no-auto-layout",
|
|
5800
|
+
"fixed-size-in-auto-layout"
|
|
5801
|
+
];
|
|
5802
|
+
var BATCHABLE_SET = new Set(BATCHABLE_RULE_IDS);
|
|
5803
|
+
var NO_SOURCE_SENTINEL = "_no-source";
|
|
5804
|
+
function groupAndBatchSurveyQuestions(questions) {
|
|
5805
|
+
if (questions.length === 0) {
|
|
5806
|
+
return { groups: [] };
|
|
5807
|
+
}
|
|
5808
|
+
const sorted = [...questions].sort(compareQuestions);
|
|
5809
|
+
const groups = [];
|
|
5810
|
+
let currentGroup = null;
|
|
5811
|
+
let lastGroupKey = null;
|
|
5812
|
+
for (const question of sorted) {
|
|
5813
|
+
const groupKey = sourceComponentKey(question);
|
|
5814
|
+
if (currentGroup === null || groupKey !== lastGroupKey) {
|
|
5815
|
+
currentGroup = {
|
|
5816
|
+
instanceContext: question.instanceContext ?? null,
|
|
5817
|
+
batches: []
|
|
5818
|
+
};
|
|
5819
|
+
groups.push(currentGroup);
|
|
5820
|
+
lastGroupKey = groupKey;
|
|
5821
|
+
}
|
|
5822
|
+
pushIntoBatch(currentGroup, question);
|
|
5823
|
+
}
|
|
5824
|
+
return { groups };
|
|
5825
|
+
}
|
|
5826
|
+
function compareQuestions(a, b) {
|
|
5827
|
+
const aKey = sourceComponentKey(a);
|
|
5828
|
+
const bKey = sourceComponentKey(b);
|
|
5829
|
+
if (aKey !== bKey) {
|
|
5830
|
+
if (aKey === NO_SOURCE_SENTINEL) return 1;
|
|
5831
|
+
if (bKey === NO_SOURCE_SENTINEL) return -1;
|
|
5832
|
+
return aKey.localeCompare(bKey);
|
|
5833
|
+
}
|
|
5834
|
+
if (a.ruleId !== b.ruleId) return a.ruleId.localeCompare(b.ruleId);
|
|
5835
|
+
if (a.nodeName !== b.nodeName) return a.nodeName.localeCompare(b.nodeName);
|
|
5836
|
+
return a.nodeId.localeCompare(b.nodeId);
|
|
5837
|
+
}
|
|
5838
|
+
function sourceComponentKey(question) {
|
|
5839
|
+
return question.instanceContext?.sourceComponentId ?? NO_SOURCE_SENTINEL;
|
|
5840
|
+
}
|
|
5841
|
+
function pushIntoBatch(group, question) {
|
|
5842
|
+
const sceneWeight = Math.max(question.replicas ?? 1, 1);
|
|
5843
|
+
const isBatchable = BATCHABLE_SET.has(question.ruleId);
|
|
5844
|
+
const last = group.batches.at(-1);
|
|
5845
|
+
if (last !== void 0 && last.ruleId === question.ruleId && isBatchable && last.batchable) {
|
|
5846
|
+
last.questions.push(question);
|
|
5847
|
+
last.totalScenes += sceneWeight;
|
|
5848
|
+
return;
|
|
5849
|
+
}
|
|
5850
|
+
group.batches.push({
|
|
5851
|
+
ruleId: question.ruleId,
|
|
5852
|
+
batchable: isBatchable,
|
|
5853
|
+
questions: [question],
|
|
5854
|
+
totalScenes: sceneWeight
|
|
5855
|
+
});
|
|
5856
|
+
}
|
|
5857
|
+
|
|
5734
5858
|
// src/core/gotcha/survey-generator.ts
|
|
5735
5859
|
var NODE_PATH_SEPARATOR = " > ";
|
|
5736
|
-
function generateGotchaSurvey(result, scores) {
|
|
5860
|
+
function generateGotchaSurvey(result, scores, options = {}) {
|
|
5737
5861
|
const grade = scores.overall.grade;
|
|
5738
5862
|
const relevantIssues = result.issues.filter(
|
|
5739
5863
|
(issue) => issue.config.severity === "blocking" || issue.config.severity === "risk"
|
|
@@ -5742,16 +5866,23 @@ function generateGotchaSurvey(result, scores) {
|
|
|
5742
5866
|
const sorted = stableSortBySeverity(deduped);
|
|
5743
5867
|
const mapped = sorted.map((issue) => mapToQuestion(issue, result.file)).filter((q) => q !== null);
|
|
5744
5868
|
const questions = deduplicateBySourceComponent(mapped);
|
|
5869
|
+
const groupedQuestions = groupAndBatchSurveyQuestions(questions);
|
|
5745
5870
|
return {
|
|
5746
5871
|
designGrade: grade,
|
|
5747
5872
|
isReadyForCodeGen: isReadyForCodeGen(grade),
|
|
5748
|
-
questions
|
|
5873
|
+
questions,
|
|
5874
|
+
groupedQuestions,
|
|
5875
|
+
designKey: options.designKey ?? ""
|
|
5749
5876
|
};
|
|
5750
5877
|
}
|
|
5751
5878
|
function deduplicateSiblingIssues(issues) {
|
|
5752
5879
|
const seen = /* @__PURE__ */ new Set();
|
|
5753
5880
|
const result = [];
|
|
5754
5881
|
for (const issue of issues) {
|
|
5882
|
+
if (isInstanceChildNodeId(issue.violation.nodeId)) {
|
|
5883
|
+
result.push(issue);
|
|
5884
|
+
continue;
|
|
5885
|
+
}
|
|
5755
5886
|
const parentPath = getParentPath(issue.violation.nodePath);
|
|
5756
5887
|
const key = `${parentPath}||${issue.violation.ruleId}`;
|
|
5757
5888
|
if (!seen.has(key)) {
|
|
@@ -5900,7 +6031,7 @@ async function runGotchaSurvey(input, options) {
|
|
|
5900
6031
|
...effectiveNodeId ? { targetNodeId: effectiveNodeId } : {}
|
|
5901
6032
|
});
|
|
5902
6033
|
const scores = calculateScores(result, configs);
|
|
5903
|
-
return generateGotchaSurvey(result, scores);
|
|
6034
|
+
return generateGotchaSurvey(result, scores, { designKey: computeDesignKey(input) });
|
|
5904
6035
|
}
|
|
5905
6036
|
function formatHumanSummary(survey) {
|
|
5906
6037
|
const lines = [
|
|
@@ -5968,6 +6099,198 @@ ${msg}`);
|
|
|
5968
6099
|
}
|
|
5969
6100
|
});
|
|
5970
6101
|
}
|
|
6102
|
+
z.enum([
|
|
6103
|
+
"missing",
|
|
6104
|
+
"valid",
|
|
6105
|
+
"missing-heading",
|
|
6106
|
+
"clobbered"
|
|
6107
|
+
]);
|
|
6108
|
+
var COLLECTED_GOTCHAS_HEADING = "# Collected Gotchas";
|
|
6109
|
+
var SECTION_HEADER_RE = /^## #(\d{3,}) — /gm;
|
|
6110
|
+
function detectGotchasFileState(content) {
|
|
6111
|
+
if (content === null) return "missing";
|
|
6112
|
+
if (!hasFrontmatter(content)) return "clobbered";
|
|
6113
|
+
if (!hasCollectedGotchasHeading(content)) return "missing-heading";
|
|
6114
|
+
return "valid";
|
|
6115
|
+
}
|
|
6116
|
+
function hasFrontmatter(content) {
|
|
6117
|
+
if (!content.startsWith("---\n") && !content.startsWith("---\r\n")) {
|
|
6118
|
+
return false;
|
|
6119
|
+
}
|
|
6120
|
+
const rest = content.slice(4);
|
|
6121
|
+
return /^---\s*$/m.test(rest);
|
|
6122
|
+
}
|
|
6123
|
+
function hasCollectedGotchasHeading(content) {
|
|
6124
|
+
return /^# Collected Gotchas\s*$/m.test(content);
|
|
6125
|
+
}
|
|
6126
|
+
function findOrAppendSection(content, designKey) {
|
|
6127
|
+
const regionStart = locateCollectedGotchasRegion(content);
|
|
6128
|
+
const region = content.slice(regionStart);
|
|
6129
|
+
const sections = parseSections(region);
|
|
6130
|
+
let maxNumber = 0;
|
|
6131
|
+
for (const section of sections) {
|
|
6132
|
+
if (section.numericValue > maxNumber) maxNumber = section.numericValue;
|
|
6133
|
+
if (sectionMatchesDesignKey(section.body, designKey)) {
|
|
6134
|
+
return {
|
|
6135
|
+
action: "replace",
|
|
6136
|
+
sectionNumber: section.padded,
|
|
6137
|
+
replaceRange: [
|
|
6138
|
+
regionStart + section.start,
|
|
6139
|
+
regionStart + section.end
|
|
6140
|
+
]
|
|
6141
|
+
};
|
|
6142
|
+
}
|
|
6143
|
+
}
|
|
6144
|
+
const next = maxNumber + 1;
|
|
6145
|
+
return {
|
|
6146
|
+
action: "append",
|
|
6147
|
+
sectionNumber: padNumber(next)
|
|
6148
|
+
};
|
|
6149
|
+
}
|
|
6150
|
+
function parseSections(region) {
|
|
6151
|
+
const sections = [];
|
|
6152
|
+
const matches = [...region.matchAll(SECTION_HEADER_RE)];
|
|
6153
|
+
for (let i = 0; i < matches.length; i += 1) {
|
|
6154
|
+
const match = matches[i];
|
|
6155
|
+
const start = match.index;
|
|
6156
|
+
const next = matches[i + 1];
|
|
6157
|
+
const end = next?.index ?? region.length;
|
|
6158
|
+
const captured = match[1];
|
|
6159
|
+
sections.push({
|
|
6160
|
+
padded: captured,
|
|
6161
|
+
numericValue: parseInt(captured, 10),
|
|
6162
|
+
start,
|
|
6163
|
+
end,
|
|
6164
|
+
body: region.slice(start, end)
|
|
6165
|
+
});
|
|
6166
|
+
}
|
|
6167
|
+
return sections;
|
|
6168
|
+
}
|
|
6169
|
+
function locateCollectedGotchasRegion(content) {
|
|
6170
|
+
const re = /^# Collected Gotchas\s*$/m;
|
|
6171
|
+
const match = re.exec(content);
|
|
6172
|
+
if (!match) return content.length;
|
|
6173
|
+
return match.index + match[0].length;
|
|
6174
|
+
}
|
|
6175
|
+
function sectionMatchesDesignKey(body, designKey) {
|
|
6176
|
+
const re = /^-\s+\*\*Design key\*\*:\s+(.+?)\s*$/m;
|
|
6177
|
+
const m = body.match(re);
|
|
6178
|
+
if (!m) return false;
|
|
6179
|
+
return m[1].includes(designKey);
|
|
6180
|
+
}
|
|
6181
|
+
function padNumber(n) {
|
|
6182
|
+
return n.toString().padStart(3, "0");
|
|
6183
|
+
}
|
|
6184
|
+
function renderUpsertedFile(args) {
|
|
6185
|
+
const { currentContent, designKey, sectionMarkdown } = args;
|
|
6186
|
+
const state = detectGotchasFileState(currentContent);
|
|
6187
|
+
if (state === "missing" || state === "clobbered") {
|
|
6188
|
+
return { state, newContent: null };
|
|
6189
|
+
}
|
|
6190
|
+
let working = currentContent;
|
|
6191
|
+
if (state === "missing-heading") {
|
|
6192
|
+
const sep = working.endsWith("\n\n") ? "" : working.endsWith("\n") ? "\n" : "\n\n";
|
|
6193
|
+
working = `${working}${sep}${COLLECTED_GOTCHAS_HEADING}
|
|
6194
|
+
`;
|
|
6195
|
+
}
|
|
6196
|
+
const plan = findOrAppendSection(working, designKey);
|
|
6197
|
+
const sectionWithNumber = sectionMarkdown.includes("{{SECTION_NUMBER}}") ? sectionMarkdown.replace(/\{\{SECTION_NUMBER\}\}/g, plan.sectionNumber) : sectionMarkdown;
|
|
6198
|
+
let newContent;
|
|
6199
|
+
if (plan.action === "replace") {
|
|
6200
|
+
const [start, end] = plan.replaceRange;
|
|
6201
|
+
const before = working.slice(0, start);
|
|
6202
|
+
const after = working.slice(end);
|
|
6203
|
+
newContent = `${before}${ensureTrailingNewline(sectionWithNumber)}${after}`;
|
|
6204
|
+
} else {
|
|
6205
|
+
const trimmed = working.replace(/\s+$/, "");
|
|
6206
|
+
newContent = `${trimmed}
|
|
6207
|
+
|
|
6208
|
+
${ensureTrailingNewline(sectionWithNumber)}`;
|
|
6209
|
+
}
|
|
6210
|
+
return { state, newContent, plan };
|
|
6211
|
+
}
|
|
6212
|
+
function ensureTrailingNewline(s) {
|
|
6213
|
+
return s.endsWith("\n") ? s : `${s}
|
|
6214
|
+
`;
|
|
6215
|
+
}
|
|
6216
|
+
|
|
6217
|
+
// src/cli/commands/upsert-gotcha-section.ts
|
|
6218
|
+
var UpsertOptionsSchema = z.object({
|
|
6219
|
+
file: z.string().min(1, "--file is required"),
|
|
6220
|
+
designKey: z.string().min(1, "--design-key is required"),
|
|
6221
|
+
section: z.string().min(1, "--section is required (use '-' to read stdin)")
|
|
6222
|
+
});
|
|
6223
|
+
var USER_MESSAGES = {
|
|
6224
|
+
missing: "Gotchas SKILL.md not found at the given path. Run `canicode init` first, then re-invoke this skill.",
|
|
6225
|
+
clobbered: "Your gotchas SKILL.md is missing the canicode YAML frontmatter (pre-#340 single-design clobber). Run `canicode init --force` to restore the workflow, then re-run this survey."
|
|
6226
|
+
};
|
|
6227
|
+
async function readStdin() {
|
|
6228
|
+
const chunks = [];
|
|
6229
|
+
for await (const chunk of process.stdin) {
|
|
6230
|
+
chunks.push(chunk);
|
|
6231
|
+
}
|
|
6232
|
+
return Buffer.concat(chunks).toString("utf-8");
|
|
6233
|
+
}
|
|
6234
|
+
async function runUpsertGotchaSection(options) {
|
|
6235
|
+
const sectionMarkdown = options.section === "-" ? await readStdin() : options.section;
|
|
6236
|
+
const currentContent = existsSync(options.file) ? readFileSync(options.file, "utf-8") : null;
|
|
6237
|
+
const { state, newContent, plan } = renderUpsertedFile({
|
|
6238
|
+
currentContent,
|
|
6239
|
+
designKey: options.designKey,
|
|
6240
|
+
sectionMarkdown
|
|
6241
|
+
});
|
|
6242
|
+
if (newContent === null) {
|
|
6243
|
+
return {
|
|
6244
|
+
state,
|
|
6245
|
+
action: null,
|
|
6246
|
+
sectionNumber: null,
|
|
6247
|
+
wrote: false,
|
|
6248
|
+
userMessage: USER_MESSAGES[state] ?? null
|
|
6249
|
+
};
|
|
6250
|
+
}
|
|
6251
|
+
writeFileSync(options.file, newContent, "utf-8");
|
|
6252
|
+
return {
|
|
6253
|
+
state,
|
|
6254
|
+
action: plan?.action ?? null,
|
|
6255
|
+
sectionNumber: plan?.sectionNumber ?? null,
|
|
6256
|
+
wrote: true,
|
|
6257
|
+
userMessage: null
|
|
6258
|
+
};
|
|
6259
|
+
}
|
|
6260
|
+
function registerUpsertGotchaSection(cli2) {
|
|
6261
|
+
cli2.command(
|
|
6262
|
+
"upsert-gotcha-section",
|
|
6263
|
+
"Upsert a per-design section into the canicode-gotchas SKILL.md (used by the canicode-gotchas skill \u2014 Step 4b)"
|
|
6264
|
+
).option("--file <path>", "Path to the canicode-gotchas SKILL.md").option(
|
|
6265
|
+
"--design-key <key>",
|
|
6266
|
+
"Canonical design key from gotcha-survey's response"
|
|
6267
|
+
).option(
|
|
6268
|
+
"--section <markdown>",
|
|
6269
|
+
"Already-rendered per-design section markdown. Use '-' to read from stdin."
|
|
6270
|
+
).action(async (rawOptions) => {
|
|
6271
|
+
const parseResult = UpsertOptionsSchema.safeParse(rawOptions);
|
|
6272
|
+
if (!parseResult.success) {
|
|
6273
|
+
const msg = parseResult.error.issues.map((i) => `--${i.path.join(".")}: ${i.message}`).join("\n");
|
|
6274
|
+
console.error(`
|
|
6275
|
+
Invalid options:
|
|
6276
|
+
${msg}`);
|
|
6277
|
+
process.exit(1);
|
|
6278
|
+
}
|
|
6279
|
+
try {
|
|
6280
|
+
const result = await runUpsertGotchaSection(parseResult.data);
|
|
6281
|
+
console.log(JSON.stringify(result, null, 2));
|
|
6282
|
+
if (!result.wrote && result.userMessage) {
|
|
6283
|
+
process.exitCode = 2;
|
|
6284
|
+
}
|
|
6285
|
+
} catch (error) {
|
|
6286
|
+
console.error(
|
|
6287
|
+
"\nError:",
|
|
6288
|
+
error instanceof Error ? error.message : String(error)
|
|
6289
|
+
);
|
|
6290
|
+
process.exitCode = 1;
|
|
6291
|
+
}
|
|
6292
|
+
});
|
|
6293
|
+
}
|
|
5971
6294
|
function registerDesignTree(cli2) {
|
|
5972
6295
|
cli2.command(
|
|
5973
6296
|
"design-tree <input>",
|
|
@@ -7688,9 +8011,9 @@ function registerCalibrateEvaluate(cli2) {
|
|
|
7688
8011
|
if (!existsSync(conversionPath)) {
|
|
7689
8012
|
throw new Error(`Conversion file not found: ${conversionPath}`);
|
|
7690
8013
|
}
|
|
7691
|
-
const { readFile:
|
|
7692
|
-
const analysisData = JSON.parse(await
|
|
7693
|
-
const conversionData = JSON.parse(await
|
|
8014
|
+
const { readFile: readFile4 } = await import('fs/promises');
|
|
8015
|
+
const analysisData = JSON.parse(await readFile4(analysisPath, "utf-8"));
|
|
8016
|
+
const conversionData = JSON.parse(await readFile4(conversionPath, "utf-8"));
|
|
7694
8017
|
let fixtureName;
|
|
7695
8018
|
if (options.runDir) {
|
|
7696
8019
|
const dirName = resolve(options.runDir).split(/[/\\]/).pop() ?? "";
|
|
@@ -9191,8 +9514,8 @@ ${msg}`);
|
|
|
9191
9514
|
const resp = await fetch(url);
|
|
9192
9515
|
if (resp.ok) {
|
|
9193
9516
|
const buffer = Buffer.from(await resp.arrayBuffer());
|
|
9194
|
-
const { writeFile:
|
|
9195
|
-
await
|
|
9517
|
+
const { writeFile: writeFileSync7 } = await import('fs/promises');
|
|
9518
|
+
await writeFileSync7(resolve(fixtureDir, "screenshot.png"), buffer);
|
|
9196
9519
|
console.log(` screenshot.png: saved`);
|
|
9197
9520
|
}
|
|
9198
9521
|
}
|
|
@@ -9542,6 +9865,7 @@ process.on("beforeExit", () => {
|
|
|
9542
9865
|
});
|
|
9543
9866
|
registerAnalyze(cli);
|
|
9544
9867
|
registerGotchaSurvey(cli);
|
|
9868
|
+
registerUpsertGotchaSection(cli);
|
|
9545
9869
|
registerDesignTree(cli);
|
|
9546
9870
|
registerVisualCompare(cli);
|
|
9547
9871
|
registerInit(cli);
|
|
@@ -9599,7 +9923,7 @@ cli.help((sections) => {
|
|
|
9599
9923
|
title: "\nInstallation",
|
|
9600
9924
|
body: [
|
|
9601
9925
|
` CLI: npm install -g canicode`,
|
|
9602
|
-
` MCP: claude mcp add canicode -- npx
|
|
9926
|
+
` MCP: claude mcp add canicode -- npx --yes --package=canicode canicode-mcp`,
|
|
9603
9927
|
` Skills: github.com/let-sunny/canicode`
|
|
9604
9928
|
].join("\n")
|
|
9605
9929
|
}
|