from pathlib import Path import pytest import yaml import semantica.context.context_graph as context_graph_module from semantica.context.context_graph import ContextEdge, ContextGraph, ContextNode def _read_markdown(path: Path): lines = path.read_text(encoding="utf-8").splitlines(keepends=True) closing_index = next( index for index, line in enumerate(lines[1:], start=1) if line.rstrip("\r\n") == "---" ) frontmatter = yaml.safe_load("".join(lines[1:closing_index])) or {} body = "".join(lines[closing_index + 1 :]) if body.startswith("\r\n"): body = body[2:] elif body.startswith("\n"): body = body[1:] return frontmatter, body def _write_markdown(path: Path, frontmatter, body: str) -> None: yaml_text = yaml.safe_dump( frontmatter, sort_keys=False, allow_unicode=True, default_flow_style=False, ) path.write_text(f"---\n{yaml_text}---\n\n{body}", encoding="utf-8") def _node_file(export_path: Path, node_id: str) -> Path: for path in (export_path / "nodes").glob("*.md"): frontmatter, _ = _read_markdown(path) if frontmatter.get("id") == node_id: return path raise AssertionError(f"No Markdown file found for node {node_id!r}") def _normalized_state(graph: ContextGraph): links = {link_id: dict(link) for link_id, link in graph._unresolved_links.items()} for link_id, ( other_graph, source_node_id, target_node_id, ) in graph._linked_graphs.items(): links[link_id] = { "link_id": link_id, "source_node_id": source_node_id, "target_node_id": target_node_id, "other_graph_id": other_graph.graph_id, } return { "graph_id": graph.graph_id, "nodes": { node_id: { "type": node.node_type, "content": node.content, "properties": node.properties, "metadata": node.metadata, "valid_from": node.valid_from, "valid_until": node.valid_until, } for node_id, node in graph.nodes.items() }, "edges": sorted( ( { "id": edge.edge_id, "family_id": edge.family_id, "source": edge.source_id, "target": edge.target_id, "type": edge.edge_type, "weight": edge.weight, "metadata": edge.metadata, "valid_from": edge.valid_from, "valid_until": edge.valid_until, } for edge in graph.edges ), key=lambda edge: edge["id"], ), "links": links, } def _sample_graph(): graph = ContextGraph(advanced_analytics=False) graph.graph_id = "graph-primary" graph._add_internal_node( ContextNode( node_id="policy/\u6771\u4eac", node_type="Policy", content="# Retention\n\nKeep evidence.\n---\n", properties={"priority": 2, "nested": {"owner": "governance"}}, metadata={"source": "manual", "tags": ["retention", "legal"]}, valid_from="2026-01-01T00:00:00+00:00", valid_until="2027-01-01T00:00:00+00:00", ) ) graph._add_internal_node( ContextNode( node_id="evidence-1", node_type="Evidence", content="Original source", properties={"checksum": "abc123"}, metadata={"classification": "internal"}, ) ) graph._add_internal_edge( ContextEdge( edge_id="edge-supports", family_id="family-supports", source_id="evidence-1", target_id="policy/\u6771\u4eac", edge_type="SUPPORTS", weight=0.75, metadata={"confidence": 0.9}, valid_from="2026-02-01", valid_until="2026-12-31", ) ) other = ContextGraph(advanced_analytics=False) other.graph_id = "graph-secondary" other.add_node("source-page", "Page", "Source page") link_id = graph.link_graph(other, "policy/\u6771\u4eac", "source-page") return graph, other, link_id def _directory_contents(path: Path): return { str(file_path.relative_to(path)): file_path.read_bytes() for file_path in path.rglob("*") if file_path.is_file() } def test_markdown_round_trip_preserves_complete_graph_state(tmp_path): graph, other, link_id = _sample_graph() export_path = tmp_path / "context-graph" graph.save_to_file(export_path, format="markdown") restored = ContextGraph(advanced_analytics=False) restored.load_from_file(export_path, format="markdown") assert _normalized_state(restored) == _normalized_state(graph) assert restored.resolve_links({other.graph_id: other}) == 1 linked_graph, entry_node = restored.navigate_to(link_id) assert linked_graph is other assert entry_node == "source-page" def test_markdown_manual_node_and_edge_edits_are_imported(tmp_path): graph, _, _ = _sample_graph() export_path = tmp_path / "context-graph" graph.save_to_file(export_path, format="markdown") node_path = _node_file(export_path, "policy/\u6771\u4eac") node_frontmatter, _ = _read_markdown(node_path) node_frontmatter["metadata"]["reviewed"] = True _write_markdown(node_path, node_frontmatter, "# Updated policy\n") manifest_path = export_path / "graph.md" manifest, manifest_body = _read_markdown(manifest_path) manifest["edges"][0]["type"] = "VERIFIES" manifest["edges"][0]["weight"] = 1.0 manifest["edges"][0]["metadata"]["reviewed_by"] = "human" _write_markdown(manifest_path, manifest, manifest_body) restored = ContextGraph(advanced_analytics=False) restored.load_from_file(export_path, format="markdown") node = restored.nodes["policy/\u6771\u4eac"] assert node.content == "# Updated policy\n" assert node.metadata["reviewed"] is True edge = restored.edges[0] assert edge.edge_type == "VERIFIES" assert edge.weight == 1.0 assert edge.metadata["reviewed_by"] == "human" def test_markdown_export_is_deterministic_and_removes_stale_nodes(tmp_path): graph = ContextGraph(advanced_analytics=False) graph.graph_id = "deterministic-graph" graph.add_node("kept", "Note", "Keep") graph.add_node("removed", "Note", "Remove") graph.add_edge("kept", "removed", "REFERENCES") first = tmp_path / "first" second = tmp_path / "second" graph.save_to_file(first, format="markdown") graph.save_to_file(second, format="markdown") assert _directory_contents(first) == _directory_contents(second) stale_path = _node_file(first, "removed") graph.nodes.pop("removed") graph.edges.clear() graph.save_to_file(first, format="markdown") assert not stale_path.exists() restored = ContextGraph(advanced_analytics=False) restored.load_from_file(first, format="markdown") assert set(restored.nodes) == {"kept"} assert restored.edges == [] def test_markdown_empty_graph_round_trip(tmp_path): graph = ContextGraph(advanced_analytics=False) graph.graph_id = "empty-graph" export_path = tmp_path / "empty" graph.save_to_file(export_path, format="markdown") restored = ContextGraph(advanced_analytics=False) restored.load_from_file(export_path, format="markdown") assert restored.graph_id == "empty-graph" assert restored.nodes == {} assert restored.edges == [] def test_markdown_export_rejects_duplicate_edge_ids_before_writing(tmp_path): graph = ContextGraph(advanced_analytics=False) graph.add_node("source", "Note", "Source") graph.add_node("target", "Note", "Target") graph.add_edge("source", "target", "REFERENCES") graph.add_edge("source", "target", "REFERENCES") export_path = tmp_path / "graph" assert graph.edges[0].edge_id == graph.edges[1].edge_id with pytest.raises(ValueError, match=r"duplicate edge ID.*[0-9a-f-]+"): graph.save_to_file(export_path, format="markdown") assert not export_path.exists() @pytest.mark.parametrize( "corruption, expected_error", [ ("unsupported-version", "Unsupported ContextGraph Markdown version"), ("duplicate-edge", "Duplicate Markdown edge ID"), ("dangling-edge", "do not have node files"), ("duplicate-node", "Duplicate Markdown node ID"), ("cyclic-skos", "SKOS hierarchy contains a cycle"), ], ) def test_invalid_markdown_does_not_mutate_existing_graph( tmp_path, corruption, expected_error ): source, _, _ = _sample_graph() export_path = tmp_path / corruption source.save_to_file(export_path, format="markdown") manifest_path = export_path / "graph.md" manifest, manifest_body = _read_markdown(manifest_path) if corruption == "unsupported-version": manifest["version"] = 2 elif corruption == "duplicate-edge": manifest["edges"].append(dict(manifest["edges"][0])) elif corruption == "dangling-edge": manifest["edges"][0]["target"] = "missing-node" elif corruption == "duplicate-node": original = _node_file(export_path, "evidence-1") (original.parent / "duplicate.md").write_bytes(original.read_bytes()) elif corruption == "cyclic-skos": manifest["edges"] = [ { "id": "broader-1", "family_id": "broader-1", "source": "evidence-1", "target": "policy/\u6771\u4eac", "type": "skos:broader", "weight": 1.0, "metadata": {}, }, { "id": "broader-2", "family_id": "broader-2", "source": "policy/\u6771\u4eac", "target": "evidence-1", "type": "skos:broader", "weight": 1.0, "metadata": {}, }, ] _write_markdown(manifest_path, manifest, manifest_body) target = ContextGraph(advanced_analytics=False) target.add_node("sentinel", "Existing", "Do not replace") before = _normalized_state(target) with pytest.raises(ValueError, match=expected_error): target.load_from_file(export_path, format="markdown") assert _normalized_state(target) == before @pytest.mark.parametrize( ("location", "field_name"), [("node", "valid_from"), ("edge", "valid_until")], ) def test_invalid_markdown_temporal_value_does_not_mutate_existing_graph( tmp_path, location, field_name ): source, _, _ = _sample_graph() export_path = tmp_path / location source.save_to_file(export_path, format="markdown") if location == "node": document_path = _node_file(export_path, "policy/\u6771\u4eac") else: document_path = export_path / "graph.md" frontmatter, body = _read_markdown(document_path) if location == "node": frontmatter[field_name] = "not-a-date" else: frontmatter["edges"][0][field_name] = "not-a-date" _write_markdown(document_path, frontmatter, body) target = ContextGraph(advanced_analytics=False) target.add_node("sentinel", "Existing", "Do not replace") before = _normalized_state(target) with pytest.raises(ValueError, match=rf"'{field_name}'.*valid ISO-8601"): target.load_from_file(export_path, format="markdown") assert _normalized_state(target) == before def test_duplicate_yaml_keys_are_rejected(tmp_path): graph = ContextGraph(advanced_analytics=False) graph.add_node("node-1", "Note", "Body") export_path = tmp_path / "graph" graph.save_to_file(export_path, format="markdown") node_path = _node_file(export_path, "node-1") document = node_path.read_text(encoding="utf-8") node_path.write_text(document.replace("id: node-1", "id: node-1\nid: duplicate")) with pytest.raises(ValueError, match="duplicate key 'id'"): ContextGraph(advanced_analytics=False).load_from_file( export_path, format="markdown" ) def test_markdown_export_refuses_unmanaged_nonempty_directory(tmp_path): destination = tmp_path / "existing" destination.mkdir() marker = destination / "keep.txt" marker.write_text("keep", encoding="utf-8") with pytest.raises(ValueError, match="not a managed ContextGraph export"): ContextGraph(advanced_analytics=False).save_to_file( destination, format="markdown" ) assert marker.read_text(encoding="utf-8") == "keep" def test_markdown_export_rejects_unrelated_graph_markdown_file(tmp_path): destination = tmp_path / "existing" destination.mkdir() unrelated = destination / "graph.md" _write_markdown(unrelated, {"title": "Unrelated notes"}, "Keep me") with pytest.raises(ValueError, match="not a managed ContextGraph export"): ContextGraph(advanced_analytics=False).save_to_file( destination, format="markdown" ) assert unrelated.exists() def test_markdown_export_preserves_manifest_inspection_errors(tmp_path, monkeypatch): graph = ContextGraph(advanced_analytics=False) destination = tmp_path / "existing" graph.save_to_file(destination, format="markdown") real_reader = ContextGraph._read_markdown_file def fail_manifest_read(path): if path == destination / "graph.md": raise PermissionError("permission denied") return real_reader(path) monkeypatch.setattr( ContextGraph, "_read_markdown_file", staticmethod(fail_manifest_read) ) with pytest.raises(PermissionError, match="permission denied"): graph.save_to_file(destination, format="markdown") def test_markdown_export_restores_previous_directory_when_publish_fails( tmp_path, monkeypatch ): graph = ContextGraph(advanced_analytics=False) graph.add_node("original", "Note", "Original") destination = tmp_path / "graph" graph.save_to_file(destination, format="markdown") original_contents = _directory_contents(destination) graph.add_node("new", "Note", "New") real_replace = context_graph_module.os.replace def fail_staged_publish(source, target): if ".staging-" in Path(source).name and Path(target) == destination: raise OSError("simulated publish failure") return real_replace(source, target) monkeypatch.setattr(context_graph_module.os, "replace", fail_staged_publish) with pytest.raises(OSError, match="simulated publish failure"): graph.save_to_file(destination, format="markdown") assert _directory_contents(destination) == original_contents assert not list(tmp_path.glob(".graph.staging-*")) assert not list(tmp_path.glob(".graph.backup-*")) def test_markdown_load_rebuilds_indexes_and_emits_one_reload_event(tmp_path): source, _, _ = _sample_graph() destination = tmp_path / "graph" source.save_to_file(destination, format="markdown") events = [] target = ContextGraph( advanced_analytics=False, mutation_callback=lambda *event: events.append(event), ) target.load_from_file(destination, format="markdown") assert target.node_type_index["Policy"] == {"policy/\u6771\u4eac"} assert target.edge_type_index["SUPPORTS"][0].edge_id == "edge-supports" assert target._adjacency["evidence-1"][0].target_id == "policy/\u6771\u4eac" assert events == [ ( "RELOAD_GRAPH", "graph-primary", {"node_count": len(target.nodes), "edge_count": len(target.edges)}, ) ] def test_markdown_export_rejects_recursive_metadata(tmp_path): recursive = {} recursive["self"] = recursive graph = ContextGraph(advanced_analytics=False) graph._add_internal_node(ContextNode("node-1", "Note", "Body", metadata=recursive)) with pytest.raises(ValueError, match="values cannot contain cycles"): graph.save_to_file(tmp_path / "graph", format="markdown") def test_markdown_import_and_export_reject_symlinks(tmp_path): graph = ContextGraph(advanced_analytics=False) graph.add_node("node-1", "Note", "Body") real_export = tmp_path / "real" graph.save_to_file(real_export, format="markdown") directory_link = tmp_path / "directory-link" try: directory_link.symlink_to(real_export, target_is_directory=True) except (NotImplementedError, OSError): pytest.skip("Symbolic links are not available on this platform") with pytest.raises(ValueError, match="symbolic link"): ContextGraph(advanced_analytics=False).load_from_file( directory_link, format="markdown" ) with pytest.raises(ValueError, match="symbolic link"): graph.save_to_file(directory_link, format="markdown") manifest_path = real_export / "graph.md" manifest_target = tmp_path / "manifest.md" manifest_target.write_bytes(manifest_path.read_bytes()) manifest_path.unlink() manifest_path.symlink_to(manifest_target) with pytest.raises(ValueError, match="symbolic link"): ContextGraph(advanced_analytics=False).load_from_file( real_export, format="markdown" ) @pytest.mark.skipif( not hasattr(context_graph_module.os, "O_NOFOLLOW"), reason="O_NOFOLLOW is unavailable on this platform", ) def test_markdown_import_nofollow_check_closes_symlink_race(tmp_path, monkeypatch): graph = ContextGraph(advanced_analytics=False) graph.add_node("node-1", "Note", "Body") export_path = tmp_path / "graph" graph.save_to_file(export_path, format="markdown") node_path = _node_file(export_path, "node-1") target = tmp_path / "target.md" target.write_bytes(node_path.read_bytes()) node_path.unlink() node_path.symlink_to(target) real_is_symlink = Path.is_symlink def miss_precheck(path): if path == node_path: return False return real_is_symlink(path) monkeypatch.setattr(Path, "is_symlink", miss_precheck) with pytest.raises(ValueError, match="symbolic link"): ContextGraph(advanced_analytics=False).load_from_file( export_path, format="markdown" ) def test_json_remains_default_and_unknown_format_is_rejected(tmp_path): graph = ContextGraph(advanced_analytics=False) graph.add_node("node-1", "Note", "Body") json_path = tmp_path / "graph.json" graph.save_to_file(json_path) restored = ContextGraph(advanced_analytics=False) restored.load_from_file(json_path) assert "node-1" in restored.nodes with pytest.raises(ValueError, match="Unsupported context graph"): graph.save_to_file(tmp_path / "graph", format="html")