@allansantos-dev/smart-tool 0.9.2 → 0.9.4
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 +86 -0
- package/README.md +27 -2
- package/affected_tests.py +301 -0
- package/client_hooks.py +2 -2
- package/code_graph.py +200 -14
- package/code_graph_java.mjs +4 -1
- package/code_graph_js.cjs +15 -1
- package/code_impact.py +132 -0
- package/config.py +22 -0
- package/doc_check.py +256 -0
- package/duplicates.py +188 -16
- package/edit_preview.py +92 -0
- package/hook_decision.py +100 -2
- package/index_profile.py +1 -1
- package/index_scope.py +16 -0
- package/indexer.py +10 -0
- package/package.json +1 -1
- package/router.py +19 -11
- package/setup_ui.py +53 -5
- package/smart_tool_daemon.py +110 -21
- package/version.py +1 -1
- package/web_adapters/browser/fetch_camoufox.py +16 -8
- package/web_adapters/browser/ranged_download.py +78 -0
- package/web_fetch.py +60 -3
- package/web_search_health.py +12 -4
package/doc_check.py
ADDED
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
"""Function docstrings: which functions a file has, whether each one is documented, and which ones an edit touched.
|
|
2
|
+
Python is read with ast; TypeScript/JavaScript and Java with the code graph analyzers, where the documentation is the
|
|
3
|
+
/** */ block right above the function. Used by the edit hook (missing or possibly stale docstrings), the docstring
|
|
4
|
+
coverage listing and the one-line summaries in graph and impact results."""
|
|
5
|
+
import ast
|
|
6
|
+
import difflib
|
|
7
|
+
import hashlib
|
|
8
|
+
import os
|
|
9
|
+
import re
|
|
10
|
+
import threading
|
|
11
|
+
from collections import Counter, OrderedDict, defaultdict
|
|
12
|
+
|
|
13
|
+
import code_graph
|
|
14
|
+
import index_profile
|
|
15
|
+
import index_scope
|
|
16
|
+
|
|
17
|
+
PYTHON = (".py",)
|
|
18
|
+
SCRIPT = (".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".mts", ".cts")
|
|
19
|
+
JAVA = (".java",)
|
|
20
|
+
SUPPORTED = PYTHON + SCRIPT + JAVA
|
|
21
|
+
MAX_PARSED = 64
|
|
22
|
+
_SPACE = re.compile(r"\s+")
|
|
23
|
+
_PARSED = OrderedDict()
|
|
24
|
+
_PARSED_LOCK = threading.Lock()
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def supported(path):
|
|
28
|
+
return str(path).lower().endswith(SUPPORTED)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _first_line(text):
|
|
32
|
+
return next((line.strip(" *") for line in text.strip().splitlines() if line.strip(" *")), "")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _public_name(name):
|
|
36
|
+
return not name.startswith("_") or name.startswith("__") and name.endswith("__")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _python_functions(source):
|
|
40
|
+
"""Python functions with "required" following pydocstyle publicity (public name, public parents, not nested in a
|
|
41
|
+
function) and PEP 698: an @override method inherits its documentation."""
|
|
42
|
+
tree = ast.parse(source)
|
|
43
|
+
found = []
|
|
44
|
+
|
|
45
|
+
def visit(node, owner, public, nested):
|
|
46
|
+
for child in ast.iter_child_nodes(node):
|
|
47
|
+
if isinstance(child, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
48
|
+
doc = ast.get_docstring(child)
|
|
49
|
+
returns = f" -> {ast.unparse(child.returns)}" if child.returns else ""
|
|
50
|
+
found.append({"name": f"{owner}{child.name}", "start": child.lineno, "end": child.end_lineno,
|
|
51
|
+
"documented": doc is not None, "doc": _first_line(doc or ""),
|
|
52
|
+
"signature": f"{child.name}({ast.unparse(child.args)}){returns}",
|
|
53
|
+
"required": public and not nested and _public_name(child.name)
|
|
54
|
+
and not any(ast.unparse(d).split(".")[-1] == "override"
|
|
55
|
+
for d in child.decorator_list)})
|
|
56
|
+
visit(child, f"{owner}{child.name}.", False, True)
|
|
57
|
+
elif isinstance(child, ast.ClassDef):
|
|
58
|
+
visit(child, f"{owner}{child.name}.", public and _public_name(child.name), nested)
|
|
59
|
+
visit(tree, "", True, False)
|
|
60
|
+
return found
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _overload_head(lines, index, name):
|
|
64
|
+
"""Line where the overload signature ending at index starts, or None when the lines above are not an overload of
|
|
65
|
+
name (a declaration ending in ';')."""
|
|
66
|
+
head = re.compile(rf"^\s*(export\s+)?(default\s+)?(declare\s+)?(public\s+|protected\s+)?(static\s+)?(async\s+)?"
|
|
67
|
+
rf"(function\s+)?{re.escape(name)}\s*[<(]")
|
|
68
|
+
for k in range(index, max(index - 30, -1), -1):
|
|
69
|
+
line = lines[k].rstrip()
|
|
70
|
+
if k != index and (line.endswith(("*/", "}", ";")) or not line):
|
|
71
|
+
return None
|
|
72
|
+
if head.match(lines[k]):
|
|
73
|
+
return k
|
|
74
|
+
return None
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _block_above(lines, start, name=None):
|
|
78
|
+
"""Text of the /** */ block above the function starting at line start. With name (TypeScript/JavaScript), uncommented
|
|
79
|
+
overload signatures in between are skipped, as eslint-plugin-jsdoc does by default."""
|
|
80
|
+
index = start - 2
|
|
81
|
+
while index >= 0:
|
|
82
|
+
if not lines[index].strip() or lines[index].lstrip().startswith("@"):
|
|
83
|
+
index -= 1
|
|
84
|
+
continue
|
|
85
|
+
head = _overload_head(lines, index, name) if name and lines[index].rstrip().endswith(";") else None
|
|
86
|
+
if head is None:
|
|
87
|
+
break
|
|
88
|
+
index = head - 1
|
|
89
|
+
if index < 0 or not lines[index].rstrip().endswith("*/"):
|
|
90
|
+
return None
|
|
91
|
+
end = index
|
|
92
|
+
while index >= 0 and "/*" not in lines[index]:
|
|
93
|
+
index -= 1
|
|
94
|
+
if index < 0 or "/**" not in lines[index]:
|
|
95
|
+
return None
|
|
96
|
+
return "\n".join(line.strip().removeprefix("/**").removesuffix("*/").strip().removeprefix("*").strip()
|
|
97
|
+
for line in lines[index:end + 1])
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _annotations(lines, start, end):
|
|
101
|
+
"""Annotation lines right above the function and at its first lines (the Java analyzer starts a method at its
|
|
102
|
+
annotations), and the first line that is not an annotation."""
|
|
103
|
+
index, found = start - 2, []
|
|
104
|
+
while index >= 0 and (not lines[index].strip() or lines[index].lstrip().startswith("@")):
|
|
105
|
+
found.append(lines[index].strip())
|
|
106
|
+
index -= 1
|
|
107
|
+
index = start - 1
|
|
108
|
+
while index < min(end, len(lines)) and lines[index].lstrip().startswith("@"):
|
|
109
|
+
found.append(lines[index].strip())
|
|
110
|
+
index += 1
|
|
111
|
+
return found, lines[index] if index < len(lines) else ""
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _required(path, symbol, parent, first_line, annotations):
|
|
115
|
+
"""Whether a braced-language function needs documentation by the linter defaults: Checkstyle MissingJavadocMethod
|
|
116
|
+
(no @Override, not private) and eslint-plugin-jsdoc require-jsdoc (named declarations and class methods, never
|
|
117
|
+
callbacks or functions nested in another function; private members and unexported arrow functions left out)."""
|
|
118
|
+
short = symbol["name"].split("(")[0].split(".")[-1]
|
|
119
|
+
if short in ("callback", "anonymous") or not _public_name(short) or short.startswith("#"):
|
|
120
|
+
return False
|
|
121
|
+
if re.search(r"\bprivate\b", first_line):
|
|
122
|
+
return False
|
|
123
|
+
if path.lower().endswith(JAVA):
|
|
124
|
+
return not any(a.startswith("@Override") for a in annotations)
|
|
125
|
+
if parent and parent.get("kind") in ("function", "method"):
|
|
126
|
+
return False
|
|
127
|
+
if symbol.get("kind") == "method" or parent and parent.get("kind") in ("class", "interface"):
|
|
128
|
+
return True
|
|
129
|
+
return bool(re.match(r"\s*(export\s+)?(default\s+)?(declare\s+)?(async\s+)?function\b", first_line)
|
|
130
|
+
or re.match(r"\s*export\b", first_line))
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _braced_functions(path, source, symbols):
|
|
134
|
+
lines = source.splitlines()
|
|
135
|
+
by_id = {s.get("id"): s for s in symbols}
|
|
136
|
+
script = path.lower().endswith(SCRIPT)
|
|
137
|
+
found = []
|
|
138
|
+
for symbol in symbols:
|
|
139
|
+
if symbol.get("kind") not in ("function", "method") or "@" in symbol["name"]:
|
|
140
|
+
continue
|
|
141
|
+
start, end = symbol["start_line"], symbol["end_line"]
|
|
142
|
+
short = symbol["name"].split("(")[0].split(".")[-1]
|
|
143
|
+
block = _block_above(lines, start, short if script else None)
|
|
144
|
+
header = " ".join(lines[start - 1:min(end, start + 8)])
|
|
145
|
+
annotations, first_line = _annotations(lines, start, end)
|
|
146
|
+
required = _required(path, symbol, by_id.get(symbol.get("parent")), first_line, annotations)
|
|
147
|
+
found.append({"name": symbol["name"], "start": start, "end": end, "documented": block is not None,
|
|
148
|
+
"doc": _first_line(block or ""), "signature": _SPACE.sub(" ", header.split("{")[0]).strip(),
|
|
149
|
+
"required": required})
|
|
150
|
+
return found
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def functions(path, source, symbols=None):
|
|
154
|
+
"""Functions of one file with start/end lines, documented flag, first doc line and normalized signature. symbols
|
|
155
|
+
are the code graph symbols of that file when already known; otherwise the analyzer runs on source."""
|
|
156
|
+
lower = path.lower()
|
|
157
|
+
if lower.endswith(PYTHON):
|
|
158
|
+
try:
|
|
159
|
+
return _python_functions(source)
|
|
160
|
+
except SyntaxError:
|
|
161
|
+
return []
|
|
162
|
+
if symbols is None:
|
|
163
|
+
symbols = analyze(path, source)[0]
|
|
164
|
+
return _braced_functions(path, source, symbols)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def analyze(path, *sources):
|
|
168
|
+
"""Analyzer symbols of several versions of one TypeScript/JavaScript/Java file, from one analyzer run for the
|
|
169
|
+
versions not seen yet (an edit hook needs the file before and after; the next edit's 'before' is this 'after')."""
|
|
170
|
+
lower, name = path.lower(), os.path.basename(path)
|
|
171
|
+
keys = [hashlib.sha1(f"{name}\0{source}".encode("utf-8")).hexdigest() for source in sources]
|
|
172
|
+
with _PARSED_LOCK:
|
|
173
|
+
missing = {key: source for key, source in zip(keys, sources) if key not in _PARSED}
|
|
174
|
+
if missing:
|
|
175
|
+
batch = {f"v{i}/{name}": source for i, source in enumerate(missing.values())}
|
|
176
|
+
analysis = (code_graph._javascript_graph(batch) if lower.endswith(SCRIPT)
|
|
177
|
+
else code_graph._java_graph(batch) if lower.endswith(JAVA) else {})
|
|
178
|
+
found = {key: [dict(s, path=name) for s in analysis.get("symbols") or [] if s.get("path") == f"v{i}/{name}"]
|
|
179
|
+
for i, key in enumerate(missing)}
|
|
180
|
+
with _PARSED_LOCK:
|
|
181
|
+
_PARSED.update(found)
|
|
182
|
+
while len(_PARSED) > MAX_PARSED:
|
|
183
|
+
_PARSED.popitem(last=False)
|
|
184
|
+
with _PARSED_LOCK:
|
|
185
|
+
return [_PARSED[key] if key in _PARSED else [] for key in keys]
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def _touched_lines(before, after):
|
|
189
|
+
old, new = (before or "").splitlines(), after.splitlines()
|
|
190
|
+
touched = set()
|
|
191
|
+
for tag, _i1, _i2, j1, j2 in difflib.SequenceMatcher(None, old, new, autojunk=False).get_opcodes():
|
|
192
|
+
if tag != "equal":
|
|
193
|
+
touched.update(range(j1 + 1, max(j1 + 1, j2) + 1))
|
|
194
|
+
return touched
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def written(path, before, after):
|
|
198
|
+
"""Functions of the edited file after the edit ('all') and the ones the edit touched ('touched')."""
|
|
199
|
+
if path.lower().endswith(SCRIPT + JAVA):
|
|
200
|
+
analyze(path, *(source for source in (before, after) if source is not None))
|
|
201
|
+
found = functions(path, after) if supported(path) else []
|
|
202
|
+
lines = _touched_lines(before, after)
|
|
203
|
+
return {"all": found, "touched": [f for f in found if lines.intersection(range(f["start"], f["end"] + 1))]}
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def review(path, before, after, edited=None):
|
|
207
|
+
"""Functions the edit from before to after touched that are left without a docstring although the language
|
|
208
|
+
convention expects one ('missing': public, not nested, not an override) or keep one while their signature changed
|
|
209
|
+
('signature_changed'). Untouched functions and body-only edits are not reported. edited is written() of the same
|
|
210
|
+
edit when the caller already has it."""
|
|
211
|
+
if not supported(path):
|
|
212
|
+
return []
|
|
213
|
+
edited = edited or written(path, before, after)
|
|
214
|
+
if not edited["touched"]:
|
|
215
|
+
return []
|
|
216
|
+
old = {f["name"]: f for f in functions(path, before)} if before else {}
|
|
217
|
+
findings = []
|
|
218
|
+
for function in edited["touched"]:
|
|
219
|
+
previous = old.get(function["name"])
|
|
220
|
+
if not function["documented"]:
|
|
221
|
+
if function["required"]:
|
|
222
|
+
findings.append({**function, "issue": "missing"})
|
|
223
|
+
elif previous and _SPACE.sub("", previous["signature"]) != _SPACE.sub("", function["signature"]):
|
|
224
|
+
findings.append({**function, "issue": "signature_changed"})
|
|
225
|
+
return findings
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def coverage(root, include_tests=False, limit=30, view_id=None):
|
|
229
|
+
"""Docstring coverage of the indexed code: functions the language convention expects documented (plus any already
|
|
230
|
+
documented) and those still missing, file by file, so an agent can document a project that started without it.
|
|
231
|
+
Tests are left out unless include_tests."""
|
|
232
|
+
data = code_graph.build_current(root, view_id)
|
|
233
|
+
profile = index_profile.current((index_scope.load_scope(root) or {}).get("profile"))
|
|
234
|
+
kinds = ("code", "test") if include_tests else ("code",)
|
|
235
|
+
by_path = defaultdict(list)
|
|
236
|
+
for symbol in data.get("symbols") or []:
|
|
237
|
+
if supported(symbol["path"]) and index_profile.kind(symbol["path"], profile) in kinds:
|
|
238
|
+
by_path[symbol["path"]].append(symbol)
|
|
239
|
+
total, missing = 0, []
|
|
240
|
+
for path in sorted(by_path):
|
|
241
|
+
try:
|
|
242
|
+
with open(os.path.join(root, path), encoding="utf-8") as stream:
|
|
243
|
+
source = stream.read()
|
|
244
|
+
except (OSError, UnicodeDecodeError):
|
|
245
|
+
continue
|
|
246
|
+
found = [f for f in functions(path, source, by_path[path]) if f["required"] or f["documented"]]
|
|
247
|
+
total += len(found)
|
|
248
|
+
missing += [{"path": path, "function": f["name"], "line": f["start"]} for f in found if not f["documented"]]
|
|
249
|
+
per_file = Counter(item["path"] for item in missing)
|
|
250
|
+
return {"functions": total, "documented": total - len(missing),
|
|
251
|
+
"coverage_percent": round(100 * (total - len(missing)) / total, 1) if total else None,
|
|
252
|
+
"missing_total": len(missing), "missing": missing[:limit],
|
|
253
|
+
"files_with_most_missing": [{"path": p, "missing": n} for p, n in per_file.most_common(10)],
|
|
254
|
+
"include_tests": include_tests,
|
|
255
|
+
"diagnostics": [d.get("reason") for d in data.get("diagnostics") or [] if d.get("reason")],
|
|
256
|
+
"view": (data.get("selected") or {}).get("label")}
|
package/duplicates.py
CHANGED
|
@@ -9,6 +9,7 @@ import threading
|
|
|
9
9
|
import time
|
|
10
10
|
|
|
11
11
|
import code_graph
|
|
12
|
+
import doc_check
|
|
12
13
|
import embedding_cache
|
|
13
14
|
import index_inventory
|
|
14
15
|
import index_profile
|
|
@@ -25,8 +26,35 @@ EMBED_SLICE = 64
|
|
|
25
26
|
CUTOFF_NOTE = ("Default semantic cutoff 0.90: in the 2026-10-02 measurement (claude-code, independent judge) pairs above "
|
|
26
27
|
"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
28
|
"and reviewing them costs time without leading to a refactor.")
|
|
29
|
+
MIN_TOKENS = 50
|
|
30
|
+
NEAR_MIN_TOKENS = 70
|
|
31
|
+
SHINGLE = 5
|
|
32
|
+
NEAR_NOTE = ("Near = at least 0.80 of 5-token sequences shared with keywords, called names, attributes and types kept "
|
|
33
|
+
"and only local names, strings and numbers abstracted; in the 2026-10-08 measurement on Python, "
|
|
34
|
+
"JavaScript, TypeScript and Java projects it caught every exact and locally renamed copy and 95-100% of "
|
|
35
|
+
"copies with one added statement, and fired on 0-1.6% of real functions.")
|
|
28
36
|
_HASH_COMMENT = {"py", "rb", "sh"}
|
|
29
|
-
_TOKEN = re.compile(r"[A-Za-z_]\w*|\d
|
|
37
|
+
_TOKEN = re.compile(r"\"(?:\\.|[^\"\\\n])*\"|'(?:\\.|[^'\\\n])*'|`(?:\\.|[^`\\])*`|[A-Za-z_$][\w$]*|\d[\w.]*|[^\s\w]")
|
|
38
|
+
_KEYWORDS = {
|
|
39
|
+
"py": "False None True and as assert async await break class continue def del elif else except finally for from "
|
|
40
|
+
"global if import in is lambda nonlocal not or pass raise return try while with yield self cls",
|
|
41
|
+
"js": "await break case catch class const continue debugger default delete do else export extends false finally "
|
|
42
|
+
"for function if import in instanceof let new null of return super switch this throw true try typeof "
|
|
43
|
+
"undefined var void while with yield async",
|
|
44
|
+
"java": "abstract assert boolean break byte case catch char class const continue default do double else enum "
|
|
45
|
+
"extends final finally float for if implements import instanceof int interface long native new null "
|
|
46
|
+
"package private protected public return short static super switch synchronized this throw throws "
|
|
47
|
+
"transient try void volatile while true false var",
|
|
48
|
+
}
|
|
49
|
+
_KEYWORDS["ts"] = _KEYWORDS["js"] + " as interface type enum implements private public protected readonly keyof never unknown any"
|
|
50
|
+
_KEYWORDS = {lang: frozenset(words.split()) for lang, words in _KEYWORDS.items()}
|
|
51
|
+
_LANG = {"py": "py", "js": "js", "jsx": "js", "mjs": "js", "cjs": "js", "ts": "ts", "tsx": "ts", "mts": "ts",
|
|
52
|
+
"cts": "ts", "java": "java"}
|
|
53
|
+
_CONTRACT = re.compile(r"^(__\w+__|equals|hashCode|toString|compareTo|clone|close|run|call|apply|get|set|render|"
|
|
54
|
+
r"constructor|ng[A-Z]\w*|componentDid\w+|main)$")
|
|
55
|
+
_DOCSTRING = re.compile(r"\A\s*[rRuUbB]{0,2}(\"\"\"|''')[\s\S]*?\1")
|
|
56
|
+
_GENERATED = re.compile(r"@generated|DO NOT EDIT|Code generated by|auto-generated", re.I)
|
|
57
|
+
_WRITE_POOLS = {}
|
|
30
58
|
_CONTAINER_KINDS = {"module", "template", "class", "interface"}
|
|
31
59
|
_GENERIC_NAMES = {"callback", "anonymous", "constructor"}
|
|
32
60
|
_WARMING = set()
|
|
@@ -64,18 +92,48 @@ def _strip_comments(text, ext):
|
|
|
64
92
|
|
|
65
93
|
|
|
66
94
|
def _body(text, ext):
|
|
95
|
+
"""Function body without the signature and, in Python, without the leading docstring, so two copies that differ
|
|
96
|
+
only in documentation compare as identical."""
|
|
67
97
|
if ext in _HASH_COMMENT:
|
|
68
98
|
lines = text.splitlines()
|
|
69
99
|
start = next((k for k, line in enumerate(lines) if re.match(r"\s*(async\s+)?def\s", line)), 0)
|
|
70
100
|
end = next((k for k in range(start, len(lines)) if lines[k].rstrip().endswith(":")), start)
|
|
71
|
-
return "\n".join(lines[end + 1:])
|
|
101
|
+
return _DOCSTRING.sub("", "\n".join(lines[end + 1:]), count=1)
|
|
72
102
|
brace = text.find("{")
|
|
73
103
|
return text[brace + 1:] if brace >= 0 else text
|
|
74
104
|
|
|
75
105
|
|
|
76
|
-
def
|
|
77
|
-
|
|
78
|
-
|
|
106
|
+
def _features(body, ext, name):
|
|
107
|
+
"""Near-duplicate features of a function body: 5-token shingles where keywords, called names, attribute names and
|
|
108
|
+
type names stay and local names, strings and numbers become placeholders, so parallel functions calling different
|
|
109
|
+
APIs (get_stdin/get_stdout, rotateLeft/rotateRight) do not match while a copy with renamed locals does."""
|
|
110
|
+
words = _KEYWORDS.get(_LANG.get(ext), frozenset())
|
|
111
|
+
raw = _TOKEN.findall(body)
|
|
112
|
+
tokens = []
|
|
113
|
+
for k, t in enumerate(raw):
|
|
114
|
+
if t[0] in "\"'`":
|
|
115
|
+
tokens.append("STR")
|
|
116
|
+
elif t[0].isdigit():
|
|
117
|
+
tokens.append("NUM")
|
|
118
|
+
elif re.match(r"[A-Za-z_$]", t):
|
|
119
|
+
kept = (t in words or t[0].isupper() or (k and raw[k - 1] == ".")
|
|
120
|
+
or (k + 1 < len(raw) and raw[k + 1] == "("))
|
|
121
|
+
tokens.append(t if kept else "ID")
|
|
122
|
+
else:
|
|
123
|
+
tokens.append(t)
|
|
124
|
+
return {"shingles": frozenset(tuple(tokens[i:i + SHINGLE]) for i in range(max(len(tokens) - SHINGLE + 1, 1))),
|
|
125
|
+
"tokens": len(tokens), "lang": _LANG.get(ext, ext),
|
|
126
|
+
"near_ok": len(tokens) >= NEAR_MIN_TOKENS and not _CONTRACT.match(name)}
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _idioms(functions):
|
|
130
|
+
"""Shingles shared by so many functions that they are language idioms (if err != nil, try/except), not copies."""
|
|
131
|
+
counts = {}
|
|
132
|
+
for f in functions:
|
|
133
|
+
for shingle in f["shingles"]:
|
|
134
|
+
counts[shingle] = counts.get(shingle, 0) + 1
|
|
135
|
+
cut = max(100, int(0.005 * len(functions)))
|
|
136
|
+
return frozenset(s for s, c in counts.items() if c > cut)
|
|
79
137
|
|
|
80
138
|
|
|
81
139
|
def _index_state(path):
|
|
@@ -112,13 +170,10 @@ def _functions(root, include_tests):
|
|
|
112
170
|
if any(o is not s and o["start_line"] <= s["start_line"] and s["end_line"] <= o["end_line"]
|
|
113
171
|
and (o["start_line"], o["end_line"]) != (s["start_line"], s["end_line"]) for o in symbols):
|
|
114
172
|
continue
|
|
115
|
-
|
|
116
|
-
if
|
|
173
|
+
entry = _entry(path, s["name"], s["start_line"], s["end_line"], file_lines, ext)
|
|
174
|
+
if entry is None:
|
|
117
175
|
continue
|
|
118
|
-
|
|
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)})
|
|
176
|
+
functions.append(entry)
|
|
122
177
|
occurrence = occurrences[functions[-1]["hash"]] = occurrences.get(functions[-1]["hash"], 0) + 1
|
|
123
178
|
functions[-1]["kind"] = kind
|
|
124
179
|
functions[-1]["fp"] = hashlib.sha1(f"{path}\0{functions[-1]['hash']}\0{occurrence}".encode("utf-8")).hexdigest()
|
|
@@ -132,6 +187,43 @@ def _functions(root, include_tests):
|
|
|
132
187
|
return functions, notes, view
|
|
133
188
|
|
|
134
189
|
|
|
190
|
+
def _entry(path, name, start, end, file_lines, ext):
|
|
191
|
+
"""One function as the duplicate checks compare it: normalized body hash and near-duplicate features."""
|
|
192
|
+
text = "\n".join(file_lines[start - 1:end])
|
|
193
|
+
if not text.strip():
|
|
194
|
+
return None
|
|
195
|
+
body = _strip_comments(_body(text, ext), ext)
|
|
196
|
+
normalized = re.sub(r"\s+", " ", body).strip()
|
|
197
|
+
short = name.split("(")[0].split(".")[-1]
|
|
198
|
+
return {"name": short, "path": path, "line": start, "end": end, "lines": end - start + 1, "text": text[:MAX_TEXT],
|
|
199
|
+
"hash": hashlib.sha1(normalized.encode("utf-8")).hexdigest(), **_features(body, ext, short)}
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def _working_tree_functions(root, changes, profile):
|
|
203
|
+
"""Production functions of the files changed in the working tree, read from disk, so a copy of a function written
|
|
204
|
+
earlier in the same session is caught before the index catches up."""
|
|
205
|
+
found = []
|
|
206
|
+
for rel, status in changes.items():
|
|
207
|
+
ext = rel.rsplit(".", 1)[-1].lower() if "." in rel else ""
|
|
208
|
+
if status == "deleted" or ext not in _LANG or index_profile.kind(rel, profile) != "code":
|
|
209
|
+
continue
|
|
210
|
+
try:
|
|
211
|
+
with open(os.path.join(root, rel), encoding="utf-8") as stream:
|
|
212
|
+
source = stream.read()
|
|
213
|
+
except (OSError, UnicodeDecodeError):
|
|
214
|
+
continue
|
|
215
|
+
if _GENERATED.search(source[:4000]):
|
|
216
|
+
continue
|
|
217
|
+
lines = source.splitlines()
|
|
218
|
+
for function in doc_check.functions(rel, source):
|
|
219
|
+
if function["end"] - function["start"] + 1 < MIN_LINES:
|
|
220
|
+
continue
|
|
221
|
+
entry = _entry(rel, function["name"], function["start"], function["end"], lines, ext)
|
|
222
|
+
if entry:
|
|
223
|
+
found.append({**entry, "kind": "code"})
|
|
224
|
+
return found
|
|
225
|
+
|
|
226
|
+
|
|
135
227
|
def _ref(f):
|
|
136
228
|
return {"name": f["name"], "path": f["path"], "lines": f"{f['line']}-{f['end']}"}
|
|
137
229
|
|
|
@@ -183,16 +275,24 @@ def _warm(root, view, functions, embed):
|
|
|
183
275
|
_WARMING.discard(root)
|
|
184
276
|
|
|
185
277
|
|
|
278
|
+
def _similar(fa, fb):
|
|
279
|
+
"""Whether two functions may be compared as near copies: same language, not overloads of one name in one file."""
|
|
280
|
+
return fa["lang"] == fb["lang"] and fa["hash"] != fb["hash"] and not (fa["path"] == fb["path"]
|
|
281
|
+
and fa["name"] == fb["name"])
|
|
282
|
+
|
|
283
|
+
|
|
186
284
|
def _near(functions):
|
|
187
|
-
|
|
285
|
+
common = _idioms(functions)
|
|
286
|
+
cut = [f["shingles"] - common if f["near_ok"] else None for f in functions]
|
|
287
|
+
order = sorted((i for i in range(len(functions)) if cut[i]), key=lambda i: len(cut[i]))
|
|
188
288
|
found = []
|
|
189
289
|
for position, a in enumerate(order):
|
|
190
|
-
sa =
|
|
290
|
+
sa = cut[a]
|
|
191
291
|
for b in order[position + 1:]:
|
|
192
|
-
sb =
|
|
292
|
+
sb = cut[b]
|
|
193
293
|
if len(sb) * NEAR_JACCARD > len(sa):
|
|
194
294
|
break
|
|
195
|
-
if functions[a]
|
|
295
|
+
if not _similar(functions[a], functions[b]):
|
|
196
296
|
continue
|
|
197
297
|
inter = len(sa & sb)
|
|
198
298
|
score = inter / (len(sa) + len(sb) - inter)
|
|
@@ -296,7 +396,7 @@ def find(root, embed, configured_model, min_similarity=DEFAULT_MIN_SIMILARITY, i
|
|
|
296
396
|
remembered.pop(next(iter(remembered)))
|
|
297
397
|
result = {
|
|
298
398
|
"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,
|
|
399
|
+
"min_similarity": min_similarity, "cutoff_note": CUTOFF_NOTE, "near_note": NEAR_NOTE, "semantic_status": status, "notes": notes,
|
|
300
400
|
"counts": {"exact_groups": len(exact), "near_pairs": len(near), "semantic_pairs": semantic_total,
|
|
301
401
|
"dismissed_hidden": len(hidden) + len(hidden_semantic)},
|
|
302
402
|
"exact": [{"id": fid, "lines": g[0]["lines"], "copies": [_ref(f) for f in g]} for fid, g in exact[:limit]],
|
|
@@ -311,3 +411,75 @@ def find(root, embed, configured_model, min_similarity=DEFAULT_MIN_SIMILARITY, i
|
|
|
311
411
|
"members": [_ref(skip[fid][1]), _ref(skip[fid][2])], **dismissed[fid]}
|
|
312
412
|
for fid in hidden_semantic])
|
|
313
413
|
return result
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
def _write_pool(root):
|
|
417
|
+
"""Indexed production functions with their idiom shingles, with the files changed in the working tree read from
|
|
418
|
+
disk (see code_graph.pending_changes). The indexed part is rebuilt only when the index changes and the changed
|
|
419
|
+
files on every call (an edit hook sees what earlier edits and commits wrote). None while the code graph is not cached yet, so a hook never waits for it."""
|
|
420
|
+
view_path = indexer.existing_db_path(root)
|
|
421
|
+
if not view_path or code_graph.cached_symbols(root)[0] is None:
|
|
422
|
+
return None
|
|
423
|
+
state = (view_path, *_index_state(view_path))
|
|
424
|
+
with _LOCK:
|
|
425
|
+
cached = _WRITE_POOLS.get(root)
|
|
426
|
+
if cached and cached[0] == state:
|
|
427
|
+
indexed = cached[1]
|
|
428
|
+
else:
|
|
429
|
+
functions, _notes, _view = _functions(root, False)
|
|
430
|
+
indexed = [f for f in functions if f["kind"] == "code"]
|
|
431
|
+
with _LOCK:
|
|
432
|
+
_WRITE_POOLS[root] = (state, indexed)
|
|
433
|
+
changes = code_graph.pending_changes(root, view_path)
|
|
434
|
+
profile = index_profile.current((index_scope.load_scope(root) or {}).get("profile"))
|
|
435
|
+
production = [f for f in indexed if f["path"] not in changes] + _working_tree_functions(root, changes, profile)
|
|
436
|
+
return production, _idioms(production)
|
|
437
|
+
|
|
438
|
+
|
|
439
|
+
def on_write(root, path, before, after, written):
|
|
440
|
+
"""Functions an edit writes whose body copies one already indexed: exact (same tokens, comments and spacing
|
|
441
|
+
ignored) or near (see NEAR_NOTE). written lists the touched functions as doc_check.functions returns them. The
|
|
442
|
+
function being edited in place, overloads of one name and functions the edit removes from the file are skipped."""
|
|
443
|
+
ext = path.rsplit(".", 1)[-1].lower() if "." in path else ""
|
|
444
|
+
rel = os.path.relpath(path, root).replace(os.sep, "/")
|
|
445
|
+
profile = index_profile.current((index_scope.load_scope(root) or {}).get("profile"))
|
|
446
|
+
if ext not in _LANG or index_profile.kind(rel, profile) != "code" or _GENERATED.search(after[:4000]):
|
|
447
|
+
return []
|
|
448
|
+
pool = _write_pool(root)
|
|
449
|
+
if pool is None:
|
|
450
|
+
return []
|
|
451
|
+
production, common = pool
|
|
452
|
+
lines = after.splitlines()
|
|
453
|
+
remaining = {f["name"].split("(")[0].split(".")[-1] for f in written["all"]}
|
|
454
|
+
matches = []
|
|
455
|
+
for function in written["touched"]:
|
|
456
|
+
name = function["name"].split("(")[0].split(".")[-1]
|
|
457
|
+
text = "\n".join(lines[function["start"] - 1:function["end"]])
|
|
458
|
+
body = _strip_comments(_body(text, ext), ext)
|
|
459
|
+
features = _features(body, ext, name)
|
|
460
|
+
if function["end"] - function["start"] + 1 < MIN_LINES or features["tokens"] < MIN_TOKENS \
|
|
461
|
+
or _CONTRACT.match(name):
|
|
462
|
+
continue
|
|
463
|
+
digest = hashlib.sha1(re.sub(r"\s+", " ", body).strip().encode("utf-8")).hexdigest()
|
|
464
|
+
mine = features["shingles"] - common
|
|
465
|
+
best = None
|
|
466
|
+
for other in production:
|
|
467
|
+
if other["lang"] != features["lang"] or other["path"] == rel and (other["name"] == name
|
|
468
|
+
or other["name"] not in remaining):
|
|
469
|
+
continue
|
|
470
|
+
if other["hash"] == digest:
|
|
471
|
+
best = ("exact", 1.0, other)
|
|
472
|
+
break
|
|
473
|
+
if not (features["near_ok"] and other["near_ok"]):
|
|
474
|
+
continue
|
|
475
|
+
theirs = other["shingles"] - common
|
|
476
|
+
if not mine or min(len(mine), len(theirs)) < NEAR_JACCARD * max(len(mine), len(theirs)):
|
|
477
|
+
continue
|
|
478
|
+
inter = len(mine & theirs)
|
|
479
|
+
score = inter / (len(mine) + len(theirs) - inter)
|
|
480
|
+
if score >= NEAR_JACCARD and (best is None or score > best[1]):
|
|
481
|
+
best = ("near", score, other)
|
|
482
|
+
if best:
|
|
483
|
+
matches.append({"name": name, "lines": f"{function['start']}-{function['end']}", "type": best[0],
|
|
484
|
+
"score": round(best[1], 2), "existing": _ref(best[2])})
|
|
485
|
+
return matches
|
package/edit_preview.py
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"""The files an agent edit would produce, computed before the edit runs: Claude Code Edit, MultiEdit and Write, and
|
|
2
|
+
Codex apply_patch (*** Begin Patch / Update File / Add File). Returns (absolute path, text before or None, text after)
|
|
3
|
+
for each file it can rebuild; an edit it cannot rebuild yields nothing, so a hook built on it never blocks wrongly."""
|
|
4
|
+
import os
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def _read(path):
|
|
8
|
+
try:
|
|
9
|
+
with open(path, encoding="utf-8") as stream:
|
|
10
|
+
return stream.read()
|
|
11
|
+
except (OSError, UnicodeDecodeError):
|
|
12
|
+
return None
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _replace(text, old, new, every):
|
|
16
|
+
if not old or old not in text:
|
|
17
|
+
return None
|
|
18
|
+
return text.replace(old, new) if every else text.replace(old, new, 1)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _claude(tool, data):
|
|
22
|
+
path = data.get("file_path")
|
|
23
|
+
if not isinstance(path, str) or not os.path.isabs(path):
|
|
24
|
+
return []
|
|
25
|
+
before = _read(path)
|
|
26
|
+
if tool == "Write":
|
|
27
|
+
content = data.get("content")
|
|
28
|
+
return [(path, before, content)] if isinstance(content, str) else []
|
|
29
|
+
if before is None:
|
|
30
|
+
return []
|
|
31
|
+
edits = data.get("edits") if tool == "MultiEdit" else [data]
|
|
32
|
+
after = before
|
|
33
|
+
for edit in edits or []:
|
|
34
|
+
after = _replace(after, edit.get("old_string"), edit.get("new_string", ""), edit.get("replace_all") is True)
|
|
35
|
+
if after is None:
|
|
36
|
+
return []
|
|
37
|
+
return [(path, before, after)]
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _apply_hunks(text, hunks):
|
|
41
|
+
lines = text.splitlines()
|
|
42
|
+
for old, new in hunks:
|
|
43
|
+
if not old:
|
|
44
|
+
lines.extend(new)
|
|
45
|
+
continue
|
|
46
|
+
at = next((i for i in range(len(lines) - len(old) + 1) if lines[i:i + len(old)] == old), None)
|
|
47
|
+
if at is None:
|
|
48
|
+
return None
|
|
49
|
+
lines[at:at + len(old)] = new
|
|
50
|
+
return "\n".join(lines) + ("\n" if text.endswith("\n") or not text else "")
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _codex(patch, cwd):
|
|
54
|
+
if not isinstance(patch, str) or "*** Begin Patch" not in patch:
|
|
55
|
+
return []
|
|
56
|
+
files, current = [], None
|
|
57
|
+
for raw in patch.splitlines():
|
|
58
|
+
if raw.startswith(("*** Update File: ", "*** Add File: ")):
|
|
59
|
+
current = {"action": raw.split(":", 1)[0][4:], "path": raw.split(":", 1)[1].strip(), "hunks": [[[], []]]}
|
|
60
|
+
files.append(current)
|
|
61
|
+
elif raw.startswith(("*** Delete File: ", "*** End Patch", "*** Move to: ")) or current is None:
|
|
62
|
+
current = None if raw.startswith(("*** Delete File: ", "*** End Patch")) else current
|
|
63
|
+
elif raw.startswith("@@"):
|
|
64
|
+
current["hunks"].append([[], []])
|
|
65
|
+
elif raw.startswith("+"):
|
|
66
|
+
current["hunks"][-1][1].append(raw[1:])
|
|
67
|
+
elif raw.startswith("-"):
|
|
68
|
+
current["hunks"][-1][0].append(raw[1:])
|
|
69
|
+
elif raw.startswith(" ") or raw == "":
|
|
70
|
+
current["hunks"][-1][0].append(raw[1:])
|
|
71
|
+
current["hunks"][-1][1].append(raw[1:])
|
|
72
|
+
results = []
|
|
73
|
+
for entry in files:
|
|
74
|
+
path = entry["path"] if os.path.isabs(entry["path"]) else os.path.join(cwd or os.getcwd(), entry["path"])
|
|
75
|
+
hunks = [hunk for hunk in entry["hunks"] if hunk[0] or hunk[1]]
|
|
76
|
+
if entry["action"] == "Add File":
|
|
77
|
+
results.append((path, None, "\n".join(line for hunk in hunks for line in hunk[1]) + "\n"))
|
|
78
|
+
continue
|
|
79
|
+
before = _read(path)
|
|
80
|
+
after = _apply_hunks(before, hunks) if before is not None else None
|
|
81
|
+
if after is not None:
|
|
82
|
+
results.append((path, before, after))
|
|
83
|
+
return results
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def preview(tool_name, tool_input, cwd=None):
|
|
87
|
+
data = tool_input if isinstance(tool_input, dict) else {}
|
|
88
|
+
if tool_name in ("Edit", "MultiEdit", "Write") and "file_path" in data:
|
|
89
|
+
return _claude(tool_name, data)
|
|
90
|
+
if tool_name in ("apply_patch", "Edit", "Write") and "command" in data:
|
|
91
|
+
return _codex(data.get("command"), cwd)
|
|
92
|
+
return []
|