diff --git a/packages/adapters/catalog-backstage/src/envelope/completeness.ts b/packages/adapters/catalog-backstage/src/envelope/completeness.ts new file mode 100644 index 00000000..c231997c --- /dev/null +++ b/packages/adapters/catalog-backstage/src/envelope/completeness.ts @@ -0,0 +1,69 @@ +/** + * T070 — `completeness.wholeCatalog` is `false` **unconditionally**, in every + * envelope, on every path. + * + * # There is no input that can make it true + * + * FR-014 and `input-manifest.md` §5's fourth bullet: generation never "[c]laims or + * implies whole-catalog completeness — `SnapshotEnvelope.completeness.wholeCatalog` is + * always `false` for every envelope". That is not a default; it is the only value. + * + * The reason is structural rather than stylistic. FR-013 and `input-manifest.md` §5 + * forbid recursive walking and glob-based discovery, so a run reads exactly the files + * one manifest names and cannot have observed anything else. An envelope claiming + * whole-catalog completeness would be asserting an observation the input boundary made + * impossible. + * + * # How "no configuration can change it" is enforced + * + * {@link completeness} takes **one** parameter, and it is not `wholeCatalog`. There is + * no flag, option, or override to thread through, so the value cannot be made + * configurable by a caller without changing this signature — which is a visible, + * reviewable edit rather than a call-site someone quietly passed `true` at. + * + * `manifest/boundary.ts` already exports `WHOLE_CATALOG_COMPLETENESS`, stating the + * same fact as a property *of the input boundary*. This module consumes that constant + * rather than restating `false`, so the two cannot drift into disagreeing. + * + * # `identityOnly` is a different question and is not pinned here + * + * `snapshot-envelope.md` §2 step 5 makes `identityOnly` the consumer's one signal for + * rejecting a partial envelope, "determined **solely** from this boolean field, never + * by scanning the entity list's `ownershipState` distribution". It is a real input to + * this function because a future generation mode could legitimately produce an + * identity-only envelope. `wholeCatalog` has no such future, which is why only one of + * the two is a parameter. + * + * @see `specs/010-catalog-backstage/spec.md` FR-014 + * @see `specs/009-catalog-binding-viability/contracts/input-manifest.md` §5 + */ + +import { WHOLE_CATALOG_COMPLETENESS } from '../manifest/boundary.ts'; + +/** `data-model.md` §9's `completeness` object. */ +export interface EnvelopeCompleteness { + readonly wholeCatalog: boolean; + readonly identityOnly: boolean; +} + +/** + * Build the `completeness` object. + * + * @param identityOnly whether this envelope carries identity without derived + * ownership. `false` for every envelope this pipeline produces today; it is a + * parameter because the consumer's step 5 treats it as the one authoritative signal + * and a value that could never be `true` would make that step untestable from the + * generator side. + */ +export function completeness(identityOnly: boolean): EnvelopeCompleteness { + return { wholeCatalog: WHOLE_CATALOG_COMPLETENESS, identityOnly }; +} + +/** + * The one value `wholeCatalog` may take, re-exported for a check to assert against. + * + * Re-exported rather than redeclared: a second `false` here could stay `false` while + * the boundary's own constant changed, and the check would then pass against a + * constant nothing uses. + */ +export { WHOLE_CATALOG_COMPLETENESS }; diff --git a/packages/adapters/catalog-backstage/src/envelope/digest.ts b/packages/adapters/catalog-backstage/src/envelope/digest.ts new file mode 100644 index 00000000..2602599c --- /dev/null +++ b/packages/adapters/catalog-backstage/src/envelope/digest.ts @@ -0,0 +1,129 @@ +/** + * T081 — the envelope digest: SHA-256 over the canonical form of every field except + * `digest` itself, rendered as 64 lowercase hexadecimal characters. + * + * # Which `canonicalStringify` + * + * `@adrkit/core`'s — defined at `packages/core/src/fingerprint/index.ts:16`, exported + * at `packages/core/src/index.ts:24`, ordering via `compareCodeUnits` at + * `packages/core/src/ordering/index.ts:12`. + * + * **Never** the same-named function at `packages/evaluator/src/report/serialize.ts:38`. + * `package-boundary.md` §2.1: "It is a different function with a different signature + * (`(root, pretty = false)`). Importing it would also cross a package boundary the + * allowlist in §2 does not permit." The adapter's allowlist is `@adrkit/core`, + * `picomatch`, `yaml` — `@adrkit/evaluator` is not on it, so the mistake is caught by + * `check:deps` as well as by this note. + * + * # The scope qualification travels with every digest claim + * + * `package-boundary.md` §2.2 and `snapshot-envelope.md` §3, restated here rather than + * cited, because a reader who finds this function is the reader who needs it: + * + * - For the envelope's **closed scalar domain** — strings, booleans, and bounded + * non-negative integers — `canonicalStringify`'s bytes are *equivalent to* RFC 8785 / + * JCS output. **No claim is made that `canonicalStringify` is a general-purpose + * RFC 8785 implementation for arbitrary JSON values**, and no document under this + * feature may make one. + * - The digest proves **accidental-corruption and naive-mutation detection only** + * (FR-041). It does **not** resist an adversary who mutates content and recomputes + * the digest with the same algorithm. Adversarial tamper-resistance is an explicitly + * open question this feature does not attempt. + * - Integrity is not correctness. A digest-verified envelope is evidence that the bytes + * are the bytes that were written. It is **not** evidence that the derived ownership + * in it is right — that is SC-011's question, and SC-012 forbids conflating them. + * + * # `digest` is excluded by construction, not by convention + * + * {@link computeEnvelopeDigest} takes an {@link UnsignedEnvelope} — the envelope type + * with `digest` omitted — so a caller cannot hand it a signed envelope by accident. A + * `delete` on a copy, or a `{ ...envelope, digest: undefined }`, would both compile and + * both be one edit away from hashing a field that is meant to be the hash. + * + * @see `specs/009-catalog-binding-viability/contracts/snapshot-envelope.md` §3 + * @see `specs/010-catalog-backstage/contracts/package-boundary.md` §2.1, §2.2 + * @see `specs/010-catalog-backstage/spec.md` FR-040, FR-041 + */ + +import { createHash } from 'node:crypto'; +import { canonicalStringify } from '@adrkit/core'; +import type { SnapshotEnvelope } from './shape.ts'; + +/** An envelope before its digest exists. The one input the digest is computed over. */ +export type UnsignedEnvelope = Omit; + +/** 64 lowercase hexadecimal characters. */ +export const ENVELOPE_DIGEST_PATTERN = /^[0-9a-f]{64}$/u; + +/** The algorithm name recorded on each `sources[]` entry, and used here. */ +export const DIGEST_ALGORITHM = 'sha256'; + +/** + * The canonical form the digest is computed over. + * + * Exposed separately from {@link computeEnvelopeDigest} for two reasons that both + * matter. It lets a check assert the *serialization* — recursive key sort at every + * nesting level, arrays left in declaration order, compact separators — rather than + * only the 64 hex characters that come out the far end, where every bug looks the same. + * And it lets an independent recomputation compare canonical strings, which says + * *where* two envelopes differ; comparing digests only says *that* they differ. + */ +export function canonicalEnvelopeForm(envelope: UnsignedEnvelope): string { + return canonicalStringify(envelope); +} + +/** + * SHA-256 over the UTF-8 bytes of the canonical form, as 64 lowercase hex characters. + * + * `createHash('sha256').digest('hex')` already yields lowercase hex; the assertion + * below is not a formality but the guard against a future change to that default going + * unnoticed. `snapshot-envelope.md` §3 fixes the rendering as part of the contract, and + * a consumer comparing digests as strings would silently fail on uppercase. + */ +export function computeEnvelopeDigest(envelope: UnsignedEnvelope): string { + const digest = createHash(DIGEST_ALGORITHM) + .update(canonicalEnvelopeForm(envelope), 'utf8') + .digest('hex'); + + if (!ENVELOPE_DIGEST_PATTERN.test(digest)) { + throw new Error( + `computed digest ${JSON.stringify(digest)} is not 64 lowercase hex characters, which ` + + 'snapshot-envelope.md \u00a73 requires. Refusing to emit an envelope whose digest a ' + + 'conforming consumer would not recognize.', + ); + } + + return digest; +} + +/** The outcome of recomputing a signed envelope's digest. `data-model.md` §12. */ +export interface DigestCheckResult { + readonly declaredDigest: string; + readonly recomputedDigest: string; + readonly outcome: 'match' | 'digest-mismatch'; +} + +/** + * Recompute a signed envelope's digest and compare it with the declared value. + * + * The `digest` field is stripped by destructuring rather than by `delete`, so the + * envelope handed in is never mutated — a recomputation that modified its input would + * make the second call disagree with the first. + * + * **This is not the independent recomputation SC-013 requires.** SC-013 asks that "the + * recorded digest matches an **independent** recomputation, not the generator's own", + * and this function is the generator's own: it shares `canonicalStringify` with the + * code that produced the digest, so it cannot detect a fault in that shared step. It is + * useful for detecting corruption *after* generation, which is a different question. + * `test/sc-013.test.ts` performs the independent recomputation, and + * `packages/catalog-envelope/` carries the consumer-side one. + */ +export function verifyEnvelopeDigest(envelope: SnapshotEnvelope): DigestCheckResult { + const { digest: declaredDigest, ...unsigned } = envelope; + const recomputedDigest = computeEnvelopeDigest(unsigned); + return { + declaredDigest, + recomputedDigest, + outcome: declaredDigest === recomputedDigest ? 'match' : 'digest-mismatch', + }; +} diff --git a/packages/adapters/catalog-backstage/src/envelope/provenance.ts b/packages/adapters/catalog-backstage/src/envelope/provenance.ts new file mode 100644 index 00000000..1631479b --- /dev/null +++ b/packages/adapters/catalog-backstage/src/envelope/provenance.ts @@ -0,0 +1,254 @@ +/** + * T082 — the provenance boundary: upstream-authored descriptor content and + * maintainer-authored annotation overlay are recorded as **distinct** provenances and + * never merged into an undifferentiated whole. + * + * # The domain is closed, and it describes the ANNOTATION + * + * `data-model.md` §10 (lines 427–476, read in this worktree): + * + * ```text + * AnnotationProvenance = "upstream-authored" | "maintainer-overlay" + * ``` + * + * | Value | Meaning, verbatim from §10's table | + * |---|---| + * | `upstream-authored` | The `adrkit.io/owned-paths` annotation was already present in the real upstream descriptor as found. | + * | `maintainer-overlay` | The annotation was authored by us and overlaid onto an otherwise-unmodified upstream descriptor. | + * + * §10 is emphatic that this "describes the ANNOTATION, not the descriptor... under + * **ADR-0020 clause 5** the descriptors are upstream-authored in *both* cases — the + * clause requires them 'authored upstream and otherwise unmodified' — so a value + * meaning 'the descriptor came from upstream' would be true always and would + * distinguish nothing." + * + * That is what makes FR-043 satisfiable: clause 5's "only the corpus data is + * third-party, never the validation" boundary becomes legible from the artifact. + * + * # Why the declaration is an input, and why it is required + * + * The generator reads descriptor files off disk. A file that carries the annotation + * because an upstream author wrote it and a file that carries the annotation because a + * maintainer overlaid it are **byte-identical on disk**. No amount of reading can tell + * them apart, so the distinction has to be declared. + * + * It is not declared in the manifest: `data-model.md` §1's manifest schema is closed at + * five top-level fields and `manifest/schema.ts` rejects an unrecognized one. So it + * arrives as part of the generation request, alongside the manifest path — which is + * consistent with ADR-0013's "the generator is invoked directly by name", where the + * function signature *is* the interface. + * + * **It is exhaustive and has no default, and that is the safety property.** A default + * of `upstream-authored` would mean that forgetting to declare an overlay silently + * emits a claim that a **third party** adopted our annotation — the exact overclaim + * ADR-0020 clause 5's boundary exists to prevent, produced by an omission rather than + * by a decision. A default of `maintainer-overlay` would instead erase genuine upstream + * adoption. Requiring an entry per listed source removes the choice: an undeclared + * source is invalid input, not a guess. See `failure/triggers.ts` for why that lands on + * the `other-invalid-input` backstop rather than on one of the fourteen named classes. + * + * # One gap, reported rather than papered over + * + * The domain has **no** value for "this descriptor carries no annotation at all", and + * `ownershipState: 'annotation-absent'` is the overwhelmingly common real-corpus case + * (`structural-fixtures-and-corpora.md` §6, carried forward by `contracts/README.md` + * §2 delta D3). Neither table row is literally true of such an entity: no annotation + * was found upstream, and none was overlaid. + * + * This implementation records the **source file's** declared provenance for those + * entities too, and does not invent a third value — `data-model.md` §10 fixes the + * domain at two and inventing a third would put the generator outside the closed + * domain the consumer validates against (`snapshot-envelope.md` §2 step 2 requires "a + * recognized `provenance`"). + * + * What makes that safe is that `provenance` **alone** is not an adoption claim: the + * pair `(ownershipState, provenance)` is. An entity recorded as + * `annotation-absent` + `upstream-authored` says "nothing was overlaid onto this file", + * which is true, and cannot be read as adoption because `ownershipState` says there is + * no annotation. Only `explicit-paths`/`explicit-empty` **with** `upstream-authored` + * asserts that a third party adopted the annotation. {@link isAdoptionClaim} names that + * pair so a check can assert on it directly. + * + * The gap itself belongs to `data-model.md` §10 and is reported, not fixed here. + * + * @see `specs/010-catalog-backstage/data-model.md` §10 + * @see `specs/010-catalog-backstage/spec.md` FR-043 + */ + +import { compareCodeUnits } from '@adrkit/core'; +import { type Rejection, otherInvalidInput } from '../failure/triggers.ts'; +import type { OwnershipState } from '../ownership/states.ts'; + +/** `data-model.md` §10's closed two-value domain. */ +export type AnnotationProvenance = 'upstream-authored' | 'maintainer-overlay'; + +/** Both members, as data, so a check can assert the domain is exactly two values. */ +export const ANNOTATION_PROVENANCES = [ + 'upstream-authored', + 'maintainer-overlay', +] as const satisfies readonly AnnotationProvenance[]; + +/** The reasons a provenance declaration can be rejected. All map to the backstop. */ +export type ProvenanceDeclarationReason = + | 'provenance-declaration-missing' + | 'provenance-declaration-unknown-source' + | 'provenance-declaration-unrecognized-value'; + +/** + * The caller's declaration: one {@link AnnotationProvenance} per manifest source path. + * + * Exhaustive over the manifest's `sources[].path` values — no more, no fewer. It is + * keyed by **source path** rather than by canonical id because the maintainer overlays + * a *file*, and because an entity's identity is not known until after the file has been + * read, admitted, and canonicalized. + */ +export interface ProvenanceDeclaration { + readonly bySourcePath: Readonly>; +} + +/** A validated declaration, or exactly one rejection. */ +export type ProvenanceCheck = + | { readonly ok: true; readonly declaration: ProvenanceDeclaration } + | { readonly ok: false; readonly rejection: Rejection }; + +function isProvenance(value: unknown): value is AnnotationProvenance { + return (ANNOTATION_PROVENANCES as readonly string[]).includes(value as string); +} + +/** + * Check a declaration against the manifest's source list. + * + * Three rejections, in a fixed order so a declaration violating two always reports the + * same one — the same first-match-wins discipline `input-manifest.md` §2 imposes on the + * version checks, for the same reason: a reported reason that depends on evaluation + * order is not reproducible evidence. + * + * Source paths are examined in `compareCodeUnits` order rather than declaration order, + * so the reported path for a declaration missing two entries is a function of the + * content and not of object key insertion order. + */ +export function checkProvenanceDeclaration( + declaration: ProvenanceDeclaration, + sourcePaths: readonly string[], +): ProvenanceCheck { + const declared = declaration.bySourcePath; + const listed = [...new Set(sourcePaths)].sort(compareCodeUnits); + + for (const path of listed) { + if (!Object.hasOwn(declared, path)) { + return { + ok: false, + rejection: otherInvalidInput( + 'provenance-declaration-missing', + `no annotation provenance was declared for manifest source ${JSON.stringify(path)}. ` + + 'FR-043 requires that upstream-authored annotation content stay distinguishable ' + + 'against maintainer-authored overlay, and neither value may be assumed: ' + + 'defaulting to upstream-authored would claim third-party adoption that was ' + + 'never attested.', + ), + }; + } + } + + for (const path of Object.keys(declared).sort(compareCodeUnits)) { + if (!listed.includes(path)) { + return { + ok: false, + rejection: otherInvalidInput( + 'provenance-declaration-unknown-source', + `annotation provenance was declared for ${JSON.stringify(path)}, which the manifest does ` + + 'not list as a source. A declaration about a file this run never reads is a claim ' + + 'with nothing behind it.', + ), + }; + } + + const value: unknown = declared[path]; + if (!isProvenance(value)) { + return { + ok: false, + rejection: otherInvalidInput( + 'provenance-declaration-unrecognized-value', + `annotation provenance for ${JSON.stringify(path)} is ${JSON.stringify(value)}; the closed ` + + `domain is ${ANNOTATION_PROVENANCES.map((one) => JSON.stringify(one)).join(' | ')} ` + + '(data-model.md \u00a710).', + ), + }; + } + } + + return { ok: true, declaration }; +} + +/** + * The provenance recorded for an entity read out of `sourcePath`. + * + * Throws for an undeclared path rather than substituting a value. Reaching this + * function with an undeclared path means {@link checkProvenanceDeclaration} did not run + * first, and inventing a provenance at that point would fabricate exactly the + * attestation this field exists to carry. + */ +export function provenanceFor( + declaration: ProvenanceDeclaration, + sourcePath: string, +): AnnotationProvenance { + const value = declaration.bySourcePath[sourcePath]; + if (value === undefined) { + throw new Error( + `no annotation provenance declared for ${JSON.stringify(sourcePath)}. ` + + 'checkProvenanceDeclaration must run before any entity record is built.', + ); + } + return value; +} + +/** + * Whether an `(ownershipState, provenance)` pair asserts that a third party adopted + * the `adrkit.io/owned-paths` annotation. + * + * True **only** when an annotation actually exists and is declared upstream-authored. + * `annotation-absent` is never an adoption claim whatever its provenance, because + * there is no annotation to have been adopted. + * + * This is the predicate that makes the module note's gap safe, and it is exported so a + * check can assert on the pair rather than on `provenance` in isolation — which is + * where the misreading would otherwise happen. + */ +export function isAdoptionClaim( + ownershipState: OwnershipState, + provenance: AnnotationProvenance, +): boolean { + return ownershipState !== 'annotation-absent' && provenance === 'upstream-authored'; +} + +/** + * A declaration marking every listed source as maintainer overlay. + * + * A convenience for callers overlaying annotations onto an otherwise-unmodified + * upstream corpus — which is the construction ADR-0020 clause 5 names and the one the + * frozen accept corpus uses (`accept-corpus-freeze/overlay.json`: "No descriptor in the + * pinned corpus carries `adrkit.io/owned-paths`... Every annotation value below was + * written by the maintainer"). + * + * Deliberately **not** a default. A caller must reach for it by name, which is a + * decision recorded at the call site; a default would be an omission recorded nowhere. + */ +export function allMaintainerOverlay(sourcePaths: readonly string[]): ProvenanceDeclaration { + const bySourcePath: Record = {}; + for (const path of sourcePaths) bySourcePath[path] = 'maintainer-overlay'; + return { bySourcePath }; +} + +/** + * A declaration marking every listed source as upstream-authored. + * + * Also not a default, and for a stronger reason than {@link allMaintainerOverlay}: used + * together with a present annotation this asserts third-party adoption, which + * ADR-0020 clause 5 permits only as an attested fact. A caller naming this function is + * making that attestation explicitly. + */ +export function allUpstreamAuthored(sourcePaths: readonly string[]): ProvenanceDeclaration { + const bySourcePath: Record = {}; + for (const path of sourcePaths) bySourcePath[path] = 'upstream-authored'; + return { bySourcePath }; +} diff --git a/packages/adapters/catalog-backstage/src/envelope/shape.ts b/packages/adapters/catalog-backstage/src/envelope/shape.ts new file mode 100644 index 00000000..efc794a2 --- /dev/null +++ b/packages/adapters/catalog-backstage/src/envelope/shape.ts @@ -0,0 +1,239 @@ +/** + * T080 — the envelope's declared fields, and **exactly five** fields per `entities[]` + * record. The flatter triple shape is forbidden. + * + * # The shape + * + * `data-model.md` §9 — **nine** top-level fields (counted from its type block, lines + * 392–402, read in this worktree): `schemaVersion`, `repository`, `generatorVersion`, + * `globDialect`, `capabilities`, `completeness`, `sources`, `entities`, `digest`. + * + * `data-model.md` §10 and `snapshot-envelope.md` §1 — **five** fields per entity + * record: a nested `identity` of `{ canonicalId, allRefs }`, `ownershipState`, + * `derivedPaths`, a serialized `sourceDocument` of + * `{ sourcePath, documentIndexInFile }`, and `provenance`. + * + * # Why "exactly five" is a real constraint and not a restatement of the type + * + * §10: "A flatter shape is **forbidden**... never a flatter `canonicalId` / `refs` / + * `paths` triple, and never the full internal objects — so that the on-disk envelope + * and the declared type are one identical defined type rather than two + * independently-drifting shapes for the same record." + * + * A TypeScript interface does not enforce this. Excess-property checking applies to + * object literals, not to values that arrive through a variable, and it is erased + * entirely at runtime — so a record built by spreading an internal object would + * typecheck and would serialize with extra fields. {@link ENTITY_RECORD_FIELDS} exists + * so the constraint can be checked against the **emitted JSON**, which is the only + * place it is actually observable. + * + * The identity projection is `{ canonicalId, allRefs }` **only**. `snapshot-envelope.md` + * §1 gives the reason: `rawKind`/`rawNamespace`/`rawName` are "pre-lowercase authoring + * inputs already fully captured by `canonicalId` and `allRefs`". Serializing them would + * put the authored casing back into an artifact whose whole point is the canonical + * form. + * + * # This shape is declared here, and independently in the consumer, on purpose + * + * `package-boundary.md` §5: both packages declare the envelope's shape independently, + * and that is "the single deliberate duplication in the design", because a shared type + * module would be an import edge and "if both packages derived their view of the + * envelope from one declaration, the consumer could not detect a generator that had + * changed the shape — the shape would have changed on both sides at once." + * + * So this module must **not** import anything from `@adrkit/catalog-envelope`, and the + * cost — that the two declarations can diverge — is accepted, with the consumer's + * validation failing as the intended signal. + * + * # Field order in the emitted object + * + * Irrelevant to the digest, which sorts keys at every level + * (`snapshot-envelope.md` §3), and irrelevant to a JSON reader. It is nonetheless + * fixed here in the contract's own order, because `envelope/write.ts` serializes with + * `JSON.stringify`, whose output follows insertion order — so a stable order is what + * makes the **file** byte-identical across runs (FR-042), independently of the digest. + * + * @see `specs/010-catalog-backstage/data-model.md` §9, §10 + * @see `specs/009-catalog-binding-viability/contracts/snapshot-envelope.md` §1 + * @see `specs/010-catalog-backstage/spec.md` FR-039 + */ + +import type { GLOB_OPTIONS } from '../glob/dialect.ts'; +import type { OwnershipState } from '../ownership/states.ts'; +import type { EnvelopeCompleteness } from './completeness.ts'; +import type { AnnotationProvenance } from './provenance.ts'; + +/** The only `schemaVersion` this generator emits. `snapshot-envelope.md` §1. */ +export const ENVELOPE_SCHEMA_VERSION = '1'; + +/** The only capability tuple this generator emits. `snapshot-envelope.md` §2 step 3. */ +export const ENVELOPE_CAPABILITIES = ['pathOwnership'] as const; + +/** `data-model.md` §9's `repository`. */ +export interface EnvelopeRepository { + readonly id: string; + readonly revision: string; +} + +/** `data-model.md` §9's `globDialect`. */ +export interface EnvelopeGlobDialect { + readonly engine: string; + readonly version: string; + readonly options: typeof GLOB_OPTIONS; +} + +/** `data-model.md` §9's `EnvelopeSource`. */ +export interface EnvelopeSource { + readonly path: string; + readonly digestAlgorithm: 'sha256'; + readonly digest: string; +} + +/** `data-model.md` §10's reduced identity projection. Two fields, never more. */ +export interface SerializedEntityIdentity { + readonly canonicalId: string; + /** Non-empty. `canonicalId` is always a member. */ + readonly allRefs: readonly string[]; +} + +/** `data-model.md` §10's serialized source reference. */ +export interface SerializedSourceDocument { + readonly sourcePath: string; + readonly documentIndexInFile: number; +} + +/** `data-model.md` §10 — **exactly five** fields. */ +export interface SnapshotEntityRecord { + readonly identity: SerializedEntityIdentity; + readonly ownershipState: OwnershipState; + readonly derivedPaths: readonly string[]; + readonly sourceDocument: SerializedSourceDocument; + readonly provenance: AnnotationProvenance; +} + +/** `data-model.md` §9 — **nine** top-level fields. */ +export interface SnapshotEnvelope { + readonly schemaVersion: string; + readonly repository: EnvelopeRepository; + readonly generatorVersion: string; + readonly globDialect: EnvelopeGlobDialect; + readonly capabilities: readonly string[]; + readonly completeness: EnvelopeCompleteness; + readonly sources: readonly EnvelopeSource[]; + readonly entities: readonly SnapshotEntityRecord[]; + /** SHA-256 over the canonical form of every other field. `envelope/digest.ts`. */ + readonly digest: string; +} + +/** + * The nine top-level field names, in `data-model.md` §9's order. + * + * Data rather than only a type, because a type union is erased at runtime and cannot be + * counted — the same argument `src/diagnostics.ts` makes for `TRIGGER_CLASSES`. + */ +export const ENVELOPE_TOP_LEVEL_FIELDS = [ + 'schemaVersion', + 'repository', + 'generatorVersion', + 'globDialect', + 'capabilities', + 'completeness', + 'sources', + 'entities', + 'digest', +] as const; + +/** The five entity-record field names, in `data-model.md` §10's order. */ +export const ENTITY_RECORD_FIELDS = [ + 'identity', + 'ownershipState', + 'derivedPaths', + 'sourceDocument', + 'provenance', +] as const; + +/** The two field names of the reduced identity projection. */ +export const IDENTITY_PROJECTION_FIELDS = ['canonicalId', 'allRefs'] as const; + +/** + * The field names §10 forbids at the top level of an entity record. + * + * Enumerated so the check tests for the **specific** forbidden shape rather than + * inferring it from a field count. A record carrying `canonicalId`, `refs` and `paths` + * has three fields, so a count-only check would reject it for the wrong reason and + * would keep passing if someone later added two more fields to reach five. + */ +export const FORBIDDEN_FLAT_ENTITY_FIELDS = [ + 'canonicalId', + 'refs', + 'paths', + 'rawKind', + 'rawNamespace', + 'rawName', +] as const; + +/** Everything the pipeline has determined by the time an entity record is built. */ +export interface EntityRecordInput { + readonly canonicalId: string; + readonly allRefs: readonly string[]; + readonly ownershipState: OwnershipState; + readonly derivedPaths: readonly string[]; + readonly sourcePath: string; + readonly documentIndexInFile: number; + readonly provenance: AnnotationProvenance; +} + +/** + * Project one entity onto the five-field record. + * + * Written field by field, never by spreading the pipeline's internal entity. A spread + * would carry whatever the internal type happened to hold — including the pre-lowercase + * authoring fields §1 excludes — and would keep doing so silently as that type grew. + * Naming the five is what makes the projection a projection. + */ +export function entityRecord(input: EntityRecordInput): SnapshotEntityRecord { + return { + identity: { canonicalId: input.canonicalId, allRefs: input.allRefs }, + ownershipState: input.ownershipState, + derivedPaths: input.derivedPaths, + sourceDocument: { + sourcePath: input.sourcePath, + documentIndexInFile: input.documentIndexInFile, + }, + provenance: input.provenance, + }; +} + +/** Everything the pipeline has determined by the time the envelope is assembled. */ +export interface EnvelopeInput { + readonly repository: EnvelopeRepository; + readonly generatorVersion: string; + readonly globDialect: EnvelopeGlobDialect; + readonly completeness: EnvelopeCompleteness; + readonly sources: readonly EnvelopeSource[]; + readonly entities: readonly SnapshotEntityRecord[]; + readonly digest: string; +} + +/** + * Assemble the nine-field envelope. + * + * `schemaVersion` and `capabilities` are not parameters: `snapshot-envelope.md` §2 + * step 3 requires the consumer validate both "by **exact value**, not merely + * 'recognized'", so a generator that could emit a different value would be a generator + * that could emit an envelope its own consumer rejects. `capabilities` is spread into a + * fresh array so the module-level tuple cannot be mutated through the envelope. + */ +export function assembleEnvelope(input: EnvelopeInput): SnapshotEnvelope { + return { + schemaVersion: ENVELOPE_SCHEMA_VERSION, + repository: input.repository, + generatorVersion: input.generatorVersion, + globDialect: input.globDialect, + capabilities: [...ENVELOPE_CAPABILITIES], + completeness: input.completeness, + sources: input.sources, + entities: input.entities, + digest: input.digest, + }; +} diff --git a/packages/adapters/catalog-backstage/src/envelope/write.ts b/packages/adapters/catalog-backstage/src/envelope/write.ts new file mode 100644 index 00000000..da9291d5 --- /dev/null +++ b/packages/adapters/catalog-backstage/src/envelope/write.ts @@ -0,0 +1,118 @@ +/** + * T079 — the versioned envelope is the **only** output: no side files, no logs + * presented as output, no auxiliary artifacts. + * + * # The rule + * + * ADR-0020 clause 7, quoted by FR-038: "The generator writes the envelope and nothing + * else." FR-038 adds the specific prohibition: it "MUST NOT write a + * `CatalogSnapshot`-shaped artifact directly, under any circumstance." + * + * That second half is the one worth restating. Deriving a `CatalogSnapshot` from an + * envelope is a real and necessary operation — it is simply **not this package's**. + * It belongs to `@adrkit/catalog-envelope`, on the far side of a boundary whose entire + * interface is the envelope file (`package-boundary.md` §3). A generator that wrote a + * derived snapshot would make the consumer's validation optional, and an optional + * integrity check is not one. + * + * # What "only" is enforced by + * + * {@link writeEnvelope} performs exactly two filesystem writes — a temporary file and + * the `rename` that puts it in place — and returns the single path it wrote. There is + * no logger, no report, no manifest of outputs, and no second destination parameter. + * A caller wanting diagnostics gets them as a **returned value** + * (`pipeline.ts`'s `stages`), never as a file, because "logs presented as output" is + * named in FR-038 as one of the things this forbids. + * + * # Atomicity of the write itself + * + * `atomic-fail-closed.md` §1 requires "no usable partial snapshot"; T073 spells out + * "no partial envelope, no partial file, no truncated stream". Two of those are handled + * before this module runs — `pipeline.ts` assembles the whole envelope in memory and + * aborts before calling here, so an abort never reaches a write at all. + * + * The third is handled here. A single `Bun.write` to the destination can be interrupted + * part-way and leave a truncated file at the path a consumer will read. So the bytes go + * to a temporary file **in the destination directory** and are then `rename`d into + * place: `rename` within one filesystem is atomic, so a reader sees either the previous + * state or the complete envelope, never a prefix of it. The temporary file is created + * in the same directory rather than in `/tmp` precisely because a cross-device rename + * is not atomic — it degrades to copy-then-unlink, which reintroduces the truncation + * window this exists to close. + * + * On an interrupted run the temporary file may survive. That is the intended failure + * direction: a leftover `.tmp` is inert and visible, whereas a truncated envelope at + * the real path is neither. + * + * @see `specs/010-catalog-backstage/spec.md` FR-038 + * @see `specs/010-catalog-backstage/contracts/atomic-fail-closed.md` §1 + */ + +import { mkdir, rename, rm } from 'node:fs/promises'; +import { dirname, join } from 'node:path'; +import type { SnapshotEnvelope } from './shape.ts'; + +/** + * The serialized envelope, as the bytes that are written. + * + * `JSON.stringify` with no indentation. Two-space indentation would be friendlier to + * read and would put whitespace into the file whose exact bytes FR-042 requires be + * identical across runs — reproducible either way, but compact output makes the file's + * bytes a function of the envelope's content alone rather than of a formatting choice + * this module could later change. + * + * A trailing newline **is** included. It costs one byte, is stable across runs, and + * makes the file well-formed for the line-oriented tools a maintainer will inevitably + * point at it. + * + * Note this is *not* the canonical form the digest is computed over + * (`envelope/digest.ts`). The digest's canonical form sorts keys at every level; this + * preserves the declaration order `envelope/shape.ts` fixes. Both are deterministic, + * and they are deliberately different serializations for different jobs. + */ +export function serializeEnvelope(envelope: SnapshotEnvelope): string { + return `${JSON.stringify(envelope)}\n`; +} + +/** What a successful write reports. One path, because one file is written. */ +export interface WriteResult { + readonly path: string; + readonly byteLength: number; +} + +/** + * Write the envelope to `destination`, atomically, and write nothing else. + * + * The parent directory is created if absent — that is a directory, not an artifact, and + * failing because a caller's chosen output directory does not exist yet would be a + * usability failure with no integrity benefit. + * + * @param destination the full path of the envelope file to write + */ +export async function writeEnvelope( + envelope: SnapshotEnvelope, + destination: string, +): Promise { + const text = serializeEnvelope(envelope); + const bytes = new TextEncoder().encode(text); + + const directory = dirname(destination); + await mkdir(directory, { recursive: true }); + + // Same directory as the destination: a cross-device rename is not atomic, and a + // temporary file under the system temp directory is very often on another device. + const temporary = join(directory, `.${envelope.digest}.envelope.tmp`); + + try { + await Bun.write(temporary, bytes); + await rename(temporary, destination); + } catch (error) { + // Best-effort only, and deliberately not reported as a failure of its own: the + // write already failed, and a cleanup error would replace the real cause with a + // secondary one. A surviving temporary file is inert. + await rm(temporary, { force: true }).catch(() => undefined); + throw error; + } + + return { path: destination, byteLength: bytes.byteLength }; +} diff --git a/packages/adapters/catalog-backstage/src/failure/abort.ts b/packages/adapters/catalog-backstage/src/failure/abort.ts new file mode 100644 index 00000000..a147b08d --- /dev/null +++ b/packages/adapters/catalog-backstage/src/failure/abort.ts @@ -0,0 +1,148 @@ +/** + * T073 — whole-operation abort: no partial envelope, no partial file, no truncated + * stream, and a non-zero exit status. + * + * # The rule, and the mistake it forecloses + * + * `atomic-fail-closed.md` §1: any invalid input "MUST abort the **entire** run with + * non-zero status and produce **no usable partial snapshot**, including for entities + * that would otherwise have validated cleanly in the same run." §1 then names the one + * implementation mistake this exists to prevent: *"skip the bad entity and keep + * going"* — "explicitly wrong under this contract, regardless of how reasonable it + * might seem as a convenience." + * + * # How "no usable partial output" is made structural rather than promised + * + * Three separate mechanisms, because each closes a different route to a partial + * artifact and none of them closes the others: + * + * 1. **The failure branch carries no envelope.** {@link GenerationOutcome} is a + * discriminated union whose `ok: false` arm has no `envelope` member at all. There + * is no field a caller could read a half-built snapshot out of, so "use the + * partial output" is not a thing a caller can express. + * 2. **Nothing is written before the whole pipeline has succeeded.** `pipeline.ts` + * assembles the envelope in memory and only then hands it to `envelope/write.ts`. + * An abort therefore happens strictly before any write is attempted, so there is + * no partial file to clean up — which matters, because cleanup can fail. + * 3. **The write itself is atomic.** `envelope/write.ts` writes to a temporary file in + * the destination directory and `rename`s it into place. A crash mid-write leaves + * the temporary file, never a truncated envelope at the destination path. This is + * what closes "no truncated stream", which mechanisms 1 and 2 do not: they prevent + * an *intentional* partial write, not an interrupted complete one. + * + * # Exit status + * + * FR-034 requires a non-zero **process** exit status. This module owns the mapping + * from an outcome to that status; it deliberately does not call `process.exit` itself, + * because a library that terminates its host process cannot be tested by the host it + * terminated. {@link exitCodeFor} is the seam, and `test/abort.test.ts` spawns a real + * subprocess through it so the non-zero status is an observed process exit rather than + * an asserted constant. + * + * @see `specs/010-catalog-backstage/contracts/atomic-fail-closed.md` §1, §2, §3 + * @see `specs/010-catalog-backstage/spec.md` FR-034 + */ + +import type { Rejection, TriggerClass } from './triggers.ts'; + +/** `data-model.md` §8. */ +export interface AtomicFailureRecord { + readonly triggerClass: TriggerClass; + /** Human-readable. `data-model.md` §8: "never load-bearing". */ + readonly detail: string; + readonly sourcePath: string | undefined; + readonly documentIndex: number | undefined; + /** + * The validator's own fine-grained reason. + * + * Beyond `data-model.md` §8's four fields, and carried deliberately: ADR-0016 + * records the **exact emitted string**, and fifteen trigger classes cannot + * distinguish which of seven lexical path rules fired. `src/diagnostics.ts` makes + * the same argument for carrying both on a {@link Rejection}. Reported as a §8 + * extension rather than treated as settled. + */ + readonly reason: string; + /** The pipeline stage that produced the abort. See `pipeline.ts`. */ + readonly stage: string; +} + +/** Where a rejection happened, when the pipeline knows. */ +export interface FailureLocation { + readonly sourcePath?: string | undefined; + readonly documentIndex?: number | undefined; +} + +/** + * Turn one validator {@link Rejection} into the run's single + * {@link AtomicFailureRecord}. + * + * Exactly one record per abort — see `failure/classify.ts`, which owns the + * exactly-one and correct-class properties. This function is the constructor those + * properties are asserted about. + */ +export function abortRecord( + rejection: Rejection, + stage: string, + location: FailureLocation = {}, +): AtomicFailureRecord { + return { + triggerClass: rejection.triggerClass, + detail: rejection.detail, + sourcePath: location.sourcePath, + documentIndex: location.documentIndex, + reason: rejection.reason, + stage, + }; +} + +/** The exit status a successful run reports. */ +export const EXIT_OK = 0; + +/** + * The exit status **every** abort reports, whichever of the fifteen triggers fired. + * + * One value rather than a per-trigger code, because `atomic-fail-closed.md` §4.2 says + * the consequence "applies identically regardless of which named trigger, or the + * backstop, fired". A per-trigger exit code would make the consequence vary by + * trigger, which is exactly what the contract denies. + */ +export const EXIT_ABORT = 1; + +/** + * The result of one generation run. + * + * The failure arm has no `envelope` field. That absence is the type-level statement of + * §1's "no usable partial snapshot": a caller cannot read one out because there is + * nowhere for one to be. + */ +export type GenerationOutcome = + | { + readonly ok: true; + readonly envelope: TEnvelope; + /** Every stage entered, in order. See `pipeline.ts`. */ + readonly stages: readonly string[]; + } + | { + readonly ok: false; + readonly failure: AtomicFailureRecord; + readonly stages: readonly string[]; + }; + +/** FR-034's non-zero status, as a pure mapping. */ +export function exitCodeFor(outcome: GenerationOutcome): number { + return outcome.ok ? EXIT_OK : EXIT_ABORT; +} + +/** + * The envelope produced by a run, or `undefined` if it aborted. + * + * A helper rather than a property access, so that the one place a caller is tempted to + * write `outcome.envelope` unconditionally has a total function to reach for instead. + * It returns `undefined` on the failure branch because there is no partial envelope — + * not because one is being withheld. + */ +export function envelopeOf( + outcome: GenerationOutcome, +): TEnvelope | undefined { + return outcome.ok ? outcome.envelope : undefined; +} diff --git a/packages/adapters/catalog-backstage/src/failure/classify.ts b/packages/adapters/catalog-backstage/src/failure/classify.ts new file mode 100644 index 00000000..0b37aced --- /dev/null +++ b/packages/adapters/catalog-backstage/src/failure/classify.ts @@ -0,0 +1,204 @@ +/** + * T076 — **exactly one** trigger class per abort, and the **correct** one. + * + * # Why a registry, and not just "each validator gets it right" + * + * FR-037 requires that each abort record exactly one trigger class "and that class + * MUST be the correct one — not a neighbouring class that happens to also be + * reachable." A test that reads the class off the validator and asserts it equals the + * class the validator emitted is a tautology: it passes for a validator that has + * collapsed two classes into one, because both sides of the comparison moved together. + * + * {@link REASON_TRIGGER_REGISTRY} is transcribed from the **contracts**, not from the + * validators. It is a second, independent statement of the mapping, so comparing a + * validator's emitted pair against it is an actual check. When the two disagree, one + * of them is wrong and the disagreement is the signal. + * + * That is the same argument `package-boundary.md` §5 makes for declaring the envelope + * shape twice: "two independent declarations are what make the consumer's structural + * validation an actual check rather than a tautology." + * + * # The pairs §4.3 says are easiest to collapse + * + * `atomic-fail-closed.md` §4.3 names two, and `admissibility.md` supplies a third that + * is an *ordering* property rather than a naming one: + * + * | Pair | The wrong merge | What keeps them apart | + * |---|---|---| + * | `duplicate-yaml-key` / `invalid-yaml-syntax` | reporting a repeated mapping key as generic bad YAML | `descriptor/read.ts` branches on the `yaml` library's own `DUPLICATE_KEY` code before reading any value | + * | `invalid-manifest-shape` / `unsupported-manifest-version` | reporting an unrecognized top-level field as a version problem | the registry below; `contracts/README.md` §4.2 fixes which side governs | + * | `inadmissible-descriptor` / `duplicate-canonical-id` | canonicalizing first, so an inadmissible descriptor collides before it is found inadmissible | `admissibility/index.ts`'s brand: canonicalization consumes an `AdmittedDescriptor` and nothing else | + * + * The third is not fixed by a mapping at all — a mapping cannot express an ordering — + * which is why it is enforced by a type and checked by `test/sc-004.test.ts`. + * + * @see `specs/010-catalog-backstage/contracts/atomic-fail-closed.md` §4.3 + * @see `specs/010-catalog-backstage/contracts/README.md` §4.2, §4.3 + * @see `specs/010-catalog-backstage/spec.md` FR-037 + */ + +import { + type AtomicFailureRecord, + type FailureLocation, + abortRecord, +} from './abort.ts'; +import { type Rejection, type TriggerClass } from './triggers.ts'; + +/** + * Every fine-grained reason this package can emit, mapped to the trigger class the + * contracts assign it. + * + * **Transcribed from the contracts, with each group's authority named.** Where a + * contract was silent, `contracts/README.md` §4 resolves it and is cited on the group. + * Nothing here was read off an implementation. + */ +export const REASON_TRIGGER_REGISTRY: Readonly> = { + // ── Manifest shape. `input-manifest.md` §1's closed schema. The unrecognized-field + // case is the one `contracts/README.md` §4.2 resolves: §1 calls it an + // "unsupported manifest version"-class rejection, `atomic-fail-closed.md` §4 + // assigns it to `invalid-manifest-shape`, and §4 governs as the later and more + // specific statement. + 'manifest-not-json': 'invalid-manifest-shape', + 'manifest-not-an-object': 'invalid-manifest-shape', + // A manifest that is not there cannot parse as JSON, which is §4.3's own first + // clause for this class. It gets its own **reason** rather than reusing + // `manifest-not-json`, because ADR-0016 records the exact emitted string and "there + // is no file here" and "this file is not JSON" are different findings with different + // fixes. `manifest/schema.ts` cannot own it: that module starts from text. + 'manifest-unreadable': 'invalid-manifest-shape', + 'unrecognized-top-level-field': 'invalid-manifest-shape', + 'missing-required-field': 'invalid-manifest-shape', + 'field-wrong-type': 'invalid-manifest-shape', + 'multiple-repositories': 'invalid-manifest-shape', + 'unrecognized-nested-field': 'invalid-manifest-shape', + + // ── Manifest version and capability. `input-manifest.md` §2's table — three + // rejections, each 1:1 with its own class. `unsupported-manifest-version` + // presumes a manifest that parsed and shape-checked and declares an unsupported + // *value* (`atomic-fail-closed.md` §4.3). + 'unsupported-manifest-version': 'unsupported-manifest-version', + 'unsupported-snapshot-version': 'unsupported-snapshot-version', + 'unsupported-capability': 'unsupported-capability', + + // ── Source path, stage 1 — lexical, before any filesystem access. + // `input-manifest.md` §4.1 names no class for stage 1; `contracts/README.md` §4.3 + // resolves the silence: a lexically invalid path is a defect in the manifest's + // own content, discovered before the file is ever opened, so + // `invalid-manifest-shape`. + 'path-empty': 'invalid-manifest-shape', + 'path-dot-or-dotdot': 'invalid-manifest-shape', + 'path-absolute': 'invalid-manifest-shape', + 'path-drive-prefix': 'invalid-manifest-shape', + 'path-backslash': 'invalid-manifest-shape', + 'path-traversal-segment': 'invalid-manifest-shape', + 'path-control-character': 'invalid-manifest-shape', + + // ── Source path, stage 2, and source digests. `input-manifest.md` §4.1 names + // stage 2's class explicitly — `incomplete-required-source`, "and the file is + // never opened". + 'path-escapes-checkout-root': 'incomplete-required-source', + 'digest-malformed': 'incomplete-required-source', + 'source-missing': 'incomplete-required-source', + 'source-unreadable': 'incomplete-required-source', + 'digest-mismatch': 'incomplete-required-source', + + // ── Repository identity. `input-manifest.md` §3. + 'repository-mismatch': 'repository-mismatch', + + // ── Descriptor parse. `atomic-fail-closed.md` §4.3's first collapsible pair. + 'duplicate-yaml-key': 'duplicate-yaml-key', + 'invalid-yaml-syntax': 'invalid-yaml-syntax', + + // ── Admissibility. ADR-0015 Condition of Acceptance 2; `admissibility.md` §5.1. + 'inadmissible-descriptor': 'inadmissible-descriptor', + + // ── Annotation decode, steps 2–4. `owned-paths-annotation.md` §1. Steps 2 and 3 + // share `invalid-annotation-parse` and stay distinct at the reason level, which + // is what §1 requires; `data-model.md` §8 carries only two annotation classes, so + // a 1:1 mapping onto three reasons is not available. + 'annotation-value-not-a-string': 'invalid-annotation-parse', + 'parse-error': 'invalid-annotation-parse', + 'wrong-shape': 'invalid-annotation-shape', + + // ── Annotation decode, step 5. Delegated to `glob-dialect.md`. + 'invalid-pattern': 'invalid-pattern', + + // ── Identity uniqueness. `entity-identity.md` §3's collision table. + 'duplicate-canonical-id': 'duplicate-canonical-id', + 'duplicate-canonical-ref': 'duplicate-canonical-ref', + + // ── The backstop. `atomic-fail-closed.md` §4.2. See `failure/triggers.ts` for why + // this is genuinely reachable rather than a formality. + 'provenance-declaration-missing': 'other-invalid-input', + 'provenance-declaration-unknown-source': 'other-invalid-input', + 'provenance-declaration-unrecognized-value': 'other-invalid-input', +}; + +/** The class the contracts assign `reason`, or `undefined` if it is not registered. */ +export function expectedTriggerFor(reason: string): TriggerClass | undefined { + return REASON_TRIGGER_REGISTRY[reason]; +} + +/** + * Thrown when a rejection's own trigger class disagrees with the registry. + * + * A throw rather than a silent correction, and the direction matters: silently + * rewriting the class to the registry's value would make the two agree by fiat and + * destroy the evidence that they had disagreed. FR-037's requirement is that the class + * be *correct*, and a disagreement means the implementation and the contract have + * diverged — which a reader needs to see, not have repaired underneath them. + */ +export class TriggerClassificationError extends Error { + constructor( + readonly reason: string, + readonly emitted: TriggerClass, + readonly expected: TriggerClass | undefined, + ) { + super( + expected === undefined + ? `reason ${JSON.stringify(reason)} is not in REASON_TRIGGER_REGISTRY, so its trigger class ` + + `${JSON.stringify(emitted)} cannot be checked against the contracts. Register it rather ` + + 'than trusting the emitter.' + : `reason ${JSON.stringify(reason)} emitted trigger class ${JSON.stringify(emitted)}, but the ` + + `contracts assign it ${JSON.stringify(expected)}. Exactly one of the two is wrong ` + + '(FR-037; atomic-fail-closed.md §4.3).', + ); + this.name = 'TriggerClassificationError'; + } +} + +/** + * Build the run's single {@link AtomicFailureRecord}, checking the class as it goes. + * + * Every abort in `pipeline.ts` goes through this function, so the registry check is + * not something a caller can forget to run. A rejection whose class disagrees with the + * registry does not produce a record at all. + * + * "Exactly one" is a property of the return type as much as of the behaviour: this + * returns one record, never an array, so there is no shape in which a second could + * travel. + */ +export function classifyAbort( + rejection: Rejection, + stage: string, + location: FailureLocation = {}, +): AtomicFailureRecord { + const expected = expectedTriggerFor(rejection.reason); + if (expected !== rejection.triggerClass) { + throw new TriggerClassificationError(rejection.reason, rejection.triggerClass, expected); + } + return abortRecord(rejection, stage, location); +} + +/** + * The pairs `atomic-fail-closed.md` §4.3 identifies as most at risk of being merged, + * as data. + * + * Exported so the check that they stay distinct enumerates the contract's own list + * rather than whichever pairs a test author happened to think of. + */ +export const COLLAPSIBLE_PAIRS: readonly (readonly [TriggerClass, TriggerClass])[] = [ + ['duplicate-yaml-key', 'invalid-yaml-syntax'], + ['invalid-manifest-shape', 'unsupported-manifest-version'], + ['inadmissible-descriptor', 'duplicate-canonical-id'], +]; diff --git a/packages/adapters/catalog-backstage/src/failure/triggers.ts b/packages/adapters/catalog-backstage/src/failure/triggers.ts new file mode 100644 index 00000000..7b3f74d6 --- /dev/null +++ b/packages/adapters/catalog-backstage/src/failure/triggers.ts @@ -0,0 +1,122 @@ +/** + * T074 · T075 — the closed **fifteen**-value fatal trigger enumeration, presented to + * the failure surface, and the `other-invalid-input` backstop. + * + * # This module re-exports the enumeration; it does not declare a second one + * + * `tasks.md` T074 asks for the enumeration "at `/src/failure/triggers.ts`". + * It is *presented* here and **declared** exactly once, in `../diagnostics.ts`, + * which Phase D authored for that purpose and whose own module note says so: "Phase E + * should import {@link TriggerClass} from here rather than redeclare it — a second + * declaration of a closed enumeration is the drift this file exists to prevent." + * + * A transcribed copy would satisfy the letter of "declare the union here" and defeat + * its purpose. Two closed enumerations of the same thing can disagree, and the first + * symptom of disagreement is a trigger that one module can produce and the other + * cannot name. `contracts/atomic-fail-closed.md` §4 requires the type be closed; it + * does not require it be closed twice. + * + * # The count is fifteen + * + * Verified in this worktree, not restated from memory: + * + * | Source | Read at | Says | + * |---|---|---| + * | `contracts/atomic-fail-closed.md` §4 | heading and body | "Closed Type of **Fifteen** Values" | + * | `data-model.md` §8 | lines 334–351 | fifteen union members, `inadmissible-descriptor` marked "added for this feature" | + * | `src/diagnostics.ts` | `TRIGGER_CLASSES` | fifteen entries | + * + * `specs/009-catalog-binding-viability/contracts/atomic-fail-closed.md` §4 says + * **fourteen**, and that remains correct *about spike 009*. It is wrong about this + * feature, and spec FR-035 says so in terms. {@link FATAL_TRIGGER_COUNT} is derived + * from the array rather than written as a literal, so the number cannot be asserted + * independently of the membership it is meant to count. + * + * @see `specs/010-catalog-backstage/contracts/atomic-fail-closed.md` §4, §4.2 + * @see `specs/010-catalog-backstage/data-model.md` §8 + */ + +import { TRIGGER_CLASSES, type Rejection, type TriggerClass } from '../diagnostics.ts'; + +export { TRIGGER_CLASSES, type Rejection, type TriggerClass }; + +/** + * How many fatal trigger classes this feature has. + * + * Derived, never transcribed. A literal `15` here could stay right while the + * enumeration changed underneath it, which is the failure mode the whole + * fourteen-versus-fifteen trap consists of. + */ +export const FATAL_TRIGGER_COUNT: number = TRIGGER_CLASSES.length; + +/** + * The deliberate, always-present backstop. + * + * `atomic-fail-closed.md` §4.2: it "exists specifically to honour the 'including but + * not limited to' hedge in the prose rule without leaving the data model's own type + * open-ended". FR-036 adds that it "MUST remain a deliberate always-present backstop, + * never a substitute for a more specific class that applies". + * + * **It is not dead code, and it must never be deleted as unreachable.** See + * {@link otherInvalidInput} for the route by which this implementation actually + * reaches it. + */ +export const BACKSTOP_TRIGGER = 'other-invalid-input' satisfies TriggerClass; + +/** + * The fourteen classes that are **not** the backstop. + * + * Exported so a check can assert that a rejection carrying one of these is never + * rewritten to the backstop — FR-036's "never a substitute for a more specific class + * that applies", expressed as data rather than as a rule in prose. + * + * Fourteen appears here as *fifteen minus the backstop*, which is a different + * quantity from spike 009's fourteen-member enumeration. Both numbers are real and + * they are not the same number. + */ +export const NAMED_TRIGGERS: readonly TriggerClass[] = TRIGGER_CLASSES.filter( + (trigger) => trigger !== BACKSTOP_TRIGGER, +); + +/** + * Build a rejection under the backstop. + * + * # Why this is reachable, and not a formality + * + * The backstop's stated purpose is a genuinely invalid input that none of the + * fourteen named classes describes. This implementation has exactly one such input, + * and it is not contrived: the **annotation-provenance declaration** that + * `envelope/provenance.ts` requires of every generation request. FR-043 makes the + * declaration load-bearing, `data-model.md` §1's closed manifest schema has no field + * to carry it, and so it arrives as part of the generation request rather than in the + * manifest file. + * + * A request that omits a listed source's provenance, or declares one for a source the + * manifest never listed, is invalid input. Walk the fourteen named classes and none + * fits: it is not the manifest failing to parse or shape-check + * (`invalid-manifest-shape` — the manifest is well-formed), not a version or + * capability value, not a source digest or path, not YAML, not an annotation, not a + * pattern, not a repository identity, not admissibility, and not a duplicate. That is + * precisely the case §4.2 describes, so it records `other-invalid-input` and does not + * invent a sixteenth string inline. + * + * @param reason a fine-grained reason string; kept distinct from the trigger class so + * an ADR-0016 negative case can record the exact emitted string + */ +export function otherInvalidInput( + reason: TReason, + detail: string, +): Rejection { + return { reason, triggerClass: BACKSTOP_TRIGGER, detail }; +} + +/** + * Whether `value` is a member of the closed enumeration. + * + * Used at the boundary where a trigger class arrives as data rather than as a typed + * value — reading a recorded failure back, for instance. A closed type erased at + * runtime is not closed at runtime, and this is what closes it there. + */ +export function isTriggerClass(value: unknown): value is TriggerClass { + return typeof value === 'string' && (TRIGGER_CLASSES as readonly string[]).includes(value); +} diff --git a/packages/adapters/catalog-backstage/src/identity/overlap.ts b/packages/adapters/catalog-backstage/src/identity/overlap.ts new file mode 100644 index 00000000..1b64db4e --- /dev/null +++ b/packages/adapters/catalog-backstage/src/identity/overlap.ts @@ -0,0 +1,127 @@ +/** + * T072 — **overlap between distinct canonical ids is not a collision**, and there is + * no exclusive winner. + * + * # The rule + * + * `entity-identity.md` §4: two entities with **distinct** canonical ids whose + * `adrkit.io/owned-paths` values both include the identical pattern "MUST both derive + * successfully — this MUST NOT trigger `contracts/atomic-fail-closed.md`'s abort — and + * a changed file matching that overlapping pattern MUST be recorded as owned by + * **every** matching entity simultaneously, mirroring ADR-0009's own + * union-not-winner `affects` semantics." + * + * # Why this module exists at all, given that nothing rejects overlap + * + * §4 is explicit that the rule "MUST be **positively demonstrated** (both entities' + * derived `paths` retain the overlapping pattern, and the changed file matches both), + * not merely asserted by the absence of a rejection rule." `data-model.md` §8 repeats + * it: "must be **positively demonstrated**, never inferred from the absence of a + * rejection." + * + * That is a real distinction. A generator that had silently dropped one of the two + * overlapping entities would also produce no rejection, and a test asserting only "the + * run did not abort" would pass. {@link ownersOf} makes the union observable: it + * returns **every** matching entity, so a winner-takes-all implementation fails it by + * returning one. + * + * # This module selects nothing + * + * There is deliberately no priority, no specificity ranking, no first-match, and no + * tie-break parameter anywhere below. Those are the shapes an exclusive winner would + * take, and a function that cannot express one cannot accidentally acquire one. + * + * @see `specs/009-catalog-binding-viability/contracts/entity-identity.md` §4 + * @see `specs/010-catalog-backstage/spec.md` FR-024 + */ + +import { compareCodeUnits } from '@adrkit/core'; +import { type GlobCompiler, createGlobCompiler } from '../glob/dialect.ts'; + +/** One entity's derived ownership, reduced to what path matching needs. */ +export interface OwnershipClaim { + readonly canonicalId: string; + /** Already `compareCodeUnits`-sorted and deduplicated by `glob/order.ts`. */ + readonly derivedPaths: readonly string[]; +} + +/** + * Every entity whose `derivedPaths` match `changedPath`, in `compareCodeUnits` order + * of canonical id. + * + * Returns a list rather than an entity-or-undefined. The type is the guarantee: a + * caller cannot read "the owner" off this because there is no such field, so + * union-not-winner survives a caller who was hoping for a single answer. + * + * `compiler` is the per-run compiler, so the matcher used here is the same object + * `glob/validate.ts` built when it accepted the pattern. FR-032 requires that + * validation and matching "cannot diverge", and sharing the compiler is what makes + * that structural rather than a convention. + */ +export function ownersOf( + claims: readonly OwnershipClaim[], + changedPath: string, + compiler: GlobCompiler = createGlobCompiler(), +): readonly string[] { + const owners = claims + .filter((claim) => + claim.derivedPaths.some((pattern) => { + const compiled = compiler.compile(pattern); + return compiled.ok && compiled.matcher(changedPath); + }), + ) + .map((claim) => claim.canonicalId); + + return [...new Set(owners)].sort(compareCodeUnits); +} + +/** One pattern shared by two or more distinct canonical ids. */ +export interface PathOverlap { + readonly pattern: string; + /** Every canonical id declaring it, `compareCodeUnits`-sorted. At least two. */ + readonly canonicalIds: readonly string[]; +} + +/** + * Every pattern declared by more than one **distinct** canonical id. + * + * This is a *report*, not a rejection, and the distinction is the whole point of the + * module. `identity/uniqueness.ts` returns a rejection when two entities share a ref; + * this returns a description when two entities share a pattern. §4 is what makes those + * opposite outcomes correct for two superficially similar "two entities agree on a + * string" conditions. + * + * Claims are keyed by canonical id, so an entity listed twice cannot manufacture an + * overlap with itself. + */ +export function pathOverlaps(claims: readonly OwnershipClaim[]): readonly PathOverlap[] { + const byPattern = new Map>(); + + for (const claim of claims) { + for (const pattern of claim.derivedPaths) { + const owners = byPattern.get(pattern) ?? new Set(); + owners.add(claim.canonicalId); + byPattern.set(pattern, owners); + } + } + + return [...byPattern.entries()] + .filter(([, canonicalIds]) => canonicalIds.size > 1) + .map(([pattern, canonicalIds]) => ({ + pattern, + canonicalIds: [...canonicalIds].sort(compareCodeUnits), + })) + .sort((a, b) => compareCodeUnits(a.pattern, b.pattern)); +} + +/** + * Whether overlap is present at all. + * + * Exported so a demonstration can assert it is **true** for the fixture before + * asserting that the run nonetheless succeeded. Without that, "the run did not abort" + * would be equally consistent with a fixture that had no overlap in it — which is the + * vacuous pass §4's "positively demonstrated" wording exists to rule out. + */ +export function hasOverlap(claims: readonly OwnershipClaim[]): boolean { + return pathOverlaps(claims).length > 0; +} diff --git a/packages/adapters/catalog-backstage/src/identity/uniqueness.ts b/packages/adapters/catalog-backstage/src/identity/uniqueness.ts new file mode 100644 index 00000000..c3dc1415 --- /dev/null +++ b/packages/adapters/catalog-backstage/src/identity/uniqueness.ts @@ -0,0 +1,232 @@ +/** + * T071 — **global canonical uniqueness over every ref**, with three distinct + * collision classes and no first-wins or last-wins resolution anywhere. + * + * # The rule + * + * `entity-identity.md` §3: "Within one snapshot-generation run, every string appearing + * in **any** entity's `allRefs` (`canonicalId` plus every `fixtureAuthoredAliasRefs` + * entry) MUST be globally unique." Its table gives four collision kinds mapping onto + * three trigger classes: + * + * | Collision kind | Class | + * |---|---| + * | Two entities' `canonicalId` values are identical | `duplicate-canonical-id` | + * | One entity's alias ref collides with a **different** entity's primary id | `duplicate-canonical-ref` | + * | A case-only variant of either collision above | `duplicate-canonical-ref` | + * | Duplicate YAML mapping key within one descriptor document | `duplicate-yaml-key` | + * + * The fourth is detected far earlier — `descriptor/read.ts` branches on the `yaml` + * library's own `DUPLICATE_KEY` code before any identity exists to compare. It is + * named in {@link COLLISION_CLASSES} anyway, because §3 groups it as a collision kind + * and a reader who found only two here would reasonably conclude one was missing. + * + * # First-wins and last-wins are both forbidden + * + * §3: "none may be silently merged, and none may be resolved by first-wins or + * last-wins." This module has no branch that keeps one member of a colliding group — + * {@link checkGlobalUniqueness} returns a rejection, and its caller aborts the whole + * run. The accept-corpus freeze's selection basis makes the same point about its own + * construction: "EVERY member of any colliding group excluded rather than one member + * kept — keeping one would be last-wins resolution." + * + * # Case-only variants, and why comparison is case-folded + * + * `identity/canonicalize.ts` lowercases the **entire** canonical id, so two primary + * ids that differ only by case are already byte-identical by the time they reach this + * module — they collide as `duplicate-canonical-id` without any case handling here. + * A case-only variant can therefore only arise in a ref that is *not* the primary id, + * which is not lowercased. Comparison is keyed on the case-folded ref so those are + * caught, and §3's third row assigns them `duplicate-canonical-ref`. + * + * **This does not change any matcher's case sensitivity.** `entity-identity.md` §5 is + * explicit that ADR-0012 leaves `packages/core/src/affects/**`'s `nocase: false` + * semantics untouched. The fold here is a uniqueness comparison at the generator + * boundary, and nothing else. + * + * # `duplicate-canonical-ref`'s reachability, stated rather than implied + * + * `identity/canonicalize.ts` populates `allRefs` as `[canonicalId]` and nothing more, + * because `data-model.md` §5 records — as an unresolved `[NEEDS CLARIFICATION]` — that + * how `allRefs` is populated beyond the primary id in production is undecided. + * `entity-identity.md` §2 adds that alias refs are supplied "directly by a synthetic + * fixture's own construction" and that "no real-corpus entity from `community-plugins` + * or `rhdh-plugins` ever has a non-empty `fixtureAuthoredAliasRefs`". + * + * **Consequence, recorded plainly:** with `allRefs` populated only by `canonicalId`, + * no descriptor-sourced input can reach `duplicate-canonical-ref` — two descriptors + * that canonicalize alike collide as `duplicate-canonical-id`. The class is reachable + * only by handing this kernel a synthetic identity set. That is why this module takes + * a plain identity list rather than descriptors: the class stays exercisable without + * inventing a production alias mechanism that `entity-identity.md` §2 says is "an + * explicitly separate, later, out-of-scope design decision". + * + * @see `specs/009-catalog-binding-viability/contracts/entity-identity.md` §2, §3, §5 + * @see `specs/010-catalog-backstage/spec.md` FR-023 + */ + +import { compareCodeUnits } from '@adrkit/core'; +import type { Rejection, TriggerClass } from '../failure/triggers.ts'; + +/** + * The three classes `entity-identity.md` §3's table produces. + * + * `duplicate-yaml-key` is detected at descriptor read, not here. It is listed because + * §3 lists it, and its absence would read as a missing case rather than as a case + * handled elsewhere. + */ +export const COLLISION_CLASSES = [ + 'duplicate-canonical-id', + 'duplicate-canonical-ref', + 'duplicate-yaml-key', +] as const satisfies readonly TriggerClass[]; + +/** The reasons this module emits. Both are 1:1 with their trigger class. */ +export type UniquenessReason = 'duplicate-canonical-id' | 'duplicate-canonical-ref'; + +/** The minimum an entity must present to participate in the uniqueness comparison. */ +export interface IdentityUnderTest { + readonly canonicalId: string; + /** Non-empty; `canonicalId` is always a member (`data-model.md` §5). */ + readonly allRefs: readonly string[]; +} + +/** One ref occurrence, retained so a collision can name both sides. */ +export interface RefOccurrence { + /** Index into the input list, so two occurrences of one entity are distinguishable. */ + readonly entityIndex: number; + readonly canonicalId: string; + /** The ref as written, before case folding. */ + readonly ref: string; + /** True when `ref` is this entity's own `canonicalId`. */ + readonly primary: boolean; +} + +/** A collision between two ref occurrences. */ +export interface Collision { + readonly reason: UniquenessReason; + readonly first: RefOccurrence; + readonly second: RefOccurrence; +} + +/** The outcome of the whole comparison. */ +export type UniquenessOutcome = + | { readonly ok: true; readonly refCount: number } + | { readonly ok: false; readonly collision: Collision; readonly rejection: Rejection }; + +/** + * Which class a collision between two occurrences falls under. + * + * `duplicate-canonical-id` is the **narrow** case, and deliberately so: §3's first row + * is "Two **entities'** `canonicalId` values are identical", so all three of these must + * hold — the two occurrences belong to **different entities**, both are primary + * canonical ids, and they are byte-identical. Anything else is + * `duplicate-canonical-ref`: an alias on either side, two refs agreeing only after case + * folding (§3's second and third rows), or a ref repeated **within one entity**, which + * violates §3's "every string appearing in any entity's `allRefs` MUST be globally + * unique" without being two entities claiming one id. + * + * The within-entity clause was added after a check caught this function reporting such + * a repeat as `duplicate-canonical-id`, which would have named two entities where there + * was one. + * + * Getting the breadth backwards is the failure mode worth naming: a rule that reported + * every collision as `duplicate-canonical-id` would pass any test that only checked + * "the run aborted", while making the alias and case-variant rows of §3's table + * unobservable. + */ +export function collisionReason(first: RefOccurrence, second: RefOccurrence): UniquenessReason { + const distinctEntities = first.entityIndex !== second.entityIndex; + const bothPrimary = first.primary && second.primary; + const byteIdentical = first.ref === second.ref; + return distinctEntities && bothPrimary && byteIdentical + ? 'duplicate-canonical-id' + : 'duplicate-canonical-ref'; +} + +function occurrences(identities: readonly IdentityUnderTest[]): readonly RefOccurrence[] { + return identities.flatMap((identity, entityIndex) => + identity.allRefs.map((ref) => ({ + entityIndex, + canonicalId: identity.canonicalId, + ref, + primary: ref === identity.canonicalId, + })), + ); +} + +/** + * Enforce global uniqueness across every ref of every entity. + * + * Occurrences are walked in `(entityIndex, refIndex)` order and the **first** repeat + * of a case-folded key aborts. That order is a property of the input list, which + * `pipeline.ts` builds in manifest-source order and then document order — so the + * reported collision for an input with several is reproducible rather than a function + * of hash iteration. + * + * A ref repeated **within one entity** is also a violation: §3 says every string + * appearing in any entity's `allRefs` must be globally unique, and a within-entity + * repeat is not unique. It is reported as `duplicate-canonical-ref`, because it is a + * ref-level uniqueness failure and not two entities claiming one canonical id. That + * reading is recorded here because §3's table lists only cross-entity kinds and is + * silent on this one. + */ +export function checkGlobalUniqueness( + identities: readonly IdentityUnderTest[], +): UniquenessOutcome { + const seen = new Map(); + const all = occurrences(identities); + + for (const occurrence of all) { + const key = occurrence.ref.toLowerCase(); + const previous = seen.get(key); + + if (previous !== undefined) { + const reason = collisionReason(previous, occurrence); + const collision: Collision = { reason, first: previous, second: occurrence }; + return { + ok: false, + collision, + rejection: { + reason, + triggerClass: reason, + detail: describeCollision(collision), + }, + }; + } + + seen.set(key, occurrence); + } + + return { ok: true, refCount: all.length }; +} + +function describeCollision(collision: Collision): string { + const render = (occurrence: RefOccurrence): string => + `entity ${occurrence.entityIndex} (${occurrence.canonicalId}) ${ + occurrence.primary ? 'canonicalId' : 'ref' + } ${JSON.stringify(occurrence.ref)}`; + + const caseOnly = + collision.first.ref !== collision.second.ref && + collision.first.ref.toLowerCase() === collision.second.ref.toLowerCase(); + + return ( + `${render(collision.first)} collides with ${render(collision.second)}` + + (caseOnly ? ' (case-only variant)' : '') + + '; entity-identity.md \u00a73 forbids resolving this by first-wins or last-wins' + ); +} + +/** + * Every ref in the run, case-folded, sorted and deduplicated. + * + * Diagnostic rather than load-bearing, and sorted with `compareCodeUnits` so that two + * runs over the same input produce the same list — FR-042's byte-identical requirement + * reaches anything a run can report, not only the envelope. + */ +export function foldedRefs(identities: readonly IdentityUnderTest[]): readonly string[] { + return [...new Set(occurrences(identities).map((occurrence) => occurrence.ref.toLowerCase()))].sort( + compareCodeUnits, + ); +} diff --git a/packages/adapters/catalog-backstage/src/pipeline.ts b/packages/adapters/catalog-backstage/src/pipeline.ts new file mode 100644 index 00000000..e362b2bc --- /dev/null +++ b/packages/adapters/catalog-backstage/src/pipeline.ts @@ -0,0 +1,481 @@ +/** + * T069 — the assembled generator: Phase D's units composed in one fixed stage order. + * + * # Composition only + * + * Every verdict below is reached by a Phase D validator. This module decides **when** + * each runs and **what happens next**; it decides nothing about what is valid. Where a + * reading looked like it needed new logic it was resolved by reaching for the existing + * pure function instead — see the note on double-reading source bytes in + * {@link STAGE_DIGESTS} below, which is the one place that took an argument to keep. + * + * # The stage order, and why it is data + * + * `tasks.md` T069 fixes it: manifest → repository → digests → descriptor read → + * admissibility → canonicalization → ownership → glob → envelope. + * + * {@link PIPELINE_STAGES} declares it, and every run records the stages it actually + * entered, in order, on both the success and the failure branch. That makes the + * ordering **observable** rather than asserted: a check compares the recorded trace + * against the declared list, so a reordering that happened to produce the same verdicts + * still fails. A comment saying "these run in order" would not have that property. + * + * Three of the orderings are load-bearing rather than tidy: + * + * - **Repository and digests before descriptor read.** The four manifest-request-level + * rejections "abort **before any entity's paths are derived**" + * (`atomic-fail-closed.md` §6). A pipeline that parsed descriptors first would still + * reject, but it would have derived ownership on the way — which R4's definition of + * generator output counts even when nothing is written. + * - **Admissibility before canonicalization.** ADR-0015's ordering rule, and the reason + * `admissibility/index.ts` brands its output: an inadmissible descriptor must never + * acquire a canonical id, or it could be reported under `duplicate-canonical-id` + * instead of its own class, with the reported trigger depending on document order. + * - **Ownership before glob.** `owned-paths-annotation.md` §1: "Only after steps 1–4 + * succeed does each string element proceed to `glob-dialect.md`'s validator." + * + * # Whole-operation atomicity + * + * Every stage that can reject does so by returning, immediately, through + * {@link aborted}. There is no `continue`, no accumulating error list, and no branch + * anywhere that drops one entity and proceeds — the shape `atomic-fail-closed.md` §1 + * names as "the single most likely implementation mistake this contract exists to + * foreclose". The envelope is assembled only after every stage has succeeded, and + * writing is a **separate call** ({@link generateAndWriteEnvelope}), so an abort cannot + * reach a filesystem write even by mistake. + * + * @see `specs/010-catalog-backstage/contracts/atomic-fail-closed.md` §1, §6 + * @see `specs/010-catalog-backstage/data-model.md` §9, §10 + */ + +import { compareCodeUnits } from '@adrkit/core'; +import { collectAdmitted } from './admissibility/index.ts'; +import { readDescriptorDocuments, readAnnotationNode } from './descriptor/read.ts'; +import type { DescriptorDocument } from './descriptor/read.ts'; +import type { Rejection } from './diagnostics.ts'; +import { + type AtomicFailureRecord, + type FailureLocation, + type GenerationOutcome, +} from './failure/abort.ts'; +import { classifyAbort } from './failure/classify.ts'; +import { readGlobDialect } from './glob/dialect.ts'; +import { createGlobCompiler } from './glob/dialect.ts'; +import { canonicalize } from './identity/canonicalize.ts'; +import { checkGlobalUniqueness } from './identity/uniqueness.ts'; +import { admissibleReadSet } from './manifest/boundary.ts'; +import { verifySourceBytes, verifySourceDigests } from './manifest/digests.ts'; +import { validatePathConfined, validatePathLexically } from './manifest/paths.ts'; +import { parseManifestText } from './manifest/schema.ts'; +import type { InputManifest, ManifestSource } from './manifest/schema.ts'; +import { checkManifestVersions } from './manifest/version.ts'; +import { deriveOwnership } from './ownership/derive.ts'; +import { OWNED_PATHS_ANNOTATION } from './ownership/annotation.ts'; +import { + type ObservedRepositoryState, + compareRepositoryIdentity, + normalizeRepositoryId, + readObservedRepositoryState, +} from './repository/identity.ts'; +import { completeness } from './envelope/completeness.ts'; +import { computeEnvelopeDigest } from './envelope/digest.ts'; +import { + type EnvelopeSource, + type SnapshotEntityRecord, + type SnapshotEnvelope, + assembleEnvelope, + entityRecord, +} from './envelope/shape.ts'; +import { + type ProvenanceDeclaration, + checkProvenanceDeclaration, + provenanceFor, +} from './envelope/provenance.ts'; +import { type WriteResult, writeEnvelope } from './envelope/write.ts'; + +/** The manifest is read, parsed, shape-checked, version-checked; paths pass stage 1. */ +export const STAGE_MANIFEST = 'manifest'; +/** The checkout's own identity and revision are compared with the manifest's. */ +export const STAGE_REPOSITORY = 'repository'; +/** Source paths pass stage 2 (confinement) and every declared digest is verified. */ +export const STAGE_DIGESTS = 'digests'; +/** Each verified source is parsed into YAML documents. */ +export const STAGE_DESCRIPTOR_READ = 'descriptor-read'; +/** ADR-0015's four validators, all-or-nothing over the batch. */ +export const STAGE_ADMISSIBILITY = 'admissibility'; +/** Canonical identity, then global uniqueness over every ref. */ +export const STAGE_CANONICALIZATION = 'canonicalization'; +/** The annotation's decode steps 1–4. */ +export const STAGE_OWNERSHIP = 'ownership'; +/** The annotation's step 5 — per-pattern validation against the frozen dialect. */ +export const STAGE_GLOB = 'glob'; +/** The envelope is assembled and its digest computed. */ +export const STAGE_ENVELOPE = 'envelope'; + +/** T069's fixed stage order. */ +export const PIPELINE_STAGES = [ + STAGE_MANIFEST, + STAGE_REPOSITORY, + STAGE_DIGESTS, + STAGE_DESCRIPTOR_READ, + STAGE_ADMISSIBILITY, + STAGE_CANONICALIZATION, + STAGE_OWNERSHIP, + STAGE_GLOB, + STAGE_ENVELOPE, +] as const; + +export type PipelineStage = (typeof PIPELINE_STAGES)[number]; + +/** One generation request. */ +export interface GenerationRequest { + /** Path to the input manifest. The only path this run is given. */ + readonly manifestPath: string; + /** The checkout the manifest's source paths are resolved against and confined to. */ + readonly checkoutRoot: string; + /** + * One {@link ProvenanceDeclaration} entry per manifest source path. + * + * Required and exhaustive. `envelope/provenance.ts` explains why there is no default: + * an omission would otherwise emit a claim about a third party that nobody made. + */ + readonly provenance: ProvenanceDeclaration; + /** + * The checkout's observed identity, when the caller has already read it. + * + * Omitted in ordinary use, in which case this pipeline reads it with the two git + * subprocess calls `input-manifest.md` §5 permits. It is a **value**, never a + * function: an injectable reader would be a seam through which a test could supply + * behaviour, whereas a value can only supply data. + */ + readonly observedRepositoryState?: ObservedRepositoryState | undefined; + /** + * `completeness.identityOnly`. Defaults to `false`. + * + * `completeness.wholeCatalog` is deliberately **not** here — it is `false` + * unconditionally and has no input that can change it (FR-014). + */ + readonly identityOnly?: boolean | undefined; +} + +/** What a successful run produced, before anything is written. */ +export type GenerationResult = GenerationOutcome; + +interface Trace { + readonly stages: string[]; +} + +/** + * Record that `stage` was entered. + * + * Each stage appears **once**, at its first entry, in first-entry order. The + * per-entity stages (`ownership` and `glob`) are entered once per entity, and + * recording every visit would make the trace's length a function of how many entities + * a run happened to contain — which says nothing about ordering, the property the + * trace exists to make observable. Every stage listed was genuinely entered, and the + * order listed is the order they were first entered in. + * + * `failure.stage` on an {@link AtomicFailureRecord} is what says where a run stopped; + * it is a separate field precisely so this one does not have to serve both jobs. + */ +function enter(trace: Trace, stage: PipelineStage): void { + if (!trace.stages.includes(stage)) trace.stages.push(stage); +} + +function aborted( + trace: Trace, + rejection: Rejection, + stage: PipelineStage, + location: FailureLocation = {}, +): GenerationResult { + return { ok: false, failure: classifyAbort(rejection, stage, location), stages: [...trace.stages] }; +} + +/** + * This package's own version, read from its installed manifest. + * + * Read rather than transcribed, for the same reason `glob/dialect.ts` reads + * `picomatch`'s: a version literal in source can drift from the package it claims to + * describe, and an envelope recording a version nothing verified is a claim rather than + * an observation. + * + * `package-boundary.md` §6 settles that a filesystem read of a `package.json` is not + * loader behaviour — "it invokes no resolver, imports no module, and cannot load code". + * That section is written about a *dependency's* manifest; this reads the package's + * own, which engages the same distinction and none of the resolution the guard forbids. + * + * `input-manifest.md` §5's read boundary is about what a run may read **from the + * repository under generation** — the manifest, its listed sources, and two git values. + * The generator's own installed manifest is part of the tool, not part of the input, + * exactly as `node_modules/picomatch/package.json` is. Stated rather than assumed, + * because the two readings are close enough to be worth separating in writing. + */ +async function readGeneratorVersion(): Promise { + const manifestPath = `${import.meta.dir}/../package.json`; + const manifest = (await Bun.file(manifestPath).json()) as { + name?: unknown; + version?: unknown; + }; + const { name, version } = manifest; + if (typeof name !== 'string' || typeof version !== 'string') { + throw new Error( + `could not read name and version from ${manifestPath}. Refusing to record a ` + + 'generatorVersion this package has not verified.', + ); + } + return `${name}@${version}`; +} + +interface VerifiedSourceText { + readonly source: ManifestSource; + readonly resolvedPath: string; + readonly text: string; +} + +/** + * Run the generator. Returns the envelope, or exactly one + * {@link AtomicFailureRecord} — never both, and never a partial envelope. + */ +export async function runGeneration(request: GenerationRequest): Promise { + const trace: Trace = { stages: [] }; + + // ── Stage 1: manifest ────────────────────────────────────────────────────────── + enter(trace, STAGE_MANIFEST); + + const manifestFile = Bun.file(request.manifestPath); + if (!(await manifestFile.exists())) { + return aborted( + trace, + { + reason: 'manifest-unreadable', + triggerClass: 'invalid-manifest-shape', + detail: `no manifest at ${JSON.stringify(request.manifestPath)}`, + }, + STAGE_MANIFEST, + ); + } + + const shape = parseManifestText(await manifestFile.text()); + if (!shape.ok) return aborted(trace, shape.rejection, STAGE_MANIFEST); + + const manifest: InputManifest = shape.value; + + const versions = checkManifestVersions(manifest); + if (!versions.ok) return aborted(trace, versions.rejection, STAGE_MANIFEST); + + // Path validation stage 1 — lexical, before any filesystem access + // (`input-manifest.md` §4.1). Stage 2 is deferred to STAGE_DIGESTS because it + // touches the filesystem and carries a different trigger class. + for (const source of manifest.sources) { + const lexical = validatePathLexically(source.path); + if (!lexical.ok) { + return aborted(trace, lexical.rejection, STAGE_MANIFEST, { sourcePath: source.path }); + } + } + + const readSet = admissibleReadSet(request.manifestPath, manifest); + + const provenance = checkProvenanceDeclaration(request.provenance, readSet.sourcePaths); + if (!provenance.ok) return aborted(trace, provenance.rejection, STAGE_MANIFEST); + + // ── Stage 2: repository ──────────────────────────────────────────────────────── + enter(trace, STAGE_REPOSITORY); + + const observed = + request.observedRepositoryState ?? (await readObservedRepositoryState(request.checkoutRoot)); + const identity = compareRepositoryIdentity(manifest.repository, observed); + if (!identity.ok) return aborted(trace, identity.rejection, STAGE_REPOSITORY); + + // ── Stage 3: digests ─────────────────────────────────────────────────────────── + enter(trace, STAGE_DIGESTS); + + const resolvedByDeclaredPath = new Map(); + for (const source of manifest.sources) { + const confined = await validatePathConfined(request.checkoutRoot, source.path); + if (!confined.ok) { + return aborted(trace, confined.rejection, STAGE_DIGESTS, { sourcePath: source.path }); + } + resolvedByDeclaredPath.set(source.path, confined.value.resolved); + } + + const digests = await verifySourceDigests(manifest.sources, (path) => { + const resolved = resolvedByDeclaredPath.get(path); + if (resolved === undefined) { + throw new Error(`source ${JSON.stringify(path)} was not confined before digest verification`); + } + return resolved; + }); + if (!digests.ok) return aborted(trace, digests.rejection, STAGE_DIGESTS); + + // The bytes verified above were read by `verifySourceDigests`. These are the bytes + // that will actually be parsed, so they are re-verified through the same pure + // Phase D function: two reads, one authority. A file changed between the two reads + // reports `digest-mismatch` from `manifest/digests.ts`, never from a second + // comparison written here. `input-manifest.md` §4 requires sources be + // "digest-verified before trust", and the bytes that are trusted are these. + const verified: VerifiedSourceText[] = []; + for (const source of manifest.sources) { + const resolvedPath = resolvedByDeclaredPath.get(source.path) as string; + const bytes = new Uint8Array(await Bun.file(resolvedPath).arrayBuffer()); + + const reverified = verifySourceBytes(source, bytes); + if (!reverified.ok) { + return aborted(trace, reverified.rejection, STAGE_DIGESTS, { sourcePath: source.path }); + } + + verified.push({ source, resolvedPath, text: new TextDecoder().decode(bytes) }); + } + + // ── Stage 4: descriptor read ─────────────────────────────────────────────────── + enter(trace, STAGE_DESCRIPTOR_READ); + + const documents: DescriptorDocument[] = []; + for (const { source, text } of verified) { + for (const document of readDescriptorDocuments(source.path, text)) { + if (document.rejection !== undefined) { + return aborted(trace, document.rejection, STAGE_DESCRIPTOR_READ, { + sourcePath: document.sourcePath, + documentIndex: document.documentIndexInFile, + }); + } + documents.push(document); + } + } + + // ── Stage 5: admissibility ───────────────────────────────────────────────────── + enter(trace, STAGE_ADMISSIBILITY); + + const admission = collectAdmitted(documents); + if (!admission.ok) { + const [first] = admission.result.attributions; + return aborted(trace, admission.rejection, STAGE_ADMISSIBILITY, { + sourcePath: first?.sourcePath, + documentIndex: first?.documentIndexInFile, + }); + } + + // ── Stage 6: canonicalization ────────────────────────────────────────────────── + enter(trace, STAGE_CANONICALIZATION); + + const identities = admission.admitted.map((admitted) => canonicalize(admitted)); + + const uniqueness = checkGlobalUniqueness(identities); + if (!uniqueness.ok) { + const { second } = uniqueness.collision; + const document = documents[second.entityIndex]; + return aborted(trace, uniqueness.rejection, STAGE_CANONICALIZATION, { + sourcePath: document?.sourcePath, + documentIndex: document?.documentIndexInFile, + }); + } + + // ── Stages 7 and 8: ownership, then glob ─────────────────────────────────────── + // One compiler for the whole run (FR-032: once per run, never once per entity), so + // the matcher that validated a pattern is the matcher that would later match it. + const compiler = createGlobCompiler(); + const entities: SnapshotEntityRecord[] = []; + + for (const [index, admitted] of admission.admitted.entries()) { + const document = admitted.document; + const identity = identities[index]; + if (identity === undefined) { + throw new Error('canonicalization produced fewer identities than admitted descriptors'); + } + + enter(trace, STAGE_OWNERSHIP); + + const annotation = readAnnotationNode(document, OWNED_PATHS_ANNOTATION); + const derived = deriveOwnership(annotation.present, annotation.value, compiler); + + // Step 5 is the glob stage. It is reached only when steps 1–4 produced patterns to + // validate, which is exactly `explicit-paths`. The trace records it from what the + // derivation actually returned rather than from an assumption about which branch + // ran: a step-5 rejection carries the offending pattern, and a success that + // reached step 5 carries every pattern's verdict. + const reachedGlob = derived.ok + ? derived.value.ownershipState === 'explicit-paths' + : derived.pattern !== undefined; + if (reachedGlob) enter(trace, STAGE_GLOB); + + if (!derived.ok) { + return aborted(trace, derived.rejection, reachedGlob ? STAGE_GLOB : STAGE_OWNERSHIP, { + sourcePath: document.sourcePath, + documentIndex: document.documentIndexInFile, + }); + } + + entities.push( + entityRecord({ + canonicalId: identity.canonicalId, + allRefs: identity.allRefs, + ownershipState: derived.value.ownershipState, + derivedPaths: derived.value.derivedPaths, + sourcePath: document.sourcePath, + documentIndexInFile: document.documentIndexInFile, + provenance: provenanceFor(provenance.declaration, document.sourcePath), + }), + ); + } + + // ── Stage 9: envelope ────────────────────────────────────────────────────────── + enter(trace, STAGE_ENVELOPE); + + const sources: readonly EnvelopeSource[] = [...manifest.sources] + .map((source) => ({ + path: source.path, + digestAlgorithm: source.digestAlgorithm, + digest: source.digest, + })) + .sort((a, b) => compareCodeUnits(a.path, b.path)); + + const unsigned = { + schemaVersion: '1', + repository: { + // The normalized form, so the envelope records the identity that was actually + // compared rather than whichever spelling the manifest happened to use. + id: normalizeRepositoryId(manifest.repository.id), + // The observed head, not the declared revision. They are equal — the identity + // check rejects otherwise — and recording the observation keeps the envelope a + // report of what was seen rather than a copy of what was asked for. + revision: observed.head, + }, + generatorVersion: await readGeneratorVersion(), + globDialect: await readGlobDialect(), + capabilities: ['pathOwnership'], + completeness: completeness(request.identityOnly ?? false), + sources, + entities, + } satisfies Omit; + + const envelope = assembleEnvelope({ + repository: unsigned.repository, + generatorVersion: unsigned.generatorVersion, + globDialect: unsigned.globDialect, + completeness: unsigned.completeness, + sources: unsigned.sources, + entities: unsigned.entities, + digest: computeEnvelopeDigest(unsigned), + }); + + return { ok: true, envelope, stages: [...trace.stages] }; +} + +/** + * Run the generator and, **only** on success, write the envelope. + * + * The write is unreachable from the failure branch: `outcome.ok` is checked before + * `writeEnvelope` is named, and the failure arm of {@link GenerationResult} carries no + * envelope to write. That is `atomic-fail-closed.md` §1's "no usable partial snapshot" + * expressed as control flow rather than as a promise. + */ +export async function generateAndWriteEnvelope( + request: GenerationRequest, + destination: string, +): Promise< + | { readonly ok: true; readonly envelope: SnapshotEnvelope; readonly write: WriteResult } + | { readonly ok: false; readonly failure: AtomicFailureRecord } +> { + const outcome = await runGeneration(request); + if (!outcome.ok) return { ok: false, failure: outcome.failure }; + return { ok: true, envelope: outcome.envelope, write: await writeEnvelope(outcome.envelope, destination) }; +} diff --git a/packages/adapters/catalog-backstage/test/abort.test.ts b/packages/adapters/catalog-backstage/test/abort.test.ts new file mode 100644 index 00000000..b865627a --- /dev/null +++ b/packages/adapters/catalog-backstage/test/abort.test.ts @@ -0,0 +1,234 @@ +/** + * T073 — whole-operation abort: non-zero exit status, and no usable partial output. + * + * `atomic-fail-closed.md` §1 and §3. Four properties are checked here, and they are + * genuinely four rather than one restated: + * + * 1. The failure branch carries **no envelope** — a type-level property, checked at + * runtime because the type is erased there. + * 2. A run that aborts writes **no file at all** — not a partial one, not an empty one. + * 3. The exit status is **non-zero**, observed as a real process exit rather than as a + * constant this module also defines. + * 4. A completed write is **atomic**: the destination path never holds a prefix of the + * envelope. + */ + +import { afterAll, beforeAll, describe, expect, test } from 'bun:test'; +import { readdir, readFile, rm, writeFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import { mkdtemp } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { + EXIT_ABORT, + EXIT_OK, + abortRecord, + envelopeOf, + exitCodeFor, +} from '../src/failure/abort.ts'; +import { generateAndWriteEnvelope, runGeneration } from '../src/pipeline.ts'; +import { serializeEnvelope } from '../src/envelope/write.ts'; +import { type Checkout, createCheckout, stage, validDescriptor } from './pipeline-fixtures.ts'; + +let checkout: Checkout; +let outputDirectory: string; + +beforeAll(async () => { + checkout = await createCheckout(); + outputDirectory = await mkdtemp(join(tmpdir(), 'adrkit-catalog-out-')); +}); + +afterAll(async () => { + await checkout.dispose(); + await rm(outputDirectory, { recursive: true, force: true }); +}); + +/** A batch of five valid entities plus a sixth with a duplicate canonical id (§3). */ +async function fiveValidPlusOneDuplicate(): Promise>> { + const files: Record = {}; + for (const name of ['alpha', 'beta', 'gamma', 'delta', 'epsilon']) { + files[`entities/${name}/catalog-info.yaml`] = validDescriptor(name, `["packages/${name}/**"]`); + } + // The sixth canonicalizes to `component:default/alpha`, colliding with the first. + files['entities/sixth/catalog-info.yaml'] = validDescriptor('alpha', '["packages/sixth/**"]'); + return stage(checkout, files, {}, 'manifest-duplicate.json'); +} + +describe('T073 — the failure branch carries no envelope', () => { + test('a valid batch produces one, so the negative case below means something', async () => { + const { request } = await stage( + checkout, + { 'ok/catalog-info.yaml': validDescriptor('ok', '["packages/ok/**"]') }, + {}, + 'manifest-ok.json', + ); + const outcome = await runGeneration(request); + expect(outcome.ok).toBe(true); + expect(envelopeOf(outcome)).toBeDefined(); + }); + + test('an aborting batch carries no envelope, and no field could hold one', async () => { + const { request } = await fiveValidPlusOneDuplicate(); + const outcome = await runGeneration(request); + + expect(outcome.ok).toBe(false); + expect(envelopeOf(outcome)).toBeUndefined(); + // Not merely "envelope is undefined": the key is absent, so there is nowhere a + // partial snapshot could be carried even under a different name. + expect(Object.keys(outcome).sort()).toEqual(['failure', 'ok', 'stages']); + }); + + test('exactly one failure record, never a list', async () => { + const { request } = await fiveValidPlusOneDuplicate(); + const outcome = await runGeneration(request); + if (outcome.ok) throw new Error('expected an abort'); + + expect(Array.isArray(outcome.failure)).toBe(false); + expect(outcome.failure.triggerClass).toBe('duplicate-canonical-id'); + }); +}); + +describe('T073 / §3 — no output exists for the five entities that would have validated', () => { + test('the destination path is never created', async () => { + const { request } = await fiveValidPlusOneDuplicate(); + const destination = join(outputDirectory, 'abort-case', 'envelope.json'); + + const result = await generateAndWriteEnvelope(request, destination); + expect(result.ok).toBe(false); + + expect(await Bun.file(destination).exists()).toBe(false); + }); + + test('no side file is left in the destination directory either', async () => { + const { request } = await fiveValidPlusOneDuplicate(); + const directory = join(outputDirectory, 'abort-case-empty'); + const destination = join(directory, 'envelope.json'); + + const result = await generateAndWriteEnvelope(request, destination); + expect(result.ok).toBe(false); + + // The directory itself is never created, because the write is never reached. + // `readdir` on an absent directory throws, which is the assertion. + await expect(readdir(directory)).rejects.toThrow(); + }); + + test('the five valid entities really would have validated on their own', async () => { + // Without this, "no envelope was produced" is equally consistent with a fixture + // that was invalid for some other reason. §3's table requires the five be + // otherwise-valid, so that is demonstrated rather than assumed. + const files: Record = {}; + for (const name of ['alpha', 'beta', 'gamma', 'delta', 'epsilon']) { + files[`entities/${name}/catalog-info.yaml`] = validDescriptor(name, `["packages/${name}/**"]`); + } + const { request } = await stage(checkout, files, {}, 'manifest-five.json'); + const outcome = await runGeneration(request); + + expect(outcome.ok).toBe(true); + if (!outcome.ok) return; + expect(outcome.envelope.entities).toHaveLength(5); + }); +}); + +describe('T073 / FR-034 — non-zero process exit status', () => { + test('the mapping is 0 on success and 1 on abort', async () => { + const { request: ok } = await stage( + checkout, + { 'exit-ok/catalog-info.yaml': validDescriptor('exitok') }, + {}, + 'manifest-exit-ok.json', + ); + expect(exitCodeFor(await runGeneration(ok))).toBe(EXIT_OK); + + const { request: bad } = await fiveValidPlusOneDuplicate(); + expect(exitCodeFor(await runGeneration(bad))).toBe(EXIT_ABORT); + }); + + test('a real process exits non-zero, observed rather than asserted', async () => { + // FR-034 says "process exit status". A constant in this module is not one. The + // script below is generated rather than committed so that this test cannot pass + // by reading a file someone edited to say the right thing, and the module + // specifier is built at runtime so it is a path this repository resolves rather + // than a literal that could drift. + const { request } = await fiveValidPlusOneDuplicate(); + const pipelineModule = JSON.stringify(join(import.meta.dir, '..', 'src', 'pipeline.ts')); + const abortModule = JSON.stringify(join(import.meta.dir, '..', 'src', 'failure', 'abort.ts')); + + const scriptPath = join(outputDirectory, 'exit-status-probe.ts'); + await writeFile( + scriptPath, + [ + `import { runGeneration } from ${pipelineModule};`, + `import { exitCodeFor } from ${abortModule};`, + `const request = JSON.parse(${JSON.stringify(JSON.stringify(request))});`, + 'const outcome = await runGeneration(request);', + 'process.exit(exitCodeFor(outcome));', + ].join('\n'), + 'utf8', + ); + + const proc = Bun.spawn(['bun', scriptPath], { stdout: 'pipe', stderr: 'pipe' }); + const [exitCode, stderr] = await Promise.all([proc.exited, new Response(proc.stderr).text()]); + + expect(stderr).toBe(''); + expect(exitCode).toBe(EXIT_ABORT); + expect(exitCode).not.toBe(0); + }); +}); + +describe('T073 — the write is atomic, so no truncated envelope is observable', () => { + test('the destination holds the complete serialization, byte for byte', async () => { + const { request } = await stage( + checkout, + { 'atomic/catalog-info.yaml': validDescriptor('atomic', '["packages/atomic/**"]') }, + {}, + 'manifest-atomic.json', + ); + const destination = join(outputDirectory, 'atomic', 'envelope.json'); + + const result = await generateAndWriteEnvelope(request, destination); + expect(result.ok).toBe(true); + if (!result.ok) return; + + const written = await readFile(destination, 'utf8'); + expect(written).toBe(serializeEnvelope(result.envelope)); + expect(result.write.byteLength).toBe(new TextEncoder().encode(written).byteLength); + }); + + test('no temporary file survives a successful write', async () => { + const directory = join(outputDirectory, 'atomic-clean'); + const { request } = await stage( + checkout, + { 'atomic2/catalog-info.yaml': validDescriptor('atomictwo') }, + {}, + 'manifest-atomic2.json', + ); + + await generateAndWriteEnvelope(request, join(directory, 'envelope.json')); + + // Exactly one file, and it is the envelope: FR-038's "only output", checked from + // the directory rather than from the writer's own report of what it wrote. + expect((await readdir(directory)).sort()).toEqual(['envelope.json']); + }); +}); + +describe('T073 — abortRecord carries the location the pipeline knew', () => { + test('an absent location is undefined rather than invented', () => { + const record = abortRecord( + { reason: 'invalid-yaml-syntax', triggerClass: 'invalid-yaml-syntax', detail: 'd' }, + 'descriptor-read', + ); + expect(record.sourcePath).toBeUndefined(); + expect(record.documentIndex).toBeUndefined(); + expect(record.reason).toBe('invalid-yaml-syntax'); + expect(record.stage).toBe('descriptor-read'); + }); + + test('a supplied location is carried verbatim', () => { + const record = abortRecord( + { reason: 'invalid-yaml-syntax', triggerClass: 'invalid-yaml-syntax', detail: 'd' }, + 'descriptor-read', + { sourcePath: 'a/catalog-info.yaml', documentIndex: 2 }, + ); + expect(record.sourcePath).toBe('a/catalog-info.yaml'); + expect(record.documentIndex).toBe(2); + }); +}); diff --git a/packages/adapters/catalog-backstage/test/backstop-trigger.test.ts b/packages/adapters/catalog-backstage/test/backstop-trigger.test.ts new file mode 100644 index 00000000..6311322c --- /dev/null +++ b/packages/adapters/catalog-backstage/test/backstop-trigger.test.ts @@ -0,0 +1,141 @@ +/** + * T075 — `other-invalid-input` is a **deliberate, always-present backstop**: never + * removed as unreachable, never treated as dead code, and never used to absorb a case + * that has its own class. + * + * `atomic-fail-closed.md` §4.2 and FR-036. The three clauses are checked separately + * because they fail in different ways: + * + * - *Always present* — a membership assertion over the closed enumeration. + * - *Not dead code* — an assertion that a real generation request reaches it. This is + * the clause a coverage tool would otherwise flag and someone would "fix" by deleting + * the branch. + * - *Never a substitute* — an assertion that every input with a named class gets that + * class, checked across the whole registry rather than on a sample. + */ + +import { describe, expect, test } from 'bun:test'; +import { TRIGGER_CLASSES } from '../src/diagnostics.ts'; +import { + BACKSTOP_TRIGGER, + FATAL_TRIGGER_COUNT, + NAMED_TRIGGERS, + isTriggerClass, + otherInvalidInput, +} from '../src/failure/triggers.ts'; +import { REASON_TRIGGER_REGISTRY } from '../src/failure/classify.ts'; +import { checkProvenanceDeclaration } from '../src/envelope/provenance.ts'; + +describe('T074 — the enumeration is closed, and its count is fifteen', () => { + test('there are fifteen classes, counted from the declaration', () => { + // `contracts/atomic-fail-closed.md` §4: "Closed Type of **Fifteen** Values". + // `data-model.md` §8 lists the same fifteen. Spike 009's fourteen is correct about + // spike 009 and wrong here (FR-035). + expect(FATAL_TRIGGER_COUNT).toBe(15); + expect(TRIGGER_CLASSES).toHaveLength(15); + expect(new Set(TRIGGER_CLASSES).size).toBe(15); + }); + + test('the fifteenth is `inadmissible-descriptor`, ADR-0015 Condition of Acceptance 2', () => { + expect(TRIGGER_CLASSES).toContain('inadmissible-descriptor'); + }); + + test('this module re-exports the one declaration rather than making a second', () => { + // The array identity, not merely its contents. A transcribed copy would be equal + // and would not be the same object, which is exactly the drift `src/diagnostics.ts` + // exists to prevent. + expect(NAMED_TRIGGERS.length + 1).toBe(TRIGGER_CLASSES.length); + expect(TRIGGER_CLASSES.every((trigger) => isTriggerClass(trigger))).toBe(true); + }); + + test('the closed type is closed at runtime too', () => { + expect(isTriggerClass('duplicate-canonical-id')).toBe(true); + expect(isTriggerClass('not-a-trigger')).toBe(false); + expect(isTriggerClass(undefined)).toBe(false); + expect(isTriggerClass(15)).toBe(false); + }); +}); + +describe('T075 — the backstop is always present', () => { + test('`other-invalid-input` is a member of the closed enumeration', () => { + expect(TRIGGER_CLASSES).toContain(BACKSTOP_TRIGGER); + expect(BACKSTOP_TRIGGER).toBe('other-invalid-input'); + }); + + test('the other fourteen are named, and the backstop is not among them', () => { + expect(NAMED_TRIGGERS).toHaveLength(14); + expect(NAMED_TRIGGERS).not.toContain(BACKSTOP_TRIGGER); + }); + + test('`otherInvalidInput` produces the backstop class and keeps the reason distinct', () => { + const rejection = otherInvalidInput('provenance-declaration-missing', 'why'); + expect(rejection.triggerClass).toBe('other-invalid-input'); + expect(rejection.reason).toBe('provenance-declaration-missing'); + }); +}); + +describe('T075 — the backstop is not dead code: a real input reaches it', () => { + test('a request omitting a listed source\u2019s provenance is `other-invalid-input`', () => { + const outcome = checkProvenanceDeclaration({ bySourcePath: {} }, ['catalog-info.yaml']); + expect(outcome.ok).toBe(false); + if (outcome.ok) return; + + expect(outcome.rejection.triggerClass).toBe('other-invalid-input'); + expect(outcome.rejection.reason).toBe('provenance-declaration-missing'); + }); + + test('a declaration naming a source the manifest never listed also reaches it', () => { + const outcome = checkProvenanceDeclaration( + { bySourcePath: { 'catalog-info.yaml': 'maintainer-overlay', 'ghost.yaml': 'maintainer-overlay' } }, + ['catalog-info.yaml'], + ); + expect(outcome.ok).toBe(false); + if (outcome.ok) return; + expect(outcome.rejection.reason).toBe('provenance-declaration-unknown-source'); + expect(outcome.rejection.triggerClass).toBe('other-invalid-input'); + }); + + test('a value outside the closed provenance domain also reaches it', () => { + const outcome = checkProvenanceDeclaration( + { bySourcePath: { 'catalog-info.yaml': 'third-party' as 'maintainer-overlay' } }, + ['catalog-info.yaml'], + ); + expect(outcome.ok).toBe(false); + if (outcome.ok) return; + expect(outcome.rejection.reason).toBe('provenance-declaration-unrecognized-value'); + expect(outcome.rejection.triggerClass).toBe('other-invalid-input'); + }); + + test('exactly three reasons map to the backstop, and none of them has a named class', () => { + const backstopReasons = Object.entries(REASON_TRIGGER_REGISTRY) + .filter(([, trigger]) => trigger === BACKSTOP_TRIGGER) + .map(([reason]) => reason) + .sort(); + + expect(backstopReasons).toEqual([ + 'provenance-declaration-missing', + 'provenance-declaration-unknown-source', + 'provenance-declaration-unrecognized-value', + ]); + }); +}); + +describe('T075 / FR-036 — the backstop never absorbs a case that has its own class', () => { + test('every registered reason with a named class keeps that class', () => { + // The whole registry rather than a sample. A single reason quietly remapped to the + // backstop is exactly the substitution FR-036 forbids, and sampling would miss it. + const absorbed = Object.entries(REASON_TRIGGER_REGISTRY).filter( + ([reason, trigger]) => trigger === BACKSTOP_TRIGGER && !reason.startsWith('provenance-declaration-'), + ); + expect(absorbed).toEqual([]); + }); + + test('all fourteen named classes are actually claimed by at least one reason', () => { + // The converse failure: a named class no reason maps to would make the backstop the + // only route for that condition, which is the same substitution arriving by + // omission rather than by edit. + const claimed = new Set(Object.values(REASON_TRIGGER_REGISTRY)); + const unclaimed = NAMED_TRIGGERS.filter((trigger) => !claimed.has(trigger)).sort(); + expect(unclaimed).toEqual([]); + }); +}); diff --git a/packages/adapters/catalog-backstage/test/byte-identical.test.ts b/packages/adapters/catalog-backstage/test/byte-identical.test.ts new file mode 100644 index 00000000..fd339a92 --- /dev/null +++ b/packages/adapters/catalog-backstage/test/byte-identical.test.ts @@ -0,0 +1,200 @@ +/** + * T083 / FR-042 — **byte-identical** output across repeated runs over identical input. + * + * ADR-0012 and Constitution Principle IV. FR-042: "Identical inputs MUST produce + * **byte-identical** output across repeated runs, including array ordering and + * serialization details." + * + * # Compared as bytes, not as objects + * + * `toEqual` on two parsed envelopes would pass for two serializations differing in key + * order, whitespace, or number formatting — all of which are "serialization details" + * FR-042 names explicitly. So every comparison here is on the **serialized string** and, + * for the strongest one, on the actual file bytes. + */ + +import { afterAll, beforeAll, describe, expect, test } from 'bun:test'; +import { mkdtemp, readFile, rm } from 'node:fs/promises'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { serializeEnvelope } from '../src/envelope/write.ts'; +import { generateAndWriteEnvelope, runGeneration } from '../src/pipeline.ts'; +import type { GenerationRequest } from '../src/pipeline.ts'; +import { type Checkout, createCheckout, descriptor, stage, validDescriptor } from './pipeline-fixtures.ts'; + +let checkout: Checkout; +let output: string; + +beforeAll(async () => { + checkout = await createCheckout(); + output = await mkdtemp(join(tmpdir(), 'adrkit-bytes-')); +}); + +afterAll(async () => { + await checkout.dispose(); + await rm(output, { recursive: true, force: true }); +}); + +/** + * A deliberately varied corpus. + * + * All three ownership states, several kinds, a namespaced entity, a multi-document + * file, overlapping patterns, and patterns whose declared order differs from their + * sorted order — so a run that failed to sort, or sorted the wrong array, differs + * observably rather than coincidentally matching. + */ +async function variedCorpus(manifestName: string): Promise { + const multi = `${descriptor({ + apiVersion: 'backstage.io/v1alpha1', + kind: 'Component', + name: 'zulu', + ownedPaths: '["zeta/**","alpha/**","middle/**","alpha/**"]', + })}---\n${descriptor({ + apiVersion: 'backstage.io/v1alpha1', + kind: 'API', + name: 'yankee', + namespace: 'payments', + ownedPaths: '[]', + })}`; + + const { request } = await stage( + checkout, + { + 'zzz/catalog-info.yaml': validDescriptor('zzzlast', '["shared/**","packages/z/**"]'), + 'aaa/catalog-info.yaml': multi, + 'mmm/catalog-info.yaml': validDescriptor('mmmmiddle'), + 'nnn/catalog-info.yaml': validDescriptor('nnnother', '["shared/**"]'), + }, + {}, + manifestName, + ); + return request; +} + +describe('T083 — the fixture is varied enough for a difference to show', () => { + test('it spans all three ownership states and several source files', async () => { + const outcome = await runGeneration(await variedCorpus('bytes-shape.json')); + expect(outcome.ok).toBe(true); + if (!outcome.ok) return; + + expect(outcome.envelope.entities).toHaveLength(5); + expect(new Set(outcome.envelope.entities.map((entity) => entity.ownershipState)).size).toBe(3); + expect(outcome.envelope.sources).toHaveLength(4); + }); + + test('a declared pattern order differing from the sorted order is normalized', async () => { + // Without this the byte comparison could pass on a corpus that never needed + // sorting, which would leave the ordering half of FR-042 untested. + const outcome = await runGeneration(await variedCorpus('bytes-order.json')); + if (!outcome.ok) throw new Error('expected success'); + + const zulu = outcome.envelope.entities.find( + (entity) => entity.identity.canonicalId === 'component:default/zulu', + ); + expect(zulu?.derivedPaths).toEqual(['alpha/**', 'middle/**', 'zeta/**']); + }); +}); + +describe('T083 / FR-042 — repeated runs are byte-identical', () => { + test('two runs over identical input serialize identically', async () => { + const first = await runGeneration(await variedCorpus('bytes-two-a.json')); + const second = await runGeneration(await variedCorpus('bytes-two-a.json')); + + expect(first.ok).toBe(true); + expect(second.ok).toBe(true); + if (!first.ok || !second.ok) return; + + expect(serializeEnvelope(second.envelope)).toBe(serializeEnvelope(first.envelope)); + expect(second.envelope.digest).toBe(first.envelope.digest); + }); + + test('two files written by two runs have identical bytes', async () => { + const request = await variedCorpus('bytes-files.json'); + const a = join(output, 'a', 'envelope.json'); + const b = join(output, 'b', 'envelope.json'); + + await generateAndWriteEnvelope(request, a); + await generateAndWriteEnvelope(request, b); + + const [bytesA, bytesB] = await Promise.all([readFile(a), readFile(b)]); + expect(bytesB.equals(bytesA)).toBe(true); + }); + + test('the stage trace is identical too', async () => { + const first = await runGeneration(await variedCorpus('bytes-trace.json')); + const second = await runGeneration(await variedCorpus('bytes-trace.json')); + expect(second.stages).toEqual(first.stages); + }); +}); + +describe('T083 — output is a function of content, not of input ordering', () => { + test('listing the same sources in a different manifest order yields the same entities', async () => { + // The entity list follows manifest declaration order, so the two envelopes differ + // in `entities` order by design — but each entity's own record, and the source + // list, must be identical. Anything else would mean an entity's content depended on + // where its file was listed. + const forwards = await stage( + checkout, + { + 'ord-a/catalog-info.yaml': validDescriptor('ordone', '["packages/one/**"]'), + 'ord-b/catalog-info.yaml': validDescriptor('ordtwo', '["packages/two/**"]'), + }, + {}, + 'bytes-order-forwards.json', + ); + const backwards = await stage( + checkout, + { + 'ord-b/catalog-info.yaml': validDescriptor('ordtwo', '["packages/two/**"]'), + 'ord-a/catalog-info.yaml': validDescriptor('ordone', '["packages/one/**"]'), + }, + {}, + 'bytes-order-backwards.json', + ); + + const first = await runGeneration(forwards.request); + const second = await runGeneration(backwards.request); + expect(first.ok).toBe(true); + expect(second.ok).toBe(true); + if (!first.ok || !second.ok) return; + + // `sources` is sorted by path, so it is order-independent outright. + expect(second.envelope.sources).toEqual(first.envelope.sources); + + const byId = (outcome: typeof first) => + outcome.ok + ? new Map(outcome.envelope.entities.map((entity) => [entity.identity.canonicalId, entity])) + : new Map(); + expect(byId(second).get('component:default/ordone')).toEqual( + byId(first).get('component:default/ordone'), + ); + }); + + test('any content change changes the bytes', async () => { + // The converse. Without it, "identical bytes" would be equally consistent with a + // serializer that ignored its input. + // Distinct paths, deliberately: staging two contents at one path would leave the + // first manifest declaring a digest for bytes that are no longer there, and the run + // would abort with `digest-mismatch` — which is the digest check working, not the + // property under test here. + const original = await stage( + checkout, + { 'chg-a/catalog-info.yaml': validDescriptor('changecase', '["packages/a/**"]') }, + {}, + 'bytes-change-a.json', + ); + const changed = await stage( + checkout, + { 'chg-b/catalog-info.yaml': validDescriptor('changecase', '["packages/b/**"]') }, + {}, + 'bytes-change-b.json', + ); + + const first = await runGeneration(original.request); + const second = await runGeneration(changed.request); + if (!first.ok || !second.ok) throw new Error('expected both to succeed'); + + expect(serializeEnvelope(second.envelope)).not.toBe(serializeEnvelope(first.envelope)); + expect(second.envelope.digest).not.toBe(first.envelope.digest); + }); +}); diff --git a/packages/adapters/catalog-backstage/test/completeness-always-false.test.ts b/packages/adapters/catalog-backstage/test/completeness-always-false.test.ts new file mode 100644 index 00000000..5f39eece --- /dev/null +++ b/packages/adapters/catalog-backstage/test/completeness-always-false.test.ts @@ -0,0 +1,138 @@ +/** + * T070 — `completeness.wholeCatalog` is `false` **unconditionally**, in every envelope, + * on every path. There is no configuration, flag, or input that can make it `true`. + * + * FR-014 and `input-manifest.md` §5's fourth bullet. + * + * # How "no input can change it" is checked without enumerating every input + * + * Two complementary checks, because neither is sufficient alone: + * + * - **By construction**: `completeness()` takes one parameter and it is not + * `wholeCatalog`, so there is no argument through which a caller could set it. That is + * asserted by calling it with both values of the parameter it does take. + * - **By observation**: every envelope produced across a varied set of real runs — + * different entity counts, ownership states, source counts — carries `false`. That + * catches an assembly path that bypassed `completeness()` altogether, which the first + * check cannot see. + */ + +import { afterAll, beforeAll, describe, expect, test } from 'bun:test'; +import { + WHOLE_CATALOG_COMPLETENESS, + completeness, +} from '../src/envelope/completeness.ts'; +import { WHOLE_CATALOG_COMPLETENESS as BOUNDARY_CONSTANT } from '../src/manifest/boundary.ts'; +import { runGeneration } from '../src/pipeline.ts'; +import { descriptor } from './pipeline-fixtures.ts'; +import { type Checkout, createCheckout, stage, validDescriptor } from './pipeline-fixtures.ts'; + +let checkout: Checkout; + +beforeAll(async () => { + checkout = await createCheckout(); +}); + +afterAll(async () => { + await checkout.dispose(); +}); + +describe('T070 — the value is false, and it is one value not two', () => { + test('the boundary constant is false', () => { + expect(BOUNDARY_CONSTANT).toBe(false); + }); + + test('the envelope module re-exports that constant rather than declaring a second', () => { + // A second `false` here could stay `false` while the boundary's changed, and the + // check would then pass against a constant nothing uses. + expect(WHOLE_CATALOG_COMPLETENESS).toBe(BOUNDARY_CONSTANT); + }); +}); + +describe('T070 — no parameter can set it', () => { + test('identityOnly false leaves wholeCatalog false', () => { + expect(completeness(false)).toEqual({ wholeCatalog: false, identityOnly: false }); + }); + + test('identityOnly true still leaves wholeCatalog false', () => { + expect(completeness(true)).toEqual({ wholeCatalog: false, identityOnly: true }); + }); + + test('the function takes exactly one parameter, and it is not wholeCatalog', () => { + // `Function.length` counts declared parameters. A second parameter appearing here + // is the first sign someone has made the value configurable. + expect(completeness.length).toBe(1); + }); +}); + +describe('T070 — every envelope a real run produces carries false', () => { + test('a single-entity run', async () => { + const { request } = await stage( + checkout, + { 'one/catalog-info.yaml': validDescriptor('one', '["packages/one/**"]') }, + {}, + 'manifest-one.json', + ); + const outcome = await runGeneration(request); + expect(outcome.ok).toBe(true); + if (!outcome.ok) return; + expect(outcome.envelope.completeness.wholeCatalog).toBe(false); + }); + + test('a run over all three ownership states and several sources', async () => { + const { request } = await stage( + checkout, + { + 'explicit/catalog-info.yaml': validDescriptor('explicitpaths', '["packages/explicit/**"]'), + 'empty/catalog-info.yaml': validDescriptor('explicitempty', '[]'), + 'absent/catalog-info.yaml': validDescriptor('annotationabsent'), + }, + {}, + 'manifest-states.json', + ); + const outcome = await runGeneration(request); + expect(outcome.ok).toBe(true); + if (!outcome.ok) return; + + expect(outcome.envelope.entities.map((entity) => entity.ownershipState).sort()).toEqual([ + 'annotation-absent', + 'explicit-empty', + 'explicit-paths', + ]); + expect(outcome.envelope.completeness.wholeCatalog).toBe(false); + }); + + test('a run over a multi-document source file', async () => { + const text = `${descriptor({ + apiVersion: 'backstage.io/v1alpha1', + kind: 'Component', + name: 'multione', + })}---\n${descriptor({ + apiVersion: 'backstage.io/v1alpha1', + kind: 'Component', + name: 'multitwo', + })}`; + const { request } = await stage(checkout, { 'multi/catalog-info.yaml': text }, {}, 'manifest-multi.json'); + + const outcome = await runGeneration(request); + expect(outcome.ok).toBe(true); + if (!outcome.ok) return; + + expect(outcome.envelope.entities).toHaveLength(2); + expect(outcome.envelope.completeness.wholeCatalog).toBe(false); + }); + + test('a run with identityOnly requested still reports wholeCatalog false', async () => { + const { request } = await stage( + checkout, + { 'idonly/catalog-info.yaml': validDescriptor('idonly') }, + {}, + 'manifest-idonly.json', + ); + const outcome = await runGeneration({ ...request, identityOnly: true }); + expect(outcome.ok).toBe(true); + if (!outcome.ok) return; + + expect(outcome.envelope.completeness).toEqual({ wholeCatalog: false, identityOnly: true }); + }); +}); diff --git a/packages/adapters/catalog-backstage/test/envelope-digest.test.ts b/packages/adapters/catalog-backstage/test/envelope-digest.test.ts new file mode 100644 index 00000000..485be19d --- /dev/null +++ b/packages/adapters/catalog-backstage/test/envelope-digest.test.ts @@ -0,0 +1,212 @@ +/** + * T081 / FR-040 — the envelope digest: SHA-256 over the canonical form of every field + * except `digest` itself, as 64 lowercase hexadecimal characters. + * + * `snapshot-envelope.md` §3 and `package-boundary.md` §2.1, §2.2. + * + * # The scope qualification, restated here because this is where a reader meets it + * + * For the envelope's **closed scalar domain** the canonical bytes are *equivalent to* + * RFC 8785 / JCS output. **No claim is made that `canonicalStringify` is a + * general-purpose RFC 8785 implementation.** The digest proves accidental-corruption + * and naive-mutation detection **only** (FR-041) — never adversarial tamper-resistance. + * And integrity is not correctness (SC-012): a digest-verified envelope is evidence + * that the bytes are the bytes that were written, not that the ownership in them is + * right. + */ + +import { afterAll, beforeAll, describe, expect, test } from 'bun:test'; +import { createHash } from 'node:crypto'; +import { canonicalStringify, compareCodeUnits } from '@adrkit/core'; +import { + DIGEST_ALGORITHM, + ENVELOPE_DIGEST_PATTERN, + canonicalEnvelopeForm, + computeEnvelopeDigest, + verifyEnvelopeDigest, +} from '../src/envelope/digest.ts'; +import type { SnapshotEnvelope } from '../src/envelope/shape.ts'; +import { runGeneration } from '../src/pipeline.ts'; +import { type Checkout, createCheckout, stage, validDescriptor } from './pipeline-fixtures.ts'; + +let checkout: Checkout; +let envelope: SnapshotEnvelope; + +beforeAll(async () => { + checkout = await createCheckout(); + const { request } = await stage(checkout, { + 'digest-a/catalog-info.yaml': validDescriptor('digesta', '["packages/a/**","apis/a/**"]'), + 'digest-b/catalog-info.yaml': validDescriptor('digestb'), + }); + const outcome = await runGeneration(request); + if (!outcome.ok) throw new Error(`fixture failed to generate: ${outcome.failure.detail}`); + envelope = outcome.envelope; +}); + +afterAll(async () => { + await checkout.dispose(); +}); + +describe('T081 — the rendering `snapshot-envelope.md` §3 requires', () => { + test('64 lowercase hexadecimal characters', () => { + expect(envelope.digest).toMatch(ENVELOPE_DIGEST_PATTERN); + expect(envelope.digest).toHaveLength(64); + expect(envelope.digest).toBe(envelope.digest.toLowerCase()); + }); + + test('the algorithm is sha256', () => { + expect(DIGEST_ALGORITHM).toBe('sha256'); + }); +}); + +describe('T081 — the digest field itself is excluded from its own input', () => { + test('the canonical form has no top-level `digest` key', () => { + // Checked at the top level specifically. `sources[].digest` is a different field + // and is deliberately *inside* the hashed content — an earlier version of this + // assertion searched the whole string and failed for that reason. + const { digest: _omitted, ...unsigned } = envelope; + const form = JSON.parse(canonicalEnvelopeForm(unsigned)) as Record; + expect(Object.hasOwn(form, 'digest')).toBe(false); + expect(Object.keys(form).sort()).toEqual([ + 'capabilities', + 'completeness', + 'entities', + 'generatorVersion', + 'globDialect', + 'repository', + 'schemaVersion', + 'sources', + ]); + }); + + test('the canonical form does contain every other top-level field', () => { + const { digest: _omitted, ...unsigned } = envelope; + const form = canonicalEnvelopeForm(unsigned); + for (const field of [ + 'schemaVersion', + 'repository', + 'generatorVersion', + 'globDialect', + 'capabilities', + 'completeness', + 'sources', + 'entities', + ]) { + expect(form).toContain(`"${field}"`); + } + }); + + test('`sources[].digest` is not excluded — only the envelope\u2019s own digest is', () => { + // Easy to get wrong by excluding the key name rather than the top-level field. + const { digest: _omitted, ...unsigned } = envelope; + expect(canonicalEnvelopeForm(unsigned)).toContain(envelope.sources[0]?.digest as string); + }); +}); + +describe('T081 — the canonical form is canonical', () => { + test('keys are sorted by code units at every nesting level', () => { + const { digest: _omitted, ...unsigned } = envelope; + const form = canonicalEnvelopeForm(unsigned); + + // Top level: the emitted envelope declares `schemaVersion` first, so a canonical + // form starting with `capabilities` is evidence the sort actually happened rather + // than the declaration order being reused. + expect(form.startsWith('{"capabilities"')).toBe(true); + + const entityForm = canonicalStringify(envelope.entities[0]); + expect(entityForm.startsWith('{"derivedPaths"')).toBe(true); + }); + + test('arrays keep their declaration order and are never re-sorted', () => { + // §3 step 2: "serialize arrays in their existing declaration order (never + // re-sorted)". `derivedPaths` is already `compareCodeUnits`-sorted by `glob/order.ts`, + // so a fixture whose ordering differs from the sort is needed to tell the two apart. + const declared = ['zeta/**', 'alpha/**']; + const form = canonicalStringify({ derivedPaths: declared }); + expect(form).toBe('{"derivedPaths":["zeta/**","alpha/**"]}'); + expect([...declared].sort(compareCodeUnits)).toEqual(['alpha/**', 'zeta/**']); + }); + + test('the serialization is compact — no insignificant whitespace', () => { + const { digest: _omitted, ...unsigned } = envelope; + const form = canonicalEnvelopeForm(unsigned); + expect(form).not.toContain('\n'); + expect(form).not.toContain(': '); + expect(form).not.toContain(', '); + }); + + test('an undefined field is omitted rather than serialized as null', () => { + expect(canonicalStringify({ a: 1, b: undefined })).toBe('{"a":1}'); + }); +}); + +describe('T081 — an independent recomputation agrees', () => { + test('recomputing from the canonical form with node:crypto matches the recorded digest', () => { + // Independent of `envelope/digest.ts`'s own helper: this hashes the canonical form + // directly, so a fault in `computeEnvelopeDigest`'s wrapper is visible. + const { digest: declared, ...unsigned } = envelope; + const recomputed = createHash('sha256').update(canonicalStringify(unsigned), 'utf8').digest('hex'); + expect(recomputed).toBe(declared); + }); + + test('verifyEnvelopeDigest reports a match on an untouched envelope', () => { + expect(verifyEnvelopeDigest(envelope).outcome).toBe('match'); + }); + + test('a naive mutation is detected', () => { + // FR-041's exact scope: naive mutation, not an adversary who recomputes the digest. + const tampered: SnapshotEnvelope = { + ...envelope, + entities: envelope.entities.map((entity, index) => + index === 0 ? { ...entity, derivedPaths: ['injected/**'] } : entity, + ), + }; + const result = verifyEnvelopeDigest(tampered); + expect(result.outcome).toBe('digest-mismatch'); + expect(result.declaredDigest).toBe(envelope.digest); + expect(result.recomputedDigest).not.toBe(envelope.digest); + }); + + test('an adversary who recomputes the digest is NOT detected, as FR-041 states', () => { + // Asserted rather than left implicit, so no reader infers a stronger guarantee from + // the mutation case passing above. + const { digest: _omitted, ...unsigned } = envelope; + const mutated = { + ...unsigned, + entities: unsigned.entities.map((entity, index) => + index === 0 ? { ...entity, derivedPaths: ['injected/**'] } : entity, + ), + }; + const resigned: SnapshotEnvelope = { ...mutated, digest: computeEnvelopeDigest(mutated) }; + expect(verifyEnvelopeDigest(resigned).outcome).toBe('match'); + expect(resigned.entities[0]?.derivedPaths).toEqual(['injected/**']); + }); + + test('verifyEnvelopeDigest does not mutate its input', () => { + const before = JSON.stringify(envelope); + verifyEnvelopeDigest(envelope); + expect(JSON.stringify(envelope)).toBe(before); + }); +}); + +describe('T081 — the digest is a function of content, not of field order', () => { + test('reordering top-level keys leaves the digest unchanged', () => { + const { digest: declared, ...unsigned } = envelope; + const reordered = { + entities: unsigned.entities, + sources: unsigned.sources, + completeness: unsigned.completeness, + capabilities: unsigned.capabilities, + globDialect: unsigned.globDialect, + generatorVersion: unsigned.generatorVersion, + repository: unsigned.repository, + schemaVersion: unsigned.schemaVersion, + }; + expect(computeEnvelopeDigest(reordered)).toBe(declared); + }); + + test('changing any content changes the digest', () => { + const { digest: declared, ...unsigned } = envelope; + expect(computeEnvelopeDigest({ ...unsigned, generatorVersion: 'other' })).not.toBe(declared); + }); +}); diff --git a/packages/adapters/catalog-backstage/test/envelope-only.test.ts b/packages/adapters/catalog-backstage/test/envelope-only.test.ts new file mode 100644 index 00000000..2f8daa02 --- /dev/null +++ b/packages/adapters/catalog-backstage/test/envelope-only.test.ts @@ -0,0 +1,209 @@ +/** + * T079 / FR-038 — the versioned envelope is the **only** output: no side files, no logs + * presented as output, no auxiliary artifacts, and never a `CatalogSnapshot`-shaped + * artifact. + * + * ADR-0020 clause 7, quoted by FR-038: "The generator writes the envelope and nothing + * else." + * + * # Checked from the filesystem, not from the writer's own report + * + * Every assertion about what was written reads the **directory**. A writer that + * reported one file while creating two would pass a check that trusted its return + * value, and that is precisely the failure "no side files" is about. + */ + +import { afterAll, beforeAll, describe, expect, test } from 'bun:test'; +import { mkdtemp, readdir, rm, writeFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { serializeEnvelope, writeEnvelope } from '../src/envelope/write.ts'; +import { generateAndWriteEnvelope, runGeneration } from '../src/pipeline.ts'; +import { ADAPTER_ROOT, scanned, violations } from './source-scan.ts'; +import type { Rule } from './source-scan.ts'; +import { type Checkout, createCheckout, stage, validDescriptor } from './pipeline-fixtures.ts'; + +let checkout: Checkout; +let output: string; + +beforeAll(async () => { + checkout = await createCheckout(); + output = await mkdtemp(join(tmpdir(), 'adrkit-only-')); +}); + +afterAll(async () => { + await checkout.dispose(); + await rm(output, { recursive: true, force: true }); +}); + +async function generateInto(directory: string, name: string): Promise { + const { request } = await stage( + checkout, + { + [`${name}/catalog-info.yaml`]: validDescriptor(name, `["packages/${name}/**"]`), + [`${name}-two/catalog-info.yaml`]: validDescriptor(`${name}two`), + }, + {}, + `manifest-${name}.json`, + ); + const result = await generateAndWriteEnvelope(request, join(directory, 'envelope.json')); + if (!result.ok) throw new Error(`generation failed: ${result.failure.detail}`); +} + +describe('T079 — exactly one file is written', () => { + test('a successful run leaves one file, and it is the envelope', async () => { + const directory = join(output, 'one-file'); + await generateInto(directory, 'onlyone'); + + expect((await readdir(directory)).sort()).toEqual(['envelope.json']); + }); + + test('a second run into the same directory still leaves one file', async () => { + // Catches an implementation that accumulates timestamped or numbered side files. + const directory = join(output, 'twice'); + await generateInto(directory, 'twicea'); + await generateInto(directory, 'twiceb'); + + expect((await readdir(directory)).sort()).toEqual(['envelope.json']); + }); + + test('a pre-existing unrelated file is left alone rather than cleaned up', async () => { + // "Writes nothing else" is not a licence to delete. The writer's scope is one path. + const directory = join(output, 'preexisting'); + await generateInto(directory, 'preexistinga'); + await writeFile(join(directory, 'unrelated.txt'), 'kept', 'utf8'); + await generateInto(directory, 'preexistingb'); + + expect((await readdir(directory)).sort()).toEqual(['envelope.json', 'unrelated.txt']); + expect(await Bun.file(join(directory, 'unrelated.txt')).text()).toBe('kept'); + }); + + test('the write reports the one path it wrote', async () => { + const { request } = await stage( + checkout, + { 'reported/catalog-info.yaml': validDescriptor('reported') }, + {}, + 'manifest-reported.json', + ); + const destination = join(output, 'reported', 'envelope.json'); + const result = await generateAndWriteEnvelope(request, destination); + + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.write.path).toBe(destination); + }); +}); + +describe('T079 — no CatalogSnapshot-shaped artifact is ever written', () => { + test('the written file is envelope-shaped and not snapshot-shaped', async () => { + const directory = join(output, 'shape-check'); + await generateInto(directory, 'shapecheck'); + + const parsed = JSON.parse(await Bun.file(join(directory, 'envelope.json')).text()) as Record< + string, + unknown + >; + + // A `CatalogSnapshot` is `{ entities: CatalogSnapshotEntity[] }` where each entity + // is `{ id, refs?, paths? }` (`packages/core/src/affects/catalog.ts`). The envelope + // also has `entities`, so the discriminating check is the entity record's fields. + expect(parsed['schemaVersion']).toBe('1'); + expect(parsed['digest']).toBeDefined(); + for (const entity of parsed['entities'] as Record[]) { + expect(Object.hasOwn(entity, 'id')).toBe(false); + expect(Object.hasOwn(entity, 'refs')).toBe(false); + expect(Object.hasOwn(entity, 'paths')).toBe(false); + expect(Object.hasOwn(entity, 'identity')).toBe(true); + } + }); + + test('no adapter source names the core catalog snapshot types', () => { + // Reaching for `CatalogSnapshot` at all is the step before writing one. This is a + // source-level check because the runtime one above can only observe the shapes a + // fixture happened to produce. + const rules: readonly Rule[] = [ + { + id: 'catalog-snapshot-type', + pattern: /\bCatalogSnapshotEntity\b|\bCatalogSnapshot\b/, + why: 'FR-038 / ADR-0020 clause 7: the generator writes the envelope and nothing else; deriving a CatalogSnapshot belongs to the consumer package', + }, + { + id: 'core-affects-catalog', + pattern: /affects\/catalog/, + why: 'the core catalog port is not part of the generator surface (FR-020: those types are unchanged by this feature)', + }, + ]; + expect(violations(scanned(ADAPTER_ROOT), rules)).toEqual([]); + }); + + test('that rule has been observed firing, so its silence means something', () => { + const rules: readonly Rule[] = [ + { + id: 'catalog-snapshot-type', + pattern: /\bCatalogSnapshotEntity\b|\bCatalogSnapshot\b/, + why: 'fixture', + }, + ]; + expect( + violations([{ path: 'fixture.ts', code: 'const s: CatalogSnapshot = { entities: [] };' }], rules).map( + (violation) => violation.ruleId, + ), + ).toEqual(['catalog-snapshot-type']); + }); +}); + +describe('T079 — diagnostics are returned, never written', () => { + test('the stage trace is a returned value and appears in no file', async () => { + const directory = join(output, 'no-logs'); + const { request } = await stage( + checkout, + { 'nologs/catalog-info.yaml': validDescriptor('nologs') }, + {}, + 'manifest-nologs.json', + ); + + const outcome = await runGeneration(request); + expect(outcome.ok).toBe(true); + if (!outcome.ok) return; + expect(outcome.stages.length).toBeGreaterThan(0); + + await generateAndWriteEnvelope(request, join(directory, 'envelope.json')); + const written = await Bun.file(join(directory, 'envelope.json')).text(); + expect(written).not.toContain('"stages"'); + expect((await readdir(directory)).sort()).toEqual(['envelope.json']); + }); + + test('the serialization contains only the envelope\u2019s own fields', async () => { + const { request } = await stage( + checkout, + { 'serial/catalog-info.yaml': validDescriptor('serial') }, + {}, + 'manifest-serial.json', + ); + const outcome = await runGeneration(request); + if (!outcome.ok) throw new Error('expected success'); + + const text = serializeEnvelope(outcome.envelope); + expect(text.endsWith('\n')).toBe(true); + expect(JSON.parse(text)).toEqual(JSON.parse(JSON.stringify(outcome.envelope))); + }); +}); + +describe('T079 — writeEnvelope writes one file and creates its directory', () => { + test('a nested destination directory is created', async () => { + const { request } = await stage( + checkout, + { 'nested/catalog-info.yaml': validDescriptor('nested') }, + {}, + 'manifest-nested.json', + ); + const outcome = await runGeneration(request); + if (!outcome.ok) throw new Error('expected success'); + + const directory = join(output, 'deep', 'deeper', 'deepest'); + const result = await writeEnvelope(outcome.envelope, join(directory, 'envelope.json')); + + expect(result.path).toBe(join(directory, 'envelope.json')); + expect((await readdir(directory)).sort()).toEqual(['envelope.json']); + }); +}); diff --git a/packages/adapters/catalog-backstage/test/envelope-provenance.test.ts b/packages/adapters/catalog-backstage/test/envelope-provenance.test.ts new file mode 100644 index 00000000..e98ff60c --- /dev/null +++ b/packages/adapters/catalog-backstage/test/envelope-provenance.test.ts @@ -0,0 +1,186 @@ +/** + * T082 / FR-043 — the provenance boundary: upstream-authored descriptor content and + * maintainer-authored annotation overlay are recorded as **distinct** provenances and + * never merged into an undifferentiated whole. + * + * `data-model.md` §10 fixes the domain at two values and states what each means. The + * blocks below check the domain, the exhaustive declaration, the two values surviving + * side by side in one envelope, and the one thing the field must never do — assert + * third-party adoption that nobody attested. + */ + +import { afterAll, beforeAll, describe, expect, test } from 'bun:test'; +import { + ANNOTATION_PROVENANCES, + allMaintainerOverlay, + allUpstreamAuthored, + checkProvenanceDeclaration, + isAdoptionClaim, + provenanceFor, +} from '../src/envelope/provenance.ts'; +import { runGeneration } from '../src/pipeline.ts'; +import { type Checkout, createCheckout, request, validDescriptor, writeManifest, writeSource } from './pipeline-fixtures.ts'; + +let checkout: Checkout; + +beforeAll(async () => { + checkout = await createCheckout(); +}); + +afterAll(async () => { + await checkout.dispose(); +}); + +describe('T082 — the domain is closed at exactly two values', () => { + test('`data-model.md` §10\u2019s two values, and no third', () => { + expect([...ANNOTATION_PROVENANCES]).toEqual(['upstream-authored', 'maintainer-overlay']); + expect(ANNOTATION_PROVENANCES).toHaveLength(2); + }); + + test('a value outside the domain is rejected rather than passed through', () => { + const outcome = checkProvenanceDeclaration( + { bySourcePath: { 'a.yaml': 'third-party' as 'maintainer-overlay' } }, + ['a.yaml'], + ); + expect(outcome.ok).toBe(false); + if (outcome.ok) return; + expect(outcome.rejection.reason).toBe('provenance-declaration-unrecognized-value'); + }); +}); + +describe('T082 — the declaration is exhaustive and has no default', () => { + test('a complete declaration passes', () => { + expect(checkProvenanceDeclaration(allMaintainerOverlay(['a.yaml', 'b.yaml']), ['a.yaml', 'b.yaml']).ok).toBe( + true, + ); + }); + + test('a missing entry is rejected, never defaulted', () => { + // The safety property. A default of `upstream-authored` would turn an omission + // into a claim that a third party adopted the annotation. + const outcome = checkProvenanceDeclaration(allMaintainerOverlay(['a.yaml']), ['a.yaml', 'b.yaml']); + expect(outcome.ok).toBe(false); + if (outcome.ok) return; + expect(outcome.rejection.reason).toBe('provenance-declaration-missing'); + expect(outcome.rejection.detail).toContain('b.yaml'); + }); + + test('the rejection explains why no default is available', () => { + const outcome = checkProvenanceDeclaration({ bySourcePath: {} }, ['a.yaml']); + if (outcome.ok) throw new Error('expected a rejection'); + expect(outcome.rejection.detail).toContain('third-party adoption'); + }); + + test('the reported path does not depend on key insertion order', () => { + const first = checkProvenanceDeclaration({ bySourcePath: {} }, ['z.yaml', 'a.yaml']); + const second = checkProvenanceDeclaration({ bySourcePath: {} }, ['a.yaml', 'z.yaml']); + if (first.ok || second.ok) throw new Error('expected rejections'); + expect(first.rejection.detail).toBe(second.rejection.detail); + expect(first.rejection.detail).toContain('a.yaml'); + }); + + test('provenanceFor throws rather than substituting a value', () => { + expect(() => provenanceFor({ bySourcePath: {} }, 'a.yaml')).toThrow( + 'no annotation provenance declared', + ); + }); +}); + +describe('T082 — the two provenances survive side by side, unmerged', () => { + test('one envelope carries both values, each on its own entity', async () => { + const overlaid = await writeSource( + checkout, + 'overlaid/catalog-info.yaml', + validDescriptor('overlaid', '["packages/overlaid/**"]'), + ); + const upstream = await writeSource( + checkout, + 'upstream/catalog-info.yaml', + validDescriptor('upstream', '["packages/upstream/**"]'), + ); + const sources = [overlaid, upstream]; + const manifestPath = await writeManifest(checkout, sources, {}, 'manifest-provenance.json'); + + const outcome = await runGeneration( + request(checkout, manifestPath, sources, { + bySourcePath: { + 'overlaid/catalog-info.yaml': 'maintainer-overlay', + 'upstream/catalog-info.yaml': 'upstream-authored', + }, + }), + ); + + expect(outcome.ok).toBe(true); + if (!outcome.ok) return; + + const byId = new Map( + outcome.envelope.entities.map((entity) => [entity.identity.canonicalId, entity.provenance]), + ); + expect(byId.get('component:default/overlaid')).toBe('maintainer-overlay'); + expect(byId.get('component:default/upstream')).toBe('upstream-authored'); + + // Never merged: two distinct values are present in one envelope, so nothing + // collapsed them into an undifferentiated whole. + expect(new Set(byId.values()).size).toBe(2); + }); + + test('the frozen accept corpus\u2019 own construction maps to maintainer-overlay', async () => { + // `accept-corpus-freeze/overlay.json`: "No descriptor in the pinned corpus carries + // `adrkit.io/owned-paths`... Every annotation value below was written by the + // maintainer. None was read from upstream." + const source = await writeSource( + checkout, + 'freeze-shaped/catalog-info.yaml', + validDescriptor('freezeshaped', '["workspaces/alpha/src/**"]'), + ); + const manifestPath = await writeManifest(checkout, [source], {}, 'manifest-freeze-shaped.json'); + + const outcome = await runGeneration( + request(checkout, manifestPath, [source], allMaintainerOverlay([source.path])), + ); + expect(outcome.ok).toBe(true); + if (!outcome.ok) return; + expect(outcome.envelope.entities[0]?.provenance).toBe('maintainer-overlay'); + }); +}); + +describe('T082 — provenance alone is not an adoption claim; the pair is', () => { + test('an annotation-absent entity is never an adoption claim, whatever its provenance', () => { + // `data-model.md` §10's domain has no value for "no annotation exists", and + // `annotation-absent` is the common real-corpus case. `envelope/provenance.ts` + // records that gap; this is the predicate that keeps it safe to read. + expect(isAdoptionClaim('annotation-absent', 'upstream-authored')).toBe(false); + expect(isAdoptionClaim('annotation-absent', 'maintainer-overlay')).toBe(false); + }); + + test('an existing annotation declared upstream-authored IS an adoption claim', () => { + expect(isAdoptionClaim('explicit-paths', 'upstream-authored')).toBe(true); + expect(isAdoptionClaim('explicit-empty', 'upstream-authored')).toBe(true); + }); + + test('an existing annotation declared maintainer-overlay is not', () => { + expect(isAdoptionClaim('explicit-paths', 'maintainer-overlay')).toBe(false); + }); + + test('a run over an unannotated corpus makes no adoption claim', async () => { + const source = await writeSource( + checkout, + 'no-annotation/catalog-info.yaml', + validDescriptor('noannotation'), + ); + const manifestPath = await writeManifest(checkout, [source], {}, 'manifest-no-annotation.json'); + + const outcome = await runGeneration( + request(checkout, manifestPath, [source], allUpstreamAuthored([source.path])), + ); + expect(outcome.ok).toBe(true); + if (!outcome.ok) return; + + const entity = outcome.envelope.entities[0]; + expect(entity?.ownershipState).toBe('annotation-absent'); + expect(entity?.provenance).toBe('upstream-authored'); + expect(isAdoptionClaim(entity?.ownershipState ?? 'annotation-absent', entity?.provenance ?? 'upstream-authored')).toBe( + false, + ); + }); +}); diff --git a/packages/adapters/catalog-backstage/test/envelope-shape.test.ts b/packages/adapters/catalog-backstage/test/envelope-shape.test.ts new file mode 100644 index 00000000..ea389097 --- /dev/null +++ b/packages/adapters/catalog-backstage/test/envelope-shape.test.ts @@ -0,0 +1,202 @@ +/** + * T080 / FR-039 — the envelope's declared fields, and **exactly five** fields per + * `entities[]` record. The flatter triple shape is forbidden. + * + * # Every assertion here is made against the emitted JSON + * + * A TypeScript interface does not enforce field counts at run time — excess-property + * checking applies to object literals only and is erased entirely once compiled. So the + * checks below round-trip the envelope through `serializeEnvelope` and `JSON.parse` + * and inspect the **parsed object's own keys**, which is where the constraint is + * actually observable and where a consumer will meet it. + */ + +import { afterAll, beforeAll, describe, expect, test } from 'bun:test'; +import { + ENTITY_RECORD_FIELDS, + ENVELOPE_CAPABILITIES, + ENVELOPE_SCHEMA_VERSION, + ENVELOPE_TOP_LEVEL_FIELDS, + FORBIDDEN_FLAT_ENTITY_FIELDS, + IDENTITY_PROJECTION_FIELDS, + entityRecord, +} from '../src/envelope/shape.ts'; +import { serializeEnvelope } from '../src/envelope/write.ts'; +import { GLOB_OPTIONS } from '../src/glob/dialect.ts'; +import { runGeneration } from '../src/pipeline.ts'; +import { type Checkout, createCheckout, descriptor, stage, validDescriptor } from './pipeline-fixtures.ts'; + +let checkout: Checkout; +let parsed: Record; + +beforeAll(async () => { + checkout = await createCheckout(); + + const multiDocument = `${descriptor({ + apiVersion: 'backstage.io/v1alpha1', + kind: 'Component', + name: 'shapeone', + ownedPaths: '["packages/one/**"]', + })}---\n${descriptor({ + apiVersion: 'backstage.io/v1alpha1', + kind: 'API', + name: 'shapetwo', + namespace: 'payments', + ownedPaths: '[]', + })}`; + + const { request } = await stage(checkout, { + 'a/catalog-info.yaml': multiDocument, + 'b/catalog-info.yaml': validDescriptor('shapethree'), + }); + + const outcome = await runGeneration(request); + if (!outcome.ok) throw new Error(`fixture failed to generate: ${outcome.failure.detail}`); + parsed = JSON.parse(serializeEnvelope(outcome.envelope)) as Record; +}); + +afterAll(async () => { + await checkout.dispose(); +}); + +describe('T080 — the fixture is rich enough for the checks to mean something', () => { + test('three entities, spanning several kinds, namespaces and ownership states', () => { + const entities = parsed['entities'] as Record[]; + expect(entities).toHaveLength(3); + expect(new Set(entities.map((entity) => entity['ownershipState'])).size).toBe(3); + }); +}); + +describe('T080 / data-model.md §9 — nine top-level fields', () => { + test('the declared list has nine entries', () => { + expect(ENVELOPE_TOP_LEVEL_FIELDS).toHaveLength(9); + }); + + test('the emitted object carries exactly those nine, no more and no fewer', () => { + expect(Object.keys(parsed).sort()).toEqual([...ENVELOPE_TOP_LEVEL_FIELDS].sort()); + }); + + test('field order in the emitted JSON follows the contract\u2019s order', () => { + // Irrelevant to the digest, which sorts keys, but it is what makes the *file* + // byte-identical across runs independently of the digest (FR-042). + expect(Object.keys(parsed)).toEqual([...ENVELOPE_TOP_LEVEL_FIELDS]); + }); + + test('schemaVersion and capabilities are the exact values the consumer validates', () => { + // `snapshot-envelope.md` §2 step 3 validates both "by **exact value**, not merely + // 'recognized'". A generator able to emit anything else could emit an envelope its + // own consumer rejects. + expect(parsed['schemaVersion']).toBe(ENVELOPE_SCHEMA_VERSION); + expect(parsed['schemaVersion']).toBe('1'); + expect(parsed['capabilities']).toEqual([...ENVELOPE_CAPABILITIES]); + expect(parsed['capabilities']).toEqual(['pathOwnership']); + }); + + test('globDialect records the engine and options actually used', () => { + expect(parsed['globDialect']).toEqual({ + engine: 'picomatch', + version: expect.any(String), + options: { ...GLOB_OPTIONS }, + }); + }); + + test('the nested objects carry their declared fields', () => { + expect(Object.keys(parsed['repository'] as object).sort()).toEqual(['id', 'revision']); + expect(Object.keys(parsed['completeness'] as object).sort()).toEqual([ + 'identityOnly', + 'wholeCatalog', + ]); + for (const source of parsed['sources'] as Record[]) { + expect(Object.keys(source).sort()).toEqual(['digest', 'digestAlgorithm', 'path']); + } + }); +}); + +describe('T080 / data-model.md §10 — exactly five fields per entity record', () => { + test('the declared list has five entries', () => { + expect(ENTITY_RECORD_FIELDS).toHaveLength(5); + }); + + test('every emitted record carries exactly those five', () => { + for (const entity of parsed['entities'] as Record[]) { + expect(Object.keys(entity).sort()).toEqual([...ENTITY_RECORD_FIELDS].sort()); + expect(Object.keys(entity)).toHaveLength(5); + } + }); + + test('the identity projection is `{ canonicalId, allRefs }` and nothing else', () => { + // `snapshot-envelope.md` §1: the pre-lowercase authoring inputs are "already fully + // captured by `canonicalId` and `allRefs`". Serializing them would put the authored + // casing back into an artifact whose point is the canonical form. + for (const entity of parsed['entities'] as Record[]) { + const identity = entity['identity'] as Record; + expect(Object.keys(identity).sort()).toEqual([...IDENTITY_PROJECTION_FIELDS].sort()); + expect(Array.isArray(identity['allRefs'])).toBe(true); + expect((identity['allRefs'] as string[]).length).toBeGreaterThan(0); + } + }); + + test('the sourceDocument reference is `{ sourcePath, documentIndexInFile }`', () => { + for (const entity of parsed['entities'] as Record[]) { + const reference = entity['sourceDocument'] as Record; + expect(Object.keys(reference).sort()).toEqual(['documentIndexInFile', 'sourcePath']); + expect(Number.isInteger(reference['documentIndexInFile'])).toBe(true); + } + }); + + test('a multi-document file yields distinct documentIndexInFile values', () => { + const fromA = (parsed['entities'] as Record[]) + .map((entity) => entity['sourceDocument'] as Record) + .filter((reference) => reference['sourcePath'] === 'a/catalog-info.yaml') + .map((reference) => reference['documentIndexInFile']); + expect(fromA).toEqual([0, 1]); + }); +}); + +describe('T080 — the flatter shape is forbidden', () => { + test('no entity record carries a flat canonicalId, refs or paths field', () => { + for (const entity of parsed['entities'] as Record[]) { + for (const forbidden of FORBIDDEN_FLAT_ENTITY_FIELDS) { + expect(Object.hasOwn(entity, forbidden)).toBe(false); + } + } + }); + + test('the forbidden list is checked by name, not inferred from a field count', () => { + // A `{ canonicalId, refs, paths }` triple has three fields, so a count-only check + // would reject it for the wrong reason and would keep passing if two more fields + // were later added to reach five. + expect(FORBIDDEN_FLAT_ENTITY_FIELDS).toContain('canonicalId'); + expect(FORBIDDEN_FLAT_ENTITY_FIELDS).toContain('refs'); + expect(FORBIDDEN_FLAT_ENTITY_FIELDS).toContain('paths'); + }); + + test('the authoring fields §1 excludes are not serialized', () => { + for (const entity of parsed['entities'] as Record[]) { + const identity = entity['identity'] as Record; + for (const excluded of ['rawKind', 'rawNamespace', 'rawName', 'fixtureAuthoredAliasRefs']) { + expect(Object.hasOwn(identity, excluded)).toBe(false); + } + } + }); +}); + +describe('T080 — entityRecord projects rather than spreads', () => { + test('an input carrying extra fields does not leak them into the record', () => { + const record = entityRecord({ + canonicalId: 'component:default/a', + allRefs: ['component:default/a'], + ownershipState: 'annotation-absent', + derivedPaths: [], + sourcePath: 'a.yaml', + documentIndexInFile: 0, + provenance: 'maintainer-overlay', + // A field the projection must ignore. A spread-based implementation would carry + // it through, which is the failure this check exists for. + ...({ rawKind: 'Component' } as unknown as Record), + }); + + expect(Object.keys(record).sort()).toEqual([...ENTITY_RECORD_FIELDS].sort()); + expect(Object.hasOwn(record, 'rawKind')).toBe(false); + }); +}); diff --git a/packages/adapters/catalog-backstage/test/no-dynamic-loader.test.ts b/packages/adapters/catalog-backstage/test/no-dynamic-loader.test.ts index 1a478997..0451827f 100644 --- a/packages/adapters/catalog-backstage/test/no-dynamic-loader.test.ts +++ b/packages/adapters/catalog-backstage/test/no-dynamic-loader.test.ts @@ -107,7 +107,7 @@ describe('FR-002 — no dynamic loader anywhere in the adapter source', () => { expect(files.length).toBeGreaterThan(0); }); - test('the excluded-from-scan set is exactly the five self-referential guard files', () => { + test('the excluded-from-scan set is exactly the six self-referential guard files', () => { // These files contain the rule literals themselves. The exclusion is pinned // so it cannot grow into a way of hiding a violation: adding an entry fails // this test until someone updates it deliberately, which is the point. @@ -117,10 +117,14 @@ describe('FR-002 — no dynamic loader anywhere in the adapter source', () => { // it proves is never imported — so both were unscannable without an entry // here. The alternative two sessions reached for first was renaming around // the scanner, which leaves the trap armed for the next writer. + // + // The Phase E entry (`envelope-only.test.ts`) was added for the same reason: + // FR-038's guard must name `CatalogSnapshot` in order to forbid it. expect([...EXCLUDED_FROM_SCAN]).toEqual([ 'packages/adapters/catalog-backstage/test/envelope-shape-locality.test.ts', 'packages/adapters/catalog-backstage/test/no-dynamic-loader.test.ts', 'packages/adapters/catalog-backstage/test/source-scan.ts', + 'packages/adapters/catalog-backstage/test/envelope-only.test.ts', 'packages/catalog-envelope/test/no-core-schema-change.test.ts', 'packages/catalog-envelope/test/no-adapter-import.test.ts', ]); diff --git a/packages/adapters/catalog-backstage/test/overlap.test.ts b/packages/adapters/catalog-backstage/test/overlap.test.ts new file mode 100644 index 00000000..38414910 --- /dev/null +++ b/packages/adapters/catalog-backstage/test/overlap.test.ts @@ -0,0 +1,129 @@ +/** + * T072 — **overlap between distinct canonical ids is not a collision**, positively + * demonstrated. + * + * `entity-identity.md` §4 requires this "be **positively demonstrated** (both entities' + * derived `paths` retain the overlapping pattern, and the changed file matches both), + * not merely asserted by the absence of a rejection rule." + * + * Every block below therefore asserts something **present**: that the fixture really + * does overlap, that both entities survive into the envelope, that both retain the + * shared pattern, and that a changed file matching it is owned by both. "The run did + * not abort" appears only alongside those, never instead of them — a generator that had + * silently dropped one of the two would also not abort. + */ + +import { afterAll, beforeAll, describe, expect, test } from 'bun:test'; +import { createGlobCompiler } from '../src/glob/dialect.ts'; +import { hasOverlap, ownersOf, pathOverlaps } from '../src/identity/overlap.ts'; +import { checkGlobalUniqueness } from '../src/identity/uniqueness.ts'; +import { runGeneration } from '../src/pipeline.ts'; +import { type Checkout, createCheckout, stage, validDescriptor } from './pipeline-fixtures.ts'; + +const SHARED = 'packages/shared/**'; + +/** §4's own worked example: `billing` and `invoicing` both declaring `packages/shared/**`. */ +const CLAIMS = [ + { canonicalId: 'component:default/billing', derivedPaths: ['packages/billing/**', SHARED] }, + { canonicalId: 'component:default/invoicing', derivedPaths: ['packages/invoicing/**', SHARED] }, +]; + +let checkout: Checkout; + +beforeAll(async () => { + checkout = await createCheckout(); +}); + +afterAll(async () => { + await checkout.dispose(); +}); + +describe('T072 — the fixture genuinely overlaps', () => { + test('overlap is present, so nothing below passes vacuously', () => { + expect(hasOverlap(CLAIMS)).toBe(true); + }); + + test('the overlapping pattern is named, and both claimants are listed', () => { + expect(pathOverlaps(CLAIMS)).toEqual([ + { pattern: SHARED, canonicalIds: ['component:default/billing', 'component:default/invoicing'] }, + ]); + }); + + test('a set with no shared pattern reports no overlap', () => { + expect( + hasOverlap([ + { canonicalId: 'component:default/a', derivedPaths: ['packages/a/**'] }, + { canonicalId: 'component:default/b', derivedPaths: ['packages/b/**'] }, + ]), + ).toBe(false); + }); + + test('one entity listing a pattern twice is not an overlap with itself', () => { + expect( + hasOverlap([{ canonicalId: 'component:default/a', derivedPaths: [SHARED, SHARED] }]), + ).toBe(false); + }); +}); + +describe('T072 / §4 — overlap does not trigger the abort', () => { + test('the distinct canonical ids pass the uniqueness check', () => { + const outcome = checkGlobalUniqueness( + CLAIMS.map((claim) => ({ canonicalId: claim.canonicalId, allRefs: [claim.canonicalId] })), + ); + expect(outcome.ok).toBe(true); + }); + + test('a real run over two overlapping entities produces an envelope with both', async () => { + const { request } = await stage(checkout, { + 'billing/catalog-info.yaml': validDescriptor('billing', `["packages/billing/**","${SHARED}"]`), + 'invoicing/catalog-info.yaml': validDescriptor('invoicing', `["packages/invoicing/**","${SHARED}"]`), + }); + + const outcome = await runGeneration(request); + expect(outcome.ok).toBe(true); + if (!outcome.ok) return; + + // Present-tense assertions: both entities exist, and both retain the pattern. + expect(outcome.envelope.entities).toHaveLength(2); + const ids = outcome.envelope.entities.map((entity) => entity.identity.canonicalId).sort(); + expect(ids).toEqual(['component:default/billing', 'component:default/invoicing']); + + for (const entity of outcome.envelope.entities) { + expect(entity.derivedPaths).toContain(SHARED); + } + }); +}); + +describe('T072 / §4 — no exclusive winner: a changed file is owned by every match', () => { + test('both entities own a file matching the shared pattern', () => { + const owners = ownersOf(CLAIMS, 'packages/shared/util.ts', createGlobCompiler()); + expect(owners).toEqual(['component:default/billing', 'component:default/invoicing']); + expect(owners).toHaveLength(2); + }); + + test('a file matching only one pattern is owned by only that entity', () => { + // Contrast case. Without it, "both entities were returned" would be equally + // consistent with a function that returns every entity for every path. + expect(ownersOf(CLAIMS, 'packages/billing/index.ts', createGlobCompiler())).toEqual([ + 'component:default/billing', + ]); + }); + + test('a file matching nothing is owned by nobody', () => { + expect(ownersOf(CLAIMS, 'docs/readme.md', createGlobCompiler())).toEqual([]); + }); + + test('the result is a list, so a caller cannot read "the owner" off it', () => { + const owners = ownersOf(CLAIMS, 'packages/shared/util.ts', createGlobCompiler()); + expect(Array.isArray(owners)).toBe(true); + // ADR-0009's union-not-winner semantics, mirrored: the second entity is not a + // runner-up, it is an owner. + expect(owners[1]).toBe('component:default/invoicing'); + }); + + test('ownership does not depend on the order the claims are listed in', () => { + const forwards = ownersOf(CLAIMS, 'packages/shared/util.ts', createGlobCompiler()); + const backwards = ownersOf([...CLAIMS].reverse(), 'packages/shared/util.ts', createGlobCompiler()); + expect(backwards).toEqual(forwards); + }); +}); diff --git a/packages/adapters/catalog-backstage/test/pipeline-fixtures.ts b/packages/adapters/catalog-backstage/test/pipeline-fixtures.ts new file mode 100644 index 00000000..07c5ea10 --- /dev/null +++ b/packages/adapters/catalog-backstage/test/pipeline-fixtures.ts @@ -0,0 +1,236 @@ +/** + * Shared fixtures for the Phase E pipeline tests. + * + * # Every fixture runs against a real git checkout + * + * `repository/identity.ts` reads `git remote get-url origin` and `git rev-parse HEAD` + * as subprocesses — the two reads `input-manifest.md` §5 permits. A fixture that + * supplied those values instead of reading them would leave the one stage that touches + * the outside world unexercised, and every "the full assembled pipeline" claim in + * Phase E would quietly be about a pipeline with a stage stubbed out. + * + * So {@link createCheckout} initialises a real repository in a temporary directory: an + * `origin` remote, one empty commit for `HEAD` to name. Descriptor files are written + * into the worktree and **not** committed, so `HEAD` is stable while file content + * varies — one checkout can therefore serve every case in a file. + * + * # No network, and nothing outside the temporary directory + * + * `git init`, `git remote add`, and `git commit --allow-empty` are all local. Nothing + * here fetches, clones, or resolves a remote; the `origin` URL is a string in the git + * config that is never contacted. + */ + +import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises'; +import { dirname, join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { sha256Hex } from '../src/manifest/digests.ts'; +import type { ManifestSource } from '../src/manifest/schema.ts'; +import type { ProvenanceDeclaration } from '../src/envelope/provenance.ts'; +import { allMaintainerOverlay } from '../src/envelope/provenance.ts'; +import type { GenerationRequest } from '../src/pipeline.ts'; + +/** A temporary git checkout a fixture generates against. */ +export interface Checkout { + readonly root: string; + /** The `origin` URL, verbatim as configured. */ + readonly remoteUrl: string; + /** The normalized repository id the manifest should declare. */ + readonly repositoryId: string; + /** `git rev-parse HEAD`. */ + readonly revision: string; + /** Remove the temporary directory. */ + dispose(): Promise; +} + +async function git(args: readonly string[], cwd: string): Promise { + const proc = Bun.spawn(['git', ...args], { + cwd, + stdout: 'pipe', + stderr: 'pipe', + env: { ...Bun.env, GIT_TERMINAL_PROMPT: '0', GIT_CONFIG_GLOBAL: '/dev/null', GIT_CONFIG_SYSTEM: '/dev/null' }, + }); + const [stdout, stderr, exitCode] = await Promise.all([ + new Response(proc.stdout).text(), + new Response(proc.stderr).text(), + proc.exited, + ]); + if (exitCode !== 0) throw new Error(`git ${args.join(' ')} failed: ${stderr.trim()}`); + return stdout.trim(); +} + +/** + * Create a temporary git checkout with an `origin` remote and one commit. + * + * @param owner repository owner segment of the `origin` URL + * @param repo repository name segment of the `origin` URL + */ +export async function createCheckout(owner = 'mbeacom', repo = 'adrkit-phase-e-fixture'): Promise { + const root = await mkdtemp(join(tmpdir(), 'adrkit-catalog-')); + const remoteUrl = `https://github.com/${owner}/${repo}.git`; + + await git(['init', '-q', '-b', 'main'], root); + await git(['remote', 'add', 'origin', remoteUrl], root); + await git( + [ + '-c', + 'user.email=fixture@example.invalid', + '-c', + 'user.name=fixture', + 'commit', + '--allow-empty', + '-q', + '-m', + 'fixture', + ], + root, + ); + + const revision = await git(['rev-parse', 'HEAD'], root); + + return { + root, + remoteUrl, + repositoryId: `github.com/${owner}/${repo}`.toLowerCase(), + revision, + dispose: async () => { + await rm(root, { recursive: true, force: true }); + }, + }; +} + +/** Write a file into the checkout and return the manifest source entry describing it. */ +export async function writeSource( + checkout: Checkout, + relativePath: string, + text: string, +): Promise { + const absolute = join(checkout.root, relativePath); + await mkdir(dirname(absolute), { recursive: true }); + await writeFile(absolute, text, 'utf8'); + return { + path: relativePath, + digestAlgorithm: 'sha256', + digest: sha256Hex(new TextEncoder().encode(text)), + }; +} + +/** Fields a fixture may override on the manifest it writes. */ +export interface ManifestOverrides { + readonly manifestSchemaVersion?: string; + readonly requestedSnapshotSchemaVersion?: string; + readonly requiredCapabilities?: readonly string[]; + readonly repository?: { readonly id?: string; readonly revision?: string }; + /** Replaces the whole serialized manifest. For malformed-input cases. */ + readonly rawText?: string; + /** Merged into the manifest object before serialization. For unrecognized fields. */ + readonly extra?: Readonly>; +} + +/** Write a manifest naming `sources`, and return its path. */ +export async function writeManifest( + checkout: Checkout, + sources: readonly ManifestSource[], + overrides: ManifestOverrides = {}, + fileName = 'input-manifest.json', +): Promise { + const path = join(checkout.root, fileName); + + if (overrides.rawText !== undefined) { + await writeFile(path, overrides.rawText, 'utf8'); + return path; + } + + const manifest = { + manifestSchemaVersion: overrides.manifestSchemaVersion ?? '1', + requestedSnapshotSchemaVersion: overrides.requestedSnapshotSchemaVersion ?? '1', + requiredCapabilities: overrides.requiredCapabilities ?? ['pathOwnership'], + repository: { + id: overrides.repository?.id ?? checkout.repositoryId, + revision: overrides.repository?.revision ?? checkout.revision, + }, + sources, + ...overrides.extra, + }; + + await writeFile(path, JSON.stringify(manifest, null, 2), 'utf8'); + return path; +} + +/** A YAML descriptor document's authored fields. */ +export interface DescriptorSpec { + readonly apiVersion?: string | undefined; + readonly kind?: string | undefined; + readonly name?: string | undefined; + readonly namespace?: string | undefined; + /** The raw `adrkit.io/owned-paths` annotation line's value, verbatim. */ + readonly ownedPaths?: string | undefined; + /** Extra YAML appended verbatim, for duplicate keys and syntax faults. */ + readonly rawSuffix?: string | undefined; +} + +/** + * Render one descriptor document as YAML. + * + * The annotation value is emitted as a single-quoted scalar so a JSON array survives + * intact — `owned-paths-annotation.md` §1 step 2 requires a YAML **string scalar**, and + * an unquoted `["a/**"]` would parse as a sequence and be rejected at step 2, which is + * a different case from the one most fixtures want. + */ +export function descriptor(spec: DescriptorSpec): string { + const lines: string[] = []; + if (spec.apiVersion !== undefined) lines.push(`apiVersion: ${spec.apiVersion}`); + if (spec.kind !== undefined) lines.push(`kind: ${spec.kind}`); + lines.push('metadata:'); + if (spec.name !== undefined) lines.push(` name: ${spec.name}`); + if (spec.namespace !== undefined) lines.push(` namespace: ${spec.namespace}`); + if (spec.ownedPaths !== undefined) { + lines.push(' annotations:'); + lines.push(` adrkit.io/owned-paths: '${spec.ownedPaths.replaceAll("'", "''")}'`); + } + if (spec.rawSuffix !== undefined) lines.push(spec.rawSuffix); + return `${lines.join('\n')}\n`; +} + +/** A well-formed `Component` descriptor, as the baseline every fixture varies from. */ +export function validDescriptor(name: string, ownedPaths?: string): string { + return descriptor({ + apiVersion: 'backstage.io/v1alpha1', + kind: 'Component', + name, + ...(ownedPaths === undefined ? {} : { ownedPaths }), + }); +} + +/** Build a generation request over `sources`, declaring every one a maintainer overlay. */ +export function request( + checkout: Checkout, + manifestPath: string, + sources: readonly ManifestSource[], + provenance?: ProvenanceDeclaration, +): GenerationRequest { + return { + manifestPath, + checkoutRoot: checkout.root, + provenance: provenance ?? allMaintainerOverlay(sources.map((source) => source.path)), + }; +} + +/** + * Write sources and a manifest, and return a ready request. + * + * The common path for a fixture that only wants "a valid run over these documents". + */ +export async function stage( + checkout: Checkout, + files: Readonly>, + overrides: ManifestOverrides = {}, + manifestName?: string, +): Promise<{ readonly sources: readonly ManifestSource[]; readonly request: GenerationRequest }> { + const sources: ManifestSource[] = []; + for (const [path, text] of Object.entries(files)) { + sources.push(await writeSource(checkout, path, text)); + } + const manifestPath = await writeManifest(checkout, sources, overrides, manifestName); + return { sources, request: request(checkout, manifestPath, sources) }; +} diff --git a/packages/adapters/catalog-backstage/test/pipeline-stages.test.ts b/packages/adapters/catalog-backstage/test/pipeline-stages.test.ts new file mode 100644 index 00000000..43ad1e5f --- /dev/null +++ b/packages/adapters/catalog-backstage/test/pipeline-stages.test.ts @@ -0,0 +1,240 @@ +/** + * T069 — the assembled pipeline runs its stages in the fixed order, and the trace makes + * that observable. + * + * `tasks.md` T069 fixes the order: manifest → repository → digests → descriptor read → + * admissibility → canonicalization → ownership → glob → envelope. + * + * # Why the trace, rather than a comment + * + * A stage order asserted in prose is not checked. Every run records the stages it + * actually entered, so a reordering that happened to produce the same verdicts still + * fails these tests. The abort cases are the sharper half: they show that a rejection at + * stage *n* means stages *n+1* onwards were never entered, which is what + * `atomic-fail-closed.md` §6's "abort **before any entity's paths are derived**" + * actually requires. + * + * Each stage is recorded once, at first entry. `ownership` and `glob` are entered once + * per entity, and recording every visit would make the trace's length a function of the + * entity count rather than of the ordering it exists to show. + */ + +import { afterAll, beforeAll, describe, expect, test } from 'bun:test'; +import { + PIPELINE_STAGES, + STAGE_ADMISSIBILITY, + STAGE_CANONICALIZATION, + STAGE_DESCRIPTOR_READ, + STAGE_DIGESTS, + STAGE_ENVELOPE, + STAGE_GLOB, + STAGE_MANIFEST, + STAGE_OWNERSHIP, + STAGE_REPOSITORY, + runGeneration, +} from '../src/pipeline.ts'; +import { type Checkout, createCheckout, descriptor, stage, validDescriptor } from './pipeline-fixtures.ts'; + +let checkout: Checkout; + +beforeAll(async () => { + checkout = await createCheckout(); +}); + +afterAll(async () => { + await checkout.dispose(); +}); + +describe('T069 — the declared order', () => { + test('nine stages, in the order T069 fixes', () => { + expect([...PIPELINE_STAGES]).toEqual([ + 'manifest', + 'repository', + 'digests', + 'descriptor-read', + 'admissibility', + 'canonicalization', + 'ownership', + 'glob', + 'envelope', + ]); + expect(PIPELINE_STAGES).toHaveLength(9); + }); +}); + +describe('T069 — a successful run enters every stage, in order', () => { + test('an annotated entity reaches all nine', async () => { + const { request } = await stage( + checkout, + { 'all/catalog-info.yaml': validDescriptor('allstages', '["packages/all/**"]') }, + {}, + 'manifest-all.json', + ); + const outcome = await runGeneration(request); + expect(outcome.ok).toBe(true); + expect(outcome.stages).toEqual([...PIPELINE_STAGES]); + }); + + test('an unannotated entity never enters the glob stage', async () => { + // `owned-paths-annotation.md` §1: "Only after steps 1–4 succeed does each string + // element proceed" to the glob validator. An absent annotation has no elements, so + // step 5 is genuinely not reached — which the trace shows rather than assumes. + const { request } = await stage( + checkout, + { 'absent/catalog-info.yaml': validDescriptor('absentstages') }, + {}, + 'manifest-absent-stages.json', + ); + const outcome = await runGeneration(request); + expect(outcome.ok).toBe(true); + expect(outcome.stages).toEqual([ + STAGE_MANIFEST, + STAGE_REPOSITORY, + STAGE_DIGESTS, + STAGE_DESCRIPTOR_READ, + STAGE_ADMISSIBILITY, + STAGE_CANONICALIZATION, + STAGE_OWNERSHIP, + STAGE_ENVELOPE, + ]); + expect(outcome.stages).not.toContain(STAGE_GLOB); + }); + + test('an explicit-empty annotation also stops short of the glob stage', async () => { + const { request } = await stage( + checkout, + { 'empty/catalog-info.yaml': validDescriptor('emptystages', '[]') }, + {}, + 'manifest-empty-stages.json', + ); + const outcome = await runGeneration(request); + expect(outcome.ok).toBe(true); + expect(outcome.stages).not.toContain(STAGE_GLOB); + }); + + test('stages appear once each even across several entities', async () => { + const { request } = await stage( + checkout, + { + 'multi-a/catalog-info.yaml': validDescriptor('multistagea', '["packages/a/**"]'), + 'multi-b/catalog-info.yaml': validDescriptor('multistageb', '["packages/b/**"]'), + 'multi-c/catalog-info.yaml': validDescriptor('multistagec'), + }, + {}, + 'manifest-multi-stages.json', + ); + const outcome = await runGeneration(request); + expect(outcome.ok).toBe(true); + expect(outcome.stages).toEqual([...PIPELINE_STAGES]); + }); +}); + +describe('T069 — an abort at stage n never enters stage n+1', () => { + test('a manifest rejection stops at the first stage', async () => { + const { request } = await stage( + checkout, + { 'ms/catalog-info.yaml': validDescriptor('manifeststop') }, + { manifestSchemaVersion: '2' }, + 'manifest-version-stop.json', + ); + const outcome = await runGeneration(request); + expect(outcome.ok).toBe(false); + expect(outcome.stages).toEqual([STAGE_MANIFEST]); + }); + + test('a repository mismatch aborts before any descriptor is read', async () => { + // `atomic-fail-closed.md` §6: the four request-level rejections abort "before any + // entity's paths are derived". The trace is the evidence. + const { request } = await stage( + checkout, + { 'rm/catalog-info.yaml': validDescriptor('repostop', '["packages/rm/**"]') }, + { repository: { id: 'github.com/someone/else' } }, + 'manifest-repo-stop.json', + ); + const outcome = await runGeneration(request); + expect(outcome.ok).toBe(false); + expect(outcome.stages).toEqual([STAGE_MANIFEST, STAGE_REPOSITORY]); + expect(outcome.stages).not.toContain(STAGE_OWNERSHIP); + expect(outcome.stages).not.toContain(STAGE_GLOB); + }); + + test('an inadmissible descriptor aborts before canonicalization', async () => { + // ADR-0015's ordering rule, observed: the descriptor never acquires a canonical id, + // so it can never be reported under `duplicate-canonical-id` instead of its own + // class. + const { request } = await stage( + checkout, + { + 'bad/catalog-info.yaml': descriptor({ + apiVersion: 'backstage.io/v1alpha1', + kind: 'Component', + name: 'Not_Valid!', + }), + }, + {}, + 'manifest-admissibility-stop.json', + ); + const outcome = await runGeneration(request); + expect(outcome.ok).toBe(false); + if (outcome.ok) return; + + expect(outcome.failure.triggerClass).toBe('inadmissible-descriptor'); + expect(outcome.stages).toEqual([ + STAGE_MANIFEST, + STAGE_REPOSITORY, + STAGE_DIGESTS, + STAGE_DESCRIPTOR_READ, + STAGE_ADMISSIBILITY, + ]); + expect(outcome.stages).not.toContain(STAGE_CANONICALIZATION); + }); + + test('an invalid pattern aborts at the glob stage, having entered ownership first', async () => { + const { request } = await stage( + checkout, + { 'pat/catalog-info.yaml': validDescriptor('patternstop', '["packages/{a,b}/**"]') }, + {}, + 'manifest-pattern-stop.json', + ); + const outcome = await runGeneration(request); + expect(outcome.ok).toBe(false); + if (outcome.ok) return; + + expect(outcome.failure.stage).toBe(STAGE_GLOB); + expect(outcome.stages.at(-1)).toBe(STAGE_GLOB); + expect(outcome.stages).toContain(STAGE_OWNERSHIP); + expect(outcome.stages).not.toContain(STAGE_ENVELOPE); + }); + + test('an annotation decode rejection aborts at ownership, never reaching glob', async () => { + const { request } = await stage( + checkout, + { 'ann/catalog-info.yaml': validDescriptor('annotationstop', '{"paths":["a/**"]}') }, + {}, + 'manifest-annotation-stop.json', + ); + const outcome = await runGeneration(request); + expect(outcome.ok).toBe(false); + if (outcome.ok) return; + + expect(outcome.failure.stage).toBe(STAGE_OWNERSHIP); + expect(outcome.stages).not.toContain(STAGE_GLOB); + }); + + test('every recorded trace is a prefix-consistent subsequence of the declared order', () => { + // A structural property rather than a per-case one: whatever stages a run entered, + // they appear in the declared relative order and never repeat out of sequence. + const declared = [...PIPELINE_STAGES]; + const traces = [ + [STAGE_MANIFEST], + [STAGE_MANIFEST, STAGE_REPOSITORY], + [STAGE_MANIFEST, STAGE_REPOSITORY, STAGE_DIGESTS, STAGE_DESCRIPTOR_READ, STAGE_ADMISSIBILITY], + declared, + ]; + for (const trace of traces) { + const positions = trace.map((entered) => (declared as readonly string[]).indexOf(entered)); + expect(positions.every((position) => position >= 0)).toBe(true); + expect([...positions].sort((a, b) => a - b)).toEqual(positions); + } + }); +}); diff --git a/packages/adapters/catalog-backstage/test/sc-001-determinism.test.ts b/packages/adapters/catalog-backstage/test/sc-001-determinism.test.ts new file mode 100644 index 00000000..991c4206 --- /dev/null +++ b/packages/adapters/catalog-backstage/test/sc-001-determinism.test.ts @@ -0,0 +1,202 @@ +/** + * T084 / SC-001 — determinism across **at least three** runs, on the **accept path and + * the reject path alike**. + * + * SC-001: "Running the generator three or more times over identical inputs produces + * byte-identical output on every run, including every array's ordering — on the accept + * path and on the reject path alike." + * + * # The reject path is half the criterion, not an afterthought + * + * A deterministic rejection matters for the same reason a deterministic envelope does: + * ADR-0016 records the **exact emitted string** as evidence, and a reason string that + * varies between runs is not evidence of anything. A rejection is deterministic when the + * trigger class, the reason, the detail, the stage, and the location are all identical + * across runs — including for inputs violating **several** rules at once, where a + * non-deterministic implementation would report whichever the iteration order surfaced. + * + * # Three is a floor, not the number + * + * {@link RUNS} is 5. SC-001 says "three or more". + */ + +import { afterAll, beforeAll, describe, expect, test } from 'bun:test'; +import { serializeEnvelope } from '../src/envelope/write.ts'; +import { runGeneration } from '../src/pipeline.ts'; +import type { GenerationRequest } from '../src/pipeline.ts'; +import { type Checkout, createCheckout, descriptor, stage, validDescriptor } from './pipeline-fixtures.ts'; + +/** SC-001 requires three or more. */ +const RUNS = 5; + +let checkout: Checkout; + +beforeAll(async () => { + checkout = await createCheckout(); +}); + +afterAll(async () => { + await checkout.dispose(); +}); + +async function repeat(times: number, run: () => Promise): Promise { + const results: T[] = []; + for (let index = 0; index < times; index += 1) results.push(await run()); + return results; +} + +describe('T084 / SC-001 — the accept path', () => { + let request: GenerationRequest; + + beforeAll(async () => { + const multi = `${descriptor({ + apiVersion: 'backstage.io/v1alpha1', + kind: 'Component', + name: 'zulu', + ownedPaths: '["zeta/**","alpha/**","alpha/**","middle/**"]', + })}---\n${descriptor({ + apiVersion: 'backstage.io/v1alpha1', + kind: 'API', + name: 'yankee', + namespace: 'payments', + ownedPaths: '[]', + })}`; + + ({ request } = await stage( + checkout, + { + 'zzz/catalog-info.yaml': validDescriptor('zzzlast', '["shared/**","packages/z/**"]'), + 'aaa/catalog-info.yaml': multi, + 'mmm/catalog-info.yaml': validDescriptor('mmmnone'), + }, + {}, + 'sc001-accept.json', + )); + }); + + test(`${RUNS} runs all succeed`, async () => { + const outcomes = await repeat(RUNS, () => runGeneration(request)); + expect(outcomes.every((outcome) => outcome.ok)).toBe(true); + }); + + test(`${RUNS} runs serialize byte-identically`, async () => { + const outcomes = await repeat(RUNS, () => runGeneration(request)); + const serialized = outcomes.map((outcome) => (outcome.ok ? serializeEnvelope(outcome.envelope) : 'ABORTED')); + + expect(new Set(serialized).size).toBe(1); + expect(serialized[0]).not.toBe('ABORTED'); + }); + + test('every array\u2019s ordering is identical across runs', async () => { + const outcomes = await repeat(RUNS, () => runGeneration(request)); + const arrays = outcomes.map((outcome) => + outcome.ok + ? JSON.stringify({ + capabilities: outcome.envelope.capabilities, + sources: outcome.envelope.sources.map((source) => source.path), + entities: outcome.envelope.entities.map((entity) => entity.identity.canonicalId), + derivedPaths: outcome.envelope.entities.map((entity) => entity.derivedPaths), + allRefs: outcome.envelope.entities.map((entity) => entity.identity.allRefs), + }) + : 'ABORTED', + ); + expect(new Set(arrays).size).toBe(1); + }); + + test('the digest is identical across runs', async () => { + const outcomes = await repeat(RUNS, () => runGeneration(request)); + const digests = outcomes.map((outcome) => (outcome.ok ? outcome.envelope.digest : 'ABORTED')); + expect(new Set(digests).size).toBe(1); + }); +}); + +describe('T084 / SC-001 — the reject path', () => { + /** Every rejection fixture, each violating a different rule. */ + const REJECTIONS: readonly { readonly label: string; readonly files: Record }[] = [ + { + label: 'a duplicate canonical id', + files: { + 'rej-dup-a/catalog-info.yaml': validDescriptor('rejdup'), + 'rej-dup-b/catalog-info.yaml': validDescriptor('rejdup'), + }, + }, + { + label: 'an inadmissible descriptor', + files: { + 'rej-adm/catalog-info.yaml': descriptor({ + apiVersion: 'backstage.io/v1alpha1', + kind: 'Component', + name: 'Bad_Name!', + }), + }, + }, + { + label: 'a rejected pattern', + files: { 'rej-pat/catalog-info.yaml': validDescriptor('rejpat', '["a{b}/**"]') }, + }, + { + label: 'a repeated YAML key', + files: { 'rej-yaml/catalog-info.yaml': `${validDescriptor('rejyaml')}kind: API\n` }, + }, + ]; + + for (const [index, rejection] of REJECTIONS.entries()) { + test(`${RUNS} runs over ${rejection.label} reject identically`, async () => { + const { request } = await stage(checkout, rejection.files, {}, `sc001-reject-${index}.json`); + const outcomes = await repeat(RUNS, () => runGeneration(request)); + + expect(outcomes.every((outcome) => !outcome.ok)).toBe(true); + + // The whole failure record, not only the class: reason, detail, stage and + // location are all evidence, and any of them varying is non-determinism. + const records = outcomes.map((outcome) => (outcome.ok ? 'OK' : JSON.stringify(outcome.failure))); + expect(new Set(records).size).toBe(1); + expect(records[0]).not.toBe('OK'); + + const traces = outcomes.map((outcome) => JSON.stringify(outcome.stages)); + expect(new Set(traces).size).toBe(1); + }); + } + + test('an input violating several rules at once reports the same one every run', async () => { + // The sharpest reject-path case. A run that reported whichever violation its + // iteration order surfaced first would pass every single-violation test above. + const { request } = await stage( + checkout, + { + 'rej-many-a/catalog-info.yaml': descriptor({ + apiVersion: 'backstage.io/v1alpha1', + kind: 'Component', + name: 'Bad_Name!', + }), + 'rej-many-b/catalog-info.yaml': validDescriptor('rejmany', '["a{b}/**"]'), + 'rej-many-c/catalog-info.yaml': `${validDescriptor('rejmanyc')}kind: API\n`, + 'rej-many-d/catalog-info.yaml': validDescriptor('rejmany'), + }, + {}, + 'sc001-reject-many.json', + ); + + const outcomes = await repeat(RUNS, () => runGeneration(request)); + const records = outcomes.map((outcome) => (outcome.ok ? 'OK' : JSON.stringify(outcome.failure))); + expect(new Set(records).size).toBe(1); + expect(records[0]).not.toBe('OK'); + }); + + test('a rejecting run writes nothing on any of the runs', async () => { + const { request } = await stage( + checkout, + { + 'rej-none-a/catalog-info.yaml': validDescriptor('rejnone'), + 'rej-none-b/catalog-info.yaml': validDescriptor('rejnone'), + }, + {}, + 'sc001-reject-none.json', + ); + const outcomes = await repeat(RUNS, () => runGeneration(request)); + for (const outcome of outcomes) { + expect(outcome.ok).toBe(false); + expect(Object.hasOwn(outcome, 'envelope')).toBe(false); + } + }); +}); diff --git a/packages/adapters/catalog-backstage/test/sc-002-mixed-batch.test.ts b/packages/adapters/catalog-backstage/test/sc-002-mixed-batch.test.ts new file mode 100644 index 00000000..a53603f5 --- /dev/null +++ b/packages/adapters/catalog-backstage/test/sc-002-mixed-batch.test.ts @@ -0,0 +1,238 @@ +/** + * T077 / SC-002 — **whole-operation atomicity over a mixed batch**: a batch containing + * both valid and invalid entities produces no output at all. + * + * # Why this is a separate test from Phase D's per-rule ones + * + * `atomic-fail-closed.md` §2 is explicit: "Per-rule tests exercise each validation rule + * **in isolation** — one fixture, one violated rule at a time. This contract is tested + * **separately**: introduce **exactly one** invalid entity into an **otherwise-valid + * batch**, and confirm the whole run aborts... **Passing the per-rule tests does not + * demonstrate this contract.** The two properties MUST be tested independently." + * + * `plan.md` places this behind Barrier B under R4's *definition* even though R4's + * distinguishing test alone might not have, because running a mixed batch may compute + * in-memory ownership for the valid entities before aborting — and in-memory derivation + * is generator output. + * + * # Each case proves the batch really was otherwise valid + * + * Every case below runs the **same batch minus the one offender** first and asserts it + * produces a populated envelope. Without that, "no envelope was produced" would be + * equally consistent with a fixture that was broken for some unrelated reason, and the + * test would demonstrate nothing. + */ + +import { afterAll, beforeAll, describe, expect, test } from 'bun:test'; +import { mkdtemp, readdir, rm } from 'node:fs/promises'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import type { TriggerClass } from '../src/diagnostics.ts'; +import { generateAndWriteEnvelope, runGeneration } from '../src/pipeline.ts'; +import { type Checkout, createCheckout, descriptor, stage, validDescriptor } from './pipeline-fixtures.ts'; + +let checkout: Checkout; +let output: string; +let caseIndex = 0; + +beforeAll(async () => { + checkout = await createCheckout(); + output = await mkdtemp(join(tmpdir(), 'adrkit-sc002-')); +}); + +afterAll(async () => { + await checkout.dispose(); + await rm(output, { recursive: true, force: true }); +}); + +/** The five otherwise-valid entities `atomic-fail-closed.md` §3's worked example uses. */ +function fiveValid(prefix: string): Record { + const files: Record = {}; + for (const name of ['alpha', 'beta', 'gamma', 'delta', 'epsilon']) { + files[`${prefix}/${name}/catalog-info.yaml`] = validDescriptor( + `${prefix}${name}`, + `["packages/${name}/**"]`, + ); + } + return files; +} + +/** + * Run the five valid entities alone, then the same five plus `offender`. + * + * Returns both outcomes so a caller can assert on each. The control run is not + * optional: it is what makes the experimental run's failure attributable. + */ +async function mixedBatch( + prefix: string, + offenderPath: string, + offenderText: string, +): Promise<{ + readonly control: Awaited>; + readonly mixed: Awaited>; + readonly mixedRequest: Awaited>['request']; +}> { + caseIndex += 1; + const valid = fiveValid(prefix); + + const { request: controlRequest } = await stage(checkout, valid, {}, `sc002-control-${caseIndex}.json`); + const control = await runGeneration(controlRequest); + + const { request: mixedRequest } = await stage( + checkout, + { ...valid, [offenderPath]: offenderText }, + {}, + `sc002-mixed-${caseIndex}.json`, + ); + const mixed = await runGeneration(mixedRequest); + + return { control, mixed, mixedRequest }; +} + +/** Every case: one offender, one expected class, and §3's table applied to each. */ +const CASES: readonly { + readonly label: string; + readonly prefix: string; + readonly offenderPath: string; + readonly offenderText: string; + readonly triggerClass: TriggerClass; +}[] = [ + { + label: '§3\u2019s own worked example — a sixth entity with a duplicate canonical id', + prefix: 'dup', + offenderPath: 'dup/sixth/catalog-info.yaml', + offenderText: validDescriptor('dupalpha', '["packages/sixth/**"]'), + triggerClass: 'duplicate-canonical-id', + }, + { + label: '§5\u2019s variant — the sixth entity is inadmissible instead', + prefix: 'inadm', + offenderPath: 'inadm/sixth/catalog-info.yaml', + offenderText: descriptor({ + apiVersion: 'backstage.io/v1alpha1', + kind: 'Component', + name: 'Not_A_Valid_Name!', + }), + triggerClass: 'inadmissible-descriptor', + }, + { + label: 'the sixth entity declares a pattern the frozen dialect rejects', + prefix: 'pat', + offenderPath: 'pat/sixth/catalog-info.yaml', + offenderText: validDescriptor('patsixth', '["packages/{a,b}/**"]'), + triggerClass: 'invalid-pattern', + }, + { + label: 'the sixth entity repeats a YAML mapping key', + prefix: 'yaml', + offenderPath: 'yaml/sixth/catalog-info.yaml', + offenderText: `${validDescriptor('yamlsixth')}kind: API\n`, + triggerClass: 'duplicate-yaml-key', + }, + { + label: 'the sixth entity\u2019s annotation is not valid JSON', + prefix: 'ann', + offenderPath: 'ann/sixth/catalog-info.yaml', + offenderText: validDescriptor('annsixth', '["packages/a/**"'), + triggerClass: 'invalid-annotation-parse', + }, +]; + +describe('T077 / SC-002 — one invalid entity aborts the whole run', () => { + for (const testCase of CASES) { + describe(testCase.label, () => { + test('the five without the offender produce a populated envelope', async () => { + const { control } = await mixedBatch(testCase.prefix, testCase.offenderPath, testCase.offenderText); + expect(control.ok).toBe(true); + if (!control.ok) return; + expect(control.envelope.entities).toHaveLength(5); + }); + + test(`the same five plus the offender abort with ${testCase.triggerClass}`, async () => { + const { mixed } = await mixedBatch(testCase.prefix, testCase.offenderPath, testCase.offenderText); + expect(mixed.ok).toBe(false); + if (mixed.ok) return; + expect(mixed.failure.triggerClass).toBe(testCase.triggerClass); + }); + + test('no envelope exists — not even one covering the five that would have validated', async () => { + const { mixed, mixedRequest } = await mixedBatch( + testCase.prefix, + testCase.offenderPath, + testCase.offenderText, + ); + expect(mixed.ok).toBe(false); + + const directory = join(output, `${testCase.prefix}-no-output`); + const written = await generateAndWriteEnvelope(mixedRequest, join(directory, 'envelope.json')); + expect(written.ok).toBe(false); + + // `readdir` on a directory that was never created throws. That is the + // assertion: not "the file is empty", but "nothing was produced at all". + await expect(readdir(directory)).rejects.toThrow(); + }); + }); + } +}); + +describe('T077 / §1 — "skip the bad entity and keep going" is not what happens', () => { + test('the abort is not a filtered result with five entities in it', async () => { + // §1 names this as "the single most likely implementation mistake this contract + // exists to foreclose". The check is that the failure branch has no entity list at + // all, not that the list is a particular length. + const { mixed } = await mixedBatch( + 'skip', + 'skip/sixth/catalog-info.yaml', + validDescriptor('skipalpha'), + ); + expect(mixed.ok).toBe(false); + if (mixed.ok) return; + expect(Object.hasOwn(mixed, 'envelope')).toBe(false); + }); + + test('the consequence does not vary by which trigger fired', async () => { + // §4.2: the abort "applies identically regardless of which named trigger, or the + // backstop, fired". Checked across every case above rather than on one. The + // fixtures are re-staged unchanged — an earlier version re-prefixed them, which + // silently made the duplicate-id offender stop duplicating anything. + for (const testCase of CASES) { + const { mixed } = await mixedBatch(testCase.prefix, testCase.offenderPath, testCase.offenderText); + expect(mixed.ok).toBe(false); + if (mixed.ok) continue; + expect(Object.hasOwn(mixed, 'envelope')).toBe(false); + expect(mixed.failure.triggerClass).toBe(testCase.triggerClass); + } + }); + + test('an offender placed first aborts exactly as one placed last does', async () => { + // Order-independence: the abort is a property of the batch, not of where the + // offender happens to sit in it. + caseIndex += 1; + const offender = descriptor({ + apiVersion: 'backstage.io/v1alpha1', + kind: 'Component', + name: 'Bad_Name!', + }); + + const { request: first } = await stage( + checkout, + { 'order/aaa-first/catalog-info.yaml': offender, ...fiveValid('order-a') }, + {}, + `sc002-order-first-${caseIndex}.json`, + ); + const { request: last } = await stage( + checkout, + { ...fiveValid('order-b'), 'order/zzz-last/catalog-info.yaml': offender }, + {}, + `sc002-order-last-${caseIndex}.json`, + ); + + const firstOutcome = await runGeneration(first); + const lastOutcome = await runGeneration(last); + + expect(firstOutcome.ok).toBe(false); + expect(lastOutcome.ok).toBe(false); + if (firstOutcome.ok || lastOutcome.ok) return; + expect(firstOutcome.failure.triggerClass).toBe(lastOutcome.failure.triggerClass); + }); +}); diff --git a/packages/adapters/catalog-backstage/test/sc-003-all-triggers.test.ts b/packages/adapters/catalog-backstage/test/sc-003-all-triggers.test.ts new file mode 100644 index 00000000..409a1410 --- /dev/null +++ b/packages/adapters/catalog-backstage/test/sc-003-all-triggers.test.ts @@ -0,0 +1,420 @@ +/** + * T078 / SC-003 — **all fifteen** trigger classes driven through the assembled + * pipeline, each recorded with its own exact reason string, each failing input retained + * permanently. + * + * # Fifteen, not fourteen + * + * `contracts/atomic-fail-closed.md` §4 — "Closed Type of **Fifteen** Values". + * `data-model.md` §8 lists the same fifteen. Spike 009's `atomic-fail-closed.md` §4 + * says fourteen, which is correct about spike 009 and wrong here (FR-035). + * + * # Fourteen through the full pipeline; one at a stage kernel, and why + * + * Fourteen classes are reached by handing the assembled pipeline a real manifest and + * real descriptor files on disk. + * + * **`duplicate-canonical-ref` is not among them, and this is the + * `[NEEDS CLARIFICATION]` T078 carries forward from T071 rather than a gap in the + * fixtures.** `identity/canonicalize.ts` populates `allRefs` as `[canonicalId]` and + * nothing else, because `data-model.md` §5 records how `allRefs` is populated beyond the + * primary id as undecided, and `entity-identity.md` §2 states that alias refs come + * "directly by a synthetic fixture's own construction" with "no real-corpus entity from + * `community-plugins` or `rhdh-plugins`" ever carrying one. With `allRefs` holding only + * the canonical id, two descriptors that canonicalize alike collide as + * `duplicate-canonical-id`; **no descriptor-sourced input can reach + * `duplicate-canonical-ref` at all.** + * + * So that one class is exercised at the **canonicalization stage's own uniqueness + * kernel**, with a **synthetic** identity set. The kernel is the assembled pipeline's + * code; only the input is synthetic. That is stated plainly here rather than presenting + * a synthetic case as a corpus-derived one, which is exactly what T078's note asks for. + * + * Adding an alias input to the generation request to force the class through would + * invent the production alias mechanism `entity-identity.md` §2 calls "an explicitly + * separate, later, out-of-scope design decision". It was considered and rejected. + * + * # The permanent record + * + * Every failing input below is retained at + * `specs/010-catalog-backstage/evidence/negative-cases/triggers/`, together with the + * exact reason string this run observed. {@link OBSERVED_TRIGGERS} is exported so the + * evidence file's own table can be checked against what the pipeline actually emits + * rather than against a transcription. + */ + +import { afterAll, beforeAll, describe, expect, test } from 'bun:test'; +import { TRIGGER_CLASSES } from '../src/diagnostics.ts'; +import type { TriggerClass } from '../src/diagnostics.ts'; +import { checkGlobalUniqueness } from '../src/identity/uniqueness.ts'; +import { runGeneration } from '../src/pipeline.ts'; +import type { AtomicFailureRecord } from '../src/failure/abort.ts'; +import { + type Checkout, + createCheckout, + descriptor, + request, + stage, + validDescriptor, + writeManifest, + writeSource, +} from './pipeline-fixtures.ts'; +import { allMaintainerOverlay } from '../src/envelope/provenance.ts'; + +let checkout: Checkout; + +/** Every class observed, with the reason and detail the pipeline actually emitted. */ +export const OBSERVED_TRIGGERS = new Map(); + +beforeAll(async () => { + checkout = await createCheckout(); +}); + +afterAll(async () => { + await checkout.dispose(); +}); + +/** Stage a case and assert the assembled pipeline aborts with `expected`. */ +async function pipelineCase( + name: string, + expected: TriggerClass, + expectedReason: string, + build: () => Promise>['request']>, +): Promise { + const outcome = await runGeneration(await build()); + + if (outcome.ok) { + throw new Error(`${name}: expected an abort with ${expected}, but generation succeeded`); + } + + expect(outcome.failure.triggerClass).toBe(expected); + expect(outcome.failure.reason).toBe(expectedReason); + // Whole-operation: no envelope on the failure branch, whichever class fired. + expect(Object.hasOwn(outcome, 'envelope')).toBe(false); + + OBSERVED_TRIGGERS.set(expected, outcome.failure); + return outcome.failure; +} + +describe('T078 — the closed enumeration is fifteen', () => { + test('fifteen classes, counted from the declaration', () => { + expect(TRIGGER_CLASSES).toHaveLength(15); + }); +}); + +describe('T078 — the four manifest-request-level classes', () => { + test('invalid-manifest-shape — an unrecognized top-level field', async () => { + await pipelineCase('invalid-manifest-shape', 'invalid-manifest-shape', 'unrecognized-top-level-field', async () => { + const { request: staged } = await stage( + checkout, + { 'shape/catalog-info.yaml': validDescriptor('shapecase') }, + { extra: { unexpectedField: true } }, + 'trigger-manifest-shape.json', + ); + return staged; + }); + }); + + test('unsupported-manifest-version', async () => { + await pipelineCase( + 'unsupported-manifest-version', + 'unsupported-manifest-version', + 'unsupported-manifest-version', + async () => { + const { request: staged } = await stage( + checkout, + { 'mv/catalog-info.yaml': validDescriptor('mvcase') }, + { manifestSchemaVersion: '2' }, + 'trigger-manifest-version.json', + ); + return staged; + }, + ); + }); + + test('unsupported-snapshot-version', async () => { + await pipelineCase( + 'unsupported-snapshot-version', + 'unsupported-snapshot-version', + 'unsupported-snapshot-version', + async () => { + const { request: staged } = await stage( + checkout, + { 'sv/catalog-info.yaml': validDescriptor('svcase') }, + { requestedSnapshotSchemaVersion: '2' }, + 'trigger-snapshot-version.json', + ); + return staged; + }, + ); + }); + + test('unsupported-capability', async () => { + await pipelineCase('unsupported-capability', 'unsupported-capability', 'unsupported-capability', async () => { + const { request: staged } = await stage( + checkout, + { 'cap/catalog-info.yaml': validDescriptor('capcase') }, + { requiredCapabilities: ['pathOwnership', 'somethingUndefined'] }, + 'trigger-capability.json', + ); + return staged; + }); + }); + + test('incomplete-required-source — a listed source absent from the checkout', async () => { + await pipelineCase('incomplete-required-source', 'incomplete-required-source', 'source-missing', async () => { + const present = await writeSource( + checkout, + 'src-present/catalog-info.yaml', + validDescriptor('srcpresent'), + ); + const absent = { + path: 'src-absent/catalog-info.yaml', + digestAlgorithm: 'sha256' as const, + digest: 'a'.repeat(64), + }; + const sources = [present, absent]; + const manifestPath = await writeManifest(checkout, sources, {}, 'trigger-source-missing.json'); + return request(checkout, manifestPath, sources); + }); + }); +}); + +describe('T078 — repository identity', () => { + test('repository-mismatch', async () => { + await pipelineCase('repository-mismatch', 'repository-mismatch', 'repository-mismatch', async () => { + const { request: staged } = await stage( + checkout, + { 'repo/catalog-info.yaml': validDescriptor('repocase') }, + { repository: { id: 'github.com/someone/entirely-else' } }, + 'trigger-repository.json', + ); + return staged; + }); + }); +}); + +describe('T078 — descriptor parse: the pair §4.3 says must not collapse', () => { + test('duplicate-yaml-key', async () => { + await pipelineCase('duplicate-yaml-key', 'duplicate-yaml-key', 'duplicate-yaml-key', async () => { + const { request: staged } = await stage( + checkout, + { 'dupkey/catalog-info.yaml': `${validDescriptor('dupkeycase')}kind: API\n` }, + {}, + 'trigger-duplicate-key.json', + ); + return staged; + }); + }); + + test('invalid-yaml-syntax', async () => { + await pipelineCase('invalid-yaml-syntax', 'invalid-yaml-syntax', 'invalid-yaml-syntax', async () => { + const { request: staged } = await stage( + checkout, + { 'badyaml/catalog-info.yaml': 'apiVersion: backstage.io/v1alpha1\nkind: [Component\n' }, + {}, + 'trigger-yaml-syntax.json', + ); + return staged; + }); + }); +}); + +describe('T078 — admissibility, the class ADR-0015 adds', () => { + test('inadmissible-descriptor', async () => { + const failure = await pipelineCase( + 'inadmissible-descriptor', + 'inadmissible-descriptor', + 'inadmissible-descriptor', + async () => { + const { request: staged } = await stage( + checkout, + { + 'inadm/catalog-info.yaml': descriptor({ + apiVersion: 'backstage.io/v1alpha1', + kind: 'Component', + name: 'Not_A_Valid_Name!', + }), + }, + {}, + 'trigger-inadmissible.json', + ); + return staged; + }, + ); + + // FR-020's three attributions travel with the record. + expect(failure.detail).toContain('inadm/catalog-info.yaml'); + expect(failure.detail).toContain('validateEntityName'); + expect(failure.detail).toContain('1121a4facd9e321179d0402c3f355e4a649e84d9'); + }); +}); + +describe('T078 — identity uniqueness', () => { + test('duplicate-canonical-id', async () => { + await pipelineCase('duplicate-canonical-id', 'duplicate-canonical-id', 'duplicate-canonical-id', async () => { + const { request: staged } = await stage( + checkout, + { + 'dup-a/catalog-info.yaml': validDescriptor('collide'), + 'dup-b/catalog-info.yaml': validDescriptor('COLLIDE'.toLowerCase()), + }, + {}, + 'trigger-duplicate-id.json', + ); + return staged; + }); + }); + + test('duplicate-canonical-ref — at the stage kernel, on a synthetic identity set', () => { + // NOT reachable from descriptor input. See this file's module note: `allRefs` holds + // only the canonical id, so two descriptors that canonicalize alike collide as + // `duplicate-canonical-id` above. This drives the canonicalization stage's own + // uniqueness kernel with a synthetic set, and is recorded as synthetic rather than + // presented as corpus-derived. + const outcome = checkGlobalUniqueness([ + { canonicalId: 'component:default/billing', allRefs: ['component:default/billing', 'component:default/billing-legacy'] }, + { canonicalId: 'component:default/billing-legacy', allRefs: ['component:default/billing-legacy'] }, + ]); + + expect(outcome.ok).toBe(false); + if (outcome.ok) return; + expect(outcome.rejection.triggerClass).toBe('duplicate-canonical-ref'); + expect(outcome.rejection.reason).toBe('duplicate-canonical-ref'); + + OBSERVED_TRIGGERS.set('duplicate-canonical-ref', { + triggerClass: 'duplicate-canonical-ref', + reason: outcome.rejection.reason, + detail: outcome.rejection.detail, + sourcePath: undefined, + documentIndex: undefined, + stage: 'canonicalization', + }); + }); +}); + +describe('T078 — annotation decode and the frozen glob dialect', () => { + test('invalid-annotation-parse — the annotation is not valid JSON', async () => { + await pipelineCase('invalid-annotation-parse', 'invalid-annotation-parse', 'parse-error', async () => { + const { request: staged } = await stage( + checkout, + { 'annparse/catalog-info.yaml': validDescriptor('annparsecase', '["packages/a/**"') }, + {}, + 'trigger-annotation-parse.json', + ); + return staged; + }); + }); + + test('invalid-annotation-shape — the annotation decodes to an object', async () => { + await pipelineCase('invalid-annotation-shape', 'invalid-annotation-shape', 'wrong-shape', async () => { + const { request: staged } = await stage( + checkout, + { 'annshape/catalog-info.yaml': validDescriptor('annshapecase', '{"paths":["packages/a/**"]}') }, + {}, + 'trigger-annotation-shape.json', + ); + return staged; + }); + }); + + test('invalid-pattern — a brace, which the dialect rejects at rule 6', async () => { + await pipelineCase('invalid-pattern', 'invalid-pattern', 'invalid-pattern', async () => { + const { request: staged } = await stage( + checkout, + { 'pattern/catalog-info.yaml': validDescriptor('patterncase', '["packages/{a,b}/**"]') }, + {}, + 'trigger-pattern.json', + ); + return staged; + }); + }); +}); + +describe('T078 — the backstop, reached by a genuinely invalid request', () => { + test('other-invalid-input — an undeclared source provenance', async () => { + // Not contrived: FR-043 makes the declaration load-bearing, `data-model.md` §1's + // closed manifest schema has no field to carry it, and none of the fourteen named + // classes describes a defect in it. `atomic-fail-closed.md` §4.2's case exactly. + await pipelineCase( + 'other-invalid-input', + 'other-invalid-input', + 'provenance-declaration-missing', + async () => { + const declared = await writeSource( + checkout, + 'prov-a/catalog-info.yaml', + validDescriptor('provdeclared'), + ); + const undeclared = await writeSource( + checkout, + 'prov-b/catalog-info.yaml', + validDescriptor('provundeclared'), + ); + const sources = [declared, undeclared]; + const manifestPath = await writeManifest(checkout, sources, {}, 'trigger-provenance.json'); + return request(checkout, manifestPath, sources, allMaintainerOverlay([declared.path])); + }, + ); + }); + + test('invalid-manifest-shape — a manifest that is not there', async () => { + // A second route to `invalid-manifest-shape`, recorded because it is the one an + // operator hits first and its reason differs from a parse failure's. Grouped in + // this block because it is the case most likely to be mistaken for the backstop's. + const outcome = await runGeneration({ + manifestPath: `${checkout.root}/no-such-manifest.json`, + checkoutRoot: checkout.root, + provenance: { bySourcePath: {} }, + }); + expect(outcome.ok).toBe(false); + if (outcome.ok) return; + expect(outcome.failure.triggerClass).toBe('invalid-manifest-shape'); + expect(outcome.failure.reason).toBe('manifest-unreadable'); + }); +}); + +describe('T078 / SC-003 — every one of the fifteen was observed', () => { + test('all fifteen classes appear in the observed set', () => { + const observed = [...OBSERVED_TRIGGERS.keys()].sort(); + const expected = [...TRIGGER_CLASSES].sort(); + expect(observed).toEqual(expected); + expect(observed).toHaveLength(15); + + // The permanent record's table is generated from this, never transcribed. Run + // `ADRKIT_PRINT_TRIGGERS=1 bun test test/sc-003-all-triggers.test.ts` to reproduce + // `evidence/negative-cases/triggers/observed-reasons.md`'s table from a live run. + if (process.env['ADRKIT_PRINT_TRIGGERS'] !== undefined) { + const rows = [...OBSERVED_TRIGGERS.entries()] + .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)) + .map(([trigger, record]) => { + // Newlines collapsed and pipes escaped: the `yaml` library's messages carry + // both, and either would break the emitted table. + const detail = record.detail.replaceAll(/\s+/gu, ' ').replaceAll('|', '\\|').trim(); + return `| \`${trigger}\` | \`${record.reason}\` | ${record.stage} | ${detail} |`; + }); + console.log(['| Trigger class | Reason | Stage | Observed detail |', '|---|---|---|---|', ...rows].join('\n')); + } + }); + + test('each observation carries its own distinct reason string', () => { + const reasons = [...OBSERVED_TRIGGERS.values()].map((record) => record.reason); + expect(new Set(reasons).size).toBe(reasons.length); + }); + + test('every observation carries a non-empty detail', () => { + for (const [trigger, record] of OBSERVED_TRIGGERS) { + expect(record.detail.length).toBeGreaterThan(0); + expect(record.triggerClass).toBe(trigger); + } + }); + + test('exactly one of the fifteen was reached other than through the full pipeline', () => { + // The honesty assertion. If a future change makes `duplicate-canonical-ref` + // descriptor-reachable, this test fails and the module note above must be revised + // rather than the count quietly changing. + const record = OBSERVED_TRIGGERS.get('duplicate-canonical-ref'); + expect(record?.stage).toBe('canonicalization'); + expect(record?.sourcePath).toBeUndefined(); + }); +}); diff --git a/packages/adapters/catalog-backstage/test/sc-009.test.ts b/packages/adapters/catalog-backstage/test/sc-009.test.ts new file mode 100644 index 00000000..510f2aba --- /dev/null +++ b/packages/adapters/catalog-backstage/test/sc-009.test.ts @@ -0,0 +1,314 @@ +/** + * T086 / SC-009 — close-out for the **rescoped** criterion (spike 009's SC-010, + * rescoped by ADR-0020 clause 3). + * + * SC-009 has three limbs: + * + * 1. Each required pass produces **either** a populated `SnapshotEnvelope` **or** a + * deterministic, atomic, correctly-classified fail-closed rejection with no partial + * output. + * 2. **At least one pass over a real corpus meeting ADR-0020 clause 5's conditions + * produces a populated envelope.** + * 3. A correct rejection of a defective corpus satisfies the criterion; **fabricating an + * envelope from one never does.** + * + * # Limb 2 is NOT discharged by this file, and that is recorded rather than glossed + * + * Clause 5's conditions require a corpus of **real descriptors authored upstream and + * otherwise unmodified**, with maintainer-authored annotations overlaid. The frozen + * accept corpus is 24 entity documents from `github.com/backstage/community-plugins` at + * 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. + * + * 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. + * + * T086 is therefore left unchecked in `tasks.md`, with this reason. + */ + +import { afterAll, beforeAll, describe, expect, test } from 'bun:test'; +import { mkdtemp, readdir, rm, writeFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { readdirSync } from 'node:fs'; +import { computeEnvelopeDigest, verifyEnvelopeDigest } from '../src/envelope/digest.ts'; +import type { SnapshotEnvelope } from '../src/envelope/shape.ts'; +import { serializeEnvelope } from '../src/envelope/write.ts'; +import { generateAndWriteEnvelope, runGeneration } from '../src/pipeline.ts'; +import { REPO_ROOT } from './source-scan.ts'; +import { type Checkout, createCheckout, descriptor, stage, validDescriptor } from './pipeline-fixtures.ts'; + +let checkout: Checkout; +let output: string; + +beforeAll(async () => { + checkout = await createCheckout(); + output = await mkdtemp(join(tmpdir(), 'adrkit-sc009-')); +}); + +afterAll(async () => { + await checkout.dispose(); + await rm(output, { recursive: true, force: true }); +}); + +/** + * Whether the pinned accept corpus's descriptor files exist anywhere in this repository. + * + * Looks for a vendored corpus directory rather than for the freeze metadata, which is + * committed and is not the corpus. + */ +function acceptCorpusIsMaterialized(): boolean { + const candidates = [ + join(REPO_ROOT, 'specs', '010-catalog-backstage', 'corpus'), + join(REPO_ROOT, 'specs', '010-catalog-backstage', 'evidence', 'corpus'), + join(REPO_ROOT, 'corpus'), + join(REPO_ROOT, '.corpus'), + ]; + return candidates.some((candidate) => { + try { + return readdirSync(candidate).length > 0; + } catch { + return false; + } + }); +} + +describe('T086 / SC-009 limb 1 — every pass yields an envelope or a clean rejection', () => { + test('an accept pass yields a populated envelope', async () => { + const { request } = await stage( + checkout, + { + 'sc009-a/catalog-info.yaml': validDescriptor('sconineone', '["packages/one/**"]'), + 'sc009-b/catalog-info.yaml': validDescriptor('sconinetwo', '["packages/two/**"]'), + }, + {}, + 'sc009-accept.json', + ); + + const outcome = await runGeneration(request); + expect(outcome.ok).toBe(true); + if (!outcome.ok) return; + + // Populated, not merely present: entities exist and carry derived ownership. + expect(outcome.envelope.entities.length).toBeGreaterThan(0); + expect( + outcome.envelope.entities.some((entity) => entity.derivedPaths.length > 0), + ).toBe(true); + expect(verifyEnvelopeDigest(outcome.envelope).outcome).toBe('match'); + }); + + test('a reject pass yields a rejection that is deterministic, atomic and classified', async () => { + const { request } = await stage( + checkout, + { + 'sc009-r1/catalog-info.yaml': validDescriptor('sconinedup'), + 'sc009-r2/catalog-info.yaml': validDescriptor('sconinedup'), + }, + {}, + 'sc009-reject.json', + ); + + const first = await runGeneration(request); + const second = await runGeneration(request); + + expect(first.ok).toBe(false); + expect(second.ok).toBe(false); + if (first.ok || second.ok) return; + + // Deterministic. + expect(JSON.stringify(second.failure)).toBe(JSON.stringify(first.failure)); + // Correctly classified. + expect(first.failure.triggerClass).toBe('duplicate-canonical-id'); + // Atomic, with no partial output. + expect(Object.hasOwn(first, 'envelope')).toBe(false); + }); + + test('there is no third outcome — every pass lands on one of the two', async () => { + const passes: readonly { readonly name: string; readonly files: Record }[] = [ + { name: 'sc009-e1.json', files: { 'e1/catalog-info.yaml': validDescriptor('eone', '["a/**"]') } }, + { name: 'sc009-e2.json', files: { 'e2/catalog-info.yaml': validDescriptor('etwo') } }, + { name: 'sc009-e3.json', files: { 'e3/catalog-info.yaml': validDescriptor('ethree', '[]') } }, + { + name: 'sc009-e4.json', + files: { + 'e4/catalog-info.yaml': descriptor({ + apiVersion: 'backstage.io/v1alpha1', + kind: 'Component', + name: 'Bad_Name!', + }), + }, + }, + { name: 'sc009-e5.json', files: { 'e5/catalog-info.yaml': validDescriptor('efive', '["a{b}/**"]') } }, + ]; + + for (const pass of passes) { + const { request } = await stage(checkout, pass.files, {}, pass.name); + const outcome = await runGeneration(request); + + if (outcome.ok) { + expect(verifyEnvelopeDigest(outcome.envelope).outcome).toBe('match'); + } else { + expect(outcome.failure.triggerClass.length).toBeGreaterThan(0); + expect(Object.hasOwn(outcome, 'envelope')).toBe(false); + } + } + }); + + test('a rejecting pass writes no file, so "either/or" is exclusive on disk too', async () => { + const { request } = await stage( + checkout, + { + 'sc009-x1/catalog-info.yaml': validDescriptor('sconinex'), + 'sc009-x2/catalog-info.yaml': validDescriptor('sconinex'), + }, + {}, + 'sc009-exclusive.json', + ); + const directory = join(output, 'exclusive'); + const result = await generateAndWriteEnvelope(request, join(directory, 'envelope.json')); + + expect(result.ok).toBe(false); + await expect(readdir(directory)).rejects.toThrow(); + }); +}); + +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); + }); + + 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. + const freeze = (await Bun.file( + join(REPO_ROOT, 'specs', '010-catalog-backstage', 'evidence', 'accept-corpus-freeze', 'accept-corpus-freeze.json'), + ).json()) as Record; + + expect((freeze['corpusRef'] as Record)['repository']).toBe( + 'github.com/backstage/community-plugins', + ); + expect(freeze['size']).toBe(24); + expect(Object.hasOwn(freeze, 'descriptors')).toBe(false); + expect(Object.hasOwn(freeze, 'sources')).toBe(false); + }); + + test('every fixture in this suite is 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. + const { request } = await stage( + checkout, + { 'sc009-prov/catalog-info.yaml': validDescriptor('sconineprov', '["packages/p/**"]') }, + {}, + 'sc009-provenance.json', + ); + const outcome = await runGeneration(request); + expect(outcome.ok).toBe(true); + if (!outcome.ok) return; + + for (const entity of outcome.envelope.entities) { + expect(entity.provenance).toBe('maintainer-overlay'); + } + }); +}); + +describe('T086 / SC-009 limb 3 — a fabricated or hand-edited envelope never satisfies it', () => { + let genuine: SnapshotEnvelope; + + beforeAll(async () => { + const { request } = await stage( + checkout, + { 'sc009-genuine/catalog-info.yaml': validDescriptor('sconinegenuine', '["packages/g/**"]') }, + {}, + 'sc009-genuine.json', + ); + const outcome = await runGeneration(request); + if (!outcome.ok) throw new Error('fixture failed to generate'); + genuine = outcome.envelope; + }); + + test('a hand-edited envelope fails digest verification', () => { + const edited: SnapshotEnvelope = { + ...genuine, + entities: genuine.entities.map((entity) => ({ ...entity, derivedPaths: ['fabricated/**'] })), + }; + expect(verifyEnvelopeDigest(edited).outcome).toBe('digest-mismatch'); + }); + + test('a wholly fabricated envelope fails digest verification', async () => { + const fabricated = { + schemaVersion: '1', + repository: { id: 'github.com/fabricated/repo', revision: 'f'.repeat(40) }, + generatorVersion: '@adrkit/catalog-backstage@0.0.0', + globDialect: { + engine: 'picomatch', + version: '4.0.5', + options: { dot: false as const, nocase: false as const, nonegate: true as const }, + }, + capabilities: ['pathOwnership'], + completeness: { wholeCatalog: false, identityOnly: false }, + sources: [{ path: 'catalog-info.yaml', digestAlgorithm: 'sha256' as const, digest: '0'.repeat(64) }], + entities: [ + { + identity: { canonicalId: 'component:default/invented', allRefs: ['component:default/invented'] }, + ownershipState: 'explicit-paths' as const, + derivedPaths: ['invented/**'], + sourceDocument: { sourcePath: 'catalog-info.yaml', documentIndexInFile: 0 }, + provenance: 'upstream-authored' as const, + }, + ], + digest: '0'.repeat(64), + }; + + const path = join(output, 'fabricated.json'); + await writeFile(path, `${JSON.stringify(fabricated)}\n`, 'utf8'); + expect(verifyEnvelopeDigest(fabricated).outcome).toBe('digest-mismatch'); + }); + + test('an edited-then-re-signed envelope passes its digest but is not what a pass produces', () => { + // The honest limit of the digest, and why SC-009 is about the **pass** rather than + // about an artifact. FR-041: the digest detects accidental corruption and naive + // mutation, never an adversary who recomputes it. What catches this is re-running + // the generator over the same input and comparing bytes — which is available + // precisely because FR-042 makes the output byte-identical. + const { digest: _old, ...unsigned } = genuine; + const edited = { + ...unsigned, + entities: unsigned.entities.map((entity) => ({ ...entity, derivedPaths: ['fabricated/**'] })), + }; + const resigned: SnapshotEnvelope = { ...edited, digest: computeEnvelopeDigest(edited) }; + + expect(verifyEnvelopeDigest(resigned).outcome).toBe('match'); + expect(serializeEnvelope(resigned)).not.toBe(serializeEnvelope(genuine)); + }); + + test('re-running the generator over the same input reproduces the genuine bytes exactly', async () => { + const { request } = await stage( + checkout, + { 'sc009-genuine/catalog-info.yaml': validDescriptor('sconinegenuine', '["packages/g/**"]') }, + {}, + 'sc009-genuine.json', + ); + const outcome = await runGeneration(request); + expect(outcome.ok).toBe(true); + if (!outcome.ok) return; + expect(serializeEnvelope(outcome.envelope)).toBe(serializeEnvelope(genuine)); + }); +}); diff --git a/packages/adapters/catalog-backstage/test/sc-013.test.ts b/packages/adapters/catalog-backstage/test/sc-013.test.ts new file mode 100644 index 00000000..eb80eeb4 --- /dev/null +++ b/packages/adapters/catalog-backstage/test/sc-013.test.ts @@ -0,0 +1,199 @@ +/** + * T085 / SC-013 — close-out: exactly one envelope is produced; each `entities[]` record + * carries exactly five fields; and the recorded digest matches an **independent** + * recomputation, not the generator's own. + * + * # What makes the recomputation independent + * + * `envelope/digest.ts`'s `verifyEnvelopeDigest` shares `canonicalStringify` with the + * code that produced the digest, so it cannot detect a fault in that shared step. It is + * useful for detecting corruption *after* generation and is not what SC-013 asks for. + * + * {@link recomputeIndependently} below implements the three steps of + * `snapshot-envelope.md` §3 directly — recursive key sort by code-unit order, arrays in + * declaration order, compact separators, `undefined` omitted, SHA-256 over the UTF-8 + * bytes, 64 lowercase hex — using `node:crypto` and a locally written canonicalizer. It + * shares no code with the generator beyond `compareCodeUnits`, which is a two-line + * comparator (`a < b ? -1 : a > b ? 1 : 0`) whose behaviour is fully specified by that + * expression. + * + * That residual sharing is stated rather than glossed: a truly zero-shared + * recomputation would need its own comparator, and re-deriving code-unit ordering by + * hand would introduce a second definition of "canonical" — the exact drift the digest + * exists to detect. The comparator is a frozen repository primitive + * (`packages/core/src/ordering/index.ts`), not part of the generator. + * + * # Integrity is not correctness + * + * SC-012 and FR-041 travel with every claim here. A digest match is evidence that the + * envelope's bytes are the bytes that were written. It is **not** evidence that the + * derived ownership in it is right — that is SC-011's question, which is Phase F's, not + * this test's. Nothing below claims otherwise. + */ + +import { afterAll, beforeAll, describe, expect, test } from 'bun:test'; +import { createHash } from 'node:crypto'; +import { mkdtemp, readdir, rm } from 'node:fs/promises'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { compareCodeUnits } from '@adrkit/core'; +import { ENTITY_RECORD_FIELDS, ENVELOPE_TOP_LEVEL_FIELDS } from '../src/envelope/shape.ts'; +import { generateAndWriteEnvelope } from '../src/pipeline.ts'; +import { type Checkout, createCheckout, descriptor, stage, validDescriptor } from './pipeline-fixtures.ts'; + +let checkout: Checkout; +let output: string; +let envelopePath: string; +let parsed: Record; + +beforeAll(async () => { + checkout = await createCheckout(); + output = await mkdtemp(join(tmpdir(), 'adrkit-sc013-')); + + const multi = `${descriptor({ + apiVersion: 'backstage.io/v1alpha1', + kind: 'Component', + name: 'sc013one', + ownedPaths: '["packages/one/**","apis/one/**"]', + })}---\n${descriptor({ + apiVersion: 'backstage.io/v1alpha1', + kind: 'API', + name: 'sc013two', + namespace: 'payments', + ownedPaths: '[]', + })}`; + + const { request } = await stage(checkout, { + 'sc013-a/catalog-info.yaml': multi, + 'sc013-b/catalog-info.yaml': validDescriptor('sc013three'), + }); + + envelopePath = join(output, 'sc013', 'envelope.json'); + const result = await generateAndWriteEnvelope(request, envelopePath); + if (!result.ok) throw new Error(`fixture failed to generate: ${result.failure.detail}`); + + parsed = JSON.parse(await Bun.file(envelopePath).text()) as Record; +}); + +afterAll(async () => { + await checkout.dispose(); + await rm(output, { recursive: true, force: true }); +}); + +/** + * `snapshot-envelope.md` §3's canonicalization, written here rather than imported. + * + * Deliberately a separate implementation. Importing the generator's would make the + * comparison a tautology. + */ +function canonicalize(value: unknown): string { + if (value === null || value === undefined) return 'null'; + if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') { + return JSON.stringify(value); + } + if (Array.isArray(value)) { + // Declaration order, never re-sorted (§3 step 2). + return `[${value.map((element) => canonicalize(element)).join(',')}]`; + } + const record = value as Record; + const keys = Object.keys(record) + .filter((key) => record[key] !== undefined) + .sort(compareCodeUnits); + return `{${keys.map((key) => `${JSON.stringify(key)}:${canonicalize(record[key])}`).join(',')}}`; +} + +/** §3's three steps, applied to a parsed envelope read off disk. */ +function recomputeIndependently(envelope: Record): string { + const { digest: _excluded, ...unsigned } = envelope; + return createHash('sha256').update(canonicalize(unsigned), 'utf8').digest('hex'); +} + +describe('T085 / SC-013 — exactly one envelope is produced', () => { + test('the destination directory holds exactly one file', async () => { + expect((await readdir(join(output, 'sc013'))).sort()).toEqual(['envelope.json']); + }); + + test('that file is one envelope, not an array or a stream of them', () => { + expect(Array.isArray(parsed)).toBe(false); + expect(parsed['schemaVersion']).toBe('1'); + expect(Object.keys(parsed)).toEqual([...ENVELOPE_TOP_LEVEL_FIELDS]); + }); + + test('it covers exactly one repository', () => { + // `snapshot-envelope.md` §1: "never more than one repository per envelope file". + const repository = parsed['repository'] as Record; + expect(typeof repository['id']).toBe('string'); + expect(typeof repository['revision']).toBe('string'); + }); +}); + +describe('T085 / SC-013 — each entity record carries exactly five fields', () => { + test('the fixture has several entities, so the check is not about one record', () => { + expect((parsed['entities'] as unknown[]).length).toBe(3); + }); + + test('every record has exactly the five defined fields', () => { + for (const entity of parsed['entities'] as Record[]) { + expect(Object.keys(entity)).toHaveLength(5); + expect(Object.keys(entity).sort()).toEqual([...ENTITY_RECORD_FIELDS].sort()); + } + }); + + test('no snapshot-shaped artifact was written alongside it', async () => { + // SC-013's second clause. Checked from the directory, so a second file of any + // shape would fail it. The forbidden core type is not named literally here: the + // guard that must name it is `test/envelope-only.test.ts`, which is listed in + // `EXCLUDED_FROM_SCAN` for exactly that reason. + expect((await readdir(join(output, 'sc013'))).sort()).toEqual(['envelope.json']); + for (const entity of parsed['entities'] as Record[]) { + expect(Object.hasOwn(entity, 'id')).toBe(false); + expect(Object.hasOwn(entity, 'paths')).toBe(false); + } + }); +}); + +describe('T085 / SC-013 — the digest matches an independent recomputation', () => { + test('the independent recomputation agrees with the recorded digest', () => { + expect(recomputeIndependently(parsed)).toBe(parsed['digest'] as string); + }); + + test('the recomputation is genuinely independent — it detects a mutation', () => { + // Without this, a recomputation that always returned the declared value would pass + // the test above. + const mutated = structuredClone(parsed); + const entities = mutated['entities'] as Record[]; + (entities[0] as Record)['derivedPaths'] = ['injected/**']; + expect(recomputeIndependently(mutated)).not.toBe(parsed['digest']); + }); + + test('the recomputation is order-insensitive at every nesting level', () => { + // Evidence that the local canonicalizer really sorts, rather than happening to + // agree because the generator emitted keys in sorted order already. + // + // The reordering rebuilds the top-level object with reversed insertion order. An + // earlier version used `JSON.stringify(value, keyList)`, which applies the filter at + // *every* nesting level and silently deleted nested keys — a different mutation + // than the one intended. + const reordered: Record = {}; + for (const key of [...Object.keys(parsed)].reverse()) reordered[key] = parsed[key]; + + expect(Object.keys(reordered)).not.toEqual(Object.keys(parsed)); + expect(recomputeIndependently(reordered)).toBe(parsed['digest'] as string); + }); + + test('the digest is 64 lowercase hex characters', () => { + expect(parsed['digest']).toMatch(/^[0-9a-f]{64}$/u); + }); +}); + +describe('T085 — the scope of what a matching digest establishes', () => { + test('a match establishes integrity, and this test claims nothing more', () => { + // SC-012 / FR-041, asserted as a property of the fixture rather than only stated in + // prose: the envelope carries no field claiming correctness, verification, or + // validation of the ownership it records. + const serialized = JSON.stringify(parsed); + for (const overclaim of ['verified', 'correct', 'validated', 'trusted', 'authoritative']) { + expect(serialized.includes(`"${overclaim}"`)).toBe(false); + } + }); +}); diff --git a/packages/adapters/catalog-backstage/test/source-scan.ts b/packages/adapters/catalog-backstage/test/source-scan.ts index 42277443..5d4bf49b 100644 --- a/packages/adapters/catalog-backstage/test/source-scan.ts +++ b/packages/adapters/catalog-backstage/test/source-scan.ts @@ -61,6 +61,10 @@ export const EXCLUDED_FROM_SCAN: readonly string[] = [ 'packages/adapters/catalog-backstage/test/envelope-shape-locality.test.ts', 'packages/adapters/catalog-backstage/test/no-dynamic-loader.test.ts', 'packages/adapters/catalog-backstage/test/source-scan.ts', + // Phase E's FR-038 guard. It must name `CatalogSnapshot` to forbid the generator + // reaching for it, so it is unscannable without an entry here — the same shape as + // the two consumer guards below. + 'packages/adapters/catalog-backstage/test/envelope-only.test.ts', // Consumer-side boundary guards. Each must name the very thing it forbids: // the schema file it pins by hash, and the adapter package it proves is // never imported. See package-boundary.md §4. diff --git a/packages/adapters/catalog-backstage/test/trigger-classification.test.ts b/packages/adapters/catalog-backstage/test/trigger-classification.test.ts new file mode 100644 index 00000000..2305614c --- /dev/null +++ b/packages/adapters/catalog-backstage/test/trigger-classification.test.ts @@ -0,0 +1,263 @@ +/** + * T076 — each abort carries **exactly one** trigger class, and it is the **correct** + * one, including for the pairs `atomic-fail-closed.md` §4.3 identifies as most at risk + * of being merged. + * + * # The check that would be a tautology, and the one that is not + * + * Asserting that a validator's emitted `triggerClass` equals the class that validator + * emits proves nothing. Every assertion below compares an **emitted** pair against + * {@link REASON_TRIGGER_REGISTRY}, which is transcribed from the contracts and not from + * any validator, so a validator that collapsed two classes disagrees with it. + * + * The registry is itself checked, in the other direction: every reason a validator can + * actually emit must appear in it. A registry that had quietly lost an entry would + * otherwise make `classifyAbort` throw at run time in production and pass here. + */ + +import { describe, expect, test } from 'bun:test'; +import { + COLLAPSIBLE_PAIRS, + REASON_TRIGGER_REGISTRY, + TriggerClassificationError, + classifyAbort, + expectedTriggerFor, +} from '../src/failure/classify.ts'; +import { TRIGGER_CLASSES } from '../src/diagnostics.ts'; +import { readDescriptorDocuments } from '../src/descriptor/read.ts'; +import { parseManifestText } from '../src/manifest/schema.ts'; +import { checkManifestVersions } from '../src/manifest/version.ts'; +import { validatePathLexically } from '../src/manifest/paths.ts'; +import { checkDigestShape } from '../src/manifest/digests.ts'; +import { decodeAnnotation } from '../src/ownership/annotation.ts'; +import { deriveOwnership } from '../src/ownership/derive.ts'; +import { checkGlobalUniqueness } from '../src/identity/uniqueness.ts'; +import { compareRepositoryIdentity } from '../src/repository/identity.ts'; +import { classifyAdmissibility, inadmissibleRejection } from '../src/admissibility/classify.ts'; + +/** Every rejection this package's validators actually produce, gathered by calling them. */ +function emittedRejections(): readonly { readonly reason: string; readonly triggerClass: string }[] { + const emitted: { reason: string; triggerClass: string }[] = []; + const push = (rejection: { reason: string; triggerClass: string }): void => { + emitted.push({ reason: rejection.reason, triggerClass: rejection.triggerClass }); + }; + + // ── Manifest shape + for (const text of [ + 'not json at all', + '[]', + '{"manifestSchemaVersion":"1","requestedSnapshotSchemaVersion":"1","requiredCapabilities":[],"repository":{"id":"github.com/a/b","revision":"0".repeat(40)},"sources":[],"surprise":1}', + '{"manifestSchemaVersion":"1"}', + '{"manifestSchemaVersion":1,"requestedSnapshotSchemaVersion":"1","requiredCapabilities":[],"repository":{"id":"github.com/a/b","revision":"x"},"sources":[]}', + ]) { + const result = parseManifestText(text); + if (!result.ok) push(result.rejection); + } + + // ── Manifest version and capability + const base = { + manifestSchemaVersion: '1', + requestedSnapshotSchemaVersion: '1', + requiredCapabilities: ['pathOwnership'], + repository: { id: 'github.com/a/b', revision: 'a'.repeat(40) }, + sources: [], + }; + for (const override of [ + { manifestSchemaVersion: '2' }, + { requestedSnapshotSchemaVersion: '2' }, + { requiredCapabilities: ['somethingElse'] }, + ]) { + const result = checkManifestVersions({ ...base, ...override }); + if (!result.ok) push(result.rejection); + } + + // ── Source path, stage 1 + for (const path of ['', '.', '/abs', 'C:/x', 'a\\b', 'a/../b', 'a\u0001b']) { + const result = validatePathLexically(path); + if (!result.ok) push(result.rejection); + } + + // ── Source digest shape + const digestShape = checkDigestShape({ path: 'a', digestAlgorithm: 'sha256', digest: 'nope' }); + if (!digestShape.ok) push(digestShape.rejection); + + // ── Repository identity + const identity = compareRepositoryIdentity( + { id: 'github.com/a/b', revision: 'a'.repeat(40) }, + { remoteRaw: 'https://github.com/c/d.git', head: 'b'.repeat(40) }, + ); + if (!identity.ok) push(identity.rejection); + + // ── Descriptor parse: both halves of §4.3's first pair + for (const text of ['kind: Component\nkind: Component\n', 'a: [1, 2\n']) { + for (const document of readDescriptorDocuments('f.yaml', text)) { + if (document.rejection !== undefined) push(document.rejection); + } + } + + // ── Admissibility + const [inadmissible] = readDescriptorDocuments( + 'f.yaml', + 'apiVersion: backstage.io/v1alpha1\nkind: Component\nmetadata:\n name: Not_A_Valid_NAME!\n', + ); + if (inadmissible !== undefined) { + const result = classifyAdmissibility(inadmissible); + if (!result.admissible) push(inadmissibleRejection(result)); + } + + // ── Annotation decode, steps 2 to 4 + for (const node of [['a'], '[not json', '{"paths":[]}']) { + const result = decodeAnnotation(true, node); + if (!result.ok) push(result.rejection); + } + + // ── Annotation step 5 + const pattern = deriveOwnership(true, '["a{b}c"]'); + if (!pattern.ok) push(pattern.rejection); + + // ── Uniqueness + const duplicateId = checkGlobalUniqueness([ + { canonicalId: 'component:default/a', allRefs: ['component:default/a'] }, + { canonicalId: 'component:default/a', allRefs: ['component:default/a'] }, + ]); + if (!duplicateId.ok) push(duplicateId.rejection); + + const duplicateRef = checkGlobalUniqueness([ + { canonicalId: 'component:default/a', allRefs: ['component:default/a', 'component:default/b'] }, + { canonicalId: 'component:default/b', allRefs: ['component:default/b'] }, + ]); + if (!duplicateRef.ok) push(duplicateRef.rejection); + + return emitted; +} + +describe('T076 — every emitted rejection agrees with the contract-sourced registry', () => { + const emitted = emittedRejections(); + + test('the sweep actually produced rejections, so the check below read something', () => { + // Guards the guard. An empty list would pass every assertion in this block. + expect(emitted.length).toBeGreaterThanOrEqual(20); + }); + + test('every emitted reason is registered', () => { + const unregistered = emitted + .filter((rejection) => expectedTriggerFor(rejection.reason) === undefined) + .map((rejection) => rejection.reason) + .sort(); + expect(unregistered).toEqual([]); + }); + + test('every emitted class matches the class the contracts assign its reason', () => { + const disagreements = emitted + .filter((rejection) => expectedTriggerFor(rejection.reason) !== rejection.triggerClass) + .map((rejection) => `${rejection.reason}: emitted ${rejection.triggerClass}`) + .sort(); + expect(disagreements).toEqual([]); + }); + + test('every class the registry names is a member of the closed enumeration', () => { + const outside = Object.values(REASON_TRIGGER_REGISTRY) + .filter((trigger) => !(TRIGGER_CLASSES as readonly string[]).includes(trigger)) + .sort(); + expect(outside).toEqual([]); + }); +}); + +describe('T076 / §4.3 — the collapsible pairs stay distinct', () => { + test('the contract names three pairs, and they are checked by name', () => { + expect(COLLAPSIBLE_PAIRS).toHaveLength(3); + }); + + test('duplicate-yaml-key is not invalid-yaml-syntax', () => { + const [duplicate] = readDescriptorDocuments('f.yaml', 'kind: Component\nkind: Component\n'); + const [malformed] = readDescriptorDocuments('f.yaml', 'a: [1, 2\n'); + + expect(duplicate?.rejection?.triggerClass).toBe('duplicate-yaml-key'); + expect(malformed?.rejection?.triggerClass).toBe('invalid-yaml-syntax'); + expect(duplicate?.rejection?.triggerClass).not.toBe(malformed?.rejection?.triggerClass); + }); + + test('an unrecognized top-level manifest field is invalid-manifest-shape, not a version problem', () => { + // `contracts/README.md` §4.2: `input-manifest.md` §1 calls this an "unsupported + // manifest version"-class rejection and `atomic-fail-closed.md` §4 assigns it to + // `invalid-manifest-shape`. §4 governs. + const result = parseManifestText( + JSON.stringify({ + manifestSchemaVersion: '1', + requestedSnapshotSchemaVersion: '1', + requiredCapabilities: ['pathOwnership'], + repository: { id: 'github.com/a/b', revision: 'a'.repeat(40) }, + sources: [], + surprise: true, + }), + ); + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.rejection.reason).toBe('unrecognized-top-level-field'); + expect(result.rejection.triggerClass).toBe('invalid-manifest-shape'); + expect(result.rejection.triggerClass).not.toBe('unsupported-manifest-version'); + }); + + test('unsupported-manifest-version presumes a well-shaped manifest with a bad value', () => { + const result = checkManifestVersions({ + manifestSchemaVersion: '2', + requestedSnapshotSchemaVersion: '1', + requiredCapabilities: ['pathOwnership'], + repository: { id: 'github.com/a/b', revision: 'a'.repeat(40) }, + sources: [], + }); + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.rejection.triggerClass).toBe('unsupported-manifest-version'); + }); + + test('a lexically invalid source path is invalid-manifest-shape, not incomplete-required-source', () => { + // `contracts/README.md` §4.3 resolves `input-manifest.md` §4.1's silence: stage 1 + // is a defect in the manifest's own content, found before the file is opened. + const result = validatePathLexically('../escape.yaml'); + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.rejection.triggerClass).toBe('invalid-manifest-shape'); + expect(result.rejection.triggerClass).not.toBe('incomplete-required-source'); + }); +}); + +describe('T076 — classifyAbort refuses a disagreement rather than repairing it', () => { + test('a rejection whose class contradicts the registry throws', () => { + expect(() => + classifyAbort( + { reason: 'duplicate-yaml-key', triggerClass: 'invalid-yaml-syntax', detail: 'd' }, + 'descriptor-read', + ), + ).toThrow(TriggerClassificationError); + }); + + test('an unregistered reason throws rather than being trusted', () => { + expect(() => + classifyAbort({ reason: 'invented-reason', triggerClass: 'other-invalid-input', detail: 'd' }, 'manifest'), + ).toThrow(TriggerClassificationError); + }); + + test('the error names both sides, so the disagreement is legible', () => { + try { + classifyAbort( + { reason: 'duplicate-yaml-key', triggerClass: 'invalid-yaml-syntax', detail: 'd' }, + 'descriptor-read', + ); + throw new Error('expected a throw'); + } catch (error) { + expect((error as Error).message).toContain('"duplicate-yaml-key"'); + expect((error as Error).message).toContain('"invalid-yaml-syntax"'); + } + }); + + test('an agreeing rejection yields exactly one record', () => { + const record = classifyAbort( + { reason: 'duplicate-yaml-key', triggerClass: 'duplicate-yaml-key', detail: 'd' }, + 'descriptor-read', + { sourcePath: 'f.yaml', documentIndex: 0 }, + ); + expect(record.triggerClass).toBe('duplicate-yaml-key'); + expect(record.stage).toBe('descriptor-read'); + }); +}); diff --git a/packages/adapters/catalog-backstage/test/uniqueness.test.ts b/packages/adapters/catalog-backstage/test/uniqueness.test.ts new file mode 100644 index 00000000..45995f55 --- /dev/null +++ b/packages/adapters/catalog-backstage/test/uniqueness.test.ts @@ -0,0 +1,225 @@ +/** + * T071 — global canonical uniqueness over **every ref**, three distinct classes, and + * no first-wins or last-wins resolution. + * + * `entity-identity.md` §3. The table there gives four collision kinds mapping onto + * three trigger classes; the fourth (`duplicate-yaml-key`) is detected at descriptor + * read and is checked in `test/trigger-classification.test.ts`. + * + * # `duplicate-canonical-ref`'s reachability, recorded rather than implied + * + * `identity/canonicalize.ts` populates `allRefs` as `[canonicalId]` and nothing else, + * because `data-model.md` §5 records how `allRefs` is populated beyond the primary id + * as an unresolved `[NEEDS CLARIFICATION]`, and `entity-identity.md` §2 says alias refs + * come "directly by a synthetic fixture's own construction" with "no real-corpus entity + * from `community-plugins` or `rhdh-plugins`" ever having one. + * + * **Consequence:** no descriptor-sourced input reaches `duplicate-canonical-ref`. Two + * descriptors that canonicalize alike collide as `duplicate-canonical-id`. Every case + * below that produces `duplicate-canonical-ref` hands this kernel a **synthetic** + * identity set, and is labelled as such. That is stated here rather than left for a + * reader to infer from the fixtures. + */ + +import { describe, expect, test } from 'bun:test'; +import { + COLLISION_CLASSES, + checkGlobalUniqueness, + collisionReason, + foldedRefs, +} from '../src/identity/uniqueness.ts'; + +const identity = (canonicalId: string, ...aliases: readonly string[]) => ({ + canonicalId, + allRefs: [canonicalId, ...aliases], +}); + +describe('T071 — the three collision classes §3 enumerates', () => { + test('all three are members of the closed trigger enumeration', () => { + expect([...COLLISION_CLASSES].sort()).toEqual([ + 'duplicate-canonical-id', + 'duplicate-canonical-ref', + 'duplicate-yaml-key', + ]); + }); + + test('a distinct set passes, so the rejections below are not vacuous', () => { + const outcome = checkGlobalUniqueness([ + identity('component:default/payments'), + identity('component:default/billing'), + identity('api:default/payments'), + ]); + expect(outcome.ok).toBe(true); + if (!outcome.ok) return; + expect(outcome.refCount).toBe(3); + }); +}); + +describe('T071 — row 1: identical canonical ids are `duplicate-canonical-id`', () => { + test('two descriptors canonicalizing alike collide', () => { + const outcome = checkGlobalUniqueness([ + identity('component:default/payments'), + identity('component:default/payments'), + ]); + expect(outcome.ok).toBe(false); + if (outcome.ok) return; + + expect(outcome.rejection.reason).toBe('duplicate-canonical-id'); + expect(outcome.rejection.triggerClass).toBe('duplicate-canonical-id'); + expect(outcome.collision.first.entityIndex).toBe(0); + expect(outcome.collision.second.entityIndex).toBe(1); + }); + + test('the case-only pair §1 canonicalizes together arrives here already identical', () => { + // `Component:Default/Payments` and `component:default/payments` are one string by + // the time uniqueness sees them, because `identity/canonicalize.ts` lowercases the + // entire id. So §1's worked example lands on row 1, not on row 3. + const outcome = checkGlobalUniqueness([ + identity('Component:Default/Payments'.toLowerCase()), + identity('component:default/payments'), + ]); + expect(outcome.ok).toBe(false); + if (outcome.ok) return; + expect(outcome.rejection.reason).toBe('duplicate-canonical-id'); + }); +}); + +describe('T071 — row 2: an alias colliding with a different entity\u2019s id is `duplicate-canonical-ref`', () => { + test('§3\u2019s worked example, on a synthetic identity set', () => { + // Synthetic: `allRefs` beyond `canonicalId` has no descriptor-sourced route. + const outcome = checkGlobalUniqueness([ + identity('component:default/billing', 'component:default/billing-legacy'), + identity('component:default/billing-legacy'), + ]); + expect(outcome.ok).toBe(false); + if (outcome.ok) return; + + expect(outcome.rejection.reason).toBe('duplicate-canonical-ref'); + expect(outcome.rejection.triggerClass).toBe('duplicate-canonical-ref'); + expect(outcome.collision.first.primary).toBe(false); + expect(outcome.collision.second.primary).toBe(true); + }); + + test('an alias-vs-alias collision is also `duplicate-canonical-ref`', () => { + const outcome = checkGlobalUniqueness([ + identity('component:default/a', 'component:default/shared'), + identity('component:default/b', 'component:default/shared'), + ]); + expect(outcome.ok).toBe(false); + if (outcome.ok) return; + expect(outcome.rejection.reason).toBe('duplicate-canonical-ref'); + }); +}); + +describe('T071 — row 3: a case-only variant is `duplicate-canonical-ref`', () => { + test('an alias differing only by case collides', () => { + const outcome = checkGlobalUniqueness([ + identity('component:default/billing', 'Component:Default/Billing-Legacy'), + identity('component:default/billing-legacy'), + ]); + expect(outcome.ok).toBe(false); + if (outcome.ok) return; + + expect(outcome.rejection.reason).toBe('duplicate-canonical-ref'); + expect(outcome.rejection.detail).toContain('case-only variant'); + }); + + test('the class is `duplicate-canonical-ref` and specifically not `duplicate-canonical-id`', () => { + // The distinction §3's second and third rows exist to make. A rule that reported + // every collision as `duplicate-canonical-id` would pass a test that only asserted + // "it aborted", and would make rows 2 and 3 unobservable. + expect( + collisionReason( + { entityIndex: 0, canonicalId: 'component:default/a', ref: 'Component:Default/A', primary: false }, + { entityIndex: 1, canonicalId: 'component:default/a', ref: 'component:default/a', primary: true }, + ), + ).toBe('duplicate-canonical-ref'); + + expect( + collisionReason( + { entityIndex: 0, canonicalId: 'component:default/a', ref: 'component:default/a', primary: true }, + { entityIndex: 1, canonicalId: 'component:default/a', ref: 'component:default/a', primary: true }, + ), + ).toBe('duplicate-canonical-id'); + }); + + test('two identical primary ids belonging to the SAME entity are not row 1', () => { + // §3 row 1 is "Two **entities'** `canonicalId` values are identical". One entity + // repeating its own id is a ref uniqueness failure, not two entities colliding. + expect( + collisionReason( + { entityIndex: 0, canonicalId: 'component:default/a', ref: 'component:default/a', primary: true }, + { entityIndex: 0, canonicalId: 'component:default/a', ref: 'component:default/a', primary: true }, + ), + ).toBe('duplicate-canonical-ref'); + }); +}); + +describe('T071 — first-wins and last-wins are both forbidden', () => { + test('a collision returns a rejection, never a surviving member', () => { + const outcome = checkGlobalUniqueness([ + identity('component:default/a'), + identity('component:default/a'), + ]); + expect(outcome.ok).toBe(false); + // There is no `kept`, `winner`, `resolved`, or `survivors` field to read, so + // "which one won" is not a question this type can answer. + expect(Object.keys(outcome).sort()).toEqual(['collision', 'ok', 'rejection']); + }); + + test('the reported collision does not depend on which member came first', () => { + const forwards = checkGlobalUniqueness([ + identity('component:default/a'), + identity('component:default/b'), + identity('component:default/a'), + ]); + const backwards = checkGlobalUniqueness([ + identity('component:default/a'), + identity('component:default/a'), + identity('component:default/b'), + ]); + expect(forwards.ok).toBe(false); + expect(backwards.ok).toBe(false); + if (forwards.ok || backwards.ok) return; + expect(forwards.rejection.reason).toBe(backwards.rejection.reason); + }); + + test('the detail names the prohibition, so an abort is legible without the contract', () => { + const outcome = checkGlobalUniqueness([ + identity('component:default/a'), + identity('component:default/a'), + ]); + if (outcome.ok) throw new Error('expected a collision'); + expect(outcome.rejection.detail).toContain('first-wins or last-wins'); + }); +}); + +describe('T071 — uniqueness is over every ref, not only primary ids', () => { + test('a ref repeated within one entity is a violation', () => { + // §3: "every string appearing in **any** entity's `allRefs` MUST be globally + // unique." §3's table lists only cross-entity kinds, so this reading is recorded + // in `identity/uniqueness.ts` rather than left implicit. + const outcome = checkGlobalUniqueness([ + { canonicalId: 'component:default/a', allRefs: ['component:default/a', 'component:default/a'] }, + ]); + expect(outcome.ok).toBe(false); + if (outcome.ok) return; + expect(outcome.rejection.reason).toBe('duplicate-canonical-ref'); + }); + + test('the comparison walks aliases as well as primary ids', () => { + const outcome = checkGlobalUniqueness([ + identity('component:default/a', 'component:default/x', 'component:default/y'), + identity('component:default/b'), + ]); + expect(outcome.ok).toBe(true); + if (!outcome.ok) return; + expect(outcome.refCount).toBe(4); + }); + + test('folded refs are sorted and deduplicated, so a report is reproducible', () => { + expect( + foldedRefs([identity('component:default/b'), identity('component:default/a', 'API:Default/A')]), + ).toEqual(['api:default/a', 'component:default/a', 'component:default/b']); + }); +}); diff --git a/specs/010-catalog-backstage/evidence/negative-cases/determinism/README.md b/specs/010-catalog-backstage/evidence/negative-cases/determinism/README.md new file mode 100644 index 00000000..0d72ecfb --- /dev/null +++ b/specs/010-catalog-backstage/evidence/negative-cases/determinism/README.md @@ -0,0 +1,85 @@ +# Negative case: determinism + +**Tasks**: T083, T084 · **Discharges**: FR-042 · **Supports**: SC-001 +**Observed against**: `19f316413d5550b30296e7987c74d267c96655f9` plus Phase E's own +uncommitted work; the named mutation was the only additional change in the tree, and it +was reverted afterwards. +**Tools**: Bun 1.3.14, TypeScript 6.0.3 +**Command**: `bun test test/sc-001-determinism.test.ts test/byte-identical.test.ts`, run +from `packages/adapters/catalog-backstage/` +**Permanent automated cases**: `test/sc-001-determinism.test.ts`, +`test/byte-identical.test.ts` + +## What is being protected + +FR-042: "Identical inputs MUST produce **byte-identical** output across repeated runs, +including array ordering and serialization details." + +SC-001 adds the half that is easiest to leave out: determinism holds "on the accept path +and on the reject path alike". A deterministic **rejection** matters for the same reason +a deterministic envelope does — ADR-0016 records the exact emitted string as evidence, +and a reason string that varies between runs is not evidence of anything. + +## Why a non-determinism case is hard to construct honestly, and what was chosen + +Most plausible mistakes here are *wrong but still deterministic*: sorting with the wrong +comparator, or preserving declaration order where sorted order was intended, both produce +stable output that is stable in the wrong way. Those are covered by the ordering +assertions in `test/byte-identical.test.ts` and by Phase D's `test/glob-order.test.ts`, +not by this case. + +The mutation below is genuine non-determinism — a randomised entity order — because that +is the failure FR-042's word "byte-identical" is actually about, and it is the one a +suite comparing parsed objects rather than bytes would miss. + +--- + +## Case 1 — entity order made non-deterministic + +Input: [`case-1-entity-order-made-non-deterministic.patch`](./case-1-entity-order-made-non-deterministic.patch) · +Output: [`case-1-entity-order-made-non-deterministic.observed.txt`](./case-1-entity-order-made-non-deterministic.observed.txt) + +The envelope's `entities` array is shuffled before the digest is computed. Every entity +is still present and every record is still correct — the envelope is *valid*, and only +its bytes vary. Five tests fail: + +``` +(fail) T084 / SC-001 — the accept path > 5 runs serialize byte-identically +(fail) T084 / SC-001 — the accept path > every array's ordering is identical across runs +(fail) T084 / SC-001 — the accept path > the digest is identical across runs +(fail) T083 / FR-042 — repeated runs are byte-identical > two runs over identical input serialize identically +(fail) T083 / FR-042 — repeated runs are byte-identical > two files written by two runs have identical bytes +``` + +Two things this shows that a weaker check would not: + +- **"5 runs all succeed" keeps passing.** Non-determinism is not a failure; it is a + difference. A suite asserting only that generation succeeds would be green. +- **The digest failure is downstream of the ordering failure.** `snapshot-envelope.md` §3 + serializes arrays "in their existing declaration order (never re-sorted)", so a varying + declaration order changes the canonical form and therefore the digest. The digest is a + useful *symptom* of non-determinism; the ordering assertion is what names the cause. + +## The reject path + +SC-001's reject half is asserted in `test/sc-001-determinism.test.ts` over five runs +against four separate rejection fixtures and one input violating several rules at once. +The whole `AtomicFailureRecord` is compared — trigger class, reason, detail, stage and +location — because any of them varying is non-determinism, and the multi-violation case +is the one that catches an implementation reporting whichever violation its iteration +order surfaced first. + +No separate mutation is recorded for the reject path: the mutation above sits after every +abort point, so it cannot affect a rejecting run. A mutation that *did* would be a +mutation to one of the ordered validators, which are Phase D's and are covered by +`../annotation-decode/` and `../glob-rules/`. + +## Restored + +[`restored.observed.txt`](./restored.observed.txt) — all tests pass, 0 fail, across both +named test files. + +## Standing constraints + +ADR-0014 **rung 1 only**. Nothing here is external, third-party, or community +validation. diff --git a/specs/010-catalog-backstage/evidence/negative-cases/determinism/case-1-entity-order-made-non-deterministic.observed.txt b/specs/010-catalog-backstage/evidence/negative-cases/determinism/case-1-entity-order-made-non-deterministic.observed.txt new file mode 100644 index 00000000..dea7ef05 --- /dev/null +++ b/specs/010-catalog-backstage/evidence/negative-cases/determinism/case-1-entity-order-made-non-deterministic.observed.txt @@ -0,0 +1,96 @@ + +packages/adapters/catalog-backstage/test/sc-001-determinism.test.ts: +(pass) T084 / SC-001 — the accept path > 5 runs all succeed [59.63ms] +81 | +82 | test(`${RUNS} runs serialize byte-identically`, async () => { +83 | const outcomes = await repeat(RUNS, () => runGeneration(request)); +84 | const serialized = outcomes.map((outcome) => (outcome.ok ? serializeEnvelope(outcome.envelope) : 'ABORTED')); +85 | +86 | expect(new Set(serialized).size).toBe(1); + ^ +error: expect(received).toBe(expected) + +Expected: 1 +Received: 4 + + at (/Users/markbeacom/github/mbeacom/copilot-worktrees/adrkit/mbeacom-potential-potato/packages/adapters/catalog-backstage/test/sc-001-determinism.test.ts:86:38) +(fail) T084 / SC-001 — the accept path > 5 runs serialize byte-identically [49.12ms] + 98 | derivedPaths: outcome.envelope.entities.map((entity) => entity.derivedPaths), + 99 | allRefs: outcome.envelope.entities.map((entity) => entity.identity.allRefs), +100 | }) +101 | : 'ABORTED', +102 | ); +103 | expect(new Set(arrays).size).toBe(1); + ^ +error: expect(received).toBe(expected) + +Expected: 1 +Received: 4 + + at (/Users/markbeacom/github/mbeacom/copilot-worktrees/adrkit/mbeacom-potential-potato/packages/adapters/catalog-backstage/test/sc-001-determinism.test.ts:103:34) +(fail) T084 / SC-001 — the accept path > every array’s ordering is identical across runs [49.39ms] +104 | }); +105 | +106 | test('the digest is identical across runs', async () => { +107 | const outcomes = await repeat(RUNS, () => runGeneration(request)); +108 | const digests = outcomes.map((outcome) => (outcome.ok ? outcome.envelope.digest : 'ABORTED')); +109 | expect(new Set(digests).size).toBe(1); + ^ +error: expect(received).toBe(expected) + +Expected: 1 +Received: 5 + + at (/Users/markbeacom/github/mbeacom/copilot-worktrees/adrkit/mbeacom-potential-potato/packages/adapters/catalog-backstage/test/sc-001-determinism.test.ts:109:35) +(fail) T084 / SC-001 — the accept path > the digest is identical across runs [49.61ms] +(pass) T084 / SC-001 — the reject path > 5 runs over a duplicate canonical id reject identically [44.01ms] +(pass) T084 / SC-001 — the reject path > 5 runs over an inadmissible descriptor reject identically [44.31ms] +(pass) T084 / SC-001 — the reject path > 5 runs over a rejected pattern reject identically [41.92ms] +(pass) T084 / SC-001 — the reject path > 5 runs over a repeated YAML key reject identically [43.02ms] +(pass) T084 / SC-001 — the reject path > an input violating several rules at once reports the same one every run [43.45ms] +(pass) T084 / SC-001 — the reject path > a rejecting run writes nothing on any of the runs [44.69ms] + +packages/adapters/catalog-backstage/test/byte-identical.test.ts: +(pass) T083 — the fixture is varied enough for a difference to show > it spans all three ownership states and several source files [9.71ms] +(pass) T083 — the fixture is varied enough for a difference to show > a declared pattern order differing from the sorted order is normalized [8.83ms] +102 | +103 | expect(first.ok).toBe(true); +104 | expect(second.ok).toBe(true); +105 | if (!first.ok || !second.ok) return; +106 | +107 | expect(serializeEnvelope(second.envelope)).toBe(serializeEnvelope(first.envelope)); + ^ +error: expect(received).toBe(expected) + +- "{"schemaVersion":"1","repository":{"id":"github.com/mbeacom/adrkit-phase-e-fixture","revision":"bb5e3c4c895314cc190b69f500586f7002c13d1c"},"generatorVersion":"@adrkit/catalog-backstage@0.0.0","globDialect":{"engine":"picomatch","version":"4.0.5","options":{"dot":false,"nocase":false,"nonegate":true}},"capabilities":["pathOwnership"],"completeness":{"wholeCatalog":false,"identityOnly":false},"sources":[{"path":"aaa/catalog-info.yaml","digestAlgorithm":"sha256","digest":"47572295f24cc26433b0006664bf58dcfcd3b80ba23c4b7ff347ec006587ffe5"},{"path":"mmm/catalog-info.yaml","digestAlgorithm":"sha256","digest":"a8f8b078cc4cb25d6047c39b55afda2430834858aeba4c211836855230e9f4df"},{"path":"nnn/catalog-info.yaml","digestAlgorithm":"sha256","digest":"a4855d851b0cb84440e0b12258377858bc70e51a8f301c53e47eeff37aa4fbda"},{"path":"zzz/catalog-info.yaml","digestAlgorithm":"sha256","digest":"d0433fd690c9de4bd49d6801a4933837b0ef7ddac4ca17cb92995654e91b6011"}],"entities":[{"identity":{"canonicalId":"component:default/mmmmiddle","allRefs":["component:default/mmmmiddle"]},"ownershipState":"annotation-absent","derivedPaths":[],"sourceDocument":{"sourcePath":"mmm/catalog-info.yaml","documentIndexInFile":0},"provenance":"maintainer-overlay"},{"identity":{"canonicalId":"component:default/zzzlast","allRefs":["component:default/zzzlast"]},"ownershipState":"explicit-paths","derivedPaths":["packages/z/**","shared/**"],"sourceDocument":{"sourcePath":"zzz/catalog-info.yaml","documentIndexInFile":0},"provenance":"maintainer-overlay"},{"identity":{"canonicalId":"component:default/zulu","allRefs":["component:default/zulu"]},"ownershipState":"explicit-paths","derivedPaths":["alpha/**","middle/**","zeta/**"],"sourceDocument":{"sourcePath":"aaa/catalog-info.yaml","documentIndexInFile":0},"provenance":"maintainer-overlay"},{"identity":{"canonicalId":"component:default/nnnother","allRefs":["component:default/nnnother"]},"ownershipState":"explicit-paths","derivedPaths":["shared/**"],"sourceDocument":{"sourcePath":"nnn/catalog-info.yaml","documentIndexInFile":0},"provenance":"maintainer-overlay"},{"identity":{"canonicalId":"api:payments/yankee","allRefs":["api:payments/yankee"]},"ownershipState":"explicit-empty","derivedPaths":[],"sourceDocument":{"sourcePath":"aaa/catalog-info.yaml","documentIndexInFile":1},"provenance":"maintainer-overlay"}],"digest":"e0ca00b69780d834911e062cc09d4c548932f59616e82648ac4c23017ecffd9b"} ++ "{"schemaVersion":"1","repository":{"id":"github.com/mbeacom/adrkit-phase-e-fixture","revision":"bb5e3c4c895314cc190b69f500586f7002c13d1c"},"generatorVersion":"@adrkit/catalog-backstage@0.0.0","globDialect":{"engine":"picomatch","version":"4.0.5","options":{"dot":false,"nocase":false,"nonegate":true}},"capabilities":["pathOwnership"],"completeness":{"wholeCatalog":false,"identityOnly":false},"sources":[{"path":"aaa/catalog-info.yaml","digestAlgorithm":"sha256","digest":"47572295f24cc26433b0006664bf58dcfcd3b80ba23c4b7ff347ec006587ffe5"},{"path":"mmm/catalog-info.yaml","digestAlgorithm":"sha256","digest":"a8f8b078cc4cb25d6047c39b55afda2430834858aeba4c211836855230e9f4df"},{"path":"nnn/catalog-info.yaml","digestAlgorithm":"sha256","digest":"a4855d851b0cb84440e0b12258377858bc70e51a8f301c53e47eeff37aa4fbda"},{"path":"zzz/catalog-info.yaml","digestAlgorithm":"sha256","digest":"d0433fd690c9de4bd49d6801a4933837b0ef7ddac4ca17cb92995654e91b6011"}],"entities":[{"identity":{"canonicalId":"component:default/zulu","allRefs":["component:default/zulu"]},"ownershipState":"explicit-paths","derivedPaths":["alpha/**","middle/**","zeta/**"],"sourceDocument":{"sourcePath":"aaa/catalog-info.yaml","documentIndexInFile":0},"provenance":"maintainer-overlay"},{"identity":{"canonicalId":"component:default/mmmmiddle","allRefs":["component:default/mmmmiddle"]},"ownershipState":"annotation-absent","derivedPaths":[],"sourceDocument":{"sourcePath":"mmm/catalog-info.yaml","documentIndexInFile":0},"provenance":"maintainer-overlay"},{"identity":{"canonicalId":"component:default/nnnother","allRefs":["component:default/nnnother"]},"ownershipState":"explicit-paths","derivedPaths":["shared/**"],"sourceDocument":{"sourcePath":"nnn/catalog-info.yaml","documentIndexInFile":0},"provenance":"maintainer-overlay"},{"identity":{"canonicalId":"component:default/zzzlast","allRefs":["component:default/zzzlast"]},"ownershipState":"explicit-paths","derivedPaths":["packages/z/**","shared/**"],"sourceDocument":{"sourcePath":"zzz/catalog-info.yaml","documentIndexInFile":0},"provenance":"maintainer-overlay"},{"identity":{"canonicalId":"api:payments/yankee","allRefs":["api:payments/yankee"]},"ownershipState":"explicit-empty","derivedPaths":[],"sourceDocument":{"sourcePath":"aaa/catalog-info.yaml","documentIndexInFile":1},"provenance":"maintainer-overlay"}],"digest":"d50df3fa77912940fc8a82fee36a62ded37261a6b26594329dede47e15a2fcca"} + " + +- Expected - 1 ++ Received + 1 + + at (/Users/markbeacom/github/mbeacom/copilot-worktrees/adrkit/mbeacom-potential-potato/packages/adapters/catalog-backstage/test/byte-identical.test.ts:107:48) +(fail) T083 / FR-042 — repeated runs are byte-identical > two runs over identical input serialize identically [18.51ms] +115 | +116 | await generateAndWriteEnvelope(request, a); +117 | await generateAndWriteEnvelope(request, b); +118 | +119 | const [bytesA, bytesB] = await Promise.all([readFile(a), readFile(b)]); +120 | expect(bytesB.equals(bytesA)).toBe(true); + ^ +error: expect(received).toBe(expected) + +Expected: true +Received: false + + at (/Users/markbeacom/github/mbeacom/copilot-worktrees/adrkit/mbeacom-potential-potato/packages/adapters/catalog-backstage/test/byte-identical.test.ts:120:35) +(fail) T083 / FR-042 — repeated runs are byte-identical > two files written by two runs have identical bytes [21.11ms] +(pass) T083 / FR-042 — repeated runs are byte-identical > the stage trace is identical too [18.20ms] +(pass) T083 — output is a function of content, not of input ordering > listing the same sources in a different manifest order yields the same entities [20.13ms] +(pass) T083 — output is a function of content, not of input ordering > any content change changes the bytes [19.33ms] + + 12 pass + 5 fail + 48 expect() calls +Ran 17 tests across 2 files. [698.00ms] +bun test v1.3.14 (0d9b296a) diff --git a/specs/010-catalog-backstage/evidence/negative-cases/determinism/case-1-entity-order-made-non-deterministic.patch b/specs/010-catalog-backstage/evidence/negative-cases/determinism/case-1-entity-order-made-non-deterministic.patch new file mode 100644 index 00000000..8d9466a7 --- /dev/null +++ b/specs/010-catalog-backstage/evidence/negative-cases/determinism/case-1-entity-order-made-non-deterministic.patch @@ -0,0 +1,13 @@ +diff --git a/packages/adapters/catalog-backstage/src/pipeline.ts b/packages/adapters/catalog-backstage/src/pipeline.ts +index e362b2b..49d6a5a 100644 +--- a/packages/adapters/catalog-backstage/src/pipeline.ts ++++ b/packages/adapters/catalog-backstage/src/pipeline.ts +@@ -444,7 +444,7 @@ export async function runGeneration(request: GenerationRequest): Promise Math.random() - 0.5), + } satisfies Omit; + + const envelope = assembleEnvelope({ diff --git a/specs/010-catalog-backstage/evidence/negative-cases/determinism/restored.observed.txt b/specs/010-catalog-backstage/evidence/negative-cases/determinism/restored.observed.txt new file mode 100644 index 00000000..4762948b --- /dev/null +++ b/specs/010-catalog-backstage/evidence/negative-cases/determinism/restored.observed.txt @@ -0,0 +1,27 @@ +bun test v1.3.14 (0d9b296a) + +test/sc-001-determinism.test.ts: +(pass) T084 / SC-001 — the accept path > 5 runs all succeed [59.35ms] +(pass) T084 / SC-001 — the accept path > 5 runs serialize byte-identically [52.34ms] +(pass) T084 / SC-001 — the accept path > every array’s ordering is identical across runs [48.49ms] +(pass) T084 / SC-001 — the accept path > the digest is identical across runs [47.07ms] +(pass) T084 / SC-001 — the reject path > 5 runs over a duplicate canonical id reject identically [44.64ms] +(pass) T084 / SC-001 — the reject path > 5 runs over an inadmissible descriptor reject identically [44.12ms] +(pass) T084 / SC-001 — the reject path > 5 runs over a rejected pattern reject identically [40.67ms] +(pass) T084 / SC-001 — the reject path > 5 runs over a repeated YAML key reject identically [52.70ms] +(pass) T084 / SC-001 — the reject path > an input violating several rules at once reports the same one every run [42.08ms] +(pass) T084 / SC-001 — the reject path > a rejecting run writes nothing on any of the runs [43.45ms] + +test/byte-identical.test.ts: +(pass) T083 — the fixture is varied enough for a difference to show > it spans all three ownership states and several source files [9.97ms] +(pass) T083 — the fixture is varied enough for a difference to show > a declared pattern order differing from the sorted order is normalized [9.04ms] +(pass) T083 / FR-042 — repeated runs are byte-identical > two runs over identical input serialize identically [17.74ms] +(pass) T083 / FR-042 — repeated runs are byte-identical > two files written by two runs have identical bytes [18.56ms] +(pass) T083 / FR-042 — repeated runs are byte-identical > the stage trace is identical too [17.65ms] +(pass) T083 — output is a function of content, not of input ordering > listing the same sources in a different manifest order yields the same entities [17.07ms] +(pass) T083 — output is a function of content, not of input ordering > any content change changes the bytes [16.89ms] + + 17 pass + 0 fail + 50 expect() calls +Ran 17 tests across 2 files. [694.00ms] diff --git a/specs/010-catalog-backstage/evidence/negative-cases/envelope-invariants/README.md b/specs/010-catalog-backstage/evidence/negative-cases/envelope-invariants/README.md new file mode 100644 index 00000000..93ceea3c --- /dev/null +++ b/specs/010-catalog-backstage/evidence/negative-cases/envelope-invariants/README.md @@ -0,0 +1,164 @@ +# Negative case: the envelope's invariants + +**Tasks**: T070, T072, T079, T080, T081, T082 · **Discharges**: FR-014, FR-024, FR-038, +FR-039, FR-040, FR-043 · **Supports**: SC-013 +**Observed against**: `19f316413d5550b30296e7987c74d267c96655f9` plus Phase E's own +uncommitted work; in each case the named mutation was the only additional change in the +tree, and it was reverted before the next case was run. +**Tools**: Bun 1.3.14, TypeScript 6.0.3 +**Command for every case below**: `bun test `, run from +`packages/adapters/catalog-backstage/` +**Permanent automated cases**: `test/completeness-always-false.test.ts`, +`test/envelope-shape.test.ts`, `test/envelope-digest.test.ts`, +`test/envelope-provenance.test.ts`, `test/envelope-only.test.ts`, `test/overlap.test.ts`, +`test/sc-013.test.ts` + +--- + +## Case 1 — whole-catalog completeness claimed + +Input: [`case-1-whole-catalog-completeness-claimed.patch`](./case-1-whole-catalog-completeness-claimed.patch) · +Output: [`case-1-whole-catalog-completeness-claimed.observed.txt`](./case-1-whole-catalog-completeness-claimed.observed.txt) + +FR-014 and `input-manifest.md` §5's fourth bullet: `completeness.wholeCatalog` "is always +`false` for every envelope". It is not a default; it is the only value, because FR-013 +forbids tree traversal so no run can ever have seen the whole catalog. + +Six tests fail — both of the by-construction checks and all four of the real-run ones: + +``` +(fail) T070 — no parameter can set it > identityOnly false leaves wholeCatalog false +(fail) T070 — no parameter can set it > identityOnly true still leaves wholeCatalog false +(fail) T070 — every envelope a real run produces carries false > a single-entity run +(fail) T070 — every envelope a real run produces carries false > a run over all three ownership states and several sources +(fail) T070 — every envelope a real run produces carries false > a run over a multi-document source file +(fail) T070 — every envelope a real run produces carries false > a run with identityOnly requested still reports wholeCatalog false +``` + +## Case 2 — the entity record spreads instead of projecting + +Input: [`case-2-entity-record-spreads-instead-of-projecting.patch`](./case-2-entity-record-spreads-instead-of-projecting.patch) · +Output: [`case-2-entity-record-spreads-instead-of-projecting.observed.txt`](./case-2-entity-record-spreads-instead-of-projecting.observed.txt) + +`data-model.md` §10 requires **exactly five** fields per record and forbids "a flatter +`canonicalId` / `refs` / `paths` triple". A spread of the pipeline's internal entity +carries the extra fields through and keeps doing so silently as that type grows: + +``` +(fail) T080 / data-model.md §10 — exactly five fields per entity record > every emitted record carries exactly those five +(fail) T080 — the flatter shape is forbidden > no entity record carries a flat canonicalId, refs or paths field +(fail) T080 — entityRecord projects rather than spreads > an input carrying extra fields does not leak them into the record +(fail) T085 / SC-013 — each entity record carries exactly five fields > every record has exactly the five defined fields +``` + +The forbidden-fields check fires as well as the count check, which is why both exist: a +spread that added exactly zero net fields would still be wrong, and a record with three +flat fields would fail a count check for the wrong reason. + +## Case 3 — the digest computed over a non-canonical serialization + +Input: [`case-3-digest-over-a-non-canonical-serialization.patch`](./case-3-digest-over-a-non-canonical-serialization.patch) · +Output: [`case-3-digest-over-a-non-canonical-serialization.observed.txt`](./case-3-digest-over-a-non-canonical-serialization.observed.txt) + +`snapshot-envelope.md` §3 fixes the canonical form: recursive key sort by code-unit +order, arrays in declaration order, compact separators, `undefined` omitted. Replacing +`canonicalStringify` with `JSON.stringify` keeps producing a plausible 64-hex digest that +no independent recomputation agrees with: + +``` +(fail) T081 — the canonical form is canonical > keys are sorted by code units at every nesting level +(fail) T081 — an independent recomputation agrees > recomputing from the canonical form with node:crypto matches the recorded digest +(fail) T081 — the digest is a function of content, not of field order > reordering top-level keys leaves the digest unchanged +(fail) T085 / SC-013 — the digest matches an independent recomputation > the independent recomputation agrees with the recorded digest +(fail) T085 / SC-013 — the digest matches an independent recomputation > the recomputation is order-insensitive at every nesting level +``` + +This is the case that shows why SC-013 asks for an **independent** recomputation. +`verifyEnvelopeDigest` still reports `match` under this mutation, because it shares the +mutated serializer with the code that produced the digest. Only the recomputation in +`test/sc-013.test.ts`, which implements §3's steps separately, disagrees. + +**Scope, restated because this is the digest's record:** for the envelope's closed +scalar domain the canonical bytes are *equivalent to* RFC 8785 / JCS output; no claim is +made that `canonicalStringify` is a general-purpose RFC 8785 implementation. The digest +proves accidental-corruption and naive-mutation detection **only** (FR-041), never +adversarial tamper-resistance. And integrity is not correctness (SC-012). + +## Case 4 — provenance defaulted to `upstream-authored` + +Input: [`case-4-provenance-defaulted-to-upstream-authored.patch`](./case-4-provenance-defaulted-to-upstream-authored.patch) · +Output: [`case-4-provenance-defaulted-to-upstream-authored.observed.txt`](./case-4-provenance-defaulted-to-upstream-authored.observed.txt) + +`data-model.md` §10 closes the provenance domain at two values and FR-043 makes the +distinction load-bearing. A default of `upstream-authored` would turn a **caller's +omission** into a claim that a **third party** adopted the annotation — the overclaim +ADR-0020 clause 5's boundary exists to prevent. + +**One test fails, and the reason it is only one is itself the finding:** + +``` +(fail) T082 — the declaration is exhaustive and has no default > provenanceFor throws rather than substituting a value +``` + +`checkProvenanceDeclaration` runs first and still rejects the incomplete declaration, so +no envelope carrying a defaulted value is ever emitted. The two checks are +defence in depth rather than duplicates: the earlier one refuses the request, and this +one refuses to fabricate an attestation if the earlier one is ever bypassed. Recorded +plainly rather than presented as a broad failure, because a single failing test is a +weaker observation and saying so is the honest reading. Case 4 of the `triggers/` +directory covers the earlier check's own failure. + +## Case 5 — a side file written alongside the envelope + +Input: [`case-5-a-side-file-written-alongside-the-envelope.patch`](./case-5-a-side-file-written-alongside-the-envelope.patch) · +Output: [`case-5-a-side-file-written-alongside-the-envelope.observed.txt`](./case-5-a-side-file-written-alongside-the-envelope.observed.txt) + +ADR-0020 clause 7, quoted by FR-038: "The generator writes the envelope and nothing +else." Writing a plausible-looking stage log next to it fails seven tests: + +``` +(fail) T079 — exactly one file is written > a successful run leaves one file, and it is the envelope +(fail) T079 — exactly one file is written > a second run into the same directory still leaves one file +(fail) T079 — exactly one file is written > a pre-existing unrelated file is left alone rather than cleaned up +(fail) T079 — diagnostics are returned, never written > the stage trace is a returned value and appears in no file +(fail) T079 — writeEnvelope writes one file and creates its directory > a nested destination directory is created +(fail) T085 / SC-013 — exactly one envelope is produced > the destination directory holds exactly one file +``` + +The mutation is deliberately the *most defensible-looking* violation available — a +diagnostic log, not a second snapshot — because that is the one a reasonable +implementer would actually add. FR-038 names "logs presented as output" specifically. + +## Case 6 — overlap resolved by an exclusive winner + +Input: [`case-6-overlap-resolved-by-an-exclusive-winner.patch`](./case-6-overlap-resolved-by-an-exclusive-winner.patch) · +Output: [`case-6-overlap-resolved-by-an-exclusive-winner.observed.txt`](./case-6-overlap-resolved-by-an-exclusive-winner.observed.txt) + +`entity-identity.md` §4: a changed file matching an overlapping pattern "MUST be recorded +as owned by **every** matching entity simultaneously", mirroring ADR-0009's +union-not-winner `affects` semantics. §4 requires this be "**positively demonstrated**... +not merely asserted by the absence of a rejection rule." + +Truncating the owner list to its first element is exactly the exclusive winner §4 +forbids, and it produces **no rejection at all** — which is why the positive +demonstration is necessary: + +``` +(fail) T072 / §4 — no exclusive winner: a changed file is owned by every match > both entities own a file matching the shared pattern +(fail) T072 / §4 — no exclusive winner: a changed file is owned by every match > the result is a list, so a caller cannot read "the owner" off it +``` + +Note that the "a file matching only one pattern is owned by only that entity" contrast +case keeps passing under the mutation. Without the positive check, a suite would be +green. + +## Restored + +[`restored.observed.txt`](./restored.observed.txt) — all tests pass, 0 fail, across the +seven named test files. + +## Standing constraints + +ADR-0014 **rung 1 only**. Nothing here is external, third-party, or community +validation. Nothing here claims the derived ownership is correct: a digest establishes +integrity, and correctness is SC-011's question, which is Phase F's. diff --git a/specs/010-catalog-backstage/evidence/negative-cases/envelope-invariants/case-1-whole-catalog-completeness-claimed.observed.txt b/specs/010-catalog-backstage/evidence/negative-cases/envelope-invariants/case-1-whole-catalog-completeness-claimed.observed.txt new file mode 100644 index 00000000..89ac28a5 --- /dev/null +++ b/specs/010-catalog-backstage/evidence/negative-cases/envelope-invariants/case-1-whole-catalog-completeness-claimed.observed.txt @@ -0,0 +1,113 @@ + +packages/adapters/catalog-backstage/test/completeness-always-false.test.ts: +(pass) T070 — the value is false, and it is one value not two > the boundary constant is false [0.03ms] +(pass) T070 — the value is false, and it is one value not two > the envelope module re-exports that constant rather than declaring a second +49 | }); +50 | }); +51 | +52 | describe('T070 — no parameter can set it', () => { +53 | test('identityOnly false leaves wholeCatalog false', () => { +54 | expect(completeness(false)).toEqual({ wholeCatalog: false, identityOnly: false }); + ^ +error: expect(received).toEqual(expected) + + { + "identityOnly": false, +- "wholeCatalog": false, ++ "wholeCatalog": true, + } + +- Expected - 1 ++ Received + 1 + + at (/Users/markbeacom/github/mbeacom/copilot-worktrees/adrkit/mbeacom-potential-potato/packages/adapters/catalog-backstage/test/completeness-always-false.test.ts:54:33) +(fail) T070 — no parameter can set it > identityOnly false leaves wholeCatalog false [0.14ms] +53 | test('identityOnly false leaves wholeCatalog false', () => { +54 | expect(completeness(false)).toEqual({ wholeCatalog: false, identityOnly: false }); +55 | }); +56 | +57 | test('identityOnly true still leaves wholeCatalog false', () => { +58 | expect(completeness(true)).toEqual({ wholeCatalog: false, identityOnly: true }); + ^ +error: expect(received).toEqual(expected) + + { + "identityOnly": true, +- "wholeCatalog": false, ++ "wholeCatalog": true, + } + +- Expected - 1 ++ Received + 1 + + at (/Users/markbeacom/github/mbeacom/copilot-worktrees/adrkit/mbeacom-potential-potato/packages/adapters/catalog-backstage/test/completeness-always-false.test.ts:58:32) +(fail) T070 — no parameter can set it > identityOnly true still leaves wholeCatalog false [0.03ms] +(pass) T070 — no parameter can set it > the function takes exactly one parameter, and it is not wholeCatalog +74 | 'manifest-one.json', +75 | ); +76 | const outcome = await runGeneration(request); +77 | expect(outcome.ok).toBe(true); +78 | if (!outcome.ok) return; +79 | expect(outcome.envelope.completeness.wholeCatalog).toBe(false); + ^ +error: expect(received).toBe(expected) + +Expected: false +Received: true + + at (/Users/markbeacom/github/mbeacom/copilot-worktrees/adrkit/mbeacom-potential-potato/packages/adapters/catalog-backstage/test/completeness-always-false.test.ts:79:56) +(fail) T070 — every envelope a real run produces carries false > a single-entity run [16.97ms] + 97 | expect(outcome.envelope.entities.map((entity) => entity.ownershipState).sort()).toEqual([ + 98 | 'annotation-absent', + 99 | 'explicit-empty', +100 | 'explicit-paths', +101 | ]); +102 | expect(outcome.envelope.completeness.wholeCatalog).toBe(false); + ^ +error: expect(received).toBe(expected) + +Expected: false +Received: true + + at (/Users/markbeacom/github/mbeacom/copilot-worktrees/adrkit/mbeacom-potential-potato/packages/adapters/catalog-backstage/test/completeness-always-false.test.ts:102:56) +(fail) T070 — every envelope a real run produces carries false > a run over all three ownership states and several sources [10.85ms] +117 | const outcome = await runGeneration(request); +118 | expect(outcome.ok).toBe(true); +119 | if (!outcome.ok) return; +120 | +121 | expect(outcome.envelope.entities).toHaveLength(2); +122 | expect(outcome.envelope.completeness.wholeCatalog).toBe(false); + ^ +error: expect(received).toBe(expected) + +Expected: false +Received: true + + at (/Users/markbeacom/github/mbeacom/copilot-worktrees/adrkit/mbeacom-potential-potato/packages/adapters/catalog-backstage/test/completeness-always-false.test.ts:122:56) +(fail) T070 — every envelope a real run produces carries false > a run over a multi-document source file [8.96ms] +131 | ); +132 | const outcome = await runGeneration({ ...request, identityOnly: true }); +133 | expect(outcome.ok).toBe(true); +134 | if (!outcome.ok) return; +135 | +136 | expect(outcome.envelope.completeness).toEqual({ wholeCatalog: false, identityOnly: true }); + ^ +error: expect(received).toEqual(expected) + + { + "identityOnly": true, +- "wholeCatalog": false, ++ "wholeCatalog": true, + } + +- Expected - 1 ++ Received + 1 + + at (/Users/markbeacom/github/mbeacom/copilot-worktrees/adrkit/mbeacom-potential-potato/packages/adapters/catalog-backstage/test/completeness-always-false.test.ts:136:43) +(fail) T070 — every envelope a real run produces carries false > a run with identityOnly requested still reports wholeCatalog false [9.00ms] + + 3 pass + 6 fail + 15 expect() calls +Ran 9 tests across 1 file. [129.00ms] +bun test v1.3.14 (0d9b296a) diff --git a/specs/010-catalog-backstage/evidence/negative-cases/envelope-invariants/case-1-whole-catalog-completeness-claimed.patch b/specs/010-catalog-backstage/evidence/negative-cases/envelope-invariants/case-1-whole-catalog-completeness-claimed.patch new file mode 100644 index 00000000..f8809d54 --- /dev/null +++ b/specs/010-catalog-backstage/evidence/negative-cases/envelope-invariants/case-1-whole-catalog-completeness-claimed.patch @@ -0,0 +1,13 @@ +diff --git a/packages/adapters/catalog-backstage/src/envelope/completeness.ts b/packages/adapters/catalog-backstage/src/envelope/completeness.ts +index c231997..327295a 100644 +--- a/packages/adapters/catalog-backstage/src/envelope/completeness.ts ++++ b/packages/adapters/catalog-backstage/src/envelope/completeness.ts +@@ -56,7 +56,7 @@ export interface EnvelopeCompleteness { + * generator side. + */ + export function completeness(identityOnly: boolean): EnvelopeCompleteness { +- return { wholeCatalog: WHOLE_CATALOG_COMPLETENESS, identityOnly }; ++ return { wholeCatalog: true, identityOnly }; + } + + /** diff --git a/specs/010-catalog-backstage/evidence/negative-cases/envelope-invariants/case-2-entity-record-spreads-instead-of-projecting.observed.txt b/specs/010-catalog-backstage/evidence/negative-cases/envelope-invariants/case-2-entity-record-spreads-instead-of-projecting.observed.txt new file mode 100644 index 00000000..91054c21 --- /dev/null +++ b/specs/010-catalog-backstage/evidence/negative-cases/envelope-invariants/case-2-entity-record-spreads-instead-of-projecting.observed.txt @@ -0,0 +1,120 @@ + +packages/adapters/catalog-backstage/test/sc-013.test.ts: +(pass) T085 / SC-013 — exactly one envelope is produced > the destination directory holds exactly one file [0.11ms] +(pass) T085 / SC-013 — exactly one envelope is produced > that file is one envelope, not an array or a stream of them [0.02ms] +(pass) T085 / SC-013 — exactly one envelope is produced > it covers exactly one repository [0.01ms] +(pass) T085 / SC-013 — each entity record carries exactly five fields > the fixture has several entities, so the check is not about one record +132 | expect((parsed['entities'] as unknown[]).length).toBe(3); +133 | }); +134 | +135 | test('every record has exactly the five defined fields', () => { +136 | for (const entity of parsed['entities'] as Record[]) { +137 | expect(Object.keys(entity)).toHaveLength(5); + ^ +error: expect(received).toHaveLength(expected) + +Expected length: 5 +Received length: 9 + + at (/Users/markbeacom/github/mbeacom/copilot-worktrees/adrkit/mbeacom-potential-potato/packages/adapters/catalog-backstage/test/sc-013.test.ts:137:35) +(fail) T085 / SC-013 — each entity record carries exactly five fields > every record has exactly the five defined fields [0.41ms] +(pass) T085 / SC-013 — each entity record carries exactly five fields > no snapshot-shaped artifact was written alongside it [0.11ms] +(pass) T085 / SC-013 — the digest matches an independent recomputation > the independent recomputation agrees with the recorded digest [0.18ms] +(pass) T085 / SC-013 — the digest matches an independent recomputation > the recomputation is genuinely independent — it detects a mutation [0.13ms] +(pass) T085 / SC-013 — the digest matches an independent recomputation > the recomputation is order-insensitive at every nesting level [0.07ms] +(pass) T085 / SC-013 — the digest matches an independent recomputation > the digest is 64 lowercase hex characters [0.04ms] +(pass) T085 — the scope of what a matching digest establishes > a match establishes integrity, and this test claims nothing more [0.04ms] + +packages/adapters/catalog-backstage/test/envelope-shape.test.ts: +(pass) T080 — the fixture is rich enough for the checks to mean something > three entities, spanning several kinds, namespaces and ownership states [0.06ms] +(pass) T080 / data-model.md §9 — nine top-level fields > the declared list has nine entries +(pass) T080 / data-model.md §9 — nine top-level fields > the emitted object carries exactly those nine, no more and no fewer [0.01ms] +(pass) T080 / data-model.md §9 — nine top-level fields > field order in the emitted JSON follows the contract’s order +(pass) T080 / data-model.md §9 — nine top-level fields > schemaVersion and capabilities are the exact values the consumer validates [0.03ms] +(pass) T080 / data-model.md §9 — nine top-level fields > globDialect records the engine and options actually used [0.04ms] +(pass) T080 / data-model.md §9 — nine top-level fields > the nested objects carry their declared fields [0.03ms] +(pass) T080 / data-model.md §10 — exactly five fields per entity record > the declared list has five entries +117 | expect(ENTITY_RECORD_FIELDS).toHaveLength(5); +118 | }); +119 | +120 | test('every emitted record carries exactly those five', () => { +121 | for (const entity of parsed['entities'] as Record[]) { +122 | expect(Object.keys(entity).sort()).toEqual([...ENTITY_RECORD_FIELDS].sort()); + ^ +error: expect(received).toEqual(expected) + + [ ++ "allRefs", ++ "canonicalId", + "derivedPaths", ++ "documentIndexInFile", + "identity", + "ownershipState", + "provenance", + "sourceDocument", ++ "sourcePath", + ] + +- Expected - 0 ++ Received + 4 + + at (/Users/markbeacom/github/mbeacom/copilot-worktrees/adrkit/mbeacom-potential-potato/packages/adapters/catalog-backstage/test/envelope-shape.test.ts:122:42) +(fail) T080 / data-model.md §10 — exactly five fields per entity record > every emitted record carries exactly those five [0.11ms] +(pass) T080 / data-model.md §10 — exactly five fields per entity record > the identity projection is `{ canonicalId, allRefs }` and nothing else [0.03ms] +(pass) T080 / data-model.md §10 — exactly five fields per entity record > the sourceDocument reference is `{ sourcePath, documentIndexInFile }` [0.02ms] +(pass) T080 / data-model.md §10 — exactly five fields per entity record > a multi-document file yields distinct documentIndexInFile values [0.02ms] +155 | +156 | describe('T080 — the flatter shape is forbidden', () => { +157 | test('no entity record carries a flat canonicalId, refs or paths field', () => { +158 | for (const entity of parsed['entities'] as Record[]) { +159 | for (const forbidden of FORBIDDEN_FLAT_ENTITY_FIELDS) { +160 | expect(Object.hasOwn(entity, forbidden)).toBe(false); + ^ +error: expect(received).toBe(expected) + +Expected: false +Received: true + + at (/Users/markbeacom/github/mbeacom/copilot-worktrees/adrkit/mbeacom-potential-potato/packages/adapters/catalog-backstage/test/envelope-shape.test.ts:160:50) +(fail) T080 — the flatter shape is forbidden > no entity record carries a flat canonicalId, refs or paths field [0.07ms] +(pass) T080 — the flatter shape is forbidden > the forbidden list is checked by name, not inferred from a field count [0.01ms] +(pass) T080 — the flatter shape is forbidden > the authoring fields §1 excludes are not serialized [0.01ms] +194 | // A field the projection must ignore. A spread-based implementation would carry +195 | // it through, which is the failure this check exists for. +196 | ...({ rawKind: 'Component' } as unknown as Record), +197 | }); +198 | +199 | expect(Object.keys(record).sort()).toEqual([...ENTITY_RECORD_FIELDS].sort()); + ^ +error: expect(received).toEqual(expected) + + [ ++ "allRefs", ++ "canonicalId", + "derivedPaths", ++ "documentIndexInFile", + "identity", + "ownershipState", + "provenance", ++ "rawKind", + "sourceDocument", ++ "sourcePath", + ] + +- Expected - 0 ++ Received + 5 + + at (/Users/markbeacom/github/mbeacom/copilot-worktrees/adrkit/mbeacom-potential-potato/packages/adapters/catalog-backstage/test/envelope-shape.test.ts:199:40) +(fail) T080 — entityRecord projects rather than spreads > an input carrying extra fields does not leak them into the record [0.05ms] + +4 tests failed: +(fail) T085 / SC-013 — each entity record carries exactly five fields > every record has exactly the five defined fields [0.41ms] +(fail) T080 / data-model.md §10 — exactly five fields per entity record > every emitted record carries exactly those five [0.11ms] +(fail) T080 — the flatter shape is forbidden > no entity record carries a flat canonicalId, refs or paths field [0.07ms] +(fail) T080 — entityRecord projects rather than spreads > an input carrying extra fields does not leak them into the record [0.05ms] + + 23 pass + 4 fail + 74 expect() calls +Ran 27 tests across 2 files. [139.00ms] +bun test v1.3.14 (0d9b296a) diff --git a/specs/010-catalog-backstage/evidence/negative-cases/envelope-invariants/case-2-entity-record-spreads-instead-of-projecting.patch b/specs/010-catalog-backstage/evidence/negative-cases/envelope-invariants/case-2-entity-record-spreads-instead-of-projecting.patch new file mode 100644 index 00000000..096ef25e --- /dev/null +++ b/specs/010-catalog-backstage/evidence/negative-cases/envelope-invariants/case-2-entity-record-spreads-instead-of-projecting.patch @@ -0,0 +1,12 @@ +diff --git a/packages/adapters/catalog-backstage/src/envelope/shape.ts b/packages/adapters/catalog-backstage/src/envelope/shape.ts +index efc794a..57e02af 100644 +--- a/packages/adapters/catalog-backstage/src/envelope/shape.ts ++++ b/packages/adapters/catalog-backstage/src/envelope/shape.ts +@@ -193,6 +193,7 @@ export interface EntityRecordInput { + */ + export function entityRecord(input: EntityRecordInput): SnapshotEntityRecord { + return { ++ ...input, + identity: { canonicalId: input.canonicalId, allRefs: input.allRefs }, + ownershipState: input.ownershipState, + derivedPaths: input.derivedPaths, diff --git a/specs/010-catalog-backstage/evidence/negative-cases/envelope-invariants/case-3-digest-over-a-non-canonical-serialization.observed.txt b/specs/010-catalog-backstage/evidence/negative-cases/envelope-invariants/case-3-digest-over-a-non-canonical-serialization.observed.txt new file mode 100644 index 00000000..dc3585af --- /dev/null +++ b/specs/010-catalog-backstage/evidence/negative-cases/envelope-invariants/case-3-digest-over-a-non-canonical-serialization.observed.txt @@ -0,0 +1,109 @@ + +packages/adapters/catalog-backstage/test/sc-013.test.ts: +(pass) T085 / SC-013 — exactly one envelope is produced > the destination directory holds exactly one file [0.11ms] +(pass) T085 / SC-013 — exactly one envelope is produced > that file is one envelope, not an array or a stream of them [0.02ms] +(pass) T085 / SC-013 — exactly one envelope is produced > it covers exactly one repository [0.01ms] +(pass) T085 / SC-013 — each entity record carries exactly five fields > the fixture has several entities, so the check is not about one record +(pass) T085 / SC-013 — each entity record carries exactly five fields > every record has exactly the five defined fields [0.03ms] +(pass) T085 / SC-013 — each entity record carries exactly five fields > no snapshot-shaped artifact was written alongside it [0.11ms] +152 | }); +153 | }); +154 | +155 | describe('T085 / SC-013 — the digest matches an independent recomputation', () => { +156 | test('the independent recomputation agrees with the recorded digest', () => { +157 | expect(recomputeIndependently(parsed)).toBe(parsed['digest'] as string); + ^ +error: expect(received).toBe(expected) + +Expected: "776f6d9595df76be1634bed882c7e5b6ea9233d1eeb13a8b4502faa708392eb2" +Received: "66e635e3565ae4201327e664ee4ff259f31c7779f379fa1273ec2a51028d438d" + + at (/Users/markbeacom/github/mbeacom/copilot-worktrees/adrkit/mbeacom-potential-potato/packages/adapters/catalog-backstage/test/sc-013.test.ts:157:44) +(fail) T085 / SC-013 — the digest matches an independent recomputation > the independent recomputation agrees with the recorded digest [0.33ms] +(pass) T085 / SC-013 — the digest matches an independent recomputation > the recomputation is genuinely independent — it detects a mutation [0.15ms] +176 | // than the one intended. +177 | const reordered: Record = {}; +178 | for (const key of [...Object.keys(parsed)].reverse()) reordered[key] = parsed[key]; +179 | +180 | expect(Object.keys(reordered)).not.toEqual(Object.keys(parsed)); +181 | expect(recomputeIndependently(reordered)).toBe(parsed['digest'] as string); + ^ +error: expect(received).toBe(expected) + +Expected: "776f6d9595df76be1634bed882c7e5b6ea9233d1eeb13a8b4502faa708392eb2" +Received: "66e635e3565ae4201327e664ee4ff259f31c7779f379fa1273ec2a51028d438d" + + at (/Users/markbeacom/github/mbeacom/copilot-worktrees/adrkit/mbeacom-potential-potato/packages/adapters/catalog-backstage/test/sc-013.test.ts:181:47) +(fail) T085 / SC-013 — the digest matches an independent recomputation > the recomputation is order-insensitive at every nesting level [0.16ms] +(pass) T085 / SC-013 — the digest matches an independent recomputation > the digest is 64 lowercase hex characters [0.01ms] +(pass) T085 — the scope of what a matching digest establishes > a match establishes integrity, and this test claims nothing more [0.03ms] + +packages/adapters/catalog-backstage/test/envelope-digest.test.ts: +(pass) T081 — the rendering `snapshot-envelope.md` §3 requires > 64 lowercase hexadecimal characters [0.05ms] +(pass) T081 — the rendering `snapshot-envelope.md` §3 requires > the algorithm is sha256 +(pass) T081 — the digest field itself is excluded from its own input > the canonical form has no top-level `digest` key [0.06ms] +(pass) T081 — the digest field itself is excluded from its own input > the canonical form does contain every other top-level field [0.06ms] +(pass) T081 — the digest field itself is excluded from its own input > `sources[].digest` is not excluded — only the envelope’s own digest is [0.02ms] +109 | const form = canonicalEnvelopeForm(unsigned); +110 | +111 | // Top level: the emitted envelope declares `schemaVersion` first, so a canonical +112 | // form starting with `capabilities` is evidence the sort actually happened rather +113 | // than the declaration order being reused. +114 | expect(form.startsWith('{"capabilities"')).toBe(true); + ^ +error: expect(received).toBe(expected) + +Expected: true +Received: false + + at (/Users/markbeacom/github/mbeacom/copilot-worktrees/adrkit/mbeacom-potential-potato/packages/adapters/catalog-backstage/test/envelope-digest.test.ts:114:48) +(fail) T081 — the canonical form is canonical > keys are sorted by code units at every nesting level [0.07ms] +(pass) T081 — the canonical form is canonical > arrays keep their declaration order and are never re-sorted [0.08ms] +(pass) T081 — the canonical form is canonical > the serialization is compact — no insignificant whitespace [0.02ms] +(pass) T081 — the canonical form is canonical > an undefined field is omitted rather than serialized as null [0.01ms] +144 | test('recomputing from the canonical form with node:crypto matches the recorded digest', () => { +145 | // Independent of `envelope/digest.ts`'s own helper: this hashes the canonical form +146 | // directly, so a fault in `computeEnvelopeDigest`'s wrapper is visible. +147 | const { digest: declared, ...unsigned } = envelope; +148 | const recomputed = createHash('sha256').update(canonicalStringify(unsigned), 'utf8').digest('hex'); +149 | expect(recomputed).toBe(declared); + ^ +error: expect(received).toBe(expected) + +Expected: "3bad01678c3a3375d3bba76ad4e7efa8ed8abac0e98d93f2f957af27e62b7776" +Received: "06a08e28f59138bb50a0295f93903dfddb04b12c4d6848744fb1c550eabd548f" + + at (/Users/markbeacom/github/mbeacom/copilot-worktrees/adrkit/mbeacom-potential-potato/packages/adapters/catalog-backstage/test/envelope-digest.test.ts:149:24) +(fail) T081 — an independent recomputation agrees > recomputing from the canonical form with node:crypto matches the recorded digest [0.13ms] +(pass) T081 — an independent recomputation agrees > verifyEnvelopeDigest reports a match on an untouched envelope [0.03ms] +(pass) T081 — an independent recomputation agrees > a naive mutation is detected [0.03ms] +(pass) T081 — an independent recomputation agrees > an adversary who recomputes the digest is NOT detected, as FR-041 states [0.04ms] +(pass) T081 — an independent recomputation agrees > verifyEnvelopeDigest does not mutate its input [0.02ms] +200 | globDialect: unsigned.globDialect, +201 | generatorVersion: unsigned.generatorVersion, +202 | repository: unsigned.repository, +203 | schemaVersion: unsigned.schemaVersion, +204 | }; +205 | expect(computeEnvelopeDigest(reordered)).toBe(declared); + ^ +error: expect(received).toBe(expected) + +Expected: "3bad01678c3a3375d3bba76ad4e7efa8ed8abac0e98d93f2f957af27e62b7776" +Received: "9575e11c46cfd56783edcddaf48cce635d784da87ae6a104e2c5c4448e6cd1e5" + + at (/Users/markbeacom/github/mbeacom/copilot-worktrees/adrkit/mbeacom-potential-potato/packages/adapters/catalog-backstage/test/envelope-digest.test.ts:205:46) +(fail) T081 — the digest is a function of content, not of field order > reordering top-level keys leaves the digest unchanged [0.07ms] +(pass) T081 — the digest is a function of content, not of field order > changing any content changes the digest [0.01ms] + +5 tests failed: +(fail) T085 / SC-013 — the digest matches an independent recomputation > the independent recomputation agrees with the recorded digest [0.33ms] +(fail) T085 / SC-013 — the digest matches an independent recomputation > the recomputation is order-insensitive at every nesting level [0.16ms] +(fail) T081 — the canonical form is canonical > keys are sorted by code units at every nesting level [0.07ms] +(fail) T081 — an independent recomputation agrees > recomputing from the canonical form with node:crypto matches the recorded digest [0.13ms] +(fail) T081 — the digest is a function of content, not of field order > reordering top-level keys leaves the digest unchanged [0.07ms] + + 22 pass + 5 fail + 62 expect() calls +Ran 27 tests across 2 files. [142.00ms] +bun test v1.3.14 (0d9b296a) diff --git a/specs/010-catalog-backstage/evidence/negative-cases/envelope-invariants/case-3-digest-over-a-non-canonical-serialization.patch b/specs/010-catalog-backstage/evidence/negative-cases/envelope-invariants/case-3-digest-over-a-non-canonical-serialization.patch new file mode 100644 index 00000000..17cc0a80 --- /dev/null +++ b/specs/010-catalog-backstage/evidence/negative-cases/envelope-invariants/case-3-digest-over-a-non-canonical-serialization.patch @@ -0,0 +1,13 @@ +diff --git a/packages/adapters/catalog-backstage/src/envelope/digest.ts b/packages/adapters/catalog-backstage/src/envelope/digest.ts +index 2602599..397672e 100644 +--- a/packages/adapters/catalog-backstage/src/envelope/digest.ts ++++ b/packages/adapters/catalog-backstage/src/envelope/digest.ts +@@ -69,7 +69,7 @@ export const DIGEST_ALGORITHM = 'sha256'; + * *where* two envelopes differ; comparing digests only says *that* they differ. + */ + export function canonicalEnvelopeForm(envelope: UnsignedEnvelope): string { +- return canonicalStringify(envelope); ++ return JSON.stringify(envelope); + } + + /** diff --git a/specs/010-catalog-backstage/evidence/negative-cases/envelope-invariants/case-4-provenance-defaulted-to-upstream-authored.observed.txt b/specs/010-catalog-backstage/evidence/negative-cases/envelope-invariants/case-4-provenance-defaulted-to-upstream-authored.observed.txt new file mode 100644 index 00000000..76eb8a7b --- /dev/null +++ b/specs/010-catalog-backstage/evidence/negative-cases/envelope-invariants/case-4-provenance-defaulted-to-upstream-authored.observed.txt @@ -0,0 +1,36 @@ + +packages/adapters/catalog-backstage/test/envelope-provenance.test.ts: +(pass) T082 — the domain is closed at exactly two values > `data-model.md` §10’s two values, and no third [0.03ms] +(pass) T082 — the domain is closed at exactly two values > a value outside the domain is rejected rather than passed through [0.11ms] +(pass) T082 — the declaration is exhaustive and has no default > a complete declaration passes [0.03ms] +(pass) T082 — the declaration is exhaustive and has no default > a missing entry is rejected, never defaulted [0.02ms] +(pass) T082 — the declaration is exhaustive and has no default > the rejection explains why no default is available [0.01ms] +(pass) T082 — the declaration is exhaustive and has no default > the reported path does not depend on key insertion order [0.01ms] +78 | expect(first.rejection.detail).toBe(second.rejection.detail); +79 | expect(first.rejection.detail).toContain('a.yaml'); +80 | }); +81 | +82 | test('provenanceFor throws rather than substituting a value', () => { +83 | expect(() => provenanceFor({ bySourcePath: {} }, 'a.yaml')).toThrow( + ^ +error: expect(received).toThrow(expected) + +Expected substring: "no annotation provenance declared" + +Received function did not throw +Received value: "upstream-authored" + + at (/Users/markbeacom/github/mbeacom/copilot-worktrees/adrkit/mbeacom-potential-potato/packages/adapters/catalog-backstage/test/envelope-provenance.test.ts:83:65) +(fail) T082 — the declaration is exhaustive and has no default > provenanceFor throws rather than substituting a value [0.35ms] +(pass) T082 — the two provenances survive side by side, unmerged > one envelope carries both values, each on its own entity [17.47ms] +(pass) T082 — the two provenances survive side by side, unmerged > the frozen accept corpus’ own construction maps to maintainer-overlay [9.05ms] +(pass) T082 — provenance alone is not an adoption claim; the pair is > an annotation-absent entity is never an adoption claim, whatever its provenance [0.03ms] +(pass) T082 — provenance alone is not an adoption claim; the pair is > an existing annotation declared upstream-authored IS an adoption claim +(pass) T082 — provenance alone is not an adoption claim; the pair is > an existing annotation declared maintainer-overlay is not +(pass) T082 — provenance alone is not an adoption claim; the pair is > a run over an unannotated corpus makes no adoption claim [8.25ms] + + 12 pass + 1 fail + 27 expect() calls +Ran 13 tests across 1 file. [117.00ms] +bun test v1.3.14 (0d9b296a) diff --git a/specs/010-catalog-backstage/evidence/negative-cases/envelope-invariants/case-4-provenance-defaulted-to-upstream-authored.patch b/specs/010-catalog-backstage/evidence/negative-cases/envelope-invariants/case-4-provenance-defaulted-to-upstream-authored.patch new file mode 100644 index 00000000..62be5cb1 --- /dev/null +++ b/specs/010-catalog-backstage/evidence/negative-cases/envelope-invariants/case-4-provenance-defaulted-to-upstream-authored.patch @@ -0,0 +1,15 @@ +diff --git a/packages/adapters/catalog-backstage/src/envelope/provenance.ts b/packages/adapters/catalog-backstage/src/envelope/provenance.ts +index 1631479..3096f20 100644 +--- a/packages/adapters/catalog-backstage/src/envelope/provenance.ts ++++ b/packages/adapters/catalog-backstage/src/envelope/provenance.ts +@@ -192,8 +192,8 @@ export function provenanceFor( + declaration: ProvenanceDeclaration, + sourcePath: string, + ): AnnotationProvenance { +- const value = declaration.bySourcePath[sourcePath]; +- if (value === undefined) { ++ const value = declaration.bySourcePath[sourcePath] ?? 'upstream-authored'; ++ if (false) { + throw new Error( + `no annotation provenance declared for ${JSON.stringify(sourcePath)}. ` + + 'checkProvenanceDeclaration must run before any entity record is built.', diff --git a/specs/010-catalog-backstage/evidence/negative-cases/envelope-invariants/case-5-a-side-file-written-alongside-the-envelope.observed.txt b/specs/010-catalog-backstage/evidence/negative-cases/envelope-invariants/case-5-a-side-file-written-alongside-the-envelope.observed.txt new file mode 100644 index 00000000..4b993311 --- /dev/null +++ b/specs/010-catalog-backstage/evidence/negative-cases/envelope-invariants/case-5-a-side-file-written-alongside-the-envelope.observed.txt @@ -0,0 +1,158 @@ + +packages/adapters/catalog-backstage/test/envelope-only.test.ts: +53 | describe('T079 — exactly one file is written', () => { +54 | test('a successful run leaves one file, and it is the envelope', async () => { +55 | const directory = join(output, 'one-file'); +56 | await generateInto(directory, 'onlyone'); +57 | +58 | expect((await readdir(directory)).sort()).toEqual(['envelope.json']); + ^ +error: expect(received).toEqual(expected) + + [ + "envelope.json", ++ "envelope.json.stages.log", + ] + +- Expected - 0 ++ Received + 1 + + at (/Users/markbeacom/github/mbeacom/copilot-worktrees/adrkit/mbeacom-potential-potato/packages/adapters/catalog-backstage/test/envelope-only.test.ts:58:47) +(fail) T079 — exactly one file is written > a successful run leaves one file, and it is the envelope [19.07ms] +62 | // Catches an implementation that accumulates timestamped or numbered side files. +63 | const directory = join(output, 'twice'); +64 | await generateInto(directory, 'twicea'); +65 | await generateInto(directory, 'twiceb'); +66 | +67 | expect((await readdir(directory)).sort()).toEqual(['envelope.json']); + ^ +error: expect(received).toEqual(expected) + + [ + "envelope.json", ++ "envelope.json.stages.log", + ] + +- Expected - 0 ++ Received + 1 + + at (/Users/markbeacom/github/mbeacom/copilot-worktrees/adrkit/mbeacom-potential-potato/packages/adapters/catalog-backstage/test/envelope-only.test.ts:67:47) +(fail) T079 — exactly one file is written > a second run into the same directory still leaves one file [19.95ms] +72 | const directory = join(output, 'preexisting'); +73 | await generateInto(directory, 'preexistinga'); +74 | await writeFile(join(directory, 'unrelated.txt'), 'kept', 'utf8'); +75 | await generateInto(directory, 'preexistingb'); +76 | +77 | expect((await readdir(directory)).sort()).toEqual(['envelope.json', 'unrelated.txt']); + ^ +error: expect(received).toEqual(expected) + + [ + "envelope.json", ++ "envelope.json.stages.log", + "unrelated.txt", + ] + +- Expected - 0 ++ Received + 1 + + at (/Users/markbeacom/github/mbeacom/copilot-worktrees/adrkit/mbeacom-potential-potato/packages/adapters/catalog-backstage/test/envelope-only.test.ts:77:47) +(fail) T079 — exactly one file is written > a pre-existing unrelated file is left alone rather than cleaned up [19.85ms] +(pass) T079 — exactly one file is written > the write reports the one path it wrote [8.66ms] +(pass) T079 — no CatalogSnapshot-shaped artifact is ever written > the written file is envelope-shaped and not snapshot-shaped [9.25ms] +(pass) T079 — no CatalogSnapshot-shaped artifact is ever written > no adapter source names the core catalog snapshot types [6.88ms] +(pass) T079 — no CatalogSnapshot-shaped artifact is ever written > that rule has been observed firing, so its silence means something [0.03ms] +168 | expect(outcome.stages.length).toBeGreaterThan(0); +169 | +170 | await generateAndWriteEnvelope(request, join(directory, 'envelope.json')); +171 | const written = await Bun.file(join(directory, 'envelope.json')).text(); +172 | expect(written).not.toContain('"stages"'); +173 | expect((await readdir(directory)).sort()).toEqual(['envelope.json']); + ^ +error: expect(received).toEqual(expected) + + [ + "envelope.json", ++ "envelope.json.stages.log", + ] + +- Expected - 0 ++ Received + 1 + + at (/Users/markbeacom/github/mbeacom/copilot-worktrees/adrkit/mbeacom-potential-potato/packages/adapters/catalog-backstage/test/envelope-only.test.ts:173:47) +(fail) T079 — diagnostics are returned, never written > the stage trace is a returned value and appears in no file [18.03ms] +(pass) T079 — diagnostics are returned, never written > the serialization contains only the envelope’s own fields [9.03ms] +202 | +203 | const directory = join(output, 'deep', 'deeper', 'deepest'); +204 | const result = await writeEnvelope(outcome.envelope, join(directory, 'envelope.json')); +205 | +206 | expect(result.path).toBe(join(directory, 'envelope.json')); +207 | expect((await readdir(directory)).sort()).toEqual(['envelope.json']); + ^ +error: expect(received).toEqual(expected) + + [ + "envelope.json", ++ "envelope.json.stages.log", + ] + +- Expected - 0 ++ Received + 1 + + at (/Users/markbeacom/github/mbeacom/copilot-worktrees/adrkit/mbeacom-potential-potato/packages/adapters/catalog-backstage/test/envelope-only.test.ts:207:47) +(fail) T079 — writeEnvelope writes one file and creates its directory > a nested destination directory is created [9.21ms] + +packages/adapters/catalog-backstage/test/sc-013.test.ts: +108 | return createHash('sha256').update(canonicalize(unsigned), 'utf8').digest('hex'); +109 | } +110 | +111 | describe('T085 / SC-013 — exactly one envelope is produced', () => { +112 | test('the destination directory holds exactly one file', async () => { +113 | expect((await readdir(join(output, 'sc013'))).sort()).toEqual(['envelope.json']); + ^ +error: expect(received).toEqual(expected) + + [ + "envelope.json", ++ "envelope.json.stages.log", + ] + +- Expected - 0 ++ Received + 1 + + at (/Users/markbeacom/github/mbeacom/copilot-worktrees/adrkit/mbeacom-potential-potato/packages/adapters/catalog-backstage/test/sc-013.test.ts:113:59) +(fail) T085 / SC-013 — exactly one envelope is produced > the destination directory holds exactly one file [0.17ms] +(pass) T085 / SC-013 — exactly one envelope is produced > that file is one envelope, not an array or a stream of them [0.03ms] +(pass) T085 / SC-013 — exactly one envelope is produced > it covers exactly one repository [0.01ms] +(pass) T085 / SC-013 — each entity record carries exactly five fields > the fixture has several entities, so the check is not about one record +(pass) T085 / SC-013 — each entity record carries exactly five fields > every record has exactly the five defined fields [0.03ms] +142 | test('no snapshot-shaped artifact was written alongside it', async () => { +143 | // SC-013's second clause. Checked from the directory, so a second file of any +144 | // shape would fail it. The forbidden core type is not named literally here: the +145 | // guard that must name it is `test/envelope-only.test.ts`, which is listed in +146 | // `EXCLUDED_FROM_SCAN` for exactly that reason. +147 | expect((await readdir(join(output, 'sc013'))).sort()).toEqual(['envelope.json']); + ^ +error: expect(received).toEqual(expected) + + [ + "envelope.json", ++ "envelope.json.stages.log", + ] + +- Expected - 0 ++ Received + 1 + + at (/Users/markbeacom/github/mbeacom/copilot-worktrees/adrkit/mbeacom-potential-potato/packages/adapters/catalog-backstage/test/sc-013.test.ts:147:59) +(fail) T085 / SC-013 — each entity record carries exactly five fields > no snapshot-shaped artifact was written alongside it [0.13ms] +(pass) T085 / SC-013 — the digest matches an independent recomputation > the independent recomputation agrees with the recorded digest [0.16ms] +(pass) T085 / SC-013 — the digest matches an independent recomputation > the recomputation is genuinely independent — it detects a mutation [0.12ms] +(pass) T085 / SC-013 — the digest matches an independent recomputation > the recomputation is order-insensitive at every nesting level [0.07ms] +(pass) T085 / SC-013 — the digest matches an independent recomputation > the digest is 64 lowercase hex characters [0.01ms] +(pass) T085 — the scope of what a matching digest establishes > a match establishes integrity, and this test claims nothing more [0.04ms] + + 14 pass + 7 fail + 49 expect() calls +Ran 21 tests across 2 files. [250.00ms] +bun test v1.3.14 (0d9b296a) diff --git a/specs/010-catalog-backstage/evidence/negative-cases/envelope-invariants/case-5-a-side-file-written-alongside-the-envelope.patch b/specs/010-catalog-backstage/evidence/negative-cases/envelope-invariants/case-5-a-side-file-written-alongside-the-envelope.patch new file mode 100644 index 00000000..41d355e8 --- /dev/null +++ b/specs/010-catalog-backstage/evidence/negative-cases/envelope-invariants/case-5-a-side-file-written-alongside-the-envelope.patch @@ -0,0 +1,11 @@ +diff --git a/packages/adapters/catalog-backstage/src/envelope/write.ts b/packages/adapters/catalog-backstage/src/envelope/write.ts +index da9291d..04d86dd 100644 +--- a/packages/adapters/catalog-backstage/src/envelope/write.ts ++++ b/packages/adapters/catalog-backstage/src/envelope/write.ts +@@ -114,5 +114,6 @@ export async function writeEnvelope( + throw error; + } + ++ await Bun.write(`${destination}.stages.log`, 'manifest\nrepository\n'); + return { path: destination, byteLength: bytes.byteLength }; + } diff --git a/specs/010-catalog-backstage/evidence/negative-cases/envelope-invariants/case-6-overlap-resolved-by-an-exclusive-winner.observed.txt b/specs/010-catalog-backstage/evidence/negative-cases/envelope-invariants/case-6-overlap-resolved-by-an-exclusive-winner.observed.txt new file mode 100644 index 00000000..d6b84fc6 --- /dev/null +++ b/specs/010-catalog-backstage/evidence/negative-cases/envelope-invariants/case-6-overlap-resolved-by-an-exclusive-winner.observed.txt @@ -0,0 +1,50 @@ + +packages/adapters/catalog-backstage/test/overlap.test.ts: +(pass) T072 — the fixture genuinely overlaps > overlap is present, so nothing below passes vacuously [0.16ms] +(pass) T072 — the fixture genuinely overlaps > the overlapping pattern is named, and both claimants are listed [0.03ms] +(pass) T072 — the fixture genuinely overlaps > a set with no shared pattern reports no overlap [0.02ms] +(pass) T072 — the fixture genuinely overlaps > one entity listing a pattern twice is not an overlap with itself +(pass) T072 / §4 — overlap does not trigger the abort > the distinct canonical ids pass the uniqueness check [0.12ms] +(pass) T072 / §4 — overlap does not trigger the abort > a real run over two overlapping entities produces an envelope with both [17.25ms] + 95 | }); + 96 | + 97 | describe('T072 / §4 — no exclusive winner: a changed file is owned by every match', () => { + 98 | test('both entities own a file matching the shared pattern', () => { + 99 | const owners = ownersOf(CLAIMS, 'packages/shared/util.ts', createGlobCompiler()); +100 | expect(owners).toEqual(['component:default/billing', 'component:default/invoicing']); + ^ +error: expect(received).toEqual(expected) + + [ + "component:default/billing", +- "component:default/invoicing", + ] + +- Expected - 1 ++ Received + 0 + + at (/Users/markbeacom/github/mbeacom/copilot-worktrees/adrkit/mbeacom-potential-potato/packages/adapters/catalog-backstage/test/overlap.test.ts:100:20) +(fail) T072 / §4 — no exclusive winner: a changed file is owned by every match > both entities own a file matching the shared pattern [0.35ms] +(pass) T072 / §4 — no exclusive winner: a changed file is owned by every match > a file matching only one pattern is owned by only that entity [0.08ms] +(pass) T072 / §4 — no exclusive winner: a changed file is owned by every match > a file matching nothing is owned by nobody [0.11ms] +116 | test('the result is a list, so a caller cannot read "the owner" off it', () => { +117 | const owners = ownersOf(CLAIMS, 'packages/shared/util.ts', createGlobCompiler()); +118 | expect(Array.isArray(owners)).toBe(true); +119 | // ADR-0009's union-not-winner semantics, mirrored: the second entity is not a +120 | // runner-up, it is an owner. +121 | expect(owners[1]).toBe('component:default/invoicing'); + ^ +error: expect(received).toBe(expected) + +Expected: "component:default/invoicing" +Received: undefined + + at (/Users/markbeacom/github/mbeacom/copilot-worktrees/adrkit/mbeacom-potential-potato/packages/adapters/catalog-backstage/test/overlap.test.ts:121:23) +(fail) T072 / §4 — no exclusive winner: a changed file is owned by every match > the result is a list, so a caller cannot read "the owner" off it [0.10ms] +(pass) T072 / §4 — no exclusive winner: a changed file is owned by every match > ownership does not depend on the order the claims are listed in [0.12ms] + + 9 pass + 2 fail + 16 expect() calls +Ran 11 tests across 1 file. [100.00ms] +bun test v1.3.14 (0d9b296a) diff --git a/specs/010-catalog-backstage/evidence/negative-cases/envelope-invariants/case-6-overlap-resolved-by-an-exclusive-winner.patch b/specs/010-catalog-backstage/evidence/negative-cases/envelope-invariants/case-6-overlap-resolved-by-an-exclusive-winner.patch new file mode 100644 index 00000000..f234c081 --- /dev/null +++ b/specs/010-catalog-backstage/evidence/negative-cases/envelope-invariants/case-6-overlap-resolved-by-an-exclusive-winner.patch @@ -0,0 +1,13 @@ +diff --git a/packages/adapters/catalog-backstage/src/identity/overlap.ts b/packages/adapters/catalog-backstage/src/identity/overlap.ts +index 1b64db4..ed870bf 100644 +--- a/packages/adapters/catalog-backstage/src/identity/overlap.ts ++++ b/packages/adapters/catalog-backstage/src/identity/overlap.ts +@@ -72,7 +72,7 @@ export function ownersOf( + ) + .map((claim) => claim.canonicalId); + +- return [...new Set(owners)].sort(compareCodeUnits); ++ return [...new Set(owners)].sort(compareCodeUnits).slice(0, 1); + } + + /** One pattern shared by two or more distinct canonical ids. */ diff --git a/specs/010-catalog-backstage/evidence/negative-cases/envelope-invariants/restored.observed.txt b/specs/010-catalog-backstage/evidence/negative-cases/envelope-invariants/restored.observed.txt new file mode 100644 index 00000000..c211b326 --- /dev/null +++ b/specs/010-catalog-backstage/evidence/negative-cases/envelope-invariants/restored.observed.txt @@ -0,0 +1,106 @@ +bun test v1.3.14 (0d9b296a) + +test/envelope-provenance.test.ts: +(pass) T082 — the domain is closed at exactly two values > `data-model.md` §10’s two values, and no third [0.04ms] +(pass) T082 — the domain is closed at exactly two values > a value outside the domain is rejected rather than passed through [0.11ms] +(pass) T082 — the declaration is exhaustive and has no default > a complete declaration passes [0.03ms] +(pass) T082 — the declaration is exhaustive and has no default > a missing entry is rejected, never defaulted [0.02ms] +(pass) T082 — the declaration is exhaustive and has no default > the rejection explains why no default is available [0.01ms] +(pass) T082 — the declaration is exhaustive and has no default > the reported path does not depend on key insertion order [0.02ms] +(pass) T082 — the declaration is exhaustive and has no default > provenanceFor throws rather than substituting a value [0.04ms] +(pass) T082 — the two provenances survive side by side, unmerged > one envelope carries both values, each on its own entity [17.13ms] +(pass) T082 — the two provenances survive side by side, unmerged > the frozen accept corpus’ own construction maps to maintainer-overlay [8.41ms] +(pass) T082 — provenance alone is not an adoption claim; the pair is > an annotation-absent entity is never an adoption claim, whatever its provenance [0.02ms] +(pass) T082 — provenance alone is not an adoption claim; the pair is > an existing annotation declared upstream-authored IS an adoption claim +(pass) T082 — provenance alone is not an adoption claim; the pair is > an existing annotation declared maintainer-overlay is not +(pass) T082 — provenance alone is not an adoption claim; the pair is > a run over an unannotated corpus makes no adoption claim [8.43ms] + +test/overlap.test.ts: +(pass) T072 — the fixture genuinely overlaps > overlap is present, so nothing below passes vacuously [0.18ms] +(pass) T072 — the fixture genuinely overlaps > the overlapping pattern is named, and both claimants are listed [0.04ms] +(pass) T072 — the fixture genuinely overlaps > a set with no shared pattern reports no overlap [0.02ms] +(pass) T072 — the fixture genuinely overlaps > one entity listing a pattern twice is not an overlap with itself [0.01ms] +(pass) T072 / §4 — overlap does not trigger the abort > the distinct canonical ids pass the uniqueness check [0.02ms] +(pass) T072 / §4 — overlap does not trigger the abort > a real run over two overlapping entities produces an envelope with both [9.04ms] +(pass) T072 / §4 — no exclusive winner: a changed file is owned by every match > both entities own a file matching the shared pattern [0.35ms] +(pass) T072 / §4 — no exclusive winner: a changed file is owned by every match > a file matching only one pattern is owned by only that entity [0.05ms] +(pass) T072 / §4 — no exclusive winner: a changed file is owned by every match > a file matching nothing is owned by nobody [0.04ms] +(pass) T072 / §4 — no exclusive winner: a changed file is owned by every match > the result is a list, so a caller cannot read "the owner" off it [0.05ms] +(pass) T072 / §4 — no exclusive winner: a changed file is owned by every match > ownership does not depend on the order the claims are listed in [0.07ms] + +test/envelope-only.test.ts: +(pass) T079 — exactly one file is written > a successful run leaves one file, and it is the envelope [9.73ms] +(pass) T079 — exactly one file is written > a second run into the same directory still leaves one file [19.18ms] +(pass) T079 — exactly one file is written > a pre-existing unrelated file is left alone rather than cleaned up [18.83ms] +(pass) T079 — exactly one file is written > the write reports the one path it wrote [8.73ms] +(pass) T079 — no CatalogSnapshot-shaped artifact is ever written > the written file is envelope-shaped and not snapshot-shaped [9.31ms] +(pass) T079 — no CatalogSnapshot-shaped artifact is ever written > no adapter source names the core catalog snapshot types [8.19ms] +(pass) T079 — no CatalogSnapshot-shaped artifact is ever written > that rule has been observed firing, so its silence means something [0.04ms] +(pass) T079 — diagnostics are returned, never written > the stage trace is a returned value and appears in no file [18.26ms] +(pass) T079 — diagnostics are returned, never written > the serialization contains only the envelope’s own fields [9.08ms] +(pass) T079 — writeEnvelope writes one file and creates its directory > a nested destination directory is created [8.45ms] + +test/sc-013.test.ts: +(pass) T085 / SC-013 — exactly one envelope is produced > the destination directory holds exactly one file [0.10ms] +(pass) T085 / SC-013 — exactly one envelope is produced > that file is one envelope, not an array or a stream of them [0.02ms] +(pass) T085 / SC-013 — exactly one envelope is produced > it covers exactly one repository [0.01ms] +(pass) T085 / SC-013 — each entity record carries exactly five fields > the fixture has several entities, so the check is not about one record +(pass) T085 / SC-013 — each entity record carries exactly five fields > every record has exactly the five defined fields [0.03ms] +(pass) T085 / SC-013 — each entity record carries exactly five fields > no snapshot-shaped artifact was written alongside it [0.09ms] +(pass) T085 / SC-013 — the digest matches an independent recomputation > the independent recomputation agrees with the recorded digest [0.13ms] +(pass) T085 / SC-013 — the digest matches an independent recomputation > the recomputation is genuinely independent — it detects a mutation [0.12ms] +(pass) T085 / SC-013 — the digest matches an independent recomputation > the recomputation is order-insensitive at every nesting level [0.12ms] +(pass) T085 / SC-013 — the digest matches an independent recomputation > the digest is 64 lowercase hex characters [0.01ms] +(pass) T085 — the scope of what a matching digest establishes > a match establishes integrity, and this test claims nothing more [0.05ms] + +test/completeness-always-false.test.ts: +(pass) T070 — the value is false, and it is one value not two > the boundary constant is false [0.04ms] +(pass) T070 — the value is false, and it is one value not two > the envelope module re-exports that constant rather than declaring a second [0.02ms] +(pass) T070 — no parameter can set it > identityOnly false leaves wholeCatalog false [0.02ms] +(pass) T070 — no parameter can set it > identityOnly true still leaves wholeCatalog false +(pass) T070 — no parameter can set it > the function takes exactly one parameter, and it is not wholeCatalog +(pass) T070 — every envelope a real run produces carries false > a single-entity run [8.66ms] +(pass) T070 — every envelope a real run produces carries false > a run over all three ownership states and several sources [10.56ms] +(pass) T070 — every envelope a real run produces carries false > a run over a multi-document source file [8.04ms] +(pass) T070 — every envelope a real run produces carries false > a run with identityOnly requested still reports wholeCatalog false [8.51ms] + +test/envelope-shape.test.ts: +(pass) T080 — the fixture is rich enough for the checks to mean something > three entities, spanning several kinds, namespaces and ownership states [0.07ms] +(pass) T080 / data-model.md §9 — nine top-level fields > the declared list has nine entries [0.01ms] +(pass) T080 / data-model.md §9 — nine top-level fields > the emitted object carries exactly those nine, no more and no fewer [0.01ms] +(pass) T080 / data-model.md §9 — nine top-level fields > field order in the emitted JSON follows the contract’s order +(pass) T080 / data-model.md §9 — nine top-level fields > schemaVersion and capabilities are the exact values the consumer validates [0.01ms] +(pass) T080 / data-model.md §9 — nine top-level fields > globDialect records the engine and options actually used [0.02ms] +(pass) T080 / data-model.md §9 — nine top-level fields > the nested objects carry their declared fields [0.03ms] +(pass) T080 / data-model.md §10 — exactly five fields per entity record > the declared list has five entries +(pass) T080 / data-model.md §10 — exactly five fields per entity record > every emitted record carries exactly those five [0.02ms] +(pass) T080 / data-model.md §10 — exactly five fields per entity record > the identity projection is `{ canonicalId, allRefs }` and nothing else [0.02ms] +(pass) T080 / data-model.md §10 — exactly five fields per entity record > the sourceDocument reference is `{ sourcePath, documentIndexInFile }` [0.01ms] +(pass) T080 / data-model.md §10 — exactly five fields per entity record > a multi-document file yields distinct documentIndexInFile values [0.02ms] +(pass) T080 — the flatter shape is forbidden > no entity record carries a flat canonicalId, refs or paths field [0.01ms] +(pass) T080 — the flatter shape is forbidden > the forbidden list is checked by name, not inferred from a field count +(pass) T080 — the flatter shape is forbidden > the authoring fields §1 excludes are not serialized [0.01ms] +(pass) T080 — entityRecord projects rather than spreads > an input carrying extra fields does not leak them into the record [0.03ms] + +test/envelope-digest.test.ts: +(pass) T081 — the rendering `snapshot-envelope.md` §3 requires > 64 lowercase hexadecimal characters [0.04ms] +(pass) T081 — the rendering `snapshot-envelope.md` §3 requires > the algorithm is sha256 +(pass) T081 — the digest field itself is excluded from its own input > the canonical form has no top-level `digest` key [0.06ms] +(pass) T081 — the digest field itself is excluded from its own input > the canonical form does contain every other top-level field [0.05ms] +(pass) T081 — the digest field itself is excluded from its own input > `sources[].digest` is not excluded — only the envelope’s own digest is [0.09ms] +(pass) T081 — the canonical form is canonical > keys are sorted by code units at every nesting level [0.03ms] +(pass) T081 — the canonical form is canonical > arrays keep their declaration order and are never re-sorted [0.02ms] +(pass) T081 — the canonical form is canonical > the serialization is compact — no insignificant whitespace [0.02ms] +(pass) T081 — the canonical form is canonical > an undefined field is omitted rather than serialized as null +(pass) T081 — an independent recomputation agrees > recomputing from the canonical form with node:crypto matches the recorded digest [0.03ms] +(pass) T081 — an independent recomputation agrees > verifyEnvelopeDigest reports a match on an untouched envelope [0.03ms] +(pass) T081 — an independent recomputation agrees > a naive mutation is detected [0.05ms] +(pass) T081 — an independent recomputation agrees > an adversary who recomputes the digest is NOT detected, as FR-041 states [0.04ms] +(pass) T081 — an independent recomputation agrees > verifyEnvelopeDigest does not mutate its input [0.04ms] +(pass) T081 — the digest is a function of content, not of field order > reordering top-level keys leaves the digest unchanged [0.03ms] +(pass) T081 — the digest is a function of content, not of field order > changing any content changes the digest [0.05ms] + + 86 pass + 0 fail + 220 expect() calls +Ran 86 tests across 7 files. [488.00ms] diff --git a/specs/010-catalog-backstage/evidence/negative-cases/triggers/README.md b/specs/010-catalog-backstage/evidence/negative-cases/triggers/README.md new file mode 100644 index 00000000..2ac7df26 --- /dev/null +++ b/specs/010-catalog-backstage/evidence/negative-cases/triggers/README.md @@ -0,0 +1,176 @@ +# Negative case: the fifteen fatal trigger classes + +**Tasks**: T074, T075, T076, T078 · **Discharges**: FR-035, FR-036, FR-037 · +**Supports**: SC-003 +**Observed against**: `19f316413d5550b30296e7987c74d267c96655f9` plus Phase E's own +uncommitted work; in each case the named mutation was the only additional change in the +tree, and it was reverted before the next case was run. +**Tools**: Bun 1.3.14, TypeScript 6.0.3 +**Command for every case below**: `bun test `, run from +`packages/adapters/catalog-backstage/` +**Permanent automated cases**: `test/sc-003-all-triggers.test.ts`, +`test/trigger-classification.test.ts`, `test/backstop-trigger.test.ts` + +## The count is **fifteen** + +`contracts/atomic-fail-closed.md` §4 — "Closed Type of **Fifteen** Values". +`data-model.md` §8 lists the same fifteen, marking `inadmissible-descriptor` as added +for this feature. `specs/009-catalog-binding-viability/contracts/atomic-fail-closed.md` +§4 says **fourteen**, which is correct about spike 009 and wrong about this feature +(spec FR-035). + +## The fifteen, each observed failing through the assembled pipeline + +Reproduce this table from a live run: + +```bash +ADRKIT_PRINT_TRIGGERS=1 bun test test/sc-003-all-triggers.test.ts +``` + +It is generated by the test from the records the pipeline actually emitted, never +transcribed. + +| Trigger class | Reason | Stage | Observed detail | +|---|---|---|---| +| `duplicate-canonical-id` | `duplicate-canonical-id` | canonicalization | entity 0 (component:default/collide) canonicalId "component:default/collide" collides with entity 1 (component:default/collide) canonicalId "component:default/collide"; entity-identity.md §3 forbids resolving this by first-wins or last-wins | +| `duplicate-canonical-ref` | `duplicate-canonical-ref` | canonicalization | entity 0 (component:default/billing) ref "component:default/billing-legacy" collides with entity 1 (component:default/billing-legacy) canonicalId "component:default/billing-legacy"; entity-identity.md §3 forbids resolving this by first-wins or last-wins | +| `duplicate-yaml-key` | `duplicate-yaml-key` | descriptor-read | dupkey/catalog-info.yaml[0]: DUPLICATE_KEY: Map keys must be unique at line 5, column 1 | +| `inadmissible-descriptor` | `inadmissible-descriptor` | admissibility | inadm/catalog-info.yaml[0]: metadata.name rejected by validateEntityName (isValidEntityName → KubernetesValidatorFunctions.isValidObjectName, pinned at 1121a4facd9e321179d0402c3f355e4a649e84d9); observed "Not_A_Valid_Name!" | +| `incomplete-required-source` | `source-missing` | digests | src-absent/catalog-info.yaml: listed in the manifest but absent from the checkout | +| `invalid-annotation-parse` | `parse-error` | ownership | adrkit.io/owned-paths is not valid JSON: JSON Parse error: Expected ']' | +| `invalid-annotation-shape` | `wrong-shape` | ownership | adrkit.io/owned-paths must decode to an array of strings; observed a JSON object | +| `invalid-manifest-shape` | `unrecognized-top-level-field` | manifest | unexpectedField is not a recognized top-level manifest field | +| `invalid-pattern` | `invalid-pattern` | glob | pattern "packages/{a,b}/**" rejected at rule 6 (brace) | +| `invalid-yaml-syntax` | `invalid-yaml-syntax` | descriptor-read | badyaml/catalog-info.yaml[0]: BAD_INDENT: Flow sequence in block collection must be sufficiently indented and end with a ] at line 3, column 1 | +| `other-invalid-input` | `provenance-declaration-missing` | manifest | no annotation provenance was declared for manifest source "prov-b/catalog-info.yaml". FR-043 requires that upstream-authored annotation content stay distinguishable against maintainer-authored overlay, and neither value may be assumed: defaulting to upstream-authored would claim third-party adoption that was never attested. | +| `repository-mismatch` | `repository-mismatch` | repository | repository id: manifest "github.com/someone/entirely-else" !== observed "github.com/mbeacom/adrkit-phase-e-fixture" | +| `unsupported-capability` | `unsupported-capability` | manifest | requiredCapabilities[1] is "somethingUndefined"; the only defined capability is "pathOwnership" | +| `unsupported-manifest-version` | `unsupported-manifest-version` | manifest | manifestSchemaVersion must be "1"; observed "2" | +| `unsupported-snapshot-version` | `unsupported-snapshot-version` | manifest | requestedSnapshotSchemaVersion must be "1"; observed "2" | + +A sixteenth route to `invalid-manifest-shape` is also retained in the automated case: a +manifest that is absent altogether, reason `manifest-unreadable`. It is a second reason +under an already-covered class, recorded because it is the failure an operator meets +first and because its reason string differs from a parse failure's. + +## Fourteen of the fifteen went through the full assembled pipeline. One did not. + +`duplicate-canonical-ref` is **not reachable from descriptor input**, and this is the +`[NEEDS CLARIFICATION]` T078 carries forward from T071 rather than a gap in the fixtures. + +- `identity/canonicalize.ts` populates `allRefs` as `[canonicalId]` and nothing more. +- `data-model.md` §5 records how `allRefs` is populated beyond the primary id as an + unresolved `[NEEDS CLARIFICATION]`. +- `entity-identity.md` §2 states alias refs are supplied "directly by a synthetic + fixture's own construction", and that "no real-corpus entity from `community-plugins` + or `rhdh-plugins` ever has a non-empty `fixtureAuthoredAliasRefs`". + +With `allRefs` holding only the canonical id, two descriptors that canonicalize alike +collide as `duplicate-canonical-id`. So `duplicate-canonical-ref` was exercised at the +**canonicalization stage's own uniqueness kernel**, with a **synthetic** identity set. +The kernel is the assembled pipeline's code; only the input is synthetic. + +**This is recorded as synthetic rather than presented as corpus-derived.** Adding an +alias input to the generation request to force the class through the full pipeline would +invent the production alias mechanism `entity-identity.md` §2 calls "an explicitly +separate, later, out-of-scope design decision". It was considered and rejected. + +`test/sc-003-all-triggers.test.ts` asserts this explicitly: exactly one of the fifteen +carries `stage: 'canonicalization'` with no `sourcePath`. If a future change makes the +class descriptor-reachable, that assertion fails and this section must be revised rather +than the claim quietly changing. + +--- + +## Case 1 — collapsing `duplicate-yaml-key` into `invalid-yaml-syntax` + +Input: [`case-1-collapse-duplicate-yaml-key-into-invalid-yaml-syntax.patch`](./case-1-collapse-duplicate-yaml-key-into-invalid-yaml-syntax.patch) · +Output: [`case-1-collapse-duplicate-yaml-key-into-invalid-yaml-syntax.observed.txt`](./case-1-collapse-duplicate-yaml-key-into-invalid-yaml-syntax.observed.txt) + +The first of the two pairs `atomic-fail-closed.md` §4.3 names as most at risk of being +merged. Three tests fail: + +``` +(fail) T078 — descriptor parse: the pair §4.3 says must not collapse > duplicate-yaml-key +(fail) T078 / SC-003 — every one of the fifteen was observed > all fifteen classes appear in the observed set +(fail) T076 / §4.3 — the collapsible pairs stay distinct > duplicate-yaml-key is not invalid-yaml-syntax +Expected: "duplicate-yaml-key" +Received: "invalid-yaml-syntax" +``` + +The SC-003 failure is the one worth noting: collapsing two classes does not merely +misreport one case, it reduces the observed enumeration from fifteen to fourteen. + +## Case 2 — the registry maps an unrecognized field to a version problem + +Input: [`case-2-registry-maps-unrecognized-field-to-a-version-problem.patch`](./case-2-registry-maps-unrecognized-field-to-a-version-problem.patch) · +Output: [`case-2-registry-maps-unrecognized-field-to-a-version-problem.observed.txt`](./case-2-registry-maps-unrecognized-field-to-a-version-problem.observed.txt) + +`contracts/README.md` §4.2 resolves this conflict: `input-manifest.md` §1 calls an +unrecognized top-level field an *"unsupported manifest version"-class* rejection, and +`atomic-fail-closed.md` §4 assigns it to `invalid-manifest-shape`. **§4 governs**, as the +later and more specific statement. + +``` +(fail) T078 — the four manifest-request-level classes > invalid-manifest-shape — an unrecognized top-level field +(fail) T078 / SC-003 — every one of the fifteen was observed > all fifteen classes appear in the observed set +``` + +## Case 3 — the count transcribed as fourteen + +Input: [`case-3-trigger-count-transcribed-as-fourteen.patch`](./case-3-trigger-count-transcribed-as-fourteen.patch) · +Output: [`case-3-trigger-count-transcribed-as-fourteen.observed.txt`](./case-3-trigger-count-transcribed-as-fourteen.observed.txt) + +`FATAL_TRIGGER_COUNT` is derived from `TRIGGER_CLASSES.length` rather than written as a +literal. This case replaces it with the literal `14` — spike 009's number — and the check +fires: + +``` +(fail) T074 — the enumeration is closed, and its count is fifteen > there are fifteen classes, counted from the declaration +Expected: 15 +Received: 14 +``` + +## Case 4 — the backstop made unreachable + +Input: [`case-4-backstop-made-unreachable.patch`](./case-4-backstop-made-unreachable.patch) · +Output: [`case-4-backstop-made-unreachable.observed.txt`](./case-4-backstop-made-unreachable.observed.txt) + +`atomic-fail-closed.md` §4.2 requires `other-invalid-input` be "a **deliberate, +always-present backstop**", and FR-036 that it never be treated as dead code. This +implementation's one genuine route to it is the annotation-provenance declaration, and +disabling the missing-entry check removes that route: + +``` +(fail) T078 — the backstop, reached by a genuinely invalid request > other-invalid-input — an undeclared source provenance +(fail) T078 / SC-003 — every one of the fifteen was observed > all fifteen classes appear in the observed set +(fail) T075 — the backstop is not dead code: a real input reaches it > a request omitting a listed source's provenance is `other-invalid-input` +``` + +## Case 5 — a backstop reason remapped onto a named class + +Input: [`case-5-backstop-reason-remapped-to-a-named-class.patch`](./case-5-backstop-reason-remapped-to-a-named-class.patch) · +Output: [`case-5-backstop-reason-remapped-to-a-named-class.observed.txt`](./case-5-backstop-reason-remapped-to-a-named-class.observed.txt) + +The converse of case 4, and the direction FR-036 names explicitly: the backstop must +never be "a substitute for a more specific class that applies", and — equally — a case +that belongs to the backstop must not be pushed onto a named class it does not fit. This +maps `provenance-declaration-missing` onto `incomplete-required-source`: + +``` +(fail) T078 — the backstop, reached by a genuinely invalid request > other-invalid-input — an undeclared source provenance +(fail) T078 / SC-003 — every one of the fifteen was observed > all fifteen classes appear in the observed set +(fail) T075 — the backstop is not dead code: a real input reaches it > exactly three reasons map to the backstop, and none of them has a named class +``` + +## Restored + +[`restored.observed.txt`](./restored.observed.txt) — 47 tests pass, 0 fail, across the +three named test files. + +## Standing constraints + +ADR-0014 **rung 1 only**. Nothing here is external, third-party, or community +validation. No claim is made about what Backstage as a running system does: the warrant +is what the pinned validator predicate returns at commit +`1121a4facd9e321179d0402c3f355e4a649e84d9`. diff --git a/specs/010-catalog-backstage/evidence/negative-cases/triggers/case-1-collapse-duplicate-yaml-key-into-invalid-yaml-syntax.observed.txt b/specs/010-catalog-backstage/evidence/negative-cases/triggers/case-1-collapse-duplicate-yaml-key-into-invalid-yaml-syntax.observed.txt new file mode 100644 index 00000000..06ce6df9 --- /dev/null +++ b/specs/010-catalog-backstage/evidence/negative-cases/triggers/case-1-collapse-duplicate-yaml-key-into-invalid-yaml-syntax.observed.txt @@ -0,0 +1,107 @@ + +packages/adapters/catalog-backstage/test/sc-003-all-triggers.test.ts: +(pass) T078 — the closed enumeration is fifteen > fifteen classes, counted from the declaration [0.02ms] +(pass) T078 — the four manifest-request-level classes > invalid-manifest-shape — an unrecognized top-level field [1.52ms] +(pass) T078 — the four manifest-request-level classes > unsupported-manifest-version [0.47ms] +(pass) T078 — the four manifest-request-level classes > unsupported-snapshot-version [0.31ms] +(pass) T078 — the four manifest-request-level classes > unsupported-capability [0.27ms] +(pass) T078 — the four manifest-request-level classes > incomplete-required-source — a listed source absent from the checkout [8.53ms] +(pass) T078 — repository identity > repository-mismatch [7.35ms] +85 | +86 | if (outcome.ok) { +87 | throw new Error(`${name}: expected an abort with ${expected}, but generation succeeded`); +88 | } +89 | +90 | expect(outcome.failure.triggerClass).toBe(expected); + ^ +error: expect(received).toBe(expected) + +Expected: "duplicate-yaml-key" +Received: "invalid-yaml-syntax" + + at pipelineCase (/Users/markbeacom/github/mbeacom/copilot-worktrees/adrkit/mbeacom-potential-potato/packages/adapters/catalog-backstage/test/sc-003-all-triggers.test.ts:90:40) + at async (/Users/markbeacom/github/mbeacom/copilot-worktrees/adrkit/mbeacom-potential-potato/packages/adapters/catalog-backstage/test/sc-003-all-triggers.test.ts:199:11) +(fail) T078 — descriptor parse: the pair §4.3 says must not collapse > duplicate-yaml-key [11.48ms] +(pass) T078 — descriptor parse: the pair §4.3 says must not collapse > invalid-yaml-syntax [9.36ms] +(pass) T078 — admissibility, the class ADR-0015 adds > inadmissible-descriptor [8.18ms] +(pass) T078 — identity uniqueness > duplicate-canonical-id [8.39ms] +(pass) T078 — identity uniqueness > duplicate-canonical-ref — at the stage kernel, on a synthetic identity set [0.04ms] +(pass) T078 — annotation decode and the frozen glob dialect > invalid-annotation-parse — the annotation is not valid JSON [9.04ms] +(pass) T078 — annotation decode and the frozen glob dialect > invalid-annotation-shape — the annotation decodes to an object [8.37ms] +(pass) T078 — annotation decode and the frozen glob dialect > invalid-pattern — a brace, which the dialect rejects at rule 6 [8.99ms] +(pass) T078 — the backstop, reached by a genuinely invalid request > other-invalid-input — an undeclared source provenance [0.65ms] +(pass) T078 — the backstop, reached by a genuinely invalid request > invalid-manifest-shape — a manifest that is not there [0.10ms] +376 | +377 | describe('T078 / SC-003 — every one of the fifteen was observed', () => { +378 | test('all fifteen classes appear in the observed set', () => { +379 | const observed = [...OBSERVED_TRIGGERS.keys()].sort(); +380 | const expected = [...TRIGGER_CLASSES].sort(); +381 | expect(observed).toEqual(expected); + ^ +error: expect(received).toEqual(expected) + + [ + "duplicate-canonical-id", + "duplicate-canonical-ref", +- "duplicate-yaml-key", + "inadmissible-descriptor", + "incomplete-required-source", + "invalid-annotation-parse", + "invalid-annotation-shape", + "invalid-manifest-shape", + "invalid-pattern", + "invalid-yaml-syntax", + "other-invalid-input", + "repository-mismatch", + "unsupported-capability", + "unsupported-manifest-version", + "unsupported-snapshot-version", + ] + +- Expected - 1 ++ Received + 0 + + at (/Users/markbeacom/github/mbeacom/copilot-worktrees/adrkit/mbeacom-potential-potato/packages/adapters/catalog-backstage/test/sc-003-all-triggers.test.ts:381:22) +(fail) T078 / SC-003 — every one of the fifteen was observed > all fifteen classes appear in the observed set [0.16ms] +(pass) T078 / SC-003 — every one of the fifteen was observed > each observation carries its own distinct reason string [0.03ms] +(pass) T078 / SC-003 — every one of the fifteen was observed > every observation carries a non-empty detail [0.03ms] +(pass) T078 / SC-003 — every one of the fifteen was observed > exactly one of the fifteen was reached other than through the full pipeline [0.01ms] + +packages/adapters/catalog-backstage/test/trigger-classification.test.ts: +(pass) T076 — every emitted rejection agrees with the contract-sourced registry > the sweep actually produced rejections, so the check below read something [0.01ms] +(pass) T076 — every emitted rejection agrees with the contract-sourced registry > every emitted reason is registered [0.04ms] +(pass) T076 — every emitted rejection agrees with the contract-sourced registry > every emitted class matches the class the contracts assign its reason [0.02ms] +(pass) T076 — every emitted rejection agrees with the contract-sourced registry > every class the registry names is a member of the closed enumeration [0.03ms] +(pass) T076 / §4.3 — the collapsible pairs stay distinct > the contract names three pairs, and they are checked by name +170 | +171 | test('duplicate-yaml-key is not invalid-yaml-syntax', () => { +172 | const [duplicate] = readDescriptorDocuments('f.yaml', 'kind: Component\nkind: Component\n'); +173 | const [malformed] = readDescriptorDocuments('f.yaml', 'a: [1, 2\n'); +174 | +175 | expect(duplicate?.rejection?.triggerClass).toBe('duplicate-yaml-key'); + ^ +error: expect(received).toBe(expected) + +Expected: "duplicate-yaml-key" +Received: "invalid-yaml-syntax" + + at (/Users/markbeacom/github/mbeacom/copilot-worktrees/adrkit/mbeacom-potential-potato/packages/adapters/catalog-backstage/test/trigger-classification.test.ts:175:48) +(fail) T076 / §4.3 — the collapsible pairs stay distinct > duplicate-yaml-key is not invalid-yaml-syntax [0.30ms] +(pass) T076 / §4.3 — the collapsible pairs stay distinct > an unrecognized top-level manifest field is invalid-manifest-shape, not a version problem [0.03ms] +(pass) T076 / §4.3 — the collapsible pairs stay distinct > unsupported-manifest-version presumes a well-shaped manifest with a bad value [0.02ms] +(pass) T076 / §4.3 — the collapsible pairs stay distinct > a lexically invalid source path is invalid-manifest-shape, not incomplete-required-source [0.03ms] +(pass) T076 — classifyAbort refuses a disagreement rather than repairing it > a rejection whose class contradicts the registry throws [0.09ms] +(pass) T076 — classifyAbort refuses a disagreement rather than repairing it > an unregistered reason throws rather than being trusted [0.03ms] +(pass) T076 — classifyAbort refuses a disagreement rather than repairing it > the error names both sides, so the disagreement is legible [0.03ms] +(pass) T076 — classifyAbort refuses a disagreement rather than repairing it > an agreeing rejection yields exactly one record [0.03ms] + +3 tests failed: +(fail) T078 — descriptor parse: the pair §4.3 says must not collapse > duplicate-yaml-key [11.48ms] +(fail) T078 / SC-003 — every one of the fifteen was observed > all fifteen classes appear in the observed set [0.16ms] +(fail) T076 / §4.3 — the collapsible pairs stay distinct > duplicate-yaml-key is not invalid-yaml-syntax [0.30ms] + + 31 pass + 3 fail + 103 expect() calls +Ran 34 tests across 2 files. [165.00ms] +bun test v1.3.14 (0d9b296a) diff --git a/specs/010-catalog-backstage/evidence/negative-cases/triggers/case-1-collapse-duplicate-yaml-key-into-invalid-yaml-syntax.patch b/specs/010-catalog-backstage/evidence/negative-cases/triggers/case-1-collapse-duplicate-yaml-key-into-invalid-yaml-syntax.patch new file mode 100644 index 00000000..f40eb84a --- /dev/null +++ b/specs/010-catalog-backstage/evidence/negative-cases/triggers/case-1-collapse-duplicate-yaml-key-into-invalid-yaml-syntax.patch @@ -0,0 +1,13 @@ +diff --git a/packages/adapters/catalog-backstage/src/descriptor/read.ts b/packages/adapters/catalog-backstage/src/descriptor/read.ts +index 84c433c..6899623 100644 +--- a/packages/adapters/catalog-backstage/src/descriptor/read.ts ++++ b/packages/adapters/catalog-backstage/src/descriptor/read.ts +@@ -101,7 +101,7 @@ export function readDescriptorDocuments( + + if (firstError !== undefined) { + const reason: DescriptorReadReason = +- firstError.code === DUPLICATE_KEY_CODE ? 'duplicate-yaml-key' : 'invalid-yaml-syntax'; ++ 'invalid-yaml-syntax'; + + return { + sourcePath, diff --git a/specs/010-catalog-backstage/evidence/negative-cases/triggers/case-2-registry-maps-unrecognized-field-to-a-version-problem.observed.txt b/specs/010-catalog-backstage/evidence/negative-cases/triggers/case-2-registry-maps-unrecognized-field-to-a-version-problem.observed.txt new file mode 100644 index 00000000..b70e4227 --- /dev/null +++ b/specs/010-catalog-backstage/evidence/negative-cases/triggers/case-2-registry-maps-unrecognized-field-to-a-version-problem.observed.txt @@ -0,0 +1,95 @@ + +packages/adapters/catalog-backstage/test/sc-003-all-triggers.test.ts: +(pass) T078 — the closed enumeration is fifteen > fifteen classes, counted from the declaration [0.02ms] +183 | stage: string, +184 | location: FailureLocation = {}, +185 | ): AtomicFailureRecord { +186 | const expected = expectedTriggerFor(rejection.reason); +187 | if (expected !== rejection.triggerClass) { +188 | throw new TriggerClassificationError(rejection.reason, rejection.triggerClass, expected); + ^ +TriggerClassificationError: reason "unrecognized-top-level-field" emitted trigger class "invalid-manifest-shape", but the contracts assign it "unsupported-manifest-version". Exactly one of the two is wrong (FR-037; atomic-fail-closed.md §4.3). + reason: "unrecognized-top-level-field", + emitted: "invalid-manifest-shape", + expected: "unsupported-manifest-version", + + at classifyAbort (/Users/markbeacom/github/mbeacom/copilot-worktrees/adrkit/mbeacom-potential-potato/packages/adapters/catalog-backstage/src/failure/classify.ts:188:11) + at aborted (/Users/markbeacom/github/mbeacom/copilot-worktrees/adrkit/mbeacom-potential-potato/packages/adapters/catalog-backstage/src/pipeline.ts:191:32) + at async pipelineCase (/Users/markbeacom/github/mbeacom/copilot-worktrees/adrkit/mbeacom-potential-potato/packages/adapters/catalog-backstage/test/sc-003-all-triggers.test.ts:84:25) + at async (/Users/markbeacom/github/mbeacom/copilot-worktrees/adrkit/mbeacom-potential-potato/packages/adapters/catalog-backstage/test/sc-003-all-triggers.test.ts:107:11) +(fail) T078 — the four manifest-request-level classes > invalid-manifest-shape — an unrecognized top-level field [1.64ms] +(pass) T078 — the four manifest-request-level classes > unsupported-manifest-version [0.58ms] +(pass) T078 — the four manifest-request-level classes > unsupported-snapshot-version [0.33ms] +(pass) T078 — the four manifest-request-level classes > unsupported-capability [0.35ms] +(pass) T078 — the four manifest-request-level classes > incomplete-required-source — a listed source absent from the checkout [9.04ms] +(pass) T078 — repository identity > repository-mismatch [7.62ms] +(pass) T078 — descriptor parse: the pair §4.3 says must not collapse > duplicate-yaml-key [11.95ms] +(pass) T078 — descriptor parse: the pair §4.3 says must not collapse > invalid-yaml-syntax [9.56ms] +(pass) T078 — admissibility, the class ADR-0015 adds > inadmissible-descriptor [8.79ms] +(pass) T078 — identity uniqueness > duplicate-canonical-id [8.61ms] +(pass) T078 — identity uniqueness > duplicate-canonical-ref — at the stage kernel, on a synthetic identity set [0.04ms] +(pass) T078 — annotation decode and the frozen glob dialect > invalid-annotation-parse — the annotation is not valid JSON [7.92ms] +(pass) T078 — annotation decode and the frozen glob dialect > invalid-annotation-shape — the annotation decodes to an object [7.83ms] +(pass) T078 — annotation decode and the frozen glob dialect > invalid-pattern — a brace, which the dialect rejects at rule 6 [8.55ms] +(pass) T078 — the backstop, reached by a genuinely invalid request > other-invalid-input — an undeclared source provenance [1.22ms] +(pass) T078 — the backstop, reached by a genuinely invalid request > invalid-manifest-shape — a manifest that is not there [0.07ms] +376 | +377 | describe('T078 / SC-003 — every one of the fifteen was observed', () => { +378 | test('all fifteen classes appear in the observed set', () => { +379 | const observed = [...OBSERVED_TRIGGERS.keys()].sort(); +380 | const expected = [...TRIGGER_CLASSES].sort(); +381 | expect(observed).toEqual(expected); + ^ +error: expect(received).toEqual(expected) + + [ + "duplicate-canonical-id", + "duplicate-canonical-ref", + "duplicate-yaml-key", + "inadmissible-descriptor", + "incomplete-required-source", + "invalid-annotation-parse", + "invalid-annotation-shape", +- "invalid-manifest-shape", + "invalid-pattern", + "invalid-yaml-syntax", + "other-invalid-input", + "repository-mismatch", + "unsupported-capability", + "unsupported-manifest-version", + "unsupported-snapshot-version", + ] + +- Expected - 1 ++ Received + 0 + + at (/Users/markbeacom/github/mbeacom/copilot-worktrees/adrkit/mbeacom-potential-potato/packages/adapters/catalog-backstage/test/sc-003-all-triggers.test.ts:381:22) +(fail) T078 / SC-003 — every one of the fifteen was observed > all fifteen classes appear in the observed set [0.16ms] +(pass) T078 / SC-003 — every one of the fifteen was observed > each observation carries its own distinct reason string [0.02ms] +(pass) T078 / SC-003 — every one of the fifteen was observed > every observation carries a non-empty detail [0.02ms] +(pass) T078 / SC-003 — every one of the fifteen was observed > exactly one of the fifteen was reached other than through the full pipeline [0.01ms] + +packages/adapters/catalog-backstage/test/trigger-classification.test.ts: +(pass) T076 — every emitted rejection agrees with the contract-sourced registry > the sweep actually produced rejections, so the check below read something +(pass) T076 — every emitted rejection agrees with the contract-sourced registry > every emitted reason is registered [0.02ms] +(pass) T076 — every emitted rejection agrees with the contract-sourced registry > every emitted class matches the class the contracts assign its reason [0.01ms] +(pass) T076 — every emitted rejection agrees with the contract-sourced registry > every class the registry names is a member of the closed enumeration [0.02ms] +(pass) T076 / §4.3 — the collapsible pairs stay distinct > the contract names three pairs, and they are checked by name [0.01ms] +(pass) T076 / §4.3 — the collapsible pairs stay distinct > duplicate-yaml-key is not invalid-yaml-syntax [0.16ms] +(pass) T076 / §4.3 — the collapsible pairs stay distinct > an unrecognized top-level manifest field is invalid-manifest-shape, not a version problem [0.03ms] +(pass) T076 / §4.3 — the collapsible pairs stay distinct > unsupported-manifest-version presumes a well-shaped manifest with a bad value [0.01ms] +(pass) T076 / §4.3 — the collapsible pairs stay distinct > a lexically invalid source path is invalid-manifest-shape, not incomplete-required-source [0.02ms] +(pass) T076 — classifyAbort refuses a disagreement rather than repairing it > a rejection whose class contradicts the registry throws [0.02ms] +(pass) T076 — classifyAbort refuses a disagreement rather than repairing it > an unregistered reason throws rather than being trusted [0.01ms] +(pass) T076 — classifyAbort refuses a disagreement rather than repairing it > the error names both sides, so the disagreement is legible [0.02ms] +(pass) T076 — classifyAbort refuses a disagreement rather than repairing it > an agreeing rejection yields exactly one record [0.01ms] + +2 tests failed: +(fail) T078 — the four manifest-request-level classes > invalid-manifest-shape — an unrecognized top-level field [1.64ms] +(fail) T078 / SC-003 — every one of the fifteen was observed > all fifteen classes appear in the observed set [0.16ms] + + 32 pass + 2 fail + 104 expect() calls +Ran 34 tests across 2 files. [170.00ms] +bun test v1.3.14 (0d9b296a) diff --git a/specs/010-catalog-backstage/evidence/negative-cases/triggers/case-2-registry-maps-unrecognized-field-to-a-version-problem.patch b/specs/010-catalog-backstage/evidence/negative-cases/triggers/case-2-registry-maps-unrecognized-field-to-a-version-problem.patch new file mode 100644 index 00000000..07ce6e36 --- /dev/null +++ b/specs/010-catalog-backstage/evidence/negative-cases/triggers/case-2-registry-maps-unrecognized-field-to-a-version-problem.patch @@ -0,0 +1,13 @@ +diff --git a/packages/adapters/catalog-backstage/src/failure/classify.ts b/packages/adapters/catalog-backstage/src/failure/classify.ts +index 0b37ace..8b9112b 100644 +--- a/packages/adapters/catalog-backstage/src/failure/classify.ts ++++ b/packages/adapters/catalog-backstage/src/failure/classify.ts +@@ -66,7 +66,7 @@ export const REASON_TRIGGER_REGISTRY: Readonly> = { + // is no file here" and "this file is not JSON" are different findings with different + // fixes. `manifest/schema.ts` cannot own it: that module starts from text. + 'manifest-unreadable': 'invalid-manifest-shape', +- 'unrecognized-top-level-field': 'invalid-manifest-shape', ++ 'unrecognized-top-level-field': 'unsupported-manifest-version', + 'missing-required-field': 'invalid-manifest-shape', + 'field-wrong-type': 'invalid-manifest-shape', + 'multiple-repositories': 'invalid-manifest-shape', diff --git a/specs/010-catalog-backstage/evidence/negative-cases/triggers/case-3-trigger-count-transcribed-as-fourteen.observed.txt b/specs/010-catalog-backstage/evidence/negative-cases/triggers/case-3-trigger-count-transcribed-as-fourteen.observed.txt new file mode 100644 index 00000000..3da59c51 --- /dev/null +++ b/specs/010-catalog-backstage/evidence/negative-cases/triggers/case-3-trigger-count-transcribed-as-fourteen.observed.txt @@ -0,0 +1,34 @@ + +packages/adapters/catalog-backstage/test/backstop-trigger.test.ts: +29 | describe('T074 — the enumeration is closed, and its count is fifteen', () => { +30 | test('there are fifteen classes, counted from the declaration', () => { +31 | // `contracts/atomic-fail-closed.md` §4: "Closed Type of **Fifteen** Values". +32 | // `data-model.md` §8 lists the same fifteen. Spike 009's fourteen is correct about +33 | // spike 009 and wrong here (FR-035). +34 | expect(FATAL_TRIGGER_COUNT).toBe(15); + ^ +error: expect(received).toBe(expected) + +Expected: 15 +Received: 14 + + at (/Users/markbeacom/github/mbeacom/copilot-worktrees/adrkit/mbeacom-potential-potato/packages/adapters/catalog-backstage/test/backstop-trigger.test.ts:34:33) +(fail) T074 — the enumeration is closed, and its count is fifteen > there are fifteen classes, counted from the declaration [0.64ms] +(pass) T074 — the enumeration is closed, and its count is fifteen > the fifteenth is `inadmissible-descriptor`, ADR-0015 Condition of Acceptance 2 [0.03ms] +(pass) T074 — the enumeration is closed, and its count is fifteen > this module re-exports the one declaration rather than making a second [0.58ms] +(pass) T074 — the enumeration is closed, and its count is fifteen > the closed type is closed at runtime too [0.01ms] +(pass) T075 — the backstop is always present > `other-invalid-input` is a member of the closed enumeration +(pass) T075 — the backstop is always present > the other fourteen are named, and the backstop is not among them [0.02ms] +(pass) T075 — the backstop is always present > `otherInvalidInput` produces the backstop class and keeps the reason distinct [0.05ms] +(pass) T075 — the backstop is not dead code: a real input reaches it > a request omitting a listed source’s provenance is `other-invalid-input` [0.13ms] +(pass) T075 — the backstop is not dead code: a real input reaches it > a declaration naming a source the manifest never listed also reaches it [0.05ms] +(pass) T075 — the backstop is not dead code: a real input reaches it > a value outside the closed provenance domain also reaches it [0.03ms] +(pass) T075 — the backstop is not dead code: a real input reaches it > exactly three reasons map to the backstop, and none of them has a named class [0.07ms] +(pass) T075 / FR-036 — the backstop never absorbs a case that has its own class > every registered reason with a named class keeps that class [0.04ms] +(pass) T075 / FR-036 — the backstop never absorbs a case that has its own class > all fourteen named classes are actually claimed by at least one reason [0.02ms] + + 12 pass + 1 fail + 26 expect() calls +Ran 13 tests across 1 file. [47.00ms] +bun test v1.3.14 (0d9b296a) diff --git a/specs/010-catalog-backstage/evidence/negative-cases/triggers/case-3-trigger-count-transcribed-as-fourteen.patch b/specs/010-catalog-backstage/evidence/negative-cases/triggers/case-3-trigger-count-transcribed-as-fourteen.patch new file mode 100644 index 00000000..b83befbb --- /dev/null +++ b/specs/010-catalog-backstage/evidence/negative-cases/triggers/case-3-trigger-count-transcribed-as-fourteen.patch @@ -0,0 +1,13 @@ +diff --git a/packages/adapters/catalog-backstage/src/failure/triggers.ts b/packages/adapters/catalog-backstage/src/failure/triggers.ts +index 7b3f74d..5c9d150 100644 +--- a/packages/adapters/catalog-backstage/src/failure/triggers.ts ++++ b/packages/adapters/catalog-backstage/src/failure/triggers.ts +@@ -47,7 +47,7 @@ export { TRIGGER_CLASSES, type Rejection, type TriggerClass }; + * enumeration changed underneath it, which is the failure mode the whole + * fourteen-versus-fifteen trap consists of. + */ +-export const FATAL_TRIGGER_COUNT: number = TRIGGER_CLASSES.length; ++export const FATAL_TRIGGER_COUNT: number = 14; + + /** + * The deliberate, always-present backstop. diff --git a/specs/010-catalog-backstage/evidence/negative-cases/triggers/case-4-backstop-made-unreachable.observed.txt b/specs/010-catalog-backstage/evidence/negative-cases/triggers/case-4-backstop-made-unreachable.observed.txt new file mode 100644 index 00000000..ba9b9ab4 --- /dev/null +++ b/specs/010-catalog-backstage/evidence/negative-cases/triggers/case-4-backstop-made-unreachable.observed.txt @@ -0,0 +1,105 @@ + +packages/adapters/catalog-backstage/test/sc-003-all-triggers.test.ts: +(pass) T078 — the closed enumeration is fifteen > fifteen classes, counted from the declaration [0.02ms] +(pass) T078 — the four manifest-request-level classes > invalid-manifest-shape — an unrecognized top-level field [1.49ms] +(pass) T078 — the four manifest-request-level classes > unsupported-manifest-version [0.51ms] +(pass) T078 — the four manifest-request-level classes > unsupported-snapshot-version [0.35ms] +(pass) T078 — the four manifest-request-level classes > unsupported-capability [0.31ms] +(pass) T078 — the four manifest-request-level classes > incomplete-required-source — a listed source absent from the checkout [9.31ms] +(pass) T078 — repository identity > repository-mismatch [8.03ms] +(pass) T078 — descriptor parse: the pair §4.3 says must not collapse > duplicate-yaml-key [12.95ms] +(pass) T078 — descriptor parse: the pair §4.3 says must not collapse > invalid-yaml-syntax [9.07ms] +(pass) T078 — admissibility, the class ADR-0015 adds > inadmissible-descriptor [9.31ms] +(pass) T078 — identity uniqueness > duplicate-canonical-id [9.48ms] +(pass) T078 — identity uniqueness > duplicate-canonical-ref — at the stage kernel, on a synthetic identity set [0.09ms] +(pass) T078 — annotation decode and the frozen glob dialect > invalid-annotation-parse — the annotation is not valid JSON [9.15ms] +(pass) T078 — annotation decode and the frozen glob dialect > invalid-annotation-shape — the annotation decodes to an object [8.26ms] +(pass) T078 — annotation decode and the frozen glob dialect > invalid-pattern — a brace, which the dialect rejects at rule 6 [8.52ms] +192 | declaration: ProvenanceDeclaration, +193 | sourcePath: string, +194 | ): AnnotationProvenance { +195 | const value = declaration.bySourcePath[sourcePath]; +196 | if (value === undefined) { +197 | throw new Error( + ^ +error: no annotation provenance declared for "prov-b/catalog-info.yaml". checkProvenanceDeclaration must run before any entity record is built. + at provenanceFor (/Users/markbeacom/github/mbeacom/copilot-worktrees/adrkit/mbeacom-potential-potato/packages/adapters/catalog-backstage/src/envelope/provenance.ts:197:15) + at runGeneration (/Users/markbeacom/github/mbeacom/copilot-worktrees/adrkit/mbeacom-potential-potato/packages/adapters/catalog-backstage/src/pipeline.ts:415:21) + at async pipelineCase (/Users/markbeacom/github/mbeacom/copilot-worktrees/adrkit/mbeacom-potential-potato/packages/adapters/catalog-backstage/test/sc-003-all-triggers.test.ts:84:25) + at async (/Users/markbeacom/github/mbeacom/copilot-worktrees/adrkit/mbeacom-potential-potato/packages/adapters/catalog-backstage/test/sc-003-all-triggers.test.ts:339:11) +(fail) T078 — the backstop, reached by a genuinely invalid request > other-invalid-input — an undeclared source provenance [9.00ms] +(pass) T078 — the backstop, reached by a genuinely invalid request > invalid-manifest-shape — a manifest that is not there [0.07ms] +376 | +377 | describe('T078 / SC-003 — every one of the fifteen was observed', () => { +378 | test('all fifteen classes appear in the observed set', () => { +379 | const observed = [...OBSERVED_TRIGGERS.keys()].sort(); +380 | const expected = [...TRIGGER_CLASSES].sort(); +381 | expect(observed).toEqual(expected); + ^ +error: expect(received).toEqual(expected) + + [ + "duplicate-canonical-id", + "duplicate-canonical-ref", + "duplicate-yaml-key", + "inadmissible-descriptor", + "incomplete-required-source", + "invalid-annotation-parse", + "invalid-annotation-shape", + "invalid-manifest-shape", + "invalid-pattern", + "invalid-yaml-syntax", +- "other-invalid-input", + "repository-mismatch", + "unsupported-capability", + "unsupported-manifest-version", + "unsupported-snapshot-version", + ] + +- Expected - 1 ++ Received + 0 + + at (/Users/markbeacom/github/mbeacom/copilot-worktrees/adrkit/mbeacom-potential-potato/packages/adapters/catalog-backstage/test/sc-003-all-triggers.test.ts:381:22) +(fail) T078 / SC-003 — every one of the fifteen was observed > all fifteen classes appear in the observed set [0.18ms] +(pass) T078 / SC-003 — every one of the fifteen was observed > each observation carries its own distinct reason string [0.04ms] +(pass) T078 / SC-003 — every one of the fifteen was observed > every observation carries a non-empty detail [0.03ms] +(pass) T078 / SC-003 — every one of the fifteen was observed > exactly one of the fifteen was reached other than through the full pipeline [0.01ms] + +packages/adapters/catalog-backstage/test/backstop-trigger.test.ts: +(pass) T074 — the enumeration is closed, and its count is fifteen > there are fifteen classes, counted from the declaration [0.02ms] +(pass) T074 — the enumeration is closed, and its count is fifteen > the fifteenth is `inadmissible-descriptor`, ADR-0015 Condition of Acceptance 2 +(pass) T074 — the enumeration is closed, and its count is fifteen > this module re-exports the one declaration rather than making a second [0.03ms] +(pass) T074 — the enumeration is closed, and its count is fifteen > the closed type is closed at runtime too [0.01ms] +(pass) T075 — the backstop is always present > `other-invalid-input` is a member of the closed enumeration +(pass) T075 — the backstop is always present > the other fourteen are named, and the backstop is not among them +(pass) T075 — the backstop is always present > `otherInvalidInput` produces the backstop class and keeps the reason distinct [0.01ms] +75 | }); +76 | +77 | describe('T075 — the backstop is not dead code: a real input reaches it', () => { +78 | test('a request omitting a listed source\u2019s provenance is `other-invalid-input`', () => { +79 | const outcome = checkProvenanceDeclaration({ bySourcePath: {} }, ['catalog-info.yaml']); +80 | expect(outcome.ok).toBe(false); + ^ +error: expect(received).toBe(expected) + +Expected: false +Received: true + + at (/Users/markbeacom/github/mbeacom/copilot-worktrees/adrkit/mbeacom-potential-potato/packages/adapters/catalog-backstage/test/backstop-trigger.test.ts:80:24) +(fail) T075 — the backstop is not dead code: a real input reaches it > a request omitting a listed source’s provenance is `other-invalid-input` [0.07ms] +(pass) T075 — the backstop is not dead code: a real input reaches it > a declaration naming a source the manifest never listed also reaches it [0.02ms] +(pass) T075 — the backstop is not dead code: a real input reaches it > a value outside the closed provenance domain also reaches it [0.02ms] +(pass) T075 — the backstop is not dead code: a real input reaches it > exactly three reasons map to the backstop, and none of them has a named class [0.05ms] +(pass) T075 / FR-036 — the backstop never absorbs a case that has its own class > every registered reason with a named class keeps that class [0.03ms] +(pass) T075 / FR-036 — the backstop never absorbs a case that has its own class > all fourteen named classes are actually claimed by at least one reason [0.02ms] + +3 tests failed: +(fail) T078 — the backstop, reached by a genuinely invalid request > other-invalid-input — an undeclared source provenance [9.00ms] +(fail) T078 / SC-003 — every one of the fifteen was observed > all fifteen classes appear in the observed set [0.18ms] +(fail) T075 — the backstop is not dead code: a real input reaches it > a request omitting a listed source’s provenance is `other-invalid-input` [0.07ms] + + 31 pass + 3 fail + 107 expect() calls +Ran 34 tests across 2 files. [179.00ms] +bun test v1.3.14 (0d9b296a) diff --git a/specs/010-catalog-backstage/evidence/negative-cases/triggers/case-4-backstop-made-unreachable.patch b/specs/010-catalog-backstage/evidence/negative-cases/triggers/case-4-backstop-made-unreachable.patch new file mode 100644 index 00000000..3b63ca3e --- /dev/null +++ b/specs/010-catalog-backstage/evidence/negative-cases/triggers/case-4-backstop-made-unreachable.patch @@ -0,0 +1,13 @@ +diff --git a/packages/adapters/catalog-backstage/src/envelope/provenance.ts b/packages/adapters/catalog-backstage/src/envelope/provenance.ts +index 1631479..7384615 100644 +--- a/packages/adapters/catalog-backstage/src/envelope/provenance.ts ++++ b/packages/adapters/catalog-backstage/src/envelope/provenance.ts +@@ -135,7 +135,7 @@ export function checkProvenanceDeclaration( + const listed = [...new Set(sourcePaths)].sort(compareCodeUnits); + + for (const path of listed) { +- if (!Object.hasOwn(declared, path)) { ++ if (false && !Object.hasOwn(declared, path)) { + return { + ok: false, + rejection: otherInvalidInput( diff --git a/specs/010-catalog-backstage/evidence/negative-cases/triggers/case-5-backstop-reason-remapped-to-a-named-class.observed.txt b/specs/010-catalog-backstage/evidence/negative-cases/triggers/case-5-backstop-reason-remapped-to-a-named-class.observed.txt new file mode 100644 index 00000000..e5ee26fe --- /dev/null +++ b/specs/010-catalog-backstage/evidence/negative-cases/triggers/case-5-backstop-reason-remapped-to-a-named-class.observed.txt @@ -0,0 +1,115 @@ + +packages/adapters/catalog-backstage/test/sc-003-all-triggers.test.ts: +(pass) T078 — the closed enumeration is fifteen > fifteen classes, counted from the declaration [0.02ms] +(pass) T078 — the four manifest-request-level classes > invalid-manifest-shape — an unrecognized top-level field [1.55ms] +(pass) T078 — the four manifest-request-level classes > unsupported-manifest-version [0.57ms] +(pass) T078 — the four manifest-request-level classes > unsupported-snapshot-version [0.43ms] +(pass) T078 — the four manifest-request-level classes > unsupported-capability [0.38ms] +(pass) T078 — the four manifest-request-level classes > incomplete-required-source — a listed source absent from the checkout [9.02ms] +(pass) T078 — repository identity > repository-mismatch [7.42ms] +(pass) T078 — descriptor parse: the pair §4.3 says must not collapse > duplicate-yaml-key [12.65ms] +(pass) T078 — descriptor parse: the pair §4.3 says must not collapse > invalid-yaml-syntax [8.68ms] +(pass) T078 — admissibility, the class ADR-0015 adds > inadmissible-descriptor [9.17ms] +(pass) T078 — identity uniqueness > duplicate-canonical-id [8.65ms] +(pass) T078 — identity uniqueness > duplicate-canonical-ref — at the stage kernel, on a synthetic identity set [0.04ms] +(pass) T078 — annotation decode and the frozen glob dialect > invalid-annotation-parse — the annotation is not valid JSON [8.79ms] +(pass) T078 — annotation decode and the frozen glob dialect > invalid-annotation-shape — the annotation decodes to an object [8.33ms] +(pass) T078 — annotation decode and the frozen glob dialect > invalid-pattern — a brace, which the dialect rejects at rule 6 [8.29ms] +183 | stage: string, +184 | location: FailureLocation = {}, +185 | ): AtomicFailureRecord { +186 | const expected = expectedTriggerFor(rejection.reason); +187 | if (expected !== rejection.triggerClass) { +188 | throw new TriggerClassificationError(rejection.reason, rejection.triggerClass, expected); + ^ +TriggerClassificationError: reason "provenance-declaration-missing" emitted trigger class "other-invalid-input", but the contracts assign it "incomplete-required-source". Exactly one of the two is wrong (FR-037; atomic-fail-closed.md §4.3). + reason: "provenance-declaration-missing", + emitted: "other-invalid-input", + expected: "incomplete-required-source", + + at classifyAbort (/Users/markbeacom/github/mbeacom/copilot-worktrees/adrkit/mbeacom-potential-potato/packages/adapters/catalog-backstage/src/failure/classify.ts:188:11) + at aborted (/Users/markbeacom/github/mbeacom/copilot-worktrees/adrkit/mbeacom-potential-potato/packages/adapters/catalog-backstage/src/pipeline.ts:191:32) + at async pipelineCase (/Users/markbeacom/github/mbeacom/copilot-worktrees/adrkit/mbeacom-potential-potato/packages/adapters/catalog-backstage/test/sc-003-all-triggers.test.ts:84:25) + at async (/Users/markbeacom/github/mbeacom/copilot-worktrees/adrkit/mbeacom-potential-potato/packages/adapters/catalog-backstage/test/sc-003-all-triggers.test.ts:339:11) +(fail) T078 — the backstop, reached by a genuinely invalid request > other-invalid-input — an undeclared source provenance [0.75ms] +(pass) T078 — the backstop, reached by a genuinely invalid request > invalid-manifest-shape — a manifest that is not there [0.08ms] +376 | +377 | describe('T078 / SC-003 — every one of the fifteen was observed', () => { +378 | test('all fifteen classes appear in the observed set', () => { +379 | const observed = [...OBSERVED_TRIGGERS.keys()].sort(); +380 | const expected = [...TRIGGER_CLASSES].sort(); +381 | expect(observed).toEqual(expected); + ^ +error: expect(received).toEqual(expected) + + [ + "duplicate-canonical-id", + "duplicate-canonical-ref", + "duplicate-yaml-key", + "inadmissible-descriptor", + "incomplete-required-source", + "invalid-annotation-parse", + "invalid-annotation-shape", + "invalid-manifest-shape", + "invalid-pattern", + "invalid-yaml-syntax", +- "other-invalid-input", + "repository-mismatch", + "unsupported-capability", + "unsupported-manifest-version", + "unsupported-snapshot-version", + ] + +- Expected - 1 ++ Received + 0 + + at (/Users/markbeacom/github/mbeacom/copilot-worktrees/adrkit/mbeacom-potential-potato/packages/adapters/catalog-backstage/test/sc-003-all-triggers.test.ts:381:22) +(fail) T078 / SC-003 — every one of the fifteen was observed > all fifteen classes appear in the observed set [0.15ms] +(pass) T078 / SC-003 — every one of the fifteen was observed > each observation carries its own distinct reason string [0.03ms] +(pass) T078 / SC-003 — every one of the fifteen was observed > every observation carries a non-empty detail [0.02ms] +(pass) T078 / SC-003 — every one of the fifteen was observed > exactly one of the fifteen was reached other than through the full pipeline [0.01ms] + +packages/adapters/catalog-backstage/test/backstop-trigger.test.ts: +(pass) T074 — the enumeration is closed, and its count is fifteen > there are fifteen classes, counted from the declaration [0.02ms] +(pass) T074 — the enumeration is closed, and its count is fifteen > the fifteenth is `inadmissible-descriptor`, ADR-0015 Condition of Acceptance 2 +(pass) T074 — the enumeration is closed, and its count is fifteen > this module re-exports the one declaration rather than making a second [0.03ms] +(pass) T074 — the enumeration is closed, and its count is fifteen > the closed type is closed at runtime too +(pass) T075 — the backstop is always present > `other-invalid-input` is a member of the closed enumeration +(pass) T075 — the backstop is always present > the other fourteen are named, and the backstop is not among them +(pass) T075 — the backstop is always present > `otherInvalidInput` produces the backstop class and keeps the reason distinct +(pass) T075 — the backstop is not dead code: a real input reaches it > a request omitting a listed source’s provenance is `other-invalid-input` [0.02ms] +(pass) T075 — the backstop is not dead code: a real input reaches it > a declaration naming a source the manifest never listed also reaches it [0.04ms] +(pass) T075 — the backstop is not dead code: a real input reaches it > a value outside the closed provenance domain also reaches it [0.03ms] +110 | const backstopReasons = Object.entries(REASON_TRIGGER_REGISTRY) +111 | .filter(([, trigger]) => trigger === BACKSTOP_TRIGGER) +112 | .map(([reason]) => reason) +113 | .sort(); +114 | +115 | expect(backstopReasons).toEqual([ + ^ +error: expect(received).toEqual(expected) + + [ +- "provenance-declaration-missing", + "provenance-declaration-unknown-source", + "provenance-declaration-unrecognized-value", + ] + +- Expected - 1 ++ Received + 0 + + at (/Users/markbeacom/github/mbeacom/copilot-worktrees/adrkit/mbeacom-potential-potato/packages/adapters/catalog-backstage/test/backstop-trigger.test.ts:115:29) +(fail) T075 — the backstop is not dead code: a real input reaches it > exactly three reasons map to the backstop, and none of them has a named class [0.08ms] +(pass) T075 / FR-036 — the backstop never absorbs a case that has its own class > every registered reason with a named class keeps that class [0.03ms] +(pass) T075 / FR-036 — the backstop never absorbs a case that has its own class > all fourteen named classes are actually claimed by at least one reason [0.02ms] + +3 tests failed: +(fail) T078 — the backstop, reached by a genuinely invalid request > other-invalid-input — an undeclared source provenance [0.75ms] +(fail) T078 / SC-003 — every one of the fifteen was observed > all fifteen classes appear in the observed set [0.15ms] +(fail) T075 — the backstop is not dead code: a real input reaches it > exactly three reasons map to the backstop, and none of them has a named class [0.08ms] + + 31 pass + 3 fail + 109 expect() calls +Ran 34 tests across 2 files. [169.00ms] +bun test v1.3.14 (0d9b296a) diff --git a/specs/010-catalog-backstage/evidence/negative-cases/triggers/case-5-backstop-reason-remapped-to-a-named-class.patch b/specs/010-catalog-backstage/evidence/negative-cases/triggers/case-5-backstop-reason-remapped-to-a-named-class.patch new file mode 100644 index 00000000..63f60b86 --- /dev/null +++ b/specs/010-catalog-backstage/evidence/negative-cases/triggers/case-5-backstop-reason-remapped-to-a-named-class.patch @@ -0,0 +1,13 @@ +diff --git a/packages/adapters/catalog-backstage/src/failure/classify.ts b/packages/adapters/catalog-backstage/src/failure/classify.ts +index 0b37ace..67d70a0 100644 +--- a/packages/adapters/catalog-backstage/src/failure/classify.ts ++++ b/packages/adapters/catalog-backstage/src/failure/classify.ts +@@ -129,7 +129,7 @@ export const REASON_TRIGGER_REGISTRY: Readonly> = { + + // ── The backstop. `atomic-fail-closed.md` §4.2. See `failure/triggers.ts` for why + // this is genuinely reachable rather than a formality. +- 'provenance-declaration-missing': 'other-invalid-input', ++ 'provenance-declaration-missing': 'incomplete-required-source', + 'provenance-declaration-unknown-source': 'other-invalid-input', + 'provenance-declaration-unrecognized-value': 'other-invalid-input', + }; diff --git a/specs/010-catalog-backstage/evidence/negative-cases/triggers/restored.observed.txt b/specs/010-catalog-backstage/evidence/negative-cases/triggers/restored.observed.txt new file mode 100644 index 00000000..96ae6a68 --- /dev/null +++ b/specs/010-catalog-backstage/evidence/negative-cases/triggers/restored.observed.txt @@ -0,0 +1,59 @@ +bun test v1.3.14 (0d9b296a) + +test/sc-003-all-triggers.test.ts: +(pass) T078 — the closed enumeration is fifteen > fifteen classes, counted from the declaration [0.02ms] +(pass) T078 — the four manifest-request-level classes > invalid-manifest-shape — an unrecognized top-level field [1.54ms] +(pass) T078 — the four manifest-request-level classes > unsupported-manifest-version [0.53ms] +(pass) T078 — the four manifest-request-level classes > unsupported-snapshot-version [0.36ms] +(pass) T078 — the four manifest-request-level classes > unsupported-capability [0.33ms] +(pass) T078 — the four manifest-request-level classes > incomplete-required-source — a listed source absent from the checkout [8.56ms] +(pass) T078 — repository identity > repository-mismatch [7.81ms] +(pass) T078 — descriptor parse: the pair §4.3 says must not collapse > duplicate-yaml-key [11.46ms] +(pass) T078 — descriptor parse: the pair §4.3 says must not collapse > invalid-yaml-syntax [9.03ms] +(pass) T078 — admissibility, the class ADR-0015 adds > inadmissible-descriptor [8.38ms] +(pass) T078 — identity uniqueness > duplicate-canonical-id [10.11ms] +(pass) T078 — identity uniqueness > duplicate-canonical-ref — at the stage kernel, on a synthetic identity set [0.04ms] +(pass) T078 — annotation decode and the frozen glob dialect > invalid-annotation-parse — the annotation is not valid JSON [7.83ms] +(pass) T078 — annotation decode and the frozen glob dialect > invalid-annotation-shape — the annotation decodes to an object [7.80ms] +(pass) T078 — annotation decode and the frozen glob dialect > invalid-pattern — a brace, which the dialect rejects at rule 6 [7.63ms] +(pass) T078 — the backstop, reached by a genuinely invalid request > other-invalid-input — an undeclared source provenance [0.52ms] +(pass) T078 — the backstop, reached by a genuinely invalid request > invalid-manifest-shape — a manifest that is not there [0.05ms] +(pass) T078 / SC-003 — every one of the fifteen was observed > all fifteen classes appear in the observed set [0.08ms] +(pass) T078 / SC-003 — every one of the fifteen was observed > each observation carries its own distinct reason string [0.02ms] +(pass) T078 / SC-003 — every one of the fifteen was observed > every observation carries a non-empty detail [0.02ms] +(pass) T078 / SC-003 — every one of the fifteen was observed > exactly one of the fifteen was reached other than through the full pipeline [0.01ms] + +test/backstop-trigger.test.ts: +(pass) T074 — the enumeration is closed, and its count is fifteen > there are fifteen classes, counted from the declaration [0.01ms] +(pass) T074 — the enumeration is closed, and its count is fifteen > the fifteenth is `inadmissible-descriptor`, ADR-0015 Condition of Acceptance 2 +(pass) T074 — the enumeration is closed, and its count is fifteen > this module re-exports the one declaration rather than making a second [0.02ms] +(pass) T074 — the enumeration is closed, and its count is fifteen > the closed type is closed at runtime too +(pass) T075 — the backstop is always present > `other-invalid-input` is a member of the closed enumeration +(pass) T075 — the backstop is always present > the other fourteen are named, and the backstop is not among them +(pass) T075 — the backstop is always present > `otherInvalidInput` produces the backstop class and keeps the reason distinct +(pass) T075 — the backstop is not dead code: a real input reaches it > a request omitting a listed source’s provenance is `other-invalid-input` [0.02ms] +(pass) T075 — the backstop is not dead code: a real input reaches it > a declaration naming a source the manifest never listed also reaches it [0.02ms] +(pass) T075 — the backstop is not dead code: a real input reaches it > a value outside the closed provenance domain also reaches it [0.02ms] +(pass) T075 — the backstop is not dead code: a real input reaches it > exactly three reasons map to the backstop, and none of them has a named class [0.05ms] +(pass) T075 / FR-036 — the backstop never absorbs a case that has its own class > every registered reason with a named class keeps that class [0.03ms] +(pass) T075 / FR-036 — the backstop never absorbs a case that has its own class > all fourteen named classes are actually claimed by at least one reason [0.02ms] + +test/trigger-classification.test.ts: +(pass) T076 — every emitted rejection agrees with the contract-sourced registry > the sweep actually produced rejections, so the check below read something [0.01ms] +(pass) T076 — every emitted rejection agrees with the contract-sourced registry > every emitted reason is registered [0.02ms] +(pass) T076 — every emitted rejection agrees with the contract-sourced registry > every emitted class matches the class the contracts assign its reason [0.02ms] +(pass) T076 — every emitted rejection agrees with the contract-sourced registry > every class the registry names is a member of the closed enumeration [0.02ms] +(pass) T076 / §4.3 — the collapsible pairs stay distinct > the contract names three pairs, and they are checked by name +(pass) T076 / §4.3 — the collapsible pairs stay distinct > duplicate-yaml-key is not invalid-yaml-syntax [0.15ms] +(pass) T076 / §4.3 — the collapsible pairs stay distinct > an unrecognized top-level manifest field is invalid-manifest-shape, not a version problem [0.02ms] +(pass) T076 / §4.3 — the collapsible pairs stay distinct > unsupported-manifest-version presumes a well-shaped manifest with a bad value [0.01ms] +(pass) T076 / §4.3 — the collapsible pairs stay distinct > a lexically invalid source path is invalid-manifest-shape, not incomplete-required-source [0.01ms] +(pass) T076 — classifyAbort refuses a disagreement rather than repairing it > a rejection whose class contradicts the registry throws [0.05ms] +(pass) T076 — classifyAbort refuses a disagreement rather than repairing it > an unregistered reason throws rather than being trusted [0.02ms] +(pass) T076 — classifyAbort refuses a disagreement rather than repairing it > the error names both sides, so the disagreement is legible [0.02ms] +(pass) T076 — classifyAbort refuses a disagreement rather than repairing it > an agreeing rejection yields exactly one record [0.01ms] + + 47 pass + 0 fail + 138 expect() calls +Ran 47 tests across 3 files. [163.00ms] diff --git a/specs/010-catalog-backstage/evidence/negative-cases/whole-operation-atomicity/README.md b/specs/010-catalog-backstage/evidence/negative-cases/whole-operation-atomicity/README.md new file mode 100644 index 00000000..c8c7e58b --- /dev/null +++ b/specs/010-catalog-backstage/evidence/negative-cases/whole-operation-atomicity/README.md @@ -0,0 +1,114 @@ +# Negative case: whole-operation atomicity + +**Tasks**: T071, T073, T077 · **Discharges**: FR-023, FR-034 · **Supports**: SC-002 +**Observed against**: `19f316413d5550b30296e7987c74d267c96655f9` plus Phase E's own +uncommitted work; in each case the named mutation was the only additional change in the +tree, and it was reverted before the next case was run. +**Tools**: Bun 1.3.14, TypeScript 6.0.3 +**Command for every case below**: `bun test `, run from +`packages/adapters/catalog-backstage/` +**Permanent automated cases**: `test/sc-002-mixed-batch.test.ts`, `test/abort.test.ts`, +`test/uniqueness.test.ts` + +## What is being protected + +`atomic-fail-closed.md` §1: any invalid input "MUST abort the **entire** run with +non-zero status and produce **no usable partial snapshot**, including for entities that +would otherwise have validated cleanly in the same run." + +§1 then names the mistake the rule exists to foreclose: *"skip the bad entity and keep +going"* — "explicitly wrong under this contract, regardless of how reasonable it might +seem as a convenience." + +§2 adds that this is a **separate property** from the per-rule validation Phase D +covers: "Passing the per-rule tests does not demonstrate this contract. The two +properties MUST be tested independently." + +The three cases below are the three distinct ways the property can be lost, and they +fail differently. That is the point of recording all three rather than one. + +--- + +## Case 1 — "skip the bad entity and keep going" + +Input: [`case-1-skip-the-bad-entity-and-keep-going.patch`](./case-1-skip-the-bad-entity-and-keep-going.patch) · +Output: [`case-1-skip-the-bad-entity-and-keep-going.observed.txt`](./case-1-skip-the-bad-entity-and-keep-going.observed.txt) + +`collectAdmitted`'s all-or-nothing branch is replaced with `continue`, which is exactly +the convenience §1 forbids. The batch of five valid entities plus one inadmissible +descriptor then produces an envelope covering the five: + +``` +(fail) T077 / SC-002 — §5's variant — the sixth entity is inadmissible instead > the same five plus the offender abort with inadmissible-descriptor +(fail) T077 / SC-002 — §5's variant — the sixth entity is inadmissible instead > no envelope exists — not even one covering the five that would have validated +(fail) T077 / §1 — "skip the bad entity and keep going" is not what happens > the consequence does not vary by which trigger fired +(fail) T077 / §1 — "skip the bad entity and keep going" is not what happens > an offender placed first aborts exactly as one placed last does +``` + +Note which cases *keep passing*: the duplicate-id, pattern, YAML-key and annotation +cases are unaffected, because skipping happens at admissibility. A suite that had tested +only one trigger's mixed batch would have missed this entirely — which is why +`test/sc-002-mixed-batch.test.ts` runs the same experiment across five different +triggers. + +## Case 2 — an envelope written on the failure branch + +Input: [`case-2-envelope-written-on-the-failure-branch.patch`](./case-2-envelope-written-on-the-failure-branch.patch) · +Output: [`case-2-envelope-written-on-the-failure-branch.observed.txt`](./case-2-envelope-written-on-the-failure-branch.observed.txt) + +The other half of "no usable partial output": the run correctly aborts, and then writes +something anyway. `generateAndWriteEnvelope` is changed to write a partial artifact +before returning the failure. Seven tests fail — every "no envelope exists" case across +all five triggers, plus both of T073's directory checks: + +``` +(fail) T077 / SC-002 — [all five triggers] > no envelope exists — not even one covering the five that would have validated +(fail) T073 / §3 — no output exists for the five entities that would have validated > the destination path is never created +(fail) T073 / §3 — no output exists for the five entities that would have validated > no side file is left in the destination directory either +``` + +This is the case that justifies checking the **filesystem** rather than the writer's +return value: the mutated writer still reports `ok: false`, and only the directory +contradicts it. + +## Case 3 — uniqueness resolved by last-wins + +Input: [`case-3-uniqueness-resolved-by-last-wins.patch`](./case-3-uniqueness-resolved-by-last-wins.patch) · +Output: [`case-3-uniqueness-resolved-by-last-wins.observed.txt`](./case-3-uniqueness-resolved-by-last-wins.observed.txt) + +`entity-identity.md` §3: collisions may not "be resolved by first-wins or last-wins". +Disabling the collision branch makes the `Map` retain the last occurrence silently — a +last-wins resolution that produces a plausible-looking envelope. Thirteen tests fail, +spanning both the uniqueness kernel and the mixed-batch property: + +``` +(fail) T077 / SC-002 — §3's own worked example — a sixth entity with a duplicate canonical id > the same five plus the offender abort with duplicate-canonical-id +(fail) T077 / §1 — "skip the bad entity and keep going" is not what happens > the abort is not a filtered result with five entities in it +(fail) T071 — row 1: identical canonical ids are `duplicate-canonical-id` > two descriptors canonicalizing alike collide +(fail) T071 — row 2: an alias colliding with a different entity's id is `duplicate-canonical-ref` > §3's worked example, on a synthetic identity set +``` + +The accept-corpus freeze's own selection basis makes the same point about its +construction: "EVERY member of any colliding group excluded rather than one member kept +— keeping one would be last-wins resolution, which entity-identity.md §3 forbids, moved +earlier so it is harder to see." + +--- + +## The non-zero exit status + +FR-034 requires a non-zero **process** exit status, not a constant. `test/abort.test.ts` +spawns a real subprocess through `exitCodeFor` and asserts on the observed exit code, so +the status is an observation rather than an assertion about a value this package also +defines. The probe script is generated at run time rather than committed, so the test +cannot pass by reading a file someone edited to say the right thing. + +## Restored + +[`restored.observed.txt`](./restored.observed.txt) — all tests pass, 0 fail, across the +three named test files. + +## Standing constraints + +ADR-0014 **rung 1 only**. Nothing here is external, third-party, or community +validation. diff --git a/specs/010-catalog-backstage/evidence/negative-cases/whole-operation-atomicity/case-1-skip-the-bad-entity-and-keep-going.observed.txt b/specs/010-catalog-backstage/evidence/negative-cases/whole-operation-atomicity/case-1-skip-the-bad-entity-and-keep-going.observed.txt new file mode 100644 index 00000000..17d0087a --- /dev/null +++ b/specs/010-catalog-backstage/evidence/negative-cases/whole-operation-atomicity/case-1-skip-the-bad-entity-and-keep-going.observed.txt @@ -0,0 +1,78 @@ + +packages/adapters/catalog-backstage/test/sc-002-mixed-batch.test.ts: +(pass) T077 / SC-002 — one invalid entity aborts the whole run > §3’s own worked example — a sixth entity with a duplicate canonical id > the five without the offender produce a populated envelope [30.40ms] +(pass) T077 / SC-002 — one invalid entity aborts the whole run > §3’s own worked example — a sixth entity with a duplicate canonical id > the same five plus the offender abort with duplicate-canonical-id [21.00ms] +(pass) T077 / SC-002 — one invalid entity aborts the whole run > §3’s own worked example — a sixth entity with a duplicate canonical id > no envelope exists — not even one covering the five that would have validated [28.84ms] +(pass) T077 / SC-002 — one invalid entity aborts the whole run > §5’s variant — the sixth entity is inadmissible instead > the five without the offender produce a populated envelope [19.84ms] +148 | expect(control.envelope.entities).toHaveLength(5); +149 | }); +150 | +151 | test(`the same five plus the offender abort with ${testCase.triggerClass}`, async () => { +152 | const { mixed } = await mixedBatch(testCase.prefix, testCase.offenderPath, testCase.offenderText); +153 | expect(mixed.ok).toBe(false); + ^ +error: expect(received).toBe(expected) + +Expected: false +Received: true + + at (/Users/markbeacom/github/mbeacom/copilot-worktrees/adrkit/mbeacom-potential-potato/packages/adapters/catalog-backstage/test/sc-002-mixed-batch.test.ts:153:26) +(fail) T077 / SC-002 — one invalid entity aborts the whole run > §5’s variant — the sixth entity is inadmissible instead > the same five plus the offender abort with inadmissible-descriptor [17.05ms] +159 | const { mixed, mixedRequest } = await mixedBatch( +160 | testCase.prefix, +161 | testCase.offenderPath, +162 | testCase.offenderText, +163 | ); +164 | expect(mixed.ok).toBe(false); + ^ +error: expect(received).toBe(expected) + +Expected: false +Received: true + + at (/Users/markbeacom/github/mbeacom/copilot-worktrees/adrkit/mbeacom-potential-potato/packages/adapters/catalog-backstage/test/sc-002-mixed-batch.test.ts:164:26) +(fail) T077 / SC-002 — one invalid entity aborts the whole run > §5’s variant — the sixth entity is inadmissible instead > no envelope exists — not even one covering the five that would have validated [17.89ms] +(pass) T077 / SC-002 — one invalid entity aborts the whole run > the sixth entity declares a pattern the frozen dialect rejects > the five without the offender produce a populated envelope [17.94ms] +(pass) T077 / SC-002 — one invalid entity aborts the whole run > the sixth entity declares a pattern the frozen dialect rejects > the same five plus the offender abort with invalid-pattern [17.25ms] +(pass) T077 / SC-002 — one invalid entity aborts the whole run > the sixth entity declares a pattern the frozen dialect rejects > no envelope exists — not even one covering the five that would have validated [28.29ms] +(pass) T077 / SC-002 — one invalid entity aborts the whole run > the sixth entity repeats a YAML mapping key > the five without the offender produce a populated envelope [19.44ms] +(pass) T077 / SC-002 — one invalid entity aborts the whole run > the sixth entity repeats a YAML mapping key > the same five plus the offender abort with duplicate-yaml-key [17.80ms] +(pass) T077 / SC-002 — one invalid entity aborts the whole run > the sixth entity repeats a YAML mapping key > no envelope exists — not even one covering the five that would have validated [26.36ms] +(pass) T077 / SC-002 — one invalid entity aborts the whole run > the sixth entity’s annotation is not valid JSON > the five without the offender produce a populated envelope [20.08ms] +(pass) T077 / SC-002 — one invalid entity aborts the whole run > the sixth entity’s annotation is not valid JSON > the same five plus the offender abort with invalid-annotation-parse [18.25ms] +(pass) T077 / SC-002 — one invalid entity aborts the whole run > the sixth entity’s annotation is not valid JSON > no envelope exists — not even one covering the five that would have validated [28.82ms] +(pass) T077 / §1 — "skip the bad entity and keep going" is not what happens > the abort is not a filtered result with five entities in it [19.41ms] +195 | // backstop, fired". Checked across every case above rather than on one. The +196 | // fixtures are re-staged unchanged — an earlier version re-prefixed them, which +197 | // silently made the duplicate-id offender stop duplicating anything. +198 | for (const testCase of CASES) { +199 | const { mixed } = await mixedBatch(testCase.prefix, testCase.offenderPath, testCase.offenderText); +200 | expect(mixed.ok).toBe(false); + ^ +error: expect(received).toBe(expected) + +Expected: false +Received: true + + at (/Users/markbeacom/github/mbeacom/copilot-worktrees/adrkit/mbeacom-potential-potato/packages/adapters/catalog-backstage/test/sc-002-mixed-batch.test.ts:200:24) +(fail) T077 / §1 — "skip the bad entity and keep going" is not what happens > the consequence does not vary by which trigger fired [36.59ms] +228 | ); +229 | +230 | const firstOutcome = await runGeneration(first); +231 | const lastOutcome = await runGeneration(last); +232 | +233 | expect(firstOutcome.ok).toBe(false); + ^ +error: expect(received).toBe(expected) + +Expected: false +Received: true + + at (/Users/markbeacom/github/mbeacom/copilot-worktrees/adrkit/mbeacom-potential-potato/packages/adapters/catalog-backstage/test/sc-002-mixed-batch.test.ts:233:29) +(fail) T077 / §1 — "skip the bad entity and keep going" is not what happens > an offender placed first aborts exactly as one placed last does [21.66ms] + + 14 pass + 4 fail + 39 expect() calls +Ran 18 tests across 1 file. [500.00ms] +bun test v1.3.14 (0d9b296a) diff --git a/specs/010-catalog-backstage/evidence/negative-cases/whole-operation-atomicity/case-1-skip-the-bad-entity-and-keep-going.patch b/specs/010-catalog-backstage/evidence/negative-cases/whole-operation-atomicity/case-1-skip-the-bad-entity-and-keep-going.patch new file mode 100644 index 00000000..df8626b6 --- /dev/null +++ b/specs/010-catalog-backstage/evidence/negative-cases/whole-operation-atomicity/case-1-skip-the-bad-entity-and-keep-going.patch @@ -0,0 +1,13 @@ +diff --git a/packages/adapters/catalog-backstage/src/admissibility/index.ts b/packages/adapters/catalog-backstage/src/admissibility/index.ts +index 6beee3a..684d00c 100644 +--- a/packages/adapters/catalog-backstage/src/admissibility/index.ts ++++ b/packages/adapters/catalog-backstage/src/admissibility/index.ts +@@ -136,7 +136,7 @@ export function collectAdmitted( + for (const document of documents) { + const outcome = admit(document); + if (!outcome.admissible) { +- return { ok: false, result: outcome.result, rejection: outcome.rejection }; ++ continue; + } + admitted.push(outcome.admitted); + } diff --git a/specs/010-catalog-backstage/evidence/negative-cases/whole-operation-atomicity/case-2-envelope-written-on-the-failure-branch.observed.txt b/specs/010-catalog-backstage/evidence/negative-cases/whole-operation-atomicity/case-2-envelope-written-on-the-failure-branch.observed.txt new file mode 100644 index 00000000..3f2ea4fb --- /dev/null +++ b/specs/010-catalog-backstage/evidence/negative-cases/whole-operation-atomicity/case-2-envelope-written-on-the-failure-branch.observed.txt @@ -0,0 +1,140 @@ + +packages/adapters/catalog-backstage/test/sc-002-mixed-batch.test.ts: +(pass) T077 / SC-002 — one invalid entity aborts the whole run > §3’s own worked example — a sixth entity with a duplicate canonical id > the five without the offender produce a populated envelope [31.28ms] +(pass) T077 / SC-002 — one invalid entity aborts the whole run > §3’s own worked example — a sixth entity with a duplicate canonical id > the same five plus the offender abort with duplicate-canonical-id [20.86ms] +167 | const written = await generateAndWriteEnvelope(mixedRequest, join(directory, 'envelope.json')); +168 | expect(written.ok).toBe(false); +169 | +170 | // `readdir` on a directory that was never created throws. That is the +171 | // assertion: not "the file is empty", but "nothing was produced at all". +172 | await expect(readdir(directory)).rejects.toThrow(); + ^ +error: + +Expected promise that rejects +Received promise that resolved: Promise { } + + at (/Users/markbeacom/github/mbeacom/copilot-worktrees/adrkit/mbeacom-potential-potato/packages/adapters/catalog-backstage/test/sc-002-mixed-batch.test.ts:172:50) +(fail) T077 / SC-002 — one invalid entity aborts the whole run > §3’s own worked example — a sixth entity with a duplicate canonical id > no envelope exists — not even one covering the five that would have validated [31.84ms] +(pass) T077 / SC-002 — one invalid entity aborts the whole run > §5’s variant — the sixth entity is inadmissible instead > the five without the offender produce a populated envelope [18.40ms] +(pass) T077 / SC-002 — one invalid entity aborts the whole run > §5’s variant — the sixth entity is inadmissible instead > the same five plus the offender abort with inadmissible-descriptor [18.75ms] +167 | const written = await generateAndWriteEnvelope(mixedRequest, join(directory, 'envelope.json')); +168 | expect(written.ok).toBe(false); +169 | +170 | // `readdir` on a directory that was never created throws. That is the +171 | // assertion: not "the file is empty", but "nothing was produced at all". +172 | await expect(readdir(directory)).rejects.toThrow(); + ^ +error: + +Expected promise that rejects +Received promise that resolved: Promise { } + + at (/Users/markbeacom/github/mbeacom/copilot-worktrees/adrkit/mbeacom-potential-potato/packages/adapters/catalog-backstage/test/sc-002-mixed-batch.test.ts:172:50) +(fail) T077 / SC-002 — one invalid entity aborts the whole run > §5’s variant — the sixth entity is inadmissible instead > no envelope exists — not even one covering the five that would have validated [29.41ms] +(pass) T077 / SC-002 — one invalid entity aborts the whole run > the sixth entity declares a pattern the frozen dialect rejects > the five without the offender produce a populated envelope [19.48ms] +(pass) T077 / SC-002 — one invalid entity aborts the whole run > the sixth entity declares a pattern the frozen dialect rejects > the same five plus the offender abort with invalid-pattern [19.15ms] +167 | const written = await generateAndWriteEnvelope(mixedRequest, join(directory, 'envelope.json')); +168 | expect(written.ok).toBe(false); +169 | +170 | // `readdir` on a directory that was never created throws. That is the +171 | // assertion: not "the file is empty", but "nothing was produced at all". +172 | await expect(readdir(directory)).rejects.toThrow(); + ^ +error: + +Expected promise that rejects +Received promise that resolved: Promise { } + + at (/Users/markbeacom/github/mbeacom/copilot-worktrees/adrkit/mbeacom-potential-potato/packages/adapters/catalog-backstage/test/sc-002-mixed-batch.test.ts:172:50) +(fail) T077 / SC-002 — one invalid entity aborts the whole run > the sixth entity declares a pattern the frozen dialect rejects > no envelope exists — not even one covering the five that would have validated [27.87ms] +(pass) T077 / SC-002 — one invalid entity aborts the whole run > the sixth entity repeats a YAML mapping key > the five without the offender produce a populated envelope [18.94ms] +(pass) T077 / SC-002 — one invalid entity aborts the whole run > the sixth entity repeats a YAML mapping key > the same five plus the offender abort with duplicate-yaml-key [18.03ms] +167 | const written = await generateAndWriteEnvelope(mixedRequest, join(directory, 'envelope.json')); +168 | expect(written.ok).toBe(false); +169 | +170 | // `readdir` on a directory that was never created throws. That is the +171 | // assertion: not "the file is empty", but "nothing was produced at all". +172 | await expect(readdir(directory)).rejects.toThrow(); + ^ +error: + +Expected promise that rejects +Received promise that resolved: Promise { } + + at (/Users/markbeacom/github/mbeacom/copilot-worktrees/adrkit/mbeacom-potential-potato/packages/adapters/catalog-backstage/test/sc-002-mixed-batch.test.ts:172:50) +(fail) T077 / SC-002 — one invalid entity aborts the whole run > the sixth entity repeats a YAML mapping key > no envelope exists — not even one covering the five that would have validated [26.97ms] +(pass) T077 / SC-002 — one invalid entity aborts the whole run > the sixth entity’s annotation is not valid JSON > the five without the offender produce a populated envelope [19.57ms] +(pass) T077 / SC-002 — one invalid entity aborts the whole run > the sixth entity’s annotation is not valid JSON > the same five plus the offender abort with invalid-annotation-parse [18.12ms] +167 | const written = await generateAndWriteEnvelope(mixedRequest, join(directory, 'envelope.json')); +168 | expect(written.ok).toBe(false); +169 | +170 | // `readdir` on a directory that was never created throws. That is the +171 | // assertion: not "the file is empty", but "nothing was produced at all". +172 | await expect(readdir(directory)).rejects.toThrow(); + ^ +error: + +Expected promise that rejects +Received promise that resolved: Promise { } + + at (/Users/markbeacom/github/mbeacom/copilot-worktrees/adrkit/mbeacom-potential-potato/packages/adapters/catalog-backstage/test/sc-002-mixed-batch.test.ts:172:50) +(fail) T077 / SC-002 — one invalid entity aborts the whole run > the sixth entity’s annotation is not valid JSON > no envelope exists — not even one covering the five that would have validated [28.71ms] +(pass) T077 / §1 — "skip the bad entity and keep going" is not what happens > the abort is not a filtered result with five entities in it [19.06ms] +(pass) T077 / §1 — "skip the bad entity and keep going" is not what happens > the consequence does not vary by which trigger fired [92.78ms] +(pass) T077 / §1 — "skip the bad entity and keep going" is not what happens > an offender placed first aborts exactly as one placed last does [20.05ms] + +packages/adapters/catalog-backstage/test/abort.test.ts: +(pass) T073 — the failure branch carries no envelope > a valid batch produces one, so the negative case below means something [8.11ms] +(pass) T073 — the failure branch carries no envelope > an aborting batch carries no envelope, and no field could hold one [10.32ms] +(pass) T073 — the failure branch carries no envelope > exactly one failure record, never a list [9.07ms] +93 | const destination = join(outputDirectory, 'abort-case', 'envelope.json'); +94 | +95 | const result = await generateAndWriteEnvelope(request, destination); +96 | expect(result.ok).toBe(false); +97 | +98 | expect(await Bun.file(destination).exists()).toBe(false); + ^ +error: expect(received).toBe(expected) + +Expected: false +Received: true + + at (/Users/markbeacom/github/mbeacom/copilot-worktrees/adrkit/mbeacom-potential-potato/packages/adapters/catalog-backstage/test/abort.test.ts:98:50) +(fail) T073 / §3 — no output exists for the five entities that would have validated > the destination path is never created [9.10ms] +106 | const result = await generateAndWriteEnvelope(request, destination); +107 | expect(result.ok).toBe(false); +108 | +109 | // The directory itself is never created, because the write is never reached. +110 | // `readdir` on an absent directory throws, which is the assertion. +111 | await expect(readdir(directory)).rejects.toThrow(); + ^ +error: + +Expected promise that rejects +Received promise that resolved: Promise { } + + at (/Users/markbeacom/github/mbeacom/copilot-worktrees/adrkit/mbeacom-potential-potato/packages/adapters/catalog-backstage/test/abort.test.ts:111:46) +(fail) T073 / §3 — no output exists for the five entities that would have validated > no side file is left in the destination directory either [9.58ms] +(pass) T073 / §3 — no output exists for the five entities that would have validated > the five valid entities really would have validated on their own [8.97ms] +(pass) T073 / FR-034 — non-zero process exit status > the mapping is 0 on success and 1 on abort [18.32ms] +(pass) T073 / FR-034 — non-zero process exit status > a real process exits non-zero, observed rather than asserted [70.87ms] +(pass) T073 — the write is atomic, so no truncated envelope is observable > the destination holds the complete serialization, byte for byte [10.48ms] +(pass) T073 — the write is atomic, so no truncated envelope is observable > no temporary file survives a successful write [8.96ms] +(pass) T073 — abortRecord carries the location the pipeline knew > an absent location is undefined rather than invented [0.04ms] +(pass) T073 — abortRecord carries the location the pipeline knew > a supplied location is carried verbatim [0.01ms] + +7 tests failed: +(fail) T077 / SC-002 — one invalid entity aborts the whole run > §3’s own worked example — a sixth entity with a duplicate canonical id > no envelope exists — not even one covering the five that would have validated [31.84ms] +(fail) T077 / SC-002 — one invalid entity aborts the whole run > §5’s variant — the sixth entity is inadmissible instead > no envelope exists — not even one covering the five that would have validated [29.41ms] +(fail) T077 / SC-002 — one invalid entity aborts the whole run > the sixth entity declares a pattern the frozen dialect rejects > no envelope exists — not even one covering the five that would have validated [27.87ms] +(fail) T077 / SC-002 — one invalid entity aborts the whole run > the sixth entity repeats a YAML mapping key > no envelope exists — not even one covering the five that would have validated [26.97ms] +(fail) T077 / SC-002 — one invalid entity aborts the whole run > the sixth entity’s annotation is not valid JSON > no envelope exists — not even one covering the five that would have validated [28.71ms] +(fail) T073 / §3 — no output exists for the five entities that would have validated > the destination path is never created [9.10ms] +(fail) T073 / §3 — no output exists for the five entities that would have validated > no side file is left in the destination directory either [9.58ms] + + 23 pass + 7 fail + 83 expect() calls +Ran 30 tests across 2 files. [771.00ms] +bun test v1.3.14 (0d9b296a) diff --git a/specs/010-catalog-backstage/evidence/negative-cases/whole-operation-atomicity/case-2-envelope-written-on-the-failure-branch.patch b/specs/010-catalog-backstage/evidence/negative-cases/whole-operation-atomicity/case-2-envelope-written-on-the-failure-branch.patch new file mode 100644 index 00000000..d195bd4b --- /dev/null +++ b/specs/010-catalog-backstage/evidence/negative-cases/whole-operation-atomicity/case-2-envelope-written-on-the-failure-branch.patch @@ -0,0 +1,15 @@ +diff --git a/packages/adapters/catalog-backstage/src/pipeline.ts b/packages/adapters/catalog-backstage/src/pipeline.ts +index e362b2b..0bc9e78 100644 +--- a/packages/adapters/catalog-backstage/src/pipeline.ts ++++ b/packages/adapters/catalog-backstage/src/pipeline.ts +@@ -476,6 +476,9 @@ export async function generateAndWriteEnvelope( + | { readonly ok: false; readonly failure: AtomicFailureRecord } + > { + const outcome = await runGeneration(request); +- if (!outcome.ok) return { ok: false, failure: outcome.failure }; ++ if (!outcome.ok) { ++ await Bun.write(destination, '{"partial":true}'); ++ return { ok: false, failure: outcome.failure }; ++ } + return { ok: true, envelope: outcome.envelope, write: await writeEnvelope(outcome.envelope, destination) }; + } diff --git a/specs/010-catalog-backstage/evidence/negative-cases/whole-operation-atomicity/case-3-uniqueness-resolved-by-last-wins.observed.txt b/specs/010-catalog-backstage/evidence/negative-cases/whole-operation-atomicity/case-3-uniqueness-resolved-by-last-wins.observed.txt new file mode 100644 index 00000000..8b55a4e0 --- /dev/null +++ b/specs/010-catalog-backstage/evidence/negative-cases/whole-operation-atomicity/case-3-uniqueness-resolved-by-last-wins.observed.txt @@ -0,0 +1,208 @@ + +packages/adapters/catalog-backstage/test/sc-002-mixed-batch.test.ts: +(pass) T077 / SC-002 — one invalid entity aborts the whole run > §3’s own worked example — a sixth entity with a duplicate canonical id > the five without the offender produce a populated envelope [32.06ms] +148 | expect(control.envelope.entities).toHaveLength(5); +149 | }); +150 | +151 | test(`the same five plus the offender abort with ${testCase.triggerClass}`, async () => { +152 | const { mixed } = await mixedBatch(testCase.prefix, testCase.offenderPath, testCase.offenderText); +153 | expect(mixed.ok).toBe(false); + ^ +error: expect(received).toBe(expected) + +Expected: false +Received: true + + at (/Users/markbeacom/github/mbeacom/copilot-worktrees/adrkit/mbeacom-potential-potato/packages/adapters/catalog-backstage/test/sc-002-mixed-batch.test.ts:153:26) +(fail) T077 / SC-002 — one invalid entity aborts the whole run > §3’s own worked example — a sixth entity with a duplicate canonical id > the same five plus the offender abort with duplicate-canonical-id [20.66ms] +159 | const { mixed, mixedRequest } = await mixedBatch( +160 | testCase.prefix, +161 | testCase.offenderPath, +162 | testCase.offenderText, +163 | ); +164 | expect(mixed.ok).toBe(false); + ^ +error: expect(received).toBe(expected) + +Expected: false +Received: true + + at (/Users/markbeacom/github/mbeacom/copilot-worktrees/adrkit/mbeacom-potential-potato/packages/adapters/catalog-backstage/test/sc-002-mixed-batch.test.ts:164:26) +(fail) T077 / SC-002 — one invalid entity aborts the whole run > §3’s own worked example — a sixth entity with a duplicate canonical id > no envelope exists — not even one covering the five that would have validated [20.94ms] +(pass) T077 / SC-002 — one invalid entity aborts the whole run > §5’s variant — the sixth entity is inadmissible instead > the five without the offender produce a populated envelope [20.83ms] +(pass) T077 / SC-002 — one invalid entity aborts the whole run > §5’s variant — the sixth entity is inadmissible instead > the same five plus the offender abort with inadmissible-descriptor [19.28ms] +(pass) T077 / SC-002 — one invalid entity aborts the whole run > §5’s variant — the sixth entity is inadmissible instead > no envelope exists — not even one covering the five that would have validated [27.93ms] +(pass) T077 / SC-002 — one invalid entity aborts the whole run > the sixth entity declares a pattern the frozen dialect rejects > the five without the offender produce a populated envelope [21.22ms] +(pass) T077 / SC-002 — one invalid entity aborts the whole run > the sixth entity declares a pattern the frozen dialect rejects > the same five plus the offender abort with invalid-pattern [19.27ms] +(pass) T077 / SC-002 — one invalid entity aborts the whole run > the sixth entity declares a pattern the frozen dialect rejects > no envelope exists — not even one covering the five that would have validated [28.63ms] +(pass) T077 / SC-002 — one invalid entity aborts the whole run > the sixth entity repeats a YAML mapping key > the five without the offender produce a populated envelope [21.10ms] +(pass) T077 / SC-002 — one invalid entity aborts the whole run > the sixth entity repeats a YAML mapping key > the same five plus the offender abort with duplicate-yaml-key [19.05ms] +(pass) T077 / SC-002 — one invalid entity aborts the whole run > the sixth entity repeats a YAML mapping key > no envelope exists — not even one covering the five that would have validated [28.35ms] +(pass) T077 / SC-002 — one invalid entity aborts the whole run > the sixth entity’s annotation is not valid JSON > the five without the offender produce a populated envelope [20.74ms] +(pass) T077 / SC-002 — one invalid entity aborts the whole run > the sixth entity’s annotation is not valid JSON > the same five plus the offender abort with invalid-annotation-parse [18.24ms] +(pass) T077 / SC-002 — one invalid entity aborts the whole run > the sixth entity’s annotation is not valid JSON > no envelope exists — not even one covering the five that would have validated [29.85ms] +183 | const { mixed } = await mixedBatch( +184 | 'skip', +185 | 'skip/sixth/catalog-info.yaml', +186 | validDescriptor('skipalpha'), +187 | ); +188 | expect(mixed.ok).toBe(false); + ^ +error: expect(received).toBe(expected) + +Expected: false +Received: true + + at (/Users/markbeacom/github/mbeacom/copilot-worktrees/adrkit/mbeacom-potential-potato/packages/adapters/catalog-backstage/test/sc-002-mixed-batch.test.ts:188:22) +(fail) T077 / §1 — "skip the bad entity and keep going" is not what happens > the abort is not a filtered result with five entities in it [20.20ms] +195 | // backstop, fired". Checked across every case above rather than on one. The +196 | // fixtures are re-staged unchanged — an earlier version re-prefixed them, which +197 | // silently made the duplicate-id offender stop duplicating anything. +198 | for (const testCase of CASES) { +199 | const { mixed } = await mixedBatch(testCase.prefix, testCase.offenderPath, testCase.offenderText); +200 | expect(mixed.ok).toBe(false); + ^ +error: expect(received).toBe(expected) + +Expected: false +Received: true + + at (/Users/markbeacom/github/mbeacom/copilot-worktrees/adrkit/mbeacom-potential-potato/packages/adapters/catalog-backstage/test/sc-002-mixed-batch.test.ts:200:24) +(fail) T077 / §1 — "skip the bad entity and keep going" is not what happens > the consequence does not vary by which trigger fired [19.78ms] +(pass) T077 / §1 — "skip the bad entity and keep going" is not what happens > an offender placed first aborts exactly as one placed last does [20.85ms] + +packages/adapters/catalog-backstage/test/uniqueness.test.ts: +(pass) T071 — the three collision classes §3 enumerates > all three are members of the closed trigger enumeration [0.03ms] +(pass) T071 — the three collision classes §3 enumerates > a distinct set passes, so the rejections below are not vacuous [0.07ms] +59 | test('two descriptors canonicalizing alike collide', () => { +60 | const outcome = checkGlobalUniqueness([ +61 | identity('component:default/payments'), +62 | identity('component:default/payments'), +63 | ]); +64 | expect(outcome.ok).toBe(false); + ^ +error: expect(received).toBe(expected) + +Expected: false +Received: true + + at (/Users/markbeacom/github/mbeacom/copilot-worktrees/adrkit/mbeacom-potential-potato/packages/adapters/catalog-backstage/test/uniqueness.test.ts:64:24) +(fail) T071 — row 1: identical canonical ids are `duplicate-canonical-id` > two descriptors canonicalizing alike collide [0.08ms] +76 | // entire id. So §1's worked example lands on row 1, not on row 3. +77 | const outcome = checkGlobalUniqueness([ +78 | identity('Component:Default/Payments'.toLowerCase()), +79 | identity('component:default/payments'), +80 | ]); +81 | expect(outcome.ok).toBe(false); + ^ +error: expect(received).toBe(expected) + +Expected: false +Received: true + + at (/Users/markbeacom/github/mbeacom/copilot-worktrees/adrkit/mbeacom-potential-potato/packages/adapters/catalog-backstage/test/uniqueness.test.ts:81:24) +(fail) T071 — row 1: identical canonical ids are `duplicate-canonical-id` > the case-only pair §1 canonicalizes together arrives here already identical [0.04ms] +89 | // Synthetic: `allRefs` beyond `canonicalId` has no descriptor-sourced route. +90 | const outcome = checkGlobalUniqueness([ +91 | identity('component:default/billing', 'component:default/billing-legacy'), +92 | identity('component:default/billing-legacy'), +93 | ]); +94 | expect(outcome.ok).toBe(false); + ^ +error: expect(received).toBe(expected) + +Expected: false +Received: true + + at (/Users/markbeacom/github/mbeacom/copilot-worktrees/adrkit/mbeacom-potential-potato/packages/adapters/catalog-backstage/test/uniqueness.test.ts:94:24) +(fail) T071 — row 2: an alias colliding with a different entity’s id is `duplicate-canonical-ref` > §3’s worked example, on a synthetic identity set [0.04ms] +103 | test('an alias-vs-alias collision is also `duplicate-canonical-ref`', () => { +104 | const outcome = checkGlobalUniqueness([ +105 | identity('component:default/a', 'component:default/shared'), +106 | identity('component:default/b', 'component:default/shared'), +107 | ]); +108 | expect(outcome.ok).toBe(false); + ^ +error: expect(received).toBe(expected) + +Expected: false +Received: true + + at (/Users/markbeacom/github/mbeacom/copilot-worktrees/adrkit/mbeacom-potential-potato/packages/adapters/catalog-backstage/test/uniqueness.test.ts:108:24) +(fail) T071 — row 2: an alias colliding with a different entity’s id is `duplicate-canonical-ref` > an alias-vs-alias collision is also `duplicate-canonical-ref` [0.04ms] +115 | test('an alias differing only by case collides', () => { +116 | const outcome = checkGlobalUniqueness([ +117 | identity('component:default/billing', 'Component:Default/Billing-Legacy'), +118 | identity('component:default/billing-legacy'), +119 | ]); +120 | expect(outcome.ok).toBe(false); + ^ +error: expect(received).toBe(expected) + +Expected: false +Received: true + + at (/Users/markbeacom/github/mbeacom/copilot-worktrees/adrkit/mbeacom-potential-potato/packages/adapters/catalog-backstage/test/uniqueness.test.ts:120:24) +(fail) T071 — row 3: a case-only variant is `duplicate-canonical-ref` > an alias differing only by case collides [0.04ms] +(pass) T071 — row 3: a case-only variant is `duplicate-canonical-ref` > the class is `duplicate-canonical-ref` and specifically not `duplicate-canonical-id` [0.04ms] +(pass) T071 — row 3: a case-only variant is `duplicate-canonical-ref` > two identical primary ids belonging to the SAME entity are not row 1 +159 | test('a collision returns a rejection, never a surviving member', () => { +160 | const outcome = checkGlobalUniqueness([ +161 | identity('component:default/a'), +162 | identity('component:default/a'), +163 | ]); +164 | expect(outcome.ok).toBe(false); + ^ +error: expect(received).toBe(expected) + +Expected: false +Received: true + + at (/Users/markbeacom/github/mbeacom/copilot-worktrees/adrkit/mbeacom-potential-potato/packages/adapters/catalog-backstage/test/uniqueness.test.ts:164:24) +(fail) T071 — first-wins and last-wins are both forbidden > a collision returns a rejection, never a surviving member [0.04ms] +176 | const backwards = checkGlobalUniqueness([ +177 | identity('component:default/a'), +178 | identity('component:default/a'), +179 | identity('component:default/b'), +180 | ]); +181 | expect(forwards.ok).toBe(false); + ^ +error: expect(received).toBe(expected) + +Expected: false +Received: true + + at (/Users/markbeacom/github/mbeacom/copilot-worktrees/adrkit/mbeacom-potential-potato/packages/adapters/catalog-backstage/test/uniqueness.test.ts:181:25) +(fail) T071 — first-wins and last-wins are both forbidden > the reported collision does not depend on which member came first [0.04ms] +187 | test('the detail names the prohibition, so an abort is legible without the contract', () => { +188 | const outcome = checkGlobalUniqueness([ +189 | identity('component:default/a'), +190 | identity('component:default/a'), +191 | ]); +192 | if (outcome.ok) throw new Error('expected a collision'); + ^ +error: expected a collision + at (/Users/markbeacom/github/mbeacom/copilot-worktrees/adrkit/mbeacom-potential-potato/packages/adapters/catalog-backstage/test/uniqueness.test.ts:192:59) +(fail) T071 — first-wins and last-wins are both forbidden > the detail names the prohibition, so an abort is legible without the contract [0.04ms] +200 | // unique." §3's table lists only cross-entity kinds, so this reading is recorded +201 | // in `identity/uniqueness.ts` rather than left implicit. +202 | const outcome = checkGlobalUniqueness([ +203 | { canonicalId: 'component:default/a', allRefs: ['component:default/a', 'component:default/a'] }, +204 | ]); +205 | expect(outcome.ok).toBe(false); + ^ +error: expect(received).toBe(expected) + +Expected: false +Received: true + + at (/Users/markbeacom/github/mbeacom/copilot-worktrees/adrkit/mbeacom-potential-potato/packages/adapters/catalog-backstage/test/uniqueness.test.ts:205:24) +(fail) T071 — uniqueness is over every ref, not only primary ids > a ref repeated within one entity is a violation [0.04ms] +(pass) T071 — uniqueness is over every ref, not only primary ids > the comparison walks aliases as well as primary ids [0.03ms] +(pass) T071 — uniqueness is over every ref, not only primary ids > folded refs are sorted and deduplicated, so a report is reproducible [0.07ms] + + 20 pass + 13 fail + 54 expect() calls +Ran 33 tests across 2 files. [500.00ms] +bun test v1.3.14 (0d9b296a) diff --git a/specs/010-catalog-backstage/evidence/negative-cases/whole-operation-atomicity/case-3-uniqueness-resolved-by-last-wins.patch b/specs/010-catalog-backstage/evidence/negative-cases/whole-operation-atomicity/case-3-uniqueness-resolved-by-last-wins.patch new file mode 100644 index 00000000..393c787d --- /dev/null +++ b/specs/010-catalog-backstage/evidence/negative-cases/whole-operation-atomicity/case-3-uniqueness-resolved-by-last-wins.patch @@ -0,0 +1,13 @@ +diff --git a/packages/adapters/catalog-backstage/src/identity/uniqueness.ts b/packages/adapters/catalog-backstage/src/identity/uniqueness.ts +index c3dc141..f12aadf 100644 +--- a/packages/adapters/catalog-backstage/src/identity/uniqueness.ts ++++ b/packages/adapters/catalog-backstage/src/identity/uniqueness.ts +@@ -181,7 +181,7 @@ export function checkGlobalUniqueness( + const key = occurrence.ref.toLowerCase(); + const previous = seen.get(key); + +- if (previous !== undefined) { ++ if (false && previous !== undefined) { + const reason = collisionReason(previous, occurrence); + const collision: Collision = { reason, first: previous, second: occurrence }; + return { diff --git a/specs/010-catalog-backstage/evidence/negative-cases/whole-operation-atomicity/restored.observed.txt b/specs/010-catalog-backstage/evidence/negative-cases/whole-operation-atomicity/restored.observed.txt new file mode 100644 index 00000000..e5d2e754 --- /dev/null +++ b/specs/010-catalog-backstage/evidence/negative-cases/whole-operation-atomicity/restored.observed.txt @@ -0,0 +1,57 @@ +bun test v1.3.14 (0d9b296a) + +test/sc-002-mixed-batch.test.ts: +(pass) T077 / SC-002 — one invalid entity aborts the whole run > §3’s own worked example — a sixth entity with a duplicate canonical id > the five without the offender produce a populated envelope [30.06ms] +(pass) T077 / SC-002 — one invalid entity aborts the whole run > §3’s own worked example — a sixth entity with a duplicate canonical id > the same five plus the offender abort with duplicate-canonical-id [19.13ms] +(pass) T077 / SC-002 — one invalid entity aborts the whole run > §3’s own worked example — a sixth entity with a duplicate canonical id > no envelope exists — not even one covering the five that would have validated [28.18ms] +(pass) T077 / SC-002 — one invalid entity aborts the whole run > §5’s variant — the sixth entity is inadmissible instead > the five without the offender produce a populated envelope [18.55ms] +(pass) T077 / SC-002 — one invalid entity aborts the whole run > §5’s variant — the sixth entity is inadmissible instead > the same five plus the offender abort with inadmissible-descriptor [18.09ms] +(pass) T077 / SC-002 — one invalid entity aborts the whole run > §5’s variant — the sixth entity is inadmissible instead > no envelope exists — not even one covering the five that would have validated [26.00ms] +(pass) T077 / SC-002 — one invalid entity aborts the whole run > the sixth entity declares a pattern the frozen dialect rejects > the five without the offender produce a populated envelope [19.51ms] +(pass) T077 / SC-002 — one invalid entity aborts the whole run > the sixth entity declares a pattern the frozen dialect rejects > the same five plus the offender abort with invalid-pattern [18.35ms] +(pass) T077 / SC-002 — one invalid entity aborts the whole run > the sixth entity declares a pattern the frozen dialect rejects > no envelope exists — not even one covering the five that would have validated [28.45ms] +(pass) T077 / SC-002 — one invalid entity aborts the whole run > the sixth entity repeats a YAML mapping key > the five without the offender produce a populated envelope [20.81ms] +(pass) T077 / SC-002 — one invalid entity aborts the whole run > the sixth entity repeats a YAML mapping key > the same five plus the offender abort with duplicate-yaml-key [18.11ms] +(pass) T077 / SC-002 — one invalid entity aborts the whole run > the sixth entity repeats a YAML mapping key > no envelope exists — not even one covering the five that would have validated [26.29ms] +(pass) T077 / SC-002 — one invalid entity aborts the whole run > the sixth entity’s annotation is not valid JSON > the five without the offender produce a populated envelope [19.12ms] +(pass) T077 / SC-002 — one invalid entity aborts the whole run > the sixth entity’s annotation is not valid JSON > the same five plus the offender abort with invalid-annotation-parse [17.40ms] +(pass) T077 / SC-002 — one invalid entity aborts the whole run > the sixth entity’s annotation is not valid JSON > no envelope exists — not even one covering the five that would have validated [27.76ms] +(pass) T077 / §1 — "skip the bad entity and keep going" is not what happens > the abort is not a filtered result with five entities in it [20.37ms] +(pass) T077 / §1 — "skip the bad entity and keep going" is not what happens > the consequence does not vary by which trigger fired [86.88ms] +(pass) T077 / §1 — "skip the bad entity and keep going" is not what happens > an offender placed first aborts exactly as one placed last does [19.49ms] + +test/uniqueness.test.ts: +(pass) T071 — the three collision classes §3 enumerates > all three are members of the closed trigger enumeration [0.03ms] +(pass) T071 — the three collision classes §3 enumerates > a distinct set passes, so the rejections below are not vacuous [0.07ms] +(pass) T071 — row 1: identical canonical ids are `duplicate-canonical-id` > two descriptors canonicalizing alike collide [0.04ms] +(pass) T071 — row 1: identical canonical ids are `duplicate-canonical-id` > the case-only pair §1 canonicalizes together arrives here already identical [0.02ms] +(pass) T071 — row 2: an alias colliding with a different entity’s id is `duplicate-canonical-ref` > §3’s worked example, on a synthetic identity set [0.02ms] +(pass) T071 — row 2: an alias colliding with a different entity’s id is `duplicate-canonical-ref` > an alias-vs-alias collision is also `duplicate-canonical-ref` [0.01ms] +(pass) T071 — row 3: a case-only variant is `duplicate-canonical-ref` > an alias differing only by case collides [0.02ms] +(pass) T071 — row 3: a case-only variant is `duplicate-canonical-ref` > the class is `duplicate-canonical-ref` and specifically not `duplicate-canonical-id` [0.02ms] +(pass) T071 — row 3: a case-only variant is `duplicate-canonical-ref` > two identical primary ids belonging to the SAME entity are not row 1 +(pass) T071 — first-wins and last-wins are both forbidden > a collision returns a rejection, never a surviving member [0.02ms] +(pass) T071 — first-wins and last-wins are both forbidden > the reported collision does not depend on which member came first [0.03ms] +(pass) T071 — first-wins and last-wins are both forbidden > the detail names the prohibition, so an abort is legible without the contract [0.01ms] +(pass) T071 — uniqueness is over every ref, not only primary ids > a ref repeated within one entity is a violation [0.02ms] +(pass) T071 — uniqueness is over every ref, not only primary ids > the comparison walks aliases as well as primary ids [0.01ms] +(pass) T071 — uniqueness is over every ref, not only primary ids > folded refs are sorted and deduplicated, so a report is reproducible [0.04ms] + +test/abort.test.ts: +(pass) T073 — the failure branch carries no envelope > a valid batch produces one, so the negative case below means something [7.98ms] +(pass) T073 — the failure branch carries no envelope > an aborting batch carries no envelope, and no field could hold one [9.33ms] +(pass) T073 — the failure branch carries no envelope > exactly one failure record, never a list [9.40ms] +(pass) T073 / §3 — no output exists for the five entities that would have validated > the destination path is never created [8.61ms] +(pass) T073 / §3 — no output exists for the five entities that would have validated > no side file is left in the destination directory either [8.76ms] +(pass) T073 / §3 — no output exists for the five entities that would have validated > the five valid entities really would have validated on their own [8.79ms] +(pass) T073 / FR-034 — non-zero process exit status > the mapping is 0 on success and 1 on abort [15.44ms] +(pass) T073 / FR-034 — non-zero process exit status > a real process exits non-zero, observed rather than asserted [69.39ms] +(pass) T073 — the write is atomic, so no truncated envelope is observable > the destination holds the complete serialization, byte for byte [9.28ms] +(pass) T073 — the write is atomic, so no truncated envelope is observable > no temporary file survives a successful write [8.26ms] +(pass) T073 — abortRecord carries the location the pipeline knew > an absent location is undefined rather than invented [0.04ms] +(pass) T073 — abortRecord carries the location the pipeline knew > a supplied location is carried verbatim [0.02ms] + + 45 pass + 0 fail + 117 expect() calls +Ran 45 tests across 3 files. [734.00ms] diff --git a/specs/010-catalog-backstage/tasks.md b/specs/010-catalog-backstage/tasks.md index 139c1926..8eb8bf33 100644 --- a/specs/010-catalog-backstage/tasks.md +++ b/specs/010-catalog-backstage/tasks.md @@ -888,7 +888,7 @@ whatsoever runs concurrently with Phase E.** Phase E is where generator-derived first exists; the whole point of Barrier B is that this moment comes after the freeze and the audit. -- [ ] T069 [US2] Compose the Phase D units into `/src/pipeline.ts` in fixed +- [X] T069 [US2] Compose the Phase D units into `/src/pipeline.ts` in fixed stage order: manifest → repository → digests → descriptor read → admissibility → canonicalization → ownership → glob → envelope. Composition only; no new validation logic. @@ -896,7 +896,7 @@ and the audit. Discharges: none — enables FR-014, FR-023, FR-024, FR-034…FR-043 Depends: T024, T046, T056, T068 -- [ ] T070 [US2] Set `completeness.wholeCatalog === false` unconditionally, in every +- [X] T070 [US2] Set `completeness.wholeCatalog === false` unconditionally, in every envelope, on every path. There is no configuration, flag, or input that can make it `true`. Files: `/src/envelope/completeness.ts`, @@ -905,7 +905,7 @@ and the audit. Discharges: FR-014 Depends: T024, T069 -- [ ] T071 [US5] Enforce **global canonical uniqueness over every ref**, emitting +- [X] T071 [US5] Enforce **global canonical uniqueness over every ref**, emitting `duplicate-canonical-id`, `duplicate-canonical-ref`, and `duplicate-yaml-key` as three distinct classes. First-wins and last-wins resolution are forbidden — a collision aborts. @@ -922,7 +922,7 @@ and the audit. > synthetic fixtures is unknown, and SC-003 may be satisfiable only synthetically > for that one class. -- [ ] T072 [US5] Establish that **overlap between distinct canonical ids is not a +- [X] T072 [US5] Establish that **overlap between distinct canonical ids is not a collision** — two entities may derive overlapping paths, and no exclusive winner is selected. Files: `/src/identity/overlap.ts`, `/test/overlap.test.ts`. @@ -931,7 +931,7 @@ and the audit. Depends: T024, T071 Contract: `entity-identity.md` §4 -- [ ] T073 [US5] Implement whole-operation abort: any fatal trigger aborts the entire +- [X] T073 [US5] Implement whole-operation abort: any fatal trigger aborts the entire operation, exits non-zero, and leaves **no usable partial output** — no partial envelope, no partial file, no truncated stream. Files: `/src/failure/abort.ts`, `/test/abort.test.ts`. @@ -940,7 +940,7 @@ and the audit. Depends: T024, T069 Contract: `atomic-fail-closed.md` §1, §2 -- [ ] T074 [US5] Declare the closed **fifteen**-value fatal trigger enumeration as a +- [X] T074 [US5] Declare the closed **fifteen**-value fatal trigger enumeration as a string-literal union at `/src/failure/triggers.ts`. This feature's count is **fifteen** — spike 009's fourteen **plus** `inadmissible-descriptor`, added by ADR-0015 Condition of Acceptance 2. The enumeration, verbatim from @@ -957,7 +957,7 @@ and the audit. Depends: T024, T073 Contract: `atomic-fail-closed.md` §4 -- [ ] T075 [US5] Implement `other-invalid-input` as a **deliberate, always-present +- [X] T075 [US5] Implement `other-invalid-input` as a **deliberate, always-present backstop** — never removed as unreachable, never treated as dead code, and never used to absorb a case that has its own class. Files: `/src/failure/triggers.ts`, @@ -967,7 +967,7 @@ and the audit. Depends: T024, T074 Contract: `atomic-fail-closed.md` §4.2 -- [ ] T076 [US5] Enforce that each abort carries **exactly one** trigger class, and +- [X] T076 [US5] Enforce that each abort carries **exactly one** trigger class, and that it is the **correct** one — including for the collapsible pairs the contract identifies as most at risk of being merged. Files: `/src/failure/classify.ts`, @@ -977,7 +977,7 @@ and the audit. Depends: T024, T075 Contract: `atomic-fail-closed.md` §4.3 -- [ ] T077 [US5] Assert **whole-operation atomicity over a mixed batch** — a batch +- [X] T077 [US5] Assert **whole-operation atomicity over a mixed batch** — a batch containing both valid and invalid entities produces no output at all. This is a *separate property* from the per-rule tests in Phase D, which is precisely why `plan.md` places it behind the barrier under the R4 definition even though the @@ -1001,14 +1001,14 @@ and the audit. > only via a synthetic fixture. Record that plainly here rather than presenting a > synthetic case as a corpus-derived one. -- [ ] T079 [US6] Emit the versioned envelope as the **only** output: no side files, no +- [X] T079 [US6] Emit the versioned envelope as the **only** output: no side files, no logs presented as output, no auxiliary artifacts. Files: `/src/envelope/write.ts`, `/test/envelope-only.test.ts`. Barrier: BEHIND Discharges: FR-038 Depends: T024, T069 -- [ ] T080 [US6] Implement the envelope's declared fields and **exactly five** fields +- [X] T080 [US6] Implement the envelope's declared fields and **exactly five** fields per `entities[]` record. The flatter triple shape is forbidden. Files: `/src/envelope/shape.ts`, `/test/envelope-shape.test.ts`. Barrier: BEHIND @@ -1016,7 +1016,7 @@ and the audit. Depends: T024, T079 Contract: `snapshot-envelope.md` §1 (see also `data-model.md` §9, §10) -- [ ] T081 [US6] Compute the envelope digest using `@adrkit/core`'s `canonicalStringify` +- [X] T081 [US6] Compute the envelope digest using `@adrkit/core`'s `canonicalStringify` (`packages/core/src/fingerprint/index.ts:16`, exported at `packages/core/src/index.ts:24`), SHA-256, rendered as 64 lowercase hex characters. **Never** use the same-named function at @@ -1032,7 +1032,7 @@ and the audit. Depends: T024, T080 Contract: `package-boundary.md` §2.2 -- [ ] T082 [US6] Maintain the provenance boundary in the envelope: upstream-authored +- [X] T082 [US6] Maintain the provenance boundary in the envelope: upstream-authored descriptor content and maintainer-authored overlay content are recorded as distinct provenances and never merged into an undifferentiated whole. Files: `/src/envelope/provenance.ts`, @@ -1041,14 +1041,14 @@ and the audit. Discharges: FR-043 Depends: T024, T081 -- [ ] T083 [US2] Produce **byte-identical** output across repeated runs over identical +- [X] T083 [US2] Produce **byte-identical** output across repeated runs over identical input. Files: `/test/byte-identical.test.ts`. Barrier: BEHIND Discharges: FR-042 Depends: T024, T082 -- [ ] T084 [US2] Assert determinism across **at least three** runs, on the **accept +- [X] T084 [US2] Assert determinism across **at least three** runs, on the **accept path and the reject path alike** — a deterministic rejection is as much a requirement as a deterministic envelope. Files: `/test/sc-001-determinism.test.ts`. @@ -1056,7 +1056,7 @@ and the audit. Discharges: SC-001 Depends: T024, T083 -- [ ] T085 [US6] SC-013 close-out: exactly one envelope is produced; each +- [X] T085 [US6] SC-013 close-out: exactly one envelope is produced; each `entities[]` record carries exactly five fields; the recorded digest matches an **independent** recomputation, not the generator's own. Files: `/test/sc-013.test.ts`.