/** * `props/invalid-value` — fires when a JSX prop is statically assigned a * value outside the component's declared enum values. * * The rule is intentionally conservative: it only runs for exact static * prop values and prop metadata that declares an allowed values list. * Dynamic expressions, spreads, JSX values, and open-ended prop schemas stay * silent so the scanner does not turn weak metadata into false certainty. */ import type { FactIndex } from "../facts/index.js"; import { makeFinding } from "./finding.js"; import type { Finding } from "./types.js"; import { indexComponentByNodeId, indexPropsByNodeId, isUniversalJsxProp, readUsageNode, } from "./utils.js"; export const RULE_ID = "props/invalid-value"; export const RULE_VERSION = "1"; export function rulePropsInvalidValue(ix: FactIndex): Finding[] { const policy = ix.policy.ruleConfig(RULE_ID); if (!policy?.enabled) return []; 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; if (isUniversalJsxProp(propUsage.prop)) continue; const metadata = ix.components.propOf(usageComponent.componentId, propUsage.prop); if (!metadata?.values?.length) continue; const allowedValues = metadata.values; const rawValue = toComparableValue(propUsage.value); if (rawValue === null) continue; if (allowedValues.includes(rawValue)) continue; const fix = allowedValues.length === 1 ? { kind: "replacePropValue" as const, title: `Replace ${propUsage.prop} with ${formatValue(allowedValues[0])}`, prop: propUsage.prop, value: allowedValues[0], deterministic: true, } : undefined; findings.push( makeFinding({ ruleId: RULE_ID, ruleVersion: RULE_VERSION, severity: policy.severity ?? "warn", message: buildMessage(node.element, propUsage.prop, propUsage.value, allowedValues), location: propUsage.location ?? node.location, evidence: ix.evidence([node.id, usageComponent.id, propUsage.id, metadata.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, allowedValues, }, }) ); } } return findings; } function toComparableValue(value: unknown): string | null { if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") { return String(value); } if (value === null) return "null"; return null; } function formatValue(value: unknown): string { if (typeof value === "string") return `"${value}"`; return String(value); } function buildMessage( element: string, prop: string, value: unknown, allowedValues: readonly string[] ): string { const allowed = allowedValues.map(formatValue).join(", "); return `<${element}> ${prop}=${formatValue(value)} is not an allowed value. Allowed values: ${allowed}.`; }