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 }); + } +});