From 7eb5a2f8c7f9e05eb50520b42822b801e4302ee9 Mon Sep 17 00:00:00 2001 From: let-sunny Date: Wed, 25 Mar 2026 23:46:45 +0900 Subject: [PATCH 01/12] =?UTF-8?q?feat:=20implement=203=20stub=20rules=20?= =?UTF-8?q?=E2=80=94=20overflow-hidden-abuse,=20component-property-unused,?= =?UTF-8?q?=20prototype-link-in-design=20(#74)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit All 3 rules were registered but always returned null (stub). Now they have real detection logic: 1. overflow-hidden-abuse: flags non-auto-layout containers with clipsContent that have children — likely hiding overflow instead of fixing layout. Skips auto-layout frames, small elements (icons). 2. component-property-unused: checks INSTANCE nodes against their master component's propertyDefinitions. Flags instances that don't customize any available properties. 3. prototype-link-in-design (REDEFINED): was "flag if interactions exist" (useless). Now: "flag interactive-looking elements that LACK prototype interactions". Detects by name patterns (button, link, tab, CTA, etc.) and component state variants (hover, pressed, active). Also adds `interactions` field to AnalysisNode schema and collects it in figma-transformer. Tests: 648 → 664 (+16), todo: 6 → 3. Closes #74 Co-Authored-By: Claude Opus 4.6 (1M context) --- src/core/adapters/figma-transformer.ts | 5 ++ src/core/contracts/figma-node.ts | 3 + .../component-property-unused.test.ts | 50 ++++++++++++-- src/core/rules/component/index.ts | 30 +++++++-- src/core/rules/handoff-risk/index.ts | 65 ++++++++++++++++--- .../prototype-link-in-design.test.ts | 60 ++++++++++++++++- src/core/rules/layout/index.ts | 27 +++++++- .../layout/overflow-hidden-abuse.test.ts | 54 ++++++++++++++- 8 files changed, 267 insertions(+), 27 deletions(-) diff --git a/src/core/adapters/figma-transformer.ts b/src/core/adapters/figma-transformer.ts index 967db7d2..7bb7e5dc 100644 --- a/src/core/adapters/figma-transformer.ts +++ b/src/core/adapters/figma-transformer.ts @@ -213,6 +213,11 @@ function transformNode(node: Node): AnalysisNode { base.devStatus = node.devStatus as AnalysisNode["devStatus"]; } + // Prototype interactions + if ("interactions" in node && Array.isArray(node.interactions) && node.interactions.length > 0) { + base.interactions = node.interactions; + } + // Recursively transform children if ("children" in node && Array.isArray(node.children)) { base.children = node.children.map(transformNode); diff --git a/src/core/contracts/figma-node.ts b/src/core/contracts/figma-node.ts index 392cba7b..65809d07 100644 --- a/src/core/contracts/figma-node.ts +++ b/src/core/contracts/figma-node.ts @@ -163,6 +163,9 @@ const BaseAnalysisNodeSchema = z.object({ }) .optional(), + // Prototype interactions + interactions: z.array(z.unknown()).optional(), + // Naming analysis metadata isAsset: z.boolean().optional(), }); diff --git a/src/core/rules/component/component-property-unused.test.ts b/src/core/rules/component/component-property-unused.test.ts index c1a64b5a..f20a85cf 100644 --- a/src/core/rules/component/component-property-unused.test.ts +++ b/src/core/rules/component/component-property-unused.test.ts @@ -1,26 +1,64 @@ import { makeNode, makeFile, makeContext } from "../test-helpers.js"; +import type { AnalysisFile } from "../../contracts/figma-node.js"; import { componentPropertyUnused } from "./index.js"; +function makeFileWithComponentDefs(defs: Record): AnalysisFile { + return { + ...makeFile(), + componentDefinitions: { + "comp:1": makeNode({ + id: "comp:1", + type: "COMPONENT", + name: "Button", + componentPropertyDefinitions: defs, + }), + }, + }; +} + describe("component-property-unused", () => { it("has correct rule definition metadata", () => { expect(componentPropertyUnused.definition.id).toBe("component-property-unused"); expect(componentPropertyUnused.definition.category).toBe("component"); }); - it("returns null for non-component nodes", () => { + it("returns null for non-INSTANCE nodes", () => { const node = makeNode({ type: "FRAME" }); expect(componentPropertyUnused.check(node, makeContext())).toBeNull(); }); - it("returns null for components without property definitions", () => { - const node = makeNode({ type: "COMPONENT" }); + it("returns null for instance without componentId", () => { + const node = makeNode({ type: "INSTANCE" }); expect(componentPropertyUnused.check(node, makeContext())).toBeNull(); }); - it("returns null for components with empty property definitions", () => { - const node = makeNode({ type: "COMPONENT", componentPropertyDefinitions: {} }); + it("returns null when component has no property definitions", () => { + const file = makeFileWithComponentDefs({}); + const node = makeNode({ type: "INSTANCE", componentId: "comp:1" }); + expect(componentPropertyUnused.check(node, makeContext({ file }))).toBeNull(); + }); + + it("returns null when no componentDefinitions in file", () => { + const node = makeNode({ type: "INSTANCE", componentId: "comp:1" }); expect(componentPropertyUnused.check(node, makeContext())).toBeNull(); }); - it.todo("flags component with unused property bindings (binding check not yet implemented)"); + it("flags instance that does not customize any properties", () => { + const file = makeFileWithComponentDefs({ label: { type: "TEXT" }, icon: { type: "BOOLEAN" } }); + const node = makeNode({ type: "INSTANCE", name: "MyButton", componentId: "comp:1" }); + const result = componentPropertyUnused.check(node, makeContext({ file })); + expect(result).not.toBeNull(); + expect(result!.ruleId).toBe("component-property-unused"); + expect(result!.message).toContain("2 available component properties"); + }); + + it("returns null when instance has property overrides", () => { + const file = makeFileWithComponentDefs({ label: { type: "TEXT" } }); + const node = makeNode({ + type: "INSTANCE", + componentId: "comp:1", + componentProperties: { label: { value: "Submit" } }, + }); + expect(componentPropertyUnused.check(node, makeContext({ file }))).toBeNull(); + }); }); diff --git a/src/core/rules/component/index.ts b/src/core/rules/component/index.ts index 5ce9ff99..30aa16c0 100644 --- a/src/core/rules/component/index.ts +++ b/src/core/rules/component/index.ts @@ -355,17 +355,33 @@ const componentPropertyUnusedDef: RuleDefinition = { fix: "Connect the value to a component property", }; -const componentPropertyUnusedCheck: RuleCheckFn = (node, _context) => { - if (!isComponent(node)) return null; +const componentPropertyUnusedCheck: RuleCheckFn = (node, context) => { + // Check instances: does this instance override any component properties? + if (node.type !== "INSTANCE") return null; + if (!node.componentId) return null; + + // Look up the component's property definitions from componentDefinitions + const compDefs = context.file.componentDefinitions; + if (!compDefs) return null; - // Check if component has property definitions but children don't use them - if (!node.componentPropertyDefinitions) return null; + const masterDef = compDefs[node.componentId]; + if (!masterDef) return null; + if (!masterDef.componentPropertyDefinitions) return null; - const definedProps = Object.keys(node.componentPropertyDefinitions); + const definedProps = Object.keys(masterDef.componentPropertyDefinitions); if (definedProps.length === 0) return null; - // This would require checking if properties are actually bound - // Simplified for now + // Check if the instance has any property overrides + const instanceProps = node.componentProperties; + if (!instanceProps || Object.keys(instanceProps).length === 0) { + return { + ruleId: componentPropertyUnusedDef.id, + nodeId: node.id, + nodePath: context.path.join(" > "), + message: `Instance "${node.name}" does not customize any of ${definedProps.length} available component properties`, + }; + } + return null; }; diff --git a/src/core/rules/handoff-risk/index.ts b/src/core/rules/handoff-risk/index.ts index 50dca594..a47b91d6 100644 --- a/src/core/rules/handoff-risk/index.ts +++ b/src/core/rules/handoff-risk/index.ts @@ -157,17 +157,66 @@ export const imageNoPlaceholder = defineRule({ const prototypeLinkInDesignDef: RuleDefinition = { id: "prototype-link-in-design", - name: "Prototype Link in Design", + name: "Missing Prototype Interaction", category: "handoff-risk", - why: "Prototype connections may affect how the design is interpreted", - impact: "Developers may misunderstand which elements should be interactive", - fix: "Document interactions separately or use clear naming", + why: "Interactive-looking elements without prototype interactions force developers to guess behavior", + impact: "Developers cannot know the intended interaction (hover state, navigation, etc.)", + fix: "Add prototype interactions to interactive elements, or use naming to clarify non-interactive intent", }; -const prototypeLinkInDesignCheck: RuleCheckFn = (_node, _context) => { - // This would require checking prototype/interaction data - // Not available in basic node structure - needs more Figma API data - return null; +/** Name patterns that suggest an interactive element */ +const INTERACTIVE_NAME_PATTERNS = [ + /\bbtn\b/i, /\bbutton\b/i, /\blink\b/i, /\btab\b/i, + /\bcta\b/i, /\btoggle\b/i, /\bswitch\b/i, /\bcheckbox\b/i, + /\bradio\b/i, /\bdropdown\b/i, /\bselect\b/i, /\bmenu\b/i, + /\bnav\b/i, /\bclickable\b/i, /\btappable\b/i, +]; + +/** Variant names that imply interactive states */ +const STATE_VARIANT_PATTERNS = [ + /\bhover\b/i, /\bpressed\b/i, /\bactive\b/i, /\bfocused\b/i, + /\bdisabled\b/i, /\bselected\b/i, +]; + +function looksInteractive(node: AnalysisNode): boolean { + // Check name patterns + if (node.name && INTERACTIVE_NAME_PATTERNS.some((p) => p.test(node.name))) { + return true; + } + + // Check if component has state variants (hover, pressed, etc.) + if (node.componentPropertyDefinitions) { + const propValues = Object.values(node.componentPropertyDefinitions); + for (const prop of propValues) { + const p = prop as Record; + // VARIANT type properties with state-like values + if (p["type"] === "VARIANT" && p["variantOptions"]) { + const options = p["variantOptions"] as string[]; + if (options.some((opt) => STATE_VARIANT_PATTERNS.some((pat) => pat.test(opt)))) { + return true; + } + } + } + } + + return false; +} + +const prototypeLinkInDesignCheck: RuleCheckFn = (node, context) => { + // Only check components and instances (interactive elements are typically components) + if (node.type !== "COMPONENT" && node.type !== "INSTANCE" && node.type !== "FRAME") return null; + + if (!looksInteractive(node)) return null; + + // If interactions exist, the element has prototype behavior defined + if (node.interactions && node.interactions.length > 0) return null; + + return { + ruleId: prototypeLinkInDesignDef.id, + nodeId: node.id, + nodePath: context.path.join(" > "), + message: `"${node.name}" looks interactive but has no prototype interactions defined`, + }; }; export const prototypeLinkInDesign = defineRule({ diff --git a/src/core/rules/handoff-risk/prototype-link-in-design.test.ts b/src/core/rules/handoff-risk/prototype-link-in-design.test.ts index 1fc5247b..8022a6fc 100644 --- a/src/core/rules/handoff-risk/prototype-link-in-design.test.ts +++ b/src/core/rules/handoff-risk/prototype-link-in-design.test.ts @@ -1,11 +1,67 @@ import { makeNode, makeFile, makeContext } from "../test-helpers.js"; import { prototypeLinkInDesign } from "./index.js"; -describe("prototype-link-in-design", () => { +describe("prototype-link-in-design (missing prototype interaction)", () => { it("has correct rule definition metadata", () => { expect(prototypeLinkInDesign.definition.id).toBe("prototype-link-in-design"); expect(prototypeLinkInDesign.definition.category).toBe("handoff-risk"); }); - it.todo("flags nodes with prototype/interaction links (AnalysisNode does not yet model interaction data)"); + it("flags button-named element without interactions", () => { + const node = makeNode({ type: "COMPONENT", name: "Button Primary" }); + const result = prototypeLinkInDesign.check(node, makeContext()); + expect(result).not.toBeNull(); + expect(result!.ruleId).toBe("prototype-link-in-design"); + expect(result!.message).toContain("looks interactive"); + }); + + it("flags element with interactive name patterns", () => { + for (const name of ["Submit Btn", "Nav Link", "Tab Item", "CTA", "Toggle Switch"]) { + const node = makeNode({ type: "FRAME", name }); + expect(prototypeLinkInDesign.check(node, makeContext())).not.toBeNull(); + } + }); + + it("returns null for button with interactions defined", () => { + const node = makeNode({ + type: "COMPONENT", + name: "Button", + interactions: [{ trigger: { type: "ON_CLICK" }, actions: [{ type: "NAVIGATE" }] }], + }); + expect(prototypeLinkInDesign.check(node, makeContext())).toBeNull(); + }); + + it("returns null for non-interactive names", () => { + const node = makeNode({ type: "FRAME", name: "Card Header" }); + expect(prototypeLinkInDesign.check(node, makeContext())).toBeNull(); + }); + + it("returns null for non-container/non-component nodes", () => { + const node = makeNode({ type: "TEXT", name: "Button Label" }); + expect(prototypeLinkInDesign.check(node, makeContext())).toBeNull(); + }); + + it("flags component with state variants but no interactions", () => { + const node = makeNode({ + type: "COMPONENT", + name: "Chip", + componentPropertyDefinitions: { + State: { type: "VARIANT", variantOptions: ["default", "hover", "pressed"] }, + }, + }); + const result = prototypeLinkInDesign.check(node, makeContext()); + expect(result).not.toBeNull(); + expect(result!.message).toContain("looks interactive"); + }); + + it("returns null for component with non-state variants", () => { + const node = makeNode({ + type: "COMPONENT", + name: "Icon", + componentPropertyDefinitions: { + Size: { type: "VARIANT", variantOptions: ["small", "medium", "large"] }, + }, + }); + expect(prototypeLinkInDesign.check(node, makeContext())).toBeNull(); + }); }); diff --git a/src/core/rules/layout/index.ts b/src/core/rules/layout/index.ts index 25922ddf..1431fb90 100644 --- a/src/core/rules/layout/index.ts +++ b/src/core/rules/layout/index.ts @@ -400,9 +400,30 @@ const overflowHiddenAbuseDef: RuleDefinition = { fix: "Fix the underlying layout issue instead of hiding overflow", }; -const overflowHiddenAbuseCheck: RuleCheckFn = (_node, _context) => { - // This would check for clipsContent property - // Simplified for now - needs more Figma API data +const overflowHiddenAbuseCheck: RuleCheckFn = (node, context) => { + if (!isContainerNode(node)) return null; + if (!node.clipsContent) return null; + + // Clip content on auto-layout frames is normal (scrollable containers, cards) + if (hasAutoLayout(node)) return null; + + // Clip content on small elements (icons, avatars) is expected + if (node.absoluteBoundingBox) { + const { width, height } = node.absoluteBoundingBox; + if (width <= 48 && height <= 48) return null; + } + + // Non-auto-layout container with clipsContent = suspicious + // It may be hiding children that overflow due to missing auto-layout + if (node.children && node.children.length > 0) { + return { + ruleId: overflowHiddenAbuseDef.id, + nodeId: node.id, + nodePath: context.path.join(" > "), + message: `"${node.name}" uses clip content without Auto Layout — may be hiding overflow instead of fixing layout`, + }; + } + return null; }; diff --git a/src/core/rules/layout/overflow-hidden-abuse.test.ts b/src/core/rules/layout/overflow-hidden-abuse.test.ts index 4b4db4bc..248db2b0 100644 --- a/src/core/rules/layout/overflow-hidden-abuse.test.ts +++ b/src/core/rules/layout/overflow-hidden-abuse.test.ts @@ -7,5 +7,57 @@ describe("overflow-hidden-abuse", () => { expect(overflowHiddenAbuse.definition.category).toBe("layout"); }); - it.todo("flags frames with clipsContent hiding overflow instead of fixing layout (detection logic not yet implemented)"); + it("flags non-auto-layout container with clipsContent and children", () => { + const node = makeNode({ + type: "FRAME", + name: "ClippedFrame", + clipsContent: true, + children: [makeNode({ id: "c:1" })], + }); + const result = overflowHiddenAbuse.check(node, makeContext()); + expect(result).not.toBeNull(); + expect(result!.ruleId).toBe("overflow-hidden-abuse"); + expect(result!.message).toContain("ClippedFrame"); + }); + + it("returns null for auto-layout container with clipsContent", () => { + const node = makeNode({ + type: "FRAME", + clipsContent: true, + layoutMode: "VERTICAL", + children: [makeNode({ id: "c:1" })], + }); + expect(overflowHiddenAbuse.check(node, makeContext())).toBeNull(); + }); + + it("returns null for container without clipsContent", () => { + const node = makeNode({ + type: "FRAME", + children: [makeNode({ id: "c:1" })], + }); + expect(overflowHiddenAbuse.check(node, makeContext())).toBeNull(); + }); + + it("returns null for small elements (icons/avatars)", () => { + const node = makeNode({ + type: "FRAME", + clipsContent: true, + absoluteBoundingBox: { x: 0, y: 0, width: 24, height: 24 }, + children: [makeNode({ id: "c:1" })], + }); + expect(overflowHiddenAbuse.check(node, makeContext())).toBeNull(); + }); + + it("returns null for non-container nodes", () => { + const node = makeNode({ type: "TEXT", clipsContent: true }); + expect(overflowHiddenAbuse.check(node, makeContext())).toBeNull(); + }); + + it("returns null for empty container with clipsContent", () => { + const node = makeNode({ + type: "FRAME", + clipsContent: true, + }); + expect(overflowHiddenAbuse.check(node, makeContext())).toBeNull(); + }); }); From f34fa430b9e69c32606b194944b4c755b2ec8647 Mon Sep 17 00:00:00 2001 From: let-sunny Date: Wed, 25 Mar 2026 23:56:15 +0900 Subject: [PATCH 02/12] refactor: remove overflow-hidden-abuse and component-property-unused rules MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both rules measured "design hygiene" rather than "AI implementation difficulty" — the core question this tool answers. - overflow-hidden-abuse: AI implements overflow:hidden trivially - component-property-unused: default property values are often intentional Removed from: rule definitions, rule-config, RuleId type, scoring constants (Layout 11→10, Component 6→5), tests, README, REFERENCE.md. 37 rules remain (was 39). Co-Authored-By: Claude Opus 4.6 (1M context) --- README.md | 6 +- docs/REFERENCE.md | 6 +- src/core/contracts/rule.ts | 4 +- src/core/engine/scoring.test.ts | 4 +- src/core/engine/scoring.ts | 4 +- .../component-property-unused.test.ts | 64 ------------------- src/core/rules/component/index.ts | 48 -------------- src/core/rules/layout/index.ts | 45 ------------- .../layout/overflow-hidden-abuse.test.ts | 63 ------------------ src/core/rules/rule-config.ts | 10 --- 10 files changed, 10 insertions(+), 244 deletions(-) delete mode 100644 src/core/rules/component/component-property-unused.test.ts delete mode 100644 src/core/rules/layout/overflow-hidden-abuse.test.ts diff --git a/README.md b/README.md index d5383e97..a0913712 100644 --- a/README.md +++ b/README.md @@ -30,13 +30,13 @@ ## How It Works -40 rules. 6 categories. Every node in the Figma tree. +38 rules. 6 categories. Every node in the Figma tree. | Category | Rules | What it checks | |----------|-------|----------------| | Layout | 10 | Auto-layout usage, responsive behavior | | Design Token | 7 | Color/font/shadow tokenization, spacing consistency | -| Component | 6 | Component reuse, detached instances, variant coverage | +| Component | 5 | Component reuse, detached instances, variant coverage | | Naming | 5 | Semantic names, default names, naming conventions | | AI Readability | 5 | Structure clarity, z-index reliance, empty frames | | Handoff Risk | 6 | Hardcoded values, truncation handling, placeholder images, deep nesting | @@ -229,7 +229,7 @@ For architecture details, see [`CLAUDE.md`](CLAUDE.md). For calibration pipeline ## Roadmap -- [x] **Phase 1** — 39 rules, density-based scoring, HTML reports, presets, scoped analysis +- [x] **Phase 1** — 38 rules, density-based scoring, HTML reports, presets, scoped analysis - [x] **Phase 2** — 4-agent calibration pipeline, `/calibrate-loop` debate loop - [x] **Phase 3** — Config overrides, MCP server, Claude Skills - [x] **Phase 4** — Figma comment from report (per-issue "Comment" button in HTML report, posts to Figma node via API) diff --git a/docs/REFERENCE.md b/docs/REFERENCE.md index d0b9b123..b3481a7a 100644 --- a/docs/REFERENCE.md +++ b/docs/REFERENCE.md @@ -92,7 +92,7 @@ Override score, severity, or enable/disable individual rules: ### All Rule IDs -**Layout (11 rules)** +**Layout (10 rules)** | Rule ID | Default Score | Default Severity | |---------|--------------|-----------------| @@ -104,7 +104,6 @@ Override score, severity, or enable/disable individual rules: | `fixed-size-in-auto-layout` | -5 | risk | | `missing-min-width` | -5 | risk | | `missing-max-width` | -4 | risk | -| `overflow-hidden-abuse` | -3 | missing-info | | `inconsistent-sibling-layout-direction` | -2 | missing-info | **Token (7 rules)** @@ -119,14 +118,13 @@ Override score, severity, or enable/disable individual rules: | `raw-opacity` | -5 | risk | | `multiple-fill-colors` | -3 | missing-info | -**Component (6 rules)** +**Component (5 rules)** | Rule ID | Default Score | Default Severity | |---------|--------------|-----------------| | `missing-component` | -7 | risk | | `detached-instance` | -5 | risk | | `variant-not-used` | -3 | suggestion | -| `component-property-unused` | -2 | suggestion | | `single-use-component` | -2 | suggestion | | `missing-component-description` | -2 | missing-info | diff --git a/src/core/contracts/rule.ts b/src/core/contracts/rule.ts index d6189376..9d980455 100644 --- a/src/core/contracts/rule.ts +++ b/src/core/contracts/rule.ts @@ -100,7 +100,6 @@ export type RuleId = | "missing-min-width" | "missing-max-width" | "deep-nesting" - | "overflow-hidden-abuse" | "inconsistent-sibling-layout-direction" // Token (7) | "raw-color" @@ -110,11 +109,10 @@ export type RuleId = | "raw-shadow" | "raw-opacity" | "multiple-fill-colors" - // Component (6) + // Component (5) | "missing-component" | "detached-instance" | "variant-not-used" - | "component-property-unused" | "single-use-component" | "missing-component-description" // Naming (5) diff --git a/src/core/engine/scoring.test.ts b/src/core/engine/scoring.test.ts index 7da43739..613ed140 100644 --- a/src/core/engine/scoring.test.ts +++ b/src/core/engine/scoring.test.ts @@ -162,7 +162,7 @@ describe("calculateScores", () => { "no-auto-layout", "group-usage", "deep-nesting", "fixed-size-in-auto-layout", "missing-responsive-behavior", "absolute-position-in-auto-layout", "fixed-width-in-responsive-context", "missing-min-width", "missing-max-width", - "overflow-hidden-abuse", "inconsistent-sibling-layout-direction", + "inconsistent-sibling-layout-direction", ] as const; const issues: AnalysisIssue[] = []; @@ -276,7 +276,7 @@ describe("calculateGrade (via calculateScores)", () => { const rulesPerCat: Record = { layout: ["no-auto-layout", "group-usage", "deep-nesting", "fixed-size-in-auto-layout", "missing-responsive-behavior"], token: ["raw-color", "raw-font", "inconsistent-spacing", "magic-number-spacing", "raw-shadow"], - component: ["missing-component", "detached-instance", "variant-not-used", "component-property-unused", "single-use-component"], + component: ["missing-component", "detached-instance", "variant-not-used", "single-use-component"], naming: ["default-name", "non-semantic-name", "inconsistent-naming-convention", "numeric-suffix-name", "too-long-name"], "ai-readability": ["ambiguous-structure", "z-index-dependent-layout", "missing-layout-hint", "invisible-layer", "empty-frame"], "handoff-risk": ["hardcode-risk", "text-truncation-unhandled", "image-no-placeholder", "prototype-link-in-design", "no-dev-status"], diff --git a/src/core/engine/scoring.ts b/src/core/engine/scoring.ts index 58021be9..b76c8400 100644 --- a/src/core/engine/scoring.ts +++ b/src/core/engine/scoring.ts @@ -73,9 +73,9 @@ const SEVERITY_DENSITY_WEIGHT: Record = { * Must be updated when rules are added/removed from a category. */ const TOTAL_RULES_PER_CATEGORY: Record = { - layout: 11, + layout: 10, token: 7, - component: 6, + component: 5, naming: 5, "ai-readability": 5, "handoff-risk": 5, diff --git a/src/core/rules/component/component-property-unused.test.ts b/src/core/rules/component/component-property-unused.test.ts deleted file mode 100644 index f20a85cf..00000000 --- a/src/core/rules/component/component-property-unused.test.ts +++ /dev/null @@ -1,64 +0,0 @@ -import { makeNode, makeFile, makeContext } from "../test-helpers.js"; -import type { AnalysisFile } from "../../contracts/figma-node.js"; -import { componentPropertyUnused } from "./index.js"; - -function makeFileWithComponentDefs(defs: Record): AnalysisFile { - return { - ...makeFile(), - componentDefinitions: { - "comp:1": makeNode({ - id: "comp:1", - type: "COMPONENT", - name: "Button", - componentPropertyDefinitions: defs, - }), - }, - }; -} - -describe("component-property-unused", () => { - it("has correct rule definition metadata", () => { - expect(componentPropertyUnused.definition.id).toBe("component-property-unused"); - expect(componentPropertyUnused.definition.category).toBe("component"); - }); - - it("returns null for non-INSTANCE nodes", () => { - const node = makeNode({ type: "FRAME" }); - expect(componentPropertyUnused.check(node, makeContext())).toBeNull(); - }); - - it("returns null for instance without componentId", () => { - const node = makeNode({ type: "INSTANCE" }); - expect(componentPropertyUnused.check(node, makeContext())).toBeNull(); - }); - - it("returns null when component has no property definitions", () => { - const file = makeFileWithComponentDefs({}); - const node = makeNode({ type: "INSTANCE", componentId: "comp:1" }); - expect(componentPropertyUnused.check(node, makeContext({ file }))).toBeNull(); - }); - - it("returns null when no componentDefinitions in file", () => { - const node = makeNode({ type: "INSTANCE", componentId: "comp:1" }); - expect(componentPropertyUnused.check(node, makeContext())).toBeNull(); - }); - - it("flags instance that does not customize any properties", () => { - const file = makeFileWithComponentDefs({ label: { type: "TEXT" }, icon: { type: "BOOLEAN" } }); - const node = makeNode({ type: "INSTANCE", name: "MyButton", componentId: "comp:1" }); - const result = componentPropertyUnused.check(node, makeContext({ file })); - expect(result).not.toBeNull(); - expect(result!.ruleId).toBe("component-property-unused"); - expect(result!.message).toContain("2 available component properties"); - }); - - it("returns null when instance has property overrides", () => { - const file = makeFileWithComponentDefs({ label: { type: "TEXT" } }); - const node = makeNode({ - type: "INSTANCE", - componentId: "comp:1", - componentProperties: { label: { value: "Submit" } }, - }); - expect(componentPropertyUnused.check(node, makeContext({ file }))).toBeNull(); - }); -}); diff --git a/src/core/rules/component/index.ts b/src/core/rules/component/index.ts index 30aa16c0..7ab1baaa 100644 --- a/src/core/rules/component/index.ts +++ b/src/core/rules/component/index.ts @@ -342,54 +342,6 @@ export const variantNotUsed = defineRule({ check: variantNotUsedCheck, }); -// ============================================ -// component-property-unused -// ============================================ - -const componentPropertyUnusedDef: RuleDefinition = { - id: "component-property-unused", - name: "Component Property Unused", - category: "component", - why: "Component properties should be utilized to expose customization", - impact: "Hardcoded values that should be configurable", - fix: "Connect the value to a component property", -}; - -const componentPropertyUnusedCheck: RuleCheckFn = (node, context) => { - // Check instances: does this instance override any component properties? - if (node.type !== "INSTANCE") return null; - if (!node.componentId) return null; - - // Look up the component's property definitions from componentDefinitions - const compDefs = context.file.componentDefinitions; - if (!compDefs) return null; - - const masterDef = compDefs[node.componentId]; - if (!masterDef) return null; - if (!masterDef.componentPropertyDefinitions) return null; - - const definedProps = Object.keys(masterDef.componentPropertyDefinitions); - if (definedProps.length === 0) return null; - - // Check if the instance has any property overrides - const instanceProps = node.componentProperties; - if (!instanceProps || Object.keys(instanceProps).length === 0) { - return { - ruleId: componentPropertyUnusedDef.id, - nodeId: node.id, - nodePath: context.path.join(" > "), - message: `Instance "${node.name}" does not customize any of ${definedProps.length} available component properties`, - }; - } - - return null; -}; - -export const componentPropertyUnused = defineRule({ - definition: componentPropertyUnusedDef, - check: componentPropertyUnusedCheck, -}); - // ============================================ // single-use-component // ============================================ diff --git a/src/core/rules/layout/index.ts b/src/core/rules/layout/index.ts index 1431fb90..d58de907 100644 --- a/src/core/rules/layout/index.ts +++ b/src/core/rules/layout/index.ts @@ -387,51 +387,6 @@ export const deepNesting = defineRule({ check: deepNestingCheck, }); -// ============================================ -// overflow-hidden-abuse -// ============================================ - -const overflowHiddenAbuseDef: RuleDefinition = { - id: "overflow-hidden-abuse", - name: "Overflow Hidden Abuse", - category: "layout", - why: "Using clip content to hide layout problems masks underlying issues", - impact: "Content may be unintentionally cut off, problems harder to diagnose", - fix: "Fix the underlying layout issue instead of hiding overflow", -}; - -const overflowHiddenAbuseCheck: RuleCheckFn = (node, context) => { - if (!isContainerNode(node)) return null; - if (!node.clipsContent) return null; - - // Clip content on auto-layout frames is normal (scrollable containers, cards) - if (hasAutoLayout(node)) return null; - - // Clip content on small elements (icons, avatars) is expected - if (node.absoluteBoundingBox) { - const { width, height } = node.absoluteBoundingBox; - if (width <= 48 && height <= 48) return null; - } - - // Non-auto-layout container with clipsContent = suspicious - // It may be hiding children that overflow due to missing auto-layout - if (node.children && node.children.length > 0) { - return { - ruleId: overflowHiddenAbuseDef.id, - nodeId: node.id, - nodePath: context.path.join(" > "), - message: `"${node.name}" uses clip content without Auto Layout — may be hiding overflow instead of fixing layout`, - }; - } - - return null; -}; - -export const overflowHiddenAbuse = defineRule({ - definition: overflowHiddenAbuseDef, - check: overflowHiddenAbuseCheck, -}); - // ============================================ // inconsistent-sibling-layout-direction // ============================================ diff --git a/src/core/rules/layout/overflow-hidden-abuse.test.ts b/src/core/rules/layout/overflow-hidden-abuse.test.ts deleted file mode 100644 index 248db2b0..00000000 --- a/src/core/rules/layout/overflow-hidden-abuse.test.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { makeNode, makeFile, makeContext } from "../test-helpers.js"; -import { overflowHiddenAbuse } from "./index.js"; - -describe("overflow-hidden-abuse", () => { - it("has correct rule definition metadata", () => { - expect(overflowHiddenAbuse.definition.id).toBe("overflow-hidden-abuse"); - expect(overflowHiddenAbuse.definition.category).toBe("layout"); - }); - - it("flags non-auto-layout container with clipsContent and children", () => { - const node = makeNode({ - type: "FRAME", - name: "ClippedFrame", - clipsContent: true, - children: [makeNode({ id: "c:1" })], - }); - const result = overflowHiddenAbuse.check(node, makeContext()); - expect(result).not.toBeNull(); - expect(result!.ruleId).toBe("overflow-hidden-abuse"); - expect(result!.message).toContain("ClippedFrame"); - }); - - it("returns null for auto-layout container with clipsContent", () => { - const node = makeNode({ - type: "FRAME", - clipsContent: true, - layoutMode: "VERTICAL", - children: [makeNode({ id: "c:1" })], - }); - expect(overflowHiddenAbuse.check(node, makeContext())).toBeNull(); - }); - - it("returns null for container without clipsContent", () => { - const node = makeNode({ - type: "FRAME", - children: [makeNode({ id: "c:1" })], - }); - expect(overflowHiddenAbuse.check(node, makeContext())).toBeNull(); - }); - - it("returns null for small elements (icons/avatars)", () => { - const node = makeNode({ - type: "FRAME", - clipsContent: true, - absoluteBoundingBox: { x: 0, y: 0, width: 24, height: 24 }, - children: [makeNode({ id: "c:1" })], - }); - expect(overflowHiddenAbuse.check(node, makeContext())).toBeNull(); - }); - - it("returns null for non-container nodes", () => { - const node = makeNode({ type: "TEXT", clipsContent: true }); - expect(overflowHiddenAbuse.check(node, makeContext())).toBeNull(); - }); - - it("returns null for empty container with clipsContent", () => { - const node = makeNode({ - type: "FRAME", - clipsContent: true, - }); - expect(overflowHiddenAbuse.check(node, makeContext())).toBeNull(); - }); -}); diff --git a/src/core/rules/rule-config.ts b/src/core/rules/rule-config.ts index 5459f597..eee0464f 100644 --- a/src/core/rules/rule-config.ts +++ b/src/core/rules/rule-config.ts @@ -61,11 +61,6 @@ export const RULE_CONFIGS: Record = { maxDepth: 5, }, }, - "overflow-hidden-abuse": { - severity: "missing-info", - score: -3, - enabled: true, - }, "inconsistent-sibling-layout-direction": { severity: "missing-info", score: -2, @@ -143,11 +138,6 @@ export const RULE_CONFIGS: Record = { score: -3, enabled: true, }, - "component-property-unused": { - severity: "missing-info", - score: -2, - enabled: true, - }, "single-use-component": { severity: "suggestion", score: -1, From 4aaf9834912f70287abaf06f2e11232fcd36faec Mon Sep 17 00:00:00 2001 From: let-sunny Date: Wed, 25 Mar 2026 23:59:11 +0900 Subject: [PATCH 03/12] refactor: remove single-use-component rule MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Single-use component doesn't affect AI implementation difficulty — AI implements what it sees regardless of component reuse count. This is design hygiene, not implementation readiness. 37 → 36 rules. Component: 5 → 4. Co-Authored-By: Claude Opus 4.6 (1M context) --- README.md | 6 +- docs/REFERENCE.md | 3 +- src/core/contracts/rule.ts | 3 +- src/core/engine/scoring.test.ts | 2 +- src/core/engine/scoring.ts | 2 +- src/core/rules/component/index.ts | 52 -------- .../component/single-use-component.test.ts | 122 ------------------ src/core/rules/rule-config.ts | 5 - 8 files changed, 7 insertions(+), 188 deletions(-) delete mode 100644 src/core/rules/component/single-use-component.test.ts diff --git a/README.md b/README.md index a0913712..1d484e12 100644 --- a/README.md +++ b/README.md @@ -30,13 +30,13 @@ ## How It Works -38 rules. 6 categories. Every node in the Figma tree. +37 rules. 6 categories. Every node in the Figma tree. | Category | Rules | What it checks | |----------|-------|----------------| | Layout | 10 | Auto-layout usage, responsive behavior | | Design Token | 7 | Color/font/shadow tokenization, spacing consistency | -| Component | 5 | Component reuse, detached instances, variant coverage | +| Component | 4 | Component reuse, detached instances, variant coverage | | Naming | 5 | Semantic names, default names, naming conventions | | AI Readability | 5 | Structure clarity, z-index reliance, empty frames | | Handoff Risk | 6 | Hardcoded values, truncation handling, placeholder images, deep nesting | @@ -229,7 +229,7 @@ For architecture details, see [`CLAUDE.md`](CLAUDE.md). For calibration pipeline ## Roadmap -- [x] **Phase 1** — 38 rules, density-based scoring, HTML reports, presets, scoped analysis +- [x] **Phase 1** — 37 rules, density-based scoring, HTML reports, presets, scoped analysis - [x] **Phase 2** — 4-agent calibration pipeline, `/calibrate-loop` debate loop - [x] **Phase 3** — Config overrides, MCP server, Claude Skills - [x] **Phase 4** — Figma comment from report (per-issue "Comment" button in HTML report, posts to Figma node via API) diff --git a/docs/REFERENCE.md b/docs/REFERENCE.md index b3481a7a..83a1226a 100644 --- a/docs/REFERENCE.md +++ b/docs/REFERENCE.md @@ -118,14 +118,13 @@ Override score, severity, or enable/disable individual rules: | `raw-opacity` | -5 | risk | | `multiple-fill-colors` | -3 | missing-info | -**Component (5 rules)** +**Component (4 rules)** | Rule ID | Default Score | Default Severity | |---------|--------------|-----------------| | `missing-component` | -7 | risk | | `detached-instance` | -5 | risk | | `variant-not-used` | -3 | suggestion | -| `single-use-component` | -2 | suggestion | | `missing-component-description` | -2 | missing-info | **Naming (5 rules)** diff --git a/src/core/contracts/rule.ts b/src/core/contracts/rule.ts index 9d980455..b325a0b3 100644 --- a/src/core/contracts/rule.ts +++ b/src/core/contracts/rule.ts @@ -109,11 +109,10 @@ export type RuleId = | "raw-shadow" | "raw-opacity" | "multiple-fill-colors" - // Component (5) + // Component (4) | "missing-component" | "detached-instance" | "variant-not-used" - | "single-use-component" | "missing-component-description" // Naming (5) | "default-name" diff --git a/src/core/engine/scoring.test.ts b/src/core/engine/scoring.test.ts index 613ed140..3994d802 100644 --- a/src/core/engine/scoring.test.ts +++ b/src/core/engine/scoring.test.ts @@ -276,7 +276,7 @@ describe("calculateGrade (via calculateScores)", () => { const rulesPerCat: Record = { layout: ["no-auto-layout", "group-usage", "deep-nesting", "fixed-size-in-auto-layout", "missing-responsive-behavior"], token: ["raw-color", "raw-font", "inconsistent-spacing", "magic-number-spacing", "raw-shadow"], - component: ["missing-component", "detached-instance", "variant-not-used", "single-use-component"], + component: ["missing-component", "detached-instance", "variant-not-used"], naming: ["default-name", "non-semantic-name", "inconsistent-naming-convention", "numeric-suffix-name", "too-long-name"], "ai-readability": ["ambiguous-structure", "z-index-dependent-layout", "missing-layout-hint", "invisible-layer", "empty-frame"], "handoff-risk": ["hardcode-risk", "text-truncation-unhandled", "image-no-placeholder", "prototype-link-in-design", "no-dev-status"], diff --git a/src/core/engine/scoring.ts b/src/core/engine/scoring.ts index b76c8400..c3899360 100644 --- a/src/core/engine/scoring.ts +++ b/src/core/engine/scoring.ts @@ -75,7 +75,7 @@ const SEVERITY_DENSITY_WEIGHT: Record = { const TOTAL_RULES_PER_CATEGORY: Record = { layout: 10, token: 7, - component: 5, + component: 4, naming: 5, "ai-readability": 5, "handoff-risk": 5, diff --git a/src/core/rules/component/index.ts b/src/core/rules/component/index.ts index 7ab1baaa..77611477 100644 --- a/src/core/rules/component/index.ts +++ b/src/core/rules/component/index.ts @@ -8,9 +8,6 @@ import { getRuleOption } from "../rule-config.js"; // Helper functions // ============================================ -function isComponent(node: AnalysisNode): boolean { - return node.type === "COMPONENT" || node.type === "COMPONENT_SET"; -} /** Style properties to compare between master and instance. */ const STYLE_COMPARE_KEYS = ["fills", "strokes", "effects", "cornerRadius", "strokeWeight", "individualStrokeWeights"] as const; @@ -342,55 +339,6 @@ export const variantNotUsed = defineRule({ check: variantNotUsedCheck, }); -// ============================================ -// single-use-component -// ============================================ - -const singleUseComponentDef: RuleDefinition = { - id: "single-use-component", - name: "Single Use Component", - category: "component", - why: "Components used only once add complexity without reuse benefit", - impact: "Unnecessary abstraction increases maintenance overhead", - fix: "Consider inlining if this component won't be reused", -}; - -const singleUseComponentCheck: RuleCheckFn = (node, context) => { - if (!isComponent(node)) return null; - - // Count instances of this component in the file - let instanceCount = 0; - - function countInstances(n: AnalysisNode): void { - if (n.type === "INSTANCE" && n.componentId === node.id) { - instanceCount++; - } - if (n.children) { - for (const child of n.children) { - countInstances(child); - } - } - } - - countInstances(context.file.document); - - if (instanceCount === 1) { - return { - ruleId: singleUseComponentDef.id, - nodeId: node.id, - nodePath: context.path.join(" > "), - message: `Component "${node.name}" is only used once`, - }; - } - - return null; -}; - -export const singleUseComponent = defineRule({ - definition: singleUseComponentDef, - check: singleUseComponentCheck, -}); - // ============================================ // missing-component-description // ============================================ diff --git a/src/core/rules/component/single-use-component.test.ts b/src/core/rules/component/single-use-component.test.ts deleted file mode 100644 index 90bb9130..00000000 --- a/src/core/rules/component/single-use-component.test.ts +++ /dev/null @@ -1,122 +0,0 @@ -import { makeNode, makeFile, makeContext } from "../test-helpers.js"; -import { singleUseComponent } from "./index.js"; - -describe("single-use-component", () => { - it("has correct rule definition metadata", () => { - expect(singleUseComponent.definition.id).toBe("single-use-component"); - expect(singleUseComponent.definition.category).toBe("component"); - }); - - it("flags component used only once", () => { - const component = makeNode({ id: "comp:1", type: "COMPONENT", name: "Badge" }); - const instance = makeNode({ id: "inst:1", type: "INSTANCE", componentId: "comp:1" }); - const document = makeNode({ - id: "0:1", - name: "Document", - type: "DOCUMENT", - children: [component, instance], - }); - - const ctx = makeContext({ - file: { - fileKey: "f", - name: "F", - lastModified: "", - version: "1", - document, - components: {}, - styles: {}, - }, - }); - - const result = singleUseComponent.check(component, ctx); - expect(result).not.toBeNull(); - expect(result!.ruleId).toBe("single-use-component"); - expect(result!.message).toContain("Badge"); - }); - - it("returns null for component used multiple times", () => { - const component = makeNode({ id: "comp:1", type: "COMPONENT", name: "Badge" }); - const inst1 = makeNode({ id: "inst:1", type: "INSTANCE", componentId: "comp:1" }); - const inst2 = makeNode({ id: "inst:2", type: "INSTANCE", componentId: "comp:1" }); - const document = makeNode({ - id: "0:1", - name: "Document", - type: "DOCUMENT", - children: [component, inst1, inst2], - }); - - const ctx = makeContext({ - file: { - fileKey: "f", - name: "F", - lastModified: "", - version: "1", - document, - components: {}, - styles: {}, - }, - }); - - expect(singleUseComponent.check(component, ctx)).toBeNull(); - }); - - it("flags single-use component when instance is deeply nested", () => { - const component = makeNode({ id: "comp:1", type: "COMPONENT", name: "Badge" }); - const instance = makeNode({ id: "inst:1", type: "INSTANCE", componentId: "comp:1" }); - const innerFrame = makeNode({ id: "inner:1", type: "FRAME", children: [instance] }); - const outerFrame = makeNode({ id: "outer:1", type: "FRAME", children: [innerFrame] }); - const document = makeNode({ - id: "0:1", - name: "Document", - type: "DOCUMENT", - children: [component, outerFrame], - }); - - const ctx = makeContext({ - file: { - fileKey: "f", - name: "F", - lastModified: "", - version: "1", - document, - components: {}, - styles: {}, - }, - }); - - const result = singleUseComponent.check(component, ctx); - expect(result).not.toBeNull(); - expect(result!.ruleId).toBe("single-use-component"); - expect(result!.message).toContain("Badge"); - }); - - it("returns null for non-component nodes", () => { - const node = makeNode({ type: "FRAME" }); - expect(singleUseComponent.check(node, makeContext())).toBeNull(); - }); - - it("returns null for component with zero instances", () => { - const component = makeNode({ id: "comp:1", type: "COMPONENT", name: "Unused" }); - const document = makeNode({ - id: "0:1", - name: "Document", - type: "DOCUMENT", - children: [component], - }); - - const ctx = makeContext({ - file: { - fileKey: "f", - name: "F", - lastModified: "", - version: "1", - document, - components: {}, - styles: {}, - }, - }); - - expect(singleUseComponent.check(component, ctx)).toBeNull(); - }); -}); diff --git a/src/core/rules/rule-config.ts b/src/core/rules/rule-config.ts index eee0464f..e617a9ec 100644 --- a/src/core/rules/rule-config.ts +++ b/src/core/rules/rule-config.ts @@ -138,11 +138,6 @@ export const RULE_CONFIGS: Record = { score: -3, enabled: true, }, - "single-use-component": { - severity: "suggestion", - score: -1, - enabled: true, - }, "missing-component-description": { severity: "missing-info", score: -2, From ad5ec291fc722b49739a9a86abd2f10cebdbff99 Mon Sep 17 00:00:00 2001 From: let-sunny Date: Thu, 26 Mar 2026 00:05:36 +0900 Subject: [PATCH 04/12] refactor: remove no-dev-status rule MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dev status is a process/workflow metadata, not an AI implementation difficulty signal. It was already disabled by default. 36 → 35 rules. Handoff Risk: 5 → 4. Co-Authored-By: Claude Opus 4.6 (1M context) --- CLAUDE.md | 3 +- README.md | 6 ++-- docs/REFERENCE.md | 4 +-- src/core/contracts/rule.ts | 3 +- src/core/engine/rule-engine.test.ts | 29 +++++++-------- src/core/engine/scoring.test.ts | 2 +- src/core/engine/scoring.ts | 2 +- src/core/rules/handoff-risk/index.ts | 33 ----------------- .../rules/handoff-risk/no-dev-status.test.ts | 36 ------------------- src/core/rules/rule-config.ts | 5 --- 10 files changed, 23 insertions(+), 100 deletions(-) delete mode 100644 src/core/rules/handoff-risk/no-dev-status.test.ts diff --git a/CLAUDE.md b/CLAUDE.md index e2a7cbd0..f31c18d0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -266,5 +266,4 @@ Rule logic and score config are intentionally separated so scores can be tuned w Configurable thresholds: - `gridBase` (default: 4) — spacing grid unit for inconsistent-spacing and magic-number-spacing -- `tolerance` (default: 10) — color difference tolerance for multiple-fill-colors -- `no-dev-status` — disabled by default \ No newline at end of file +- `tolerance` (default: 10) — color difference tolerance for multiple-fill-colors \ No newline at end of file diff --git a/README.md b/README.md index 1d484e12..0007c35c 100644 --- a/README.md +++ b/README.md @@ -30,7 +30,7 @@ ## How It Works -37 rules. 6 categories. Every node in the Figma tree. +35 rules. 6 categories. Every node in the Figma tree. | Category | Rules | What it checks | |----------|-------|----------------| @@ -39,7 +39,7 @@ | Component | 4 | Component reuse, detached instances, variant coverage | | Naming | 5 | Semantic names, default names, naming conventions | | AI Readability | 5 | Structure clarity, z-index reliance, empty frames | -| Handoff Risk | 6 | Hardcoded values, truncation handling, placeholder images, deep nesting | +| Handoff Risk | 4 | Hardcoded values, truncation handling, placeholder images | Each issue is classified: **Blocking** > **Risk** > **Missing Info** > **Suggestion**. @@ -229,7 +229,7 @@ For architecture details, see [`CLAUDE.md`](CLAUDE.md). For calibration pipeline ## Roadmap -- [x] **Phase 1** — 37 rules, density-based scoring, HTML reports, presets, scoped analysis +- [x] **Phase 1** — 35 rules, density-based scoring, HTML reports, presets, scoped analysis - [x] **Phase 2** — 4-agent calibration pipeline, `/calibrate-loop` debate loop - [x] **Phase 3** — Config overrides, MCP server, Claude Skills - [x] **Phase 4** — Figma comment from report (per-issue "Comment" button in HTML report, posts to Figma node via API) diff --git a/docs/REFERENCE.md b/docs/REFERENCE.md index 83a1226a..24242e2b 100644 --- a/docs/REFERENCE.md +++ b/docs/REFERENCE.md @@ -147,7 +147,7 @@ Override score, severity, or enable/disable individual rules: | `invisible-layer` | -1 | suggestion | | `empty-frame` | -2 | missing-info | -**Handoff Risk (5 rules)** +**Handoff Risk (4 rules)** | Rule ID | Default Score | Default Severity | |---------|--------------|-----------------| @@ -155,8 +155,6 @@ Override score, severity, or enable/disable individual rules: | `text-truncation-unhandled` | -5 | risk | | `image-no-placeholder` | -4 | risk | | `prototype-link-in-design` | -2 | suggestion | -| `no-dev-status` | -2 | suggestion | -| `deep-nesting` | -4 | risk | ### Example Configs diff --git a/src/core/contracts/rule.ts b/src/core/contracts/rule.ts index b325a0b3..877e95c6 100644 --- a/src/core/contracts/rule.ts +++ b/src/core/contracts/rule.ts @@ -130,8 +130,7 @@ export type RuleId = | "hardcode-risk" | "text-truncation-unhandled" | "image-no-placeholder" - | "prototype-link-in-design" - | "no-dev-status"; + | "prototype-link-in-design"; /** * Categories that support depthWeight diff --git a/src/core/engine/rule-engine.test.ts b/src/core/engine/rule-engine.test.ts index 8d79315a..b6fb8245 100644 --- a/src/core/engine/rule-engine.test.ts +++ b/src/core/engine/rule-engine.test.ts @@ -243,31 +243,32 @@ describe("RuleEngine.analyze — rule filtering", () => { }); it("skips rules disabled in config (enabled: false)", () => { + // Frame without auto-layout triggers no-auto-layout rule when enabled const doc = makeNode({ id: "0:1", name: "Document", type: "DOCUMENT", children: [ - makeNode({ id: "f:1", name: "Frame", type: "FRAME" }), + makeNode({ id: "f:1", name: "Frame", type: "FRAME", children: [makeNode({ id: "c:1" })] }), ], }); const file = makeFile({ document: doc }); - // Positive control: explicitly enable no-dev-status to prove it can fire - const enabledConfigs = { ...RULE_CONFIGS }; - enabledConfigs["no-dev-status"] = { ...enabledConfigs["no-dev-status"], enabled: true }; - const resultEnabled = analyzeFile(file, { configs: enabledConfigs }); - const devStatusEnabled = resultEnabled.issues.filter( - (i) => i.violation.ruleId === "no-dev-status" + // Positive control: no-auto-layout fires when enabled (default) + const resultEnabled = analyzeFile(file); + const enabledIssues = resultEnabled.issues.filter( + (i) => i.violation.ruleId === "no-auto-layout" ); - expect(devStatusEnabled.length).toBeGreaterThan(0); - - // Default config: no-dev-status is disabled → no issues - const result = analyzeFile(file); - const devStatusIssues = result.issues.filter( - (i) => i.violation.ruleId === "no-dev-status" + expect(enabledIssues.length).toBeGreaterThan(0); + + // Disable the rule → no issues for that rule + const disabledConfigs = { ...RULE_CONFIGS }; + disabledConfigs["no-auto-layout"] = { ...disabledConfigs["no-auto-layout"], enabled: false }; + const result = analyzeFile(file, { configs: disabledConfigs }); + const disabledIssues = result.issues.filter( + (i) => i.violation.ruleId === "no-auto-layout" ); - expect(devStatusIssues.length).toBe(0); + expect(disabledIssues.length).toBe(0); }); }); diff --git a/src/core/engine/scoring.test.ts b/src/core/engine/scoring.test.ts index 3994d802..c285e7e6 100644 --- a/src/core/engine/scoring.test.ts +++ b/src/core/engine/scoring.test.ts @@ -279,7 +279,7 @@ describe("calculateGrade (via calculateScores)", () => { component: ["missing-component", "detached-instance", "variant-not-used"], naming: ["default-name", "non-semantic-name", "inconsistent-naming-convention", "numeric-suffix-name", "too-long-name"], "ai-readability": ["ambiguous-structure", "z-index-dependent-layout", "missing-layout-hint", "invisible-layer", "empty-frame"], - "handoff-risk": ["hardcode-risk", "text-truncation-unhandled", "image-no-placeholder", "prototype-link-in-design", "no-dev-status"], + "handoff-risk": ["hardcode-risk", "text-truncation-unhandled", "image-no-placeholder", "prototype-link-in-design"], }; for (const cat of categories) { diff --git a/src/core/engine/scoring.ts b/src/core/engine/scoring.ts index c3899360..f6090105 100644 --- a/src/core/engine/scoring.ts +++ b/src/core/engine/scoring.ts @@ -78,7 +78,7 @@ const TOTAL_RULES_PER_CATEGORY: Record = { component: 4, naming: 5, "ai-readability": 5, - "handoff-risk": 5, + "handoff-risk": 4, }; /** diff --git a/src/core/rules/handoff-risk/index.ts b/src/core/rules/handoff-risk/index.ts index a47b91d6..0bc56540 100644 --- a/src/core/rules/handoff-risk/index.ts +++ b/src/core/rules/handoff-risk/index.ts @@ -224,36 +224,3 @@ export const prototypeLinkInDesign = defineRule({ check: prototypeLinkInDesignCheck, }); -// ============================================ -// no-dev-status -// ============================================ - -const noDevStatusDef: RuleDefinition = { - id: "no-dev-status", - name: "No Dev Status", - category: "handoff-risk", - why: "Without dev status, developers cannot know if a design is ready", - impact: "May implement designs that are still in progress", - fix: "Mark frames as 'Ready for Dev' or 'Completed' when appropriate", -}; - -const noDevStatusCheck: RuleCheckFn = (node, context) => { - // Only check top-level frames (likely screens/pages) - if (node.type !== "FRAME") return null; - if (context.depth > 1) return null; - - // Check for devStatus - if (node.devStatus) return null; - - return { - ruleId: noDevStatusDef.id, - nodeId: node.id, - nodePath: context.path.join(" > "), - message: `"${node.name}" has no dev status set`, - }; -}; - -export const noDevStatus = defineRule({ - definition: noDevStatusDef, - check: noDevStatusCheck, -}); diff --git a/src/core/rules/handoff-risk/no-dev-status.test.ts b/src/core/rules/handoff-risk/no-dev-status.test.ts deleted file mode 100644 index 0abcf8c4..00000000 --- a/src/core/rules/handoff-risk/no-dev-status.test.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { makeNode, makeFile, makeContext } from "../test-helpers.js"; -import { noDevStatus } from "./index.js"; - -describe("no-dev-status", () => { - it("has correct rule definition metadata", () => { - expect(noDevStatus.definition.id).toBe("no-dev-status"); - expect(noDevStatus.definition.category).toBe("handoff-risk"); - }); - - it("flags top-level frame without devStatus", () => { - const node = makeNode({ type: "FRAME", name: "LoginScreen" }); - const result = noDevStatus.check(node, makeContext({ depth: 1 })); - expect(result).not.toBeNull(); - expect(result!.ruleId).toBe("no-dev-status"); - expect(result!.message).toContain("LoginScreen"); - }); - - it("returns null when devStatus is set", () => { - const node = makeNode({ - type: "FRAME", - name: "LoginScreen", - devStatus: { type: "READY_FOR_DEV" }, - }); - expect(noDevStatus.check(node, makeContext({ depth: 1 }))).toBeNull(); - }); - - it("returns null for nested frames (depth > 1)", () => { - const node = makeNode({ type: "FRAME", name: "Card" }); - expect(noDevStatus.check(node, makeContext({ depth: 2 }))).toBeNull(); - }); - - it("returns null for non-FRAME nodes", () => { - const node = makeNode({ type: "GROUP" }); - expect(noDevStatus.check(node, makeContext({ depth: 1 }))).toBeNull(); - }); -}); diff --git a/src/core/rules/rule-config.ts b/src/core/rules/rule-config.ts index e617a9ec..70d39b3c 100644 --- a/src/core/rules/rule-config.ts +++ b/src/core/rules/rule-config.ts @@ -234,11 +234,6 @@ export const RULE_CONFIGS: Record = { score: -2, enabled: true, }, - "no-dev-status": { - severity: "missing-info", - score: -2, - enabled: false, // Disabled by default - }, }; /** From 7fda9063d51aa81fabbb7a581089a9f359e71882 Mon Sep 17 00:00:00 2001 From: let-sunny Date: Thu, 26 Mar 2026 00:15:49 +0900 Subject: [PATCH 05/12] docs: rewrite rule why/impact/fix descriptions with AI implementation rationale Every rule's why/impact/fix now answers "how does this affect AI's ability to implement the design?" instead of generic design quality language. Examples: - raw-color: "Raw hex values repeated across nodes increase the chance of AI mismatching colors" (was: "not connected to design system") - default-name: "gives AI no semantic context to choose HTML tags" (was: "provide no context about purpose") - deep-nesting: "consumes AI context exponentially" (was: "hard to understand for developers") Co-Authored-By: Claude Opus 4.6 (1M context) --- src/core/rules/component/index.ts | 10 ++--- src/core/rules/handoff-risk/index.ts | 18 ++++----- src/core/rules/layout/index.ts | 56 +++++++++++++------------- src/core/rules/naming/index.ts | 28 ++++++------- src/core/rules/token/index.ts | 42 +++++++++---------- src/core/rules/token/raw-color.test.ts | 4 +- 6 files changed, 79 insertions(+), 79 deletions(-) diff --git a/src/core/rules/component/index.ts b/src/core/rules/component/index.ts index 77611477..e9477150 100644 --- a/src/core/rules/component/index.ts +++ b/src/core/rules/component/index.ts @@ -279,8 +279,8 @@ const detachedInstanceDef: RuleDefinition = { id: "detached-instance", name: "Detached Instance", category: "component", - why: "Detached instances lose their connection to the source component", - impact: "Updates to the component won't propagate to this instance", + why: "Detached instances lose component relationship — AI sees a one-off frame instead of a reusable component reference", + impact: "AI generates duplicate code instead of reusing the component, inflating output and causing inconsistencies", fix: "Reset the instance or create a new variant if customization is needed", }; @@ -322,9 +322,9 @@ const variantNotUsedDef: RuleDefinition = { id: "variant-not-used", name: "Variant Not Used", category: "component", - why: "Using instances but not leveraging variants defeats their purpose", - impact: "Manual changes instead of using designed variants", - fix: "Use the appropriate variant instead of overriding the default", + why: "Available variants exist but aren't used — AI doesn't know which state the design intends to show", + impact: "AI may generate the wrong variant or hardcode overrides instead of using the variant system", + fix: "Use the appropriate variant so AI can reference the correct state directly", }; const variantNotUsedCheck: RuleCheckFn = (_node, _context) => { diff --git a/src/core/rules/handoff-risk/index.ts b/src/core/rules/handoff-risk/index.ts index 0bc56540..ac027c08 100644 --- a/src/core/rules/handoff-risk/index.ts +++ b/src/core/rules/handoff-risk/index.ts @@ -37,9 +37,9 @@ const hardcodeRiskDef: RuleDefinition = { id: "hardcode-risk", name: "Hardcode Risk", category: "handoff-risk", - why: "Absolute positioning with fixed values creates inflexible layouts", - impact: "Layout will break when content changes or on different screens", - fix: "Use Auto Layout with relative positioning", + why: "Hardcoded position/size values force AI to use magic numbers instead of computed layouts", + impact: "Generated code is brittle — any content change (longer text, different image) breaks the layout", + fix: "Use Auto Layout with relative positioning so AI generates flexible CSS", }; const hardcodeRiskCheck: RuleCheckFn = (node, context) => { @@ -74,9 +74,9 @@ const textTruncationUnhandledDef: RuleDefinition = { id: "text-truncation-unhandled", name: "Text Truncation Unhandled", category: "handoff-risk", - why: "Text nodes without truncation handling may overflow", - impact: "Long text will break the layout", - fix: "Set text truncation (ellipsis) or ensure container can grow", + why: "Long text in a narrow container without truncation rules — AI doesn't know if it should clip, ellipsis, or grow", + impact: "AI may generate code where text overflows the container, breaking the visual layout", + fix: "Set text truncation (ellipsis) or ensure the container uses 'Hug' so the intent is explicit", }; const textTruncationUnhandledCheck: RuleCheckFn = (node, context) => { @@ -121,9 +121,9 @@ const imageNoPlaceholderDef: RuleDefinition = { id: "image-no-placeholder", name: "Image No Placeholder", category: "handoff-risk", - why: "Images without placeholder state may cause layout shifts", - impact: "Poor user experience during image loading", - fix: "Define a placeholder state or background color", + why: "Image node with only an IMAGE fill — AI doesn't know the intended fallback when the image fails to load", + impact: "Generated code shows a broken image icon or blank space instead of a graceful placeholder", + fix: "Add a background fill color behind the image to serve as a loading/error placeholder", }; const imageNoPlaceholderCheck: RuleCheckFn = (node, context) => { diff --git a/src/core/rules/layout/index.ts b/src/core/rules/layout/index.ts index d58de907..a28c0b46 100644 --- a/src/core/rules/layout/index.ts +++ b/src/core/rules/layout/index.ts @@ -27,9 +27,9 @@ const noAutoLayoutDef: RuleDefinition = { id: "no-auto-layout", name: "No Auto Layout", category: "layout", - why: "Frames without Auto Layout require manual positioning for every element", - impact: "Layout breaks on content changes, harder to maintain and scale", - fix: "Apply Auto Layout to the frame with appropriate direction and spacing", + why: "Without Auto Layout, AI must guess positioning from absolute coordinates instead of reading explicit layout rules", + impact: "Generated code uses hardcoded positions that break on any content or screen size change", + fix: "Apply Auto Layout so AI can generate flexbox/grid instead of absolute positioning", }; const noAutoLayoutCheck: RuleCheckFn = (node, context) => { @@ -59,8 +59,8 @@ const absolutePositionInAutoLayoutDef: RuleDefinition = { id: "absolute-position-in-auto-layout", name: "Absolute Position in Auto Layout", category: "layout", - why: "Absolute positioning inside Auto Layout breaks the automatic flow", - impact: "Element will not respond to sibling changes, may overlap unexpectedly", + why: "Absolute positioning inside Auto Layout contradicts the parent's layout rules — AI sees conflicting instructions", + impact: "AI must decide whether to follow the parent's flexbox or the child's absolute position — often gets it wrong", fix: "Remove absolute positioning or use proper Auto Layout alignment", }; @@ -119,9 +119,9 @@ const fixedWidthInResponsiveContextDef: RuleDefinition = { id: "fixed-width-in-responsive-context", name: "Fixed Width in Responsive Context", category: "layout", - why: "Fixed width inside Auto Layout prevents responsive behavior", - impact: "Content will not adapt to container size changes", - fix: "Use 'Fill' or 'Hug' instead of fixed width", + why: "Fixed width inside Auto Layout sends mixed signals — AI sees flexbox parent but hardcoded child width", + impact: "Generated code may fight between flex sizing and explicit width, causing layout mismatches", + fix: "Use 'Fill' or 'Hug' so the intent is clear to AI", }; const fixedWidthInResponsiveContextCheck: RuleCheckFn = (node, context) => { @@ -163,9 +163,9 @@ const missingResponsiveBehaviorDef: RuleDefinition = { id: "missing-responsive-behavior", name: "Missing Responsive Behavior", category: "layout", - why: "Elements without constraints won't adapt to different screen sizes", - impact: "Layout will break or look wrong on different devices", - fix: "Set appropriate constraints (left/right, top/bottom, scale, etc.)", + why: "Without constraints, AI has no information about how elements should behave when the container resizes", + impact: "AI generates static layouts that break on any screen size other than the one in the design", + fix: "Set appropriate constraints so AI can generate responsive CSS (min/max-width, flex-grow, etc.)", }; const missingResponsiveBehaviorCheck: RuleCheckFn = (node, context) => { @@ -201,9 +201,9 @@ const groupUsageDef: RuleDefinition = { id: "group-usage", name: "Group Usage", category: "layout", - why: "Groups don't support Auto Layout and have limited layout control", - impact: "Harder to maintain consistent spacing and alignment", - fix: "Convert Group to Frame and apply Auto Layout", + why: "Groups have no layout rules — AI sees children with absolute coordinates but no container logic", + impact: "AI wraps grouped elements in a plain div with no spacing/alignment, producing fragile layouts", + fix: "Convert Group to Frame with Auto Layout so AI can generate proper flex/grid containers", }; const groupUsageCheck: RuleCheckFn = (node, context) => { @@ -230,9 +230,9 @@ const fixedSizeInAutoLayoutDef: RuleDefinition = { id: "fixed-size-in-auto-layout", name: "Fixed Size in Auto Layout", category: "layout", - why: "Fixed sizes inside Auto Layout limit flexibility", - impact: "Element won't adapt to content or container changes", - fix: "Consider using 'Hug' for content-driven sizing", + why: "Both axes fixed inside Auto Layout contradicts the flexible layout intent", + impact: "AI generates a rigid element inside a flex container — the layout won't respond to content changes", + fix: "Use 'Hug' or 'Fill' for at least one axis so AI generates responsive sizing", }; const fixedSizeInAutoLayoutCheck: RuleCheckFn = (node, context) => { @@ -277,9 +277,9 @@ const missingMinWidthDef: RuleDefinition = { id: "missing-min-width", name: "Missing Min Width", category: "layout", - why: "Without min-width, containers can collapse to unusable sizes", - impact: "Text truncation or layout collapse on narrow screens", - fix: "Set a minimum width constraint on the container", + why: "Without min-width, AI has no lower bound — generated code may collapse the container to zero on narrow screens", + impact: "Content becomes unreadable or invisible when the layout shrinks", + fix: "Set a minimum width so AI can generate a proper min-width constraint", }; const missingMinWidthCheck: RuleCheckFn = (node, context) => { @@ -320,9 +320,9 @@ const missingMaxWidthDef: RuleDefinition = { id: "missing-max-width", name: "Missing Max Width", category: "layout", - why: "Without max-width, content can stretch too wide on large screens", - impact: "Poor readability and layout on wide screens", - fix: "Set a maximum width constraint, especially for text containers", + why: "Without max-width, AI has no upper bound — text lines stretch infinitely on wide screens", + impact: "Unreadable text with 200+ character lines, broken layout proportions", + fix: "Set a maximum width so AI can generate a proper max-width constraint", }; const missingMaxWidthCheck: RuleCheckFn = (node, context) => { @@ -363,8 +363,8 @@ const deepNestingDef: RuleDefinition = { id: "deep-nesting", name: "Deep Nesting", category: "handoff-risk", - why: "Deep nesting within a single component makes the structure hard to understand for developers during handoff", - impact: "Developers must trace through many layers to understand layout intent, increasing implementation time", + why: "Deep nesting consumes AI context exponentially — each level adds indentation and structural overhead", + impact: "AI may lose track of parent-child relationships in deeply nested trees, producing wrong layout hierarchy", fix: "Flatten the structure by extracting deeply nested groups into sub-components", }; @@ -395,9 +395,9 @@ const inconsistentSiblingLayoutDirectionDef: RuleDefinition = { id: "inconsistent-sibling-layout-direction", name: "Inconsistent Sibling Layout Direction", category: "layout", - why: "Sibling containers with mixed layout directions without clear reason create confusion", - impact: "Harder to understand and maintain the design structure", - fix: "Use consistent layout direction for similar sibling elements", + why: "Siblings with mixed layout directions (one horizontal, one vertical) without clear reason confuse AI's layout interpretation", + impact: "AI may apply wrong flex-direction or misinterpret the visual grouping", + fix: "Use consistent layout direction for similar sibling elements, or extract into distinct components", }; const inconsistentSiblingLayoutDirectionCheck: RuleCheckFn = (node, context) => { diff --git a/src/core/rules/naming/index.ts b/src/core/rules/naming/index.ts index 1a0eb87a..2143f903 100644 --- a/src/core/rules/naming/index.ts +++ b/src/core/rules/naming/index.ts @@ -65,9 +65,9 @@ const defaultNameDef: RuleDefinition = { id: "default-name", name: "Default Name", category: "naming", - why: "Default names like 'Frame 123' provide no context about the element's purpose", - impact: "Designers and developers cannot understand the structure", - fix: "Rename with a descriptive, semantic name (e.g., 'Header', 'ProductCard')", + why: "Default names like 'Frame 123' give AI no semantic context to choose appropriate HTML tags or class names", + impact: "AI generates generic
wrappers instead of semantic elements like
,