diff --git a/docs/product-catalog.json b/docs/product-catalog.json index 299e8cc9f..ca86ea0d7 100644 --- a/docs/product-catalog.json +++ b/docs/product-catalog.json @@ -671,7 +671,8 @@ "tools_sha256": "5a88bf598a1e0fe705761db6af92f1ceabaf026cb6efa425e87b9633f1d7a3de", "prompts_sha256": "f6da3ad38a1000c11364d1710f122ac4990551f04efd8c53ffb2e819b5cf5707", "resources_sha256": "339a984ea8cd8be5f234adf24395bf0295258d990492005cf6fe1f293d12cd46", - "combined_sha256": "861cd8618e793342daa8b149bc2944360d84b077fd19cf22f6e0a57b6b249c73" + "combined_sha256": "861cd8618e793342daa8b149bc2944360d84b077fd19cf22f6e0a57b6b249c73", + "providers_sha256": "918ccd6b8ae442cd5844dcfde55086f458176241c8d166c1e9dd0f242ccd2bb0" } } } diff --git a/scripts/lib/catalog-core.mjs b/scripts/lib/catalog-core.mjs index 25fd557e2..e75692c36 100644 --- a/scripts/lib/catalog-core.mjs +++ b/scripts/lib/catalog-core.mjs @@ -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); diff --git a/scripts/lib/product-catalog.d.mts b/scripts/lib/product-catalog.d.mts index 8dc637cd3..b0a5336d5 100644 --- a/scripts/lib/product-catalog.d.mts +++ b/scripts/lib/product-catalog.d.mts @@ -46,6 +46,9 @@ export interface ProductCatalog { } export function createProductCatalog(root: string): Promise; +export function providerCatalogDigest( + providers: ProductCatalog["providers"], +): string; export function createCliInventory(cli: unknown): { readonly primary: readonly string[]; readonly aliases: readonly { diff --git a/scripts/lib/product-catalog.mjs b/scripts/lib/product-catalog.mjs index bfc38914d..b676f28d8 100644 --- a/scripts/lib/product-catalog.mjs +++ b/scripts/lib/product-catalog.mjs @@ -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), + }, }, }; }; diff --git a/tests/productCatalog.test.ts b/tests/productCatalog.test.ts index 90bcc567b..63c21af90 100644 --- a/tests/productCatalog.test.ts +++ b/tests/productCatalog.test.ts @@ -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);