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
2 changes: 1 addition & 1 deletion src/agents/orchestrator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -146,7 +146,7 @@ const ELIGIBLE_NODE_TYPES: Set<AnalysisNodeType> = new Set([
"INSTANCE",
]);

import { isExcludedName } from "../core/rules/excluded-names.js";
import { isExcludedName } from "../core/rules/node-semantics.js";

/**
* Filter node summaries to meaningful conversion candidates.
Expand Down
15 changes: 0 additions & 15 deletions src/core/rules/excluded-names.ts

This file was deleted.

79 changes: 32 additions & 47 deletions src/core/rules/interaction/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,33 +4,10 @@ import type { AnalysisNode } from "../../contracts/figma-node.js";
import { defineRule } from "../rule-registry.js";
import type { MissingInteractionStateSubType, MissingPrototypeSubType } from "../rule-messages.js";
import { missingInteractionStateMsg, missingPrototypeMsg } from "../rule-messages.js";

// ============================================
// Interactive component classification
// ============================================

type InteractiveType = "button" | "link" | "tab" | "input" | "toggle";

const INTERACTIVE_PATTERNS: Array<{ pattern: RegExp; type: InteractiveType }> = [
{ pattern: /\b(btn|button|cta)\b/i, type: "button" },
{ pattern: /\b(link|anchor)\b/i, type: "link" },
{ pattern: /\b(tab|tabs)\b/i, type: "tab" },
{ pattern: /\b(nav|navigation|menu|navbar)\b/i, type: "tab" },
{ pattern: /\b(input|text-?field|search-?bar|textarea)\b/i, type: "input" },
{ pattern: /\b(select|dropdown|combo-?box)\b/i, type: "input" },
{ pattern: /\b(toggle|switch|checkbox|radio)\b/i, type: "toggle" },
];

function getInteractiveType(node: AnalysisNode): InteractiveType | null {
if (!node.name) return null;
for (const entry of INTERACTIVE_PATTERNS) {
if (entry.pattern.test(node.name)) return entry.type;
}
return null;
}
import { getStatefulComponentType, isOverlayNode, isCarouselNode, type StatefulComponentType } from "../node-semantics.js";

/** Expected state variants by interactive type */
const EXPECTED_STATES: Record<InteractiveType, MissingInteractionStateSubType[]> = {
const EXPECTED_STATES: Record<StatefulComponentType, MissingInteractionStateSubType[]> = {
button: ["hover", "active", "disabled"],
link: ["hover"],
tab: ["hover", "active"],
Expand Down Expand Up @@ -110,7 +87,7 @@ const missingInteractionStateCheck: RuleCheckFn = (node, context) => {
// Only check component instances and components
if (node.type !== "INSTANCE" && node.type !== "COMPONENT") return null;

const interactiveType = getInteractiveType(node);
const interactiveType = getStatefulComponentType(node);
if (!interactiveType) return null;

const expectedStates = EXPECTED_STATES[interactiveType];
Expand Down Expand Up @@ -155,26 +132,20 @@ export const missingInteractionState = defineRule({
// ============================================

/** Interactive types that need click prototype */
const PROTOTYPE_TYPES: Record<InteractiveType, MissingPrototypeSubType> = {
const PROTOTYPE_TYPES: Record<StatefulComponentType, MissingPrototypeSubType> = {
button: "button",
link: "navigation",
tab: "tab",
input: "input",
toggle: "toggle",
};

/** Name patterns for overlay elements (open on top of current view) */
const OVERLAY_PATTERN = /\b(dropdown|select|combo-?box|popover|accordion|drawer|modal|bottom-?sheet|sheet|sidebar|panel|dialog|popup|toast)\b/i;

/** Name patterns for carousel elements (swipe/slide between items) */
const CAROUSEL_PATTERN = /\b(carousel|slider|swiper|slide-?show|gallery)\b/i;

function getPrototypeSubType(node: AnalysisNode): MissingPrototypeSubType | null {
// Check dropdown pattern first — select/dropdown are classified as "input" in
// INTERACTIVE_PATTERNS but need "dropdown" subType for prototype checks
if (node.name && OVERLAY_PATTERN.test(node.name)) return "overlay";
if (node.name && CAROUSEL_PATTERN.test(node.name)) return "carousel";
const interactiveType = getInteractiveType(node);
// Check overlay/carousel first — select/dropdown are classified as "input" in
// STATEFUL_PATTERNS but need "overlay" subType for prototype checks
if (isOverlayNode(node)) return "overlay";
if (isCarouselNode(node)) return "carousel";
const interactiveType = getStatefulComponentType(node);
if (interactiveType) {
const mapped = PROTOTYPE_TYPES[interactiveType];
if (mapped) return mapped;
Expand All @@ -190,24 +161,37 @@ function hasInteractionTrigger(node: AnalysisNode, triggerType: string): boolean
});
}

/** Check if node (or its component master) has ON_CLICK prototype interaction */
function hasClickInteraction(node: AnalysisNode, context: RuleContext): boolean {
if (hasInteractionTrigger(node, "ON_CLICK")) return true;
/** Check if node (or its component master) has any of the given trigger types */
function hasAnyInteraction(node: AnalysisNode, context: RuleContext, triggers: string[]): boolean {
for (const trigger of triggers) {
if (hasInteractionTrigger(node, trigger)) return true;
}
// INSTANCE nodes don't inherit interactions from master — check master fallback
if (node.componentId && context.file.componentDefinitions) {
const master = context.file.componentDefinitions[node.componentId];
if (master && hasInteractionTrigger(master, "ON_CLICK")) return true;
if (master) {
for (const trigger of triggers) {
if (hasInteractionTrigger(master, trigger)) return true;
}
}
}
return false;
}

/** Trigger types to check per subType */
const PROTOTYPE_TRIGGERS: Record<string, string[]> = {
carousel: ["ON_CLICK", "ON_DRAG"],
};

const DEFAULT_TRIGGERS = ["ON_CLICK"];
Comment on lines +181 to +186
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.

⚠️ Potential issue | 🟡 Minor

Update the missing-prototype guidance for drag-supported carousels.

missingPrototype now accepts ON_DRAG for carousel, but the inline docs and surfaced fix text still tell users to add click interactions. That leaves the remediation inaccurate for the new subtype.

📝 Suggested wording update
-/** Interactive types that need click prototype */
+/** Interactive types that need prototype interactions */
 const PROTOTYPE_TYPES: Record<InteractiveType, MissingPrototypeSubType> = {
   button: "button",
   link: "navigation",
   tab: "tab",
   input: "input",
   toggle: "toggle",
 };
@@
 const missingPrototypeDef: RuleDefinition = {
   id: "missing-prototype",
   name: "Missing Prototype",
   category: "interaction",
-  why: "Interactive elements without click prototypes give AI no information about navigation or behavior on click",
-  impact: "AI cannot generate click handlers, routing, or state changes — interactive elements become static",
-  fix: "Add ON_CLICK prototype interactions to define navigation targets or state changes",
+  why: "Interactive elements without prototype interactions give AI no information about navigation or behavior",
+  impact: "AI cannot generate navigation, gesture handlers, or state changes — interactive elements become static",
+  fix: "Add prototype interactions (for example ON_CLICK, or ON_DRAG for carousels) to define navigation targets or state changes",
 };

As per coding guidelines, If the code change introduces behavior that contradicts existing documentation (README.md, CLAUDE.md, JSDoc comments), flag it and suggest updating the relevant documentation to stay in sync.

Also applies to: 188-195, 209-211

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

In `@src/core/rules/interaction/index.ts` around lines 181 - 186, The inline docs
and fix text for the missing-prototype guidance were not updated after adding
ON_DRAG to the carousel triggers; update any JSDoc/inline comments and the
user-facing remediation text (the logic that constructs the "add click
interaction" message) to reflect PROTOTYPE_TRIGGERS and DEFAULT_TRIGGERS so
carousels suggest adding ON_DRAG (or the appropriate trigger(s)) rather than
always saying "add click interactions"; locate references to PROTOTYPE_TRIGGERS,
DEFAULT_TRIGGERS and the missingPrototype remediation function/messages and make
the remediation message dynamic based on PROTOTYPE_TRIGGERS['carousel'] (falling
back to DEFAULT_TRIGGERS).


const missingPrototypeDef: RuleDefinition = {
id: "missing-prototype",
name: "Missing Prototype",
category: "interaction",
why: "Interactive elements without click prototypes give AI no information about navigation or behavior on click",
impact: "AI cannot generate click handlers, routing, or state changes — interactive elements become static",
fix: "Add ON_CLICK prototype interactions to define navigation targets or state changes",
why: "Interactive elements without prototype interactions give AI no information about navigation or behavior",
impact: "AI cannot generate interaction handlers, routing, or state changes — interactive elements become static",
fix: "Add prototype interactions (ON_CLICK, ON_DRAG) to define navigation targets or state changes",
};

const SEEN_PROTO_KEY = "missing-prototype:seen";
Expand All @@ -222,8 +206,9 @@ const missingPrototypeCheck: RuleCheckFn = (node, context) => {
const subType = getPrototypeSubType(node);
if (!subType) return null;

// Already has click interaction (check instance + master)
if (hasClickInteraction(node, context)) return null;
// Already has relevant interaction (click, or drag for carousel)
const triggers = PROTOTYPE_TRIGGERS[subType] ?? DEFAULT_TRIGGERS;
if (hasAnyInteraction(node, context, triggers)) return null;

// Dedup per componentId + subType
const seen = getSeenProto(context);
Expand Down
42 changes: 1 addition & 41 deletions src/core/rules/naming/index.ts
Original file line number Diff line number Diff line change
@@ -1,47 +1,7 @@
import type { RuleCheckFn, RuleDefinition } from "../../contracts/rule.js";
import { defineRule } from "../rule-registry.js";
import { isExcludedName } from "../excluded-names.js";
import { defaultNameMsg, getDefaultNameSubType, nonSemanticNameMsg, inconsistentNamingMsg } from "../rule-messages.js";

// ============================================
// Helper functions
// ============================================

const DEFAULT_NAME_PATTERNS = [
/^Frame\s*\d*$/i,
/^Group\s*\d*$/i,
/^Rectangle\s*\d*$/i,
/^Ellipse\s*\d*$/i,
/^Vector\s*\d*$/i,
/^Line\s*\d*$/i,
/^Text\s*\d*$/i,
/^Image\s*\d*$/i,
/^Component\s*\d*$/i,
/^Instance\s*\d*$/i,
];

const NON_SEMANTIC_NAMES = [
"rectangle",
"ellipse",
"vector",
"line",
"polygon",
"star",
"path",
"shape",
"image",
"fill",
"stroke",
];

function isDefaultName(name: string): boolean {
return DEFAULT_NAME_PATTERNS.some((pattern) => pattern.test(name));
}

function isNonSemanticName(name: string): boolean {
const normalized = name.toLowerCase().trim();
return NON_SEMANTIC_NAMES.includes(normalized);
}
import { isExcludedName, isDefaultName, isNonSemanticName } from "../node-semantics.js";

function detectNamingConvention(name: string): string | null {
if (/^[a-z]+(-[a-z]+)*$/.test(name)) return "kebab-case";
Expand Down
168 changes: 168 additions & 0 deletions src/core/rules/node-semantics.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
/**
* Centralized node semantic classification.
* All "what is this node?" logic lives here so rules share the same predicates.
*
* Categories:
* - Container: layout containers (frame, group, component, instance)
* - Visual: decorative/graphic elements (vector, shape, image)
* - Interactive: user-interactable elements (button, link, tab, input, toggle)
* - Overlay: elements that open on top (modal, drawer, dropdown)
* - Carousel: elements that slide/swipe (carousel, slider, gallery)
* - Token: style/variable binding checks
* - Naming: name pattern classification
*/

import type { AnalysisNode } from "../contracts/figma-node.js";

// ── Container classification ─────────────────────────────────────────────────

export function isContainerNode(node: AnalysisNode): boolean {
return node.type === "FRAME" || node.type === "GROUP" || node.type === "COMPONENT" || node.type === "INSTANCE";
}

export function hasAutoLayout(node: AnalysisNode): boolean {
return node.layoutMode !== undefined && node.layoutMode !== "NONE";
}

export function hasTextContent(node: AnalysisNode): boolean {
return node.type === "TEXT" || (node.children?.some((c) => c.type === "TEXT") ?? false);
}

export function hasOverlappingBounds(a: AnalysisNode, b: AnalysisNode): boolean {
const boxA = a.absoluteBoundingBox;
const boxB = b.absoluteBoundingBox;

if (!boxA || !boxB) return false;

return !(
boxA.x + boxA.width <= boxB.x ||
boxB.x + boxB.width <= boxA.x ||
boxA.y + boxA.height <= boxB.y ||
boxB.y + boxB.height <= boxA.y
);
}

// ── Visual classification ────────────────────────────────────────────────────

const VISUAL_LEAF_TYPES = new Set([
"VECTOR", "BOOLEAN_OPERATION", "ELLIPSE", "LINE", "STAR", "REGULAR_POLYGON", "RECTANGLE",
]);

export function isVisualLeafType(type: string): boolean {
return VISUAL_LEAF_TYPES.has(type);
}

export function hasImageFill(node: AnalysisNode): boolean {
if (!Array.isArray(node.fills)) return false;
return node.fills.some(
(fill) =>
typeof fill === "object" &&
fill !== null &&
(fill as { type?: unknown }).type === "IMAGE",
);
}

export function isVisualOnlyNode(node: AnalysisNode): boolean {
if (VISUAL_LEAF_TYPES.has(node.type)) return true;
const hasOnlyVisualChildren =
node.children !== undefined &&
node.children.length > 0 &&
node.children.every((c) => VISUAL_LEAF_TYPES.has(c.type));
// Image fill only counts as visual-only when there are no content children
if (hasImageFill(node) && (!node.children || node.children.length === 0 || hasOnlyVisualChildren)) {
return true;
}
if (hasOnlyVisualChildren) return true;
return false;
}

// ── Interactive classification ───────────────────────────────────────────────

export type StatefulComponentType = "button" | "link" | "tab" | "input" | "toggle";

/** Name patterns → interactive type mapping */
export const STATEFUL_PATTERNS: Array<{ pattern: RegExp; type: StatefulComponentType }> = [
{ pattern: /\b(btn|button|cta)\b/i, type: "button" },
{ pattern: /\b(link|anchor)\b/i, type: "link" },
{ pattern: /\b(tab|tabs)\b/i, type: "tab" },
{ pattern: /\b(nav|navigation|menu|navbar)\b/i, type: "tab" },
{ pattern: /\b(input|text-?field|search-?bar|textarea)\b/i, type: "input" },
{ pattern: /\b(select|dropdown|combo-?box)\b/i, type: "input" },
{ pattern: /\b(toggle|switch|checkbox|radio)\b/i, type: "toggle" },
];

export function getStatefulComponentType(node: AnalysisNode): StatefulComponentType | null {
if (!node.name) return null;
for (const entry of STATEFUL_PATTERNS) {
if (entry.pattern.test(node.name)) return entry.type;
}
return null;
}

export function isStatefulComponent(node: AnalysisNode): boolean {
return getStatefulComponentType(node) !== null;
}

// ── Overlay / Carousel patterns ──────────────────────────────────────────────

/** Elements that open on top of current view */
export const OVERLAY_PATTERN = /\b(dropdown|select|combo-?box|popover|accordion|drawer|modal|bottom-?sheet|sheet|sidebar|panel|dialog|popup|toast)\b/i;

/** Elements that swipe/slide between items */
export const CAROUSEL_PATTERN = /\b(carousel|slider|swiper|slide-?show|gallery)\b/i;

export function isOverlayNode(node: AnalysisNode): boolean {
return node.name !== undefined && OVERLAY_PATTERN.test(node.name);
}

export function isCarouselNode(node: AnalysisNode): boolean {
return node.name !== undefined && CAROUSEL_PATTERN.test(node.name);
}

// ── Token classification ─────────────────────────────────────────────────────

export function hasStyleReference(node: AnalysisNode, styleType: string): boolean {
return node.styles !== undefined && styleType in node.styles;
}

export function hasBoundVariable(node: AnalysisNode, key: string): boolean {
return node.boundVariables !== undefined && key in node.boundVariables;
}

// ── Naming patterns ──────────────────────────────────────────────────────────

/** Figma default name patterns (Frame 1, Group 2, etc.) */
export const DEFAULT_NAME_PATTERNS = [
/^Frame\s*\d*$/i,
/^Group\s*\d*$/i,
/^Rectangle\s*\d*$/i,
/^Ellipse\s*\d*$/i,
/^Vector\s*\d*$/i,
/^Line\s*\d*$/i,
/^Text\s*\d*$/i,
/^Image\s*\d*$/i,
/^Component\s*\d*$/i,
/^Instance\s*\d*$/i,
];

export function isDefaultName(name: string): boolean {
return DEFAULT_NAME_PATTERNS.some((pattern) => pattern.test(name));
}

/** Shape-only non-semantic names */
export const NON_SEMANTIC_NAMES = [
"rectangle", "ellipse", "vector", "line", "polygon",
"star", "path", "shape", "image", "fill", "stroke",
];

export function isNonSemanticName(name: string): boolean {
return NON_SEMANTIC_NAMES.includes(name.toLowerCase().trim());
}

// ── Exclusion ────────────────────────────────────────────────────────────────

export const EXCLUDED_NAME_PATTERN = /(badge|close|dismiss|overlay|float|fab|dot|indicator|corner|decoration|tag|status|notification|icon|ico|image|asset|filter|dim|dimmed|bg|background|logo|avatar|divider|separator|nav|navigation|gnb|header|footer|sidebar|toolbar|modal|dialog|popup|toast|tooltip|dropdown|menu|sticky|spinner|loader|cursor|cta|chatbot|thumb|thumbnail|tabbar|tab-bar|statusbar|status-bar)/i;

export function isExcludedName(name: string): boolean {
return EXCLUDED_NAME_PATTERN.test(name);
Comment on lines +164 to +167
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.

⚠️ Potential issue | 🟠 Major

Don't use the shared exclusion regex to hide interactive controls.

This pattern is no longer naming-only: src/agents/orchestrator.ts uses isExcludedName() as a hard filter on Lines 188-190, and src/core/rules/rule-exceptions.ts uses it for layout exemptions. Entries like cta and filter will now suppress analysis of real controls instead of just decorative nodes.

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 as-is. isExcludedName is not used in interaction rules at all — interaction detection uses getInteractiveType() which is an independent classification. The exclusion pattern only affects naming rules and absolute-position exemptions, which is the intended scope.

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.

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Check all usages of isExcludedName across the codebase
rg -n "isExcludedName" --type ts -C3

Length of output: 4553


@let-sunny, you're right — I confirmed via the codebase search that isExcludedName is only called in:

  • src/core/rules/naming/index.ts (naming rule guards)
  • src/core/rules/rule-exceptions.ts (auto-layout exemptions)
  • src/agents/orchestrator.ts (decorative node filtering)

It is not referenced anywhere in src/core/rules/interaction/index.ts, and getInteractiveType() operates entirely independently through INTERACTIVE_PATTERNS. My original concern was unfounded. Sorry for the noise!


✏️ Learnings added
Learnt from: let-sunny
Repo: let-sunny/canicode PR: 161
File: src/core/rules/node-semantics.ts:157-160
Timestamp: 2026-03-29T02:11:22.940Z
Learning: In let-sunny/canicode, `isExcludedName()` from `src/core/rules/node-semantics.ts` is intentionally NOT used in interaction rules. Interaction detection uses `getInteractiveType()` (via `INTERACTIVE_PATTERNS`) as an independent classification path. `isExcludedName` only affects: naming rules (`src/core/rules/naming/index.ts`), auto-layout exemptions (`src/core/rules/rule-exceptions.ts`), and orchestrator-level node filtering (`src/agents/orchestrator.ts`). Do not flag `cta`, `filter`, or other terms in `EXCLUDED_NAME_PATTERN` as suppressing interactive control analysis.

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.


🧠 Learnings used
Learnt from: let-sunny
Repo: let-sunny/canicode PR: 154
File: src/core/rules/structure/index.ts:296-304
Timestamp: 2026-03-29T00:24:13.455Z
Learning: In let-sunny/canicode, the `non-layout-container` rule in `src/core/rules/structure/index.ts` intentionally flags non-empty `SECTION` nodes (`children.length > 0`) because Figma `SECTION` is not a layout container by design — using it structurally with children is treated as semantic misuse. This predicate is considered sufficient and intentional; do not flag it as too broad.

Learnt from: let-sunny
Repo: let-sunny/canicode PR: 158
File: src/core/rules/interaction/index.ts:64-75
Timestamp: 2026-03-29T01:43:45.901Z
Learning: In let-sunny/canicode, `hasStateInVariantProps()` in `src/core/rules/interaction/index.ts` intentionally scans ALL `VARIANT`-typed `componentPropertyDefinitions` (not just ones keyed as `"State"`) because Figma variant axis names are freeform — designers use `State`, `Status`, `Mode`, `Type`, or localized names. If any variant option value matches the state regex pattern, the state is considered present. Do not flag this as a false-positive risk.

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 : `no-auto-layout` is the single highest-impact rule with score -10 — empirically validated via ablation experiments

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/rule-config.ts : Component-related rule scores (missing-component, etc.) should NOT be lowered based on small fixture calibration

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: CR
Repo: let-sunny/canicode PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-03-28T00:25:18.075Z
Learning: Applies to src/cli/**/*.ts : Full-file analysis is discouraged — use section or page level analysis with `node-id` required in URL

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 : Token consumption is a first-class metric — designs that waste tokens on repeated structures are penalized in scoring

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`).

}
Loading
Loading