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.
@@ -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
  */
@@ -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.10.5";
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 GOTCHA_QUESTIONS = {
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
- (issue) => issue.config.severity === "blocking" || issue.config.severity === "risk"
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
- maxWidth: (name, currentWidth) => ({
3881
- message: `"${name}" uses FILL width (currently ${currentWidth}) without max-width`,
3882
- suggestion: `Add maxWidth to prevent stretching on large screens`
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
- minWidth: (name, currentWidth) => ({
3885
- message: `"${name}" uses FILL width (currently ${currentWidth}) without min-width`,
3886
- suggestion: `Add minWidth to prevent collapsing on small screens`
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
- wrap: (name) => ({
3889
- message: `"${name}" is in a wrap container without min-width`,
3890
- suggestion: `Add minWidth to control when wrapping occurs`
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
- grid: (name) => ({
3893
- message: `"${name}" is in a grid layout without size constraints`,
3894
- suggestion: `Add min/max-width for proper column sizing`
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: "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"
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 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
- };
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
- 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
- };
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 null;
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 [{ 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.")
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) });