diff --git a/.coderabbit.yaml b/.coderabbit.yaml index e531f4fa..2fdef815 100644 --- a/.coderabbit.yaml +++ b/.coderabbit.yaml @@ -93,11 +93,9 @@ reviews: CI workflows. This project uses pnpm as package manager and Node.js 24. Ensure workflows use pnpm/action-setup@v4 and --frozen-lockfile. - # Pre-merge check overrides - # TypeScript strict types serve as documentation; 80% docstring threshold is excessive pre_merge_checks: docstring_coverage: - threshold: 40 + enabled: false chat: auto_reply: true diff --git a/src/agents/analysis-agent.test.ts b/src/agents/analysis-agent.test.ts index b1a20ed1..b3f80f56 100644 --- a/src/agents/analysis-agent.test.ts +++ b/src/agents/analysis-agent.test.ts @@ -36,7 +36,7 @@ function createMockIssue(overrides: { definition: { id: overrides.ruleId, name: `Rule ${overrides.ruleId}`, - category: "layout", + category: "structure", why: "test reason", impact: "test impact", fix: "test fix", diff --git a/src/agents/contracts/calibration.ts b/src/agents/contracts/calibration.ts index 940ddfc0..30f1576c 100644 --- a/src/agents/contracts/calibration.ts +++ b/src/agents/contracts/calibration.ts @@ -25,6 +25,7 @@ export const CalibrationConfigSchema = z.object({ }); export type CalibrationConfig = z.infer; +export type CalibrationConfigInput = z.input; export interface CalibrationRun { config: CalibrationConfig; diff --git a/src/agents/evidence-collector.test.ts b/src/agents/evidence-collector.test.ts index b3fe2974..ee0347a5 100644 --- a/src/agents/evidence-collector.test.ts +++ b/src/agents/evidence-collector.test.ts @@ -192,25 +192,25 @@ describe("evidence-collector", () => { const file = { schemaVersion: DISCOVERY_EVIDENCE_SCHEMA_VERSION, entries: [ - { description: "gap1", category: "layout", impact: "hard", fixture: "fx1", timestamp: "t1", source: "evaluation" }, + { description: "gap1", category: "structure", impact: "hard", fixture: "fx1", timestamp: "t1", source: "evaluation" }, ], }; writeFileSync(disPath, JSON.stringify(file), "utf-8"); const result = loadDiscoveryEvidence(disPath); expect(result).toHaveLength(1); - expect(result[0]!.category).toBe("layout"); + expect(result[0]!.category).toBe("structure"); }); it("loads entries from legacy plain-array format (v0 fallback)", () => { const entries: DiscoveryEvidenceEntry[] = [ - { description: "gap1", category: "layout", impact: "hard", fixture: "fx1", timestamp: "t1", source: "evaluation" }, + { description: "gap1", category: "structure", impact: "hard", fixture: "fx1", timestamp: "t1", source: "evaluation" }, ]; writeFileSync(disPath, JSON.stringify(entries), "utf-8"); const result = loadDiscoveryEvidence(disPath); expect(result).toHaveLength(1); - expect(result[0]!.category).toBe("layout"); + expect(result[0]!.category).toBe("structure"); }); it("handles malformed JSON gracefully", () => { @@ -221,7 +221,7 @@ describe("evidence-collector", () => { it("skips invalid entries in legacy array", () => { writeFileSync(disPath, JSON.stringify([ - { description: "gap1", category: "layout", impact: "hard", fixture: "fx1", timestamp: "t1", source: "evaluation" }, + { description: "gap1", category: "structure", impact: "hard", fixture: "fx1", timestamp: "t1", source: "evaluation" }, { bad: "entry" }, ]), "utf-8"); @@ -233,7 +233,7 @@ describe("evidence-collector", () => { const file = { schemaVersion: DISCOVERY_EVIDENCE_SCHEMA_VERSION, entries: [ - { description: "good", category: "layout", impact: "hard", fixture: "fx1", timestamp: "t1", source: "evaluation" }, + { description: "good", category: "structure", impact: "hard", fixture: "fx1", timestamp: "t1", source: "evaluation" }, { bad: "entry" }, { description: "also good", category: "color", impact: "easy", fixture: "fx2", timestamp: "t2", source: "gap-analysis" }, ], @@ -250,7 +250,7 @@ describe("evidence-collector", () => { const file = { schemaVersion: 999, entries: [ - { description: "gap1", category: "layout", impact: "hard", fixture: "fx1", timestamp: "t1", source: "evaluation" }, + { description: "gap1", category: "structure", impact: "hard", fixture: "fx1", timestamp: "t1", source: "evaluation" }, ], }; writeFileSync(disPath, JSON.stringify(file), "utf-8"); @@ -262,7 +262,7 @@ describe("evidence-collector", () => { describe("appendDiscoveryEvidence", () => { it("creates file in versioned format", () => { appendDiscoveryEvidence([ - { description: "gap1", category: "layout", impact: "hard", fixture: "fx1", timestamp: "t1", source: "gap-analysis" }, + { description: "gap1", category: "structure", impact: "hard", fixture: "fx1", timestamp: "t1", source: "gap-analysis" }, ], disPath); const raw = JSON.parse(readFileSync(disPath, "utf-8")) as { schemaVersion: number; entries: DiscoveryEvidenceEntry[] }; @@ -273,7 +273,7 @@ describe("evidence-collector", () => { it("appends to existing entries (different keys)", () => { appendDiscoveryEvidence([ - { description: "gap1", category: "layout", impact: "hard", fixture: "fx1", timestamp: "t1", source: "evaluation" }, + { description: "gap1", category: "structure", impact: "hard", fixture: "fx1", timestamp: "t1", source: "evaluation" }, ], disPath); appendDiscoveryEvidence([ @@ -286,12 +286,12 @@ describe("evidence-collector", () => { it("deduplicates by (category + description + fixture), last-write-wins", () => { appendDiscoveryEvidence([ - { description: "gap1", category: "layout", impact: "hard", fixture: "fx1", timestamp: "t1", source: "evaluation" }, + { description: "gap1", category: "structure", impact: "hard", fixture: "fx1", timestamp: "t1", source: "evaluation" }, ], disPath); // Same category+description+fixture, different impact/timestamp → replaces appendDiscoveryEvidence([ - { description: "gap1", category: "layout", impact: "moderate", fixture: "fx1", timestamp: "t2", source: "evaluation" }, + { description: "gap1", category: "structure", impact: "moderate", fixture: "fx1", timestamp: "t2", source: "evaluation" }, ], disPath); const raw = JSON.parse(readFileSync(disPath, "utf-8")) as { entries: DiscoveryEvidenceEntry[] }; @@ -302,11 +302,11 @@ describe("evidence-collector", () => { it("dedupe is case-insensitive for category and description", () => { appendDiscoveryEvidence([ - { description: "Gap One", category: "Layout", impact: "hard", fixture: "fx1", timestamp: "t1", source: "evaluation" }, + { description: "Gap One", category: "Structure", impact: "hard", fixture: "fx1", timestamp: "t1", source: "evaluation" }, ], disPath); appendDiscoveryEvidence([ - { description: "gap one", category: "layout", impact: "easy", fixture: "fx1", timestamp: "t2", source: "evaluation" }, + { description: "gap one", category: "structure", impact: "easy", fixture: "fx1", timestamp: "t2", source: "evaluation" }, ], disPath); const raw = JSON.parse(readFileSync(disPath, "utf-8")) as { entries: DiscoveryEvidenceEntry[] }; @@ -316,11 +316,11 @@ describe("evidence-collector", () => { it("dedupe is case-insensitive for fixture", () => { appendDiscoveryEvidence([ - { description: "gap1", category: "layout", impact: "hard", fixture: "FX1", timestamp: "t1", source: "evaluation" }, + { description: "gap1", category: "structure", impact: "hard", fixture: "FX1", timestamp: "t1", source: "evaluation" }, ], disPath); appendDiscoveryEvidence([ - { description: "gap1", category: "layout", impact: "easy", fixture: "fx1", timestamp: "t2", source: "evaluation" }, + { description: "gap1", category: "structure", impact: "easy", fixture: "fx1", timestamp: "t2", source: "evaluation" }, ], disPath); const raw = JSON.parse(readFileSync(disPath, "utf-8")) as { entries: DiscoveryEvidenceEntry[] }; @@ -330,8 +330,8 @@ describe("evidence-collector", () => { it("dedupes within a single append call (last row wins)", () => { appendDiscoveryEvidence([ - { description: "gap1", category: "layout", impact: "hard", fixture: "fx1", timestamp: "t1", source: "evaluation" }, - { description: "gap1", category: "layout", impact: "easy", fixture: "fx1", timestamp: "t2", source: "evaluation" }, + { description: "gap1", category: "structure", impact: "hard", fixture: "fx1", timestamp: "t1", source: "evaluation" }, + { description: "gap1", category: "structure", impact: "easy", fixture: "fx1", timestamp: "t2", source: "evaluation" }, ], disPath); const raw = JSON.parse(readFileSync(disPath, "utf-8")) as { entries: DiscoveryEvidenceEntry[] }; @@ -341,8 +341,8 @@ describe("evidence-collector", () => { it("same description different fixture → kept as separate entries", () => { appendDiscoveryEvidence([ - { description: "gap1", category: "layout", impact: "hard", fixture: "fx1", timestamp: "t1", source: "evaluation" }, - { description: "gap1", category: "layout", impact: "hard", fixture: "fx2", timestamp: "t1", source: "evaluation" }, + { description: "gap1", category: "structure", impact: "hard", fixture: "fx1", timestamp: "t1", source: "evaluation" }, + { description: "gap1", category: "structure", impact: "hard", fixture: "fx2", timestamp: "t1", source: "evaluation" }, ], disPath); const raw = JSON.parse(readFileSync(disPath, "utf-8")) as { entries: DiscoveryEvidenceEntry[] }; @@ -352,7 +352,7 @@ describe("evidence-collector", () => { it("migrates legacy array to versioned format on append", () => { // Write legacy format writeFileSync(disPath, JSON.stringify([ - { description: "old", category: "layout", impact: "hard", fixture: "fx1", timestamp: "t0", source: "evaluation" }, + { description: "old", category: "structure", impact: "hard", fixture: "fx1", timestamp: "t0", source: "evaluation" }, ]), "utf-8"); appendDiscoveryEvidence([ @@ -366,7 +366,7 @@ describe("evidence-collector", () => { it("does nothing for empty entries", () => { appendDiscoveryEvidence([ - { description: "gap1", category: "layout", impact: "hard", fixture: "fx1", timestamp: "t1", source: "evaluation" }, + { description: "gap1", category: "structure", impact: "hard", fixture: "fx1", timestamp: "t1", source: "evaluation" }, ], disPath); const before = readFileSync(disPath, "utf-8"); @@ -382,7 +382,7 @@ describe("evidence-collector", () => { const before = readFileSync(disPath, "utf-8"); expect(() => appendDiscoveryEvidence([ - { description: "new", category: "layout", impact: "hard", fixture: "fx1", timestamp: "t1", source: "evaluation" }, + { description: "new", category: "structure", impact: "hard", fixture: "fx1", timestamp: "t1", source: "evaluation" }, ], disPath)).toThrow(/Unsupported discovery-evidence schemaVersion/); // File must not be overwritten @@ -393,12 +393,12 @@ describe("evidence-collector", () => { describe("pruneDiscoveryEvidence", () => { it("removes entries for specified categories (case-insensitive)", () => { appendDiscoveryEvidence([ - { description: "gap1", category: "Layout", impact: "hard", fixture: "fx1", timestamp: "t1", source: "evaluation" }, - { description: "gap2", category: "layout", impact: "hard", fixture: "fx2", timestamp: "t2", source: "gap-analysis" }, + { description: "gap1", category: "Structure", impact: "hard", fixture: "fx1", timestamp: "t1", source: "evaluation" }, + { description: "gap2", category: "structure", impact: "hard", fixture: "fx2", timestamp: "t2", source: "gap-analysis" }, { description: "gap3", category: "color", impact: "moderate", fixture: "fx1", timestamp: "t1", source: "evaluation" }, ], disPath); - pruneDiscoveryEvidence(["layout"], disPath); + pruneDiscoveryEvidence(["structure"], disPath); const raw = JSON.parse(readFileSync(disPath, "utf-8")) as { entries: DiscoveryEvidenceEntry[] }; expect(raw.entries).toHaveLength(1); @@ -407,10 +407,10 @@ describe("evidence-collector", () => { it("writes versioned format after prune", () => { appendDiscoveryEvidence([ - { description: "gap1", category: "layout", impact: "hard", fixture: "fx1", timestamp: "t1", source: "evaluation" }, + { description: "gap1", category: "structure", impact: "hard", fixture: "fx1", timestamp: "t1", source: "evaluation" }, ], disPath); - pruneDiscoveryEvidence(["layout"], disPath); + pruneDiscoveryEvidence(["structure"], disPath); const raw = JSON.parse(readFileSync(disPath, "utf-8")) as { schemaVersion: number; entries: DiscoveryEvidenceEntry[] }; expect(raw.schemaVersion).toBe(DISCOVERY_EVIDENCE_SCHEMA_VERSION); @@ -419,7 +419,7 @@ describe("evidence-collector", () => { it("does nothing for empty categories", () => { appendDiscoveryEvidence([ - { description: "gap1", category: "layout", impact: "hard", fixture: "fx1", timestamp: "t1", source: "evaluation" }, + { description: "gap1", category: "structure", impact: "hard", fixture: "fx1", timestamp: "t1", source: "evaluation" }, ], disPath); pruneDiscoveryEvidence([], disPath); @@ -430,10 +430,10 @@ describe("evidence-collector", () => { it("trims categories when matching", () => { appendDiscoveryEvidence([ - { description: "gap1", category: "layout", impact: "hard", fixture: "fx1", timestamp: "t1", source: "evaluation" }, + { description: "gap1", category: "structure", impact: "hard", fixture: "fx1", timestamp: "t1", source: "evaluation" }, ], disPath); - pruneDiscoveryEvidence([" layout "], disPath); + pruneDiscoveryEvidence([" structure "], disPath); const raw = JSON.parse(readFileSync(disPath, "utf-8")) as { entries: DiscoveryEvidenceEntry[] }; expect(raw.entries).toHaveLength(0); @@ -441,12 +441,12 @@ describe("evidence-collector", () => { it("throws when file has unsupported schemaVersion", () => { const file = { schemaVersion: 999, entries: [ - { description: "gap1", category: "layout", impact: "hard", fixture: "fx1", timestamp: "t1", source: "evaluation" }, + { description: "gap1", category: "structure", impact: "hard", fixture: "fx1", timestamp: "t1", source: "evaluation" }, ]}; writeFileSync(disPath, JSON.stringify(file), "utf-8"); const before = readFileSync(disPath, "utf-8"); - expect(() => pruneDiscoveryEvidence(["layout"], disPath)).toThrow(/Unsupported discovery-evidence schemaVersion/); + expect(() => pruneDiscoveryEvidence(["structure"], disPath)).toThrow(/Unsupported discovery-evidence schemaVersion/); expect(readFileSync(disPath, "utf-8")).toBe(before); }); diff --git a/src/agents/gap-rule-report.test.ts b/src/agents/gap-rule-report.test.ts index 29e66815..00564f5c 100644 --- a/src/agents/gap-rule-report.test.ts +++ b/src/agents/gap-rule-report.test.ts @@ -26,7 +26,7 @@ describe("generateGapRuleReport", () => { fileKey: "fx-a", gaps: [ { - category: "layout", + category: "structure", area: "Title", description: "Alignment mismatch", coveredByExistingRule: false, @@ -44,7 +44,7 @@ describe("generateGapRuleReport", () => { fileKey: "fx-b", gaps: [ { - category: "layout", + category: "structure", area: "Title", description: "Alignment mismatch", coveredByExistingRule: false, @@ -63,7 +63,7 @@ describe("generateGapRuleReport", () => { expect(gapRunCount).toBe(2); expect(runCount).toBe(0); // No analysis.json + conversion.json in these dirs - expect(markdown).toContain("layout"); + expect(markdown).toContain("structure"); expect(markdown).toContain("text-alignment-mismatch"); expect(markdown).toContain("Repeating patterns"); }); diff --git a/src/agents/orchestrator.test.ts b/src/agents/orchestrator.test.ts index cc239de5..e8e49fa8 100644 --- a/src/agents/orchestrator.test.ts +++ b/src/agents/orchestrator.test.ts @@ -85,8 +85,8 @@ describe("runCalibrationEvaluate", () => { scoreReport: { overall: { score: 75, maxScore: 100, percentage: 75, grade: "B" as const }, byCategory: { - layout: { - category: "layout" as const, + structure: { + category: "structure" as const, score: 70, maxScore: 100, percentage: 70, @@ -133,20 +133,8 @@ describe("runCalibrationEvaluate", () => { diversityScore: 100, bySeverity: { blocking: 0, risk: 0, "missing-info": 0, suggestion: 0 }, }, - "ai-readability": { - category: "ai-readability" as const, - score: 100, - maxScore: 100, - percentage: 100, - issueCount: 0, - uniqueRuleCount: 0, - weightedIssueCount: 0, - densityScore: 100, - diversityScore: 100, - bySeverity: { blocking: 0, risk: 0, "missing-info": 0, suggestion: 0 }, - }, - "handoff-risk": { - category: "handoff-risk" as const, + behavior: { + category: "behavior" as const, score: 100, maxScore: 100, percentage: 100, diff --git a/src/agents/orchestrator.ts b/src/agents/orchestrator.ts index d958e2d4..b3d64a26 100644 --- a/src/agents/orchestrator.ts +++ b/src/agents/orchestrator.ts @@ -2,7 +2,7 @@ import type { AnalysisFile, AnalysisNode, AnalysisNodeType } from "../core/contr import { analyzeFile } from "../core/engine/rule-engine.js"; import { RULE_CONFIGS } from "../core/rules/rule-config.js"; -import type { CalibrationConfig } from "./contracts/calibration.js"; +import type { CalibrationConfigInput } from "./contracts/calibration.js"; import { CalibrationConfigSchema } from "./contracts/calibration.js"; import type { NodeIssueSummary } from "./contracts/analysis-agent.js"; import type { ScoreReport } from "../core/engine/scoring.js"; @@ -172,7 +172,7 @@ function buildRuleScoresMap(): Record extends infer T ? T : never; ruleScores: Record; diff --git a/src/agents/report-generator.test.ts b/src/agents/report-generator.test.ts index b7ddaf11..28dff554 100644 --- a/src/agents/report-generator.test.ts +++ b/src/agents/report-generator.test.ts @@ -8,12 +8,11 @@ import { } from "./report-generator.js"; const ALL_CATEGORIES: Category[] = [ - "layout", + "structure", "token", "component", "naming", - "ai-readability", - "handoff-risk", + "behavior", ]; function buildCategoryScore( @@ -222,7 +221,7 @@ describe("generateCalibrationReport", () => { it("renders new rule proposals when they exist", () => { const proposal: NewRuleProposal = { suggestedId: "shadow-complexity", - category: "layout", + category: "structure", description: "Detects complex shadow configurations", suggestedSeverity: "risk", suggestedScore: -4, @@ -234,7 +233,7 @@ describe("generateCalibrationReport", () => { const report = generateCalibrationReport(data); expect(report).toContain("### shadow-complexity"); - expect(report).toContain("layout"); + expect(report).toContain("structure"); expect(report).toContain("Detects complex shadow configurations"); expect(report).toContain("risk"); expect(report).toContain("-4"); diff --git a/src/cli/commands/internal/calibrate-run.ts b/src/cli/commands/internal/calibrate-run.ts index 0906a47a..b1dbde8b 100644 --- a/src/cli/commands/internal/calibrate-run.ts +++ b/src/cli/commands/internal/calibrate-run.ts @@ -37,7 +37,6 @@ export function registerCalibrateRun(cli: CAC): void { input, maxConversionNodes: options.maxNodes ?? 5, samplingStrategy: (options.sampling as "all" | "top-issues" | "random") ?? "top-issues", - outputPath: "unused", ...(figmaToken && { token: figmaToken }), }); diff --git a/src/core/contracts/category.ts b/src/core/contracts/category.ts index d527ad78..b6b65c82 100644 --- a/src/core/contracts/category.ts +++ b/src/core/contracts/category.ts @@ -1,12 +1,11 @@ import { z } from "zod"; export const CategorySchema = z.enum([ - "layout", + "structure", "token", "component", "naming", - "ai-readability", - "handoff-risk", + "behavior", ]); export type Category = z.infer; @@ -14,10 +13,9 @@ export type Category = z.infer; export const CATEGORIES = CategorySchema.options; export const CATEGORY_LABELS: Record = { - layout: "Layout", + structure: "Structure", token: "Design Token", component: "Component", naming: "Naming", - "ai-readability": "AI Readability", - "handoff-risk": "Handoff Risk", + behavior: "Behavior", }; diff --git a/src/core/contracts/rule.ts b/src/core/contracts/rule.ts index 3921e1be..7eda902c 100644 --- a/src/core/contracts/rule.ts +++ b/src/core/contracts/rule.ts @@ -90,16 +90,16 @@ export interface Rule { * Rule ID type for type safety */ export type RuleId = - // Layout (11) + // Structure (9) | "no-auto-layout" | "absolute-position-in-auto-layout" - | "fixed-width-in-responsive-context" + | "fixed-size-in-auto-layout" + | "missing-size-constraint" | "missing-responsive-behavior" | "group-usage" - | "fixed-size-in-auto-layout" - | "missing-min-width" - | "missing-max-width" | "deep-nesting" + | "z-index-dependent-layout" + | "unnecessary-node" // Token (7) | "raw-color" | "raw-font" @@ -112,27 +112,23 @@ export type RuleId = | "missing-component" | "detached-instance" | "missing-component-description" + | "variant-structure-mismatch" // Naming (5) | "default-name" | "non-semantic-name" | "inconsistent-naming-convention" | "numeric-suffix-name" | "too-long-name" - // AI Readability (5) - | "ambiguous-structure" - | "z-index-dependent-layout" - | "missing-layout-hint" - | "invisible-layer" - | "empty-frame" - // Handoff Risk (5) - | "hardcode-risk" + // Behavior (4) | "text-truncation-unhandled" - | "prototype-link-in-design"; + | "prototype-link-in-design" + | "overflow-behavior-unknown" + | "wrap-behavior-unknown"; /** * Categories that support depthWeight */ -export const DEPTH_WEIGHT_CATEGORIES: Category[] = ["layout", "handoff-risk"]; +export const DEPTH_WEIGHT_CATEGORIES: Category[] = ["structure", "behavior"]; /** * Check if a category supports depth weighting diff --git a/src/core/engine/integration.test.ts b/src/core/engine/integration.test.ts index e47931c4..d3afc757 100644 --- a/src/core/engine/integration.test.ts +++ b/src/core/engine/integration.test.ts @@ -71,10 +71,10 @@ describe("Integration: fixture → analyze → score", () => { expect(scores.overall.score).toBe(scores.overall.percentage); expect(scores.overall.maxScore).toBe(100); - // Exactly 6 categories present + // Exactly 5 categories present const categories = Object.keys(scores.byCategory).sort(); expect(categories).toEqual( - ["ai-readability", "component", "handoff-risk", "layout", "naming", "token"], + ["behavior", "component", "naming", "structure", "token"], ); // Each category has valid percentages diff --git a/src/core/engine/rule-engine.test.ts b/src/core/engine/rule-engine.test.ts index 1f257cbf..bffc485c 100644 --- a/src/core/engine/rule-engine.test.ts +++ b/src/core/engine/rule-engine.test.ts @@ -290,7 +290,7 @@ describe("RuleEngine.analyze — depth weight calculation", () => { }); const file = makeFile({ document: root }); - // Use only no-auto-layout which has depthWeight: 1.5 and is in "layout" (supports depth weight) + // Use only no-auto-layout which has depthWeight: 1.5 and is in "structure" (supports depth weight) const result = analyzeFile(file, { enabledRules: ["no-auto-layout"] }); // Find issues at different depths — assert they exist to avoid vacuous pass diff --git a/src/core/engine/scoring.test.ts b/src/core/engine/scoring.test.ts index 2314cada..91bd5259 100644 --- a/src/core/engine/scoring.test.ts +++ b/src/core/engine/scoring.test.ts @@ -72,8 +72,8 @@ describe("calculateScores", () => { it("counts issues by severity correctly", () => { const issues = [ - makeIssue({ ruleId: "no-auto-layout", category: "layout", severity: "blocking" }), - makeIssue({ ruleId: "group-usage", category: "layout", severity: "risk" }), + makeIssue({ ruleId: "no-auto-layout", category: "structure", severity: "blocking" }), + makeIssue({ ruleId: "group-usage", category: "structure", severity: "risk" }), makeIssue({ ruleId: "raw-color", category: "token", severity: "missing-info" }), makeIssue({ ruleId: "numeric-suffix-name", category: "naming", severity: "suggestion" }), ]; @@ -87,125 +87,111 @@ describe("calculateScores", () => { }); it("applies severity density weights (blocking=3.0 > risk=2.0 > missing-info=1.0 > suggestion=0.5)", () => { - // Single blocking issue on 100 nodes const blocking = calculateScores(makeResult([ - makeIssue({ ruleId: "no-auto-layout", category: "layout", severity: "blocking" }), + makeIssue({ ruleId: "no-auto-layout", category: "structure", severity: "blocking" }), ], 100)); - // Single suggestion issue on 100 nodes const suggestion = calculateScores(makeResult([ makeIssue({ ruleId: "numeric-suffix-name", category: "naming", severity: "suggestion" }), ], 100)); - // Blocking issue should reduce layout category score more than suggestion reduces naming - expect(blocking.byCategory.layout.densityScore).toBeLessThan( + expect(blocking.byCategory.structure.densityScore).toBeLessThan( suggestion.byCategory.naming.densityScore ); }); it("density score decreases as weighted issue count increases relative to node count", () => { const few = calculateScores(makeResult([ - makeIssue({ ruleId: "no-auto-layout", category: "layout", severity: "blocking" }), + makeIssue({ ruleId: "no-auto-layout", category: "structure", severity: "blocking" }), ], 100)); const many = calculateScores(makeResult([ - makeIssue({ ruleId: "no-auto-layout", category: "layout", severity: "blocking" }), - makeIssue({ ruleId: "no-auto-layout", category: "layout", severity: "blocking" }), - makeIssue({ ruleId: "no-auto-layout", category: "layout", severity: "blocking" }), - makeIssue({ ruleId: "no-auto-layout", category: "layout", severity: "blocking" }), - makeIssue({ ruleId: "no-auto-layout", category: "layout", severity: "blocking" }), + makeIssue({ ruleId: "no-auto-layout", category: "structure", severity: "blocking" }), + makeIssue({ ruleId: "no-auto-layout", category: "structure", severity: "blocking" }), + makeIssue({ ruleId: "no-auto-layout", category: "structure", severity: "blocking" }), + makeIssue({ ruleId: "no-auto-layout", category: "structure", severity: "blocking" }), + makeIssue({ ruleId: "no-auto-layout", category: "structure", severity: "blocking" }), ], 100)); - expect(many.byCategory.layout.densityScore).toBeLessThan( - few.byCategory.layout.densityScore + expect(many.byCategory.structure.densityScore).toBeLessThan( + few.byCategory.structure.densityScore ); }); it("diversity score penalizes more unique rules being triggered", () => { - // 3 issues from 1 rule (low diversity = high diversity score) const concentrated = calculateScores(makeResult([ - makeIssue({ ruleId: "no-auto-layout", category: "layout", severity: "risk" }), - makeIssue({ ruleId: "no-auto-layout", category: "layout", severity: "risk" }), - makeIssue({ ruleId: "no-auto-layout", category: "layout", severity: "risk" }), + makeIssue({ ruleId: "no-auto-layout", category: "structure", severity: "risk" }), + makeIssue({ ruleId: "no-auto-layout", category: "structure", severity: "risk" }), + makeIssue({ ruleId: "no-auto-layout", category: "structure", severity: "risk" }), ], 100)); - // 3 issues from 3 different rules (high diversity = low diversity score) const spread = calculateScores(makeResult([ - makeIssue({ ruleId: "no-auto-layout", category: "layout", severity: "risk" }), - makeIssue({ ruleId: "group-usage", category: "layout", severity: "risk" }), - makeIssue({ ruleId: "deep-nesting", category: "layout", severity: "risk" }), + makeIssue({ ruleId: "no-auto-layout", category: "structure", severity: "risk" }), + makeIssue({ ruleId: "group-usage", category: "structure", severity: "risk" }), + makeIssue({ ruleId: "deep-nesting", category: "structure", severity: "risk" }), ], 100)); - expect(concentrated.byCategory.layout.diversityScore).toBeGreaterThan( - spread.byCategory.layout.diversityScore + expect(concentrated.byCategory.structure.diversityScore).toBeGreaterThan( + spread.byCategory.structure.diversityScore ); }); it("combined score = density * 0.7 + diversity * 0.3", () => { const issues = [ - makeIssue({ ruleId: "no-auto-layout", category: "layout", severity: "blocking" }), - makeIssue({ ruleId: "group-usage", category: "layout", severity: "risk" }), + makeIssue({ ruleId: "no-auto-layout", category: "structure", severity: "blocking" }), + makeIssue({ ruleId: "group-usage", category: "structure", severity: "risk" }), ]; const scores = calculateScores(makeResult(issues, 100)); - const layout = scores.byCategory.layout; + const structure = scores.byCategory.structure; - const expected = Math.round(layout.densityScore * 0.7 + layout.diversityScore * 0.3); - // Floor is 5, so clamp + const expected = Math.round(structure.densityScore * 0.7 + structure.diversityScore * 0.3); const clamped = Math.max(5, Math.min(100, expected)); - expect(layout.percentage).toBe(clamped); + expect(structure.percentage).toBe(clamped); }); it("score never goes below SCORE_FLOOR (5) when issues exist", () => { - // To hit the floor, we need both density→0 AND diversity→0 - // Use many issues from many different rules to maximize both penalties - const layoutRules = [ + const structureRules = [ "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", + "missing-size-constraint", "z-index-dependent-layout", "unnecessary-node", ] as const; const issues: AnalysisIssue[] = []; - for (const ruleId of layoutRules) { + for (const ruleId of structureRules) { for (let i = 0; i < 50; i++) { - issues.push(makeIssue({ ruleId, category: "layout", severity: "blocking" })); + issues.push(makeIssue({ ruleId, category: "structure", severity: "blocking" })); } } const scores = calculateScores(makeResult(issues, 10)); - - // With density near 0 and diversity near 0, combined should clamp to floor - expect(scores.byCategory.layout.percentage).toBe(5); + expect(scores.byCategory.structure.percentage).toBe(5); }); it("categories without issues get 100%", () => { - // Issues only in layout category const scores = calculateScores(makeResult([ - makeIssue({ ruleId: "no-auto-layout", category: "layout", severity: "blocking" }), + makeIssue({ ruleId: "no-auto-layout", category: "structure", severity: "blocking" }), ])); expect(scores.byCategory.token.percentage).toBe(100); expect(scores.byCategory.component.percentage).toBe(100); expect(scores.byCategory.naming.percentage).toBe(100); - expect(scores.byCategory["ai-readability"].percentage).toBe(100); - expect(scores.byCategory["handoff-risk"].percentage).toBe(100); + expect(scores.byCategory.behavior.percentage).toBe(100); }); - it("overall score is weighted average of all 6 categories", () => { - // With equal weights (all 1.0), overall = average of all category percentages + it("overall score is weighted average of all 5 categories", () => { const scores = calculateScores(makeResult([ - makeIssue({ ruleId: "no-auto-layout", category: "layout", severity: "blocking" }), + makeIssue({ ruleId: "no-auto-layout", category: "structure", severity: "blocking" }), ], 100)); const categoryPercentages = [ - scores.byCategory.layout.percentage, + scores.byCategory.structure.percentage, scores.byCategory.token.percentage, scores.byCategory.component.percentage, scores.byCategory.naming.percentage, - scores.byCategory["ai-readability"].percentage, - scores.byCategory["handoff-risk"].percentage, + scores.byCategory.behavior.percentage, ]; const expectedOverall = Math.round( - categoryPercentages.reduce((a, b) => a + b, 0) / 6 + categoryPercentages.reduce((a, b) => a + b, 0) / 5 ); expect(scores.overall.percentage).toBe(expectedOverall); }); @@ -215,8 +201,6 @@ describe("calculateScores", () => { makeIssue({ ruleId: "raw-color", category: "token", severity: "missing-info" }), ], 0)); - // With nodeCount 0, density stays at 100 (no density penalty applied) - // But diversity still applies since there are issues expect(scores.byCategory.token.densityScore).toBe(100); expect(scores.byCategory.token.percentage).toBeLessThan(100); expect(Number.isFinite(scores.overall.percentage)).toBe(true); @@ -234,29 +218,28 @@ describe("calculateScores", () => { it("tracks uniqueRuleCount per category", () => { const issues = [ - makeIssue({ ruleId: "no-auto-layout", category: "layout", severity: "blocking" }), - makeIssue({ ruleId: "no-auto-layout", category: "layout", severity: "blocking" }), - makeIssue({ ruleId: "group-usage", category: "layout", severity: "risk" }), + makeIssue({ ruleId: "no-auto-layout", category: "structure", severity: "blocking" }), + makeIssue({ ruleId: "no-auto-layout", category: "structure", severity: "blocking" }), + makeIssue({ ruleId: "group-usage", category: "structure", severity: "risk" }), ]; const scores = calculateScores(makeResult(issues)); - // 2 unique rules in layout, despite 3 issues - expect(scores.byCategory.layout.uniqueRuleCount).toBe(2); - expect(scores.byCategory.layout.issueCount).toBe(3); + expect(scores.byCategory.structure.uniqueRuleCount).toBe(2); + expect(scores.byCategory.structure.issueCount).toBe(3); }); it("bySeverity counts are accurate per category", () => { const issues = [ - makeIssue({ ruleId: "no-auto-layout", category: "layout", severity: "blocking" }), - makeIssue({ ruleId: "group-usage", category: "layout", severity: "risk" }), - makeIssue({ ruleId: "group-usage", category: "layout", severity: "risk" }), + makeIssue({ ruleId: "no-auto-layout", category: "structure", severity: "blocking" }), + makeIssue({ ruleId: "group-usage", category: "structure", severity: "risk" }), + makeIssue({ ruleId: "group-usage", category: "structure", severity: "risk" }), ]; const scores = calculateScores(makeResult(issues)); - expect(scores.byCategory.layout.bySeverity.blocking).toBe(1); - expect(scores.byCategory.layout.bySeverity.risk).toBe(2); - expect(scores.byCategory.layout.bySeverity["missing-info"]).toBe(0); - expect(scores.byCategory.layout.bySeverity.suggestion).toBe(0); + expect(scores.byCategory.structure.bySeverity.blocking).toBe(1); + expect(scores.byCategory.structure.bySeverity.risk).toBe(2); + expect(scores.byCategory.structure.bySeverity["missing-info"]).toBe(0); + expect(scores.byCategory.structure.bySeverity.suggestion).toBe(0); }); }); @@ -269,16 +252,14 @@ describe("calculateGrade (via calculateScores)", () => { }); it("score < 50% -> F", () => { - // Many blocking issues in all categories to push overall below 50% const issues: AnalysisIssue[] = []; - const categories: Category[] = ["layout", "token", "component", "naming", "ai-readability", "handoff-risk"]; + const categories: Category[] = ["structure", "token", "component", "naming", "behavior"]; const rulesPerCat: Record = { - layout: ["no-auto-layout", "group-usage", "deep-nesting", "fixed-size-in-auto-layout", "missing-responsive-behavior"], + structure: ["no-auto-layout", "group-usage", "deep-nesting", "fixed-size-in-auto-layout", "missing-responsive-behavior", "absolute-position-in-auto-layout", "missing-size-constraint", "z-index-dependent-layout", "unnecessary-node"], token: ["raw-color", "raw-font", "inconsistent-spacing", "magic-number-spacing", "raw-shadow"], - component: ["missing-component", "detached-instance", "missing-component-description"], + component: ["missing-component", "detached-instance", "missing-component-description", "variant-structure-mismatch"], 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", "prototype-link-in-design"], + behavior: ["text-truncation-unhandled", "prototype-link-in-design", "overflow-behavior-unknown", "wrap-behavior-unknown"], }; for (const cat of categories) { @@ -315,21 +296,20 @@ describe("formatScoreSummary", () => { expect(summary).toContain("Overall: S (100%)"); }); - it("includes all 6 categories", () => { + it("includes all 5 categories", () => { const scores = calculateScores(makeResult([])); const summary = formatScoreSummary(scores); - expect(summary).toContain("layout:"); + expect(summary).toContain("structure:"); expect(summary).toContain("token:"); expect(summary).toContain("component:"); expect(summary).toContain("naming:"); - expect(summary).toContain("ai-readability:"); - expect(summary).toContain("handoff-risk:"); + expect(summary).toContain("behavior:"); }); it("includes severity breakdown", () => { const scores = calculateScores(makeResult([ - makeIssue({ ruleId: "no-auto-layout", category: "layout", severity: "blocking" }), + makeIssue({ ruleId: "no-auto-layout", category: "structure", severity: "blocking" }), ])); const summary = formatScoreSummary(scores); @@ -342,12 +322,11 @@ describe("formatScoreSummary", () => { describe("getCategoryLabel", () => { it("returns correct labels for all categories", () => { - expect(getCategoryLabel("layout")).toBe("Layout"); + expect(getCategoryLabel("structure")).toBe("Structure"); expect(getCategoryLabel("token")).toBe("Design Token"); expect(getCategoryLabel("component")).toBe("Component"); expect(getCategoryLabel("naming")).toBe("Naming"); - expect(getCategoryLabel("ai-readability")).toBe("AI Readability"); - expect(getCategoryLabel("handoff-risk")).toBe("Handoff Risk"); + expect(getCategoryLabel("behavior")).toBe("Behavior"); }); }); @@ -365,8 +344,8 @@ describe("getSeverityLabel", () => { describe("buildResultJson", () => { it("includes all expected fields", () => { const result = makeResult([ - makeIssue({ ruleId: "no-auto-layout", category: "layout", severity: "blocking" }), - makeIssue({ ruleId: "no-auto-layout", category: "layout", severity: "blocking" }), + makeIssue({ ruleId: "no-auto-layout", category: "structure", severity: "blocking" }), + makeIssue({ ruleId: "no-auto-layout", category: "structure", severity: "blocking" }), makeIssue({ ruleId: "raw-color", category: "token", severity: "missing-info" }), ]); const scores = calculateScores(result); @@ -383,8 +362,8 @@ describe("buildResultJson", () => { it("aggregates issuesByRule correctly", () => { const result = makeResult([ - makeIssue({ ruleId: "no-auto-layout", category: "layout", severity: "blocking" }), - makeIssue({ ruleId: "no-auto-layout", category: "layout", severity: "blocking" }), + makeIssue({ ruleId: "no-auto-layout", category: "structure", severity: "blocking" }), + makeIssue({ ruleId: "no-auto-layout", category: "structure", severity: "blocking" }), makeIssue({ ruleId: "raw-color", category: "token", severity: "missing-info" }), ]); const scores = calculateScores(result); diff --git a/src/core/engine/scoring.ts b/src/core/engine/scoring.ts index 59cd12c9..e6732586 100644 --- a/src/core/engine/scoring.ts +++ b/src/core/engine/scoring.ts @@ -73,12 +73,11 @@ const SEVERITY_DENSITY_WEIGHT: Record = { * Must be updated when rules are added/removed from a category. */ const TOTAL_RULES_PER_CATEGORY: Record = { - layout: 8, + structure: 9, token: 7, - component: 3, + component: 4, naming: 5, - "ai-readability": 5, - "handoff-risk": 4, + behavior: 4, }; /** @@ -89,12 +88,11 @@ const TOTAL_RULES_PER_CATEGORY: Record = { * more strongly with visual-compare similarity, these weights can be adjusted. */ const CATEGORY_WEIGHT: Record = { - layout: 1.0, + structure: 1.0, token: 1.0, component: 1.0, naming: 1.0, - "ai-readability": 1.0, - "handoff-risk": 1.0, + behavior: 1.0, }; /** @@ -335,12 +333,11 @@ export function formatScoreSummary(report: ScoreReport): string { */ export function getCategoryLabel(category: Category): string { const labels: Record = { - layout: "Layout", + structure: "Structure", token: "Design Token", component: "Component", naming: "Naming", - "ai-readability": "AI Readability", - "handoff-risk": "Handoff Risk", + behavior: "Behavior", }; return labels[category]; } diff --git a/src/core/report-html/index.ts b/src/core/report-html/index.ts index ba9ff0c4..bdac2c6b 100644 --- a/src/core/report-html/index.ts +++ b/src/core/report-html/index.ts @@ -114,7 +114,7 @@ export function generateHtmlReport(
-
+
${CATEGORIES.map(cat => { const cs = scores.byCategory[cat]; const desc = CATEGORY_DESCRIPTIONS[cat]; diff --git a/src/core/rules/ai-readability/ambiguous-structure.test.ts b/src/core/rules/ai-readability/ambiguous-structure.test.ts deleted file mode 100644 index f5e6bd7b..00000000 --- a/src/core/rules/ai-readability/ambiguous-structure.test.ts +++ /dev/null @@ -1,114 +0,0 @@ -import { makeNode, makeFile, makeContext } from "../test-helpers.js"; -import { ambiguousStructure } from "./index.js"; - -describe("ambiguous-structure", () => { - it("has correct rule definition metadata", () => { - const def = ambiguousStructure.definition; - expect(def.id).toBe("ambiguous-structure"); - expect(def.category).toBe("ai-readability"); - expect(def.why).toContain("Overlapping"); - expect(def.fix).toContain("Auto Layout"); - }); - - it("returns null for non-container nodes", () => { - const node = makeNode({ type: "TEXT" }); - const ctx = makeContext(); - expect(ambiguousStructure.check(node, ctx)).toBeNull(); - }); - - it("returns null for container with auto layout", () => { - const child1 = makeNode({ id: "c:1" }); - const child2 = makeNode({ id: "c:2" }); - const node = makeNode({ - layoutMode: "HORIZONTAL", - children: [child1, child2], - }); - const ctx = makeContext(); - expect(ambiguousStructure.check(node, ctx)).toBeNull(); - }); - - it("returns null for container with only 1 child", () => { - const child = makeNode({ id: "c:1" }); - const node = makeNode({ children: [child] }); - const ctx = makeContext(); - expect(ambiguousStructure.check(node, ctx)).toBeNull(); - }); - - it("returns null for non-overlapping children", () => { - const child1 = makeNode({ - id: "c:1", - absoluteBoundingBox: { x: 0, y: 0, width: 100, height: 100 }, - }); - const child2 = makeNode({ - id: "c:2", - absoluteBoundingBox: { x: 200, y: 0, width: 100, height: 100 }, - }); - const node = makeNode({ - name: "Container", - children: [child1, child2], - }); - const ctx = makeContext(); - expect(ambiguousStructure.check(node, ctx)).toBeNull(); - }); - - it("flags container with overlapping visible children", () => { - const child1 = makeNode({ - id: "c:1", - absoluteBoundingBox: { x: 0, y: 0, width: 100, height: 100 }, - }); - const child2 = makeNode({ - id: "c:2", - absoluteBoundingBox: { x: 50, y: 50, width: 100, height: 100 }, - }); - const node = makeNode({ - name: "Ambiguous", - children: [child1, child2], - }); - const ctx = makeContext(); - - const result = ambiguousStructure.check(node, ctx); - expect(result).not.toBeNull(); - expect(result!.ruleId).toBe("ambiguous-structure"); - expect(result!.message).toContain("Ambiguous"); - expect(result!.message).toContain("overlapping"); - }); - - it("returns null when overlapping children are hidden", () => { - const child1 = makeNode({ - id: "c:1", - visible: false, - absoluteBoundingBox: { x: 0, y: 0, width: 100, height: 100 }, - }); - const child2 = makeNode({ - id: "c:2", - absoluteBoundingBox: { x: 50, y: 50, width: 100, height: 100 }, - }); - const node = makeNode({ - name: "Container", - children: [child1, child2], - }); - const ctx = makeContext(); - expect(ambiguousStructure.check(node, ctx)).toBeNull(); - }); - - it("works with GROUP container type", () => { - const child1 = makeNode({ - id: "c:1", - absoluteBoundingBox: { x: 0, y: 0, width: 100, height: 100 }, - }); - const child2 = makeNode({ - id: "c:2", - absoluteBoundingBox: { x: 50, y: 50, width: 100, height: 100 }, - }); - const node = makeNode({ - name: "GroupContainer", - type: "GROUP", - children: [child1, child2], - }); - const ctx = makeContext(); - - const result = ambiguousStructure.check(node, ctx); - expect(result).not.toBeNull(); - expect(result!.ruleId).toBe("ambiguous-structure"); - }); -}); diff --git a/src/core/rules/ai-readability/empty-frame.test.ts b/src/core/rules/ai-readability/empty-frame.test.ts deleted file mode 100644 index 832218d7..00000000 --- a/src/core/rules/ai-readability/empty-frame.test.ts +++ /dev/null @@ -1,67 +0,0 @@ -import { makeNode, makeFile, makeContext } from "../test-helpers.js"; -import { emptyFrame } from "./index.js"; - -describe("empty-frame", () => { - it("has correct rule definition metadata", () => { - expect(emptyFrame.definition.id).toBe("empty-frame"); - expect(emptyFrame.definition.category).toBe("ai-readability"); - }); - - it("flags empty frame with no children", () => { - const node = makeNode({ - type: "FRAME", - name: "EmptySection", - absoluteBoundingBox: { x: 0, y: 0, width: 300, height: 200 }, - }); - const result = emptyFrame.check(node, makeContext()); - expect(result).not.toBeNull(); - expect(result!.ruleId).toBe("empty-frame"); - expect(result!.message).toContain("EmptySection"); - }); - - it("returns null for frame with children", () => { - const node = makeNode({ - type: "FRAME", - children: [makeNode({ id: "c:1" })], - }); - expect(emptyFrame.check(node, makeContext())).toBeNull(); - }); - - it("returns null for non-FRAME nodes", () => { - const node = makeNode({ type: "GROUP" }); - expect(emptyFrame.check(node, makeContext())).toBeNull(); - }); - - it("allows small placeholder frames (<=48x48)", () => { - const node = makeNode({ - type: "FRAME", - name: "Spacer", - absoluteBoundingBox: { x: 0, y: 0, width: 24, height: 24 }, - }); - expect(emptyFrame.check(node, makeContext())).toBeNull(); - }); - - it("allows placeholder frames exactly at 48x48 boundary", () => { - const node = makeNode({ - type: "FRAME", - name: "IconPlaceholder", - absoluteBoundingBox: { x: 0, y: 0, width: 48, height: 48 }, - }); - expect(emptyFrame.check(node, makeContext())).toBeNull(); - }); - - it("flags empty frame when only one dimension is <= 48", () => { - const node = makeNode({ - type: "FRAME", - name: "TallSpacer", - absoluteBoundingBox: { x: 0, y: 0, width: 48, height: 80 }, - }); - expect(emptyFrame.check(node, makeContext())).not.toBeNull(); - }); - - it("flags empty frame without bounding box", () => { - const node = makeNode({ type: "FRAME", name: "NoBox" }); - const result = emptyFrame.check(node, makeContext()); - expect(result).not.toBeNull(); - }); -}); diff --git a/src/core/rules/ai-readability/index.ts b/src/core/rules/ai-readability/index.ts deleted file mode 100644 index 26e17d0b..00000000 --- a/src/core/rules/ai-readability/index.ts +++ /dev/null @@ -1,278 +0,0 @@ -import type { RuleCheckFn, RuleDefinition } from "../../contracts/rule.js"; -import type { AnalysisNode } from "../../contracts/figma-node.js"; -import { defineRule } from "../rule-registry.js"; -import { getRuleOption } from "../rule-config.js"; - -// ============================================ -// Helper functions -// ============================================ - -function hasAutoLayout(node: AnalysisNode): boolean { - return node.layoutMode !== undefined && node.layoutMode !== "NONE"; -} - -function isContainerNode(node: AnalysisNode): boolean { - return node.type === "FRAME" || node.type === "GROUP" || node.type === "COMPONENT"; -} - -function hasOverlappingBounds(a: AnalysisNode, b: AnalysisNode): boolean { - const boxA = a.absoluteBoundingBox; - const boxB = b.absoluteBoundingBox; - - if (!boxA || !boxB) return false; - - return !( - boxA.x + boxA.width <= boxB.x || - boxB.x + boxB.width <= boxA.x || - boxA.y + boxA.height <= boxB.y || - boxB.y + boxB.height <= boxA.y - ); -} - -// ============================================ -// ambiguous-structure -// ============================================ - -const ambiguousStructureDef: RuleDefinition = { - id: "ambiguous-structure", - name: "Ambiguous Structure", - category: "ai-readability", - why: "Overlapping nodes without Auto Layout create ambiguous visual hierarchy", - impact: "AI cannot reliably determine the reading order or structure", - fix: "Use Auto Layout to create clear, explicit structure", -}; - -const ambiguousStructureCheck: RuleCheckFn = (node, context) => { - if (!isContainerNode(node)) return null; - if (hasAutoLayout(node)) return null; // Auto Layout provides clear structure - if (!node.children || node.children.length < 2) return null; - - // Check for overlapping children - for (let i = 0; i < node.children.length; i++) { - for (let j = i + 1; j < node.children.length; j++) { - const childA = node.children[i]; - const childB = node.children[j]; - - if (childA && childB && hasOverlappingBounds(childA, childB)) { - // Check if this is intentional layering (both visible) - if (childA.visible !== false && childB.visible !== false) { - return { - ruleId: ambiguousStructureDef.id, - nodeId: node.id, - nodePath: context.path.join(" > "), - message: `"${node.name}" has overlapping children without Auto Layout`, - }; - } - } - } - } - - return null; -}; - -export const ambiguousStructure = defineRule({ - definition: ambiguousStructureDef, - check: ambiguousStructureCheck, -}); - -// ============================================ -// z-index-dependent-layout -// ============================================ - -const zIndexDependentLayoutDef: RuleDefinition = { - id: "z-index-dependent-layout", - name: "Z-Index Dependent Layout", - category: "ai-readability", - why: "Using overlapping layers to create visual layout is hard to interpret", - impact: "Code generation may misinterpret the intended layout", - fix: "Restructure using Auto Layout to express the visual relationship explicitly", -}; - -const zIndexDependentLayoutCheck: RuleCheckFn = (node, context) => { - if (!isContainerNode(node)) return null; - if (!node.children || node.children.length < 2) return null; - - // Look for patterns where position overlap is used to create visual effects - // e.g., badge on card, avatar overlapping header - let significantOverlapCount = 0; - - for (let i = 0; i < node.children.length; i++) { - for (let j = i + 1; j < node.children.length; j++) { - const childA = node.children[i]; - const childB = node.children[j]; - - if (!childA || !childB) continue; - if (childA.visible === false || childB.visible === false) continue; - - const boxA = childA.absoluteBoundingBox; - const boxB = childB.absoluteBoundingBox; - - if (!boxA || !boxB) continue; - - if (hasOverlappingBounds(childA, childB)) { - // Calculate overlap percentage - const overlapX = Math.min(boxA.x + boxA.width, boxB.x + boxB.width) - - Math.max(boxA.x, boxB.x); - const overlapY = Math.min(boxA.y + boxA.height, boxB.y + boxB.height) - - Math.max(boxA.y, boxB.y); - - if (overlapX > 0 && overlapY > 0) { - const overlapArea = overlapX * overlapY; - const smallerArea = Math.min( - boxA.width * boxA.height, - boxB.width * boxB.height - ); - - // If overlap is significant (> 20% of smaller element) - if (overlapArea > smallerArea * 0.2) { - significantOverlapCount++; - } - } - } - } - } - - if (significantOverlapCount > 0) { - return { - ruleId: zIndexDependentLayoutDef.id, - nodeId: node.id, - nodePath: context.path.join(" > "), - message: `"${node.name}" uses layer stacking for layout (${significantOverlapCount} overlaps)`, - }; - } - - return null; -}; - -export const zIndexDependentLayout = defineRule({ - definition: zIndexDependentLayoutDef, - check: zIndexDependentLayoutCheck, -}); - -// ============================================ -// missing-layout-hint -// ============================================ - -const missingLayoutHintDef: RuleDefinition = { - id: "missing-layout-hint", - name: "Missing Layout Hint", - category: "ai-readability", - why: "Complex nesting without Auto Layout makes structure unpredictable", - impact: "AI may generate incorrect code due to ambiguous relationships", - fix: "Add Auto Layout or simplify the nesting structure", -}; - -const missingLayoutHintCheck: RuleCheckFn = (node, context) => { - if (!isContainerNode(node)) return null; - if (hasAutoLayout(node)) return null; - if (!node.children || node.children.length === 0) return null; - - // Check for nested containers without layout hints - const nestedContainers = node.children.filter((c) => isContainerNode(c)); - - // If there are multiple nested containers without layout direction - if (nestedContainers.length >= 2) { - const withoutLayout = nestedContainers.filter((c) => !hasAutoLayout(c)); - - if (withoutLayout.length >= 2) { - return { - ruleId: missingLayoutHintDef.id, - nodeId: node.id, - nodePath: context.path.join(" > "), - message: `"${node.name}" has ${withoutLayout.length} nested containers without layout hints`, - }; - } - } - - return null; -}; - -export const missingLayoutHint = defineRule({ - definition: missingLayoutHintDef, - check: missingLayoutHintCheck, -}); - -// ============================================ -// invisible-layer -// ============================================ - -const invisibleLayerDef: RuleDefinition = { - id: "invisible-layer", - name: "Invisible Layer", - category: "ai-readability", - why: "Hidden layers increase API response size and node count but are skipped during code generation. They are a normal part of the Figma workflow (version history, A/B options, state layers) and do not block implementation.", - impact: "Minor token overhead from larger API responses. No impact on code generation accuracy since hidden nodes are excluded from the design tree.", - fix: "No action required if hidden layers are intentional. Clean up unused hidden layers to reduce file size. If a frame has many hidden children representing states, consider using Figma's Slot feature for cleaner state management.", -}; - -const invisibleLayerCheck: RuleCheckFn = (node, context) => { - if (node.visible !== false) return null; - - // Skip if parent is also invisible (only report top-level invisible) - if (context.parent?.visible === false) return null; - - // Check if parent has many hidden children — suggest Slot - const slotThreshold = - getRuleOption("invisible-layer", "slotRecommendationThreshold", 3); - const hiddenSiblingCount = context.siblings - ? context.siblings.filter((s) => s.visible === false).length - : 0; - - if (hiddenSiblingCount >= slotThreshold) { - return { - ruleId: invisibleLayerDef.id, - nodeId: node.id, - nodePath: context.path.join(" > "), - message: `"${node.name}" is hidden (${hiddenSiblingCount} hidden siblings) — if these represent states, consider using Figma Slots instead`, - }; - } - - return { - ruleId: invisibleLayerDef.id, - nodeId: node.id, - nodePath: context.path.join(" > "), - message: `"${node.name}" is hidden — no impact on code generation, clean up if unused`, - }; -}; - -export const invisibleLayer = defineRule({ - definition: invisibleLayerDef, - check: invisibleLayerCheck, -}); - -// ============================================ -// empty-frame -// ============================================ - -const emptyFrameDef: RuleDefinition = { - id: "empty-frame", - name: "Empty Frame", - category: "ai-readability", - why: "Empty frames add noise and may indicate incomplete design", - impact: "Generates unnecessary wrapper elements in code", - fix: "Remove the frame or add content", -}; - -const emptyFrameCheck: RuleCheckFn = (node, context) => { - if (node.type !== "FRAME") return null; - if (node.children && node.children.length > 0) return null; - - // Allow empty frames that are clearly placeholders (small size) - if (node.absoluteBoundingBox) { - const { width, height } = node.absoluteBoundingBox; - // Allow small placeholder frames (icons, spacers) - if (width <= 48 && height <= 48) return null; - } - - return { - ruleId: emptyFrameDef.id, - nodeId: node.id, - nodePath: context.path.join(" > "), - message: `"${node.name}" is an empty frame`, - }; -}; - -export const emptyFrame = defineRule({ - definition: emptyFrameDef, - check: emptyFrameCheck, -}); diff --git a/src/core/rules/ai-readability/invisible-layer.test.ts b/src/core/rules/ai-readability/invisible-layer.test.ts deleted file mode 100644 index ee055456..00000000 --- a/src/core/rules/ai-readability/invisible-layer.test.ts +++ /dev/null @@ -1,75 +0,0 @@ -import { makeNode, makeFile, makeContext } from "../test-helpers.js"; -import { invisibleLayer } from "./index.js"; - -describe("invisible-layer", () => { - it("has correct rule definition metadata", () => { - const def = invisibleLayer.definition; - expect(def.id).toBe("invisible-layer"); - expect(def.category).toBe("ai-readability"); - expect(def.why).toContain("Hidden layers"); - expect(def.fix).toContain("Slot"); - }); - - it("returns null for visible nodes", () => { - const node = makeNode({ visible: true }); - const ctx = makeContext(); - expect(invisibleLayer.check(node, ctx)).toBeNull(); - }); - - it("flags hidden node with basic message", () => { - const node = makeNode({ visible: false, name: "OldVersion" }); - const ctx = makeContext({ siblings: [node] }); - - const result = invisibleLayer.check(node, ctx); - expect(result).not.toBeNull(); - expect(result!.message).toContain("OldVersion"); - expect(result!.message).toContain("hidden"); - expect(result!.message).toContain("clean up if unused"); - }); - - it("skips when parent is also invisible", () => { - const node = makeNode({ visible: false }); - const parent = makeNode({ visible: false, name: "HiddenParent" }); - const ctx = makeContext({ parent }); - - expect(invisibleLayer.check(node, ctx)).toBeNull(); - }); - - it("suggests Slot when 3+ hidden siblings", () => { - const hidden1 = makeNode({ id: "h:1", visible: false, name: "StateA" }); - const hidden2 = makeNode({ id: "h:2", visible: false, name: "StateB" }); - const hidden3 = makeNode({ id: "h:3", visible: false, name: "StateC" }); - const visible1 = makeNode({ id: "v:1", visible: true, name: "Active" }); - - const siblings = [hidden1, hidden2, hidden3, visible1]; - const ctx = makeContext({ siblings }); - - const result = invisibleLayer.check(hidden1, ctx); - expect(result).not.toBeNull(); - expect(result!.message).toContain("3 hidden siblings"); - expect(result!.message).toContain("Slot"); - }); - - it("does not suggest Slot when fewer than 3 hidden siblings", () => { - const hidden1 = makeNode({ id: "h:1", visible: false, name: "StateA" }); - const hidden2 = makeNode({ id: "h:2", visible: false, name: "StateB" }); - const visible1 = makeNode({ id: "v:1", visible: true, name: "Active" }); - - const siblings = [hidden1, hidden2, visible1]; - const ctx = makeContext({ siblings }); - - const result = invisibleLayer.check(hidden1, ctx); - expect(result).not.toBeNull(); - expect(result!.message).not.toContain("Slot"); - expect(result!.message).toContain("clean up if unused"); - }); - - it("handles undefined siblings gracefully", () => { - const node = makeNode({ visible: false, name: "Hidden" }); - const ctx = makeContext({ siblings: undefined }); - - const result = invisibleLayer.check(node, ctx); - expect(result).not.toBeNull(); - expect(result!.message).toContain("clean up if unused"); - }); -}); diff --git a/src/core/rules/ai-readability/missing-layout-hint.test.ts b/src/core/rules/ai-readability/missing-layout-hint.test.ts deleted file mode 100644 index 61cf6bb0..00000000 --- a/src/core/rules/ai-readability/missing-layout-hint.test.ts +++ /dev/null @@ -1,82 +0,0 @@ -import { makeNode, makeFile, makeContext } from "../test-helpers.js"; -import { missingLayoutHint } from "./index.js"; - -describe("missing-layout-hint", () => { - it("has correct rule definition metadata", () => { - expect(missingLayoutHint.definition.id).toBe("missing-layout-hint"); - expect(missingLayoutHint.definition.category).toBe("ai-readability"); - }); - - it("flags container with 2+ nested containers without auto layout", () => { - const childA = makeNode({ id: "c:1", type: "FRAME", name: "Panel A" }); - const childB = makeNode({ id: "c:2", type: "FRAME", name: "Panel B" }); - const node = makeNode({ - type: "FRAME", - name: "Wrapper", - children: [childA, childB], - }); - - const result = missingLayoutHint.check(node, makeContext()); - expect(result).not.toBeNull(); - expect(result!.ruleId).toBe("missing-layout-hint"); - expect(result!.message).toContain("Wrapper"); - }); - - it("returns null when node has auto layout", () => { - const childA = makeNode({ id: "c:1", type: "FRAME" }); - const childB = makeNode({ id: "c:2", type: "FRAME" }); - const node = makeNode({ - type: "FRAME", - layoutMode: "VERTICAL", - children: [childA, childB], - }); - - expect(missingLayoutHint.check(node, makeContext())).toBeNull(); - }); - - it("returns null when nested containers have auto layout", () => { - const childA = makeNode({ id: "c:1", type: "FRAME", layoutMode: "HORIZONTAL" }); - const childB = makeNode({ id: "c:2", type: "FRAME", layoutMode: "VERTICAL" }); - const node = makeNode({ - type: "FRAME", - name: "Wrapper", - children: [childA, childB], - }); - - expect(missingLayoutHint.check(node, makeContext())).toBeNull(); - }); - - it("does not flag when only one of two nested containers has auto layout", () => { - const childA = makeNode({ id: "c:1", type: "FRAME", layoutMode: "HORIZONTAL" }); - const childB = makeNode({ id: "c:2", type: "FRAME" }); - const node = makeNode({ - type: "FRAME", - name: "MixedWrapper", - children: [childA, childB], - }); - - expect(missingLayoutHint.check(node, makeContext())).toBeNull(); - }); - - it("returns null for non-container nodes", () => { - const node = makeNode({ type: "TEXT" }); - expect(missingLayoutHint.check(node, makeContext())).toBeNull(); - }); - - it("returns null when fewer than 2 nested containers", () => { - const child = makeNode({ id: "c:1", type: "FRAME" }); - const text = makeNode({ id: "c:2", type: "TEXT" }); - const node = makeNode({ - type: "FRAME", - name: "Simple", - children: [child, text], - }); - - expect(missingLayoutHint.check(node, makeContext())).toBeNull(); - }); - - it("returns null when no children", () => { - const node = makeNode({ type: "FRAME" }); - expect(missingLayoutHint.check(node, makeContext())).toBeNull(); - }); -}); diff --git a/src/core/rules/handoff-risk/index.ts b/src/core/rules/behavior/index.ts similarity index 56% rename from src/core/rules/handoff-risk/index.ts rename to src/core/rules/behavior/index.ts index 55ed240d..b3257de7 100644 --- a/src/core/rules/handoff-risk/index.ts +++ b/src/core/rules/behavior/index.ts @@ -10,51 +10,10 @@ function hasAutoLayout(node: AnalysisNode): boolean { return node.layoutMode !== undefined && node.layoutMode !== "NONE"; } -function isContainerNode(node: AnalysisNode): boolean { - return node.type === "FRAME" || node.type === "GROUP" || node.type === "COMPONENT"; -} - function isTextNode(node: AnalysisNode): boolean { return node.type === "TEXT"; } -// ============================================ -// hardcode-risk -// ============================================ - -const hardcodeRiskDef: RuleDefinition = { - id: "hardcode-risk", - name: "Hardcode Risk", - category: "handoff-risk", - 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) => { - if (!isContainerNode(node)) return null; - - // Check for absolute positioning - if (node.layoutPositioning !== "ABSOLUTE") return null; - - // Check if parent has Auto Layout - if (context.parent && hasAutoLayout(context.parent)) { - return { - ruleId: hardcodeRiskDef.id, - nodeId: node.id, - nodePath: context.path.join(" > "), - message: `"${node.name}" uses absolute positioning with fixed values`, - }; - } - - return null; -}; - -export const hardcodeRisk = defineRule({ - definition: hardcodeRiskDef, - check: hardcodeRiskCheck, -}); - // ============================================ // text-truncation-unhandled // ============================================ @@ -62,7 +21,7 @@ export const hardcodeRisk = defineRule({ const textTruncationUnhandledDef: RuleDefinition = { id: "text-truncation-unhandled", name: "Text Truncation Unhandled", - category: "handoff-risk", + category: "behavior", 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", @@ -75,15 +34,8 @@ const textTruncationUnhandledCheck: RuleCheckFn = (node, context) => { if (!context.parent) return null; if (!hasAutoLayout(context.parent)) return null; - // Check if text has fixed width in the Auto Layout direction - // This is a heuristic - would need more Figma API data for accuracy - // Parent direction would be: context.parent.layoutMode - - // If parent is horizontal and text doesn't have truncation configured - // Simplified check - full implementation would examine text truncation property if (node.absoluteBoundingBox) { const { width } = node.absoluteBoundingBox; - // Flag if text is in a constrained space but long if (node.characters && node.characters.length > 50 && width < 300) { return { ruleId: textTruncationUnhandledDef.id, @@ -109,7 +61,7 @@ export const textTruncationUnhandled = defineRule({ const prototypeLinkInDesignDef: RuleDefinition = { id: "prototype-link-in-design", name: "Missing Prototype Interaction", - category: "handoff-risk", + category: "behavior", 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", @@ -189,3 +141,96 @@ export const prototypeLinkInDesign = defineRule({ check: prototypeLinkInDesignCheck, }); +// ============================================ +// overflow-behavior-unknown +// ============================================ + +const overflowBehaviorUnknownDef: RuleDefinition = { + id: "overflow-behavior-unknown", + name: "Overflow Behavior Unknown", + category: "behavior", + why: "Children overflowing parent bounds without explicit clip/scroll behavior forces AI to guess overflow handling", + impact: "AI may generate incorrect overflow: hidden, scroll, or visible — breaking the intended design", + fix: "Enable 'Clip content' or set an explicit overflow/scroll behavior on the container", +}; + +const overflowBehaviorUnknownCheck: RuleCheckFn = (node, context) => { + // Only check container nodes + if (!["FRAME", "COMPONENT", "COMPONENT_SET", "INSTANCE"].includes(node.type)) return null; + // Must have children + if (!node.children?.length) return null; + // Check parent bounds + const parentBox = node.absoluteBoundingBox; + if (!parentBox) return null; + // If clipsContent is true, behavior is explicit — skip + if (node.clipsContent === true) return null; + // Check if any visible child overflows + const hasOverflow = node.children.some(child => { + if (child.visible === false) return false; + const childBox = child.absoluteBoundingBox; + if (!childBox) return false; + return ( + childBox.x < parentBox.x || + childBox.y < parentBox.y || + // +1 tolerance for floating-point rounding in Figma coordinates + childBox.x + childBox.width > parentBox.x + parentBox.width + 1 || + childBox.y + childBox.height > parentBox.y + parentBox.height + 1 + ); + }); + if (!hasOverflow) return null; + return { + ruleId: overflowBehaviorUnknownDef.id, + nodeId: node.id, + nodePath: context.path.join(" > "), + message: `"${node.name}" has children overflowing bounds without explicit clip/scroll behavior — AI must guess overflow handling`, + }; +}; + +export const overflowBehaviorUnknown = defineRule({ + definition: overflowBehaviorUnknownDef, + check: overflowBehaviorUnknownCheck, +}); + +// ============================================ +// wrap-behavior-unknown +// ============================================ + +const wrapBehaviorUnknownDef: RuleDefinition = { + id: "wrap-behavior-unknown", + name: "Wrap Behavior Unknown", + category: "behavior", + why: "Horizontal children exceeding container width without wrap behavior forces AI to guess if content should wrap or scroll", + impact: "AI may generate incorrect flex-wrap or overflow behavior, breaking the layout on narrow screens", + fix: "Set layoutWrap to WRAP if children should flow to the next line, or add explicit overflow/scroll behavior", +}; + +const wrapBehaviorUnknownCheck: RuleCheckFn = (node, context) => { + // Only horizontal Auto Layout + if (node.layoutMode !== "HORIZONTAL") return null; + // Need 3+ visible children + const visibleChildren = (node.children ?? []).filter(c => c.visible !== false); + if (visibleChildren.length < 3) return null; + // layoutWrap must be unset or NO_WRAP + if (node.layoutWrap === "WRAP") return null; + // Check if children total width exceeds parent + const parentBox = node.absoluteBoundingBox; + if (!parentBox) return null; + // Skip if any child lacks bounding box data — can't reliably compare widths + const childrenWithBox = visibleChildren.filter(c => c.absoluteBoundingBox); + if (childrenWithBox.length !== visibleChildren.length) return null; + const totalChildWidth = childrenWithBox.reduce((sum, child) => { + return sum + child.absoluteBoundingBox!.width; + }, 0); + if (totalChildWidth <= parentBox.width) return null; + return { + ruleId: wrapBehaviorUnknownDef.id, + nodeId: node.id, + nodePath: context.path.join(" > "), + message: `"${node.name}" has ${visibleChildren.length} horizontal children exceeding container width without wrap behavior — AI cannot determine if content should wrap or scroll`, + }; +}; + +export const wrapBehaviorUnknown = defineRule({ + definition: wrapBehaviorUnknownDef, + check: wrapBehaviorUnknownCheck, +}); diff --git a/src/core/rules/behavior/overflow-behavior-unknown.test.ts b/src/core/rules/behavior/overflow-behavior-unknown.test.ts new file mode 100644 index 00000000..37f485ee --- /dev/null +++ b/src/core/rules/behavior/overflow-behavior-unknown.test.ts @@ -0,0 +1,89 @@ +import { makeNode, makeFile, makeContext } from "../test-helpers.js"; +import { overflowBehaviorUnknown } from "./index.js"; + +describe("overflow-behavior-unknown", () => { + it("has correct rule definition metadata", () => { + expect(overflowBehaviorUnknown.definition.id).toBe("overflow-behavior-unknown"); + expect(overflowBehaviorUnknown.definition.category).toBe("behavior"); + }); + + it("flags container with overflowing child and no clip", () => { + const child = makeNode({ + id: "c:1", + name: "Overflow", + absoluteBoundingBox: { x: -10, y: 0, width: 200, height: 50 }, + }); + const node = makeNode({ + type: "FRAME", + name: "Container", + absoluteBoundingBox: { x: 0, y: 0, width: 100, height: 100 }, + children: [child], + }); + + const result = overflowBehaviorUnknown.check(node, makeContext()); + expect(result).not.toBeNull(); + expect(result!.ruleId).toBe("overflow-behavior-unknown"); + expect(result!.message).toContain("Container"); + expect(result!.message).toContain("overflowing"); + }); + + it("returns null when clipsContent is true", () => { + const child = makeNode({ + id: "c:1", + absoluteBoundingBox: { x: -10, y: 0, width: 200, height: 50 }, + }); + const node = makeNode({ + type: "FRAME", + name: "Clipped", + absoluteBoundingBox: { x: 0, y: 0, width: 100, height: 100 }, + clipsContent: true, + children: [child], + }); + + expect(overflowBehaviorUnknown.check(node, makeContext())).toBeNull(); + }); + + it("returns null when no children overflow", () => { + const child = makeNode({ + id: "c:1", + absoluteBoundingBox: { x: 10, y: 10, width: 50, height: 50 }, + }); + const node = makeNode({ + type: "FRAME", + name: "FitsInside", + absoluteBoundingBox: { x: 0, y: 0, width: 100, height: 100 }, + children: [child], + }); + + expect(overflowBehaviorUnknown.check(node, makeContext())).toBeNull(); + }); + + it("returns null for non-container nodes", () => { + const node = makeNode({ type: "TEXT" }); + expect(overflowBehaviorUnknown.check(node, makeContext())).toBeNull(); + }); + + it("returns null when no children", () => { + const node = makeNode({ + type: "FRAME", + absoluteBoundingBox: { x: 0, y: 0, width: 100, height: 100 }, + }); + expect(overflowBehaviorUnknown.check(node, makeContext())).toBeNull(); + }); + + it("ignores hidden children when checking overflow", () => { + const child = makeNode({ + id: "c:1", + visible: false, + absoluteBoundingBox: { x: -10, y: 0, width: 200, height: 50 }, + }); + const node = makeNode({ + type: "FRAME", + name: "Container", + absoluteBoundingBox: { x: 0, y: 0, width: 100, height: 100 }, + children: [child], + }); + + expect(overflowBehaviorUnknown.check(node, makeContext())).toBeNull(); + }); +}); diff --git a/src/core/rules/behavior/prototype-link-in-design.test.ts b/src/core/rules/behavior/prototype-link-in-design.test.ts new file mode 100644 index 00000000..0afbe3b2 --- /dev/null +++ b/src/core/rules/behavior/prototype-link-in-design.test.ts @@ -0,0 +1,54 @@ +import { makeNode, makeFile, makeContext } from "../test-helpers.js"; +import { prototypeLinkInDesign } from "./index.js"; + +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("behavior"); + }); + + 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("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("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(); + }); + + it("does not throw on malformed variantOptions (not an array)", () => { + const node = makeNode({ + type: "COMPONENT", + name: "Card", + componentPropertyDefinitions: { + State: { type: "VARIANT", variantOptions: "not-an-array" }, + }, + }); + expect(prototypeLinkInDesign.check(node, makeContext())).toBeNull(); + }); +}); diff --git a/src/core/rules/handoff-risk/text-truncation-unhandled.test.ts b/src/core/rules/behavior/text-truncation-unhandled.test.ts similarity index 89% rename from src/core/rules/handoff-risk/text-truncation-unhandled.test.ts rename to src/core/rules/behavior/text-truncation-unhandled.test.ts index 56a6f191..7c6fb0d9 100644 --- a/src/core/rules/handoff-risk/text-truncation-unhandled.test.ts +++ b/src/core/rules/behavior/text-truncation-unhandled.test.ts @@ -4,7 +4,7 @@ import { textTruncationUnhandled } from "./index.js"; describe("text-truncation-unhandled", () => { it("has correct rule definition metadata", () => { expect(textTruncationUnhandled.definition.id).toBe("text-truncation-unhandled"); - expect(textTruncationUnhandled.definition.category).toBe("handoff-risk"); + expect(textTruncationUnhandled.definition.category).toBe("behavior"); }); it("flags long text in constrained auto layout parent", () => { @@ -46,7 +46,16 @@ describe("text-truncation-unhandled", () => { expect(textTruncationUnhandled.check(node, makeContext({ parent }))).toBeNull(); }); - it("returns null for wide text container (exactly at 300px boundary)", () => { + it("returns null when no parent", () => { + const node = makeNode({ + type: "TEXT", + characters: "A".repeat(60), + absoluteBoundingBox: { x: 0, y: 0, width: 200, height: 20 }, + }); + expect(textTruncationUnhandled.check(node, makeContext())).toBeNull(); + }); + + it("returns null at width boundary (300px)", () => { const parent = makeNode({ layoutMode: "HORIZONTAL" }); const node = makeNode({ type: "TEXT", @@ -56,7 +65,7 @@ describe("text-truncation-unhandled", () => { expect(textTruncationUnhandled.check(node, makeContext({ parent }))).toBeNull(); }); - it("returns null for text with exactly 50 characters (boundary: > 50 required)", () => { + it("returns null at length boundary (50 chars)", () => { const parent = makeNode({ layoutMode: "HORIZONTAL" }); const node = makeNode({ type: "TEXT", @@ -66,11 +75,11 @@ describe("text-truncation-unhandled", () => { expect(textTruncationUnhandled.check(node, makeContext({ parent }))).toBeNull(); }); - it("flags text with 51 characters in narrow container", () => { + it("flags when length is 51 chars in constrained width", () => { const parent = makeNode({ layoutMode: "HORIZONTAL" }); const node = makeNode({ type: "TEXT", - name: "LongText", + name: "Description", characters: "A".repeat(51), absoluteBoundingBox: { x: 0, y: 0, width: 200, height: 20 }, }); @@ -78,13 +87,4 @@ describe("text-truncation-unhandled", () => { expect(result).not.toBeNull(); expect(result!.ruleId).toBe("text-truncation-unhandled"); }); - - it("returns null when no parent", () => { - const node = makeNode({ - type: "TEXT", - characters: "A".repeat(60), - absoluteBoundingBox: { x: 0, y: 0, width: 200, height: 20 }, - }); - expect(textTruncationUnhandled.check(node, makeContext())).toBeNull(); - }); }); diff --git a/src/core/rules/behavior/wrap-behavior-unknown.test.ts b/src/core/rules/behavior/wrap-behavior-unknown.test.ts new file mode 100644 index 00000000..80f436cc --- /dev/null +++ b/src/core/rules/behavior/wrap-behavior-unknown.test.ts @@ -0,0 +1,121 @@ +import { makeNode, makeFile, makeContext } from "../test-helpers.js"; +import { wrapBehaviorUnknown } from "./index.js"; + +describe("wrap-behavior-unknown", () => { + it("has correct rule definition metadata", () => { + expect(wrapBehaviorUnknown.definition.id).toBe("wrap-behavior-unknown"); + expect(wrapBehaviorUnknown.definition.category).toBe("behavior"); + }); + + it("flags horizontal auto layout with 3+ children exceeding width", () => { + const child1 = makeNode({ + id: "c:1", + absoluteBoundingBox: { x: 0, y: 0, width: 150, height: 50 }, + }); + const child2 = makeNode({ + id: "c:2", + absoluteBoundingBox: { x: 150, y: 0, width: 150, height: 50 }, + }); + const child3 = makeNode({ + id: "c:3", + absoluteBoundingBox: { x: 300, y: 0, width: 150, height: 50 }, + }); + const node = makeNode({ + type: "FRAME", + name: "TagList", + layoutMode: "HORIZONTAL", + absoluteBoundingBox: { x: 0, y: 0, width: 400, height: 50 }, + children: [child1, child2, child3], + }); + + const result = wrapBehaviorUnknown.check(node, makeContext()); + expect(result).not.toBeNull(); + expect(result!.ruleId).toBe("wrap-behavior-unknown"); + expect(result!.message).toContain("TagList"); + expect(result!.message).toContain("3 horizontal children"); + }); + + it("returns null when layoutWrap is WRAP", () => { + const child1 = makeNode({ + id: "c:1", + absoluteBoundingBox: { x: 0, y: 0, width: 150, height: 50 }, + }); + const child2 = makeNode({ + id: "c:2", + absoluteBoundingBox: { x: 150, y: 0, width: 150, height: 50 }, + }); + const child3 = makeNode({ + id: "c:3", + absoluteBoundingBox: { x: 300, y: 0, width: 150, height: 50 }, + }); + const node = makeNode({ + type: "FRAME", + name: "WrappedList", + layoutMode: "HORIZONTAL", + layoutWrap: "WRAP", + absoluteBoundingBox: { x: 0, y: 0, width: 400, height: 100 }, + children: [child1, child2, child3], + }); + + expect(wrapBehaviorUnknown.check(node, makeContext())).toBeNull(); + }); + + it("returns null for vertical auto layout", () => { + const child1 = makeNode({ id: "c:1", absoluteBoundingBox: { x: 0, y: 0, width: 100, height: 200 } }); + const child2 = makeNode({ id: "c:2", absoluteBoundingBox: { x: 0, y: 200, width: 100, height: 200 } }); + const child3 = makeNode({ id: "c:3", absoluteBoundingBox: { x: 0, y: 400, width: 100, height: 200 } }); + const node = makeNode({ + type: "FRAME", + name: "VertList", + layoutMode: "VERTICAL", + absoluteBoundingBox: { x: 0, y: 0, width: 100, height: 400 }, + children: [child1, child2, child3], + }); + + expect(wrapBehaviorUnknown.check(node, makeContext())).toBeNull(); + }); + + it("returns null when fewer than 3 visible children", () => { + const child1 = makeNode({ + id: "c:1", + absoluteBoundingBox: { x: 0, y: 0, width: 150, height: 50 }, + }); + const child2 = makeNode({ + id: "c:2", + absoluteBoundingBox: { x: 150, y: 0, width: 150, height: 50 }, + }); + const node = makeNode({ + type: "FRAME", + name: "ShortList", + layoutMode: "HORIZONTAL", + absoluteBoundingBox: { x: 0, y: 0, width: 200, height: 50 }, + children: [child1, child2], + }); + + expect(wrapBehaviorUnknown.check(node, makeContext())).toBeNull(); + }); + + it("returns null when children fit within parent width", () => { + const child1 = makeNode({ + id: "c:1", + absoluteBoundingBox: { x: 0, y: 0, width: 100, height: 50 }, + }); + const child2 = makeNode({ + id: "c:2", + absoluteBoundingBox: { x: 100, y: 0, width: 100, height: 50 }, + }); + const child3 = makeNode({ + id: "c:3", + absoluteBoundingBox: { x: 200, y: 0, width: 100, height: 50 }, + }); + const node = makeNode({ + type: "FRAME", + name: "FitsList", + layoutMode: "HORIZONTAL", + absoluteBoundingBox: { x: 0, y: 0, width: 400, height: 50 }, + children: [child1, child2, child3], + }); + + expect(wrapBehaviorUnknown.check(node, makeContext())).toBeNull(); + }); +}); diff --git a/src/core/rules/component/index.ts b/src/core/rules/component/index.ts index b182111a..872df6ae 100644 --- a/src/core/rules/component/index.ts +++ b/src/core/rules/component/index.ts @@ -363,3 +363,50 @@ export const missingComponentDescription = defineRule({ check: missingComponentDescriptionCheck, }); +// ============================================ +// variant-structure-mismatch +// ============================================ + +const variantStructureMismatchDef: RuleDefinition = { + id: "variant-structure-mismatch", + name: "Variant Structure Mismatch", + category: "component", + why: "Variants with different child structures prevent AI from creating a unified component template", + impact: "AI must generate separate implementations for each variant instead of a single parameterized component", + fix: "Ensure all variants share the same child structure, using visibility toggles for optional elements", +}; + +const variantStructureMismatchCheck: RuleCheckFn = (node, context) => { + // Only COMPONENT_SET + if (node.type !== "COMPONENT_SET") return null; + if (!node.children?.length || node.children.length < 2) return null; + + // Build fingerprint for each variant child + const fingerprints = node.children + .filter(child => child.type === "COMPONENT") + .map(child => buildFingerprint(child, 2)); + + if (fingerprints.length < 2) return null; + + // Compare all fingerprints to the first one + const base = fingerprints[0]; + const mismatched = fingerprints.filter(fp => fp !== base); + + if (mismatched.length === 0) return null; + + const mismatchCount = mismatched.length; + const totalVariants = fingerprints.length; + + return { + ruleId: variantStructureMismatchDef.id, + nodeId: node.id, + nodePath: context.path.join(" > "), + message: `"${node.name}" has ${mismatchCount}/${totalVariants} variants with different child structures — AI cannot create a unified component template`, + }; +}; + +export const variantStructureMismatch = defineRule({ + definition: variantStructureMismatchDef, + check: variantStructureMismatchCheck, +}); + diff --git a/src/core/rules/component/variant-structure-mismatch.test.ts b/src/core/rules/component/variant-structure-mismatch.test.ts new file mode 100644 index 00000000..a2fe7c9b --- /dev/null +++ b/src/core/rules/component/variant-structure-mismatch.test.ts @@ -0,0 +1,95 @@ +import { makeNode, makeFile, makeContext } from "../test-helpers.js"; +import { variantStructureMismatch } from "./index.js"; + +describe("variant-structure-mismatch", () => { + it("has correct rule definition metadata", () => { + expect(variantStructureMismatch.definition.id).toBe("variant-structure-mismatch"); + expect(variantStructureMismatch.definition.category).toBe("component"); + }); + + it("flags COMPONENT_SET with mismatched variant structures", () => { + const variantA = makeNode({ + id: "v:1", + type: "COMPONENT", + name: "Default", + children: [ + makeNode({ id: "v1c:1", type: "TEXT", name: "Label" }), + ], + }); + const variantB = makeNode({ + id: "v:2", + type: "COMPONENT", + name: "WithIcon", + children: [ + makeNode({ id: "v2c:1", type: "FRAME", name: "Icon" }), + makeNode({ id: "v2c:2", type: "TEXT", name: "Label" }), + ], + }); + const node = makeNode({ + type: "COMPONENT_SET", + name: "Button", + children: [variantA, variantB], + }); + + const result = variantStructureMismatch.check(node, makeContext()); + expect(result).not.toBeNull(); + expect(result!.ruleId).toBe("variant-structure-mismatch"); + expect(result!.message).toContain("Button"); + expect(result!.message).toContain("1/2"); + }); + + it("returns null when all variants have same structure", () => { + const variantA = makeNode({ + id: "v:1", + type: "COMPONENT", + name: "Default", + children: [ + makeNode({ id: "v1c:1", type: "TEXT", name: "Label" }), + ], + }); + const variantB = makeNode({ + id: "v:2", + type: "COMPONENT", + name: "Hover", + children: [ + makeNode({ id: "v2c:1", type: "TEXT", name: "Label" }), + ], + }); + const node = makeNode({ + type: "COMPONENT_SET", + name: "Button", + children: [variantA, variantB], + }); + + expect(variantStructureMismatch.check(node, makeContext())).toBeNull(); + }); + + it("returns null for non-COMPONENT_SET nodes", () => { + const node = makeNode({ type: "FRAME" }); + expect(variantStructureMismatch.check(node, makeContext())).toBeNull(); + }); + + it("returns null with fewer than 2 children", () => { + const variant = makeNode({ + id: "v:1", + type: "COMPONENT", + name: "Only", + children: [makeNode({ id: "c:1", type: "TEXT" })], + }); + const node = makeNode({ + type: "COMPONENT_SET", + name: "Single", + children: [variant], + }); + + expect(variantStructureMismatch.check(node, makeContext())).toBeNull(); + }); + + it("returns null for empty COMPONENT_SET", () => { + const node = makeNode({ + type: "COMPONENT_SET", + name: "Empty", + }); + expect(variantStructureMismatch.check(node, makeContext())).toBeNull(); + }); +}); diff --git a/src/core/rules/custom/custom-rule-loader.test.ts b/src/core/rules/custom/custom-rule-loader.test.ts index 58bc6fb1..9b6e7ae8 100644 --- a/src/core/rules/custom/custom-rule-loader.test.ts +++ b/src/core/rules/custom/custom-rule-loader.test.ts @@ -43,7 +43,7 @@ describe("loadCustomRules", () => { const rules = [ { id: "my-custom-rule", - category: "layout", + category: "structure", severity: "risk", score: -3, match: { type: ["FRAME"] }, @@ -61,7 +61,7 @@ describe("loadCustomRules", () => { const rule = result.rules[0]; expect(rule).toBeDefined(); expect(rule!.definition.id).toBe("my-custom-rule"); - expect(rule!.definition.category).toBe("layout"); + expect(rule!.definition.category).toBe("structure"); expect(rule!.definition.why).toBe("Because it matters"); expect(rule!.definition.impact).toBe("Causes confusion"); expect(rule!.definition.fix).toBe("Fix the thing"); @@ -147,7 +147,7 @@ describe("loadCustomRules", () => { const invalid = [ { id: "bad-rule", - category: "layout", + category: "structure", severity: "risk", score: 5, // must be <= 0 match: { type: ["FRAME"] }, @@ -172,7 +172,7 @@ describe("loadCustomRules", () => { const rules = [ { id: "old-rule", - category: "layout", + category: "structure", severity: "risk", score: -2, match: { type: ["FRAME"] }, @@ -194,7 +194,7 @@ describe("loadCustomRules", () => { const rules = [ { id: "test-rule", - category: "layout", + category: "structure", severity: "risk", score: -2, match: { type: ["FRAME"] }, @@ -241,7 +241,7 @@ describe("pattern matching - type conditions", () => { const rules = [ { id: "type-match", - category: "layout", + category: "structure", severity: "risk", score: -2, match: { type: ["FRAME", "GROUP"] }, @@ -266,7 +266,7 @@ describe("pattern matching - type conditions", () => { const rules = [ { id: "not-type-match", - category: "layout", + category: "structure", severity: "risk", score: -2, match: { notType: ["TEXT", "VECTOR"] }, @@ -389,7 +389,7 @@ describe("pattern matching - size conditions", () => { const rules = [ { id: "size-match", - category: "layout", + category: "structure", severity: "risk", score: -2, match: { minWidth: 100, maxWidth: 300, minHeight: 50 }, @@ -449,7 +449,7 @@ describe("pattern matching - layout conditions", () => { const rules = [ { id: "auto-layout-check", - category: "layout", + category: "structure", severity: "risk", score: -2, match: { hasAutoLayout: false }, @@ -475,7 +475,7 @@ describe("pattern matching - layout conditions", () => { const rules = [ { id: "children-check", - category: "layout", + category: "structure", severity: "risk", score: -2, match: { hasChildren: true, minChildren: 2, maxChildren: 5 }, @@ -606,7 +606,7 @@ describe("pattern matching - visibility conditions", () => { const rules = [ { id: "visibility-check", - category: "ai-readability", + category: "structure", severity: "blocking", score: -5, match: { isVisible: false }, @@ -687,7 +687,7 @@ describe("pattern matching - depth conditions", () => { const rules = [ { id: "depth-check", - category: "layout", + category: "structure", severity: "risk", score: -2, match: { minDepth: 2, maxDepth: 5 }, @@ -723,7 +723,7 @@ describe("pattern matching - message template", () => { const rules = [ { id: "msg-template", - category: "layout", + category: "structure", severity: "risk", score: -2, match: { type: ["FRAME"] }, @@ -748,7 +748,7 @@ describe("pattern matching - message template", () => { const rules = [ { id: "no-msg", - category: "layout", + category: "structure", severity: "risk", score: -2, match: { type: ["FRAME"] }, diff --git a/src/core/rules/handoff-risk/hardcode-risk.test.ts b/src/core/rules/handoff-risk/hardcode-risk.test.ts deleted file mode 100644 index d579629c..00000000 --- a/src/core/rules/handoff-risk/hardcode-risk.test.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { makeNode, makeFile, makeContext } from "../test-helpers.js"; -import { hardcodeRisk } from "./index.js"; - -describe("hardcode-risk", () => { - it("has correct rule definition metadata", () => { - expect(hardcodeRisk.definition.id).toBe("hardcode-risk"); - expect(hardcodeRisk.definition.category).toBe("handoff-risk"); - }); - - it("flags container with absolute positioning in auto layout parent", () => { - const parent = makeNode({ layoutMode: "VERTICAL" }); - const node = makeNode({ - type: "FRAME", - name: "FloatingPanel", - layoutPositioning: "ABSOLUTE", - }); - const result = hardcodeRisk.check(node, makeContext({ parent })); - expect(result).not.toBeNull(); - expect(result!.ruleId).toBe("hardcode-risk"); - expect(result!.message).toContain("FloatingPanel"); - }); - - it("returns null for non-container nodes", () => { - const parent = makeNode({ layoutMode: "VERTICAL" }); - const node = makeNode({ type: "TEXT", layoutPositioning: "ABSOLUTE" }); - expect(hardcodeRisk.check(node, makeContext({ parent }))).toBeNull(); - }); - - it("returns null when not using absolute positioning", () => { - const parent = makeNode({ layoutMode: "VERTICAL" }); - const node = makeNode({ type: "FRAME" }); - expect(hardcodeRisk.check(node, makeContext({ parent }))).toBeNull(); - }); - - it("returns null when parent has no auto layout", () => { - const parent = makeNode({}); - const node = makeNode({ type: "FRAME", layoutPositioning: "ABSOLUTE" }); - expect(hardcodeRisk.check(node, makeContext({ parent }))).toBeNull(); - }); - - it("returns null when no parent", () => { - const node = makeNode({ type: "FRAME", layoutPositioning: "ABSOLUTE" }); - expect(hardcodeRisk.check(node, makeContext())).toBeNull(); - }); -}); 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 deleted file mode 100644 index 1ac24f94..00000000 --- a/src/core/rules/handoff-risk/prototype-link-in-design.test.ts +++ /dev/null @@ -1,114 +0,0 @@ -import { makeNode, makeFile, makeContext } from "../test-helpers.js"; -import { prototypeLinkInDesign } from "./index.js"; - -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("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 (FRAME and INSTANCE)", () => { - const testCases = [ - { type: "FRAME" as const, name: "Submit Btn" }, - { type: "FRAME" as const, name: "Nav Link" }, - { type: "INSTANCE" as const, name: "Tab Item" }, - { type: "FRAME" as const, name: "CTA" }, - { type: "INSTANCE" as const, name: "Toggle Switch" }, - ]; - for (const { type, name } of testCases) { - const node = makeNode({ type, 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 container frame whose children have interactions", () => { - const child = makeNode({ - id: "c:1", - type: "COMPONENT", - name: "Button", - interactions: [{ trigger: { type: "ON_CLICK" }, actions: [{ type: "NAVIGATE" }] }], - }); - const container = makeNode({ - type: "FRAME", - name: "Button Group", - children: [child], - }); - expect(prototypeLinkInDesign.check(container, makeContext())).toBeNull(); - }); - - it("does not throw on malformed variantOptions (not an array)", () => { - const node = makeNode({ - type: "COMPONENT", - name: "Card", - componentPropertyDefinitions: { - State: { type: "VARIANT", variantOptions: "not-an-array" }, - }, - }); - // "Card" is not an interactive name, malformed variantOptions should not match - expect(prototypeLinkInDesign.check(node, makeContext())).toBeNull(); - }); - - it("does not throw on variantOptions with non-string entries", () => { - const node = makeNode({ - type: "COMPONENT", - name: "Button", - componentPropertyDefinitions: { - State: { type: "VARIANT", variantOptions: [123, null, "hover"] }, - }, - }); - const result = prototypeLinkInDesign.check(node, makeContext()); - // "hover" matches STATE_VARIANT_PATTERNS, so should flag (no interactions) - expect(result).not.toBeNull(); - }); - - 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/index.ts b/src/core/rules/index.ts index 72edf1aa..ee73fc16 100644 --- a/src/core/rules/index.ts +++ b/src/core/rules/index.ts @@ -4,20 +4,17 @@ export * from "./rule-registry.js"; export * from "./rule-config.js"; -// Layout rules (11) -export * from "./layout/index.js"; +// Structure rules (9) +export * from "./structure/index.js"; // Token rules (7) export * from "./token/index.js"; -// Component rules (6) +// Component rules (4) export * from "./component/index.js"; // Naming rules (5) export * from "./naming/index.js"; -// AI Readability rules (5) -export * from "./ai-readability/index.js"; - -// Handoff Risk rules (5) -export * from "./handoff-risk/index.js"; +// Behavior rules (4) +export * from "./behavior/index.js"; diff --git a/src/core/rules/layout/absolute-position.test.ts b/src/core/rules/layout/absolute-position.test.ts deleted file mode 100644 index 4be331f5..00000000 --- a/src/core/rules/layout/absolute-position.test.ts +++ /dev/null @@ -1,103 +0,0 @@ -import { makeNode, makeFile, makeContext } from "../test-helpers.js"; -import { absolutePositionInAutoLayout } from "./index.js"; - -describe("absolute-position-in-auto-layout", () => { - it("has correct rule definition metadata", () => { - const def = absolutePositionInAutoLayout.definition; - expect(def.id).toBe("absolute-position-in-auto-layout"); - expect(def.category).toBe("layout"); - expect(def.why).toContain("Absolute positioning"); - expect(def.fix).toContain("absolute positioning"); - }); - - it("returns null when no parent", () => { - const node = makeNode({ layoutPositioning: "ABSOLUTE" }); - const ctx = makeContext(); - expect(absolutePositionInAutoLayout.check(node, ctx)).toBeNull(); - }); - - it("returns null when parent has no auto layout", () => { - const parent = makeNode({ id: "p:1", name: "Parent" }); - const node = makeNode({ layoutPositioning: "ABSOLUTE" }); - const ctx = makeContext({ parent }); - expect(absolutePositionInAutoLayout.check(node, ctx)).toBeNull(); - }); - - it("returns null for non-absolute node", () => { - const parent = makeNode({ - id: "p:1", - name: "Parent", - layoutMode: "HORIZONTAL", - }); - const node = makeNode({ layoutPositioning: "AUTO" }); - const ctx = makeContext({ parent }); - expect(absolutePositionInAutoLayout.check(node, ctx)).toBeNull(); - }); - - it("flags absolute positioned node in auto layout parent", () => { - const parent = makeNode({ - id: "p:1", - name: "AutoParent", - layoutMode: "HORIZONTAL", - absoluteBoundingBox: { x: 0, y: 0, width: 400, height: 400 }, - }); - const node = makeNode({ - name: "AbsChild", - layoutPositioning: "ABSOLUTE", - absoluteBoundingBox: { x: 0, y: 0, width: 200, height: 200 }, - }); - const ctx = makeContext({ parent }); - - const result = absolutePositionInAutoLayout.check(node, ctx); - expect(result).not.toBeNull(); - expect(result!.ruleId).toBe("absolute-position-in-auto-layout"); - expect(result!.message).toContain("AbsChild"); - expect(result!.message).toContain("absolute positioning"); - }); - - it("skips vector/graphic nodes", () => { - const parent = makeNode({ - id: "p:1", - name: "Parent", - layoutMode: "HORIZONTAL", - }); - const vectorNode = makeNode({ - type: "VECTOR", - layoutPositioning: "ABSOLUTE", - }); - const ctx = makeContext({ parent }); - expect(absolutePositionInAutoLayout.check(vectorNode, ctx)).toBeNull(); - }); - - it("skips nodes inside COMPONENT parent", () => { - const parent = makeNode({ - id: "p:1", - name: "CompParent", - type: "COMPONENT", - layoutMode: "VERTICAL", - }); - const node = makeNode({ - name: "InnerNode", - layoutPositioning: "ABSOLUTE", - absoluteBoundingBox: { x: 0, y: 0, width: 200, height: 200 }, - }); - const ctx = makeContext({ parent }); - expect(absolutePositionInAutoLayout.check(node, ctx)).toBeNull(); - }); - - it("skips small decorations (< 25% parent size)", () => { - const parent = makeNode({ - id: "p:1", - name: "BigParent", - layoutMode: "HORIZONTAL", - absoluteBoundingBox: { x: 0, y: 0, width: 400, height: 400 }, - }); - const smallNode = makeNode({ - name: "SmallBadge", - layoutPositioning: "ABSOLUTE", - absoluteBoundingBox: { x: 0, y: 0, width: 20, height: 20 }, - }); - const ctx = makeContext({ parent }); - expect(absolutePositionInAutoLayout.check(smallNode, ctx)).toBeNull(); - }); -}); diff --git a/src/core/rules/layout/deep-nesting.test.ts b/src/core/rules/layout/deep-nesting.test.ts deleted file mode 100644 index 4a6f2172..00000000 --- a/src/core/rules/layout/deep-nesting.test.ts +++ /dev/null @@ -1,160 +0,0 @@ -import { analyzeFile } from "../../engine/rule-engine.js"; -import type { AnalysisFile, AnalysisNode } from "../../contracts/figma-node.js"; - -// Import rules to register -import "../index.js"; - -function makeNode(overrides: Partial & { name: string; type: string }): AnalysisNode { - return { - id: overrides.id ?? overrides.name, - visible: true, - ...overrides, - } as AnalysisNode; -} - -function makeFile(document: AnalysisNode): AnalysisFile { - return { - fileKey: "test", - name: "Test", - lastModified: "", - version: "1", - document, - components: {}, - styles: {}, - }; -} - -describe("deep-nesting rule (componentDepth)", () => { - it("does not flag shallow nesting", () => { - const file = makeFile( - makeNode({ - name: "Root", type: "FRAME", - children: [ - makeNode({ - name: "Level1", type: "FRAME", - children: [ - makeNode({ name: "Level2", type: "FRAME" }), - ], - }), - ], - }) - ); - const result = analyzeFile(file); - const deepNestingIssues = result.issues.filter(i => i.rule.definition.id === "deep-nesting"); - expect(deepNestingIssues).toHaveLength(0); - }); - - it("flags deeply nested nodes (6+ levels)", () => { - // Build 7-level deep nesting without component boundaries - let deepest = makeNode({ name: "Level6", type: "FRAME" }); - let current = deepest; - for (let i = 5; i >= 0; i--) { - current = makeNode({ - name: `Level${i}`, type: "FRAME", - children: [current], - }); - } - - const file = makeFile(current); - const result = analyzeFile(file); - const deepNestingIssues = result.issues.filter(i => i.rule.definition.id === "deep-nesting"); - expect(deepNestingIssues.length).toBeGreaterThan(0); - }); - - it("resets depth at INSTANCE boundary", () => { - // 3 levels FRAME → INSTANCE → 3 levels FRAME = componentDepth max 3 (not 6) - const file = makeFile( - makeNode({ - name: "Root", type: "FRAME", - children: [ - makeNode({ - name: "L1", type: "FRAME", - children: [ - makeNode({ - name: "L2", type: "FRAME", - children: [ - makeNode({ - name: "MyInstance", type: "INSTANCE", - children: [ - makeNode({ - name: "Inner1", type: "FRAME", - children: [ - makeNode({ - name: "Inner2", type: "FRAME", - children: [ - makeNode({ name: "Inner3", type: "FRAME" }), - ], - }), - ], - }), - ], - }), - ], - }), - ], - }), - ], - }) - ); - - const result = analyzeFile(file); - const deepNestingIssues = result.issues.filter(i => i.rule.definition.id === "deep-nesting"); - // componentDepth resets at INSTANCE, so Inner3 is only componentDepth 3, not 6 - expect(deepNestingIssues).toHaveLength(0); - }); - - it("resets depth at COMPONENT boundary", () => { - // 4 levels → COMPONENT → 4 levels = componentDepth max 4 - let inner = makeNode({ name: "Deep4", type: "FRAME" }); - for (let i = 3; i >= 1; i--) { - inner = makeNode({ name: `Deep${i}`, type: "FRAME", children: [inner] }); - } - - let outer: AnalysisNode = makeNode({ name: "MyComponent", type: "COMPONENT", children: [inner] }); - for (let i = 3; i >= 0; i--) { - outer = makeNode({ name: `Outer${i}`, type: "FRAME", children: [outer] }); - } - - const file = makeFile(outer); - const result = analyzeFile(file); - const deepNestingIssues = result.issues.filter(i => i.rule.definition.id === "deep-nesting"); - // max componentDepth is 4 (inside component), threshold is 5 → no flag - expect(deepNestingIssues).toHaveLength(0); - }); - - it("flags deep nesting within a component", () => { - // COMPONENT → 6 levels deep = componentDepth 6, threshold 5 → flag - let inner = makeNode({ name: "VeryDeep", type: "FRAME" }); - for (let i = 5; i >= 1; i--) { - inner = makeNode({ name: `Level${i}`, type: "FRAME", children: [inner] }); - } - - const file = makeFile( - makeNode({ - name: "MyComponent", type: "COMPONENT", - children: [inner], - }) - ); - - const result = analyzeFile(file); - const deepNestingIssues = result.issues.filter(i => i.rule.definition.id === "deep-nesting"); - expect(deepNestingIssues.length).toBeGreaterThan(0); - expect(deepNestingIssues[0]!.violation.message).toContain("within its component"); - }); - - it("rule is in handoff-risk category", () => { - const file = makeFile(makeNode({ name: "Root", type: "FRAME" })); - const result = analyzeFile(file); - // Check rule registry - const rules = result.issues.map(i => i.rule.definition); - // Even if no issues, we can verify from a flagged case - let inner = makeNode({ name: "Deep", type: "FRAME" }); - for (let i = 5; i >= 0; i--) { - inner = makeNode({ name: `L${i}`, type: "FRAME", children: [inner] }); - } - const file2 = makeFile(inner); - const result2 = analyzeFile(file2); - const issue = result2.issues.find(i => i.rule.definition.id === "deep-nesting"); - expect(issue?.rule.definition.category).toBe("handoff-risk"); - }); -}); diff --git a/src/core/rules/layout/fixed-width-in-responsive-context.test.ts b/src/core/rules/layout/fixed-width-in-responsive-context.test.ts deleted file mode 100644 index fb4ddf84..00000000 --- a/src/core/rules/layout/fixed-width-in-responsive-context.test.ts +++ /dev/null @@ -1,71 +0,0 @@ -import { makeNode, makeFile, makeContext } from "../test-helpers.js"; -import { fixedWidthInResponsiveContext } from "./index.js"; - -describe("fixed-width-in-responsive-context", () => { - it("has correct rule definition metadata", () => { - expect(fixedWidthInResponsiveContext.definition.id).toBe("fixed-width-in-responsive-context"); - expect(fixedWidthInResponsiveContext.definition.category).toBe("layout"); - }); - - it("flags container with FIXED horizontal sizing in auto layout parent", () => { - const parent = makeNode({ layoutMode: "HORIZONTAL" }); - const node = makeNode({ type: "FRAME", name: "LeftPanel", layoutSizingHorizontal: "FIXED" }); - const result = fixedWidthInResponsiveContext.check(node, makeContext({ parent })); - expect(result).not.toBeNull(); - expect(result!.ruleId).toBe("fixed-width-in-responsive-context"); - }); - - it("returns null when no parent", () => { - const node = makeNode({ type: "FRAME", layoutSizingHorizontal: "FIXED" }); - expect(fixedWidthInResponsiveContext.check(node, makeContext())).toBeNull(); - }); - - it("returns null when parent has no auto layout", () => { - const parent = makeNode({}); - const node = makeNode({ type: "FRAME", layoutSizingHorizontal: "FIXED" }); - expect(fixedWidthInResponsiveContext.check(node, makeContext({ parent }))).toBeNull(); - }); - - it("returns null for non-container nodes", () => { - const parent = makeNode({ layoutMode: "HORIZONTAL" }); - const node = makeNode({ type: "TEXT", layoutSizingHorizontal: "FIXED" }); - expect(fixedWidthInResponsiveContext.check(node, makeContext({ parent }))).toBeNull(); - }); - - it("returns null when sizing is FILL", () => { - const parent = makeNode({ layoutMode: "HORIZONTAL" }); - const node = makeNode({ type: "FRAME", layoutSizingHorizontal: "FILL" }); - expect(fixedWidthInResponsiveContext.check(node, makeContext({ parent }))).toBeNull(); - }); - - it("returns null when sizing is HUG", () => { - const parent = makeNode({ layoutMode: "HORIZONTAL" }); - const node = makeNode({ type: "FRAME", layoutSizingHorizontal: "HUG" }); - expect(fixedWidthInResponsiveContext.check(node, makeContext({ parent }))).toBeNull(); - }); - - it("returns null for excluded name patterns", () => { - const parent = makeNode({ layoutMode: "HORIZONTAL" }); - const node = makeNode({ type: "FRAME", name: "navigation", layoutSizingHorizontal: "FIXED" }); - expect(fixedWidthInResponsiveContext.check(node, makeContext({ parent }))).toBeNull(); - }); - - it("fallback: returns null when layoutAlign is STRETCH (no layoutSizingHorizontal)", () => { - const parent = makeNode({ layoutMode: "HORIZONTAL" }); - const node = makeNode({ type: "FRAME", layoutAlign: "STRETCH" }); - expect(fixedWidthInResponsiveContext.check(node, makeContext({ parent }))).toBeNull(); - }); - - it("fallback: flags when layoutAlign is INHERIT (no layoutSizingHorizontal)", () => { - const parent = makeNode({ layoutMode: "HORIZONTAL" }); - const node = makeNode({ - type: "FRAME", - name: "FixedPanel", - layoutAlign: "INHERIT", - absoluteBoundingBox: { x: 0, y: 0, width: 200, height: 100 }, - }); - const result = fixedWidthInResponsiveContext.check(node, makeContext({ parent })); - expect(result).not.toBeNull(); - expect(result!.ruleId).toBe("fixed-width-in-responsive-context"); - }); -}); diff --git a/src/core/rules/layout/group-usage.test.ts b/src/core/rules/layout/group-usage.test.ts deleted file mode 100644 index 20d1355a..00000000 --- a/src/core/rules/layout/group-usage.test.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { makeNode, makeFile, makeContext } from "../test-helpers.js"; -import { groupUsage } from "./index.js"; - -describe("group-usage", () => { - it("has correct rule definition metadata", () => { - expect(groupUsage.definition.id).toBe("group-usage"); - expect(groupUsage.definition.category).toBe("layout"); - }); - - it("flags GROUP nodes", () => { - const node = makeNode({ type: "GROUP", name: "My Group" }); - const result = groupUsage.check(node, makeContext()); - expect(result).not.toBeNull(); - expect(result!.ruleId).toBe("group-usage"); - expect(result!.message).toContain("My Group"); - }); - - it("returns null for FRAME nodes", () => { - const node = makeNode({ type: "FRAME" }); - expect(groupUsage.check(node, makeContext())).toBeNull(); - }); - - it("returns null for COMPONENT nodes", () => { - const node = makeNode({ type: "COMPONENT" }); - expect(groupUsage.check(node, makeContext())).toBeNull(); - }); - - it("returns null for TEXT nodes", () => { - const node = makeNode({ type: "TEXT" }); - expect(groupUsage.check(node, makeContext())).toBeNull(); - }); -}); diff --git a/src/core/rules/layout/index.ts b/src/core/rules/layout/index.ts deleted file mode 100644 index 6584404b..00000000 --- a/src/core/rules/layout/index.ts +++ /dev/null @@ -1,389 +0,0 @@ -import type { RuleCheckFn, RuleDefinition } from "../../contracts/rule.js"; -import type { AnalysisNode } from "../../contracts/figma-node.js"; -import { defineRule } from "../rule-registry.js"; -import { getRuleOption } from "../rule-config.js"; - -// ============================================ -// Helper functions -// ============================================ - -function isContainerNode(node: AnalysisNode): boolean { - return node.type === "FRAME" || node.type === "GROUP" || node.type === "COMPONENT"; -} - -function hasAutoLayout(node: AnalysisNode): boolean { - return node.layoutMode !== undefined && node.layoutMode !== "NONE"; -} - -function hasTextContent(node: AnalysisNode): boolean { - return node.type === "TEXT" || (node.children?.some((c) => c.type === "TEXT") ?? false); -} - -// ============================================ -// no-auto-layout -// ============================================ - -const noAutoLayoutDef: RuleDefinition = { - id: "no-auto-layout", - name: "No Auto Layout", - category: "layout", - 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) => { - if (node.type !== "FRAME") return null; - if (hasAutoLayout(node)) return null; - // Skip if frame has no children (might be intentional placeholder) - if (!node.children || node.children.length === 0) return null; - - return { - ruleId: noAutoLayoutDef.id, - nodeId: node.id, - nodePath: context.path.join(" > "), - message: `Frame "${node.name}" has no Auto Layout`, - }; -}; - -export const noAutoLayout = defineRule({ - definition: noAutoLayoutDef, - check: noAutoLayoutCheck, -}); - -// ============================================ -// absolute-position-in-auto-layout -// ============================================ - -const absolutePositionInAutoLayoutDef: RuleDefinition = { - id: "absolute-position-in-auto-layout", - name: "Absolute Position in Auto Layout", - category: "layout", - 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", -}; - -import { isExcludedName } from "../excluded-names.js"; - -/** - * Check if a node is small relative to its parent (decoration/badge pattern). - * Returns true if the node is less than 25% of the parent's width AND height. - */ -function isSmallRelativeToParent(node: AnalysisNode, parent: AnalysisNode): boolean { - const nodeBB = node.absoluteBoundingBox; - const parentBB = parent.absoluteBoundingBox; - if (!nodeBB || !parentBB) return false; - if (parentBB.width === 0 || parentBB.height === 0) return false; - - const widthRatio = nodeBB.width / parentBB.width; - const heightRatio = nodeBB.height / parentBB.height; - return widthRatio < 0.25 && heightRatio < 0.25; -} - -const absolutePositionInAutoLayoutCheck: RuleCheckFn = (node, context) => { - if (!context.parent) return null; - if (!hasAutoLayout(context.parent)) return null; - if (node.layoutPositioning !== "ABSOLUTE") return null; - - // Exception: vector/graphic nodes (icons, illustrations — absolute positioning is expected) - if (node.type === "VECTOR" || node.type === "BOOLEAN_OPERATION" || node.type === "LINE" || node.type === "ELLIPSE" || node.type === "STAR" || node.type === "REGULAR_POLYGON") return null; - - // Exception: intentional name patterns (badge, close, overlay, etc.) - if (isExcludedName(node.name)) return null; - - // Exception: small decoration relative to parent (< 25% size) - if (isSmallRelativeToParent(node, context.parent)) return null; - - // Exception: inside a component definition (designer's intentional layout) - if (context.parent.type === "COMPONENT") return null; - - return { - ruleId: absolutePositionInAutoLayoutDef.id, - nodeId: node.id, - nodePath: context.path.join(" > "), - message: `"${node.name}" uses absolute positioning inside Auto Layout parent "${context.parent.name}". If intentional (badge, overlay, close button), rename to badge-*, overlay-*, close-* to suppress this warning.`, - }; -}; - -export const absolutePositionInAutoLayout = defineRule({ - definition: absolutePositionInAutoLayoutDef, - check: absolutePositionInAutoLayoutCheck, -}); - -// ============================================ -// fixed-width-in-responsive-context -// ============================================ - -const fixedWidthInResponsiveContextDef: RuleDefinition = { - id: "fixed-width-in-responsive-context", - name: "Fixed Width in Responsive Context", - category: "layout", - 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) => { - if (!context.parent) return null; - if (!hasAutoLayout(context.parent)) return null; - if (!isContainerNode(node)) return null; - - // Use layoutSizingHorizontal if available (accurate) - if (node.layoutSizingHorizontal) { - if (node.layoutSizingHorizontal !== "FIXED") return null; - } else { - // Fallback: STRETCH means fill, skip - if (node.layoutAlign === "STRETCH") return null; - if (!node.absoluteBoundingBox) return null; - if (node.layoutAlign !== "INHERIT") return null; - } - - // Excluded names (nav, header, etc.) - if (isExcludedName(node.name)) return null; - - return { - ruleId: fixedWidthInResponsiveContextDef.id, - nodeId: node.id, - nodePath: context.path.join(" > "), - message: `"${node.name}" has fixed width inside Auto Layout`, - }; -}; - -export const fixedWidthInResponsiveContext = defineRule({ - definition: fixedWidthInResponsiveContextDef, - check: fixedWidthInResponsiveContextCheck, -}); - -// ============================================ -// missing-responsive-behavior -// ============================================ - -const missingResponsiveBehaviorDef: RuleDefinition = { - id: "missing-responsive-behavior", - name: "Missing Responsive Behavior", - category: "layout", - 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) => { - if (!isContainerNode(node)) return null; - // Skip if inside Auto Layout (Auto Layout handles responsiveness) - if (context.parent && hasAutoLayout(context.parent)) return null; - // Skip root-level frames (they define the viewport) - if (context.depth < 2) return null; - - // Check for missing layout mode and no parent auto layout - if (!hasAutoLayout(node) && !node.layoutAlign) { - return { - ruleId: missingResponsiveBehaviorDef.id, - nodeId: node.id, - nodePath: context.path.join(" > "), - message: `"${node.name}" has no responsive behavior configured`, - }; - } - - return null; -}; - -export const missingResponsiveBehavior = defineRule({ - definition: missingResponsiveBehaviorDef, - check: missingResponsiveBehaviorCheck, -}); - -// ============================================ -// group-usage -// ============================================ - -const groupUsageDef: RuleDefinition = { - id: "group-usage", - name: "Group Usage", - category: "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) => { - if (node.type !== "GROUP") return null; - - return { - ruleId: groupUsageDef.id, - nodeId: node.id, - nodePath: context.path.join(" > "), - message: `"${node.name}" is a Group - consider converting to Frame with Auto Layout`, - }; -}; - -export const groupUsage = defineRule({ - definition: groupUsageDef, - check: groupUsageCheck, -}); - -// ============================================ -// fixed-size-in-auto-layout -// ============================================ - -const fixedSizeInAutoLayoutDef: RuleDefinition = { - id: "fixed-size-in-auto-layout", - name: "Fixed Size in Auto Layout", - category: "layout", - 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) => { - if (!context.parent) return null; - if (!hasAutoLayout(context.parent)) return null; - // Only check containers, not leaf nodes - if (!isContainerNode(node)) return null; - if (!node.absoluteBoundingBox) return null; - - // Skip if it's intentionally a small fixed element (icon, avatar, etc.) - const { width, height } = node.absoluteBoundingBox; - if (width <= 48 && height <= 48) return null; - - // Both axes must be FIXED for this to be a problem - const hFixed = - node.layoutSizingHorizontal === "FIXED" || node.layoutSizingHorizontal === undefined; - const vFixed = - node.layoutSizingVertical === "FIXED" || node.layoutSizingVertical === undefined; - if (!hFixed || !vFixed) return null; - - // Skip if it has children — only flag leaf-like containers with no auto-layout of their own - if (node.layoutMode && node.layoutMode !== "NONE") return null; - - return { - ruleId: fixedSizeInAutoLayoutDef.id, - nodeId: node.id, - nodePath: context.path.join(" > "), - message: `Container "${node.name}" (${width}×${height}) uses fixed size on both axes inside Auto Layout. Consider HUG or FILL for at least one axis.`, - }; -}; - -export const fixedSizeInAutoLayout = defineRule({ - definition: fixedSizeInAutoLayoutDef, - check: fixedSizeInAutoLayoutCheck, -}); - -// ============================================ -// missing-min-width -// ============================================ - -const missingMinWidthDef: RuleDefinition = { - id: "missing-min-width", - name: "Missing Min Width", - category: "layout", - 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) => { - // Only check containers and text-containing nodes - if (!isContainerNode(node) && !hasTextContent(node)) return null; - // Skip small fixed elements (icons, dividers) - if (node.absoluteBoundingBox) { - const { width, height } = node.absoluteBoundingBox; - if (width <= 48 && height <= 24) return null; - } - // Skip if not in Auto Layout context - if (!context.parent || !hasAutoLayout(context.parent)) return null; - - // Only flag FILL containers — FIXED/HUG don't need min-width - if (node.layoutSizingHorizontal !== "FILL") return null; - - // Has minWidth set — no issue - if (node.minWidth !== undefined) return null; - - return { - ruleId: missingMinWidthDef.id, - nodeId: node.id, - nodePath: context.path.join(" > "), - message: `"${node.name}" uses FILL width without a min-width constraint. It may collapse on narrow screens.`, - }; -}; - -export const missingMinWidth = defineRule({ - definition: missingMinWidthDef, - check: missingMinWidthCheck, -}); - -// ============================================ -// missing-max-width -// ============================================ - -const missingMaxWidthDef: RuleDefinition = { - id: "missing-max-width", - name: "Missing Max Width", - category: "layout", - 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) => { - // Only check containers and text-containing nodes - if (!isContainerNode(node) && !hasTextContent(node)) return null; - // Skip small elements - if (node.absoluteBoundingBox) { - const { width } = node.absoluteBoundingBox; - if (width <= 200) return null; - } - // Skip if not in Auto Layout context - if (!context.parent || !hasAutoLayout(context.parent)) return null; - - // Only flag FILL containers — FIXED/HUG don't need max-width - if (node.layoutSizingHorizontal !== "FILL") return null; - - // Has maxWidth set — no issue - if (node.maxWidth !== undefined) return null; - - return { - ruleId: missingMaxWidthDef.id, - nodeId: node.id, - nodePath: context.path.join(" > "), - message: `"${node.name}" uses FILL width without a max-width constraint. Content may stretch too wide on large screens.`, - }; -}; - -export const missingMaxWidth = defineRule({ - definition: missingMaxWidthDef, - check: missingMaxWidthCheck, -}); - -// ============================================ -// deep-nesting -// ============================================ - -const deepNestingDef: RuleDefinition = { - id: "deep-nesting", - name: "Deep Nesting", - category: "handoff-risk", - 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", -}; - -const deepNestingCheck: RuleCheckFn = (node, context, options) => { - const maxDepth = (options?.["maxDepth"] as number) ?? getRuleOption("deep-nesting", "maxDepth", 5); - - if (context.componentDepth < maxDepth) return null; - if (!isContainerNode(node)) return null; - - return { - ruleId: deepNestingDef.id, - nodeId: node.id, - nodePath: context.path.join(" > "), - message: `"${node.name}" is nested ${context.componentDepth} levels deep within its component (max: ${maxDepth})`, - }; -}; - -export const deepNesting = defineRule({ - definition: deepNestingDef, - check: deepNestingCheck, -}); - diff --git a/src/core/rules/layout/missing-responsive-behavior.test.ts b/src/core/rules/layout/missing-responsive-behavior.test.ts deleted file mode 100644 index d36aae8b..00000000 --- a/src/core/rules/layout/missing-responsive-behavior.test.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { makeNode, makeFile, makeContext } from "../test-helpers.js"; -import { missingResponsiveBehavior } from "./index.js"; - -describe("missing-responsive-behavior", () => { - it("has correct rule definition metadata", () => { - expect(missingResponsiveBehavior.definition.id).toBe("missing-responsive-behavior"); - expect(missingResponsiveBehavior.definition.category).toBe("layout"); - }); - - it("flags container without auto layout or layoutAlign", () => { - const node = makeNode({ type: "FRAME", name: "Card" }); - const result = missingResponsiveBehavior.check(node, makeContext()); - expect(result).not.toBeNull(); - expect(result!.ruleId).toBe("missing-responsive-behavior"); - expect(result!.message).toContain("Card"); - }); - - it("returns null for non-container nodes", () => { - const node = makeNode({ type: "TEXT" }); - expect(missingResponsiveBehavior.check(node, makeContext())).toBeNull(); - }); - - it("returns null when parent has auto layout", () => { - const node = makeNode({ type: "FRAME", name: "Card" }); - const parent = makeNode({ layoutMode: "HORIZONTAL" }); - expect(missingResponsiveBehavior.check(node, makeContext({ parent }))).toBeNull(); - }); - - it("returns null for root-level frames (depth < 2)", () => { - const node = makeNode({ type: "FRAME" }); - expect(missingResponsiveBehavior.check(node, makeContext({ depth: 1 }))).toBeNull(); - }); - - it("returns null when node has auto layout", () => { - const node = makeNode({ type: "FRAME", layoutMode: "VERTICAL" }); - expect(missingResponsiveBehavior.check(node, makeContext())).toBeNull(); - }); - - it("returns null when node has layoutAlign", () => { - const node = makeNode({ type: "FRAME", layoutAlign: "STRETCH" }); - expect(missingResponsiveBehavior.check(node, makeContext())).toBeNull(); - }); -}); diff --git a/src/core/rules/layout/no-auto-layout.test.ts b/src/core/rules/layout/no-auto-layout.test.ts deleted file mode 100644 index fc530ea3..00000000 --- a/src/core/rules/layout/no-auto-layout.test.ts +++ /dev/null @@ -1,69 +0,0 @@ -import { makeNode, makeFile, makeContext } from "../test-helpers.js"; -import { noAutoLayout } from "./index.js"; - -describe("no-auto-layout", () => { - it("has correct rule definition metadata", () => { - const def = noAutoLayout.definition; - expect(def.id).toBe("no-auto-layout"); - expect(def.category).toBe("layout"); - expect(def.why).toContain("Auto Layout"); - expect(def.fix).toContain("Auto Layout"); - }); - - it("returns null for non-FRAME nodes", () => { - const textNode = makeNode({ type: "TEXT" }); - const ctx = makeContext(); - expect(noAutoLayout.check(textNode, ctx)).toBeNull(); - - const groupNode = makeNode({ type: "GROUP" }); - expect(noAutoLayout.check(groupNode, ctx)).toBeNull(); - }); - - it("returns null for frame with auto layout", () => { - const node = makeNode({ - layoutMode: "HORIZONTAL", - children: [makeNode({ id: "c:1", name: "Child" })], - }); - const ctx = makeContext(); - expect(noAutoLayout.check(node, ctx)).toBeNull(); - }); - - it("returns null for empty frame (no children)", () => { - const node = makeNode({ children: [] }); - const ctx = makeContext(); - expect(noAutoLayout.check(node, ctx)).toBeNull(); - }); - - it("returns null for frame without children property", () => { - const node = makeNode({}); - const ctx = makeContext(); - expect(noAutoLayout.check(node, ctx)).toBeNull(); - }); - - it("flags frame without auto layout that has children", () => { - const child = makeNode({ id: "c:1", name: "Child" }); - const node = makeNode({ name: "Container", children: [child] }); - const ctx = makeContext(); - - const result = noAutoLayout.check(node, ctx); - expect(result).not.toBeNull(); - expect(result!.ruleId).toBe("no-auto-layout"); - expect(result!.message).toContain("Container"); - expect(result!.message).toContain("no Auto Layout"); - }); - - it("flags frame with layoutMode NONE that has children", () => { - const child = makeNode({ id: "c:1", name: "Child" }); - const node = makeNode({ - name: "NoneLayout", - layoutMode: "NONE", - children: [child], - }); - const ctx = makeContext(); - - const result = noAutoLayout.check(node, ctx); - expect(result).not.toBeNull(); - expect(result!.ruleId).toBe("no-auto-layout"); - expect(result!.message).toContain("NoneLayout"); - }); -}); diff --git a/src/core/rules/naming/index.ts b/src/core/rules/naming/index.ts index 2143f903..5ba83083 100644 --- a/src/core/rules/naming/index.ts +++ b/src/core/rules/naming/index.ts @@ -135,7 +135,7 @@ const inconsistentNamingConventionDef: RuleDefinition = { category: "naming", why: "Mixed naming conventions (camelCase + kebab-case + Title Case) at the same level confuse AI pattern recognition", impact: "AI generates inconsistent class/component names, making the codebase harder to maintain", - fix: "Use a consistent naming convention for sibling elements", + fix: "Pick one convention for sibling elements (e.g., kebab-case: 'product-card', or PascalCase: 'ProductCard') — AI maps names to CSS classes and component names, so mixed conventions produce inconsistent code", }; const inconsistentNamingConventionCheck: RuleCheckFn = (node, context) => { diff --git a/src/core/rules/rule-config.ts b/src/core/rules/rule-config.ts index 820f3717..d05eacbf 100644 --- a/src/core/rules/rule-config.ts +++ b/src/core/rules/rule-config.ts @@ -6,7 +6,7 @@ import type { RuleConfig, RuleId } from "../contracts/rule.js"; */ export const RULE_CONFIGS: Record = { // ============================================ - // Layout (11 rules) + // Structure (9 rules) // ============================================ "no-auto-layout": { severity: "blocking", @@ -20,10 +20,14 @@ export const RULE_CONFIGS: Record = { depthWeight: 1.3, enabled: true, }, - "fixed-width-in-responsive-context": { - severity: "missing-info", - score: -2, - depthWeight: 1.3, + "fixed-size-in-auto-layout": { + severity: "risk", + score: -5, + enabled: true, + }, + "missing-size-constraint": { + severity: "risk", + score: -5, enabled: true, }, "missing-responsive-behavior": { @@ -38,27 +42,26 @@ export const RULE_CONFIGS: Record = { depthWeight: 1.2, enabled: true, }, - "fixed-size-in-auto-layout": { + "deep-nesting": { severity: "risk", - score: -5, + score: -4, enabled: true, + options: { + maxDepth: 5, + }, }, - "missing-min-width": { + "z-index-dependent-layout": { severity: "risk", score: -5, + depthWeight: 1.3, enabled: true, }, - "missing-max-width": { - severity: "risk", - score: -4, - enabled: true, - }, - "deep-nesting": { - severity: "risk", - score: -4, + "unnecessary-node": { + severity: "suggestion", + score: -2, enabled: true, options: { - maxDepth: 5, + slotRecommendationThreshold: 3, }, }, @@ -111,7 +114,7 @@ export const RULE_CONFIGS: Record = { }, // ============================================ - // Component (6 rules) + // Component (4 rules) // ============================================ "missing-component": { severity: "risk", @@ -133,6 +136,11 @@ export const RULE_CONFIGS: Record = { score: -2, enabled: true, }, + "variant-structure-mismatch": { + severity: "risk", + score: -4, + enabled: true, + }, // ============================================ // Naming (5 rules) @@ -167,56 +175,26 @@ export const RULE_CONFIGS: Record = { }, // ============================================ - // AI Readability (5 rules) + // Behavior (4 rules) // ============================================ - "ambiguous-structure": { - severity: "blocking", - score: -10, - depthWeight: 1.3, - enabled: true, - }, - "z-index-dependent-layout": { + "text-truncation-unhandled": { severity: "risk", score: -5, - depthWeight: 1.3, enabled: true, }, - "missing-layout-hint": { - severity: "risk", - score: -5, + "prototype-link-in-design": { + severity: "missing-info", + score: -2, enabled: true, }, - "invisible-layer": { - severity: "suggestion", - score: -1, - enabled: true, - options: { - slotRecommendationThreshold: 3, - }, - }, - "empty-frame": { + "overflow-behavior-unknown": { severity: "missing-info", score: -3, enabled: true, }, - - // ============================================ - // Handoff Risk (5 rules) - // ============================================ - "hardcode-risk": { - severity: "risk", - score: -5, - depthWeight: 1.5, - enabled: true, - }, - "text-truncation-unhandled": { - severity: "risk", - score: -5, - enabled: true, - }, - "prototype-link-in-design": { + "wrap-behavior-unknown": { severity: "missing-info", - score: -2, + score: -3, enabled: true, }, }; @@ -249,13 +227,16 @@ export function getConfigsWithPreset( break; case "dev-friendly": - // Focus on layout and handoff issues + // Focus on structure and behavior issues for (const [id, config] of Object.entries(configs)) { const ruleId = id as RuleId; if ( - !ruleId.includes("layout") && - !ruleId.includes("handoff") && - !ruleId.includes("responsive") + !ruleId.includes("auto-layout") && + !ruleId.includes("responsive") && + !ruleId.includes("truncation") && + !ruleId.includes("overflow") && + !ruleId.includes("wrap") && + !ruleId.includes("size") ) { configs[ruleId] = { ...config, enabled: false }; } @@ -263,13 +244,14 @@ export function getConfigsWithPreset( break; case "ai-ready": - // Boost AI readability and naming rules + // Boost structure and naming rules for (const [id, config] of Object.entries(configs)) { const ruleId = id as RuleId; if ( - ruleId.includes("ambiguous") || + ruleId.includes("auto-layout") || ruleId.includes("structure") || - ruleId.includes("name") + ruleId.includes("name") || + ruleId.includes("unnecessary") ) { configs[ruleId] = { ...config, diff --git a/src/core/rules/structure/index.ts b/src/core/rules/structure/index.ts new file mode 100644 index 00000000..b91c4f86 --- /dev/null +++ b/src/core/rules/structure/index.ts @@ -0,0 +1,550 @@ +import type { RuleCheckFn, RuleDefinition } from "../../contracts/rule.js"; +import type { AnalysisNode } from "../../contracts/figma-node.js"; +import { defineRule } from "../rule-registry.js"; +import { getRuleOption } from "../rule-config.js"; +import { isExcludedName } from "../excluded-names.js"; + +// ============================================ +// Helper functions +// ============================================ + +function isContainerNode(node: AnalysisNode): boolean { + return node.type === "FRAME" || node.type === "GROUP" || node.type === "COMPONENT" || node.type === "INSTANCE"; +} + +function hasAutoLayout(node: AnalysisNode): boolean { + return node.layoutMode !== undefined && node.layoutMode !== "NONE"; +} + +function hasTextContent(node: AnalysisNode): boolean { + return node.type === "TEXT" || (node.children?.some((c) => c.type === "TEXT") ?? false); +} + +function hasOverlappingBounds(a: AnalysisNode, b: AnalysisNode): boolean { + const boxA = a.absoluteBoundingBox; + const boxB = b.absoluteBoundingBox; + + if (!boxA || !boxB) return false; + + return !( + boxA.x + boxA.width <= boxB.x || + boxB.x + boxB.width <= boxA.x || + boxA.y + boxA.height <= boxB.y || + boxB.y + boxB.height <= boxA.y + ); +} + +// ============================================ +// no-auto-layout (merged: absorbs ambiguous-structure + missing-layout-hint) +// ============================================ + +const noAutoLayoutDef: RuleDefinition = { + id: "no-auto-layout", + name: "No Auto Layout", + category: "structure", + 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 to create clear, explicit structure — enables AI to generate flexbox/grid instead of absolute positioning", +}; + +const noAutoLayoutCheck: RuleCheckFn = (node, context) => { + if (node.type !== "FRAME" && !isContainerNode(node)) return null; + if (hasAutoLayout(node)) return null; + if (!node.children || node.children.length === 0) return null; + + // Priority 1: Check for overlapping visible children (ambiguous-structure) + if (node.children.length >= 2) { + for (let i = 0; i < node.children.length; i++) { + for (let j = i + 1; j < node.children.length; j++) { + const childA = node.children[i]; + const childB = node.children[j]; + if (!childA || !childB) continue; + + if (hasOverlappingBounds(childA, childB)) { + if (childA.visible !== false && childB.visible !== false) { + return { + ruleId: noAutoLayoutDef.id, + nodeId: node.id, + nodePath: context.path.join(" > "), + message: `"${node.name}" has overlapping children without Auto Layout — AI cannot determine intended layout`, + }; + } + } + } + } + } + + // Priority 2: Check for nested containers without layout hints (missing-layout-hint) + if (node.children.length >= 2) { + const nestedContainers = node.children.filter((c) => isContainerNode(c)); + if (nestedContainers.length >= 2) { + const withoutLayout = nestedContainers.filter((c) => !hasAutoLayout(c)); + if (withoutLayout.length >= 2) { + return { + ruleId: noAutoLayoutDef.id, + nodeId: node.id, + nodePath: context.path.join(" > "), + message: `"${node.name}" has nested containers without layout hints — structure is unpredictable for AI`, + }; + } + } + } + + // Priority 3: Basic no-auto-layout check (FRAME only) + if (node.type !== "FRAME") return null; + + return { + ruleId: noAutoLayoutDef.id, + nodeId: node.id, + nodePath: context.path.join(" > "), + message: `Frame "${node.name}" has no Auto Layout`, + }; +}; + +export const noAutoLayout = defineRule({ + definition: noAutoLayoutDef, + check: noAutoLayoutCheck, +}); + +// ============================================ +// absolute-position-in-auto-layout +// ============================================ + +const absolutePositionInAutoLayoutDef: RuleDefinition = { + id: "absolute-position-in-auto-layout", + name: "Absolute Position in Auto Layout", + category: "structure", + 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", +}; + +/** + * Check if a node is small relative to its parent (decoration/badge pattern). + * Returns true if the node is less than 25% of the parent's width AND height. + */ +function isSmallRelativeToParent(node: AnalysisNode, parent: AnalysisNode): boolean { + const nodeBB = node.absoluteBoundingBox; + const parentBB = parent.absoluteBoundingBox; + if (!nodeBB || !parentBB) return false; + if (parentBB.width === 0 || parentBB.height === 0) return false; + + const widthRatio = nodeBB.width / parentBB.width; + const heightRatio = nodeBB.height / parentBB.height; + return widthRatio < 0.25 && heightRatio < 0.25; +} + +const absolutePositionInAutoLayoutCheck: RuleCheckFn = (node, context) => { + if (!context.parent) return null; + if (!hasAutoLayout(context.parent)) return null; + if (node.layoutPositioning !== "ABSOLUTE") return null; + + // Exception: vector/graphic nodes (icons, illustrations — absolute positioning is expected) + if (node.type === "VECTOR" || node.type === "BOOLEAN_OPERATION" || node.type === "LINE" || node.type === "ELLIPSE" || node.type === "STAR" || node.type === "REGULAR_POLYGON") return null; + + // Exception: intentional name patterns (badge, close, overlay, etc.) + if (isExcludedName(node.name)) return null; + + // Exception: small decoration relative to parent (< 25% size) + if (isSmallRelativeToParent(node, context.parent)) return null; + + // Exception: inside a component definition (designer's intentional layout) + if (context.parent.type === "COMPONENT") return null; + + return { + ruleId: absolutePositionInAutoLayoutDef.id, + nodeId: node.id, + nodePath: context.path.join(" > "), + message: `"${node.name}" uses absolute positioning inside Auto Layout parent "${context.parent.name}". If intentional (badge, overlay, close button), rename to badge-*, overlay-*, close-* to suppress this warning.`, + }; +}; + +export const absolutePositionInAutoLayout = defineRule({ + definition: absolutePositionInAutoLayoutDef, + check: absolutePositionInAutoLayoutCheck, +}); + +// ============================================ +// fixed-size-in-auto-layout (merged: absorbs fixed-width-in-responsive-context) +// ============================================ + +const fixedSizeInAutoLayoutDef: RuleDefinition = { + id: "fixed-size-in-auto-layout", + name: "Fixed Size in Auto Layout", + category: "structure", + why: "Fixed sizing 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. Both-axes FIXED → layout completely rigid; horizontal-only FIXED → width won't adapt to parent resize", +}; + +const fixedSizeInAutoLayoutCheck: RuleCheckFn = (node, context) => { + if (!context.parent) return null; + if (!hasAutoLayout(context.parent)) return null; + if (!isContainerNode(node)) return null; + if (!node.absoluteBoundingBox) return null; + + // Skip if it's intentionally a small fixed element (icon, avatar, etc.) + const { width, height } = node.absoluteBoundingBox; + if (width <= 48 && height <= 48) return null; + + // Check both axes FIXED (stronger case) + const hFixed = + node.layoutSizingHorizontal === "FIXED" || node.layoutSizingHorizontal === undefined; + const vFixed = + node.layoutSizingVertical === "FIXED" || node.layoutSizingVertical === undefined; + + if (hFixed && vFixed) { + // Skip if it has its own auto-layout + if (node.layoutMode && node.layoutMode !== "NONE") return null; + + return { + ruleId: fixedSizeInAutoLayoutDef.id, + nodeId: node.id, + nodePath: context.path.join(" > "), + message: `Container "${node.name}" (${width}×${height}) uses fixed size on both axes inside Auto Layout. Consider HUG or FILL for at least one axis.`, + }; + } + + // Check horizontal-only FIXED (lighter case, from fixed-width-in-responsive-context) + if (hFixed && !vFixed) { + // Use layoutSizingHorizontal if available (accurate) + if (node.layoutSizingHorizontal) { + if (node.layoutSizingHorizontal !== "FIXED") return null; + } else { + // Fallback: STRETCH means fill, skip + if (node.layoutAlign === "STRETCH") return null; + if (node.layoutAlign !== "INHERIT") return null; + } + + // Excluded names (nav, header, etc.) + if (isExcludedName(node.name)) return null; + + return { + ruleId: fixedSizeInAutoLayoutDef.id, + nodeId: node.id, + nodePath: context.path.join(" > "), + message: `"${node.name}" has fixed width inside Auto Layout`, + }; + } + + return null; +}; + +export const fixedSizeInAutoLayout = defineRule({ + definition: fixedSizeInAutoLayoutDef, + check: fixedSizeInAutoLayoutCheck, +}); + +// ============================================ +// missing-size-constraint (merged: missing-min-width + missing-max-width) +// ============================================ + +const missingSizeConstraintDef: RuleDefinition = { + id: "missing-size-constraint", + name: "Missing Size Constraint", + category: "structure", + why: "Without min/max-width, AI has no bounds — generated code may collapse or stretch indefinitely", + impact: "Content becomes unreadable or invisible at extreme screen sizes", + fix: "Set min-width and/or max-width so AI can generate proper size constraints", +}; + +const missingSizeConstraintCheck: RuleCheckFn = (node, context) => { + // Only check containers and text-containing nodes + if (!isContainerNode(node) && !hasTextContent(node)) return null; + // Skip if not in Auto Layout context + if (!context.parent || !hasAutoLayout(context.parent)) return null; + // Only flag FILL containers — FIXED/HUG don't need min/max-width + if (node.layoutSizingHorizontal !== "FILL") return null; + + const missingMin = node.minWidth === undefined; + const missingMax = node.maxWidth === undefined; + + // Skip small fixed elements (icons, dividers) for min-width check + let skipMinCheck = false; + if (node.absoluteBoundingBox) { + const { width, height } = node.absoluteBoundingBox; + if (width <= 48 && height <= 24) skipMinCheck = true; + } + + // Skip small elements for max-width check + let skipMaxCheck = false; + if (node.absoluteBoundingBox) { + const { width } = node.absoluteBoundingBox; + if (width <= 200) skipMaxCheck = true; + } + + const effectiveMissingMin = missingMin && !skipMinCheck; + const effectiveMissingMax = missingMax && !skipMaxCheck; + + if (effectiveMissingMin && effectiveMissingMax) { + return { + ruleId: missingSizeConstraintDef.id, + nodeId: node.id, + nodePath: context.path.join(" > "), + message: `"${node.name}" uses FILL width without min or max width constraints`, + }; + } + + if (effectiveMissingMin) { + return { + ruleId: missingSizeConstraintDef.id, + nodeId: node.id, + nodePath: context.path.join(" > "), + message: `"${node.name}" uses FILL width without a min-width constraint. It may collapse on narrow screens.`, + }; + } + + if (effectiveMissingMax) { + return { + ruleId: missingSizeConstraintDef.id, + nodeId: node.id, + nodePath: context.path.join(" > "), + message: `"${node.name}" uses FILL width without a max-width constraint. Content may stretch too wide on large screens.`, + }; + } + + return null; +}; + +export const missingSizeConstraint = defineRule({ + definition: missingSizeConstraintDef, + check: missingSizeConstraintCheck, +}); + +// ============================================ +// missing-responsive-behavior +// ============================================ + +const missingResponsiveBehaviorDef: RuleDefinition = { + id: "missing-responsive-behavior", + name: "Missing Responsive Behavior", + category: "structure", + 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) => { + if (!isContainerNode(node)) return null; + // Skip if inside Auto Layout (Auto Layout handles responsiveness) + if (context.parent && hasAutoLayout(context.parent)) return null; + // Skip root-level frames (they define the viewport) + if (context.depth < 2) return null; + + // Check for missing layout mode and no parent auto layout + if (!hasAutoLayout(node) && !node.layoutAlign) { + return { + ruleId: missingResponsiveBehaviorDef.id, + nodeId: node.id, + nodePath: context.path.join(" > "), + message: `"${node.name}" has no responsive behavior configured`, + }; + } + + return null; +}; + +export const missingResponsiveBehavior = defineRule({ + definition: missingResponsiveBehaviorDef, + check: missingResponsiveBehaviorCheck, +}); + +// ============================================ +// group-usage +// ============================================ + +const groupUsageDef: RuleDefinition = { + id: "group-usage", + name: "Group Usage", + category: "structure", + 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) => { + if (node.type !== "GROUP") return null; + + return { + ruleId: groupUsageDef.id, + nodeId: node.id, + nodePath: context.path.join(" > "), + message: `"${node.name}" is a Group - consider converting to Frame with Auto Layout`, + }; +}; + +export const groupUsage = defineRule({ + definition: groupUsageDef, + check: groupUsageCheck, +}); + +// ============================================ +// deep-nesting +// ============================================ + +const deepNestingDef: RuleDefinition = { + id: "deep-nesting", + name: "Deep Nesting", + category: "structure", + 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", +}; + +const deepNestingCheck: RuleCheckFn = (node, context, options) => { + const maxDepth = (options?.["maxDepth"] as number) ?? getRuleOption("deep-nesting", "maxDepth", 5); + + if (context.componentDepth < maxDepth) return null; + if (!isContainerNode(node)) return null; + + return { + ruleId: deepNestingDef.id, + nodeId: node.id, + nodePath: context.path.join(" > "), + message: `"${node.name}" is nested ${context.componentDepth} levels deep within its component (max: ${maxDepth})`, + }; +}; + +export const deepNesting = defineRule({ + definition: deepNestingDef, + check: deepNestingCheck, +}); + +// ============================================ +// z-index-dependent-layout +// ============================================ + +const zIndexDependentLayoutDef: RuleDefinition = { + id: "z-index-dependent-layout", + name: "Z-Index Dependent Layout", + category: "structure", + why: "Using overlapping layers to create visual layout is hard to interpret", + impact: "Code generation may misinterpret the intended layout", + fix: "Restructure using Auto Layout to express the visual relationship explicitly", +}; + +const zIndexDependentLayoutCheck: RuleCheckFn = (node, context) => { + if (!isContainerNode(node)) return null; + if (!node.children || node.children.length < 2) return null; + + let significantOverlapCount = 0; + + for (let i = 0; i < node.children.length; i++) { + for (let j = i + 1; j < node.children.length; j++) { + const childA = node.children[i]; + const childB = node.children[j]; + + if (!childA || !childB) continue; + if (childA.visible === false || childB.visible === false) continue; + + const boxA = childA.absoluteBoundingBox; + const boxB = childB.absoluteBoundingBox; + + if (!boxA || !boxB) continue; + + if (hasOverlappingBounds(childA, childB)) { + const overlapX = Math.min(boxA.x + boxA.width, boxB.x + boxB.width) - + Math.max(boxA.x, boxB.x); + const overlapY = Math.min(boxA.y + boxA.height, boxB.y + boxB.height) - + Math.max(boxA.y, boxB.y); + + if (overlapX > 0 && overlapY > 0) { + const overlapArea = overlapX * overlapY; + const smallerArea = Math.min( + boxA.width * boxA.height, + boxB.width * boxB.height + ); + + if (overlapArea > smallerArea * 0.2) { + significantOverlapCount++; + } + } + } + } + } + + if (significantOverlapCount > 0) { + return { + ruleId: zIndexDependentLayoutDef.id, + nodeId: node.id, + nodePath: context.path.join(" > "), + message: `"${node.name}" uses layer stacking for layout (${significantOverlapCount} overlaps)`, + }; + } + + return null; +}; + +export const zIndexDependentLayout = defineRule({ + definition: zIndexDependentLayoutDef, + check: zIndexDependentLayoutCheck, +}); + +// ============================================ +// unnecessary-node (merged: invisible-layer + empty-frame) +// ============================================ + +const unnecessaryNodeDef: RuleDefinition = { + id: "unnecessary-node", + name: "Unnecessary Node", + category: "structure", + why: "Hidden layers and empty frames add noise to the design tree without contributing to the visual output", + impact: "Increases API response size and may generate unnecessary wrapper elements in code", + fix: "Remove unused hidden layers or empty frames. If hidden layers represent states, consider using Figma Slots.", +}; + +const unnecessaryNodeCheck: RuleCheckFn = (node, context) => { + // Check 1: Invisible layer (from invisible-layer rule) + if (node.visible === false) { + // Skip if parent is also invisible (only report top-level invisible) + if (context.parent?.visible === false) return null; + + // Check if parent has many hidden children — suggest Slot + const slotThreshold = + getRuleOption("unnecessary-node", "slotRecommendationThreshold", 3); + const hiddenSiblingCount = context.siblings + ? context.siblings.filter((s) => s.visible === false).length + : 0; + + if (hiddenSiblingCount >= slotThreshold) { + return { + ruleId: unnecessaryNodeDef.id, + nodeId: node.id, + nodePath: context.path.join(" > "), + message: `"${node.name}" is hidden (${hiddenSiblingCount} hidden siblings) — if these represent states, consider using Figma Slots instead`, + }; + } + + return { + ruleId: unnecessaryNodeDef.id, + nodeId: node.id, + nodePath: context.path.join(" > "), + message: `"${node.name}" is hidden — no impact on code generation, clean up if unused`, + }; + } + + // Check 2: Empty frame (from empty-frame rule) + if (node.type === "FRAME") { + if (node.children && node.children.length > 0) return null; + + // Allow empty frames that are clearly placeholders (small size) + if (node.absoluteBoundingBox) { + const { width, height } = node.absoluteBoundingBox; + if (width <= 48 && height <= 48) return null; + } + + return { + ruleId: unnecessaryNodeDef.id, + nodeId: node.id, + nodePath: context.path.join(" > "), + message: `"${node.name}" is an empty frame`, + }; + } + + return null; +}; + +export const unnecessaryNode = defineRule({ + definition: unnecessaryNodeDef, + check: unnecessaryNodeCheck, +}); diff --git a/src/core/rules/structure/no-auto-layout.test.ts b/src/core/rules/structure/no-auto-layout.test.ts new file mode 100644 index 00000000..e81747b2 --- /dev/null +++ b/src/core/rules/structure/no-auto-layout.test.ts @@ -0,0 +1,140 @@ +import { makeNode, makeFile, makeContext } from "../test-helpers.js"; +import { noAutoLayout } from "./index.js"; + +describe("no-auto-layout", () => { + it("has correct rule definition metadata", () => { + const def = noAutoLayout.definition; + expect(def.id).toBe("no-auto-layout"); + expect(def.category).toBe("structure"); + expect(def.why).toContain("Auto Layout"); + expect(def.fix).toContain("Auto Layout"); + }); + + it("returns null for non-FRAME nodes without overlapping/nested issues", () => { + const textNode = makeNode({ type: "TEXT" }); + const ctx = makeContext(); + expect(noAutoLayout.check(textNode, ctx)).toBeNull(); + }); + + it("returns null for frame with auto layout", () => { + const node = makeNode({ + layoutMode: "HORIZONTAL", + children: [makeNode({ id: "c:1", name: "Child" })], + }); + const ctx = makeContext(); + expect(noAutoLayout.check(node, ctx)).toBeNull(); + }); + + it("returns null for empty frame (no children)", () => { + const node = makeNode({ children: [] }); + const ctx = makeContext(); + expect(noAutoLayout.check(node, ctx)).toBeNull(); + }); + + it("returns null for frame without children property", () => { + const node = makeNode({}); + const ctx = makeContext(); + expect(noAutoLayout.check(node, ctx)).toBeNull(); + }); + + it("flags frame without auto layout that has children", () => { + const child = makeNode({ id: "c:1", name: "Child" }); + const node = makeNode({ name: "Container", children: [child] }); + const ctx = makeContext(); + + const result = noAutoLayout.check(node, ctx); + expect(result).not.toBeNull(); + expect(result!.ruleId).toBe("no-auto-layout"); + expect(result!.message).toContain("Container"); + expect(result!.message).toContain("no Auto Layout"); + }); + + it("flags frame with layoutMode NONE that has children", () => { + const child = makeNode({ id: "c:1", name: "Child" }); + const node = makeNode({ + name: "NoneLayout", + layoutMode: "NONE", + children: [child], + }); + const ctx = makeContext(); + + const result = noAutoLayout.check(node, ctx); + expect(result).not.toBeNull(); + expect(result!.ruleId).toBe("no-auto-layout"); + expect(result!.message).toContain("NoneLayout"); + }); + + // Merged: ambiguous-structure checks + it("flags container with overlapping visible children (ambiguous-structure)", () => { + const child1 = makeNode({ + id: "c:1", + absoluteBoundingBox: { x: 0, y: 0, width: 100, height: 100 }, + }); + const child2 = makeNode({ + id: "c:2", + absoluteBoundingBox: { x: 50, y: 50, width: 100, height: 100 }, + }); + const node = makeNode({ + name: "Ambiguous", + children: [child1, child2], + }); + const ctx = makeContext(); + + const result = noAutoLayout.check(node, ctx); + expect(result).not.toBeNull(); + expect(result!.ruleId).toBe("no-auto-layout"); + expect(result!.message).toContain("Ambiguous"); + expect(result!.message).toContain("overlapping"); + }); + + it("flags basic no-auto-layout when overlapping children are hidden", () => { + const child1 = makeNode({ + id: "c:1", + visible: false, + absoluteBoundingBox: { x: 0, y: 0, width: 100, height: 100 }, + }); + const child2 = makeNode({ + id: "c:2", + absoluteBoundingBox: { x: 50, y: 50, width: 100, height: 100 }, + }); + const node = makeNode({ + name: "Container", + children: [child1, child2], + }); + const ctx = makeContext(); + // Should not flag overlapping since one is hidden; falls through to basic check + const result = noAutoLayout.check(node, ctx); + // Still flags as basic no-auto-layout since it's a FRAME with children + expect(result).not.toBeNull(); + expect(result!.message).not.toContain("overlapping"); + }); + + // Merged: missing-layout-hint checks + it("flags container with 2+ nested containers without auto layout (missing-layout-hint)", () => { + const childA = makeNode({ id: "c:1", type: "FRAME", name: "Panel A" }); + const childB = makeNode({ id: "c:2", type: "FRAME", name: "Panel B" }); + const node = makeNode({ + type: "FRAME", + name: "Wrapper", + children: [childA, childB], + }); + + const result = noAutoLayout.check(node, makeContext()); + expect(result).not.toBeNull(); + expect(result!.ruleId).toBe("no-auto-layout"); + expect(result!.message).toContain("nested containers"); + }); + + it("returns null when nested containers have auto layout", () => { + const childA = makeNode({ id: "c:1", type: "FRAME", layoutMode: "HORIZONTAL" }); + const childB = makeNode({ id: "c:2", type: "FRAME", layoutMode: "VERTICAL" }); + const node = makeNode({ + type: "FRAME", + layoutMode: "VERTICAL", + name: "Wrapper", + children: [childA, childB], + }); + + expect(noAutoLayout.check(node, makeContext())).toBeNull(); + }); +}); diff --git a/src/core/rules/layout/responsive-fields.test.ts b/src/core/rules/structure/responsive-fields.test.ts similarity index 83% rename from src/core/rules/layout/responsive-fields.test.ts rename to src/core/rules/structure/responsive-fields.test.ts index cb9b76c4..0cda5296 100644 --- a/src/core/rules/layout/responsive-fields.test.ts +++ b/src/core/rules/structure/responsive-fields.test.ts @@ -101,8 +101,8 @@ describe("fixed-size-in-auto-layout", () => { }); }); -describe("missing-min-width", () => { - it("flags FILL container without minWidth in auto-layout", () => { +describe("missing-size-constraint", () => { + it("flags FILL container without any size constraints", () => { const file = makeFile( makeNode({ name: "Root", @@ -120,13 +120,13 @@ describe("missing-min-width", () => { ); const result = analyzeFile(file); const issues = result.issues.filter( - (i) => i.rule.definition.id === "missing-min-width", + (i) => i.rule.definition.id === "missing-size-constraint", ); expect(issues.length).toBeGreaterThanOrEqual(1); - expect(issues.at(0)?.violation.message).toContain("Content"); + expect(issues.at(0)?.violation.message).toContain("min or max"); }); - it("does not flag FILL container that has minWidth set", () => { + it("flags missing maxWidth when minWidth is set and container is wide", () => { const file = makeFile( makeNode({ name: "Root", @@ -145,9 +145,10 @@ describe("missing-min-width", () => { ); const result = analyzeFile(file); const issues = result.issues.filter( - (i) => i.rule.definition.id === "missing-min-width", + (i) => i.rule.definition.id === "missing-size-constraint", ); - expect(issues).toHaveLength(0); + expect(issues.length).toBeGreaterThanOrEqual(1); + expect(issues.at(0)?.violation.message).toContain("max-width"); }); it("does not flag FIXED container", () => { @@ -168,14 +169,12 @@ describe("missing-min-width", () => { ); const result = analyzeFile(file); const issues = result.issues.filter( - (i) => i.rule.definition.id === "missing-min-width", + (i) => i.rule.definition.id === "missing-size-constraint", ); expect(issues).toHaveLength(0); }); -}); -describe("missing-max-width", () => { - it("flags FILL container without maxWidth", () => { + it("flags missing maxWidth when only minWidth is set on wide container", () => { const file = makeFile( makeNode({ name: "Root", @@ -186,6 +185,7 @@ describe("missing-max-width", () => { name: "TextBlock", type: "FRAME", layoutSizingHorizontal: "FILL", + minWidth: 100, absoluteBoundingBox: { x: 0, y: 0, width: 600, height: 100 }, children: [ makeNode({ name: "Label", type: "TEXT", characters: "Hello" }), @@ -196,13 +196,13 @@ describe("missing-max-width", () => { ); const result = analyzeFile(file); const issues = result.issues.filter( - (i) => i.rule.definition.id === "missing-max-width", + (i) => i.rule.definition.id === "missing-size-constraint", ); expect(issues.length).toBeGreaterThanOrEqual(1); - expect(issues.at(0)?.violation.message).toContain("TextBlock"); + expect(issues.at(0)?.violation.message).toContain("max-width"); }); - it("does not flag FILL container that has maxWidth set", () => { + it("flags missing minWidth when only maxWidth is set", () => { const file = makeFile( makeNode({ name: "Root", @@ -210,23 +210,21 @@ describe("missing-max-width", () => { layoutMode: "HORIZONTAL", children: [ makeNode({ - name: "TextBlock", + name: "Content", type: "FRAME", layoutSizingHorizontal: "FILL", maxWidth: 800, - absoluteBoundingBox: { x: 0, y: 0, width: 600, height: 100 }, - children: [ - makeNode({ name: "Label", type: "TEXT", characters: "Hello" }), - ], + absoluteBoundingBox: { x: 0, y: 0, width: 300, height: 100 }, }), ], }), ); const result = analyzeFile(file); const issues = result.issues.filter( - (i) => i.rule.definition.id === "missing-max-width", + (i) => i.rule.definition.id === "missing-size-constraint", ); - expect(issues).toHaveLength(0); + expect(issues.length).toBeGreaterThanOrEqual(1); + expect(issues.at(0)?.violation.message).toContain("min-width"); }); it("does not flag FILL container outside auto-layout parent", () => { @@ -249,7 +247,7 @@ describe("missing-max-width", () => { ); const result = analyzeFile(file); const issues = result.issues.filter( - (i) => i.rule.definition.id === "missing-max-width", + (i) => i.rule.definition.id === "missing-size-constraint", ); expect(issues).toHaveLength(0); }); diff --git a/src/core/rules/structure/unnecessary-node.test.ts b/src/core/rules/structure/unnecessary-node.test.ts new file mode 100644 index 00000000..d872226f --- /dev/null +++ b/src/core/rules/structure/unnecessary-node.test.ts @@ -0,0 +1,110 @@ +import { makeNode, makeFile, makeContext } from "../test-helpers.js"; +import { unnecessaryNode } from "./index.js"; + +describe("unnecessary-node", () => { + it("has correct rule definition metadata", () => { + const def = unnecessaryNode.definition; + expect(def.id).toBe("unnecessary-node"); + expect(def.category).toBe("structure"); + }); + + // Invisible layer checks + it("returns null for visible non-empty nodes", () => { + const node = makeNode({ visible: true, type: "TEXT" }); + const ctx = makeContext(); + expect(unnecessaryNode.check(node, ctx)).toBeNull(); + }); + + it("flags hidden node with basic message", () => { + const node = makeNode({ visible: false, name: "OldVersion" }); + const ctx = makeContext({ siblings: [node] }); + + const result = unnecessaryNode.check(node, ctx); + expect(result).not.toBeNull(); + expect(result!.message).toContain("OldVersion"); + expect(result!.message).toContain("hidden"); + expect(result!.message).toContain("clean up if unused"); + }); + + it("skips when parent is also invisible", () => { + const node = makeNode({ visible: false }); + const parent = makeNode({ visible: false, name: "HiddenParent" }); + const ctx = makeContext({ parent }); + + expect(unnecessaryNode.check(node, ctx)).toBeNull(); + }); + + it("suggests Slot when 3+ hidden siblings", () => { + const hidden1 = makeNode({ id: "h:1", visible: false, name: "StateA" }); + const hidden2 = makeNode({ id: "h:2", visible: false, name: "StateB" }); + const hidden3 = makeNode({ id: "h:3", visible: false, name: "StateC" }); + const visible1 = makeNode({ id: "v:1", visible: true, name: "Active" }); + + const siblings = [hidden1, hidden2, hidden3, visible1]; + const ctx = makeContext({ siblings }); + + const result = unnecessaryNode.check(hidden1, ctx); + expect(result).not.toBeNull(); + expect(result!.message).toContain("3 hidden siblings"); + expect(result!.message).toContain("Slot"); + }); + + it("does not suggest Slot when fewer than 3 hidden siblings", () => { + const hidden1 = makeNode({ id: "h:1", visible: false, name: "StateA" }); + const hidden2 = makeNode({ id: "h:2", visible: false, name: "StateB" }); + const visible1 = makeNode({ id: "v:1", visible: true, name: "Active" }); + + const siblings = [hidden1, hidden2, visible1]; + const ctx = makeContext({ siblings }); + + const result = unnecessaryNode.check(hidden1, ctx); + expect(result).not.toBeNull(); + expect(result!.message).not.toContain("Slot"); + expect(result!.message).toContain("clean up if unused"); + }); + + // Empty frame checks + it("flags empty frame with no children", () => { + const node = makeNode({ + type: "FRAME", + name: "EmptySection", + absoluteBoundingBox: { x: 0, y: 0, width: 300, height: 200 }, + }); + const result = unnecessaryNode.check(node, makeContext()); + expect(result).not.toBeNull(); + expect(result!.ruleId).toBe("unnecessary-node"); + expect(result!.message).toContain("empty frame"); + }); + + it("returns null for frame with children", () => { + const node = makeNode({ + type: "FRAME", + children: [makeNode({ id: "c:1" })], + }); + expect(unnecessaryNode.check(node, makeContext())).toBeNull(); + }); + + it("allows small placeholder frames (<=48x48)", () => { + const node = makeNode({ + type: "FRAME", + name: "Spacer", + absoluteBoundingBox: { x: 0, y: 0, width: 24, height: 24 }, + }); + expect(unnecessaryNode.check(node, makeContext())).toBeNull(); + }); + + it("allows frames at exact 48x48 boundary", () => { + const node = makeNode({ + type: "FRAME", + name: "Icon Placeholder", + absoluteBoundingBox: { x: 0, y: 0, width: 48, height: 48 }, + }); + expect(unnecessaryNode.check(node, makeContext())).toBeNull(); + }); + + it("flags empty frame without bounding box", () => { + const node = makeNode({ type: "FRAME", name: "NoBox" }); + const result = unnecessaryNode.check(node, makeContext()); + expect(result).not.toBeNull(); + }); +}); diff --git a/src/core/rules/ai-readability/z-index-dependent-layout.test.ts b/src/core/rules/structure/z-index-dependent-layout.test.ts similarity index 69% rename from src/core/rules/ai-readability/z-index-dependent-layout.test.ts rename to src/core/rules/structure/z-index-dependent-layout.test.ts index ac035a4a..d496e759 100644 --- a/src/core/rules/ai-readability/z-index-dependent-layout.test.ts +++ b/src/core/rules/structure/z-index-dependent-layout.test.ts @@ -4,7 +4,7 @@ import { zIndexDependentLayout } from "./index.js"; describe("z-index-dependent-layout", () => { it("has correct rule definition metadata", () => { expect(zIndexDependentLayout.definition.id).toBe("z-index-dependent-layout"); - expect(zIndexDependentLayout.definition.category).toBe("ai-readability"); + expect(zIndexDependentLayout.definition.category).toBe("structure"); }); it("flags container with significant overlap (>20% of smaller element)", () => { @@ -80,37 +80,4 @@ describe("z-index-dependent-layout", () => { expect(zIndexDependentLayout.check(node, makeContext())).toBeNull(); }); - - it("returns null when overlap is exactly 20% (boundary: > not >=)", () => { - // overlapX=2, overlapY=5 → area=10, smallerArea=50 → 10/50=0.2 exactly - const childA = makeNode({ - id: "c:1", - name: "A", - absoluteBoundingBox: { x: 0, y: 0, width: 10, height: 10 }, - }); - const childB = makeNode({ - id: "c:2", - name: "B", - absoluteBoundingBox: { x: 8, y: 0, width: 10, height: 5 }, - }); - const node = makeNode({ - type: "FRAME", - name: "Boundary", - children: [childA, childB], - }); - - expect(zIndexDependentLayout.check(node, makeContext())).toBeNull(); - }); - - it("returns null when children lack absoluteBoundingBox", () => { - const childA = makeNode({ id: "c:1", name: "NoBbox" }); - const childB = makeNode({ id: "c:2", name: "AlsoNoBbox" }); - const node = makeNode({ - type: "FRAME", - name: "NoBounds", - children: [childA, childB], - }); - - expect(zIndexDependentLayout.check(node, makeContext())).toBeNull(); - }); }); diff --git a/src/core/ui-constants.ts b/src/core/ui-constants.ts index 7e415141..f73bb812 100644 --- a/src/core/ui-constants.ts +++ b/src/core/ui-constants.ts @@ -12,15 +12,14 @@ export const GAUGE_R = 54; export const GAUGE_C = Math.round(2 * Math.PI * GAUGE_R); // ~339 export const CATEGORY_DESCRIPTIONS: Record = { - layout: - "Auto Layout, responsive constraints, nesting depth, absolute positioning", + structure: + "Auto Layout, responsive constraints, nesting depth, positioning, structure clarity", token: "Design token binding for colors, fonts, shadows, spacing grid", - component: "Component reuse, detached instances, variant coverage", + component: + "Component reuse, detached instances, variant coverage and structure", naming: "Semantic layer names, naming conventions, default names", - "ai-readability": - "Structure clarity for AI code generation, z-index, empty frames", - "handoff-risk": - "Hardcoded values, text truncation, image placeholders, dev status", + behavior: + "Overflow handling, text truncation, wrap behavior, dynamic interactions", }; export const SEVERITY_ORDER: Severity[] = [