canicode 0.10.2 → 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/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,6 +3287,15 @@ function hasStateInComponentMaster(node, context, statePattern) {
3286
3287
  if (!master) return false;
3287
3288
  return hasStateInVariantProps(master, statePattern);
3288
3289
  }
3290
+ function canDetermineVariants(node, context) {
3291
+ if (node.type === "COMPONENT") return true;
3292
+ if (node.componentPropertyDefinitions !== void 0) return true;
3293
+ if (node.componentId !== void 0) {
3294
+ const defs = context.file.componentDefinitions;
3295
+ if (defs && defs[node.componentId] !== void 0) return true;
3296
+ }
3297
+ return false;
3298
+ }
3289
3299
  var missingInteractionStateDef = {
3290
3300
  id: "missing-interaction-state",
3291
3301
  name: "Missing Interaction State",
@@ -3300,6 +3310,7 @@ var missingInteractionStateCheck = (node, context) => {
3300
3310
  if (!interactiveType) return null;
3301
3311
  const expectedStates = EXPECTED_STATES[interactiveType];
3302
3312
  if (!expectedStates) return null;
3313
+ if (!canDetermineVariants(node, context)) return null;
3303
3314
  const seen = getSeen(context);
3304
3315
  const nodePath = context.path.join(" > ");
3305
3316
  for (const state of expectedStates) {
@@ -3399,6 +3410,14 @@ defineRule({
3399
3410
  definition: missingPrototypeDef,
3400
3411
  check: missingPrototypeCheck
3401
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
+ }
3402
3421
 
3403
3422
  // src/core/engine/rule-engine.ts
3404
3423
  function calculateMaxDepth(node, currentDepth = 0) {
@@ -3449,6 +3468,7 @@ var RuleEngine = class {
3449
3468
  targetNodeId;
3450
3469
  excludeNamePattern;
3451
3470
  excludeNodeTypes;
3471
+ acknowledgments;
3452
3472
  constructor(options = {}) {
3453
3473
  this.configs = options.configs ?? RULE_CONFIGS;
3454
3474
  this.enabledRuleIds = options.enabledRules ? new Set(options.enabledRules) : null;
@@ -3456,6 +3476,11 @@ var RuleEngine = class {
3456
3476
  this.targetNodeId = options.targetNodeId;
3457
3477
  this.excludeNamePattern = options.excludeNodeNames && options.excludeNodeNames.length > 0 ? new RegExp(`\\b(${options.excludeNodeNames.join("|")})\\b`, "i") : null;
3458
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
+ );
3459
3484
  }
3460
3485
  /**
3461
3486
  * Analyze a Figma file and return issues
@@ -3490,6 +3515,14 @@ var RuleEngine = class {
3490
3515
  void 0,
3491
3516
  void 0
3492
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
+ }
3493
3526
  return {
3494
3527
  file,
3495
3528
  issues,
@@ -3970,8 +4003,6 @@ function resolveTargetProperty(ruleId, subType) {
3970
4003
  if (subType === "horizontal") return "layoutSizingHorizontal";
3971
4004
  return ["layoutSizingHorizontal", "layoutSizingVertical"];
3972
4005
  case "missing-size-constraint":
3973
- if (subType === "wrap") return "minWidth";
3974
- if (subType === "max-width") return "maxWidth";
3975
4006
  return ["minWidth", "maxWidth"];
3976
4007
  case "irregular-spacing":
3977
4008
  if (subType === "gap") return "itemSpacing";
@@ -4015,7 +4046,7 @@ function computeApplyContext(violation, instanceContext) {
4015
4046
  }
4016
4047
 
4017
4048
  // package.json
4018
- var version2 = "0.10.2";
4049
+ var version2 = "0.10.4";
4019
4050
 
4020
4051
  // src/core/engine/scoring.ts
4021
4052
  function computeTotalScorePerCategory(configs) {
@@ -4071,7 +4102,8 @@ function calculateScores(result, configs) {
4071
4102
  uniqueRulesPerCategory.get(category).add(ruleId);
4072
4103
  ruleScorePerCategory.get(category).set(ruleId, Math.abs(issue.config.score));
4073
4104
  const ruleCountMap = ruleIssueCountPerCategory.get(category);
4074
- 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);
4075
4107
  }
4076
4108
  for (const category of CATEGORIES) {
4077
4109
  const ruleCountMap = ruleIssueCountPerCategory.get(category);
@@ -4118,7 +4150,8 @@ function calculateScores(result, configs) {
4118
4150
  risk: 0,
4119
4151
  missingInfo: 0,
4120
4152
  suggestion: 0,
4121
- nodeCount
4153
+ nodeCount,
4154
+ acknowledgedCount: 0
4122
4155
  };
4123
4156
  for (const issue of result.issues) {
4124
4157
  switch (issue.config.severity) {
@@ -4135,6 +4168,7 @@ function calculateScores(result, configs) {
4135
4168
  summary.suggestion++;
4136
4169
  break;
4137
4170
  }
4171
+ if (issue.acknowledged === true) summary.acknowledgedCount++;
4138
4172
  }
4139
4173
  return {
4140
4174
  overall: {
@@ -4185,7 +4219,14 @@ function formatScoreSummary(report) {
4185
4219
  lines.push(` Risk: ${report.summary.risk}`);
4186
4220
  lines.push(` Missing Info: ${report.summary.missingInfo}`);
4187
4221
  lines.push(` Suggestion: ${report.summary.suggestion}`);
4188
- 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
+ }
4189
4230
  return lines.join("\n");
4190
4231
  }
4191
4232
  function buildResultJson(fileName, result, scores, options) {
@@ -4209,17 +4250,20 @@ function buildResultJson(fileName, result, scores, options) {
4209
4250
  ...applyContext.annotationProperties !== void 0 ? { annotationProperties: applyContext.annotationProperties } : {},
4210
4251
  ...suggestedName !== void 0 ? { suggestedName } : {},
4211
4252
  isInstanceChild: applyContext.isInstanceChild,
4212
- ...applyContext.sourceChildId !== void 0 ? { sourceChildId: applyContext.sourceChildId } : {}
4253
+ ...applyContext.sourceChildId !== void 0 ? { sourceChildId: applyContext.sourceChildId } : {},
4254
+ ...issue.acknowledged === true ? { acknowledged: true } : {}
4213
4255
  };
4214
4256
  });
4215
4257
  const json = {
4216
4258
  version: version2,
4217
4259
  analyzedAt: result.analyzedAt,
4218
4260
  ...options?.fileKey && { fileKey: options.fileKey },
4261
+ ...options?.designKey && { designKey: options.designKey },
4219
4262
  fileName,
4220
4263
  nodeCount: result.nodeCount,
4221
4264
  maxDepth: result.maxDepth,
4222
4265
  issueCount: result.issues.length,
4266
+ acknowledgedCount: scores.summary.acknowledgedCount,
4223
4267
  isReadyForCodeGen: isReadyForCodeGen(scores.overall.grade),
4224
4268
  blockingIssueCount: scores.summary.blocking,
4225
4269
  scores: {
@@ -4235,6 +4279,18 @@ function buildResultJson(fileName, result, scores, options) {
4235
4279
  }
4236
4280
  return json;
4237
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
+ }
4238
4294
  var VALID_RULE_IDS = new Set(Object.keys(RULE_CONFIGS));
4239
4295
  var RuleOverrideSchema = z.object({
4240
4296
  score: z.number().int().max(0).optional(),
@@ -5468,10 +5524,11 @@ var AnalyzeOptionsSchema = z.object({
5468
5524
  screenshot: z.boolean().optional(),
5469
5525
  config: z.string().optional(),
5470
5526
  noOpen: z.boolean().optional(),
5471
- json: z.boolean().optional()
5527
+ json: z.boolean().optional(),
5528
+ acknowledgments: z.string().optional()
5472
5529
  });
5473
5530
  function registerAnalyze(cli2) {
5474
- 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) => {
5475
5532
  const parseResult = AnalyzeOptionsSchema.safeParse(rawOptions);
5476
5533
  if (!parseResult.success) {
5477
5534
  const msg = parseResult.error.issues.map((i) => `--${i.path.join(".")}: ${i.message}`).join("\n");
@@ -5540,17 +5597,31 @@ Analyzing: ${file.name}`);
5540
5597
  excludeNodeTypes = configFile.excludeNodeTypes;
5541
5598
  log(`Config loaded: ${options.config}`);
5542
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
+ }
5543
5613
  const analyzeOptions = {
5544
5614
  configs,
5545
5615
  ...effectiveNodeId && { targetNodeId: effectiveNodeId },
5546
5616
  ...excludeNodeNames && { excludeNodeNames },
5547
- ...excludeNodeTypes && { excludeNodeTypes }
5617
+ ...excludeNodeTypes && { excludeNodeTypes },
5618
+ ...acknowledgments && { acknowledgments }
5548
5619
  };
5549
5620
  const result = analyzeFile(file, analyzeOptions);
5550
5621
  log(`Nodes: ${result.nodeCount} (max depth: ${result.maxDepth})`);
5551
5622
  const scores = calculateScores(result, configs);
5552
5623
  if (options.json) {
5553
- 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));
5554
5625
  if (scores.overall.grade === "F") {
5555
5626
  process.exitCode = 1;
5556
5627
  }
@@ -5721,26 +5792,97 @@ var GOTCHA_QUESTIONS = {
5721
5792
  }
5722
5793
  };
5723
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
+
5724
5858
  // src/core/gotcha/survey-generator.ts
5725
5859
  var NODE_PATH_SEPARATOR = " > ";
5726
- function generateGotchaSurvey(result, scores) {
5860
+ function generateGotchaSurvey(result, scores, options = {}) {
5727
5861
  const grade = scores.overall.grade;
5728
5862
  const relevantIssues = result.issues.filter(
5729
5863
  (issue) => issue.config.severity === "blocking" || issue.config.severity === "risk"
5730
5864
  );
5731
5865
  const deduped = deduplicateSiblingIssues(relevantIssues);
5732
5866
  const sorted = stableSortBySeverity(deduped);
5733
- const questions = sorted.map((issue) => mapToQuestion(issue, result.file)).filter((q) => q !== null);
5867
+ const mapped = sorted.map((issue) => mapToQuestion(issue, result.file)).filter((q) => q !== null);
5868
+ const questions = deduplicateBySourceComponent(mapped);
5869
+ const groupedQuestions = groupAndBatchSurveyQuestions(questions);
5734
5870
  return {
5735
5871
  designGrade: grade,
5736
5872
  isReadyForCodeGen: isReadyForCodeGen(grade),
5737
- questions
5873
+ questions,
5874
+ groupedQuestions,
5875
+ designKey: options.designKey ?? ""
5738
5876
  };
5739
5877
  }
5740
5878
  function deduplicateSiblingIssues(issues) {
5741
5879
  const seen = /* @__PURE__ */ new Set();
5742
5880
  const result = [];
5743
5881
  for (const issue of issues) {
5882
+ if (isInstanceChildNodeId(issue.violation.nodeId)) {
5883
+ result.push(issue);
5884
+ continue;
5885
+ }
5744
5886
  const parentPath = getParentPath(issue.violation.nodePath);
5745
5887
  const key = `${parentPath}||${issue.violation.ruleId}`;
5746
5888
  if (!seen.has(key)) {
@@ -5800,6 +5942,50 @@ function mapToQuestion(issue, file) {
5800
5942
  ...applyContext.sourceChildId !== void 0 ? { sourceChildId: applyContext.sourceChildId } : {}
5801
5943
  };
5802
5944
  }
5945
+ function deduplicateBySourceComponent(questions) {
5946
+ const groups = /* @__PURE__ */ new Map();
5947
+ const order = [];
5948
+ let uniqueCounter = 0;
5949
+ for (const q of questions) {
5950
+ const ic = q.instanceContext;
5951
+ let key;
5952
+ if (ic && ic.sourceComponentId && ic.sourceNodeId) {
5953
+ key = `${ic.sourceComponentId}::${ic.sourceNodeId}::${q.ruleId}`;
5954
+ } else {
5955
+ key = `__unique__${uniqueCounter++}`;
5956
+ }
5957
+ const bucket = groups.get(key);
5958
+ if (bucket) {
5959
+ bucket.push(q);
5960
+ } else {
5961
+ groups.set(key, [q]);
5962
+ order.push(key);
5963
+ }
5964
+ }
5965
+ return order.map((key) => {
5966
+ const group = groups.get(key);
5967
+ const first = group[0];
5968
+ if (group.length === 1) return first;
5969
+ const otherIds = group.slice(1).map((q) => q.nodeId);
5970
+ const sourceComponentName = first.instanceContext?.sourceComponentName;
5971
+ const template = GOTCHA_QUESTIONS[first.ruleId];
5972
+ const renamed = {
5973
+ ...first,
5974
+ replicas: group.length,
5975
+ replicaNodeIds: otherIds
5976
+ };
5977
+ if (sourceComponentName) {
5978
+ renamed.nodeName = sourceComponentName;
5979
+ if (template) {
5980
+ renamed.question = template.question.replace(
5981
+ "{nodeName}",
5982
+ sourceComponentName
5983
+ );
5984
+ }
5985
+ }
5986
+ return renamed;
5987
+ });
5988
+ }
5803
5989
  function buildInstanceContext(nodeId, file) {
5804
5990
  const parts = parseInstanceChildNodeId(nodeId);
5805
5991
  if (!parts) return null;
@@ -5845,7 +6031,7 @@ async function runGotchaSurvey(input, options) {
5845
6031
  ...effectiveNodeId ? { targetNodeId: effectiveNodeId } : {}
5846
6032
  });
5847
6033
  const scores = calculateScores(result, configs);
5848
- return generateGotchaSurvey(result, scores);
6034
+ return generateGotchaSurvey(result, scores, { designKey: computeDesignKey(input) });
5849
6035
  }
5850
6036
  function formatHumanSummary(survey) {
5851
6037
  const lines = [
@@ -5913,6 +6099,198 @@ ${msg}`);
5913
6099
  }
5914
6100
  });
5915
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
+ }
5916
6294
  function registerDesignTree(cli2) {
5917
6295
  cli2.command(
5918
6296
  "design-tree <input>",
@@ -7633,9 +8011,9 @@ function registerCalibrateEvaluate(cli2) {
7633
8011
  if (!existsSync(conversionPath)) {
7634
8012
  throw new Error(`Conversion file not found: ${conversionPath}`);
7635
8013
  }
7636
- const { readFile: readFile3 } = await import('fs/promises');
7637
- const analysisData = JSON.parse(await readFile3(analysisPath, "utf-8"));
7638
- 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"));
7639
8017
  let fixtureName;
7640
8018
  if (options.runDir) {
7641
8019
  const dirName = resolve(options.runDir).split(/[/\\]/).pop() ?? "";
@@ -9136,8 +9514,8 @@ ${msg}`);
9136
9514
  const resp = await fetch(url);
9137
9515
  if (resp.ok) {
9138
9516
  const buffer = Buffer.from(await resp.arrayBuffer());
9139
- const { writeFile: writeFileSync6 } = await import('fs/promises');
9140
- await writeFileSync6(resolve(fixtureDir, "screenshot.png"), buffer);
9517
+ const { writeFile: writeFileSync7 } = await import('fs/promises');
9518
+ await writeFileSync7(resolve(fixtureDir, "screenshot.png"), buffer);
9141
9519
  console.log(` screenshot.png: saved`);
9142
9520
  }
9143
9521
  }
@@ -9487,6 +9865,7 @@ process.on("beforeExit", () => {
9487
9865
  });
9488
9866
  registerAnalyze(cli);
9489
9867
  registerGotchaSurvey(cli);
9868
+ registerUpsertGotchaSection(cli);
9490
9869
  registerDesignTree(cli);
9491
9870
  registerVisualCompare(cli);
9492
9871
  registerInit(cli);
@@ -9544,7 +9923,7 @@ cli.help((sections) => {
9544
9923
  title: "\nInstallation",
9545
9924
  body: [
9546
9925
  ` CLI: npm install -g canicode`,
9547
- ` MCP: claude mcp add canicode -- npx -y -p canicode canicode-mcp`,
9926
+ ` MCP: claude mcp add canicode -- npx --yes --package=canicode canicode-mcp`,
9548
9927
  ` Skills: github.com/let-sunny/canicode`
9549
9928
  ].join("\n")
9550
9929
  }