bugpilot 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 (59) hide show
  1. bugpilot/__init__.py +5 -0
  2. bugpilot/__main__.py +5 -0
  3. bugpilot/cli.py +2615 -0
  4. bugpilot/cli_json.py +173 -0
  5. bugpilot/core/__init__.py +1 -0
  6. bugpilot/core/agent_runner.py +138 -0
  7. bugpilot/core/artifact_io.py +40 -0
  8. bugpilot/core/artifacts.py +47 -0
  9. bugpilot/core/attachments.py +247 -0
  10. bugpilot/core/branch_policy.py +223 -0
  11. bugpilot/core/cleanup.py +58 -0
  12. bugpilot/core/code_files.py +99 -0
  13. bugpilot/core/config.py +242 -0
  14. bugpilot/core/context.py +436 -0
  15. bugpilot/core/copilot.py +47 -0
  16. bugpilot/core/delivery_instructions.py +106 -0
  17. bugpilot/core/doctor.py +66 -0
  18. bugpilot/core/email_notify.py +316 -0
  19. bugpilot/core/errors.py +105 -0
  20. bugpilot/core/executables.py +87 -0
  21. bugpilot/core/fix_mode_state.py +212 -0
  22. bugpilot/core/fix_mode_store.py +600 -0
  23. bugpilot/core/fix_modes.py +412 -0
  24. bugpilot/core/fix_report.py +124 -0
  25. bugpilot/core/git_history.py +1579 -0
  26. bugpilot/core/git_ops.py +254 -0
  27. bugpilot/core/handoff.py +127 -0
  28. bugpilot/core/identity.py +112 -0
  29. bugpilot/core/input_adapters.py +97 -0
  30. bugpilot/core/instructions.py +337 -0
  31. bugpilot/core/issue.py +487 -0
  32. bugpilot/core/jira.py +886 -0
  33. bugpilot/core/jira_adf.py +167 -0
  34. bugpilot/core/jira_parse.py +442 -0
  35. bugpilot/core/keywords.py +386 -0
  36. bugpilot/core/logging_utils.py +28 -0
  37. bugpilot/core/memory.py +151 -0
  38. bugpilot/core/models.py +322 -0
  39. bugpilot/core/project_settings.py +283 -0
  40. bugpilot/core/prompts.py +567 -0
  41. bugpilot/core/repository_profile.py +739 -0
  42. bugpilot/core/retrieval.py +485 -0
  43. bugpilot/core/review_changes.py +155 -0
  44. bugpilot/core/review_report.py +171 -0
  45. bugpilot/core/run.py +171 -0
  46. bugpilot/core/safe_paths.py +173 -0
  47. bugpilot/core/search.py +765 -0
  48. bugpilot/core/search_terms.py +310 -0
  49. bugpilot/core/setup.py +212 -0
  50. bugpilot/core/user_config.py +211 -0
  51. bugpilot/core/verification_report.py +313 -0
  52. bugpilot/core/workflow.py +2385 -0
  53. bugpilot/mcp_server.py +651 -0
  54. bugpilot-0.1.0.dist-info/METADATA +270 -0
  55. bugpilot-0.1.0.dist-info/RECORD +59 -0
  56. bugpilot-0.1.0.dist-info/WHEEL +5 -0
  57. bugpilot-0.1.0.dist-info/entry_points.txt +3 -0
  58. bugpilot-0.1.0.dist-info/licenses/LICENSE +122 -0
  59. bugpilot-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,765 @@
1
+ """Ripgrep-based code search and related file ranking."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import subprocess
6
+ from collections import defaultdict
7
+ from dataclasses import dataclass, field, replace
8
+ from pathlib import Path
9
+
10
+ from .code_files import is_documentation, is_implementation, is_searchable, search_globs
11
+ from .executables import child_environment, find_executable
12
+ from .git_ops import command_available
13
+ from .models import InvestigationOptions
14
+ from .retrieval import RelatedFile, RetrievalArtifact, RetrievalTerm, Snippet
15
+ from .search_terms import (
16
+ WEIGHT_IDENTIFIER,
17
+ WEIGHT_PROSE,
18
+ WEIGHT_WEAK,
19
+ SearchTerm,
20
+ shape_candidates,
21
+ terms_from_extraction,
22
+ )
23
+
24
+
25
+ #: Derived, never hand-written: `code_files` is the one list, and this used to be
26
+ #: a second one that had drifted nine extensions away from it.
27
+ INCLUDE_GLOBS = search_globs()
28
+ EXCLUDE_DIRS = [
29
+ ".git",
30
+ "build",
31
+ "out",
32
+ "node_modules",
33
+ "vcpkg",
34
+ "third_party",
35
+ "external",
36
+ ".ai",
37
+ ".ai_memory",
38
+ ".venv",
39
+ "__pycache__",
40
+ ]
41
+ # The retained evidence for one term, counted in *files* rather than lines.
42
+ #
43
+ # Counting lines was the single worst defect §33 found, and it was invisible:
44
+ # ripgrep walks in directory order, so the first 20 lines of a term with 76
45
+ # matches are whatever it happened to reach first. `bugpilot/core/fix_mode_state.py`
46
+ # contains `persisted` nine times and `FixMode` twenty-four, and was never
47
+ # retained for either — its hits sat past the line budget, which had already
48
+ # been spent in `bugpilot/cli.py`. A term's evidence should span the repository,
49
+ # not its first few directories.
50
+ MAX_FILES_PER_TERM = 20
51
+ MAX_MATCHES_PER_FILE_PER_TERM = 3
52
+ # A broad term gets a much smaller share of that budget. Spreading evidence
53
+ # helps a term that names something and hurts one that is everywhere: measured,
54
+ # giving broad terms the full budget moved `fix-mode-persistence` from absent to
55
+ # rank 6 and simultaneously pushed `identifier-persist-fix-mode` from 3 to 7,
56
+ # because sixty matches of `Mode` across twenty files score twenty files.
57
+ BROAD_TERM_FILE_BUDGET = 4
58
+ # Above this many repository matches, a term stops discriminating and is scored
59
+ # as the weakest thing there is (§33.4).
60
+ #
61
+ # Measured, not guessed. Over the §33.1 corpus against this repository: 58 terms,
62
+ # median 193 matches, deciles [12, 27, 40, 76, 193, 308, 512, 791, 2254]. The two
63
+ # genuinely specific terms — `persist_fix_mode` (12) and `fix_mode.json` (19) —
64
+ # sit in the bottom two deciles, three orders of magnitude below `Fix` (3,472)
65
+ # and `Mode` (4,150).
66
+ #
67
+ # The anchor is the median: a term matching more often than the typical term in
68
+ # a bug report is not telling you much. 250 is just above it.
69
+ #
70
+ # A sweep over the corpus (100/150/200/250/300/400/500/800/1200) put top-5
71
+ # recall between 1/5 and 3/5 with no sharp edge — 250 was the best and 200 and
72
+ # 300 were one case behind. That flatness is the honest reading: six cases
73
+ # cannot resolve this constant finely, and picking the argmax of six would be
74
+ # fitting the ranker to them. Re-derive it with `tests/retrieval_corpus.py` on a
75
+ # larger corpus, especially a C++/Qt one, where the distribution will differ.
76
+ BROAD_MATCH_THRESHOLD = 250
77
+ MAX_SNIPPETS_PER_FILE = 5
78
+ MAX_TOTAL_RELATED_FILES = 10
79
+ # How many of the files that reach `context.md` are kept for implementation.
80
+ #
81
+ # Measured before this existed (§33.1): documentation held 15 of the 30 top-5
82
+ # slots across the corpus, because prose terms match prose files and `.md` is
83
+ # searched. The answer is not to stop searching documentation — a design note
84
+ # naming the subsystem is a real lead — but to stop it taking the seats that
85
+ # decide what an agent reads. Three of five: enough that implementation leads,
86
+ # few enough that a genuinely better document still appears.
87
+ RESERVED_IMPLEMENTATION_SLOTS = 3
88
+ # Large enough to outrank keyword evidence: an explicit --focus-file is a
89
+ # stronger signal than any automatic ranking heuristic.
90
+ FOCUS_FILE_BONUS = 25
91
+ #: Written once: `_select_with_reserved_slots` reads it to know what not to demote.
92
+ FOCUS_REASON = "developer marked this file as a focus area"
93
+ LOW_VALUE_KEYWORDS = {
94
+ "crash",
95
+ "error",
96
+ "failed",
97
+ "issue",
98
+ "problem",
99
+ "stale",
100
+ "result",
101
+ "results",
102
+ "change",
103
+ "changes",
104
+ "update",
105
+ }
106
+ MEDIUM_VALUE_KEYWORDS = {
107
+ "import",
108
+ "filter",
109
+ "search",
110
+ "export",
111
+ "volume",
112
+ "project",
113
+ }
114
+ NOISE_PATH_INDICATORS = {
115
+ ".github": "build_or_ci_path",
116
+ ".gitlab": "build_or_ci_path",
117
+ "ci": "build_or_ci_path",
118
+ "build": "build_or_ci_path",
119
+ "out": "build_or_ci_path",
120
+ "cmake": "build_or_ci_path",
121
+ "scripts/build": "build_or_ci_path",
122
+ "license": "license_or_sdk_path",
123
+ "licensing": "license_or_sdk_path",
124
+ "sdk": "license_or_sdk_path",
125
+ "third_party": "vendor_or_external_path",
126
+ "external": "vendor_or_external_path",
127
+ "vendor": "vendor_or_external_path",
128
+ "generated": "generated_path",
129
+ "auto_generated": "generated_path",
130
+ "testdata": "testdata_path",
131
+ "docs": "docs_or_examples_path",
132
+ "documentation": "docs_or_examples_path",
133
+ "examples": "docs_or_examples_path",
134
+ "sample": "docs_or_examples_path",
135
+ }
136
+ APPLICATION_PATH_INDICATORS = {
137
+ "src",
138
+ "source",
139
+ "lib",
140
+ "app",
141
+ "modules",
142
+ "plugins",
143
+ }
144
+
145
+ # Test directories. Not noise — a test that names the broken behaviour is one of
146
+ # the best leads there is — but not an implementation either, and "high
147
+ # confidence" is read as "the implementation is here".
148
+ #
149
+ # Found on real data: a test fixture quoting a Jira issue's own prose produced an
150
+ # exact phrase match, which is the strongest per-file signal there is, and that
151
+ # one file flipped the whole search from low to high confidence. Test names and
152
+ # comments routinely quote ticket text, so this is not a quirk of testing
153
+ # bugpilot on itself.
154
+ TEST_PATH_INDICATORS = {"test", "tests", "spec", "specs", "__tests__", "testing"}
155
+
156
+
157
+ @dataclass
158
+ class TermSearchResult:
159
+ """What one term found, and how much of the repository it touched.
160
+
161
+ `total_match_count` and `retained_matches` are different questions. The
162
+ first says whether the term discriminates; the second is the evidence shown
163
+ downstream. Keeping thousands of Match records to learn "this term is
164
+ everywhere" would be paying in memory for a number rg already printed.
165
+ """
166
+
167
+ term: SearchTerm
168
+ total_match_count: int
169
+ retained_matches: list["Match"] = field(default_factory=list)
170
+
171
+ @property
172
+ def classification(self) -> str:
173
+ if self.total_match_count == 0:
174
+ return "zero"
175
+ return "broad" if self.total_match_count > BROAD_MATCH_THRESHOLD else "specific"
176
+
177
+ @property
178
+ def effective_weight(self) -> int:
179
+ """What this term is worth once the repository has had its say.
180
+
181
+ A broad term is demoted rather than dropped: it may still be the only
182
+ thread connecting a file to the report, and removing it outright would
183
+ trade a precision problem for a recall one.
184
+ """
185
+ if self.classification == "broad":
186
+ return WEIGHT_WEAK
187
+ return self.term.weight
188
+
189
+
190
+ @dataclass
191
+ class Match:
192
+ keyword: str
193
+ tier: str
194
+ file: str
195
+ line_number: int
196
+ line: str
197
+ #: What the term was worth (§33.3). 0 means a caller built this Match without
198
+ #: a term — the legacy tier path below still scores those.
199
+ weight: int = 0
200
+
201
+
202
+ @dataclass
203
+ class FileScore:
204
+ file: str
205
+ score: int = 0
206
+ matched_keywords: set[str] = field(default_factory=set)
207
+ match_count: int = 0
208
+ snippets: list[Match] = field(default_factory=list)
209
+ confidence: str = "low"
210
+ reasons: list[str] = field(default_factory=list)
211
+ noise_flags: list[str] = field(default_factory=list)
212
+ keyword_quality_counts: dict[str, int] = field(default_factory=lambda: {"high": 0, "medium": 0, "low": 0})
213
+
214
+
215
+ def run_code_search(
216
+ repo_root: Path,
217
+ keywords: dict[str, object],
218
+ options: InvestigationOptions | None = None,
219
+ ) -> RetrievalArtifact:
220
+ """Search the repository for the extracted terms and rank what they hit.
221
+
222
+ ``keywords`` is the extractor's output, handed over in memory by the step
223
+ that produced it. The result is the whole retrieval, ready to persist.
224
+ """
225
+ options = options or InvestigationOptions()
226
+ high_value = _keyword_list(keywords.get("high_value_keywords", []))
227
+ normal = _keyword_list(keywords.get("normal_keywords", []))
228
+
229
+ if not command_available("rg"):
230
+ return _retrieval([], [], ["rg is unavailable; code search was skipped."], options)
231
+
232
+ # §33.3: one weighted list, ordered by what each term is worth, instead of
233
+ # four tiers whose membership was decided by position.
234
+ terms = terms_from_extraction(
235
+ keywords, user_keywords=list(options.keywords or []), hint=options.hint or ""
236
+ )
237
+ all_matches: list[Match] = []
238
+ warnings: list[str] = []
239
+ probes: list[TermSearchResult] = []
240
+ for term in terms:
241
+ probe = _probe_term(repo_root, term, warnings)
242
+ probes.append(probe)
243
+ # A term that matches nothing contributes nothing; it is kept in
244
+ # `probes` so the diagnostics can say it was tried and found wanting.
245
+ all_matches.extend(probe.retained_matches)
246
+
247
+ # §33.7B: identifier shapes assembled from adjacent words — `output type`
248
+ # becoming `outputType`. Every one is a string this code made up, so each is
249
+ # probed and only the ones the repository actually contains are admitted.
250
+ # A shape with no matches is discarded here and never reaches the ranker.
251
+ for term in shape_candidates(
252
+ keywords, hint=options.hint or "", already={probe.term.key for probe in probes}
253
+ ):
254
+ probe = _probe_term(repo_root, term, warnings)
255
+ probes.append(probe)
256
+ if probe.total_match_count:
257
+ all_matches.extend(probe.retained_matches)
258
+
259
+ all_matches = [match for match in all_matches if not _matches_any_path(match.file, options.ignore_paths)]
260
+ ranked = _rank_related_files(
261
+ all_matches, high_value, normal, max_files=options.max_files, focus_files=options.focus_files
262
+ )
263
+ if not ranked:
264
+ warnings.append("No code search results found for extracted keywords.")
265
+ return _retrieval(probes, ranked, warnings, options)
266
+
267
+
268
+ def _retrieval(
269
+ probes: list[TermSearchResult],
270
+ ranked: list[FileScore],
271
+ warnings: list[str],
272
+ options: InvestigationOptions,
273
+ ) -> RetrievalArtifact:
274
+ related = _related_files(ranked, options.max_search_lines)
275
+ confidence, reasons, noise_indicators = _overall_quality(related, warnings)
276
+ return RetrievalArtifact(
277
+ confidence=confidence,
278
+ reasons=tuple(reasons),
279
+ noise_indicators=tuple(noise_indicators),
280
+ terms=tuple(_term_diagnostics(probes)),
281
+ related_files=tuple(related),
282
+ )
283
+
284
+
285
+ def _term_diagnostics(probes: list[TermSearchResult]) -> list[RetrievalTerm]:
286
+ """What each term was worth, what it matched, and what that did to it.
287
+
288
+ Beside the files in the same artifact: "should I trust this search" and
289
+ "half your terms match four thousand lines each" are the same question.
290
+ """
291
+ return [
292
+ RetrievalTerm(
293
+ value=probe.term.value,
294
+ source=probe.term.source,
295
+ weight=probe.term.weight,
296
+ effective_weight=probe.effective_weight,
297
+ match_count=probe.total_match_count,
298
+ classification=probe.classification,
299
+ # Why this term exists at all, and whether it survived. Only a
300
+ # generated shape has an answer to the first; everything else was
301
+ # simply present in the text.
302
+ derived_from=probe.term.derived_from,
303
+ status="dropped" if probe.total_match_count == 0 else "retained",
304
+ )
305
+ for probe in probes
306
+ ]
307
+
308
+
309
+ def _related_files(ranked: list[FileScore], max_search_lines: int) -> list[RelatedFile]:
310
+ snippets = _budgeted_snippets(ranked, max_search_lines)
311
+ return [
312
+ RelatedFile(
313
+ file=item.file,
314
+ # Why a file sits where it does: §33.2 keeps the leading slots for
315
+ # implementation, and without this the artifact cannot show it.
316
+ documentation=is_documentation(item.file),
317
+ score=item.score,
318
+ confidence=item.confidence,
319
+ match_count=item.match_count,
320
+ matched_keywords=tuple(sorted(item.matched_keywords)),
321
+ reasons=tuple(item.reasons),
322
+ noise_flags=tuple(item.noise_flags),
323
+ snippets=kept,
324
+ )
325
+ for item, kept in zip(ranked, snippets)
326
+ ]
327
+
328
+
329
+ def _budgeted_snippets(ranked: list[FileScore], max_search_lines: int) -> list[tuple[Snippet, ...]]:
330
+ """Each file's matched lines, under the line budget the report used to spend.
331
+
332
+ `max_search_lines` bounds what an agent has to read, and it used to be spent
333
+ rendering `code_search.md`: two lines of heading per file, one per matched
334
+ line, one blank after. The same arithmetic decides what is kept here, so the
335
+ option still bounds the evidence even though the report is gone.
336
+ """
337
+ kept: list[tuple[Snippet, ...]] = []
338
+ budget = max_search_lines
339
+ for item in ranked:
340
+ if budget <= 0:
341
+ kept.append(())
342
+ continue
343
+ budget -= 2
344
+ lines: list[Snippet] = []
345
+ for match in item.snippets:
346
+ if budget <= 0:
347
+ break
348
+ lines.append(Snippet(line=match.line_number, text=match.line))
349
+ budget -= 1
350
+ budget -= 1
351
+ kept.append(tuple(lines))
352
+ return kept
353
+
354
+
355
+ def _keyword_list(value: object) -> list[str]:
356
+ if not isinstance(value, list):
357
+ return []
358
+ return [str(item) for item in value if str(item).strip()]
359
+
360
+
361
+ def _probe_term(repo_root: Path, term: SearchTerm, warnings: list[str]) -> TermSearchResult:
362
+ """Search for one term, keeping both what it found and how much it touched.
363
+
364
+ One ripgrep run, read twice: once to count, once to retain. How much is
365
+ retained depends on what the count said — a term that names something earns
366
+ evidence from across the repository, a term that is everywhere does not.
367
+ """
368
+ completed = _run_rg(repo_root, term.value, warnings)
369
+ if completed is None:
370
+ return TermSearchResult(term=term, total_match_count=0)
371
+
372
+ _sample, total = _collect(completed.stdout, term.value, term.source, max_files=0)
373
+ budget = BROAD_TERM_FILE_BUDGET if total > BROAD_MATCH_THRESHOLD else MAX_FILES_PER_TERM
374
+ matches, _total = _collect(completed.stdout, term.value, term.source, max_files=budget)
375
+
376
+ result = TermSearchResult(term=term, total_match_count=total)
377
+ weight = result.effective_weight
378
+ result.retained_matches = [replace(match, weight=weight) for match in matches]
379
+ return result
380
+
381
+
382
+ def _run_rg(repo_root: Path, keyword: str, warnings: list[str]):
383
+ """Run ripgrep for one literal term, or report why it could not."""
384
+ args = ["rg", "--line-number", "--no-heading", "--ignore-case", "--fixed-strings"]
385
+ for glob in INCLUDE_GLOBS:
386
+ args.extend(["-g", glob])
387
+ for directory in EXCLUDE_DIRS:
388
+ args.extend(["-g", f"!{directory}/**"])
389
+ # `--` ends the flags. Without it a keyword that starts with a dash is read
390
+ # by rg as one: `-Wall` produces "rg: unrecognized flag -W", exit 2, and the
391
+ # keyword is silently dropped into a warning nobody reads. Compiler flags,
392
+ # CLI options and switch names are exactly the terms a bug report is about.
393
+ args.append("--")
394
+ args.append(keyword)
395
+ args.append(".")
396
+
397
+ # Started by its absolute PATH location: a repository's own rg.exe must not
398
+ # run because the repository is the working directory.
399
+ program = find_executable(args[0])
400
+ if program is None:
401
+ warnings.append(f"Search failed for keyword `{keyword}`: rg was not found on PATH.")
402
+ return None
403
+ try:
404
+ completed = subprocess.run(
405
+ [program, *args[1:]],
406
+ cwd=repo_root,
407
+ env=child_environment(),
408
+ encoding="utf-8",
409
+ errors="replace",
410
+ stdout=subprocess.PIPE,
411
+ stderr=subprocess.PIPE,
412
+ check=False,
413
+ timeout=20,
414
+ )
415
+ except (FileNotFoundError, subprocess.TimeoutExpired) as exc:
416
+ warnings.append(f"Search failed for keyword `{keyword}`: {exc}")
417
+ return None
418
+
419
+ if completed.returncode not in {0, 1}:
420
+ warnings.append(f"Search failed for keyword `{keyword}`: {completed.stderr.strip()}")
421
+ return None
422
+
423
+ return completed
424
+
425
+
426
+ def _collect(
427
+ stdout: str, keyword: str, tier: str, max_files: int = MAX_FILES_PER_TERM
428
+ ) -> tuple[list[Match], int]:
429
+ """Bounded evidence, unbounded count — and the budget spread across files.
430
+
431
+ Sorted before anything is kept, because ripgrep walks directories in
432
+ parallel and its output order is not stable between runs. With a per-file
433
+ budget that order decides *which* files a broad term retains, so two
434
+ identical searches of an unchanged tree returned different rankings — caught
435
+ by running the §33.1 corpus twice. A search an agent depends on has to be
436
+ reproducible, and sorting costs one pass over output already in memory.
437
+ """
438
+ parsed_lines = []
439
+ total = 0
440
+ for line in stdout.splitlines():
441
+ parsed = _parse_rg_line(line)
442
+ if parsed is None:
443
+ continue
444
+ path, line_number, text = parsed
445
+ if not _is_included_path(path):
446
+ continue
447
+ total += 1
448
+ parsed_lines.append((path, line_number, text))
449
+
450
+ matches: list[Match] = []
451
+ per_file: dict[str, int] = {}
452
+ for path, line_number, text in sorted(parsed_lines, key=lambda item: (item[0], item[1])):
453
+ seen = per_file.get(path, 0)
454
+ if seen >= MAX_MATCHES_PER_FILE_PER_TERM:
455
+ continue
456
+ if seen == 0 and len(per_file) >= max_files:
457
+ continue
458
+ per_file[path] = seen + 1
459
+ matches.append(
460
+ Match(keyword=keyword, tier=tier, file=path, line_number=line_number, line=text.strip())
461
+ )
462
+ return matches, total
463
+
464
+
465
+ def _parse_rg_line(line: str) -> tuple[str, int, str] | None:
466
+ parts = line.split(":", 2)
467
+ if len(parts) != 3:
468
+ return None
469
+ path, line_number, text = parts
470
+ try:
471
+ normalized = path.replace("\\", "/")
472
+ if normalized.startswith("./"):
473
+ normalized = normalized[2:]
474
+ return normalized, int(line_number), text
475
+ except ValueError:
476
+ return None
477
+
478
+
479
+ def _is_included_path(path: str) -> bool:
480
+ return is_searchable(path)
481
+
482
+
483
+ def _match_weight(match: Match, high_set: set[str], normal_set: set[str]) -> tuple[int, str]:
484
+ """Per-keyword weight + quality. Specificity beats frequency: an exact
485
+ phrase or a qualified `Foo::bar` name is near-unique and weighted highest."""
486
+ if match.weight:
487
+ # §33.3: the term already knows what it is worth, from its own shape and
488
+ # provenance rather than from which list it landed in. Quality is the
489
+ # same three bands the confidence rules have always read.
490
+ if match.weight >= WEIGHT_IDENTIFIER:
491
+ return match.weight, "high"
492
+ if match.weight >= WEIGHT_PROSE:
493
+ return match.weight, "medium"
494
+ return match.weight, "low"
495
+ if match.tier == "phrase":
496
+ return 12, "high"
497
+ if match.tier == "expanded":
498
+ return 1, "low"
499
+ if "::" in match.keyword:
500
+ return 10, "high"
501
+ keyword = match.keyword.lower()
502
+ quality = _keyword_quality(match.keyword, keyword in high_set, keyword in normal_set)
503
+ if quality == "high":
504
+ return 6, "high"
505
+ if quality == "medium":
506
+ return 2, "medium"
507
+ return 1, "low"
508
+
509
+
510
+ def _rank_related_files(
511
+ matches: list[Match],
512
+ high_value: list[str],
513
+ normal: list[str],
514
+ max_files: int = MAX_TOTAL_RELATED_FILES,
515
+ focus_files: list[str] | None = None,
516
+ ) -> list[FileScore]:
517
+ scores: dict[str, FileScore] = {}
518
+ high_set = {keyword.lower() for keyword in high_value}
519
+ normal_set = {keyword.lower() for keyword in normal}
520
+
521
+ # file -> keyword(lower) -> [count, weight, quality]
522
+ per_keyword: dict[str, dict[str, list]] = defaultdict(dict)
523
+ for match in matches:
524
+ item = scores.setdefault(match.file, FileScore(file=match.file))
525
+ weight, quality = _match_weight(match, high_set, normal_set)
526
+ stats = per_keyword[match.file].get(match.keyword.lower())
527
+ if stats is None:
528
+ per_keyword[match.file][match.keyword.lower()] = [1, weight, quality]
529
+ else:
530
+ stats[0] += 1
531
+ item.matched_keywords.add(match.keyword)
532
+ item.match_count += 1
533
+ if len(item.snippets) < MAX_SNIPPETS_PER_FILE:
534
+ item.snippets.append(match)
535
+ if match.tier == "phrase" and "exact phrase match" not in item.reasons:
536
+ item.reasons.append("exact phrase match")
537
+
538
+ for file, keyword_map in per_keyword.items():
539
+ item = scores[file]
540
+ base = Path(file).name.lower()
541
+ distinct_high = 0
542
+ for keyword, (count, weight, quality) in keyword_map.items():
543
+ # Diminishing returns: repeated hits of the SAME keyword add little,
544
+ # so 100x a common name can't outweigh a few specific matches.
545
+ item.score += int(round(weight * (1.0 + min(count - 1, 4) * 0.25)))
546
+ item.keyword_quality_counts[quality] += 1 # distinct keywords, not raw hits
547
+ if quality == "high":
548
+ distinct_high += 1
549
+ stem = keyword.split("::", 1)[0].rsplit(".", 1)[0]
550
+ if len(stem) >= 4 and stem in base:
551
+ item.score += 6 # the file *is* the matched class/name
552
+ if "keyword matches file name" not in item.reasons:
553
+ item.reasons.append("keyword matches file name")
554
+ # Breadth: matching several DISTINCT high-value keywords is a strong,
555
+ # precision-friendly signal that this is the right spot.
556
+ if distinct_high >= 2:
557
+ item.score += (distinct_high - 1) * 3
558
+ item.reasons.append(f"matched {distinct_high} distinct high-value keywords")
559
+
560
+ _apply_header_implementation_bonus(scores)
561
+ for item in scores.values():
562
+ _apply_path_adjustments(item)
563
+ _apply_focus_bonus(item, focus_files or [])
564
+ _assign_confidence(item)
565
+ # The only place the file cap is applied, so a caller's max_files cannot be
566
+ # honored in one view of the results and ignored in another.
567
+ ordered = sorted(list(scores.values()), key=lambda item: (-item.score, item.file))
568
+ return _select_with_reserved_slots(ordered, max_files)
569
+
570
+
571
+ def _select_with_reserved_slots(ordered: list[FileScore], max_files: int) -> list[FileScore]:
572
+ """Take the top files, keeping room at the front for implementation.
573
+
574
+ Score order is otherwise untouched: this decides *which* files survive the
575
+ cap and in what order, not what any of them scored.
576
+
577
+ Files the developer pointed at go first whatever they are. A --focus-file is
578
+ an instruction, and a reservation that could demote one would be this
579
+ function quietly overruling it.
580
+ """
581
+ pinned = [item for item in ordered if FOCUS_REASON in item.reasons]
582
+ rest = [item for item in ordered if FOCUS_REASON not in item.reasons]
583
+ implementation = [item for item in rest if is_implementation(item.file)]
584
+
585
+ reserved_count = max(0, min(RESERVED_IMPLEMENTATION_SLOTS, max_files - len(pinned)))
586
+ reserved = implementation[:reserved_count]
587
+ taken = {id(item) for item in pinned} | {id(item) for item in reserved}
588
+ remainder = [item for item in ordered if id(item) not in taken]
589
+
590
+ selected = pinned + reserved
591
+ return (selected + remainder)[:max_files]
592
+
593
+
594
+ def _matches_any_path(path: str, patterns: list[str]) -> bool:
595
+ """True when ``path`` is covered by one of the user's path patterns.
596
+
597
+ A pattern may be a directory prefix (``src/reader``), a full relative path,
598
+ or a bare file name (``CsvReader.cpp``). Comparison is case-insensitive and
599
+ separator-agnostic so a Windows-style pattern still matches rg's output.
600
+ """
601
+ if not patterns:
602
+ return False
603
+ normalized = path.replace("\\", "/").lower().lstrip("./")
604
+ for pattern in patterns:
605
+ candidate = (pattern or "").replace("\\", "/").strip().lower().lstrip("./").rstrip("/")
606
+ if not candidate:
607
+ continue
608
+ if normalized == candidate or normalized.startswith(candidate + "/"):
609
+ return True
610
+ if "/" not in candidate and normalized.rsplit("/", 1)[-1] == candidate:
611
+ return True
612
+ return False
613
+
614
+
615
+ def _apply_focus_bonus(item: FileScore, focus_files: list[str]) -> None:
616
+ """Lift files the developer pointed at, without hiding anything else.
617
+
618
+ A boost rather than a filter: focus is a steer, and silently dropping every
619
+ unlisted file would turn a wrong guess into an empty search with no signal
620
+ that the guess was wrong.
621
+ """
622
+ if _matches_any_path(item.file, focus_files):
623
+ item.score += FOCUS_FILE_BONUS
624
+ item.reasons.append(FOCUS_REASON)
625
+
626
+
627
+ def _apply_header_implementation_bonus(scores: dict[str, FileScore]) -> None:
628
+ by_stem: defaultdict[str, list[FileScore]] = defaultdict(list)
629
+ for item in scores.values():
630
+ path = Path(item.file)
631
+ if path.suffix.lower() in {".h", ".hpp", ".cpp", ".cxx", ".cc"}:
632
+ by_stem[str(path.with_suffix(""))].append(item)
633
+ for items in by_stem.values():
634
+ suffixes = {Path(item.file).suffix.lower() for item in items}
635
+ if suffixes & {".h", ".hpp"} and suffixes & {".cpp", ".cxx", ".cc"}:
636
+ for item in items:
637
+ item.score += 2
638
+ item.reasons.append("header/source pair bonus")
639
+
640
+
641
+ def _keyword_quality(keyword: str, in_high_value: bool, in_normal: bool) -> str:
642
+ lower = keyword.lower()
643
+ if lower in LOW_VALUE_KEYWORDS:
644
+ return "low"
645
+ if _looks_like_specific_identifier(keyword):
646
+ return "high"
647
+ if in_high_value and lower not in MEDIUM_VALUE_KEYWORDS:
648
+ return "high"
649
+ if lower in MEDIUM_VALUE_KEYWORDS or in_normal:
650
+ return "medium"
651
+ return "low"
652
+
653
+
654
+ def _looks_like_specific_identifier(keyword: str) -> bool:
655
+ return (
656
+ any(char.islower() for char in keyword) and any(char.isupper() for char in keyword)
657
+ ) or "." in keyword or "/" in keyword or "\\" in keyword or "::" in keyword or '"' in keyword
658
+
659
+
660
+ def _apply_path_adjustments(item: FileScore) -> None:
661
+ flags = _noise_flags(item.file)
662
+ if flags:
663
+ item.noise_flags = flags
664
+ item.score -= 4
665
+ item.reasons.append("path looks like CI/build/license/vendor/docs/generated content")
666
+ if _is_application_path(item.file):
667
+ item.score += 3
668
+ item.reasons.append("matched keyword in application source path")
669
+
670
+
671
+ def _noise_flags(path: str) -> list[str]:
672
+ normalized = path.replace("\\", "/").lower()
673
+ parts = [part for part in normalized.split("/") if part]
674
+ flags: set[str] = set()
675
+ for indicator, flag in NOISE_PATH_INDICATORS.items():
676
+ if "/" in indicator:
677
+ if indicator in normalized:
678
+ flags.add(flag)
679
+ elif indicator in parts:
680
+ flags.add(flag)
681
+ return sorted(flags)
682
+
683
+
684
+ def _is_application_path(path: str) -> bool:
685
+ parts = [part.lower() for part in path.replace("\\", "/").split("/") if part]
686
+ return any(part in APPLICATION_PATH_INDICATORS for part in parts)
687
+
688
+
689
+ def _is_test_path(path: str) -> bool:
690
+ parts = [part.lower() for part in path.replace("\\", "/").split("/") if part]
691
+ if any(part in TEST_PATH_INDICATORS for part in parts):
692
+ return True
693
+ name = parts[-1] if parts else ""
694
+ return name.startswith("test_") or name.startswith("test.") or "_test." in name
695
+
696
+
697
+ def _assign_confidence(item: FileScore) -> None:
698
+ high = item.keyword_quality_counts["high"]
699
+ medium = item.keyword_quality_counts["medium"]
700
+ low = item.keyword_quality_counts["low"]
701
+ has_app_path = _is_application_path(item.file)
702
+ has_noise = bool(item.noise_flags)
703
+ is_test = _is_test_path(item.file)
704
+ # A keyword matching the file's own name, an exact phrase hit, or several
705
+ # distinct high-value keywords is a strong signal on its own — enough for
706
+ # high confidence even outside a recognised application-source directory.
707
+ strong_signal = (
708
+ "keyword matches file name" in item.reasons
709
+ or "exact phrase match" in item.reasons
710
+ or high >= 3
711
+ )
712
+ if is_test and (high or medium or low):
713
+ # Capped at medium: still listed, still a lead, but it cannot be the
714
+ # file that tells the reader the implementation was located.
715
+ item.confidence = "medium"
716
+ item.reasons.append("matched in a test path, which is a lead rather than an implementation")
717
+ return
718
+ if high and (has_app_path or strong_signal) and not has_noise:
719
+ item.confidence = "high"
720
+ item.reasons.append(
721
+ "matched high-value keyword in application source path" if has_app_path
722
+ else "strong file-name / phrase / multi-keyword match"
723
+ )
724
+ elif (high or medium) and (has_app_path or not has_noise):
725
+ item.confidence = "medium"
726
+ item.reasons.append("matched plausible implementation keyword")
727
+ else:
728
+ item.confidence = "low"
729
+ if low and not high and not medium:
730
+ item.reasons.append("matched only generic keyword")
731
+ if has_noise:
732
+ item.reasons.append("match appears in noisy path")
733
+ if not item.reasons:
734
+ item.reasons.append("keyword match candidate")
735
+
736
+
737
+ def _overall_quality(related: list[RelatedFile], warnings: list[str]) -> tuple[str, list[str], list[str]]:
738
+ """Confidence, the reasons for it, and the noisy-path flags seen."""
739
+ high_files = [item.file for item in related if item.confidence == "high"]
740
+ medium_files = [item.file for item in related if item.confidence == "medium"]
741
+ low_files = [item.file for item in related if item.confidence == "low"]
742
+ noise_indicators = sorted({flag for item in related for flag in item.noise_flags})
743
+ reasons: list[str] = []
744
+ if high_files:
745
+ confidence = "high"
746
+ reasons.append("At least one high-confidence application source file was found.")
747
+ elif medium_files:
748
+ confidence = "medium"
749
+ reasons.append("Some plausible application or implementation files were matched.")
750
+ else:
751
+ confidence = "low"
752
+ reasons.append("No high-confidence application source file was found.")
753
+
754
+ if related and len(low_files) >= max(1, len(related) // 2) and not high_files:
755
+ reasons.append("Matches are mostly from low-value keywords or weak candidates.")
756
+ confidence = "low" if not medium_files else confidence
757
+ if noise_indicators:
758
+ reasons.append("Several matches appear in build/CI/license/vendor/docs/generated paths.")
759
+ if not high_files:
760
+ confidence = "low"
761
+ if not related:
762
+ confidence = "low"
763
+ reasons.append("No related files were found.")
764
+ reasons.extend(warnings)
765
+ return confidence, reasons, noise_indicators