From cec0884cc3b12043f64b79038b1eca70f8ad3e5c Mon Sep 17 00:00:00 2001 From: jepegit Date: Thu, 14 May 2026 21:53:43 +0200 Subject: [PATCH] fix(graphify): translate `issue-flow build` to a real graphify subcommand MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `graphify` is dispatch-based — every action is a subcommand (`extract `, `update `, `watch `, …). The original integration emitted `graphify ` with no subcommand, so every `issue-flow build` (and the `/build` slash command) failed with `error: unknown command ''`. The scaffolded docs also advertised flags (`--update`, `--no-viz`, `--mode deep`, `--cluster-only`, `--watch`) that do not exist on graphify; they are subcommands or per-subcommand flags. Changes: - `_build_graphify_argv` constructs `graphify [args]`. Default subcommand is `extract` (full AST + semantic LLM build); a leading recognized build subcommand (`extract`, `update`, `watch`, `cluster-only`, `check-update`) overrides it. Project root is injected after the subcommand unless the user supplied a positional path, in which case it is trusted. - `cli.build` converts `project_dir` from a positional to `-C` / `--project-dir`, modeled on `git -C`. The previous positional ate args like `update` (`issue-flow build update` failed because Typer bound `update` to `project_dir` and the existence check rejected it). - Update build template, rules entry, cursor-issue-workflow doc, issue-close template, build skill, and README to describe real graphify subcommands and the new `-C` flag. Replace every `graphify .` fallback (which was never valid) with `graphify extract .`. - New tests cover argv construction across no-args, leading subcommand, leading flag, and explicit-path cases. Existing `subprocess.run`-mocked tests are updated to the new `-C` shape and also patch `_candidate_install_locations` so the install-hint branch is exercised on machines that already have graphify on disk. - Add a "Correction" section to `.issueflows/04-designs-and-guides/graphify-integration.md` so the next reader sees that graphify is subcommand-based and that future integrations should test against real subcommand names. Co-authored-by: Cursor --- .../graphify-integration.md | 12 ++ README.md | 13 +- src/issue_flow/cli.py | 18 ++- src/issue_flow/graphify.py | 77 ++++++++-- src/issue_flow/templates/commands/build.md.j2 | 25 ++- .../templates/commands/issue-close.md.j2 | 2 +- .../docs/cursor-issue-workflow.md.j2 | 14 +- .../templates/rules/issueflow-rules.mdc.j2 | 2 +- .../skills/issueflow_build/SKILL.md.j2 | 8 +- tests/test_cli.py | 24 ++- tests/test_graphify.py | 145 ++++++++++++++++-- 11 files changed, 265 insertions(+), 75 deletions(-) diff --git a/.issueflows/04-designs-and-guides/graphify-integration.md b/.issueflows/04-designs-and-guides/graphify-integration.md index 907b152..eb03090 100644 --- a/.issueflows/04-designs-and-guides/graphify-integration.md +++ b/.issueflows/04-designs-and-guides/graphify-integration.md @@ -81,3 +81,15 @@ What we deliberately **do not** ship: - If we add `issue-flow status` (already on the README's Future plans), it could surface graph freshness (`graphify-out/manifest.json` mtime vs source tree) without re-implementing graphify's freshness check. - If multi-tool support lands (Claude Code, Windsurf, etc.), `register_with_cursor` should grow a sibling `register_with_` that calls `graphify install`. + +## Correction (2026-05-14): graphify is subcommand-based + +The original implementation assumed `graphify [flags…]` was the canonical "build" invocation, modeled on tools like `ruff` or `pyright`. **It is not.** The `graphify` CLI is dispatch-based — every action is a subcommand (`extract `, `update `, `watch `, `cluster-only `, …) and there is no top-level "scan this folder" mode. Running `graphify C:\some\dir` fails with `unknown command 'C:\some\dir'`. The published `/build` doc, the rules entry, the cursor-issue-workflow doc, and the README all advertised non-existent flags (`--update`, `--no-viz`, `--mode deep`, `--watch`, `--cluster-only`) that are actually subcommands or per-subcommand flags. + +**Fixes landed in this iteration:** + +- `_build_graphify_argv` translates `issue-flow build [args…]` into `graphify [args…]`. Default subcommand is `extract` (full AST + semantic LLM build, matches the natural meaning of "build the graph"). A leading recognized build subcommand (`extract`, `update`, `watch`, `cluster-only`, `check-update`) overrides the default. +- `project_dir` on the Typer `build` command became `-C` / `--project-dir` (modeled on `git -C`) so positional args flow into `_build_graphify_argv` untouched. Without this change, `issue-flow build update` failed because Typer eagerly bound `update` to the `project_dir` positional and the path-existence check rejected it. +- All scaffolded docs (`commands/build.md.j2`, `skills/issueflow_build/SKILL.md.j2`, `rules/issueflow-rules.mdc.j2`, `docs/cursor-issue-workflow.md.j2`, `commands/issue-close.md.j2`) and the README now describe real graphify subcommands and the `-C` option. The `graphify .` fallback (which never worked) is replaced everywhere with `graphify extract .`. + +**Why this slipped through originally:** `run_build` was tested with `subprocess.run` mocked to a no-op, so the test suite never observed graphify's actual argv parser. The new tests exercise the argv-construction function directly with realistic subcommand combinations. Future graphify integrations should always include at least one test that uses real graphify subcommand names (or a contract test that mirrors `graphify --help`). diff --git a/README.md b/README.md index 50ec4dd..f81f174 100644 --- a/README.md +++ b/README.md @@ -111,8 +111,11 @@ What `issue-flow` does when `graphify` is on PATH: If graphify is not installed, both commands just print install hints and continue — they never block. - A new slash command `/build` (and matching `/issueflow-build` skill) wraps - `issue-flow build`, which forwards every argument to the `graphify` CLI - verbatim (`--update`, `--no-viz`, `--mode deep`, `--watch`, …). + `issue-flow build`. With no extra args it runs `graphify extract ` + (full build); pass a graphify build subcommand to pick a different action + (`issue-flow build update`, `issue-flow build watch`, + `issue-flow build cluster-only --no-viz`, …). Trailing flags forward to + the chosen subcommand verbatim. - The scaffolded rules and `/issue-start` mention `graphify-out/GRAPH_REPORT.md` as a recommended pre-read when the file exists. `/build` is **off-path** — `/iflow` never auto-dispatches to it. @@ -173,7 +176,7 @@ That's it. Open the project in Cursor and start with `/iflow` (or step through ` ``` issue-flow init [PROJECT_DIR] [--force] [--skip-dep-check] issue-flow update [PROJECT_DIR] [--skip-dep-check] -issue-flow build [PROJECT_DIR] [-- ...graphify args] +issue-flow build [-C PROJECT_DIR] [...graphify subcommand + args] ``` ### `issue-flow init` @@ -199,8 +202,8 @@ Use `update` after upgrading the **issue-flow** package to refresh the packaged | Argument / Option | Description | |---|---| -| `PROJECT_DIR` | Project root directory to scan with graphify. Defaults to `.`. | -| `...graphify args` | Any extra arguments are forwarded **verbatim** to the `graphify` CLI (`--update`, `--no-viz`, `--mode deep`, `--watch`, …). | +| `-C`, `--project-dir` | Project root directory to scan with graphify. Defaults to `.` (current directory). Modeled on `git -C` so positional args can flow into graphify untouched. | +| `...graphify subcommand + args` | Optional graphify subcommand + flags. With no extras runs `graphify extract ` (full AST + semantic LLM build). The first extra arg, if it is a recognized build subcommand (`extract`, `update`, `watch`, `cluster-only`, `check-update`), picks the action; trailing tokens forward verbatim. Examples: `issue-flow build update`, `issue-flow build cluster-only --no-viz`, `issue-flow build ./subdir`. | `build` requires `graphifyy` to be installed (`uv tool install graphifyy`). When the `graphify` CLI is missing, the command prints install hints and exits with code `2`. Outputs land in `graphify-out/` (`graph.html`, `GRAPH_REPORT.md`, `graph.json`). diff --git a/src/issue_flow/cli.py b/src/issue_flow/cli.py index 9680292..cc14549 100644 --- a/src/issue_flow/cli.py +++ b/src/issue_flow/cli.py @@ -84,8 +84,10 @@ def update( ) def build( ctx: typer.Context, - project_dir: Path = typer.Argument( - default=Path("."), + project_dir: Path = typer.Option( + Path("."), + "--project-dir", + "-C", help=( "Project root directory to scan with graphify. " "Defaults to the current directory." @@ -97,10 +99,14 @@ def build( ) -> None: """Rebuild the graphify knowledge graph for the project. - Forwards every extra argument to the ``graphify`` CLI verbatim, so - flags like ``--update``, ``--no-viz``, ``--mode deep``, or - ``--watch`` pass straight through. Requires ``graphify`` to be on - ``PATH`` (install with ``uv tool install graphifyy``). + With no extra arguments runs ``graphify extract `` (the + full AST + semantic LLM build). Override the subcommand by passing + it as the first argument: ``issue-flow build update`` (fast, + code-only re-extract), ``issue-flow build watch`` (live rebuild), + ``issue-flow build cluster-only --no-viz`` (re-cluster), etc. + Trailing flags pass through verbatim. Use ``-C `` to scan a + project other than the current directory. Requires ``graphify`` to + be on ``PATH`` (install with ``uv tool install graphifyy``). """ from issue_flow.graphify import run_build diff --git a/src/issue_flow/graphify.py b/src/issue_flow/graphify.py index 6fb68f5..9a63862 100644 --- a/src/issue_flow/graphify.py +++ b/src/issue_flow/graphify.py @@ -36,6 +36,59 @@ GRAPHIFY_COMMAND = "graphify" GRAPHIFY_PYPI = "graphifyy" +# Graphify is a multi-subcommand CLI. The subcommands below all take a +# project path as their first positional argument and are the ones that +# fit the "build / refresh the graph" surface ``issue-flow build`` +# exposes. Anything else (``query``, ``explain``, ``cursor install``, +# …) is out of scope for ``build``; users invoke ``graphify`` directly +# for those. +_GRAPHIFY_BUILD_SUBCOMMANDS: frozenset[str] = frozenset( + {"extract", "update", "watch", "cluster-only", "check-update"} +) +# Default subcommand when the user runs ``issue-flow build`` without +# specifying one. ``extract`` is the full AST + semantic LLM build +# (matches the natural meaning of "build the graph"). +_DEFAULT_BUILD_SUBCOMMAND: str = "extract" + + +def _build_graphify_argv( + project_root: Path, extra_args: Sequence[str] +) -> list[str]: + """Translate ``issue-flow build`` arguments into a ``graphify`` argv. + + ``graphify`` is subcommand-based — there is no top-level "scan this + folder" mode — so every invocation needs an explicit subcommand. + Behavior: + + * No extra args → ``graphify extract ``. + * First arg is a recognized build subcommand (``extract``, + ``update``, ``watch``, ``cluster-only``, ``check-update``) → use + it. If a positional path follows, trust it; otherwise inject + ``project_root`` so graphify scans the right tree even when the + agent's cwd differs from the project root. + * First arg is anything else → assume the default subcommand + (``extract``) and treat the args as positional/flag tail. A + first arg that does not start with ``-`` is taken as the path + the user wants graphify to scan (e.g. ``issue-flow build ./docs`` + → ``graphify extract ./docs``). + """ + args = list(extra_args) + + if args and args[0] in _GRAPHIFY_BUILD_SUBCOMMANDS: + subcommand = args[0] + rest = args[1:] + else: + subcommand = _DEFAULT_BUILD_SUBCOMMAND + rest = args + + has_explicit_path = bool(rest) and not rest[0].startswith("-") + if has_explicit_path: + positional_tail = rest + else: + positional_tail = [str(project_root), *rest] + + return [GRAPHIFY_COMMAND, subcommand, *positional_tail] + def _graphify_dependency(): """Return the ``Dependency`` entry for graphify from the recommended list.""" @@ -203,13 +256,14 @@ def run_build( extra_args: Sequence[str], console: Console, ) -> int: - """Run ``graphify [extra_args...]`` and return its exit code. + """Run ``graphify [extra_args...]`` and return its exit code. - When the user supplies an explicit path in ``extra_args`` (e.g. - ``issue-flow build ./docs``), it is forwarded as-is and we do not - inject the project root. Otherwise the project root is passed - explicitly so graphify knows what to scan even if the agent's CWD - differs from the project root. + See :func:`_build_graphify_argv` for the argv-construction rules. + The short version: ``issue-flow build`` with no args invokes + ``graphify extract `` (the natural "build the graph" + action). Users can pick a different build subcommand by passing it + as the first argument (e.g. ``issue-flow build update``, + ``issue-flow build cluster-only --no-viz``). Returns ``2`` and prints install hints when graphify is missing. Re-raises ``KeyboardInterrupt`` so users can ^C a long build. @@ -222,16 +276,7 @@ def run_build( _print_install_hints(console) return 2 - cmd: list[str] = [GRAPHIFY_COMMAND] - args_list = list(extra_args) - # Only inject the project root when the user did not supply a leading - # positional argument. We use a deliberately narrow rule (first token - # is a flag, or there are no tokens) so we do not misclassify a flag - # value like ``deep`` after ``--mode`` as a path. - has_explicit_path = bool(args_list) and not args_list[0].startswith("-") - if not has_explicit_path: - cmd.append(str(project_root)) - cmd.extend(args_list) + cmd = _build_graphify_argv(project_root, extra_args) console.print( "[dim]running:[/dim] [bold]" diff --git a/src/issue_flow/templates/commands/build.md.j2 b/src/issue_flow/templates/commands/build.md.j2 index 5546619..0084de5 100644 --- a/src/issue_flow/templates/commands/build.md.j2 +++ b/src/issue_flow/templates/commands/build.md.j2 @@ -6,17 +6,16 @@ This is an **off-path** command — the lifecycle dispatcher (`/iflow`) never au ## Input -Optional free-form text after the command. Forwarded verbatim to `graphify`. Common combinations: +Optional free-form text after the command. The first word, if it is a recognized graphify build subcommand, picks the action; otherwise the default is `extract`. Trailing tokens forward verbatim. Common combinations: -- **No extra text** — full rebuild of the project root. -- **`./subdir`** — rebuild for a sub-directory (e.g. `/build ./src`). -- **`--update`** — re-extract only files that changed since the last build (much faster). -- **`--no-viz`** — skip the HTML; produce report + JSON only. -- **`--mode deep`** — more aggressive relationship extraction. -- **`--cluster-only`** — rerun clustering on the existing graph without re-extraction. -- **`--watch`** — auto-sync as files change (long-running). +- **No extra text** — full rebuild (`graphify extract `); requires an LLM API key for graphify's semantic pass. +- **`update`** — fast incremental re-extract of changed code files only, no LLM (`graphify update `). +- **`watch`** — long-running file watcher that auto-rebuilds on save (`graphify watch `). +- **`cluster-only`** — rerun clustering on the existing `graph.json` without re-extraction (`graphify cluster-only `). +- **`./subdir`** — scan a sub-directory instead of the project root (`graphify extract ./subdir`). +- **Trailing flags** (e.g. `--no-cluster`, `--force`, `--no-viz`) — passed straight through to the chosen subcommand. See `graphify --help` for the per-subcommand flag set. -See the [graphify CLI reference](https://graphify.net/graphify-cli-commands.html) for the full flag set. +See the [graphify CLI reference](https://graphify.net/graphify-cli-commands.html) for the full subcommand and flag list. ## Steps @@ -26,15 +25,15 @@ See the [graphify CLI reference](https://graphify.net/graphify-cli-commands.html issue-flow build ``` - Pass any of the flags above after the project dir, e.g. `issue-flow build . --update --no-viz`. Extra args are forwarded verbatim to `graphify`. + To pick a non-default subcommand or pass flags, append them after the project dir, e.g. `issue-flow build update` or `issue-flow build cluster-only --no-viz`. Extra args are forwarded verbatim. 2. **Fallback: call `graphify` directly** if `issue-flow` is not on PATH: ```bash - graphify . + graphify extract . ``` - On PowerShell, drop the leading slash: write `graphify .` (a leading `/` is parsed as a path separator). + `graphify` is subcommand-based — `graphify .` on its own is **not** valid and will fail with `unknown command '.'`. Always pick a subcommand (`extract`, `update`, `watch`, `cluster-only`, …). 3. **Verify outputs.** After a successful run there should be a `graphify-out/` folder with at least `graph.html`, `GRAPH_REPORT.md`, and `graph.json`. Skim `GRAPH_REPORT.md` once to confirm the run picked up new modules or docs. @@ -52,7 +51,7 @@ See the [graphify CLI reference](https://graphify.net/graphify-cli-commands.html - Do **not** run `/build` automatically from `/issue-start`, `/issue-close`, or `/iflow`. The user opts in. - Do **not** commit `graphify-out/cost.json` or `graphify-out/manifest.json`; both are local-only. The graph itself (`graph.json`, `graph.html`, `GRAPH_REPORT.md`) is fine to commit so teammates start with a map. -- Long-running flags (`--watch`) keep the process running; ask the user before launching them in an agent context. +- Long-running modes (`watch`) keep the process running; ask the user before launching them in an agent context. ## Output to user diff --git a/src/issue_flow/templates/commands/issue-close.md.j2 b/src/issue_flow/templates/commands/issue-close.md.j2 index ff28dd3..52758ed 100644 --- a/src/issue_flow/templates/commands/issue-close.md.j2 +++ b/src/issue_flow/templates/commands/issue-close.md.j2 @@ -22,7 +22,7 @@ Other optional notes still apply: branch name, PR title, draft PR, skip issue do - Run tests and any checks you rely on (e.g. `uv run pytest`). - Skim the diff so the commit matches what you intend to ship. - Confirm that any design decisions or good-practices that emerged from this issue are captured under `{{ issueflows_dir }}/{{ designs_folder }}/` before committing. If something is missing, add it now (short markdown: context, decision, alternatives, link back to the issue). - - **Graph freshness (optional).** If this change touched the project's structure (new modules, big refactor, removed files) and `graphify-out/` exists, suggest the user run `/build` (or `issue-flow build --update`) before pushing so teammates pull a current `GRAPH_REPORT.md`. Do not run `/build` automatically — it is opt-in. Skip this bullet entirely if `graphify-out/` is not present. + - **Graph freshness (optional).** If this change touched the project's structure (new modules, big refactor, removed files) and `graphify-out/` exists, suggest the user run `/build` (or `issue-flow build update` for a fast, no-LLM refresh) before pushing so teammates pull a current `GRAPH_REPORT.md`. Do not run `/build` automatically — it is opt-in. Skip this bullet entirely if `graphify-out/` is not present. 2. **Optional version bump** (only if the user asked for it in the command input) - Read `{{ agent_dir }}/skills/issueflow-version-bump/SKILL.md` and follow it. diff --git a/src/issue_flow/templates/docs/cursor-issue-workflow.md.j2 b/src/issue_flow/templates/docs/cursor-issue-workflow.md.j2 index 5069295..2744813 100644 --- a/src/issue_flow/templates/docs/cursor-issue-workflow.md.j2 +++ b/src/issue_flow/templates/docs/cursor-issue-workflow.md.j2 @@ -196,17 +196,17 @@ The bump runs **after** tests and **before** issue-folder moves and **before** c **When:** The project has the optional [graphify](https://graphify.net) integration enabled (the `graphify` CLI is on `PATH` and a `graphify-out/` folder is present), and the graph has gone stale relative to the source tree. -**What you pass:** Optional graphify args, forwarded verbatim. Common picks: +**What you pass:** Optional graphify subcommand and args, forwarded verbatim. Common picks: -- *(nothing)* — full rebuild of the project root. -- `./subdir` — restrict the build to a sub-directory. -- `--update` — re-extract only files changed since the last build. -- `--no-viz` — skip the HTML; produce report + JSON only. -- `--mode deep` — more aggressive relationship extraction. +- *(nothing)* — full rebuild of the project root (`graphify extract `). +- `update` — fast incremental re-extract of changed code files only, no LLM. +- `watch` — long-running watcher that auto-rebuilds on save. +- `cluster-only` — rerun clustering on the existing `graph.json` without re-extraction (e.g. `cluster-only --no-viz`). +- `./subdir` — restrict the scan to a sub-directory (default subcommand: `extract`). **What the assistant does:** -1. Runs `issue-flow build` (which shells out to the `graphify` CLI). If `issue-flow` is unavailable, falls back to `graphify .` directly. +1. Runs `issue-flow build` (which shells out to the `graphify` CLI). If `issue-flow` is unavailable, falls back to `graphify extract .` directly (`graphify .` alone is **not** valid — graphify requires a subcommand). 2. If `graphify` is not installed, prints install hints (`uv tool install graphifyy`) and stops — never silently retries. 3. Verifies that `graphify-out/graph.html`, `GRAPH_REPORT.md`, and `graph.json` exist after a successful run. diff --git a/src/issue_flow/templates/rules/issueflow-rules.mdc.j2 b/src/issue_flow/templates/rules/issueflow-rules.mdc.j2 index 5da588a..f6da74a 100644 --- a/src/issue_flow/templates/rules/issueflow-rules.mdc.j2 +++ b/src/issue_flow/templates/rules/issueflow-rules.mdc.j2 @@ -130,6 +130,6 @@ Long-lived design docs, design decisions, and project "good practices" live unde If a `graphify-out/` folder exists in the project root, the project has the optional [graphify](https://graphify.net) integration enabled and a knowledge graph is available alongside the source. - **Before grepping**, skim `graphify-out/GRAPH_REPORT.md`. It surfaces god-nodes (most-connected concepts), surprising cross-module connections, and suggested questions the graph can answer — often a faster way to locate the files an issue actually touches than full-text search. -- **`/build`** (slash command) or **`issue-flow build`** (CLI) rebuild the graph; both forward extra args (`--update`, `--no-viz`, `--mode deep`, etc.) verbatim to the `graphify` CLI. +- **`/build`** (slash command) or **`issue-flow build`** (CLI) rebuild the graph. With no extra args this runs `graphify extract ` (full build). Pass a graphify build subcommand to pick a different action: `issue-flow build update` (fast incremental, no LLM), `issue-flow build watch` (live), `issue-flow build cluster-only --no-viz` (re-cluster only). Trailing flags pass through verbatim. - `/build` is **off-path**: never auto-dispatched by `/iflow`, `/issue-start`, or `/issue-close`. It is the user's call. `/issue-start` may *suggest* skimming `GRAPH_REPORT.md`; `/issue-close` may *suggest* a rebuild after large structural changes — neither runs `graphify` automatically. - If `graphify-out/` is not present, ignore graph-related guidance entirely. The integration is opt-in (install with `uv tool install graphifyy`, then `issue-flow update` to register the graphify Cursor skill). diff --git a/src/issue_flow/templates/skills/issueflow_build/SKILL.md.j2 b/src/issue_flow/templates/skills/issueflow_build/SKILL.md.j2 index 6c7be08..9d9f1d9 100644 --- a/src/issue_flow/templates/skills/issueflow_build/SKILL.md.j2 +++ b/src/issue_flow/templates/skills/issueflow_build/SKILL.md.j2 @@ -28,15 +28,15 @@ Do **not** use this skill from `/issue-start`, `/issue-close`, or `/iflow`. `/bu issue-flow build ``` - Any extra args from the user (`--update`, `--no-viz`, `--mode deep`, `./subdir`, etc.) pass straight through to `graphify`. Do not invent new wrapper flags. + With no extra args this runs `graphify extract ` (full AST + semantic LLM build). To pick a different graphify subcommand, pass it as the first arg: `issue-flow build update` (fast, code-only re-extract), `issue-flow build watch` (live rebuild), `issue-flow build cluster-only --no-viz`, etc. A leading `./subdir` overrides the scan path. Trailing flags pass through verbatim. Do not invent new wrapper flags. 2. **Fallback to `graphify` directly** when `issue-flow` is unavailable: ```bash - graphify . + graphify extract . ``` - On Windows PowerShell drop any leading `/` (use `graphify .`, not `/graphify .`). + `graphify` is subcommand-based — `graphify .` on its own is **not** valid (graphify reports `unknown command '.'`). Always pick a subcommand: `extract` for a full build, `update` for a fast incremental, `watch` for a long-running watcher, etc. 3. **Handle missing graphify gracefully.** If the run reports `graphify` is not on PATH, do **not** retry blindly. Tell the user to install it once: @@ -57,5 +57,5 @@ Do **not** use this skill from `/issue-start`, `/issue-close`, or `/iflow`. `/bu - Never auto-dispatch `/build` from another slash command. The user opts in explicitly. - Never commit `graphify-out/cost.json` or `graphify-out/manifest.json`; they are local-only. -- Long-running modes (`--watch`) keep the process alive; ask the user before launching them in an agent context. +- Long-running modes (`watch`) keep the process alive; ask the user before launching them in an agent context. - Forward extra arguments verbatim. Do **not** translate or rewrite graphify's flag set inside issue-flow. diff --git a/tests/test_cli.py b/tests/test_cli.py index e2bddbf..abdf85f 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -48,16 +48,18 @@ def fake_run(cmd: list[str], **kwargs: Any) -> _Result: monkeypatch.setattr(graphify_module.subprocess, "run", fake_run) - result = runner.invoke(app, ["build", str(tmp_path)]) + result = runner.invoke(app, ["build", "-C", str(tmp_path)]) assert result.exit_code == 0, result.output assert captured["cmd"][0] == "graphify" + # Default subcommand must be injected since graphify requires one. + assert captured["cmd"][1] == "extract" def test_build_forwards_extra_args( runner: CliRunner, tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: - """Extra args after the project dir must reach `graphify` verbatim.""" + """A leading subcommand and trailing flags must reach `graphify` verbatim.""" from issue_flow import graphify as graphify_module monkeypatch.setattr(graphify_module.shutil, "which", lambda _cmd: "/usr/bin/graphify") @@ -73,13 +75,20 @@ def fake_run(cmd: list[str], **kwargs: Any) -> _Result: monkeypatch.setattr(graphify_module.subprocess, "run", fake_run) + # `cluster-only` is a real graphify build subcommand; `--no-viz` is + # one of its real flags. Both must reach graphify verbatim, and the + # project root must be injected after the subcommand. result = runner.invoke( - app, ["build", str(tmp_path), "--update", "--no-viz"] + app, ["build", "-C", str(tmp_path), "cluster-only", "--no-viz"] ) assert result.exit_code == 0, result.output - assert "--update" in captured["cmd"] - assert "--no-viz" in captured["cmd"] + assert captured["cmd"] == [ + "graphify", + "cluster-only", + str(tmp_path), + "--no-viz", + ] def test_build_exits_nonzero_when_graphify_missing( @@ -89,13 +98,14 @@ def test_build_exits_nonzero_when_graphify_missing( from issue_flow import graphify as graphify_module monkeypatch.setattr(graphify_module.shutil, "which", lambda _cmd: None) + monkeypatch.setattr(graphify_module, "_candidate_install_locations", lambda: []) def fail_run(*_a: Any, **_kw: Any) -> Any: raise AssertionError("subprocess.run must not be called when graphify is missing") monkeypatch.setattr(graphify_module.subprocess, "run", fail_run) - result = runner.invoke(app, ["build", str(tmp_path)]) + result = runner.invoke(app, ["build", "-C", str(tmp_path)]) assert result.exit_code == 2 assert "graphifyy" in result.output @@ -113,6 +123,6 @@ class _Result: monkeypatch.setattr(graphify_module.subprocess, "run", lambda *a, **kw: _Result()) - result = runner.invoke(app, ["build", str(tmp_path)]) + result = runner.invoke(app, ["build", "-C", str(tmp_path)]) assert result.exit_code == 7 diff --git a/tests/test_graphify.py b/tests/test_graphify.py index dd89b72..7baab6e 100644 --- a/tests/test_graphify.py +++ b/tests/test_graphify.py @@ -44,6 +44,9 @@ def test_register_with_cursor_skips_when_graphify_missing( ) -> None: """When graphify is not on PATH, register_with_cursor returns False, prints hints, and never calls subprocess.""" monkeypatch.setattr(graphify_module.shutil, "which", lambda _cmd: None) + # Isolate from any real graphify install on the dev machine; we want + # the "not installed at all" hint branch here, not the orphan branch. + monkeypatch.setattr(graphify_module, "_candidate_install_locations", lambda: []) def fail_run(*_a: Any, **_kw: Any) -> Any: raise AssertionError("subprocess.run must not be called when graphify is missing") @@ -131,6 +134,7 @@ def test_run_build_returns_2_and_prints_hints_when_missing( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: monkeypatch.setattr(graphify_module.shutil, "which", lambda _cmd: None) + monkeypatch.setattr(graphify_module, "_candidate_install_locations", lambda: []) def fail_run(*_a: Any, **_kw: Any) -> Any: raise AssertionError("subprocess.run must not be called when graphify is missing") @@ -146,10 +150,15 @@ def fail_run(*_a: Any, **_kw: Any) -> Any: assert "graphifyy" in text -def test_run_build_forwards_args_verbatim( +def test_run_build_no_args_uses_default_extract_subcommand( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: - """Extra args must pass straight through to graphify.""" + """`issue-flow build` with no args must invoke `graphify extract `. + + graphify is subcommand-based — `graphify ` alone fails with + `unknown command`. The default action for a "build" is `extract` + (full AST + semantic LLM build). + """ monkeypatch.setattr(graphify_module.shutil, "which", lambda _cmd: "/usr/bin/graphify") captured: dict[str, Any] = {} @@ -165,23 +174,126 @@ def fake_run(cmd: list[str], **kwargs: Any) -> _Result: monkeypatch.setattr(graphify_module.subprocess, "run", fake_run) console, _buffer = _fake_console() - exit_code = run_build(tmp_path, ["--update", "--no-viz", "--mode", "deep"], console) + exit_code = run_build(tmp_path, [], console) assert exit_code == 0 - assert captured["cmd"][0] == GRAPHIFY_COMMAND - # No explicit path argument from the user → run_build inserts the project dir - assert str(tmp_path) in captured["cmd"] - assert "--update" in captured["cmd"] - assert "--no-viz" in captured["cmd"] - assert "--mode" in captured["cmd"] - assert "deep" in captured["cmd"] + assert captured["cmd"] == [GRAPHIFY_COMMAND, "extract", str(tmp_path)] assert captured["cwd"] == tmp_path +def test_run_build_respects_explicit_subcommand_and_forwards_flags( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """A leading build subcommand picks the action; trailing flags forward verbatim.""" + monkeypatch.setattr(graphify_module.shutil, "which", lambda _cmd: "/usr/bin/graphify") + + captured: dict[str, Any] = {} + + class _Result: + returncode = 0 + + def fake_run(cmd: list[str], **kwargs: Any) -> _Result: + captured["cmd"] = cmd + return _Result() + + monkeypatch.setattr(graphify_module.subprocess, "run", fake_run) + console, _buffer = _fake_console() + + exit_code = run_build( + tmp_path, ["cluster-only", "--no-viz"], console + ) + + assert exit_code == 0 + # Project root must still be injected after the subcommand because + # the user did not pass an explicit path. + assert captured["cmd"] == [ + GRAPHIFY_COMMAND, + "cluster-only", + str(tmp_path), + "--no-viz", + ] + + +def test_run_build_update_subcommand_injects_project_root( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """`issue-flow build update` → `graphify update `.""" + monkeypatch.setattr(graphify_module.shutil, "which", lambda _cmd: "/usr/bin/graphify") + + captured: dict[str, Any] = {} + + class _Result: + returncode = 0 + + def fake_run(cmd: list[str], **kwargs: Any) -> _Result: + captured["cmd"] = cmd + return _Result() + + monkeypatch.setattr(graphify_module.subprocess, "run", fake_run) + console, _buffer = _fake_console() + + run_build(tmp_path, ["update"], console) + + assert captured["cmd"] == [GRAPHIFY_COMMAND, "update", str(tmp_path)] + + +def test_run_build_subcommand_with_explicit_path_is_trusted( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """If the user supplies both subcommand and path, do not double-add.""" + monkeypatch.setattr(graphify_module.shutil, "which", lambda _cmd: "/usr/bin/graphify") + + captured: dict[str, Any] = {} + + class _Result: + returncode = 0 + + def fake_run(cmd: list[str], **kwargs: Any) -> _Result: + captured["cmd"] = cmd + return _Result() + + monkeypatch.setattr(graphify_module.subprocess, "run", fake_run) + console, _buffer = _fake_console() + + run_build(tmp_path, ["extract", "./docs", "--no-cluster"], console) + + assert captured["cmd"] == [ + GRAPHIFY_COMMAND, + "extract", + "./docs", + "--no-cluster", + ] + + def test_run_build_does_not_inject_path_when_user_supplied_one( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: - """If the user passes ./subdir, do not also pass the project root.""" + """`issue-flow build ./docs` → `graphify extract ./docs` (no double path).""" + monkeypatch.setattr(graphify_module.shutil, "which", lambda _cmd: "/usr/bin/graphify") + + captured: dict[str, Any] = {} + + class _Result: + returncode = 0 + + def fake_run(cmd: list[str], **kwargs: Any) -> _Result: + captured["cmd"] = cmd + return _Result() + + monkeypatch.setattr(graphify_module.subprocess, "run", fake_run) + console, _buffer = _fake_console() + + run_build(tmp_path, ["./docs"], console) + + # Subcommand defaulted to extract; the only positional after it is + # the user's "./docs" — not the project root. + assert captured["cmd"] == [GRAPHIFY_COMMAND, "extract", "./docs"] + + +def test_run_build_leading_flag_falls_back_to_default_subcommand( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """A leading flag (no subcommand, no path) → `extract `.""" monkeypatch.setattr(graphify_module.shutil, "which", lambda _cmd: "/usr/bin/graphify") captured: dict[str, Any] = {} @@ -196,11 +308,14 @@ def fake_run(cmd: list[str], **kwargs: Any) -> _Result: monkeypatch.setattr(graphify_module.subprocess, "run", fake_run) console, _buffer = _fake_console() - run_build(tmp_path, ["./docs", "--update"], console) + run_build(tmp_path, ["--no-cluster"], console) - # Only one positional arg: the user's "./docs" — not the project root. - positional = [a for a in captured["cmd"][1:] if not a.startswith("-")] - assert positional == ["./docs"] + assert captured["cmd"] == [ + GRAPHIFY_COMMAND, + "extract", + str(tmp_path), + "--no-cluster", + ] def test_run_build_propagates_nonzero_exit_code(