From aefd0fcc503b40c27b5585b73dfb3269134c63ce Mon Sep 17 00:00:00 2001 From: Takashi Yamashina Date: Fri, 31 Jul 2026 18:25:09 +0900 Subject: [PATCH 1/2] test: add golden wire-contract fixtures and breaking-change CI guard (#301) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pins the current http_contract: 1 / mcp_contract: 1 wire shapes as machine-readable fixtures under tests/fixtures/wire/ (ADR 0005 §9), including #216's evidence-assembly package. Rust generates and verifies them against a live server (tests/http_api/contract.rs); Python and TypeScript read the same files (sdk/python/tests/unit/ test_wire_contract.py, sdk/typescript/tests/unit/wire-contract.test.ts). New sdk/spec/check_contract.py classifies fixture changes against a base ref per ADR 0005 §4 and fails CI (new contract-guard job) unless a breaking change lands beside a matching HTTP_CONTRACT/MCP_CONTRACT bump. Claude-Session: https://claude.ai/code/session_011KBpNVCCcdTUtCuPdpXh2n --- .github/workflows/ci.yml | 26 + CHANGELOG.md | 20 + CONTRIBUTING.md | 20 + sdk/python/tests/unit/test_wire_contract.py | 99 ++ sdk/spec/check_contract.py | 409 +++++++ .../tests/unit/wire-contract.test.ts | 121 ++ tests/fixtures/wire/README.md | 98 ++ tests/fixtures/wire/http/activate.json | 43 + .../wire/http/communities_search.json | 59 + tests/fixtures/wire/http/contexts_list.json | 62 + tests/fixtures/wire/http/error_forbidden.json | 19 + .../wire/http/error_malformed_request.json | 19 + .../fixtures/wire/http/error_no_context.json | 18 + .../fixtures/wire/http/error_over_limit.json | 1018 +++++++++++++++++ .../http/evidence_budget_constrained.json | 136 +++ ...communities_degrade_and_rerank_reason.json | 94 ++ .../http/evidence_contradiction_group.json | 184 +++ .../wire/http/evidence_duplicate_passage.json | 103 ++ .../wire/http/evidence_mixed_lanes.json | 149 +++ tests/fixtures/wire/http/explore.json | 43 + tests/fixtures/wire/http/health.json | 12 + tests/fixtures/wire/http/recall.json | 40 + tests/fixtures/wire/http/sources_search.json | 46 + tests/fixtures/wire/http/version.json | 44 + .../wire/mcp/assemble_evidence_call.json | 20 + .../mcp/assemble_evidence_tool_error.json | 18 + .../mcp/assemble_evidence_tool_schema.json | 108 ++ tests/fixtures/wire/shapes.json | 62 + tests/http_api/contract.rs | 875 ++++++++++++++ tests/http_api/main.rs | 1 + 30 files changed, 3966 insertions(+) create mode 100644 sdk/python/tests/unit/test_wire_contract.py create mode 100755 sdk/spec/check_contract.py create mode 100644 sdk/typescript/tests/unit/wire-contract.test.ts create mode 100644 tests/fixtures/wire/README.md create mode 100644 tests/fixtures/wire/http/activate.json create mode 100644 tests/fixtures/wire/http/communities_search.json create mode 100644 tests/fixtures/wire/http/contexts_list.json create mode 100644 tests/fixtures/wire/http/error_forbidden.json create mode 100644 tests/fixtures/wire/http/error_malformed_request.json create mode 100644 tests/fixtures/wire/http/error_no_context.json create mode 100644 tests/fixtures/wire/http/error_over_limit.json create mode 100644 tests/fixtures/wire/http/evidence_budget_constrained.json create mode 100644 tests/fixtures/wire/http/evidence_communities_degrade_and_rerank_reason.json create mode 100644 tests/fixtures/wire/http/evidence_contradiction_group.json create mode 100644 tests/fixtures/wire/http/evidence_duplicate_passage.json create mode 100644 tests/fixtures/wire/http/evidence_mixed_lanes.json create mode 100644 tests/fixtures/wire/http/explore.json create mode 100644 tests/fixtures/wire/http/health.json create mode 100644 tests/fixtures/wire/http/recall.json create mode 100644 tests/fixtures/wire/http/sources_search.json create mode 100644 tests/fixtures/wire/http/version.json create mode 100644 tests/fixtures/wire/mcp/assemble_evidence_call.json create mode 100644 tests/fixtures/wire/mcp/assemble_evidence_tool_error.json create mode 100644 tests/fixtures/wire/mcp/assemble_evidence_tool_schema.json create mode 100644 tests/fixtures/wire/shapes.json create mode 100644 tests/http_api/contract.rs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index dae0f363..c82b651e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -52,6 +52,32 @@ jobs: with: command: check advisories + # Independent of `check`: no Rust toolchain needed, and this only + # reads the wire fixtures `cargo test` (in `check`, above) already + # verified match the live server — it does not re-run the server + # itself. `fetch-depth: 0` so `git show`/`git ls-tree` can reach the + # base ref's committed fixtures (ADR 0005 §9, #301). + contract-guard: + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v7 + with: + fetch-depth: 0 + persist-credentials: false + - name: Fixtures <-> shapes.json self-consistency + run: python3 sdk/spec/check_contract.py --check + - name: Determine base ref + id: base + run: | + if [ -n "${{ github.event.pull_request.base.sha }}" ]; then + echo "ref=${{ github.event.pull_request.base.sha }}" >> "$GITHUB_OUTPUT" + else + echo "ref=HEAD^" >> "$GITHUB_OUTPUT" + fi + - name: Breaking-change guard + run: python3 sdk/spec/check_contract.py --base "${{ steps.base.outputs.ref }}" + # Coverage and per-PR mutation testing used to run here too. Both # retired to keep CI at the signals that gate a merge: coverage # produced a report nobody's decision hung on, and diff-scoped diff --git a/CHANGELOG.md b/CHANGELOG.md index 43e92598..a4f59942 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -76,6 +76,26 @@ Entries that change an on-disk format or a response shape say so. against. Locked to the server's own version by `sdk/spec/check_versions.py`, the same way Python's `taguru.__version__` already is. +- Golden wire-contract fixtures and a breaking-change CI guard (#301, + ADR 0005 §9): `tests/fixtures/wire/` pins the current `http_contract: + 1`/`mcp_contract: 1` shapes — thirteen representative HTTP/MCP + operations, including five #216 evidence-assembly cases (mixed + lanes, budget-constrained, duplicate-passage suppression, a + contradiction group, and the communities/rerank degrade) — generated + from a live server (`tests/http_api/contract.rs`, + `TAGURU_UPDATE_WIRE_FIXTURES=1 cargo test --test http_api contract`) + and read identically by Python + (`sdk/python/tests/unit/test_wire_contract.py`) and TypeScript + (`sdk/typescript/tests/unit/wire-contract.test.ts`). New + `sdk/spec/check_contract.py` diffs the committed fixtures against a + base ref and fails a PR that ships a field removal, a container-shape + change (array ↔ object), a known enum value disappearing, a newly + required request field, or a removed operation without a matching + `HTTP_CONTRACT`/`MCP_CONTRACT` bump in `src/api.rs` — the mechanical + half of ADR 0005 §4's compatible/breaking table, run in CI's new + `contract-guard` job. `tests/fixtures/wire/README.md` documents the + update procedure, including the contract-version judgment call ADR + 0005 §4/§7 already require. ### Changed - The pre-1.0 compatibility guarantee (`src/llm-protocol.md` diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index a5fae2af..2b0904b0 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -34,6 +34,26 @@ All three run in CI ([.github/workflows/sdk.yml](.github/workflows/sdk.yml)); a change that adds or renames a public SDK method updates `surface.yaml` in the same commit. +## Wire-contract fixtures + +`http_contract: 1`/`mcp_contract: 1`'s public shape — every enveloped +HTTP response/request/error and the MCP-specific envelope, including +#216's evidence-assembly package — is pinned as golden fixtures in +[tests/fixtures/wire/](tests/fixtures/wire/), generated from a live +server and read identically by Rust, Python, and TypeScript: + +```sh +TAGURU_UPDATE_WIRE_FIXTURES=1 cargo test --test http_api contract # regenerate + Rust check +python sdk/spec/check_contract.py --check # fixtures <-> shapes.json +python sdk/spec/check_contract.py --base origin/main # breaking-change guard +``` + +All three run in CI (`check` and the new `contract-guard` job in +[.github/workflows/ci.yml](.github/workflows/ci.yml)); a change to a +pinned shape updates the fixtures in the same commit, classified per +[tests/fixtures/wire/README.md](tests/fixtures/wire/README.md) — see +that file before regenerating. + ## Running the examples [examples/](examples/) holds library-level examples that drive diff --git a/sdk/python/tests/unit/test_wire_contract.py b/sdk/python/tests/unit/test_wire_contract.py new file mode 100644 index 00000000..77819878 --- /dev/null +++ b/sdk/python/tests/unit/test_wire_contract.py @@ -0,0 +1,99 @@ +"""Golden wire-contract fixtures (#301): the Python SDK's own read of +``tests/fixtures/wire/``, alongside Rust's generator/verifier +(``tests/http_api/contract.rs``) and TypeScript's +(``sdk/typescript/tests/unit/wire-contract.test.ts``). Two checks: + +- every fixture whose response this SDK already has a typed model for + (``MatchPage``, ``PassagePage``, ``ContextPage``, ``ExplorePage``, + ``ActivationPage``, ``CommunityPage``) decodes through the real + ``taguru._decode.decode`` — the same function every live call uses — + without error; +- every fixture's declared enum-like fields only carry values + ``shapes.json`` knows about, reusing ``sdk/spec/check_contract.py``'s + own path matcher so the two checkers cannot silently disagree about + what a path expression means. + +#216's evidence-assembly package and the MCP-specific envelope have no +SDK model yet (#306 adds them); those fixtures are covered here only by +the shapes-driven structural check below, not a typed decode. +""" + +from __future__ import annotations + +import importlib.util +import json +from pathlib import Path +from typing import Any + +import pytest + +from taguru._decode import decode +from taguru._models import ( + ActivationPage, + CommunityPage, + ContextPage, + ExplorePage, + MatchPage, + PassagePage, +) + +# sdk/python/tests/unit/test_wire_contract.py -> repo root: same depth +# sdk/python-langchain/tests/unit/test_extract.py's own comment climbs +# (unit, tests, python, sdk). +REPO_ROOT = Path(__file__).resolve().parents[4] +WIRE_DIR = REPO_ROOT / "tests" / "fixtures" / "wire" + +# sdk/spec/check_contract.py is a script, not an installed package — +# loaded by path so this test reuses its `collect_by_path` matcher +# instead of a second copy that could silently drift from it. +_spec = importlib.util.spec_from_file_location( + "check_contract", REPO_ROOT / "sdk" / "spec" / "check_contract.py" +) +assert _spec is not None and _spec.loader is not None +check_contract = importlib.util.module_from_spec(_spec) +_spec.loader.exec_module(check_contract) + + +def _load_fixtures() -> list[tuple[Path, dict[str, Any]]]: + paths = sorted((WIRE_DIR / "http").glob("*.json")) + sorted((WIRE_DIR / "mcp").glob("*.json")) + return [(path, json.loads(path.read_text(encoding="utf-8"))) for path in paths] + + +FIXTURES = _load_fixtures() +FIXTURES_BY_STEM = {path.stem: fixture for path, fixture in FIXTURES} +SHAPES = json.loads((WIRE_DIR / "shapes.json").read_text(encoding="utf-8")) + +# operation -> the model this SDK already decodes its response into. +TYPED_OPERATIONS = { + "recall": MatchPage, + "contexts_list": ContextPage, + "sources_search": PassagePage, + "explore": ExplorePage, + "activate": ActivationPage, + "communities_search": CommunityPage, +} + + +def test_wire_fixture_corpus_is_not_empty() -> None: + assert FIXTURES, "tests/fixtures/wire must carry at least one fixture" + assert set(TYPED_OPERATIONS) <= {path.stem for path, _ in FIXTURES} + + +@pytest.mark.parametrize("operation", sorted(TYPED_OPERATIONS), ids=sorted(TYPED_OPERATIONS)) +def test_typed_operations_decode_through_the_real_sdk_decoder(operation: str) -> None: + fixture = FIXTURES_BY_STEM[operation] + model = TYPED_OPERATIONS[operation] + decoded = decode(model, fixture["response"]["result"]) + assert decoded is not None + + +@pytest.mark.parametrize("path,fixture", FIXTURES, ids=[path.name for path, _ in FIXTURES]) +def test_every_declared_enum_only_carries_known_values(path: Path, fixture: dict[str, Any]) -> None: + for path_expr, allowed in SHAPES["enums"].items(): + allowed_set = set(allowed) + for value in check_contract.collect_by_path(fixture, path_expr.split(".")): + if isinstance(value, str): + assert value in allowed_set, ( + f"{path.name}: {path_expr} carries {value!r}, which is not " + "declared in shapes.json's enums" + ) diff --git a/sdk/spec/check_contract.py b/sdk/spec/check_contract.py new file mode 100755 index 00000000..e7e5af90 --- /dev/null +++ b/sdk/spec/check_contract.py @@ -0,0 +1,409 @@ +#!/usr/bin/env python3 +"""Breaking-change guard over `tests/fixtures/wire/` (#301, ADR 0005 §4/§9). + +Classifies every difference between the golden wire-contract fixtures +committed at HEAD and at some earlier ref, using the same compatible/ +breaking table ADR 0005 §4 defines, and fails unless a genuinely +breaking change lands beside a matching `HTTP_CONTRACT`/`MCP_CONTRACT` +bump in `src/api.rs`. This does not replace human classification (see +`tests/fixtures/wire/README.md`); it is the mechanical backstop for the +cases ADR 0005 §4 already gives an unambiguous answer for — a removed +or renamed field, a container-shape change (array ↔ object), a known +enum value disappearing, a newly required request field, or a removed +operation. + +Deliberately NOT a general-purpose schema-diff engine (ADR 0005 §1 +scopes that out to #220's v0.7.0+ follow-up): array elements are +compared representative-element-only (index 0), and a scalar value +simply changing (a string becoming a different string, a null becoming +a number) is not itself classified — only container-shape changes +(array ↔ object ↔ scalar), field add/remove, and the two `shapes.json`- +declared cases (enum value removed, request field newly required) are. + + check_contract.py --check # fixtures <-> shapes.json self-consistency + check_contract.py --base origin/main # classify HEAD's changes against that ref +""" + +from __future__ import annotations + +import argparse +import json +import re +import subprocess +import sys +from pathlib import Path + +# Sibling script, not an installed package — `check_versions` is only +# reliably importable once its own directory is on sys.path (plain +# `python3 check_contract.py` already puts sdk/spec there, but a +# caller that loads this file by path, like +# sdk/python/tests/unit/test_wire_contract.py, would not). +sys.path.insert(0, str(Path(__file__).resolve().parent)) +from check_versions import rel # noqa: E402 + +ROOT = Path(__file__).resolve().parents[2] +WIRE_DIR = ROOT / "tests/fixtures/wire" +API_RS = ROOT / "src/api.rs" + +CONTRACT_CONST = re.compile(r"pub\(crate\) const (HTTP|MCP)_CONTRACT: u64 = (\d+);") + + +# --- path matching: "foo.bar[].baz" — "[]" means "every array element" --- + + +def collect_by_path(value: object, segments: list[str]) -> list[object]: + if not segments: + return [value] + head, *rest = segments + is_array = head.endswith("[]") + key = head[:-2] if is_array else head + if not isinstance(value, dict) or key not in value: + return [] + nxt = value[key] + if is_array: + if not isinstance(nxt, list): + return [] + collected: list[object] = [] + for item in nxt: + collected += collect_by_path(item, rest) + return collected + return collect_by_path(nxt, rest) + + +# --- fixture discovery --- + + +def load_json(path: Path) -> object: + return json.loads(path.read_text()) + + +def fixture_paths() -> list[Path]: + return sorted((WIRE_DIR / "http").glob("*.json")) + sorted( + (WIRE_DIR / "mcp").glob("*.json") + ) + + +# --- --check: fixtures <-> shapes.json self-consistency --- + + +def run_check() -> None: + shapes = load_json(WIRE_DIR / "shapes.json") + enums: dict[str, list[str]] = shapes.get("enums", {}) + required: dict[str, list[str]] = shapes.get("required_request_fields", {}) + problems = [] + routes_seen: set[str] = set() + + paths = fixture_paths() + for path in paths: + fixture = load_json(path) + for path_expr, allowed in enums.items(): + allowed_set = set(allowed) + for value in collect_by_path(fixture, path_expr.split(".")): + if isinstance(value, str) and value not in allowed_set: + problems.append( + f"{rel(path)}: {path_expr} carries {value!r}, not declared " + "in shapes.json's enums" + ) + route = fixture.get("route") + if isinstance(route, str): + routes_seen.add(route) + request = fixture.get("request") + if route in required and isinstance(request, dict): + for field in required[route]: + if field not in request: + problems.append( + f"{rel(path)}: shapes.json marks {field!r} required for " + f"{route}, but this fixture's request omits it" + ) + + # The reverse direction: a route named in required_request_fields + # with no fixture left to check it against is a stale entry (a + # renamed or removed route) that nothing else would catch. + for route in required: + if route not in routes_seen: + problems.append( + f"shapes.json's required_request_fields names {route!r}, which no " + "fixture's route matches" + ) + + if problems: + print("fixtures <-> shapes.json are out of sync:", file=sys.stderr) + for problem in problems: + print(f" {problem}", file=sys.stderr) + sys.exit(1) + print(f"shapes.json self-consistency: {len(paths)} fixtures agree") + + +# --- --base REF: classify HEAD's changes against REF --- + + +def git_show(ref: str, relpath: str) -> str | None: + result = subprocess.run( + ["git", "show", f"{ref}:{relpath}"], + cwd=ROOT, + capture_output=True, + text=True, + ) + return result.stdout if result.returncode == 0 else None + + +def git_ls_tree(ref: str, *relpaths: str) -> list[str]: + result = subprocess.run( + ["git", "ls-tree", "-r", "--name-only", ref, "--", *relpaths], + cwd=ROOT, + capture_output=True, + text=True, + ) + if result.returncode != 0: + return [] + return [line for line in result.stdout.splitlines() if line] + + +def kind(value: object) -> str: + if isinstance(value, dict): + return "object" + if isinstance(value, list): + return "array" + if isinstance(value, bool): + return "boolean" + if isinstance(value, (int, float)): + return "number" + if isinstance(value, str): + return "string" + if value is None: + return "null" + return "unknown" + + +def bucket(value: object) -> str: + """[`kind`], collapsed to the three buckets a container-shape change + is classified over — object, array, or everything else.""" + return kind(value) if isinstance(value, (dict, list)) else "scalar" + + +Finding = tuple[str, str, str] # (severity, path, detail) + + +def maybe_parse(value: object) -> object: + """A string that itself decodes to a JSON object/array is treated as + that object/array — an MCP tool result carries the whole HTTP body a + second time this way, as `content[].text` (ADR 0005 §2.4's pass- + through convention), and a shape change hiding inside that text + must be diffed structurally, not skipped as an opaque scalar. + """ + if isinstance(value, str): + try: + parsed = json.loads(value) + except ValueError: + return value + if isinstance(parsed, (dict, list)): + return parsed + return value + + +def classify(base: object, head: object, path: str) -> list[Finding]: + """Structural diff between `base` and `head`: field add/remove and + container-shape changes only (ADR 0005 §4) — see the module + docstring for what this deliberately does not attempt. + """ + base = maybe_parse(base) + head = maybe_parse(head) + if bucket(base) != bucket(head): + return [("BREAKING", path, f"{kind(base)} -> {kind(head)}")] + findings: list[Finding] = [] + if bucket(base) == "object": + assert isinstance(base, dict) and isinstance(head, dict) + for key in base: + if key not in head: + findings.append(("BREAKING", f"{path}.{key}", "field removed")) + else: + findings += classify(base[key], head[key], f"{path}.{key}") + for key in head: + if key not in base: + findings.append(("compatible", f"{path}.{key}", "field added")) + elif bucket(base) == "array": + assert isinstance(base, list) and isinstance(head, list) + if base and head: + findings += classify(base[0], head[0], f"{path}[]") + return findings + + +def classify_request( + base: object, head: object, path: str, route: str | None, required_by_route: dict +) -> list[Finding]: + """[`classify`], with ADR 0005 §4's asymmetric request rule applied: + a newly added field is only compatible if it is optional — a field + `shapes.json` marks required for this route is breaking instead + ("old clients never send it"). + """ + findings = classify(base, head, path) + required = set(required_by_route.get(route, [])) if route else set() + upgraded = [] + for severity, finding_path, detail in findings: + if severity == "compatible" and detail == "field added": + field = finding_path.rsplit(".", 1)[-1] + if field in required: + severity = "BREAKING" + detail = "field added AND required — old clients never send it" + upgraded.append((severity, finding_path, detail)) + return upgraded + + +def diff_fixture( + base: dict, head: dict, operation: str, required_by_route: dict +) -> list[Finding]: + findings: list[Finding] = [] + for field in ("route", "method", "status"): + if field in base and field in head and base[field] != head[field]: + findings.append( + ( + "BREAKING", + f"{operation}.{field}", + f"{base[field]!r} -> {head[field]!r}", + ) + ) + route = head.get("route") + findings += classify_request( + base.get("request"), + head.get("request"), + f"{operation}.request", + route, + required_by_route, + ) + findings += classify( + base.get("response"), head.get("response"), f"{operation}.response" + ) + return findings + + +def diff_shapes(base_shapes: dict, head_shapes: dict) -> list[Finding]: + findings: list[Finding] = [] + base_enums = base_shapes.get("enums", {}) + head_enums = head_shapes.get("enums", {}) + for path_expr, head_values in head_enums.items(): + if path_expr not in base_enums: + continue + missing = set(base_enums[path_expr]) - set(head_values) + for value in sorted(missing): + findings.append( + ( + "BREAKING", + f"shapes.enums[{path_expr}]", + f"known value {value!r} removed", + ) + ) + base_required = base_shapes.get("required_request_fields", {}) + head_required = head_shapes.get("required_request_fields", {}) + for route, head_fields in head_required.items(): + if route not in base_required: + continue + added = set(head_fields) - set(base_required[route]) + for field in sorted(added): + findings.append( + ( + "BREAKING", + f"shapes.required_request_fields[{route}]", + f"{field!r} newly required", + ) + ) + return findings + + +def contract_versions(text: str) -> dict[str, int]: + return { + match.group(1): int(match.group(2)) for match in CONTRACT_CONST.finditer(text) + } + + +def run_base(ref: str) -> None: + head_shapes = load_json(WIRE_DIR / "shapes.json") + base_shapes_text = git_show(ref, rel(WIRE_DIR / "shapes.json")) + base_shapes = json.loads(base_shapes_text) if base_shapes_text else {} + required_by_route = head_shapes.get("required_request_fields", {}) + + head_files = {rel(path) for path in fixture_paths()} + base_files = set(git_ls_tree(ref, rel(WIRE_DIR / "http"), rel(WIRE_DIR / "mcp"))) + base_files = { + path + for path in base_files + if path.endswith(".json") and "shapes.json" not in path + } + + findings: list[Finding] = [] + for removed in sorted(base_files - head_files): + findings.append(("BREAKING", removed, "operation removed")) + for added in sorted(head_files - base_files): + findings.append(("compatible", added, "operation added")) + + for relpath in sorted(head_files & base_files): + head_fixture = load_json(ROOT / relpath) + base_text = git_show(ref, relpath) + assert base_text is not None + base_fixture = json.loads(base_text) + operation = head_fixture.get("operation", relpath) + findings += diff_fixture( + base_fixture, head_fixture, operation, required_by_route + ) + + findings += diff_shapes(base_shapes, head_shapes) + + breaking = [finding for finding in findings if finding[0] == "BREAKING"] + compatible = [finding for finding in findings if finding[0] == "compatible"] + + for severity, path, detail in findings: + print(f"{severity:10} {path}\n {detail}") + + if not breaking: + print( + f"\nno breaking wire-contract changes against {ref} ({len(compatible)} compatible)" + ) + return + + head_versions = contract_versions(API_RS.read_text()) + base_api_text = git_show(ref, rel(API_RS)) or "" + base_versions = contract_versions(base_api_text) + + bumped = { + dimension + for dimension in ("HTTP", "MCP") + if head_versions.get(dimension, 0) > base_versions.get(dimension, 0) + } + if not bumped: + print( + f"\n{len(breaking)} breaking change(s) against {ref}, but neither " + "HTTP_CONTRACT nor MCP_CONTRACT was bumped in src/api.rs — see " + "ADR 0005 §4/§7 and tests/fixtures/wire/README.md", + file=sys.stderr, + ) + sys.exit(1) + print( + f"\n{len(breaking)} breaking change(s) against {ref}, matched by a bump to " + f"{'/'.join(sorted(bumped))}_CONTRACT — see the CHANGELOG entry and migration " + "note this PR must also carry (ADR 0005 §7)" + ) + + +def main() -> None: + parser = argparse.ArgumentParser( + description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter + ) + group = parser.add_mutually_exclusive_group(required=True) + group.add_argument( + "--check", + action="store_true", + help="verify fixtures <-> shapes.json self-consistency", + ) + group.add_argument( + "--base", + metavar="REF", + help="classify HEAD's fixture changes against this git ref", + ) + args = parser.parse_args() + if args.check: + run_check() + else: + run_base(args.base) + + +if __name__ == "__main__": + main() diff --git a/sdk/typescript/tests/unit/wire-contract.test.ts b/sdk/typescript/tests/unit/wire-contract.test.ts new file mode 100644 index 00000000..dd61ce41 --- /dev/null +++ b/sdk/typescript/tests/unit/wire-contract.test.ts @@ -0,0 +1,121 @@ +// Golden wire-contract fixtures (#301): the TypeScript SDK's own read +// of tests/fixtures/wire/, alongside Rust's generator/verifier +// (tests/http_api/contract.rs) and Python's +// (sdk/python/tests/unit/test_wire_contract.py). Two checks: +// +// - every fixture whose response this SDK already has a typed +// interface for (MatchPage, PassagePage, ContextPage, ExplorePage, +// ActivationPage, CommunityPage) unwraps through the real +// `unwrapEnvelope` — the same function every live call uses — +// without throwing; +// - every fixture's declared enum-like fields only carry values +// shapes.json knows about. +// +// #216's evidence-assembly package and the MCP-specific envelope have +// no SDK model yet (#306 adds them); those fixtures are covered here +// only by the enum check below, not a typed unwrap — matching this +// SDK's own documented posture (ADR 0005 §2.5): no runtime schema +// validation beyond the envelope, so there is nothing stronger to run +// those fixtures through yet. + +import { readFileSync, readdirSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +import { describe, expect, it } from "vitest"; + +import { unwrapEnvelope } from "../../src/transport.js"; + +// tests/unit/wire-contract.test.ts -> repo root: same depth as the +// Rust twin's CARGO_MANIFEST_DIR-relative path and the Python twin's +// parents[4] (unit, tests, typescript, sdk). +const WIRE_DIR = join(dirname(fileURLToPath(import.meta.url)), "../../../../tests/fixtures/wire"); + +interface Fixture { + operation: string; + contract: string; + route: string; + status: number; + request: unknown; + response: unknown; +} + +function loadFixtures(): Array<[string, Fixture]> { + const paths: string[] = []; + for (const dir of ["http", "mcp"]) { + for (const name of readdirSync(join(WIRE_DIR, dir)).filter((n) => n.endsWith(".json"))) { + paths.push(join(WIRE_DIR, dir, name)); + } + } + paths.sort(); + return paths.map((path) => [path, JSON.parse(readFileSync(path, "utf-8")) as Fixture]); +} + +const FIXTURES = loadFixtures(); +const SHAPES = JSON.parse(readFileSync(join(WIRE_DIR, "shapes.json"), "utf-8")) as { + enums: Record; +}; + +/** + * "foo.bar[].baz" — "[]" means "every array element" — the one small + * path language shapes.json's `enums` keys use. Mirrored (not shared) + * in sdk/spec/check_contract.py and tests/http_api/contract.rs: each + * language keeps its own ~15-line copy rather than a cross-language + * import. + */ +function collectByPath(value: unknown, segments: string[]): unknown[] { + if (segments.length === 0) return [value]; + const [head, ...rest] = segments; + const isArray = head!.endsWith("[]"); + const key = isArray ? head!.slice(0, -2) : head!; + if (typeof value !== "object" || value === null || !(key in value)) return []; + const next = (value as Record)[key]; + if (isArray) { + if (!Array.isArray(next)) return []; + return next.flatMap((item) => collectByPath(item, rest)); + } + return collectByPath(next, rest); +} + +// operation -> the fixture this SDK already has a typed interface for. +const TYPED_OPERATIONS = [ + "recall", + "contexts_list", + "sources_search", + "explore", + "activate", + "communities_search", +]; + +describe("golden wire-contract fixtures (#301)", () => { + it("has a non-empty fixture corpus", () => { + expect(FIXTURES.length).toBeGreaterThan(0); + const operations = new Set(FIXTURES.map(([, fixture]) => fixture.operation)); + for (const operation of TYPED_OPERATIONS) { + expect(operations.has(operation)).toBe(true); + } + }); + + const typedFixtures = FIXTURES.filter(([, fixture]) => + TYPED_OPERATIONS.includes(fixture.operation), + ); + + it.each(typedFixtures)("%s unwraps through the real transport envelope", (_path, fixture) => { + const result = unwrapEnvelope(fixture.status, JSON.stringify(fixture.response)); + expect(result).toBeTruthy(); + }); + + it.each(FIXTURES)("%s: every declared enum only carries known values", (path, fixture) => { + for (const [pathExpr, allowed] of Object.entries(SHAPES.enums)) { + const allowedSet = new Set(allowed); + for (const value of collectByPath(fixture, pathExpr.split("."))) { + if (typeof value === "string" && !allowedSet.has(value)) { + throw new Error( + `${path}: ${pathExpr} carries ${JSON.stringify(value)}, which is not ` + + "declared in shapes.json's enums", + ); + } + } + } + }); +}); diff --git a/tests/fixtures/wire/README.md b/tests/fixtures/wire/README.md new file mode 100644 index 00000000..d24519a3 --- /dev/null +++ b/tests/fixtures/wire/README.md @@ -0,0 +1,98 @@ +# Golden wire-contract fixtures + +Machine-readable pins of `http_contract: 1` / `mcp_contract: 1` (ADR +0005 §3, §9) — a representative request/response for every wire shape +this repository has committed to keeping stable within the current +contract version, including #216's evidence-assembly package +(`POST /contexts/{name}/evidence`, `assemble_evidence`). Rust, Python, +and TypeScript all read the same files; `sdk/spec/check_contract.py` +diffs them across a base ref to fail CI on an unclassified breaking +change. This is #301's answer to "no golden wire-shape fixture exists +in the repository today" (ADR 0005 §2.5) — inventory and pinning, not +a schema-generation pivot (ADR 0005 §9). + +## Layout + +- `shapes.json` — the one thing a fixture file cannot say about itself: + which request fields are *required* (`required_request_fields`, keyed + by each fixture's own `route` field — a bare path template, e.g. + `"/contexts/{name}/evidence"`, since every route this covers today is + POST-only) and the full known-value set for every + open-string enum-like field (`enums`, keyed by a dotted path into a + fixture's `response`, with `[]` meaning "every array element"). + `volatile_fields` names the field names blanked to a fixed placeholder + before a fixture is written or compared — `time` (elapsed seconds, + different every call), `server`/`version` (this build's own + `Cargo.toml` version, e.g. `GET /version`'s `server` and `GET + /health`'s `version`), and `last_read_epoch`/`last_write_epoch` (a + directory entry's own wall-clock usage stamps) — so a slow CI run, a + routine version bump, or the literal moment a fixture happened to + regenerate never shows up as wire drift. Applies inside an MCP tool + result's `content[].text` too, which carries the whole HTTP body a + second time as a JSON string (ADR 0005 §2.4's pass-through + convention). **Documentation only** — the actual blanking is + `tests/http_api/contract.rs`'s `normalize_volatile`, which hardcodes + the same five field names (each needs its own placeholder *type*: + `time` a float, `server`/`version` a string, the two epoch fields an + integer, which a flat list can't express); nothing reads this array + back. A new volatile field needs an entry both here and in + `normalize_volatile` — no check ties the two together, so keep them + in sync by hand. +- `http/*.json` — one file per HTTP operation: `{operation, contract, + method, route, status, request, response}`. `response` is the exact + parsed JSON body, envelope included where the real endpoint has one. +- `mcp/*.json` — one file per MCP operation, same shape with `route` + instead of `method`/`route` split (e.g. `"tools/call + assemble_evidence"`), covering the MCP-specific envelope + (`isError`/`structuredContent`) and the `assemble_evidence` tool's + `inputSchema` — not a second copy of every HTTP shape, since 45 of 46 + tools inherit `http_contract` by pass-through (ADR 0005 §2.4) and are + already pinned under `http/`. + +## Updating a fixture + +1. Change the implementation. +2. Regenerate every fixture this module owns from a live server: + ```sh + TAGURU_UPDATE_WIRE_FIXTURES=1 cargo test --test http_api contract + ``` + This rewrites `http/*.json`/`mcp/*.json` in place from the real + server binary's real responses — nothing here is hand-typed. +3. `git diff tests/fixtures/wire/` and classify each change against + [ADR 0005 §4](/adr/0005-wire-contract-compatibility.md#4-compatible--breaking-change-table): + an added optional field or a new operation is compatible; a removed + or renamed field, a container-shape change (array ↔ object), a + pagination-envelope change, or a known enum value disappearing from + `shapes.json`'s `enums` is breaking. +4. If anything is breaking: bump `HTTP_CONTRACT` and/or `MCP_CONTRACT` + in `src/api.rs` in the **same PR**, and add the CHANGELOG + `[Unreleased]` → `### Changed` entry plus a migration note (ADR 0005 + §7) — a caller reading the fixture diff alone should be able to act + on it. +5. Adding a field that closes over a fixed set of values (a new `kind`, + `lane`, `reason`, or `ErrorCode`)? Add it to `shapes.json`'s `enums` + in the same commit — `sdk/spec/check_contract.py --base ` + otherwise cannot tell "a known value is now missing" (breaking) from + "the fixture just didn't happen to exercise it" (nothing changed). + Similarly, a newly *required* request field goes in + `required_request_fields`; a newly optional one does not. +6. Confirm the guard agrees with your own classification: + ```sh + python sdk/spec/check_contract.py --base origin/main + ``` + `--check` alone (no `--base`) verifies fixtures stay self-consistent + with `shapes.json` without needing a base ref. + +## Why this shape + +- **JSON, not YAML**: Rust has no YAML dependency in this tree and the + TypeScript SDK ships with zero runtime dependencies — JSON is the one + format all three read with nothing extra installed. +- **Fixtures are generated, never hand-typed**: a fixture is exactly + what the real server, seeded with a small deterministic corpus, + answered — the same discipline `sdk/python/scripts/generate_sync.py` + and `sdk/spec/check_versions.py --set` already use elsewhere in this + tree (regenerate mechanically, review the diff, commit). +- **Committed fixtures already carry the placeholder volatile values**: + normalization happens once, at generation time, so a plain + `git diff`/`assert_eq!` across languages needs no special-casing. diff --git a/tests/fixtures/wire/http/activate.json b/tests/fixtures/wire/http/activate.json new file mode 100644 index 00000000..8fdf3306 --- /dev/null +++ b/tests/fixtures/wire/http/activate.json @@ -0,0 +1,43 @@ +{ + "contract": "http_contract", + "method": "POST", + "operation": "activate", + "request": { + "origins": [ + "alpha" + ] + }, + "response": { + "result": { + "matches": [ + { + "association": { + "attributions": [ + { + "count": 1, + "paragraph": 0, + "section": null, + "source": "doc.md", + "weight": 2.0 + } + ], + "count": 1, + "label": "connects_to", + "object": "beta", + "subject": "alpha", + "weight": 2.0 + }, + "path": [ + "alpha" + ], + "strength": 1.0 + } + ], + "total": 1 + }, + "status": "ok", + "time": 0.0 + }, + "route": "/contexts/{name}/activate", + "status": 200 +} diff --git a/tests/fixtures/wire/http/communities_search.json b/tests/fixtures/wire/http/communities_search.json new file mode 100644 index 00000000..d268661c --- /dev/null +++ b/tests/fixtures/wire/http/communities_search.json @@ -0,0 +1,59 @@ +{ + "contract": "http_contract", + "method": "POST", + "operation": "communities_search", + "request": { + "query": "酒造りの歴史" + }, + "response": { + "result": { + "algorithm": "louvain-cc/1", + "derived": "corpus-d::communities", + "hits": [ + { + "community": "L0-0", + "concept_count": 2, + "level": 0, + "members": [ + { + "name": "a1", + "strength": 6.0 + }, + { + "name": "a2", + "strength": 4.0 + } + ], + "paragraph": 0, + "score": 3.9352872, + "text": "この共同体のテーマは酒造りの歴史です。" + } + ], + "plan": { + "contexts": [ + { + "context": "corpus-d", + "lanes": { + "bm25": { + "ran": true + }, + "vector": { + "ran": false, + "reason": "no embedding provider is configured" + } + } + } + ] + }, + "revision": { + "current_graph": 1, + "recorded_graph": 1 + }, + "stale": false + }, + "status": "ok", + "time": 0.0 + }, + "route": "/contexts/{name}/communities/search", + "status": 200 +} diff --git a/tests/fixtures/wire/http/contexts_list.json b/tests/fixtures/wire/http/contexts_list.json new file mode 100644 index 00000000..adfef0ef --- /dev/null +++ b/tests/fixtures/wire/http/contexts_list.json @@ -0,0 +1,62 @@ +{ + "contract": "http_contract", + "method": "GET", + "operation": "contexts_list", + "request": null, + "response": { + "result": { + "contexts": [ + { + "description": "wire-contract corpus", + "dice_floor": null, + "loaded": true, + "name": "corpus-a", + "pinned": false, + "revision": { + "config": 0, + "graph": 1, + "passages": 0 + }, + "semantic_floor": null, + "stats": { + "arena_slack": 0, + "associations": 1, + "concepts": 2, + "dead_attributions": 0, + "dead_edges": 0, + "footprint_bytes": 1804, + "label_sample": [ + "connects_to" + ], + "labels": 1, + "sources": 1, + "top_concepts": [ + { + "count": 1, + "label": "alpha" + }, + { + "count": 1, + "label": "beta" + } + ], + "unsourced_edges": 0, + "unsourced_weight": 0.0 + }, + "usage": { + "empty_reads": 0, + "last_read_epoch": 0, + "last_write_epoch": 0, + "reads": 1, + "writes": 1 + } + } + ], + "total": 1 + }, + "status": "ok", + "time": 0.0 + }, + "route": "/contexts", + "status": 200 +} diff --git a/tests/fixtures/wire/http/error_forbidden.json b/tests/fixtures/wire/http/error_forbidden.json new file mode 100644 index 00000000..4994d1a7 --- /dev/null +++ b/tests/fixtures/wire/http/error_forbidden.json @@ -0,0 +1,19 @@ +{ + "contract": "http_contract", + "method": "POST", + "operation": "error_forbidden", + "request": { + "include_communities": true, + "origins": [ + "a" + ] + }, + "response": { + "code": "forbidden", + "error": "this key's scope does not extend to the artifact context 'forbidden-corpus::communities' — grant it alongside 'forbidden-corpus'", + "status": "error", + "time": 0.0 + }, + "route": "/contexts/{name}/evidence", + "status": 403 +} diff --git a/tests/fixtures/wire/http/error_malformed_request.json b/tests/fixtures/wire/http/error_malformed_request.json new file mode 100644 index 00000000..27b3b082 --- /dev/null +++ b/tests/fixtures/wire/http/error_malformed_request.json @@ -0,0 +1,19 @@ +{ + "contract": "http_contract", + "method": "POST", + "operation": "error_malformed_request", + "request": { + "budget": "not-an-object", + "origins": [ + "x" + ] + }, + "response": { + "code": "malformed_request", + "error": "Failed to deserialize the JSON body into the target type: budget: invalid type: string \"not-an-object\", expected struct BudgetRequest at line 1 column 25", + "status": "error", + "time": 0.0 + }, + "route": "/contexts/{name}/evidence", + "status": 422 +} diff --git a/tests/fixtures/wire/http/error_no_context.json b/tests/fixtures/wire/http/error_no_context.json new file mode 100644 index 00000000..dffac04c --- /dev/null +++ b/tests/fixtures/wire/http/error_no_context.json @@ -0,0 +1,18 @@ +{ + "contract": "http_contract", + "method": "POST", + "operation": "error_no_context", + "request": { + "origins": [ + "x" + ] + }, + "response": { + "code": "no_context", + "error": "context 'does-not-exist' not found", + "status": "error", + "time": 0.0 + }, + "route": "/contexts/{name}/evidence", + "status": 404 +} diff --git a/tests/fixtures/wire/http/error_over_limit.json b/tests/fixtures/wire/http/error_over_limit.json new file mode 100644 index 00000000..3542dd4b --- /dev/null +++ b/tests/fixtures/wire/http/error_over_limit.json @@ -0,0 +1,1018 @@ +{ + "contract": "http_contract", + "method": "POST", + "operation": "error_over_limit", + "request": { + "origins": [ + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x" + ] + }, + "response": { + "code": "over_limit", + "error": "origins carries 1001 items, past the per-request limit of 1000; split the request", + "status": "error", + "time": 0.0 + }, + "route": "/contexts/{name}/evidence", + "status": 400 +} diff --git a/tests/fixtures/wire/http/evidence_budget_constrained.json b/tests/fixtures/wire/http/evidence_budget_constrained.json new file mode 100644 index 00000000..c117a2bf --- /dev/null +++ b/tests/fixtures/wire/http/evidence_budget_constrained.json @@ -0,0 +1,136 @@ +{ + "contract": "http_contract", + "method": "POST", + "operation": "evidence_budget_constrained", + "request": { + "budget": { + "max_items": 2 + }, + "origins": [ + "s0", + "s1", + "s2", + "s3", + "s4" + ] + }, + "response": { + "result": { + "budget": { + "bytes_used": 525, + "items_used": 2, + "limits": { + "max_bytes": 65536, + "max_items": 2, + "max_tokens": 4000 + }, + "tokens_used": 132 + }, + "citations": [], + "items": [ + { + "association": { + "attributions": [], + "count": 1, + "label": "rel", + "object": "o0", + "subject": "s0", + "weight": 1.0 + }, + "bytes": 260, + "candidate_id": "association\u0000s0\u0000rel\u0000o0", + "citation_refs": [], + "estimated_tokens": 65, + "fused_rank": 1, + "kind": "association", + "lane_ranks": [ + { + "lane": "graph_activate", + "rank": 1 + } + ] + }, + { + "association": { + "attributions": [], + "count": 1, + "label": "rel", + "object": "o1", + "subject": "s1", + "weight": 1.0 + }, + "bytes": 260, + "candidate_id": "association\u0000s1\u0000rel\u0000o1", + "citation_refs": [], + "estimated_tokens": 65, + "fused_rank": 2, + "kind": "association", + "lane_ranks": [ + { + "lane": "graph_activate", + "rank": 2 + } + ] + } + ], + "omitted": [ + { + "candidate_id": "association\u0000s2\u0000rel\u0000o2", + "kind": "association", + "reason": "budget_exceeded" + }, + { + "candidate_id": "association\u0000s3\u0000rel\u0000o3", + "kind": "association", + "reason": "budget_exceeded" + }, + { + "candidate_id": "association\u0000s4\u0000rel\u0000o4", + "kind": "association", + "reason": "budget_exceeded" + } + ], + "omitted_by_reason": { + "budget_exceeded": 3 + }, + "omitted_total": 3, + "plan": { + "lanes": { + "activate": { + "ran": true + }, + "citations": { + "ran": true + }, + "communities": { + "ran": false, + "reason": "include_communities was false" + }, + "passages": { + "ran": true + }, + "query": { + "ran": false, + "reason": "no 'labels' given" + }, + "resolve": { + "ran": true + } + }, + "reranker": { + "configured": false, + "ran": false + }, + "selection": { + "contradiction_groups": 0, + "dedup_dropped": 0, + "diversity_tier_width": 1 + } + } + }, + "status": "ok", + "time": 0.0 + }, + "route": "/contexts/{name}/evidence", + "status": 200 +} diff --git a/tests/fixtures/wire/http/evidence_communities_degrade_and_rerank_reason.json b/tests/fixtures/wire/http/evidence_communities_degrade_and_rerank_reason.json new file mode 100644 index 00000000..c04cbd7c --- /dev/null +++ b/tests/fixtures/wire/http/evidence_communities_degrade_and_rerank_reason.json @@ -0,0 +1,94 @@ +{ + "contract": "http_contract", + "method": "POST", + "operation": "evidence_communities_degrade_and_rerank_reason", + "request": { + "include_communities": true, + "origins": [ + "alpha" + ], + "rerank": { + "model": "not-configured" + } + }, + "response": { + "result": { + "budget": { + "bytes_used": 274, + "items_used": 1, + "limits": { + "max_bytes": 65536, + "max_items": 40, + "max_tokens": 4000 + }, + "tokens_used": 69 + }, + "citations": [], + "items": [ + { + "association": { + "attributions": [], + "count": 1, + "label": "rel", + "object": "beta", + "subject": "alpha", + "weight": 1.0 + }, + "bytes": 270, + "candidate_id": "association\u0000alpha\u0000rel\u0000beta", + "citation_refs": [], + "estimated_tokens": 68, + "fused_rank": 1, + "kind": "association", + "lane_ranks": [ + { + "lane": "graph_activate", + "rank": 1 + } + ] + } + ], + "omitted": [], + "omitted_by_reason": {}, + "omitted_total": 0, + "plan": { + "lanes": { + "activate": { + "ran": true + }, + "citations": { + "ran": true + }, + "communities": { + "ran": false, + "reason": "no communities artifact for 'comm-corpus': context 'comm-corpus::communities' does not exist — run `taguru communities` to build it" + }, + "passages": { + "ran": true + }, + "query": { + "ran": false, + "reason": "no 'labels' given" + }, + "resolve": { + "ran": true + } + }, + "reranker": { + "configured": false, + "ran": false, + "reason": "no reranker provider is configured on this server (#307); the deterministic order was used" + }, + "selection": { + "contradiction_groups": 0, + "dedup_dropped": 0, + "diversity_tier_width": 10 + } + } + }, + "status": "ok", + "time": 0.0 + }, + "route": "/contexts/{name}/evidence", + "status": 200 +} diff --git a/tests/fixtures/wire/http/evidence_contradiction_group.json b/tests/fixtures/wire/http/evidence_contradiction_group.json new file mode 100644 index 00000000..203e87be --- /dev/null +++ b/tests/fixtures/wire/http/evidence_contradiction_group.json @@ -0,0 +1,184 @@ +{ + "contract": "http_contract", + "method": "POST", + "operation": "evidence_contradiction_group", + "request": { + "origins": [ + "猫" + ] + }, + "response": { + "result": { + "budget": { + "bytes_used": 1322, + "items_used": 2, + "limits": { + "max_bytes": 65536, + "max_items": 40, + "max_tokens": 4000 + }, + "tokens_used": 343 + }, + "citations": [ + { + "citation": { + "section": null, + "source": "s1.md", + "text": "猫は哺乳類である。" + }, + "paragraph": 0, + "source": "s1.md" + }, + { + "citation": { + "section": null, + "source": "s2.md", + "text": "猫は爬虫類だと主張する文献もある。" + }, + "paragraph": 0, + "source": "s2.md" + } + ], + "items": [ + { + "association": { + "attributions": [ + { + "count": 1, + "paragraph": 0, + "section": null, + "source": "s1.md", + "weight": 1.0 + } + ], + "count": 1, + "label": "is_a", + "object": "哺乳類", + "subject": "猫", + "weight": 1.0 + }, + "bytes": 532, + "candidate_id": "association\u0000猫\u0000is_a\u0000哺乳類", + "citation_refs": [ + { + "paragraph": 0, + "source": "s1.md" + } + ], + "contradicts": [ + "association\u0000猫\u0000is_a\u0000爬虫類" + ], + "corroboration": { + "attributions": [ + { + "paragraph": 0, + "source": "s1.md" + } + ], + "sources": [ + "s1.md" + ] + }, + "estimated_tokens": 136, + "fused_rank": 1, + "kind": "association", + "lane_ranks": [ + { + "lane": "graph_activate", + "rank": 1 + } + ] + }, + { + "association": { + "attributions": [ + { + "count": 1, + "paragraph": 0, + "section": null, + "source": "s2.md", + "weight": 1.0 + } + ], + "count": 1, + "label": "is_a", + "object": "爬虫類", + "subject": "猫", + "weight": 1.0 + }, + "bytes": 532, + "candidate_id": "association\u0000猫\u0000is_a\u0000爬虫類", + "citation_refs": [ + { + "paragraph": 0, + "source": "s2.md" + } + ], + "contradicts": [ + "association\u0000猫\u0000is_a\u0000哺乳類" + ], + "corroboration": { + "attributions": [ + { + "paragraph": 0, + "source": "s2.md" + } + ], + "sources": [ + "s2.md" + ] + }, + "estimated_tokens": 136, + "fused_rank": 2, + "kind": "association", + "lane_ranks": [ + { + "lane": "graph_activate", + "rank": 2 + } + ] + } + ], + "omitted": [], + "omitted_by_reason": {}, + "omitted_total": 0, + "plan": { + "lanes": { + "activate": { + "ran": true + }, + "citations": { + "ran": true + }, + "communities": { + "ran": false, + "reason": "include_communities was false" + }, + "passages": { + "ran": true + }, + "query": { + "ran": false, + "reason": "no 'labels' given" + }, + "resolve": { + "ran": true + } + }, + "reranker": { + "configured": false, + "ran": false + }, + "selection": { + "contradiction_groups": 1, + "dedup_dropped": 0, + "diversity_tier_width": 10 + } + } + }, + "status": "ok", + "time": 0.0 + }, + "route": "/contexts/{name}/evidence", + "status": 200 +} diff --git a/tests/fixtures/wire/http/evidence_duplicate_passage.json b/tests/fixtures/wire/http/evidence_duplicate_passage.json new file mode 100644 index 00000000..92168d23 --- /dev/null +++ b/tests/fixtures/wire/http/evidence_duplicate_passage.json @@ -0,0 +1,103 @@ +{ + "contract": "http_contract", + "method": "POST", + "operation": "evidence_duplicate_passage", + "request": { + "origins": [], + "text_fallback_query": "quick brown fox" + }, + "response": { + "result": { + "budget": { + "bytes_used": 339, + "items_used": 1, + "limits": { + "max_bytes": 65536, + "max_items": 40, + "max_tokens": 4000 + }, + "tokens_used": 85 + }, + "citations": [], + "items": [ + { + "bytes": 335, + "candidate_id": "passage\u0000dup-corpus\u0000a.md\u00000", + "citation_refs": [], + "estimated_tokens": 84, + "fused_rank": 1, + "kind": "passage", + "lane_ranks": [ + { + "lane": "passage_bm25", + "rank": 1 + } + ], + "passage": { + "lanes": { + "bm25": { + "rank": 1, + "score": 0.54696476 + } + }, + "paragraph": 0, + "score": 0.54696476, + "source": "a.md", + "text": "the quick brown fox jumps over the lazy dog" + } + } + ], + "omitted": [ + { + "candidate_id": "passage\u0000dup-corpus\u0000b.md\u00000", + "duplicate_of": "passage\u0000dup-corpus\u0000a.md\u00000", + "kind": "passage", + "reason": "duplicate_passage" + } + ], + "omitted_by_reason": { + "duplicate_passage": 1 + }, + "omitted_total": 1, + "plan": { + "lanes": { + "activate": { + "ran": false, + "reason": "no anchors resolved from 'origins'" + }, + "citations": { + "ran": true + }, + "communities": { + "ran": false, + "reason": "include_communities was false" + }, + "passages": { + "ran": true + }, + "query": { + "ran": false, + "reason": "no anchors resolved from 'origins'" + }, + "resolve": { + "ran": false, + "reason": "origins was empty" + } + }, + "reranker": { + "configured": false, + "ran": false + }, + "selection": { + "contradiction_groups": 0, + "dedup_dropped": 0, + "diversity_tier_width": 10 + } + } + }, + "status": "ok", + "time": 0.0 + }, + "route": "/contexts/{name}/evidence", + "status": 200 +} diff --git a/tests/fixtures/wire/http/evidence_mixed_lanes.json b/tests/fixtures/wire/http/evidence_mixed_lanes.json new file mode 100644 index 00000000..86630ebc --- /dev/null +++ b/tests/fixtures/wire/http/evidence_mixed_lanes.json @@ -0,0 +1,149 @@ +{ + "contract": "http_contract", + "method": "POST", + "operation": "evidence_mixed_lanes", + "request": { + "origins": [ + "青嶺酒造" + ] + }, + "response": { + "result": { + "budget": { + "bytes_used": 1082, + "items_used": 2, + "limits": { + "max_bytes": 65536, + "max_items": 40, + "max_tokens": 4000 + }, + "tokens_used": 288 + }, + "citations": [ + { + "citation": { + "section": null, + "source": "docs/kura.md", + "text": "青嶺酒造は雲居県霧沢町の蔵元である。杜氏は高瀬である。" + }, + "paragraph": 0, + "source": "docs/kura.md" + } + ], + "items": [ + { + "association": { + "attributions": [ + { + "count": 1, + "paragraph": 0, + "section": null, + "source": "docs/kura.md", + "weight": 1.0 + } + ], + "count": 1, + "label": "杜氏", + "object": "高瀬", + "subject": "青嶺酒造", + "weight": 1.0 + }, + "bytes": 512, + "candidate_id": "association\u0000青嶺酒造\u0000杜氏\u0000高瀬", + "citation_refs": [ + { + "paragraph": 0, + "source": "docs/kura.md" + } + ], + "corroboration": { + "attributions": [ + { + "paragraph": 0, + "source": "docs/kura.md" + } + ], + "sources": [ + "docs/kura.md" + ] + }, + "estimated_tokens": 132, + "fused_rank": 1, + "kind": "association", + "lane_ranks": [ + { + "lane": "graph_activate", + "rank": 1 + } + ] + }, + { + "bytes": 383, + "candidate_id": "passage\u0000sake\u0000docs/kura.md\u00000", + "citation_refs": [], + "estimated_tokens": 103, + "fused_rank": 2, + "kind": "passage", + "lane_ranks": [ + { + "lane": "passage_bm25", + "rank": 1 + } + ], + "passage": { + "lanes": { + "bm25": { + "rank": 1, + "score": 0.86304635 + } + }, + "paragraph": 0, + "score": 0.86304635, + "source": "docs/kura.md", + "text": "青嶺酒造は雲居県霧沢町の蔵元である。杜氏は高瀬である。" + } + } + ], + "omitted": [], + "omitted_by_reason": {}, + "omitted_total": 0, + "plan": { + "lanes": { + "activate": { + "ran": true + }, + "citations": { + "ran": true + }, + "communities": { + "ran": false, + "reason": "include_communities was false" + }, + "passages": { + "ran": true + }, + "query": { + "ran": false, + "reason": "no 'labels' given" + }, + "resolve": { + "ran": true + } + }, + "reranker": { + "configured": false, + "ran": false + }, + "selection": { + "contradiction_groups": 0, + "dedup_dropped": 0, + "diversity_tier_width": 10 + } + } + }, + "status": "ok", + "time": 0.0 + }, + "route": "/contexts/{name}/evidence", + "status": 200 +} diff --git a/tests/fixtures/wire/http/explore.json b/tests/fixtures/wire/http/explore.json new file mode 100644 index 00000000..b93b8b23 --- /dev/null +++ b/tests/fixtures/wire/http/explore.json @@ -0,0 +1,43 @@ +{ + "contract": "http_contract", + "method": "POST", + "operation": "explore", + "request": { + "origins": [ + "alpha" + ] + }, + "response": { + "result": { + "matches": [ + { + "association": { + "attributions": [ + { + "count": 1, + "paragraph": 0, + "section": null, + "source": "doc.md", + "weight": 2.0 + } + ], + "count": 1, + "label": "connects_to", + "object": "beta", + "subject": "alpha", + "weight": 2.0 + }, + "distance": 1, + "path": [ + "alpha" + ] + } + ], + "total": 1 + }, + "status": "ok", + "time": 0.0 + }, + "route": "/contexts/{name}/explore", + "status": 200 +} diff --git a/tests/fixtures/wire/http/health.json b/tests/fixtures/wire/http/health.json new file mode 100644 index 00000000..b1492820 --- /dev/null +++ b/tests/fixtures/wire/http/health.json @@ -0,0 +1,12 @@ +{ + "contract": "http_contract", + "method": "GET", + "operation": "health", + "request": null, + "response": { + "status": "ok", + "version": "0.0.0" + }, + "route": "/health", + "status": 200 +} diff --git a/tests/fixtures/wire/http/recall.json b/tests/fixtures/wire/http/recall.json new file mode 100644 index 00000000..809e324d --- /dev/null +++ b/tests/fixtures/wire/http/recall.json @@ -0,0 +1,40 @@ +{ + "contract": "http_contract", + "method": "POST", + "operation": "recall", + "request": { + "cue": "alpha" + }, + "response": { + "result": { + "matches": [ + { + "attributions": [ + { + "count": 1, + "paragraph": 0, + "section": null, + "source": "doc.md", + "weight": 2.0 + } + ], + "count": 1, + "label": "connects_to", + "object": "beta", + "subject": "alpha", + "weight": 2.0 + } + ], + "plan": { + "contexts": [ + "corpus-a" + ] + }, + "total": 1 + }, + "status": "ok", + "time": 0.0 + }, + "route": "/contexts/{name}/recall", + "status": 200 +} diff --git a/tests/fixtures/wire/http/sources_search.json b/tests/fixtures/wire/http/sources_search.json new file mode 100644 index 00000000..1822d096 --- /dev/null +++ b/tests/fixtures/wire/http/sources_search.json @@ -0,0 +1,46 @@ +{ + "contract": "http_contract", + "method": "POST", + "operation": "sources_search", + "request": { + "query": "酒造" + }, + "response": { + "result": { + "hits": [ + { + "lanes": { + "bm25": { + "rank": 1, + "score": 0.28768212 + } + }, + "paragraph": 0, + "score": 0.28768212, + "source": "doc.md", + "text": "青嶺酒造は雲居県霧沢町の蔵元である。" + } + ], + "plan": { + "contexts": [ + { + "context": "corpus-c", + "lanes": { + "bm25": { + "ran": true + }, + "vector": { + "ran": false, + "reason": "no embedding provider is configured" + } + } + } + ] + } + }, + "status": "ok", + "time": 0.0 + }, + "route": "/contexts/{name}/sources/search", + "status": 200 +} diff --git a/tests/fixtures/wire/http/version.json b/tests/fixtures/wire/http/version.json new file mode 100644 index 00000000..fa2008aa --- /dev/null +++ b/tests/fixtures/wire/http/version.json @@ -0,0 +1,44 @@ +{ + "contract": "http_contract", + "method": "GET", + "operation": "version", + "request": null, + "response": { + "batch_formats": [ + 1 + ], + "communities_formats": [ + 1 + ], + "http_contract": { + "current": 1, + "supported": [ + 1 + ] + }, + "image_formats": [ + 1, + 2, + 3, + 4, + 5, + 6 + ], + "mcp_contract": { + "current": 1, + "supported": [ + 1 + ] + }, + "mcp_protocol": { + "supported": [ + "2024-11-05", + "2025-03-26", + "2025-06-18" + ] + }, + "server": "0.0.0" + }, + "route": "/version", + "status": 200 +} diff --git a/tests/fixtures/wire/mcp/assemble_evidence_call.json b/tests/fixtures/wire/mcp/assemble_evidence_call.json new file mode 100644 index 00000000..b6daf3e9 --- /dev/null +++ b/tests/fixtures/wire/mcp/assemble_evidence_call.json @@ -0,0 +1,20 @@ +{ + "contract": "mcp_contract", + "operation": "assemble_evidence_call", + "request": { + "context": "mcp-corpus", + "origins": [ + "a" + ] + }, + "response": { + "content": [ + { + "text": "{\"result\":{\"budget\":{\"bytes_used\":260,\"items_used\":1,\"limits\":{\"max_bytes\":65536,\"max_items\":40,\"max_tokens\":4000},\"tokens_used\":65},\"citations\":[],\"items\":[{\"association\":{\"attributions\":[],\"count\":1,\"label\":\"rel\",\"object\":\"b\",\"subject\":\"a\",\"weight\":1.0},\"bytes\":256,\"candidate_id\":\"association\\u0000a\\u0000rel\\u0000b\",\"citation_refs\":[],\"estimated_tokens\":64,\"fused_rank\":1,\"kind\":\"association\",\"lane_ranks\":[{\"lane\":\"graph_activate\",\"rank\":1}]}],\"omitted\":[],\"omitted_by_reason\":{},\"omitted_total\":0,\"plan\":{\"lanes\":{\"activate\":{\"ran\":true},\"citations\":{\"ran\":true},\"communities\":{\"ran\":false,\"reason\":\"include_communities was false\"},\"passages\":{\"ran\":true},\"query\":{\"ran\":false,\"reason\":\"no 'labels' given\"},\"resolve\":{\"ran\":true}},\"reranker\":{\"configured\":false,\"ran\":false},\"selection\":{\"contradiction_groups\":0,\"dedup_dropped\":0,\"diversity_tier_width\":10}}},\"status\":\"ok\",\"time\":0.0}", + "type": "text" + } + ] + }, + "route": "tools/call assemble_evidence", + "status": 200 +} diff --git a/tests/fixtures/wire/mcp/assemble_evidence_tool_error.json b/tests/fixtures/wire/mcp/assemble_evidence_tool_error.json new file mode 100644 index 00000000..e024cf4c --- /dev/null +++ b/tests/fixtures/wire/mcp/assemble_evidence_tool_error.json @@ -0,0 +1,18 @@ +{ + "contract": "mcp_contract", + "operation": "assemble_evidence_tool_error", + "request": { + "context": "mcp-error-corpus" + }, + "response": { + "content": [ + { + "text": "missing required argument 'origins'", + "type": "text" + } + ], + "isError": true + }, + "route": "tools/call assemble_evidence", + "status": 200 +} diff --git a/tests/fixtures/wire/mcp/assemble_evidence_tool_schema.json b/tests/fixtures/wire/mcp/assemble_evidence_tool_schema.json new file mode 100644 index 00000000..3cfd1245 --- /dev/null +++ b/tests/fixtures/wire/mcp/assemble_evidence_tool_schema.json @@ -0,0 +1,108 @@ +{ + "contract": "mcp_contract", + "operation": "assemble_evidence_tool_schema", + "request": {}, + "response": { + "tools": [ + { + "description": "Opt-in evidence assembly: runs the same resolve/query/activate/search_passages/cite_passage fan-out `retrieve` runs, then normalizes every graph association, graph activation, passage hit, and (opt-in) community hit into one ranked, deduplicated, citation-complete package under an explicit byte/token/item budget — for a caller that will hand the result to an external answer model with a bounded context window, unlike `retrieve`'s raw, unranked results. `origins` and `labels` share `retrieve`'s own contract; the passage/community lanes search `text_fallback_query` when given, otherwise `origins` joined with '; '. Contradictory and corroborating evidence are both preserved intentionally — never silently collapsed to a majority view or a single opaque count. `budget` bounds the response (defaults: 40 items, 64 KiB, ~4000 estimated tokens); a budget too small for even the smallest candidate still answers 200 with an empty package and every candidate accounted for under `omitted`/`omitted_total`/`omitted_by_reason`, never an error. `plan` reports which lanes ran and why not when they did not, plus the selection/reranker trace — no reranker is configured on this server today, so `plan.reranker` is always `{configured: false, ran: false}` and selection is fully deterministic. Does not change `retrieve` or any direct endpoint's behavior.", + "inputSchema": { + "properties": { + "activate_decay": { + "description": "activate's decay (default 0.5)", + "type": "number" + }, + "activate_limit": { + "description": "activate's limit (default 20)", + "minimum": 0, + "type": "integer" + }, + "budget": { + "description": "three independent hard ceilings on the returned package; an omitted field takes its own default", + "properties": { + "max_bytes": { + "description": "default 65536 (64 KiB), ceiling 1048576 (1 MiB)", + "minimum": 0, + "type": "integer" + }, + "max_items": { + "description": "default 40, ceiling 1000", + "minimum": 0, + "type": "integer" + }, + "max_tokens": { + "description": "default 4000, an estimate — no separate ceiling beyond max_items/max_bytes", + "minimum": 0, + "type": "integer" + } + }, + "type": "object" + }, + "context": { + "description": "Context name (from list_contexts)", + "type": "string" + }, + "dice_floor": { + "description": "one-call override of the resolve fuzzy floor", + "type": "number" + }, + "include_communities": { + "description": "also search the context's derived community-summary artifact (default false); no artifact is a degrade (plan.lanes.communities.ran = false), never a refusal", + "type": "boolean" + }, + "labels": { + "description": "relation labels to query on, alongside the always-run activate", + "items": { + "type": "string" + }, + "type": [ + "string", + "array" + ] + }, + "origins": { + "description": "cue(s) to resolve into anchors", + "items": { + "type": "string" + }, + "type": [ + "string", + "array" + ] + }, + "rerank": { + "description": "accepted but not yet acted on — no reranker provider is configured on this server; selection stays fully deterministic", + "type": "object" + }, + "resolve_limit": { + "description": "max resolve candidates per origin (default/ceiling 1000)", + "minimum": 0, + "type": "integer" + }, + "search_limit": { + "description": "the passage/community search calls' limit (default 5)", + "minimum": 0, + "type": "integer" + }, + "semantic_floor": { + "description": "one-call override of the resolve/search/communities semantic floor", + "type": "number" + }, + "text_fallback_query": { + "description": "passage/community lanes' query text; omitted means 'origins' joined with '; '", + "type": "string" + } + }, + "required": [ + "context", + "origins" + ], + "type": "object" + }, + "name": "assemble_evidence" + } + ] + }, + "route": "tools/list", + "status": 200 +} diff --git a/tests/fixtures/wire/shapes.json b/tests/fixtures/wire/shapes.json new file mode 100644 index 00000000..c82cf77b --- /dev/null +++ b/tests/fixtures/wire/shapes.json @@ -0,0 +1,62 @@ +{ + "http_contract": 1, + "mcp_contract": 1, + "volatile_fields": [ + "time", + "server", + "version", + "last_read_epoch", + "last_write_epoch" + ], + "required_request_fields": { + "/contexts/{name}/recall": ["cue"], + "/contexts/{name}/explore": ["origins"], + "/contexts/{name}/activate": ["origins"], + "/contexts/{name}/sources/search": ["query"], + "/contexts/{name}/communities/search": ["query"], + "/contexts/{name}/evidence": ["origins"] + }, + "enums": { + "response.code": [ + "malformed_request", + "invalid_argument", + "over_limit", + "unauthorized", + "forbidden", + "no_context", + "no_source", + "no_paragraph", + "no_group", + "unknown_path", + "method_not_allowed", + "timeout", + "already_exists", + "conflict", + "payload_too_large", + "rate_limited", + "internal", + "embeddings_unconfigured", + "embeddings_failed", + "overloaded", + "unhealthy", + "storage_full", + "maintenance", + "read_only_replica", + "shard_unreachable" + ], + "response.result.items[].kind": ["association", "passage", "community"], + "response.result.items[].lane_ranks[].lane": [ + "graph_query", + "graph_activate", + "passage_bm25", + "passage_vector", + "passage_fused", + "community" + ], + "response.result.omitted[].reason": [ + "duplicate_passage", + "budget_exceeded", + "contradiction_group_exceeds_budget" + ] + } +} diff --git a/tests/http_api/contract.rs b/tests/http_api/contract.rs new file mode 100644 index 00000000..0a42ede9 --- /dev/null +++ b/tests/http_api/contract.rs @@ -0,0 +1,875 @@ +//! Golden wire-contract fixtures (#301, ADR 0005 §9): the machine- +//! readable pin of the current `http_contract: 1` / `mcp_contract: 1` +//! shapes, including #216's evidence-assembly package (#305). Every +//! fixture under `tests/fixtures/wire/{http,mcp}/` is produced and +//! verified here, against the real server binary — Python and +//! TypeScript check the same committed files structurally +//! (`sdk/python/tests/unit/test_wire_contract.py`, +//! `sdk/typescript/tests/unit/wire-contract.test.ts`), and +//! `sdk/spec/check_contract.py` diffs them across a base ref so an +//! unclassified breaking change fails CI instead of shipping quietly. +//! +//! A drift here does not by itself mean a bug — see +//! `tests/fixtures/wire/README.md` for how to classify and regenerate: +//! `TAGURU_UPDATE_WIRE_FIXTURES=1 cargo test --test http_api contract` +//! rewrites every fixture this module owns from a live server. + +use std::path::PathBuf; + +use serde_json::{Value, json}; + +use crate::support::*; + +fn wire_dir() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/wire") +} + +fn should_update() -> bool { + std::env::var_os("TAGURU_UPDATE_WIRE_FIXTURES").is_some() +} + +/// Blanks fields whose real value is either build-specific (`server`, +/// `version` — this crate's own SemVer), call-specific (`time`, every +/// `ApiResponse`/`ApiError`'s elapsed-seconds field), or wall-clock +/// specific (`last_read_epoch`/`last_write_epoch`, a directory entry's +/// own usage stamps) — the same list `shapes.json`'s `volatile_fields` +/// names, so a version bump, a slow CI run, or the literal wall time a +/// fixture happened to regenerate at never reads as wire drift. Runs +/// before a fixture is written OR compared, so a committed fixture +/// always already carries the placeholder and a plain +/// `assert_eq!`/`git diff` needs no special-casing. +/// +/// An MCP tool result carries the whole HTTP body a second time, as a +/// JSON string inside `content[].text` (the pass-through convention +/// ADR 0005 §2.4 documents) — every volatile field inside that string +/// needs the same treatment, so a string value that itself parses as a +/// JSON object/array is recursively normalized and re-encoded in +/// place, not left as opaque text. +fn normalize_volatile(value: &mut Value) { + match value { + Value::Object(map) => { + if matches!(map.get("time"), Some(Value::Number(_))) { + map.insert("time".to_string(), json!(0.0)); + } + if matches!(map.get("server"), Some(Value::String(_))) { + map.insert("server".to_string(), json!("0.0.0")); + } + if let Some(Value::String(text)) = map.get("version") + && text.split('.').count() >= 2 + { + map.insert("version".to_string(), json!("0.0.0")); + } + for key in ["last_read_epoch", "last_write_epoch"] { + if matches!(map.get(key), Some(Value::Number(_))) { + map.insert(key.to_string(), json!(0)); + } + } + for child in map.values_mut() { + normalize_volatile(child); + } + } + Value::Array(items) => { + for item in items { + normalize_volatile(item); + } + } + Value::String(text) => { + if let Ok(mut inner) = serde_json::from_str::(text) + && (inner.is_object() || inner.is_array()) + { + normalize_volatile(&mut inner); + *text = serde_json::to_string(&inner).expect("a Value always re-serializes"); + } + } + _ => {} + } +} + +/// Checks one fixture against `tests/fixtures/wire/{transport}/{operation}.json`, +/// or (`TAGURU_UPDATE_WIRE_FIXTURES=1`) rewrites it from `fixture`. +fn check_or_update(transport: &str, operation: &str, mut fixture: Value) { + normalize_volatile(&mut fixture); + let path = wire_dir().join(transport).join(format!("{operation}.json")); + if should_update() { + let pretty = serde_json::to_string_pretty(&fixture).expect("fixture must serialize") + "\n"; + std::fs::write(&path, pretty) + .unwrap_or_else(|error| panic!("{path:?} must be writable: {error}")); + return; + } + let committed_text = std::fs::read_to_string(&path).unwrap_or_else(|error| { + panic!( + "missing wire fixture {path:?} ({error}) — run \ + `TAGURU_UPDATE_WIRE_FIXTURES=1 cargo test --test http_api contract` \ + to create it, then read tests/fixtures/wire/README.md before committing" + ) + }); + let committed: Value = serde_json::from_str(&committed_text) + .unwrap_or_else(|error| panic!("{path:?} is not valid JSON: {error}")); + assert_eq!( + committed, fixture, + "wire fixture drift at {path:?} — classify the change against ADR 0005 §4 \ + (tests/fixtures/wire/README.md), then regenerate with \ + TAGURU_UPDATE_WIRE_FIXTURES=1 if it's intentional" + ); +} + +fn http_fixture( + operation: &str, + method: &str, + route: &str, + request: Option, + status: u16, + response: Value, +) { + check_or_update( + "http", + operation, + json!({ + "operation": operation, + "contract": "http_contract", + "method": method, + "route": route, + "status": status, + "request": request, + "response": response, + }), + ); +} + +/// [`http_fixture`] pinned to `POST /contexts/{name}/evidence` — every +/// evidence-assembly and evidence-error fixture below targets this one +/// endpoint, so only `operation`/`request`/`status`/`response` vary. +fn evidence_fixture(operation: &str, request: Value, status: u16, response: Value) { + http_fixture( + operation, + "POST", + "/contexts/{name}/evidence", + Some(request), + status, + response, + ); +} + +fn mcp_fixture(operation: &str, route: &str, request: Value, status: u16, response: Value) { + check_or_update( + "mcp", + operation, + json!({ + "operation": operation, + "contract": "mcp_contract", + "route": route, + "status": status, + "request": request, + "response": response, + }), + ); +} + +// --- HTTP: probes --- + +#[test] +fn version_and_health() { + let server = Server::start("contract-probes"); + + let (status, body) = server.call("GET", "/version", None); + assert_eq!(status, 200, "{body}"); + http_fixture("version", "GET", "/version", None, status, body); + + let (status, body) = server.call("GET", "/health", None); + assert_eq!(status, 200, "{body}"); + http_fixture("health", "GET", "/health", None, status, body); +} + +// --- HTTP: graph search envelopes --- + +fn seed_basic_corpus(server: &Server, name: &str) { + server.ok( + "PUT", + &format!("/contexts/{name}"), + Some(json!({"description": "wire-contract corpus"})), + ); + server.ok( + "POST", + &format!("/contexts/{name}/associations"), + Some(json!([ + {"subject": "alpha", "label": "connects_to", "object": "beta", "weight": 2.0, + "source": "doc.md", "paragraph": 0}, + ])), + ); +} + +#[test] +fn recall_match_page_and_contexts_list() { + let server = Server::start("contract-recall"); + seed_basic_corpus(&server, "corpus-a"); + + let request = json!({"cue": "alpha"}); + let (status, body) = server.call("POST", "/contexts/corpus-a/recall", Some(request.clone())); + assert_eq!(status, 200, "{body}"); + assert!( + !body["result"]["matches"].as_array().unwrap().is_empty(), + "{body}" + ); + http_fixture( + "recall", + "POST", + "/contexts/{name}/recall", + Some(request), + status, + body, + ); + + let (status, body) = server.call("GET", "/contexts", None); + assert_eq!(status, 200, "{body}"); + assert!( + !body["result"]["contexts"].as_array().unwrap().is_empty(), + "{body}" + ); + http_fixture("contexts_list", "GET", "/contexts", None, status, body); +} + +#[test] +fn explore_and_activate_pages() { + let server = Server::start("contract-explore"); + seed_basic_corpus(&server, "corpus-b"); + + let request = json!({"origins": ["alpha"]}); + let (status, body) = server.call("POST", "/contexts/corpus-b/explore", Some(request.clone())); + assert_eq!(status, 200, "{body}"); + http_fixture( + "explore", + "POST", + "/contexts/{name}/explore", + Some(request), + status, + body, + ); + + let request = json!({"origins": ["alpha"]}); + let (status, body) = server.call("POST", "/contexts/corpus-b/activate", Some(request.clone())); + assert_eq!(status, 200, "{body}"); + assert!( + !body["result"]["matches"].as_array().unwrap().is_empty(), + "{body}" + ); + http_fixture( + "activate", + "POST", + "/contexts/{name}/activate", + Some(request), + status, + body, + ); +} + +// --- HTTP: passage and community search — PassagePage is the 0.4.0 +// incident shape (ADR 0005 §2.1), CommunityPage the richest of the +// thirteen pagination envelopes. --- + +#[test] +fn sources_search_passage_page() { + let server = Server::start("contract-sources-search"); + server.ok("PUT", "/contexts/corpus-c", None); + server.ok( + "POST", + "/contexts/corpus-c/sources", + Some(json!({"passages": { + "doc.md": "青嶺酒造は雲居県霧沢町の蔵元である。" + }})), + ); + + let request = json!({"query": "酒造"}); + let (status, body) = server.call( + "POST", + "/contexts/corpus-c/sources/search", + Some(request.clone()), + ); + assert_eq!(status, 200, "{body}"); + assert!( + !body["result"]["hits"].as_array().unwrap().is_empty(), + "{body}" + ); + http_fixture( + "sources_search", + "POST", + "/contexts/{name}/sources/search", + Some(request), + status, + body, + ); +} + +/// A community artifact built by hand through the same API `taguru +/// communities` itself writes through (the pattern +/// `tests/http_api/communities.rs::search_refuses_without_an_artifact_and_verdicts_staleness_with_one` +/// already uses) — no LLM stub needed for a deterministic fixture. +#[test] +fn communities_search_community_page() { + let server = Server::start("contract-communities-search"); + server.ok("PUT", "/contexts/corpus-d", None); + server.ok( + "POST", + "/contexts/corpus-d/associations", + Some(json!([ + {"subject": "a1", "label": "近い", "object": "a2", "weight": 2.0}, + ])), + ); + let revision = server.ok("GET", "/contexts/corpus-d", None)["revision"].clone(); + server.ok("PUT", "/contexts/corpus-d::communities", None); + let manifest = json!({ + "taguru_communities": 1, + "algorithm": "louvain-cc/1", + "source_context": "corpus-d", + "revision": revision, + "levels": 1, + "communities": [ + {"id": "L0-0", "level": 0, "fingerprint": "00aa00aa00aa00aa", "concept_count": 2}, + ], + }); + server.ok( + "POST", + "/contexts/corpus-d::communities/sources", + Some(json!({"passages": { + "community:L0-0": "この共同体のテーマは酒造りの歴史です。", + "communities:manifest": manifest.to_string(), + }})), + ); + server.ok( + "POST", + "/contexts/corpus-d::communities/associations", + Some(json!([ + {"subject": "community:L0-0", "label": "contains", "object": "a1", "weight": 6.0}, + {"subject": "community:L0-0", "label": "contains", "object": "a2", "weight": 4.0}, + ])), + ); + + let request = json!({"query": "酒造りの歴史"}); + let (status, body) = server.call( + "POST", + "/contexts/corpus-d/communities/search", + Some(request.clone()), + ); + assert_eq!(status, 200, "{body}"); + assert!( + !body["result"]["hits"].as_array().unwrap().is_empty(), + "{body}" + ); + http_fixture( + "communities_search", + "POST", + "/contexts/{name}/communities/search", + Some(request), + status, + body, + ); +} + +// --- HTTP: evidence assembly (#216, #305, ADR 0006 §10) — the public +// shape #301's own issue names as the thing it must cover. --- + +fn seed_evidence_corpus(server: &Server, name: &str) { + server.ok( + "PUT", + &format!("/contexts/{name}"), + Some(json!({"description": "evidence wire-contract corpus"})), + ); + server.ok( + "POST", + &format!("/contexts/{name}/sources"), + Some(json!({"passages": { + "docs/kura.md": "青嶺酒造は雲居県霧沢町の蔵元である。杜氏は高瀬である。" + }})), + ); + server.ok( + "POST", + &format!("/contexts/{name}/associations"), + Some(json!([ + {"subject": "青嶺酒造", "label": "杜氏", "object": "高瀬", "weight": 1.0, + "source": "docs/kura.md", "paragraph": 0}, + ])), + ); +} + +/// Mixed graph/passage evidence, complete provenance, every lane's +/// `plan` — the baseline shape. +#[test] +fn evidence_mixed_lanes() { + let server = Server::start("contract-evidence-mixed"); + seed_evidence_corpus(&server, "sake"); + + let request = json!({"origins": ["青嶺酒造"]}); + let (status, body) = server.call("POST", "/contexts/sake/evidence", Some(request.clone())); + assert_eq!(status, 200, "{body}"); + let items = body["result"]["items"].as_array().unwrap(); + assert!( + items.iter().any(|item| item["kind"] == "association"), + "{body}" + ); + assert!(items.iter().any(|item| item["kind"] == "passage"), "{body}"); + evidence_fixture("evidence_mixed_lanes", request, status, body); +} + +/// A budget too small for every candidate: some admitted, some +/// `omitted` under `budget_exceeded`, `omitted_total`/`omitted_by_reason` +/// both populated (ADR 0006 §8/§9). +#[test] +fn evidence_budget_constrained() { + let server = Server::start("contract-evidence-budget"); + server.ok("PUT", "/contexts/budget-corpus", None); + let associations: Vec = (0..5) + .map(|index| { + json!({"subject": format!("s{index}"), "label": "rel", + "object": format!("o{index}"), "weight": 1.0}) + }) + .collect(); + server.ok( + "POST", + "/contexts/budget-corpus/associations", + Some(Value::Array(associations)), + ); + + let request = json!({ + "origins": ["s0", "s1", "s2", "s3", "s4"], + "budget": {"max_items": 2}, + }); + let (status, body) = server.call( + "POST", + "/contexts/budget-corpus/evidence", + Some(request.clone()), + ); + assert_eq!(status, 200, "{body}"); + assert_eq!(body["result"]["budget"]["limits"]["max_items"], json!(2)); + assert!( + body["result"]["omitted_total"].as_u64().unwrap() > 0, + "{body}" + ); + assert!( + body["result"]["omitted_by_reason"]["budget_exceeded"] + .as_u64() + .unwrap() + > 0, + "{body}" + ); + evidence_fixture("evidence_budget_constrained", request, status, body); +} + +/// Two near-identical passages: the lower-ranked one is `omitted` +/// under `duplicate_passage`, naming the survivor via `duplicate_of` +/// (ADR 0006 §9). `origins: []` doubles as coverage for the +/// `resolve`/`query`/`activate` lanes' "origins was empty" skip +/// reason, since `text_fallback_query` drives the passages lane +/// directly. +#[test] +fn evidence_duplicate_passage() { + let server = Server::start("contract-evidence-dup"); + server.ok("PUT", "/contexts/dup-corpus", None); + server.ok( + "POST", + "/contexts/dup-corpus/sources", + Some(json!({"passages": { + "a.md": "the quick brown fox jumps over the lazy dog", + "b.md": "the quick brown fox jumps over the lazy dogs" + }})), + ); + + let request = json!({ + "origins": [], + "text_fallback_query": "quick brown fox", + }); + let (status, body) = server.call( + "POST", + "/contexts/dup-corpus/evidence", + Some(request.clone()), + ); + assert_eq!(status, 200, "{body}"); + assert_eq!( + body["result"]["plan"]["lanes"]["resolve"]["ran"], + json!(false), + "{body}" + ); + assert!( + body["result"]["omitted"] + .as_array() + .unwrap() + .iter() + .any(|omission| omission["reason"] == "duplicate_passage"), + "{body}" + ); + evidence_fixture("evidence_duplicate_passage", request, status, body); +} + +/// Two associations sharing `(subject, label)` but disagreeing on +/// `object`: a contradiction group, both items' `contradicts` +/// populated (ADR 0006 §9). +#[test] +fn evidence_contradiction_group() { + let server = Server::start("contract-evidence-contradiction"); + server.ok("PUT", "/contexts/contradiction-corpus", None); + server.ok( + "POST", + "/contexts/contradiction-corpus/sources", + Some(json!({"passages": { + "s1.md": "猫は哺乳類である。", + "s2.md": "猫は爬虫類だと主張する文献もある。" + }})), + ); + server.ok( + "POST", + "/contexts/contradiction-corpus/associations", + Some(json!([ + {"subject": "猫", "label": "is_a", "object": "哺乳類", "weight": 1.0, + "source": "s1.md", "paragraph": 0}, + {"subject": "猫", "label": "is_a", "object": "爬虫類", "weight": 1.0, + "source": "s2.md", "paragraph": 0}, + ])), + ); + + let request = json!({"origins": ["猫"]}); + let (status, body) = server.call( + "POST", + "/contexts/contradiction-corpus/evidence", + Some(request.clone()), + ); + assert_eq!(status, 200, "{body}"); + let items = body["result"]["items"].as_array().unwrap(); + assert!( + items.iter().any(|item| !item["contradicts"] + .as_array() + .unwrap_or(&Vec::new()) + .is_empty()), + "{body}" + ); + evidence_fixture("evidence_contradiction_group", request, status, body); +} + +/// `include_communities: true` with no artifact yet — a degrade, not a +/// refusal (ADR 0006 §11) — plus a `rerank` hint that no provider acts +/// on, pinning `plan.reranker.reason`. +#[test] +fn evidence_communities_degrade_and_rerank_reason() { + let server = Server::start("contract-evidence-communities"); + server.ok("PUT", "/contexts/comm-corpus", None); + server.ok( + "POST", + "/contexts/comm-corpus/associations", + Some(json!([{"subject": "alpha", "label": "rel", "object": "beta", "weight": 1.0}])), + ); + + let request = json!({ + "origins": ["alpha"], + "include_communities": true, + "rerank": {"model": "not-configured"}, + }); + let (status, body) = server.call( + "POST", + "/contexts/comm-corpus/evidence", + Some(request.clone()), + ); + assert_eq!(status, 200, "{body}"); + assert_eq!( + body["result"]["plan"]["lanes"]["communities"]["ran"], + json!(false), + "{body}" + ); + assert!( + body["result"]["plan"]["reranker"]["reason"].is_string(), + "{body}" + ); + evidence_fixture( + "evidence_communities_degrade_and_rerank_reason", + request, + status, + body, + ); +} + +// --- HTTP: errors --- + +#[test] +fn error_no_context() { + let server = Server::start("contract-error-no-context"); + let request = json!({"origins": ["x"]}); + let (status, body) = server.call( + "POST", + "/contexts/does-not-exist/evidence", + Some(request.clone()), + ); + assert_eq!(status, 404, "{body}"); + evidence_fixture("error_no_context", request, status, body); +} + +#[test] +fn error_over_limit() { + let server = Server::start("contract-error-over-limit"); + server.ok("PUT", "/contexts/over-limit-corpus", None); + let request = json!({"origins": vec!["x"; 1001]}); + let (status, body) = server.call( + "POST", + "/contexts/over-limit-corpus/evidence", + Some(request.clone()), + ); + assert_eq!(status, 400, "{body}"); + assert_eq!(body["code"], json!("over_limit"), "{body}"); + evidence_fixture("error_over_limit", request, status, body); +} + +#[test] +fn error_malformed_request() { + let server = Server::start("contract-error-malformed"); + server.ok("PUT", "/contexts/malformed-corpus", None); + let request = json!({"origins": ["x"], "budget": "not-an-object"}); + let (status, body) = server.call( + "POST", + "/contexts/malformed-corpus/evidence", + Some(request.clone()), + ); + assert_eq!(status, 422, "{body}"); + assert_eq!(body["code"], json!("malformed_request"), "{body}"); + evidence_fixture("error_malformed_request", request, status, body); +} + +/// A read-scoped key restricted to `forbidden-corpus` alone (never +/// `forbidden-corpus::communities`) asking for `include_communities`. +#[test] +fn error_forbidden() { + let server = Server::start_with_env( + "contract-error-forbidden", + &[ + ("TAGURU_API_TOKENS", "boss:atok,reader:rtok"), + ( + "TAGURU_KEY_SCOPES", + r#"{"reader": {"role": "read", "contexts": ["forbidden-corpus"]}}"#, + ), + ], + ); + let (status, body) = + server.call_with_token("PUT", "/contexts/forbidden-corpus", None, Some("atok")); + assert_eq!(status, 200, "{body}"); + let (status, body) = server.call_with_token( + "POST", + "/contexts/forbidden-corpus/associations", + Some(json!([{"subject": "a", "label": "rel", "object": "b", "weight": 1.0}])), + Some("atok"), + ); + assert_eq!(status, 200, "{body}"); + + let request = json!({"origins": ["a"], "include_communities": true}); + let (status, body) = server.call_with_token( + "POST", + "/contexts/forbidden-corpus/evidence", + Some(request.clone()), + Some("rtok"), + ); + assert_eq!(status, 403, "{body}"); + assert_eq!(body["code"], json!("forbidden"), "{body}"); + evidence_fixture("error_forbidden", request, status, body); +} + +// --- MCP --- + +#[test] +fn mcp_tools_list_assemble_evidence_schema() { + let server = Server::start("contract-mcp-schema"); + let (status, body) = server.call( + "POST", + "/mcp", + Some(json!({"jsonrpc": "2.0", "id": 1, "method": "tools/list", "params": {}})), + ); + assert_eq!(status, 200, "{body}"); + let tools = body["result"]["tools"].as_array().expect("tools array"); + let tool = tools + .iter() + .find(|tool| tool["name"] == "assemble_evidence") + .expect("assemble_evidence tool present") + .clone(); + assert_eq!( + tool["inputSchema"]["required"], + json!(["context", "origins"]), + "{tool}" + ); + mcp_fixture( + "assemble_evidence_tool_schema", + "tools/list", + json!({}), + status, + json!({"tools": [tool]}), + ); +} + +#[test] +fn mcp_assemble_evidence_call() { + let server = Server::start("contract-mcp-call"); + server.ok("PUT", "/contexts/mcp-corpus", None); + server.ok( + "POST", + "/contexts/mcp-corpus/associations", + Some(json!([{"subject": "a", "label": "rel", "object": "b", "weight": 1.0}])), + ); + + let arguments = json!({"context": "mcp-corpus", "origins": ["a"]}); + let (status, body) = server.call( + "POST", + "/mcp", + Some(json!({"jsonrpc": "2.0", "id": 1, "method": "tools/call", + "params": {"name": "assemble_evidence", "arguments": arguments}})), + ); + assert_eq!(status, 200, "{body}"); + let result = body["result"].clone(); + assert!(result.get("isError").is_none(), "{result}"); + mcp_fixture( + "assemble_evidence_call", + "tools/call assemble_evidence", + arguments, + status, + result, + ); +} + +/// `origins` missing entirely — a tool-level error (`isError: true`), +/// never a JSON-RPC abort (ADR 0005 §2.4). +#[test] +fn mcp_assemble_evidence_missing_origins_is_a_tool_error() { + let server = Server::start("contract-mcp-error"); + server.ok("PUT", "/contexts/mcp-error-corpus", None); + + let arguments = json!({"context": "mcp-error-corpus"}); + let (status, body) = server.call( + "POST", + "/mcp", + Some(json!({"jsonrpc": "2.0", "id": 1, "method": "tools/call", + "params": {"name": "assemble_evidence", "arguments": arguments}})), + ); + assert_eq!(status, 200, "{body}"); + let result = body["result"].clone(); + assert_eq!(result["isError"], json!(true), "{result}"); + mcp_fixture( + "assemble_evidence_tool_error", + "tools/call assemble_evidence", + arguments, + status, + result, + ); +} + +// --- shapes.json self-consistency --- + +/// `path`'s dotted segments, `[]` meaning "every element of the array +/// at this point" — the one small path language `shapes.json`'s +/// `enums` keys use, matched against a fixture's own JSON tree. +fn collect_by_path<'a>(value: &'a Value, path: &[&str]) -> Vec<&'a Value> { + let Some((head, rest)) = path.split_first() else { + return vec![value]; + }; + let (key, is_array) = match head.strip_suffix("[]") { + Some(key) => (key, true), + None => (*head, false), + }; + let Some(next) = value.get(key) else { + return Vec::new(); + }; + if is_array { + match next.as_array() { + Some(items) => items + .iter() + .flat_map(|item| collect_by_path(item, rest)) + .collect(), + None => Vec::new(), + } + } else { + collect_by_path(next, rest) + } +} + +fn load_shapes() -> Value { + let text = std::fs::read_to_string(wire_dir().join("shapes.json")) + .expect("tests/fixtures/wire/shapes.json must exist"); + serde_json::from_str(&text).expect("shapes.json must be valid JSON") +} + +fn http_fixtures() -> Vec<(PathBuf, Value)> { + let dir = wire_dir().join("http"); + let mut fixtures = Vec::new(); + for entry in std::fs::read_dir(&dir).expect("tests/fixtures/wire/http must exist") { + let path = entry.expect("readable dir entry").path(); + if path.extension().and_then(|extension| extension.to_str()) != Some("json") { + continue; + } + let text = std::fs::read_to_string(&path).expect("fixture must be readable"); + let value: Value = serde_json::from_str(&text).expect("fixture must be valid JSON"); + fixtures.push((path, value)); + } + fixtures +} + +/// Every string value a declared `enums` path reaches, across every +/// `http/*.json` fixture, must be one of the declared values — so +/// introducing a new `kind`/`lane`/`reason`/`ErrorCode` without adding +/// it to `shapes.json` fails locally, before `check_contract.py` ever +/// runs against a base ref. +#[test] +fn shapes_enums_cover_every_value_every_fixture_actually_emits() { + let shapes = load_shapes(); + let enums = shapes["enums"].as_object().expect("enums object"); + for (path, fixture) in http_fixtures() { + for (path_expr, allowed) in enums { + let allowed: Vec<&str> = allowed + .as_array() + .expect("enum value list") + .iter() + .map(|value| value.as_str().expect("enum value must be a string")) + .collect(); + let segments: Vec<&str> = path_expr.split('.').collect(); + for value in collect_by_path(&fixture, &segments) { + if let Some(text) = value.as_str() { + assert!( + allowed.contains(&text), + "{path_expr} in {path:?} carries {text:?}, which is not in \ + shapes.json's enums — add it there if this is a new, intentional value" + ); + } + } + } + } +} + +/// Every field `shapes.json` marks required for a route is present in +/// every fixture whose `request` targets that route — keeps +/// `required_request_fields` honest against the fixtures it classifies. +#[test] +fn shapes_required_request_fields_are_present_in_every_matching_fixture() { + let shapes = load_shapes(); + let required = shapes["required_request_fields"] + .as_object() + .expect("required_request_fields object"); + let mut routes_seen: std::collections::HashSet = std::collections::HashSet::new(); + for (path, fixture) in http_fixtures() { + let Some(route) = fixture["route"].as_str() else { + continue; + }; + routes_seen.insert(route.to_string()); + let Some(fields) = required.get(route).and_then(Value::as_array) else { + continue; + }; + let Some(request) = fixture.get("request").filter(|value| !value.is_null()) else { + continue; + }; + for field in fields { + let field = field.as_str().expect("required field name"); + assert!( + request.get(field).is_some(), + "{path:?}: shapes.json marks '{field}' required for {route}, \ + but this fixture's request omits it" + ); + } + } + // The reverse direction: a route named in `required_request_fields` + // with no fixture left to check it against is a stale entry (a + // renamed or removed route) that the loop above would never catch. + for route in required.keys() { + assert!( + routes_seen.contains(route.as_str()), + "shapes.json's required_request_fields names '{route}', which no \ + fixture's route matches" + ); + } +} diff --git a/tests/http_api/main.rs b/tests/http_api/main.rs index 8523e347..ec91c237 100644 --- a/tests/http_api/main.rs +++ b/tests/http_api/main.rs @@ -14,6 +14,7 @@ mod benchmark; mod benchmark_search; mod calibrate; mod communities; +mod contract; mod directory; mod directory_labels_compact; mod errors; From 4ad884e10ea3bf2328cb22fb71f954edd9649443 Mon Sep 17 00:00:00 2001 From: Takashi Yamashina Date: Fri, 31 Jul 2026 19:41:20 +0900 Subject: [PATCH 2/2] retrieval: address CodeRabbit findings on #301's PR MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - collect_by_path (Python, Rust, TypeScript): the enum self-consistency check silently found nothing inside MCP fixtures, since their shape lives a second time as JSON text under content[].text rather than a nested object — now unwraps that pass-through text before continuing the same path. tests/http_api/contract.rs also widens both shapes.json self-consistency tests to cover mcp/ fixtures, not just http/. - check_contract.py's classify_request matched a required field by its last path segment alone, so a NEW nested field sharing a name with a required top-level one (request.filter.query vs. a required top-level query) was misclassified as a breaking change. Now matches the exact top-level path. Added sdk/python/tests/unit/test_check_contract.py to cover this and the module's other documented classification rules. - shapes.json: declared response.result.omitted[].kind (same values as items[].kind) — omitted candidates carry it too. - ci.yml: route the base-ref/PR-SHA values through env: instead of interpolating them directly into shell text (zizmor template-injection nitpick). - Cargo.toml: declare rust-version — clippy's own incompatible_msrv lint puts the true floor at 1.89 (File::try_lock in src/storage.rs), not the 1.88 let-chains alone would suggest. Claude-Session: https://claude.ai/code/session_011KBpNVCCcdTUtCuPdpXh2n --- .github/workflows/ci.yml | 10 +- Cargo.toml | 5 + sdk/python/tests/unit/test_check_contract.py | 180 ++++++++++++++++++ sdk/spec/check_contract.py | 33 +++- .../tests/unit/wire-contract.test.ts | 22 ++- tests/fixtures/wire/shapes.json | 3 +- tests/http_api/contract.rs | 54 ++++-- 7 files changed, 281 insertions(+), 26 deletions(-) create mode 100644 sdk/python/tests/unit/test_check_contract.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c82b651e..ed808c2e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -69,14 +69,18 @@ jobs: run: python3 sdk/spec/check_contract.py --check - name: Determine base ref id: base + env: + PR_BASE_SHA: ${{ github.event.pull_request.base.sha }} run: | - if [ -n "${{ github.event.pull_request.base.sha }}" ]; then - echo "ref=${{ github.event.pull_request.base.sha }}" >> "$GITHUB_OUTPUT" + if [ -n "$PR_BASE_SHA" ]; then + echo "ref=$PR_BASE_SHA" >> "$GITHUB_OUTPUT" else echo "ref=HEAD^" >> "$GITHUB_OUTPUT" fi - name: Breaking-change guard - run: python3 sdk/spec/check_contract.py --base "${{ steps.base.outputs.ref }}" + env: + BASE_REF: ${{ steps.base.outputs.ref }} + run: python3 sdk/spec/check_contract.py --base "$BASE_REF" # Coverage and per-PR mutation testing used to run here too. Both # retired to keep CI at the signals that gate a merge: coverage diff --git a/Cargo.toml b/Cargo.toml index 061787fc..06b048b7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,6 +2,11 @@ name = "taguru" version = "0.5.0" edition = "2024" +# `cargo clippy`'s own `incompatible_msrv` lint is the source of +# truth here, not a guess: 1.88 (edition 2024's let-chains, already +# used throughout src/) undershoots it — `File::try_lock` +# (src/storage.rs) needs 1.89. +rust-version = "1.89" license = "MIT" description = "Long-term semantic memory for LLMs: an association-graph server with structural recall, plus an MCP stdio bridge" repository = "https://github.com/t0k0sh1/taguru" diff --git a/sdk/python/tests/unit/test_check_contract.py b/sdk/python/tests/unit/test_check_contract.py new file mode 100644 index 00000000..c431e637 --- /dev/null +++ b/sdk/python/tests/unit/test_check_contract.py @@ -0,0 +1,180 @@ +"""Unit coverage for `sdk/spec/check_contract.py` (#301, ADR 0005 §4): +the breaking-change guard `contract-guard` (CI) runs against every PR. +A regression here silently changes what that guard enforces, so this +locks in the classification rules the module's own docstring documents +— loaded by path the same way `test_wire_contract.py` already does, +since the script is not an installed package. +""" + +from __future__ import annotations + +import importlib.util +import json +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parents[4] + +_spec = importlib.util.spec_from_file_location( + "check_contract", REPO_ROOT / "sdk" / "spec" / "check_contract.py" +) +assert _spec is not None and _spec.loader is not None +check_contract = importlib.util.module_from_spec(_spec) +_spec.loader.exec_module(check_contract) + + +# --- classify(): field add/remove, container-shape changes --- + + +def test_classify_flags_a_removed_field_as_breaking() -> None: + findings = check_contract.classify({"a": 1, "b": 2}, {"a": 1}, "root") + assert findings == [("BREAKING", "root.b", "field removed")] + + +def test_classify_flags_an_added_field_as_compatible() -> None: + findings = check_contract.classify({"a": 1}, {"a": 1, "b": 2}, "root") + assert findings == [("compatible", "root.b", "field added")] + + +def test_classify_flags_array_to_object_as_breaking() -> None: + findings = check_contract.classify({"hits": []}, {"hits": {}}, "root") + assert findings == [("BREAKING", "root.hits", "array -> object")] + + +def test_classify_ignores_a_bare_scalar_value_change() -> None: + # ADR 0005 §4 classifies field add/remove and container-shape + # changes; a same-typed value simply changing is not itself a + # wire-shape change (the module docstring's own stated scope). + findings = check_contract.classify({"status": "ok"}, {"status": "degraded"}, "root") + assert findings == [] + + +def test_classify_recurses_into_a_shared_nested_object() -> None: + base = {"plan": {"ran": True}} + head = {"plan": {"ran": True, "reason": "skipped"}} + findings = check_contract.classify(base, head, "root") + assert findings == [("compatible", "root.plan.reason", "field added")] + + +def test_classify_compares_only_the_first_array_element() -> None: + # Documented, deliberate scope limit (module docstring) — a shape + # change only visible past index 0 is out of scope for this guard. + base = {"items": [{"a": 1}]} + head = {"items": [{"a": 1}, {"a": 1, "b": 2}]} + assert check_contract.classify(base, head, "root") == [] + + +# --- classify_request(): ADR 0005 §4's asymmetric request rule --- + + +def test_classify_request_flags_a_new_top_level_required_field() -> None: + base = {"origins": ["x"]} + head = {"origins": ["x"], "query": "y"} + required = {"/contexts/{name}/evidence": ["query"]} + findings = check_contract.classify_request( + base, head, "op.request", "/contexts/{name}/evidence", required + ) + assert findings == [ + ( + "BREAKING", + "op.request.query", + "field added AND required — old clients never send it", + ) + ] + + +def test_classify_request_leaves_a_new_top_level_optional_field_compatible() -> None: + base = {"origins": ["x"]} + head = {"origins": ["x"], "query": "y"} + required = {"/contexts/{name}/evidence": ["origins"]} + findings = check_contract.classify_request( + base, head, "op.request", "/contexts/{name}/evidence", required + ) + assert findings == [("compatible", "op.request.query", "field added")] + + +def test_classify_request_does_not_confuse_a_nested_field_with_a_required_top_level_one() -> None: + # Regression: a route requires top-level `query`; a NEW nested + # `filter.query` must not be misclassified as that same field — + # only the exact top-level path counts, not just the last segment. + base = {"origins": ["x"], "filter": {}} + head = {"origins": ["x"], "filter": {"query": "nested, not top-level"}} + required = {"/contexts/{name}/evidence": ["query"]} + findings = check_contract.classify_request( + base, head, "op.request", "/contexts/{name}/evidence", required + ) + assert findings == [("compatible", "op.request.filter.query", "field added")] + + +def test_classify_request_with_no_route_never_promotes_to_breaking() -> None: + base = {"origins": ["x"]} + head = {"origins": ["x"], "query": "y"} + required = {"/contexts/{name}/evidence": ["query"]} + findings = check_contract.classify_request(base, head, "op.request", None, required) + assert findings == [("compatible", "op.request.query", "field added")] + + +# --- diff_shapes(): the two shapes.json-declared breaking cases --- + + +def test_diff_shapes_flags_a_removed_known_enum_value() -> None: + base_shapes = {"enums": {"result.kind": ["a", "b"]}} + head_shapes = {"enums": {"result.kind": ["a"]}} + findings = check_contract.diff_shapes(base_shapes, head_shapes) + assert findings == [("BREAKING", "shapes.enums[result.kind]", "known value 'b' removed")] + + +def test_diff_shapes_does_not_flag_a_newly_added_enum_value() -> None: + base_shapes = {"enums": {"result.kind": ["a"]}} + head_shapes = {"enums": {"result.kind": ["a", "b"]}} + assert check_contract.diff_shapes(base_shapes, head_shapes) == [] + + +def test_diff_shapes_flags_a_newly_required_request_field() -> None: + base_shapes = {"required_request_fields": {"/x": ["a"]}} + head_shapes = {"required_request_fields": {"/x": ["a", "b"]}} + findings = check_contract.diff_shapes(base_shapes, head_shapes) + assert findings == [("BREAKING", "shapes.required_request_fields[/x]", "'b' newly required")] + + +def test_diff_shapes_does_not_flag_a_field_no_longer_required() -> None: + base_shapes = {"required_request_fields": {"/x": ["a", "b"]}} + head_shapes = {"required_request_fields": {"/x": ["a"]}} + assert check_contract.diff_shapes(base_shapes, head_shapes) == [] + + +# --- collect_by_path(): MCP pass-through unwrap (ADR 0005 §2.4) --- + + +def test_collect_by_path_finds_a_value_behind_a_plain_object_walk() -> None: + fixture = {"response": {"result": {"items": [{"kind": "passage"}]}}} + values = check_contract.collect_by_path(fixture, "response.result.items[].kind".split(".")) + assert values == ["passage"] + + +def test_collect_by_path_unwraps_an_mcp_tool_results_embedded_json_text() -> None: + # The MCP pass-through convention: the same body lives a second + # time as JSON text inside content[].text, not as a nested object. + embedded = json.dumps({"result": {"items": [{"kind": "association"}]}}) + fixture = {"response": {"content": [{"type": "text", "text": embedded}]}} + values = check_contract.collect_by_path(fixture, "response.result.items[].kind".split(".")) + assert values == ["association"] + + +def test_collect_by_path_returns_nothing_for_a_genuinely_missing_path() -> None: + fixture = {"response": {"status": "ok"}} + values = check_contract.collect_by_path(fixture, "response.result.items[].kind".split(".")) + assert values == [] + + +# --- bucket()/kind(): container-shape classification --- + + +def test_bucket_collapses_scalars_kind_stays_specific() -> None: + assert check_contract.bucket("x") == "scalar" + assert check_contract.bucket(1) == "scalar" + assert check_contract.bucket(None) == "scalar" + assert check_contract.bucket({}) == "object" + assert check_contract.bucket([]) == "array" + assert check_contract.kind("x") == "string" + assert check_contract.kind(1) == "number" + assert check_contract.kind(None) == "null" diff --git a/sdk/spec/check_contract.py b/sdk/spec/check_contract.py index e7e5af90..156293b3 100755 --- a/sdk/spec/check_contract.py +++ b/sdk/spec/check_contract.py @@ -57,13 +57,34 @@ def collect_by_path(value: object, segments: list[str]) -> list[object]: head, *rest = segments is_array = head.endswith("[]") key = head[:-2] if is_array else head - if not isinstance(value, dict) or key not in value: + if not isinstance(value, dict): return [] + if key not in value: + # MCP pass-through (ADR 0005 §2.4): a tool result carries the + # whole HTTP body a second time as JSON text inside + # `content[].text`, so the shape this path expects (e.g. + # `result.items[]...`) lives one level deeper than a plain + # object walk reaches. Retry the SAME unconsumed segments + # (`key` included) against each parsed `content[].text` — it + # takes the place of `value` at this level, not `value[key]`. + content = value.get("content") + if not isinstance(content, list): + return [] + collected: list[object] = [] + for item in content: + if not (isinstance(item, dict) and isinstance(item.get("text"), str)): + continue + try: + parsed = json.loads(item["text"]) + except ValueError: + continue + collected += collect_by_path(parsed, segments) + return collected nxt = value[key] if is_array: if not isinstance(nxt, list): return [] - collected: list[object] = [] + collected = [] for item in nxt: collected += collect_by_path(item, rest) return collected @@ -241,8 +262,12 @@ def classify_request( upgraded = [] for severity, finding_path, detail in findings: if severity == "compatible" and detail == "field added": - field = finding_path.rsplit(".", 1)[-1] - if field in required: + # Match the exact top-level path, not just the last + # segment — a NESTED field sharing a name with a + # top-level required one (`request.filter.query` vs. a + # required top-level `query`) is not the same field. + prefix, _, field = finding_path.rpartition(".") + if prefix == path and field in required: severity = "BREAKING" detail = "field added AND required — old clients never send it" upgraded.append((severity, finding_path, detail)) diff --git a/sdk/typescript/tests/unit/wire-contract.test.ts b/sdk/typescript/tests/unit/wire-contract.test.ts index dd61ce41..52e78cee 100644 --- a/sdk/typescript/tests/unit/wire-contract.test.ts +++ b/sdk/typescript/tests/unit/wire-contract.test.ts @@ -62,13 +62,33 @@ const SHAPES = JSON.parse(readFileSync(join(WIRE_DIR, "shapes.json"), "utf-8")) * in sdk/spec/check_contract.py and tests/http_api/contract.rs: each * language keeps its own ~15-line copy rather than a cross-language * import. + * + * An MCP tool result carries the whole HTTP body a second time as + * JSON text inside `content[].text` (ADR 0005 §2.4's pass-through + * convention), one level deeper than a plain object walk reaches — + * when `key` isn't found directly, each `content[].text` is parsed + * and the SAME unconsumed `segments` (not `rest`) is retried against + * it, since the parsed value takes `value`'s own place at this level. */ function collectByPath(value: unknown, segments: string[]): unknown[] { if (segments.length === 0) return [value]; const [head, ...rest] = segments; const isArray = head!.endsWith("[]"); const key = isArray ? head!.slice(0, -2) : head!; - if (typeof value !== "object" || value === null || !(key in value)) return []; + if (typeof value !== "object" || value === null) return []; + if (!(key in value)) { + const content = (value as Record)["content"]; + if (!Array.isArray(content)) return []; + return content.flatMap((item: unknown) => { + const text = (item as Record | null)?.["text"]; + if (typeof text !== "string") return []; + try { + return collectByPath(JSON.parse(text), segments); + } catch { + return []; + } + }); + } const next = (value as Record)[key]; if (isArray) { if (!Array.isArray(next)) return []; diff --git a/tests/fixtures/wire/shapes.json b/tests/fixtures/wire/shapes.json index c82cf77b..df315da9 100644 --- a/tests/fixtures/wire/shapes.json +++ b/tests/fixtures/wire/shapes.json @@ -57,6 +57,7 @@ "duplicate_passage", "budget_exceeded", "contradiction_group_exceeds_budget" - ] + ], + "response.result.omitted[].kind": ["association", "passage", "community"] } } diff --git a/tests/http_api/contract.rs b/tests/http_api/contract.rs index 0a42ede9..cfb74e94 100644 --- a/tests/http_api/contract.rs +++ b/tests/http_api/contract.rs @@ -756,16 +756,31 @@ fn mcp_assemble_evidence_missing_origins_is_a_tool_error() { /// `path`'s dotted segments, `[]` meaning "every element of the array /// at this point" — the one small path language `shapes.json`'s /// `enums` keys use, matched against a fixture's own JSON tree. -fn collect_by_path<'a>(value: &'a Value, path: &[&str]) -> Vec<&'a Value> { +/// +/// An MCP tool result carries the whole HTTP body a second time as +/// JSON text inside `content[].text` (ADR 0005 §2.4's pass-through +/// convention), one level deeper than a plain object walk reaches — +/// when `key` isn't found directly, each `content[].text` is parsed +/// and the SAME unconsumed `path` (not `rest`) is retried against it, +/// since the parsed value takes `value`'s own place at this level. +fn collect_by_path(value: &Value, path: &[&str]) -> Vec { let Some((head, rest)) = path.split_first() else { - return vec![value]; + return vec![value.clone()]; }; let (key, is_array) = match head.strip_suffix("[]") { Some(key) => (key, true), None => (*head, false), }; let Some(next) = value.get(key) else { - return Vec::new(); + let Some(content) = value.get("content").and_then(Value::as_array) else { + return Vec::new(); + }; + return content + .iter() + .filter_map(|item| item.get("text")?.as_str()) + .filter_map(|text| serde_json::from_str::(text).ok()) + .flat_map(|parsed| collect_by_path(&parsed, path)) + .collect(); }; if is_array { match next.as_array() { @@ -786,31 +801,36 @@ fn load_shapes() -> Value { serde_json::from_str(&text).expect("shapes.json must be valid JSON") } -fn http_fixtures() -> Vec<(PathBuf, Value)> { - let dir = wire_dir().join("http"); +/// Every fixture under `tests/fixtures/wire/{http,mcp}/` — both +/// transports, so the two self-consistency checks below cover the MCP +/// pass-through shape too, not just the HTTP one it inherits from. +fn wire_fixtures() -> Vec<(PathBuf, Value)> { let mut fixtures = Vec::new(); - for entry in std::fs::read_dir(&dir).expect("tests/fixtures/wire/http must exist") { - let path = entry.expect("readable dir entry").path(); - if path.extension().and_then(|extension| extension.to_str()) != Some("json") { - continue; + for transport in ["http", "mcp"] { + let dir = wire_dir().join(transport); + for entry in std::fs::read_dir(&dir).unwrap_or_else(|_| panic!("{dir:?} must exist")) { + let path = entry.expect("readable dir entry").path(); + if path.extension().and_then(|extension| extension.to_str()) != Some("json") { + continue; + } + let text = std::fs::read_to_string(&path).expect("fixture must be readable"); + let value: Value = serde_json::from_str(&text).expect("fixture must be valid JSON"); + fixtures.push((path, value)); } - let text = std::fs::read_to_string(&path).expect("fixture must be readable"); - let value: Value = serde_json::from_str(&text).expect("fixture must be valid JSON"); - fixtures.push((path, value)); } fixtures } /// Every string value a declared `enums` path reaches, across every -/// `http/*.json` fixture, must be one of the declared values — so -/// introducing a new `kind`/`lane`/`reason`/`ErrorCode` without adding -/// it to `shapes.json` fails locally, before `check_contract.py` ever +/// fixture (HTTP and MCP alike), must be one of the declared values — +/// so introducing a new `kind`/`lane`/`reason`/`ErrorCode` without +/// adding it to `shapes.json` fails locally, before `check_contract.py` ever /// runs against a base ref. #[test] fn shapes_enums_cover_every_value_every_fixture_actually_emits() { let shapes = load_shapes(); let enums = shapes["enums"].as_object().expect("enums object"); - for (path, fixture) in http_fixtures() { + for (path, fixture) in wire_fixtures() { for (path_expr, allowed) in enums { let allowed: Vec<&str> = allowed .as_array() @@ -842,7 +862,7 @@ fn shapes_required_request_fields_are_present_in_every_matching_fixture() { .as_object() .expect("required_request_fields object"); let mut routes_seen: std::collections::HashSet = std::collections::HashSet::new(); - for (path, fixture) in http_fixtures() { + for (path, fixture) in wire_fixtures() { let Some(route) = fixture["route"].as_str() else { continue; };