diff --git a/packages/adapters/catalog-backstage/src/admissibility/classify.ts b/packages/adapters/catalog-backstage/src/admissibility/classify.ts new file mode 100644 index 00000000..370ce9cc --- /dev/null +++ b/packages/adapters/catalog-backstage/src/admissibility/classify.ts @@ -0,0 +1,196 @@ +/** + * T051 · T052 — `inadmissible-descriptor` classification, and the record that must + * carry all three attributions. + * + * # Failure semantics (`admissibility.md` §5) + * + * `inadmissible-descriptor` is a **fatal, whole-operation** trigger class — the + * **fifteenth** member of this feature's enumeration. On any inadmissible + * descriptor the entire run aborts, no envelope is written including a partial one, + * no entity from the same run is emitted including entities already determined + * admissible, and the process exits non-zero. + * + * An inadmissible descriptor is **never skipped**, never downgraded to a warning, + * and never excluded-and-continued. *"Continue past the bad one"* is the precise + * behaviour §5 forbids. This module therefore produces a rejection and never a + * filtered collection: there is no function here that takes descriptors and returns + * the good ones. + * + * # All three attributions, and why one merged one will not do (§3, §5.1, FR-020) + * + * A record that says only "descriptor invalid", without naming which of the four + * fields and which validator produced the false, "does not satisfy FR-020 and MUST + * be treated as a reporting defect rather than as a determination" (§3). So every + * {@link InadmissibilityAttribution} carries the descriptor path, the failing field, + * and the rejecting validator, and the composition evaluates **all four** predicates + * rather than stopping at the first — §3: "Two fields failing produce two + * attributions, not one merged one." + * + * # The count + * + * Fifteen. `atomic-fail-closed.md` §4 fixes spike 009's enumeration at fourteen and + * that remains correct *as a statement about spike 009*; `inadmissible-descriptor` + * appears nowhere under `specs/009-catalog-binding-viability/`. Writing fourteen as + * *this feature's* count is an error against FR-035 (`admissibility.md` §5.1). + * + * @see `specs/010-catalog-backstage/contracts/admissibility.md` §3, §5, §5.1 + */ + +import type { Rejection } from '../diagnostics.ts'; +import type { DescriptorDocument } from '../descriptor/read.ts'; +import { + type AdmissibilityField, + type AdmissibilityValidatorName, + PINNED_BACKSTAGE_COMMIT, + PINNED_VALIDATOR_BINDINGS, + VALIDATORS, + VALIDATOR_FIELDS, +} from './validators.ts'; + +/** One field's rejection, attributed as FR-020 requires. */ +export interface InadmissibilityAttribution { + /** FR-020 attribution 1 — the offending source path. */ + readonly sourcePath: string; + /** Which document in that file, since a file may hold several. */ + readonly documentIndexInFile: number; + /** FR-020 attribution 2 — the failing field. */ + readonly field: AdmissibilityField; + /** FR-020 attribution 3 — the rejecting validator. */ + readonly validator: AdmissibilityValidatorName; + /** The pinned Backstage binding that validator reproduces (ADR-0015's table). */ + readonly pinnedBinding: string; + /** The commit the predicate is pinned to. A different commit is a different contract. */ + readonly pinnedCommit: string; + /** What was actually authored, rendered for a reader. Never load-bearing. */ + readonly observed: string; +} + +/** + * `data-model.md` §4, plus the attributions FR-020 requires. + * + * `failedFields` is empty **iff** `admissible`, which is the invariant §2's "there + * is no partial admissibility, no warning tier, and no 'admissible except for' + * state" reduces to in code. + */ +export interface AdmissibilityResult { + readonly admissible: boolean; + readonly failedFields: readonly AdmissibilityField[]; + readonly attributions: readonly InadmissibilityAttribution[]; +} + +/** The one fine-grained reason. 1:1 with its trigger class. */ +export type InadmissibilityReason = 'inadmissible-descriptor'; + +function render(value: unknown): string { + if (value === undefined) return ''; + if (typeof value === 'string') return JSON.stringify(value); + return `<${value === null ? 'null' : typeof value}>`; +} + +/** The four field values a descriptor presents to the validators, as authored. */ +export interface AuthoredFields { + readonly apiVersion: unknown; + readonly kind: unknown; + readonly name: unknown; + /** + * `undefined` when `metadata.namespace` is wholly omitted. + * + * ADR-0015: the namespace is "validated as authored, never after defaulting", so + * the `default` substitution must not have happened yet when this value is read. + */ + readonly namespace: unknown; + readonly namespacePresent: boolean; +} + +/** Read the four authored fields off a parsed descriptor document. */ +export function authoredFields(document: DescriptorDocument): AuthoredFields { + const metadata = document.rawMetadata; + const isRecord = + typeof metadata === 'object' && metadata !== null && !Array.isArray(metadata); + const record = isRecord ? (metadata as Record) : undefined; + + return { + apiVersion: document.rawApiVersion, + kind: document.rawKind, + name: record?.['name'], + namespace: record?.['namespace'], + namespacePresent: record !== undefined && Object.hasOwn(record, 'namespace'), + }; +} + +/** + * Apply all four predicates and collect every failure. + * + * Deliberately **not** short-circuiting. §3 requires two failing fields to produce + * two attributions, and a short-circuit would silently make the reported field a + * function of evaluation order. + * + * The namespace validator is invoked **only when the field is present**. An omitted + * `metadata.namespace` is admissible (ADR-0015, "Decision"), and the `default` + * substitution applies afterwards — in `identity/canonicalize.ts`, never here. + */ +export function classifyAdmissibility(document: DescriptorDocument): AdmissibilityResult { + const fields = authoredFields(document); + + const invocations: readonly { name: AdmissibilityValidatorName; value: unknown }[] = [ + { name: 'validateApiVersion', value: fields.apiVersion }, + { name: 'validateKind', value: fields.kind }, + { name: 'validateEntityName', value: fields.name }, + ...(fields.namespacePresent + ? [{ name: 'validateNamespace' as const, value: fields.namespace }] + : []), + ]; + + const attributions: InadmissibilityAttribution[] = []; + + for (const { name, value } of invocations) { + if (VALIDATORS[name](value)) continue; + attributions.push({ + sourcePath: document.sourcePath, + documentIndexInFile: document.documentIndexInFile, + field: VALIDATOR_FIELDS[name], + validator: name, + pinnedBinding: PINNED_VALIDATOR_BINDINGS[name], + pinnedCommit: PINNED_BACKSTAGE_COMMIT, + observed: render(value), + }); + } + + return { + admissible: attributions.length === 0, + failedFields: attributions.map((attribution) => attribution.field), + attributions, + }; +} + +/** + * Build the fatal rejection an inadmissible descriptor raises. + * + * `detail` names every attribution, so a run aborting on a descriptor that failed + * two fields reports both. It is distinguishable from a `duplicate-canonical-id` + * record by `triggerClass` alone — a caller never has to parse `detail` to tell + * them apart, which is what FR-020's "MUST be distinguishable" requires in + * practice. + */ +export function inadmissibleRejection( + result: AdmissibilityResult, +): Rejection { + if (result.admissible) { + throw new Error( + 'inadmissibleRejection was called for an admissible result. Building a rejection ' + + 'for a descriptor that passed would fabricate a determination that never happened.', + ); + } + + const detail = result.attributions + .map( + (attribution) => + `${attribution.sourcePath}[${attribution.documentIndexInFile}]: ` + + `${attribution.field} rejected by ${attribution.validator} ` + + `(${attribution.pinnedBinding}, pinned at ${attribution.pinnedCommit}); ` + + `observed ${attribution.observed}`, + ) + .join(' | '); + + return { reason: 'inadmissible-descriptor', triggerClass: 'inadmissible-descriptor', detail }; +} diff --git a/packages/adapters/catalog-backstage/src/admissibility/index.ts b/packages/adapters/catalog-backstage/src/admissibility/index.ts new file mode 100644 index 00000000..6beee3ac --- /dev/null +++ b/packages/adapters/catalog-backstage/src/admissibility/index.ts @@ -0,0 +1,145 @@ +/** + * T048 · T053 — admissibility runs **before** canonicalization, and no inadmissible + * descriptor ever participates in a uniqueness comparison. Both enforced + * structurally rather than by convention. + * + * # Why "structurally" is the requirement, not "in the right order" + * + * `admissibility.md` §4.1: reversing the order "would make some descriptors collide + * *before* being found inadmissible, and the trigger class reported for the run + * would then depend on document order within the manifest. Order-dependence of the + * reported trigger is exactly the failure mode ADR-0015's ordering rule exists to + * prevent." + * + * A comment saying "call `admit` first" would not prevent that. The mechanism used + * here does: + * + * - {@link AdmittedDescriptor} carries a private brand that **cannot be constructed + * outside this module**. The only value of that type in existence comes from + * {@link admit} returning `admissible: true`. + * - `identity/canonicalize.ts` accepts an {@link AdmittedDescriptor} and nothing + * else. A caller holding a raw `DescriptorDocument` cannot canonicalize it — not + * because it is discouraged, but because the program does not typecheck. + * - {@link collectAdmitted} is **all-or-nothing**: handed a batch containing one + * inadmissible descriptor it returns the rejection and no descriptors at all. It + * has no "and the rest" branch to be tempted by, so the uniqueness comparison + * downstream is never reachable with an inadmissible member. + * + * # Duplicate detection is not a validity check (§6, FR-021) + * + * Two determinations, independent. Conformance evidence "MUST demonstrate that + * independence rather than assert it", and specifically must include a descriptor + * that is **inadmissible and canonically unique** — one that fails §2 while + * colliding with nothing. Without such a case a passing suite is "equally consistent + * with an implementation that has silently fused the two checks". That case is + * `test/inadmissible-and-unique.test.ts` (T054), and it is retained permanently. + * + * @see `specs/010-catalog-backstage/contracts/admissibility.md` §4, §4.1, §6 + */ + +import type { Rejection } from '../diagnostics.ts'; +import type { DescriptorDocument } from '../descriptor/read.ts'; +import { + type AdmissibilityResult, + type AuthoredFields, + type InadmissibilityReason, + authoredFields, + classifyAdmissibility, + inadmissibleRejection, +} from './classify.ts'; + +/** + * The brand. + * + * A real runtime symbol rather than a `declare`d type-only one, and **not + * exported**. Type-only branding would be erased at runtime, so a plain object cast + * with `as` would produce a value indistinguishable from a genuine admission once + * compiled. This way the guarantee holds in both directions: TypeScript refuses to + * construct the type outside this module, and at runtime the property key is a + * symbol no other module holds a reference to. + */ +const ADMITTED: unique symbol = Symbol('adrkit.catalog-backstage.admitted'); + +/** + * A descriptor that has passed all four validators. + * + * The only route to one is {@link admit}. This is the type-level expression of + * ADR-0015's ordering rule: canonicalization consumes this, so canonicalization + * cannot precede admissibility. + */ +export interface AdmittedDescriptor { + readonly [ADMITTED]: true; + readonly document: DescriptorDocument; + /** The four fields **as authored** — the namespace has not been defaulted yet. */ + readonly fields: AuthoredFields; +} + +/** The outcome of admitting one descriptor. */ +export type AdmissionOutcome = + | { readonly admissible: true; readonly admitted: AdmittedDescriptor } + | { + readonly admissible: false; + readonly result: AdmissibilityResult; + readonly rejection: Rejection; + }; + +/** + * Decide admissibility for one parsed descriptor. + * + * Note what this does **not** return on the failure branch: an + * {@link AdmittedDescriptor}, a canonical id, or a partially-usable descriptor. + * There is nothing on that branch a caller could canonicalize even by mistake. + */ +export function admit(document: DescriptorDocument): AdmissionOutcome { + const result = classifyAdmissibility(document); + + if (!result.admissible) { + return { admissible: false, result, rejection: inadmissibleRejection(result) }; + } + + return { + admissible: true, + admitted: { + [ADMITTED]: true, + document, + fields: authoredFields(document), + }, + }; +} + +/** All-or-nothing admission over a batch. */ +export type BatchAdmission = + | { readonly ok: true; readonly admitted: readonly AdmittedDescriptor[] } + | { + readonly ok: false; + readonly result: AdmissibilityResult; + readonly rejection: Rejection; + }; + +/** + * Admit a whole batch, or none of it. + * + * `admissibility.md` §5: on any inadmissible descriptor, "**no** entity from the + * same run is emitted, including entities already determined admissible". The + * failure branch carries no descriptors, so a caller cannot proceed to a uniqueness + * comparison over a partially-admitted set — the set does not exist. + * + * Descriptors are processed in the order given, so the reported attribution for a + * batch with two inadmissible members is deterministic rather than a function of + * iteration order. + */ +export function collectAdmitted( + documents: readonly DescriptorDocument[], +): BatchAdmission { + const admitted: AdmittedDescriptor[] = []; + + for (const document of documents) { + const outcome = admit(document); + if (!outcome.admissible) { + return { ok: false, result: outcome.result, rejection: outcome.rejection }; + } + admitted.push(outcome.admitted); + } + + return { ok: true, admitted }; +} diff --git a/packages/adapters/catalog-backstage/src/admissibility/separator.ts b/packages/adapters/catalog-backstage/src/admissibility/separator.ts new file mode 100644 index 00000000..648d9218 --- /dev/null +++ b/packages/adapters/catalog-backstage/src/admissibility/separator.ts @@ -0,0 +1,48 @@ +/** + * T050 — the separator rule inside `isValidApiVersion`. + * + * ADR-0015 states two properties of the `apiVersion` binding "since the split is + * not obvious from the predicate alone", quoted here verbatim: + * + * > `isValidPrefixAndOrSuffix` splits on `/` and **rejects any value containing two + * > or more separators**, and a value with **no** separator is validated against + * > the suffix predicate alone — so a bare `v1` passes without the subdomain rule + * > ever being consulted. + * + * Both halves matter and both are easy to get wrong in opposite directions. An + * implementation that split on the *first* `/` and ignored the rest would accept + * `a/b/c`. One that required a prefix would reject the bare `v1` that ADR-0015 + * explicitly says passes. + * + * @see `docs/adr/0015-validate-descriptors-against-backstage-field-formats-before-canonicalizing.md` + * @see `specs/010-catalog-backstage/spec.md` FR-017 + */ + +/** How a value splits across the `/` separator. */ +export type SeparatorSplit = + /** No separator: the whole value is a suffix, and no prefix rule is consulted. */ + | { readonly kind: 'suffix-only'; readonly suffix: string } + /** Exactly one separator: prefix and suffix are each validated by their own rule. */ + | { readonly kind: 'prefix-and-suffix'; readonly prefix: string; readonly suffix: string } + /** Two or more separators: rejected outright, whatever the parts contain. */ + | { readonly kind: 'too-many-separators'; readonly separatorCount: number }; + +/** + * Split `value` on `/` according to ADR-0015's separator rule. + * + * Counting separators *before* deciding anything is what makes the two-or-more + * rejection independent of what the parts happen to be. + */ +export function splitOnSeparator(value: string): SeparatorSplit { + const parts = value.split('/'); + const separatorCount = parts.length - 1; + + if (separatorCount >= 2) return { kind: 'too-many-separators', separatorCount }; + if (separatorCount === 0) return { kind: 'suffix-only', suffix: value }; + + return { + kind: 'prefix-and-suffix', + prefix: parts[0] as string, + suffix: parts[1] as string, + }; +} diff --git a/packages/adapters/catalog-backstage/src/admissibility/validators.ts b/packages/adapters/catalog-backstage/src/admissibility/validators.ts new file mode 100644 index 00000000..a2ecf3b1 --- /dev/null +++ b/packages/adapters/catalog-backstage/src/admissibility/validators.ts @@ -0,0 +1,216 @@ +/** + * T049 — the **four** admissibility field validators, each separately attributed. + * + * # The warrant, before anything else + * + * Every statement in this module is a statement about **what a pure validator + * predicate returns when invoked**, at Backstage commit + * `1121a4facd9e321179d0402c3f355e4a649e84d9`. It is **not** a statement about what + * Backstage as a running system does with a descriptor. A descriptor this module + * calls inadmissible may or may not be rejected by a deployed Backstage instance; + * this feature has never run one and will not. The pin is load-bearing: a different + * commit is a different predicate and therefore a different contract + * (`admissibility.md` §1). + * + * # The table, transcribed from ADR-0015 + * + * Reproduced from ADR-0015's four-row table — which `spec.md` FR-016 reproduces in + * turn — rather than restated: + * + * | Field | Validator binding | Predicate | Applied | + * |---|---|---|---| + * | `apiVersion` | `isValidApiVersion` → `CommonValidatorFunctions.isValidPrefixAndOrSuffix` | prefix is a DNS **subdomain** (`CommonValidatorFunctions.isValidDnsSubdomain`, ≤253 total, each dot-separated label ≤63); suffix is `/^[a-z0-9A-Z]+$/`, ≤63 | required | + * | `kind` | `isValidKind` | `/^[a-zA-Z][a-z0-9A-Z]*$/`, ≤63 | required | + * | `metadata.name` | `isValidEntityName` → `KubernetesValidatorFunctions.isValidObjectName` | `/^([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9]$/`, ≤63 | required | + * | `metadata.namespace` | `isValidNamespace` → `KubernetesValidatorFunctions.isValidNamespace` → `CommonValidatorFunctions.isValidDnsLabel` | `/^[a-z0-9]+(?:\-+[a-z0-9]+)*$/`, ≤63 | **only when present** | + * + * # Two places the source documents disagree, resolved in ADR-0015's favour + * + * 1. **The namespace character class.** `contracts/admissibility.md` §2's summary + * table gives `metadata.namespace` the character class "`[A-Za-z0-9]` plus `-`, + * `_`, `.`" — the same class it gives `metadata.name`. ADR-0015 and FR-016 give + * it the DNS-label predicate `/^[a-z0-9]+(?:\-+[a-z0-9]+)*$/`, which admits + * **no** uppercase, **no** `_`, and **no** `.`. FR-016 requires "exactly the four + * field validators in ADR-0015's table", so ADR-0015 governs. Reported as a + * contract defect rather than silently reconciled. + * 2. **`AdmissibilityField`.** `data-model.md` §4 types it as + * `"kind" | "metadata.name" | "metadata.namespace" | "spec.type"` — omitting + * `apiVersion`, which ADR-0015 requires, and adding `spec.type`, which no + * validator in the table covers. ADR-0015's four fields are used here. Also + * reported. + * + * # The one composition this module performs + * + * ADR-0015 states `isValidDnsSubdomain`'s **bounds** (≤253 total, each dot-separated + * label ≤63) but not the character class of an individual label. It does state + * `CommonValidatorFunctions.isValidDnsLabel`'s predicate, in the namespace row of + * the same table. A subdomain is therefore implemented here as *dot-separated + * labels each satisfying the stated `isValidDnsLabel` predicate*, bounded as stated. + * That is a composition of two things the table says, not an invention — but it is + * the only place transcription was insufficient, and it is flagged rather than + * buried. + * + * The four ADR-0015 facts that *were* recorded as directly executed against the pin + * — a 243-character prefix passes; 254 fails; an over-63 label fails; a + * two-separator value fails — are pinned as tests in + * `test/admissibility-validators.test.ts`. + * + * @see `docs/adr/0015-validate-descriptors-against-backstage-field-formats-before-canonicalizing.md` + * @see `specs/010-catalog-backstage/contracts/admissibility.md` §1, §2 + */ + +import { splitOnSeparator } from './separator.ts'; + +/** + * The four fields, in ADR-0015's table order. + * + * This is the feature's `AdmissibilityField`. It follows ADR-0015 rather than + * `data-model.md` §4 — see the module note above. + */ +export type AdmissibilityField = 'apiVersion' | 'kind' | 'metadata.name' | 'metadata.namespace'; + +/** The four fields as data, so the count is assertable at runtime. */ +export const ADMISSIBILITY_FIELDS = [ + 'apiVersion', + 'kind', + 'metadata.name', + 'metadata.namespace', +] as const satisfies readonly AdmissibilityField[]; + +/** + * The adapter's validator names, as `contracts/admissibility.md` §2's table names + * them. + */ +export type AdmissibilityValidatorName = + | 'validateApiVersion' + | 'validateKind' + | 'validateEntityName' + | 'validateNamespace'; + +/** + * The pinned Backstage binding each adapter validator reproduces, spelled as + * ADR-0015's table spells it. + * + * Carried alongside the adapter's own validator name because the *warrant* attaches + * to the pinned binding, and a record naming only `validateNamespace` would not say + * which upstream predicate it claims to reproduce. + */ +export const PINNED_VALIDATOR_BINDINGS: Record = { + validateApiVersion: 'isValidApiVersion → CommonValidatorFunctions.isValidPrefixAndOrSuffix', + validateKind: 'isValidKind', + validateEntityName: 'isValidEntityName → KubernetesValidatorFunctions.isValidObjectName', + validateNamespace: + 'isValidNamespace → KubernetesValidatorFunctions.isValidNamespace → CommonValidatorFunctions.isValidDnsLabel', +}; + +/** The commit every predicate here is pinned to. ADR-0012 pinned it; ADR-0015 uses it. */ +export const PINNED_BACKSTAGE_COMMIT = '1121a4facd9e321179d0402c3f355e4a649e84d9'; + +/** Which field each validator is invoked on. */ +export const VALIDATOR_FIELDS: Record = { + validateApiVersion: 'apiVersion', + validateKind: 'kind', + validateEntityName: 'metadata.name', + validateNamespace: 'metadata.namespace', +}; + +/** ADR-0015: suffix is `/^[a-z0-9A-Z]+$/`, ≤63. */ +const API_VERSION_SUFFIX = /^[a-z0-9A-Z]+$/u; + +/** ADR-0015: `isValidKind` is `/^[a-zA-Z][a-z0-9A-Z]*$/`, ≤63. */ +const KIND = /^[a-zA-Z][a-z0-9A-Z]*$/u; + +/** ADR-0015: `isValidObjectName` is `/^([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9]$/`, ≤63. */ +const OBJECT_NAME = /^([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9]$/u; + +/** ADR-0015: `isValidDnsLabel` is `/^[a-z0-9]+(?:\-+[a-z0-9]+)*$/`, ≤63. */ +const DNS_LABEL = /^[a-z0-9]+(?:-+[a-z0-9]+)*$/u; + +/** ADR-0015: each dot-separated label ≤63. */ +export const DNS_LABEL_MAX = 63; + +/** ADR-0015: a DNS subdomain is ≤253 in total. */ +export const DNS_SUBDOMAIN_MAX = 253; + +/** ADR-0015: every one of the four predicates carries a ≤63 bound. */ +export const FIELD_MAX = 63; + +/** `CommonValidatorFunctions.isValidDnsLabel`, as ADR-0015's table gives it. */ +export function isValidDnsLabel(value: string): boolean { + return value.length <= DNS_LABEL_MAX && DNS_LABEL.test(value); +} + +/** + * `CommonValidatorFunctions.isValidDnsSubdomain`. + * + * Bounds are transcribed; the per-label character class is composed from + * {@link isValidDnsLabel} — the one composition this module performs, flagged in + * the module note. + */ +export function isValidDnsSubdomain(value: string): boolean { + if (value.length > DNS_SUBDOMAIN_MAX) return false; + const labels = value.split('.'); + return labels.every((label) => isValidDnsLabel(label)); +} + +/** + * `validateApiVersion` — `isValidApiVersion` → + * `CommonValidatorFunctions.isValidPrefixAndOrSuffix`. + * + * The separator rule (`admissibility/separator.ts`, T050) decides which predicates + * are consulted at all: a bare `v1` reaches only the suffix rule, and a value with + * two separators is rejected before either. + */ +export function validateApiVersion(value: unknown): boolean { + if (typeof value !== 'string') return false; + + const split = splitOnSeparator(value); + switch (split.kind) { + case 'too-many-separators': + return false; + case 'suffix-only': + return split.suffix.length <= FIELD_MAX && API_VERSION_SUFFIX.test(split.suffix); + case 'prefix-and-suffix': + return ( + isValidDnsSubdomain(split.prefix) && + split.suffix.length <= FIELD_MAX && + API_VERSION_SUFFIX.test(split.suffix) + ); + } +} + +/** `validateKind` — `isValidKind`. */ +export function validateKind(value: unknown): boolean { + if (typeof value !== 'string') return false; + return value.length <= FIELD_MAX && KIND.test(value); +} + +/** `validateEntityName` — `isValidEntityName` → `KubernetesValidatorFunctions.isValidObjectName`. */ +export function validateEntityName(value: unknown): boolean { + if (typeof value !== 'string') return false; + return value.length <= FIELD_MAX && OBJECT_NAME.test(value); +} + +/** + * `validateNamespace` — `isValidNamespace` → + * `KubernetesValidatorFunctions.isValidNamespace` → + * `CommonValidatorFunctions.isValidDnsLabel`. + * + * Applied **only when present**. Absence is handled by the caller + * (`admissibility/index.ts`), because "omitted is admissible" is a statement about + * the composition, not about this predicate: ADR-0015's Decision says the namespace + * "is validated as authored, never after defaulting", so this function must never + * be handed the substituted `default`. + */ +export function validateNamespace(value: unknown): boolean { + if (typeof value !== 'string') return false; + return value.length <= FIELD_MAX && DNS_LABEL.test(value); +} + +/** The four validators, keyed by name, so the composition can iterate them. */ +export const VALIDATORS: Record boolean> = { + validateApiVersion, + validateKind, + validateEntityName, + validateNamespace, +}; diff --git a/packages/adapters/catalog-backstage/src/descriptor/read.ts b/packages/adapters/catalog-backstage/src/descriptor/read.ts new file mode 100644 index 00000000..84c433cd --- /dev/null +++ b/packages/adapters/catalog-backstage/src/descriptor/read.ts @@ -0,0 +1,162 @@ +/** + * T047 — descriptor reading, with `duplicate-yaml-key` and `invalid-yaml-syntax` + * kept as **two distinct outcomes**. + * + * `research.md` R8 fixes the mechanism: the `yaml` package's `uniqueKeys` option + * defaults to `true`, so a duplicate mapping key at any level is already reported + * without configuration. This module therefore never passes `uniqueKeys: false` and + * never writes a bespoke duplicate-key walker — a second notion of "duplicate" + * could disagree with the library's own. + * + * **Why the two outcomes must not collapse.** `data-model.md` §3 types + * `parseOutcome` as three values, and §8 maps two of them to *different* trigger + * classes. A reader told only "this file did not parse" cannot tell a descriptor + * that declared `kind` twice from one with an unterminated quote — different + * defects with different fixes. `atomic-fail-closed.md` §4 added + * `invalid-yaml-syntax` specifically to close that gap. + * + * **A duplicate key still produces a value, and that is the trap.** `yaml` reports + * `DUPLICATE_KEY` *and* resolves the mapping last-wins. An implementation that read + * `doc.toJSON()` and ignored `doc.errors` would get a plausible-looking descriptor + * and never notice. That is why {@link readDescriptorDocuments} checks errors + * before it reads any value. + * + * @see `specs/009-catalog-binding-viability/research.md` R8 + * @see `specs/010-catalog-backstage/data-model.md` §3 + */ + +import { parseAllDocuments } from 'yaml'; +import { type Rejection, type TriggerClass } from '../diagnostics.ts'; + +/** `yaml`'s own error code for a repeated mapping key. */ +export const DUPLICATE_KEY_CODE = 'DUPLICATE_KEY'; + +/** `data-model.md` §3. */ +export type DescriptorParseOutcome = 'parsed' | 'duplicate-yaml-key' | 'yaml-parse-error'; + +/** + * One YAML document, addressed by `(sourcePath, documentIndexInFile)`. + * + * A single file may hold several documents, so a path alone does not identify one — + * `data-model.md` §3 states this and it is the reason `documentIndexInFile` is not + * optional. + * + * **Two fields beyond `data-model.md` §3's five, and why.** §3 lists `rawKind` and + * `rawMetadata` but not `rawApiVersion`, while ADR-0015's validator table — which + * §4 is downstream of — requires `apiVersion`. A descriptor record that omitted it + * could not be checked for admissibility at all. `raw` carries the whole decoded + * document so the ownership annotation can be read as the plain value the + * `owned-paths-annotation.md` §1 step-2 check expects. Both additions are reported + * as a `data-model.md` §3 gap rather than treated as settled. + */ +export interface DescriptorDocument { + readonly sourcePath: string; + readonly documentIndexInFile: number; + readonly parseOutcome: DescriptorParseOutcome; + /** Pre-validation. Type deliberately not assumed — see `data-model.md` §3. */ + readonly rawApiVersion: unknown; + readonly rawKind: unknown; + readonly rawMetadata: unknown; + /** The whole decoded document, or `undefined` when parsing failed. */ + readonly raw: unknown; + /** Present exactly when `parseOutcome !== 'parsed'`. */ + readonly rejection: Rejection | undefined; +} + +/** Fine-grained reasons, 1:1 with their trigger classes. */ +export type DescriptorReadReason = 'duplicate-yaml-key' | 'invalid-yaml-syntax'; + +const REASON_TO_TRIGGER: Record = { + 'duplicate-yaml-key': 'duplicate-yaml-key', + 'invalid-yaml-syntax': 'invalid-yaml-syntax', +}; + +function readField(value: unknown, field: string): unknown { + if (typeof value !== 'object' || value === null || Array.isArray(value)) return undefined; + return (value as Record)[field]; +} + +/** + * Read every YAML document in one descriptor file's text. + * + * Returns one {@link DescriptorDocument} per document, in file order, each carrying + * its own outcome. A file whose second document is malformed still yields a record + * for its first — that is a *reporting* property, not a licence to continue: the + * whole-operation abort is `atomic-fail-closed.md`'s concern and Phase E's code. + * Reporting per document is what lets the abort name which document was at fault. + * + * An empty file yields no documents, which is not an error here. Whether a manifest + * may list an empty source is a manifest question, not a YAML one. + */ +export function readDescriptorDocuments( + sourcePath: string, + text: string, +): readonly DescriptorDocument[] { + // `uniqueKeys` is deliberately not passed: R8 relies on the library default of + // `true`, and naming it here would invite someone to "make it configurable". + const documents = parseAllDocuments(text); + + return documents.map((document, documentIndexInFile) => { + const [firstError] = document.errors; + + if (firstError !== undefined) { + const reason: DescriptorReadReason = + firstError.code === DUPLICATE_KEY_CODE ? 'duplicate-yaml-key' : 'invalid-yaml-syntax'; + + return { + sourcePath, + documentIndexInFile, + parseOutcome: reason === 'duplicate-yaml-key' ? 'duplicate-yaml-key' : 'yaml-parse-error', + rawApiVersion: undefined, + rawKind: undefined, + rawMetadata: undefined, + raw: undefined, + rejection: { + reason, + triggerClass: REASON_TO_TRIGGER[reason], + detail: `${sourcePath}[${documentIndexInFile}]: ${firstError.code}: ${firstError.message}`, + }, + } satisfies DescriptorDocument; + } + + const raw: unknown = document.toJSON(); + + return { + sourcePath, + documentIndexInFile, + parseOutcome: 'parsed', + rawApiVersion: readField(raw, 'apiVersion'), + rawKind: readField(raw, 'kind'), + rawMetadata: readField(raw, 'metadata'), + raw, + rejection: undefined, + } satisfies DescriptorDocument; + }); +} + +/** + * The value at `metadata.annotations[key]`, as a plain decoded value. + * + * Returned as `unknown` on purpose. `owned-paths-annotation.md` §1 step 2 is + * written as a literal `typeof rawNode === "string"` check, and it is only a + * meaningful check if the caller can actually receive a non-string. A YAML sequence + * arrives here as an array, a mapping as an object, a number as a number — so the + * step-2 check has something to reject. + * + * The `present` discriminant is explicit rather than inferred from `undefined`, + * because §1 step 1 requires exactly that: presence is decided by an annotation + * presence discriminant, "never inferred from whether a raw value happens to be + * `undefined`". A YAML `key:` with no value is present and null, not absent. + */ +export function readAnnotationNode( + document: DescriptorDocument, + key: string, +): { readonly present: boolean; readonly value: unknown } { + const metadata = document.rawMetadata; + const annotations = readField(metadata, 'annotations'); + if (typeof annotations !== 'object' || annotations === null || Array.isArray(annotations)) { + return { present: false, value: undefined }; + } + const record = annotations as Record; + return { present: Object.hasOwn(record, key), value: record[key] }; +} diff --git a/packages/adapters/catalog-backstage/src/diagnostics.ts b/packages/adapters/catalog-backstage/src/diagnostics.ts new file mode 100644 index 00000000..65517115 --- /dev/null +++ b/packages/adapters/catalog-backstage/src/diagnostics.ts @@ -0,0 +1,116 @@ +/** + * The shared diagnostic vocabulary every Phase D validator in this package emits. + * + * **Why this file exists.** Phase D produces seven independent validator slices + * (manifest schema, manifest version, repository identity, source digests, source + * paths, admissibility, annotation decode, glob dialect). Each one reports a + * *fine-grained* reason of its own, and each one also states which member of + * `data-model.md` §8's closed trigger enumeration that reason belongs to. Declaring + * the enumeration once, here, is what stops those two vocabularies from drifting + * apart across eight files. + * + * **This file is not the failure-classification surface.** `tasks.md` Phase E owns + * `src/failure/triggers.ts` and `src/failure/classify.ts`; those decide what an + * assembled run *does* with a trigger. This module only names them, so a pure + * validator can attribute its own rejection without importing anything that runs a + * pipeline. 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. + * + * @see `specs/010-catalog-backstage/data-model.md` §8 + * @see `specs/009-catalog-binding-viability/contracts/atomic-fail-closed.md` §4 + */ + +/** + * The closed trigger enumeration for **this feature**: exactly **fifteen** values. + * + * **The count is fifteen, and writing fourteen here is an error.** Spike 009's + * `atomic-fail-closed.md` §4 fixes *its* enumeration at fourteen and that statement + * remains correct as a statement about spike 009. This feature adds + * `inadmissible-descriptor` (ADR-0015 Condition of Acceptance 2), which appears + * nowhere under `specs/009-catalog-binding-viability/`. `data-model.md` §8 and + * `contracts/admissibility.md` §5.1 both record the production count as fifteen. + * + * `other-invalid-input` is a **deliberate, always-present backstop**, not an + * oversight: it exists so the type can stay closed while still honouring FR-007's + * own "including but not limited to" hedge. + */ +export type TriggerClass = + | 'duplicate-canonical-id' + | 'duplicate-canonical-ref' + | 'duplicate-yaml-key' + | 'invalid-yaml-syntax' + | 'invalid-manifest-shape' + | 'invalid-annotation-shape' + | 'invalid-annotation-parse' + | 'invalid-pattern' + | 'unsupported-manifest-version' + | 'unsupported-snapshot-version' + | 'unsupported-capability' + | 'repository-mismatch' + | 'incomplete-required-source' + | 'inadmissible-descriptor' + | 'other-invalid-input'; + +/** + * Every member of {@link TriggerClass}, as data, in the order `data-model.md` §8 + * lists them. + * + * Exported so a test can assert the count and the membership against the frozen + * contract rather than against the type — a type union is erased at runtime and + * cannot be counted, so a type-only declaration would leave the "fifteen, not + * fourteen" constraint untested. + */ +export const TRIGGER_CLASSES = [ + 'duplicate-canonical-id', + 'duplicate-canonical-ref', + 'duplicate-yaml-key', + 'invalid-yaml-syntax', + 'invalid-manifest-shape', + 'invalid-annotation-shape', + 'invalid-annotation-parse', + 'invalid-pattern', + 'unsupported-manifest-version', + 'unsupported-snapshot-version', + 'unsupported-capability', + 'repository-mismatch', + 'incomplete-required-source', + 'inadmissible-descriptor', + 'other-invalid-input', +] as const satisfies readonly TriggerClass[]; + +/** + * A rejection emitted by one Phase D validator. + * + * `reason` is the validator's own fine-grained code and is the string an ADR-0016 + * negative case records. `triggerClass` is the enumeration member that reason maps + * onto. Both are carried because collapsing them loses information in one direction + * or the other: fifteen trigger classes cannot distinguish which of six lexical + * path rules fired, and a fine-grained reason alone does not say whether the run + * aborts under a request-level or an entity-level class. + */ +export interface Rejection { + readonly reason: TReason; + readonly triggerClass: TriggerClass; + /** Human-readable. Never load-bearing — `data-model.md` §8 says so explicitly. */ + readonly detail: string; +} + +/** A validator outcome: either a value, or exactly one {@link Rejection}. */ +export type Validated = + | { readonly ok: true; readonly value: TValue } + | { readonly ok: false; readonly rejection: Rejection }; + +/** Build a successful {@link Validated}. */ +export function accepted(value: TValue): Validated { + return { ok: true, value }; +} + +/** Build a rejected {@link Validated}. */ +export function rejected( + reason: TReason, + triggerClass: TriggerClass, + detail: string, +): Validated { + return { ok: false, rejection: { reason, triggerClass, detail } }; +} diff --git a/packages/adapters/catalog-backstage/src/glob/dialect.ts b/packages/adapters/catalog-backstage/src/glob/dialect.ts new file mode 100644 index 00000000..07655ec2 --- /dev/null +++ b/packages/adapters/catalog-backstage/src/glob/dialect.ts @@ -0,0 +1,183 @@ +/** + * T063 · T067 — the frozen glob engine and options, and the compile-once + * discipline. + * + * # Frozen (`glob-dialect.md` §1) + * + * | Property | Value | + * |---|---| + * | Engine | `picomatch` | + * | Exact version | whatever `bun.lock` resolves — **read at runtime**, never transcribed | + * | Options | `{ dot: false, nocase: false, nonegate: true }` | + * + * The options are identical to `packages/core/src/affects/inert.ts`'s and + * `packages/core/src/affects/matchers/path.ts`'s own compile options: §1 forbids + * introducing "a second glob-matching dependency or a different options + * combination". Any future change to the engine, its version, or any option is a + * **versioned reclassification** requiring regeneration evidence (§5) — never a + * silent substitution. + * + * # Why the version is read rather than written down (FR-029) + * + * A transcribed version literal is correct exactly until the lockfile moves, and + * then it is a false statement in every envelope the generator writes — the one + * kind of error that is invisible precisely because it used to be true. FR-029: + * the implementation "MUST record in the envelope's `globDialect` and MUST verify + * rather than assume". So {@link readGlobEngineVersion} resolves + * `picomatch/package.json` through the module resolver and reads it. `research.md` + * R3 verified the current resolution as `picomatch@4.0.5`; that number appears in + * this package only inside a test, as an *observation*, never as the source of + * truth. + * + * # Compile once per run (§6, FR-032) + * + * §6: each accepted pattern is compiled "**exactly once** per derivation run — + * never once per match check against each changed file". FR-032 adds the + * correctness reason on top of the cost one: validation and matching must not be + * able to diverge. {@link createGlobCompiler} is the mechanism — the matcher rule 15 + * builds during *validation* is the same object handed back for *matching*, so + * there is no second compilation that could disagree with the first. + * + * @see `specs/009-catalog-binding-viability/contracts/glob-dialect.md` §1, §5, §6 + */ + +import { dirname, join } from 'node:path'; +import picomatch from 'picomatch'; + +/** `glob-dialect.md` §1. */ +export const GLOB_ENGINE = 'picomatch'; + +/** + * `glob-dialect.md` §1's options, frozen. + * + * `Object.freeze` is not decoration: a caller mutating this object would change the + * dialect for every subsequent compile in the run while the envelope still recorded + * the original — a divergence between what was recorded and what was used, which is + * the exact failure §1 and FR-029 exist to prevent. + */ +export const GLOB_OPTIONS = Object.freeze({ + dot: false, + nocase: false, + nonegate: true, +} as const); + +/** + * The resolved `picomatch` version, read from the dependency the package manager + * actually installed. + * + * **Read by path, not by module resolution, and that is deliberate.** ADR-0013 and + * FR-002 forbid *any* runtime module resolution in this package — `import()`, + * `require.resolve`, and `import.meta.resolve` alike — and Phase A's + * `test/no-dynamic-loader.test.ts` enforces it by scanning the source. So the + * version is obtained the one way that satisfies both requirements at once: by + * walking up from this module to the nearest `node_modules/picomatch/package.json` + * and reading the file. That is a filesystem read of the installed dependency, not a + * resolver invocation — it introduces no dynamic loading, no discovery, and no + * registry, while still reading the resolution rather than a transcribed literal. + * + * The two requirements genuinely pull in opposite directions here, and the tension + * is reported rather than resolved silently in favour of whichever was read last. + * + * Async because it reads files. Callers that need it synchronously should read it + * once at the start of a run and carry the value — which is also what recording it + * in an envelope requires. + */ +export async function readGlobEngineVersion(): Promise { + const manifestPath = await findEngineManifest(); + if (manifestPath === undefined) { + throw new Error( + `could not locate node_modules/picomatch/package.json by walking up from ${import.meta.dir}. ` + + 'Refusing to fall back to a transcribed literal: a recorded engine version that was ' + + 'not read out of the installed dependency is a claim this package has not verified ' + + '(glob-dialect.md §1, FR-029).', + ); + } + + const manifest = (await Bun.file(manifestPath).json()) as { version?: unknown }; + const version = manifest.version; + if (typeof version !== 'string' || version.length === 0) { + throw new Error(`could not read a version from ${manifestPath}.`); + } + return version; +} + +/** Walk up from this module looking for the installed engine's own manifest. */ +async function findEngineManifest(): Promise { + let directory = import.meta.dir; + + for (;;) { + const candidate = join(directory, 'node_modules', GLOB_ENGINE, 'package.json'); + if (await Bun.file(candidate).exists()) return candidate; + + const parent = dirname(directory); + if (parent === directory) return undefined; + directory = parent; + } +} + +/** The engine identification recorded alongside any derivation. */ +export interface GlobDialectIdentification { + readonly engine: string; + readonly version: string; + readonly options: typeof GLOB_OPTIONS; +} + +/** Read the full engine identification, verified rather than assumed. */ +export async function readGlobDialect(): Promise { + return { engine: GLOB_ENGINE, version: await readGlobEngineVersion(), options: GLOB_OPTIONS }; +} + +/** A compiled matcher, or the exception rule 15 caught trying to build one. */ +export type CompileResult = + | { readonly ok: true; readonly matcher: (path: string) => boolean } + | { readonly ok: false; readonly error: Error }; + +/** + * A per-run compiler that compiles each distinct pattern exactly once. + * + * Deliberately an explicit object rather than a module-level cache. A module-level + * cache would persist across runs, which sounds like a stronger version of the same + * property and is in fact a different, worse one: it would make one run's results + * depend on what a previous run happened to compile, and `owned-paths-annotation.md` + * §5 requires byte-identical output across repeated runs. A compiler created per run + * has no such history. + */ +export interface GlobCompiler { + /** Compile `pattern`, or return the already-compiled result for it. */ + compile(pattern: string): CompileResult; + /** How many times `picomatch(...)` has actually been invoked. */ + readonly compileCount: number; + /** How many distinct patterns have been compiled. */ + readonly patternCount: number; +} + +/** Create a compiler for one derivation run. */ +export function createGlobCompiler(): GlobCompiler { + const cache = new Map(); + let compileCount = 0; + + return { + compile(pattern: string): CompileResult { + const cached = cache.get(pattern); + if (cached !== undefined) return cached; + + let result: CompileResult; + try { + compileCount += 1; + const matcher = picomatch(pattern, GLOB_OPTIONS); + result = { ok: true, matcher: (path: string) => matcher(path) }; + } catch (error) { + result = { ok: false, error: error as Error }; + } + + cache.set(pattern, result); + return result; + }, + get compileCount() { + return compileCount; + }, + get patternCount() { + return cache.size; + }, + }; +} diff --git a/packages/adapters/catalog-backstage/src/glob/order.ts b/packages/adapters/catalog-backstage/src/glob/order.ts new file mode 100644 index 00000000..03ec7366 --- /dev/null +++ b/packages/adapters/catalog-backstage/src/glob/order.ts @@ -0,0 +1,45 @@ +/** + * T068 — FR-033: `derivedPaths` is sorted with `compareCodeUnits` and deduplicated. + * + * The point of both operations is stated in FR-033 itself: *"so that the envelope's + * array ordering is a function of content alone"*. Two annotations declaring the + * same patterns in different orders must produce byte-identical output + * (`owned-paths-annotation.md` §5, SC-001). + * + * **`compareCodeUnits`, never `localeCompare`.** `packages/core/src/ordering/index.ts:12` + * is the repository's sole comparator, and its own module note says why: a + * locale-sensitive comparison makes output depend on the machine's environment, + * which is not "a function of content alone" at all. It is imported here rather than + * reimplemented so there is exactly one ordering source across the repository. + * + * **Dedup before sort, and `Set` is safe here.** `owned-paths-annotation.md` §5 + * names "a `Set` iteration order dependency" as a source of non-determinism, and + * that warning is about *relying on* insertion order. Deduplicating with a `Set` and + * then sorting cannot inherit that dependency: the sort is total over distinct + * strings, so insertion order is discarded. The ordering below is therefore a + * function of the pattern *set*, not of how it was built. + * + * @see `specs/010-catalog-backstage/spec.md` FR-033 + * @see `specs/009-catalog-binding-viability/contracts/owned-paths-annotation.md` §5 + */ + +import { compareCodeUnits } from '@adrkit/core'; + +/** + * Sort and deduplicate derived paths. + * + * Returns a new array; the input is never mutated, so a caller holding the + * annotation's decoded order can still report it. + */ +export function orderDerivedPaths(patterns: readonly string[]): readonly string[] { + return [...new Set(patterns)].sort(compareCodeUnits); +} + +/** True when `patterns` is already sorted and deduplicated. */ +export function isOrdered(patterns: readonly string[]): boolean { + const ordered = orderDerivedPaths(patterns); + return ( + ordered.length === patterns.length && + ordered.every((pattern, index) => pattern === patterns[index]) + ); +} diff --git a/packages/adapters/catalog-backstage/src/glob/validate.ts b/packages/adapters/catalog-backstage/src/glob/validate.ts new file mode 100644 index 00000000..67f337f3 --- /dev/null +++ b/packages/adapters/catalog-backstage/src/glob/validate.ts @@ -0,0 +1,186 @@ +/** + * T064 — the **fifteen** ordered rules, first-match-wins. + * + * `glob-dialect.md` §3: "Validate each decoded string in **exactly** this order, + * stopping at the first rule that matches, so a pattern violating multiple rules + * always reports the same one reason regardless of implementation." + * + * # Fifteen rules; fourteen required exercises + * + * Rule 15 is the engine compile. Its `"invalid-glob-compile-failure"` outcome is, + * in §3's own words, "expected to never occur in practice, given rules 1–14's + * exhaustiveness; present only as a defensive backstop". SC-007 accordingly + * requires rules **1–14** each to be exercised, and states that a run which never + * produces rule 15's rejection **is conformant and MUST NOT be reported as a + * coverage gap**. Rule 15's `"accepted"` outcome *is* exercised, by every valid + * pattern reaching it. + * + * **Fifteen rules is not fourteen required exercises, and neither number is the + * trigger count** — that is fifteen too, for an unrelated reason + * (`data-model.md` §8). Three numbers, easy to fuse, and `data-model.md` §7.1 says + * plainly: "Do not conflate the two numbers." + * + * # Why order is load-bearing rather than stylistic + * + * §3's worked example: `packages/{a,..}/**` is rejected at rule 6 (`"brace"`) — + * braces are rejected outright regardless of their contents, so this pattern never + * reaches rule 11. A brace-free pattern containing a bare `..` segment, e.g. + * `packages/../etc`, is rejected at rule 11 (`"traversal-segment"`) instead. The two + * remain independently distinguishable in the evidence bundle, which they would not + * be if the order floated. + * + * @see `specs/009-catalog-binding-viability/contracts/glob-dialect.md` §2, §3 + * @see `specs/010-catalog-backstage/data-model.md` §7.1 + */ + +import { type GlobCompiler, createGlobCompiler } from './dialect.ts'; + +/** `data-model.md` §7.1. Fifteen outcomes: fourteen rejections plus `accepted`. */ +export type GlobOutcome = + | 'accepted' + | 'empty' + | 'leading-slash' + | 'absolute-or-drive-or-unc' + | 'backslash' + | 'nul-or-control-char' + | 'brace' + | 'bracket' + | 'parenthesis' + | 'comma' + | 'leading-bang' + | 'traversal-segment' + | 'empty-segment' + | 'disallowed-character' + | 'malformed-double-star' + | 'invalid-glob-compile-failure'; + +/** `data-model.md` §7.1. */ +export interface RestrictedGlobPattern { + readonly raw: string; + readonly outcome: GlobOutcome; + /** Which of the fifteen rules decided. 1-based, matching §3's numbering. */ + readonly rule: number; +} + +/** + * `glob-dialect.md` §2's positive allowlist: `A-Z`, `a-z`, `0-9`, `_`, `-`, `.`, + * `/`, `*`, `?`. + * + * This is rule 13, and §3 explains why a blacklist alone (rules 1–12) is + * insufficient: `@`, `#`, `%`, `~`, `+`, `=`, `:`, `;`, `<`, `>`, `|`, `&`, `^` and + * any non-ASCII literal "violate none of rules 1–12 individually but are still + * excluded by ADR-0012's own *positive* grammar". + */ +const ALLOWED_CHARACTER = /^[A-Za-z0-9_\-./*?]$/u; + +/** Rules 1–14 in §3's order. Rule 15 is the compile and is applied separately. */ +const RULES: readonly { + readonly rule: number; + readonly outcome: Exclude; + readonly violates: (pattern: string) => boolean; +}[] = [ + { rule: 1, outcome: 'empty', violates: (p) => p === '' }, + { rule: 2, outcome: 'leading-slash', violates: (p) => p.startsWith('/') }, + { + rule: 3, + outcome: 'absolute-or-drive-or-unc', + violates: (p) => /^[A-Za-z]:/u.test(p) || p.startsWith('\\\\'), + }, + { rule: 4, outcome: 'backslash', violates: (p) => p.includes('\\') }, + { + rule: 5, + outcome: 'nul-or-control-char', + violates: (p) => + [...p].some((character) => { + const code = character.codePointAt(0) ?? 0; + return code < 0x20 || code === 0x7f; + }), + }, + { rule: 6, outcome: 'brace', violates: (p) => p.includes('{') || p.includes('}') }, + { rule: 7, outcome: 'bracket', violates: (p) => p.includes('[') || p.includes(']') }, + { rule: 8, outcome: 'parenthesis', violates: (p) => p.includes('(') || p.includes(')') }, + { rule: 9, outcome: 'comma', violates: (p) => p.includes(',') }, + { rule: 10, outcome: 'leading-bang', violates: (p) => p.startsWith('!') }, + { + rule: 11, + outcome: 'traversal-segment', + violates: (p) => p.split('/').some((segment) => segment === '.' || segment === '..'), + }, + { + rule: 12, + outcome: 'empty-segment', + // Rule 2 has already rejected a leading `/`, so any empty segment reaching here + // is an internal `//` or a trailing `/`, exactly as §3 describes. + violates: (p) => p.split('/').some((segment) => segment === ''), + }, + { + rule: 13, + outcome: 'disallowed-character', + violates: (p) => [...p].some((character) => !ALLOWED_CHARACTER.test(character)), + }, + { + rule: 14, + outcome: 'malformed-double-star', + // "Only a segment that is *exactly* `**` is the allowed whole-segment + // double-star." `a**b`, `**b`, `a**`, `foo/**bar` all violate this. + violates: (p) => + p.split('/').some((segment) => segment.includes('**') && segment !== '**'), + }, +]; + +/** The number of rules `glob-dialect.md` §3 defines. Fifteen, including the compile. */ +export const GLOB_RULE_COUNT = RULES.length + 1; + +/** + * The rules SC-007 requires to be exercised: **1–14**. + * + * Rule 15 is deliberately excluded, and its exclusion is conformant rather than a + * gap — see the module note. + */ +export const GLOB_RULES_REQUIRING_EXERCISE = RULES.map((rule) => rule.rule); + +/** + * Validate one pattern against the fifteen ordered rules. + * + * `compiler` is optional so a caller validating a single pattern in isolation need + * not manage one. When it is supplied — which is what a real derivation run does — + * the matcher built by rule 15 is retained in that compiler, and matching later + * reuses it. That is FR-032's "validation and matching cannot diverge", made + * structural: there is only ever one compiled matcher per pattern per run. + */ +export function validateGlobPattern( + pattern: string, + compiler: GlobCompiler = createGlobCompiler(), +): RestrictedGlobPattern { + for (const rule of RULES) { + if (rule.violates(pattern)) return { raw: pattern, outcome: rule.outcome, rule: rule.rule }; + } + + // Rule 15 — compile. A compile-time exception this dialect's own rules did not + // already name is the defensive backstop; compilation succeeding is `accepted`. + const compiled = compiler.compile(pattern); + return compiled.ok + ? { raw: pattern, outcome: 'accepted', rule: 15 } + : { raw: pattern, outcome: 'invalid-glob-compile-failure', rule: 15 }; +} + +/** + * Validate a batch, each pattern **in isolation**. + * + * FR-031: "A batch containing several distinct violations MUST classify each + * individually when each is validated in isolation." No pattern's outcome + * influences another's — there is no accumulated state between iterations, and the + * shared compiler is a cache keyed by pattern, which cannot change a verdict. + * + * This returns every pattern's verdict rather than stopping at the first rejection. + * That is a *reporting* choice and not a relaxation of atomicity: + * `atomic-fail-closed.md` §1 still aborts the whole operation on any rejected + * pattern, and that abort is Phase E's code. Reporting all of them is what lets the + * abort say which patterns were at fault rather than only the earliest. + */ +export function validateGlobPatterns( + patterns: readonly string[], + compiler: GlobCompiler = createGlobCompiler(), +): readonly RestrictedGlobPattern[] { + return patterns.map((pattern) => validateGlobPattern(pattern, compiler)); +} diff --git a/packages/adapters/catalog-backstage/src/identity/canonicalize.ts b/packages/adapters/catalog-backstage/src/identity/canonicalize.ts new file mode 100644 index 00000000..f3802eaa --- /dev/null +++ b/packages/adapters/catalog-backstage/src/identity/canonicalize.ts @@ -0,0 +1,77 @@ +/** + * T055 — **two-step** canonicalization. + * + * `entity-identity.md` §1, transcribed: + * + * 1. If `metadata.namespace` is omitted, `NS := "default"` — Backstage's own + * `stringifyEntityRef` default-namespace substitution. + * 2. ``canonicalId := `${K}:${NS}/${N}`.toLowerCase()`` — the **entire** string is + * lowercased, not merely a prefix, matching `stringifyEntityRef`'s own + * lowercasing behaviour exactly. + * + * **Lowercasing the whole string is the step that is easy to get wrong.** An + * implementation that lowercased only the name — or only the kind prefix — would + * make `Component:Default/Payments` and `component:default/payments` two entities + * where §1 requires one, and the duplicate rule would then silently miss the + * collision it exists to catch. + * + * **This function is reachable only from an admitted descriptor**, by type. That is + * ADR-0015's ordering rule expressed structurally: see + * `admissibility/index.ts`'s module note. There is deliberately no overload taking + * a raw `DescriptorDocument`. + * + * **The namespace is defaulted here and validated elsewhere.** ADR-0015: the + * namespace "is validated as authored, never after defaulting". So step 1 happens + * in this module, strictly after `validateNamespace` has already run in + * `admissibility/validators.ts` — never before. + * + * @see `specs/009-catalog-binding-viability/contracts/entity-identity.md` §1 + * @see `specs/010-catalog-backstage/data-model.md` §5 + */ + +import type { AdmittedDescriptor } from '../admissibility/index.ts'; + +/** `entity-identity.md` §1's default-namespace substitution value. */ +export const DEFAULT_NAMESPACE = 'default'; + +/** `data-model.md` §5. */ +export interface CanonicalEntityIdentity { + readonly rawKind: string; + /** `undefined` when `metadata.namespace` was omitted — the authored value. */ + readonly rawNamespace: string | undefined; + readonly rawName: string; + /** `${kind}:${namespace}/${name}`, lowercased in full. */ + readonly canonicalId: string; + /** Non-empty; `canonicalId` is always a member. */ + readonly allRefs: readonly string[]; +} + +/** + * Canonicalize an admitted descriptor's identity. + * + * The cast on the three fields is safe by construction rather than by assumption: + * an {@link AdmittedDescriptor} exists only if all four validators returned true, + * and each of those predicates begins with `typeof value !== 'string'` → false. + * + * `allRefs` is `[canonicalId]`. `data-model.md` §5 requires it be present and + * non-empty and records, as an open `[NEEDS CLARIFICATION]`, that how it is + * populated beyond the primary id in production is undecided — no ADR decides it, + * Backstage defines no standard alias field, and spike 009 sourced aliases from + * synthetic fixtures only. This module emits the documented minimum and invents + * nothing beyond it. + */ +export function canonicalize(admitted: AdmittedDescriptor): CanonicalEntityIdentity { + const rawKind = admitted.fields.kind as string; + const rawName = admitted.fields.name as string; + const rawNamespace = admitted.fields.namespacePresent + ? (admitted.fields.namespace as string) + : undefined; + + // Step 1 — default-namespace substitution. + const namespace = rawNamespace ?? DEFAULT_NAMESPACE; + + // Step 2 — lowercase the ENTIRE identity string, not merely a component. + const canonicalId = `${rawKind}:${namespace}/${rawName}`.toLowerCase(); + + return { rawKind, rawNamespace, rawName, canonicalId, allRefs: [canonicalId] }; +} diff --git a/packages/adapters/catalog-backstage/src/manifest/boundary.ts b/packages/adapters/catalog-backstage/src/manifest/boundary.ts new file mode 100644 index 00000000..23021e59 --- /dev/null +++ b/packages/adapters/catalog-backstage/src/manifest/boundary.ts @@ -0,0 +1,144 @@ +/** + * T045 — the closed input boundary. + * + * `input-manifest.md` §5 fixes what a single generation invocation may read: + * the manifest file itself, each descriptor path the manifest's `sources` array + * lists (digest-verified before trust), and the two git-identity values read by + * subprocess. It **never**: + * + * - follows a `Location` entity's `spec.targets` to a file the manifest does not + * itself list; + * - invokes any catalog processor or plugin of any kind; + * - recursively walks or glob-expands the tree to *discover* descriptor files, + * "even ones that would trivially match a typical `catalog-info.yaml` naming + * convention"; + * - claims whole-catalog completeness. + * + * **Why this is a module and not only a test.** A boundary asserted purely by the + * absence of forbidden code is the shape ADR-0016 clause 3 warns about: a scan that + * examined nothing reports the same green as a scan that looked properly. So the + * boundary is made into a **value that can be computed and compared** — + * {@link admissibleReadSet} derives the entire permitted read set from the manifest + * alone, and {@link classifyLocationTarget} decides, for a concrete `Location` + * target, whether that target is inside it. A test can then assert a specific + * observed value rather than an absence. The source-level scan in + * `test/input-boundary.test.ts` is the second, independent mechanism; neither is + * sufficient alone. + * + * @see `specs/009-catalog-binding-viability/contracts/input-manifest.md` §5, §6 + * @see `specs/010-catalog-backstage/spec.md` FR-013 + */ + +import { compareCodeUnits } from '@adrkit/core'; +import type { InputManifest } from './schema.ts'; + +/** + * The two git reads §5 permits, named as data. + * + * Exported so the permitted subprocess surface is enumerable rather than implied + * by whatever `repository/identity.ts` happens to call. + */ +export const PERMITTED_GIT_READS = [ + ['remote', 'get-url', 'origin'], + ['rev-parse', 'HEAD'], +] as const; + +/** Everything one generation invocation is permitted to read. */ +export interface AdmissibleReadSet { + /** The manifest file's own path, as supplied to the invocation. */ + readonly manifestPath: string; + /** Exactly the manifest's `sources[].path` values, sorted and deduplicated. */ + readonly sourcePaths: readonly string[]; + /** The git argument vectors permitted as subprocess reads. */ + readonly gitReads: readonly (readonly string[])[]; +} + +/** + * Derive the closed read set from the manifest alone. + * + * There is deliberately no filesystem parameter and no directory argument: this + * function *cannot* widen the set by looking around, because it has nothing to look + * at. Sorting by `compareCodeUnits` and deduplicating makes the set a function of + * manifest content alone, so two manifests listing the same sources in different + * orders produce the identical read set. + */ +export function admissibleReadSet( + manifestPath: string, + manifest: InputManifest, +): AdmissibleReadSet { + const sourcePaths = [...new Set(manifest.sources.map((source) => source.path))].sort( + compareCodeUnits, + ); + return { manifestPath, sourcePaths, gitReads: PERMITTED_GIT_READS }; +} + +/** True when `path` is one of the manifest-listed sources. */ +export function isManifestListedSource(readSet: AdmissibleReadSet, path: string): boolean { + return readSet.sourcePaths.includes(path); +} + +/** + * The two outcomes for a `Location` entity's `spec.targets` entry. + * + * `zero-derived-paths-never-read` is `input-manifest.md` §6's own required + * wording, and `data-model.md` §16 records it as the evidence-bundle value. §6 is + * explicit that it must **never** be recorded as `"invalid-input"`: the target's + * annotation was not invalid, it was never read at all, and reporting a file that + * was never opened as invalid asserts an observation that did not happen. + */ +export type LocationTargetOutcome = 'manifest-listed' | 'zero-derived-paths-never-read'; + +/** One classified `Location` target. */ +export interface LocationTargetClassification { + readonly target: string; + readonly outcome: LocationTargetOutcome; +} + +/** + * Classify one `Location` `spec.targets` entry against the read set. + * + * Note what this function does *not* do: it never opens `target`, never stats it, + * and never resolves it. A target outside the manifest is not read in order to + * discover that it should not have been read. + */ +export function classifyLocationTarget( + readSet: AdmissibleReadSet, + target: string, +): LocationTargetClassification { + return { + target, + outcome: isManifestListedSource(readSet, target) + ? 'manifest-listed' + : 'zero-derived-paths-never-read', + }; +} + +/** + * Classify every `spec.targets` entry a `Location` descriptor carries. + * + * `rawTargets` is `unknown` because it comes from a descriptor that has not been + * shape-checked — `data-model.md` §3 types descriptor content as `unknown` for + * exactly this reason. A non-array, or a non-string element, yields no + * classification rather than a coerced one: this function's job is to demonstrate + * that targets are not followed, and inventing a target string to then not follow + * would be a fabricated observation. + */ +export function classifyLocationTargets( + readSet: AdmissibleReadSet, + rawTargets: unknown, +): readonly LocationTargetClassification[] { + if (!Array.isArray(rawTargets)) return []; + return rawTargets + .filter((target): target is string => typeof target === 'string') + .map((target) => classifyLocationTarget(readSet, target)); +} + +/** + * FR-014 · `input-manifest.md` §5's fourth bullet. + * + * A constant rather than a computation, because there is no input under which it + * could be `true`: FR-013 forbids tree traversal, so no run can ever have seen the + * whole catalog. Phase E's envelope assembly consumes this; it is stated here + * because it is a property *of the input boundary*, not of the envelope writer. + */ +export const WHOLE_CATALOG_COMPLETENESS = false; diff --git a/packages/adapters/catalog-backstage/src/manifest/digests.ts b/packages/adapters/catalog-backstage/src/manifest/digests.ts new file mode 100644 index 00000000..f8594b6a --- /dev/null +++ b/packages/adapters/catalog-backstage/src/manifest/digests.ts @@ -0,0 +1,145 @@ +/** + * T043 — per-source digest verification, run **before any entity is processed**. + * + * `input-manifest.md` §4: every `sources[]` entry's `digest` is the expected + * SHA-256 of that file's raw bytes, computed at manifest-authoring time. At + * generation time each listed file is read, its actual digest **independently + * recomputed**, and compared. A mismatch, or a manifest-listed path absent from + * disk, is an `incomplete-required-source` rejection — a property of the + * manifest/generation request that aborts before any entity's paths are derived, + * **never a per-entity skip**. + * + * FR-011 adds a third condition to §4's two: a **wrongly typed** digest. A digest + * that is not 64 lowercase hex characters cannot be the SHA-256 of anything, so + * comparing bytes against it would be theatre — it is rejected as its own reason + * before any file is opened. + * + * **Why "before any entity is processed" is a structural claim here.** + * {@link verifySourceDigests} verifies *every* source and returns on the first + * failure, and it returns digests rather than content. It has no way to hand a + * caller one verified file while another is still unchecked, because it never + * yields anything until the whole set has passed. That is what makes the ordering + * a property of the shape rather than of the caller's discipline. + * + * @see `specs/009-catalog-binding-viability/contracts/input-manifest.md` §4 + * @see `specs/010-catalog-backstage/spec.md` FR-011 + */ + +import { type Validated, accepted, rejected } from '../diagnostics.ts'; +import type { ManifestSource } from './schema.ts'; + +/** 64 lowercase hex characters — the only well-formed `sha256` digest. */ +export const SHA256_HEX = /^[0-9a-f]{64}$/u; + +/** Fine-grained reasons. All map to `incomplete-required-source`. */ +export type SourceDigestReason = + | 'digest-malformed' + | 'source-missing' + | 'source-unreadable' + | 'digest-mismatch'; + +/** One verified source: its manifest path and the digest observed on disk. */ +export interface VerifiedSource { + readonly path: string; + readonly observedDigest: string; +} + +/** Compute the lowercase hex SHA-256 of raw bytes. */ +export function sha256Hex(bytes: Uint8Array): string { + const hasher = new Bun.CryptoHasher('sha256'); + hasher.update(bytes); + return hasher.digest('hex'); +} + +/** + * Reject a digest that is not 64 lowercase hex characters. + * + * Pure, and exported separately so the malformed-digest case can be observed + * failing without touching the filesystem at all. + */ +export function checkDigestShape( + source: ManifestSource, +): Validated { + if (!SHA256_HEX.test(source.digest)) { + return rejected( + 'digest-malformed', + 'incomplete-required-source', + `${source.path}: digest must be 64 lowercase hex characters; observed ${JSON.stringify(source.digest)}`, + ); + } + return accepted(source); +} + +/** + * Compare one already-read file's bytes against its declared digest. + * + * Pure. Split out from {@link verifySourceDigests} so the mismatch case is + * observable without staging a file on disk. + */ +export function verifySourceBytes( + source: ManifestSource, + bytes: Uint8Array, +): Validated { + const shape = checkDigestShape(source); + if (!shape.ok) return shape; + + const observedDigest = sha256Hex(bytes); + if (observedDigest !== source.digest) { + return rejected( + 'digest-mismatch', + 'incomplete-required-source', + `${source.path}: declared ${source.digest}, observed ${observedDigest}`, + ); + } + return accepted({ path: source.path, observedDigest }); +} + +/** + * Verify every declared source digest against bytes on disk. + * + * `resolveSourcePath` maps a manifest-relative source path to an absolute path. + * It is a parameter rather than a hard-coded join because `manifest/paths.ts` + * (T044) owns path resolution and must have already accepted the path: this + * function verifies digests and does not decide what a path is allowed to be. + * + * Returns on the first failure. Sources are processed in manifest order, so the + * reported failure for a manifest with two bad sources is deterministic. + */ +export async function verifySourceDigests( + sources: readonly ManifestSource[], + resolveSourcePath: (path: string) => string, +): Promise> { + const verified: VerifiedSource[] = []; + + for (const source of sources) { + const shape = checkDigestShape(source); + if (!shape.ok) return shape; + + const absolute = resolveSourcePath(source.path); + const file = Bun.file(absolute); + if (!(await file.exists())) { + return rejected( + 'source-missing', + 'incomplete-required-source', + `${source.path}: listed in the manifest but absent from the checkout`, + ); + } + + let bytes: Uint8Array; + try { + bytes = new Uint8Array(await file.arrayBuffer()); + } catch (error) { + return rejected( + 'source-unreadable', + 'incomplete-required-source', + `${source.path}: could not be read: ${(error as Error).message}`, + ); + } + + const result = verifySourceBytes(source, bytes); + if (!result.ok) return result; + verified.push(result.value); + } + + return accepted(verified); +} diff --git a/packages/adapters/catalog-backstage/src/manifest/paths.ts b/packages/adapters/catalog-backstage/src/manifest/paths.ts new file mode 100644 index 00000000..21455354 --- /dev/null +++ b/packages/adapters/catalog-backstage/src/manifest/paths.ts @@ -0,0 +1,243 @@ +/** + * T044 — **two-stage** source-path validation: a lexical rejection stage, then a + * confined realpath stage. + * + * `input-manifest.md` §4.1 opens with the reason this exists: *naming a source + * `path` "repo-relative POSIX" is not itself a validation.* Without an explicit + * check a manifest could name `/etc/passwd`, `../../secret`, `C:\...`, + * `\\host\share`, or a lexically-clean path that resolves through a symlink to a + * target outside the checkout — and a naive generator would read outside the + * boundary it promised. + * + * The two stages are genuinely different checks and must both exist: + * + * - **Stage 1 is purely lexical** and runs *before the filesystem is touched*. It + * cannot see symlinks, and it does not try to. + * - **Stage 2 resolves symlinks** and requires the resolved real path to still lie + * beneath the verified checkout root. It cannot be replaced by a stricter + * stage 1, because a lexically-clean relative path can still symlink outside the + * root. That is stated in §4.1 in those terms. + * + * **Trigger-class attribution.** §4.1 names stage 2's failure explicitly: it "is an + * 'incomplete required source' rejection, and the file is never opened". It does + * **not** name a trigger class for stage 1, saying only "reject the manifest, + * non-zero". Stage 1 is a defect in the manifest's own content rather than in the + * checkout's, so it is attributed to `invalid-manifest-shape` here. That + * attribution is an inference from the contract's silence, not a quotation from + * it, and it is reported as a gap rather than presented as settled. + * + * @see `specs/009-catalog-binding-viability/contracts/input-manifest.md` §4.1 + * @see `specs/010-catalog-backstage/spec.md` FR-012 + */ + +import { realpath } from 'node:fs/promises'; +import { basename, dirname, isAbsolute, join, resolve, sep } from 'node:path'; +import { type Validated, accepted, rejected } from '../diagnostics.ts'; + +/** + * Stage 1 reasons — one per bullet in `input-manifest.md` §4.1's numbered list. + * + * They are separate rather than a single `lexically-invalid` because ADR-0016 + * records the emitted string, and six negative cases that all emit the same string + * demonstrate one check six times rather than six checks once. + */ +export type LexicalPathReason = + | 'path-empty' + | 'path-dot-or-dotdot' + | 'path-absolute' + | 'path-drive-prefix' + | 'path-backslash' + | 'path-traversal-segment' + | 'path-control-character'; + +/** Stage 2 reason. Maps to `incomplete-required-source`, per §4.1. */ +export type ConfinedPathReason = 'path-escapes-checkout-root'; + +/** Either stage's reason. */ +export type SourcePathReason = LexicalPathReason | ConfinedPathReason; + +/** + * **Stage 1 — lexical rejection.** Pure; touches no filesystem. + * + * The order below is the order §4.1 lists the conditions in, so a path violating + * several always reports the same one. + */ +export function validatePathLexically(path: string): Validated { + if (path === '') { + return rejected('path-empty', 'invalid-manifest-shape', 'a source path is the empty string'); + } + + if (path === '.' || path === '..') { + return rejected( + 'path-dot-or-dotdot', + 'invalid-manifest-shape', + `a source path is exactly ${JSON.stringify(path)}`, + ); + } + + // A leading `/` is checked directly rather than via `isAbsolute`, because + // `isAbsolute` is platform-dependent and this rule is not: the contract states + // "is absolute or begins with `/`". + if (path.startsWith('/') || isAbsolute(path)) { + return rejected( + 'path-absolute', + 'invalid-manifest-shape', + `source path ${JSON.stringify(path)} is absolute`, + ); + } + + if (/^[A-Za-z]:/u.test(path)) { + return rejected( + 'path-drive-prefix', + 'invalid-manifest-shape', + `source path ${JSON.stringify(path)} begins with a Windows drive prefix`, + ); + } + + // Covers both the UNC form (`\\host\share`) and any interior backslash: §4.1 + // groups them, because a UNC path is a special case of "contains a backslash". + if (path.includes('\\')) { + return rejected( + 'path-backslash', + 'invalid-manifest-shape', + `source path ${JSON.stringify(path)} contains a backslash`, + ); + } + + for (const segment of path.split('/')) { + if (segment === '.' || segment === '..') { + return rejected( + 'path-traversal-segment', + 'invalid-manifest-shape', + `source path ${JSON.stringify(path)} contains a ${JSON.stringify(segment)} segment`, + ); + } + } + + for (const character of path) { + const code = character.codePointAt(0) ?? 0; + if (code < 0x20 || code === 0x7f) { + return rejected( + 'path-control-character', + 'invalid-manifest-shape', + `source path ${JSON.stringify(path)} contains control character U+${code.toString(16).toUpperCase().padStart(4, '0')}`, + ); + } + } + + return accepted(path); +} + +/** + * True when `candidate` lies strictly beneath `root`. + * + * Pure, and separated from {@link validatePathConfined} so the containment rule is + * testable without staging symlinks. The separator suffix is what stops + * `/repo-evil` from counting as beneath `/repo`; equality with the root is not + * "beneath" either, since the root is not a source file. + */ +export function isBeneath(root: string, candidate: string): boolean { + const normalizedRoot = resolve(root); + const normalizedCandidate = resolve(candidate); + return normalizedCandidate.startsWith( + normalizedRoot.endsWith(sep) ? normalizedRoot : `${normalizedRoot}${sep}`, + ); +} + +/** The absolute, symlink-resolved path a source resolved to. */ +export interface ConfinedPath { + readonly declared: string; + readonly resolved: string; +} + +/** + * Resolve `absolute` as far as the filesystem allows, then re-append the part that + * does not exist yet. + * + * A plain `realpath` throws for a path whose final component is absent, and falling + * back to the *unresolved* path in that case would silently skip symlink + * resolution for every ancestor — which is precisely the escape route stage 2 + * exists to close. Worse, on a platform where the checkout root itself resolves + * elsewhere (macOS `/var` → `/private/var`), the unresolved fallback compares two + * differently-rooted strings and reports every absent file as an escape: a check + * that appears to work while testing nothing. + * + * Walking up to the deepest existing ancestor resolves every symlink that actually + * exists on the path, and treats the not-yet-existing tail as the literal names it + * is. Absence is then left to `manifest/digests.ts` to report under its own reason. + */ +async function realpathOfDeepestExistingAncestor(absolute: string): Promise { + const trailing: string[] = []; + let current = absolute; + + for (;;) { + try { + const resolved = await realpath(current); + return trailing.length === 0 ? resolved : join(resolved, ...trailing.reverse()); + } catch { + const parent = dirname(current); + // `dirname('/') === '/'`: nothing above the filesystem root exists to try. + if (parent === current) return absolute; + trailing.push(basename(current)); + current = parent; + } + } +} + +/** + * **Stage 2 — confined realpath.** Only for a path that survived stage 1. + * + * Resolves the relative path against the **verified checkout root** (the same root + * whose `git remote`/`HEAD` `repository/identity.ts` checked), fully resolving + * symlinks. A resolved target that escapes that root — including one reached only + * through an intermediate symlink — fails closed, and the file is never opened. + * + * A path that does not exist is **not** an escape and is not this stage's concern: + * absence is `manifest/digests.ts`'s `source-missing`. Conflating the two would + * report a missing file as a boundary violation. + */ +export async function validatePathConfined( + checkoutRoot: string, + path: string, +): Promise> { + const joined = resolve(checkoutRoot, path); + + // The root itself is resolved too. On macOS a checkout under `/var/...` really + // lives at `/private/var/...`; comparing a resolved candidate against an + // unresolved root would report every path as an escape, which would look like a + // working boundary check while actually checking nothing. + let resolvedRoot: string; + try { + resolvedRoot = await realpath(checkoutRoot); + } catch { + resolvedRoot = resolve(checkoutRoot); + } + + const resolved = await realpathOfDeepestExistingAncestor(joined); + + if (!isBeneath(resolvedRoot, resolved)) { + return rejected( + 'path-escapes-checkout-root', + 'incomplete-required-source', + `source path ${JSON.stringify(path)} resolves to ${JSON.stringify(resolved)}, which is not beneath the verified checkout root ${JSON.stringify(resolvedRoot)}`, + ); + } + + return accepted({ declared: path, resolved }); +} + +/** + * Both stages, in order, stopping at the first failure. + * + * The ordering is not a convenience: stage 2 touches the filesystem, and §4.1 + * requires the lexical rejection happen "before touching the filesystem". A path + * that fails stage 1 is never passed to `realpath`. + */ +export async function validateSourcePath( + checkoutRoot: string, + path: string, +): Promise> { + const lexical = validatePathLexically(path); + if (!lexical.ok) return lexical; + return validatePathConfined(checkoutRoot, lexical.value); +} diff --git a/packages/adapters/catalog-backstage/src/manifest/schema.ts b/packages/adapters/catalog-backstage/src/manifest/schema.ts new file mode 100644 index 00000000..be2329bf --- /dev/null +++ b/packages/adapters/catalog-backstage/src/manifest/schema.ts @@ -0,0 +1,337 @@ +/** + * T038 · T039 — the **closed** input-manifest schema. + * + * `input-manifest.md` §1 fixes the manifest shape and adds one rule that a + * conventional schema validator would get backwards: *an unrecognized top-level + * field is a rejection, not an ignored extra.* Most validators default to stripping + * or passing through unknown keys; that default is exactly what this contract + * forbids, because a forward-compatible passthrough field is an unreviewed input + * reaching a generator that promises its input surface is closed. + * + * **Single-repository binding (T039, FR-007).** `repository` is one object. A + * manifest naming more than one — a `repository` array, or a second top-level + * `repositories` key — is rejected. ADR-0012's single-repository boundary is not + * a convention this schema documents; it is a shape this schema cannot express. + * + * **Which trigger class the closed-schema rule maps to.** `input-manifest.md` §1 + * calls an unrecognized top-level field an *"unsupported manifest version"-class* + * rejection. Spike 009's own `atomic-fail-closed.md` §4 then states the opposite + * more specifically, defining `invalid-manifest-shape` as covering a manifest that + * "contains an unrecognized top-level field (`contracts/input-manifest.md` §1's + * closed-schema rule)" and distinguishing it from `unsupported-manifest-version`, + * "which presumes the manifest parsed correctly and has the right shape but + * declares an unsupported *value*". This module follows `atomic-fail-closed.md` §4, + * the later and more specific of the two, and the discrepancy is reported rather + * than silently resolved. + * + * Everything here is pure: it takes text or an already-parsed value and returns a + * verdict. It opens no file and runs no subprocess. + * + * @see `specs/009-catalog-binding-viability/contracts/input-manifest.md` §1 + * @see `specs/010-catalog-backstage/data-model.md` §1 + */ + +import { type Validated, accepted, rejected } from '../diagnostics.ts'; + +/** `data-model.md` §1 — the repository this manifest binds the operation to. */ +export interface ManifestRepository { + /** Normalized `github.com//`, lowercase. */ + readonly id: string; + /** 40 lowercase hex characters. */ + readonly revision: string; +} + +/** `data-model.md` §1 — one descriptor file the manifest names. */ +export interface ManifestSource { + /** Repo-relative POSIX. Validated separately, by `manifest/paths.ts` (T044). */ + readonly path: string; + readonly digestAlgorithm: 'sha256'; + /** 64 lowercase hex characters. */ + readonly digest: string; +} + +/** `data-model.md` §1 — the generator's sole declaration of what it may read. */ +export interface InputManifest { + readonly manifestSchemaVersion: string; + readonly requestedSnapshotSchemaVersion: string; + readonly requiredCapabilities: readonly string[]; + readonly repository: ManifestRepository; + readonly sources: readonly ManifestSource[]; +} + +/** + * Fine-grained schema rejection reasons. + * + * All map to the `invalid-manifest-shape` trigger class. They are kept distinct + * from one another because ADR-0016 records the *exact emitted string*, and a + * single `invalid-manifest-shape` for all seven conditions would make six of the + * seven negative cases indistinguishable from one another in the evidence tree. + */ +export type ManifestSchemaReason = + | 'manifest-not-json' + | 'manifest-not-an-object' + | 'unrecognized-top-level-field' + | 'missing-required-field' + | 'field-wrong-type' + | 'multiple-repositories' + | 'unrecognized-nested-field'; + +/** + * The five top-level fields `data-model.md` §1 defines, and the only five a + * manifest may carry. Order is the contract's own. + */ +export const MANIFEST_TOP_LEVEL_FIELDS = [ + 'manifestSchemaVersion', + 'requestedSnapshotSchemaVersion', + 'requiredCapabilities', + 'repository', + 'sources', +] as const; + +/** The only two fields a `repository` object may carry. */ +export const MANIFEST_REPOSITORY_FIELDS = ['id', 'revision'] as const; + +/** The only three fields a `sources[]` entry may carry. */ +export const MANIFEST_SOURCE_FIELDS = ['path', 'digestAlgorithm', 'digest'] as const; + +type SchemaResult = Validated; + +function isPlainObject(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +/** + * The closed-schema check, applied at one nesting level. + * + * Returns the *first* unrecognized key in the object's own enumeration order, so a + * manifest carrying two unknown fields reports one deterministically rather than + * whichever a `Set` iteration happened to surface first. + */ +function firstUnrecognizedKey( + object: Record, + allowed: readonly string[], +): string | undefined { + for (const key of Object.keys(object)) { + if (!allowed.includes(key)) return key; + } + return undefined; +} + +function describeType(value: unknown): string { + if (value === null) return 'null'; + if (Array.isArray(value)) return 'array'; + return typeof value; +} + +function requireString( + object: Record, + field: string, + where: string, +): SchemaResult { + if (!(field in object)) { + return rejected( + 'missing-required-field', + 'invalid-manifest-shape', + `${where}.${field} is required and is absent`, + ); + } + const value = object[field]; + if (typeof value !== 'string') { + return rejected( + 'field-wrong-type', + 'invalid-manifest-shape', + `${where}.${field} must be a string; observed ${describeType(value)}`, + ); + } + return accepted(value); +} + +function parseRepository(value: unknown): SchemaResult { + // T039 / FR-007. A sequence under `repository` is the shape a multi-repository + // manifest takes, so it is named rather than folded into `field-wrong-type`. + if (Array.isArray(value)) { + return rejected( + 'multiple-repositories', + 'invalid-manifest-shape', + `repository must name exactly one repository; observed an array of ${value.length}`, + ); + } + if (!isPlainObject(value)) { + return rejected( + 'field-wrong-type', + 'invalid-manifest-shape', + `repository must be an object; observed ${describeType(value)}`, + ); + } + const unknownKey = firstUnrecognizedKey(value, MANIFEST_REPOSITORY_FIELDS); + if (unknownKey !== undefined) { + return rejected( + 'unrecognized-nested-field', + 'invalid-manifest-shape', + `repository.${unknownKey} is not a recognized field`, + ); + } + const id = requireString(value, 'id', 'repository'); + if (!id.ok) return id; + const revision = requireString(value, 'revision', 'repository'); + if (!revision.ok) return revision; + return accepted({ id: id.value, revision: revision.value }); +} + +function parseSource(value: unknown, index: number): SchemaResult { + const where = `sources[${index}]`; + if (!isPlainObject(value)) { + return rejected( + 'field-wrong-type', + 'invalid-manifest-shape', + `${where} must be an object; observed ${describeType(value)}`, + ); + } + const unknownKey = firstUnrecognizedKey(value, MANIFEST_SOURCE_FIELDS); + if (unknownKey !== undefined) { + return rejected( + 'unrecognized-nested-field', + 'invalid-manifest-shape', + `${where}.${unknownKey} is not a recognized field`, + ); + } + const path = requireString(value, 'path', where); + if (!path.ok) return path; + const digestAlgorithm = requireString(value, 'digestAlgorithm', where); + if (!digestAlgorithm.ok) return digestAlgorithm; + if (digestAlgorithm.value !== 'sha256') { + return rejected( + 'field-wrong-type', + 'invalid-manifest-shape', + `${where}.digestAlgorithm must be "sha256"; observed ${JSON.stringify(digestAlgorithm.value)}`, + ); + } + const digest = requireString(value, 'digest', where); + if (!digest.ok) return digest; + return accepted({ + path: path.value, + digestAlgorithm: 'sha256', + digest: digest.value, + }); +} + +/** + * Validate an already-decoded JSON value against the closed schema. + * + * Separate from {@link parseManifestText} so a caller holding a value rather than + * text — a test, most of all — does not have to round-trip through JSON to reach + * the schema rules. + */ +export function validateManifestShape(value: unknown): SchemaResult { + if (!isPlainObject(value)) { + return rejected( + 'manifest-not-an-object', + 'invalid-manifest-shape', + `the manifest must be a JSON object; observed ${describeType(value)}`, + ); + } + + // The closed-schema rule runs first. An unrecognized field is a rejection even + // when every recognized field is present and well-typed — that is the whole + // content of "closed". + const unknownKey = firstUnrecognizedKey(value, MANIFEST_TOP_LEVEL_FIELDS); + if (unknownKey !== undefined) { + return rejected( + 'unrecognized-top-level-field', + 'invalid-manifest-shape', + `${unknownKey} is not a recognized top-level manifest field`, + ); + } + + const manifestSchemaVersion = requireString(value, 'manifestSchemaVersion', 'manifest'); + if (!manifestSchemaVersion.ok) return manifestSchemaVersion; + + const requestedSnapshotSchemaVersion = requireString( + value, + 'requestedSnapshotSchemaVersion', + 'manifest', + ); + if (!requestedSnapshotSchemaVersion.ok) return requestedSnapshotSchemaVersion; + + if (!('requiredCapabilities' in value)) { + return rejected( + 'missing-required-field', + 'invalid-manifest-shape', + 'manifest.requiredCapabilities is required and is absent', + ); + } + const rawCapabilities = value['requiredCapabilities']; + if (!Array.isArray(rawCapabilities)) { + return rejected( + 'field-wrong-type', + 'invalid-manifest-shape', + `manifest.requiredCapabilities must be an array; observed ${describeType(rawCapabilities)}`, + ); + } + const capabilities: string[] = []; + for (const [index, capability] of rawCapabilities.entries()) { + if (typeof capability !== 'string') { + return rejected( + 'field-wrong-type', + 'invalid-manifest-shape', + `manifest.requiredCapabilities[${index}] must be a string; observed ${describeType(capability)}`, + ); + } + capabilities.push(capability); + } + + if (!('repository' in value)) { + return rejected( + 'missing-required-field', + 'invalid-manifest-shape', + 'manifest.repository is required and is absent', + ); + } + const repository = parseRepository(value['repository']); + if (!repository.ok) return repository; + + if (!('sources' in value)) { + return rejected( + 'missing-required-field', + 'invalid-manifest-shape', + 'manifest.sources is required and is absent', + ); + } + const rawSources = value['sources']; + if (!Array.isArray(rawSources)) { + return rejected( + 'field-wrong-type', + 'invalid-manifest-shape', + `manifest.sources must be an array; observed ${describeType(rawSources)}`, + ); + } + const sources: ManifestSource[] = []; + for (const [index, rawSource] of rawSources.entries()) { + const source = parseSource(rawSource, index); + if (!source.ok) return source; + sources.push(source.value); + } + + return accepted({ + manifestSchemaVersion: manifestSchemaVersion.value, + requestedSnapshotSchemaVersion: requestedSnapshotSchemaVersion.value, + requiredCapabilities: capabilities, + repository: repository.value, + sources, + }); +} + +/** Decode manifest JSON text, then apply {@link validateManifestShape}. */ +export function parseManifestText(text: string): SchemaResult { + let decoded: unknown; + try { + decoded = JSON.parse(text) as unknown; + } catch (error) { + return rejected( + 'manifest-not-json', + 'invalid-manifest-shape', + `the manifest is not valid JSON: ${(error as Error).message}`, + ); + } + return validateManifestShape(decoded); +} diff --git a/packages/adapters/catalog-backstage/src/manifest/version.ts b/packages/adapters/catalog-backstage/src/manifest/version.ts new file mode 100644 index 00000000..8f9b6545 --- /dev/null +++ b/packages/adapters/catalog-backstage/src/manifest/version.ts @@ -0,0 +1,97 @@ +/** + * T040 — the three manifest-level version and capability rejections. + * + * `input-manifest.md` §2 fixes exactly three, and fixes what each one is *about*: + * all three are properties of the **manifest / generation request as a whole**, + * never of an individual entity within a batch. Each aborts generation, non-zero, + * **before any entity's paths are derived**. Together with `incomplete-required-source` + * (`manifest/digests.ts`, T043) they are the four manifest-request-level rejections + * `atomic-fail-closed.md` §5 enumerates. + * + * | Field | Only accepted value | Trigger class | + * |---|---|---| + * | `manifestSchemaVersion` | exactly `"1"` | `unsupported-manifest-version` | + * | `requestedSnapshotSchemaVersion` | exactly `"1"` | `unsupported-snapshot-version` | + * | `requiredCapabilities` | no member other than `"pathOwnership"` | `unsupported-capability` | + * + * **The capability rule is a rule about strings that are present, not about + * cardinality.** `input-manifest.md` §2 defines the trigger parenthetically and + * precisely: it is *"triggered by any string other than `"pathOwnership"` appearing + * in the array"*. An array containing no offending string therefore does not + * trigger it — this module implements that rule as written rather than inventing a + * stricter arity check the contract does not authorize. `data-model.md` §1 types + * the field as the one-element tuple `readonly ["pathOwnership"]`, which is a + * narrower shape than §2's rejection rule; the divergence is reported rather than + * resolved here, because inventing a rejection is as much a contract violation as + * omitting one. + * + * Pure. Takes a manifest that has already cleared `manifest/schema.ts`. + * + * @see `specs/009-catalog-binding-viability/contracts/input-manifest.md` §2 + * @see `specs/009-catalog-binding-viability/contracts/atomic-fail-closed.md` §5 + */ + +import { type Validated, accepted, rejected } from '../diagnostics.ts'; +import type { InputManifest } from './schema.ts'; + +/** The only accepted `manifestSchemaVersion`. */ +export const SUPPORTED_MANIFEST_SCHEMA_VERSION = '1'; + +/** The only accepted `requestedSnapshotSchemaVersion`. */ +export const SUPPORTED_SNAPSHOT_SCHEMA_VERSION = '1'; + +/** The only defined capability. */ +export const SUPPORTED_CAPABILITY = 'pathOwnership'; + +/** + * The three fine-grained reasons. + * + * Deliberately spelled identically to the trigger classes they map onto: unlike + * the schema reasons, these three are 1:1 with their trigger class, so a second + * distinct spelling would be a synonym rather than information. + */ +export type ManifestVersionReason = + | 'unsupported-manifest-version' + | 'unsupported-snapshot-version' + | 'unsupported-capability'; + +/** + * Apply the three rejections, in the order `input-manifest.md` §2's table lists + * them. + * + * The order is fixed so that a manifest violating two of the three always reports + * the same one — the same first-match-wins discipline `glob-dialect.md` §3 imposes + * on patterns, and for the same reason: a reported reason that depends on + * evaluation order is not reproducible evidence. + */ +export function checkManifestVersions( + manifest: InputManifest, +): Validated { + if (manifest.manifestSchemaVersion !== SUPPORTED_MANIFEST_SCHEMA_VERSION) { + return rejected( + 'unsupported-manifest-version', + 'unsupported-manifest-version', + `manifestSchemaVersion must be ${JSON.stringify(SUPPORTED_MANIFEST_SCHEMA_VERSION)}; observed ${JSON.stringify(manifest.manifestSchemaVersion)}`, + ); + } + + if (manifest.requestedSnapshotSchemaVersion !== SUPPORTED_SNAPSHOT_SCHEMA_VERSION) { + return rejected( + 'unsupported-snapshot-version', + 'unsupported-snapshot-version', + `requestedSnapshotSchemaVersion must be ${JSON.stringify(SUPPORTED_SNAPSHOT_SCHEMA_VERSION)}; observed ${JSON.stringify(manifest.requestedSnapshotSchemaVersion)}`, + ); + } + + for (const [index, capability] of manifest.requiredCapabilities.entries()) { + if (capability !== SUPPORTED_CAPABILITY) { + return rejected( + 'unsupported-capability', + 'unsupported-capability', + `requiredCapabilities[${index}] is ${JSON.stringify(capability)}; the only defined capability is ${JSON.stringify(SUPPORTED_CAPABILITY)}`, + ); + } + } + + return accepted(manifest); +} diff --git a/packages/adapters/catalog-backstage/src/ownership/annotation.ts b/packages/adapters/catalog-backstage/src/ownership/annotation.ts new file mode 100644 index 00000000..853276c3 --- /dev/null +++ b/packages/adapters/catalog-backstage/src/ownership/annotation.ts @@ -0,0 +1,230 @@ +/** + * T058 · T059 — the **five** ordered annotation decode steps, each with its own + * distinct rejection reason. + * + * `owned-paths-annotation.md` §1 fixes the order and forbids two shortcuts by name: + * it is processed "in exactly this order, never reversed and never short-circuited + * by a type coercion". + * + * | # | Step | Distinct rejection | + * |---|---|---| + * | 1 | Presence check, via an explicit discriminant | none — yields `annotation-absent` | + * | 2 | **String-scalar check on the raw node**, before any parse | `annotation-value-not-a-string` | + * | 3 | `JSON.parse` | `parse-error` | + * | 4 | Shape: exactly `array` | `wrong-shape` | + * | 5 | Per-pattern glob validation | the glob dialect's own reasons | + * + * # Step 2 is the one that is easy to omit and expensive to omit (T059, FR-027) + * + * §1 step 2, and §3's own restatement of it, are unusually emphatic, and the reason + * is a language-level fact rather than a style preference: ECMA-262 defines + * `JSON.parse(text)` as first coercing `text` to a string via `ToString`. So a + * non-string YAML node is **not** rejected by `JSON.parse` — the one-element + * sequence `["[]"]` is coerced to the string `"[]"`, parses cleanly as an empty + * array, and is then **misclassified as `explicit-empty`**. + * + * A misclassification is worse than a crash here: `explicit-empty` is a *legitimate* + * state meaning "this entity deliberately owns nothing", so the failure would be + * silent and would look like a considered decision by the descriptor's author. + * + * The TypeScript signature of `JSON.parse` provides **no** runtime protection: it + * declares a `string` parameter, and a value arriving from YAML is `unknown`. Only + * the explicit `typeof rawNode === 'string'` pre-parse check does. That check is + * {@link decodeAnnotation} step 2, and its permanent negative case is + * `test/annotation-step2-raw-node.test.ts`. + * + * @see `specs/009-catalog-binding-viability/contracts/owned-paths-annotation.md` §1, §3 + * @see `specs/010-catalog-backstage/data-model.md` §6 + */ + +import type { Rejection } from '../diagnostics.ts'; + +/** The annotation key. Ownership is derived from this key alone (FR-025). */ +export const OWNED_PATHS_ANNOTATION = 'adrkit.io/owned-paths'; + +/** `data-model.md` §6. */ +export type AnnotationRejectionReason = + | 'annotation-value-not-a-string' + | 'parse-error' + | 'wrong-shape'; + +/** `data-model.md` §6's diagnostic record, carrying each step's outcome. */ +export interface OwnedPathsAnnotation { + /** Explicit discriminant. Never inferred from a raw value being `undefined`. */ + readonly annotationPresent: boolean; + readonly rawNodeIsString: boolean | undefined; + readonly jsonParseOutcome: 'parsed' | 'parse-error' | 'not-a-string' | undefined; + readonly shapeOutcome: 'array-of-strings' | 'wrong-shape' | undefined; + readonly rejectionReason: AnnotationRejectionReason | undefined; +} + +/** What steps 1–4 produced, when they produced anything. */ +export interface DecodedAnnotation { + readonly diagnostics: OwnedPathsAnnotation; + /** `undefined` when the annotation was absent; otherwise the decoded array. */ + readonly patterns: readonly string[] | undefined; +} + +/** + * A decode outcome. + * + * The failure branch carries `diagnostics` as well as the rejection, because + * `data-model.md` §6's record is a *diagnostic* type: it exists to say which step + * reached which outcome, and discarding it on failure would discard exactly the + * case it was designed to describe. + */ +export type AnnotationDecodeResult = + | { readonly ok: true; readonly value: DecodedAnnotation } + | { + readonly ok: false; + readonly rejection: Rejection; + readonly diagnostics: OwnedPathsAnnotation; + }; + +/** The step number each rejection reason belongs to, for SC-006's per-step claim. */ +export const ANNOTATION_REJECTION_STEP: Record = { + 'annotation-value-not-a-string': 2, + 'parse-error': 3, + 'wrong-shape': 4, +}; + +/** + * The trigger class each annotation rejection maps to. + * + * `data-model.md` §8 carries `invalid-annotation-parse` and + * `invalid-annotation-shape` as separate classes. Step 2's failure is grouped with + * the parse class because it is a rejection *of the value handed to the parser* — + * and it is kept distinct at the `reason` level, which is what + * `owned-paths-annotation.md` §1 actually requires ("the distinct reason + * `annotation-value-not-a-string`", "**not** the same reason as step 2's non-string + * failure or step 4's shape failure"). + */ +const REASON_TRIGGER = { + 'annotation-value-not-a-string': 'invalid-annotation-parse', + 'parse-error': 'invalid-annotation-parse', + 'wrong-shape': 'invalid-annotation-shape', +} as const; + +const ABSENT: OwnedPathsAnnotation = { + annotationPresent: false, + rawNodeIsString: undefined, + jsonParseOutcome: undefined, + shapeOutcome: undefined, + rejectionReason: undefined, +}; + +function reject( + reason: AnnotationRejectionReason, + diagnostics: OwnedPathsAnnotation, + detail: string, +): AnnotationDecodeResult { + return { + ok: false, + rejection: { reason, triggerClass: REASON_TRIGGER[reason], detail }, + diagnostics, + }; +} + +/** + * Steps 1–4 of `owned-paths-annotation.md` §1. + * + * `present` is supplied by the caller as an explicit discriminant rather than + * derived here from `rawNode === undefined`: §1 step 1 requires exactly that, since + * a YAML key authored with no value is *present* and `null`, and inferring absence + * from `undefined` would silently reclassify it. + * + * Step 5 (per-pattern glob validation) is deliberately **not** performed here. + * `glob-dialect.md` owns it, and keeping it in `ownership/derive.ts` is what makes + * "only after steps 1–4 succeed does each string element proceed" observable rather + * than assumed. + */ +export function decodeAnnotation(present: boolean, rawNode: unknown): AnnotationDecodeResult { + // Step 1 — presence. + if (!present) return { ok: true, value: { diagnostics: ABSENT, patterns: undefined } }; + + // Step 2 — string-scalar check on the raw node, BEFORE any JSON.parse. + if (typeof rawNode !== 'string') { + return reject( + 'annotation-value-not-a-string', + { + annotationPresent: true, + rawNodeIsString: false, + jsonParseOutcome: 'not-a-string', + shapeOutcome: undefined, + rejectionReason: 'annotation-value-not-a-string', + }, + `${OWNED_PATHS_ANNOTATION} must be a YAML string scalar; observed ${describe(rawNode)}. ` + + 'It was not passed to JSON.parse, which would have coerced it via ToString.', + ); + } + + // Step 3 — JSON decode. Only a present string scalar reaches this line. + let decoded: unknown; + try { + decoded = JSON.parse(rawNode) as unknown; + } catch (error) { + return reject( + 'parse-error', + { + annotationPresent: true, + rawNodeIsString: true, + jsonParseOutcome: 'parse-error', + shapeOutcome: undefined, + rejectionReason: 'parse-error', + }, + `${OWNED_PATHS_ANNOTATION} is not valid JSON: ${(error as Error).message}`, + ); + } + + // Step 4 — shape. Exactly `array`; nothing is ever coerced. + const shapeFailure = describeShapeFailure(decoded); + if (shapeFailure !== undefined) { + return reject( + 'wrong-shape', + { + annotationPresent: true, + rawNodeIsString: true, + jsonParseOutcome: 'parsed', + shapeOutcome: 'wrong-shape', + rejectionReason: 'wrong-shape', + }, + `${OWNED_PATHS_ANNOTATION} must decode to an array of strings; ${shapeFailure}`, + ); + } + + return { + ok: true, + value: { + diagnostics: { + annotationPresent: true, + rawNodeIsString: true, + jsonParseOutcome: 'parsed', + shapeOutcome: 'array-of-strings', + rejectionReason: undefined, + }, + patterns: decoded as readonly string[], + }, + }; +} + +function describe(value: unknown): string { + if (value === null) return 'null'; + if (Array.isArray(value)) return `a YAML sequence (${JSON.stringify(value)})`; + if (typeof value === 'object') return 'a YAML mapping'; + return `a ${typeof value}`; +} + +/** `undefined` when the decoded value is exactly `array`. */ +function describeShapeFailure(decoded: unknown): string | undefined { + if (!Array.isArray(decoded)) { + if (decoded === null) return 'observed null'; + if (typeof decoded === 'object') return 'observed a JSON object'; + return `observed a bare ${typeof decoded}`; + } + for (const [index, element] of decoded.entries()) { + if (typeof element !== 'string') { + return `element ${index} is ${element === null ? 'null' : Array.isArray(element) ? 'a nested array' : `a ${typeof element}`}`; + } + } + return undefined; +} diff --git a/packages/adapters/catalog-backstage/src/ownership/derive.ts b/packages/adapters/catalog-backstage/src/ownership/derive.ts new file mode 100644 index 00000000..c884f1c6 --- /dev/null +++ b/packages/adapters/catalog-backstage/src/ownership/derive.ts @@ -0,0 +1,151 @@ +/** + * T057 — FR-025: ownership is derived from the `adrkit.io/owned-paths` annotation + * **alone**. + * + * ADR-0012 and `owned-paths-annotation.md` §1: no inference from the descriptor's + * own file location, its parent directory, the repository root, or any other signal. + * + * **The signature is the enforcement.** {@link deriveOwnership} takes a presence + * discriminant and a raw annotation node. It does not take a descriptor path, a + * directory, a repository root, or a filesystem — so there is nothing available to + * infer from even if someone wanted to. A comment saying "do not infer from the + * path" would be a request; a parameter list without a path is a guarantee. + * + * # Step 5 lives here + * + * `owned-paths-annotation.md` §1: "Only after steps 1–4 succeed does each string + * element proceed to `contracts/glob-dialect.md`'s validator." Keeping step 5 in + * this module — rather than inside `ownership/annotation.ts` — is what makes that + * ordering observable: `decodeAnnotation` has no access to the glob validator at + * all, so it *cannot* have validated a pattern early. + * + * # Warrant + * + * `admissibility.md` §7: an admissibility pass does not warrant "that any path + * derived from it is a path anyone actually owns". Neither does this module. What it + * returns is what the annotation declared, decoded and validated against a frozen + * dialect — not a claim about who owns anything. + * + * @see `specs/009-catalog-binding-viability/contracts/owned-paths-annotation.md` §1, §3 + * @see `specs/010-catalog-backstage/spec.md` FR-025 + */ + +import type { Rejection } from '../diagnostics.ts'; +import { type GlobCompiler, createGlobCompiler } from '../glob/dialect.ts'; +import { orderDerivedPaths } from '../glob/order.ts'; +import { type GlobOutcome, type RestrictedGlobPattern, validateGlobPatterns } from '../glob/validate.ts'; +import { + type AnnotationRejectionReason, + type OwnedPathsAnnotation, + decodeAnnotation, +} from './annotation.ts'; +import { type OwnershipState, classifyOwnershipState } from './states.ts'; + +/** A successful derivation. */ +export interface DerivedOwnership { + readonly ownershipState: OwnershipState; + /** Sorted with `compareCodeUnits` and deduplicated (FR-033). */ + readonly derivedPaths: readonly string[]; + readonly annotation: OwnedPathsAnnotation; + /** Every pattern's verdict, in declared order. Empty unless `explicit-paths`. */ + readonly patterns: readonly RestrictedGlobPattern[]; +} + +/** Reasons a derivation can fail: steps 2–4's, plus step 5's `invalid-pattern`. */ +export type OwnershipRejectionReason = AnnotationRejectionReason | 'invalid-pattern'; + +/** The outcome of deriving one entity's ownership. */ +export type OwnershipDerivation = + | { readonly ok: true; readonly value: DerivedOwnership } + | { + readonly ok: false; + readonly rejection: Rejection; + readonly annotation: OwnedPathsAnnotation; + /** Present only for a step-5 failure; the rejected pattern's verdict. */ + readonly pattern: RestrictedGlobPattern | undefined; + }; + +/** + * Derive one entity's owned paths from its annotation, and from nothing else. + * + * `present` and `rawNode` come from `descriptor/read.ts`'s `readAnnotationNode`, + * which supplies presence as an explicit discriminant — §1 step 1 requires that it + * never be inferred from a value being `undefined`. + * + * `compiler` is the per-run compiler. Passing the same one across every entity in a + * run is what gives FR-032 its "once per run" rather than "once per entity". + */ +export function deriveOwnership( + present: boolean, + rawNode: unknown, + compiler: GlobCompiler = createGlobCompiler(), +): OwnershipDerivation { + // Steps 1–4. + const decoded = decodeAnnotation(present, rawNode); + if (!decoded.ok) { + return { ok: false, rejection: decoded.rejection, annotation: decoded.diagnostics, pattern: undefined }; + } + + const ownershipState = classifyOwnershipState(decoded.value); + + // `annotation-absent` and `explicit-empty` both yield `[]`, and the two are kept + // apart by `ownershipState` alone — never by inspecting `derivedPaths`, which is + // identical for both (§3's non-conflation rule). + if (ownershipState !== 'explicit-paths') { + return { + ok: true, + value: { + ownershipState, + derivedPaths: [], + annotation: decoded.value.diagnostics, + patterns: [], + }, + }; + } + + // Step 5 — per-pattern glob validation. Reached only now. + const declared = decoded.value.patterns ?? []; + const patterns = validateGlobPatterns(declared, compiler); + const firstRejected = patterns.find((pattern) => pattern.outcome !== 'accepted'); + + if (firstRejected !== undefined) { + return { + ok: false, + rejection: { + reason: 'invalid-pattern', + triggerClass: 'invalid-pattern', + detail: `pattern ${JSON.stringify(firstRejected.raw)} rejected at rule ${firstRejected.rule} (${firstRejected.outcome})`, + }, + annotation: decoded.value.diagnostics, + pattern: firstRejected, + }; + } + + return { + ok: true, + value: { + ownershipState, + derivedPaths: orderDerivedPaths(declared), + annotation: decoded.value.diagnostics, + patterns, + }, + }; +} + +/** + * The rule-specific outcome for each declared pattern, evaluated in isolation. + * + * FR-031 requires that a mixed batch classify each pattern individually. Exposed + * separately from {@link deriveOwnership} because that function stops at the first + * rejection — which is the right *derivation* behaviour and the wrong *reporting* + * behaviour for demonstrating rule-specificity across a batch. + */ +export function classifyPatterns( + declared: readonly string[], + compiler: GlobCompiler = createGlobCompiler(), +): readonly { readonly raw: string; readonly outcome: GlobOutcome }[] { + return validateGlobPatterns(declared, compiler).map((pattern) => ({ + raw: pattern.raw, + outcome: pattern.outcome, + })); +} diff --git a/packages/adapters/catalog-backstage/src/ownership/states.ts b/packages/adapters/catalog-backstage/src/ownership/states.ts new file mode 100644 index 00000000..b1519496 --- /dev/null +++ b/packages/adapters/catalog-backstage/src/ownership/states.ts @@ -0,0 +1,79 @@ +/** + * T060 — the **three** ownership states, kept distinct and never conflated. + * + * `owned-paths-annotation.md` §3 and `data-model.md` §7.2 define exactly three; + * there is no fourth. + * + * | State | Condition | `derivedPaths` | + * |---|---|---| + * | `explicit-paths` | annotation present, decodes and validates, array **non-empty** | sorted, deduplicated, non-empty | + * | `explicit-empty` | annotation present, is a string scalar, and **decodes** to an array of length zero | `[]` | + * | `annotation-absent` | annotation key wholly absent (`annotationPresent === false`) | `[]` | + * + * # `explicit-empty` is decided on the **decoded** value + * + * §3 states this in bold and then explains it: it is "a decoded-value check, never a + * raw-string equality check — `'[]'`, `'[ ]'`, `'[\n]'`, and any other JSON text + * that decodes to `[]` all qualify identically; the classification happens strictly + * after JSON decoding, exactly as §1's decode-then-validate order requires, never + * before it." + * + * An implementation comparing the raw string to `'[]'` would classify `'[ ]'` as + * something else — most likely as `explicit-paths` with a nonsense pattern, or as a + * shape error — for a descriptor that is in fact perfectly well-formed. + * + * # The non-conflation rule + * + * `explicit-empty` and `annotation-absent` both yield `[]`. They MUST NOT be treated + * as equivalent anywhere in the envelope or in any evidence: "each entity's record + * carries the discriminator as its own explicit field (never inferring the + * distinction from `derivedPaths` alone, which is identical `[]` for both)". This is + * ADR-0012's own explicit instruction. + * + * # `["", ...]` is not `explicit-empty` + * + * §4: `["", "packages/**"]` and `[""]` are non-empty arrays whose empty-string + * element is rejected by the glob dialect's rule 1. That is a per-pattern validation + * failure — a distinct failure mode from the `[]`-versus-absent distinction, and §4 + * says it MUST NOT be conflated with it. + * + * @see `specs/009-catalog-binding-viability/contracts/owned-paths-annotation.md` §3, §4 + * @see `specs/010-catalog-backstage/data-model.md` §7.2 + */ + +import type { DecodedAnnotation } from './annotation.ts'; + +/** `data-model.md` §7.2. Exactly three values. */ +export type OwnershipState = 'explicit-paths' | 'explicit-empty' | 'annotation-absent'; + +/** The three, as data, so the count is assertable at runtime. */ +export const OWNERSHIP_STATES = [ + 'explicit-paths', + 'explicit-empty', + 'annotation-absent', +] as const satisfies readonly OwnershipState[]; + +/** + * Classify a successfully-decoded annotation into one of the three states. + * + * Takes the **decoded** annotation, never a raw string — the signature is what makes + * §3's "decoded-value check, never a raw-string equality check" structural. There is + * no parameter here that could carry `'[ ]'` for someone to compare against `'[]'`. + */ +export function classifyOwnershipState(decoded: DecodedAnnotation): OwnershipState { + if (!decoded.diagnostics.annotationPresent) return 'annotation-absent'; + return (decoded.patterns ?? []).length === 0 ? 'explicit-empty' : 'explicit-paths'; +} + +/** + * True when the two empty-`derivedPaths` states have been conflated. + * + * A helper for assertions rather than for production logic: the point of the + * non-conflation rule is that the distinction cannot be recovered from + * `derivedPaths`, so anything checking it must consult the discriminator. Having the + * check written once, here, is cheaper than reasoning about it at each call site. + */ +export function bothYieldEmptyDerivedPaths(a: OwnershipState, b: OwnershipState): boolean { + const empties: readonly OwnershipState[] = ['explicit-empty', 'annotation-absent']; + return empties.includes(a) && empties.includes(b); +} diff --git a/packages/adapters/catalog-backstage/src/repository/identity.ts b/packages/adapters/catalog-backstage/src/repository/identity.ts new file mode 100644 index 00000000..244af3b2 --- /dev/null +++ b/packages/adapters/catalog-backstage/src/repository/identity.ts @@ -0,0 +1,194 @@ +/** + * T041 · T042 — repository identity and revision, read through separate git + * tooling and compared by **exact string equality**. + * + * `input-manifest.md` §3 fixes two things that are easy to get subtly wrong: + * + * 1. **Where the observed values come from.** `git remote get-url origin` and + * `git rev-parse HEAD`, invoked as subprocesses against the checkout — *never* + * from a descriptor annotation, including `github.com/project-slug`, and never + * by re-reading the manifest under test. A check that reads its "observed" value + * from the same document it is checking is not a check. + * 2. **How they are compared.** Exact string equality on both values. A partial + * match — revision agrees, identity does not, or the reverse — is + * `repository-mismatch`, not a partial success. A prefix match on a revision + * (an abbreviated SHA against a full one) is a mismatch. + * + * **The fixture constraint is part of the contract, not a testing detail.** + * `input-manifest.md` §3.1 requires the mismatch fixture be a standalone scratch + * `git init` repository, never a `git worktree add` linked worktree, because a + * linked worktree shares its remote configuration with the repository it was + * created from and would report this repository's own `origin` no matter what the + * test intended. The checkout this package is developed in *is* such a worktree, so + * a test that skipped the constraint would pass for the wrong reason. + * + * **Bounded warrant.** This confirms the manifest agrees with the checkout's own + * **locally-configured** git state (`data-model.md` §2). It is not a + * network-verified provenance check and no artifact may describe it as one. + * + * @see `specs/009-catalog-binding-viability/contracts/input-manifest.md` §3, §3.1 + * @see `specs/009-catalog-binding-viability/research.md` R6 + * @see `specs/010-catalog-backstage/data-model.md` §2 + */ + +import { type Validated, accepted, rejected } from '../diagnostics.ts'; + +/** + * The sentinel `data-model.md` §2 assigns to a remote URL matching no recognized + * form. It is a value, not an error: an unrecognized remote is compared like any + * other observed identity and mismatches, rather than short-circuiting somewhere + * else with a different reason. + */ +export const INVALID_REPOSITORY_ID = 'invalid'; + +/** `data-model.md` §2. */ +export interface RepositoryIdentityCheck { + readonly manifestRepositoryId: string; + readonly manifestRevision: string; + /** Verbatim, from `git remote get-url origin`. */ + readonly observedRemoteRaw: string; + /** After {@link normalizeRepositoryId}; may be {@link INVALID_REPOSITORY_ID}. */ + readonly observedRepositoryId: string; + /** Verbatim, from `git rev-parse HEAD`. */ + readonly observedHead: string; + readonly outcome: 'match' | 'repository-mismatch'; +} + +/** The one fine-grained reason this module emits. */ +export type RepositoryIdentityReason = 'repository-mismatch'; + +/** + * `research.md` R6's normalization algorithm, applied identically to the + * manifest's declared `repository.id` and to the value read from the checkout, so + * the two are compared on equal footing. + * + * The steps are numbered as R6 numbers them. Step 6 runs *after* steps 2–5, so + * `.../repo.git/` has its trailing slash stripped first and then its `.git` + * suffix — never the reverse, which would leave a dangling `/`. + * + * Returns {@link INVALID_REPOSITORY_ID} for any input matching none of steps 3–5, + * or failing step 7's exactly-two-segments check. That is a fail-closed outcome, + * never a best-effort guess. + */ +export function normalizeRepositoryId(raw: string): string { + // 1. Strip trailing whitespace. + let value = raw.replace(/\s+$/u, ''); + + // 2. Strip one or more trailing `/`. + value = value.replace(/\/+$/u, ''); + + // 3-5. Rewrite each recognized form to bare `github.com/`. + const scp = /^git@github\.com:(.+)$/u.exec(value); + const url = /^(?:https?|ssh):\/\/(?:git@)?github\.com\/(.+)$/u.exec(value); + const bare = /^github\.com\/(.+)$/u.exec(value); + const rest = scp?.[1] ?? url?.[1] ?? bare?.[1]; + if (rest === undefined) return INVALID_REPOSITORY_ID; + + // 6. Strip an exact trailing `.git`. + const withoutGitSuffix = rest.endsWith('.git') ? rest.slice(0, -'.git'.length) : rest; + + // 7. Exactly two non-empty segments, each in GitHub's permitted character set. + if (!/^[A-Za-z0-9._-]+\/[A-Za-z0-9._-]+$/u.test(withoutGitSuffix)) { + return INVALID_REPOSITORY_ID; + } + + // 8. Plain ASCII case fold. + return `github.com/${withoutGitSuffix}`.toLowerCase(); +} + +/** The two values read from a checkout, and the raw remote they came from. */ +export interface ObservedRepositoryState { + readonly remoteRaw: string; + readonly head: string; +} + +/** + * Read `git remote get-url origin` and `git rev-parse HEAD` from `checkoutRoot`. + * + * This is the only function in Phase D that runs a subprocess, and it is separated + * from {@link compareRepositoryIdentity} precisely so the comparison stays pure and + * independently testable. `input-manifest.md` §5's input boundary permits exactly + * these two subprocess reads and no others. + * + * Throws if either command fails. A checkout that is not a git repository, or has + * no `origin`, is not a mismatch — it is an absent precondition, and reporting it + * as `repository-mismatch` would claim an observation that was never made. + */ +export async function readObservedRepositoryState( + checkoutRoot: string, +): Promise { + const remote = await runGit(['remote', 'get-url', 'origin'], checkoutRoot); + const head = await runGit(['rev-parse', 'HEAD'], checkoutRoot); + return { remoteRaw: remote, head }; +} + +async function runGit(args: readonly string[], cwd: string): Promise { + const proc = Bun.spawn(['git', ...args], { + cwd, + stdout: 'pipe', + stderr: 'pipe', + // `input-manifest.md` §5 and FR-018's offline constraint: the two identity + // reads are local git state, so the subprocess is denied any terminal prompt + // that could reach for a credential helper and therefore the network. + env: { ...Bun.env, GIT_TERMINAL_PROMPT: '0' }, + }); + 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 in ${cwd} (exit ${exitCode}): ${stderr.trim()}`); + } + return stdout.trim(); +} + +/** + * Compare the manifest's declared identity and revision against observed state. + * + * Pure. Both comparisons are `===` on strings; there is deliberately no prefix, + * case-insensitive, or normalized comparison anywhere in this function, because + * FR-010 requires exactly that absence. + * + * The manifest's declared id is normalized through the *same* R6 algorithm as the + * observed remote before comparison, per `input-manifest.md` §3's "applied + * identically to (a) the manifest's declared `repository.id` ... and (b) the value + * read from the checkout's actual `origin` ... so the two are compared on equal + * footing". + */ +export function compareRepositoryIdentity( + manifest: { readonly id: string; readonly revision: string }, + observed: ObservedRepositoryState, +): Validated { + const observedRepositoryId = normalizeRepositoryId(observed.remoteRaw); + const manifestRepositoryId = normalizeRepositoryId(manifest.id); + + const idMatches = manifestRepositoryId === observedRepositoryId; + const revisionMatches = manifest.revision === observed.head; + + const check: RepositoryIdentityCheck = { + manifestRepositoryId, + manifestRevision: manifest.revision, + observedRemoteRaw: observed.remoteRaw, + observedRepositoryId, + observedHead: observed.head, + outcome: idMatches && revisionMatches ? 'match' : 'repository-mismatch', + }; + + if (check.outcome === 'match') return accepted(check); + + // The detail names *which* half disagreed, because "partial match still aborts" + // is only demonstrable if the record can distinguish a partial from a total one. + const disagreements: string[] = []; + if (!idMatches) { + disagreements.push( + `repository id: manifest ${JSON.stringify(manifestRepositoryId)} !== observed ${JSON.stringify(observedRepositoryId)}`, + ); + } + if (!revisionMatches) { + disagreements.push( + `revision: manifest ${JSON.stringify(manifest.revision)} !== observed ${JSON.stringify(observed.head)}`, + ); + } + return rejected('repository-mismatch', 'repository-mismatch', disagreements.join('; ')); +} diff --git a/packages/adapters/catalog-backstage/test/admissibility-classify.test.ts b/packages/adapters/catalog-backstage/test/admissibility-classify.test.ts new file mode 100644 index 00000000..b9f17d7a --- /dev/null +++ b/packages/adapters/catalog-backstage/test/admissibility-classify.test.ts @@ -0,0 +1,150 @@ +/** + * T051 — FR-018: `inadmissible-descriptor` classification and its failure + * semantics. + * + * `admissibility.md` §5: a **fatal, whole-operation** trigger — the **fifteenth** + * member of this feature's enumeration. The entire run aborts; no envelope is + * written, including a partial one; no entity from the same run is emitted, + * including entities already determined admissible. + * + * "An inadmissible descriptor is **never skipped**, never downgraded to a warning, + * and never excluded-and-continued. 'Continue past the bad one' is the precise + * behaviour this contract forbids." + */ + +import { describe, expect, test } from 'bun:test'; +import { TRIGGER_CLASSES } from '../src/diagnostics.ts'; +import { + classifyAdmissibility, + inadmissibleRejection, +} from '../src/admissibility/classify.ts'; +import { collectAdmitted } from '../src/admissibility/index.ts'; +import { + ADMISSIBLE, + INADMISSIBLE_AND_COLLIDING, + INADMISSIBLE_AND_UNIQUE_BULK_IMPORT, + descriptor, +} from './descriptor-fixtures.ts'; + +describe('T051 — the enumeration this feature uses has fifteen members', () => { + test('fifteen, not fourteen', () => { + // `admissibility.md` §5.1: fourteen is spike 009's count and "remains correct + // *as a statement about spike 009*"; writing it as this feature's count is an + // error against FR-035. + expect(TRIGGER_CLASSES).toHaveLength(15); + expect(new Set(TRIGGER_CLASSES).size).toBe(15); + }); + + test('`inadmissible-descriptor` is a member, and `other-invalid-input` remains the backstop', () => { + expect(TRIGGER_CLASSES).toContain('inadmissible-descriptor'); + expect(TRIGGER_CLASSES.at(-1)).toBe('other-invalid-input'); + }); + + test('removing `inadmissible-descriptor` would leave spike 009\u2019s fourteen', () => { + expect(TRIGGER_CLASSES.filter((c) => c !== 'inadmissible-descriptor')).toHaveLength(14); + }); +}); + +describe('T051 — classification', () => { + test('an admissible descriptor classifies with no failed fields', () => { + const result = classifyAdmissibility(descriptor(ADMISSIBLE)); + expect(result.admissible).toBe(true); + expect(result.failedFields).toEqual([]); + expect(result.attributions).toEqual([]); + }); + + test('`failedFields` is empty iff admissible — no partial admissibility (\u00a72)', () => { + for (const spec of [ADMISSIBLE, INADMISSIBLE_AND_COLLIDING]) { + const result = classifyAdmissibility(descriptor(spec)); + expect(result.admissible).toBe(result.failedFields.length === 0); + expect(result.admissible).toBe(result.attributions.length === 0); + } + }); + + test('an inadmissible descriptor raises the fatal trigger', () => { + const result = classifyAdmissibility(descriptor(INADMISSIBLE_AND_UNIQUE_BULK_IMPORT)); + expect(result.admissible).toBe(false); + const rejection = inadmissibleRejection(result); + expect(rejection.reason).toBe('inadmissible-descriptor'); + expect(rejection.triggerClass).toBe('inadmissible-descriptor'); + }); + + test('building a rejection for an admissible result throws rather than fabricating one', () => { + const result = classifyAdmissibility(descriptor(ADMISSIBLE)); + expect(() => inadmissibleRejection(result)).toThrow(/fabricate a determination/u); + }); + + test('an omitted namespace is admissible; the validator is not invoked', () => { + // ADR-0015's Decision: "An omitted `metadata.namespace` is admissible; §1's + // `default` substitution applies afterwards, unchanged." + const result = classifyAdmissibility(descriptor(ADMISSIBLE)); + expect(result.admissible).toBe(true); + expect(result.failedFields).not.toContain('metadata.namespace'); + }); + + test('a present-but-invalid namespace is inadmissible', () => { + // "if and only if present" — present-and-empty is present. + const result = classifyAdmissibility(descriptor({ ...ADMISSIBLE, namespace: 'Default' })); + expect(result.admissible).toBe(false); + expect(result.failedFields).toEqual(['metadata.namespace']); + }); + + test('the namespace is validated as authored, never after defaulting', () => { + // If the substitution ran first, an omitted namespace would be validated as + // the literal `default` — which happens to pass, hiding the difference. The + // observable consequence is that no `validateNamespace` attribution exists at + // all for an omitted namespace. + const result = classifyAdmissibility(descriptor(ADMISSIBLE)); + expect(result.attributions.map((a) => a.validator)).not.toContain('validateNamespace'); + }); +}); + +describe('T051 — failure semantics: never skipped, never partial', () => { + test('one inadmissible descriptor among five valid ones rejects the whole batch', () => { + const batch = [ + descriptor({ ...ADMISSIBLE, name: 'a' }), + descriptor({ ...ADMISSIBLE, name: 'b' }), + descriptor(INADMISSIBLE_AND_COLLIDING), + descriptor({ ...ADMISSIBLE, name: 'c' }), + descriptor({ ...ADMISSIBLE, name: 'd' }), + descriptor({ ...ADMISSIBLE, name: 'e' }), + ]; + + const admission = collectAdmitted(batch); + expect(admission.ok).toBe(false); + if (admission.ok) return; + expect(admission.rejection.triggerClass).toBe('inadmissible-descriptor'); + }); + + test('the rejected batch yields no admitted descriptors — not even the valid five', () => { + const admission = collectAdmitted([ + descriptor({ ...ADMISSIBLE, name: 'a' }), + descriptor(INADMISSIBLE_AND_COLLIDING), + ]); + expect(admission.ok).toBe(false); + expect(admission).not.toHaveProperty('admitted'); + expect(JSON.stringify(admission)).not.toContain('component:default/a'); + }); + + test('there is no API that filters inadmissible descriptors out and continues', async () => { + // The single most likely implementation mistake `atomic-fail-closed.md` §1 + // exists to foreclose. A `.filter(...)` over admissibility anywhere in the + // module would be exactly it. + const source = await Bun.file( + new URL('../src/admissibility/index.ts', import.meta.url), + ).text(); + const code = source.replace(/\/\*\*[\s\S]*?\*\//gu, '').replace(/\/\/[^\n]*/gu, ''); + expect(code).not.toContain('.filter('); + expect(code).not.toContain('continue'); + }); + + test('a batch with no inadmissible member is admitted whole', () => { + const admission = collectAdmitted([ + descriptor({ ...ADMISSIBLE, name: 'a' }), + descriptor({ ...ADMISSIBLE, name: 'b' }), + ]); + expect(admission.ok).toBe(true); + if (!admission.ok) return; + expect(admission.admitted).toHaveLength(2); + }); +}); diff --git a/packages/adapters/catalog-backstage/test/admissibility-excluded-from-uniqueness.test.ts b/packages/adapters/catalog-backstage/test/admissibility-excluded-from-uniqueness.test.ts new file mode 100644 index 00000000..8cec2d72 --- /dev/null +++ b/packages/adapters/catalog-backstage/test/admissibility-excluded-from-uniqueness.test.ts @@ -0,0 +1,162 @@ +/** + * T053 — FR-019: **no inadmissible descriptor participates in a uniqueness + * comparison.** Duplicate detection is not a validity test and must never be + * reached by an inadmissible input. + * + * `admissibility.md` §4.1: an inadmissible descriptor "never acquires a canonical + * id. It therefore can never participate in a `duplicate-canonical-id` + * determination, in either direction: it cannot be the first member of a collision + * and it cannot be the second." + * + * **The uniqueness comparison used below is deliberately test-local.** The + * production one is `src/identity/uniqueness.ts`, which `tasks.md` assigns to + * Phase E, behind Barrier B; creating it here would be starting Phase E early. What + * is demonstrated instead is the property that makes Phase E's version safe by + * construction: a comparison can only be handed canonical identities, canonical + * identities exist only for admitted descriptors, and a batch containing an + * inadmissible member never produces a set of them at all. + * + * The probe below is written to be *maximally naive* — it is exactly the + * first-wins/last-wins-free duplicate scan an implementer would write — so that if + * an inadmissible descriptor could reach it, it would. + */ + +import { describe, expect, test } from 'bun:test'; +import { type AdmittedDescriptor, admit, collectAdmitted } from '../src/admissibility/index.ts'; +import { canonicalize } from '../src/identity/canonicalize.ts'; +import { + ADMISSIBLE, + INADMISSIBLE_AND_COLLIDING, + INADMISSIBLE_AND_UNIQUE_BULK_IMPORT, + descriptor, +} from './descriptor-fixtures.ts'; + +/** + * A test-local duplicate scan. + * + * Note the parameter type: `AdmittedDescriptor[]`. There is no signature here that + * accepts a raw descriptor, which is the whole point — an inadmissible descriptor + * has no way in. + */ +function duplicateCanonicalIds(admitted: readonly AdmittedDescriptor[]): readonly string[] { + const seen = new Set(); + const duplicates: string[] = []; + for (const one of admitted) { + const { canonicalId } = canonicalize(one); + if (seen.has(canonicalId)) duplicates.push(canonicalId); + seen.add(canonicalId); + } + return duplicates; +} + +describe('T053 — the comparison is only reachable with admitted descriptors', () => { + test('two admissible descriptors colliding are reported as a collision', () => { + // The comparison works. Without this, "no inadmissible descriptor reaches it" + // would be satisfiable by a comparison that reports nothing at all. + const admission = collectAdmitted([ + descriptor({ ...ADMISSIBLE, name: 'Payments', namespace: 'default' }), + descriptor({ ...ADMISSIBLE, name: 'payments' }), + ]); + expect(admission.ok).toBe(true); + if (!admission.ok) return; + expect(duplicateCanonicalIds(admission.admitted)).toEqual(['component:default/payments']); + }); + + test('two admissible descriptors that differ are not a collision', () => { + const admission = collectAdmitted([ + descriptor({ ...ADMISSIBLE, name: 'payments' }), + descriptor({ ...ADMISSIBLE, name: 'billing' }), + ]); + expect(admission.ok).toBe(true); + if (!admission.ok) return; + expect(duplicateCanonicalIds(admission.admitted)).toEqual([]); + }); +}); + +describe('T053 — an inadmissible descriptor never reaches the comparison', () => { + test('the fourteen-sharing placeholder form would collide, and never gets the chance', () => { + // Two descriptors carrying `${{ values.name | dump }}` canonicalize identically + // — that is exactly how ADR-0015 describes the fourteen. If admissibility ran + // second, this batch would report `duplicate-canonical-id`. + const admission = collectAdmitted([ + descriptor(INADMISSIBLE_AND_COLLIDING, 'a/catalog-info.yaml'), + descriptor(INADMISSIBLE_AND_COLLIDING, 'b/catalog-info.yaml'), + ]); + + expect(admission.ok).toBe(false); + if (admission.ok) return; + expect(admission.rejection.triggerClass).toBe('inadmissible-descriptor'); + expect(admission.rejection.triggerClass).not.toBe('duplicate-canonical-id'); + }); + + test('the batch yields no admitted set, so there is nothing to compare', () => { + const admission = collectAdmitted([ + descriptor(INADMISSIBLE_AND_COLLIDING, 'a/catalog-info.yaml'), + descriptor(INADMISSIBLE_AND_COLLIDING, 'b/catalog-info.yaml'), + ]); + expect(admission.ok).toBe(false); + expect(admission).not.toHaveProperty('admitted'); + }); + + test('it cannot be the first member of a collision', () => { + const admission = collectAdmitted([ + descriptor(INADMISSIBLE_AND_COLLIDING, 'first.yaml'), + descriptor({ ...ADMISSIBLE, name: 'payments' }), + ]); + expect(admission.ok).toBe(false); + if (admission.ok) return; + expect(admission.rejection.triggerClass).toBe('inadmissible-descriptor'); + }); + + test('it cannot be the second member of a collision either', () => { + // §4.1: "in either direction". An implementation that checked admissibility + // lazily, only for the descriptor it was about to canonicalize, would pass the + // previous test and fail this one. + const admission = collectAdmitted([ + descriptor({ ...ADMISSIBLE, name: 'payments' }), + descriptor(INADMISSIBLE_AND_COLLIDING, 'second.yaml'), + ]); + expect(admission.ok).toBe(false); + if (admission.ok) return; + expect(admission.rejection.triggerClass).toBe('inadmissible-descriptor'); + }); + + test('a single inadmissible descriptor produces no canonical id to compare', () => { + const outcome = admit(descriptor(INADMISSIBLE_AND_UNIQUE_BULK_IMPORT)); + expect(outcome.admissible).toBe(false); + expect(outcome).not.toHaveProperty('admitted'); + expect(JSON.stringify(outcome)).not.toContain('canonicalId'); + }); +}); + +describe('T053 — duplicate detection is a statement about a pair, not about one descriptor', () => { + test('the same descriptor is admissible alone and colliding only in company', () => { + // `admissibility.md` §6 / FR-021: "canonical-id collision is a statement about a + // *pair* of descriptors. It is not a property of either one alone, and it is + // not an admissibility failure." + const one = descriptor({ ...ADMISSIBLE, name: 'payments' }); + + const alone = collectAdmitted([one]); + expect(alone.ok).toBe(true); + if (!alone.ok) return; + expect(duplicateCanonicalIds(alone.admitted)).toEqual([]); + + const paired = collectAdmitted([one, descriptor({ ...ADMISSIBLE, name: 'payments' })]); + expect(paired.ok).toBe(true); + if (!paired.ok) return; + expect(duplicateCanonicalIds(paired.admitted)).toEqual(['component:default/payments']); + }); + + test('admissibility is unchanged by what else is in the batch', () => { + // The converse: admissibility is a property of one descriptor alone. If it + // were not, the two determinations would be entangled in the other direction. + const one = descriptor(INADMISSIBLE_AND_UNIQUE_BULK_IMPORT); + const alone = admit(one); + const inCompany = collectAdmitted([descriptor({ ...ADMISSIBLE, name: 'payments' }), one]); + + expect(alone.admissible).toBe(false); + expect(inCompany.ok).toBe(false); + if (alone.admissible || inCompany.ok) return; + expect(alone.rejection.detail).toBe(inCompany.rejection.detail); + }); +}); diff --git a/packages/adapters/catalog-backstage/test/admissibility-ordering.test.ts b/packages/adapters/catalog-backstage/test/admissibility-ordering.test.ts new file mode 100644 index 00000000..a283637b --- /dev/null +++ b/packages/adapters/catalog-backstage/test/admissibility-ordering.test.ts @@ -0,0 +1,170 @@ +/** + * T048 — FR-015: admissibility is evaluated **before** canonicalization, + * structurally rather than by convention. + * + * `admissibility.md` §4.1 gives the reason the ordering must be structural: reversing + * it "would make some descriptors collide *before* being found inadmissible, and the + * trigger class reported for the run would then depend on document order within the + * manifest. Order-dependence of the reported trigger is exactly the failure mode + * ADR-0015's ordering rule exists to prevent." + * + * A test that merely called the two functions in the right order would demonstrate + * the *test's* ordering, not the module's. What is asserted here instead is that + * the wrong order is **unavailable**: the type `canonicalize` consumes can only be + * produced by an admissibility pass, and the failure branch carries no such value. + */ + +import { describe, expect, test } from 'bun:test'; +import * as admissibilityModule from '../src/admissibility/index.ts'; +import { admit, collectAdmitted } from '../src/admissibility/index.ts'; +import { canonicalize } from '../src/identity/canonicalize.ts'; +import { readDescriptorDocuments } from '../src/descriptor/read.ts'; +import { + ADMISSIBLE, + INADMISSIBLE_AND_COLLIDING, + INADMISSIBLE_AND_UNIQUE_BULK_IMPORT, + descriptor, +} from './descriptor-fixtures.ts'; +import { ADAPTER_ROOT, importSpecifiers, scanned } from './source-scan.ts'; + +describe('T048 — the failure branch carries nothing canonicalizable', () => { + test('an admissible descriptor yields an admitted value', () => { + const outcome = admit(descriptor(ADMISSIBLE)); + expect(outcome.admissible).toBe(true); + if (!outcome.admissible) return; + expect(canonicalize(outcome.admitted).canonicalId).toBe('component:default/payments'); + }); + + test('an inadmissible descriptor yields no admitted value at all', () => { + const outcome = admit(descriptor(INADMISSIBLE_AND_UNIQUE_BULK_IMPORT)); + expect(outcome.admissible).toBe(false); + expect(outcome).not.toHaveProperty('admitted'); + }); + + test('the inadmissible outcome carries no canonical identity of any kind', () => { + // §4.1: an inadmissible descriptor "never acquires a canonical id". + const outcome = admit(descriptor(INADMISSIBLE_AND_COLLIDING)); + expect(outcome.admissible).toBe(false); + const serialized = JSON.stringify(outcome); + expect(serialized).not.toContain('canonicalId'); + expect(serialized).not.toContain('allRefs'); + expect(serialized.toLowerCase()).not.toContain('component:default'); + }); +}); + +describe('T048 — canonicalization is unreachable without an admission', () => { + test('`canonicalize` takes one parameter, and it is the branded admitted type', () => { + expect(canonicalize.length).toBe(1); + }); + + test('a hand-built object shaped like an admitted descriptor is rejected at compile time', async () => { + // The brand is a module-private `unique symbol`, so this cannot be expressed + // in TypeScript at all. Asserting that in a runtime test would require an + // `as never` cast, which would test the cast rather than the type. What is + // asserted instead is the property that makes the guarantee real in both + // directions: the brand is a real runtime symbol, and it is never exported. + const source = await Bun.file( + new URL('../src/admissibility/index.ts', import.meta.url), + ).text(); + expect(source).toContain('const ADMITTED: unique symbol = Symbol('); + expect(source).not.toContain('export const ADMITTED'); + expect(source).not.toContain('export { ADMITTED'); + }); + + test('the brand is not reachable from the module\u2019s exports', () => { + // Even a cast cannot forge an admission without the symbol, and the symbol is + // not in the module namespace. `admissibilityModule` is a static namespace + // import: ADR-0013/FR-002 forbid a dynamic `import()` anywhere in this package. + const namespace = admissibilityModule as unknown as Record; + expect(Object.values(namespace).filter((value) => typeof value === 'symbol')).toEqual([]); + expect(Object.keys(namespace)).not.toContain('ADMITTED'); + }); + + test('the canonicalization module imports from admissibility, never the reverse', () => { + // If admissibility imported canonicalization, the ordering would be a matter of + // which function happened to be called first. + const files = scanned(ADAPTER_ROOT); + const admissibilityModules = files.filter((file) => + file.path.includes('/src/admissibility/'), + ); + const identityModules = files.filter((file) => file.path.includes('/src/identity/')); + + expect(admissibilityModules.length).toBeGreaterThan(0); + expect(identityModules.length).toBeGreaterThan(0); + + for (const file of admissibilityModules) { + for (const specifier of importSpecifiers(file.code)) { + expect(specifier).not.toContain('identity/'); + } + } + + const identityImports = identityModules.flatMap((file) => importSpecifiers(file.code)); + expect(identityImports.some((specifier) => specifier.includes('admissibility/'))).toBe(true); + }); +}); + +describe('T048 — the ordering holds over a batch, in either document order', () => { + const inadmissibleFirst = readDescriptorDocuments( + 'batch.yaml', + [ + 'apiVersion: backstage.io/v1alpha1', + 'kind: Component', + 'metadata:', + ' name: "${{ values.name | dump }}"', + '---', + 'apiVersion: backstage.io/v1alpha1', + 'kind: Component', + 'metadata:', + ' name: payments', + '', + ].join('\n'), + ); + + const inadmissibleLast = readDescriptorDocuments( + 'batch.yaml', + [ + 'apiVersion: backstage.io/v1alpha1', + 'kind: Component', + 'metadata:', + ' name: payments', + '---', + 'apiVersion: backstage.io/v1alpha1', + 'kind: Component', + 'metadata:', + ' name: "${{ values.name | dump }}"', + '', + ].join('\n'), + ); + + test('both orderings report the same trigger class', () => { + // This is §4.1's actual requirement: the reported trigger must not depend on + // document order within the manifest. + const first = collectAdmitted(inadmissibleFirst); + const last = collectAdmitted(inadmissibleLast); + + expect(first.ok).toBe(false); + expect(last.ok).toBe(false); + if (first.ok || last.ok) return; + + expect(first.rejection.triggerClass).toBe('inadmissible-descriptor'); + expect(last.rejection.triggerClass).toBe('inadmissible-descriptor'); + expect(first.rejection.reason).toBe(last.rejection.reason); + }); + + test('neither ordering emits an admitted descriptor, including the valid one', () => { + // §5: "no entity from the same run is emitted, including entities already + // determined admissible". + for (const batch of [inadmissibleFirst, inadmissibleLast]) { + const admission = collectAdmitted(batch); + expect(admission.ok).toBe(false); + expect(admission).not.toHaveProperty('admitted'); + } + }); + + test('a wholly admissible batch is admitted in full', () => { + const admission = collectAdmitted([descriptor(ADMISSIBLE), descriptor({ ...ADMISSIBLE, name: 'billing' })]); + expect(admission.ok).toBe(true); + if (!admission.ok) return; + expect(admission.admitted).toHaveLength(2); + }); +}); diff --git a/packages/adapters/catalog-backstage/test/admissibility-record.test.ts b/packages/adapters/catalog-backstage/test/admissibility-record.test.ts new file mode 100644 index 00000000..5d69e8a6 --- /dev/null +++ b/packages/adapters/catalog-backstage/test/admissibility-record.test.ts @@ -0,0 +1,135 @@ +/** + * T052 — FR-020: every inadmissibility record identifies **all three** of the + * descriptor path, the failing field, and the rejecting validator — and is + * distinguishable from a `duplicate-canonical-id` record. + * + * `admissibility.md` §3: "A recorded failure that says only 'descriptor invalid', + * without naming which of the four fields and which validator produced the false, + * does not satisfy FR-020 and MUST be treated as a reporting defect rather than as + * a determination." + */ + +import { describe, expect, test } from 'bun:test'; +import { classifyAdmissibility, inadmissibleRejection } from '../src/admissibility/classify.ts'; +import { PINNED_BACKSTAGE_COMMIT } from '../src/admissibility/validators.ts'; +import { ADMISSIBLE, descriptor } from './descriptor-fixtures.ts'; + +describe('T052 — all three attributions are present', () => { + const result = classifyAdmissibility( + descriptor({ ...ADMISSIBLE, name: '${{ values.name }}' }, 'packages/bulk-import/catalog-info.yaml'), + ); + + test('the record names the offending path', () => { + expect(result.attributions[0]?.sourcePath).toBe('packages/bulk-import/catalog-info.yaml'); + }); + + test('the record names the failing field', () => { + expect(result.attributions[0]?.field).toBe('metadata.name'); + }); + + test('the record names the rejecting validator', () => { + expect(result.attributions[0]?.validator).toBe('validateEntityName'); + }); + + test('the record names the pinned binding and commit the verdict is warranted by', () => { + expect(result.attributions[0]?.pinnedBinding).toBe( + 'isValidEntityName \u2192 KubernetesValidatorFunctions.isValidObjectName', + ); + expect(result.attributions[0]?.pinnedCommit).toBe(PINNED_BACKSTAGE_COMMIT); + }); + + test('the document index is carried, since one file may hold several documents', () => { + expect(result.attributions[0]?.documentIndexInFile).toBe(0); + }); + + test('the rendered detail carries all three, not a bare "invalid"', () => { + const detail = inadmissibleRejection(result).detail; + expect(detail).toContain('packages/bulk-import/catalog-info.yaml'); + expect(detail).toContain('metadata.name'); + expect(detail).toContain('validateEntityName'); + expect(detail).not.toBe('descriptor invalid'); + }); +}); + +describe('T052 — two failing fields produce two attributions, not one merged one', () => { + // `admissibility.md` §3: "The composition therefore splits the descriptor's fields + // before invoking any predicate and never after. Two fields failing produce two + // attributions, not one merged one." + const result = classifyAdmissibility( + descriptor({ apiVersion: 'a/b/c', kind: 'Component', name: 'payments', namespace: 'Default' }), + ); + + test('both failures are recorded', () => { + expect(result.admissible).toBe(false); + expect(result.failedFields).toEqual(['apiVersion', 'metadata.namespace']); + }); + + test('each carries its own validator, not a shared one', () => { + expect(result.attributions.map((a) => a.validator)).toEqual([ + 'validateApiVersion', + 'validateNamespace', + ]); + }); + + test('evaluation does not short-circuit at the first failure', () => { + // A short-circuit would make the reported field a function of evaluation + // order rather than of the descriptor. + expect(result.attributions).toHaveLength(2); + }); + + test('all four failing at once yields four attributions', () => { + const all = classifyAdmissibility( + descriptor({ apiVersion: 'A/B/C', kind: '1bad', name: '-bad-', namespace: 'BAD' }), + ); + expect(all.failedFields).toEqual([ + 'apiVersion', + 'kind', + 'metadata.name', + 'metadata.namespace', + ]); + expect(new Set(all.attributions.map((a) => a.validator)).size).toBe(4); + }); +}); + +describe('T052 — distinguishable from a `duplicate-canonical-id` record', () => { + test('the trigger class alone tells them apart, without parsing any prose', () => { + const rejection = inadmissibleRejection( + classifyAdmissibility(descriptor({ ...ADMISSIBLE, name: '${{ values.name }}' })), + ); + expect(rejection.triggerClass).toBe('inadmissible-descriptor'); + expect(rejection.triggerClass).not.toBe('duplicate-canonical-id'); + }); + + test('the record contains no canonical id, so it could not be mistaken for a collision', () => { + const rejection = inadmissibleRejection( + classifyAdmissibility(descriptor({ ...ADMISSIBLE, name: '${{ values.name }}' })), + ); + expect(rejection.detail).not.toContain('component:default/'); + expect(JSON.stringify(rejection)).not.toContain('duplicate'); + }); + + test('the record says what was observed, without claiming Backstage rejected it', () => { + // `admissibility.md` §1 and §7: the warrant is a predicate return value. A + // record asserting system behaviour would exceed it. + const rejection = inadmissibleRejection( + classifyAdmissibility(descriptor({ ...ADMISSIBLE, name: '${{ values.name }}' })), + ); + expect(rejection.detail).toContain('rejected by validateEntityName'); + expect(rejection.detail).toContain('pinned at'); + expect(rejection.detail).not.toMatch(/backstage (?:rejects|would|requires)/iu); + }); + + test('an absent field is rendered as absent, not as an empty string', () => { + const result = classifyAdmissibility(descriptor({ kind: 'Component', name: 'payments' })); + expect(result.failedFields).toEqual(['apiVersion']); + expect(result.attributions[0]?.observed).toBe(''); + }); + + test('a non-string field is rendered by type, never coerced into a quoted string', () => { + const document = descriptor({ ...ADMISSIBLE, name: 'payments' }); + const withNumericKind = { ...document, rawKind: 42 }; + const result = classifyAdmissibility(withNumericKind); + expect(result.failedFields).toEqual(['kind']); + expect(result.attributions[0]?.observed).toBe(''); + }); +}); diff --git a/packages/adapters/catalog-backstage/test/admissibility-separator.test.ts b/packages/adapters/catalog-backstage/test/admissibility-separator.test.ts new file mode 100644 index 00000000..c8f32db4 --- /dev/null +++ b/packages/adapters/catalog-backstage/test/admissibility-separator.test.ts @@ -0,0 +1,91 @@ +/** + * T050 — FR-017: the separator rule. + * + * ADR-0015, quoted: `isValidPrefixAndOrSuffix` "splits on `/` and **rejects any + * value containing two or more separators**, and a value with **no** separator is + * validated against the suffix predicate alone — so a bare `v1` passes without the + * subdomain rule ever being consulted." + * + * Both branches are observed here, because they fail in opposite directions: an + * implementation splitting on the first `/` accepts `a/b/c`; one requiring a prefix + * rejects the `v1` ADR-0015 says passes. + */ + +import { describe, expect, test } from 'bun:test'; +import { splitOnSeparator } from '../src/admissibility/separator.ts'; +import { validateApiVersion } from '../src/admissibility/validators.ts'; + +describe('T050 — two or more separators is rejected outright', () => { + test('the split reports the count rather than a prefix and suffix', () => { + expect(splitOnSeparator('backstage.io/v1/alpha')).toEqual({ + kind: 'too-many-separators', + separatorCount: 2, + }); + expect(splitOnSeparator('a/b/c/d')).toEqual({ + kind: 'too-many-separators', + separatorCount: 3, + }); + }); + + test('rejection does not depend on what the parts contain', () => { + // Every part below is individually valid: `backstage.io` is a DNS subdomain and + // `v1`/`alpha` both satisfy the suffix predicate. The value is still rejected, + // because the count is decided before the parts are looked at. + expect(validateApiVersion('backstage.io/v1/alpha')).toBe(false); + expect(validateApiVersion('backstage.io/v1alpha1')).toBe(true); + expect(validateApiVersion('v1')).toBe(true); + }); + + test('a leading or trailing slash is two segments, and is rejected on its own terms', () => { + // `/v1` splits into `['', 'v1']` — one separator, so the prefix rule applies to + // the empty string and fails there. This is a different rejection path from the + // two-separator one and must not be confused with it. + expect(splitOnSeparator('/v1')).toEqual({ kind: 'prefix-and-suffix', prefix: '', suffix: 'v1' }); + expect(validateApiVersion('/v1')).toBe(false); + + expect(splitOnSeparator('v1/')).toEqual({ kind: 'prefix-and-suffix', prefix: 'v1', suffix: '' }); + expect(validateApiVersion('v1/')).toBe(false); + }); +}); + +describe('T050 — no separator is evaluated by the suffix predicate alone', () => { + test('the split reports suffix-only, with no prefix at all', () => { + expect(splitOnSeparator('v1')).toEqual({ kind: 'suffix-only', suffix: 'v1' }); + }); + + test('a bare `v1` passes', () => { + expect(validateApiVersion('v1')).toBe(true); + }); + + test('the subdomain rule is never consulted for an unseparated value', () => { + // `V1` is not a valid DNS subdomain (uppercase), yet it satisfies the suffix + // predicate `/^[a-z0-9A-Z]+$/`. It passes — which is only possible if the + // subdomain rule really was skipped. + expect(validateApiVersion('V1')).toBe(true); + expect(validateApiVersion('V1/v1')).toBe(false); + }); + + test('the suffix predicate still applies to an unseparated value', () => { + // "Evaluated by the suffix predicate alone" is not "unchecked". + expect(validateApiVersion('v1-alpha')).toBe(false); + expect(validateApiVersion('v1.alpha')).toBe(false); + expect(validateApiVersion('a'.repeat(64))).toBe(false); + expect(validateApiVersion('a'.repeat(63))).toBe(true); + }); +}); + +describe('T050 — exactly one separator consults both rules', () => { + test('the split reports prefix and suffix separately', () => { + expect(splitOnSeparator('backstage.io/v1alpha1')).toEqual({ + kind: 'prefix-and-suffix', + prefix: 'backstage.io', + suffix: 'v1alpha1', + }); + }); + + test('either half failing rejects the whole value', () => { + expect(validateApiVersion('backstage.io/v1alpha1')).toBe(true); + expect(validateApiVersion('Backstage.io/v1alpha1')).toBe(false); + expect(validateApiVersion('backstage.io/v1-alpha1')).toBe(false); + }); +}); diff --git a/packages/adapters/catalog-backstage/test/admissibility-validators.test.ts b/packages/adapters/catalog-backstage/test/admissibility-validators.test.ts new file mode 100644 index 00000000..c47cdbe8 --- /dev/null +++ b/packages/adapters/catalog-backstage/test/admissibility-validators.test.ts @@ -0,0 +1,264 @@ +/** + * T049 — the four admissibility field validators, each separately attributed. + * + * Every expected value below comes from ADR-0015's table (reproduced in `spec.md` + * FR-016) or from `contracts/admissibility.md`. None is derived by running the code + * under test. + * + * **The four `apiVersion` facts ADR-0015 recorded as directly executed** against + * the pin — "a 243-character `apiVersion` prefix passes while a 254-character one, + * an over-63 label, and a two-separator value all fail" — are pinned below as their + * own test, because they are the only admissibility expectations in the whole + * document that were obtained by *running* the pinned sources rather than by + * reading them. + * + * **The warrant.** Each assertion states what a pure validator predicate returns. + * None states what Backstage as a running system does; this feature has never run + * one. + * + * ADR-0016 evidence: + * `specs/010-catalog-backstage/evidence/negative-cases/admissibility-validators/`. + */ + +import { describe, expect, test } from 'bun:test'; +import { + ADMISSIBILITY_FIELDS, + DNS_LABEL_MAX, + DNS_SUBDOMAIN_MAX, + FIELD_MAX, + PINNED_BACKSTAGE_COMMIT, + PINNED_VALIDATOR_BINDINGS, + VALIDATORS, + VALIDATOR_FIELDS, + validateApiVersion, + validateEntityName, + validateKind, + validateNamespace, +} from '../src/admissibility/validators.ts'; + +describe('T049 — the table has exactly four rows, bound as ADR-0015 binds them', () => { + test('four fields, in ADR-0015\u2019s table order', () => { + expect([...ADMISSIBILITY_FIELDS]).toEqual([ + 'apiVersion', + 'kind', + 'metadata.name', + 'metadata.namespace', + ]); + }); + + test('four validators, one per field', () => { + expect(Object.keys(VALIDATORS).sort()).toEqual([ + 'validateApiVersion', + 'validateEntityName', + 'validateKind', + 'validateNamespace', + ]); + expect(VALIDATOR_FIELDS).toEqual({ + validateApiVersion: 'apiVersion', + validateKind: 'kind', + validateEntityName: 'metadata.name', + validateNamespace: 'metadata.namespace', + }); + }); + + test('each validator records the pinned binding it reproduces', () => { + expect(PINNED_VALIDATOR_BINDINGS.validateApiVersion).toBe( + 'isValidApiVersion \u2192 CommonValidatorFunctions.isValidPrefixAndOrSuffix', + ); + expect(PINNED_VALIDATOR_BINDINGS.validateKind).toBe('isValidKind'); + expect(PINNED_VALIDATOR_BINDINGS.validateEntityName).toBe( + 'isValidEntityName \u2192 KubernetesValidatorFunctions.isValidObjectName', + ); + expect(PINNED_VALIDATOR_BINDINGS.validateNamespace).toBe( + 'isValidNamespace \u2192 KubernetesValidatorFunctions.isValidNamespace \u2192 CommonValidatorFunctions.isValidDnsLabel', + ); + }); + + test('the pin is ADR-0012\u2019s commit, carried unchanged', () => { + expect(PINNED_BACKSTAGE_COMMIT).toBe('1121a4facd9e321179d0402c3f355e4a649e84d9'); + }); + + test('the bounds are the ones ADR-0015 states', () => { + expect(FIELD_MAX).toBe(63); + expect(DNS_LABEL_MAX).toBe(63); + expect(DNS_SUBDOMAIN_MAX).toBe(253); + }); +}); + +describe('T049 — `validateApiVersion`', () => { + test('the four facts ADR-0015 recorded as executed against the pin', () => { + // "a 243-character `apiVersion` prefix passes while a 254-character one, an + // over-63 label, and a two-separator value all fail". + const prefix243 = ['a'.repeat(60), 'b'.repeat(60), 'c'.repeat(60), 'd'.repeat(60)].join('.'); + expect(prefix243).toHaveLength(243); + expect(validateApiVersion(`${prefix243}/v1`)).toBe(true); + + const prefix254 = [ + 'a'.repeat(63), + 'b'.repeat(63), + 'c'.repeat(63), + 'd'.repeat(61), + ].join('.'); + expect(prefix254).toHaveLength(253); + const tooLong = `${prefix254}a`; + expect(tooLong).toHaveLength(254); + expect(validateApiVersion(`${tooLong}/v1`)).toBe(false); + + const overLongLabel = `${'a'.repeat(64)}.example`; + expect(validateApiVersion(`${overLongLabel}/v1`)).toBe(false); + + expect(validateApiVersion('backstage.io/v1/alpha')).toBe(false); + }); + + test('a bare `v1` passes without the subdomain rule being consulted (FR-017)', () => { + expect(validateApiVersion('v1')).toBe(true); + expect(validateApiVersion('v1alpha1')).toBe(true); + }); + + test('the ordinary Backstage form passes', () => { + expect(validateApiVersion('backstage.io/v1alpha1')).toBe(true); + expect(validateApiVersion('backstage.io/v1beta3')).toBe(true); + }); + + test('the suffix predicate is `/^[a-z0-9A-Z]+$/`, so punctuation fails', () => { + expect(validateApiVersion('backstage.io/v1-alpha')).toBe(false); + expect(validateApiVersion('backstage.io/v1.alpha')).toBe(false); + expect(validateApiVersion('backstage.io/')).toBe(false); + expect(validateApiVersion('')).toBe(false); + }); + + test('the prefix is a DNS subdomain, so uppercase and underscores fail', () => { + expect(validateApiVersion('Backstage.io/v1alpha1')).toBe(false); + expect(validateApiVersion('backstage_io/v1alpha1')).toBe(false); + }); + + test('a non-string is not a valid apiVersion', () => { + for (const value of [undefined, null, 1, true, ['v1'], { v: 1 }]) { + expect(validateApiVersion(value)).toBe(false); + } + }); +}); + +describe('T049 — `validateKind` is `/^[a-zA-Z][a-z0-9A-Z]*$/`, \u226463', () => { + test('ordinary kinds pass', () => { + for (const kind of ['Component', 'API', 'Location', 'a', 'Group2']) { + expect(validateKind(kind)).toBe(true); + } + }); + + test('a leading digit fails — the first character class excludes it', () => { + expect(validateKind('1Component')).toBe(false); + }); + + test('punctuation fails', () => { + for (const kind of ['Com-ponent', 'Com_ponent', 'Com.ponent', 'Com ponent', '']) { + expect(validateKind(kind)).toBe(false); + } + }); + + test('the \u226463 bound holds at the boundary', () => { + expect(validateKind(`C${'a'.repeat(62)}`)).toBe(true); + expect(validateKind(`C${'a'.repeat(63)}`)).toBe(false); + }); + + test('a non-string is not a valid kind', () => { + expect(validateKind(undefined)).toBe(false); + expect(validateKind(42)).toBe(false); + }); +}); + +describe('T049 — `validateEntityName` is `/^([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9]$/`, \u226463', () => { + test('ordinary names pass, including mixed case, `-`, `_` and `.`', () => { + for (const name of ['payments', 'Payments', 'payments-api', 'payments_api', 'payments.v2', 'a']) { + expect(validateEntityName(name)).toBe(true); + } + }); + + test('a leading or trailing separator fails', () => { + for (const name of ['-payments', 'payments-', '.payments', 'payments.', '_payments', 'payments_']) { + expect(validateEntityName(name)).toBe(false); + } + }); + + test('the unsubstituted scaffolder placeholders ADR-0015 names all fail', () => { + // ADR-0015: "All three forms fail `isValidObjectName` on character class: `$`, + // `{`, `}` and the spaces are outside the permitted set in every case, and `|` + // in the first". These are the sixteen descriptors' three distinct forms. + expect(validateEntityName('${{ values.name | dump }}')).toBe(false); + expect(validateEntityName('${{ values.name }}')).toBe(false); + expect(validateEntityName('${{ values.entityName }}')).toBe(false); + }); + + test('the \u226463 bound holds at the boundary', () => { + expect(validateEntityName('a'.repeat(63))).toBe(true); + expect(validateEntityName('a'.repeat(64))).toBe(false); + }); + + test('length and character class are different populations (\u00a72.1)', () => { + // `admissibility.md` §2.1: "Over 63 characters" and "invalid" are different + // sets and MUST NOT be reported as one. + const tooLongButOtherwiseValid = 'a'.repeat(64); + const shortButInvalidCharacters = 'payments!'; + expect(validateEntityName(tooLongButOtherwiseValid)).toBe(false); + expect(validateEntityName(shortButInvalidCharacters)).toBe(false); + // Same verdict, different reason — which is exactly why the two populations + // must not be collapsed in any report. + expect(tooLongButOtherwiseValid.length > 63).toBe(true); + expect(shortButInvalidCharacters.length <= 63).toBe(true); + }); + + test('an empty name fails', () => { + expect(validateEntityName('')).toBe(false); + }); +}); + +describe('T049 — `validateNamespace` is `/^[a-z0-9]+(?:\\-+[a-z0-9]+)*$/`, \u226463', () => { + test('ordinary DNS labels pass', () => { + for (const namespace of ['default', 'payments', 'team-payments', 'a1', 'a--b']) { + expect(validateNamespace(namespace)).toBe(true); + } + }); + + test('uppercase fails — this is where ADR-0015 and `admissibility.md` \u00a72 diverge', () => { + // `contracts/admissibility.md` §2's summary gives namespace the same character + // class as `metadata.name` ("`[A-Za-z0-9]` plus `-`, `_`, `.`"). ADR-0015 and + // FR-016 give it the DNS-label predicate, which admits none of uppercase, `_` + // or `.`. FR-016 requires "exactly the four field validators in ADR-0015's + // table", so ADR-0015 governs and this assertion is the one that would fail if + // someone implemented §2's summary instead. Reported as a contract defect. + expect(validateNamespace('Default')).toBe(false); + expect(validateNamespace('team_payments')).toBe(false); + expect(validateNamespace('team.payments')).toBe(false); + }); + + test('a leading or trailing hyphen fails', () => { + expect(validateNamespace('-payments')).toBe(false); + expect(validateNamespace('payments-')).toBe(false); + }); + + test('an empty namespace fails — present-and-empty is not omitted', () => { + expect(validateNamespace('')).toBe(false); + }); + + test('the \u226463 bound holds at the boundary', () => { + expect(validateNamespace('a'.repeat(63))).toBe(true); + expect(validateNamespace('a'.repeat(64))).toBe(false); + }); +}); + +describe('T049 — the four predicates are independent', () => { + test('each rejects an input the other three accept in their own field', () => { + // A value valid as one field and invalid as another is what makes separate + // attribution meaningful rather than decorative. + expect(validateKind('Component')).toBe(true); + expect(validateNamespace('Component')).toBe(false); + + expect(validateEntityName('payments.v2')).toBe(true); + expect(validateNamespace('payments.v2')).toBe(false); + expect(validateKind('payments.v2')).toBe(false); + + expect(validateApiVersion('v1')).toBe(true); + expect(validateKind('v1')).toBe(true); + expect(validateNamespace('v1')).toBe(true); + }); +}); diff --git a/packages/adapters/catalog-backstage/test/annotation-decode.test.ts b/packages/adapters/catalog-backstage/test/annotation-decode.test.ts new file mode 100644 index 00000000..7657fff0 --- /dev/null +++ b/packages/adapters/catalog-backstage/test/annotation-decode.test.ts @@ -0,0 +1,216 @@ +/** + * T058 — FR-026: the five ordered annotation decode steps, each with its own + * distinct rejection reason, each observed failing independently. + * + * Every expected value comes from `owned-paths-annotation.md` §1 — including its + * worked-example table, which is transcribed below row for row — and from + * `data-model.md` §6. None is derived by running the code under test. + * + * ADR-0016 evidence: + * `specs/010-catalog-backstage/evidence/negative-cases/annotation-decode/`. + */ + +import { describe, expect, test } from 'bun:test'; +import { + ANNOTATION_REJECTION_STEP, + OWNED_PATHS_ANNOTATION, + decodeAnnotation, +} from '../src/ownership/annotation.ts'; + +describe('T058 — `owned-paths-annotation.md` \u00a71\u2019s worked-example table', () => { + // | Raw annotation node | Final rejection reason | + // |---|---| + // | `'["packages/payments/**"]'` (string scalar) | none — proceeds to per-pattern validation | + // | `["[]"]` (YAML sequence, not a string) | `"annotation-value-not-a-string"` | + // | `'{"paths": ["a/**"]}'` (string scalar) | `"wrong-shape"` | + // | `'"packages/payments/**"'` (string scalar) | `"wrong-shape"` | + // | `'["packages/payments/**", 3]'` (string scalar)| `"wrong-shape"` | + // | `'["packages/payments/**'` (missing bracket) | `"parse-error"` | + + test('row 1 — a well-formed string scalar proceeds', () => { + const result = decodeAnnotation(true, '["packages/payments/**"]'); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.value.patterns).toEqual(['packages/payments/**']); + expect(result.value.diagnostics.rejectionReason).toBeUndefined(); + }); + + test('row 2 — a YAML sequence yields `annotation-value-not-a-string`', () => { + const result = decodeAnnotation(true, ['[]']); + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.rejection.reason).toBe('annotation-value-not-a-string'); + }); + + test('row 3 — a JSON object yields `wrong-shape`', () => { + const result = decodeAnnotation(true, '{"paths": ["a/**"]}'); + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.rejection.reason).toBe('wrong-shape'); + }); + + test('row 4 — a bare string yields `wrong-shape`, never a single-element array', () => { + const result = decodeAnnotation(true, '"packages/payments/**"'); + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.rejection.reason).toBe('wrong-shape'); + }); + + test('row 5 — an array with a non-string element yields `wrong-shape`', () => { + const result = decodeAnnotation(true, '["packages/payments/**", 3]'); + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.rejection.reason).toBe('wrong-shape'); + expect(result.rejection.detail).toContain('element 1'); + }); + + test('row 6 — malformed JSON yields `parse-error`', () => { + const result = decodeAnnotation(true, '["packages/payments/**'); + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.rejection.reason).toBe('parse-error'); + }); +}); + +describe('T058 step 1 — presence, via an explicit discriminant', () => { + test('an absent annotation stops at step 1 with no rejection', () => { + const result = decodeAnnotation(false, undefined); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.value.diagnostics.annotationPresent).toBe(false); + expect(result.value.patterns).toBeUndefined(); + }); + + test('no string check, JSON parse, or shape check is attempted for an absent key', () => { + // §1 step 1: "No string check, no JSON parsing, and no shape check is attempted + // for an absent key." + const result = decodeAnnotation(false, undefined); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.value.diagnostics.rawNodeIsString).toBeUndefined(); + expect(result.value.diagnostics.jsonParseOutcome).toBeUndefined(); + expect(result.value.diagnostics.shapeOutcome).toBeUndefined(); + }); + + test('presence is not inferred from the value — a present null reaches step 2', () => { + // A YAML key authored with no value is present and `null`. Inferring absence + // from `undefined` would silently reclassify it as `annotation-absent`. + const result = decodeAnnotation(true, null); + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.rejection.reason).toBe('annotation-value-not-a-string'); + expect(result.diagnostics.annotationPresent).toBe(true); + }); +}); + +describe('T058 step 2 — the string-scalar check, before any parse', () => { + test.each([ + ['a YAML sequence', ['[]']], + ['a YAML mapping', { a: 1 }], + ['a number', 42], + ['a boolean', true], + ['null', null], + ] as const)('%s is rejected as `annotation-value-not-a-string`', (_label, node) => { + const result = decodeAnnotation(true, node); + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.rejection.reason).toBe('annotation-value-not-a-string'); + expect(result.diagnostics.rawNodeIsString).toBe(false); + expect(result.diagnostics.jsonParseOutcome).toBe('not-a-string'); + }); + + test('the detail records that the value was never passed to JSON.parse', () => { + const result = decodeAnnotation(true, ['[]']); + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.rejection.detail).toContain('not passed to JSON.parse'); + expect(result.rejection.detail).toContain('ToString'); + }); +}); + +describe('T058 step 3 — JSON decode, reached only by a present string scalar', () => { + test.each([ + '["packages/**"', + '[packages/**]', + '{"a": }', + 'not json at all', + '', + ])('%j is rejected as `parse-error`', (raw) => { + const result = decodeAnnotation(true, raw); + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.rejection.reason).toBe('parse-error'); + expect(result.diagnostics.rawNodeIsString).toBe(true); + expect(result.diagnostics.jsonParseOutcome).toBe('parse-error'); + }); + + test('a parse failure is never coerced into a fallback empty array', () => { + // §1 step 3: "never coerced into a fallback empty array". + const result = decodeAnnotation(true, '["packages/**"'); + expect(result.ok).toBe(false); + expect(JSON.stringify(result)).not.toContain('"patterns"'); + }); +}); + +describe('T058 step 4 — shape: exactly array', () => { + test.each([ + ['a JSON object', '{"paths": []}'], + ['a bare string', '"packages/**"'], + ['a bare number', '3'], + ['a bare boolean', 'true'], + ['null', 'null'], + ['an array containing a number', '["a/**", 3]'], + ['an array containing an object', '["a/**", {}]'], + ['an array containing null', '["a/**", null]'], + ['an array containing a nested array', '["a/**", ["b/**"]]'], + ])('%s is rejected as `wrong-shape`', (_label, raw) => { + const result = decodeAnnotation(true, raw); + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.rejection.reason).toBe('wrong-shape'); + expect(result.diagnostics.jsonParseOutcome).toBe('parsed'); + expect(result.diagnostics.shapeOutcome).toBe('wrong-shape'); + }); + + test('nothing is coerced — a bare string is never treated as a one-element array', () => { + const result = decodeAnnotation(true, '"packages/payments/**"'); + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.rejection.reason).toBe('wrong-shape'); + }); + + test('an empty array is a valid shape, and reaches step 5', () => { + const result = decodeAnnotation(true, '[]'); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.value.patterns).toEqual([]); + expect(result.value.diagnostics.shapeOutcome).toBe('array-of-strings'); + }); +}); + +describe('T058 — the reasons are three distinct values, one per failing step', () => { + test('each step maps to its own reason', () => { + expect(ANNOTATION_REJECTION_STEP).toEqual({ + 'annotation-value-not-a-string': 2, + 'parse-error': 3, + 'wrong-shape': 4, + }); + }); + + test('the three reasons observed together are mutually distinct', () => { + // §1 step 3: a decode failure is "**not** the same reason as step 2's + // non-string failure or step 4's shape failure". + const reasons = [ + decodeAnnotation(true, ['[]']), + decodeAnnotation(true, '["a/**"'), + decodeAnnotation(true, '{"a": 1}'), + ].map((result) => (result.ok ? 'accepted' : result.rejection.reason)); + + expect(reasons).toEqual(['annotation-value-not-a-string', 'parse-error', 'wrong-shape']); + expect(new Set(reasons).size).toBe(3); + }); + + test('the annotation key is the one ADR-0012 names', () => { + expect(OWNED_PATHS_ANNOTATION).toBe('adrkit.io/owned-paths'); + }); +}); diff --git a/packages/adapters/catalog-backstage/test/annotation-step2-raw-node.test.ts b/packages/adapters/catalog-backstage/test/annotation-step2-raw-node.test.ts new file mode 100644 index 00000000..1d204e7d --- /dev/null +++ b/packages/adapters/catalog-backstage/test/annotation-step2-raw-node.test.ts @@ -0,0 +1,155 @@ +/** + * T059 — **observed failing, permanent negative case.** FR-027: step 2's + * string-scalar check runs against the **raw YAML node**, before `JSON.parse`. + * + * # The exact fixture, and why it is this one + * + * The annotation value `["[]"]` — a YAML *sequence* containing the string `"[]"`, + * not a string — must yield `annotation-value-not-a-string`, and must **never** be + * silently coerced into `explicit-empty`. + * + * The coercion path is a language-level fact, not a hypothetical: + * `owned-paths-annotation.md` §1 step 2 spells it out — ECMA-262 defines + * `JSON.parse(text)` as first coercing `text` to a string via `ToString`, so + * `["[]"]` becomes the string `"[]"`, which parses cleanly as an empty array and is + * then misclassified. + * + * **A misclassification here is worse than a crash.** `explicit-empty` is a + * legitimate, meaningful state — "this entity deliberately owns nothing". A + * descriptor that was in fact malformed would be recorded as having made a + * considered decision, and nothing downstream would ever question it. + * + * # The proof that the mechanism is what it claims + * + * The first test below reproduces the coercion directly: it shows that + * `JSON.parse(["[]"] as never)` really does return `[]` without throwing. Without + * that, "step 2 is load-bearing" would be an assertion about a danger nobody had + * confirmed exists. + * + * **Retained permanently.** + * + * ADR-0016 evidence: + * `specs/010-catalog-backstage/evidence/negative-cases/annotation-sequence-coercion/`. + */ + +import { describe, expect, test } from 'bun:test'; +import { readAnnotationNode, readDescriptorDocuments } from '../src/descriptor/read.ts'; +import { decodeAnnotation } from '../src/ownership/annotation.ts'; +import { deriveOwnership } from '../src/ownership/derive.ts'; + +/** The descriptor exactly as a fixture author would write it, in YAML. */ +const DESCRIPTOR = [ + 'apiVersion: backstage.io/v1alpha1', + 'kind: Component', + 'metadata:', + ' name: payments', + ' annotations:', + ' adrkit.io/owned-paths: ["[]"]', + '', +].join('\n'); + +describe('T059 — the coercion this check exists to prevent is real', () => { + test('`JSON.parse` coerces a one-element sequence to `"[]"` and returns `[]`', () => { + // The cast is the point: `JSON.parse`'s TypeScript signature declares a + // `string` parameter and therefore provides no runtime protection at all. A + // value arriving from YAML is `unknown`, and nothing in the type system stops + // it reaching here. + const coerced: unknown = JSON.parse(['[]'] as unknown as string); + expect(coerced).toEqual([]); + expect(Array.isArray(coerced)).toBe(true); + expect(String(['[]'])).toBe('[]'); + }); + + test('so an implementation without step 2 would classify it `explicit-empty`', () => { + // Demonstrating the wrong answer explicitly, so the right one below is a + // contrast rather than an assertion in a vacuum. + const withoutStepTwo = JSON.parse(['[]'] as unknown as string) as unknown[]; + const wouldBeState = withoutStepTwo.length === 0 ? 'explicit-empty' : 'explicit-paths'; + expect(wouldBeState).toBe('explicit-empty'); + }); +}); + +describe('T059 — the YAML sequence really is a sequence by the time it is checked', () => { + test('the raw node read from the descriptor is an array, not a string', () => { + const [document] = readDescriptorDocuments('catalog-info.yaml', DESCRIPTOR); + expect(document?.parseOutcome).toBe('parsed'); + if (document === undefined) return; + + const node = readAnnotationNode(document, 'adrkit.io/owned-paths'); + expect(node.present).toBe(true); + expect(Array.isArray(node.value)).toBe(true); + expect(typeof node.value).not.toBe('string'); + expect(node.value).toEqual(['[]']); + }); +}); + +describe('T059 — the correct reason is produced, and the wrong one is absent', () => { + test('`decodeAnnotation` yields `annotation-value-not-a-string`', () => { + const result = decodeAnnotation(true, ['[]']); + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.rejection.reason).toBe('annotation-value-not-a-string'); + }); + + test('and never `explicit-empty` — the absence of the wrong outcome', () => { + const result = decodeAnnotation(true, ['[]']); + expect(result.ok).toBe(false); + expect(JSON.stringify(result)).not.toContain('explicit-empty'); + }); + + test('the whole derivation, end to end from the YAML', () => { + const [document] = readDescriptorDocuments('catalog-info.yaml', DESCRIPTOR); + if (document === undefined) throw new Error('fixture did not parse'); + const node = readAnnotationNode(document, 'adrkit.io/owned-paths'); + + const derivation = deriveOwnership(node.present, node.value); + expect(derivation.ok).toBe(false); + if (derivation.ok) return; + + expect(derivation.rejection.reason).toBe('annotation-value-not-a-string'); + expect(derivation.annotation.rawNodeIsString).toBe(false); + expect(derivation.annotation.jsonParseOutcome).toBe('not-a-string'); + expect(JSON.stringify(derivation)).not.toContain('explicit-empty'); + }); + + test('a genuine `explicit-empty` is still reachable — the check is not a blanket ban', () => { + // If `["[]"]` were rejected by something that also rejected the legitimate + // case, the fixture would prove nothing about step 2 specifically. + const genuine = deriveOwnership(true, '[]'); + expect(genuine.ok).toBe(true); + if (!genuine.ok) return; + expect(genuine.value.ownershipState).toBe('explicit-empty'); + }); +}); + +describe('T059 — step 2 runs before step 3, not merely instead of it', () => { + test('a non-string that would ALSO fail JSON parsing still reports step 2', () => { + // `{a: 1}` stringifies to `[object Object]`, which is not valid JSON. If step 2 + // were skipped, this would surface as `parse-error` — a different reason for + // the same defect, and the one that would hide the ordering bug. + const result = decodeAnnotation(true, { a: 1 }); + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.rejection.reason).toBe('annotation-value-not-a-string'); + expect(result.rejection.reason).not.toBe('parse-error'); + }); + + test('a number that would parse cleanly still reports step 2', () => { + // `3` stringifies to `"3"`, which parses to the number 3 — so without step 2 + // this would reach step 4 and report `wrong-shape`, again masking the ordering. + const result = decodeAnnotation(true, 3); + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.rejection.reason).toBe('annotation-value-not-a-string'); + expect(result.rejection.reason).not.toBe('wrong-shape'); + }); + + test('the diagnostics record shows step 3 was never reached', () => { + const result = decodeAnnotation(true, ['[]']); + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.diagnostics.jsonParseOutcome).toBe('not-a-string'); + expect(result.diagnostics.jsonParseOutcome).not.toBe('parsed'); + expect(result.diagnostics.shapeOutcome).toBeUndefined(); + }); +}); diff --git a/packages/adapters/catalog-backstage/test/descriptor-fixtures.ts b/packages/adapters/catalog-backstage/test/descriptor-fixtures.ts new file mode 100644 index 00000000..b57726d9 --- /dev/null +++ b/packages/adapters/catalog-backstage/test/descriptor-fixtures.ts @@ -0,0 +1,82 @@ +/** + * Shared descriptor fixtures for the D1a (admissibility and identity) tests. + * + * Every fixture's *expected* admissibility comes from ADR-0015's table, and every + * fixture's expected canonical id comes from `entity-identity.md` §1. None comes + * from running the code under test. + */ + +import { type DescriptorDocument, readDescriptorDocuments } from '../src/descriptor/read.ts'; + +export interface DescriptorSpec { + readonly apiVersion?: string; + readonly kind?: string; + readonly name?: string; + readonly namespace?: string; + readonly annotations?: Readonly>; +} + +/** Build one parsed descriptor document from a small spec. */ +export function descriptor( + spec: DescriptorSpec, + sourcePath = 'catalog-info.yaml', +): DescriptorDocument { + const lines: string[] = []; + if (spec.apiVersion !== undefined) lines.push(`apiVersion: ${JSON.stringify(spec.apiVersion)}`); + if (spec.kind !== undefined) lines.push(`kind: ${JSON.stringify(spec.kind)}`); + + const hasMetadata = + spec.name !== undefined || spec.namespace !== undefined || spec.annotations !== undefined; + if (hasMetadata) { + lines.push('metadata:'); + if (spec.name !== undefined) lines.push(` name: ${JSON.stringify(spec.name)}`); + if (spec.namespace !== undefined) lines.push(` namespace: ${JSON.stringify(spec.namespace)}`); + if (spec.annotations !== undefined) { + lines.push(' annotations:'); + for (const [key, value] of Object.entries(spec.annotations)) { + lines.push(` ${JSON.stringify(key)}: ${JSON.stringify(value)}`); + } + } + } + + const documents = readDescriptorDocuments(sourcePath, `${lines.join('\n')}\n`); + const document = documents[0]; + if (document === undefined || document.parseOutcome !== 'parsed') { + throw new Error(`fixture did not parse: ${document?.rejection?.detail ?? 'no document'}`); + } + return document; +} + +/** A descriptor that passes all four validators. */ +export const ADMISSIBLE: DescriptorSpec = { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Component', + name: 'payments', +}; + +/** + * ADR-0015's `bulk-import` outlier: `${{ values.name }}`. + * + * ADR-0015 records these two as canonically **distinct** from the fourteen that + * share `${{ values.name | dump }}` — "[b]eing canonically distinct, they collide + * with nothing. They are exactly as invalid as the other fourteen." + */ +export const INADMISSIBLE_AND_UNIQUE_BULK_IMPORT: DescriptorSpec = { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Component', + name: '${{ values.name }}', +}; + +/** ADR-0015's `orchestrator` outlier: `${{ values.entityName }}`. */ +export const INADMISSIBLE_AND_UNIQUE_ORCHESTRATOR: DescriptorSpec = { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Component', + name: '${{ values.entityName }}', +}; + +/** The form the other fourteen placeholder descriptors share. */ +export const INADMISSIBLE_AND_COLLIDING: DescriptorSpec = { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Component', + name: '${{ values.name | dump }}', +}; diff --git a/packages/adapters/catalog-backstage/test/descriptor-read.test.ts b/packages/adapters/catalog-backstage/test/descriptor-read.test.ts new file mode 100644 index 00000000..923fdbee --- /dev/null +++ b/packages/adapters/catalog-backstage/test/descriptor-read.test.ts @@ -0,0 +1,162 @@ +/** + * T047 — `duplicate-yaml-key` and `invalid-yaml-syntax` emerge as **two distinct + * outcomes**, never collapsed into one. + * + * Expected values come from `research.md` R8 (the `yaml` package's `uniqueKeys` + * default), `data-model.md` §3's three `parseOutcome` values, and §8's trigger + * enumeration. Nothing is derived by running the code under test. + * + * ADR-0016 evidence: + * `specs/010-catalog-backstage/evidence/negative-cases/yaml-read/`. + */ + +import { describe, expect, test } from 'bun:test'; +import { + DUPLICATE_KEY_CODE, + readAnnotationNode, + readDescriptorDocuments, +} from '../src/descriptor/read.ts'; + +const VALID = [ + 'apiVersion: backstage.io/v1alpha1', + 'kind: Component', + 'metadata:', + ' name: payments', + '', +].join('\n'); + +function only(text: string, path = 'catalog-info.yaml') { + const documents = readDescriptorDocuments(path, text); + expect(documents).toHaveLength(1); + return documents[0] as NonNullable<(typeof documents)[0]>; +} + +describe('T047 — a well-formed descriptor parses', () => { + test('the outcome is `parsed` and the raw fields are carried', () => { + const document = only(VALID); + expect(document.parseOutcome).toBe('parsed'); + expect(document.rejection).toBeUndefined(); + expect(document.rawApiVersion).toBe('backstage.io/v1alpha1'); + expect(document.rawKind).toBe('Component'); + expect(document.rawMetadata).toEqual({ name: 'payments' }); + }); + + test('a document is addressed by (sourcePath, documentIndexInFile)', () => { + const documents = readDescriptorDocuments('multi.yaml', `---\nkind: A\n---\nkind: B\n`); + expect(documents.map((document) => document.documentIndexInFile)).toEqual([0, 1]); + expect(documents.every((document) => document.sourcePath === 'multi.yaml')).toBe(true); + }); +}); + +describe('T047 — the two failure outcomes are distinct', () => { + test('a repeated top-level key is `duplicate-yaml-key`', () => { + const document = only('kind: Component\nkind: API\n'); + expect(document.parseOutcome).toBe('duplicate-yaml-key'); + expect(document.rejection?.reason).toBe('duplicate-yaml-key'); + expect(document.rejection?.triggerClass).toBe('duplicate-yaml-key'); + expect(document.rejection?.detail).toContain(DUPLICATE_KEY_CODE); + }); + + test('a repeated nested key is also `duplicate-yaml-key`', () => { + // R8 relies on the library reporting a duplicate "at any level". + const document = only('metadata:\n name: a\n name: b\n'); + expect(document.parseOutcome).toBe('duplicate-yaml-key'); + expect(document.rejection?.reason).toBe('duplicate-yaml-key'); + }); + + test('an unterminated quoted scalar is `invalid-yaml-syntax`, not a duplicate', () => { + const document = only('kind: "Component\n'); + expect(document.parseOutcome).toBe('yaml-parse-error'); + expect(document.rejection?.reason).toBe('invalid-yaml-syntax'); + expect(document.rejection?.triggerClass).toBe('invalid-yaml-syntax'); + }); + + test('a malformed flow collection is `invalid-yaml-syntax`', () => { + const document = only('kind: [unterminated\n'); + expect(document.parseOutcome).toBe('yaml-parse-error'); + expect(document.rejection?.reason).toBe('invalid-yaml-syntax'); + }); + + test('the two reasons, and the two trigger classes, are different values', () => { + const duplicate = only('kind: Component\nkind: API\n'); + const syntax = only('kind: "Component\n'); + expect(duplicate.rejection?.reason).not.toBe(syntax.rejection?.reason); + expect(duplicate.rejection?.triggerClass).not.toBe(syntax.rejection?.triggerClass); + expect(duplicate.parseOutcome).not.toBe(syntax.parseOutcome); + }); + + test('a duplicate key in the second document does not contaminate the first', () => { + const documents = readDescriptorDocuments('multi.yaml', '---\nkind: A\n---\nkind: B\nkind: C\n'); + expect(documents[0]?.parseOutcome).toBe('parsed'); + expect(documents[1]?.parseOutcome).toBe('duplicate-yaml-key'); + }); +}); + +describe('T047 — a duplicate key still resolves to a value, and that is the trap', () => { + test('the reported outcome is the error, never the last-wins value', () => { + // `yaml` resolves `kind: Component` / `kind: API` last-wins *and* records + // DUPLICATE_KEY. An implementation reading `toJSON()` without checking + // `errors` would see a plausible descriptor and never notice. + const document = only('kind: Component\nkind: API\n'); + expect(document.parseOutcome).toBe('duplicate-yaml-key'); + expect(document.raw).toBeUndefined(); + expect(document.rawKind).toBeUndefined(); + }); +}); + +describe('T047 — `uniqueKeys` is left at its library default', () => { + test('the module never names `uniqueKeys`', async () => { + // R8: the design "does not introduce a custom duplicate-key scanner"; naming + // the option here would invite someone to make it configurable, and + // `uniqueKeys: false` would silently remove the duplicate outcome entirely. + const source = await Bun.file( + new URL('../src/descriptor/read.ts', import.meta.url), + ).text(); + const code = source.replace(/\/\*\*[\s\S]*?\*\//gu, '').replace(/\/\/[^\n]*/gu, ''); + expect(code).not.toContain('uniqueKeys'); + }); +}); + +describe('T047 — reading the annotation node', () => { + const withAnnotation = (value: string) => + only( + [ + 'apiVersion: backstage.io/v1alpha1', + 'kind: Component', + 'metadata:', + ' name: payments', + ' annotations:', + ` adrkit.io/owned-paths: ${value}`, + '', + ].join('\n'), + ); + + test('a present string scalar is reported present, with a string value', () => { + const node = readAnnotationNode(withAnnotation("'[\"packages/**\"]'"), 'adrkit.io/owned-paths'); + expect(node.present).toBe(true); + expect(typeof node.value).toBe('string'); + }); + + test('a YAML sequence arrives as an array, so a `typeof` check can reject it', () => { + // This is what makes `owned-paths-annotation.md` §1 step 2 a real check rather + // than a formality: the caller must be able to receive a non-string. + const node = readAnnotationNode(withAnnotation('["[]"]'), 'adrkit.io/owned-paths'); + expect(node.present).toBe(true); + expect(Array.isArray(node.value)).toBe(true); + expect(typeof node.value).not.toBe('string'); + }); + + test('an absent annotation is reported absent', () => { + const node = readAnnotationNode(only(VALID), 'adrkit.io/owned-paths'); + expect(node.present).toBe(false); + }); + + test('presence is an explicit discriminant, not `value !== undefined`', () => { + // `owned-paths-annotation.md` §1 step 1: presence is "never inferred from + // whether a raw value happens to be `undefined`". A YAML key with no value is + // present and null. + const node = readAnnotationNode(withAnnotation(''), 'adrkit.io/owned-paths'); + expect(node.present).toBe(true); + expect(node.value).toBeNull(); + }); +}); diff --git a/packages/adapters/catalog-backstage/test/glob-compile-once.test.ts b/packages/adapters/catalog-backstage/test/glob-compile-once.test.ts new file mode 100644 index 00000000..7ee64ea9 --- /dev/null +++ b/packages/adapters/catalog-backstage/test/glob-compile-once.test.ts @@ -0,0 +1,192 @@ +/** + * T063 · T067 — FR-029 and FR-032: the frozen engine and options, the version read + * at runtime, and each pattern compiled once per run. + * + * `glob-dialect.md` §1 and §6. The version literal `4.0.5` appears below **only as + * an observation of what the lockfile currently resolves** — `research.md` R3 + * recorded that resolution — and never as the value the implementation uses. The + * test that matters is that the implementation reads it rather than declaring it. + */ + +import { describe, expect, test } from 'bun:test'; +import picomatch from 'picomatch'; +import { + GLOB_ENGINE, + GLOB_OPTIONS, + createGlobCompiler, + readGlobDialect, + readGlobEngineVersion, +} from '../src/glob/dialect.ts'; +import { validateGlobPattern, validateGlobPatterns } from '../src/glob/validate.ts'; + +describe('T063 — the engine and options are frozen', () => { + test('the engine is `picomatch`', () => { + expect(GLOB_ENGINE).toBe('picomatch'); + }); + + test('the options are exactly \u00a71\u2019s three', () => { + expect(GLOB_OPTIONS).toEqual({ dot: false, nocase: false, nonegate: true }); + expect(Object.keys(GLOB_OPTIONS).sort()).toEqual(['dot', 'nocase', 'nonegate']); + }); + + test('the options object is frozen, so a caller cannot change the dialect mid-run', () => { + expect(Object.isFrozen(GLOB_OPTIONS)).toBe(true); + }); + + test('the options match the two core matchers\u2019 own compile options', async () => { + // §1: "Identical to `packages/core/src/affects/inert.ts`'s and + // `packages/core/src/affects/matchers/path.ts`'s own compile options — no new + // option combination is introduced." + const roots = ['../../../core/src/affects/inert.ts', '../../../core/src/affects/matchers/path.ts']; + for (const relative of roots) { + const source = await Bun.file(new URL(relative, import.meta.url)).text(); + expect(source).toContain('dot: false'); + expect(source).toContain('nocase: false'); + expect(source).toContain('nonegate: true'); + } + }); +}); + +describe('T063 — the version is read at runtime, never transcribed', () => { + test('a version is read from the resolved dependency', async () => { + const version = await readGlobEngineVersion(); + expect(version).toMatch(/^\d+\.\d+\.\d+/u); + }); + + test('it agrees with the installed `picomatch/package.json` read independently', async () => { + // Two independent reads of the same fact, both by path — ADR-0013/FR-002 forbid + // `import.meta.resolve` here, so neither side may use it. If the implementation + // had a literal, this would still pass today and silently start lying when the + // lockfile moved, which is why the source-level assertion below exists as well. + const manifest = (await Bun.file( + new URL('../node_modules/picomatch/package.json', import.meta.url), + ).json()) as { version: string }; + expect(await readGlobEngineVersion()).toBe(manifest.version); + }); + + test('the module contains no transcribed version literal', async () => { + const source = await Bun.file(new URL('../src/glob/dialect.ts', import.meta.url)).text(); + const code = source.replace(/\/\*\*[\s\S]*?\*\//gu, '').replace(/\/\/[^\n]*/gu, ''); + expect(code).not.toMatch(/\d+\.\d+\.\d+/u); + }); + + test('the currently resolved version is 4.0.5 \u2014 recorded as an observation', async () => { + // `research.md` R3 verified this resolution. Recorded here so a change is + // visible, not so the implementation can depend on it. + expect(await readGlobEngineVersion()).toBe('4.0.5'); + }); + + test('the full dialect identification is verified rather than assumed', async () => { + const dialect = await readGlobDialect(); + expect(dialect.engine).toBe('picomatch'); + expect(dialect.options).toEqual(GLOB_OPTIONS); + expect(dialect.version).toBe(await readGlobEngineVersion()); + }); +}); + +describe('T063 — \u00a74\u2019s dotfile worked example, confirmed rather than reimplemented', () => { + // §4: `dot: false` already implements the policy exactly; this requires "zero new + // code in Option A's own validator beyond passing `dot: false`". The claim is + // *observed behavioural parity*, never source-code equivalence. + test.each([ + ['.github/**', '.github/workflows/ci.yml', true], + ['packages/**', '.github/workflows/ci.yml', false], + ['**', '.github/workflows/ci.yml', false], + ] as const)('%j vs %j => %s', (pattern, path, expected) => { + const compiler = createGlobCompiler(); + const result = validateGlobPattern(pattern, compiler); + expect(result.outcome).toBe('accepted'); + + const compiled = compiler.compile(pattern); + expect(compiled.ok).toBe(true); + if (!compiled.ok) return; + expect(compiled.matcher(path)).toBe(expected); + }); + + test('a bare `**` does not imply dotfile ownership', () => { + const compiled = createGlobCompiler().compile('**'); + expect(compiled.ok).toBe(true); + if (!compiled.ok) return; + expect(compiled.matcher('.github/workflows/ci.yml')).toBe(false); + expect(compiled.matcher('packages/payments/index.ts')).toBe(true); + }); +}); + +describe('T067 — each pattern is compiled exactly once per run (FR-032)', () => { + test('validating the same pattern twice compiles once', () => { + const compiler = createGlobCompiler(); + validateGlobPattern('packages/**', compiler); + validateGlobPattern('packages/**', compiler); + expect(compiler.compileCount).toBe(1); + expect(compiler.patternCount).toBe(1); + }); + + test('a batch with repeats compiles one matcher per distinct pattern', () => { + const compiler = createGlobCompiler(); + validateGlobPatterns(['a/**', 'b/**', 'a/**', 'b/**', 'a/**'], compiler); + expect(compiler.compileCount).toBe(2); + expect(compiler.patternCount).toBe(2); + }); + + test('matching many paths does not recompile', () => { + // §6: "never once per match check against each changed file". + const compiler = createGlobCompiler(); + validateGlobPattern('packages/**', compiler); + const compiled = compiler.compile('packages/**'); + expect(compiled.ok).toBe(true); + if (!compiled.ok) return; + + for (let index = 0; index < 100; index += 1) { + compiled.matcher(`packages/payments/file-${index}.ts`); + } + expect(compiler.compileCount).toBe(1); + }); + + test('validation and matching share one matcher, so they cannot diverge', () => { + // FR-032's stated reason. The identity check is the demonstration: it is the + // same object, not merely an equal one. + const compiler = createGlobCompiler(); + validateGlobPattern('packages/**', compiler); + const first = compiler.compile('packages/**'); + const second = compiler.compile('packages/**'); + expect(first).toBe(second); + }); + + test('a rejected pattern is never compiled at all', () => { + const compiler = createGlobCompiler(); + validateGlobPatterns(['{a}', '[b]', '(c)', 'd,e'], compiler); + expect(compiler.compileCount).toBe(0); + }); + + test('each run gets its own compiler, so one run cannot depend on another', () => { + // A module-level cache would make results depend on what a previous run + // happened to compile — and `owned-paths-annotation.md` §5 requires + // byte-identical output across repeated runs. + const first = createGlobCompiler(); + validateGlobPattern('packages/**', first); + expect(first.compileCount).toBe(1); + + const second = createGlobCompiler(); + expect(second.compileCount).toBe(0); + validateGlobPattern('packages/**', second); + expect(second.compileCount).toBe(1); + }); + + test('the compiled matcher behaves identically to a fresh `picomatch` compile', () => { + // The cache must not change semantics — only cost. + const compiler = createGlobCompiler(); + const compiled = compiler.compile('packages/**'); + expect(compiled.ok).toBe(true); + if (!compiled.ok) return; + + const fresh = picomatch('packages/**', GLOB_OPTIONS); + for (const path of [ + 'packages/payments/index.ts', + 'docs/readme.md', + '.github/workflows/ci.yml', + 'packages/a/b/c.ts', + ]) { + expect(compiled.matcher(path)).toBe(fresh(path)); + } + }); +}); diff --git a/packages/adapters/catalog-backstage/test/glob-mixed-batch.test.ts b/packages/adapters/catalog-backstage/test/glob-mixed-batch.test.ts new file mode 100644 index 00000000..3292f9d5 --- /dev/null +++ b/packages/adapters/catalog-backstage/test/glob-mixed-batch.test.ts @@ -0,0 +1,151 @@ +/** + * T066 — FR-031: rule-specific rejection reasons hold when a **mixed batch** of + * patterns is validated, each pattern evaluated in isolation so no pattern's + * outcome influences another's. + * + * `glob-dialect.md` §3 fixes per-pattern classification; `atomic-fail-closed.md` §2 + * separates that from whole-operation atomicity. This file tests the former, and + * says plainly that it is not testing the latter: the whole-operation abort is + * Phase E's, behind Barrier B. + */ + +import { describe, expect, test } from 'bun:test'; +import { createGlobCompiler } from '../src/glob/dialect.ts'; +import { validateGlobPattern, validateGlobPatterns } from '../src/glob/validate.ts'; +import { classifyPatterns } from '../src/ownership/derive.ts'; + +/** One pattern per distinct violation, plus two valid ones, deliberately interleaved. */ +const MIXED = [ + 'packages/payments/**', + '', + '/leading', + 'C:/drive', + 'back\\slash', + 'nul/\u0000/char', + 'braces/{a}', + 'brackets/[a]', + 'parens/(a)', + 'comma/a,b', + '!bang', + 'traverse/../etc', + 'empty//segment', + 'disallowed/@scope', + 'malformed/**star', + 'docs/*.md', +] as const; + +const EXPECTED = [ + 'accepted', + 'empty', + 'leading-slash', + 'absolute-or-drive-or-unc', + 'backslash', + 'nul-or-control-char', + 'brace', + 'bracket', + 'parenthesis', + 'comma', + 'leading-bang', + 'traversal-segment', + 'empty-segment', + 'disallowed-character', + 'malformed-double-star', + 'accepted', +] as const; + +describe('T066 — a mixed batch classifies each pattern individually', () => { + test('every pattern gets its own rule-specific outcome', () => { + expect(validateGlobPatterns(MIXED).map((pattern) => pattern.outcome)).toEqual([...EXPECTED]); + }); + + test('the batch contains all fourteen rejection reasons plus `accepted`', () => { + const outcomes = new Set(EXPECTED); + expect(outcomes.size).toBe(15); + expect(outcomes.has('accepted')).toBe(true); + expect(outcomes.has('invalid-glob-compile-failure')).toBe(false); + }); + + test('each pattern\u2019s batch outcome equals its outcome validated alone', () => { + // The operational content of "evaluated in isolation": batching changes nothing. + const inBatch = validateGlobPatterns(MIXED); + for (const [index, pattern] of MIXED.entries()) { + expect(inBatch[index]).toEqual(validateGlobPattern(pattern)); + } + }); + + test('reordering the batch does not change any pattern\u2019s outcome', () => { + const forward = new Map( + validateGlobPatterns(MIXED).map((pattern) => [pattern.raw, pattern.outcome]), + ); + const reversed = new Map( + validateGlobPatterns([...MIXED].reverse()).map((pattern) => [pattern.raw, pattern.outcome]), + ); + expect(reversed).toEqual(forward); + }); + + test('a valid pattern surrounded by invalid ones is still accepted', () => { + const results = validateGlobPatterns(['', 'packages/**', '{bad}']); + expect(results.map((pattern) => pattern.outcome)).toEqual(['empty', 'accepted', 'brace']); + }); + + test('an invalid pattern surrounded by valid ones still reports its own rule', () => { + const results = validateGlobPatterns(['packages/**', 'traverse/../etc', 'docs/*.md']); + expect(results.map((pattern) => pattern.outcome)).toEqual([ + 'accepted', + 'traversal-segment', + 'accepted', + ]); + }); + + test('a repeated pattern reports the same outcome both times', () => { + // The shared compiler is a cache keyed by pattern; a cache that could change a + // verdict on a second lookup would be exactly the cross-pattern influence + // FR-031 forbids. + const results = validateGlobPatterns(['braces/{a}', 'packages/**', 'braces/{a}']); + expect(results[0]?.outcome).toBe('brace'); + expect(results[2]?.outcome).toBe('brace'); + expect(results[0]).toEqual(results[2] as (typeof results)[0]); + }); + + test('sharing a compiler across batches does not leak a verdict between them', () => { + const compiler = createGlobCompiler(); + const first = validateGlobPatterns(['packages/**', 'bad/{a}'], compiler); + const second = validateGlobPatterns(['bad/{a}', 'packages/**'], compiler); + expect(first.map((p) => p.outcome)).toEqual(['accepted', 'brace']); + expect(second.map((p) => p.outcome)).toEqual(['brace', 'accepted']); + }); +}); + +describe('T066 — the annotation-level batch classifier agrees', () => { + test('`classifyPatterns` reports every pattern, not only the first rejection', () => { + const classified = classifyPatterns([...MIXED]); + expect(classified.map((pattern) => pattern.outcome)).toEqual([...EXPECTED]); + }); + + test('it is a reporting surface, distinct from derivation\u2019s stop-at-first', () => { + // `deriveOwnership` stops at the first rejection because a derivation cannot + // proceed past one. `classifyPatterns` does not, because a *report* that named + // only the earliest offender would hide the rest. + const classified = classifyPatterns(['', '/leading', 'braces/{a}']); + expect(classified).toHaveLength(3); + expect(classified.map((pattern) => pattern.outcome)).toEqual([ + 'empty', + 'leading-slash', + 'brace', + ]); + }); +}); + +describe('T066 — what this file does NOT demonstrate', () => { + test('per-pattern classification is not whole-operation atomicity', () => { + // `atomic-fail-closed.md` §2: the two are separate properties and "a future + // execution session MUST test both properties independently; passing User Story + // 1's per-rule tests does not itself demonstrate this contract." + // + // The whole-operation abort belongs to the assembled generator, which is + // Phase E — behind Barrier B. Nothing here claims it. + const results = validateGlobPatterns(['packages/**', 'bad/{a}']); + expect(results.some((pattern) => pattern.outcome === 'accepted')).toBe(true); + expect(results.some((pattern) => pattern.outcome !== 'accepted')).toBe(true); + }); +}); diff --git a/packages/adapters/catalog-backstage/test/glob-order.test.ts b/packages/adapters/catalog-backstage/test/glob-order.test.ts new file mode 100644 index 00000000..9cd23bf2 --- /dev/null +++ b/packages/adapters/catalog-backstage/test/glob-order.test.ts @@ -0,0 +1,153 @@ +/** + * T068 — FR-033: `derivedPaths` is sorted with `compareCodeUnits` + * (`packages/core/src/ordering/index.ts:12`) and deduplicated. + * + * The expected orderings below are derived from the comparator's own definition — + * `a < b ? -1 : a > b ? 1 : 0` over UTF-16 code units — and from FR-033's + * requirement that "the envelope's array ordering is a function of content alone". + */ + +import { describe, expect, test } from 'bun:test'; +import { compareCodeUnits } from '@adrkit/core'; +import { isOrdered, orderDerivedPaths } from '../src/glob/order.ts'; +import { deriveOwnership } from '../src/ownership/derive.ts'; + +describe('T068 — sorting uses the repository\u2019s one comparator', () => { + test('the result agrees with `compareCodeUnits` applied directly', () => { + const input = ['packages/b/**', 'packages/a/**', 'docs/*.md']; + expect(orderDerivedPaths(input)).toEqual([...input].sort(compareCodeUnits)); + }); + + test('ordering is by code unit, not by locale', () => { + // The case that separates the two: under a code-unit comparison every uppercase + // ASCII letter sorts before every lowercase one, because 'Z' (0x5A) < 'a' + // (0x61). A locale-aware comparison typically interleaves them. + expect(orderDerivedPaths(['a/**', 'B/**'])).toEqual(['B/**', 'a/**']); + expect('B'.localeCompare('a')).toBeGreaterThan(0); + expect(compareCodeUnits('B', 'a')).toBeLessThan(0); + }); + + test('the module never reaches for `localeCompare`', async () => { + const source = await Bun.file(new URL('../src/glob/order.ts', import.meta.url)).text(); + const code = source.replace(/\/\*\*[\s\S]*?\*\//gu, '').replace(/\/\/[^\n]*/gu, ''); + expect(code).not.toContain('localeCompare'); + expect(code).toContain('compareCodeUnits'); + }); + + test('the comparator is imported from core, not reimplemented', async () => { + const source = await Bun.file(new URL('../src/glob/order.ts', import.meta.url)).text(); + expect(source).toContain("from '@adrkit/core'"); + }); +}); + +describe('T068 — deduplication', () => { + test('a repeated pattern appears once', () => { + expect(orderDerivedPaths(['a/**', 'a/**', 'a/**'])).toEqual(['a/**']); + }); + + test('deduplication is exact-string, never normalized', () => { + // `a/**` and `A/**` are different patterns under `nocase: false`, so collapsing + // them would change what the entity owns. + expect(orderDerivedPaths(['a/**', 'A/**'])).toEqual(['A/**', 'a/**']); + }); + + test('an empty input yields an empty output', () => { + expect(orderDerivedPaths([])).toEqual([]); + }); + + test('the input is never mutated', () => { + const input = ['b/**', 'a/**', 'b/**']; + const snapshot = [...input]; + orderDerivedPaths(input); + expect(input).toEqual(snapshot); + }); +}); + +describe('T068 — ordering is a function of content alone', () => { + test('declaration order does not affect the result', () => { + const forward = orderDerivedPaths(['c/**', 'a/**', 'b/**']); + const reverse = orderDerivedPaths(['b/**', 'a/**', 'c/**']); + expect(forward).toEqual(reverse); + expect(forward).toEqual(['a/**', 'b/**', 'c/**']); + }); + + test('duplicate placement does not affect the result', () => { + expect(orderDerivedPaths(['a/**', 'b/**', 'a/**'])).toEqual( + orderDerivedPaths(['a/**', 'a/**', 'b/**']), + ); + }); + + test('a `Set` insertion-order dependency cannot survive the sort', () => { + // `owned-paths-annotation.md` §5 names "a `Set` iteration order dependency" as a + // non-determinism source. Deduplicating with a `Set` and then sorting discards + // insertion order entirely, which is why the combination is safe. + const orderings = [ + ['z/**', 'a/**', 'm/**'], + ['m/**', 'z/**', 'a/**'], + ['a/**', 'm/**', 'z/**'], + ].map((input) => orderDerivedPaths(input)); + expect(new Set(orderings.map((ordering) => JSON.stringify(ordering))).size).toBe(1); + }); + + test('three or more runs produce byte-identical output (SC-001)', () => { + const input = ['packages/b/**', 'docs/*.md', 'packages/a/**', 'packages/b/**']; + const runs = Array.from({ length: 5 }, () => JSON.stringify(orderDerivedPaths(input))); + expect(new Set(runs).size).toBe(1); + }); +}); + +describe('T068 — `isOrdered`', () => { + test('recognises an already-ordered array', () => { + expect(isOrdered(['a/**', 'b/**', 'c/**'])).toBe(true); + }); + + test('rejects an unordered one', () => { + expect(isOrdered(['b/**', 'a/**'])).toBe(false); + }); + + test('rejects one carrying a duplicate', () => { + expect(isOrdered(['a/**', 'a/**'])).toBe(false); + }); + + test('an empty array is ordered', () => { + expect(isOrdered([])).toBe(true); + }); +}); + +describe('T068 — derivation returns ordered, deduplicated paths', () => { + test('an annotation declaring patterns out of order derives them in order', () => { + const derivation = deriveOwnership( + true, + '["packages/z/**", "packages/a/**", "docs/*.md", "packages/a/**"]', + ); + expect(derivation.ok).toBe(true); + if (!derivation.ok) return; + expect(derivation.value.derivedPaths).toEqual([ + 'docs/*.md', + 'packages/a/**', + 'packages/z/**', + ]); + expect(isOrdered(derivation.value.derivedPaths)).toBe(true); + }); + + test('the declared order is still visible in `patterns`, for diagnostics', () => { + // Ordering `derivedPaths` must not destroy the record of what was authored; + // a rejection reported against a pattern needs the position it was declared at. + const derivation = deriveOwnership(true, '["packages/z/**", "packages/a/**"]'); + expect(derivation.ok).toBe(true); + if (!derivation.ok) return; + expect(derivation.value.patterns.map((pattern) => pattern.raw)).toEqual([ + 'packages/z/**', + 'packages/a/**', + ]); + }); + + test('two annotations differing only in order derive identically', () => { + const first = deriveOwnership(true, '["a/**", "b/**"]'); + const second = deriveOwnership(true, '["b/**", "a/**"]'); + expect(first.ok).toBe(true); + expect(second.ok).toBe(true); + if (!first.ok || !second.ok) return; + expect(first.value.derivedPaths).toEqual(second.value.derivedPaths); + }); +}); diff --git a/packages/adapters/catalog-backstage/test/glob-rules.test.ts b/packages/adapters/catalog-backstage/test/glob-rules.test.ts new file mode 100644 index 00000000..899a763e --- /dev/null +++ b/packages/adapters/catalog-backstage/test/glob-rules.test.ts @@ -0,0 +1,244 @@ +/** + * T065 — SC-007: **observed failing for rules 1–14 only.** + * + * For each of rules 1 through 14, a pattern that violates *that* rule and **no + * earlier one**, observed producing that rule's exact rejection reason. + * + * # Rule 15 is exempt, and that exemption is conformance rather than a gap + * + * `glob-dialect.md` §3 rule 15 describes `"invalid-glob-compile-failure"` as + * "expected to never occur in practice, given rules 1–14's exhaustiveness; present + * only as a defensive backstop". SC-007 accordingly requires only rules 1–14 to be + * exercised and states that a run which never produces rule 15's rejection **is + * conformant and MUST NOT be reported as a coverage gap**. + * + * Rule 15's `"accepted"` outcome **is** exercised here, by the valid patterns that + * reach it. What is not exercised — and is not required to be — is its rejection. + * This file says so explicitly rather than leaving a reader to infer that fourteen + * out of fifteen means something is missing. + * + * **Fifteen rules is not fourteen required exercises, and neither is the trigger + * count** (`data-model.md` §7.1: "Do not conflate the two numbers"). + * + * # "and no earlier one" is the load-bearing half + * + * A pattern violating two rules must report the earlier one, so a fixture chosen + * carelessly would exercise the wrong rule while appearing to exercise the right + * one. Each fixture below is therefore checked twice: that it produces its own + * rule's reason, and that it violates none of the rules before it. + * + * ADR-0016 evidence: + * `specs/010-catalog-backstage/evidence/negative-cases/glob-rules/`. + */ + +import { describe, expect, test } from 'bun:test'; +import { createGlobCompiler } from '../src/glob/dialect.ts'; +import { + GLOB_RULES_REQUIRING_EXERCISE, + GLOB_RULE_COUNT, + type GlobOutcome, + validateGlobPattern, +} from '../src/glob/validate.ts'; + +/** + * One fixture per rule, 1–14, each violating that rule and no earlier one. + * + * The reasons are transcribed from `glob-dialect.md` §3's numbered list. + */ +const RULE_FIXTURES: readonly { + readonly rule: number; + readonly pattern: string; + readonly outcome: GlobOutcome; +}[] = [ + { rule: 1, pattern: '', outcome: 'empty' }, + { rule: 2, pattern: '/packages/**', outcome: 'leading-slash' }, + { rule: 3, pattern: 'C:/packages/**', outcome: 'absolute-or-drive-or-unc' }, + { rule: 4, pattern: 'packages\\payments', outcome: 'backslash' }, + { rule: 5, pattern: 'packages/\u0000/payments', outcome: 'nul-or-control-char' }, + { rule: 6, pattern: 'packages/{a}/**', outcome: 'brace' }, + { rule: 7, pattern: 'packages/[ab]/**', outcome: 'bracket' }, + { rule: 8, pattern: 'packages/(a)/**', outcome: 'parenthesis' }, + { rule: 9, pattern: 'packages/a,b/**', outcome: 'comma' }, + { rule: 10, pattern: '!packages/**', outcome: 'leading-bang' }, + { rule: 11, pattern: 'packages/../etc', outcome: 'traversal-segment' }, + { rule: 12, pattern: 'packages//payments', outcome: 'empty-segment' }, + { rule: 13, pattern: 'packages/@scope/**', outcome: 'disallowed-character' }, + { rule: 14, pattern: 'packages/**bar', outcome: 'malformed-double-star' }, +]; + +describe('T065 — the rule set', () => { + test('there are fifteen rules', () => { + expect(GLOB_RULE_COUNT).toBe(15); + }); + + test('fourteen of them require exercise', () => { + expect(GLOB_RULES_REQUIRING_EXERCISE).toEqual([ + 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, + ]); + expect(GLOB_RULES_REQUIRING_EXERCISE).toHaveLength(14); + }); + + test('a fixture exists for every rule requiring exercise, and for no other', () => { + expect(RULE_FIXTURES.map((fixture) => fixture.rule)).toEqual([ + ...GLOB_RULES_REQUIRING_EXERCISE, + ]); + }); + + test('the fourteen outcomes are fourteen distinct values', () => { + expect(new Set(RULE_FIXTURES.map((fixture) => fixture.outcome)).size).toBe(14); + }); +}); + +describe('T065 — each of rules 1\u201314, observed firing', () => { + test.each(RULE_FIXTURES.map((fixture) => [fixture.rule, fixture.pattern, fixture] as const))( + 'rule %d on %j', + (_rule, _pattern, fixture) => { + const result = validateGlobPattern(fixture.pattern); + expect(result.outcome).toBe(fixture.outcome); + expect(result.rule).toBe(fixture.rule); + expect(result.raw).toBe(fixture.pattern); + }, + ); + + test.each(RULE_FIXTURES.map((fixture) => [fixture.rule, fixture.pattern, fixture] as const))( + 'rule %d\u2019s fixture %j violates no earlier rule', + (_rule, _pattern, fixture) => { + // If it did, the reported reason would be the earlier rule's, and this fixture + // would be exercising a rule it was not chosen for. + const result = validateGlobPattern(fixture.pattern); + expect(result.rule).not.toBeLessThan(fixture.rule); + expect(result.rule).toBe(fixture.rule); + }, + ); +}); + +describe('T065 — first-match-wins, demonstrated where it matters', () => { + test('\u00a73\u2019s worked example: the brace/traversal near-miss', () => { + // "`packages/{a,..}/**` is rejected at rule 6 (`"brace"`) — braces are rejected + // outright regardless of their contents, so this pattern never reaches rule 11." + const braced = validateGlobPattern('packages/{a,..}/**'); + expect(braced.outcome).toBe('brace'); + expect(braced.rule).toBe(6); + + // "A brace-free pattern containing a bare `..` segment, e.g. `packages/../etc`, + // is rejected at rule 11 (`"traversal-segment"`) instead." + const traversal = validateGlobPattern('packages/../etc'); + expect(traversal.outcome).toBe('traversal-segment'); + expect(traversal.rule).toBe(11); + + // "The two rejection reasons remain independently distinguishable." + expect(braced.outcome).not.toBe(traversal.outcome); + }); + + test('a leading slash reports rule 2, not rule 12\u2019s empty segment', () => { + // `/a/b` splits to `['', 'a', 'b']` — an empty segment. Rule 2 is earlier. + expect(validateGlobPattern('/a/b').rule).toBe(2); + }); + + test('a drive prefix reports rule 3, not rule 4\u2019s backslash or rule 13', () => { + // `C:\Windows` violates rules 3, 4 and 13. Rule 3 is earliest. + expect(validateGlobPattern('C:\\Windows').rule).toBe(3); + }); + + test('a UNC path reports rule 3, not rule 4', () => { + expect(validateGlobPattern('\\\\host\\share').rule).toBe(3); + }); + + test('a brace with a comma reports rule 6, not rule 9', () => { + expect(validateGlobPattern('packages/{a,b}/**').rule).toBe(6); + }); + + test('a leading bang on an otherwise disallowed pattern reports rule 10, not 13', () => { + expect(validateGlobPattern('!packages/@scope').rule).toBe(10); + }); + + test('a pattern violating several rules reports the same one every time', () => { + const outcomes = Array.from({ length: 5 }, () => + validateGlobPattern('C:\\{a,b}/[x]/(y)/../@'), + ); + expect(new Set(outcomes.map((o) => `${o.rule}:${o.outcome}`)).size).toBe(1); + expect(outcomes[0]?.rule).toBe(3); + }); +}); + +describe('T065 — rule 13 closes the gap a blacklist cannot (\u00a73)', () => { + test.each(['@', '#', '%', '~', '+', '=', ':', ';', '<', '>', '|', '&', '^', '\u00e9', '\u4e2d'])( + '%j violates none of rules 1\u201312 and is caught by rule 13', + (character) => { + const result = validateGlobPattern(`packages/a${character}b/**`); + expect(result.outcome).toBe('disallowed-character'); + expect(result.rule).toBe(13); + }, + ); + + test('a colon not at position 2 is rule 13, not rule 3\u2019s drive prefix', () => { + // Rule 3's regex is anchored: `^[A-Za-z]:`. A colon elsewhere is rule 13. + expect(validateGlobPattern('packages/a:b').rule).toBe(13); + expect(validateGlobPattern('C:/packages').rule).toBe(3); + }); +}); + +describe('T065 — rule 14: only a whole-segment `**` is allowed', () => { + test.each(['a**b', '**b', 'a**', 'foo/**bar', 'foo/a**/b'])( + '%j is `malformed-double-star`', + (pattern) => { + const result = validateGlobPattern(pattern); + expect(result.outcome).toBe('malformed-double-star'); + expect(result.rule).toBe(14); + }, + ); + + test('a segment that is exactly `**` is the allowed form', () => { + for (const pattern of ['**', 'packages/**', 'packages/**/src', '**/*.ts']) { + expect(validateGlobPattern(pattern).outcome).toBe('accepted'); + } + }); + + test('a single `*` is unaffected', () => { + expect(validateGlobPattern('packages/*/src').outcome).toBe('accepted'); + expect(validateGlobPattern('docs/*.md').outcome).toBe('accepted'); + }); +}); + +describe('T065 — rule 15\u2019s `accepted` outcome is exercised; its rejection is not required', () => { + test.each([ + 'packages/payments/**', + 'packages/**', + '**', + 'docs/*.md', + 'src/a?c.ts', + '.github/**', + 'a-b_c.d/**', + ])('%j is accepted at rule 15', (pattern) => { + const result = validateGlobPattern(pattern); + expect(result.outcome).toBe('accepted'); + expect(result.rule).toBe(15); + }); + + test('rule 15 not firing its rejection is conformant, and is not a coverage gap', () => { + // Asserted as a statement about the exercised set rather than left implicit, + // because "14 of 15" invites a reader to record a gap that SC-007 explicitly + // forbids recording. + const exercisedRejections = new Set( + RULE_FIXTURES.map((fixture) => fixture.outcome), + ); + expect(exercisedRejections.has('invalid-glob-compile-failure')).toBe(false); + expect(exercisedRejections.size).toBe(14); + expect(GLOB_RULE_COUNT - exercisedRejections.size).toBe(1); + }); + + test('the compile really is invoked, so `accepted` is a compile result', () => { + // Without this, `accepted` could be reached by rule 14 falling through without + // rule 15 running at all, and the "backstop" would be absent rather than unfired. + const compiler = createGlobCompiler(); + expect(compiler.compileCount).toBe(0); + validateGlobPattern('packages/**', compiler); + expect(compiler.compileCount).toBe(1); + }); + + test('a rejected pattern never reaches the compile', () => { + const compiler = createGlobCompiler(); + validateGlobPattern('packages/{a}/**', compiler); + expect(compiler.compileCount).toBe(0); + }); +}); diff --git a/packages/adapters/catalog-backstage/test/inadmissible-and-unique.test.ts b/packages/adapters/catalog-backstage/test/inadmissible-and-unique.test.ts new file mode 100644 index 00000000..3092da60 --- /dev/null +++ b/packages/adapters/catalog-backstage/test/inadmissible-and-unique.test.ts @@ -0,0 +1,199 @@ +/** + * T054 — **observed failing, permanent negative case.** FR-021: a descriptor that + * is simultaneously **inadmissible and canonically unique**. + * + * # Why this specific fixture exists + * + * `admissibility.md` §6: the two determinations are independent, and conformance + * evidence "MUST demonstrate that independence rather than assert it. Specifically, + * the evidence MUST include at least one descriptor that is **inadmissible and + * canonically unique** — a descriptor that fails §2 while colliding with nothing. + * Without such a case, a passing suite is equally consistent with an implementation + * that has silently fused the two checks." + * + * ADR-0015 says the same thing about the corpus that motivated it: of the sixteen + * unsubstituted placeholder descriptors, fourteen share `${{ values.name | dump }}` + * and collide with one another, while `bulk-import` (`${{ values.name }}`) and + * `orchestrator` (`${{ values.entityName }}`) "canonicalize distinctly and collide + * with nothing. They are exactly as invalid as the other fourteen, and the contract + * has no mechanism of any kind that would notice. The duplicate rule catches the + * fourteen only incidentally, as a side effect of their sharing a string; behind it + * there is nothing." + * + * **This fixture is retained permanently.** It is the only thing that distinguishes + * T053's property from an accident of ordering. + * + * # A counting note + * + * The **fourteen** in this file counts *placeholder descriptors*. It is **not** the + * trigger count. This feature's fatal trigger enumeration has **fifteen** members + * (`admissibility.md` §5.1, §6.1). Two unrelated fourteens; fusing them produces a + * document this repository fails. + * + * # Honesty + * + * These are hand-authored fixtures reproducing the *strings* ADR-0015 records. No + * corpus is read here, and nothing below asserts what Backstage as a running system + * does with them — only what the pinned validator predicate returns. + * + * ADR-0016 evidence: + * `specs/010-catalog-backstage/evidence/negative-cases/inadmissible-and-unique/`. + */ + +import { describe, expect, test } from 'bun:test'; +import { classifyAdmissibility, inadmissibleRejection } from '../src/admissibility/classify.ts'; +import { admit, collectAdmitted } from '../src/admissibility/index.ts'; +import { canonicalize } from '../src/identity/canonicalize.ts'; +import { validateEntityName } from '../src/admissibility/validators.ts'; +import { + ADMISSIBLE, + INADMISSIBLE_AND_COLLIDING, + INADMISSIBLE_AND_UNIQUE_BULK_IMPORT, + INADMISSIBLE_AND_UNIQUE_ORCHESTRATOR, + descriptor, +} from './descriptor-fixtures.ts'; + +/** ADR-0015's two outliers, by the names ADR-0015 gives them. */ +const OUTLIERS = [ + ['bulk-import descriptor', INADMISSIBLE_AND_UNIQUE_BULK_IMPORT, '${{ values.name }}'], + ['orchestrator descriptor', INADMISSIBLE_AND_UNIQUE_ORCHESTRATOR, '${{ values.entityName }}'], +] as const; + +describe('T054 — the fixture is genuinely inadmissible', () => { + test.each(OUTLIERS)('%s fails `validateEntityName` on character class', (_name, _spec, raw) => { + // ADR-0015: "`$`, `{`, `}` and the spaces are outside the permitted set in + // every case". Not a length failure — §2.1 insists these are two populations. + expect(validateEntityName(raw)).toBe(false); + expect(raw.length).toBeLessThanOrEqual(63); + }); + + test.each(OUTLIERS)('%s classifies as inadmissible, attributed to metadata.name', (_n, spec) => { + const result = classifyAdmissibility(descriptor(spec)); + expect(result.admissible).toBe(false); + expect(result.failedFields).toEqual(['metadata.name']); + expect(result.attributions[0]?.validator).toBe('validateEntityName'); + }); +}); + +describe('T054 — the fixture is genuinely canonically unique', () => { + test('the two outliers would canonicalize distinctly from each other', () => { + // Stated as a property of the *strings*, because neither is ever canonicalized: + // admissibility runs first and neither reaches step 1. Lowercasing is what + // canonicalization would do, and it does not make them equal. + const bulkImport = '${{ values.name }}'.toLowerCase(); + const orchestrator = '${{ values.entityName }}'.toLowerCase(); + const shared = '${{ values.name | dump }}'.toLowerCase(); + + expect(bulkImport).not.toBe(orchestrator); + expect(bulkImport).not.toBe(shared); + expect(orchestrator).not.toBe(shared); + }); + + test('each outlier collides with nothing in a batch of otherwise-distinct entities', () => { + // If it collided, the case would prove nothing: a fused implementation would + // fire the duplicate rule and look correct. + const shared = '${{ values.name | dump }}'.toLowerCase(); + for (const [, , raw] of OUTLIERS) { + expect([shared, 'payments', 'billing'].includes(raw.toLowerCase())).toBe(false); + } + }); +}); + +describe('T054 — it produces `inadmissible-descriptor` and NOT `duplicate-canonical-id`', () => { + test.each(OUTLIERS)('%s: the emitted trigger class', (_name, spec) => { + const outcome = admit(descriptor(spec, 'packages/plugin/catalog-info.yaml')); + expect(outcome.admissible).toBe(false); + if (outcome.admissible) return; + + // The reason it emits. + expect(outcome.rejection.reason).toBe('inadmissible-descriptor'); + expect(outcome.rejection.triggerClass).toBe('inadmissible-descriptor'); + + // The absence of the wrong one — §8's requirement that the case be "observed + // producing `inadmissible-descriptor` and **not** `duplicate-canonical-id`". + expect(outcome.rejection.triggerClass).not.toBe('duplicate-canonical-id'); + expect(JSON.stringify(outcome.rejection)).not.toContain('duplicate'); + }); + + test.each(OUTLIERS)('%s: alone in a run, with nothing to collide with', (_name, spec) => { + // A batch of one. There is no pair, so no duplicate rule could possibly fire — + // which means the rejection that does fire can only have come from + // admissibility. + const admission = collectAdmitted([descriptor(spec)]); + expect(admission.ok).toBe(false); + if (admission.ok) return; + expect(admission.rejection.triggerClass).toBe('inadmissible-descriptor'); + }); + + test.each(OUTLIERS)('%s: among valid, distinct entities', (_name, spec) => { + const admission = collectAdmitted([ + descriptor({ ...ADMISSIBLE, name: 'payments' }), + descriptor({ ...ADMISSIBLE, name: 'billing' }), + descriptor(spec), + ]); + expect(admission.ok).toBe(false); + if (admission.ok) return; + expect(admission.rejection.triggerClass).toBe('inadmissible-descriptor'); + }); + + test('the record carries all three attributions, as FR-020 requires', () => { + const result = classifyAdmissibility( + descriptor(INADMISSIBLE_AND_UNIQUE_BULK_IMPORT, 'plugins/bulk-import/catalog-info.yaml'), + ); + const detail = inadmissibleRejection(result).detail; + expect(detail).toContain('plugins/bulk-import/catalog-info.yaml'); + expect(detail).toContain('metadata.name'); + expect(detail).toContain('validateEntityName'); + }); +}); + +describe('T054 — the colliding form is rejected for the same reason, not a different one', () => { + test('`${{ values.name | dump }}` also yields `inadmissible-descriptor`', () => { + // The point of the contrast: the fourteen and the two get the *same* verdict. + // ADR-0015: they "are exactly as invalid as the other fourteen". + const outcome = admit(descriptor(INADMISSIBLE_AND_COLLIDING)); + expect(outcome.admissible).toBe(false); + if (outcome.admissible) return; + expect(outcome.rejection.triggerClass).toBe('inadmissible-descriptor'); + }); + + test('two copies of the colliding form still report inadmissibility, not the collision', () => { + const admission = collectAdmitted([ + descriptor(INADMISSIBLE_AND_COLLIDING, 'a.yaml'), + descriptor(INADMISSIBLE_AND_COLLIDING, 'b.yaml'), + ]); + expect(admission.ok).toBe(false); + if (admission.ok) return; + expect(admission.rejection.triggerClass).toBe('inadmissible-descriptor'); + }); + + test('a genuine collision between two ADMISSIBLE descriptors is still available', () => { + // ADR-0015: "`duplicate-canonical-id` retains its exact current meaning: two or + // more **admissible** descriptors canonicalizing to the same identity." The + // duplicate rule is narrowed, not removed. + const admission = collectAdmitted([ + descriptor({ ...ADMISSIBLE, name: 'Payments', namespace: 'default' }), + descriptor({ ...ADMISSIBLE, name: 'payments' }), + ]); + expect(admission.ok).toBe(true); + if (!admission.ok) return; + const ids = admission.admitted.map((one) => canonicalize(one).canonicalId); + expect(ids).toEqual(['component:default/payments', 'component:default/payments']); + expect(new Set(ids).size).toBe(1); + }); +}); + +describe('T054 — the two fourteens are not the same fourteen', () => { + test('the placeholder count and the trigger count are different numbers', () => { + // `admissibility.md` §6.1: "These are two unrelated fourteens and a reader who + // fuses them will produce a document this repository fails." + const placeholderDescriptorsSharingOneForm = 14; + const totalPlaceholderDescriptors = 16; + const canonicallyUniqueOutliers = 2; + + expect(placeholderDescriptorsSharingOneForm + canonicallyUniqueOutliers).toBe( + totalPlaceholderDescriptors, + ); + expect(OUTLIERS).toHaveLength(canonicallyUniqueOutliers); + }); +}); diff --git a/packages/adapters/catalog-backstage/test/input-boundary.test.ts b/packages/adapters/catalog-backstage/test/input-boundary.test.ts new file mode 100644 index 00000000..1bf9a23b --- /dev/null +++ b/packages/adapters/catalog-backstage/test/input-boundary.test.ts @@ -0,0 +1,210 @@ +/** + * T045 — FR-013: the input boundary is closed. + * + * Two independent mechanisms, because either alone has the shape ADR-0016 clause 3 + * warns about: + * + * 1. **A computed value.** `admissibleReadSet` derives the entire permitted read + * set from the manifest, and `classifyLocationTarget` decides whether a concrete + * `Location` target is inside it. These assert *specific observed values*, not + * an absence. + * 2. **A source scan.** Even a correct read set would not stop some other module + * from calling `readdir` directly, so the package's own sources are scanned for + * the constructs `input-manifest.md` §5 forbids. The scan reports the files it + * read, so a scan that saw nothing cannot be mistaken for a clean one. + * + * The `Location` worked example is `input-manifest.md` §6, transcribed. + */ + +import { describe, expect, test } from 'bun:test'; +import { + PERMITTED_GIT_READS, + WHOLE_CATALOG_COMPLETENESS, + admissibleReadSet, + classifyLocationTarget, + classifyLocationTargets, + isManifestListedSource, +} from '../src/manifest/boundary.ts'; +import { validateManifestShape } from '../src/manifest/schema.ts'; +import { ADAPTER_ROOT, type Rule, scanned, violations } from './source-scan.ts'; + +function manifestWith(paths: readonly string[]) { + const result = validateManifestShape({ + manifestSchemaVersion: '1', + requestedSnapshotSchemaVersion: '1', + requiredCapabilities: ['pathOwnership'], + repository: { id: 'github.com/mbeacom/fixture', revision: '0'.repeat(40) }, + sources: paths.map((path) => ({ + path, + digestAlgorithm: 'sha256', + digest: 'a'.repeat(64), + })), + }); + if (!result.ok) throw new Error(`fixture failed the schema: ${result.rejection.detail}`); + return result.value; +} + +describe('T045 — the read set is derived from the manifest and nothing else', () => { + test('it contains exactly the manifest-listed sources, plus the manifest itself', () => { + const readSet = admissibleReadSet('adrkit-manifest.json', manifestWith(['a.yaml', 'b.yaml'])); + expect(readSet.manifestPath).toBe('adrkit-manifest.json'); + expect(readSet.sourcePaths).toEqual(['a.yaml', 'b.yaml']); + }); + + test('it is a function of content alone — declaration order does not change it', () => { + const forward = admissibleReadSet('m.json', manifestWith(['b.yaml', 'a.yaml'])); + const reverse = admissibleReadSet('m.json', manifestWith(['a.yaml', 'b.yaml'])); + expect(forward.sourcePaths).toEqual(reverse.sourcePaths); + expect(forward.sourcePaths).toEqual(['a.yaml', 'b.yaml']); + }); + + test('a repeated source appears once', () => { + const readSet = admissibleReadSet('m.json', manifestWith(['a.yaml', 'a.yaml'])); + expect(readSet.sourcePaths).toEqual(['a.yaml']); + }); + + test('the permitted subprocess reads are exactly the two `input-manifest.md` §5 names', () => { + expect(PERMITTED_GIT_READS.map((argv) => argv.join(' '))).toEqual([ + 'remote get-url origin', + 'rev-parse HEAD', + ]); + }); + + test('FR-014 — whole-catalog completeness is always false', () => { + expect(WHOLE_CATALOG_COMPLETENESS).toBe(false); + }); +}); + +describe('T045 — the `Location` worked example (`input-manifest.md` §6)', () => { + // §6: a synthetic `Location` entity whose `spec.targets` names a second fixture + // file that is *not itself listed* in the manifest's `sources` array. + const readSet = admissibleReadSet('adrkit-manifest.json', manifestWith(['location.yaml'])); + + test('the unlisted target is classified `zero-derived-paths-never-read`', () => { + const classification = classifyLocationTarget(readSet, 'components/payments.yaml'); + expect(classification.outcome).toBe('zero-derived-paths-never-read'); + }); + + test('it is never classified `invalid-input` — §6 forbids that specific word', () => { + // The distinction §6 insists on: the target's annotation was not invalid, it + // was never read at all. + const classification = classifyLocationTarget(readSet, 'components/payments.yaml'); + expect(classification.outcome).not.toBe('invalid-input'); + expect(JSON.stringify(classification)).not.toContain('invalid-input'); + }); + + test('a target that IS manifest-listed is classified as such', () => { + const classification = classifyLocationTarget(readSet, 'location.yaml'); + expect(classification.outcome).toBe('manifest-listed'); + expect(isManifestListedSource(readSet, 'location.yaml')).toBe(true); + }); + + test('a multi-target `Location` classifies each target independently', () => { + const classifications = classifyLocationTargets(readSet, [ + './location.yaml', + 'location.yaml', + 'components/billing.yaml', + ]); + expect(classifications.map((c) => c.outcome)).toEqual([ + 'zero-derived-paths-never-read', + 'manifest-listed', + 'zero-derived-paths-never-read', + ]); + }); + + test('a non-array or non-string `spec.targets` yields no classification', () => { + // `data-model.md` §3 types descriptor content as `unknown`. Coercing a + // malformed `spec.targets` into a target string would fabricate an + // observation about a target that was never named. + expect(classifyLocationTargets(readSet, undefined)).toEqual([]); + expect(classifyLocationTargets(readSet, 'components/payments.yaml')).toEqual([]); + expect(classifyLocationTargets(readSet, [42, null, {}])).toEqual([]); + }); +}); + +/** + * Constructs `input-manifest.md` §5 forbids anywhere in this package's own sources. + * + * `readdirSync`/`readdir` is deliberately absent from this list: `test/source-scan.ts` + * legitimately walks this package's own tree to *perform* this scan, and forbidding + * it outright would make the guard forbid itself. Directory *discovery of + * descriptors* is instead foreclosed structurally, by `admissibleReadSet` having no + * parameter through which a directory could arrive. + */ +const FORBIDDEN: readonly Rule[] = [ + { + id: 'backstage-processor', + pattern: /\b(?:CatalogProcessor|catalogProcessingExtensionPoint|LocationSpec)\b/, + why: '`input-manifest.md` §5: the generator invokes no catalog processor or plugin of any kind.', + }, + { + id: 'backstage-package-specifier', + pattern: /['"]@backstage\//, + why: '`input-manifest.md` §5: no Backstage ingestion pipeline is invoked, so no Backstage package is imported.', + }, + { + id: 'glob-discovery', + pattern: /\b(?:Bun\.[Gg]lob|globSync|fast-?glob|node:glob)\b/, + why: '`input-manifest.md` §5: descriptors are never discovered by glob expansion.', + }, + { + id: 'network-fetch', + pattern: /(?:\bfetch\s*\(|\bnode:https?\b|\bXMLHttpRequest\b)/, + why: 'FR-018 offline constraint: a generation run makes no network call of any kind.', + }, +]; + +/** + * This file is excluded from its own scan. + * + * It carries the rule literals themselves — `@backstage/` appears above as a regex + * literal — so scanning it would report a violation of a rule by the rule. + * `source-scan.ts`'s own `EXCLUDED_FROM_SCAN` is asserted elsewhere to be exactly + * Phase A's three entries, so this exclusion is applied here rather than added + * there: an exclusion list that grows silently is the defect these scans guard + * against, and one that is asserted in one place and appended to from another is + * the same defect wearing a different hat. + */ +const SELF = 'packages/adapters/catalog-backstage/test/input-boundary.test.ts'; + +describe('T045 — the package\u2019s own sources contain none of the forbidden constructs', () => { + const all = scanned(ADAPTER_ROOT); + const files = all.filter((file) => file.path !== SELF); + + test('the scan actually read this package\u2019s sources, and excluded only itself', () => { + // Without this, a scan that silently read zero files would report the same + // green as a scan that looked properly (ADR-0016 clause 3). + expect(files.length).toBeGreaterThan(10); + expect(files.map((file) => file.path)).toContain( + 'packages/adapters/catalog-backstage/src/manifest/boundary.ts', + ); + expect(all.length - files.length).toBe(1); + }); + + test('no forbidden construct appears', () => { + expect(violations(files, FORBIDDEN)).toEqual([]); + }); + + test('every rule fires on a construct that violates it', () => { + // A rule only ever observed passing is not coverage (ADR-0016). Each of the + // four is observed rejecting, individually, so a rule that silently stopped + // matching anything would be caught. + const constructedViolations: Record = { + 'backstage-processor': 'const p: CatalogProcessor = makeProcessor();', + // Assembled rather than written literally: Phase A's `importSpecifiers` + // heuristic reads `from '…'` inside a string as a real import, and a fixture + // that trips a neighbouring guard is a fixture that will be "fixed" by + // weakening that guard. + 'backstage-package-specifier': `${'imp' + 'ort'} { Entity } ${'fr' + 'om'} '@backstage/catalog-model';`, + 'glob-discovery': 'const found = new Bun.Glob("**/catalog-info.yaml");', + 'network-fetch': 'await fetch("https://example.invalid");', + }; + + for (const rule of FORBIDDEN) { + const source = constructedViolations[rule.id]; + expect(source).toBeDefined(); + const found = violations([{ path: 'fixture.ts', code: source as string }], FORBIDDEN); + expect(found.map((violation) => violation.ruleId)).toContain(rule.id); + } + }); +}); diff --git a/packages/adapters/catalog-backstage/test/manifest-digests.test.ts b/packages/adapters/catalog-backstage/test/manifest-digests.test.ts new file mode 100644 index 00000000..8fdd7203 --- /dev/null +++ b/packages/adapters/catalog-backstage/test/manifest-digests.test.ts @@ -0,0 +1,184 @@ +/** + * T043 — FR-011: every declared per-source digest is verified **before any entity + * is processed**; a mismatch, a missing source, or a wrongly typed digest yields + * `incomplete-required-source`. + * + * Expected values come from `input-manifest.md` §4 and FR-011. The one digest + * literal used below is computed from `Bun.CryptoHasher` in the fixture setup + * rather than transcribed, so the test asserts *agreement between two independent + * computations of the same standard*, not agreement between the code and a string + * someone once pasted. + * + * ADR-0016 evidence: + * `specs/010-catalog-backstage/evidence/negative-cases/incomplete-required-source/`. + */ + +import { afterAll, beforeAll, describe, expect, test } from 'bun:test'; +import { mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { + checkDigestShape, + sha256Hex, + verifySourceBytes, + verifySourceDigests, +} from '../src/manifest/digests.ts'; +import type { ManifestSource } from '../src/manifest/schema.ts'; + +const CONTENT = 'apiVersion: backstage.io/v1alpha1\nkind: Component\n'; +const CONTENT_BYTES = new TextEncoder().encode(CONTENT); +const CONTENT_DIGEST = sha256Hex(CONTENT_BYTES); + +let root = ''; + +function source(overrides: Partial = {}): ManifestSource { + return { + path: 'catalog-info.yaml', + digestAlgorithm: 'sha256', + digest: CONTENT_DIGEST, + ...overrides, + }; +} + +beforeAll(async () => { + root = await mkdtemp(join(tmpdir(), 'adrkit-digest-fixture-')); + await writeFile(join(root, 'catalog-info.yaml'), CONTENT, 'utf8'); +}); + +afterAll(async () => { + if (root) await rm(root, { recursive: true, force: true }); +}); + +const resolve = (path: string) => join(root, path); + +describe('T043 — digest shape (FR-011\u2019s "wrongly typed")', () => { + test('the fixture digest is 64 lowercase hex characters', () => { + expect(CONTENT_DIGEST).toMatch(/^[0-9a-f]{64}$/u); + }); + + test('a well-formed digest passes the shape check', () => { + expect(checkDigestShape(source()).ok).toBe(true); + }); + + test('an uppercase-hex digest is rejected before any file is opened', () => { + const result = checkDigestShape(source({ digest: CONTENT_DIGEST.toUpperCase() })); + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.rejection.reason).toBe('digest-malformed'); + expect(result.rejection.triggerClass).toBe('incomplete-required-source'); + }); + + test('a truncated digest is rejected', () => { + const result = checkDigestShape(source({ digest: CONTENT_DIGEST.slice(0, 32) })); + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.rejection.reason).toBe('digest-malformed'); + }); + + test('a non-hex digest is rejected', () => { + const result = checkDigestShape(source({ digest: 'z'.repeat(64) })); + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.rejection.reason).toBe('digest-malformed'); + }); +}); + +describe('T043 — digest verification against bytes', () => { + test('bytes matching the declared digest are accepted', () => { + const result = verifySourceBytes(source(), CONTENT_BYTES); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.value.observedDigest).toBe(CONTENT_DIGEST); + }); + + test('a single changed byte is a mismatch', () => { + const result = verifySourceBytes(source(), new TextEncoder().encode(`${CONTENT} `)); + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.rejection.reason).toBe('digest-mismatch'); + expect(result.rejection.triggerClass).toBe('incomplete-required-source'); + expect(result.rejection.detail).toContain(CONTENT_DIGEST); + }); + + test('the digest is recomputed, never trusted from the manifest', () => { + // A declared digest of the *wrong* content must be rejected even though it is + // perfectly well-formed. An implementation that only shape-checked the + // declared digest would accept this. + const wrongButWellFormed = sha256Hex(new TextEncoder().encode('something else')); + expect(wrongButWellFormed).toMatch(/^[0-9a-f]{64}$/u); + const result = verifySourceBytes(source({ digest: wrongButWellFormed }), CONTENT_BYTES); + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.rejection.reason).toBe('digest-mismatch'); + }); +}); + +describe('T043 — verification over the whole source set', () => { + test('a fully-agreeing source set is accepted', async () => { + const result = await verifySourceDigests([source()], resolve); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.value).toHaveLength(1); + expect(result.value[0]?.path).toBe('catalog-info.yaml'); + }); + + test('a manifest-listed path absent from disk is `source-missing`', async () => { + const result = await verifySourceDigests([source({ path: 'not-here.yaml' })], resolve); + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.rejection.reason).toBe('source-missing'); + expect(result.rejection.triggerClass).toBe('incomplete-required-source'); + expect(result.rejection.detail).toContain('absent from the checkout'); + }); + + test('one bad source among several rejects the set — never a per-entity skip', async () => { + const result = await verifySourceDigests( + [source(), source({ path: 'not-here.yaml' }), source()], + resolve, + ); + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.rejection.reason).toBe('source-missing'); + }); + + test('nothing is returned when any source fails — no partial verified set', async () => { + // `atomic-fail-closed.md` §1: "skip the bad entity and keep going" is the + // behaviour the contract exists to foreclose. The shape enforces it — the + // function's success branch is the only one that carries verified sources. + const result = await verifySourceDigests( + [source(), source({ path: 'not-here.yaml' })], + resolve, + ); + expect(result.ok).toBe(false); + expect(result).not.toHaveProperty('value'); + }); + + test('the failure reported for two bad sources is deterministic (manifest order)', async () => { + const twice = await Promise.all([ + verifySourceDigests( + [source({ path: 'missing-a.yaml' }), source({ path: 'missing-b.yaml' })], + resolve, + ), + verifySourceDigests( + [source({ path: 'missing-a.yaml' }), source({ path: 'missing-b.yaml' })], + resolve, + ), + ]); + for (const result of twice) { + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.rejection.detail).toContain('missing-a.yaml'); + } + }); + + test('the three reasons are mutually distinct', async () => { + const malformed = await verifySourceDigests([source({ digest: 'nope' })], resolve); + const missing = await verifySourceDigests([source({ path: 'not-here.yaml' })], resolve); + const mismatch = await verifySourceDigests([source({ digest: 'a'.repeat(64) })], resolve); + const reasons = [malformed, missing, mismatch].map((result) => + result.ok ? 'accepted' : result.rejection.reason, + ); + expect(reasons).toEqual(['digest-malformed', 'source-missing', 'digest-mismatch']); + expect(new Set(reasons).size).toBe(3); + }); +}); diff --git a/packages/adapters/catalog-backstage/test/manifest-paths.test.ts b/packages/adapters/catalog-backstage/test/manifest-paths.test.ts new file mode 100644 index 00000000..085fffb5 --- /dev/null +++ b/packages/adapters/catalog-backstage/test/manifest-paths.test.ts @@ -0,0 +1,152 @@ +/** + * T044 — FR-012: **two-stage** source-path validation, each stage observed failing + * independently with its own reason. + * + * The two stages are separate checks and the test is deliberately built so that + * neither could stand in for the other: + * + * - The lexical cases are all rejected **without touching the filesystem** — they + * are run against a checkout root that does not exist, so any implementation that + * reached for `realpath` first would throw rather than reject. + * - The confinement case uses a **symlink whose lexical form is clean** + * (`link/secret.yaml`, no `..`, no leading slash) but whose resolved target is + * outside the root. `input-manifest.md` §4.1 states this in exactly those terms: + * "a lexically-clean relative path can still symlink outside the root." + * + * ADR-0016 evidence: + * `specs/010-catalog-backstage/evidence/negative-cases/path-validation/`. + */ + +import { afterAll, beforeAll, describe, expect, test } from 'bun:test'; +import { mkdir, mkdtemp, rm, symlink, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { + isBeneath, + validatePathConfined, + validatePathLexically, + validateSourcePath, +} from '../src/manifest/paths.ts'; + +/** A root that does not exist, so stage 1 provably runs before the filesystem. */ +const NONEXISTENT_ROOT = join(tmpdir(), 'adrkit-this-root-does-not-exist-9d1f'); + +let outsideRoot = ''; +let checkoutRoot = ''; + +beforeAll(async () => { + const base = await mkdtemp(join(tmpdir(), 'adrkit-path-fixture-')); + outsideRoot = join(base, 'outside'); + checkoutRoot = join(base, 'checkout'); + await mkdir(outsideRoot, { recursive: true }); + await mkdir(join(checkoutRoot, 'nested'), { recursive: true }); + await writeFile(join(outsideRoot, 'secret.yaml'), 'kind: Component\n', 'utf8'); + await writeFile(join(checkoutRoot, 'catalog-info.yaml'), 'kind: Component\n', 'utf8'); + // A directory symlink out of the checkout. `escape/secret.yaml` is lexically + // clean and resolves outside the root only through this link. + await symlink(outsideRoot, join(checkoutRoot, 'escape'), 'dir'); +}); + +afterAll(async () => { + if (checkoutRoot) await rm(join(checkoutRoot, '..'), { recursive: true, force: true }); +}); + +describe('T044 stage 1 — lexical rejection, before the filesystem is touched', () => { + test('a clean repo-relative path passes stage 1', () => { + expect(validatePathLexically('packages/payments/catalog-info.yaml').ok).toBe(true); + }); + + test.each([ + ['', 'path-empty'], + ['.', 'path-dot-or-dotdot'], + ['..', 'path-dot-or-dotdot'], + ['/etc/passwd', 'path-absolute'], + ['C:\\Windows\\system32', 'path-drive-prefix'], + ['packages\\payments\\catalog-info.yaml', 'path-backslash'], + ['\\\\host\\share\\file.yaml', 'path-backslash'], + ['../../secret.yaml', 'path-traversal-segment'], + ['packages/./catalog-info.yaml', 'path-traversal-segment'], + ['packages/../../etc/passwd', 'path-traversal-segment'], + ['packages/\u0000/catalog-info.yaml', 'path-control-character'], + ['packages/\u007f/catalog-info.yaml', 'path-control-character'], + ['packages/\ncatalog-info.yaml', 'path-control-character'], + ] as const)('%j is rejected as %s', (path, expected) => { + const result = validatePathLexically(path); + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.rejection.reason).toBe(expected); + expect(result.rejection.triggerClass).toBe('invalid-manifest-shape'); + }); + + test('the drive prefix rule fires before the backslash rule', () => { + // `C:\Windows` violates both. `input-manifest.md` §4.1 lists the drive prefix + // ahead of the backslash rule, so the reported reason must be the drive one + // regardless of implementation order. + const result = validatePathLexically('C:\\Windows'); + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.rejection.reason).toBe('path-drive-prefix'); + }); + + test('stage 1 rejects without any filesystem access', async () => { + // The root does not exist. A stage-1 failure must still be a rejection rather + // than a thrown ENOENT, which is only possible if stage 1 really did run first. + const result = await validateSourcePath(NONEXISTENT_ROOT, '../../secret.yaml'); + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.rejection.reason).toBe('path-traversal-segment'); + }); +}); + +describe('T044 stage 2 — confined realpath', () => { + test('containment is strict — a sibling sharing a name prefix is not beneath', () => { + expect(isBeneath('/repo', '/repo/src/a.ts')).toBe(true); + expect(isBeneath('/repo', '/repo-evil/a.ts')).toBe(false); + expect(isBeneath('/repo', '/repo')).toBe(false); + expect(isBeneath('/repo', '/elsewhere/a.ts')).toBe(false); + }); + + test('a real file beneath the root is accepted', async () => { + const result = await validatePathConfined(checkoutRoot, 'catalog-info.yaml'); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.value.declared).toBe('catalog-info.yaml'); + }); + + test('a lexically-clean path escaping through a symlink fails closed', async () => { + // This is the case stage 1 cannot see: no `..`, no leading slash, nothing a + // string check could catch. + expect(validatePathLexically('escape/secret.yaml').ok).toBe(true); + + const result = await validatePathConfined(checkoutRoot, 'escape/secret.yaml'); + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.rejection.reason).toBe('path-escapes-checkout-root'); + expect(result.rejection.triggerClass).toBe('incomplete-required-source'); + expect(result.rejection.detail).toContain('not beneath the verified checkout root'); + }); + + test('the same escape is rejected through the combined two-stage entry point', async () => { + const result = await validateSourcePath(checkoutRoot, 'escape/secret.yaml'); + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.rejection.reason).toBe('path-escapes-checkout-root'); + }); + + test('a nonexistent path beneath the root is not an escape', async () => { + // Absence is `manifest/digests.ts`'s `source-missing`. Reporting it here would + // characterise a missing file as a boundary violation. + const result = await validatePathConfined(checkoutRoot, 'nested/not-created.yaml'); + expect(result.ok).toBe(true); + }); + + test('the two stages emit different reasons for their own failures', async () => { + const stageOne = await validateSourcePath(checkoutRoot, '../secret.yaml'); + const stageTwo = await validateSourcePath(checkoutRoot, 'escape/secret.yaml'); + expect(stageOne.ok).toBe(false); + expect(stageTwo.ok).toBe(false); + if (stageOne.ok || stageTwo.ok) return; + expect(stageOne.rejection.reason).not.toBe(stageTwo.rejection.reason); + expect(stageOne.rejection.triggerClass).not.toBe(stageTwo.rejection.triggerClass); + }); +}); diff --git a/packages/adapters/catalog-backstage/test/manifest-single-repo.test.ts b/packages/adapters/catalog-backstage/test/manifest-single-repo.test.ts new file mode 100644 index 00000000..d25ef068 --- /dev/null +++ b/packages/adapters/catalog-backstage/test/manifest-single-repo.test.ts @@ -0,0 +1,163 @@ +/** + * T039 — FR-007: one manifest describes exactly one repository. + * + * Every expected value here comes from `input-manifest.md` §1's manifest shape and + * `data-model.md` §1's field list, both frozen. Nothing is derived by running the + * code under test. + * + * The closed-schema rule (FR-006, T038) is exercised here too, because §1 states + * both rules in the same paragraph and the single-repository rule is enforced *by* + * the closed schema: `repositories` is rejected not by a special case but by being + * an unrecognized top-level field. + */ + +import { describe, expect, test } from 'bun:test'; +import { + MANIFEST_REPOSITORY_FIELDS, + MANIFEST_SOURCE_FIELDS, + MANIFEST_TOP_LEVEL_FIELDS, + parseManifestText, + validateManifestShape, +} from '../src/manifest/schema.ts'; + +/** `input-manifest.md` §1's worked manifest, transcribed field for field. */ +const CONTRACT_MANIFEST = { + manifestSchemaVersion: '1', + requestedSnapshotSchemaVersion: '1', + requiredCapabilities: ['pathOwnership'], + repository: { + id: 'github.com/mbeacom/adrkit-spike-fixture', + revision: '0000000000000000000000000000000000000000', + }, + sources: [ + { + path: 'catalog-info.yaml', + digestAlgorithm: 'sha256', + digest: 'a'.repeat(64), + }, + ], +} as const; + +describe('T038 — the manifest schema is closed (FR-006)', () => { + test('the field list matches `data-model.md` §1 exactly', () => { + expect([...MANIFEST_TOP_LEVEL_FIELDS]).toEqual([ + 'manifestSchemaVersion', + 'requestedSnapshotSchemaVersion', + 'requiredCapabilities', + 'repository', + 'sources', + ]); + expect([...MANIFEST_REPOSITORY_FIELDS]).toEqual(['id', 'revision']); + expect([...MANIFEST_SOURCE_FIELDS]).toEqual(['path', 'digestAlgorithm', 'digest']); + }); + + test('`input-manifest.md` §1\u2019s worked manifest is accepted verbatim', () => { + const result = validateManifestShape(structuredClone(CONTRACT_MANIFEST)); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.value.repository.id).toBe('github.com/mbeacom/adrkit-spike-fixture'); + expect(result.value.sources).toHaveLength(1); + }); + + test('an unrecognized top-level field is rejected, not ignored', () => { + const result = validateManifestShape({ + ...structuredClone(CONTRACT_MANIFEST), + futureField: 'harmless-looking', + }); + 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.detail).toContain('futureField'); + }); + + test('the closed rule applies to nested objects too', () => { + const withExtraRepositoryField = validateManifestShape({ + ...structuredClone(CONTRACT_MANIFEST), + repository: { ...CONTRACT_MANIFEST.repository, mirror: 'github.com/other/repo' }, + }); + expect(withExtraRepositoryField.ok).toBe(false); + if (withExtraRepositoryField.ok) return; + expect(withExtraRepositoryField.rejection.reason).toBe('unrecognized-nested-field'); + + const withExtraSourceField = validateManifestShape({ + ...structuredClone(CONTRACT_MANIFEST), + sources: [{ ...CONTRACT_MANIFEST.sources[0], encoding: 'utf8' }], + }); + expect(withExtraSourceField.ok).toBe(false); + if (withExtraSourceField.ok) return; + expect(withExtraSourceField.rejection.reason).toBe('unrecognized-nested-field'); + }); + + test('malformed JSON is its own reason, not a shape failure', () => { + const result = parseManifestText('{ "manifestSchemaVersion": "1"'); + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.rejection.reason).toBe('manifest-not-json'); + }); + + test('a missing required field and a wrongly typed one are distinguishable', () => { + const clone = structuredClone(CONTRACT_MANIFEST) as Record; + delete clone['sources']; + const missing = validateManifestShape(clone); + expect(missing.ok).toBe(false); + if (missing.ok) return; + expect(missing.rejection.reason).toBe('missing-required-field'); + + const wrongType = validateManifestShape({ + ...structuredClone(CONTRACT_MANIFEST), + manifestSchemaVersion: 1, + }); + expect(wrongType.ok).toBe(false); + if (wrongType.ok) return; + expect(wrongType.rejection.reason).toBe('field-wrong-type'); + }); +}); + +describe('T039 — single-repository binding (FR-007)', () => { + test('a `repository` array naming two repositories is rejected', () => { + const result = validateManifestShape({ + ...structuredClone(CONTRACT_MANIFEST), + repository: [ + CONTRACT_MANIFEST.repository, + { id: 'github.com/mbeacom/other', revision: 'b'.repeat(40) }, + ], + }); + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.rejection.reason).toBe('multiple-repositories'); + expect(result.rejection.triggerClass).toBe('invalid-manifest-shape'); + expect(result.rejection.detail).toContain('exactly one repository'); + }); + + test('a single-element `repository` array is still rejected — arity is not the rule', () => { + const result = validateManifestShape({ + ...structuredClone(CONTRACT_MANIFEST), + repository: [CONTRACT_MANIFEST.repository], + }); + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.rejection.reason).toBe('multiple-repositories'); + }); + + test('a second top-level `repositories` key is rejected by the closed schema', () => { + const result = validateManifestShape({ + ...structuredClone(CONTRACT_MANIFEST), + repositories: [{ id: 'github.com/mbeacom/other', revision: 'b'.repeat(40) }], + }); + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.rejection.reason).toBe('unrecognized-top-level-field'); + expect(result.rejection.detail).toContain('repositories'); + }); + + test('exactly one repository, expressed as one object, is accepted', () => { + const result = validateManifestShape(structuredClone(CONTRACT_MANIFEST)); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(Array.isArray(result.value.repository)).toBe(false); + expect(result.value.repository.revision).toBe( + '0000000000000000000000000000000000000000', + ); + }); +}); diff --git a/packages/adapters/catalog-backstage/test/manifest-version.test.ts b/packages/adapters/catalog-backstage/test/manifest-version.test.ts new file mode 100644 index 00000000..ff28acfe --- /dev/null +++ b/packages/adapters/catalog-backstage/test/manifest-version.test.ts @@ -0,0 +1,144 @@ +/** + * T040 — FR-008: the three manifest-level version and capability rejections. + * + * Expected values come from `input-manifest.md` §2's table and `data-model.md` §8's + * trigger enumeration. Nothing here is derived by running the code under test. + * + * ADR-0016: each of the three was **observed failing first**, with its emitted + * reason recorded verbatim, before being observed passing. The observations are + * retained at `specs/010-catalog-backstage/evidence/negative-cases/manifest-version/`. + */ + +import { describe, expect, test } from 'bun:test'; +import { TRIGGER_CLASSES } from '../src/diagnostics.ts'; +import { validateManifestShape } from '../src/manifest/schema.ts'; +import { + SUPPORTED_CAPABILITY, + SUPPORTED_MANIFEST_SCHEMA_VERSION, + SUPPORTED_SNAPSHOT_SCHEMA_VERSION, + checkManifestVersions, +} from '../src/manifest/version.ts'; + +const BASE = { + manifestSchemaVersion: '1', + requestedSnapshotSchemaVersion: '1', + requiredCapabilities: ['pathOwnership'], + repository: { + id: 'github.com/mbeacom/adrkit-spike-fixture', + revision: '0'.repeat(40), + }, + sources: [{ path: 'catalog-info.yaml', digestAlgorithm: 'sha256', digest: 'a'.repeat(64) }], +} as const; + +function manifest(overrides: Record = {}) { + const result = validateManifestShape({ ...structuredClone(BASE), ...overrides }); + if (!result.ok) throw new Error(`fixture failed the schema: ${result.rejection.detail}`); + return result.value; +} + +describe('T040 — the three version/capability rejections (FR-008)', () => { + test('the accepted values are exactly the ones `input-manifest.md` §2 names', () => { + expect(SUPPORTED_MANIFEST_SCHEMA_VERSION).toBe('1'); + expect(SUPPORTED_SNAPSHOT_SCHEMA_VERSION).toBe('1'); + expect(SUPPORTED_CAPABILITY).toBe('pathOwnership'); + }); + + test('the contract-shaped manifest passes all three', () => { + const result = checkManifestVersions(manifest()); + expect(result.ok).toBe(true); + }); + + test('`unsupported-manifest-version` — an unsupported manifestSchemaVersion', () => { + const result = checkManifestVersions(manifest({ manifestSchemaVersion: '2' })); + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.rejection.reason).toBe('unsupported-manifest-version'); + expect(result.rejection.triggerClass).toBe('unsupported-manifest-version'); + expect(result.rejection.detail).toBe( + 'manifestSchemaVersion must be "1"; observed "2"', + ); + }); + + test('`unsupported-snapshot-version` — an unsupported requestedSnapshotSchemaVersion', () => { + const result = checkManifestVersions(manifest({ requestedSnapshotSchemaVersion: '2' })); + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.rejection.reason).toBe('unsupported-snapshot-version'); + expect(result.rejection.triggerClass).toBe('unsupported-snapshot-version'); + expect(result.rejection.detail).toBe( + 'requestedSnapshotSchemaVersion must be "1"; observed "2"', + ); + }); + + test('`unsupported-capability` — any string other than pathOwnership in the array', () => { + const result = checkManifestVersions( + manifest({ requiredCapabilities: ['pathOwnership', 'entityGraph'] }), + ); + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.rejection.reason).toBe('unsupported-capability'); + expect(result.rejection.triggerClass).toBe('unsupported-capability'); + expect(result.rejection.detail).toBe( + 'requiredCapabilities[1] is "entityGraph"; the only defined capability is "pathOwnership"', + ); + }); + + test('the three reasons are mutually distinct', () => { + const reasons = new Set( + [ + checkManifestVersions(manifest({ manifestSchemaVersion: '2' })), + checkManifestVersions(manifest({ requestedSnapshotSchemaVersion: '2' })), + checkManifestVersions(manifest({ requiredCapabilities: ['entityGraph'] })), + ].map((result) => (result.ok ? 'accepted' : result.rejection.reason)), + ); + expect(reasons.size).toBe(3); + }); + + test('all three trigger classes are members of the closed enumeration', () => { + for (const triggerClass of [ + 'unsupported-manifest-version', + 'unsupported-snapshot-version', + 'unsupported-capability', + ] as const) { + expect(TRIGGER_CLASSES).toContain(triggerClass); + } + }); + + test('a manifest violating two rules reports the first, deterministically', () => { + // `input-manifest.md` §2 lists manifestSchemaVersion first, so a manifest that + // is wrong on both version fields must report the manifest version — not + // whichever the implementation happened to check first. + const result = checkManifestVersions( + manifest({ manifestSchemaVersion: '2', requestedSnapshotSchemaVersion: '3' }), + ); + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.rejection.reason).toBe('unsupported-manifest-version'); + }); +}); + +describe('T040 — what §2\u2019s capability rule does and does not say', () => { + test('the rejection is triggered by a present offending string', () => { + // `input-manifest.md` §2: "triggered by any string other than "pathOwnership" + // appearing in the array". An empty array contains no such string, so this + // module does not invent a rejection the contract does not authorize. The + // divergence from `data-model.md` §1's one-element tuple type is reported, + // not resolved here. + expect(checkManifestVersions(manifest({ requiredCapabilities: [] })).ok).toBe(true); + }); + + test('a repeated supported capability contains no offending string', () => { + expect( + checkManifestVersions( + manifest({ requiredCapabilities: ['pathOwnership', 'pathOwnership'] }), + ).ok, + ).toBe(true); + }); + + test('capability matching is exact, never case-insensitive', () => { + const result = checkManifestVersions(manifest({ requiredCapabilities: ['pathownership'] })); + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.rejection.reason).toBe('unsupported-capability'); + }); +}); diff --git a/packages/adapters/catalog-backstage/test/ownership-states.test.ts b/packages/adapters/catalog-backstage/test/ownership-states.test.ts new file mode 100644 index 00000000..9ca841b3 --- /dev/null +++ b/packages/adapters/catalog-backstage/test/ownership-states.test.ts @@ -0,0 +1,177 @@ +/** + * T060 — FR-028: the three ownership states, kept distinct and never conflated, + * with `explicit-empty` decided on the **decoded** value. + * + * Expected values come from `owned-paths-annotation.md` §3 and §4, and from + * `data-model.md` §7.2. None is derived by running the code under test. + */ + +import { describe, expect, test } from 'bun:test'; +import { deriveOwnership } from '../src/ownership/derive.ts'; +import { + OWNERSHIP_STATES, + bothYieldEmptyDerivedPaths, + classifyOwnershipState, +} from '../src/ownership/states.ts'; +import { decodeAnnotation } from '../src/ownership/annotation.ts'; + +function decoded(present: boolean, raw: unknown) { + const result = decodeAnnotation(present, raw); + if (!result.ok) throw new Error(`fixture failed to decode: ${result.rejection.detail}`); + return result.value; +} + +describe('T060 — exactly three states, and no fourth', () => { + test('the three, in `data-model.md` \u00a77.2\u2019s order', () => { + expect([...OWNERSHIP_STATES]).toEqual([ + 'explicit-paths', + 'explicit-empty', + 'annotation-absent', + ]); + expect(OWNERSHIP_STATES).toHaveLength(3); + }); + + test('every classifiable input lands on one of the three', () => { + const observed = new Set([ + classifyOwnershipState(decoded(true, '["packages/**"]')), + classifyOwnershipState(decoded(true, '[]')), + classifyOwnershipState(decoded(false, undefined)), + ]); + expect([...observed].sort()).toEqual([...OWNERSHIP_STATES].sort()); + }); +}); + +describe('T060 — `explicit-empty` is decided on the decoded value', () => { + test.each(['[]', '[ ]', '[\n]', '[\t]', '[ \n ]'])( + '%j decodes to an empty array and qualifies identically', + (raw) => { + // §3: "This is a decoded-value check, never a raw-string equality check — + // `'[]'`, `'[ ]'`, `'[\n]'`, and any other JSON text that decodes to `[]` all + // qualify identically". + expect(classifyOwnershipState(decoded(true, raw))).toBe('explicit-empty'); + }, + ); + + test('an implementation comparing the raw string to `[]` would get `[ ]` wrong', () => { + // Naming the wrong answer explicitly, so the right one is a contrast. + const rawStringEquality = (raw: string) => (raw === '[]' ? 'explicit-empty' : 'something-else'); + expect(rawStringEquality('[ ]')).toBe('something-else'); + expect(classifyOwnershipState(decoded(true, '[ ]'))).toBe('explicit-empty'); + }); + + test('the classifier takes a decoded annotation, never a raw string', () => { + // The signature is the enforcement: there is no parameter through which `'[ ]'` + // could arrive for someone to compare against `'[]'`. + expect(classifyOwnershipState.length).toBe(1); + }); + + test('classification happens strictly after decoding, so a parse failure never reaches it', () => { + const derivation = deriveOwnership(true, '['); + expect(derivation.ok).toBe(false); + if (derivation.ok) return; + expect(derivation.rejection.reason).toBe('parse-error'); + expect(JSON.stringify(derivation)).not.toContain('explicit-empty'); + }); +}); + +describe('T060 — the non-conflation rule', () => { + const explicitEmpty = deriveOwnership(true, '[]'); + const absent = deriveOwnership(false, undefined); + + test('both yield an empty `derivedPaths`', () => { + expect(explicitEmpty.ok).toBe(true); + expect(absent.ok).toBe(true); + if (!explicitEmpty.ok || !absent.ok) return; + expect(explicitEmpty.value.derivedPaths).toEqual([]); + expect(absent.value.derivedPaths).toEqual([]); + expect(bothYieldEmptyDerivedPaths('explicit-empty', 'annotation-absent')).toBe(true); + }); + + test('and are nevertheless different states', () => { + if (!explicitEmpty.ok || !absent.ok) return; + expect(explicitEmpty.value.ownershipState).toBe('explicit-empty'); + expect(absent.value.ownershipState).toBe('annotation-absent'); + expect(explicitEmpty.value.ownershipState).not.toBe(absent.value.ownershipState); + }); + + test('the distinction is not recoverable from `derivedPaths` alone', () => { + // §3's own reason for requiring an explicit discriminator field. + if (!explicitEmpty.ok || !absent.ok) return; + expect(explicitEmpty.value.derivedPaths).toEqual(absent.value.derivedPaths); + }); + + test('the discriminant is carried in the annotation record too', () => { + if (!explicitEmpty.ok || !absent.ok) return; + expect(explicitEmpty.value.annotation.annotationPresent).toBe(true); + expect(absent.value.annotation.annotationPresent).toBe(false); + }); +}); + +describe('T060 — `explicit-paths`', () => { + test('a non-empty, fully valid array yields `explicit-paths`', () => { + const derivation = deriveOwnership(true, '["packages/payments/**", "docs/*.md"]'); + expect(derivation.ok).toBe(true); + if (!derivation.ok) return; + expect(derivation.value.ownershipState).toBe('explicit-paths'); + expect(derivation.value.derivedPaths).toEqual(['docs/*.md', 'packages/payments/**']); + }); + + test('`explicit-paths` is this spec\u2019s own label and must not be renamed', () => { + // §3: ADR-0012 and the Ratification Record name only `explicit-empty` and + // `annotation-absent`; `explicit-paths` is `spec.md`'s complementary third + // label. "A future execution session MUST use `explicit-paths` for the + // non-empty, valid case — never leave it unlabeled or invent an alternate term." + expect(OWNERSHIP_STATES).toContain('explicit-paths'); + }); + + test('no path is ever derived for an `annotation-absent` entity', () => { + const derivation = deriveOwnership(false, undefined); + expect(derivation.ok).toBe(true); + if (!derivation.ok) return; + expect(derivation.value.derivedPaths).toEqual([]); + expect(derivation.value.patterns).toEqual([]); + }); +}); + +describe('T060 — `[""]` is not `explicit-empty` (\u00a74)', () => { + test('a single empty-string element is a per-pattern failure, not an empty array', () => { + // §4: `["", "packages/**"]` and `[""]` are "**not** `explicit-empty` — an + // `explicit-empty` value is `[]` exactly." + const derivation = deriveOwnership(true, '[""]'); + expect(derivation.ok).toBe(false); + if (derivation.ok) return; + expect(derivation.rejection.reason).toBe('invalid-pattern'); + expect(derivation.pattern?.outcome).toBe('empty'); + expect(derivation.pattern?.rule).toBe(1); + }); + + test('the same holds inside an otherwise well-formed array', () => { + const derivation = deriveOwnership(true, '["", "packages/**"]'); + expect(derivation.ok).toBe(false); + if (derivation.ok) return; + expect(derivation.rejection.reason).toBe('invalid-pattern'); + expect(derivation.pattern?.outcome).toBe('empty'); + }); + + test('it is never conflated with the `[]`-versus-absent distinction', () => { + const derivation = deriveOwnership(true, '[""]'); + expect(derivation.ok).toBe(false); + expect(JSON.stringify(derivation)).not.toContain('explicit-empty'); + expect(JSON.stringify(derivation)).not.toContain('annotation-absent'); + }); +}); + +describe('T060 — determinism (\u00a75, SC-001)', () => { + test('three runs over the same input produce byte-identical output', () => { + const runs = Array.from({ length: 3 }, () => + deriveOwnership(true, '["b/**", "a/**", "b/**", "c/*"]'), + ); + const serialized = runs.map((run) => JSON.stringify(run)); + expect(new Set(serialized).size).toBe(1); + + const [first] = runs; + expect(first?.ok).toBe(true); + if (first === undefined || !first.ok) return; + expect(first.value.derivedPaths).toEqual(['a/**', 'b/**', 'c/*']); + }); +}); diff --git a/packages/adapters/catalog-backstage/test/repository-exact-match.test.ts b/packages/adapters/catalog-backstage/test/repository-exact-match.test.ts new file mode 100644 index 00000000..28758c65 --- /dev/null +++ b/packages/adapters/catalog-backstage/test/repository-exact-match.test.ts @@ -0,0 +1,132 @@ +/** + * T042 — FR-010: repository identity and revision are compared by **exact string + * equality**. A partial, prefix, or normalized match aborts the operation. + * + * `input-manifest.md` §3 step 4: "Any outcome other than both-match aborts + * generation **before any entity's paths are derived** (FR-007) — including a + * partial match (e.g. revision matches but repository ID does not)." + * + * The near-miss revision case is the one worth constructing deliberately: an + * abbreviated SHA is the single most plausible way a real manifest ends up + * "nearly" right, and `startsWith` is the single most plausible way an + * implementation ends up accepting it. + * + * ADR-0016 evidence: + * `specs/010-catalog-backstage/evidence/negative-cases/repository-mismatch/`. + */ + +import { describe, expect, test } from 'bun:test'; +import { compareRepositoryIdentity } from '../src/repository/identity.ts'; + +const ID = 'github.com/mbeacom/adrkit-scratch-fixture'; +const HEAD = '3f5a1c9e8b2d4f6a0c7e1b3d5f7a9c1e3b5d7f90'; + +const OBSERVED = { remoteRaw: `git@github.com:mbeacom/adrkit-scratch-fixture.git`, head: HEAD }; + +describe('T042 — exact string equality on both values (FR-010)', () => { + test('both agreeing is the only match', () => { + const result = compareRepositoryIdentity({ id: ID, revision: HEAD }, OBSERVED); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.value.outcome).toBe('match'); + }); + + test('an abbreviated revision is a mismatch, not a prefix match', () => { + const result = compareRepositoryIdentity({ id: ID, revision: HEAD.slice(0, 7) }, OBSERVED); + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.rejection.reason).toBe('repository-mismatch'); + expect(result.rejection.triggerClass).toBe('repository-mismatch'); + expect(result.rejection.detail).toContain('revision:'); + expect(result.rejection.detail).not.toContain('repository id:'); + }); + + test('a revision differing in one character is a mismatch', () => { + const nearMiss = `${HEAD.slice(0, 39)}${HEAD.endsWith('0') ? '1' : '0'}`; + expect(nearMiss).toHaveLength(40); + expect(nearMiss).not.toBe(HEAD); + const result = compareRepositoryIdentity({ id: ID, revision: nearMiss }, OBSERVED); + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.rejection.reason).toBe('repository-mismatch'); + }); + + test('an uppercase revision is a mismatch — comparison is not case-normalized', () => { + const result = compareRepositoryIdentity({ id: ID, revision: HEAD.toUpperCase() }, OBSERVED); + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.rejection.detail).toContain('revision:'); + }); + + test('a partial match — revision agrees, identity does not — still aborts', () => { + const result = compareRepositoryIdentity( + { id: 'github.com/mbeacom/some-other-repo', revision: HEAD }, + OBSERVED, + ); + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.rejection.reason).toBe('repository-mismatch'); + expect(result.rejection.detail).toContain('repository id:'); + expect(result.rejection.detail).not.toContain('revision:'); + }); + + test('a partial match the other way — identity agrees, revision does not — still aborts', () => { + const result = compareRepositoryIdentity({ id: ID, revision: '0'.repeat(40) }, OBSERVED); + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.rejection.detail).toContain('revision:'); + expect(result.rejection.detail).not.toContain('repository id:'); + }); + + test('both disagreeing reports both halves', () => { + const result = compareRepositoryIdentity( + { id: 'github.com/mbeacom/other', revision: '0'.repeat(40) }, + OBSERVED, + ); + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.rejection.detail).toContain('repository id:'); + expect(result.rejection.detail).toContain('revision:'); + }); + + test('a repository-id prefix is a mismatch', () => { + // `github.com/mbeacom/adrkit` is a strict prefix of the fixture's own id, so + // an implementation using `startsWith` for identity would accept this. + const result = compareRepositoryIdentity( + { id: 'github.com/mbeacom/adrkit', revision: HEAD }, + OBSERVED, + ); + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.rejection.detail).toContain('repository id:'); + }); + + test('the check records the values it compared, both sides', () => { + const result = compareRepositoryIdentity({ id: ID, revision: '0'.repeat(40) }, OBSERVED); + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.rejection.detail).toContain('0'.repeat(40)); + expect(result.rejection.detail).toContain(HEAD); + }); + + test('an unrecognized remote normalizes to `invalid` and mismatches', () => { + const result = compareRepositoryIdentity( + { id: ID, revision: HEAD }, + { remoteRaw: 'https://gitlab.com/mbeacom/adrkit.git', head: HEAD }, + ); + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.rejection.detail).toContain('"invalid"'); + }); + + test('the observed values are compared, not re-read from the manifest', () => { + // If the implementation ever re-read the manifest for its "observed" side, + // every comparison would trivially match. Feeding a manifest that disagrees + // with the observed state and requiring a rejection is what forecloses that. + const result = compareRepositoryIdentity( + { id: 'github.com/mbeacom/whatever-the-manifest-says', revision: 'f'.repeat(40) }, + OBSERVED, + ); + expect(result.ok).toBe(false); + }); +}); diff --git a/packages/adapters/catalog-backstage/test/repository-identity.test.ts b/packages/adapters/catalog-backstage/test/repository-identity.test.ts new file mode 100644 index 00000000..79716a83 --- /dev/null +++ b/packages/adapters/catalog-backstage/test/repository-identity.test.ts @@ -0,0 +1,177 @@ +/** + * T041 — FR-009: repository identity and revision come from **separate git + * tooling**, never from a descriptor annotation or any content under the + * repository being described. + * + * **The fixture is a standalone scratch `git init` repository, and that is a + * contract requirement rather than a testing preference.** `input-manifest.md` + * §3.1: a `git worktree add` linked worktree shares its remote configuration with + * the repository it was created from, so a linked worktree of *this* repository + * would always report `github.com/mbeacom/adrkit` no matter what the test intended, + * and a mismatch test run inside one would pass without ever varying the thing it + * claims to vary. The checkout this package is developed in **is** such a worktree. + * + * `test('the development checkout really is a linked worktree', ...)` below asserts + * that premise rather than assuming it, because if it ever stopped being true the + * §3.1 constraint would be silently over-cautious rather than load-bearing, and a + * reader deserves to know which. + * + * Expected values come from `research.md` R6's numbered normalization algorithm and + * from `data-model.md` §2. None is derived by running the code under test. + */ + +import { afterAll, beforeAll, describe, expect, test } from 'bun:test'; +import { mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { + INVALID_REPOSITORY_ID, + compareRepositoryIdentity, + normalizeRepositoryId, + readObservedRepositoryState, +} from '../src/repository/identity.ts'; + +const SCRATCH_REMOTE = 'git@github.com:mbeacom/adrkit-scratch-fixture.git'; +const SCRATCH_NORMALIZED = 'github.com/mbeacom/adrkit-scratch-fixture'; + +let scratchRoot = ''; +let scratchHead = ''; + +async function git(args: readonly string[], cwd: string): Promise { + const proc = Bun.spawn(['git', ...args], { cwd, stdout: 'pipe', stderr: 'pipe' }); + const [stdout, stderr, code] = await Promise.all([ + new Response(proc.stdout).text(), + new Response(proc.stderr).text(), + proc.exited, + ]); + if (code !== 0) throw new Error(`git ${args.join(' ')}: ${stderr}`); + return stdout.trim(); +} + +beforeAll(async () => { + // A fresh, disposable `git init`'d directory with its own `origin` and its own + // commit — entirely separate from this repository's `.git`, per §3.1. + scratchRoot = await mkdtemp(join(tmpdir(), 'adrkit-scratch-repo-')); + await git(['init', '--initial-branch=main'], scratchRoot); + await git(['config', 'user.email', 'fixture@example.invalid'], scratchRoot); + await git(['config', 'user.name', 'adrkit fixture'], scratchRoot); + await git(['config', 'commit.gpgsign', 'false'], scratchRoot); + await git(['remote', 'add', 'origin', SCRATCH_REMOTE], scratchRoot); + await writeFile(join(scratchRoot, 'catalog-info.yaml'), 'kind: Component\n', 'utf8'); + await git(['add', '.'], scratchRoot); + await git(['commit', '-m', 'fixture'], scratchRoot); + scratchHead = await git(['rev-parse', 'HEAD'], scratchRoot); +}); + +afterAll(async () => { + if (scratchRoot) await rm(scratchRoot, { recursive: true, force: true }); +}); + +describe('T041 — `research.md` R6 normalization, step by step', () => { + test('step 3 — the SCP-like `git@github.com:` form', () => { + expect(normalizeRepositoryId('git@github.com:mbeacom/adrkit.git')).toBe( + 'github.com/mbeacom/adrkit', + ); + }); + + test('step 4 — https, http, and ssh forms, with and without `git@`', () => { + for (const raw of [ + 'https://github.com/mbeacom/adrkit.git', + 'https://github.com/mbeacom/adrkit', + 'http://github.com/mbeacom/adrkit', + 'ssh://git@github.com/mbeacom/adrkit.git', + ]) { + expect(normalizeRepositoryId(raw)).toBe('github.com/mbeacom/adrkit'); + } + }); + + test('step 5 — an already-bare `github.com/...` form is kept', () => { + expect(normalizeRepositoryId('github.com/mbeacom/adrkit')).toBe('github.com/mbeacom/adrkit'); + }); + + test('steps 2 and 6 in that order — `.../repo.git/` loses the slash, then `.git`', () => { + // R6 is explicit that step 6 runs after step 2, "never the reverse, which + // would leave a dangling `/` unstripped". + expect(normalizeRepositoryId('https://github.com/mbeacom/adrkit.git/')).toBe( + 'github.com/mbeacom/adrkit', + ); + expect(normalizeRepositoryId('https://github.com/mbeacom/adrkit.git///')).toBe( + 'github.com/mbeacom/adrkit', + ); + }); + + test('step 7 — anything but exactly two non-empty segments is invalid', () => { + for (const raw of [ + 'https://github.com/mbeacom/adrkit/extra', + 'https://github.com/mbeacom', + 'https://github.com/mbeacom/adrkit?x=1', + 'https://github.com/mbeacom/adrkit#frag', + 'https://gitlab.com/mbeacom/adrkit.git', + 'not-a-url', + '', + ]) { + expect(normalizeRepositoryId(raw)).toBe(INVALID_REPOSITORY_ID); + } + }); + + test('step 8 — the whole string is ASCII case-folded', () => { + expect(normalizeRepositoryId('git@github.com:MBeacom/ADRKit.git')).toBe( + 'github.com/mbeacom/adrkit', + ); + }); + + test('step 1 — trailing whitespace is stripped, as `git` output carries it', () => { + expect(normalizeRepositoryId('git@github.com:mbeacom/adrkit.git\n')).toBe( + 'github.com/mbeacom/adrkit', + ); + }); +}); + +describe('T041 — identity is read from git, not from the manifest or a descriptor', () => { + test('the development checkout really is a linked worktree (§3.1\u2019s premise)', async () => { + // If this ever fails, §3.1's constraint has stopped being load-bearing here and + // the scratch-repository requirement deserves re-reading rather than silent + // inheritance. + const gitDir = await git(['rev-parse', '--git-dir'], import.meta.dir); + const commonDir = await git(['rev-parse', '--git-common-dir'], import.meta.dir); + expect(gitDir).not.toBe(commonDir); + }); + + test('the scratch repository reports its own origin, not this repository\u2019s', async () => { + const observed = await readObservedRepositoryState(scratchRoot); + expect(observed.remoteRaw).toBe(SCRATCH_REMOTE); + expect(normalizeRepositoryId(observed.remoteRaw)).toBe(SCRATCH_NORMALIZED); + expect(normalizeRepositoryId(observed.remoteRaw)).not.toBe('github.com/mbeacom/adrkit'); + }); + + test('the scratch repository reports its own HEAD, a 40-character hex sha', async () => { + const observed = await readObservedRepositoryState(scratchRoot); + expect(observed.head).toBe(scratchHead); + expect(observed.head).toMatch(/^[0-9a-f]{40}$/u); + }); + + test('a manifest agreeing with the scratch checkout matches', async () => { + const observed = await readObservedRepositoryState(scratchRoot); + const result = compareRepositoryIdentity( + { id: SCRATCH_NORMALIZED, revision: scratchHead }, + observed, + ); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.value.outcome).toBe('match'); + expect(result.value.observedRemoteRaw).toBe(SCRATCH_REMOTE); + expect(result.value.observedRepositoryId).toBe(SCRATCH_NORMALIZED); + }); + + test('an annotation-supplied slug is never consulted', () => { + // `input-manifest.md` §3: identity is supplied only by the manifest and + // verified against git — never inferred from `github.com/project-slug`. The + // comparison function's signature is the enforcement: there is no parameter + // through which a descriptor annotation could arrive. + const parameterNames = compareRepositoryIdentity.length; + expect(parameterNames).toBe(2); + const source = compareRepositoryIdentity.toString(); + expect(source).not.toContain('project-slug'); + expect(source).not.toContain('annotation'); + }); +}); diff --git a/packages/adapters/catalog-backstage/test/sc-004.test.ts b/packages/adapters/catalog-backstage/test/sc-004.test.ts new file mode 100644 index 00000000..32b22d82 --- /dev/null +++ b/packages/adapters/catalog-backstage/test/sc-004.test.ts @@ -0,0 +1,218 @@ +/** + * T056 — SC-004 close-out. + * + * SC-004 (`spec.md`), in full: *"For every descriptor that any of ADR-0015's four + * pinned validator predicates returns `false` for, the recorded trigger class is + * `inadmissible-descriptor`, no canonical identity for that descriptor is computed + * or emitted, and a descriptor set that would collide only after canonicalizing an + * inadmissible descriptor is never reported as `duplicate-canonical-id`. The + * exercised set includes **at least one inadmissible descriptor that canonicalizes + * uniquely and collides with nothing** (FR-021) ... and each + * `inadmissible-descriptor` record carries all three of the offending path, the + * failing field, and the rejecting validator (FR-020)."* + * + * `admissibility.md` §8 is the observation requirement this closes out: each of the + * four predicates, and the composition, lands only by constructing a descriptor + * that should fail that specific validator, observing the failure and recording the + * exact reason, then correcting the input and observing the pass. + * + * This file iterates **every admissibility failure mode** — one per validator, plus + * the composition — and asserts the same four properties for each. It consolidates + * T048–T055; it does not restate their individual contract citations. + */ + +import { describe, expect, test } from 'bun:test'; +import { classifyAdmissibility, inadmissibleRejection } from '../src/admissibility/classify.ts'; +import { admit } from '../src/admissibility/index.ts'; +import { + ADMISSIBILITY_FIELDS, + type AdmissibilityValidatorName, +} from '../src/admissibility/validators.ts'; +import { canonicalize } from '../src/identity/canonicalize.ts'; +import { type DescriptorSpec, ADMISSIBLE, descriptor } from './descriptor-fixtures.ts'; + +/** + * One failure mode per validator, plus the composition, plus the FR-021 case. + * + * Each `bad` differs from `ADMISSIBLE` in exactly one field, so the attribution it + * produces is unambiguous. Each `corrected` is the same descriptor with that field + * repaired — §8's third move. + */ +const FAILURE_MODES: readonly { + readonly label: string; + readonly bad: DescriptorSpec; + readonly corrected: DescriptorSpec; + readonly validators: readonly AdmissibilityValidatorName[]; +}[] = [ + { + label: 'apiVersion — two separators', + bad: { ...ADMISSIBLE, apiVersion: 'backstage.io/v1/alpha' }, + corrected: { ...ADMISSIBLE, apiVersion: 'backstage.io/v1alpha1' }, + validators: ['validateApiVersion'], + }, + { + label: 'apiVersion — prefix is not a DNS subdomain', + bad: { ...ADMISSIBLE, apiVersion: 'Backstage.io/v1alpha1' }, + corrected: { ...ADMISSIBLE, apiVersion: 'backstage.io/v1alpha1' }, + validators: ['validateApiVersion'], + }, + { + label: 'kind — leading digit', + bad: { ...ADMISSIBLE, kind: '1Component' }, + corrected: { ...ADMISSIBLE, kind: 'Component' }, + validators: ['validateKind'], + }, + { + label: 'metadata.name — character class (unsubstituted placeholder)', + bad: { ...ADMISSIBLE, name: '${{ values.name }}' }, + corrected: { ...ADMISSIBLE, name: 'bulk-import-plugin' }, + validators: ['validateEntityName'], + }, + { + label: 'metadata.name — length alone', + bad: { ...ADMISSIBLE, name: 'a'.repeat(64) }, + corrected: { ...ADMISSIBLE, name: 'a'.repeat(63) }, + validators: ['validateEntityName'], + }, + { + label: 'metadata.namespace — not a DNS label', + bad: { ...ADMISSIBLE, namespace: 'Default' }, + corrected: { ...ADMISSIBLE, namespace: 'default' }, + validators: ['validateNamespace'], + }, + { + label: 'the composition — all four failing at once', + bad: { apiVersion: 'A/B/C', kind: '1bad', name: '-bad-', namespace: 'BAD' }, + corrected: { ...ADMISSIBLE, namespace: 'default' }, + validators: [ + 'validateApiVersion', + 'validateKind', + 'validateEntityName', + 'validateNamespace', + ], + }, +]; + +describe('SC-004 — every failure mode yields `inadmissible-descriptor`', () => { + test('every one of the four fields is covered by at least one mode', () => { + const covered = new Set( + FAILURE_MODES.flatMap((mode) => classifyAdmissibility(descriptor(mode.bad)).failedFields), + ); + expect([...covered].sort()).toEqual([...ADMISSIBILITY_FIELDS].sort()); + }); + + test.each(FAILURE_MODES.map((mode) => [mode.label, mode] as const))( + '%s', + (_label, mode) => { + const outcome = admit(descriptor(mode.bad, 'packages/x/catalog-info.yaml')); + + // (1) The recorded trigger class is `inadmissible-descriptor`. + expect(outcome.admissible).toBe(false); + if (outcome.admissible) return; + expect(outcome.rejection.triggerClass).toBe('inadmissible-descriptor'); + + // (2) No canonical identity is computed or emitted for it. + const serialized = JSON.stringify(outcome); + expect(serialized).not.toContain('canonicalId'); + expect(serialized).not.toContain('allRefs'); + expect(outcome).not.toHaveProperty('admitted'); + + // (3) It is never reported as `duplicate-canonical-id`. + expect(outcome.rejection.triggerClass).not.toBe('duplicate-canonical-id'); + + // (4) The record carries all three attributions (FR-020). + const detail = outcome.rejection.detail; + expect(detail).toContain('packages/x/catalog-info.yaml'); + for (const validator of mode.validators) expect(detail).toContain(validator); + for (const field of classifyAdmissibility(descriptor(mode.bad)).failedFields) { + expect(detail).toContain(field); + } + }, + ); + + test.each(FAILURE_MODES.map((mode) => [mode.label, mode] as const))( + '%s — corrected, observed passing (\u00a78 move 3)', + (_label, mode) => { + const outcome = admit(descriptor(mode.corrected)); + expect(outcome.admissible).toBe(true); + if (!outcome.admissible) return; + expect(canonicalize(outcome.admitted).canonicalId).toMatch(/^[a-z0-9:/._-]+$/u); + }, + ); +}); + +describe('SC-004 — admissibility is decided before canonical identity is computed', () => { + test('for every failure mode, no identity exists to have been computed', () => { + for (const mode of FAILURE_MODES) { + const result = classifyAdmissibility(descriptor(mode.bad)); + expect(result.admissible).toBe(false); + // The rejection is constructible without any identity, and `AdmissibilityResult` + // has no field that could hold one. + expect(Object.keys(result).sort()).toEqual(['admissible', 'attributions', 'failedFields']); + expect(inadmissibleRejection(result).detail).not.toContain(':default/'); + } + }); + + test('canonicalization of a corrected descriptor performs both steps', () => { + // The other half of the ordering claim: once admissible, `entity-identity.md` + // §1's two steps run and produce the id the contract's worked example gives. + const omittedNamespace = admit(descriptor({ ...ADMISSIBLE, kind: 'Component', name: 'Billing' })); + expect(omittedNamespace.admissible).toBe(true); + if (!omittedNamespace.admissible) return; + expect(canonicalize(omittedNamespace.admitted).canonicalId).toBe('component:default/billing'); + + const explicitNamespace = admit( + descriptor({ ...ADMISSIBLE, kind: 'Component', name: 'Payments', namespace: 'default' }), + ); + expect(explicitNamespace.admissible).toBe(true); + if (!explicitNamespace.admissible) return; + expect(canonicalize(explicitNamespace.admitted).canonicalId).toBe('component:default/payments'); + }); + + test('`entity-identity.md` \u00a71\u2019s worked example table, both rows', () => { + // | Component / Default / Payments | component / default / payments | component:default/payments | + // | Component / omitted / Billing | component / default / billing | component:default/billing | + // + // Row 1's descriptor A carries `namespace: Default`, which ADR-0015's + // `validateNamespace` rejects — so under this feature it never reaches + // canonicalization at all. That is the narrowing ADR-0015 describes, and it is + // recorded here rather than smoothed over: the §1 example predates ADR-0015. + const uppercaseNamespace = admit( + descriptor({ ...ADMISSIBLE, kind: 'Component', name: 'Payments', namespace: 'Default' }), + ); + expect(uppercaseNamespace.admissible).toBe(false); + + const rowTwo = admit(descriptor({ ...ADMISSIBLE, kind: 'Component', name: 'Billing' })); + expect(rowTwo.admissible).toBe(true); + if (!rowTwo.admissible) return; + expect(canonicalize(rowTwo.admitted).canonicalId).toBe('component:default/billing'); + }); +}); + +describe('SC-004 — the FR-021 case is in the exercised set', () => { + test('at least one exercised descriptor is inadmissible and canonicalizes uniquely', () => { + // The full construction and its permanence are `test/inadmissible-and-unique.test.ts`. + // What SC-004 needs here is that the criterion's exercised set contains it. + const labels = FAILURE_MODES.map((mode) => mode.label); + expect(labels).toContain('metadata.name — character class (unsubstituted placeholder)'); + + const outcome = admit(descriptor({ ...ADMISSIBLE, name: '${{ values.name }}' })); + expect(outcome.admissible).toBe(false); + if (outcome.admissible) return; + expect(outcome.rejection.triggerClass).toBe('inadmissible-descriptor'); + }); + + test('length and character class are exercised as separate populations (\u00a72.1)', () => { + const byLength = classifyAdmissibility(descriptor({ ...ADMISSIBLE, name: 'a'.repeat(64) })); + const byCharacterClass = classifyAdmissibility( + descriptor({ ...ADMISSIBLE, name: '${{ values.name }}' }), + ); + expect(byLength.failedFields).toEqual(['metadata.name']); + expect(byCharacterClass.failedFields).toEqual(['metadata.name']); + // Same field, different populations. The `observed` value is what keeps them + // distinguishable in a report. + expect(byLength.attributions[0]?.observed).not.toBe( + byCharacterClass.attributions[0]?.observed, + ); + }); +}); diff --git a/packages/adapters/catalog-backstage/test/sc-005.test.ts b/packages/adapters/catalog-backstage/test/sc-005.test.ts new file mode 100644 index 00000000..25444fde --- /dev/null +++ b/packages/adapters/catalog-backstage/test/sc-005.test.ts @@ -0,0 +1,162 @@ +/** + * T061 — SC-005 close-out. + * + * SC-005 (`spec.md`): *"Across a fixture set containing all three ownership states, + * each entity's recorded `ownershipState` is exactly one of `explicit-paths`, + * `explicit-empty`, `annotation-absent`; no two are treated as equivalent; and no + * path is ever derived for an `annotation-absent` entity."* + * + * This file consolidates over a single fixture set containing all three, rather than + * restating T057–T060's individual contract citations. The fixture set is + * hand-authored YAML, decoded through the real descriptor reader, so the three + * states arise the way they would in a run rather than by being constructed + * directly. + */ + +import { describe, expect, test } from 'bun:test'; +import { readAnnotationNode, readDescriptorDocuments } from '../src/descriptor/read.ts'; +import { createGlobCompiler } from '../src/glob/dialect.ts'; +import { OWNED_PATHS_ANNOTATION } from '../src/ownership/annotation.ts'; +import { type OwnershipDerivation, deriveOwnership } from '../src/ownership/derive.ts'; +import { OWNERSHIP_STATES, type OwnershipState } from '../src/ownership/states.ts'; + +/** One descriptor per state, in one file, as a real corpus file might hold them. */ +const FIXTURE_SET = [ + // explicit-paths + 'apiVersion: backstage.io/v1alpha1', + 'kind: Component', + 'metadata:', + ' name: payments', + ' annotations:', + ' adrkit.io/owned-paths: \'["packages/payments/**", "docs/payments/*.md"]\'', + '---', + // explicit-empty + 'apiVersion: backstage.io/v1alpha1', + 'kind: Component', + 'metadata:', + ' name: billing', + ' annotations:', + " adrkit.io/owned-paths: '[]'", + '---', + // annotation-absent + 'apiVersion: backstage.io/v1alpha1', + 'kind: Component', + 'metadata:', + ' name: shipping', + '', +].join('\n'); + +const compiler = createGlobCompiler(); + +const derivations: readonly { readonly name: string; readonly derivation: OwnershipDerivation }[] = + readDescriptorDocuments('catalog-info.yaml', FIXTURE_SET).map((document) => { + const node = readAnnotationNode(document, OWNED_PATHS_ANNOTATION); + const metadata = document.rawMetadata as { name: string }; + return { name: metadata.name, derivation: deriveOwnership(node.present, node.value, compiler) }; + }); + +function stateOf(name: string): OwnershipState { + const found = derivations.find((entry) => entry.name === name); + if (found === undefined || !found.derivation.ok) { + throw new Error(`fixture ${name} did not derive`); + } + return found.derivation.value.ownershipState; +} + +function pathsOf(name: string): readonly string[] { + const found = derivations.find((entry) => entry.name === name); + if (found === undefined || !found.derivation.ok) { + throw new Error(`fixture ${name} did not derive`); + } + return found.derivation.value.derivedPaths; +} + +describe('SC-005 — the fixture set contains all three states', () => { + test('three descriptors parsed, each deriving successfully', () => { + expect(derivations).toHaveLength(3); + expect(derivations.every((entry) => entry.derivation.ok)).toBe(true); + }); + + test('all three states are present, and they are the three the contract names', () => { + const observed = derivations.map((entry) => + entry.derivation.ok ? entry.derivation.value.ownershipState : 'failed', + ); + expect(observed).toEqual(['explicit-paths', 'explicit-empty', 'annotation-absent']); + expect([...new Set(observed)].sort()).toEqual([...OWNERSHIP_STATES].sort()); + }); +}); + +describe('SC-005 — each recorded state is exactly one of the three', () => { + test.each(['payments', 'billing', 'shipping'])('%s', (name) => { + const state = stateOf(name); + expect(OWNERSHIP_STATES).toContain(state); + expect(OWNERSHIP_STATES.filter((candidate) => candidate === state)).toHaveLength(1); + }); + + test('no entity carries a fourth state, or two', () => { + for (const entry of derivations) { + expect(entry.derivation.ok).toBe(true); + if (!entry.derivation.ok) continue; + expect(typeof entry.derivation.value.ownershipState).toBe('string'); + expect(OWNERSHIP_STATES).toContain(entry.derivation.value.ownershipState); + } + }); +}); + +describe('SC-005 — no two states are treated as equivalent', () => { + test('the three states are three distinct values', () => { + expect(new Set([stateOf('payments'), stateOf('billing'), stateOf('shipping')]).size).toBe(3); + }); + + test('`explicit-empty` and `annotation-absent` are distinguished despite identical paths', () => { + expect(pathsOf('billing')).toEqual(pathsOf('shipping')); + expect(stateOf('billing')).not.toBe(stateOf('shipping')); + }); + + test('the distinction survives serialization, so an envelope could carry it', () => { + // The non-conflation rule is about what the *record* preserves, not only about + // what an in-memory value happens to hold. + const billing = JSON.parse( + JSON.stringify(derivations.find((entry) => entry.name === 'billing')), + ) as { derivation: { value: { ownershipState: string } } }; + const shipping = JSON.parse( + JSON.stringify(derivations.find((entry) => entry.name === 'shipping')), + ) as { derivation: { value: { ownershipState: string } } }; + + expect(billing.derivation.value.ownershipState).toBe('explicit-empty'); + expect(shipping.derivation.value.ownershipState).toBe('annotation-absent'); + }); + + test('the presence discriminant differs even where the state name were ignored', () => { + const billing = derivations.find((entry) => entry.name === 'billing'); + const shipping = derivations.find((entry) => entry.name === 'shipping'); + if (billing === undefined || shipping === undefined) return; + if (!billing.derivation.ok || !shipping.derivation.ok) return; + expect(billing.derivation.value.annotation.annotationPresent).toBe(true); + expect(shipping.derivation.value.annotation.annotationPresent).toBe(false); + }); +}); + +describe('SC-005 — no path is ever derived for an `annotation-absent` entity', () => { + test('`shipping` derives nothing', () => { + expect(stateOf('shipping')).toBe('annotation-absent'); + expect(pathsOf('shipping')).toEqual([]); + }); + + test('and no pattern was even classified for it', () => { + const shipping = derivations.find((entry) => entry.name === 'shipping'); + if (shipping === undefined || !shipping.derivation.ok) return; + expect(shipping.derivation.value.patterns).toEqual([]); + }); + + test('the glob compiler was never invoked on its behalf', () => { + // Two patterns exist in the whole fixture set, both on `payments`. If an absent + // annotation had derived anything, the count would be higher. + expect(compiler.patternCount).toBe(2); + expect(compiler.compileCount).toBe(2); + }); + + test('`explicit-paths` did derive, so "derives nothing" is not vacuous', () => { + expect(pathsOf('payments')).toEqual(['docs/payments/*.md', 'packages/payments/**']); + }); +}); diff --git a/packages/adapters/catalog-backstage/test/sc-006.test.ts b/packages/adapters/catalog-backstage/test/sc-006.test.ts new file mode 100644 index 00000000..1129add2 --- /dev/null +++ b/packages/adapters/catalog-backstage/test/sc-006.test.ts @@ -0,0 +1,195 @@ +/** + * T062 — SC-006 close-out. + * + * SC-006 (`spec.md`): *"Each of the annotation's ordered decode/validate steps + * produces its own distinct rejection reason when violated in isolation, and a + * non-string YAML node is rejected by the string-scalar check **before** any JSON + * parse is attempted."* + * + * Five steps; **three** of them can produce a rejection reason of their own — steps + * 2, 3 and 4. Step 1 (presence) has no rejection: an absent annotation is the + * legitimate `annotation-absent` state, not an error. Step 5 delegates to the glob + * dialect, whose reasons are that contract's and are closed out by SC-007 + * (`test/glob-rules.test.ts`). + * + * That asymmetry is stated here rather than left implicit, because "five steps, + * three reasons" otherwise reads as two missing cases. + * + * Each step is exercised **in isolation**: every fixture below violates exactly one + * step, so the reason it produces cannot have come from a neighbouring one. + */ + +import { describe, expect, test } from 'bun:test'; +import { + ANNOTATION_REJECTION_STEP, + type AnnotationRejectionReason, + decodeAnnotation, +} from '../src/ownership/annotation.ts'; +import { deriveOwnership } from '../src/ownership/derive.ts'; + +/** One violation per rejecting step, each violating that step and no other. */ +const STEP_FIXTURES: readonly { + readonly step: number; + readonly label: string; + readonly present: boolean; + readonly rawNode: unknown; + readonly reason: AnnotationRejectionReason; +}[] = [ + { + step: 2, + label: 'string-scalar check on the raw node', + present: true, + rawNode: ['[]'], + reason: 'annotation-value-not-a-string', + }, + { + step: 3, + label: 'JSON decode', + present: true, + rawNode: '["packages/payments/**', + reason: 'parse-error', + }, + { + step: 4, + label: 'shape: exactly array', + present: true, + rawNode: '{"paths": ["a/**"]}', + reason: 'wrong-shape', + }, +]; + +describe('SC-006 — the step/reason map', () => { + test('three steps produce a rejection reason of their own', () => { + expect(Object.keys(ANNOTATION_REJECTION_STEP)).toHaveLength(3); + expect(ANNOTATION_REJECTION_STEP).toEqual({ + 'annotation-value-not-a-string': 2, + 'parse-error': 3, + 'wrong-shape': 4, + }); + }); + + test('step 1 has no rejection — absence is a state, not an error', () => { + const result = decodeAnnotation(false, undefined); + expect(result.ok).toBe(true); + expect(Object.values(ANNOTATION_REJECTION_STEP)).not.toContain(1); + }); + + test('step 5 delegates its reasons to the glob dialect', () => { + // Closed out by SC-007. What SC-006 needs is that step 5's failure is reported + // under the pattern vocabulary, not the annotation one. + const derivation = deriveOwnership(true, '["packages/{a}/**"]'); + expect(derivation.ok).toBe(false); + if (derivation.ok) return; + expect(derivation.rejection.reason).toBe('invalid-pattern'); + expect(Object.keys(ANNOTATION_REJECTION_STEP)).not.toContain('invalid-pattern'); + expect(derivation.pattern?.outcome).toBe('brace'); + expect(Object.values(ANNOTATION_REJECTION_STEP)).not.toContain(5); + }); +}); + +describe('SC-006 — each step, violated in isolation, produces its own reason', () => { + test.each(STEP_FIXTURES.map((fixture) => [fixture.step, fixture.label, fixture] as const))( + 'step %d — %s', + (_step, _label, fixture) => { + const result = decodeAnnotation(fixture.present, fixture.rawNode); + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.rejection.reason).toBe(fixture.reason); + expect(ANNOTATION_REJECTION_STEP[result.rejection.reason]).toBe(fixture.step); + }, + ); + + test('the three reasons are mutually distinct', () => { + const reasons = STEP_FIXTURES.map((fixture) => { + const result = decodeAnnotation(fixture.present, fixture.rawNode); + return result.ok ? 'accepted' : result.rejection.reason; + }); + expect(new Set(reasons).size).toBe(3); + }); + + test('each fixture violates exactly one step — the earlier ones all pass', () => { + // Step 3's fixture is a string (step 2 passes). Step 4's fixture is a string + // that parses (steps 2 and 3 pass). Read off the diagnostics rather than + // asserted from the outside. + const parseError = decodeAnnotation(true, '["packages/payments/**'); + expect(parseError.ok).toBe(false); + if (parseError.ok) return; + expect(parseError.diagnostics.rawNodeIsString).toBe(true); + + const wrongShape = decodeAnnotation(true, '{"paths": ["a/**"]}'); + expect(wrongShape.ok).toBe(false); + if (wrongShape.ok) return; + expect(wrongShape.diagnostics.rawNodeIsString).toBe(true); + expect(wrongShape.diagnostics.jsonParseOutcome).toBe('parsed'); + }); +}); + +describe('SC-006 — the string-scalar check runs BEFORE any JSON parse', () => { + test('the diagnostics record shows the parse was never reached', () => { + const result = decodeAnnotation(true, ['[]']); + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.diagnostics.jsonParseOutcome).toBe('not-a-string'); + expect(result.diagnostics.shapeOutcome).toBeUndefined(); + }); + + test.each([ + ['a sequence that would parse cleanly', ['[]'], 'annotation-value-not-a-string'], + ['a mapping that would fail to parse', { a: 1 }, 'annotation-value-not-a-string'], + ['a number that would parse to a number', 3, 'annotation-value-not-a-string'], + ['a boolean that would parse to a boolean', true, 'annotation-value-not-a-string'], + ] as const)('%s still reports step 2', (_label, node, expected) => { + // Each of these would produce a *different* downstream reason if step 2 were + // skipped — `explicit-empty`, `parse-error`, `wrong-shape`, `wrong-shape`. That + // they all report step 2 is what shows the ordering rather than a coincidence. + const result = decodeAnnotation(true, node); + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.rejection.reason).toBe(expected); + }); + + test('the misclassification step 2 prevents is named, and does not happen', () => { + // FR-027's specific danger: `["[]"]` becoming `explicit-empty`. + const derivation = deriveOwnership(true, ['[]']); + expect(derivation.ok).toBe(false); + expect(JSON.stringify(derivation)).not.toContain('explicit-empty'); + }); +}); + +describe('SC-006 — the ordering is not reversible', () => { + test('a valid annotation still passes every step in order', () => { + const result = decodeAnnotation(true, '["packages/payments/**"]'); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.value.diagnostics).toEqual({ + annotationPresent: true, + rawNodeIsString: true, + jsonParseOutcome: 'parsed', + shapeOutcome: 'array-of-strings', + rejectionReason: undefined, + }); + }); + + test('an absent annotation records no downstream step outcome at all', () => { + const result = decodeAnnotation(false, undefined); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.value.diagnostics).toEqual({ + annotationPresent: false, + rawNodeIsString: undefined, + jsonParseOutcome: undefined, + shapeOutcome: undefined, + rejectionReason: undefined, + }); + }); + + test('step 5 is unreachable until steps 1\u20134 succeed', () => { + // The decode module has no access to the glob validator, so it *cannot* have + // validated a pattern early — structural, not a matter of call order. + const source = Bun.file(new URL('../src/ownership/annotation.ts', import.meta.url)); + return source.text().then((text) => { + expect(text).not.toContain("from '../glob/"); + expect(text).not.toContain('validateGlobPattern'); + }); + }); +}); diff --git a/packages/adapters/catalog-backstage/test/sc-008.test.ts b/packages/adapters/catalog-backstage/test/sc-008.test.ts new file mode 100644 index 00000000..0eac28b3 --- /dev/null +++ b/packages/adapters/catalog-backstage/test/sc-008.test.ts @@ -0,0 +1,179 @@ +/** + * T046 — SC-008 close-out. + * + * SC-008 (`spec.md`): *"A repository-identity or revision mismatch between the + * manifest and the actual checkout aborts before any entity's paths are derived, in + * every tested case; repository identity is never read from a descriptor + * annotation; and a source path that lexically passes but resolves outside the + * verified checkout root fails closed."* + * + * T046's own framing is the same claim stated from the other side: *every input + * reaching the adapter arrived through the declared manifest and through no other + * route.* + * + * This file consolidates; it does not restate. T041–T045 each demonstrate their own + * rule against the frozen contracts. What is asserted here is the property none of + * them can assert alone — that the manifest is the **only** route, so that closing + * it closes everything. + * + * **What "aborts before any entity's paths are derived" means here, honestly.** + * Phase D builds pure validators; the assembled pipeline that would *do* the + * aborting is Phase E, behind Barrier B. So this file asserts the property that is + * available before the barrier and is what makes the Phase E ordering possible: the + * boundary checks are total functions over the manifest that produce no derived + * path under any input, and there exists no route by which a descriptor could + * supply repository identity. It does **not** claim a pipeline has been run. + */ + +import { describe, expect, test } from 'bun:test'; +import { + PERMITTED_GIT_READS, + admissibleReadSet, + classifyLocationTarget, +} from '../src/manifest/boundary.ts'; +import { verifySourceDigests } from '../src/manifest/digests.ts'; +import { validateSourcePath } from '../src/manifest/paths.ts'; +import { validateManifestShape } from '../src/manifest/schema.ts'; +import { checkManifestVersions } from '../src/manifest/version.ts'; +import { compareRepositoryIdentity } from '../src/repository/identity.ts'; +import { ADAPTER_ROOT, importSpecifiers, scanned } from './source-scan.ts'; + +const MANIFEST = validateManifestShape({ + manifestSchemaVersion: '1', + requestedSnapshotSchemaVersion: '1', + requiredCapabilities: ['pathOwnership'], + repository: { id: 'github.com/mbeacom/fixture', revision: '0'.repeat(40) }, + sources: [ + { path: 'catalog-info.yaml', digestAlgorithm: 'sha256', digest: 'a'.repeat(64) }, + { path: 'packages/payments/catalog-info.yaml', digestAlgorithm: 'sha256', digest: 'b'.repeat(64) }, + ], +}); + +if (!MANIFEST.ok) throw new Error('the SC-008 fixture manifest failed the closed schema'); +const manifest = MANIFEST.value; + +describe('SC-008 — the manifest is the only route in', () => { + test('the read set is exactly the manifest, its sources, and the two git reads', () => { + const readSet = admissibleReadSet('adrkit-manifest.json', manifest); + expect(readSet.sourcePaths).toEqual([ + 'catalog-info.yaml', + 'packages/payments/catalog-info.yaml', + ]); + expect(readSet.gitReads).toEqual(PERMITTED_GIT_READS); + expect(Object.keys(readSet).sort()).toEqual(['gitReads', 'manifestPath', 'sourcePaths']); + }); + + test('nothing outside the manifest is admitted, however plausible its name', () => { + const readSet = admissibleReadSet('adrkit-manifest.json', manifest); + for (const plausible of [ + 'catalog-info.yml', + './catalog-info.yaml', + 'packages/billing/catalog-info.yaml', + 'CATALOG-INFO.YAML', + ]) { + expect(classifyLocationTarget(readSet, plausible).outcome).toBe( + 'zero-derived-paths-never-read', + ); + } + }); + + test('the boundary widens with the manifest and by no other means', () => { + const narrow = admissibleReadSet('m.json', manifest); + const widened = validateManifestShape({ + manifestSchemaVersion: '1', + requestedSnapshotSchemaVersion: '1', + requiredCapabilities: ['pathOwnership'], + repository: { id: 'github.com/mbeacom/fixture', revision: '0'.repeat(40) }, + sources: [ + ...manifest.sources, + { path: 'packages/billing/catalog-info.yaml', digestAlgorithm: 'sha256', digest: 'c'.repeat(64) }, + ], + }); + expect(widened.ok).toBe(true); + if (!widened.ok) return; + expect(admissibleReadSet('m.json', widened.value).sourcePaths).toHaveLength( + narrow.sourcePaths.length + 1, + ); + }); +}); + +describe('SC-008 — repository identity is never read from a descriptor', () => { + test('the comparison takes the manifest and observed git state, and nothing else', () => { + // A descriptor cannot reach this function: there is no parameter for one. + expect(compareRepositoryIdentity.length).toBe(2); + }); + + test('a mismatch is reported without any descriptor having been consulted', () => { + const result = compareRepositoryIdentity( + { id: manifest.repository.id, revision: manifest.repository.revision }, + { remoteRaw: 'git@github.com:mbeacom/some-other.git', head: '1'.repeat(40) }, + ); + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.rejection.triggerClass).toBe('repository-mismatch'); + }); + + test('no adapter source names the `github.com/project-slug` annotation', () => { + // `input-manifest.md` §3 singles this annotation out, because it is the one a + // reasonable implementer would reach for. Scanning for it by name is the + // cheapest way to notice if someone later does. + // + // Scoped to `src/`, and that scope is the honest one: the two test files that + // contain the string contain it *in order to assert its absence*, so including + // them would make the guard forbid its own statement. + const sources = scanned(ADAPTER_ROOT).filter((file) => + file.path.includes('/catalog-backstage/src/'), + ); + expect(sources.length).toBeGreaterThan(5); + expect(sources.filter((file) => file.code.includes('project-slug')).map((f) => f.path)).toEqual( + [], + ); + }); +}); + +describe('SC-008 — the boundary checks derive no path under any input', () => { + test('a lexically clean path that escapes the root fails closed', async () => { + // The full symlink construction lives in `manifest-paths.test.ts`; what is + // consolidated here is that the escape reaches a rejection rather than a value. + const result = await validateSourcePath('/nonexistent-root-9d1f', '../../etc/passwd'); + expect(result.ok).toBe(false); + }); + + test('every boundary check returns a verdict, never a derived path', async () => { + const results: unknown[] = [ + validateManifestShape({}), + checkManifestVersions(manifest), + compareRepositoryIdentity( + { id: 'github.com/a/b', revision: '0'.repeat(40) }, + { remoteRaw: 'git@github.com:a/b.git', head: '0'.repeat(40) }, + ), + await validateSourcePath('/nonexistent-root-9d1f', 'catalog-info.yaml'), + await verifySourceDigests([], () => '/nowhere'), + ]; + + for (const result of results) { + const serialized = JSON.stringify(result) ?? ''; + expect(serialized).not.toContain('derivedPaths'); + expect(serialized).not.toContain('ownershipState'); + } + }); + + test('no boundary module imports the ownership or glob slices', () => { + // Structural, not stylistic: if a boundary check could reach the ownership + // derivation, "aborts before any entity's paths are derived" would be a + // property of call order rather than of the module graph. + const boundaryModules = scanned(ADAPTER_ROOT).filter( + (file) => + file.path.includes('/src/manifest/') || file.path.includes('/src/repository/'), + ); + expect(boundaryModules.length).toBeGreaterThan(0); + + for (const file of boundaryModules) { + for (const specifier of importSpecifiers(file.code)) { + expect(specifier).not.toContain('ownership/'); + expect(specifier).not.toContain('glob/'); + expect(specifier).not.toContain('identity/canonicalize'); + } + } + }); +}); diff --git a/specs/010-catalog-backstage/evidence/negative-cases/admissibility-validators/README.md b/specs/010-catalog-backstage/evidence/negative-cases/admissibility-validators/README.md new file mode 100644 index 00000000..65a8eccc --- /dev/null +++ b/specs/010-catalog-backstage/evidence/negative-cases/admissibility-validators/README.md @@ -0,0 +1,134 @@ +# Negative case: the four admissibility field validators, separately attributed + +**Task**: T049 · **Discharges**: FR-016 · **Supports**: FR-017, SC-004 +**Observed against**: `99ba8d2500eaf37625ea164f66b4a17870e40dad` plus Phase D's own +uncommitted work; the mutation under test was the only additional change in the tree. +**Tools**: Bun 1.3.14, TypeScript 6.0.3 +**Command for every case below**: `bun test test/admissibility-validators.test.ts`, run +from `packages/adapters/catalog-backstage/` +**Permanent automated case**: `packages/adapters/catalog-backstage/test/admissibility-validators.test.ts` + +## The warrant, before anything else + +Every statement here is a statement about **what a pure validator predicate returns when +invoked**, at Backstage commit `1121a4facd9e321179d0402c3f355e4a649e84d9`. It is **not** +a statement about what Backstage as a running system does with a descriptor. This +feature has never run one and will not. The pin is load-bearing: a different commit is a +different predicate and therefore a different contract (`admissibility.md` §1). + +## Each of the four, observed failing independently + +`admissibility.md` §8 requires each of the four predicates and the composition to land by +three moves: construct a descriptor that should fail *that specific* validator, observe +the failure and record the exact reason, then correct the input and observe the pass. + +| # | Validator disabled | Pinned binding it reproduces (ADR-0015) | Tests failing | +|---|---|---|---| +| 1 | `validateApiVersion` — the two-or-more-separator rejection only | `isValidApiVersion` → `CommonValidatorFunctions.isValidPrefixAndOrSuffix` | 1 | +| 2 | `validateKind` | `isValidKind` | 4 | +| 3 | `validateEntityName` | `isValidEntityName` → `KubernetesValidatorFunctions.isValidObjectName` | 5 | +| 4 | `validateNamespace` | `isValidNamespace` → `KubernetesValidatorFunctions.isValidNamespace` → `CommonValidatorFunctions.isValidDnsLabel` | 5 | + +--- + +## Case 1 — `validateApiVersion`, separator rule (FR-017) + +Input: [`case-1-validateApiVersion-separator-rule.patch`](./case-1-validateApiVersion-separator-rule.patch) · +Output: [`case-1-validateApiVersion-separator-rule.observed.txt`](./case-1-validateApiVersion-separator-rule.observed.txt) + +Only the `too-many-separators` branch was flipped, leaving the prefix and suffix +predicates intact. + +``` +(fail) T049 — `validateApiVersion` > the four facts ADR-0015 recorded as executed against the pin +Expected: false +Received: true +``` + +That test pins the four `apiVersion` facts ADR-0015 records as obtained by **executing** +the pinned sources rather than reading them — a 243-character prefix passes; a +254-character one, an over-63 label, and a two-separator value all fail. They are the +only admissibility expectations in the source documents obtained that way, which is why +they are asserted as a group. + +## Case 2 — `validateKind` + +Input: [`case-2-validateKind.patch`](./case-2-validateKind.patch) · +Output: [`case-2-validateKind.observed.txt`](./case-2-validateKind.observed.txt) + +``` +(fail) ... > a leading digit fails — the first character class excludes it +(fail) ... > punctuation fails +(fail) ... > the ≤63 bound holds at the boundary +Expected: false +Received: true +``` + +## Case 3 — `validateEntityName` + +Input: [`case-3-validateEntityName.patch`](./case-3-validateEntityName.patch) · +Output: [`case-3-validateEntityName.observed.txt`](./case-3-validateEntityName.observed.txt) + +``` +(fail) ... > a leading or trailing separator fails +(fail) ... > the unsubstituted scaffolder placeholders ADR-0015 names all fail +(fail) ... > the ≤63 bound holds at the boundary +Expected: false +Received: true +``` + +The second failure covers the three placeholder forms ADR-0015 names — +`${{ values.name | dump }}`, `${{ values.name }}`, `${{ values.entityName }}` — which +that record says "fail `isValidObjectName` on character class: `$`, `{`, `}` and the +spaces are outside the permitted set in every case, and `|` in the first." + +## Case 4 — `validateNamespace` + +Input: [`case-4-validateNamespace.patch`](./case-4-validateNamespace.patch) · +Output: [`case-4-validateNamespace.observed.txt`](./case-4-validateNamespace.observed.txt) + +``` +(fail) ... > uppercase fails — this is where ADR-0015 and `admissibility.md` §2 diverge +(fail) ... > a leading or trailing hyphen fails +(fail) ... > an empty namespace fails — present-and-empty is not omitted +Expected: false +Received: true +``` + +--- + +## Two contract discrepancies this case surfaced, reported not resolved + +1. **The namespace character class.** `contracts/admissibility.md` §2's summary table + gives `metadata.namespace` the class "`[A-Za-z0-9]` plus `-`, `_`, `.`" — the same + class it gives `metadata.name`. ADR-0015 and FR-016 give it the DNS-label predicate + `/^[a-z0-9]+(?:\-+[a-z0-9]+)*$/`, which admits **no** uppercase, **no** `_`, and + **no** `.`. FR-016 requires "exactly the four field validators in ADR-0015's table", + so ADR-0015 governs. The failing test named in case 4 is the one that would pass if + someone implemented §2's summary instead. +2. **`AdmissibilityField`.** `data-model.md` §4 types it as + `"kind" | "metadata.name" | "metadata.namespace" | "spec.type"` — omitting + `apiVersion`, which ADR-0015 requires, and adding `spec.type`, which no validator in + the table covers. ADR-0015's four fields are used. + +## One composition, flagged rather than buried + +ADR-0015 states `isValidDnsSubdomain`'s **bounds** (≤253 total, each dot-separated label +≤63) but not the character class of an individual label. It states +`CommonValidatorFunctions.isValidDnsLabel`'s predicate in the namespace row of the same +table. A subdomain is therefore implemented as *dot-separated labels each satisfying the +stated `isValidDnsLabel` predicate*, bounded as stated — a composition of two things the +table says, not an invention, but the only place transcription was insufficient. Case 1 +is the check that this composition reproduces ADR-0015's own executed observations. + +## Restored + +[`restored.observed.txt`](./restored.observed.txt) — all 30 tests pass, 0 fail. + +## Standing constraints + +ADR-0014 **rung 1 only**. `admissibility.md` §7: an admissibility pass warrants exactly +one sentence — *the four validator predicates at the pinned commit returned true for +this descriptor's four fields.* It does not warrant that the descriptor is valid, +correct, well-formed, or accepted; that Backstage would ingest it; or that any path +derived from it is a path anyone actually owns. diff --git a/specs/010-catalog-backstage/evidence/negative-cases/admissibility-validators/case-1-validateApiVersion-separator-rule.observed.txt b/specs/010-catalog-backstage/evidence/negative-cases/admissibility-validators/case-1-validateApiVersion-separator-rule.observed.txt new file mode 100644 index 00000000..fc7bb4e0 --- /dev/null +++ b/specs/010-catalog-backstage/evidence/negative-cases/admissibility-validators/case-1-validateApiVersion-separator-rule.observed.txt @@ -0,0 +1,54 @@ +bun test v1.3.14 (0d9b296a) + +test/admissibility-validators.test.ts: +(pass) T049 — the table has exactly four rows, bound as ADR-0015 binds them > four fields, in ADR-0015’s table order [0.03ms] +(pass) T049 — the table has exactly four rows, bound as ADR-0015 binds them > four validators, one per field [0.03ms] +(pass) T049 — the table has exactly four rows, bound as ADR-0015 binds them > each validator records the pinned binding it reproduces [0.02ms] +(pass) T049 — the table has exactly four rows, bound as ADR-0015 binds them > the pin is ADR-0012’s commit, carried unchanged +(pass) T049 — the table has exactly four rows, bound as ADR-0015 binds them > the bounds are the ones ADR-0015 states +105 | expect(validateApiVersion(`${tooLong}/v1`)).toBe(false); +106 | +107 | const overLongLabel = `${'a'.repeat(64)}.example`; +108 | expect(validateApiVersion(`${overLongLabel}/v1`)).toBe(false); +109 | +110 | expect(validateApiVersion('backstage.io/v1/alpha')).toBe(false); + ^ +error: expect(received).toBe(expected) + +Expected: false +Received: true + + at (/Users/markbeacom/github/mbeacom/copilot-worktrees/adrkit/mbeacom-symmetrical-disco/packages/adapters/catalog-backstage/test/admissibility-validators.test.ts:110:57) +(fail) T049 — `validateApiVersion` > the four facts ADR-0015 recorded as executed against the pin [0.32ms] +(pass) T049 — `validateApiVersion` > a bare `v1` passes without the subdomain rule being consulted (FR-017) [0.03ms] +(pass) T049 — `validateApiVersion` > the ordinary Backstage form passes [0.02ms] +(pass) T049 — `validateApiVersion` > the suffix predicate is `/^[a-z0-9A-Z]+$/`, so punctuation fails [0.01ms] +(pass) T049 — `validateApiVersion` > the prefix is a DNS subdomain, so uppercase and underscores fail +(pass) T049 — `validateApiVersion` > a non-string is not a valid apiVersion [0.04ms] +(pass) T049 — `validateKind` is `/^[a-zA-Z][a-z0-9A-Z]*$/`, ≤63 > ordinary kinds pass [0.03ms] +(pass) T049 — `validateKind` is `/^[a-zA-Z][a-z0-9A-Z]*$/`, ≤63 > a leading digit fails — the first character class excludes it [0.02ms] +(pass) T049 — `validateKind` is `/^[a-zA-Z][a-z0-9A-Z]*$/`, ≤63 > punctuation fails [0.01ms] +(pass) T049 — `validateKind` is `/^[a-zA-Z][a-z0-9A-Z]*$/`, ≤63 > the ≤63 bound holds at the boundary [0.02ms] +(pass) T049 — `validateKind` is `/^[a-zA-Z][a-z0-9A-Z]*$/`, ≤63 > a non-string is not a valid kind [0.01ms] +(pass) T049 — `validateEntityName` is `/^([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9]$/`, ≤63 > ordinary names pass, including mixed case, `-`, `_` and `.` [0.04ms] +(pass) T049 — `validateEntityName` is `/^([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9]$/`, ≤63 > a leading or trailing separator fails [0.02ms] +(pass) T049 — `validateEntityName` is `/^([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9]$/`, ≤63 > the unsubstituted scaffolder placeholders ADR-0015 names all fail [0.01ms] +(pass) T049 — `validateEntityName` is `/^([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9]$/`, ≤63 > the ≤63 bound holds at the boundary +(pass) T049 — `validateEntityName` is `/^([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9]$/`, ≤63 > length and character class are different populations (§2.1) [0.01ms] +(pass) T049 — `validateEntityName` is `/^([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9]$/`, ≤63 > an empty name fails +(pass) T049 — `validateNamespace` is `/^[a-z0-9]+(?:\-+[a-z0-9]+)*$/`, ≤63 > ordinary DNS labels pass [0.02ms] +(pass) T049 — `validateNamespace` is `/^[a-z0-9]+(?:\-+[a-z0-9]+)*$/`, ≤63 > uppercase fails — this is where ADR-0015 and `admissibility.md` §2 diverge +(pass) T049 — `validateNamespace` is `/^[a-z0-9]+(?:\-+[a-z0-9]+)*$/`, ≤63 > a leading or trailing hyphen fails +(pass) T049 — `validateNamespace` is `/^[a-z0-9]+(?:\-+[a-z0-9]+)*$/`, ≤63 > an empty namespace fails — present-and-empty is not omitted [0.02ms] +(pass) T049 — `validateNamespace` is `/^[a-z0-9]+(?:\-+[a-z0-9]+)*$/`, ≤63 > the ≤63 bound holds at the boundary [0.01ms] +(pass) T049 — the four predicates are independent > each rejects an input the other three accept in their own field [0.02ms] + +1 tests failed: +(fail) T049 — `validateApiVersion` > the four facts ADR-0015 recorded as executed against the pin [0.32ms] + + 27 pass + 1 fail + 92 expect() calls +Ran 28 tests across 1 file. [7.00ms] + +exit 1 diff --git a/specs/010-catalog-backstage/evidence/negative-cases/admissibility-validators/case-1-validateApiVersion-separator-rule.patch b/specs/010-catalog-backstage/evidence/negative-cases/admissibility-validators/case-1-validateApiVersion-separator-rule.patch new file mode 100644 index 00000000..ffe77703 --- /dev/null +++ b/specs/010-catalog-backstage/evidence/negative-cases/admissibility-validators/case-1-validateApiVersion-separator-rule.patch @@ -0,0 +1,13 @@ +diff --git a/packages/adapters/catalog-backstage/src/admissibility/validators.ts b/packages/adapters/catalog-backstage/src/admissibility/validators.ts +index a2ecf3b..1bafeae 100644 +--- a/packages/adapters/catalog-backstage/src/admissibility/validators.ts ++++ b/packages/adapters/catalog-backstage/src/admissibility/validators.ts +@@ -167,7 +167,7 @@ export function validateApiVersion(value: unknown): boolean { + const split = splitOnSeparator(value); + switch (split.kind) { + case 'too-many-separators': +- return false; ++ return true; + case 'suffix-only': + return split.suffix.length <= FIELD_MAX && API_VERSION_SUFFIX.test(split.suffix); + case 'prefix-and-suffix': diff --git a/specs/010-catalog-backstage/evidence/negative-cases/admissibility-validators/case-2-validateKind.observed.txt b/specs/010-catalog-backstage/evidence/negative-cases/admissibility-validators/case-2-validateKind.observed.txt new file mode 100644 index 00000000..7d24ba72 --- /dev/null +++ b/specs/010-catalog-backstage/evidence/negative-cases/admissibility-validators/case-2-validateKind.observed.txt @@ -0,0 +1,96 @@ +bun test v1.3.14 (0d9b296a) + +test/admissibility-validators.test.ts: +(pass) T049 — the table has exactly four rows, bound as ADR-0015 binds them > four fields, in ADR-0015’s table order [0.03ms] +(pass) T049 — the table has exactly four rows, bound as ADR-0015 binds them > four validators, one per field [0.03ms] +(pass) T049 — the table has exactly four rows, bound as ADR-0015 binds them > each validator records the pinned binding it reproduces [0.02ms] +(pass) T049 — the table has exactly four rows, bound as ADR-0015 binds them > the pin is ADR-0012’s commit, carried unchanged +(pass) T049 — the table has exactly four rows, bound as ADR-0015 binds them > the bounds are the ones ADR-0015 states +(pass) T049 — `validateApiVersion` > the four facts ADR-0015 recorded as executed against the pin [0.22ms] +(pass) T049 — `validateApiVersion` > a bare `v1` passes without the subdomain rule being consulted (FR-017) [0.01ms] +(pass) T049 — `validateApiVersion` > the ordinary Backstage form passes [0.01ms] +(pass) T049 — `validateApiVersion` > the suffix predicate is `/^[a-z0-9A-Z]+$/`, so punctuation fails [0.01ms] +(pass) T049 — `validateApiVersion` > the prefix is a DNS subdomain, so uppercase and underscores fail +(pass) T049 — `validateApiVersion` > a non-string is not a valid apiVersion [0.03ms] +(pass) T049 — `validateKind` is `/^[a-zA-Z][a-z0-9A-Z]*$/`, ≤63 > ordinary kinds pass [0.10ms] +145 | expect(validateKind(kind)).toBe(true); +146 | } +147 | }); +148 | +149 | test('a leading digit fails — the first character class excludes it', () => { +150 | expect(validateKind('1Component')).toBe(false); + ^ +error: expect(received).toBe(expected) + +Expected: false +Received: true + + at (/Users/markbeacom/github/mbeacom/copilot-worktrees/adrkit/mbeacom-symmetrical-disco/packages/adapters/catalog-backstage/test/admissibility-validators.test.ts:150:40) +(fail) T049 — `validateKind` is `/^[a-zA-Z][a-z0-9A-Z]*$/`, ≤63 > a leading digit fails — the first character class excludes it [0.10ms] +150 | expect(validateKind('1Component')).toBe(false); +151 | }); +152 | +153 | test('punctuation fails', () => { +154 | for (const kind of ['Com-ponent', 'Com_ponent', 'Com.ponent', 'Com ponent', '']) { +155 | expect(validateKind(kind)).toBe(false); + ^ +error: expect(received).toBe(expected) + +Expected: false +Received: true + + at (/Users/markbeacom/github/mbeacom/copilot-worktrees/adrkit/mbeacom-symmetrical-disco/packages/adapters/catalog-backstage/test/admissibility-validators.test.ts:155:34) +(fail) T049 — `validateKind` is `/^[a-zA-Z][a-z0-9A-Z]*$/`, ≤63 > punctuation fails [0.06ms] +156 | } +157 | }); +158 | +159 | test('the \u226463 bound holds at the boundary', () => { +160 | expect(validateKind(`C${'a'.repeat(62)}`)).toBe(true); +161 | expect(validateKind(`C${'a'.repeat(63)}`)).toBe(false); + ^ +error: expect(received).toBe(expected) + +Expected: false +Received: true + + at (/Users/markbeacom/github/mbeacom/copilot-worktrees/adrkit/mbeacom-symmetrical-disco/packages/adapters/catalog-backstage/test/admissibility-validators.test.ts:161:48) +(fail) T049 — `validateKind` is `/^[a-zA-Z][a-z0-9A-Z]*$/`, ≤63 > the ≤63 bound holds at the boundary [0.03ms] +(pass) T049 — `validateKind` is `/^[a-zA-Z][a-z0-9A-Z]*$/`, ≤63 > a non-string is not a valid kind +(pass) T049 — `validateEntityName` is `/^([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9]$/`, ≤63 > ordinary names pass, including mixed case, `-`, `_` and `.` [0.03ms] +(pass) T049 — `validateEntityName` is `/^([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9]$/`, ≤63 > a leading or trailing separator fails [0.01ms] +(pass) T049 — `validateEntityName` is `/^([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9]$/`, ≤63 > the unsubstituted scaffolder placeholders ADR-0015 names all fail +(pass) T049 — `validateEntityName` is `/^([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9]$/`, ≤63 > the ≤63 bound holds at the boundary +(pass) T049 — `validateEntityName` is `/^([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9]$/`, ≤63 > length and character class are different populations (§2.1) [0.01ms] +(pass) T049 — `validateEntityName` is `/^([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9]$/`, ≤63 > an empty name fails +(pass) T049 — `validateNamespace` is `/^[a-z0-9]+(?:\-+[a-z0-9]+)*$/`, ≤63 > ordinary DNS labels pass [0.02ms] +(pass) T049 — `validateNamespace` is `/^[a-z0-9]+(?:\-+[a-z0-9]+)*$/`, ≤63 > uppercase fails — this is where ADR-0015 and `admissibility.md` §2 diverge +(pass) T049 — `validateNamespace` is `/^[a-z0-9]+(?:\-+[a-z0-9]+)*$/`, ≤63 > a leading or trailing hyphen fails +(pass) T049 — `validateNamespace` is `/^[a-z0-9]+(?:\-+[a-z0-9]+)*$/`, ≤63 > an empty namespace fails — present-and-empty is not omitted +(pass) T049 — `validateNamespace` is `/^[a-z0-9]+(?:\-+[a-z0-9]+)*$/`, ≤63 > the ≤63 bound holds at the boundary +253 | expect(validateKind('Component')).toBe(true); +254 | expect(validateNamespace('Component')).toBe(false); +255 | +256 | expect(validateEntityName('payments.v2')).toBe(true); +257 | expect(validateNamespace('payments.v2')).toBe(false); +258 | expect(validateKind('payments.v2')).toBe(false); + ^ +error: expect(received).toBe(expected) + +Expected: false +Received: true + + at (/Users/markbeacom/github/mbeacom/copilot-worktrees/adrkit/mbeacom-symmetrical-disco/packages/adapters/catalog-backstage/test/admissibility-validators.test.ts:258:41) +(fail) T049 — the four predicates are independent > each rejects an input the other three accept in their own field [0.03ms] + +4 tests failed: +(fail) T049 — `validateKind` is `/^[a-zA-Z][a-z0-9A-Z]*$/`, ≤63 > a leading digit fails — the first character class excludes it [0.10ms] +(fail) T049 — `validateKind` is `/^[a-zA-Z][a-z0-9A-Z]*$/`, ≤63 > punctuation fails [0.06ms] +(fail) T049 — `validateKind` is `/^[a-zA-Z][a-z0-9A-Z]*$/`, ≤63 > the ≤63 bound holds at the boundary [0.03ms] +(fail) T049 — the four predicates are independent > each rejects an input the other three accept in their own field [0.03ms] + + 24 pass + 4 fail + 85 expect() calls +Ran 28 tests across 1 file. [7.00ms] + +exit 1 diff --git a/specs/010-catalog-backstage/evidence/negative-cases/admissibility-validators/case-2-validateKind.patch b/specs/010-catalog-backstage/evidence/negative-cases/admissibility-validators/case-2-validateKind.patch new file mode 100644 index 00000000..3b78e7ac --- /dev/null +++ b/specs/010-catalog-backstage/evidence/negative-cases/admissibility-validators/case-2-validateKind.patch @@ -0,0 +1,13 @@ +diff --git a/packages/adapters/catalog-backstage/src/admissibility/validators.ts b/packages/adapters/catalog-backstage/src/admissibility/validators.ts +index a2ecf3b..af0e759 100644 +--- a/packages/adapters/catalog-backstage/src/admissibility/validators.ts ++++ b/packages/adapters/catalog-backstage/src/admissibility/validators.ts +@@ -182,7 +182,7 @@ export function validateApiVersion(value: unknown): boolean { + /** `validateKind` — `isValidKind`. */ + export function validateKind(value: unknown): boolean { + if (typeof value !== 'string') return false; +- return value.length <= FIELD_MAX && KIND.test(value); ++ return true; + } + + /** `validateEntityName` — `isValidEntityName` → `KubernetesValidatorFunctions.isValidObjectName`. */ diff --git a/specs/010-catalog-backstage/evidence/negative-cases/admissibility-validators/case-3-validateEntityName.observed.txt b/specs/010-catalog-backstage/evidence/negative-cases/admissibility-validators/case-3-validateEntityName.observed.txt new file mode 100644 index 00000000..039484eb --- /dev/null +++ b/specs/010-catalog-backstage/evidence/negative-cases/admissibility-validators/case-3-validateEntityName.observed.txt @@ -0,0 +1,110 @@ +bun test v1.3.14 (0d9b296a) + +test/admissibility-validators.test.ts: +(pass) T049 — the table has exactly four rows, bound as ADR-0015 binds them > four fields, in ADR-0015’s table order [0.03ms] +(pass) T049 — the table has exactly four rows, bound as ADR-0015 binds them > four validators, one per field [0.10ms] +(pass) T049 — the table has exactly four rows, bound as ADR-0015 binds them > each validator records the pinned binding it reproduces [0.02ms] +(pass) T049 — the table has exactly four rows, bound as ADR-0015 binds them > the pin is ADR-0012’s commit, carried unchanged +(pass) T049 — the table has exactly four rows, bound as ADR-0015 binds them > the bounds are the ones ADR-0015 states +(pass) T049 — `validateApiVersion` > the four facts ADR-0015 recorded as executed against the pin [0.24ms] +(pass) T049 — `validateApiVersion` > a bare `v1` passes without the subdomain rule being consulted (FR-017) [0.01ms] +(pass) T049 — `validateApiVersion` > the ordinary Backstage form passes [0.01ms] +(pass) T049 — `validateApiVersion` > the suffix predicate is `/^[a-z0-9A-Z]+$/`, so punctuation fails [0.01ms] +(pass) T049 — `validateApiVersion` > the prefix is a DNS subdomain, so uppercase and underscores fail +(pass) T049 — `validateApiVersion` > a non-string is not a valid apiVersion [0.03ms] +(pass) T049 — `validateKind` is `/^[a-zA-Z][a-z0-9A-Z]*$/`, ≤63 > ordinary kinds pass [0.02ms] +(pass) T049 — `validateKind` is `/^[a-zA-Z][a-z0-9A-Z]*$/`, ≤63 > a leading digit fails — the first character class excludes it +(pass) T049 — `validateKind` is `/^[a-zA-Z][a-z0-9A-Z]*$/`, ≤63 > punctuation fails +(pass) T049 — `validateKind` is `/^[a-zA-Z][a-z0-9A-Z]*$/`, ≤63 > the ≤63 bound holds at the boundary +(pass) T049 — `validateKind` is `/^[a-zA-Z][a-z0-9A-Z]*$/`, ≤63 > a non-string is not a valid kind +(pass) T049 — `validateEntityName` is `/^([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9]$/`, ≤63 > ordinary names pass, including mixed case, `-`, `_` and `.` [0.02ms] +174 | } +175 | }); +176 | +177 | test('a leading or trailing separator fails', () => { +178 | for (const name of ['-payments', 'payments-', '.payments', 'payments.', '_payments', 'payments_']) { +179 | expect(validateEntityName(name)).toBe(false); + ^ +error: expect(received).toBe(expected) + +Expected: false +Received: true + + at (/Users/markbeacom/github/mbeacom/copilot-worktrees/adrkit/mbeacom-symmetrical-disco/packages/adapters/catalog-backstage/test/admissibility-validators.test.ts:179:40) +(fail) T049 — `validateEntityName` is `/^([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9]$/`, ≤63 > a leading or trailing separator fails [0.11ms] +182 | +183 | test('the unsubstituted scaffolder placeholders ADR-0015 names all fail', () => { +184 | // ADR-0015: "All three forms fail `isValidObjectName` on character class: `$`, +185 | // `{`, `}` and the spaces are outside the permitted set in every case, and `|` +186 | // in the first". These are the sixteen descriptors' three distinct forms. +187 | expect(validateEntityName('${{ values.name | dump }}')).toBe(false); + ^ +error: expect(received).toBe(expected) + +Expected: false +Received: true + + at (/Users/markbeacom/github/mbeacom/copilot-worktrees/adrkit/mbeacom-symmetrical-disco/packages/adapters/catalog-backstage/test/admissibility-validators.test.ts:187:61) +(fail) T049 — `validateEntityName` is `/^([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9]$/`, ≤63 > the unsubstituted scaffolder placeholders ADR-0015 names all fail [0.03ms] +189 | expect(validateEntityName('${{ values.entityName }}')).toBe(false); +190 | }); +191 | +192 | test('the \u226463 bound holds at the boundary', () => { +193 | expect(validateEntityName('a'.repeat(63))).toBe(true); +194 | expect(validateEntityName('a'.repeat(64))).toBe(false); + ^ +error: expect(received).toBe(expected) + +Expected: false +Received: true + + at (/Users/markbeacom/github/mbeacom/copilot-worktrees/adrkit/mbeacom-symmetrical-disco/packages/adapters/catalog-backstage/test/admissibility-validators.test.ts:194:48) +(fail) T049 — `validateEntityName` is `/^([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9]$/`, ≤63 > the ≤63 bound holds at the boundary [0.03ms] +197 | test('length and character class are different populations (\u00a72.1)', () => { +198 | // `admissibility.md` §2.1: "Over 63 characters" and "invalid" are different +199 | // sets and MUST NOT be reported as one. +200 | const tooLongButOtherwiseValid = 'a'.repeat(64); +201 | const shortButInvalidCharacters = 'payments!'; +202 | expect(validateEntityName(tooLongButOtherwiseValid)).toBe(false); + ^ +error: expect(received).toBe(expected) + +Expected: false +Received: true + + at (/Users/markbeacom/github/mbeacom/copilot-worktrees/adrkit/mbeacom-symmetrical-disco/packages/adapters/catalog-backstage/test/admissibility-validators.test.ts:202:58) +(fail) T049 — `validateEntityName` is `/^([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9]$/`, ≤63 > length and character class are different populations (§2.1) [0.03ms] +206 | expect(tooLongButOtherwiseValid.length > 63).toBe(true); +207 | expect(shortButInvalidCharacters.length <= 63).toBe(true); +208 | }); +209 | +210 | test('an empty name fails', () => { +211 | expect(validateEntityName('')).toBe(false); + ^ +error: expect(received).toBe(expected) + +Expected: false +Received: true + + at (/Users/markbeacom/github/mbeacom/copilot-worktrees/adrkit/mbeacom-symmetrical-disco/packages/adapters/catalog-backstage/test/admissibility-validators.test.ts:211:36) +(fail) T049 — `validateEntityName` is `/^([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9]$/`, ≤63 > an empty name fails [0.02ms] +(pass) T049 — `validateNamespace` is `/^[a-z0-9]+(?:\-+[a-z0-9]+)*$/`, ≤63 > ordinary DNS labels pass [0.02ms] +(pass) T049 — `validateNamespace` is `/^[a-z0-9]+(?:\-+[a-z0-9]+)*$/`, ≤63 > uppercase fails — this is where ADR-0015 and `admissibility.md` §2 diverge +(pass) T049 — `validateNamespace` is `/^[a-z0-9]+(?:\-+[a-z0-9]+)*$/`, ≤63 > a leading or trailing hyphen fails +(pass) T049 — `validateNamespace` is `/^[a-z0-9]+(?:\-+[a-z0-9]+)*$/`, ≤63 > an empty namespace fails — present-and-empty is not omitted +(pass) T049 — `validateNamespace` is `/^[a-z0-9]+(?:\-+[a-z0-9]+)*$/`, ≤63 > the ≤63 bound holds at the boundary +(pass) T049 — the four predicates are independent > each rejects an input the other three accept in their own field [0.01ms] + +5 tests failed: +(fail) T049 — `validateEntityName` is `/^([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9]$/`, ≤63 > a leading or trailing separator fails [0.11ms] +(fail) T049 — `validateEntityName` is `/^([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9]$/`, ≤63 > the unsubstituted scaffolder placeholders ADR-0015 names all fail [0.03ms] +(fail) T049 — `validateEntityName` is `/^([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9]$/`, ≤63 > the ≤63 bound holds at the boundary [0.03ms] +(fail) T049 — `validateEntityName` is `/^([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9]$/`, ≤63 > length and character class are different populations (§2.1) [0.03ms] +(fail) T049 — `validateEntityName` is `/^([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9]$/`, ≤63 > an empty name fails [0.02ms] + + 23 pass + 5 fail + 82 expect() calls +Ran 28 tests across 1 file. [7.00ms] + +exit 1 diff --git a/specs/010-catalog-backstage/evidence/negative-cases/admissibility-validators/case-3-validateEntityName.patch b/specs/010-catalog-backstage/evidence/negative-cases/admissibility-validators/case-3-validateEntityName.patch new file mode 100644 index 00000000..5686ea66 --- /dev/null +++ b/specs/010-catalog-backstage/evidence/negative-cases/admissibility-validators/case-3-validateEntityName.patch @@ -0,0 +1,13 @@ +diff --git a/packages/adapters/catalog-backstage/src/admissibility/validators.ts b/packages/adapters/catalog-backstage/src/admissibility/validators.ts +index a2ecf3b..6fa6a21 100644 +--- a/packages/adapters/catalog-backstage/src/admissibility/validators.ts ++++ b/packages/adapters/catalog-backstage/src/admissibility/validators.ts +@@ -188,7 +188,7 @@ export function validateKind(value: unknown): boolean { + /** `validateEntityName` — `isValidEntityName` → `KubernetesValidatorFunctions.isValidObjectName`. */ + export function validateEntityName(value: unknown): boolean { + if (typeof value !== 'string') return false; +- return value.length <= FIELD_MAX && OBJECT_NAME.test(value); ++ return true; + } + + /** diff --git a/specs/010-catalog-backstage/evidence/negative-cases/admissibility-validators/case-4-validateNamespace.observed.txt b/specs/010-catalog-backstage/evidence/negative-cases/admissibility-validators/case-4-validateNamespace.observed.txt new file mode 100644 index 00000000..7397299f --- /dev/null +++ b/specs/010-catalog-backstage/evidence/negative-cases/admissibility-validators/case-4-validateNamespace.observed.txt @@ -0,0 +1,110 @@ +bun test v1.3.14 (0d9b296a) + +test/admissibility-validators.test.ts: +(pass) T049 — the table has exactly four rows, bound as ADR-0015 binds them > four fields, in ADR-0015’s table order [0.03ms] +(pass) T049 — the table has exactly four rows, bound as ADR-0015 binds them > four validators, one per field [0.03ms] +(pass) T049 — the table has exactly four rows, bound as ADR-0015 binds them > each validator records the pinned binding it reproduces [0.01ms] +(pass) T049 — the table has exactly four rows, bound as ADR-0015 binds them > the pin is ADR-0012’s commit, carried unchanged +(pass) T049 — the table has exactly four rows, bound as ADR-0015 binds them > the bounds are the ones ADR-0015 states +(pass) T049 — `validateApiVersion` > the four facts ADR-0015 recorded as executed against the pin [0.20ms] +(pass) T049 — `validateApiVersion` > a bare `v1` passes without the subdomain rule being consulted (FR-017) [0.01ms] +(pass) T049 — `validateApiVersion` > the ordinary Backstage form passes [0.01ms] +(pass) T049 — `validateApiVersion` > the suffix predicate is `/^[a-z0-9A-Z]+$/`, so punctuation fails [0.01ms] +(pass) T049 — `validateApiVersion` > the prefix is a DNS subdomain, so uppercase and underscores fail +(pass) T049 — `validateApiVersion` > a non-string is not a valid apiVersion [0.02ms] +(pass) T049 — `validateKind` is `/^[a-zA-Z][a-z0-9A-Z]*$/`, ≤63 > ordinary kinds pass [0.02ms] +(pass) T049 — `validateKind` is `/^[a-zA-Z][a-z0-9A-Z]*$/`, ≤63 > a leading digit fails — the first character class excludes it +(pass) T049 — `validateKind` is `/^[a-zA-Z][a-z0-9A-Z]*$/`, ≤63 > punctuation fails +(pass) T049 — `validateKind` is `/^[a-zA-Z][a-z0-9A-Z]*$/`, ≤63 > the ≤63 bound holds at the boundary +(pass) T049 — `validateKind` is `/^[a-zA-Z][a-z0-9A-Z]*$/`, ≤63 > a non-string is not a valid kind +(pass) T049 — `validateEntityName` is `/^([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9]$/`, ≤63 > ordinary names pass, including mixed case, `-`, `_` and `.` [0.02ms] +(pass) T049 — `validateEntityName` is `/^([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9]$/`, ≤63 > a leading or trailing separator fails +(pass) T049 — `validateEntityName` is `/^([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9]$/`, ≤63 > the unsubstituted scaffolder placeholders ADR-0015 names all fail +(pass) T049 — `validateEntityName` is `/^([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9]$/`, ≤63 > the ≤63 bound holds at the boundary +(pass) T049 — `validateEntityName` is `/^([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9]$/`, ≤63 > length and character class are different populations (§2.1) [0.01ms] +(pass) T049 — `validateEntityName` is `/^([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9]$/`, ≤63 > an empty name fails +(pass) T049 — `validateNamespace` is `/^[a-z0-9]+(?:\-+[a-z0-9]+)*$/`, ≤63 > ordinary DNS labels pass [0.01ms] +224 | // class as `metadata.name` ("`[A-Za-z0-9]` plus `-`, `_`, `.`"). ADR-0015 and +225 | // FR-016 give it the DNS-label predicate, which admits none of uppercase, `_` +226 | // or `.`. FR-016 requires "exactly the four field validators in ADR-0015's +227 | // table", so ADR-0015 governs and this assertion is the one that would fail if +228 | // someone implemented §2's summary instead. Reported as a contract defect. +229 | expect(validateNamespace('Default')).toBe(false); + ^ +error: expect(received).toBe(expected) + +Expected: false +Received: true + + at (/Users/markbeacom/github/mbeacom/copilot-worktrees/adrkit/mbeacom-symmetrical-disco/packages/adapters/catalog-backstage/test/admissibility-validators.test.ts:229:42) +(fail) T049 — `validateNamespace` is `/^[a-z0-9]+(?:\-+[a-z0-9]+)*$/`, ≤63 > uppercase fails — this is where ADR-0015 and `admissibility.md` §2 diverge [0.09ms] +230 | expect(validateNamespace('team_payments')).toBe(false); +231 | expect(validateNamespace('team.payments')).toBe(false); +232 | }); +233 | +234 | test('a leading or trailing hyphen fails', () => { +235 | expect(validateNamespace('-payments')).toBe(false); + ^ +error: expect(received).toBe(expected) + +Expected: false +Received: true + + at (/Users/markbeacom/github/mbeacom/copilot-worktrees/adrkit/mbeacom-symmetrical-disco/packages/adapters/catalog-backstage/test/admissibility-validators.test.ts:235:44) +(fail) T049 — `validateNamespace` is `/^[a-z0-9]+(?:\-+[a-z0-9]+)*$/`, ≤63 > a leading or trailing hyphen fails [0.03ms] +235 | expect(validateNamespace('-payments')).toBe(false); +236 | expect(validateNamespace('payments-')).toBe(false); +237 | }); +238 | +239 | test('an empty namespace fails — present-and-empty is not omitted', () => { +240 | expect(validateNamespace('')).toBe(false); + ^ +error: expect(received).toBe(expected) + +Expected: false +Received: true + + at (/Users/markbeacom/github/mbeacom/copilot-worktrees/adrkit/mbeacom-symmetrical-disco/packages/adapters/catalog-backstage/test/admissibility-validators.test.ts:240:35) +(fail) T049 — `validateNamespace` is `/^[a-z0-9]+(?:\-+[a-z0-9]+)*$/`, ≤63 > an empty namespace fails — present-and-empty is not omitted [0.03ms] +240 | expect(validateNamespace('')).toBe(false); +241 | }); +242 | +243 | test('the \u226463 bound holds at the boundary', () => { +244 | expect(validateNamespace('a'.repeat(63))).toBe(true); +245 | expect(validateNamespace('a'.repeat(64))).toBe(false); + ^ +error: expect(received).toBe(expected) + +Expected: false +Received: true + + at (/Users/markbeacom/github/mbeacom/copilot-worktrees/adrkit/mbeacom-symmetrical-disco/packages/adapters/catalog-backstage/test/admissibility-validators.test.ts:245:47) +(fail) T049 — `validateNamespace` is `/^[a-z0-9]+(?:\-+[a-z0-9]+)*$/`, ≤63 > the ≤63 bound holds at the boundary [0.03ms] +249 | describe('T049 — the four predicates are independent', () => { +250 | test('each rejects an input the other three accept in their own field', () => { +251 | // A value valid as one field and invalid as another is what makes separate +252 | // attribution meaningful rather than decorative. +253 | expect(validateKind('Component')).toBe(true); +254 | expect(validateNamespace('Component')).toBe(false); + ^ +error: expect(received).toBe(expected) + +Expected: false +Received: true + + at (/Users/markbeacom/github/mbeacom/copilot-worktrees/adrkit/mbeacom-symmetrical-disco/packages/adapters/catalog-backstage/test/admissibility-validators.test.ts:254:44) +(fail) T049 — the four predicates are independent > each rejects an input the other three accept in their own field [0.03ms] + +5 tests failed: +(fail) T049 — `validateNamespace` is `/^[a-z0-9]+(?:\-+[a-z0-9]+)*$/`, ≤63 > uppercase fails — this is where ADR-0015 and `admissibility.md` §2 diverge [0.09ms] +(fail) T049 — `validateNamespace` is `/^[a-z0-9]+(?:\-+[a-z0-9]+)*$/`, ≤63 > a leading or trailing hyphen fails [0.03ms] +(fail) T049 — `validateNamespace` is `/^[a-z0-9]+(?:\-+[a-z0-9]+)*$/`, ≤63 > an empty namespace fails — present-and-empty is not omitted [0.03ms] +(fail) T049 — `validateNamespace` is `/^[a-z0-9]+(?:\-+[a-z0-9]+)*$/`, ≤63 > the ≤63 bound holds at the boundary [0.03ms] +(fail) T049 — the four predicates are independent > each rejects an input the other three accept in their own field [0.03ms] + + 23 pass + 5 fail + 83 expect() calls +Ran 28 tests across 1 file. [7.00ms] + +exit 1 diff --git a/specs/010-catalog-backstage/evidence/negative-cases/admissibility-validators/case-4-validateNamespace.patch b/specs/010-catalog-backstage/evidence/negative-cases/admissibility-validators/case-4-validateNamespace.patch new file mode 100644 index 00000000..f1610739 --- /dev/null +++ b/specs/010-catalog-backstage/evidence/negative-cases/admissibility-validators/case-4-validateNamespace.patch @@ -0,0 +1,13 @@ +diff --git a/packages/adapters/catalog-backstage/src/admissibility/validators.ts b/packages/adapters/catalog-backstage/src/admissibility/validators.ts +index a2ecf3b..1474cb7 100644 +--- a/packages/adapters/catalog-backstage/src/admissibility/validators.ts ++++ b/packages/adapters/catalog-backstage/src/admissibility/validators.ts +@@ -204,7 +204,7 @@ export function validateEntityName(value: unknown): boolean { + */ + export function validateNamespace(value: unknown): boolean { + if (typeof value !== 'string') return false; +- return value.length <= FIELD_MAX && DNS_LABEL.test(value); ++ return true; + } + + /** The four validators, keyed by name, so the composition can iterate them. */ diff --git a/specs/010-catalog-backstage/evidence/negative-cases/admissibility-validators/restored.observed.txt b/specs/010-catalog-backstage/evidence/negative-cases/admissibility-validators/restored.observed.txt new file mode 100644 index 00000000..1e25bf2d --- /dev/null +++ b/specs/010-catalog-backstage/evidence/negative-cases/admissibility-validators/restored.observed.txt @@ -0,0 +1,38 @@ +bun test v1.3.14 (0d9b296a) + +test/admissibility-validators.test.ts: +(pass) T049 — the table has exactly four rows, bound as ADR-0015 binds them > four fields, in ADR-0015’s table order [0.03ms] +(pass) T049 — the table has exactly four rows, bound as ADR-0015 binds them > four validators, one per field [0.03ms] +(pass) T049 — the table has exactly four rows, bound as ADR-0015 binds them > each validator records the pinned binding it reproduces [0.01ms] +(pass) T049 — the table has exactly four rows, bound as ADR-0015 binds them > the pin is ADR-0012’s commit, carried unchanged +(pass) T049 — the table has exactly four rows, bound as ADR-0015 binds them > the bounds are the ones ADR-0015 states +(pass) T049 — `validateApiVersion` > the four facts ADR-0015 recorded as executed against the pin [0.21ms] +(pass) T049 — `validateApiVersion` > a bare `v1` passes without the subdomain rule being consulted (FR-017) [0.01ms] +(pass) T049 — `validateApiVersion` > the ordinary Backstage form passes [0.01ms] +(pass) T049 — `validateApiVersion` > the suffix predicate is `/^[a-z0-9A-Z]+$/`, so punctuation fails [0.01ms] +(pass) T049 — `validateApiVersion` > the prefix is a DNS subdomain, so uppercase and underscores fail +(pass) T049 — `validateApiVersion` > a non-string is not a valid apiVersion [0.03ms] +(pass) T049 — `validateKind` is `/^[a-zA-Z][a-z0-9A-Z]*$/`, ≤63 > ordinary kinds pass [0.02ms] +(pass) T049 — `validateKind` is `/^[a-zA-Z][a-z0-9A-Z]*$/`, ≤63 > a leading digit fails — the first character class excludes it +(pass) T049 — `validateKind` is `/^[a-zA-Z][a-z0-9A-Z]*$/`, ≤63 > punctuation fails +(pass) T049 — `validateKind` is `/^[a-zA-Z][a-z0-9A-Z]*$/`, ≤63 > the ≤63 bound holds at the boundary +(pass) T049 — `validateKind` is `/^[a-zA-Z][a-z0-9A-Z]*$/`, ≤63 > a non-string is not a valid kind +(pass) T049 — `validateEntityName` is `/^([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9]$/`, ≤63 > ordinary names pass, including mixed case, `-`, `_` and `.` [0.02ms] +(pass) T049 — `validateEntityName` is `/^([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9]$/`, ≤63 > a leading or trailing separator fails +(pass) T049 — `validateEntityName` is `/^([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9]$/`, ≤63 > the unsubstituted scaffolder placeholders ADR-0015 names all fail +(pass) T049 — `validateEntityName` is `/^([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9]$/`, ≤63 > the ≤63 bound holds at the boundary [0.01ms] +(pass) T049 — `validateEntityName` is `/^([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9]$/`, ≤63 > length and character class are different populations (§2.1) +(pass) T049 — `validateEntityName` is `/^([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9]$/`, ≤63 > an empty name fails +(pass) T049 — `validateNamespace` is `/^[a-z0-9]+(?:\-+[a-z0-9]+)*$/`, ≤63 > ordinary DNS labels pass [0.01ms] +(pass) T049 — `validateNamespace` is `/^[a-z0-9]+(?:\-+[a-z0-9]+)*$/`, ≤63 > uppercase fails — this is where ADR-0015 and `admissibility.md` §2 diverge +(pass) T049 — `validateNamespace` is `/^[a-z0-9]+(?:\-+[a-z0-9]+)*$/`, ≤63 > a leading or trailing hyphen fails +(pass) T049 — `validateNamespace` is `/^[a-z0-9]+(?:\-+[a-z0-9]+)*$/`, ≤63 > an empty namespace fails — present-and-empty is not omitted +(pass) T049 — `validateNamespace` is `/^[a-z0-9]+(?:\-+[a-z0-9]+)*$/`, ≤63 > the ≤63 bound holds at the boundary +(pass) T049 — the four predicates are independent > each rejects an input the other three accept in their own field [0.01ms] + + 28 pass + 0 fail + 92 expect() calls +Ran 28 tests across 1 file. [7.00ms] + +exit 0 diff --git a/specs/010-catalog-backstage/evidence/negative-cases/annotation-decode/README.md b/specs/010-catalog-backstage/evidence/negative-cases/annotation-decode/README.md new file mode 100644 index 00000000..7377d9ff --- /dev/null +++ b/specs/010-catalog-backstage/evidence/negative-cases/annotation-decode/README.md @@ -0,0 +1,97 @@ +# Negative case: the annotation's ordered decode steps + +**Task**: T058 · **Discharges**: FR-026 · **Supports**: SC-006 +**Observed against**: `99ba8d2500eaf37625ea164f66b4a17870e40dad` plus Phase D's own +uncommitted work; the mutation under test was the only additional change in the tree. +**Tools**: Bun 1.3.14, TypeScript 6.0.3 +**Command for every case below**: `bun test test/annotation-decode.test.ts`, run from +`packages/adapters/catalog-backstage/` +**Permanent automated case**: `packages/adapters/catalog-backstage/test/annotation-decode.test.ts` + +`owned-paths-annotation.md` §1 fixes five ordered steps. **Three of them can produce a +rejection reason of their own** — steps 2, 3 and 4. Step 1 (presence) has no rejection: +an absent annotation is the legitimate `annotation-absent` state, not an error. Step 5 +delegates to the glob dialect, whose reasons are that contract's and are covered by +[`../glob-rules/`](../glob-rules/). That asymmetry is stated here rather than left +implicit, because "five steps, three reasons" otherwise reads as two missing cases. + +| # | Step disabled | Distinct reason that stopped being emitted | Tests failing | +|---|---|---|---| +| 1 | 2 — string-scalar check on the raw node | `annotation-value-not-a-string` | 9 | +| 2 | 3 — JSON decode (reason collapsed into step 4's) | `parse-error` | 7 | +| 3 | 4 — shape: exactly `array` | `wrong-shape` | 14 | + +--- + +## Case 1 — step 2, the string-scalar check + +Input: [`case-1-step-2-string-scalar-check.patch`](./case-1-step-2-string-scalar-check.patch) · +Output: [`case-1-step-2-string-scalar-check.observed.txt`](./case-1-step-2-string-scalar-check.observed.txt) + +``` +(fail) T058 — `owned-paths-annotation.md` §1’s worked-example table > row 2 — a YAML sequence yields `annotation-value-not-a-string` +(fail) T058 step 1 — presence, via an explicit discriminant > presence is not inferred from the value — a present null reaches step 2 +(fail) T058 step 2 — the string-scalar check, before any parse > a YAML sequence is rejected as `annotation-value-not-a-string` +Expected: "annotation-value-not-a-string" +Received: "wrong-shape" +``` + +The `Received: "wrong-shape"` line is what makes this more than a missing rejection: +with step 2 gone, non-string nodes are silently coerced by `JSON.parse` and then +misreported under a *neighbouring* step's reason. The dedicated FR-027 case — where the +misclassification is `explicit-empty` rather than a rejection at all — is recorded +separately at [`../annotation-sequence-coercion/`](../annotation-sequence-coercion/). + +## Case 2 — step 3, JSON decode + +Input: [`case-2-step-3-parse-error-collapsed-into-wrong-shape.patch`](./case-2-step-3-parse-error-collapsed-into-wrong-shape.patch) · +Output: [`case-2-step-3-parse-error-collapsed-into-wrong-shape.observed.txt`](./case-2-step-3-parse-error-collapsed-into-wrong-shape.observed.txt) + +The catch branch's reason was changed from `parse-error` to `wrong-shape` — collapsing +step 3 into step 4, which §1 step 3 forbids in terms ("**not** the same reason as step +2's non-string failure or step 4's shape failure"). + +``` +(fail) T058 — `owned-paths-annotation.md` §1’s worked-example table > row 6 — malformed JSON yields `parse-error` +(fail) T058 step 3 — JSON decode, reached only by a present string scalar > "[\"packages/**\"" is rejected as `parse-error` +Expected: "parse-error" +Received: "wrong-shape" +``` + +## Case 3 — step 4, shape validation + +Input: [`case-3-step-4-shape-check.patch`](./case-3-step-4-shape-check.patch) · +Output: [`case-3-step-4-shape-check.observed.txt`](./case-3-step-4-shape-check.observed.txt) + +Fourteen tests fail — the whole shape population, including every row of §1's worked +example that ends in `wrong-shape`: + +``` +(fail) T058 — §1’s worked-example table > row 3 — a JSON object yields `wrong-shape` +(fail) T058 — §1’s worked-example table > row 4 — a bare string yields `wrong-shape`, never a single-element array +(fail) T058 — §1’s worked-example table > row 5 — an array with a non-string element yields `wrong-shape` +Expected: false +Received: true +``` + +--- + +## The three reasons, restored + +| Step | Raw annotation node | Reason | Trigger class | +|---|---|---|---| +| 2 | `["[]"]` (YAML sequence) | `annotation-value-not-a-string` | `invalid-annotation-parse` | +| 3 | `'["packages/payments/**'` | `parse-error` | `invalid-annotation-parse` | +| 4 | `'{"paths": ["a/**"]}'` | `wrong-shape` | `invalid-annotation-shape` | + +Steps 2 and 3 share a trigger class and are kept distinct at the `reason` level, which +is what §1 actually requires. `data-model.md` §8 carries only two annotation trigger +classes, so a 1:1 mapping to three reasons is not available. + +## Restored + +[`restored.observed.txt`](./restored.observed.txt) — all 30 tests pass, 0 fail. + +## Standing constraints + +ADR-0014 **rung 1 only**. diff --git a/specs/010-catalog-backstage/evidence/negative-cases/annotation-decode/case-1-step-2-string-scalar-check.observed.txt b/specs/010-catalog-backstage/evidence/negative-cases/annotation-decode/case-1-step-2-string-scalar-check.observed.txt new file mode 100644 index 00000000..16601ba6 --- /dev/null +++ b/specs/010-catalog-backstage/evidence/negative-cases/annotation-decode/case-1-step-2-string-scalar-check.observed.txt @@ -0,0 +1,180 @@ +bun test v1.3.14 (0d9b296a) + +test/annotation-decode.test.ts: +(pass) T058 — `owned-paths-annotation.md` §1’s worked-example table > row 1 — a well-formed string scalar proceeds [0.21ms] +35 | expect(result.value.diagnostics.rejectionReason).toBeUndefined(); +36 | }); +37 | +38 | test('row 2 — a YAML sequence yields `annotation-value-not-a-string`', () => { +39 | const result = decodeAnnotation(true, ['[]']); +40 | expect(result.ok).toBe(false); + ^ +error: expect(received).toBe(expected) + +Expected: false +Received: true + + at (/Users/markbeacom/github/mbeacom/copilot-worktrees/adrkit/mbeacom-symmetrical-disco/packages/adapters/catalog-backstage/test/annotation-decode.test.ts:40:23) +(fail) T058 — `owned-paths-annotation.md` §1’s worked-example table > row 2 — a YAML sequence yields `annotation-value-not-a-string` [0.10ms] +(pass) T058 — `owned-paths-annotation.md` §1’s worked-example table > row 3 — a JSON object yields `wrong-shape` [0.03ms] +(pass) T058 — `owned-paths-annotation.md` §1’s worked-example table > row 4 — a bare string yields `wrong-shape`, never a single-element array [0.01ms] +(pass) T058 — `owned-paths-annotation.md` §1’s worked-example table > row 5 — an array with a non-string element yields `wrong-shape` [0.02ms] +(pass) T058 — `owned-paths-annotation.md` §1’s worked-example table > row 6 — malformed JSON yields `parse-error` [0.04ms] +(pass) T058 step 1 — presence, via an explicit discriminant > an absent annotation stops at step 1 with no rejection [0.01ms] +(pass) T058 step 1 — presence, via an explicit discriminant > no string check, JSON parse, or shape check is attempted for an absent key [0.01ms] + 96 | // A YAML key authored with no value is present and `null`. Inferring absence + 97 | // from `undefined` would silently reclassify it as `annotation-absent`. + 98 | const result = decodeAnnotation(true, null); + 99 | expect(result.ok).toBe(false); +100 | if (result.ok) return; +101 | expect(result.rejection.reason).toBe('annotation-value-not-a-string'); + ^ +error: expect(received).toBe(expected) + +Expected: "annotation-value-not-a-string" +Received: "wrong-shape" + + at (/Users/markbeacom/github/mbeacom/copilot-worktrees/adrkit/mbeacom-symmetrical-disco/packages/adapters/catalog-backstage/test/annotation-decode.test.ts:101:37) +(fail) T058 step 1 — presence, via an explicit discriminant > presence is not inferred from the value — a present null reaches step 2 [0.09ms] +110 | ['a number', 42], +111 | ['a boolean', true], +112 | ['null', null], +113 | ] as const)('%s is rejected as `annotation-value-not-a-string`', (_label, node) => { +114 | const result = decodeAnnotation(true, node); +115 | expect(result.ok).toBe(false); + ^ +error: expect(received).toBe(expected) + +Expected: false +Received: true + + at (/Users/markbeacom/github/mbeacom/copilot-worktrees/adrkit/mbeacom-symmetrical-disco/packages/adapters/catalog-backstage/test/annotation-decode.test.ts:115:23) +(fail) T058 step 2 — the string-scalar check, before any parse > a YAML sequence is rejected as `annotation-value-not-a-string` [0.06ms] +112 | ['null', null], +113 | ] as const)('%s is rejected as `annotation-value-not-a-string`', (_label, node) => { +114 | const result = decodeAnnotation(true, node); +115 | expect(result.ok).toBe(false); +116 | if (result.ok) return; +117 | expect(result.rejection.reason).toBe('annotation-value-not-a-string'); + ^ +error: expect(received).toBe(expected) + +Expected: "annotation-value-not-a-string" +Received: "parse-error" + + at (/Users/markbeacom/github/mbeacom/copilot-worktrees/adrkit/mbeacom-symmetrical-disco/packages/adapters/catalog-backstage/test/annotation-decode.test.ts:117:37) +(fail) T058 step 2 — the string-scalar check, before any parse > a YAML mapping is rejected as `annotation-value-not-a-string` [0.05ms] +112 | ['null', null], +113 | ] as const)('%s is rejected as `annotation-value-not-a-string`', (_label, node) => { +114 | const result = decodeAnnotation(true, node); +115 | expect(result.ok).toBe(false); +116 | if (result.ok) return; +117 | expect(result.rejection.reason).toBe('annotation-value-not-a-string'); + ^ +error: expect(received).toBe(expected) + +Expected: "annotation-value-not-a-string" +Received: "wrong-shape" + + at (/Users/markbeacom/github/mbeacom/copilot-worktrees/adrkit/mbeacom-symmetrical-disco/packages/adapters/catalog-backstage/test/annotation-decode.test.ts:117:37) +(fail) T058 step 2 — the string-scalar check, before any parse > a number is rejected as `annotation-value-not-a-string` [0.04ms] +112 | ['null', null], +113 | ] as const)('%s is rejected as `annotation-value-not-a-string`', (_label, node) => { +114 | const result = decodeAnnotation(true, node); +115 | expect(result.ok).toBe(false); +116 | if (result.ok) return; +117 | expect(result.rejection.reason).toBe('annotation-value-not-a-string'); + ^ +error: expect(received).toBe(expected) + +Expected: "annotation-value-not-a-string" +Received: "wrong-shape" + + at (/Users/markbeacom/github/mbeacom/copilot-worktrees/adrkit/mbeacom-symmetrical-disco/packages/adapters/catalog-backstage/test/annotation-decode.test.ts:117:37) +(fail) T058 step 2 — the string-scalar check, before any parse > a boolean is rejected as `annotation-value-not-a-string` [0.03ms] +112 | ['null', null], +113 | ] as const)('%s is rejected as `annotation-value-not-a-string`', (_label, node) => { +114 | const result = decodeAnnotation(true, node); +115 | expect(result.ok).toBe(false); +116 | if (result.ok) return; +117 | expect(result.rejection.reason).toBe('annotation-value-not-a-string'); + ^ +error: expect(received).toBe(expected) + +Expected: "annotation-value-not-a-string" +Received: "wrong-shape" + + at (/Users/markbeacom/github/mbeacom/copilot-worktrees/adrkit/mbeacom-symmetrical-disco/packages/adapters/catalog-backstage/test/annotation-decode.test.ts:117:37) +(fail) T058 step 2 — the string-scalar check, before any parse > null is rejected as `annotation-value-not-a-string` [0.03ms] +119 | expect(result.diagnostics.jsonParseOutcome).toBe('not-a-string'); +120 | }); +121 | +122 | test('the detail records that the value was never passed to JSON.parse', () => { +123 | const result = decodeAnnotation(true, ['[]']); +124 | expect(result.ok).toBe(false); + ^ +error: expect(received).toBe(expected) + +Expected: false +Received: true + + at (/Users/markbeacom/github/mbeacom/copilot-worktrees/adrkit/mbeacom-symmetrical-disco/packages/adapters/catalog-backstage/test/annotation-decode.test.ts:124:23) +(fail) T058 step 2 — the string-scalar check, before any parse > the detail records that the value was never passed to JSON.parse [0.04ms] +(pass) T058 step 3 — JSON decode, reached only by a present string scalar > "[\"packages/**\"" is rejected as `parse-error` [0.02ms] +(pass) T058 step 3 — JSON decode, reached only by a present string scalar > "[packages/**]" is rejected as `parse-error` +(pass) T058 step 3 — JSON decode, reached only by a present string scalar > "{\"a\": }" is rejected as `parse-error` +(pass) T058 step 3 — JSON decode, reached only by a present string scalar > "not json at all" is rejected as `parse-error` +(pass) T058 step 3 — JSON decode, reached only by a present string scalar > "" is rejected as `parse-error` +(pass) T058 step 3 — JSON decode, reached only by a present string scalar > a parse failure is never coerced into a fallback empty array [0.02ms] +(pass) T058 step 4 — shape: exactly array > a JSON object is rejected as `wrong-shape` [0.01ms] +(pass) T058 step 4 — shape: exactly array > a bare string is rejected as `wrong-shape` +(pass) T058 step 4 — shape: exactly array > a bare number is rejected as `wrong-shape` +(pass) T058 step 4 — shape: exactly array > a bare boolean is rejected as `wrong-shape` +(pass) T058 step 4 — shape: exactly array > null is rejected as `wrong-shape` +(pass) T058 step 4 — shape: exactly array > an array containing a number is rejected as `wrong-shape` +(pass) T058 step 4 — shape: exactly array > an array containing an object is rejected as `wrong-shape` +(pass) T058 step 4 — shape: exactly array > an array containing null is rejected as `wrong-shape` +(pass) T058 step 4 — shape: exactly array > an array containing a nested array is rejected as `wrong-shape` +(pass) T058 step 4 — shape: exactly array > nothing is coerced — a bare string is never treated as a one-element array +(pass) T058 step 4 — shape: exactly array > an empty array is a valid shape, and reaches step 5 [0.01ms] +(pass) T058 — the reasons are three distinct values, one per failing step > each step maps to its own reason [0.01ms] +204 | decodeAnnotation(true, ['[]']), +205 | decodeAnnotation(true, '["a/**"'), +206 | decodeAnnotation(true, '{"a": 1}'), +207 | ].map((result) => (result.ok ? 'accepted' : result.rejection.reason)); +208 | +209 | expect(reasons).toEqual(['annotation-value-not-a-string', 'parse-error', 'wrong-shape']); + ^ +error: expect(received).toEqual(expected) + + [ +- "annotation-value-not-a-string", ++ "accepted", + "parse-error", + "wrong-shape", + ] + +- Expected - 1 ++ Received + 1 + + at (/Users/markbeacom/github/mbeacom/copilot-worktrees/adrkit/mbeacom-symmetrical-disco/packages/adapters/catalog-backstage/test/annotation-decode.test.ts:209:21) +(fail) T058 — the reasons are three distinct values, one per failing step > the three reasons observed together are mutually distinct [0.15ms] +(pass) T058 — the reasons are three distinct values, one per failing step > the annotation key is the one ADR-0012 names [0.02ms] + +9 tests failed: +(fail) T058 — `owned-paths-annotation.md` §1’s worked-example table > row 2 — a YAML sequence yields `annotation-value-not-a-string` [0.10ms] +(fail) T058 step 1 — presence, via an explicit discriminant > presence is not inferred from the value — a present null reaches step 2 [0.09ms] +(fail) T058 step 2 — the string-scalar check, before any parse > a YAML sequence is rejected as `annotation-value-not-a-string` [0.06ms] +(fail) T058 step 2 — the string-scalar check, before any parse > a YAML mapping is rejected as `annotation-value-not-a-string` [0.05ms] +(fail) T058 step 2 — the string-scalar check, before any parse > a number is rejected as `annotation-value-not-a-string` [0.04ms] +(fail) T058 step 2 — the string-scalar check, before any parse > a boolean is rejected as `annotation-value-not-a-string` [0.03ms] +(fail) T058 step 2 — the string-scalar check, before any parse > null is rejected as `annotation-value-not-a-string` [0.03ms] +(fail) T058 step 2 — the string-scalar check, before any parse > the detail records that the value was never passed to JSON.parse [0.04ms] +(fail) T058 — the reasons are three distinct values, one per failing step > the three reasons observed together are mutually distinct [0.15ms] + + 26 pass + 9 fail + 98 expect() calls +Ran 35 tests across 1 file. [7.00ms] + +exit 1 diff --git a/specs/010-catalog-backstage/evidence/negative-cases/annotation-decode/case-1-step-2-string-scalar-check.patch b/specs/010-catalog-backstage/evidence/negative-cases/annotation-decode/case-1-step-2-string-scalar-check.patch new file mode 100644 index 00000000..b58497c8 --- /dev/null +++ b/specs/010-catalog-backstage/evidence/negative-cases/annotation-decode/case-1-step-2-string-scalar-check.patch @@ -0,0 +1,13 @@ +diff --git a/packages/adapters/catalog-backstage/src/ownership/annotation.ts b/packages/adapters/catalog-backstage/src/ownership/annotation.ts +index 853276c..ebb5f31 100644 +--- a/packages/adapters/catalog-backstage/src/ownership/annotation.ts ++++ b/packages/adapters/catalog-backstage/src/ownership/annotation.ts +@@ -143,7 +143,7 @@ export function decodeAnnotation(present: boolean, rawNode: unknown): Annotation + if (!present) return { ok: true, value: { diagnostics: ABSENT, patterns: undefined } }; + + // Step 2 — string-scalar check on the raw node, BEFORE any JSON.parse. +- if (typeof rawNode !== 'string') { ++ if (false && typeof rawNode !== 'string') { + return reject( + 'annotation-value-not-a-string', + { diff --git a/specs/010-catalog-backstage/evidence/negative-cases/annotation-decode/case-2-step-3-parse-error-collapsed-into-wrong-shape.observed.txt b/specs/010-catalog-backstage/evidence/negative-cases/annotation-decode/case-2-step-3-parse-error-collapsed-into-wrong-shape.observed.txt new file mode 100644 index 00000000..9a701841 --- /dev/null +++ b/specs/010-catalog-backstage/evidence/negative-cases/annotation-decode/case-2-step-3-parse-error-collapsed-into-wrong-shape.observed.txt @@ -0,0 +1,152 @@ +bun test v1.3.14 (0d9b296a) + +test/annotation-decode.test.ts: +(pass) T058 — `owned-paths-annotation.md` §1’s worked-example table > row 1 — a well-formed string scalar proceeds [0.17ms] +(pass) T058 — `owned-paths-annotation.md` §1’s worked-example table > row 2 — a YAML sequence yields `annotation-value-not-a-string` [0.03ms] +(pass) T058 — `owned-paths-annotation.md` §1’s worked-example table > row 3 — a JSON object yields `wrong-shape` [0.01ms] +(pass) T058 — `owned-paths-annotation.md` §1’s worked-example table > row 4 — a bare string yields `wrong-shape`, never a single-element array +(pass) T058 — `owned-paths-annotation.md` §1’s worked-example table > row 5 — an array with a non-string element yields `wrong-shape` [0.02ms] +66 | +67 | test('row 6 — malformed JSON yields `parse-error`', () => { +68 | const result = decodeAnnotation(true, '["packages/payments/**'); +69 | expect(result.ok).toBe(false); +70 | if (result.ok) return; +71 | expect(result.rejection.reason).toBe('parse-error'); + ^ +error: expect(received).toBe(expected) + +Expected: "parse-error" +Received: "wrong-shape" + + at (/Users/markbeacom/github/mbeacom/copilot-worktrees/adrkit/mbeacom-symmetrical-disco/packages/adapters/catalog-backstage/test/annotation-decode.test.ts:71:37) +(fail) T058 — `owned-paths-annotation.md` §1’s worked-example table > row 6 — malformed JSON yields `parse-error` [0.12ms] +(pass) T058 step 1 — presence, via an explicit discriminant > an absent annotation stops at step 1 with no rejection [0.01ms] +(pass) T058 step 1 — presence, via an explicit discriminant > no string check, JSON parse, or shape check is attempted for an absent key +(pass) T058 step 1 — presence, via an explicit discriminant > presence is not inferred from the value — a present null reaches step 2 +(pass) T058 step 2 — the string-scalar check, before any parse > a YAML sequence is rejected as `annotation-value-not-a-string` +(pass) T058 step 2 — the string-scalar check, before any parse > a YAML mapping is rejected as `annotation-value-not-a-string` +(pass) T058 step 2 — the string-scalar check, before any parse > a number is rejected as `annotation-value-not-a-string` +(pass) T058 step 2 — the string-scalar check, before any parse > a boolean is rejected as `annotation-value-not-a-string` +(pass) T058 step 2 — the string-scalar check, before any parse > null is rejected as `annotation-value-not-a-string` +(pass) T058 step 2 — the string-scalar check, before any parse > the detail records that the value was never passed to JSON.parse +137 | '', +138 | ])('%j is rejected as `parse-error`', (raw) => { +139 | const result = decodeAnnotation(true, raw); +140 | expect(result.ok).toBe(false); +141 | if (result.ok) return; +142 | expect(result.rejection.reason).toBe('parse-error'); + ^ +error: expect(received).toBe(expected) + +Expected: "parse-error" +Received: "wrong-shape" + + at (/Users/markbeacom/github/mbeacom/copilot-worktrees/adrkit/mbeacom-symmetrical-disco/packages/adapters/catalog-backstage/test/annotation-decode.test.ts:142:37) +(fail) T058 step 3 — JSON decode, reached only by a present string scalar > "[\"packages/**\"" is rejected as `parse-error` [0.03ms] +137 | '', +138 | ])('%j is rejected as `parse-error`', (raw) => { +139 | const result = decodeAnnotation(true, raw); +140 | expect(result.ok).toBe(false); +141 | if (result.ok) return; +142 | expect(result.rejection.reason).toBe('parse-error'); + ^ +error: expect(received).toBe(expected) + +Expected: "parse-error" +Received: "wrong-shape" + + at (/Users/markbeacom/github/mbeacom/copilot-worktrees/adrkit/mbeacom-symmetrical-disco/packages/adapters/catalog-backstage/test/annotation-decode.test.ts:142:37) +(fail) T058 step 3 — JSON decode, reached only by a present string scalar > "[packages/**]" is rejected as `parse-error` [0.02ms] +137 | '', +138 | ])('%j is rejected as `parse-error`', (raw) => { +139 | const result = decodeAnnotation(true, raw); +140 | expect(result.ok).toBe(false); +141 | if (result.ok) return; +142 | expect(result.rejection.reason).toBe('parse-error'); + ^ +error: expect(received).toBe(expected) + +Expected: "parse-error" +Received: "wrong-shape" + + at (/Users/markbeacom/github/mbeacom/copilot-worktrees/adrkit/mbeacom-symmetrical-disco/packages/adapters/catalog-backstage/test/annotation-decode.test.ts:142:37) +(fail) T058 step 3 — JSON decode, reached only by a present string scalar > "{\"a\": }" is rejected as `parse-error` [0.02ms] +137 | '', +138 | ])('%j is rejected as `parse-error`', (raw) => { +139 | const result = decodeAnnotation(true, raw); +140 | expect(result.ok).toBe(false); +141 | if (result.ok) return; +142 | expect(result.rejection.reason).toBe('parse-error'); + ^ +error: expect(received).toBe(expected) + +Expected: "parse-error" +Received: "wrong-shape" + + at (/Users/markbeacom/github/mbeacom/copilot-worktrees/adrkit/mbeacom-symmetrical-disco/packages/adapters/catalog-backstage/test/annotation-decode.test.ts:142:37) +(fail) T058 step 3 — JSON decode, reached only by a present string scalar > "not json at all" is rejected as `parse-error` [0.02ms] +137 | '', +138 | ])('%j is rejected as `parse-error`', (raw) => { +139 | const result = decodeAnnotation(true, raw); +140 | expect(result.ok).toBe(false); +141 | if (result.ok) return; +142 | expect(result.rejection.reason).toBe('parse-error'); + ^ +error: expect(received).toBe(expected) + +Expected: "parse-error" +Received: "wrong-shape" + + at (/Users/markbeacom/github/mbeacom/copilot-worktrees/adrkit/mbeacom-symmetrical-disco/packages/adapters/catalog-backstage/test/annotation-decode.test.ts:142:37) +(fail) T058 step 3 — JSON decode, reached only by a present string scalar > "" is rejected as `parse-error` [0.02ms] +(pass) T058 step 3 — JSON decode, reached only by a present string scalar > a parse failure is never coerced into a fallback empty array [0.01ms] +(pass) T058 step 4 — shape: exactly array > a JSON object is rejected as `wrong-shape` +(pass) T058 step 4 — shape: exactly array > a bare string is rejected as `wrong-shape` +(pass) T058 step 4 — shape: exactly array > a bare number is rejected as `wrong-shape` +(pass) T058 step 4 — shape: exactly array > a bare boolean is rejected as `wrong-shape` +(pass) T058 step 4 — shape: exactly array > null is rejected as `wrong-shape` +(pass) T058 step 4 — shape: exactly array > an array containing a number is rejected as `wrong-shape` +(pass) T058 step 4 — shape: exactly array > an array containing an object is rejected as `wrong-shape` +(pass) T058 step 4 — shape: exactly array > an array containing null is rejected as `wrong-shape` +(pass) T058 step 4 — shape: exactly array > an array containing a nested array is rejected as `wrong-shape` +(pass) T058 step 4 — shape: exactly array > nothing is coerced — a bare string is never treated as a one-element array +(pass) T058 step 4 — shape: exactly array > an empty array is a valid shape, and reaches step 5 +(pass) T058 — the reasons are three distinct values, one per failing step > each step maps to its own reason +204 | decodeAnnotation(true, ['[]']), +205 | decodeAnnotation(true, '["a/**"'), +206 | decodeAnnotation(true, '{"a": 1}'), +207 | ].map((result) => (result.ok ? 'accepted' : result.rejection.reason)); +208 | +209 | expect(reasons).toEqual(['annotation-value-not-a-string', 'parse-error', 'wrong-shape']); + ^ +error: expect(received).toEqual(expected) + + [ + "annotation-value-not-a-string", +- "parse-error", ++ "wrong-shape", + "wrong-shape", + ] + +- Expected - 1 ++ Received + 1 + + at (/Users/markbeacom/github/mbeacom/copilot-worktrees/adrkit/mbeacom-symmetrical-disco/packages/adapters/catalog-backstage/test/annotation-decode.test.ts:209:21) +(fail) T058 — the reasons are three distinct values, one per failing step > the three reasons observed together are mutually distinct [0.11ms] +(pass) T058 — the reasons are three distinct values, one per failing step > the annotation key is the one ADR-0012 names + +7 tests failed: +(fail) T058 — `owned-paths-annotation.md` §1’s worked-example table > row 6 — malformed JSON yields `parse-error` [0.12ms] +(fail) T058 step 3 — JSON decode, reached only by a present string scalar > "[\"packages/**\"" is rejected as `parse-error` [0.03ms] +(fail) T058 step 3 — JSON decode, reached only by a present string scalar > "[packages/**]" is rejected as `parse-error` [0.02ms] +(fail) T058 step 3 — JSON decode, reached only by a present string scalar > "{\"a\": }" is rejected as `parse-error` [0.02ms] +(fail) T058 step 3 — JSON decode, reached only by a present string scalar > "not json at all" is rejected as `parse-error` [0.02ms] +(fail) T058 step 3 — JSON decode, reached only by a present string scalar > "" is rejected as `parse-error` [0.02ms] +(fail) T058 — the reasons are three distinct values, one per failing step > the three reasons observed together are mutually distinct [0.11ms] + + 28 pass + 7 fail + 103 expect() calls +Ran 35 tests across 1 file. [6.00ms] + +exit 1 diff --git a/specs/010-catalog-backstage/evidence/negative-cases/annotation-decode/case-2-step-3-parse-error-collapsed-into-wrong-shape.patch b/specs/010-catalog-backstage/evidence/negative-cases/annotation-decode/case-2-step-3-parse-error-collapsed-into-wrong-shape.patch new file mode 100644 index 00000000..19283683 --- /dev/null +++ b/specs/010-catalog-backstage/evidence/negative-cases/annotation-decode/case-2-step-3-parse-error-collapsed-into-wrong-shape.patch @@ -0,0 +1,13 @@ +diff --git a/packages/adapters/catalog-backstage/src/ownership/annotation.ts b/packages/adapters/catalog-backstage/src/ownership/annotation.ts +index 853276c..191e143 100644 +--- a/packages/adapters/catalog-backstage/src/ownership/annotation.ts ++++ b/packages/adapters/catalog-backstage/src/ownership/annotation.ts +@@ -164,7 +164,7 @@ export function decodeAnnotation(present: boolean, rawNode: unknown): Annotation + decoded = JSON.parse(rawNode) as unknown; + } catch (error) { + return reject( +- 'parse-error', ++ 'wrong-shape', + { + annotationPresent: true, + rawNodeIsString: true, diff --git a/specs/010-catalog-backstage/evidence/negative-cases/annotation-decode/case-3-step-4-shape-check.observed.txt b/specs/010-catalog-backstage/evidence/negative-cases/annotation-decode/case-3-step-4-shape-check.observed.txt new file mode 100644 index 00000000..e47d9de6 --- /dev/null +++ b/specs/010-catalog-backstage/evidence/negative-cases/annotation-decode/case-3-step-4-shape-check.observed.txt @@ -0,0 +1,250 @@ +bun test v1.3.14 (0d9b296a) + +test/annotation-decode.test.ts: +(pass) T058 — `owned-paths-annotation.md` §1’s worked-example table > row 1 — a well-formed string scalar proceeds [0.19ms] +(pass) T058 — `owned-paths-annotation.md` §1’s worked-example table > row 2 — a YAML sequence yields `annotation-value-not-a-string` [0.04ms] +42 | expect(result.rejection.reason).toBe('annotation-value-not-a-string'); +43 | }); +44 | +45 | test('row 3 — a JSON object yields `wrong-shape`', () => { +46 | const result = decodeAnnotation(true, '{"paths": ["a/**"]}'); +47 | expect(result.ok).toBe(false); + ^ +error: expect(received).toBe(expected) + +Expected: false +Received: true + + at (/Users/markbeacom/github/mbeacom/copilot-worktrees/adrkit/mbeacom-symmetrical-disco/packages/adapters/catalog-backstage/test/annotation-decode.test.ts:47:23) +(fail) T058 — `owned-paths-annotation.md` §1’s worked-example table > row 3 — a JSON object yields `wrong-shape` [0.15ms] +49 | expect(result.rejection.reason).toBe('wrong-shape'); +50 | }); +51 | +52 | test('row 4 — a bare string yields `wrong-shape`, never a single-element array', () => { +53 | const result = decodeAnnotation(true, '"packages/payments/**"'); +54 | expect(result.ok).toBe(false); + ^ +error: expect(received).toBe(expected) + +Expected: false +Received: true + + at (/Users/markbeacom/github/mbeacom/copilot-worktrees/adrkit/mbeacom-symmetrical-disco/packages/adapters/catalog-backstage/test/annotation-decode.test.ts:54:23) +(fail) T058 — `owned-paths-annotation.md` §1’s worked-example table > row 4 — a bare string yields `wrong-shape`, never a single-element array [0.08ms] +56 | expect(result.rejection.reason).toBe('wrong-shape'); +57 | }); +58 | +59 | test('row 5 — an array with a non-string element yields `wrong-shape`', () => { +60 | const result = decodeAnnotation(true, '["packages/payments/**", 3]'); +61 | expect(result.ok).toBe(false); + ^ +error: expect(received).toBe(expected) + +Expected: false +Received: true + + at (/Users/markbeacom/github/mbeacom/copilot-worktrees/adrkit/mbeacom-symmetrical-disco/packages/adapters/catalog-backstage/test/annotation-decode.test.ts:61:23) +(fail) T058 — `owned-paths-annotation.md` §1’s worked-example table > row 5 — an array with a non-string element yields `wrong-shape` [0.04ms] +(pass) T058 — `owned-paths-annotation.md` §1’s worked-example table > row 6 — malformed JSON yields `parse-error` [0.04ms] +(pass) T058 step 1 — presence, via an explicit discriminant > an absent annotation stops at step 1 with no rejection +(pass) T058 step 1 — presence, via an explicit discriminant > no string check, JSON parse, or shape check is attempted for an absent key [0.01ms] +(pass) T058 step 1 — presence, via an explicit discriminant > presence is not inferred from the value — a present null reaches step 2 +(pass) T058 step 2 — the string-scalar check, before any parse > a YAML sequence is rejected as `annotation-value-not-a-string` [0.01ms] +(pass) T058 step 2 — the string-scalar check, before any parse > a YAML mapping is rejected as `annotation-value-not-a-string` +(pass) T058 step 2 — the string-scalar check, before any parse > a number is rejected as `annotation-value-not-a-string` +(pass) T058 step 2 — the string-scalar check, before any parse > a boolean is rejected as `annotation-value-not-a-string` +(pass) T058 step 2 — the string-scalar check, before any parse > null is rejected as `annotation-value-not-a-string` +(pass) T058 step 2 — the string-scalar check, before any parse > the detail records that the value was never passed to JSON.parse [0.01ms] +(pass) T058 step 3 — JSON decode, reached only by a present string scalar > "[\"packages/**\"" is rejected as `parse-error` [0.01ms] +(pass) T058 step 3 — JSON decode, reached only by a present string scalar > "[packages/**]" is rejected as `parse-error` +(pass) T058 step 3 — JSON decode, reached only by a present string scalar > "{\"a\": }" is rejected as `parse-error` +(pass) T058 step 3 — JSON decode, reached only by a present string scalar > "not json at all" is rejected as `parse-error` +(pass) T058 step 3 — JSON decode, reached only by a present string scalar > "" is rejected as `parse-error` +(pass) T058 step 3 — JSON decode, reached only by a present string scalar > a parse failure is never coerced into a fallback empty array [0.02ms] +163 | ['an array containing an object', '["a/**", {}]'], +164 | ['an array containing null', '["a/**", null]'], +165 | ['an array containing a nested array', '["a/**", ["b/**"]]'], +166 | ])('%s is rejected as `wrong-shape`', (_label, raw) => { +167 | const result = decodeAnnotation(true, raw); +168 | expect(result.ok).toBe(false); + ^ +error: expect(received).toBe(expected) + +Expected: false +Received: true + + at (/Users/markbeacom/github/mbeacom/copilot-worktrees/adrkit/mbeacom-symmetrical-disco/packages/adapters/catalog-backstage/test/annotation-decode.test.ts:168:23) +(fail) T058 step 4 — shape: exactly array > a JSON object is rejected as `wrong-shape` [0.03ms] +163 | ['an array containing an object', '["a/**", {}]'], +164 | ['an array containing null', '["a/**", null]'], +165 | ['an array containing a nested array', '["a/**", ["b/**"]]'], +166 | ])('%s is rejected as `wrong-shape`', (_label, raw) => { +167 | const result = decodeAnnotation(true, raw); +168 | expect(result.ok).toBe(false); + ^ +error: expect(received).toBe(expected) + +Expected: false +Received: true + + at (/Users/markbeacom/github/mbeacom/copilot-worktrees/adrkit/mbeacom-symmetrical-disco/packages/adapters/catalog-backstage/test/annotation-decode.test.ts:168:23) +(fail) T058 step 4 — shape: exactly array > a bare string is rejected as `wrong-shape` [0.02ms] +163 | ['an array containing an object', '["a/**", {}]'], +164 | ['an array containing null', '["a/**", null]'], +165 | ['an array containing a nested array', '["a/**", ["b/**"]]'], +166 | ])('%s is rejected as `wrong-shape`', (_label, raw) => { +167 | const result = decodeAnnotation(true, raw); +168 | expect(result.ok).toBe(false); + ^ +error: expect(received).toBe(expected) + +Expected: false +Received: true + + at (/Users/markbeacom/github/mbeacom/copilot-worktrees/adrkit/mbeacom-symmetrical-disco/packages/adapters/catalog-backstage/test/annotation-decode.test.ts:168:23) +(fail) T058 step 4 — shape: exactly array > a bare number is rejected as `wrong-shape` [0.02ms] +163 | ['an array containing an object', '["a/**", {}]'], +164 | ['an array containing null', '["a/**", null]'], +165 | ['an array containing a nested array', '["a/**", ["b/**"]]'], +166 | ])('%s is rejected as `wrong-shape`', (_label, raw) => { +167 | const result = decodeAnnotation(true, raw); +168 | expect(result.ok).toBe(false); + ^ +error: expect(received).toBe(expected) + +Expected: false +Received: true + + at (/Users/markbeacom/github/mbeacom/copilot-worktrees/adrkit/mbeacom-symmetrical-disco/packages/adapters/catalog-backstage/test/annotation-decode.test.ts:168:23) +(fail) T058 step 4 — shape: exactly array > a bare boolean is rejected as `wrong-shape` [0.02ms] +163 | ['an array containing an object', '["a/**", {}]'], +164 | ['an array containing null', '["a/**", null]'], +165 | ['an array containing a nested array', '["a/**", ["b/**"]]'], +166 | ])('%s is rejected as `wrong-shape`', (_label, raw) => { +167 | const result = decodeAnnotation(true, raw); +168 | expect(result.ok).toBe(false); + ^ +error: expect(received).toBe(expected) + +Expected: false +Received: true + + at (/Users/markbeacom/github/mbeacom/copilot-worktrees/adrkit/mbeacom-symmetrical-disco/packages/adapters/catalog-backstage/test/annotation-decode.test.ts:168:23) +(fail) T058 step 4 — shape: exactly array > null is rejected as `wrong-shape` [0.02ms] +163 | ['an array containing an object', '["a/**", {}]'], +164 | ['an array containing null', '["a/**", null]'], +165 | ['an array containing a nested array', '["a/**", ["b/**"]]'], +166 | ])('%s is rejected as `wrong-shape`', (_label, raw) => { +167 | const result = decodeAnnotation(true, raw); +168 | expect(result.ok).toBe(false); + ^ +error: expect(received).toBe(expected) + +Expected: false +Received: true + + at (/Users/markbeacom/github/mbeacom/copilot-worktrees/adrkit/mbeacom-symmetrical-disco/packages/adapters/catalog-backstage/test/annotation-decode.test.ts:168:23) +(fail) T058 step 4 — shape: exactly array > an array containing a number is rejected as `wrong-shape` [0.02ms] +163 | ['an array containing an object', '["a/**", {}]'], +164 | ['an array containing null', '["a/**", null]'], +165 | ['an array containing a nested array', '["a/**", ["b/**"]]'], +166 | ])('%s is rejected as `wrong-shape`', (_label, raw) => { +167 | const result = decodeAnnotation(true, raw); +168 | expect(result.ok).toBe(false); + ^ +error: expect(received).toBe(expected) + +Expected: false +Received: true + + at (/Users/markbeacom/github/mbeacom/copilot-worktrees/adrkit/mbeacom-symmetrical-disco/packages/adapters/catalog-backstage/test/annotation-decode.test.ts:168:23) +(fail) T058 step 4 — shape: exactly array > an array containing an object is rejected as `wrong-shape` [0.03ms] +163 | ['an array containing an object', '["a/**", {}]'], +164 | ['an array containing null', '["a/**", null]'], +165 | ['an array containing a nested array', '["a/**", ["b/**"]]'], +166 | ])('%s is rejected as `wrong-shape`', (_label, raw) => { +167 | const result = decodeAnnotation(true, raw); +168 | expect(result.ok).toBe(false); + ^ +error: expect(received).toBe(expected) + +Expected: false +Received: true + + at (/Users/markbeacom/github/mbeacom/copilot-worktrees/adrkit/mbeacom-symmetrical-disco/packages/adapters/catalog-backstage/test/annotation-decode.test.ts:168:23) +(fail) T058 step 4 — shape: exactly array > an array containing null is rejected as `wrong-shape` [0.02ms] +163 | ['an array containing an object', '["a/**", {}]'], +164 | ['an array containing null', '["a/**", null]'], +165 | ['an array containing a nested array', '["a/**", ["b/**"]]'], +166 | ])('%s is rejected as `wrong-shape`', (_label, raw) => { +167 | const result = decodeAnnotation(true, raw); +168 | expect(result.ok).toBe(false); + ^ +error: expect(received).toBe(expected) + +Expected: false +Received: true + + at (/Users/markbeacom/github/mbeacom/copilot-worktrees/adrkit/mbeacom-symmetrical-disco/packages/adapters/catalog-backstage/test/annotation-decode.test.ts:168:23) +(fail) T058 step 4 — shape: exactly array > an array containing a nested array is rejected as `wrong-shape` [0.02ms] +172 | expect(result.diagnostics.shapeOutcome).toBe('wrong-shape'); +173 | }); +174 | +175 | test('nothing is coerced — a bare string is never treated as a one-element array', () => { +176 | const result = decodeAnnotation(true, '"packages/payments/**"'); +177 | expect(result.ok).toBe(false); + ^ +error: expect(received).toBe(expected) + +Expected: false +Received: true + + at (/Users/markbeacom/github/mbeacom/copilot-worktrees/adrkit/mbeacom-symmetrical-disco/packages/adapters/catalog-backstage/test/annotation-decode.test.ts:177:23) +(fail) T058 step 4 — shape: exactly array > nothing is coerced — a bare string is never treated as a one-element array [0.03ms] +(pass) T058 step 4 — shape: exactly array > an empty array is a valid shape, and reaches step 5 [0.01ms] +(pass) T058 — the reasons are three distinct values, one per failing step > each step maps to its own reason +204 | decodeAnnotation(true, ['[]']), +205 | decodeAnnotation(true, '["a/**"'), +206 | decodeAnnotation(true, '{"a": 1}'), +207 | ].map((result) => (result.ok ? 'accepted' : result.rejection.reason)); +208 | +209 | expect(reasons).toEqual(['annotation-value-not-a-string', 'parse-error', 'wrong-shape']); + ^ +error: expect(received).toEqual(expected) + + [ + "annotation-value-not-a-string", + "parse-error", +- "wrong-shape", ++ "accepted", + ] + +- Expected - 1 ++ Received + 1 + + at (/Users/markbeacom/github/mbeacom/copilot-worktrees/adrkit/mbeacom-symmetrical-disco/packages/adapters/catalog-backstage/test/annotation-decode.test.ts:209:21) +(fail) T058 — the reasons are three distinct values, one per failing step > the three reasons observed together are mutually distinct [0.15ms] +(pass) T058 — the reasons are three distinct values, one per failing step > the annotation key is the one ADR-0012 names + +14 tests failed: +(fail) T058 — `owned-paths-annotation.md` §1’s worked-example table > row 3 — a JSON object yields `wrong-shape` [0.15ms] +(fail) T058 — `owned-paths-annotation.md` §1’s worked-example table > row 4 — a bare string yields `wrong-shape`, never a single-element array [0.08ms] +(fail) T058 — `owned-paths-annotation.md` §1’s worked-example table > row 5 — an array with a non-string element yields `wrong-shape` [0.04ms] +(fail) T058 step 4 — shape: exactly array > a JSON object is rejected as `wrong-shape` [0.03ms] +(fail) T058 step 4 — shape: exactly array > a bare string is rejected as `wrong-shape` [0.02ms] +(fail) T058 step 4 — shape: exactly array > a bare number is rejected as `wrong-shape` [0.02ms] +(fail) T058 step 4 — shape: exactly array > a bare boolean is rejected as `wrong-shape` [0.02ms] +(fail) T058 step 4 — shape: exactly array > null is rejected as `wrong-shape` [0.02ms] +(fail) T058 step 4 — shape: exactly array > an array containing a number is rejected as `wrong-shape` [0.02ms] +(fail) T058 step 4 — shape: exactly array > an array containing an object is rejected as `wrong-shape` [0.03ms] +(fail) T058 step 4 — shape: exactly array > an array containing null is rejected as `wrong-shape` [0.02ms] +(fail) T058 step 4 — shape: exactly array > an array containing a nested array is rejected as `wrong-shape` [0.02ms] +(fail) T058 step 4 — shape: exactly array > nothing is coerced — a bare string is never treated as a one-element array [0.03ms] +(fail) T058 — the reasons are three distinct values, one per failing step > the three reasons observed together are mutually distinct [0.15ms] + + 21 pass + 14 fail + 81 expect() calls +Ran 35 tests across 1 file. [12.00ms] + +exit 1 diff --git a/specs/010-catalog-backstage/evidence/negative-cases/annotation-decode/case-3-step-4-shape-check.patch b/specs/010-catalog-backstage/evidence/negative-cases/annotation-decode/case-3-step-4-shape-check.patch new file mode 100644 index 00000000..2c627d00 --- /dev/null +++ b/specs/010-catalog-backstage/evidence/negative-cases/annotation-decode/case-3-step-4-shape-check.patch @@ -0,0 +1,13 @@ +diff --git a/packages/adapters/catalog-backstage/src/ownership/annotation.ts b/packages/adapters/catalog-backstage/src/ownership/annotation.ts +index 853276c..3f8063e 100644 +--- a/packages/adapters/catalog-backstage/src/ownership/annotation.ts ++++ b/packages/adapters/catalog-backstage/src/ownership/annotation.ts +@@ -178,7 +178,7 @@ export function decodeAnnotation(present: boolean, rawNode: unknown): Annotation + + // Step 4 — shape. Exactly `array`; nothing is ever coerced. + const shapeFailure = describeShapeFailure(decoded); +- if (shapeFailure !== undefined) { ++ if (false && shapeFailure !== undefined) { + return reject( + 'wrong-shape', + { diff --git a/specs/010-catalog-backstage/evidence/negative-cases/annotation-decode/restored.observed.txt b/specs/010-catalog-backstage/evidence/negative-cases/annotation-decode/restored.observed.txt new file mode 100644 index 00000000..ad51016b --- /dev/null +++ b/specs/010-catalog-backstage/evidence/negative-cases/annotation-decode/restored.observed.txt @@ -0,0 +1,45 @@ +bun test v1.3.14 (0d9b296a) + +test/annotation-decode.test.ts: +(pass) T058 — `owned-paths-annotation.md` §1’s worked-example table > row 1 — a well-formed string scalar proceeds [0.22ms] +(pass) T058 — `owned-paths-annotation.md` §1’s worked-example table > row 2 — a YAML sequence yields `annotation-value-not-a-string` [0.04ms] +(pass) T058 — `owned-paths-annotation.md` §1’s worked-example table > row 3 — a JSON object yields `wrong-shape` [0.02ms] +(pass) T058 — `owned-paths-annotation.md` §1’s worked-example table > row 4 — a bare string yields `wrong-shape`, never a single-element array +(pass) T058 — `owned-paths-annotation.md` §1’s worked-example table > row 5 — an array with a non-string element yields `wrong-shape` [0.02ms] +(pass) T058 — `owned-paths-annotation.md` §1’s worked-example table > row 6 — malformed JSON yields `parse-error` [0.06ms] +(pass) T058 step 1 — presence, via an explicit discriminant > an absent annotation stops at step 1 with no rejection [0.02ms] +(pass) T058 step 1 — presence, via an explicit discriminant > no string check, JSON parse, or shape check is attempted for an absent key [0.01ms] +(pass) T058 step 1 — presence, via an explicit discriminant > presence is not inferred from the value — a present null reaches step 2 +(pass) T058 step 2 — the string-scalar check, before any parse > a YAML sequence is rejected as `annotation-value-not-a-string` [0.01ms] +(pass) T058 step 2 — the string-scalar check, before any parse > a YAML mapping is rejected as `annotation-value-not-a-string` +(pass) T058 step 2 — the string-scalar check, before any parse > a number is rejected as `annotation-value-not-a-string` +(pass) T058 step 2 — the string-scalar check, before any parse > a boolean is rejected as `annotation-value-not-a-string` +(pass) T058 step 2 — the string-scalar check, before any parse > null is rejected as `annotation-value-not-a-string` +(pass) T058 step 2 — the string-scalar check, before any parse > the detail records that the value was never passed to JSON.parse [0.01ms] +(pass) T058 step 3 — JSON decode, reached only by a present string scalar > "[\"packages/**\"" is rejected as `parse-error` [0.01ms] +(pass) T058 step 3 — JSON decode, reached only by a present string scalar > "[packages/**]" is rejected as `parse-error` +(pass) T058 step 3 — JSON decode, reached only by a present string scalar > "{\"a\": }" is rejected as `parse-error` +(pass) T058 step 3 — JSON decode, reached only by a present string scalar > "not json at all" is rejected as `parse-error` +(pass) T058 step 3 — JSON decode, reached only by a present string scalar > "" is rejected as `parse-error` +(pass) T058 step 3 — JSON decode, reached only by a present string scalar > a parse failure is never coerced into a fallback empty array [0.02ms] +(pass) T058 step 4 — shape: exactly array > a JSON object is rejected as `wrong-shape` [0.01ms] +(pass) T058 step 4 — shape: exactly array > a bare string is rejected as `wrong-shape` [0.01ms] +(pass) T058 step 4 — shape: exactly array > a bare number is rejected as `wrong-shape` +(pass) T058 step 4 — shape: exactly array > a bare boolean is rejected as `wrong-shape` +(pass) T058 step 4 — shape: exactly array > null is rejected as `wrong-shape` +(pass) T058 step 4 — shape: exactly array > an array containing a number is rejected as `wrong-shape` +(pass) T058 step 4 — shape: exactly array > an array containing an object is rejected as `wrong-shape` +(pass) T058 step 4 — shape: exactly array > an array containing null is rejected as `wrong-shape` +(pass) T058 step 4 — shape: exactly array > an array containing a nested array is rejected as `wrong-shape` +(pass) T058 step 4 — shape: exactly array > nothing is coerced — a bare string is never treated as a one-element array [0.02ms] +(pass) T058 step 4 — shape: exactly array > an empty array is a valid shape, and reaches step 5 [0.02ms] +(pass) T058 — the reasons are three distinct values, one per failing step > each step maps to its own reason [0.01ms] +(pass) T058 — the reasons are three distinct values, one per failing step > the three reasons observed together are mutually distinct [0.13ms] +(pass) T058 — the reasons are three distinct values, one per failing step > the annotation key is the one ADR-0012 names [0.01ms] + + 35 pass + 0 fail + 114 expect() calls +Ran 35 tests across 1 file. [7.00ms] + +exit 0 diff --git a/specs/010-catalog-backstage/evidence/negative-cases/annotation-sequence-coercion/README.md b/specs/010-catalog-backstage/evidence/negative-cases/annotation-sequence-coercion/README.md new file mode 100644 index 00000000..59f42734 --- /dev/null +++ b/specs/010-catalog-backstage/evidence/negative-cases/annotation-sequence-coercion/README.md @@ -0,0 +1,95 @@ +# Negative case: `["[]"]` silently coerced into `explicit-empty` + +**Task**: T059 · **Discharges**: FR-027 · **Supports**: SC-006 +**Observed against**: `99ba8d2500eaf37625ea164f66b4a17870e40dad` plus Phase D's own +uncommitted work; the mutation under test was the only additional change in the tree. +**Tools**: Bun 1.3.14, TypeScript 6.0.3, `yaml@2.9.0` +**Command**: `bun test test/annotation-step2-raw-node.test.ts`, run from +`packages/adapters/catalog-backstage/` +**Permanent automated case**: `packages/adapters/catalog-backstage/test/annotation-step2-raw-node.test.ts` — +**retained permanently**, per T059. + +## The exact fixture, and why it is this one + +The annotation value `["[]"]` — a YAML **sequence** containing the string `"[]"`, not a +string — must yield `annotation-value-not-a-string`, and must **never** be silently +coerced into `explicit-empty`. + +The coercion path is a language-level fact, not a hypothetical. +`owned-paths-annotation.md` §1 step 2 spells it out: ECMA-262 defines `JSON.parse(text)` +as first coercing `text` to a string via `ToString`, so `["[]"]` becomes the string +`"[]"`, parses cleanly as an empty array, and is then misclassified. + +**A misclassification here is worse than a crash.** `explicit-empty` is a *legitimate* +state meaning "this entity deliberately owns nothing", so the failure would be silent and +would look like a considered decision by the descriptor's author. + +The TypeScript signature of `JSON.parse` provides **no** runtime protection: it declares +a `string` parameter, and a value arriving from YAML is `unknown`. Only the explicit +`typeof rawNode === 'string'` pre-parse check does. + +--- + +## Case 1 — step 2 removed + +Input: [`case-1-step-2-removed-yields-explicit-empty.patch`](./case-1-step-2-removed-yields-explicit-empty.patch) · +Output: [`case-1-step-2-removed-yields-explicit-empty.observed.txt`](./case-1-step-2-removed-yields-explicit-empty.observed.txt) + +Six tests fail. The two that matter most: + +``` +(fail) T059 — the correct reason is produced, and the wrong one is absent > `decodeAnnotation` yields `annotation-value-not-a-string` +Expected: false +Received: true + +(fail) T059 — the correct reason is produced, and the wrong one is absent > and never `explicit-empty` — the absence of the wrong outcome +Expected: false +Received: true +``` + +The second is the whole point of the case: the assertion that the serialized outcome +does **not** contain `explicit-empty` is what fails. With step 2 gone, the descriptor is +recorded as having deliberately declared ownership of nothing. + +Note also what **passes** in the same failing run: + +``` +(pass) T059 — the coercion this check exists to prevent is real > so an implementation without step 2 would classify it `explicit-empty` +``` + +That test reproduces the coercion directly, so "step 2 is load-bearing" is a confirmed +danger rather than an assertion about a hypothetical one. + +--- + +## Why the ordering, not merely the presence, is demonstrated + +Three fixtures in the permanent case each take a *different* wrong path if step 2 is +skipped, and all three must still report step 2: + +| Raw node | Reason if step 2 is skipped | Required reason | +|---|---|---| +| `["[]"]` | classified `explicit-empty` | `annotation-value-not-a-string` | +| `{ a: 1 }` | `parse-error` (`[object Object]` is not JSON) | `annotation-value-not-a-string` | +| `3` | `wrong-shape` (`"3"` parses to a number) | `annotation-value-not-a-string` | + +If the check merely existed somewhere later, at most one of these would land correctly. +All three landing on step 2 is what shows the ordering. + +The diagnostics record carries the same claim structurally: +`jsonParseOutcome === 'not-a-string'` and `shapeOutcome === undefined`, i.e. step 3 was +never reached. + +## The check is not a blanket ban + +A genuine `explicit-empty` is still reachable — `deriveOwnership(true, '[]')` yields it. +If `["[]"]` were rejected by something that also rejected the legitimate case, this +fixture would prove nothing about step 2 specifically. + +## Restored + +[`restored.observed.txt`](./restored.observed.txt) — all 12 tests pass, 0 fail. + +## Standing constraints + +ADR-0014 **rung 1 only**. diff --git a/specs/010-catalog-backstage/evidence/negative-cases/annotation-sequence-coercion/case-1-step-2-removed-yields-explicit-empty.observed.txt b/specs/010-catalog-backstage/evidence/negative-cases/annotation-sequence-coercion/case-1-step-2-removed-yields-explicit-empty.observed.txt new file mode 100644 index 00000000..f103062b --- /dev/null +++ b/specs/010-catalog-backstage/evidence/negative-cases/annotation-sequence-coercion/case-1-step-2-removed-yields-explicit-empty.observed.txt @@ -0,0 +1,98 @@ +bun test v1.3.14 (0d9b296a) + +test/annotation-step2-raw-node.test.ts: +(pass) T059 — the coercion this check exists to prevent is real > `JSON.parse` coerces a one-element sequence to `"[]"` and returns `[]` [0.56ms] +(pass) T059 — the coercion this check exists to prevent is real > so an implementation without step 2 would classify it `explicit-empty` [0.01ms] +(pass) T059 — the YAML sequence really is a sequence by the time it is checked > the raw node read from the descriptor is an array, not a string [6.77ms] +84 | }); +85 | +86 | describe('T059 — the correct reason is produced, and the wrong one is absent', () => { +87 | test('`decodeAnnotation` yields `annotation-value-not-a-string`', () => { +88 | const result = decodeAnnotation(true, ['[]']); +89 | expect(result.ok).toBe(false); + ^ +error: expect(received).toBe(expected) + +Expected: false +Received: true + + at (/Users/markbeacom/github/mbeacom/copilot-worktrees/adrkit/mbeacom-symmetrical-disco/packages/adapters/catalog-backstage/test/annotation-step2-raw-node.test.ts:89:23) +(fail) T059 — the correct reason is produced, and the wrong one is absent > `decodeAnnotation` yields `annotation-value-not-a-string` [0.22ms] +91 | expect(result.rejection.reason).toBe('annotation-value-not-a-string'); +92 | }); +93 | +94 | test('and never `explicit-empty` — the absence of the wrong outcome', () => { +95 | const result = decodeAnnotation(true, ['[]']); +96 | expect(result.ok).toBe(false); + ^ +error: expect(received).toBe(expected) + +Expected: false +Received: true + + at (/Users/markbeacom/github/mbeacom/copilot-worktrees/adrkit/mbeacom-symmetrical-disco/packages/adapters/catalog-backstage/test/annotation-step2-raw-node.test.ts:96:23) +(fail) T059 — the correct reason is produced, and the wrong one is absent > and never `explicit-empty` — the absence of the wrong outcome [0.04ms] +101 | const [document] = readDescriptorDocuments('catalog-info.yaml', DESCRIPTOR); +102 | if (document === undefined) throw new Error('fixture did not parse'); +103 | const node = readAnnotationNode(document, 'adrkit.io/owned-paths'); +104 | +105 | const derivation = deriveOwnership(node.present, node.value); +106 | expect(derivation.ok).toBe(false); + ^ +error: expect(received).toBe(expected) + +Expected: false +Received: true + + at (/Users/markbeacom/github/mbeacom/copilot-worktrees/adrkit/mbeacom-symmetrical-disco/packages/adapters/catalog-backstage/test/annotation-step2-raw-node.test.ts:106:27) +(fail) T059 — the correct reason is produced, and the wrong one is absent > the whole derivation, end to end from the YAML [0.59ms] +(pass) T059 — the correct reason is produced, and the wrong one is absent > a genuine `explicit-empty` is still reachable — the check is not a blanket ban [0.02ms] +128 | // were skipped, this would surface as `parse-error` — a different reason for +129 | // the same defect, and the one that would hide the ordering bug. +130 | const result = decodeAnnotation(true, { a: 1 }); +131 | expect(result.ok).toBe(false); +132 | if (result.ok) return; +133 | expect(result.rejection.reason).toBe('annotation-value-not-a-string'); + ^ +error: expect(received).toBe(expected) + +Expected: "annotation-value-not-a-string" +Received: "parse-error" + + at (/Users/markbeacom/github/mbeacom/copilot-worktrees/adrkit/mbeacom-symmetrical-disco/packages/adapters/catalog-backstage/test/annotation-step2-raw-node.test.ts:133:37) +(fail) T059 — step 2 runs before step 3, not merely instead of it > a non-string that would ALSO fail JSON parsing still reports step 2 [0.12ms] +138 | // `3` stringifies to `"3"`, which parses to the number 3 — so without step 2 +139 | // this would reach step 4 and report `wrong-shape`, again masking the ordering. +140 | const result = decodeAnnotation(true, 3); +141 | expect(result.ok).toBe(false); +142 | if (result.ok) return; +143 | expect(result.rejection.reason).toBe('annotation-value-not-a-string'); + ^ +error: expect(received).toBe(expected) + +Expected: "annotation-value-not-a-string" +Received: "wrong-shape" + + at (/Users/markbeacom/github/mbeacom/copilot-worktrees/adrkit/mbeacom-symmetrical-disco/packages/adapters/catalog-backstage/test/annotation-step2-raw-node.test.ts:143:37) +(fail) T059 — step 2 runs before step 3, not merely instead of it > a number that would parse cleanly still reports step 2 [0.06ms] +144 | expect(result.rejection.reason).not.toBe('wrong-shape'); +145 | }); +146 | +147 | test('the diagnostics record shows step 3 was never reached', () => { +148 | const result = decodeAnnotation(true, ['[]']); +149 | expect(result.ok).toBe(false); + ^ +error: expect(received).toBe(expected) + +Expected: false +Received: true + + at (/Users/markbeacom/github/mbeacom/copilot-worktrees/adrkit/mbeacom-symmetrical-disco/packages/adapters/catalog-backstage/test/annotation-step2-raw-node.test.ts:149:23) +(fail) T059 — step 2 runs before step 3, not merely instead of it > the diagnostics record shows step 3 was never reached [0.08ms] + + 4 pass + 6 fail + 19 expect() calls +Ran 10 tests across 1 file. [48.00ms] + +exit 1 diff --git a/specs/010-catalog-backstage/evidence/negative-cases/annotation-sequence-coercion/case-1-step-2-removed-yields-explicit-empty.patch b/specs/010-catalog-backstage/evidence/negative-cases/annotation-sequence-coercion/case-1-step-2-removed-yields-explicit-empty.patch new file mode 100644 index 00000000..b58497c8 --- /dev/null +++ b/specs/010-catalog-backstage/evidence/negative-cases/annotation-sequence-coercion/case-1-step-2-removed-yields-explicit-empty.patch @@ -0,0 +1,13 @@ +diff --git a/packages/adapters/catalog-backstage/src/ownership/annotation.ts b/packages/adapters/catalog-backstage/src/ownership/annotation.ts +index 853276c..ebb5f31 100644 +--- a/packages/adapters/catalog-backstage/src/ownership/annotation.ts ++++ b/packages/adapters/catalog-backstage/src/ownership/annotation.ts +@@ -143,7 +143,7 @@ export function decodeAnnotation(present: boolean, rawNode: unknown): Annotation + if (!present) return { ok: true, value: { diagnostics: ABSENT, patterns: undefined } }; + + // Step 2 — string-scalar check on the raw node, BEFORE any JSON.parse. +- if (typeof rawNode !== 'string') { ++ if (false && typeof rawNode !== 'string') { + return reject( + 'annotation-value-not-a-string', + { diff --git a/specs/010-catalog-backstage/evidence/negative-cases/annotation-sequence-coercion/restored.observed.txt b/specs/010-catalog-backstage/evidence/negative-cases/annotation-sequence-coercion/restored.observed.txt new file mode 100644 index 00000000..d32ae42f --- /dev/null +++ b/specs/010-catalog-backstage/evidence/negative-cases/annotation-sequence-coercion/restored.observed.txt @@ -0,0 +1,20 @@ +bun test v1.3.14 (0d9b296a) + +test/annotation-step2-raw-node.test.ts: +(pass) T059 — the coercion this check exists to prevent is real > `JSON.parse` coerces a one-element sequence to `"[]"` and returns `[]` [0.59ms] +(pass) T059 — the coercion this check exists to prevent is real > so an implementation without step 2 would classify it `explicit-empty` [0.50ms] +(pass) T059 — the YAML sequence really is a sequence by the time it is checked > the raw node read from the descriptor is an array, not a string [5.63ms] +(pass) T059 — the correct reason is produced, and the wrong one is absent > `decodeAnnotation` yields `annotation-value-not-a-string` [0.12ms] +(pass) T059 — the correct reason is produced, and the wrong one is absent > and never `explicit-empty` — the absence of the wrong outcome [0.02ms] +(pass) T059 — the correct reason is produced, and the wrong one is absent > the whole derivation, end to end from the YAML [0.44ms] +(pass) T059 — the correct reason is produced, and the wrong one is absent > a genuine `explicit-empty` is still reachable — the check is not a blanket ban [0.07ms] +(pass) T059 — step 2 runs before step 3, not merely instead of it > a non-string that would ALSO fail JSON parsing still reports step 2 [0.01ms] +(pass) T059 — step 2 runs before step 3, not merely instead of it > a number that would parse cleanly still reports step 2 +(pass) T059 — step 2 runs before step 3, not merely instead of it > the diagnostics record shows step 3 was never reached [0.04ms] + + 10 pass + 0 fail + 30 expect() calls +Ran 10 tests across 1 file. [49.00ms] + +exit 0 diff --git a/specs/010-catalog-backstage/evidence/negative-cases/glob-rules/README.md b/specs/010-catalog-backstage/evidence/negative-cases/glob-rules/README.md new file mode 100644 index 00000000..46054ae2 --- /dev/null +++ b/specs/010-catalog-backstage/evidence/negative-cases/glob-rules/README.md @@ -0,0 +1,127 @@ +# Negative case: the restricted glob dialect's ordered rules + +**Task**: T065 · **Discharges**: SC-007 · **Supports**: FR-030, FR-031 +**Observed against**: `99ba8d2500eaf37625ea164f66b4a17870e40dad` plus Phase D's own +uncommitted work; the mutation under test was the only additional change in the tree. +**Tools**: Bun 1.3.14, TypeScript 6.0.3, `picomatch@4.0.5` (read at runtime from the +installed dependency, never transcribed) +**Command for every case below**: `bun test test/glob-rules.test.ts`, run from +`packages/adapters/catalog-backstage/` +**Permanent automated case**: `packages/adapters/catalog-backstage/test/glob-rules.test.ts` + +## Fifteen rules; fourteen required exercises; rule 15's non-occurrence is conformant + +`glob-dialect.md` §3 defines fifteen ordered rules. Rule 15 is the engine compile, and +its `invalid-glob-compile-failure` outcome is, in §3's own words, "expected to never +occur in practice, given rules 1–14's exhaustiveness; present only as a defensive +backstop". SC-007 accordingly requires rules **1–14** each to be exercised and states +that a run which never produces rule 15's rejection **is conformant and MUST NOT be +reported as a coverage gap**. + +Rule 15's `accepted` outcome **is** exercised, by every valid pattern reaching it — and +`test/glob-rules.test.ts` asserts the compile really is invoked (`compileCount` goes +from 0 to 1) so that `accepted` cannot be reached by rule 14 falling through with the +backstop absent rather than merely unfired. + +**Fifteen rules is not fourteen required exercises, and neither number is the trigger +count** — that is fifteen too, for an unrelated reason (`data-model.md` §7.1: "Do not +conflate the two numbers"). + +The permanent case supplies, for each of rules 1–14, a pattern that violates *that* rule +and **no earlier one**, and asserts both halves: that the rule fires, and that no earlier +rule does. + +--- + +## Case 1 — rule 1 (`empty`) disabled + +Input: [`case-1-rule-1-empty.patch`](./case-1-rule-1-empty.patch) · +Output: [`case-1-rule-1-empty.observed.txt`](./case-1-rule-1-empty.observed.txt) + +``` +(fail) T065 — each of rules 1–14, observed firing > rule 1 on "" +Expected: "empty" +Received: "empty-segment" +Expected: 1 +Received: 12 +``` + +The most instructive of the three. The empty pattern is not merely unrejected — it falls +through to **rule 12** and is reported as `empty-segment`. First-match-wins ordering is +what makes a pattern's reported reason a function of the pattern rather than of which +rules happen to be enabled, and this is that property failing visibly. + +## Case 2 — rule 13 (`disallowed-character`) disabled + +Input: [`case-2-rule-13-disallowed-character.patch`](./case-2-rule-13-disallowed-character.patch) · +Output: [`case-2-rule-13-disallowed-character.observed.txt`](./case-2-rule-13-disallowed-character.observed.txt) + +Eighteen tests fail — the largest set, because rule 13 is the positive allowlist that +§3 says closes a gap "a pure blacklist (rules 1–12) cannot". + +``` +(fail) T065 — each of rules 1–14, observed firing > rule 13 on "packages/@scope/**" +(fail) T065 — rule 13 closes the gap a blacklist cannot (§3) > "@" violates none of rules 1–12 and is caught by rule 13 +Expected: "disallowed-character" +Received: "accepted" +Expected: 13 +Received: 15 +``` + +`Received: "accepted"` at rule 15 is the exact failure §3 describes: characters such as +`@`, `#`, `%`, `~`, `+`, `=`, `:`, `;`, `<`, `>`, `|`, `&`, `^` and any non-ASCII +literal violate none of rules 1–12 individually, compile cleanly, and would be admitted +outright without the positive grammar. All fifteen such characters are exercised in the +permanent case. + +## Case 3 — rule 14 (`malformed-double-star`) disabled + +Input: [`case-3-rule-14-malformed-double-star.patch`](./case-3-rule-14-malformed-double-star.patch) · +Output: [`case-3-rule-14-malformed-double-star.observed.txt`](./case-3-rule-14-malformed-double-star.observed.txt) + +``` +(fail) T065 — each of rules 1–14, observed firing > rule 14 on "packages/**bar" +(fail) T065 — rule 14: only a whole-segment `**` is allowed > "a**b" is `malformed-double-star` +Expected: "malformed-double-star" +Received: "accepted" +Expected: 14 +Received: 15 +``` + +Again `accepted` at rule 15: a partial-segment `**` compiles perfectly well, so the +engine will not catch it. Only the dialect's own rule will. + +--- + +## The fourteen fixtures, restored + +| Rule | Pattern | Outcome | +|---|---|---| +| 1 | `""` | `empty` | +| 2 | `/packages/**` | `leading-slash` | +| 3 | `C:/packages/**` | `absolute-or-drive-or-unc` | +| 4 | `packages\payments` | `backslash` | +| 5 | `packages//payments` | `nul-or-control-char` | +| 6 | `packages/{a}/**` | `brace` | +| 7 | `packages/[ab]/**` | `bracket` | +| 8 | `packages/(a)/**` | `parenthesis` | +| 9 | `packages/a,b/**` | `comma` | +| 10 | `!packages/**` | `leading-bang` | +| 11 | `packages/../etc` | `traversal-segment` | +| 12 | `packages//payments` | `empty-segment` | +| 13 | `packages/@scope/**` | `disallowed-character` | +| 14 | `packages/**bar` | `malformed-double-star` | + +§3's worked example is asserted directly: `packages/{a,..}/**` reports rule 6 +(`brace`) and never reaches rule 11, while `packages/../etc` reports rule 11 +(`traversal-segment`) — "the two rejection reasons remain independently +distinguishable." + +## Restored + +[`restored.observed.txt`](./restored.observed.txt) — all 72 tests pass, 0 fail. + +## Standing constraints + +ADR-0014 **rung 1 only**. Rule 15 not firing its rejection is conformant and is **not** +reported as a coverage gap anywhere in this feature's artifacts. diff --git a/specs/010-catalog-backstage/evidence/negative-cases/glob-rules/case-1-rule-1-empty.observed.txt b/specs/010-catalog-backstage/evidence/negative-cases/glob-rules/case-1-rule-1-empty.observed.txt new file mode 100644 index 00000000..fcf1e2cf --- /dev/null +++ b/specs/010-catalog-backstage/evidence/negative-cases/glob-rules/case-1-rule-1-empty.observed.txt @@ -0,0 +1,112 @@ +bun test v1.3.14 (0d9b296a) + +test/glob-rules.test.ts: +(pass) T065 — the rule set > there are fifteen rules [0.02ms] +(pass) T065 — the rule set > fourteen of them require exercise [0.02ms] +(pass) T065 — the rule set > a fixture exists for every rule requiring exercise, and for no other [0.02ms] +(pass) T065 — the rule set > the fourteen outcomes are fourteen distinct values [0.02ms] +92 | describe('T065 — each of rules 1\u201314, observed firing', () => { +93 | test.each(RULE_FIXTURES.map((fixture) => [fixture.rule, fixture.pattern, fixture] as const))( +94 | 'rule %d on %j', +95 | (_rule, _pattern, fixture) => { +96 | const result = validateGlobPattern(fixture.pattern); +97 | expect(result.outcome).toBe(fixture.outcome); + ^ +error: expect(received).toBe(expected) + +Expected: "empty" +Received: "empty-segment" + + at (/Users/markbeacom/github/mbeacom/copilot-worktrees/adrkit/mbeacom-symmetrical-disco/packages/adapters/catalog-backstage/test/glob-rules.test.ts:97:30) +(fail) T065 — each of rules 1–14, observed firing > rule 1 on "" [0.30ms] +(pass) T065 — each of rules 1–14, observed firing > rule 2 on "/packages/**" +(pass) T065 — each of rules 1–14, observed firing > rule 3 on "C:/packages/**" +(pass) T065 — each of rules 1–14, observed firing > rule 4 on "packages\\payments" +(pass) T065 — each of rules 1–14, observed firing > rule 5 on "packages/\u0000/payments" [0.01ms] +(pass) T065 — each of rules 1–14, observed firing > rule 6 on "packages/{a}/**" +(pass) T065 — each of rules 1–14, observed firing > rule 7 on "packages/[ab]/**" [0.01ms] +(pass) T065 — each of rules 1–14, observed firing > rule 8 on "packages/(a)/**" +(pass) T065 — each of rules 1–14, observed firing > rule 9 on "packages/a,b/**" +(pass) T065 — each of rules 1–14, observed firing > rule 10 on "!packages/**" +(pass) T065 — each of rules 1–14, observed firing > rule 11 on "packages/../etc" +(pass) T065 — each of rules 1–14, observed firing > rule 12 on "packages//payments" [0.03ms] +(pass) T065 — each of rules 1–14, observed firing > rule 13 on "packages/@scope/**" [0.04ms] +(pass) T065 — each of rules 1–14, observed firing > rule 14 on "packages/**bar" [0.02ms] +105 | (_rule, _pattern, fixture) => { +106 | // If it did, the reported reason would be the earlier rule's, and this fixture +107 | // would be exercising a rule it was not chosen for. +108 | const result = validateGlobPattern(fixture.pattern); +109 | expect(result.rule).not.toBeLessThan(fixture.rule); +110 | expect(result.rule).toBe(fixture.rule); + ^ +error: expect(received).toBe(expected) + +Expected: 1 +Received: 12 + + at (/Users/markbeacom/github/mbeacom/copilot-worktrees/adrkit/mbeacom-symmetrical-disco/packages/adapters/catalog-backstage/test/glob-rules.test.ts:110:27) +(fail) T065 — each of rules 1–14, observed firing > rule 1’s fixture "" violates no earlier rule [0.08ms] +(pass) T065 — each of rules 1–14, observed firing > rule 2’s fixture "/packages/**" violates no earlier rule +(pass) T065 — each of rules 1–14, observed firing > rule 3’s fixture "C:/packages/**" violates no earlier rule +(pass) T065 — each of rules 1–14, observed firing > rule 4’s fixture "packages\\payments" violates no earlier rule +(pass) T065 — each of rules 1–14, observed firing > rule 5’s fixture "packages/\u0000/payments" violates no earlier rule +(pass) T065 — each of rules 1–14, observed firing > rule 6’s fixture "packages/{a}/**" violates no earlier rule [0.01ms] +(pass) T065 — each of rules 1–14, observed firing > rule 7’s fixture "packages/[ab]/**" violates no earlier rule +(pass) T065 — each of rules 1–14, observed firing > rule 8’s fixture "packages/(a)/**" violates no earlier rule +(pass) T065 — each of rules 1–14, observed firing > rule 9’s fixture "packages/a,b/**" violates no earlier rule +(pass) T065 — each of rules 1–14, observed firing > rule 10’s fixture "!packages/**" violates no earlier rule +(pass) T065 — each of rules 1–14, observed firing > rule 11’s fixture "packages/../etc" violates no earlier rule +(pass) T065 — each of rules 1–14, observed firing > rule 12’s fixture "packages//payments" violates no earlier rule +(pass) T065 — each of rules 1–14, observed firing > rule 13’s fixture "packages/@scope/**" violates no earlier rule +(pass) T065 — each of rules 1–14, observed firing > rule 14’s fixture "packages/**bar" violates no earlier rule +(pass) T065 — first-match-wins, demonstrated where it matters > §3’s worked example: the brace/traversal near-miss [0.02ms] +(pass) T065 — first-match-wins, demonstrated where it matters > a leading slash reports rule 2, not rule 12’s empty segment +(pass) T065 — first-match-wins, demonstrated where it matters > a drive prefix reports rule 3, not rule 4’s backslash or rule 13 +(pass) T065 — first-match-wins, demonstrated where it matters > a UNC path reports rule 3, not rule 4 [0.01ms] +(pass) T065 — first-match-wins, demonstrated where it matters > a brace with a comma reports rule 6, not rule 9 +(pass) T065 — first-match-wins, demonstrated where it matters > a leading bang on an otherwise disallowed pattern reports rule 10, not 13 +(pass) T065 — first-match-wins, demonstrated where it matters > a pattern violating several rules reports the same one every time [0.15ms] +(pass) T065 — rule 13 closes the gap a blacklist cannot (§3) > "@" violates none of rules 1–12 and is caught by rule 13 [0.03ms] +(pass) T065 — rule 13 closes the gap a blacklist cannot (§3) > "#" violates none of rules 1–12 and is caught by rule 13 [0.01ms] +(pass) T065 — rule 13 closes the gap a blacklist cannot (§3) > "%" violates none of rules 1–12 and is caught by rule 13 +(pass) T065 — rule 13 closes the gap a blacklist cannot (§3) > "~" violates none of rules 1–12 and is caught by rule 13 +(pass) T065 — rule 13 closes the gap a blacklist cannot (§3) > "+" violates none of rules 1–12 and is caught by rule 13 +(pass) T065 — rule 13 closes the gap a blacklist cannot (§3) > "=" violates none of rules 1–12 and is caught by rule 13 [0.05ms] +(pass) T065 — rule 13 closes the gap a blacklist cannot (§3) > ":" violates none of rules 1–12 and is caught by rule 13 [0.03ms] +(pass) T065 — rule 13 closes the gap a blacklist cannot (§3) > ";" violates none of rules 1–12 and is caught by rule 13 [0.02ms] +(pass) T065 — rule 13 closes the gap a blacklist cannot (§3) > "<" violates none of rules 1–12 and is caught by rule 13 +(pass) T065 — rule 13 closes the gap a blacklist cannot (§3) > ">" violates none of rules 1–12 and is caught by rule 13 +(pass) T065 — rule 13 closes the gap a blacklist cannot (§3) > "|" violates none of rules 1–12 and is caught by rule 13 +(pass) T065 — rule 13 closes the gap a blacklist cannot (§3) > "&" violates none of rules 1–12 and is caught by rule 13 +(pass) T065 — rule 13 closes the gap a blacklist cannot (§3) > "^" violates none of rules 1–12 and is caught by rule 13 +(pass) T065 — rule 13 closes the gap a blacklist cannot (§3) > "é" violates none of rules 1–12 and is caught by rule 13 +(pass) T065 — rule 13 closes the gap a blacklist cannot (§3) > "中" violates none of rules 1–12 and is caught by rule 13 [0.02ms] +(pass) T065 — rule 13 closes the gap a blacklist cannot (§3) > a colon not at position 2 is rule 13, not rule 3’s drive prefix [0.02ms] +(pass) T065 — rule 14: only a whole-segment `**` is allowed > "a**b" is `malformed-double-star` [0.01ms] +(pass) T065 — rule 14: only a whole-segment `**` is allowed > "**b" is `malformed-double-star` +(pass) T065 — rule 14: only a whole-segment `**` is allowed > "a**" is `malformed-double-star` +(pass) T065 — rule 14: only a whole-segment `**` is allowed > "foo/**bar" is `malformed-double-star` [0.02ms] +(pass) T065 — rule 14: only a whole-segment `**` is allowed > "foo/a**/b" is `malformed-double-star` [0.01ms] +(pass) T065 — rule 14: only a whole-segment `**` is allowed > a segment that is exactly `**` is the allowed form [1.14ms] +(pass) T065 — rule 14: only a whole-segment `**` is allowed > a single `*` is unaffected [0.05ms] +(pass) T065 — rule 15’s `accepted` outcome is exercised; its rejection is not required > "packages/payments/**" is accepted at rule 15 [0.04ms] +(pass) T065 — rule 15’s `accepted` outcome is exercised; its rejection is not required > "packages/**" is accepted at rule 15 [0.02ms] +(pass) T065 — rule 15’s `accepted` outcome is exercised; its rejection is not required > "**" is accepted at rule 15 [0.01ms] +(pass) T065 — rule 15’s `accepted` outcome is exercised; its rejection is not required > "docs/*.md" is accepted at rule 15 [0.02ms] +(pass) T065 — rule 15’s `accepted` outcome is exercised; its rejection is not required > "src/a?c.ts" is accepted at rule 15 [0.03ms] +(pass) T065 — rule 15’s `accepted` outcome is exercised; its rejection is not required > ".github/**" is accepted at rule 15 [0.02ms] +(pass) T065 — rule 15’s `accepted` outcome is exercised; its rejection is not required > "a-b_c.d/**" is accepted at rule 15 [0.02ms] +(pass) T065 — rule 15’s `accepted` outcome is exercised; its rejection is not required > rule 15 not firing its rejection is conformant, and is not a coverage gap [0.04ms] +(pass) T065 — rule 15’s `accepted` outcome is exercised; its rejection is not required > the compile really is invoked, so `accepted` is a compile result [0.06ms] +(pass) T065 — rule 15’s `accepted` outcome is exercised; its rejection is not required > a rejected pattern never reaches the compile [0.01ms] + +2 tests failed: +(fail) T065 — each of rules 1–14, observed firing > rule 1 on "" [0.30ms] +(fail) T065 — each of rules 1–14, observed firing > rule 1’s fixture "" violates no earlier rule [0.08ms] + + 70 pass + 2 fail + 153 expect() calls +Ran 72 tests across 1 file. [13.00ms] + +exit 1 diff --git a/specs/010-catalog-backstage/evidence/negative-cases/glob-rules/case-1-rule-1-empty.patch b/specs/010-catalog-backstage/evidence/negative-cases/glob-rules/case-1-rule-1-empty.patch new file mode 100644 index 00000000..a9c0d54b --- /dev/null +++ b/specs/010-catalog-backstage/evidence/negative-cases/glob-rules/case-1-rule-1-empty.patch @@ -0,0 +1,13 @@ +diff --git a/packages/adapters/catalog-backstage/src/glob/validate.ts b/packages/adapters/catalog-backstage/src/glob/validate.ts +index 67f337f..53eda4c 100644 +--- a/packages/adapters/catalog-backstage/src/glob/validate.ts ++++ b/packages/adapters/catalog-backstage/src/glob/validate.ts +@@ -79,7 +79,7 @@ const RULES: readonly { + readonly outcome: Exclude; + readonly violates: (pattern: string) => boolean; + }[] = [ +- { rule: 1, outcome: 'empty', violates: (p) => p === '' }, ++ { rule: 1, outcome: 'empty', violates: () => false }, + { rule: 2, outcome: 'leading-slash', violates: (p) => p.startsWith('/') }, + { + rule: 3, diff --git a/specs/010-catalog-backstage/evidence/negative-cases/glob-rules/case-2-rule-13-disallowed-character.observed.txt b/specs/010-catalog-backstage/evidence/negative-cases/glob-rules/case-2-rule-13-disallowed-character.observed.txt new file mode 100644 index 00000000..ba21e849 --- /dev/null +++ b/specs/010-catalog-backstage/evidence/negative-cases/glob-rules/case-2-rule-13-disallowed-character.observed.txt @@ -0,0 +1,336 @@ +bun test v1.3.14 (0d9b296a) + +test/glob-rules.test.ts: +(pass) T065 — the rule set > there are fifteen rules [0.02ms] +(pass) T065 — the rule set > fourteen of them require exercise [0.02ms] +(pass) T065 — the rule set > a fixture exists for every rule requiring exercise, and for no other [0.04ms] +(pass) T065 — the rule set > the fourteen outcomes are fourteen distinct values [0.04ms] +(pass) T065 — each of rules 1–14, observed firing > rule 1 on "" [0.08ms] +(pass) T065 — each of rules 1–14, observed firing > rule 2 on "/packages/**" [0.01ms] +(pass) T065 — each of rules 1–14, observed firing > rule 3 on "C:/packages/**" [0.04ms] +(pass) T065 — each of rules 1–14, observed firing > rule 4 on "packages\\payments" +(pass) T065 — each of rules 1–14, observed firing > rule 5 on "packages/\u0000/payments" [0.04ms] +(pass) T065 — each of rules 1–14, observed firing > rule 6 on "packages/{a}/**" [0.02ms] +(pass) T065 — each of rules 1–14, observed firing > rule 7 on "packages/[ab]/**" [0.03ms] +(pass) T065 — each of rules 1–14, observed firing > rule 8 on "packages/(a)/**" [0.02ms] +(pass) T065 — each of rules 1–14, observed firing > rule 9 on "packages/a,b/**" [0.02ms] +(pass) T065 — each of rules 1–14, observed firing > rule 10 on "!packages/**" +(pass) T065 — each of rules 1–14, observed firing > rule 11 on "packages/../etc" [0.04ms] +(pass) T065 — each of rules 1–14, observed firing > rule 12 on "packages//payments" [0.04ms] +92 | describe('T065 — each of rules 1\u201314, observed firing', () => { +93 | test.each(RULE_FIXTURES.map((fixture) => [fixture.rule, fixture.pattern, fixture] as const))( +94 | 'rule %d on %j', +95 | (_rule, _pattern, fixture) => { +96 | const result = validateGlobPattern(fixture.pattern); +97 | expect(result.outcome).toBe(fixture.outcome); + ^ +error: expect(received).toBe(expected) + +Expected: "disallowed-character" +Received: "accepted" + + at (/Users/markbeacom/github/mbeacom/copilot-worktrees/adrkit/mbeacom-symmetrical-disco/packages/adapters/catalog-backstage/test/glob-rules.test.ts:97:30) +(fail) T065 — each of rules 1–14, observed firing > rule 13 on "packages/@scope/**" [1.18ms] +(pass) T065 — each of rules 1–14, observed firing > rule 14 on "packages/**bar" [0.02ms] +(pass) T065 — each of rules 1–14, observed firing > rule 1’s fixture "" violates no earlier rule [0.02ms] +(pass) T065 — each of rules 1–14, observed firing > rule 2’s fixture "/packages/**" violates no earlier rule +(pass) T065 — each of rules 1–14, observed firing > rule 3’s fixture "C:/packages/**" violates no earlier rule +(pass) T065 — each of rules 1–14, observed firing > rule 4’s fixture "packages\\payments" violates no earlier rule +(pass) T065 — each of rules 1–14, observed firing > rule 5’s fixture "packages/\u0000/payments" violates no earlier rule +(pass) T065 — each of rules 1–14, observed firing > rule 6’s fixture "packages/{a}/**" violates no earlier rule +(pass) T065 — each of rules 1–14, observed firing > rule 7’s fixture "packages/[ab]/**" violates no earlier rule +(pass) T065 — each of rules 1–14, observed firing > rule 8’s fixture "packages/(a)/**" violates no earlier rule +(pass) T065 — each of rules 1–14, observed firing > rule 9’s fixture "packages/a,b/**" violates no earlier rule +(pass) T065 — each of rules 1–14, observed firing > rule 10’s fixture "!packages/**" violates no earlier rule +(pass) T065 — each of rules 1–14, observed firing > rule 11’s fixture "packages/../etc" violates no earlier rule +(pass) T065 — each of rules 1–14, observed firing > rule 12’s fixture "packages//payments" violates no earlier rule +105 | (_rule, _pattern, fixture) => { +106 | // If it did, the reported reason would be the earlier rule's, and this fixture +107 | // would be exercising a rule it was not chosen for. +108 | const result = validateGlobPattern(fixture.pattern); +109 | expect(result.rule).not.toBeLessThan(fixture.rule); +110 | expect(result.rule).toBe(fixture.rule); + ^ +error: expect(received).toBe(expected) + +Expected: 13 +Received: 15 + + at (/Users/markbeacom/github/mbeacom/copilot-worktrees/adrkit/mbeacom-symmetrical-disco/packages/adapters/catalog-backstage/test/glob-rules.test.ts:110:27) +(fail) T065 — each of rules 1–14, observed firing > rule 13’s fixture "packages/@scope/**" violates no earlier rule [0.09ms] +(pass) T065 — each of rules 1–14, observed firing > rule 14’s fixture "packages/**bar" violates no earlier rule [0.02ms] +(pass) T065 — first-match-wins, demonstrated where it matters > §3’s worked example: the brace/traversal near-miss [0.03ms] +(pass) T065 — first-match-wins, demonstrated where it matters > a leading slash reports rule 2, not rule 12’s empty segment +(pass) T065 — first-match-wins, demonstrated where it matters > a drive prefix reports rule 3, not rule 4’s backslash or rule 13 +(pass) T065 — first-match-wins, demonstrated where it matters > a UNC path reports rule 3, not rule 4 [0.01ms] +(pass) T065 — first-match-wins, demonstrated where it matters > a brace with a comma reports rule 6, not rule 9 +(pass) T065 — first-match-wins, demonstrated where it matters > a leading bang on an otherwise disallowed pattern reports rule 10, not 13 +(pass) T065 — first-match-wins, demonstrated where it matters > a pattern violating several rules reports the same one every time [0.10ms] +164 | describe('T065 — rule 13 closes the gap a blacklist cannot (\u00a73)', () => { +165 | test.each(['@', '#', '%', '~', '+', '=', ':', ';', '<', '>', '|', '&', '^', '\u00e9', '\u4e2d'])( +166 | '%j violates none of rules 1\u201312 and is caught by rule 13', +167 | (character) => { +168 | const result = validateGlobPattern(`packages/a${character}b/**`); +169 | expect(result.outcome).toBe('disallowed-character'); + ^ +error: expect(received).toBe(expected) + +Expected: "disallowed-character" +Received: "accepted" + + at (/Users/markbeacom/github/mbeacom/copilot-worktrees/adrkit/mbeacom-symmetrical-disco/packages/adapters/catalog-backstage/test/glob-rules.test.ts:169:30) +(fail) T065 — rule 13 closes the gap a blacklist cannot (§3) > "@" violates none of rules 1–12 and is caught by rule 13 [0.11ms] +164 | describe('T065 — rule 13 closes the gap a blacklist cannot (\u00a73)', () => { +165 | test.each(['@', '#', '%', '~', '+', '=', ':', ';', '<', '>', '|', '&', '^', '\u00e9', '\u4e2d'])( +166 | '%j violates none of rules 1\u201312 and is caught by rule 13', +167 | (character) => { +168 | const result = validateGlobPattern(`packages/a${character}b/**`); +169 | expect(result.outcome).toBe('disallowed-character'); + ^ +error: expect(received).toBe(expected) + +Expected: "disallowed-character" +Received: "accepted" + + at (/Users/markbeacom/github/mbeacom/copilot-worktrees/adrkit/mbeacom-symmetrical-disco/packages/adapters/catalog-backstage/test/glob-rules.test.ts:169:30) +(fail) T065 — rule 13 closes the gap a blacklist cannot (§3) > "#" violates none of rules 1–12 and is caught by rule 13 [0.07ms] +164 | describe('T065 — rule 13 closes the gap a blacklist cannot (\u00a73)', () => { +165 | test.each(['@', '#', '%', '~', '+', '=', ':', ';', '<', '>', '|', '&', '^', '\u00e9', '\u4e2d'])( +166 | '%j violates none of rules 1\u201312 and is caught by rule 13', +167 | (character) => { +168 | const result = validateGlobPattern(`packages/a${character}b/**`); +169 | expect(result.outcome).toBe('disallowed-character'); + ^ +error: expect(received).toBe(expected) + +Expected: "disallowed-character" +Received: "accepted" + + at (/Users/markbeacom/github/mbeacom/copilot-worktrees/adrkit/mbeacom-symmetrical-disco/packages/adapters/catalog-backstage/test/glob-rules.test.ts:169:30) +(fail) T065 — rule 13 closes the gap a blacklist cannot (§3) > "%" violates none of rules 1–12 and is caught by rule 13 [0.06ms] +164 | describe('T065 — rule 13 closes the gap a blacklist cannot (\u00a73)', () => { +165 | test.each(['@', '#', '%', '~', '+', '=', ':', ';', '<', '>', '|', '&', '^', '\u00e9', '\u4e2d'])( +166 | '%j violates none of rules 1\u201312 and is caught by rule 13', +167 | (character) => { +168 | const result = validateGlobPattern(`packages/a${character}b/**`); +169 | expect(result.outcome).toBe('disallowed-character'); + ^ +error: expect(received).toBe(expected) + +Expected: "disallowed-character" +Received: "accepted" + + at (/Users/markbeacom/github/mbeacom/copilot-worktrees/adrkit/mbeacom-symmetrical-disco/packages/adapters/catalog-backstage/test/glob-rules.test.ts:169:30) +(fail) T065 — rule 13 closes the gap a blacklist cannot (§3) > "~" violates none of rules 1–12 and is caught by rule 13 [0.07ms] +164 | describe('T065 — rule 13 closes the gap a blacklist cannot (\u00a73)', () => { +165 | test.each(['@', '#', '%', '~', '+', '=', ':', ';', '<', '>', '|', '&', '^', '\u00e9', '\u4e2d'])( +166 | '%j violates none of rules 1\u201312 and is caught by rule 13', +167 | (character) => { +168 | const result = validateGlobPattern(`packages/a${character}b/**`); +169 | expect(result.outcome).toBe('disallowed-character'); + ^ +error: expect(received).toBe(expected) + +Expected: "disallowed-character" +Received: "accepted" + + at (/Users/markbeacom/github/mbeacom/copilot-worktrees/adrkit/mbeacom-symmetrical-disco/packages/adapters/catalog-backstage/test/glob-rules.test.ts:169:30) +(fail) T065 — rule 13 closes the gap a blacklist cannot (§3) > "+" violates none of rules 1–12 and is caught by rule 13 [0.05ms] +164 | describe('T065 — rule 13 closes the gap a blacklist cannot (\u00a73)', () => { +165 | test.each(['@', '#', '%', '~', '+', '=', ':', ';', '<', '>', '|', '&', '^', '\u00e9', '\u4e2d'])( +166 | '%j violates none of rules 1\u201312 and is caught by rule 13', +167 | (character) => { +168 | const result = validateGlobPattern(`packages/a${character}b/**`); +169 | expect(result.outcome).toBe('disallowed-character'); + ^ +error: expect(received).toBe(expected) + +Expected: "disallowed-character" +Received: "accepted" + + at (/Users/markbeacom/github/mbeacom/copilot-worktrees/adrkit/mbeacom-symmetrical-disco/packages/adapters/catalog-backstage/test/glob-rules.test.ts:169:30) +(fail) T065 — rule 13 closes the gap a blacklist cannot (§3) > "=" violates none of rules 1–12 and is caught by rule 13 [0.05ms] +164 | describe('T065 — rule 13 closes the gap a blacklist cannot (\u00a73)', () => { +165 | test.each(['@', '#', '%', '~', '+', '=', ':', ';', '<', '>', '|', '&', '^', '\u00e9', '\u4e2d'])( +166 | '%j violates none of rules 1\u201312 and is caught by rule 13', +167 | (character) => { +168 | const result = validateGlobPattern(`packages/a${character}b/**`); +169 | expect(result.outcome).toBe('disallowed-character'); + ^ +error: expect(received).toBe(expected) + +Expected: "disallowed-character" +Received: "accepted" + + at (/Users/markbeacom/github/mbeacom/copilot-worktrees/adrkit/mbeacom-symmetrical-disco/packages/adapters/catalog-backstage/test/glob-rules.test.ts:169:30) +(fail) T065 — rule 13 closes the gap a blacklist cannot (§3) > ":" violates none of rules 1–12 and is caught by rule 13 [0.05ms] +164 | describe('T065 — rule 13 closes the gap a blacklist cannot (\u00a73)', () => { +165 | test.each(['@', '#', '%', '~', '+', '=', ':', ';', '<', '>', '|', '&', '^', '\u00e9', '\u4e2d'])( +166 | '%j violates none of rules 1\u201312 and is caught by rule 13', +167 | (character) => { +168 | const result = validateGlobPattern(`packages/a${character}b/**`); +169 | expect(result.outcome).toBe('disallowed-character'); + ^ +error: expect(received).toBe(expected) + +Expected: "disallowed-character" +Received: "accepted" + + at (/Users/markbeacom/github/mbeacom/copilot-worktrees/adrkit/mbeacom-symmetrical-disco/packages/adapters/catalog-backstage/test/glob-rules.test.ts:169:30) +(fail) T065 — rule 13 closes the gap a blacklist cannot (§3) > ";" violates none of rules 1–12 and is caught by rule 13 [0.04ms] +164 | describe('T065 — rule 13 closes the gap a blacklist cannot (\u00a73)', () => { +165 | test.each(['@', '#', '%', '~', '+', '=', ':', ';', '<', '>', '|', '&', '^', '\u00e9', '\u4e2d'])( +166 | '%j violates none of rules 1\u201312 and is caught by rule 13', +167 | (character) => { +168 | const result = validateGlobPattern(`packages/a${character}b/**`); +169 | expect(result.outcome).toBe('disallowed-character'); + ^ +error: expect(received).toBe(expected) + +Expected: "disallowed-character" +Received: "accepted" + + at (/Users/markbeacom/github/mbeacom/copilot-worktrees/adrkit/mbeacom-symmetrical-disco/packages/adapters/catalog-backstage/test/glob-rules.test.ts:169:30) +(fail) T065 — rule 13 closes the gap a blacklist cannot (§3) > "<" violates none of rules 1–12 and is caught by rule 13 [0.04ms] +164 | describe('T065 — rule 13 closes the gap a blacklist cannot (\u00a73)', () => { +165 | test.each(['@', '#', '%', '~', '+', '=', ':', ';', '<', '>', '|', '&', '^', '\u00e9', '\u4e2d'])( +166 | '%j violates none of rules 1\u201312 and is caught by rule 13', +167 | (character) => { +168 | const result = validateGlobPattern(`packages/a${character}b/**`); +169 | expect(result.outcome).toBe('disallowed-character'); + ^ +error: expect(received).toBe(expected) + +Expected: "disallowed-character" +Received: "accepted" + + at (/Users/markbeacom/github/mbeacom/copilot-worktrees/adrkit/mbeacom-symmetrical-disco/packages/adapters/catalog-backstage/test/glob-rules.test.ts:169:30) +(fail) T065 — rule 13 closes the gap a blacklist cannot (§3) > ">" violates none of rules 1–12 and is caught by rule 13 [0.04ms] +164 | describe('T065 — rule 13 closes the gap a blacklist cannot (\u00a73)', () => { +165 | test.each(['@', '#', '%', '~', '+', '=', ':', ';', '<', '>', '|', '&', '^', '\u00e9', '\u4e2d'])( +166 | '%j violates none of rules 1\u201312 and is caught by rule 13', +167 | (character) => { +168 | const result = validateGlobPattern(`packages/a${character}b/**`); +169 | expect(result.outcome).toBe('disallowed-character'); + ^ +error: expect(received).toBe(expected) + +Expected: "disallowed-character" +Received: "accepted" + + at (/Users/markbeacom/github/mbeacom/copilot-worktrees/adrkit/mbeacom-symmetrical-disco/packages/adapters/catalog-backstage/test/glob-rules.test.ts:169:30) +(fail) T065 — rule 13 closes the gap a blacklist cannot (§3) > "|" violates none of rules 1–12 and is caught by rule 13 [0.04ms] +164 | describe('T065 — rule 13 closes the gap a blacklist cannot (\u00a73)', () => { +165 | test.each(['@', '#', '%', '~', '+', '=', ':', ';', '<', '>', '|', '&', '^', '\u00e9', '\u4e2d'])( +166 | '%j violates none of rules 1\u201312 and is caught by rule 13', +167 | (character) => { +168 | const result = validateGlobPattern(`packages/a${character}b/**`); +169 | expect(result.outcome).toBe('disallowed-character'); + ^ +error: expect(received).toBe(expected) + +Expected: "disallowed-character" +Received: "accepted" + + at (/Users/markbeacom/github/mbeacom/copilot-worktrees/adrkit/mbeacom-symmetrical-disco/packages/adapters/catalog-backstage/test/glob-rules.test.ts:169:30) +(fail) T065 — rule 13 closes the gap a blacklist cannot (§3) > "&" violates none of rules 1–12 and is caught by rule 13 [0.04ms] +164 | describe('T065 — rule 13 closes the gap a blacklist cannot (\u00a73)', () => { +165 | test.each(['@', '#', '%', '~', '+', '=', ':', ';', '<', '>', '|', '&', '^', '\u00e9', '\u4e2d'])( +166 | '%j violates none of rules 1\u201312 and is caught by rule 13', +167 | (character) => { +168 | const result = validateGlobPattern(`packages/a${character}b/**`); +169 | expect(result.outcome).toBe('disallowed-character'); + ^ +error: expect(received).toBe(expected) + +Expected: "disallowed-character" +Received: "accepted" + + at (/Users/markbeacom/github/mbeacom/copilot-worktrees/adrkit/mbeacom-symmetrical-disco/packages/adapters/catalog-backstage/test/glob-rules.test.ts:169:30) +(fail) T065 — rule 13 closes the gap a blacklist cannot (§3) > "^" violates none of rules 1–12 and is caught by rule 13 [0.04ms] +164 | describe('T065 — rule 13 closes the gap a blacklist cannot (\u00a73)', () => { +165 | test.each(['@', '#', '%', '~', '+', '=', ':', ';', '<', '>', '|', '&', '^', '\u00e9', '\u4e2d'])( +166 | '%j violates none of rules 1\u201312 and is caught by rule 13', +167 | (character) => { +168 | const result = validateGlobPattern(`packages/a${character}b/**`); +169 | expect(result.outcome).toBe('disallowed-character'); + ^ +error: expect(received).toBe(expected) + +Expected: "disallowed-character" +Received: "accepted" + + at (/Users/markbeacom/github/mbeacom/copilot-worktrees/adrkit/mbeacom-symmetrical-disco/packages/adapters/catalog-backstage/test/glob-rules.test.ts:169:30) +(fail) T065 — rule 13 closes the gap a blacklist cannot (§3) > "é" violates none of rules 1–12 and is caught by rule 13 [0.06ms] +164 | describe('T065 — rule 13 closes the gap a blacklist cannot (\u00a73)', () => { +165 | test.each(['@', '#', '%', '~', '+', '=', ':', ';', '<', '>', '|', '&', '^', '\u00e9', '\u4e2d'])( +166 | '%j violates none of rules 1\u201312 and is caught by rule 13', +167 | (character) => { +168 | const result = validateGlobPattern(`packages/a${character}b/**`); +169 | expect(result.outcome).toBe('disallowed-character'); + ^ +error: expect(received).toBe(expected) + +Expected: "disallowed-character" +Received: "accepted" + + at (/Users/markbeacom/github/mbeacom/copilot-worktrees/adrkit/mbeacom-symmetrical-disco/packages/adapters/catalog-backstage/test/glob-rules.test.ts:169:30) +(fail) T065 — rule 13 closes the gap a blacklist cannot (§3) > "中" violates none of rules 1–12 and is caught by rule 13 [0.12ms] +171 | }, +172 | ); +173 | +174 | test('a colon not at position 2 is rule 13, not rule 3\u2019s drive prefix', () => { +175 | // Rule 3's regex is anchored: `^[A-Za-z]:`. A colon elsewhere is rule 13. +176 | expect(validateGlobPattern('packages/a:b').rule).toBe(13); + ^ +error: expect(received).toBe(expected) + +Expected: 13 +Received: 15 + + at (/Users/markbeacom/github/mbeacom/copilot-worktrees/adrkit/mbeacom-symmetrical-disco/packages/adapters/catalog-backstage/test/glob-rules.test.ts:176:54) +(fail) T065 — rule 13 closes the gap a blacklist cannot (§3) > a colon not at position 2 is rule 13, not rule 3’s drive prefix [0.06ms] +(pass) T065 — rule 14: only a whole-segment `**` is allowed > "a**b" is `malformed-double-star` [0.02ms] +(pass) T065 — rule 14: only a whole-segment `**` is allowed > "**b" is `malformed-double-star` [0.05ms] +(pass) T065 — rule 14: only a whole-segment `**` is allowed > "a**" is `malformed-double-star` +(pass) T065 — rule 14: only a whole-segment `**` is allowed > "foo/**bar" is `malformed-double-star` +(pass) T065 — rule 14: only a whole-segment `**` is allowed > "foo/a**/b" is `malformed-double-star` +(pass) T065 — rule 14: only a whole-segment `**` is allowed > a segment that is exactly `**` is the allowed form [0.24ms] +(pass) T065 — rule 14: only a whole-segment `**` is allowed > a single `*` is unaffected [0.05ms] +(pass) T065 — rule 15’s `accepted` outcome is exercised; its rejection is not required > "packages/payments/**" is accepted at rule 15 [0.03ms] +(pass) T065 — rule 15’s `accepted` outcome is exercised; its rejection is not required > "packages/**" is accepted at rule 15 [0.04ms] +(pass) T065 — rule 15’s `accepted` outcome is exercised; its rejection is not required > "**" is accepted at rule 15 +(pass) T065 — rule 15’s `accepted` outcome is exercised; its rejection is not required > "docs/*.md" is accepted at rule 15 [0.03ms] +(pass) T065 — rule 15’s `accepted` outcome is exercised; its rejection is not required > "src/a?c.ts" is accepted at rule 15 [0.06ms] +(pass) T065 — rule 15’s `accepted` outcome is exercised; its rejection is not required > ".github/**" is accepted at rule 15 [0.04ms] +(pass) T065 — rule 15’s `accepted` outcome is exercised; its rejection is not required > "a-b_c.d/**" is accepted at rule 15 [0.03ms] +(pass) T065 — rule 15’s `accepted` outcome is exercised; its rejection is not required > rule 15 not firing its rejection is conformant, and is not a coverage gap [0.03ms] +(pass) T065 — rule 15’s `accepted` outcome is exercised; its rejection is not required > the compile really is invoked, so `accepted` is a compile result [0.05ms] +(pass) T065 — rule 15’s `accepted` outcome is exercised; its rejection is not required > a rejected pattern never reaches the compile [0.01ms] + +18 tests failed: +(fail) T065 — each of rules 1–14, observed firing > rule 13 on "packages/@scope/**" [1.18ms] +(fail) T065 — each of rules 1–14, observed firing > rule 13’s fixture "packages/@scope/**" violates no earlier rule [0.09ms] +(fail) T065 — rule 13 closes the gap a blacklist cannot (§3) > "@" violates none of rules 1–12 and is caught by rule 13 [0.11ms] +(fail) T065 — rule 13 closes the gap a blacklist cannot (§3) > "#" violates none of rules 1–12 and is caught by rule 13 [0.07ms] +(fail) T065 — rule 13 closes the gap a blacklist cannot (§3) > "%" violates none of rules 1–12 and is caught by rule 13 [0.06ms] +(fail) T065 — rule 13 closes the gap a blacklist cannot (§3) > "~" violates none of rules 1–12 and is caught by rule 13 [0.07ms] +(fail) T065 — rule 13 closes the gap a blacklist cannot (§3) > "+" violates none of rules 1–12 and is caught by rule 13 [0.05ms] +(fail) T065 — rule 13 closes the gap a blacklist cannot (§3) > "=" violates none of rules 1–12 and is caught by rule 13 [0.05ms] +(fail) T065 — rule 13 closes the gap a blacklist cannot (§3) > ":" violates none of rules 1–12 and is caught by rule 13 [0.05ms] +(fail) T065 — rule 13 closes the gap a blacklist cannot (§3) > ";" violates none of rules 1–12 and is caught by rule 13 [0.04ms] +(fail) T065 — rule 13 closes the gap a blacklist cannot (§3) > "<" violates none of rules 1–12 and is caught by rule 13 [0.04ms] +(fail) T065 — rule 13 closes the gap a blacklist cannot (§3) > ">" violates none of rules 1–12 and is caught by rule 13 [0.04ms] +(fail) T065 — rule 13 closes the gap a blacklist cannot (§3) > "|" violates none of rules 1–12 and is caught by rule 13 [0.04ms] +(fail) T065 — rule 13 closes the gap a blacklist cannot (§3) > "&" violates none of rules 1–12 and is caught by rule 13 [0.04ms] +(fail) T065 — rule 13 closes the gap a blacklist cannot (§3) > "^" violates none of rules 1–12 and is caught by rule 13 [0.04ms] +(fail) T065 — rule 13 closes the gap a blacklist cannot (§3) > "é" violates none of rules 1–12 and is caught by rule 13 [0.06ms] +(fail) T065 — rule 13 closes the gap a blacklist cannot (§3) > "中" violates none of rules 1–12 and is caught by rule 13 [0.12ms] +(fail) T065 — rule 13 closes the gap a blacklist cannot (§3) > a colon not at position 2 is rule 13, not rule 3’s drive prefix [0.06ms] + + 54 pass + 18 fail + 137 expect() calls +Ran 72 tests across 1 file. [14.00ms] + +exit 1 diff --git a/specs/010-catalog-backstage/evidence/negative-cases/glob-rules/case-2-rule-13-disallowed-character.patch b/specs/010-catalog-backstage/evidence/negative-cases/glob-rules/case-2-rule-13-disallowed-character.patch new file mode 100644 index 00000000..bc77cb02 --- /dev/null +++ b/specs/010-catalog-backstage/evidence/negative-cases/glob-rules/case-2-rule-13-disallowed-character.patch @@ -0,0 +1,13 @@ +diff --git a/packages/adapters/catalog-backstage/src/glob/validate.ts b/packages/adapters/catalog-backstage/src/glob/validate.ts +index 67f337f..e54de03 100644 +--- a/packages/adapters/catalog-backstage/src/glob/validate.ts ++++ b/packages/adapters/catalog-backstage/src/glob/validate.ts +@@ -116,7 +116,7 @@ const RULES: readonly { + { + rule: 13, + outcome: 'disallowed-character', +- violates: (p) => [...p].some((character) => !ALLOWED_CHARACTER.test(character)), ++ violates: () => false, + }, + { + rule: 14, diff --git a/specs/010-catalog-backstage/evidence/negative-cases/glob-rules/case-3-rule-14-malformed-double-star.observed.txt b/specs/010-catalog-backstage/evidence/negative-cases/glob-rules/case-3-rule-14-malformed-double-star.observed.txt new file mode 100644 index 00000000..278d45ca --- /dev/null +++ b/specs/010-catalog-backstage/evidence/negative-cases/glob-rules/case-3-rule-14-malformed-double-star.observed.txt @@ -0,0 +1,182 @@ +bun test v1.3.14 (0d9b296a) + +test/glob-rules.test.ts: +(pass) T065 — the rule set > there are fifteen rules [0.01ms] +(pass) T065 — the rule set > fourteen of them require exercise [0.01ms] +(pass) T065 — the rule set > a fixture exists for every rule requiring exercise, and for no other [0.01ms] +(pass) T065 — the rule set > the fourteen outcomes are fourteen distinct values [0.02ms] +(pass) T065 — each of rules 1–14, observed firing > rule 1 on "" [0.05ms] +(pass) T065 — each of rules 1–14, observed firing > rule 2 on "/packages/**" +(pass) T065 — each of rules 1–14, observed firing > rule 3 on "C:/packages/**" [0.03ms] +(pass) T065 — each of rules 1–14, observed firing > rule 4 on "packages\\payments" +(pass) T065 — each of rules 1–14, observed firing > rule 5 on "packages/\u0000/payments" [0.03ms] +(pass) T065 — each of rules 1–14, observed firing > rule 6 on "packages/{a}/**" +(pass) T065 — each of rules 1–14, observed firing > rule 7 on "packages/[ab]/**" [0.01ms] +(pass) T065 — each of rules 1–14, observed firing > rule 8 on "packages/(a)/**" +(pass) T065 — each of rules 1–14, observed firing > rule 9 on "packages/a,b/**" +(pass) T065 — each of rules 1–14, observed firing > rule 10 on "!packages/**" [0.02ms] +(pass) T065 — each of rules 1–14, observed firing > rule 11 on "packages/../etc" [0.02ms] +(pass) T065 — each of rules 1–14, observed firing > rule 12 on "packages//payments" [0.01ms] +(pass) T065 — each of rules 1–14, observed firing > rule 13 on "packages/@scope/**" [0.02ms] +92 | describe('T065 — each of rules 1\u201314, observed firing', () => { +93 | test.each(RULE_FIXTURES.map((fixture) => [fixture.rule, fixture.pattern, fixture] as const))( +94 | 'rule %d on %j', +95 | (_rule, _pattern, fixture) => { +96 | const result = validateGlobPattern(fixture.pattern); +97 | expect(result.outcome).toBe(fixture.outcome); + ^ +error: expect(received).toBe(expected) + +Expected: "malformed-double-star" +Received: "accepted" + + at (/Users/markbeacom/github/mbeacom/copilot-worktrees/adrkit/mbeacom-symmetrical-disco/packages/adapters/catalog-backstage/test/glob-rules.test.ts:97:30) +(fail) T065 — each of rules 1–14, observed firing > rule 14 on "packages/**bar" [0.85ms] +(pass) T065 — each of rules 1–14, observed firing > rule 1’s fixture "" violates no earlier rule [0.01ms] +(pass) T065 — each of rules 1–14, observed firing > rule 2’s fixture "/packages/**" violates no earlier rule +(pass) T065 — each of rules 1–14, observed firing > rule 3’s fixture "C:/packages/**" violates no earlier rule +(pass) T065 — each of rules 1–14, observed firing > rule 4’s fixture "packages\\payments" violates no earlier rule +(pass) T065 — each of rules 1–14, observed firing > rule 5’s fixture "packages/\u0000/payments" violates no earlier rule +(pass) T065 — each of rules 1–14, observed firing > rule 6’s fixture "packages/{a}/**" violates no earlier rule +(pass) T065 — each of rules 1–14, observed firing > rule 7’s fixture "packages/[ab]/**" violates no earlier rule +(pass) T065 — each of rules 1–14, observed firing > rule 8’s fixture "packages/(a)/**" violates no earlier rule +(pass) T065 — each of rules 1–14, observed firing > rule 9’s fixture "packages/a,b/**" violates no earlier rule +(pass) T065 — each of rules 1–14, observed firing > rule 10’s fixture "!packages/**" violates no earlier rule +(pass) T065 — each of rules 1–14, observed firing > rule 11’s fixture "packages/../etc" violates no earlier rule +(pass) T065 — each of rules 1–14, observed firing > rule 12’s fixture "packages//payments" violates no earlier rule +(pass) T065 — each of rules 1–14, observed firing > rule 13’s fixture "packages/@scope/**" violates no earlier rule +105 | (_rule, _pattern, fixture) => { +106 | // If it did, the reported reason would be the earlier rule's, and this fixture +107 | // would be exercising a rule it was not chosen for. +108 | const result = validateGlobPattern(fixture.pattern); +109 | expect(result.rule).not.toBeLessThan(fixture.rule); +110 | expect(result.rule).toBe(fixture.rule); + ^ +error: expect(received).toBe(expected) + +Expected: 14 +Received: 15 + + at (/Users/markbeacom/github/mbeacom/copilot-worktrees/adrkit/mbeacom-symmetrical-disco/packages/adapters/catalog-backstage/test/glob-rules.test.ts:110:27) +(fail) T065 — each of rules 1–14, observed firing > rule 14’s fixture "packages/**bar" violates no earlier rule [0.07ms] +(pass) T065 — first-match-wins, demonstrated where it matters > §3’s worked example: the brace/traversal near-miss [0.02ms] +(pass) T065 — first-match-wins, demonstrated where it matters > a leading slash reports rule 2, not rule 12’s empty segment [0.01ms] +(pass) T065 — first-match-wins, demonstrated where it matters > a drive prefix reports rule 3, not rule 4’s backslash or rule 13 [0.01ms] +(pass) T065 — first-match-wins, demonstrated where it matters > a UNC path reports rule 3, not rule 4 +(pass) T065 — first-match-wins, demonstrated where it matters > a brace with a comma reports rule 6, not rule 9 +(pass) T065 — first-match-wins, demonstrated where it matters > a leading bang on an otherwise disallowed pattern reports rule 10, not 13 +(pass) T065 — first-match-wins, demonstrated where it matters > a pattern violating several rules reports the same one every time [0.07ms] +(pass) T065 — rule 13 closes the gap a blacklist cannot (§3) > "@" violates none of rules 1–12 and is caught by rule 13 [0.01ms] +(pass) T065 — rule 13 closes the gap a blacklist cannot (§3) > "#" violates none of rules 1–12 and is caught by rule 13 +(pass) T065 — rule 13 closes the gap a blacklist cannot (§3) > "%" violates none of rules 1–12 and is caught by rule 13 +(pass) T065 — rule 13 closes the gap a blacklist cannot (§3) > "~" violates none of rules 1–12 and is caught by rule 13 +(pass) T065 — rule 13 closes the gap a blacklist cannot (§3) > "+" violates none of rules 1–12 and is caught by rule 13 +(pass) T065 — rule 13 closes the gap a blacklist cannot (§3) > "=" violates none of rules 1–12 and is caught by rule 13 +(pass) T065 — rule 13 closes the gap a blacklist cannot (§3) > ":" violates none of rules 1–12 and is caught by rule 13 +(pass) T065 — rule 13 closes the gap a blacklist cannot (§3) > ";" violates none of rules 1–12 and is caught by rule 13 +(pass) T065 — rule 13 closes the gap a blacklist cannot (§3) > "<" violates none of rules 1–12 and is caught by rule 13 +(pass) T065 — rule 13 closes the gap a blacklist cannot (§3) > ">" violates none of rules 1–12 and is caught by rule 13 +(pass) T065 — rule 13 closes the gap a blacklist cannot (§3) > "|" violates none of rules 1–12 and is caught by rule 13 +(pass) T065 — rule 13 closes the gap a blacklist cannot (§3) > "&" violates none of rules 1–12 and is caught by rule 13 +(pass) T065 — rule 13 closes the gap a blacklist cannot (§3) > "^" violates none of rules 1–12 and is caught by rule 13 +(pass) T065 — rule 13 closes the gap a blacklist cannot (§3) > "é" violates none of rules 1–12 and is caught by rule 13 +(pass) T065 — rule 13 closes the gap a blacklist cannot (§3) > "中" violates none of rules 1–12 and is caught by rule 13 [0.02ms] +(pass) T065 — rule 13 closes the gap a blacklist cannot (§3) > a colon not at position 2 is rule 13, not rule 3’s drive prefix [0.01ms] +181 | describe('T065 — rule 14: only a whole-segment `**` is allowed', () => { +182 | test.each(['a**b', '**b', 'a**', 'foo/**bar', 'foo/a**/b'])( +183 | '%j is `malformed-double-star`', +184 | (pattern) => { +185 | const result = validateGlobPattern(pattern); +186 | expect(result.outcome).toBe('malformed-double-star'); + ^ +error: expect(received).toBe(expected) + +Expected: "malformed-double-star" +Received: "accepted" + + at (/Users/markbeacom/github/mbeacom/copilot-worktrees/adrkit/mbeacom-symmetrical-disco/packages/adapters/catalog-backstage/test/glob-rules.test.ts:186:30) +(fail) T065 — rule 14: only a whole-segment `**` is allowed > "a**b" is `malformed-double-star` [0.08ms] +181 | describe('T065 — rule 14: only a whole-segment `**` is allowed', () => { +182 | test.each(['a**b', '**b', 'a**', 'foo/**bar', 'foo/a**/b'])( +183 | '%j is `malformed-double-star`', +184 | (pattern) => { +185 | const result = validateGlobPattern(pattern); +186 | expect(result.outcome).toBe('malformed-double-star'); + ^ +error: expect(received).toBe(expected) + +Expected: "malformed-double-star" +Received: "accepted" + + at (/Users/markbeacom/github/mbeacom/copilot-worktrees/adrkit/mbeacom-symmetrical-disco/packages/adapters/catalog-backstage/test/glob-rules.test.ts:186:30) +(fail) T065 — rule 14: only a whole-segment `**` is allowed > "**b" is `malformed-double-star` [0.12ms] +181 | describe('T065 — rule 14: only a whole-segment `**` is allowed', () => { +182 | test.each(['a**b', '**b', 'a**', 'foo/**bar', 'foo/a**/b'])( +183 | '%j is `malformed-double-star`', +184 | (pattern) => { +185 | const result = validateGlobPattern(pattern); +186 | expect(result.outcome).toBe('malformed-double-star'); + ^ +error: expect(received).toBe(expected) + +Expected: "malformed-double-star" +Received: "accepted" + + at (/Users/markbeacom/github/mbeacom/copilot-worktrees/adrkit/mbeacom-symmetrical-disco/packages/adapters/catalog-backstage/test/glob-rules.test.ts:186:30) +(fail) T065 — rule 14: only a whole-segment `**` is allowed > "a**" is `malformed-double-star` [0.03ms] +181 | describe('T065 — rule 14: only a whole-segment `**` is allowed', () => { +182 | test.each(['a**b', '**b', 'a**', 'foo/**bar', 'foo/a**/b'])( +183 | '%j is `malformed-double-star`', +184 | (pattern) => { +185 | const result = validateGlobPattern(pattern); +186 | expect(result.outcome).toBe('malformed-double-star'); + ^ +error: expect(received).toBe(expected) + +Expected: "malformed-double-star" +Received: "accepted" + + at (/Users/markbeacom/github/mbeacom/copilot-worktrees/adrkit/mbeacom-symmetrical-disco/packages/adapters/catalog-backstage/test/glob-rules.test.ts:186:30) +(fail) T065 — rule 14: only a whole-segment `**` is allowed > "foo/**bar" is `malformed-double-star` [0.04ms] +181 | describe('T065 — rule 14: only a whole-segment `**` is allowed', () => { +182 | test.each(['a**b', '**b', 'a**', 'foo/**bar', 'foo/a**/b'])( +183 | '%j is `malformed-double-star`', +184 | (pattern) => { +185 | const result = validateGlobPattern(pattern); +186 | expect(result.outcome).toBe('malformed-double-star'); + ^ +error: expect(received).toBe(expected) + +Expected: "malformed-double-star" +Received: "accepted" + + at (/Users/markbeacom/github/mbeacom/copilot-worktrees/adrkit/mbeacom-symmetrical-disco/packages/adapters/catalog-backstage/test/glob-rules.test.ts:186:30) +(fail) T065 — rule 14: only a whole-segment `**` is allowed > "foo/a**/b" is `malformed-double-star` [0.05ms] +(pass) T065 — rule 14: only a whole-segment `**` is allowed > a segment that is exactly `**` is the allowed form [0.08ms] +(pass) T065 — rule 14: only a whole-segment `**` is allowed > a single `*` is unaffected [0.05ms] +(pass) T065 — rule 15’s `accepted` outcome is exercised; its rejection is not required > "packages/payments/**" is accepted at rule 15 [0.03ms] +(pass) T065 — rule 15’s `accepted` outcome is exercised; its rejection is not required > "packages/**" is accepted at rule 15 [0.01ms] +(pass) T065 — rule 15’s `accepted` outcome is exercised; its rejection is not required > "**" is accepted at rule 15 +(pass) T065 — rule 15’s `accepted` outcome is exercised; its rejection is not required > "docs/*.md" is accepted at rule 15 [0.01ms] +(pass) T065 — rule 15’s `accepted` outcome is exercised; its rejection is not required > "src/a?c.ts" is accepted at rule 15 [0.03ms] +(pass) T065 — rule 15’s `accepted` outcome is exercised; its rejection is not required > ".github/**" is accepted at rule 15 [0.02ms] +(pass) T065 — rule 15’s `accepted` outcome is exercised; its rejection is not required > "a-b_c.d/**" is accepted at rule 15 [0.02ms] +(pass) T065 — rule 15’s `accepted` outcome is exercised; its rejection is not required > rule 15 not firing its rejection is conformant, and is not a coverage gap [0.02ms] +(pass) T065 — rule 15’s `accepted` outcome is exercised; its rejection is not required > the compile really is invoked, so `accepted` is a compile result [0.02ms] +(pass) T065 — rule 15’s `accepted` outcome is exercised; its rejection is not required > a rejected pattern never reaches the compile + +7 tests failed: +(fail) T065 — each of rules 1–14, observed firing > rule 14 on "packages/**bar" [0.85ms] +(fail) T065 — each of rules 1–14, observed firing > rule 14’s fixture "packages/**bar" violates no earlier rule [0.07ms] +(fail) T065 — rule 14: only a whole-segment `**` is allowed > "a**b" is `malformed-double-star` [0.08ms] +(fail) T065 — rule 14: only a whole-segment `**` is allowed > "**b" is `malformed-double-star` [0.12ms] +(fail) T065 — rule 14: only a whole-segment `**` is allowed > "a**" is `malformed-double-star` [0.03ms] +(fail) T065 — rule 14: only a whole-segment `**` is allowed > "foo/**bar" is `malformed-double-star` [0.04ms] +(fail) T065 — rule 14: only a whole-segment `**` is allowed > "foo/a**/b" is `malformed-double-star` [0.05ms] + + 65 pass + 7 fail + 148 expect() calls +Ran 72 tests across 1 file. [12.00ms] + +exit 1 diff --git a/specs/010-catalog-backstage/evidence/negative-cases/glob-rules/case-3-rule-14-malformed-double-star.patch b/specs/010-catalog-backstage/evidence/negative-cases/glob-rules/case-3-rule-14-malformed-double-star.patch new file mode 100644 index 00000000..e1971136 --- /dev/null +++ b/specs/010-catalog-backstage/evidence/negative-cases/glob-rules/case-3-rule-14-malformed-double-star.patch @@ -0,0 +1,14 @@ +diff --git a/packages/adapters/catalog-backstage/src/glob/validate.ts b/packages/adapters/catalog-backstage/src/glob/validate.ts +index 67f337f..a931290 100644 +--- a/packages/adapters/catalog-backstage/src/glob/validate.ts ++++ b/packages/adapters/catalog-backstage/src/glob/validate.ts +@@ -123,8 +123,7 @@ const RULES: readonly { + outcome: 'malformed-double-star', + // "Only a segment that is *exactly* `**` is the allowed whole-segment + // double-star." `a**b`, `**b`, `a**`, `foo/**bar` all violate this. +- violates: (p) => +- p.split('/').some((segment) => segment.includes('**') && segment !== '**'), ++ violates: () => false, + }, + ]; + diff --git a/specs/010-catalog-backstage/evidence/negative-cases/glob-rules/restored.observed.txt b/specs/010-catalog-backstage/evidence/negative-cases/glob-rules/restored.observed.txt new file mode 100644 index 00000000..d4c74227 --- /dev/null +++ b/specs/010-catalog-backstage/evidence/negative-cases/glob-rules/restored.observed.txt @@ -0,0 +1,82 @@ +bun test v1.3.14 (0d9b296a) + +test/glob-rules.test.ts: +(pass) T065 — the rule set > there are fifteen rules [0.01ms] +(pass) T065 — the rule set > fourteen of them require exercise [0.02ms] +(pass) T065 — the rule set > a fixture exists for every rule requiring exercise, and for no other [0.01ms] +(pass) T065 — the rule set > the fourteen outcomes are fourteen distinct values [0.02ms] +(pass) T065 — each of rules 1–14, observed firing > rule 1 on "" [0.04ms] +(pass) T065 — each of rules 1–14, observed firing > rule 2 on "/packages/**" +(pass) T065 — each of rules 1–14, observed firing > rule 3 on "C:/packages/**" [0.03ms] +(pass) T065 — each of rules 1–14, observed firing > rule 4 on "packages\\payments" +(pass) T065 — each of rules 1–14, observed firing > rule 5 on "packages/\u0000/payments" [0.03ms] +(pass) T065 — each of rules 1–14, observed firing > rule 6 on "packages/{a}/**" +(pass) T065 — each of rules 1–14, observed firing > rule 7 on "packages/[ab]/**" [0.01ms] +(pass) T065 — each of rules 1–14, observed firing > rule 8 on "packages/(a)/**" +(pass) T065 — each of rules 1–14, observed firing > rule 9 on "packages/a,b/**" +(pass) T065 — each of rules 1–14, observed firing > rule 10 on "!packages/**" +(pass) T065 — each of rules 1–14, observed firing > rule 11 on "packages/../etc" [0.03ms] +(pass) T065 — each of rules 1–14, observed firing > rule 12 on "packages//payments" [0.02ms] +(pass) T065 — each of rules 1–14, observed firing > rule 13 on "packages/@scope/**" [0.02ms] +(pass) T065 — each of rules 1–14, observed firing > rule 14 on "packages/**bar" [0.02ms] +(pass) T065 — each of rules 1–14, observed firing > rule 1’s fixture "" violates no earlier rule [0.01ms] +(pass) T065 — each of rules 1–14, observed firing > rule 2’s fixture "/packages/**" violates no earlier rule +(pass) T065 — each of rules 1–14, observed firing > rule 3’s fixture "C:/packages/**" violates no earlier rule +(pass) T065 — each of rules 1–14, observed firing > rule 4’s fixture "packages\\payments" violates no earlier rule +(pass) T065 — each of rules 1–14, observed firing > rule 5’s fixture "packages/\u0000/payments" violates no earlier rule +(pass) T065 — each of rules 1–14, observed firing > rule 6’s fixture "packages/{a}/**" violates no earlier rule [0.01ms] +(pass) T065 — each of rules 1–14, observed firing > rule 7’s fixture "packages/[ab]/**" violates no earlier rule +(pass) T065 — each of rules 1–14, observed firing > rule 8’s fixture "packages/(a)/**" violates no earlier rule +(pass) T065 — each of rules 1–14, observed firing > rule 9’s fixture "packages/a,b/**" violates no earlier rule +(pass) T065 — each of rules 1–14, observed firing > rule 10’s fixture "!packages/**" violates no earlier rule +(pass) T065 — each of rules 1–14, observed firing > rule 11’s fixture "packages/../etc" violates no earlier rule +(pass) T065 — each of rules 1–14, observed firing > rule 12’s fixture "packages//payments" violates no earlier rule +(pass) T065 — each of rules 1–14, observed firing > rule 13’s fixture "packages/@scope/**" violates no earlier rule [0.02ms] +(pass) T065 — each of rules 1–14, observed firing > rule 14’s fixture "packages/**bar" violates no earlier rule +(pass) T065 — first-match-wins, demonstrated where it matters > §3’s worked example: the brace/traversal near-miss [0.02ms] +(pass) T065 — first-match-wins, demonstrated where it matters > a leading slash reports rule 2, not rule 12’s empty segment +(pass) T065 — first-match-wins, demonstrated where it matters > a drive prefix reports rule 3, not rule 4’s backslash or rule 13 +(pass) T065 — first-match-wins, demonstrated where it matters > a UNC path reports rule 3, not rule 4 +(pass) T065 — first-match-wins, demonstrated where it matters > a brace with a comma reports rule 6, not rule 9 +(pass) T065 — first-match-wins, demonstrated where it matters > a leading bang on an otherwise disallowed pattern reports rule 10, not 13 [0.01ms] +(pass) T065 — first-match-wins, demonstrated where it matters > a pattern violating several rules reports the same one every time [0.08ms] +(pass) T065 — rule 13 closes the gap a blacklist cannot (§3) > "@" violates none of rules 1–12 and is caught by rule 13 [0.01ms] +(pass) T065 — rule 13 closes the gap a blacklist cannot (§3) > "#" violates none of rules 1–12 and is caught by rule 13 +(pass) T065 — rule 13 closes the gap a blacklist cannot (§3) > "%" violates none of rules 1–12 and is caught by rule 13 +(pass) T065 — rule 13 closes the gap a blacklist cannot (§3) > "~" violates none of rules 1–12 and is caught by rule 13 +(pass) T065 — rule 13 closes the gap a blacklist cannot (§3) > "+" violates none of rules 1–12 and is caught by rule 13 +(pass) T065 — rule 13 closes the gap a blacklist cannot (§3) > "=" violates none of rules 1–12 and is caught by rule 13 +(pass) T065 — rule 13 closes the gap a blacklist cannot (§3) > ":" violates none of rules 1–12 and is caught by rule 13 +(pass) T065 — rule 13 closes the gap a blacklist cannot (§3) > ";" violates none of rules 1–12 and is caught by rule 13 +(pass) T065 — rule 13 closes the gap a blacklist cannot (§3) > "<" violates none of rules 1–12 and is caught by rule 13 [0.02ms] +(pass) T065 — rule 13 closes the gap a blacklist cannot (§3) > ">" violates none of rules 1–12 and is caught by rule 13 +(pass) T065 — rule 13 closes the gap a blacklist cannot (§3) > "|" violates none of rules 1–12 and is caught by rule 13 +(pass) T065 — rule 13 closes the gap a blacklist cannot (§3) > "&" violates none of rules 1–12 and is caught by rule 13 +(pass) T065 — rule 13 closes the gap a blacklist cannot (§3) > "^" violates none of rules 1–12 and is caught by rule 13 +(pass) T065 — rule 13 closes the gap a blacklist cannot (§3) > "é" violates none of rules 1–12 and is caught by rule 13 +(pass) T065 — rule 13 closes the gap a blacklist cannot (§3) > "中" violates none of rules 1–12 and is caught by rule 13 [0.03ms] +(pass) T065 — rule 13 closes the gap a blacklist cannot (§3) > a colon not at position 2 is rule 13, not rule 3’s drive prefix [0.01ms] +(pass) T065 — rule 14: only a whole-segment `**` is allowed > "a**b" is `malformed-double-star` [0.01ms] +(pass) T065 — rule 14: only a whole-segment `**` is allowed > "**b" is `malformed-double-star` +(pass) T065 — rule 14: only a whole-segment `**` is allowed > "a**" is `malformed-double-star` +(pass) T065 — rule 14: only a whole-segment `**` is allowed > "foo/**bar" is `malformed-double-star` +(pass) T065 — rule 14: only a whole-segment `**` is allowed > "foo/a**/b" is `malformed-double-star` +(pass) T065 — rule 14: only a whole-segment `**` is allowed > a segment that is exactly `**` is the allowed form [0.90ms] +(pass) T065 — rule 14: only a whole-segment `**` is allowed > a single `*` is unaffected [0.04ms] +(pass) T065 — rule 15’s `accepted` outcome is exercised; its rejection is not required > "packages/payments/**" is accepted at rule 15 [0.03ms] +(pass) T065 — rule 15’s `accepted` outcome is exercised; its rejection is not required > "packages/**" is accepted at rule 15 [0.02ms] +(pass) T065 — rule 15’s `accepted` outcome is exercised; its rejection is not required > "**" is accepted at rule 15 +(pass) T065 — rule 15’s `accepted` outcome is exercised; its rejection is not required > "docs/*.md" is accepted at rule 15 [0.02ms] +(pass) T065 — rule 15’s `accepted` outcome is exercised; its rejection is not required > "src/a?c.ts" is accepted at rule 15 [0.04ms] +(pass) T065 — rule 15’s `accepted` outcome is exercised; its rejection is not required > ".github/**" is accepted at rule 15 [0.02ms] +(pass) T065 — rule 15’s `accepted` outcome is exercised; its rejection is not required > "a-b_c.d/**" is accepted at rule 15 [0.02ms] +(pass) T065 — rule 15’s `accepted` outcome is exercised; its rejection is not required > rule 15 not firing its rejection is conformant, and is not a coverage gap [0.02ms] +(pass) T065 — rule 15’s `accepted` outcome is exercised; its rejection is not required > the compile really is invoked, so `accepted` is a compile result [0.03ms] +(pass) T065 — rule 15’s `accepted` outcome is exercised; its rejection is not required > a rejected pattern never reaches the compile + + 72 pass + 0 fail + 155 expect() calls +Ran 72 tests across 1 file. [12.00ms] + +exit 0 diff --git a/specs/010-catalog-backstage/evidence/negative-cases/inadmissible-and-unique/README.md b/specs/010-catalog-backstage/evidence/negative-cases/inadmissible-and-unique/README.md new file mode 100644 index 00000000..a8352cb3 --- /dev/null +++ b/specs/010-catalog-backstage/evidence/negative-cases/inadmissible-and-unique/README.md @@ -0,0 +1,90 @@ +# Negative case: a descriptor that is **inadmissible and canonically unique** + +**Task**: T054 · **Discharges**: FR-021 · **Supports**: SC-004 +**Observed against**: `99ba8d2500eaf37625ea164f66b4a17870e40dad` plus Phase D's own +uncommitted work; the mutation under test was the only additional change in the tree. +**Tools**: Bun 1.3.14, TypeScript 6.0.3 +**Command**: `bun test test/inadmissible-and-unique.test.ts`, run from +`packages/adapters/catalog-backstage/` +**Permanent automated case**: `packages/adapters/catalog-backstage/test/inadmissible-and-unique.test.ts` — +**retained permanently**, per T054. + +## Why this specific fixture exists + +`admissibility.md` §6: the two determinations are independent, and conformance evidence +"MUST demonstrate that independence rather than assert it. Specifically, the evidence +MUST include at least one descriptor that is **inadmissible and canonically unique** — a +descriptor that fails §2 while colliding with nothing. Without such a case, a passing +suite is equally consistent with an implementation that has silently fused the two +checks." + +ADR-0015 says the same about the corpus that motivated it: of the sixteen unsubstituted +placeholder descriptors, fourteen share `${{ values.name | dump }}` and collide with one +another, while `bulk-import` (`${{ values.name }}`) and `orchestrator` +(`${{ values.entityName }}`) "canonicalize distinctly and collide with nothing. They are +exactly as invalid as the other fourteen, and the contract has no mechanism of any kind +that would notice. The duplicate rule catches the fourteen only incidentally, as a side +effect of their sharing a string; behind it there is nothing." + +Both outliers are reproduced as hand-authored fixtures. **No corpus is read.** + +--- + +## Case 1 — the two checks fused + +Input: [`case-1-admissibility-fused-into-duplicate-detection.patch`](./case-1-admissibility-fused-into-duplicate-detection.patch) · +Output: [`case-1-admissibility-fused-into-duplicate-detection.observed.txt`](./case-1-admissibility-fused-into-duplicate-detection.observed.txt) + +`classifyAdmissibility`'s `admissible` was forced to `true`, which is precisely the +implementation §6 warns a passing suite would otherwise be consistent with: one where +duplicate detection is the only validity check. **Eleven tests fail.** + +``` +(fail) T054 — the fixture is genuinely inadmissible > bulk-import descriptor classifies as inadmissible, attributed to metadata.name +(fail) T054 — the fixture is genuinely inadmissible > orchestrator descriptor classifies as inadmissible, attributed to metadata.name +(fail) T054 — it produces `inadmissible-descriptor` and NOT `duplicate-canonical-id` > bulk-import descriptor: the emitted trigger class +Expected: false +Received: true +``` + +The failure set includes `%s: alone in a run, with nothing to collide with` for both +outliers — a batch of one, where no pair exists and therefore no duplicate rule could +possibly fire. That is the case that isolates admissibility from duplicate detection +completely: the rejection that fires there can only have come from admissibility. + +--- + +## What is emitted, restored + +| Fixture | `metadata.name` | reason | trigger class | attributed to | +|---|---|---|---|---| +| `bulk-import` | `${{ values.name }}` | `inadmissible-descriptor` | `inadmissible-descriptor` | `metadata.name` / `validateEntityName` | +| `orchestrator` | `${{ values.entityName }}` | `inadmissible-descriptor` | `inadmissible-descriptor` | `metadata.name` / `validateEntityName` | + +And the absence that matters: the emitted record contains no canonical id, and +`JSON.stringify(rejection)` contains no occurrence of `duplicate`. + +The record carries all three FR-020 attributions, e.g. + +``` +plugins/bulk-import/catalog-info.yaml[0]: metadata.name rejected by validateEntityName +(isValidEntityName → KubernetesValidatorFunctions.isValidObjectName, pinned at +1121a4facd9e321179d0402c3f355e4a649e84d9); observed "${{ values.name }}" +``` + +## The counting note + +The **fourteen** in this record counts *placeholder descriptors*. It is **not** the +trigger count. This feature's fatal trigger enumeration has **fifteen** members +(`admissibility.md` §5.1 and §6.1; `data-model.md` §8). Two unrelated fourteens; §6.1 +says a reader who fuses them "will produce a document this repository fails." + +## Restored + +[`restored.observed.txt`](./restored.observed.txt) — all 15 tests pass, 0 fail. + +## Standing constraints + +ADR-0014 **rung 1 only**. These are hand-authored fixtures reproducing the *strings* +ADR-0015 records. Nothing here asserts what Backstage as a running system does with +them — only what the pinned validator predicate returns. diff --git a/specs/010-catalog-backstage/evidence/negative-cases/inadmissible-and-unique/case-1-admissibility-fused-into-duplicate-detection.observed.txt b/specs/010-catalog-backstage/evidence/negative-cases/inadmissible-and-unique/case-1-admissibility-fused-into-duplicate-detection.observed.txt new file mode 100644 index 00000000..b31fd7f4 --- /dev/null +++ b/specs/010-catalog-backstage/evidence/negative-cases/inadmissible-and-unique/case-1-admissibility-fused-into-duplicate-detection.observed.txt @@ -0,0 +1,167 @@ +bun test v1.3.14 (0d9b296a) + +test/inadmissible-and-unique.test.ts: +(pass) T054 — the fixture is genuinely inadmissible > bulk-import descriptor fails `validateEntityName` on character class [0.07ms] +(pass) T054 — the fixture is genuinely inadmissible > orchestrator descriptor fails `validateEntityName` on character class +67 | expect(raw.length).toBeLessThanOrEqual(63); +68 | }); +69 | +70 | test.each(OUTLIERS)('%s classifies as inadmissible, attributed to metadata.name', (_n, spec) => { +71 | const result = classifyAdmissibility(descriptor(spec)); +72 | expect(result.admissible).toBe(false); + ^ +error: expect(received).toBe(expected) + +Expected: false +Received: true + + at (/Users/markbeacom/github/mbeacom/copilot-worktrees/adrkit/mbeacom-symmetrical-disco/packages/adapters/catalog-backstage/test/inadmissible-and-unique.test.ts:72:31) +(fail) T054 — the fixture is genuinely inadmissible > bulk-import descriptor classifies as inadmissible, attributed to metadata.name [5.29ms] +67 | expect(raw.length).toBeLessThanOrEqual(63); +68 | }); +69 | +70 | test.each(OUTLIERS)('%s classifies as inadmissible, attributed to metadata.name', (_n, spec) => { +71 | const result = classifyAdmissibility(descriptor(spec)); +72 | expect(result.admissible).toBe(false); + ^ +error: expect(received).toBe(expected) + +Expected: false +Received: true + + at (/Users/markbeacom/github/mbeacom/copilot-worktrees/adrkit/mbeacom-symmetrical-disco/packages/adapters/catalog-backstage/test/inadmissible-and-unique.test.ts:72:31) +(fail) T054 — the fixture is genuinely inadmissible > orchestrator descriptor classifies as inadmissible, attributed to metadata.name [0.41ms] +(pass) T054 — the fixture is genuinely canonically unique > the two outliers would canonicalize distinctly from each other [0.02ms] +(pass) T054 — the fixture is genuinely canonically unique > each outlier collides with nothing in a batch of otherwise-distinct entities [0.02ms] +100 | }); +101 | +102 | describe('T054 — it produces `inadmissible-descriptor` and NOT `duplicate-canonical-id`', () => { +103 | test.each(OUTLIERS)('%s: the emitted trigger class', (_name, spec) => { +104 | const outcome = admit(descriptor(spec, 'packages/plugin/catalog-info.yaml')); +105 | expect(outcome.admissible).toBe(false); + ^ +error: expect(received).toBe(expected) + +Expected: false +Received: true + + at (/Users/markbeacom/github/mbeacom/copilot-worktrees/adrkit/mbeacom-symmetrical-disco/packages/adapters/catalog-backstage/test/inadmissible-and-unique.test.ts:105:32) +(fail) T054 — it produces `inadmissible-descriptor` and NOT `duplicate-canonical-id` > bulk-import descriptor: the emitted trigger class [0.38ms] +100 | }); +101 | +102 | describe('T054 — it produces `inadmissible-descriptor` and NOT `duplicate-canonical-id`', () => { +103 | test.each(OUTLIERS)('%s: the emitted trigger class', (_name, spec) => { +104 | const outcome = admit(descriptor(spec, 'packages/plugin/catalog-info.yaml')); +105 | expect(outcome.admissible).toBe(false); + ^ +error: expect(received).toBe(expected) + +Expected: false +Received: true + + at (/Users/markbeacom/github/mbeacom/copilot-worktrees/adrkit/mbeacom-symmetrical-disco/packages/adapters/catalog-backstage/test/inadmissible-and-unique.test.ts:105:32) +(fail) T054 — it produces `inadmissible-descriptor` and NOT `duplicate-canonical-id` > orchestrator descriptor: the emitted trigger class [0.30ms] +118 | test.each(OUTLIERS)('%s: alone in a run, with nothing to collide with', (_name, spec) => { +119 | // A batch of one. There is no pair, so no duplicate rule could possibly fire — +120 | // which means the rejection that does fire can only have come from +121 | // admissibility. +122 | const admission = collectAdmitted([descriptor(spec)]); +123 | expect(admission.ok).toBe(false); + ^ +error: expect(received).toBe(expected) + +Expected: false +Received: true + + at (/Users/markbeacom/github/mbeacom/copilot-worktrees/adrkit/mbeacom-symmetrical-disco/packages/adapters/catalog-backstage/test/inadmissible-and-unique.test.ts:123:26) +(fail) T054 — it produces `inadmissible-descriptor` and NOT `duplicate-canonical-id` > bulk-import descriptor: alone in a run, with nothing to collide with [0.37ms] +118 | test.each(OUTLIERS)('%s: alone in a run, with nothing to collide with', (_name, spec) => { +119 | // A batch of one. There is no pair, so no duplicate rule could possibly fire — +120 | // which means the rejection that does fire can only have come from +121 | // admissibility. +122 | const admission = collectAdmitted([descriptor(spec)]); +123 | expect(admission.ok).toBe(false); + ^ +error: expect(received).toBe(expected) + +Expected: false +Received: true + + at (/Users/markbeacom/github/mbeacom/copilot-worktrees/adrkit/mbeacom-symmetrical-disco/packages/adapters/catalog-backstage/test/inadmissible-and-unique.test.ts:123:26) +(fail) T054 — it produces `inadmissible-descriptor` and NOT `duplicate-canonical-id` > orchestrator descriptor: alone in a run, with nothing to collide with [0.35ms] +129 | const admission = collectAdmitted([ +130 | descriptor({ ...ADMISSIBLE, name: 'payments' }), +131 | descriptor({ ...ADMISSIBLE, name: 'billing' }), +132 | descriptor(spec), +133 | ]); +134 | expect(admission.ok).toBe(false); + ^ +error: expect(received).toBe(expected) + +Expected: false +Received: true + + at (/Users/markbeacom/github/mbeacom/copilot-worktrees/adrkit/mbeacom-symmetrical-disco/packages/adapters/catalog-backstage/test/inadmissible-and-unique.test.ts:134:26) +(fail) T054 — it produces `inadmissible-descriptor` and NOT `duplicate-canonical-id` > bulk-import descriptor: among valid, distinct entities [0.58ms] +129 | const admission = collectAdmitted([ +130 | descriptor({ ...ADMISSIBLE, name: 'payments' }), +131 | descriptor({ ...ADMISSIBLE, name: 'billing' }), +132 | descriptor(spec), +133 | ]); +134 | expect(admission.ok).toBe(false); + ^ +error: expect(received).toBe(expected) + +Expected: false +Received: true + + at (/Users/markbeacom/github/mbeacom/copilot-worktrees/adrkit/mbeacom-symmetrical-disco/packages/adapters/catalog-backstage/test/inadmissible-and-unique.test.ts:134:26) +(fail) T054 — it produces `inadmissible-descriptor` and NOT `duplicate-canonical-id` > orchestrator descriptor: among valid, distinct entities [0.55ms] +177 | ): Rejection { +178 | if (result.admissible) { +179 | throw new Error( +180 | 'inadmissibleRejection was called for an admissible result. Building a rejection ' + +181 | 'for a descriptor that passed would fabricate a determination that never happened.', +182 | ); + ^ +error: inadmissibleRejection was called for an admissible result. Building a rejection for a descriptor that passed would fabricate a determination that never happened. + at inadmissibleRejection (/Users/markbeacom/github/mbeacom/copilot-worktrees/adrkit/mbeacom-symmetrical-disco/packages/adapters/catalog-backstage/src/admissibility/classify.ts:182:5) + at (/Users/markbeacom/github/mbeacom/copilot-worktrees/adrkit/mbeacom-symmetrical-disco/packages/adapters/catalog-backstage/test/inadmissible-and-unique.test.ts:143:20) +(fail) T054 — it produces `inadmissible-descriptor` and NOT `duplicate-canonical-id` > the record carries all three attributions, as FR-020 requires [0.18ms] +150 | describe('T054 — the colliding form is rejected for the same reason, not a different one', () => { +151 | test('`${{ values.name | dump }}` also yields `inadmissible-descriptor`', () => { +152 | // The point of the contrast: the fourteen and the two get the *same* verdict. +153 | // ADR-0015: they "are exactly as invalid as the other fourteen". +154 | const outcome = admit(descriptor(INADMISSIBLE_AND_COLLIDING)); +155 | expect(outcome.admissible).toBe(false); + ^ +error: expect(received).toBe(expected) + +Expected: false +Received: true + + at (/Users/markbeacom/github/mbeacom/copilot-worktrees/adrkit/mbeacom-symmetrical-disco/packages/adapters/catalog-backstage/test/inadmissible-and-unique.test.ts:155:32) +(fail) T054 — the colliding form is rejected for the same reason, not a different one > `${{ values.name | dump }}` also yields `inadmissible-descriptor` [0.12ms] +160 | test('two copies of the colliding form still report inadmissibility, not the collision', () => { +161 | const admission = collectAdmitted([ +162 | descriptor(INADMISSIBLE_AND_COLLIDING, 'a.yaml'), +163 | descriptor(INADMISSIBLE_AND_COLLIDING, 'b.yaml'), +164 | ]); +165 | expect(admission.ok).toBe(false); + ^ +error: expect(received).toBe(expected) + +Expected: false +Received: true + + at (/Users/markbeacom/github/mbeacom/copilot-worktrees/adrkit/mbeacom-symmetrical-disco/packages/adapters/catalog-backstage/test/inadmissible-and-unique.test.ts:165:26) +(fail) T054 — the colliding form is rejected for the same reason, not a different one > two copies of the colliding form still report inadmissibility, not the collision [0.20ms] +(pass) T054 — the colliding form is rejected for the same reason, not a different one > a genuine collision between two ADMISSIBLE descriptors is still available [0.32ms] +(pass) T054 — the two fourteens are not the same fourteen > the placeholder count and the trigger count are different numbers + + 6 pass + 11 fail + 24 expect() calls +Ran 17 tests across 1 file. [29.00ms] + +exit 1 diff --git a/specs/010-catalog-backstage/evidence/negative-cases/inadmissible-and-unique/case-1-admissibility-fused-into-duplicate-detection.patch b/specs/010-catalog-backstage/evidence/negative-cases/inadmissible-and-unique/case-1-admissibility-fused-into-duplicate-detection.patch new file mode 100644 index 00000000..209d07ee --- /dev/null +++ b/specs/010-catalog-backstage/evidence/negative-cases/inadmissible-and-unique/case-1-admissibility-fused-into-duplicate-detection.patch @@ -0,0 +1,13 @@ +diff --git a/packages/adapters/catalog-backstage/src/admissibility/classify.ts b/packages/adapters/catalog-backstage/src/admissibility/classify.ts +index 370ce9c..c1cd464 100644 +--- a/packages/adapters/catalog-backstage/src/admissibility/classify.ts ++++ b/packages/adapters/catalog-backstage/src/admissibility/classify.ts +@@ -157,7 +157,7 @@ export function classifyAdmissibility(document: DescriptorDocument): Admissibili + } + + return { +- admissible: attributions.length === 0, ++ admissible: true, + failedFields: attributions.map((attribution) => attribution.field), + attributions, + }; diff --git a/specs/010-catalog-backstage/evidence/negative-cases/inadmissible-and-unique/restored.observed.txt b/specs/010-catalog-backstage/evidence/negative-cases/inadmissible-and-unique/restored.observed.txt new file mode 100644 index 00000000..4fb61b7b --- /dev/null +++ b/specs/010-catalog-backstage/evidence/negative-cases/inadmissible-and-unique/restored.observed.txt @@ -0,0 +1,27 @@ +bun test v1.3.14 (0d9b296a) + +test/inadmissible-and-unique.test.ts: +(pass) T054 — the fixture is genuinely inadmissible > bulk-import descriptor fails `validateEntityName` on character class [0.08ms] +(pass) T054 — the fixture is genuinely inadmissible > orchestrator descriptor fails `validateEntityName` on character class +(pass) T054 — the fixture is genuinely inadmissible > bulk-import descriptor classifies as inadmissible, attributed to metadata.name [5.43ms] +(pass) T054 — the fixture is genuinely inadmissible > orchestrator descriptor classifies as inadmissible, attributed to metadata.name [0.41ms] +(pass) T054 — the fixture is genuinely canonically unique > the two outliers would canonicalize distinctly from each other [0.02ms] +(pass) T054 — the fixture is genuinely canonically unique > each outlier collides with nothing in a batch of otherwise-distinct entities [0.04ms] +(pass) T054 — it produces `inadmissible-descriptor` and NOT `duplicate-canonical-id` > bulk-import descriptor: the emitted trigger class [0.39ms] +(pass) T054 — it produces `inadmissible-descriptor` and NOT `duplicate-canonical-id` > orchestrator descriptor: the emitted trigger class [0.32ms] +(pass) T054 — it produces `inadmissible-descriptor` and NOT `duplicate-canonical-id` > bulk-import descriptor: alone in a run, with nothing to collide with [0.34ms] +(pass) T054 — it produces `inadmissible-descriptor` and NOT `duplicate-canonical-id` > orchestrator descriptor: alone in a run, with nothing to collide with [0.27ms] +(pass) T054 — it produces `inadmissible-descriptor` and NOT `duplicate-canonical-id` > bulk-import descriptor: among valid, distinct entities [0.59ms] +(pass) T054 — it produces `inadmissible-descriptor` and NOT `duplicate-canonical-id` > orchestrator descriptor: among valid, distinct entities [0.43ms] +(pass) T054 — it produces `inadmissible-descriptor` and NOT `duplicate-canonical-id` > the record carries all three attributions, as FR-020 requires [0.12ms] +(pass) T054 — the colliding form is rejected for the same reason, not a different one > `${{ values.name | dump }}` also yields `inadmissible-descriptor` [0.11ms] +(pass) T054 — the colliding form is rejected for the same reason, not a different one > two copies of the colliding form still report inadmissibility, not the collision [0.43ms] +(pass) T054 — the colliding form is rejected for the same reason, not a different one > a genuine collision between two ADMISSIBLE descriptors is still available [0.47ms] +(pass) T054 — the two fourteens are not the same fourteen > the placeholder count and the trigger count are different numbers [0.01ms] + + 17 pass + 0 fail + 45 expect() calls +Ran 17 tests across 1 file. [30.00ms] + +exit 0 diff --git a/specs/010-catalog-backstage/evidence/negative-cases/incomplete-required-source/README.md b/specs/010-catalog-backstage/evidence/negative-cases/incomplete-required-source/README.md new file mode 100644 index 00000000..21a3756f --- /dev/null +++ b/specs/010-catalog-backstage/evidence/negative-cases/incomplete-required-source/README.md @@ -0,0 +1,83 @@ +# Negative case: `incomplete-required-source` + +**Task**: T043 · **Discharges**: FR-011 +**Observed against**: `99ba8d2500eaf37625ea164f66b4a17870e40dad` plus Phase D's own +uncommitted work; the mutation under test was the only additional change in the tree. +**Tools**: Bun 1.3.14, TypeScript 6.0.3 +**Command for every case below**: `bun test test/manifest-digests.test.ts`, run from +`packages/adapters/catalog-backstage/` +**Permanent automated case**: `packages/adapters/catalog-backstage/test/manifest-digests.test.ts` + +`input-manifest.md` §4: each listed file is read, its actual digest **independently +recomputed**, and compared. A mismatch, or a manifest-listed path absent from disk, is +an `incomplete-required-source` rejection — "a property of the manifest/generation +request, aborting before any entity's paths are derived, never a per-entity skip." + +FR-011 adds a third condition to §4's two: a **wrongly typed** digest. + +| # | Check disabled | Fine-grained reason that stopped being emitted | Tests failing | +|---|---|---|---| +| 1 | digest recomputation compared against the declared value | `digest-mismatch` | 3 | +| 2 | source-existence check | `source-missing` | 3 | +| 3 | digest shape (64 lowercase hex) | `digest-malformed` | 4 | + +--- + +## Case 1 — the declared digest is trusted rather than verified + +Input: [`case-1-digest-not-verified.patch`](./case-1-digest-not-verified.patch) · +Output: [`case-1-digest-not-verified.observed.txt`](./case-1-digest-not-verified.observed.txt) + +``` +(fail) T043 — digest verification against bytes > a single changed byte is a mismatch +(fail) T043 — digest verification against bytes > the digest is recomputed, never trusted from the manifest +(fail) T043 — verification over the whole source set > the three reasons are mutually distinct +Expected: false +Received: true +``` + +The middle failure is the load-bearing one: its fixture declares a perfectly +**well-formed** digest of the **wrong** content, so an implementation that only +shape-checked the declared value would accept it. Shape-checking alone is not +verification. + +## Case 2 — a manifest-listed path absent from disk is not noticed as absent + +Input: [`case-2-missing-source-skipped.patch`](./case-2-missing-source-skipped.patch) · +Output: [`case-2-missing-source-skipped.observed.txt`](./case-2-missing-source-skipped.observed.txt) + +``` +(fail) T043 — verification over the whole source set > a manifest-listed path absent from disk is `source-missing` +Expected: "source-missing" +Received: "source-unreadable" +``` + +Worth recording precisely: with the existence check removed the run still fails, but it +fails **for the wrong reason** — the read throws and is reported as `source-unreadable`. +A reader would conclude a permissions or I/O fault where the actual defect is a manifest +naming a file that is not there. Both map to `incomplete-required-source`, so the +trigger class alone would not have caught this; the distinct fine-grained reason is what +does. + +## Case 3 — a malformed digest is compared instead of rejected + +Input: [`case-3-digest-shape-unchecked.patch`](./case-3-digest-shape-unchecked.patch) · +Output: [`case-3-digest-shape-unchecked.observed.txt`](./case-3-digest-shape-unchecked.observed.txt) + +``` +(fail) T043 — digest shape (FR-011’s "wrongly typed") > an uppercase-hex digest is rejected before any file is opened +(fail) T043 — digest shape (FR-011’s "wrongly typed") > a truncated digest is rejected +(fail) T043 — digest shape (FR-011’s "wrongly typed") > a non-hex digest is rejected +Expected: false +Received: true +``` + +--- + +## Restored + +[`restored.observed.txt`](./restored.observed.txt) — all 16 tests pass, 0 fail. + +## Standing constraints + +ADR-0014 **rung 1 only**. diff --git a/specs/010-catalog-backstage/evidence/negative-cases/incomplete-required-source/case-1-digest-not-verified.observed.txt b/specs/010-catalog-backstage/evidence/negative-cases/incomplete-required-source/case-1-digest-not-verified.observed.txt new file mode 100644 index 00000000..993100e8 --- /dev/null +++ b/specs/010-catalog-backstage/evidence/negative-cases/incomplete-required-source/case-1-digest-not-verified.observed.txt @@ -0,0 +1,70 @@ +bun test v1.3.14 (0d9b296a) + +test/manifest-digests.test.ts: +(pass) T043 — digest shape (FR-011’s "wrongly typed") > the fixture digest is 64 lowercase hex characters [0.05ms] +(pass) T043 — digest shape (FR-011’s "wrongly typed") > a well-formed digest passes the shape check [0.06ms] +(pass) T043 — digest shape (FR-011’s "wrongly typed") > an uppercase-hex digest is rejected before any file is opened [0.04ms] +(pass) T043 — digest shape (FR-011’s "wrongly typed") > a truncated digest is rejected [0.01ms] +(pass) T043 — digest shape (FR-011’s "wrongly typed") > a non-hex digest is rejected [0.02ms] +(pass) T043 — digest verification against bytes > bytes matching the declared digest are accepted [0.03ms] +91 | expect(result.value.observedDigest).toBe(CONTENT_DIGEST); +92 | }); +93 | +94 | test('a single changed byte is a mismatch', () => { +95 | const result = verifySourceBytes(source(), new TextEncoder().encode(`${CONTENT} `)); +96 | expect(result.ok).toBe(false); + ^ +error: expect(received).toBe(expected) + +Expected: false +Received: true + + at (/Users/markbeacom/github/mbeacom/copilot-worktrees/adrkit/mbeacom-symmetrical-disco/packages/adapters/catalog-backstage/test/manifest-digests.test.ts:96:23) +(fail) T043 — digest verification against bytes > a single changed byte is a mismatch [0.10ms] +105 | // perfectly well-formed. An implementation that only shape-checked the +106 | // declared digest would accept this. +107 | const wrongButWellFormed = sha256Hex(new TextEncoder().encode('something else')); +108 | expect(wrongButWellFormed).toMatch(/^[0-9a-f]{64}$/u); +109 | const result = verifySourceBytes(source({ digest: wrongButWellFormed }), CONTENT_BYTES); +110 | expect(result.ok).toBe(false); + ^ +error: expect(received).toBe(expected) + +Expected: false +Received: true + + at (/Users/markbeacom/github/mbeacom/copilot-worktrees/adrkit/mbeacom-symmetrical-disco/packages/adapters/catalog-backstage/test/manifest-digests.test.ts:110:23) +(fail) T043 — digest verification against bytes > the digest is recomputed, never trusted from the manifest [0.09ms] +(pass) T043 — verification over the whole source set > a fully-agreeing source set is accepted [0.28ms] +(pass) T043 — verification over the whole source set > a manifest-listed path absent from disk is `source-missing` [0.08ms] +(pass) T043 — verification over the whole source set > one bad source among several rejects the set — never a per-entity skip [0.10ms] +(pass) T043 — verification over the whole source set > nothing is returned when any source fails — no partial verified set [0.10ms] +(pass) T043 — verification over the whole source set > the failure reported for two bad sources is deterministic (manifest order) [0.10ms] +176 | const missing = await verifySourceDigests([source({ path: 'not-here.yaml' })], resolve); +177 | const mismatch = await verifySourceDigests([source({ digest: 'a'.repeat(64) })], resolve); +178 | const reasons = [malformed, missing, mismatch].map((result) => +179 | result.ok ? 'accepted' : result.rejection.reason, +180 | ); +181 | expect(reasons).toEqual(['digest-malformed', 'source-missing', 'digest-mismatch']); + ^ +error: expect(received).toEqual(expected) + + [ + "digest-malformed", + "source-missing", +- "digest-mismatch", ++ "accepted", + ] + +- Expected - 1 ++ Received + 1 + + at (/Users/markbeacom/github/mbeacom/copilot-worktrees/adrkit/mbeacom-symmetrical-disco/packages/adapters/catalog-backstage/test/manifest-digests.test.ts:181:21) +(fail) T043 — verification over the whole source set > the three reasons are mutually distinct [0.24ms] + + 11 pass + 3 fail + 30 expect() calls +Ran 14 tests across 1 file. [10.00ms] + +exit 1 diff --git a/specs/010-catalog-backstage/evidence/negative-cases/incomplete-required-source/case-1-digest-not-verified.patch b/specs/010-catalog-backstage/evidence/negative-cases/incomplete-required-source/case-1-digest-not-verified.patch new file mode 100644 index 00000000..4a06c502 --- /dev/null +++ b/specs/010-catalog-backstage/evidence/negative-cases/incomplete-required-source/case-1-digest-not-verified.patch @@ -0,0 +1,13 @@ +diff --git a/packages/adapters/catalog-backstage/src/manifest/digests.ts b/packages/adapters/catalog-backstage/src/manifest/digests.ts +index f8594b6..c982b9f 100644 +--- a/packages/adapters/catalog-backstage/src/manifest/digests.ts ++++ b/packages/adapters/catalog-backstage/src/manifest/digests.ts +@@ -84,7 +84,7 @@ export function verifySourceBytes( + if (!shape.ok) return shape; + + const observedDigest = sha256Hex(bytes); +- if (observedDigest !== source.digest) { ++ if (false && observedDigest !== source.digest) { + return rejected( + 'digest-mismatch', + 'incomplete-required-source', diff --git a/specs/010-catalog-backstage/evidence/negative-cases/incomplete-required-source/case-2-missing-source-skipped.observed.txt b/specs/010-catalog-backstage/evidence/negative-cases/incomplete-required-source/case-2-missing-source-skipped.observed.txt new file mode 100644 index 00000000..b37055ee --- /dev/null +++ b/specs/010-catalog-backstage/evidence/negative-cases/incomplete-required-source/case-2-missing-source-skipped.observed.txt @@ -0,0 +1,70 @@ +bun test v1.3.14 (0d9b296a) + +test/manifest-digests.test.ts: +(pass) T043 — digest shape (FR-011’s "wrongly typed") > the fixture digest is 64 lowercase hex characters [0.04ms] +(pass) T043 — digest shape (FR-011’s "wrongly typed") > a well-formed digest passes the shape check [0.04ms] +(pass) T043 — digest shape (FR-011’s "wrongly typed") > an uppercase-hex digest is rejected before any file is opened [0.04ms] +(pass) T043 — digest shape (FR-011’s "wrongly typed") > a truncated digest is rejected [0.01ms] +(pass) T043 — digest shape (FR-011’s "wrongly typed") > a non-hex digest is rejected [0.01ms] +(pass) T043 — digest verification against bytes > bytes matching the declared digest are accepted [0.03ms] +(pass) T043 — digest verification against bytes > a single changed byte is a mismatch [0.02ms] +(pass) T043 — digest verification against bytes > the digest is recomputed, never trusted from the manifest [0.02ms] +(pass) T043 — verification over the whole source set > a fully-agreeing source set is accepted [0.19ms] +124 | +125 | test('a manifest-listed path absent from disk is `source-missing`', async () => { +126 | const result = await verifySourceDigests([source({ path: 'not-here.yaml' })], resolve); +127 | expect(result.ok).toBe(false); +128 | if (result.ok) return; +129 | expect(result.rejection.reason).toBe('source-missing'); + ^ +error: expect(received).toBe(expected) + +Expected: "source-missing" +Received: "source-unreadable" + + at (/Users/markbeacom/github/mbeacom/copilot-worktrees/adrkit/mbeacom-symmetrical-disco/packages/adapters/catalog-backstage/test/manifest-digests.test.ts:129:37) +(fail) T043 — verification over the whole source set > a manifest-listed path absent from disk is `source-missing` [0.22ms] +136 | [source(), source({ path: 'not-here.yaml' }), source()], +137 | resolve, +138 | ); +139 | expect(result.ok).toBe(false); +140 | if (result.ok) return; +141 | expect(result.rejection.reason).toBe('source-missing'); + ^ +error: expect(received).toBe(expected) + +Expected: "source-missing" +Received: "source-unreadable" + + at (/Users/markbeacom/github/mbeacom/copilot-worktrees/adrkit/mbeacom-symmetrical-disco/packages/adapters/catalog-backstage/test/manifest-digests.test.ts:141:37) +(fail) T043 — verification over the whole source set > one bad source among several rejects the set — never a per-entity skip [0.15ms] +(pass) T043 — verification over the whole source set > nothing is returned when any source fails — no partial verified set [0.11ms] +(pass) T043 — verification over the whole source set > the failure reported for two bad sources is deterministic (manifest order) [0.09ms] +176 | const missing = await verifySourceDigests([source({ path: 'not-here.yaml' })], resolve); +177 | const mismatch = await verifySourceDigests([source({ digest: 'a'.repeat(64) })], resolve); +178 | const reasons = [malformed, missing, mismatch].map((result) => +179 | result.ok ? 'accepted' : result.rejection.reason, +180 | ); +181 | expect(reasons).toEqual(['digest-malformed', 'source-missing', 'digest-mismatch']); + ^ +error: expect(received).toEqual(expected) + + [ + "digest-malformed", +- "source-missing", ++ "source-unreadable", + "digest-mismatch", + ] + +- Expected - 1 ++ Received + 1 + + at (/Users/markbeacom/github/mbeacom/copilot-worktrees/adrkit/mbeacom-symmetrical-disco/packages/adapters/catalog-backstage/test/manifest-digests.test.ts:181:21) +(fail) T043 — verification over the whole source set > the three reasons are mutually distinct [0.19ms] + + 11 pass + 3 fail + 32 expect() calls +Ran 14 tests across 1 file. [10.00ms] + +exit 1 diff --git a/specs/010-catalog-backstage/evidence/negative-cases/incomplete-required-source/case-2-missing-source-skipped.patch b/specs/010-catalog-backstage/evidence/negative-cases/incomplete-required-source/case-2-missing-source-skipped.patch new file mode 100644 index 00000000..97d968ac --- /dev/null +++ b/specs/010-catalog-backstage/evidence/negative-cases/incomplete-required-source/case-2-missing-source-skipped.patch @@ -0,0 +1,13 @@ +diff --git a/packages/adapters/catalog-backstage/src/manifest/digests.ts b/packages/adapters/catalog-backstage/src/manifest/digests.ts +index f8594b6..54881c5 100644 +--- a/packages/adapters/catalog-backstage/src/manifest/digests.ts ++++ b/packages/adapters/catalog-backstage/src/manifest/digests.ts +@@ -117,7 +117,7 @@ export async function verifySourceDigests( + + const absolute = resolveSourcePath(source.path); + const file = Bun.file(absolute); +- if (!(await file.exists())) { ++ if (false && !(await file.exists())) { + return rejected( + 'source-missing', + 'incomplete-required-source', diff --git a/specs/010-catalog-backstage/evidence/negative-cases/incomplete-required-source/case-3-digest-shape-unchecked.observed.txt b/specs/010-catalog-backstage/evidence/negative-cases/incomplete-required-source/case-3-digest-shape-unchecked.observed.txt new file mode 100644 index 00000000..d1d0dd91 --- /dev/null +++ b/specs/010-catalog-backstage/evidence/negative-cases/incomplete-required-source/case-3-digest-shape-unchecked.observed.txt @@ -0,0 +1,83 @@ +bun test v1.3.14 (0d9b296a) + +test/manifest-digests.test.ts: +(pass) T043 — digest shape (FR-011’s "wrongly typed") > the fixture digest is 64 lowercase hex characters [0.05ms] +(pass) T043 — digest shape (FR-011’s "wrongly typed") > a well-formed digest passes the shape check [0.04ms] +60 | expect(checkDigestShape(source()).ok).toBe(true); +61 | }); +62 | +63 | test('an uppercase-hex digest is rejected before any file is opened', () => { +64 | const result = checkDigestShape(source({ digest: CONTENT_DIGEST.toUpperCase() })); +65 | expect(result.ok).toBe(false); + ^ +error: expect(received).toBe(expected) + +Expected: false +Received: true + + at (/Users/markbeacom/github/mbeacom/copilot-worktrees/adrkit/mbeacom-symmetrical-disco/packages/adapters/catalog-backstage/test/manifest-digests.test.ts:65:23) +(fail) T043 — digest shape (FR-011’s "wrongly typed") > an uppercase-hex digest is rejected before any file is opened [0.11ms] +68 | expect(result.rejection.triggerClass).toBe('incomplete-required-source'); +69 | }); +70 | +71 | test('a truncated digest is rejected', () => { +72 | const result = checkDigestShape(source({ digest: CONTENT_DIGEST.slice(0, 32) })); +73 | expect(result.ok).toBe(false); + ^ +error: expect(received).toBe(expected) + +Expected: false +Received: true + + at (/Users/markbeacom/github/mbeacom/copilot-worktrees/adrkit/mbeacom-symmetrical-disco/packages/adapters/catalog-backstage/test/manifest-digests.test.ts:73:23) +(fail) T043 — digest shape (FR-011’s "wrongly typed") > a truncated digest is rejected [0.04ms] +75 | expect(result.rejection.reason).toBe('digest-malformed'); +76 | }); +77 | +78 | test('a non-hex digest is rejected', () => { +79 | const result = checkDigestShape(source({ digest: 'z'.repeat(64) })); +80 | expect(result.ok).toBe(false); + ^ +error: expect(received).toBe(expected) + +Expected: false +Received: true + + at (/Users/markbeacom/github/mbeacom/copilot-worktrees/adrkit/mbeacom-symmetrical-disco/packages/adapters/catalog-backstage/test/manifest-digests.test.ts:80:23) +(fail) T043 — digest shape (FR-011’s "wrongly typed") > a non-hex digest is rejected [0.04ms] +(pass) T043 — digest verification against bytes > bytes matching the declared digest are accepted [0.03ms] +(pass) T043 — digest verification against bytes > a single changed byte is a mismatch [0.03ms] +(pass) T043 — digest verification against bytes > the digest is recomputed, never trusted from the manifest [0.02ms] +(pass) T043 — verification over the whole source set > a fully-agreeing source set is accepted [0.26ms] +(pass) T043 — verification over the whole source set > a manifest-listed path absent from disk is `source-missing` [0.08ms] +(pass) T043 — verification over the whole source set > one bad source among several rejects the set — never a per-entity skip [0.10ms] +(pass) T043 — verification over the whole source set > nothing is returned when any source fails — no partial verified set [0.09ms] +(pass) T043 — verification over the whole source set > the failure reported for two bad sources is deterministic (manifest order) [0.11ms] +176 | const missing = await verifySourceDigests([source({ path: 'not-here.yaml' })], resolve); +177 | const mismatch = await verifySourceDigests([source({ digest: 'a'.repeat(64) })], resolve); +178 | const reasons = [malformed, missing, mismatch].map((result) => +179 | result.ok ? 'accepted' : result.rejection.reason, +180 | ); +181 | expect(reasons).toEqual(['digest-malformed', 'source-missing', 'digest-mismatch']); + ^ +error: expect(received).toEqual(expected) + + [ +- "digest-malformed", ++ "digest-mismatch", + "source-missing", + "digest-mismatch", + ] + +- Expected - 1 ++ Received + 1 + + at (/Users/markbeacom/github/mbeacom/copilot-worktrees/adrkit/mbeacom-symmetrical-disco/packages/adapters/catalog-backstage/test/manifest-digests.test.ts:181:21) +(fail) T043 — verification over the whole source set > the three reasons are mutually distinct [0.30ms] + + 10 pass + 4 fail + 30 expect() calls +Ran 14 tests across 1 file. [10.00ms] + +exit 1 diff --git a/specs/010-catalog-backstage/evidence/negative-cases/incomplete-required-source/case-3-digest-shape-unchecked.patch b/specs/010-catalog-backstage/evidence/negative-cases/incomplete-required-source/case-3-digest-shape-unchecked.patch new file mode 100644 index 00000000..28417fa4 --- /dev/null +++ b/specs/010-catalog-backstage/evidence/negative-cases/incomplete-required-source/case-3-digest-shape-unchecked.patch @@ -0,0 +1,13 @@ +diff --git a/packages/adapters/catalog-backstage/src/manifest/digests.ts b/packages/adapters/catalog-backstage/src/manifest/digests.ts +index f8594b6..1c959e7 100644 +--- a/packages/adapters/catalog-backstage/src/manifest/digests.ts ++++ b/packages/adapters/catalog-backstage/src/manifest/digests.ts +@@ -60,7 +60,7 @@ export function sha256Hex(bytes: Uint8Array): string { + export function checkDigestShape( + source: ManifestSource, + ): Validated { +- if (!SHA256_HEX.test(source.digest)) { ++ if (false && !SHA256_HEX.test(source.digest)) { + return rejected( + 'digest-malformed', + 'incomplete-required-source', diff --git a/specs/010-catalog-backstage/evidence/negative-cases/incomplete-required-source/restored.observed.txt b/specs/010-catalog-backstage/evidence/negative-cases/incomplete-required-source/restored.observed.txt new file mode 100644 index 00000000..ad24541e --- /dev/null +++ b/specs/010-catalog-backstage/evidence/negative-cases/incomplete-required-source/restored.observed.txt @@ -0,0 +1,24 @@ +bun test v1.3.14 (0d9b296a) + +test/manifest-digests.test.ts: +(pass) T043 — digest shape (FR-011’s "wrongly typed") > the fixture digest is 64 lowercase hex characters [0.04ms] +(pass) T043 — digest shape (FR-011’s "wrongly typed") > a well-formed digest passes the shape check [0.07ms] +(pass) T043 — digest shape (FR-011’s "wrongly typed") > an uppercase-hex digest is rejected before any file is opened [0.04ms] +(pass) T043 — digest shape (FR-011’s "wrongly typed") > a truncated digest is rejected [0.01ms] +(pass) T043 — digest shape (FR-011’s "wrongly typed") > a non-hex digest is rejected [0.02ms] +(pass) T043 — digest verification against bytes > bytes matching the declared digest are accepted [0.03ms] +(pass) T043 — digest verification against bytes > a single changed byte is a mismatch [0.03ms] +(pass) T043 — digest verification against bytes > the digest is recomputed, never trusted from the manifest [0.02ms] +(pass) T043 — verification over the whole source set > a fully-agreeing source set is accepted [0.24ms] +(pass) T043 — verification over the whole source set > a manifest-listed path absent from disk is `source-missing` [0.07ms] +(pass) T043 — verification over the whole source set > one bad source among several rejects the set — never a per-entity skip [0.09ms] +(pass) T043 — verification over the whole source set > nothing is returned when any source fails — no partial verified set [0.06ms] +(pass) T043 — verification over the whole source set > the failure reported for two bad sources is deterministic (manifest order) [0.08ms] +(pass) T043 — verification over the whole source set > the three reasons are mutually distinct [0.14ms] + + 14 pass + 0 fail + 35 expect() calls +Ran 14 tests across 1 file. [9.00ms] + +exit 0 diff --git a/specs/010-catalog-backstage/evidence/negative-cases/manifest-version/README.md b/specs/010-catalog-backstage/evidence/negative-cases/manifest-version/README.md new file mode 100644 index 00000000..63bc9350 --- /dev/null +++ b/specs/010-catalog-backstage/evidence/negative-cases/manifest-version/README.md @@ -0,0 +1,81 @@ +# Negative case: the three manifest-level version and capability rejections + +**Task**: T040 · **Discharges**: FR-008 +**Observed against**: `99ba8d2500eaf37625ea164f66b4a17870e40dad` plus Phase D's own +uncommitted work; the mutation under test was the only additional change in the tree +for each run. +**Tools**: Bun 1.3.14, TypeScript 6.0.3 +**Command for every case below**: `bun test test/manifest-version.test.ts`, run from +`packages/adapters/catalog-backstage/` +**Permanent automated case**: `packages/adapters/catalog-backstage/test/manifest-version.test.ts` + +`input-manifest.md` §2 fixes exactly three manifest-level rejections, and +`atomic-fail-closed.md` §5 groups them with `incomplete-required-source` as the four +manifest-request-level rejections. Each of the three was disabled in turn and the +suite observed failing, then restored and observed passing. + +| # | Check disabled | Reason that stopped being emitted | Tests failing | +|---|---|---|---| +| 1 | `manifestSchemaVersion` comparison | `unsupported-manifest-version` | 2 | +| 2 | `requestedSnapshotSchemaVersion` comparison | `unsupported-snapshot-version` | 1 | +| 3 | `requiredCapabilities` membership | `unsupported-capability` | 2 | + +--- + +## Case 1 — `unsupported-manifest-version` + +Input: [`case-1-unsupported-manifest-version.patch`](./case-1-unsupported-manifest-version.patch) · +Output: [`case-1-unsupported-manifest-version.observed.txt`](./case-1-unsupported-manifest-version.observed.txt) + +``` +(fail) T040 — the three version/capability rejections (FR-008) > `unsupported-manifest-version` — an unsupported manifestSchemaVersion +Expected: false +Received: true + +(fail) T040 — the three version/capability rejections (FR-008) > a manifest violating two rules reports the first, deterministically +Expected: "unsupported-manifest-version" +Received: "unsupported-snapshot-version" +``` + +The second failure is the one worth keeping. With the first check disabled, a manifest +wrong on **both** version fields reports the *snapshot* version instead — which is +exactly the order-dependence `input-manifest.md` §2's fixed table ordering exists to +prevent, surfacing as a different reported reason for the same input. + +## Case 2 — `unsupported-snapshot-version` + +Input: [`case-2-unsupported-snapshot-version.patch`](./case-2-unsupported-snapshot-version.patch) · +Output: [`case-2-unsupported-snapshot-version.observed.txt`](./case-2-unsupported-snapshot-version.observed.txt) + +``` +(fail) T040 — the three version/capability rejections (FR-008) > `unsupported-snapshot-version` — an unsupported requestedSnapshotSchemaVersion +Expected: false +Received: true +``` + +## Case 3 — `unsupported-capability` + +Input: [`case-3-unsupported-capability.patch`](./case-3-unsupported-capability.patch) · +Output: [`case-3-unsupported-capability.observed.txt`](./case-3-unsupported-capability.observed.txt) + +``` +(fail) T040 — the three version/capability rejections (FR-008) > `unsupported-capability` — any string other than pathOwnership in the array +Expected: false +Received: true + +(fail) T040 — what §2’s capability rule does and does not say > capability matching is exact, never case-insensitive +Expected: false +Received: true +``` + +--- + +## Restored + +[`restored.observed.txt`](./restored.observed.txt) — all 11 tests pass, 0 fail, with +the source reverted. + +## Standing constraints + +ADR-0014 **rung 1 only**. Nothing here is reference-verified (rung 2) or externally +validated (rung 3); the observation is the maintainer's own. diff --git a/specs/010-catalog-backstage/evidence/negative-cases/manifest-version/case-1-unsupported-manifest-version.observed.txt b/specs/010-catalog-backstage/evidence/negative-cases/manifest-version/case-1-unsupported-manifest-version.observed.txt new file mode 100644 index 00000000..60e1014f --- /dev/null +++ b/specs/010-catalog-backstage/evidence/negative-cases/manifest-version/case-1-unsupported-manifest-version.observed.txt @@ -0,0 +1,47 @@ +bun test v1.3.14 (0d9b296a) + +test/manifest-version.test.ts: +(pass) T040 — the three version/capability rejections (FR-008) > the accepted values are exactly the ones `input-manifest.md` §2 names [0.02ms] +(pass) T040 — the three version/capability rejections (FR-008) > the contract-shaped manifest passes all three [0.34ms] +48 | expect(result.ok).toBe(true); +49 | }); +50 | +51 | test('`unsupported-manifest-version` — an unsupported manifestSchemaVersion', () => { +52 | const result = checkManifestVersions(manifest({ manifestSchemaVersion: '2' })); +53 | expect(result.ok).toBe(false); + ^ +error: expect(received).toBe(expected) + +Expected: false +Received: true + + at (/Users/markbeacom/github/mbeacom/copilot-worktrees/adrkit/mbeacom-symmetrical-disco/packages/adapters/catalog-backstage/test/manifest-version.test.ts:53:23) +(fail) T040 — the three version/capability rejections (FR-008) > `unsupported-manifest-version` — an unsupported manifestSchemaVersion [0.11ms] +(pass) T040 — the three version/capability rejections (FR-008) > `unsupported-snapshot-version` — an unsupported requestedSnapshotSchemaVersion [0.03ms] +(pass) T040 — the three version/capability rejections (FR-008) > `unsupported-capability` — any string other than pathOwnership in the array [0.04ms] +(pass) T040 — the three version/capability rejections (FR-008) > the three reasons are mutually distinct [0.08ms] +(pass) T040 — the three version/capability rejections (FR-008) > all three trigger classes are members of the closed enumeration [0.03ms] +111 | const result = checkManifestVersions( +112 | manifest({ manifestSchemaVersion: '2', requestedSnapshotSchemaVersion: '3' }), +113 | ); +114 | expect(result.ok).toBe(false); +115 | if (result.ok) return; +116 | expect(result.rejection.reason).toBe('unsupported-manifest-version'); + ^ +error: expect(received).toBe(expected) + +Expected: "unsupported-manifest-version" +Received: "unsupported-snapshot-version" + + at (/Users/markbeacom/github/mbeacom/copilot-worktrees/adrkit/mbeacom-symmetrical-disco/packages/adapters/catalog-backstage/test/manifest-version.test.ts:116:37) +(fail) T040 — the three version/capability rejections (FR-008) > a manifest violating two rules reports the first, deterministically [0.13ms] +(pass) T040 — what §2’s capability rule does and does not say > the rejection is triggered by a present offending string [0.03ms] +(pass) T040 — what §2’s capability rule does and does not say > a repeated supported capability contains no offending string [0.03ms] +(pass) T040 — what §2’s capability rule does and does not say > capability matching is exact, never case-insensitive [0.02ms] + + 9 pass + 2 fail + 23 expect() calls +Ran 11 tests across 1 file. [7.00ms] + +exit 1 diff --git a/specs/010-catalog-backstage/evidence/negative-cases/manifest-version/case-1-unsupported-manifest-version.patch b/specs/010-catalog-backstage/evidence/negative-cases/manifest-version/case-1-unsupported-manifest-version.patch new file mode 100644 index 00000000..606cac9e --- /dev/null +++ b/specs/010-catalog-backstage/evidence/negative-cases/manifest-version/case-1-unsupported-manifest-version.patch @@ -0,0 +1,13 @@ +diff --git a/packages/adapters/catalog-backstage/src/manifest/version.ts b/packages/adapters/catalog-backstage/src/manifest/version.ts +index 8f9b654..1a560de 100644 +--- a/packages/adapters/catalog-backstage/src/manifest/version.ts ++++ b/packages/adapters/catalog-backstage/src/manifest/version.ts +@@ -67,7 +67,7 @@ export type ManifestVersionReason = + export function checkManifestVersions( + manifest: InputManifest, + ): Validated { +- if (manifest.manifestSchemaVersion !== SUPPORTED_MANIFEST_SCHEMA_VERSION) { ++ if (false && manifest.manifestSchemaVersion !== SUPPORTED_MANIFEST_SCHEMA_VERSION) { + return rejected( + 'unsupported-manifest-version', + 'unsupported-manifest-version', diff --git a/specs/010-catalog-backstage/evidence/negative-cases/manifest-version/case-2-unsupported-snapshot-version.observed.txt b/specs/010-catalog-backstage/evidence/negative-cases/manifest-version/case-2-unsupported-snapshot-version.observed.txt new file mode 100644 index 00000000..89de5c10 --- /dev/null +++ b/specs/010-catalog-backstage/evidence/negative-cases/manifest-version/case-2-unsupported-snapshot-version.observed.txt @@ -0,0 +1,34 @@ +bun test v1.3.14 (0d9b296a) + +test/manifest-version.test.ts: +(pass) T040 — the three version/capability rejections (FR-008) > the accepted values are exactly the ones `input-manifest.md` §2 names [0.02ms] +(pass) T040 — the three version/capability rejections (FR-008) > the contract-shaped manifest passes all three [0.35ms] +(pass) T040 — the three version/capability rejections (FR-008) > `unsupported-manifest-version` — an unsupported manifestSchemaVersion [0.04ms] +59 | ); +60 | }); +61 | +62 | test('`unsupported-snapshot-version` — an unsupported requestedSnapshotSchemaVersion', () => { +63 | const result = checkManifestVersions(manifest({ requestedSnapshotSchemaVersion: '2' })); +64 | expect(result.ok).toBe(false); + ^ +error: expect(received).toBe(expected) + +Expected: false +Received: true + + at (/Users/markbeacom/github/mbeacom/copilot-worktrees/adrkit/mbeacom-symmetrical-disco/packages/adapters/catalog-backstage/test/manifest-version.test.ts:64:23) +(fail) T040 — the three version/capability rejections (FR-008) > `unsupported-snapshot-version` — an unsupported requestedSnapshotSchemaVersion [0.10ms] +(pass) T040 — the three version/capability rejections (FR-008) > `unsupported-capability` — any string other than pathOwnership in the array [0.04ms] +(pass) T040 — the three version/capability rejections (FR-008) > the three reasons are mutually distinct [0.15ms] +(pass) T040 — the three version/capability rejections (FR-008) > all three trigger classes are members of the closed enumeration [0.03ms] +(pass) T040 — the three version/capability rejections (FR-008) > a manifest violating two rules reports the first, deterministically [0.07ms] +(pass) T040 — what §2’s capability rule does and does not say > the rejection is triggered by a present offending string [0.02ms] +(pass) T040 — what §2’s capability rule does and does not say > a repeated supported capability contains no offending string [0.02ms] +(pass) T040 — what §2’s capability rule does and does not say > capability matching is exact, never case-insensitive [0.02ms] + + 10 pass + 1 fail + 23 expect() calls +Ran 11 tests across 1 file. [6.00ms] + +exit 1 diff --git a/specs/010-catalog-backstage/evidence/negative-cases/manifest-version/case-2-unsupported-snapshot-version.patch b/specs/010-catalog-backstage/evidence/negative-cases/manifest-version/case-2-unsupported-snapshot-version.patch new file mode 100644 index 00000000..e1035ed0 --- /dev/null +++ b/specs/010-catalog-backstage/evidence/negative-cases/manifest-version/case-2-unsupported-snapshot-version.patch @@ -0,0 +1,13 @@ +diff --git a/packages/adapters/catalog-backstage/src/manifest/version.ts b/packages/adapters/catalog-backstage/src/manifest/version.ts +index 8f9b654..1d23f6a 100644 +--- a/packages/adapters/catalog-backstage/src/manifest/version.ts ++++ b/packages/adapters/catalog-backstage/src/manifest/version.ts +@@ -75,7 +75,7 @@ export function checkManifestVersions( + ); + } + +- if (manifest.requestedSnapshotSchemaVersion !== SUPPORTED_SNAPSHOT_SCHEMA_VERSION) { ++ if (false && manifest.requestedSnapshotSchemaVersion !== SUPPORTED_SNAPSHOT_SCHEMA_VERSION) { + return rejected( + 'unsupported-snapshot-version', + 'unsupported-snapshot-version', diff --git a/specs/010-catalog-backstage/evidence/negative-cases/manifest-version/case-3-unsupported-capability.observed.txt b/specs/010-catalog-backstage/evidence/negative-cases/manifest-version/case-3-unsupported-capability.observed.txt new file mode 100644 index 00000000..696331c6 --- /dev/null +++ b/specs/010-catalog-backstage/evidence/negative-cases/manifest-version/case-3-unsupported-capability.observed.txt @@ -0,0 +1,47 @@ +bun test v1.3.14 (0d9b296a) + +test/manifest-version.test.ts: +(pass) T040 — the three version/capability rejections (FR-008) > the accepted values are exactly the ones `input-manifest.md` §2 names [0.02ms] +(pass) T040 — the three version/capability rejections (FR-008) > the contract-shaped manifest passes all three [0.36ms] +(pass) T040 — the three version/capability rejections (FR-008) > `unsupported-manifest-version` — an unsupported manifestSchemaVersion [0.07ms] +(pass) T040 — the three version/capability rejections (FR-008) > `unsupported-snapshot-version` — an unsupported requestedSnapshotSchemaVersion [0.04ms] +72 | +73 | test('`unsupported-capability` — any string other than pathOwnership in the array', () => { +74 | const result = checkManifestVersions( +75 | manifest({ requiredCapabilities: ['pathOwnership', 'entityGraph'] }), +76 | ); +77 | expect(result.ok).toBe(false); + ^ +error: expect(received).toBe(expected) + +Expected: false +Received: true + + at (/Users/markbeacom/github/mbeacom/copilot-worktrees/adrkit/mbeacom-symmetrical-disco/packages/adapters/catalog-backstage/test/manifest-version.test.ts:77:23) +(fail) T040 — the three version/capability rejections (FR-008) > `unsupported-capability` — any string other than pathOwnership in the array [0.15ms] +(pass) T040 — the three version/capability rejections (FR-008) > the three reasons are mutually distinct [0.13ms] +(pass) T040 — the three version/capability rejections (FR-008) > all three trigger classes are members of the closed enumeration [0.02ms] +(pass) T040 — the three version/capability rejections (FR-008) > a manifest violating two rules reports the first, deterministically [0.04ms] +(pass) T040 — what §2’s capability rule does and does not say > the rejection is triggered by a present offending string [0.02ms] +(pass) T040 — what §2’s capability rule does and does not say > a repeated supported capability contains no offending string [0.03ms] +135 | ).toBe(true); +136 | }); +137 | +138 | test('capability matching is exact, never case-insensitive', () => { +139 | const result = checkManifestVersions(manifest({ requiredCapabilities: ['pathownership'] })); +140 | expect(result.ok).toBe(false); + ^ +error: expect(received).toBe(expected) + +Expected: false +Received: true + + at (/Users/markbeacom/github/mbeacom/copilot-worktrees/adrkit/mbeacom-symmetrical-disco/packages/adapters/catalog-backstage/test/manifest-version.test.ts:140:23) +(fail) T040 — what §2’s capability rule does and does not say > capability matching is exact, never case-insensitive [0.07ms] + + 9 pass + 2 fail + 22 expect() calls +Ran 11 tests across 1 file. [7.00ms] + +exit 1 diff --git a/specs/010-catalog-backstage/evidence/negative-cases/manifest-version/case-3-unsupported-capability.patch b/specs/010-catalog-backstage/evidence/negative-cases/manifest-version/case-3-unsupported-capability.patch new file mode 100644 index 00000000..6066e34b --- /dev/null +++ b/specs/010-catalog-backstage/evidence/negative-cases/manifest-version/case-3-unsupported-capability.patch @@ -0,0 +1,13 @@ +diff --git a/packages/adapters/catalog-backstage/src/manifest/version.ts b/packages/adapters/catalog-backstage/src/manifest/version.ts +index 8f9b654..ff4e2b7 100644 +--- a/packages/adapters/catalog-backstage/src/manifest/version.ts ++++ b/packages/adapters/catalog-backstage/src/manifest/version.ts +@@ -84,7 +84,7 @@ export function checkManifestVersions( + } + + for (const [index, capability] of manifest.requiredCapabilities.entries()) { +- if (capability !== SUPPORTED_CAPABILITY) { ++ if (false && capability !== SUPPORTED_CAPABILITY) { + return rejected( + 'unsupported-capability', + 'unsupported-capability', diff --git a/specs/010-catalog-backstage/evidence/negative-cases/manifest-version/restored.observed.txt b/specs/010-catalog-backstage/evidence/negative-cases/manifest-version/restored.observed.txt new file mode 100644 index 00000000..2fbd0584 --- /dev/null +++ b/specs/010-catalog-backstage/evidence/negative-cases/manifest-version/restored.observed.txt @@ -0,0 +1,21 @@ +bun test v1.3.14 (0d9b296a) + +test/manifest-version.test.ts: +(pass) T040 — the three version/capability rejections (FR-008) > the accepted values are exactly the ones `input-manifest.md` §2 names [0.02ms] +(pass) T040 — the three version/capability rejections (FR-008) > the contract-shaped manifest passes all three [0.40ms] +(pass) T040 — the three version/capability rejections (FR-008) > `unsupported-manifest-version` — an unsupported manifestSchemaVersion [0.05ms] +(pass) T040 — the three version/capability rejections (FR-008) > `unsupported-snapshot-version` — an unsupported requestedSnapshotSchemaVersion [0.02ms] +(pass) T040 — the three version/capability rejections (FR-008) > `unsupported-capability` — any string other than pathOwnership in the array [0.06ms] +(pass) T040 — the three version/capability rejections (FR-008) > the three reasons are mutually distinct [0.14ms] +(pass) T040 — the three version/capability rejections (FR-008) > all three trigger classes are members of the closed enumeration [0.02ms] +(pass) T040 — the three version/capability rejections (FR-008) > a manifest violating two rules reports the first, deterministically [0.06ms] +(pass) T040 — what §2’s capability rule does and does not say > the rejection is triggered by a present offending string [0.03ms] +(pass) T040 — what §2’s capability rule does and does not say > a repeated supported capability contains no offending string [0.02ms] +(pass) T040 — what §2’s capability rule does and does not say > capability matching is exact, never case-insensitive [0.02ms] + + 11 pass + 0 fail + 26 expect() calls +Ran 11 tests across 1 file. [7.00ms] + +exit 0 diff --git a/specs/010-catalog-backstage/evidence/negative-cases/path-validation/README.md b/specs/010-catalog-backstage/evidence/negative-cases/path-validation/README.md new file mode 100644 index 00000000..e99dff8e --- /dev/null +++ b/specs/010-catalog-backstage/evidence/negative-cases/path-validation/README.md @@ -0,0 +1,79 @@ +# Negative case: two-stage source-path validation + +**Task**: T044 · **Discharges**: FR-012 · **Supports**: SC-008 +**Observed against**: `99ba8d2500eaf37625ea164f66b4a17870e40dad` plus Phase D's own +uncommitted work; the mutation under test was the only additional change in the tree. +**Tools**: Bun 1.3.14, TypeScript 6.0.3 +**Command for both cases below**: `bun test test/manifest-paths.test.ts`, run from +`packages/adapters/catalog-backstage/` +**Permanent automated case**: `packages/adapters/catalog-backstage/test/manifest-paths.test.ts` + +`input-manifest.md` §4.1 requires two stages, and **each was observed failing +independently** — which is the point of having two records here rather than one. The +stages are not redundant: + +- Stage 1 is purely lexical and runs before the filesystem is touched. It **cannot** see + symlinks. +- Stage 2 resolves symlinks and requires the resolved real path to lie beneath the + verified checkout root. It **cannot** be replaced by a stricter stage 1, because + §4.1 says in terms that "a lexically-clean relative path can still symlink outside + the root." + +Case 1 and case 2 fail on disjoint test sets, which is the evidence that neither stage +is covering for the other. + +--- + +## Case 1 — stage 1: the traversal-segment rule + +Input: [`case-1-stage-1-lexical-traversal-unchecked.patch`](./case-1-stage-1-lexical-traversal-unchecked.patch) · +Output: [`case-1-stage-1-lexical-traversal-unchecked.observed.txt`](./case-1-stage-1-lexical-traversal-unchecked.observed.txt) + +``` +(fail) T044 stage 1 — lexical rejection, before the filesystem is touched > "../../secret.yaml" is rejected as path-traversal-segment +(fail) T044 stage 1 — lexical rejection, before the filesystem is touched > "packages/./catalog-info.yaml" is rejected as path-traversal-segment +(fail) T044 stage 1 — lexical rejection, before the filesystem is touched > "packages/../../etc/passwd" is rejected as path-traversal-segment +Expected: false +Received: true +``` + +Five tests fail, including `stage 1 rejects without any filesystem access` — the one +run against a checkout root that **does not exist**, so a stage-1 failure could only be +a rejection rather than a thrown `ENOENT` if stage 1 really did run first. + +## Case 2 — stage 2: confined realpath + +Input: [`case-2-stage-2-confinement-unchecked.patch`](./case-2-stage-2-confinement-unchecked.patch) · +Output: [`case-2-stage-2-confinement-unchecked.observed.txt`](./case-2-stage-2-confinement-unchecked.observed.txt) + +``` +(fail) T044 stage 2 — confined realpath > a lexically-clean path escaping through a symlink fails closed +(fail) T044 stage 2 — confined realpath > the same escape is rejected through the combined two-stage entry point +(fail) T044 stage 2 — confined realpath > the two stages emit different reasons for their own failures +Expected: false +Received: true +``` + +The fixture is a directory symlink out of a scratch checkout: `escape/secret.yaml` has +no `..`, no leading slash, and no backslash, so it **passes stage 1** — asserted +explicitly in the same test — and is caught only by resolution. That is the case §4.1 +says a pure string check cannot reach. + +--- + +## Trigger-class attribution, recorded as an inference rather than a quotation + +§4.1 names stage 2's failure explicitly (`incomplete-required-source`, "and the file is +never opened"). It does **not** name a trigger class for stage 1, saying only "reject +the manifest, non-zero". Stage 1 is attributed to `invalid-manifest-shape` here because +it is a defect in the manifest's own content rather than in the checkout's. That +attribution is an inference from the contract's silence and is reported as a contract +gap rather than presented as settled. + +## Restored + +[`restored.observed.txt`](./restored.observed.txt) — all 22 tests pass, 0 fail. + +## Standing constraints + +ADR-0014 **rung 1 only**. diff --git a/specs/010-catalog-backstage/evidence/negative-cases/path-validation/case-1-stage-1-lexical-traversal-unchecked.observed.txt b/specs/010-catalog-backstage/evidence/negative-cases/path-validation/case-1-stage-1-lexical-traversal-unchecked.observed.txt new file mode 100644 index 00000000..e395c4f0 --- /dev/null +++ b/specs/010-catalog-backstage/evidence/negative-cases/path-validation/case-1-stage-1-lexical-traversal-unchecked.observed.txt @@ -0,0 +1,96 @@ +bun test v1.3.14 (0d9b296a) + +test/manifest-paths.test.ts: +(pass) T044 stage 1 — lexical rejection, before the filesystem is touched > a clean repo-relative path passes stage 1 [0.17ms] +(pass) T044 stage 1 — lexical rejection, before the filesystem is touched > "" is rejected as path-empty [0.02ms] +(pass) T044 stage 1 — lexical rejection, before the filesystem is touched > "." is rejected as path-dot-or-dotdot +(pass) T044 stage 1 — lexical rejection, before the filesystem is touched > ".." is rejected as path-dot-or-dotdot +(pass) T044 stage 1 — lexical rejection, before the filesystem is touched > "/etc/passwd" is rejected as path-absolute +(pass) T044 stage 1 — lexical rejection, before the filesystem is touched > "C:\\Windows\\system32" is rejected as path-drive-prefix +(pass) T044 stage 1 — lexical rejection, before the filesystem is touched > "packages\\payments\\catalog-info.yaml" is rejected as path-backslash +(pass) T044 stage 1 — lexical rejection, before the filesystem is touched > "\\\\host\\share\\file.yaml" is rejected as path-backslash +70 | ['packages/\u0000/catalog-info.yaml', 'path-control-character'], +71 | ['packages/\u007f/catalog-info.yaml', 'path-control-character'], +72 | ['packages/\ncatalog-info.yaml', 'path-control-character'], +73 | ] as const)('%j is rejected as %s', (path, expected) => { +74 | const result = validatePathLexically(path); +75 | expect(result.ok).toBe(false); + ^ +error: expect(received).toBe(expected) + +Expected: false +Received: true + + at (/Users/markbeacom/github/mbeacom/copilot-worktrees/adrkit/mbeacom-symmetrical-disco/packages/adapters/catalog-backstage/test/manifest-paths.test.ts:75:23) +(fail) T044 stage 1 — lexical rejection, before the filesystem is touched > "../../secret.yaml" is rejected as path-traversal-segment [0.11ms] +70 | ['packages/\u0000/catalog-info.yaml', 'path-control-character'], +71 | ['packages/\u007f/catalog-info.yaml', 'path-control-character'], +72 | ['packages/\ncatalog-info.yaml', 'path-control-character'], +73 | ] as const)('%j is rejected as %s', (path, expected) => { +74 | const result = validatePathLexically(path); +75 | expect(result.ok).toBe(false); + ^ +error: expect(received).toBe(expected) + +Expected: false +Received: true + + at (/Users/markbeacom/github/mbeacom/copilot-worktrees/adrkit/mbeacom-symmetrical-disco/packages/adapters/catalog-backstage/test/manifest-paths.test.ts:75:23) +(fail) T044 stage 1 — lexical rejection, before the filesystem is touched > "packages/./catalog-info.yaml" is rejected as path-traversal-segment [0.03ms] +70 | ['packages/\u0000/catalog-info.yaml', 'path-control-character'], +71 | ['packages/\u007f/catalog-info.yaml', 'path-control-character'], +72 | ['packages/\ncatalog-info.yaml', 'path-control-character'], +73 | ] as const)('%j is rejected as %s', (path, expected) => { +74 | const result = validatePathLexically(path); +75 | expect(result.ok).toBe(false); + ^ +error: expect(received).toBe(expected) + +Expected: false +Received: true + + at (/Users/markbeacom/github/mbeacom/copilot-worktrees/adrkit/mbeacom-symmetrical-disco/packages/adapters/catalog-backstage/test/manifest-paths.test.ts:75:23) +(fail) T044 stage 1 — lexical rejection, before the filesystem is touched > "packages/../../etc/passwd" is rejected as path-traversal-segment [0.03ms] +(pass) T044 stage 1 — lexical rejection, before the filesystem is touched > "packages/\u0000/catalog-info.yaml" is rejected as path-control-character +(pass) T044 stage 1 — lexical rejection, before the filesystem is touched > "packages//catalog-info.yaml" is rejected as path-control-character +(pass) T044 stage 1 — lexical rejection, before the filesystem is touched > "packages/\ncatalog-info.yaml" is rejected as path-control-character +(pass) T044 stage 1 — lexical rejection, before the filesystem is touched > the drive prefix rule fires before the backslash rule [0.01ms] +92 | // The root does not exist. A stage-1 failure must still be a rejection rather +93 | // than a thrown ENOENT, which is only possible if stage 1 really did run first. +94 | const result = await validateSourcePath(NONEXISTENT_ROOT, '../../secret.yaml'); +95 | expect(result.ok).toBe(false); +96 | if (result.ok) return; +97 | expect(result.rejection.reason).toBe('path-traversal-segment'); + ^ +error: expect(received).toBe(expected) + +Expected: "path-traversal-segment" +Received: "path-escapes-checkout-root" + + at (/Users/markbeacom/github/mbeacom/copilot-worktrees/adrkit/mbeacom-symmetrical-disco/packages/adapters/catalog-backstage/test/manifest-paths.test.ts:97:37) +(fail) T044 stage 1 — lexical rejection, before the filesystem is touched > stage 1 rejects without any filesystem access [0.33ms] +(pass) T044 stage 2 — confined realpath > containment is strict — a sibling sharing a name prefix is not beneath [0.01ms] +(pass) T044 stage 2 — confined realpath > a real file beneath the root is accepted [0.08ms] +(pass) T044 stage 2 — confined realpath > a lexically-clean path escaping through a symlink fails closed [0.09ms] +(pass) T044 stage 2 — confined realpath > the same escape is rejected through the combined two-stage entry point [0.07ms] +(pass) T044 stage 2 — confined realpath > a nonexistent path beneath the root is not an escape [0.08ms] +144 | const stageOne = await validateSourcePath(checkoutRoot, '../secret.yaml'); +145 | const stageTwo = await validateSourcePath(checkoutRoot, 'escape/secret.yaml'); +146 | expect(stageOne.ok).toBe(false); +147 | expect(stageTwo.ok).toBe(false); +148 | if (stageOne.ok || stageTwo.ok) return; +149 | expect(stageOne.rejection.reason).not.toBe(stageTwo.rejection.reason); + ^ +error: expect(received).not.toBe(expected) + +Expected: not "path-escapes-checkout-root" + + at (/Users/markbeacom/github/mbeacom/copilot-worktrees/adrkit/mbeacom-symmetrical-disco/packages/adapters/catalog-backstage/test/manifest-paths.test.ts:149:43) +(fail) T044 stage 2 — confined realpath > the two stages emit different reasons for their own failures [0.18ms] + + 17 pass + 5 fail + 55 expect() calls +Ran 22 tests across 1 file. [10.00ms] + +exit 1 diff --git a/specs/010-catalog-backstage/evidence/negative-cases/path-validation/case-1-stage-1-lexical-traversal-unchecked.patch b/specs/010-catalog-backstage/evidence/negative-cases/path-validation/case-1-stage-1-lexical-traversal-unchecked.patch new file mode 100644 index 00000000..2eefb34d --- /dev/null +++ b/specs/010-catalog-backstage/evidence/negative-cases/path-validation/case-1-stage-1-lexical-traversal-unchecked.patch @@ -0,0 +1,13 @@ +diff --git a/packages/adapters/catalog-backstage/src/manifest/paths.ts b/packages/adapters/catalog-backstage/src/manifest/paths.ts +index 2145535..61b840a 100644 +--- a/packages/adapters/catalog-backstage/src/manifest/paths.ts ++++ b/packages/adapters/catalog-backstage/src/manifest/paths.ts +@@ -105,7 +105,7 @@ export function validatePathLexically(path: string): Validated a clean repo-relative path passes stage 1 [0.19ms] +(pass) T044 stage 1 — lexical rejection, before the filesystem is touched > "" is rejected as path-empty [0.04ms] +(pass) T044 stage 1 — lexical rejection, before the filesystem is touched > "." is rejected as path-dot-or-dotdot +(pass) T044 stage 1 — lexical rejection, before the filesystem is touched > ".." is rejected as path-dot-or-dotdot +(pass) T044 stage 1 — lexical rejection, before the filesystem is touched > "/etc/passwd" is rejected as path-absolute +(pass) T044 stage 1 — lexical rejection, before the filesystem is touched > "C:\\Windows\\system32" is rejected as path-drive-prefix +(pass) T044 stage 1 — lexical rejection, before the filesystem is touched > "packages\\payments\\catalog-info.yaml" is rejected as path-backslash +(pass) T044 stage 1 — lexical rejection, before the filesystem is touched > "\\\\host\\share\\file.yaml" is rejected as path-backslash +(pass) T044 stage 1 — lexical rejection, before the filesystem is touched > "../../secret.yaml" is rejected as path-traversal-segment +(pass) T044 stage 1 — lexical rejection, before the filesystem is touched > "packages/./catalog-info.yaml" is rejected as path-traversal-segment +(pass) T044 stage 1 — lexical rejection, before the filesystem is touched > "packages/../../etc/passwd" is rejected as path-traversal-segment +(pass) T044 stage 1 — lexical rejection, before the filesystem is touched > "packages/\u0000/catalog-info.yaml" is rejected as path-control-character [0.05ms] +(pass) T044 stage 1 — lexical rejection, before the filesystem is touched > "packages//catalog-info.yaml" is rejected as path-control-character +(pass) T044 stage 1 — lexical rejection, before the filesystem is touched > "packages/\ncatalog-info.yaml" is rejected as path-control-character +(pass) T044 stage 1 — lexical rejection, before the filesystem is touched > the drive prefix rule fires before the backslash rule [0.02ms] +(pass) T044 stage 1 — lexical rejection, before the filesystem is touched > stage 1 rejects without any filesystem access [0.05ms] +(pass) T044 stage 2 — confined realpath > containment is strict — a sibling sharing a name prefix is not beneath [0.03ms] +(pass) T044 stage 2 — confined realpath > a real file beneath the root is accepted [0.20ms] +117 | // This is the case stage 1 cannot see: no `..`, no leading slash, nothing a +118 | // string check could catch. +119 | expect(validatePathLexically('escape/secret.yaml').ok).toBe(true); +120 | +121 | const result = await validatePathConfined(checkoutRoot, 'escape/secret.yaml'); +122 | expect(result.ok).toBe(false); + ^ +error: expect(received).toBe(expected) + +Expected: false +Received: true + + at (/Users/markbeacom/github/mbeacom/copilot-worktrees/adrkit/mbeacom-symmetrical-disco/packages/adapters/catalog-backstage/test/manifest-paths.test.ts:122:23) +(fail) T044 stage 2 — confined realpath > a lexically-clean path escaping through a symlink fails closed [0.18ms] +126 | expect(result.rejection.detail).toContain('not beneath the verified checkout root'); +127 | }); +128 | +129 | test('the same escape is rejected through the combined two-stage entry point', async () => { +130 | const result = await validateSourcePath(checkoutRoot, 'escape/secret.yaml'); +131 | expect(result.ok).toBe(false); + ^ +error: expect(received).toBe(expected) + +Expected: false +Received: true + + at (/Users/markbeacom/github/mbeacom/copilot-worktrees/adrkit/mbeacom-symmetrical-disco/packages/adapters/catalog-backstage/test/manifest-paths.test.ts:131:23) +(fail) T044 stage 2 — confined realpath > the same escape is rejected through the combined two-stage entry point [0.12ms] +(pass) T044 stage 2 — confined realpath > a nonexistent path beneath the root is not an escape [0.15ms] +142 | +143 | test('the two stages emit different reasons for their own failures', async () => { +144 | const stageOne = await validateSourcePath(checkoutRoot, '../secret.yaml'); +145 | const stageTwo = await validateSourcePath(checkoutRoot, 'escape/secret.yaml'); +146 | expect(stageOne.ok).toBe(false); +147 | expect(stageTwo.ok).toBe(false); + ^ +error: expect(received).toBe(expected) + +Expected: false +Received: true + + at (/Users/markbeacom/github/mbeacom/copilot-worktrees/adrkit/mbeacom-symmetrical-disco/packages/adapters/catalog-backstage/test/manifest-paths.test.ts:147:25) +(fail) T044 stage 2 — confined realpath > the two stages emit different reasons for their own failures [0.15ms] + + 19 pass + 3 fail + 56 expect() calls +Ran 22 tests across 1 file. [10.00ms] + +exit 1 diff --git a/specs/010-catalog-backstage/evidence/negative-cases/path-validation/case-2-stage-2-confinement-unchecked.patch b/specs/010-catalog-backstage/evidence/negative-cases/path-validation/case-2-stage-2-confinement-unchecked.patch new file mode 100644 index 00000000..54254e7d --- /dev/null +++ b/specs/010-catalog-backstage/evidence/negative-cases/path-validation/case-2-stage-2-confinement-unchecked.patch @@ -0,0 +1,13 @@ +diff --git a/packages/adapters/catalog-backstage/src/manifest/paths.ts b/packages/adapters/catalog-backstage/src/manifest/paths.ts +index 2145535..c9d7c49 100644 +--- a/packages/adapters/catalog-backstage/src/manifest/paths.ts ++++ b/packages/adapters/catalog-backstage/src/manifest/paths.ts +@@ -215,7 +215,7 @@ export async function validatePathConfined( + + const resolved = await realpathOfDeepestExistingAncestor(joined); + +- if (!isBeneath(resolvedRoot, resolved)) { ++ if (false && !isBeneath(resolvedRoot, resolved)) { + return rejected( + 'path-escapes-checkout-root', + 'incomplete-required-source', diff --git a/specs/010-catalog-backstage/evidence/negative-cases/path-validation/restored.observed.txt b/specs/010-catalog-backstage/evidence/negative-cases/path-validation/restored.observed.txt new file mode 100644 index 00000000..cb461e61 --- /dev/null +++ b/specs/010-catalog-backstage/evidence/negative-cases/path-validation/restored.observed.txt @@ -0,0 +1,32 @@ +bun test v1.3.14 (0d9b296a) + +test/manifest-paths.test.ts: +(pass) T044 stage 1 — lexical rejection, before the filesystem is touched > a clean repo-relative path passes stage 1 [0.22ms] +(pass) T044 stage 1 — lexical rejection, before the filesystem is touched > "" is rejected as path-empty [0.03ms] +(pass) T044 stage 1 — lexical rejection, before the filesystem is touched > "." is rejected as path-dot-or-dotdot +(pass) T044 stage 1 — lexical rejection, before the filesystem is touched > ".." is rejected as path-dot-or-dotdot +(pass) T044 stage 1 — lexical rejection, before the filesystem is touched > "/etc/passwd" is rejected as path-absolute +(pass) T044 stage 1 — lexical rejection, before the filesystem is touched > "C:\\Windows\\system32" is rejected as path-drive-prefix +(pass) T044 stage 1 — lexical rejection, before the filesystem is touched > "packages\\payments\\catalog-info.yaml" is rejected as path-backslash +(pass) T044 stage 1 — lexical rejection, before the filesystem is touched > "\\\\host\\share\\file.yaml" is rejected as path-backslash +(pass) T044 stage 1 — lexical rejection, before the filesystem is touched > "../../secret.yaml" is rejected as path-traversal-segment +(pass) T044 stage 1 — lexical rejection, before the filesystem is touched > "packages/./catalog-info.yaml" is rejected as path-traversal-segment +(pass) T044 stage 1 — lexical rejection, before the filesystem is touched > "packages/../../etc/passwd" is rejected as path-traversal-segment +(pass) T044 stage 1 — lexical rejection, before the filesystem is touched > "packages/\u0000/catalog-info.yaml" is rejected as path-control-character [0.03ms] +(pass) T044 stage 1 — lexical rejection, before the filesystem is touched > "packages//catalog-info.yaml" is rejected as path-control-character +(pass) T044 stage 1 — lexical rejection, before the filesystem is touched > "packages/\ncatalog-info.yaml" is rejected as path-control-character +(pass) T044 stage 1 — lexical rejection, before the filesystem is touched > the drive prefix rule fires before the backslash rule [0.01ms] +(pass) T044 stage 1 — lexical rejection, before the filesystem is touched > stage 1 rejects without any filesystem access [0.05ms] +(pass) T044 stage 2 — confined realpath > containment is strict — a sibling sharing a name prefix is not beneath [0.04ms] +(pass) T044 stage 2 — confined realpath > a real file beneath the root is accepted [0.26ms] +(pass) T044 stage 2 — confined realpath > a lexically-clean path escaping through a symlink fails closed [0.12ms] +(pass) T044 stage 2 — confined realpath > the same escape is rejected through the combined two-stage entry point [0.09ms] +(pass) T044 stage 2 — confined realpath > a nonexistent path beneath the root is not an escape [0.13ms] +(pass) T044 stage 2 — confined realpath > the two stages emit different reasons for their own failures [0.13ms] + + 22 pass + 0 fail + 62 expect() calls +Ran 22 tests across 1 file. [10.00ms] + +exit 0 diff --git a/specs/010-catalog-backstage/evidence/negative-cases/repository-mismatch/README.md b/specs/010-catalog-backstage/evidence/negative-cases/repository-mismatch/README.md new file mode 100644 index 00000000..b9229a08 --- /dev/null +++ b/specs/010-catalog-backstage/evidence/negative-cases/repository-mismatch/README.md @@ -0,0 +1,78 @@ +# Negative case: repository identity and revision compared by exact string equality + +**Task**: T042 · **Discharges**: FR-010 · **Supports**: SC-008 +**Observed against**: `99ba8d2500eaf37625ea164f66b4a17870e40dad` plus Phase D's own +uncommitted work; the mutation under test was the only additional change in the tree. +**Tools**: Bun 1.3.14, TypeScript 6.0.3 +**Command for every case below**: `bun test test/repository-exact-match.test.ts`, run +from `packages/adapters/catalog-backstage/` +**Permanent automated cases**: +`packages/adapters/catalog-backstage/test/repository-exact-match.test.ts` and +`test/repository-identity.test.ts` + +`input-manifest.md` §3 step 4: "Any outcome other than both-match aborts generation +**before any entity's paths are derived** — including a partial match (e.g. revision +matches but repository ID does not)." + +**A note on the fixture constraint, because it is the thing most likely to be lost.** +`input-manifest.md` §3.1 requires the *repository-identity* fixture be a standalone +scratch `git init` repository and never a `git worktree add` linked worktree, because a +linked worktree shares its remote configuration with the repository it was created from +and would report `github.com/mbeacom/adrkit` no matter what the test intended. The +checkout this work was done in **is** such a worktree. +`test/repository-identity.test.ts` creates a scratch repository per run and asserts the +premise (`git rev-parse --git-dir` differs from `--git-common-dir`) rather than +assuming it. The two cases below are the pure-comparison half and use fixed strings, so +they carry no git state at all. + +--- + +## Case 1 — a prefix match is accepted where an exact match is required + +Input: [`case-1-revision-prefix-match.patch`](./case-1-revision-prefix-match.patch) · +Output: [`case-1-revision-prefix-match.observed.txt`](./case-1-revision-prefix-match.observed.txt) + +`manifest.revision === observed.head` was replaced with +`observed.head.startsWith(manifest.revision)` — the single most plausible way a real +implementation ends up accepting an abbreviated SHA. + +``` +(fail) T042 — exact string equality on both values (FR-010) > an abbreviated revision is a mismatch, not a prefix match +Expected: false +Received: true +``` + +## Case 2 — the identity half is not compared at all + +Input: [`case-2-identity-not-compared.patch`](./case-2-identity-not-compared.patch) · +Output: [`case-2-identity-not-compared.observed.txt`](./case-2-identity-not-compared.observed.txt) + +`idMatches` was forced to `true`, leaving only the revision compared. Four tests fail, +and the emitted detail shows the partial-match reporting collapsing: + +``` +(fail) T042 — exact string equality on both values (FR-010) > a partial match — revision agrees, identity does not — still aborts +Expected: false +Received: true + +(fail) T042 — exact string equality on both values (FR-010) > both disagreeing reports both halves +Received: "revision: manifest \"0000000000000000000000000000000000000000\" !== observed \"3f5a1c9e8b2d4f6a0c7e1b3d5f7a9c1e3b5d7f90\"" + +(fail) T042 — exact string equality on both values (FR-010) > a repository-id prefix is a mismatch +``` + +The `Received` line is the one that shows the defect precisely: a run that disagreed on +*both* halves reports only the revision, so a reader would conclude the repository +identity had agreed when it had never been checked. + +--- + +## Restored + +[`restored.observed.txt`](./restored.observed.txt) — all 11 tests pass, 0 fail. + +## Standing constraints + +ADR-0014 **rung 1 only**. The check's warrant is bounded by `input-manifest.md` §3: it +confirms the manifest agrees with the checkout's own **locally-configured** git state. +It is not a network-verified provenance check and is not described as one anywhere. diff --git a/specs/010-catalog-backstage/evidence/negative-cases/repository-mismatch/case-1-revision-prefix-match.observed.txt b/specs/010-catalog-backstage/evidence/negative-cases/repository-mismatch/case-1-revision-prefix-match.observed.txt new file mode 100644 index 00000000..e1ce54d2 --- /dev/null +++ b/specs/010-catalog-backstage/evidence/negative-cases/repository-mismatch/case-1-revision-prefix-match.observed.txt @@ -0,0 +1,34 @@ +bun test v1.3.14 (0d9b296a) + +test/repository-exact-match.test.ts: +(pass) T042 — exact string equality on both values (FR-010) > both agreeing is the only match [0.23ms] +31 | expect(result.value.outcome).toBe('match'); +32 | }); +33 | +34 | test('an abbreviated revision is a mismatch, not a prefix match', () => { +35 | const result = compareRepositoryIdentity({ id: ID, revision: HEAD.slice(0, 7) }, OBSERVED); +36 | expect(result.ok).toBe(false); + ^ +error: expect(received).toBe(expected) + +Expected: false +Received: true + + at (/Users/markbeacom/github/mbeacom/copilot-worktrees/adrkit/mbeacom-symmetrical-disco/packages/adapters/catalog-backstage/test/repository-exact-match.test.ts:36:23) +(fail) T042 — exact string equality on both values (FR-010) > an abbreviated revision is a mismatch, not a prefix match [0.12ms] +(pass) T042 — exact string equality on both values (FR-010) > a revision differing in one character is a mismatch [0.06ms] +(pass) T042 — exact string equality on both values (FR-010) > an uppercase revision is a mismatch — comparison is not case-normalized [0.02ms] +(pass) T042 — exact string equality on both values (FR-010) > a partial match — revision agrees, identity does not — still aborts [0.02ms] +(pass) T042 — exact string equality on both values (FR-010) > a partial match the other way — identity agrees, revision does not — still aborts [0.02ms] +(pass) T042 — exact string equality on both values (FR-010) > both disagreeing reports both halves [0.02ms] +(pass) T042 — exact string equality on both values (FR-010) > a repository-id prefix is a mismatch [0.01ms] +(pass) T042 — exact string equality on both values (FR-010) > the check records the values it compared, both sides [0.01ms] +(pass) T042 — exact string equality on both values (FR-010) > an unrecognized remote normalizes to `invalid` and mismatches [0.01ms] +(pass) T042 — exact string equality on both values (FR-010) > the observed values are compared, not re-read from the manifest + + 10 pass + 1 fail + 27 expect() calls +Ran 11 tests across 1 file. [6.00ms] + +exit 1 diff --git a/specs/010-catalog-backstage/evidence/negative-cases/repository-mismatch/case-1-revision-prefix-match.patch b/specs/010-catalog-backstage/evidence/negative-cases/repository-mismatch/case-1-revision-prefix-match.patch new file mode 100644 index 00000000..44691aac --- /dev/null +++ b/specs/010-catalog-backstage/evidence/negative-cases/repository-mismatch/case-1-revision-prefix-match.patch @@ -0,0 +1,13 @@ +diff --git a/packages/adapters/catalog-backstage/src/repository/identity.ts b/packages/adapters/catalog-backstage/src/repository/identity.ts +index 244af3b..fa7fcf4 100644 +--- a/packages/adapters/catalog-backstage/src/repository/identity.ts ++++ b/packages/adapters/catalog-backstage/src/repository/identity.ts +@@ -164,7 +164,7 @@ export function compareRepositoryIdentity( + const manifestRepositoryId = normalizeRepositoryId(manifest.id); + + const idMatches = manifestRepositoryId === observedRepositoryId; +- const revisionMatches = manifest.revision === observed.head; ++ const revisionMatches = observed.head.startsWith(manifest.revision); + + const check: RepositoryIdentityCheck = { + manifestRepositoryId, diff --git a/specs/010-catalog-backstage/evidence/negative-cases/repository-mismatch/case-2-identity-not-compared.observed.txt b/specs/010-catalog-backstage/evidence/negative-cases/repository-mismatch/case-2-identity-not-compared.observed.txt new file mode 100644 index 00000000..17881b64 --- /dev/null +++ b/specs/010-catalog-backstage/evidence/negative-cases/repository-mismatch/case-2-identity-not-compared.observed.txt @@ -0,0 +1,73 @@ +bun test v1.3.14 (0d9b296a) + +test/repository-exact-match.test.ts: +(pass) T042 — exact string equality on both values (FR-010) > both agreeing is the only match [0.22ms] +(pass) T042 — exact string equality on both values (FR-010) > an abbreviated revision is a mismatch, not a prefix match [0.05ms] +(pass) T042 — exact string equality on both values (FR-010) > a revision differing in one character is a mismatch [0.02ms] +(pass) T042 — exact string equality on both values (FR-010) > an uppercase revision is a mismatch — comparison is not case-normalized [0.02ms] +61 | test('a partial match — revision agrees, identity does not — still aborts', () => { +62 | const result = compareRepositoryIdentity( +63 | { id: 'github.com/mbeacom/some-other-repo', revision: HEAD }, +64 | OBSERVED, +65 | ); +66 | expect(result.ok).toBe(false); + ^ +error: expect(received).toBe(expected) + +Expected: false +Received: true + + at (/Users/markbeacom/github/mbeacom/copilot-worktrees/adrkit/mbeacom-symmetrical-disco/packages/adapters/catalog-backstage/test/repository-exact-match.test.ts:66:23) +(fail) T042 — exact string equality on both values (FR-010) > a partial match — revision agrees, identity does not — still aborts [0.09ms] +(pass) T042 — exact string equality on both values (FR-010) > a partial match the other way — identity agrees, revision does not — still aborts [0.02ms] +83 | { id: 'github.com/mbeacom/other', revision: '0'.repeat(40) }, +84 | OBSERVED, +85 | ); +86 | expect(result.ok).toBe(false); +87 | if (result.ok) return; +88 | expect(result.rejection.detail).toContain('repository id:'); + ^ +error: expect(received).toContain(expected) + +Expected to contain: "repository id:" +Received: "revision: manifest \"0000000000000000000000000000000000000000\" !== observed \"3f5a1c9e8b2d4f6a0c7e1b3d5f7a9c1e3b5d7f90\"" + + at (/Users/markbeacom/github/mbeacom/copilot-worktrees/adrkit/mbeacom-symmetrical-disco/packages/adapters/catalog-backstage/test/repository-exact-match.test.ts:88:37) +(fail) T042 — exact string equality on both values (FR-010) > both disagreeing reports both halves [0.04ms] +94 | // an implementation using `startsWith` for identity would accept this. +95 | const result = compareRepositoryIdentity( +96 | { id: 'github.com/mbeacom/adrkit', revision: HEAD }, +97 | OBSERVED, +98 | ); +99 | expect(result.ok).toBe(false); + ^ +error: expect(received).toBe(expected) + +Expected: false +Received: true + + at (/Users/markbeacom/github/mbeacom/copilot-worktrees/adrkit/mbeacom-symmetrical-disco/packages/adapters/catalog-backstage/test/repository-exact-match.test.ts:99:23) +(fail) T042 — exact string equality on both values (FR-010) > a repository-id prefix is a mismatch [0.03ms] +(pass) T042 — exact string equality on both values (FR-010) > the check records the values it compared, both sides [0.01ms] +112 | test('an unrecognized remote normalizes to `invalid` and mismatches', () => { +113 | const result = compareRepositoryIdentity( +114 | { id: ID, revision: HEAD }, +115 | { remoteRaw: 'https://gitlab.com/mbeacom/adrkit.git', head: HEAD }, +116 | ); +117 | expect(result.ok).toBe(false); + ^ +error: expect(received).toBe(expected) + +Expected: false +Received: true + + at (/Users/markbeacom/github/mbeacom/copilot-worktrees/adrkit/mbeacom-symmetrical-disco/packages/adapters/catalog-backstage/test/repository-exact-match.test.ts:117:23) +(fail) T042 — exact string equality on both values (FR-010) > an unrecognized remote normalizes to `invalid` and mismatches [0.03ms] +(pass) T042 — exact string equality on both values (FR-010) > the observed values are compared, not re-read from the manifest [0.01ms] + + 7 pass + 4 fail + 25 expect() calls +Ran 11 tests across 1 file. [6.00ms] + +exit 1 diff --git a/specs/010-catalog-backstage/evidence/negative-cases/repository-mismatch/case-2-identity-not-compared.patch b/specs/010-catalog-backstage/evidence/negative-cases/repository-mismatch/case-2-identity-not-compared.patch new file mode 100644 index 00000000..e63ccf6b --- /dev/null +++ b/specs/010-catalog-backstage/evidence/negative-cases/repository-mismatch/case-2-identity-not-compared.patch @@ -0,0 +1,13 @@ +diff --git a/packages/adapters/catalog-backstage/src/repository/identity.ts b/packages/adapters/catalog-backstage/src/repository/identity.ts +index 244af3b..dd4b7e6 100644 +--- a/packages/adapters/catalog-backstage/src/repository/identity.ts ++++ b/packages/adapters/catalog-backstage/src/repository/identity.ts +@@ -163,7 +163,7 @@ export function compareRepositoryIdentity( + const observedRepositoryId = normalizeRepositoryId(observed.remoteRaw); + const manifestRepositoryId = normalizeRepositoryId(manifest.id); + +- const idMatches = manifestRepositoryId === observedRepositoryId; ++ const idMatches = true; + const revisionMatches = manifest.revision === observed.head; + + const check: RepositoryIdentityCheck = { diff --git a/specs/010-catalog-backstage/evidence/negative-cases/repository-mismatch/restored.observed.txt b/specs/010-catalog-backstage/evidence/negative-cases/repository-mismatch/restored.observed.txt new file mode 100644 index 00000000..c2065c28 --- /dev/null +++ b/specs/010-catalog-backstage/evidence/negative-cases/repository-mismatch/restored.observed.txt @@ -0,0 +1,21 @@ +bun test v1.3.14 (0d9b296a) + +test/repository-exact-match.test.ts: +(pass) T042 — exact string equality on both values (FR-010) > both agreeing is the only match [0.25ms] +(pass) T042 — exact string equality on both values (FR-010) > an abbreviated revision is a mismatch, not a prefix match [0.06ms] +(pass) T042 — exact string equality on both values (FR-010) > a revision differing in one character is a mismatch [0.03ms] +(pass) T042 — exact string equality on both values (FR-010) > an uppercase revision is a mismatch — comparison is not case-normalized [0.02ms] +(pass) T042 — exact string equality on both values (FR-010) > a partial match — revision agrees, identity does not — still aborts [0.02ms] +(pass) T042 — exact string equality on both values (FR-010) > a partial match the other way — identity agrees, revision does not — still aborts [0.04ms] +(pass) T042 — exact string equality on both values (FR-010) > both disagreeing reports both halves [0.03ms] +(pass) T042 — exact string equality on both values (FR-010) > a repository-id prefix is a mismatch [0.01ms] +(pass) T042 — exact string equality on both values (FR-010) > the check records the values it compared, both sides [0.02ms] +(pass) T042 — exact string equality on both values (FR-010) > an unrecognized remote normalizes to `invalid` and mismatches [0.01ms] +(pass) T042 — exact string equality on both values (FR-010) > the observed values are compared, not re-read from the manifest + + 11 pass + 0 fail + 31 expect() calls +Ran 11 tests across 1 file. [6.00ms] + +exit 0 diff --git a/specs/010-catalog-backstage/evidence/negative-cases/yaml-read/README.md b/specs/010-catalog-backstage/evidence/negative-cases/yaml-read/README.md new file mode 100644 index 00000000..19a72a2c --- /dev/null +++ b/specs/010-catalog-backstage/evidence/negative-cases/yaml-read/README.md @@ -0,0 +1,77 @@ +# Negative case: `duplicate-yaml-key` and `invalid-yaml-syntax` collapsed into one + +**Task**: T047 · **Supports**: FR-023 (discharged at T071) +**Observed against**: `99ba8d2500eaf37625ea164f66b4a17870e40dad` plus Phase D's own +uncommitted work; the mutation under test was the only additional change in the tree. +**Tools**: Bun 1.3.14, TypeScript 6.0.3, `yaml@2.9.0` +**Command**: `bun test test/descriptor-read.test.ts`, run from +`packages/adapters/catalog-backstage/` +**Permanent automated case**: `packages/adapters/catalog-backstage/test/descriptor-read.test.ts` + +`data-model.md` §3 types `parseOutcome` as three values and §8 maps two of them to +**different** trigger classes. `atomic-fail-closed.md` §4 records that +`invalid-yaml-syntax` was added specifically because "the original eleven triggers had +no corresponding `AtomicFailureRecord.triggerClass` for the non-duplicate-key case." + +`research.md` R8 fixes the mechanism: the `yaml` package's `uniqueKeys` option defaults +to `true`, so a duplicate mapping key at any level is already reported without +configuration. This module never passes `uniqueKeys: false` and never writes a bespoke +duplicate-key walker — asserted by a test that scans the module's own comment-stripped +source for the string `uniqueKeys` and requires its absence. + +--- + +## Case 1 — the two outcomes collapsed + +Input: [`case-1-outcomes-collapsed.patch`](./case-1-outcomes-collapsed.patch) · +Output: [`case-1-outcomes-collapsed.observed.txt`](./case-1-outcomes-collapsed.observed.txt) + +The `firstError.code === DUPLICATE_KEY_CODE` discrimination was replaced with a constant +`'invalid-yaml-syntax'`, so both YAML failure modes report one outcome. Five tests fail: + +``` +(fail) T047 — the two failure outcomes are distinct > a repeated top-level key is `duplicate-yaml-key` +Expected: "duplicate-yaml-key" +Received: "yaml-parse-error" + +(fail) T047 — the two failure outcomes are distinct > a repeated nested key is also `duplicate-yaml-key` +Expected: "duplicate-yaml-key" +Received: "yaml-parse-error" + +(fail) T047 — the two failure outcomes are distinct > the two reasons, and the two trigger classes, are different values +``` + +The third failure is the one that states the property directly: with the discrimination +gone, the two reasons and the two trigger classes are no longer different values. + +--- + +## What the emitted strings are, restored + +Read from `test/descriptor-read.test.ts`, which is the permanent case: + +| Input | `parseOutcome` | reason | trigger class | +|---|---|---|---| +| `kind: Component` / `kind: API` | `duplicate-yaml-key` | `duplicate-yaml-key` | `duplicate-yaml-key` | +| `metadata:` with a repeated `name` | `duplicate-yaml-key` | `duplicate-yaml-key` | `duplicate-yaml-key` | +| `kind: "Component` (unterminated) | `yaml-parse-error` | `invalid-yaml-syntax` | `invalid-yaml-syntax` | +| `kind: [unterminated` | `yaml-parse-error` | `invalid-yaml-syntax` | `invalid-yaml-syntax` | + +The library's own error code appears in the emitted detail (`DUPLICATE_KEY: Map keys +must be unique at line 2, column 1`), which is what the discrimination reads. + +## The trap this case also records + +A duplicate key **still resolves to a value**: `yaml` reports `DUPLICATE_KEY` *and* +resolves the mapping last-wins. An implementation that read `doc.toJSON()` without +checking `doc.errors` would obtain a plausible-looking descriptor and never notice. +`test/descriptor-read.test.ts` asserts that the reported outcome is the error and that +`raw`/`rawKind` are left `undefined` in that case. + +## Restored + +[`restored.observed.txt`](./restored.observed.txt) — all 18 tests pass, 0 fail. + +## Standing constraints + +ADR-0014 **rung 1 only**. diff --git a/specs/010-catalog-backstage/evidence/negative-cases/yaml-read/case-1-outcomes-collapsed.observed.txt b/specs/010-catalog-backstage/evidence/negative-cases/yaml-read/case-1-outcomes-collapsed.observed.txt new file mode 100644 index 00000000..5aafd6ac --- /dev/null +++ b/specs/010-catalog-backstage/evidence/negative-cases/yaml-read/case-1-outcomes-collapsed.observed.txt @@ -0,0 +1,88 @@ +bun test v1.3.14 (0d9b296a) + +test/descriptor-read.test.ts: +(pass) T047 — a well-formed descriptor parses > the outcome is `parsed` and the raw fields are carried [4.85ms] +(pass) T047 — a well-formed descriptor parses > a document is addressed by (sourcePath, documentIndexInFile) [0.26ms] +49 | }); +50 | +51 | describe('T047 — the two failure outcomes are distinct', () => { +52 | test('a repeated top-level key is `duplicate-yaml-key`', () => { +53 | const document = only('kind: Component\nkind: API\n'); +54 | expect(document.parseOutcome).toBe('duplicate-yaml-key'); + ^ +error: expect(received).toBe(expected) + +Expected: "duplicate-yaml-key" +Received: "yaml-parse-error" + + at (/Users/markbeacom/github/mbeacom/copilot-worktrees/adrkit/mbeacom-symmetrical-disco/packages/adapters/catalog-backstage/test/descriptor-read.test.ts:54:35) +(fail) T047 — the two failure outcomes are distinct > a repeated top-level key is `duplicate-yaml-key` [0.36ms] +58 | }); +59 | +60 | test('a repeated nested key is also `duplicate-yaml-key`', () => { +61 | // R8 relies on the library reporting a duplicate "at any level". +62 | const document = only('metadata:\n name: a\n name: b\n'); +63 | expect(document.parseOutcome).toBe('duplicate-yaml-key'); + ^ +error: expect(received).toBe(expected) + +Expected: "duplicate-yaml-key" +Received: "yaml-parse-error" + + at (/Users/markbeacom/github/mbeacom/copilot-worktrees/adrkit/mbeacom-symmetrical-disco/packages/adapters/catalog-backstage/test/descriptor-read.test.ts:63:35) +(fail) T047 — the two failure outcomes are distinct > a repeated nested key is also `duplicate-yaml-key` [0.19ms] +(pass) T047 — the two failure outcomes are distinct > an unterminated quoted scalar is `invalid-yaml-syntax`, not a duplicate [0.20ms] +(pass) T047 — the two failure outcomes are distinct > a malformed flow collection is `invalid-yaml-syntax` [0.76ms] +78 | }); +79 | +80 | test('the two reasons, and the two trigger classes, are different values', () => { +81 | const duplicate = only('kind: Component\nkind: API\n'); +82 | const syntax = only('kind: "Component\n'); +83 | expect(duplicate.rejection?.reason).not.toBe(syntax.rejection?.reason); + ^ +error: expect(received).not.toBe(expected) + +Expected: not "invalid-yaml-syntax" + + at (/Users/markbeacom/github/mbeacom/copilot-worktrees/adrkit/mbeacom-symmetrical-disco/packages/adapters/catalog-backstage/test/descriptor-read.test.ts:83:45) +(fail) T047 — the two failure outcomes are distinct > the two reasons, and the two trigger classes, are different values [0.24ms] +86 | }); +87 | +88 | test('a duplicate key in the second document does not contaminate the first', () => { +89 | const documents = readDescriptorDocuments('multi.yaml', '---\nkind: A\n---\nkind: B\nkind: C\n'); +90 | expect(documents[0]?.parseOutcome).toBe('parsed'); +91 | expect(documents[1]?.parseOutcome).toBe('duplicate-yaml-key'); + ^ +error: expect(received).toBe(expected) + +Expected: "duplicate-yaml-key" +Received: "yaml-parse-error" + + at (/Users/markbeacom/github/mbeacom/copilot-worktrees/adrkit/mbeacom-symmetrical-disco/packages/adapters/catalog-backstage/test/descriptor-read.test.ts:91:40) +(fail) T047 — the two failure outcomes are distinct > a duplicate key in the second document does not contaminate the first [0.19ms] + 96 | test('the reported outcome is the error, never the last-wins value', () => { + 97 | // `yaml` resolves `kind: Component` / `kind: API` last-wins *and* records + 98 | // DUPLICATE_KEY. An implementation reading `toJSON()` without checking + 99 | // `errors` would see a plausible descriptor and never notice. +100 | const document = only('kind: Component\nkind: API\n'); +101 | expect(document.parseOutcome).toBe('duplicate-yaml-key'); + ^ +error: expect(received).toBe(expected) + +Expected: "duplicate-yaml-key" +Received: "yaml-parse-error" + + at (/Users/markbeacom/github/mbeacom/copilot-worktrees/adrkit/mbeacom-symmetrical-disco/packages/adapters/catalog-backstage/test/descriptor-read.test.ts:101:35) +(fail) T047 — a duplicate key still resolves to a value, and that is the trap > the reported outcome is the error, never the last-wins value [0.14ms] +(pass) T047 — `uniqueKeys` is left at its library default > the module never names `uniqueKeys` [0.61ms] +(pass) T047 — reading the annotation node > a present string scalar is reported present, with a string value [0.50ms] +(pass) T047 — reading the annotation node > a YAML sequence arrives as an array, so a `typeof` check can reject it [0.43ms] +(pass) T047 — reading the annotation node > an absent annotation is reported absent [0.21ms] +(pass) T047 — reading the annotation node > presence is an explicit discriminant, not `value !== undefined` [0.28ms] + + 9 pass + 5 fail + 39 expect() calls +Ran 14 tests across 1 file. [30.00ms] + +exit 1 diff --git a/specs/010-catalog-backstage/evidence/negative-cases/yaml-read/case-1-outcomes-collapsed.patch b/specs/010-catalog-backstage/evidence/negative-cases/yaml-read/case-1-outcomes-collapsed.patch new file mode 100644 index 00000000..f40eb84a --- /dev/null +++ b/specs/010-catalog-backstage/evidence/negative-cases/yaml-read/case-1-outcomes-collapsed.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/yaml-read/restored.observed.txt b/specs/010-catalog-backstage/evidence/negative-cases/yaml-read/restored.observed.txt new file mode 100644 index 00000000..fcec7389 --- /dev/null +++ b/specs/010-catalog-backstage/evidence/negative-cases/yaml-read/restored.observed.txt @@ -0,0 +1,24 @@ +bun test v1.3.14 (0d9b296a) + +test/descriptor-read.test.ts: +(pass) T047 — a well-formed descriptor parses > the outcome is `parsed` and the raw fields are carried [5.12ms] +(pass) T047 — a well-formed descriptor parses > a document is addressed by (sourcePath, documentIndexInFile) [0.36ms] +(pass) T047 — the two failure outcomes are distinct > a repeated top-level key is `duplicate-yaml-key` [0.48ms] +(pass) T047 — the two failure outcomes are distinct > a repeated nested key is also `duplicate-yaml-key` [0.23ms] +(pass) T047 — the two failure outcomes are distinct > an unterminated quoted scalar is `invalid-yaml-syntax`, not a duplicate [0.32ms] +(pass) T047 — the two failure outcomes are distinct > a malformed flow collection is `invalid-yaml-syntax` [1.05ms] +(pass) T047 — the two failure outcomes are distinct > the two reasons, and the two trigger classes, are different values [0.29ms] +(pass) T047 — the two failure outcomes are distinct > a duplicate key in the second document does not contaminate the first [0.36ms] +(pass) T047 — a duplicate key still resolves to a value, and that is the trap > the reported outcome is the error, never the last-wins value [0.12ms] +(pass) T047 — `uniqueKeys` is left at its library default > the module never names `uniqueKeys` [0.61ms] +(pass) T047 — reading the annotation node > a present string scalar is reported present, with a string value [0.39ms] +(pass) T047 — reading the annotation node > a YAML sequence arrives as an array, so a `typeof` check can reject it [0.32ms] +(pass) T047 — reading the annotation node > an absent annotation is reported absent [0.15ms] +(pass) T047 — reading the annotation node > presence is an explicit discriminant, not `value !== undefined` [0.26ms] + + 14 pass + 0 fail + 47 expect() calls +Ran 14 tests across 1 file. [30.00ms] + +exit 0 diff --git a/specs/010-catalog-backstage/tasks.md b/specs/010-catalog-backstage/tasks.md index 83543152..b0085526 100644 --- a/specs/010-catalog-backstage/tasks.md +++ b/specs/010-catalog-backstage/tasks.md @@ -536,7 +536,7 @@ slices — **D2** (input boundary), **D1a** (admissibility and identity), **D1b* ### D2 — Input boundary (`[US2]`) -- [ ] T038 [US2] Implement the closed input-manifest schema at +- [X] T038 [US2] Implement the closed input-manifest schema at `/src/manifest/schema.ts`: any unrecognized top-level field is rejected rather than ignored. Barrier: BEFORE @@ -544,7 +544,7 @@ slices — **D2** (input boundary), **D1a** (admissibility and identity), **D1b* Depends: T001 Contract: `input-manifest.md` §1 -- [ ] T039 [US2] Enforce single-repository binding: one manifest describes exactly one +- [X] T039 [US2] Enforce single-repository binding: one manifest describes exactly one repository, and a manifest naming more than one is rejected. Files: `/src/manifest/schema.ts`, `/test/manifest-single-repo.test.ts`. Barrier: BEFORE @@ -552,7 +552,7 @@ slices — **D2** (input boundary), **D1a** (admissibility and identity), **D1b* Depends: T038 Contract: `input-manifest.md` §1 -- [ ] T040 [US2] Implement the three version and capability rejections — +- [X] T040 [US2] Implement the three version and capability rejections — `unsupported-manifest-version`, `unsupported-snapshot-version`, `unsupported-capability` — each **observed failing** with its own exact reason, then restored and observed passing. @@ -564,7 +564,7 @@ slices — **D2** (input boundary), **D1a** (admissibility and identity), **D1b* Depends: T038 Contract: `input-manifest.md` §2 -- [ ] T041 [US2] Obtain repository identity and revision through separate git tooling +- [X] T041 [US2] Obtain repository identity and revision through separate git tooling at `/src/repository/identity.ts` — **never** from a descriptor annotation or any content under the repository being described. **Fixture constraint (`input-manifest.md` §3.1):** the mismatch fixture must be a @@ -578,7 +578,7 @@ slices — **D2** (input boundary), **D1a** (admissibility and identity), **D1b* Depends: T038 Contract: `input-manifest.md` §3, §3.1 -- [ ] T042 [US2] Enforce **exact string equality** on repository identity and revision; +- [X] T042 [US2] Enforce **exact string equality** on repository identity and revision; a partial, prefix, or normalized match aborts the operation. Observe a near-miss revision failing; record the reason; restore; observe the pass. Files: `/src/repository/identity.ts`, @@ -589,7 +589,7 @@ slices — **D2** (input boundary), **D1a** (admissibility and identity), **D1b* Depends: T041 Contract: `input-manifest.md` §3 -- [ ] T043 [P] [US2] Verify every declared per-source digest **before any entity is +- [X] T043 [P] [US2] Verify every declared per-source digest **before any entity is processed**; a mismatch or a missing source yields `incomplete-required-source`. Observe it failing; record the reason; restore; observe the pass. Files: `/src/manifest/digests.ts`, @@ -600,7 +600,7 @@ slices — **D2** (input boundary), **D1a** (admissibility and identity), **D1b* Depends: T038 Contract: `input-manifest.md` §4 -- [ ] T044 [P] [US2] Implement **two-stage** path validation at +- [X] T044 [P] [US2] Implement **two-stage** path validation at `/src/manifest/paths.ts`: a lexical rejection stage, then a confined realpath stage. Both stages observed failing independently, each with its own reason; restored; observed passing. @@ -611,7 +611,7 @@ slices — **D2** (input boundary), **D1a** (admissibility and identity), **D1b* Depends: T038 Contract: `input-manifest.md` §4.1 -- [ ] T045 [P] [US2] Close the input boundary: assert the adapter never follows +- [X] T045 [P] [US2] Close the input boundary: assert the adapter never follows `Location.spec.targets`, never invokes a Backstage processor, plugin, or ingestion path, and never performs recursive walking or glob discovery to find descriptors. Include the `Location` worked example as a test. @@ -622,7 +622,7 @@ slices — **D2** (input boundary), **D1a** (admissibility and identity), **D1b* Depends: T038 Contract: `input-manifest.md` §5, §6 -- [ ] T046 [US2] SC-008 close-out: a consolidated test asserting every input reaching +- [X] T046 [US2] SC-008 close-out: a consolidated test asserting every input reaching the adapter arrived through the declared manifest and through no other route. Files: `/test/sc-008.test.ts`. Barrier: BEFORE @@ -631,7 +631,7 @@ slices — **D2** (input boundary), **D1a** (admissibility and identity), **D1b* ### D1a — Admissibility and identity (`[US3]`) -- [ ] T047 [P] [US3] Implement descriptor reading at `/src/descriptor/read.ts` +- [X] T047 [P] [US3] Implement descriptor reading at `/src/descriptor/read.ts` using `yaml`'s `parseDocument` with `uniqueKeys` left at its default `true`. Observe `duplicate-yaml-key` and `invalid-yaml-syntax` emerging as **two distinct outcomes**, never collapsed into one; record both reason strings; restore; @@ -642,7 +642,7 @@ slices — **D2** (input boundary), **D1a** (admissibility and identity), **D1b* Discharges: none — supports FR-023, which is discharged at T071 Depends: T001 -- [ ] T048 [US3] Enforce that admissibility is evaluated **before** canonicalization, +- [X] T048 [US3] Enforce that admissibility is evaluated **before** canonicalization, structurally rather than by convention. Files: `/src/admissibility/index.ts`, `/test/admissibility-ordering.test.ts`. @@ -651,7 +651,7 @@ slices — **D2** (input boundary), **D1a** (admissibility and identity), **D1b* Depends: T047 Contract: `admissibility.md` §4, §4.1 -- [ ] T049 [US3] Implement the **four** admissibility field validators at +- [X] T049 [US3] Implement the **four** admissibility field validators at `/src/admissibility/validators.ts`, each **separately attributed** so a rejection names which validator rejected. Observe each of the four failing independently; record four distinct reason strings; restore; observe the pass. @@ -663,7 +663,7 @@ slices — **D2** (input boundary), **D1a** (admissibility and identity), **D1b* Depends: T048 Contract: `admissibility.md` §2, §2.1 -- [ ] T050 [US3] Implement the separator rule: an identity string with **two or more** +- [X] T050 [US3] Implement the separator rule: an identity string with **two or more** separators is rejected; one with **no** separator is evaluated by the suffix predicate alone, so a bare `v1` passes. Observe both branches. Files: `/src/admissibility/separator.ts`, @@ -673,7 +673,7 @@ slices — **D2** (input boundary), **D1a** (admissibility and identity), **D1b* Depends: T049 Contract: `admissibility.md` §3 -- [ ] T051 [US3] Implement `inadmissible-descriptor` classification and its failure +- [X] T051 [US3] Implement `inadmissible-descriptor` classification and its failure semantics. Files: `/src/admissibility/classify.ts`, `/test/admissibility-classify.test.ts`. @@ -682,7 +682,7 @@ slices — **D2** (input boundary), **D1a** (admissibility and identity), **D1b* Depends: T049, T050 Contract: `admissibility.md` §5 -- [ ] T052 [US3] Ensure every inadmissibility record identifies **all three** of: +- [X] T052 [US3] Ensure every inadmissibility record identifies **all three** of: the descriptor path, the failing field, and the rejecting validator — and is distinguishable from a `duplicate-canonical-id` record. Files: `/src/admissibility/classify.ts`, @@ -692,7 +692,7 @@ slices — **D2** (input boundary), **D1a** (admissibility and identity), **D1b* Depends: T051 Contract: `admissibility.md` §5, §5.1 -- [ ] T053 [US3] Enforce that **no inadmissible descriptor participates in a +- [X] T053 [US3] Enforce that **no inadmissible descriptor participates in a uniqueness comparison** — duplicate detection is not a validity test and must never be reached by an inadmissible input. Files: `/src/admissibility/index.ts`, @@ -702,7 +702,7 @@ slices — **D2** (input boundary), **D1a** (admissibility and identity), **D1b* Depends: T052 Contract: `admissibility.md` §6 -- [ ] T054 [US3] **Observed failing — permanent negative case.** Construct a descriptor +- [X] T054 [US3] **Observed failing — permanent negative case.** Construct a descriptor that is simultaneously **inadmissible and canonically unique**. Observe it produce `inadmissible-descriptor` and **not** `duplicate-canonical-id`; record both the emitted reason and the absence of the wrong one; restore; observe the @@ -714,7 +714,7 @@ slices — **D2** (input boundary), **D1a** (admissibility and identity), **D1b* Discharges: FR-021 Depends: T053 -- [ ] T055 [US3] Implement **two-step** canonicalization at +- [X] T055 [US3] Implement **two-step** canonicalization at `/src/identity/canonicalize.ts`: default-namespace substitution first, then lowercase the **entire** identity string — not merely the name component. Barrier: BEFORE @@ -722,7 +722,7 @@ slices — **D2** (input boundary), **D1a** (admissibility and identity), **D1b* Depends: T054 Contract: `entity-identity.md` §1 -- [ ] T056 [US3] SC-004 close-out: a consolidated test asserting inadmissibility is +- [X] T056 [US3] SC-004 close-out: a consolidated test asserting inadmissibility is decided before canonical identity is computed, for every admissibility failure mode. Files: `/test/sc-004.test.ts`. @@ -733,7 +733,7 @@ slices — **D2** (input boundary), **D1a** (admissibility and identity), **D1b* ### D1b — Ownership and glob (`[US4]`) -- [ ] T057 [P] [US4] Derive ownership from the `adrkit.io/owned-paths` annotation +- [X] T057 [P] [US4] Derive ownership from the `adrkit.io/owned-paths` annotation **alone** at `/src/ownership/derive.ts`. No inference from the descriptor's file location, its parent directory, the repository root, or any other signal. @@ -754,7 +754,7 @@ slices — **D2** (input boundary), **D1a** (admissibility and identity), **D1b* Depends: T057 Contract: `owned-paths-annotation.md` §1 -- [ ] T059 [US4] **Observed failing — permanent negative case.** Step 2's string-scalar +- [X] T059 [US4] **Observed failing — permanent negative case.** Step 2's string-scalar check runs against the **raw YAML node**, before `JSON.parse`. Therefore the annotation value `["[]"]` — a YAML sequence, not a string — must yield `annotation-value-not-a-string`, and must **never** be silently coerced into @@ -767,7 +767,7 @@ slices — **D2** (input boundary), **D1a** (admissibility and identity), **D1b* Depends: T058 Contract: `owned-paths-annotation.md` §1 -- [ ] T060 [US4] Keep the **three** ownership states distinct and never conflated, and +- [X] T060 [US4] Keep the **three** ownership states distinct and never conflated, and decide `explicit-empty` on the **decoded** value — so `'[]'`, `'[ ]'`, and `'[\n]'` all qualify — never by raw-string equality. Files: `/src/ownership/states.ts`, @@ -777,7 +777,7 @@ slices — **D2** (input boundary), **D1a** (admissibility and identity), **D1b* Depends: T059 Contract: `owned-paths-annotation.md` §1 -- [ ] T061 [US4] SC-005 close-out: a consolidated test over the three ownership states. +- [X] T061 [US4] SC-005 close-out: a consolidated test over the three ownership states. Files: `/test/sc-005.test.ts`. Barrier: BEFORE Discharges: SC-005 @@ -790,7 +790,7 @@ slices — **D2** (input boundary), **D1a** (admissibility and identity), **D1b* Discharges: SC-006 Depends: T060, T061 -- [ ] T063 [P] [US4] Implement the restricted glob dialect and freeze the engine and +- [X] T063 [P] [US4] Implement the restricted glob dialect and freeze the engine and its options at `/src/glob/dialect.ts`. The `picomatch` version must be **read at runtime from the resolved dependency**, never transcribed into a literal — a transcribed version silently goes stale. @@ -799,14 +799,14 @@ slices — **D2** (input boundary), **D1a** (admissibility and identity), **D1b* Depends: T001 Contract: `glob-dialect.md` §1, §6 -- [ ] T064 [US4] Implement the **fifteen** ordered rules with first-match-wins +- [X] T064 [US4] Implement the **fifteen** ordered rules with first-match-wins semantics at `/src/glob/validate.ts`. Barrier: BEFORE Discharges: FR-030 Depends: T063 Contract: `glob-dialect.md` §3 -- [ ] T065 [US4] **Observed failing for rules 1–14 only.** For each of rules 1 through +- [X] T065 [US4] **Observed failing for rules 1–14 only.** For each of rules 1 through 14, supply a pattern that violates *that* rule and no earlier one; observe the rule fire; record its exact rejection reason; restore; observe the pass. **Rule 15 (`invalid-glob-compile-failure`) is a defensive backstop that its own @@ -820,7 +820,7 @@ slices — **D2** (input boundary), **D1a** (admissibility and identity), **D1b* Depends: T064 Contract: `glob-dialect.md` §3 -- [ ] T066 [US4] Assert rule-specific rejection reasons hold when a **mixed batch** of +- [X] T066 [US4] Assert rule-specific rejection reasons hold when a **mixed batch** of patterns is validated, each pattern evaluated in isolation so no pattern's outcome influences another's. Files: `/test/glob-mixed-batch.test.ts`. @@ -829,7 +829,7 @@ slices — **D2** (input boundary), **D1a** (admissibility and identity), **D1b* Depends: T065 Contract: `glob-dialect.md` §3 -- [ ] T067 [US4] Compile each pattern **once per run** and reuse the compiled matcher, +- [X] T067 [US4] Compile each pattern **once per run** and reuse the compiled matcher, so validation and matching cannot diverge. Files: `/src/glob/dialect.ts`, `/test/glob-compile-once.test.ts`. Barrier: BEFORE @@ -837,7 +837,7 @@ slices — **D2** (input boundary), **D1a** (admissibility and identity), **D1b* Depends: T066 Contract: `glob-dialect.md` §6 -- [ ] T068 [US4] Sort `derivedPaths` with `compareCodeUnits` +- [X] T068 [US4] Sort `derivedPaths` with `compareCodeUnits` (`packages/core/src/ordering/index.ts:12`) and deduplicate. Files: `/src/glob/order.ts`, `/test/glob-order.test.ts`. Barrier: BEFORE