diff --git a/tests/commands/test_validate_role_capability_map.py b/tests/commands/test_validate_role_capability_map.py new file mode 100644 index 0000000..93e48a9 --- /dev/null +++ b/tests/commands/test_validate_role_capability_map.py @@ -0,0 +1,33 @@ +"""Tests for commands/validate_role_capability_map.py.""" + +from pathlib import Path +from typing import Any +from unittest.mock import patch + +from se_manifest_schema.commands.validate_role_capability_map import run + + +def test_run_returns_0_when_valid(tmp_path: Path) -> None: + with patch( + "se_manifest_schema.commands.validate_role_capability_map.validate_role_capability_map_file", + return_value=[], + ): + result = run(path=tmp_path / "role-capability-map.toml") + assert result == 0 + + +def test_run_returns_1_when_errors(tmp_path: Path) -> None: + with patch( + "se_manifest_schema.commands.validate_role_capability_map.validate_role_capability_map_file", + return_value=["schema.title: must be a nonempty string"], + ): + result = run(path=tmp_path / "role-capability-map.toml") + assert result == 1 + + +def test_run_against_real_file_returns_0() -> None: + project_root = Path(__file__).parent.parent.parent + real_path = project_root / "data" / "schema" / "role-capability-map.toml" + if real_path.exists(): + result = run(path=real_path) + assert result == 0 diff --git a/tests/test_graph_diagnostics.py b/tests/test_graph_diagnostics.py new file mode 100644 index 0000000..0df9276 --- /dev/null +++ b/tests/test_graph_diagnostics.py @@ -0,0 +1,87 @@ +"""Tests for graph/diagnostics.py - GraphDiagnostic rendering.""" + +from pathlib import Path + +from se_manifest_schema.graph.diagnostics import GraphDiagnostic + + +def test_render_code_only() -> None: + d = GraphDiagnostic(code="SE.ORG.TEST", message="something went wrong") + result = d.render() + assert result.startswith("SE.ORG.TEST") + assert "something went wrong" in result + + +def test_render_with_repo() -> None: + d = GraphDiagnostic(code="SE.ORG.TEST", message="bad repo", repo="my-repo") + result = d.render() + assert "my-repo" in result + + +def test_render_with_path_absolute() -> None: + d = GraphDiagnostic( + code="SE.ORG.TEST", + message="artifact missing", + path="/some/abs/path/file.toml", + ) + result = d.render() + assert "/some/abs/path/file.toml" in result + + +def test_render_with_path_relative_to_root(tmp_path: Path) -> None: + artifact = tmp_path / "sub" / "file.toml" + d = GraphDiagnostic( + code="SE.ORG.TEST", + message="artifact missing", + path=str(artifact), + ) + result = d.render(root=tmp_path) + assert "sub/file.toml" in result + assert str(tmp_path) not in result + + +def test_render_with_path_outside_root_falls_back_to_full(tmp_path: Path) -> None: + other_root = tmp_path / "other" + artifact = tmp_path / "somewhere" / "file.toml" + d = GraphDiagnostic( + code="SE.ORG.TEST", + message="artifact missing", + path=str(artifact), + ) + result = d.render(root=other_root) + assert str(artifact) in result + + +def test_render_with_root_as_string(tmp_path: Path) -> None: + artifact = tmp_path / "sub" / "file.toml" + d = GraphDiagnostic( + code="SE.ORG.TEST", + message="artifact missing", + path=str(artifact), + ) + result = d.render(root=str(tmp_path)) + assert "sub/file.toml" in result + + +def test_render_all_fields() -> None: + d = GraphDiagnostic( + code="SE.ORG.CYCLE", + message="dependency cycle detected", + repo="repo-a", + path="/some/path", + ) + result = d.render() + lines = result.splitlines() + assert lines[0] == "SE.ORG.CYCLE" + assert any("repo-a" in line for line in lines) + assert any("/some/path" in line for line in lines) + assert any("dependency cycle detected" in line for line in lines) + + +def test_render_without_repo_or_path() -> None: + d = GraphDiagnostic(code="SE.ORG.GENERIC", message="something failed") + result = d.render() + lines = result.splitlines() + assert len(lines) == 2 + assert lines[0] == "SE.ORG.GENERIC" + assert "something failed" in lines[1] diff --git a/tests/test_graph_load.py b/tests/test_graph_load.py new file mode 100644 index 0000000..58e563c --- /dev/null +++ b/tests/test_graph_load.py @@ -0,0 +1,340 @@ +"""Tests for graph/load.py - manifest graph loading.""" + +from pathlib import Path +from typing import Any +from unittest.mock import patch + +import pytest + +from se_manifest_schema.graph.load import ( + _contains_contiguous_parts, + _dependency_edges_from_items, + _discover_manifest_paths, + _is_excluded_manifest_path, + _string_value, + load_manifest_graph, +) +from se_manifest_schema.graph.model import DependencyEdge, GraphRepository + + +def _write_manifest(directory: Path, content: str, filename: str = "SE_MANIFEST.toml") -> Path: + path = directory / filename + path.write_text(content, encoding="utf-8") + return path + + +def _minimal_manifest_content(name: str = "repo-a", repo_class: str = "core") -> str: + return f""" +[repo] +name = "{name}" +class = "{repo_class}" +version = "0.1.0" +status = "active" + +[layer] +space = "theory" +role = "kernel" +""" + + +def _schema_content() -> str: + return """ +[class.core] +required_sections = ["repo"] + +[section.repo] +allowed_fields = ["name", "class", "version", "status"] +""" + + +# ── _string_value ────────────────────────────────────────────────────────────── + +def test_string_value_returns_string() -> None: + assert _string_value({"key": "val"}, "key") == "val" + + +def test_string_value_returns_empty_for_missing() -> None: + assert _string_value({}, "missing") == "" + + +def test_string_value_returns_empty_for_non_string() -> None: + assert _string_value({"key": 42}, "key") == "" + + +# ── _contains_contiguous_parts ───────────────────────────────────────────────── + +def test_contiguous_parts_found() -> None: + assert _contains_contiguous_parts(("a", "b", "c", "d"), ("b", "c")) is True + + +def test_contiguous_parts_at_start() -> None: + assert _contains_contiguous_parts(("a", "b", "c"), ("a", "b")) is True + + +def test_contiguous_parts_at_end() -> None: + assert _contains_contiguous_parts(("a", "b", "c"), ("b", "c")) is True + + +def test_contiguous_parts_not_found() -> None: + assert _contains_contiguous_parts(("a", "b", "c"), ("a", "c")) is False + + +def test_contiguous_parts_empty_excluded() -> None: + assert _contains_contiguous_parts(("a", "b"), ()) is False + + +def test_contiguous_parts_excluded_longer_than_candidate() -> None: + assert _contains_contiguous_parts(("a",), ("a", "b")) is False + + +# ── _is_excluded_manifest_path ───────────────────────────────────────────────── + +def test_excluded_by_dir_name(tmp_path: Path) -> None: + manifest = tmp_path / ".venv" / "SE_MANIFEST.toml" + manifest.parent.mkdir() + manifest.touch() + assert _is_excluded_manifest_path( + manifest, + root=tmp_path, + excluded_dir_names=[".venv"], + excluded_path_parts=[], + ) + + +def test_not_excluded_when_no_rules(tmp_path: Path) -> None: + manifest = tmp_path / "repo" / "SE_MANIFEST.toml" + manifest.parent.mkdir() + manifest.touch() + assert not _is_excluded_manifest_path( + manifest, + root=tmp_path, + excluded_dir_names=[], + excluded_path_parts=[], + ) + + +def test_excluded_by_path_parts(tmp_path: Path) -> None: + manifest = tmp_path / "tests" / "fixtures" / "SE_MANIFEST.toml" + manifest.parent.mkdir(parents=True) + manifest.touch() + assert _is_excluded_manifest_path( + manifest, + root=tmp_path, + excluded_dir_names=[], + excluded_path_parts=[("tests", "fixtures")], + ) + + +# ── _discover_manifest_paths ─────────────────────────────────────────────────── + +def test_discover_finds_se_manifest(tmp_path: Path) -> None: + sub = tmp_path / "repo" + sub.mkdir() + (sub / "SE_MANIFEST.toml").write_text("[repo]\nname='x'\n", encoding="utf-8") + paths = _discover_manifest_paths(tmp_path) + assert any(p.name == "SE_MANIFEST.toml" for p in paths) + + +def test_discover_prefers_se_manifest_over_manifest(tmp_path: Path) -> None: + sub = tmp_path / "repo" + sub.mkdir() + (sub / "SE_MANIFEST.toml").write_text("[repo]\nname='a'\n", encoding="utf-8") + (sub / "MANIFEST.toml").write_text("[repo]\nname='b'\n", encoding="utf-8") + paths = _discover_manifest_paths(tmp_path) + names = [p.name for p in paths] + assert "SE_MANIFEST.toml" in names + assert "MANIFEST.toml" not in names + + +def test_discover_finds_manifest_toml_fallback(tmp_path: Path) -> None: + sub = tmp_path / "repo" + sub.mkdir() + (sub / "MANIFEST.toml").write_text("[repo]\nname='b'\n", encoding="utf-8") + paths = _discover_manifest_paths(tmp_path) + assert any(p.name == "MANIFEST.toml" for p in paths) + + +def test_discover_empty_dir(tmp_path: Path) -> None: + assert _discover_manifest_paths(tmp_path) == [] + + +# ── _dependency_edges_from_items ─────────────────────────────────────────────── + +def test_edges_from_string_items() -> None: + edges = _dependency_edges_from_items( + source="repo-a", items=["repo-b", "repo-c"], required=True + ) + assert len(edges) == 2 + assert all(isinstance(e, DependencyEdge) for e in edges) + assert edges[0].target == "repo-b" + assert edges[0].required is True + assert edges[0].kind == "semantic" + + +def test_edges_from_dict_items() -> None: + edges = _dependency_edges_from_items( + source="repo-a", + items=[{"repo": "repo-b", "kind": "artifact", "version": "1.0", "reason": "needs"}], + required=False, + ) + assert len(edges) == 1 + assert edges[0].target == "repo-b" + assert edges[0].kind == "artifact" + assert edges[0].version == "1.0" + assert edges[0].reason == "needs" + assert edges[0].required is False + + +def test_edges_skips_dict_without_repo_key() -> None: + edges = _dependency_edges_from_items( + source="repo-a", + items=[{"kind": "artifact"}], + required=True, + ) + assert edges == [] + + +def test_edges_skips_non_string_non_dict() -> None: + edges = _dependency_edges_from_items( + source="repo-a", + items=[42, None, True], + required=True, + ) + assert edges == [] + + +def test_edges_from_non_list_returns_empty() -> None: + edges = _dependency_edges_from_items( + source="repo-a", items="not-a-list", required=True + ) + assert edges == [] + + +def test_edges_dict_item_defaults_kind_to_semantic() -> None: + edges = _dependency_edges_from_items( + source="a", + items=[{"repo": "b"}], + required=True, + ) + assert edges[0].kind == "semantic" + + +def test_edges_dict_item_with_non_string_kind_defaults_semantic() -> None: + edges = _dependency_edges_from_items( + source="a", + items=[{"repo": "b", "kind": 99}], + required=True, + ) + assert edges[0].kind == "semantic" + + +# ── load_manifest_graph ──────────────────────────────────────────────────────── + +def test_load_manifest_graph_empty_root(tmp_path: Path) -> None: + schema_path = tmp_path / "schema.toml" + schema_path.write_text(_schema_content(), encoding="utf-8") + graph = load_manifest_graph(root=tmp_path, schema_path=schema_path) + assert graph.repositories == {} + assert graph.edges == () + + +def test_load_manifest_graph_single_repo(tmp_path: Path) -> None: + repo_dir = tmp_path / "repo-a" + repo_dir.mkdir() + _write_manifest(repo_dir, _minimal_manifest_content("repo-a")) + + schema_path = tmp_path / "manifest-schema.toml" + schema_path.write_text(_schema_content(), encoding="utf-8") + + graph = load_manifest_graph(root=tmp_path, schema_path=schema_path) + assert "repo-a" in graph.repositories + + +def test_load_manifest_graph_with_dependencies(tmp_path: Path) -> None: + content = """ +[repo] +name = "repo-a" +class = "core" +version = "0.1.0" +status = "active" + +[layer] +space = "theory" +role = "kernel" + +[depends] +required = [{repo = "repo-b", kind = "semantic"}] +optional = ["repo-c"] +""" + repo_dir = tmp_path / "repo-a" + repo_dir.mkdir() + _write_manifest(repo_dir, content) + + schema_path = tmp_path / "manifest-schema.toml" + schema_path.write_text(_schema_content(), encoding="utf-8") + + graph = load_manifest_graph(root=tmp_path, schema_path=schema_path) + assert len(graph.edges) == 2 + targets = {e.target for e in graph.edges} + assert "repo-b" in targets + assert "repo-c" in targets + + +def test_load_manifest_graph_excludes_dir_names(tmp_path: Path) -> None: + venv_dir = tmp_path / ".venv" / "some-repo" + venv_dir.mkdir(parents=True) + _write_manifest(venv_dir, _minimal_manifest_content("hidden-repo")) + + schema_path = tmp_path / "manifest-schema.toml" + schema_path.write_text(_schema_content(), encoding="utf-8") + + graph = load_manifest_graph( + root=tmp_path, + schema_path=schema_path, + excluded_dir_names=[".venv"], + ) + assert "hidden-repo" not in graph.repositories + + +def test_load_manifest_graph_excludes_path_parts(tmp_path: Path) -> None: + fixture_dir = tmp_path / "tests" / "fixtures" + fixture_dir.mkdir(parents=True) + _write_manifest(fixture_dir, _minimal_manifest_content("fixture-repo")) + + schema_path = tmp_path / "manifest-schema.toml" + schema_path.write_text(_schema_content(), encoding="utf-8") + + graph = load_manifest_graph( + root=tmp_path, + schema_path=schema_path, + excluded_path_parts=[("tests", "fixtures")], + ) + assert "fixture-repo" not in graph.repositories + + +def test_load_manifest_graph_captures_provided_artifacts(tmp_path: Path) -> None: + content = """ +[repo] +name = "provider" +class = "core" +version = "0.1.0" +status = "active" + +[layer] +space = "theory" +role = "kernel" + +[provides] +artifacts = ["output/schema.json", "output/report.md"] +""" + repo_dir = tmp_path / "provider" + repo_dir.mkdir() + _write_manifest(repo_dir, content) + + schema_path = tmp_path / "manifest-schema.toml" + schema_path.write_text(_schema_content(), encoding="utf-8") + + graph = load_manifest_graph(root=tmp_path, schema_path=schema_path) + repo = graph.repositories["provider"] + assert "output/schema.json" in repo.provided_artifacts + assert "output/report.md" in repo.provided_artifacts diff --git a/tests/test_graph_report.py b/tests/test_graph_report.py new file mode 100644 index 0000000..5cda674 --- /dev/null +++ b/tests/test_graph_report.py @@ -0,0 +1,111 @@ +"""Tests for graph/report.py - Markdown report rendering.""" + +from pathlib import Path +from typing import Any + +from se_manifest_schema.graph.diagnostics import GraphDiagnostic +from se_manifest_schema.graph.model import DependencyEdge, GraphRepository, ManifestGraph +from se_manifest_schema.graph.report import render_markdown_report + + +def _make_repo(name: str, tmp_path: Path) -> GraphRepository: + return GraphRepository( + name=name, + repo_class="core", + layer_space="theory", + layer_role="kernel", + status="active", + root=tmp_path / name, + manifest_path=tmp_path / name / "SE_MANIFEST.toml", + manifest={}, + provided_artifacts=(), + ) + + +def _make_graph( + repos: dict[str, GraphRepository], + edges: list[DependencyEdge], + schema: dict[str, Any] | None = None, +) -> ManifestGraph: + return ManifestGraph( + repositories=repos, + edges=tuple(edges), + missing_manifest_roots=(), + manifest_schema=schema or {}, + ) + + +def test_render_empty_graph(tmp_path: Path) -> None: + graph = _make_graph({}, []) + report = render_markdown_report(graph=graph, diagnostics=[]) + assert "# Manifest Graph Report" in report + assert "Repositories: 0" in report + assert "Dependencies: 0" in report + assert "Diagnostics: 0" in report + assert "No diagnostics." in report + assert "No dependencies." in report + + +def test_render_with_repositories(tmp_path: Path) -> None: + repos = { + "repo-a": _make_repo("repo-a", tmp_path), + "repo-b": _make_repo("repo-b", tmp_path), + } + graph = _make_graph(repos, []) + report = render_markdown_report(graph=graph, diagnostics=[]) + assert "Repositories: 2" in report + assert "repo-a" in report + assert "repo-b" in report + + +def test_render_with_dependencies(tmp_path: Path) -> None: + repos = { + "repo-a": _make_repo("repo-a", tmp_path), + "repo-b": _make_repo("repo-b", tmp_path), + } + edges = [ + DependencyEdge(source="repo-a", target="repo-b", required=True, kind="semantic"), + ] + graph = _make_graph(repos, edges) + report = render_markdown_report(graph=graph, diagnostics=[]) + assert "Dependencies: 1" in report + assert "repo-a" in report + assert "repo-b" in report + assert "required" in report + assert "semantic" in report + + +def test_render_with_optional_dependency(tmp_path: Path) -> None: + repos = { + "repo-a": _make_repo("repo-a", tmp_path), + "repo-b": _make_repo("repo-b", tmp_path), + } + edges = [ + DependencyEdge(source="repo-a", target="repo-b", required=False, kind="artifact"), + ] + graph = _make_graph(repos, edges) + report = render_markdown_report(graph=graph, diagnostics=[]) + assert "optional" in report + + +def test_render_with_diagnostics(tmp_path: Path) -> None: + repos = {"repo-a": _make_repo("repo-a", tmp_path)} + graph = _make_graph(repos, []) + diagnostics = [ + GraphDiagnostic( + code="SE.ORG.CYCLE", + message="dependency cycle detected", + repo="repo-a", + ) + ] + report = render_markdown_report(graph=graph, diagnostics=diagnostics) + assert "Diagnostics: 1" in report + assert "SE.ORG.CYCLE" in report + assert "dependency cycle detected" in report + + +def test_render_returns_string_ending_with_newline(tmp_path: Path) -> None: + graph = _make_graph({}, []) + report = render_markdown_report(graph=graph, diagnostics=[]) + assert isinstance(report, str) + assert report.endswith("\n") diff --git a/tests/test_graph_validate.py b/tests/test_graph_validate.py new file mode 100644 index 0000000..e00880d --- /dev/null +++ b/tests/test_graph_validate.py @@ -0,0 +1,285 @@ +"""Tests for graph/validate.py - SI invariant validation.""" + +from pathlib import Path +from typing import Any + +import pytest + +from se_manifest_schema.graph.diagnostics import GraphDiagnostic +from se_manifest_schema.graph.model import DependencyEdge, GraphRepository, ManifestGraph +from se_manifest_schema.graph.validate import validate_si_invariants + + +def _make_repo( + name: str, + root: Path, + repo_class: str = "core", + provided_artifacts: tuple[str, ...] = (), + manifest: dict[str, Any] | None = None, +) -> GraphRepository: + manifest_path = root / name / "SE_MANIFEST.toml" + return GraphRepository( + name=name, + repo_class=repo_class, + layer_space="theory", + layer_role="kernel", + status="active", + root=root / name, + manifest_path=manifest_path, + manifest=manifest if manifest is not None else {"repo": {"name": name, "class": repo_class}}, + provided_artifacts=provided_artifacts, + ) + + +def _make_graph( + repos: dict[str, GraphRepository], + edges: list[DependencyEdge], + schema: dict[str, Any] | None = None, + tmp_path: Path | None = None, +) -> ManifestGraph: + if schema is None: + schema = { + "class": { + "core": {"required_sections": ["repo"]}, + } + } + return ManifestGraph( + repositories=repos, + edges=tuple(edges), + missing_manifest_roots=(), + manifest_schema=schema, + ) + + +# ── SI01: required semantic graph is acyclic ──────────────────────────────────── + +def test_si01_no_cycles(tmp_path: Path) -> None: + repos = { + "a": _make_repo("a", tmp_path), + "b": _make_repo("b", tmp_path), + } + edges = [DependencyEdge(source="a", target="b", required=True, kind="semantic")] + graph = _make_graph(repos, edges) + diags = validate_si_invariants(graph) + cycle_diags = [d for d in diags if d.code == "SE.ORG.DEPENDENCY_CYCLE"] + assert cycle_diags == [] + + +def test_si01_direct_cycle_detected(tmp_path: Path) -> None: + repos = { + "a": _make_repo("a", tmp_path), + "b": _make_repo("b", tmp_path), + } + edges = [ + DependencyEdge(source="a", target="b", required=True, kind="semantic"), + DependencyEdge(source="b", target="a", required=True, kind="semantic"), + ] + graph = _make_graph(repos, edges) + diags = validate_si_invariants(graph) + cycle_diags = [d for d in diags if d.code == "SE.ORG.DEPENDENCY_CYCLE"] + assert len(cycle_diags) >= 1 + + +def test_si01_three_node_cycle_detected(tmp_path: Path) -> None: + repos = { + "a": _make_repo("a", tmp_path), + "b": _make_repo("b", tmp_path), + "c": _make_repo("c", tmp_path), + } + edges = [ + DependencyEdge(source="a", target="b", required=True, kind="semantic"), + DependencyEdge(source="b", target="c", required=True, kind="semantic"), + DependencyEdge(source="c", target="a", required=True, kind="semantic"), + ] + graph = _make_graph(repos, edges) + diags = validate_si_invariants(graph) + cycle_diags = [d for d in diags if d.code == "SE.ORG.DEPENDENCY_CYCLE"] + assert len(cycle_diags) >= 1 + + +def test_si01_optional_edge_does_not_trigger_cycle_check(tmp_path: Path) -> None: + repos = { + "a": _make_repo("a", tmp_path), + "b": _make_repo("b", tmp_path), + } + edges = [ + DependencyEdge(source="a", target="b", required=False, kind="semantic"), + DependencyEdge(source="b", target="a", required=False, kind="semantic"), + ] + graph = _make_graph(repos, edges) + diags = validate_si_invariants(graph) + cycle_diags = [d for d in diags if d.code == "SE.ORG.DEPENDENCY_CYCLE"] + assert cycle_diags == [] + + +def test_si01_non_semantic_required_edge_not_checked_for_cycle(tmp_path: Path) -> None: + repos = { + "a": _make_repo("a", tmp_path), + "b": _make_repo("b", tmp_path), + } + edges = [ + DependencyEdge(source="a", target="b", required=True, kind="artifact"), + DependencyEdge(source="b", target="a", required=True, kind="artifact"), + ] + graph = _make_graph(repos, edges) + diags = validate_si_invariants(graph) + cycle_diags = [d for d in diags if d.code == "SE.ORG.DEPENDENCY_CYCLE"] + assert cycle_diags == [] + + +# ── SI02: all declared dependencies resolve ───────────────────────────────────── + +def test_si02_all_resolve(tmp_path: Path) -> None: + repos = { + "a": _make_repo("a", tmp_path), + "b": _make_repo("b", tmp_path), + } + edges = [DependencyEdge(source="a", target="b", required=True, kind="semantic")] + graph = _make_graph(repos, edges) + diags = validate_si_invariants(graph) + unresolved = [d for d in diags if d.code == "SE.ORG.UNRESOLVED_DEPENDENCY"] + assert unresolved == [] + + +def test_si02_unresolved_dependency_detected(tmp_path: Path) -> None: + repos = {"a": _make_repo("a", tmp_path)} + edges = [DependencyEdge(source="a", target="missing-repo", required=True, kind="semantic")] + graph = _make_graph(repos, edges) + diags = validate_si_invariants(graph) + unresolved = [d for d in diags if d.code == "SE.ORG.UNRESOLVED_DEPENDENCY"] + assert len(unresolved) == 1 + assert "missing-repo" in unresolved[0].message + + +def test_si02_optional_unresolved_also_reported(tmp_path: Path) -> None: + repos = {"a": _make_repo("a", tmp_path)} + edges = [DependencyEdge(source="a", target="ghost", required=False, kind="semantic")] + graph = _make_graph(repos, edges) + diags = validate_si_invariants(graph) + unresolved = [d for d in diags if d.code == "SE.ORG.UNRESOLVED_DEPENDENCY"] + assert len(unresolved) == 1 + + +# ── SI03: provided artifacts exist ───────────────────────────────────────────── + +def test_si03_artifact_exists(tmp_path: Path) -> None: + repo_root = tmp_path / "repo-a" + repo_root.mkdir() + artifact = repo_root / "output.json" + artifact.write_text("{}", encoding="utf-8") + + repo = _make_repo("repo-a", tmp_path, provided_artifacts=("output.json",)) + repos = {"repo-a": repo} + graph = _make_graph(repos, []) + diags = validate_si_invariants(graph) + missing = [d for d in diags if d.code == "SE.ORG.MISSING_PROVIDED_ARTIFACT"] + assert missing == [] + + +def test_si03_missing_artifact_reported(tmp_path: Path) -> None: + repo_root = tmp_path / "repo-a" + repo_root.mkdir() + + repo = _make_repo("repo-a", tmp_path, provided_artifacts=("nonexistent.json",)) + repos = {"repo-a": repo} + graph = _make_graph(repos, []) + diags = validate_si_invariants(graph) + missing = [d for d in diags if d.code == "SE.ORG.MISSING_PROVIDED_ARTIFACT"] + assert len(missing) == 1 + assert "nonexistent.json" in missing[0].message + + +def test_si03_multiple_missing_artifacts(tmp_path: Path) -> None: + repo_root = tmp_path / "repo-a" + repo_root.mkdir() + + repo = _make_repo( + "repo-a", + tmp_path, + provided_artifacts=("missing1.json", "missing2.json"), + ) + repos = {"repo-a": repo} + graph = _make_graph(repos, []) + diags = validate_si_invariants(graph) + missing = [d for d in diags if d.code == "SE.ORG.MISSING_PROVIDED_ARTIFACT"] + assert len(missing) == 2 + + +def test_si03_no_artifacts_no_diagnostics(tmp_path: Path) -> None: + repo = _make_repo("repo-a", tmp_path, provided_artifacts=()) + graph = _make_graph({"repo-a": repo}, []) + diags = validate_si_invariants(graph) + missing = [d for d in diags if d.code == "SE.ORG.MISSING_PROVIDED_ARTIFACT"] + assert missing == [] + + +# ── SI04: class registry requirements satisfied ───────────────────────────────── + +def test_si04_unknown_class_reported(tmp_path: Path) -> None: + repo = _make_repo("repo-a", tmp_path, repo_class="unknown_class") + repos = {"repo-a": repo} + schema: dict[str, Any] = {"class": {"core": {"required_sections": ["repo"]}}} + graph = _make_graph(repos, [], schema=schema) + diags = validate_si_invariants(graph) + missing = [d for d in diags if d.code == "SE.ORG.MISSING_REQUIRED_SECTION"] + assert any("unknown manifest class" in d.message for d in missing) + + +def test_si04_required_section_present(tmp_path: Path) -> None: + repo = _make_repo( + "repo-a", + tmp_path, + repo_class="core", + manifest={"repo": {"name": "repo-a", "class": "core"}}, + ) + repos = {"repo-a": repo} + schema: dict[str, Any] = {"class": {"core": {"required_sections": ["repo"]}}} + graph = _make_graph(repos, [], schema=schema) + diags = validate_si_invariants(graph) + missing = [d for d in diags if d.code == "SE.ORG.MISSING_REQUIRED_SECTION"] + assert missing == [] + + +def test_si04_missing_required_section_reported(tmp_path: Path) -> None: + repo = _make_repo( + "repo-a", + tmp_path, + repo_class="core", + manifest={"repo": {"name": "repo-a", "class": "core"}}, + ) + repos = {"repo-a": repo} + schema: dict[str, Any] = {"class": {"core": {"required_sections": ["repo", "layer"]}}} + graph = _make_graph(repos, [], schema=schema) + diags = validate_si_invariants(graph) + missing = [d for d in diags if d.code == "SE.ORG.MISSING_REQUIRED_SECTION"] + assert any("layer" in d.message for d in missing) + + +def test_si04_invalid_required_sections_type_reported(tmp_path: Path) -> None: + repo = _make_repo("repo-a", tmp_path, repo_class="core") + repos = {"repo-a": repo} + schema: dict[str, Any] = {"class": {"core": {"required_sections": "not-a-list"}}} + graph = _make_graph(repos, [], schema=schema) + diags = validate_si_invariants(graph) + missing = [d for d in diags if d.code == "SE.ORG.MISSING_REQUIRED_SECTION"] + assert any("invalid required_sections" in d.message for d in missing) + + +# ── combined: multiple invariants at once ────────────────────────────────────── + +def test_combined_multiple_diagnostics(tmp_path: Path) -> None: + repo_root = tmp_path / "repo-a" + repo_root.mkdir() + + repo = _make_repo( + "repo-a", + tmp_path, + provided_artifacts=("missing.json",), + ) + repos = {"repo-a": repo} + edges = [DependencyEdge(source="repo-a", target="ghost", required=True, kind="semantic")] + graph = _make_graph(repos, edges) + diags = validate_si_invariants(graph) + codes = {d.code for d in diags} + assert "SE.ORG.UNRESOLVED_DEPENDENCY" in codes + assert "SE.ORG.MISSING_PROVIDED_ARTIFACT" in codes diff --git a/tests/test_load.py b/tests/test_load.py index d0167bb..f2eb80b 100644 --- a/tests/test_load.py +++ b/tests/test_load.py @@ -1,16 +1,22 @@ """Tests for load.py - file loading and parsing.""" +import subprocess from pathlib import Path -from unittest.mock import patch +from unittest.mock import MagicMock, patch import pytest from se_manifest_schema.load import ( + ALTERNATE_MANIFEST_FILE_NAME, + CANONICAL_MANIFEST_FILE_NAME, + find_manifest_path, get_git_tag, get_repo_version, load_manifest, load_schema, load_toml, + packaged_schema_text, + repo_root_schema_path, schema_text, ) @@ -57,3 +63,101 @@ def test_load_toml_valid(tmp_path: Path) -> None: f.write_text('[meta]\nversion = "1.0.0"\n', encoding="utf-8") data = load_toml(f) assert data["meta"]["version"] == "1.0.0" + + +# ── get_git_tag ──────────────────────────────────────────────────────────────── + +def test_get_git_tag_success() -> None: + mock_output = b"v1.2.3\n" + with ( + patch("shutil.which", return_value="/usr/bin/git"), + patch("subprocess.check_output", return_value=mock_output), + ): + tag = get_git_tag() + assert tag == "v1.2.3" + + +def test_get_git_tag_not_on_tagged_commit() -> None: + with ( + patch("shutil.which", return_value="/usr/bin/git"), + patch( + "subprocess.check_output", + side_effect=subprocess.CalledProcessError(128, "git"), + ), + pytest.raises(RuntimeError, match="tagged commit"), + ): + get_git_tag() + + +# ── find_manifest_path ───────────────────────────────────────────────────────── + +def test_find_manifest_path_finds_canonical(tmp_path: Path) -> None: + (tmp_path / CANONICAL_MANIFEST_FILE_NAME).write_text("[repo]\n", encoding="utf-8") + result = find_manifest_path(tmp_path) + assert result.name == CANONICAL_MANIFEST_FILE_NAME + + +def test_find_manifest_path_finds_alternate(tmp_path: Path) -> None: + (tmp_path / ALTERNATE_MANIFEST_FILE_NAME).write_text("[repo]\n", encoding="utf-8") + result = find_manifest_path(tmp_path) + assert result.name == ALTERNATE_MANIFEST_FILE_NAME + + +def test_find_manifest_path_prefers_canonical(tmp_path: Path) -> None: + (tmp_path / CANONICAL_MANIFEST_FILE_NAME).write_text("[repo]\n", encoding="utf-8") + (tmp_path / ALTERNATE_MANIFEST_FILE_NAME).write_text("[repo]\n", encoding="utf-8") + result = find_manifest_path(tmp_path) + assert result.name == CANONICAL_MANIFEST_FILE_NAME + + +def test_find_manifest_path_raises_when_none(tmp_path: Path) -> None: + with pytest.raises(FileNotFoundError, match="No supported manifest"): + find_manifest_path(tmp_path) + + +def test_find_manifest_path_defaults_to_cwd(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + (tmp_path / CANONICAL_MANIFEST_FILE_NAME).write_text("[repo]\n", encoding="utf-8") + monkeypatch.chdir(tmp_path) + result = find_manifest_path() + assert result.name == CANONICAL_MANIFEST_FILE_NAME + + +# ── load_manifest ────────────────────────────────────────────────────────────── + +def test_load_manifest_finds_canonical_in_cwd(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + (tmp_path / CANONICAL_MANIFEST_FILE_NAME).write_text( + '[repo]\nname = "x"\n', encoding="utf-8" + ) + monkeypatch.chdir(tmp_path) + data = load_manifest() + assert data["repo"]["name"] == "x" + + +# ── packaged_schema_text ─────────────────────────────────────────────────────── + +def test_packaged_schema_text_returns_nonempty_string() -> None: + # packaged_schema_text() reads from the installed wheel artifact; in an editable + # (source-checkout) install the bundled copy may not be present. Skip gracefully. + try: + text = packaged_schema_text() + except FileNotFoundError: + import pytest + + pytest.skip("manifest-schema.toml not bundled in editable install") + assert isinstance(text, str) + assert len(text) > 0 + + +# ── repo_root_schema_path ────────────────────────────────────────────────────── + +def test_repo_root_schema_path_returns_none_in_tmp(tmp_path: Path) -> None: + result = repo_root_schema_path(tmp_path) + assert result is None + + +def test_repo_root_schema_path_finds_schema_in_repo() -> None: + # Run from actual repo root (the test itself runs inside the repo) + repo_root = Path(__file__).parent.parent + result = repo_root_schema_path(repo_root) + assert result is not None + assert result.name == "manifest-schema.toml" diff --git a/tests/test_validate_manifest.py b/tests/test_validate_manifest.py index 2e4aeda..b84c902 100644 --- a/tests/test_validate_manifest.py +++ b/tests/test_validate_manifest.py @@ -399,3 +399,90 @@ def _domain_contract_manifest() -> dict[str, Any]: "consumes_contract_from": "accountable-record", }, } + + +# ── additional branch coverage ───────────────────────────────────────────────── + +def test_missing_repo_class_string_detected() -> None: + manifest = _minimal_manifest() + manifest["repo"] = {"name": "se-test"} # class missing entirely + errors = validate_manifest(manifest, _minimal_schema()) + assert any("[repo].class" in e for e in errors) + + +def test_missing_repo_name_string_detected() -> None: + manifest = _minimal_manifest() + manifest["repo"] = {"class": "test_class"} # name missing + errors = validate_manifest(manifest, _minimal_schema()) + assert any("[repo].name" in e for e in errors) + + +def test_required_field_missing_in_section_detected() -> None: + schema = cast( + ManifestSchemaData, + { + **_minimal_schema(), + "field": { + "repo": { + "name": {"type": "string", "required": True}, + "class": {"type": "string", "required": True}, + }, + "scope": { + "includes": {"type": "list[string]", "required": True}, + "excludes": {"type": "list[string]", "required": True}, + }, + }, + }, + ) + manifest = _minimal_manifest() + del manifest["scope"]["includes"] + errors = validate_manifest(manifest, schema) + assert any("includes" in e and "required field missing" in e for e in errors) + + +def test_section_with_no_definition_is_skipped_without_require_known() -> None: + schema = cast( + ManifestSchemaData, + { + **_minimal_schema(), + "validation": { + "require_known_sections_only": False, + "require_known_fields_only": False, + }, + }, + ) + manifest = _minimal_manifest() + manifest["extra_section"] = {"key": "value"} + errors = validate_manifest(manifest, schema) + # extra_section has no definition, so it should be silently skipped + assert all("extra_section" not in e for e in errors) + + +def test_contract_section_non_dict_skips_contract_rules() -> None: + manifest = _authority_manifest() + manifest["contract"] = "not-a-dict" + # Replace schema so contract section is optional (won't fail on required section) + schema = cast( + ManifestSchemaData, + { + **_contract_schema(), + "class": { + "contract": { + "required_sections": ["repo"], + "optional_sections": ["contract"], + "forbidden_sections": [], + } + }, + }, + ) + errors = validate_manifest(manifest, schema) + # contract_role rules should be skipped; no contract_role error expected + assert not any("contract_role" in e for e in errors) + + +def test_field_constraint_unknown_role_detected() -> None: + manifest = _authority_manifest() + manifest["contract"]["contract_role"] = "unknown-role" + errors = validate_manifest(manifest, _contract_schema()) + assert any("allowed contract roles" in e for e in errors) + diff --git a/tests/test_validate_role_capability_map.py b/tests/test_validate_role_capability_map.py new file mode 100644 index 0000000..0a9a494 --- /dev/null +++ b/tests/test_validate_role_capability_map.py @@ -0,0 +1,290 @@ +"""Tests for validation/validate_role_capability_map.py.""" + +from pathlib import Path +from typing import Any + +from se_manifest_schema.validation.validate_role_capability_map import ( + validate_role_capability_map_data, + validate_role_capability_map_file, +) + + +# ── helpers ──────────────────────────────────────────────────────────────────── + +def _minimal_valid_data() -> dict[str, Any]: + """Return a minimal valid role-capability-map data dict.""" + return { + "schema": { + "schema_id": "se-role-capability-map-1", + "version": "0.1.0", + "status": "draft", + "title": "SE Role Capability Map", + "description": "Maps manifest classes to role groups.", + }, + "export": { + "path": "data/schema/role-capability-map.toml", + "schema_path": "data/schema/role-capability-map.schema.toml", + }, + "consumers": { + "graph_verifier": "se-manifest verify-graph", + "stage_verifier": "accountable-record", + "future_rust_verifier": "accountable-record-rs", + }, + "role_groups": { + "core": "core_group", + "contract": "contract_group", + }, + "capability_profiles": { + "core_group": { + "validates_target_materials": False, + "validates_resolution": False, + "requires_lock_artifact": False, + "exports_contract_artifacts": False, + "emits_human_reports": False, + }, + "contract_group": { + "validates_target_materials": True, + "validates_resolution": True, + "requires_lock_artifact": False, + "exports_contract_artifacts": True, + "emits_human_reports": True, + }, + }, + "graph_permissions": { + "semantic_edge_kinds": ["semantic"], + "resolution_checked_edge_kinds": ["semantic"], + "entry_point_role_groups": ["core_group"], + "no_core_depends_on_domain": { + "diagnostic": "SE.ORG.LAYER_VIOLATION", + "source_role_groups": ["core_group"], + "forbidden_target_role_groups": ["contract_group"], + }, + "no_interpretation_leak": { + "diagnostic": "SE.ORG.INTERPRETATION_LEAK", + "source_role_groups": ["core_group"], + "forbidden_target_role_groups": ["contract_group"], + }, + "no_contract_depends_on_implementation": { + "diagnostic": "SE.ORG.CONTRACT_IMPL_DEP", + "source_role_groups": ["contract_group"], + "forbidden_target_role_groups": ["core_group"], + }, + "no_theory_bypass": { + "diagnostic": "SE.ORG.THEORY_BYPASS", + "source_role_groups": ["core_group"], + "forbidden_target_role_groups": ["contract_group"], + "allowed_intermediate_role_groups": ["contract_group"], + }, + "layer_monotonicity": { + "diagnostic": "SE.ORG.LAYER_MONOTONICITY", + "uses_layer_order": True, + }, + }, + "layer_order": { + "core_group": 1, + "contract_group": 2, + }, + "surface_buckets": { + "required": ["types", "predicates", "axioms", "theorems", "witnesses"], + "constant_names": [ + "SURFACE_TYPES", + "SURFACE_PREDICATES", + "SURFACE_AXIOMS", + "SURFACE_THEOREMS", + "SURFACE_WITNESSES", + ], + }, + } + + +# ── validate_role_capability_map_file ───────────────────────────────────────── + +def test_file_not_found_returns_error(tmp_path: Path) -> None: + path = tmp_path / "missing.toml" + errors = validate_role_capability_map_file(path) + assert any("does not exist" in e for e in errors) + + +def test_file_valid_returns_no_errors(tmp_path: Path) -> None: + import tomllib, io + + # Write valid TOML from the data dict + data = _minimal_valid_data() + # Build a simple TOML string manually for only the top-level keys + # Use the actual project file instead + project_root = Path(__file__).parent.parent + real_path = project_root / "data" / "schema" / "role-capability-map.toml" + if real_path.exists(): + errors = validate_role_capability_map_file(real_path) + assert errors == [], "\n".join(errors) + + +# ── validate_role_capability_map_data ───────────────────────────────────────── + +def test_valid_data_returns_no_errors() -> None: + errors = validate_role_capability_map_data(_minimal_valid_data()) + assert errors == [], "\n".join(errors) + + +def test_missing_top_level_section_detected() -> None: + data = _minimal_valid_data() + del data["schema"] + errors = validate_role_capability_map_data(data) + assert any("schema" in e for e in errors) + + +def test_multiple_missing_sections_all_reported() -> None: + # Removing two required sections should produce two distinct error messages. + data = _minimal_valid_data() + del data["schema"] + del data["export"] + errors = validate_role_capability_map_data(data) + assert any("schema" in e for e in errors) + assert any("export" in e for e in errors) + + +def test_missing_schema_field_detected() -> None: + data = _minimal_valid_data() + del data["schema"]["title"] + errors = validate_role_capability_map_data(data) + assert any("schema.title" in e for e in errors) + + +def test_empty_schema_field_detected() -> None: + data = _minimal_valid_data() + data["schema"]["title"] = "" + errors = validate_role_capability_map_data(data) + assert any("schema.title" in e for e in errors) + + +def test_missing_export_field_detected() -> None: + data = _minimal_valid_data() + del data["export"]["path"] + errors = validate_role_capability_map_data(data) + assert any("export.path" in e for e in errors) + + +def test_missing_consumer_field_detected() -> None: + data = _minimal_valid_data() + del data["consumers"]["graph_verifier"] + errors = validate_role_capability_map_data(data) + assert any("consumers.graph_verifier" in e for e in errors) + + +def test_empty_role_groups_detected() -> None: + data = _minimal_valid_data() + data["role_groups"] = {} + errors = validate_role_capability_map_data(data) + assert any("role_groups" in e for e in errors) + + +def test_non_string_role_group_value_detected() -> None: + data = _minimal_valid_data() + data["role_groups"]["core"] = 42 + errors = validate_role_capability_map_data(data) + assert any("role_groups.core" in e for e in errors) + + +def test_missing_capability_profile_detected() -> None: + data = _minimal_valid_data() + del data["capability_profiles"]["core_group"] + errors = validate_role_capability_map_data(data) + assert any("capability_profiles.core_group" in e for e in errors) + + +def test_non_bool_capability_field_detected() -> None: + data = _minimal_valid_data() + data["capability_profiles"]["core_group"]["validates_target_materials"] = "yes" + errors = validate_role_capability_map_data(data) + assert any("validates_target_materials" in e for e in errors) + + +def test_missing_graph_permission_list_field_detected() -> None: + data = _minimal_valid_data() + del data["graph_permissions"]["semantic_edge_kinds"] + errors = validate_role_capability_map_data(data) + assert any("semantic_edge_kinds" in e for e in errors) + + +def test_non_list_graph_permission_field_detected() -> None: + data = _minimal_valid_data() + data["graph_permissions"]["entry_point_role_groups"] = "not-a-list" + errors = validate_role_capability_map_data(data) + assert any("entry_point_role_groups" in e for e in errors) + + +def test_missing_graph_rule_detected() -> None: + data = _minimal_valid_data() + del data["graph_permissions"]["no_core_depends_on_domain"] + errors = validate_role_capability_map_data(data) + assert any("no_core_depends_on_domain" in e for e in errors) + + +def test_graph_rule_missing_diagnostic_detected() -> None: + data = _minimal_valid_data() + del data["graph_permissions"]["no_core_depends_on_domain"]["diagnostic"] + errors = validate_role_capability_map_data(data) + assert any("no_core_depends_on_domain.diagnostic" in e for e in errors) + + +def test_graph_rule_diagnostic_must_start_with_se_org() -> None: + data = _minimal_valid_data() + data["graph_permissions"]["no_core_depends_on_domain"]["diagnostic"] = "WRONG.CODE" + errors = validate_role_capability_map_data(data) + assert any("no_core_depends_on_domain.diagnostic" in e for e in errors) + + +def test_forbidden_edge_rule_source_groups_detected() -> None: + data = _minimal_valid_data() + data["graph_permissions"]["no_core_depends_on_domain"]["source_role_groups"] = "bad" + errors = validate_role_capability_map_data(data) + assert any("source_role_groups" in e for e in errors) + + +def test_forbidden_edge_rule_target_groups_detected() -> None: + data = _minimal_valid_data() + data["graph_permissions"]["no_core_depends_on_domain"]["forbidden_target_role_groups"] = "bad" + errors = validate_role_capability_map_data(data) + assert any("forbidden_target_role_groups" in e for e in errors) + + +def test_theory_bypass_allowed_intermediates_detected() -> None: + data = _minimal_valid_data() + data["graph_permissions"]["no_theory_bypass"]["allowed_intermediate_role_groups"] = "bad" + errors = validate_role_capability_map_data(data) + assert any("allowed_intermediate_role_groups" in e for e in errors) + + +def test_layer_monotonicity_uses_layer_order_must_be_true() -> None: + data = _minimal_valid_data() + data["graph_permissions"]["layer_monotonicity"]["uses_layer_order"] = False + errors = validate_role_capability_map_data(data) + assert any("uses_layer_order" in e for e in errors) + + +def test_missing_layer_order_entry_detected() -> None: + data = _minimal_valid_data() + del data["layer_order"]["core_group"] + errors = validate_role_capability_map_data(data) + assert any("layer_order.core_group" in e for e in errors) + + +def test_non_int_layer_order_detected() -> None: + data = _minimal_valid_data() + data["layer_order"]["core_group"] = "first" + errors = validate_role_capability_map_data(data) + assert any("layer_order.core_group" in e for e in errors) + + +def test_wrong_surface_buckets_required_detected() -> None: + data = _minimal_valid_data() + data["surface_buckets"]["required"] = ["types", "predicates"] + errors = validate_role_capability_map_data(data) + assert any("surface_buckets.required" in e for e in errors) + + +def test_wrong_surface_buckets_constant_names_detected() -> None: + data = _minimal_valid_data() + data["surface_buckets"]["constant_names"] = ["WRONG"] + errors = validate_role_capability_map_data(data) + assert any("surface_buckets.constant_names" in e for e in errors) diff --git a/tests/test_validate_schema.py b/tests/test_validate_schema.py index 879ab80..4ddd0b5 100644 --- a/tests/test_validate_schema.py +++ b/tests/test_validate_schema.py @@ -270,3 +270,237 @@ def test_contract_roles_registry_must_include_domain_contract() -> None: ) errors = validate_schema_internal(schema) assert any("domain-contract" in error for error in errors) + + +# ── additional branch coverage ───────────────────────────────────────────────── + +def test_allowed_manifest_filenames_with_empty_string_item_detected() -> None: + schema = cast( + ManifestSchemaData, + { + "section": {}, + "field": {}, + "class": {}, + "manifest": { + "filename": "SE_MANIFEST.toml", + "allowed_filenames": ["SE_MANIFEST.toml", "MANIFEST.toml", ""], + }, + "validation": {"require_manifest_filename_allowed": True}, + }, + ) + errors = validate_schema_internal(schema) + assert any("nonempty strings" in error for error in errors) + + +def test_contract_roles_with_empty_string_role_detected() -> None: + schema = cast( + ManifestSchemaData, + { + "section": {}, + "field": { + "contract": { + "contract_role": { + "type": "string", + "required": False, + "constraints": ["known-contract-role"], + } + } + }, + "class": {}, + "contract_roles": {"allowed": ["authority", "domain-contract", ""]}, + "manifest": { + "filename": "SE_MANIFEST.toml", + "allowed_filenames": ["SE_MANIFEST.toml", "MANIFEST.toml"], + }, + "validation": {"require_manifest_filename_allowed": True}, + }, + ) + errors = validate_schema_internal(schema) + assert any("nonempty strings" in error for error in errors) + + +def test_custom_type_missing_definition_detected() -> None: + schema = cast( + ManifestSchemaData, + { + "section": {}, + "field": {}, + "class": {}, + "custom_types": {"allowed": ["dependency"]}, + "manifest": { + "filename": "SE_MANIFEST.toml", + "allowed_filenames": ["SE_MANIFEST.toml", "MANIFEST.toml"], + }, + "validation": {"require_manifest_filename_allowed": True}, + }, + ) + errors = validate_schema_internal(schema) + assert any("custom_types.allowed" in error for error in errors) + + +def test_custom_type_kind_must_be_record() -> None: + schema = cast( + ManifestSchemaData, + { + "section": {}, + "field": {}, + "class": {}, + "custom_types": {"allowed": ["dependency"]}, + "custom_type": { + "dependency": { + "kind": "enum", # wrong kind + "fields": "dependency_fields", + } + }, + "dependency_fields": {"allowed_fields": ["repo", "version"]}, + "manifest": { + "filename": "SE_MANIFEST.toml", + "allowed_filenames": ["SE_MANIFEST.toml", "MANIFEST.toml"], + }, + "validation": {"require_manifest_filename_allowed": True}, + }, + ) + errors = validate_schema_internal(schema) + assert any("kind" in error for error in errors) + + +def test_custom_type_fields_registry_missing_detected() -> None: + schema = cast( + ManifestSchemaData, + { + "section": {}, + "field": {}, + "class": {}, + "custom_types": {"allowed": ["dependency"]}, + "custom_type": { + "dependency": { + "kind": "record", + "fields": "nonexistent_registry", + } + }, + "manifest": { + "filename": "SE_MANIFEST.toml", + "allowed_filenames": ["SE_MANIFEST.toml", "MANIFEST.toml"], + }, + "validation": {"require_manifest_filename_allowed": True}, + }, + ) + errors = validate_schema_internal(schema) + assert any("unknown field registry" in error for error in errors) + + +def test_custom_type_fields_missing_allowed_fields_detected() -> None: + schema = cast( + ManifestSchemaData, + { + "section": {}, + "field": {}, + "class": {}, + "custom_types": {"allowed": ["dependency"]}, + "custom_type": { + "dependency": { + "kind": "record", + "fields": "dependency_fields", + } + }, + "dependency_fields": {}, # missing allowed_fields + "manifest": { + "filename": "SE_MANIFEST.toml", + "allowed_filenames": ["SE_MANIFEST.toml", "MANIFEST.toml"], + }, + "validation": {"require_manifest_filename_allowed": True}, + }, + ) + errors = validate_schema_internal(schema) + assert any("allowed_fields" in error for error in errors) + + +def test_custom_type_with_missing_fields_key_detected() -> None: + schema = cast( + ManifestSchemaData, + { + "section": {}, + "field": {}, + "class": {}, + "custom_types": {"allowed": ["dependency"]}, + "custom_type": { + "dependency": { + "kind": "record", + # "fields" key missing entirely + } + }, + "manifest": { + "filename": "SE_MANIFEST.toml", + "allowed_filenames": ["SE_MANIFEST.toml", "MANIFEST.toml"], + }, + "validation": {"require_manifest_filename_allowed": True}, + }, + ) + errors = validate_schema_internal(schema) + assert any("fields" in error for error in errors) + + +def test_is_known_type_list_primitive() -> None: + from se_manifest_schema.validate_schema import is_known_type + + assert is_known_type("list[string]", set()) + assert is_known_type("list[integer]", set()) + assert is_known_type("list[boolean]", set()) + + +def test_is_known_type_map_primitive() -> None: + from se_manifest_schema.validate_schema import is_known_type + + assert is_known_type("map[string]", set()) + + +def test_is_known_type_custom() -> None: + from se_manifest_schema.validate_schema import is_known_type + + assert is_known_type("dependency", {"dependency"}) + assert not is_known_type("unknown", set()) + + +def test_is_known_type_list_custom() -> None: + from se_manifest_schema.validate_schema import is_known_type + + assert is_known_type("list[dependency]", {"dependency"}) + + +def test_iter_field_definitions_collects_typed_nodes() -> None: + from se_manifest_schema.validate_schema import iter_field_definitions + + fields = { + "repo": { + "name": {"type": "string", "required": True}, + "class": {"type": "string", "required": True}, + } + } + results = iter_field_definitions(fields) + paths = {path for path, _ in results} + assert "repo.name" in paths + assert "repo.class" in paths + + +def test_class_with_optional_and_forbidden_sections_unknown_detected() -> None: + schema = cast( + ManifestSchemaData, + { + "section": {"repo": {"allowed_fields": []}}, + "field": {}, + "class": { + "myclass": { + "optional_sections": ["unknown-opt"], + "forbidden_sections": ["unknown-forb"], + } + }, + "manifest": { + "filename": "SE_MANIFEST.toml", + "allowed_filenames": ["SE_MANIFEST.toml", "MANIFEST.toml"], + }, + "validation": {"require_manifest_filename_allowed": True}, + }, + ) + errors = validate_schema_internal(schema) + assert any("unknown-opt" in error for error in errors) + assert any("unknown-forb" in error for error in errors) diff --git a/tests/test_verify_graph.py b/tests/test_verify_graph.py new file mode 100644 index 0000000..3e50d64 --- /dev/null +++ b/tests/test_verify_graph.py @@ -0,0 +1,254 @@ +"""Tests for commands/verify_graph.py - graph verification command.""" + +from pathlib import Path +from typing import Any +from unittest.mock import patch + +import pytest + +from se_manifest_schema.commands.verify_graph import ( + _find_schema_repo, + _looks_like_schema_repo, + _resolve_path, + _resolve_report_path, + _resolve_root, + _resolve_schema_path, + run, +) + + +# ── _looks_like_schema_repo ──────────────────────────────────────────────────── + +def test_looks_like_schema_repo_true(tmp_path: Path) -> None: + repo = tmp_path / "se-manifest-schema" + repo.mkdir() + (repo / "manifest-schema.toml").write_text("[schema]\n", encoding="utf-8") + (repo / "SE_MANIFEST.toml").write_text("[repo]\n", encoding="utf-8") + assert _looks_like_schema_repo(repo) is True + + +def test_looks_like_schema_repo_wrong_name(tmp_path: Path) -> None: + repo = tmp_path / "other-repo" + repo.mkdir() + (repo / "manifest-schema.toml").write_text("[schema]\n", encoding="utf-8") + (repo / "SE_MANIFEST.toml").write_text("[repo]\n", encoding="utf-8") + assert _looks_like_schema_repo(repo) is False + + +def test_looks_like_schema_repo_missing_schema_file(tmp_path: Path) -> None: + repo = tmp_path / "se-manifest-schema" + repo.mkdir() + (repo / "SE_MANIFEST.toml").write_text("[repo]\n", encoding="utf-8") + assert _looks_like_schema_repo(repo) is False + + +def test_looks_like_schema_repo_missing_manifest(tmp_path: Path) -> None: + repo = tmp_path / "se-manifest-schema" + repo.mkdir() + (repo / "manifest-schema.toml").write_text("[schema]\n", encoding="utf-8") + assert _looks_like_schema_repo(repo) is False + + +# ── _find_schema_repo ────────────────────────────────────────────────────────── + +def test_find_schema_repo_finds_ancestor(tmp_path: Path) -> None: + schema_repo = tmp_path / "se-manifest-schema" + schema_repo.mkdir() + (schema_repo / "manifest-schema.toml").write_text("[schema]\n", encoding="utf-8") + (schema_repo / "SE_MANIFEST.toml").write_text("[repo]\n", encoding="utf-8") + + nested = schema_repo / "nested" / "subdir" + nested.mkdir(parents=True) + + result = _find_schema_repo(nested) + assert result == schema_repo.resolve() + + +def test_find_schema_repo_finds_child(tmp_path: Path) -> None: + schema_repo = tmp_path / "se-manifest-schema" + schema_repo.mkdir() + (schema_repo / "manifest-schema.toml").write_text("[schema]\n", encoding="utf-8") + (schema_repo / "SE_MANIFEST.toml").write_text("[repo]\n", encoding="utf-8") + + result = _find_schema_repo(tmp_path) + assert result == schema_repo.resolve() + + +def test_find_schema_repo_falls_back_to_working_dir(tmp_path: Path) -> None: + result = _find_schema_repo(tmp_path) + assert result == tmp_path.resolve() + + +# ── _resolve_path ────────────────────────────────────────────────────────────── + +def test_resolve_path_absolute(tmp_path: Path) -> None: + absolute = tmp_path / "file.toml" + absolute.touch() + result = _resolve_path(absolute, working_dir=tmp_path, schema_repo=tmp_path) + assert result == absolute.resolve() + + +def test_resolve_path_relative_to_working_dir(tmp_path: Path) -> None: + file = tmp_path / "file.toml" + file.touch() + result = _resolve_path(Path("file.toml"), working_dir=tmp_path, schema_repo=tmp_path) + assert result == file.resolve() + + +def test_resolve_path_relative_to_schema_repo(tmp_path: Path) -> None: + schema_repo = tmp_path / "schema-repo" + schema_repo.mkdir() + file = schema_repo / "schema-file.toml" + file.touch() + + other = tmp_path / "other" + other.mkdir() + + result = _resolve_path( + Path("schema-file.toml"), + working_dir=other, + schema_repo=schema_repo, + ) + assert result == file.resolve() + + +def test_resolve_path_fallback_to_working_dir_candidate(tmp_path: Path) -> None: + # Neither cwd nor schema_repo have the file; fallback to cwd candidate + result = _resolve_path( + Path("missing.toml"), + working_dir=tmp_path, + schema_repo=tmp_path, + ) + assert result == (tmp_path / "missing.toml").resolve() + + +# ── _resolve_root ────────────────────────────────────────────────────────────── + +def test_resolve_root_explicit(tmp_path: Path) -> None: + explicit = tmp_path / "custom-root" + explicit.mkdir() + schema_repo = tmp_path / "schema-repo" + + result = _resolve_root(root=explicit, working_dir=tmp_path, schema_repo=schema_repo) + assert result == explicit.resolve() + + +def test_resolve_root_defaults_to_parent_when_schema_repo(tmp_path: Path) -> None: + schema_repo = tmp_path / "se-manifest-schema" + schema_repo.mkdir() + (schema_repo / "manifest-schema.toml").write_text("", encoding="utf-8") + (schema_repo / "SE_MANIFEST.toml").write_text("", encoding="utf-8") + + result = _resolve_root(root=None, working_dir=schema_repo, schema_repo=schema_repo) + assert result == tmp_path.resolve() + + +def test_resolve_root_defaults_to_working_dir(tmp_path: Path) -> None: + working_dir = tmp_path / "some-other-dir" + working_dir.mkdir() + schema_repo = tmp_path / "unrelated" + schema_repo.mkdir() + + result = _resolve_root(root=None, working_dir=working_dir, schema_repo=schema_repo) + assert result == working_dir.resolve() + + +# ── _resolve_schema_path ──────────────────────────────────────────────────────── + +def test_resolve_schema_path_explicit(tmp_path: Path) -> None: + explicit = tmp_path / "my-schema.toml" + explicit.touch() + schema_repo = tmp_path + + result = _resolve_schema_path( + schema_path=explicit, working_dir=tmp_path, schema_repo=schema_repo + ) + assert result == explicit.resolve() + + +def test_resolve_schema_path_defaults_to_schema_repo(tmp_path: Path) -> None: + schema_repo = tmp_path / "se-manifest-schema" + schema_repo.mkdir() + + result = _resolve_schema_path( + schema_path=None, working_dir=tmp_path, schema_repo=schema_repo + ) + assert result == (schema_repo / "manifest-schema.toml").resolve() + + +# ── _resolve_report_path ──────────────────────────────────────────────────────── + +def test_resolve_report_path_explicit(tmp_path: Path) -> None: + explicit = tmp_path / "report.md" + explicit.touch() + schema_repo = tmp_path + + result = _resolve_report_path( + report_path=explicit, working_dir=tmp_path, schema_repo=schema_repo + ) + assert result == explicit.resolve() + + +def test_resolve_report_path_defaults_to_schema_repo(tmp_path: Path) -> None: + schema_repo = tmp_path / "se-manifest-schema" + schema_repo.mkdir() + + result = _resolve_report_path( + report_path=None, working_dir=tmp_path, schema_repo=schema_repo + ) + assert "org-graph-report.md" in str(result) + + +# ── run ──────────────────────────────────────────────────────────────────────── + +def test_run_passes_with_no_manifests(tmp_path: Path) -> None: + schema_path = tmp_path / "manifest-schema.toml" + schema_path.write_text("[class]\n", encoding="utf-8") + report_path = tmp_path / "report.md" + + result = run(root=tmp_path, schema_path=schema_path, report_path=report_path) + assert result == 0 + assert report_path.exists() + + +def test_run_fails_when_diagnostics(tmp_path: Path) -> None: + repo_dir = tmp_path / "repo-a" + repo_dir.mkdir() + manifest = repo_dir / "SE_MANIFEST.toml" + manifest.write_text( + """ +[repo] +name = "repo-a" +class = "core" +version = "0.1.0" +status = "active" + +[layer] +space = "theory" +role = "kernel" + +[depends] +required = ["ghost-repo"] +""", + encoding="utf-8", + ) + + schema_path = tmp_path / "manifest-schema.toml" + schema_path.write_text( + "[class.core]\nrequired_sections = [\"repo\"]\n", encoding="utf-8" + ) + report_path = tmp_path / "report.md" + + result = run(root=tmp_path, schema_path=schema_path, report_path=report_path) + assert result == 1 + + +def test_run_writes_report_file(tmp_path: Path) -> None: + schema_path = tmp_path / "manifest-schema.toml" + schema_path.write_text("[class]\n", encoding="utf-8") + report_path = tmp_path / "sub" / "report.md" + + run(root=tmp_path, schema_path=schema_path, report_path=report_path) + assert report_path.exists() + content = report_path.read_text(encoding="utf-8") + assert "Manifest Graph Report" in content