fix: preserve named title contract schemas
This commit is contained in:
@@ -570,6 +570,16 @@
|
||||
"summary": {
|
||||
"$ref": "#/components/schemas/DraftWorkspaceSummary"
|
||||
},
|
||||
"title": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"workspace_id": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -1358,6 +1368,16 @@
|
||||
"summary": {
|
||||
"$ref": "#/components/schemas/DraftWorkspaceSummary"
|
||||
},
|
||||
"title": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"workspace_id": {
|
||||
"type": "string"
|
||||
}
|
||||
@@ -3399,6 +3419,17 @@
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"title": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"default": null
|
||||
},
|
||||
"type": {
|
||||
"anyOf": [
|
||||
{
|
||||
@@ -4119,6 +4150,9 @@
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"title": {
|
||||
"type": "string"
|
||||
},
|
||||
"version": {
|
||||
"type": "integer"
|
||||
},
|
||||
@@ -4281,6 +4315,9 @@
|
||||
"source_id": {
|
||||
"type": "string"
|
||||
},
|
||||
"title": {
|
||||
"type": "string"
|
||||
},
|
||||
"version": {
|
||||
"type": "integer"
|
||||
},
|
||||
@@ -4352,6 +4389,9 @@
|
||||
"source_id": {
|
||||
"type": "string"
|
||||
},
|
||||
"title": {
|
||||
"type": "string"
|
||||
},
|
||||
"version": {
|
||||
"type": "integer"
|
||||
}
|
||||
|
||||
@@ -41,7 +41,7 @@
|
||||
- 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.
|
||||
|
||||
- [ ] **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:
|
||||
|
||||
@@ -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`:
|
||||
|
||||
@@ -200,7 +200,7 @@ def test_preserves_conditional_schema_keywords() -> None:
|
||||
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:
|
||||
|
||||
@@ -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'`.
|
||||
|
||||
- [ ] **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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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.
|
||||
|
||||
- [ ] **Step 6: Run focused tests and static checks**
|
||||
- [x] **Step 6: Run focused tests and static checks**
|
||||
|
||||
Run:
|
||||
|
||||
@@ -370,7 +370,7 @@ Run:
|
||||
|
||||
Expected: all normalization tests pass; Ruff and basedpyright report no errors.
|
||||
|
||||
- [ ] **Step 7: Commit Task 1**
|
||||
- [x] **Step 7: Commit Task 1**
|
||||
|
||||
```powershell
|
||||
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.
|
||||
- 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`:
|
||||
|
||||
@@ -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.
|
||||
|
||||
- [ ] **Step 2: Add failing reference-graph tests**
|
||||
- [x] **Step 2: Add failing reference-graph tests**
|
||||
|
||||
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:
|
||||
|
||||
@@ -512,7 +512,7 @@ Run:
|
||||
|
||||
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`:
|
||||
|
||||
@@ -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.
|
||||
|
||||
- [ ] **Step 5: Implement one complete reference-graph walk**
|
||||
- [x] **Step 5: Implement one complete reference-graph walk**
|
||||
|
||||
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.
|
||||
|
||||
- [ ] **Step 6: Run focused tests and static checks**
|
||||
- [x] **Step 6: Run focused tests and static checks**
|
||||
|
||||
```powershell
|
||||
.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.
|
||||
|
||||
- [ ] **Step 7: Commit Task 2**
|
||||
- [x] **Step 7: Commit Task 2**
|
||||
|
||||
```powershell
|
||||
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`.
|
||||
- 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`:
|
||||
|
||||
@@ -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.
|
||||
|
||||
- [ ] **Step 2: Write canonical I/O tests**
|
||||
- [x] **Step 2: Write canonical I/O tests**
|
||||
|
||||
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
|
||||
```
|
||||
|
||||
- [ ] **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
|
||||
.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.
|
||||
|
||||
- [ ] **Step 4: Implement real in-process generation**
|
||||
- [x] **Step 4: Implement real in-process generation**
|
||||
|
||||
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.
|
||||
|
||||
- [ ] **Step 5: Implement byte-canonical I/O**
|
||||
- [x] **Step 5: Implement byte-canonical I/O**
|
||||
|
||||
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.
|
||||
|
||||
- [ ] **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:
|
||||
|
||||
@@ -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
|
||||
.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.
|
||||
|
||||
- [ ] **Step 8: Commit Task 3**
|
||||
- [x] **Step 8: Commit Task 3**
|
||||
|
||||
```powershell
|
||||
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.
|
||||
- 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`:
|
||||
|
||||
@@ -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.
|
||||
|
||||
- [ ] **Step 2: Run the CLI test and confirm it is red**
|
||||
- [x] **Step 2: Run the CLI test and confirm it is red**
|
||||
|
||||
```powershell
|
||||
.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.
|
||||
|
||||
- [ ] **Step 3: Implement the module CLI**
|
||||
- [x] **Step 3: Implement the module CLI**
|
||||
|
||||
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.
|
||||
|
||||
- [ ] **Step 4: Run the CLI test and static checks**
|
||||
- [x] **Step 4: Run the CLI test and static checks**
|
||||
|
||||
```powershell
|
||||
.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.
|
||||
|
||||
- [ ] **Step 5: Generate and independently check the committed artifact**
|
||||
- [x] **Step 5: Generate and independently check the committed artifact**
|
||||
|
||||
```powershell
|
||||
.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.
|
||||
|
||||
- [ ] **Step 6: Commit Task 4**
|
||||
- [x] **Step 6: Commit Task 4**
|
||||
|
||||
```powershell
|
||||
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.
|
||||
- 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`:
|
||||
|
||||
@@ -1023,7 +1023,7 @@ def test_committed_manifest_matches_the_python_workflow_contract() -> None:
|
||||
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:
|
||||
|
||||
@@ -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.
|
||||
|
||||
- [ ] **Step 3: Run the restored drift gate**
|
||||
- [x] **Step 3: Run the restored drift gate**
|
||||
|
||||
```powershell
|
||||
.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.
|
||||
|
||||
- [ ] **Step 4: Update current documentation**
|
||||
- [x] **Step 4: Update current documentation**
|
||||
|
||||
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.
|
||||
|
||||
- [ ] **Step 5: Run the complete scoped verification gate**
|
||||
- [x] **Step 5: Run the complete scoped verification gate**
|
||||
|
||||
```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 wf_contract_manifest check
|
||||
.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;
|
||||
- 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:
|
||||
|
||||
@@ -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.
|
||||
|
||||
- [ ] **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:
|
||||
|
||||
@@ -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"
|
||||
```
|
||||
|
||||
- [ ] **Step 8: Remove only the plan-owned temporary pytest directory**
|
||||
- [x] **Step 8: Remove only the plan-owned temporary pytest directory**
|
||||
|
||||
```powershell
|
||||
$temp = (Resolve-Path '.pytest-tmp').Path
|
||||
|
||||
@@ -15,9 +15,20 @@ class ManifestDriftError(RuntimeError):
|
||||
|
||||
def canonical_manifest_json(manifest: ContractManifest) -> str:
|
||||
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:
|
||||
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(
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from collections.abc import Iterator, Mapping
|
||||
from typing import cast
|
||||
|
||||
from .model import (
|
||||
ContractManifest,
|
||||
@@ -13,6 +15,28 @@ from .model import (
|
||||
|
||||
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]:
|
||||
if not isinstance(value, Mapping):
|
||||
@@ -20,6 +44,13 @@ def _mapping(value: object, path: str) -> Mapping[str, object]:
|
||||
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]:
|
||||
if not isinstance(value, list):
|
||||
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:
|
||||
# Generated titles are removed recursively; every other schema keyword/value stays opaque.
|
||||
if value is None or isinstance(value, bool | int | float | str):
|
||||
if value is None or isinstance(value, bool | int | str):
|
||||
return value
|
||||
if isinstance(value, float):
|
||||
if not math.isfinite(value):
|
||||
raise ManifestError(path, "expected a finite JSON number")
|
||||
return value
|
||||
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):
|
||||
normalized: dict[str, JsonValue] = {}
|
||||
for key, item in value.items():
|
||||
if not isinstance(key, str):
|
||||
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
|
||||
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:
|
||||
normalized = _json_value(value, path)
|
||||
if not isinstance(normalized, dict):
|
||||
if not isinstance(value, Mapping):
|
||||
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
|
||||
|
||||
|
||||
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]]:
|
||||
"""Yield every ``$ref`` while treating JSON Schema vocabulary as opaque."""
|
||||
if isinstance(value, dict):
|
||||
@@ -91,7 +176,9 @@ def _validate_references(
|
||||
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"]):
|
||||
values.append((f"{operation_path}.errors[{error_index}]", error))
|
||||
|
||||
@@ -103,7 +190,9 @@ def _validate_references(
|
||||
for value_path, value in values:
|
||||
for reference_path, reference in _walk_references(value, value_path):
|
||||
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("/")
|
||||
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")
|
||||
components = _mapping(document.get("components"), "$.components")
|
||||
schemas = _mapping(components.get("schemas"), "$.components.schemas")
|
||||
component_errors = _mapping(components.get("errors"), "$.components.errors")
|
||||
schemas = _string_key_mapping(components.get("schemas"), "$.components.schemas")
|
||||
component_errors = _string_key_mapping(
|
||||
components.get("errors"), "$.components.errors"
|
||||
)
|
||||
|
||||
operations: list[ManifestOperation] = []
|
||||
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(
|
||||
_list(method.get("errors"), f"{method_path}.errors")
|
||||
):
|
||||
errors.append(
|
||||
_schema(error_value, f"{method_path}.errors[{error_index}]")
|
||||
)
|
||||
errors.append(_schema(error_value, f"{method_path}.errors[{error_index}]"))
|
||||
|
||||
operations.append(
|
||||
{
|
||||
@@ -213,7 +302,7 @@ def manifest_from_openrpc(document: Mapping[str, object]) -> ContractManifest:
|
||||
for key in sorted(schemas)
|
||||
}
|
||||
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)
|
||||
}
|
||||
|
||||
|
||||
@@ -63,8 +63,15 @@ def synthetic_openrpc_document() -> dict[str, Any]:
|
||||
"properties": {
|
||||
"mode": {"title": "Mode", "const": "alpha"},
|
||||
"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"}}},
|
||||
"then": {"required": ["payload"]},
|
||||
"not": {"required": ["forbidden"]},
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Iterator, Mapping
|
||||
from typing import Any
|
||||
|
||||
from wf_contract_manifest import generate_manifest
|
||||
|
||||
UNION_RESULTS = {
|
||||
@@ -18,6 +21,69 @@ AUTH_SECURITY_COMPONENTS = {
|
||||
"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:
|
||||
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:
|
||||
schemas = generate_manifest()["components"]["schemas"]
|
||||
manifest = generate_manifest()
|
||||
schemas = manifest["components"]["schemas"]
|
||||
|
||||
assert AUTH_SECURITY_COMPONENTS <= schemas.keys()
|
||||
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 "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["RegistryEntryPayload"]["additionalProperties"] is True
|
||||
|
||||
|
||||
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 "\\\\Temp\\\\" not in serialized
|
||||
assert "127.0.0.1" not in serialized
|
||||
assert '"/rpc"' not in serialized
|
||||
assert not any("TemporaryDirectory" in value for value in strings)
|
||||
assert not any("\\Temp\\" in value for value in strings)
|
||||
assert "127.0.0.1" not in strings
|
||||
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
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from collections.abc import Mapping
|
||||
from pathlib import Path
|
||||
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:
|
||||
path = tmp_path / "workflow-api.manifest.json"
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from copy import deepcopy
|
||||
from typing import Any, Protocol
|
||||
|
||||
@@ -14,9 +15,7 @@ class DocumentMutation(Protocol):
|
||||
def __call__(self, document: dict[str, Any]) -> object: ...
|
||||
|
||||
|
||||
def assert_manifest_error(
|
||||
document: dict[str, Any], path: str, message: str
|
||||
) -> None:
|
||||
def assert_manifest_error(document: dict[str, Any], path: str, message: str) -> None:
|
||||
with pytest.raises(ManifestError) as error_info:
|
||||
manifest_from_openrpc(document)
|
||||
|
||||
@@ -44,9 +43,7 @@ def test_normalizes_operations_and_components_deterministically() -> None:
|
||||
assert manifest["operations"][0]["result"] == {
|
||||
"schema": {"$ref": "#/components/schemas/AlphaResult"}
|
||||
}
|
||||
assert manifest["operations"][0]["errors"] == [
|
||||
{"$ref": "#/components/errors/5000"}
|
||||
]
|
||||
assert manifest["operations"][0]["errors"] == [{"$ref": "#/components/errors/5000"}]
|
||||
assert list(manifest["components"]["schemas"]) == [
|
||||
"AlphaResult",
|
||||
"FreeJson",
|
||||
@@ -81,17 +78,34 @@ def test_removes_only_titles_and_preserves_unknown_schema_keywords() -> None:
|
||||
optional_schema = manifest["operations"][0]["params"][0]["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"]["ZetaResult"]["properties"] == {
|
||||
"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:
|
||||
alpha = manifest_from_openrpc(synthetic_openrpc_document())["components"]["schemas"][
|
||||
"AlphaResult"
|
||||
]
|
||||
alpha = manifest_from_openrpc(synthetic_openrpc_document())["components"][
|
||||
"schemas"
|
||||
]["AlphaResult"]
|
||||
|
||||
assert alpha["if"] == {"properties": {"mode": {"const": "alpha"}}}
|
||||
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(
|
||||
("mutate", "path", "message"),
|
||||
[
|
||||
@@ -248,7 +292,11 @@ def test_rejects_invalid_method_error_schema_shape() -> None:
|
||||
"$.openrpc",
|
||||
"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(
|
||||
deepcopy(document["methods"][0])
|
||||
@@ -338,9 +386,7 @@ def test_reports_reference_errors_at_original_method_path() -> None:
|
||||
"workflow.zeta.run",
|
||||
"workflow.alpha.inspect",
|
||||
]
|
||||
document["methods"][0]["errors"][0] = {
|
||||
"$ref": "#/components/errors/Missing"
|
||||
}
|
||||
document["methods"][0]["errors"][0] = {"$ref": "#/components/errors/Missing"}
|
||||
|
||||
with pytest.raises(ManifestError) as exc_info:
|
||||
manifest_from_openrpc(document)
|
||||
|
||||
Reference in New Issue
Block a user