diff --git a/.claude/agents/rule-discovery/designer.md b/.claude/agents/rule-discovery/designer.md index bc796db4..dcfd6d82 100644 --- a/.claude/agents/rule-discovery/designer.md +++ b/.claude/agents/rule-discovery/designer.md @@ -20,8 +20,8 @@ You will receive: - Read `src/core/rules/` to understand rule structure - Read `src/core/rules/rule-config.ts` for score/severity conventions 3. Design the rule: - - **Rule ID**: kebab-case, descriptive (e.g., `missing-component-description`) - - **Category**: existing (`structure | token | component | naming | behavior`) or propose a new category if none fits. New categories require justification. + - **Rule ID**: kebab-case, descriptive (e.g., `raw-value`) + - **Category**: existing (`pixel-critical | responsive-critical | code-quality | token-management | minor`) or propose a new category if none fits. New categories require justification. - **Severity**: `blocking | risk | missing-info | suggestion` - **Initial score**: based on estimated impact on implementation difficulty - **Check logic**: what condition triggers the violation diff --git a/.github/ISSUE_TEMPLATE/bug.yml b/.github/ISSUE_TEMPLATE/bug.yml index 44282332..a88007ee 100644 --- a/.github/ISSUE_TEMPLATE/bug.yml +++ b/.github/ISSUE_TEMPLATE/bug.yml @@ -7,7 +7,7 @@ body: attributes: label: Symptom description: What's happening? Include error messages, wrong output, or unexpected behavior. - placeholder: "unnecessary-node scores -10 (blocking) but hidden layers don't block implementation" + placeholder: "raw-value scores -3 (missing-info) but tokenized fills are still flagged" validations: required: true - type: textarea diff --git a/docs/REFERENCE.md b/docs/REFERENCE.md index 6032a60a..4b130411 100644 --- a/docs/REFERENCE.md +++ b/docs/REFERENCE.md @@ -22,10 +22,9 @@ canicode analyze --config ./my-config.json "excludeNodeTypes": ["VECTOR", "BOOLEAN_OPERATION", "SLICE"], "excludeNodeNames": ["chatbot", "ad-banner", "custom-widget"], "gridBase": 4, - "colorTolerance": 5, "rules": { "no-auto-layout": { "score": -15, "severity": "blocking", "enabled": true }, - "raw-color": { "score": -12 }, + "raw-value": { "score": -5 }, "default-name": { "enabled": false } } } @@ -37,8 +36,7 @@ canicode analyze --config ./my-config.json |-------|------|---------|-------------| | `excludeNodeTypes` | `string[]` | `[]` | Figma node types to skip entirely (node + children) | | `excludeNodeNames` | `string[]` | `[]` | Name patterns to skip (case-insensitive word match) | -| `gridBase` | `number` | `4` | Spacing grid unit for `inconsistent-spacing` and `magic-number-spacing` | -| `colorTolerance` | `number` | `10` | Color difference tolerance for `multiple-fill-colors` | +| `gridBase` | `number` | `4` | Spacing grid unit for `irregular-spacing` | | `rules` | `object` | — | Per-rule overrides (see below) | ### Node Exclusions @@ -93,59 +91,44 @@ Override score, severity, or enable/disable individual rules: ### All Rule IDs -**Structure (9 rules)** +**Pixel Critical (3 rules)** — layout issues that directly affect pixel accuracy (ΔV ≥ 5%) | Rule ID | Default Score | Default Severity | |---------|--------------|-----------------| | `no-auto-layout` | -10 | blocking | | `absolute-position-in-auto-layout` | -7 | blocking | -| `fixed-size-in-auto-layout` | -3 | risk | -| `missing-size-constraint` | -3 | risk | -| `missing-responsive-behavior` | -3 | risk | -| `group-usage` | -5 | risk | -| `deep-nesting` | -4 | risk | -| `z-index-dependent-layout` | -5 | risk | -| `unnecessary-node` | -2 | suggestion | +| `non-layout-container` | -8 | blocking | -**Token (7 rules)** +**Responsive Critical (2 rules)** — size issues that break at different viewports (ΔV ≥ 15%) | Rule ID | Default Score | Default Severity | |---------|--------------|-----------------| -| `raw-color` | -2 | missing-info | -| `raw-font` | -4 | risk | -| `inconsistent-spacing` | -2 | missing-info | -| `magic-number-spacing` | -3 | risk | -| `raw-shadow` | -3 | missing-info | -| `raw-opacity` | -2 | missing-info | -| `multiple-fill-colors` | -3 | missing-info | +| `fixed-size-in-auto-layout` | -6 | risk | +| `missing-size-constraint` | -5 | risk | -**Component (4 rules)** +**Code Quality (4 rules)** — structural issues affecting code reuse (ΔV ≈ 0%, CSS classes -8~15) | Rule ID | Default Score | Default Severity | |---------|--------------|-----------------| | `missing-component` | -7 | risk | -| `detached-instance` | -5 | risk | -| `missing-component-description` | -2 | missing-info | +| `detached-instance` | -4 | risk | | `variant-structure-mismatch` | -4 | risk | +| `deep-nesting` | -3 | risk | -**Naming (5 rules)** +**Token Management (2 rules)** — raw values without design tokens | Rule ID | Default Score | Default Severity | |---------|--------------|-----------------| -| `default-name` | -2 | missing-info | -| `non-semantic-name` | -2 | missing-info | -| `inconsistent-naming-convention` | -2 | missing-info | -| `numeric-suffix-name` | -1 | suggestion | -| `too-long-name` | -1 | suggestion | +| `raw-value` | -3 | missing-info | +| `irregular-spacing` | -2 | missing-info | -**Behavior (4 rules)** +**Minor (3 rules)** — naming issues with negligible impact (ΔV < 2%) | Rule ID | Default Score | Default Severity | |---------|--------------|-----------------| -| `text-truncation-unhandled` | -5 | risk | -| `prototype-link-in-design` | -2 | missing-info | -| `overflow-behavior-unknown` | -3 | missing-info | -| `wrap-behavior-unknown` | -3 | missing-info | +| `default-name` | -1 | suggestion | +| `non-semantic-name` | -1 | suggestion | +| `inconsistent-naming-convention` | -1 | suggestion | ### Example Configs @@ -155,8 +138,8 @@ Override score, severity, or enable/disable individual rules: { "rules": { "no-auto-layout": { "score": -15 }, - "raw-color": { "score": -10, "severity": "blocking" }, - "default-name": { "score": -8, "severity": "blocking" } + "raw-value": { "score": -5, "severity": "risk" }, + "default-name": { "score": -3, "severity": "risk" } } } ``` @@ -178,7 +161,6 @@ Override score, severity, or enable/disable individual rules: ```json { "rules": { - "missing-responsive-behavior": { "score": -10, "severity": "blocking" }, "fixed-size-in-auto-layout": { "score": -8, "severity": "blocking" }, "no-auto-layout": { "score": -12 } } diff --git a/examples/config.json b/examples/config.json index 946653e2..9acc6b2b 100644 --- a/examples/config.json +++ b/examples/config.json @@ -2,22 +2,17 @@ "excludeNodeTypes": ["SLICE", "STICKY"], "excludeNodeNames": ["chatbot", "ad-banner", "prototype", "wip"], "gridBase": 4, - "colorTolerance": 5, "rules": { "no-auto-layout": { "score": -15, "severity": "blocking" }, - "raw-color": { - "score": -12, - "severity": "blocking" + "raw-value": { + "score": -3, + "severity": "missing-info" }, "default-name": { "enabled": false - }, - "missing-responsive-behavior": { - "score": -8, - "severity": "blocking" } } } diff --git a/src/agents/analysis-agent.test.ts b/src/agents/analysis-agent.test.ts index b3f80f56..ac5506ba 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: "structure", + category: "pixel-critical", why: "test reason", impact: "test impact", fix: "test fix", diff --git a/src/agents/evidence-collector.test.ts b/src/agents/evidence-collector.test.ts index ee0347a5..25258793 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: "structure", impact: "hard", fixture: "fx1", timestamp: "t1", source: "evaluation" }, + { description: "gap1", category: "pixel-critical", 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("structure"); + expect(result[0]!.category).toBe("pixel-critical"); }); it("loads entries from legacy plain-array format (v0 fallback)", () => { const entries: DiscoveryEvidenceEntry[] = [ - { description: "gap1", category: "structure", impact: "hard", fixture: "fx1", timestamp: "t1", source: "evaluation" }, + { description: "gap1", category: "pixel-critical", 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("structure"); + expect(result[0]!.category).toBe("pixel-critical"); }); 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: "structure", impact: "hard", fixture: "fx1", timestamp: "t1", source: "evaluation" }, + { description: "gap1", category: "pixel-critical", 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: "structure", impact: "hard", fixture: "fx1", timestamp: "t1", source: "evaluation" }, + { description: "good", category: "pixel-critical", 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: "structure", impact: "hard", fixture: "fx1", timestamp: "t1", source: "evaluation" }, + { description: "gap1", category: "pixel-critical", 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: "structure", impact: "hard", fixture: "fx1", timestamp: "t1", source: "gap-analysis" }, + { description: "gap1", category: "pixel-critical", 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: "structure", impact: "hard", fixture: "fx1", timestamp: "t1", source: "evaluation" }, + { description: "gap1", category: "pixel-critical", 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: "structure", impact: "hard", fixture: "fx1", timestamp: "t1", source: "evaluation" }, + { description: "gap1", category: "pixel-critical", impact: "hard", fixture: "fx1", timestamp: "t1", source: "evaluation" }, ], disPath); // Same category+description+fixture, different impact/timestamp → replaces appendDiscoveryEvidence([ - { description: "gap1", category: "structure", impact: "moderate", fixture: "fx1", timestamp: "t2", source: "evaluation" }, + { description: "gap1", category: "pixel-critical", 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: "Structure", impact: "hard", fixture: "fx1", timestamp: "t1", source: "evaluation" }, + { description: "Gap One", category: "Pixel-critical", impact: "hard", fixture: "fx1", timestamp: "t1", source: "evaluation" }, ], disPath); appendDiscoveryEvidence([ - { description: "gap one", category: "structure", impact: "easy", fixture: "fx1", timestamp: "t2", source: "evaluation" }, + { description: "gap one", category: "pixel-critical", 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: "structure", impact: "hard", fixture: "FX1", timestamp: "t1", source: "evaluation" }, + { description: "gap1", category: "pixel-critical", impact: "hard", fixture: "FX1", timestamp: "t1", source: "evaluation" }, ], disPath); appendDiscoveryEvidence([ - { description: "gap1", category: "structure", impact: "easy", fixture: "fx1", timestamp: "t2", source: "evaluation" }, + { description: "gap1", category: "pixel-critical", 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: "structure", impact: "hard", fixture: "fx1", timestamp: "t1", source: "evaluation" }, - { description: "gap1", category: "structure", impact: "easy", fixture: "fx1", timestamp: "t2", source: "evaluation" }, + { description: "gap1", category: "pixel-critical", impact: "hard", fixture: "fx1", timestamp: "t1", source: "evaluation" }, + { description: "gap1", category: "pixel-critical", 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: "structure", impact: "hard", fixture: "fx1", timestamp: "t1", source: "evaluation" }, - { description: "gap1", category: "structure", impact: "hard", fixture: "fx2", timestamp: "t1", source: "evaluation" }, + { description: "gap1", category: "pixel-critical", impact: "hard", fixture: "fx1", timestamp: "t1", source: "evaluation" }, + { description: "gap1", category: "pixel-critical", 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: "structure", impact: "hard", fixture: "fx1", timestamp: "t0", source: "evaluation" }, + { description: "old", category: "pixel-critical", 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: "structure", impact: "hard", fixture: "fx1", timestamp: "t1", source: "evaluation" }, + { description: "gap1", category: "pixel-critical", 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: "structure", impact: "hard", fixture: "fx1", timestamp: "t1", source: "evaluation" }, + { description: "new", category: "pixel-critical", 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: "Structure", impact: "hard", fixture: "fx1", timestamp: "t1", source: "evaluation" }, - { description: "gap2", category: "structure", impact: "hard", fixture: "fx2", timestamp: "t2", source: "gap-analysis" }, + { description: "gap1", category: "Pixel-critical", impact: "hard", fixture: "fx1", timestamp: "t1", source: "evaluation" }, + { description: "gap2", category: "pixel-critical", impact: "hard", fixture: "fx2", timestamp: "t2", source: "gap-analysis" }, { description: "gap3", category: "color", impact: "moderate", fixture: "fx1", timestamp: "t1", source: "evaluation" }, ], disPath); - pruneDiscoveryEvidence(["structure"], disPath); + pruneDiscoveryEvidence(["pixel-critical"], 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: "structure", impact: "hard", fixture: "fx1", timestamp: "t1", source: "evaluation" }, + { description: "gap1", category: "pixel-critical", impact: "hard", fixture: "fx1", timestamp: "t1", source: "evaluation" }, ], disPath); - pruneDiscoveryEvidence(["structure"], disPath); + pruneDiscoveryEvidence(["pixel-critical"], 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: "structure", impact: "hard", fixture: "fx1", timestamp: "t1", source: "evaluation" }, + { description: "gap1", category: "pixel-critical", 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: "structure", impact: "hard", fixture: "fx1", timestamp: "t1", source: "evaluation" }, + { description: "gap1", category: "pixel-critical", impact: "hard", fixture: "fx1", timestamp: "t1", source: "evaluation" }, ], disPath); - pruneDiscoveryEvidence([" structure "], disPath); + pruneDiscoveryEvidence([" pixel-critical "], 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: "structure", impact: "hard", fixture: "fx1", timestamp: "t1", source: "evaluation" }, + { description: "gap1", category: "pixel-critical", impact: "hard", fixture: "fx1", timestamp: "t1", source: "evaluation" }, ]}; writeFileSync(disPath, JSON.stringify(file), "utf-8"); const before = readFileSync(disPath, "utf-8"); - expect(() => pruneDiscoveryEvidence(["structure"], disPath)).toThrow(/Unsupported discovery-evidence schemaVersion/); + expect(() => pruneDiscoveryEvidence(["pixel-critical"], 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 00564f5c..7ed8c3e2 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: "structure", + category: "pixel-critical", area: "Title", description: "Alignment mismatch", coveredByExistingRule: false, @@ -44,7 +44,7 @@ describe("generateGapRuleReport", () => { fileKey: "fx-b", gaps: [ { - category: "structure", + category: "pixel-critical", 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("structure"); + expect(markdown).toContain("pixel-critical"); 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 e8e49fa8..65909291 100644 --- a/src/agents/orchestrator.test.ts +++ b/src/agents/orchestrator.test.ts @@ -78,15 +78,15 @@ describe("runCalibrationEvaluate", () => { nodePath: "Page > Frame > Node2", totalScore: -5, issueCount: 1, - flaggedRuleIds: ["raw-color"], + flaggedRuleIds: ["raw-value"], severities: ["risk"], }, ], scoreReport: { overall: { score: 75, maxScore: 100, percentage: 75, grade: "B" as const }, byCategory: { - structure: { - category: "structure" as const, + "pixel-critical": { + category: "pixel-critical" as const, score: 70, maxScore: 100, percentage: 70, @@ -97,8 +97,8 @@ describe("runCalibrationEvaluate", () => { diversityScore: 80, bySeverity: { blocking: 1, risk: 1, "missing-info": 0, suggestion: 0 }, }, - token: { - category: "token" as const, + "token-management": { + category: "token-management" as const, score: 80, maxScore: 100, percentage: 80, @@ -109,8 +109,8 @@ describe("runCalibrationEvaluate", () => { diversityScore: 85, bySeverity: { blocking: 0, risk: 1, "missing-info": 0, suggestion: 0 }, }, - component: { - category: "component" as const, + "code-quality": { + category: "code-quality" as const, score: 100, maxScore: 100, percentage: 100, @@ -121,8 +121,8 @@ describe("runCalibrationEvaluate", () => { diversityScore: 100, bySeverity: { blocking: 0, risk: 0, "missing-info": 0, suggestion: 0 }, }, - naming: { - category: "naming" as const, + minor: { + category: "minor" as const, score: 100, maxScore: 100, percentage: 100, @@ -133,8 +133,8 @@ describe("runCalibrationEvaluate", () => { diversityScore: 100, bySeverity: { blocking: 0, risk: 0, "missing-info": 0, suggestion: 0 }, }, - behavior: { - category: "behavior" as const, + "responsive-critical": { + category: "responsive-critical" as const, score: 100, maxScore: 100, percentage: 100, @@ -178,7 +178,7 @@ describe("runCalibrationEvaluate", () => { uncoveredStruggles: [ { description: "Complex gradient pattern not covered by rules", - suggestedCategory: "token", + suggestedCategory: "token-management", estimatedImpact: "moderate", }, ], @@ -189,8 +189,8 @@ describe("runCalibrationEvaluate", () => { difficulty: "easy", ruleRelatedStruggles: [ { - ruleId: "raw-color", - description: "Raw color was trivial to handle", + ruleId: "raw-value", + description: "Raw value was trivial to handle", actualImpact: "easy", }, ], @@ -203,7 +203,7 @@ describe("runCalibrationEvaluate", () => { const ruleScores: Record = { "no-auto-layout": { score: -12, severity: "blocking" }, "deep-nesting": { score: -4, severity: "risk" }, - "raw-color": { score: -5, severity: "risk" }, + "raw-value": { score: -5, severity: "risk" }, }; const result = runCalibrationEvaluate(analysisJson, conversionJson, ruleScores); diff --git a/src/agents/report-generator.test.ts b/src/agents/report-generator.test.ts index 28dff554..fe958876 100644 --- a/src/agents/report-generator.test.ts +++ b/src/agents/report-generator.test.ts @@ -1,5 +1,5 @@ import type { ScoreReport, CategoryScoreResult } from "../core/engine/scoring.js"; -import type { Category } from "../core/contracts/category.js"; +import { CATEGORIES, type Category } from "../core/contracts/category.js"; import type { MismatchCase } from "./contracts/evaluation-agent.js"; import type { ScoreAdjustment, NewRuleProposal } from "./contracts/tuning-agent.js"; import { @@ -7,13 +7,7 @@ import { type CalibrationReportData, } from "./report-generator.js"; -const ALL_CATEGORIES: Category[] = [ - "structure", - "token", - "component", - "naming", - "behavior", -]; +const ALL_CATEGORIES: Category[] = [...CATEGORIES]; function buildCategoryScore( category: Category, @@ -150,7 +144,7 @@ describe("generateCalibrationReport", () => { it("renders proposedDisable indicator in adjustment table and application guide", () => { const adjustment: ScoreAdjustment = { - ruleId: "raw-color", + ruleId: "raw-value", currentScore: -3, proposedScore: -1, currentSeverity: "risk", @@ -166,7 +160,7 @@ describe("generateCalibrationReport", () => { // Table should show disable indicator expect(report).toContain("⛔ YES"); // Application guide should show DISABLE with enabled: false hint - expect(report).toContain("// raw-color: DISABLE (high confidence, 3 cases)"); + expect(report).toContain("// raw-value: DISABLE (high confidence, 3 cases)"); expect(report).toContain("// enabled: false"); }); @@ -190,7 +184,7 @@ describe("generateCalibrationReport", () => { it("omits severity line when proposedDisable is true even with proposedSeverity", () => { const adjustment: ScoreAdjustment = { - ruleId: "raw-color", + ruleId: "raw-value", currentScore: -3, proposedScore: -1, currentSeverity: "risk", @@ -206,7 +200,7 @@ describe("generateCalibrationReport", () => { // Should show disable indicator expect(report).toContain("⛔ YES"); - expect(report).toContain("// raw-color: DISABLE (high confidence, 3 cases)"); + expect(report).toContain("// raw-value: DISABLE (high confidence, 3 cases)"); // Should NOT show severity change line in application guide expect(report).not.toContain('severity: "risk" -> "suggestion"'); }); @@ -221,7 +215,7 @@ describe("generateCalibrationReport", () => { it("renders new rule proposals when they exist", () => { const proposal: NewRuleProposal = { suggestedId: "shadow-complexity", - category: "structure", + category: "pixel-critical", description: "Detects complex shadow configurations", suggestedSeverity: "risk", suggestedScore: -4, @@ -233,7 +227,7 @@ describe("generateCalibrationReport", () => { const report = generateCalibrationReport(data); expect(report).toContain("### shadow-complexity"); - expect(report).toContain("structure"); + expect(report).toContain("pixel-critical"); expect(report).toContain("Detects complex shadow configurations"); expect(report).toContain("risk"); expect(report).toContain("-4"); diff --git a/src/agents/tuning-agent.test.ts b/src/agents/tuning-agent.test.ts index 36935987..53609539 100644 --- a/src/agents/tuning-agent.test.ts +++ b/src/agents/tuning-agent.test.ts @@ -59,7 +59,7 @@ describe("runTuningAgent", () => { mismatches: [ makeMismatch({ type: "underscored", - ruleId: "magic-number-spacing", + ruleId: "irregular-spacing", currentScore: -2, currentSeverity: "missing-info", actualDifficulty: "hard", @@ -67,7 +67,7 @@ describe("runTuningAgent", () => { }), makeMismatch({ type: "underscored", - ruleId: "magic-number-spacing", + ruleId: "irregular-spacing", nodeId: "node-2", currentScore: -2, currentSeverity: "missing-info", @@ -76,7 +76,7 @@ describe("runTuningAgent", () => { }), makeMismatch({ type: "underscored", - ruleId: "magic-number-spacing", + ruleId: "irregular-spacing", nodeId: "node-3", currentScore: -2, currentSeverity: "missing-info", @@ -85,7 +85,7 @@ describe("runTuningAgent", () => { }), ], ruleScores: { - "magic-number-spacing": { score: -2, severity: "missing-info" }, + "irregular-spacing": { score: -2, severity: "missing-info" }, }, }; @@ -93,7 +93,7 @@ describe("runTuningAgent", () => { expect(result.adjustments).toHaveLength(1); const adj = result.adjustments[0]!; - expect(adj.ruleId).toBe("magic-number-spacing"); + expect(adj.ruleId).toBe("irregular-spacing"); expect(adj.proposedScore).toBe(-10); expect(adj.confidence).toBe("high"); expect(adj.supportingCases).toBe(3); @@ -284,10 +284,10 @@ describe("runTuningAgent", () => { const input: TuningAgentInput = { mismatches: [], ruleScores: { - "raw-color": { score: -6, severity: "risk" }, + "raw-value": { score: -6, severity: "risk" }, }, priorEvidence: { - "raw-color": { + "raw-value": { overscoredCount: 3, underscoredCount: 0, overscoredDifficulties: ["easy", "easy", "easy"], @@ -300,7 +300,7 @@ describe("runTuningAgent", () => { expect(result.adjustments).toHaveLength(1); const adj = result.adjustments[0]!; - expect(adj.ruleId).toBe("raw-color"); + expect(adj.ruleId).toBe("raw-value"); expect(adj.supportingCases).toBe(3); expect(adj.confidence).toBe("high"); expect(adj.reasoning).toContain("+ 3 case(s) from prior runs"); @@ -312,7 +312,7 @@ describe("runTuningAgent", () => { mismatches: [ makeMismatch({ type: "overscored", - ruleId: "raw-color", + ruleId: "raw-value", currentScore: -2, currentSeverity: "suggestion", actualDifficulty: "easy", @@ -320,10 +320,10 @@ describe("runTuningAgent", () => { }), ], ruleScores: { - "raw-color": { score: -2, severity: "suggestion" }, + "raw-value": { score: -2, severity: "suggestion" }, }, priorEvidence: { - "raw-color": { + "raw-value": { overscoredCount: 2, underscoredCount: 0, overscoredDifficulties: ["easy", "easy"], @@ -336,7 +336,7 @@ describe("runTuningAgent", () => { expect(result.adjustments).toHaveLength(1); const adj = result.adjustments[0]!; - expect(adj.ruleId).toBe("raw-color"); + expect(adj.ruleId).toBe("raw-value"); expect(adj.proposedDisable).toBe(true); expect(adj.confidence).toBe("high"); expect(adj.supportingCases).toBe(3); @@ -348,7 +348,7 @@ describe("runTuningAgent", () => { mismatches: [ makeMismatch({ type: "overscored", - ruleId: "raw-color", + ruleId: "raw-value", currentScore: -4, currentSeverity: "risk", actualDifficulty: "easy", @@ -356,7 +356,7 @@ describe("runTuningAgent", () => { }), makeMismatch({ type: "overscored", - ruleId: "raw-color", + ruleId: "raw-value", nodeId: "node-2", currentScore: -4, currentSeverity: "risk", @@ -365,7 +365,7 @@ describe("runTuningAgent", () => { }), makeMismatch({ type: "overscored", - ruleId: "raw-color", + ruleId: "raw-value", nodeId: "node-3", currentScore: -4, currentSeverity: "risk", @@ -374,7 +374,7 @@ describe("runTuningAgent", () => { }), ], ruleScores: { - "raw-color": { score: -4, severity: "risk" }, + "raw-value": { score: -4, severity: "risk" }, }, }; @@ -390,7 +390,7 @@ describe("runTuningAgent", () => { mismatches: [ makeMismatch({ type: "overscored", - ruleId: "raw-color", + ruleId: "raw-value", currentScore: -2, currentSeverity: "suggestion", actualDifficulty: "easy", @@ -398,7 +398,7 @@ describe("runTuningAgent", () => { }), makeMismatch({ type: "overscored", - ruleId: "raw-color", + ruleId: "raw-value", nodeId: "node-2", currentScore: -2, currentSeverity: "suggestion", @@ -407,7 +407,7 @@ describe("runTuningAgent", () => { }), ], ruleScores: { - "raw-color": { score: -2, severity: "suggestion" }, + "raw-value": { score: -2, severity: "suggestion" }, }, }; @@ -429,7 +429,7 @@ describe("runTuningAgent", () => { }), makeMismatch({ type: "validated", - ruleId: "magic-number-spacing", + ruleId: "irregular-spacing", nodeId: "node-2", actualDifficulty: "moderate", reasoning: "Matched expected difficulty", @@ -444,7 +444,7 @@ describe("runTuningAgent", () => { ], ruleScores: { "no-auto-layout": { score: -8, severity: "blocking" }, - "magic-number-spacing": { score: -5, severity: "risk" }, + "irregular-spacing": { score: -5, severity: "risk" }, }, }; diff --git a/src/cli/docs.ts b/src/cli/docs.ts index 2d4c8864..20855a0e 100644 --- a/src/cli/docs.ts +++ b/src/cli/docs.ts @@ -91,7 +91,6 @@ STRUCTURE - excludeNodeTypes: node types to skip (e.g. VECTOR, BOOLEAN_OPERATION) - excludeNodeNames: name patterns to skip (e.g. icon, ico) - gridBase: spacing grid unit, default 4 - - colorTolerance: color diff tolerance, default 10 - rules: per-rule overrides (score, severity, enabled) EXAMPLE @@ -101,7 +100,7 @@ EXAMPLE "gridBase": 4, "rules": { "no-auto-layout": { "score": -15, "severity": "blocking" }, - "raw-color": { "score": -12 }, + "raw-value": { "score": -5 }, "default-name": { "enabled": false } } } diff --git a/src/core/contracts/category.ts b/src/core/contracts/category.ts index b6b65c82..5e9bf024 100644 --- a/src/core/contracts/category.ts +++ b/src/core/contracts/category.ts @@ -1,11 +1,11 @@ import { z } from "zod"; export const CategorySchema = z.enum([ - "structure", - "token", - "component", - "naming", - "behavior", + "pixel-critical", + "responsive-critical", + "code-quality", + "token-management", + "minor", ]); export type Category = z.infer; @@ -13,9 +13,9 @@ export type Category = z.infer; export const CATEGORIES = CategorySchema.options; export const CATEGORY_LABELS: Record = { - structure: "Structure", - token: "Design Token", - component: "Component", - naming: "Naming", - behavior: "Behavior", + "pixel-critical": "Pixel Critical", + "responsive-critical": "Responsive Critical", + "code-quality": "Code Quality", + "token-management": "Token Management", + "minor": "Minor", }; diff --git a/src/core/contracts/rule.ts b/src/core/contracts/rule.ts index 6ee79a9b..ab706e45 100644 --- a/src/core/contracts/rule.ts +++ b/src/core/contracts/rule.ts @@ -64,6 +64,7 @@ export function getAnalysisState(context: RuleContext, key: string, init: () */ export interface RuleViolation { ruleId: string; + subType?: string; nodeId: string; nodePath: string; message: string; @@ -90,45 +91,30 @@ export interface Rule { * Rule ID type for type safety */ export type RuleId = - // Structure + // Pixel Critical — layout issues that directly affect pixel accuracy (ΔV ≥ 5%) | "no-auto-layout" | "absolute-position-in-auto-layout" + | "non-layout-container" + // Responsive Critical — size issues that break at different viewports (ΔV ≥ 15%) | "fixed-size-in-auto-layout" | "missing-size-constraint" - | "missing-responsive-behavior" - | "group-usage" - | "deep-nesting" - | "z-index-dependent-layout" - | "unnecessary-node" - // Token - | "raw-color" - | "raw-font" - | "inconsistent-spacing" - | "magic-number-spacing" - | "raw-shadow" - | "raw-opacity" - | "multiple-fill-colors" - // Component + // Code Quality — structural issues affecting code reuse (ΔV ≈ 0%, CSS classes -8~15) | "missing-component" | "detached-instance" - | "missing-component-description" | "variant-structure-mismatch" - // Naming + | "deep-nesting" + // Token Management — raw values without design tokens + | "raw-value" + | "irregular-spacing" + // Minor — naming issues with negligible impact (ΔV < 2%) | "default-name" | "non-semantic-name" - | "inconsistent-naming-convention" - | "numeric-suffix-name" - | "too-long-name" - // Behavior - | "text-truncation-unhandled" - | "prototype-link-in-design" - | "overflow-behavior-unknown" - | "wrap-behavior-unknown"; + | "inconsistent-naming-convention"; /** * Categories that support depthWeight */ -export const DEPTH_WEIGHT_CATEGORIES: Category[] = ["structure", "behavior"]; +export const DEPTH_WEIGHT_CATEGORIES: Category[] = ["pixel-critical", "responsive-critical"]; /** * Check if a category supports depth weighting diff --git a/src/core/engine/integration.test.ts b/src/core/engine/integration.test.ts index f98a672c..f047aff3 100644 --- a/src/core/engine/integration.test.ts +++ b/src/core/engine/integration.test.ts @@ -74,7 +74,7 @@ describe("Integration: fixture → analyze → score", () => { // Exactly 5 categories present const categories = Object.keys(scores.byCategory).sort(); expect(categories).toEqual( - ["behavior", "component", "naming", "structure", "token"], + ["code-quality", "minor", "pixel-critical", "responsive-critical", "token-management"], ); // Each category has valid percentages diff --git a/src/core/engine/rule-engine.test.ts b/src/core/engine/rule-engine.test.ts index bcc1ef85..35d4130d 100644 --- a/src/core/engine/rule-engine.test.ts +++ b/src/core/engine/rule-engine.test.ts @@ -163,7 +163,7 @@ describe("RuleEngine.analyze — node exclusion", () => { }); it("skips nodes matching excludeNodeNames pattern", () => { - // Use GROUP type to trigger group-usage rule (enabled) + // Use GROUP type to trigger non-layout-container rule (enabled) const ignoredNode = makeNode({ id: "i:1", name: "IgnoreMe", type: "GROUP", children: [makeNode({ id: "i:2", name: "Child", type: "FRAME" })] }); const normalNode = makeNode({ id: "n:1", name: "Normal", type: "GROUP", children: [makeNode({ id: "n:2", name: "Child", type: "FRAME" })] }); const doc = makeNode({ @@ -291,7 +291,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 "structure" (supports depth weight) + // Use only no-auto-layout which has depthWeight: 1.5 and is in "pixel-critical" (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 f27e9f35..7eadf02f 100644 --- a/src/core/engine/scoring.test.ts +++ b/src/core/engine/scoring.test.ts @@ -72,10 +72,10 @@ describe("calculateScores", () => { it("counts issues by severity correctly", () => { const issues = [ - 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" }), + makeIssue({ ruleId: "no-auto-layout", category: "pixel-critical", severity: "blocking" }), + makeIssue({ ruleId: "non-layout-container", category: "pixel-critical", severity: "risk" }), + makeIssue({ ruleId: "raw-value", category: "token-management", severity: "missing-info" }), + makeIssue({ ruleId: "default-name", category: "minor", severity: "suggestion" }), ]; const scores = calculateScores(makeResult(issues)); @@ -87,165 +87,150 @@ describe("calculateScores", () => { }); it("uses calculatedScore for density: higher score = more density impact", () => { - // Create issue where calculatedScore differs from config.score - const heavyIssue = makeIssue({ ruleId: "no-auto-layout", category: "structure", severity: "blocking", score: -10 }); + const heavyIssue = makeIssue({ ruleId: "no-auto-layout", category: "pixel-critical", severity: "blocking", score: -10 }); heavyIssue.calculatedScore = -15; // Simulate depthWeight effect - const lightIssue = makeIssue({ ruleId: "unnecessary-node", category: "structure", severity: "suggestion", score: -2 }); - lightIssue.calculatedScore = -2; // No depthWeight + const lightIssue = makeIssue({ ruleId: "default-name", category: "minor", severity: "suggestion", score: -1 }); + lightIssue.calculatedScore = -1; const heavy = calculateScores(makeResult([heavyIssue], 100)); const light = calculateScores(makeResult([lightIssue], 100)); - // Density should use calculatedScore (-15 vs -2), not config.score - expect(heavy.byCategory.structure.weightedIssueCount).toBe(15); - expect(light.byCategory.structure.weightedIssueCount).toBe(2); - expect(heavy.byCategory.structure.densityScore).toBeLessThan( - light.byCategory.structure.densityScore - ); + expect(heavy.byCategory["pixel-critical"].weightedIssueCount).toBe(15); + expect(light.byCategory["minor"].weightedIssueCount).toBe(1); }); it("differentiates rules within the same severity by score", () => { - // Create issues where calculatedScore differs from config.score - const highScoreIssue = makeIssue({ ruleId: "no-auto-layout", category: "structure", severity: "blocking", score: -10 }); - highScoreIssue.calculatedScore = -15; // Simulate depthWeight effect + const highScoreIssue = makeIssue({ ruleId: "no-auto-layout", category: "pixel-critical", severity: "blocking", score: -10 }); + highScoreIssue.calculatedScore = -15; - const lowScoreIssue = makeIssue({ ruleId: "absolute-position-in-auto-layout", category: "structure", severity: "blocking", score: -3 }); - lowScoreIssue.calculatedScore = -5; // Simulate different depthWeight + const lowScoreIssue = makeIssue({ ruleId: "absolute-position-in-auto-layout", category: "pixel-critical", severity: "blocking", score: -3 }); + lowScoreIssue.calculatedScore = -5; const highScore = calculateScores(makeResult([highScoreIssue], 100)); const lowScore = calculateScores(makeResult([lowScoreIssue], 100)); - // weightedIssueCount should use calculatedScore, not config.score - expect(highScore.byCategory.structure.densityScore).toBeLessThan( - lowScore.byCategory.structure.densityScore + expect(highScore.byCategory["pixel-critical"].densityScore).toBeLessThan( + lowScore.byCategory["pixel-critical"].densityScore ); - expect(highScore.byCategory.structure.weightedIssueCount).toBe(15); - expect(lowScore.byCategory.structure.weightedIssueCount).toBe(5); + expect(highScore.byCategory["pixel-critical"].weightedIssueCount).toBe(15); + expect(lowScore.byCategory["pixel-critical"].weightedIssueCount).toBe(5); }); it("density score decreases as weighted issue count increases relative to node count", () => { const few = calculateScores(makeResult([ - makeIssue({ ruleId: "no-auto-layout", category: "structure", severity: "blocking" }), + makeIssue({ ruleId: "no-auto-layout", category: "pixel-critical", severity: "blocking" }), ], 100)); const many = calculateScores(makeResult([ - 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" }), + makeIssue({ ruleId: "no-auto-layout", category: "pixel-critical", severity: "blocking" }), + makeIssue({ ruleId: "no-auto-layout", category: "pixel-critical", severity: "blocking" }), + makeIssue({ ruleId: "no-auto-layout", category: "pixel-critical", severity: "blocking" }), + makeIssue({ ruleId: "no-auto-layout", category: "pixel-critical", severity: "blocking" }), + makeIssue({ ruleId: "no-auto-layout", category: "pixel-critical", severity: "blocking" }), ], 100)); - expect(many.byCategory.structure.densityScore).toBeLessThan( - few.byCategory.structure.densityScore + expect(many.byCategory["pixel-critical"].densityScore).toBeLessThan( + few.byCategory["pixel-critical"].densityScore ); }); it("diversity score penalizes more unique rules being triggered", () => { const concentrated = calculateScores(makeResult([ - makeIssue({ ruleId: "no-auto-layout", category: "structure", severity: "risk", score: -5 }), - makeIssue({ ruleId: "no-auto-layout", category: "structure", severity: "risk", score: -5 }), - makeIssue({ ruleId: "no-auto-layout", category: "structure", severity: "risk", score: -5 }), + makeIssue({ ruleId: "no-auto-layout", category: "pixel-critical", severity: "risk", score: -5 }), + makeIssue({ ruleId: "no-auto-layout", category: "pixel-critical", severity: "risk", score: -5 }), + makeIssue({ ruleId: "no-auto-layout", category: "pixel-critical", severity: "risk", score: -5 }), ], 100)); const spread = calculateScores(makeResult([ - makeIssue({ ruleId: "no-auto-layout", category: "structure", severity: "risk", score: -5 }), - makeIssue({ ruleId: "group-usage", category: "structure", severity: "risk", score: -5 }), - makeIssue({ ruleId: "deep-nesting", category: "structure", severity: "risk", score: -5 }), + makeIssue({ ruleId: "no-auto-layout", category: "pixel-critical", severity: "risk", score: -5 }), + makeIssue({ ruleId: "non-layout-container", category: "pixel-critical", severity: "risk", score: -5 }), + makeIssue({ ruleId: "absolute-position-in-auto-layout", category: "pixel-critical", severity: "risk", score: -5 }), ], 100)); - expect(concentrated.byCategory.structure.diversityScore).toBeGreaterThan( - spread.byCategory.structure.diversityScore + expect(concentrated.byCategory["pixel-critical"].diversityScore).toBeGreaterThan( + spread.byCategory["pixel-critical"].diversityScore ); }); it("diversity weights triggered rules by score severity", () => { - // One high-severity rule triggered const heavyRule = calculateScores(makeResult([ - makeIssue({ ruleId: "no-auto-layout", category: "structure", severity: "blocking", score: -10 }), + makeIssue({ ruleId: "no-auto-layout", category: "pixel-critical", severity: "blocking", score: -10 }), ], 100)); - // One low-severity rule triggered const lightRule = calculateScores(makeResult([ - makeIssue({ ruleId: "unnecessary-node", category: "structure", severity: "suggestion", score: -2 }), + makeIssue({ ruleId: "default-name", category: "minor", severity: "suggestion", score: -1 }), ], 100)); - expect(heavyRule.byCategory.structure.diversityScore).toBeLessThan( - lightRule.byCategory.structure.diversityScore + expect(heavyRule.byCategory["pixel-critical"].diversityScore).toBeLessThan( + lightRule.byCategory["minor"].diversityScore ); }); it("low-severity rules have minimal diversity impact (intentional)", () => { - // 1 suggestion rule (score -2) vs 1 blocking rule (score -10). - // Weighted ratio for the suggestion is much smaller, so diversity stays high. - // Low-severity rules correctly penalize diversity less. const lowSeverity = calculateScores(makeResult([ - makeIssue({ ruleId: "unnecessary-node", category: "structure", severity: "suggestion", score: -2 }), + makeIssue({ ruleId: "default-name", category: "minor", severity: "suggestion", score: -1 }), ], 100)); const highSeverity = calculateScores(makeResult([ - makeIssue({ ruleId: "no-auto-layout", category: "structure", severity: "blocking", score: -10 }), + makeIssue({ ruleId: "no-auto-layout", category: "pixel-critical", severity: "blocking", score: -10 }), ], 100)); - // Both trigger exactly 1 rule, but severity-weighted diversity differs significantly - expect(lowSeverity.byCategory.structure.diversityScore).toBeGreaterThan(90); - expect(highSeverity.byCategory.structure.diversityScore).toBeLessThan(80); + expect(lowSeverity.byCategory["minor"].diversityScore).toBeGreaterThan(50); + expect(highSeverity.byCategory["pixel-critical"].diversityScore).toBeLessThan(80); }); it("combined score = density * 0.7 + diversity * 0.3", () => { const issues = [ - makeIssue({ ruleId: "no-auto-layout", category: "structure", severity: "blocking" }), - makeIssue({ ruleId: "group-usage", category: "structure", severity: "risk" }), + makeIssue({ ruleId: "no-auto-layout", category: "pixel-critical", severity: "blocking" }), + makeIssue({ ruleId: "non-layout-container", category: "pixel-critical", severity: "risk" }), ]; const scores = calculateScores(makeResult(issues, 100)); - const structure = scores.byCategory.structure; + const pixelCritical = scores.byCategory["pixel-critical"]; - const expected = Math.round(structure.densityScore * 0.7 + structure.diversityScore * 0.3); + const expected = Math.round(pixelCritical.densityScore * 0.7 + pixelCritical.diversityScore * 0.3); const clamped = Math.max(5, Math.min(100, expected)); - expect(structure.percentage).toBe(clamped); + expect(pixelCritical.percentage).toBe(clamped); }); it("score never goes below SCORE_FLOOR (5) when issues exist", () => { - const structureRules = [ - "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", + const pixelCriticalRules = [ + "no-auto-layout", "non-layout-container", "absolute-position-in-auto-layout", ] as const; const issues: AnalysisIssue[] = []; - for (const ruleId of structureRules) { - for (let i = 0; i < 50; i++) { - issues.push(makeIssue({ ruleId, category: "structure", severity: "blocking" })); + for (const ruleId of pixelCriticalRules) { + for (let i = 0; i < 200; i++) { + issues.push(makeIssue({ ruleId, category: "pixel-critical", severity: "blocking", score: -10 })); } } - const scores = calculateScores(makeResult(issues, 10)); - expect(scores.byCategory.structure.percentage).toBe(5); + const scores = calculateScores(makeResult(issues, 5)); + expect(scores.byCategory["pixel-critical"].percentage).toBe(5); }); it("categories without issues get 100%", () => { const scores = calculateScores(makeResult([ - makeIssue({ ruleId: "no-auto-layout", category: "structure", severity: "blocking" }), + makeIssue({ ruleId: "no-auto-layout", category: "pixel-critical", 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.behavior.percentage).toBe(100); + expect(scores.byCategory["token-management"].percentage).toBe(100); + expect(scores.byCategory["code-quality"].percentage).toBe(100); + expect(scores.byCategory["minor"].percentage).toBe(100); + expect(scores.byCategory["responsive-critical"].percentage).toBe(100); }); it("overall score is weighted average of all 5 categories", () => { const scores = calculateScores(makeResult([ - makeIssue({ ruleId: "no-auto-layout", category: "structure", severity: "blocking" }), + makeIssue({ ruleId: "no-auto-layout", category: "pixel-critical", severity: "blocking" }), ], 100)); const categoryPercentages = [ - scores.byCategory.structure.percentage, - scores.byCategory.token.percentage, - scores.byCategory.component.percentage, - scores.byCategory.naming.percentage, - scores.byCategory.behavior.percentage, + scores.byCategory["pixel-critical"].percentage, + scores.byCategory["responsive-critical"].percentage, + scores.byCategory["code-quality"].percentage, + scores.byCategory["token-management"].percentage, + scores.byCategory["minor"].percentage, ]; const expectedOverall = Math.round( categoryPercentages.reduce((a, b) => a + b, 0) / 5 @@ -255,17 +240,17 @@ describe("calculateScores", () => { it("handles nodeCount = 0 gracefully (no division by zero)", () => { const scores = calculateScores(makeResult([ - makeIssue({ ruleId: "raw-color", category: "token", severity: "missing-info" }), + makeIssue({ ruleId: "raw-value", category: "token-management", severity: "missing-info" }), ], 0)); - expect(scores.byCategory.token.densityScore).toBe(100); - expect(scores.byCategory.token.percentage).toBeLessThan(100); + expect(scores.byCategory["token-management"].densityScore).toBe(100); + expect(scores.byCategory["token-management"].percentage).toBeLessThan(100); expect(Number.isFinite(scores.overall.percentage)).toBe(true); }); it("handles nodeCount = 1 without edge case issues", () => { const scores = calculateScores(makeResult([ - makeIssue({ ruleId: "raw-color", category: "token", severity: "missing-info" }), + makeIssue({ ruleId: "raw-value", category: "token-management", severity: "missing-info" }), ], 1)); expect(Number.isFinite(scores.overall.percentage)).toBe(true); @@ -275,28 +260,28 @@ describe("calculateScores", () => { it("tracks uniqueRuleCount per category", () => { const issues = [ - 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" }), + makeIssue({ ruleId: "no-auto-layout", category: "pixel-critical", severity: "blocking" }), + makeIssue({ ruleId: "no-auto-layout", category: "pixel-critical", severity: "blocking" }), + makeIssue({ ruleId: "non-layout-container", category: "pixel-critical", severity: "risk" }), ]; const scores = calculateScores(makeResult(issues)); - expect(scores.byCategory.structure.uniqueRuleCount).toBe(2); - expect(scores.byCategory.structure.issueCount).toBe(3); + expect(scores.byCategory["pixel-critical"].uniqueRuleCount).toBe(2); + expect(scores.byCategory["pixel-critical"].issueCount).toBe(3); }); it("bySeverity counts are accurate per category", () => { const issues = [ - 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" }), + makeIssue({ ruleId: "no-auto-layout", category: "pixel-critical", severity: "blocking" }), + makeIssue({ ruleId: "non-layout-container", category: "pixel-critical", severity: "risk" }), + makeIssue({ ruleId: "non-layout-container", category: "pixel-critical", severity: "risk" }), ]; const scores = calculateScores(makeResult(issues)); - 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); + expect(scores.byCategory["pixel-critical"].bySeverity.blocking).toBe(1); + expect(scores.byCategory["pixel-critical"].bySeverity.risk).toBe(2); + expect(scores.byCategory["pixel-critical"].bySeverity["missing-info"]).toBe(0); + expect(scores.byCategory["pixel-critical"].bySeverity.suggestion).toBe(0); }); }); @@ -310,13 +295,13 @@ describe("calculateGrade (via calculateScores)", () => { it("score < 50% -> F", () => { const issues: AnalysisIssue[] = []; - const categories: Category[] = ["structure", "token", "component", "naming", "behavior"]; + const categories: Category[] = ["pixel-critical", "responsive-critical", "code-quality", "token-management", "minor"]; const rulesPerCat: Record = { - 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", "raw-opacity", "multiple-fill-colors"], - 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"], - behavior: ["text-truncation-unhandled", "prototype-link-in-design", "overflow-behavior-unknown", "wrap-behavior-unknown"], + "pixel-critical": ["no-auto-layout", "non-layout-container", "absolute-position-in-auto-layout"], + "responsive-critical": ["fixed-size-in-auto-layout", "missing-size-constraint"], + "code-quality": ["missing-component", "detached-instance", "variant-structure-mismatch", "deep-nesting"], + "token-management": ["raw-value", "irregular-spacing"], + "minor": ["default-name", "non-semantic-name", "inconsistent-naming-convention"], }; for (const cat of categories) { @@ -357,16 +342,16 @@ describe("formatScoreSummary", () => { const scores = calculateScores(makeResult([])); const summary = formatScoreSummary(scores); - expect(summary).toContain("structure:"); - expect(summary).toContain("token:"); - expect(summary).toContain("component:"); - expect(summary).toContain("naming:"); - expect(summary).toContain("behavior:"); + expect(summary).toContain("pixel-critical:"); + expect(summary).toContain("responsive-critical:"); + expect(summary).toContain("code-quality:"); + expect(summary).toContain("token-management:"); + expect(summary).toContain("minor:"); }); it("includes severity breakdown", () => { const scores = calculateScores(makeResult([ - makeIssue({ ruleId: "no-auto-layout", category: "structure", severity: "blocking" }), + makeIssue({ ruleId: "no-auto-layout", category: "pixel-critical", severity: "blocking" }), ])); const summary = formatScoreSummary(scores); @@ -379,11 +364,11 @@ describe("formatScoreSummary", () => { describe("getCategoryLabel", () => { it("returns correct labels for all categories", () => { - expect(getCategoryLabel("structure")).toBe("Structure"); - expect(getCategoryLabel("token")).toBe("Design Token"); - expect(getCategoryLabel("component")).toBe("Component"); - expect(getCategoryLabel("naming")).toBe("Naming"); - expect(getCategoryLabel("behavior")).toBe("Behavior"); + expect(getCategoryLabel("pixel-critical")).toBe("Pixel Critical"); + expect(getCategoryLabel("responsive-critical")).toBe("Responsive Critical"); + expect(getCategoryLabel("code-quality")).toBe("Code Quality"); + expect(getCategoryLabel("token-management")).toBe("Token Management"); + expect(getCategoryLabel("minor")).toBe("Minor"); }); }); @@ -401,9 +386,9 @@ describe("getSeverityLabel", () => { describe("buildResultJson", () => { it("includes all expected fields", () => { const result = makeResult([ - 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" }), + makeIssue({ ruleId: "no-auto-layout", category: "pixel-critical", severity: "blocking" }), + makeIssue({ ruleId: "no-auto-layout", category: "pixel-critical", severity: "blocking" }), + makeIssue({ ruleId: "raw-value", category: "token-management", severity: "missing-info" }), ]); const scores = calculateScores(result); const json = buildResultJson("TestFile", result, scores); @@ -420,26 +405,29 @@ describe("buildResultJson", () => { it("aggregates issuesByRule correctly", () => { const result = makeResult([ - 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" }), + makeIssue({ ruleId: "no-auto-layout", category: "pixel-critical", severity: "blocking" }), + makeIssue({ ruleId: "no-auto-layout", category: "pixel-critical", severity: "blocking" }), + makeIssue({ ruleId: "raw-value", category: "token-management", severity: "missing-info" }), ]); const scores = calculateScores(result); const json = buildResultJson("TestFile", result, scores); const issuesByRule = json.issuesByRule as Record; expect(issuesByRule["no-auto-layout"]).toBe(2); - expect(issuesByRule["raw-color"]).toBe(1); + expect(issuesByRule["raw-value"]).toBe(1); }); it("includes detailed issues list with severity and node info", () => { + const tokenIssue = makeIssue({ ruleId: "raw-value", category: "token-management", severity: "missing-info" }); + tokenIssue.violation.subType = "color"; + const result = makeResult([ - makeIssue({ ruleId: "no-auto-layout", category: "structure", severity: "blocking" }), - makeIssue({ ruleId: "raw-color", category: "token", severity: "missing-info" }), + makeIssue({ ruleId: "no-auto-layout", category: "pixel-critical", severity: "blocking" }), + tokenIssue, ]); const scores = calculateScores(result); const json = buildResultJson("TestFile", result, scores); - const issues = json.issues as Array<{ ruleId: string; severity: string; nodeId: string; nodePath: string; message: string }>; + const issues = json.issues as Array<{ ruleId: string; subType?: string; severity: string; nodeId: string; nodePath: string; message: string }>; expect(issues).toHaveLength(2); expect(issues[0]).toMatchObject({ @@ -449,12 +437,27 @@ describe("buildResultJson", () => { nodePath: expect.any(String), message: expect.any(String), }); + expect(issues[0]!["subType"]).toBeUndefined(); expect(issues[1]).toMatchObject({ - ruleId: "raw-color", + ruleId: "raw-value", + subType: "color", severity: "missing-info", }); }); + it("omits subType when it is an empty string", () => { + const emptySubTypeIssue = makeIssue({ ruleId: "raw-value", category: "token-management", severity: "missing-info" }); + emptySubTypeIssue.violation.subType = ""; + + const result = makeResult([emptySubTypeIssue]); + const scores = calculateScores(result); + const json = buildResultJson("TestFile", result, scores); + const issues = json.issues as Array<{ ruleId: string; subType?: string }>; + + expect(issues).toHaveLength(1); + expect(issues[0]!["subType"]).toBeUndefined(); + }); + it("includes fileKey when provided", () => { const result = makeResult([]); const scores = calculateScores(result); @@ -465,4 +468,4 @@ describe("buildResultJson", () => { const withoutKey = buildResultJson("TestFile", result, scores); expect(withoutKey.fileKey).toBeUndefined(); }); -}); \ No newline at end of file +}); diff --git a/src/core/engine/scoring.ts b/src/core/engine/scoring.ts index 6f8e5bb0..86ef5c3f 100644 --- a/src/core/engine/scoring.ts +++ b/src/core/engine/scoring.ts @@ -1,5 +1,5 @@ import type { Category } from "../contracts/category.js"; -import { CATEGORIES } from "../contracts/category.js"; +import { CATEGORIES, CATEGORY_LABELS } from "../contracts/category.js"; import type { RuleId, RuleConfig } from "../contracts/rule.js"; import type { Severity } from "../contracts/severity.js"; import type { AnalysisResult } from "./rule-engine.js"; @@ -57,7 +57,7 @@ export type Grade = "S" | "A+" | "A" | "B+" | "B" | "C+" | "C" | "D" | "F"; * the per-rule scores in rule-config.ts effectively unused. * * Now: `no-auto-layout` (score: -10, depthWeight: 1.5) at root contributes 15 - * to density, while `unnecessary-node` (score: -2, no depthWeight) contributes 2. + * to density, while `default-name` (score: -1, no depthWeight) contributes 1. * This makes calibration loop score adjustments flow through to user-facing scores. */ @@ -92,11 +92,11 @@ function computeTotalScorePerCategory( * more strongly with visual-compare similarity, these weights can be adjusted. */ const CATEGORY_WEIGHT: Record = { - structure: 1.0, - token: 1.0, - component: 1.0, - naming: 1.0, - behavior: 1.0, + "pixel-critical": 1.0, + "responsive-critical": 1.0, + "code-quality": 1.0, + "token-management": 1.0, + "minor": 1.0, }; /** @@ -357,14 +357,7 @@ export function formatScoreSummary(report: ScoreReport): string { * Get category label for display */ export function getCategoryLabel(category: Category): string { - const labels: Record = { - structure: "Structure", - token: "Design Token", - component: "Component", - naming: "Naming", - behavior: "Behavior", - }; - return labels[category]; + return CATEGORY_LABELS[category]; } /** @@ -398,6 +391,7 @@ export function buildResultJson( const issues = result.issues.map((issue) => ({ ruleId: issue.violation.ruleId, + ...(issue.violation.subType && { subType: issue.violation.subType }), severity: issue.config.severity, nodeId: issue.violation.nodeId, nodePath: issue.violation.nodePath, diff --git a/src/core/rules/behavior/index.ts b/src/core/rules/behavior/index.ts deleted file mode 100644 index d2b0da71..00000000 --- a/src/core/rules/behavior/index.ts +++ /dev/null @@ -1,236 +0,0 @@ -import type { RuleCheckFn, RuleDefinition } from "../../contracts/rule.js"; -import type { AnalysisNode } from "../../contracts/figma-node.js"; -import { defineRule } from "../rule-registry.js"; - -// ============================================ -// Helper functions -// ============================================ - -function hasAutoLayout(node: AnalysisNode): boolean { - return node.layoutMode !== undefined && node.layoutMode !== "NONE"; -} - -function isTextNode(node: AnalysisNode): boolean { - return node.type === "TEXT"; -} - -// ============================================ -// text-truncation-unhandled -// ============================================ - -const textTruncationUnhandledDef: RuleDefinition = { - id: "text-truncation-unhandled", - name: "Text Truncation Unhandled", - 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", -}; - -const textTruncationUnhandledCheck: RuleCheckFn = (node, context) => { - if (!isTextNode(node)) return null; - - // Check if parent is Auto Layout with fixed size - if (!context.parent) return null; - if (!hasAutoLayout(context.parent)) return null; - - if (node.absoluteBoundingBox) { - const { width } = node.absoluteBoundingBox; - if (node.characters && node.characters.length > 50 && width < 300) { - return { - ruleId: textTruncationUnhandledDef.id, - nodeId: node.id, - nodePath: context.path.join(" > "), - message: `"${node.name}" has long text (${node.characters!.length} chars) in narrow container (${width}px) — set text truncation (ellipsis) or use HUG sizing`, - }; - } - } - - return null; -}; - -export const textTruncationUnhandled = defineRule({ - definition: textTruncationUnhandledDef, - check: textTruncationUnhandledCheck, -}); - -// ============================================ -// prototype-link-in-design -// ============================================ - -const prototypeLinkInDesignDef: RuleDefinition = { - id: "prototype-link-in-design", - name: "Missing Prototype Interaction", - 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", -}; - -/** Name patterns that suggest an interactive element */ -const INTERACTIVE_NAME_PATTERNS = [ - /\bbtn\b/i, /\bbutton\b/i, /\blink\b/i, /\btab\b/i, - /\bcta\b/i, /\btoggle\b/i, /\bswitch\b/i, /\bcheckbox\b/i, - /\bradio\b/i, /\bdropdown\b/i, /\bselect\b/i, /\bmenu\b/i, - /\bnav\b/i, /\bclickable\b/i, /\btappable\b/i, -]; - -/** Variant names that imply interactive states */ -const STATE_VARIANT_PATTERNS = [ - /\bhover\b/i, /\bpressed\b/i, /\bactive\b/i, /\bfocused\b/i, - /\bdisabled\b/i, /\bselected\b/i, -]; - -function looksInteractive(node: AnalysisNode): boolean { - // Check name patterns - if (node.name && INTERACTIVE_NAME_PATTERNS.some((p) => p.test(node.name))) { - return true; - } - - // Check if component has state variants (hover, pressed, etc.) - if (node.componentPropertyDefinitions) { - const propValues = Object.values(node.componentPropertyDefinitions); - for (const prop of propValues) { - const p = prop as Record; - // VARIANT type properties with state-like values - if (p["type"] === "VARIANT" && p["variantOptions"]) { - const options = p["variantOptions"]; - if (Array.isArray(options) && options.some((opt) => typeof opt === "string" && STATE_VARIANT_PATTERNS.some((pat) => pat.test(opt)))) { - return true; - } - } - } - } - - return false; -} - -/** Check if any descendant has interactions defined */ -function hasDescendantInteractions(node: AnalysisNode): boolean { - if (node.interactions && node.interactions.length > 0) return true; - for (const child of node.children ?? []) { - if (hasDescendantInteractions(child)) return true; - } - return false; -} - -const prototypeLinkInDesignCheck: RuleCheckFn = (node, context) => { - // Only check components and instances (interactive elements are typically components) - if (node.type !== "COMPONENT" && node.type !== "INSTANCE" && node.type !== "FRAME") return null; - - if (!looksInteractive(node)) return null; - - // If interactions exist on this node, it's covered - if (node.interactions && node.interactions.length > 0) return null; - - // Skip container frames whose children already have interactions (e.g., "Button Group" wrapping interactive buttons) - if (node.type === "FRAME" && node.children && node.children.length > 0) { - if (hasDescendantInteractions(node)) return null; - } - - return { - ruleId: prototypeLinkInDesignDef.id, - nodeId: node.id, - nodePath: context.path.join(" > "), - message: `"${node.name}" looks interactive but has no prototype interactions — add prototype interactions or rename to clarify non-interactive intent`, - }; -}; - -export const prototypeLinkInDesign = defineRule({ - definition: prototypeLinkInDesignDef, - 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 — enable "Clip content" or set explicit scroll behavior`, - }; -}; - -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 — set layoutWrap to WRAP or add horizontal scroll behavior`, - }; -}; - -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 deleted file mode 100644 index 37f485ee..00000000 --- a/src/core/rules/behavior/overflow-behavior-unknown.test.ts +++ /dev/null @@ -1,89 +0,0 @@ -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 deleted file mode 100644 index 0afbe3b2..00000000 --- a/src/core/rules/behavior/prototype-link-in-design.test.ts +++ /dev/null @@ -1,54 +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("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/behavior/text-truncation-unhandled.test.ts b/src/core/rules/behavior/text-truncation-unhandled.test.ts deleted file mode 100644 index 7c6fb0d9..00000000 --- a/src/core/rules/behavior/text-truncation-unhandled.test.ts +++ /dev/null @@ -1,90 +0,0 @@ -import { makeNode, makeFile, makeContext } from "../test-helpers.js"; -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("behavior"); - }); - - it("flags long text in constrained auto layout parent", () => { - const parent = makeNode({ layoutMode: "HORIZONTAL" }); - const node = makeNode({ - type: "TEXT", - name: "Description", - characters: "A".repeat(60), - absoluteBoundingBox: { x: 0, y: 0, width: 200, height: 20 }, - }); - const result = textTruncationUnhandled.check(node, makeContext({ parent })); - expect(result).not.toBeNull(); - expect(result!.ruleId).toBe("text-truncation-unhandled"); - }); - - it("returns null for non-TEXT nodes", () => { - const node = makeNode({ type: "FRAME" }); - expect(textTruncationUnhandled.check(node, makeContext())).toBeNull(); - }); - - it("returns null when parent has no auto layout", () => { - const parent = makeNode({}); - const node = makeNode({ - type: "TEXT", - characters: "A".repeat(60), - absoluteBoundingBox: { x: 0, y: 0, width: 200, height: 20 }, - }); - expect(textTruncationUnhandled.check(node, makeContext({ parent }))).toBeNull(); - }); - - it("returns null for short text", () => { - const parent = makeNode({ layoutMode: "HORIZONTAL" }); - const node = makeNode({ - type: "TEXT", - name: "Label", - characters: "Short", - absoluteBoundingBox: { x: 0, y: 0, width: 200, height: 20 }, - }); - expect(textTruncationUnhandled.check(node, makeContext({ parent }))).toBeNull(); - }); - - 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", - characters: "A".repeat(60), - absoluteBoundingBox: { x: 0, y: 0, width: 300, height: 20 }, - }); - expect(textTruncationUnhandled.check(node, makeContext({ parent }))).toBeNull(); - }); - - it("returns null at length boundary (50 chars)", () => { - const parent = makeNode({ layoutMode: "HORIZONTAL" }); - const node = makeNode({ - type: "TEXT", - characters: "A".repeat(50), - absoluteBoundingBox: { x: 0, y: 0, width: 200, height: 20 }, - }); - expect(textTruncationUnhandled.check(node, makeContext({ parent }))).toBeNull(); - }); - - it("flags when length is 51 chars in constrained width", () => { - const parent = makeNode({ layoutMode: "HORIZONTAL" }); - const node = makeNode({ - type: "TEXT", - name: "Description", - characters: "A".repeat(51), - absoluteBoundingBox: { x: 0, y: 0, width: 200, height: 20 }, - }); - const result = textTruncationUnhandled.check(node, makeContext({ parent })); - expect(result).not.toBeNull(); - expect(result!.ruleId).toBe("text-truncation-unhandled"); - }); -}); diff --git a/src/core/rules/behavior/wrap-behavior-unknown.test.ts b/src/core/rules/behavior/wrap-behavior-unknown.test.ts deleted file mode 100644 index 80f436cc..00000000 --- a/src/core/rules/behavior/wrap-behavior-unknown.test.ts +++ /dev/null @@ -1,121 +0,0 @@ -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/detached-instance.test.ts b/src/core/rules/component/detached-instance.test.ts index d960a860..4317eadd 100644 --- a/src/core/rules/component/detached-instance.test.ts +++ b/src/core/rules/component/detached-instance.test.ts @@ -4,7 +4,7 @@ import { detachedInstance } from "./index.js"; describe("detached-instance", () => { it("has correct rule definition metadata", () => { expect(detachedInstance.definition.id).toBe("detached-instance"); - expect(detachedInstance.definition.category).toBe("component"); + expect(detachedInstance.definition.category).toBe("code-quality"); }); it("flags FRAME whose name matches a component", () => { diff --git a/src/core/rules/component/index.test.ts b/src/core/rules/component/index.test.ts deleted file mode 100644 index 60507c8a..00000000 --- a/src/core/rules/component/index.test.ts +++ /dev/null @@ -1,277 +0,0 @@ -import type { RuleContext } from "../../contracts/rule.js"; -import type { AnalysisFile, AnalysisNode } from "../../contracts/figma-node.js"; -import { missingComponentDescription } from "./index.js"; - -// ============================================ -// Test helpers -// ============================================ - -function makeNode(overrides?: Partial): AnalysisNode { - return { - id: "1:1", - name: "TestNode", - type: "INSTANCE", - visible: true, - ...overrides, - }; -} - -function makeFile( - components: AnalysisFile["components"] = {} -): AnalysisFile { - return { - fileKey: "test-file", - name: "Test File", - lastModified: "2026-01-01T00:00:00Z", - version: "1", - document: makeNode({ id: "0:1", name: "Document", type: "DOCUMENT" }), - components, - styles: {}, - }; -} - -/** Each test gets a fresh analysisState to isolate dedup state */ -let analysisState: Map; - -function makeContext(overrides?: Partial): RuleContext { - return { - file: makeFile(), - depth: 2, - componentDepth: 0, - maxDepth: 10, - path: ["Page", "Frame"], - analysisState, - ...overrides, - }; -} - -// ============================================ -// missing-component-description -// ============================================ - -describe("missing-component-description", () => { - beforeEach(() => { - analysisState = new Map(); - }); - - it("returns null for non-INSTANCE nodes", () => { - const ctx = makeContext({ - file: makeFile({ - "comp:1": { key: "comp:1", name: "Button", description: "" }, - }), - }); - - expect( - missingComponentDescription.check(makeNode({ type: "FRAME" }), ctx) - ).toBeNull(); - expect( - missingComponentDescription.check(makeNode({ type: "COMPONENT" }), ctx) - ).toBeNull(); - expect( - missingComponentDescription.check(makeNode({ type: "TEXT" }), ctx) - ).toBeNull(); - }); - - it("returns null when INSTANCE has no componentId", () => { - const ctx = makeContext({ - file: makeFile({ - "comp:1": { key: "comp:1", name: "Button", description: "" }, - }), - }); - - expect( - missingComponentDescription.check( - makeNode({ type: "INSTANCE" }), - ctx - ) - ).toBeNull(); - }); - - it("returns null when componentId is not found in file.components", () => { - const ctx = makeContext({ - file: makeFile({}), - }); - - expect( - missingComponentDescription.check( - makeNode({ type: "INSTANCE", componentId: "comp:999" }), - ctx - ) - ).toBeNull(); - }); - - it("returns null when component has a non-empty description", () => { - const ctx = makeContext({ - file: makeFile({ - "comp:1": { - key: "comp:1", - name: "Button", - description: "A primary action button", - }, - }), - }); - - expect( - missingComponentDescription.check( - makeNode({ type: "INSTANCE", componentId: "comp:1" }), - ctx - ) - ).toBeNull(); - }); - - it("returns null when component has a whitespace-only description", () => { - const ctx = makeContext({ - file: makeFile({ - "comp:1": { - key: "comp:1", - name: "Button", - description: " ", - }, - }), - }); - - // Whitespace-only is treated as empty — should flag, not skip - const result = missingComponentDescription.check( - makeNode({ type: "INSTANCE", componentId: "comp:1" }), - ctx - ); - expect(result).not.toBeNull(); - }); - - it("flags INSTANCE node whose component has an empty description", () => { - const ctx = makeContext({ - file: makeFile({ - "comp:1": { key: "comp:1", name: "Button", description: "" }, - }), - }); - - const result = missingComponentDescription.check( - makeNode({ id: "inst:1", type: "INSTANCE", componentId: "comp:1" }), - ctx - ); - - expect(result).not.toBeNull(); - expect(result!.ruleId).toBe("missing-component-description"); - expect(result!.nodeId).toBe("inst:1"); - expect(result!.message).toContain("Button"); - expect(result!.message).toContain("no description"); - }); - - it("includes the component name in the message", () => { - const ctx = makeContext({ - file: makeFile({ - "comp:42": { key: "comp:42", name: "Icon/Close", description: "" }, - }), - }); - - const result = missingComponentDescription.check( - makeNode({ type: "INSTANCE", componentId: "comp:42" }), - ctx - ); - - expect(result).not.toBeNull(); - expect(result!.message).toContain("Icon/Close"); - }); - - it("includes the node path in the violation", () => { - const ctx = makeContext({ - file: makeFile({ - "comp:1": { key: "comp:1", name: "Button", description: "" }, - }), - path: ["Page", "Section", "Card"], - }); - - const result = missingComponentDescription.check( - makeNode({ type: "INSTANCE", componentId: "comp:1" }), - ctx - ); - - expect(result).not.toBeNull(); - expect(result!.nodePath).toBe("Page > Section > Card"); - }); - - it("deduplicates: flags only once per unique componentId", () => { - const ctx = makeContext({ - file: makeFile({ - "comp:1": { key: "comp:1", name: "Button", description: "" }, - }), - }); - - const first = missingComponentDescription.check( - makeNode({ id: "inst:1", type: "INSTANCE", componentId: "comp:1" }), - ctx - ); - const second = missingComponentDescription.check( - makeNode({ id: "inst:2", type: "INSTANCE", componentId: "comp:1" }), - ctx - ); - const third = missingComponentDescription.check( - makeNode({ id: "inst:3", type: "INSTANCE", componentId: "comp:1" }), - ctx - ); - - expect(first).not.toBeNull(); - expect(second).toBeNull(); - expect(third).toBeNull(); - }); - - it("flags different components independently", () => { - const ctx = makeContext({ - file: makeFile({ - "comp:1": { key: "comp:1", name: "Button", description: "" }, - "comp:2": { key: "comp:2", name: "Input", description: "" }, - }), - }); - - const result1 = missingComponentDescription.check( - makeNode({ id: "inst:1", type: "INSTANCE", componentId: "comp:1" }), - ctx - ); - const result2 = missingComponentDescription.check( - makeNode({ id: "inst:2", type: "INSTANCE", componentId: "comp:2" }), - ctx - ); - - expect(result1).not.toBeNull(); - expect(result2).not.toBeNull(); - expect(result1!.message).toContain("Button"); - expect(result2!.message).toContain("Input"); - }); - - it("fresh analysisState clears dedup state between analysis runs", () => { - const ctx = makeContext({ - file: makeFile({ - "comp:1": { key: "comp:1", name: "Button", description: "" }, - }), - }); - - const first = missingComponentDescription.check( - makeNode({ id: "inst:1", type: "INSTANCE", componentId: "comp:1" }), - ctx - ); - expect(first).not.toBeNull(); - - // Fresh analysisState simulates a new analysis run — should flag again - analysisState = new Map(); - const freshCtx = makeContext({ - file: makeFile({ - "comp:1": { key: "comp:1", name: "Button", description: "" }, - }), - }); - - const second = missingComponentDescription.check( - makeNode({ id: "inst:1", type: "INSTANCE", componentId: "comp:1" }), - freshCtx - ); - expect(second).not.toBeNull(); - }); - - it("has correct rule definition metadata", () => { - const def = missingComponentDescription.definition; - expect(def.id).toBe("missing-component-description"); - expect(def.category).toBe("component"); - expect(def.why).toBeTruthy(); - expect(def.impact).toBeTruthy(); - expect(def.fix).toBeTruthy(); - }); -}); diff --git a/src/core/rules/component/index.ts b/src/core/rules/component/index.ts index 01bcfebf..211e9249 100644 --- a/src/core/rules/component/index.ts +++ b/src/core/rules/component/index.ts @@ -3,6 +3,7 @@ import { getAnalysisState } 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 { missingComponentMsg, detachedInstanceMsg, variantStructureMismatchMsg } from "../rule-messages.js"; // ============================================ // Helper functions @@ -107,7 +108,7 @@ function getSeenStage4(context: RuleContext): Set { const missingComponentDef: RuleDefinition = { id: "missing-component", name: "Missing Component", - category: "component", + category: "code-quality", why: "Repeated structures, unused components, and divergent instance overrides indicate missing or underutilized components. This inflates AI token consumption and forces manual maintenance.", impact: "AI code generators reproduce each repeated frame independently instead of emitting a reusable component. Divergent instance overrides produce inconsistent implementations.", fix: "Create components from repeated structures, use instances instead of duplicated frames, and create variants for instances with significantly different overrides.", @@ -141,9 +142,10 @@ const missingComponentCheck: RuleCheckFn = (node, context, options) => { if (firstFrame === node.id) { return { ruleId: missingComponentDef.id, + subType: "unused-component" as const, nodeId: node.id, nodePath: context.path.join(" > "), - message: `Component "${matchingComponent.name}" exists — use instances instead of repeated frames (${sameNameFrames.length} found) — replace frames with component instances`, + message: missingComponentMsg.unusedComponent(matchingComponent.name, sameNameFrames.length), }; } } @@ -158,9 +160,10 @@ const missingComponentCheck: RuleCheckFn = (node, context, options) => { if (firstFrame === node.id) { return { ruleId: missingComponentDef.id, + subType: "name-repetition" as const, nodeId: node.id, nodePath: context.path.join(" > "), - message: `"${node.name}" appears ${sameNameFrames.length} times — extract as a reusable component`, + message: missingComponentMsg.nameRepetition(node.name, sameNameFrames.length), }; } } @@ -219,9 +222,10 @@ const missingComponentCheck: RuleCheckFn = (node, context, options) => { if (firstMatchId === node.id) { return { ruleId: missingComponentDef.id, + subType: "structure-repetition" as const, nodeId: node.id, nodePath: context.path.join(" > "), - message: `"${node.name}" and ${count - 1} sibling frame(s) share the same internal structure — extract a shared component from the repeated structure`, + message: missingComponentMsg.structureRepetition(node.name, count - 1), }; } } @@ -255,9 +259,10 @@ const missingComponentCheck: RuleCheckFn = (node, context, options) => { return { ruleId: missingComponentDef.id, + subType: "style-override" as const, nodeId: node.id, nodePath: context.path.join(" > "), - message: `"${componentName}" instance has style overrides (${overrides.join(", ")}) — create a new variant for this style combination`, + message: missingComponentMsg.styleOverride(componentName, overrides), }; } return null; @@ -278,7 +283,7 @@ export const missingComponent = defineRule({ const detachedInstanceDef: RuleDefinition = { id: "detached-instance", name: "Detached Instance", - category: "component", + category: "code-quality", why: "Detached instances lose component relationship — AI sees a one-off frame instead of a reusable component reference", impact: "AI generates duplicate code instead of reusing the component, inflating output and causing inconsistencies", fix: "Reset the instance or create a new variant if customization is needed", @@ -301,7 +306,7 @@ const detachedInstanceCheck: RuleCheckFn = (node, context) => { ruleId: detachedInstanceDef.id, nodeId: node.id, nodePath: context.path.join(" > "), - message: `"${node.name}" may be a detached instance of component "${component.name}" — restore as an instance of "${component.name}" or create a new variant`, + message: detachedInstanceMsg(node.name, component.name), }; } } @@ -314,55 +319,6 @@ export const detachedInstance = defineRule({ check: detachedInstanceCheck, }); -// ============================================ -// missing-component-description -// ============================================ - -/** State key for per-analysis deduplication via RuleContext.analysisState */ -const SEEN_MISSING_DESC_KEY = "missing-component-description:seenComponentIds"; - -function getSeenMissingDescription(context: RuleContext): Set { - return getAnalysisState(context, SEEN_MISSING_DESC_KEY, () => new Set()); -} - -const missingComponentDescriptionDef: RuleDefinition = { - id: "missing-component-description", - name: "Missing Component Description", - category: "component", - why: "Component descriptions in Figma are the primary channel for communicating intent, usage guidelines, and prop expectations to developers. Without them, developers must reverse-engineer purpose from visual appearance alone.", - impact: "Increases implementation ambiguity, especially for icon-only components, compound components with multiple variants, and components whose names are variant key strings that give no prose context.", - fix: "Open the component in Figma, select it, and add a description in the right-hand panel under the component's properties. Include: what the component is, when to use it, any accessibility or interaction notes, and the owning team or design token set if applicable.", -}; - -const missingComponentDescriptionCheck: RuleCheckFn = (node, context) => { - if (node.type !== "INSTANCE") return null; - - const componentId = node.componentId; - if (!componentId) return null; - - const componentMeta = context.file.components[componentId]; - if (!componentMeta) return null; - - if (componentMeta.description.trim() !== "") return null; - - // Deduplicate: emit at most one issue per unique componentId - const seenDesc = getSeenMissingDescription(context); - if (seenDesc.has(componentId)) return null; - seenDesc.add(componentId); - - return { - ruleId: missingComponentDescriptionDef.id, - nodeId: node.id, - nodePath: context.path.join(" > "), - message: `Component "${componentMeta.name}" has no description — add usage guidelines in the component's description field`, - }; -}; - -export const missingComponentDescription = defineRule({ - definition: missingComponentDescriptionDef, - check: missingComponentDescriptionCheck, -}); - // ============================================ // variant-structure-mismatch // ============================================ @@ -370,7 +326,7 @@ export const missingComponentDescription = defineRule({ const variantStructureMismatchDef: RuleDefinition = { id: "variant-structure-mismatch", name: "Variant Structure Mismatch", - category: "component", + category: "code-quality", 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", @@ -401,7 +357,7 @@ const variantStructureMismatchCheck: RuleCheckFn = (node, context) => { ruleId: variantStructureMismatchDef.id, nodeId: node.id, nodePath: context.path.join(" > "), - message: `"${node.name}" has ${mismatchCount}/${totalVariants} variants with different child structures — unify variant structures using visibility toggles for optional elements`, + message: variantStructureMismatchMsg(node.name, mismatchCount, totalVariants), }; }; diff --git a/src/core/rules/component/missing-component.test.ts b/src/core/rules/component/missing-component.test.ts index 72f09056..9effff94 100644 --- a/src/core/rules/component/missing-component.test.ts +++ b/src/core/rules/component/missing-component.test.ts @@ -715,7 +715,7 @@ describe("missing-component — General", () => { it("has correct rule definition metadata", () => { const def = missingComponent.definition; expect(def.id).toBe("missing-component"); - expect(def.category).toBe("component"); + expect(def.category).toBe("code-quality"); expect(def.why).toBeTruthy(); expect(def.impact).toBeTruthy(); expect(def.fix).toBeTruthy(); diff --git a/src/core/rules/component/variant-structure-mismatch.test.ts b/src/core/rules/component/variant-structure-mismatch.test.ts index a2fe7c9b..5eaaeab9 100644 --- a/src/core/rules/component/variant-structure-mismatch.test.ts +++ b/src/core/rules/component/variant-structure-mismatch.test.ts @@ -4,7 +4,7 @@ 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"); + expect(variantStructureMismatch.definition.category).toBe("code-quality"); }); it("flags COMPONENT_SET with mismatched variant structures", () => { diff --git a/src/core/rules/config-loader.test.ts b/src/core/rules/config-loader.test.ts index 6aeca480..90bf1c22 100644 --- a/src/core/rules/config-loader.test.ts +++ b/src/core/rules/config-loader.test.ts @@ -22,7 +22,6 @@ describe("loadConfigFile", () => { excludeNodeTypes: ["SLICE"], excludeNodeNames: ["_ignore"], gridBase: 4, - colorTolerance: 5, rules: { "no-auto-layout": { score: -8, severity: "blocking", enabled: false }, }, @@ -32,7 +31,7 @@ describe("loadConfigFile", () => { const result = await loadConfigFile(filePath); expect(result.gridBase).toBe(4); - expect(result.colorTolerance).toBe(5); + expect(result.excludeNodeTypes).toEqual(["SLICE"]); expect(result.excludeNodeNames).toEqual(["_ignore"]); expect(result.rules?.["no-auto-layout"]?.score).toBe(-8); @@ -85,27 +84,32 @@ describe("loadConfigFile", () => { await expect(loadConfigFile(filePath)).rejects.toThrow(); }); + + it("throws for unknown rule ID", async () => { + const invalid = { + rules: { + "nonexistent-rule": { score: -5 }, + }, + }; + const filePath = join(tempDir, "unknown-rule.json"); + writeFileSync(filePath, JSON.stringify(invalid)); + + await expect(loadConfigFile(filePath)).rejects.toThrow(/Unknown rule ID[\s\S]*Valid IDs/i); + }); }); describe("mergeConfigs", () => { const baseConfigs: Record = { - "inconsistent-spacing": { - severity: "risk", - score: -3, - enabled: true, - options: { gridBase: 4 }, - }, - "magic-number-spacing": { - severity: "suggestion", - score: -1, + "irregular-spacing": { + severity: "missing-info", + score: -2, enabled: true, options: { gridBase: 4 }, }, - "multiple-fill-colors": { - severity: "risk", - score: -2, + "raw-value": { + severity: "missing-info", + score: -3, enabled: true, - options: { tolerance: 10 }, }, "no-auto-layout": { severity: "blocking", @@ -118,7 +122,7 @@ describe("mergeConfigs", () => { const overrides: ConfigFile = {}; const result = mergeConfigs(baseConfigs, overrides); - expect(result["inconsistent-spacing"]?.score).toBe(-3); + expect(result["irregular-spacing"]?.score).toBe(-2); expect(result["no-auto-layout"]?.score).toBe(-5); }); @@ -157,35 +161,19 @@ describe("mergeConfigs", () => { }); it("applies gridBase to rules with gridBase in options", () => { - const overrides: ConfigFile = { gridBase: 4 }; + const overrides: ConfigFile = { gridBase: 8 }; const result = mergeConfigs(baseConfigs, overrides); expect( - (result["inconsistent-spacing"]?.options as Record)?.["gridBase"] - ).toBe(4); - expect( - (result["magic-number-spacing"]?.options as Record)?.["gridBase"] - ).toBe(4); + (result["irregular-spacing"]?.options as Record)?.["gridBase"] + ).toBe(8); // no-auto-layout has no options, should be unaffected expect(result["no-auto-layout"]?.options).toBeUndefined(); }); - it("applies colorTolerance to rules with tolerance in options", () => { - const overrides: ConfigFile = { colorTolerance: 20 }; - const result = mergeConfigs(baseConfigs, overrides); - - expect( - (result["multiple-fill-colors"]?.options as Record)?.["tolerance"] - ).toBe(20); - // gridBase rules unaffected - expect( - (result["inconsistent-spacing"]?.options as Record)?.["gridBase"] - ).toBe(4); - }); - it("does not modify base configs object", () => { const overrides: ConfigFile = { - gridBase: 4, + gridBase: 8, rules: { "no-auto-layout": { score: -10 }, }, @@ -195,7 +183,7 @@ describe("mergeConfigs", () => { // Original should be unchanged expect(baseConfigs["no-auto-layout"]?.score).toBe(-5); expect( - (baseConfigs["inconsistent-spacing"]?.options as Record)?.["gridBase"] + (baseConfigs["irregular-spacing"]?.options as Record)?.["gridBase"] ).toBe(4); }); diff --git a/src/core/rules/config-loader.ts b/src/core/rules/config-loader.ts index 30dd6df5..8fe8cb01 100644 --- a/src/core/rules/config-loader.ts +++ b/src/core/rules/config-loader.ts @@ -3,6 +3,9 @@ import { resolve } from "node:path"; import { z } from "zod"; import { SeveritySchema } from "../contracts/severity.js"; import type { RuleConfig } from "../contracts/rule.js"; +import { RULE_CONFIGS } from "./rule-config.js"; + +const VALID_RULE_IDS = new Set(Object.keys(RULE_CONFIGS)); const RuleOverrideSchema = z.object({ score: z.number().int().max(0).optional(), @@ -14,8 +17,17 @@ const ConfigFileSchema = z.object({ excludeNodeTypes: z.array(z.string()).optional(), excludeNodeNames: z.array(z.string()).optional(), gridBase: z.number().int().positive().optional(), - colorTolerance: z.number().int().positive().optional(), - rules: z.record(z.string(), RuleOverrideSchema).optional(), + rules: z.record(z.string(), RuleOverrideSchema) + .superRefine((rules, ctx) => { + const unknown = Object.keys(rules).filter((id) => !VALID_RULE_IDS.has(id)); + if (unknown.length > 0) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: `Unknown rule ID(s): ${unknown.join(", ")}. Valid IDs: ${[...VALID_RULE_IDS].join(", ")}`, + }); + } + }) + .optional(), }); export type ConfigFile = z.infer; @@ -48,18 +60,6 @@ export function mergeConfigs( } } - // Apply global colorTolerance - if (overrides.colorTolerance !== undefined) { - for (const [id, config] of Object.entries(merged)) { - if (config.options && "tolerance" in config.options) { - merged[id] = { - ...config, - options: { ...config.options, tolerance: overrides.colorTolerance }, - }; - } - } - } - // Apply per-rule overrides if (overrides.rules) { for (const [ruleId, override] of Object.entries(overrides.rules)) { diff --git a/src/core/rules/index.ts b/src/core/rules/index.ts index 9527d0c5..25bc1f8a 100644 --- a/src/core/rules/index.ts +++ b/src/core/rules/index.ts @@ -9,4 +9,3 @@ export * from "./structure/index.js"; export * from "./token/index.js"; export * from "./component/index.js"; export * from "./naming/index.js"; -export * from "./behavior/index.js"; diff --git a/src/core/rules/naming/default-name.test.ts b/src/core/rules/naming/default-name.test.ts index 4ad49177..943333d5 100644 --- a/src/core/rules/naming/default-name.test.ts +++ b/src/core/rules/naming/default-name.test.ts @@ -4,7 +4,7 @@ import { defaultName } from "./index.js"; describe("default-name", () => { it("has correct rule definition metadata", () => { expect(defaultName.definition.id).toBe("default-name"); - expect(defaultName.definition.category).toBe("naming"); + expect(defaultName.definition.category).toBe("minor"); }); it.each([ diff --git a/src/core/rules/naming/inconsistent-naming-convention.test.ts b/src/core/rules/naming/inconsistent-naming-convention.test.ts index c587f920..f06e5ab8 100644 --- a/src/core/rules/naming/inconsistent-naming-convention.test.ts +++ b/src/core/rules/naming/inconsistent-naming-convention.test.ts @@ -4,7 +4,7 @@ import { inconsistentNamingConvention } from "./index.js"; describe("inconsistent-naming-convention", () => { it("has correct rule definition metadata", () => { expect(inconsistentNamingConvention.definition.id).toBe("inconsistent-naming-convention"); - expect(inconsistentNamingConvention.definition.category).toBe("naming"); + expect(inconsistentNamingConvention.definition.category).toBe("minor"); }); it("flags node with different convention from dominant siblings", () => { diff --git a/src/core/rules/naming/index.ts b/src/core/rules/naming/index.ts index b2e0c1e7..f6a8bb33 100644 --- a/src/core/rules/naming/index.ts +++ b/src/core/rules/naming/index.ts @@ -1,7 +1,7 @@ import type { RuleCheckFn, RuleDefinition } from "../../contracts/rule.js"; import { defineRule } from "../rule-registry.js"; -import { getRuleOption } from "../rule-config.js"; import { isExcludedName } from "../excluded-names.js"; +import { defaultNameMsg, getDefaultNameSubType, nonSemanticNameMsg, inconsistentNamingMsg } from "../rule-messages.js"; // ============================================ // Helper functions @@ -43,10 +43,6 @@ function isNonSemanticName(name: string): boolean { return NON_SEMANTIC_NAMES.includes(normalized); } -function hasNumericSuffix(name: string): boolean { - return /\s+\d+$/.test(name); -} - function detectNamingConvention(name: string): string | null { if (/^[a-z]+(-[a-z]+)*$/.test(name)) return "kebab-case"; if (/^[a-z]+(_[a-z]+)*$/.test(name)) return "snake_case"; @@ -64,7 +60,7 @@ function detectNamingConvention(name: string): string | null { const defaultNameDef: RuleDefinition = { id: "default-name", name: "Default Name", - category: "naming", + category: "minor", why: "Default names like 'Frame 123' give AI no semantic context to choose appropriate HTML tags or class names", impact: "AI generates generic
wrappers instead of semantic elements like
,