fix(context): address Markdown round-trip review

This commit is contained in:
Saurabh Meena
2026-08-21 18:35:14 +05:30
parent e7ce092ccf
commit b7af18a70a
5 changed files with 413 additions and 114 deletions
+6
View File
@@ -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
+10 -4
View File
@@ -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:
+32
View File
@@ -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
+210 -99
View File
@@ -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")
+155 -11
View File
@@ -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")