From f0cafb33444e6fb938ede5d0d8cb61f7de1fa501 Mon Sep 17 00:00:00 2001 From: Vishal Katyal Date: Wed, 2 Sep 2026 20:22:33 -0400 Subject: [PATCH] fix: generate capability declaration schemas A capability may redeclare the platform_schema, business_schema and response_schema roles that capability.json defines, under a $defs key equal to its own reverse domain name. Nothing in the --src list reaches those declarations, and handing the file to quicktype whole generates nothing for them either: these files are bare $defs containers with no root type, properties or $ref, so quicktype generates only what the root references, drops the rest, and exits 0. The omission is silent. Four files declare this way at 2026-08-25: common/identity_linking.json, shopping/fulfillment.json, shopping/permalink.json and common/payment_split_payments.json. None of their declarations were generated. Discovery is keyed on the shape, never on file names, so a capability added later is picked up with no edit here. The declarations are generated in their own isolated quicktype invocation and merged, which leaves the main invocation's inputs untouched so existing export names cannot move. The generated change is additive only: no existing line is changed, moved or removed. quicktype structurally unifies declarations that differ only in annotations, keeping one title's name. A completeness gate re-derives the declaration set from the schema tree independently of discovery, aliases any unified away name to its surviving structural sibling, and fails the build if a declaration produced nothing. --- generate_models.sh | 61 ++++- scripts/discover-declaration-srcs.mjs | 183 ++++++++++++++ scripts/ensure-declaration-exports.mjs | 215 +++++++++++++++++ scripts/merge-generated-fragment.mjs | 256 ++++++++++++++++++++ src/spec_generated.ts | 218 +++++++++++++++++ tests/declaration-srcs.test.js | 275 +++++++++++++++++++++ tests/ensure-declaration-exports.test.js | 228 ++++++++++++++++++ tests/merge-generated-fragment.test.js | 295 +++++++++++++++++++++++ 8 files changed, 1726 insertions(+), 5 deletions(-) create mode 100644 scripts/discover-declaration-srcs.mjs create mode 100644 scripts/ensure-declaration-exports.mjs create mode 100644 scripts/merge-generated-fragment.mjs create mode 100644 tests/declaration-srcs.test.js create mode 100644 tests/ensure-declaration-exports.test.js create mode 100644 tests/merge-generated-fragment.test.js diff --git a/generate_models.sh b/generate_models.sh index 2398ab6..2531871 100755 --- a/generate_models.sh +++ b/generate_models.sh @@ -65,9 +65,11 @@ RAW_CONSTRAINT_SCHEMA_DIR="$SPEC_DIR/schemas" PROJECTED_CONSTRAINT_SCHEMA_DIR="" TMP_OUTPUT="$(mktemp "${TMPDIR:-/tmp}/ucp-spec-generated.XXXXXX.ts")" +TMP_DECLARATIONS="$(mktemp "${TMPDIR:-/tmp}/ucp-declarations-generated.XXXXXX.ts")" +DECLARATION_MANIFEST="$(mktemp "${TMPDIR:-/tmp}/ucp-declarations-manifest.XXXXXX.json")" PROJECTED_SPEC_DIR="" cleanup() { - rm -f "$TMP_OUTPUT" + rm -f "$TMP_OUTPUT" "$TMP_DECLARATIONS" "$DECLARATION_MANIFEST" if [[ -n "$PROJECTED_SPEC_DIR" ]]; then rm -rf "$PROJECTED_SPEC_DIR" fi @@ -194,14 +196,63 @@ fi QUICKTYPE_ARGS+=(-o "$TMP_OUTPUT") -if [[ -x "./node_modules/.bin/quicktype" ]]; then - ./node_modules/.bin/quicktype "${QUICKTYPE_ARGS[@]}" -else - npx quicktype "${QUICKTYPE_ARGS[@]}" +run_quicktype() { + if [[ -x "./node_modules/.bin/quicktype" ]]; then + ./node_modules/.bin/quicktype "$@" + else + npx quicktype "$@" + fi +} + +run_quicktype "${QUICKTYPE_ARGS[@]}" + +# Capability DECLARATION schemas. A capability may redeclare the +# platform_schema / business_schema / response_schema roles that capability.json +# defines, under a $defs key equal to its own reverse domain name. Those +# declarations are not reachable from any root schema, so handing the file to +# quicktype whole generates nothing for them and exits 0 -- the omission is +# silent. Discovered by shape (scripts/discover-declaration-srcs.mjs), so a +# capability added to the spec later generates with no edit here. +# +# They are generated in their OWN quicktype invocation and merged in, never +# added to the shared invocation above: quicktype assigns names globally across +# one invocation, so new sources in the shared pool re-pick disambiguating +# names for unrelated existing types -- a breaking public API change. The +# merge appends only new declarations, proves colliding names identical, and +# fails loudly otherwise (scripts/merge-generated-fragment.mjs). +# +# Read from the RAW tree for the same reason the constraint injector does: the +# projection prunes `$defs` that nothing reachable references, so the projected +# copies of these declarations point at a `capability.json` that is not emitted +# and at `ucp.json#/$defs/entity` which the projection drops. The authored tree +# still has both, so the declaration refs resolve there. +DECLARATION_ARGS=() +if [[ -d "$RAW_CONSTRAINT_SCHEMA_DIR" ]]; then + while IFS= read -r declaration_src; do + [[ -n "$declaration_src" ]] || continue + DECLARATION_ARGS+=(--src "$RAW_CONSTRAINT_SCHEMA_DIR/$declaration_src") + done < <(node scripts/discover-declaration-srcs.mjs --manifest "$DECLARATION_MANIFEST" "$RAW_CONSTRAINT_SCHEMA_DIR") +fi + +if (( ${#DECLARATION_ARGS[@]} )); then + run_quicktype --lang typescript-zod --src-lang schema "${DECLARATION_ARGS[@]}" -o "$TMP_DECLARATIONS" + node scripts/merge-generated-fragment.mjs "$TMP_OUTPUT" "$TMP_DECLARATIONS" fi node scripts/normalize-generated-schemas.mjs "$TMP_OUTPUT" src/spec_generated.ts +# quicktype structurally unifies declarations that are identical modulo +# annotations, keeping one title's name, and exits 0 when it drops a schema. +# Guarantee every discovered declaration stays addressable: alias unified-away +# names to their surviving structural sibling, and fail the build if any +# declaration produced nothing (scripts/ensure-declaration-exports.mjs). +# Runs UNCONDITIONALLY, and is given the schema root so it can re-derive the +# declaration set independently of discovery. Guarding this on "did discovery +# find anything" would make it blind to discovery finding nothing, which is the +# precise failure quicktype's exit 0 produces. +node scripts/ensure-declaration-exports.mjs \ + "$DECLARATION_MANIFEST" src/spec_generated.ts "$RAW_CONSTRAINT_SCHEMA_DIR" + # Re-attach the value constraints (minimum, pattern, type: integer, ...) that # quicktype's typescript-zod target drops. The raw schema pass preserves # authored cross-file constraints; the projected pass then fills constraints on diff --git a/scripts/discover-declaration-srcs.mjs b/scripts/discover-declaration-srcs.mjs new file mode 100644 index 0000000..80464a5 --- /dev/null +++ b/scripts/discover-declaration-srcs.mjs @@ -0,0 +1,183 @@ +// Copyright 2026 UCP Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Discover capability DECLARATION schemas and print one quicktype `--src` +// fragment per capability and role, relative to the schema root. +// +// A capability may redeclare the platform_schema / business_schema / +// response_schema roles that capability.json defines, under a `$defs` key equal +// to the reverse domain name the capability itself declares. Nothing in the +// hand written `--src` list in generate_models.sh reaches those declarations, +// and handing the whole file to quicktype yields nothing for them either: +// quicktype generates only what the ROOT schema references, and these files are +// bare `$defs` containers with no root `type`, `properties` or `$ref`. It drops +// the unreferenced `$defs` silently and exits 0, so the omission is invisible. +// +// Discovery is keyed on that SHAPE, never on file names, so a capability added +// later is picked up without editing this script. +// +// Usage: node scripts/discover-declaration-srcs.mjs + +import fs from "node:fs"; +import path from "node:path"; +import crypto from "node:crypto"; + +// The roles capability.json defines. A `$defs` entry holding at least one of +// these, under a reverse domain name key, is a declaration. +const DECLARATION_ROLES = [ + "business_schema", + "platform_schema", + "response_schema", +]; + +// Mirrors common/types/reverse_domain_name.json. A capability name always has +// at least one dot, which is what separates it from an ordinary `$defs` key +// such as `checkout`. +const REVERSE_DOMAIN_NAME = + /^[a-z](?:[a-z0-9-]*[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9_-]*[a-z0-9_])?)+$/; + +// The suffixes the projector appends when it splits one authored file into +// request and response variants. +const VARIANT_SUFFIXES = [".create_req", ".update_req", "_resp"]; + +function walkJsonFiles(root) { + const found = []; + const visit = (dir) => { + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const abs = path.join(dir, entry.name); + if (entry.isDirectory()) visit(abs); + else if (entry.isFile() && entry.name.endsWith(".json")) found.push(abs); + } + }; + if (fs.existsSync(root)) visit(root); + return found.sort(); +} + +// Structural identity, ignoring annotation-only keywords. The projector gives +// each variant a distinct title suffix, so titles would defeat deduplication +// even when the three copies of a declaration are the same schema; and +// quicktype unifies schemas that differ only in annotations, so the alias +// step downstream needs two such declarations to share ONE hash (e.g. +// identity_linking's and permalink's platform_schema differ only in title and +// description). Constraint keywords are never stripped at schema positions. +// The strip is key-name based, so a PROPERTY literally named title or +// description is stripped too. That is harmless here: the hash only ever +// groups declarations of the same capability and role, and a false pairing +// would still have to get past the completeness gate. +const ANNOTATION_KEYS = new Set(["title", "description", "$comment", "examples"]); + +function structureHash(node) { + const strip = (value) => { + if (Array.isArray(value)) return value.map(strip); + if (value && typeof value === "object") { + const out = {}; + for (const key of Object.keys(value).sort()) { + if (ANNOTATION_KEYS.has(key)) continue; + out[key] = strip(value[key]); + } + return out; + } + return value; + }; + return crypto + .createHash("sha256") + .update(JSON.stringify(strip(node))) + .digest("hex"); +} + +// Prefer a file the projector did not split, then the response variant, then +// the lexicographically first path. Deterministic in every case, so the +// generated output does not depend on directory iteration order. +function preferredSource(relPaths) { + const unsplit = relPaths.filter( + (rel) => !VARIANT_SUFFIXES.some((suffix) => rel.includes(suffix)) + ); + if (unsplit.length) return unsplit.sort()[0]; + const response = relPaths.filter((rel) => rel.includes("_resp")); + if (response.length) return response.sort()[0]; + return [...relPaths].sort()[0]; +} + +function discover(schemaRoot) { + // key: `${capability} ${role} ${structureHash}` -> { relPaths, titles } + const groups = new Map(); + + for (const abs of walkJsonFiles(schemaRoot)) { + let doc; + try { + doc = JSON.parse(fs.readFileSync(abs, "utf8")); + } catch { + continue; // a file that is not JSON is not a schema; nothing to discover + } + const defs = doc && typeof doc === "object" ? doc.$defs : null; + if (!defs || typeof defs !== "object") continue; + + for (const [capability, node] of Object.entries(defs)) { + if (!REVERSE_DOMAIN_NAME.test(capability)) continue; + if (!node || typeof node !== "object") continue; + for (const role of DECLARATION_ROLES) { + const roleNode = node[role]; + if (!roleNode || typeof roleNode !== "object") continue; + const rel = path.relative(schemaRoot, abs).split(path.sep).join("/"); + const key = `${capability} ${role} ${structureHash(roleNode)}`; + if (!groups.has(key)) { + groups.set(key, { relPaths: [], titles: new Map() }); + } + const group = groups.get(key); + group.relPaths.push(rel); + if (typeof roleNode.title === "string") { + group.titles.set(rel, roleNode.title); + } + } + } + } + + const entries = []; + for (const [key, { relPaths, titles }] of groups) { + const [capability, role, hash] = key.split(" "); + const source = preferredSource(relPaths); + entries.push({ + src: `${source}#/$defs/${capability}/${role}`, + capability, + role, + title: titles.get(source), + structureHash: hash, + }); + } + // Sorting keeps the quicktype argument list stable, which keeps the + // generated file stable and the drift check meaningful. + return entries.sort((a, b) => a.src.localeCompare(b.src)); +} + +const args = process.argv.slice(2); +let manifestPath = null; +const manifestFlag = args.indexOf("--manifest"); +if (manifestFlag !== -1) { + manifestPath = args[manifestFlag + 1]; + args.splice(manifestFlag, 2); +} +const schemaRoot = args[0]; +if (!schemaRoot || (manifestFlag !== -1 && !manifestPath)) { + console.error( + "Usage: node scripts/discover-declaration-srcs.mjs [--manifest ] " + ); + process.exit(1); +} +const entries = discover(schemaRoot); +if (manifestPath) { + fs.writeFileSync(manifestPath, `${JSON.stringify(entries, null, 2)}\n`); +} +for (const entry of entries) { + console.log(entry.src); +} diff --git a/scripts/ensure-declaration-exports.mjs b/scripts/ensure-declaration-exports.mjs new file mode 100644 index 0000000..f393887 --- /dev/null +++ b/scripts/ensure-declaration-exports.mjs @@ -0,0 +1,215 @@ +// Copyright 2026 UCP Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// The completeness gate for capability declaration schemas. +// +// Two quicktype behaviors make generation silently incomplete: +// 1. it exits 0 when a schema yields nothing; +// 2. it structurally UNIFIES declarations that are identical modulo +// annotations (title/description), so only one declaration's name +// survives -- e.g. identity_linking's and permalink's platform_schema +// are both a bare allOf over capability.json#/$defs/platform_schema, +// and only one of the two names is emitted. +// +// This script closes both holes AFTER generation, driven purely by the +// manifest that scripts/discover-declaration-srcs.mjs derives from the spec +// (per-capability specifics are spec data, never code): +// - a declaration whose title-derived export name is present: nothing to do; +// - a name that is missing while a STRUCTURE-IDENTICAL sibling (same +// structureHash) is present: append a deterministic alias pair, the same +// shape quicktype itself emits for unified types. Every spec-declared +// name stays addressable, and stays valid if the two schemas later fork +// (regeneration then materializes the name as its own type); +// - a name that is missing with NO exported sibling: HARD ERROR. This is +// the loud replacement for the silent drop that motivated the pipeline +// change: a capability declaration that stops generating turns the build +// red instead of vanishing from the SDK. +// +// A declaration without a `title` has no predictable generated name and is +// exempt from the name check (the merge step still guarantees its fragment +// generated SOMETHING). Every declaration in the 2026-08-25 spec is titled. + +import fs from "node:fs"; + +const [, , manifestPath, generatedPath, schemaRoot] = process.argv; + +if (!manifestPath || !generatedPath) { + console.error( + "Usage: node scripts/ensure-declaration-exports.mjs [schema_root]" + ); + process.exit(1); +} + +// INDEPENDENT ORACLE. +// +// The manifest is produced by discover-declaration-srcs.mjs, so trusting it to +// say what the spec declares makes this gate blind to the one failure it exists +// to catch: discovery silently finding nothing. That is not hypothetical -- a +// kill test that neutralised discovery left the whole pipeline exiting 0. +// +// So when a schema root is supplied, re-derive the declaration set HERE, with a +// deliberately separate scan rather than by importing the discovery module. Two +// independent derivations must agree; if the manifest under-reports, the build +// fails. Written to be obvious rather than clever, because its only job is to +// disagree with the other implementation when that one breaks. +function declaredCapabilityRoles(root) { + const ROLES = ["business_schema", "platform_schema", "response_schema"]; + const found = new Set(); + const visit = (dir) => { + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const abs = `${dir}/${entry.name}`; + if (entry.isDirectory()) { + visit(abs); + continue; + } + if (!entry.isFile() || !entry.name.endsWith(".json")) continue; + let doc; + try { + doc = JSON.parse(fs.readFileSync(abs, "utf8")); + } catch { + continue; + } + const defs = doc && typeof doc === "object" ? doc.$defs : null; + if (!defs || typeof defs !== "object") continue; + for (const [capability, node] of Object.entries(defs)) { + if (!capability.includes(".")) continue; + if (!node || typeof node !== "object") continue; + for (const role of ROLES) { + if (node[role] && typeof node[role] === "object") { + found.add(`${capability}/${role}`); + } + } + } + } + }; + if (fs.existsSync(root)) visit(root); + return found; +} + +// Mirrors quicktype's typescript name styling closely enough for the simple +// "Words (Qualifier)" titles capability declarations carry. If a future title +// styles differently than predicted, the result is a HARD ERROR below (never +// a silently wrong alias), fixed by retitling or extending this rule. +function pascalize(title) { + return title + .split(/[^A-Za-z0-9]+/) + .filter(Boolean) + .map((word) => + /^[A-Z0-9]+$/.test(word) + ? word[0] + word.slice(1).toLowerCase() + : word[0].toUpperCase() + word.slice(1) + ) + .join(""); +} + +const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8")); +const generated = fs.readFileSync(generatedPath, "utf8"); + +if (schemaRoot) { + const declared = declaredCapabilityRoles(schemaRoot); + const reported = new Set( + manifest.map((entry) => `${entry.capability}/${entry.role}`) + ); + const unreported = [...declared].filter((key) => !reported.has(key)).sort(); + if (unreported.length > 0) { + console.error( + `ensure-declaration-exports: the schema tree declares ${declared.size} ` + + `capability declaration role(s) but the manifest reports ` + + `${reported.size}. Discovery missed: ${unreported.join(", ")}. ` + + `Refusing to publish an SDK that silently lost a spec-declared schema.` + ); + process.exit(1); + } +} + +function schemaExportPresent(text, name) { + return new RegExp(`^export const ${name}Schema\\b`, "m").test(text); +} + +const titled = manifest.filter((entry) => typeof entry.title === "string"); + +// Two structurally different declarations sharing one title would make the +// surviving export name silently mean only one of them. +const byExpectedName = new Map(); +for (const entry of titled) { + const expected = pascalize(entry.title); + const seen = byExpectedName.get(expected); + if (seen && seen.structureHash !== entry.structureHash) { + console.error( + `ensure-declaration-exports: ambiguous declaration name "${expected}": ` + + `${seen.src} and ${entry.src} share the title but differ structurally.` + ); + process.exit(1); + } + if (!seen) { + byExpectedName.set(expected, entry); + } +} + +const presentByHash = new Map(); +for (const entry of titled) { + const expected = pascalize(entry.title); + if (schemaExportPresent(generated, expected)) { + const names = presentByHash.get(entry.structureHash) ?? []; + names.push(expected); + presentByHash.set(entry.structureHash, names); + } +} + +const aliases = []; +const missing = []; +for (const entry of [...titled].sort((a, b) => a.src.localeCompare(b.src))) { + const expected = pascalize(entry.title); + if (schemaExportPresent(generated, expected)) { + continue; + } + const siblings = (presentByHash.get(entry.structureHash) ?? []).sort(); + if (siblings.length > 0) { + aliases.push({ alias: expected, target: siblings[0] }); + continue; + } + missing.push({ entry, expected }); +} + +if (missing.length > 0) { + for (const { entry, expected } of missing) { + console.error( + `ensure-declaration-exports: the declaration ${entry.capability} / ` + + `${entry.role} (${entry.src}) produced no export: expected ` + + `"${expected}Schema" and found neither it nor a structure-identical ` + + `sibling. quicktype dropped or renamed it; refusing to publish an ` + + `SDK that silently lost a spec-declared schema.` + ); + } + process.exit(1); +} + +if (aliases.length > 0) { + const block = aliases + .sort((a, b) => a.alias.localeCompare(b.alias)) + .map( + ({ alias, target }) => + `export const ${alias}Schema = ${target}Schema;\nexport type ${alias} = ${target};` + ) + .join("\n\n"); + fs.writeFileSync( + generatedPath, + `${generated.replace(/\s*$/, "")}\n\n${block}\n` + ); +} + +console.error( + `ensure-declaration-exports: ${titled.length} titled declaration(s) checked, ` + + `${aliases.length} unified-away name(s) re-exported as aliases.` +); diff --git a/scripts/merge-generated-fragment.mjs b/scripts/merge-generated-fragment.mjs new file mode 100644 index 0000000..fe427eb --- /dev/null +++ b/scripts/merge-generated-fragment.mjs @@ -0,0 +1,256 @@ +// Copyright 2026 UCP Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Folds a separately generated quicktype fragment into the raw main output. +// +// Why a separate invocation at all: quicktype assigns names GLOBALLY across +// one invocation. Adding new sources to the shared invocation changes the +// name pool and re-picks disambiguating names for UNRELATED existing types -- +// a breaking public API change. Generating the new sources in their own +// invocation leaves the main invocation's bytes untouched by construction. +// +// Merge contract (each "chunk" is a blank-line separated declaration group in +// quicktype's raw typescript-zod output, normally one `export const ...Schema` +// plus its `export type ...` pair): +// - all exported names NEW -> appended verbatim; +// - all exported names EXISTING -> the fragment chunk must be EQUIVALENT to +// the main file's declarations for those names; then skipped. Equivalent +// means equal modulo whitespace and modulo a consistent renaming of +// referenced schema identifiers, each renamed pair proven equivalent +// recursively (the two invocations legitimately pick different names for +// the same underlying schema, e.g. AllowedCombinationElement vs +// InstrumentGroup for one instrument_group definition). A real shape +// mismatch is a HARD ERROR: silently skipping would bind every fragment +// reference to a same-named but differently-shaped main type -- silent +// wrong output, the class of failure this pipeline exists to kill; +// - names MIXED new/existing -> hard error (partial merge is ambiguous); +// - fragment with NO exports -> hard error. quicktype exits 0 while +// silently dropping unreferenced schemas; an empty fragment means the +// drop happened and must never pass unnoticed again. + +import fs from "node:fs"; + +const [, , mainPath, fragmentPath] = process.argv; + +if (!mainPath || !fragmentPath) { + console.error( + "Usage: node scripts/merge-generated-fragment.mjs " + ); + process.exit(1); +} + +const EXPORT_NAME_RE = /^export (?:const|type) (\w+)/; + +function chunksOf(text) { + return text + .split(/\n{2,}/) + .map((chunk) => chunk.trim()) + .filter(Boolean); +} + +function exportedNames(chunk) { + const names = []; + for (const line of chunk.split("\n")) { + const match = line.match(EXPORT_NAME_RE); + if (match && !names.includes(match[1])) { + names.push(match[1]); + } + } + return names; +} + +function normalizeWhitespace(text) { + return text.replace(/\s+/g, " ").trim(); +} + +const mainContent = fs.readFileSync(mainPath, "utf8"); + +// Index the main file's declaration text by exported name so a colliding +// fragment chunk can be compared against what the name already means. +const mainChunkByName = new Map(); +for (const chunk of chunksOf(mainContent)) { + for (const name of exportedNames(chunk)) { + mainChunkByName.set(name, chunk); + } +} + +const fragmentContent = fs.readFileSync(fragmentPath, "utf8"); + +const fragmentChunkByName = new Map(); +for (const chunk of chunksOf(fragmentContent)) { + for (const name of exportedNames(chunk)) { + fragmentChunkByName.set(name, chunk); + } +} + +const IDENTIFIER_RE = /\b[A-Za-z_$][A-Za-z0-9_$]*\b/g; + +// String and regex literals, whose CONTENTS are never renameable identifiers. +const LITERAL_RE = + /"(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'|`(?:[^`\\]|\\.)*`|\/(?:[^/\\\n[]|\\.|\[(?:[^\]\\]|\\.)*\])+\/[gimsuy]*/g; + +// Replace every literal with a fixed placeholder and return the literals in +// order. The placeholder deliberately contains no identifier characters, so the +// identifier walk cannot see inside a literal. +function maskLiterals(text) { + const literals = []; + const masked = text.replace(LITERAL_RE, (match) => { + literals.push(match); + return ""; + }); + return { masked, literals }; +} + +// True when the main file's declaration of `mainName` and the fragment's +// declaration of `fragmentName` are the same schema up to whitespace and a +// consistent renaming of referenced schema identifiers, with every renamed +// pair proven equivalent the same way. Cycles assume equivalence on revisit +// (coinductive), which is sound for the equality this guards. +function equivalentChunks(mainName, fragmentName, visited) { + const pairKey = `${mainName}\u0000${fragmentName}`; + if (visited.has(pairKey)) { + return true; + } + visited.add(pairKey); + + const mainChunk = mainChunkByName.get(mainName); + const fragmentChunk = fragmentChunkByName.get(fragmentName); + if (!mainChunk || !fragmentChunk) { + return false; + } + + const strip = (text, name) => + normalizeWhitespace(text).replaceAll(name, ""); + const mainMasked = maskLiterals(strip(mainChunk, mainName)); + const fragmentMasked = maskLiterals(strip(fragmentChunk, fragmentName)); + + // Literal contents must be byte equal. `z.enum(["Card"])` and + // `z.enum(["Voucher"])` are different schemas even when Card and Voucher + // happen to name equivalent ones; without this the identifier walk below + // pairs them, calls the chunks identical, and silently drops the fragment + // version, which is the silent wrong output this pipeline exists to kill. + if ( + mainMasked.literals.length !== fragmentMasked.literals.length || + mainMasked.literals.some( + (literal, index) => literal !== fragmentMasked.literals[index] + ) + ) { + return false; + } + + const mainText = mainMasked.masked; + const fragmentText = fragmentMasked.masked; + + const mainIds = mainText.match(IDENTIFIER_RE) ?? []; + const fragmentIds = fragmentText.match(IDENTIFIER_RE) ?? []; + if ( + mainText.replace(IDENTIFIER_RE, "") !== + fragmentText.replace(IDENTIFIER_RE, "") || + mainIds.length !== fragmentIds.length + ) { + return false; + } + + for (let i = 0; i < mainIds.length; i += 1) { + const a = mainIds[i]; + const b = fragmentIds[i]; + if (a === b) { + continue; + } + // Only schema identifier pairs may differ, and only if the schemas they + // name are themselves equivalent. Bare type names ride along with their + // Schema constants. + const aSchema = a.endsWith("Schema") ? a : `${a}Schema`; + const bSchema = b.endsWith("Schema") ? b : `${b}Schema`; + const aName = aSchema.slice(0, -6); + const bName = bSchema.slice(0, -6); + if (!aName || !bName) { + return false; + } + if (!equivalentChunks(aSchema, bSchema, visited)) { + return false; + } + } + return true; +} + +const newChunks = []; +let skipped = 0; +let sawExports = false; +for (const chunk of chunksOf(fragmentContent)) { + const names = exportedNames(chunk); + if (names.length === 0) { + // Not a declaration (e.g. quicktype's banner comment) -- drop it; the + // main file already carries any header it needs. + continue; + } + sawExports = true; + + const existing = names.filter((name) => mainChunkByName.has(name)); + if (existing.length === 0) { + newChunks.push(chunk); + for (const name of names) { + mainChunkByName.set(name, chunk); + } + continue; + } + + if (existing.length !== names.length) { + console.error( + `merge-generated-fragment: ${fragmentPath}: a fragment chunk mixes ` + + `existing (${existing.join(", ")}) and new names ` + + `(${names.filter((n) => !existing.includes(n)).join(", ")}); ` + + `refusing to merge it partially.` + ); + process.exit(1); + } + + for (const name of names) { + if (!equivalentChunks(name, name, new Set())) { + console.error( + `merge-generated-fragment: ${fragmentPath}: export "${name}" ` + + `conflicts -- the fragment's declaration differs from the main ` + + `output's declaration of the same name (beyond a consistent ` + + `renaming of equivalent referenced schemas). Refusing to skip it ` + + `silently, because the fragment's other types would then bind to ` + + `a different shape than they were generated against.\n` + + ` main: ${normalizeWhitespace(mainChunkByName.get(name))}\n` + + ` fragment: ${normalizeWhitespace(chunk)}` + ); + process.exit(1); + } + } + skipped += 1; +} + +if (!sawExports) { + console.error( + `merge-generated-fragment: ${fragmentPath}: the fragment contains no ` + + `exports. quicktype exits 0 when it silently drops unreferenced ` + + `schemas; refusing to treat that silence as success.` + ); + process.exit(1); +} + +if (newChunks.length > 0) { + fs.writeFileSync( + mainPath, + `${mainContent.replace(/\s*$/, "")}\n\n${newChunks.join("\n\n")}\n` + ); +} + +console.error( + `merge-generated-fragment: ${fragmentPath}: ${newChunks.length} new ` + + `declaration(s) merged, ${skipped} identical already-present chunk(s) skipped.` +); diff --git a/src/spec_generated.ts b/src/spec_generated.ts index 94bf518..6af0351 100644 --- a/src/spec_generated.ts +++ b/src/spec_generated.ts @@ -280,6 +280,8 @@ export const A2ASchema = z.object({ .url(), }); export type A2A = z.infer; +export const PermalinkCapabilityBusinessConfigSchema = A2ASchema; +export type PermalinkCapabilityBusinessConfig = A2A; export const EmbeddedSchema = z.object({ schema: z.string().url(), @@ -1194,6 +1196,8 @@ export const PurpleInstrumentGroupSchema = z.object({ export type PurpleInstrumentGroup = z.infer; export const AllowedCombinationElementSchema = PurpleInstrumentGroupSchema; export type AllowedCombinationElement = PurpleInstrumentGroup; +export const InstrumentGroupSchema = PurpleInstrumentGroupSchema; +export type InstrumentGroup = PurpleInstrumentGroup; export const PaymentInstrumentSplitPaymentsSchema = z.object({ billing_address: BillingAddressClassSchema.optional(), @@ -3132,6 +3136,217 @@ export const UcpDiscoveryProfileSchema = z.object({ }); export type UcpDiscoveryProfile = z.infer; +export const PermalinkCapabilityPlatformSchema = z.object({ + config: z.record(z.string(), z.any()).optional(), + id: z.string().optional(), + schema: z.string().url(), + spec: z.string().url(), + version: z.string().regex(/^\d{4}-\d{2}-\d{2}$/), + extends: z + .union([ + z + .array( + z + .string() + .regex( + /^[a-z](?:[a-z0-9-]*[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9_-]*[a-z0-9_])?)+$/ + ) + ) + .min(1), + z + .string() + .regex( + /^[a-z](?:[a-z0-9-]*[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9_-]*[a-z0-9_])?)+$/ + ), + ]) + .optional(), +}); +export type PermalinkCapabilityPlatform = z.infer< + typeof PermalinkCapabilityPlatformSchema +>; + +export const PermalinkCapabilityResponseSchema = z.object({ + config: z.record(z.string(), z.any()).optional(), + id: z.string().optional(), + schema: z.string().url().optional(), + spec: z.string().url().optional(), + version: z.string().regex(/^\d{4}-\d{2}-\d{2}$/), + extends: z + .union([ + z + .array( + z + .string() + .regex( + /^[a-z](?:[a-z0-9-]*[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9_-]*[a-z0-9_])?)+$/ + ) + ) + .min(1), + z + .string() + .regex( + /^[a-z](?:[a-z0-9-]*[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9_-]*[a-z0-9_])?)+$/ + ), + ]) + .optional(), +}); +export type PermalinkCapabilityResponse = z.infer< + typeof PermalinkCapabilityResponseSchema +>; + +export const FulfillmentCapabilityPlatformSchema = z.object({ + config: PlatformFulfillmentConfigSchema.optional(), + id: z.string().optional(), + schema: z.string().url(), + spec: z.string().url(), + version: z.string().regex(/^\d{4}-\d{2}-\d{2}$/), + extends: z + .union([ + z + .array( + z + .string() + .regex( + /^[a-z](?:[a-z0-9-]*[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9_-]*[a-z0-9_])?)+$/ + ) + ) + .min(1), + z + .string() + .regex( + /^[a-z](?:[a-z0-9-]*[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9_-]*[a-z0-9_])?)+$/ + ), + ]) + .optional(), +}); +export type FulfillmentCapabilityPlatform = z.infer< + typeof FulfillmentCapabilityPlatformSchema +>; + +export const PermalinkCapabilityBusinessSchema = z.object({ + config: PermalinkCapabilityBusinessConfigSchema, + id: z.string().optional(), + schema: z.string().url(), + spec: z.string().url().optional(), + version: z.string().regex(/^\d{4}-\d{2}-\d{2}$/), + extends: z + .union([ + z + .array( + z + .string() + .regex( + /^[a-z](?:[a-z0-9-]*[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9_-]*[a-z0-9_])?)+$/ + ) + ) + .min(1), + z + .string() + .regex( + /^[a-z](?:[a-z0-9-]*[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9_-]*[a-z0-9_])?)+$/ + ), + ]) + .optional(), +}); +export type PermalinkCapabilityBusiness = z.infer< + typeof PermalinkCapabilityBusinessSchema +>; + +export const IdentityLinkingBusinessConfigSchema = z.object({ + providers: z.record(z.string(), z.array(IdentityProviderSchema)).optional(), + scopes: z.record(z.string(), ScopePolicySchema), +}); +export type IdentityLinkingBusinessConfig = z.infer< + typeof IdentityLinkingBusinessConfigSchema +>; + +export const SplitPaymentsCapabilityBusinessSchema = z.object({ + config: BusinessSplitPaymentsConfigSchema.optional(), + id: z.string().optional(), + schema: z.string().url(), + spec: z.string().url().optional(), + version: z.string().regex(/^\d{4}-\d{2}-\d{2}$/), + extends: z + .union([ + z + .array( + z + .string() + .regex( + /^[a-z](?:[a-z0-9-]*[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9_-]*[a-z0-9_])?)+$/ + ) + ) + .min(1), + z + .string() + .regex( + /^[a-z](?:[a-z0-9-]*[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9_-]*[a-z0-9_])?)+$/ + ), + ]) + .optional(), +}); +export type SplitPaymentsCapabilityBusiness = z.infer< + typeof SplitPaymentsCapabilityBusinessSchema +>; + +export const FulfillmentCapabilityBusinessSchema = z.object({ + config: BusinessFulfillmentConfigSchema.optional(), + id: z.string().optional(), + schema: z.string().url(), + spec: z.string().url().optional(), + version: z.string().regex(/^\d{4}-\d{2}-\d{2}$/), + extends: z + .union([ + z + .array( + z + .string() + .regex( + /^[a-z](?:[a-z0-9-]*[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9_-]*[a-z0-9_])?)+$/ + ) + ) + .min(1), + z + .string() + .regex( + /^[a-z](?:[a-z0-9-]*[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9_-]*[a-z0-9_])?)+$/ + ), + ]) + .optional(), +}); +export type FulfillmentCapabilityBusiness = z.infer< + typeof FulfillmentCapabilityBusinessSchema +>; + +export const IdentityLinkingBusinessSchema = z.object({ + config: IdentityLinkingBusinessConfigSchema, + id: z.string().optional(), + schema: z.string().url(), + spec: z.string().url().optional(), + version: z.string().regex(/^\d{4}-\d{2}-\d{2}$/), + extends: z + .union([ + z + .array( + z + .string() + .regex( + /^[a-z](?:[a-z0-9-]*[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9_-]*[a-z0-9_])?)+$/ + ) + ) + .min(1), + z + .string() + .regex( + /^[a-z](?:[a-z0-9-]*[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9_-]*[a-z0-9_])?)+$/ + ), + ]) + .optional(), +}); +export type IdentityLinkingBusiness = z.infer< + typeof IdentityLinkingBusinessSchema +>; + export const TotalResponseSchema = TotalSchema; export type TotalResponse = Total; @@ -3164,3 +3379,6 @@ export type LineItemQuantityRef = EventLineItem; export const ProviderSchema = IdentityProviderSchema; export type Provider = IdentityProvider; + +export const IdentityLinkingPlatformSchema = PermalinkCapabilityPlatformSchema; +export type IdentityLinkingPlatform = PermalinkCapabilityPlatform; diff --git a/tests/declaration-srcs.test.js b/tests/declaration-srcs.test.js new file mode 100644 index 0000000..7266ae2 --- /dev/null +++ b/tests/declaration-srcs.test.js @@ -0,0 +1,275 @@ +// Copyright 2026 UCP Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +const { test } = require("node:test"); +const assert = require("node:assert/strict"); +const fs = require("node:fs"); +const os = require("node:os"); +const path = require("node:path"); +const { execFileSync } = require("node:child_process"); + +const SCRIPT = path.join( + __dirname, + "..", + "scripts", + "discover-declaration-srcs.mjs" +); + +// A capability may redeclare the platform_schema / business_schema roles that +// capability.json defines, under a $defs key equal to its own declared name. +// Nothing in the hand written --src list reaches those declarations, and a file +// passed whole yields nothing for them because quicktype only generates what the +// root schema references. This script discovers them by SHAPE so no capability +// has to be named anywhere. + +function writeTree(root, files) { + for (const [rel, value] of Object.entries(files)) { + const abs = path.join(root, rel); + fs.mkdirSync(path.dirname(abs), { recursive: true }); + fs.writeFileSync(abs, JSON.stringify(value, null, 2)); + } +} + +function declaration(name, roles) { + const defs = {}; + for (const [role, title] of Object.entries(roles)) { + defs[role] = { title, allOf: [{ $ref: "../capability.json" }] }; + } + return { $defs: { [name]: defs } }; +} + +function run(root) { + return execFileSync("node", [SCRIPT, root], { encoding: "utf8" }) + .split("\n") + .map((l) => l.trim()) + .filter(Boolean); +} + +function withTree(files, fn) { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "ucp-decl-")); + try { + writeTree(root, files); + return fn(root); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +} + +test("discovers every declaration role, keyed on shape not on file names", () => { + const out = withTree( + { + "schemas/common/identity_linking.json": declaration( + "dev.ucp.common.identity_linking", + { + platform_schema: "Identity Linking (Platform)", + business_schema: "Identity Linking (Business)", + } + ), + "schemas/shopping/permalink.json": declaration( + "dev.ucp.shopping.permalink", + { + platform_schema: "Permalink Capability (Platform)", + business_schema: "Permalink Capability (Business)", + response_schema: "Permalink Capability (Response)", + } + ), + }, + run + ); + + assert.deepEqual(out, [ + "schemas/common/identity_linking.json#/$defs/dev.ucp.common.identity_linking/business_schema", + "schemas/common/identity_linking.json#/$defs/dev.ucp.common.identity_linking/platform_schema", + "schemas/shopping/permalink.json#/$defs/dev.ucp.shopping.permalink/business_schema", + "schemas/shopping/permalink.json#/$defs/dev.ucp.shopping.permalink/platform_schema", + "schemas/shopping/permalink.json#/$defs/dev.ucp.shopping.permalink/response_schema", + ]); +}); + +test("emits one fragment per capability and role when projection split the file into request and response variants", () => { + // The projector splits a capability that also declares checkout attachments + // into create_req / update_req / _resp files. All three carry the SAME + // declaration; emitting all three would generate three copies of one type. + const roles = { + platform_schema: "Fulfillment Capability (Platform)", + business_schema: "Fulfillment Capability (Business)", + }; + const out = withTree( + { + "schemas/shopping/fulfillment.create_req.json": declaration( + "dev.ucp.shopping.fulfillment", + roles + ), + "schemas/shopping/fulfillment.update_req.json": declaration( + "dev.ucp.shopping.fulfillment", + roles + ), + "schemas/shopping/fulfillment_resp.json": declaration( + "dev.ucp.shopping.fulfillment", + roles + ), + }, + run + ); + + assert.deepEqual(out, [ + "schemas/shopping/fulfillment_resp.json#/$defs/dev.ucp.shopping.fulfillment/business_schema", + "schemas/shopping/fulfillment_resp.json#/$defs/dev.ucp.shopping.fulfillment/platform_schema", + ]); +}); + +test("emits every variant when the variants genuinely differ", () => { + // Deduplication is by STRUCTURE, not by name. If a projection ever makes the + // declarations genuinely differ, dropping one would silently lose a type. + const out = withTree( + { + "schemas/shopping/thing.create_req.json": { + $defs: { + "dev.ucp.shopping.thing": { + business_schema: { + title: "Thing (Business) Create Request", + properties: { a: { type: "string" } }, + }, + }, + }, + }, + "schemas/shopping/thing_resp.json": { + $defs: { + "dev.ucp.shopping.thing": { + business_schema: { + title: "Thing (Business) Response", + properties: { b: { type: "string" } }, + }, + }, + }, + }, + }, + run + ); + + assert.equal(out.length, 2, `expected both variants, got ${out.join(", ")}`); +}); + +test("ignores schemas that are not declaration shaped", () => { + // Negative control. Without this the discovery could pass by emitting + // everything it sees. + const out = withTree( + { + "schemas/common/types/amount.json": { + type: "object", + properties: { value: { type: "number" } }, + }, + "schemas/shopping/checkout.json": { + $defs: { + // a $defs key that is NOT a reverse domain capability name + checkout: { type: "object", properties: { id: { type: "string" } } }, + }, + }, + "schemas/shopping/other.json": { + $defs: { + // a reverse domain key that holds no declaration roles + "dev.ucp.shopping.other": { + type: "object", + properties: { id: { type: "string" } }, + }, + }, + }, + }, + run + ); + + assert.deepEqual(out, []); +}); + +test("--manifest writes one JSON entry per emitted fragment, carrying title and structure hash", () => { + const out = withTree( + { + "schemas/common/identity_linking.json": declaration( + "dev.ucp.common.identity_linking", + { platform_schema: "Identity Linking (Platform)" } + ), + "schemas/shopping/permalink.json": declaration( + "dev.ucp.shopping.permalink", + { platform_schema: "Permalink Capability (Platform)" } + ), + }, + (root) => { + const manifestPath = path.join(root, "manifest.json"); + const stdout = execFileSync( + "node", + [SCRIPT, "--manifest", manifestPath, root], + { encoding: "utf8" } + ); + return { + srcs: stdout + .split("\n") + .map((l) => l.trim()) + .filter(Boolean), + manifest: JSON.parse(fs.readFileSync(manifestPath, "utf8")), + }; + } + ); + + // The src list on stdout is unchanged by the flag. + assert.equal(out.srcs.length, 2); + assert.equal(out.manifest.length, 2); + const byCapability = new Map(out.manifest.map((e) => [e.capability, e])); + const idLink = byCapability.get("dev.ucp.common.identity_linking"); + const permalink = byCapability.get("dev.ucp.shopping.permalink"); + assert.equal(idLink.role, "platform_schema"); + assert.equal(idLink.title, "Identity Linking (Platform)"); + assert.equal(idLink.src, out.srcs[0]); + assert.ok(idLink.structureHash); + // Both declarations are a bare allOf over capability.json: identical modulo + // annotations, so they share one structure hash. quicktype unifies exactly + // such pairs, and the shared hash is what lets the alias step find the + // surviving sibling. + assert.equal(idLink.structureHash, permalink.structureHash); +}); + +test("structure hashing ignores annotations (title, description) but not constraints", () => { + const variant = (description, extra) => ({ + $defs: { + "dev.ucp.common.thing": { + business_schema: { + title: "Thing (Business)", + description, + allOf: [{ $ref: "../capability.json" }], + ...extra, + }, + }, + }, + }); + // Same structure, different description: deduplicated to ONE fragment. + const deduped = withTree( + { + "schemas/a/thing.create_req.json": variant("described one way"), + "schemas/a/thing_resp.json": variant("described another way"), + }, + run + ); + assert.equal(deduped.length, 1, deduped.join(", ")); + + // A real structural difference still yields both. + const kept = withTree( + { + "schemas/a/thing.create_req.json": variant("same words"), + "schemas/a/thing_resp.json": variant("same words", { + required: ["config"], + }), + }, + run + ); + assert.equal(kept.length, 2, kept.join(", ")); +}); diff --git a/tests/ensure-declaration-exports.test.js b/tests/ensure-declaration-exports.test.js new file mode 100644 index 0000000..3a59189 --- /dev/null +++ b/tests/ensure-declaration-exports.test.js @@ -0,0 +1,228 @@ +// Copyright 2026 UCP Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +const { test } = require("node:test"); +const assert = require("node:assert/strict"); +const fs = require("node:fs"); +const os = require("node:os"); +const path = require("node:path"); +const { execFileSync, spawnSync } = require("node:child_process"); + +const SCRIPT = path.join( + __dirname, + "..", + "scripts", + "ensure-declaration-exports.mjs" +); + +// The completeness gate for capability declaration schemas. quicktype exits 0 +// when it silently drops a schema, and it structurally unifies declarations +// that are identical modulo annotations, keeping only one title's name. This +// script guarantees that every discovered declaration ends up addressable: +// - a declaration whose title-derived name is exported: nothing to do; +// - a declaration whose name is missing but whose structure-identical +// sibling (same structureHash in the manifest) IS exported: a +// deterministic alias pair is appended; +// - a declaration with no exported name and no exported sibling: hard +// error. This is the loud replacement for the silent drop that motivated +// the whole pipeline change. + +function withFiles(manifest, generated, fn) { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "ucp-ensure-")); + const manifestPath = path.join(root, "manifest.json"); + const generatedPath = path.join(root, "generated.ts"); + try { + fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2)); + fs.writeFileSync(generatedPath, generated); + return fn(manifestPath, generatedPath); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +} + +function run(manifestPath, generatedPath) { + execFileSync("node", [SCRIPT, manifestPath, generatedPath], { + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + }); + return fs.readFileSync(generatedPath, "utf8"); +} + +function runExpectingFailure(manifestPath, generatedPath) { + try { + execFileSync("node", [SCRIPT, manifestPath, generatedPath], { + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + }); + } catch (error) { + return { status: error.status, stderr: String(error.stderr) }; + } + assert.fail("expected the check to exit non-zero"); +} + +const GENERATED = `export const PermalinkCapabilityPlatformSchema = z.object({ + name: z.string(), +}); +export type PermalinkCapabilityPlatform = z.infer< + typeof PermalinkCapabilityPlatformSchema +>; +`; + +function entry(overrides) { + return { + src: "shopping/permalink.json#/$defs/dev.ucp.shopping.permalink/platform_schema", + capability: "dev.ucp.shopping.permalink", + role: "platform_schema", + title: "Permalink Capability (Platform)", + structureHash: "hash-a", + ...overrides, + }; +} + +test("a declaration whose title-derived export exists leaves the file unchanged", () => { + const out = withFiles([entry({})], GENERATED, run); + assert.equal(out, GENERATED); +}); + +test("a unified-away declaration gains a deterministic alias to its structural sibling", () => { + const manifest = [ + entry({}), + entry({ + src: "common/identity_linking.json#/$defs/dev.ucp.common.identity_linking/platform_schema", + capability: "dev.ucp.common.identity_linking", + title: "Identity Linking (Platform)", + // Same structureHash: quicktype unified the two declarations and only + // one title's name survived. + }), + ]; + const out = withFiles(manifest, GENERATED, run); + assert.match( + out, + /export const IdentityLinkingPlatformSchema =\s*PermalinkCapabilityPlatformSchema;/ + ); + assert.match( + out, + /export type IdentityLinkingPlatform = PermalinkCapabilityPlatform;/ + ); + // The pre-existing content is untouched. + assert.ok(out.startsWith(GENERATED.replace(/\s*$/, ""))); +}); + +test("a declaration with no export and no exported sibling is a hard error", () => { + const manifest = [ + entry({ + src: "common/ghost.json#/$defs/dev.ucp.common.ghost/business_schema", + capability: "dev.ucp.common.ghost", + role: "business_schema", + title: "Ghost Capability (Business)", + structureHash: "hash-ghost", + }), + ]; + const { status, stderr } = withFiles( + manifest, + GENERATED, + runExpectingFailure + ); + assert.notEqual(status, 0); + assert.match(stderr, /dev\.ucp\.common\.ghost/); + assert.match(stderr, /GhostCapabilityBusiness/); +}); + +test("a declaration without a title is exempt (its generated name is not predictable)", () => { + const manifest = [ + entry({}), + entry({ title: undefined, structureHash: "hash-b" }), + ]; + const out = withFiles(manifest, GENERATED, run); + assert.equal(out, GENERATED); +}); + +test("two structurally different declarations sharing one title is a hard error (ambiguous name)", () => { + const manifest = [ + entry({}), + entry({ + src: "common/other.json#/$defs/dev.ucp.common.other/platform_schema", + capability: "dev.ucp.common.other", + structureHash: "hash-c", + // Same title as entry() but a different structure: the surviving export + // name would silently mean only one of them. + }), + ]; + const { status, stderr } = withFiles( + manifest, + GENERATED, + runExpectingFailure + ); + assert.notEqual(status, 0); + assert.match(stderr, /ambiguous/i); +}); + +test("alias appending is deterministic and idempotent", () => { + const manifest = [ + entry({}), + entry({ + src: "common/identity_linking.json#/$defs/dev.ucp.common.identity_linking/platform_schema", + capability: "dev.ucp.common.identity_linking", + title: "Identity Linking (Platform)", + }), + ]; + const once = withFiles(manifest, GENERATED, run); + const twice = withFiles(manifest, once, run); + assert.equal(twice, once); +}); + +// A gate that only runs when discovery SUCCEEDED cannot catch discovery +// silently returning nothing, which is the exact failure quicktype's exit 0 +// creates. The manifest is therefore not trusted as the source of truth: the +// gate independently re-derives what the schema tree declares and fails when +// the manifest under-reports it. +test("fails when the manifest under-reports what the schema tree declares", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "ucp-gate-oracle-")); + try { + const schemaRoot = path.join(root, "schemas"); + fs.mkdirSync(path.join(schemaRoot, "shopping"), { recursive: true }); + fs.writeFileSync( + path.join(schemaRoot, "shopping", "permalink.json"), + JSON.stringify({ + $defs: { + "dev.ucp.shopping.permalink": { + platform_schema: { title: "Permalink Capability (Platform)" }, + business_schema: { title: "Permalink Capability (Business)" }, + }, + }, + }) + ); + + // An EMPTY manifest is what a broken discovery produces. + const manifestPath = path.join(root, "manifest.json"); + fs.writeFileSync(manifestPath, "[]"); + const generatedPath = path.join(root, "generated.ts"); + fs.writeFileSync(generatedPath, 'import * as z from "zod";\n'); + + const result = spawnSync( + "node", + [SCRIPT, manifestPath, generatedPath, schemaRoot], + { encoding: "utf8" } + ); + + assert.equal( + result.status, + 1, + `expected a loud failure, got status ${result.status}. stderr: ${result.stderr}` + ); + assert.match(result.stderr, /declaration/i); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +}); diff --git a/tests/merge-generated-fragment.test.js b/tests/merge-generated-fragment.test.js new file mode 100644 index 0000000..9d2babb --- /dev/null +++ b/tests/merge-generated-fragment.test.js @@ -0,0 +1,295 @@ +// Copyright 2026 UCP Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +const { test } = require("node:test"); +const assert = require("node:assert/strict"); +const fs = require("node:fs"); +const os = require("node:os"); +const path = require("node:path"); +const { execFileSync, spawnSync } = require("node:child_process"); + +const SCRIPT = path.join( + __dirname, + "..", + "scripts", + "merge-generated-fragment.mjs" +); + +// The declaration schemas are generated in their own quicktype invocation so +// the main invocation's global name pool is untouched (adding sources to one +// shared invocation renames existing exports, a breaking API change). This +// script folds the fragment into the raw main output. Its contract: +// - a chunk whose exported names are all NEW is appended verbatim; +// - a chunk whose exported names all EXIST must match the main file's text +// for those names (modulo whitespace), and is then skipped -- a mismatch +// is a hard error, never a silent skip, because silently binding a +// fragment reference to a same-named but differently-shaped main type is +// exactly the class of silent wrong output this pipeline exists to kill; +// - a chunk mixing new and existing names is a hard error; +// - a fragment containing no exports at all is a hard error: quicktype +// exits 0 when it silently drops everything, and that silence is the +// original bug. + +const MAIN = `// UCP generated models + +export const FooSchema = z.object({ + a: z.string(), +}); +export type Foo = z.infer; + +export const BarSchema = z.object({ + b: z.number(), +}); +export type Bar = z.infer; +`; + +function withFiles(mainText, fragmentText, fn) { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "ucp-merge-")); + const mainPath = path.join(root, "main.ts"); + const fragmentPath = path.join(root, "fragment.ts"); + try { + fs.writeFileSync(mainPath, mainText); + fs.writeFileSync(fragmentPath, fragmentText); + return fn(mainPath, fragmentPath); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +} + +function run(mainPath, fragmentPath) { + execFileSync("node", [SCRIPT, mainPath, fragmentPath], { + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + }); + return fs.readFileSync(mainPath, "utf8"); +} + +function runExpectingFailure(mainPath, fragmentPath) { + try { + execFileSync("node", [SCRIPT, mainPath, fragmentPath], { + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + }); + } catch (error) { + return { status: error.status, stderr: String(error.stderr) }; + } + assert.fail("expected the merge to exit non-zero"); +} + +test("appends chunks whose exported names are all new", () => { + const fragment = `// fragment banner comment + +export const NewThingSchema = z.object({ + c: z.boolean(), +}); +export type NewThing = z.infer; +`; + const merged = withFiles(MAIN, fragment, run); + assert.match(merged, /export const NewThingSchema = z\.object\(\{/); + assert.match( + merged, + /export type NewThing = z\.infer;/ + ); + // The pre-existing declarations are byte-for-byte untouched. + assert.ok(merged.startsWith(MAIN.replace(/\s*$/, ""))); + // The fragment's banner comment is not copied in. + assert.ok(!merged.includes("fragment banner comment")); +}); + +test("skips a chunk identical to the main file's chunk for the same name", () => { + const fragment = `export const FooSchema = z.object({ + a: z.string(), +}); +export type Foo = z.infer; + +export const NewThingSchema = z.object({ + c: z.boolean(), +}); +export type NewThing = z.infer; +`; + const merged = withFiles(MAIN, fragment, run); + // Foo appears exactly once (the original), NewThing was appended. + assert.equal(merged.match(/export const FooSchema/g).length, 1); + assert.match(merged, /export const NewThingSchema/); +}); + +test("whitespace differences alone do not count as a conflict", () => { + const fragment = `export const FooSchema = z.object({ a: z.string(), }); +export type Foo = z.infer; +`; + const merged = withFiles(MAIN, fragment, run); + assert.equal(merged.match(/export const FooSchema/g).length, 1); +}); + +test("a same-named chunk with a different shape is a hard error, not a silent skip", () => { + const fragment = `export const FooSchema = z.object({ + a: z.number(), +}); +export type Foo = z.infer; +`; + const { status, stderr } = withFiles(MAIN, fragment, runExpectingFailure); + assert.notEqual(status, 0); + assert.match(stderr, /Foo/); + assert.match(stderr, /conflict|differs|mismatch/i); +}); + +test("a chunk mixing existing and new names is a hard error", () => { + // One chunk (no blank line inside) declaring both an existing and a new name. + const fragment = `export const FooSchema = z.object({ + a: z.string(), +}); +export type Foo = z.infer; +export const NewThingSchema = z.object({ + c: z.boolean(), +}); +export type NewThing = z.infer; +`; + const { status, stderr } = withFiles(MAIN, fragment, runExpectingFailure); + assert.notEqual(status, 0); + assert.match(stderr, /mix/i); +}); + +test("a fragment with no exports at all is a hard error (the silent-drop tripwire)", () => { + const fragment = `// quicktype emitted nothing but a banner\n`; + const { status, stderr } = withFiles(MAIN, fragment, runExpectingFailure); + assert.notEqual(status, 0); + assert.match(stderr, /no export/i); +}); + +test("a colliding chunk that differs only by a consistent rename of an equivalent referenced schema is skipped", () => { + // The two invocations can name the SAME underlying schema differently + // (main: AllowedCombinationElement, fragment: InstrumentGroup, both + // generated from the same instrument_group definition). The chunks are + // interchangeable, so the fragment copy is skipped -- but only after the + // referenced pair is itself proven equivalent, recursively. + const main = `export const AllowedCombinationElementSchema = z.object({ + handlers: z.array(z.string()), +}); +export type AllowedCombinationElement = z.infer; + +export const SplitConfigSchema = z.object({ + allowed_combinations: z.array(z.array(AllowedCombinationElementSchema)), +}); +export type SplitConfig = z.infer; +`; + const fragment = `export const InstrumentGroupSchema = z.object({ + handlers: z.array(z.string()), +}); +export type InstrumentGroup = z.infer; + +export const SplitConfigSchema = z.object({ + allowed_combinations: z.array(z.array(InstrumentGroupSchema)), +}); +export type SplitConfig = z.infer; + +export const NewDeclSchema = z.object({ + config: SplitConfigSchema.optional(), +}); +export type NewDecl = z.infer; +`; + const merged = withFiles(main, fragment, run); + // SplitConfig kept its main-invocation body, InstrumentGroup and NewDecl + // were appended, and nothing was duplicated. + assert.equal(merged.match(/export const SplitConfigSchema/g).length, 1); + assert.match( + merged, + /z\.array\(z\.array\(AllowedCombinationElementSchema\)\)/ + ); + assert.match(merged, /export const InstrumentGroupSchema/); + assert.match(merged, /export const NewDeclSchema/); +}); + +test("a renamed reference that is NOT equivalent still fails loudly", () => { + const main = `export const AllowedCombinationElementSchema = z.object({ + handlers: z.array(z.string()), +}); +export type AllowedCombinationElement = z.infer; + +export const SplitConfigSchema = z.object({ + allowed_combinations: z.array(AllowedCombinationElementSchema), +}); +export type SplitConfig = z.infer; +`; + const fragment = `export const InstrumentGroupSchema = z.object({ + handlers: z.number(), +}); +export type InstrumentGroup = z.infer; + +export const SplitConfigSchema = z.object({ + allowed_combinations: z.array(InstrumentGroupSchema), +}); +export type SplitConfig = z.infer; +`; + const { status, stderr } = withFiles(main, fragment, runExpectingFailure); + assert.notEqual(status, 0); + assert.match(stderr, /SplitConfig/); +}); + +// Identifier renaming must never reach INSIDE a string or regex literal. Two +// enums whose members differ are different schemas, however their surrounding +// identifiers pair up. Treating literal contents as renameable schema +// references would silently skip the fragment chunk and bind its references to +// a differently shaped main type, which is the exact silent wrong output this +// pipeline exists to kill. +test("two enums differing only inside string literals are not equivalent", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "ucp-merge-lit-")); + try { + const mainPath = path.join(root, "main.ts"); + const fragmentPath = path.join(root, "fragment.ts"); + fs.writeFileSync( + mainPath, + [ + 'import * as z from "zod";', + "", + "export const CardSchema = z.object({ kind: z.string() });", + "export type Card = z.infer;", + "", + 'export const MethodSchema = z.enum(["Card"]);', + "export type Method = z.infer;", + "", + ].join("\n") + ); + // Same export name, same shape except the enum MEMBER. Card and Voucher + // name equivalent schemas, so a renaming-based comparison pairs them. + fs.writeFileSync( + fragmentPath, + [ + 'import * as z from "zod";', + "", + "export const VoucherSchema = z.object({ kind: z.string() });", + "export type Voucher = z.infer;", + "", + 'export const MethodSchema = z.enum(["Voucher"]);', + "export type Method = z.infer;", + "", + ].join("\n") + ); + + const result = spawnSync("node", [SCRIPT, mainPath, fragmentPath], { + encoding: "utf8", + }); + + assert.equal( + result.status, + 1, + `expected a hard error for a differing enum member, got status ${result.status}. stderr: ${result.stderr}` + ); + assert.ok( + !fs.readFileSync(mainPath, "utf8").includes('z.enum(["Voucher"])'), + "the fragment must not have been silently merged" + ); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +});