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.
Files changed (47) hide show
  1. sourceindex/__init__.py +30 -0
  2. sourceindex/build/__init__.py +592 -0
  3. sourceindex/build/indexer.py +403 -0
  4. sourceindex/build/linerange/__init__.py +24 -0
  5. sourceindex/build/linerange/python_ast.py +155 -0
  6. sourceindex/build/linerange/treesitter.py +397 -0
  7. sourceindex/build/prompts.py +76 -0
  8. sourceindex/build/state.py +261 -0
  9. sourceindex/build/walker.py +94 -0
  10. sourceindex/claudecode/__init__.py +0 -0
  11. sourceindex/claudecode/savings.py +325 -0
  12. sourceindex/claudecode/savings_summary.py +88 -0
  13. sourceindex/claudecode/statusline.py +116 -0
  14. sourceindex/cli/__init__.py +304 -0
  15. sourceindex/cli/__main__.py +10 -0
  16. sourceindex/cli/api_key.py +171 -0
  17. sourceindex/cli/commands.py +678 -0
  18. sourceindex/cli/install.py +378 -0
  19. sourceindex/daemon/__init__.py +83 -0
  20. sourceindex/daemon/client.py +141 -0
  21. sourceindex/daemon/crypto.py +48 -0
  22. sourceindex/daemon/keyring_store.py +129 -0
  23. sourceindex/daemon/lifecycle.py +237 -0
  24. sourceindex/daemon/protocol.py +134 -0
  25. sourceindex/daemon/server.py +389 -0
  26. sourceindex/daemon/store.py +426 -0
  27. sourceindex/lib/__init__.py +0 -0
  28. sourceindex/lib/backend.py +240 -0
  29. sourceindex/lib/cost.py +96 -0
  30. sourceindex/lib/env.py +30 -0
  31. sourceindex/lib/git.py +33 -0
  32. sourceindex/lib/languages.py +406 -0
  33. sourceindex/lib/llm.py +298 -0
  34. sourceindex/lib/log.py +228 -0
  35. sourceindex/lib/registry.py +75 -0
  36. sourceindex/lib/timing.py +10 -0
  37. sourceindex/search/__init__.py +251 -0
  38. sourceindex/search/experiments.py +276 -0
  39. sourceindex/search/imports.py +155 -0
  40. sourceindex/search/passes.py +51 -0
  41. sourceindex/search/prompts.py +379 -0
  42. sourceindex/search/roadmap.py +265 -0
  43. sourceindex/server.py +127 -0
  44. sourceindex-0.1.0.dist-info/METADATA +73 -0
  45. sourceindex-0.1.0.dist-info/RECORD +47 -0
  46. sourceindex-0.1.0.dist-info/WHEEL +4 -0
  47. 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