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 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 -y -p canicode canicode-mcp
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>CLI</strong></summary>
125
+ <summary><strong>Claude Code Skills</strong> — install details</summary>
114
126
 
115
127
  ```bash
116
- npx canicode analyze "https://www.figma.com/design/ABC123/MyDesign?node-id=1-234"
128
+ npx canicode init --token figd_xxxxxxxxxxxxx
117
129
  ```
118
130
 
119
- Setup: `npx canicode init --token figd_xxxxxxxxxxxxx` — saves the token AND installs the Claude Code skills (see below).
120
-
121
- > **Get your token:** Figma → Settings → Security → Personal access tokens → Generate new token
131
+ Drops three skills into `./.claude/skills/`:
122
132
 
123
- **Figma API Rate Limits** — Rate limits depend on **where the file lives**, not just your plan.
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
- | Seat | File in Starter plan | File in Pro/Org/Enterprise |
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
- 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/)
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> (Claude Code / Cursor / Claude Desktop)</summary>
144
+ <summary><strong>MCP Server</strong> install details</summary>
136
145
 
137
146
  ```bash
138
- claude mcp add canicode -- npx -y -p canicode canicode-mcp
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
- With a Figma API token:
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>Claude Code Skills</strong> (lightweight, no MCP install)</summary>
161
+ <summary><strong>CLI</strong> install details</summary>
158
162
 
159
163
  ```bash
160
- npx canicode init --token figd_xxxxxxxxxxxxx
164
+ npx canicode analyze "https://www.figma.com/design/ABC123/MyDesign?node-id=1-234"
161
165
  ```
162
166
 
163
- Saves your Figma token AND installs three skills into `./.claude/skills/`:
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
- - **canicode**lightweight CLI wrapper (use `/canicode <figma-url>`)
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
- > **Next:** install the Figma MCP server (`claude mcp add -s project -t http figma https://mcp.figma.com/mcp`) and restart Claude Code so both the skills and the MCP tools load. See the **MCP Server** section above for context.
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
- 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.
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 { writeFile, readFile } from 'fs/promises';
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.3";
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
- ruleCountMap.set(ruleId, (ruleCountMap.get(ruleId) ?? 0) + 1);
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
- lines.push(` Total: ${report.summary.totalIssues}`);
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: readFile3 } = await import('fs/promises');
7692
- const analysisData = JSON.parse(await readFile3(analysisPath, "utf-8"));
7693
- const conversionData = JSON.parse(await readFile3(conversionPath, "utf-8"));
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: writeFileSync6 } = await import('fs/promises');
9195
- await writeFileSync6(resolve(fixtureDir, "screenshot.png"), buffer);
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 -y -p canicode canicode-mcp`,
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
  }