@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.
Files changed (41) hide show
  1. package/CHANGELOG.md +50 -23
  2. package/NOTICE +32 -32
  3. package/README.md +117 -116
  4. package/atomic_io.py +94 -94
  5. package/bin/smart-tool.mjs +5 -1
  6. package/client_hooks.py +192 -192
  7. package/code_graph_java.mjs +49 -49
  8. package/code_graph_js.cjs +135 -135
  9. package/duplicates.py +313 -313
  10. package/embedding_cache.py +99 -99
  11. package/endpoint_sync.py +179 -179
  12. package/gateway.py +220 -220
  13. package/hook_decision.py +96 -96
  14. package/index_inventory.py +75 -75
  15. package/indexer.py +878 -878
  16. package/install.py +113 -11
  17. package/install_runtime.py +101 -35
  18. package/integration_metrics.py +82 -82
  19. package/local_embedder.py +172 -172
  20. package/model_defaults.py +70 -70
  21. package/package.json +35 -35
  22. package/project_insights_ui.py +38 -38
  23. package/project_monitor.py +204 -204
  24. package/project_store.py +220 -220
  25. package/projects_ui.py +146 -146
  26. package/requirements.txt +8 -8
  27. package/research_cache.py +171 -171
  28. package/research_metrics.py +110 -110
  29. package/router.py +582 -582
  30. package/secret_store.py +121 -121
  31. package/setup_ui.py +1011 -986
  32. package/smart_tool_daemon.py +3604 -3604
  33. package/version.py +1 -1
  34. package/web_adapters/browser/fetch_camoufox.py +69 -0
  35. package/web_adapters/node/ddg-search.mjs +89 -89
  36. package/web_adapters/node/open-websearch-adapter.mjs +77 -77
  37. package/web_adapters/node/package.json +12 -12
  38. package/web_adapters/node/wikipedia-search.mjs +44 -44
  39. package/web_search_adapters.py +193 -193
  40. package/web_search_external.py +288 -288
  41. 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