@allansantos-dev/smart-tool 0.9.0 → 0.9.2
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.
- package/CHANGELOG.md +50 -23
- package/NOTICE +32 -32
- package/README.md +117 -116
- package/atomic_io.py +94 -94
- package/bin/smart-tool.mjs +5 -1
- package/client_hooks.py +192 -192
- package/code_graph_java.mjs +49 -49
- package/code_graph_js.cjs +135 -135
- package/duplicates.py +313 -313
- package/embedding_cache.py +99 -99
- package/endpoint_sync.py +179 -179
- package/gateway.py +220 -220
- package/hook_decision.py +96 -96
- package/index_inventory.py +75 -75
- package/indexer.py +878 -878
- package/install.py +113 -11
- package/install_runtime.py +101 -35
- package/integration_metrics.py +82 -82
- package/local_embedder.py +172 -172
- package/model_defaults.py +70 -70
- package/package.json +35 -35
- package/project_insights_ui.py +38 -38
- package/project_monitor.py +204 -204
- package/project_store.py +220 -220
- package/projects_ui.py +146 -146
- package/requirements.txt +8 -8
- package/research_cache.py +171 -171
- package/research_metrics.py +110 -110
- package/router.py +582 -582
- package/secret_store.py +121 -121
- package/setup_ui.py +1011 -986
- package/smart_tool_daemon.py +3604 -3604
- package/version.py +1 -1
- package/web_adapters/browser/fetch_camoufox.py +69 -0
- package/web_adapters/node/ddg-search.mjs +89 -89
- package/web_adapters/node/open-websearch-adapter.mjs +77 -77
- package/web_adapters/node/package.json +12 -12
- package/web_adapters/node/wikipedia-search.mjs +44 -44
- package/web_search_adapters.py +193 -193
- package/web_search_external.py +288 -288
- package/web_search_health.py +88 -88
package/duplicates.py
CHANGED
|
@@ -1,313 +1,313 @@
|
|
|
1
|
-
import array
|
|
2
|
-
import contextvars
|
|
3
|
-
import hashlib
|
|
4
|
-
import heapq
|
|
5
|
-
import math
|
|
6
|
-
import os
|
|
7
|
-
import re
|
|
8
|
-
import threading
|
|
9
|
-
import time
|
|
10
|
-
|
|
11
|
-
import code_graph
|
|
12
|
-
import embedding_cache
|
|
13
|
-
import index_inventory
|
|
14
|
-
import index_profile
|
|
15
|
-
import index_scope
|
|
16
|
-
import indexer
|
|
17
|
-
|
|
18
|
-
DEFAULT_MIN_SIMILARITY = 0.90
|
|
19
|
-
NEAR_JACCARD = 0.80
|
|
20
|
-
MIN_LINES = 5
|
|
21
|
-
MAX_TEXT = 3000
|
|
22
|
-
MAX_SEMANTIC_FUNCTIONS = 4000
|
|
23
|
-
SEMANTIC_DEADLINE_S = 45
|
|
24
|
-
EMBED_SLICE = 64
|
|
25
|
-
CUTOFF_NOTE = ("Default semantic cutoff 0.90: in the 2026-10-02 measurement (claude-code, independent judge) pairs above "
|
|
26
|
-
"0.95 were 10/10 actionable and those from 0.80 to 0.94 only 7/20; below 0.90 most are false positives "
|
|
27
|
-
"and reviewing them costs time without leading to a refactor.")
|
|
28
|
-
_HASH_COMMENT = {"py", "rb", "sh"}
|
|
29
|
-
_TOKEN = re.compile(r"[A-Za-z_]\w*|\d+|[^\s\w]")
|
|
30
|
-
_CONTAINER_KINDS = {"module", "template", "class", "interface"}
|
|
31
|
-
_GENERIC_NAMES = {"callback", "anonymous", "constructor"}
|
|
32
|
-
_WARMING = set()
|
|
33
|
-
_WARM_ERRORS = {}
|
|
34
|
-
LAST_FINDINGS = {}
|
|
35
|
-
MAX_REMEMBERED_FINDINGS = 20000
|
|
36
|
-
NOTE_MAX = 500
|
|
37
|
-
TYPE_BY_PREFIX = {"e": "exact", "n": "near", "s": "semantic"}
|
|
38
|
-
DISMISS_REASONS = {"false_positive", "intentional"}
|
|
39
|
-
_LOCK = threading.Lock()
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
def _strip_comments(text, ext):
|
|
43
|
-
hash_comments = ext in _HASH_COMMENT
|
|
44
|
-
out, i, n = [], 0, len(text)
|
|
45
|
-
while i < n:
|
|
46
|
-
ch = text[i]
|
|
47
|
-
if ch in "'\"`":
|
|
48
|
-
j = i + 1
|
|
49
|
-
while j < n and text[j] != ch:
|
|
50
|
-
j += 2 if text[j] == "\\" else 1
|
|
51
|
-
out.append(text[i:j + 1]); i = j + 1
|
|
52
|
-
elif hash_comments and ch == "#":
|
|
53
|
-
while i < n and text[i] != "\n":
|
|
54
|
-
i += 1
|
|
55
|
-
elif not hash_comments and text.startswith("//", i):
|
|
56
|
-
while i < n and text[i] != "\n":
|
|
57
|
-
i += 1
|
|
58
|
-
elif not hash_comments and text.startswith("/*", i):
|
|
59
|
-
end = text.find("*/", i + 2)
|
|
60
|
-
i = n if end < 0 else end + 2
|
|
61
|
-
else:
|
|
62
|
-
out.append(ch); i += 1
|
|
63
|
-
return "".join(out)
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
def _body(text, ext):
|
|
67
|
-
if ext in _HASH_COMMENT:
|
|
68
|
-
lines = text.splitlines()
|
|
69
|
-
start = next((k for k, line in enumerate(lines) if re.match(r"\s*(async\s+)?def\s", line)), 0)
|
|
70
|
-
end = next((k for k in range(start, len(lines)) if lines[k].rstrip().endswith(":")), start)
|
|
71
|
-
return "\n".join(lines[end + 1:])
|
|
72
|
-
brace = text.find("{")
|
|
73
|
-
return text[brace + 1:] if brace >= 0 else text
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
def _shingles(text):
|
|
77
|
-
tokens = ["ID" if re.match(r"[A-Za-z_]", t) else t for t in _TOKEN.findall(text)]
|
|
78
|
-
return frozenset(tuple(tokens[i:i + 5]) for i in range(max(len(tokens) - 4, 1)))
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
def _index_state(path):
|
|
82
|
-
stat = os.stat(path)
|
|
83
|
-
return stat.st_mtime_ns, stat.st_size
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
def _functions(root, include_tests):
|
|
87
|
-
inventory = index_inventory.inspect(root)
|
|
88
|
-
if not inventory["selected"]:
|
|
89
|
-
raise ValueError("Project not indexed in this view yet; run a search or index it first.")
|
|
90
|
-
view = inventory["selected"]
|
|
91
|
-
before = _index_state(view["path"])
|
|
92
|
-
graph = code_graph.build(root, storage_id=os.path.basename(view["path"]))
|
|
93
|
-
_files, sources, _diagnostics, _truncated = code_graph._snapshot(view["path"])
|
|
94
|
-
if _index_state(view["path"]) != before:
|
|
95
|
-
raise RuntimeError("The index changed during the analysis (reindex in progress); try again shortly.")
|
|
96
|
-
profile = index_profile.current((index_scope.load_scope(root) or {}).get("profile"))
|
|
97
|
-
candidates = [s for s in graph["symbols"] if s.get("kind") not in _CONTAINER_KINDS
|
|
98
|
-
and s["end_line"] - s["start_line"] + 1 >= MIN_LINES
|
|
99
|
-
and s["name"].split("(")[0].split(".")[-1] not in _GENERIC_NAMES and "@" not in s["name"]]
|
|
100
|
-
by_file = {}
|
|
101
|
-
for s in candidates:
|
|
102
|
-
by_file.setdefault(s["path"], []).append(s)
|
|
103
|
-
functions = []
|
|
104
|
-
for path, symbols in by_file.items():
|
|
105
|
-
kind = index_profile.kind(path, profile)
|
|
106
|
-
if kind == "doc" or (kind == "test" and not include_tests):
|
|
107
|
-
continue
|
|
108
|
-
ext = path.rsplit(".", 1)[-1].lower() if "." in path else ""
|
|
109
|
-
file_lines = sources.get(path, "").splitlines()
|
|
110
|
-
occurrences = {}
|
|
111
|
-
for s in sorted(symbols, key=lambda item: (item["start_line"], item["end_line"])):
|
|
112
|
-
if any(o is not s and o["start_line"] <= s["start_line"] and s["end_line"] <= o["end_line"]
|
|
113
|
-
and (o["start_line"], o["end_line"]) != (s["start_line"], s["end_line"]) for o in symbols):
|
|
114
|
-
continue
|
|
115
|
-
text = "\n".join(file_lines[s["start_line"] - 1:s["end_line"]])
|
|
116
|
-
if not text.strip():
|
|
117
|
-
continue
|
|
118
|
-
normalized = re.sub(r"\s+", " ", _strip_comments(_body(text, ext), ext)).strip()
|
|
119
|
-
functions.append({"name": s["name"].split("(")[0].split(".")[-1], "path": path, "line": s["start_line"],
|
|
120
|
-
"end": s["end_line"], "lines": s["end_line"] - s["start_line"] + 1, "text": text[:MAX_TEXT],
|
|
121
|
-
"hash": hashlib.sha1(normalized.encode("utf-8")).hexdigest(), "shingles": _shingles(normalized)})
|
|
122
|
-
occurrence = occurrences[functions[-1]["hash"]] = occurrences.get(functions[-1]["hash"], 0) + 1
|
|
123
|
-
functions[-1]["kind"] = kind
|
|
124
|
-
functions[-1]["fp"] = hashlib.sha1(f"{path}\0{functions[-1]['hash']}\0{occurrence}".encode("utf-8")).hexdigest()
|
|
125
|
-
notes = []
|
|
126
|
-
if inventory.get("message"):
|
|
127
|
-
notes.append(inventory["message"])
|
|
128
|
-
if graph.get("truncated"):
|
|
129
|
-
notes.append("Graph truncated by analysis limits; some functions were left out.")
|
|
130
|
-
if graph.get("retryable"):
|
|
131
|
-
notes.append("Partial analysis: " + "; ".join(d.get("reason", "") for d in graph.get("diagnostics", [])[:2]))
|
|
132
|
-
return functions, notes, view
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
def _ref(f):
|
|
136
|
-
return {"name": f["name"], "path": f["path"], "lines": f"{f['line']}-{f['end']}"}
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
def _finding_id(prefix, members):
|
|
140
|
-
production = [f for f in members if f["kind"] != "test"]
|
|
141
|
-
basis = production if len(production) >= 2 else members
|
|
142
|
-
return prefix + hashlib.sha1("|".join(sorted(f["fp"] for f in basis)).encode("utf-8")).hexdigest()[:16]
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
def _cache(root, view):
|
|
146
|
-
if not view.get("model") or not view.get("dimensions"):
|
|
147
|
-
raise ValueError("Index has no registered embedding model; reindex the project.")
|
|
148
|
-
return embedding_cache.VectorCache(root, indexer.INDEX_DIR, {"purpose": "function_duplicates", "model": view["model"],
|
|
149
|
-
"dimensions": view["dimensions"]})
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
def _cached_vectors(root, view, functions):
|
|
153
|
-
cache = _cache(root, view)
|
|
154
|
-
try:
|
|
155
|
-
vectors, missing = [], 0
|
|
156
|
-
for f in functions:
|
|
157
|
-
row = cache.db.execute("SELECT vector FROM vectors WHERE key=?", (cache.key(f["text"]),)).fetchone()
|
|
158
|
-
if row is None:
|
|
159
|
-
missing += 1
|
|
160
|
-
continue
|
|
161
|
-
vector = array.array("f"); vector.frombytes(row[0])
|
|
162
|
-
norm = math.sqrt(indexer._sumprod(vector, vector))
|
|
163
|
-
vectors.append(array.array("f", (x / norm for x in vector)) if norm else None)
|
|
164
|
-
return (vectors if not missing else None), missing
|
|
165
|
-
finally:
|
|
166
|
-
cache.close()
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
def _warm(root, view, functions, embed):
|
|
170
|
-
try:
|
|
171
|
-
cache = _cache(root, view)
|
|
172
|
-
try:
|
|
173
|
-
for start in range(0, len(functions), EMBED_SLICE):
|
|
174
|
-
cache.embed([f["text"] for f in functions[start:start + EMBED_SLICE]],
|
|
175
|
-
lambda texts: embed(view["model"], texts), indexer.validate_vector)
|
|
176
|
-
finally:
|
|
177
|
-
cache.close()
|
|
178
|
-
except Exception as exc:
|
|
179
|
-
with _LOCK:
|
|
180
|
-
_WARM_ERRORS[root] = f"{type(exc).__name__}: {exc}"[:300]
|
|
181
|
-
finally:
|
|
182
|
-
with _LOCK:
|
|
183
|
-
_WARMING.discard(root)
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
def _near(functions):
|
|
187
|
-
order = sorted(range(len(functions)), key=lambda i: len(functions[i]["shingles"]))
|
|
188
|
-
found = []
|
|
189
|
-
for position, a in enumerate(order):
|
|
190
|
-
sa = functions[a]["shingles"]
|
|
191
|
-
for b in order[position + 1:]:
|
|
192
|
-
sb = functions[b]["shingles"]
|
|
193
|
-
if len(sb) * NEAR_JACCARD > len(sa):
|
|
194
|
-
break
|
|
195
|
-
if functions[a]["hash"] == functions[b]["hash"]:
|
|
196
|
-
continue
|
|
197
|
-
inter = len(sa & sb)
|
|
198
|
-
score = inter / (len(sa) + len(sb) - inter)
|
|
199
|
-
if score >= NEAR_JACCARD:
|
|
200
|
-
found.append((score, a, b))
|
|
201
|
-
found.sort(reverse=True)
|
|
202
|
-
return found
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
def _semantic(root, view, functions, embed, configured_model, min_similarity, limit, lexical, skip):
|
|
206
|
-
if embed is None or not configured_model:
|
|
207
|
-
return "unavailable", [], 0, ["Semantic level unavailable: no embedding_model configured."]
|
|
208
|
-
if configured_model != view.get("model"):
|
|
209
|
-
return "unavailable", [], 0, [f"Semantic level unavailable: the configured model ({configured_model}) differs from the "
|
|
210
|
-
f"index model ({view.get('model')}); reindex the project to compare vectors from the same model."]
|
|
211
|
-
pool = sorted(range(len(functions)), key=lambda i: -functions[i]["lines"])[:MAX_SEMANTIC_FUNCTIONS]
|
|
212
|
-
notes = []
|
|
213
|
-
if len(functions) > MAX_SEMANTIC_FUNCTIONS:
|
|
214
|
-
notes.append(f"Semantic level limited to the {MAX_SEMANTIC_FUNCTIONS} largest of {len(functions)} functions.")
|
|
215
|
-
chosen = [functions[i] for i in pool]
|
|
216
|
-
with _LOCK:
|
|
217
|
-
error = _WARM_ERRORS.pop(root, None)
|
|
218
|
-
vectors, missing = _cached_vectors(root, view, chosen)
|
|
219
|
-
if vectors is None:
|
|
220
|
-
with _LOCK:
|
|
221
|
-
start = root not in _WARMING
|
|
222
|
-
if start:
|
|
223
|
-
_WARMING.add(root)
|
|
224
|
-
if start:
|
|
225
|
-
context = contextvars.copy_context()
|
|
226
|
-
threading.Thread(target=context.run, args=(_warm, root, view, chosen, embed), daemon=True).start()
|
|
227
|
-
pending = f"{missing} functions without embedding" if missing else "preparation in progress"
|
|
228
|
-
notes.append(f"Semantic level being prepared ({pending}); available on the next call."
|
|
229
|
-
+ (f" The last preparation failed: {error}" if error else ""))
|
|
230
|
-
return "preparing", [], 0, notes
|
|
231
|
-
heap, total, deadline = [], 0, time.monotonic() + SEMANTIC_DEADLINE_S
|
|
232
|
-
for a in range(len(chosen)):
|
|
233
|
-
if time.monotonic() > deadline:
|
|
234
|
-
notes.append(f"Semantic comparison stopped at the {SEMANTIC_DEADLINE_S} s deadline; partial result.")
|
|
235
|
-
break
|
|
236
|
-
va, fa = vectors[a], chosen[a]
|
|
237
|
-
if va is None:
|
|
238
|
-
continue
|
|
239
|
-
for b in range(a + 1, len(chosen)):
|
|
240
|
-
vb, fb = vectors[b], chosen[b]
|
|
241
|
-
if vb is None or fa["hash"] == fb["hash"] or (pool[a], pool[b]) in lexical or (pool[b], pool[a]) in lexical:
|
|
242
|
-
continue
|
|
243
|
-
score = indexer._sumprod(va, vb)
|
|
244
|
-
if score >= min_similarity:
|
|
245
|
-
if _finding_id("s", (fa, fb)) in skip:
|
|
246
|
-
skip[_finding_id("s", (fa, fb))] = (score, fa, fb)
|
|
247
|
-
continue
|
|
248
|
-
total += 1
|
|
249
|
-
item = (score, pool[a], pool[b])
|
|
250
|
-
if len(heap) < limit:
|
|
251
|
-
heapq.heappush(heap, item)
|
|
252
|
-
elif item > heap[0]:
|
|
253
|
-
heapq.heapreplace(heap, item)
|
|
254
|
-
return "ready", sorted(heap, reverse=True), total, notes
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
def find(root, embed, configured_model, min_similarity=DEFAULT_MIN_SIMILARITY, include_tests=False, limit=30,
|
|
258
|
-
dismissed=None, include_dismissed=False):
|
|
259
|
-
started = time.monotonic()
|
|
260
|
-
dismissed = dismissed or {}
|
|
261
|
-
functions, notes, view = _functions(root, include_tests)
|
|
262
|
-
groups = {}
|
|
263
|
-
for f in functions:
|
|
264
|
-
groups.setdefault(f["hash"], []).append(f)
|
|
265
|
-
exact_all = sorted((g for g in groups.values() if len(g) > 1), key=lambda g: (-g[0]["lines"], -len(g)))
|
|
266
|
-
near_all = _near(functions)
|
|
267
|
-
lexical = {(a, b) for _s, a, b in near_all}
|
|
268
|
-
hidden = []
|
|
269
|
-
exact = []
|
|
270
|
-
for g in exact_all:
|
|
271
|
-
fid = _finding_id("e", g)
|
|
272
|
-
(hidden.append(("exact", fid, None, g)) if fid in dismissed else exact.append((fid, g)))
|
|
273
|
-
near = []
|
|
274
|
-
for s, a, b in near_all:
|
|
275
|
-
fid = _finding_id("n", (functions[a], functions[b]))
|
|
276
|
-
(hidden.append(("near", fid, s, (functions[a], functions[b]))) if fid in dismissed else near.append((fid, s, a, b)))
|
|
277
|
-
skip = {fid: None for fid in dismissed if fid.startswith("s")}
|
|
278
|
-
try:
|
|
279
|
-
status, semantic, semantic_total, semantic_notes = _semantic(root, view, functions, embed, configured_model,
|
|
280
|
-
min_similarity, limit, lexical, skip)
|
|
281
|
-
except Exception as exc:
|
|
282
|
-
status, semantic, semantic_total, semantic_notes = "error", [], 0, [f"Semantic level failed: {type(exc).__name__}: {exc}"[:300]]
|
|
283
|
-
notes += semantic_notes
|
|
284
|
-
if min_similarity < DEFAULT_MIN_SIMILARITY:
|
|
285
|
-
notes.append(f"Semantic cutoff below the default ({min_similarity:.2f}): expect many false positives.")
|
|
286
|
-
semantic_rows = [(_finding_id("s", (functions[a], functions[b])), s, a, b) for s, a, b in semantic]
|
|
287
|
-
hidden_semantic = [fid for fid, score in skip.items() if score is not None]
|
|
288
|
-
with _LOCK:
|
|
289
|
-
remembered = LAST_FINDINGS.setdefault(root, {})
|
|
290
|
-
remembered.update({fid: "exact" for fid, _g in exact})
|
|
291
|
-
remembered.update({fid: "near" for fid, *_r in near})
|
|
292
|
-
remembered.update({fid: "semantic" for fid, *_r in semantic_rows})
|
|
293
|
-
remembered.update({fid: kind for kind, fid, *_r in hidden})
|
|
294
|
-
remembered.update({fid: "semantic" for fid in hidden_semantic})
|
|
295
|
-
while len(remembered) > MAX_REMEMBERED_FINDINGS:
|
|
296
|
-
remembered.pop(next(iter(remembered)))
|
|
297
|
-
result = {
|
|
298
|
-
"view": view["label"], "functions_analyzed": len(functions), "include_tests": include_tests,
|
|
299
|
-
"min_similarity": min_similarity, "cutoff_note": CUTOFF_NOTE, "semantic_status": status, "notes": notes,
|
|
300
|
-
"counts": {"exact_groups": len(exact), "near_pairs": len(near), "semantic_pairs": semantic_total,
|
|
301
|
-
"dismissed_hidden": len(hidden) + len(hidden_semantic)},
|
|
302
|
-
"exact": [{"id": fid, "lines": g[0]["lines"], "copies": [_ref(f) for f in g]} for fid, g in exact[:limit]],
|
|
303
|
-
"near": [{"id": fid, "score": round(s, 3), "a": _ref(functions[a]), "b": _ref(functions[b])} for fid, s, a, b in near[:limit]],
|
|
304
|
-
"semantic": [{"id": fid, "score": round(s, 3), "a": _ref(functions[a]), "b": _ref(functions[b])} for fid, s, a, b in semantic_rows],
|
|
305
|
-
"elapsed_s": round(time.monotonic() - started, 1),
|
|
306
|
-
}
|
|
307
|
-
if include_dismissed:
|
|
308
|
-
result["dismissed"] = ([{"id": fid, "type": kind, **({"score": round(s, 3)} if s is not None else {}),
|
|
309
|
-
"members": [_ref(f) for f in members], **dismissed[fid]} for kind, fid, s, members in hidden]
|
|
310
|
-
+ [{"id": fid, "type": "semantic", "score": round(skip[fid][0], 3),
|
|
311
|
-
"members": [_ref(skip[fid][1]), _ref(skip[fid][2])], **dismissed[fid]}
|
|
312
|
-
for fid in hidden_semantic])
|
|
313
|
-
return result
|
|
1
|
+
import array
|
|
2
|
+
import contextvars
|
|
3
|
+
import hashlib
|
|
4
|
+
import heapq
|
|
5
|
+
import math
|
|
6
|
+
import os
|
|
7
|
+
import re
|
|
8
|
+
import threading
|
|
9
|
+
import time
|
|
10
|
+
|
|
11
|
+
import code_graph
|
|
12
|
+
import embedding_cache
|
|
13
|
+
import index_inventory
|
|
14
|
+
import index_profile
|
|
15
|
+
import index_scope
|
|
16
|
+
import indexer
|
|
17
|
+
|
|
18
|
+
DEFAULT_MIN_SIMILARITY = 0.90
|
|
19
|
+
NEAR_JACCARD = 0.80
|
|
20
|
+
MIN_LINES = 5
|
|
21
|
+
MAX_TEXT = 3000
|
|
22
|
+
MAX_SEMANTIC_FUNCTIONS = 4000
|
|
23
|
+
SEMANTIC_DEADLINE_S = 45
|
|
24
|
+
EMBED_SLICE = 64
|
|
25
|
+
CUTOFF_NOTE = ("Default semantic cutoff 0.90: in the 2026-10-02 measurement (claude-code, independent judge) pairs above "
|
|
26
|
+
"0.95 were 10/10 actionable and those from 0.80 to 0.94 only 7/20; below 0.90 most are false positives "
|
|
27
|
+
"and reviewing them costs time without leading to a refactor.")
|
|
28
|
+
_HASH_COMMENT = {"py", "rb", "sh"}
|
|
29
|
+
_TOKEN = re.compile(r"[A-Za-z_]\w*|\d+|[^\s\w]")
|
|
30
|
+
_CONTAINER_KINDS = {"module", "template", "class", "interface"}
|
|
31
|
+
_GENERIC_NAMES = {"callback", "anonymous", "constructor"}
|
|
32
|
+
_WARMING = set()
|
|
33
|
+
_WARM_ERRORS = {}
|
|
34
|
+
LAST_FINDINGS = {}
|
|
35
|
+
MAX_REMEMBERED_FINDINGS = 20000
|
|
36
|
+
NOTE_MAX = 500
|
|
37
|
+
TYPE_BY_PREFIX = {"e": "exact", "n": "near", "s": "semantic"}
|
|
38
|
+
DISMISS_REASONS = {"false_positive", "intentional"}
|
|
39
|
+
_LOCK = threading.Lock()
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _strip_comments(text, ext):
|
|
43
|
+
hash_comments = ext in _HASH_COMMENT
|
|
44
|
+
out, i, n = [], 0, len(text)
|
|
45
|
+
while i < n:
|
|
46
|
+
ch = text[i]
|
|
47
|
+
if ch in "'\"`":
|
|
48
|
+
j = i + 1
|
|
49
|
+
while j < n and text[j] != ch:
|
|
50
|
+
j += 2 if text[j] == "\\" else 1
|
|
51
|
+
out.append(text[i:j + 1]); i = j + 1
|
|
52
|
+
elif hash_comments and ch == "#":
|
|
53
|
+
while i < n and text[i] != "\n":
|
|
54
|
+
i += 1
|
|
55
|
+
elif not hash_comments and text.startswith("//", i):
|
|
56
|
+
while i < n and text[i] != "\n":
|
|
57
|
+
i += 1
|
|
58
|
+
elif not hash_comments and text.startswith("/*", i):
|
|
59
|
+
end = text.find("*/", i + 2)
|
|
60
|
+
i = n if end < 0 else end + 2
|
|
61
|
+
else:
|
|
62
|
+
out.append(ch); i += 1
|
|
63
|
+
return "".join(out)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _body(text, ext):
|
|
67
|
+
if ext in _HASH_COMMENT:
|
|
68
|
+
lines = text.splitlines()
|
|
69
|
+
start = next((k for k, line in enumerate(lines) if re.match(r"\s*(async\s+)?def\s", line)), 0)
|
|
70
|
+
end = next((k for k in range(start, len(lines)) if lines[k].rstrip().endswith(":")), start)
|
|
71
|
+
return "\n".join(lines[end + 1:])
|
|
72
|
+
brace = text.find("{")
|
|
73
|
+
return text[brace + 1:] if brace >= 0 else text
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _shingles(text):
|
|
77
|
+
tokens = ["ID" if re.match(r"[A-Za-z_]", t) else t for t in _TOKEN.findall(text)]
|
|
78
|
+
return frozenset(tuple(tokens[i:i + 5]) for i in range(max(len(tokens) - 4, 1)))
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _index_state(path):
|
|
82
|
+
stat = os.stat(path)
|
|
83
|
+
return stat.st_mtime_ns, stat.st_size
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _functions(root, include_tests):
|
|
87
|
+
inventory = index_inventory.inspect(root)
|
|
88
|
+
if not inventory["selected"]:
|
|
89
|
+
raise ValueError("Project not indexed in this view yet; run a search or index it first.")
|
|
90
|
+
view = inventory["selected"]
|
|
91
|
+
before = _index_state(view["path"])
|
|
92
|
+
graph = code_graph.build(root, storage_id=os.path.basename(view["path"]))
|
|
93
|
+
_files, sources, _diagnostics, _truncated = code_graph._snapshot(view["path"])
|
|
94
|
+
if _index_state(view["path"]) != before:
|
|
95
|
+
raise RuntimeError("The index changed during the analysis (reindex in progress); try again shortly.")
|
|
96
|
+
profile = index_profile.current((index_scope.load_scope(root) or {}).get("profile"))
|
|
97
|
+
candidates = [s for s in graph["symbols"] if s.get("kind") not in _CONTAINER_KINDS
|
|
98
|
+
and s["end_line"] - s["start_line"] + 1 >= MIN_LINES
|
|
99
|
+
and s["name"].split("(")[0].split(".")[-1] not in _GENERIC_NAMES and "@" not in s["name"]]
|
|
100
|
+
by_file = {}
|
|
101
|
+
for s in candidates:
|
|
102
|
+
by_file.setdefault(s["path"], []).append(s)
|
|
103
|
+
functions = []
|
|
104
|
+
for path, symbols in by_file.items():
|
|
105
|
+
kind = index_profile.kind(path, profile)
|
|
106
|
+
if kind == "doc" or (kind == "test" and not include_tests):
|
|
107
|
+
continue
|
|
108
|
+
ext = path.rsplit(".", 1)[-1].lower() if "." in path else ""
|
|
109
|
+
file_lines = sources.get(path, "").splitlines()
|
|
110
|
+
occurrences = {}
|
|
111
|
+
for s in sorted(symbols, key=lambda item: (item["start_line"], item["end_line"])):
|
|
112
|
+
if any(o is not s and o["start_line"] <= s["start_line"] and s["end_line"] <= o["end_line"]
|
|
113
|
+
and (o["start_line"], o["end_line"]) != (s["start_line"], s["end_line"]) for o in symbols):
|
|
114
|
+
continue
|
|
115
|
+
text = "\n".join(file_lines[s["start_line"] - 1:s["end_line"]])
|
|
116
|
+
if not text.strip():
|
|
117
|
+
continue
|
|
118
|
+
normalized = re.sub(r"\s+", " ", _strip_comments(_body(text, ext), ext)).strip()
|
|
119
|
+
functions.append({"name": s["name"].split("(")[0].split(".")[-1], "path": path, "line": s["start_line"],
|
|
120
|
+
"end": s["end_line"], "lines": s["end_line"] - s["start_line"] + 1, "text": text[:MAX_TEXT],
|
|
121
|
+
"hash": hashlib.sha1(normalized.encode("utf-8")).hexdigest(), "shingles": _shingles(normalized)})
|
|
122
|
+
occurrence = occurrences[functions[-1]["hash"]] = occurrences.get(functions[-1]["hash"], 0) + 1
|
|
123
|
+
functions[-1]["kind"] = kind
|
|
124
|
+
functions[-1]["fp"] = hashlib.sha1(f"{path}\0{functions[-1]['hash']}\0{occurrence}".encode("utf-8")).hexdigest()
|
|
125
|
+
notes = []
|
|
126
|
+
if inventory.get("message"):
|
|
127
|
+
notes.append(inventory["message"])
|
|
128
|
+
if graph.get("truncated"):
|
|
129
|
+
notes.append("Graph truncated by analysis limits; some functions were left out.")
|
|
130
|
+
if graph.get("retryable"):
|
|
131
|
+
notes.append("Partial analysis: " + "; ".join(d.get("reason", "") for d in graph.get("diagnostics", [])[:2]))
|
|
132
|
+
return functions, notes, view
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _ref(f):
|
|
136
|
+
return {"name": f["name"], "path": f["path"], "lines": f"{f['line']}-{f['end']}"}
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _finding_id(prefix, members):
|
|
140
|
+
production = [f for f in members if f["kind"] != "test"]
|
|
141
|
+
basis = production if len(production) >= 2 else members
|
|
142
|
+
return prefix + hashlib.sha1("|".join(sorted(f["fp"] for f in basis)).encode("utf-8")).hexdigest()[:16]
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _cache(root, view):
|
|
146
|
+
if not view.get("model") or not view.get("dimensions"):
|
|
147
|
+
raise ValueError("Index has no registered embedding model; reindex the project.")
|
|
148
|
+
return embedding_cache.VectorCache(root, indexer.INDEX_DIR, {"purpose": "function_duplicates", "model": view["model"],
|
|
149
|
+
"dimensions": view["dimensions"]})
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _cached_vectors(root, view, functions):
|
|
153
|
+
cache = _cache(root, view)
|
|
154
|
+
try:
|
|
155
|
+
vectors, missing = [], 0
|
|
156
|
+
for f in functions:
|
|
157
|
+
row = cache.db.execute("SELECT vector FROM vectors WHERE key=?", (cache.key(f["text"]),)).fetchone()
|
|
158
|
+
if row is None:
|
|
159
|
+
missing += 1
|
|
160
|
+
continue
|
|
161
|
+
vector = array.array("f"); vector.frombytes(row[0])
|
|
162
|
+
norm = math.sqrt(indexer._sumprod(vector, vector))
|
|
163
|
+
vectors.append(array.array("f", (x / norm for x in vector)) if norm else None)
|
|
164
|
+
return (vectors if not missing else None), missing
|
|
165
|
+
finally:
|
|
166
|
+
cache.close()
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _warm(root, view, functions, embed):
|
|
170
|
+
try:
|
|
171
|
+
cache = _cache(root, view)
|
|
172
|
+
try:
|
|
173
|
+
for start in range(0, len(functions), EMBED_SLICE):
|
|
174
|
+
cache.embed([f["text"] for f in functions[start:start + EMBED_SLICE]],
|
|
175
|
+
lambda texts: embed(view["model"], texts), indexer.validate_vector)
|
|
176
|
+
finally:
|
|
177
|
+
cache.close()
|
|
178
|
+
except Exception as exc:
|
|
179
|
+
with _LOCK:
|
|
180
|
+
_WARM_ERRORS[root] = f"{type(exc).__name__}: {exc}"[:300]
|
|
181
|
+
finally:
|
|
182
|
+
with _LOCK:
|
|
183
|
+
_WARMING.discard(root)
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _near(functions):
|
|
187
|
+
order = sorted(range(len(functions)), key=lambda i: len(functions[i]["shingles"]))
|
|
188
|
+
found = []
|
|
189
|
+
for position, a in enumerate(order):
|
|
190
|
+
sa = functions[a]["shingles"]
|
|
191
|
+
for b in order[position + 1:]:
|
|
192
|
+
sb = functions[b]["shingles"]
|
|
193
|
+
if len(sb) * NEAR_JACCARD > len(sa):
|
|
194
|
+
break
|
|
195
|
+
if functions[a]["hash"] == functions[b]["hash"]:
|
|
196
|
+
continue
|
|
197
|
+
inter = len(sa & sb)
|
|
198
|
+
score = inter / (len(sa) + len(sb) - inter)
|
|
199
|
+
if score >= NEAR_JACCARD:
|
|
200
|
+
found.append((score, a, b))
|
|
201
|
+
found.sort(reverse=True)
|
|
202
|
+
return found
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def _semantic(root, view, functions, embed, configured_model, min_similarity, limit, lexical, skip):
|
|
206
|
+
if embed is None or not configured_model:
|
|
207
|
+
return "unavailable", [], 0, ["Semantic level unavailable: no embedding_model configured."]
|
|
208
|
+
if configured_model != view.get("model"):
|
|
209
|
+
return "unavailable", [], 0, [f"Semantic level unavailable: the configured model ({configured_model}) differs from the "
|
|
210
|
+
f"index model ({view.get('model')}); reindex the project to compare vectors from the same model."]
|
|
211
|
+
pool = sorted(range(len(functions)), key=lambda i: -functions[i]["lines"])[:MAX_SEMANTIC_FUNCTIONS]
|
|
212
|
+
notes = []
|
|
213
|
+
if len(functions) > MAX_SEMANTIC_FUNCTIONS:
|
|
214
|
+
notes.append(f"Semantic level limited to the {MAX_SEMANTIC_FUNCTIONS} largest of {len(functions)} functions.")
|
|
215
|
+
chosen = [functions[i] for i in pool]
|
|
216
|
+
with _LOCK:
|
|
217
|
+
error = _WARM_ERRORS.pop(root, None)
|
|
218
|
+
vectors, missing = _cached_vectors(root, view, chosen)
|
|
219
|
+
if vectors is None:
|
|
220
|
+
with _LOCK:
|
|
221
|
+
start = root not in _WARMING
|
|
222
|
+
if start:
|
|
223
|
+
_WARMING.add(root)
|
|
224
|
+
if start:
|
|
225
|
+
context = contextvars.copy_context()
|
|
226
|
+
threading.Thread(target=context.run, args=(_warm, root, view, chosen, embed), daemon=True).start()
|
|
227
|
+
pending = f"{missing} functions without embedding" if missing else "preparation in progress"
|
|
228
|
+
notes.append(f"Semantic level being prepared ({pending}); available on the next call."
|
|
229
|
+
+ (f" The last preparation failed: {error}" if error else ""))
|
|
230
|
+
return "preparing", [], 0, notes
|
|
231
|
+
heap, total, deadline = [], 0, time.monotonic() + SEMANTIC_DEADLINE_S
|
|
232
|
+
for a in range(len(chosen)):
|
|
233
|
+
if time.monotonic() > deadline:
|
|
234
|
+
notes.append(f"Semantic comparison stopped at the {SEMANTIC_DEADLINE_S} s deadline; partial result.")
|
|
235
|
+
break
|
|
236
|
+
va, fa = vectors[a], chosen[a]
|
|
237
|
+
if va is None:
|
|
238
|
+
continue
|
|
239
|
+
for b in range(a + 1, len(chosen)):
|
|
240
|
+
vb, fb = vectors[b], chosen[b]
|
|
241
|
+
if vb is None or fa["hash"] == fb["hash"] or (pool[a], pool[b]) in lexical or (pool[b], pool[a]) in lexical:
|
|
242
|
+
continue
|
|
243
|
+
score = indexer._sumprod(va, vb)
|
|
244
|
+
if score >= min_similarity:
|
|
245
|
+
if _finding_id("s", (fa, fb)) in skip:
|
|
246
|
+
skip[_finding_id("s", (fa, fb))] = (score, fa, fb)
|
|
247
|
+
continue
|
|
248
|
+
total += 1
|
|
249
|
+
item = (score, pool[a], pool[b])
|
|
250
|
+
if len(heap) < limit:
|
|
251
|
+
heapq.heappush(heap, item)
|
|
252
|
+
elif item > heap[0]:
|
|
253
|
+
heapq.heapreplace(heap, item)
|
|
254
|
+
return "ready", sorted(heap, reverse=True), total, notes
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def find(root, embed, configured_model, min_similarity=DEFAULT_MIN_SIMILARITY, include_tests=False, limit=30,
|
|
258
|
+
dismissed=None, include_dismissed=False):
|
|
259
|
+
started = time.monotonic()
|
|
260
|
+
dismissed = dismissed or {}
|
|
261
|
+
functions, notes, view = _functions(root, include_tests)
|
|
262
|
+
groups = {}
|
|
263
|
+
for f in functions:
|
|
264
|
+
groups.setdefault(f["hash"], []).append(f)
|
|
265
|
+
exact_all = sorted((g for g in groups.values() if len(g) > 1), key=lambda g: (-g[0]["lines"], -len(g)))
|
|
266
|
+
near_all = _near(functions)
|
|
267
|
+
lexical = {(a, b) for _s, a, b in near_all}
|
|
268
|
+
hidden = []
|
|
269
|
+
exact = []
|
|
270
|
+
for g in exact_all:
|
|
271
|
+
fid = _finding_id("e", g)
|
|
272
|
+
(hidden.append(("exact", fid, None, g)) if fid in dismissed else exact.append((fid, g)))
|
|
273
|
+
near = []
|
|
274
|
+
for s, a, b in near_all:
|
|
275
|
+
fid = _finding_id("n", (functions[a], functions[b]))
|
|
276
|
+
(hidden.append(("near", fid, s, (functions[a], functions[b]))) if fid in dismissed else near.append((fid, s, a, b)))
|
|
277
|
+
skip = {fid: None for fid in dismissed if fid.startswith("s")}
|
|
278
|
+
try:
|
|
279
|
+
status, semantic, semantic_total, semantic_notes = _semantic(root, view, functions, embed, configured_model,
|
|
280
|
+
min_similarity, limit, lexical, skip)
|
|
281
|
+
except Exception as exc:
|
|
282
|
+
status, semantic, semantic_total, semantic_notes = "error", [], 0, [f"Semantic level failed: {type(exc).__name__}: {exc}"[:300]]
|
|
283
|
+
notes += semantic_notes
|
|
284
|
+
if min_similarity < DEFAULT_MIN_SIMILARITY:
|
|
285
|
+
notes.append(f"Semantic cutoff below the default ({min_similarity:.2f}): expect many false positives.")
|
|
286
|
+
semantic_rows = [(_finding_id("s", (functions[a], functions[b])), s, a, b) for s, a, b in semantic]
|
|
287
|
+
hidden_semantic = [fid for fid, score in skip.items() if score is not None]
|
|
288
|
+
with _LOCK:
|
|
289
|
+
remembered = LAST_FINDINGS.setdefault(root, {})
|
|
290
|
+
remembered.update({fid: "exact" for fid, _g in exact})
|
|
291
|
+
remembered.update({fid: "near" for fid, *_r in near})
|
|
292
|
+
remembered.update({fid: "semantic" for fid, *_r in semantic_rows})
|
|
293
|
+
remembered.update({fid: kind for kind, fid, *_r in hidden})
|
|
294
|
+
remembered.update({fid: "semantic" for fid in hidden_semantic})
|
|
295
|
+
while len(remembered) > MAX_REMEMBERED_FINDINGS:
|
|
296
|
+
remembered.pop(next(iter(remembered)))
|
|
297
|
+
result = {
|
|
298
|
+
"view": view["label"], "functions_analyzed": len(functions), "include_tests": include_tests,
|
|
299
|
+
"min_similarity": min_similarity, "cutoff_note": CUTOFF_NOTE, "semantic_status": status, "notes": notes,
|
|
300
|
+
"counts": {"exact_groups": len(exact), "near_pairs": len(near), "semantic_pairs": semantic_total,
|
|
301
|
+
"dismissed_hidden": len(hidden) + len(hidden_semantic)},
|
|
302
|
+
"exact": [{"id": fid, "lines": g[0]["lines"], "copies": [_ref(f) for f in g]} for fid, g in exact[:limit]],
|
|
303
|
+
"near": [{"id": fid, "score": round(s, 3), "a": _ref(functions[a]), "b": _ref(functions[b])} for fid, s, a, b in near[:limit]],
|
|
304
|
+
"semantic": [{"id": fid, "score": round(s, 3), "a": _ref(functions[a]), "b": _ref(functions[b])} for fid, s, a, b in semantic_rows],
|
|
305
|
+
"elapsed_s": round(time.monotonic() - started, 1),
|
|
306
|
+
}
|
|
307
|
+
if include_dismissed:
|
|
308
|
+
result["dismissed"] = ([{"id": fid, "type": kind, **({"score": round(s, 3)} if s is not None else {}),
|
|
309
|
+
"members": [_ref(f) for f in members], **dismissed[fid]} for kind, fid, s, members in hidden]
|
|
310
|
+
+ [{"id": fid, "type": "semantic", "score": round(skip[fid][0], 3),
|
|
311
|
+
"members": [_ref(skip[fid][1]), _ref(skip[fid][2])], **dismissed[fid]}
|
|
312
|
+
for fid in hidden_semantic])
|
|
313
|
+
return result
|