From 1159ef6a1f9d06ae9548ce032696867271f1b368 Mon Sep 17 00:00:00 2001 From: "martin.perry" Date: Tue, 25 Aug 2026 07:55:52 -0400 Subject: [PATCH 1/7] fix(extract): honour follow_symlinks over root containment Passing follow_symlinks=True declares intent to follow links outside the scan root, but collect_files applied _resolves_under_root unconditionally, rejecting every file reached through such a link. That made the flag inert for its only documented use case: a directory of links to scattered source dirs. Skip containment when the caller explicitly opted in. Co-Authored-By: Claude Opus 5 (1M context) --- graphify/extract.py | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/graphify/extract.py b/graphify/extract.py index 89082af87..2b7c437da 100644 --- a/graphify/extract.py +++ b/graphify/extract.py @@ -7075,9 +7075,15 @@ def _canon(nid: str) -> str: def collect_files(target: Path, *, follow_symlinks: bool = False, root: Path | None = None) -> list[Path]: containment_root = root if root is not None else target + # follow_symlinks=True IS the caller's declaration of intent to follow links + # out of the root. Applying containment on top of it makes the flag inert for + # its only documented use case (a directory of links to scattered source + # dirs), so honour the explicit opt-in. + def _contained(_p): + return True if follow_symlinks else _resolves_under_root(_p, containment_root) from graphify.detect import _resolves_under_root if target.is_file(): - return [target] if _resolves_under_root(target, containment_root) else [] + return [target] if _contained(target) else [] _EXTENSIONS = set(_DISPATCH.keys()) from graphify.detect import _is_ignored, _is_noise_dir, _load_graphifyignore ignore_root = root if root is not None else target @@ -7109,7 +7115,7 @@ def _ignored(p: Path) -> bool: for fname in filenames: p = dp / fname suffix = p.suffix - if (suffix in _EXTENSIONS or suffix.lower() in _EXTENSIONS) and not _ignored(p) and _resolves_under_root(p, containment_root): + if (suffix in _EXTENSIONS or suffix.lower() in _EXTENSIONS) and not _ignored(p) and _contained(p): results.append(p) return sorted(results) # Walk with symlink following + cycle detection @@ -7125,12 +7131,12 @@ def _ignored(p: Path) -> bool: dirnames[:] = [ d for d in dirnames if not _is_noise_dir(d, dp) # pass parent so "env"/"*_env" is marker-gated (#2058) - and (not (dp / d).is_symlink() or _resolves_under_root(dp / d, containment_root)) + and (not (dp / d).is_symlink() or _contained(dp / d)) ] for fname in filenames: p = dp / fname suffix = p.suffix - if (suffix in _EXTENSIONS or suffix.lower() in _EXTENSIONS) and not _ignored(p) and _resolves_under_root(p, containment_root): + if (suffix in _EXTENSIONS or suffix.lower() in _EXTENSIONS) and not _ignored(p) and _contained(p): results.append(p) return sorted(results) From f1b40b4e4fec5b455e33a4946ea9bec633b85427 Mon Sep 17 00:00:00 2001 From: "martin.perry" Date: Thu, 27 Aug 2026 11:30:30 -0400 Subject: [PATCH 2/7] feat(cli): resolve graph.json from an ancestor when absent in cwd Read commands defaulted to /graph.json relative to cwd, so query/path/explain/affected/god-nodes failed from any subdirectory of the scan root -- a package subdir, a git worktree -- even when a usable graph sat one or more levels up. The workaround was a shell wrapper function, which only ever worked in the one shell that sourced it and did nothing for tools that exec graphify directly. Walk up to the nearest readable, non-empty /graph.json when the configured path is not present here, and name the resolved graph on stderr (stdout carries the answer and is parsed by callers). Candidates that exist but are empty, unreadable, or a dangling link are skipped so the walk continues rather than returning a path that fails to parse. An explicit --graph still wins and is passed through untouched even when it does not exist; a graph present in cwd is returned with no filesystem walk, keeping the common case byte-identical; and an absolute GRAPHIFY_OUT disables the walk, since it names one fixed location rather than a per-root convention. Co-Authored-By: Claude Opus 5 (1M context) --- graphify/cli.py | 45 ++++++++++++++++++++++++++++++++++++++ graphify/paths.py | 55 +++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 100 insertions(+) diff --git a/graphify/cli.py b/graphify/cli.py index 5b7339726..561684244 100644 --- a/graphify/cli.py +++ b/graphify/cli.py @@ -85,6 +85,34 @@ def _default_graph_path() -> str: return str(Path(_GRAPHIFY_OUT) / "graph.json") +def _resolve_graph_path(graph_path: str, *, explicit: bool) -> str: + """Resolve a read command's graph path, walking up to an ancestor if needed. + + The default is relative to cwd, so a read command run from any subdirectory + of the scan root (a package subdir, a git worktree) used to fail with "graph + file not found" while a usable graph sat one or more levels up. When the + configured path is not present here, fall back to the nearest ancestor that + has one. + + An explicit ``--graph`` always wins and is returned untouched, including when + it does not exist: silently substituting a different graph for a path the + caller named would be worse than the error they get today. + + When a graph IS present relative to cwd this returns it unchanged and does no + filesystem walk, so the common case behaves exactly as before. The note about + an ancestor hit goes to stderr -- stdout carries the command's answer and is + parsed by callers. + """ + if explicit or Path(graph_path).exists(): + return graph_path + from graphify.paths import find_graph_json_upward + found = find_graph_json_upward() + if found is None: + return graph_path + print(f"graphify: using graph from ancestor: {found}", file=sys.stderr) + return str(found) + + def _stamped_manifest_files( files_by_type: dict[str, list[str]], sem_result: dict, @@ -1078,6 +1106,7 @@ def dispatch_command(cmd: str) -> None: use_dfs = "--dfs" in sys.argv budget = 2000 graph_path = _default_graph_path() + graph_explicit = False context_filters: list[str] = [] args = sys.argv[3:] i = 0 @@ -1104,9 +1133,11 @@ def dispatch_command(cmd: str) -> None: i += 1 elif args[i] == "--graph" and i + 1 < len(args): graph_path = args[i + 1] + graph_explicit = True i += 2 else: i += 1 + graph_path = _resolve_graph_path(graph_path, explicit=graph_explicit) gp = Path(graph_path).resolve() if not gp.exists(): print(f"error: graph file not found: {gp}", file=sys.stderr) @@ -1193,6 +1224,7 @@ def dispatch_command(cmd: str) -> None: from graphify.affected import DEFAULT_AFFECTED_RELATIONS, format_affected, load_graph query = sys.argv[2] graph_path = _default_graph_path() + graph_explicit = False depth = 2 relations: list[str] = [] args = sys.argv[3:] @@ -1200,9 +1232,11 @@ def dispatch_command(cmd: str) -> None: while i < len(args): if args[i] == "--graph" and i + 1 < len(args): graph_path = args[i + 1] + graph_explicit = True i += 2 elif args[i].startswith("--graph="): graph_path = args[i].split("=", 1)[1] + graph_explicit = True i += 1 elif args[i] == "--depth" and i + 1 < len(args): try: @@ -1226,6 +1260,7 @@ def dispatch_command(cmd: str) -> None: i += 1 else: i += 1 + graph_path = _resolve_graph_path(graph_path, explicit=graph_explicit) gp = Path(graph_path).resolve() if not gp.exists(): print(f"error: graph file not found: {gp}", file=sys.stderr) @@ -1263,6 +1298,7 @@ def dispatch_command(cmd: str) -> None: from graphify.analyze import god_nodes as _god_nodes from graphify.security import sanitize_label as _sanitize_label graph_path = _default_graph_path() + graph_explicit = False top_n = 10 as_json = "--json" in sys.argv args = sys.argv[2:] @@ -1270,9 +1306,11 @@ def dispatch_command(cmd: str) -> None: while i < len(args): if args[i] == "--graph" and i + 1 < len(args): graph_path = args[i + 1] + graph_explicit = True i += 2 elif args[i].startswith("--graph="): graph_path = args[i].split("=", 1)[1] + graph_explicit = True i += 1 elif args[i] == "--top" and i + 1 < len(args): try: @@ -1290,6 +1328,7 @@ def dispatch_command(cmd: str) -> None: i += 1 else: i += 1 + graph_path = _resolve_graph_path(graph_path, explicit=graph_explicit) gp = Path(graph_path).resolve() if not gp.exists(): print(f"error: graph file not found: {gp}", file=sys.stderr) @@ -1412,11 +1451,13 @@ def dispatch_command(cmd: str) -> None: source_label = sys.argv[2] target_label = sys.argv[3] graph_path = _default_graph_path() + graph_explicit = False args = sys.argv[4:] direction_flag = None for i, a in enumerate(args): if a == "--graph" and i + 1 < len(args): graph_path = args[i + 1] + graph_explicit = True elif a == "--directed": if direction_flag == "undirected": print( @@ -1437,6 +1478,7 @@ def dispatch_command(cmd: str) -> None: # graph.json (arc order on post-#563 files, _src/_tgt markers on legacy # canonicalized files), so respect it unless the caller opts out. undirected = direction_flag == "undirected" + graph_path = _resolve_graph_path(graph_path, explicit=graph_explicit) gp = Path(graph_path).resolve() if not gp.exists(): print(f"error: graph file not found: {gp}", file=sys.stderr) @@ -1573,10 +1615,13 @@ def dispatch_command(cmd: str) -> None: label = sys.argv[2] graph_path = _default_graph_path() + graph_explicit = False args = sys.argv[3:] for i, a in enumerate(args): if a == "--graph" and i + 1 < len(args): graph_path = args[i + 1] + graph_explicit = True + graph_path = _resolve_graph_path(graph_path, explicit=graph_explicit) gp = Path(graph_path).resolve() if not gp.exists(): print(f"error: graph file not found: {gp}", file=sys.stderr) diff --git a/graphify/paths.py b/graphify/paths.py index ba15b32cc..fd209721c 100644 --- a/graphify/paths.py +++ b/graphify/paths.py @@ -311,6 +311,61 @@ def default_graph_json() -> str: return str(out_path("graph.json")) +def _usable_graph_json(candidate: Path) -> bool: + """Whether *candidate* is a graph.json we can actually read. + + An output directory can exist while its graph.json is unusable: a dangling + junction/symlink (common when a worktree's ``graphify-out`` points at a + deleted sibling), a zero-byte file left by an interrupted write, or a file + the current user cannot read. Returning such a path from the ancestor walk + would replace "no graph found" with a confusing parse/permission failure, so + treat it as absent and keep walking. + """ + try: + st = candidate.stat() # follows links: a dangling link raises here + except OSError: + return False + if not stat.S_ISREG(st.st_mode) or st.st_size <= 0: + return False + return os.access(candidate, os.R_OK) + + +def find_graph_json_upward(start: "str | Path | None" = None) -> "Path | None": + """Nearest readable ``/graph.json`` at *start* or an ancestor. + + The read commands (``query``/``path``/``explain``/...) default to a graph + path relative to cwd, so running them from a subdirectory of the scan root — + a package subdir, a git worktree, anywhere below where the graph was built — + failed even though a perfectly good graph sat one or more levels up. Users + papered over this with a shell wrapper, which only ever worked in that one + shell. Resolving it here makes the behaviour native to every caller. + + Returns the first usable candidate walking *start* then its parents, or + ``None`` when the filesystem root is reached without a hit. The directory + searched for is ``GRAPHIFY_OUT`` itself, so a relative override + (``GRAPHIFY_OUT=graphify-out-feature``, or even a nested ``build/gout``) is + honoured exactly as ``out_path`` honours it against cwd. + + An ABSOLUTE ``GRAPHIFY_OUT`` disables the walk entirely (returns ``None``): + the override then names one fixed location rather than a per-root + convention, so every ancestor would resolve to the identical path and a hit + would say nothing about the caller's position in the tree. + """ + if Path(GRAPHIFY_OUT).is_absolute(): + return None + try: + base = Path(start).resolve() if start is not None else Path.cwd().resolve() + except OSError: + return None + # `parents` is finite and ends at the anchor, so this terminates at the + # filesystem root without a hand-rolled "did the parent change" guard. + for directory in (base, *base.parents): + candidate = Path(directory, GRAPHIFY_OUT, "graph.json") + if _usable_graph_json(candidate): + return candidate + return None + + def is_absolute_any_platform(p: "str | Path | None") -> bool: """Whether *p* is absolute under POSIX **or** Windows rules. From 69b452896786ccff8bf7bda4052f1a79cc50128e Mon Sep 17 00:00:00 2001 From: "martin.perry" Date: Thu, 27 Aug 2026 11:42:30 -0400 Subject: [PATCH 3/7] feat(explain): break label ties by the directory the caller is in `explain` resolved an ambiguous label by refusing to answer: when the winning match tier spanned several source files it listed them and exited 1. Indexing git worktrees beside their canonical repo makes that tie the normal case -- the same symbol legitimately exists as matching/Foo.py and matching-1234-ga-merge/Foo.py -- so the command failed even when standing in the worktree whose copy was obviously meant. Prefer the candidate whose top-level directory is the one cwd is in, derived from the graph's own scan root the way `affected` derives it. When cwd singles out nothing, warn on stderr naming every alternative and answer anyway, matching the "warning: ... was ambiguous" style `path` already uses. Ambiguity now travels on stderr so stdout stays parseable. The fallback picks the lowest source_file rather than the first match, so the answer stays stable under graph iteration order -- the ordering bug the previous hard failure existed to prevent is still covered, without making the command unusable. `deduplicate_by_label` is deliberately left off: it conflates same-label symbols across files, which is the opposite of the distinction being drawn here. Co-Authored-By: Claude Opus 5 (1M context) --- graphify/cli.py | 97 +++++++++++++++++++++++++++++++--- tests/test_explain_cli.py | 107 +++++++++++++++++++++++++++++++------- 2 files changed, 178 insertions(+), 26 deletions(-) diff --git a/graphify/cli.py b/graphify/cli.py index 561684244..d06f587d9 100644 --- a/graphify/cli.py +++ b/graphify/cli.py @@ -113,6 +113,90 @@ def _resolve_graph_path(graph_path: str, *, explicit: bool) -> str: return str(found) +def _top_level_segment(source_file: str) -> str: + """First path segment of a repo-relative ``source_file``, or "" if none. + + Both separators are accepted: ``source_file`` values are stored as they were + extracted and travel between machines, so a graph built on Windows can carry + backslashes into a POSIX reader. + """ + norm = str(source_file).replace("\\", "/").strip("/") + return norm.split("/", 1)[0] if norm else "" + + +def _cwd_scope_segment(graph_path: Path) -> str: + """Top-level directory of cwd relative to the graph's scan root, or "". + + Indexing git worktrees alongside their canonical repo puts the same symbol in + the graph twice under different top-level directories (``matching/Foo.cs`` and + ``matching-1234-ga-merge/Foo.cs``). Where the caller is standing is the only + signal that says which one they mean, so surface it as the segment to match. + + Returns "" when cwd is outside the scan root (or is the root itself), which + leaves the caller with no preference rather than a wrong one. + """ + from graphify.paths import GRAPHIFY_OUT_NAME + # The graph lives at //graph.json, so the scan root is + # the output dir's parent; a --graph pointed straight at a file falls back to + # its own directory. Same derivation as `affected` (#2706). + root = ( + graph_path.parent.parent + if graph_path.parent.name == GRAPHIFY_OUT_NAME + else graph_path.parent + ) + try: + rel = Path.cwd().resolve().relative_to(root.resolve()) + except (OSError, ValueError): + return "" + return rel.parts[0] if rel.parts else "" + + +def _disambiguate_explain_match(G, label: str, matches: list, rivals: list, + graph_path: Path) -> str: + """Choose one node when several source files define ``label``. + + Prefers the candidate whose top-level directory is the one the caller is + standing in, so `explain` inside a worktree answers about that worktree + instead of its canonical repo. When cwd does not single one out, warn on + stderr naming every alternative and proceed: erroring out would make the + command unusable once worktrees are indexed, and a silent pick would hide + in-flight branch edits behind an equally confident answer. + + The fallback picks the lowest ``source_file`` rather than the first match, so + the answer is stable under graph iteration order (the ordering bug that made + the same query report a different file on a reordered graph). + """ + by_source: dict[str, str] = {} + for rid in rivals: + by_source.setdefault(str(G.nodes[rid].get("source_file") or ""), rid) + + def _first_match_in(source: str) -> str: + # Keep within-file precedence (a file node ahead of its members) by + # re-using the ranked `matches` order rather than the representative. + for mid in matches: + if str(G.nodes[mid].get("source_file") or "") == source: + return mid + return by_source[source] + + scope = _cwd_scope_segment(graph_path) + if scope: + local = [s for s in by_source if _top_level_segment(s) == scope] + if len(local) == 1: + return _first_match_in(local[0]) + + chosen = min(by_source) + print( + f"warning: '{label}' match was ambiguous " + f"({len(by_source)} nodes in different files); using {chosen}", + file=sys.stderr, + ) + for source in sorted(by_source): + if source != chosen: + print(f"warning: alternative: {source} (id: {by_source[source]})", + file=sys.stderr) + return _first_match_in(chosen) + + def _stamped_manifest_files( files_by_type: dict[str, list[str]], sem_result: dict, @@ -1641,14 +1725,11 @@ def dispatch_command(cmd: str) -> None: print(f"No node matching '{label}' found.") sys.exit(0) rivals = find_node_ambiguity(G, label) - if rivals: - print(f"Ambiguous: '{label}' matches {len(rivals)} nodes in different files.") - for rival in rivals: - print(f" {G.nodes[rival].get('source_file') or rival}") - print(f" id: {rival}") - print("Retry with the repo-relative path or the full node id.") - sys.exit(1) - nid = matches[0] + nid = ( + _disambiguate_explain_match(G, label, matches, rivals, gp) + if rivals + else matches[0] + ) d = G.nodes[nid] print(f"Node: {d.get('label', nid)}") print(f" ID: {nid}") diff --git a/tests/test_explain_cli.py b/tests/test_explain_cli.py index 60b3e626e..669cd98fb 100644 --- a/tests/test_explain_cli.py +++ b/tests/test_explain_cli.py @@ -265,40 +265,111 @@ def _write_ambiguous_graph(tmp_path, *, reverse: bool = False): return p -def _run_expect_exit(monkeypatch, graph_path, label, capsys): +def _run_capture(monkeypatch, graph_path, label, capsys): + """Run explain, returning (stdout, stderr). Ambiguity is reported on stderr.""" monkeypatch.setattr(mainmod, "_check_skill_version", lambda _: None) monkeypatch.setattr(mainmod.sys, "argv", ["graphify", "explain", label, "--graph", str(graph_path)]) try: mainmod.main() - except SystemExit as exc: - return capsys.readouterr().out, exc.code - return capsys.readouterr().out, None + except SystemExit: + pass + captured = capsys.readouterr() + return captured.out, captured.err -def test_explain_ambiguous_label_lists_every_candidate(monkeypatch, tmp_path, capsys): +def test_explain_ambiguous_label_warns_naming_every_candidate(monkeypatch, tmp_path, capsys): + """An unresolvable tie must name every candidate and still answer. + + Erroring out instead would make `explain` unusable wherever ambiguity is the + normal case (worktrees indexed beside their canonical repo), so the contract + is: warn on stderr, answer on stdout. + """ p = _write_ambiguous_graph(tmp_path) - out, code = _run_expect_exit(monkeypatch, p, "MetricsPort", capsys) - assert "Ambiguous" in out - assert "services/chat/src/application/ports/metrics.port.ts" in out - assert "services/scraping/src/application/ports/metrics.port.ts" in out - assert code == 1 - # It must not present one file as the answer. - assert "Node: MetricsPort\n ID:" not in out + out, err = _run_capture(monkeypatch, p, "MetricsPort", capsys) + assert "was ambiguous" in err + assert "services/chat/src/application/ports/metrics.port.ts" in err + assert "services/scraping/src/application/ports/metrics.port.ts" in err + # The answer still comes, and the ambiguity never leaks into stdout. + assert "Node: MetricsPort" in out + assert "ambiguous" not in out.lower() def test_explain_ambiguous_answer_does_not_depend_on_node_order( monkeypatch, tmp_path, capsys ): """The bug: reversing node order flipped which file was reported as fact.""" - forward, _ = _run_expect_exit( + forward_out, forward_err = _run_capture( monkeypatch, _write_ambiguous_graph(tmp_path), "MetricsPort", capsys) - reverse, _ = _run_expect_exit( + reverse_out, reverse_err = _run_capture( monkeypatch, _write_ambiguous_graph(tmp_path, reverse=True), "MetricsPort", capsys) - assert "Ambiguous" in forward and "Ambiguous" in reverse - # Same candidate set either way, regardless of iteration order. - assert sorted(l.strip() for l in forward.splitlines() if "metrics.port.ts" in l) == \ - sorted(l.strip() for l in reverse.splitlines() if "metrics.port.ts" in l) + # Same node chosen and same alternatives named, regardless of iteration order. + assert [l for l in forward_out.splitlines() if l.startswith(" ID:")] == \ + [l for l in reverse_out.splitlines() if l.startswith(" ID:")] + assert sorted(l.strip() for l in forward_err.splitlines() if "metrics.port.ts" in l) == \ + sorted(l.strip() for l in reverse_err.splitlines() if "metrics.port.ts" in l) + + +# --- cwd tie-break: worktree indexed beside its canonical repo --------------- + + +def _write_worktree_graph(tmp_path): + """Same symbol in two sibling TOP-LEVEL dirs: a repo and one of its worktrees. + + The graph is written where a real build puts it (/graphify-out/graph.json) + so the scan root is derived the same way the CLI derives it. + """ + out_dir = tmp_path / "graphify-out" + out_dir.mkdir() + graph_data = { + "directed": False, "multigraph": False, "graph": {}, + "nodes": [ + {"id": "canonical", "label": "shared_handler", + "source_file": "matching/svc.py", "community": 0}, + {"id": "worktree", "label": "shared_handler", + "source_file": "matching-156294-ga-merge/svc.py", "community": 0}, + ], + "links": [], + } + p = out_dir / "graph.json" + p.write_text(json.dumps(graph_data)) + for sub in ("matching", "matching-156294-ga-merge"): + (tmp_path / sub).mkdir() + return p + + +def test_explain_prefers_the_top_level_dir_the_caller_is_standing_in( + monkeypatch, tmp_path, capsys +): + """Inside a worktree, `explain` must answer about THAT worktree, silently.""" + p = _write_worktree_graph(tmp_path) + monkeypatch.chdir(tmp_path / "matching-156294-ga-merge") + out, err = _run_capture(monkeypatch, p, "shared_handler", capsys) + assert "ID: worktree" in out + assert "matching-156294-ga-merge/svc.py" in out + # cwd resolved it, so there is nothing ambiguous left to report. + assert "was ambiguous" not in err + + +def test_explain_cwd_tie_break_picks_the_canonical_repo_from_inside_it( + monkeypatch, tmp_path, capsys +): + """Mirror case: the tie-break follows cwd, it does not favour one name.""" + p = _write_worktree_graph(tmp_path) + monkeypatch.chdir(tmp_path / "matching") + out, err = _run_capture(monkeypatch, p, "shared_handler", capsys) + assert "ID: canonical" in out + assert "was ambiguous" not in err + + +def test_explain_warns_when_cwd_is_outside_every_candidate(monkeypatch, tmp_path, capsys): + """At the scan root, cwd singles out nothing -> warn and still answer.""" + p = _write_worktree_graph(tmp_path) + monkeypatch.chdir(tmp_path) + out, err = _run_capture(monkeypatch, p, "shared_handler", capsys) + assert "was ambiguous" in err + assert "matching/svc.py" in err + assert "Node: shared_handler" in out def test_explain_matches_within_one_file_are_not_ambiguous(monkeypatch, tmp_path, capsys): From f60bd3264614f4abac09adb6fed0d1bd6417da49 Mon Sep 17 00:00:00 2001 From: "martin.perry" Date: Thu, 27 Aug 2026 17:18:38 -0400 Subject: [PATCH 4/7] fix(paths): resolve an ancestor graph for non-CLI callers too default_graph_json is documented as the package-wide fallback, but only the CLI walked up to an ancestor graph; serve, build, prs and benchmark got a cwd-relative path that does not exist outside the scan root. The MCP server inherits the client's cwd, which is routinely a git worktree, so it failed where the CLI succeeded from the identical directory. Co-Authored-By: Claude Opus 5 (1M context) --- graphify/paths.py | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/graphify/paths.py b/graphify/paths.py index fd209721c..3214f909f 100644 --- a/graphify/paths.py +++ b/graphify/paths.py @@ -307,8 +307,24 @@ def default_graph_json() -> str: The package-wide fallback used by serve/build/benchmark/prs and the CLI read commands so a ``GRAPHIFY_OUT`` override is honoured everywhere, not just where the path is passed explicitly (#1423). + + When no graph exists at cwd, fall back to the nearest one in an ancestor. + Read-only callers reach this from wherever the user happens to be standing — + the MCP server inherits the client's cwd, which is routinely a git worktree + or a package subdirectory rather than the scan root — and returning a + non-existent relative path there makes them fail while the CLI, which + resolves upward separately, succeeds from the identical directory. + + Only an existing ancestor graph substitutes. With no graph anywhere up the + tree the cwd-relative path is returned unchanged, so a caller that creates + the file (rather than reading it) still writes where it always did. """ - return str(out_path("graph.json")) + direct = out_path("graph.json") + if not Path(direct).is_file(): + found = find_graph_json_upward() + if found is not None: + return str(found) + return str(direct) def _usable_graph_json(candidate: Path) -> bool: From 14a85002b0156a504b06c78e99335eb0c6e3fef7 Mon Sep 17 00:00:00 2001 From: "martin.perry" Date: Thu, 27 Aug 2026 21:49:27 -0400 Subject: [PATCH 5/7] fix(security): raise the graph.json size cap to 2 GiB A multi-root workspace graph covering many repositories plus the worktrees under active development runs well past 512 MiB. Tripping the cap fails both the CLI and the MCP server with "could not load graph.json", which reads as corruption rather than as a configurable limit. The cap guards against absurd input, not against a legitimate graph. GRAPHIFY_MAX_GRAPH_BYTES still overrides. It is not a sufficient answer on its own: a machine environment variable does not reach processes that are already running, so the stack stays broken until every client is restarted. Co-Authored-By: Claude Opus 5 (1M context) --- graphify/security.py | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/graphify/security.py b/graphify/security.py index 2dbe5bd77..0f729e987 100644 --- a/graphify/security.py +++ b/graphify/security.py @@ -29,7 +29,17 @@ # discoverable and so existing callers/tests that reference it directly keep # working; the effective cap is resolved at call time by # ``_max_graph_file_bytes`` (which lets ``GRAPHIFY_MAX_GRAPH_BYTES`` override it). -_MAX_GRAPH_FILE_BYTES = 512 * 1024 * 1024 # 512 MiB +# 512 MiB was comfortable when a graph covered one repository. A multi-root +# workspace graph is routinely larger — indexing 23 repos plus the git worktrees +# actively being worked in produces ~840 MB here — and the cap is a guard against +# absurd input, not a statement about what a legitimate graph may weigh. Tripping +# it fails BOTH the CLI and the MCP server with "could not load graph.json", +# which reads as corruption rather than as a configurable limit. +# +# Relying on GRAPHIFY_MAX_GRAPH_BYTES instead is worse than it looks: a machine +# env var is not picked up by processes already running, so the stack stays +# broken until every client is restarted. +_MAX_GRAPH_FILE_BYTES = 2 * 1024 * 1024 * 1024 # 2 GiB def _max_graph_file_bytes() -> int: From a6c3df5afbc5da8d721502ce87ad0f5f84fa29ee Mon Sep 17 00:00:00 2001 From: "martin.perry" Date: Fri, 28 Aug 2026 09:09:56 -0400 Subject: [PATCH 6/7] docs(skill): the graph may live above cwd, not in it The fast-path check told agents to look for graphify-out/graph.json "relative to the current working directory". In a git worktree or any nested subdirectory that file is absent while a perfectly good graph sits one or more levels up, so an agent following the skill concludes no graph exists and falls back to grep or to rebuilding one. The read commands already resolve upward and report which graph they used, so the reliable check is to run one rather than to stat a fixed path. Applied to every client variant, since the mistake is identical in all of them. Co-Authored-By: Claude Opus 5 (1M context) --- graphify/skill-agents.md | 2 +- graphify/skill-amp.md | 2 +- graphify/skill-claw.md | 2 +- graphify/skill-codex.md | 2 +- graphify/skill-copilot.md | 2 +- graphify/skill-droid.md | 2 +- graphify/skill-kilo.md | 2 +- graphify/skill-kiro.md | 2 +- graphify/skill-opencode.md | 2 +- graphify/skill-pi.md | 2 +- graphify/skill-trae.md | 2 +- graphify/skill-vscode.md | 2 +- graphify/skill-windows.md | 2 +- graphify/skill.md | 2 +- 14 files changed, 14 insertions(+), 14 deletions(-) diff --git a/graphify/skill-agents.md b/graphify/skill-agents.md index 190827d9a..cbf0db69d 100644 --- a/graphify/skill-agents.md +++ b/graphify/skill-agents.md @@ -50,7 +50,7 @@ Drop any folder of code, docs, papers, images, or video into graphify and get a If the user invoked `/graphify --help` or `/graphify -h` (with no other arguments), print the contents of the `## Usage` section above verbatim and stop. Do not run any commands, do not detect files, do not default the path to `.`. Just print the Usage block and return. -**Fast path — existing graph:** Before doing anything else, check whether `graphify-out/graph.json` exists. The expected location is `graphify-out/graph.json` relative to the **current working directory** (i.e. the project root where you are running commands). If it exists AND the user's request is a natural-language question about the codebase (e.g. "How does X work?", "What calls Y?", "Trace the data flow through Z") and NOT an explicit rebuild command (`--update`, `--cluster-only`, or a bare path/URL that implies fresh extraction): **skip Steps 1–5 entirely and jump straight to `## For /graphify query`.** Run `graphify query ""` immediately. Do not run detect. Do not check corpus size. Do not ask the user to narrow. The graph is already built — use it. +**Fast path — existing graph:** Before doing anything else, check whether `graphify-out/graph.json` exists. The graph lives in `graphify-out/graph.json` at the **scan root** — the directory the graph was built for. That is not necessarily your current directory: in a git worktree, a package subdirectory, or any nested folder, the graph sits one or more levels UP. Do not conclude there is no graph just because the file is absent from cwd. The read commands (`query`/`path`/`explain`) resolve it themselves by walking up to the nearest ancestor that has one, and print which graph they used, so the reliable check is simply to RUN one and read its output. If it exists AND the user's request is a natural-language question about the codebase (e.g. "How does X work?", "What calls Y?", "Trace the data flow through Z") and NOT an explicit rebuild command (`--update`, `--cluster-only`, or a bare path/URL that implies fresh extraction): **skip Steps 1–5 entirely and jump straight to `## For /graphify query`.** Run `graphify query ""` immediately. Do not run detect. Do not check corpus size. Do not ask the user to narrow. The graph is already built — use it. If no path was given, use `.` (current directory). Do not ask the user for a path. diff --git a/graphify/skill-amp.md b/graphify/skill-amp.md index 190827d9a..cbf0db69d 100644 --- a/graphify/skill-amp.md +++ b/graphify/skill-amp.md @@ -50,7 +50,7 @@ Drop any folder of code, docs, papers, images, or video into graphify and get a If the user invoked `/graphify --help` or `/graphify -h` (with no other arguments), print the contents of the `## Usage` section above verbatim and stop. Do not run any commands, do not detect files, do not default the path to `.`. Just print the Usage block and return. -**Fast path — existing graph:** Before doing anything else, check whether `graphify-out/graph.json` exists. The expected location is `graphify-out/graph.json` relative to the **current working directory** (i.e. the project root where you are running commands). If it exists AND the user's request is a natural-language question about the codebase (e.g. "How does X work?", "What calls Y?", "Trace the data flow through Z") and NOT an explicit rebuild command (`--update`, `--cluster-only`, or a bare path/URL that implies fresh extraction): **skip Steps 1–5 entirely and jump straight to `## For /graphify query`.** Run `graphify query ""` immediately. Do not run detect. Do not check corpus size. Do not ask the user to narrow. The graph is already built — use it. +**Fast path — existing graph:** Before doing anything else, check whether `graphify-out/graph.json` exists. The graph lives in `graphify-out/graph.json` at the **scan root** — the directory the graph was built for. That is not necessarily your current directory: in a git worktree, a package subdirectory, or any nested folder, the graph sits one or more levels UP. Do not conclude there is no graph just because the file is absent from cwd. The read commands (`query`/`path`/`explain`) resolve it themselves by walking up to the nearest ancestor that has one, and print which graph they used, so the reliable check is simply to RUN one and read its output. If it exists AND the user's request is a natural-language question about the codebase (e.g. "How does X work?", "What calls Y?", "Trace the data flow through Z") and NOT an explicit rebuild command (`--update`, `--cluster-only`, or a bare path/URL that implies fresh extraction): **skip Steps 1–5 entirely and jump straight to `## For /graphify query`.** Run `graphify query ""` immediately. Do not run detect. Do not check corpus size. Do not ask the user to narrow. The graph is already built — use it. If no path was given, use `.` (current directory). Do not ask the user for a path. diff --git a/graphify/skill-claw.md b/graphify/skill-claw.md index abd2811d2..c877b00f7 100644 --- a/graphify/skill-claw.md +++ b/graphify/skill-claw.md @@ -50,7 +50,7 @@ Drop any folder of code, docs, papers, images, or video into graphify and get a If the user invoked `/graphify --help` or `/graphify -h` (with no other arguments), print the contents of the `## Usage` section above verbatim and stop. Do not run any commands, do not detect files, do not default the path to `.`. Just print the Usage block and return. -**Fast path — existing graph:** Before doing anything else, check whether `graphify-out/graph.json` exists. The expected location is `graphify-out/graph.json` relative to the **current working directory** (i.e. the project root where you are running commands). If it exists AND the user's request is a natural-language question about the codebase (e.g. "How does X work?", "What calls Y?", "Trace the data flow through Z") and NOT an explicit rebuild command (`--update`, `--cluster-only`, or a bare path/URL that implies fresh extraction): **skip Steps 1–5 entirely and jump straight to `## For /graphify query`.** Run `graphify query ""` immediately. Do not run detect. Do not check corpus size. Do not ask the user to narrow. The graph is already built — use it. +**Fast path — existing graph:** Before doing anything else, check whether `graphify-out/graph.json` exists. The graph lives in `graphify-out/graph.json` at the **scan root** — the directory the graph was built for. That is not necessarily your current directory: in a git worktree, a package subdirectory, or any nested folder, the graph sits one or more levels UP. Do not conclude there is no graph just because the file is absent from cwd. The read commands (`query`/`path`/`explain`) resolve it themselves by walking up to the nearest ancestor that has one, and print which graph they used, so the reliable check is simply to RUN one and read its output. If it exists AND the user's request is a natural-language question about the codebase (e.g. "How does X work?", "What calls Y?", "Trace the data flow through Z") and NOT an explicit rebuild command (`--update`, `--cluster-only`, or a bare path/URL that implies fresh extraction): **skip Steps 1–5 entirely and jump straight to `## For /graphify query`.** Run `graphify query ""` immediately. Do not run detect. Do not check corpus size. Do not ask the user to narrow. The graph is already built — use it. If no path was given, use `.` (current directory). Do not ask the user for a path. diff --git a/graphify/skill-codex.md b/graphify/skill-codex.md index af3f723c7..a441a15f1 100644 --- a/graphify/skill-codex.md +++ b/graphify/skill-codex.md @@ -50,7 +50,7 @@ Drop any folder of code, docs, papers, images, or video into graphify and get a If the user invoked `/graphify --help` or `/graphify -h` (with no other arguments), print the contents of the `## Usage` section above verbatim and stop. Do not run any commands, do not detect files, do not default the path to `.`. Just print the Usage block and return. -**Fast path — existing graph:** Before doing anything else, check whether `graphify-out/graph.json` exists. The expected location is `graphify-out/graph.json` relative to the **current working directory** (i.e. the project root where you are running commands). If it exists AND the user's request is a natural-language question about the codebase (e.g. "How does X work?", "What calls Y?", "Trace the data flow through Z") and NOT an explicit rebuild command (`--update`, `--cluster-only`, or a bare path/URL that implies fresh extraction): **skip Steps 1–5 entirely and jump straight to `## For /graphify query`.** Run `graphify query ""` immediately. Do not run detect. Do not check corpus size. Do not ask the user to narrow. The graph is already built — use it. +**Fast path — existing graph:** Before doing anything else, check whether `graphify-out/graph.json` exists. The graph lives in `graphify-out/graph.json` at the **scan root** — the directory the graph was built for. That is not necessarily your current directory: in a git worktree, a package subdirectory, or any nested folder, the graph sits one or more levels UP. Do not conclude there is no graph just because the file is absent from cwd. The read commands (`query`/`path`/`explain`) resolve it themselves by walking up to the nearest ancestor that has one, and print which graph they used, so the reliable check is simply to RUN one and read its output. If it exists AND the user's request is a natural-language question about the codebase (e.g. "How does X work?", "What calls Y?", "Trace the data flow through Z") and NOT an explicit rebuild command (`--update`, `--cluster-only`, or a bare path/URL that implies fresh extraction): **skip Steps 1–5 entirely and jump straight to `## For /graphify query`.** Run `graphify query ""` immediately. Do not run detect. Do not check corpus size. Do not ask the user to narrow. The graph is already built — use it. If no path was given, use `.` (current directory). Do not ask the user for a path. diff --git a/graphify/skill-copilot.md b/graphify/skill-copilot.md index abd2811d2..c877b00f7 100644 --- a/graphify/skill-copilot.md +++ b/graphify/skill-copilot.md @@ -50,7 +50,7 @@ Drop any folder of code, docs, papers, images, or video into graphify and get a If the user invoked `/graphify --help` or `/graphify -h` (with no other arguments), print the contents of the `## Usage` section above verbatim and stop. Do not run any commands, do not detect files, do not default the path to `.`. Just print the Usage block and return. -**Fast path — existing graph:** Before doing anything else, check whether `graphify-out/graph.json` exists. The expected location is `graphify-out/graph.json` relative to the **current working directory** (i.e. the project root where you are running commands). If it exists AND the user's request is a natural-language question about the codebase (e.g. "How does X work?", "What calls Y?", "Trace the data flow through Z") and NOT an explicit rebuild command (`--update`, `--cluster-only`, or a bare path/URL that implies fresh extraction): **skip Steps 1–5 entirely and jump straight to `## For /graphify query`.** Run `graphify query ""` immediately. Do not run detect. Do not check corpus size. Do not ask the user to narrow. The graph is already built — use it. +**Fast path — existing graph:** Before doing anything else, check whether `graphify-out/graph.json` exists. The graph lives in `graphify-out/graph.json` at the **scan root** — the directory the graph was built for. That is not necessarily your current directory: in a git worktree, a package subdirectory, or any nested folder, the graph sits one or more levels UP. Do not conclude there is no graph just because the file is absent from cwd. The read commands (`query`/`path`/`explain`) resolve it themselves by walking up to the nearest ancestor that has one, and print which graph they used, so the reliable check is simply to RUN one and read its output. If it exists AND the user's request is a natural-language question about the codebase (e.g. "How does X work?", "What calls Y?", "Trace the data flow through Z") and NOT an explicit rebuild command (`--update`, `--cluster-only`, or a bare path/URL that implies fresh extraction): **skip Steps 1–5 entirely and jump straight to `## For /graphify query`.** Run `graphify query ""` immediately. Do not run detect. Do not check corpus size. Do not ask the user to narrow. The graph is already built — use it. If no path was given, use `.` (current directory). Do not ask the user for a path. diff --git a/graphify/skill-droid.md b/graphify/skill-droid.md index fd148d485..fa1c6befe 100644 --- a/graphify/skill-droid.md +++ b/graphify/skill-droid.md @@ -50,7 +50,7 @@ Drop any folder of code, docs, papers, images, or video into graphify and get a If the user invoked `/graphify --help` or `/graphify -h` (with no other arguments), print the contents of the `## Usage` section above verbatim and stop. Do not run any commands, do not detect files, do not default the path to `.`. Just print the Usage block and return. -**Fast path — existing graph:** Before doing anything else, check whether `graphify-out/graph.json` exists. The expected location is `graphify-out/graph.json` relative to the **current working directory** (i.e. the project root where you are running commands). If it exists AND the user's request is a natural-language question about the codebase (e.g. "How does X work?", "What calls Y?", "Trace the data flow through Z") and NOT an explicit rebuild command (`--update`, `--cluster-only`, or a bare path/URL that implies fresh extraction): **skip Steps 1–5 entirely and jump straight to `## For /graphify query`.** Run `graphify query ""` immediately. Do not run detect. Do not check corpus size. Do not ask the user to narrow. The graph is already built — use it. +**Fast path — existing graph:** Before doing anything else, check whether `graphify-out/graph.json` exists. The graph lives in `graphify-out/graph.json` at the **scan root** — the directory the graph was built for. That is not necessarily your current directory: in a git worktree, a package subdirectory, or any nested folder, the graph sits one or more levels UP. Do not conclude there is no graph just because the file is absent from cwd. The read commands (`query`/`path`/`explain`) resolve it themselves by walking up to the nearest ancestor that has one, and print which graph they used, so the reliable check is simply to RUN one and read its output. If it exists AND the user's request is a natural-language question about the codebase (e.g. "How does X work?", "What calls Y?", "Trace the data flow through Z") and NOT an explicit rebuild command (`--update`, `--cluster-only`, or a bare path/URL that implies fresh extraction): **skip Steps 1–5 entirely and jump straight to `## For /graphify query`.** Run `graphify query ""` immediately. Do not run detect. Do not check corpus size. Do not ask the user to narrow. The graph is already built — use it. If no path was given, use `.` (current directory). Do not ask the user for a path. diff --git a/graphify/skill-kilo.md b/graphify/skill-kilo.md index 3e70b050a..43c2134ed 100644 --- a/graphify/skill-kilo.md +++ b/graphify/skill-kilo.md @@ -50,7 +50,7 @@ Drop any folder of code, docs, papers, images, or video into graphify and get a If the user invoked `/graphify --help` or `/graphify -h` (with no other arguments), print the contents of the `## Usage` section above verbatim and stop. Do not run any commands, do not detect files, do not default the path to `.`. Just print the Usage block and return. -**Fast path — existing graph:** Before doing anything else, check whether `graphify-out/graph.json` exists. The expected location is `graphify-out/graph.json` relative to the **current working directory** (i.e. the project root where you are running commands). If it exists AND the user's request is a natural-language question about the codebase (e.g. "How does X work?", "What calls Y?", "Trace the data flow through Z") and NOT an explicit rebuild command (`--update`, `--cluster-only`, or a bare path/URL that implies fresh extraction): **skip Steps 1–5 entirely and jump straight to `## For /graphify query`.** Run `graphify query ""` immediately. Do not run detect. Do not check corpus size. Do not ask the user to narrow. The graph is already built — use it. +**Fast path — existing graph:** Before doing anything else, check whether `graphify-out/graph.json` exists. The graph lives in `graphify-out/graph.json` at the **scan root** — the directory the graph was built for. That is not necessarily your current directory: in a git worktree, a package subdirectory, or any nested folder, the graph sits one or more levels UP. Do not conclude there is no graph just because the file is absent from cwd. The read commands (`query`/`path`/`explain`) resolve it themselves by walking up to the nearest ancestor that has one, and print which graph they used, so the reliable check is simply to RUN one and read its output. If it exists AND the user's request is a natural-language question about the codebase (e.g. "How does X work?", "What calls Y?", "Trace the data flow through Z") and NOT an explicit rebuild command (`--update`, `--cluster-only`, or a bare path/URL that implies fresh extraction): **skip Steps 1–5 entirely and jump straight to `## For /graphify query`.** Run `graphify query ""` immediately. Do not run detect. Do not check corpus size. Do not ask the user to narrow. The graph is already built — use it. If no path was given, use `.` (current directory). Do not ask the user for a path. diff --git a/graphify/skill-kiro.md b/graphify/skill-kiro.md index abd2811d2..c877b00f7 100644 --- a/graphify/skill-kiro.md +++ b/graphify/skill-kiro.md @@ -50,7 +50,7 @@ Drop any folder of code, docs, papers, images, or video into graphify and get a If the user invoked `/graphify --help` or `/graphify -h` (with no other arguments), print the contents of the `## Usage` section above verbatim and stop. Do not run any commands, do not detect files, do not default the path to `.`. Just print the Usage block and return. -**Fast path — existing graph:** Before doing anything else, check whether `graphify-out/graph.json` exists. The expected location is `graphify-out/graph.json` relative to the **current working directory** (i.e. the project root where you are running commands). If it exists AND the user's request is a natural-language question about the codebase (e.g. "How does X work?", "What calls Y?", "Trace the data flow through Z") and NOT an explicit rebuild command (`--update`, `--cluster-only`, or a bare path/URL that implies fresh extraction): **skip Steps 1–5 entirely and jump straight to `## For /graphify query`.** Run `graphify query ""` immediately. Do not run detect. Do not check corpus size. Do not ask the user to narrow. The graph is already built — use it. +**Fast path — existing graph:** Before doing anything else, check whether `graphify-out/graph.json` exists. The graph lives in `graphify-out/graph.json` at the **scan root** — the directory the graph was built for. That is not necessarily your current directory: in a git worktree, a package subdirectory, or any nested folder, the graph sits one or more levels UP. Do not conclude there is no graph just because the file is absent from cwd. The read commands (`query`/`path`/`explain`) resolve it themselves by walking up to the nearest ancestor that has one, and print which graph they used, so the reliable check is simply to RUN one and read its output. If it exists AND the user's request is a natural-language question about the codebase (e.g. "How does X work?", "What calls Y?", "Trace the data flow through Z") and NOT an explicit rebuild command (`--update`, `--cluster-only`, or a bare path/URL that implies fresh extraction): **skip Steps 1–5 entirely and jump straight to `## For /graphify query`.** Run `graphify query ""` immediately. Do not run detect. Do not check corpus size. Do not ask the user to narrow. The graph is already built — use it. If no path was given, use `.` (current directory). Do not ask the user for a path. diff --git a/graphify/skill-opencode.md b/graphify/skill-opencode.md index 91ced6067..4ba27681f 100644 --- a/graphify/skill-opencode.md +++ b/graphify/skill-opencode.md @@ -50,7 +50,7 @@ Drop any folder of code, docs, papers, images, or video into graphify and get a If the user invoked `/graphify --help` or `/graphify -h` (with no other arguments), print the contents of the `## Usage` section above verbatim and stop. Do not run any commands, do not detect files, do not default the path to `.`. Just print the Usage block and return. -**Fast path — existing graph:** Before doing anything else, check whether `graphify-out/graph.json` exists. The expected location is `graphify-out/graph.json` relative to the **current working directory** (i.e. the project root where you are running commands). If it exists AND the user's request is a natural-language question about the codebase (e.g. "How does X work?", "What calls Y?", "Trace the data flow through Z") and NOT an explicit rebuild command (`--update`, `--cluster-only`, or a bare path/URL that implies fresh extraction): **skip Steps 1–5 entirely and jump straight to `## For /graphify query`.** Run `graphify query ""` immediately. Do not run detect. Do not check corpus size. Do not ask the user to narrow. The graph is already built — use it. +**Fast path — existing graph:** Before doing anything else, check whether `graphify-out/graph.json` exists. The graph lives in `graphify-out/graph.json` at the **scan root** — the directory the graph was built for. That is not necessarily your current directory: in a git worktree, a package subdirectory, or any nested folder, the graph sits one or more levels UP. Do not conclude there is no graph just because the file is absent from cwd. The read commands (`query`/`path`/`explain`) resolve it themselves by walking up to the nearest ancestor that has one, and print which graph they used, so the reliable check is simply to RUN one and read its output. If it exists AND the user's request is a natural-language question about the codebase (e.g. "How does X work?", "What calls Y?", "Trace the data flow through Z") and NOT an explicit rebuild command (`--update`, `--cluster-only`, or a bare path/URL that implies fresh extraction): **skip Steps 1–5 entirely and jump straight to `## For /graphify query`.** Run `graphify query ""` immediately. Do not run detect. Do not check corpus size. Do not ask the user to narrow. The graph is already built — use it. If no path was given, use `.` (current directory). Do not ask the user for a path. diff --git a/graphify/skill-pi.md b/graphify/skill-pi.md index abd2811d2..c877b00f7 100644 --- a/graphify/skill-pi.md +++ b/graphify/skill-pi.md @@ -50,7 +50,7 @@ Drop any folder of code, docs, papers, images, or video into graphify and get a If the user invoked `/graphify --help` or `/graphify -h` (with no other arguments), print the contents of the `## Usage` section above verbatim and stop. Do not run any commands, do not detect files, do not default the path to `.`. Just print the Usage block and return. -**Fast path — existing graph:** Before doing anything else, check whether `graphify-out/graph.json` exists. The expected location is `graphify-out/graph.json` relative to the **current working directory** (i.e. the project root where you are running commands). If it exists AND the user's request is a natural-language question about the codebase (e.g. "How does X work?", "What calls Y?", "Trace the data flow through Z") and NOT an explicit rebuild command (`--update`, `--cluster-only`, or a bare path/URL that implies fresh extraction): **skip Steps 1–5 entirely and jump straight to `## For /graphify query`.** Run `graphify query ""` immediately. Do not run detect. Do not check corpus size. Do not ask the user to narrow. The graph is already built — use it. +**Fast path — existing graph:** Before doing anything else, check whether `graphify-out/graph.json` exists. The graph lives in `graphify-out/graph.json` at the **scan root** — the directory the graph was built for. That is not necessarily your current directory: in a git worktree, a package subdirectory, or any nested folder, the graph sits one or more levels UP. Do not conclude there is no graph just because the file is absent from cwd. The read commands (`query`/`path`/`explain`) resolve it themselves by walking up to the nearest ancestor that has one, and print which graph they used, so the reliable check is simply to RUN one and read its output. If it exists AND the user's request is a natural-language question about the codebase (e.g. "How does X work?", "What calls Y?", "Trace the data flow through Z") and NOT an explicit rebuild command (`--update`, `--cluster-only`, or a bare path/URL that implies fresh extraction): **skip Steps 1–5 entirely and jump straight to `## For /graphify query`.** Run `graphify query ""` immediately. Do not run detect. Do not check corpus size. Do not ask the user to narrow. The graph is already built — use it. If no path was given, use `.` (current directory). Do not ask the user for a path. diff --git a/graphify/skill-trae.md b/graphify/skill-trae.md index 050667bc2..1e2f485ac 100644 --- a/graphify/skill-trae.md +++ b/graphify/skill-trae.md @@ -50,7 +50,7 @@ Drop any folder of code, docs, papers, images, or video into graphify and get a If the user invoked `/graphify --help` or `/graphify -h` (with no other arguments), print the contents of the `## Usage` section above verbatim and stop. Do not run any commands, do not detect files, do not default the path to `.`. Just print the Usage block and return. -**Fast path — existing graph:** Before doing anything else, check whether `graphify-out/graph.json` exists. The expected location is `graphify-out/graph.json` relative to the **current working directory** (i.e. the project root where you are running commands). If it exists AND the user's request is a natural-language question about the codebase (e.g. "How does X work?", "What calls Y?", "Trace the data flow through Z") and NOT an explicit rebuild command (`--update`, `--cluster-only`, or a bare path/URL that implies fresh extraction): **skip Steps 1–5 entirely and jump straight to `## For /graphify query`.** Run `graphify query ""` immediately. Do not run detect. Do not check corpus size. Do not ask the user to narrow. The graph is already built — use it. +**Fast path — existing graph:** Before doing anything else, check whether `graphify-out/graph.json` exists. The graph lives in `graphify-out/graph.json` at the **scan root** — the directory the graph was built for. That is not necessarily your current directory: in a git worktree, a package subdirectory, or any nested folder, the graph sits one or more levels UP. Do not conclude there is no graph just because the file is absent from cwd. The read commands (`query`/`path`/`explain`) resolve it themselves by walking up to the nearest ancestor that has one, and print which graph they used, so the reliable check is simply to RUN one and read its output. If it exists AND the user's request is a natural-language question about the codebase (e.g. "How does X work?", "What calls Y?", "Trace the data flow through Z") and NOT an explicit rebuild command (`--update`, `--cluster-only`, or a bare path/URL that implies fresh extraction): **skip Steps 1–5 entirely and jump straight to `## For /graphify query`.** Run `graphify query ""` immediately. Do not run detect. Do not check corpus size. Do not ask the user to narrow. The graph is already built — use it. If no path was given, use `.` (current directory). Do not ask the user for a path. diff --git a/graphify/skill-vscode.md b/graphify/skill-vscode.md index 20c7c0835..cf7043676 100644 --- a/graphify/skill-vscode.md +++ b/graphify/skill-vscode.md @@ -50,7 +50,7 @@ Drop any folder of code, docs, papers, images, or video into graphify and get a If the user invoked `/graphify --help` or `/graphify -h` (with no other arguments), print the contents of the `## Usage` section above verbatim and stop. Do not run any commands, do not detect files, do not default the path to `.`. Just print the Usage block and return. -**Fast path — existing graph:** Before doing anything else, check whether `graphify-out/graph.json` exists. The expected location is `graphify-out/graph.json` relative to the **current working directory** (i.e. the project root where you are running commands). If it exists AND the user's request is a natural-language question about the codebase (e.g. "How does X work?", "What calls Y?", "Trace the data flow through Z") and NOT an explicit rebuild command (`--update`, `--cluster-only`, or a bare path/URL that implies fresh extraction): **skip Steps 1–5 entirely and jump straight to `## For /graphify query`.** Run `graphify query ""` immediately. Do not run detect. Do not check corpus size. Do not ask the user to narrow. The graph is already built — use it. +**Fast path — existing graph:** Before doing anything else, check whether `graphify-out/graph.json` exists. The graph lives in `graphify-out/graph.json` at the **scan root** — the directory the graph was built for. That is not necessarily your current directory: in a git worktree, a package subdirectory, or any nested folder, the graph sits one or more levels UP. Do not conclude there is no graph just because the file is absent from cwd. The read commands (`query`/`path`/`explain`) resolve it themselves by walking up to the nearest ancestor that has one, and print which graph they used, so the reliable check is simply to RUN one and read its output. If it exists AND the user's request is a natural-language question about the codebase (e.g. "How does X work?", "What calls Y?", "Trace the data flow through Z") and NOT an explicit rebuild command (`--update`, `--cluster-only`, or a bare path/URL that implies fresh extraction): **skip Steps 1–5 entirely and jump straight to `## For /graphify query`.** Run `graphify query ""` immediately. Do not run detect. Do not check corpus size. Do not ask the user to narrow. The graph is already built — use it. If no path was given, use `.` (current directory). Do not ask the user for a path. diff --git a/graphify/skill-windows.md b/graphify/skill-windows.md index d631821ec..fa4819170 100644 --- a/graphify/skill-windows.md +++ b/graphify/skill-windows.md @@ -50,7 +50,7 @@ Drop any folder of code, docs, papers, images, or video into graphify and get a If the user invoked `/graphify --help` or `/graphify -h` (with no other arguments), print the contents of the `## Usage` section above verbatim and stop. Do not run any commands, do not detect files, do not default the path to `.`. Just print the Usage block and return. -**Fast path — existing graph:** Before doing anything else, check whether `graphify-out/graph.json` exists. The expected location is `graphify-out/graph.json` relative to the **current working directory** (i.e. the project root where you are running commands). If it exists AND the user's request is a natural-language question about the codebase (e.g. "How does X work?", "What calls Y?", "Trace the data flow through Z") and NOT an explicit rebuild command (`--update`, `--cluster-only`, or a bare path/URL that implies fresh extraction): **skip Steps 1–5 entirely and jump straight to `## For /graphify query`.** Run `graphify query ""` immediately. Do not run detect. Do not check corpus size. Do not ask the user to narrow. The graph is already built — use it. +**Fast path — existing graph:** Before doing anything else, check whether `graphify-out/graph.json` exists. The graph lives in `graphify-out/graph.json` at the **scan root** — the directory the graph was built for. That is not necessarily your current directory: in a git worktree, a package subdirectory, or any nested folder, the graph sits one or more levels UP. Do not conclude there is no graph just because the file is absent from cwd. The read commands (`query`/`path`/`explain`) resolve it themselves by walking up to the nearest ancestor that has one, and print which graph they used, so the reliable check is simply to RUN one and read its output. If it exists AND the user's request is a natural-language question about the codebase (e.g. "How does X work?", "What calls Y?", "Trace the data flow through Z") and NOT an explicit rebuild command (`--update`, `--cluster-only`, or a bare path/URL that implies fresh extraction): **skip Steps 1–5 entirely and jump straight to `## For /graphify query`.** Run `graphify query ""` immediately. Do not run detect. Do not check corpus size. Do not ask the user to narrow. The graph is already built — use it. If no path was given, use `.` (current directory). Do not ask the user for a path. diff --git a/graphify/skill.md b/graphify/skill.md index abd2811d2..c877b00f7 100644 --- a/graphify/skill.md +++ b/graphify/skill.md @@ -50,7 +50,7 @@ Drop any folder of code, docs, papers, images, or video into graphify and get a If the user invoked `/graphify --help` or `/graphify -h` (with no other arguments), print the contents of the `## Usage` section above verbatim and stop. Do not run any commands, do not detect files, do not default the path to `.`. Just print the Usage block and return. -**Fast path — existing graph:** Before doing anything else, check whether `graphify-out/graph.json` exists. The expected location is `graphify-out/graph.json` relative to the **current working directory** (i.e. the project root where you are running commands). If it exists AND the user's request is a natural-language question about the codebase (e.g. "How does X work?", "What calls Y?", "Trace the data flow through Z") and NOT an explicit rebuild command (`--update`, `--cluster-only`, or a bare path/URL that implies fresh extraction): **skip Steps 1–5 entirely and jump straight to `## For /graphify query`.** Run `graphify query ""` immediately. Do not run detect. Do not check corpus size. Do not ask the user to narrow. The graph is already built — use it. +**Fast path — existing graph:** Before doing anything else, check whether `graphify-out/graph.json` exists. The graph lives in `graphify-out/graph.json` at the **scan root** — the directory the graph was built for. That is not necessarily your current directory: in a git worktree, a package subdirectory, or any nested folder, the graph sits one or more levels UP. Do not conclude there is no graph just because the file is absent from cwd. The read commands (`query`/`path`/`explain`) resolve it themselves by walking up to the nearest ancestor that has one, and print which graph they used, so the reliable check is simply to RUN one and read its output. If it exists AND the user's request is a natural-language question about the codebase (e.g. "How does X work?", "What calls Y?", "Trace the data flow through Z") and NOT an explicit rebuild command (`--update`, `--cluster-only`, or a bare path/URL that implies fresh extraction): **skip Steps 1–5 entirely and jump straight to `## For /graphify query`.** Run `graphify query ""` immediately. Do not run detect. Do not check corpus size. Do not ask the user to narrow. The graph is already built — use it. If no path was given, use `.` (current directory). Do not ask the user for a path. From b4dbad0a6da49dcacbe9a22550a2d15d1b1b0458 Mon Sep 17 00:00:00 2001 From: "martin.perry" Date: Fri, 28 Aug 2026 09:19:25 -0400 Subject: [PATCH 7/7] fix(hook-guard): resolve the graph upward, not just in cwd MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The PreToolUse guard checked out_path("graph.json") relative to the current directory. Agents run from wherever they happen to be — a git worktree, a package subdirectory — while the graph sits at the scan root above them, so the guard silently never fired in exactly the places it was needed and the agent grepped a codebase that had a graph one level up. Mirrors what the read commands already do. Still fails open: any error resolves to None and the guard prints nothing. Co-Authored-By: Claude Opus 5 (1M context) --- graphify/cli.py | 28 ++++++++++++++++++++++++---- 1 file changed, 24 insertions(+), 4 deletions(-) diff --git a/graphify/cli.py b/graphify/cli.py index d06f587d9..d432e487b 100644 --- a/graphify/cli.py +++ b/graphify/cli.py @@ -808,14 +808,31 @@ def _run_hook_guard(kind: str, strict: bool = False) -> None: ignored, and a graph that is stale for the target file softens to a non-mandatory nudge instead of blocking or demanding. """ - from graphify.paths import out_path, GRAPHIFY_OUT_NAME + from graphify.paths import out_path, GRAPHIFY_OUT_NAME, find_graph_json_upward + + def _guard_graph(): + """The graph this caller would actually query, or None. + + Not cwd-only. Agents run from wherever they happen to be — a git + worktree, a package subdirectory — while the graph sits at the scan + root above them. A cwd-only check makes the guard silently never fire + in exactly those places, so the agent greps a codebase that has a + perfectly good graph one level up. Mirror what the read commands do. + """ + try: + direct = out_path("graph.json") + if direct.is_file(): + return direct + return find_graph_json_upward() + except Exception: + return None # Gemini's BeforeTool hook takes no stdin and must ALWAYS return a decision so # the tool is never blocked; the graph nudge is appended only when a graph # exists. Handled before the stdin read below (which the search/read guards need). if kind == "gemini": payload = {"decision": "allow"} try: - if out_path("graph.json").is_file(): + if _guard_graph() is not None: payload["additionalContext"] = _GEMINI_NUDGE_TEXT except Exception: pass @@ -844,7 +861,7 @@ def _run_hook_guard(kind: str, strict: bool = False) -> None: is_grep_tool = not cmd_str and bool(t.get("pattern")) is_bash_search = any(tok in cmd_str for tok in ( "grep", "ripgrep", "rg ", "find ", "fd ", "ack ", "ag ")) - if (is_grep_tool or is_bash_search) and out_path("graph.json").is_file(): + if (is_grep_tool or is_bash_search) and _guard_graph() is not None: sys.stdout.write(_SEARCH_NUDGE) elif kind == "read": vals = [str(t.get("file_path") or ""), str(t.get("pattern") or ""), str(t.get("path") or "")] @@ -886,7 +903,10 @@ def _run_hook_guard(kind: str, strict: bool = False) -> None: return # One stat for existence + mtime of the graph. try: - gmtime = os.stat(str(out_path("graph.json"))).st_mtime + _gp = _guard_graph() + if _gp is None: + raise FileNotFoundError("no graph") + gmtime = os.stat(str(_gp)).st_mtime except OSError: return # #1840 (b): stale-for-target -> soften, never block. The target file