@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/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+|[^\s\w]")
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 _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)))
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
- text = "\n".join(file_lines[s["start_line"] - 1:s["end_line"]])
116
- if not text.strip():
173
+ entry = _entry(path, s["name"], s["start_line"], s["end_line"], file_lines, ext)
174
+ if entry is None:
117
175
  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)})
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
- order = sorted(range(len(functions)), key=lambda i: len(functions[i]["shingles"]))
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 = functions[a]["shingles"]
290
+ sa = cut[a]
191
291
  for b in order[position + 1:]:
192
- sb = functions[b]["shingles"]
292
+ sb = cut[b]
193
293
  if len(sb) * NEAR_JACCARD > len(sa):
194
294
  break
195
- if functions[a]["hash"] == functions[b]["hash"]:
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
@@ -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 []