import type { CanonicalSource, GovernanceSeverity } from "../governance.js";
import type {
FactIndex,
UsageImportFact,
UsageNodeFact,
UsagePropResolvedFact,
UsageTextChildFact,
} from "../facts/index.js";
import {
isEnforceableHtmlEquivalent,
isRawHtmlAdvisoryTier,
resolveCanonicalForAriaRole,
resolveCanonicalForRawHtml,
type RawHtmlPrecisionTier,
} from "../raw-html-canonical.js";
import { makeFinding } from "./finding.js";
import type { Finding } from "./types.js";
import { indexPropsByNodeId, indexTextChildrenByNodeId } from "./utils.js";
export const RULE_ID = "components/prefer-library";
export const RULE_VERSION = "1";
interface CanonicalMappingOption {
name: string;
canonical?: string;
htmlEquivalent?: string;
/**
* ARIA role of the canonical primitive this mapping points at (e.g. `status`
* for Toast, `alert` for Alert). Lets the rule key on element *semantics*
* rather than the raw tag name, so a bespoke `
` re-impl is
* flagged structurally — not just literal tag/import matches.
*/
ariaRole?: string;
/**
* Intrinsic input `type` of the canonical primitive this mapping points at
* (e.g. `checkbox` for Checkbox, `radio` for RadioGroup). Only meaningful when
* `htmlEquivalent === "input"`: it discriminates one raw `` from
* another so a library can map `` → Checkbox and
* `` → RadioGroup independently. Unset → matches any
* `` regardless of type (backward-compatible tag-level match).
*/
htmlType?: string;
importPath?: string;
propMapping?: Array<{
rawProp: string;
canonicalProp: string;
valueMap?: Record;
}>;
confidence?: number;
/**
* The `htmlEquivalent` was EXPLICITLY declared by the project (provenance:
* `localCanonical.resolves`), not inferred. Lets the enforceability gate fire
* a specific interactive tag (button/a) against any canonical NAME — the
* library-agnostic path — without re-opening the inferred-htmlEquivalent flood.
*/
declaredHtmlEquivalent?: boolean;
}
function isExplicitTagDeclaration(mapping: CanonicalMappingOption): boolean {
return mapping.declaredHtmlEquivalent === true;
}
/** Advisory findings never exceed `warn`, whatever the project configured. */
function capAdvisorySeverity(configured: GovernanceSeverity | undefined): GovernanceSeverity {
const base = configured ?? "warn";
return base === "error" ? "warn" : base;
}
/**
* Advisory-precision matches (role-reimpl, interactive-nonsemantic, html-advisory,
* icon-only) are lower-confidence and never carry a deterministic fix, so they must
* never hard-block a write or hard-fail CI. Cap their severity at `warn` even when a
* project raises `components/prefer-library` to `error` — only the confident
* exact-html / input-type tiers inherit the escalated severity. (The blocking hook's
* `isDenyEligible` also refuses to deny `attributes.advisory` findings; this keeps
* the CI severity honest to the same confidence line.)
*/
function severityForTier(
configured: GovernanceSeverity | undefined,
tier: RawHtmlPrecisionTier
): GovernanceSeverity {
return isRawHtmlAdvisoryTier(tier) ? capAdvisorySeverity(configured) : (configured ?? "warn");
}
/**
* Generic structural / typography / primitive class names that routinely appear
* as plain layout classes even in fully-canonical codebases. Even when one equals
* a canonical component name (Box, Grid, Stack, List, Text…), a `
`
* is almost never a hand-rolled component — so they are excluded from the
* className-reimplementation heuristic to keep it low-noise. Distinctive component
* names (card, badge, alert, dialog, tooltip, drawer, accordion, chip…) are NOT
* listed here, so they still flag.
*/
const GENERIC_CLASSNAME_DENYLIST: ReadonlySet = new Set([
"box",
"grid",
"stack",
"row",
"col",
"cols",
"column",
"columns",
"flex",
"wrap",
"wrapper",
"container",
"content",
"inner",
"outer",
"main",
"root",
"layout",
"panel",
"group",
"area",
"block",
"section",
"item",
"items",
"entry",
"list",
"header",
"heading",
"footer",
"nav",
"navbar",
"bar",
"toolbar",
"sidebar",
"aside",
"body",
"cell",
"title",
"subtitle",
"label",
"caption",
"text",
"copy",
"icon",
"img",
"image",
"media",
"link",
"links",
"button",
"btn",
"input",
"field",
"fields",
"form",
"control",
"controls",
"menu",
"dropdown",
"table",
"page",
"view",
"screen",
"overlay",
"backdrop",
"spacer",
"divider",
"separator",
"line",
"dot",
"tabs",
]);
interface ReimplTarget {
name: string;
import?: string;
}
/** lowercased class token → the canonical component it likely reimplements. */
function canonicalReimplTargets(
sources: readonly CanonicalSource[],
mappings: readonly CanonicalMappingOption[]
): Map {
const out = new Map();
const add = (name: string | undefined, importPath: string | undefined) => {
if (!name || !isIdentifier(name)) return;
const key = name.toLowerCase();
if (GENERIC_CLASSNAME_DENYLIST.has(key) || out.has(key)) return;
out.set(key, { name, ...(importPath ? { import: importPath } : {}) });
};
for (const mapping of mappings) add(mapping.name, mapping.importPath);
for (const source of sources) {
const label = sourceLabel(source);
for (const name of source.include ?? []) add(name, label);
}
return out;
}
/** The CSS-module member of a `styles.card` / `styles["card"]` snippet, or null. */
function classMemberFromSnippet(snippet: string): string | null {
const bracket = /\[\s*["'`]([A-Za-z0-9_-]+)["'`]\s*\]\s*$/.exec(snippet);
if (bracket) return bracket[1];
const dot = /\.([A-Za-z_$][\w$]*)\s*$/.exec(snippet);
return dot ? dot[1] : null;
}
/** Candidate class-name tokens per node: literal classes + resolvable module members. */
function indexClassTokensByNode(ix: FactIndex): Map {
const out = new Map();
const push = (nodeId: string, token: string | null) => {
if (!token) return;
const list = out.get(nodeId);
if (list) list.push(token);
else out.set(nodeId, [token]);
};
for (const fact of ix.byKind("classname_literal")) {
for (const cls of fact.classes) push(fact.nodeId, cls);
}
for (const fact of ix.byKind("classname_dynamic")) {
// Only the `styles.card` identifier form yields a clean member name; clsx()/
// template/spread forms are too fuzzy to key a suggestion on.
if (fact.reason !== "identifier") continue;
push(fact.nodeId, classMemberFromSnippet(fact.snippet));
}
return out;
}
interface ClassNameReimplMatch {
canonical: string;
matchedClass: string;
import?: string;
}
function findClassNameReimpl(
node: UsageNodeFact,
classTokensByNode: Map,
targets: Map
): ClassNameReimplMatch | null {
const tokens = classTokensByNode.get(node.id);
if (!tokens) return null;
for (const token of tokens) {
const target = targets.get(token.toLowerCase());
if (target) {
return {
canonical: target.name,
matchedClass: token,
...(target.import ? { import: target.import } : {}),
};
}
}
return null;
}
export function ruleComponentsPreferLibrary(ix: FactIndex): Finding[] {
const policy = ix.policy.ruleConfig(RULE_ID);
if (!policy?.enabled) return [];
const sources = canonicalSourcesFromPolicy(policy.options?.canonicalSources);
const mappings = canonicalMappingsFromPolicy(policy.options?.canonicalMappings);
if (sources.length === 0 && mappings.length === 0) return [];
const importsByFileAndLocal = indexImportsByFileAndLocal(ix);
const propsByNode = indexPropsByNodeId(ix);
const textByNode = indexTextChildrenByNodeId(ix);
const classTokensByNode = indexClassTokensByNode(ix);
const reimplTargets = canonicalReimplTargets(sources, mappings);
const findings: Finding[] = [];
const seenImportFixes = new Set();
for (const node of ix.byKind("usage_node")) {
if (isCanonicalSourceImplementationFile(node.file, sources)) continue;
if (node.element.includes(".")) continue;
const props = propsByNode.get(node.id) ?? [];
const textChildren = textByNode.get(node.id) ?? [];
const imported = importsByFileAndLocal.get(node.file)?.get(node.element);
const nodeInputType =
node.element === "input" ? readStaticStringProp(props, "type") : undefined;
const mapped = findMapping(node, nodeInputType, mappings);
if (mapped && imported && isMappedCanonicalImport(imported, mapped)) continue;
if (imported && isCanonicalImport(imported, node.element, sources)) continue;
if (mapped) {
if (!shouldSuggestMapped(node, nodeInputType, imported, mapped)) continue;
const importPath = mapped.importPath;
const suggestedComponent = mapped.name;
const suggestedImport = importPath ?? "the canonical library";
const propMapping = mapped.propMapping ?? [];
const roleMatch = isRoleMatch(node, mapped);
const precisionTier: RawHtmlPrecisionTier = roleMatch ? "role-reimpl" : "exact-html";
if (
imported &&
importPath &&
imported.source !== importPath &&
node.element === suggestedComponent
) {
const key = `${imported.file}\0${imported.local}\0${imported.source}\0${importPath}`;
if (seenImportFixes.has(key)) continue;
seenImportFixes.add(key);
findings.push(
makeFinding({
ruleId: RULE_ID,
ruleVersion: RULE_VERSION,
severity: policy.severity ?? "warn",
message: `<${node.element}> should import from ${importPath}.`,
location: imported.location,
evidence: ix.evidence([node.id, imported.id, policy.id]),
fingerprintIdentity: {
file: imported.file,
local: imported.local,
from: imported.source,
to: importPath,
},
fix: {
kind: "replaceImport",
title: `Replace import path with "${importPath}"`,
from: imported.source,
to: importPath,
deterministic: true,
},
attributes: {
rawValue: imported.source,
suggestedComponent,
suggestedImport: importPath,
canonical: mapped.canonical,
confidence: mapped.confidence,
},
})
);
continue;
}
// No-op guard: the element already IS the suggested canonical (any
// wrong-import-path fix was handled just above). "Replace with "
// is not drift — skip it. This kills the icon self-mappings
// (e.g. → ) that floated up as noise.
if (node.element === suggestedComponent) continue;
findings.push(
makeFinding({
ruleId: RULE_ID,
ruleVersion: RULE_VERSION,
severity: severityForTier(policy.severity, precisionTier),
message:
node.element === "button"
? `Bespoke