mirror of
https://github.com/safishamsi/graphify.git
synced 2026-08-29 17:56:41 +00:00
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:
@@ -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
@@ -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()
|
||||
|
||||
@@ -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."
|
||||
)
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user