/** * Generator-provenance by CONTENT-HASH for the capsule-compile corpus. * * The capsule factory compiler (`scripts/capsule-compile.ts`) writes generated * test/bench files under `tests/generated/` and a `reports/capsule-manifest.json` * listing every capsule. Historically a generated artifact traced back to its * source by MTIME only (`capsule:verify` flagged staleness via * `sourceAge > testAge`), which is fragile: git checkouts don't preserve mtimes, * and a skip-if-unchanged optimization once let an inner gauntlet `capsule:compile` * skip while a source mtime landed between two runs, falsely tripping staleness * (see the `atomicWrite` note in `scripts/capsule-compile.ts`). * * This module binds each generated artifact to its source by CONTENT-HASH, the * same source-content ⊕ generator-logic split the gauntlet B2 cache uses * (coverage-digest catches files-under-test changes; toolchain-digest catches the * gate-doing-the-testing changes): * * - {@link sourceProvenanceDigest} — a blake3 digest of the SOURCE call-site * file's bytes (paired with its repo-relative path). A source edit changes the * bytes ⇒ a new digest, deterministically and mtime-independently. * - {@link generatorVersionDigest} — a blake3 digest over the GENERATOR's own * logic source ({@link GENERATOR_SOURCE_FILES}: the compile driver + the * harness generators + the type-directed detector). A generator-logic edit * rebuilds the digest even when every source file is byte-identical — so a * change to HOW artifacts are generated invalidates the whole corpus, exactly * like the toolchain-digest keystone. * * Both digests are blake3 (via the `@czap/core` `AddressedDigest` over canonical * CBOR bytes), NOT bare fnv1a — fnv1a is the 32-bit display id and collides at * repo scale. Neither digest mixes in any volatile field (no `Date.now`, no * mtime, no run id); `generatedAt` stays a separate volatile manifest field, * never an input to a digest. * * The compiler RECORDS these (per-entry `sourceDigest` + top-level * `generatorVersion`); `capsule:verify` RECOMPUTES them from the live repo and * compares — a fast, deterministic staleness SUSPICION that REPLACES the mtime * heuristic. Regeneration byte-compare remains the CONFIRMATION (the content-hash * is the cheap suspicion; regeneration is the proof — same shape as the B2 * coverage-digest ⊕ correctness-property pairing). * * @module */ import { readFileSync } from 'node:fs'; import { resolve } from 'node:path'; import { InvariantViolationError } from '@czap/error'; import { AddressedDigest, canonicalAddressBytes } from '@czap/core'; /** * THE PATH CONTRACT for the provenance digests: callers pass REPO-RELATIVE POSIX * paths. This is the manifest invariant — `scripts/capsule-compile.ts` normalizes * every path through `@czap/audit`'s `normalizeRepoPath` (the single B5b slash- * normalize home) BEFORE writing `reports/capsule-manifest.json`, so every path * that flows back into these digests is already forward-slashed. `@czap/command` * therefore does NOT (and must not) re-normalize: it never imports the heavy * `@czap/audit` TS-compiler/glob engine — the ADR-0012/D7b host-injection * boundary that keeps `@czap/mcp-server` lean. * * `assertRepoRelativePosix` is a GUARD, not a normalizer: it fails LOUD with a * tagged error if a caller violates the contract (a backslash slipped through), * rather than silently laundering a Windows path into a normalized one. A guard * that throws is not a `\\→/` rewrite, so it does not — and must not — duplicate * the slash-normalize home the cage forbids in published packages. */ function assertRepoRelativePosix(value: string): string { if (value.includes('\\')) { throw InvariantViolationError( 'capsule-provenance', `path must be repo-relative POSIX (the manifest invariant); got a backslash in ${JSON.stringify(value)}`, ); } return value; } /** * The repo-relative source files whose bytes constitute the capsule * generator's LOGIC. A change to any of these (the compile driver, a harness * template, the bench marker/classifier, or the type-directed detector) is a * generator-logic change that must invalidate every generated artifact's * provenance — the content-hash analogue of the gauntlet toolchain-digest. * * SOURCE OF TRUTH: this list IS the definition of "the generator." It must * enumerate every module whose source text can change the generated bytes. * `tests/unit/devops/capsule-provenance.test.ts` pins it: each listed file must * exist, and the harness generators it names must match the live harness * directory (so a NEW harness arm that isn't enrolled here fails RED rather than * silently escaping generator-version invalidation). * * Ordered + frozen so the digest fold is deterministic across machines. */ export const GENERATOR_SOURCE_FILES: readonly string[] = Object.freeze([ 'scripts/capsule-compile.ts', 'scripts/lib/capsule-detector.ts', 'packages/core/src/harness/index.ts', 'packages/core/src/harness/pure-transform.ts', 'packages/core/src/harness/receipted-mutation.ts', 'packages/core/src/harness/state-machine.ts', 'packages/core/src/harness/site-adapter.ts', 'packages/core/src/harness/policy-gate.ts', 'packages/core/src/harness/cached-projection.ts', 'packages/core/src/harness/scene-composition.ts', 'packages/core/src/harness/bench-marker.ts', 'packages/core/src/harness/bench-classify.ts', 'packages/core/src/harness/arbitrary-from-schema.ts', ]); /** A `blake3:` integrity digest string (the provenance currency). */ export type ProvenanceDigest = string; /** * Blake3 digest of a source call-site file, bound to its repo-relative path. * Deterministic + mtime-independent: identical bytes at the same path ⇒ * identical digest, across machines and across checkouts. Reads the file at * `root`-relative `sourceRel`. */ export function sourceProvenanceDigest(root: string, sourceRel: string): ProvenanceDigest { const path = assertRepoRelativePosix(sourceRel); const bytes = readFileSync(resolve(root, sourceRel), 'utf8'); // Hash the (path, bytes) pair so two byte-identical sources at different paths // mint distinct provenance — the path is part of the artifact's identity. const canonical = canonicalAddressBytes({ path, bytes }); return AddressedDigest.of(canonical, 'blake3').integrity_digest; } /** * Blake3 digest over the GENERATOR-LOGIC source set ({@link GENERATOR_SOURCE_FILES}). * A change to any generator module yields a new token; a corpus carrying an older * token is stale-by-generator (its bytes may be byte-identical to the source, but * the logic that would produce them has changed). Reads each file at * `root`-relative paths; missing/unreadable files throw (a generator file that * vanished is itself a defect, never silently degraded). */ export function generatorVersionDigest(root: string): ProvenanceDigest { const members = GENERATOR_SOURCE_FILES.map((rel) => ({ path: assertRepoRelativePosix(rel), bytes: readFileSync(resolve(root, rel), 'utf8'), })); const canonical = canonicalAddressBytes({ generatorSources: members }); return AddressedDigest.of(canonical, 'blake3').integrity_digest; } /** Per-capsule provenance recorded in the manifest entry. */ export interface CapsuleProvenance { /** Blake3 digest of the source call-site file (bound to its path). */ readonly sourceDigest: ProvenanceDigest; }