Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 40 additions & 1 deletion scripts/project-current-ucp-schemas.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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),
Expand Down
19 changes: 18 additions & 1 deletion src/spec_generated.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down
64 changes: 64 additions & 0 deletions tests/spec-constraints.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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" }));
Expand Down
Loading