diff --git a/docs/REFERENCE.md b/docs/REFERENCE.md index f8bbc089..0bc83763 100644 --- a/docs/REFERENCE.md +++ b/docs/REFERENCE.md @@ -129,12 +129,11 @@ Override score, severity, or enable/disable individual rules: | `missing-interaction-state` | -3 | missing-info | | `missing-prototype` | -3 | missing-info | -**Minor (4 rules)** — naming issues with negligible impact (ΔV < 2%) +**Minor (3 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/cli/docs.ts b/src/cli/docs.ts index 20855a0e..b18804b5 100644 --- a/src/cli/docs.ts +++ b/src/cli/docs.ts @@ -101,7 +101,7 @@ EXAMPLE "rules": { "no-auto-layout": { "score": -15, "severity": "blocking" }, "raw-value": { "score": -5 }, - "default-name": { "enabled": false } + "non-semantic-name": { "enabled": false } } } diff --git a/src/core/contracts/rule.ts b/src/core/contracts/rule.ts index c9bd4753..5cb211b2 100644 --- a/src/core/contracts/rule.ts +++ b/src/core/contracts/rule.ts @@ -111,7 +111,6 @@ export type RuleId = | "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/rule-engine.test.ts b/src/core/engine/rule-engine.test.ts index 35d4130d..0395c72e 100644 --- a/src/core/engine/rule-engine.test.ts +++ b/src/core/engine/rule-engine.test.ts @@ -206,14 +206,14 @@ describe("RuleEngine.analyze — rule filtering", () => { const file = makeFile({ document: doc }); // Enable only default-name rule - const result = analyzeFile(file, { enabledRules: ["default-name"] }); + const result = analyzeFile(file, { enabledRules: ["non-semantic-name"] }); // Must have at least one issue to avoid vacuous pass expect(result.issues.length).toBeGreaterThan(0); // All issues should be from default-name only for (const issue of result.issues) { - expect(issue.violation.ruleId).toBe("default-name"); + expect(issue.violation.ruleId).toBe("non-semantic-name"); } }); @@ -229,13 +229,13 @@ describe("RuleEngine.analyze — rule filtering", () => { const file = makeFile({ document: doc }); const resultAll = analyzeFile(file); - const resultDisabled = analyzeFile(file, { disabledRules: ["default-name"] }); + const resultDisabled = analyzeFile(file, { disabledRules: ["non-semantic-name"] }); const defaultNameAll = resultAll.issues.filter( - (i) => i.violation.ruleId === "default-name" + (i) => i.violation.ruleId === "non-semantic-name" ); const defaultNameDisabled = resultDisabled.issues.filter( - (i) => i.violation.ruleId === "default-name" + (i) => i.violation.ruleId === "non-semantic-name" ); // Baseline must have default-name issues to validate the filter @@ -392,7 +392,7 @@ describe("RuleEngine.analyze — error resilience", () => { const file = makeFile({ document: doc }); // Make an existing rule's check throw to verify error resilience - const defaultNameRule = ruleRegistry.get("default-name"); + const defaultNameRule = ruleRegistry.get("non-semantic-name"); expect(defaultNameRule).toBeDefined(); const originalCheck = defaultNameRule!.check; @@ -407,7 +407,7 @@ describe("RuleEngine.analyze — error resilience", () => { // failedRules should contain the failure details expect(result.failedRules.length).toBeGreaterThan(0); - const failure = result.failedRules.find((f) => f.ruleId === "default-name"); + const failure = result.failedRules.find((f) => f.ruleId === "non-semantic-name"); expect(failure).toBeDefined(); expect(failure!.error).toBe("boom"); expect(failure!.nodeName).toBeDefined(); diff --git a/src/core/engine/scoring.test.ts b/src/core/engine/scoring.test.ts index 19cfa425..68e502ec 100644 --- a/src/core/engine/scoring.test.ts +++ b/src/core/engine/scoring.test.ts @@ -75,7 +75,7 @@ describe("calculateScores", () => { 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" }), + makeIssue({ ruleId: "non-semantic-name", category: "minor", severity: "suggestion" }), ]; const scores = calculateScores(makeResult(issues)); @@ -90,7 +90,7 @@ describe("calculateScores", () => { const heavyIssue = makeIssue({ ruleId: "no-auto-layout", category: "pixel-critical", severity: "blocking", score: -10 }); heavyIssue.calculatedScore = -15; // Simulate depthWeight effect - const lightIssue = makeIssue({ ruleId: "default-name", category: "minor", severity: "suggestion", score: -1 }); + const lightIssue = makeIssue({ ruleId: "non-semantic-name", category: "minor", severity: "suggestion", score: -1 }); lightIssue.calculatedScore = -1; const heavy = calculateScores(makeResult([heavyIssue], 100)); @@ -159,7 +159,7 @@ describe("calculateScores", () => { ], 100)); const lightRule = calculateScores(makeResult([ - makeIssue({ ruleId: "default-name", category: "minor", severity: "suggestion", score: -1 }), + makeIssue({ ruleId: "non-semantic-name", category: "minor", severity: "suggestion", score: -1 }), ], 100)); expect(heavyRule.byCategory["pixel-critical"].diversityScore).toBeLessThan( @@ -169,7 +169,7 @@ describe("calculateScores", () => { it("low-severity rules have minimal diversity impact (intentional)", () => { const lowSeverity = calculateScores(makeResult([ - makeIssue({ ruleId: "default-name", category: "minor", severity: "suggestion", score: -1 }), + makeIssue({ ruleId: "non-semantic-name", category: "minor", severity: "suggestion", score: -1 }), ], 100)); const highSeverity = calculateScores(makeResult([ @@ -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": ["non-standard-naming", "default-name", "non-semantic-name", "inconsistent-naming-convention"], + "minor": ["non-standard-naming", "non-semantic-name", "inconsistent-naming-convention"], }; for (const cat of categories) { diff --git a/src/core/engine/scoring.ts b/src/core/engine/scoring.ts index c8a5dbed..0b0d2dd6 100644 --- a/src/core/engine/scoring.ts +++ b/src/core/engine/scoring.ts @@ -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 `default-name` (score: -1, no depthWeight) contributes 1. + * to density, while `non-semantic-name` (score: -1, no depthWeight) contributes 1. * This makes calibration loop score adjustments flow through to user-facing scores. */ diff --git a/src/core/rules/component/detached-instance.test.ts b/src/core/rules/component/detached-instance.test.ts index 4317eadd..ae9937a5 100644 --- a/src/core/rules/component/detached-instance.test.ts +++ b/src/core/rules/component/detached-instance.test.ts @@ -47,6 +47,26 @@ describe("detached-instance", () => { expect(detachedInstance.check(node, makeContext({ file }))).toBeNull(); }); + it("case-sensitive: 'button' does not match component 'Button'", () => { + const file = makeFile({ + components: { + "comp-1": { key: "k1", name: "Button", description: "" }, + }, + }); + const node = makeNode({ type: "FRAME", name: "button" }); + expect(detachedInstance.check(node, makeContext({ file }))).toBeNull(); + }); + + it("word boundary: 'Discard' does not match component 'Card'", () => { + const file = makeFile({ + components: { + "comp-1": { key: "k1", name: "Card", description: "" }, + }, + }); + const node = makeNode({ type: "FRAME", name: "Discard" }); + expect(detachedInstance.check(node, makeContext({ file }))).toBeNull(); + }); + it("returns null when file has no components", () => { const node = makeNode({ type: "FRAME", name: "SomeFrame" }); expect(detachedInstance.check(node, makeContext())).toBeNull(); diff --git a/src/core/rules/component/index.ts b/src/core/rules/component/index.ts index 211e9249..31f5bcfa 100644 --- a/src/core/rules/component/index.ts +++ b/src/core/rules/component/index.ts @@ -295,12 +295,12 @@ const detachedInstanceCheck: RuleCheckFn = (node, context) => { // Heuristic: Frame with a name that looks like it came from a component if (node.type !== "FRAME") return null; - // Check if there's a component in the file with a similar name + // Check if there's a component in the file with a matching name (word boundary) const components = context.file.components; - const nodeName = node.name.toLowerCase(); for (const [, component] of Object.entries(components)) { - if (nodeName.includes(component.name.toLowerCase())) { + const pattern = new RegExp(`\\b${component.name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\b`); + if (pattern.test(node.name)) { // This frame might be a detached instance of this component return { ruleId: detachedInstanceDef.id, diff --git a/src/core/rules/component/missing-component.test.ts b/src/core/rules/component/missing-component.test.ts index 9effff94..17b2bd18 100644 --- a/src/core/rules/component/missing-component.test.ts +++ b/src/core/rules/component/missing-component.test.ts @@ -74,7 +74,7 @@ describe("missing-component — Stage 1: Component exists but not used", () => { id: "0:1", name: "Document", type: "DOCUMENT", - children: [frame, makeNode({ id: "f:2", name: "Card" })], + children: [frame], }); const ctx = makeContext({ @@ -118,11 +118,12 @@ describe("missing-component — Stage 1: Component exists but not used", () => { it("only flags first occurrence (dedup)", () => { const frameA = makeNode({ id: "f:1", name: "Button" }); const frameB = makeNode({ id: "f:2", name: "Button" }); + const frameC = makeNode({ id: "f:3", name: "Button" }); const doc = makeNode({ id: "0:1", name: "Document", type: "DOCUMENT", - children: [frameA, frameB], + children: [frameA, frameB, frameC], }); const ctx = makeContext({ @@ -134,12 +135,13 @@ describe("missing-component — Stage 1: Component exists but not used", () => { }), }); - // First call flags + // First call on frameA flags (stage 1: unused-component, frameA is first frame) const result1 = missingComponent.check(frameA, ctx); expect(result1).not.toBeNull(); + expect(result1!.subType).toBe("unused-component"); - // Second call with same name is deduped - const result2 = missingComponent.check(frameA, ctx); + // frameB is not first frame → stage 1 deduped, stage 2 skips (not first frame) + const result2 = missingComponent.check(frameB, ctx); expect(result2).toBeNull(); }); @@ -179,19 +181,18 @@ describe("missing-component — Stage 2: Name-based repetition", () => { it("returns null below minRepetitions threshold", () => { const frameA = makeNode({ id: "f:1", name: "Card" }); - const frameB = makeNode({ id: "f:2", name: "Card" }); const doc = makeNode({ id: "0:1", name: "Document", type: "DOCUMENT", - children: [frameA, frameB], + children: [frameA], }); const ctx = makeContext({ file: makeFile({ document: doc }), }); - // Default minRepetitions is 3, only 2 frames + // Default minRepetitions is 2, only 1 frame expect(missingComponent.check(frameA, ctx)).toBeNull(); }); @@ -724,11 +725,12 @@ describe("missing-component — General", () => { it("fresh analysisState clears dedup state", () => { const frameA = makeNode({ id: "f:1", name: "Button" }); const frameB = makeNode({ id: "f:2", name: "Button" }); + const frameC = makeNode({ id: "f:3", name: "Button" }); const doc = makeNode({ id: "0:1", name: "Document", type: "DOCUMENT", - children: [frameA, frameB], + children: [frameA, frameB, frameC], }); const file = makeFile({ @@ -742,9 +744,8 @@ describe("missing-component — General", () => { // First call flags (Stage 1) expect(missingComponent.check(frameA, ctx)).not.toBeNull(); - - // Deduped - expect(missingComponent.check(frameA, ctx)).toBeNull(); + // frameB: not first frame → deduped + expect(missingComponent.check(frameB, ctx)).toBeNull(); // Fresh analysisState simulates a new analysis run — should flag again analysisState = new Map(); diff --git a/src/core/rules/naming/default-name.test.ts b/src/core/rules/naming/default-name.test.ts deleted file mode 100644 index 943333d5..00000000 --- a/src/core/rules/naming/default-name.test.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { makeNode, makeFile, makeContext } from "../test-helpers.js"; -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("minor"); - }); - - it.each([ - "Frame 1", - "Frame", - "Group 12", - "Ellipse", - "Vector 1", - "Line 5", - "Text 2", - "Component 1", - "Instance 3", - ])("flags default name: %s", (name) => { - const node = makeNode({ name }); - const result = defaultName.check(node, makeContext()); - expect(result).not.toBeNull(); - expect(result!.ruleId).toBe("default-name"); - }); - - it("returns null for semantic names", () => { - const node = makeNode({ name: "ProductCard" }); - expect(defaultName.check(node, makeContext())).toBeNull(); - }); - - it("returns null for excluded name patterns", () => { - const node = makeNode({ name: "Icon Badge" }); - expect(defaultName.check(node, makeContext())).toBeNull(); - }); - - it("returns null when name is empty", () => { - const node = makeNode({ name: "" }); - expect(defaultName.check(node, makeContext())).toBeNull(); - }); -}); diff --git a/src/core/rules/naming/index.ts b/src/core/rules/naming/index.ts index 14514891..0d784c5d 100644 --- a/src/core/rules/naming/index.ts +++ b/src/core/rules/naming/index.ts @@ -1,6 +1,6 @@ import type { RuleCheckFn, RuleDefinition } from "../../contracts/rule.js"; import { defineRule } from "../rule-registry.js"; -import { defaultNameMsg, getDefaultNameSubType, nonSemanticNameMsg, inconsistentNamingMsg, nonStandardNamingMsg } from "../rule-messages.js"; +import { 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 { @@ -14,67 +14,51 @@ function detectNamingConvention(name: string): string | null { } // ============================================ -// default-name -// ============================================ - -const defaultNameDef: RuleDefinition = { - id: "default-name", - name: "Default Name", - 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
,