Skip to content

hook-style relative changed_paths corrupt incremental code graph #1348

Closed

Description

@pkudinov

Summary

graphify.watch._rebuild_code(..., changed_paths=[...]) corrupts the graph when changed_paths contains repo-relative paths, which is the shape used by the installed post-commit hook.

With absolute changed paths, the same small repro preserves the graph. With relative changed paths, the rebuild removes the changed file's nodes and the cross-file import/call edges.

This makes hook-driven incremental updates unsafe for reverse impact / affected-test workflows, because incoming edges to changed code can disappear.

Why this appears public-facing

The installed hook parses GRAPHIFY_CHANGED into relative Path(...) values and calls:

_root = Path('.')
_rebuild_code(_root, changed_paths=changed, force=_force)

So even though _rebuild_code is private, this path is reachable through graphify hook install.

Minimal repro

repo=$(mktemp -d /tmp/graphify-changed-path-rel.XXXXXX)
mkdir -p "$repo/src"
cat > "$repo/src/lib.ts" <<'TS'
export function answer() { return 42; }
TS
cat > "$repo/src/app.ts" <<'TS'
import { answer } from "./lib";
export function run() { return answer(); }
TS

graphify extract "$repo" --no-cluster --out "$repo" --max-workers 1
python3 - <<PY
import json
from pathlib import Path
p = Path("$repo") / "graphify-out" / "graph.json"
data = json.loads(p.read_text())
print("before", len(data.get("nodes", [])), len(data.get("links", data.get("edges", []))))
for e in data.get("links", data.get("edges", [])):
    print(e.get("source"), "->", e.get("target"), e.get("relation"))
PY

# Simulate the hook-style relative changed path.
cat > "$repo/src/lib.ts" <<'TS'
export function answer() { return 43; }
TS
cd "$repo"
python3 - <<'PY'
from pathlib import Path
from graphify.watch import _rebuild_code
ok = _rebuild_code(Path('.'), changed_paths=[Path('src/lib.ts')], no_cluster=True, block_on_lock=True)
print('ok', ok)
PY

# If graphify is not importable from python3, run the same snippet from a Graphify checkout with `uv run python`.

python3 - <<PY
import json
from pathlib import Path
p = Path("$repo") / "graphify-out" / "graph.json"
data = json.loads(p.read_text())
print("after", len(data.get("nodes", [])), len(data.get("links", data.get("edges", []))))
for e in data.get("links", data.get("edges", [])):
    print(e.get("source"), "->", e.get("target"), e.get("relation"))
PY

Observed output

Before the incremental rebuild, the graph has both files and cross-file edges:

before 4 5
src_app -> src_lib imports_from
src_app -> src_lib_answer imports
src_app -> src_app_run contains
src_lib -> src_lib_answer contains
src_app_run -> src_lib_answer calls

After _rebuild_code(Path('.'), changed_paths=[Path('src/lib.ts')], ...), it is corrupted:

[graphify watch] Rebuilt (no clustering): 2 nodes, 1 edges
[graphify watch] graph.json updated in /tmp/graphify-changed-path-rel.../graphify-out
ok True

after 2 1
src_app -> src_app_run contains

The changed file (src/lib.ts) and the incoming import/call edges are gone.

Expected behavior

Relative changed paths should be normalized consistently against watch_path / project root before extraction and eviction.

For the repro above, the post-rebuild graph should still contain:

src_app -> src_lib imports_from
src_app -> src_lib_answer imports
src_app_run -> src_lib_answer calls
src_lib -> src_lib_answer contains

Notes

The same repro works when the changed path is passed as an absolute path. That points to path normalization / source-file normalization mismatch rather than TypeScript import resolution itself.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions