Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 36 additions & 25 deletions packages/adapters/catalog-backstage/test/sc-009.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,24 +20,30 @@
* commit `92e9e4e09c76cc57f3475029b73e5ec84498a459`
* (`evidence/accept-corpus-freeze/accept-corpus-freeze.json`).
*
* **Those descriptor files are not present in this repository.** The freeze records the
* corpus's *metadata* — source paths, canonical ids, the overlay values, the expected
* paths — but not the descriptors themselves. Every fixture in this file is
* maintainer-authored, so none of them meets clause 5's conditions, and asserting
* otherwise would present a synthetic corpus as a third-party one.
* **When Phase E wrote this file those descriptor files were not present in this
* repository.** The freeze records the corpus's *metadata* — source paths, canonical ids,
* the overlay values, the expected paths — but not the descriptors themselves. Every
* fixture in this file is maintainer-authored, so none of them meets clause 5's
* conditions, and asserting otherwise would present a synthetic corpus as a third-party
* one.
*
* Reconstructing descriptors from the freeze was considered and rejected twice over: the
* reconstruction would be maintainer-authored (so still not clause-5 conforming), and
* feeding a corpus derived from the freeze back in would make the input a function of
* the expectations — which is the circularity Barrier B exists to prevent.
*
* Materializing the pinned corpus is **Phase F's** concern: T088 diffs "every annotated
* entity in the frozen accept corpus" against the frozen expectations and cannot run
* without it either. {@link acceptCorpusIsMaterialized} below is an executable record of
* the gap: it fails the day someone vendors the corpus, which is the day this limb
* becomes dischargeable and this file should be completed.
* **That gap is now closed.** T086a vendored the 24 descriptors verbatim from the pin
* into `specs/010-catalog-backstage/corpus/`, verified against the content address the
* pinned commit fixes (`scripts/vendor-accept-corpus.ts`). The block below is updated to
* record the new fact rather than left asserting a false one — but it is updated *only*
* to that extent.
*
* T086 is therefore left unchecked in `tasks.md`, with this reason.
* **Limb 2 is still not discharged here, and T086 stays unchecked.** Discharging it means
* asserting, in this file, that a pass over the frozen accept corpus produces a populated
* envelope. That pass is Phase F's (`scripts/compare-accept-corpus.ts`, T088), and wiring
* its result into SC-009's close-out is T086's own remaining work — outside the scope of
* the session that vendored the corpus. What the block below now records is the state of
* the repository, which is the fact it was always about.
*/

import { afterAll, beforeAll, describe, expect, test } from 'bun:test';
Expand Down Expand Up @@ -66,10 +72,11 @@ afterAll(async () => {
});

/**
* Whether the pinned accept corpus's descriptor files exist anywhere in this repository.
* Whether the pinned accept corpus's descriptor files exist in this repository.
*
* Looks for a vendored corpus directory rather than for the freeze metadata, which is
* committed and is not the corpus.
* Looks for the vendored corpus directory rather than for the freeze metadata, which is
* committed and is not the corpus. Phase E wrote this to record an absence; T086a made
* it a presence, so it now reports which.
*/
function acceptCorpusIsMaterialized(): boolean {
const candidates = [
Expand Down Expand Up @@ -186,17 +193,20 @@ describe('T086 / SC-009 limb 1 — every pass yields an envelope or a clean reje
});
});

describe('T086 / SC-009 limb 2 — NOT discharged, and why', () => {
test('the frozen accept corpus is not materialized in this repository', () => {
// An executable record of the gap rather than a prose note. When someone vendors
// the pinned corpus this test fails, which is the signal that limb 2 has become
// dischargeable and this file should be completed.
expect(acceptCorpusIsMaterialized()).toBe(false);
describe('T086 / SC-009 limb 2 — still NOT discharged here, and why', () => {
test('the frozen accept corpus is now materialized in this repository (T086a)', () => {
// Phase E recorded the opposite, because it was true then. T086a vendored the 24
// pinned descriptors, so the fact changed and this record changed with it. What has
// NOT changed is that limb 2 is undischarged: a populated envelope over this corpus
// is produced by Phase F's harness, and wiring that result into SC-009's close-out
// is T086's remaining work.
expect(acceptCorpusIsMaterialized()).toBe(true);
});

test('the freeze records the corpus metadata, not the descriptors', async () => {
// Evidence for the claim above: what is committed is the selection basis, the
// overlay, and the expected paths — never a descriptor file.
test('the freeze still records the corpus metadata, not the descriptors', async () => {
// Unchanged and load-bearing: the descriptors are vendored OUTSIDE the freeze tree.
// evidence/README.md §4 requires the freeze tree to carry no corpus the generator
// could read, because R5 mechanism 1 (input absence) depends on it.
const freeze = (await Bun.file(
join(REPO_ROOT, 'specs', '010-catalog-backstage', 'evidence', 'accept-corpus-freeze', 'accept-corpus-freeze.json'),
).json()) as Record<string, unknown>;
Expand All @@ -209,10 +219,11 @@ describe('T086 / SC-009 limb 2 — NOT discharged, and why', () => {
expect(Object.hasOwn(freeze, 'sources')).toBe(false);
});

test('every fixture in this suite is maintainer-authored, so none meets clause 5', async () => {
test('every fixture in THIS suite is still maintainer-authored, so none meets clause 5', async () => {
// Stated as an assertion so no reader mistakes a passing accept case above for a
// clause-5 conforming pass. Clause 5 requires descriptors "authored upstream and
// otherwise unmodified"; these were written by this test file.
// otherwise unmodified"; these were written by this test file. The vendored corpus
// is not used here — it is used by scripts/compare-accept-corpus.ts.
const { request } = await stage(
checkout,
{ 'sc009-prov/catalog-info.yaml': validDescriptor('sconineprov', '["packages/p/**"]') },
Expand Down
116 changes: 116 additions & 0 deletions scripts/check-freeze-hashes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { afterEach, describe, expect, test } from 'bun:test';
import { cp, readFile, writeFile } from 'node:fs/promises';
import { join } from 'node:path';
import { cleanupTestDir, resetTestDir } from '../packages/core/test/helpers.ts';
import { canonicalHash } from './audit-oracle-freeze.ts';
import { checkFreezeHashes, REASON_DRIFT } from './check-freeze-hashes.ts';

const DIR_NAME = 'check-freeze-hashes';
Expand Down Expand Up @@ -31,6 +32,121 @@ describe('T022 — freeze-hash drift check (R5 mechanism 2)', () => {
});
});

describe('T091 — the clause-5 prohibition: expectations are never amended to fit output', () => {
/**
* Phase B's hash values, read from the Barrier B checkpoint.
*
* The checkpoint was written by the independent auditor session — the author of the
* T019 audit procedure, and *not* the author of the T014–T018 freeze — before any
* generator existed. So the baseline is not a value Phase E or Phase F could have
* chosen, which is the whole point of comparing against it.
*/
async function phaseBHashes(): Promise<Record<string, string>> {
const checkpoint = JSON.parse(
await readFile(join(LIVE_EVIDENCE, 'barrier-b-checkpoint.json'), 'utf8'),
) as {
confirmations: { mechanism2_hashMatch: { recordedHashes: Record<string, string> } };
};
return checkpoint.confirmations.mechanism2_hashMatch.recordedHashes;
}

test('every frozen hash is unchanged from its Phase B value, across all of E and F', async () => {
// ADR-0020 clause 5: "the expectations are never amended to fit the output". A
// comparison that passes because the expectations moved is not a passing comparison.
const baseline = await phaseBHashes();
expect(Object.keys(baseline).sort()).toEqual([
'accept-corpus-freeze/accept-corpus-freeze.json',
'frozen-expectations/frozen-expectation-set.json',
]);

for (const [artifact, phaseB] of Object.entries(baseline)) {
const parsed = JSON.parse(await readFile(join(LIVE_EVIDENCE, artifact), 'utf8')) as Record<
string,
unknown
>;
// Two comparisons, not one. The recorded field could have been edited to match
// edited bytes; the recomputation could not.
expect(parsed['contentHash']).toBe(phaseB);
expect(canonicalHash(parsed)).toBe(phaseB);
}
});

test('the drift check is green over the live trees after Phase E and Phase F', async () => {
const result = await checkFreezeHashes(LIVE_EVIDENCE);
expect(result.ok).toBe(true);
expect(result.findings).toEqual([]);
});

test('Phase F wrote its evidence outside the frozen trees', async () => {
// The comparison's own artifacts live in evidence/comparison/, which is not one of
// the two FREEZE_DIRS. If step (b) had written into a frozen tree, `checked` would
// name a third artifact.
const result = await checkFreezeHashes(LIVE_EVIDENCE);
expect(result.checked).toEqual([
'frozen-expectations/frozen-expectation-set.json',
'accept-corpus-freeze/accept-corpus-freeze.json',
]);
expect(await Bun.file(join(LIVE_EVIDENCE, 'comparison/diff-report.json')).exists()).toBe(true);
});

test('the T091 record agrees with what this test independently recomputed', async () => {
// The record is produced by the harness run; this test recomputes from the artifacts.
// Two derivations of the same fact, so a wrong record cannot pass unnoticed.
const record = (await Bun.file(
join(LIVE_EVIDENCE, 'comparison/expectations-unchanged.json'),
).json()) as {
allUnchanged: boolean;
comparisons: readonly {
artifact: string;
phaseBRecordedHash: string;
recomputedNow: string;
unchangedSincePhaseB: boolean;
selfConsistentNow: boolean;
}[];
};
expect(record.allUnchanged).toBe(true);

const baseline = await phaseBHashes();
for (const entry of record.comparisons) {
expect(entry.unchangedSincePhaseB).toBe(true);
expect(entry.selfConsistentNow).toBe(true);
expect(entry.phaseBRecordedHash).toBe(baseline[entry.artifact] as string);
const parsed = JSON.parse(
await readFile(join(LIVE_EVIDENCE, entry.artifact), 'utf8'),
) as Record<string, unknown>;
expect(entry.recomputedNow).toBe(canonicalHash(parsed));
}
});

test('observed failing: an amended expectation is caught against the Phase B baseline', async () => {
// The prohibition's failure mode, driven. An artifact edited to fit output and
// re-hashed is SELF-consistent — its own contentHash matches its own bytes — so the
// drift check alone would pass it. Comparing against Phase B's independently
// recorded value is what catches it, and that is why this assertion exists
// separately from T023's.
const original = JSON.parse(
await readFile(join(LIVE_EVIDENCE, 'frozen-expectations/frozen-expectation-set.json'), 'utf8'),
) as Record<string, unknown>;
const baseline = await phaseBHashes();

const { contentHash: _dropped, ...unsigned } = original;
const amended = {
...unsigned,
derivedPathPatterns: [...(original['derivedPathPatterns'] as string[]), 'amended/**'],
};
const resigned = { ...amended, contentHash: canonicalHash(amended) };

// Self-consistent, so the narrower drift check would not flag it...
expect(canonicalHash(resigned)).toBe(resigned.contentHash);
// ...and yet it is NOT the artifact Phase B recorded.
expect(resigned.contentHash).not.toBe(
baseline['frozen-expectations/frozen-expectation-set.json'],
);
// The live artifact is untouched; nothing above was written to disk.
expect(original['contentHash']).toBe(baseline['frozen-expectations/frozen-expectation-set.json']);
});
});

describe('T023 — observed failing: a single mutated byte in a frozen artifact', () => {
test('mutating one byte makes the drift check FAIL with the exact reason; restoring makes it PASS', async () => {
const root = await resetTestDir(DIR_NAME);
Expand Down
Loading