Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,40 @@ jobs:
uv run --frozen graphify --help
uv run --frozen graphify install

platform-paths:
# Keep the filesystem boundary honest on every supported desktop OS. The
# Windows run creates and extracts a real 300+ character source path; the
# same tests verify that the adapter remains a no-op on Linux and macOS.
name: Filesystem paths (${{ matrix.os }})
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]

steps:
- uses: actions/checkout@v6

- name: Install uv
uses: astral-sh/setup-uv@v8.1.0
with:
python-version: "3.12"

- name: Install dependencies
run: uv sync --frozen

- name: Exercise logical and extended path boundaries
run: >-
uv run --frozen pytest
tests/test_windows_long_paths.py
tests/test_long_path_hashing.py
tests/test_paths.py
tests/test_atomic_writes.py
tests/test_google_workspace.py
tests/test_cargo_introspect.py
tests/test_cpp_preprocess.py
-q --tb=short

security-scan:
# The dev deps include bandit and pip-audit. Run them in CI so a new
# HIGH-severity finding or vulnerable dependency is caught on the PR that
Expand Down
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,14 @@

Full release notes with details on each version: [GitHub Releases](https://github.com/safishamsi/graphify/releases)

## Unreleased

- Fix: Windows scans no longer skip deeply nested local or UNC files when their paths
cross the legacy `MAX_PATH` boundary. Filesystem calls now use extended-length paths
only at the I/O boundary while graph IDs, manifests, cache keys, and diagnostics retain
ordinary paths; discovery, hashing, extraction, incremental rebuilds, and document/media
readers share the same cross-platform adapter. A focused Ubuntu/macOS/Windows CI matrix
exercises 300+ character paths and verifies that the adapter remains a no-op off Windows.
## 0.9.32 (unreleased)

- Fix: incremental extraction and `_rebuild_code` no longer drop a file's other tier (#2333, #2334, #2336). Node/edge ownership was keyed on `source_file` alone, so a semantic re-extract deleted a doc's AST headings and a full rebuild deleted document AST nodes. Merge is now tier-aware (an AST re-extract replaces only AST nodes and keeps the semantic layer, and vice versa), the `_origin` provenance marker is backfilled on load so old graphs self-heal, and the full-rebuild drop is scoped to sources actually regenerated.
Expand Down
31 changes: 18 additions & 13 deletions graphify/build.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,12 @@
from pathlib import Path
import networkx as nx
from .ids import make_id, normalize_id as _normalize_id
from .paths import default_graph_json as _default_graph_json
from .paths import (
default_graph_json as _default_graph_json,
path_exists as _path_exists,
read_text as _read_text,
resolve_path as _resolve_path,
)
from .validate import validate_extraction


Expand Down Expand Up @@ -251,7 +256,7 @@ def _norm_source_file(p: str | None, root: str | None = None) -> str | None:
# matching. Only the slow path resolves, so the common lexical match
# stays filesystem-free.
try:
p = Path(p).resolve().relative_to(Path(root).resolve()).as_posix()
p = _resolve_path(p).relative_to(_resolve_path(root)).as_posix()
except (ValueError, OSError):
pass
return p
Expand All @@ -275,7 +280,7 @@ def _abs_identity(p: str | None, root: str | None = None) -> str | None:
if not pp.is_absolute() and root:
pp = Path(root) / q
try:
return pp.resolve().as_posix()
return _resolve_path(pp).as_posix()
except OSError:
return pp.as_posix()

Expand Down Expand Up @@ -365,14 +370,14 @@ def _infer_merge_root(graph_path: Path) -> str | None:
"""
try:
marker = graph_path.parent / ".graphify_root"
if marker.exists():
recorded = marker.read_text(encoding="utf-8").strip()
if _path_exists(marker):
recorded = _read_text(marker, encoding="utf-8").strip()
if recorded:
return str(Path(recorded).resolve())
return str(_resolve_path(recorded))
except OSError:
pass
try:
return str(graph_path.parent.parent.resolve())
return str(_resolve_path(graph_path.parent.parent))
except Exception:
return None

Expand Down Expand Up @@ -617,7 +622,7 @@ def build_from_json(extraction: dict, *, directed: bool = False, root: str | Pat
root: if given, absolute source_file paths from semantic subagents are made
relative to root so all nodes share a consistent path key (#932).
"""
_root = str(Path(root).resolve()) if root else None
_root = str(_resolve_path(root)) if root else None
# NetworkX <= 3.1 serialised edges as "links"; remap to "edges" for compatibility.
if "edges" not in extraction and "links" in extraction:
extraction = dict(extraction, edges=extraction["links"])
Expand Down Expand Up @@ -1228,12 +1233,12 @@ def _load_existing_graph(graph_path: Path) -> "tuple[list, list, list, bool] | N
exists but cannot be parsed — callers must refuse to overwrite rather
than silently replace a possibly-recoverable graph.
"""
if not graph_path.exists():
if not _path_exists(graph_path):
return None
from graphify.security import check_graph_file_size_cap
check_graph_file_size_cap(graph_path)
try:
data = json.loads(graph_path.read_text(encoding="utf-8"))
data = json.loads(_read_text(graph_path, encoding="utf-8"))
except (json.JSONDecodeError, OSError) as exc:
raise RuntimeError(
f"Cannot read {graph_path} for incremental merge: {exc}. "
Expand Down Expand Up @@ -1301,7 +1306,7 @@ def merge_raw_extraction(
existing_nodes, existing_edges, existing_hyperedges, _ = loaded

_eff_root = (
str(Path(root).resolve()) if root is not None
str(_resolve_path(root)) if root is not None
else _infer_merge_root(graph_path)
)

Expand Down Expand Up @@ -1420,7 +1425,7 @@ def build_merge(
# absolute deleted-file paths never matched the relative node keys and their
# nodes survived as ghosts).
_eff_root = (
str(Path(root).resolve()) if root is not None
str(_resolve_path(root)) if root is not None
else _infer_merge_root(graph_path)
)

Expand Down Expand Up @@ -1575,7 +1580,7 @@ def _prune_match(sf: "str | None") -> bool:

# Safety check: refuse to shrink the graph silently (#479)
# Skip when dedup or prune_sources is active — shrinkage is intentional there.
if graph_path.exists() and not dedup and not prune_sources:
if _path_exists(graph_path) and not dedup and not prune_sources:
existing_n = len(existing_nodes)
new_n = G.number_of_nodes()
if new_n < existing_n:
Expand Down
Loading