@allansantos-dev/smart-tool 0.0.0-stage → 0.9.1
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 +35 -0
- package/LICENSE +202 -0
- package/NOTICE +32 -0
- package/README.md +116 -2
- package/atomic_io.py +94 -0
- package/autostart.py +199 -0
- package/bin/smart-tool.mjs +86 -0
- package/browser_service_client.py +232 -0
- package/capabilities.py +63 -0
- package/client_hooks.py +192 -0
- package/code_graph.py +403 -0
- package/code_graph_java.mjs +49 -0
- package/code_graph_js.cjs +135 -0
- package/config.py +241 -0
- package/daemon_launcher.py +165 -0
- package/directory_watch.py +131 -0
- package/document_text.py +69 -0
- package/duplicates.py +313 -0
- package/embedding_cache.py +99 -0
- package/endpoint_sync.py +179 -0
- package/folder_picker.py +73 -0
- package/freshness.py +278 -0
- package/gateway.py +220 -0
- package/hook_decision.py +96 -0
- package/hooks/pretool_router.py +45 -0
- package/index_inventory.py +75 -0
- package/index_profile.py +167 -0
- package/index_scope.py +458 -0
- package/index_views.py +121 -0
- package/indexer.py +878 -0
- package/install.py +353 -0
- package/install_runtime.py +283 -0
- package/integration_metrics.py +82 -0
- package/local_embedder.py +172 -0
- package/model_adapters/__init__.py +0 -0
- package/model_adapters/openai_compatible.py +57 -0
- package/model_catalog.py +74 -0
- package/model_client.py +38 -0
- package/model_defaults.py +70 -0
- package/package.json +33 -4
- package/paths.py +6 -0
- package/project_identity.py +42 -0
- package/project_insights_ui.py +38 -0
- package/project_maps_ui.py +96 -0
- package/project_monitor.py +204 -0
- package/project_scheduler.py +61 -0
- package/project_storage.py +165 -0
- package/project_store.py +220 -0
- package/projects_ui.py +146 -0
- package/requirements.txt +8 -0
- package/research_cache.py +171 -0
- package/research_metrics.py +110 -0
- package/router.py +582 -0
- package/secret_store.py +121 -0
- package/setup_ui.py +986 -0
- package/site_memory.py +53 -0
- package/smart_tool_daemon.py +3604 -0
- package/tray.py +245 -0
- package/update_check.py +22 -0
- package/usage_meter.py +124 -0
- package/version.py +18 -0
- package/view_compare.py +68 -0
- package/web_adapters/browser/browser_service.py +302 -0
- package/web_adapters/browser/crawl4ai_search.py +236 -0
- package/web_adapters/browser/fetch_camoufox.py +28 -0
- package/web_adapters/browser/requirements.txt +2 -0
- package/web_adapters/node/adapter-common.mjs +40 -0
- package/web_adapters/node/ddg-search.mjs +89 -0
- package/web_adapters/node/open-websearch-adapter.mjs +77 -0
- package/web_adapters/node/package-lock.json +2720 -0
- package/web_adapters/node/package.json +12 -0
- package/web_adapters/node/wikipedia-search.mjs +44 -0
- package/web_document_graph.py +71 -0
- package/web_fetch.py +242 -0
- package/web_search_adapters.py +193 -0
- package/web_search_ddgs.py +42 -0
- package/web_search_external.py +288 -0
- package/web_search_health.py +88 -0
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
"""ReadDirectoryChangesW assíncrono: eventos nativos com parada cooperativa."""
|
|
2
|
+
import os
|
|
3
|
+
import struct
|
|
4
|
+
import threading
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class DirectoryWatcher:
|
|
8
|
+
def __init__(self, root, on_events, on_error):
|
|
9
|
+
self.root = root
|
|
10
|
+
self.on_events = on_events
|
|
11
|
+
self.on_error = on_error
|
|
12
|
+
self._stop = threading.Event()
|
|
13
|
+
self._thread = None
|
|
14
|
+
self.ready = threading.Event()
|
|
15
|
+
|
|
16
|
+
def start(self):
|
|
17
|
+
if self._thread and self._thread.is_alive():
|
|
18
|
+
return
|
|
19
|
+
self._thread = threading.Thread(target=self._run, name="directory-events", daemon=True)
|
|
20
|
+
self._thread.start()
|
|
21
|
+
|
|
22
|
+
@property
|
|
23
|
+
def alive(self):
|
|
24
|
+
return bool(self._thread and self._thread.is_alive())
|
|
25
|
+
|
|
26
|
+
def stop(self):
|
|
27
|
+
self._stop.set()
|
|
28
|
+
if self._thread and self._thread is not threading.current_thread():
|
|
29
|
+
self._thread.join(timeout=3)
|
|
30
|
+
|
|
31
|
+
@staticmethod
|
|
32
|
+
def decode(buffer, length):
|
|
33
|
+
rows, offset = [], 0
|
|
34
|
+
while offset < length:
|
|
35
|
+
if offset + 12 > length:
|
|
36
|
+
raise ValueError("Incomplete directory event.")
|
|
37
|
+
next_offset, action, size = struct.unpack_from("<III", buffer, offset)
|
|
38
|
+
if size % 2 or offset + 12 + size > length:
|
|
39
|
+
raise ValueError("Invalid file name in event.")
|
|
40
|
+
name = buffer[offset + 12:offset + 12 + size].decode("utf-16-le")
|
|
41
|
+
rows.append({"path": name.replace("\\", "/"), "action": action})
|
|
42
|
+
if not next_offset:
|
|
43
|
+
break
|
|
44
|
+
if next_offset < 12 or offset + next_offset >= length:
|
|
45
|
+
raise ValueError("Invalid event offset.")
|
|
46
|
+
offset += next_offset
|
|
47
|
+
return rows
|
|
48
|
+
|
|
49
|
+
def _run(self):
|
|
50
|
+
if os.name != "nt":
|
|
51
|
+
self.on_error("Native events unavailable on this platform; periodic reconciliation is active.")
|
|
52
|
+
return
|
|
53
|
+
import ctypes
|
|
54
|
+
from ctypes import wintypes
|
|
55
|
+
|
|
56
|
+
class Overlapped(ctypes.Structure):
|
|
57
|
+
_fields_ = [("Internal", ctypes.c_size_t), ("InternalHigh", ctypes.c_size_t),
|
|
58
|
+
("Offset", wintypes.DWORD), ("OffsetHigh", wintypes.DWORD),
|
|
59
|
+
("hEvent", wintypes.HANDLE)]
|
|
60
|
+
|
|
61
|
+
kernel = ctypes.WinDLL("kernel32", use_last_error=True)
|
|
62
|
+
kernel.CreateFileW.argtypes = [wintypes.LPCWSTR, wintypes.DWORD, wintypes.DWORD,
|
|
63
|
+
ctypes.c_void_p, wintypes.DWORD, wintypes.DWORD, wintypes.HANDLE]
|
|
64
|
+
kernel.CreateFileW.restype = wintypes.HANDLE
|
|
65
|
+
kernel.CreateEventW.argtypes = [ctypes.c_void_p, wintypes.BOOL, wintypes.BOOL, wintypes.LPCWSTR]
|
|
66
|
+
kernel.CreateEventW.restype = wintypes.HANDLE
|
|
67
|
+
kernel.ReadDirectoryChangesW.argtypes = [wintypes.HANDLE, ctypes.c_void_p, wintypes.DWORD,
|
|
68
|
+
wintypes.BOOL, wintypes.DWORD, ctypes.POINTER(wintypes.DWORD), ctypes.POINTER(Overlapped), ctypes.c_void_p]
|
|
69
|
+
kernel.ReadDirectoryChangesW.restype = wintypes.BOOL
|
|
70
|
+
kernel.GetOverlappedResult.argtypes = [wintypes.HANDLE, ctypes.POINTER(Overlapped),
|
|
71
|
+
ctypes.POINTER(wintypes.DWORD), wintypes.BOOL]
|
|
72
|
+
kernel.GetOverlappedResult.restype = wintypes.BOOL
|
|
73
|
+
kernel.CancelIoEx.argtypes = [wintypes.HANDLE, ctypes.c_void_p]
|
|
74
|
+
kernel.CancelIoEx.restype = wintypes.BOOL
|
|
75
|
+
kernel.WaitForSingleObject.argtypes = [wintypes.HANDLE, wintypes.DWORD]
|
|
76
|
+
kernel.WaitForSingleObject.restype = wintypes.DWORD
|
|
77
|
+
kernel.ResetEvent.argtypes = [wintypes.HANDLE]
|
|
78
|
+
kernel.CloseHandle.argtypes = [wintypes.HANDLE]
|
|
79
|
+
handle, event, operation = None, None, None
|
|
80
|
+
pending = False
|
|
81
|
+
try:
|
|
82
|
+
handle = kernel.CreateFileW(self.root, 0x0001, 0x0007, None, 3,
|
|
83
|
+
0x02000000 | 0x40000000, None)
|
|
84
|
+
if handle == ctypes.c_void_p(-1).value:
|
|
85
|
+
handle = None
|
|
86
|
+
raise OSError(ctypes.get_last_error(), "Could not watch the folder.")
|
|
87
|
+
event = kernel.CreateEventW(None, True, False, None)
|
|
88
|
+
if not event:
|
|
89
|
+
raise OSError(ctypes.get_last_error(), "Could not create the watch event.")
|
|
90
|
+
buffer = ctypes.create_string_buffer(64 * 1024)
|
|
91
|
+
count = wintypes.DWORD()
|
|
92
|
+
while not self._stop.is_set():
|
|
93
|
+
kernel.ResetEvent(event)
|
|
94
|
+
operation = Overlapped()
|
|
95
|
+
operation.hEvent = event
|
|
96
|
+
ok = kernel.ReadDirectoryChangesW(handle, buffer, len(buffer), True,
|
|
97
|
+
0x0001 | 0x0002 | 0x0008 | 0x0010 | 0x0040,
|
|
98
|
+
None, ctypes.byref(operation), None)
|
|
99
|
+
if not ok and ctypes.get_last_error() != 997:
|
|
100
|
+
raise OSError(ctypes.get_last_error(), "Failed to receive folder changes.")
|
|
101
|
+
pending = True
|
|
102
|
+
self.ready.set()
|
|
103
|
+
while not self._stop.is_set():
|
|
104
|
+
status = kernel.WaitForSingleObject(event, 250)
|
|
105
|
+
if status == 0:
|
|
106
|
+
break
|
|
107
|
+
if status != 258:
|
|
108
|
+
raise OSError(ctypes.get_last_error(), "Failed while waiting for folder events.")
|
|
109
|
+
if self._stop.is_set():
|
|
110
|
+
break
|
|
111
|
+
ok = kernel.GetOverlappedResult(handle, ctypes.byref(operation), ctypes.byref(count), False)
|
|
112
|
+
pending = False
|
|
113
|
+
if not ok:
|
|
114
|
+
raise OSError(ctypes.get_last_error(), "Folder event interrupted.")
|
|
115
|
+
try:
|
|
116
|
+
rows = self.decode(buffer.raw, count.value) if count.value else []
|
|
117
|
+
except (ValueError, UnicodeError):
|
|
118
|
+
rows = []
|
|
119
|
+
self.on_events(rows, not bool(rows))
|
|
120
|
+
except Exception as exc:
|
|
121
|
+
if not self._stop.is_set():
|
|
122
|
+
self.on_error(f"{type(exc).__name__}: {exc}")
|
|
123
|
+
finally:
|
|
124
|
+
if handle and pending and operation is not None:
|
|
125
|
+
kernel.CancelIoEx(handle, ctypes.byref(operation))
|
|
126
|
+
count = wintypes.DWORD()
|
|
127
|
+
kernel.GetOverlappedResult(handle, ctypes.byref(operation), ctypes.byref(count), True)
|
|
128
|
+
if handle:
|
|
129
|
+
kernel.CloseHandle(handle)
|
|
130
|
+
if event:
|
|
131
|
+
kernel.CloseHandle(event)
|
package/document_text.py
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""Extração local de texto DOCX, sem Office, execução de macros ou acesso externo."""
|
|
2
|
+
import io
|
|
3
|
+
import re
|
|
4
|
+
import zipfile
|
|
5
|
+
import xml.etree.ElementTree as ET
|
|
6
|
+
|
|
7
|
+
VERSION = 1
|
|
8
|
+
MAX_ARCHIVE_BYTES = 10 * 1024 * 1024
|
|
9
|
+
MAX_XML_BYTES = 8 * 1024 * 1024
|
|
10
|
+
MAX_TEXT_BYTES = 1024 * 1024
|
|
11
|
+
W = '{http://schemas.openxmlformats.org/wordprocessingml/2006/main}'
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class DocumentError(ValueError):
|
|
15
|
+
pass
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class _DocumentTreeBuilder(ET.TreeBuilder):
|
|
19
|
+
def doctype(self, name, pubid, system):
|
|
20
|
+
# O callback também recusa DTD em XML UTF-16, antes de expandir entidades.
|
|
21
|
+
raise DocumentError('DOCX contains disallowed entities.')
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def extract_docx(data):
|
|
25
|
+
if len(data)>MAX_ARCHIVE_BYTES:
|
|
26
|
+
raise DocumentError('DOCX over the 10 MiB limit.')
|
|
27
|
+
try:
|
|
28
|
+
with zipfile.ZipFile(io.BytesIO(data)) as archive:
|
|
29
|
+
listing=archive.infolist()
|
|
30
|
+
entries={info.filename:info for info in listing}
|
|
31
|
+
if len(listing)>2000 or 'word/document.xml' not in entries:
|
|
32
|
+
raise DocumentError('Invalid DOCX or excessive structure.')
|
|
33
|
+
names=['word/document.xml']+sorted(name for name in entries if re.fullmatch(r'word/(?:header\d+|footer\d+|footnotes|endnotes)\.xml',name))
|
|
34
|
+
paragraphs=[];xml_bytes=0;text_bytes=0
|
|
35
|
+
for name in names:
|
|
36
|
+
info=entries[name]
|
|
37
|
+
if info.flag_bits&1 or info.compress_type not in (zipfile.ZIP_STORED,zipfile.ZIP_DEFLATED):
|
|
38
|
+
raise DocumentError('Encrypted DOCX or unsupported compression.')
|
|
39
|
+
if info.file_size>MAX_XML_BYTES-xml_bytes:
|
|
40
|
+
raise DocumentError('DOCX XML content over the limit.')
|
|
41
|
+
with archive.open(info) as stream:
|
|
42
|
+
raw=stream.read(MAX_XML_BYTES-xml_bytes+1)
|
|
43
|
+
xml_bytes+=len(raw)
|
|
44
|
+
if xml_bytes>MAX_XML_BYTES or b'<!DOCTYPE' in raw.upper() or b'<!ENTITY' in raw.upper():
|
|
45
|
+
raise DocumentError('DOCX contains excessive XML or disallowed entities.')
|
|
46
|
+
root=ET.fromstring(raw,parser=ET.XMLParser(target=_DocumentTreeBuilder()))
|
|
47
|
+
namespace=W if root.tag.startswith(W) else '{http://purl.oclc.org/ooxml/wordprocessingml/main}'
|
|
48
|
+
for paragraph in root.iter(namespace+'p'):
|
|
49
|
+
def parts(node):
|
|
50
|
+
pending=list(reversed(node))
|
|
51
|
+
while pending:
|
|
52
|
+
child=pending.pop()
|
|
53
|
+
if child.tag==namespace+'p':continue
|
|
54
|
+
if child.tag==namespace+'t':yield child.text or ''
|
|
55
|
+
elif child.tag==namespace+'tab':yield '\t'
|
|
56
|
+
elif child.tag in (namespace+'br',namespace+'cr'):yield '\n'
|
|
57
|
+
else:pending.extend(reversed(child))
|
|
58
|
+
text=''.join(parts(paragraph))
|
|
59
|
+
if text.strip():
|
|
60
|
+
text_bytes+=len(text.encode('utf-8'))+1
|
|
61
|
+
if text_bytes>MAX_TEXT_BYTES:raise DocumentError('Text extracted from DOCX exceeds 1 MiB.')
|
|
62
|
+
paragraphs.append(text)
|
|
63
|
+
return ('\n'.join(paragraphs)+'\n' if paragraphs else '').encode('utf-8')
|
|
64
|
+
except (zipfile.BadZipFile,ET.ParseError,RuntimeError,NotImplementedError,OSError) as exc:
|
|
65
|
+
raise DocumentError('Could not extract text from this DOCX.') from exc
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def location_kind(path):
|
|
69
|
+
return 'extracted_text_line' if str(path).lower().endswith('.docx') else 'source_line'
|
package/duplicates.py
ADDED
|
@@ -0,0 +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
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"""Reúso de vetores por texto exato e contrato; não guarda texto nem faz rede."""
|
|
2
|
+
import array
|
|
3
|
+
import concurrent.futures
|
|
4
|
+
import contextvars
|
|
5
|
+
import hashlib
|
|
6
|
+
import json
|
|
7
|
+
import math
|
|
8
|
+
import os
|
|
9
|
+
import sqlite3
|
|
10
|
+
import time
|
|
11
|
+
|
|
12
|
+
import project_identity
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class VectorCache:
|
|
16
|
+
def __init__(self, root, directory, contract):
|
|
17
|
+
os.makedirs(directory, exist_ok=True)
|
|
18
|
+
path = os.path.join(directory, project_identity.project_id(root) + '.vectors.sqlite3')
|
|
19
|
+
self.db = sqlite3.connect(path, timeout=10)
|
|
20
|
+
self.db.execute('PRAGMA secure_delete=ON')
|
|
21
|
+
self.db.execute('CREATE TABLE IF NOT EXISTS vectors(key TEXT PRIMARY KEY, vector BLOB NOT NULL, used_at REAL NOT NULL)')
|
|
22
|
+
self.contract = json.dumps(contract, sort_keys=True, ensure_ascii=False)
|
|
23
|
+
self.dimensions = contract.get('dimensions')
|
|
24
|
+
self.hits = self.misses = self.characters = 0
|
|
25
|
+
|
|
26
|
+
def key(self, text):
|
|
27
|
+
return hashlib.sha256((self.contract + '\0' + text).encode('utf-8')).hexdigest()
|
|
28
|
+
|
|
29
|
+
def put(self, text, vector):
|
|
30
|
+
if not self.dimensions or len(vector) != self.dimensions:
|
|
31
|
+
return
|
|
32
|
+
packed = array.array('f', vector)
|
|
33
|
+
if not all(math.isfinite(value) for value in packed):
|
|
34
|
+
raise ValueError('Embedding does not fit in float32; cache not written.')
|
|
35
|
+
self.db.execute('INSERT OR REPLACE INTO vectors VALUES(?,?,?)',
|
|
36
|
+
(self.key(text), packed.tobytes(), time.time()))
|
|
37
|
+
|
|
38
|
+
def embed(self, texts, embed_batch, validate, use_cache=True, split=None, workers=1):
|
|
39
|
+
"""Cache lookups and writes stay on the calling thread (sqlite); only embed_batch runs in workers."""
|
|
40
|
+
output = [None] * len(texts)
|
|
41
|
+
missing = {}
|
|
42
|
+
for i, text in enumerate(texts):
|
|
43
|
+
row = self.db.execute('SELECT vector FROM vectors WHERE key=?', (self.key(text),)).fetchone() if use_cache else None
|
|
44
|
+
if row and self.dimensions:
|
|
45
|
+
vector = array.array('f'); vector.frombytes(row[0])
|
|
46
|
+
validate(vector, self.dimensions)
|
|
47
|
+
output[i] = list(vector)
|
|
48
|
+
self.hits += 1
|
|
49
|
+
else:
|
|
50
|
+
missing.setdefault(text, []).append(i)
|
|
51
|
+
if missing:
|
|
52
|
+
unique = list(missing)
|
|
53
|
+
batches = split(unique) if split else [unique]
|
|
54
|
+
dimensions = [self.dimensions]
|
|
55
|
+
|
|
56
|
+
def store(part, vectors):
|
|
57
|
+
# Each batch is cached as soon as it returns: a later failure never discards paid embeddings.
|
|
58
|
+
if len(vectors) != len(part):
|
|
59
|
+
raise ValueError('Vector count differs from the chunks sent.')
|
|
60
|
+
dimensions[0] = dimensions[0] or (len(vectors[0]) if vectors else None)
|
|
61
|
+
for vector in vectors:
|
|
62
|
+
validate(vector, dimensions[0])
|
|
63
|
+
for text, vector in zip(part, vectors):
|
|
64
|
+
self.put(text, vector)
|
|
65
|
+
positions = missing[text]
|
|
66
|
+
for i in positions:
|
|
67
|
+
output[i] = vector
|
|
68
|
+
self.hits += len(positions) - 1
|
|
69
|
+
self.misses += len(part)
|
|
70
|
+
self.characters += sum(map(len, part))
|
|
71
|
+
self.db.commit()
|
|
72
|
+
|
|
73
|
+
if workers > 1 and len(batches) > 1:
|
|
74
|
+
pool = concurrent.futures.ThreadPoolExecutor(min(workers, len(batches)))
|
|
75
|
+
futures = {pool.submit(contextvars.copy_context().run, embed_batch, part): part for part in batches}
|
|
76
|
+
error = None
|
|
77
|
+
try:
|
|
78
|
+
for future in concurrent.futures.as_completed(futures):
|
|
79
|
+
if future.cancelled():
|
|
80
|
+
continue
|
|
81
|
+
try:
|
|
82
|
+
store(futures[future], list(future.result()))
|
|
83
|
+
except Exception as exc:
|
|
84
|
+
if error is None:
|
|
85
|
+
error = exc
|
|
86
|
+
for other in futures:
|
|
87
|
+
other.cancel()
|
|
88
|
+
finally:
|
|
89
|
+
pool.shutdown(wait=True, cancel_futures=True)
|
|
90
|
+
if error is not None:
|
|
91
|
+
raise error
|
|
92
|
+
else:
|
|
93
|
+
for part in batches:
|
|
94
|
+
store(part, list(embed_batch(part)))
|
|
95
|
+
return output
|
|
96
|
+
|
|
97
|
+
def close(self):
|
|
98
|
+
self.db.commit()
|
|
99
|
+
self.db.close()
|