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.
- bugpilot/__init__.py +5 -0
- bugpilot/__main__.py +5 -0
- bugpilot/cli.py +2615 -0
- bugpilot/cli_json.py +173 -0
- bugpilot/core/__init__.py +1 -0
- bugpilot/core/agent_runner.py +138 -0
- bugpilot/core/artifact_io.py +40 -0
- bugpilot/core/artifacts.py +47 -0
- bugpilot/core/attachments.py +247 -0
- bugpilot/core/branch_policy.py +223 -0
- bugpilot/core/cleanup.py +58 -0
- bugpilot/core/code_files.py +99 -0
- bugpilot/core/config.py +242 -0
- bugpilot/core/context.py +436 -0
- bugpilot/core/copilot.py +47 -0
- bugpilot/core/delivery_instructions.py +106 -0
- bugpilot/core/doctor.py +66 -0
- bugpilot/core/email_notify.py +316 -0
- bugpilot/core/errors.py +105 -0
- bugpilot/core/executables.py +87 -0
- bugpilot/core/fix_mode_state.py +212 -0
- bugpilot/core/fix_mode_store.py +600 -0
- bugpilot/core/fix_modes.py +412 -0
- bugpilot/core/fix_report.py +124 -0
- bugpilot/core/git_history.py +1579 -0
- bugpilot/core/git_ops.py +254 -0
- bugpilot/core/handoff.py +127 -0
- bugpilot/core/identity.py +112 -0
- bugpilot/core/input_adapters.py +97 -0
- bugpilot/core/instructions.py +337 -0
- bugpilot/core/issue.py +487 -0
- bugpilot/core/jira.py +886 -0
- bugpilot/core/jira_adf.py +167 -0
- bugpilot/core/jira_parse.py +442 -0
- bugpilot/core/keywords.py +386 -0
- bugpilot/core/logging_utils.py +28 -0
- bugpilot/core/memory.py +151 -0
- bugpilot/core/models.py +322 -0
- bugpilot/core/project_settings.py +283 -0
- bugpilot/core/prompts.py +567 -0
- bugpilot/core/repository_profile.py +739 -0
- bugpilot/core/retrieval.py +485 -0
- bugpilot/core/review_changes.py +155 -0
- bugpilot/core/review_report.py +171 -0
- bugpilot/core/run.py +171 -0
- bugpilot/core/safe_paths.py +173 -0
- bugpilot/core/search.py +765 -0
- bugpilot/core/search_terms.py +310 -0
- bugpilot/core/setup.py +212 -0
- bugpilot/core/user_config.py +211 -0
- bugpilot/core/verification_report.py +313 -0
- bugpilot/core/workflow.py +2385 -0
- bugpilot/mcp_server.py +651 -0
- bugpilot-0.1.0.dist-info/METADATA +270 -0
- bugpilot-0.1.0.dist-info/RECORD +59 -0
- bugpilot-0.1.0.dist-info/WHEEL +5 -0
- bugpilot-0.1.0.dist-info/entry_points.txt +3 -0
- bugpilot-0.1.0.dist-info/licenses/LICENSE +122 -0
- bugpilot-0.1.0.dist-info/top_level.txt +1 -0
bugpilot/core/search.py
ADDED
|
@@ -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
|