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 +78 -15
- code_retrieval/engine.py +296 -19
- code_retrieval/index_store.py +30 -13
- code_retrieval/installer.py +445 -42
- code_retrieval/models.py +17 -2
- code_retrieval/query.py +45 -1
- code_retrieval/repository.py +69 -7
- code_retrieval/resources/code-search/SKILL.md +8 -3
- code_retrieval/structure.py +107 -27
- code_search_cli-0.3.0.dist-info/METADATA +392 -0
- code_search_cli-0.3.0.dist-info/RECORD +19 -0
- code_search_cli-0.3.0.dist-info/licenses/NOTICE +4 -0
- code_search_cli-0.1.0.dist-info/METADATA +0 -287
- code_search_cli-0.1.0.dist-info/RECORD +0 -19
- code_search_cli-0.1.0.dist-info/licenses/NOTICE +0 -7
- {code_search_cli-0.1.0.dist-info → code_search_cli-0.3.0.dist-info}/WHEEL +0 -0
- {code_search_cli-0.1.0.dist-info → code_search_cli-0.3.0.dist-info}/entry_points.txt +0 -0
- {code_search_cli-0.1.0.dist-info → code_search_cli-0.3.0.dist-info}/licenses/LICENSE +0 -0
- {code_search_cli-0.1.0.dist-info → code_search_cli-0.3.0.dist-info}/top_level.txt +0 -0
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
|
|
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=
|
|
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=
|
|
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
|
|
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
|
-
|
|
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 =
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
print("
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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 * (
|
|
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(
|
|
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
|
|
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 =
|
|
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(
|
|
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
|
|
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
|
|
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
|
|
598
|
-
|
|
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
|
|
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
|
+
)
|