canicode 0.10.5 → 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.
@@ -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
- severity: "risk",
390
- score: -8,
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: "suggestion",
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: -3,
446
- enabled: false
447
- // disabled: interactionDestinations data missing from fixtures (#139)
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
- default: [{ type: "width" }, { type: "height" }]
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.10.5";
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 GOTCHA_QUESTIONS = {
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
- (issue) => issue.config.severity === "blocking" || issue.config.severity === "risk"
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
- maxWidth: (name, currentWidth) => ({
3881
- message: `"${name}" uses FILL width (currently ${currentWidth}) without max-width`,
3882
- suggestion: `Add maxWidth to prevent stretching on large screens`
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
- minWidth: (name, currentWidth) => ({
3885
- message: `"${name}" uses FILL width (currently ${currentWidth}) without min-width`,
3886
- suggestion: `Add minWidth to prevent collapsing on small screens`
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
- wrap: (name) => ({
3889
- message: `"${name}" is in a wrap container without min-width`,
3890
- suggestion: `Add minWidth to control when wrapping occurs`
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
- grid: (name) => ({
3893
- message: `"${name}" is in a grid layout without size constraints`,
3894
- suggestion: `Add min/max-width for proper column sizing`
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: "Without min/max-width, AI has no bounds \u2014 generated code may collapse or stretch indefinitely",
4187
- impact: "Content becomes unreadable or invisible at extreme screen sizes",
4188
- fix: "Set min-width and/or max-width so AI can generate proper size constraints"
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 missingSizeConstraintCheck = (node, context) => {
4191
- if (!isContainerNode(node) && !hasTextContent(node)) return null;
4192
- if (!context.parent || !hasAutoLayout(context.parent)) return null;
4193
- const nodePath = context.path.join(" > ");
4194
- if (context.parent.layoutWrap === "WRAP" && node.layoutSizingHorizontal === "FILL" && node.minWidth === void 0) {
4195
- return {
4196
- ruleId: missingSizeConstraintDef.id,
4197
- subType: "wrap",
4198
- nodeId: node.id,
4199
- nodePath,
4200
- ...missingSizeConstraintMsg.wrap(node.name)
4201
- };
4202
- }
4203
- if (context.parent.layoutMode === "GRID" && node.layoutSizingHorizontal === "FILL" && node.minWidth === void 0 && node.maxWidth === void 0) {
4204
- return {
4205
- ruleId: missingSizeConstraintDef.id,
4206
- subType: "grid",
4207
- nodeId: node.id,
4208
- nodePath,
4209
- ...missingSizeConstraintMsg.grid(node.name)
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
- if (node.layoutSizingHorizontal === "FILL") {
4213
- if (isSizeConstraintExempt(node, context)) return null;
4214
- const currentWidth = node.absoluteBoundingBox ? `${node.absoluteBoundingBox.width}px` : "unknown";
4215
- return {
4216
- ruleId: missingSizeConstraintDef.id,
4217
- subType: "max-width",
4218
- nodeId: node.id,
4219
- nodePath,
4220
- ...missingSizeConstraintMsg.maxWidth(node.name, currentWidth)
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 null;
4370
+ return checkPageInstanceFixed(node, context) ?? checkPageContainerUnbound(node, context);
4224
4371
  };
4225
4372
  defineRule({
4226
4373
  definition: missingSizeConstraintDef,
@@ -5010,7 +5157,8 @@ Provide a Figma URL or fixture path via the input parameter. Requires FIGMA_TOKE
5010
5157
  targetNodeId: z.string().optional().describe("Scope analysis to a specific node ID"),
5011
5158
  configPath: z.string().optional().describe("Path to config JSON file for rule overrides"),
5012
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."),
5013
- 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`.")
5014
5162
  },
5015
5163
  {
5016
5164
  readOnlyHint: false,
@@ -5018,7 +5166,7 @@ Provide a Figma URL or fixture path via the input parameter. Requires FIGMA_TOKE
5018
5166
  openWorldHint: true,
5019
5167
  title: "Analyze Figma Design"
5020
5168
  },
5021
- async ({ input, token, preset, targetNodeId, configPath, openReport, acknowledgments }) => {
5169
+ async ({ input, token, preset, targetNodeId, configPath, openReport, acknowledgments, scope }) => {
5022
5170
  trackEvent(EVENTS.MCP_TOOL_CALLED, { tool: "analyze" });
5023
5171
  try {
5024
5172
  const { file, nodeId } = await loadFile(input, token);
@@ -5031,7 +5179,8 @@ Provide a Figma URL or fixture path via the input parameter. Requires FIGMA_TOKE
5031
5179
  const result = analyzeFile(file, {
5032
5180
  configs,
5033
5181
  ...effectiveNodeId ? { targetNodeId: effectiveNodeId } : {},
5034
- ...acknowledgments && acknowledgments.length > 0 ? { acknowledgments } : {}
5182
+ ...acknowledgments && acknowledgments.length > 0 ? { acknowledgments } : {},
5183
+ ...scope ? { scope } : {}
5035
5184
  });
5036
5185
  const scores = calculateScores(result, configs);
5037
5186
  const figmaToken = token ?? process.env["FIGMA_TOKEN"];
@@ -5098,7 +5247,8 @@ Provide a Figma URL or fixture path via the input parameter. Requires FIGMA_TOKE
5098
5247
  token: z.string().optional().describe("Figma API token (falls back to FIGMA_TOKEN env var)"),
5099
5248
  preset: z.enum(["relaxed", "dev-friendly", "ai-ready", "strict"]).optional().describe("Analysis preset"),
5100
5249
  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")
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.")
5102
5252
  },
5103
5253
  {
5104
5254
  readOnlyHint: false,
@@ -5106,7 +5256,7 @@ Provide a Figma URL or fixture path via the input parameter. Requires FIGMA_TOKE
5106
5256
  openWorldHint: true,
5107
5257
  title: "Gotcha Survey"
5108
5258
  },
5109
- async ({ input, token, preset, targetNodeId, configPath }) => {
5259
+ async ({ input, token, preset, targetNodeId, configPath, scope }) => {
5110
5260
  trackEvent(EVENTS.MCP_TOOL_CALLED, { tool: "gotcha-survey" });
5111
5261
  try {
5112
5262
  const { file, nodeId } = await loadFile(input, token);
@@ -5118,7 +5268,8 @@ Provide a Figma URL or fixture path via the input parameter. Requires FIGMA_TOKE
5118
5268
  }
5119
5269
  const result = analyzeFile(file, {
5120
5270
  configs,
5121
- ...effectiveNodeId ? { targetNodeId: effectiveNodeId } : {}
5271
+ ...effectiveNodeId ? { targetNodeId: effectiveNodeId } : {},
5272
+ ...scope ? { scope } : {}
5122
5273
  });
5123
5274
  const scores = calculateScores(result, configs);
5124
5275
  const survey = generateGotchaSurvey(result, scores, { designKey: computeDesignKey(input) });