From d1a2c3f958ef6a3f88258b362e0d8f146742475a Mon Sep 17 00:00:00 2001 From: Jon Attree Date: Fri, 15 May 2026 20:17:52 -0700 Subject: [PATCH] Stop telling assistants to read GRAPH_REPORT.md first (#580) The current install writes "ALWAYS read graphify-out/GRAPH_REPORT.md before reading any source files, running grep/glob searches, or answering codebase questions" into CLAUDE.md and equivalents, plus a PreToolUse hook with the same instruction. On real corpora that report is 47-91K characters, so Claude Code sessions pay roughly 12-25K tokens of context up front for every search-able question. Three users on #580 reported this making token usage worse than no install at all. Reproduced on a 1500-file Go monorepo: a "where is X defined" question takes 5 tool calls and 34k agent tokens with stock graphify, 4 calls and 30k tokens with no install, and 1 call and 30k tokens after this patch. Stock graphify's Read of GRAPH_REPORT.md hit Claude Code's 25k token cap and failed entirely, then recovered via a partial read plus graphify explain. Demote GRAPH_REPORT.md to a fallback for broad architecture review and route first action to the existing scoped commands: graphify query, path, explain. The 2k-budget BFS subgraph already exists in serve.py; the install just wasn't pointing at it. Updated across all ten install surfaces: _SETTINGS_HOOK, _CLAUDE_MD_SECTION, _AGENTS_MD_SECTION, _GEMINI_MD_SECTION, _GEMINI_HOOK, _VSCODE_INSTRUCTIONS_SECTION, _ANTIGRAVITY_RULES, _KIRO_STEERING, _CURSOR_RULE, _OPENCODE_PLUGIN_JS. Plus the matching sentence in README.md, which also fixes an inaccuracy about Codex hooks (Codex's installed hook is intentionally a no-op because Codex rejects additionalContext, so the guidance there comes from AGENTS.md, not the hook). Five installers (claude, agents, vscode, gemini, kiro, cursor) were also writing their section only when no marker was present, so users who installed pre-fix kept the old "ALWAYS read" text after upgrading. Added _replace_or_append_section helper that updates in place when the graphify marker is found. claude_install also no longer returns before re-running _install_claude_hook, so stale settings.json hook payloads get refreshed on upgrade. Tests: - tests/test_install_strings.py (3): every install constant still mentions `graphify query` and matches no banned report-first regex. - tests/test_install_upgrade.py (7): seeds each platform's instruction file with pre-fix text, runs install, asserts the on-disk file reflects the new policy. - test_claude_md.py idempotency tests still pass. Fixes #580. --- README.md | 2 +- graphify/__main__.py | 178 ++++++++++++++++++-------- tests/test_install_strings.py | 117 +++++++++++++++++ tests/test_install_upgrade.py | 233 ++++++++++++++++++++++++++++++++++ 4 files changed, 475 insertions(+), 55 deletions(-) create mode 100644 tests/test_install_strings.py create mode 100644 tests/test_install_upgrade.py diff --git a/README.md b/README.md index ed183993..225d927f 100644 --- a/README.md +++ b/README.md @@ -174,7 +174,7 @@ Run this once in your project after building a graph: | Pi coding agent | `graphify pi install` | | Google Antigravity | `graphify antigravity install` | -This writes a small config file that tells your assistant to read `GRAPH_REPORT.md` before answering questions about your codebase. On platforms that support hooks (Claude Code, Codex, Gemini CLI), a hook fires automatically before every file-read call — your assistant navigates by the graph instead of grepping through everything. +This writes a small config file that tells your assistant to consult the knowledge graph for codebase questions — preferring scoped queries like `graphify query ""` over reading the full report or grepping raw files. On platforms that support payload-bearing hooks (Claude Code, Gemini CLI), a hook fires automatically before search-style tool calls and nudges your assistant toward the graph path. On the others (Codex, OpenCode, Cursor, etc.), the persistent instruction files (`AGENTS.md`, `.cursor/rules/`, etc.) provide the same query-first guidance. `GRAPH_REPORT.md` is still available for broad architecture review. To remove graphify from all platforms at once: `graphify uninstall` (add `--purge` to also delete `graphify-out/`). Or use the per-platform command (e.g. `graphify claude uninstall`). diff --git a/graphify/__main__.py b/graphify/__main__.py index dfdd465a..010805a8 100644 --- a/graphify/__main__.py +++ b/graphify/__main__.py @@ -62,7 +62,7 @@ _SETTINGS_HOOK = { "case \"$CMD\" in " r"*grep*|*rg\ *|*ripgrep*|*find\ *|*fd\ *|*ack\ *|*ag\ *) " " [ -f graphify-out/graph.json ] && " - r""" echo '{"hookSpecificOutput":{"hookEventName":"PreToolUse","additionalContext":"graphify: Knowledge graph exists. Read graphify-out/GRAPH_REPORT.md for god nodes and community structure before searching raw files."}}' """ + r""" echo '{"hookSpecificOutput":{"hookEventName":"PreToolUse","additionalContext":"graphify: knowledge graph at graphify-out/. For focused questions, run `graphify query \"\"` (scoped subgraph, usually much smaller than GRAPH_REPORT.md) instead of grepping raw files. Read GRAPH_REPORT.md only for broad architecture context."}}' """ " || true ;; " "esac" ), @@ -163,6 +163,51 @@ _PLATFORM_CONFIG: dict[str, dict] = { } +def _replace_or_append_section(content: str, marker: str, new_section: str) -> str: + """Idempotently update or append a graphify-owned section in shared files. + + If ``marker`` is not in ``content``, append ``new_section`` to the end + (with a blank-line separator if there's existing content). + + If ``marker`` IS in ``content``, replace the existing section in place. + The section runs from the first line containing ``marker`` to the line + before the next H2 heading (``## `` at line start), or to EOF if no later + H2 exists. This lets older installs receive the updated copy without + users having to uninstall and reinstall — important for the issue #580 + fix where existing report-first text would otherwise silently linger. + """ + if marker not in content: + if content.strip(): + return content.rstrip() + "\n\n" + new_section.lstrip() + return new_section.lstrip() + + lines = content.split("\n") + start = next((i for i, line in enumerate(lines) if marker in line), None) + if start is None: + return content.rstrip() + "\n\n" + new_section.lstrip() + + end = len(lines) + for j in range(start + 1, len(lines)): + if lines[j].startswith("## "): + end = j + break + + head = "\n".join(lines[:start]).rstrip() + tail = "\n".join(lines[end:]).lstrip() + section = new_section.strip() + + parts: list[str] = [] + if head: + parts.append(head) + parts.append(section) + if tail: + parts.append(tail) + out = "\n\n".join(parts) + if not out.endswith("\n"): + out += "\n" + return out + + def install(platform: str = "claude") -> None: if platform == "gemini": gemini_install() @@ -247,9 +292,9 @@ _CLAUDE_MD_SECTION = """\ This project has a knowledge graph at graphify-out/ with god nodes, community structure, and cross-file relationships. Rules: -- ALWAYS read graphify-out/GRAPH_REPORT.md before reading any source files, running grep/glob searches, or answering codebase questions. The graph is your primary map of the codebase. -- IF graphify-out/wiki/index.md EXISTS, navigate it instead of reading raw files -- For cross-module "how does X relate to Y" questions, prefer `graphify query ""`, `graphify path "" ""`, or `graphify explain ""` over grep — these traverse the graph's EXTRACTED + INFERRED edges instead of scanning files +- For codebase questions, first run `graphify query ""` when graphify-out/graph.json exists. Use `graphify path "" ""` for relationships and `graphify explain ""` for focused concepts. These return a scoped subgraph, usually much smaller than GRAPH_REPORT.md or raw grep output. +- If graphify-out/wiki/index.md exists, use it for broad navigation instead of raw source browsing. +- Read graphify-out/GRAPH_REPORT.md only for broad architecture review or when query/path/explain do not surface enough context. - After modifying code, run `graphify update .` to keep the graph current (AST-only, no API cost). """ @@ -265,9 +310,9 @@ This project has a knowledge graph at graphify-out/ with god nodes, community st When the user types `/graphify`, invoke the `skill` tool with `skill: "graphify"` before doing anything else. Rules: -- ALWAYS read graphify-out/GRAPH_REPORT.md before reading any source files, running grep/glob searches, or answering codebase questions. The graph is your primary map of the codebase. -- IF graphify-out/wiki/index.md EXISTS, navigate it instead of reading raw files -- For cross-module "how does X relate to Y" questions, prefer `graphify query ""`, `graphify path "" ""`, or `graphify explain ""` over grep — these traverse the graph's EXTRACTED + INFERRED edges instead of scanning files +- For codebase questions, first run `graphify query ""` when graphify-out/graph.json exists. Use `graphify path "" ""` for relationships and `graphify explain ""` for focused concepts. These return a scoped subgraph, usually much smaller than GRAPH_REPORT.md or raw grep output. +- If graphify-out/wiki/index.md exists, use it for broad navigation instead of raw source browsing. +- Read graphify-out/GRAPH_REPORT.md only for broad architecture review or when query/path/explain do not surface enough context. - After modifying code, run `graphify update .` to keep the graph current (AST-only, no API cost). """ @@ -279,9 +324,9 @@ _GEMINI_MD_SECTION = """\ This project has a knowledge graph at graphify-out/ with god nodes, community structure, and cross-file relationships. Rules: -- ALWAYS read graphify-out/GRAPH_REPORT.md before reading any source files, running grep/glob searches, or answering codebase questions. The graph is your primary map of the codebase. -- IF graphify-out/wiki/index.md EXISTS, navigate it instead of reading raw files -- For cross-module "how does X relate to Y" questions, prefer `graphify query ""`, `graphify path "" ""`, or `graphify explain ""` over grep — these traverse the graph's EXTRACTED + INFERRED edges instead of scanning files +- For codebase questions, first run `graphify query ""` when graphify-out/graph.json exists. Use `graphify path "" ""` for relationships and `graphify explain ""` for focused concepts. These return a scoped subgraph, usually much smaller than GRAPH_REPORT.md or raw grep output. +- If graphify-out/wiki/index.md exists, use it for broad navigation instead of raw source browsing. +- Read graphify-out/GRAPH_REPORT.md only for broad architecture review or when query/path/explain do not surface enough context. - After modifying code, run `graphify update .` to keep the graph current (AST-only, no API cost). """ @@ -297,7 +342,7 @@ _GEMINI_HOOK = { "import sys,pathlib,json;" "e=pathlib.Path('graphify-out/graph.json').exists();" "d={'decision':'allow'};" - "e and d.update({'additionalContext':'graphify: Knowledge graph exists. Read graphify-out/GRAPH_REPORT.md for god nodes and community structure before searching raw files.'});" + "e and d.update({'additionalContext':'graphify: knowledge graph at graphify-out/. For focused questions, run `graphify query \"\"` (scoped subgraph, usually much smaller than GRAPH_REPORT.md) instead of grepping raw files. Read GRAPH_REPORT.md only for broad architecture context.'});" "sys.stdout.write(json.dumps(d))" '"' ), @@ -324,15 +369,20 @@ def gemini_install(project_dir: Path | None = None) -> None: if target.exists(): content = target.read_text(encoding="utf-8") - if _GEMINI_MD_MARKER in content: - print("graphify already configured in GEMINI.md") - else: - target.write_text(content.rstrip() + "\n\n" + _GEMINI_MD_SECTION, encoding="utf-8") - print(f"graphify section written to {target.resolve()}") + new_content = _replace_or_append_section( + content, _GEMINI_MD_MARKER, _GEMINI_MD_SECTION + ) else: - target.write_text(_GEMINI_MD_SECTION, encoding="utf-8") + new_content = _GEMINI_MD_SECTION + + if target.exists() and new_content == target.read_text(encoding="utf-8"): + print(f"graphify already configured in {target.resolve()} (no change)") + else: + target.write_text(new_content, encoding="utf-8") print(f"graphify section written to {target.resolve()}") + # Always re-install the Gemini hook so an older payload (e.g. pre-issue-#580 + # wording) is replaced on upgrade. _install_gemini_hook(project_dir or Path(".")) print() print("Gemini CLI will now check the knowledge graph before answering") @@ -412,14 +462,18 @@ _VSCODE_INSTRUCTIONS_SECTION = """\ ## graphify For any question about this repo's architecture, structure, components, or how to add/modify/find -code, your **first tool call must be** to read `graphify-out/GRAPH_REPORT.md` (if it exists). +code, your first action should be `graphify query ""` when `graphify-out/graph.json` +exists. Use `graphify path "" ""` for relationship questions and `graphify explain ""` +for focused-concept questions. These return a scoped subgraph, usually much smaller than the full +report or raw grep output. Triggers: "how do I…", "where is…", "what does … do", "add/modify a ", "explain the architecture", or anything that depends on how files or classes relate. -After reading the report (and `graphify-out/wiki/index.md` for deep questions), answer from the -graph. Only read source files when (a) modifying/debugging specific code, (b) the graph lacks -the needed detail, or (c) the graph is missing or stale. +If `graphify-out/wiki/index.md` exists, use it for broad navigation. Read `graphify-out/GRAPH_REPORT.md` +only for broad architecture review or when query/path/explain do not surface enough context. Only read +source files when (a) modifying/debugging specific code, (b) the graph lacks the needed detail, or +(c) the graph is missing or stale. Type `/graphify` in Copilot Chat to build or update the graph. """ @@ -440,11 +494,14 @@ def vscode_install(project_dir: Path | None = None) -> None: instructions.parent.mkdir(parents=True, exist_ok=True) if instructions.exists(): content = instructions.read_text(encoding="utf-8") - if _VSCODE_INSTRUCTIONS_MARKER in content: + new_content = _replace_or_append_section( + content, _VSCODE_INSTRUCTIONS_MARKER, _VSCODE_INSTRUCTIONS_SECTION + ) + if new_content == content: print(f" {instructions} -> already configured (no change)") else: - instructions.write_text(content.rstrip() + "\n\n" + _VSCODE_INSTRUCTIONS_SECTION, encoding="utf-8") - print(f" {instructions} -> graphify section added") + instructions.write_text(new_content, encoding="utf-8") + print(f" {instructions} -> graphify section {'updated' if _VSCODE_INSTRUCTIONS_MARKER in content else 'added'}") else: instructions.write_text(_VSCODE_INSTRUCTIONS_SECTION, encoding="utf-8") print(f" {instructions} -> created") @@ -490,7 +547,7 @@ _ANTIGRAVITY_WORKFLOW_PATH = Path(".agents") / "workflows" / "graphify.md" _ANTIGRAVITY_RULES = """\ --- trigger: always_on -description: Always consult the graphify knowledge graph at graphify-out/ before answering codebase or architecture questions. +description: Consult the graphify knowledge graph at graphify-out/ for codebase and architecture questions. --- ## graphify @@ -498,10 +555,9 @@ description: Always consult the graphify knowledge graph at graphify-out/ before This project has a graphify knowledge graph at graphify-out/. Rules: -- Before answering architecture or codebase questions, read graphify-out/GRAPH_REPORT.md for god nodes and community structure +- For codebase or architecture questions, when `graphify-out/graph.json` exists, first run `graphify query ""` (CLI) or `query_graph` (MCP). Use `graphify path "" ""` / `shortest_path` for relationships and `graphify explain ""` / `get_node` for focused concepts. These return a scoped subgraph, usually much smaller than `GRAPH_REPORT.md` or raw grep output. - If graphify-out/wiki/index.md exists, navigate it instead of reading raw files -- If the graphify MCP server is active, utilize tools like `query_graph`, `get_node`, and `shortest_path` for precise architecture navigation instead of falling back to `grep` -- If the MCP server is not active, the CLI equivalents are `graphify query ""`, `graphify path "" ""`, and `graphify explain ""` - prefer these over grep for cross-module questions +- Read graphify-out/GRAPH_REPORT.md only for broad architecture review or when query/path/explain do not surface enough context - After modifying code files in this session, run `graphify update .` to keep the graph current (AST-only, no API cost) """ @@ -525,9 +581,10 @@ inclusion: always --- graphify: A knowledge graph of this project lives in `graphify-out/`. \ -If `graphify-out/GRAPH_REPORT.md` exists, read it before answering architecture questions, \ -tracing dependencies, or searching files — it contains god nodes, community structure, \ -and surprising connections the graph found. Navigate by graph structure instead of grepping raw files. +For codebase, architecture, or dependency questions, when `graphify-out/graph.json` exists, \ +first run `graphify query ""` (or `graphify path "" ""` / `graphify explain ""`). \ +These return a scoped subgraph, usually much smaller than `GRAPH_REPORT.md` or raw grep output. \ +Read `GRAPH_REPORT.md` only for broad architecture review or when those commands do not surface enough context. """ _KIRO_STEERING_MARKER = "graphify: A knowledge graph of this project" @@ -548,11 +605,14 @@ def _kiro_install(project_dir: Path) -> None: steering_dir = project_dir / ".kiro" / "steering" steering_dir.mkdir(parents=True, exist_ok=True) steering_dst = steering_dir / "graphify.md" - if steering_dst.exists() and _KIRO_STEERING_MARKER in steering_dst.read_text(encoding="utf-8"): - print(f" .kiro/steering/graphify.md -> already configured") + if steering_dst.exists() and steering_dst.read_text(encoding="utf-8") == _KIRO_STEERING: + print(f" .kiro/steering/graphify.md -> already configured (no change)") else: + # File is wholly graphify-owned. Overwrite on upgrade so older + # report-first wording does not silently linger (issue #580). + action = "updated" if steering_dst.exists() else "written" steering_dst.write_text(_KIRO_STEERING, encoding="utf-8") - print(f" .kiro/steering/graphify.md -> always-on steering written") + print(f" .kiro/steering/graphify.md -> always-on steering {action}") print() print("Kiro will now read the knowledge graph before every conversation.") @@ -604,7 +664,7 @@ def _antigravity_install(project_dir: Path) -> None: rules_path.write_text(_ANTIGRAVITY_RULES, encoding="utf-8") print(f"graphify rule updated at {rules_path.resolve()}") else: - print(f"graphify rule already up to date at {rules_path.resolve()}") + print(f"graphify rule already configured at {rules_path.resolve()} (no change)") else: rules_path.write_text(_ANTIGRAVITY_RULES, encoding="utf-8") print(f"graphify rule written to {rules_path.resolve()}") @@ -618,7 +678,7 @@ def _antigravity_install(project_dir: Path) -> None: wf_path.write_text(_ANTIGRAVITY_WORKFLOW, encoding="utf-8") print(f"graphify workflow updated at {wf_path.resolve()}") else: - print(f"graphify workflow already up to date at {wf_path.resolve()}") + print(f"graphify workflow already configured at {wf_path.resolve()} (no change)") else: wf_path.write_text(_ANTIGRAVITY_WORKFLOW, encoding="utf-8") print(f"graphify workflow written to {wf_path.resolve()}") @@ -674,8 +734,9 @@ alwaysApply: true This project has a graphify knowledge graph at graphify-out/. -- Before answering architecture or codebase questions, read graphify-out/GRAPH_REPORT.md for god nodes and community structure +- For codebase or architecture questions, when `graphify-out/graph.json` exists, first run `graphify query ""` (or `graphify path "" ""` / `graphify explain ""`). These return a scoped subgraph, usually much smaller than `GRAPH_REPORT.md` or raw grep output. - If graphify-out/wiki/index.md exists, navigate it instead of reading raw files +- Read graphify-out/GRAPH_REPORT.md only for broad architecture review or when query/path/explain do not surface enough context - After modifying code files in this session, run `graphify update .` to keep the graph current (AST-only, no API cost) """ @@ -684,11 +745,14 @@ def _cursor_install(project_dir: Path) -> None: """Write .cursor/rules/graphify.mdc with alwaysApply: true.""" rule_path = (project_dir or Path(".")) / _CURSOR_RULE_PATH rule_path.parent.mkdir(parents=True, exist_ok=True) - if rule_path.exists(): - print(f"graphify rule already exists at {rule_path} (no change)") + if rule_path.exists() and rule_path.read_text(encoding="utf-8") == _CURSOR_RULE: + print(f"graphify rule at {rule_path} already configured (no change)") return + # File is wholly graphify-owned. Overwrite on upgrade so older + # report-first wording does not silently linger (issue #580). + action = "updated" if rule_path.exists() else "written" rule_path.write_text(_CURSOR_RULE, encoding="utf-8") - print(f"graphify rule written to {rule_path.resolve()}") + print(f"graphify rule {action} at {rule_path.resolve()}") print() print("Cursor will now always include the knowledge graph context.") print("Run /graphify . first to build the graph if you haven't already.") @@ -722,7 +786,7 @@ export const GraphifyPlugin = async ({ directory }) => { if (input.tool === "bash") { output.args.command = - 'echo "[graphify] Knowledge graph available. Read graphify-out/GRAPH_REPORT.md for god nodes and architecture context before searching files." && ' + + 'echo "[graphify] knowledge graph at graphify-out/. For focused questions, run \\`graphify query \\"\\"\\` (scoped subgraph, usually much smaller than GRAPH_REPORT.md) instead of grepping raw files. Read GRAPH_REPORT.md only for broad architecture context." && ' + output.args.command; reminded = true; } @@ -880,13 +944,16 @@ def _agents_install(project_dir: Path, platform: str) -> None: if target.exists(): content = target.read_text(encoding="utf-8") - if _AGENTS_MD_MARKER in content: - print(f"graphify already configured in AGENTS.md") - else: - target.write_text(content.rstrip() + "\n\n" + _AGENTS_MD_SECTION, encoding="utf-8") - print(f"graphify section written to {target.resolve()}") + new_content = _replace_or_append_section( + content, _AGENTS_MD_MARKER, _AGENTS_MD_SECTION + ) else: - target.write_text(_AGENTS_MD_SECTION, encoding="utf-8") + new_content = _AGENTS_MD_SECTION + + if target.exists() and new_content == target.read_text(encoding="utf-8"): + print(f"graphify already configured in {target.resolve()} (no change)") + else: + target.write_text(new_content, encoding="utf-8") print(f"graphify section written to {target.resolve()}") if platform == "codex": @@ -939,17 +1006,20 @@ def claude_install(project_dir: Path | None = None) -> None: if target.exists(): content = target.read_text(encoding="utf-8") - if _CLAUDE_MD_MARKER in content: - print("graphify already configured in CLAUDE.md") - return - new_content = content.rstrip() + "\n\n" + _CLAUDE_MD_SECTION + new_content = _replace_or_append_section( + content, _CLAUDE_MD_MARKER, _CLAUDE_MD_SECTION + ) else: new_content = _CLAUDE_MD_SECTION - target.write_text(new_content, encoding="utf-8") - print(f"graphify section written to {target.resolve()}") + if target.exists() and new_content == target.read_text(encoding="utf-8"): + print(f"graphify already configured in {target.resolve()} (no change)") + else: + target.write_text(new_content, encoding="utf-8") + print(f"graphify section written to {target.resolve()}") - # Also write Claude Code PreToolUse hook to .claude/settings.json + # Always re-install the Claude Code PreToolUse hook so an old hook + # payload (e.g. pre-issue-#580 wording) is replaced on upgrade. _install_claude_hook(project_dir or Path(".")) print() diff --git a/tests/test_install_strings.py b/tests/test_install_strings.py new file mode 100644 index 00000000..a6f12ca3 --- /dev/null +++ b/tests/test_install_strings.py @@ -0,0 +1,117 @@ +"""Regression tests for install-time instruction strings. + +These strings live in graphify/__main__.py and are written into project-local +files (CLAUDE.md, AGENTS.md, GEMINI.md, .cursor/rules/, .kiro/steering/, etc.) +or into in-process hook payloads. Earlier versions of graphify told every +assistant to "ALWAYS read graphify-out/GRAPH_REPORT.md before answering" — +which silently increased per-question token usage in Claude Code sessions +(issue #580). This file locks in the query-first policy so a future revert +or partial change is caught by CI. +""" +from __future__ import annotations +import json + +from graphify.__main__ import ( + _SETTINGS_HOOK, + _CLAUDE_MD_SECTION, + _AGENTS_MD_SECTION, + _GEMINI_MD_SECTION, + _GEMINI_HOOK, + _VSCODE_INSTRUCTIONS_SECTION, + _ANTIGRAVITY_RULES, + _KIRO_STEERING, + _CURSOR_RULE, + _OPENCODE_PLUGIN_JS, +) + + +# All install-surface text rendered as plain strings, in one place. +# Hook constants are dicts/JSON; serialize them so we can do substring checks +# against the actual payload text the assistant will receive. +_INSTALL_TEXTS: dict[str, str] = { + "_SETTINGS_HOOK": json.dumps(_SETTINGS_HOOK), + "_CLAUDE_MD_SECTION": _CLAUDE_MD_SECTION, + "_AGENTS_MD_SECTION": _AGENTS_MD_SECTION, + "_GEMINI_MD_SECTION": _GEMINI_MD_SECTION, + "_GEMINI_HOOK": json.dumps(_GEMINI_HOOK), + "_VSCODE_INSTRUCTIONS_SECTION": _VSCODE_INSTRUCTIONS_SECTION, + "_ANTIGRAVITY_RULES": _ANTIGRAVITY_RULES, + "_KIRO_STEERING": _KIRO_STEERING, + "_CURSOR_RULE": _CURSOR_RULE, + "_OPENCODE_PLUGIN_JS": _OPENCODE_PLUGIN_JS, +} + + +def test_every_install_surface_recommends_graphify_query(): + """All ten install surfaces must point the assistant at `graphify query` + as the first action for codebase questions. This is the load-bearing + fix for issue #580 — the alternative (reading GRAPH_REPORT.md) costs + ~10x more tokens per question and made the project worse-than-baseline + in real Claude Code sessions.""" + missing: list[str] = [] + for name, text in _INSTALL_TEXTS.items(): + if "graphify query" not in text: + missing.append(name) + assert not missing, ( + f"these install surfaces no longer mention `graphify query`: {missing}. " + f"If you removed it intentionally, consider whether issue #580 is back." + ) + + +def test_no_install_surface_demands_reading_the_full_report_first(): + """The pre-fix instructions told assistants to read GRAPH_REPORT.md as + their first action for codebase questions. The new policy demotes the + report to a fallback; any phrasing that puts reading the report BEFORE + other actions for codebase questions is a regression of issue #580. + + Uses regex patterns instead of literal strings so a future revert that + rephrases ("MUST read", "Always consult", "first task is to open ...") + is also caught. Note: bare 'ALWAYS' is NOT banned because + ``alwaysApply: true`` (Cursor) and ``trigger: always_on`` (Antigravity) + are legitimate platform metadata, not the bug. + """ + import re + banned = [ + # "read ... GRAPH_REPORT.md ... before" + re.compile(r"read[^.\n]{0,80}GRAPH_REPORT\.md[^.\n]{0,80}before", re.IGNORECASE), + # "first tool call ... GRAPH_REPORT" (VS Code variant) + re.compile(r"first\s+tool\s+call[^.\n]{0,80}GRAPH_REPORT", re.IGNORECASE), + # "ALWAYS read ... GRAPH_REPORT" (catches the literal old text and minor variants) + re.compile(r"always\s+read[^.\n]{0,80}GRAPH_REPORT", re.IGNORECASE), + ] + hits: list[tuple[str, str]] = [] + for name, text in _INSTALL_TEXTS.items(): + for pattern in banned: + m = pattern.search(text) + if m: + hits.append((name, m.group(0))) + assert not hits, ( + f"banned report-first phrasing reappeared: {hits}. " + f"This regresses issue #580." + ) + + +def test_report_is_still_referenced_as_fallback(): + """The fix demotes GRAPH_REPORT.md, it doesn't delete the reference. + Most install surfaces should still mention the report as the deep-dive + artifact so users know it exists for broad architecture review. + (Hook payloads may or may not name the report; check the MD sections + explicitly — those are the rule lists assistants follow.)""" + md_section_texts = { + "_CLAUDE_MD_SECTION": _CLAUDE_MD_SECTION, + "_AGENTS_MD_SECTION": _AGENTS_MD_SECTION, + "_GEMINI_MD_SECTION": _GEMINI_MD_SECTION, + "_VSCODE_INSTRUCTIONS_SECTION": _VSCODE_INSTRUCTIONS_SECTION, + "_ANTIGRAVITY_RULES": _ANTIGRAVITY_RULES, + "_KIRO_STEERING": _KIRO_STEERING, + "_CURSOR_RULE": _CURSOR_RULE, + } + missing: list[str] = [] + for name, text in md_section_texts.items(): + if "GRAPH_REPORT.md" not in text: + missing.append(name) + assert not missing, ( + f"these install sections no longer mention GRAPH_REPORT.md at all: {missing}. " + f"The fix should demote the report, not delete the reference — users need to know " + f"it's available for broad-architecture queries." + ) diff --git a/tests/test_install_upgrade.py b/tests/test_install_upgrade.py new file mode 100644 index 00000000..09ee3d81 --- /dev/null +++ b/tests/test_install_upgrade.py @@ -0,0 +1,233 @@ +"""Installer-level regression tests for upgrade-in-place behavior (issue #580). + +Pre-fix, the installers wrote a "## graphify" section with report-first +instructions and skipped writing if the marker was already present. So users +who installed graphify and then upgraded to the fixed package still had the +old report-first text on disk — the bug stayed live for them. + +These tests seed each platform's instruction file with the old report-first +section, run the installer, and assert that the on-disk file now contains +the new query-first wording and does not contain the old report-first text. +""" +from __future__ import annotations +import json +from pathlib import Path + +import pytest + +import graphify.__main__ as mainmod + + +# A representative slice of the pre-fix text. Each platform's old install +# wrote a variant of "ALWAYS read graphify-out/GRAPH_REPORT.md before ...". +_OLD_CLAUDE_SECTION = """\ +## graphify + +This project has a knowledge graph at graphify-out/ with god nodes, community structure, and cross-file relationships. + +Rules: +- ALWAYS read graphify-out/GRAPH_REPORT.md before reading any source files, running grep/glob searches, or answering codebase questions. The graph is your primary map of the codebase. +- IF graphify-out/wiki/index.md EXISTS, navigate it instead of reading raw files +- For cross-module "how does X relate to Y" questions, prefer `graphify query ""`, `graphify path "" ""`, or `graphify explain ""` over grep — these traverse the graph's EXTRACTED + INFERRED edges instead of scanning files +- After modifying code, run `graphify update .` to keep the graph current (AST-only, no API cost). +""" + + +_OLD_AGENTS_SECTION = _OLD_CLAUDE_SECTION # identical pre-fix shape + +_OLD_GEMINI_SECTION = _OLD_CLAUDE_SECTION + +_OLD_VSCODE_SECTION = """\ +## graphify + +For any question about this repo's architecture, structure, components, or how to add/modify/find +code, your **first tool call must be** to read `graphify-out/GRAPH_REPORT.md` (if it exists). + +Triggers: "how do I…", "where is…", "what does … do", "add/modify a ". +""" + + +_OLD_CURSOR_RULE = """\ +--- +description: graphify knowledge graph context +alwaysApply: true +--- + +This project has a graphify knowledge graph at graphify-out/. + +- Before answering architecture or codebase questions, read graphify-out/GRAPH_REPORT.md for god nodes and community structure +- If graphify-out/wiki/index.md exists, navigate it instead of reading raw files +""" + + +_OLD_KIRO_STEERING = """\ +--- +inclusion: always +--- + +graphify: A knowledge graph of this project lives in `graphify-out/`. \ +If `graphify-out/GRAPH_REPORT.md` exists, read it before answering architecture questions, \ +tracing dependencies, or searching files — it contains god nodes, community structure, \ +and surprising connections the graph found. +""" + + +_OLD_HOOK_PAYLOAD_SNIPPET = "Read graphify-out/GRAPH_REPORT.md for god nodes and community structure before searching raw files" + + +def _assert_no_report_first(text: str, ctx: str) -> None: + assert "ALWAYS read graphify-out/GRAPH_REPORT.md" not in text, ( + f"{ctx}: old 'ALWAYS read' phrasing survived upgrade" + ) + assert "first tool call must be" not in text, ( + f"{ctx}: old VS Code 'first tool call must be' phrasing survived upgrade" + ) + + +def _assert_query_first(text: str, ctx: str) -> None: + assert "graphify query" in text, ( + f"{ctx}: new 'graphify query' guidance missing after upgrade" + ) + + +def test_claude_install_upgrades_stale_section(tmp_path, monkeypatch): + """A pre-fix CLAUDE.md gets the new section in place when the user runs + `graphify claude install` again after upgrading to a fixed package.""" + monkeypatch.chdir(tmp_path) + claude_md = tmp_path / "CLAUDE.md" + claude_md.write_text("# My Project\n\nSome description.\n\n" + _OLD_CLAUDE_SECTION, encoding="utf-8") + monkeypatch.setattr(mainmod, "_check_skill_version", lambda _: None) + + mainmod.claude_install(tmp_path) + + after = claude_md.read_text(encoding="utf-8") + _assert_no_report_first(after, "CLAUDE.md") + _assert_query_first(after, "CLAUDE.md") + # Pre-existing non-graphify content must be preserved + assert "# My Project" in after + assert "Some description." in after + + +def test_claude_install_upgrades_stale_hook_payload(tmp_path, monkeypatch): + """The Claude install must also rewrite a stale .claude/settings.json hook + payload on upgrade. Pre-fix, the install returned early when CLAUDE.md was + already configured, leaving the old hook in place.""" + monkeypatch.chdir(tmp_path) + claude_md = tmp_path / "CLAUDE.md" + claude_md.write_text(_OLD_CLAUDE_SECTION, encoding="utf-8") + settings = tmp_path / ".claude" / "settings.json" + settings.parent.mkdir(parents=True, exist_ok=True) + stale_settings = { + "hooks": { + "PreToolUse": [ + { + "matcher": "Bash", + "hooks": [ + { + "type": "command", + "command": ( + "case x in *) " + + _OLD_HOOK_PAYLOAD_SNIPPET + + " esac" + ), + } + ], + } + ] + } + } + settings.write_text(json.dumps(stale_settings), encoding="utf-8") + monkeypatch.setattr(mainmod, "_check_skill_version", lambda _: None) + + mainmod.claude_install(tmp_path) + + new_settings_text = settings.read_text(encoding="utf-8") + assert _OLD_HOOK_PAYLOAD_SNIPPET not in new_settings_text, ( + "stale hook payload survived upgrade" + ) + assert "graphify query" in new_settings_text, ( + "new hook payload should route to `graphify query`" + ) + + +def test_agents_install_upgrades_stale_section(tmp_path, monkeypatch): + """Same upgrade behavior for AGENTS.md (Codex / OpenCode / Aider / Trae).""" + monkeypatch.chdir(tmp_path) + agents_md = tmp_path / "AGENTS.md" + agents_md.write_text("# Project agents\n\n" + _OLD_AGENTS_SECTION, encoding="utf-8") + monkeypatch.setattr(mainmod, "_check_skill_version", lambda _: None) + + mainmod._agents_install(tmp_path, platform="codex") + + after = agents_md.read_text(encoding="utf-8") + _assert_no_report_first(after, "AGENTS.md") + _assert_query_first(after, "AGENTS.md") + assert "# Project agents" in after + + +def test_gemini_install_upgrades_stale_section(tmp_path, monkeypatch): + """Same upgrade behavior for GEMINI.md.""" + monkeypatch.chdir(tmp_path) + gemini_md = tmp_path / "GEMINI.md" + gemini_md.write_text(_OLD_GEMINI_SECTION, encoding="utf-8") + monkeypatch.setattr(mainmod, "_check_skill_version", lambda _: None) + + mainmod.gemini_install(tmp_path) + + after = gemini_md.read_text(encoding="utf-8") + _assert_no_report_first(after, "GEMINI.md") + _assert_query_first(after, "GEMINI.md") + + +def test_vscode_install_upgrades_stale_section(tmp_path, monkeypatch): + """Same upgrade behavior for .github/copilot-instructions.md (VS Code).""" + monkeypatch.chdir(tmp_path) + instructions = tmp_path / ".github" / "copilot-instructions.md" + instructions.parent.mkdir(parents=True, exist_ok=True) + instructions.write_text(_OLD_VSCODE_SECTION, encoding="utf-8") + monkeypatch.setattr(mainmod, "_check_skill_version", lambda _: None) + + mainmod.vscode_install(tmp_path) + + after = instructions.read_text(encoding="utf-8") + _assert_no_report_first(after, "copilot-instructions.md") + _assert_query_first(after, "copilot-instructions.md") + + +def test_cursor_install_upgrades_stale_rule(tmp_path, monkeypatch): + """Same upgrade behavior for .cursor/rules/graphify.mdc. + The Cursor rule file is wholly graphify-owned; overwrite on upgrade.""" + monkeypatch.chdir(tmp_path) + rule_path = tmp_path / ".cursor" / "rules" / "graphify.mdc" + rule_path.parent.mkdir(parents=True, exist_ok=True) + rule_path.write_text(_OLD_CURSOR_RULE, encoding="utf-8") + monkeypatch.setattr(mainmod, "_check_skill_version", lambda _: None) + + mainmod._cursor_install(tmp_path) + + after = rule_path.read_text(encoding="utf-8") + assert "read graphify-out/GRAPH_REPORT.md for god nodes and community structure" not in after + _assert_query_first(after, ".cursor/rules/graphify.mdc") + # YAML frontmatter must be preserved + assert "alwaysApply: true" in after + + +def test_kiro_install_upgrades_stale_steering(tmp_path, monkeypatch): + """Same upgrade behavior for .kiro/steering/graphify.md (wholly owned).""" + monkeypatch.chdir(tmp_path) + steering = tmp_path / ".kiro" / "steering" / "graphify.md" + steering.parent.mkdir(parents=True, exist_ok=True) + steering.write_text(_OLD_KIRO_STEERING, encoding="utf-8") + monkeypatch.setattr(mainmod, "_check_skill_version", lambda _: None) + + # Kiro install copies a skill file too; provide a minimal stand-in + skill_src = Path(mainmod.__file__).parent / "skill-kiro.md" + if not skill_src.exists(): + pytest.skip("skill-kiro.md not present in this checkout") + + mainmod._kiro_install(tmp_path) + + after = steering.read_text(encoding="utf-8") + assert "read it before answering architecture questions" not in after + _assert_query_first(after, ".kiro/steering/graphify.md") + assert "inclusion: always" in after # frontmatter preserved