canicode 0.10.4 → 0.11.0
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 +9 -2
- package/dist/cli/index.js +559 -141
- package/dist/cli/index.js.map +1 -1
- package/dist/index.d.ts +254 -19
- package/dist/index.js +256 -73
- package/dist/index.js.map +1 -1
- package/dist/mcp/server.js +242 -81
- package/dist/mcp/server.js.map +1 -1
- package/docs/CUSTOMIZATION.md +39 -3
- package/package.json +1 -1
- package/skills/canicode-gotchas/SKILL.md +17 -14
- package/skills/canicode-roundtrip/SKILL.md +12 -11
- package/skills/canicode-roundtrip/helpers.js +1 -1
- package/skills/cursor/canicode/SKILL.md +76 -0
- package/skills/cursor/canicode-gotchas/SKILL.md +199 -0
- package/skills/cursor/canicode-roundtrip/SKILL.md +618 -0
- package/skills/cursor/canicode-roundtrip/helpers.js +523 -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
|
*/
|
|
@@ -623,6 +679,11 @@ z.array(AcknowledgmentSchema);
|
|
|
623
679
|
function normalizeNodeId(id) {
|
|
624
680
|
return id.replace(/-/g, ":");
|
|
625
681
|
}
|
|
682
|
+
z.enum(["page", "component"]);
|
|
683
|
+
var COMPONENT_SCOPE_ROOT_TYPES = /* @__PURE__ */ new Set(["COMPONENT", "COMPONENT_SET", "INSTANCE"]);
|
|
684
|
+
function detectAnalysisScope(rootNode) {
|
|
685
|
+
return COMPONENT_SCOPE_ROOT_TYPES.has(rootNode.type) ? "component" : "page";
|
|
686
|
+
}
|
|
626
687
|
|
|
627
688
|
// src/core/engine/rule-engine.ts
|
|
628
689
|
function calculateMaxDepth(node, currentDepth = 0) {
|
|
@@ -674,6 +735,7 @@ var RuleEngine = class {
|
|
|
674
735
|
excludeNamePattern;
|
|
675
736
|
excludeNodeTypes;
|
|
676
737
|
acknowledgments;
|
|
738
|
+
scopeOverride;
|
|
677
739
|
constructor(options = {}) {
|
|
678
740
|
this.configs = options.configs ?? RULE_CONFIGS;
|
|
679
741
|
this.enabledRuleIds = options.enabledRules ? new Set(options.enabledRules) : null;
|
|
@@ -686,6 +748,7 @@ var RuleEngine = class {
|
|
|
686
748
|
(a) => `${normalizeNodeId(a.nodeId)}::${a.ruleId}`
|
|
687
749
|
)
|
|
688
750
|
);
|
|
751
|
+
this.scopeOverride = options.scope;
|
|
689
752
|
}
|
|
690
753
|
/**
|
|
691
754
|
* Analyze a Figma file and return issues
|
|
@@ -702,6 +765,8 @@ var RuleEngine = class {
|
|
|
702
765
|
}
|
|
703
766
|
const maxDepth = calculateMaxDepth(rootNode);
|
|
704
767
|
const nodeCount = countNodes(rootNode);
|
|
768
|
+
const scope = this.scopeOverride ?? detectAnalysisScope(rootNode);
|
|
769
|
+
const rootNodeType = rootNode.type;
|
|
705
770
|
const issues = [];
|
|
706
771
|
const failedRules = [];
|
|
707
772
|
const enabledRules = this.getEnabledRules();
|
|
@@ -717,6 +782,8 @@ var RuleEngine = class {
|
|
|
717
782
|
[],
|
|
718
783
|
0,
|
|
719
784
|
analysisState,
|
|
785
|
+
scope,
|
|
786
|
+
rootNodeType,
|
|
720
787
|
void 0,
|
|
721
788
|
void 0
|
|
722
789
|
);
|
|
@@ -734,7 +801,8 @@ var RuleEngine = class {
|
|
|
734
801
|
failedRules,
|
|
735
802
|
maxDepth,
|
|
736
803
|
nodeCount,
|
|
737
|
-
analyzedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
804
|
+
analyzedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
805
|
+
scope
|
|
738
806
|
};
|
|
739
807
|
}
|
|
740
808
|
/**
|
|
@@ -752,7 +820,7 @@ var RuleEngine = class {
|
|
|
752
820
|
/**
|
|
753
821
|
* Recursively traverse the tree and run rules
|
|
754
822
|
*/
|
|
755
|
-
traverseAndCheck(node, file, rules, maxDepth, issues, failedRules, depth, path, ancestorTypes, componentDepth, analysisState, parent, siblings) {
|
|
823
|
+
traverseAndCheck(node, file, rules, maxDepth, issues, failedRules, depth, path, ancestorTypes, componentDepth, analysisState, scope, rootNodeType, parent, siblings) {
|
|
756
824
|
const nodePath = [...path, node.name];
|
|
757
825
|
const isComponentBoundary = node.type === "COMPONENT" || node.type === "COMPONENT_SET" || node.type === "INSTANCE";
|
|
758
826
|
const currentComponentDepth = isComponentBoundary ? 0 : componentDepth;
|
|
@@ -771,7 +839,9 @@ var RuleEngine = class {
|
|
|
771
839
|
path: nodePath,
|
|
772
840
|
ancestorTypes,
|
|
773
841
|
siblings,
|
|
774
|
-
analysisState
|
|
842
|
+
analysisState,
|
|
843
|
+
scope,
|
|
844
|
+
rootNodeType
|
|
775
845
|
};
|
|
776
846
|
for (const rule of rules) {
|
|
777
847
|
const ruleId = rule.definition.id;
|
|
@@ -818,6 +888,8 @@ var RuleEngine = class {
|
|
|
818
888
|
childAncestorTypes,
|
|
819
889
|
currentComponentDepth + 1,
|
|
820
890
|
analysisState,
|
|
891
|
+
scope,
|
|
892
|
+
rootNodeType,
|
|
821
893
|
node,
|
|
822
894
|
node.children
|
|
823
895
|
);
|
|
@@ -1773,7 +1845,7 @@ function computeApplyContext(violation, instanceContext) {
|
|
|
1773
1845
|
}
|
|
1774
1846
|
|
|
1775
1847
|
// package.json
|
|
1776
|
-
var version = "0.
|
|
1848
|
+
var version = "0.11.0";
|
|
1777
1849
|
|
|
1778
1850
|
// src/core/engine/scoring.ts
|
|
1779
1851
|
function computeTotalScorePerCategory(configs) {
|
|
@@ -1967,6 +2039,10 @@ function buildResultJson(fileName, result, scores, options) {
|
|
|
1967
2039
|
const suggestedName = issue.violation.suggestedName;
|
|
1968
2040
|
return {
|
|
1969
2041
|
ruleId: issue.violation.ruleId,
|
|
2042
|
+
detection: "rule-based",
|
|
2043
|
+
outputChannel: "score",
|
|
2044
|
+
persistenceIntent: "transient",
|
|
2045
|
+
purpose: getRulePurpose(issue.violation.ruleId),
|
|
1970
2046
|
...issue.violation.subType && { subType: issue.violation.subType },
|
|
1971
2047
|
severity: issue.config.severity,
|
|
1972
2048
|
nodeId: issue.violation.nodeId,
|
|
@@ -1989,6 +2065,7 @@ function buildResultJson(fileName, result, scores, options) {
|
|
|
1989
2065
|
fileName,
|
|
1990
2066
|
nodeCount: result.nodeCount,
|
|
1991
2067
|
maxDepth: result.maxDepth,
|
|
2068
|
+
scope: result.scope,
|
|
1992
2069
|
issueCount: result.issues.length,
|
|
1993
2070
|
acknowledgedCount: scores.summary.acknowledgedCount,
|
|
1994
2071
|
isReadyForCodeGen: isReadyForCodeGen(scores.overall.grade),
|
|
@@ -2008,11 +2085,14 @@ function buildResultJson(fileName, result, scores, options) {
|
|
|
2008
2085
|
}
|
|
2009
2086
|
z.object({
|
|
2010
2087
|
ruleId: z.string(),
|
|
2088
|
+
detection: z.literal("rule-based"),
|
|
2089
|
+
outputChannel: z.literal("annotation"),
|
|
2090
|
+
persistenceIntent: z.literal("durable"),
|
|
2011
2091
|
question: z.string(),
|
|
2012
2092
|
hint: z.string(),
|
|
2013
2093
|
example: z.string()
|
|
2014
2094
|
});
|
|
2015
|
-
var
|
|
2095
|
+
var GOTCHA_QUESTION_CONTENT = {
|
|
2016
2096
|
// ── Pixel Critical (blocking) ──
|
|
2017
2097
|
"no-auto-layout": {
|
|
2018
2098
|
ruleId: "no-auto-layout",
|
|
@@ -2116,6 +2196,17 @@ var GOTCHA_QUESTIONS = {
|
|
|
2116
2196
|
example: "Use PascalCase for all component layers (e.g., CardTitle, CardBody)"
|
|
2117
2197
|
}
|
|
2118
2198
|
};
|
|
2199
|
+
var GOTCHA_QUESTIONS = Object.fromEntries(
|
|
2200
|
+
Object.entries(GOTCHA_QUESTION_CONTENT).map(([ruleId, content]) => [
|
|
2201
|
+
ruleId,
|
|
2202
|
+
{
|
|
2203
|
+
...content,
|
|
2204
|
+
detection: "rule-based",
|
|
2205
|
+
outputChannel: "annotation",
|
|
2206
|
+
persistenceIntent: "durable"
|
|
2207
|
+
}
|
|
2208
|
+
])
|
|
2209
|
+
);
|
|
2119
2210
|
|
|
2120
2211
|
// src/core/gotcha/group-and-batch-questions.ts
|
|
2121
2212
|
var BATCHABLE_RULE_IDS = [
|
|
@@ -2184,9 +2275,14 @@ function pushIntoBatch(group, question) {
|
|
|
2184
2275
|
var NODE_PATH_SEPARATOR = " > ";
|
|
2185
2276
|
function generateGotchaSurvey(result, scores, options = {}) {
|
|
2186
2277
|
const grade = scores.overall.grade;
|
|
2187
|
-
const relevantIssues = result.issues.filter(
|
|
2188
|
-
|
|
2189
|
-
|
|
2278
|
+
const relevantIssues = result.issues.filter((issue) => {
|
|
2279
|
+
const severity = issue.config.severity;
|
|
2280
|
+
if (severity === "blocking" || severity === "risk") return true;
|
|
2281
|
+
if (severity === "missing-info") {
|
|
2282
|
+
return getRulePurpose(issue.violation.ruleId) === "info-collection";
|
|
2283
|
+
}
|
|
2284
|
+
return false;
|
|
2285
|
+
});
|
|
2190
2286
|
const deduped = deduplicateSiblingIssues(relevantIssues);
|
|
2191
2287
|
const sorted = stableSortBySeverity(deduped);
|
|
2192
2288
|
const mapped = sorted.map((issue) => mapToQuestion(issue, result.file)).filter((q) => q !== null);
|
|
@@ -2230,14 +2326,17 @@ function getNodeName(nodePath) {
|
|
|
2230
2326
|
function stableSortBySeverity(issues) {
|
|
2231
2327
|
const blocking = [];
|
|
2232
2328
|
const risk = [];
|
|
2329
|
+
const missingInfo = [];
|
|
2233
2330
|
for (const issue of issues) {
|
|
2234
2331
|
if (issue.config.severity === "blocking") {
|
|
2235
2332
|
blocking.push(issue);
|
|
2333
|
+
} else if (issue.config.severity === "missing-info") {
|
|
2334
|
+
missingInfo.push(issue);
|
|
2236
2335
|
} else {
|
|
2237
2336
|
risk.push(issue);
|
|
2238
2337
|
}
|
|
2239
2338
|
}
|
|
2240
|
-
return [...blocking, ...risk];
|
|
2339
|
+
return [...blocking, ...risk, ...missingInfo];
|
|
2241
2340
|
}
|
|
2242
2341
|
function mapToQuestion(issue, file) {
|
|
2243
2342
|
const ruleId = issue.violation.ruleId;
|
|
@@ -2254,6 +2353,10 @@ function mapToQuestion(issue, file) {
|
|
|
2254
2353
|
nodeId: issue.violation.nodeId,
|
|
2255
2354
|
nodeName,
|
|
2256
2355
|
ruleId,
|
|
2356
|
+
detection: template.detection,
|
|
2357
|
+
outputChannel: template.outputChannel,
|
|
2358
|
+
persistenceIntent: template.persistenceIntent,
|
|
2359
|
+
purpose: getRulePurpose(issue.violation.ruleId),
|
|
2257
2360
|
severity: issue.config.severity,
|
|
2258
2361
|
question: template.question.replace("{nodeName}", nodeName),
|
|
2259
2362
|
hint: template.hint,
|
|
@@ -3662,9 +3765,6 @@ function isContainerNode(node) {
|
|
|
3662
3765
|
function hasAutoLayout(node) {
|
|
3663
3766
|
return node.layoutMode !== void 0 && node.layoutMode !== "NONE";
|
|
3664
3767
|
}
|
|
3665
|
-
function hasTextContent(node) {
|
|
3666
|
-
return node.type === "TEXT" || (node.children?.some((c) => c.type === "TEXT") ?? false);
|
|
3667
|
-
}
|
|
3668
3768
|
function hasOverlappingBounds(a, b) {
|
|
3669
3769
|
const boxA = a.absoluteBoundingBox;
|
|
3670
3770
|
const boxB = b.absoluteBoundingBox;
|
|
@@ -3805,12 +3905,6 @@ function isAbsolutePositionExempt(node) {
|
|
|
3805
3905
|
if (isExcludedName(node.name)) return true;
|
|
3806
3906
|
return false;
|
|
3807
3907
|
}
|
|
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
3908
|
function isFixedSizeExempt(node) {
|
|
3815
3909
|
if (isVisualOnlyNode(node)) return true;
|
|
3816
3910
|
if (isExcludedName(node.name)) return true;
|
|
@@ -3877,21 +3971,21 @@ var fixedSizeMsg = {
|
|
|
3877
3971
|
})
|
|
3878
3972
|
};
|
|
3879
3973
|
var missingSizeConstraintMsg = {
|
|
3880
|
-
|
|
3881
|
-
message: `"${name}" uses FILL width (currently ${currentWidth})
|
|
3882
|
-
suggestion: `
|
|
3974
|
+
pageContainerUnbound: (name, currentWidth) => ({
|
|
3975
|
+
message: `Container "${name}" uses FILL width (currently ${currentWidth}) and no ancestor defines a width bound`,
|
|
3976
|
+
suggestion: `Decide whether this area should stretch with the screen, or set min/max-width here so the responsive behavior is explicit`
|
|
3883
3977
|
}),
|
|
3884
|
-
|
|
3885
|
-
message: `"${name}"
|
|
3886
|
-
suggestion: `
|
|
3978
|
+
pageInstanceFixed: (name, currentWidth) => ({
|
|
3979
|
+
message: `Instance "${name}" has fixed width (${currentWidth}) inside an Auto Layout parent`,
|
|
3980
|
+
suggestion: `Confirm whether this fixed width is intentional \u2014 if not, set the instance to FILL so it follows the parent's layout`
|
|
3887
3981
|
}),
|
|
3888
|
-
|
|
3889
|
-
message: `"${name}"
|
|
3890
|
-
suggestion: `
|
|
3982
|
+
componentFixedByDesign: (name, currentWidth) => ({
|
|
3983
|
+
message: `Component "${name}" has fixed width (${currentWidth}) at its root`,
|
|
3984
|
+
suggestion: `Confirm whether this component is intentionally non-responsive \u2014 otherwise switch root sizing to FILL or set min/max bounds`
|
|
3891
3985
|
}),
|
|
3892
|
-
|
|
3893
|
-
message: `"${name}"
|
|
3894
|
-
suggestion: `
|
|
3986
|
+
componentFixedByOverride: (name, currentWidth) => ({
|
|
3987
|
+
message: `Instance "${name}" overrides root width to fixed (${currentWidth}); the original component may be FILL`,
|
|
3988
|
+
suggestion: `Confirm whether the fixed-width override is intentional \u2014 if not, restore root sizing to inherit from the component definition`
|
|
3895
3989
|
})
|
|
3896
3990
|
};
|
|
3897
3991
|
var nonLayoutContainerMsg = {
|
|
@@ -4183,44 +4277,97 @@ var missingSizeConstraintDef = {
|
|
|
4183
4277
|
id: "missing-size-constraint",
|
|
4184
4278
|
name: "Missing Size Constraint",
|
|
4185
4279
|
category: "responsive-critical",
|
|
4186
|
-
why: "
|
|
4187
|
-
impact: "
|
|
4188
|
-
fix: "
|
|
4280
|
+
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",
|
|
4281
|
+
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",
|
|
4282
|
+
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
4283
|
};
|
|
4190
|
-
var
|
|
4191
|
-
|
|
4192
|
-
|
|
4193
|
-
|
|
4194
|
-
|
|
4195
|
-
|
|
4196
|
-
|
|
4197
|
-
|
|
4198
|
-
|
|
4199
|
-
|
|
4200
|
-
|
|
4201
|
-
|
|
4202
|
-
|
|
4203
|
-
|
|
4204
|
-
|
|
4205
|
-
|
|
4206
|
-
|
|
4207
|
-
|
|
4208
|
-
|
|
4209
|
-
|
|
4210
|
-
|
|
4284
|
+
var CHAIN_BOUND_KEY = "missing-size-constraint:chain-bound";
|
|
4285
|
+
function getChainBoundCache(context) {
|
|
4286
|
+
return getAnalysisState(context, CHAIN_BOUND_KEY, () => /* @__PURE__ */ new Map());
|
|
4287
|
+
}
|
|
4288
|
+
function establishesOwnWidthBound(node) {
|
|
4289
|
+
if (node.layoutSizingHorizontal === "FIXED") return true;
|
|
4290
|
+
if (node.minWidth !== void 0 || node.maxWidth !== void 0) return true;
|
|
4291
|
+
return false;
|
|
4292
|
+
}
|
|
4293
|
+
function recordChainBound(context, node) {
|
|
4294
|
+
const cache = getChainBoundCache(context);
|
|
4295
|
+
const cached = cache.get(node.id);
|
|
4296
|
+
if (cached !== void 0) return cached;
|
|
4297
|
+
const own = establishesOwnWidthBound(node);
|
|
4298
|
+
const parent = context.parent;
|
|
4299
|
+
const inherited = parent ? cache.get(parent.id) ?? false : false;
|
|
4300
|
+
const result = own || inherited;
|
|
4301
|
+
cache.set(node.id, result);
|
|
4302
|
+
return result;
|
|
4303
|
+
}
|
|
4304
|
+
function parentChainBound(context) {
|
|
4305
|
+
if (!context.parent) return false;
|
|
4306
|
+
return getChainBoundCache(context).get(context.parent.id) ?? false;
|
|
4307
|
+
}
|
|
4308
|
+
var PAGE_CONTAINER_FRAME_TYPES = /* @__PURE__ */ new Set(["FRAME", "SECTION"]);
|
|
4309
|
+
function formatWidth(node) {
|
|
4310
|
+
return node.absoluteBoundingBox ? `${node.absoluteBoundingBox.width}px` : "unknown";
|
|
4311
|
+
}
|
|
4312
|
+
function buildViolation(subType, node, context, msg) {
|
|
4313
|
+
return {
|
|
4314
|
+
ruleId: missingSizeConstraintDef.id,
|
|
4315
|
+
subType,
|
|
4316
|
+
nodeId: node.id,
|
|
4317
|
+
nodePath: context.path.join(" > "),
|
|
4318
|
+
...msg
|
|
4319
|
+
};
|
|
4320
|
+
}
|
|
4321
|
+
function checkComponentScopeRoot(node, context) {
|
|
4322
|
+
if (context.depth !== 0) return null;
|
|
4323
|
+
if (node.layoutSizingHorizontal !== "FIXED") return null;
|
|
4324
|
+
const currentWidth = formatWidth(node);
|
|
4325
|
+
if (context.rootNodeType === "INSTANCE") {
|
|
4326
|
+
return buildViolation(
|
|
4327
|
+
"component-fixed-by-override",
|
|
4328
|
+
node,
|
|
4329
|
+
context,
|
|
4330
|
+
missingSizeConstraintMsg.componentFixedByOverride(node.name, currentWidth)
|
|
4331
|
+
);
|
|
4211
4332
|
}
|
|
4212
|
-
|
|
4213
|
-
|
|
4214
|
-
|
|
4215
|
-
|
|
4216
|
-
|
|
4217
|
-
|
|
4218
|
-
|
|
4219
|
-
|
|
4220
|
-
|
|
4221
|
-
|
|
4333
|
+
return buildViolation(
|
|
4334
|
+
"component-fixed-by-design",
|
|
4335
|
+
node,
|
|
4336
|
+
context,
|
|
4337
|
+
missingSizeConstraintMsg.componentFixedByDesign(node.name, currentWidth)
|
|
4338
|
+
);
|
|
4339
|
+
}
|
|
4340
|
+
function checkPageInstanceFixed(node, context) {
|
|
4341
|
+
if (node.type !== "INSTANCE") return null;
|
|
4342
|
+
if (node.layoutSizingHorizontal !== "FIXED") return null;
|
|
4343
|
+
if (!context.parent || !hasAutoLayout(context.parent)) return null;
|
|
4344
|
+
const currentWidth = formatWidth(node);
|
|
4345
|
+
return buildViolation(
|
|
4346
|
+
"page-instance-fixed",
|
|
4347
|
+
node,
|
|
4348
|
+
context,
|
|
4349
|
+
missingSizeConstraintMsg.pageInstanceFixed(node.name, currentWidth)
|
|
4350
|
+
);
|
|
4351
|
+
}
|
|
4352
|
+
function checkPageContainerUnbound(node, context) {
|
|
4353
|
+
if (!PAGE_CONTAINER_FRAME_TYPES.has(node.type)) return null;
|
|
4354
|
+
if (node.layoutSizingHorizontal !== "FILL") return null;
|
|
4355
|
+
if (parentChainBound(context)) return null;
|
|
4356
|
+
const currentWidth = formatWidth(node);
|
|
4357
|
+
return buildViolation(
|
|
4358
|
+
"page-container-unbound",
|
|
4359
|
+
node,
|
|
4360
|
+
context,
|
|
4361
|
+
missingSizeConstraintMsg.pageContainerUnbound(node.name, currentWidth)
|
|
4362
|
+
);
|
|
4363
|
+
}
|
|
4364
|
+
var missingSizeConstraintCheck = (node, context) => {
|
|
4365
|
+
recordChainBound(context, node);
|
|
4366
|
+
if (context.ancestorTypes.includes("INSTANCE")) return null;
|
|
4367
|
+
if (context.scope === "component") {
|
|
4368
|
+
return checkComponentScopeRoot(node, context);
|
|
4222
4369
|
}
|
|
4223
|
-
return
|
|
4370
|
+
return checkPageInstanceFixed(node, context) ?? checkPageContainerUnbound(node, context);
|
|
4224
4371
|
};
|
|
4225
4372
|
defineRule({
|
|
4226
4373
|
definition: missingSizeConstraintDef,
|
|
@@ -4856,12 +5003,22 @@ function hasStateInComponentMaster(node, context, statePattern) {
|
|
|
4856
5003
|
if (!master) return false;
|
|
4857
5004
|
return hasStateInVariantProps(master, statePattern);
|
|
4858
5005
|
}
|
|
5006
|
+
var VARIANT_POSITION_NAME_RE = /^[\w ]+=[^,]+(,\s*[\w ]+=[^,]+)*$/;
|
|
5007
|
+
function hasUsablePropDefs(propDefs) {
|
|
5008
|
+
return propDefs != null && typeof propDefs === "object";
|
|
5009
|
+
}
|
|
4859
5010
|
function canDetermineVariants(node, context) {
|
|
4860
|
-
if (node.
|
|
4861
|
-
if (node.
|
|
5011
|
+
if (hasUsablePropDefs(node.componentPropertyDefinitions)) return true;
|
|
5012
|
+
if (node.type === "COMPONENT") {
|
|
5013
|
+
return !VARIANT_POSITION_NAME_RE.test(node.name);
|
|
5014
|
+
}
|
|
4862
5015
|
if (node.componentId !== void 0) {
|
|
4863
5016
|
const defs = context.file.componentDefinitions;
|
|
4864
|
-
|
|
5017
|
+
const master = defs?.[node.componentId];
|
|
5018
|
+
if (master) {
|
|
5019
|
+
if (hasUsablePropDefs(master.componentPropertyDefinitions)) return true;
|
|
5020
|
+
return !VARIANT_POSITION_NAME_RE.test(master.name);
|
|
5021
|
+
}
|
|
4865
5022
|
}
|
|
4866
5023
|
return false;
|
|
4867
5024
|
}
|
|
@@ -5000,7 +5157,8 @@ Provide a Figma URL or fixture path via the input parameter. Requires FIGMA_TOKE
|
|
|
5000
5157
|
targetNodeId: z.string().optional().describe("Scope analysis to a specific node ID"),
|
|
5001
5158
|
configPath: z.string().optional().describe("Path to config JSON file for rule overrides"),
|
|
5002
5159
|
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."),
|
|
5003
|
-
acknowledgments: z.array(AcknowledgmentSchema).optional().describe("(#371) Pre-resolved [{ nodeId, ruleId }] pairs harvested from canicode-authored Figma annotations (e.g. via the `readCanicodeAcknowledgments` Plugin helper inside a use_figma batch). Matching issues are flagged `acknowledged: true` and contribute half weight to the density score so re-analyze surfaces movement after a roundtrip even under ADR-012's annotate-by-default policy.")
|
|
5160
|
+
acknowledgments: z.array(AcknowledgmentSchema).optional().describe("(#371) Pre-resolved [{ nodeId, ruleId }] pairs harvested from canicode-authored Figma annotations (e.g. via the `readCanicodeAcknowledgments` Plugin helper inside a use_figma batch). Matching issues are flagged `acknowledged: true` and contribute half weight to the density score so re-analyze surfaces movement after a roundtrip even under ADR-012's annotate-by-default policy."),
|
|
5161
|
+
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`.")
|
|
5004
5162
|
},
|
|
5005
5163
|
{
|
|
5006
5164
|
readOnlyHint: false,
|
|
@@ -5008,7 +5166,7 @@ Provide a Figma URL or fixture path via the input parameter. Requires FIGMA_TOKE
|
|
|
5008
5166
|
openWorldHint: true,
|
|
5009
5167
|
title: "Analyze Figma Design"
|
|
5010
5168
|
},
|
|
5011
|
-
async ({ input, token, preset, targetNodeId, configPath, openReport, acknowledgments }) => {
|
|
5169
|
+
async ({ input, token, preset, targetNodeId, configPath, openReport, acknowledgments, scope }) => {
|
|
5012
5170
|
trackEvent(EVENTS.MCP_TOOL_CALLED, { tool: "analyze" });
|
|
5013
5171
|
try {
|
|
5014
5172
|
const { file, nodeId } = await loadFile(input, token);
|
|
@@ -5021,7 +5179,8 @@ Provide a Figma URL or fixture path via the input parameter. Requires FIGMA_TOKE
|
|
|
5021
5179
|
const result = analyzeFile(file, {
|
|
5022
5180
|
configs,
|
|
5023
5181
|
...effectiveNodeId ? { targetNodeId: effectiveNodeId } : {},
|
|
5024
|
-
...acknowledgments && acknowledgments.length > 0 ? { acknowledgments } : {}
|
|
5182
|
+
...acknowledgments && acknowledgments.length > 0 ? { acknowledgments } : {},
|
|
5183
|
+
...scope ? { scope } : {}
|
|
5025
5184
|
});
|
|
5026
5185
|
const scores = calculateScores(result, configs);
|
|
5027
5186
|
const figmaToken = token ?? process.env["FIGMA_TOKEN"];
|
|
@@ -5088,7 +5247,8 @@ Provide a Figma URL or fixture path via the input parameter. Requires FIGMA_TOKE
|
|
|
5088
5247
|
token: z.string().optional().describe("Figma API token (falls back to FIGMA_TOKEN env var)"),
|
|
5089
5248
|
preset: z.enum(["relaxed", "dev-friendly", "ai-ready", "strict"]).optional().describe("Analysis preset"),
|
|
5090
5249
|
targetNodeId: z.string().optional().describe("Scope analysis to a specific node ID"),
|
|
5091
|
-
configPath: z.string().optional().describe("Path to config JSON file for rule overrides")
|
|
5250
|
+
configPath: z.string().optional().describe("Path to config JSON file for rule overrides"),
|
|
5251
|
+
scope: z.enum(["page", "component"]).optional().describe("(#404) Override analysis scope \u2014 `page` or `component`. Defaults to auto-detection from the root node type.")
|
|
5092
5252
|
},
|
|
5093
5253
|
{
|
|
5094
5254
|
readOnlyHint: false,
|
|
@@ -5096,7 +5256,7 @@ Provide a Figma URL or fixture path via the input parameter. Requires FIGMA_TOKE
|
|
|
5096
5256
|
openWorldHint: true,
|
|
5097
5257
|
title: "Gotcha Survey"
|
|
5098
5258
|
},
|
|
5099
|
-
async ({ input, token, preset, targetNodeId, configPath }) => {
|
|
5259
|
+
async ({ input, token, preset, targetNodeId, configPath, scope }) => {
|
|
5100
5260
|
trackEvent(EVENTS.MCP_TOOL_CALLED, { tool: "gotcha-survey" });
|
|
5101
5261
|
try {
|
|
5102
5262
|
const { file, nodeId } = await loadFile(input, token);
|
|
@@ -5108,7 +5268,8 @@ Provide a Figma URL or fixture path via the input parameter. Requires FIGMA_TOKE
|
|
|
5108
5268
|
}
|
|
5109
5269
|
const result = analyzeFile(file, {
|
|
5110
5270
|
configs,
|
|
5111
|
-
...effectiveNodeId ? { targetNodeId: effectiveNodeId } : {}
|
|
5271
|
+
...effectiveNodeId ? { targetNodeId: effectiveNodeId } : {},
|
|
5272
|
+
...scope ? { scope } : {}
|
|
5112
5273
|
});
|
|
5113
5274
|
const scores = calculateScores(result, configs);
|
|
5114
5275
|
const survey = generateGotchaSurvey(result, scores, { designKey: computeDesignKey(input) });
|