/** * Content-addressed fact IDs. * * A fact ID is a deterministic function of (kind, identity). Two runs over the * same inputs produce identical IDs; identity attribute order is irrelevant * because the payload is canonicalized before hashing. * * The hash is a 64-bit FNV-1a (computed as two 32-bit halves with different * seeds). It is not a cryptographic hash — fact IDs are integrity references * inside one build, not security tokens. */ import type { ComponentId, FactId } from "./types.js"; // --------------------------------------------------------------------------- // Canonical JSON // --------------------------------------------------------------------------- export function canonicalJson(value: unknown): string { if (value === undefined) return "null"; if (value === null || typeof value !== "object") return JSON.stringify(value); if (Array.isArray(value)) { return `[${value.map(canonicalJson).join(",")}]`; } const entries = Object.entries(value as Record) .filter(([, v]) => v !== undefined) .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)); return `{${entries.map(([k, v]) => `${JSON.stringify(k)}:${canonicalJson(v)}`).join(",")}}`; } // --------------------------------------------------------------------------- // 64-bit FNV-1a (two 32-bit halves with different seeds) // --------------------------------------------------------------------------- const FNV_PRIME = 0x01000193; const FNV_SEED_A = 0x811c9dc5; const FNV_SEED_B = 0x9e3779b1; function fnv1a32(input: string, seed: number): number { let hash = seed >>> 0; for (let i = 0; i < input.length; i++) { const code = input.charCodeAt(i); hash ^= code & 0xff; hash = Math.imul(hash, FNV_PRIME) >>> 0; if (code > 0xff) { hash ^= (code >>> 8) & 0xff; hash = Math.imul(hash, FNV_PRIME) >>> 0; } } return hash >>> 0; } export function hash64Hex(input: string): string { const a = fnv1a32(input, FNV_SEED_A); const b = fnv1a32(input, FNV_SEED_B); return a.toString(16).padStart(8, "0") + b.toString(16).padStart(8, "0"); } // --------------------------------------------------------------------------- // IDs // --------------------------------------------------------------------------- export function componentId(packageName: string, name: string): ComponentId { if (!packageName.length || !name.length) { throw new Error("componentId requires a non-empty packageName and name"); } return `${packageName}#${name}` as ComponentId; } export function asComponentId(value: string): ComponentId { if (!value.includes("#")) { throw new Error(`componentId must look like "package#Name", got "${value}"`); } return value as ComponentId; } /** * Compute a content-addressed fact ID. Identity is canonicalized so attribute * order is irrelevant. */ export function factId(kind: string, identity: Record): FactId { const payload = canonicalJson({ k: kind, i: identity }); return `${kind}:${hash64Hex(payload)}` as FactId; }