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/README.md +36 -31
- package/dist/cli/index.js +401 -22
- package/dist/cli/index.js.map +1 -1
- package/dist/index.d.ts +261 -3
- package/dist/index.js +102 -10
- package/dist/index.js.map +1 -1
- package/dist/mcp/server.js +200 -24
- package/dist/mcp/server.js.map +1 -1
- package/package.json +3 -2
- package/skills/canicode-gotchas/SKILL.md +66 -28
- package/skills/canicode-roundtrip/SKILL.md +290 -86
- package/skills/canicode-roundtrip/helpers.js +270 -10
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,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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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:
|
|
7637
|
-
const analysisData = JSON.parse(await
|
|
7638
|
-
const conversionData = JSON.parse(await
|
|
8014
|
+
const { readFile: readFile4 } = await import('fs/promises');
|
|
8015
|
+
const analysisData = JSON.parse(await readFile4(analysisPath, "utf-8"));
|
|
8016
|
+
const conversionData = JSON.parse(await readFile4(conversionPath, "utf-8"));
|
|
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:
|
|
9140
|
-
await
|
|
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
|
|
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
|
}
|