code-search-cli 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.
- code_retrieval/__init__.py +6 -0
- code_retrieval/__main__.py +3 -0
- code_retrieval/cli.py +144 -0
- code_retrieval/engine.py +722 -0
- code_retrieval/index_store.py +286 -0
- code_retrieval/installer.py +65 -0
- code_retrieval/models.py +90 -0
- code_retrieval/query.py +61 -0
- code_retrieval/repository.py +161 -0
- code_retrieval/resources/code-search/SKILL.md +47 -0
- code_retrieval/resources/code-search/agents/openai.yaml +4 -0
- code_retrieval/structure.py +115 -0
- code_search_cli-0.1.0.dist-info/METADATA +287 -0
- code_search_cli-0.1.0.dist-info/RECORD +19 -0
- code_search_cli-0.1.0.dist-info/WHEEL +5 -0
- code_search_cli-0.1.0.dist-info/entry_points.txt +2 -0
- code_search_cli-0.1.0.dist-info/licenses/LICENSE +202 -0
- code_search_cli-0.1.0.dist-info/licenses/NOTICE +7 -0
- code_search_cli-0.1.0.dist-info/top_level.txt +1 -0
code_retrieval/engine.py
ADDED
|
@@ -0,0 +1,722 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import math
|
|
4
|
+
import re
|
|
5
|
+
import time
|
|
6
|
+
from collections import Counter
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from .index_store import IndexRefresh, PersistentIndex
|
|
11
|
+
from .models import Evidence, RetrievalResult, Section, SourceFile
|
|
12
|
+
from .query import document_terms, query_terms, split_identifier
|
|
13
|
+
from .repository import RepositoryReader
|
|
14
|
+
from .structure import containing_section, sections_for
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
SYMBOL_RE = re.compile(r"\b[A-Z][A-Za-z0-9_$]{3,}\b")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass(slots=True)
|
|
21
|
+
class _FileCandidate:
|
|
22
|
+
source: SourceFile
|
|
23
|
+
terms: Counter[str]
|
|
24
|
+
score: float
|
|
25
|
+
matches: list[str]
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class RetrievalEngine:
|
|
29
|
+
"""Build a small, explainable evidence bundle for an engineering task."""
|
|
30
|
+
|
|
31
|
+
def __init__(self, repository: str | Path, ref: str | None = None):
|
|
32
|
+
self.reader = RepositoryReader(repository, ref)
|
|
33
|
+
self.index = PersistentIndex(self.reader)
|
|
34
|
+
self._index_refresh: IndexRefresh | None = None
|
|
35
|
+
self._files: list[SourceFile] | None = None
|
|
36
|
+
self._document_terms: dict[str, Counter[str]] = {}
|
|
37
|
+
self._path_terms: dict[str, Counter[str]] = {}
|
|
38
|
+
self._sections: dict[str, list[Section]] = {}
|
|
39
|
+
self._section_terms: dict[tuple[str, int, int], Counter[str]] = {}
|
|
40
|
+
|
|
41
|
+
def retrieve(
|
|
42
|
+
self,
|
|
43
|
+
task: str,
|
|
44
|
+
max_evidence: int = 16,
|
|
45
|
+
) -> RetrievalResult:
|
|
46
|
+
if not task.strip():
|
|
47
|
+
raise ValueError("task must not be empty")
|
|
48
|
+
started = time.perf_counter()
|
|
49
|
+
weighted, original = query_terms(task)
|
|
50
|
+
files = self._load_files()
|
|
51
|
+
candidates = self._rank_files(task, files, weighted, original)
|
|
52
|
+
section_candidates = self._rank_sections(candidates[:40], weighted, original)
|
|
53
|
+
section_candidates.extend(self._reference_expansion(task, candidates, section_candidates, weighted, original))
|
|
54
|
+
section_candidates.extend(self._structural_expansion(task, candidates))
|
|
55
|
+
section_candidates.extend(self._call_expansion(candidates, section_candidates, weighted))
|
|
56
|
+
section_candidates.extend(self._sibling_contrast_expansion(task, candidates, weighted, original))
|
|
57
|
+
section_candidates.extend(self._initialization_order_expansion(task, candidates, weighted))
|
|
58
|
+
section_candidates.extend(self._dependency_order_expansion(task, candidates, weighted))
|
|
59
|
+
evidence = self._pack(section_candidates, max_evidence)
|
|
60
|
+
elapsed = time.perf_counter() - started
|
|
61
|
+
used = sum(item.estimated_tokens for item in evidence)
|
|
62
|
+
return RetrievalResult(
|
|
63
|
+
task=task,
|
|
64
|
+
repository=str(self.reader.root),
|
|
65
|
+
ref=self.reader.ref,
|
|
66
|
+
used_tokens=used,
|
|
67
|
+
query_terms=original,
|
|
68
|
+
evidence=evidence,
|
|
69
|
+
stats={
|
|
70
|
+
"source_files": len(files),
|
|
71
|
+
"candidate_files": len(candidates),
|
|
72
|
+
"elapsed_ms": round(elapsed * 1000, 2),
|
|
73
|
+
"strategy": "weighted-lexical+structure+references+causal-contrast",
|
|
74
|
+
"index": self._index_refresh.to_dict() if self._index_refresh else None,
|
|
75
|
+
},
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
def prepare(self) -> dict[str, float | int]:
|
|
79
|
+
"""Build the reusable in-process lexical and structural index."""
|
|
80
|
+
started = time.perf_counter()
|
|
81
|
+
files = self._load_files()
|
|
82
|
+
section_count = 0
|
|
83
|
+
for source in files:
|
|
84
|
+
self._terms_for_source(source)
|
|
85
|
+
self._terms_for_path(source.path)
|
|
86
|
+
sections = self._sections_for(source)
|
|
87
|
+
section_count += len(sections)
|
|
88
|
+
for section in sections:
|
|
89
|
+
self._terms_for_section(section)
|
|
90
|
+
return {
|
|
91
|
+
"source_files": len(files),
|
|
92
|
+
"sections": section_count,
|
|
93
|
+
"elapsed_ms": round((time.perf_counter() - started) * 1000, 2),
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
def expand(
|
|
97
|
+
self,
|
|
98
|
+
path: str,
|
|
99
|
+
*,
|
|
100
|
+
line: int | None = None,
|
|
101
|
+
symbol: str | None = None,
|
|
102
|
+
relation: str = "enclosing",
|
|
103
|
+
) -> list[Evidence]:
|
|
104
|
+
if relation not in {"enclosing", "siblings", "references"}:
|
|
105
|
+
raise ValueError("relation must be enclosing, siblings, or references")
|
|
106
|
+
source = self.reader.read_file(path)
|
|
107
|
+
sections = sections_for(source)
|
|
108
|
+
anchor = containing_section(source, line, sections) if line else _section_named(sections, symbol)
|
|
109
|
+
if relation == "enclosing":
|
|
110
|
+
selected = [anchor]
|
|
111
|
+
reason = "enclosing structural unit"
|
|
112
|
+
elif relation == "references":
|
|
113
|
+
name = symbol or anchor.name
|
|
114
|
+
selected = []
|
|
115
|
+
for candidate in self._load_files():
|
|
116
|
+
if candidate.path != path and re.search(rf"\b{re.escape(name)}\b", candidate.content):
|
|
117
|
+
selected.append(
|
|
118
|
+
containing_section(candidate, _first_line(candidate.content, name), self._sections_for(candidate))
|
|
119
|
+
)
|
|
120
|
+
reason = f"references `{name}`"
|
|
121
|
+
else:
|
|
122
|
+
selected = [item for item in sections if item.name != anchor.name]
|
|
123
|
+
reason = f"sibling of `{anchor.name}`"
|
|
124
|
+
items = [_evidence(item, 1.0, [reason]) for item in selected]
|
|
125
|
+
return self._pack(items, 20)
|
|
126
|
+
|
|
127
|
+
def _load_files(self) -> list[SourceFile]:
|
|
128
|
+
if self._files is None:
|
|
129
|
+
self._index_refresh = self.index.refresh()
|
|
130
|
+
snapshot = self.index.load()
|
|
131
|
+
self._files = snapshot.files
|
|
132
|
+
self._document_terms.update(snapshot.document_terms)
|
|
133
|
+
self._sections.update(snapshot.sections)
|
|
134
|
+
return self._files
|
|
135
|
+
|
|
136
|
+
def _rank_files(
|
|
137
|
+
self,
|
|
138
|
+
task: str,
|
|
139
|
+
files: list[SourceFile],
|
|
140
|
+
weighted: dict[str, float],
|
|
141
|
+
original: list[str],
|
|
142
|
+
) -> list[_FileCandidate]:
|
|
143
|
+
task_lower = task.lower()
|
|
144
|
+
candidates = []
|
|
145
|
+
for source in files:
|
|
146
|
+
terms = self._terms_for_source(source)
|
|
147
|
+
path_terms = self._terms_for_path(source.path)
|
|
148
|
+
score = 0.0
|
|
149
|
+
matches = []
|
|
150
|
+
for term, weight in weighted.items():
|
|
151
|
+
content_count = terms.get(term, 0)
|
|
152
|
+
path_count = path_terms.get(term, 0)
|
|
153
|
+
if content_count or path_count:
|
|
154
|
+
matches.append(term)
|
|
155
|
+
score += weight * (math.log2(1 + min(content_count, 16)) + 5 * min(path_count, 1))
|
|
156
|
+
lowered = source.content.lower()
|
|
157
|
+
if task_lower in lowered:
|
|
158
|
+
score += 20
|
|
159
|
+
matches.append("exact task phrase")
|
|
160
|
+
original_hits = sum(1 for term in original if terms.get(term) or path_terms.get(term))
|
|
161
|
+
if original and original_hits == len(original):
|
|
162
|
+
score += 8
|
|
163
|
+
elif original_hits >= 2:
|
|
164
|
+
score += original_hits
|
|
165
|
+
if score > 0:
|
|
166
|
+
candidates.append(_FileCandidate(source, terms, score, matches))
|
|
167
|
+
return sorted(candidates, key=lambda item: (-item.score, item.source.path))
|
|
168
|
+
|
|
169
|
+
def _rank_sections(
|
|
170
|
+
self,
|
|
171
|
+
candidates: list[_FileCandidate],
|
|
172
|
+
weighted: dict[str, float],
|
|
173
|
+
original: list[str],
|
|
174
|
+
) -> list[Evidence]:
|
|
175
|
+
result = []
|
|
176
|
+
for file_rank, candidate in enumerate(candidates, 1):
|
|
177
|
+
ranked = []
|
|
178
|
+
for section in self._sections_for(candidate.source):
|
|
179
|
+
terms = self._terms_for_section(section)
|
|
180
|
+
hits = [(term, terms[term], weight) for term, weight in weighted.items() if terms.get(term)]
|
|
181
|
+
if not hits:
|
|
182
|
+
continue
|
|
183
|
+
local = sum(weight * math.log2(1 + min(count, 12)) for term, count, weight in hits)
|
|
184
|
+
original_hits = sum(1 for term in original if terms.get(term))
|
|
185
|
+
local += 2 * original_hits
|
|
186
|
+
score = local + candidate.score * 0.35 - math.log2(file_rank + 1)
|
|
187
|
+
reasons = [f"file rank {file_rank}", "matched " + ", ".join(term for term, _, _ in hits[:6])]
|
|
188
|
+
focused = _focused_sections(section, weighted)
|
|
189
|
+
for window_rank, window in enumerate(focused):
|
|
190
|
+
window_reasons = reasons if len(focused) == 1 else reasons + [f"focused window {window_rank + 1}"]
|
|
191
|
+
ranked.append(_evidence(window, score - window_rank * 0.5, window_reasons))
|
|
192
|
+
ranked.sort(key=lambda item: (-item.score, item.start_line))
|
|
193
|
+
representatives = []
|
|
194
|
+
represented = set()
|
|
195
|
+
for item in ranked:
|
|
196
|
+
key = item.path, item.name
|
|
197
|
+
if key in represented:
|
|
198
|
+
continue
|
|
199
|
+
representatives.append(item)
|
|
200
|
+
represented.add(key)
|
|
201
|
+
if len(representatives) == 4:
|
|
202
|
+
break
|
|
203
|
+
result.extend(representatives)
|
|
204
|
+
if representatives:
|
|
205
|
+
primary = representatives[0]
|
|
206
|
+
second_window = next(
|
|
207
|
+
(
|
|
208
|
+
item for item in ranked
|
|
209
|
+
if item.path == primary.path
|
|
210
|
+
and item.name == primary.name
|
|
211
|
+
and item.key() != primary.key()
|
|
212
|
+
),
|
|
213
|
+
None,
|
|
214
|
+
)
|
|
215
|
+
if second_window:
|
|
216
|
+
result.append(second_window)
|
|
217
|
+
return result
|
|
218
|
+
|
|
219
|
+
def _reference_expansion(
|
|
220
|
+
self,
|
|
221
|
+
task: str,
|
|
222
|
+
files: list[_FileCandidate],
|
|
223
|
+
sections: list[Evidence],
|
|
224
|
+
weighted: dict[str, float],
|
|
225
|
+
original: list[str],
|
|
226
|
+
) -> list[Evidence]:
|
|
227
|
+
query_symbols = set(SYMBOL_RE.findall(task))
|
|
228
|
+
for item in sections[:8]:
|
|
229
|
+
query_symbols.update(SYMBOL_RE.findall(item.content))
|
|
230
|
+
# Prefer task symbols and class names of strong anchors; cap fan-out aggressively.
|
|
231
|
+
anchor_names = {Path(item.path).stem for item in sections[:8]}
|
|
232
|
+
symbols = sorted(query_symbols & anchor_names | set(SYMBOL_RE.findall(task)))[:8]
|
|
233
|
+
if not symbols:
|
|
234
|
+
return []
|
|
235
|
+
expanded: list[Evidence] = []
|
|
236
|
+
seen_paths = {item.path for item in sections[:4]}
|
|
237
|
+
for symbol in symbols:
|
|
238
|
+
references = []
|
|
239
|
+
for candidate in files:
|
|
240
|
+
if candidate.source.path in seen_paths or not re.search(rf"\b{re.escape(symbol)}\b", candidate.source.content):
|
|
241
|
+
continue
|
|
242
|
+
line = _first_line(candidate.source.content, symbol)
|
|
243
|
+
section = containing_section(candidate.source, line, self._sections_for(candidate.source))
|
|
244
|
+
terms = document_terms(section.content)
|
|
245
|
+
task_hits = sum(weight for term, weight in weighted.items() if terms.get(term))
|
|
246
|
+
score = candidate.score * 0.15 + task_hits + 2
|
|
247
|
+
references.append(
|
|
248
|
+
_evidence(_focused_section(section, weighted, line), score, [f"references anchor `{symbol}`"])
|
|
249
|
+
)
|
|
250
|
+
references.sort(key=lambda item: (-item.score, item.path))
|
|
251
|
+
expanded.extend(references[:5])
|
|
252
|
+
return expanded
|
|
253
|
+
|
|
254
|
+
def _structural_expansion(self, task: str, files: list[_FileCandidate]) -> list[Evidence]:
|
|
255
|
+
intent = set(split_identifier(task))
|
|
256
|
+
non_schedule_intent = intent - {
|
|
257
|
+
"scheduled", "schedule", "scheduler", "cron", "trigger",
|
|
258
|
+
"resolve", "fix", "failure", "error", "exception", "null", "pointer",
|
|
259
|
+
"nullpointerexception", "npe", "in", "the",
|
|
260
|
+
}
|
|
261
|
+
patterns: list[tuple[str, re.Pattern[str]]] = []
|
|
262
|
+
if intent & {"dependency", "injection", "order", "startup", "initialization"}:
|
|
263
|
+
patterns.extend(
|
|
264
|
+
[
|
|
265
|
+
("dependency ordering annotation", re.compile(r"@DependsOn\b")),
|
|
266
|
+
("startup lifecycle hook", re.compile(r"@PostConstruct\b|\bafterPropertiesSet\s*\(")),
|
|
267
|
+
]
|
|
268
|
+
)
|
|
269
|
+
if intent & {"callback", "event", "events", "listener", "subscriber"}:
|
|
270
|
+
patterns.extend(
|
|
271
|
+
[
|
|
272
|
+
("event registration", re.compile(r"\bregister(?:Subscriber|Listener|ToPublisher)\s*\(")),
|
|
273
|
+
("event publication", re.compile(r"\bpublishEvent\s*\(")),
|
|
274
|
+
]
|
|
275
|
+
)
|
|
276
|
+
if intent & {"scheduled", "schedule", "scheduler", "cron", "trigger"}:
|
|
277
|
+
patterns.append(
|
|
278
|
+
(
|
|
279
|
+
"scheduled execution entry",
|
|
280
|
+
re.compile(
|
|
281
|
+
r"@Scheduled\b|\bconfigureTasks\s*\(|\baddTriggerTask\s*\(|"
|
|
282
|
+
r"\bscheduleAtFixedRate\s*\("
|
|
283
|
+
),
|
|
284
|
+
)
|
|
285
|
+
)
|
|
286
|
+
if not patterns:
|
|
287
|
+
return []
|
|
288
|
+
expanded = []
|
|
289
|
+
for candidate in files:
|
|
290
|
+
matches = [(label, pattern.search(candidate.source.content)) for label, pattern in patterns]
|
|
291
|
+
matches = [(label, match) for label, match in matches if match]
|
|
292
|
+
if matches and all(label == "scheduled execution entry" for label, _ in matches):
|
|
293
|
+
if not non_schedule_intent.intersection(candidate.matches):
|
|
294
|
+
continue
|
|
295
|
+
if not matches:
|
|
296
|
+
continue
|
|
297
|
+
label, match = matches[0]
|
|
298
|
+
assert match is not None
|
|
299
|
+
line = candidate.source.content.count("\n", 0, match.start()) + 1
|
|
300
|
+
section = containing_section(candidate.source, line, self._sections_for(candidate.source))
|
|
301
|
+
score = candidate.score * 0.35 + 36 + 8 * len(matches)
|
|
302
|
+
labels = [matched_label for matched_label, _ in matches]
|
|
303
|
+
expanded.append(
|
|
304
|
+
_evidence(
|
|
305
|
+
_focused_section(section, {}, line),
|
|
306
|
+
score,
|
|
307
|
+
labels + [f"obligation: {matched_label}" for matched_label in labels]
|
|
308
|
+
+ ["task-specific structural pattern"],
|
|
309
|
+
)
|
|
310
|
+
)
|
|
311
|
+
expanded.sort(key=lambda item: (-item.score, item.path))
|
|
312
|
+
return expanded[:16]
|
|
313
|
+
|
|
314
|
+
def _call_expansion(
|
|
315
|
+
self,
|
|
316
|
+
files: list[_FileCandidate],
|
|
317
|
+
evidence: list[Evidence],
|
|
318
|
+
weighted: dict[str, float],
|
|
319
|
+
) -> list[Evidence]:
|
|
320
|
+
by_path = {candidate.source.path: candidate.source for candidate in files}
|
|
321
|
+
expanded: list[Evidence] = []
|
|
322
|
+
anchors = sorted(evidence, key=lambda item: -item.score)[:20]
|
|
323
|
+
for anchor in anchors:
|
|
324
|
+
source = by_path.get(anchor.path)
|
|
325
|
+
if source is None:
|
|
326
|
+
continue
|
|
327
|
+
definitions = {section.name: section for section in self._sections_for(source)}
|
|
328
|
+
calls = re.findall(r"\b([A-Za-z_$][\w$]*)\s*\(", anchor.content)
|
|
329
|
+
for name in list(dict.fromkeys(calls)):
|
|
330
|
+
target = definitions.get(name)
|
|
331
|
+
if target is None or target.start_line == anchor.start_line:
|
|
332
|
+
continue
|
|
333
|
+
terms = document_terms(target.content + " " + target.name)
|
|
334
|
+
task_signal = sum(weight for term, weight in weighted.items() if terms.get(term))
|
|
335
|
+
if task_signal <= 0:
|
|
336
|
+
continue
|
|
337
|
+
score = anchor.score * 0.85 + task_signal
|
|
338
|
+
expanded.append(
|
|
339
|
+
_evidence(
|
|
340
|
+
_focused_section(target, weighted),
|
|
341
|
+
score,
|
|
342
|
+
[f"called by `{anchor.name}`", "definition expansion"],
|
|
343
|
+
)
|
|
344
|
+
)
|
|
345
|
+
expanded.sort(key=lambda item: (-item.score, item.path, item.start_line))
|
|
346
|
+
return expanded[:20]
|
|
347
|
+
|
|
348
|
+
def _sibling_contrast_expansion(
|
|
349
|
+
self,
|
|
350
|
+
task: str,
|
|
351
|
+
files: list[_FileCandidate],
|
|
352
|
+
weighted: dict[str, float],
|
|
353
|
+
original: list[str],
|
|
354
|
+
) -> list[Evidence]:
|
|
355
|
+
intent = set(split_identifier(task))
|
|
356
|
+
if not intent.intersection({"callback", "callbacks"}):
|
|
357
|
+
return []
|
|
358
|
+
identity_terms = {
|
|
359
|
+
term: weighted[term]
|
|
360
|
+
for term in original
|
|
361
|
+
if term not in {"callback", "callbacks", "event", "events", "listener", "subscriber"}
|
|
362
|
+
}
|
|
363
|
+
identity_terms = identity_terms or weighted
|
|
364
|
+
publication = re.compile(r"\bpublishEvent\s*\(")
|
|
365
|
+
expanded: list[Evidence] = []
|
|
366
|
+
for candidate in files[:30]:
|
|
367
|
+
sections = [item for item in self._sections_for(candidate.source) if item.kind in {"method", "function"}]
|
|
368
|
+
for target in sections:
|
|
369
|
+
if publication.search(target.content):
|
|
370
|
+
continue
|
|
371
|
+
target_name_terms = set(split_identifier(target.name))
|
|
372
|
+
target_relevance = _term_signal(target, identity_terms)
|
|
373
|
+
if target_relevance <= 0:
|
|
374
|
+
continue
|
|
375
|
+
comparisons = []
|
|
376
|
+
for sibling in sections:
|
|
377
|
+
if sibling is target or not publication.search(sibling.content):
|
|
378
|
+
continue
|
|
379
|
+
sibling_name_terms = set(split_identifier(sibling.name))
|
|
380
|
+
common = target_name_terms & sibling_name_terms
|
|
381
|
+
if len(common) < 2:
|
|
382
|
+
continue
|
|
383
|
+
similarity = len(common) / max(1, min(len(target_name_terms), len(sibling_name_terms)))
|
|
384
|
+
if similarity < 0.6 or target_relevance <= _term_signal(sibling, identity_terms):
|
|
385
|
+
continue
|
|
386
|
+
comparisons.append((similarity, sibling))
|
|
387
|
+
if not comparisons:
|
|
388
|
+
continue
|
|
389
|
+
_, sibling = max(comparisons, key=lambda item: (item[0], -item[1].start_line))
|
|
390
|
+
base = candidate.score * 0.35 + target_relevance
|
|
391
|
+
expanded.extend(
|
|
392
|
+
[
|
|
393
|
+
_evidence(
|
|
394
|
+
_focused_section(target, weighted),
|
|
395
|
+
base + 72,
|
|
396
|
+
[
|
|
397
|
+
f"sibling contrast: `{sibling.name}` publishes an event",
|
|
398
|
+
"callback path lacks the sibling's event publication",
|
|
399
|
+
"obligation: callback completion",
|
|
400
|
+
],
|
|
401
|
+
),
|
|
402
|
+
_evidence(
|
|
403
|
+
_focused_section(sibling, weighted),
|
|
404
|
+
base + 64,
|
|
405
|
+
[
|
|
406
|
+
f"comparison sibling for `{target.name}`",
|
|
407
|
+
"contains event publication",
|
|
408
|
+
],
|
|
409
|
+
),
|
|
410
|
+
]
|
|
411
|
+
)
|
|
412
|
+
expanded.sort(key=lambda item: (-item.score, item.path, item.start_line))
|
|
413
|
+
return expanded[:8]
|
|
414
|
+
|
|
415
|
+
def _initialization_order_expansion(
|
|
416
|
+
self,
|
|
417
|
+
task: str,
|
|
418
|
+
files: list[_FileCandidate],
|
|
419
|
+
weighted: dict[str, float],
|
|
420
|
+
) -> list[Evidence]:
|
|
421
|
+
intent = set(split_identifier(task))
|
|
422
|
+
if not intent.intersection({"startup", "initialization", "dependency", "order"}):
|
|
423
|
+
return []
|
|
424
|
+
if not intent.intersection({"event", "events", "listener", "subscriber", "missed", "missing"}):
|
|
425
|
+
return []
|
|
426
|
+
|
|
427
|
+
task_lower = task.lower()
|
|
428
|
+
subscribers: dict[str, list[tuple[_FileCandidate, Section]]] = {}
|
|
429
|
+
for candidate in files:
|
|
430
|
+
content = candidate.source.content
|
|
431
|
+
if "registerSubscriber" not in content:
|
|
432
|
+
continue
|
|
433
|
+
event_types = re.findall(r"return\s+([A-Z][A-Za-z0-9_$]*)\.class\s*;", content)
|
|
434
|
+
if not event_types:
|
|
435
|
+
continue
|
|
436
|
+
static_registration = next(
|
|
437
|
+
(
|
|
438
|
+
match for match in re.finditer(r"\bregisterSubscriber\s*\(", content)
|
|
439
|
+
if _inside_static_initializer(content, match.start())
|
|
440
|
+
),
|
|
441
|
+
None,
|
|
442
|
+
)
|
|
443
|
+
if static_registration is None:
|
|
444
|
+
continue
|
|
445
|
+
line = content.count("\n", 0, static_registration.start()) + 1
|
|
446
|
+
section = containing_section(candidate.source, line, self._sections_for(candidate.source))
|
|
447
|
+
for event_type in event_types:
|
|
448
|
+
subscribers.setdefault(event_type, []).append((candidate, section))
|
|
449
|
+
|
|
450
|
+
expanded: list[Evidence] = []
|
|
451
|
+
for publisher in files:
|
|
452
|
+
publisher_name = Path(publisher.source.path).stem
|
|
453
|
+
if publisher_name.lower() not in task_lower:
|
|
454
|
+
continue
|
|
455
|
+
published_events = list(dict.fromkeys(re.findall(
|
|
456
|
+
r"\bpublishEvent\s*\(\s*new\s+([A-Z][A-Za-z0-9_$]*)\b",
|
|
457
|
+
publisher.source.content,
|
|
458
|
+
)))
|
|
459
|
+
if not published_events:
|
|
460
|
+
continue
|
|
461
|
+
constructor = next(
|
|
462
|
+
(section for section in self._sections_for(publisher.source) if section.name == publisher_name),
|
|
463
|
+
None,
|
|
464
|
+
)
|
|
465
|
+
if constructor is None:
|
|
466
|
+
continue
|
|
467
|
+
for event_type in published_events:
|
|
468
|
+
for subscriber, registration in subscribers.get(event_type, []):
|
|
469
|
+
subscriber_name = Path(subscriber.source.path).stem
|
|
470
|
+
if subscriber.source.path == publisher.source.path or subscriber_name in constructor.content:
|
|
471
|
+
continue
|
|
472
|
+
base = publisher.score * 0.35 + _term_signal(constructor, weighted)
|
|
473
|
+
expanded.extend(
|
|
474
|
+
[
|
|
475
|
+
_evidence(
|
|
476
|
+
_focused_section(constructor, weighted),
|
|
477
|
+
base + 84,
|
|
478
|
+
[
|
|
479
|
+
f"publishes `{event_type}` before `{subscriber_name}` is guaranteed initialized",
|
|
480
|
+
f"missing initialization edge to `{subscriber_name}`",
|
|
481
|
+
f"required repair site: initialize `{subscriber_name}` from this component",
|
|
482
|
+
"obligation: initialization responsibility",
|
|
483
|
+
],
|
|
484
|
+
),
|
|
485
|
+
_evidence(
|
|
486
|
+
_focused_section(registration, weighted),
|
|
487
|
+
base + 76,
|
|
488
|
+
[
|
|
489
|
+
f"registers the subscriber for `{event_type}`",
|
|
490
|
+
f"initialization counterpart for `{publisher_name}`",
|
|
491
|
+
],
|
|
492
|
+
),
|
|
493
|
+
]
|
|
494
|
+
)
|
|
495
|
+
expanded.sort(key=lambda item: (-item.score, item.path, item.start_line))
|
|
496
|
+
return expanded[:8]
|
|
497
|
+
|
|
498
|
+
def _dependency_order_expansion(
|
|
499
|
+
self,
|
|
500
|
+
task: str,
|
|
501
|
+
files: list[_FileCandidate],
|
|
502
|
+
weighted: dict[str, float],
|
|
503
|
+
) -> list[Evidence]:
|
|
504
|
+
intent = set(split_identifier(task))
|
|
505
|
+
if not intent.intersection({"startup", "initialization", "dependency", "order"}):
|
|
506
|
+
return []
|
|
507
|
+
task_components = [
|
|
508
|
+
symbol for symbol in SYMBOL_RE.findall(task)
|
|
509
|
+
if symbol.endswith(("Notifier", "Listener", "Initializer", "Manager", "Service"))
|
|
510
|
+
]
|
|
511
|
+
if not task_components:
|
|
512
|
+
return []
|
|
513
|
+
|
|
514
|
+
expanded: list[Evidence] = []
|
|
515
|
+
for candidate in files:
|
|
516
|
+
content = candidate.source.content
|
|
517
|
+
annotation = re.search(r"@DependsOn\s*\(([^)]*)\)", content, re.DOTALL)
|
|
518
|
+
if annotation is None or not re.search(r"@PostConstruct\b|\bafterPropertiesSet\s*\(", content):
|
|
519
|
+
continue
|
|
520
|
+
declared = annotation.group(1)
|
|
521
|
+
for component in task_components:
|
|
522
|
+
bean_name = component[0].lower() + component[1:]
|
|
523
|
+
if component in content or bean_name in declared:
|
|
524
|
+
continue
|
|
525
|
+
line = content.count("\n", 0, annotation.start()) + 1
|
|
526
|
+
section = containing_section(candidate.source, line, self._sections_for(candidate.source))
|
|
527
|
+
base = candidate.score * 0.35 + _term_signal(section, weighted)
|
|
528
|
+
expanded.append(
|
|
529
|
+
_evidence(
|
|
530
|
+
_focused_section(section, weighted, line),
|
|
531
|
+
base + 96,
|
|
532
|
+
[
|
|
533
|
+
f"startup lifecycle has `@DependsOn` but omits `{bean_name}`",
|
|
534
|
+
f"missing dependency edge to task component `{component}`",
|
|
535
|
+
f"required repair site: add `{bean_name}` to this dependency declaration",
|
|
536
|
+
"joint responsibility: order startup work after notifier initialization",
|
|
537
|
+
"obligation: startup dependency owner",
|
|
538
|
+
],
|
|
539
|
+
)
|
|
540
|
+
)
|
|
541
|
+
expanded.sort(key=lambda item: (-item.score, item.path, item.start_line))
|
|
542
|
+
return expanded[:4]
|
|
543
|
+
|
|
544
|
+
def _sections_for(self, source: SourceFile) -> list[Section]:
|
|
545
|
+
cached = self._sections.get(source.path)
|
|
546
|
+
if cached is None:
|
|
547
|
+
cached = sections_for(source)
|
|
548
|
+
self._sections[source.path] = cached
|
|
549
|
+
return cached
|
|
550
|
+
|
|
551
|
+
def _terms_for_source(self, source: SourceFile) -> Counter[str]:
|
|
552
|
+
cached = self._document_terms.get(source.path)
|
|
553
|
+
if cached is None:
|
|
554
|
+
cached = document_terms(source.content)
|
|
555
|
+
self._document_terms[source.path] = cached
|
|
556
|
+
return cached
|
|
557
|
+
|
|
558
|
+
def _terms_for_path(self, path: str) -> Counter[str]:
|
|
559
|
+
cached = self._path_terms.get(path)
|
|
560
|
+
if cached is None:
|
|
561
|
+
cached = document_terms(path)
|
|
562
|
+
self._path_terms[path] = cached
|
|
563
|
+
return cached
|
|
564
|
+
|
|
565
|
+
def _terms_for_section(self, section: Section) -> Counter[str]:
|
|
566
|
+
key = section.path, section.start_line, section.end_line
|
|
567
|
+
cached = self._section_terms.get(key)
|
|
568
|
+
if cached is None:
|
|
569
|
+
cached = document_terms(section.content + " " + section.name)
|
|
570
|
+
self._section_terms[key] = cached
|
|
571
|
+
return cached
|
|
572
|
+
|
|
573
|
+
def _pack(self, candidates: list[Evidence], limit: int) -> list[Evidence]:
|
|
574
|
+
unique: dict[tuple[str, int, int], Evidence] = {}
|
|
575
|
+
for item in candidates:
|
|
576
|
+
existing = unique.get(item.key())
|
|
577
|
+
if existing is None or item.score > existing.score:
|
|
578
|
+
unique[item.key()] = item
|
|
579
|
+
ranked = sorted(unique.values(), key=lambda item: (-_packing_score(item), item.path, item.start_line))
|
|
580
|
+
selected: list[Evidence] = []
|
|
581
|
+
# Preserve one strong, preferably production, example for every structural
|
|
582
|
+
# obligation inferred from the task before adding similar hits.
|
|
583
|
+
obligations: dict[str, list[Evidence]] = {}
|
|
584
|
+
for item in ranked:
|
|
585
|
+
for reason in item.reasons:
|
|
586
|
+
if reason.startswith("obligation: "):
|
|
587
|
+
obligations.setdefault(reason, []).append(item)
|
|
588
|
+
for reason in sorted(obligations):
|
|
589
|
+
if len(selected) >= limit:
|
|
590
|
+
break
|
|
591
|
+
options = sorted(obligations[reason], key=lambda item: (-_packing_score(item), item.path))
|
|
592
|
+
for item in options:
|
|
593
|
+
if item not in selected:
|
|
594
|
+
selected.append(item)
|
|
595
|
+
break
|
|
596
|
+
|
|
597
|
+
# Prefer multiple candidate files before adding more results from the same file.
|
|
598
|
+
diversity_limit = min(4, limit)
|
|
599
|
+
representatives: dict[str, Evidence] = {}
|
|
600
|
+
for item in ranked:
|
|
601
|
+
representatives.setdefault(item.path, item)
|
|
602
|
+
diverse = sorted(
|
|
603
|
+
representatives.values(),
|
|
604
|
+
key=lambda item: (-_packing_score(item), item.path),
|
|
605
|
+
)
|
|
606
|
+
for item in diverse:
|
|
607
|
+
if len(selected) >= diversity_limit:
|
|
608
|
+
break
|
|
609
|
+
if item in selected:
|
|
610
|
+
continue
|
|
611
|
+
selected.append(item)
|
|
612
|
+
for item in ranked:
|
|
613
|
+
if len(selected) >= limit or item in selected:
|
|
614
|
+
continue
|
|
615
|
+
selected.append(item)
|
|
616
|
+
return selected
|
|
617
|
+
|
|
618
|
+
|
|
619
|
+
def _packing_score(item: Evidence) -> float:
|
|
620
|
+
test_penalty = 12 if "/test/" in item.path or item.path.startswith("test/") else 0
|
|
621
|
+
return item.score - min(item.estimated_tokens, 2000) / 200 - test_penalty
|
|
622
|
+
|
|
623
|
+
|
|
624
|
+
def _term_signal(section: Section, weighted: dict[str, float]) -> float:
|
|
625
|
+
terms = document_terms(section.content + " " + section.name)
|
|
626
|
+
return sum(weight for term, weight in weighted.items() if terms.get(term))
|
|
627
|
+
|
|
628
|
+
|
|
629
|
+
def _inside_static_initializer(content: str, offset: int) -> bool:
|
|
630
|
+
for match in reversed(list(re.finditer(r"\bstatic\s*\{", content[:offset]))):
|
|
631
|
+
depth = 0
|
|
632
|
+
for index in range(match.end() - 1, len(content)):
|
|
633
|
+
if content[index] == "{":
|
|
634
|
+
depth += 1
|
|
635
|
+
elif content[index] == "}":
|
|
636
|
+
depth -= 1
|
|
637
|
+
if depth == 0:
|
|
638
|
+
return offset < index
|
|
639
|
+
return True
|
|
640
|
+
return False
|
|
641
|
+
|
|
642
|
+
|
|
643
|
+
def _focused_section(
|
|
644
|
+
section: Section,
|
|
645
|
+
weighted: dict[str, float],
|
|
646
|
+
focus_line: int | None = None,
|
|
647
|
+
size: int = 48,
|
|
648
|
+
) -> Section:
|
|
649
|
+
return _focused_sections(section, weighted, focus_line, size, limit=1)[0]
|
|
650
|
+
|
|
651
|
+
|
|
652
|
+
def _focused_sections(
|
|
653
|
+
section: Section,
|
|
654
|
+
weighted: dict[str, float],
|
|
655
|
+
focus_line: int | None = None,
|
|
656
|
+
size: int = 48,
|
|
657
|
+
limit: int = 2,
|
|
658
|
+
) -> list[Section]:
|
|
659
|
+
lines = section.content.splitlines()
|
|
660
|
+
if len(lines) <= size:
|
|
661
|
+
return [section]
|
|
662
|
+
if focus_line is not None:
|
|
663
|
+
focus = max(0, min(len(lines) - 1, focus_line - section.start_line))
|
|
664
|
+
starts = [max(0, min(focus - size // 2, len(lines) - size))]
|
|
665
|
+
else:
|
|
666
|
+
lowered = [line.lower() for line in lines]
|
|
667
|
+
candidates = list(range(0, len(lines) - size + 1, size // 2))
|
|
668
|
+
candidates.append(len(lines) - size)
|
|
669
|
+
scored = []
|
|
670
|
+
for candidate in dict.fromkeys(candidates):
|
|
671
|
+
text = "\n".join(lowered[candidate : candidate + size])
|
|
672
|
+
score = sum(weight * text.count(term) for term, weight in weighted.items())
|
|
673
|
+
scored.append((score, candidate))
|
|
674
|
+
starts = []
|
|
675
|
+
for _, candidate in sorted(scored, key=lambda item: (-item[0], item[1])):
|
|
676
|
+
if all(abs(candidate - selected) >= size // 2 for selected in starts):
|
|
677
|
+
starts.append(candidate)
|
|
678
|
+
if len(starts) == limit:
|
|
679
|
+
break
|
|
680
|
+
return [
|
|
681
|
+
Section(
|
|
682
|
+
section.path,
|
|
683
|
+
section.name,
|
|
684
|
+
f"{section.kind}-window",
|
|
685
|
+
section.start_line + start,
|
|
686
|
+
section.start_line + start + size - 1,
|
|
687
|
+
"\n".join(lines[start : start + size]),
|
|
688
|
+
)
|
|
689
|
+
for start in starts
|
|
690
|
+
]
|
|
691
|
+
|
|
692
|
+
|
|
693
|
+
def _evidence(section: Section, score: float, reasons: list[str]) -> Evidence:
|
|
694
|
+
return Evidence(
|
|
695
|
+
path=section.path,
|
|
696
|
+
start_line=section.start_line,
|
|
697
|
+
end_line=section.end_line,
|
|
698
|
+
kind=section.kind,
|
|
699
|
+
name=section.name,
|
|
700
|
+
score=round(score, 4),
|
|
701
|
+
reasons=reasons,
|
|
702
|
+
content=section.content,
|
|
703
|
+
estimated_tokens=max(1, (len(section.content) + 3) // 4),
|
|
704
|
+
)
|
|
705
|
+
|
|
706
|
+
|
|
707
|
+
def _section_named(sections: list[Section], symbol: str | None) -> Section:
|
|
708
|
+
if not sections:
|
|
709
|
+
raise ValueError("file contains no retrievable sections")
|
|
710
|
+
if symbol:
|
|
711
|
+
for section in sections:
|
|
712
|
+
if section.name == symbol or re.search(rf"\b{re.escape(symbol)}\b", section.content):
|
|
713
|
+
return section
|
|
714
|
+
raise ValueError(f"symbol not found: {symbol}")
|
|
715
|
+
return sections[0]
|
|
716
|
+
|
|
717
|
+
|
|
718
|
+
def _first_line(content: str, value: str) -> int:
|
|
719
|
+
for index, line in enumerate(content.splitlines(), 1):
|
|
720
|
+
if value in line:
|
|
721
|
+
return index
|
|
722
|
+
return 1
|