build

@dataclass
class CGEdge:
    src: str            # CGEntity.id
    dst: str            # CGEntity.id
    type: str           # imports|calls|owns|tests

class CodeGraph:
    def __init__(self, repo_root: str, ignore: Optional[List[str]] = None):
        self.root = os.path.abspath(repo_root)
        self.ignore_rules = [s for s in (ignore or []) if s]
        self.entities_by_id: Dict[str, CGEntity] = {}
        self.edges_list: List[CGEdge] = []
        self._file_hash: Dict[str, str] = {}
        self._id_by_module: Dict[str, str] = {}
        self._ids_by_file: Dict[str, List[str]] = {}
        self._index_identifiers: Dict[str, List[str]] = {}

    # Build
    def build(self) -> "CodeGraph":
        py_files = self._discover_py_files(self.root, self.ignore_rules)
        for abs_fp in py_files:
            mod = self._module_name_for(abs_fp)
            mid = f"py:{mod}"
            self._id_by_module[mod] = mid
            ent = CGEntity(
                id=mid, kind="module", name=mod, file=abs_fp, owner=None,
                start_line=1, end_line=self._safe_count_lines(abs_fp),
            )
            self.entities_by_id[mid] = ent
            self._ids_by_file.setdefault(abs_fp, []).append(mid)
            self._index_identifiers.setdefault(mod.lower(), []).append(mid)
            # Parse AST for defs/imports/calls
            try:
                with open(abs_fp, "r", encoding="utf-8", errors="ignore") as fh:
                    src = fh.read()
                tree = ast.parse(src)
            except Exception:
                tree = None
            if tree is None:
                continue
            # functions/classes
            for node in ast.walk(tree):
                if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
                    name = getattr(node, "name", "")
                    a = int(getattr(node, "lineno", 1))
                    b = int(getattr(node, "end_lineno", a))
                    fid = f"py:{mod}.{name}"
                    self.entities_by_id[fid] = CGEntity(
                        id=fid, kind="function", name=name, file=abs_fp, owner=mid,
                        start_line=a, end_line=b,
                    )
                    self._ids_by_file.setdefault(abs_fp, []).append(fid)
                    self.edges_list.append(CGEdge(src=mid, dst=fid, type="owns"))
                    self._index_identifiers.setdefault(name.lower(), []).append(fid)
                elif isinstance(node, ast.ClassDef):
                    name = getattr(node, "name", "")
                    a = int(getattr(node, "lineno", 1))
                    b = int(getattr(node, "end_lineno", a))
                    cid = f"py:{mod}.{name}"
                    self.entities_by_id[cid] = CGEntity(
                        id=cid, kind="class", name=name, file=abs_fp, owner=mid,
                        start_line=a, end_line=b,
                    )
                    self._ids_by_file.setdefault(abs_fp, []).append(cid)
                    self.edges_list.append(CGEdge(src=mid, dst=cid, type="owns"))
                    self._index_identifiers.setdefault(name.lower(), []).append(cid)
            # imports (module-level)
            try:
                for node in ast.walk(tree):
                    if isinstance(node, ast.Import):
                        for alias in node.names:
                            im = alias.name
                            if not im:
                                continue
                            # record import edge to module-name id if present later; store raw as meta by name
                            tgt_mod = im
                            tid = f"py:{tgt_mod}"
                            self.edges_list.append(CGEdge(src=mid, dst=tid, type="imports"))
                    elif isinstance(node, ast.ImportFrom):
                        im = node.module or ""
                        if not im:
                            continue
                        tgt_mod = im
                        tid = f"py:{tgt_mod}"
                        self.edges_list.append(CGEdge(src=mid, dst=tid, type="imports"))
            except Exception:
                pass
            # calls (best-effort): record identifiers used in Call nodes
            try:
                for node in ast.walk(tree):
                    if isinstance(node, ast.Call):
                        fn = getattr(node, "func", None)
                        name = None
                        if isinstance(fn, ast.Attribute):
                            name = getattr(fn.attr, "lower", None)
                            name = fn.attr if fn and hasattr(fn, "attr") else None
                        elif isinstance(fn, ast.Name):
                            name = fn.id
                        if name:
                            lid = name.lower()
                            for cand in self._index_identifiers.get(lid, []):
                                self.edges_list.append(CGEdge(src=mid, dst=cand, type="calls"))
            except Exception:
                pass
            # tests tag
            base = os.path.basename(abs_fp)
            if base.startswith("test_") or base.endswith("_test.py"):
                self.entities_by_id[mid].kind = "test_module"
        # finalize file hashes
        self._precompute_hashes(py_files)
        return self

    # Public accessors
    def entities(self) -> Iterable[CGEntity]:
        return self.entities_by_id.values()

    def edges(self) -> Iterable[CGEdge]:
        # Filter edges whose endpoints are known (post totality)
        known = set(self.entities_by_id.keys())
        for e in self.edges_list:
            if (e.src in known) and (e.dst in known):
                yield e

    def file_hash(self, abs_path: str) -> str:
        return self._file_hash.get(abs_path) or ""

    def ids_for_file(self, abs_path: str) -> List[str]:
        return list(self._ids_by_file.get(abs_path, []))

    def find_identifier_ids(self, token: str) -> List[str]:
        return list(self._index_identifiers.get(token.lower(), []))


entities

                        if isinstance(fn, ast.Attribute):
                            name = getattr(fn.attr, "lower", None)
                            name = fn.attr if fn and hasattr(fn, "attr") else None
                        elif isinstance(fn, ast.Name):
                            name = fn.id
                        if name:
                            lid = name.lower()
                            for cand in self._index_identifiers.get(lid, []):
                                self.edges_list.append(CGEdge(src=mid, dst=cand, type="calls"))
            except Exception:
                pass
            # tests tag
            base = os.path.basename(abs_fp)
            if base.startswith("test_") or base.endswith("_test.py"):
                self.entities_by_id[mid].kind = "test_module"
        # finalize file hashes
        self._precompute_hashes(py_files)
        return self

    # Public accessors
    def entities(self) -> Iterable[CGEntity]:
        return self.entities_by_id.values()

    def edges(self) -> Iterable[CGEdge]:
        # Filter edges whose endpoints are known (post totality)
        known = set(self.entities_by_id.keys())
        for e in self.edges_list:
            if (e.src in known) and (e.dst in known):
                yield e

    def file_hash(self, abs_path: str) -> str:
        return self._file_hash.get(abs_path) or ""

    def ids_for_file(self, abs_path: str) -> List[str]:
        return list(self._ids_by_file.get(abs_path, []))

    def find_identifier_ids(self, token: str) -> List[str]:
        return list(self._index_identifiers.get(token.lower(), []))

    # Helpers
    def _discover_py_files(self, root: str, ignore: List[str]) -> List[str]:
        out: List[str] = []


edges

                        elif isinstance(fn, ast.Name):
                            name = fn.id
                        if name:
                            lid = name.lower()
                            for cand in self._index_identifiers.get(lid, []):
                                self.edges_list.append(CGEdge(src=mid, dst=cand, type="calls"))
            except Exception:
                pass
            # tests tag
            base = os.path.basename(abs_fp)
            if base.startswith("test_") or base.endswith("_test.py"):
                self.entities_by_id[mid].kind = "test_module"
        # finalize file hashes
        self._precompute_hashes(py_files)
        return self

    # Public accessors
    def entities(self) -> Iterable[CGEntity]:
        return self.entities_by_id.values()

    def edges(self) -> Iterable[CGEdge]:
        # Filter edges whose endpoints are known (post totality)
        known = set(self.entities_by_id.keys())
        for e in self.edges_list:
            if (e.src in known) and (e.dst in known):
                yield e

    def file_hash(self, abs_path: str) -> str:
        return self._file_hash.get(abs_path) or ""

    def ids_for_file(self, abs_path: str) -> List[str]:
        return list(self._ids_by_file.get(abs_path, []))

    def find_identifier_ids(self, token: str) -> List[str]:
        return list(self._index_identifiers.get(token.lower(), []))

    # Helpers
    def _discover_py_files(self, root: str, ignore: List[str]) -> List[str]:
        out: List[str] = []
        for dirpath, dirnames, filenames in os.walk(root):
            # naive ignore: drop segments that contain any ignore pattern
            if any(ig in dirpath for ig in ignore):
                continue
            for fn in filenames:
                if not fn.endswith(".py"):
                    continue


                pass
            # tests tag
            base = os.path.basename(abs_fp)
            if base.startswith("test_") or base.endswith("_test.py"):
                self.entities_by_id[mid].kind = "test_module"
        # finalize file hashes
        self._precompute_hashes(py_files)
        return self

    # Public accessors
    def entities(self) -> Iterable[CGEntity]:
        return self.entities_by_id.values()

    def edges(self) -> Iterable[CGEdge]:
        # Filter edges whose endpoints are known (post totality)
        known = set(self.entities_by_id.keys())
        for e in self.edges_list:
            if (e.src in known) and (e.dst in known):
                yield e

    def file_hash(self, abs_path: str) -> str:
        return self._file_hash.get(abs_path) or ""

    def ids_for_file(self, abs_path: str) -> List[str]:
        return list(self._ids_by_file.get(abs_path, []))

    def find_identifier_ids(self, token: str) -> List[str]:
        return list(self._index_identifiers.get(token.lower(), []))

    # Helpers
    def _discover_py_files(self, root: str, ignore: List[str]) -> List[str]:
        out: List[str] = []
        for dirpath, dirnames, filenames in os.walk(root):
            # naive ignore: drop segments that contain any ignore pattern
            if any(ig in dirpath for ig in ignore):
                continue
            for fn in filenames:
                if not fn.endswith(".py"):
                    continue
                ap = os.path.abspath(os.path.join(dirpath, fn))
                out.append(ap)
        return out


            if base.startswith("test_") or base.endswith("_test.py"):
                self.entities_by_id[mid].kind = "test_module"
        # finalize file hashes
        self._precompute_hashes(py_files)
        return self

    # Public accessors
    def entities(self) -> Iterable[CGEntity]:
        return self.entities_by_id.values()

    def edges(self) -> Iterable[CGEdge]:
        # Filter edges whose endpoints are known (post totality)
        known = set(self.entities_by_id.keys())
        for e in self.edges_list:
            if (e.src in known) and (e.dst in known):
                yield e

    def file_hash(self, abs_path: str) -> str:
        return self._file_hash.get(abs_path) or ""

    def ids_for_file(self, abs_path: str) -> List[str]:
        return list(self._ids_by_file.get(abs_path, []))

    def find_identifier_ids(self, token: str) -> List[str]:
        return list(self._index_identifiers.get(token.lower(), []))

    # Helpers
    def _discover_py_files(self, root: str, ignore: List[str]) -> List[str]:
        out: List[str] = []
        for dirpath, dirnames, filenames in os.walk(root):
            # naive ignore: drop segments that contain any ignore pattern
            if any(ig in dirpath for ig in ignore):
                continue
            for fn in filenames:
                if not fn.endswith(".py"):
                    continue
                ap = os.path.abspath(os.path.join(dirpath, fn))
                out.append(ap)
        return out

    def _module_name_for(self, abs_file: str) -> str:
        # repo-relative without extension, path with dots


        self._precompute_hashes(py_files)
        return self

    # Public accessors
    def entities(self) -> Iterable[CGEntity]:
        return self.entities_by_id.values()

    def edges(self) -> Iterable[CGEdge]:
        # Filter edges whose endpoints are known (post totality)
        known = set(self.entities_by_id.keys())
        for e in self.edges_list:
            if (e.src in known) and (e.dst in known):
                yield e

    def file_hash(self, abs_path: str) -> str:
        return self._file_hash.get(abs_path) or ""

    def ids_for_file(self, abs_path: str) -> List[str]:
        return list(self._ids_by_file.get(abs_path, []))

    def find_identifier_ids(self, token: str) -> List[str]:
        return list(self._index_identifiers.get(token.lower(), []))

    # Helpers
    def _discover_py_files(self, root: str, ignore: List[str]) -> List[str]:
        out: List[str] = []
        for dirpath, dirnames, filenames in os.walk(root):
            # naive ignore: drop segments that contain any ignore pattern
            if any(ig in dirpath for ig in ignore):
                continue
            for fn in filenames:
                if not fn.endswith(".py"):
                    continue
                ap = os.path.abspath(os.path.join(dirpath, fn))
                out.append(ap)
        return out

    def _module_name_for(self, abs_file: str) -> str:
        # repo-relative without extension, path with dots
        rel = os.path.relpath(abs_file, self.root).replace("\\", "/")
        if rel.endswith(".py"):
            rel = rel[:-3]


    def entities(self) -> Iterable[CGEntity]:
        return self.entities_by_id.values()

    def edges(self) -> Iterable[CGEdge]:
        # Filter edges whose endpoints are known (post totality)
        known = set(self.entities_by_id.keys())
        for e in self.edges_list:
            if (e.src in known) and (e.dst in known):
                yield e

    def file_hash(self, abs_path: str) -> str:
        return self._file_hash.get(abs_path) or ""

    def ids_for_file(self, abs_path: str) -> List[str]:
        return list(self._ids_by_file.get(abs_path, []))

    def find_identifier_ids(self, token: str) -> List[str]:
        return list(self._index_identifiers.get(token.lower(), []))

    # Helpers
    def _discover_py_files(self, root: str, ignore: List[str]) -> List[str]:
        out: List[str] = []
        for dirpath, dirnames, filenames in os.walk(root):
            # naive ignore: drop segments that contain any ignore pattern
            if any(ig in dirpath for ig in ignore):
                continue
            for fn in filenames:
                if not fn.endswith(".py"):
                    continue
                ap = os.path.abspath(os.path.join(dirpath, fn))
                out.append(ap)
        return out

    def _module_name_for(self, abs_file: str) -> str:
        # repo-relative without extension, path with dots
        rel = os.path.relpath(abs_file, self.root).replace("\\", "/")
        if rel.endswith(".py"):
            rel = rel[:-3]
        parts = [p for p in rel.split("/") if p and p != "__init__"]
        return ".".join(parts) or os.path.splitext(os.path.basename(abs_file))[0]

    def _safe_count_lines(self, abs_file: str) -> int:
        try:
            with open(abs_file, "r", encoding="utf-8", errors="ignore") as fh:
                return sum(1 for _ in fh)
        except Exception:
            return 1

    def _precompute_hashes(self, files: List[str]) -> None:
        for fp in files:
            try:
                with open(fp, "rb") as fh:


    def ids_for_file(self, abs_path: str) -> List[str]:
        return list(self._ids_by_file.get(abs_path, []))

    def find_identifier_ids(self, token: str) -> List[str]:
        return list(self._index_identifiers.get(token.lower(), []))

    # Helpers
    def _discover_py_files(self, root: str, ignore: List[str]) -> List[str]:
        out: List[str] = []
        for dirpath, dirnames, filenames in os.walk(root):
            # naive ignore: drop segments that contain any ignore pattern
            if any(ig in dirpath for ig in ignore):
                continue
            for fn in filenames:
                if not fn.endswith(".py"):
                    continue
                ap = os.path.abspath(os.path.join(dirpath, fn))
                out.append(ap)
        return out

    def _module_name_for(self, abs_file: str) -> str:
        # repo-relative without extension, path with dots
        rel = os.path.relpath(abs_file, self.root).replace("\\", "/")
        if rel.endswith(".py"):
            rel = rel[:-3]
        parts = [p for p in rel.split("/") if p and p != "__init__"]
        return ".".join(parts) or os.path.splitext(os.path.basename(abs_file))[0]

    def _safe_count_lines(self, abs_file: str) -> int:
        try:
            with open(abs_file, "r", encoding="utf-8", errors="ignore") as fh:
                return sum(1 for _ in fh)
        except Exception:
            return 1

    def _precompute_hashes(self, files: List[str]) -> None:
        for fp in files:
            try:
                with open(fp, "rb") as fh:
                    raw = fh.read()
                h = hashlib.sha256(raw).hexdigest()
            except Exception:
                h = ""
            self._file_hash[fp] = h


        out: List[str] = []
        for dirpath, dirnames, filenames in os.walk(root):
            # naive ignore: drop segments that contain any ignore pattern
            if any(ig in dirpath for ig in ignore):
                continue
            for fn in filenames:
                if not fn.endswith(".py"):
                    continue
                ap = os.path.abspath(os.path.join(dirpath, fn))
                out.append(ap)
        return out

    def _module_name_for(self, abs_file: str) -> str:
        # repo-relative without extension, path with dots
        rel = os.path.relpath(abs_file, self.root).replace("\\", "/")
        if rel.endswith(".py"):
            rel = rel[:-3]
        parts = [p for p in rel.split("/") if p and p != "__init__"]
        return ".".join(parts) or os.path.splitext(os.path.basename(abs_file))[0]

    def _safe_count_lines(self, abs_file: str) -> int:
        try:
            with open(abs_file, "r", encoding="utf-8", errors="ignore") as fh:
                return sum(1 for _ in fh)
        except Exception:
            return 1

    def _precompute_hashes(self, files: List[str]) -> None:
        for fp in files:
            try:
                with open(fp, "rb") as fh:
                    raw = fh.read()
                h = hashlib.sha256(raw).hexdigest()
            except Exception:
                h = ""
            self._file_hash[fp] = h


                    continue
                ap = os.path.abspath(os.path.join(dirpath, fn))
                out.append(ap)
        return out

    def _module_name_for(self, abs_file: str) -> str:
        # repo-relative without extension, path with dots
        rel = os.path.relpath(abs_file, self.root).replace("\\", "/")
        if rel.endswith(".py"):
            rel = rel[:-3]
        parts = [p for p in rel.split("/") if p and p != "__init__"]
        return ".".join(parts) or os.path.splitext(os.path.basename(abs_file))[0]

    def _safe_count_lines(self, abs_file: str) -> int:
        try:
            with open(abs_file, "r", encoding="utf-8", errors="ignore") as fh:
                return sum(1 for _ in fh)
        except Exception:
            return 1

    def _precompute_hashes(self, files: List[str]) -> None:
        for fp in files:
            try:
                with open(fp, "rb") as fh:
                    raw = fh.read()
                h = hashlib.sha256(raw).hexdigest()
            except Exception:
                h = ""
            self._file_hash[fp] = h


from typing import List
from ..code_graph import CodeGraph

def build_prompts_for_module(g: CodeGraph, module: str, max_q: int = 3) -> List[str]:
    """Construct a few simple, verifiable prompts for a module using symbol names and doc headers.

    Prefers questions that can be answered via local context/citations.
    """
    prompts: List[str] = []
    # Symbols defined in the module
    defs = list(g.defs_in(module) or [])
    # Ask for an explanation of the module
    prompts.append(f"Explain the key functions/classes in {module}. Cite path:line for each claim.")
    # Ask about up to two concrete defs
    for fqn in defs[:2]:
        name = fqn.split(".")[-1]
        prompts.append(f"What does `{name}` do in {module}? Show signature and cite path:line.")
    # Unique and bounded
    uniq: List[str] = []
    for p in prompts:
        if p not in uniq:
            uniq.append(p)
    return uniq[: max(1, int(max_q))]


# citation regex check, pytest test runner

from typing import Dict, List, Any
import subprocess
from ..code_graph import CodeGraph
import os

def verify_with_tests(g: CodeGraph, module: str, *, repo_root: str, env: Dict[str, str]) -> bool:
    """Run mapped tests for a module if available; return True if all selected tests pass.

    Uses CodeGraph's tests mapping (best-effort). If no tests mapped, returns True as a soft pass.
    """
    nodes = g.pytest_nodes_by_module.get(module, [])
    # Also include module-level mapping
    if not nodes:
        nodes = g.tests_for_module(module)
    if not nodes:
        return True  # no tests to run; accept
    # Build pytest command
    cmd = ["pytest", "-q"]
    # Prefer node ids if present (file::Class::test), else module paths
    for n in nodes[:8]:  # cap for speed
        cmd.append(n)
    try:
        proc = subprocess.run(cmd, cwd=repo_root, env=env, capture_output=True, text=True)
        return int(proc.returncode) == 0
    except Exception:
        return False

def extract_citations(text: str) -> List[str]:
    try:
        import re
        rx = re.compile(r"(?:path:\s*)?([A-Za-z0-9_./\-]+?\.\w+):(\d+)(?:-(\d+))?")
        return [m.group(1) for m in rx.finditer(text or "")]
    except Exception:
        return []

def has_citations(s: str, per_para: bool) -> bool:
    import re as _re
    rx = _re.compile(r"(?:path:\s*)?[A-Za-z0-9_./-]+?\.\w+:\d+(?:-\d+)?")
    if not rx.search(s):
        return False
    if per_para:
        paras = [p.strip() for p in s.split("\n\n") if p.strip()]
        return all(rx.search(p) for p in paras)
    return True

def normalize_citations(text: str) -> List[tuple[str, int, int]]:
    """Return normalized (path, a, b) triples for all citations in text.

    If end is missing, b==a. Paths are returned as-is (caller can make them repo-relative).
    """
    try:
        import re
        rx = re.compile(r"(?:path:\s*)?([A-Za-z0-9_./\-]+?\.\w+):(\d+)(?:-(\d+))?")
        out: List[tuple[str, int, int]] = []
        for m in rx.finditer(text or ""):
            p = m.group(1)
            try:
                a = int(m.group(2) or "0")
            except Exception:
                a = 0
            try:
                b = int(m.group(3) or a)
            except Exception:
                b = a
            out.append((p, min(a, b), max(a, b)))
        return out
    except Exception:
        return []

def extract_typed_facts(text: str, g: CodeGraph) -> List[dict]:
    """Best-effort, lightweight fact extraction from answer text.

    Emits dicts like {kind, symbol, span} where:
      - kind in {"signature", "mentions", "returns"}
      - symbol is best-effort FQN or name
      - span optionally refers to a cited (path,a-b) if the symbol can be mapped later
    """
    out: List[dict] = []
    try:
        import re
        # signature claims: show name and parenthesized params
        for m in re.finditer(r"\b([A-Za-z_][A-Za-z0-9_]*)\s*\(([^)]*)\)", text or ""):
            name = m.group(1)
            params = m.group(2)
            # map to FQN if possible (short name match)
            fqns = [s.fqn for s in g.find_symbol(name)] if name else []
            out.append({
                "kind": "signature",
                "symbol": fqns[0] if fqns else name,
                "params": params,
            })
        # explicit returns claims (very naive)
        for m in re.finditer(r"returns\s+([A-Za-z_][A-Za-z0-9_.]*)", (text or "").lower()):
            typ = m.group(1)
            out.append({"kind": "returns", "type": typ})
        # code mentions in backticks
        for m in re.finditer(r"`([A-Za-z_][A-Za-z0-9_\.]+)`", text or ""):
            sym = m.group(1)
            out.append({"kind": "mentions", "symbol": sym})
    except Exception:
        return out
    return out

def extract_symbol_mentions(text: str) -> List[str]:
    """Collect probable symbol names from text using simple patterns."""
    names: List[str] = []
    try:
        import re
        # Backticked identifiers or dotted names
        for m in re.finditer(r"`([A-Za-z_][A-Za-z0-9_\.]+)`", text or ""):
            names.append(m.group(1).split(".")[-1])
        # Bare function/class name followed by '('
        for m in re.finditer(r"\b([A-Za-z_][A-Za-z0-9_]*)\s*\(", text or ""):
            names.append(m.group(1))
    except Exception:
        return names
    # De-dup while preserving order
    seen = set()
    uniq: List[str] = []
    for n in names:
        if n and n not in seen:
            seen.add(n)
            uniq.append(n)
    return uniq

def _abs_paths_like(root: str, files: List[str]) -> List[str]:
    out: List[str] = []
    for rel in files or []:
        if not rel:
            continue
        if os.path.isabs(rel):
            out.append(os.path.abspath(rel))
        else:
            out.append(os.path.abspath(os.path.join(root, rel)))
    return out

def _to_repo_relative(root: str, abs_path: str) -> str:
    try:
        root_abs = os.path.abspath(root or "")
        p_abs = os.path.abspath(abs_path or "")
        if p_abs.startswith(root_abs):
            rel = p_abs[len(root_abs):].lstrip(os.sep)
            return rel.replace("\\", "/")
        return abs_path.replace("\\", "/")
    except Exception:
        return abs_path.replace("\\", "/")

def _merge_overlapping_spans(spans: List[tuple[str, int, int]]) -> List[tuple[str, int, int]]:
    by_file: Dict[str, List[tuple[int, int]]] = {}
    for p, a, b in spans:
        by_file.setdefault(p, []).append((min(a, b), max(a, b)))
    merged: List[tuple[str, int, int]] = []
    for p, ranges in by_file.items():
        ranges.sort()
        cur_a, cur_b = ranges[0]
        for a, b in ranges[1:]:
            if a <= cur_b + 1:
                cur_b = max(cur_b, b)
            else:
                merged.append((p, cur_a, cur_b))
                cur_a, cur_b = a, b
        merged.append((p, cur_a, cur_b))
    return merged

def resolve_claim_spans(text: str, g: CodeGraph, idx: object, files_scope: List[str]) -> List[tuple[str, int, int]]:
    """Resolve spans for claims using anchor-first mapping, then similarity fallback."""
    try:
        scope_abs = set(_abs_paths_like(g.root, files_scope))
    except Exception:
        scope_abs = set()
    spans: List[tuple[str, int, int]] = []
    names = extract_symbol_mentions(text or "")
    # Anchor-first by symbol lookup
    for name in names:
        try:
            cands = [s for s in g.find_symbol(name) if (not scope_abs or os.path.abspath(s.file) in scope_abs)]
        except Exception:
            cands = []
        if cands:
            # Prefer shortest span (tighter definition)
            s = min(cands, key=lambda z: (int(getattr(z, "end_line", getattr(z, "line", 1))) - int(getattr(z, "line", 1))), default=None)
            if s:
                rel = _to_repo_relative(g.root, s.file)
                spans.append((rel, int(getattr(s, "line", 1)), int(getattr(s, "end_line", getattr(s, "line", 1)))))
    # Similarity fallback via SymbolIndex if nothing found
    if not spans and idx is not None:
        try:
            # Late import type to avoid hard dependency
            top = getattr(idx, "query")(text or "", 3)
            for it in (top or [])[:3]:
                rel = it.get("rel") or _to_repo_relative(g.root, it.get("file", ""))
                a = int(it.get("line", 1)); b = int(it.get("end_line", a))
                spans.append((rel, min(a, b), max(a, b)))
        except Exception:
            pass
    # Merge overlaps and de-dup
    seen = set()
    spans = _merge_overlapping_spans([s for s in spans if s not in seen and not seen.add(s)])
    return spans

def validate_spans(cites: List[tuple[str, int, int]], g: CodeGraph) -> List[tuple[str, int, int]]:
    """Clamp to file bounds and ensure 1-based inclusive ranges."""
    ok: List[tuple[str, int, int]] = []
    for p, a, b in cites:
        a0 = max(1, int(a)); b0 = max(a0, int(b))
        # Best-effort cap to file length
        try:
            abs_fp = os.path.join(g.root, p) if not os.path.isabs(p) else p
            with open(abs_fp, "r", encoding="utf-8", errors="ignore") as fh:
                n = sum(1 for _ in fh)
            b0 = min(b0, int(n))
        except Exception:
            pass
        ok.append((p, a0, b0))
    return ok

def _file_from_citation_path(g: CodeGraph, files_scope: List[str], cite_path: str) -> str | None:
    """Resolve a citation path to a repo-relative path within scope, allowing basename-only matches."""
    if not cite_path:
        return None
    # If absolute, try to make it repo-relative
    if os.path.isabs(cite_path):
        return _to_repo_relative(g.root, cite_path)
    # Direct match in scope
    scope_rel = [f if not os.path.isabs(f) else _to_repo_relative(g.root, f) for f in (files_scope or [])]
    if cite_path in scope_rel:
        return cite_path
    # Basename match in scope
    base = os.path.basename(cite_path)
    cands = [r for r in scope_rel if os.path.basename(r) == base]
    if len(cands) == 1:
        return cands[0]
    # As a fallback, accept the path as-is (caller will attempt to open)
    return cite_path

def _nearest_symbol_span_for_line(g: CodeGraph, rel_path: str, a: int, b: int) -> tuple[int, int] | None:
    """Find a symbol span in file that overlaps [a,b], else nearest by distance to midpoint."""
    try:
        abs_fp = os.path.join(g.root, rel_path) if not os.path.isabs(rel_path) else rel_path
        # Filter symbols for this file
        syms: List[Any] = []
        try:
            syms_all = list(getattr(g, "symbols_by_fqn", {}).values())
        except Exception:
            syms_all = []
        for s in syms_all or []:
            try:
                if os.path.abspath(getattr(s, "file", "")) == os.path.abspath(abs_fp):
                    syms.append(s)
            except Exception:
                continue
        if not syms:
            return None
        a0, b0 = int(a), int(b)
        # Overlap first
        overlaps = [s for s in syms if not (int(s.end_line) < a0 or int(s.line) > b0)]
        if overlaps:
            s = min(overlaps, key=lambda z: (int(z.end_line) - int(z.line)))
            return int(s.line), int(s.end_line)
        # Nearest by midpoint distance
        mid = (a0 + b0) // 2
        s = min(syms, key=lambda z: min(abs(mid - int(z.line)), abs(mid - int(z.end_line))))
        return int(s.line), int(s.end_line)
    except Exception:
        return None

def repair_citations(text: str, g: CodeGraph, files_scope: List[str]) -> List[tuple[str, int, int]]:
    """Map cited path:a-b to nearest anchored symbol spans within scope, de-dup and validate."""
    cites = normalize_citations(text or "")
    if not cites:
        return []
    repaired: List[tuple[str, int, int]] = []
    for (p_raw, a, b) in cites:
        try:
            rel = _file_from_citation_path(g, files_scope, p_raw)
            if not rel:
                continue
            span = _nearest_symbol_span_for_line(g, rel, a, b)
            if not span:
                # Keep original range if no symbol found
                repaired.append((rel, min(int(a), int(b)), max(int(a), int(b))))
                continue
            sa, sb = span
            repaired.append((rel, int(sa), int(sb)))
        except Exception:
            continue
    # Validate and merge
    repaired = validate_spans(repaired, g)
    # De-dup
    seen = set()
    uniq = []
    for r in repaired:
        if r not in seen:
            seen.add(r)
            uniq.append(r)
    return _merge_overlapping_spans(uniq)


# citation regex check, pytest test runner

from typing import Dict, List, Any
import subprocess
from ..code_graph import CodeGraph
import os

def verify_with_tests(g: CodeGraph, module: str, *, repo_root: str, env: Dict[str, str]) -> bool:
    """Run mapped tests for a module if available; return True if all selected tests pass.

    Uses CodeGraph's tests mapping (best-effort). If no tests mapped, returns True as a soft pass.
    """
    nodes = g.pytest_nodes_by_module.get(module, [])
    # Also include module-level mapping
    if not nodes:
        nodes = g.tests_for_module(module)
    if not nodes:
        return True  # no tests to run; accept
    # Build pytest command
    cmd = ["pytest", "-q"]
    # Prefer node ids if present (file::Class::test), else module paths
    for n in nodes[:8]:  # cap for speed
        cmd.append(n)
    try:
        proc = subprocess.run(cmd, cwd=repo_root, env=env, capture_output=True, text=True)
        return int(proc.returncode) == 0
    except Exception:
        return False

def extract_citations(text: str) -> List[str]:
    try:
        import re
        rx = re.compile(r"(?:path:\s*)?([A-Za-z0-9_./\-]+?\.\w+):(\d+)(?:-(\d+))?")
        return [m.group(1) for m in rx.finditer(text or "")]
    except Exception:
        return []

def has_citations(s: str, per_para: bool) -> bool:
    import re as _re
    rx = _re.compile(r"(?:path:\s*)?[A-Za-z0-9_./-]+?\.\w+:\d+(?:-\d+)?")
    if not rx.search(s):
        return False
    if per_para:
        paras = [p.strip() for p in s.split("\n\n") if p.strip()]
        return all(rx.search(p) for p in paras)
    return True


    Uses CodeGraph's tests mapping (best-effort). If no tests mapped, returns True as a soft pass.
    """
    nodes = g.pytest_nodes_by_module.get(module, [])
    # Also include module-level mapping
    if not nodes:
        nodes = g.tests_for_module(module)
    if not nodes:
        return True  # no tests to run; accept
    # Build pytest command
    cmd = ["pytest", "-q"]
    # Prefer node ids if present (file::Class::test), else module paths
    for n in nodes[:8]:  # cap for speed
        cmd.append(n)
    try:
        proc = subprocess.run(cmd, cwd=repo_root, env=env, capture_output=True, text=True)
        return int(proc.returncode) == 0
    except Exception:
        return False

def extract_citations(text: str) -> List[str]:
    try:
        import re
        rx = re.compile(r"(?:path:\s*)?([A-Za-z0-9_./\-]+?\.\w+):(\d+)(?:-(\d+))?")
        return [m.group(1) for m in rx.finditer(text or "")]
    except Exception:
        return []

def has_citations(s: str, per_para: bool) -> bool:
    import re as _re
    rx = _re.compile(r"(?:path:\s*)?[A-Za-z0-9_./-]+?\.\w+:\d+(?:-\d+)?")
    if not rx.search(s):
        return False
    if per_para:
        paras = [p.strip() for p in s.split("\n\n") if p.strip()]
        return all(rx.search(p) for p in paras)
    return True

def normalize_citations(text: str) -> List[tuple[str, int, int]]:
    """Return normalized (path, a, b) triples for all citations in text.

    If end is missing, b==a. Paths are returned as-is (caller can make them repo-relative).
    """
    try:
        import re


    cmd = ["pytest", "-q"]
    # Prefer node ids if present (file::Class::test), else module paths
    for n in nodes[:8]:  # cap for speed
        cmd.append(n)
    try:
        proc = subprocess.run(cmd, cwd=repo_root, env=env, capture_output=True, text=True)
        return int(proc.returncode) == 0
    except Exception:
        return False

def extract_citations(text: str) -> List[str]:
    try:
        import re
        rx = re.compile(r"(?:path:\s*)?([A-Za-z0-9_./\-]+?\.\w+):(\d+)(?:-(\d+))?")
        return [m.group(1) for m in rx.finditer(text or "")]
    except Exception:
        return []

def has_citations(s: str, per_para: bool) -> bool:
    import re as _re
    rx = _re.compile(r"(?:path:\s*)?[A-Za-z0-9_./-]+?\.\w+:\d+(?:-\d+)?")
    if not rx.search(s):
        return False
    if per_para:
        paras = [p.strip() for p in s.split("\n\n") if p.strip()]
        return all(rx.search(p) for p in paras)
    return True

def normalize_citations(text: str) -> List[tuple[str, int, int]]:
    """Return normalized (path, a, b) triples for all citations in text.

    If end is missing, b==a. Paths are returned as-is (caller can make them repo-relative).
    """
    try:
        import re
        rx = re.compile(r"(?:path:\s*)?([A-Za-z0-9_./\-]+?\.\w+):(\d+)(?:-(\d+))?")
        out: List[tuple[str, int, int]] = []
        for m in rx.finditer(text or ""):
            p = m.group(1)
            try:
                a = int(m.group(2) or "0")
            except Exception:
                a = 0
            try:
                b = int(m.group(3) or a)
            except Exception:


def extract_citations(text: str) -> List[str]:
    try:
        import re
        rx = re.compile(r"(?:path:\s*)?([A-Za-z0-9_./\-]+?\.\w+):(\d+)(?:-(\d+))?")
        return [m.group(1) for m in rx.finditer(text or "")]
    except Exception:
        return []

def has_citations(s: str, per_para: bool) -> bool:
    import re as _re
    rx = _re.compile(r"(?:path:\s*)?[A-Za-z0-9_./-]+?\.\w+:\d+(?:-\d+)?")
    if not rx.search(s):
        return False
    if per_para:
        paras = [p.strip() for p in s.split("\n\n") if p.strip()]
        return all(rx.search(p) for p in paras)
    return True

def normalize_citations(text: str) -> List[tuple[str, int, int]]:
    """Return normalized (path, a, b) triples for all citations in text.

    If end is missing, b==a. Paths are returned as-is (caller can make them repo-relative).
    """
    try:
        import re
        rx = re.compile(r"(?:path:\s*)?([A-Za-z0-9_./\-]+?\.\w+):(\d+)(?:-(\d+))?")
        out: List[tuple[str, int, int]] = []
        for m in rx.finditer(text or ""):
            p = m.group(1)
            try:
                a = int(m.group(2) or "0")
            except Exception:
                a = 0
            try:
                b = int(m.group(3) or a)
            except Exception:
                b = a
            out.append((p, min(a, b), max(a, b)))
        return out
    except Exception:
        return []

def extract_typed_facts(text: str, g: CodeGraph) -> List[dict]:
    """Best-effort, lightweight fact extraction from answer text.

    Emits dicts like {kind, symbol, span} where:
      - kind in {"signature", "mentions", "returns"}
      - symbol is best-effort FQN or name
      - span optionally refers to a cited (path,a-b) if the symbol can be mapped later
    """
    out: List[dict] = []
    try:
        import re
        # signature claims: show name and parenthesized params
        for m in re.finditer(r"\b([A-Za-z_][A-Za-z0-9_]*)\s*\(([^)]*)\)", text or ""):
            name = m.group(1)
            params = m.group(2)
            # map to FQN if possible (short name match)
            fqns = [s.fqn for s in g.find_symbol(name)] if name else []
            out.append({


    try:
        import re
        rx = re.compile(r"(?:path:\s*)?([A-Za-z0-9_./\-]+?\.\w+):(\d+)(?:-(\d+))?")
        out: List[tuple[str, int, int]] = []
        for m in rx.finditer(text or ""):
            p = m.group(1)
            try:
                a = int(m.group(2) or "0")
            except Exception:
                a = 0
            try:
                b = int(m.group(3) or a)
            except Exception:
                b = a
            out.append((p, min(a, b), max(a, b)))
        return out
    except Exception:
        return []

def extract_typed_facts(text: str, g: CodeGraph) -> List[dict]:
    """Best-effort, lightweight fact extraction from answer text.

    Emits dicts like {kind, symbol, span} where:
      - kind in {"signature", "mentions", "returns"}
      - symbol is best-effort FQN or name
      - span optionally refers to a cited (path,a-b) if the symbol can be mapped later
    """
    out: List[dict] = []
    try:
        import re
        # signature claims: show name and parenthesized params
        for m in re.finditer(r"\b([A-Za-z_][A-Za-z0-9_]*)\s*\(([^)]*)\)", text or ""):
            name = m.group(1)
            params = m.group(2)
            # map to FQN if possible (short name match)
            fqns = [s.fqn for s in g.find_symbol(name)] if name else []
            out.append({
                "kind": "signature",
                "symbol": fqns[0] if fqns else name,
                "params": params,
            })
        # explicit returns claims (very naive)
        for m in re.finditer(r"returns\s+([A-Za-z_][A-Za-z0-9_.]*)", (text or "").lower()):
            typ = m.group(1)
            out.append({"kind": "returns", "type": typ})
        # code mentions in backticks
        for m in re.finditer(r"`([A-Za-z_][A-Za-z0-9_\.]+)`", text or ""):
            sym = m.group(1)
            out.append({"kind": "mentions", "symbol": sym})
    except Exception:
        return out
    return out

def extract_symbol_mentions(text: str) -> List[str]:
    """Collect probable symbol names from text using simple patterns."""
    names: List[str] = []
    try:
        import re
        # Backticked identifiers or dotted names
        for m in re.finditer(r"`([A-Za-z_][A-Za-z0-9_\.]+)`", text or ""):
            names.append(m.group(1).split(".")[-1])
        # Bare function/class name followed by '('
        for m in re.finditer(r"\b([A-Za-z_][A-Za-z0-9_]*)\s*\(", text or ""):
            names.append(m.group(1))
    except Exception:
        return names
    # De-dup while preserving order
    seen = set()
    uniq: List[str] = []
    for n in names:
        if n and n not in seen:


            # map to FQN if possible (short name match)
            fqns = [s.fqn for s in g.find_symbol(name)] if name else []
            out.append({
                "kind": "signature",
                "symbol": fqns[0] if fqns else name,
                "params": params,
            })
        # explicit returns claims (very naive)
        for m in re.finditer(r"returns\s+([A-Za-z_][A-Za-z0-9_.]*)", (text or "").lower()):
            typ = m.group(1)
            out.append({"kind": "returns", "type": typ})
        # code mentions in backticks
        for m in re.finditer(r"`([A-Za-z_][A-Za-z0-9_\.]+)`", text or ""):
            sym = m.group(1)
            out.append({"kind": "mentions", "symbol": sym})
    except Exception:
        return out
    return out

def extract_symbol_mentions(text: str) -> List[str]:
    """Collect probable symbol names from text using simple patterns."""
    names: List[str] = []
    try:
        import re
        # Backticked identifiers or dotted names
        for m in re.finditer(r"`([A-Za-z_][A-Za-z0-9_\.]+)`", text or ""):
            names.append(m.group(1).split(".")[-1])
        # Bare function/class name followed by '('
        for m in re.finditer(r"\b([A-Za-z_][A-Za-z0-9_]*)\s*\(", text or ""):
            names.append(m.group(1))
    except Exception:
        return names
    # De-dup while preserving order
    seen = set()
    uniq: List[str] = []
    for n in names:
        if n and n not in seen:
            seen.add(n)
            uniq.append(n)
    return uniq

def _abs_paths_like(root: str, files: List[str]) -> List[str]:
    out: List[str] = []
    for rel in files or []:
        if not rel:
            continue
        if os.path.isabs(rel):
            out.append(os.path.abspath(rel))
        else:
            out.append(os.path.abspath(os.path.join(root, rel)))
    return out

def _to_repo_relative(root: str, abs_path: str) -> str:
    try:
        root_abs = os.path.abspath(root or "")
        p_abs = os.path.abspath(abs_path or "")
        if p_abs.startswith(root_abs):
            rel = p_abs[len(root_abs):].lstrip(os.sep)


    try:
        import re
        # Backticked identifiers or dotted names
        for m in re.finditer(r"`([A-Za-z_][A-Za-z0-9_\.]+)`", text or ""):
            names.append(m.group(1).split(".")[-1])
        # Bare function/class name followed by '('
        for m in re.finditer(r"\b([A-Za-z_][A-Za-z0-9_]*)\s*\(", text or ""):
            names.append(m.group(1))
    except Exception:
        return names
    # De-dup while preserving order
    seen = set()
    uniq: List[str] = []
    for n in names:
        if n and n not in seen:
            seen.add(n)
            uniq.append(n)
    return uniq

def _abs_paths_like(root: str, files: List[str]) -> List[str]:
    out: List[str] = []
    for rel in files or []:
        if not rel:
            continue
        if os.path.isabs(rel):
            out.append(os.path.abspath(rel))
        else:
            out.append(os.path.abspath(os.path.join(root, rel)))
    return out

def _to_repo_relative(root: str, abs_path: str) -> str:
    try:
        root_abs = os.path.abspath(root or "")
        p_abs = os.path.abspath(abs_path or "")
        if p_abs.startswith(root_abs):
            rel = p_abs[len(root_abs):].lstrip(os.sep)
            return rel.replace("\\", "/")
        return abs_path.replace("\\", "/")
    except Exception:
        return abs_path.replace("\\", "/")

def _merge_overlapping_spans(spans: List[tuple[str, int, int]]) -> List[tuple[str, int, int]]:
    by_file: Dict[str, List[tuple[int, int]]] = {}
    for p, a, b in spans:
        by_file.setdefault(p, []).append((min(a, b), max(a, b)))
    merged: List[tuple[str, int, int]] = []
    for p, ranges in by_file.items():


    uniq: List[str] = []
    for n in names:
        if n and n not in seen:
            seen.add(n)
            uniq.append(n)
    return uniq

def _abs_paths_like(root: str, files: List[str]) -> List[str]:
    out: List[str] = []
    for rel in files or []:
        if not rel:
            continue
        if os.path.isabs(rel):
            out.append(os.path.abspath(rel))
        else:
            out.append(os.path.abspath(os.path.join(root, rel)))
    return out

def _to_repo_relative(root: str, abs_path: str) -> str:
    try:
        root_abs = os.path.abspath(root or "")
        p_abs = os.path.abspath(abs_path or "")
        if p_abs.startswith(root_abs):
            rel = p_abs[len(root_abs):].lstrip(os.sep)
            return rel.replace("\\", "/")
        return abs_path.replace("\\", "/")
    except Exception:
        return abs_path.replace("\\", "/")

def _merge_overlapping_spans(spans: List[tuple[str, int, int]]) -> List[tuple[str, int, int]]:
    by_file: Dict[str, List[tuple[int, int]]] = {}
    for p, a, b in spans:
        by_file.setdefault(p, []).append((min(a, b), max(a, b)))
    merged: List[tuple[str, int, int]] = []
    for p, ranges in by_file.items():
        ranges.sort()
        cur_a, cur_b = ranges[0]
        for a, b in ranges[1:]:
            if a <= cur_b + 1:
                cur_b = max(cur_b, b)
            else:
                merged.append((p, cur_a, cur_b))
                cur_a, cur_b = a, b
        merged.append((p, cur_a, cur_b))
    return merged


            continue
        if os.path.isabs(rel):
            out.append(os.path.abspath(rel))
        else:
            out.append(os.path.abspath(os.path.join(root, rel)))
    return out

def _to_repo_relative(root: str, abs_path: str) -> str:
    try:
        root_abs = os.path.abspath(root or "")
        p_abs = os.path.abspath(abs_path or "")
        if p_abs.startswith(root_abs):
            rel = p_abs[len(root_abs):].lstrip(os.sep)
            return rel.replace("\\", "/")
        return abs_path.replace("\\", "/")
    except Exception:
        return abs_path.replace("\\", "/")

def _merge_overlapping_spans(spans: List[tuple[str, int, int]]) -> List[tuple[str, int, int]]:
    by_file: Dict[str, List[tuple[int, int]]] = {}
    for p, a, b in spans:
        by_file.setdefault(p, []).append((min(a, b), max(a, b)))
    merged: List[tuple[str, int, int]] = []
    for p, ranges in by_file.items():
        ranges.sort()
        cur_a, cur_b = ranges[0]
        for a, b in ranges[1:]:
            if a <= cur_b + 1:
                cur_b = max(cur_b, b)
            else:
                merged.append((p, cur_a, cur_b))
                cur_a, cur_b = a, b
        merged.append((p, cur_a, cur_b))
    return merged

def resolve_claim_spans(text: str, g: CodeGraph, idx: object, files_scope: List[str]) -> List[tuple[str, int, int]]:
    """Resolve spans for claims using anchor-first mapping, then similarity fallback."""
    try:
        scope_abs = set(_abs_paths_like(g.root, files_scope))
    except Exception:
        scope_abs = set()
    spans: List[tuple[str, int, int]] = []
    names = extract_symbol_mentions(text or "")
    # Anchor-first by symbol lookup
    for name in names:
        try:
            cands = [s for s in g.find_symbol(name) if (not scope_abs or os.path.abspath(s.file) in scope_abs)]
        except Exception:
            cands = []
        if cands:
            # Prefer shortest span (tighter definition)
            s = min(cands, key=lambda z: (int(getattr(z, "end_line", getattr(z, "line", 1))) - int(getattr(z, "line", 1))), default=None)
            if s:


def _merge_overlapping_spans(spans: List[tuple[str, int, int]]) -> List[tuple[str, int, int]]:
    by_file: Dict[str, List[tuple[int, int]]] = {}
    for p, a, b in spans:
        by_file.setdefault(p, []).append((min(a, b), max(a, b)))
    merged: List[tuple[str, int, int]] = []
    for p, ranges in by_file.items():
        ranges.sort()
        cur_a, cur_b = ranges[0]
        for a, b in ranges[1:]:
            if a <= cur_b + 1:
                cur_b = max(cur_b, b)
            else:
                merged.append((p, cur_a, cur_b))
                cur_a, cur_b = a, b
        merged.append((p, cur_a, cur_b))
    return merged

def resolve_claim_spans(text: str, g: CodeGraph, idx: object, files_scope: List[str]) -> List[tuple[str, int, int]]:
    """Resolve spans for claims using anchor-first mapping, then similarity fallback."""
    try:
        scope_abs = set(_abs_paths_like(g.root, files_scope))
    except Exception:
        scope_abs = set()
    spans: List[tuple[str, int, int]] = []
    names = extract_symbol_mentions(text or "")
    # Anchor-first by symbol lookup
    for name in names:
        try:
            cands = [s for s in g.find_symbol(name) if (not scope_abs or os.path.abspath(s.file) in scope_abs)]
        except Exception:
            cands = []
        if cands:
            # Prefer shortest span (tighter definition)
            s = min(cands, key=lambda z: (int(getattr(z, "end_line", getattr(z, "line", 1))) - int(getattr(z, "line", 1))), default=None)
            if s:
                rel = _to_repo_relative(g.root, s.file)
                spans.append((rel, int(getattr(s, "line", 1)), int(getattr(s, "end_line", getattr(s, "line", 1)))))
    # Similarity fallback via SymbolIndex if nothing found
    if not spans and idx is not None:
        try:
            # Late import type to avoid hard dependency
            top = getattr(idx, "query")(text or "", 3)
            for it in (top or [])[:3]:
                rel = it.get("rel") or _to_repo_relative(g.root, it.get("file", ""))
                a = int(it.get("line", 1)); b = int(it.get("end_line", a))
                spans.append((rel, min(a, b), max(a, b)))
        except Exception:
            pass
    # Merge overlaps and de-dup
    seen = set()
    spans = _merge_overlapping_spans([s for s in spans if s not in seen and not seen.add(s)])
    return spans

def validate_spans(cites: List[tuple[str, int, int]], g: CodeGraph) -> List[tuple[str, int, int]]:
    """Clamp to file bounds and ensure 1-based inclusive ranges."""
    ok: List[tuple[str, int, int]] = []
    for p, a, b in cites:
        a0 = max(1, int(a)); b0 = max(a0, int(b))
        # Best-effort cap to file length
        try:
            abs_fp = os.path.join(g.root, p) if not os.path.isabs(p) else p
            with open(abs_fp, "r", encoding="utf-8", errors="ignore") as fh:
                n = sum(1 for _ in fh)
            b0 = min(b0, int(n))
        except Exception:
            pass
        ok.append((p, a0, b0))
    return ok

def _file_from_citation_path(g: CodeGraph, files_scope: List[str], cite_path: str) -> str | None:


            if s:
                rel = _to_repo_relative(g.root, s.file)
                spans.append((rel, int(getattr(s, "line", 1)), int(getattr(s, "end_line", getattr(s, "line", 1)))))
    # Similarity fallback via SymbolIndex if nothing found
    if not spans and idx is not None:
        try:
            # Late import type to avoid hard dependency
            top = getattr(idx, "query")(text or "", 3)
            for it in (top or [])[:3]:
                rel = it.get("rel") or _to_repo_relative(g.root, it.get("file", ""))
                a = int(it.get("line", 1)); b = int(it.get("end_line", a))
                spans.append((rel, min(a, b), max(a, b)))
        except Exception:
            pass
    # Merge overlaps and de-dup
    seen = set()
    spans = _merge_overlapping_spans([s for s in spans if s not in seen and not seen.add(s)])
    return spans

def validate_spans(cites: List[tuple[str, int, int]], g: CodeGraph) -> List[tuple[str, int, int]]:
    """Clamp to file bounds and ensure 1-based inclusive ranges."""
    ok: List[tuple[str, int, int]] = []
    for p, a, b in cites:
        a0 = max(1, int(a)); b0 = max(a0, int(b))
        # Best-effort cap to file length
        try:
            abs_fp = os.path.join(g.root, p) if not os.path.isabs(p) else p
            with open(abs_fp, "r", encoding="utf-8", errors="ignore") as fh:
                n = sum(1 for _ in fh)
            b0 = min(b0, int(n))
        except Exception:
            pass
        ok.append((p, a0, b0))
    return ok

def _file_from_citation_path(g: CodeGraph, files_scope: List[str], cite_path: str) -> str | None:
    """Resolve a citation path to a repo-relative path within scope, allowing basename-only matches."""
    if not cite_path:
        return None
    # If absolute, try to make it repo-relative
    if os.path.isabs(cite_path):
        return _to_repo_relative(g.root, cite_path)
    # Direct match in scope
    scope_rel = [f if not os.path.isabs(f) else _to_repo_relative(g.root, f) for f in (files_scope or [])]
    if cite_path in scope_rel:
        return cite_path
    # Basename match in scope
    base = os.path.basename(cite_path)
    cands = [r for r in scope_rel if os.path.basename(r) == base]
    if len(cands) == 1:
        return cands[0]
    # As a fallback, accept the path as-is (caller will attempt to open)
    return cite_path


    return spans

def validate_spans(cites: List[tuple[str, int, int]], g: CodeGraph) -> List[tuple[str, int, int]]:
    """Clamp to file bounds and ensure 1-based inclusive ranges."""
    ok: List[tuple[str, int, int]] = []
    for p, a, b in cites:
        a0 = max(1, int(a)); b0 = max(a0, int(b))
        # Best-effort cap to file length
        try:
            abs_fp = os.path.join(g.root, p) if not os.path.isabs(p) else p
            with open(abs_fp, "r", encoding="utf-8", errors="ignore") as fh:
                n = sum(1 for _ in fh)
            b0 = min(b0, int(n))
        except Exception:
            pass
        ok.append((p, a0, b0))
    return ok

def _file_from_citation_path(g: CodeGraph, files_scope: List[str], cite_path: str) -> str | None:
    """Resolve a citation path to a repo-relative path within scope, allowing basename-only matches."""
    if not cite_path:
        return None
    # If absolute, try to make it repo-relative
    if os.path.isabs(cite_path):
        return _to_repo_relative(g.root, cite_path)
    # Direct match in scope
    scope_rel = [f if not os.path.isabs(f) else _to_repo_relative(g.root, f) for f in (files_scope or [])]
    if cite_path in scope_rel:
        return cite_path
    # Basename match in scope
    base = os.path.basename(cite_path)
    cands = [r for r in scope_rel if os.path.basename(r) == base]
    if len(cands) == 1:
        return cands[0]
    # As a fallback, accept the path as-is (caller will attempt to open)
    return cite_path

def _nearest_symbol_span_for_line(g: CodeGraph, rel_path: str, a: int, b: int) -> tuple[int, int] | None:
    """Find a symbol span in file that overlaps [a,b], else nearest by distance to midpoint."""
    try:
        abs_fp = os.path.join(g.root, rel_path) if not os.path.isabs(rel_path) else rel_path
        # Filter symbols for this file
        syms: List[Any] = []
        try:
            syms_all = list(getattr(g, "symbols_by_fqn", {}).values())
        except Exception:
            syms_all = []
        for s in syms_all or []:
            try:
                if os.path.abspath(getattr(s, "file", "")) == os.path.abspath(abs_fp):
                    syms.append(s)
            except Exception:
                continue
        if not syms:
            return None


def _file_from_citation_path(g: CodeGraph, files_scope: List[str], cite_path: str) -> str | None:
    """Resolve a citation path to a repo-relative path within scope, allowing basename-only matches."""
    if not cite_path:
        return None
    # If absolute, try to make it repo-relative
    if os.path.isabs(cite_path):
        return _to_repo_relative(g.root, cite_path)
    # Direct match in scope
    scope_rel = [f if not os.path.isabs(f) else _to_repo_relative(g.root, f) for f in (files_scope or [])]
    if cite_path in scope_rel:
        return cite_path
    # Basename match in scope
    base = os.path.basename(cite_path)
    cands = [r for r in scope_rel if os.path.basename(r) == base]
    if len(cands) == 1:
        return cands[0]
    # As a fallback, accept the path as-is (caller will attempt to open)
    return cite_path

def _nearest_symbol_span_for_line(g: CodeGraph, rel_path: str, a: int, b: int) -> tuple[int, int] | None:
    """Find a symbol span in file that overlaps [a,b], else nearest by distance to midpoint."""
    try:
        abs_fp = os.path.join(g.root, rel_path) if not os.path.isabs(rel_path) else rel_path
        # Filter symbols for this file
        syms: List[Any] = []
        try:
            syms_all = list(getattr(g, "symbols_by_fqn", {}).values())
        except Exception:
            syms_all = []
        for s in syms_all or []:
            try:
                if os.path.abspath(getattr(s, "file", "")) == os.path.abspath(abs_fp):
                    syms.append(s)
            except Exception:
                continue
        if not syms:
            return None
        a0, b0 = int(a), int(b)
        # Overlap first
        overlaps = [s for s in syms if not (int(s.end_line) < a0 or int(s.line) > b0)]
        if overlaps:
            s = min(overlaps, key=lambda z: (int(z.end_line) - int(z.line)))
            return int(s.line), int(s.end_line)
        # Nearest by midpoint distance
        mid = (a0 + b0) // 2
        s = min(syms, key=lambda z: min(abs(mid - int(z.line)), abs(mid - int(z.end_line))))
        return int(s.line), int(s.end_line)
    except Exception:
        return None

def repair_citations(text: str, g: CodeGraph, files_scope: List[str]) -> List[tuple[str, int, int]]:
    """Map cited path:a-b to nearest anchored symbol spans within scope, de-dup and validate."""
    cites = normalize_citations(text or "")
    if not cites:
        return []
    repaired: List[tuple[str, int, int]] = []
    for (p_raw, a, b) in cites:
        try:
            rel = _file_from_citation_path(g, files_scope, p_raw)
            if not rel:
                continue
            span = _nearest_symbol_span_for_line(g, rel, a, b)
            if not span:
                # Keep original range if no symbol found
                repaired.append((rel, min(int(a), int(b)), max(int(a), int(b))))
                continue
            sa, sb = span
            repaired.append((rel, int(sa), int(sb)))


                if os.path.abspath(getattr(s, "file", "")) == os.path.abspath(abs_fp):
                    syms.append(s)
            except Exception:
                continue
        if not syms:
            return None
        a0, b0 = int(a), int(b)
        # Overlap first
        overlaps = [s for s in syms if not (int(s.end_line) < a0 or int(s.line) > b0)]
        if overlaps:
            s = min(overlaps, key=lambda z: (int(z.end_line) - int(z.line)))
            return int(s.line), int(s.end_line)
        # Nearest by midpoint distance
        mid = (a0 + b0) // 2
        s = min(syms, key=lambda z: min(abs(mid - int(z.line)), abs(mid - int(z.end_line))))
        return int(s.line), int(s.end_line)
    except Exception:
        return None

def repair_citations(text: str, g: CodeGraph, files_scope: List[str]) -> List[tuple[str, int, int]]:
    """Map cited path:a-b to nearest anchored symbol spans within scope, de-dup and validate."""
    cites = normalize_citations(text or "")
    if not cites:
        return []
    repaired: List[tuple[str, int, int]] = []
    for (p_raw, a, b) in cites:
        try:
            rel = _file_from_citation_path(g, files_scope, p_raw)
            if not rel:
                continue
            span = _nearest_symbol_span_for_line(g, rel, a, b)
            if not span:
                # Keep original range if no symbol found
                repaired.append((rel, min(int(a), int(b)), max(int(a), int(b))))
                continue
            sa, sb = span
            repaired.append((rel, int(sa), int(sb)))
        except Exception:
            continue
    # Validate and merge
    repaired = validate_spans(repaired, g)
    # De-dup
    seen = set()
    uniq = []
    for r in repaired:
        if r not in seen:
            seen.add(r)
            uniq.append(r)
    return _merge_overlapping_spans(uniq)


# question-aware selection, zoom-by-symbol, function-first candidate collection, name-match helpers
import os
import re
from typing import Dict, List, Optional, Tuple

from ..code_graph import CodeGraph

def _re_escape(s: str) -> str:
    try:
        import re as _re
        return _re.escape(s)
    except Exception:
        return s

def modules_from_symbols(repo_root: str, seeds: List[str], *, radius: int = 1, top_k: int = 8, ignore: Optional[List[str]] = None) -> Tuple[List[str], List[str]]:
    g = CodeGraph.load_or_build(repo_root, ignore=[s for s in (ignore or []) if s])
    modules_set: Dict[str, bool] = {}
    files_set: Dict[str, bool] = {}
    for s in seeds:
        try:
            for sym in g.find_symbol(s):
                modules_set[sym.module] = True
                files_set[os.path.relpath(sym.file, g.root)] = True
        except Exception:
            continue
    if radius > 0 and modules_set:
        cur = list(modules_set.keys())
        seen = set(cur)
        for _ in range(max(0, int(radius))):
            nxt: List[str] = []
            for m in cur:
                for dep in g.module_imports.get(m, []):
                    if dep not in seen:
                        seen.add(dep)
                        nxt.append(dep)
            cur = nxt
        for m in seen:
            modules_set[m] = True
            f = g.file_for_module(m)
            if f:
                files_set[os.path.relpath(f, g.root)] = True
    modules = sorted(modules_set.keys())[: top_k]
    files = sorted(files_set.keys())[: max(top_k, 8)]
    return modules, files

def question_aware_modules_and_files(repo_root: str, prompt: str, *, top_k: int = 8, ignore: Optional[List[str]] = None) -> Tuple[List[str], List[str]]:
    g = CodeGraph.load_or_build(repo_root, ignore=[s for s in (ignore or []) if s])
    try:
        toks = [t for t in re.findall(r"[A-Za-z0-9_]+", (prompt or "").lower()) if len(t) >= 3]
    except Exception:
        toks = []
    score_by_file: Dict[str, int] = {}
    for t in toks[:10]:
        try:
            for (rel, _ln, _txt) in g.search_refs(_re_escape(t)):
                score_by_file[rel] = score_by_file.get(rel, 0) + 1
        except Exception:
            continue
    ranked_files = [fp for fp, _ in sorted(score_by_file.items(), key=lambda x: x[1], reverse=True)]
    files = ranked_files[: max(top_k, 8)]
    modules_set: Dict[str, bool] = {}
    for f in files:
        m = g.module_for_file(f)
        if m:
            modules_set[m] = True
    modules = sorted(modules_set.keys())[: top_k]
    return modules, files

def name_matches_prompt(name: Optional[str], prompt_tokens: set) -> bool:
    if not name:
        return False
    n = name.lower()
    if n in " ".join(sorted(prompt_tokens)):
        return True
    parts = [p for p in n.replace("_", " ").split() if p]
    if not parts:
        return False
    inter = sum(1 for p in parts if p in prompt_tokens)
    need = 2 if len(parts) >= 2 else 1
    cat = "".join(parts)
    return bool(inter >= need or cat in prompt_tokens)

def prompt_token_set(prompt_q: str) -> set:
    try:
        import re as _re
        toks = _re.findall(r"[A-Za-z0-9_]+", (prompt_q or "").lower())
        return set(toks)
    except Exception:
        return set()

def _signature_tokens(sig: Optional[str]) -> List[str]:
    if not sig:
        return []
    try:
        toks = [t for t in re.findall(r"[A-Za-z0-9_]+", sig) if len(t) >= 2]
        return toks
    except Exception:
        return []

def _doc_head_tokens(doc: Optional[str]) -> List[str]:
    if not doc:
        return []
    try:
        head = (doc or "").splitlines()[0].lower()[:200]
        return [t for t in re.findall(r"[a-z0-9_]+", head) if len(t) >= 3]
    except Exception:
        return []

def _load_self_queries(path: Optional[str]) -> Dict[str, List[str]]:
    out: Dict[str, List[str]] = {}
    if not path:
        return out
    try:
        import json
        with open(path, "r", encoding="utf-8") as fh:
            for ln in fh:
                try:
                    obj = json.loads(ln)
                    sym = str(obj.get("symbol", ""))
                    qs = [str(q) for q in (obj.get("queries") or [])]
                    if sym and qs:
                        out[sym] = qs[:5]
                except Exception:
                    continue
    except Exception:
        return {}
    return out

def rerank_modules_and_files(
    repo_root: str,
    prompt: str,
    modules: List[str],
    files: List[str],
    *,
    ignore: Optional[List[str]] = None,
    self_queries_path: Optional[str] = None,
    weights: Tuple[float, float, float, float, float] = (0.35, 0.25, 0.15, 0.15, 0.10),
) -> Tuple[List[str], List[str]]:
    """Hybrid reranker over initial module/file candidates.

    weights: (w_sig, w_call, w_cov, w_doc, w_vis)
    """
    g = CodeGraph.load_or_build(repo_root, ignore=[s for s in (ignore or []) if s])
    p_tokens = prompt_token_set(prompt)
    w_sig, w_call, w_cov, w_doc, w_vis = [float(x) for x in weights]

    # Self-queries
    sq = _load_self_queries(self_queries_path)
    sq_tokens_by_sym: Dict[str, set] = {}
    for sym, qs in sq.items():
        toks = set()
        for q in qs:
            toks.update(prompt_token_set(q))
        sq_tokens_by_sym[sym] = toks

    # Pre-compute per-module features
    sig_by_mod: Dict[str, set] = {}
    doc_by_mod: Dict[str, set] = {}
    cov_by_mod: Dict[str, int] = {}
    call_neighbors: Dict[str, set] = {}
    vis_by_mod: Dict[str, float] = {}

    for fqn, s in g.symbols_by_fqn.items():
        m = s.module
        sig_by_mod.setdefault(m, set()).update(_signature_tokens(s.signature))
        doc_by_mod.setdefault(m, set()).update(_doc_head_tokens(s.doc))
        vis_by_mod[m] = 1.0 if (not m.endswith(".__init__") and not m.endswith(".tests")) else 0.8
    # coverage proxy: pytest nodes count
    for m, nodes in g.pytest_nodes_by_module.items():
        cov_by_mod[m] = len(nodes or [])
    # call neighborhoods
    for caller, callee in g.calls:
        cm = caller.rsplit(".", 1)[0] if "." in caller else caller
        call_neighbors.setdefault(cm, set()).add(callee)

    def _module_score(m: str) -> float:
        sig = sig_by_mod.get(m, set())
        doc = doc_by_mod.get(m, set())
        sig_overlap = len(sig & p_tokens) / float(max(1, len(sig))) if sig else 0.0
        doc_overlap = len(doc & p_tokens) / float(max(1, len(doc))) if doc else 0.0
        call_compat = float(len(call_neighbors.get(m, set()))) / 25.0  # crude proxy, cap later
        cov = float(min(1.0, (cov_by_mod.get(m, 0) / 5.0)))
        vis = float(vis_by_mod.get(m, 1.0))
        # self-query boost if any symbol under module matches prompt tokens
        sq_boost = 0.0
        for fqn in g.defs_in(m):
            toks = sq_tokens_by_sym.get(fqn)
            if toks and (toks & p_tokens):
                sq_boost = max(sq_boost, 0.15)
                break
        score = (
            w_sig * sig_overlap + w_call * min(1.0, call_compat) + w_cov * cov + w_doc * doc_overlap + w_vis * vis + sq_boost
        )
        return float(max(0.0, min(2.0, score)))

    mod_scored = [(m, _module_score(m)) for m in modules]
    mod_scored.sort(key=lambda x: x[1], reverse=True)
    modules_new = [m for m, _s in mod_scored][: max(1, len(modules))]

    # File scores inherit module score; break ties by filename token overlap
    file_scored: List[Tuple[str, float]] = []
    for f in files:
        m = g.module_for_file(f)
        base = 0.0
        if m:
            base = next((s for (mm, s) in mod_scored if mm == m), 0.0)
        fn_toks = set([t for t in re.findall(r"[a-z0-9_]+", os.path.basename(f).lower()) if len(t) >= 3])
        fn_overlap = len(fn_toks & p_tokens) / float(max(1, len(fn_toks))) if fn_toks else 0.0
        file_scored.append((f, float(base + 0.05 * fn_overlap)))
    file_scored.sort(key=lambda x: x[1], reverse=True)
    files_new = [f for f, _s in file_scored][: max(1, len(files))]

    return modules_new, files_new


# question-aware selection, zoom-by-symbol, function-first candidate collection, name-match helpers
import os
import re
from typing import Dict, List, Optional, Tuple

from ..code_graph import CodeGraph

def _re_escape(s: str) -> str:
    try:
        import re as _re
        return _re.escape(s)
    except Exception:
        return s

def modules_from_symbols(repo_root: str, seeds: List[str], *, radius: int = 1, top_k: int = 8, ignore: Optional[List[str]] = None) -> Tuple[List[str], List[str]]:
    g = CodeGraph.load_or_build(repo_root, ignore=[s for s in (ignore or []) if s])
    modules_set: Dict[str, bool] = {}
    files_set: Dict[str, bool] = {}
    for s in seeds:
        try:
            for sym in g.find_symbol(s):
                modules_set[sym.module] = True
                files_set[os.path.relpath(sym.file, g.root)] = True
        except Exception:
            continue
    if radius > 0 and modules_set:
        cur = list(modules_set.keys())
        seen = set(cur)
        for _ in range(max(0, int(radius))):
            nxt: List[str] = []
            for m in cur:
                for dep in g.module_imports.get(m, []):


# question-aware selection, zoom-by-symbol, function-first candidate collection, name-match helpers
import os
import re
from typing import Dict, List, Optional, Tuple

from ..code_graph import CodeGraph

def _re_escape(s: str) -> str:
    try:
        import re as _re
        return _re.escape(s)
    except Exception:
        return s

def modules_from_symbols(repo_root: str, seeds: List[str], *, radius: int = 1, top_k: int = 8, ignore: Optional[List[str]] = None) -> Tuple[List[str], List[str]]:
    g = CodeGraph.load_or_build(repo_root, ignore=[s for s in (ignore or []) if s])
    modules_set: Dict[str, bool] = {}
    files_set: Dict[str, bool] = {}
    for s in seeds:
        try:
            for sym in g.find_symbol(s):
                modules_set[sym.module] = True
                files_set[os.path.relpath(sym.file, g.root)] = True
        except Exception:
            continue
    if radius > 0 and modules_set:
        cur = list(modules_set.keys())
        seen = set(cur)
        for _ in range(max(0, int(radius))):
            nxt: List[str] = []
            for m in cur:
                for dep in g.module_imports.get(m, []):
                    if dep not in seen:
                        seen.add(dep)
                        nxt.append(dep)
            cur = nxt
        for m in seen:
            modules_set[m] = True
            f = g.file_for_module(m)
            if f:
                files_set[os.path.relpath(f, g.root)] = True
    modules = sorted(modules_set.keys())[: top_k]
    files = sorted(files_set.keys())[: max(top_k, 8)]
    return modules, files

def question_aware_modules_and_files(repo_root: str, prompt: str, *, top_k: int = 8, ignore: Optional[List[str]] = None) -> Tuple[List[str], List[str]]:
    g = CodeGraph.load_or_build(repo_root, ignore=[s for s in (ignore or []) if s])
    try:
        toks = [t for t in re.findall(r"[A-Za-z0-9_]+", (prompt or "").lower()) if len(t) >= 3]
    except Exception:
        toks = []
    score_by_file: Dict[str, int] = {}
    for t in toks[:10]:
        try:
            for (rel, _ln, _txt) in g.search_refs(_re_escape(t)):
                score_by_file[rel] = score_by_file.get(rel, 0) + 1
        except Exception:
            continue
    ranked_files = [fp for fp, _ in sorted(score_by_file.items(), key=lambda x: x[1], reverse=True)]
    files = ranked_files[: max(top_k, 8)]
    modules_set: Dict[str, bool] = {}
    for f in files:
        m = g.module_for_file(f)


        cur = list(modules_set.keys())
        seen = set(cur)
        for _ in range(max(0, int(radius))):
            nxt: List[str] = []
            for m in cur:
                for dep in g.module_imports.get(m, []):
                    if dep not in seen:
                        seen.add(dep)
                        nxt.append(dep)
            cur = nxt
        for m in seen:
            modules_set[m] = True
            f = g.file_for_module(m)
            if f:
                files_set[os.path.relpath(f, g.root)] = True
    modules = sorted(modules_set.keys())[: top_k]
    files = sorted(files_set.keys())[: max(top_k, 8)]
    return modules, files

def question_aware_modules_and_files(repo_root: str, prompt: str, *, top_k: int = 8, ignore: Optional[List[str]] = None) -> Tuple[List[str], List[str]]:
    g = CodeGraph.load_or_build(repo_root, ignore=[s for s in (ignore or []) if s])
    try:
        toks = [t for t in re.findall(r"[A-Za-z0-9_]+", (prompt or "").lower()) if len(t) >= 3]
    except Exception:
        toks = []
    score_by_file: Dict[str, int] = {}
    for t in toks[:10]:
        try:
            for (rel, _ln, _txt) in g.search_refs(_re_escape(t)):
                score_by_file[rel] = score_by_file.get(rel, 0) + 1
        except Exception:
            continue
    ranked_files = [fp for fp, _ in sorted(score_by_file.items(), key=lambda x: x[1], reverse=True)]
    files = ranked_files[: max(top_k, 8)]
    modules_set: Dict[str, bool] = {}
    for f in files:
        m = g.module_for_file(f)
        if m:
            modules_set[m] = True
    modules = sorted(modules_set.keys())[: top_k]
    return modules, files

def name_matches_prompt(name: Optional[str], prompt_tokens: set) -> bool:
    if not name:
        return False
    n = name.lower()
    if n in " ".join(sorted(prompt_tokens)):
        return True
    parts = [p for p in n.replace("_", " ").split() if p]
    if not parts:
        return False
    inter = sum(1 for p in parts if p in prompt_tokens)
    need = 2 if len(parts) >= 2 else 1
    cat = "".join(parts)
    return bool(inter >= need or cat in prompt_tokens)

def prompt_token_set(prompt_q: str) -> set:
    try:
        import re as _re


    except Exception:
        toks = []
    score_by_file: Dict[str, int] = {}
    for t in toks[:10]:
        try:
            for (rel, _ln, _txt) in g.search_refs(_re_escape(t)):
                score_by_file[rel] = score_by_file.get(rel, 0) + 1
        except Exception:
            continue
    ranked_files = [fp for fp, _ in sorted(score_by_file.items(), key=lambda x: x[1], reverse=True)]
    files = ranked_files[: max(top_k, 8)]
    modules_set: Dict[str, bool] = {}
    for f in files:
        m = g.module_for_file(f)
        if m:
            modules_set[m] = True
    modules = sorted(modules_set.keys())[: top_k]
    return modules, files

def name_matches_prompt(name: Optional[str], prompt_tokens: set) -> bool:
    if not name:
        return False
    n = name.lower()
    if n in " ".join(sorted(prompt_tokens)):
        return True
    parts = [p for p in n.replace("_", " ").split() if p]
    if not parts:
        return False
    inter = sum(1 for p in parts if p in prompt_tokens)
    need = 2 if len(parts) >= 2 else 1
    cat = "".join(parts)
    return bool(inter >= need or cat in prompt_tokens)

def prompt_token_set(prompt_q: str) -> set:
    try:
        import re as _re
        toks = _re.findall(r"[A-Za-z0-9_]+", (prompt_q or "").lower())
        return set(toks)
    except Exception:
        return set()

def _signature_tokens(sig: Optional[str]) -> List[str]:
    if not sig:
        return []
    try:
        toks = [t for t in re.findall(r"[A-Za-z0-9_]+", sig) if len(t) >= 2]
        return toks
    except Exception:
        return []


            modules_set[m] = True
    modules = sorted(modules_set.keys())[: top_k]
    return modules, files

def name_matches_prompt(name: Optional[str], prompt_tokens: set) -> bool:
    if not name:
        return False
    n = name.lower()
    if n in " ".join(sorted(prompt_tokens)):
        return True
    parts = [p for p in n.replace("_", " ").split() if p]
    if not parts:
        return False
    inter = sum(1 for p in parts if p in prompt_tokens)
    need = 2 if len(parts) >= 2 else 1
    cat = "".join(parts)
    return bool(inter >= need or cat in prompt_tokens)

def prompt_token_set(prompt_q: str) -> set:
    try:
        import re as _re
        toks = _re.findall(r"[A-Za-z0-9_]+", (prompt_q or "").lower())
        return set(toks)
    except Exception:
        return set()

def _signature_tokens(sig: Optional[str]) -> List[str]:
    if not sig:
        return []
    try:
        toks = [t for t in re.findall(r"[A-Za-z0-9_]+", sig) if len(t) >= 2]
        return toks
    except Exception:
        return []

def _doc_head_tokens(doc: Optional[str]) -> List[str]:
    if not doc:
        return []
    try:
        head = (doc or "").splitlines()[0].lower()[:200]
        return [t for t in re.findall(r"[a-z0-9_]+", head) if len(t) >= 3]
    except Exception:
        return []


    if n in " ".join(sorted(prompt_tokens)):
        return True
    parts = [p for p in n.replace("_", " ").split() if p]
    if not parts:
        return False
    inter = sum(1 for p in parts if p in prompt_tokens)
    need = 2 if len(parts) >= 2 else 1
    cat = "".join(parts)
    return bool(inter >= need or cat in prompt_tokens)

def prompt_token_set(prompt_q: str) -> set:
    try:
        import re as _re
        toks = _re.findall(r"[A-Za-z0-9_]+", (prompt_q or "").lower())
        return set(toks)
    except Exception:
        return set()

def _signature_tokens(sig: Optional[str]) -> List[str]:
    if not sig:
        return []
    try:
        toks = [t for t in re.findall(r"[A-Za-z0-9_]+", sig) if len(t) >= 2]
        return toks
    except Exception:
        return []

def _doc_head_tokens(doc: Optional[str]) -> List[str]:
    if not doc:
        return []
    try:
        head = (doc or "").splitlines()[0].lower()[:200]
        return [t for t in re.findall(r"[a-z0-9_]+", head) if len(t) >= 3]
    except Exception:
        return []

def _load_self_queries(path: Optional[str]) -> Dict[str, List[str]]:
    out: Dict[str, List[str]] = {}
    if not path:
        return out
    try:
        import json
        with open(path, "r", encoding="utf-8") as fh:
            for ln in fh:


def prompt_token_set(prompt_q: str) -> set:
    try:
        import re as _re
        toks = _re.findall(r"[A-Za-z0-9_]+", (prompt_q or "").lower())
        return set(toks)
    except Exception:
        return set()

def _signature_tokens(sig: Optional[str]) -> List[str]:
    if not sig:
        return []
    try:
        toks = [t for t in re.findall(r"[A-Za-z0-9_]+", sig) if len(t) >= 2]
        return toks
    except Exception:
        return []

def _doc_head_tokens(doc: Optional[str]) -> List[str]:
    if not doc:
        return []
    try:
        head = (doc or "").splitlines()[0].lower()[:200]
        return [t for t in re.findall(r"[a-z0-9_]+", head) if len(t) >= 3]
    except Exception:
        return []

def _load_self_queries(path: Optional[str]) -> Dict[str, List[str]]:
    out: Dict[str, List[str]] = {}
    if not path:
        return out
    try:
        import json
        with open(path, "r", encoding="utf-8") as fh:
            for ln in fh:
                try:
                    obj = json.loads(ln)
                    sym = str(obj.get("symbol", ""))
                    qs = [str(q) for q in (obj.get("queries") or [])]
                    if sym and qs:
                        out[sym] = qs[:5]
                except Exception:
                    continue
    except Exception:
        return {}


def _signature_tokens(sig: Optional[str]) -> List[str]:
    if not sig:
        return []
    try:
        toks = [t for t in re.findall(r"[A-Za-z0-9_]+", sig) if len(t) >= 2]
        return toks
    except Exception:
        return []

def _doc_head_tokens(doc: Optional[str]) -> List[str]:
    if not doc:
        return []
    try:
        head = (doc or "").splitlines()[0].lower()[:200]
        return [t for t in re.findall(r"[a-z0-9_]+", head) if len(t) >= 3]
    except Exception:
        return []

def _load_self_queries(path: Optional[str]) -> Dict[str, List[str]]:
    out: Dict[str, List[str]] = {}
    if not path:
        return out
    try:
        import json
        with open(path, "r", encoding="utf-8") as fh:
            for ln in fh:
                try:
                    obj = json.loads(ln)
                    sym = str(obj.get("symbol", ""))
                    qs = [str(q) for q in (obj.get("queries") or [])]
                    if sym and qs:
                        out[sym] = qs[:5]
                except Exception:
                    continue
    except Exception:
        return {}
    return out

def rerank_modules_and_files(
    repo_root: str,
    prompt: str,
    modules: List[str],
    files: List[str],
    *,
    ignore: Optional[List[str]] = None,
    self_queries_path: Optional[str] = None,
    weights: Tuple[float, float, float, float, float] = (0.35, 0.25, 0.15, 0.15, 0.10),
) -> Tuple[List[str], List[str]]:
    """Hybrid reranker over initial module/file candidates.

    weights: (w_sig, w_call, w_cov, w_doc, w_vis)
    """
    g = CodeGraph.load_or_build(repo_root, ignore=[s for s in (ignore or []) if s])
    p_tokens = prompt_token_set(prompt)
    w_sig, w_call, w_cov, w_doc, w_vis = [float(x) for x in weights]


    out: Dict[str, List[str]] = {}
    if not path:
        return out
    try:
        import json
        with open(path, "r", encoding="utf-8") as fh:
            for ln in fh:
                try:
                    obj = json.loads(ln)
                    sym = str(obj.get("symbol", ""))
                    qs = [str(q) for q in (obj.get("queries") or [])]
                    if sym and qs:
                        out[sym] = qs[:5]
                except Exception:
                    continue
    except Exception:
        return {}
    return out

def rerank_modules_and_files(
    repo_root: str,
    prompt: str,
    modules: List[str],
    files: List[str],
    *,
    ignore: Optional[List[str]] = None,
    self_queries_path: Optional[str] = None,
    weights: Tuple[float, float, float, float, float] = (0.35, 0.25, 0.15, 0.15, 0.10),
) -> Tuple[List[str], List[str]]:
    """Hybrid reranker over initial module/file candidates.

    weights: (w_sig, w_call, w_cov, w_doc, w_vis)
    """
    g = CodeGraph.load_or_build(repo_root, ignore=[s for s in (ignore or []) if s])
    p_tokens = prompt_token_set(prompt)
    w_sig, w_call, w_cov, w_doc, w_vis = [float(x) for x in weights]

    # Self-queries
    sq = _load_self_queries(self_queries_path)
    sq_tokens_by_sym: Dict[str, set] = {}
    for sym, qs in sq.items():
        toks = set()
        for q in qs:
            toks.update(prompt_token_set(q))
        sq_tokens_by_sym[sym] = toks

    # Pre-compute per-module features
    sig_by_mod: Dict[str, set] = {}
    doc_by_mod: Dict[str, set] = {}
    cov_by_mod: Dict[str, int] = {}
    call_neighbors: Dict[str, set] = {}
    vis_by_mod: Dict[str, float] = {}

    for fqn, s in g.symbols_by_fqn.items():
        m = s.module
        sig_by_mod.setdefault(m, set()).update(_signature_tokens(s.signature))
        doc_by_mod.setdefault(m, set()).update(_doc_head_tokens(s.doc))
        vis_by_mod[m] = 1.0 if (not m.endswith(".__init__") and not m.endswith(".tests")) else 0.8
    # coverage proxy: pytest nodes count
    for m, nodes in g.pytest_nodes_by_module.items():
        cov_by_mod[m] = len(nodes or [])
    # call neighborhoods
    for caller, callee in g.calls:
        cm = caller.rsplit(".", 1)[0] if "." in caller else caller
        call_neighbors.setdefault(cm, set()).add(callee)

    def _module_score(m: str) -> float:
        sig = sig_by_mod.get(m, set())
        doc = doc_by_mod.get(m, set())
        sig_overlap = len(sig & p_tokens) / float(max(1, len(sig))) if sig else 0.0
        doc_overlap = len(doc & p_tokens) / float(max(1, len(doc))) if doc else 0.0
        call_compat = float(len(call_neighbors.get(m, set()))) / 25.0  # crude proxy, cap later
        cov = float(min(1.0, (cov_by_mod.get(m, 0) / 5.0)))
        vis = float(vis_by_mod.get(m, 1.0))
        # self-query boost if any symbol under module matches prompt tokens
        sq_boost = 0.0
        for fqn in g.defs_in(m):
            toks = sq_tokens_by_sym.get(fqn)
            if toks and (toks & p_tokens):
                sq_boost = max(sq_boost, 0.15)
                break
        score = (
            w_sig * sig_overlap + w_call * min(1.0, call_compat) + w_cov * cov + w_doc * doc_overlap + w_vis * vis + sq_boost
        )
        return float(max(0.0, min(2.0, score)))

    mod_scored = [(m, _module_score(m)) for m in modules]
    mod_scored.sort(key=lambda x: x[1], reverse=True)
    modules_new = [m for m, _s in mod_scored][: max(1, len(modules))]

    # File scores inherit module score; break ties by filename token overlap
    file_scored: List[Tuple[str, float]] = []
    for f in files:
        m = g.module_for_file(f)
        base = 0.0
        if m:
            base = next((s for (mm, s) in mod_scored if mm == m), 0.0)
        fn_toks = set([t for t in re.findall(r"[a-z0-9_]+", os.path.basename(f).lower()) if len(t) >= 3])
        fn_overlap = len(fn_toks & p_tokens) / float(max(1, len(fn_toks))) if fn_toks else 0.0
        file_scored.append((f, float(base + 0.05 * fn_overlap)))
    file_scored.sort(key=lambda x: x[1], reverse=True)
    files_new = [f for f, _s in file_scored][: max(1, len(files))]

    return modules_new, files_new


    # Pre-compute per-module features
    sig_by_mod: Dict[str, set] = {}
    doc_by_mod: Dict[str, set] = {}
    cov_by_mod: Dict[str, int] = {}
    call_neighbors: Dict[str, set] = {}
    vis_by_mod: Dict[str, float] = {}

    for fqn, s in g.symbols_by_fqn.items():
        m = s.module
        sig_by_mod.setdefault(m, set()).update(_signature_tokens(s.signature))
        doc_by_mod.setdefault(m, set()).update(_doc_head_tokens(s.doc))
        vis_by_mod[m] = 1.0 if (not m.endswith(".__init__") and not m.endswith(".tests")) else 0.8
    # coverage proxy: pytest nodes count
    for m, nodes in g.pytest_nodes_by_module.items():
        cov_by_mod[m] = len(nodes or [])
    # call neighborhoods
    for caller, callee in g.calls:
        cm = caller.rsplit(".", 1)[0] if "." in caller else caller
        call_neighbors.setdefault(cm, set()).add(callee)

    def _module_score(m: str) -> float:
        sig = sig_by_mod.get(m, set())
        doc = doc_by_mod.get(m, set())
        sig_overlap = len(sig & p_tokens) / float(max(1, len(sig))) if sig else 0.0
        doc_overlap = len(doc & p_tokens) / float(max(1, len(doc))) if doc else 0.0
        call_compat = float(len(call_neighbors.get(m, set()))) / 25.0  # crude proxy, cap later
        cov = float(min(1.0, (cov_by_mod.get(m, 0) / 5.0)))
        vis = float(vis_by_mod.get(m, 1.0))
        # self-query boost if any symbol under module matches prompt tokens
        sq_boost = 0.0
        for fqn in g.defs_in(m):
            toks = sq_tokens_by_sym.get(fqn)
            if toks and (toks & p_tokens):
                sq_boost = max(sq_boost, 0.15)
                break
        score = (
            w_sig * sig_overlap + w_call * min(1.0, call_compat) + w_cov * cov + w_doc * doc_overlap + w_vis * vis + sq_boost
        )
        return float(max(0.0, min(2.0, score)))

    mod_scored = [(m, _module_score(m)) for m in modules]
    mod_scored.sort(key=lambda x: x[1], reverse=True)
    modules_new = [m for m, _s in mod_scored][: max(1, len(modules))]

    # File scores inherit module score; break ties by filename token overlap
    file_scored: List[Tuple[str, float]] = []
    for f in files:
        m = g.module_for_file(f)
        base = 0.0
        if m:
            base = next((s for (mm, s) in mod_scored if mm == m), 0.0)
        fn_toks = set([t for t in re.findall(r"[a-z0-9_]+", os.path.basename(f).lower()) if len(t) >= 3])
        fn_overlap = len(fn_toks & p_tokens) / float(max(1, len(fn_toks))) if fn_toks else 0.0
        file_scored.append((f, float(base + 0.05 * fn_overlap)))
    file_scored.sort(key=lambda x: x[1], reverse=True)
    files_new = [f for f, _s in file_scored][: max(1, len(files))]

    return modules_new, files_new