From de07fb12fc614f97af80a63dbcda9a0056b25d71 Mon Sep 17 00:00:00 2001 From: MattThomas-fastino Date: Mon, 26 Oct 2026 07:54:12 +0100 Subject: [PATCH] fix(cmind): handle list meta.path in GraphQueryEngine search enrich_from_code stores a list when a feature group or area spans more than one file. Search assumed a string and crashed on .lower(). Co-authored-by: Cursor --- CoderMind/scripts/rpg/graph_query.py | 11 ++- CoderMind/tests/test_graph_query_meta_path.py | 98 +++++++++++++++++++ 2 files changed, 105 insertions(+), 4 deletions(-) create mode 100644 CoderMind/tests/test_graph_query_meta_path.py diff --git a/CoderMind/scripts/rpg/graph_query.py b/CoderMind/scripts/rpg/graph_query.py index 734d07f..ea63db9 100644 --- a/CoderMind/scripts/rpg/graph_query.py +++ b/CoderMind/scripts/rpg/graph_query.py @@ -19,7 +19,7 @@ import logging from collections import defaultdict from pathlib import Path -from typing import Any, Dict, List, Optional, Tuple +from typing import Any, Dict, List, Optional, Tuple, Union from common.rpg_io import safe_load_rpg @@ -135,14 +135,17 @@ def from_files(cls, rpg_path: str, dep_graph_path: str = "") -> "GraphQueryEngin # Helpers # ------------------------------------------------------------------ - def _normalize_path(self, meta_path: str) -> str: + def _normalize_path(self, meta_path: Union[str, List[str]]) -> Union[str, List[str]]: """Strip redundant code_dir prefix from RPG meta.path. For legacy data where ``_dep_graph_code_dir`` was ``"repo"``, this converts e.g. ``repo/routes/auth.py`` to ``routes/auth.py`` so it aligns with dep_graph node IDs. In the unified workspace==repo layout the prefix is empty and this is a no-op. + A list path, as stored for a multi-file group, is normalised per entry. """ + if isinstance(meta_path, list): + return [self._normalize_path(entry) for entry in meta_path] if self._code_dir_prefix and meta_path.startswith(self._code_dir_prefix): return meta_path[len(self._code_dir_prefix):] return meta_path @@ -306,8 +309,8 @@ def _search_rpg_tree(self, query: str, top_k: int) -> List[Dict[str, Any]]: elif query in name.lower(): score = 75 else: - file_part = meta_path.split(":")[0] if ":" in meta_path else meta_path - if query in file_part.lower(): + paths = meta_path if isinstance(meta_path, list) else [meta_path] + if any(query in path.split(":")[0].lower() for path in paths): score = 60 elif query in nid.lower(): score = 55 diff --git a/CoderMind/tests/test_graph_query_meta_path.py b/CoderMind/tests/test_graph_query_meta_path.py new file mode 100644 index 0000000..488dbcc --- /dev/null +++ b/CoderMind/tests/test_graph_query_meta_path.py @@ -0,0 +1,98 @@ +"""Search still works when a feature node's ``meta.path`` is a list. + +``enrich_from_code`` stores a list when a group or area spans more than one +file or directory. Search and path normalisation must accept that shape. +""" + +import sys +from pathlib import Path + +_PROJECT_ROOT = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(_PROJECT_ROOT)) +sys.path.insert(0, str(_PROJECT_ROOT / "scripts")) + +from rpg.graph_query import GraphQueryEngine + +_LIST_PATH = ["services/inference/router.py", "api/inference/routes.py"] + + +def _engine(path, code_dir: str = "") -> GraphQueryEngine: + rpg = { + "_dep_graph_code_dir": code_dir, + "root": { + "id": "repo", + "name": "brain", + "node_type": "repo", + "meta": {"path": "."}, + "children": [ + { + "id": "area", + "name": "Inference Serving", + "node_type": "functional_area", + "meta": {"path": path, "description": "routes a request"}, + "children": [], + } + ], + }, + } + dep = { + "nodes": { + "services/inference/router.py:route": { + "name": "route", + "type": "function", + } + }, + "edges": [], + } + return GraphQueryEngine(rpg, dep) + + +def _area(results: list[dict]) -> dict: + matches = [item for item in results if item.get("id") == "area"] + assert matches, results + return matches[0] + + +def test_feature_search_matches_a_later_list_entry() -> None: + """A query in the second path scores the path match, not a crash.""" + engine = _engine(_LIST_PATH) + for scope in ("feature", "all"): + hit = _area(engine.search("routes", scope=scope)) + assert hit["score"] == 60 + assert hit["path"] == _LIST_PATH + + +def test_string_path_search_is_unchanged() -> None: + """A string path still matches and comes back as a string.""" + path = "services/inference/router.py" + hit = _area(_engine(path).search("router", scope="feature")) + assert hit["score"] == 60 + assert hit["path"] == path + + +def test_normalize_path_strips_prefix_from_each_list_entry() -> None: + """A legacy code-dir prefix is removed from every path in a list.""" + engine = _engine( + ["repo/services/inference", "repo/api/inference"], + code_dir="repo", + ) + assert engine._normalize_path(["repo/services/inference", "other/api"]) == [ + "services/inference", + "other/api", + ] + assert engine._normalize_path("repo/services/inference/router.py") == ( + "services/inference/router.py" + ) + + +def test_tree_and_detail_keep_list_paths() -> None: + """Browsing and detail return the same list search does.""" + engine = _engine( + ["repo/services/inference", "repo/api/inference"], + code_dir="repo", + ) + expected = ["services/inference", "api/inference"] + tree = engine.list_tree(max_depth=2) + area = tree["children"][0] + assert area["path"] == expected + assert engine.get_node_detail("area")["code_path"] == expected