377 lines
12 KiB
TypeScript
377 lines
12 KiB
TypeScript
import { spawnSync } from "node:child_process";
|
|
import { mkdtemp, readFile, rm } from "node:fs/promises";
|
|
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;
|
|
requiredFields: string[];
|
|
fieldTypes: Record<string, string>;
|
|
path: string;
|
|
exportName: string;
|
|
rowsPath?: string;
|
|
rowKeyFields?: string[];
|
|
uniqueFields?: string[];
|
|
uniqueFieldSets?: string[][];
|
|
snapshotProjection?: {
|
|
singletonRowKey: string;
|
|
canonicalArrayKeyFields: Record<string, string[]>;
|
|
};
|
|
consumers?: Array<{ path: string; token: string }>;
|
|
breakingFields?: string[];
|
|
}>;
|
|
|
|
describe("registry governance manifest", () => {
|
|
it("declares eleven typed, single-owner executable registries", async () => {
|
|
const governance = JSON.parse(
|
|
await readFile("config/contracts/registry-governance.json", "utf8"),
|
|
);
|
|
const registries = governance.registries as RegistryDefinition[];
|
|
expect(governance.registries).toHaveLength(11);
|
|
expect(new Set(registries.map((entry) => entry.registryId)).size).toBe(
|
|
11,
|
|
);
|
|
expect(registries.every((entry) => entry.owner)).toBe(true);
|
|
expect(
|
|
registries.every(
|
|
(entry) =>
|
|
Array.isArray(entry.requiredFields) &&
|
|
entry.requiredFields.length > 0 &&
|
|
entry.fieldTypes &&
|
|
Object.keys(entry.fieldTypes).length > 0,
|
|
),
|
|
).toBe(true);
|
|
});
|
|
|
|
it("governs the installed invalidation graph and wire versions at their real exports", async () => {
|
|
const governance = JSON.parse(
|
|
await readFile("config/contracts/registry-governance.json", "utf8"),
|
|
) as { registries: RegistryDefinition[] };
|
|
const byId = new Map(
|
|
governance.registries.map((registry) => [registry.registryId, registry]),
|
|
);
|
|
|
|
expect(byId.get("FE-REG-QUERY-INVALIDATION")).toMatchObject({
|
|
path: "src/features/installed-feature-contracts.ts",
|
|
exportName: "INVALIDATION_REGISTRY",
|
|
rowsPath: "edges",
|
|
rowKeyFields: [
|
|
"topicId",
|
|
"namespace.namespaceId",
|
|
"namespace.namespaceVersion",
|
|
],
|
|
uniqueFieldSets: [
|
|
[
|
|
"topicId",
|
|
"namespace.namespaceId",
|
|
"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: ["topics", "namespaces", "edges"],
|
|
});
|
|
expect(byId.get("FE-REG-QUERY-INVALIDATION-TOPIC-VERSION")).toMatchObject({
|
|
path: "src/features/installed-feature-contracts.ts",
|
|
exportName: "INVALIDATION_TOPIC_VERSIONS",
|
|
rowKeyFields: ["topicId"],
|
|
uniqueFields: ["topicId"],
|
|
consumers: [
|
|
{
|
|
path: "src/bootstrap/runtime-adapters.ts",
|
|
token: "indexInvalidationTopicVersions(",
|
|
},
|
|
],
|
|
breakingFields: ["topicId", "topicVersion"],
|
|
});
|
|
});
|
|
|
|
it("projects graph and array exports into deterministic governed rows", async () => {
|
|
const directory = await mkdtemp(
|
|
path.join(tmpdir(), "registry-governance-projection-"),
|
|
);
|
|
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;
|
|
rows: Record<string, Record<string, unknown>>;
|
|
}>;
|
|
};
|
|
expect(
|
|
report.registries.find(
|
|
(registry) => registry.registryId === "FIXTURE-INVALIDATION-EDGES",
|
|
)?.rows,
|
|
).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",
|
|
)?.rows,
|
|
).toHaveProperty('["qinv.fixture.changed"]');
|
|
} finally {
|
|
await rm(directory, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
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-"),
|
|
);
|
|
const artifact = path.join(directory, "registries.json");
|
|
try {
|
|
const result = spawnSync(
|
|
process.execPath,
|
|
[
|
|
"scripts/check-registries.ts",
|
|
"--governance",
|
|
"tests/fixtures/registry/invalidation/duplicate-governance.json",
|
|
"--artifact",
|
|
artifact,
|
|
"--no-baseline",
|
|
],
|
|
{ encoding: "utf8" },
|
|
);
|
|
|
|
expect(result.status).toBe(1);
|
|
const report = JSON.parse(await readFile(artifact, "utf8")) as {
|
|
failures: string[];
|
|
};
|
|
expect(report.failures).toEqual([
|
|
expect.stringContaining(
|
|
"duplicates topicId+namespace.namespaceId+namespace.namespaceVersion",
|
|
),
|
|
]);
|
|
} finally {
|
|
await rm(directory, { recursive: true, force: true });
|
|
}
|
|
});
|
|
});
|