diff --git a/README.md b/README.md index 333411a..6d9f053 100644 --- a/README.md +++ b/README.md @@ -28,13 +28,14 @@ a precise edit with minimal token burn. ## Pipeline ``` +0. doctor.sh --fix self-heal: install deps, build/refresh graph (run once per repo) 1. qdrant-find "" recall prior context (skip on trivial lookups) 2. graphify query "" orient by topology — which abstracts are involved 3. beacon semantic-search fuzzy-locate files when the symbol name is unknown 4. serena find_symbol / pinpoint exact symbol + callers (the authority) - find_referencing_symbols + find_referencing_symbols (editing a hub / prepping a PR? blast_radius.py FIRST) 5. grep / Glob last resort -6. after change: graphify update . + qdrant-store the decision/gotcha +6. after change: graphify update . + graphify_to_qdrant.py + qdrant-store the decision ``` You don't always run all five — see `skills/codenav/SKILL.md` for the decision rule. @@ -77,6 +78,17 @@ flowchart TD - **fan-out**: `scripts/locate.sh ""` runs graphify now and prints the exact beacon + serena calls for the agent to merge. +Two surfaces derived from the same graph close the gap with dedicated graph-review and +visual-onboarding tools: + +- **change-impact**: `scripts/blast_radius.py --base origin/main` walks the dependents of + changed files N hops → impacted files/abstracts + per-seed test-coverage gaps + a + low/medium/high risk hint. Run it before editing a hub or prepping a PR to size the blast + radius first. Every hit is a graph-derived hypothesis — confirm hot edges with serena. +- **onboarding**: graphify already emits `graphify-out/GRAPH_REPORT.md` (god-nodes, abstracts, + suggested questions) and an interactive `*.html` map on every build. Read/open those to + orient a human on an unfamiliar repo; `graphify explain` / `graphify path` give walkthroughs. + Each tool's output sharpens another's input — the combine is a cycle, not a one-way pipe: ```mermaid @@ -134,11 +146,18 @@ Make scripts runnable and call them from your repo root (where `graphify-out/gra ```bash chmod +x scripts/*.sh scripts/*.py +bash scripts/doctor.sh --fix # one-command self-heal: deps + graph python3 scripts/graphify_to_qdrant.py --project --skip-barrels # emit architectural facts python3 scripts/beacon_enrich.py --file # situate a beacon hit +python3 scripts/blast_radius.py --base origin/main # change-impact before a PR bash scripts/locate.sh "" # fan-out locate ``` +`doctor.sh` is the bootstrap a fresh install runs first: it installs graphify + python deps if +missing, builds or refreshes `graphify-out/graph.json`, applies a `taxonomy.py` re-cluster when +present, checks every bundled script, and prints the agent-side MCP checks (qdrant/beacon/serena) +a shell cannot run. Bare `doctor.sh` diagnoses without changing anything. + `--skip-barrels` drops `index.ts` / `__init__.py` re-export files from the god-node list so the signal is real abstractions, not structural plumbing. Barrels that remain (in bridges) are disambiguated by parent dir (`index.ts (deps)`) instead of collapsing into one fake node. @@ -157,8 +176,8 @@ python3 tests/test_glue.py ``` Covers barrel disambiguation, `--skip-barrels`, the bridge metric (own + reached communities), -and beacon_enrich's three outcomes: full-path resolve, ambiguous bare filename (reports -candidates instead of silently merging), and unknown file. +beacon_enrich's three outcomes (full-path resolve, ambiguous bare filename, unknown file), and +blast_radius's reverse/forward dependent walk + per-seed test-coverage-gap detection. ### Wiring the four tools diff --git a/scripts/blast_radius.py b/scripts/blast_radius.py new file mode 100755 index 0000000..f8f985f --- /dev/null +++ b/scripts/blast_radius.py @@ -0,0 +1,244 @@ +#!/usr/bin/env python3 +"""blast-radius / change-impact off the graphify graph. + +The codenav combine already builds graphify-out/graph.json. This is the axis it +never used: "if I touch these files, what else breaks, and is any of it tested?" + +Given a set of changed files (explicit --files, or derived from a git ref via +--base, or the current working tree by default), it walks the dependency graph +to find the dependents — the nodes that point AT the changed code — out to N +hops, then groups them by file and by canonical abstract and flags changed files +that have no test node in their blast radius. + +Runs entirely off graph.json + git. No MCP calls — the agent runs it, reads the +result, and confirms the hot edges with serena (the live-source authority) before +trusting them. A graphify edge is a hypothesis, not a fact. + +Edge-direction assumption: a link source->target means "source depends on target" +(source imports/calls target). Dependents of X are therefore the nodes reached by +walking edges in REVERSE from X. If your graph encodes the opposite direction, +pass --forward to flip, or --undirected to ignore direction entirely. + +Usage: + python3 blast_radius.py --base origin/main + python3 blast_radius.py --files apps/api/src/modules/ai/chat/engine/loop.ts + python3 blast_radius.py # uncommitted working-tree changes + python3 blast_radius.py --base HEAD~3 --hops 3 --json +""" +import argparse +import json +import subprocess +from collections import defaultdict, deque +from pathlib import Path + +# source files matching these are test nodes — a changed file with a test in its +# blast radius is covered; one without is a coverage gap worth flagging. +TEST_MARKERS = ( + ".test.", ".spec.", "_test.", "-test.", ".tc.test.", + "/tests/", "/test/", "/__tests__/", "/spec/", +) + + +def _norm(p: str) -> str: + return p.replace("\\", "/").lstrip("./") + + +def _is_test(source_file: str) -> bool: + sf = _norm(source_file) + return any(m in sf for m in TEST_MARKERS) + + +def _git_changed(base: str | None) -> list[str]: + """Changed files: a diff against if given, else staged + unstaged + + untracked in the working tree. Returns [] on any git failure (not a repo, + bad ref) — the caller then requires --files.""" + cmds: list[list[str]] + if base: + cmds = [["git", "diff", "--name-only", f"{base}...HEAD"], + ["git", "diff", "--name-only", base]] + else: + cmds = [["git", "diff", "--name-only"], + ["git", "diff", "--name-only", "--staged"], + ["git", "ls-files", "--others", "--exclude-standard"]] + seen: list[str] = [] + for cmd in cmds: + try: + out = subprocess.run(cmd, capture_output=True, text=True, check=True).stdout + except (subprocess.CalledProcessError, FileNotFoundError): + continue + for line in out.splitlines(): + line = line.strip() + if line and line not in seen: + seen.append(line) + return seen + + +def main() -> None: + ap = argparse.ArgumentParser() + ap.add_argument("--graph", default="graphify-out/graph.json") + ap.add_argument("--labels", default="graphify-out/.graphify_labels.json") + ap.add_argument("--base", default=None, + help="git ref to diff against (e.g. origin/main, HEAD~3)") + ap.add_argument("--files", default=None, + help="comma-separated changed files (overrides git detection)") + ap.add_argument("--hops", type=int, default=2, help="dependency hops to walk") + ap.add_argument("--forward", action="store_true", + help="walk edges source->target (flip if your graph is inverted)") + ap.add_argument("--undirected", action="store_true", + help="ignore edge direction entirely") + ap.add_argument("--json", action="store_true", help="emit JSON instead of text") + args = ap.parse_args() + + gp = Path(args.graph) + if not gp.exists(): + raise SystemExit(f"no {gp} — build the graph first: graphify .") + g = json.loads(gp.read_text(encoding="utf-8")) + + labels: dict[str, str] = {} + lp = Path(args.labels) + if lp.exists(): + labels = json.loads(lp.read_text(encoding="utf-8")) + + if args.files: + changed = [c.strip() for c in args.files.split(",") if c.strip()] + source = "--files" + else: + changed = _git_changed(args.base) + source = f"git diff ({args.base or 'working tree'})" + if not changed: + raise SystemExit( + "no changed files detected — pass --files \"a,b\" or run inside a git " + "repo with uncommitted changes / a valid --base ref") + + nodes = g["nodes"] + label_of = {n["id"]: n.get("label", n["id"]) for n in nodes} + comm_of = {n["id"]: n.get("community", -1) for n in nodes} + file_of = {n["id"]: _norm(n.get("source_file", "")) for n in nodes} + abstract_of = {nid: labels.get(str(c), f"community {c}") for nid, c in comm_of.items()} + + changed_norm = [_norm(c) for c in changed] + + def in_changed(sf: str) -> bool: + return any(sf == c or sf.endswith("/" + c) or c.endswith("/" + sf) + for c in changed_norm) + + seed_ids = {nid for nid, sf in file_of.items() if sf and in_changed(sf)} + matched_files = sorted({file_of[i] for i in seed_ids}) + unmatched = [c for c in changed_norm + if not any(in_changed(file_of[i]) and file_of[i] and + (file_of[i] == c or file_of[i].endswith("/" + c) or + c.endswith("/" + file_of[i])) for i in seed_ids)] + + # adjacency: dependents walk REVERSE (incoming) by default + adj: dict[str, set] = defaultdict(set) + for link in g.get("links", []): + s, t = link["source"], link["target"] + if args.undirected: + adj[s].add(t) + adj[t].add(s) + elif args.forward: + adj[s].add(t) + else: # default: dependents = who points AT me = reverse + adj[t].add(s) + + # BFS out to N hops, recording the shortest hop distance per node and the set + # of originating seeds that reach it (provenance — needed for per-seed coverage). + dist: dict[str, int] = {i: 0 for i in seed_ids} + roots: dict[str, set] = {i: {i} for i in seed_ids} + q: deque = deque((i, 0) for i in seed_ids) + while q: + nid, d = q.popleft() + if d >= args.hops: + continue + for nb in adj.get(nid, ()): # noqa: B007 + if nb not in dist: + dist[nb] = d + 1 + roots[nb] = set(roots[nid]) + q.append((nb, d + 1)) + elif dist[nb] == d + 1: + roots[nb] |= roots[nid] + + impacted = {i for i in dist if i not in seed_ids} + + files_hit: dict[str, int] = {} + for i in impacted: + sf = file_of.get(i, "") + if sf: + files_hit[sf] = min(files_hit.get(sf, 99), dist[i]) + abstracts_hit = sorted({abstract_of[i] for i in impacted}) + + # test coverage (per-seed): a changed file is covered only if a test node whose + # provenance includes one of THAT file's seeds is in the radius. Crediting every + # seed whenever any test appears anywhere would mask real gaps on multi-file diffs. + covered: set = set() + for i in dist: + if _is_test(file_of.get(i, "")): + for s in roots.get(i, ()): + covered.add(file_of[s]) + test_gaps = sorted(f for f in matched_files if f not in covered) + + direction = ("undirected" if args.undirected + else "forward (source->target)" if args.forward + else "reverse (dependents)") + risk = "low" + if len(files_hit) >= 8 or len(abstracts_hit) >= 4: + risk = "high" + elif len(files_hit) >= 3 or len(abstracts_hit) >= 2: + risk = "medium" + + result = { + "changed_source": source, + "edge_direction": direction, + "hops": args.hops, + "changed_files_in_graph": matched_files, + "changed_files_not_in_graph": unmatched, + "impacted_file_count": len(files_hit), + "impacted_files": [ + {"file": f, "hop": h} for f, h in + sorted(files_hit.items(), key=lambda kv: (kv[1], kv[0])) + ], + "abstracts_spanned": abstracts_hit, + "test_coverage_gaps": test_gaps, + "risk_hint": risk, + "note": "graph-derived hypothesis — confirm hot edges with serena " + "find_referencing_symbols before trusting; run graphify update . if stale", + } + + if args.json: + print(json.dumps(result, ensure_ascii=False, indent=2)) + return + + print(f"=== blast radius: {source} ({direction}, {args.hops} hops) ===\n") + if not matched_files: + print("none of the changed files have nodes in the graph.") + print("run `graphify update .` — the graph is stale or these are new files.") + if unmatched: + print("\nchanged but absent from graph:") + for u in unmatched: + print(f" {u}") + return + print(f"changed (in graph): {len(matched_files)} file(s)") + for f in matched_files: + print(f" * {f}") + if unmatched: + print(f"\nchanged but NOT in graph ({len(unmatched)}) — possibly new/stale:") + for u in unmatched: + print(f" ? {u}") + print(f"\nimpacted: {len(files_hit)} file(s) across " + f"{len(abstracts_hit)} abstract(s) — risk: {risk.upper()}") + for f, h in sorted(files_hit.items(), key=lambda kv: (kv[1], kv[0]))[:40]: + print(f" {h}h {f}") + if len(files_hit) > 40: + print(f" ... +{len(files_hit) - 40} more") + print("\nabstracts touched: " + (", ".join(abstracts_hit) or "(none)")) + if test_gaps: + print(f"\nTEST COVERAGE GAPS ({len(test_gaps)}) — changed, no test in blast radius:") + for f in test_gaps: + print(f" ! {f}") + else: + print("\ntest coverage: every changed file has a test in its blast radius.") + print(f"\n{result['note']}") + + +if __name__ == "__main__": + main() diff --git a/scripts/doctor.sh b/scripts/doctor.sh new file mode 100755 index 0000000..8fcfabc --- /dev/null +++ b/scripts/doctor.sh @@ -0,0 +1,169 @@ +#!/usr/bin/env bash +# doctor.sh — codenav self-healing bootstrap + health check. +# +# Diagnoses (and with --fix, repairs) everything the codenav combine needs: +# * graphify CLI + its python deps (graphify, networkx) +# * graphify-out/graph.json present and fresh vs the current git HEAD +# * canonical-taxonomy re-cluster applied when a taxonomy.py is present +# * the bundled scripts are runnable +# It also prints the agent-side checks for the three MCP tools a shell cannot +# reach (qdrant, beacon, serena), so the in-session agent can verify them. +# +# Shell can only touch graphify + git + the filesystem. MCP servers are reachable +# only by the agent, so for those this script prints the exact call to make rather +# than pretending to verify them. +# +# Usage: +# doctor.sh # diagnose only — report health, change nothing +# doctor.sh --fix # self-heal: install deps, build/refresh graph, recluster +# doctor.sh --fix --quiet +# +# Exit: 0 = healthy (or all fixes applied), 1 = unhealthy and --fix not given / failed. +set -uo pipefail + +FIX=0 +QUIET=0 +for arg in "$@"; do + case "$arg" in + --fix) FIX=1 ;; + --quiet) QUIET=1 ;; + -h|--help) grep '^#' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;; + *) echo "unknown arg: $arg" >&2; exit 2 ;; + esac +done + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +GRAPH_DIR="graphify-out" +GRAPH="$GRAPH_DIR/graph.json" +LABELS="$GRAPH_DIR/.graphify_labels.json" +PYMARK="$GRAPH_DIR/.graphify_python" +UNHEALTHY=0 +FIXED=0 + +c_red=$'\033[31m'; c_grn=$'\033[32m'; c_ylw=$'\033[33m'; c_dim=$'\033[2m'; c_rst=$'\033[0m' +if [ ! -t 1 ] || [ "$QUIET" = 1 ]; then c_red=""; c_grn=""; c_ylw=""; c_dim=""; c_rst=""; fi + +say() { [ "$QUIET" = 1 ] || echo "$*"; } +ok() { say " ${c_grn}OK${c_rst} $*"; } +warn() { say " ${c_ylw}WARN${c_rst} $*"; } +bad() { say " ${c_red}FAIL${c_rst} $*"; UNHEALTHY=1; } +fixed(){ say " ${c_grn}FIXED${c_rst} $*"; FIXED=1; } + +# The interpreter graphify recorded at build time, if any; else python3. +PY="python3" +if [ -f "$PYMARK" ]; then + _rec="$(cat "$PYMARK" 2>/dev/null || true)" + [ -n "$_rec" ] && [ -x "$_rec" ] && PY="$_rec" +fi + +say "=== codenav doctor ${c_dim}($([ "$FIX" = 1 ] && echo 'self-heal' || echo 'diagnose-only'))${c_rst} ===" +say "" + +# --- 1. graphify CLI + python deps ------------------------------------------- +say "[1] graphify toolchain" +if command -v graphify >/dev/null 2>&1; then + ok "graphify on PATH ($(command -v graphify))" +else + if [ "$FIX" = 1 ] && command -v pip >/dev/null 2>&1; then + say " installing graphifyy ..." + if pip install --quiet graphifyy >/dev/null 2>&1; then + command -v graphify >/dev/null 2>&1 && fixed "graphify installed" || bad "pip ran but graphify still not on PATH" + else + bad "pip install graphifyy failed — install manually" + fi + else + bad "graphify not on PATH — run with --fix, or: pip install graphifyy" + fi +fi + +if "$PY" -c "import graphify, networkx" >/dev/null 2>&1; then + ok "python deps importable (graphify, networkx)" +else + if [ "$FIX" = 1 ] && command -v pip >/dev/null 2>&1; then + pip install --quiet graphifyy networkx >/dev/null 2>&1 \ + && { "$PY" -c "import graphify, networkx" >/dev/null 2>&1 \ + && fixed "python deps installed" || warn "installed but not importable under $PY — recluster may need the recorded interpreter"; } \ + || warn "could not install python deps (graphify, networkx) — recluster.py will be unavailable" + else + warn "graphify/networkx not importable under $PY — recluster.py needs them (run --fix)" + fi +fi + +# --- 2. graph.json present + fresh ------------------------------------------- +say "" +say "[2] knowledge graph" +in_git=0 +git rev-parse --git-dir >/dev/null 2>&1 && in_git=1 +HEAD_SHA="" +[ "$in_git" = 1 ] && HEAD_SHA="$(git rev-parse --short HEAD 2>/dev/null || true)" + +build_graph() { # $1 = build|update + command -v graphify >/dev/null 2>&1 || { bad "cannot $1 graph — graphify missing"; return 1; } + say " running: graphify $([ "$1" = update ] && echo 'update .' || echo '.') ..." + if [ "$1" = update ]; then graphify update . >/dev/null 2>&1; else graphify . --no-viz >/dev/null 2>&1 || graphify . >/dev/null 2>&1; fi +} + +if [ -f "$GRAPH" ]; then + built_at="$("$PY" -c "import json,sys; print(json.load(open('$GRAPH')).get('built_at_commit','') or '')" 2>/dev/null || true)" + if [ -n "$built_at" ] && [ -n "$HEAD_SHA" ] && [ "${built_at#"$HEAD_SHA"}" = "$built_at" ] && [ "${HEAD_SHA#"$built_at"}" = "$HEAD_SHA" ]; then + if [ "$FIX" = 1 ]; then + build_graph update && fixed "graph refreshed to HEAD ($HEAD_SHA)" || warn "graph update failed — using stale graph" + else + warn "graph built at '$built_at' but HEAD is '$HEAD_SHA' — stale (run --fix or: graphify update .)" + fi + else + ok "graph present$([ -n "$built_at" ] && echo " (built_at $built_at)")" + fi +else + if [ "$FIX" = 1 ]; then + build_graph build && [ -f "$GRAPH" ] && fixed "graph built" || bad "graph build failed" + else + bad "no $GRAPH — run --fix, or: graphify ." + fi +fi + +# --- 3. canonical-taxonomy re-cluster ---------------------------------------- +say "" +say "[3] taxonomy re-cluster" +if [ -f taxonomy.py ]; then + if [ -f "$LABELS" ]; then + ok "taxonomy.py present and labels applied ($LABELS)" + elif [ "$FIX" = 1 ] && [ -f "$GRAPH" ]; then + say " running: recluster.py --map taxonomy.py ..." + "$PY" "$SCRIPT_DIR/recluster.py" --map taxonomy.py >/dev/null 2>&1 \ + && [ -f "$LABELS" ] && fixed "re-clustered into canonical abstracts" \ + || warn "recluster failed — graph still uses Louvain communities" + else + warn "taxonomy.py present but not applied — run --fix to re-cluster into named abstracts" + fi +else + say " ${c_dim}no taxonomy.py — using graphify's Louvain communities (fine for most repos)${c_rst}" + say " ${c_dim}for named abstracts: cp $SCRIPT_DIR/taxonomy.example.py taxonomy.py, edit classify(), re-run --fix${c_rst}" +fi + +# --- 4. bundled scripts runnable --------------------------------------------- +say "" +say "[4] bundled scripts" +for s in blast_radius.py beacon_enrich.py graphify_to_qdrant.py recluster.py locate.sh; do + if [ -f "$SCRIPT_DIR/$s" ]; then ok "$s"; else bad "$s missing from $SCRIPT_DIR"; fi +done + +# --- 5. MCP tools (agent must verify — shell cannot reach them) -------------- +say "" +say "[5] MCP tools — ${c_dim}shell cannot reach these; agent verifies${c_rst}" +say " qdrant : call mcp__qdrant__qdrant-find with any keyword — empty result OK, an error = not wired" +say " beacon : run the beacon:index-status skill — expect a healthy index with chunks" +say " serena : call mcp__serena__check_onboarding_performed — 'no project activated' → run onboarding once" + +# --- summary ----------------------------------------------------------------- +say "" +if [ "$UNHEALTHY" = 1 ]; then + if [ "$FIX" = 1 ]; then + say "${c_ylw}=== still unhealthy after --fix — see FAIL lines above ===${c_rst}" + else + say "${c_red}=== unhealthy — re-run with --fix to self-heal ===${c_rst}" + fi + exit 1 +fi +[ "$FIXED" = 1 ] && say "${c_grn}=== healthy (repairs applied) ===${c_rst}" || say "${c_grn}=== healthy ===${c_rst}" +exit 0 diff --git a/skills/codenav/SKILL.md b/skills/codenav/SKILL.md index 9ac0fa9..75a0649 100644 --- a/skills/codenav/SKILL.md +++ b/skills/codenav/SKILL.md @@ -29,18 +29,29 @@ Key insight: graphify and serena both assume you roughly KNOW a name. beacon is point when you have only a fuzzy description. qdrant is the only one that crosses sessions. serena is the only one that reads live source (the other three read an index that can lag). +Derived from graphify's graph, two surfaces that close the gap with dedicated +graph-review and visual-onboarding tools: + +| Surface | Axis | The question it answers | How | +|---|---|---|---| +| **blast radius** | change-impact | "if I touch these files, what breaks, and is it tested?" | `scripts/blast_radius.py --base ` walks dependents N hops → impacted files/abstracts + per-seed test gaps + risk hint | +| **onboarding** | human-facing map | "teach me this unfamiliar repo / show the big picture" | `graphify-out/GRAPH_REPORT.md` + `*.html` (already emitted on build); `graphify explain` / `path` for walkthroughs | + ## The mandatory pipeline Run in this order. Each step narrows the search space for the next. ``` +0. doctor.sh --fix → self-heal the environment (deps, graph freshness) — run once on a new repo / when a tool misbehaves 1. qdrant-find "" → recall prior context for THIS repo (skip on trivial lookups) 2. graphify query "" → orient by topology; which abstracts/communities are involved 3. beacon semantic-search "" → fuzzy-locate candidate files when the symbol name is unknown 4. serena find_symbol / find_referencing_symbols → pinpoint exact symbol + callers (the authority) + (editing a hub or prepping a PR? → blast_radius.py FIRST: impacted files + test gaps + risk) 5. grep / Glob → last resort only 6. AFTER the change: graphify update . → keep the map fresh (AST-only, free) + graphify_to_qdrant.py → persist god-nodes/bridges as architectural-facts (self-improve) qdrant-store → persist any decision / gotcha / architectural fact ``` @@ -50,6 +61,8 @@ You do NOT always run all five. Decision rule: - Only a vague description ("the thing that retries failed calls")? → beacon first to surface candidates, then serena to confirm. - Pure "what is the architecture / where are the seams?" question? → graphify alone answers it; no serena needed. - Touching code you've edited before? → always qdrant-find first; you may have left a gotcha. +- About to edit a hub, or prepping a PR/review? → run `blast_radius.py` FIRST: it tells you what the change touches and which impacted files have no test, so you size the edit before making it. Confirm the hot edges with serena before trusting them. +- Onboarding onto an unfamiliar repo (human or you)? → read `graphify-out/GRAPH_REPORT.md` and open the `*.html` map before grepping; orient on the whole before the part. ## How they complement each other (sync directions) @@ -89,11 +102,58 @@ you genuinely don't know where to start. - serena is the authority on live code. If graphify/beacon disagree with serena, trust serena and flag the index as stale (`graphify update .`, beacon reindex). - A graphify INFERRED edge is a hypothesis, not a fact — verify with serena before acting. +- Blast radius is graph-derived: every impacted node and test gap is a hypothesis. Confirm hot edges with `serena find_referencing_symbols`; if changed files show "not in graph", run `graphify update .` (the graph is stale) before believing a small radius. - Never narrate memory ops. qdrant-find/store happen silently unless the result changes the answer. - Re-cluster graphify by canonical project taxonomy when Louvain over-fragments (see README). -## Setup +## Setup — agent does it for the downloader (one command) + +Someone who just installed this skill has nothing configured. Do NOT make them wire +four tools by hand. On the FIRST codenav task in a repo, run the self-healing bootstrap: + +``` +${CLAUDE_PLUGIN_ROOT}/scripts/doctor.sh --fix +``` + +It installs graphify + python deps if missing, builds `graphify-out/graph.json` if +absent, refreshes it if stale vs HEAD, applies a `taxonomy.py` re-cluster if present, +checks every bundled script, and prints the three agent-side MCP checks (qdrant, beacon, +serena) it cannot run itself. Then make those three MCP calls to confirm the tools are +wired; a missing one degrades gracefully (the doctrine still runs on whatever is present). + +`${CLAUDE_PLUGIN_ROOT}` is the plugin's install dir, expanded automatically when codenav +runs as a plugin. Installed manually? Substitute the path where you placed `scripts/`. +Full per-tool wiring + the re-cluster recipe live in `README.md`. + +## Self-healing — never fail on a stale or missing index + +The combine repairs itself instead of dead-ending: + +- A script that needs the graph and finds none / a stale one tells you the exact fix + (`graphify .` / `graphify update .`); run `doctor.sh --fix` to apply it in one shot. +- `blast_radius.py` reports changed files "not in graph" → the graph is stale; run + `graphify update .` and re-run rather than trusting a falsely-small radius. +- serena says "no project activated" → `check_onboarding_performed` once, retry. +- `recluster.py` re-execs under the interpreter graphify recorded (`.graphify_python`) + when graphify/networkx aren't importable under the current `python3`. +- Tool disagreement → serena (live source) wins; flag the index stale and refresh it. + +Rule: when a tool errors, READ the error — every script's failure message names its own +remedy. Re-run the remedy, don't fall back to blind grep. + +## Self-improving — each pass sharpens the next + +The combine gets better at THIS repo every time it runs. After a change (pipeline step 6): + +- `graphify update .` keeps the map fresh (AST-only, free) so the next blast-radius is accurate. +- `graphify_to_qdrant.py --project

` persists god-nodes / bridges / abstracts as + `kind: architectural-fact` → a future session recalls the topology with one + `qdrant-find`, skipping the rebuild. +- `qdrant-store` any decision / gotcha you hit (tag `metadata.project`) → the diary that + only qdrant crosses sessions on. +- When Louvain over-fragments, write a `taxonomy.py` once and `recluster.py`; from then on + blast-radius / enrich / qdrant facts all report named abstracts, compounding clarity. -This skill assumes four MCP servers / tools are available in the session: -graphify (CLI + `--mcp`), serena, beacon, qdrant. See `README.md` for wiring each one and the -project-taxonomy re-cluster recipe. +The loop: orient (graphify) → locate (beacon) → pinpoint (serena) → recall/persist +(qdrant). Outputs feed back as inputs, so the fourth pass on a repo is far cheaper and +sharper than the first. diff --git a/tests/test_glue.py b/tests/test_glue.py index 08cc825..8f26d8d 100644 --- a/tests/test_glue.py +++ b/tests/test_glue.py @@ -75,6 +75,49 @@ def test_hub_spans_three_communities(self): self.assertEqual(span, 3) # touches communities 0,1,2 +class TestBlastRadius(unittest.TestCase): + def _run(self, args: list[str], graph_path: Path) -> dict: + out = subprocess.run( + [sys.executable, str(SCRIPTS / "blast_radius.py"), + "--graph", str(graph_path), "--json", *args], + capture_output=True, text=True, check=True, + ) + return json.loads(out.stdout) + + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.gpath = Path(self.tmp.name) / "graph.json" + self.gpath.write_text(json.dumps(mini_graph()), encoding="utf-8") + + def tearDown(self): + self.tmp.cleanup() + + def test_reverse_dependents_of_leaf(self): + # default direction: dependents = who points AT the changed file. + # leaf1 (pkg/a/foo.ts) is pointed at by hub and a_index. + d = self._run(["--files", "pkg/a/foo.ts", "--hops", "2"], self.gpath) + hit = {f["file"] for f in d["impacted_files"]} + self.assertIn("app/core/root.ts", hit) + self.assertIn("pkg/a/index.ts", hit) + + def test_test_coverage_gap_flagged(self): + # mini_graph has no test nodes, so any changed file is an uncovered gap. + d = self._run(["--files", "pkg/a/foo.ts"], self.gpath) + self.assertIn("pkg/a/foo.ts", d["test_coverage_gaps"]) + + def test_unknown_file_reports_not_in_graph(self): + d = self._run(["--files", "does/not/exist.ts"], self.gpath) + self.assertEqual(d["changed_files_in_graph"], []) + self.assertIn("does/not/exist.ts", d["changed_files_not_in_graph"]) + + def test_forward_flips_direction(self): + # forward: hub depends on its targets, so editing hub impacts the leaves. + d = self._run(["--files", "app/core/root.ts", "--forward", "--hops", "1"], self.gpath) + hit = {f["file"] for f in d["impacted_files"]} + self.assertIn("pkg/a/foo.ts", hit) + self.assertIn("pkg/b/bar.ts", hit) + + class TestBeaconEnrich(unittest.TestCase): def _run(self, file_arg: str, graph_path: Path) -> dict: out = subprocess.run(