From f2191119a18e817122d43caec8fd20774ed87689 Mon Sep 17 00:00:00 2001 From: Sebastiangmz Date: Tue, 30 Jun 2026 15:32:33 -0600 Subject: [PATCH 1/4] test: add Milestone 5 code graph contract Spec-Slice: CRAG-20260630-M5-CODE-INTELLIGENCE-GRAPH --- ...-30-m5-code-intelligence-graph-contract.md | 40 ++++ tests/test_m5_code_graph.py | 171 ++++++++++++++++++ 2 files changed, 211 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-30-m5-code-intelligence-graph-contract.md create mode 100644 tests/test_m5_code_graph.py diff --git a/docs/superpowers/plans/2026-06-30-m5-code-intelligence-graph-contract.md b/docs/superpowers/plans/2026-06-30-m5-code-intelligence-graph-contract.md new file mode 100644 index 0000000..7cc1368 --- /dev/null +++ b/docs/superpowers/plans/2026-06-30-m5-code-intelligence-graph-contract.md @@ -0,0 +1,40 @@ +# CodeRAG Milestone 5: Code Intelligence Graph Contract + +Spec-Slice: CRAG-20260630-M5-CODE-INTELLIGENCE-GRAPH + +## Scope + +Milestone 5 adds a deterministic, local code-intelligence graph that can answer graph questions without LLM generation. + +## Acceptance + +1. Extract symbol graph data for Python and TypeScript/JavaScript documents. +2. Persist graph facts in the SQLite registry, keyed by repository and file path. +3. Support incremental replacement by file path so changed files do not require a full graph rebuild. +4. Expose service, REST, MCP, and CLI paths for: + - `find_symbol` + - `find_references` + - `get_blast_radius` +5. Return explicit capability flags when a language is unsupported and falls back to text retrieval only. +6. Include graph evidence in blast radius: impacted files, symbols, direct edges, associated tests, and reason strings. + +## Required tests + +- Parser fixtures for Python and TS/JS symbols/imports/calls/references. +- Registry persistence and per-file replacement tests. +- Service tests for symbol lookup, references, blast radius, and unsupported-language capability fallback. +- REST/MCP/CLI adapter shape tests. +- EvalFly smoke cases for graph model, service, and adapter exposure. + +## Verification + +Run before closing the slice: + +```bash +PYTHONPATH=src .venv/bin/python -m pytest tests/test_m0_baseline.py tests/test_m1_services.py tests/test_m2_registry.py tests/test_citations.py tests/test_m3_citations.py tests/test_api_routes.py tests/test_mcp_handlers.py tests/test_mcp_tools.py tests/test_ui_handlers.py tests/test_m4_hybrid_retrieval.py tests/test_m5_code_graph.py +.venv/bin/ruff check src/coderag/generation src/coderag/indexing/vectorstore.py src/coderag/models src/coderag/retrieval src/coderag/services src/coderag/config.py src/coderag/api/schemas.py src/coderag/api/routes.py src/coderag/mcp/handlers.py src/coderag/mcp/tools.py src/coderag/cli.py src/coderag/ui/grounding.py src/coderag/ui/repository_metadata.py src/coderag/ui/handlers.py tests/test_m0_baseline.py tests/test_m1_services.py tests/test_m2_registry.py tests/test_citations.py tests/test_m3_citations.py tests/test_api_routes.py tests/test_mcp_handlers.py tests/test_mcp_tools.py tests/test_ui_handlers.py tests/test_m4_hybrid_retrieval.py tests/test_m5_code_graph.py scripts/evalfly_smoke.py +PYTHONPATH=src .venv/bin/mypy src/coderag/generation src/coderag/indexing/vectorstore.py src/coderag/models src/coderag/retrieval src/coderag/services src/coderag/config.py src/coderag/api/schemas.py src/coderag/api/routes.py src/coderag/mcp/handlers.py src/coderag/ui/grounding.py src/coderag/ui/repository_metadata.py src/coderag/ui/handlers.py tests/test_m0_baseline.py tests/test_m1_services.py tests/test_m2_registry.py tests/test_citations.py tests/test_m3_citations.py tests/test_api_routes.py tests/test_mcp_handlers.py tests/test_ui_handlers.py tests/test_m4_hybrid_retrieval.py tests/test_m5_code_graph.py scripts/evalfly_smoke.py +python -m py_compile src/coderag/__init__.py src/coderag/config.py src/coderag/main.py src/coderag/cli.py src/coderag/indexing/embeddings.py src/coderag/indexing/vectorstore.py src/coderag/generation/*.py src/coderag/models/*.py src/coderag/retrieval/*.py src/coderag/services/*.py src/coderag/api/schemas.py src/coderag/api/routes.py src/coderag/mcp/handlers.py src/coderag/mcp/tools.py src/coderag/ui/grounding.py src/coderag/ui/repository_metadata.py src/coderag/ui/handlers.py tests/test_m0_baseline.py tests/test_m1_services.py tests/test_m2_registry.py tests/test_citations.py tests/test_m3_citations.py tests/test_api_routes.py tests/test_ui_handlers.py tests/test_m4_hybrid_retrieval.py tests/test_m5_code_graph.py scripts/evalfly_smoke.py +python scripts/evalfly_smoke.py --suite smoke +.venv/bin/python -m build +``` diff --git a/tests/test_m5_code_graph.py b/tests/test_m5_code_graph.py new file mode 100644 index 0000000..ca55523 --- /dev/null +++ b/tests/test_m5_code_graph.py @@ -0,0 +1,171 @@ +"""Milestone 5 code intelligence graph tests.""" +# mypy: disable-error-code="no-untyped-def,no-untyped-call,union-attr" + +import json +from types import SimpleNamespace +from typing import Any, cast + +from click.testing import CliRunner + +from coderag.models.document import Document, DocumentMetadata +from coderag.models.repository import Repository, RepositoryStatus +from coderag.services.registry import RepositoryRegistry + + +def document(content: str, file_path: str, language: str, repo_id: str = "repo-1") -> Document: + return Document( + content=content, + metadata=DocumentMetadata(file_path=file_path, language=language, line_count=len(content.splitlines())), + repo_id=repo_id, + ) + + +def test_graph_extractor_extracts_python_and_typescript_symbols_edges_and_capabilities(): + from coderag.graph.extractor import CodeGraphExtractor + + extractor = CodeGraphExtractor() + python_graph = extractor.extract( + document( + "import os\nfrom auth.tokens import validate_token\n\nclass AuthService:\n def login(self, token):\n return validate_token(token)\n\ndef helper():\n return AuthService()\n", + "src/auth.py", + "python", + ) + ) + ts_graph = extractor.extract( + document( + "import { validateToken } from './auth'\nexport class LoginController {\n login(token: string) {\n return validateToken(token)\n }\n}\nexport function helper() { return new LoginController() }\n", + "src/login.ts", + "typescript", + ) + ) + unsupported = extractor.extract(document("body { color: red }", "src/style.css", "css")) + + assert {symbol.name for symbol in python_graph.symbols} >= {"AuthService", "login", "helper"} + assert {symbol.name for symbol in ts_graph.symbols} >= {"LoginController", "login", "helper"} + assert any(edge.edge_type == "import" and edge.target_name == "validate_token" for edge in python_graph.edges) + assert any(edge.edge_type == "call" and edge.target_name == "validateToken" for edge in ts_graph.edges) + assert unsupported.capabilities["graph_supported"] is False + assert unsupported.capabilities["fallback"] == "text" + + +def test_registry_persists_and_replaces_graph_file(tmp_path): + from coderag.graph.extractor import CodeGraphExtractor + + registry = RepositoryRegistry(db_path=tmp_path / "registry.sqlite3") + registry.add(Repository(id="repo-1", url="https://github.com/acme/demo", status=RepositoryStatus.READY)) + extractor = CodeGraphExtractor() + first = extractor.extract(document("def login():\n return True\n", "src/auth.py", "python")) + second = extractor.extract(document("def logout():\n return True\n", "src/auth.py", "python")) + + registry.replace_graph_file("repo-1", "src/auth.py", first) + assert [symbol.name for symbol in registry.find_graph_symbols("repo-1", "login")] == ["login"] + + registry.replace_graph_file("repo-1", "src/auth.py", second) + assert registry.find_graph_symbols("repo-1", "login") == [] + assert [symbol.name for symbol in registry.find_graph_symbols("repo-1", "logout")] == ["logout"] + + +def test_graph_service_finds_symbols_references_and_blast_radius(tmp_path): + from coderag.graph.extractor import CodeGraphExtractor + from coderag.services.graph import CodeGraphService + + registry = RepositoryRegistry(db_path=tmp_path / "registry.sqlite3") + registry.add(Repository(id="repo-1", url="https://github.com/acme/demo", status=RepositoryStatus.READY)) + extractor = CodeGraphExtractor() + registry.replace_graph_file( + "repo-1", + "src/auth.py", + extractor.extract(document("def validate_token(token):\n return bool(token)\n", "src/auth.py", "python")), + ) + registry.replace_graph_file( + "repo-1", + "src/api.py", + extractor.extract( + document( + "from auth import validate_token\n\ndef login(token):\n return validate_token(token)\n", + "src/api.py", + "python", + ) + ), + ) + registry.replace_graph_file( + "repo-1", + "tests/test_auth.py", + extractor.extract( + document( + "from src.auth import validate_token\n\ndef test_validate_token():\n assert validate_token('x')\n", + "tests/test_auth.py", + "python", + ) + ), + ) + service = CodeGraphService(registry=registry) + + symbols = service.find_symbol("repo-1", "validate_token") + references = service.find_references("repo-1", "validate_token") + blast = service.get_blast_radius("repo-1", "validate_token") + + assert symbols[0].file_path == "src/auth.py" + assert {reference.file_path for reference in references} >= {"src/api.py", "tests/test_auth.py"} + assert blast.symbol == "validate_token" + assert "src/api.py" in blast.impacted_files + assert "tests/test_auth.py" in blast.associated_tests + assert any("call" in reason or "import" in reason for reason in blast.reasons) + + +def test_graph_adapters_return_json_shapes(tmp_path, monkeypatch): + from coderag.api import routes + from coderag.api.schemas import GraphQueryRequest + from coderag.cli import cli + from coderag.mcp.handlers import MCPHandlers + from coderag.models.graph import BlastRadius, CodeReference, CodeSymbol + + symbol = CodeSymbol(repo_id="repo-1", file_path="src/auth.py", name="validate_token", kind="function", language="python", start_line=1, end_line=2) + reference = CodeReference(repo_id="repo-1", file_path="src/api.py", symbol_name="validate_token", reference_kind="call", start_line=4, end_line=4) + blast = BlastRadius( + repo_id="repo-1", + symbol="validate_token", + impacted_files=["src/api.py"], + impacted_symbols=["login"], + associated_tests=["tests/test_auth.py"], + edges=[{"edge_type": "call", "source": "login", "target": "validate_token"}], + reasons=["src/api.py calls validate_token"], + capabilities={"graph_supported": True}, + ) + + class FakeGraphService: + def find_symbol(self, _repo_id: str, _symbol_name: str): + return [symbol] + + def find_references(self, _repo_id: str, _symbol_name: str): + return [reference] + + def get_blast_radius(self, _repo_id: str, _symbol_name: str): + return blast + + registry = RepositoryRegistry(db_path=tmp_path / "registry.sqlite3") + registry.add(Repository(id="repo-1", url="https://github.com/acme/demo", status=RepositoryStatus.READY)) + fake_graph_service = FakeGraphService() + monkeypatch.setattr(routes, "registry", registry) + monkeypatch.setattr(routes, "graph_service", fake_graph_service) + + api_response = routes.find_symbol(GraphQueryRequest(repo_id="repo-1", symbol="validate_token")) + mcp = MCPHandlers(registry=registry, graph_service=cast("Any", fake_graph_service)) + mcp_result = mcp.find_symbol("repo-1", "validate_token") + + assert api_response.symbols[0].name == "validate_token" + assert mcp_result["symbols"][0]["file_path"] == "src/auth.py" + assert json.dumps(mcp.get_blast_radius("repo-1", "validate_token")) + + async def get_blast_radius(**_kwargs): + return blast.to_dict() + + monkeypatch.setitem( + __import__("sys").modules, + "coderag.mcp.handlers", + SimpleNamespace(get_mcp_handlers=lambda: SimpleNamespace(get_blast_radius=get_blast_radius)), + ) + result = CliRunner().invoke(cli, ["blast-radius", "repo-1", "validate_token", "--format", "json"]) + + assert result.exit_code == 0 + assert "src/api.py" in result.output From 44bbea28440a1b4419c39ae7a86b60cf82039d8f Mon Sep 17 00:00:00 2001 From: Sebastiangmz Date: Tue, 30 Jun 2026 15:32:38 -0600 Subject: [PATCH 2/4] feat: add persisted code graph service Spec-Slice: CRAG-20260630-M5-CODE-INTELLIGENCE-GRAPH --- src/coderag/graph/__init__.py | 5 ++ src/coderag/graph/extractor.py | 133 +++++++++++++++++++++++++++++++ src/coderag/models/__init__.py | 6 ++ src/coderag/models/graph.py | 119 +++++++++++++++++++++++++++ src/coderag/services/graph.py | 56 +++++++++++++ src/coderag/services/indexing.py | 8 ++ src/coderag/services/registry.py | 123 ++++++++++++++++++++++++++++ 7 files changed, 450 insertions(+) create mode 100644 src/coderag/graph/__init__.py create mode 100644 src/coderag/graph/extractor.py create mode 100644 src/coderag/models/graph.py create mode 100644 src/coderag/services/graph.py diff --git a/src/coderag/graph/__init__.py b/src/coderag/graph/__init__.py new file mode 100644 index 0000000..260468d --- /dev/null +++ b/src/coderag/graph/__init__.py @@ -0,0 +1,5 @@ +"""Code intelligence graph extraction.""" + +from coderag.graph.extractor import CodeGraphExtractor + +__all__ = ["CodeGraphExtractor"] diff --git a/src/coderag/graph/extractor.py b/src/coderag/graph/extractor.py new file mode 100644 index 0000000..83e5dac --- /dev/null +++ b/src/coderag/graph/extractor.py @@ -0,0 +1,133 @@ +"""Lightweight code graph extraction for supported source languages.""" + +from __future__ import annotations + +import re + +from coderag.models.document import Document +from coderag.models.graph import CodeEdge, CodeSymbol, FileGraph + +_PY_SYMBOL_RE = re.compile(r"^(?P\s*)(?Pclass|def|async\s+def)\s+(?P[A-Za-z_][A-Za-z0-9_]*)") +_PY_IMPORT_RE = re.compile(r"^(?:from\s+[A-Za-z0-9_.]+\s+import\s+(?P.+)|import\s+(?P.+))") +_TS_CLASS_RE = re.compile(r"^(?:export\s+)?class\s+(?P[A-Za-z_$][A-Za-z0-9_$]*)") +_TS_FUNCTION_RE = re.compile(r"^(?:export\s+)?(?:async\s+)?function\s+(?P[A-Za-z_$][A-Za-z0-9_$]*)") +_TS_METHOD_RE = re.compile(r"^(?:async\s+)?(?P[A-Za-z_$][A-Za-z0-9_$]*)\s*\([^)]*\)\s*[:A-Za-z0-9_<>,\s|]*\{") +_TS_IMPORT_RE = re.compile(r"^import\s+(?:\{(?P[^}]+)\}|(?P[A-Za-z_$][A-Za-z0-9_$]*))") +_CALL_RE = re.compile(r"(?P[A-Za-z_$][A-Za-z0-9_$]*)\s*\(") +_KEYWORDS = {"if", "for", "while", "switch", "return", "function", "def", "class", "new"} +_SUPPORTED = {"python", "typescript", "javascript"} + + +class CodeGraphExtractor: + """Extract symbols and simple import/call edges from one document.""" + + def extract(self, document: Document) -> FileGraph: + language = document.language + if language not in _SUPPORTED: + return FileGraph( + repo_id=document.repo_id, + file_path=document.file_path, + language=language, + capabilities={"graph_supported": False, "fallback": "text"}, + ) + if language == "python": + symbols, edges = self._extract_python(document) + else: + symbols, edges = self._extract_typescript(document) + return FileGraph( + repo_id=document.repo_id, + file_path=document.file_path, + language=language, + symbols=symbols, + edges=edges, + capabilities={"graph_supported": True, "languages": sorted(_SUPPORTED)}, + ) + + def _extract_python(self, document: Document) -> tuple[list[CodeSymbol], list[CodeEdge]]: + symbols: list[CodeSymbol] = [] + edges: list[CodeEdge] = [] + container_stack: list[tuple[int, str]] = [] + for line_number, line in enumerate(document.content.splitlines(), 1): + stripped = line.strip() + indent = len(line) - len(line.lstrip()) + container_stack = [(level, name) for level, name in container_stack if level < indent] + match = _PY_SYMBOL_RE.match(line) + if match: + raw_kind = match.group("kind") + kind = "class" if raw_kind == "class" else "function" + if kind == "function" and container_stack: + kind = "method" + name = match.group("name") + symbols.append( + CodeSymbol( + repo_id=document.repo_id, + file_path=document.file_path, + name=name, + kind=kind, + language=document.language, + start_line=line_number, + end_line=line_number, + container=container_stack[-1][1] if container_stack else None, + ) + ) + container_stack.append((indent, name)) + continue + import_match = _PY_IMPORT_RE.match(stripped) + if import_match: + targets = import_match.group("from") or import_match.group("import") or "" + for target in _split_import_targets(targets): + edges.append(self._edge(document, "import", target, line_number, container_stack[-1][1] if container_stack else None)) + for call in _CALL_RE.finditer(stripped): + target = call.group("name") + if target not in _KEYWORDS: + edges.append(self._edge(document, "call", target, line_number, container_stack[-1][1] if container_stack else None)) + return symbols, edges + + def _extract_typescript(self, document: Document) -> tuple[list[CodeSymbol], list[CodeEdge]]: + symbols: list[CodeSymbol] = [] + edges: list[CodeEdge] = [] + current_class: str | None = None + class_depth = 0 + for line_number, line in enumerate(document.content.splitlines(), 1): + stripped = line.strip() + class_match = _TS_CLASS_RE.match(stripped) + function_match = _TS_FUNCTION_RE.match(stripped) + method_match = _TS_METHOD_RE.match(stripped) if current_class else None + if class_match: + current_class = class_match.group("name") + class_depth = 1 + symbols.append(self._symbol(document, current_class, "class", line_number)) + elif function_match: + symbols.append(self._symbol(document, function_match.group("name"), "function", line_number)) + elif method_match: + symbols.append(self._symbol(document, method_match.group("name"), "method", line_number, current_class)) + import_match = _TS_IMPORT_RE.match(stripped) + if import_match: + targets = import_match.group("named") or import_match.group("default") or "" + for target in _split_import_targets(targets): + edges.append(self._edge(document, "import", target, line_number, current_class)) + for call in _CALL_RE.finditer(stripped): + target = call.group("name") + if target not in _KEYWORDS: + edges.append(self._edge(document, "call", target, line_number, current_class)) + if current_class: + class_depth += stripped.count("{") - stripped.count("}") + if class_depth <= 0: + current_class = None + return symbols, edges + + def _symbol(self, document: Document, name: str, kind: str, line_number: int, container: str | None = None) -> CodeSymbol: + return CodeSymbol(document.repo_id, document.file_path, name, kind, document.language, line_number, line_number, container) + + def _edge(self, document: Document, edge_type: str, target: str, line_number: int, source: str | None) -> CodeEdge: + return CodeEdge(document.repo_id, document.file_path, edge_type, target, line_number, line_number, source) + + +def _split_import_targets(raw: str) -> list[str]: + targets: list[str] = [] + for part in raw.split(","): + name = part.strip().split(" as ", 1)[0].strip() + if not name: + continue + targets.append(name.split(".")[-1]) + return targets diff --git a/src/coderag/models/__init__.py b/src/coderag/models/__init__.py index 12eff77..8e9440e 100644 --- a/src/coderag/models/__init__.py +++ b/src/coderag/models/__init__.py @@ -3,12 +3,18 @@ from coderag.models.chunk import Chunk, ChunkMetadata, ChunkType from coderag.models.context import ContextPack, ContextSnippet from coderag.models.document import Document, DocumentMetadata +from coderag.models.graph import BlastRadius, CodeEdge, CodeReference, CodeSymbol, FileGraph from coderag.models.repository import Repository, RepositoryStatus from coderag.models.response import Citation, Query, Response, RetrievedChunk __all__ = [ "ContextPack", "ContextSnippet", + "BlastRadius", + "CodeEdge", + "CodeReference", + "CodeSymbol", + "FileGraph", "Document", "DocumentMetadata", "Chunk", diff --git a/src/coderag/models/graph.py b/src/coderag/models/graph.py new file mode 100644 index 0000000..09f6ed0 --- /dev/null +++ b/src/coderag/models/graph.py @@ -0,0 +1,119 @@ +"""Code intelligence graph models.""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any + + +@dataclass(frozen=True) +class CodeSymbol: + repo_id: str + file_path: str + name: str + kind: str + language: str | None + start_line: int + end_line: int + container: str | None = None + + def to_dict(self) -> dict[str, Any]: + return { + "repo_id": self.repo_id, + "file_path": self.file_path, + "name": self.name, + "kind": self.kind, + "language": self.language, + "start_line": self.start_line, + "end_line": self.end_line, + "container": self.container, + } + + +@dataclass(frozen=True) +class CodeReference: + repo_id: str + file_path: str + symbol_name: str + reference_kind: str + start_line: int + end_line: int + source_name: str | None = None + + def to_dict(self) -> dict[str, Any]: + return { + "repo_id": self.repo_id, + "file_path": self.file_path, + "symbol_name": self.symbol_name, + "reference_kind": self.reference_kind, + "start_line": self.start_line, + "end_line": self.end_line, + "source_name": self.source_name, + } + + +@dataclass(frozen=True) +class CodeEdge: + repo_id: str + file_path: str + edge_type: str + target_name: str + start_line: int + end_line: int + source_name: str | None = None + + def to_reference(self) -> CodeReference: + return CodeReference( + repo_id=self.repo_id, + file_path=self.file_path, + symbol_name=self.target_name, + reference_kind=self.edge_type, + start_line=self.start_line, + end_line=self.end_line, + source_name=self.source_name, + ) + + def to_dict(self) -> dict[str, Any]: + return { + "repo_id": self.repo_id, + "file_path": self.file_path, + "edge_type": self.edge_type, + "target_name": self.target_name, + "start_line": self.start_line, + "end_line": self.end_line, + "source_name": self.source_name, + } + + +@dataclass(frozen=True) +class FileGraph: + repo_id: str + file_path: str + language: str | None + symbols: list[CodeSymbol] = field(default_factory=list) + edges: list[CodeEdge] = field(default_factory=list) + capabilities: dict[str, Any] = field(default_factory=dict) + + +@dataclass(frozen=True) +class BlastRadius: + repo_id: str + symbol: str + impacted_files: list[str] + impacted_symbols: list[str] + associated_tests: list[str] + edges: list[dict[str, Any]] + reasons: list[str] + capabilities: dict[str, Any] + + def to_dict(self) -> dict[str, Any]: + return { + "repo_id": self.repo_id, + "symbol": self.symbol, + "impacted_files": self.impacted_files, + "impacted_symbols": self.impacted_symbols, + "associated_tests": self.associated_tests, + "edges": self.edges, + "reasons": self.reasons, + "capabilities": self.capabilities, + } diff --git a/src/coderag/services/graph.py b/src/coderag/services/graph.py new file mode 100644 index 0000000..d8e5e9b --- /dev/null +++ b/src/coderag/services/graph.py @@ -0,0 +1,56 @@ +"""Service seam for code intelligence graph queries.""" + +from __future__ import annotations + +from coderag.models.graph import BlastRadius, CodeReference, CodeSymbol +from coderag.models.repository import RepositoryStatus +from coderag.services.registry import RepositoryRegistry + + +class CodeGraphService: + """Query persisted symbol/reference graph facts.""" + + def __init__(self, registry: RepositoryRegistry | None = None) -> None: + self.registry = registry or RepositoryRegistry() + + def find_symbol(self, repo_id: str, symbol_name: str) -> list[CodeSymbol]: + self._require_ready(repo_id) + return self.registry.find_graph_symbols(repo_id, symbol_name) + + def find_references(self, repo_id: str, symbol_name: str) -> list[CodeReference]: + self._require_ready(repo_id) + return [edge.to_reference() for edge in self.registry.find_graph_edges(repo_id, symbol_name)] + + def get_blast_radius(self, repo_id: str, symbol_name: str) -> BlastRadius: + self._require_ready(repo_id) + symbols = self.find_symbol(repo_id, symbol_name) + edges = self.registry.find_graph_edges(repo_id, symbol_name) + impacted_files = sorted({edge.file_path for edge in edges}) + impacted_symbols = sorted({edge.source_name for edge in edges if edge.source_name}) + associated_tests = sorted(path for path in impacted_files if _is_test_file(path)) + reasons = [f"{edge.file_path}:{edge.start_line} {edge.edge_type}s {symbol_name}" for edge in edges] + return BlastRadius( + repo_id=repo_id, + symbol=symbol_name, + impacted_files=impacted_files, + impacted_symbols=impacted_symbols, + associated_tests=associated_tests, + edges=[edge.to_dict() for edge in edges], + reasons=reasons, + capabilities={ + "graph_supported": bool(symbols or edges), + "fallback": "text" if not symbols and not edges else None, + }, + ) + + def _require_ready(self, repo_id: str) -> None: + repo = self.registry.get(repo_id) + if repo is None: + raise ValueError(f"Repository not found: {repo_id}") + if repo.status is not RepositoryStatus.READY: + raise ValueError(f"Repository not ready: status is {repo.status.value}") + + +def _is_test_file(path: str) -> bool: + lowered = path.lower() + return "/test" in lowered or lowered.startswith("test") or lowered.endswith("_test.py") or ".test." in lowered diff --git a/src/coderag/services/indexing.py b/src/coderag/services/indexing.py index 054a729..b1a7ce3 100644 --- a/src/coderag/services/indexing.py +++ b/src/coderag/services/indexing.py @@ -7,9 +7,11 @@ from typing import Any, cast from coderag.config import get_settings +from coderag.graph.extractor import CodeGraphExtractor from coderag.logging import get_logger from coderag.models.chunk import Chunk from coderag.models.document import Document +from coderag.models.graph import FileGraph from coderag.models.repository import Repository, RepositoryStatus from coderag.services.registry import RepositoryRegistry @@ -70,6 +72,7 @@ def __init__( chunker: Any | None = None, embedder: Any | None = None, vectorstore: Any | None = None, + graph_extractor: CodeGraphExtractor | None = None, commit_resolver: Callable[[Path], str | None] | None = None, ) -> None: self.settings = get_settings() @@ -81,6 +84,7 @@ def __init__( self.embedder = embedder self.vectorstore = vectorstore self.commit_resolver = commit_resolver or self._get_current_commit + self.graph_extractor = graph_extractor or CodeGraphExtractor() async def index_repository(self, url: str, options: IndexingOptions | None = None) -> IndexingResult: """Async-compatible full indexing entrypoint.""" @@ -153,6 +157,7 @@ def index_existing_repository( self._get_vectorstore().delete_repo_chunks(repo.id) self.registry.clear_file_metadata(repo.id) self.registry.clear_chunk_metadata(repo.id) + self.registry.clear_graph_metadata(repo.id) for file_path in files: relative_path = str(file_path.relative_to(repo_path)) self.registry.record_file_metadata(repo.id, relative_path, size_bytes=file_path.stat().st_size) @@ -187,6 +192,8 @@ def index_files(self, files: Iterable[Path], repo_path: Path, repo_id: str) -> i for file_path in files: try: document = Document.from_file(file_path, repo_path, repo_id) + graph = self.graph_extractor.extract(document) + self.registry.replace_graph_file(repo_id, document.file_path, graph) for chunk in self._get_chunker().chunk_document(document): chunk.repo_id = repo_id batch.append(chunk) @@ -262,6 +269,7 @@ def update_repository(self, repo_id: str) -> IncrementalIndexingResult: for file_path in deleted | modified: count = self._get_vectorstore().delete_file_chunks(repo.id, file_path) chunks_deleted += count or 0 + self.registry.replace_graph_file(repo.id, file_path, FileGraph(repo_id=repo.id, file_path=file_path, language=None)) self.registry.remove_file_metadata(repo.id, file_path) file_filter = self._get_file_filter_factory()() diff --git a/src/coderag/services/registry.py b/src/coderag/services/registry.py index d3f782f..413459f 100644 --- a/src/coderag/services/registry.py +++ b/src/coderag/services/registry.py @@ -13,6 +13,7 @@ from coderag.config import get_settings from coderag.logging import get_logger from coderag.models.chunk import Chunk +from coderag.models.graph import CodeEdge, CodeSymbol, FileGraph from coderag.models.repository import Repository logger = get_logger(__name__) @@ -368,6 +369,79 @@ def list_chunks(self, repo_id: str) -> list_type[RepositoryChunkRecord]: ).fetchall() return [self._chunk_from_row(row) for row in rows] + def replace_graph_file(self, repo_id: str, file_path: str, graph: FileGraph) -> None: + """Replace persisted graph facts for one repository file.""" + with self._connect() as conn: + conn.execute("DELETE FROM code_graph_edges WHERE repo_id = ? AND file_path = ?", (repo_id, file_path)) + conn.execute("DELETE FROM code_graph_symbols WHERE repo_id = ? AND file_path = ?", (repo_id, file_path)) + for symbol in graph.symbols: + conn.execute( + """ + INSERT INTO code_graph_symbols ( + repo_id, file_path, name, kind, language, start_line, end_line, container + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + symbol.repo_id, + symbol.file_path, + symbol.name, + symbol.kind, + symbol.language, + symbol.start_line, + symbol.end_line, + symbol.container, + ), + ) + for edge in graph.edges: + conn.execute( + """ + INSERT INTO code_graph_edges ( + repo_id, file_path, edge_type, target_name, start_line, end_line, source_name + ) VALUES (?, ?, ?, ?, ?, ?, ?) + """, + ( + edge.repo_id, + edge.file_path, + edge.edge_type, + edge.target_name, + edge.start_line, + edge.end_line, + edge.source_name, + ), + ) + + def clear_graph_metadata(self, repo_id: str) -> None: + """Delete all graph facts for a repository.""" + with self._connect() as conn: + conn.execute("DELETE FROM code_graph_edges WHERE repo_id = ?", (repo_id,)) + conn.execute("DELETE FROM code_graph_symbols WHERE repo_id = ?", (repo_id,)) + + def find_graph_symbols(self, repo_id: str, symbol_name: str) -> list_type[CodeSymbol]: + """Find symbols by exact name in a repository.""" + with self._connect() as conn: + rows = conn.execute( + """ + SELECT * FROM code_graph_symbols + WHERE repo_id = ? AND name = ? + ORDER BY file_path, start_line + """, + (repo_id, symbol_name), + ).fetchall() + return [self._graph_symbol_from_row(row) for row in rows] + + def find_graph_edges(self, repo_id: str, target_name: str) -> list_type[CodeEdge]: + """Find edges pointing at a symbol name in a repository.""" + with self._connect() as conn: + rows = conn.execute( + """ + SELECT * FROM code_graph_edges + WHERE repo_id = ? AND target_name = ? + ORDER BY file_path, start_line, edge_type + """, + (repo_id, target_name), + ).fetchall() + return [self._graph_edge_from_row(row) for row in rows] + def _connect(self) -> sqlite3.Connection: self.db_path.parent.mkdir(parents=True, exist_ok=True) conn = sqlite3.connect(self.db_path) @@ -428,6 +502,31 @@ def _ensure_schema(self) -> None: FOREIGN KEY (repo_id) REFERENCES repositories(id) ON DELETE CASCADE ); + + CREATE TABLE IF NOT EXISTS code_graph_symbols ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + repo_id TEXT NOT NULL, + file_path TEXT NOT NULL, + name TEXT NOT NULL, + kind TEXT NOT NULL, + language TEXT, + start_line INTEGER NOT NULL, + end_line INTEGER NOT NULL, + container TEXT, + FOREIGN KEY (repo_id) REFERENCES repositories(id) ON DELETE CASCADE + ); + + CREATE TABLE IF NOT EXISTS code_graph_edges ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + repo_id TEXT NOT NULL, + file_path TEXT NOT NULL, + edge_type TEXT NOT NULL, + target_name TEXT NOT NULL, + start_line INTEGER NOT NULL, + end_line INTEGER NOT NULL, + source_name TEXT, + FOREIGN KEY (repo_id) REFERENCES repositories(id) ON DELETE CASCADE + ); CREATE TABLE IF NOT EXISTS index_jobs ( id TEXT PRIMARY KEY, repo_id TEXT, @@ -572,3 +671,27 @@ def _chunk_from_row(self, row: sqlite3.Row) -> RepositoryChunkRecord: content_hash=row["content_hash"], metadata_json=row["metadata_json"], ) + + + def _graph_symbol_from_row(self, row: sqlite3.Row) -> CodeSymbol: + return CodeSymbol( + repo_id=row["repo_id"], + file_path=row["file_path"], + name=row["name"], + kind=row["kind"], + language=row["language"], + start_line=row["start_line"], + end_line=row["end_line"], + container=row["container"], + ) + + def _graph_edge_from_row(self, row: sqlite3.Row) -> CodeEdge: + return CodeEdge( + repo_id=row["repo_id"], + file_path=row["file_path"], + edge_type=row["edge_type"], + target_name=row["target_name"], + start_line=row["start_line"], + end_line=row["end_line"], + source_name=row["source_name"], + ) From 046294623c551a5f3e7fd587a566839dd2f4aa62 Mon Sep 17 00:00:00 2001 From: Sebastiangmz Date: Tue, 30 Jun 2026 15:32:42 -0600 Subject: [PATCH 3/4] feat: expose code graph adapters Spec-Slice: CRAG-20260630-M5-CODE-INTELLIGENCE-GRAPH --- src/coderag/api/routes.py | 32 +++++++++++++++ src/coderag/api/schemas.py | 47 ++++++++++++++++++++++ src/coderag/cli.py | 80 +++++++++++++++++++++++++++++++++++++ src/coderag/mcp/handlers.py | 39 +++++++++++++++++- src/coderag/mcp/tools.py | 45 +++++++++++++++++++++ 5 files changed, 242 insertions(+), 1 deletion(-) diff --git a/src/coderag/api/routes.py b/src/coderag/api/routes.py index 9709789..8a9ed66 100644 --- a/src/coderag/api/routes.py +++ b/src/coderag/api/routes.py @@ -5,11 +5,17 @@ from fastapi import APIRouter, BackgroundTasks, HTTPException from coderag.api.schemas import ( + BlastRadiusResponse, CitationResponse, CitationVerificationResponse, ContextPackRequest, ContextPackResponse, ContextSnippetResponse, + FindReferencesResponse, + FindSymbolResponse, + GraphQueryRequest, + GraphReferenceResponse, + GraphSymbolResponse, IndexRepositoryRequest, IndexRepositoryResponse, ListRepositoriesResponse, @@ -20,6 +26,7 @@ ) from coderag.logging import get_logger from coderag.models.repository import Repository +from coderag.services.graph import CodeGraphService from coderag.services.indexing import IndexingOptions, IndexingService from coderag.services.registry import RepositoryRegistry from coderag.services.retrieval import RetrievalService @@ -30,6 +37,7 @@ registry = RepositoryRegistry() indexing_service = IndexingService(registry=registry) retrieval_service = RetrievalService(registry=registry) +graph_service = CodeGraphService(registry=registry) def get_repo_or_404(repo_id: str) -> Repository: @@ -210,6 +218,30 @@ async def context_pack(request: ContextPackRequest) -> ContextPackResponse: ) +@router.post("/graph/find-symbol", response_model=FindSymbolResponse) +def find_symbol(request: GraphQueryRequest) -> FindSymbolResponse: + """Find graph symbols by name.""" + repo = get_repo_or_404(request.repo_id) + symbols = graph_service.find_symbol(repo.id, request.symbol) + return FindSymbolResponse(symbols=[GraphSymbolResponse(**symbol.to_dict()) for symbol in symbols]) + + +@router.post("/graph/find-references", response_model=FindReferencesResponse) +def find_references(request: GraphQueryRequest) -> FindReferencesResponse: + """Find graph references by symbol name.""" + repo = get_repo_or_404(request.repo_id) + references = graph_service.find_references(repo.id, request.symbol) + return FindReferencesResponse(references=[GraphReferenceResponse(**reference.to_dict()) for reference in references]) + + +@router.post("/graph/blast-radius", response_model=BlastRadiusResponse) +def get_blast_radius(request: GraphQueryRequest) -> BlastRadiusResponse: + """Return graph-backed blast radius evidence for a symbol.""" + repo = get_repo_or_404(request.repo_id) + blast = graph_service.get_blast_radius(repo.id, request.symbol) + return BlastRadiusResponse(**blast.to_dict()) + + @router.get("/repos", response_model=ListRepositoriesResponse) async def list_repositories() -> ListRepositoriesResponse: """List all repositories.""" diff --git a/src/coderag/api/schemas.py b/src/coderag/api/schemas.py index a9dbe21..7f75694 100644 --- a/src/coderag/api/schemas.py +++ b/src/coderag/api/schemas.py @@ -122,6 +122,53 @@ class ContextPackResponse(BaseModel): budget: dict[str, int] capabilities: dict[str, object] + +class GraphQueryRequest(BaseModel): + """Request for graph symbol queries.""" + + repo_id: str = Field(..., description="Repository ID to query") + symbol: str = Field(..., description="Symbol name") + + +class GraphSymbolResponse(BaseModel): + repo_id: str + file_path: str + name: str + kind: str + language: str | None = None + start_line: int + end_line: int + container: str | None = None + + +class GraphReferenceResponse(BaseModel): + repo_id: str + file_path: str + symbol_name: str + reference_kind: str + start_line: int + end_line: int + source_name: str | None = None + + +class FindSymbolResponse(BaseModel): + symbols: list[GraphSymbolResponse] + + +class FindReferencesResponse(BaseModel): + references: list[GraphReferenceResponse] + + +class BlastRadiusResponse(BaseModel): + repo_id: str + symbol: str + impacted_files: list[str] + impacted_symbols: list[str] + associated_tests: list[str] + edges: list[dict[str, object]] + reasons: list[str] + capabilities: dict[str, object] + class RepositoryInfo(BaseModel): """Repository information.""" diff --git a/src/coderag/cli.py b/src/coderag/cli.py index 84069af..d07860f 100644 --- a/src/coderag/cli.py +++ b/src/coderag/cli.py @@ -448,6 +448,86 @@ async def run_context_pack(): click.echo(snippet.get("content", "")) click.echo("```") + + +@cli.command("find-symbol") +@click.argument("repo_id") +@click.argument("symbol_name") +@click.option("--format", "output_format", type=click.Choice(["text", "json"]), default="text", help="Output format") +def find_symbol(repo_id: str, symbol_name: str, output_format: str): + """Find indexed code graph symbols by exact name.""" + _apply_config_to_env() + + from coderag.mcp.handlers import get_mcp_handlers + + result = get_mcp_handlers().find_symbol(repo_id=repo_id, symbol_name=symbol_name) + if result.get("error"): + click.echo(f"❌ Error: {result['error']}") + sys.exit(1) + if output_format == "json": + click.echo(json.dumps(result, indent=2)) + return + click.echo(f"Symbols for {symbol_name}: {result.get('count', 0)}") + for symbol in result.get("symbols", []): + click.echo(f"- {symbol['name']} ({symbol['kind']}) {symbol['file_path']}:{symbol['start_line']}-{symbol['end_line']}") + + +@cli.command("find-references") +@click.argument("repo_id") +@click.argument("symbol_name") +@click.option("--format", "output_format", type=click.Choice(["text", "json"]), default="text", help="Output format") +def find_references(repo_id: str, symbol_name: str, output_format: str): + """Find indexed code graph references to a symbol.""" + _apply_config_to_env() + + from coderag.mcp.handlers import get_mcp_handlers + + result = get_mcp_handlers().find_references(repo_id=repo_id, symbol_name=symbol_name) + if result.get("error"): + click.echo(f"❌ Error: {result['error']}") + sys.exit(1) + if output_format == "json": + click.echo(json.dumps(result, indent=2)) + return + click.echo(f"References for {symbol_name}: {result.get('count', 0)}") + for reference in result.get("references", []): + source = reference.get("source_name") or "module" + click.echo( + f"- {source} {reference['reference_kind']} {reference['symbol_name']} " + f"{reference['file_path']}:{reference['start_line']}-{reference['end_line']}" + ) + + +@cli.command("blast-radius") +@click.argument("repo_id") +@click.argument("symbol_name") +@click.option("--format", "output_format", type=click.Choice(["text", "json"]), default="text", help="Output format") +def blast_radius(repo_id: str, symbol_name: str, output_format: str): + """Return graph-backed blast radius evidence for a symbol.""" + _apply_config_to_env() + + from coderag.mcp.handlers import get_mcp_handlers + + result = get_mcp_handlers().get_blast_radius(repo_id=repo_id, symbol_name=symbol_name) + if hasattr(result, "__await__"): + import asyncio + + result = asyncio.run(result) + if result.get("error"): + click.echo(f"❌ Error: {result['error']}") + sys.exit(1) + if output_format == "json": + click.echo(json.dumps(result, indent=2)) + return + click.echo(f"Blast radius for {symbol_name}") + click.echo("Impacted files:") + for file_path in result.get("impacted_files", []): + click.echo(f"- {file_path}") + if result.get("associated_tests"): + click.echo("Associated tests:") + for file_path in result["associated_tests"]: + click.echo(f"- {file_path}") + @cli.command("repos") @click.option("--format", "output_format", type=click.Choice(["text", "json"]), default="text", help="Output format") def repos(output_format: str): diff --git a/src/coderag/mcp/handlers.py b/src/coderag/mcp/handlers.py index 33d8a34..6f5d4ed 100644 --- a/src/coderag/mcp/handlers.py +++ b/src/coderag/mcp/handlers.py @@ -1,9 +1,10 @@ """MCP handlers for CodeRAG.""" -from typing import Any +from typing import Any, cast from coderag.logging import get_logger from coderag.models.repository import Repository +from coderag.services.graph import CodeGraphService from coderag.services.indexing import IndexingOptions, IndexingService from coderag.services.registry import RepositoryRegistry from coderag.services.retrieval import RetrievalService @@ -51,6 +52,14 @@ def _retrieved_chunk_dict(chunk: Any) -> dict[str, Any]: "ranking_reason": _read_field(chunk, "ranking_reason"), } +def _model_dict(item: Any) -> dict[str, Any]: + to_dict = getattr(item, "to_dict", None) + if callable(to_dict): + return cast("dict[str, Any]", to_dict()) + if isinstance(item, dict): + return cast("dict[str, Any]", item) + return dict(getattr(item, "__dict__", {})) + class MCPHandlers: """Handlers for MCP tools backed by shared application services.""" @@ -60,10 +69,12 @@ def __init__( registry: RepositoryRegistry | None = None, indexing_service: IndexingService | None = None, retrieval_service: RetrievalService | None = None, + graph_service: CodeGraphService | None = None, ) -> None: self.registry = registry or RepositoryRegistry() self.indexing_service = indexing_service or IndexingService(registry=self.registry) self.retrieval_service = retrieval_service or RetrievalService(registry=self.registry) + self.graph_service = graph_service or CodeGraphService(registry=self.registry) @property def repositories(self) -> dict[str, Repository]: @@ -292,6 +303,32 @@ async def get_context_pack( logger.error("MCP: Context pack failed", error=str(exc)) return {"snippets": [], "error": str(exc)} + def find_symbol(self, repo_id: str, symbol_name: str) -> dict[str, Any]: + """Find symbols by name using the code graph.""" + try: + symbols = self.graph_service.find_symbol(repo_id, symbol_name) + return {"symbols": [_model_dict(symbol) for symbol in symbols], "count": len(symbols)} + except Exception as exc: + logger.error("MCP: Find symbol failed", error=str(exc)) + return {"symbols": [], "error": str(exc)} + + def find_references(self, repo_id: str, symbol_name: str) -> dict[str, Any]: + """Find references by symbol name using the code graph.""" + try: + references = self.graph_service.find_references(repo_id, symbol_name) + return {"references": [_model_dict(reference) for reference in references], "count": len(references)} + except Exception as exc: + logger.error("MCP: Find references failed", error=str(exc)) + return {"references": [], "error": str(exc)} + + def get_blast_radius(self, repo_id: str, symbol_name: str) -> dict[str, Any]: + """Return graph-backed blast radius for a symbol.""" + try: + return self.graph_service.get_blast_radius(repo_id, symbol_name).to_dict() + except Exception as exc: + logger.error("MCP: Blast radius failed", error=str(exc)) + return {"impacted_files": [], "associated_tests": [], "error": str(exc)} + _mcp_handlers: MCPHandlers | None = None diff --git a/src/coderag/mcp/tools.py b/src/coderag/mcp/tools.py index 5c6e882..30a41a7 100644 --- a/src/coderag/mcp/tools.py +++ b/src/coderag/mcp/tools.py @@ -198,3 +198,48 @@ async def get_context_pack( max_tokens=max_tokens, max_chunks_per_file=max_chunks_per_file, ) + + +@mcp.tool() +async def find_symbol(repo_id: str, symbol_name: str) -> dict: + """Find indexed code graph symbols by exact name. + + Args: + repo_id: Repository ID (full or first 8 characters) + symbol_name: Symbol name to find + + Returns: + dict with symbols array and count + """ + handlers = get_mcp_handlers() + return handlers.find_symbol(repo_id=repo_id, symbol_name=symbol_name) + + +@mcp.tool() +async def find_references(repo_id: str, symbol_name: str) -> dict: + """Find indexed code graph references to a symbol. + + Args: + repo_id: Repository ID (full or first 8 characters) + symbol_name: Symbol name to find references for + + Returns: + dict with references array and count + """ + handlers = get_mcp_handlers() + return handlers.find_references(repo_id=repo_id, symbol_name=symbol_name) + + +@mcp.tool() +async def get_blast_radius(repo_id: str, symbol_name: str) -> dict: + """Return graph-backed blast radius evidence for a symbol. + + Args: + repo_id: Repository ID (full or first 8 characters) + symbol_name: Symbol name to inspect + + Returns: + dict with impacted files, symbols, tests, graph edges, and reasons + """ + handlers = get_mcp_handlers() + return handlers.get_blast_radius(repo_id=repo_id, symbol_name=symbol_name) From ca6ddbe12442694674ebca5aaa4d8daa753e99ca Mon Sep 17 00:00:00 2001 From: Sebastiangmz Date: Tue, 30 Jun 2026 15:32:46 -0600 Subject: [PATCH 4/4] ci: extend Milestone 5 validation gates Spec-Slice: CRAG-20260630-M5-CODE-INTELLIGENCE-GRAPH --- .github/workflows/ci.yml | 14 ++--- .pi/.specsafe-state.json | 21 ++++++- evals/config.json | 132 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 159 insertions(+), 8 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e1d355a..99ac0bb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -25,17 +25,17 @@ jobs: python -m pip install -e . --no-deps python -m pip install pytest pytest-asyncio pydantic pydantic-settings click PyYAML fastapi structlog ruff mypy mcp httpx - - name: Run Milestone 0-4 tests - run: PYTHONPATH=src python -m pytest tests/test_m0_baseline.py tests/test_m1_services.py tests/test_m2_registry.py tests/test_citations.py tests/test_m3_citations.py tests/test_api_routes.py tests/test_mcp_handlers.py tests/test_mcp_tools.py tests/test_ui_handlers.py tests/test_m4_hybrid_retrieval.py + - name: Run Milestone 0-5 tests + run: PYTHONPATH=src python -m pytest tests/test_m0_baseline.py tests/test_m1_services.py tests/test_m2_registry.py tests/test_citations.py tests/test_m3_citations.py tests/test_api_routes.py tests/test_mcp_handlers.py tests/test_mcp_tools.py tests/test_ui_handlers.py tests/test_m4_hybrid_retrieval.py tests/test_m5_code_graph.py - - name: Run Milestone 0-4 lint baseline - run: ruff check src/coderag/generation src/coderag/indexing/vectorstore.py src/coderag/models src/coderag/retrieval src/coderag/services src/coderag/config.py src/coderag/api/schemas.py src/coderag/api/routes.py src/coderag/mcp/handlers.py src/coderag/mcp/tools.py src/coderag/cli.py src/coderag/ui/grounding.py src/coderag/ui/repository_metadata.py src/coderag/ui/handlers.py tests/test_m0_baseline.py tests/test_m1_services.py tests/test_m2_registry.py tests/test_citations.py tests/test_m3_citations.py tests/test_api_routes.py tests/test_mcp_handlers.py tests/test_mcp_tools.py tests/test_ui_handlers.py tests/test_m4_hybrid_retrieval.py scripts/evalfly_smoke.py + - name: Run Milestone 0-5 lint baseline + run: ruff check src/coderag/generation src/coderag/indexing/vectorstore.py src/coderag/models src/coderag/retrieval src/coderag/graph src/coderag/services src/coderag/config.py src/coderag/api/schemas.py src/coderag/api/routes.py src/coderag/mcp/handlers.py src/coderag/mcp/tools.py src/coderag/cli.py src/coderag/ui/grounding.py src/coderag/ui/repository_metadata.py src/coderag/ui/handlers.py tests/test_m0_baseline.py tests/test_m1_services.py tests/test_m2_registry.py tests/test_citations.py tests/test_m3_citations.py tests/test_api_routes.py tests/test_mcp_handlers.py tests/test_mcp_tools.py tests/test_ui_handlers.py tests/test_m4_hybrid_retrieval.py tests/test_m5_code_graph.py scripts/evalfly_smoke.py - - name: Run Milestone 0-4 type baseline - run: PYTHONPATH=src mypy src/coderag/generation src/coderag/indexing/vectorstore.py src/coderag/models src/coderag/retrieval src/coderag/services src/coderag/config.py src/coderag/api/schemas.py src/coderag/api/routes.py src/coderag/mcp/handlers.py src/coderag/ui/grounding.py src/coderag/ui/repository_metadata.py src/coderag/ui/handlers.py tests/test_m0_baseline.py tests/test_m1_services.py tests/test_m2_registry.py tests/test_citations.py tests/test_m3_citations.py tests/test_api_routes.py tests/test_mcp_handlers.py tests/test_ui_handlers.py tests/test_m4_hybrid_retrieval.py scripts/evalfly_smoke.py + - name: Run Milestone 0-5 type baseline + run: PYTHONPATH=src mypy src/coderag/generation src/coderag/indexing/vectorstore.py src/coderag/models src/coderag/retrieval src/coderag/graph src/coderag/services src/coderag/config.py src/coderag/api/schemas.py src/coderag/api/routes.py src/coderag/mcp/handlers.py src/coderag/ui/grounding.py src/coderag/ui/repository_metadata.py src/coderag/ui/handlers.py tests/test_m0_baseline.py tests/test_m1_services.py tests/test_m2_registry.py tests/test_citations.py tests/test_m3_citations.py tests/test_api_routes.py tests/test_mcp_handlers.py tests/test_ui_handlers.py tests/test_m4_hybrid_retrieval.py tests/test_m5_code_graph.py scripts/evalfly_smoke.py - name: Compile changed runtime modules - run: python -m py_compile src/coderag/__init__.py src/coderag/config.py src/coderag/main.py src/coderag/cli.py src/coderag/indexing/embeddings.py src/coderag/indexing/vectorstore.py src/coderag/generation/*.py src/coderag/models/*.py src/coderag/retrieval/*.py src/coderag/services/*.py src/coderag/api/schemas.py src/coderag/api/routes.py src/coderag/mcp/handlers.py src/coderag/mcp/tools.py src/coderag/ui/grounding.py src/coderag/ui/repository_metadata.py src/coderag/ui/handlers.py tests/test_m0_baseline.py tests/test_m1_services.py tests/test_m2_registry.py tests/test_citations.py tests/test_m3_citations.py tests/test_api_routes.py tests/test_ui_handlers.py tests/test_m4_hybrid_retrieval.py scripts/evalfly_smoke.py + run: python -m py_compile src/coderag/__init__.py src/coderag/config.py src/coderag/main.py src/coderag/cli.py src/coderag/indexing/embeddings.py src/coderag/indexing/vectorstore.py src/coderag/generation/*.py src/coderag/models/*.py src/coderag/retrieval/*.py src/coderag/graph/*.py src/coderag/services/*.py src/coderag/api/schemas.py src/coderag/api/routes.py src/coderag/mcp/handlers.py src/coderag/mcp/tools.py src/coderag/ui/grounding.py src/coderag/ui/repository_metadata.py src/coderag/ui/handlers.py tests/test_m0_baseline.py tests/test_m1_services.py tests/test_m2_registry.py tests/test_citations.py tests/test_m3_citations.py tests/test_api_routes.py tests/test_ui_handlers.py tests/test_m4_hybrid_retrieval.py tests/test_m5_code_graph.py scripts/evalfly_smoke.py - name: Build package run: python -m build diff --git a/.pi/.specsafe-state.json b/.pi/.specsafe-state.json index df81ffa..15acb51 100644 --- a/.pi/.specsafe-state.json +++ b/.pi/.specsafe-state.json @@ -1,5 +1,4 @@ { - "currentSlice": null, "history": [ { "sliceId": "CRAG-20260627-ROADMAP", @@ -140,6 +139,26 @@ "turns": 0 } } + }, + { + "sliceId": "CRAG-20260630-M5-CODE-INTELLIGENCE-GRAPH", + "workspaceId": "CodeRAG", + "sessionId": "omp-session", + "beganAt": "2026-06-30T21:02:39.132Z", + "endedAt": "2026-06-30T21:32:20.419Z", + "outcome": "PASS", + "costSummary": { + "externalMemoryCalls": 0, + "externalMemoryCost": 0, + "subagentTokens": { + "input": 0, + "output": 0, + "cacheRead": 0, + "cacheWrite": 0, + "cost": 0, + "turns": 0 + } + } } ] } diff --git a/evals/config.json b/evals/config.json index 0b92946..7b14e55 100644 --- a/evals/config.json +++ b/evals/config.json @@ -638,6 +638,138 @@ } ] } + }, + { + "schema_version": "evalfly.case.v1", + "case_id": "m5-code-graph-contract-exists", + "title": "Milestone 5 code graph contract exists", + "suite": "smoke", + "risk_tier": "critical", + "task_type": "delivery_governance", + "source": { + "kind": "acceptance_criteria", + "criteria": "M5-AC1 M5-AC2 M5-AC3" + }, + "privacy": { + "classification": "public", + "sanitized": true + }, + "expected": { + "success_criteria": [ + "The Milestone 5 contract is committed for code graph intelligence and blast radius." + ] + }, + "judge": { + "type": "deterministic", + "assertions": [ + { + "type": "file_exists", + "path": "docs/superpowers/plans/2026-06-30-m5-code-intelligence-graph-contract.md" + } + ] + } + }, + { + "schema_version": "evalfly.case.v1", + "case_id": "m5-code-graph-extractor-exists", + "title": "Code graph extractor exists", + "suite": "smoke", + "risk_tier": "critical", + "task_type": "code_intelligence", + "source": { + "kind": "acceptance_criteria", + "criteria": "M5-AC1" + }, + "privacy": { + "classification": "public", + "sanitized": true + }, + "expected": { + "success_criteria": [ + "CodeGraphExtractor is implemented as the graph fact extraction seam." + ] + }, + "judge": { + "type": "deterministic", + "assertions": [ + { + "type": "file_contains", + "path": "src/coderag/graph/extractor.py", + "text": "class CodeGraphExtractor" + } + ] + } + }, + { + "schema_version": "evalfly.case.v1", + "case_id": "m5-graph-adapters-exist", + "title": "Code graph adapters exist", + "suite": "smoke", + "risk_tier": "critical", + "task_type": "adapter_contract", + "source": { + "kind": "acceptance_criteria", + "criteria": "M5-AC2 M5-AC3" + }, + "privacy": { + "classification": "public", + "sanitized": true + }, + "expected": { + "success_criteria": [ + "REST, MCP, and CLI expose symbol, reference, and blast radius graph paths." + ] + }, + "judge": { + "type": "deterministic", + "assertions": [ + { + "type": "file_contains", + "path": "src/coderag/api/routes.py", + "text": "@router.post(\"/graph/blast-radius\"" + }, + { + "type": "file_contains", + "path": "src/coderag/mcp/tools.py", + "text": "async def get_blast_radius" + }, + { + "type": "file_contains", + "path": "src/coderag/cli.py", + "text": "@cli.command(\"blast-radius\")" + } + ] + } + }, + { + "schema_version": "evalfly.case.v1", + "case_id": "m5-targeted-tests-exist", + "title": "Milestone 5 targeted tests exist", + "suite": "smoke", + "risk_tier": "critical", + "task_type": "test_coverage", + "source": { + "kind": "acceptance_criteria", + "criteria": "M5-AC1 M5-AC2 M5-AC3" + }, + "privacy": { + "classification": "public", + "sanitized": true + }, + "expected": { + "success_criteria": [ + "Targeted code graph extraction, registry, service, and adapter tests are committed." + ] + }, + "judge": { + "type": "deterministic", + "assertions": [ + { + "type": "file_exists", + "path": "tests/test_m5_code_graph.py" + } + ] + } } ] }