fix: preserve named title contract schemas

This commit is contained in:
lda
2026-08-03 08:38:19 +07:00 Verified
parent 859d251755
commit 231e023a3c
8 changed files with 371 additions and 74 deletions
+40
View File
@@ -570,6 +570,16 @@
"summary": { "summary": {
"$ref": "#/components/schemas/DraftWorkspaceSummary" "$ref": "#/components/schemas/DraftWorkspaceSummary"
}, },
"title": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
]
},
"workspace_id": { "workspace_id": {
"type": "string" "type": "string"
}, },
@@ -1358,6 +1368,16 @@
"summary": { "summary": {
"$ref": "#/components/schemas/DraftWorkspaceSummary" "$ref": "#/components/schemas/DraftWorkspaceSummary"
}, },
"title": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
]
},
"workspace_id": { "workspace_id": {
"type": "string" "type": "string"
} }
@@ -3399,6 +3419,17 @@
}, },
"type": "array" "type": "array"
}, },
"title": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"default": null
},
"type": { "type": {
"anyOf": [ "anyOf": [
{ {
@@ -4119,6 +4150,9 @@
}, },
"type": "array" "type": "array"
}, },
"title": {
"type": "string"
},
"version": { "version": {
"type": "integer" "type": "integer"
}, },
@@ -4281,6 +4315,9 @@
"source_id": { "source_id": {
"type": "string" "type": "string"
}, },
"title": {
"type": "string"
},
"version": { "version": {
"type": "integer" "type": "integer"
}, },
@@ -4352,6 +4389,9 @@
"source_id": { "source_id": {
"type": "string" "type": "string"
}, },
"title": {
"type": "string"
},
"version": { "version": {
"type": "integer" "type": "integer"
} }
@@ -41,7 +41,7 @@
- Produces: `ManifestError`, `JsonValue`, `JsonSchema`, `ContractManifest`, and `manifest_from_openrpc(document: Mapping[str, object]) -> ContractManifest`. - Produces: `ManifestError`, `JsonValue`, `JsonSchema`, `ContractManifest`, and `manifest_from_openrpc(document: Mapping[str, object]) -> ContractManifest`.
- Contract: this task validates the manifest envelope and normalizes valid operations; Task 2 adds complete reference-graph validation. - Contract: this task validates the manifest envelope and normalizes valid operations; Task 2 adds complete reference-graph validation.
- [ ] **Step 1: Add the reusable synthetic OpenRPC fixture** - [x] **Step 1: Add the reusable synthetic OpenRPC fixture**
Create `tests/wf_contract_manifest/fixtures.py` with a fixture that deliberately exercises ordering and schema preservation: Create `tests/wf_contract_manifest/fixtures.py` with a fixture that deliberately exercises ordering and schema preservation:
@@ -130,7 +130,7 @@ def synthetic_openrpc_document() -> dict[str, Any]:
} }
``` ```
- [ ] **Step 2: Write failing happy-path normalization tests** - [x] **Step 2: Write failing happy-path normalization tests**
Create `tests/wf_contract_manifest/test_normalize.py`: Create `tests/wf_contract_manifest/test_normalize.py`:
@@ -200,7 +200,7 @@ def test_preserves_conditional_schema_keywords() -> None:
assert alpha["not"] == {"required": ["forbidden"]} assert alpha["not"] == {"required": ["forbidden"]}
``` ```
- [ ] **Step 3: Run the tests and confirm the package is missing** - [x] **Step 3: Run the tests and confirm the package is missing**
Run: Run:
@@ -211,7 +211,7 @@ New-Item -ItemType Directory -Force -Path '.pytest-tmp\manifest-task1' | Out-Nul
Expected: collection fails with `ModuleNotFoundError: No module named 'wf_contract_manifest'`. Expected: collection fails with `ModuleNotFoundError: No module named 'wf_contract_manifest'`.
- [ ] **Step 4: Add the typed manifest model** - [x] **Step 4: Add the typed manifest model**
Create `src/wf_contract_manifest/model.py` with these public definitions: Create `src/wf_contract_manifest/model.py` with these public definitions:
@@ -285,7 +285,7 @@ __all__ = [
] ]
``` ```
- [ ] **Step 5: Implement the minimal pure normalizer** - [x] **Step 5: Implement the minimal pure normalizer**
Create `src/wf_contract_manifest/normalize.py`. Keep the envelope readers small and path-aware. The implementation must: Create `src/wf_contract_manifest/normalize.py`. Keep the envelope readers small and path-aware. The implementation must:
@@ -358,7 +358,7 @@ Then implement `manifest_from_openrpc()` using the helpers above:
Use a short comment over `_json_value`: generated titles are removed recursively, while every other schema keyword and value is intentionally opaque. Use a short comment over `_json_value`: generated titles are removed recursively, while every other schema keyword and value is intentionally opaque.
- [ ] **Step 6: Run focused tests and static checks** - [x] **Step 6: Run focused tests and static checks**
Run: Run:
@@ -370,7 +370,7 @@ Run:
Expected: all normalization tests pass; Ruff and basedpyright report no errors. Expected: all normalization tests pass; Ruff and basedpyright report no errors.
- [ ] **Step 7: Commit Task 1** - [x] **Step 7: Commit Task 1**
```powershell ```powershell
git add src\wf_contract_manifest tests\wf_contract_manifest git add src\wf_contract_manifest tests\wf_contract_manifest
@@ -389,7 +389,7 @@ git commit -m "feat: normalize workflow OpenRPC manifests"
- Consumes: `ManifestError`, normalized operation/component values from Task 1. - Consumes: `ManifestError`, normalized operation/component values from Task 1.
- Produces: the same `manifest_from_openrpc()` interface, now rejecting malformed methods and invalid `$ref` graphs before returning. - Produces: the same `manifest_from_openrpc()` interface, now rejecting malformed methods and invalid `$ref` graphs before returning.
- [ ] **Step 1: Add failing malformed-envelope tests** - [x] **Step 1: Add failing malformed-envelope tests**
Append parameterized tests to `tests/wf_contract_manifest/test_normalize.py`: Append parameterized tests to `tests/wf_contract_manifest/test_normalize.py`:
@@ -459,7 +459,7 @@ def test_rejects_malformed_openrpc_contracts(mutate, path: str, message: str) ->
If basedpyright rejects untyped lambdas, define a `Protocol` named `DocumentMutation` and annotate the parameter, or replace the table with named mutation functions. Do not silence it with `Any` casts. If basedpyright rejects untyped lambdas, define a `Protocol` named `DocumentMutation` and annotate the parameter, or replace the table with named mutation functions. Do not silence it with `Any` casts.
- [ ] **Step 2: Add failing reference-graph tests** - [x] **Step 2: Add failing reference-graph tests**
Add tests for every rejected reference class: Add tests for every rejected reference class:
@@ -502,7 +502,7 @@ def test_accepts_nested_schema_and_error_component_references() -> None:
] ]
``` ```
- [ ] **Step 3: Run tests and verify fail-closed cases are red** - [x] **Step 3: Run tests and verify fail-closed cases are red**
Run: Run:
@@ -512,7 +512,7 @@ Run:
Expected: new duplicate-method, generic-result, and invalid-reference cases fail because Task 1 does not yet reject them. Expected: new duplicate-method, generic-result, and invalid-reference cases fail because Task 1 does not yet reject them.
- [ ] **Step 4: Implement strict method/result validation** - [x] **Step 4: Implement strict method/result validation**
In `normalize.py`: In `normalize.py`:
@@ -524,7 +524,7 @@ In `normalize.py`:
This top-level result rule is intentionally stricter than nested JSON Schema. Every current successful operation has a named result component, which is the stable seam the TypeScript generator will consume. This top-level result rule is intentionally stricter than nested JSON Schema. Every current successful operation has a named result component, which is the stable seam the TypeScript generator will consume.
- [ ] **Step 5: Implement one complete reference-graph walk** - [x] **Step 5: Implement one complete reference-graph walk**
Add these internal interfaces: Add these internal interfaces:
@@ -567,7 +567,7 @@ For every operation and component value, walk references and validate:
Run this validation once after the whole manifest is assembled so forward references are valid. Run this validation once after the whole manifest is assembled so forward references are valid.
- [ ] **Step 6: Run focused tests and static checks** - [x] **Step 6: Run focused tests and static checks**
```powershell ```powershell
.venv\Scripts\python.exe -m pytest tests\wf_contract_manifest\test_normalize.py -n 0 --basetemp '.pytest-tmp\manifest-task2' -q .venv\Scripts\python.exe -m pytest tests\wf_contract_manifest\test_normalize.py -n 0 --basetemp '.pytest-tmp\manifest-task2' -q
@@ -577,7 +577,7 @@ Run this validation once after the whole manifest is assembled so forward refere
Expected: all normalization and validation tests pass; static checks are clean. Expected: all normalization and validation tests pass; static checks are clean.
- [ ] **Step 7: Commit Task 2** - [x] **Step 7: Commit Task 2**
```powershell ```powershell
git add src\wf_contract_manifest\normalize.py tests\wf_contract_manifest\test_normalize.py git add src\wf_contract_manifest\normalize.py tests\wf_contract_manifest\test_normalize.py
@@ -599,7 +599,7 @@ git commit -m "feat: validate workflow contract references"
- Consumes: `manifest_from_openrpc()` from Tasks 1-2, `wf_server.build_local_static_workflow_server`, and `wf_transport_rpc_http.create_rpc_app`. - Consumes: `manifest_from_openrpc()` from Tasks 1-2, `wf_server.build_local_static_workflow_server`, and `wf_transport_rpc_http.create_rpc_app`.
- Produces: `generate_manifest() -> ContractManifest`, `canonical_manifest_json(manifest) -> str`, `write_manifest(manifest, path) -> Path`, `check_manifest(manifest, path) -> None`, `ManifestDriftError`, and `DEFAULT_MANIFEST_PATH`. - Produces: `generate_manifest() -> ContractManifest`, `canonical_manifest_json(manifest) -> str`, `write_manifest(manifest, path) -> Path`, `check_manifest(manifest, path) -> None`, `ManifestDriftError`, and `DEFAULT_MANIFEST_PATH`.
- [ ] **Step 1: Write the real-contract integration test** - [x] **Step 1: Write the real-contract integration test**
Create `tests/wf_contract_manifest/test_generate.py`: Create `tests/wf_contract_manifest/test_generate.py`:
@@ -655,7 +655,7 @@ def test_generated_contract_contains_no_temporary_or_transport_state() -> None:
If current auth result components use a narrower naming convention, replace `auth_result_names` with the exact current component names discovered from the real document and pin them explicitly. Do not weaken the assertion to a no-op. If current auth result components use a narrower naming convention, replace `auth_result_names` with the exact current component names discovered from the real document and pin them explicitly. Do not weaken the assertion to a no-op.
- [ ] **Step 2: Write canonical I/O tests** - [x] **Step 2: Write canonical I/O tests**
Create `tests/wf_contract_manifest/test_io.py`: Create `tests/wf_contract_manifest/test_io.py`:
@@ -711,7 +711,7 @@ def test_check_reports_drift_without_mutating_the_file(tmp_path: Path) -> None:
assert path.read_bytes() == before assert path.read_bytes() == before
``` ```
- [ ] **Step 3: Run tests and verify generation/I/O modules are missing** - [x] **Step 3: Run tests and verify generation/I/O modules are missing**
```powershell ```powershell
.venv\Scripts\python.exe -m pytest tests\wf_contract_manifest\test_generate.py tests\wf_contract_manifest\test_io.py -n 0 --basetemp '.pytest-tmp\manifest-task3' -q .venv\Scripts\python.exe -m pytest tests\wf_contract_manifest\test_generate.py tests\wf_contract_manifest\test_io.py -n 0 --basetemp '.pytest-tmp\manifest-task3' -q
@@ -719,7 +719,7 @@ def test_check_reports_drift_without_mutating_the_file(tmp_path: Path) -> None:
Expected: collection fails because `generate_manifest`, `ManifestDriftError`, and the I/O helpers are not exported. Expected: collection fails because `generate_manifest`, `ManifestDriftError`, and the I/O helpers are not exported.
- [ ] **Step 4: Implement real in-process generation** - [x] **Step 4: Implement real in-process generation**
Create `src/wf_contract_manifest/generate.py`: Create `src/wf_contract_manifest/generate.py`:
@@ -749,7 +749,7 @@ def generate_manifest() -> ContractManifest:
Do not start Uvicorn, bind a socket, read `.env`, or construct a remote client. The composed in-process app is the authoritative transport registration surface. Do not start Uvicorn, bind a socket, read `.env`, or construct a remote client. The composed in-process app is the authoritative transport registration surface.
- [ ] **Step 5: Implement byte-canonical I/O** - [x] **Step 5: Implement byte-canonical I/O**
Create `src/wf_contract_manifest/io.py`: Create `src/wf_contract_manifest/io.py`:
@@ -799,7 +799,7 @@ def check_manifest(manifest: ContractManifest, path: Path = DEFAULT_MANIFEST_PAT
Byte comparison is intentional: it catches semantic contract drift and non-canonical manual edits with the same deterministic remediation. Byte comparison is intentional: it catches semantic contract drift and non-canonical manual edits with the same deterministic remediation.
- [ ] **Step 6: Export the generation and I/O interfaces** - [x] **Step 6: Export the generation and I/O interfaces**
Update `src/wf_contract_manifest/__init__.py` so `__all__` also contains: Update `src/wf_contract_manifest/__init__.py` so `__all__` also contains:
@@ -814,7 +814,7 @@ from .io import (
) )
``` ```
- [ ] **Step 7: Run focused tests and static checks** - [x] **Step 7: Run focused tests and static checks**
```powershell ```powershell
.venv\Scripts\python.exe -m pytest tests\wf_contract_manifest -n 0 --basetemp '.pytest-tmp\manifest-task3' -q .venv\Scripts\python.exe -m pytest tests\wf_contract_manifest -n 0 --basetemp '.pytest-tmp\manifest-task3' -q
@@ -824,7 +824,7 @@ from .io import (
Expected: synthetic and real-contract tests pass; static checks are clean. Expected: synthetic and real-contract tests pass; static checks are clean.
- [ ] **Step 8: Commit Task 3** - [x] **Step 8: Commit Task 3**
```powershell ```powershell
git add src\wf_contract_manifest tests\wf_contract_manifest git add src\wf_contract_manifest tests\wf_contract_manifest
@@ -844,7 +844,7 @@ git commit -m "feat: generate canonical workflow contract"
- Consumes: `generate_manifest`, `write_manifest`, `check_manifest`, `DEFAULT_MANIFEST_PATH`, and `ManifestDriftError` from Task 3. - Consumes: `generate_manifest`, `write_manifest`, `check_manifest`, `DEFAULT_MANIFEST_PATH`, and `ManifestDriftError` from Task 3.
- Produces: `main(argv: Sequence[str] | None = None) -> int` and the supported commands `.venv\Scripts\python.exe -m wf_contract_manifest write|check`. - Produces: `main(argv: Sequence[str] | None = None) -> int` and the supported commands `.venv\Scripts\python.exe -m wf_contract_manifest write|check`.
- [ ] **Step 1: Write CLI behavior tests** - [x] **Step 1: Write CLI behavior tests**
Create `tests/wf_contract_manifest/test_cli.py`: Create `tests/wf_contract_manifest/test_cli.py`:
@@ -900,7 +900,7 @@ def test_rejects_unknown_command() -> None:
Use explicit pytest fixture types already established in the repository if basedpyright requires them; do not replace behavior assertions with subprocess-only smoke tests. Use explicit pytest fixture types already established in the repository if basedpyright requires them; do not replace behavior assertions with subprocess-only smoke tests.
- [ ] **Step 2: Run the CLI test and confirm it is red** - [x] **Step 2: Run the CLI test and confirm it is red**
```powershell ```powershell
.venv\Scripts\python.exe -m pytest tests\wf_contract_manifest\test_cli.py -n 0 --basetemp '.pytest-tmp\manifest-task4' -q .venv\Scripts\python.exe -m pytest tests\wf_contract_manifest\test_cli.py -n 0 --basetemp '.pytest-tmp\manifest-task4' -q
@@ -908,7 +908,7 @@ Use explicit pytest fixture types already established in the repository if based
Expected: collection fails because `wf_contract_manifest.__main__` does not exist. Expected: collection fails because `wf_contract_manifest.__main__` does not exist.
- [ ] **Step 3: Implement the module CLI** - [x] **Step 3: Implement the module CLI**
Create `src/wf_contract_manifest/__main__.py`: Create `src/wf_contract_manifest/__main__.py`:
@@ -952,7 +952,7 @@ if __name__ == "__main__":
Do not add a `[project.scripts]` entry. The approved surface is the module command. Do not add a `[project.scripts]` entry. The approved surface is the module command.
- [ ] **Step 4: Run the CLI test and static checks** - [x] **Step 4: Run the CLI test and static checks**
```powershell ```powershell
.venv\Scripts\python.exe -m pytest tests\wf_contract_manifest\test_cli.py -n 0 --basetemp '.pytest-tmp\manifest-task4' -q .venv\Scripts\python.exe -m pytest tests\wf_contract_manifest\test_cli.py -n 0 --basetemp '.pytest-tmp\manifest-task4' -q
@@ -962,7 +962,7 @@ Do not add a `[project.scripts]` entry. The approved surface is the module comma
Expected: CLI tests pass and static checks are clean. Expected: CLI tests pass and static checks are clean.
- [ ] **Step 5: Generate and independently check the committed artifact** - [x] **Step 5: Generate and independently check the committed artifact**
```powershell ```powershell
.venv\Scripts\python.exe -m wf_contract_manifest write .venv\Scripts\python.exe -m wf_contract_manifest write
@@ -989,7 +989,7 @@ print(manifest["operations"][-1]["method"])
Expected: `70`, `126`, `1`, followed by the lexically first and last method names. Expected: `70`, `126`, `1`, followed by the lexically first and last method names.
- [ ] **Step 6: Commit Task 4** - [x] **Step 6: Commit Task 4**
```powershell ```powershell
git add src\wf_contract_manifest\__main__.py tests\wf_contract_manifest\test_cli.py contracts\workflow-api.manifest.json git add src\wf_contract_manifest\__main__.py tests\wf_contract_manifest\test_cli.py contracts\workflow-api.manifest.json
@@ -1011,7 +1011,7 @@ git commit -m "feat: check in workflow contract manifest"
- Consumes: the real generator, canonical checker, and checked artifact from Tasks 3-4. - Consumes: the real generator, canonical checker, and checked artifact from Tasks 3-4.
- Produces: a deterministic pytest drift gate and current documentation pointing to the manifest seam and the next TypeScript generation slice. - Produces: a deterministic pytest drift gate and current documentation pointing to the manifest seam and the next TypeScript generation slice.
- [ ] **Step 1: Write the committed-manifest drift test** - [x] **Step 1: Write the committed-manifest drift test**
Create `tests/wf_contract_manifest/test_committed_manifest.py`: Create `tests/wf_contract_manifest/test_committed_manifest.py`:
@@ -1023,7 +1023,7 @@ def test_committed_manifest_matches_the_python_workflow_contract() -> None:
check_manifest(generate_manifest(), DEFAULT_MANIFEST_PATH) check_manifest(generate_manifest(), DEFAULT_MANIFEST_PATH)
``` ```
- [ ] **Step 2: Prove the drift gate fails without mutating the artifact** - [x] **Step 2: Prove the drift gate fails without mutating the artifact**
Use a temporary backup and restore in one PowerShell `try/finally` block: Use a temporary backup and restore in one PowerShell `try/finally` block:
@@ -1041,7 +1041,7 @@ try {
Expected: pytest fails with `ManifestDriftError` and guidance to run `python -m wf_contract_manifest write`; the `finally` block restores the exact original bytes. Expected: pytest fails with `ManifestDriftError` and guidance to run `python -m wf_contract_manifest write`; the `finally` block restores the exact original bytes.
- [ ] **Step 3: Run the restored drift gate** - [x] **Step 3: Run the restored drift gate**
```powershell ```powershell
.venv\Scripts\python.exe -m pytest tests\wf_contract_manifest\test_committed_manifest.py -n 0 --basetemp '.pytest-tmp\manifest-task5-green' -q .venv\Scripts\python.exe -m pytest tests\wf_contract_manifest\test_committed_manifest.py -n 0 --basetemp '.pytest-tmp\manifest-task5-green' -q
@@ -1049,7 +1049,7 @@ Expected: pytest fails with `ManifestDriftError` and guidance to run `python -m
Expected: one test passes. Expected: one test passes.
- [ ] **Step 4: Update current documentation** - [x] **Step 4: Update current documentation**
Read `docs/AGENTS.md` before editing these files. Read `docs/AGENTS.md` before editing these files.
@@ -1076,9 +1076,10 @@ Under `Important Entry Points`, add:
In `docs/current_roadmap.md` under `Recently Completed Platform Milestones`, add a concise completed bullet linking the approved design spec and checked artifact, then state that generated TypeScript inventory/types and representative Effect translation are the next contract-parity slice. Do not claim TypeScript parity is complete. In `docs/current_roadmap.md` under `Recently Completed Platform Milestones`, add a concise completed bullet linking the approved design spec and checked artifact, then state that generated TypeScript inventory/types and representative Effect translation are the next contract-parity slice. Do not claim TypeScript parity is complete.
- [ ] **Step 5: Run the complete scoped verification gate** - [x] **Step 5: Run the complete scoped verification gate**
```powershell ```powershell
New-Item -ItemType Directory -Force -Path '.pytest-tmp' | Out-Null
.venv\Scripts\python.exe -m pytest tests\wf_contract_manifest tests\wf_transport_rpc_http\test_openrpc_contract.py -n 0 --basetemp '.pytest-tmp\manifest-final' -q .venv\Scripts\python.exe -m pytest tests\wf_contract_manifest tests\wf_transport_rpc_http\test_openrpc_contract.py -n 0 --basetemp '.pytest-tmp\manifest-final' -q
.venv\Scripts\python.exe -m wf_contract_manifest check .venv\Scripts\python.exe -m wf_contract_manifest check
.venv\Scripts\ruff.exe check src\wf_contract_manifest tests\wf_contract_manifest .venv\Scripts\ruff.exe check src\wf_contract_manifest tests\wf_contract_manifest
@@ -1094,7 +1095,7 @@ Expected:
- Ruff, basedpyright, and whitespace checks are clean; - Ruff, basedpyright, and whitespace checks are clean;
- status contains only files intentionally changed by this plan. - status contains only files intentionally changed by this plan.
- [ ] **Step 6: Run independent two-axis review and fix valid findings** - [x] **Step 6: Run independent two-axis review and fix valid findings**
Dispatch a fresh reviewer that did not implement the slice. Give it: Dispatch a fresh reviewer that did not implement the slice. Give it:
@@ -1105,7 +1106,7 @@ Dispatch a fresh reviewer that did not implement the slice. Give it:
Fix every valid Critical or Important finding and add a regression test for behavioral fixes. Re-run Step 5 after fixes. Record Minor deferrals with rationale in the final report rather than silently ignoring them. Fix every valid Critical or Important finding and add a regression test for behavioral fixes. Re-run Step 5 after fixes. Record Minor deferrals with rationale in the final report rather than silently ignoring them.
- [ ] **Step 7: Archive the completed plan and commit documentation** - [x] **Step 7: Archive the completed plan and commit documentation**
Only after all code, tests, review fixes, and verification are complete: Only after all code, tests, review fixes, and verification are complete:
@@ -1115,7 +1116,7 @@ git add ISSUES.md docs\project_map.md docs\current_roadmap.md docs\superpowers\p
git commit -m "docs: record workflow contract manifest" git commit -m "docs: record workflow contract manifest"
``` ```
- [ ] **Step 8: Remove only the plan-owned temporary pytest directory** - [x] **Step 8: Remove only the plan-owned temporary pytest directory**
```powershell ```powershell
$temp = (Resolve-Path '.pytest-tmp').Path $temp = (Resolve-Path '.pytest-tmp').Path
+13 -2
View File
@@ -15,9 +15,20 @@ class ManifestDriftError(RuntimeError):
def canonical_manifest_json(manifest: ContractManifest) -> str: def canonical_manifest_json(manifest: ContractManifest) -> str:
try: try:
return json.dumps(manifest, ensure_ascii=False, indent=2, sort_keys=True) + "\n" return (
json.dumps(
manifest,
allow_nan=False,
ensure_ascii=False,
indent=2,
sort_keys=True,
)
+ "\n"
)
except (TypeError, ValueError) as error: except (TypeError, ValueError) as error:
raise ValueError(f"manifest is not canonically serializable: {error}") from error raise ValueError(
f"manifest is not canonically serializable: {error}"
) from error
def write_manifest( def write_manifest(
+104 -15
View File
@@ -1,6 +1,8 @@
from __future__ import annotations from __future__ import annotations
import math
from collections.abc import Iterator, Mapping from collections.abc import Iterator, Mapping
from typing import cast
from .model import ( from .model import (
ContractManifest, ContractManifest,
@@ -13,6 +15,28 @@ from .model import (
type ComponentIndex = dict[str, set[str]] type ComponentIndex = dict[str, set[str]]
_SCHEMA_MAP_KEYWORDS = {
"$defs",
"definitions",
"dependentSchemas",
"patternProperties",
"properties",
}
_SINGLE_SCHEMA_KEYWORDS = {
"additionalProperties",
"contains",
"contentSchema",
"else",
"if",
"items",
"not",
"propertyNames",
"then",
"unevaluatedItems",
"unevaluatedProperties",
}
_SCHEMA_ARRAY_KEYWORDS = {"allOf", "anyOf", "oneOf", "prefixItems"}
def _mapping(value: object, path: str) -> Mapping[str, object]: def _mapping(value: object, path: str) -> Mapping[str, object]:
if not isinstance(value, Mapping): if not isinstance(value, Mapping):
@@ -20,6 +44,13 @@ def _mapping(value: object, path: str) -> Mapping[str, object]:
return value return value
def _string_key_mapping(value: object, path: str) -> Mapping[str, object]:
mapping = _mapping(value, path)
if any(not isinstance(key, str) for key in mapping):
raise ManifestError(path, "expected string object keys")
return cast("Mapping[str, object]", mapping)
def _list(value: object, path: str) -> list[object]: def _list(value: object, path: str) -> list[object]:
if not isinstance(value, list): if not isinstance(value, list):
raise ManifestError(path, "expected an array") raise ManifestError(path, "expected an array")
@@ -39,29 +70,83 @@ def _boolean(value: object, path: str) -> bool:
def _json_value(value: object, path: str) -> JsonValue: def _json_value(value: object, path: str) -> JsonValue:
# Generated titles are removed recursively; every other schema keyword/value stays opaque. if value is None or isinstance(value, bool | int | str):
if value is None or isinstance(value, bool | int | float | str): return value
if isinstance(value, float):
if not math.isfinite(value):
raise ManifestError(path, "expected a finite JSON number")
return value return value
if isinstance(value, list): if isinstance(value, list):
return [_json_value(item, f"{path}[{index}]") for index, item in enumerate(value)] return [
_json_value(item, f"{path}[{index}]") for index, item in enumerate(value)
]
if isinstance(value, Mapping): if isinstance(value, Mapping):
normalized: dict[str, JsonValue] = {} normalized: dict[str, JsonValue] = {}
for key, item in value.items(): for key, item in value.items():
if not isinstance(key, str): if not isinstance(key, str):
raise ManifestError(path, "expected string object keys") raise ManifestError(path, "expected string object keys")
if key != "title": normalized[key] = _json_value(item, f"{path}.{key}")
normalized[key] = _json_value(item, f"{path}.{key}")
return normalized return normalized
raise ManifestError(path, "expected a JSON value") raise ManifestError(path, "expected a JSON value")
def _schema_child(value: object, path: str) -> JsonValue:
if isinstance(value, Mapping):
return _schema(value, path)
if isinstance(value, list):
return [
_schema_child(item, f"{path}[{index}]") for index, item in enumerate(value)
]
return _json_value(value, path)
def _schema_map(value: object, path: str) -> JsonValue:
if not isinstance(value, Mapping):
return _json_value(value, path)
mapping = _string_key_mapping(value, path)
return {
key: _schema_child(child, f"{path}.{key}") for key, child in mapping.items()
}
def _schema(value: object, path: str) -> JsonSchema: def _schema(value: object, path: str) -> JsonSchema:
normalized = _json_value(value, path) if not isinstance(value, Mapping):
if not isinstance(normalized, dict):
raise ManifestError(path, "expected a schema object") raise ManifestError(path, "expected a schema object")
mapping = _string_key_mapping(value, path)
normalized: JsonSchema = {}
for key, child in mapping.items():
child_path = f"{path}.{key}"
if key == "title":
continue
if key in _SCHEMA_MAP_KEYWORDS:
normalized[key] = _schema_map(child, child_path)
elif key in _SINGLE_SCHEMA_KEYWORDS:
normalized[key] = _schema_child(child, child_path)
elif key in _SCHEMA_ARRAY_KEYWORDS and isinstance(child, list):
normalized[key] = [
_schema_child(item, f"{child_path}[{index}]")
for index, item in enumerate(child)
]
else:
normalized[key] = _json_value(child, child_path)
return normalized return normalized
def _error_component(value: object, path: str) -> JsonValue:
"""Normalize an OpenRPC error object, whose ``data`` member is a schema."""
if not isinstance(value, Mapping):
return _json_value(value, path)
mapping = _string_key_mapping(value, path)
return {
key: (
_schema_child(child, f"{path}.{key}")
if key == "data"
else _json_value(child, f"{path}.{key}")
)
for key, child in mapping.items()
}
def _walk_references(value: JsonValue, path: str) -> Iterator[tuple[str, str]]: def _walk_references(value: JsonValue, path: str) -> Iterator[tuple[str, str]]:
"""Yield every ``$ref`` while treating JSON Schema vocabulary as opaque.""" """Yield every ``$ref`` while treating JSON Schema vocabulary as opaque."""
if isinstance(value, dict): if isinstance(value, dict):
@@ -91,7 +176,9 @@ def _validate_references(
parameter["schema"], parameter["schema"],
) )
) )
values.append((f"{operation_path}.result.schema", operation["result"]["schema"])) values.append(
(f"{operation_path}.result.schema", operation["result"]["schema"])
)
for error_index, error in enumerate(operation["errors"]): for error_index, error in enumerate(operation["errors"]):
values.append((f"{operation_path}.errors[{error_index}]", error)) values.append((f"{operation_path}.errors[{error_index}]", error))
@@ -103,7 +190,9 @@ def _validate_references(
for value_path, value in values: for value_path, value in values:
for reference_path, reference in _walk_references(value, value_path): for reference_path, reference in _walk_references(value, value_path):
if not reference.startswith("#/"): if not reference.startswith("#/"):
raise ManifestError(reference_path, "external references are not supported") raise ManifestError(
reference_path, "external references are not supported"
)
parts = reference[2:].split("/") parts = reference[2:].split("/")
if len(parts) != 3 or parts[0] != "components": if len(parts) != 3 or parts[0] != "components":
@@ -133,8 +222,10 @@ def manifest_from_openrpc(document: Mapping[str, object]) -> ContractManifest:
) )
methods = _list(document.get("methods"), "$.methods") methods = _list(document.get("methods"), "$.methods")
components = _mapping(document.get("components"), "$.components") components = _mapping(document.get("components"), "$.components")
schemas = _mapping(components.get("schemas"), "$.components.schemas") schemas = _string_key_mapping(components.get("schemas"), "$.components.schemas")
component_errors = _mapping(components.get("errors"), "$.components.errors") component_errors = _string_key_mapping(
components.get("errors"), "$.components.errors"
)
operations: list[ManifestOperation] = [] operations: list[ManifestOperation] = []
seen_methods: set[str] = set() seen_methods: set[str] = set()
@@ -193,9 +284,7 @@ def manifest_from_openrpc(document: Mapping[str, object]) -> ContractManifest:
for error_index, error_value in enumerate( for error_index, error_value in enumerate(
_list(method.get("errors"), f"{method_path}.errors") _list(method.get("errors"), f"{method_path}.errors")
): ):
errors.append( errors.append(_schema(error_value, f"{method_path}.errors[{error_index}]"))
_schema(error_value, f"{method_path}.errors[{error_index}]")
)
operations.append( operations.append(
{ {
@@ -213,7 +302,7 @@ def manifest_from_openrpc(document: Mapping[str, object]) -> ContractManifest:
for key in sorted(schemas) for key in sorted(schemas)
} }
normalized_errors = { normalized_errors = {
key: _json_value(component_errors[key], f"$.components.errors.{key}") key: _error_component(component_errors[key], f"$.components.errors.{key}")
for key in sorted(component_errors) for key in sorted(component_errors)
} }
+8 -1
View File
@@ -63,8 +63,15 @@ def synthetic_openrpc_document() -> dict[str, Any]:
"properties": { "properties": {
"mode": {"title": "Mode", "const": "alpha"}, "mode": {"title": "Mode", "const": "alpha"},
"payload": {}, "payload": {},
"title": {"title": "Display Title", "type": "string"},
},
"required": ["mode", "payload", "title"],
"$defs": {
"title": {
"title": "Reusable Title",
"type": "string",
}
}, },
"required": ["mode", "payload"],
"if": {"properties": {"mode": {"const": "alpha"}}}, "if": {"properties": {"mode": {"const": "alpha"}}},
"then": {"required": ["payload"]}, "then": {"required": ["payload"]},
"not": {"required": ["forbidden"]}, "not": {"required": ["forbidden"]},
+99 -6
View File
@@ -1,5 +1,8 @@
from __future__ import annotations from __future__ import annotations
from collections.abc import Iterator, Mapping
from typing import Any
from wf_contract_manifest import generate_manifest from wf_contract_manifest import generate_manifest
UNION_RESULTS = { UNION_RESULTS = {
@@ -18,6 +21,69 @@ AUTH_SECURITY_COMPONENTS = {
"SourceDiagnosisResult", "SourceDiagnosisResult",
} }
AUTH_METHODS = {
"workflow.admin.auth.delete",
"workflow.admin.auth.inspect",
"workflow.admin.auth.list",
"workflow.admin.auth.save",
}
def _schema_references(value: Any) -> Iterator[str]:
if isinstance(value, Mapping):
reference = value.get("$ref")
if isinstance(reference, str):
yield reference
for child in value.values():
yield from _schema_references(child)
elif isinstance(value, list):
for child in value:
yield from _schema_references(child)
def _reachable_schema_names(schemas: Mapping[str, Any], roots: set[str]) -> set[str]:
"""Return schema components reachable through local schema references."""
reachable: set[str] = set()
pending = list(roots)
while pending:
name = pending.pop()
if name in reachable:
continue
reachable.add(name)
prefix = "#/components/schemas/"
for reference in _schema_references(schemas[name]):
if reference.startswith(prefix):
pending.append(reference.removeprefix(prefix))
return reachable
def _structured_strings(value: Any) -> Iterator[str]:
if isinstance(value, str):
yield value
elif isinstance(value, Mapping):
for key, child in value.items():
yield str(key)
yield from _structured_strings(child)
elif isinstance(value, list):
for child in value:
yield from _structured_strings(child)
def _schema_objects(value: Any) -> Iterator[Mapping[str, Any]]:
if isinstance(value, Mapping):
yield value
for child in value.values():
yield from _schema_objects(child)
elif isinstance(value, list):
for child in value:
yield from _schema_objects(child)
def _result_component_name(operation: Any) -> str:
reference = operation["result"]["schema"]["$ref"]
assert isinstance(reference, str)
return reference.removeprefix("#/components/schemas/")
def test_generates_the_complete_real_workflow_contract() -> None: def test_generates_the_complete_real_workflow_contract() -> None:
manifest = generate_manifest() manifest = generate_manifest()
@@ -35,7 +101,8 @@ def test_generates_the_complete_real_workflow_contract() -> None:
def test_generated_contract_preserves_security_and_extension_boundaries() -> None: def test_generated_contract_preserves_security_and_extension_boundaries() -> None:
schemas = generate_manifest()["components"]["schemas"] manifest = generate_manifest()
schemas = manifest["components"]["schemas"]
assert AUTH_SECURITY_COMPONENTS <= schemas.keys() assert AUTH_SECURITY_COMPONENTS <= schemas.keys()
for name in AUTH_SECURITY_COMPONENTS: for name in AUTH_SECURITY_COMPONENTS:
@@ -43,14 +110,40 @@ def test_generated_contract_preserves_security_and_extension_boundaries() -> Non
assert isinstance(properties, dict) assert isinstance(properties, dict)
assert "payload" not in properties assert "payload" not in properties
result_components = {
_result_component_name(operation)
for operation in manifest["operations"]
if operation["method"] in AUTH_METHODS
}
assert {
operation["method"] for operation in manifest["operations"]
} & AUTH_METHODS == AUTH_METHODS
reachable = _reachable_schema_names(schemas, result_components)
for name in reachable:
for schema in _schema_objects(schemas[name]):
properties = schema.get("properties", {})
if isinstance(properties, Mapping):
assert "payload" not in properties, name
assert schemas["SourceDiagnosisResult"]["additionalProperties"] is True assert schemas["SourceDiagnosisResult"]["additionalProperties"] is True
assert schemas["RegistryEntryPayload"]["additionalProperties"] is True assert schemas["RegistryEntryPayload"]["additionalProperties"] is True
def test_generated_contract_contains_no_temporary_or_transport_state() -> None: def test_generated_contract_contains_no_temporary_or_transport_state() -> None:
serialized = str(generate_manifest()) strings = set(_structured_strings(generate_manifest()))
assert "TemporaryDirectory" not in serialized assert not any("TemporaryDirectory" in value for value in strings)
assert "\\\\Temp\\\\" not in serialized assert not any("\\Temp\\" in value for value in strings)
assert "127.0.0.1" not in serialized assert "127.0.0.1" not in strings
assert '"/rpc"' not in serialized assert "/rpc" not in strings
def test_generated_required_properties_are_declared() -> None:
schemas = generate_manifest()["components"]["schemas"]
for component_name, component in schemas.items():
for schema in _schema_objects(component):
required = schema.get("required")
properties = schema.get("properties")
if isinstance(required, list) and isinstance(properties, Mapping):
assert set(required) <= properties.keys(), component_name
+10
View File
@@ -1,5 +1,6 @@
from __future__ import annotations from __future__ import annotations
import math
from collections.abc import Mapping from collections.abc import Mapping
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
@@ -51,6 +52,15 @@ def test_canonical_json_ignores_recursive_mapping_insertion_order() -> None:
) )
@pytest.mark.parametrize("value", [math.nan, math.inf, -math.inf])
def test_canonical_json_rejects_non_finite_numbers(value: float) -> None:
manifest = _manifest()
manifest["components"]["schemas"]["FreeJson"] = {"const": value}
with pytest.raises(ValueError, match="manifest is not canonically serializable"):
canonical_manifest_json(manifest)
def test_write_and_check_round_trip(tmp_path: Path) -> None: def test_write_and_check_round_trip(tmp_path: Path) -> None:
path = tmp_path / "workflow-api.manifest.json" path = tmp_path / "workflow-api.manifest.json"
+60 -14
View File
@@ -1,5 +1,6 @@
from __future__ import annotations from __future__ import annotations
import math
from copy import deepcopy from copy import deepcopy
from typing import Any, Protocol from typing import Any, Protocol
@@ -14,9 +15,7 @@ class DocumentMutation(Protocol):
def __call__(self, document: dict[str, Any]) -> object: ... def __call__(self, document: dict[str, Any]) -> object: ...
def assert_manifest_error( def assert_manifest_error(document: dict[str, Any], path: str, message: str) -> None:
document: dict[str, Any], path: str, message: str
) -> None:
with pytest.raises(ManifestError) as error_info: with pytest.raises(ManifestError) as error_info:
manifest_from_openrpc(document) manifest_from_openrpc(document)
@@ -44,9 +43,7 @@ def test_normalizes_operations_and_components_deterministically() -> None:
assert manifest["operations"][0]["result"] == { assert manifest["operations"][0]["result"] == {
"schema": {"$ref": "#/components/schemas/AlphaResult"} "schema": {"$ref": "#/components/schemas/AlphaResult"}
} }
assert manifest["operations"][0]["errors"] == [ assert manifest["operations"][0]["errors"] == [{"$ref": "#/components/errors/5000"}]
{"$ref": "#/components/errors/5000"}
]
assert list(manifest["components"]["schemas"]) == [ assert list(manifest["components"]["schemas"]) == [
"AlphaResult", "AlphaResult",
"FreeJson", "FreeJson",
@@ -81,17 +78,34 @@ def test_removes_only_titles_and_preserves_unknown_schema_keywords() -> None:
optional_schema = manifest["operations"][0]["params"][0]["schema"] optional_schema = manifest["operations"][0]["params"][0]["schema"]
assert "title" not in optional_schema assert "title" not in optional_schema
assert optional_schema["x-future-keyword"] == {"value": 1} assert optional_schema["x-future-keyword"] == {
"title": "removed recursively",
"value": 1,
}
assert manifest["components"]["schemas"]["FreeJson"] == {} assert manifest["components"]["schemas"]["FreeJson"] == {}
assert manifest["components"]["schemas"]["ZetaResult"]["properties"] == { assert manifest["components"]["schemas"]["ZetaResult"]["properties"] == {
"extension": {"additionalProperties": True} "extension": {"additionalProperties": True}
} }
def test_preserves_named_title_entries_in_schema_maps() -> None:
alpha = manifest_from_openrpc(synthetic_openrpc_document())["components"][
"schemas"
]["AlphaResult"]
assert alpha["required"] == ["mode", "payload", "title"]
properties = alpha["properties"]
definitions = alpha["$defs"]
assert isinstance(properties, dict)
assert isinstance(definitions, dict)
assert properties["title"] == {"type": "string"}
assert definitions["title"] == {"type": "string"}
def test_preserves_conditional_schema_keywords() -> None: def test_preserves_conditional_schema_keywords() -> None:
alpha = manifest_from_openrpc(synthetic_openrpc_document())["components"]["schemas"][ alpha = manifest_from_openrpc(synthetic_openrpc_document())["components"][
"AlphaResult" "schemas"
] ]["AlphaResult"]
assert alpha["if"] == {"properties": {"mode": {"const": "alpha"}}} assert alpha["if"] == {"properties": {"mode": {"const": "alpha"}}}
assert alpha["then"] == {"required": ["payload"]} assert alpha["then"] == {"required": ["payload"]}
@@ -240,6 +254,36 @@ def test_rejects_invalid_method_error_schema_shape() -> None:
) )
@pytest.mark.parametrize("value", [math.nan, math.inf, -math.inf])
def test_rejects_non_finite_schema_numbers_with_exact_path(value: float) -> None:
document = synthetic_openrpc_document()
document["components"]["schemas"]["AlphaResult"]["properties"]["mode"]["const"] = (
value
)
assert_manifest_error(
document,
"$.components.schemas.AlphaResult.properties.mode.const",
"expected a finite JSON number",
)
@pytest.mark.parametrize(
("namespace", "path"),
[
("schemas", "$.components.schemas"),
("errors", "$.components.errors"),
],
)
def test_rejects_non_string_component_keys_before_sorting(
namespace: str, path: str
) -> None:
document = synthetic_openrpc_document()
document["components"][namespace][1] = {}
assert_manifest_error(document, path, "expected string object keys")
@pytest.mark.parametrize( @pytest.mark.parametrize(
("mutate", "path", "message"), ("mutate", "path", "message"),
[ [
@@ -248,7 +292,11 @@ def test_rejects_invalid_method_error_schema_shape() -> None:
"$.openrpc", "$.openrpc",
"unsupported OpenRPC version '2.0.0'; expected '1.2.6'", "unsupported OpenRPC version '2.0.0'; expected '1.2.6'",
), ),
(lambda document: document.update({"methods": {}}), "$.methods", "expected an array"), (
lambda document: document.update({"methods": {}}),
"$.methods",
"expected an array",
),
( (
lambda document: document["methods"].append( lambda document: document["methods"].append(
deepcopy(document["methods"][0]) deepcopy(document["methods"][0])
@@ -338,9 +386,7 @@ def test_reports_reference_errors_at_original_method_path() -> None:
"workflow.zeta.run", "workflow.zeta.run",
"workflow.alpha.inspect", "workflow.alpha.inspect",
] ]
document["methods"][0]["errors"][0] = { document["methods"][0]["errors"][0] = {"$ref": "#/components/errors/Missing"}
"$ref": "#/components/errors/Missing"
}
with pytest.raises(ManifestError) as exc_info: with pytest.raises(ManifestError) as exc_info:
manifest_from_openrpc(document) manifest_from_openrpc(document)