diff --git a/src/wf_contract_manifest/normalize.py b/src/wf_contract_manifest/normalize.py index 59585503..811a11f7 100644 --- a/src/wf_contract_manifest/normalize.py +++ b/src/wf_contract_manifest/normalize.py @@ -132,6 +132,31 @@ def _schema(value: object, path: str) -> JsonSchema: return normalized +def _is_named_schema_reference(value: object) -> bool: + return ( + isinstance(value, Mapping) + and set(value) == {"$ref"} + and isinstance(value.get("$ref"), str) + and value["$ref"].startswith("#/components/schemas/") + ) + + +def _is_composed_named_result_schema(value: object) -> bool: + if not isinstance(value, Mapping): + return False + + composition_keys = [key for key in ("allOf", "anyOf", "oneOf") if key in value] + if len(composition_keys) != 1: + return False + + branches = value[composition_keys[0]] + return ( + isinstance(branches, list) + and bool(branches) + and all(_is_named_schema_reference(branch) for branch in branches) + ) + + def _error_component(value: object, path: str) -> JsonValue: """Normalize an OpenRPC error object, whose ``data`` member is a schema.""" if not isinstance(value, Mapping): @@ -269,22 +294,9 @@ def manifest_from_openrpc(document: Mapping[str, object]) -> ContractManifest: f"{result_path}.schema", "missing success result schema" ) raw_result_schema = result["schema"] - if not isinstance(raw_result_schema, Mapping): - raise ManifestError( - f"{result_path}.schema", - "success result must be a schema object", - ) - named_result = ( - set(raw_result_schema) == {"$ref"} - and isinstance(raw_result_schema.get("$ref"), str) - and raw_result_schema["$ref"].startswith("#/components/schemas/") - ) - # This inspection RPC returns either its inventory or the standard - # revision-conflict workspace payload, so its OpenRPC result is a union. - if ( - not named_result - and method_name != "workflow.draft_workspaces.inspect_authoring_contract" - ): + if not _is_named_schema_reference( + raw_result_schema + ) and not _is_composed_named_result_schema(raw_result_schema): raise ManifestError( f"{result_path}.schema", "success result must reference a named schema component", diff --git a/tests/wf_contract_manifest/test_normalize.py b/tests/wf_contract_manifest/test_normalize.py index 9ccf74fd..56bcee14 100644 --- a/tests/wf_contract_manifest/test_normalize.py +++ b/tests/wf_contract_manifest/test_normalize.py @@ -240,6 +240,43 @@ def test_rejects_invalid_result_schema_shape() -> None: ) +def test_accepts_composed_result_with_named_component_branches() -> None: + document = synthetic_openrpc_document() + document["methods"][1]["result"]["schema"] = { + "anyOf": [ + {"$ref": "#/components/schemas/AlphaResult"}, + {"$ref": "#/components/schemas/ZetaResult"}, + ] + } + + manifest = manifest_from_openrpc(document) + + assert manifest["operations"][0]["result"] == { + "schema": { + "anyOf": [ + {"$ref": "#/components/schemas/AlphaResult"}, + {"$ref": "#/components/schemas/ZetaResult"}, + ] + } + } + + +def test_rejects_composed_result_with_an_inline_branch() -> None: + document = synthetic_openrpc_document() + document["methods"][1]["result"]["schema"] = { + "oneOf": [ + {"$ref": "#/components/schemas/AlphaResult"}, + {"type": "object"}, + ] + } + + assert_manifest_error( + document, + "$.methods[1].result.schema", + "success result must reference a named schema component", + ) + + def test_reports_a_missing_success_result_schema() -> None: document = synthetic_openrpc_document() del document["methods"][1]["result"]["schema"] diff --git a/web/packages/rpc/src/json-schema/rpc-parity.test.ts b/web/packages/rpc/src/json-schema/rpc-parity.test.ts index a4c76e1e..c119ad4f 100644 --- a/web/packages/rpc/src/json-schema/rpc-parity.test.ts +++ b/web/packages/rpc/src/json-schema/rpc-parity.test.ts @@ -221,11 +221,22 @@ const authoringPathOption = { uses: ["step_input"], }; +const conditionalRuntimeContextOption = { + ...authoringPathOption, + path: "context.viewer_id", + label: "Viewer ID", + origin: "runtime_context", + required: false, + availability: "conditional", + reason: "available when the selected capability accepts runtime context", + uses: ["step_input"], +}; + const authoringContractInventory = { workspace_id: "console.demo", revision: 7, selected_step_id: "render", - readable_sources: [authoringPathOption], + readable_sources: [authoringPathOption, conditionalRuntimeContextOption], step_input_targets: [ { ...authoringPathOption, @@ -1113,6 +1124,16 @@ const parityReport = (): ParityReport => { }; describe("authored RPC and manifest schema parity", () => { + it("covers conditional runtime context sources with a reason", () => { + expect(authoringContractInventory.readable_sources).toContainEqual( + expect.objectContaining({ + origin: "runtime_context", + availability: "conditional", + reason: "available when the selected capability accepts runtime context", + }), + ); + }); + it("rejects empty segments in authored structural binding paths", () => { const cases: ReadonlyArray<{ readonly method: keyof typeof authoredRpcSchemas;