fix(catalog): digest provider projections

This commit is contained in:
morluto 2026-07-26 16:07:12 +00:00
parent 8ec9bb1bfe
commit c4cd0330e0
5 changed files with 43 additions and 4 deletions

View File

@ -671,7 +671,8 @@
"tools_sha256": "5a88bf598a1e0fe705761db6af92f1ceabaf026cb6efa425e87b9633f1d7a3de",
"prompts_sha256": "f6da3ad38a1000c11364d1710f122ac4990551f04efd8c53ffb2e819b5cf5707",
"resources_sha256": "339a984ea8cd8be5f234adf24395bf0295258d990492005cf6fe1f293d12cd46",
"combined_sha256": "861cd8618e793342daa8b149bc2944360d84b077fd19cf22f6e0a57b6b249c73"
"combined_sha256": "861cd8618e793342daa8b149bc2944360d84b077fd19cf22f6e0a57b6b249c73",
"providers_sha256": "918ccd6b8ae442cd5844dcfde55086f458176241c8d166c1e9dd0f242ccd2bb0"
}
}
}

View File

@ -1,6 +1,9 @@
import { createHash } from "node:crypto";
import { join } from "node:path";
import { pathToFileURL } from "node:url";
import canonicalize from "canonicalize";
const load = (root, path) => import(pathToFileURL(join(root, path)).href);
/** Sort tool/provider names alphabetically. */
@ -9,6 +12,14 @@ export const names = (contracts) =>
.map(({ name }) => name)
.sort((left, right) => left.localeCompare(right));
/** Return the stable digest for a generated catalog projection. */
export const digest = (value) => {
const encoded = canonicalize(value);
if (encoded === undefined)
throw new TypeError("Catalog projection is not canonical JSON");
return createHash("sha256").update(encoded).digest("hex");
};
/** Throw when two name lists diverge. */
export const assertSameNames = (label, actual, expected) => {
const actualSet = new Set(actual);

View File

@ -46,6 +46,9 @@ export interface ProductCatalog {
}
export function createProductCatalog(root: string): Promise<ProductCatalog>;
export function providerCatalogDigest(
providers: ProductCatalog["providers"],
): string;
export function createCliInventory(cli: unknown): {
readonly primary: readonly string[];
readonly aliases: readonly {

View File

@ -1,6 +1,6 @@
import { format } from "prettier";
import { assertSameNames, loadSources } from "./catalog-core.mjs";
import { assertSameNames, digest, loadSources } from "./catalog-core.mjs";
import {
createCliInventory,
cliCommandDescriptionIssues,
@ -15,6 +15,9 @@ export {
cliCommandOptionNames,
};
/** Return the stable digest for the source-derived provider projection. */
export const providerCatalogDigest = (providers) => digest(providers);
/** Project current runtime contracts into deterministic, machine-readable facts. */
export const createProductCatalog = async (root) => {
const sources = await loadSources(root);
@ -25,6 +28,7 @@ export const createProductCatalog = async (root) => {
sources.catalogIdentity.CLI_COMMAND_NAMES,
);
const tools = toolFamilyCatalog(sources);
const providers = providerCatalog(sources);
const metadata = sources.packageMetadata.PACKAGE_METADATA;
return {
catalog_schema_version: 1,
@ -39,7 +43,7 @@ export const createProductCatalog = async (root) => {
skill_version: metadata.skillVersion,
},
tools,
providers: providerCatalog(sources),
providers,
setup_clients: sources.supportedClients.SUPPORTED_CLIENT_DEFINITIONS.map(
({ name, displayName, format }) => ({
id: name,
@ -56,7 +60,10 @@ export const createProductCatalog = async (root) => {
},
runtime_catalog: {
counts: sources.catalogIdentity.CATALOG_IDENTITY.counts,
digests: sources.catalogIdentity.CATALOG_IDENTITY.digests,
digests: {
...sources.catalogIdentity.CATALOG_IDENTITY.digests,
providers_sha256: providerCatalogDigest(providers),
},
},
};
};

View File

@ -44,6 +44,7 @@ import {
cliCommandDescriptionIssues,
cliCommandOptionNames,
createProductCatalog,
providerCatalogDigest,
serializeProductCatalog,
} from "../scripts/lib/product-catalog.mjs";
@ -139,6 +140,9 @@ describe("canonical product catalog", () => {
"project_managed_application_graph",
"verify_managed_native_boundaries",
]);
expect(catalog.runtime_catalog.digests.providers_sha256).toBe(
providerCatalogDigest(catalog.providers),
);
expect(
z.toJSONSchema(analysisSnapshotSchema).properties?.snapshot_version,
).toMatchObject({
@ -230,6 +234,19 @@ describe("canonical product catalog", () => {
);
});
it("changes the provider projection digest when provider facts drift", async () => {
const catalog = await createProductCatalog(root);
const firstProvider = catalog.providers[0];
if (firstProvider === undefined) throw new TypeError("Missing provider");
const driftedProviders = [
{ ...firstProvider, name: `${firstProvider.name} drifted` },
...catalog.providers.slice(1),
];
expect(providerCatalogDigest(driftedProviders)).not.toBe(
catalog.runtime_catalog.digests.providers_sha256,
);
});
it("fails check mode without rewriting a stale generated artifact", async () => {
const directory = await createTestTempDirectory("rea-generated-check-");
temporaryRoots.push(directory);