Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions docs/REFERENCE.md
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,13 @@ Override score, severity, or enable/disable individual rules:
| `raw-value` | -3 | missing-info |
| `irregular-spacing` | -2 | missing-info |

**Interaction (2 rules)** — missing state variants and prototypes for interactive components

| Rule ID | Default Score | Default Severity |
|---------|--------------|-----------------|
| `missing-interaction-state` | -3 | missing-info |
| `missing-prototype` | -3 | missing-info |

**Minor (3 rules)** — naming issues with negligible impact (ΔV < 2%)

| Rule ID | Default Score | Default Severity |
Expand Down
2 changes: 2 additions & 0 deletions src/core/contracts/category.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ export const CategorySchema = z.enum([
"responsive-critical",
"code-quality",
"token-management",
"interaction",
"minor",
]);

Expand All @@ -17,5 +18,6 @@ export const CATEGORY_LABELS: Record<Category, string> = {
"responsive-critical": "Responsive Critical",
"code-quality": "Code Quality",
"token-management": "Token Management",
"interaction": "Interaction",
"minor": "Minor",
};
3 changes: 3 additions & 0 deletions src/core/contracts/rule.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,9 @@ export type RuleId =
// Token Management — raw values without design tokens
| "raw-value"
| "irregular-spacing"
// Interaction — missing state variants and prototype links for interactive components
| "missing-interaction-state"
| "missing-prototype"
// Minor — naming issues with negligible impact (ΔV < 2%)
| "default-name"
| "non-semantic-name"
Expand Down
4 changes: 2 additions & 2 deletions src/core/engine/integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,10 +71,10 @@ describe("Integration: fixture → analyze → score", () => {
expect(scores.overall.score).toBe(scores.overall.percentage);
expect(scores.overall.maxScore).toBe(100);

// Exactly 5 categories present
// Exactly 6 categories present
const categories = Object.keys(scores.byCategory).sort();
expect(categories).toEqual(
["code-quality", "minor", "pixel-critical", "responsive-critical", "token-management"],
["code-quality", "interaction", "minor", "pixel-critical", "responsive-critical", "token-management"],
);

// Each category has valid percentages
Expand Down
7 changes: 5 additions & 2 deletions src/core/engine/scoring.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -216,6 +216,7 @@ describe("calculateScores", () => {

expect(scores.byCategory["token-management"].percentage).toBe(100);
expect(scores.byCategory["code-quality"].percentage).toBe(100);
expect(scores.byCategory["interaction"].percentage).toBe(100);
expect(scores.byCategory["minor"].percentage).toBe(100);
expect(scores.byCategory["responsive-critical"].percentage).toBe(100);
});
Expand All @@ -230,10 +231,11 @@ describe("calculateScores", () => {
scores.byCategory["responsive-critical"].percentage,
scores.byCategory["code-quality"].percentage,
scores.byCategory["token-management"].percentage,
scores.byCategory["interaction"].percentage,
scores.byCategory["minor"].percentage,
];
const expectedOverall = Math.round(
categoryPercentages.reduce((a, b) => a + b, 0) / 5
categoryPercentages.reduce((a, b) => a + b, 0) / 6
);
expect(scores.overall.percentage).toBe(expectedOverall);
});
Expand Down Expand Up @@ -295,12 +297,13 @@ describe("calculateGrade (via calculateScores)", () => {

it("score < 50% -> F", () => {
const issues: AnalysisIssue[] = [];
const categories: Category[] = ["pixel-critical", "responsive-critical", "code-quality", "token-management", "minor"];
const categories: Category[] = ["pixel-critical", "responsive-critical", "code-quality", "token-management", "interaction", "minor"];
const rulesPerCat: Record<Category, string[]> = {
"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"],
"interaction": ["missing-interaction-state", "missing-prototype"],
"minor": ["default-name", "non-semantic-name", "inconsistent-naming-convention"],
};

Expand Down
1 change: 1 addition & 0 deletions src/core/engine/scoring.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,7 @@ const CATEGORY_WEIGHT: Record<Category, number> = {
"responsive-critical": 1.0,
"code-quality": 1.0,
"token-management": 1.0,
"interaction": 1.0,
"minor": 1.0,
};

Expand Down
1 change: 1 addition & 0 deletions src/core/rules/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,4 @@ export * from "./structure/index.js";
export * from "./token/index.js";
export * from "./component/index.js";
export * from "./naming/index.js";
export * from "./interaction/index.js";
254 changes: 254 additions & 0 deletions src/core/rules/interaction/index.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,254 @@
import { makeNode, makeFile, makeContext } from "../test-helpers.js";
import { missingInteractionState, missingPrototype } from "./index.js";

// ============================================
// missing-interaction-state
// ============================================

describe("missing-interaction-state", () => {
it("has correct rule definition metadata", () => {
const def = missingInteractionState.definition;
expect(def.id).toBe("missing-interaction-state");
expect(def.category).toBe("interaction");
});

it("flags INSTANCE button without hover variant", () => {
const node = makeNode({ id: "1:1", name: "Primary Button", type: "INSTANCE", componentId: "c:1" });
const ctx = makeContext({ path: ["Page", "Button"] });
const result = missingInteractionState.check(node, ctx);

expect(result).not.toBeNull();
expect(result!.subType).toBe("hover");
expect(result!.message).toContain("Hover");
});

it("skips non-interactive names", () => {
const node = makeNode({ id: "1:1", name: "Card", type: "INSTANCE", componentId: "c:1" });
const ctx = makeContext();
expect(missingInteractionState.check(node, ctx)).toBeNull();
});

it("skips FRAME nodes (only INSTANCE/COMPONENT)", () => {
const node = makeNode({ id: "1:1", name: "Button", type: "FRAME" });
const ctx = makeContext();
expect(missingInteractionState.check(node, ctx)).toBeNull();
});

it("passes when variant property has pressed option (active subType)", () => {
const node = makeNode({
id: "1:1",
name: "Button",
type: "INSTANCE",
componentId: "c:1",
componentPropertyDefinitions: {
"State": { type: "VARIANT", variantOptions: ["Default", "Hover", "Pressed", "Disabled"] },
},
});
const ctx = makeContext();
expect(missingInteractionState.check(node, ctx)).toBeNull();
});

it("passes when variant property has hover option", () => {
const node = makeNode({
id: "1:1",
name: "Button",
type: "INSTANCE",
componentId: "c:1",
componentPropertyDefinitions: {
"State": { type: "VARIANT", variantOptions: ["Default", "Hover", "Pressed", "Disabled"] },
},
});
const ctx = makeContext();
expect(missingInteractionState.check(node, ctx)).toBeNull();
});
Comment on lines +51 to +63
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial

Consider adding test coverage for active/pressed state detection.

This test verifies that a button with State=Hover passes, but per issue #151, buttons should also have Pressed state. The current EXPECTED_STATES.button in the implementation only checks ["hover", "disabled"], not "active". Consider adding a test that verifies buttons with State=Pressed are recognized (once that state is added to expected states).

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/core/rules/interaction/index.test.ts` around lines 37 - 49, Add coverage
for the pressed/active interaction state: create a new test (similar to the
existing one using makeNode and makeContext) where componentPropertyDefinitions
State includes "Pressed" (or "Active") and assert
missingInteractionState.check(node, ctx) returns null; then update the
implementation's EXPECTED_STATES.button (and any logic in
missingInteractionState) to include the "pressed"/"active" state name used by
your design so the rule recognizes pressed as a valid interaction state.

Comment on lines +37 to +63
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot Mar 29, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial

Consider consolidating duplicate test setups.

Tests at lines 37-49 and 51-63 use identical componentPropertyDefinitions with ["Default", "Hover", "Pressed", "Disabled"]. Both verify the rule passes when required variants exist. Consider combining into a single test or differentiating the scenarios (e.g., one with only Hover, one with only Pressed) to increase coverage breadth.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/core/rules/interaction/index.test.ts` around lines 37 - 63, The two tests
duplicate the same setup for componentPropertyDefinitions and assertions; update
them by either merging into a single test that covers both "Hover" and "Pressed"
cases or changing one test's variantOptions to a different scenario (e.g., only
["Default","Hover"] and the other ["Default","Pressed"]) so each test exercises
a distinct path—modify the tests that call makeNode and assert
missingInteractionState.check(node, ctx) (and use makeContext) accordingly to
remove duplication and broaden coverage.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Keeping separate — first test verifies hover detection, second verifies pressed→active mapping. Same fixture data but different assertions and purposes.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@let-sunny, understood — the two tests document distinct suppression paths (hover variant detected vs. pressed→active mapping), even though the same fixture satisfies both. That's a reasonable use of separate tests as living documentation. Thanks for the clarification!


🧠 Learnings used
Learnt from: CR
Repo: let-sunny/canicode PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-03-28T00:25:18.075Z
Learning: Applies to src/**/rules/rule-config.ts : Component-related rule scores (missing-component, etc.) should NOT be lowered based on small fixture calibration

Learnt from: let-sunny
Repo: let-sunny/canicode PR: 93
File: src/core/rules/rule-config.ts:76-80
Timestamp: 2026-03-26T01:28:57.785Z
Learning: In the let-sunny/canicode repo, `src/core/rules/rule-config.ts` is automatically adjusted by a nightly calibration pipeline. Do NOT suggest adding inline comments to this file for calibration rationale — the change evidence is tracked in PR descriptions, commit messages, and `data/calibration-evidence.json` instead. Inline comments would create clutter as the file is frequently auto-modified.

Learnt from: CR
Repo: let-sunny/canicode PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-03-28T00:25:18.075Z
Learning: Applies to src/**/rules/**/*.ts : All rule scores, severity, and thresholds are managed in `rules/rule-config.ts` — rule logic and score config must be intentionally separated

Learnt from: let-sunny
Repo: let-sunny/canicode PR: 154
File: src/core/rules/token/index.ts:36-135
Timestamp: 2026-03-29T00:24:15.976Z
Learning: In let-sunny/canicode rule implementations (e.g., RuleCheckFn in src/core/rules/token/index.ts and other rule files), follow the engine contract: `RuleCheckFn` must return `RuleViolation | null`, meaning the engine supports only a single violation per node per rule. If `rawValueCheck` (or similar logic) returns as soon as it finds the first matching subtype, treat that as intentional and do not change it to accumulate multiple violations unless the engine contract is updated (tracked in issue `#155`).


it("passes when component master has hover variant", () => {
const masterNode = makeNode({
id: "c:1",
name: "Button Master",
type: "COMPONENT",
componentPropertyDefinitions: {
"State": { type: "VARIANT", variantOptions: ["Default", "Hover", "Pressed", "Disabled"] },
},
});
const node = makeNode({
id: "1:1",
name: "Button",
type: "INSTANCE",
componentId: "c:1",
});
const file = makeFile({ componentDefinitions: { "c:1": masterNode } });
const ctx = makeContext({ file });
expect(missingInteractionState.check(node, ctx)).toBeNull();
});

it("still flags hover even when ON_HOVER prototype exists (prototype ≠ variant)", () => {
const node = makeNode({
id: "1:1",
name: "Link Item",
type: "INSTANCE",
componentId: "c:1",
interactions: [
{ trigger: { type: "ON_HOVER" }, actions: [{ navigation: "CHANGE_TO", destinationId: "d:1" }] },
],
});
const ctx = makeContext();
const result = missingInteractionState.check(node, ctx);
expect(result).not.toBeNull();
expect(result!.subType).toBe("hover");
});

it("flags input without focus variant", () => {
const node = makeNode({ id: "1:1", name: "Search Input", type: "INSTANCE", componentId: "c:1" });
const ctx = makeContext({ path: ["Page", "Input"] });
const result = missingInteractionState.check(node, ctx);

expect(result).not.toBeNull();
expect(result!.subType).toBe("focus");
});

it("flags tab without hover variant", () => {
const node = makeNode({ id: "1:1", name: "Navigation Tab", type: "INSTANCE", componentId: "c:1" });
const ctx = makeContext({ path: ["Page", "Tab"] });
const result = missingInteractionState.check(node, ctx);

expect(result).not.toBeNull();
expect(result!.subType).toBe("hover");
});

it("deduplicates per componentId + subType", () => {
const ctx = makeContext({ path: ["Page", "Section"] });
// Use link — only expects hover (single state), so dedup is clean
const node1 = makeNode({ id: "1:1", name: "Link", type: "INSTANCE", componentId: "c:1" });
const node2 = makeNode({ id: "1:2", name: "Link", type: "INSTANCE", componentId: "c:1" });

const result1 = missingInteractionState.check(node1, ctx);
const result2 = missingInteractionState.check(node2, ctx);

expect(result1).not.toBeNull();
expect(result1!.subType).toBe("hover");
expect(result2).toBeNull(); // deduped: same componentId + hover
});
});

// ============================================
// missing-prototype
// ============================================

describe("missing-prototype", () => {
it("has correct rule definition metadata", () => {
const def = missingPrototype.definition;
expect(def.id).toBe("missing-prototype");
expect(def.category).toBe("interaction");
});

it("flags button without ON_CLICK", () => {
const node = makeNode({ id: "1:1", name: "CTA Button", type: "INSTANCE", componentId: "c:1" });
const ctx = makeContext({ path: ["Page", "Button"] });
const result = missingPrototype.check(node, ctx);

expect(result).not.toBeNull();
expect(result!.subType).toBe("button");
});

it("flags link without ON_CLICK", () => {
const node = makeNode({ id: "1:1", name: "Footer Link", type: "INSTANCE", componentId: "c:1" });
const ctx = makeContext({ path: ["Page", "Link"] });
const result = missingPrototype.check(node, ctx);

expect(result).not.toBeNull();
expect(result!.subType).toBe("navigation");
});

it("flags dropdown without ON_CLICK (overlay subType)", () => {
const node = makeNode({ id: "1:1", name: "Country Dropdown", type: "FRAME" });
const ctx = makeContext({ path: ["Page", "Dropdown"] });
const result = missingPrototype.check(node, ctx);

expect(result).not.toBeNull();
expect(result!.subType).toBe("overlay");
});

it("flags drawer without ON_CLICK (overlay subType)", () => {
const node = makeNode({ id: "1:1", name: "Side Drawer", type: "FRAME" });
const ctx = makeContext({ path: ["Page", "Drawer"] });
const result = missingPrototype.check(node, ctx);

expect(result).not.toBeNull();
expect(result!.subType).toBe("overlay");
});

it("passes when ON_CLICK interaction exists", () => {
const node = makeNode({
id: "1:1",
name: "Nav Link",
type: "INSTANCE",
componentId: "c:1",
interactions: [
{ trigger: { type: "ON_CLICK" }, actions: [{ navigation: "NAVIGATE", destinationId: "page:2" }] },
],
});
const ctx = makeContext();
expect(missingPrototype.check(node, ctx)).toBeNull();
});

it("passes when component master has ON_CLICK (instance inheritance)", () => {
const masterNode = makeNode({
id: "c:1",
name: "Button Master",
type: "COMPONENT",
interactions: [
{ trigger: { type: "ON_CLICK" }, actions: [{ navigation: "NAVIGATE", destinationId: "page:2" }] },
],
});
const node = makeNode({ id: "1:1", name: "CTA Button", type: "INSTANCE", componentId: "c:1" });
const file = makeFile({ componentDefinitions: { "c:1": masterNode } });
const ctx = makeContext({ file, path: ["Page", "Button"] });
expect(missingPrototype.check(node, ctx)).toBeNull();
});

it("skips non-interactive names", () => {
const node = makeNode({ id: "1:1", name: "Product Card", type: "INSTANCE", componentId: "c:1" });
const ctx = makeContext();
expect(missingPrototype.check(node, ctx)).toBeNull();
});

it("flags input without ON_CLICK", () => {
const node = makeNode({ id: "1:1", name: "Email Input", type: "INSTANCE", componentId: "c:1" });
const ctx = makeContext({ path: ["Page", "Input"] });
const result = missingPrototype.check(node, ctx);

expect(result).not.toBeNull();
expect(result!.subType).toBe("input");
});

it("flags toggle without ON_CLICK", () => {
const node = makeNode({ id: "1:1", name: "Dark Mode Toggle", type: "INSTANCE", componentId: "c:1" });
const ctx = makeContext({ path: ["Page", "Toggle"] });
const result = missingPrototype.check(node, ctx);

expect(result).not.toBeNull();
expect(result!.subType).toBe("toggle");
});

it("deduplicates per componentId + subType", () => {
const ctx = makeContext({ path: ["Page", "Section"] });
const node1 = makeNode({ id: "1:1", name: "Tab Item", type: "INSTANCE", componentId: "c:1" });
const node2 = makeNode({ id: "1:2", name: "Tab Item", type: "INSTANCE", componentId: "c:1" });

const result1 = missingPrototype.check(node1, ctx);
const result2 = missingPrototype.check(node2, ctx);

expect(result1).not.toBeNull();
expect(result2).toBeNull(); // deduped
});

it("flags FRAME with interactive name (detached instance)", () => {
const node = makeNode({ id: "1:1", name: "Submit Button", type: "FRAME" });
const ctx = makeContext({ path: ["Page", "Form"] });
const result = missingPrototype.check(node, ctx);

expect(result).not.toBeNull();
expect(result!.subType).toBe("button");
});
});
Loading
Loading