/** * In-memory index over Fact records. * * Rules consume the index, never raw fact arrays. The query surface is * intentionally narrow: components.byId / propsOf / propOf / capabilitiesOf, * and policy.(componentId, opts?). This keeps Phase 5 rules pure * and content-addressed. */ import { factId } from "./ids.js"; import type { A11yNameRequiredFact, ComponentCapabilityFact, ComponentId, ComponentMetadataFact, Fact, FactId, FactKind, FactOfKind, GovernanceRuleConfigFact, JsxComponentPreferredFact, JsxImportPathPreferredFact, JsxInlineStyleForbiddenRawFact, JsxUnknownPropsForbiddenFact, PropMetadataFact, PropValueAvoidedFact, PropValueForbiddenFact, ScaleFact, ScaleValueFact, StyleCssVarsMustBeDefinedFact, StyleFontSizeScaleFact, StylePropertyScaleFact, StyleRawColorForbiddenFact, StyleRawDimensionForbiddenFact, TailwindPaletteAllowFact, TailwindPaletteDenyFact, TailwindUnknownClassEnabledFact, TokenDefinitionFact, } from "./types.js"; import { canonicalJson } from "./ids.js"; export interface FactEvidence { factId: FactId; fact: Fact; } interface FactWithComponent { componentId: ComponentId; } function hasComponentId(fact: Fact): fact is Fact & FactWithComponent { return "componentId" in fact && typeof (fact as FactWithComponent).componentId === "string"; } /** * Match a literal path against a simple glob pattern. `*` matches any run of * non-separator characters; `**` matches any run of characters including * separators. */ export function matchesGlob(path: string, pattern: string): boolean { const escaped = pattern.replace(/[.+^${}()|[\]\\]/g, "\\$&"); const expanded = escaped .replace(/\*\*/g, "::DOUBLE::") .replace(/\*/g, "[^/]*") .replace(/::DOUBLE::/g, ".*"); return new RegExp(`^${expanded}$`).test(path); } export class FactIndex { private readonly facts = new Map(); private readonly idsByKind = new Map>(); private readonly idsByComponent = new Map>(); add(fact: Fact): void { const existing = this.facts.get(fact.id); if (existing) { if (canonicalJson(existing) !== canonicalJson(fact)) { console.warn( `FactIndex: conflicting facts for id ${fact.id} — keeping first fact, skipping ${describeFactForConflict(fact)}` ); } return; } this.facts.set(fact.id, fact); let byKind = this.idsByKind.get(fact.kind); if (!byKind) { byKind = new Set(); this.idsByKind.set(fact.kind, byKind); } byKind.add(fact.id); if (hasComponentId(fact)) { let byComponent = this.idsByComponent.get(fact.componentId); if (!byComponent) { byComponent = new Set(); this.idsByComponent.set(fact.componentId, byComponent); } byComponent.add(fact.id); } } addMany(facts: Iterable): void { for (const fact of facts) this.add(fact); } get(id: FactId): Fact | undefined { return this.facts.get(id); } has(id: FactId): boolean { return this.facts.has(id); } size(): number { return this.facts.size; } all(): Fact[] { return [...this.facts.values()]; } byKind(kind: K): Array> { const ids = this.idsByKind.get(kind); if (!ids) return []; const out: FactOfKind[] = []; for (const id of ids) { const fact = this.facts.get(id); if (fact && fact.kind === kind) out.push(fact as FactOfKind); } return out; } /** * Resolve evidence for a list of fact IDs. Throws if any ID is missing — * findings must never reference facts that aren't in the index. */ evidence(ids: readonly FactId[]): FactEvidence[] { const missing: FactId[] = []; const out: FactEvidence[] = []; for (const id of ids) { const fact = this.facts.get(id); if (!fact) { missing.push(id); continue; } out.push({ factId: id, fact }); } if (missing.length) { throw new Error(`FactIndex.evidence: missing facts in index: ${missing.join(", ")}`); } return out; } // --------------------------------------------------------------------- // Component queries // --------------------------------------------------------------------- components = { list: (): ComponentMetadataFact[] => this.byKind("component"), byId: (componentId: ComponentId): ComponentMetadataFact | undefined => { const id = factId("component", { componentId }); const fact = this.facts.get(id as FactId); return fact?.kind === "component" ? fact : undefined; }, propsOf: (componentId: ComponentId): PropMetadataFact[] => this.byComponentOfKind(componentId, "prop_metadata"), propOf: (componentId: ComponentId, prop: string): PropMetadataFact | undefined => this.components.propsOf(componentId).find((p) => p.prop === prop), capabilitiesOf: (componentId: ComponentId): ComponentCapabilityFact[] => this.byComponentOfKind(componentId, "component_capability"), }; // --------------------------------------------------------------------- // Policy queries // --------------------------------------------------------------------- policy = { forbiddenPropValues: ( componentId: ComponentId, opts: { prop?: string; path?: string; value?: unknown } = {} ): PropValueForbiddenFact[] => { const all = this.byComponentOfKind( componentId, "prop_value_forbidden" ); return all.filter((fact) => { if (opts.prop !== undefined && fact.prop !== opts.prop) return false; if (opts.value !== undefined && fact.value !== opts.value) return false; if (opts.path !== undefined && fact.pathPattern) { if (!matchesGlob(opts.path, fact.pathPattern)) return false; } return true; }); }, avoidedPropValues: ( componentId: ComponentId, opts: { prop?: string; value?: unknown } = {} ): PropValueAvoidedFact[] => { const all = this.byComponentOfKind(componentId, "prop_value_avoided"); return all.filter((fact) => { if (opts.prop !== undefined && fact.prop !== opts.prop) return false; if (opts.value !== undefined && fact.value !== opts.value) return false; return true; }); }, a11yNameRequired: (componentId: ComponentId): A11yNameRequiredFact | undefined => this.byComponentOfKind(componentId, "a11y_name_required")[0], scales: (): ScaleFact[] => this.byKind("scale"), scale: (name: string): ScaleFact | undefined => this.byKind("scale").find((s) => s.name === name), scaleValues: (name: string): ScaleValueFact[] => this.byKind("scale_value").filter((v) => v.scale === name), rawColorPolicy: (): StyleRawColorForbiddenFact | undefined => this.byKind("style_raw_color_forbidden")[0], rawDimensionPolicy: (): StyleRawDimensionForbiddenFact | undefined => this.byKind("style_raw_dimension_forbidden")[0], propertyScale: (property: string): StylePropertyScaleFact | undefined => this.byKind("style_property_scale").find((p) => p.property === property), fontSizeScale: (): StyleFontSizeScaleFact | undefined => this.byKind("style_font_size_scale")[0], cssVarsMustBeDefined: (): StyleCssVarsMustBeDefinedFact | undefined => this.byKind("style_css_vars_must_be_defined")[0], tailwindPaletteAllow: (): TailwindPaletteAllowFact | undefined => this.byKind("tailwind_palette_allow")[0], tailwindPaletteDeny: (): TailwindPaletteDenyFact | undefined => this.byKind("tailwind_palette_deny")[0], tailwindUnknownClassEnabled: (): TailwindUnknownClassEnabledFact | undefined => this.byKind("tailwind_unknown_class_enabled")[0], ruleConfig: (ruleId: string): GovernanceRuleConfigFact | undefined => this.byKind("governance_rule_config").find((fact) => fact.ruleId === ruleId), jsxUnknownPropsForbidden: (): JsxUnknownPropsForbiddenFact | undefined => this.byKind("jsx_unknown_props_forbidden")[0], jsxInlineStyleForbiddenRaw: ( opts: { property?: string } = {} ): JsxInlineStyleForbiddenRawFact[] => { const all = this.byKind("jsx_inline_style_forbidden_raw"); return opts.property === undefined ? all : all.filter((f) => f.property === opts.property); }, jsxImportPathPreferred: ( opts: { from?: string; imported?: string } = {} ): JsxImportPathPreferredFact[] => { const all = this.byKind("jsx_import_path_preferred"); return all.filter((fact) => { if (opts.from !== undefined && fact.from !== opts.from) return false; if (opts.imported !== undefined && fact.imported !== opts.imported) return false; return true; }); }, jsxComponentPreferred: (opts: { from?: ComponentId } = {}): JsxComponentPreferredFact[] => { const all = this.byKind("jsx_component_preferred"); return opts.from === undefined ? all : all.filter((fact) => fact.from === opts.from); }, }; // --------------------------------------------------------------------- // Token registry queries // --------------------------------------------------------------------- tokens = { list: (): TokenDefinitionFact[] => this.byKind("token_definition"), byCategory: (category: TokenDefinitionFact["category"]): TokenDefinitionFact[] => this.byKind("token_definition").filter((t) => t.category === category), }; // --------------------------------------------------------------------- // Internals // --------------------------------------------------------------------- private byComponentOfKind(componentId: ComponentId, kind: FactKind): F[] { const ids = this.idsByComponent.get(componentId); if (!ids) return []; const out: F[] = []; for (const id of ids) { const fact = this.facts.get(id); if (fact && fact.kind === kind) out.push(fact as F); } return out; } } function describeFactForConflict(fact: Fact): string { const parts = [`kind=${fact.kind}`]; const maybeLocation = "location" in fact ? fact.location : undefined; if (maybeLocation && typeof maybeLocation === "object") { const loc = maybeLocation as { file?: unknown; line?: unknown; column?: unknown }; if (typeof loc.file === "string") { const line = typeof loc.line === "number" ? `:${loc.line}` : ""; const column = typeof loc.column === "number" ? `:${loc.column}` : ""; parts.push(`location=${loc.file}${line}${column}`); } } if ("nodePath" in fact && typeof fact.nodePath === "string") { parts.push(`nodePath=${fact.nodePath}`); } if ("element" in fact && typeof fact.element === "string") { parts.push(`element=${fact.element}`); } return parts.join(" "); }