@allansantos-dev/smart-tool 0.9.4 → 0.9.5
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 +23 -0
- package/affected_tests.py +89 -6
- package/code_graph.py +1 -1
- package/package.json +1 -1
- package/smart_tool_daemon.py +25 -3
- package/version.py +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,28 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.9.5 - beta
|
|
4
|
+
|
|
5
|
+
Reported by the claude-code-boss session on its first day using 0.9.4.
|
|
6
|
+
|
|
7
|
+
- `affected_tests` answered "no tests" for a module covered by a 1.4 MB test file (1,427 cases): the file was over the
|
|
8
|
+
index size limit and the working-tree reading skipped files over 1 MB, so it fell out of the import graph silently.
|
|
9
|
+
Files up to 5 MB are now analyzed for the graph, and any tracked test file still outside it is selected ("may cover
|
|
10
|
+
the change") instead of disappearing.
|
|
11
|
+
- Tests that run a script by path (`spawnSync('node', [path.join(SCRIPTS, 'hook.js')])`, `subprocess.run(["python",
|
|
12
|
+
"tools/x.py"])`) now depend on that script: a file name in a string literal of a test, resolved against the test's
|
|
13
|
+
folder, the folders above it and the project root, links them.
|
|
14
|
+
- A test is left out of the list only when it has no test cases (`conftest.py`, fixtures, helpers). The former rule
|
|
15
|
+
("imported by another test") dropped real Java tests that share test types: on gson it caught 19 of 28 injected
|
|
16
|
+
faults; now 28 of 28. JUnit 3 suites (`@RunWith(AllTests.class)`, `static Test suite()`) count as tests.
|
|
17
|
+
- Test files named in `package.json` scripts (`"test": "node scripts/test-units.js"`) get a `node <file>` command.
|
|
18
|
+
- An anonymous callback among the touched functions is named after the function that contains it.
|
|
19
|
+
- `project_manage register` and `status` return the project with its last jobs summarized and without the scope's
|
|
20
|
+
folder structure and profile groups (`scope_summary` keeps include/exclude): they returned the full result of every
|
|
21
|
+
past search (108-128 thousand characters on one project, now 3.3 thousand). The projects screen keeps the full
|
|
22
|
+
payload.
|
|
23
|
+
- A project is no longer marked `degraded` because the per-chunk function analysis was still being prepared: that note
|
|
24
|
+
is shown with the search results but is not a degradation.
|
|
25
|
+
|
|
3
26
|
## 0.9.4 - beta
|
|
4
27
|
|
|
5
28
|
Found by using 0.9.3 on its own repository right after installing it.
|
package/affected_tests.py
CHANGED
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
"""Tests to run for the current changes: git diff against a base (default HEAD, so staged, unstaged and untracked
|
|
2
2
|
files), then every test file that imports a changed file directly or through other files, plus changed tests, ordered
|
|
3
3
|
so the most likely failures come first (tests calling a touched function, test file named after the function or the
|
|
4
|
-
module, import distance). Test support files (conftest.py, helpers
|
|
5
|
-
not listed to run. Selection by file imports, not by calls: in fault-injection measurements on Python,
|
|
4
|
+
module, import distance). Test support files (conftest.py, fixtures and helpers without test cases) carry the
|
|
5
|
+
selection but are not listed to run. Selection by file imports, not by calls: in fault-injection measurements on Python,
|
|
6
6
|
JavaScript, TypeScript and Java projects static calls alone found 2 to 37% of the failing test files."""
|
|
7
7
|
import ast
|
|
8
8
|
import json
|
|
9
9
|
import os
|
|
10
|
+
import posixpath
|
|
10
11
|
import re
|
|
11
12
|
import subprocess
|
|
12
13
|
from collections import defaultdict, deque
|
|
@@ -30,6 +31,8 @@ NOTE = ("Selection from the indexed import graph: tests that load code through s
|
|
|
30
31
|
"suite before committing; run_all is true when a changed file can change every test (config, lockfile, "
|
|
31
32
|
"unanalyzed code). Changed files and files committed after the last indexing are read from disk, so new "
|
|
32
33
|
"files and imports count before reindexing; pytest fixtures link a test to the conftest.py that defines them.")
|
|
34
|
+
TEST_SOURCES = (".py", ".js", ".jsx", ".ts", ".tsx", ".mjs", ".cjs", ".mts", ".cts", ".java")
|
|
35
|
+
_PATH_LITERAL = re.compile(r"""['"`]((?:\.{0,2}/)?[\w.-]+(?:/[\w.-]+)*\.(?:py|js|mjs|cjs|ts|mts|cts|java))['"`]""")
|
|
33
36
|
_HUNK = re.compile(r"^@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@", re.M)
|
|
34
37
|
|
|
35
38
|
|
|
@@ -122,6 +125,10 @@ def _commands(root, tests):
|
|
|
122
125
|
runner = next((cmd for key, cmd in (("vitest", "npx vitest run"), ("jest", "npx jest"),
|
|
123
126
|
("mocha", "npx mocha"), ("node --test", "node --test"))
|
|
124
127
|
if key in tools), None)
|
|
128
|
+
local = os.path.relpath(path, folder or ".").replace(os.sep, "/")
|
|
129
|
+
scripts = " ".join(str(v) for v in (package.get("scripts") or {}).values())
|
|
130
|
+
if runner is None and re.search(rf"\bnode\s+(\./)?{re.escape(local)}\b", scripts):
|
|
131
|
+
runner = "node-script"
|
|
125
132
|
groups[(folder or "", runner)].append(path)
|
|
126
133
|
elif lower.endswith(".java"):
|
|
127
134
|
folder, name = _nearest(root, path, ("pom.xml", "build.gradle", "build.gradle.kts"))
|
|
@@ -138,6 +145,9 @@ def _commands(root, tests):
|
|
|
138
145
|
if runner == "maven":
|
|
139
146
|
names = ",".join(sorted({os.path.basename(f).rsplit(".", 1)[0] for f in files}))
|
|
140
147
|
line = f"mvn test -Dtest={names} -Dsurefire.failIfNoSpecifiedTests=false"
|
|
148
|
+
elif runner == "node-script":
|
|
149
|
+
commands.extend({"cwd": folder or ".", "command": f"node {f}"} for f in sorted(local))
|
|
150
|
+
continue
|
|
141
151
|
elif runner == "unittest-package":
|
|
142
152
|
line = "python -m unittest " + " ".join(sorted(f[:-3].replace("/", ".") for f in files))
|
|
143
153
|
elif runner == "unittest-folder":
|
|
@@ -194,6 +204,75 @@ def _fixture_users(root, test_files):
|
|
|
194
204
|
return users
|
|
195
205
|
|
|
196
206
|
|
|
207
|
+
def _path_literal_users(root, test_files, known):
|
|
208
|
+
"""Code files a test names in a string literal ('graph-guard.js', "scripts/run.py"), resolved against the test's
|
|
209
|
+
folder, the folders above it and the project root: tests that run scripts as subprocesses or load them by path
|
|
210
|
+
(spawnSync('node', [path.join(SCRIPTS, 'hook.js')])) depend on them without importing them."""
|
|
211
|
+
users = defaultdict(set)
|
|
212
|
+
for test in test_files:
|
|
213
|
+
if not test.lower().endswith(TEST_SOURCES):
|
|
214
|
+
continue
|
|
215
|
+
source = _read(os.path.join(root, test))
|
|
216
|
+
folders, folder = [], os.path.dirname(test)
|
|
217
|
+
while True:
|
|
218
|
+
folders.append(folder)
|
|
219
|
+
if not folder:
|
|
220
|
+
break
|
|
221
|
+
folder = os.path.dirname(folder)
|
|
222
|
+
for literal in set(_PATH_LITERAL.findall(source)):
|
|
223
|
+
for base in folders:
|
|
224
|
+
candidate = posixpath.normpath(posixpath.join(base, literal.removeprefix("./")))
|
|
225
|
+
if candidate in known and candidate != test:
|
|
226
|
+
users[candidate].add(test)
|
|
227
|
+
break
|
|
228
|
+
return users
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
_TEST_CASES = {
|
|
232
|
+
".py": re.compile(r"^\s*(async\s+)?def\s+test\w*\s*\(|^\s*class\s+Test\w*|unittest\.TestCase", re.M),
|
|
233
|
+
".java": re.compile(r"@(Test|ParameterizedTest|RepeatedTest|TestFactory|TestTemplate|RunWith|Suite)\b|"
|
|
234
|
+
r"\bstatic\s+\w*Test\s+suite\s*\(|\bextends\s+TestCase\b|\bvoid\s+test\w*\s*\("),
|
|
235
|
+
"script": re.compile(r"\b(it|test|describe|suite)(\.\w+)?\s*\(|\bDeno\.test\b|\bnode:test\b|\bassert\b"),
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def _support(root, path):
|
|
240
|
+
"""Test-kind file without test cases (conftest.py, fixtures, helpers): it carries the selection but is not run.
|
|
241
|
+
A test referenced by another test (shared Java test types) is still a test."""
|
|
242
|
+
if os.path.basename(path) == "conftest.py":
|
|
243
|
+
return True
|
|
244
|
+
ext = os.path.splitext(path)[1].lower()
|
|
245
|
+
pattern = _TEST_CASES.get(ext) or (_TEST_CASES["script"] if ext in TEST_SOURCES else None)
|
|
246
|
+
source = _read(os.path.join(root, path))
|
|
247
|
+
return bool(pattern and source and not pattern.search(source))
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def _tracked_tests_outside_graph(root, parsed, profile):
|
|
251
|
+
"""Code test files git tracks that the graph could not analyze (over the size limits, unreadable): their imports
|
|
252
|
+
are unknown, so they are selected instead of silently missing (a 1.4 MB test file covering 1,427 cases did)."""
|
|
253
|
+
listed = subprocess.run(["git", "ls-files", "-z"], cwd=root, capture_output=True, timeout=30)
|
|
254
|
+
if listed.returncode:
|
|
255
|
+
return []
|
|
256
|
+
tests = []
|
|
257
|
+
for rel in listed.stdout.decode("utf-8", "replace").split("\0"):
|
|
258
|
+
if rel and rel not in parsed and rel.lower().endswith(TEST_SOURCES) and os.path.basename(rel) != "conftest.py" \
|
|
259
|
+
and index_profile.kind(rel, profile) == "test" and os.path.isfile(os.path.join(root, rel)):
|
|
260
|
+
tests.append(rel)
|
|
261
|
+
return tests
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def _label(symbol, by_id):
|
|
265
|
+
"""path::name of a touched function; an anonymous callback is named after the function that contains it."""
|
|
266
|
+
short = symbol["name"].split("(")[0].split(".")[-1]
|
|
267
|
+
if short in ("callback", "anonymous"):
|
|
268
|
+
parent = by_id.get(symbol.get("parent"))
|
|
269
|
+
while parent and parent["name"].split("(")[0].split(".")[-1] in ("callback", "anonymous"):
|
|
270
|
+
parent = by_id.get(parent.get("parent"))
|
|
271
|
+
if parent:
|
|
272
|
+
return f"{symbol['path']}::{parent['name']} (callback at line {symbol['start_line']})"
|
|
273
|
+
return f"{symbol['path']}::{symbol['name']}"
|
|
274
|
+
|
|
275
|
+
|
|
197
276
|
def affected(root, base="HEAD", limit=30, view_id=None):
|
|
198
277
|
"""Test files to run for the changes since base, most likely failures first, with the command to run them."""
|
|
199
278
|
if not isinstance(base, str) or not re.fullmatch(r"[\w./@^~{}-]{1,200}", base) or base.startswith("-"):
|
|
@@ -217,6 +296,8 @@ def affected(root, base="HEAD", limit=30, view_id=None):
|
|
|
217
296
|
callers[call["target"]].add(call["source"])
|
|
218
297
|
for conftest, users in _fixture_users(root, test_files).items():
|
|
219
298
|
importers[conftest] |= users
|
|
299
|
+
for target, users in _path_literal_users(root, test_files, {f["path"] for f in data.get("files") or []}).items():
|
|
300
|
+
importers[target] |= users
|
|
220
301
|
by_id = {s["id"]: s for s in data.get("symbols") or []}
|
|
221
302
|
run_all, not_analyzed, changed_code, changed_tests, conftests = [], [], [], [], []
|
|
222
303
|
for path, change in sorted(changes.items()):
|
|
@@ -260,9 +341,11 @@ def affected(root, base="HEAD", limit=30, view_id=None):
|
|
|
260
341
|
selected.setdefault(test, f"{conftest} changed")
|
|
261
342
|
for test in set(import_depth) | set(call_hops):
|
|
262
343
|
selected.setdefault(test, None)
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
344
|
+
selected = {test: reason for test, reason in selected.items() if not _support(root, test)}
|
|
345
|
+
if changed_code or conftests:
|
|
346
|
+
for test in _tracked_tests_outside_graph(root, parsed, profile):
|
|
347
|
+
selected.setdefault(test, "test file outside the import graph (too large or not analyzed): may cover "
|
|
348
|
+
"the change")
|
|
266
349
|
|
|
267
350
|
def why(test):
|
|
268
351
|
if selected[test]:
|
|
@@ -283,7 +366,7 @@ def affected(root, base="HEAD", limit=30, view_id=None):
|
|
|
283
366
|
"base": base,
|
|
284
367
|
"changed": {"code": changed_code[:limit], "tests": changed_tests[:limit],
|
|
285
368
|
"other": sorted(p for p in changes if p not in changed_code and p not in changed_tests)[:limit]},
|
|
286
|
-
"touched_functions": [
|
|
369
|
+
"touched_functions": [_label(s, by_id) for s in touched][:limit],
|
|
287
370
|
"tests": [{"path": t, "why": why(t)} for t in ordered[:limit]],
|
|
288
371
|
"counts": {"changed_files": len(changes), "tests": len(ordered), "test_files": len(test_files)},
|
|
289
372
|
"run_all": bool(run_all),
|
package/code_graph.py
CHANGED
|
@@ -23,7 +23,7 @@ import document_text
|
|
|
23
23
|
|
|
24
24
|
VERSION = 2
|
|
25
25
|
SOURCE_EXTENSIONS = ('.py','.java','.js','.jsx','.ts','.tsx','.mjs','.cjs','.mts','.cts','.json','.html','.htm','.css','.scss','.sass','.less','.md','.markdown')
|
|
26
|
-
MAX_OVERLAY_BYTES = 1024 * 1024
|
|
26
|
+
MAX_OVERLAY_BYTES = 5 * 1024 * 1024
|
|
27
27
|
MAX_FILES = 3000
|
|
28
28
|
MAX_TEXT = 24 * 1024 * 1024
|
|
29
29
|
MAX_SYMBOLS = 8000
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@allansantos-dev/smart-tool",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.5",
|
|
4
4
|
"description": "Local MCP server that gives coding agents (Claude Code, Codex) cheaper, sharper tools than their built-in search. Windows.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"author": "Allan Santos",
|
package/smart_tool_daemon.py
CHANGED
|
@@ -968,8 +968,10 @@ def _perform_smart_search_isolated(arguments, job_id):
|
|
|
968
968
|
else ({}, None))
|
|
969
969
|
except Exception as exc:
|
|
970
970
|
symbol_index, symbol_note = None, f"Function analysis failed: {_project_error(exc)}"
|
|
971
|
+
notes = []
|
|
971
972
|
if symbol_index is None and any(group != "doc" for group in groups):
|
|
972
|
-
warnings
|
|
973
|
+
(warnings if symbol_note else notes).append(
|
|
974
|
+
symbol_note or "Per-chunk functions are being prepared; they will show on the next search.")
|
|
973
975
|
elif symbol_note or (symbol_index or {}).get("note"):
|
|
974
976
|
warnings.append(symbol_note or symbol_index["note"])
|
|
975
977
|
query_terms = _symbol_terms(" ".join(queries[group] for group in groups if group != "doc"))
|
|
@@ -1013,7 +1015,7 @@ def _perform_smart_search_isolated(arguments, job_id):
|
|
|
1013
1015
|
warning = " ".join(warnings)
|
|
1014
1016
|
if warning:
|
|
1015
1017
|
_update_job(job_id, warning=warning)
|
|
1016
|
-
return _grouped_yaml(index_profile.public(profile), blocks,
|
|
1018
|
+
return _grouped_yaml(index_profile.public(profile), blocks, " ".join(warnings + notes))
|
|
1017
1019
|
|
|
1018
1020
|
|
|
1019
1021
|
def _perform_project_index(arguments, job_id, preview=False):
|
|
@@ -1717,11 +1719,31 @@ def _project_action_locked(arguments):
|
|
|
1717
1719
|
return {"project": _project_payload(project_store.get(key))}
|
|
1718
1720
|
|
|
1719
1721
|
|
|
1722
|
+
def _for_agent(result):
|
|
1723
|
+
"""register/status for an agent: the project without the full result of every past job, the folder structure of
|
|
1724
|
+
the scope and the profile groups (one project returned 128 thousand characters, more than an MCP client shows);
|
|
1725
|
+
scope_summary keeps include/exclude. The projects screen keeps the full payload."""
|
|
1726
|
+
project = result.get("project") if isinstance(result, dict) else None
|
|
1727
|
+
if isinstance(project, dict) and isinstance(project.get("jobs"), list):
|
|
1728
|
+
project["jobs"] = [{key: job.get(key) for key in ("job_id", "status", "kind", "phase", "updated_at", "warning",
|
|
1729
|
+
"error") if job.get(key) not in (None, "")}
|
|
1730
|
+
for job in project["jobs"][:5]]
|
|
1731
|
+
if isinstance(project, dict):
|
|
1732
|
+
if isinstance(project.get("preview"), dict):
|
|
1733
|
+
project["preview"] = {key: project["preview"].get(key)
|
|
1734
|
+
for key in ("included_files", "eligible_files", "bytes", "skipped")}
|
|
1735
|
+
project.pop("scope", None)
|
|
1736
|
+
return result
|
|
1737
|
+
|
|
1738
|
+
|
|
1720
1739
|
def _handle_project_manage(arguments):
|
|
1721
1740
|
# MCP não abre janelas; o seletor nativo pertence apenas à interface local.
|
|
1722
1741
|
if isinstance(arguments, dict) and arguments.get("action") == "pick":
|
|
1723
1742
|
raise ValueError("Use register with project_root or pick the folder from the tray.")
|
|
1724
|
-
|
|
1743
|
+
result = _project_action(arguments)
|
|
1744
|
+
if isinstance(arguments, dict) and arguments.get("action") in ("register", "status"):
|
|
1745
|
+
result = _for_agent(result)
|
|
1746
|
+
return _to_json_sanitized(result)
|
|
1725
1747
|
|
|
1726
1748
|
|
|
1727
1749
|
def _enqueue_monitored_project(project_id, **options):
|
package/version.py
CHANGED