Skip to content
Open
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
2 changes: 1 addition & 1 deletion packages/pi-rbtr/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ Seven tools, registered automatically on session start:
| `rbtr_search` | Search by name, keyword, or concept (BM25 + semantic + name fusion). Optional `keywords`/`variants` for query expansion. |
| `rbtr_read_symbol` | Read a symbol's full source by name |
| `rbtr_list_symbols` | Structural table of contents for a file |
| `rbtr_find_refs` | Find references via the dependency graph (imports, tests, docs) |
| `rbtr_find_refs` | Find references via the dependency graph (imports, docs) |
| `rbtr_changed_symbols` | Symbols that changed between two git refs |
| `rbtr_index` | Index the repository (background, incremental) |
| `rbtr_status` | Check whether the index exists and how many symbols it contains |
Expand Down
2 changes: 1 addition & 1 deletion packages/pi-rbtr/extensions/rbtr/generated/protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ export type QueryKind = "concept" | "identifier" | "code";
/**
* Kind of relationship between chunks.
*/
export type EdgeKind = "calls" | "imports" | "inherits" | "tests" | "documents" | "configures";
export type EdgeKind = "calls" | "imports" | "inherits" | "documents" | "configures";
/**
* How a symbol changed between two indexed commits.
*/
Expand Down
12 changes: 6 additions & 6 deletions packages/pi-rbtr/extensions/rbtr/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -458,7 +458,7 @@ export default function rbtrIndexExtension(pi: ExtensionAPI) {
"It understands meaning, not just substrings.\n" +
"- Known symbol by name → rbtr_read_symbol. No file path needed.\n" +
"- File outline before loading → rbtr_list_symbols.\n" +
"- Callers / tests / doc mentions of a symbol → rbtr_find_refs.\n" +
"- Callers / doc mentions of a symbol → rbtr_find_refs.\n" +
"- Structural diff between refs (for PR review) → rbtr_changed_symbols.\n" +
"Keep grep for literal strings (error messages, config keys, regexes) and use `read` for full-file reads; " +
"rbtr_* tools are for structure. When a concept-question arises, reach for rbtr_search first.",
Expand Down Expand Up @@ -848,7 +848,7 @@ export default function rbtrIndexExtension(pi: ExtensionAPI) {
"Prefer rbtr_search over grep for concept-shaped questions: 'how does X work', 'where is Y handled', 'find the code that does Z'. It understands meaning, not just substrings — 'retry logic' finds 'backoff' and 'reconnect attempts', grep would miss them.",
"Use grep when the user gives you a literal string or identifier that must match exactly (an error message, a regex, a configuration key, an import path).",
"Use rbtr_read_symbol (not rbtr_search) when you already know a symbol name and want its full source.",
"Chain: rbtr_search finds candidates → pass the `name` field from a hit as the `symbol` parameter to rbtr_read_symbol for the full source → rbtr_find_refs for callers / tests.",
"Chain: rbtr_search finds candidates → pass the `name` field from a hit as the `symbol` parameter to rbtr_read_symbol for the full source → rbtr_find_refs for callers and docs.",
"Scores are meaningful relative to the top result. A big drop-off after the first few hits means the tail is probably noise.",
"For concept queries (natural language like 'how does idle unload work'): provide `keywords` (3-5 synonym identifiers or alternative terms, e.g. ['timeout', 'evict', 'unload_model']) and `variants` (1-2 rephrases using different terminology, e.g. ['when does the model get released from memory']).",
"For identifier queries (symbol names like `fuse_scores`): optionally provide `keywords` only (alternative names the symbol might have, e.g. ['merge_scores', 'combine_results']). Omit `variants`.",
Expand Down Expand Up @@ -1010,11 +1010,11 @@ export default function rbtrIndexExtension(pi: ExtensionAPI) {
name: "rbtr_find_refs",
label: "rbtr find-refs",
description:
"Walk the dependency graph to find every place a symbol is imported, called from tests, or mentioned in docs. Takes a `symbol` string (same format as rbtr_read_symbol). Returns structural edges (source → target, kind=imports|tests|docs), not text matches.",
promptSnippet: "Find who imports / tests / documents a symbol via the index's dependency graph",
"Walk the dependency graph to find every place a symbol is imported or mentioned in docs. Takes a `symbol` string (same format as rbtr_read_symbol). Returns structural edges (source → target, kind=imports|docs), not text matches.",
promptSnippet: "Find who imports / documents a symbol via the index's dependency graph",
promptGuidelines: [
"Use rbtr_find_refs for impact analysis: 'what would break if I change X', 'which tests cover X', 'is X documented anywhere'.",
"This is structural, not textual: edge kinds ('imports', 'tests', 'docs') give you intent, not raw string occurrences. Prefer this to grep when asking a graph-shaped question.",
"Use rbtr_find_refs for impact analysis: 'what would break if I change X', 'is X documented anywhere'.",
"This is structural, not textual: edge kinds ('imports', 'docs') give you intent, not raw string occurrences. Prefer this to grep when asking a graph-shaped question.",
"Use grep when you need every raw occurrence of an identifier including inside strings / comments / unsupported file types.",
"Chain after rbtr_search or rbtr_read_symbol: you've identified the symbol, now find who depends on it.",
"Pass file_paths to disambiguate a name that exists in several files — references are resolved only against symbols defined in the listed files.",
Expand Down
2 changes: 0 additions & 2 deletions packages/rbtr/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -799,8 +799,6 @@ for the dispatch chain.

- **Import edges** - structural (tree-sitter import
extractor) or text-search fallback.
- **Test edges** - `test_foo.py` → `foo.py` by naming
convention and import analysis.
- **Doc edges** - markdown sections mentioning symbol names.

Import resolution maps a module string to a repo file:
Expand Down
2 changes: 1 addition & 1 deletion packages/rbtr/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -171,7 +171,7 @@ rbtr list-symbols src/rbtr/index/search.py
### `rbtr find-refs <symbol>`

Symbols that reference a given symbol via the dependency
graph (imports, tests, docs).
graph (imports, docs).

```bash
rbtr find-refs IndexStore
Expand Down
192 changes: 3 additions & 189 deletions packages/rbtr/src/rbtr/index/edges.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
"""Edge inference — imports, tests, docs relationships.
"""Edge inference — imports and docs relationships.

All public functions are pure: chunks in, edges out. No I/O, no
store access. The orchestrator feeds them chunks and writes
Expand Down Expand Up @@ -47,10 +47,6 @@ class ImportResolution:
"""Directories to prepend when resolving absolute imports."""
path_substitutions: tuple[tuple[str, str], ...]
"""Module path prefix replacements (e.g. `("crate/", "src/")`)."""
test_prefix: str = ""
"""Test file name prefix (e.g. `test_`)."""
test_suffix: str = ""
"""Test file name suffix (e.g. `.test`, `_test`)."""
module_style: ModuleStyle = ModuleStyle.PATH
"""How module strings map to file paths. See `ModuleStyle`."""
own_extensions: frozenset[str] = frozenset()
Expand Down Expand Up @@ -78,8 +74,6 @@ def build_resolution_map(mgr: LanguageManager) -> dict[str, ImportResolution]:
index_files=tuple(idxs),
source_roots=reg.source_roots,
path_substitutions=reg.path_substitutions,
test_prefix=reg.test_prefix,
test_suffix=reg.test_suffix,
module_style=reg.module_style,
own_extensions=reg.extensions,
)
Expand Down Expand Up @@ -158,8 +152,8 @@ def _suffix_matches(
"""Collect repo files matching *name* as a full-path suffix.

A file matches when it equals `name{ext}` or ends with `/name{ext}` for
some extension. Shared by the import resolver's Tier 3 and the test-edge
`_find_source_file` last resort so both apply one suffix-matching policy.
some extension. Used by the import resolver's Tier 3 (drop rather than
guess on ambiguity).
"""
matches: list[str] = []
for ext in extensions:
Expand Down Expand Up @@ -430,183 +424,3 @@ def infer_import_edges(
edges.extend(_text_search_import_edges(imp, file_symbols, stem_index))

return edges


# ── Test↔code edges ─────────────────────────────────────────────────
#
# Strategy: file-naming heuristic + import analysis (structural or
# text-search depending on what's available in the import chunks).


def _strip_test_affix(
file_path: str,
*,
test_prefix: str = "test_",
test_suffix: str = "",
) -> str | None:
"""Extract the base name from a test file path.

Tries the language's prefix first, then suffix::

test_prefix="test_": tests/test_foo.py → foo
test_suffix=".test": src/models.test.ts → models
test_suffix="_test": pkg/foo_test.go → foo
test_suffix="Test": FooTest.java → Foo
"""
name = PurePosixPath(file_path).stem
if test_prefix and name.startswith(test_prefix):
return name[len(test_prefix) :]
if test_suffix and name.endswith(test_suffix):
return name[: -len(test_suffix)]
return None


def _find_source_file(
base_name: str,
test_path: str,
repo_files: set[str],
resolution: ImportResolution | None = None,
) -> str | None:
"""Find a source file matching *base_name*.

Uses language-aware resolution when available, falling back
to `.py` heuristics.
"""
extensions = resolution.extensions if resolution else (".py",)
source_roots = resolution.source_roots if resolution else ("", "src")

# Direct matches: source_root/base_name + ext.
for root in source_roots:
for ext in extensions:
candidate = f"{root}/{base_name}{ext}" if root else f"{base_name}{ext}"
if candidate in repo_files:
return candidate

# Sibling of test directory: tests/test_foo → ../foo + ext.
test_dir = PurePosixPath(test_path).parent
if test_dir.name in ("tests", "test"):
parent = test_dir.parent
for root in ("", "src"):
for ext in extensions:
candidate = (
str(parent / root / f"{base_name}{ext}")
if root
else str(parent / f"{base_name}{ext}")
)
if candidate in repo_files:
return candidate

# Underscore-to-slash expansion (Python convention: test_foo_bar → foo/bar).
if "_" in base_name:
nested = base_name.replace("_", "/")
for root in source_roots:
for ext in extensions:
candidate = f"{root}/{nested}{ext}" if root else f"{nested}{ext}"
if candidate in repo_files:
return candidate

# Last resort: full-path suffix search, unique-match-only (same policy as
# the import resolver's Tier 3 — drop rather than guess on ambiguity).
candidates = _suffix_matches(base_name, repo_files, extensions)
if len(candidates) == 1:
return candidates[0]

return None


def _imported_names_from_file(
test_chunks: list[Chunk],
source_file: str,
repo_files: set[str],
resolution: ImportResolution | None = None,
) -> list[str]:
"""Extract symbol names imported from *source_file* by the test.

Reads `chunk.metadata` — no language-specific parsing.
"""
names: list[str] = []
for c in test_chunks:
if c.kind != ChunkKind.IMPORT or not c.metadata:
continue

resolved = _resolve_import_to_file(c.metadata, c.file_path, repo_files, resolution)
if resolved != source_file:
continue

names_str = c.metadata.names
if names_str:
names.extend(names_str.split(","))

return names


def infer_test_edges(
chunks: list[Chunk],
repo_files: set[str],
resolution_map: dict[str, ImportResolution] | None = None,
) -> list[Edge]:
"""Infer `TESTS` edges from test files.

Links test functions to the source symbols they test, using
file naming conventions and import analysis. When
*resolution_map* is provided, uses language-aware test-file
detection and file resolution.
"""
by_file: dict[str, list[Chunk]] = {}
# Track per-file language for resolution lookup.
file_language: dict[str, str] = {}
for c in chunks:
by_file.setdefault(c.file_path, []).append(c)
if c.language and c.file_path not in file_language:
file_language[c.file_path] = c.language

symbol_index = _build_symbol_index(chunks)
edges: list[Edge] = []

for file_path, file_chunks in by_file.items():
lang = file_language.get(file_path, "")
resolution = resolution_map.get(lang) if resolution_map else None

base_name = _strip_test_affix(
file_path,
test_prefix=resolution.test_prefix if resolution else "test_",
test_suffix=resolution.test_suffix if resolution else "",
)
if base_name is None:
continue

source_file = _find_source_file(base_name, file_path, repo_files, resolution)
if source_file is None:
continue

test_fns = [c for c in file_chunks if c.kind in (ChunkKind.FUNCTION, ChunkKind.METHOD)]
if not test_fns:
continue

imported_names = _imported_names_from_file(file_chunks, source_file, repo_files, resolution)

if imported_names:
for test_fn in test_fns:
for name in imported_names:
target = symbol_index.get((source_file, name))
if target is not None:
edges.append(
Edge(source_id=test_fn.id, target_id=target.id, kind=EdgeKind.TESTS)
)
else:
source_symbols = [
c
for c in by_file.get(source_file, [])
if c.kind in (ChunkKind.FUNCTION, ChunkKind.CLASS, ChunkKind.METHOD)
]
if source_symbols:
for test_fn in test_fns:
edges.append(
Edge(
source_id=test_fn.id,
target_id=source_symbols[0].id,
kind=EdgeKind.TESTS,
)
)

return edges
1 change: 0 additions & 1 deletion packages/rbtr/src/rbtr/index/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,6 @@ class EdgeKind(StrEnum):
CALLS = "calls"
IMPORTS = "imports"
INHERITS = "inherits"
TESTS = "tests"
DOCUMENTS = "documents"
CONFIGURES = "configures"

Expand Down
3 changes: 1 addition & 2 deletions packages/rbtr/src/rbtr/index/orchestrator.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@
from rbtr.config import config
from rbtr.git import FileEntry, changed_files, list_files
from rbtr.index.chunks import chunk_plaintext, detect_prose_format, host_presence_chunk
from rbtr.index.edges import build_resolution_map, infer_import_edges, infer_test_edges
from rbtr.index.edges import build_resolution_map, infer_import_edges
from rbtr.index.embeddings import Embedder, embedding_text
from rbtr.index.models import (
Chunk,
Expand Down Expand Up @@ -406,7 +406,6 @@ def _infer_and_store_edges(
resolution_map = build_resolution_map(mgr)
edges: list[Edge] = []
edges.extend(infer_import_edges(chunks, repo_files, resolution_map))
edges.extend(infer_test_edges(chunks, repo_files, resolution_map))

with store.session() as session:
session.replace_edges(commit_sha, edges, repo_id=repo_id)
Expand Down
1 change: 0 additions & 1 deletion packages/rbtr/src/rbtr/languages/c/plugin.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,5 @@
# any leading run.
doc_comment_node_types=frozenset({"comment"}),
source_roots=("", "include", "src"),
test_prefix="test_",
language_plugin_version=3,
)
1 change: 0 additions & 1 deletion packages/rbtr/src/rbtr/languages/cpp/plugin.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,5 @@ class Foo {
# Same grammar as C — single `comment` node.
doc_comment_node_types=frozenset({"comment"}),
source_roots=("", "include", "src"),
test_prefix="test_",
language_plugin_version=5,
)
1 change: 0 additions & 1 deletion packages/rbtr/src/rbtr/languages/go/plugin.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,5 @@
# link). The grammar uses a single `comment`
# type for both line and block forms.
doc_comment_node_types=frozenset({"comment"}),
test_suffix="_test",
language_plugin_version=3,
)
1 change: 0 additions & 1 deletion packages/rbtr/src/rbtr/languages/java/plugin.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,6 @@ class Service {
# directly above a method or class.
doc_comment_node_types=frozenset({"block_comment", "line_comment"}),
source_roots=("", "src/main/java"),
test_suffix="Test",
module_style=ModuleStyle.DOTTED,
language_plugin_version=3,
)
3 changes: 0 additions & 3 deletions packages/rbtr/src/rbtr/languages/javascript/plugin.py
Original file line number Diff line number Diff line change
Expand Up @@ -126,7 +126,6 @@ def extract_import_meta(
index_files=frozenset({"index.js"}),
import_targets=frozenset({"javascript", "css"}),
source_roots=("", "src"),
test_suffix=".test",
language_plugin_version=4,
)
typescript = LanguageRegistration(
Expand All @@ -153,7 +152,6 @@ def extract_import_meta(
index_files=frozenset({"index.ts", "index.js"}),
import_targets=frozenset({"typescript", "tsx", "javascript", "css"}),
source_roots=("", "src"),
test_suffix=".test",
language_plugin_version=4,
)
tsx = LanguageRegistration(
Expand All @@ -180,7 +178,6 @@ def extract_import_meta(
index_files=frozenset({"index.tsx", "index.ts", "index.js"}),
import_targets=frozenset({"tsx", "typescript", "javascript", "css"}),
source_roots=("", "src"),
test_suffix=".test",
language_plugin_version=1,
)

Expand Down
1 change: 0 additions & 1 deletion packages/rbtr/src/rbtr/languages/python/plugin.py
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,6 @@ def extract_import_meta(
class_scope_types=frozenset({"class_definition"}),
index_files=frozenset({"__init__.py"}),
source_roots=("", "src"),
test_prefix="test_",
module_style=ModuleStyle.DOTTED,
language_plugin_version=4,
)
Expand Down
Loading