diff --git a/docs/REFERENCE.md b/docs/REFERENCE.md index bda29787..f8bbc089 100644 --- a/docs/REFERENCE.md +++ b/docs/REFERENCE.md @@ -129,10 +129,11 @@ Override score, severity, or enable/disable individual rules: | `missing-interaction-state` | -3 | missing-info | | `missing-prototype` | -3 | missing-info | -**Minor (3 rules)** — naming issues with negligible impact (ΔV < 2%) +**Minor (4 rules)** — naming issues with negligible impact (ΔV < 2%) | Rule ID | Default Score | Default Severity | |---------|--------------|-----------------| +| `non-standard-naming` | -1 | suggestion | | `default-name` | -1 | suggestion | | `non-semantic-name` | -1 | suggestion | | `inconsistent-naming-convention` | -1 | suggestion | diff --git a/src/core/contracts/rule.ts b/src/core/contracts/rule.ts index 96fec5a3..c9bd4753 100644 --- a/src/core/contracts/rule.ts +++ b/src/core/contracts/rule.ts @@ -110,6 +110,7 @@ export type RuleId = | "missing-interaction-state" | "missing-prototype" // Minor — naming issues with negligible impact (ΔV < 2%) + | "non-standard-naming" | "default-name" | "non-semantic-name" | "inconsistent-naming-convention"; diff --git a/src/core/engine/scoring.test.ts b/src/core/engine/scoring.test.ts index e987e2e0..19cfa425 100644 --- a/src/core/engine/scoring.test.ts +++ b/src/core/engine/scoring.test.ts @@ -304,7 +304,7 @@ describe("calculateGrade (via calculateScores)", () => { "code-quality": ["missing-component", "detached-instance", "variant-structure-mismatch", "deep-nesting"], "token-management": ["raw-value", "irregular-spacing"], "interaction": ["missing-interaction-state", "missing-prototype"], - "minor": ["default-name", "non-semantic-name", "inconsistent-naming-convention"], + "minor": ["non-standard-naming", "default-name", "non-semantic-name", "inconsistent-naming-convention"], }; for (const cat of categories) { diff --git a/src/core/rules/naming/index.ts b/src/core/rules/naming/index.ts index 244bbe17..14514891 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 { defaultNameMsg, getDefaultNameSubType, nonSemanticNameMsg, inconsistentNamingMsg } from "../rule-messages.js"; -import { isExcludedName, isDefaultName, isNonSemanticName } from "../node-semantics.js"; +import { defaultNameMsg, getDefaultNameSubType, nonSemanticNameMsg, inconsistentNamingMsg, nonStandardNamingMsg } from "../rule-messages.js"; +import { isExcludedName, isDefaultName, isNonSemanticName, STANDARD_STATE_NAMES, STATE_NAME_SUGGESTIONS, STATE_LIKE_PATTERN } from "../node-semantics.js"; function detectNamingConvention(name: string): string | null { if (/^[a-z]+(-[a-z]+)*$/.test(name)) return "kebab-case"; @@ -141,3 +141,58 @@ export const inconsistentNamingConvention = defineRule({ check: inconsistentNamingConventionCheck, }); +// ============================================ +// non-standard-naming +// ============================================ + +const nonStandardNamingDef: RuleDefinition = { + id: "non-standard-naming", + name: "Non-Standard Naming", + category: "minor", + why: "Non-standard state names prevent interaction rules from detecting state variants — AI cannot generate correct :hover/:active/:disabled styles", + impact: "Interaction state detection fails, resulting in static UI with no state transitions", + fix: "Use platform-standard state names: default, hover, active, pressed, selected, highlighted, disabled, enabled, focus, focused, dragged", +}; + +const nonStandardNamingCheck: RuleCheckFn = (node, context) => { + // Only check COMPONENT_SET (variant container) + if (node.type !== "COMPONENT_SET") return null; + if (!node.componentPropertyDefinitions) return null; + + for (const prop of Object.values(node.componentPropertyDefinitions)) { + const p = prop as Record; + if (p["type"] !== "VARIANT") continue; + const options = p["variantOptions"]; + if (!Array.isArray(options)) continue; + + for (const opt of options) { + if (typeof opt !== "string") continue; + const lower = opt.toLowerCase().trim(); + + // Skip if it's a standard name + if (STANDARD_STATE_NAMES.has(lower)) continue; + + // Check if it matches a known non-standard state name + if (STATE_LIKE_PATTERN.test(opt)) { + const suggestion = STATE_NAME_SUGGESTIONS[lower]; + if (suggestion) { + return { + ruleId: nonStandardNamingDef.id, + subType: "state-name" as const, + nodeId: node.id, + nodePath: context.path.join(" > "), + message: nonStandardNamingMsg.stateName(node.name, opt, suggestion), + }; + } + } + } + } + + return null; +}; + +export const nonStandardNaming = defineRule({ + definition: nonStandardNamingDef, + check: nonStandardNamingCheck, +}); + diff --git a/src/core/rules/naming/non-standard-naming.test.ts b/src/core/rules/naming/non-standard-naming.test.ts new file mode 100644 index 00000000..42274e2f --- /dev/null +++ b/src/core/rules/naming/non-standard-naming.test.ts @@ -0,0 +1,124 @@ +import { makeNode, makeFile, makeContext } from "../test-helpers.js"; +import { nonStandardNaming } from "./index.js"; + +describe("non-standard-naming", () => { + it("has correct rule definition metadata", () => { + const def = nonStandardNaming.definition; + expect(def.id).toBe("non-standard-naming"); + expect(def.category).toBe("minor"); + }); + + it("flags non-standard state name 'Clicked'", () => { + const node = makeNode({ + id: "1:1", + name: "Button", + type: "COMPONENT_SET", + componentPropertyDefinitions: { + "State": { type: "VARIANT", variantOptions: ["Default", "Clicked", "Disabled"] }, + }, + }); + const ctx = makeContext(); + const result = nonStandardNaming.check(node, ctx); + + expect(result).not.toBeNull(); + expect(result!.subType).toBe("state-name"); + expect(result!.message).toContain("Clicked"); + expect(result!.message).toContain("pressed"); + }); + + it("flags 'On' → suggests 'active'", () => { + const node = makeNode({ + id: "1:1", + name: "Toggle", + type: "COMPONENT_SET", + componentPropertyDefinitions: { + "State": { type: "VARIANT", variantOptions: ["Default", "On"] }, + }, + }); + const ctx = makeContext(); + const result = nonStandardNaming.check(node, ctx); + + expect(result).not.toBeNull(); + expect(result!.message).toContain("On"); + expect(result!.message).toContain("active"); + }); + + it("flags 'Inactive' → suggests 'disabled'", () => { + const node = makeNode({ + id: "1:1", + name: "Toggle", + type: "COMPONENT_SET", + componentPropertyDefinitions: { + "State": { type: "VARIANT", variantOptions: ["Default", "Inactive"] }, + }, + }); + const ctx = makeContext(); + const result = nonStandardNaming.check(node, ctx); + + expect(result).not.toBeNull(); + expect(result!.message).toContain("Inactive"); + expect(result!.message).toContain("disabled"); + }); + + it("passes when all state names are standard", () => { + const node = makeNode({ + id: "1:1", + name: "Button", + type: "COMPONENT_SET", + componentPropertyDefinitions: { + "State": { type: "VARIANT", variantOptions: ["Default", "Hover", "Pressed", "Disabled"] }, + }, + }); + const ctx = makeContext(); + expect(nonStandardNaming.check(node, ctx)).toBeNull(); + }); + + it("passes for non-state variant properties", () => { + const node = makeNode({ + id: "1:1", + name: "Button", + type: "COMPONENT_SET", + componentPropertyDefinitions: { + "Size": { type: "VARIANT", variantOptions: ["Small", "Medium", "Large"] }, + }, + }); + const ctx = makeContext(); + expect(nonStandardNaming.check(node, ctx)).toBeNull(); + }); + + it("skips non-COMPONENT_SET nodes", () => { + const node = makeNode({ + id: "1:1", + name: "Button", + type: "INSTANCE", + }); + const ctx = makeContext(); + expect(nonStandardNaming.check(node, ctx)).toBeNull(); + }); + + it("accepts platform-standard names (pressed, selected, highlighted, focused)", () => { + const node = makeNode({ + id: "1:1", + name: "Nav Item", + type: "COMPONENT_SET", + componentPropertyDefinitions: { + "State": { type: "VARIANT", variantOptions: ["Default", "Pressed", "Selected", "Highlighted", "Focused"] }, + }, + }); + const ctx = makeContext(); + expect(nonStandardNaming.check(node, ctx)).toBeNull(); + }); + + it("accepts enabled and dragged (CSS/Material standard)", () => { + const node = makeNode({ + id: "1:1", + name: "Chip", + type: "COMPONENT_SET", + componentPropertyDefinitions: { + "State": { type: "VARIANT", variantOptions: ["Enabled", "Dragged", "Disabled"] }, + }, + }); + const ctx = makeContext(); + expect(nonStandardNaming.check(node, ctx)).toBeNull(); + }); +}); diff --git a/src/core/rules/node-semantics.ts b/src/core/rules/node-semantics.ts index f8daad83..f3b92cb0 100644 --- a/src/core/rules/node-semantics.ts +++ b/src/core/rules/node-semantics.ts @@ -103,6 +103,48 @@ export function isStatefulComponent(node: AnalysisNode): boolean { return getStatefulComponentType(node) !== null; } +/** + * Standard state names across web + mobile platforms. + * Used by missing-interaction-state (to detect presence) and + * non-standard-naming (to flag non-standard names). + * + * Sources: + * - CSS: :hover, :active, :focus, :disabled, :enabled — https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Selectors/Pseudo-classes + * - Material Design 3: Hovered, Focused, Pressed, Dragged — https://m3.material.io/foundations/interaction/states + * - UIKit: highlighted, selected, disabled, focused — https://developer.apple.com/documentation/uikit/uicontrol/state + */ +export const STANDARD_STATE_NAMES = new Set([ + // CSS pseudo-classes (web) + "default", "hover", "active", "focus", "focused", "disabled", "enabled", + // Material Design 3 (Android) + "pressed", "dragged", + // UIKit (iOS) + "highlighted", + // Common + "selected", +]); + +/** + * Patterns that look like state names but aren't in the standard set. + * Maps common non-standard names to their standard equivalent for suggestions. + */ +export const STATE_NAME_SUGGESTIONS: Record = { + on: "active", + off: "default", + clicked: "pressed", + tapped: "pressed", + inactive: "disabled", + normal: "default", + rest: "default", + hovered: "hover", + activated: "active", + checked: "selected", + unchecked: "default", +}; + +/** Pattern to detect state-like variant option names (broad match) */ +export const STATE_LIKE_PATTERN = /\b(on|off|clicked|tapped|inactive|normal|rest|hovered|activated|checked|unchecked)\b/i; + // ── Overlay / Carousel patterns ────────────────────────────────────────────── /** Elements that open on top of current view */ diff --git a/src/core/rules/rule-config.ts b/src/core/rules/rule-config.ts index 950d502f..fdccad0b 100644 --- a/src/core/rules/rule-config.ts +++ b/src/core/rules/rule-config.ts @@ -30,6 +30,7 @@ export const RULE_ID_CATEGORY: Record = { "missing-interaction-state": "interaction", "missing-prototype": "interaction", // Minor + "non-standard-naming": "minor", "default-name": "minor", "non-semantic-name": "minor", "inconsistent-naming-convention": "minor", @@ -135,6 +136,11 @@ export const RULE_CONFIGS: Record = { }, // ── Minor ── + "non-standard-naming": { + severity: "suggestion", + score: -1, + enabled: true, + }, "default-name": { severity: "suggestion", score: -1, diff --git a/src/core/rules/rule-messages.ts b/src/core/rules/rule-messages.ts index 29970772..97e8cc4b 100644 --- a/src/core/rules/rule-messages.ts +++ b/src/core/rules/rule-messages.ts @@ -111,6 +111,15 @@ export const detachedInstanceMsg = (name: string, componentName: string) => export const variantStructureMismatchMsg = (name: string, mismatchCount: number, totalVariants: number) => `"${name}" has ${mismatchCount}/${totalVariants} variants with different child structures — unify variant structures using visibility toggles for optional elements`; +// ── non-standard-naming ────────────────────────────────────────────────────── + +export type NonStandardNamingSubType = "state-name"; + +export const nonStandardNamingMsg = { + stateName: (name: string, nonStandard: string, suggestion: string) => + `"${name}" has non-standard state name "${nonStandard}" — use "${suggestion}" for dev-friendly naming`, +}; + // ── default-name ───────────────────────────────────────────────────────────── export type DefaultNameSubType = "frame" | "group" | "vector" | "shape" | "text" | "image" | "component" | "instance";