generated from coulomb/repo-seed
Add discovery reconciliation engine
This commit is contained in:
211
tests/test_reconciliation.py
Normal file
211
tests/test_reconciliation.py
Normal file
@@ -0,0 +1,211 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from railiance_fabric.cli import main as cli_main
|
||||
from railiance_fabric.discovery import discovery_stable_key, replacement_scope_id, source_fingerprint
|
||||
from railiance_fabric.reconciliation import reconcile_discovery_snapshots
|
||||
from railiance_fabric.scanner import ScanOptions, scan_repo
|
||||
from railiance_fabric.schema_validation import draft202012_validator
|
||||
|
||||
|
||||
def test_reconciliation_dedupes_diffs_and_tombstones_by_scope() -> None:
|
||||
scope_replace = _scope("deterministic", "file", "README.md", "replacement")
|
||||
scope_additive = _scope("llm-connect-repo-evidence", "llm", "bundle", "additive")
|
||||
service_key = discovery_stable_key("fixture-repo", "ServiceDeclaration", "fixture.api")
|
||||
old_key = discovery_stable_key("fixture-repo", "ServiceDeclaration", "old.api")
|
||||
additive_old_key = discovery_stable_key("fixture-repo", "CapabilityDeclaration", "old-llm")
|
||||
new_key = discovery_stable_key("fixture-repo", "CapabilityDeclaration", "fixture.ops")
|
||||
duplicate_key = discovery_stable_key(
|
||||
"fixture-repo",
|
||||
"ServiceDeclaration",
|
||||
"Fixture API",
|
||||
source_anchor={"path": "README.md", "line_start": 5},
|
||||
)
|
||||
|
||||
previous = _snapshot(
|
||||
replacement_scopes=[scope_replace, scope_additive],
|
||||
nodes=[
|
||||
_node(service_key, "ServiceDeclaration", "Fixture API", scope_replace["id"], attributes={"owner": "old"}),
|
||||
_node(old_key, "ServiceDeclaration", "Old API", scope_replace["id"]),
|
||||
_node(additive_old_key, "CapabilityDeclaration", "Old LLM", scope_additive["id"], origin="llm"),
|
||||
],
|
||||
tombstones=[
|
||||
{
|
||||
"stable_key": discovery_stable_key("fixture-repo", "ServiceDeclaration", "Ancient API"),
|
||||
"entity_kind": "node",
|
||||
"replacement_scope": scope_replace["id"],
|
||||
"retired_at": "2026-05-18T00:00:00Z",
|
||||
"reason": "source_missing",
|
||||
}
|
||||
],
|
||||
)
|
||||
current = _snapshot(
|
||||
replacement_scopes=[scope_replace, scope_additive],
|
||||
nodes=[
|
||||
_node(service_key, "ServiceDeclaration", "Fixture API", scope_replace["id"], origin="llm", attributes={"owner": "llm"}),
|
||||
_node(
|
||||
service_key,
|
||||
"ServiceDeclaration",
|
||||
"Fixture API",
|
||||
scope_replace["id"],
|
||||
origin="repo_declaration",
|
||||
review_state="accepted",
|
||||
attributes={"owner": "declared"},
|
||||
),
|
||||
_node(new_key, "CapabilityDeclaration", "Fixture Ops", scope_replace["id"]),
|
||||
_node(duplicate_key, "ServiceDeclaration", "Fixture API", scope_replace["id"], aliases=["fixture.api.copy"]),
|
||||
],
|
||||
)
|
||||
|
||||
reconciled = reconcile_discovery_snapshots(previous, current, retired_at="2026-05-19T00:00:00Z")
|
||||
|
||||
_validate_schema("discovery-snapshot.schema.yaml", reconciled)
|
||||
nodes = {node["stable_key"]: node for node in reconciled["candidates"]["nodes"]}
|
||||
assert nodes[service_key]["origin"] == "repo_declaration"
|
||||
assert nodes[service_key]["attributes"]["owner"] == "declared"
|
||||
assert nodes[service_key]["review_state"] == "needs_review"
|
||||
assert nodes[duplicate_key]["status"] == "conflicted"
|
||||
|
||||
diff = reconciled["reconciliation"]["diff"]
|
||||
assert new_key in diff["added"]
|
||||
assert duplicate_key in diff["added"]
|
||||
assert service_key in diff["changed"]
|
||||
assert old_key in diff["retired"]
|
||||
assert additive_old_key not in diff["retired"]
|
||||
assert {service_key, duplicate_key} <= set(diff["conflicted"])
|
||||
assert any(conflict["type"] == "possible_duplicate_node" for conflict in reconciled["reconciliation"]["conflicts"])
|
||||
assert any(tombstone["stable_key"] == old_key for tombstone in reconciled["tombstones"])
|
||||
assert any(tombstone["stable_key"].endswith("ancient-api") for tombstone in reconciled["tombstones"])
|
||||
|
||||
|
||||
def test_scan_cli_reconciles_against_previous_snapshot(tmp_path: Path, capsys) -> None:
|
||||
repo = tmp_path / "fixture-repo"
|
||||
repo.mkdir()
|
||||
(repo / "README.md").write_text("# Fixture Repo\n", encoding="utf-8")
|
||||
previous = scan_repo(
|
||||
ScanOptions(
|
||||
repo_path=repo,
|
||||
repo_slug="fixture-repo",
|
||||
repo_name="Fixture Repo",
|
||||
commit="old",
|
||||
)
|
||||
)
|
||||
scope_id = next(scope["id"] for scope in previous["replacement_scopes"] if scope["extractor_id"] == "repo-metadata")
|
||||
vanished_key = discovery_stable_key("fixture-repo", "ServiceDeclaration", "Vanished API")
|
||||
previous["candidates"]["nodes"].append(
|
||||
_node(vanished_key, "ServiceDeclaration", "Vanished API", scope_id)
|
||||
)
|
||||
previous_path = tmp_path / "previous.json"
|
||||
output_path = tmp_path / "current.json"
|
||||
previous_path.write_text(json.dumps(previous), encoding="utf-8")
|
||||
|
||||
assert cli_main(
|
||||
[
|
||||
"scan",
|
||||
str(repo),
|
||||
"--repo-slug",
|
||||
"fixture-repo",
|
||||
"--repo-name",
|
||||
"Fixture Repo",
|
||||
"--commit",
|
||||
"new",
|
||||
"--previous-snapshot",
|
||||
str(previous_path),
|
||||
"--output",
|
||||
str(output_path),
|
||||
]
|
||||
) == 0
|
||||
|
||||
summary = capsys.readouterr().out
|
||||
assert "diff +" in summary
|
||||
payload = json.loads(output_path.read_text(encoding="utf-8"))
|
||||
_validate_schema("discovery-snapshot.schema.yaml", payload)
|
||||
assert vanished_key in payload["reconciliation"]["diff"]["retired"]
|
||||
assert any(tombstone["stable_key"] == vanished_key for tombstone in payload["tombstones"])
|
||||
|
||||
|
||||
def _snapshot(
|
||||
*,
|
||||
replacement_scopes: list[dict[str, object]],
|
||||
nodes: list[dict[str, object]],
|
||||
tombstones: list[dict[str, object]] | None = None,
|
||||
) -> dict[str, object]:
|
||||
return {
|
||||
"apiVersion": "railiance.fabric/v1alpha1",
|
||||
"kind": "FabricDiscoverySnapshot",
|
||||
"generated_at": "2026-05-19T00:00:00Z",
|
||||
"source": {"repo_slug": "fixture-repo", "repo_name": "Fixture Repo", "commit": "abc123"},
|
||||
"scan": {
|
||||
"run_id": "scan:fixture-repo:deterministic:abc123",
|
||||
"profile": "deterministic",
|
||||
"deterministic_only": True,
|
||||
"llm_enabled": False,
|
||||
},
|
||||
"replacement_scopes": replacement_scopes,
|
||||
"candidates": {"nodes": nodes, "edges": [], "attributes": []},
|
||||
"tombstones": tombstones or [],
|
||||
"reconciliation": {
|
||||
"precedence": ["repo_declaration", "deterministic", "catalog", "registry", "llm", "manual"],
|
||||
"duplicate_policy": "stable-key matches merge automatically",
|
||||
"retirement_policy": "missing candidates retire only inside their replacement scope",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _scope(extractor_id: str, source_kind: str, source_path: str, mode: str) -> dict[str, object]:
|
||||
return {
|
||||
"id": replacement_scope_id("fixture-repo", extractor_id, source_kind, source_path=source_path),
|
||||
"extractor_id": extractor_id,
|
||||
"source_kind": source_kind,
|
||||
"source_path": source_path,
|
||||
"mode": mode,
|
||||
}
|
||||
|
||||
|
||||
def _node(
|
||||
stable_key: str,
|
||||
kind: str,
|
||||
label: str,
|
||||
replacement_scope: str,
|
||||
*,
|
||||
origin: str = "deterministic",
|
||||
review_state: str = "candidate",
|
||||
aliases: list[str] | None = None,
|
||||
attributes: dict[str, object] | None = None,
|
||||
) -> dict[str, object]:
|
||||
anchor = _anchor("file", "README.md")
|
||||
return {
|
||||
"stable_key": stable_key,
|
||||
"kind": kind,
|
||||
"label": label,
|
||||
"repo": "fixture-repo",
|
||||
"aliases": aliases or [label],
|
||||
"attributes": attributes or {},
|
||||
"origin": origin,
|
||||
"review_state": review_state,
|
||||
"status": "active",
|
||||
"confidence": 0.9,
|
||||
"replacement_scope": replacement_scope,
|
||||
"provenance": [
|
||||
{
|
||||
"extractor_id": "fixture",
|
||||
"extractor_version": "0.1.0",
|
||||
"method": "declaration" if origin == "repo_declaration" else "deterministic" if origin == "deterministic" else "llm",
|
||||
"origin": origin,
|
||||
}
|
||||
],
|
||||
"source_anchors": [anchor],
|
||||
}
|
||||
|
||||
|
||||
def _anchor(source_kind: str, path: str) -> dict[str, object]:
|
||||
anchor = {"source_kind": source_kind, "path": path}
|
||||
anchor["fingerprint"] = source_fingerprint(anchor)
|
||||
return anchor
|
||||
|
||||
|
||||
def _validate_schema(schema_name: str, payload: dict[str, object]) -> None:
|
||||
validator = draft202012_validator(Path("schemas") / schema_name)
|
||||
validator.validate(payload)
|
||||
Reference in New Issue
Block a user