/** * `components/forbidden-prop-value` — fires when a JSX prop is assigned a * value that the component governance forbids. Path-scoped policies only * fire when the file matches the policy's path glob; unscoped policies fire * everywhere. * * Only static prop values are checked. Dynamic props (`variant={kind}`) and * spreads (`{...props}`) cannot be evaluated at scan time. */ import type { FactIndex } from "../facts/index.js"; import { makeFinding } from "./finding.js"; import type { Finding } from "./types.js"; import { indexComponentByNodeId, indexPropsByNodeId, readUsageNode } from "./utils.js"; export const RULE_ID = "components/forbidden-prop-value"; export const RULE_VERSION = "1"; export function ruleComponentsForbiddenPropValue(ix: FactIndex): Finding[] { const findings: Finding[] = []; const componentByNode = indexComponentByNodeId(ix); const propsByNode = indexPropsByNodeId(ix); for (const [nodeId, propUsages] of propsByNode) { const usageComponent = componentByNode.get(nodeId); if (!usageComponent) continue; const node = readUsageNode(ix, nodeId); if (!node) continue; for (const propUsage of propUsages) { if (propUsage.resolution !== "static") continue; if (propUsage.value === undefined) continue; const matched = ix.policy.forbiddenPropValues(usageComponent.componentId, { prop: propUsage.prop, value: propUsage.value, path: node.file, }); if (matched.length === 0) continue; const policy = matched[0]; const replacement = policy.replaceWith; const fix = replacement !== undefined ? { kind: "replacePropValue" as const, title: `Replace ${propUsage.prop} with ${formatValue(replacement)}`, prop: propUsage.prop, value: replacement, deterministic: true, } : undefined; findings.push( makeFinding({ ruleId: RULE_ID, ruleVersion: RULE_VERSION, severity: policy.severity, message: buildMessage( node.element, propUsage.prop, propUsage.value, policy.because, replacement ), location: node.location, evidence: ix.evidence([node.id, usageComponent.id, propUsage.id, policy.id]), fingerprintIdentity: { file: node.file, componentId: usageComponent.componentId, element: node.element, nodePath: node.nodePath, prop: propUsage.prop, value: propUsage.value, }, fix, attributes: { componentId: usageComponent.componentId, prop: propUsage.prop, rawValue: propUsage.value, suggestedValue: replacement, }, }) ); } } return findings; } function formatValue(value: unknown): string { if (typeof value === "string") return `"${value}"`; return String(value); } function buildMessage( element: string, prop: string, value: unknown, because: string, replacement: unknown ): string { const head = `<${element}> ${prop}=${formatValue(value)} is forbidden.`; const reason = because ? ` ${because.trim()}` : ""; const suggestion = replacement !== undefined ? ` Use ${formatValue(replacement)}.` : ""; return `${head}${reason}${suggestion}`.trim(); }