/** * Frontend Contract preimage — the projection that defines what the contract IS. * * The FCID used to be a hash of the raw `code-contracts` artifact payload. That * conflated identity with display: a copy-edit to a component description moved * the hash, and tokens / canonical mappings / policy — the things gates actually * enforce — were not part of identity at all. This module widens the preimage to * the full enforcement surface as ONE shared projection, imported by both Cloud * (mint) and the CLI (reportedHash). Neither side may reimplement it: parity is * a property of the shared import, not a test you can fail. * * Enforced-only is implemented structurally: {@link projectContractPreimage} * copies an explicit pick-list of fields per domain. Anything an adapter passes * beyond that list (descriptions, docs URLs, timestamps, heuristic signals) * never reaches the hash. Proposed canonical mappings are excluded for the same * reason — they cannot block, so they are not identity; confirmation is the * moment a mapping enters the contract. * * The preimage itself is the record of four domain sub-hashes, so * `contractHash(preimage)` is the fcid and a domain diff is a four-string * compare — no deep structural diffing. Determinism (key sorting, undefined * dropping, number normalization) is inherited from `canonicalPreimage`; this * module adds element-level sorting for the arrays it owns and nothing else. * * Runtime-portable by construction: no Node APIs, no Convex imports, no React. */ import type { GovernanceConfig } from "../governance.js"; import { canonicalPreimage, contractHash } from "./hash.js"; // --------------------------------------------------------------------------- // Domains // --------------------------------------------------------------------------- /** * The fixed domain enum. Adding a domain is a spine change (update * `01-architecture.md` first); never rename — deprecate and alias. */ export const CONTRACT_DOMAINS = ["components", "tokens", "canonicalMap", "policy"] as const; export type ContractDomain = (typeof CONTRACT_DOMAINS)[number]; // --------------------------------------------------------------------------- // Catalog input — the neutral shape adapters assemble // --------------------------------------------------------------------------- /** * One component's enforcement identity. Props are the *name* membership surface * (what unknown-prop checks today); prop types/defaults are not enforced by the * rules engine, so they are not identity — enforced-only cuts both ways. */ export interface ContractComponentInput { name: string; /** Parent component name when this is a subcomponent (compound identity). */ parentName?: string; /** Prop names the component accepts. */ props?: readonly string[]; /** * The per-component enforcement contract as authored (bans, a11y rules, * required compound children). Passed through whole — it is enforcement data * by definition. */ contract?: unknown; /** Required compound-children names, when the structure is declared. */ compoundChildren?: readonly string[]; } export interface ContractTokenInput { name: string; value: string | number; type?: string; } export type CanonicalMappingStatus = "confirmed" | "proposed" | "unknown"; export interface ContractPropMappingInput { rawProp: string; canonicalProp: string; valueMap?: Readonly>; } export interface ContractCanonicalMappingInput { /** The raw/source component the mapping covers. */ component: string; /** The canonical component it maps to. */ canonical: string; status: CanonicalMappingStatus; /** Local/offline canonical import target, when the mapping is config-authored. */ importPath?: string; /** Explicit raw element or semantic resolutions covered by a local declaration. */ resolves?: readonly { tag: string; role?: string; inputType?: string; }[]; propMapping?: readonly ContractPropMappingInput[]; } /** * An authored waiver record. Hashed as data — including `expiresOn` as a plain * date string. Expiry *evaluation* happens at enforcement time and must never * change the fcid (no time-dependent hashing). */ export interface ContractWaiverInput { id: string; /** The diagnostic code or rule the waiver suppresses. */ target: string; reason: string; /** YYYY-MM-DD, treated as opaque data. */ expiresOn?: string; } /** * Active policy: everything that changes what a gate would allow or deny. * Waivers live here (spine open question 1, resolved): a waiver changes what * blocks, so it is enforcement-relevant. */ export interface ContractPolicyInput { rules?: Readonly>; codes?: Readonly>; compositionPatterns?: readonly unknown[]; waivers?: readonly ContractWaiverInput[]; } /** * The neutral catalog shape both adapters (Cloud mint, CLI scan) assemble. * Adapters gather data; the projection owns all normalization — element order, * optional fields, and display-data stripping never reach the hash. */ export interface ContractCatalogInput { components?: readonly ContractComponentInput[]; tokens?: readonly ContractTokenInput[]; canonicalMappings?: readonly ContractCanonicalMappingInput[]; policy?: ContractPolicyInput | null; } // --------------------------------------------------------------------------- // Preimage // --------------------------------------------------------------------------- export const CONTRACT_PREIMAGE_SCHEMA = "fcid-preimage:v1" as const; /** * The hashed identity record: four domain sub-hashes plus the projection schema * version (so an intentional projection change mints a new fcid). The fcid is * `contractHash(preimage)` — hash of the domain hashes, per the spine pipeline. */ export interface ContractPreimage { schema: typeof CONTRACT_PREIMAGE_SCHEMA; domains: Record; } /** * Sort projected elements by their canonical serialization: total, deterministic, * and independent of adapter iteration order, without per-type comparators. */ function sortCanonical(items: readonly T[]): T[] { return items .map((item) => ({ item, key: canonicalPreimage(item) })) .sort((a, b) => (a.key < b.key ? -1 : a.key > b.key ? 1 : 0)) .map(({ item }) => item); } function projectComponents(components: readonly ContractComponentInput[] | undefined): unknown { return sortCanonical( (components ?? []).map((component) => ({ name: component.name, parentName: component.parentName, props: component.props ? [...component.props].sort() : undefined, contract: component.contract, compoundChildren: component.compoundChildren ? [...component.compoundChildren].sort() : undefined, })) ); } function projectTokens(tokens: readonly ContractTokenInput[] | undefined): unknown { return sortCanonical( (tokens ?? []).map((token) => ({ name: token.name, value: token.value, type: token.type, })) ); } function projectCanonicalMap( mappings: readonly ContractCanonicalMappingInput[] | undefined ): unknown { return sortCanonical( (mappings ?? []) .filter((mapping) => mapping.status === "confirmed") .map((mapping) => ({ component: mapping.component, canonical: mapping.canonical, importPath: mapping.importPath, resolves: mapping.resolves ? sortCanonical(mapping.resolves) : undefined, propMapping: mapping.propMapping ? sortCanonical( mapping.propMapping.map((entry) => ({ rawProp: entry.rawProp, canonicalProp: entry.canonicalProp, valueMap: entry.valueMap, })) ) : undefined, })) ); } function projectPolicy(policy: ContractPolicyInput | null | undefined): unknown { if (!policy) return null; return { rules: policy.rules, codes: policy.codes, compositionPatterns: policy.compositionPatterns ? sortCanonical(policy.compositionPatterns) : undefined, waivers: policy.waivers ? sortCanonical( policy.waivers.map((waiver) => ({ id: waiver.id, target: waiver.target, reason: waiver.reason, expiresOn: waiver.expiresOn, })) ) : undefined, }; } /** * Project a catalog onto the contract preimage: four enforced-only domain * bodies, each hashed independently. `contractHash(projectContractPreimage(x))` * is the fcid. */ export function projectContractPreimage(catalog: ContractCatalogInput): ContractPreimage { return { schema: CONTRACT_PREIMAGE_SCHEMA, domains: { components: contractHash(projectComponents(catalog.components)), tokens: contractHash(projectTokens(catalog.tokens)), canonicalMap: contractHash(projectCanonicalMap(catalog.canonicalMappings)), policy: contractHash(projectPolicy(catalog.policy)), }, }; } /** * Name the domains whose identity differs between two preimages — a four-string * compare, no structural diffing. Returned in fixed {@link CONTRACT_DOMAINS} * order. Staleness checks compare these, never whole fcids. */ export function diffContractDomains(a: ContractPreimage, b: ContractPreimage): ContractDomain[] { return CONTRACT_DOMAINS.filter((domain) => a.domains[domain] !== b.domains[domain]); } /** * Map defineFragment-shaped records — the `code-contracts` payload's * `fragments` array (Cloud mint) or the CLI's loaded fragment definitions — * onto their enforcement identity. The one shared mapping is what keeps the * two adapters from drifting into a second "catalog → preimage" * implementation: both sides gather fragments however they like, then this * picks the identity fields. Records without a string `meta.name` are skipped * (no identity to hash). */ /** * The enforced-only slice of a resolved CLI governance config — the fields * that change what a scan allows or denies. CI gate options, agent repair * order, and runner config change how/when gates run, not what they enforce, * so they are not identity. This pick-list lives here, beside the projection, * so adapters cannot drift into private enforced-only boundaries: a new * enforcement field on {@link GovernanceConfig} is added to the contract in * exactly one place. */ export function contractPolicyFromGovernanceConfig( config: GovernanceConfig | null | undefined ): ContractPolicyInput | null { if (!config) return null; return { rules: { severity: config.severity, rules: config.rules, scales: config.scales, styles: config.styles, jsx: config.jsx, tailwind: config.tailwind, components: config.components, overrides: config.overrides, }, codes: config.codes as Readonly> | undefined, compositionPatterns: config.compositionPatterns as readonly unknown[] | undefined, }; } export function contractComponentsFromFragments( fragments: readonly unknown[] ): ContractComponentInput[] { const components: ContractComponentInput[] = []; for (const candidate of fragments) { const fragment = candidate as { meta?: { name?: unknown; parentComponentName?: unknown }; props?: Record; contract?: unknown; structure?: { compoundChildren?: unknown }; } | null; const name = fragment?.meta?.name; if (typeof name !== "string" || !name) continue; const parentName = fragment?.meta?.parentComponentName; const rawChildren = fragment?.structure?.compoundChildren; const compoundChildren = Array.isArray(rawChildren) ? rawChildren .map((child) => (child as { name?: unknown } | null)?.name) .filter((childName): childName is string => typeof childName === "string") : undefined; components.push({ name, parentName: typeof parentName === "string" ? parentName : undefined, props: Object.keys(fragment?.props ?? {}), contract: fragment?.contract, compoundChildren, }); } return components; }