From b7af18a70a50f017c24dd4fd075aa911ed6fa896 Mon Sep 17 00:00:00 2001 From: Saurabh Meena <127095776+SaurabhScripts@users.noreply.github.com> Date: Fri, 21 Aug 2026 18:35:14 +0530 Subject: [PATCH] fix(context): address Markdown round-trip review --- CHANGELOG.md | 6 + docs/guides/context-graphs.md | 14 +- semantica/context/_markdown_filesystem.py | 32 ++ semantica/context/context_graph.py | 309 +++++++++++++------ tests/context/test_context_graph_markdown.py | 166 +++++++++- 5 files changed, 413 insertions(+), 114 deletions(-) create mode 100644 semantica/context/_markdown_filesystem.py diff --git a/CHANGELOG.md b/CHANGELOG.md index db25f459..86e63631 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -67,6 +67,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Entities and relationships round-trip as memory-local provenance only — Markdown import intentionally does not write into `ContextGraph`, matching the MVP scope agreed on in #765 - Documented the file contract and workflow in `docs/reference/context.md`; 43 new tests in `tests/context/test_agent_memory_markdown.py` cover round-trip losslessness, idempotency, validation errors, rollback on failure, and vector-store sync ordering +- **Markdown directory round trips for `ContextGraph`** (#852) by @SaurabhScripts + - `ContextGraph.save_to_file(..., format="markdown")` and `load_from_file(..., format="markdown")` persist a deterministic `graph.md` relationship manifest plus one human-editable Markdown file per node, preserving graph, node, edge, family, temporal, and cross-graph link identities + - Imports validate the complete directory before replacing graph state, rebuild indexes and analytics state atomically, create JSON-compatible stub nodes for dangling edge endpoints, and emit the same granular node/edge audit events as JSON loading + - Existing exports are replaced atomically only after their complete canonical layout is validated; untracked files, renamed node files, symlinks, Windows directory junctions, and other reparse points cause a fail-closed error instead of authorizing directory deletion + - Added 30 focused tests covering deterministic round trips, manual edits, validation rollback, managed-directory identity, publish rollback, audit-manager compatibility, stale-cache clearing, mocked and real Windows junctions, and missing-path behavior + ### Fixed - **`VectorStore.search_vectors()` returned inconsistent result shapes across backend implementations** (#853, closes #845) by @Sameer6305, reviewed by @KaifAhmad1 diff --git a/docs/guides/context-graphs.md b/docs/guides/context-graphs.md index b23a3b85..26ec5174 100644 --- a/docs/guides/context-graphs.md +++ b/docs/guides/context-graphs.md @@ -454,10 +454,16 @@ and cross-graph link IDs are preserved across round trips. Markdown loading uses replacement semantics, like `from_dict()`: it parses and validates the complete directory before replacing the current graph. Invalid YAML, -duplicate IDs, dangling edge endpoints, unsupported versions, and unsafe symbolic -links fail without partially mutating the graph. Re-exporting to an existing managed -directory atomically replaces it, removing stale node files. To avoid accidental data -loss, a non-empty directory without the ContextGraph manifest is never replaced. +duplicate IDs, unsupported versions, and unsafe filesystem links fail without +partially mutating the graph. As with JSON loading, an edge endpoint without a node +file creates an `entity` stub node. Symlinks, Windows directory junctions, and other +Windows reparse points are rejected. + +Re-exporting to an existing managed directory atomically replaces it, removing stale +node files. Before replacement, Semantica validates the complete canonical export +layout, not just the manifest header. Untracked files, assets, extra directories, or +renamed node files therefore cause the export to fail closed instead of being deleted. +Keep attachments and hand-written indexes outside the managed export directory. If the graph had cross-graph links created with `link_graph()`, call `resolve_links()` after loading to restore live navigation — object references cannot be serialized, so they must be reconnected manually: diff --git a/semantica/context/_markdown_filesystem.py b/semantica/context/_markdown_filesystem.py new file mode 100644 index 00000000..70872f85 --- /dev/null +++ b/semantica/context/_markdown_filesystem.py @@ -0,0 +1,32 @@ +"""Filesystem safety helpers for human-editable Markdown persistence.""" + +import os +import stat +from pathlib import Path +from typing import Optional + + +def is_filesystem_link(path: Path) -> bool: + """Return whether *path* is a symlink, junction, or Windows reparse point.""" + if path.is_symlink(): + return True + + isjunction = getattr(os.path, "isjunction", None) + if isjunction is not None and isjunction(path): + return True + + try: + attributes = getattr(os.lstat(path), "st_file_attributes", 0) + except (FileNotFoundError, NotADirectoryError): + return False + + reparse_point = getattr(stat, "FILE_ATTRIBUTE_REPARSE_POINT", 0x400) + return bool(attributes & reparse_point) + + +def find_filesystem_link(path: Path) -> Optional[Path]: + """Return the first linked component in *path*, including its ancestors.""" + for candidate in (path, *path.parents): + if is_filesystem_link(candidate): + return candidate + return None diff --git a/semantica/context/context_graph.py b/semantica/context/context_graph.py index dea62d66..cb8359c2 100644 --- a/semantica/context/context_graph.py +++ b/semantica/context/context_graph.py @@ -105,30 +105,31 @@ Production Use Cases: - Business: Workflow decisions, policy compliance, audit trails """ -from collections import defaultdict, deque import copy -from dataclasses import dataclass, field -from datetime import date, datetime, timezone import errno import hashlib +import itertools import json import os -from pathlib import Path import re import shutil import stat import tempfile import threading -import itertools -from typing import Any, Dict, List, Optional, Set, Tuple, Union import uuid +from collections import defaultdict, deque +from dataclasses import dataclass, field +from datetime import date, datetime, timezone +from pathlib import Path +from typing import Any, Dict, List, Optional, Set, Tuple, Union import yaml +from ..utils.helpers import classify_path_distance from ..utils.logging import get_logger from ..utils.progress_tracker import get_progress_tracker -from ..utils.helpers import classify_path_distance from ..utils.skos import is_skos_hierarchy_edge, validate_skos_hierarchy +from ._markdown_filesystem import find_filesystem_link from .entity_linker import EntityLinker @@ -171,9 +172,15 @@ _UniqueKeySafeLoader.add_constructor( # Optional imports for advanced features try: from ..kg import ( - GraphBuilder, GraphAnalyzer, CentralityCalculator, CommunityDetector, - PathFinder, NodeEmbedder, SimilarityCalculator, LinkPredictor, - ConnectivityAnalyzer + CentralityCalculator, + CommunityDetector, + ConnectivityAnalyzer, + GraphAnalyzer, + GraphBuilder, + LinkPredictor, + NodeEmbedder, + PathFinder, + SimilarityCalculator, ) KG_AVAILABLE = True except ImportError: @@ -1084,7 +1091,17 @@ class ContextGraph: """ normalized_format = self._normalize_persistence_format(format) if normalized_format == "markdown": - self._load_markdown_directory(Path(path)) + markdown_path = Path(path) + linked_component = find_filesystem_link(markdown_path) + if linked_component is not None: + raise ValueError( + "Refusing to import Markdown symbolic link or junction: " + f"{linked_component}" + ) + if not markdown_path.exists(): + self.logger.warning(f"File not found: {path}") + return + self._load_markdown_directory(markdown_path) self.logger.info(f"Loaded context graph Markdown from {path}") return @@ -1115,6 +1132,7 @@ class ContextGraph: self.edge_type_index.clear() self._linked_graphs.clear() self._unresolved_links.clear() + self._analytics_cache.clear() if "graph_id" in data: self.graph_id = data["graph_id"] @@ -1161,28 +1179,25 @@ class ContextGraph: def _save_markdown_directory(self, destination: Path) -> None: if not destination.name: raise ValueError("Markdown export destination cannot be a filesystem root.") - if destination.is_symlink(): + linked_component = find_filesystem_link(destination) + if linked_component is not None: raise ValueError( - f"Refusing to replace Markdown symbolic link: {destination}" + "Refusing to replace Markdown symbolic link or junction: " + f"{linked_component}" ) if destination.exists() and not destination.is_dir(): raise ValueError( f"Markdown export destination is not a directory: {destination}" ) if destination.exists() and any(destination.iterdir()): - manifest = destination / self._MARKDOWN_MANIFEST try: - document = self._read_markdown_file(manifest) - frontmatter, _ = self._parse_markdown_document( - document, str(manifest) - ) - is_managed = ( - frontmatter.get("format") == self._MARKDOWN_FORMAT - and not isinstance(frontmatter.get("version"), bool) - and frontmatter.get("version") == self._MARKDOWN_VERSION + self._parse_markdown_directory( + destination, require_canonical_layout=True ) except (FileNotFoundError, ValueError): is_managed = False + else: + is_managed = True if not is_managed: raise ValueError( "Refusing to replace a non-empty directory that is not a " @@ -1191,6 +1206,12 @@ class ContextGraph: manifest_document, node_documents = self._markdown_documents() destination.parent.mkdir(parents=True, exist_ok=True) + linked_component = find_filesystem_link(destination.parent) + if linked_component is not None: + raise ValueError( + "Refusing to export through Markdown symbolic link or junction: " + f"{linked_component}" + ) staging_path = Path( tempfile.mkdtemp( dir=str(destination.parent), prefix=f".{destination.name}.staging-" @@ -1449,62 +1470,8 @@ class ContextGraph: return value def _load_markdown_directory(self, source: Path) -> None: - manifest_document, node_documents = self._read_markdown_directory(source) - manifest, _ = self._parse_markdown_document( - manifest_document, str(source / self._MARKDOWN_MANIFEST) - ) - graph_id, edges, links = self._parse_markdown_manifest(manifest, source) - - nodes_by_id: Dict[str, ContextNode] = {} - for node_source, document in node_documents: - frontmatter, body = self._parse_markdown_document(document, node_source) - node = self._parse_markdown_node(frontmatter, body, node_source) - if node.node_id in nodes_by_id: - raise ValueError( - f"Duplicate Markdown node ID {node.node_id!r} in {node_source}." - ) - nodes_by_id[node.node_id] = node - - missing_endpoints = sorted( - { - endpoint - for edge in edges - for endpoint in (edge.source_id, edge.target_id) - if endpoint not in nodes_by_id - } - ) - if missing_endpoints: - missing = ", ".join(repr(endpoint) for endpoint in missing_endpoints) - raise ValueError( - f"Invalid ContextGraph Markdown: edge endpoint(s) {missing} " - "do not have node files." - ) - - hierarchy_edges = [ - { - "source": edge.source_id, - "target": edge.target_id, - "type": edge.edge_type, - } - for edge in edges - if is_skos_hierarchy_edge(edge.to_dict()) - ] - if hierarchy_edges: - validate_skos_hierarchy(hierarchy_edges, []) - - unresolved_links = {} - for link in links: - link_id = link["link_id"] - if link_id in unresolved_links: - raise ValueError( - f"Duplicate cross-graph link ID {link_id!r} in graph manifest." - ) - if link["source_node_id"] not in nodes_by_id: - raise ValueError( - f"Cross-graph link {link_id!r} references missing source node " - f"{link['source_node_id']!r}." - ) - unresolved_links[link_id] = link + parsed_state = self._parse_markdown_directory(source) + graph_id, nodes_by_id, edges, unresolved_links = parsed_state adjacency: Dict[str, List[ContextEdge]] = defaultdict(list) node_type_index: Dict[str, Set[str]] = defaultdict(set) @@ -1533,22 +1500,114 @@ class ContextGraph: self._analytics_cache.clear() if self.mutation_callback and not self._suspend_mutation_callback: - try: - self.mutation_callback( - "RELOAD_GRAPH", - graph_id, - {"node_count": len(nodes_by_id), "edge_count": len(edges)}, + mutation_events = [ + ("ADD_NODE", node.node_id, node.to_dict()) + for node in nodes_by_id.values() + ] + mutation_events.extend( + ("ADD_EDGE", edge.edge_id, edge.to_dict()) for edge in edges + ) + for operation, entity_id, payload in mutation_events: + try: + self.mutation_callback(operation, entity_id, payload) + except Exception as exc: + self.logger.warning( + "Audit trail callback failed for Markdown graph load " + "%s %s: %s", + operation, + entity_id, + exc, + ) + + def _parse_markdown_directory( + self, source: Path, require_canonical_layout: bool = False + ) -> Tuple[ + str, + Dict[str, ContextNode], + List[ContextEdge], + Dict[str, Dict[str, str]], + ]: + manifest_document, node_documents = self._read_markdown_directory( + source, require_canonical_layout=require_canonical_layout + ) + manifest, _ = self._parse_markdown_document( + manifest_document, str(source / self._MARKDOWN_MANIFEST) + ) + graph_id, edges, links = self._parse_markdown_manifest(manifest, source) + + nodes_by_id: Dict[str, ContextNode] = {} + for node_source, document in node_documents: + frontmatter, body = self._parse_markdown_document(document, node_source) + node = self._parse_markdown_node(frontmatter, body, node_source) + if node.node_id in nodes_by_id: + raise ValueError( + f"Duplicate Markdown node ID {node.node_id!r} in {node_source}." ) - except Exception as exc: - self.logger.warning( - "Audit trail callback failed for Markdown graph load: %s", exc + node_filename = Path(node_source).name + if ( + require_canonical_layout + and node_filename != self._node_markdown_filename(node.node_id) + ): + raise ValueError( + "Invalid managed ContextGraph export: node file " + f"{node_filename!r} is not the canonical filename " + f"for node {node.node_id!r}." ) + nodes_by_id[node.node_id] = node + + missing_endpoints = sorted( + { + endpoint + for edge in edges + for endpoint in (edge.source_id, edge.target_id) + if endpoint not in nodes_by_id + } + ) + if missing_endpoints and require_canonical_layout: + missing = ", ".join(repr(endpoint) for endpoint in missing_endpoints) + raise ValueError( + "Invalid managed ContextGraph export: edge endpoint(s) " + f"{missing} do not have node files." + ) + for endpoint in missing_endpoints: + nodes_by_id[endpoint] = ContextNode(endpoint, "entity", endpoint) + + hierarchy_edges = [ + { + "source": edge.source_id, + "target": edge.target_id, + "type": edge.edge_type, + } + for edge in edges + if is_skos_hierarchy_edge(edge.to_dict()) + ] + if hierarchy_edges: + validate_skos_hierarchy(hierarchy_edges, []) + + unresolved_links = {} + for link in links: + link_id = link["link_id"] + if link_id in unresolved_links: + raise ValueError( + f"Duplicate cross-graph link ID {link_id!r} in graph manifest." + ) + if link["source_node_id"] not in nodes_by_id: + raise ValueError( + f"Cross-graph link {link_id!r} references missing source node " + f"{link['source_node_id']!r}." + ) + unresolved_links[link_id] = link + return graph_id, nodes_by_id, edges, unresolved_links def _read_markdown_directory( - self, source: Path + self, source: Path, require_canonical_layout: bool = False ) -> Tuple[str, List[Tuple[str, str]]]: - if source.is_symlink(): - raise ValueError(f"Refusing to import Markdown symbolic link: {source}") + linked_component = find_filesystem_link(source) + if linked_component is not None: + raise ValueError( + "Refusing to import Markdown symbolic link or junction: " + f"{linked_component}" + ) if not source.exists(): raise FileNotFoundError( f"ContextGraph Markdown import path does not exist: {source}" @@ -1558,11 +1617,35 @@ class ContextGraph: f"ContextGraph Markdown import path is not a directory: {source}" ) + if require_canonical_layout: + expected_entries = { + self._MARKDOWN_MANIFEST, + self._MARKDOWN_NODES_DIRECTORY, + } + actual_entries = {path.name for path in source.iterdir()} + if actual_entries != expected_entries: + unexpected = sorted(actual_entries - expected_entries) + missing = sorted(expected_entries - actual_entries) + details = [] + if unexpected: + details.append(f"unexpected entries: {unexpected!r}") + if missing: + details.append(f"missing entries: {missing!r}") + raise ValueError( + "Invalid managed ContextGraph export layout (" + + "; ".join(details) + + ")." + ) + manifest_path = source / self._MARKDOWN_MANIFEST manifest_document = self._read_markdown_file(manifest_path) nodes_path = source / self._MARKDOWN_NODES_DIRECTORY - if nodes_path.is_symlink(): - raise ValueError(f"Refusing to import Markdown symbolic link: {nodes_path}") + linked_component = find_filesystem_link(nodes_path) + if linked_component is not None: + raise ValueError( + "Refusing to import Markdown symbolic link or junction: " + f"{linked_component}" + ) if not nodes_path.is_dir(): raise ValueError( f"ContextGraph Markdown nodes directory is missing: {nodes_path}" @@ -1570,13 +1653,28 @@ class ContextGraph: node_paths = [] for path in nodes_path.iterdir(): - if path.is_symlink(): - raise ValueError(f"Refusing to import Markdown symbolic link: {path}") + linked_component = find_filesystem_link(path) + if linked_component is not None: + raise ValueError( + "Refusing to import Markdown symbolic link or junction: " + f"{linked_component}" + ) if path.suffix.lower() not in self._MARKDOWN_EXTENSIONS: + if require_canonical_layout: + raise ValueError( + "Invalid managed ContextGraph export: unexpected node " + f"entry {path.name!r}." + ) continue if not path.is_file(): raise ValueError(f"Markdown node path is not a regular file: {path}") node_paths.append(path) + linked_component = find_filesystem_link(nodes_path) + if linked_component is not None: + raise ValueError( + "Refusing to import Markdown symbolic link or junction: " + f"{linked_component}" + ) node_paths.sort(key=lambda path: (path.name.casefold(), path.name)) return manifest_document, [ (str(path), self._read_markdown_file(path)) for path in node_paths @@ -1584,17 +1682,23 @@ class ContextGraph: @staticmethod def _read_markdown_file(path: Path) -> str: - if path.is_symlink(): - raise ValueError(f"Refusing to import Markdown symbolic link: {path}") + linked_component = find_filesystem_link(path) + if linked_component is not None: + raise ValueError( + "Refusing to import Markdown symbolic link or junction: " + f"{linked_component}" + ) flags = os.O_RDONLY if hasattr(os, "O_NOFOLLOW"): flags |= os.O_NOFOLLOW try: descriptor = os.open(path, flags) except OSError as exc: - if exc.errno == errno.ELOOP or path.is_symlink(): + linked_component = find_filesystem_link(path) + if exc.errno == errno.ELOOP or linked_component is not None: raise ValueError( - f"Refusing to import Markdown symbolic link: {path}" + "Refusing to import Markdown symbolic link or junction: " + f"{linked_component or path}" ) from exc if exc.errno == errno.ENOENT: raise FileNotFoundError(f"Markdown file is missing: {path}") from exc @@ -1605,6 +1709,12 @@ class ContextGraph: ) from exc try: + linked_component = find_filesystem_link(path) + if linked_component is not None: + raise ValueError( + "Refusing to import Markdown symbolic link or junction: " + f"{linked_component}" + ) if not stat.S_ISREG(os.fstat(descriptor).st_mode): raise ValueError(f"Markdown path is not a regular file: {path}") with os.fdopen(descriptor, "r", encoding="utf-8") as input_file: @@ -2523,9 +2633,10 @@ class ContextGraph: def _load_conversation(self, file_path: str) -> Dict[str, Any]: """Load conversation from file.""" - from ..utils.helpers import read_json_file from pathlib import Path + from ..utils.helpers import read_json_file + return read_json_file(Path(file_path)) def to_dict(self) -> Dict[str, Any]: @@ -3236,7 +3347,7 @@ class ContextGraph: """ import uuid from datetime import datetime - + # Input validation if not isinstance(category, str) or not category.strip(): raise ValueError("Category must be a non-empty string") diff --git a/tests/context/test_context_graph_markdown.py b/tests/context/test_context_graph_markdown.py index 18e54e51..f81dce3b 100644 --- a/tests/context/test_context_graph_markdown.py +++ b/tests/context/test_context_graph_markdown.py @@ -1,9 +1,13 @@ +import os +import stat +import subprocess from pathlib import Path import pytest import yaml import semantica.context.context_graph as context_graph_module +from semantica.change_management.managers import TemporalVersionManager from semantica.context.context_graph import ContextEdge, ContextGraph, ContextNode @@ -244,7 +248,6 @@ def test_markdown_export_rejects_duplicate_edge_ids_before_writing(tmp_path): [ ("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"), ], @@ -262,8 +265,6 @@ def test_invalid_markdown_does_not_mutate_existing_graph( 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()) @@ -300,6 +301,26 @@ def test_invalid_markdown_does_not_mutate_existing_graph( assert _normalized_state(target) == before +def test_markdown_import_creates_json_compatible_stub_nodes_for_dangling_edges( + tmp_path, +): + source, _, _ = _sample_graph() + export_path = tmp_path / "dangling-edge" + source.save_to_file(export_path, format="markdown") + manifest_path = export_path / "graph.md" + manifest, manifest_body = _read_markdown(manifest_path) + manifest["edges"][0]["target"] = "missing-node" + _write_markdown(manifest_path, manifest, manifest_body) + + restored = ContextGraph(advanced_analytics=False) + restored.load_from_file(export_path, format="markdown") + + stub = restored.nodes["missing-node"] + assert stub.node_type == "entity" + assert stub.content == "missing-node" + assert any(edge.target_id == "missing-node" for edge in restored.edges) + + @pytest.mark.parametrize( ("location", "field_name"), [("node", "valid_from"), ("edge", "valid_until")], @@ -375,6 +396,41 @@ def test_markdown_export_rejects_unrelated_graph_markdown_file(tmp_path): assert unrelated.exists() +@pytest.mark.parametrize("extra_location", ["root", "nodes"]) +def test_markdown_export_refuses_managed_directory_with_untracked_files( + tmp_path, extra_location +): + graph = ContextGraph(advanced_analytics=False) + graph.add_node("node-1", "Note", "Body") + destination = tmp_path / "existing" + graph.save_to_file(destination, format="markdown") + parent = destination if extra_location == "root" else destination / "nodes" + human_file = parent / "human-notes.txt" + human_file.write_text("do not delete", encoding="utf-8") + original_contents = _directory_contents(destination) + + with pytest.raises(ValueError, match="not a managed ContextGraph export"): + graph.save_to_file(destination, format="markdown") + + assert _directory_contents(destination) == original_contents + + +def test_markdown_export_refuses_noncanonical_node_layout(tmp_path): + graph = ContextGraph(advanced_analytics=False) + graph.add_node("node-1", "Note", "Body") + destination = tmp_path / "existing" + graph.save_to_file(destination, format="markdown") + canonical = _node_file(destination, "node-1") + renamed = canonical.with_name("human-name.md") + canonical.rename(renamed) + original_contents = _directory_contents(destination) + + with pytest.raises(ValueError, match="not a managed ContextGraph export"): + graph.save_to_file(destination, format="markdown") + + assert _directory_contents(destination) == original_contents + + def test_markdown_export_preserves_manifest_inspection_errors(tmp_path, monkeypatch): graph = ContextGraph(advanced_analytics=False) destination = tmp_path / "existing" @@ -456,7 +512,7 @@ def test_markdown_export_preserves_publish_error_when_restore_fails( assert not list(tmp_path.glob(".graph.staging-*")) -def test_markdown_load_rebuilds_indexes_and_emits_one_reload_event(tmp_path): +def test_markdown_load_rebuilds_indexes_and_emits_json_compatible_events(tmp_path): source, _, _ = _sample_graph() destination = tmp_path / "graph" source.save_to_file(destination, format="markdown") @@ -471,13 +527,27 @@ def test_markdown_load_rebuilds_indexes_and_emits_one_reload_event(tmp_path): 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)}, - ) - ] + assert [event[0] for event in events] == ["ADD_NODE"] * len(target.nodes) + [ + "ADD_EDGE" + ] * len(target.edges) + assert {event[1] for event in events if event[0] == "ADD_NODE"} == set(target.nodes) + assert {event[1] for event in events if event[0] == "ADD_EDGE"} == { + edge.edge_id for edge in target.edges + } + + +def test_markdown_load_records_granular_change_manager_history(tmp_path): + source, _, _ = _sample_graph() + destination = tmp_path / "graph" + source.save_to_file(destination, format="markdown") + target = ContextGraph(advanced_analytics=False) + manager = TemporalVersionManager() + manager.attach_to_graph(target) + + target.load_from_file(destination, format="markdown") + + assert manager.get_node_history("evidence-1")[0]["operation"] == "ADD_NODE" + assert manager.get_node_history("edge-supports")[0]["operation"] == "ADD_EDGE" def test_markdown_export_rejects_recursive_metadata(tmp_path): @@ -520,6 +590,74 @@ def test_markdown_import_and_export_reject_symlinks(tmp_path): ) +def test_markdown_import_and_export_reject_windows_junctions(tmp_path, monkeypatch): + graph = ContextGraph(advanced_analytics=False) + graph.add_node("node-1", "Note", "Body") + export_path = tmp_path / "junction" + graph.save_to_file(export_path, format="markdown") + + monkeypatch.setattr( + os.path, + "isjunction", + lambda candidate: Path(candidate) == export_path, + raising=False, + ) + + with pytest.raises(ValueError, match="junction"): + ContextGraph(advanced_analytics=False).load_from_file( + export_path, format="markdown" + ) + with pytest.raises(ValueError, match="junction"): + graph.save_to_file(export_path, format="markdown") + + +def test_markdown_import_rejects_windows_reparse_point_fallback(tmp_path, monkeypatch): + source = tmp_path / "reparse-point" + source.mkdir() + real_lstat = os.lstat + + class ReparseStat: + st_file_attributes = getattr(stat, "FILE_ATTRIBUTE_REPARSE_POINT", 0x400) + + monkeypatch.delattr(os.path, "isjunction", raising=False) + monkeypatch.setattr(Path, "is_symlink", lambda self: False) + monkeypatch.setattr( + os, + "lstat", + lambda candidate: ( + ReparseStat() if Path(candidate) == source else real_lstat(candidate) + ), + ) + + with pytest.raises(ValueError, match="junction"): + ContextGraph(advanced_analytics=False).load_from_file(source, format="markdown") + + +@pytest.mark.skipif(os.name != "nt", reason="requires Windows junctions") +def test_markdown_import_rejects_real_windows_junction(tmp_path): + graph = ContextGraph(advanced_analytics=False) + graph.add_node("node-1", "Note", "Body") + outside = tmp_path / "outside" + graph.save_to_file(outside, format="markdown") + source = tmp_path / "junction" + result = subprocess.run( + ["cmd.exe", "/c", "mklink", "/J", str(source), str(outside)], + capture_output=True, + text=True, + check=False, + ) + if result.returncode != 0: + pytest.skip(f"could not create Windows junction: {result.stderr}") + + try: + with pytest.raises(ValueError, match="junction"): + ContextGraph(advanced_analytics=False).load_from_file( + source, format="markdown" + ) + finally: + os.rmdir(source) + + @pytest.mark.skipif( not hasattr(context_graph_module.os, "O_NOFOLLOW"), reason="O_NOFOLLOW is unavailable on this platform", @@ -557,8 +695,14 @@ def test_json_remains_default_and_unknown_format_is_rejected(tmp_path): graph.save_to_file(json_path) restored = ContextGraph(advanced_analytics=False) + restored._analytics_cache["stale"] = {"value": True} restored.load_from_file(json_path) assert "node-1" in restored.nodes + assert restored._analytics_cache == {} + + before = _normalized_state(restored) + restored.load_from_file(tmp_path / "missing", format="markdown") + assert _normalized_state(restored) == before with pytest.raises(ValueError, match="Unsupported context graph"): graph.save_to_file(tmp_path / "graph", format="html")