code-search-cli 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- code_retrieval/__init__.py +6 -0
- code_retrieval/__main__.py +3 -0
- code_retrieval/cli.py +144 -0
- code_retrieval/engine.py +722 -0
- code_retrieval/index_store.py +286 -0
- code_retrieval/installer.py +65 -0
- code_retrieval/models.py +90 -0
- code_retrieval/query.py +61 -0
- code_retrieval/repository.py +161 -0
- code_retrieval/resources/code-search/SKILL.md +47 -0
- code_retrieval/resources/code-search/agents/openai.yaml +4 -0
- code_retrieval/structure.py +115 -0
- code_search_cli-0.1.0.dist-info/METADATA +287 -0
- code_search_cli-0.1.0.dist-info/RECORD +19 -0
- code_search_cli-0.1.0.dist-info/WHEEL +5 -0
- code_search_cli-0.1.0.dist-info/entry_points.txt +2 -0
- code_search_cli-0.1.0.dist-info/licenses/LICENSE +202 -0
- code_search_cli-0.1.0.dist-info/licenses/NOTICE +7 -0
- code_search_cli-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
import sqlite3
|
|
7
|
+
import time
|
|
8
|
+
from collections import Counter
|
|
9
|
+
from dataclasses import asdict, dataclass
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
from .models import Section, SourceFile
|
|
13
|
+
from .query import document_terms
|
|
14
|
+
from .repository import RepositoryReader, SourceSignature
|
|
15
|
+
from .structure import sections_for
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
SCHEMA_VERSION = "1"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass(slots=True)
|
|
22
|
+
class IndexSnapshot:
|
|
23
|
+
files: list[SourceFile]
|
|
24
|
+
document_terms: dict[str, Counter[str]]
|
|
25
|
+
sections: dict[str, list[Section]]
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass(slots=True)
|
|
29
|
+
class IndexRefresh:
|
|
30
|
+
index_path: str
|
|
31
|
+
source_files: int
|
|
32
|
+
added: int
|
|
33
|
+
updated: int
|
|
34
|
+
removed: int
|
|
35
|
+
unchanged: int
|
|
36
|
+
elapsed_ms: float
|
|
37
|
+
|
|
38
|
+
def to_dict(self) -> dict[str, str | int | float]:
|
|
39
|
+
return asdict(self)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass(slots=True)
|
|
43
|
+
class IndexStatus:
|
|
44
|
+
index_path: str
|
|
45
|
+
exists: bool
|
|
46
|
+
fresh: bool
|
|
47
|
+
source_files: int
|
|
48
|
+
added: int
|
|
49
|
+
updated: int
|
|
50
|
+
removed: int
|
|
51
|
+
revision: str | None
|
|
52
|
+
|
|
53
|
+
def to_dict(self) -> dict[str, str | int | bool | None]:
|
|
54
|
+
return asdict(self)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def cache_root() -> Path:
|
|
58
|
+
override = os.environ.get("CODE_SEARCH_CACHE_HOME")
|
|
59
|
+
if override:
|
|
60
|
+
return Path(override).expanduser()
|
|
61
|
+
xdg = os.environ.get("XDG_CACHE_HOME")
|
|
62
|
+
return (Path(xdg).expanduser() if xdg else Path.home() / ".cache") / "code-search"
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class PersistentIndex:
|
|
66
|
+
def __init__(self, reader: RepositoryReader):
|
|
67
|
+
self.reader = reader
|
|
68
|
+
identity = f"{reader.root}\0{reader.ref or 'WORKTREE'}".encode()
|
|
69
|
+
self.path = cache_root() / f"{hashlib.sha256(identity).hexdigest()[:24]}.sqlite3"
|
|
70
|
+
|
|
71
|
+
def refresh(self) -> IndexRefresh:
|
|
72
|
+
started = time.perf_counter()
|
|
73
|
+
self.path.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
|
|
74
|
+
if not self.path.exists():
|
|
75
|
+
self.path.touch(mode=0o600)
|
|
76
|
+
with self._connect() as connection:
|
|
77
|
+
self._initialize(connection)
|
|
78
|
+
if self.reader.ref:
|
|
79
|
+
counts = self._refresh_revision(connection)
|
|
80
|
+
else:
|
|
81
|
+
counts = self._refresh_worktree(connection)
|
|
82
|
+
source_files = int(connection.execute("SELECT COUNT(*) FROM files").fetchone()[0])
|
|
83
|
+
return IndexRefresh(
|
|
84
|
+
str(self.path),
|
|
85
|
+
source_files,
|
|
86
|
+
counts["added"],
|
|
87
|
+
counts["updated"],
|
|
88
|
+
counts["removed"],
|
|
89
|
+
counts["unchanged"],
|
|
90
|
+
round((time.perf_counter() - started) * 1000, 2),
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
def load(self) -> IndexSnapshot:
|
|
94
|
+
files = []
|
|
95
|
+
terms = {}
|
|
96
|
+
sections = {}
|
|
97
|
+
with self._connect() as connection:
|
|
98
|
+
self._initialize(connection)
|
|
99
|
+
rows = connection.execute(
|
|
100
|
+
"SELECT path, content, terms, sections FROM files ORDER BY path"
|
|
101
|
+
)
|
|
102
|
+
for path, content, raw_terms, raw_sections in rows:
|
|
103
|
+
source = SourceFile(path, content)
|
|
104
|
+
files.append(source)
|
|
105
|
+
terms[path] = Counter(json.loads(raw_terms))
|
|
106
|
+
lines = content.splitlines()
|
|
107
|
+
sections[path] = [
|
|
108
|
+
Section(
|
|
109
|
+
path,
|
|
110
|
+
item["name"],
|
|
111
|
+
item["kind"],
|
|
112
|
+
item["start_line"],
|
|
113
|
+
item["end_line"],
|
|
114
|
+
"\n".join(lines[item["start_line"] - 1 : item["end_line"]]),
|
|
115
|
+
)
|
|
116
|
+
for item in json.loads(raw_sections)
|
|
117
|
+
]
|
|
118
|
+
return IndexSnapshot(files, terms, sections)
|
|
119
|
+
|
|
120
|
+
def status(self) -> IndexStatus:
|
|
121
|
+
if not self.path.exists():
|
|
122
|
+
return IndexStatus(str(self.path), False, False, 0, 0, 0, 0, self.reader.resolved_revision())
|
|
123
|
+
with self._connect() as connection:
|
|
124
|
+
self._initialize(connection)
|
|
125
|
+
source_files = int(connection.execute("SELECT COUNT(*) FROM files").fetchone()[0])
|
|
126
|
+
if self.reader.ref:
|
|
127
|
+
revision = self.reader.resolved_revision()
|
|
128
|
+
indexed_revision = self._meta(connection, "revision")
|
|
129
|
+
fresh = self._meta(connection, "initialized") == "1" and revision == indexed_revision
|
|
130
|
+
return IndexStatus(
|
|
131
|
+
str(self.path), True, fresh, source_files,
|
|
132
|
+
0 if fresh else source_files, 0, 0 if fresh else source_files, revision,
|
|
133
|
+
)
|
|
134
|
+
current = {item.path: item for item in self.reader.source_signatures()}
|
|
135
|
+
stored = self._stored_signatures(connection)
|
|
136
|
+
added = len(current.keys() - stored.keys())
|
|
137
|
+
removed = len(stored.keys() - current.keys())
|
|
138
|
+
updated = sum(
|
|
139
|
+
current[path].size != stored[path][0] or current[path].mtime_ns != stored[path][1]
|
|
140
|
+
for path in current.keys() & stored.keys()
|
|
141
|
+
)
|
|
142
|
+
return IndexStatus(
|
|
143
|
+
str(self.path), True, not (added or updated or removed), source_files,
|
|
144
|
+
added, updated, removed, None,
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
def clean(self) -> bool:
|
|
148
|
+
removed = False
|
|
149
|
+
for path in (self.path, Path(f"{self.path}-wal"), Path(f"{self.path}-shm")):
|
|
150
|
+
if path.exists():
|
|
151
|
+
path.unlink()
|
|
152
|
+
removed = True
|
|
153
|
+
return removed
|
|
154
|
+
|
|
155
|
+
def _refresh_worktree(self, connection: sqlite3.Connection) -> dict[str, int]:
|
|
156
|
+
current = {item.path: item for item in self.reader.source_signatures()}
|
|
157
|
+
stored = self._stored_signatures(connection)
|
|
158
|
+
added_paths = current.keys() - stored.keys()
|
|
159
|
+
removed_paths = stored.keys() - current.keys()
|
|
160
|
+
updated_paths = {
|
|
161
|
+
path for path in current.keys() & stored.keys()
|
|
162
|
+
if current[path].size != stored[path][0] or current[path].mtime_ns != stored[path][1]
|
|
163
|
+
}
|
|
164
|
+
for path in removed_paths:
|
|
165
|
+
connection.execute("DELETE FROM files WHERE path = ?", (path,))
|
|
166
|
+
indexed_added = 0
|
|
167
|
+
indexed_updated = 0
|
|
168
|
+
for path in sorted(added_paths | updated_paths):
|
|
169
|
+
try:
|
|
170
|
+
source = self.reader.read_file(path)
|
|
171
|
+
except OSError:
|
|
172
|
+
connection.execute("DELETE FROM files WHERE path = ?", (path,))
|
|
173
|
+
continue
|
|
174
|
+
if "\x00" in source.content:
|
|
175
|
+
connection.execute("DELETE FROM files WHERE path = ?", (path,))
|
|
176
|
+
continue
|
|
177
|
+
signature = current[path]
|
|
178
|
+
self._store_source(connection, source, signature)
|
|
179
|
+
if path in added_paths:
|
|
180
|
+
indexed_added += 1
|
|
181
|
+
else:
|
|
182
|
+
indexed_updated += 1
|
|
183
|
+
self._set_meta(connection, "revision", "")
|
|
184
|
+
self._set_meta(connection, "initialized", "1")
|
|
185
|
+
return {
|
|
186
|
+
"added": indexed_added,
|
|
187
|
+
"updated": indexed_updated,
|
|
188
|
+
"removed": len(removed_paths),
|
|
189
|
+
"unchanged": len(current) - len(added_paths) - len(updated_paths),
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
def _refresh_revision(self, connection: sqlite3.Connection) -> dict[str, int]:
|
|
193
|
+
revision = self.reader.resolved_revision()
|
|
194
|
+
previous = self._meta(connection, "revision")
|
|
195
|
+
initialized = self._meta(connection, "initialized") == "1"
|
|
196
|
+
previous_count = int(connection.execute("SELECT COUNT(*) FROM files").fetchone()[0])
|
|
197
|
+
if initialized and revision == previous:
|
|
198
|
+
return {"added": 0, "updated": 0, "removed": 0, "unchanged": previous_count}
|
|
199
|
+
connection.execute("DELETE FROM files")
|
|
200
|
+
added = 0
|
|
201
|
+
for source in self.reader.iter_source_files():
|
|
202
|
+
self._store_source(connection, source, SourceSignature(source.path, len(source.content), 0))
|
|
203
|
+
added += 1
|
|
204
|
+
self._set_meta(connection, "revision", revision or "")
|
|
205
|
+
self._set_meta(connection, "initialized", "1")
|
|
206
|
+
return {"added": added, "updated": 0, "removed": previous_count, "unchanged": 0}
|
|
207
|
+
|
|
208
|
+
def _store_source(
|
|
209
|
+
self,
|
|
210
|
+
connection: sqlite3.Connection,
|
|
211
|
+
source: SourceFile,
|
|
212
|
+
signature: SourceSignature,
|
|
213
|
+
) -> None:
|
|
214
|
+
section_metadata = [
|
|
215
|
+
{
|
|
216
|
+
"name": item.name,
|
|
217
|
+
"kind": item.kind,
|
|
218
|
+
"start_line": item.start_line,
|
|
219
|
+
"end_line": item.end_line,
|
|
220
|
+
}
|
|
221
|
+
for item in sections_for(source)
|
|
222
|
+
]
|
|
223
|
+
connection.execute(
|
|
224
|
+
"""
|
|
225
|
+
INSERT INTO files(path, size, mtime_ns, content, terms, sections)
|
|
226
|
+
VALUES (?, ?, ?, ?, ?, ?)
|
|
227
|
+
ON CONFLICT(path) DO UPDATE SET
|
|
228
|
+
size=excluded.size,
|
|
229
|
+
mtime_ns=excluded.mtime_ns,
|
|
230
|
+
content=excluded.content,
|
|
231
|
+
terms=excluded.terms,
|
|
232
|
+
sections=excluded.sections
|
|
233
|
+
""",
|
|
234
|
+
(
|
|
235
|
+
source.path,
|
|
236
|
+
signature.size,
|
|
237
|
+
signature.mtime_ns,
|
|
238
|
+
source.content,
|
|
239
|
+
json.dumps(document_terms(source.content), separators=(",", ":")),
|
|
240
|
+
json.dumps(section_metadata, separators=(",", ":")),
|
|
241
|
+
),
|
|
242
|
+
)
|
|
243
|
+
|
|
244
|
+
def _stored_signatures(self, connection: sqlite3.Connection) -> dict[str, tuple[int, int]]:
|
|
245
|
+
return {
|
|
246
|
+
path: (int(size), int(mtime_ns))
|
|
247
|
+
for path, size, mtime_ns in connection.execute("SELECT path, size, mtime_ns FROM files")
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
def _connect(self) -> sqlite3.Connection:
|
|
251
|
+
return sqlite3.connect(self.path, timeout=30)
|
|
252
|
+
|
|
253
|
+
def _initialize(self, connection: sqlite3.Connection) -> None:
|
|
254
|
+
connection.execute("CREATE TABLE IF NOT EXISTS metadata(key TEXT PRIMARY KEY, value TEXT NOT NULL)")
|
|
255
|
+
version = self._meta(connection, "schema_version")
|
|
256
|
+
if version and version != SCHEMA_VERSION:
|
|
257
|
+
connection.execute("DROP TABLE IF EXISTS files")
|
|
258
|
+
connection.execute("DELETE FROM metadata")
|
|
259
|
+
connection.execute(
|
|
260
|
+
"""
|
|
261
|
+
CREATE TABLE IF NOT EXISTS files(
|
|
262
|
+
path TEXT PRIMARY KEY,
|
|
263
|
+
size INTEGER NOT NULL,
|
|
264
|
+
mtime_ns INTEGER NOT NULL,
|
|
265
|
+
content TEXT NOT NULL,
|
|
266
|
+
terms TEXT NOT NULL,
|
|
267
|
+
sections TEXT NOT NULL
|
|
268
|
+
)
|
|
269
|
+
"""
|
|
270
|
+
)
|
|
271
|
+
self._set_meta(connection, "schema_version", SCHEMA_VERSION)
|
|
272
|
+
self._set_meta(connection, "repository", str(self.reader.root))
|
|
273
|
+
self._set_meta(connection, "ref", self.reader.ref or "")
|
|
274
|
+
|
|
275
|
+
@staticmethod
|
|
276
|
+
def _meta(connection: sqlite3.Connection, key: str) -> str | None:
|
|
277
|
+
row = connection.execute("SELECT value FROM metadata WHERE key = ?", (key,)).fetchone()
|
|
278
|
+
return str(row[0]) if row else None
|
|
279
|
+
|
|
280
|
+
@staticmethod
|
|
281
|
+
def _set_meta(connection: sqlite3.Connection, key: str, value: str) -> None:
|
|
282
|
+
connection.execute(
|
|
283
|
+
"INSERT INTO metadata(key, value) VALUES (?, ?) "
|
|
284
|
+
"ON CONFLICT(key) DO UPDATE SET value=excluded.value",
|
|
285
|
+
(key, value),
|
|
286
|
+
)
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from importlib.resources import files
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Literal
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
InstallAction = Literal["created", "updated", "unchanged"]
|
|
10
|
+
SKILL_FILES = (Path("SKILL.md"), Path("agents/openai.yaml"))
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def codex_skills_root() -> Path:
|
|
14
|
+
codex_home = os.environ.get("CODEX_HOME")
|
|
15
|
+
return (Path(codex_home).expanduser() if codex_home else Path.home() / ".codex") / "skills"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def install_codex_skill(*, force: bool = False) -> tuple[Path, InstallAction]:
|
|
19
|
+
target = codex_skills_root() / "code-search"
|
|
20
|
+
source = files("code_retrieval").joinpath("resources/code-search")
|
|
21
|
+
existed = target.exists()
|
|
22
|
+
changed = False
|
|
23
|
+
|
|
24
|
+
for relative in SKILL_FILES:
|
|
25
|
+
destination = target / relative
|
|
26
|
+
content = source.joinpath(relative.as_posix()).read_text(encoding="utf-8")
|
|
27
|
+
if destination.exists():
|
|
28
|
+
existing = destination.read_text(encoding="utf-8")
|
|
29
|
+
if existing == content:
|
|
30
|
+
continue
|
|
31
|
+
if not force:
|
|
32
|
+
raise FileExistsError(
|
|
33
|
+
f"refusing to overwrite modified skill file: {destination}; rerun with --force"
|
|
34
|
+
)
|
|
35
|
+
destination.parent.mkdir(parents=True, exist_ok=True)
|
|
36
|
+
destination.write_text(content, encoding="utf-8")
|
|
37
|
+
changed = True
|
|
38
|
+
|
|
39
|
+
action: InstallAction = "unchanged" if not changed else ("updated" if existed else "created")
|
|
40
|
+
return target, action
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def uninstall_codex_skill(*, force: bool = False) -> tuple[Path, Literal["removed", "not-found"]]:
|
|
44
|
+
target = codex_skills_root() / "code-search"
|
|
45
|
+
if not target.exists():
|
|
46
|
+
return target, "not-found"
|
|
47
|
+
|
|
48
|
+
source = files("code_retrieval").joinpath("resources/code-search")
|
|
49
|
+
for relative in SKILL_FILES:
|
|
50
|
+
destination = target / relative
|
|
51
|
+
if not destination.exists():
|
|
52
|
+
continue
|
|
53
|
+
expected = source.joinpath(relative.as_posix()).read_text(encoding="utf-8")
|
|
54
|
+
if destination.read_text(encoding="utf-8") != expected and not force:
|
|
55
|
+
raise FileExistsError(
|
|
56
|
+
f"refusing to remove modified skill file: {destination}; rerun with --force"
|
|
57
|
+
)
|
|
58
|
+
destination.unlink()
|
|
59
|
+
|
|
60
|
+
agents = target / "agents"
|
|
61
|
+
if agents.exists() and not any(agents.iterdir()):
|
|
62
|
+
agents.rmdir()
|
|
63
|
+
if target.exists() and not any(target.iterdir()):
|
|
64
|
+
target.rmdir()
|
|
65
|
+
return target, "removed"
|
code_retrieval/models.py
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import asdict, dataclass, field
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@dataclass(slots=True)
|
|
8
|
+
class SourceFile:
|
|
9
|
+
path: str
|
|
10
|
+
content: str
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass(slots=True)
|
|
14
|
+
class Section:
|
|
15
|
+
path: str
|
|
16
|
+
name: str
|
|
17
|
+
kind: str
|
|
18
|
+
start_line: int
|
|
19
|
+
end_line: int
|
|
20
|
+
content: str
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass(slots=True)
|
|
24
|
+
class Evidence:
|
|
25
|
+
path: str
|
|
26
|
+
start_line: int
|
|
27
|
+
end_line: int
|
|
28
|
+
kind: str
|
|
29
|
+
name: str
|
|
30
|
+
score: float
|
|
31
|
+
reasons: list[str]
|
|
32
|
+
content: str
|
|
33
|
+
estimated_tokens: int
|
|
34
|
+
|
|
35
|
+
def key(self) -> tuple[str, int, int]:
|
|
36
|
+
return self.path, self.start_line, self.end_line
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass(slots=True)
|
|
40
|
+
class RetrievalResult:
|
|
41
|
+
task: str
|
|
42
|
+
repository: str
|
|
43
|
+
ref: str | None
|
|
44
|
+
used_tokens: int
|
|
45
|
+
query_terms: list[str]
|
|
46
|
+
evidence: list[Evidence]
|
|
47
|
+
stats: dict[str, Any] = field(default_factory=dict)
|
|
48
|
+
|
|
49
|
+
@property
|
|
50
|
+
def files(self) -> list[str]:
|
|
51
|
+
return list(dict.fromkeys(item.path for item in self.evidence))
|
|
52
|
+
|
|
53
|
+
def to_dict(self) -> dict[str, Any]:
|
|
54
|
+
data = asdict(self)
|
|
55
|
+
data["files"] = self.files
|
|
56
|
+
return data
|
|
57
|
+
|
|
58
|
+
def to_markdown(self) -> str:
|
|
59
|
+
ref = self.ref or "working tree"
|
|
60
|
+
lines = [
|
|
61
|
+
"# Retrieval evidence",
|
|
62
|
+
"",
|
|
63
|
+
f"- Task: {self.task}",
|
|
64
|
+
f"- Repository: `{self.repository}`",
|
|
65
|
+
f"- Revision: `{ref}`",
|
|
66
|
+
f"- Files: {len(self.files)}; evidence spans: {len(self.evidence)}; "
|
|
67
|
+
f"estimated payload: {self.used_tokens} tokens",
|
|
68
|
+
"",
|
|
69
|
+
]
|
|
70
|
+
for index, item in enumerate(self.evidence, 1):
|
|
71
|
+
suffix = _language_for(item.path)
|
|
72
|
+
lines.extend(
|
|
73
|
+
[
|
|
74
|
+
f"## {index}. `{item.path}:{item.start_line}-{item.end_line}`",
|
|
75
|
+
"",
|
|
76
|
+
f"Score `{item.score:.2f}` · {item.kind} `{item.name}` · "
|
|
77
|
+
+ "; ".join(item.reasons),
|
|
78
|
+
"",
|
|
79
|
+
f"```{suffix}",
|
|
80
|
+
item.content.rstrip(),
|
|
81
|
+
"```",
|
|
82
|
+
"",
|
|
83
|
+
]
|
|
84
|
+
)
|
|
85
|
+
return "\n".join(lines)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _language_for(path: str) -> str:
|
|
89
|
+
suffix = path.rsplit(".", 1)[-1].lower() if "." in path else "text"
|
|
90
|
+
return {"py": "python", "js": "javascript", "ts": "typescript"}.get(suffix, suffix)
|
code_retrieval/query.py
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from collections import Counter
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
WORD_RE = re.compile(r"[A-Za-z][A-Za-z0-9_]*|[\u4e00-\u9fff]+")
|
|
8
|
+
CAMEL_RE = re.compile(r"[A-Z]+(?=[A-Z][a-z]|\d|$)|[A-Z]?[a-z]+|\d+")
|
|
9
|
+
|
|
10
|
+
STOP_WORDS = {
|
|
11
|
+
"a", "an", "and", "are", "be", "for", "from", "in", "is", "it", "of", "on", "or",
|
|
12
|
+
"so", "that", "the", "this", "to", "with", "not", "fix", "resolve", "detect", "issue", "problem",
|
|
13
|
+
"missing", "missed", "change", "changes", "code", "configuration",
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
# Generic engineering vocabulary, not repository- or competitor-specific rules.
|
|
17
|
+
ALIASES: dict[str, tuple[str, ...]] = {
|
|
18
|
+
"nullpointerexception": ("null", "nullable", "optional"),
|
|
19
|
+
"npe": ("null", "nullable"),
|
|
20
|
+
"scheduled": ("schedule", "scheduler", "cron", "trigger"),
|
|
21
|
+
"schedule": ("scheduler", "cron", "trigger"),
|
|
22
|
+
"configuration": ("config", "properties", "settings"),
|
|
23
|
+
"config": ("configuration", "properties", "settings"),
|
|
24
|
+
"callback": ("listener", "subscriber", "event", "notify", "publish", "handler"),
|
|
25
|
+
"utf8": ("utf", "charset", "encoding", "encode"),
|
|
26
|
+
"md5": ("digest", "checksum", "hash"),
|
|
27
|
+
"dependency": ("depends", "dependson", "bean"),
|
|
28
|
+
"injection": ("autowired", "inject", "bean", "component"),
|
|
29
|
+
"order": ("dependson", "postconstruct", "initialize", "initialization", "init"),
|
|
30
|
+
"startup": ("postconstruct", "initialize", "initialization", "init", "bean"),
|
|
31
|
+
"event": ("notify", "publish", "subscriber", "listener"),
|
|
32
|
+
"events": ("event", "notify", "publish", "subscriber", "listener"),
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def split_identifier(value: str) -> list[str]:
|
|
37
|
+
parts: list[str] = []
|
|
38
|
+
for raw in WORD_RE.findall(value.replace("_", " ")):
|
|
39
|
+
if raw.isascii():
|
|
40
|
+
camel = CAMEL_RE.findall(raw)
|
|
41
|
+
parts.extend(piece.lower() for piece in camel if piece)
|
|
42
|
+
joined = raw.lower()
|
|
43
|
+
if joined not in parts:
|
|
44
|
+
parts.append(joined)
|
|
45
|
+
else:
|
|
46
|
+
parts.append(raw)
|
|
47
|
+
return parts
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def document_terms(value: str) -> Counter[str]:
|
|
51
|
+
return Counter(split_identifier(value))
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def query_terms(task: str) -> tuple[dict[str, float], list[str]]:
|
|
55
|
+
raw = split_identifier(task)
|
|
56
|
+
original = list(dict.fromkeys(term for term in raw if term not in STOP_WORDS and len(term) > 1))
|
|
57
|
+
weighted = {term: 3.0 for term in original}
|
|
58
|
+
for term in raw:
|
|
59
|
+
for alias in ALIASES.get(term, ()):
|
|
60
|
+
weighted.setdefault(alias, 1.0)
|
|
61
|
+
return weighted, original
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import subprocess
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Iterator
|
|
8
|
+
|
|
9
|
+
from .models import SourceFile
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
SOURCE_SUFFIXES = {
|
|
13
|
+
".c", ".cc", ".cpp", ".cs", ".go", ".h", ".hpp", ".java", ".js", ".jsx", ".kt",
|
|
14
|
+
".kts", ".php", ".py", ".rb", ".rs", ".scala", ".sh", ".sql", ".swift", ".ts", ".tsx",
|
|
15
|
+
".vue", ".xml", ".yaml", ".yml", ".toml", ".properties", ".gradle",
|
|
16
|
+
}
|
|
17
|
+
SKIP_DIRS = {
|
|
18
|
+
".git", ".idea", ".mypy_cache", ".pytest_cache", ".tox", ".venv", "build", "coverage",
|
|
19
|
+
"dist", "node_modules", "target", "vendor", "venv",
|
|
20
|
+
}
|
|
21
|
+
MAX_FILE_BYTES = 1_000_000
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass(frozen=True, slots=True)
|
|
25
|
+
class SourceSignature:
|
|
26
|
+
path: str
|
|
27
|
+
size: int
|
|
28
|
+
mtime_ns: int
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class RepositoryReader:
|
|
32
|
+
def __init__(self, root: str | Path, ref: str | None = None):
|
|
33
|
+
self.root = Path(root).expanduser().resolve()
|
|
34
|
+
self.ref = ref
|
|
35
|
+
if not self.root.is_dir():
|
|
36
|
+
raise ValueError(f"repository is not a directory: {self.root}")
|
|
37
|
+
if ref:
|
|
38
|
+
self._verify_ref(ref)
|
|
39
|
+
|
|
40
|
+
def iter_source_files(self) -> Iterator[SourceFile]:
|
|
41
|
+
if self.ref:
|
|
42
|
+
yield from self._iter_git_ref()
|
|
43
|
+
else:
|
|
44
|
+
yield from self._iter_worktree()
|
|
45
|
+
|
|
46
|
+
def source_signatures(self) -> list[SourceSignature]:
|
|
47
|
+
if self.ref:
|
|
48
|
+
raise ValueError("source signatures are only available for a working tree")
|
|
49
|
+
signatures = []
|
|
50
|
+
for path, relative in self._worktree_paths():
|
|
51
|
+
try:
|
|
52
|
+
stat = path.stat()
|
|
53
|
+
except OSError:
|
|
54
|
+
continue
|
|
55
|
+
if stat.st_size <= MAX_FILE_BYTES:
|
|
56
|
+
signatures.append(SourceSignature(relative, stat.st_size, stat.st_mtime_ns))
|
|
57
|
+
return signatures
|
|
58
|
+
|
|
59
|
+
def resolved_revision(self) -> str | None:
|
|
60
|
+
if not self.ref:
|
|
61
|
+
return None
|
|
62
|
+
return subprocess.run(
|
|
63
|
+
["git", "rev-parse", "--verify", f"{self.ref}^{{commit}}"],
|
|
64
|
+
cwd=self.root,
|
|
65
|
+
capture_output=True,
|
|
66
|
+
text=True,
|
|
67
|
+
check=True,
|
|
68
|
+
).stdout.strip()
|
|
69
|
+
|
|
70
|
+
def read_file(self, path: str) -> SourceFile:
|
|
71
|
+
if not _is_source(path):
|
|
72
|
+
raise ValueError(f"unsupported source file: {path}")
|
|
73
|
+
if self.ref:
|
|
74
|
+
result = subprocess.run(
|
|
75
|
+
["git", "show", f"{self.ref}:{path}"], cwd=self.root, capture_output=True, check=True
|
|
76
|
+
)
|
|
77
|
+
return SourceFile(path, _decode(result.stdout))
|
|
78
|
+
resolved = (self.root / path).resolve()
|
|
79
|
+
if not resolved.is_relative_to(self.root):
|
|
80
|
+
raise ValueError("path escapes repository")
|
|
81
|
+
return SourceFile(path, _decode(resolved.read_bytes()))
|
|
82
|
+
|
|
83
|
+
def _iter_worktree(self) -> Iterator[SourceFile]:
|
|
84
|
+
for path, relative in self._worktree_paths():
|
|
85
|
+
try:
|
|
86
|
+
if path.stat().st_size > MAX_FILE_BYTES:
|
|
87
|
+
continue
|
|
88
|
+
data = path.read_bytes()
|
|
89
|
+
except OSError:
|
|
90
|
+
continue
|
|
91
|
+
if b"\x00" not in data:
|
|
92
|
+
yield SourceFile(relative, _decode(data))
|
|
93
|
+
|
|
94
|
+
def _worktree_paths(self) -> Iterator[tuple[Path, str]]:
|
|
95
|
+
for current, dirs, files in os.walk(self.root):
|
|
96
|
+
dirs[:] = sorted(name for name in dirs if name not in SKIP_DIRS)
|
|
97
|
+
for name in sorted(files):
|
|
98
|
+
path = Path(current, name)
|
|
99
|
+
relative = path.relative_to(self.root).as_posix()
|
|
100
|
+
if not _is_source(relative):
|
|
101
|
+
continue
|
|
102
|
+
try:
|
|
103
|
+
if not path.resolve().is_relative_to(self.root):
|
|
104
|
+
continue
|
|
105
|
+
except OSError:
|
|
106
|
+
continue
|
|
107
|
+
yield path, relative
|
|
108
|
+
|
|
109
|
+
def _iter_git_ref(self) -> Iterator[SourceFile]:
|
|
110
|
+
listing = subprocess.run(
|
|
111
|
+
["git", "ls-tree", "-r", "-z", "--name-only", self.ref],
|
|
112
|
+
cwd=self.root,
|
|
113
|
+
capture_output=True,
|
|
114
|
+
check=True,
|
|
115
|
+
).stdout
|
|
116
|
+
paths = [path.decode("utf-8", "surrogateescape") for path in listing.split(b"\x00") if path]
|
|
117
|
+
paths = [path for path in paths if _is_source(path) and not _has_skipped_dir(path)]
|
|
118
|
+
process = subprocess.Popen(
|
|
119
|
+
["git", "cat-file", "--batch"],
|
|
120
|
+
cwd=self.root,
|
|
121
|
+
stdin=subprocess.PIPE,
|
|
122
|
+
stdout=subprocess.PIPE,
|
|
123
|
+
)
|
|
124
|
+
assert process.stdin is not None and process.stdout is not None
|
|
125
|
+
try:
|
|
126
|
+
for path in paths:
|
|
127
|
+
process.stdin.write(f"{self.ref}:{path}\n".encode())
|
|
128
|
+
process.stdin.flush()
|
|
129
|
+
header = process.stdout.readline().decode().strip().split()
|
|
130
|
+
if len(header) != 3 or header[1] != "blob":
|
|
131
|
+
continue
|
|
132
|
+
size = int(header[2])
|
|
133
|
+
data = process.stdout.read(size)
|
|
134
|
+
process.stdout.read(1)
|
|
135
|
+
if size <= MAX_FILE_BYTES and b"\x00" not in data:
|
|
136
|
+
yield SourceFile(path, _decode(data))
|
|
137
|
+
finally:
|
|
138
|
+
process.stdin.close()
|
|
139
|
+
process.stdout.close()
|
|
140
|
+
process.wait(timeout=10)
|
|
141
|
+
|
|
142
|
+
def _verify_ref(self, ref: str) -> None:
|
|
143
|
+
result = subprocess.run(
|
|
144
|
+
["git", "rev-parse", "--verify", f"{ref}^{{commit}}"],
|
|
145
|
+
cwd=self.root,
|
|
146
|
+
capture_output=True,
|
|
147
|
+
)
|
|
148
|
+
if result.returncode:
|
|
149
|
+
raise ValueError(f"unknown git revision: {ref}")
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _is_source(path: str) -> bool:
|
|
153
|
+
return Path(path).suffix.lower() in SOURCE_SUFFIXES
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _has_skipped_dir(path: str) -> bool:
|
|
157
|
+
return any(part in SKIP_DIRS for part in Path(path).parts[:-1])
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _decode(data: bytes) -> str:
|
|
161
|
+
return data.decode("utf-8", errors="replace")
|