canicode 0.10.3 → 0.10.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli/index.js 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,
@@ -3286,12 +3287,22 @@ function hasStateInComponentMaster(node, context, statePattern) {
3286
3287
  if (!master) return false;
3287
3288
  return hasStateInVariantProps(master, statePattern);
3288
3289
  }
3290
+ var VARIANT_POSITION_NAME_RE = /^[\w ]+=[^,]+(,\s*[\w ]+=[^,]+)*$/;
3291
+ function hasUsablePropDefs(propDefs) {
3292
+ return propDefs != null && typeof propDefs === "object";
3293
+ }
3289
3294
  function canDetermineVariants(node, context) {
3290
- if (node.type === "COMPONENT") return true;
3291
- if (node.componentPropertyDefinitions !== void 0) return true;
3295
+ if (hasUsablePropDefs(node.componentPropertyDefinitions)) return true;
3296
+ if (node.type === "COMPONENT") {
3297
+ return !VARIANT_POSITION_NAME_RE.test(node.name);
3298
+ }
3292
3299
  if (node.componentId !== void 0) {
3293
3300
  const defs = context.file.componentDefinitions;
3294
- if (defs && defs[node.componentId] !== void 0) return true;
3301
+ const master = defs?.[node.componentId];
3302
+ if (master) {
3303
+ if (hasUsablePropDefs(master.componentPropertyDefinitions)) return true;
3304
+ return !VARIANT_POSITION_NAME_RE.test(master.name);
3305
+ }
3295
3306
  }
3296
3307
  return false;
3297
3308
  }
@@ -3409,6 +3420,14 @@ defineRule({
3409
3420
  definition: missingPrototypeDef,
3410
3421
  check: missingPrototypeCheck
3411
3422
  });
3423
+ var AcknowledgmentSchema = z.object({
3424
+ nodeId: z.string(),
3425
+ ruleId: z.string()
3426
+ });
3427
+ var AcknowledgmentListSchema = z.array(AcknowledgmentSchema);
3428
+ function normalizeNodeId(id) {
3429
+ return id.replace(/-/g, ":");
3430
+ }
3412
3431
 
3413
3432
  // src/core/engine/rule-engine.ts
3414
3433
  function calculateMaxDepth(node, currentDepth = 0) {
@@ -3459,6 +3478,7 @@ var RuleEngine = class {
3459
3478
  targetNodeId;
3460
3479
  excludeNamePattern;
3461
3480
  excludeNodeTypes;
3481
+ acknowledgments;
3462
3482
  constructor(options = {}) {
3463
3483
  this.configs = options.configs ?? RULE_CONFIGS;
3464
3484
  this.enabledRuleIds = options.enabledRules ? new Set(options.enabledRules) : null;
@@ -3466,6 +3486,11 @@ var RuleEngine = class {
3466
3486
  this.targetNodeId = options.targetNodeId;
3467
3487
  this.excludeNamePattern = options.excludeNodeNames && options.excludeNodeNames.length > 0 ? new RegExp(`\\b(${options.excludeNodeNames.join("|")})\\b`, "i") : null;
3468
3488
  this.excludeNodeTypes = options.excludeNodeTypes && options.excludeNodeTypes.length > 0 ? new Set(options.excludeNodeTypes) : null;
3489
+ this.acknowledgments = new Set(
3490
+ (options.acknowledgments ?? []).map(
3491
+ (a) => `${normalizeNodeId(a.nodeId)}::${a.ruleId}`
3492
+ )
3493
+ );
3469
3494
  }
3470
3495
  /**
3471
3496
  * Analyze a Figma file and return issues
@@ -3500,6 +3525,14 @@ var RuleEngine = class {
3500
3525
  void 0,
3501
3526
  void 0
3502
3527
  );
3528
+ if (this.acknowledgments.size > 0) {
3529
+ for (const issue of issues) {
3530
+ const key = `${normalizeNodeId(issue.violation.nodeId)}::${issue.violation.ruleId}`;
3531
+ if (this.acknowledgments.has(key)) {
3532
+ issue.acknowledged = true;
3533
+ }
3534
+ }
3535
+ }
3503
3536
  return {
3504
3537
  file,
3505
3538
  issues,
@@ -3980,8 +4013,6 @@ function resolveTargetProperty(ruleId, subType) {
3980
4013
  if (subType === "horizontal") return "layoutSizingHorizontal";
3981
4014
  return ["layoutSizingHorizontal", "layoutSizingVertical"];
3982
4015
  case "missing-size-constraint":
3983
- if (subType === "wrap") return "minWidth";
3984
- if (subType === "max-width") return "maxWidth";
3985
4016
  return ["minWidth", "maxWidth"];
3986
4017
  case "irregular-spacing":
3987
4018
  if (subType === "gap") return "itemSpacing";
@@ -4025,7 +4056,7 @@ function computeApplyContext(violation, instanceContext) {
4025
4056
  }
4026
4057
 
4027
4058
  // package.json
4028
- var version2 = "0.10.3";
4059
+ var version2 = "0.10.5";
4029
4060
 
4030
4061
  // src/core/engine/scoring.ts
4031
4062
  function computeTotalScorePerCategory(configs) {
@@ -4081,7 +4112,8 @@ function calculateScores(result, configs) {
4081
4112
  uniqueRulesPerCategory.get(category).add(ruleId);
4082
4113
  ruleScorePerCategory.get(category).set(ruleId, Math.abs(issue.config.score));
4083
4114
  const ruleCountMap = ruleIssueCountPerCategory.get(category);
4084
- ruleCountMap.set(ruleId, (ruleCountMap.get(ruleId) ?? 0) + 1);
4115
+ const weight = issue.acknowledged === true ? 0.5 : 1;
4116
+ ruleCountMap.set(ruleId, (ruleCountMap.get(ruleId) ?? 0) + weight);
4085
4117
  }
4086
4118
  for (const category of CATEGORIES) {
4087
4119
  const ruleCountMap = ruleIssueCountPerCategory.get(category);
@@ -4128,7 +4160,8 @@ function calculateScores(result, configs) {
4128
4160
  risk: 0,
4129
4161
  missingInfo: 0,
4130
4162
  suggestion: 0,
4131
- nodeCount
4163
+ nodeCount,
4164
+ acknowledgedCount: 0
4132
4165
  };
4133
4166
  for (const issue of result.issues) {
4134
4167
  switch (issue.config.severity) {
@@ -4145,6 +4178,7 @@ function calculateScores(result, configs) {
4145
4178
  summary.suggestion++;
4146
4179
  break;
4147
4180
  }
4181
+ if (issue.acknowledged === true) summary.acknowledgedCount++;
4148
4182
  }
4149
4183
  return {
4150
4184
  overall: {
@@ -4195,7 +4229,14 @@ function formatScoreSummary(report) {
4195
4229
  lines.push(` Risk: ${report.summary.risk}`);
4196
4230
  lines.push(` Missing Info: ${report.summary.missingInfo}`);
4197
4231
  lines.push(` Suggestion: ${report.summary.suggestion}`);
4198
- lines.push(` Total: ${report.summary.totalIssues}`);
4232
+ if (report.summary.acknowledgedCount > 0) {
4233
+ const unaddressed = report.summary.totalIssues - report.summary.acknowledgedCount;
4234
+ lines.push(
4235
+ ` Total: ${report.summary.totalIssues} (${report.summary.acknowledgedCount} acknowledged via canicode annotations / ${unaddressed} unaddressed)`
4236
+ );
4237
+ } else {
4238
+ lines.push(` Total: ${report.summary.totalIssues}`);
4239
+ }
4199
4240
  return lines.join("\n");
4200
4241
  }
4201
4242
  function buildResultJson(fileName, result, scores, options) {
@@ -4219,17 +4260,20 @@ function buildResultJson(fileName, result, scores, options) {
4219
4260
  ...applyContext.annotationProperties !== void 0 ? { annotationProperties: applyContext.annotationProperties } : {},
4220
4261
  ...suggestedName !== void 0 ? { suggestedName } : {},
4221
4262
  isInstanceChild: applyContext.isInstanceChild,
4222
- ...applyContext.sourceChildId !== void 0 ? { sourceChildId: applyContext.sourceChildId } : {}
4263
+ ...applyContext.sourceChildId !== void 0 ? { sourceChildId: applyContext.sourceChildId } : {},
4264
+ ...issue.acknowledged === true ? { acknowledged: true } : {}
4223
4265
  };
4224
4266
  });
4225
4267
  const json = {
4226
4268
  version: version2,
4227
4269
  analyzedAt: result.analyzedAt,
4228
4270
  ...options?.fileKey && { fileKey: options.fileKey },
4271
+ ...options?.designKey && { designKey: options.designKey },
4229
4272
  fileName,
4230
4273
  nodeCount: result.nodeCount,
4231
4274
  maxDepth: result.maxDepth,
4232
4275
  issueCount: result.issues.length,
4276
+ acknowledgedCount: scores.summary.acknowledgedCount,
4233
4277
  isReadyForCodeGen: isReadyForCodeGen(scores.overall.grade),
4234
4278
  blockingIssueCount: scores.summary.blocking,
4235
4279
  scores: {
@@ -4245,6 +4289,18 @@ function buildResultJson(fileName, result, scores, options) {
4245
4289
  }
4246
4290
  return json;
4247
4291
  }
4292
+ function isFigmaUrl2(input) {
4293
+ return input.includes("figma.com/");
4294
+ }
4295
+ z.string();
4296
+ function computeDesignKey(input) {
4297
+ if (isFigmaUrl2(input)) {
4298
+ const { fileKey, nodeId } = parseFigmaUrl(input);
4299
+ if (!nodeId) return fileKey;
4300
+ return `${fileKey}#${nodeId.replace(/-/g, ":")}`;
4301
+ }
4302
+ return resolve(input);
4303
+ }
4248
4304
  var VALID_RULE_IDS = new Set(Object.keys(RULE_CONFIGS));
4249
4305
  var RuleOverrideSchema = z.object({
4250
4306
  score: z.number().int().max(0).optional(),
@@ -5478,10 +5534,11 @@ var AnalyzeOptionsSchema = z.object({
5478
5534
  screenshot: z.boolean().optional(),
5479
5535
  config: z.string().optional(),
5480
5536
  noOpen: z.boolean().optional(),
5481
- json: z.boolean().optional()
5537
+ json: z.boolean().optional(),
5538
+ acknowledgments: z.string().optional()
5482
5539
  });
5483
5540
  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) => {
5541
+ 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
5542
  const parseResult = AnalyzeOptionsSchema.safeParse(rawOptions);
5486
5543
  if (!parseResult.success) {
5487
5544
  const msg = parseResult.error.issues.map((i) => `--${i.path.join(".")}: ${i.message}`).join("\n");
@@ -5550,17 +5607,31 @@ Analyzing: ${file.name}`);
5550
5607
  excludeNodeTypes = configFile.excludeNodeTypes;
5551
5608
  log(`Config loaded: ${options.config}`);
5552
5609
  }
5610
+ let acknowledgments;
5611
+ if (options.acknowledgments) {
5612
+ const ackPath = resolve(options.acknowledgments);
5613
+ const raw = await readFile(ackPath, "utf-8");
5614
+ const parsed = AcknowledgmentListSchema.safeParse(JSON.parse(raw));
5615
+ if (!parsed.success) {
5616
+ throw new Error(
5617
+ `Invalid --acknowledgments file at ${ackPath}: ${parsed.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`).join("; ")}`
5618
+ );
5619
+ }
5620
+ acknowledgments = parsed.data;
5621
+ log(`Acknowledgments loaded: ${acknowledgments.length} entries from ${ackPath}`);
5622
+ }
5553
5623
  const analyzeOptions = {
5554
5624
  configs,
5555
5625
  ...effectiveNodeId && { targetNodeId: effectiveNodeId },
5556
5626
  ...excludeNodeNames && { excludeNodeNames },
5557
- ...excludeNodeTypes && { excludeNodeTypes }
5627
+ ...excludeNodeTypes && { excludeNodeTypes },
5628
+ ...acknowledgments && { acknowledgments }
5558
5629
  };
5559
5630
  const result = analyzeFile(file, analyzeOptions);
5560
5631
  log(`Nodes: ${result.nodeCount} (max depth: ${result.maxDepth})`);
5561
5632
  const scores = calculateScores(result, configs);
5562
5633
  if (options.json) {
5563
- console.log(JSON.stringify(buildResultJson(file.name, result, scores, { fileKey: file.fileKey }), null, 2));
5634
+ console.log(JSON.stringify(buildResultJson(file.name, result, scores, { fileKey: file.fileKey, designKey: computeDesignKey(input) }), null, 2));
5564
5635
  if (scores.overall.grade === "F") {
5565
5636
  process.exitCode = 1;
5566
5637
  }
@@ -5731,9 +5802,72 @@ var GOTCHA_QUESTIONS = {
5731
5802
  }
5732
5803
  };
5733
5804
 
5805
+ // src/core/gotcha/group-and-batch-questions.ts
5806
+ var BATCHABLE_RULE_IDS = [
5807
+ "missing-size-constraint",
5808
+ "irregular-spacing",
5809
+ "no-auto-layout",
5810
+ "fixed-size-in-auto-layout"
5811
+ ];
5812
+ var BATCHABLE_SET = new Set(BATCHABLE_RULE_IDS);
5813
+ var NO_SOURCE_SENTINEL = "_no-source";
5814
+ function groupAndBatchSurveyQuestions(questions) {
5815
+ if (questions.length === 0) {
5816
+ return { groups: [] };
5817
+ }
5818
+ const sorted = [...questions].sort(compareQuestions);
5819
+ const groups = [];
5820
+ let currentGroup = null;
5821
+ let lastGroupKey = null;
5822
+ for (const question of sorted) {
5823
+ const groupKey = sourceComponentKey(question);
5824
+ if (currentGroup === null || groupKey !== lastGroupKey) {
5825
+ currentGroup = {
5826
+ instanceContext: question.instanceContext ?? null,
5827
+ batches: []
5828
+ };
5829
+ groups.push(currentGroup);
5830
+ lastGroupKey = groupKey;
5831
+ }
5832
+ pushIntoBatch(currentGroup, question);
5833
+ }
5834
+ return { groups };
5835
+ }
5836
+ function compareQuestions(a, b) {
5837
+ const aKey = sourceComponentKey(a);
5838
+ const bKey = sourceComponentKey(b);
5839
+ if (aKey !== bKey) {
5840
+ if (aKey === NO_SOURCE_SENTINEL) return 1;
5841
+ if (bKey === NO_SOURCE_SENTINEL) return -1;
5842
+ return aKey.localeCompare(bKey);
5843
+ }
5844
+ if (a.ruleId !== b.ruleId) return a.ruleId.localeCompare(b.ruleId);
5845
+ if (a.nodeName !== b.nodeName) return a.nodeName.localeCompare(b.nodeName);
5846
+ return a.nodeId.localeCompare(b.nodeId);
5847
+ }
5848
+ function sourceComponentKey(question) {
5849
+ return question.instanceContext?.sourceComponentId ?? NO_SOURCE_SENTINEL;
5850
+ }
5851
+ function pushIntoBatch(group, question) {
5852
+ const sceneWeight = Math.max(question.replicas ?? 1, 1);
5853
+ const isBatchable = BATCHABLE_SET.has(question.ruleId);
5854
+ const last = group.batches.at(-1);
5855
+ if (last !== void 0 && last.ruleId === question.ruleId && isBatchable && last.batchable) {
5856
+ last.questions.push(question);
5857
+ last.totalScenes += sceneWeight;
5858
+ return;
5859
+ }
5860
+ group.batches.push({
5861
+ ruleId: question.ruleId,
5862
+ batchable: isBatchable,
5863
+ questions: [question],
5864
+ totalScenes: sceneWeight
5865
+ });
5866
+ }
5867
+
5734
5868
  // src/core/gotcha/survey-generator.ts
5735
5869
  var NODE_PATH_SEPARATOR = " > ";
5736
- function generateGotchaSurvey(result, scores) {
5870
+ function generateGotchaSurvey(result, scores, options = {}) {
5737
5871
  const grade = scores.overall.grade;
5738
5872
  const relevantIssues = result.issues.filter(
5739
5873
  (issue) => issue.config.severity === "blocking" || issue.config.severity === "risk"
@@ -5742,16 +5876,23 @@ function generateGotchaSurvey(result, scores) {
5742
5876
  const sorted = stableSortBySeverity(deduped);
5743
5877
  const mapped = sorted.map((issue) => mapToQuestion(issue, result.file)).filter((q) => q !== null);
5744
5878
  const questions = deduplicateBySourceComponent(mapped);
5879
+ const groupedQuestions = groupAndBatchSurveyQuestions(questions);
5745
5880
  return {
5746
5881
  designGrade: grade,
5747
5882
  isReadyForCodeGen: isReadyForCodeGen(grade),
5748
- questions
5883
+ questions,
5884
+ groupedQuestions,
5885
+ designKey: options.designKey ?? ""
5749
5886
  };
5750
5887
  }
5751
5888
  function deduplicateSiblingIssues(issues) {
5752
5889
  const seen = /* @__PURE__ */ new Set();
5753
5890
  const result = [];
5754
5891
  for (const issue of issues) {
5892
+ if (isInstanceChildNodeId(issue.violation.nodeId)) {
5893
+ result.push(issue);
5894
+ continue;
5895
+ }
5755
5896
  const parentPath = getParentPath(issue.violation.nodePath);
5756
5897
  const key = `${parentPath}||${issue.violation.ruleId}`;
5757
5898
  if (!seen.has(key)) {
@@ -5900,7 +6041,7 @@ async function runGotchaSurvey(input, options) {
5900
6041
  ...effectiveNodeId ? { targetNodeId: effectiveNodeId } : {}
5901
6042
  });
5902
6043
  const scores = calculateScores(result, configs);
5903
- return generateGotchaSurvey(result, scores);
6044
+ return generateGotchaSurvey(result, scores, { designKey: computeDesignKey(input) });
5904
6045
  }
5905
6046
  function formatHumanSummary(survey) {
5906
6047
  const lines = [
@@ -5968,6 +6109,198 @@ ${msg}`);
5968
6109
  }
5969
6110
  });
5970
6111
  }
6112
+ z.enum([
6113
+ "missing",
6114
+ "valid",
6115
+ "missing-heading",
6116
+ "clobbered"
6117
+ ]);
6118
+ var COLLECTED_GOTCHAS_HEADING = "# Collected Gotchas";
6119
+ var SECTION_HEADER_RE = /^## #(\d{3,}) — /gm;
6120
+ function detectGotchasFileState(content) {
6121
+ if (content === null) return "missing";
6122
+ if (!hasFrontmatter(content)) return "clobbered";
6123
+ if (!hasCollectedGotchasHeading(content)) return "missing-heading";
6124
+ return "valid";
6125
+ }
6126
+ function hasFrontmatter(content) {
6127
+ if (!content.startsWith("---\n") && !content.startsWith("---\r\n")) {
6128
+ return false;
6129
+ }
6130
+ const rest = content.slice(4);
6131
+ return /^---\s*$/m.test(rest);
6132
+ }
6133
+ function hasCollectedGotchasHeading(content) {
6134
+ return /^# Collected Gotchas\s*$/m.test(content);
6135
+ }
6136
+ function findOrAppendSection(content, designKey) {
6137
+ const regionStart = locateCollectedGotchasRegion(content);
6138
+ const region = content.slice(regionStart);
6139
+ const sections = parseSections(region);
6140
+ let maxNumber = 0;
6141
+ for (const section of sections) {
6142
+ if (section.numericValue > maxNumber) maxNumber = section.numericValue;
6143
+ if (sectionMatchesDesignKey(section.body, designKey)) {
6144
+ return {
6145
+ action: "replace",
6146
+ sectionNumber: section.padded,
6147
+ replaceRange: [
6148
+ regionStart + section.start,
6149
+ regionStart + section.end
6150
+ ]
6151
+ };
6152
+ }
6153
+ }
6154
+ const next = maxNumber + 1;
6155
+ return {
6156
+ action: "append",
6157
+ sectionNumber: padNumber(next)
6158
+ };
6159
+ }
6160
+ function parseSections(region) {
6161
+ const sections = [];
6162
+ const matches = [...region.matchAll(SECTION_HEADER_RE)];
6163
+ for (let i = 0; i < matches.length; i += 1) {
6164
+ const match = matches[i];
6165
+ const start = match.index;
6166
+ const next = matches[i + 1];
6167
+ const end = next?.index ?? region.length;
6168
+ const captured = match[1];
6169
+ sections.push({
6170
+ padded: captured,
6171
+ numericValue: parseInt(captured, 10),
6172
+ start,
6173
+ end,
6174
+ body: region.slice(start, end)
6175
+ });
6176
+ }
6177
+ return sections;
6178
+ }
6179
+ function locateCollectedGotchasRegion(content) {
6180
+ const re = /^# Collected Gotchas\s*$/m;
6181
+ const match = re.exec(content);
6182
+ if (!match) return content.length;
6183
+ return match.index + match[0].length;
6184
+ }
6185
+ function sectionMatchesDesignKey(body, designKey) {
6186
+ const re = /^-\s+\*\*Design key\*\*:\s+(.+?)\s*$/m;
6187
+ const m = body.match(re);
6188
+ if (!m) return false;
6189
+ return m[1].includes(designKey);
6190
+ }
6191
+ function padNumber(n) {
6192
+ return n.toString().padStart(3, "0");
6193
+ }
6194
+ function renderUpsertedFile(args) {
6195
+ const { currentContent, designKey, sectionMarkdown } = args;
6196
+ const state = detectGotchasFileState(currentContent);
6197
+ if (state === "missing" || state === "clobbered") {
6198
+ return { state, newContent: null };
6199
+ }
6200
+ let working = currentContent;
6201
+ if (state === "missing-heading") {
6202
+ const sep = working.endsWith("\n\n") ? "" : working.endsWith("\n") ? "\n" : "\n\n";
6203
+ working = `${working}${sep}${COLLECTED_GOTCHAS_HEADING}
6204
+ `;
6205
+ }
6206
+ const plan = findOrAppendSection(working, designKey);
6207
+ const sectionWithNumber = sectionMarkdown.includes("{{SECTION_NUMBER}}") ? sectionMarkdown.replace(/\{\{SECTION_NUMBER\}\}/g, plan.sectionNumber) : sectionMarkdown;
6208
+ let newContent;
6209
+ if (plan.action === "replace") {
6210
+ const [start, end] = plan.replaceRange;
6211
+ const before = working.slice(0, start);
6212
+ const after = working.slice(end);
6213
+ newContent = `${before}${ensureTrailingNewline(sectionWithNumber)}${after}`;
6214
+ } else {
6215
+ const trimmed = working.replace(/\s+$/, "");
6216
+ newContent = `${trimmed}
6217
+
6218
+ ${ensureTrailingNewline(sectionWithNumber)}`;
6219
+ }
6220
+ return { state, newContent, plan };
6221
+ }
6222
+ function ensureTrailingNewline(s) {
6223
+ return s.endsWith("\n") ? s : `${s}
6224
+ `;
6225
+ }
6226
+
6227
+ // src/cli/commands/upsert-gotcha-section.ts
6228
+ var UpsertOptionsSchema = z.object({
6229
+ file: z.string().min(1, "--file is required"),
6230
+ designKey: z.string().min(1, "--design-key is required"),
6231
+ section: z.string().min(1, "--section is required (use '-' to read stdin)")
6232
+ });
6233
+ var USER_MESSAGES = {
6234
+ missing: "Gotchas SKILL.md not found at the given path. Run `canicode init` first, then re-invoke this skill.",
6235
+ 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."
6236
+ };
6237
+ async function readStdin() {
6238
+ const chunks = [];
6239
+ for await (const chunk of process.stdin) {
6240
+ chunks.push(chunk);
6241
+ }
6242
+ return Buffer.concat(chunks).toString("utf-8");
6243
+ }
6244
+ async function runUpsertGotchaSection(options) {
6245
+ const sectionMarkdown = options.section === "-" ? await readStdin() : options.section;
6246
+ const currentContent = existsSync(options.file) ? readFileSync(options.file, "utf-8") : null;
6247
+ const { state, newContent, plan } = renderUpsertedFile({
6248
+ currentContent,
6249
+ designKey: options.designKey,
6250
+ sectionMarkdown
6251
+ });
6252
+ if (newContent === null) {
6253
+ return {
6254
+ state,
6255
+ action: null,
6256
+ sectionNumber: null,
6257
+ wrote: false,
6258
+ userMessage: USER_MESSAGES[state] ?? null
6259
+ };
6260
+ }
6261
+ writeFileSync(options.file, newContent, "utf-8");
6262
+ return {
6263
+ state,
6264
+ action: plan?.action ?? null,
6265
+ sectionNumber: plan?.sectionNumber ?? null,
6266
+ wrote: true,
6267
+ userMessage: null
6268
+ };
6269
+ }
6270
+ function registerUpsertGotchaSection(cli2) {
6271
+ cli2.command(
6272
+ "upsert-gotcha-section",
6273
+ "Upsert a per-design section into the canicode-gotchas SKILL.md (used by the canicode-gotchas skill \u2014 Step 4b)"
6274
+ ).option("--file <path>", "Path to the canicode-gotchas SKILL.md").option(
6275
+ "--design-key <key>",
6276
+ "Canonical design key from gotcha-survey's response"
6277
+ ).option(
6278
+ "--section <markdown>",
6279
+ "Already-rendered per-design section markdown. Use '-' to read from stdin."
6280
+ ).action(async (rawOptions) => {
6281
+ const parseResult = UpsertOptionsSchema.safeParse(rawOptions);
6282
+ if (!parseResult.success) {
6283
+ const msg = parseResult.error.issues.map((i) => `--${i.path.join(".")}: ${i.message}`).join("\n");
6284
+ console.error(`
6285
+ Invalid options:
6286
+ ${msg}`);
6287
+ process.exit(1);
6288
+ }
6289
+ try {
6290
+ const result = await runUpsertGotchaSection(parseResult.data);
6291
+ console.log(JSON.stringify(result, null, 2));
6292
+ if (!result.wrote && result.userMessage) {
6293
+ process.exitCode = 2;
6294
+ }
6295
+ } catch (error) {
6296
+ console.error(
6297
+ "\nError:",
6298
+ error instanceof Error ? error.message : String(error)
6299
+ );
6300
+ process.exitCode = 1;
6301
+ }
6302
+ });
6303
+ }
5971
6304
  function registerDesignTree(cli2) {
5972
6305
  cli2.command(
5973
6306
  "design-tree <input>",
@@ -7688,9 +8021,9 @@ function registerCalibrateEvaluate(cli2) {
7688
8021
  if (!existsSync(conversionPath)) {
7689
8022
  throw new Error(`Conversion file not found: ${conversionPath}`);
7690
8023
  }
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"));
8024
+ const { readFile: readFile4 } = await import('fs/promises');
8025
+ const analysisData = JSON.parse(await readFile4(analysisPath, "utf-8"));
8026
+ const conversionData = JSON.parse(await readFile4(conversionPath, "utf-8"));
7694
8027
  let fixtureName;
7695
8028
  if (options.runDir) {
7696
8029
  const dirName = resolve(options.runDir).split(/[/\\]/).pop() ?? "";
@@ -9191,8 +9524,8 @@ ${msg}`);
9191
9524
  const resp = await fetch(url);
9192
9525
  if (resp.ok) {
9193
9526
  const buffer = Buffer.from(await resp.arrayBuffer());
9194
- const { writeFile: writeFileSync6 } = await import('fs/promises');
9195
- await writeFileSync6(resolve(fixtureDir, "screenshot.png"), buffer);
9527
+ const { writeFile: writeFileSync7 } = await import('fs/promises');
9528
+ await writeFileSync7(resolve(fixtureDir, "screenshot.png"), buffer);
9196
9529
  console.log(` screenshot.png: saved`);
9197
9530
  }
9198
9531
  }
@@ -9542,6 +9875,7 @@ process.on("beforeExit", () => {
9542
9875
  });
9543
9876
  registerAnalyze(cli);
9544
9877
  registerGotchaSurvey(cli);
9878
+ registerUpsertGotchaSection(cli);
9545
9879
  registerDesignTree(cli);
9546
9880
  registerVisualCompare(cli);
9547
9881
  registerInit(cli);
@@ -9599,7 +9933,7 @@ cli.help((sections) => {
9599
9933
  title: "\nInstallation",
9600
9934
  body: [
9601
9935
  ` CLI: npm install -g canicode`,
9602
- ` MCP: claude mcp add canicode -- npx -y -p canicode canicode-mcp`,
9936
+ ` MCP: claude mcp add canicode -- npx --yes --package=canicode canicode-mcp`,
9603
9937
  ` Skills: github.com/let-sunny/canicode`
9604
9938
  ].join("\n")
9605
9939
  }