diff --git a/scripts/project-current-ucp-schemas.mjs b/scripts/project-current-ucp-schemas.mjs index da67d89..3f5ac42 100644 --- a/scripts/project-current-ucp-schemas.mjs +++ b/scripts/project-current-ucp-schemas.mjs @@ -750,6 +750,29 @@ function toCompatLeaf(schema) { return { type: "string" }; } +// Rewrite every `$ref` inside a cloned source node so it resolves from the +// discovery/ output directory, which sits beside schemas/ rather than inside it. +// A raw clone keeps refs written relative to schemas/ (e.g. +// "common/types/reverse_domain_name.json") and generation dies with +// "Could not fetch schema" the moment such a node is emitted into discovery/. +// Keeping the node otherwise intact is the point: the reverse-domain pattern and +// minItems stay attached, so quicktype emits the real constraint instead of a +// bare union that then depends on the injector finding it by property set. +function rewriteDiscoveryRefs(node) { + if (Array.isArray(node)) return node.map(rewriteDiscoveryRefs); + if (!node || typeof node !== "object") return node; + const out = {}; + for (const [key, value] of Object.entries(node)) { + if (key === "$ref" && typeof value === "string" && !value.startsWith("#")) { + const [file, fragment = ""] = value.split("#"); + out.$ref = fragment ? `../schemas/${file}#${fragment}` : `../schemas/${file}`; + } else { + out[key] = rewriteDiscoveryRefs(value); + } + } + return out; +} + // Rewrite an array item $ref from its source (schemas-root-relative) path to the // path a discovery/ compat file uses to reach the projected type tree. function rewriteItemRefForDiscovery(items) { @@ -833,6 +856,22 @@ function writeCompatibilityDiscoverySchemas() { const version = ucpSchema.$defs.version; const entityProperties = ucpSchema.$defs.entity.properties; const serviceEndpoint = serviceSchema.$defs.base.allOf[1].properties.endpoint; + // capability.json declares `extends` once, on $defs/base, as a oneOf of a + // reverse-domain-pattern string or a non-empty array of the same -- and both + // $defs/platform_schema (this discovery projection) and $defs/response_schema + // (capabilityResponse, below) inherit it via allOf from that shared base. + // The clone is emitted into discovery/, which sits beside schemas/ rather than + // inside it, so its `$ref: common/types/reverse_domain_name.json` does not + // resolve there and generation dies with "Could not fetch schema". + // rewriteDiscoveryRefs repoints those refs and leaves the node otherwise + // intact, which is the point: the pattern and minItems stay attached, so + // quicktype emits the constraint directly. Flattening it through toCompatLeaf + // instead would resolve the ref but drop both, because the constraint would + // then have to be reattached by inject-schema-constraints.mjs, whose index is + // keyed by the containing object property set, and this object differs from + // capabilityResponse by one property name. + const capabilityExtends = + capabilitySchema.$defs.base.allOf[1].properties.extends; const signingKey = { $schema: "https://json-schema.org/draft/2020-12/schema", @@ -879,7 +918,7 @@ function writeCompatibilityDiscoverySchemas() { required: ["name", "schema", "spec", "version"], properties: { config: { type: "object", additionalProperties: true }, - extends: { type: "string" }, + extends: rewriteDiscoveryRefs(clone(capabilityExtends)), name: { type: "string" }, schema: clone(entityProperties.schema), spec: clone(entityProperties.spec), diff --git a/src/spec_generated.ts b/src/spec_generated.ts index 138877a..3f1224f 100644 --- a/src/spec_generated.ts +++ b/src/spec_generated.ts @@ -265,7 +265,24 @@ export type ConstraintExpressionProperty = ConstraintsProperty; export const CapabilityDiscoverySchema = z.object({ config: z.record(z.string(), z.any()).optional(), - extends: z.string().optional(), + 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(), name: z.string(), schema: z.string().url(), spec: z.string().url(), diff --git a/tests/spec-constraints.test.js b/tests/spec-constraints.test.js index af1bd92..74e27d3 100644 --- a/tests/spec-constraints.test.js +++ b/tests/spec-constraints.test.js @@ -543,6 +543,70 @@ test("CapabilityResponseSchema accepts valid extends names", () => { ); }); +// --- CapabilityDiscoverySchema extends: the #55 twin -------------------- +// capability.json's `extends` oneOf is declared once, on $defs/base, and +// inherited by BOTH $defs/response_schema (-> CapabilityResponseSchema, +// fixed by #55) and $defs/platform_schema (-> CapabilityDiscoverySchema, +// the discovery-profile projection). writeCompatibilityDiscoverySchemas() +// hand-authors the discovery projection separately from the derived +// response projection, and its `extends` field was left as a bare +// `{ type: "string" }` stub -- the same defect #55 fixed on the response +// side, unfixed on this twin. + +test("CapabilityDiscoverySchema rejects an empty extends array", () => { + assert.ok( + rejects(CapabilityDiscoverySchema, { + name: "dev.ucp.shopping.checkout", + schema: "https://ucp.dev/schemas/shopping/checkout.json", + spec: "https://ucp.dev/specification/checkout", + version: "2026-04-08", + extends: [], + }) + ); +}); + +test("CapabilityDiscoverySchema rejects invalid extends names", () => { + assert.ok( + rejects(CapabilityDiscoverySchema, { + name: "dev.ucp.shopping.checkout", + schema: "https://ucp.dev/schemas/shopping/checkout.json", + spec: "https://ucp.dev/specification/checkout", + version: "2026-04-08", + extends: "bad name", + }) + ); + assert.ok( + rejects(CapabilityDiscoverySchema, { + name: "dev.ucp.shopping.checkout", + schema: "https://ucp.dev/schemas/shopping/checkout.json", + spec: "https://ucp.dev/specification/checkout", + version: "2026-04-08", + extends: ["com.example.good", "BadName"], + }) + ); +}); + +test("CapabilityDiscoverySchema accepts valid extends names", () => { + assert.ok( + accepts(CapabilityDiscoverySchema, { + name: "dev.ucp.shopping.checkout", + schema: "https://ucp.dev/schemas/shopping/checkout.json", + spec: "https://ucp.dev/specification/checkout", + version: "2026-04-08", + extends: "dev.ucp.checkout", + }) + ); + assert.ok( + accepts(CapabilityDiscoverySchema, { + name: "dev.ucp.shopping.checkout", + schema: "https://ucp.dev/schemas/shopping/checkout.json", + spec: "https://ucp.dev/specification/checkout", + version: "2026-04-08", + extends: ["dev.ucp.checkout", "com.example_capability.v1"], + }) + ); +}); + test("UcpSchema enforces the discovery version pattern", () => { const discovery = { capabilities: [], services: {} }; assert.ok(rejects(UcpSchema, { ...discovery, version: "not-a-date" }));