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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,36 @@ 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
env:
PR_BASE_SHA: ${{ github.event.pull_request.base.sha }}
run: |
if [ -n "$PR_BASE_SHA" ]; then
echo "ref=$PR_BASE_SHA" >> "$GITHUB_OUTPUT"
else
echo "ref=HEAD^" >> "$GITHUB_OUTPUT"
fi
- name: Breaking-change guard
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
# produced a report nobody's decision hung on, and diff-scoped
Expand Down
20 changes: 20 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
20 changes: 20 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 5 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
180 changes: 180 additions & 0 deletions sdk/python/tests/unit/test_check_contract.py
Original file line number Diff line number Diff line change
@@ -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"
99 changes: 99 additions & 0 deletions sdk/python/tests/unit/test_wire_contract.py
Original file line number Diff line number Diff line change
@@ -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"
)
Loading