diff --git a/graphify/cli.py b/graphify/cli.py index 5b7339726..d432e487b 100644 --- a/graphify/cli.py +++ b/graphify/cli.py @@ -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 //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, @@ -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 @@ -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 "")] @@ -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 @@ -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 @@ -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) @@ -1193,6 +1328,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 +1336,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 +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) @@ -1263,6 +1402,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 +1410,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 +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) @@ -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( @@ -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) @@ -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) @@ -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}") 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) diff --git a/graphify/paths.py b/graphify/paths.py index ba15b32cc..3214f909f 100644 --- a/graphify/paths.py +++ b/graphify/paths.py @@ -307,8 +307,79 @@ 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. + """ + 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: + """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. """ - return str(out_path("graph.json")) + 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: 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: 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. 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):