Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
35274af
fix: skip no-auto-layout rule for icon-like frames
claude Mar 26, 2026
201f397
fix: tighten icon exception to single visual child only
claude Mar 26, 2026
920bbbe
fix: allow multiple visual leaf children in auto-layout exception
claude Mar 26, 2026
889fc1b
fix: simplify missing-size-constraint to only enforce maxWidth
claude Mar 26, 2026
3056339
fix: add exceptions for missing-size-constraint rule
claude Mar 26, 2026
ea25465
refactor: extract rule exceptions into rule-exceptions.ts
claude Mar 26, 2026
e4cd785
feat: add remaining rule exceptions
claude Mar 26, 2026
1b6f3ae
fix: exempt image fills from absolute-position rule, fix CI
claude Mar 26, 2026
43afcb1
refactor: remove full-size background check from absolute-position ex…
claude Mar 26, 2026
b991e44
refactor: add isVisualOnlyNode util, unify visual checks
claude Mar 26, 2026
2a5c745
refactor: remove small-decoration and component-internal exceptions
claude Mar 26, 2026
89f1013
fix: remove INSTANCE node exception from auto-layout check
claude Mar 26, 2026
5da9cb5
refactor: move isExcludedName into exempt functions, fix all-FILL sib…
claude Mar 26, 2026
86f8a72
test: add full coverage for isSizeConstraintExempt branches
claude Mar 26, 2026
8704f10
docs: add PR workflow rules to CLAUDE.md
claude Mar 26, 2026
8a896c1
docs: translate PR workflow rules to English
claude Mar 26, 2026
e774f74
docs: clarify rate limit handling in PR workflow
claude Mar 26, 2026
e131e6e
chore: trigger CI
let-sunny Mar 26, 2026
f8c15d9
chore: re-trigger review
claude Mar 26, 2026
c19200f
fix: review fixes — restore small-decoration exempt, remove duplicate…
let-sunny Mar 26, 2026
8861383
refactor: remove size-based small-decoration exception from isAbsolut…
let-sunny Mar 26, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/core/engine/rule-engine.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -279,7 +279,7 @@ describe("RuleEngine.analyze — rule filtering", () => {
describe("RuleEngine.analyze — depth weight calculation", () => {
it("applies higher weight at root level (depthWeight interpolation)", () => {
// no-auto-layout requires FRAME with children to trigger, so every level needs a child
const leaf = makeNode({ id: "leaf:1", name: "Leaf", type: "RECTANGLE" });
const leaf = makeNode({ id: "leaf:1", name: "Leaf", type: "TEXT" });
const grandchild = makeNode({ id: "gc:1", name: "GC Frame", type: "FRAME", children: [leaf] });
const child = makeNode({ id: "c:1", name: "Child Frame", type: "FRAME", children: [grandchild] });
const root = makeNode({
Expand Down
210 changes: 210 additions & 0 deletions src/core/rules/rule-exceptions.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,210 @@
import type { AnalysisNode } from "../contracts/figma-node.js";
import type { RuleContext } from "../contracts/rule.js";
import {
isAutoLayoutExempt,
isAbsolutePositionExempt,
isSizeConstraintExempt,
isFixedSizeExempt,
isVisualOnlyNode,
} from "./rule-exceptions.js";

function makeNode(overrides: Partial<AnalysisNode> = {}): AnalysisNode {
return {
id: "test",
name: "Test",
type: "FRAME",
visible: true,
...overrides,
} as AnalysisNode;
}

function makeContext(overrides: Partial<RuleContext> = {}): RuleContext {
return {
file: {} as RuleContext["file"],
depth: 2,
componentDepth: 0,
maxDepth: 10,
path: ["Root", "Test"],
analysisState: new Map(),
...overrides,
};
}

describe("isVisualOnlyNode", () => {
it("true for vector/shape types", () => {
expect(isVisualOnlyNode(makeNode({ type: "VECTOR" as any }))).toBe(true);
expect(isVisualOnlyNode(makeNode({ type: "ELLIPSE" as any }))).toBe(true);
});

it("true for nodes with image fills", () => {
const node = makeNode({ fills: [{ type: "IMAGE" }] });
expect(isVisualOnlyNode(node)).toBe(true);
});

it("true for frame with only visual leaf children", () => {
const node = makeNode({
children: [makeNode({ type: "VECTOR" as any }), makeNode({ type: "RECTANGLE" as any })],
});
expect(isVisualOnlyNode(node)).toBe(true);
});

it("false for frame with mixed children", () => {
const node = makeNode({
children: [makeNode({ type: "VECTOR" as any }), makeNode({ type: "TEXT" as any })],
});
expect(isVisualOnlyNode(node)).toBe(false);
});

it("false for plain frame without image fills", () => {
const node = makeNode({ fills: [{ type: "SOLID" }] });
expect(isVisualOnlyNode(node)).toBe(false);
});
});

describe("isAutoLayoutExempt", () => {
it("exempts frames with only visual leaf children", () => {
const node = makeNode({
children: [
makeNode({ type: "VECTOR" as any }),
makeNode({ type: "ELLIPSE" as any }),
],
});
expect(isAutoLayoutExempt(node)).toBe(true);
});

it("does not exempt image-filled frames with content children", () => {
const node = makeNode({ fills: [{ type: "IMAGE" }], children: [makeNode({ type: "TEXT" as any })] });
expect(isAutoLayoutExempt(node)).toBe(false);
});

it("does not exempt INSTANCE nodes", () => {
const node = makeNode({ type: "INSTANCE" as any, children: [makeNode()] });
expect(isAutoLayoutExempt(node)).toBe(false);
});

it("does not exempt frames with mixed children", () => {
const node = makeNode({
children: [
makeNode({ type: "VECTOR" as any }),
makeNode({ type: "TEXT" as any }),
],
});
expect(isAutoLayoutExempt(node)).toBe(false);
});
});

describe("isAbsolutePositionExempt", () => {
it("exempts nodes with image fills", () => {
const node = makeNode({
fills: [{ type: "IMAGE", scaleMode: "FILL" }],
});
expect(isAbsolutePositionExempt(node)).toBe(true);
});

it("exempts vector nodes", () => {
const node = makeNode({ type: "VECTOR" as any });
expect(isAbsolutePositionExempt(node)).toBe(true);
});

it("does not exempt plain frame", () => {
const node = makeNode({ fills: [{ type: "SOLID" }] });
expect(isAbsolutePositionExempt(node)).toBe(false);
});
});

describe("isSizeConstraintExempt", () => {
it("exempts when node has maxWidth", () => {
const node = makeNode({ maxWidth: 800 });
expect(isSizeConstraintExempt(node, makeContext())).toBe(true);
});

it("exempts small elements (width <= 200)", () => {
const node = makeNode({
absoluteBoundingBox: { x: 0, y: 0, width: 150, height: 40 },
});
expect(isSizeConstraintExempt(node, makeContext())).toBe(true);
});

it("exempts when parent has maxWidth", () => {
const parent = makeNode({ maxWidth: 1200 });
const node = makeNode({});
expect(isSizeConstraintExempt(node, makeContext({ parent }))).toBe(true);
});

it("exempts root-level frames (depth <= 1)", () => {
const node = makeNode({});
expect(isSizeConstraintExempt(node, makeContext({ depth: 1 }))).toBe(true);
});

it("exempts when all siblings are FILL", () => {
const node = makeNode({ layoutSizingHorizontal: "FILL" as any });
const sibling = makeNode({ layoutSizingHorizontal: "FILL" as any });
const ctx = makeContext({ siblings: [node, sibling] });
expect(isSizeConstraintExempt(node, ctx)).toBe(true);
});

it("does not exempt when siblings have mixed sizing", () => {
const node = makeNode({
absoluteBoundingBox: { x: 0, y: 0, width: 400, height: 100 },
layoutSizingHorizontal: "FILL" as any,
});
const sibling = makeNode({ layoutSizingHorizontal: "FIXED" as any });
const ctx = makeContext({ siblings: [node, sibling] });
expect(isSizeConstraintExempt(node, ctx)).toBe(false);
});

it("exempts inside GRID layout", () => {
const parent = makeNode({ layoutMode: "GRID" as any });
const node = makeNode({
absoluteBoundingBox: { x: 0, y: 0, width: 400, height: 100 },
layoutSizingHorizontal: "FILL" as any,
});
const sibling = makeNode({ layoutSizingHorizontal: "FIXED" as any });
expect(isSizeConstraintExempt(node, makeContext({ parent, siblings: [node, sibling] }))).toBe(true);
});

it("exempts inside flex wrap", () => {
const parent = makeNode({ layoutWrap: "WRAP" as any });
const node = makeNode({
absoluteBoundingBox: { x: 0, y: 0, width: 400, height: 100 },
layoutSizingHorizontal: "FILL" as any,
});
const sibling = makeNode({ layoutSizingHorizontal: "FIXED" as any });
expect(isSizeConstraintExempt(node, makeContext({ parent, siblings: [node, sibling] }))).toBe(true);
});

it("exempts TEXT nodes", () => {
const parent = makeNode({ layoutMode: "HORIZONTAL" as any });
const node = makeNode({ type: "TEXT" as any, layoutSizingHorizontal: "FILL" as any });
const ctx = makeContext({
parent,
siblings: [node, makeNode({ layoutSizingHorizontal: "FIXED" as any })],
});
expect(isSizeConstraintExempt(node, ctx)).toBe(true);
});
});

describe("isFixedSizeExempt", () => {
it("exempts small elements (icons)", () => {
const node = makeNode({
absoluteBoundingBox: { x: 0, y: 0, width: 24, height: 24 },
});
expect(isFixedSizeExempt(node)).toBe(true);
});

it("exempts nodes with image fills", () => {
const node = makeNode({
absoluteBoundingBox: { x: 0, y: 0, width: 200, height: 200 },
fills: [{ type: "IMAGE", scaleMode: "FILL" }],
});
expect(isFixedSizeExempt(node)).toBe(true);
});

it("does not exempt large nodes without image fills", () => {
const node = makeNode({
absoluteBoundingBox: { x: 0, y: 0, width: 200, height: 200 },
fills: [{ type: "SOLID", color: { r: 1, g: 0, b: 0, a: 1 } }],
});
expect(isFixedSizeExempt(node)).toBe(false);
});
});
124 changes: 124 additions & 0 deletions src/core/rules/rule-exceptions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
import type { AnalysisNode } from "../contracts/figma-node.js";
import type { RuleContext } from "../contracts/rule.js";
import { isExcludedName } from "./excluded-names.js";

// ============================================
// Shared node type helpers
// ============================================

const VISUAL_LEAF_TYPES = new Set([
"VECTOR", "BOOLEAN_OPERATION", "ELLIPSE", "LINE", "STAR", "REGULAR_POLYGON", "RECTANGLE",
]);

export function isVisualLeafType(type: string): boolean {
return VISUAL_LEAF_TYPES.has(type);
}

/** Node has an IMAGE type fill */
export function hasImageFill(node: AnalysisNode): boolean {
if (!Array.isArray(node.fills)) return false;
return node.fills.some(
(fill) =>
typeof fill === "object" &&
fill !== null &&
(fill as { type?: unknown }).type === "IMAGE",
);
}

/**
* Node is purely visual — not a layout container.
* True when: vector/shape type, has image fill, or frame with only visual leaf children.
*/
export function isVisualOnlyNode(node: AnalysisNode): boolean {
if (VISUAL_LEAF_TYPES.has(node.type)) return true;
if (hasImageFill(node)) return true;
if (node.children && node.children.length > 0 && node.children.every((c) => VISUAL_LEAF_TYPES.has(c.type))) return true;
return false;
}




// ============================================
// Auto-layout exceptions
// ============================================

/** Frames that don't need auto-layout (only visual-leaf children like icon paths) */
export function isAutoLayoutExempt(node: AnalysisNode): boolean {
if (
node.children &&
node.children.length > 0 &&
node.children.every((c) => VISUAL_LEAF_TYPES.has(c.type))
) return true;

return false;
Comment on lines +47 to +54
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

no-auto-layout exemption is both too broad and too narrow.

Using isVisualOnlyNode() here exempts any image-filled frame, including containers that still have real content children, while plain INSTANCE nodes can still fall through into the overlap/nested-container checks. That reverses the PR goal: icon-like visual-child frames and all instances should skip, not every image-backed container.

🎯 Proposed fix
 export function isAutoLayoutExempt(node: AnalysisNode): boolean {
-  if (isVisualOnlyNode(node)) return true;
-
-  return false;
+  if (node.type === "INSTANCE") return true;
+  if (
+    node.type === "FRAME" &&
+    node.children &&
+    node.children.length > 0 &&
+    node.children.every((child) => isVisualLeafType(child.type))
+  ) {
+    return true;
+  }
+  return false;
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/core/rules/rule-exceptions.ts` around lines 45 - 48, The exemption logic
in isAutoLayoutExempt is incorrect: replace the broad isVisualOnlyNode(node)
check with a rule that returns true for INSTANCE nodes and for image-backed
frames that are truly visual-only (i.e., image/icon-like with no meaningful
content children). Update isAutoLayoutExempt to: return true if node.type ===
'INSTANCE' OR (isVisualOnlyNode(node) AND the node has no non-visual/content
children), using the AnalysisNode children metadata to detect real content
children; keep all other nodes falling through to the overlap/nested-container
checks.

}

// ============================================
// Absolute-position exceptions
// ============================================

/** Nodes that are allowed to use absolute positioning inside auto-layout */
export function isAbsolutePositionExempt(node: AnalysisNode): boolean {
if (isVisualOnlyNode(node)) return true;

// Intentional name patterns (badge, close, overlay, etc.)
if (isExcludedName(node.name)) return true;

return false;
}

// ============================================
// Size-constraint exceptions
// ============================================

/** Nodes that don't need maxWidth even with FILL sizing */
export function isSizeConstraintExempt(node: AnalysisNode, context: RuleContext): boolean {
// Already has maxWidth
if (node.maxWidth !== undefined) return true;

// Small elements — won't stretch problematically
if (node.absoluteBoundingBox && node.absoluteBoundingBox.width <= 200) return true;

// Parent already has maxWidth — parent constrains the stretch
if (context.parent?.maxWidth !== undefined) return true;

// Root-level frames — they represent the screen itself
if (context.depth <= 1) return true;

// All siblings are FILL (e.g. single item or list view) — parent controls the width
if (context.siblings && context.siblings.length > 0) {
if (context.siblings.every((s) => s.layoutSizingHorizontal === "FILL")) return true;
}

// Inside grid layout — grid controls sizing
if (context.parent?.layoutMode === "GRID") return true;

// Inside flex wrap — wrap layout controls sizing per row
if (context.parent?.layoutWrap === "WRAP") return true;

// Text nodes — content length provides natural sizing
if (node.type === "TEXT") return true;

return false;
}
Comment on lines +75 to +104
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Potential false exemption when siblings is undefined.

When context.siblings is undefined (which can happen per the RuleContext interface), the fillSiblings filter block is skipped entirely. However, this means a node that should be flagged (e.g., multiple FILL siblings exist but siblings wasn't populated) could incorrectly pass through to later checks and potentially get exempted by other conditions.

Consider whether this is the intended behavior. If siblings being undefined should not grant an exemption, you may want to only apply this exemption when siblings are explicitly provided and the condition is met.

Suggested clarification
   // Only FILL child among siblings — intent is to fill the parent entirely
-  if (context.siblings) {
+  if (context.siblings && context.siblings.length > 0) {
     const fillSiblings = context.siblings.filter((s) => s.layoutSizingHorizontal === "FILL");
     if (fillSiblings.length <= 1) return true;
   }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/core/rules/rule-exceptions.ts` around lines 65 - 95, The sibling-based
exemption in isSizeConstraintExempt must only apply when siblings are explicitly
provided: replace the loose truthy check with an explicit
Array.isArray(context.siblings) guard and compute fillSiblings from that array
(filter by layoutSizingHorizontal === "FILL"); only return true when that array
exists and fillSiblings.length <= 1 so an undefined siblings field does not
implicitly grant an exemption. Ensure you reference the existing symbols:
isSizeConstraintExempt, context.siblings, fillSiblings, and
layoutSizingHorizontal.


// ============================================
// Fixed-size exceptions
// ============================================

/** Nodes that are allowed to use fixed sizing inside auto-layout */
export function isFixedSizeExempt(node: AnalysisNode): boolean {
// Small fixed elements (icons, avatars) — intentionally fixed
if (node.absoluteBoundingBox) {
const { width, height } = node.absoluteBoundingBox;
if (width <= 48 && height <= 48) return true;
}

if (isVisualOnlyNode(node)) return true;

// Excluded names (nav, header, etc.)
if (isExcludedName(node.name)) return true;

return false;
}
Loading
Loading