canicode 0.10.5 → 0.11.1
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 +23 -4
- package/dist/cli/index.js +1061 -153
- package/dist/cli/index.js.map +1 -1
- package/dist/index.d.ts +273 -35
- package/dist/index.js +262 -71
- package/dist/index.js.map +1 -1
- package/dist/mcp/server.js +251 -80
- package/dist/mcp/server.js.map +1 -1
- package/docs/CUSTOMIZATION.md +62 -3
- package/package.json +1 -1
- package/skills/canicode/SKILL.md +6 -0
- package/skills/canicode-gotchas/SKILL.md +54 -72
- package/skills/canicode-roundtrip/SKILL.md +47 -267
- package/skills/canicode-roundtrip/helpers.js +287 -17
- package/skills/cursor/canicode/SKILL.md +82 -0
- package/skills/cursor/canicode-gotchas/SKILL.md +178 -0
- package/skills/cursor/canicode-roundtrip/SKILL.md +397 -0
- package/skills/cursor/canicode-roundtrip/helpers.js +793 -0
package/dist/mcp/server.js
CHANGED
|
@@ -359,6 +359,41 @@ var RULE_ID_CATEGORY = {
|
|
|
359
359
|
"non-semantic-name": "semantic",
|
|
360
360
|
"inconsistent-naming-convention": "semantic"
|
|
361
361
|
};
|
|
362
|
+
var RULE_PURPOSE = {
|
|
363
|
+
// Pixel Critical
|
|
364
|
+
"no-auto-layout": "violation",
|
|
365
|
+
"absolute-position-in-auto-layout": "violation",
|
|
366
|
+
"non-layout-container": "violation",
|
|
367
|
+
// Responsive Critical
|
|
368
|
+
"fixed-size-in-auto-layout": "violation",
|
|
369
|
+
// #403: missing-size-constraint reframed as info-collection. The rule
|
|
370
|
+
// fires on width chains whose intent is structurally undecidable
|
|
371
|
+
// (FILL container with no chain-bound ancestor; FIXED component or
|
|
372
|
+
// instance root). The gotcha question is the primary signal; the
|
|
373
|
+
// -1 score is just enough to keep the rule visible in
|
|
374
|
+
// diversity scoring without driving grade swings on its own.
|
|
375
|
+
"missing-size-constraint": "info-collection",
|
|
376
|
+
// Code Quality
|
|
377
|
+
"missing-component": "violation",
|
|
378
|
+
"detached-instance": "violation",
|
|
379
|
+
"variant-structure-mismatch": "violation",
|
|
380
|
+
"deep-nesting": "violation",
|
|
381
|
+
// Token Management
|
|
382
|
+
"raw-value": "violation",
|
|
383
|
+
"irregular-spacing": "violation",
|
|
384
|
+
// Interaction — gotcha-primary: Figma cannot encode "what happens on
|
|
385
|
+
// click" or "which states exist" in a way downstream code generation can
|
|
386
|
+
// consume. Rules fire to trigger the annotation, not to flag a violation.
|
|
387
|
+
"missing-interaction-state": "info-collection",
|
|
388
|
+
"missing-prototype": "info-collection",
|
|
389
|
+
// Semantic
|
|
390
|
+
"non-standard-naming": "violation",
|
|
391
|
+
"non-semantic-name": "violation",
|
|
392
|
+
"inconsistent-naming-convention": "violation"
|
|
393
|
+
};
|
|
394
|
+
function getRulePurpose(ruleId) {
|
|
395
|
+
return RULE_PURPOSE[ruleId] ?? "violation";
|
|
396
|
+
}
|
|
362
397
|
var RULE_CONFIGS = {
|
|
363
398
|
// ── Pixel Critical ──
|
|
364
399
|
"no-auto-layout": {
|
|
@@ -386,8 +421,12 @@ var RULE_CONFIGS = {
|
|
|
386
421
|
enabled: true
|
|
387
422
|
},
|
|
388
423
|
"missing-size-constraint": {
|
|
389
|
-
|
|
390
|
-
|
|
424
|
+
// #403: severity downgraded `risk → missing-info` and score from
|
|
425
|
+
// -8 → -1 to match the new info-collection purpose. Keeping the
|
|
426
|
+
// rule enabled (not disabled) so its gotchas still surface in the
|
|
427
|
+
// survey — see RULE_PURPOSE entry above for the full rationale.
|
|
428
|
+
severity: "missing-info",
|
|
429
|
+
score: -1,
|
|
391
430
|
enabled: true
|
|
392
431
|
},
|
|
393
432
|
// ── Code Quality ──
|
|
@@ -434,17 +473,22 @@ var RULE_CONFIGS = {
|
|
|
434
473
|
}
|
|
435
474
|
},
|
|
436
475
|
// ── Interaction ──
|
|
476
|
+
// #406: both rules are `info-collection` — primary output is the gotcha
|
|
477
|
+
// annotation, not the score. Severity is `missing-info` so they surface in
|
|
478
|
+
// the gotcha survey (see `generateGotchaSurvey`) even though the penalty
|
|
479
|
+
// is minimal. Score stays at -1 so re-enabling `missing-prototype` on
|
|
480
|
+
// fixtures that lack `interactionDestinations` (#139) cannot swing grades.
|
|
437
481
|
"missing-interaction-state": {
|
|
438
|
-
severity: "
|
|
482
|
+
severity: "missing-info",
|
|
439
483
|
score: -1,
|
|
440
484
|
// uncalibrated: no metric to validate score (#210), kept at -1 to preserve category visibility
|
|
441
485
|
enabled: true
|
|
442
486
|
},
|
|
443
487
|
"missing-prototype": {
|
|
444
488
|
severity: "missing-info",
|
|
445
|
-
score: -
|
|
446
|
-
|
|
447
|
-
|
|
489
|
+
score: -1,
|
|
490
|
+
// #406: info-collection — annotation is primary output; score kept minimal so #139 fixtures don't skew calibration
|
|
491
|
+
enabled: true
|
|
448
492
|
},
|
|
449
493
|
// ── Semantic ──
|
|
450
494
|
"non-standard-naming": {
|
|
@@ -512,7 +556,11 @@ function getConfigsWithPreset(preset) {
|
|
|
512
556
|
}
|
|
513
557
|
var RULE_ANNOTATION_PROPERTIES = {
|
|
514
558
|
"missing-size-constraint": {
|
|
515
|
-
|
|
559
|
+
// #403: width-only — the redesigned rule does not evaluate the
|
|
560
|
+
// height axis (deferred follow-up). Emitting a `height` annotation
|
|
561
|
+
// here would mark properties the rule never inspected and confuse
|
|
562
|
+
// downstream Dev Mode hints.
|
|
563
|
+
default: [{ type: "width" }]
|
|
516
564
|
},
|
|
517
565
|
"irregular-spacing": {
|
|
518
566
|
bySubType: {
|
|
@@ -564,6 +612,14 @@ var RuleRegistry = class {
|
|
|
564
612
|
register(rule) {
|
|
565
613
|
this.rules.set(rule.definition.id, rule);
|
|
566
614
|
}
|
|
615
|
+
/**
|
|
616
|
+
* Remove a rule by ID. Primarily used by tests that register a
|
|
617
|
+
* throwaway rule and need to restore the registry afterwards. Returns
|
|
618
|
+
* `true` if the rule was present.
|
|
619
|
+
*/
|
|
620
|
+
unregister(id) {
|
|
621
|
+
return this.rules.delete(id);
|
|
622
|
+
}
|
|
567
623
|
/**
|
|
568
624
|
* Get a rule by ID
|
|
569
625
|
*/
|
|
@@ -615,14 +671,37 @@ function defineRule(rule) {
|
|
|
615
671
|
ruleRegistry.register(rule);
|
|
616
672
|
return rule;
|
|
617
673
|
}
|
|
674
|
+
var AcknowledgmentIntentSchema = z.object({
|
|
675
|
+
field: z.string(),
|
|
676
|
+
value: z.unknown(),
|
|
677
|
+
scope: z.enum(["instance", "definition"])
|
|
678
|
+
});
|
|
679
|
+
var AcknowledgmentSceneWriteOutcomeSchema = z.object({
|
|
680
|
+
result: z.enum([
|
|
681
|
+
"succeeded",
|
|
682
|
+
"silent-ignored",
|
|
683
|
+
"api-rejected",
|
|
684
|
+
"user-declined-propagation",
|
|
685
|
+
"unknown"
|
|
686
|
+
]),
|
|
687
|
+
reason: z.string().optional()
|
|
688
|
+
});
|
|
618
689
|
var AcknowledgmentSchema = z.object({
|
|
619
690
|
nodeId: z.string(),
|
|
620
|
-
ruleId: z.string()
|
|
691
|
+
ruleId: z.string(),
|
|
692
|
+
intent: AcknowledgmentIntentSchema.optional(),
|
|
693
|
+
sceneWriteOutcome: AcknowledgmentSceneWriteOutcomeSchema.optional(),
|
|
694
|
+
codegenDirective: z.string().optional()
|
|
621
695
|
});
|
|
622
696
|
z.array(AcknowledgmentSchema);
|
|
623
697
|
function normalizeNodeId(id) {
|
|
624
698
|
return id.replace(/-/g, ":");
|
|
625
699
|
}
|
|
700
|
+
z.enum(["page", "component"]);
|
|
701
|
+
var COMPONENT_SCOPE_ROOT_TYPES = /* @__PURE__ */ new Set(["COMPONENT", "COMPONENT_SET", "INSTANCE"]);
|
|
702
|
+
function detectAnalysisScope(rootNode) {
|
|
703
|
+
return COMPONENT_SCOPE_ROOT_TYPES.has(rootNode.type) ? "component" : "page";
|
|
704
|
+
}
|
|
626
705
|
|
|
627
706
|
// src/core/engine/rule-engine.ts
|
|
628
707
|
function calculateMaxDepth(node, currentDepth = 0) {
|
|
@@ -674,6 +753,7 @@ var RuleEngine = class {
|
|
|
674
753
|
excludeNamePattern;
|
|
675
754
|
excludeNodeTypes;
|
|
676
755
|
acknowledgments;
|
|
756
|
+
scopeOverride;
|
|
677
757
|
constructor(options = {}) {
|
|
678
758
|
this.configs = options.configs ?? RULE_CONFIGS;
|
|
679
759
|
this.enabledRuleIds = options.enabledRules ? new Set(options.enabledRules) : null;
|
|
@@ -686,6 +766,7 @@ var RuleEngine = class {
|
|
|
686
766
|
(a) => `${normalizeNodeId(a.nodeId)}::${a.ruleId}`
|
|
687
767
|
)
|
|
688
768
|
);
|
|
769
|
+
this.scopeOverride = options.scope;
|
|
689
770
|
}
|
|
690
771
|
/**
|
|
691
772
|
* Analyze a Figma file and return issues
|
|
@@ -702,6 +783,8 @@ var RuleEngine = class {
|
|
|
702
783
|
}
|
|
703
784
|
const maxDepth = calculateMaxDepth(rootNode);
|
|
704
785
|
const nodeCount = countNodes(rootNode);
|
|
786
|
+
const scope = this.scopeOverride ?? detectAnalysisScope(rootNode);
|
|
787
|
+
const rootNodeType = rootNode.type;
|
|
705
788
|
const issues = [];
|
|
706
789
|
const failedRules = [];
|
|
707
790
|
const enabledRules = this.getEnabledRules();
|
|
@@ -717,6 +800,8 @@ var RuleEngine = class {
|
|
|
717
800
|
[],
|
|
718
801
|
0,
|
|
719
802
|
analysisState,
|
|
803
|
+
scope,
|
|
804
|
+
rootNodeType,
|
|
720
805
|
void 0,
|
|
721
806
|
void 0
|
|
722
807
|
);
|
|
@@ -734,7 +819,8 @@ var RuleEngine = class {
|
|
|
734
819
|
failedRules,
|
|
735
820
|
maxDepth,
|
|
736
821
|
nodeCount,
|
|
737
|
-
analyzedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
822
|
+
analyzedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
823
|
+
scope
|
|
738
824
|
};
|
|
739
825
|
}
|
|
740
826
|
/**
|
|
@@ -752,7 +838,7 @@ var RuleEngine = class {
|
|
|
752
838
|
/**
|
|
753
839
|
* Recursively traverse the tree and run rules
|
|
754
840
|
*/
|
|
755
|
-
traverseAndCheck(node, file, rules, maxDepth, issues, failedRules, depth, path, ancestorTypes, componentDepth, analysisState, parent, siblings) {
|
|
841
|
+
traverseAndCheck(node, file, rules, maxDepth, issues, failedRules, depth, path, ancestorTypes, componentDepth, analysisState, scope, rootNodeType, parent, siblings) {
|
|
756
842
|
const nodePath = [...path, node.name];
|
|
757
843
|
const isComponentBoundary = node.type === "COMPONENT" || node.type === "COMPONENT_SET" || node.type === "INSTANCE";
|
|
758
844
|
const currentComponentDepth = isComponentBoundary ? 0 : componentDepth;
|
|
@@ -771,7 +857,9 @@ var RuleEngine = class {
|
|
|
771
857
|
path: nodePath,
|
|
772
858
|
ancestorTypes,
|
|
773
859
|
siblings,
|
|
774
|
-
analysisState
|
|
860
|
+
analysisState,
|
|
861
|
+
scope,
|
|
862
|
+
rootNodeType
|
|
775
863
|
};
|
|
776
864
|
for (const rule of rules) {
|
|
777
865
|
const ruleId = rule.definition.id;
|
|
@@ -818,6 +906,8 @@ var RuleEngine = class {
|
|
|
818
906
|
childAncestorTypes,
|
|
819
907
|
currentComponentDepth + 1,
|
|
820
908
|
analysisState,
|
|
909
|
+
scope,
|
|
910
|
+
rootNodeType,
|
|
821
911
|
node,
|
|
822
912
|
node.children
|
|
823
913
|
);
|
|
@@ -1773,7 +1863,7 @@ function computeApplyContext(violation, instanceContext) {
|
|
|
1773
1863
|
}
|
|
1774
1864
|
|
|
1775
1865
|
// package.json
|
|
1776
|
-
var version = "0.
|
|
1866
|
+
var version = "0.11.1";
|
|
1777
1867
|
|
|
1778
1868
|
// src/core/engine/scoring.ts
|
|
1779
1869
|
function computeTotalScorePerCategory(configs) {
|
|
@@ -1967,6 +2057,10 @@ function buildResultJson(fileName, result, scores, options) {
|
|
|
1967
2057
|
const suggestedName = issue.violation.suggestedName;
|
|
1968
2058
|
return {
|
|
1969
2059
|
ruleId: issue.violation.ruleId,
|
|
2060
|
+
detection: "rule-based",
|
|
2061
|
+
outputChannel: "score",
|
|
2062
|
+
persistenceIntent: "transient",
|
|
2063
|
+
purpose: getRulePurpose(issue.violation.ruleId),
|
|
1970
2064
|
...issue.violation.subType && { subType: issue.violation.subType },
|
|
1971
2065
|
severity: issue.config.severity,
|
|
1972
2066
|
nodeId: issue.violation.nodeId,
|
|
@@ -1989,6 +2083,7 @@ function buildResultJson(fileName, result, scores, options) {
|
|
|
1989
2083
|
fileName,
|
|
1990
2084
|
nodeCount: result.nodeCount,
|
|
1991
2085
|
maxDepth: result.maxDepth,
|
|
2086
|
+
scope: result.scope,
|
|
1992
2087
|
issueCount: result.issues.length,
|
|
1993
2088
|
acknowledgedCount: scores.summary.acknowledgedCount,
|
|
1994
2089
|
isReadyForCodeGen: isReadyForCodeGen(scores.overall.grade),
|
|
@@ -2008,11 +2103,14 @@ function buildResultJson(fileName, result, scores, options) {
|
|
|
2008
2103
|
}
|
|
2009
2104
|
z.object({
|
|
2010
2105
|
ruleId: z.string(),
|
|
2106
|
+
detection: z.literal("rule-based"),
|
|
2107
|
+
outputChannel: z.literal("annotation"),
|
|
2108
|
+
persistenceIntent: z.literal("durable"),
|
|
2011
2109
|
question: z.string(),
|
|
2012
2110
|
hint: z.string(),
|
|
2013
2111
|
example: z.string()
|
|
2014
2112
|
});
|
|
2015
|
-
var
|
|
2113
|
+
var GOTCHA_QUESTION_CONTENT = {
|
|
2016
2114
|
// ── Pixel Critical (blocking) ──
|
|
2017
2115
|
"no-auto-layout": {
|
|
2018
2116
|
ruleId: "no-auto-layout",
|
|
@@ -2116,6 +2214,17 @@ var GOTCHA_QUESTIONS = {
|
|
|
2116
2214
|
example: "Use PascalCase for all component layers (e.g., CardTitle, CardBody)"
|
|
2117
2215
|
}
|
|
2118
2216
|
};
|
|
2217
|
+
var GOTCHA_QUESTIONS = Object.fromEntries(
|
|
2218
|
+
Object.entries(GOTCHA_QUESTION_CONTENT).map(([ruleId, content]) => [
|
|
2219
|
+
ruleId,
|
|
2220
|
+
{
|
|
2221
|
+
...content,
|
|
2222
|
+
detection: "rule-based",
|
|
2223
|
+
outputChannel: "annotation",
|
|
2224
|
+
persistenceIntent: "durable"
|
|
2225
|
+
}
|
|
2226
|
+
])
|
|
2227
|
+
);
|
|
2119
2228
|
|
|
2120
2229
|
// src/core/gotcha/group-and-batch-questions.ts
|
|
2121
2230
|
var BATCHABLE_RULE_IDS = [
|
|
@@ -2184,9 +2293,14 @@ function pushIntoBatch(group, question) {
|
|
|
2184
2293
|
var NODE_PATH_SEPARATOR = " > ";
|
|
2185
2294
|
function generateGotchaSurvey(result, scores, options = {}) {
|
|
2186
2295
|
const grade = scores.overall.grade;
|
|
2187
|
-
const relevantIssues = result.issues.filter(
|
|
2188
|
-
|
|
2189
|
-
|
|
2296
|
+
const relevantIssues = result.issues.filter((issue) => {
|
|
2297
|
+
const severity = issue.config.severity;
|
|
2298
|
+
if (severity === "blocking" || severity === "risk") return true;
|
|
2299
|
+
if (severity === "missing-info") {
|
|
2300
|
+
return getRulePurpose(issue.violation.ruleId) === "info-collection";
|
|
2301
|
+
}
|
|
2302
|
+
return false;
|
|
2303
|
+
});
|
|
2190
2304
|
const deduped = deduplicateSiblingIssues(relevantIssues);
|
|
2191
2305
|
const sorted = stableSortBySeverity(deduped);
|
|
2192
2306
|
const mapped = sorted.map((issue) => mapToQuestion(issue, result.file)).filter((q) => q !== null);
|
|
@@ -2230,14 +2344,17 @@ function getNodeName(nodePath) {
|
|
|
2230
2344
|
function stableSortBySeverity(issues) {
|
|
2231
2345
|
const blocking = [];
|
|
2232
2346
|
const risk = [];
|
|
2347
|
+
const missingInfo = [];
|
|
2233
2348
|
for (const issue of issues) {
|
|
2234
2349
|
if (issue.config.severity === "blocking") {
|
|
2235
2350
|
blocking.push(issue);
|
|
2351
|
+
} else if (issue.config.severity === "missing-info") {
|
|
2352
|
+
missingInfo.push(issue);
|
|
2236
2353
|
} else {
|
|
2237
2354
|
risk.push(issue);
|
|
2238
2355
|
}
|
|
2239
2356
|
}
|
|
2240
|
-
return [...blocking, ...risk];
|
|
2357
|
+
return [...blocking, ...risk, ...missingInfo];
|
|
2241
2358
|
}
|
|
2242
2359
|
function mapToQuestion(issue, file) {
|
|
2243
2360
|
const ruleId = issue.violation.ruleId;
|
|
@@ -2254,6 +2371,10 @@ function mapToQuestion(issue, file) {
|
|
|
2254
2371
|
nodeId: issue.violation.nodeId,
|
|
2255
2372
|
nodeName,
|
|
2256
2373
|
ruleId,
|
|
2374
|
+
detection: template.detection,
|
|
2375
|
+
outputChannel: template.outputChannel,
|
|
2376
|
+
persistenceIntent: template.persistenceIntent,
|
|
2377
|
+
purpose: getRulePurpose(issue.violation.ruleId),
|
|
2257
2378
|
severity: issue.config.severity,
|
|
2258
2379
|
question: template.question.replace("{nodeName}", nodeName),
|
|
2259
2380
|
hint: template.hint,
|
|
@@ -3508,7 +3629,9 @@ var EVENTS = {
|
|
|
3508
3629
|
// cannot import `core/monitoring` directly, so the event fires through a
|
|
3509
3630
|
// caller-supplied callback. Define the typed name here so a future consumer
|
|
3510
3631
|
// has a single place to wire it up.
|
|
3511
|
-
ROUNDTRIP_DEFINITION_WRITE_SKIPPED: `${EVENT_PREFIX}roundtrip_definition_write_skipped
|
|
3632
|
+
ROUNDTRIP_DEFINITION_WRITE_SKIPPED: `${EVENT_PREFIX}roundtrip_definition_write_skipped`,
|
|
3633
|
+
/** CLI `canicode roundtrip-tally` completed successfully. */
|
|
3634
|
+
ROUNDTRIP_TALLY: `${EVENT_PREFIX}roundtrip_tally`
|
|
3512
3635
|
};
|
|
3513
3636
|
|
|
3514
3637
|
// src/core/monitoring/capture.ts
|
|
@@ -3662,9 +3785,6 @@ function isContainerNode(node) {
|
|
|
3662
3785
|
function hasAutoLayout(node) {
|
|
3663
3786
|
return node.layoutMode !== void 0 && node.layoutMode !== "NONE";
|
|
3664
3787
|
}
|
|
3665
|
-
function hasTextContent(node) {
|
|
3666
|
-
return node.type === "TEXT" || (node.children?.some((c) => c.type === "TEXT") ?? false);
|
|
3667
|
-
}
|
|
3668
3788
|
function hasOverlappingBounds(a, b) {
|
|
3669
3789
|
const boxA = a.absoluteBoundingBox;
|
|
3670
3790
|
const boxB = b.absoluteBoundingBox;
|
|
@@ -3805,12 +3925,6 @@ function isAbsolutePositionExempt(node) {
|
|
|
3805
3925
|
if (isExcludedName(node.name)) return true;
|
|
3806
3926
|
return false;
|
|
3807
3927
|
}
|
|
3808
|
-
function isSizeConstraintExempt(node, context) {
|
|
3809
|
-
if (node.maxWidth !== void 0) return true;
|
|
3810
|
-
if (context.parent?.maxWidth !== void 0) return true;
|
|
3811
|
-
if (context.depth <= 1) return true;
|
|
3812
|
-
return false;
|
|
3813
|
-
}
|
|
3814
3928
|
function isFixedSizeExempt(node) {
|
|
3815
3929
|
if (isVisualOnlyNode(node)) return true;
|
|
3816
3930
|
if (isExcludedName(node.name)) return true;
|
|
@@ -3877,21 +3991,21 @@ var fixedSizeMsg = {
|
|
|
3877
3991
|
})
|
|
3878
3992
|
};
|
|
3879
3993
|
var missingSizeConstraintMsg = {
|
|
3880
|
-
|
|
3881
|
-
message: `"${name}" uses FILL width (currently ${currentWidth})
|
|
3882
|
-
suggestion: `
|
|
3994
|
+
pageContainerUnbound: (name, currentWidth) => ({
|
|
3995
|
+
message: `Container "${name}" uses FILL width (currently ${currentWidth}) and no ancestor defines a width bound`,
|
|
3996
|
+
suggestion: `Decide whether this area should stretch with the screen, or set min/max-width here so the responsive behavior is explicit`
|
|
3883
3997
|
}),
|
|
3884
|
-
|
|
3885
|
-
message: `"${name}"
|
|
3886
|
-
suggestion: `
|
|
3998
|
+
pageInstanceFixed: (name, currentWidth) => ({
|
|
3999
|
+
message: `Instance "${name}" has fixed width (${currentWidth}) inside an Auto Layout parent`,
|
|
4000
|
+
suggestion: `Confirm whether this fixed width is intentional \u2014 if not, set the instance to FILL so it follows the parent's layout`
|
|
3887
4001
|
}),
|
|
3888
|
-
|
|
3889
|
-
message: `"${name}"
|
|
3890
|
-
suggestion: `
|
|
4002
|
+
componentFixedByDesign: (name, currentWidth) => ({
|
|
4003
|
+
message: `Component "${name}" has fixed width (${currentWidth}) at its root`,
|
|
4004
|
+
suggestion: `Confirm whether this component is intentionally non-responsive \u2014 otherwise switch root sizing to FILL or set min/max bounds`
|
|
3891
4005
|
}),
|
|
3892
|
-
|
|
3893
|
-
message: `"${name}"
|
|
3894
|
-
suggestion: `
|
|
4006
|
+
componentFixedByOverride: (name, currentWidth) => ({
|
|
4007
|
+
message: `Instance "${name}" overrides root width to fixed (${currentWidth}); the original component may be FILL`,
|
|
4008
|
+
suggestion: `Confirm whether the fixed-width override is intentional \u2014 if not, restore root sizing to inherit from the component definition`
|
|
3895
4009
|
})
|
|
3896
4010
|
};
|
|
3897
4011
|
var nonLayoutContainerMsg = {
|
|
@@ -4183,44 +4297,97 @@ var missingSizeConstraintDef = {
|
|
|
4183
4297
|
id: "missing-size-constraint",
|
|
4184
4298
|
name: "Missing Size Constraint",
|
|
4185
4299
|
category: "responsive-critical",
|
|
4186
|
-
why: "
|
|
4187
|
-
impact: "
|
|
4188
|
-
fix: "
|
|
4300
|
+
why: "Width sizing without explicit bounds (FIXED root or FILL chained to a bounded ancestor) is structurally indistinguishable from missing information \u2014 AI cannot tell whether the designer intended responsive stretching, a fixed cap, or simply forgot to set min/max",
|
|
4301
|
+
impact: "Generated code either guesses hard-coded widths or omits responsive constraints; either way the runtime layout drifts from the design intent at viewports the designer never explicitly considered",
|
|
4302
|
+
fix: "Answer the gotcha to declare intent (responsive vs. fixed-by-design vs. instance override), then encode the answer in Figma sizing \u2014 FILL/HUG with min/max for responsive bounds, FIXED only when intentionally non-responsive"
|
|
4189
4303
|
};
|
|
4190
|
-
var
|
|
4191
|
-
|
|
4192
|
-
|
|
4193
|
-
|
|
4194
|
-
|
|
4195
|
-
|
|
4196
|
-
|
|
4197
|
-
|
|
4198
|
-
|
|
4199
|
-
|
|
4200
|
-
|
|
4201
|
-
|
|
4202
|
-
|
|
4203
|
-
|
|
4204
|
-
|
|
4205
|
-
|
|
4206
|
-
|
|
4207
|
-
|
|
4208
|
-
|
|
4209
|
-
|
|
4210
|
-
|
|
4304
|
+
var CHAIN_BOUND_KEY = "missing-size-constraint:chain-bound";
|
|
4305
|
+
function getChainBoundCache(context) {
|
|
4306
|
+
return getAnalysisState(context, CHAIN_BOUND_KEY, () => /* @__PURE__ */ new Map());
|
|
4307
|
+
}
|
|
4308
|
+
function establishesOwnWidthBound(node) {
|
|
4309
|
+
if (node.layoutSizingHorizontal === "FIXED") return true;
|
|
4310
|
+
if (node.minWidth !== void 0 || node.maxWidth !== void 0) return true;
|
|
4311
|
+
return false;
|
|
4312
|
+
}
|
|
4313
|
+
function recordChainBound(context, node) {
|
|
4314
|
+
const cache = getChainBoundCache(context);
|
|
4315
|
+
const cached = cache.get(node.id);
|
|
4316
|
+
if (cached !== void 0) return cached;
|
|
4317
|
+
const own = establishesOwnWidthBound(node);
|
|
4318
|
+
const parent = context.parent;
|
|
4319
|
+
const inherited = parent ? cache.get(parent.id) ?? false : false;
|
|
4320
|
+
const result = own || inherited;
|
|
4321
|
+
cache.set(node.id, result);
|
|
4322
|
+
return result;
|
|
4323
|
+
}
|
|
4324
|
+
function parentChainBound(context) {
|
|
4325
|
+
if (!context.parent) return false;
|
|
4326
|
+
return getChainBoundCache(context).get(context.parent.id) ?? false;
|
|
4327
|
+
}
|
|
4328
|
+
var PAGE_CONTAINER_FRAME_TYPES = /* @__PURE__ */ new Set(["FRAME", "SECTION"]);
|
|
4329
|
+
function formatWidth(node) {
|
|
4330
|
+
return node.absoluteBoundingBox ? `${node.absoluteBoundingBox.width}px` : "unknown";
|
|
4331
|
+
}
|
|
4332
|
+
function buildViolation(subType, node, context, msg) {
|
|
4333
|
+
return {
|
|
4334
|
+
ruleId: missingSizeConstraintDef.id,
|
|
4335
|
+
subType,
|
|
4336
|
+
nodeId: node.id,
|
|
4337
|
+
nodePath: context.path.join(" > "),
|
|
4338
|
+
...msg
|
|
4339
|
+
};
|
|
4340
|
+
}
|
|
4341
|
+
function checkComponentScopeRoot(node, context) {
|
|
4342
|
+
if (context.depth !== 0) return null;
|
|
4343
|
+
if (node.layoutSizingHorizontal !== "FIXED") return null;
|
|
4344
|
+
const currentWidth = formatWidth(node);
|
|
4345
|
+
if (context.rootNodeType === "INSTANCE") {
|
|
4346
|
+
return buildViolation(
|
|
4347
|
+
"component-fixed-by-override",
|
|
4348
|
+
node,
|
|
4349
|
+
context,
|
|
4350
|
+
missingSizeConstraintMsg.componentFixedByOverride(node.name, currentWidth)
|
|
4351
|
+
);
|
|
4211
4352
|
}
|
|
4212
|
-
|
|
4213
|
-
|
|
4214
|
-
|
|
4215
|
-
|
|
4216
|
-
|
|
4217
|
-
|
|
4218
|
-
|
|
4219
|
-
|
|
4220
|
-
|
|
4221
|
-
|
|
4353
|
+
return buildViolation(
|
|
4354
|
+
"component-fixed-by-design",
|
|
4355
|
+
node,
|
|
4356
|
+
context,
|
|
4357
|
+
missingSizeConstraintMsg.componentFixedByDesign(node.name, currentWidth)
|
|
4358
|
+
);
|
|
4359
|
+
}
|
|
4360
|
+
function checkPageInstanceFixed(node, context) {
|
|
4361
|
+
if (node.type !== "INSTANCE") return null;
|
|
4362
|
+
if (node.layoutSizingHorizontal !== "FIXED") return null;
|
|
4363
|
+
if (!context.parent || !hasAutoLayout(context.parent)) return null;
|
|
4364
|
+
const currentWidth = formatWidth(node);
|
|
4365
|
+
return buildViolation(
|
|
4366
|
+
"page-instance-fixed",
|
|
4367
|
+
node,
|
|
4368
|
+
context,
|
|
4369
|
+
missingSizeConstraintMsg.pageInstanceFixed(node.name, currentWidth)
|
|
4370
|
+
);
|
|
4371
|
+
}
|
|
4372
|
+
function checkPageContainerUnbound(node, context) {
|
|
4373
|
+
if (!PAGE_CONTAINER_FRAME_TYPES.has(node.type)) return null;
|
|
4374
|
+
if (node.layoutSizingHorizontal !== "FILL") return null;
|
|
4375
|
+
if (parentChainBound(context)) return null;
|
|
4376
|
+
const currentWidth = formatWidth(node);
|
|
4377
|
+
return buildViolation(
|
|
4378
|
+
"page-container-unbound",
|
|
4379
|
+
node,
|
|
4380
|
+
context,
|
|
4381
|
+
missingSizeConstraintMsg.pageContainerUnbound(node.name, currentWidth)
|
|
4382
|
+
);
|
|
4383
|
+
}
|
|
4384
|
+
var missingSizeConstraintCheck = (node, context) => {
|
|
4385
|
+
recordChainBound(context, node);
|
|
4386
|
+
if (context.ancestorTypes.includes("INSTANCE")) return null;
|
|
4387
|
+
if (context.scope === "component") {
|
|
4388
|
+
return checkComponentScopeRoot(node, context);
|
|
4222
4389
|
}
|
|
4223
|
-
return
|
|
4390
|
+
return checkPageInstanceFixed(node, context) ?? checkPageContainerUnbound(node, context);
|
|
4224
4391
|
};
|
|
4225
4392
|
defineRule({
|
|
4226
4393
|
definition: missingSizeConstraintDef,
|
|
@@ -5010,7 +5177,8 @@ Provide a Figma URL or fixture path via the input parameter. Requires FIGMA_TOKE
|
|
|
5010
5177
|
targetNodeId: z.string().optional().describe("Scope analysis to a specific node ID"),
|
|
5011
5178
|
configPath: z.string().optional().describe("Path to config JSON file for rule overrides"),
|
|
5012
5179
|
openReport: z.boolean().optional().describe("Open the generated HTML report in the user's browser. Defaults to false \u2014 opt in only when a visible report is the explicit user request (#365). The HTML file is always written to disk regardless."),
|
|
5013
|
-
acknowledgments: z.array(AcknowledgmentSchema).optional().describe("(#371) Pre-resolved
|
|
5180
|
+
acknowledgments: z.array(AcknowledgmentSchema).optional().describe("(#371 / ADR-019) Pre-resolved acknowledgments from canicode-authored Figma annotations (e.g. via readCanicodeAcknowledgments in a use_figma batch). Each entry includes nodeId and ruleId; newer annotations may also carry intent, sceneWriteOutcome, and codegenDirective from a canicode-json fenced block (#444). Matching issues are flagged acknowledged and contribute half weight to the density score."),
|
|
5181
|
+
scope: z.enum(["page", "component"]).optional().describe("(#404) Override analysis scope \u2014 `page` (screen/section where container bounds are required) or `component` (standalone reusable unit where root FILL is the design contract). Defaults to auto-detection from the root node type: `COMPONENT` / `COMPONENT_SET` / `INSTANCE` roots resolve to `component`, everything else to `page`.")
|
|
5014
5182
|
},
|
|
5015
5183
|
{
|
|
5016
5184
|
readOnlyHint: false,
|
|
@@ -5018,7 +5186,7 @@ Provide a Figma URL or fixture path via the input parameter. Requires FIGMA_TOKE
|
|
|
5018
5186
|
openWorldHint: true,
|
|
5019
5187
|
title: "Analyze Figma Design"
|
|
5020
5188
|
},
|
|
5021
|
-
async ({ input, token, preset, targetNodeId, configPath, openReport, acknowledgments }) => {
|
|
5189
|
+
async ({ input, token, preset, targetNodeId, configPath, openReport, acknowledgments, scope }) => {
|
|
5022
5190
|
trackEvent(EVENTS.MCP_TOOL_CALLED, { tool: "analyze" });
|
|
5023
5191
|
try {
|
|
5024
5192
|
const { file, nodeId } = await loadFile(input, token);
|
|
@@ -5031,7 +5199,8 @@ Provide a Figma URL or fixture path via the input parameter. Requires FIGMA_TOKE
|
|
|
5031
5199
|
const result = analyzeFile(file, {
|
|
5032
5200
|
configs,
|
|
5033
5201
|
...effectiveNodeId ? { targetNodeId: effectiveNodeId } : {},
|
|
5034
|
-
...acknowledgments && acknowledgments.length > 0 ? { acknowledgments } : {}
|
|
5202
|
+
...acknowledgments && acknowledgments.length > 0 ? { acknowledgments } : {},
|
|
5203
|
+
...scope ? { scope } : {}
|
|
5035
5204
|
});
|
|
5036
5205
|
const scores = calculateScores(result, configs);
|
|
5037
5206
|
const figmaToken = token ?? process.env["FIGMA_TOKEN"];
|
|
@@ -5098,7 +5267,8 @@ Provide a Figma URL or fixture path via the input parameter. Requires FIGMA_TOKE
|
|
|
5098
5267
|
token: z.string().optional().describe("Figma API token (falls back to FIGMA_TOKEN env var)"),
|
|
5099
5268
|
preset: z.enum(["relaxed", "dev-friendly", "ai-ready", "strict"]).optional().describe("Analysis preset"),
|
|
5100
5269
|
targetNodeId: z.string().optional().describe("Scope analysis to a specific node ID"),
|
|
5101
|
-
configPath: z.string().optional().describe("Path to config JSON file for rule overrides")
|
|
5270
|
+
configPath: z.string().optional().describe("Path to config JSON file for rule overrides"),
|
|
5271
|
+
scope: z.enum(["page", "component"]).optional().describe("(#404) Override analysis scope \u2014 `page` or `component`. Defaults to auto-detection from the root node type.")
|
|
5102
5272
|
},
|
|
5103
5273
|
{
|
|
5104
5274
|
readOnlyHint: false,
|
|
@@ -5106,7 +5276,7 @@ Provide a Figma URL or fixture path via the input parameter. Requires FIGMA_TOKE
|
|
|
5106
5276
|
openWorldHint: true,
|
|
5107
5277
|
title: "Gotcha Survey"
|
|
5108
5278
|
},
|
|
5109
|
-
async ({ input, token, preset, targetNodeId, configPath }) => {
|
|
5279
|
+
async ({ input, token, preset, targetNodeId, configPath, scope }) => {
|
|
5110
5280
|
trackEvent(EVENTS.MCP_TOOL_CALLED, { tool: "gotcha-survey" });
|
|
5111
5281
|
try {
|
|
5112
5282
|
const { file, nodeId } = await loadFile(input, token);
|
|
@@ -5118,7 +5288,8 @@ Provide a Figma URL or fixture path via the input parameter. Requires FIGMA_TOKE
|
|
|
5118
5288
|
}
|
|
5119
5289
|
const result = analyzeFile(file, {
|
|
5120
5290
|
configs,
|
|
5121
|
-
...effectiveNodeId ? { targetNodeId: effectiveNodeId } : {}
|
|
5291
|
+
...effectiveNodeId ? { targetNodeId: effectiveNodeId } : {},
|
|
5292
|
+
...scope ? { scope } : {}
|
|
5122
5293
|
});
|
|
5123
5294
|
const scores = calculateScores(result, configs);
|
|
5124
5295
|
const survey = generateGotchaSurvey(result, scores, { designKey: computeDesignKey(input) });
|