code-search-cli 0.1.0__py3-none-any.whl → 0.3.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/cli.py CHANGED
@@ -5,30 +5,44 @@ import json
5
5
  import sqlite3
6
6
  import subprocess
7
7
  import sys
8
+ from importlib.metadata import PackageNotFoundError, version
8
9
 
9
10
  from .engine import RetrievalEngine
10
11
  from .index_store import PersistentIndex
11
- from .installer import install_codex_skill, uninstall_codex_skill
12
+ from .installer import (
13
+ AGENT_SELECTORS,
14
+ AgentName,
15
+ agent_display_name,
16
+ install_skills,
17
+ uninstall_skills,
18
+ )
12
19
  from .models import Evidence
13
- from .repository import RepositoryReader
20
+ from .repository import RETRIEVAL_SCOPES, RepositoryReader
14
21
 
15
22
 
16
23
  def build_parser() -> argparse.ArgumentParser:
17
24
  parser = argparse.ArgumentParser(prog="code-search", description="Task-aware local code retrieval")
25
+ parser.add_argument("--version", action="version", version=f"%(prog)s {_package_version()}")
18
26
  subparsers = parser.add_subparsers(dest="command", required=True)
19
27
 
20
28
  retrieve = subparsers.add_parser("retrieve", help="build an evidence bundle for a task")
21
29
  _repository_args(retrieve)
22
30
  retrieve.add_argument("--task", required=True, help="engineering task or failure description")
23
- retrieve.add_argument("--max-evidence", type=int, default=16)
31
+ retrieve.add_argument("--max-evidence", type=_positive_int, default=16)
32
+ retrieve.add_argument(
33
+ "--scope",
34
+ choices=RETRIEVAL_SCOPES,
35
+ default="all",
36
+ help="limit retrieval to code, tests, or configuration files",
37
+ )
24
38
  retrieve.add_argument("--format", choices=("markdown", "json"), default="markdown")
25
39
 
26
40
  expand = subparsers.add_parser("expand", help="expand a known source anchor")
27
41
  _repository_args(expand)
28
42
  expand.add_argument("--path", required=True, help="repository-relative source path")
29
- anchor = expand.add_mutually_exclusive_group()
30
- anchor.add_argument("--line", type=int)
31
- anchor.add_argument("--symbol")
43
+ anchor = expand.add_mutually_exclusive_group(required=True)
44
+ anchor.add_argument("--line", type=_positive_int)
45
+ anchor.add_argument("--symbol", type=_non_empty)
32
46
  expand.add_argument("--relation", choices=("enclosing", "siblings", "references"), default="enclosing")
33
47
  expand.add_argument("--format", choices=("markdown", "json"), default="markdown")
34
48
 
@@ -43,11 +57,25 @@ def build_parser() -> argparse.ArgumentParser:
43
57
  clean = subparsers.add_parser("clean", help="remove the persistent index for a repository")
44
58
  _repository_args(clean)
45
59
 
46
- install = subparsers.add_parser("install", help="install the bundled Codex skill")
60
+ install = subparsers.add_parser("install", help="install the bundled skill for a coding agent")
61
+ install.add_argument(
62
+ "--target",
63
+ choices=AGENT_SELECTORS,
64
+ default="codex",
65
+ help="agent destination; 'detected' uses best-effort local signals (default: codex)",
66
+ )
47
67
  install.add_argument("--force", action="store_true", help="replace a locally modified installed skill")
48
-
49
- uninstall = subparsers.add_parser("uninstall", help="remove the bundled Codex skill")
68
+ install.add_argument("--dry-run", action="store_true", help="preview changes without writing files")
69
+
70
+ uninstall = subparsers.add_parser("uninstall", help="remove the bundled skill for a coding agent")
71
+ uninstall.add_argument(
72
+ "--target",
73
+ choices=AGENT_SELECTORS,
74
+ default="codex",
75
+ help="agent destination; 'detected' uses best-effort local signals (default: codex)",
76
+ )
50
77
  uninstall.add_argument("--force", action="store_true", help="remove a locally modified installed skill")
78
+ uninstall.add_argument("--dry-run", action="store_true", help="preview changes without removing files")
51
79
  return parser
52
80
 
53
81
 
@@ -55,11 +83,14 @@ def main(argv: list[str] | None = None) -> int:
55
83
  args = build_parser().parse_args(argv)
56
84
  try:
57
85
  if args.command in {"install", "uninstall"}:
58
- operation = install_codex_skill if args.command == "install" else uninstall_codex_skill
59
- path, action = operation(force=args.force)
60
- print(f"Codex skill {action}: {path}")
61
- if args.command == "install":
62
- print("Restart Codex to load the skill.")
86
+ operation = install_skills if args.command == "install" else uninstall_skills
87
+ results = operation(target=args.target, force=args.force, dry_run=args.dry_run)
88
+ for result in results:
89
+ action = f"would be {result.action}" if args.dry_run else result.action
90
+ print(f"{agent_display_name(result.agent)} skill {action}: {result.path}")
91
+ if args.command == "install" and not args.dry_run:
92
+ for result in results:
93
+ print(_skill_reload_hint(result.agent))
63
94
  return 0
64
95
 
65
96
  if args.command in {"index", "status", "clean"}:
@@ -78,7 +109,7 @@ def main(argv: list[str] | None = None) -> int:
78
109
 
79
110
  engine = RetrievalEngine(args.repo, args.ref)
80
111
  if args.command == "retrieve":
81
- result = engine.retrieve(args.task, args.max_evidence)
112
+ result = engine.retrieve(args.task, args.max_evidence, scope=args.scope)
82
113
  output = json.dumps(result.to_dict(), ensure_ascii=False, indent=2) if args.format == "json" else result.to_markdown()
83
114
  else:
84
115
  evidence = engine.expand(
@@ -100,6 +131,36 @@ def _repository_args(parser: argparse.ArgumentParser) -> None:
100
131
  parser.add_argument("--ref", help="optional Git commit, tag, or branch; does not modify the working tree")
101
132
 
102
133
 
134
+ def _package_version() -> str:
135
+ try:
136
+ return version("code-search-cli")
137
+ except PackageNotFoundError:
138
+ return "0+unknown"
139
+
140
+
141
+ def _positive_int(value: str) -> int:
142
+ parsed = int(value)
143
+ if parsed < 1:
144
+ raise argparse.ArgumentTypeError("must be at least 1")
145
+ return parsed
146
+
147
+
148
+ def _non_empty(value: str) -> str:
149
+ if not value.strip():
150
+ raise argparse.ArgumentTypeError("must not be empty")
151
+ return value
152
+
153
+
154
+ def _skill_reload_hint(agent: AgentName) -> str:
155
+ if agent == "codex":
156
+ return "Restart Codex if the skill does not appear automatically."
157
+ if agent == "gemini":
158
+ return "Run /skills reload in Gemini CLI if the skill does not appear automatically."
159
+ if agent == "kiro":
160
+ return "Kiro loads Agent Skills automatically; reopen the session if it is not listed."
161
+ return f"Restart {agent_display_name(agent)} if the skill does not appear automatically."
162
+
163
+
103
164
  def _evidence_json(evidence: list[Evidence]) -> str:
104
165
  from dataclasses import asdict
105
166
 
@@ -113,6 +174,8 @@ def _evidence_markdown(evidence: list[Evidence]) -> str:
113
174
  [
114
175
  f"## `{item.path}:{item.start_line}-{item.end_line}`",
115
176
  "",
177
+ f"Role `{item.role}`; {item.confidence} confidence from `{item.provenance}`",
178
+ "",
116
179
  "; ".join(item.reasons),
117
180
  "",
118
181
  "```",
code_retrieval/engine.py CHANGED
@@ -9,12 +9,23 @@ from pathlib import Path
9
9
 
10
10
  from .index_store import IndexRefresh, PersistentIndex
11
11
  from .models import Evidence, RetrievalResult, Section, SourceFile
12
- from .query import document_terms, query_terms, split_identifier
13
- from .repository import RepositoryReader
12
+ from .query import document_terms, query_terms, split_identifier, stem
13
+ from .repository import RETRIEVAL_SCOPES, RepositoryReader, source_scope
14
14
  from .structure import containing_section, sections_for
15
15
 
16
-
17
16
  SYMBOL_RE = re.compile(r"\b[A-Z][A-Za-z0-9_$]{3,}\b")
17
+ EXACT_SYMBOL_QUERY_RE = re.compile(
18
+ r"[A-Za-z_$][A-Za-z0-9_$]*(?:(?:::|[.#\\])[A-Za-z_$][A-Za-z0-9_$]*)*"
19
+ )
20
+ DECLARATION_KEYWORDS = (
21
+ r"actor|class|def|enum|fn|func|function|interface|module|namespace|"
22
+ r"object|protocol|record|struct|trait|type|typealias|union|using"
23
+ )
24
+ IDENTIFIER_CHAR_CLASS = r"A-Za-z0-9_$"
25
+ USING_ALIAS_SUFFIX_RE = re.compile(r"\s*=")
26
+ EVENT_EMISSION_RE = re.compile(
27
+ r"\b(?:dispatchEvent|dispatch_event|emit|emitEvent|emit_event|publishEvent|publish_event)\s*\("
28
+ )
18
29
 
19
30
 
20
31
  @dataclass(slots=True)
@@ -42,20 +53,31 @@ class RetrievalEngine:
42
53
  self,
43
54
  task: str,
44
55
  max_evidence: int = 16,
56
+ *,
57
+ scope: str = "all",
45
58
  ) -> RetrievalResult:
46
59
  if not task.strip():
47
60
  raise ValueError("task must not be empty")
61
+ if scope not in RETRIEVAL_SCOPES:
62
+ raise ValueError(f"scope must be one of: {', '.join(RETRIEVAL_SCOPES)}")
48
63
  started = time.perf_counter()
49
64
  weighted, original = query_terms(task)
50
- files = self._load_files()
65
+ indexed_files = self._load_files()
66
+ files = [
67
+ source
68
+ for source in indexed_files
69
+ if scope == "all" or source_scope(source.path) == scope
70
+ ]
51
71
  candidates = self._rank_files(task, files, weighted, original)
52
72
  section_candidates = self._rank_sections(candidates[:40], weighted, original)
53
73
  section_candidates.extend(self._reference_expansion(task, candidates, section_candidates, weighted, original))
74
+ section_candidates.extend(self._collaborator_expansion(candidates, section_candidates, weighted))
54
75
  section_candidates.extend(self._structural_expansion(task, candidates))
55
76
  section_candidates.extend(self._call_expansion(candidates, section_candidates, weighted))
56
77
  section_candidates.extend(self._sibling_contrast_expansion(task, candidates, weighted, original))
57
78
  section_candidates.extend(self._initialization_order_expansion(task, candidates, weighted))
58
79
  section_candidates.extend(self._dependency_order_expansion(task, candidates, weighted))
80
+ section_candidates.extend(self._exact_symbol_expansion(task, candidates))
59
81
  evidence = self._pack(section_candidates, max_evidence)
60
82
  elapsed = time.perf_counter() - started
61
83
  used = sum(item.estimated_tokens for item in evidence)
@@ -67,14 +89,84 @@ class RetrievalEngine:
67
89
  query_terms=original,
68
90
  evidence=evidence,
69
91
  stats={
70
- "source_files": len(files),
92
+ "source_files": len(indexed_files),
93
+ "searched_files": len(files),
71
94
  "candidate_files": len(candidates),
72
95
  "elapsed_ms": round(elapsed * 1000, 2),
73
- "strategy": "weighted-lexical+structure+references+causal-contrast",
96
+ "strategy": "weighted-lexical+structure+exact-symbol+references+causal-contrast",
97
+ "scope": scope,
74
98
  "index": self._index_refresh.to_dict() if self._index_refresh else None,
75
99
  },
76
100
  )
77
101
 
102
+ def _exact_symbol_expansion(
103
+ self,
104
+ task: str,
105
+ files: list[_FileCandidate],
106
+ ) -> list[Evidence]:
107
+ symbol = _exact_symbol_query(task)
108
+ if symbol is None:
109
+ return []
110
+ raw_query = task.strip()
111
+ declaration_pattern = _direct_declaration_pattern(raw_query)
112
+ symbol_pattern = _exact_occurrence_pattern(symbol)
113
+ expanded: list[Evidence] = []
114
+ for file_rank, candidate in enumerate(files[:12], 1):
115
+ lines = candidate.source.content.splitlines()
116
+ occurrences: list[int] = []
117
+ for line_number, line in enumerate(lines, 1):
118
+ for match in declaration_pattern.finditer(line):
119
+ if (
120
+ match.group("keyword") != "using"
121
+ or USING_ALIAS_SUFFIX_RE.match(line, match.end())
122
+ ):
123
+ occurrences.append(line_number)
124
+ break
125
+ if not occurrences:
126
+ continue
127
+ windows = []
128
+ for line_number in occurrences:
129
+ start = max(1, min(line_number - 6, max(1, len(lines) - 19)))
130
+ end = min(len(lines), start + 19)
131
+ content = "\n".join(lines[start - 1 : end])
132
+ density = len(symbol_pattern.findall(content))
133
+ windows.append(
134
+ (
135
+ density,
136
+ -line_number,
137
+ Section(
138
+ candidate.source.path,
139
+ symbol,
140
+ "symbol-window",
141
+ start,
142
+ end,
143
+ content,
144
+ ),
145
+ )
146
+ )
147
+ _, _, section = max(
148
+ windows,
149
+ key=lambda item: item[:-1],
150
+ )
151
+ reasons = [
152
+ f"file rank {file_rank}",
153
+ f"exact symbol occurrence `{raw_query}`",
154
+ "line text places the symbol directly after a declaration keyword",
155
+ ]
156
+ expanded.append(
157
+ _evidence(
158
+ section,
159
+ candidate.score,
160
+ reasons,
161
+ role="anchor",
162
+ provenance="exact-symbol",
163
+ confidence="medium",
164
+ )
165
+ )
166
+ if len(expanded) == 2:
167
+ break
168
+ return expanded
169
+
78
170
  def prepare(self) -> dict[str, float | int]:
79
171
  """Build the reusable in-process lexical and structural index."""
80
172
  started = time.perf_counter()
@@ -109,6 +201,7 @@ class RetrievalEngine:
109
201
  if relation == "enclosing":
110
202
  selected = [anchor]
111
203
  reason = "enclosing structural unit"
204
+ role, provenance, confidence = "anchor", "structure", "high"
112
205
  elif relation == "references":
113
206
  name = symbol or anchor.name
114
207
  selected = []
@@ -118,10 +211,22 @@ class RetrievalEngine:
118
211
  containing_section(candidate, _first_line(candidate.content, name), self._sections_for(candidate))
119
212
  )
120
213
  reason = f"references `{name}`"
214
+ role, provenance, confidence = "reference", "exact-symbol", "high"
121
215
  else:
122
216
  selected = [item for item in sections if item.name != anchor.name]
123
217
  reason = f"sibling of `{anchor.name}`"
124
- items = [_evidence(item, 1.0, [reason]) for item in selected]
218
+ role, provenance, confidence = "sibling", "structure", "high"
219
+ items = [
220
+ _evidence(
221
+ item,
222
+ 1.0,
223
+ [reason],
224
+ role=role,
225
+ provenance=provenance,
226
+ confidence=confidence,
227
+ )
228
+ for item in selected
229
+ ]
125
230
  return self._pack(items, 20)
126
231
 
127
232
  def _load_files(self) -> list[SourceFile]:
@@ -141,10 +246,32 @@ class RetrievalEngine:
141
246
  original: list[str],
142
247
  ) -> list[_FileCandidate]:
143
248
  task_lower = task.lower()
249
+ query_stems = {stem(term) for term in weighted}
250
+ wants_tests = bool(
251
+ set(split_identifier(task))
252
+ & {"test", "tests", "testing", "tested", "spec", "specs", "regression", "coverage"}
253
+ )
144
254
  candidates = []
255
+ # Long natural-language tasks (issue reports) match everything in huge
256
+ # files; pivoted length normalization makes those files earn their
257
+ # term frequencies. Short tasks keep raw scoring.
258
+ normalize_length = len(original) >= 12
259
+ lengths = {}
260
+ average_length = 1.0
261
+ if normalize_length:
262
+ lengths = {source.path: sum(self._terms_for_source(source).values()) for source in files}
263
+ average_length = (sum(lengths.values()) / len(lengths)) if lengths else 1.0
145
264
  for source in files:
146
265
  terms = self._terms_for_source(source)
147
266
  path_terms = self._terms_for_path(source.path)
267
+ length_norm = 1.0
268
+ if normalize_length and average_length:
269
+ relative_length = lengths[source.path] / average_length
270
+ length_norm = max(0.4, min(1.0, 1.0 / (0.5 + 0.5 * relative_length)))
271
+ if _basename_stems(source.path) & query_stems:
272
+ # A file named after a query concept must not be buried
273
+ # for its size; soften the penalty.
274
+ length_norm = max(length_norm, 0.85)
148
275
  score = 0.0
149
276
  matches = []
150
277
  for term, weight in weighted.items():
@@ -152,16 +279,29 @@ class RetrievalEngine:
152
279
  path_count = path_terms.get(term, 0)
153
280
  if content_count or path_count:
154
281
  matches.append(term)
155
- score += weight * (math.log2(1 + min(content_count, 16)) + 5 * min(path_count, 1))
282
+ score += weight * (
283
+ length_norm * math.log2(1 + min(content_count, 16))
284
+ + 5 * min(path_count, 1)
285
+ )
156
286
  lowered = source.content.lower()
157
287
  if task_lower in lowered:
158
288
  score += 20
159
289
  matches.append("exact task phrase")
160
- original_hits = sum(1 for term in original if terms.get(term) or path_terms.get(term))
290
+ original_hits = sum(
291
+ 1
292
+ for term in original
293
+ if terms.get(term) or path_terms.get(term) or terms.get(stem(term))
294
+ )
161
295
  if original and original_hits == len(original):
162
296
  score += 8
163
297
  elif original_hits >= 2:
164
298
  score += original_hits
299
+ name_stems = _basename_stems(source.path)
300
+ if len(name_stems) >= 2 and name_stems <= query_stems:
301
+ score += 14.0 * len(name_stems)
302
+ matches.append("file name matches task")
303
+ if score > 0 and not wants_tests and source_scope(source.path) == "tests":
304
+ score *= 0.45
165
305
  if score > 0:
166
306
  candidates.append(_FileCandidate(source, terms, score, matches))
167
307
  return sorted(candidates, key=lambda item: (-item.score, item.source.path))
@@ -174,6 +314,7 @@ class RetrievalEngine:
174
314
  ) -> list[Evidence]:
175
315
  result = []
176
316
  for file_rank, candidate in enumerate(candidates, 1):
317
+ name_bonus = 25.0 if "file name matches task" in candidate.matches else 0.0
177
318
  ranked = []
178
319
  for section in self._sections_for(candidate.source):
179
320
  terms = self._terms_for_section(section)
@@ -183,7 +324,7 @@ class RetrievalEngine:
183
324
  local = sum(weight * math.log2(1 + min(count, 12)) for term, count, weight in hits)
184
325
  original_hits = sum(1 for term in original if terms.get(term))
185
326
  local += 2 * original_hits
186
- score = local + candidate.score * 0.35 - math.log2(file_rank + 1)
327
+ score = local + name_bonus + candidate.score * 0.35 - math.log2(file_rank + 1)
187
328
  reasons = [f"file rank {file_rank}", "matched " + ", ".join(term for term, _, _ in hits[:6])]
188
329
  focused = _focused_sections(section, weighted)
189
330
  for window_rank, window in enumerate(focused):
@@ -235,22 +376,94 @@ class RetrievalEngine:
235
376
  expanded: list[Evidence] = []
236
377
  seen_paths = {item.path for item in sections[:4]}
237
378
  for symbol in symbols:
379
+ symbol_terms = frozenset(split_identifier(symbol))
380
+ symbol_pattern = rf"\b{re.escape(symbol)}\b"
238
381
  references = []
239
382
  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):
383
+ if candidate.source.path in seen_paths:
384
+ continue
385
+ if symbol_terms and not symbol_terms.issubset(candidate.terms):
386
+ continue
387
+ if (
388
+ symbol not in candidate.source.content
389
+ or not re.search(symbol_pattern, candidate.source.content)
390
+ ):
241
391
  continue
242
392
  line = _first_line(candidate.source.content, symbol)
243
393
  section = containing_section(candidate.source, line, self._sections_for(candidate.source))
244
- terms = document_terms(section.content)
394
+ terms = self._terms_for_section(section)
245
395
  task_hits = sum(weight for term, weight in weighted.items() if terms.get(term))
246
396
  score = candidate.score * 0.15 + task_hits + 2
247
397
  references.append(
248
- _evidence(_focused_section(section, weighted, line), score, [f"references anchor `{symbol}`"])
398
+ _evidence(
399
+ _focused_section(section, weighted, line),
400
+ score,
401
+ [f"references anchor `{symbol}`"],
402
+ role="reference",
403
+ provenance="exact-symbol",
404
+ confidence="high",
405
+ )
249
406
  )
250
407
  references.sort(key=lambda item: (-item.score, item.path))
251
408
  expanded.extend(references[:5])
252
409
  return expanded
253
410
 
411
+ def _collaborator_expansion(
412
+ self,
413
+ candidates: list[_FileCandidate],
414
+ sections: list[Evidence],
415
+ weighted: dict[str, float],
416
+ ) -> list[Evidence]:
417
+ if not sections:
418
+ return []
419
+ primary_path = sections[0].path
420
+ primary = next(
421
+ (candidate for candidate in candidates if candidate.source.path == primary_path),
422
+ None,
423
+ )
424
+ if primary is None:
425
+ return []
426
+ by_name = {}
427
+ for candidate in candidates[1:60]:
428
+ name = Path(candidate.source.path).stem
429
+ if candidate.source.path != primary_path and name not in by_name:
430
+ by_name[name] = candidate
431
+ counts = Counter(SYMBOL_RE.findall(primary.source.content))
432
+ referenced = sorted(
433
+ (
434
+ (counts[name], candidate)
435
+ for name, candidate in by_name.items()
436
+ if counts.get(name, 0) >= 3
437
+ ),
438
+ key=lambda item: (-item[0], item[1].source.path),
439
+ )
440
+ covered = {item.path for item in sections[:6]}
441
+ expanded = []
442
+ primary_name = Path(primary_path).stem
443
+ for count, candidate in referenced[:2]:
444
+ if candidate.source.path in covered:
445
+ continue
446
+ best = None
447
+ best_signal = 0.0
448
+ for section in self._sections_for(candidate.source):
449
+ terms = self._terms_for_section(section)
450
+ signal = sum(weight for term, weight in weighted.items() if terms.get(term))
451
+ if signal > best_signal:
452
+ best, best_signal = section, signal
453
+ if best is None:
454
+ continue
455
+ expanded.append(
456
+ _evidence(
457
+ _focused_section(best, weighted, best.start_line),
458
+ candidate.score * 0.3 + best_signal + 4,
459
+ [f"collaborates with `{primary_name}` ({count} references)"],
460
+ role="reference",
461
+ provenance="structure",
462
+ confidence="medium",
463
+ )
464
+ )
465
+ return expanded
466
+
254
467
  def _structural_expansion(self, task: str, files: list[_FileCandidate]) -> list[Evidence]:
255
468
  intent = set(split_identifier(task))
256
469
  non_schedule_intent = intent - {
@@ -306,6 +519,9 @@ class RetrievalEngine:
306
519
  score,
307
520
  labels + [f"obligation: {matched_label}" for matched_label in labels]
308
521
  + ["task-specific structural pattern"],
522
+ role="structural-obligation",
523
+ provenance="syntax-pattern",
524
+ confidence="medium",
309
525
  )
310
526
  )
311
527
  expanded.sort(key=lambda item: (-item.score, item.path))
@@ -340,6 +556,9 @@ class RetrievalEngine:
340
556
  _focused_section(target, weighted),
341
557
  score,
342
558
  [f"called by `{anchor.name}`", "definition expansion"],
559
+ role="definition",
560
+ provenance="same-file-call",
561
+ confidence="medium",
343
562
  )
344
563
  )
345
564
  expanded.sort(key=lambda item: (-item.score, item.path, item.start_line))
@@ -361,12 +580,11 @@ class RetrievalEngine:
361
580
  if term not in {"callback", "callbacks", "event", "events", "listener", "subscriber"}
362
581
  }
363
582
  identity_terms = identity_terms or weighted
364
- publication = re.compile(r"\bpublishEvent\s*\(")
365
583
  expanded: list[Evidence] = []
366
584
  for candidate in files[:30]:
367
585
  sections = [item for item in self._sections_for(candidate.source) if item.kind in {"method", "function"}]
368
586
  for target in sections:
369
- if publication.search(target.content):
587
+ if EVENT_EMISSION_RE.search(target.content):
370
588
  continue
371
589
  target_name_terms = set(split_identifier(target.name))
372
590
  target_relevance = _term_signal(target, identity_terms)
@@ -374,7 +592,7 @@ class RetrievalEngine:
374
592
  continue
375
593
  comparisons = []
376
594
  for sibling in sections:
377
- if sibling is target or not publication.search(sibling.content):
595
+ if sibling is target or not EVENT_EMISSION_RE.search(sibling.content):
378
596
  continue
379
597
  sibling_name_terms = set(split_identifier(sibling.name))
380
598
  common = target_name_terms & sibling_name_terms
@@ -398,6 +616,9 @@ class RetrievalEngine:
398
616
  "callback path lacks the sibling's event publication",
399
617
  "obligation: callback completion",
400
618
  ],
619
+ role="missing-behavior",
620
+ provenance="sibling-contrast",
621
+ confidence="medium",
401
622
  ),
402
623
  _evidence(
403
624
  _focused_section(sibling, weighted),
@@ -406,6 +627,9 @@ class RetrievalEngine:
406
627
  f"comparison sibling for `{target.name}`",
407
628
  "contains event publication",
408
629
  ],
630
+ role="comparison",
631
+ provenance="sibling-contrast",
632
+ confidence="medium",
409
633
  ),
410
634
  ]
411
635
  )
@@ -481,6 +705,9 @@ class RetrievalEngine:
481
705
  f"required repair site: initialize `{subscriber_name}` from this component",
482
706
  "obligation: initialization responsibility",
483
707
  ],
708
+ role="repair-site",
709
+ provenance="lifecycle-inference",
710
+ confidence="medium",
484
711
  ),
485
712
  _evidence(
486
713
  _focused_section(registration, weighted),
@@ -489,6 +716,9 @@ class RetrievalEngine:
489
716
  f"registers the subscriber for `{event_type}`",
490
717
  f"initialization counterpart for `{publisher_name}`",
491
718
  ],
719
+ role="registration",
720
+ provenance="syntax-pattern",
721
+ confidence="medium",
492
722
  ),
493
723
  ]
494
724
  )
@@ -536,6 +766,9 @@ class RetrievalEngine:
536
766
  "joint responsibility: order startup work after notifier initialization",
537
767
  "obligation: startup dependency owner",
538
768
  ],
769
+ role="repair-site",
770
+ provenance="lifecycle-inference",
771
+ confidence="medium",
539
772
  )
540
773
  )
541
774
  expanded.sort(key=lambda item: (-item.score, item.path, item.start_line))
@@ -594,8 +827,9 @@ class RetrievalEngine:
594
827
  selected.append(item)
595
828
  break
596
829
 
597
- # Prefer multiple candidate files before adding more results from the same file.
598
- diversity_limit = min(4, limit)
830
+ # Prefer covering distinct files across the whole budget before adding
831
+ # more results from the same file.
832
+ diversity_limit = limit
599
833
  representatives: dict[str, Evidence] = {}
600
834
  for item in ranked:
601
835
  representatives.setdefault(item.path, item)
@@ -690,7 +924,25 @@ def _focused_sections(
690
924
  ]
691
925
 
692
926
 
693
- def _evidence(section: Section, score: float, reasons: list[str]) -> Evidence:
927
+ def _basename_stems(path: str) -> frozenset[str]:
928
+ name = Path(path).stem
929
+ joined = name.lower().replace("_", "")
930
+ return frozenset(
931
+ stem(part)
932
+ for part in split_identifier(name)
933
+ if len(part) > 1 and part != joined
934
+ )
935
+
936
+
937
+ def _evidence(
938
+ section: Section,
939
+ score: float,
940
+ reasons: list[str],
941
+ *,
942
+ role: str = "candidate",
943
+ provenance: str = "lexical",
944
+ confidence: str = "medium",
945
+ ) -> Evidence:
694
946
  return Evidence(
695
947
  path=section.path,
696
948
  start_line=section.start_line,
@@ -699,6 +951,9 @@ def _evidence(section: Section, score: float, reasons: list[str]) -> Evidence:
699
951
  name=section.name,
700
952
  score=round(score, 4),
701
953
  reasons=reasons,
954
+ role=role,
955
+ provenance=provenance,
956
+ confidence=confidence,
702
957
  content=section.content,
703
958
  estimated_tokens=max(1, (len(section.content) + 3) // 4),
704
959
  )
@@ -720,3 +975,25 @@ def _first_line(content: str, value: str) -> int:
720
975
  if value in line:
721
976
  return index
722
977
  return 1
978
+
979
+
980
+ def _exact_symbol_query(task: str) -> str | None:
981
+ stripped = task.strip()
982
+ if not EXACT_SYMBOL_QUERY_RE.fullmatch(stripped):
983
+ return None
984
+ symbol = re.split(r"::|[.#\\]", stripped)[-1]
985
+ return symbol if len(symbol) >= 2 else None
986
+
987
+
988
+ def _exact_occurrence_pattern(value: str) -> re.Pattern[str]:
989
+ return re.compile(
990
+ rf"(?<![{IDENTIFIER_CHAR_CLASS}]){re.escape(value)}"
991
+ rf"(?![{IDENTIFIER_CHAR_CLASS}])"
992
+ )
993
+
994
+
995
+ def _direct_declaration_pattern(value: str) -> re.Pattern[str]:
996
+ return re.compile(
997
+ rf"\b(?P<keyword>{DECLARATION_KEYWORDS})\s+{re.escape(value)}"
998
+ rf"(?![{IDENTIFIER_CHAR_CLASS}])"
999
+ )