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