Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -214,6 +214,9 @@ first `python` on PATH): a CPU-only interpreter puts the models on the CPU.
|------|----------|-------------|
| `semantic_code_search` | Search | FAISS + RRF search across all three tiers |
| `find_similar_code` | Search | Find duplicates and callers of a code snippet |
| `get_file_outline` | Lookup | A file's symbols: kind, signature and line range, no bodies |
| `get_symbol_source` | Lookup | One symbol's source, checked against what was indexed; lists candidates when the name is ambiguous |
| `changed_symbols` | Lookup | The symbols a branch changed since a git ref, optionally with their callers and importers |
| `analyze_blast_radius` | Impact | Map callers, dependents, and parallel implementations of a symbol |
| `detect_pattern_violations` | Impact | Find files that should follow a pattern but don't |
| `trace_data_flow` | Tracing | Full lifecycle of a data symbol (definitions → producers → consumers) |
Expand Down Expand Up @@ -278,7 +281,7 @@ codebase-indexer/
├── tests/
├── tools/ # Eval + CI harnesses (coir_eval, conformance_eval, …)
└── src/
├── MCPServer.py # MCP server entry point — all 14 tools, and the watchdog
├── MCPServer.py # MCP server entry point — all 17 tools, and the watchdog
├── config.py # Locates and parses the per-repo indexer.toml
├── index_location.py # Where the index lives: .code-index or <git dir>/code-index (ADR-042)
├── index_lock.py # One writer and one watchdog per index (ADR-038)
Expand Down
245 changes: 245 additions & 0 deletions docs/adr/ADR-050-symbol-table-read-tools.md

Large diffs are not rendered by default.

11 changes: 10 additions & 1 deletion src/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -151,9 +151,10 @@ Source files

### MCP Tools (MCPServer.py)

Fourteen AI-facing tools grouped by intent:
Seventeen AI-facing tools grouped by intent:

- **Search**: `semantic_code_search`, `find_similar_code`
- **Lookup**: `get_file_outline`, `get_symbol_source`, `changed_symbols`
- **Impact**: `analyze_blast_radius`, `detect_pattern_violations`
- **Tracing**: `trace_data_flow`, `investigate_architecture`, `what_writes`
- **Discovery**: `find_test_coverage`, `find_dead_code`, `find_unabstracted_collection_reads`, `map_module_communities`, `verify_candidate_edges`
Expand All @@ -172,6 +173,14 @@ candidate (name-based / unresolved) call edges. `index_status` (ADR-025) reports
index freshness — `last_verified_at`, `last_indexed_commit` vs HEAD, and files whose
content changed within a window — for agents and downstream context hubs.

The three **Lookup** tools (ADR-050) answer straight from the symbol table, like
`what_writes`: no retrieval and no model. `get_symbol_source` never serves the stored
copy of a symbol. It reads the file through `source.py` from what the index was built
from (the indexed commit in git mode) and checks it against the stored text: it serves
the symbol in place, serves it from its new lines when it moved, or refuses when it
changed. `changed_symbols` diffs with git from the ref's merge base; the ref is
validated before git sees it, and every git call has a timeout.

`map_module_communities` (ADR-006) is the whole-graph structural view: it reads the
SQLite edge graph, runs Louvain community detection + betweenness centrality over it
(`src/graph_analytics.py`, pure NetworkX), and renders a community map + god-object
Expand Down
562 changes: 561 additions & 1 deletion src/MCPServer.py

Large diffs are not rendered by default.

70 changes: 70 additions & 0 deletions src/db.py
Original file line number Diff line number Diff line change
Expand Up @@ -1502,3 +1502,73 @@ def stats(self) -> dict[str, int]:
t: self._conn.execute(f"SELECT COUNT(*) FROM {t}").fetchone()[0]
for t in tables
}

# ------------------------------------------------------------------
# Symbol-table reads for the outline / source / changed-symbol tools (ADR-050)
# ------------------------------------------------------------------

def has_files(self) -> bool:
"""Whether anything has been indexed into this database."""
return self._conn.execute("SELECT 1 FROM files LIMIT 1").fetchone() is not None

def get_file_record(self, path: str) -> Optional[dict]:
"""The `files` row for an exact repo-relative path, or None."""
row = self._conn.execute(
"SELECT path, content_hash, indexed_at, content_changed_at FROM files WHERE path = ?",
(path,),
).fetchone()
return dict(row) if row is not None else None

def find_file_paths(self, tail: str, limit: int = 20) -> list[str]:
"""Indexed paths equal to ``tail`` or ending in ``/tail``, compared case-insensitively.

``tail`` is a repo-relative POSIX path or a trailing part of one (``db.py``,
``src/db.py``). Matched in Python rather than with LIKE, whose ``_`` wildcard
would match half the file names in a Python repo.
"""
want = tail.lower()
rows = self._conn.execute("SELECT path FROM files").fetchall()
hits = sorted(p for (p,) in rows if p.lower() == want or p.lower().endswith("/" + want))
return hits[:limit]

def get_file_symbol_locations(self, path: str) -> list[dict]:
"""Every symbol located in ``path``, ordered by start line, outer before inner.

Read through ``symbol_locations``, so a C# partial class lists the part declared in
this file, with this file's span, rather than only under the file that claimed its
``symbols`` row. Dicts: fqn, kind, name, class_context, start_line, end_line, text.
"""
rows = self._conn.execute(
"""
SELECT s.fqn, s.kind, s.name, s.class_context,
l.start_line, l.end_line, l.text
FROM symbol_locations l
JOIN symbols s ON s.id = l.symbol_id
JOIN files f ON f.id = l.file_id
WHERE f.path = ?
ORDER BY l.start_line, l.end_line DESC, s.fqn
""",
(path,),
).fetchall()
return [dict(r) for r in rows]

def find_symbol_locations(self, name: str, limit: int = 200) -> list[dict]:
"""Every location of a symbol whose ``name`` column equals ``name`` exactly.

Dicts as get_file_symbol_locations', plus ``path``. Callers narrow by a qualified
tail or FQN themselves; ``name`` is indexed, so this is a point lookup.
"""
rows = self._conn.execute(
"""
SELECT s.fqn, s.kind, s.name, s.class_context,
l.start_line, l.end_line, l.text, f.path
FROM symbols s
JOIN symbol_locations l ON l.symbol_id = s.id
JOIN files f ON f.id = l.file_id
WHERE s.name = ?
ORDER BY f.path, l.start_line
LIMIT ?
""",
(name, limit),
).fetchall()
return [dict(r) for r in rows]
2 changes: 1 addition & 1 deletion tests/test_index_state.py
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ def test_the_server_holds_one_copy_of_the_index(stub_loading):
def test_every_registered_tool_is_async_with_the_same_arguments():
"""#53: FastMCP runs a sync tool on the event loop; an async one it awaits."""
tools = M.mcp._tool_manager.list_tools()
assert len(tools) == 14
assert len(tools) == 17 # 14, plus ADR-050's three symbol-table tools
for t in tools:
assert t.is_async, t.name
plain = getattr(M, t.name)
Expand Down
3 changes: 2 additions & 1 deletion tests/test_safety_tool_surface.py
Original file line number Diff line number Diff line change
Expand Up @@ -104,5 +104,6 @@ def test_both_instruction_branches_route_to_the_index_before_grep(tmp_path, monk
assert ("read straight from git" in text) is git_mode
assert "before Grep" in text and "exact strings" in text
registered = {t.name for t in _registered()}
for name in ("semantic_code_search", "trace_data_flow", "what_writes", "analyze_blast_radius"):
for name in ("semantic_code_search", "trace_data_flow", "what_writes", "analyze_blast_radius",
"get_file_outline", "get_symbol_source"):
assert f"`{name}`" in text and name in registered # names only tools that exist
Loading
Loading