Skip to content
170 changes: 158 additions & 12 deletions graphify/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,118 @@ 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 _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 <root>/<GRAPHIFY_OUT_NAME>/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,
Expand Down Expand Up @@ -696,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
Expand Down Expand Up @@ -732,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 "")]
Expand Down Expand Up @@ -774,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
Expand Down Expand Up @@ -1078,6 +1210,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
Expand All @@ -1104,9 +1237,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)
Expand Down Expand Up @@ -1193,16 +1328,19 @@ 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:]
i = 0
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:
Expand All @@ -1226,6 +1364,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)
Expand Down Expand Up @@ -1263,16 +1402,19 @@ 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:]
i = 0
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:
Expand All @@ -1290,6 +1432,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)
Expand Down Expand Up @@ -1412,11 +1555,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(
Expand All @@ -1437,6 +1582,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)
Expand Down Expand Up @@ -1573,10 +1719,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)
Expand All @@ -1596,14 +1745,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}")
Expand Down
14 changes: 10 additions & 4 deletions graphify/extract.py
Original file line number Diff line number Diff line change
Expand Up @@ -7075,9 +7075,15 @@ def _canon(nid: str) -> str:

def collect_files(target: Path, *, follow_symlinks: bool = False, root: Path | None = None) -> list[Path]:

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Health regressioncollect_files()

fans out to 7 callees (efferent coupling); 17 callers depend on it (afferent coupling).

Grounded coupling-delta finding (deterministic), not an LLM guess.

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
Expand Down Expand Up @@ -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
Expand All @@ -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)

Expand Down
Loading