fix: baseline invalidation registry contracts
This commit is contained in:
@@ -4,6 +4,12 @@ import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
diffRegistrySnapshots,
|
||||
validateBreakingEvidence,
|
||||
verifyRegistryBaselineApproval,
|
||||
} from "../../scripts/lib/registry-compatibility.ts";
|
||||
|
||||
type RegistryDefinition = Readonly<{
|
||||
registryId: string;
|
||||
owner: string;
|
||||
@@ -15,6 +21,10 @@ type RegistryDefinition = Readonly<{
|
||||
rowKeyFields?: string[];
|
||||
uniqueFields?: string[];
|
||||
uniqueFieldSets?: string[][];
|
||||
snapshotProjection?: {
|
||||
singletonRowKey: string;
|
||||
canonicalArrayKeyFields: Record<string, string[]>;
|
||||
};
|
||||
consumers?: Array<{ path: string; token: string }>;
|
||||
breakingFields?: string[];
|
||||
}>;
|
||||
@@ -65,17 +75,25 @@ describe("registry governance manifest", () => {
|
||||
"namespace.namespaceVersion",
|
||||
],
|
||||
],
|
||||
snapshotProjection: {
|
||||
singletonRowKey: "invalidation-graph",
|
||||
canonicalArrayKeyFields: {
|
||||
topics: ["$value"],
|
||||
namespaces: ["namespaceId", "namespaceVersion"],
|
||||
edges: [
|
||||
"topicId",
|
||||
"namespace.namespaceId",
|
||||
"namespace.namespaceVersion",
|
||||
],
|
||||
},
|
||||
},
|
||||
consumers: [
|
||||
{
|
||||
path: "src/bootstrap/runtime-adapters.ts",
|
||||
token: "indexInvalidationRegistry(INVALIDATION_REGISTRY)",
|
||||
},
|
||||
],
|
||||
breakingFields: [
|
||||
"topicId",
|
||||
"namespace.namespaceId",
|
||||
"namespace.namespaceVersion",
|
||||
],
|
||||
breakingFields: ["topics", "namespaces", "edges"],
|
||||
});
|
||||
expect(byId.get("FE-REG-QUERY-INVALIDATION-TOPIC-VERSION")).toMatchObject({
|
||||
path: "src/features/installed-feature-contracts.ts",
|
||||
@@ -113,18 +131,37 @@ describe("registry governance manifest", () => {
|
||||
|
||||
expect(result.status, result.stderr).toBe(0);
|
||||
const report = JSON.parse(await readFile(artifact, "utf8")) as {
|
||||
registries: Array<{ registryId: string; rows: Record<string, unknown> }>;
|
||||
registries: Array<{
|
||||
registryId: string;
|
||||
rows: Record<string, Record<string, unknown>>;
|
||||
}>;
|
||||
};
|
||||
expect(
|
||||
report.registries.find(
|
||||
(registry) => registry.registryId === "FIXTURE-INVALIDATION-EDGES",
|
||||
)?.rows,
|
||||
).toHaveProperty('["qinv.fixture.changed","orders",1]');
|
||||
expect(
|
||||
report.registries.find(
|
||||
(registry) => registry.registryId === "FIXTURE-INVALIDATION-EDGES",
|
||||
)?.rows,
|
||||
).toHaveProperty('["orders","qinv.fixture.changed",1]');
|
||||
).toEqual({
|
||||
"invalidation-graph": {
|
||||
topics: ["orders", "qinv.fixture.changed"],
|
||||
namespaces: [
|
||||
{ namespaceId: "orders", namespaceVersion: 1 },
|
||||
{ namespaceId: "qinv.fixture.changed", namespaceVersion: 1 },
|
||||
],
|
||||
edges: [
|
||||
{
|
||||
topicId: "orders",
|
||||
namespace: {
|
||||
namespaceId: "qinv.fixture.changed",
|
||||
namespaceVersion: 1,
|
||||
},
|
||||
},
|
||||
{
|
||||
topicId: "qinv.fixture.changed",
|
||||
namespace: { namespaceId: "orders", namespaceVersion: 1 },
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
expect(
|
||||
report.registries.find(
|
||||
(registry) => registry.registryId === "FIXTURE-INVALIDATION-VERSIONS",
|
||||
@@ -135,6 +172,175 @@ describe("registry governance manifest", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("requires explicit evidence for post-baseline graph and version drift", async () => {
|
||||
const directory = await mkdtemp(
|
||||
path.join(tmpdir(), "registry-governance-tamper-"),
|
||||
);
|
||||
const artifact = path.join(directory, "registries.json");
|
||||
try {
|
||||
const result = spawnSync(
|
||||
process.execPath,
|
||||
[
|
||||
"scripts/check-registries.ts",
|
||||
"--governance",
|
||||
"tests/fixtures/registry/invalidation/governance.json",
|
||||
"--artifact",
|
||||
artifact,
|
||||
"--no-baseline",
|
||||
],
|
||||
{ encoding: "utf8" },
|
||||
);
|
||||
expect(result.status, result.stderr).toBe(0);
|
||||
|
||||
const report = JSON.parse(await readFile(artifact, "utf8")) as {
|
||||
registries: Array<{
|
||||
registryId: string;
|
||||
contract: Record<string, unknown>;
|
||||
rows: Record<string, Record<string, unknown>>;
|
||||
}>;
|
||||
};
|
||||
const baseline = { schemaVersion: 2, registries: report.registries };
|
||||
expect(
|
||||
baseline.registries.find(
|
||||
(registry) => registry.registryId === "FIXTURE-INVALIDATION-EDGES",
|
||||
)?.rows["invalidation-graph"],
|
||||
).toBeDefined();
|
||||
const tamperCases = [
|
||||
{
|
||||
name: "topic",
|
||||
registryId: "FIXTURE-INVALIDATION-EDGES",
|
||||
field: "topics",
|
||||
apply(snapshot: typeof baseline) {
|
||||
const graph = snapshot.registries.find(
|
||||
(registry) => registry.registryId === this.registryId,
|
||||
)?.rows["invalidation-graph"];
|
||||
(graph?.topics as string[])[0] = "tampered-topic";
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "namespace",
|
||||
registryId: "FIXTURE-INVALIDATION-EDGES",
|
||||
field: "namespaces",
|
||||
apply(snapshot: typeof baseline) {
|
||||
const graph = snapshot.registries.find(
|
||||
(registry) => registry.registryId === this.registryId,
|
||||
)?.rows["invalidation-graph"];
|
||||
const namespaces = graph?.namespaces as Array<{
|
||||
namespaceId: string;
|
||||
}>;
|
||||
namespaces[0]!.namespaceId = "tampered-namespace";
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "edge",
|
||||
registryId: "FIXTURE-INVALIDATION-EDGES",
|
||||
field: "edges",
|
||||
apply(snapshot: typeof baseline) {
|
||||
const graph = snapshot.registries.find(
|
||||
(registry) => registry.registryId === this.registryId,
|
||||
)?.rows["invalidation-graph"];
|
||||
const edges = graph?.edges as Array<{ topicId: string }>;
|
||||
edges[0]!.topicId = "tampered-edge";
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "version",
|
||||
registryId: "FIXTURE-INVALIDATION-VERSIONS",
|
||||
field: "topicVersion",
|
||||
apply(snapshot: typeof baseline) {
|
||||
const versions = snapshot.registries.find(
|
||||
(registry) => registry.registryId === this.registryId,
|
||||
)?.rows;
|
||||
const firstVersion = versions?.[Object.keys(versions)[0] ?? ""];
|
||||
if (!firstVersion) throw new Error("version fixture row missing");
|
||||
firstVersion.topicVersion = 2;
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
for (const tamperCase of tamperCases) {
|
||||
const current = structuredClone(baseline);
|
||||
tamperCase.apply(current);
|
||||
const diff = diffRegistrySnapshots(baseline, current);
|
||||
expect(diff.impact, tamperCase.name).toBe("breaking");
|
||||
expect(
|
||||
diff.changes.some((change) => change.kind === "registry-added"),
|
||||
tamperCase.name,
|
||||
).toBe(false);
|
||||
const breakingChanges = diff.changes.filter(
|
||||
(change) => change.impact === "breaking",
|
||||
);
|
||||
expect(breakingChanges, tamperCase.name).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
registryId: tamperCase.registryId,
|
||||
field: tamperCase.field,
|
||||
}),
|
||||
]),
|
||||
);
|
||||
expect(
|
||||
validateBreakingEvidence(diff, { changes: [] }).passed,
|
||||
tamperCase.name,
|
||||
).toBe(false);
|
||||
expect(
|
||||
validateBreakingEvidence(diff, {
|
||||
changes: breakingChanges.map((change) => ({
|
||||
changeId: change.changeId,
|
||||
versionBump: "2",
|
||||
migration: `migrate ${tamperCase.name}`,
|
||||
compatibilityWindow: "one release",
|
||||
rollback: `restore ${tamperCase.name} baseline`,
|
||||
owner: "fixture-owner",
|
||||
})),
|
||||
}).passed,
|
||||
tamperCase.name,
|
||||
).toBe(true);
|
||||
}
|
||||
} finally {
|
||||
await rm(directory, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("pins the full invalidation graph and versions in the approved repository baseline", async () => {
|
||||
const baseline = JSON.parse(
|
||||
await readFile("config/contracts/registry-baseline.json", "utf8"),
|
||||
) as {
|
||||
schemaVersion: number;
|
||||
registries: Array<{
|
||||
registryId: string;
|
||||
rows: Record<string, Record<string, unknown>>;
|
||||
}>;
|
||||
};
|
||||
const approval = JSON.parse(
|
||||
await readFile(
|
||||
"config/contracts/registry-baseline.approval.json",
|
||||
"utf8",
|
||||
),
|
||||
) as Record<string, unknown>;
|
||||
|
||||
expect(verifyRegistryBaselineApproval(baseline, approval).passed).toBe(true);
|
||||
expect(baseline.registries).toHaveLength(11);
|
||||
const graph = baseline.registries.find(
|
||||
(registry) => registry.registryId === "FE-REG-QUERY-INVALIDATION",
|
||||
);
|
||||
const versions = baseline.registries.find(
|
||||
(registry) =>
|
||||
registry.registryId ===
|
||||
"FE-REG-QUERY-INVALIDATION-TOPIC-VERSION",
|
||||
);
|
||||
expect(graph?.rows["invalidation-graph"]).toMatchObject({
|
||||
topics: expect.any(Array),
|
||||
namespaces: expect.any(Array),
|
||||
edges: expect.any(Array),
|
||||
});
|
||||
expect(versions?.rows).toBeDefined();
|
||||
expect(approval).toMatchObject({
|
||||
owner: "frontend-platform",
|
||||
reason:
|
||||
"Baseline canonical invalidation graph and topic-version contracts after FE-REG-QUERY retirement",
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects duplicate composite invalidation edges after projection", async () => {
|
||||
const directory = await mkdtemp(
|
||||
path.join(tmpdir(), "registry-governance-duplicate-"),
|
||||
|
||||
Reference in New Issue
Block a user