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