From bdbe24530c55256a098dee196f5d0eafa20e9e7a Mon Sep 17 00:00:00 2001 From: Gal Shubeli Date: Mon, 17 Aug 2026 10:26:32 +0300 Subject: [PATCH 01/10] feat(understand-diff): optional graph query backend for retrieval Adds an opt-in, read-only query adapter over the existing knowledge-graph.json so understand-diff can resolve changed files and their blast radius with a query instead of a chain of greps. Nothing changes by default: the JSON stays the source of truth, no data is written back, and when no backend is configured the skill continues with the existing grep steps. Backends are picked automatically. FalkorDBLite runs embedded with no server or configuration; UA_FALKORDB_URL points at a FalkorDB instance instead. The graph is rebuilt only when the JSON's content hash changes. On this repo's own graph (793 nodes / 1390 edges) the blast-radius query returns the same 111 affected nodes as the grep walk. --- .../skills/understand-diff/SKILL.md | 22 ++ .../skills/understand-diff/graph-query.py | 321 ++++++++++++++++++ 2 files changed, 343 insertions(+) create mode 100644 understand-anything-plugin/skills/understand-diff/graph-query.py diff --git a/understand-anything-plugin/skills/understand-diff/SKILL.md b/understand-anything-plugin/skills/understand-diff/SKILL.md index 13880f31b..0fe63e679 100644 --- a/understand-anything-plugin/skills/understand-diff/SKILL.md +++ b/understand-anything-plugin/skills/understand-diff/SKILL.md @@ -52,6 +52,28 @@ The knowledge graph JSON has this structure: - If the committed diff or any working-tree command reports project files, warn before impact analysis that the graph may omit those changes. Suggest: Run `/understand` to refresh the graph. - Run the commit diff only when `GRAPH_COMMIT_RAW` resolves successfully. If the graph commit or Git metadata is missing, invalid, or unavailable, give a brief best-effort warning and continue instead of blocking. + **Optional fast path for steps 4–6.** Those steps walk the graph by grepping + `knowledge-graph.json` once per node id, which pulls a lot of JSON into context on + large graphs. If the optional query backend is configured, ask for the same thing in + one call instead: + + ```bash + python "/graph-query.py" batch --q '[ + {"op": "nodes-for-file", "path": ""}, + {"op": "blast-radius", "name": "", "hops": 3} + ]' + ``` + + `nodes-for-file` returns the file node plus every function and class defined in it, + which is what step 4 assembles by grepping. `blast-radius` returns the affected node + ids from step 5. Put every changed file in one `batch` call rather than calling once + per file — process startup dominates, the queries themselves are milliseconds. + + To check availability, run `python "/graph-query.py" stats`. If it exits + non-zero the backend is not configured — that is the normal default, so just continue + with steps 4–6 as written. The backend is read-only, reads the same + `knowledge-graph.json`, and writes nothing; the JSON remains the source of truth. + 4. **Find nodes for changed files** — for each changed file path, use Grep to search the knowledge graph for: - Nodes with matching `"filePath"` values (e.g., `grep "changed/file/path"`) - This finds file-level nodes (including non-code types) AND function/class nodes defined in those files diff --git a/understand-anything-plugin/skills/understand-diff/graph-query.py b/understand-anything-plugin/skills/understand-diff/graph-query.py new file mode 100644 index 000000000..578cbdd8b --- /dev/null +++ b/understand-anything-plugin/skills/understand-diff/graph-query.py @@ -0,0 +1,321 @@ +#!/usr/bin/env python3 +"""FalkorDB query adapter for Understand-Anything knowledge graphs. + +Read-only consumer of `.ua/knowledge-graph.json`. The JSON stays the source of +truth; this mirrors it into a graph so the skills can ask multi-hop questions +with a query instead of a chain of greps. + +Backends, picked automatically: + embedded FalkorDBLite, in-process, no server, no config (needs Python >= 3.12) + server any FalkorDB instance, via UA_FALKORDB_URL (e.g. redis://localhost:6379) + +The graph is rebuilt only when the JSON's content hash changes, so repeated +queries pay the load cost once. + +CLI + python graph-query.py search --q auth + python graph-query.py nodes-for-file --q src/types.ts + python graph-query.py neighbors --id "file:src/a.ts" + python graph-query.py blast-radius --name types.ts --hops 3 + python graph-query.py calls-from --name registerAllParsers + python graph-query.py calls-to --name validateGraph + python graph-query.py path --from "file:a.ts" --to "file:b.ts" + python graph-query.py cypher --q "MATCH (n:File) RETURN count(n)" + +Every command prints JSON on stdout. +""" +from __future__ import annotations + +import argparse +import hashlib +import json +import os +import sys +from pathlib import Path + +UA_DIRS = (".ua", ".understand-anything") +GRAPH_FILE = "knowledge-graph.json" +STAMP_KEY = "__ua_source_hash__" + +# UA edge types that mean "A depends on B", used by the blast-radius query. +DEPENDENCY_EDGES = ("IMPORTS", "DEPENDS_ON") + + +def find_graph_json(project_root: Path) -> Path: + """Locate the graph, honouring the legacy .understand-anything/ directory.""" + for d in UA_DIRS: + candidate = project_root / d / GRAPH_FILE + if candidate.exists(): + return candidate + raise SystemExit( + f"No {GRAPH_FILE} under {'/ or '.join(UA_DIRS)}/ in {project_root}. " + "Run /understand first." + ) + + +def label_for(node_type: str) -> str: + return "".join(part.capitalize() for part in node_type.split("_")) + + +class UAGraph: + """A UA knowledge graph, queryable over FalkorDB.""" + + def __init__(self, graph_json: Path, graph_name: str | None = None): + self.graph_json = graph_json + self.raw = json.loads(graph_json.read_text()) + self.source_hash = hashlib.sha256(graph_json.read_bytes()).hexdigest()[:16] + self.name = graph_name or self.raw.get("project", {}).get("name", "ua") + self.backend, self.graph = self._connect() + if self._stamp() != self.source_hash: + self._rebuild() + + # ---- backend selection ------------------------------------------------- + + def _connect(self): + url = os.environ.get("UA_FALKORDB_URL") + if url: + from falkordb import FalkorDB + from urllib.parse import urlparse + + parsed = urlparse(url) + db = FalkorDB(host=parsed.hostname or "localhost", port=parsed.port or 6379) + return "server", db.select_graph(self.name) + + try: + from redislite.falkordb_client import FalkorDB as EmbeddedFalkorDB + except ImportError: + raise SystemExit( + "No backend available. Either `pip install falkordblite` " + "(embedded, needs Python >= 3.12) or set UA_FALKORDB_URL to a " + "running FalkorDB instance." + ) + + db_path = self.graph_json.parent / "falkordb.db" + db = EmbeddedFalkorDB(str(db_path)) + return "embedded", db.select_graph(self.name) + + # ---- build ------------------------------------------------------------- + + def _stamp(self) -> str | None: + """Hash of the JSON this graph was last built from, if any.""" + try: + res = self.graph.query( + f"MATCH (s:{STAMP_KEY}) RETURN s.hash LIMIT 1" + ).result_set + return res[0][0] if res else None + except Exception: + return None # graph does not exist yet + + def _rebuild(self) -> None: + try: + self.graph.delete() + except Exception: + pass # nothing to delete on a first run + + self.graph.query("CREATE INDEX FOR (n:Node) ON (n.id)") + + for n in self.raw.get("nodes", []): + line_range = n.get("lineRange") or [] + self.graph.query( + f"CREATE (x:Node:{label_for(n['type'])} {{" + "id: $id, type: $type, name: $name, filePath: $filePath, " + "summary: $summary, tags: $tags, complexity: $complexity, " + "lineStart: $lineStart, lineEnd: $lineEnd})", + { + "id": n["id"], + "type": n["type"], + "name": n.get("name", ""), + "filePath": n.get("filePath", ""), + "summary": n.get("summary", ""), + "tags": n.get("tags", []), + "complexity": n.get("complexity", ""), + "lineStart": line_range[0] if len(line_range) == 2 else -1, + "lineEnd": line_range[1] if len(line_range) == 2 else -1, + }, + ) + + for e in self.raw.get("edges", []): + self.graph.query( + "MATCH (a:Node {id: $src}), (b:Node {id: $dst}) " + f"CREATE (a)-[:{e['type'].upper()} {{type: $type, " + "direction: $direction, weight: $weight}]->(b)", + { + "src": e["source"], + "dst": e["target"], + "type": e["type"], + "direction": e.get("direction", "forward"), + "weight": e.get("weight", 0.0), + }, + ) + + self.graph.query( + f"CREATE (:{STAMP_KEY} {{hash: $h}})", {"h": self.source_hash} + ) + + # ---- queries ----------------------------------------------------------- + + def _rows(self, cypher: str, params: dict | None = None) -> list: + return self.graph.query(cypher, params or {}).result_set + + def search(self, term: str, limit: int = 25) -> list[dict]: + rows = self._rows( + "MATCH (n:Node) WHERE toLower(n.name) CONTAINS toLower($t) " + "OR toLower(n.summary) CONTAINS toLower($t) " + "OR toLower(n.filePath) CONTAINS toLower($t) " + "RETURN n.id, n.type, n.name, n.filePath, n.summary " + "ORDER BY n.id LIMIT $lim", + {"t": term, "lim": limit}, + ) + return [ + dict(zip(("id", "type", "name", "filePath", "summary"), r)) for r in rows + ] + + def nodes_for_file(self, path: str) -> list[dict]: + """Every node defined in a file: the file node plus its functions/classes. + + This is what `understand-diff` needs for a changed path. Paths in the graph + are relative to the project root, so a suffix match keeps it working when the + caller passes an absolute or repo-prefixed path. + """ + rows = self._rows( + "MATCH (n:Node) WHERE n.filePath = $p OR n.filePath ENDS WITH $suffix " + "RETURN n.id, n.type, n.name, n.filePath ORDER BY n.id", + {"p": path, "suffix": "/" + path.lstrip("/")}, + ) + return [dict(zip(("id", "type", "name", "filePath"), r)) for r in rows] + + def neighbors(self, node_id: str) -> list[dict]: + rows = self._rows( + "MATCH (n:Node {id: $id})-[r]-(m:Node) " + "RETURN type(r), m.id, m.type, m.name ORDER BY m.id", + {"id": node_id}, + ) + return [dict(zip(("edge", "id", "type", "name"), r)) for r in rows] + + def blast_radius(self, name: str, hops: int = 3) -> list[str]: + """Everything that transitively depends on the named node. + + This is what `understand-diff` needs: given a changed file, what else + could be affected. + """ + rels = "|".join(DEPENDENCY_EDGES) + rows = self._rows( + f"MATCH (t:Node)<-[:{rels}*1..{hops}]-(d:Node) " + "WHERE t.name = $n RETURN DISTINCT d.id ORDER BY d.id", + {"n": name}, + ) + return [r[0] for r in rows] + + def calls_from(self, name: str, hops: int = 3) -> list[str]: + rows = self._rows( + f"MATCH (s:Node)-[:CALLS*1..{hops}]->(x:Node) " + "WHERE s.name = $n RETURN DISTINCT x.id ORDER BY x.id", + {"n": name}, + ) + return [r[0] for r in rows] + + def calls_to(self, name: str, hops: int = 2) -> list[str]: + rows = self._rows( + f"MATCH (t:Node)<-[:CALLS*1..{hops}]-(c:Node) " + "WHERE t.name = $n RETURN DISTINCT c.id ORDER BY c.id", + {"n": name}, + ) + return [r[0] for r in rows] + + def path(self, src: str, dst: str, max_hops: int = 6) -> list[str]: + # FalkorDB wants shortestPath in WITH/RETURN and a directed pattern. + rows = self._rows( + "MATCH (a:Node {id: $a}), (b:Node {id: $b}) " + f"RETURN [n IN nodes(shortestPath((a)-[*..{max_hops}]->(b))) | n.id]", + {"a": src, "b": dst}, + ) + return rows[0][0] if rows and rows[0][0] else [] + + def stats(self) -> dict: + return { + "project": self.raw.get("project", {}).get("name"), + "backend": self.backend, + "graph": self.name, + "nodes": self._rows("MATCH (n:Node) RETURN count(n)")[0][0], + "edges": self._rows("MATCH ()-[r]->() RETURN count(r)")[0][0], + "source": str(self.graph_json), + "sourceHash": self.source_hash, + } + + +def run_one(ua: "UAGraph", spec: dict): + """Dispatch a single {op, ...} request against an open graph.""" + op = spec.get("op") + if op == "stats": + return ua.stats() + if op == "search": + return ua.search(spec["q"], spec.get("limit", 25)) + if op == "nodes-for-file": + return ua.nodes_for_file(spec["path"]) + if op == "neighbors": + return ua.neighbors(spec["id"]) + if op == "blast-radius": + return ua.blast_radius(spec["name"], spec.get("hops", 3)) + if op == "calls-from": + return ua.calls_from(spec["name"], spec.get("hops", 3)) + if op == "calls-to": + return ua.calls_to(spec["name"], spec.get("hops", 2)) + if op == "path": + return ua.path(spec["from"], spec["to"], spec.get("hops", 6)) + if op == "cypher": + return ua._rows(spec["q"]) + raise SystemExit(f"unknown op: {op}") + + +def main() -> None: + p = argparse.ArgumentParser(description=__doc__.split("\n")[0]) + p.add_argument("command", choices=[ + "stats", "search", "neighbors", "blast-radius", + "calls-from", "calls-to", "path", "cypher", "batch", "nodes-for-file", + ]) + p.add_argument("--root", default=".", help="project root (default: cwd)") + p.add_argument("--q", help="search term or raw Cypher") + p.add_argument("--id", help="node id") + p.add_argument("--name", help="node name") + p.add_argument("--hops", type=int, default=3) + p.add_argument("--from", dest="src") + p.add_argument("--to", dest="dst") + args = p.parse_args() + + ua = UAGraph(find_graph_json(Path(args.root).resolve())) + + def need(value, flag): + if not value: + sys.exit(f"{args.command} requires {flag}") + return value + + if args.command == "batch": + # Many questions, one process. The embedded server's boot and shutdown + # dominate a single-shot invocation, so a skill answering a multi-part + # question should send its whole plan at once. + specs = json.loads(args.q) if args.q else json.load(sys.stdin) + out = [run_one(ua, s) for s in specs] + elif args.command == "stats": + out = ua.stats() + elif args.command == "search": + out = ua.search(need(args.q, "--q")) + elif args.command == "nodes-for-file": + out = ua.nodes_for_file(need(args.q, "--q")) + elif args.command == "neighbors": + out = ua.neighbors(need(args.id, "--id")) + elif args.command == "blast-radius": + out = ua.blast_radius(need(args.name, "--name"), args.hops) + elif args.command == "calls-from": + out = ua.calls_from(need(args.name, "--name"), args.hops) + elif args.command == "calls-to": + out = ua.calls_to(need(args.name, "--name"), args.hops) + elif args.command == "path": + out = ua.path(need(args.src, "--from"), need(args.dst, "--to"), args.hops) + else: + out = ua._rows(need(args.q, "--q")) + + print(json.dumps(out, indent=2, default=str)) + + +if __name__ == "__main__": + main() From 24f8de66ab636df0a7699f917b22140b97c8d76d Mon Sep 17 00:00:00 2001 From: Gal Shubeli Date: Mon, 17 Aug 2026 11:43:49 +0300 Subject: [PATCH 02/10] feat(understand-diff): incremental sync, semantic search, multi-repo workspaces Extends the query adapter to cover the rest of what was proposed in #649, all opt-in and all still read-only with respect to knowledge-graph.json. Incremental sync: a digest is stored per source file, so re-syncing after a /understand refresh replaces only the files whose nodes changed. Editing one file resyncs 1 file / 10 nodes instead of 379 / 793, and node and edge counts are preserved exactly because edges touching a rebuilt node are re-created. Semantic search: when UA_EMBED_URL points at an embedding endpoint, node vectors are built during sync and indexed. semantic-traverse seeds from vector similarity and traverses in a single statement. Vectors live only in the graph, never in the JSON, which would grow by ~1.2MB. Without the env var nothing changes. Multi-repo: --workspace loads several repos into one graph with ids namespaced per repo, and links repos that declare each other in package.json, so impact analysis crosses repo boundaries. File-level cross-repo edges need the scan phase's import map and are left as a follow-up. Single-repo behaviour is unchanged: blast-radius, calls-from, calls-to and nodes-for-file return identical results to before, with un-namespaced ids. --- .../skills/understand-diff/SKILL.md | 9 + .../skills/understand-diff/graph-query.py | 444 ++++++++++++++---- 2 files changed, 358 insertions(+), 95 deletions(-) diff --git a/understand-anything-plugin/skills/understand-diff/SKILL.md b/understand-anything-plugin/skills/understand-diff/SKILL.md index 0fe63e679..994840ca5 100644 --- a/understand-anything-plugin/skills/understand-diff/SKILL.md +++ b/understand-anything-plugin/skills/understand-diff/SKILL.md @@ -74,6 +74,15 @@ The knowledge graph JSON has this structure: with steps 4–6 as written. The backend is read-only, reads the same `knowledge-graph.json`, and writes nothing; the JSON remains the source of truth. + Re-syncing after a `/understand` refresh is incremental: only files whose nodes + actually changed are replaced, so a one-file edit costs one file of work. + + Two further options, both off unless configured — see `graph-query.py --help`: + `semantic` and `semantic-traverse` rank nodes by meaning rather than substring when + `UA_EMBED_URL` points at an embedding endpoint, which helps when a diff touches code + the user describes in their own words. `--workspace ` loads several + repos into one graph so impact analysis can cross repo boundaries. + 4. **Find nodes for changed files** — for each changed file path, use Grep to search the knowledge graph for: - Nodes with matching `"filePath"` values (e.g., `grep "changed/file/path"`) - This finds file-level nodes (including non-code types) AND function/class nodes defined in those files diff --git a/understand-anything-plugin/skills/understand-diff/graph-query.py b/understand-anything-plugin/skills/understand-diff/graph-query.py index 578cbdd8b..27923e9ab 100644 --- a/understand-anything-plugin/skills/understand-diff/graph-query.py +++ b/understand-anything-plugin/skills/understand-diff/graph-query.py @@ -3,24 +3,37 @@ Read-only consumer of `.ua/knowledge-graph.json`. The JSON stays the source of truth; this mirrors it into a graph so the skills can ask multi-hop questions -with a query instead of a chain of greps. +with a query instead of a chain of greps. Nothing is ever written back. Backends, picked automatically: embedded FalkorDBLite, in-process, no server, no config (needs Python >= 3.12) server any FalkorDB instance, via UA_FALKORDB_URL (e.g. redis://localhost:6379) -The graph is rebuilt only when the JSON's content hash changes, so repeated -queries pay the load cost once. +Sync is incremental. A digest is stored per source file, so re-syncing after an +edit replaces only the nodes of files that actually changed rather than +rebuilding the whole graph. + +Semantic search is optional. Set UA_EMBED_URL to a text-embedding endpoint that +accepts {"inputs": [...]} and returns a list of vectors (e.g. a local +text-embeddings-inference server) and node vectors are built during sync. +Without it, everything except the `semantic*` commands works unchanged. + +Workspaces let several repos share one graph so traversals cross repo +boundaries. Point --workspace at a JSON file: {"repos": ["../api", "../web"]}. CLI - python graph-query.py search --q auth - python graph-query.py nodes-for-file --q src/types.ts - python graph-query.py neighbors --id "file:src/a.ts" - python graph-query.py blast-radius --name types.ts --hops 3 - python graph-query.py calls-from --name registerAllParsers - python graph-query.py calls-to --name validateGraph - python graph-query.py path --from "file:a.ts" --to "file:b.ts" - python graph-query.py cypher --q "MATCH (n:File) RETURN count(n)" + python graph-query.py stats + python graph-query.py search --q auth + python graph-query.py nodes-for-file --q src/types.ts + python graph-query.py neighbors --id "file:src/a.ts" + python graph-query.py blast-radius --name types.ts --hops 3 + python graph-query.py calls-from --name registerAllParsers + python graph-query.py calls-to --name validateGraph + python graph-query.py path --from "file:a.ts" --to "file:b.ts" + python graph-query.py semantic --q "how are imports resolved" + python graph-query.py semantic-traverse --q "graph persistence" --hops 2 + python graph-query.py cypher --q "MATCH (n:File) RETURN count(n)" + python graph-query.py batch --q '[{"op":"blast-radius","name":"a.ts"}]' Every command prints JSON on stdout. """ @@ -31,15 +44,19 @@ import json import os import sys +import urllib.request from pathlib import Path UA_DIRS = (".ua", ".understand-anything") GRAPH_FILE = "knowledge-graph.json" -STAMP_KEY = "__ua_source_hash__" +FILE_STAMP = "__ua_file__" # UA edge types that mean "A depends on B", used by the blast-radius query. DEPENDENCY_EDGES = ("IMPORTS", "DEPENDS_ON") +EMBED_BATCH = 8 # the local TEI server rejects much larger payloads +EMBED_TEXT_CHARS = 150 # summaries are long; the head of one is enough to rank on + def find_graph_json(project_root: Path) -> Path: """Locate the graph, honouring the legacy .understand-anything/ directory.""" @@ -57,25 +74,86 @@ def label_for(node_type: str) -> str: return "".join(part.capitalize() for part in node_type.split("_")) +def digest(payload) -> str: + return hashlib.sha256( + json.dumps(payload, sort_keys=True).encode() + ).hexdigest()[:16] + + +# --------------------------------------------------------------------------- # +# embeddings (optional) +# --------------------------------------------------------------------------- # + +class Embedder: + """Thin client for an {"inputs": [...]} -> [[float]] embedding endpoint.""" + + def __init__(self, url: str): + self.url = url + self.dimension = len(self.encode(["dimension probe"])[0]) + + def encode(self, texts: list[str]) -> list[list[float]]: + out: list[list[float]] = [] + for i in range(0, len(texts), EMBED_BATCH): + chunk = texts[i:i + EMBED_BATCH] + req = urllib.request.Request( + self.url, + data=json.dumps({"inputs": chunk, "truncate": True}).encode(), + headers={"Content-Type": "application/json"}, + ) + out.extend(json.loads(urllib.request.urlopen(req, timeout=120).read())) + return out + + @staticmethod + def text_for(node: dict) -> str: + return " ".join([ + node.get("name", ""), + (node.get("summary") or "")[:EMBED_TEXT_CHARS], + " ".join(node.get("tags") or []), + ]).strip() + + +# --------------------------------------------------------------------------- # +# graph +# --------------------------------------------------------------------------- # + class UAGraph: - """A UA knowledge graph, queryable over FalkorDB.""" + """One or more UA knowledge graphs, queryable over FalkorDB.""" + + def __init__(self, sources: list[tuple[str | None, Path]], graph_name: str | None = None): + """sources is [(repo_or_None, path_to_knowledge_graph_json)]. + + repo is None for the single-repo case, which keeps node ids exactly as + they appear in the JSON so existing callers are unaffected. + """ + self.sources = sources + self.repos = [r for r, _ in sources if r] + first = json.loads(sources[0][1].read_text()) + self.name = graph_name or ( + "workspace" if self.repos else first.get("project", {}).get("name", "ua") + ) + self.embedder = self._make_embedder() + self.backend, self.graph = self._connect(sources[0][1]) + self.synced = self._sync() - def __init__(self, graph_json: Path, graph_name: str | None = None): - self.graph_json = graph_json - self.raw = json.loads(graph_json.read_text()) - self.source_hash = hashlib.sha256(graph_json.read_bytes()).hexdigest()[:16] - self.name = graph_name or self.raw.get("project", {}).get("name", "ua") - self.backend, self.graph = self._connect() - if self._stamp() != self.source_hash: - self._rebuild() + # ---- optional dependencies -------------------------------------------- - # ---- backend selection ------------------------------------------------- + @staticmethod + def _make_embedder() -> Embedder | None: + url = os.environ.get("UA_EMBED_URL") + if not url: + return None + try: + return Embedder(url) + except Exception as exc: # unreachable endpoint must not break plain queries + print(f"warning: UA_EMBED_URL unreachable ({exc}); " + "semantic commands disabled", file=sys.stderr) + return None - def _connect(self): + def _connect(self, anchor: Path): url = os.environ.get("UA_FALKORDB_URL") if url: - from falkordb import FalkorDB from urllib.parse import urlparse + from falkordb import FalkorDB parsed = urlparse(url) db = FalkorDB(host=parsed.hostname or "localhost", port=parsed.port or 6379) @@ -90,67 +168,190 @@ def _connect(self): "running FalkorDB instance." ) - db_path = self.graph_json.parent / "falkordb.db" - db = EmbeddedFalkorDB(str(db_path)) + db = EmbeddedFalkorDB(str(anchor.parent / "falkordb.db")) return "embedded", db.select_graph(self.name) - # ---- build ------------------------------------------------------------- + # ---- incremental sync -------------------------------------------------- - def _stamp(self) -> str | None: - """Hash of the JSON this graph was last built from, if any.""" + def _stored_digests(self) -> dict[str, str]: try: - res = self.graph.query( - f"MATCH (s:{STAMP_KEY}) RETURN s.hash LIMIT 1" + rows = self.graph.query( + f"MATCH (s:{FILE_STAMP}) RETURN s.key, s.digest" ).result_set - return res[0][0] if res else None - except Exception: - return None # graph does not exist yet - - def _rebuild(self) -> None: - try: - self.graph.delete() + return {k: d for k, d in rows} except Exception: - pass # nothing to delete on a first run - - self.graph.query("CREATE INDEX FOR (n:Node) ON (n.id)") + return {} # graph does not exist yet + + def _load_sources(self): + """Flatten every source into id-namespaced nodes and edges.""" + nodes, edges = [], [] + for repo, path in self.sources: + raw = json.loads(path.read_text()) + prefix = f"{repo}::" if repo else "" + if repo: + # A stand-in for the repo itself, so workspace-level dependency + # edges have something to connect. + nodes.append({ + "id": f"{repo}::__repo__", "type": "module", "name": repo, + "filePath": "", "summary": f"Repository {repo}", + "tags": ["repo"], "complexity": "simple", "repo": repo, + }) + for n in raw.get("nodes", []): + n = dict(n) + n["id"] = prefix + n["id"] + n["repo"] = repo or "" + nodes.append(n) + for e in raw.get("edges", []): + e = dict(e) + e["source"] = prefix + e["source"] + e["target"] = prefix + e["target"] + edges.append(e) + edges.extend(self._cross_repo_edges()) + return nodes, edges + + def _cross_repo_edges(self) -> list[dict]: + """Link repos that declare each other in package.json. + + UA's graph records only resolved intra-repo imports, so file-level + cross-repo edges would need the scan phase's import map. Package + manifests give the repo-level dependency reliably, which is enough to + make a workspace traversal meaningful. + """ + if len(self.sources) < 2: + return [] + + owner: dict[str, str] = {} # package name -> repo + manifests: dict[str, dict] = {} + for repo, path in self.sources: + pkg = path.parent.parent / "package.json" + if not pkg.exists(): + continue + try: + data = json.loads(pkg.read_text()) + except json.JSONDecodeError: + continue + manifests[repo] = data + if data.get("name"): + owner[data["name"]] = repo + + edges = [] + for repo, data in manifests.items(): + declared = { + **data.get("dependencies", {}), + **data.get("devDependencies", {}), + **data.get("peerDependencies", {}), + } + for spec in declared: + target_repo = owner.get(spec) + if target_repo and target_repo != repo: + edges.append({ + "source": f"{repo}::__repo__", + "target": f"{target_repo}::__repo__", + "type": "depends_on", + "direction": "forward", + "weight": 1.0, + }) + return edges + + def _sync(self) -> dict: + """Replace only the files whose content changed since the last sync.""" + nodes, edges = self._load_sources() + + # Group by the file a node belongs to; that is the unit of replacement. + by_key: dict[str, list[dict]] = {} + for n in nodes: + key = f"{n.get('repo','')}::{n.get('filePath') or n['id']}" + by_key.setdefault(key, []).append(n) + + current = { + k: digest([ + {kk: n.get(kk) for kk in + ("id", "type", "name", "summary", "tags", "complexity")} + for n in sorted(v, key=lambda x: x["id"]) + ]) + for k, v in by_key.items() + } - for n in self.raw.get("nodes", []): - line_range = n.get("lineRange") or [] + stored = self._stored_digests() + changed = {k for k, d in current.items() if stored.get(k) != d} + removed = set(stored) - set(current) + + if not changed and not removed: + return {"mode": "cached", "files": 0, "nodes": 0} + + first_run = not stored + if first_run: + self.graph.query("CREATE INDEX FOR (n:Node) ON (n.id)") + if self.embedder: + self.graph.query( + "CREATE VECTOR INDEX FOR (n:Node) ON (n.emb) " + "OPTIONS {dimension: $d, similarityFunction: 'cosine'}", + {"d": self.embedder.dimension}, + ) + + # Drop the nodes of changed/removed files. DETACH also drops edges that + # arrive from untouched files, so those are re-created below. + for key in changed | removed: self.graph.query( - f"CREATE (x:Node:{label_for(n['type'])} {{" - "id: $id, type: $type, name: $name, filePath: $filePath, " - "summary: $summary, tags: $tags, complexity: $complexity, " - "lineStart: $lineStart, lineEnd: $lineEnd})", - { - "id": n["id"], - "type": n["type"], - "name": n.get("name", ""), - "filePath": n.get("filePath", ""), - "summary": n.get("summary", ""), - "tags": n.get("tags", []), - "complexity": n.get("complexity", ""), - "lineStart": line_range[0] if len(line_range) == 2 else -1, - "lineEnd": line_range[1] if len(line_range) == 2 else -1, - }, + f"MATCH (n:Node) WHERE n.__key = $k DETACH DELETE n", {"k": key} + ) + self.graph.query( + f"MATCH (s:{FILE_STAMP} {{key: $k}}) DELETE s", {"k": key} ) - for e in self.raw.get("edges", []): + # Re-insert the changed nodes, with vectors when an embedder is present. + fresh = [n for k in changed for n in by_key[k]] + vectors = {} + if self.embedder and fresh: + texts = [Embedder.text_for(n) for n in fresh] + vectors = dict(zip((n["id"] for n in fresh), self.embedder.encode(texts))) + + for key in changed: + for n in by_key[key]: + line_range = n.get("lineRange") or [] + params = { + "id": n["id"], "type": n["type"], "name": n.get("name", ""), + "filePath": n.get("filePath", ""), "summary": n.get("summary", ""), + "tags": n.get("tags", []), "complexity": n.get("complexity", ""), + "lineStart": line_range[0] if len(line_range) == 2 else -1, + "lineEnd": line_range[1] if len(line_range) == 2 else -1, + "repo": n.get("repo", ""), "key": key, + } + props = ("id: $id, type: $type, name: $name, filePath: $filePath, " + "summary: $summary, tags: $tags, complexity: $complexity, " + "lineStart: $lineStart, lineEnd: $lineEnd, repo: $repo, " + "__key: $key") + if n["id"] in vectors: + params["emb"] = vectors[n["id"]] + props += ", emb: vecf32($emb)" + self.graph.query( + f"CREATE (x:Node:{label_for(n['type'])} {{{props}}})", params + ) self.graph.query( - "MATCH (a:Node {id: $src}), (b:Node {id: $dst}) " - f"CREATE (a)-[:{e['type'].upper()} {{type: $type, " - "direction: $direction, weight: $weight}]->(b)", - { - "src": e["source"], - "dst": e["target"], - "type": e["type"], - "direction": e.get("direction", "forward"), - "weight": e.get("weight", 0.0), - }, + f"CREATE (:{FILE_STAMP} {{key: $k, digest: $d}})", + {"k": key, "d": current[key]}, ) - self.graph.query( - f"CREATE (:{STAMP_KEY} {{hash: $h}})", {"h": self.source_hash} - ) + # Any edge touching a rebuilt node has to be re-created. + touched = {n["id"] for k in changed for n in by_key[k]} + for e in edges: + if e["source"] in touched or e["target"] in touched: + self.graph.query( + "MATCH (a:Node {id: $src}), (b:Node {id: $dst}) " + f"MERGE (a)-[:{e['type'].upper()} {{type: $type, " + "direction: $direction, weight: $weight}]->(b)", + { + "src": e["source"], "dst": e["target"], "type": e["type"], + "direction": e.get("direction", "forward"), + "weight": e.get("weight", 0.0), + }, + ) + + return { + "mode": "full" if first_run else "incremental", + "files": len(changed) + len(removed), + "nodes": len(fresh), + } # ---- queries ----------------------------------------------------------- @@ -166,17 +367,10 @@ def search(self, term: str, limit: int = 25) -> list[dict]: "ORDER BY n.id LIMIT $lim", {"t": term, "lim": limit}, ) - return [ - dict(zip(("id", "type", "name", "filePath", "summary"), r)) for r in rows - ] + return [dict(zip(("id", "type", "name", "filePath", "summary"), r)) for r in rows] def nodes_for_file(self, path: str) -> list[dict]: - """Every node defined in a file: the file node plus its functions/classes. - - This is what `understand-diff` needs for a changed path. Paths in the graph - are relative to the project root, so a suffix match keeps it working when the - caller passes an absolute or repo-prefixed path. - """ + """The file node plus every function and class defined in it.""" rows = self._rows( "MATCH (n:Node) WHERE n.filePath = $p OR n.filePath ENDS WITH $suffix " "RETURN n.id, n.type, n.name, n.filePath ORDER BY n.id", @@ -193,11 +387,7 @@ def neighbors(self, node_id: str) -> list[dict]: return [dict(zip(("edge", "id", "type", "name"), r)) for r in rows] def blast_radius(self, name: str, hops: int = 3) -> list[str]: - """Everything that transitively depends on the named node. - - This is what `understand-diff` needs: given a changed file, what else - could be affected. - """ + """Everything that transitively depends on the named node.""" rels = "|".join(DEPENDENCY_EDGES) rows = self._rows( f"MATCH (t:Node)<-[:{rels}*1..{hops}]-(d:Node) " @@ -231,19 +421,71 @@ def path(self, src: str, dst: str, max_hops: int = 6) -> list[str]: ) return rows[0][0] if rows and rows[0][0] else [] + def _require_embedder(self) -> Embedder: + if not self.embedder: + raise SystemExit( + "Semantic search needs an embedding endpoint. " + "Set UA_EMBED_URL (e.g. http://localhost:8080/embed)." + ) + return self.embedder + + def semantic(self, query: str, k: int = 10) -> list[dict]: + vec = self._require_embedder().encode([query])[0] + rows = self._rows( + "CALL db.idx.vector.queryNodes('Node','emb',$k,vecf32($q)) YIELD node " + "RETURN node.id, node.type, node.name, node.filePath", + {"k": k, "q": vec}, + ) + return [dict(zip(("id", "type", "name", "filePath"), r)) for r in rows] + + def semantic_traverse(self, query: str, k: int = 5, hops: int = 2) -> list[str]: + """Find nodes that mean the query, then traverse out from them. + + The point of putting vectors in the graph rather than beside it: seeding + and traversal happen in one statement. + """ + vec = self._require_embedder().encode([query])[0] + rows = self._rows( + "CALL db.idx.vector.queryNodes('Node','emb',$k,vecf32($q)) YIELD node AS seed " + f"MATCH (seed)-[:IMPORTS|CALLS|CONTAINS*1..{hops}]->(reached:Node) " + "RETURN DISTINCT reached.id ORDER BY reached.id", + {"k": k, "q": vec}, + ) + return [r[0] for r in rows] + def stats(self) -> dict: return { - "project": self.raw.get("project", {}).get("name"), - "backend": self.backend, "graph": self.name, + "backend": self.backend, + "repos": self.repos or [""], "nodes": self._rows("MATCH (n:Node) RETURN count(n)")[0][0], "edges": self._rows("MATCH ()-[r]->() RETURN count(r)")[0][0], - "source": str(self.graph_json), - "sourceHash": self.source_hash, + "sync": self.synced, + "semantic": bool(self.embedder), + "embedDimension": self.embedder.dimension if self.embedder else None, } -def run_one(ua: "UAGraph", spec: dict): +# --------------------------------------------------------------------------- # +# CLI +# --------------------------------------------------------------------------- # + +def resolve_sources(root: Path, workspace: str | None) -> list[tuple[str | None, Path]]: + if not workspace: + return [(None, find_graph_json(root))] + + manifest_path = Path(workspace).resolve() + manifest = json.loads(manifest_path.read_text()) + sources = [] + for entry in manifest.get("repos", []): + repo_root = (manifest_path.parent / entry).resolve() + sources.append((repo_root.name, find_graph_json(repo_root))) + if not sources: + raise SystemExit(f"{manifest_path} lists no repos") + return sources + + +def run_one(ua: UAGraph, spec: dict): """Dispatch a single {op, ...} request against an open graph.""" op = spec.get("op") if op == "stats": @@ -262,6 +504,10 @@ def run_one(ua: "UAGraph", spec: dict): return ua.calls_to(spec["name"], spec.get("hops", 2)) if op == "path": return ua.path(spec["from"], spec["to"], spec.get("hops", 6)) + if op == "semantic": + return ua.semantic(spec["q"], spec.get("k", 10)) + if op == "semantic-traverse": + return ua.semantic_traverse(spec["q"], spec.get("k", 5), spec.get("hops", 2)) if op == "cypher": return ua._rows(spec["q"]) raise SystemExit(f"unknown op: {op}") @@ -270,19 +516,23 @@ def run_one(ua: "UAGraph", spec: dict): def main() -> None: p = argparse.ArgumentParser(description=__doc__.split("\n")[0]) p.add_argument("command", choices=[ - "stats", "search", "neighbors", "blast-radius", - "calls-from", "calls-to", "path", "cypher", "batch", "nodes-for-file", + "stats", "search", "nodes-for-file", "neighbors", "blast-radius", + "calls-from", "calls-to", "path", "semantic", "semantic-traverse", + "cypher", "batch", ]) p.add_argument("--root", default=".", help="project root (default: cwd)") - p.add_argument("--q", help="search term or raw Cypher") + p.add_argument("--workspace", help="workspace manifest listing several repos") + p.add_argument("--q", help="search term, query text, raw Cypher, or batch JSON") p.add_argument("--id", help="node id") p.add_argument("--name", help="node name") p.add_argument("--hops", type=int, default=3) + p.add_argument("--k", type=int, default=10) p.add_argument("--from", dest="src") p.add_argument("--to", dest="dst") args = p.parse_args() - ua = UAGraph(find_graph_json(Path(args.root).resolve())) + sources = resolve_sources(Path(args.root).resolve(), args.workspace) + ua = UAGraph(sources) def need(value, flag): if not value: @@ -311,6 +561,10 @@ def need(value, flag): out = ua.calls_to(need(args.name, "--name"), args.hops) elif args.command == "path": out = ua.path(need(args.src, "--from"), need(args.dst, "--to"), args.hops) + elif args.command == "semantic": + out = ua.semantic(need(args.q, "--q"), args.k) + elif args.command == "semantic-traverse": + out = ua.semantic_traverse(need(args.q, "--q"), args.k, args.hops) else: out = ua._rows(need(args.q, "--q")) From 3d7997e3185eb34d401b0b69443136fbdb5644fa Mon Sep 17 00:00:00 2001 From: Gal Shubeli Date: Mon, 17 Aug 2026 11:57:39 +0300 Subject: [PATCH 03/10] test(diff): cover the graph query backend, and fix path matching it exposed Adds tests/skill/diff/test_graph_query.py following the existing unittest convention. Every test skips unless a FalkorDB backend is importable, so a checkout without the optional dependency runs 23 skips and exits 0. The graphs are synthetic with a hand-checkable import chain rather than a snapshot of a real repo, so the expected traversal results are derivable from the fixture. Writing the tests turned up a real bug in nodes-for-file. Paths were only matched in one direction, checking whether the stored path ends with the caller's. That fails whenever the caller's path is the longer of the two, which is what happens when git reports repository-relative paths but the graph was built from a scoped subdirectory. Now matched from the right in both directions. Also fixes a Cypher precedence bug found the same way: ENDS WITH binds tighter than string concatenation, so the appended path needed parentheses. Single-repo results are unchanged: blast-radius 111, calls-from 12, calls-to 3, nodes-for-file 10 on this repo's own graph. --- tests/skill/diff/test_graph_query.py | 361 ++++++++++++++++++ .../skills/understand-diff/graph-query.py | 20 +- 2 files changed, 377 insertions(+), 4 deletions(-) create mode 100644 tests/skill/diff/test_graph_query.py diff --git a/tests/skill/diff/test_graph_query.py b/tests/skill/diff/test_graph_query.py new file mode 100644 index 000000000..017770542 --- /dev/null +++ b/tests/skill/diff/test_graph_query.py @@ -0,0 +1,361 @@ +#!/usr/bin/env python3 +""" +test_graph_query.py — Tests for the optional graph query backend. + +Run from the repo root: + python -m unittest tests.skill.diff.test_graph_query -v + +Every test is skipped unless a FalkorDB backend is importable, so this is a +no-op on a checkout without the optional dependency installed. Install it with +`pip install falkordblite` (needs Python >= 3.12), or point UA_FALKORDB_URL at a +running instance. + +The graphs here are synthetic and small so the expected traversal results can be +worked out by hand rather than pinned to a snapshot of some real repo. +""" + +from __future__ import annotations + +import json +import os +import subprocess +import sys +import tempfile +import unittest +from pathlib import Path + +_HERE = Path(__file__).resolve().parent +_REPO_ROOT = _HERE.parent.parent.parent +_SCRIPT = ( + _REPO_ROOT + / "understand-anything-plugin" + / "skills" + / "understand-diff" + / "graph-query.py" +) + + +def _backend_available() -> bool: + if os.environ.get("UA_FALKORDB_URL"): + return True + try: + import redislite.falkordb_client # noqa: F401 + except Exception: + return False + return True + + +# ── Fixtures ────────────────────────────────────────────────────────────── +# A deliberate dependency chain, so blast radius is countable by hand: +# +# types.ts <-imports- a.ts +# <-imports- b.ts <-imports- c.ts <-imports- d.ts +# +# a.ts contains runA, which calls runB in b.ts, which calls runC in c.ts. + + +def _node(node_id: str, node_type: str, name: str, file_path: str) -> dict: + return { + "id": node_id, + "type": node_type, + "name": name, + "filePath": file_path, + "summary": f"{name} defined in {file_path}", + "tags": [], + "complexity": "simple", + } + + +def _edge(source: str, target: str, edge_type: str) -> dict: + return { + "source": source, + "target": target, + "type": edge_type, + "direction": "forward", + "weight": 1.0, + } + + +def _chain_graph() -> dict: + files = ["types.ts", "a.ts", "b.ts", "c.ts", "d.ts"] + nodes = [_node(f"file:src/{f}", "file", f, f"src/{f}") for f in files] + nodes += [ + _node("function:src/a.ts:runA", "function", "runA", "src/a.ts"), + _node("function:src/b.ts:runB", "function", "runB", "src/b.ts"), + _node("function:src/c.ts:runC", "function", "runC", "src/c.ts"), + ] + edges = [ + _edge("file:src/a.ts", "file:src/types.ts", "imports"), + _edge("file:src/b.ts", "file:src/types.ts", "imports"), + _edge("file:src/c.ts", "file:src/b.ts", "imports"), + _edge("file:src/d.ts", "file:src/c.ts", "imports"), + _edge("file:src/a.ts", "function:src/a.ts:runA", "contains"), + _edge("file:src/b.ts", "function:src/b.ts:runB", "contains"), + _edge("file:src/c.ts", "function:src/c.ts:runC", "contains"), + _edge("function:src/a.ts:runA", "function:src/b.ts:runB", "calls"), + _edge("function:src/b.ts:runB", "function:src/c.ts:runC", "calls"), + ] + return {"version": "1.0.0", "project": {"name": "chain"}, + "nodes": nodes, "edges": edges} + + +def _member_graph(name: str) -> dict: + return { + "version": "1.0.0", + "project": {"name": name}, + "nodes": [ + _node(f"file:src/{name}.ts", "file", f"{name}.ts", f"src/{name}.ts"), + _node(f"function:src/{name}.ts:go", "function", "go", f"src/{name}.ts"), + ], + "edges": [_edge(f"file:src/{name}.ts", f"function:src/{name}.ts:go", "contains")], + } + + +# ── Harness ─────────────────────────────────────────────────────────────── + +@unittest.skipUnless(_backend_available(), "no FalkorDB backend installed") +class GraphQueryTestCase(unittest.TestCase): + """Shared plumbing: a temp project holding a knowledge graph.""" + + def setUp(self) -> None: + self._tmp = tempfile.TemporaryDirectory() + self.root = Path(self._tmp.name) + self.addCleanup(self._tmp.cleanup) + + def write_graph(self, graph: dict, root: Path | None = None) -> Path: + root = root or self.root + (root / ".ua").mkdir(parents=True, exist_ok=True) + path = root / ".ua" / "knowledge-graph.json" + path.write_text(json.dumps(graph, indent=2)) + return path + + def run_cli(self, *args: str, expect_success: bool = True, + stdin: str | None = None, env: dict | None = None, + root: Path | None = None) -> subprocess.CompletedProcess: + environ = dict(os.environ) + environ.pop("UA_EMBED_URL", None) # semantic search is tested separately + environ.update(env or {}) + proc = subprocess.run( + [sys.executable, str(_SCRIPT), *args, "--root", str(root or self.root)], + capture_output=True, text=True, input=stdin, env=environ, + ) + if expect_success: + self.assertEqual(proc.returncode, 0, f"{args} failed:\n{proc.stderr[-800:]}") + return proc + + def cli_json(self, *args: str, **kwargs): + return json.loads(self.run_cli(*args, **kwargs).stdout) + + +# ── Traversal correctness ───────────────────────────────────────────────── + +class TraversalTests(GraphQueryTestCase): + def setUp(self) -> None: + super().setUp() + self.write_graph(_chain_graph()) + + def test_blast_radius_follows_the_import_chain(self) -> None: + """a and b import types directly; c and d reach it in 2 and 3 hops.""" + self.assertEqual( + self.cli_json("blast-radius", "--name", "types.ts", "--hops", "3"), + ["file:src/a.ts", "file:src/b.ts", "file:src/c.ts", "file:src/d.ts"], + ) + + def test_blast_radius_respects_the_hop_limit(self) -> None: + self.assertEqual( + self.cli_json("blast-radius", "--name", "types.ts", "--hops", "1"), + ["file:src/a.ts", "file:src/b.ts"], + ) + + def test_nodes_for_file_returns_the_file_and_its_members(self) -> None: + ids = [n["id"] for n in self.cli_json("nodes-for-file", "--q", "src/a.ts")] + self.assertEqual(ids, ["file:src/a.ts", "function:src/a.ts:runA"]) + + def test_nodes_for_file_matches_a_longer_caller_path(self) -> None: + """Callers may pass a path prefixed by the repo directory.""" + ids = [n["id"] for n in + self.cli_json("nodes-for-file", "--q", "some/prefix/src/a.ts")] + self.assertIn("file:src/a.ts", ids) + + def test_calls_from_and_calls_to_are_inverses(self) -> None: + self.assertEqual( + self.cli_json("calls-from", "--name", "runA", "--hops", "2"), + ["function:src/b.ts:runB", "function:src/c.ts:runC"], + ) + self.assertEqual( + self.cli_json("calls-to", "--name", "runC", "--hops", "2"), + ["function:src/a.ts:runA", "function:src/b.ts:runB"], + ) + + def test_search_matches_name_and_path(self) -> None: + self.assertTrue(self.cli_json("search", "--q", "runA")) + self.assertTrue(self.cli_json("search", "--q", "src/d.ts")) + + def test_batch_answers_several_questions_in_order(self) -> None: + out = self.cli_json("batch", "--q", json.dumps([ + {"op": "blast-radius", "name": "types.ts", "hops": 1}, + {"op": "calls-from", "name": "runA", "hops": 1}, + ])) + self.assertEqual(out[0], ["file:src/a.ts", "file:src/b.ts"]) + self.assertEqual(out[1], ["function:src/b.ts:runB"]) + + def test_batch_accepts_stdin(self) -> None: + out = self.cli_json( + "batch", stdin=json.dumps([{"op": "blast-radius", "name": "types.ts", + "hops": 1}])) + self.assertEqual(out[0], ["file:src/a.ts", "file:src/b.ts"]) + + def test_ids_are_not_namespaced_for_a_single_repo(self) -> None: + """Single-repo ids must stay byte-identical to the JSON's own ids.""" + for node_id in self.cli_json("blast-radius", "--name", "types.ts"): + self.assertNotIn("::", node_id) + + +# ── Incremental sync ────────────────────────────────────────────────────── + +class SyncTests(GraphQueryTestCase): + def setUp(self) -> None: + super().setUp() + self.graph = _chain_graph() + self.path = self.write_graph(self.graph) + self.baseline = self.cli_json("stats") + + def test_first_sync_is_full(self) -> None: + self.assertEqual(self.baseline["sync"]["mode"], "full") + self.assertEqual(self.baseline["nodes"], 8) + self.assertEqual(self.baseline["edges"], 9) + + def test_unchanged_graph_is_not_resynced(self) -> None: + again = self.cli_json("stats") + self.assertEqual(again["sync"]["mode"], "cached") + self.assertEqual(again["sync"]["files"], 0) + self.assertEqual(again["nodes"], self.baseline["nodes"]) + self.assertEqual(again["edges"], self.baseline["edges"]) + + def test_editing_one_file_resyncs_only_that_file(self) -> None: + for node in self.graph["nodes"]: + if node["filePath"] == "src/b.ts": + node["summary"] += " [edited]" + self.write_graph(self.graph) + + after = self.cli_json("stats") + self.assertEqual(after["sync"]["mode"], "incremental") + self.assertEqual(after["sync"]["files"], 1) + + def test_incremental_sync_preserves_incoming_edges(self) -> None: + """DETACH DELETE drops edges from untouched files; they must come back.""" + for node in self.graph["nodes"]: + if node["filePath"] == "src/b.ts": + node["summary"] += " [edited]" + self.write_graph(self.graph) + after = self.cli_json("stats") + + self.assertEqual(after["nodes"], self.baseline["nodes"]) + self.assertEqual(after["edges"], self.baseline["edges"]) + # c.ts -> b.ts is an edge owned by an untouched file. + self.assertIn("file:src/c.ts", + self.cli_json("blast-radius", "--name", "b.ts", "--hops", "1")) + + def test_removing_a_file_removes_its_nodes(self) -> None: + dropped = {n["id"] for n in self.graph["nodes"] if n["filePath"] == "src/d.ts"} + self.graph["nodes"] = [n for n in self.graph["nodes"] + if n["filePath"] != "src/d.ts"] + self.graph["edges"] = [e for e in self.graph["edges"] + if e["source"] not in dropped + and e["target"] not in dropped] + self.write_graph(self.graph) + + after = self.cli_json("stats") + self.assertEqual(after["nodes"], self.baseline["nodes"] - len(dropped)) + self.assertEqual(self.cli_json("nodes-for-file", "--q", "src/d.ts"), []) + + def test_node_without_a_file_path_still_loads(self) -> None: + self.graph["nodes"].append({ + "id": "concept:orphan", "type": "concept", "name": "Orphan", + "summary": "no filePath", "tags": [], "complexity": "simple", + }) + self.write_graph(self.graph) + + self.assertEqual(self.cli_json("stats")["nodes"], self.baseline["nodes"] + 1) + self.assertTrue(self.cli_json("search", "--q", "Orphan")) + + +# ── Workspaces ──────────────────────────────────────────────────────────── + +class WorkspaceTests(GraphQueryTestCase): + def setUp(self) -> None: + super().setUp() + for name, pkg, deps in ( + ("api", "@acme/api", {"@acme/shared": "^1.0.0"}), + ("shared", "@acme/shared", {}), + ): + member = self.root / name + member.mkdir() + (member / "package.json").write_text(json.dumps( + {"name": pkg, "version": "1.0.0", "dependencies": deps})) + self.write_graph(_member_graph(name), root=member) + + self.manifest = self.root / "workspace.json" + self.manifest.write_text(json.dumps({"repos": ["api", "shared"]})) + + def cli_ws(self, *args: str): + return self.cli_json(*args, "--workspace", str(self.manifest)) + + def test_both_members_are_loaded(self) -> None: + stats = self.cli_ws("stats") + self.assertEqual(stats["repos"], ["api", "shared"]) + # two real nodes per member, plus one stand-in node per repo + self.assertEqual(stats["nodes"], 6) + + def test_ids_are_namespaced_per_repo(self) -> None: + rows = self.cli_ws("cypher", "--q", + "MATCH (n:Node) WHERE n.id STARTS WITH 'api::' " + "RETURN count(n)") + self.assertEqual(rows[0][0], 3) + + def test_package_manifests_link_the_repos(self) -> None: + rows = self.cli_ws("cypher", "--q", + "MATCH (a:Node)-[r]->(b:Node) WHERE a.repo <> b.repo " + "RETURN a.repo, type(r), b.repo") + self.assertEqual(rows, [["api", "DEPENDS_ON", "shared"]]) + + def test_a_traversal_crosses_the_repo_boundary(self) -> None: + rows = self.cli_ws("cypher", "--q", + "MATCH (a:Node {repo:'api'})-[*1..3]->(x:Node) " + "WHERE x.repo = 'shared' RETURN count(x)") + self.assertGreater(rows[0][0], 0) + + +# ── Graceful failure ────────────────────────────────────────────────────── + +class FailureModeTests(GraphQueryTestCase): + def test_missing_graph_explains_itself(self) -> None: + proc = self.run_cli("stats", expect_success=False) + self.assertNotEqual(proc.returncode, 0) + self.assertIn("/understand", proc.stdout + proc.stderr) + + def test_semantic_search_requires_an_endpoint(self) -> None: + self.write_graph(_chain_graph()) + proc = self.run_cli("semantic", "--q", "anything", expect_success=False) + self.assertNotEqual(proc.returncode, 0) + self.assertIn("UA_EMBED_URL", proc.stdout + proc.stderr) + + def test_an_unreachable_embedder_does_not_break_plain_queries(self) -> None: + """A misconfigured endpoint must warn, not take the whole command down.""" + self.write_graph(_chain_graph()) + proc = self.run_cli("blast-radius", "--name", "types.ts", + env={"UA_EMBED_URL": "http://127.0.0.1:9/embed"}) + self.assertIn("warning", proc.stderr.lower()) + self.assertEqual(json.loads(proc.stdout), + ["file:src/a.ts", "file:src/b.ts", "file:src/c.ts", + "file:src/d.ts"]) + + def test_unknown_batch_op_is_rejected(self) -> None: + self.write_graph(_chain_graph()) + proc = self.run_cli("batch", "--q", json.dumps([{"op": "nope"}]), + expect_success=False) + self.assertNotEqual(proc.returncode, 0) + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/understand-anything-plugin/skills/understand-diff/graph-query.py b/understand-anything-plugin/skills/understand-diff/graph-query.py index 27923e9ab..c20316df6 100644 --- a/understand-anything-plugin/skills/understand-diff/graph-query.py +++ b/understand-anything-plugin/skills/understand-diff/graph-query.py @@ -370,11 +370,23 @@ def search(self, term: str, limit: int = 25) -> list[dict]: return [dict(zip(("id", "type", "name", "filePath", "summary"), r)) for r in rows] def nodes_for_file(self, path: str) -> list[dict]: - """The file node plus every function and class defined in it.""" + """The file node plus every function and class defined in it. + + Paths are matched from the right in both directions, because the caller's + path and the graph's may be rooted differently. `git diff` reports paths + relative to the repository, while graph paths are relative to whatever + directory `/understand` was pointed at — which is a subdirectory in a + scoped monorepo run. So the caller's path may be either longer or shorter + than the stored one. + """ + clean = path.lstrip("/") rows = self._rows( - "MATCH (n:Node) WHERE n.filePath = $p OR n.filePath ENDS WITH $suffix " - "RETURN n.id, n.type, n.name, n.filePath ORDER BY n.id", - {"p": path, "suffix": "/" + path.lstrip("/")}, + "MATCH (n:Node) WHERE n.filePath <> '' AND (" + " n.filePath = $p" + " OR n.filePath ENDS WITH $suffix" # caller gave a shorter path + " OR $p ENDS WITH ('/' + n.filePath)" # caller gave a longer path + ") RETURN n.id, n.type, n.name, n.filePath ORDER BY n.id", + {"p": clean, "suffix": "/" + clean}, ) return [dict(zip(("id", "type", "name", "filePath"), r)) for r in rows] From 1895127c9d5c28be27734017b5b5699bd5e4fd62 Mon Sep 17 00:00:00 2001 From: Gal Shubeli Date: Mon, 17 Aug 2026 12:54:13 +0300 Subject: [PATCH 04/10] refactor(diff): keep one graph per repo instead of merging workspaces MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Workspaces previously merged every repo into a single graph and namespaced node ids to keep them apart. That made cross-repo traversal easy but it gave up isolation, made every query pay for the whole workspace, and meant a repo's ids read differently inside a workspace than outside one. Now each repo keeps its own graph, with its own ids, and a small index graph holds only the repos and the dependencies between them. One backend instance holds all of them, so isolation costs no extra processes. A cross-repo question is answered in two cheap stages: traverse the index to find which repos are affected, then query only those repos' graphs. The index is tiny, so the traversal stays transitive and engine-side rather than becoming a repo-by-repo walk in Python. affected-repos exposes the index directly; blast-radius returns same-repo detail alongside a downstreamRepos list. Downstream results are repo-level because there are no file-level cross-repo edges to follow — that needs the scan phase's import map. Tests extended to a web -> api -> shared chain so transitivity and the hop limit are both covered, plus a check that a repo cannot see another's files. 26 tests, still skipped entirely when no backend is installed. Single-repo results unchanged: blast-radius 111, calls-from 12, calls-to 3, nodes-for-file 10. --- tests/skill/diff/test_graph_query.py | 73 ++- .../skills/understand-diff/SKILL.md | 7 +- .../skills/understand-diff/graph-query.py | 574 ++++++++++-------- 3 files changed, 366 insertions(+), 288 deletions(-) diff --git a/tests/skill/diff/test_graph_query.py b/tests/skill/diff/test_graph_query.py index 017770542..510165095 100644 --- a/tests/skill/diff/test_graph_query.py +++ b/tests/skill/diff/test_graph_query.py @@ -283,9 +283,16 @@ def test_node_without_a_file_path_still_loads(self) -> None: # ── Workspaces ──────────────────────────────────────────────────────────── class WorkspaceTests(GraphQueryTestCase): + """Repos stay in separate graphs; only their topology is duplicated. + + The chain is web -> api -> shared, so `shared` has one direct dependent and + one that is only reachable transitively. + """ + def setUp(self) -> None: super().setUp() for name, pkg, deps in ( + ("web", "@acme/web", {"@acme/api": "^1.0.0"}), ("api", "@acme/api", {"@acme/shared": "^1.0.0"}), ("shared", "@acme/shared", {}), ): @@ -296,34 +303,54 @@ def setUp(self) -> None: self.write_graph(_member_graph(name), root=member) self.manifest = self.root / "workspace.json" - self.manifest.write_text(json.dumps({"repos": ["api", "shared"]})) + self.manifest.write_text(json.dumps({"repos": ["web", "api", "shared"]})) def cli_ws(self, *args: str): return self.cli_json(*args, "--workspace", str(self.manifest)) - def test_both_members_are_loaded(self) -> None: + def test_each_repo_keeps_its_own_graph(self) -> None: stats = self.cli_ws("stats") - self.assertEqual(stats["repos"], ["api", "shared"]) - # two real nodes per member, plus one stand-in node per repo - self.assertEqual(stats["nodes"], 6) - - def test_ids_are_namespaced_per_repo(self) -> None: - rows = self.cli_ws("cypher", "--q", - "MATCH (n:Node) WHERE n.id STARTS WITH 'api::' " - "RETURN count(n)") - self.assertEqual(rows[0][0], 3) - - def test_package_manifests_link_the_repos(self) -> None: - rows = self.cli_ws("cypher", "--q", - "MATCH (a:Node)-[r]->(b:Node) WHERE a.repo <> b.repo " - "RETURN a.repo, type(r), b.repo") - self.assertEqual(rows, [["api", "DEPENDS_ON", "shared"]]) - - def test_a_traversal_crosses_the_repo_boundary(self) -> None: - rows = self.cli_ws("cypher", "--q", - "MATCH (a:Node {repo:'api'})-[*1..3]->(x:Node) " - "WHERE x.repo = 'shared' RETURN count(x)") - self.assertGreater(rows[0][0], 0) + self.assertEqual(sorted(stats["isolatedGraphs"]), ["api", "shared", "web"]) + for name in ("web", "api", "shared"): + self.assertEqual(stats["repos"][name]["nodes"], 2) + self.assertEqual(stats["repos"][name]["edges"], 1) + + def test_ids_are_never_namespaced(self) -> None: + """A repo's ids must read the same inside a workspace as outside one.""" + per_repo = self.cli_ws("nodes-for-file", "--q", "src/api.ts") + self.assertEqual([n["id"] for n in per_repo["api"]], + ["file:src/api.ts", "function:src/api.ts:go"]) + + def test_repos_are_isolated_from_each_other(self) -> None: + """Asking api for a file that lives in shared must return nothing.""" + per_repo = self.cli_ws("nodes-for-file", "--q", "src/shared.ts") + self.assertEqual(per_repo["api"], []) + self.assertTrue(per_repo["shared"]) + + def test_index_finds_direct_and_transitive_dependents(self) -> None: + self.assertEqual(self.cli_ws("affected-repos", "--repo", "shared"), + ["api", "web"]) + self.assertEqual(self.cli_ws("affected-repos", "--repo", "api"), ["web"]) + self.assertEqual(self.cli_ws("affected-repos", "--repo", "web"), []) + + def test_index_respects_the_hop_limit(self) -> None: + """One hop from shared reaches api but not web.""" + self.assertEqual( + self.cli_ws("affected-repos", "--repo", "shared", "--hops", "1"), + ["api"]) + + def test_cross_repo_blast_radius_reports_both_scopes(self) -> None: + out = self.cli_ws("blast-radius", "--name", "shared.ts") + self.assertEqual(out["definedIn"], ["shared"]) + self.assertEqual(out["downstreamRepos"], ["api", "web"]) + self.assertIn("shared", out["sameRepo"]) + + def test_workspace_second_run_is_cached(self) -> None: + first = self.cli_ws("stats") + second = self.cli_ws("stats") + for name in ("web", "api", "shared"): + self.assertEqual(first["repos"][name]["sync"]["mode"], "full") + self.assertEqual(second["repos"][name]["sync"]["mode"], "cached") # ── Graceful failure ────────────────────────────────────────────────────── diff --git a/understand-anything-plugin/skills/understand-diff/SKILL.md b/understand-anything-plugin/skills/understand-diff/SKILL.md index 994840ca5..792a6c143 100644 --- a/understand-anything-plugin/skills/understand-diff/SKILL.md +++ b/understand-anything-plugin/skills/understand-diff/SKILL.md @@ -80,8 +80,11 @@ The knowledge graph JSON has this structure: Two further options, both off unless configured — see `graph-query.py --help`: `semantic` and `semantic-traverse` rank nodes by meaning rather than substring when `UA_EMBED_URL` points at an embedding endpoint, which helps when a diff touches code - the user describes in their own words. `--workspace ` loads several - repos into one graph so impact analysis can cross repo boundaries. + the user describes in their own words. `--workspace ` keeps one graph + per repo and adds a small index of the dependencies between them, so a changed file + in one repo can report which other repos are downstream — `blast-radius` then returns + `sameRepo` detail plus a `downstreamRepos` list, and `affected-repos` answers the + repo-level question on its own. 4. **Find nodes for changed files** — for each changed file path, use Grep to search the knowledge graph for: - Nodes with matching `"filePath"` values (e.g., `grep "changed/file/path"`) diff --git a/understand-anything-plugin/skills/understand-diff/graph-query.py b/understand-anything-plugin/skills/understand-diff/graph-query.py index c20316df6..445aca147 100644 --- a/understand-anything-plugin/skills/understand-diff/graph-query.py +++ b/understand-anything-plugin/skills/understand-diff/graph-query.py @@ -10,16 +10,19 @@ server any FalkorDB instance, via UA_FALKORDB_URL (e.g. redis://localhost:6379) Sync is incremental. A digest is stored per source file, so re-syncing after an -edit replaces only the nodes of files that actually changed rather than -rebuilding the whole graph. +edit replaces only the nodes of files that actually changed. Semantic search is optional. Set UA_EMBED_URL to a text-embedding endpoint that accepts {"inputs": [...]} and returns a list of vectors (e.g. a local text-embeddings-inference server) and node vectors are built during sync. Without it, everything except the `semantic*` commands works unchanged. -Workspaces let several repos share one graph so traversals cross repo -boundaries. Point --workspace at a JSON file: {"repos": ["../api", "../web"]}. +Workspaces keep one graph per repo rather than merging them. Every repo stays +isolated, with ids exactly as they appear in its own JSON, and a small index +graph holds just the repos and the dependencies between them. A cross-repo +question is answered in two cheap stages: traverse the index to find which repos +are affected, then query only those repos' graphs. Point --workspace at a +manifest: {"repos": ["../api", "../web"]}. CLI python graph-query.py stats @@ -35,6 +38,10 @@ python graph-query.py cypher --q "MATCH (n:File) RETURN count(n)" python graph-query.py batch --q '[{"op":"blast-radius","name":"a.ts"}]' + # workspace-only + python graph-query.py affected-repos --repo shared --workspace ws.json + python graph-query.py blast-radius --name auth.ts --workspace ws.json + Every command prints JSON on stdout. """ from __future__ import annotations @@ -50,6 +57,7 @@ UA_DIRS = (".ua", ".understand-anything") GRAPH_FILE = "knowledge-graph.json" FILE_STAMP = "__ua_file__" +INDEX_GRAPH = "__ua_workspace__" # UA edge types that mean "A depends on B", used by the blast-radius query. DEPENDENCY_EDGES = ("IMPORTS", "DEPENDS_ON") @@ -75,9 +83,7 @@ def label_for(node_type: str) -> str: def digest(payload) -> str: - return hashlib.sha256( - json.dumps(payload, sort_keys=True).encode() - ).hexdigest()[:16] + return hashlib.sha256(json.dumps(payload, sort_keys=True).encode()).hexdigest()[:16] # --------------------------------------------------------------------------- # @@ -94,10 +100,11 @@ def __init__(self, url: str): def encode(self, texts: list[str]) -> list[list[float]]: out: list[list[float]] = [] for i in range(0, len(texts), EMBED_BATCH): - chunk = texts[i:i + EMBED_BATCH] req = urllib.request.Request( self.url, - data=json.dumps({"inputs": chunk, "truncate": True}).encode(), + data=json.dumps( + {"inputs": texts[i:i + EMBED_BATCH], "truncate": True} + ).encode(), headers={"Content-Type": "application/json"}, ) out.extend(json.loads(urllib.request.urlopen(req, timeout=120).read())) @@ -112,52 +119,40 @@ def text_for(node: dict) -> str: ]).strip() -# --------------------------------------------------------------------------- # -# graph -# --------------------------------------------------------------------------- # +def make_embedder() -> Embedder | None: + url = os.environ.get("UA_EMBED_URL") + if not url: + return None + try: + return Embedder(url) + except Exception as exc: # an unreachable endpoint must not break plain queries + print(f"warning: UA_EMBED_URL unreachable ({exc}); semantic commands disabled", + file=sys.stderr) + return None -class UAGraph: - """One or more UA knowledge graphs, queryable over FalkorDB.""" - def __init__(self, sources: list[tuple[str | None, Path]], graph_name: str | None = None): - """sources is [(repo_or_None, path_to_knowledge_graph_json)]. - - repo is None for the single-repo case, which keeps node ids exactly as - they appear in the JSON so existing callers are unaffected. - """ - self.sources = sources - self.repos = [r for r, _ in sources if r] - first = json.loads(sources[0][1].read_text()) - self.name = graph_name or ( - "workspace" if self.repos else first.get("project", {}).get("name", "ua") - ) - self.embedder = self._make_embedder() - self.backend, self.graph = self._connect(sources[0][1]) - self.synced = self._sync() +# --------------------------------------------------------------------------- # +# backend +# --------------------------------------------------------------------------- # - # ---- optional dependencies -------------------------------------------- +class Backend: + """One FalkorDB connection handing out graph handles by key. - @staticmethod - def _make_embedder() -> Embedder | None: - url = os.environ.get("UA_EMBED_URL") - if not url: - return None - try: - return Embedder(url) - except Exception as exc: # unreachable endpoint must not break plain queries - print(f"warning: UA_EMBED_URL unreachable ({exc}); " - "semantic commands disabled", file=sys.stderr) - return None + A single instance holds many graphs, which is what keeps repos isolated + without paying for a separate server per repo. + """ - def _connect(self, anchor: Path): + def __init__(self, anchor_dir: Path): url = os.environ.get("UA_FALKORDB_URL") if url: from urllib.parse import urlparse from falkordb import FalkorDB parsed = urlparse(url) - db = FalkorDB(host=parsed.hostname or "localhost", port=parsed.port or 6379) - return "server", db.select_graph(self.name) + self._db = FalkorDB(host=parsed.hostname or "localhost", + port=parsed.port or 6379) + self.kind = "server" + return try: from redislite.falkordb_client import FalkorDB as EmbeddedFalkorDB @@ -168,8 +163,30 @@ def _connect(self, anchor: Path): "running FalkorDB instance." ) - db = EmbeddedFalkorDB(str(anchor.parent / "falkordb.db")) - return "embedded", db.select_graph(self.name) + anchor_dir.mkdir(parents=True, exist_ok=True) + self._db = EmbeddedFalkorDB(str(anchor_dir / "falkordb.db")) + self.kind = "embedded" + + def graph(self, key: str): + return self._db.select_graph(key) + + +# --------------------------------------------------------------------------- # +# one repo, one graph +# --------------------------------------------------------------------------- # + +class RepoGraph: + """A single UA knowledge graph. Node ids are exactly the JSON's own ids.""" + + def __init__(self, backend: Backend, key: str, graph_json: Path, + embedder: Embedder | None): + self.key = key + self.graph_json = graph_json + self.embedder = embedder + self.backend_kind = backend.kind + self.graph = backend.graph(key) + self.raw = json.loads(graph_json.read_text()) + self.synced = self._sync() # ---- incremental sync -------------------------------------------------- @@ -182,86 +199,14 @@ def _stored_digests(self) -> dict[str, str]: except Exception: return {} # graph does not exist yet - def _load_sources(self): - """Flatten every source into id-namespaced nodes and edges.""" - nodes, edges = [], [] - for repo, path in self.sources: - raw = json.loads(path.read_text()) - prefix = f"{repo}::" if repo else "" - if repo: - # A stand-in for the repo itself, so workspace-level dependency - # edges have something to connect. - nodes.append({ - "id": f"{repo}::__repo__", "type": "module", "name": repo, - "filePath": "", "summary": f"Repository {repo}", - "tags": ["repo"], "complexity": "simple", "repo": repo, - }) - for n in raw.get("nodes", []): - n = dict(n) - n["id"] = prefix + n["id"] - n["repo"] = repo or "" - nodes.append(n) - for e in raw.get("edges", []): - e = dict(e) - e["source"] = prefix + e["source"] - e["target"] = prefix + e["target"] - edges.append(e) - edges.extend(self._cross_repo_edges()) - return nodes, edges - - def _cross_repo_edges(self) -> list[dict]: - """Link repos that declare each other in package.json. - - UA's graph records only resolved intra-repo imports, so file-level - cross-repo edges would need the scan phase's import map. Package - manifests give the repo-level dependency reliably, which is enough to - make a workspace traversal meaningful. - """ - if len(self.sources) < 2: - return [] - - owner: dict[str, str] = {} # package name -> repo - manifests: dict[str, dict] = {} - for repo, path in self.sources: - pkg = path.parent.parent / "package.json" - if not pkg.exists(): - continue - try: - data = json.loads(pkg.read_text()) - except json.JSONDecodeError: - continue - manifests[repo] = data - if data.get("name"): - owner[data["name"]] = repo - - edges = [] - for repo, data in manifests.items(): - declared = { - **data.get("dependencies", {}), - **data.get("devDependencies", {}), - **data.get("peerDependencies", {}), - } - for spec in declared: - target_repo = owner.get(spec) - if target_repo and target_repo != repo: - edges.append({ - "source": f"{repo}::__repo__", - "target": f"{target_repo}::__repo__", - "type": "depends_on", - "direction": "forward", - "weight": 1.0, - }) - return edges - def _sync(self) -> dict: - """Replace only the files whose content changed since the last sync.""" - nodes, edges = self._load_sources() + nodes = self.raw.get("nodes", []) + edges = self.raw.get("edges", []) - # Group by the file a node belongs to; that is the unit of replacement. + # The file a node belongs to is the unit of replacement. by_key: dict[str, list[dict]] = {} for n in nodes: - key = f"{n.get('repo','')}::{n.get('filePath') or n['id']}" - by_key.setdefault(key, []).append(n) + by_key.setdefault(n.get("filePath") or n["id"], []).append(n) current = { k: digest([ @@ -289,22 +234,21 @@ def _sync(self) -> dict: {"d": self.embedder.dimension}, ) - # Drop the nodes of changed/removed files. DETACH also drops edges that - # arrive from untouched files, so those are re-created below. + # Drop the nodes of changed/removed files. DETACH also drops edges + # arriving from untouched files, so those are re-created below. for key in changed | removed: self.graph.query( - f"MATCH (n:Node) WHERE n.__key = $k DETACH DELETE n", {"k": key} - ) + "MATCH (n:Node) WHERE n.__key = $k DETACH DELETE n", {"k": key}) self.graph.query( - f"MATCH (s:{FILE_STAMP} {{key: $k}}) DELETE s", {"k": key} - ) + f"MATCH (s:{FILE_STAMP} {{key: $k}}) DELETE s", {"k": key}) - # Re-insert the changed nodes, with vectors when an embedder is present. fresh = [n for k in changed for n in by_key[k]] - vectors = {} + vectors: dict[str, list[float]] = {} if self.embedder and fresh: - texts = [Embedder.text_for(n) for n in fresh] - vectors = dict(zip((n["id"] for n in fresh), self.embedder.encode(texts))) + vectors = dict(zip( + (n["id"] for n in fresh), + self.embedder.encode([Embedder.text_for(n) for n in fresh]), + )) for key in changed: for n in by_key[key]: @@ -315,24 +259,20 @@ def _sync(self) -> dict: "tags": n.get("tags", []), "complexity": n.get("complexity", ""), "lineStart": line_range[0] if len(line_range) == 2 else -1, "lineEnd": line_range[1] if len(line_range) == 2 else -1, - "repo": n.get("repo", ""), "key": key, + "key": key, } props = ("id: $id, type: $type, name: $name, filePath: $filePath, " "summary: $summary, tags: $tags, complexity: $complexity, " - "lineStart: $lineStart, lineEnd: $lineEnd, repo: $repo, " - "__key: $key") + "lineStart: $lineStart, lineEnd: $lineEnd, __key: $key") if n["id"] in vectors: params["emb"] = vectors[n["id"]] props += ", emb: vecf32($emb)" self.graph.query( - f"CREATE (x:Node:{label_for(n['type'])} {{{props}}})", params - ) + f"CREATE (x:Node:{label_for(n['type'])} {{{props}}})", params) self.graph.query( f"CREATE (:{FILE_STAMP} {{key: $k, digest: $d}})", - {"k": key, "d": current[key]}, - ) + {"k": key, "d": current[key]}) - # Any edge touching a rebuilt node has to be re-created. touched = {n["id"] for k in changed for n in by_key[k]} for e in edges: if e["source"] in touched or e["target"] in touched: @@ -340,12 +280,9 @@ def _sync(self) -> dict: "MATCH (a:Node {id: $src}), (b:Node {id: $dst}) " f"MERGE (a)-[:{e['type'].upper()} {{type: $type, " "direction: $direction, weight: $weight}]->(b)", - { - "src": e["source"], "dst": e["target"], "type": e["type"], - "direction": e.get("direction", "forward"), - "weight": e.get("weight", 0.0), - }, - ) + {"src": e["source"], "dst": e["target"], "type": e["type"], + "direction": e.get("direction", "forward"), + "weight": e.get("weight", 0.0)}) return { "mode": "full" if first_run else "incremental", @@ -355,18 +292,17 @@ def _sync(self) -> dict: # ---- queries ----------------------------------------------------------- - def _rows(self, cypher: str, params: dict | None = None) -> list: + def rows(self, cypher: str, params: dict | None = None) -> list: return self.graph.query(cypher, params or {}).result_set def search(self, term: str, limit: int = 25) -> list[dict]: - rows = self._rows( + rows = self.rows( "MATCH (n:Node) WHERE toLower(n.name) CONTAINS toLower($t) " "OR toLower(n.summary) CONTAINS toLower($t) " "OR toLower(n.filePath) CONTAINS toLower($t) " "RETURN n.id, n.type, n.name, n.filePath, n.summary " "ORDER BY n.id LIMIT $lim", - {"t": term, "lim": limit}, - ) + {"t": term, "lim": limit}) return [dict(zip(("id", "type", "name", "filePath", "summary"), r)) for r in rows] def nodes_for_file(self, path: str) -> list[dict]: @@ -375,79 +311,70 @@ def nodes_for_file(self, path: str) -> list[dict]: Paths are matched from the right in both directions, because the caller's path and the graph's may be rooted differently. `git diff` reports paths relative to the repository, while graph paths are relative to whatever - directory `/understand` was pointed at — which is a subdirectory in a - scoped monorepo run. So the caller's path may be either longer or shorter - than the stored one. + directory `/understand` was pointed at — a subdirectory in a scoped + monorepo run. So the caller's path may be longer or shorter than the + stored one. """ clean = path.lstrip("/") - rows = self._rows( + rows = self.rows( "MATCH (n:Node) WHERE n.filePath <> '' AND (" " n.filePath = $p" - " OR n.filePath ENDS WITH $suffix" # caller gave a shorter path - " OR $p ENDS WITH ('/' + n.filePath)" # caller gave a longer path + " OR n.filePath ENDS WITH $suffix" # caller gave a shorter path + " OR $p ENDS WITH ('/' + n.filePath)" # caller gave a longer path ") RETURN n.id, n.type, n.name, n.filePath ORDER BY n.id", - {"p": clean, "suffix": "/" + clean}, - ) + {"p": clean, "suffix": "/" + clean}) return [dict(zip(("id", "type", "name", "filePath"), r)) for r in rows] def neighbors(self, node_id: str) -> list[dict]: - rows = self._rows( + rows = self.rows( "MATCH (n:Node {id: $id})-[r]-(m:Node) " - "RETURN type(r), m.id, m.type, m.name ORDER BY m.id", - {"id": node_id}, - ) + "RETURN type(r), m.id, m.type, m.name ORDER BY m.id", {"id": node_id}) return [dict(zip(("edge", "id", "type", "name"), r)) for r in rows] def blast_radius(self, name: str, hops: int = 3) -> list[str]: - """Everything that transitively depends on the named node.""" + """Everything in this repo that transitively depends on the named node.""" rels = "|".join(DEPENDENCY_EDGES) - rows = self._rows( + rows = self.rows( f"MATCH (t:Node)<-[:{rels}*1..{hops}]-(d:Node) " - "WHERE t.name = $n RETURN DISTINCT d.id ORDER BY d.id", - {"n": name}, - ) + "WHERE t.name = $n RETURN DISTINCT d.id ORDER BY d.id", {"n": name}) return [r[0] for r in rows] def calls_from(self, name: str, hops: int = 3) -> list[str]: - rows = self._rows( + rows = self.rows( f"MATCH (s:Node)-[:CALLS*1..{hops}]->(x:Node) " - "WHERE s.name = $n RETURN DISTINCT x.id ORDER BY x.id", - {"n": name}, - ) + "WHERE s.name = $n RETURN DISTINCT x.id ORDER BY x.id", {"n": name}) return [r[0] for r in rows] def calls_to(self, name: str, hops: int = 2) -> list[str]: - rows = self._rows( + rows = self.rows( f"MATCH (t:Node)<-[:CALLS*1..{hops}]-(c:Node) " - "WHERE t.name = $n RETURN DISTINCT c.id ORDER BY c.id", - {"n": name}, - ) + "WHERE t.name = $n RETURN DISTINCT c.id ORDER BY c.id", {"n": name}) return [r[0] for r in rows] def path(self, src: str, dst: str, max_hops: int = 6) -> list[str]: # FalkorDB wants shortestPath in WITH/RETURN and a directed pattern. - rows = self._rows( + rows = self.rows( "MATCH (a:Node {id: $a}), (b:Node {id: $b}) " f"RETURN [n IN nodes(shortestPath((a)-[*..{max_hops}]->(b))) | n.id]", - {"a": src, "b": dst}, - ) + {"a": src, "b": dst}) return rows[0][0] if rows and rows[0][0] else [] + def has_name(self, name: str) -> bool: + return bool(self.rows( + "MATCH (n:Node) WHERE n.name = $n RETURN 1 LIMIT 1", {"n": name})) + def _require_embedder(self) -> Embedder: if not self.embedder: raise SystemExit( "Semantic search needs an embedding endpoint. " - "Set UA_EMBED_URL (e.g. http://localhost:8080/embed)." - ) + "Set UA_EMBED_URL (e.g. http://localhost:8080/embed).") return self.embedder def semantic(self, query: str, k: int = 10) -> list[dict]: vec = self._require_embedder().encode([query])[0] - rows = self._rows( + rows = self.rows( "CALL db.idx.vector.queryNodes('Node','emb',$k,vecf32($q)) YIELD node " - "RETURN node.id, node.type, node.name, node.filePath", - {"k": k, "q": vec}, - ) + "RETURN node.id, node.type, node.name, node.filePath", {"k": k, "q": vec}) return [dict(zip(("id", "type", "name", "filePath"), r)) for r in rows] def semantic_traverse(self, query: str, k: int = 5, hops: int = 2) -> list[str]: @@ -457,72 +384,194 @@ def semantic_traverse(self, query: str, k: int = 5, hops: int = 2) -> list[str]: and traversal happen in one statement. """ vec = self._require_embedder().encode([query])[0] - rows = self._rows( + rows = self.rows( "CALL db.idx.vector.queryNodes('Node','emb',$k,vecf32($q)) YIELD node AS seed " f"MATCH (seed)-[:IMPORTS|CALLS|CONTAINS*1..{hops}]->(reached:Node) " - "RETURN DISTINCT reached.id ORDER BY reached.id", - {"k": k, "q": vec}, - ) + "RETURN DISTINCT reached.id ORDER BY reached.id", {"k": k, "q": vec}) return [r[0] for r in rows] - def stats(self) -> dict: + def counts(self) -> dict: return { - "graph": self.name, - "backend": self.backend, - "repos": self.repos or [""], - "nodes": self._rows("MATCH (n:Node) RETURN count(n)")[0][0], - "edges": self._rows("MATCH ()-[r]->() RETURN count(r)")[0][0], - "sync": self.synced, - "semantic": bool(self.embedder), - "embedDimension": self.embedder.dimension if self.embedder else None, + "nodes": self.rows("MATCH (n:Node) RETURN count(n)")[0][0], + "edges": self.rows("MATCH ()-[r]->() RETURN count(r)")[0][0], } # --------------------------------------------------------------------------- # -# CLI +# many repos, one instance # --------------------------------------------------------------------------- # -def resolve_sources(root: Path, workspace: str | None) -> list[tuple[str | None, Path]]: - if not workspace: - return [(None, find_graph_json(root))] +class Workspace: + """Isolated per-repo graphs plus a tiny index of the dependencies between them. + + Nothing is merged. Each repo keeps its own graph and its own ids, so a + single-repo query is identical whether or not a workspace exists. Only the + repo-level topology is duplicated into the index graph, which is what makes + a cross-repo question answerable by traversal rather than by scanning every + repo. + """ + + def __init__(self, members: list[tuple[str, Path]], backend: Backend, + embedder: Embedder | None, index_key: str = INDEX_GRAPH): + self.backend = backend + self.repos = { + name: RepoGraph(backend, name, path, embedder) for name, path in members + } + self.index = backend.graph(index_key) + self.index_edges = self._build_index(members) - manifest_path = Path(workspace).resolve() - manifest = json.loads(manifest_path.read_text()) - sources = [] - for entry in manifest.get("repos", []): - repo_root = (manifest_path.parent / entry).resolve() - sources.append((repo_root.name, find_graph_json(repo_root))) - if not sources: - raise SystemExit(f"{manifest_path} lists no repos") - return sources + def _build_index(self, members: list[tuple[str, Path]]) -> int: + """Rebuild the index from package manifests. It is small, so rebuild whole. + UA's graph records only resolved intra-repo imports, so file-level + cross-repo edges would need the scan phase's import map. Package + manifests give the repo-level dependency reliably. + """ + try: + self.index.delete() + except Exception: + pass + + owner: dict[str, str] = {} + declared: dict[str, dict] = {} + for name, path in members: + manifest = path.parent.parent / "package.json" + if not manifest.exists(): + declared[name] = {} + continue + try: + data = json.loads(manifest.read_text()) + except json.JSONDecodeError: + declared[name] = {} + continue + if data.get("name"): + owner[data["name"]] = name + declared[name] = { + **data.get("dependencies", {}), + **data.get("devDependencies", {}), + **data.get("peerDependencies", {}), + } -def run_one(ua: UAGraph, spec: dict): - """Dispatch a single {op, ...} request against an open graph.""" + for name in self.repos: + self.index.query("CREATE (:Repo {name: $n})", {"n": name}) + + created = 0 + for name, deps in declared.items(): + for spec in deps: + target = owner.get(spec) + if target and target != name: + self.index.query( + "MATCH (a:Repo {name: $a}), (b:Repo {name: $b}) " + "MERGE (a)-[:DEPENDS_ON {via: $via}]->(b)", + {"a": name, "b": target, "via": spec}) + created += 1 + return created + + # ---- cross-repo --------------------------------------------------------- + + def affected_repos(self, repo: str, hops: int = 5) -> list[str]: + """Repos that transitively depend on `repo` — one query on a tiny graph.""" + rows = self.index.query( + f"MATCH (t:Repo {{name: $n}})<-[:DEPENDS_ON*1..{hops}]-(d:Repo) " + "RETURN DISTINCT d.name ORDER BY d.name", {"n": repo}).result_set + return [r[0] for r in rows] + + def repos_defining(self, name: str) -> list[str]: + return [r for r, g in self.repos.items() if g.has_name(name)] + + def blast_radius(self, name: str, hops: int = 3) -> dict: + """Two stages: local impact where the node lives, then dependent repos. + + Downstream repos are reported at repo granularity because there are no + file-level cross-repo edges to follow — see _build_index. + """ + origins = self.repos_defining(name) + local = {r: self.repos[r].blast_radius(name, hops) for r in origins} + downstream = sorted({ + d for r in origins for d in self.affected_repos(r) if d not in origins + }) + return { + "definedIn": origins, + "sameRepo": local, + "downstreamRepos": downstream, + "note": "downstream is repo-level; file-level cross-repo edges need " + "the scan phase's import map", + } + + def fan_out(self, method: str, *args) -> dict: + """Run a per-repo query against every member, keyed by repo.""" + return {r: getattr(g, method)(*args) for r, g in self.repos.items()} + + def stats(self) -> dict: + return { + "mode": "workspace", + "backend": self.backend.kind, + "repos": { + r: {**g.counts(), "sync": g.synced} for r, g in self.repos.items() + }, + "isolatedGraphs": list(self.repos), + "indexEdges": self.index_edges, + } + + +# --------------------------------------------------------------------------- # +# CLI +# --------------------------------------------------------------------------- # + +PER_REPO_OPS = { + "search": ("search", ("q",)), + "nodes-for-file": ("nodes_for_file", ("path",)), + "neighbors": ("neighbors", ("id",)), + "blast-radius": ("blast_radius", ("name", "hops")), + "calls-from": ("calls_from", ("name", "hops")), + "calls-to": ("calls_to", ("name", "hops")), + "path": ("path", ("from", "to", "hops")), + "semantic": ("semantic", ("q", "k")), + "semantic-traverse": ("semantic_traverse", ("q", "k", "hops")), +} +DEFAULTS = {"hops": 3, "k": 10, "limit": 25} + + +def run_one(target, spec: dict): + """Dispatch a single {op, ...} request. `target` is a RepoGraph or Workspace.""" op = spec.get("op") if op == "stats": - return ua.stats() - if op == "search": - return ua.search(spec["q"], spec.get("limit", 25)) - if op == "nodes-for-file": - return ua.nodes_for_file(spec["path"]) - if op == "neighbors": - return ua.neighbors(spec["id"]) - if op == "blast-radius": - return ua.blast_radius(spec["name"], spec.get("hops", 3)) - if op == "calls-from": - return ua.calls_from(spec["name"], spec.get("hops", 3)) - if op == "calls-to": - return ua.calls_to(spec["name"], spec.get("hops", 2)) - if op == "path": - return ua.path(spec["from"], spec["to"], spec.get("hops", 6)) - if op == "semantic": - return ua.semantic(spec["q"], spec.get("k", 10)) - if op == "semantic-traverse": - return ua.semantic_traverse(spec["q"], spec.get("k", 5), spec.get("hops", 2)) + return target.stats() if isinstance(target, Workspace) else single_stats(target) if op == "cypher": - return ua._rows(spec["q"]) - raise SystemExit(f"unknown op: {op}") + if isinstance(target, Workspace): + return {r: g.rows(spec["q"]) for r, g in target.repos.items()} + return target.rows(spec["q"]) + if op == "affected-repos": + if not isinstance(target, Workspace): + raise SystemExit("affected-repos needs --workspace") + return target.affected_repos(spec["repo"], spec.get("hops", 5)) + + if op not in PER_REPO_OPS: + raise SystemExit(f"unknown op: {op}") + method, params = PER_REPO_OPS[op] + args = [spec.get(p, DEFAULTS.get(p)) for p in params] + if any(a is None for a in args): + missing = [p for p, a in zip(params, args) if a is None] + raise SystemExit(f"{op} requires: {', '.join(missing)}") + + if isinstance(target, Workspace): + # blast-radius has real cross-repo meaning; the rest simply fan out. + if op == "blast-radius": + return target.blast_radius(*args) + return target.fan_out(method, *args) + return getattr(target, method)(*args) + + +def single_stats(repo: RepoGraph) -> dict: + return { + "graph": repo.key, + "backend": repo.backend_kind, + "repos": [""], + **repo.counts(), + "sync": repo.synced, + "semantic": bool(repo.embedder), + "embedDimension": repo.embedder.dimension if repo.embedder else None, + } def main() -> None: @@ -530,55 +579,54 @@ def main() -> None: p.add_argument("command", choices=[ "stats", "search", "nodes-for-file", "neighbors", "blast-radius", "calls-from", "calls-to", "path", "semantic", "semantic-traverse", - "cypher", "batch", + "affected-repos", "cypher", "batch", ]) p.add_argument("--root", default=".", help="project root (default: cwd)") - p.add_argument("--workspace", help="workspace manifest listing several repos") + p.add_argument("--workspace", help="manifest listing several repos") p.add_argument("--q", help="search term, query text, raw Cypher, or batch JSON") p.add_argument("--id", help="node id") p.add_argument("--name", help="node name") + p.add_argument("--repo", help="repo name (affected-repos)") p.add_argument("--hops", type=int, default=3) p.add_argument("--k", type=int, default=10) p.add_argument("--from", dest="src") p.add_argument("--to", dest="dst") args = p.parse_args() - sources = resolve_sources(Path(args.root).resolve(), args.workspace) - ua = UAGraph(sources) + embedder = make_embedder() + + if args.workspace: + manifest_path = Path(args.workspace).resolve() + manifest = json.loads(manifest_path.read_text()) + members = [] + for entry in manifest.get("repos", []): + repo_root = (manifest_path.parent / entry).resolve() + members.append((repo_root.name, find_graph_json(repo_root))) + if not members: + raise SystemExit(f"{manifest_path} lists no repos") + # One embedded instance beside the manifest holds every repo's graph. + backend = Backend(manifest_path.parent / ".ua") + target = Workspace(members, backend, embedder) + else: + graph_json = find_graph_json(Path(args.root).resolve()) + backend = Backend(graph_json.parent) + key = json.loads(graph_json.read_text()).get("project", {}).get("name", "ua") + target = RepoGraph(backend, key, graph_json, embedder) - def need(value, flag): - if not value: - sys.exit(f"{args.command} requires {flag}") - return value + spec_from_flags = { + "q": args.q, "id": args.id, "name": args.name, "path": args.q, + "repo": args.repo, "hops": args.hops, "k": args.k, + "from": args.src, "to": args.dst, + } if args.command == "batch": # Many questions, one process. The embedded server's boot and shutdown # dominate a single-shot invocation, so a skill answering a multi-part # question should send its whole plan at once. specs = json.loads(args.q) if args.q else json.load(sys.stdin) - out = [run_one(ua, s) for s in specs] - elif args.command == "stats": - out = ua.stats() - elif args.command == "search": - out = ua.search(need(args.q, "--q")) - elif args.command == "nodes-for-file": - out = ua.nodes_for_file(need(args.q, "--q")) - elif args.command == "neighbors": - out = ua.neighbors(need(args.id, "--id")) - elif args.command == "blast-radius": - out = ua.blast_radius(need(args.name, "--name"), args.hops) - elif args.command == "calls-from": - out = ua.calls_from(need(args.name, "--name"), args.hops) - elif args.command == "calls-to": - out = ua.calls_to(need(args.name, "--name"), args.hops) - elif args.command == "path": - out = ua.path(need(args.src, "--from"), need(args.dst, "--to"), args.hops) - elif args.command == "semantic": - out = ua.semantic(need(args.q, "--q"), args.k) - elif args.command == "semantic-traverse": - out = ua.semantic_traverse(need(args.q, "--q"), args.k, args.hops) + out = [run_one(target, s) for s in specs] else: - out = ua._rows(need(args.q, "--q")) + out = run_one(target, {"op": args.command, **spec_from_flags}) print(json.dumps(out, indent=2, default=str)) From 157509e45de01cbcbc0b70335814f35df820c12e Mon Sep 17 00:00:00 2001 From: Gal Shubeli Date: Mon, 17 Aug 2026 13:17:49 +0300 Subject: [PATCH 05/10] fix(diff): sync edge-only changes, backfill vectors, validate types Four fixes found by reviewing the sync path. Edge-only changes were never synced. Digests covered node fields only, so adding an import between two otherwise unchanged files left every digest intact, sync reported 'cached', and the edge never reached the graph. That is the most common thing a commit does, and dependency edges are the whole point of this feature. Digests now cover each file's incident edges, so both endpoints resync. Stamps were written per file before edges were created, so a crash between the two left every file stamped with its edges missing, and the next run reported 'cached' and never repaired it. Stamps are now written last, after edges, so an interrupted sync is retried instead of silently kept. The vector index was only created on a first sync. Configuring UA_EMBED_URL after a plain sync produced a raw driver error, because no index existed and no node had a vector. Indexes are now ensured on every sync and nodes missing vectors are backfilled. Node and edge types are interpolated into Cypher as labels and relationship types, which cannot be parameterised. Since knowledge-graph.json is committed and shared between teammates, types are now validated as plain identifiers and rejected otherwise. Writes are also batched with UNWIND, one query per label and per relationship type rather than one per node and per edge: a full sync of this repo's graph drops from 2183 queries to roughly 50. Tests: 34, up from 26, covering edge add/remove/property changes, both injection attempts, and the backfill path. Semantic tests skip unless UA_EMBED_URL is reachable; everything still skips without a backend. --- tests/skill/diff/test_graph_query.py | 121 ++++++++++ .../skills/understand-diff/graph-query.py | 213 +++++++++++++----- 2 files changed, 282 insertions(+), 52 deletions(-) diff --git a/tests/skill/diff/test_graph_query.py b/tests/skill/diff/test_graph_query.py index 510165095..c95d163b1 100644 --- a/tests/skill/diff/test_graph_query.py +++ b/tests/skill/diff/test_graph_query.py @@ -269,6 +269,43 @@ def test_removing_a_file_removes_its_nodes(self) -> None: self.assertEqual(after["nodes"], self.baseline["nodes"] - len(dropped)) self.assertEqual(self.cli_json("nodes-for-file", "--q", "src/d.ts"), []) + def test_adding_only_an_edge_is_synced(self) -> None: + """A commit that adds an import and changes nothing else must be seen. + + Digests cover each file's incident edges for exactly this reason. When + they covered only node fields, this was invisible: every digest matched, + sync reported "cached", and the new edge never reached the graph. + """ + self.graph["edges"].append( + _edge("file:src/d.ts", "file:src/types.ts", "imports")) + self.write_graph(self.graph) + + after = self.cli_json("stats") + self.assertEqual(after["sync"]["mode"], "incremental") + self.assertEqual(after["edges"], self.baseline["edges"] + 1) + # d.ts now depends on types.ts directly rather than only through c.ts + self.assertIn("file:src/d.ts", + self.cli_json("blast-radius", "--name", "types.ts", "--hops", "1")) + + def test_removing_only_an_edge_is_synced(self) -> None: + self.graph["edges"] = [e for e in self.graph["edges"] + if not (e["source"] == "file:src/a.ts" + and e["target"] == "file:src/types.ts")] + self.write_graph(self.graph) + + after = self.cli_json("stats") + self.assertEqual(after["edges"], self.baseline["edges"] - 1) + self.assertNotIn("file:src/a.ts", + self.cli_json("blast-radius", "--name", "types.ts")) + + def test_changing_an_edge_property_is_synced(self) -> None: + for edge in self.graph["edges"]: + if edge["type"] == "imports": + edge["weight"] = 0.25 + break + self.write_graph(self.graph) + self.assertEqual(self.cli_json("stats")["sync"]["mode"], "incremental") + def test_node_without_a_file_path_still_loads(self) -> None: self.graph["nodes"].append({ "id": "concept:orphan", "type": "concept", "name": "Orphan", @@ -353,6 +390,62 @@ def test_workspace_second_run_is_cached(self) -> None: self.assertEqual(second["repos"][name]["sync"]["mode"], "cached") +# ── Semantic search ─────────────────────────────────────────────────────── + +def _embedder_reachable() -> bool: + url = os.environ.get("UA_EMBED_URL") + if not url: + return False + import urllib.request + try: + req = urllib.request.Request( + url, data=json.dumps({"inputs": ["probe"], "truncate": True}).encode(), + headers={"Content-Type": "application/json"}) + urllib.request.urlopen(req, timeout=10) + return True + except Exception: + return False + + +@unittest.skipUnless(_embedder_reachable(), "UA_EMBED_URL not set or unreachable") +class SemanticTests(GraphQueryTestCase): + """Only runs when an embedding endpoint is configured.""" + + def setUp(self) -> None: + super().setUp() + self.write_graph(_chain_graph()) + self.embed_env = {"UA_EMBED_URL": os.environ["UA_EMBED_URL"]} + + def test_vectors_are_built_during_sync(self) -> None: + stats = self.cli_json("stats", env=self.embed_env) + self.assertTrue(stats["semantic"]) + self.assertGreater(stats["embedDimension"], 0) + + def test_semantic_search_returns_ranked_nodes(self) -> None: + self.cli_json("stats", env=self.embed_env) + hits = self.cli_json("semantic", "--q", "run a function", + "--k", "3", env=self.embed_env) + self.assertTrue(hits) + self.assertIn("id", hits[0]) + + def test_an_embedder_configured_later_backfills(self) -> None: + """Enabling the embedder after a plain sync must still index the graph. + + The vector index used to be created only on a first sync, so this path + left existing nodes without vectors and raised a raw driver error. + """ + first = self.cli_json("stats") # no embedder + self.assertFalse(first["semantic"]) + + second = self.cli_json("stats", env=self.embed_env) # embedder appears + self.assertTrue(second["semantic"]) + self.assertEqual(second["sync"]["mode"], "cached") + self.assertEqual(second["sync"]["vectorsBackfilled"], first["nodes"]) + + # and semantic search now works rather than erroring + self.assertTrue(self.cli_json("semantic", "--q", "types", env=self.embed_env)) + + # ── Graceful failure ────────────────────────────────────────────────────── class FailureModeTests(GraphQueryTestCase): @@ -377,6 +470,34 @@ def test_an_unreachable_embedder_does_not_break_plain_queries(self) -> None: ["file:src/a.ts", "file:src/b.ts", "file:src/c.ts", "file:src/d.ts"]) + def test_an_unsafe_node_type_is_refused(self) -> None: + """Types become labels, which cannot be parameterised. + + knowledge-graph.json is committed and shared, so a type that is not a + plain identifier is rejected rather than interpolated into Cypher. + """ + graph = _chain_graph() + graph["nodes"].append({ + "id": "evil", "type": "file) MATCH (n) DETACH DELETE n //", + "name": "evil", "filePath": "evil.ts", "summary": "", + "tags": [], "complexity": "simple", + }) + self.write_graph(graph) + + proc = self.run_cli("stats", expect_success=False) + self.assertNotEqual(proc.returncode, 0) + self.assertIn("unsafe node type", proc.stdout + proc.stderr) + + def test_an_unsafe_edge_type_is_refused(self) -> None: + graph = _chain_graph() + graph["edges"].append( + _edge("file:src/a.ts", "file:src/b.ts", "imports] () //")) + self.write_graph(graph) + + proc = self.run_cli("stats", expect_success=False) + self.assertNotEqual(proc.returncode, 0) + self.assertIn("unsafe edge type", proc.stdout + proc.stderr) + def test_unknown_batch_op_is_rejected(self) -> None: self.write_graph(_chain_graph()) proc = self.run_cli("batch", "--q", json.dumps([{"op": "nope"}]), diff --git a/understand-anything-plugin/skills/understand-diff/graph-query.py b/understand-anything-plugin/skills/understand-diff/graph-query.py index 445aca147..9c50f8b92 100644 --- a/understand-anything-plugin/skills/understand-diff/graph-query.py +++ b/understand-anything-plugin/skills/understand-diff/graph-query.py @@ -50,8 +50,10 @@ import hashlib import json import os +import re import sys import urllib.request +from collections import defaultdict from pathlib import Path UA_DIRS = (".ua", ".understand-anything") @@ -78,8 +80,28 @@ def find_graph_json(project_root: Path) -> Path: ) +_SAFE_TYPE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$") + + +def safe_type(raw: str, kind: str) -> str: + """Validate a type before it is interpolated into Cypher. + + Node and edge types become labels and relationship types, which cannot be + parameterised. `knowledge-graph.json` is committed to repositories and shared + between teammates, so it is not automatically trusted input. Anything that is + not a plain identifier is rejected rather than escaped. + """ + if not isinstance(raw, str) or not _SAFE_TYPE.match(raw): + raise SystemExit(f"refusing to build graph: unsafe {kind} type {raw!r}") + return raw + + def label_for(node_type: str) -> str: - return "".join(part.capitalize() for part in node_type.split("_")) + return "".join(part.capitalize() for part in safe_type(node_type, "node").split("_")) + + +def rel_for(edge_type: str) -> str: + return safe_type(edge_type, "edge").upper() def digest(payload) -> str: @@ -199,43 +221,152 @@ def _stored_digests(self) -> dict[str, str]: except Exception: return {} # graph does not exist yet + def _ensure_indexes(self) -> None: + """Create the id index, and the vector index whenever an embedder exists. + + Both are attempted every sync rather than only on the first one: an + embedder configured after the graph already exists still needs its index. + FalkorDB errors on a duplicate index, which is the expected steady state. + """ + for statement, params in ( + ("CREATE INDEX FOR (n:Node) ON (n.id)", {}), + *([( + "CREATE VECTOR INDEX FOR (n:Node) ON (n.emb) " + "OPTIONS {dimension: $d, similarityFunction: 'cosine'}", + {"d": self.embedder.dimension}, + )] if self.embedder else []), + ): + try: + self.graph.query(statement, params) + except Exception: + pass # already indexed + + def _backfill_vectors(self, nodes_by_id: dict[str, dict]) -> int: + """Embed any node that has no vector yet. + + Covers the case where the embedder is configured after a plain sync: the + nodes are already there and unchanged, so nothing else would revisit them. + """ + if not self.embedder: + return 0 + missing = [r[0] for r in self.rows( + "MATCH (n:Node) WHERE n.emb IS NULL RETURN n.id")] + pending = [i for i in missing if i in nodes_by_id] + if not pending: + return 0 + vectors = self.embedder.encode( + [Embedder.text_for(nodes_by_id[i]) for i in pending]) + self.graph.query( + "UNWIND $rows AS r MATCH (n:Node {id: r.id}) SET n.emb = vecf32(r.emb)", + {"rows": [{"id": i, "emb": v} for i, v in zip(pending, vectors)]}) + return len(pending) + + def _write_nodes(self, rows: list[dict], vectors: dict[str, list[float]]) -> None: + """Insert nodes in one query per label, rather than one query per node.""" + grouped: dict[str, list[dict]] = defaultdict(list) + for n in rows: + grouped[label_for(n["type"])].append(n) + + props = ("id: r.id, type: r.type, name: r.name, filePath: r.filePath, " + "summary: r.summary, tags: r.tags, complexity: r.complexity, " + "lineStart: r.lineStart, lineEnd: r.lineEnd, __key: r.__key") + for label, group in grouped.items(): + # Vectors cannot be applied conditionally inside one UNWIND, so the + # group is split by whether a vector is present. + plain = [r for r in group if r["id"] not in vectors] + embedded = [dict(r, emb=vectors[r["id"]]) for r in group if r["id"] in vectors] + if plain: + self.graph.query( + f"UNWIND $rows AS r CREATE (x:Node:{label} {{{props}}})", + {"rows": plain}) + if embedded: + self.graph.query( + f"UNWIND $rows AS r " + f"CREATE (x:Node:{label} {{{props}, emb: vecf32(r.emb)}})", + {"rows": embedded}) + + def _write_edges(self, edges: list[dict]) -> None: + """Insert edges in one query per relationship type.""" + grouped: dict[str, list[dict]] = defaultdict(list) + for e in edges: + grouped[rel_for(e["type"])].append({ + "src": e["source"], "dst": e["target"], "type": e["type"], + "direction": e.get("direction", "forward"), + "weight": e.get("weight", 0.0), + }) + for rel, group in grouped.items(): + self.graph.query( + "UNWIND $rows AS r MATCH (a:Node {id: r.src}), (b:Node {id: r.dst}) " + f"CREATE (a)-[:{rel} {{type: r.type, direction: r.direction, " + "weight: r.weight}]->(b)", + {"rows": group}) + + @staticmethod + def _row_for(node: dict, key: str) -> dict: + line_range = node.get("lineRange") or [] + return { + "id": node["id"], "type": node["type"], "name": node.get("name", ""), + "filePath": node.get("filePath", ""), "summary": node.get("summary", ""), + "tags": node.get("tags", []), "complexity": node.get("complexity", ""), + "lineStart": line_range[0] if len(line_range) == 2 else -1, + "lineEnd": line_range[1] if len(line_range) == 2 else -1, + "__key": key, + } + def _sync(self) -> dict: nodes = self.raw.get("nodes", []) edges = self.raw.get("edges", []) # The file a node belongs to is the unit of replacement. - by_key: dict[str, list[dict]] = {} + by_key: dict[str, list[dict]] = defaultdict(list) + key_of: dict[str, str] = {} for n in nodes: - by_key.setdefault(n.get("filePath") or n["id"], []).append(n) + key = n.get("filePath") or n["id"] + by_key[key].append(n) + key_of[n["id"]] = key + + # An edge is incident to the files at both of its ends, so adding an + # import marks both files changed. Without this, an edge-only change -- + # the most common thing a commit does -- would leave every digest intact + # and never be synced at all. + edges_by_key: dict[str, list[dict]] = defaultdict(list) + for e in edges: + fingerprint = (e["source"], e["target"], e["type"], + e.get("direction", "forward"), e.get("weight", 0.0)) + for endpoint in (e["source"], e["target"]): + key = key_of.get(endpoint) + if key is not None: + edges_by_key[key].append(fingerprint) current = { - k: digest([ - {kk: n.get(kk) for kk in - ("id", "type", "name", "summary", "tags", "complexity")} - for n in sorted(v, key=lambda x: x["id"]) - ]) + k: digest({ + "nodes": [ + {kk: n.get(kk) for kk in + ("id", "type", "name", "summary", "tags", "complexity", + "filePath", "lineRange")} + for n in sorted(v, key=lambda x: x["id"]) + ], + "edges": sorted(edges_by_key.get(k, [])), + }) for k, v in by_key.items() } stored = self._stored_digests() changed = {k for k, d in current.items() if stored.get(k) != d} removed = set(stored) - set(current) + first_run = not stored - if not changed and not removed: - return {"mode": "cached", "files": 0, "nodes": 0} + self._ensure_indexes() - first_run = not stored - if first_run: - self.graph.query("CREATE INDEX FOR (n:Node) ON (n.id)") - if self.embedder: - self.graph.query( - "CREATE VECTOR INDEX FOR (n:Node) ON (n.emb) " - "OPTIONS {dimension: $d, similarityFunction: 'cosine'}", - {"d": self.embedder.dimension}, - ) + if not changed and not removed: + backfilled = self._backfill_vectors({n["id"]: n for n in nodes}) + return {"mode": "cached", "files": 0, "nodes": 0, + **({"vectorsBackfilled": backfilled} if backfilled else {})} # Drop the nodes of changed/removed files. DETACH also drops edges - # arriving from untouched files, so those are re-created below. + # arriving from untouched files, so those are re-created below. Stamps go + # last, after edges: a crash mid-sync must leave the affected files + # unstamped so the next run repairs them instead of reporting "cached". for key in changed | removed: self.graph.query( "MATCH (n:Node) WHERE n.__key = $k DETACH DELETE n", {"k": key}) @@ -250,44 +381,22 @@ def _sync(self) -> dict: self.embedder.encode([Embedder.text_for(n) for n in fresh]), )) - for key in changed: - for n in by_key[key]: - line_range = n.get("lineRange") or [] - params = { - "id": n["id"], "type": n["type"], "name": n.get("name", ""), - "filePath": n.get("filePath", ""), "summary": n.get("summary", ""), - "tags": n.get("tags", []), "complexity": n.get("complexity", ""), - "lineStart": line_range[0] if len(line_range) == 2 else -1, - "lineEnd": line_range[1] if len(line_range) == 2 else -1, - "key": key, - } - props = ("id: $id, type: $type, name: $name, filePath: $filePath, " - "summary: $summary, tags: $tags, complexity: $complexity, " - "lineStart: $lineStart, lineEnd: $lineEnd, __key: $key") - if n["id"] in vectors: - params["emb"] = vectors[n["id"]] - props += ", emb: vecf32($emb)" - self.graph.query( - f"CREATE (x:Node:{label_for(n['type'])} {{{props}}})", params) - self.graph.query( - f"CREATE (:{FILE_STAMP} {{key: $k, digest: $d}})", - {"k": key, "d": current[key]}) + self._write_nodes([self._row_for(n, key_of[n["id"]]) for n in fresh], vectors) - touched = {n["id"] for k in changed for n in by_key[k]} - for e in edges: - if e["source"] in touched or e["target"] in touched: - self.graph.query( - "MATCH (a:Node {id: $src}), (b:Node {id: $dst}) " - f"MERGE (a)-[:{e['type'].upper()} {{type: $type, " - "direction: $direction, weight: $weight}]->(b)", - {"src": e["source"], "dst": e["target"], "type": e["type"], - "direction": e.get("direction", "forward"), - "weight": e.get("weight", 0.0)}) + touched = {n["id"] for n in fresh} + self._write_edges([e for e in edges + if e["source"] in touched or e["target"] in touched]) + + self.graph.query( + f"UNWIND $rows AS r CREATE (:{FILE_STAMP} {{key: r.key, digest: r.digest}})", + {"rows": [{"key": k, "digest": current[k]} for k in changed]}) + backfilled = self._backfill_vectors({n["id"]: n for n in nodes}) return { "mode": "full" if first_run else "incremental", "files": len(changed) + len(removed), "nodes": len(fresh), + **({"vectorsBackfilled": backfilled} if backfilled else {}), } # ---- queries ----------------------------------------------------------- From fd49fee4661a14b46c34af6c4590cfc6f9fa37b3 Mon Sep 17 00:00:00 2001 From: Gal Shubeli Date: Mon, 17 Aug 2026 13:44:53 +0300 Subject: [PATCH 06/10] fix(diff): verify index state on open instead of assuming it A second review turned up three more defects in the same sync layer, all of the same shape: persistent state was assumed rather than checked. Swapping embedding models left a stale vector index. The previous fix created an index when none existed but ignored one with the wrong dimension, so 384-dimension vectors were written into a 4-dimension index, sync reported success, and only the query failed -- with an error naming neither the cause nor the fix. The existing dimension is now read from db.indexes(); a mismatch drops the index, clears the unusable vectors and re-embeds, and the rebuild is reported in the sync summary. Stamps whose nodes no longer exist are pruned before diffing. A sync interrupted after stamping would otherwise leave the file marked present with its nodes missing, and every later run would call it cached. search silently ignored an explicit limit in batch mode, returning 25 rows to a caller that asked for 2 -- the opposite of what a skill watching its context wants. The limit is threaded through and exposed as --limit. The workspace index is no longer deleted and rebuilt on every invocation. It is stamped with a digest of the manifests it derives from, so unchanged manifests reuse it and concurrent readers never observe it half-built. Tests: 39, up from 34. The stale-index test has to plant the bad index from processes with no embedder configured, because a process that has one now repairs it on the way in. --- tests/skill/diff/test_graph_query.py | 68 ++++++++++ .../skills/understand-diff/graph-query.py | 126 ++++++++++++++---- 2 files changed, 166 insertions(+), 28 deletions(-) diff --git a/tests/skill/diff/test_graph_query.py b/tests/skill/diff/test_graph_query.py index c95d163b1..aac010f7c 100644 --- a/tests/skill/diff/test_graph_query.py +++ b/tests/skill/diff/test_graph_query.py @@ -205,6 +205,19 @@ def test_batch_accepts_stdin(self) -> None: "hops": 1}])) self.assertEqual(out[0], ["file:src/a.ts", "file:src/b.ts"]) + def test_search_honours_an_explicit_limit(self) -> None: + """A limit passed in a batch spec must reach the query. + + It used to be dropped, so a caller asking for 2 results silently got 25 -- + the opposite of what a context-conscious skill wants. + """ + out = self.cli_json("batch", "--q", json.dumps([ + {"op": "search", "q": "src", "limit": 2}, + {"op": "search", "q": "src"}, + ])) + self.assertEqual(len(out[0]), 2) + self.assertGreater(len(out[1]), 2) + def test_ids_are_not_namespaced_for_a_single_repo(self) -> None: """Single-repo ids must stay byte-identical to the JSON's own ids.""" for node_id in self.cli_json("blast-radius", "--name", "types.ts"): @@ -306,6 +319,20 @@ def test_changing_an_edge_property_is_synced(self) -> None: self.write_graph(self.graph) self.assertEqual(self.cli_json("stats")["sync"]["mode"], "incremental") + def test_a_stamp_without_nodes_is_pruned(self) -> None: + """A stamp whose nodes are gone must not mask the file on the next sync. + + Simulates a sync interrupted after stamping: the stamp says the file is + present while its nodes are not, which would otherwise be reported as + 'cached' forever. + """ + self.cli_json("cypher", "--q", + "MATCH (n:Node) WHERE n.__key = 'src/b.ts' DELETE n") + after = self.cli_json("stats") + self.assertEqual(after["sync"]["mode"], "incremental") + self.assertEqual(after["nodes"], self.baseline["nodes"]) + self.assertEqual(after["edges"], self.baseline["edges"]) + def test_node_without_a_file_path_still_loads(self) -> None: self.graph["nodes"].append({ "id": "concept:orphan", "type": "concept", "name": "Orphan", @@ -382,6 +409,24 @@ def test_cross_repo_blast_radius_reports_both_scopes(self) -> None: self.assertEqual(out["downstreamRepos"], ["api", "web"]) self.assertIn("shared", out["sameRepo"]) + def test_index_is_reused_when_manifests_are_unchanged(self) -> None: + """The index is derived from package.json, so it should not be rebuilt + on every command — a rebuild also briefly empties it for other readers.""" + first = self.cli_ws("stats") + second = self.cli_ws("stats") + self.assertEqual(first["indexEdges"], second["indexEdges"]) + self.assertEqual(self.cli_ws("affected-repos", "--repo", "shared"), + ["api", "web"]) + + def test_index_is_rebuilt_when_a_dependency_changes(self) -> None: + self.cli_ws("stats") + pkg = self.root / "web" / "package.json" + data = json.loads(pkg.read_text()) + data["dependencies"] = {} # web no longer depends on api + pkg.write_text(json.dumps(data)) + + self.assertEqual(self.cli_ws("affected-repos", "--repo", "shared"), ["api"]) + def test_workspace_second_run_is_cached(self) -> None: first = self.cli_ws("stats") second = self.cli_ws("stats") @@ -428,6 +473,29 @@ def test_semantic_search_returns_ranked_nodes(self) -> None: self.assertTrue(hits) self.assertIn("id", hits[0]) + def test_a_stale_vector_index_is_rebuilt(self) -> None: + """Switching embedding models must not leave a wrong-dimension index. + + Writing 384-dimension vectors into a 4-dimension index used to succeed + silently and fail only at query time, with an error naming neither the + cause nor the fix. + """ + self.cli_json("stats", env=self.embed_env) + + # Plant the wrong index from processes with no embedder configured, so + # they do not repair it on the way in. + self.cli_json("cypher", "--q", "DROP VECTOR INDEX FOR (n:Node) ON (n.emb)") + self.cli_json("cypher", "--q", + "CREATE VECTOR INDEX FOR (n:Node) ON (n.emb) " + "OPTIONS {dimension: 4, similarityFunction: 'cosine'}") + + rebuilt = self.cli_json("stats", env=self.embed_env) + self.assertEqual(rebuilt["sync"]["vectorIndexRebuilt"]["from"], 4) + self.assertEqual(rebuilt["sync"]["vectorIndexRebuilt"]["to"], + rebuilt["embedDimension"]) + # and the query works rather than raising a dimension mismatch + self.assertTrue(self.cli_json("semantic", "--q", "types", env=self.embed_env)) + def test_an_embedder_configured_later_backfills(self) -> None: """Enabling the embedder after a plain sync must still index the graph. diff --git a/understand-anything-plugin/skills/understand-diff/graph-query.py b/understand-anything-plugin/skills/understand-diff/graph-query.py index 9c50f8b92..cfd4dd67b 100644 --- a/understand-anything-plugin/skills/understand-diff/graph-query.py +++ b/understand-anything-plugin/skills/understand-diff/graph-query.py @@ -221,25 +221,71 @@ def _stored_digests(self) -> dict[str, str]: except Exception: return {} # graph does not exist yet - def _ensure_indexes(self) -> None: - """Create the id index, and the vector index whenever an embedder exists. + def _vector_index_dimension(self) -> int | None: + """Dimension of the existing vector index on :Node(emb), if there is one.""" + try: + rows = self.graph.query("CALL db.indexes()").result_set + except Exception: + return None + for label, _props, types, options, *_rest in rows: + if label != "Node" or not isinstance(types, dict): + continue + if "VECTOR" in (types.get("emb") or []): + return (options or {}).get("emb", {}).get("dimension") + return None + + def _ensure_indexes(self) -> dict: + """Bring indexes in line with the current configuration. - Both are attempted every sync rather than only on the first one: an - embedder configured after the graph already exists still needs its index. - FalkorDB errors on a duplicate index, which is the expected steady state. + Checked on every sync rather than only the first, because the + configuration can change under a graph that already exists. Two cases + matter: an embedder added after a plain sync needs an index created, and + an embedder swapped for a different model needs the index *rebuilt* -- + writing 384-dimension vectors into a 4-dimension index succeeds silently + and only fails later, at query time, with an error that points nowhere. """ - for statement, params in ( - ("CREATE INDEX FOR (n:Node) ON (n.id)", {}), - *([( - "CREATE VECTOR INDEX FOR (n:Node) ON (n.emb) " - "OPTIONS {dimension: $d, similarityFunction: 'cosine'}", - {"d": self.embedder.dimension}, - )] if self.embedder else []), - ): - try: - self.graph.query(statement, params) - except Exception: - pass # already indexed + try: + self.graph.query("CREATE INDEX FOR (n:Node) ON (n.id)") + except Exception: + pass # already indexed + + if not self.embedder: + return {} + + existing = self._vector_index_dimension() + wanted = self.embedder.dimension + + if existing == wanted: + return {} + + if existing is not None: + # Stale vectors are unusable at the new dimension, so drop them and + # let the backfill re-embed from scratch. + self.graph.query("DROP VECTOR INDEX FOR (n:Node) ON (n.emb)") + self.graph.query("MATCH (n:Node) WHERE n.emb IS NOT NULL SET n.emb = NULL") + + self.graph.query( + "CREATE VECTOR INDEX FOR (n:Node) ON (n.emb) " + "OPTIONS {dimension: $d, similarityFunction: 'cosine'}", {"d": wanted}) + + if existing is None: + return {} + return {"vectorIndexRebuilt": {"from": existing, "to": wanted}} + + def _prune_orphan_stamps(self) -> int: + """Drop stamps whose files have no nodes left. + + A stamp without nodes would make the next sync believe that file is + already present and skip it. + """ + live = {r[0] for r in self.rows("MATCH (n:Node) RETURN DISTINCT n.__key")} + stamped = {r[0] for r in self.rows(f"MATCH (s:{FILE_STAMP}) RETURN s.key")} + orphans = sorted(stamped - live) + if orphans: + self.graph.query( + f"UNWIND $keys AS k MATCH (s:{FILE_STAMP} {{key: k}}) DELETE s", + {"keys": orphans}) + return len(orphans) def _backfill_vectors(self, nodes_by_id: dict[str, dict]) -> int: """Embed any node that has no vector yet. @@ -351,16 +397,19 @@ def _sync(self) -> dict: for k, v in by_key.items() } + index_state = self._ensure_indexes() + stored = self._stored_digests() + if stored: + self._prune_orphan_stamps() + stored = self._stored_digests() changed = {k for k, d in current.items() if stored.get(k) != d} removed = set(stored) - set(current) first_run = not stored - self._ensure_indexes() - if not changed and not removed: backfilled = self._backfill_vectors({n["id"]: n for n in nodes}) - return {"mode": "cached", "files": 0, "nodes": 0, + return {"mode": "cached", "files": 0, "nodes": 0, **index_state, **({"vectorsBackfilled": backfilled} if backfilled else {})} # Drop the nodes of changed/removed files. DETACH also drops edges @@ -396,6 +445,7 @@ def _sync(self) -> dict: "mode": "full" if first_run else "incremental", "files": len(changed) + len(removed), "nodes": len(fresh), + **index_state, **({"vectorsBackfilled": backfilled} if backfilled else {}), } @@ -530,17 +580,16 @@ def __init__(self, members: list[tuple[str, Path]], backend: Backend, self.index_edges = self._build_index(members) def _build_index(self, members: list[tuple[str, Path]]) -> int: - """Rebuild the index from package manifests. It is small, so rebuild whole. + """Build the index from package manifests, skipping it when unchanged. + + The index is tiny, so it is rebuilt whole rather than diffed -- but only + when the manifests it derives from have actually changed, so repeated + commands do not pay for it and never observe it half-built. UA's graph records only resolved intra-repo imports, so file-level cross-repo edges would need the scan phase's import map. Package manifests give the repo-level dependency reliably. """ - try: - self.index.delete() - except Exception: - pass - owner: dict[str, str] = {} declared: dict[str, dict] = {} for name, path in members: @@ -561,6 +610,24 @@ def _build_index(self, members: list[tuple[str, Path]]) -> int: **data.get("peerDependencies", {}), } + # Rebuild only when the manifests differ from what the index was built from. + stamp = digest({"repos": sorted(self.repos), "owner": owner, + "declared": {k: sorted(v) for k, v in declared.items()}}) + try: + rows = self.index.query( + f"MATCH (s:{FILE_STAMP}) RETURN s.digest LIMIT 1").result_set + if rows and rows[0][0] == stamp: + edges = self.index.query( + "MATCH ()-[r:DEPENDS_ON]->() RETURN count(r)").result_set + return edges[0][0] if edges else 0 + except Exception: + pass # index graph does not exist yet + + try: + self.index.delete() + except Exception: + pass + for name in self.repos: self.index.query("CREATE (:Repo {name: $n})", {"n": name}) @@ -574,6 +641,8 @@ def _build_index(self, members: list[tuple[str, Path]]) -> int: "MERGE (a)-[:DEPENDS_ON {via: $via}]->(b)", {"a": name, "b": target, "via": spec}) created += 1 + + self.index.query(f"CREATE (:{FILE_STAMP} {{digest: $d}})", {"d": stamp}) return created # ---- cross-repo --------------------------------------------------------- @@ -628,7 +697,7 @@ def stats(self) -> dict: # --------------------------------------------------------------------------- # PER_REPO_OPS = { - "search": ("search", ("q",)), + "search": ("search", ("q", "limit")), "nodes-for-file": ("nodes_for_file", ("path",)), "neighbors": ("neighbors", ("id",)), "blast-radius": ("blast_radius", ("name", "hops")), @@ -697,6 +766,7 @@ def main() -> None: p.add_argument("--name", help="node name") p.add_argument("--repo", help="repo name (affected-repos)") p.add_argument("--hops", type=int, default=3) + p.add_argument("--limit", type=int, default=25) p.add_argument("--k", type=int, default=10) p.add_argument("--from", dest="src") p.add_argument("--to", dest="dst") @@ -724,7 +794,7 @@ def main() -> None: spec_from_flags = { "q": args.q, "id": args.id, "name": args.name, "path": args.q, - "repo": args.repo, "hops": args.hops, "k": args.k, + "repo": args.repo, "hops": args.hops, "k": args.k, "limit": args.limit, "from": args.src, "to": args.dst, } From 5af829cb3debda004d66674e8648939997819067 Mon Sep 17 00:00:00 2001 From: Gal Shubeli Date: Mon, 17 Aug 2026 14:11:09 +0300 Subject: [PATCH 07/10] fix(diff): scope blast-radius to a file rather than a basename Running each feature by hand turned up a defect the unit tests could not see, because the fixture had no two files sharing a name. blast-radius seeded from a node's name, and basenames are not unique: this repository has eleven files called index.ts and five called types.ts. So asking for the impact of index.ts unioned the impact of all eleven and said nothing about having done so. Worse, the skill instructions told the agent to pass exactly that -- the changed file's basename -- which is the ambiguous form. On this repo's own graph the difference is stark: --name types.ts reports 110 affected nodes, while the two real files report 47 and 65 separately. blast-radius now takes --path and seeds from that file alone, matching from the right in both directions like nodes-for-file already did. --name still works for the cases where a symbol is what you have, but the docstring and the skill instructions both now say to pass a path, which is what git reports anyway. Neither given is an error rather than an empty result. The test fixture gains a second file named types.ts so the ambiguity is representable, and a test asserts that a path does not pick up its namesake while a name does. Tests: 41, up from 39. --- tests/skill/diff/test_graph_query.py | 50 ++++++++++---- .../skills/understand-diff/SKILL.md | 7 +- .../skills/understand-diff/graph-query.py | 65 ++++++++++++++----- 3 files changed, 95 insertions(+), 27 deletions(-) diff --git a/tests/skill/diff/test_graph_query.py b/tests/skill/diff/test_graph_query.py index aac010f7c..1a6d2104c 100644 --- a/tests/skill/diff/test_graph_query.py +++ b/tests/skill/diff/test_graph_query.py @@ -79,12 +79,18 @@ def _edge(source: str, target: str, edge_type: str) -> dict: def _chain_graph() -> dict: files = ["types.ts", "a.ts", "b.ts", "c.ts", "d.ts"] nodes = [_node(f"file:src/{f}", "file", f, f"src/{f}") for f in files] + # A second file with the same basename, imported by e.ts only. Seeding by + # name picks up both; seeding by path must not. + nodes.append(_node("file:src/nested/types.ts", "file", "types.ts", + "src/nested/types.ts")) + nodes.append(_node("file:src/e.ts", "file", "e.ts", "src/e.ts")) nodes += [ _node("function:src/a.ts:runA", "function", "runA", "src/a.ts"), _node("function:src/b.ts:runB", "function", "runB", "src/b.ts"), _node("function:src/c.ts:runC", "function", "runC", "src/c.ts"), ] edges = [ + _edge("file:src/e.ts", "file:src/nested/types.ts", "imports"), _edge("file:src/a.ts", "file:src/types.ts", "imports"), _edge("file:src/b.ts", "file:src/types.ts", "imports"), _edge("file:src/c.ts", "file:src/b.ts", "imports"), @@ -157,13 +163,34 @@ def setUp(self) -> None: def test_blast_radius_follows_the_import_chain(self) -> None: """a and b import types directly; c and d reach it in 2 and 3 hops.""" self.assertEqual( - self.cli_json("blast-radius", "--name", "types.ts", "--hops", "3"), + self.cli_json("blast-radius", "--path", "src/types.ts", "--hops", "3"), ["file:src/a.ts", "file:src/b.ts", "file:src/c.ts", "file:src/d.ts"], ) + def test_blast_radius_by_path_is_scoped_to_one_file(self) -> None: + """Basenames are not unique, so a path must not seed from namesakes. + + Two different files are named types.ts here; seeding by name unions both + and overstates the impact. + """ + by_path = self.cli_json("blast-radius", "--path", "src/types.ts", "--hops", "3") + self.assertEqual(by_path, ["file:src/a.ts", "file:src/b.ts", + "file:src/c.ts", "file:src/d.ts"]) + + by_name = self.cli_json("blast-radius", "--name", "types.ts", "--hops", "1") + self.assertIn("file:src/e.ts", by_name) # only the namesake reaches this + self.assertNotIn("file:src/e.ts", + self.cli_json("blast-radius", "--path", "src/types.ts", + "--hops", "1")) + + def test_blast_radius_needs_a_path_or_a_name(self) -> None: + proc = self.run_cli("blast-radius", expect_success=False) + self.assertNotEqual(proc.returncode, 0) + self.assertIn("path", proc.stdout + proc.stderr) + def test_blast_radius_respects_the_hop_limit(self) -> None: self.assertEqual( - self.cli_json("blast-radius", "--name", "types.ts", "--hops", "1"), + self.cli_json("blast-radius", "--path", "src/types.ts", "--hops", "1"), ["file:src/a.ts", "file:src/b.ts"], ) @@ -193,7 +220,7 @@ def test_search_matches_name_and_path(self) -> None: def test_batch_answers_several_questions_in_order(self) -> None: out = self.cli_json("batch", "--q", json.dumps([ - {"op": "blast-radius", "name": "types.ts", "hops": 1}, + {"op": "blast-radius", "path": "src/types.ts", "hops": 1}, {"op": "calls-from", "name": "runA", "hops": 1}, ])) self.assertEqual(out[0], ["file:src/a.ts", "file:src/b.ts"]) @@ -201,7 +228,7 @@ def test_batch_answers_several_questions_in_order(self) -> None: def test_batch_accepts_stdin(self) -> None: out = self.cli_json( - "batch", stdin=json.dumps([{"op": "blast-radius", "name": "types.ts", + "batch", stdin=json.dumps([{"op": "blast-radius", "path": "src/types.ts", "hops": 1}])) self.assertEqual(out[0], ["file:src/a.ts", "file:src/b.ts"]) @@ -220,7 +247,7 @@ def test_search_honours_an_explicit_limit(self) -> None: def test_ids_are_not_namespaced_for_a_single_repo(self) -> None: """Single-repo ids must stay byte-identical to the JSON's own ids.""" - for node_id in self.cli_json("blast-radius", "--name", "types.ts"): + for node_id in self.cli_json("blast-radius", "--path", "src/types.ts"): self.assertNotIn("::", node_id) @@ -235,8 +262,8 @@ def setUp(self) -> None: def test_first_sync_is_full(self) -> None: self.assertEqual(self.baseline["sync"]["mode"], "full") - self.assertEqual(self.baseline["nodes"], 8) - self.assertEqual(self.baseline["edges"], 9) + self.assertEqual(self.baseline["nodes"], 10) + self.assertEqual(self.baseline["edges"], 10) def test_unchanged_graph_is_not_resynced(self) -> None: again = self.cli_json("stats") @@ -267,7 +294,7 @@ def test_incremental_sync_preserves_incoming_edges(self) -> None: self.assertEqual(after["edges"], self.baseline["edges"]) # c.ts -> b.ts is an edge owned by an untouched file. self.assertIn("file:src/c.ts", - self.cli_json("blast-radius", "--name", "b.ts", "--hops", "1")) + self.cli_json("blast-radius", "--path", "src/b.ts", "--hops", "1")) def test_removing_a_file_removes_its_nodes(self) -> None: dropped = {n["id"] for n in self.graph["nodes"] if n["filePath"] == "src/d.ts"} @@ -298,7 +325,8 @@ def test_adding_only_an_edge_is_synced(self) -> None: self.assertEqual(after["edges"], self.baseline["edges"] + 1) # d.ts now depends on types.ts directly rather than only through c.ts self.assertIn("file:src/d.ts", - self.cli_json("blast-radius", "--name", "types.ts", "--hops", "1")) + self.cli_json("blast-radius", "--path", "src/types.ts", + "--hops", "1")) def test_removing_only_an_edge_is_synced(self) -> None: self.graph["edges"] = [e for e in self.graph["edges"] @@ -309,7 +337,7 @@ def test_removing_only_an_edge_is_synced(self) -> None: after = self.cli_json("stats") self.assertEqual(after["edges"], self.baseline["edges"] - 1) self.assertNotIn("file:src/a.ts", - self.cli_json("blast-radius", "--name", "types.ts")) + self.cli_json("blast-radius", "--path", "src/types.ts")) def test_changing_an_edge_property_is_synced(self) -> None: for edge in self.graph["edges"]: @@ -531,7 +559,7 @@ def test_semantic_search_requires_an_endpoint(self) -> None: def test_an_unreachable_embedder_does_not_break_plain_queries(self) -> None: """A misconfigured endpoint must warn, not take the whole command down.""" self.write_graph(_chain_graph()) - proc = self.run_cli("blast-radius", "--name", "types.ts", + proc = self.run_cli("blast-radius", "--path", "src/types.ts", env={"UA_EMBED_URL": "http://127.0.0.1:9/embed"}) self.assertIn("warning", proc.stderr.lower()) self.assertEqual(json.loads(proc.stdout), diff --git a/understand-anything-plugin/skills/understand-diff/SKILL.md b/understand-anything-plugin/skills/understand-diff/SKILL.md index 792a6c143..0ecb2ee4e 100644 --- a/understand-anything-plugin/skills/understand-diff/SKILL.md +++ b/understand-anything-plugin/skills/understand-diff/SKILL.md @@ -60,7 +60,7 @@ The knowledge graph JSON has this structure: ```bash python "/graph-query.py" batch --q '[ {"op": "nodes-for-file", "path": ""}, - {"op": "blast-radius", "name": "", "hops": 3} + {"op": "blast-radius", "path": "", "hops": 3} ]' ``` @@ -69,6 +69,11 @@ The knowledge graph JSON has this structure: ids from step 5. Put every changed file in one `batch` call rather than calling once per file — process startup dominates, the queries themselves are milliseconds. + Pass `blast-radius` a **path**, not a basename. Basenames are not unique — this + repository has eleven files called `index.ts` and five called `types.ts` — and a + name seeds from all of them at once, overstating the impact without saying so. A + changed path is what git reports anyway. + To check availability, run `python "/graph-query.py" stats`. If it exits non-zero the backend is not configured — that is the normal default, so just continue with steps 4–6 as written. The backend is read-only, reads the same diff --git a/understand-anything-plugin/skills/understand-diff/graph-query.py b/understand-anything-plugin/skills/understand-diff/graph-query.py index cfd4dd67b..1474e3656 100644 --- a/understand-anything-plugin/skills/understand-diff/graph-query.py +++ b/understand-anything-plugin/skills/understand-diff/graph-query.py @@ -29,18 +29,18 @@ python graph-query.py search --q auth python graph-query.py nodes-for-file --q src/types.ts python graph-query.py neighbors --id "file:src/a.ts" - python graph-query.py blast-radius --name types.ts --hops 3 + python graph-query.py blast-radius --path src/types.ts --hops 3 python graph-query.py calls-from --name registerAllParsers python graph-query.py calls-to --name validateGraph python graph-query.py path --from "file:a.ts" --to "file:b.ts" python graph-query.py semantic --q "how are imports resolved" python graph-query.py semantic-traverse --q "graph persistence" --hops 2 python graph-query.py cypher --q "MATCH (n:File) RETURN count(n)" - python graph-query.py batch --q '[{"op":"blast-radius","name":"a.ts"}]' + python graph-query.py batch --q '[{"op":"blast-radius","path":"src/a.ts"}]' # workspace-only python graph-query.py affected-repos --repo shared --workspace ws.json - python graph-query.py blast-radius --name auth.ts --workspace ws.json + python graph-query.py blast-radius --path src/auth.ts --workspace ws.json Every command prints JSON on stdout. """ @@ -490,14 +490,38 @@ def neighbors(self, node_id: str) -> list[dict]: "RETURN type(r), m.id, m.type, m.name ORDER BY m.id", {"id": node_id}) return [dict(zip(("edge", "id", "type", "name"), r)) for r in rows] - def blast_radius(self, name: str, hops: int = 3) -> list[str]: - """Everything in this repo that transitively depends on the named node.""" + _PATH_MATCH = ("t.filePath <> '' AND (t.filePath = $p " + "OR t.filePath ENDS WITH $suffix OR $p ENDS WITH ('/' + t.filePath))") + + def blast_radius(self, name: str | None = None, hops: int = 3, + path: str | None = None) -> list[str]: + """Everything in this repo that transitively depends on the target. + + Prefer `path`. Basenames are not unique -- this repo has eleven files + named index.ts and five named types.ts -- so seeding by name unions the + impact of every file sharing that name and silently overstates it. + `understand-diff` starts from a changed path, so it has the precise + answer available and should pass it. + """ rels = "|".join(DEPENDENCY_EDGES) - rows = self.rows( - f"MATCH (t:Node)<-[:{rels}*1..{hops}]-(d:Node) " - "WHERE t.name = $n RETURN DISTINCT d.id ORDER BY d.id", {"n": name}) + if path: + clean = path.lstrip("/") + rows = self.rows( + f"MATCH (t:Node)<-[:{rels}*1..{hops}]-(d:Node) WHERE {self._PATH_MATCH} " + "RETURN DISTINCT d.id ORDER BY d.id", + {"p": clean, "suffix": "/" + clean}) + else: + rows = self.rows( + f"MATCH (t:Node)<-[:{rels}*1..{hops}]-(d:Node) " + "WHERE t.name = $n RETURN DISTINCT d.id ORDER BY d.id", {"n": name}) return [r[0] for r in rows] + def has_path(self, path: str) -> bool: + clean = path.lstrip("/") + return bool(self.rows( + f"MATCH (t:Node) WHERE {self._PATH_MATCH} RETURN 1 LIMIT 1", + {"p": clean, "suffix": "/" + clean})) + def calls_from(self, name: str, hops: int = 3) -> list[str]: rows = self.rows( f"MATCH (s:Node)-[:CALLS*1..{hops}]->(x:Node) " @@ -657,14 +681,19 @@ def affected_repos(self, repo: str, hops: int = 5) -> list[str]: def repos_defining(self, name: str) -> list[str]: return [r for r, g in self.repos.items() if g.has_name(name)] - def blast_radius(self, name: str, hops: int = 3) -> dict: - """Two stages: local impact where the node lives, then dependent repos. + def repos_containing_path(self, path: str) -> list[str]: + return [r for r, g in self.repos.items() if g.has_path(path)] + + def blast_radius(self, name: str | None = None, hops: int = 3, + path: str | None = None) -> dict: + """Two stages: local impact where the file lives, then dependent repos. Downstream repos are reported at repo granularity because there are no file-level cross-repo edges to follow — see _build_index. """ - origins = self.repos_defining(name) - local = {r: self.repos[r].blast_radius(name, hops) for r in origins} + origins = (self.repos_containing_path(path) if path + else self.repos_defining(name)) + local = {r: self.repos[r].blast_radius(name, hops, path) for r in origins} downstream = sorted({ d for r in origins for d in self.affected_repos(r) if d not in origins }) @@ -700,7 +729,6 @@ def stats(self) -> dict: "search": ("search", ("q", "limit")), "nodes-for-file": ("nodes_for_file", ("path",)), "neighbors": ("neighbors", ("id",)), - "blast-radius": ("blast_radius", ("name", "hops")), "calls-from": ("calls_from", ("name", "hops")), "calls-to": ("calls_to", ("name", "hops")), "path": ("path", ("from", "to", "hops")), @@ -719,6 +747,11 @@ def run_one(target, spec: dict): if isinstance(target, Workspace): return {r: g.rows(spec["q"]) for r, g in target.repos.items()} return target.rows(spec["q"]) + if op == "blast-radius": + name, path = spec.get("name"), spec.get("path") + if not (name or path): + raise SystemExit("blast-radius requires a path (preferred) or a name") + return target.blast_radius(name, spec.get("hops", 3), path) if op == "affected-repos": if not isinstance(target, Workspace): raise SystemExit("affected-repos needs --workspace") @@ -763,7 +796,8 @@ def main() -> None: p.add_argument("--workspace", help="manifest listing several repos") p.add_argument("--q", help="search term, query text, raw Cypher, or batch JSON") p.add_argument("--id", help="node id") - p.add_argument("--name", help="node name") + p.add_argument("--name", help="node name (ambiguous for common basenames)") + p.add_argument("--path", help="file path (preferred for blast-radius)") p.add_argument("--repo", help="repo name (affected-repos)") p.add_argument("--hops", type=int, default=3) p.add_argument("--limit", type=int, default=25) @@ -793,7 +827,8 @@ def main() -> None: target = RepoGraph(backend, key, graph_json, embedder) spec_from_flags = { - "q": args.q, "id": args.id, "name": args.name, "path": args.q, + "q": args.q, "id": args.id, "name": args.name, + "path": args.path or (args.q if args.command == "nodes-for-file" else None), "repo": args.repo, "hops": args.hops, "k": args.k, "limit": args.limit, "from": args.src, "to": args.dst, } From 3e25184985955423eb22db14ecf6764493ae96a5 Mon Sep 17 00:00:00 2001 From: Gal Shubeli Date: Mon, 17 Aug 2026 14:44:49 +0300 Subject: [PATCH 08/10] fix(diff): make the fast path actually reachable, and cover step 6 Walking the skill's own instructions on a real repository turned up two problems that no amount of testing the adapter directly would have found. The instructions said to run `python graph-query.py`, but the embedded backend needs Python 3.12 or newer and a project's default python is often older. On such a machine the availability check failed, the skill fell back to grep, and the feature was dead weight that never announced itself. The commands now use "${UA_PYTHON:-python}" so a user with a suitable interpreter elsewhere can point at it once, the requirement is stated where the commands are, and the error names the version it is actually running so the cause is obvious. The fast path also claimed to cover steps 4 to 6, but only covered 4 and 5: step 6 is architectural layers, and layers were not exposed at all. Added a layers-for op. It reads the layers array straight from the JSON rather than the graph, because a layer lookup is a flat intersection rather than a traversal, and keeping it out of the graph keeps it out of the incremental sync's invariants -- which is where every bug in this file has been. Verified end to end on a fixture repository: types.ts changed, and the batch call reports store.ts at one hop, view.ts at two, and the Core layer. Tests: 42, up from 41. --- tests/skill/diff/test_graph_query.py | 16 +++++++- .../skills/understand-diff/SKILL.md | 15 ++++++-- .../skills/understand-diff/graph-query.py | 38 ++++++++++++++++--- 3 files changed, 58 insertions(+), 11 deletions(-) diff --git a/tests/skill/diff/test_graph_query.py b/tests/skill/diff/test_graph_query.py index 1a6d2104c..f87c9b019 100644 --- a/tests/skill/diff/test_graph_query.py +++ b/tests/skill/diff/test_graph_query.py @@ -101,8 +101,14 @@ def _chain_graph() -> dict: _edge("function:src/a.ts:runA", "function:src/b.ts:runB", "calls"), _edge("function:src/b.ts:runB", "function:src/c.ts:runC", "calls"), ] + layers = [ + {"id": "core", "name": "Core", "description": "shared types", + "nodeIds": ["file:src/types.ts", "file:src/a.ts"]}, + {"id": "edge", "name": "Edge", "description": "leaves", + "nodeIds": ["file:src/d.ts"]}, + ] return {"version": "1.0.0", "project": {"name": "chain"}, - "nodes": nodes, "edges": edges} + "nodes": nodes, "edges": edges, "layers": layers} def _member_graph(name: str) -> dict: @@ -232,6 +238,14 @@ def test_batch_accepts_stdin(self) -> None: "hops": 1}])) self.assertEqual(out[0], ["file:src/a.ts", "file:src/b.ts"]) + def test_layers_for_reports_only_matching_layers(self) -> None: + """Step 6 of the skill. Read from the JSON, so no sync involvement.""" + out = self.cli_json("layers-for", "--ids", "file:src/types.ts,file:src/d.ts") + self.assertEqual([l["name"] for l in out], ["Core", "Edge"]) + self.assertEqual(out[0]["matched"], ["file:src/types.ts"]) + + self.assertEqual(self.cli_json("layers-for", "--ids", "file:src/b.ts"), []) + def test_search_honours_an_explicit_limit(self) -> None: """A limit passed in a batch spec must reach the query. diff --git a/understand-anything-plugin/skills/understand-diff/SKILL.md b/understand-anything-plugin/skills/understand-diff/SKILL.md index 0ecb2ee4e..9687ed9d5 100644 --- a/understand-anything-plugin/skills/understand-diff/SKILL.md +++ b/understand-anything-plugin/skills/understand-diff/SKILL.md @@ -58,15 +58,22 @@ The knowledge graph JSON has this structure: one call instead: ```bash - python "/graph-query.py" batch --q '[ + "${UA_PYTHON:-python}" "/graph-query.py" batch --q '[ {"op": "nodes-for-file", "path": ""}, - {"op": "blast-radius", "path": "", "hops": 3} + {"op": "blast-radius", "path": "", "hops": 3}, + {"op": "layers-for", "ids": [""]} ]' ``` + Use `${UA_PYTHON:-python}` rather than a bare `python`. The embedded backend needs + Python 3.12 or newer, and a project's default `python` is often older, so a user with + a suitable interpreter elsewhere sets `UA_PYTHON` once and the checks below then agree + with the calls above. A FalkorDB server via `UA_FALKORDB_URL` works on any version. + `nodes-for-file` returns the file node plus every function and class defined in it, which is what step 4 assembles by grepping. `blast-radius` returns the affected node - ids from step 5. Put every changed file in one `batch` call rather than calling once + ids from step 5, and `layers-for` the architectural layers from step 6 — send it the + ids the first two calls returned, which means a second `batch` call once you have them. Put every changed file in one `batch` call rather than calling once per file — process startup dominates, the queries themselves are milliseconds. Pass `blast-radius` a **path**, not a basename. Basenames are not unique — this @@ -74,7 +81,7 @@ The knowledge graph JSON has this structure: name seeds from all of them at once, overstating the impact without saying so. A changed path is what git reports anyway. - To check availability, run `python "/graph-query.py" stats`. If it exits + To check availability, run `"${UA_PYTHON:-python}" "/graph-query.py" stats`. If it exits non-zero the backend is not configured — that is the normal default, so just continue with steps 4–6 as written. The backend is read-only, reads the same `knowledge-graph.json`, and writes nothing; the JSON remains the source of truth. diff --git a/understand-anything-plugin/skills/understand-diff/graph-query.py b/understand-anything-plugin/skills/understand-diff/graph-query.py index 1474e3656..7c81b53bb 100644 --- a/understand-anything-plugin/skills/understand-diff/graph-query.py +++ b/understand-anything-plugin/skills/understand-diff/graph-query.py @@ -29,6 +29,7 @@ python graph-query.py search --q auth python graph-query.py nodes-for-file --q src/types.ts python graph-query.py neighbors --id "file:src/a.ts" + python graph-query.py layers-for --ids "file:src/a.ts,file:src/b.ts" python graph-query.py blast-radius --path src/types.ts --hops 3 python graph-query.py calls-from --name registerAllParsers python graph-query.py calls-to --name validateGraph @@ -179,11 +180,16 @@ def __init__(self, anchor_dir: Path): try: from redislite.falkordb_client import FalkorDB as EmbeddedFalkorDB except ImportError: - raise SystemExit( - "No backend available. Either `pip install falkordblite` " - "(embedded, needs Python >= 3.12) or set UA_FALKORDB_URL to a " - "running FalkorDB instance." + running = ".".join(str(v) for v in sys.version_info[:3]) + hint = ( + f"this interpreter is Python {running}, and the embedded backend " + "needs 3.12 or newer -- point UA_PYTHON at a newer interpreter, or " + "set UA_FALKORDB_URL to use a server on any version" + if sys.version_info < (3, 12) else + "install it with `pip install falkordblite`, or set UA_FALKORDB_URL " + "to point at a FalkorDB server" ) + raise SystemExit(f"No backend available: {hint}.") anchor_dir.mkdir(parents=True, exist_ok=True) self._db = EmbeddedFalkorDB(str(anchor_dir / "falkordb.db")) @@ -516,6 +522,23 @@ def blast_radius(self, name: str | None = None, hops: int = 3, "WHERE t.name = $n RETURN DISTINCT d.id ORDER BY d.id", {"n": name}) return [r[0] for r in rows] + def layers_for(self, ids: list[str]) -> list[dict]: + """Which architectural layers contain any of these nodes (step 6). + + Read straight from the JSON rather than the graph: layers are a flat + lookup, not a traversal, and keeping them out of the graph keeps them out + of the incremental sync's set of invariants. + """ + wanted = set(ids) + out = [] + for layer in self.raw.get("layers", []): + hits = sorted(wanted.intersection(layer.get("nodeIds", []))) + if hits: + out.append({"id": layer.get("id"), "name": layer.get("name"), + "description": layer.get("description"), + "matched": hits}) + return out + def has_path(self, path: str) -> bool: clean = path.lstrip("/") return bool(self.rows( @@ -729,6 +752,7 @@ def stats(self) -> dict: "search": ("search", ("q", "limit")), "nodes-for-file": ("nodes_for_file", ("path",)), "neighbors": ("neighbors", ("id",)), + "layers-for": ("layers_for", ("ids",)), "calls-from": ("calls_from", ("name", "hops")), "calls-to": ("calls_to", ("name", "hops")), "path": ("path", ("from", "to", "hops")), @@ -789,8 +813,8 @@ def main() -> None: p = argparse.ArgumentParser(description=__doc__.split("\n")[0]) p.add_argument("command", choices=[ "stats", "search", "nodes-for-file", "neighbors", "blast-radius", - "calls-from", "calls-to", "path", "semantic", "semantic-traverse", - "affected-repos", "cypher", "batch", + "layers-for", "calls-from", "calls-to", "path", "semantic", + "semantic-traverse", "affected-repos", "cypher", "batch", ]) p.add_argument("--root", default=".", help="project root (default: cwd)") p.add_argument("--workspace", help="manifest listing several repos") @@ -798,6 +822,7 @@ def main() -> None: p.add_argument("--id", help="node id") p.add_argument("--name", help="node name (ambiguous for common basenames)") p.add_argument("--path", help="file path (preferred for blast-radius)") + p.add_argument("--ids", help="comma-separated node ids (layers-for)") p.add_argument("--repo", help="repo name (affected-repos)") p.add_argument("--hops", type=int, default=3) p.add_argument("--limit", type=int, default=25) @@ -830,6 +855,7 @@ def main() -> None: "q": args.q, "id": args.id, "name": args.name, "path": args.path or (args.q if args.command == "nodes-for-file" else None), "repo": args.repo, "hops": args.hops, "k": args.k, "limit": args.limit, + "ids": [i for i in (args.ids or "").split(",") if i] or None, "from": args.src, "to": args.dst, } From ebd5108e96b030885ff52817776afaee254c7faa Mon Sep 17 00:00:00 2001 From: Gal Shubeli Date: Mon, 17 Aug 2026 15:02:44 +0300 Subject: [PATCH 09/10] test(diff): add unit tests, wire both suites into CI, fix directory precedence The existing tests were all integration tests that shell out to the CLI, and CI runs Python tests from an explicit module list that did not include them. So this branch contributed no executed test coverage on CI at all: the module was never named, and even named, every test would skip because the optional backend is not installed on the runner. Adds 27 unit tests over the pure helpers -- type validation, label and relationship naming, digest stability, layer intersection, data directory resolution, embedding text assembly and node row shape. They need no database, so they actually run: the full Python suite is now 164 tests in 0.55s, of which 42 skip for want of a backend. Both new modules are added to the CI step. layers_for is now a thin wrapper over a module-level layers_containing so the intersection can be tested without constructing a graph. The unit tests immediately found a real inconsistency. find_graph_json checked .ua before .understand-anything, while resolveUaDirName in packages/core/src/persistence/index.ts prefers the legacy directory when it exists. On a project with both, this adapter and the rest of UA would have disagreed about which graph is authoritative. Precedence now matches core, with a comment pointing at the function it has to agree with. Tests: 69 in this directory -- 42 integration, 27 unit. --- .github/workflows/ci.yml | 2 +- tests/skill/diff/test_graph_query_helpers.py | 227 ++++++++++++++++++ .../skills/understand-diff/graph-query.py | 39 +-- 3 files changed, 251 insertions(+), 17 deletions(-) create mode 100644 tests/skill/diff/test_graph_query_helpers.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c69e8650e..ab9263ce6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -61,4 +61,4 @@ jobs: run: pnpm test - name: Test Python skill helpers - run: python -m unittest tests.skill.understand.test_merge_batch_graphs tests.skill.understand.test_merge_subdomain_graphs tests.skill.knowledge.test_parse_knowledge_base -v + run: python -m unittest tests.skill.understand.test_merge_batch_graphs tests.skill.understand.test_merge_subdomain_graphs tests.skill.knowledge.test_parse_knowledge_base tests.skill.diff.test_graph_query_helpers tests.skill.diff.test_graph_query -v diff --git a/tests/skill/diff/test_graph_query_helpers.py b/tests/skill/diff/test_graph_query_helpers.py new file mode 100644 index 000000000..915a8dc45 --- /dev/null +++ b/tests/skill/diff/test_graph_query_helpers.py @@ -0,0 +1,227 @@ +#!/usr/bin/env python3 +""" +test_graph_query_helpers.py — Unit tests for graph-query.py's pure helpers. + +Run from the repo root: + python -m unittest tests.skill.diff.test_graph_query_helpers -v + +These need no database and no optional dependency, so unlike the integration +tests in test_graph_query.py they always execute. The module is importable +without a backend because every driver import sits inside the function that +uses it. +""" + +from __future__ import annotations + +import importlib.util +import json +import sys +import tempfile +import unittest +from pathlib import Path +from typing import Any + +# ── Module loader ───────────────────────────────────────────────────────── +# `graph-query.py` has a hyphen in its name, so we cannot `import` it directly. + +_HERE = Path(__file__).resolve().parent +_REPO_ROOT = _HERE.parent.parent.parent +_MODULE_PATH = ( + _REPO_ROOT + / "understand-anything-plugin" + / "skills" + / "understand-diff" + / "graph-query.py" +) + + +def _load_module() -> Any: + spec = importlib.util.spec_from_file_location("graph_query", _MODULE_PATH) + if spec is None or spec.loader is None: + raise RuntimeError(f"Could not load module from {_MODULE_PATH}") + module = importlib.util.module_from_spec(spec) + sys.modules["graph_query"] = module + spec.loader.exec_module(module) + return module + + +gq = _load_module() + + +class SafeTypeTests(unittest.TestCase): + """Types become Cypher labels and relationship types, which cannot be + parameterised, so they are validated rather than escaped.""" + + def test_plain_identifiers_pass_through(self) -> None: + for value in ("file", "function", "defines_schema", "Component", "_x9"): + self.assertEqual(gq.safe_type(value, "node"), value) + + def test_injection_attempts_are_refused(self) -> None: + hostile = [ + "file) MATCH (n) DETACH DELETE n //", + "imports] () //", + "a b", + "a-b", + "a.b", + "a`b", + "", + "1leading_digit", + ] + for value in hostile: + with self.subTest(value=value): + with self.assertRaises(SystemExit): + gq.safe_type(value, "node") + + def test_non_strings_are_refused(self) -> None: + for value in (None, 7, ["file"]): + with self.subTest(value=value): + with self.assertRaises(SystemExit): + gq.safe_type(value, "node") + + def test_the_message_names_the_kind(self) -> None: + with self.assertRaises(SystemExit) as caught: + gq.safe_type("a b", "edge") + self.assertIn("edge", str(caught.exception)) + + +class LabelTests(unittest.TestCase): + def test_snake_case_becomes_pascal_case(self) -> None: + self.assertEqual(gq.label_for("file"), "File") + self.assertEqual(gq.label_for("component_set"), "ComponentSet") + self.assertEqual(gq.label_for("defines_schema"), "DefinesSchema") + + def test_relationship_types_are_upper_case(self) -> None: + self.assertEqual(gq.rel_for("imports"), "IMPORTS") + self.assertEqual(gq.rel_for("depends_on"), "DEPENDS_ON") + + def test_labels_are_validated_too(self) -> None: + with self.assertRaises(SystemExit): + gq.label_for("file) //") + + +class DigestTests(unittest.TestCase): + """Digests decide what gets resynced, so stability is the whole point.""" + + def test_same_payload_gives_same_digest(self) -> None: + self.assertEqual(gq.digest({"a": 1, "b": [2, 3]}), + gq.digest({"a": 1, "b": [2, 3]})) + + def test_key_order_does_not_matter(self) -> None: + self.assertEqual(gq.digest({"a": 1, "b": 2}), gq.digest({"b": 2, "a": 1})) + + def test_list_order_does_matter(self) -> None: + """Callers sort before digesting; the digest itself must stay faithful.""" + self.assertNotEqual(gq.digest([1, 2]), gq.digest([2, 1])) + + def test_a_changed_value_changes_the_digest(self) -> None: + self.assertNotEqual(gq.digest({"summary": "x"}), gq.digest({"summary": "y"})) + + def test_digests_are_short_and_hex(self) -> None: + value = gq.digest({"a": 1}) + self.assertEqual(len(value), 16) + int(value, 16) # raises if not hex + + +class LayersContainingTests(unittest.TestCase): + LAYERS = [ + {"id": "core", "name": "Core", "description": "shared", + "nodeIds": ["file:a.ts", "file:b.ts"]}, + {"id": "ui", "name": "UI", "description": "views", + "nodeIds": ["file:c.tsx"]}, + ] + + def test_only_layers_with_a_match_are_returned(self) -> None: + out = gq.layers_containing(self.LAYERS, ["file:c.tsx"]) + self.assertEqual([l["name"] for l in out], ["UI"]) + + def test_matches_are_named_and_sorted(self) -> None: + out = gq.layers_containing(self.LAYERS, ["file:b.ts", "file:a.ts"]) + self.assertEqual(out[0]["matched"], ["file:a.ts", "file:b.ts"]) + + def test_no_overlap_returns_nothing(self) -> None: + self.assertEqual(gq.layers_containing(self.LAYERS, ["file:zz.ts"]), []) + + def test_missing_or_empty_layers_are_tolerated(self) -> None: + self.assertEqual(gq.layers_containing([], ["file:a.ts"]), []) + self.assertEqual(gq.layers_containing(None, ["file:a.ts"]), []) + self.assertEqual(gq.layers_containing([{"id": "x"}], ["file:a.ts"]), []) + + +class FindGraphJsonTests(unittest.TestCase): + def setUp(self) -> None: + self._tmp = tempfile.TemporaryDirectory() + self.root = Path(self._tmp.name) + self.addCleanup(self._tmp.cleanup) + + def _write(self, directory: str) -> Path: + target = self.root / directory + target.mkdir(parents=True) + path = target / "knowledge-graph.json" + path.write_text("{}") + return path + + def test_finds_the_current_directory_name(self) -> None: + expected = self._write(".ua") + self.assertEqual(gq.find_graph_json(self.root), expected) + + def test_legacy_directory_wins_when_both_exist(self) -> None: + """Projects analysed before the rename keep reading their old directory.""" + self._write(".ua") + legacy = self._write(".understand-anything") + self.assertEqual(gq.find_graph_json(self.root), legacy) + + def test_absence_points_at_understand(self) -> None: + with self.assertRaises(SystemExit) as caught: + gq.find_graph_json(self.root) + self.assertIn("/understand", str(caught.exception)) + + +class EmbedTextTests(unittest.TestCase): + def test_name_summary_and_tags_are_combined(self) -> None: + text = gq.Embedder.text_for( + {"name": "isStale", "summary": "checks freshness", "tags": ["git", "cache"]}) + for part in ("isStale", "checks freshness", "git", "cache"): + self.assertIn(part, text) + + def test_long_summaries_are_truncated(self) -> None: + text = gq.Embedder.text_for({"name": "n", "summary": "x" * 500, "tags": []}) + self.assertLess(len(text), 300) + + def test_missing_fields_do_not_raise(self) -> None: + self.assertEqual(gq.Embedder.text_for({}), "") + self.assertEqual(gq.Embedder.text_for({"name": "n", "summary": None}), "n") + + +class RowShapeTests(unittest.TestCase): + """Every node row must carry the same keys, or the batched UNWIND writes + would set different properties per row.""" + + def test_line_range_is_split_into_two_columns(self) -> None: + row = gq.RepoGraph._row_for( + {"id": "x", "type": "file", "lineRange": [3, 9]}, "src/x.ts") + self.assertEqual((row["lineStart"], row["lineEnd"]), (3, 9)) + + def test_a_missing_line_range_becomes_sentinels(self) -> None: + row = gq.RepoGraph._row_for({"id": "x", "type": "file"}, "src/x.ts") + self.assertEqual((row["lineStart"], row["lineEnd"]), (-1, -1)) + + def test_a_malformed_line_range_becomes_sentinels(self) -> None: + row = gq.RepoGraph._row_for( + {"id": "x", "type": "file", "lineRange": [5]}, "src/x.ts") + self.assertEqual((row["lineStart"], row["lineEnd"]), (-1, -1)) + + def test_rows_always_have_the_same_keys(self) -> None: + sparse = gq.RepoGraph._row_for({"id": "a", "type": "file"}, "k") + full = gq.RepoGraph._row_for( + {"id": "b", "type": "function", "name": "f", "filePath": "p", + "summary": "s", "tags": ["t"], "complexity": "complex", + "lineRange": [1, 2]}, "k") + self.assertEqual(sorted(sparse), sorted(full)) + + def test_the_grouping_key_is_carried(self) -> None: + row = gq.RepoGraph._row_for({"id": "x", "type": "file"}, "src/x.ts") + self.assertEqual(row["__key"], "src/x.ts") + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/understand-anything-plugin/skills/understand-diff/graph-query.py b/understand-anything-plugin/skills/understand-diff/graph-query.py index 7c81b53bb..56963c18c 100644 --- a/understand-anything-plugin/skills/understand-diff/graph-query.py +++ b/understand-anything-plugin/skills/understand-diff/graph-query.py @@ -57,7 +57,11 @@ from collections import defaultdict from pathlib import Path -UA_DIRS = (".ua", ".understand-anything") +# Legacy first, matching resolveUaDirName in packages/core/src/persistence/index.ts: +# a project analysed before the `.ua` rename keeps reading its old directory, so +# checking `.ua` first would make this adapter disagree with the rest of UA about +# which graph is authoritative. +UA_DIRS = (".understand-anything", ".ua") GRAPH_FILE = "knowledge-graph.json" FILE_STAMP = "__ua_file__" INDEX_GRAPH = "__ua_workspace__" @@ -109,6 +113,22 @@ def digest(payload) -> str: return hashlib.sha256(json.dumps(payload, sort_keys=True).encode()).hexdigest()[:16] +def layers_containing(layers: list[dict], ids: list[str]) -> list[dict]: + """Layers holding any of these node ids, with the matches named. + + A flat intersection rather than a traversal, so it stays out of the graph and + out of the incremental sync's invariants. + """ + wanted = set(ids) + out = [] + for layer in layers or []: + hits = sorted(wanted.intersection(layer.get("nodeIds", []))) + if hits: + out.append({"id": layer.get("id"), "name": layer.get("name"), + "description": layer.get("description"), "matched": hits}) + return out + + # --------------------------------------------------------------------------- # # embeddings (optional) # --------------------------------------------------------------------------- # @@ -523,21 +543,8 @@ def blast_radius(self, name: str | None = None, hops: int = 3, return [r[0] for r in rows] def layers_for(self, ids: list[str]) -> list[dict]: - """Which architectural layers contain any of these nodes (step 6). - - Read straight from the JSON rather than the graph: layers are a flat - lookup, not a traversal, and keeping them out of the graph keeps them out - of the incremental sync's set of invariants. - """ - wanted = set(ids) - out = [] - for layer in self.raw.get("layers", []): - hits = sorted(wanted.intersection(layer.get("nodeIds", []))) - if hits: - out.append({"id": layer.get("id"), "name": layer.get("name"), - "description": layer.get("description"), - "matched": hits}) - return out + """Which architectural layers contain any of these nodes (step 6).""" + return layers_containing(self.raw.get("layers", []), ids) def has_path(self, path: str) -> bool: clean = path.lstrip("/") From 117a7147e9d63ebb94e9ee0a010a1909223ae33c Mon Sep 17 00:00:00 2001 From: Gal Shubeli Date: Mon, 17 Aug 2026 17:44:19 +0300 Subject: [PATCH 10/10] feat(diff): hand over to a capable interpreter instead of giving up The embedded backend needs Python 3.12 or newer, and a project's default python is frequently older -- in which case the availability check failed, the skill fell back to grep, and the feature was inert. Documenting an environment variable put the burden on the user for a problem the script can usually solve: a newer interpreter is normally installed alongside the old one. The script now looks for one that can actually import the driver and hands over to it, announcing the switch on stderr. Verified from a 3.10 interpreter: it finds the capable one on PATH and the fast path works with no configuration at all. UA_PYTHON still wins when the right interpreter is somewhere unusual, a configured server short-circuits the whole check since no local driver is needed, and a marker in the environment means the handover happens at most once. Candidates are probed by importing the driver rather than by trusting a version number, because a 3.12 interpreter without the package installed is no more use than a 3.10 one. Unit tests cover the guards. The probing branch itself is not unit tested because it ends in os.execve, which would replace the test runner. --- tests/skill/diff/test_graph_query_helpers.py | 39 ++++++++++++++++ .../skills/understand-diff/SKILL.md | 8 ++-- .../skills/understand-diff/graph-query.py | 44 +++++++++++++++++++ 3 files changed, 87 insertions(+), 4 deletions(-) diff --git a/tests/skill/diff/test_graph_query_helpers.py b/tests/skill/diff/test_graph_query_helpers.py index 915a8dc45..f38972d60 100644 --- a/tests/skill/diff/test_graph_query_helpers.py +++ b/tests/skill/diff/test_graph_query_helpers.py @@ -15,6 +15,7 @@ import importlib.util import json +import os import sys import tempfile import unittest @@ -84,6 +85,44 @@ def test_the_message_names_the_kind(self) -> None: self.assertIn("edge", str(caught.exception)) +class ReexecGuardTests(unittest.TestCase): + """The handover must not fire when it would be wrong or looping. + + Only the early-return guards are exercised here: the probing branch ends in + os.execve, which would replace the test runner itself. + """ + + def setUp(self) -> None: + self._saved = {k: os.environ.get(k) for k in + (gq.REEXEC_FLAG, "UA_FALKORDB_URL", "UA_PYTHON")} + self.addCleanup(self._restore) + for key in self._saved: + os.environ.pop(key, None) + + def _restore(self) -> None: + for key, value in self._saved.items(): + if value is None: + os.environ.pop(key, None) + else: + os.environ[key] = value + + def test_does_nothing_once_already_handed_over(self) -> None: + os.environ[gq.REEXEC_FLAG] = "1" + self.assertIsNone(gq.reexec_under_capable_interpreter()) + + def test_does_nothing_when_a_server_is_configured(self) -> None: + """A server needs no local driver, so the interpreter is irrelevant.""" + os.environ["UA_FALKORDB_URL"] = "redis://localhost:6379" + self.assertIsNone(gq.reexec_under_capable_interpreter()) + + def test_candidates_are_newest_first(self) -> None: + self.assertEqual(list(gq.INTERPRETER_CANDIDATES), + sorted(gq.INTERPRETER_CANDIDATES, reverse=True)) + for name in gq.INTERPRETER_CANDIDATES: + major, minor = name.removeprefix("python").split(".") + self.assertGreaterEqual((int(major), int(minor)), (3, 12)) + + class LabelTests(unittest.TestCase): def test_snake_case_becomes_pascal_case(self) -> None: self.assertEqual(gq.label_for("file"), "File") diff --git a/understand-anything-plugin/skills/understand-diff/SKILL.md b/understand-anything-plugin/skills/understand-diff/SKILL.md index 9687ed9d5..bf5662de2 100644 --- a/understand-anything-plugin/skills/understand-diff/SKILL.md +++ b/understand-anything-plugin/skills/understand-diff/SKILL.md @@ -65,10 +65,10 @@ The knowledge graph JSON has this structure: ]' ``` - Use `${UA_PYTHON:-python}` rather than a bare `python`. The embedded backend needs - Python 3.12 or newer, and a project's default `python` is often older, so a user with - a suitable interpreter elsewhere sets `UA_PYTHON` once and the checks below then agree - with the calls above. A FalkorDB server via `UA_FALKORDB_URL` works on any version. + The embedded backend needs Python 3.12 or newer. If the `python` you invoke is older, + the script looks for a newer one on `PATH` and hands over to it by itself, so this + usually needs no setup. `UA_PYTHON` overrides that choice when the right interpreter + is somewhere unusual, and a FalkorDB server via `UA_FALKORDB_URL` works on any version. `nodes-for-file` returns the file node plus every function and class defined in it, which is what step 4 assembles by grepping. `blast-radius` returns the affected node diff --git a/understand-anything-plugin/skills/understand-diff/graph-query.py b/understand-anything-plugin/skills/understand-diff/graph-query.py index 56963c18c..882bff988 100644 --- a/understand-anything-plugin/skills/understand-diff/graph-query.py +++ b/understand-anything-plugin/skills/understand-diff/graph-query.py @@ -52,6 +52,8 @@ import json import os import re +import shutil +import subprocess import sys import urllib.request from collections import defaultdict @@ -73,6 +75,47 @@ EMBED_TEXT_CHARS = 150 # summaries are long; the head of one is enough to rank on +REEXEC_FLAG = "UA_GRAPH_QUERY_REEXEC" +INTERPRETER_CANDIDATES = ("python3.14", "python3.13", "python3.12") + + +def reexec_under_capable_interpreter() -> None: + """Re-run under an interpreter that can actually load the backend. + + The embedded backend needs Python 3.12 or newer, but a project's default + `python` is frequently older, and a newer one is usually installed alongside + it. Rather than making that the user's problem, look for an interpreter that + can import the driver and hand over to it. Silent when nothing needs doing. + """ + if os.environ.get(REEXEC_FLAG): + return # already handed over once + if os.environ.get("UA_FALKORDB_URL"): + return # a server needs no local driver + try: + import redislite.falkordb_client # noqa: F401 + return # this interpreter is fine + except ImportError: + pass + + probe = "import redislite.falkordb_client" + candidates = [os.environ["UA_PYTHON"]] if os.environ.get("UA_PYTHON") else [] + candidates += [c for c in (shutil.which(name) for name in INTERPRETER_CANDIDATES) if c] + + for candidate in candidates: + try: + ok = subprocess.run([candidate, "-c", probe], capture_output=True, + timeout=30).returncode == 0 + except (OSError, subprocess.SubprocessError): + continue + if ok: + print(f"note: handing over to {candidate}, which can load the backend", + file=sys.stderr) + os.execve(candidate, [candidate, os.path.abspath(__file__), *sys.argv[1:]], + {**os.environ, REEXEC_FLAG: "1"}) + + # Nothing suitable found; Backend will explain what to do. + + def find_graph_json(project_root: Path) -> Path: """Locate the graph, honouring the legacy .understand-anything/ directory.""" for d in UA_DIRS: @@ -817,6 +860,7 @@ def single_stats(repo: RepoGraph) -> dict: def main() -> None: + reexec_under_capable_interpreter() p = argparse.ArgumentParser(description=__doc__.split("\n")[0]) p.add_argument("command", choices=[ "stats", "search", "nodes-for-file", "neighbors", "blast-radius",