SourceIndex 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- sourceindex/__init__.py +30 -0
- sourceindex/build/__init__.py +592 -0
- sourceindex/build/indexer.py +403 -0
- sourceindex/build/linerange/__init__.py +24 -0
- sourceindex/build/linerange/python_ast.py +155 -0
- sourceindex/build/linerange/treesitter.py +397 -0
- sourceindex/build/prompts.py +76 -0
- sourceindex/build/state.py +261 -0
- sourceindex/build/walker.py +94 -0
- sourceindex/claudecode/__init__.py +0 -0
- sourceindex/claudecode/savings.py +325 -0
- sourceindex/claudecode/savings_summary.py +88 -0
- sourceindex/claudecode/statusline.py +116 -0
- sourceindex/cli/__init__.py +304 -0
- sourceindex/cli/__main__.py +10 -0
- sourceindex/cli/api_key.py +171 -0
- sourceindex/cli/commands.py +678 -0
- sourceindex/cli/install.py +378 -0
- sourceindex/daemon/__init__.py +83 -0
- sourceindex/daemon/client.py +141 -0
- sourceindex/daemon/crypto.py +48 -0
- sourceindex/daemon/keyring_store.py +129 -0
- sourceindex/daemon/lifecycle.py +237 -0
- sourceindex/daemon/protocol.py +134 -0
- sourceindex/daemon/server.py +389 -0
- sourceindex/daemon/store.py +426 -0
- sourceindex/lib/__init__.py +0 -0
- sourceindex/lib/backend.py +240 -0
- sourceindex/lib/cost.py +96 -0
- sourceindex/lib/env.py +30 -0
- sourceindex/lib/git.py +33 -0
- sourceindex/lib/languages.py +406 -0
- sourceindex/lib/llm.py +298 -0
- sourceindex/lib/log.py +228 -0
- sourceindex/lib/registry.py +75 -0
- sourceindex/lib/timing.py +10 -0
- sourceindex/search/__init__.py +251 -0
- sourceindex/search/experiments.py +276 -0
- sourceindex/search/imports.py +155 -0
- sourceindex/search/passes.py +51 -0
- sourceindex/search/prompts.py +379 -0
- sourceindex/search/roadmap.py +265 -0
- sourceindex/server.py +127 -0
- sourceindex-0.1.0.dist-info/METADATA +73 -0
- sourceindex-0.1.0.dist-info/RECORD +47 -0
- sourceindex-0.1.0.dist-info/WHEEL +4 -0
- sourceindex-0.1.0.dist-info/entry_points.txt +2 -0
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
"""Two-pass search pipeline producing an enriched codebase roadmap.
|
|
2
|
+
|
|
3
|
+
Pass 1a: index_search_model reads tier-1 index + query → picks relevant files.
|
|
4
|
+
Pass 1b: index_search_model reads tier-2 function indexes → picks specific
|
|
5
|
+
functions.
|
|
6
|
+
Enrichment: annotate each entry with its tier-2 description, extract top-level
|
|
7
|
+
imports from the selected files, group entries per file with merged
|
|
8
|
+
read offset/limit, and wrap in a roadmap preamble.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import re
|
|
12
|
+
import time
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
from ..build import DEFAULT_MODEL, refresh_index_lazy
|
|
16
|
+
from ..lib.llm import call_llm
|
|
17
|
+
from ..lib.log import get_logger
|
|
18
|
+
from ..lib.timing import elapsed_ms
|
|
19
|
+
from .experiments import apply_experiments
|
|
20
|
+
from .passes import _strip_llm_wrapper
|
|
21
|
+
from .prompts import (
|
|
22
|
+
PASS_1A_PROMPT, PASS_1A_QA_PROMPT,
|
|
23
|
+
PASS_1B_PROMPT, PASS_1B_QA_PROMPT,
|
|
24
|
+
)
|
|
25
|
+
from .roadmap import _build_roadmap
|
|
26
|
+
|
|
27
|
+
_log = get_logger(__name__)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _salvage_selected_path(filepath: str, store) -> str | None:
|
|
31
|
+
"""Recover a Pass-1a path that doesn't match the index keys.
|
|
32
|
+
|
|
33
|
+
The search model occasionally echoes the agent's mount prefix onto paths
|
|
34
|
+
(``/app/cmd/run.go`` for the indexed ``cmd/run.go``) or inserts stray spaces
|
|
35
|
+
around separators. Detail keys are repo-relative, so normalize spacing and
|
|
36
|
+
then progressively strip leading components until a known detail matches.
|
|
37
|
+
"""
|
|
38
|
+
candidate = re.sub(r"\s*/\s*", "/", filepath).strip()
|
|
39
|
+
for _ in range(4):
|
|
40
|
+
if store.exists(f"details/{candidate}.md"):
|
|
41
|
+
return candidate
|
|
42
|
+
if "/" not in candidate:
|
|
43
|
+
return None
|
|
44
|
+
candidate = candidate.split("/", 1)[1]
|
|
45
|
+
return candidate if store.exists(f"details/{candidate}.md") else None
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def search_index(
|
|
49
|
+
query: str,
|
|
50
|
+
repo_root: Path,
|
|
51
|
+
index_dir: Path | None = None,
|
|
52
|
+
index_search_model: str = DEFAULT_MODEL,
|
|
53
|
+
mode: str = "implement",
|
|
54
|
+
) -> str:
|
|
55
|
+
"""Run the two-pass search pipeline and return an enriched roadmap.
|
|
56
|
+
|
|
57
|
+
Args:
|
|
58
|
+
query: Natural language description of what the agent needs.
|
|
59
|
+
repo_root: Path to the repository root.
|
|
60
|
+
index_dir: Path to the index directory (default: repo_root/.sourceindex).
|
|
61
|
+
index_search_model: LLM model for passes 1a and 1b.
|
|
62
|
+
mode: "implement" (default) or "qa" — selects prompt variants and
|
|
63
|
+
roadmap constraints appropriate for the task type.
|
|
64
|
+
|
|
65
|
+
Returns:
|
|
66
|
+
Roadmap text with per-file groupings, function descriptions, and imports.
|
|
67
|
+
"""
|
|
68
|
+
from ..build import _resolve_plaintext_store
|
|
69
|
+
store = _resolve_plaintext_store(repo_root, index_dir)
|
|
70
|
+
return search_index_with_store(query, repo_root, store, index_search_model, None, mode=mode)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def search_index_with_store(
|
|
74
|
+
query: str,
|
|
75
|
+
repo_root: Path,
|
|
76
|
+
store,
|
|
77
|
+
index_search_model: str | None = None,
|
|
78
|
+
api_key: str | None = None,
|
|
79
|
+
*,
|
|
80
|
+
mode: str = "implement",
|
|
81
|
+
) -> str:
|
|
82
|
+
is_qa = mode == "qa"
|
|
83
|
+
prompt_1a_tpl = PASS_1A_QA_PROMPT if is_qa else PASS_1A_PROMPT
|
|
84
|
+
prompt_1b_tpl = PASS_1B_QA_PROMPT if is_qa else PASS_1B_PROMPT
|
|
85
|
+
model = index_search_model or DEFAULT_MODEL
|
|
86
|
+
search_start = time.perf_counter()
|
|
87
|
+
_log.debug(
|
|
88
|
+
"Search starting",
|
|
89
|
+
extra={
|
|
90
|
+
"event": "search.start",
|
|
91
|
+
"query": query,
|
|
92
|
+
"query_len": len(query),
|
|
93
|
+
"repo_root": str(repo_root),
|
|
94
|
+
"model": model,
|
|
95
|
+
},
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
try:
|
|
99
|
+
refresh_index_lazy(repo_root, store=store, model=model, api_key=api_key)
|
|
100
|
+
except Exception as e:
|
|
101
|
+
_log.warning(
|
|
102
|
+
f"Lazy refresh failed (continuing with stale index): {e}",
|
|
103
|
+
extra={
|
|
104
|
+
"event": "lazy_refresh.failed",
|
|
105
|
+
"error_type": type(e).__name__,
|
|
106
|
+
"error": str(e),
|
|
107
|
+
},
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
if not store.exists("index.md"):
|
|
111
|
+
_log.error(
|
|
112
|
+
"No index found. Run `sourceindex init` first.",
|
|
113
|
+
extra={"event": "search.no_index", "index_path": str(store.path_for("index.md"))},
|
|
114
|
+
)
|
|
115
|
+
return "ERROR: No index found. Run `sourceindex init` first."
|
|
116
|
+
|
|
117
|
+
index_content = store.read_text("index.md")
|
|
118
|
+
|
|
119
|
+
from ..lib.llm import resolve_api_key
|
|
120
|
+
search_api_key = resolve_api_key(api_key)
|
|
121
|
+
assert search_api_key, (
|
|
122
|
+
"No API key found. Set SOURCEINDEX_API_KEY (an OpenRouter key, used "
|
|
123
|
+
"for both indexing and search)."
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
# ── Pass 1a: Pick relevant files ──────────────────────────────────────
|
|
127
|
+
_log.info("Pass 1a: Selecting relevant files...", extra={"event": "search.pass_1a.start"})
|
|
128
|
+
|
|
129
|
+
prompt_1a = prompt_1a_tpl.format(index_content=index_content, instruction=query)
|
|
130
|
+
pass_1a_t0 = time.perf_counter()
|
|
131
|
+
file_list, _ = call_llm(
|
|
132
|
+
prompt_1a,
|
|
133
|
+
model=model,
|
|
134
|
+
api_key=search_api_key,
|
|
135
|
+
)
|
|
136
|
+
if not file_list:
|
|
137
|
+
_log.error(
|
|
138
|
+
"Pass 1a (file selection) failed.",
|
|
139
|
+
extra={"event": "search.pass_1a.failed"},
|
|
140
|
+
)
|
|
141
|
+
return "ERROR: Pass 1a (file selection) failed."
|
|
142
|
+
file_list = _strip_llm_wrapper(file_list)
|
|
143
|
+
|
|
144
|
+
selected_files = [f.strip().lstrip("/") for f in file_list.strip().split("\n") if f.strip()]
|
|
145
|
+
_log.info(
|
|
146
|
+
f"Selected files:\n{file_list}",
|
|
147
|
+
extra={
|
|
148
|
+
"event": "search.pass_1a",
|
|
149
|
+
"files_selected": len(selected_files),
|
|
150
|
+
"files": selected_files,
|
|
151
|
+
"latency_ms": elapsed_ms(pass_1a_t0),
|
|
152
|
+
},
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
# ── Load tier-2 details for selected files via the store ──────────────
|
|
156
|
+
selected_details = ""
|
|
157
|
+
skipped: list[str] = []
|
|
158
|
+
detail_texts: dict[str, str] = {}
|
|
159
|
+
for filepath in selected_files:
|
|
160
|
+
if not filepath:
|
|
161
|
+
continue
|
|
162
|
+
if not store.exists(f"details/{filepath}.md"):
|
|
163
|
+
salvaged = _salvage_selected_path(filepath, store)
|
|
164
|
+
if salvaged is None:
|
|
165
|
+
skipped.append(filepath)
|
|
166
|
+
_log.debug(
|
|
167
|
+
f" Skipping (no detail): {filepath}",
|
|
168
|
+
extra={"event": "search.detail_missing", "path": filepath},
|
|
169
|
+
)
|
|
170
|
+
continue
|
|
171
|
+
_log.info(
|
|
172
|
+
f" Salvaged Pass-1a path: {filepath} -> {salvaged}",
|
|
173
|
+
extra={"event": "search.detail_salvaged",
|
|
174
|
+
"from_path": filepath, "to_path": salvaged},
|
|
175
|
+
)
|
|
176
|
+
filepath = salvaged
|
|
177
|
+
text = store.read_text(f"details/{filepath}.md")
|
|
178
|
+
detail_texts[filepath] = text
|
|
179
|
+
selected_details += f"\n--- {filepath} ---\n{text}\n--- end ---\n\n"
|
|
180
|
+
|
|
181
|
+
if not selected_details:
|
|
182
|
+
_log.error(
|
|
183
|
+
"No tier-2 details found for any selected file.",
|
|
184
|
+
extra={"event": "search.no_details", "skipped": skipped},
|
|
185
|
+
)
|
|
186
|
+
return "ERROR: No tier-2 details found for any selected file."
|
|
187
|
+
|
|
188
|
+
# ── Pass 1b: Pick specific functions ──────────────────────────────────
|
|
189
|
+
_log.info(
|
|
190
|
+
"Pass 1b: Selecting specific functions...",
|
|
191
|
+
extra={"event": "search.pass_1b.start"},
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
prompt_1b = prompt_1b_tpl.format(selected_details=selected_details, instruction=query)
|
|
195
|
+
pass_1b_t0 = time.perf_counter()
|
|
196
|
+
function_list, _ = call_llm(
|
|
197
|
+
prompt_1b,
|
|
198
|
+
model=model,
|
|
199
|
+
api_key=search_api_key,
|
|
200
|
+
)
|
|
201
|
+
if not function_list:
|
|
202
|
+
_log.error(
|
|
203
|
+
"Pass 1b (function selection) failed.",
|
|
204
|
+
extra={"event": "search.pass_1b.failed"},
|
|
205
|
+
)
|
|
206
|
+
return "ERROR: Pass 1b (function selection) failed."
|
|
207
|
+
# Split out optional CODE LANDSCAPE block. The Pass-1b prompt asks the
|
|
208
|
+
# model to emit `=== CODE LANDSCAPE ===` followed by `=== SELECTIONS ===`.
|
|
209
|
+
# If markers are missing (older models / format drift), treat the entire
|
|
210
|
+
# response as SELECTIONS — backward compatible with the prior shape.
|
|
211
|
+
raw_output = function_list
|
|
212
|
+
landscape_block = ""
|
|
213
|
+
if "=== SELECTIONS ===" in raw_output:
|
|
214
|
+
head, _, tail = raw_output.partition("=== SELECTIONS ===")
|
|
215
|
+
if "=== CODE LANDSCAPE ===" in head:
|
|
216
|
+
_, _, landscape_block = head.partition("=== CODE LANDSCAPE ===")
|
|
217
|
+
else:
|
|
218
|
+
landscape_block = head
|
|
219
|
+
function_list = tail
|
|
220
|
+
function_list = _strip_llm_wrapper(function_list)
|
|
221
|
+
|
|
222
|
+
selected_funcs = [l.strip() for l in function_list.split("\n") if l.strip()]
|
|
223
|
+
_log.info(
|
|
224
|
+
f"Selected functions:\n{function_list}",
|
|
225
|
+
extra={
|
|
226
|
+
"event": "search.pass_1b",
|
|
227
|
+
"functions_selected": len(selected_funcs),
|
|
228
|
+
"landscape_chars": len(landscape_block.strip()),
|
|
229
|
+
"latency_ms": elapsed_ms(pass_1b_t0),
|
|
230
|
+
},
|
|
231
|
+
)
|
|
232
|
+
|
|
233
|
+
# Env-gated post-processors (SOURCEINDEX_FIX_*). No-op when flags unset.
|
|
234
|
+
function_list = apply_experiments(function_list, query, repo_root, store)
|
|
235
|
+
|
|
236
|
+
# ── Enrichment: annotate, group, extract imports, wrap preamble ──────
|
|
237
|
+
roadmap = _build_roadmap(
|
|
238
|
+
function_list, repo_root, store, detail_texts,
|
|
239
|
+
landscape=landscape_block, mode=mode, question=query,
|
|
240
|
+
)
|
|
241
|
+
_log.info(
|
|
242
|
+
f"Roadmap: {len(roadmap)} bytes.",
|
|
243
|
+
extra={
|
|
244
|
+
"event": "search.done",
|
|
245
|
+
"roadmap_bytes": len(roadmap),
|
|
246
|
+
"files_selected": len(selected_files),
|
|
247
|
+
"functions_selected": len(selected_funcs),
|
|
248
|
+
"latency_ms": elapsed_ms(search_start),
|
|
249
|
+
},
|
|
250
|
+
)
|
|
251
|
+
return roadmap
|
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
"""Pass-1b post-processing experiments, env-var gated.
|
|
2
|
+
|
|
3
|
+
Three independent post-processors on the Pass-1b SELECTIONS string. Each
|
|
4
|
+
is a no-op unless its ``SOURCEINDEX_FIX_*`` env var is set to ``1``, so
|
|
5
|
+
the production search path is byte-identical when flags are unset.
|
|
6
|
+
|
|
7
|
+
SOURCEINDEX_FIX_SIBCAP=1 cap_sibling_pulls
|
|
8
|
+
SOURCEINDEX_FIX_TESTPAIR=1 inject_test_pairs
|
|
9
|
+
SOURCEINDEX_FIX_DOCS=1 inject_docs_for_add_task
|
|
10
|
+
|
|
11
|
+
Apply via ``apply_experiments`` immediately before ``_build_roadmap``.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
import os
|
|
15
|
+
import re
|
|
16
|
+
from collections import defaultdict
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
# "EDIT pre_commit/store.py::Store._new_repo (L132-L171)" → tag, path, rest
|
|
20
|
+
_TAG_LINE_RE = re.compile(r"^(EDIT|READ)\s+([^\s:]+(?:[^\s:]|/)*?)::(.+)$")
|
|
21
|
+
_TIER2_ENTRY_RE = re.compile(r"^(.+?) \(L(\d+)-L(\d+)\):")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _parse_selections(function_list: str):
|
|
25
|
+
"""Yield (tag, path, rest) per non-blank SELECTIONS line."""
|
|
26
|
+
for raw in function_list.split("\n"):
|
|
27
|
+
line = raw.strip()
|
|
28
|
+
if not line or "::" not in line:
|
|
29
|
+
continue
|
|
30
|
+
m = _TAG_LINE_RE.match(line)
|
|
31
|
+
if m:
|
|
32
|
+
yield m.group(1), m.group(2).lstrip("/"), m.group(3)
|
|
33
|
+
else:
|
|
34
|
+
path, _, rest = line.partition("::")
|
|
35
|
+
yield "READ", path.strip().lstrip("/"), rest
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _sibling_groups(paths: list[str]):
|
|
39
|
+
"""Yield index lists for sibling groups of ≥3 paths.
|
|
40
|
+
|
|
41
|
+
Two heuristics combined:
|
|
42
|
+
A) Same parent directory — catches e.g. ``pre_commit/{store,clientlib,
|
|
43
|
+
repository,hook,util,yaml}.py``.
|
|
44
|
+
B) Same grand-parent dir + same basename — catches e.g.
|
|
45
|
+
``faker/providers/company/{es_ES,fr_FR,pt_BR}/__init__.py``
|
|
46
|
+
where parent dirs differ but the basename + grand-parent are shared.
|
|
47
|
+
"""
|
|
48
|
+
by_parent: dict[str, set[int]] = defaultdict(set)
|
|
49
|
+
by_grand_basename: dict[tuple[str, str], set[int]] = defaultdict(set)
|
|
50
|
+
for i, p in enumerate(paths):
|
|
51
|
+
parts = Path(p).parts
|
|
52
|
+
if len(parts) >= 1:
|
|
53
|
+
by_parent[str(Path(p).parent)].add(i)
|
|
54
|
+
if len(parts) >= 3:
|
|
55
|
+
grand = str(Path(p).parent.parent)
|
|
56
|
+
base = parts[-1]
|
|
57
|
+
by_grand_basename[(grand, base)].add(i)
|
|
58
|
+
seen: set[tuple[int, ...]] = set()
|
|
59
|
+
for idxs in list(by_parent.values()) + list(by_grand_basename.values()):
|
|
60
|
+
if len(idxs) >= 3:
|
|
61
|
+
key = tuple(sorted(idxs))
|
|
62
|
+
if key not in seen:
|
|
63
|
+
seen.add(key)
|
|
64
|
+
yield list(key)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def cap_sibling_pulls(function_list: str, max_read_per_group: int = 2) -> str:
|
|
68
|
+
"""Drop READ entries when a sibling group exceeds ``max_read_per_group``.
|
|
69
|
+
|
|
70
|
+
EDIT entries are never dropped. Within a sibling group, the first N READ
|
|
71
|
+
files (by Pass-1b emission order) are kept; the rest have *all* their
|
|
72
|
+
SELECTIONS lines stripped.
|
|
73
|
+
"""
|
|
74
|
+
parsed = list(_parse_selections(function_list))
|
|
75
|
+
if not parsed:
|
|
76
|
+
return function_list
|
|
77
|
+
|
|
78
|
+
# Per-file roll-up: dominant tag, first-emission index
|
|
79
|
+
file_tag: dict[str, str] = {}
|
|
80
|
+
file_first_idx: dict[str, int] = {}
|
|
81
|
+
for i, (tag, path, _) in enumerate(parsed):
|
|
82
|
+
if path not in file_tag:
|
|
83
|
+
file_tag[path] = tag
|
|
84
|
+
file_first_idx[path] = i
|
|
85
|
+
elif tag == "EDIT":
|
|
86
|
+
file_tag[path] = "EDIT"
|
|
87
|
+
|
|
88
|
+
unique_paths = list(file_tag.keys())
|
|
89
|
+
drop_files: set[str] = set()
|
|
90
|
+
for group_idxs in _sibling_groups(unique_paths):
|
|
91
|
+
group_paths = {unique_paths[i] for i in group_idxs}
|
|
92
|
+
read_paths = [p for p in unique_paths if p in group_paths and file_tag[p] == "READ"]
|
|
93
|
+
for p in read_paths[max_read_per_group:]:
|
|
94
|
+
drop_files.add(p)
|
|
95
|
+
|
|
96
|
+
if not drop_files:
|
|
97
|
+
return function_list
|
|
98
|
+
|
|
99
|
+
kept = []
|
|
100
|
+
for raw in function_list.split("\n"):
|
|
101
|
+
line = raw.strip()
|
|
102
|
+
if line and "::" in line:
|
|
103
|
+
m = _TAG_LINE_RE.match(line)
|
|
104
|
+
if m:
|
|
105
|
+
path = m.group(2).lstrip("/")
|
|
106
|
+
else:
|
|
107
|
+
path = line.partition("::")[0].strip().lstrip("/")
|
|
108
|
+
if path in drop_files:
|
|
109
|
+
continue
|
|
110
|
+
kept.append(raw)
|
|
111
|
+
return "\n".join(kept)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _test_path_candidates(edit_path: str) -> list[str]:
|
|
115
|
+
"""Generate candidate test-file paths for an EDIT target.
|
|
116
|
+
|
|
117
|
+
Covers conventions seen in the eval corpus:
|
|
118
|
+
pre_commit/store.py → tests/store_test.py
|
|
119
|
+
src/jinja2/parser.py → tests/test_parser.py
|
|
120
|
+
hermes_cli/models.py → tests/hermes_cli/test_ai_gateway_models.py
|
|
121
|
+
(we won't catch the qualifier — handled
|
|
122
|
+
by listing both basic + dir-mirrored forms)
|
|
123
|
+
awscli/customizations/s3/subcommands.py
|
|
124
|
+
→ tests/functional/s3/test_subcommands.py
|
|
125
|
+
"""
|
|
126
|
+
p = Path(edit_path)
|
|
127
|
+
basename = p.stem
|
|
128
|
+
parts = list(p.parts)
|
|
129
|
+
if parts and parts[0] == "src":
|
|
130
|
+
parts = parts[1:]
|
|
131
|
+
cands: list[str] = []
|
|
132
|
+
# Direct conventions at top of tests/ tree
|
|
133
|
+
for prefix in ("tests", "test"):
|
|
134
|
+
cands.append(f"{prefix}/test_{basename}.py")
|
|
135
|
+
cands.append(f"{prefix}/{basename}_test.py")
|
|
136
|
+
# Mirror inner package layout
|
|
137
|
+
if len(parts) >= 2:
|
|
138
|
+
subdir = "/".join(parts[1:-1])
|
|
139
|
+
if subdir:
|
|
140
|
+
for prefix in ("tests", "test"):
|
|
141
|
+
cands.append(f"{prefix}/{subdir}/test_{basename}.py")
|
|
142
|
+
cands.append(f"{prefix}/{subdir}/{basename}_test.py")
|
|
143
|
+
# awscli/django split layout
|
|
144
|
+
for sub in ("functional", "unit"):
|
|
145
|
+
cands.append(f"tests/{sub}/test_{basename}.py")
|
|
146
|
+
if len(parts) >= 2:
|
|
147
|
+
subdir = "/".join(parts[1:-1])
|
|
148
|
+
if subdir:
|
|
149
|
+
cands.append(f"tests/{sub}/{subdir}/test_{basename}.py")
|
|
150
|
+
seen = set()
|
|
151
|
+
out = []
|
|
152
|
+
for c in cands:
|
|
153
|
+
if c not in seen:
|
|
154
|
+
seen.add(c)
|
|
155
|
+
out.append(c)
|
|
156
|
+
return out
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def inject_test_pairs(
|
|
160
|
+
function_list: str, repo_root: Path, store=None, max_entries: int = 3
|
|
161
|
+
) -> str:
|
|
162
|
+
"""Append READ entries for test files that pair with EDIT targets.
|
|
163
|
+
|
|
164
|
+
For each EDIT target, walk a list of conventional test-file paths, pick
|
|
165
|
+
the first that exists on disk, and emit up to ``max_entries`` synthetic
|
|
166
|
+
READ lines drawn from that test file's tier-2 details (so the roadmap
|
|
167
|
+
builder produces real ``offset``/``limit`` windows). If no tier-2 is
|
|
168
|
+
cached for the file, fall back to a whole-file READ marker.
|
|
169
|
+
"""
|
|
170
|
+
parsed = list(_parse_selections(function_list))
|
|
171
|
+
edit_files = [p for tag, p, _ in parsed if tag == "EDIT"]
|
|
172
|
+
listed = {p for _, p, _ in parsed}
|
|
173
|
+
seen_edit: set[str] = set()
|
|
174
|
+
extras: list[str] = []
|
|
175
|
+
for edit_path in edit_files:
|
|
176
|
+
if edit_path in seen_edit:
|
|
177
|
+
continue
|
|
178
|
+
seen_edit.add(edit_path)
|
|
179
|
+
for candidate in _test_path_candidates(edit_path):
|
|
180
|
+
if candidate in listed:
|
|
181
|
+
break
|
|
182
|
+
full = repo_root / candidate
|
|
183
|
+
if not full.is_file():
|
|
184
|
+
continue
|
|
185
|
+
count = 0
|
|
186
|
+
rel = f"details/{candidate}.md"
|
|
187
|
+
if store is not None:
|
|
188
|
+
try:
|
|
189
|
+
if store.exists(rel):
|
|
190
|
+
for line in store.read_text(rel).splitlines():
|
|
191
|
+
m = _TIER2_ENTRY_RE.match(line.strip())
|
|
192
|
+
if m:
|
|
193
|
+
extras.append(
|
|
194
|
+
f"READ {candidate}::{m.group(1).strip()} "
|
|
195
|
+
f"(L{m.group(2)}-L{m.group(3)})"
|
|
196
|
+
)
|
|
197
|
+
count += 1
|
|
198
|
+
if count >= max_entries:
|
|
199
|
+
break
|
|
200
|
+
except Exception:
|
|
201
|
+
pass
|
|
202
|
+
if count == 0:
|
|
203
|
+
extras.append(f"READ {candidate}::module (L1-L1)")
|
|
204
|
+
listed.add(candidate)
|
|
205
|
+
break
|
|
206
|
+
if not extras:
|
|
207
|
+
return function_list
|
|
208
|
+
return function_list.rstrip("\n") + "\n" + "\n".join(extras) + "\n"
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
_ADD_VERB_RE = re.compile(
|
|
212
|
+
r"\b(add|adds|added|adding|introduce|introduces|introducing|"
|
|
213
|
+
r"support|new\s+(?:flag|command|option|provider|locale|channel|tag))\b",
|
|
214
|
+
re.IGNORECASE,
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def inject_docs_for_add_task(
|
|
219
|
+
function_list: str, query: str, repo_root: Path
|
|
220
|
+
) -> str:
|
|
221
|
+
"""When the task verb is add/introduce/etc., surface CHANGES + docs.
|
|
222
|
+
|
|
223
|
+
Scans the repo for the obvious documentation-update sites and appends
|
|
224
|
+
them as whole-file READ entries. Limited scan (no recursion past the
|
|
225
|
+
first level of ``docs/``) to keep the roadmap from ballooning.
|
|
226
|
+
"""
|
|
227
|
+
if not _ADD_VERB_RE.search(query or ""):
|
|
228
|
+
return function_list
|
|
229
|
+
listed = {p for _, p, _ in _parse_selections(function_list)}
|
|
230
|
+
extras: list[str] = []
|
|
231
|
+
|
|
232
|
+
# Top-level CHANGES / CHANGELOG variants
|
|
233
|
+
for name in ("CHANGES.rst", "CHANGES.md", "CHANGES.txt",
|
|
234
|
+
"CHANGELOG.md", "CHANGELOG.rst", "CHANGELOG.txt"):
|
|
235
|
+
if (repo_root / name).is_file() and name not in listed:
|
|
236
|
+
extras.append(f"READ {name}::module (L1-L1)")
|
|
237
|
+
listed.add(name)
|
|
238
|
+
break # at most one CHANGELOG
|
|
239
|
+
|
|
240
|
+
# .changes/next-release/ (awscli convention)
|
|
241
|
+
cd = repo_root / ".changes" / "next-release"
|
|
242
|
+
if cd.is_dir():
|
|
243
|
+
for entry in sorted(cd.iterdir())[:2]:
|
|
244
|
+
if entry.is_file():
|
|
245
|
+
rel = f".changes/next-release/{entry.name}"
|
|
246
|
+
if rel not in listed:
|
|
247
|
+
extras.append(f"READ {rel}::module (L1-L1)")
|
|
248
|
+
listed.add(rel)
|
|
249
|
+
|
|
250
|
+
# Single docs/ entry — surface one likely-relevant doc, not all of them
|
|
251
|
+
docs = repo_root / "docs"
|
|
252
|
+
if docs.is_dir():
|
|
253
|
+
for entry in sorted(docs.iterdir()):
|
|
254
|
+
if entry.is_file() and entry.suffix in (".md", ".rst"):
|
|
255
|
+
rel = f"docs/{entry.name}"
|
|
256
|
+
if rel not in listed:
|
|
257
|
+
extras.append(f"READ {rel}::module (L1-L1)")
|
|
258
|
+
listed.add(rel)
|
|
259
|
+
break
|
|
260
|
+
|
|
261
|
+
if not extras:
|
|
262
|
+
return function_list
|
|
263
|
+
return function_list.rstrip("\n") + "\n" + "\n".join(extras) + "\n"
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def apply_experiments(
|
|
267
|
+
function_list: str, query: str, repo_root: Path, store=None
|
|
268
|
+
) -> str:
|
|
269
|
+
"""Apply env-gated post-processors to Pass-1b SELECTIONS."""
|
|
270
|
+
if os.environ.get("SOURCEINDEX_FIX_SIBCAP") == "1":
|
|
271
|
+
function_list = cap_sibling_pulls(function_list)
|
|
272
|
+
if os.environ.get("SOURCEINDEX_FIX_TESTPAIR") == "1":
|
|
273
|
+
function_list = inject_test_pairs(function_list, repo_root, store)
|
|
274
|
+
if os.environ.get("SOURCEINDEX_FIX_DOCS") == "1":
|
|
275
|
+
function_list = inject_docs_for_add_task(function_list, query, repo_root)
|
|
276
|
+
return function_list
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
"""Top-of-file imports extraction + Python imports-preamble detection.
|
|
2
|
+
|
|
3
|
+
Used by the roadmap builder to:
|
|
4
|
+
- show the agent every import statement from each roadmap file (so it sees
|
|
5
|
+
the surrounding API without a full-file read), and
|
|
6
|
+
- emit a separate ``(1, preamble_end)`` Read window for Python EDIT targets
|
|
7
|
+
so new imports are added without an extra round trip.
|
|
8
|
+
|
|
9
|
+
Python is special-cased to use the AST because regex can't handle two
|
|
10
|
+
common patterns: multi-line ``from X import (a, b, c)`` and
|
|
11
|
+
``try/except ImportError`` shims with ``if TYPE_CHECKING:`` guards. Other
|
|
12
|
+
languages use the per-Language ``import_pattern`` regex.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
import ast
|
|
16
|
+
import re
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
from ..lib.languages import default_languages, language_for_path
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
# Languages whose imports are extracted via AST instead of the per-Language
|
|
23
|
+
# `import_pattern` regex. Python is the only one — and stays special-cased
|
|
24
|
+
# because (a) Python is the dominant target language for this tool, so its
|
|
25
|
+
# output quality matters most, and (b) regex can't reproduce two AST-only
|
|
26
|
+
# behaviors that are common in real Python code:
|
|
27
|
+
# 1. Multi-line imports — `from foo import (\n a,\n b,\n)` is one logical
|
|
28
|
+
# statement; regex only matches the first line and drops `a, b`.
|
|
29
|
+
# 2. `try/except ImportError:` shims and `if TYPE_CHECKING:` guards — AST
|
|
30
|
+
# sees the surrounding structure; regex sees the import lines in
|
|
31
|
+
# isolation, missing the shim that explains them.
|
|
32
|
+
# `_imports_preamble_end` (used in _build_roadmap to add a `(1, preamble_end)`
|
|
33
|
+
# Read window for EDIT targets) is also Python-only for the same reason: it
|
|
34
|
+
# needs the AST to know where the imports block ends — regex matches don't
|
|
35
|
+
# tell you that.
|
|
36
|
+
_AST_IMPORT_LANGS: frozenset[str] = frozenset({"python"})
|
|
37
|
+
|
|
38
|
+
_IMPORT_SCAN_MAX_LINES = 400 # cap for regex-based scan in non-Python files
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _parse_py(source_path: Path) -> ast.Module | None:
|
|
42
|
+
"""Parse a Python file to an AST, or return None on any read/parse failure."""
|
|
43
|
+
try:
|
|
44
|
+
return ast.parse(source_path.read_text(errors="replace"))
|
|
45
|
+
except (OSError, SyntaxError, ValueError):
|
|
46
|
+
return None
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _is_import_only_try(node: ast.Try) -> bool:
|
|
50
|
+
return bool(node.body) and all(
|
|
51
|
+
isinstance(b, (ast.Import, ast.ImportFrom)) for b in node.body
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _is_type_checking_if(node: ast.If) -> bool:
|
|
56
|
+
t = node.test
|
|
57
|
+
return (isinstance(t, ast.Name) and t.id == "TYPE_CHECKING") or (
|
|
58
|
+
isinstance(t, ast.Attribute) and t.attr == "TYPE_CHECKING"
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _is_dunder_assign(node: ast.AST) -> bool:
|
|
63
|
+
targets = getattr(node, "targets", None) or (
|
|
64
|
+
[node.target] if isinstance(node, ast.AnnAssign) else []
|
|
65
|
+
)
|
|
66
|
+
return any(
|
|
67
|
+
isinstance(t, ast.Name) and t.id.startswith("__") and t.id.endswith("__")
|
|
68
|
+
for t in targets
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _extract_imports_python(source_path: Path) -> str:
|
|
73
|
+
"""Return top-level import statements for a Python file, one per line."""
|
|
74
|
+
tree = _parse_py(source_path)
|
|
75
|
+
if tree is None:
|
|
76
|
+
return ""
|
|
77
|
+
|
|
78
|
+
out: list[str] = []
|
|
79
|
+
for node in ast.iter_child_nodes(tree):
|
|
80
|
+
if isinstance(node, ast.Import):
|
|
81
|
+
names = ", ".join(
|
|
82
|
+
a.name + (f" as {a.asname}" if a.asname else "") for a in node.names
|
|
83
|
+
)
|
|
84
|
+
out.append(f"import {names}")
|
|
85
|
+
elif isinstance(node, ast.ImportFrom):
|
|
86
|
+
module = "." * node.level + (node.module or "")
|
|
87
|
+
names = ", ".join(
|
|
88
|
+
a.name + (f" as {a.asname}" if a.asname else "") for a in node.names
|
|
89
|
+
)
|
|
90
|
+
out.append(f"from {module} import {names}")
|
|
91
|
+
return "\n".join(out)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _extract_imports_regex(source_path: Path, pattern: str) -> str:
|
|
95
|
+
"""Scan the top of a file for lines matching the language's import pattern.
|
|
96
|
+
|
|
97
|
+
Stops after _IMPORT_SCAN_MAX_LINES; collects every matching line verbatim
|
|
98
|
+
(trimmed). Designed for non-Python languages where AST parsing isn't
|
|
99
|
+
available — pattern correctness is best-effort, not authoritative."""
|
|
100
|
+
rx = re.compile(pattern)
|
|
101
|
+
out: list[str] = []
|
|
102
|
+
try:
|
|
103
|
+
with source_path.open(errors="replace") as f:
|
|
104
|
+
for i, raw in enumerate(f):
|
|
105
|
+
if i >= _IMPORT_SCAN_MAX_LINES:
|
|
106
|
+
break
|
|
107
|
+
if rx.match(raw):
|
|
108
|
+
out.append(raw.rstrip("\n").strip())
|
|
109
|
+
except OSError:
|
|
110
|
+
return ""
|
|
111
|
+
return "\n".join(out)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def extract_imports(source_path: Path, rel_path: str) -> str:
|
|
115
|
+
"""Return import statements for any supported language. Empty string if
|
|
116
|
+
the language is unknown or has no import_pattern."""
|
|
117
|
+
lang = language_for_path(rel_path, list(default_languages()))
|
|
118
|
+
if lang is None:
|
|
119
|
+
return ""
|
|
120
|
+
if lang.key in _AST_IMPORT_LANGS:
|
|
121
|
+
return _extract_imports_python(source_path)
|
|
122
|
+
if lang.import_pattern is None:
|
|
123
|
+
return ""
|
|
124
|
+
return _extract_imports_regex(source_path, lang.import_pattern)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _imports_preamble_end(source_path: Path) -> int:
|
|
128
|
+
"""Return 1-indexed end line of the top-of-file imports preamble, or 0.
|
|
129
|
+
|
|
130
|
+
Preamble = contiguous module docstring + Import/ImportFrom +
|
|
131
|
+
try/except-ImportError shims + ``if TYPE_CHECKING:`` guards + dunder
|
|
132
|
+
assignments (__all__, __version__), stopping at the first FunctionDef,
|
|
133
|
+
ClassDef, or other real code.
|
|
134
|
+
"""
|
|
135
|
+
tree = _parse_py(source_path)
|
|
136
|
+
if tree is None:
|
|
137
|
+
return 0
|
|
138
|
+
|
|
139
|
+
end = 0
|
|
140
|
+
for i, node in enumerate(ast.iter_child_nodes(tree)):
|
|
141
|
+
# A module docstring is only a docstring when it's the first statement;
|
|
142
|
+
# a string-Expr at i > 0 is a no-op, not a docstring, so reject it there.
|
|
143
|
+
ok = (
|
|
144
|
+
isinstance(node, (ast.Import, ast.ImportFrom))
|
|
145
|
+
or (i == 0 and isinstance(node, ast.Expr)
|
|
146
|
+
and isinstance(getattr(node, "value", None), ast.Constant)
|
|
147
|
+
and isinstance(node.value.value, str))
|
|
148
|
+
or (isinstance(node, (ast.Assign, ast.AnnAssign)) and _is_dunder_assign(node))
|
|
149
|
+
or (isinstance(node, ast.If) and _is_type_checking_if(node))
|
|
150
|
+
or (isinstance(node, ast.Try) and _is_import_only_try(node))
|
|
151
|
+
)
|
|
152
|
+
if not ok:
|
|
153
|
+
break
|
|
154
|
+
end = max(end, getattr(node, "end_lineno", 0) or 0)
|
|
155
|
+
return end
|