from __future__ import annotations from pathlib import Path import pytest import jsonschema from railiance_fabric.discovery import ( attribute_stable_key, discovery_stable_key, relationship_stable_key, replacement_scope_id, source_fingerprint, ) from railiance_fabric.schema_validation import draft202012_validator def test_discovery_identity_helpers_are_stable_and_scoped() -> None: service_key = discovery_stable_key("Repo Scoping", "ServiceDeclaration", "Scope Generator") duplicate_key = discovery_stable_key("repo-scoping", "service declaration", "scope-generator") readme_key = discovery_stable_key( "repo-scoping", "ServiceDeclaration", "Scope Generator", source_anchor={"path": "README.md", "line_start": 12}, ) pyproject_key = discovery_stable_key( "repo-scoping", "ServiceDeclaration", "Scope Generator", source_anchor={"path": "pyproject.toml", "line_start": 1}, ) assert service_key == duplicate_key assert service_key == "discovery:repo-scoping:service-declaration:scope-generator" assert readme_key != pyproject_key assert readme_key.startswith(service_key) edge_key = relationship_stable_key( service_key, "provides", discovery_stable_key("repo-scoping", "CapabilityDeclaration", "Scope Generation"), evidence_scope="fabric/declarations", ) assert edge_key.startswith("edge:") assert edge_key == relationship_stable_key( service_key, "provides", discovery_stable_key("repo-scoping", "CapabilityDeclaration", "Scope Generation"), evidence_scope="fabric/declarations", ) attribute_key = attribute_stable_key(service_key, "runtime") assert attribute_key == "attribute:discovery:repo-scoping:service-declaration:scope-generator:runtime" assert replacement_scope_id( "Repo Scoping", "python-package", "package_manifest", source_path="pyproject.toml", ) != replacement_scope_id( "Repo Scoping", "python-package", "package_manifest", source_path="package.json", ) def test_source_fingerprint_ignores_review_snippet_noise() -> None: base = { "source_kind": "file", "path": "README.md", "line_start": 10, "line_end": 18, "snippet": "first wording", } changed_snippet = {**base, "snippet": "edited wording"} assert source_fingerprint(base) == source_fingerprint(changed_snippet) assert source_fingerprint(base) != source_fingerprint({**base, "line_start": 11}) def test_discovery_snapshot_schema_accepts_candidate_graph() -> None: service_key = discovery_stable_key("repo-scoping", "ServiceDeclaration", "Scope Generator") capability_key = discovery_stable_key("repo-scoping", "CapabilityDeclaration", "Scope Generation") edge_key = relationship_stable_key(service_key, "provides", capability_key) scope_id = replacement_scope_id( "repo-scoping", "python-package", "package_manifest", source_path="pyproject.toml", ) anchor = { "source_kind": "package_manifest", "path": "pyproject.toml", "json_pointer": "/project/name", "fingerprint": source_fingerprint( { "source_kind": "package_manifest", "path": "pyproject.toml", "json_pointer": "/project/name", } ), } provenance = { "extractor_id": "python-package", "extractor_version": "0.1.0", "method": "deterministic", "origin": "deterministic", } payload = { "apiVersion": "railiance.fabric/v1alpha1", "kind": "FabricDiscoverySnapshot", "generated_at": "2026-05-19T00:00:00Z", "source": { "repo_slug": "repo-scoping", "repo_name": "repo-scoping", "domain": "capabilities", "commit": "abc123", "path": "/home/worsch/repo-scoping", }, "scan": { "run_id": "scan:repo-scoping:abc123", "profile": "deterministic", "deterministic_only": True, "llm_enabled": False, "started_at": "2026-05-19T00:00:00Z", "completed_at": "2026-05-19T00:00:01Z", }, "replacement_scopes": [ { "id": scope_id, "extractor_id": "python-package", "source_kind": "package_manifest", "source_path": "pyproject.toml", "mode": "replacement", } ], "candidates": { "nodes": [ { "stable_key": service_key, "kind": "ServiceDeclaration", "label": "Scope Generator", "repo": "repo-scoping", "domain": "capabilities", "lifecycle": "active", "aliases": ["repo-scoping", "scope-generator"], "attributes": {"language": "python"}, "origin": "deterministic", "review_state": "candidate", "status": "active", "confidence": 0.85, "replacement_scope": scope_id, "provenance": [provenance], "source_anchors": [anchor], }, { "stable_key": capability_key, "kind": "CapabilityDeclaration", "label": "Scope Generation", "repo": "repo-scoping", "domain": "capabilities", "aliases": ["scope-generation"], "attributes": {"capability_type": "scope-generation"}, "origin": "llm", "review_state": "needs_review", "status": "active", "confidence": 0.62, "replacement_scope": scope_id, "provenance": [ { "extractor_id": "readme-llm", "method": "llm", "origin": "llm", "prompt_version": "repo-summary-v1", "provider": "mock", "model": "mock", "usage": {"total_tokens": 0}, "rationale": "Fixture output for schema coverage.", } ], "source_anchors": [anchor], }, ], "edges": [ { "stable_key": edge_key, "edge_type": "provides", "source_key": service_key, "target_key": capability_key, "origin": "deterministic", "review_state": "candidate", "status": "active", "confidence": 0.8, "replacement_scope": scope_id, "provenance": [provenance], "source_anchors": [anchor], } ], "attributes": [ { "stable_key": attribute_stable_key(service_key, "language"), "entity_key": service_key, "name": "language", "value": "python", "origin": "deterministic", "review_state": "candidate", "confidence": 0.95, "replacement_scope": scope_id, "provenance": [provenance], "source_anchors": [anchor], } ], }, "tombstones": [ { "stable_key": discovery_stable_key("repo-scoping", "ServiceDeclaration", "Old Scope Tool"), "entity_kind": "node", "replacement_scope": scope_id, "retired_at": "2026-05-19T00:00:01Z", "reason": "source_missing", } ], "reconciliation": { "precedence": ["repo_declaration", "deterministic", "catalog", "registry", "llm", "manual"], "duplicate_policy": "stable-key matches merge automatically; alias-only matches require review", "retirement_policy": "missing candidates retire only inside their replacement scope", }, } _validate_schema("discovery-snapshot.schema.yaml", payload) def test_discovery_snapshot_schema_rejects_unscoped_tombstone() -> None: payload = { "apiVersion": "railiance.fabric/v1alpha1", "kind": "FabricDiscoverySnapshot", "source": {"repo_slug": "repo-scoping", "commit": "abc123"}, "scan": { "run_id": "scan:repo-scoping:abc123", "profile": "deterministic", "deterministic_only": True, "llm_enabled": False, }, "replacement_scopes": [], "candidates": {"nodes": [], "edges": [], "attributes": []}, "tombstones": [ { "stable_key": "discovery:repo-scoping:service:old", "entity_kind": "node", "retired_at": "2026-05-19T00:00:01Z", "reason": "source_missing", } ], "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", }, } validator = draft202012_validator(Path("schemas") / "discovery-snapshot.schema.yaml") with pytest.raises(jsonschema.ValidationError): validator.validate(payload) def _validate_schema(schema_name: str, payload: dict[str, object]) -> None: validator = draft202012_validator(Path("schemas") / schema_name) validator.validate(payload)