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/README.md +35 -30
- package/dist/cli/index.js +358 -24
- package/dist/cli/index.js.map +1 -1
- package/dist/index.d.ts +257 -3
- package/dist/index.js +96 -12
- package/dist/index.js.map +1 -1
- package/dist/mcp/server.js +157 -26
- 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/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,
|
|
@@ -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.
|
|
3291
|
-
if (node.
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
7692
|
-
const analysisData = JSON.parse(await
|
|
7693
|
-
const conversionData = JSON.parse(await
|
|
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:
|
|
9195
|
-
await
|
|
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
|
|
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
|
}
|