Merge pull request #891 from jattree/fix/install-guidance-query-first

Clean merge — fixes real token-bloat regression where GRAPH_REPORT.md was loaded on every question. All 10 install surfaces now route to graphify query first. Also fixes hidden bug where re-running graphify install after upgrade didn't refresh stale hook text.
This commit is contained in:
Safi
2026-05-16 14:13:01 +01:00
committed by GitHub
4 changed files with 475 additions and 55 deletions
+1 -1
View File
@@ -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 "<question>"` 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`).
+124 -54
View File
@@ -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 \"<question>\"` (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 "<question>"`, `graphify path "<A>" "<B>"`, or `graphify explain "<concept>"` over grep — these traverse the graph's EXTRACTED + INFERRED edges instead of scanning files
- For codebase questions, first run `graphify query "<question>"` when graphify-out/graph.json exists. Use `graphify path "<A>" "<B>"` for relationships and `graphify explain "<concept>"` 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 "<question>"`, `graphify path "<A>" "<B>"`, or `graphify explain "<concept>"` over grep — these traverse the graph's EXTRACTED + INFERRED edges instead of scanning files
- For codebase questions, first run `graphify query "<question>"` when graphify-out/graph.json exists. Use `graphify path "<A>" "<B>"` for relationships and `graphify explain "<concept>"` 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 "<question>"`, `graphify path "<A>" "<B>"`, or `graphify explain "<concept>"` over grep — these traverse the graph's EXTRACTED + INFERRED edges instead of scanning files
- For codebase questions, first run `graphify query "<question>"` when graphify-out/graph.json exists. Use `graphify path "<A>" "<B>"` for relationships and `graphify explain "<concept>"` 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 \"<question>\"` (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 "<question>"` when `graphify-out/graph.json`
exists. Use `graphify path "<A>" "<B>"` for relationship questions and `graphify explain "<concept>"`
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 <component>",
"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 "<question>"` (CLI) or `query_graph` (MCP). Use `graphify path "<A>" "<B>"` / `shortest_path` for relationships and `graphify explain "<concept>"` / `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 "<question>"`, `graphify path "<A>" "<B>"`, and `graphify explain "<concept>"` - 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 "<question>"` (or `graphify path "<A>" "<B>"` / `graphify explain "<concept>"`). \
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 "<question>"` (or `graphify path "<A>" "<B>"` / `graphify explain "<concept>"`). 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 \\"<question>\\"\\` (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()
+117
View File
@@ -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."
)
+233
View File
@@ -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 "<question>"`, `graphify path "<A>" "<B>"`, or `graphify explain "<concept>"` 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 <component>".
"""
_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