/** * `composition/cardinality` + `composition/co-occurrence` — region-scoped rules * over how components are arranged inside a container, not just per node. * * Both share the same region machinery: `regionsByPrefix` groups usage into * `(container, members)`; a `CompositionPattern` names the container (by * canonical `componentId`), a child `select`or, and a `constraint`. The config * rides in `governance_rule_config.options.patterns` — no new fact kind. Brief * 04 delivers that config from cloud policy / fragment prose; this brief drives * the rules from it directly. * * Static-resolution ceiling: a child is "selected" only when its prop resolves * statically to the configured value. A dynamic `variant={kind}` is unknown — * never a match, never treated as absent. Dynamic composition is the * render-time validator's job (doc 14), not this static rule's. * * Matching is on the resolved canonical component (its `componentId`), never the * raw `element` string — that is what makes the rule survive wrappers and * renames. A policy component ref may be fully qualified (`pkg#Name`) or a bare * name (the common authoring case); see `componentMatches`. */ import type { FactId, FactIndex, UsageComponentFact, UsageNodeFact, UsagePropResolvedFact, } from "../facts/index.js"; import type { GovernanceSeverity } from "../governance.js"; import { type CompositionChildSelector, type CompositionConstraint, type CompositionPattern, type CompositionRegionSelector, isCompositionPattern, } from "./composition-pattern-schema.js"; import { makeFinding } from "./finding.js"; import type { Finding } from "./types.js"; import { indexComponentByNodeId, indexPropsByNodeId, regionsByPrefix, type Region, } from "./utils.js"; export const CARDINALITY_RULE_ID = "composition/cardinality"; export const CO_OCCURRENCE_RULE_ID = "composition/co-occurrence"; const RULE_VERSION = "1"; /** ≤ `max` / ≥ `min` of a selected child per matching region. */ export function ruleCompositionCardinality(ix: FactIndex): Finding[] { return evaluate(ix, CARDINALITY_RULE_ID, "cardinality"); } /** A selected child requires a co-occurring sibling in the same region. */ export function ruleCompositionCoOccurrence(ix: FactIndex): Finding[] { return evaluate(ix, CO_OCCURRENCE_RULE_ID, "co-occurrence"); } interface EvalCtx { ix: FactIndex; ruleId: string; severity: GovernanceSeverity; componentByNode: Map; propsByNode: Map; } function evaluate(ix: FactIndex, ruleId: string, kind: CompositionConstraint["kind"]): Finding[] { const config = ix.policy.ruleConfig(ruleId); if (!config || !config.enabled) return []; const patterns = readPatterns(config.options); if (patterns.length === 0) return []; const ctx: EvalCtx = { ix, ruleId, severity: config.severity ?? "warn", componentByNode: indexComponentByNodeId(ix), propsByNode: indexPropsByNodeId(ix), }; const regions = regionsByPrefix(ix); const findings: Finding[] = []; for (const pattern of patterns) { const constraint = pattern.constraint; if (constraint.kind !== kind) continue; for (const region of regions) { if (!regionMatches(region, pattern.region, ctx.componentByNode)) continue; const selected = region.members.filter((m) => memberMatches(m.id, pattern.select, ctx)); const finding = constraint.kind === "cardinality" ? checkCardinality(ctx, region, pattern, constraint, selected) : checkCoOccurrence(ctx, region, pattern, constraint, selected); if (finding) findings.push(finding); } } return findings; } function readPatterns(options: Record | undefined): CompositionPattern[] { const raw = options?.patterns; // Validate each pattern rather than trusting the shape: config arrives from // cloud policy / fragment prose (brief 04) and the facts layer passes // `options` through untyped, so a malformed entry would otherwise emit garbage // findings. A bad pattern is skipped and the rest keep scanning (architecture // failure mode); a non-array yields nothing. if (!Array.isArray(raw)) return []; return raw.filter(isCompositionPattern); } function regionMatches( region: Region, selector: CompositionRegionSelector, componentByNode: Map ): boolean { // v0 matches the container by canonical componentId only. `marker`/`role` are // typed for brief 04 but unexercised by ACCEPTANCE §3–§8; a marker policy will // land with its own case (see brief 03 Deviations). if (!selector.component) return false; return componentMatches( componentByNode.get(region.container.id)?.componentId, selector.component ); } /** * Match a usage node's resolved `componentId` against a policy component ref. * A ref may be fully qualified (`@scope/pkg#Name`) or a bare name (`Name`) — the * common authoring case (ACCEPTANCE §3 and the §10 fragment sugar both write * bare names). Matching the resolved componentId — not the raw JSX element — * survives import aliasing and wrapper renames. */ function componentMatches(componentId: string | undefined, ref: string): boolean { if (componentId === undefined) return false; if (componentId === ref) return true; const hash = componentId.indexOf("#"); return hash !== -1 && componentId.slice(hash + 1) === ref; } function memberMatches(nodeId: FactId, select: CompositionChildSelector, ctx: EvalCtx): boolean { if (!componentMatches(ctx.componentByNode.get(nodeId)?.componentId, select.component)) return false; if (select.prop === undefined) return true; // Static ceiling: only a statically-resolved matching prop counts. A dynamic // or spread prop is unknown — not a match (and not treated as absent). const props = ctx.propsByNode.get(nodeId) ?? []; return props.some((p) => { if (p.prop !== select.prop || p.resolution !== "static") return false; return select.value === undefined || p.value === select.value; }); } function checkCardinality( ctx: EvalCtx, region: Region, pattern: CompositionPattern, constraint: { kind: "cardinality"; max?: number; min?: number }, selected: UsageNodeFact[] ): Finding | null { const count = selected.length; if (constraint.max !== undefined && count > constraint.max) { const extras = selected.slice(constraint.max); // the (max+1)th and later return makeFinding({ ruleId: ctx.ruleId, ruleVersion: RULE_VERSION, severity: ctx.severity, message: cardinalityMaxMessage(pattern, constraint.max, count), location: extras[0].location, evidence: ctx.ix.evidence([region.container.id, ...extras.map((e) => e.id)]), fingerprintIdentity: cardinalityFingerprint(region, pattern, "max"), }); } if (constraint.min !== undefined && count < constraint.min) { return makeFinding({ ruleId: ctx.ruleId, ruleVersion: RULE_VERSION, severity: ctx.severity, message: cardinalityMinMessage(pattern, constraint.min, count), location: region.container.location, evidence: ctx.ix.evidence([region.container.id, ...selected.map((s) => s.id)]), fingerprintIdentity: cardinalityFingerprint(region, pattern, "min"), }); } return null; } function checkCoOccurrence( ctx: EvalCtx, region: Region, pattern: CompositionPattern, constraint: { kind: "co-occurrence"; requires: { prop: string; value: string } }, selected: UsageNodeFact[] ): Finding | null { if (selected.length === 0) return null; // nothing triggers the requirement const requiredSelect: CompositionChildSelector = { component: pattern.select.component, prop: constraint.requires.prop, value: constraint.requires.value, }; const satisfied = region.members.some((m) => memberMatches(m.id, requiredSelect, ctx)); if (satisfied) return null; return makeFinding({ ruleId: ctx.ruleId, ruleVersion: RULE_VERSION, severity: ctx.severity, message: coOccurrenceMessage(pattern, constraint), location: region.container.location, evidence: ctx.ix.evidence([region.container.id, ...selected.map((s) => s.id)]), fingerprintIdentity: { kind: "co-occurrence", file: region.container.file, container: region.container.nodePath, region: pattern.region.component, requires: `${constraint.requires.prop}=${constraint.requires.value}`, }, }); } function cardinalityFingerprint( region: Region, pattern: CompositionPattern, bound: "max" | "min" ): Record { return { kind: "cardinality", bound, file: region.container.file, container: region.container.nodePath, region: pattern.region.component, select: `${pattern.select.prop ?? ""}=${pattern.select.value ?? ""}`, }; } function childLabel(select: CompositionChildSelector, plural: boolean): string { const base = select.value ? `${select.value} ${select.component}` : select.component; return plural ? `${base}s` : base; } /** The human-facing region name; falls back to a generic word when unnamed. */ function regionLabel(pattern: CompositionPattern): string { return pattern.region.component ?? "region"; } function cardinalityMaxMessage(pattern: CompositionPattern, max: number, count: number): string { const region = regionLabel(pattern); const limit = max === 1 ? "one" : String(max); const label = childLabel(pattern.select, max !== 1); const verb = max === 1 ? "is" : "are"; return `At most ${limit} ${label} ${verb} allowed per ${region} (found ${count}).`; } function cardinalityMinMessage(pattern: CompositionPattern, min: number, count: number): string { const region = regionLabel(pattern); const label = childLabel(pattern.select, min !== 1); const verb = min === 1 ? "is" : "are"; return `At least ${min} ${label} ${verb} required per ${region} (found ${count}).`; } function coOccurrenceMessage( pattern: CompositionPattern, constraint: { requires: { value: string } } ): string { const region = regionLabel(pattern); const selectLabel = childLabel(pattern.select, false); const requiresLabel = `${constraint.requires.value} ${pattern.select.component}`; return `A ${region} with a ${selectLabel} should also contain a ${requiresLabel}.`; }