@allansantos-dev/smart-tool 0.9.1 → 0.9.3

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 CHANGED
@@ -1,5 +1,82 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.9.3 - beta
4
+
5
+ - Camoufox download (1.3 GB) on networks that cut long transfers (seen stopping at 500-530 MB on every attempt): it is
6
+ now fetched in 32 MB Range requests, each on a new connection, resuming from the last byte received after a dropped
7
+ or stalled connection, and gives up only after 6 attempts in a row without a new byte. camoufox still verifies the
8
+ sha256 published for the release before extracting. The downloader uses the standard library, which on Windows
9
+ trusts the system certificate store (including a corporate TLS inspection CA) and honors HTTPS_PROXY.
10
+ - `project_manage graph` with `symbol` (function, `Class.method`, class, or `path::name` when the name repeats): where
11
+ it is defined, its callers with line, what it calls, the tests that reach it through static calls (`depth` hops,
12
+ default 3) and the files importing its module, so an agent knows what to update before an edit. With `file_path`, the graph caps its lists by `limit` and drops display-only data.
13
+ - Documentation on edit (setup, Agents): the hook checks the functions a Claude Code Edit/MultiEdit/Write or a Codex
14
+ apply_patch touches. Remind (default) tells the agent which ones have no docstring and which documented ones changed
15
+ their signature, with callers; Require blocks an edit that leaves a touched function without a
16
+ docstring; Off disables it. Body-only edits of documented functions stay silent. The hook matcher now includes the
17
+ edit tools: reinstall the hook from the setup screen (it shows as outdated).
18
+ - Documentation on edit asks only for public functions: private (`_name`, `#name`, `private`), nested, callback and
19
+ override (`@Override`, `@override`) functions are exempt, following pydocstyle, eslint-plugin-jsdoc and Checkstyle
20
+ defaults, and a JSDoc above TypeScript overload signatures documents the implementation. On remeda, gson, express
21
+ and click the former rule asked for docstrings on every overload implementation, callback and `@Override` method.
22
+ TypeScript, JavaScript and Java edits were never checked on Windows (the file was looked up by its absolute path in
23
+ the analyzer output, which normalizes it); they are now.
24
+ - Duplicate functions on edit (setup, Agents; Warn by default, Off): when an edit writes a function whose body is
25
+ identical (comments, spacing, docstrings ignored) or near-identical (only local names, strings and numbers changed)
26
+ to an indexed one, the agent is told where the original is so it can reuse it; the edit is never blocked. Measured
27
+ on click, express, remeda and gson: every exact and locally renamed copy found, no warning when each real function
28
+ is written as it is.
29
+ - `project_manage action=affected_tests`: tests to run for the uncommitted changes (or against `base`): every test
30
+ file importing a changed file, directly or transitively, plus changed tests, likeliest failures first (tests calling
31
+ a touched function, test files named after the function or module, import distance), with the command per runner
32
+ (pytest, unittest, vitest, jest, mocha, node --test, Maven, Gradle). `run_all` with the reason when configuration,
33
+ lockfiles or code outside the import graph changed. In fault-injection runs (30 faults each), selection by imports
34
+ caught every failing test file in express and all but timing-flaky ones in remeda; static calls alone, as the
35
+ `graph` impact lists them, found 2 to 37%.
36
+ Changed files are read from the working tree, so a new file or a new import counts before the project is reindexed (in on_search mode the index only updates on a search), and a test that takes a pytest fixture from a
37
+ conftest.py depends on what that conftest imports (a change in click's `testing.py` selected 4 of the 20 failing
38
+ test files; now all 20).
39
+ - Near-duplicate detection (`action=duplicates` and the edit hook) keeps keywords, called names, attributes and types
40
+ and abstracts only local names, strings and numbers, compares functions of the same language only and skips
41
+ overloads and language idioms: parallel functions such as `get_text_stdin`/`get_text_stdout` or
42
+ `rotateLeft`/`rotateRight` are no longer reported (near pairs fell from 5-14% of functions to 0-1.6%).
43
+ - Code graph: `require('../')` resolved to a wrong absolute path, and workspace packages imported by name
44
+ (`import { x } from "my-lib"` in a monorepo) were treated as external; both now resolve to the package source. Vitest
45
+ type tests (`*.test-d.ts`, `*.spec-d.ts`) are classified as tests. The TypeScript and Java analyzers retry with a
46
+ 4 GB heap when 1 GB is not enough (larger projects ended with no symbols).
47
+ - `web_fetch` on pages over 60,000 characters (14.6% of cached pages, 10.4% of real calls) no longer keeps only the
48
+ start: the whole page is cached (up to 1,000,000 characters) and the model reads the 2,000-character pieces most
49
+ similar to the prompt, up to 15,000 characters, in page order. Measured on 18 real pages and 41 questions: answers
50
+ about text after the old cut 0/28 → 13/28. Pieces are embedded once per page and embedding model (first read of a
51
+ 75-256k page adds 2.6-6.8 s, later questions about 1 s); without a working `embedding_model` such a page fails
52
+ with the reason instead of being cut silently. Pages up to 60,000 characters are read whole, as before (selection
53
+ there tied on quality and added latency).
54
+ - Hook metrics: concurrent hook calls from Claude Code and Codex could be logged with each other's client name.
55
+ - Web search health log tells a provider 429 (`rate_limited`), a captcha and a self-imposed quota pause (`paused`, not
56
+ counted against the provider) apart from an ordinary failure.
57
+ - `project_manage action=docs`: docstring coverage of the indexed code (public functions without a docstring per file,
58
+ coverage percent; tests only with `include_tests`), for documenting a project that started without them.
59
+ - Impact results (`graph` with `symbol`) carry the first docstring line of the definition, callers, calls and tests.
60
+ - `project_manage` for agents: every action takes `project_root` instead of `project_id`; `list` returns one short line
61
+ per project (it returned the full jobs, scope and preview of every project, about 1 MB with 14 projects, more than
62
+ an agent can read; the projects screen still gets the full list); an index job's result is no longer repeated as
63
+ `stats`.
64
+
65
+ ## 0.9.2 - beta
66
+
67
+ - Installer reported success after a broken Camoufox download: camoufox 0.5.6 prints the error of `fetch` and exits 0.
68
+ The installer now catches that error and fails the step (retried 3 times), every request of the download has a
69
+ 60 s read timeout (a stalled download used to wait until the 3-minute idle limit), and the final check requires the
70
+ Camoufox and Chromium executables on disk, so an incomplete browser can never end in "Smart Tool ready".
71
+ - Setup screen: text, model and adapter fields were unstyled boxes (inputs without a type), selects and model fields
72
+ now look like comboboxes; the hook change opens inside the client's card in a scrollable block with Confirm and
73
+ Cancel instead of overflowing the page; "Save behavior" works without registering the MCP first; web search keys
74
+ are set one provider at a time from a provider selector instead of a stack of key fields.
75
+ - Product page: the display font was a family that Google Fonts does not serve (titles fell back to Arial Narrow);
76
+ it now uses Big Shoulders Display. The page shows the current version (a test keeps it in sync with version.py),
77
+ links to the release notes and says what the installer does on failures and older installations. A failed Pages
78
+ deploy is retried once, the run fails unless the live page shows the version, and every release republishes it.
79
+
3
80
  ## 0.9.1 - beta
4
81
 
5
82
  - Installer on a regular Windows code page (cp1252): the steps' output was garbled or crashed the step; every child
package/README.md CHANGED
@@ -6,8 +6,8 @@ A local MCP server that gives coding agents (Claude Code, Codex) cheaper, sharpe
6
6
  |---|---|
7
7
  | `smart_search` / `smart_search_result` | Semantic + lexical search over an indexed project, results split into code, tests and docs, with the likeliest functions per chunk |
8
8
  | `web_search` / `web_search_result` | Web search across several sources in parallel, in the query language and in English, with a cache shared across sessions; `depth="research"` runs a multi-round research loop |
9
- | `web_fetch` | Reads a known URL and returns only the answer to your prompt, written by a small model; pages are cached for 24 h |
10
- | `project_manage` | Registers and indexes projects, shows coverage, code maps, duplicated functions and index storage |
9
+ | `web_fetch` | Reads a known URL and returns only the answer to your prompt, written by a small model; pages are cached for 24 h. Pages over 60,000 characters are not cut: the model reads the parts closest to the prompt (needs an `embedding_model`) |
10
+ | `project_manage` | Registers and indexes projects, shows coverage, code maps, duplicated functions and index storage; `graph` with `symbol` tells what changing a function touches: callers, calls, the tests that reach it and the files that import it |
11
11
 
12
12
  An optional hook routes the agent's native Grep/Glob/Read/Bash/WebSearch/WebFetch calls to these tools when they
13
13
  are cheaper, or just tells the agent what Smart Tool would do.
@@ -53,6 +53,31 @@ Open the setup screen from the tray icon ("Open settings") or at `http://127.0.0
53
53
  - **Redirect** (default): the native tool is denied with the reason and the Smart Tool tool to use.
54
54
  - **Advise only**: the native tool runs; the agent receives the same advice next to the result and decides.
55
55
  - **Off**: no routing.
56
+
57
+ **Documentation on edit** checks the public functions an edit touches (Python docstrings, JSDoc, Javadoc), in
58
+ Claude Code edits and Codex patches. Private (`_name`, `#name`, `private`), nested and override (`@Override`,
59
+ `@override`) functions are exempt, as in pydocstyle, eslint-plugin-jsdoc and Checkstyle, and a JSDoc above a set of
60
+ TypeScript overloads documents the implementation:
61
+ - **Remind** (default): the edit runs and the agent is told which touched functions have no docstring, and which
62
+ documented ones changed their signature, with their callers.
63
+ - **Require**: an edit that leaves a touched function without a docstring is blocked until it adds one.
64
+ - **Off**: no checks.
65
+
66
+ A documented function edited only in its body is not mentioned. `project_manage` with `action=docs` lists the
67
+ public functions still without a docstring, for documenting a project that started without them.
68
+
69
+ **Duplicate functions on edit** compares the functions an edit writes with the indexed code. **Warn** (default)
70
+ tells the agent when a written function has the same body as an existing one (comments, spacing and docstrings
71
+ ignored) or a near-identical one (only local names, strings and numbers changed), with the path and lines of the
72
+ original to reuse; it never blocks. Editing a function in place, overloads, tests, generated files and small
73
+ functions (under 5 lines or 50 tokens) are not checked. **Off** disables it.
74
+
75
+ After editing, `project_manage` with `action=affected_tests` lists the tests to run: every test file that imports a
76
+ changed file, directly or through other files (git diff against `base`, default `HEAD`, untracked files included),
77
+ plus changed tests and tests using a pytest fixture whose `conftest.py` imports the changed code, ordered with the
78
+ likeliest failures first, and the command to run them. Changed files are read from disk, so new files and imports
79
+ count before the project is reindexed. `run_all` is true, with
80
+ the reason, when a configuration or lockfile changed or a changed code file is outside the import graph.
56
81
  4. **Local models** (optional): if the mcp-memory embedding sidecar is installed on the machine, its local embedder
57
82
  can serve as a fallback (or preferred) embedding model.
58
83
  5. **Web search providers** work without keys on their free public tiers; add your own key for more volume. Set your
@@ -0,0 +1,292 @@
1
+ """Tests to run for the current changes: git diff against a base (default HEAD, so staged, unstaged and untracked
2
+ files), then every test file that imports a changed file directly or through other files, plus changed tests, ordered
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 other tests import) carry the selection but are
5
+ not listed to run. Selection by file imports, not by calls: in fault-injection measurements on Python,
6
+ JavaScript, TypeScript and Java projects static calls alone found 2 to 37% of the failing test files."""
7
+ import ast
8
+ import json
9
+ import os
10
+ import re
11
+ import subprocess
12
+ from collections import defaultdict, deque
13
+
14
+ import code_graph
15
+ import index_profile
16
+ import index_scope
17
+
18
+ RUN_ALL = re.compile(
19
+ r"(^|/)(package\.json|package-lock\.json|npm-shrinkwrap\.json|yarn\.lock|pnpm-lock\.yaml|pnpm-workspace\.yaml|"
20
+ r"bun\.lockb?|pyproject\.toml|setup\.cfg|setup\.py|pytest\.ini|tox\.ini|requirements[^/]*\.txt|poetry\.lock|"
21
+ r"uv\.lock|Pipfile(\.lock)?|tsconfig[^/]*\.json|jsconfig\.json|(jest|vitest|vite|babel)\.config\.[^/]+|"
22
+ r"\.babelrc|\.mocharc[^/]*|pom\.xml|build\.gradle(\.kts)?|settings\.gradle(\.kts)?|gradle\.properties|"
23
+ r"\.env[^/]*)$")
24
+ NOTE = ("Selection from the indexed import graph: tests that load code through strings (mock.patch targets, "
25
+ "importlib, require(variable), reflection, Spring scanning) or read data files are not seen. Run the full "
26
+ "suite before committing; run_all is true when a changed file can change every test (config, lockfile, "
27
+ "unanalyzed code). Changed files are read from the working tree, so new files and imports count before "
28
+ "reindexing; pytest fixtures link a test to the conftest.py that defines them.")
29
+ _HUNK = re.compile(r"^@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@", re.M)
30
+
31
+
32
+ def _git(root, *args):
33
+ result = subprocess.run(["git", *args], cwd=root, capture_output=True, timeout=60)
34
+ if result.returncode:
35
+ raise ValueError(f"git {' '.join(args[:2])} failed: {result.stderr.decode('utf-8', 'replace').strip()[:300]}")
36
+ return result.stdout.decode("utf-8", "replace")
37
+
38
+
39
+ def _changes(root, base):
40
+ """Project-relative changed paths with their status and the new-side changed lines (None = whole file)."""
41
+ top = _git(root, "rev-parse", "--show-toplevel").strip()
42
+ prefix = os.path.relpath(os.path.realpath(root), os.path.realpath(top)).replace(os.sep, "/")
43
+ prefix = "" if prefix == "." else prefix + "/"
44
+ _git(root, "rev-parse", "--verify", "--quiet", f"{base}^{{commit}}")
45
+ changes = {}
46
+ fields = _git(root, "diff", "--name-status", "-z", "--no-renames", base, "--", ".").split("\0")
47
+ for status, path in zip(fields[0::2], fields[1::2]):
48
+ if path.startswith(prefix):
49
+ changes[path[len(prefix):]] = {"status": "deleted" if status == "D" else "changed", "lines": set()}
50
+ for path in _git(root, "ls-files", "--others", "--exclude-standard", "-z", "--full-name", "--", ".").split("\0"):
51
+ if path and path.startswith(prefix):
52
+ changes[path[len(prefix):]] = {"status": "added", "lines": None}
53
+ diff = _git(root, "diff", "-U0", "--no-renames", "--relative", base, "--", ".")
54
+ for block in re.split(r"^diff --git ", diff, flags=re.M)[1:]:
55
+ target = re.search(r"^\+\+\+ b/(.+)$", block, re.M)
56
+ if target and target.group(1) in changes and changes[target.group(1)]["lines"] is not None:
57
+ for start, count in _HUNK.findall(block):
58
+ first, size = int(start), int(count or 1)
59
+ changes[target.group(1)]["lines"].update(range(first, first + max(size, 1)))
60
+ return changes
61
+
62
+
63
+ def _depths(start, reverse):
64
+ seen, frontier = {node: 0 for node in start}, deque(start)
65
+ while frontier:
66
+ node = frontier.popleft()
67
+ for nxt in reverse.get(node, ()):
68
+ if nxt not in seen:
69
+ seen[nxt] = seen[node] + 1
70
+ frontier.append(nxt)
71
+ return seen
72
+
73
+
74
+ def _nearest(root, start, names):
75
+ folder = os.path.dirname(start)
76
+ while True:
77
+ for name in names:
78
+ if os.path.isfile(os.path.join(root, folder, name)):
79
+ return folder, name
80
+ if not folder:
81
+ return None, None
82
+ folder = os.path.dirname(folder)
83
+
84
+
85
+ def _read(path):
86
+ try:
87
+ with open(path, encoding="utf-8") as stream:
88
+ return stream.read()
89
+ except (OSError, UnicodeDecodeError):
90
+ return ""
91
+
92
+
93
+ def _commands(root, tests):
94
+ """One command per runner and folder for the selected test files, or the reason none could be built."""
95
+ groups = defaultdict(list)
96
+ for path in tests:
97
+ lower = path.lower()
98
+ if lower.endswith(".py"):
99
+ folder, marker = _nearest(root, path, ("pytest.ini", "pyproject.toml", "setup.cfg", "tox.ini"))
100
+ pytest = (marker and "pytest" in _read(os.path.join(root, folder or "", marker))) \
101
+ or _nearest(root, path, ("conftest.py",))[1] \
102
+ or re.search(r"^\s*(import pytest|from pytest import)", _read(os.path.join(root, path)), re.M)
103
+ test_dir = os.path.dirname(path)
104
+ if pytest:
105
+ groups[(folder or "", "python -m pytest")].append(path)
106
+ elif os.path.isfile(os.path.join(root, test_dir, "__init__.py")):
107
+ groups[("", "unittest-package")].append(path)
108
+ else:
109
+ groups[(test_dir, "unittest-folder")].append(path)
110
+ elif lower.endswith((".js", ".jsx", ".ts", ".tsx", ".mjs", ".cjs", ".mts", ".cts")):
111
+ folder, _name = _nearest(root, path, ("package.json",))
112
+ try:
113
+ package = json.loads(_read(os.path.join(root, folder or "", "package.json")) or "{}")
114
+ except ValueError:
115
+ package = {}
116
+ tools = " ".join([str((package.get("scripts") or {}).get("test", ""))]
117
+ + list(package.get("devDependencies") or {}) + list(package.get("dependencies") or {}))
118
+ runner = next((cmd for key, cmd in (("vitest", "npx vitest run"), ("jest", "npx jest"),
119
+ ("mocha", "npx mocha"), ("node --test", "node --test"))
120
+ if key in tools), None)
121
+ groups[(folder or "", runner)].append(path)
122
+ elif lower.endswith(".java"):
123
+ folder, name = _nearest(root, path, ("pom.xml", "build.gradle", "build.gradle.kts"))
124
+ groups[(folder or "", "maven" if name == "pom.xml" else "gradle" if name else None)].append(path)
125
+ else:
126
+ groups[("", None)].append(path)
127
+ commands = []
128
+ for (folder, runner), files in sorted(groups.items(), key=lambda item: (item[0][0], str(item[0][1]))):
129
+ local = [os.path.relpath(f, folder or ".").replace(os.sep, "/") for f in files]
130
+ if runner is None:
131
+ commands.append({"cwd": folder or ".", "command": None, "files": local,
132
+ "reason": "No known test runner configured for these files."})
133
+ continue
134
+ if runner == "maven":
135
+ names = ",".join(sorted({os.path.basename(f).rsplit(".", 1)[0] for f in files}))
136
+ line = f"mvn test -Dtest={names} -Dsurefire.failIfNoSpecifiedTests=false"
137
+ elif runner == "unittest-package":
138
+ line = "python -m unittest " + " ".join(sorted(f[:-3].replace("/", ".") for f in files))
139
+ elif runner == "unittest-folder":
140
+ line = "python -m unittest " + " ".join(sorted(os.path.basename(f)[:-3] for f in files))
141
+ elif runner == "gradle":
142
+ wrapper = "./gradlew" if os.path.isfile(os.path.join(root, folder, "gradlew")) else "gradle"
143
+ line = wrapper + " test " + " ".join(f"--tests {os.path.basename(f).rsplit('.', 1)[0]}" for f in sorted(files))
144
+ else:
145
+ line = runner + " " + " ".join(sorted(local))
146
+ commands.append({"cwd": folder or ".", "command": line})
147
+ return commands
148
+
149
+
150
+ def _fixtures(source):
151
+ """Fixture names a conftest defines and whether one of them is autouse."""
152
+ names, autouse = set(), False
153
+ for node in ast.walk(ast.parse(source)):
154
+ if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
155
+ for decorator in node.decorator_list:
156
+ text = ast.unparse(decorator)
157
+ if "fixture" in text:
158
+ names.add(node.name)
159
+ autouse |= "autouse=True" in text.replace(" ", "")
160
+ return names, autouse
161
+
162
+
163
+ def _parameters(source):
164
+ return {arg.arg for node in ast.walk(ast.parse(source)) if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef))
165
+ for arg in node.args.posonlyargs + node.args.args + node.args.kwonlyargs}
166
+
167
+
168
+ def _fixture_users(root, test_files):
169
+ """conftest.py -> Python test files (and nested conftests) below it that take one of its fixtures as a parameter,
170
+ or all of them when it has an autouse fixture: pytest injects fixtures by name, without an import."""
171
+ users = defaultdict(set)
172
+ python = [p for p in test_files if p.endswith(".py")]
173
+ params = {}
174
+ for conftest in (p for p in python if os.path.basename(p) == "conftest.py"):
175
+ try:
176
+ names, autouse = _fixtures(_read(os.path.join(root, conftest)))
177
+ except SyntaxError:
178
+ continue
179
+ folder = os.path.dirname(conftest)
180
+ for path in python:
181
+ if path == conftest or folder and not path.startswith(folder + "/"):
182
+ continue
183
+ if path not in params:
184
+ try:
185
+ params[path] = _parameters(_read(os.path.join(root, path)))
186
+ except SyntaxError:
187
+ params[path] = set()
188
+ if autouse or names & params[path]:
189
+ users[conftest].add(path)
190
+ return users
191
+
192
+
193
+ def affected(root, base="HEAD", limit=30, view_id=None):
194
+ """Test files to run for the changes since base, most likely failures first, with the command to run them."""
195
+ if not isinstance(base, str) or not re.fullmatch(r"[\w./@^~{}-]{1,200}", base) or base.startswith("-"):
196
+ raise ValueError("base must be a git revision such as HEAD, main or origin/main.")
197
+ changes = _changes(root, base)
198
+ data = code_graph.build_overlay(root, {path: change["status"] for path, change in changes.items()}, view_id)
199
+ profile = index_profile.current((index_scope.load_scope(root) or {}).get("profile"))
200
+ kind = {}
201
+ for path in set(changes) | {f["path"] for f in data.get("files") or []}:
202
+ kind[path] = index_profile.kind(path, profile)
203
+ parsed = {f["path"] for f in data.get("files") or [] if f.get("analysis") == "parsed"}
204
+ test_files = {f["path"] for f in data.get("files") or [] if kind.get(f["path"]) == "test"}
205
+ importers, callers = defaultdict(set), defaultdict(set)
206
+ for dep in data.get("dependencies") or []:
207
+ if dep.get("target"):
208
+ importers[dep["target"]].add(dep["source"])
209
+ for call in data.get("calls") or []:
210
+ callers[call["target"]].add(call["source"])
211
+ for conftest, users in _fixture_users(root, test_files).items():
212
+ importers[conftest] |= users
213
+ by_id = {s["id"]: s for s in data.get("symbols") or []}
214
+ run_all, not_analyzed, changed_code, changed_tests, conftests = [], [], [], [], []
215
+ for path, change in sorted(changes.items()):
216
+ if RUN_ALL.search(path):
217
+ run_all.append(f"{path} changed (configuration or dependencies)")
218
+ elif os.path.basename(path) == "conftest.py":
219
+ conftests.append(path)
220
+ elif kind[path] == "test":
221
+ changed_tests.append(path)
222
+ elif kind[path] == "code":
223
+ changed_code.append(path)
224
+ if path not in parsed:
225
+ not_analyzed.append({"path": path, "status": change["status"],
226
+ "reason": "not analyzed (unsupported language, data file or outside the "
227
+ "index scope)"})
228
+ if not_analyzed:
229
+ run_all.append(f"{len(not_analyzed)} changed code file(s) outside the import graph; see not_analyzed")
230
+ touched = [s for s in by_id.values() if s["path"] in changes and s.get("kind") not in ("module", "class", "interface")
231
+ and (changes[s["path"]]["lines"] is None
232
+ or changes[s["path"]]["lines"].intersection(range(s["start_line"], s["end_line"] + 1)))]
233
+ touched = [s for s in touched if not any(o is not s and o["path"] == s["path"] and o in touched
234
+ and o["start_line"] >= s["start_line"] and o["end_line"] <= s["end_line"]
235
+ for o in touched)] or touched
236
+ import_depth = {f: d for f, d in _depths([p for p in changed_code if p in parsed], importers).items()
237
+ if f in test_files}
238
+ call_hops = {}
239
+ for sid, hops in _depths([s["id"] for s in touched], callers).items():
240
+ symbol = by_id.get(sid)
241
+ if symbol and symbol["path"] in test_files and hops:
242
+ call_hops[symbol["path"]] = min(hops, call_hops.get(symbol["path"], hops))
243
+ names = {s["name"].split("(")[0].split(".")[-1].lower() for s in touched}
244
+ names = {n for n in names if len(n) > 2 and n not in ("callback", "anonymous", "constructor")}
245
+ stems = {os.path.basename(p).split(".")[0].lower().lstrip("_") for p in changed_code}
246
+ selected = {p: "changed" for p in changed_tests}
247
+ for conftest in conftests:
248
+ folder = os.path.dirname(conftest)
249
+ for test in test_files:
250
+ if not folder or test.startswith(folder + "/"):
251
+ selected.setdefault(test, f"{conftest} changed")
252
+ for test in set(import_depth) | set(call_hops):
253
+ selected.setdefault(test, None)
254
+ support = {p for p in test_files if os.path.basename(p) == "conftest.py"
255
+ or any(source in test_files for source in importers.get(p, ()))}
256
+ selected = {test: reason for test, reason in selected.items() if test not in support}
257
+
258
+ def why(test):
259
+ if selected[test]:
260
+ return selected[test]
261
+ if test in call_hops:
262
+ return f"calls a changed function ({call_hops[test]} hop{'s' if call_hops[test] > 1 else ''})"
263
+ return f"imports a changed file ({import_depth[test]} step{'s' if import_depth[test] > 1 else ''})"
264
+
265
+ def rank(test):
266
+ base_name = os.path.basename(test).lower()
267
+ words = set(re.split(r"[^a-z0-9]+", base_name.rsplit(".", 1)[0]))
268
+ named = 0 if names & words or {n.replace("_", "") for n in names} & words else \
269
+ 1 if any(stem and stem in re.sub(r"[^a-z0-9]", "", base_name) for stem in stems) else 2
270
+ return (0 if selected[test] == "changed" else 1, call_hops.get(test, 99), named, import_depth.get(test, 99), test)
271
+
272
+ ordered = sorted(selected, key=rank)
273
+ result = {
274
+ "base": base,
275
+ "changed": {"code": changed_code[:limit], "tests": changed_tests[:limit],
276
+ "other": sorted(p for p in changes if p not in changed_code and p not in changed_tests)[:limit]},
277
+ "touched_functions": [f"{s['path']}::{s['name']}" for s in touched][:limit],
278
+ "tests": [{"path": t, "why": why(t)} for t in ordered[:limit]],
279
+ "counts": {"changed_files": len(changes), "tests": len(ordered), "test_files": len(test_files)},
280
+ "run_all": bool(run_all),
281
+ "run_all_reasons": run_all,
282
+ "not_analyzed": not_analyzed[:limit],
283
+ "commands": _commands(root, ordered) if ordered else [],
284
+ "diagnostics": [d.get("reason") for d in data.get("diagnostics") or [] if d.get("reason")],
285
+ "view": (data.get("selected") or {}).get("label"),
286
+ "note": NOTE,
287
+ }
288
+ if len(ordered) > limit:
289
+ result["omitted"] = {"tests": len(ordered) - limit}
290
+ if not changes:
291
+ result["note"] = "No changes since " + base + ". " + NOTE
292
+ return result
package/client_hooks.py CHANGED
@@ -16,9 +16,9 @@ HOME = os.path.expanduser("~")
16
16
  PREVIEW_TTL_S = 600
17
17
  CLIENTS = {
18
18
  "claude": {"label": "Claude Code", "names": {"claude-code"}, "files": [os.path.join(endpoint_sync.claude_dir(), "settings.json")],
19
- "docs": "https://code.claude.com/docs/en/hooks", "event": "PreToolUse", "matcher": "Grep|Glob|Read|Bash|WebSearch|WebFetch", "http": True},
19
+ "docs": "https://code.claude.com/docs/en/hooks", "event": "PreToolUse", "matcher": "Grep|Glob|Read|Bash|WebSearch|WebFetch|Edit|MultiEdit|Write", "http": True},
20
20
  "codex": {"label": "Codex", "prefix": "codex", "files": [os.path.join(HOME, ".codex", "hooks.json"), os.path.join(HOME, ".codex", "config.toml")],
21
- "docs": "https://learn.chatgpt.com/docs/hooks", "event": "PreToolUse", "matcher": "Bash",
21
+ "docs": "https://learn.chatgpt.com/docs/hooks", "event": "PreToolUse", "matcher": "Bash|apply_patch",
22
22
  "after_install": "Codex only runs new hooks after the user approves the definition in /hooks."},
23
23
  "cursor": {"label": "Cursor", "prefix": "cursor", "files": [os.path.join(HOME, ".cursor", "hooks.json")],
24
24
  "docs": "https://cursor.com/docs/agent/hooks", "event": "preToolUse", "matcher": "Shell|Read|Grep",
package/code_graph.py CHANGED
@@ -20,6 +20,8 @@ import web_document_graph
20
20
  import document_text
21
21
 
22
22
  VERSION = 2
23
+ SOURCE_EXTENSIONS = ('.py','.java','.js','.jsx','.ts','.tsx','.mjs','.cjs','.mts','.cts','.json','.html','.htm','.css','.scss','.sass','.less','.md','.markdown')
24
+ MAX_OVERLAY_BYTES = 1024 * 1024
23
25
  MAX_FILES = 3000
24
26
  MAX_TEXT = 24 * 1024 * 1024
25
27
  MAX_SYMBOLS = 8000
@@ -72,7 +74,7 @@ def _snapshot(path):
72
74
  'chunks':chunks,'lines':line_count or 0,'group':posixpath.dirname(file_path) or '(root)',
73
75
  'location_kind':document_text.location_kind(file_path),'format':posixpath.splitext(file_path)[1].lower().lstrip('.') or 'text'}
74
76
  files.append(record)
75
- if not file_path.lower().endswith(('.py','.java','.js','.jsx','.ts','.tsx','.mjs','.cjs','.mts','.cts','.json','.html','.htm','.css','.scss','.sass','.less','.md','.markdown')):
77
+ if not file_path.lower().endswith(SOURCE_EXTENSIONS):
76
78
  continue
77
79
  rows = conn.execute('SELECT start_line,end_line,text FROM chunks WHERE path=? ORDER BY start_line,id', (original,)).fetchall()
78
80
  text, error = reconstruct(rows)
@@ -228,19 +230,34 @@ def _python_graph(sources):
228
230
  'diagnostics':diagnostics,'parsed':list(trees),'truncated':len(symbols)>=MAX_SYMBOLS or len(dependencies)>MAX_EDGES}
229
231
 
230
232
 
233
+ NODE_HEAP_MB = (1024, 4096)
234
+
235
+
236
+ def _run_analyzer(script, payload, failure):
237
+ """Runs a Node analyzer, retrying once with a larger heap when it runs out of memory (634 TypeScript files, 2.9 MB,
238
+ needed more than the former fixed 512 MB)."""
239
+ for heap in NODE_HEAP_MB:
240
+ process=subprocess.run([web_search_adapters.NODE_EXECUTABLE,f'--max-old-space-size={heap}',str(Path(__file__).with_name(script))],
241
+ input=payload,capture_output=True,text=True,encoding='utf-8',timeout=120,
242
+ creationflags=getattr(subprocess,'CREATE_NO_WINDOW',0))
243
+ if process.returncode==0:
244
+ return json.loads(process.stdout)
245
+ if 'heap out of memory' not in (process.stderr or '') or heap==NODE_HEAP_MB[-1]:
246
+ break
247
+ detail=' (out of memory)' if 'heap out of memory' in (process.stderr or '') else ''
248
+ raise RuntimeError(failure+detail)
249
+
250
+
231
251
  def _javascript_graph(sources, all_paths=None):
232
252
  extensions=('.js','.jsx','.ts','.tsx','.mjs','.cjs','.mts','.cts')
233
253
  relevant={path:text for path,text in sources.items() if path.endswith(extensions+('.json','.html','.htm'))}
234
254
  if not any(path.endswith(extensions) for path in relevant):
235
255
  return {}
236
256
  try:
237
- process=subprocess.run([web_search_adapters.NODE_EXECUTABLE,'--max-old-space-size=512',str(Path(__file__).with_name('code_graph_js.cjs'))],
238
- input=json.dumps({'files':[{'path':path,'text':text} for path,text in relevant.items()],'paths':list(all_paths or sources)},ensure_ascii=False),
239
- capture_output=True,text=True,encoding='utf-8',timeout=40,
240
- creationflags=getattr(subprocess,'CREATE_NO_WINDOW',0))
241
- if process.returncode:
242
- raise RuntimeError('The TypeScript analyzer did not finish. Check the local code-analysis runtime.')
243
- return json.loads(process.stdout)
257
+ return _run_analyzer('code_graph_js.cjs',
258
+ json.dumps({'files':[{'path':path,'text':text} for path,text in relevant.items()],
259
+ 'paths':list(all_paths or sources)},ensure_ascii=False),
260
+ 'The TypeScript analyzer did not finish. Check the local code-analysis runtime.')
244
261
  except (OSError,subprocess.TimeoutExpired,ValueError,RuntimeError) as exc:
245
262
  return {'diagnostics':[{'path':'JavaScript/TypeScript','reason':str(exc)[:250]}],'parsed':[],'retryable':True}
246
263
 
@@ -249,17 +266,17 @@ def _java_graph(sources):
249
266
  files=[{'path':path,'text':text} for path,text in sources.items() if path.lower().endswith('.java')]
250
267
  if not files:return {}
251
268
  try:
252
- result=subprocess.run([web_search_adapters.NODE_EXECUTABLE,'--max-old-space-size=512',str(Path(__file__).with_name('code_graph_java.mjs'))],
253
- input=json.dumps({'files':files},ensure_ascii=False),capture_output=True,text=True,encoding='utf-8',timeout=40,
254
- creationflags=getattr(subprocess,'CREATE_NO_WINDOW',0))
255
- if result.returncode:raise RuntimeError('The Java analyzer did not finish. Check the local runtime.')
256
- return json.loads(result.stdout)
269
+ return _run_analyzer('code_graph_java.mjs',json.dumps({'files':files},ensure_ascii=False),
270
+ 'The Java analyzer did not finish. Check the local runtime.')
257
271
  except (OSError,ValueError,RuntimeError,subprocess.TimeoutExpired) as exc:
258
272
  return {'diagnostics':[{'path':'Java','reason':str(exc)[:250]}],'parsed':[],'retryable':True}
259
273
 
260
274
 
261
275
  def _analyze(path):
262
- files,sources,diagnostics,truncated=_snapshot(path)
276
+ return _analyze_sources(*_snapshot(path))
277
+
278
+
279
+ def _analyze_sources(files,sources,diagnostics,truncated):
263
280
  merged={'symbols':[],'dependencies':[],'calls':[],'unresolved':[],'diagnostics':diagnostics,'parsed':[]}
264
281
  retryable=False
265
282
  for output in (_python_graph(sources),_javascript_graph(sources,[f['path'] for f in files]),_java_graph(sources),web_document_graph.analyze(sources,[f['path'] for f in files])):
@@ -401,3 +418,47 @@ def build(root,view_id=None,storage_id=None,file_path=None,background=False):
401
418
  limits={'files':MAX_FILES,'symbols':MAX_SYMBOLS,'edges':MAX_EDGES,'display_nodes_default':120},
402
419
  languages=['Java','Angular','Python','JavaScript','TypeScript','JSX','TSX','HTML','CSS','Markdown'],cache_hit=was_cached)
403
420
  return _focus(data,file_path) if file_path else data
421
+
422
+
423
+ def build_overlay(root,changes,view_id=None):
424
+ """Graph of the indexed view with the given changed files read from the working tree instead of the snapshot
425
+ (changes: project-relative path -> 'deleted' or any other status), so a caller right after an edit sees new files,
426
+ new imports and current line numbers without reindexing or embeddings. A deleted file keeps its indexed copy so
427
+ the files that imported it stay linked to it. Cached per snapshot and file contents."""
428
+ inventory=index_inventory.inspect(root,view_id)
429
+ if not inventory['selected']:
430
+ return {**inventory,'symbols':[],'dependencies':[],'calls':[],'diagnostics':[],'counts':{},'files':[]}
431
+ path=inventory['selected']['path'];stat=os.stat(path)
432
+ files,sources,diagnostics,truncated=_snapshot(path)
433
+ by_path={f['path']:f for f in files};overlaid=[]
434
+ for rel,status in sorted(changes.items()):
435
+ full=os.path.join(root,rel)
436
+ if status=='deleted' or not project_identity.within_root(root,full):
437
+ continue
438
+ try:
439
+ if os.path.getsize(full)>MAX_OVERLAY_BYTES:continue
440
+ with open(full,encoding='utf-8') as stream:text=stream.read()
441
+ except (OSError,UnicodeDecodeError):continue
442
+ by_path.setdefault(rel,{'id':rel,'path':rel,'hash':None,'size':len(text),'mtime_ns':None,'chunks':0,
443
+ 'lines':text.count('\n')+1,'group':posixpath.dirname(rel) or '(root)',
444
+ 'location_kind':document_text.location_kind(rel),
445
+ 'format':posixpath.splitext(rel)[1].lower().lstrip('.') or 'text'})
446
+ if rel.lower().endswith(SOURCE_EXTENSIONS):
447
+ sources[rel]=text;overlaid.append((rel,hashlib.sha1(text.encode('utf-8')).hexdigest()))
448
+ key=(path,stat.st_mtime_ns,stat.st_size,VERSION,'overlay',tuple(overlaid))
449
+ with _LOCK:
450
+ cached=_CACHE.get(key)
451
+ if cached is None:
452
+ if not _ANALYSIS_SLOTS.acquire(timeout=3):
453
+ raise RuntimeError('Two map analyses are in progress. Wait for them to finish and retry.')
454
+ try:
455
+ cached=_analyze_sources(list(by_path.values()),sources,diagnostics,truncated)
456
+ finally:
457
+ _ANALYSIS_SLOTS.release()
458
+ if not cached['retryable']:
459
+ with _LOCK:
460
+ _CACHE[key]=cached
461
+ while len(_CACHE)>3:_CACHE.popitem(last=False)
462
+ data=copy.deepcopy(cached)
463
+ data.update(selected=inventory['selected'],source='indexed_snapshot_with_working_tree',overlaid=[r for r,_h in overlaid])
464
+ return data
@@ -33,9 +33,12 @@ for(const unit of units){
33
33
  if(cls&&n.name==='primary'){
34
34
  const prefix=n.children.primaryPrefix?.[0],prefixText=text(unit,prefix).trim(),suffixes=n.children.primarySuffix||[];let chain=prefixText,fluent=false;
35
35
  const newMatch=/^new\s+([\w.$]+)/.exec(prefixText);let constructed=newMatch?resolveType(unit,newMatch[1],cls):null;
36
+ const link=(type,line)=>{if(type&&type.unit.path!==unit.path&&dependencies.length<MAX_EDGES)dependencies.push({source:unit.path,target:type.unit.path,specifier:type.fqn,line,kind:'java_type',resolution:'resolved'})};
36
37
  if(constructed&&calls.length<MAX_EDGES)calls.push({source:(owner||cls.symbol).id,target:constructed.symbol.id,line:n.location.startLine,kind:'construct',resolution:'static'});
38
+ link(constructed,n.location.startLine);
39
+ const staticHead=/^([A-Z][\w$]*)\./.exec(prefixText);if(staticHead&&!vars.has(staticHead[1]))link(resolveType(unit,staticHead[1],cls),n.location.startLine);
37
40
  for(const suffix of suffixes){const invoke=suffix.children.methodInvocationSuffix?.[0];if(!invoke){chain+=text(unit,suffix);continue}const args=invoke.children.argumentList?.[0],arity=args?(args.children.expression||[]).length:0;const match=/^(?:([\w.$]+)\.)?([\w$]+)$/.exec(chain);let target=null;
38
- if(match&&!fluent){const receiver=match[1],name=match[2];let type=null;if(!receiver||receiver==='this')type=cls;else if(receiver==='super')type=resolveType(unit,cls.extends,cls);else if(receiver.startsWith('this.'))type=resolveType(unit,cls.fields.get(receiver.slice(5))||'',cls);else if(vars.has(receiver))type=resolveType(unit,vars.get(receiver)||'',cls);else type=resolveType(unit,receiver,cls);target=lookupMethod(type,name,arity);if(!target&&!receiver){const candidates=unit.imports.filter(i=>i.static&&(i.spec.endsWith('.'+name)||i.spec.endsWith('.*'))).map(i=>lookupMethod(classes.get(i.spec.split('.').slice(0,-1).join('.')),name,arity)).filter(Boolean);if(candidates.length===1)target=candidates[0]}}
41
+ if(match&&!fluent){const receiver=match[1],name=match[2];let type=null;if(!receiver||receiver==='this')type=cls;else if(receiver==='super')type=resolveType(unit,cls.extends,cls);else if(receiver.startsWith('this.'))type=resolveType(unit,cls.fields.get(receiver.slice(5))||'',cls);else if(vars.has(receiver))type=resolveType(unit,vars.get(receiver)||'',cls);else{type=resolveType(unit,receiver,cls);link(type,invoke.location.startLine)}target=lookupMethod(type,name,arity);if(!target&&!receiver){const candidates=unit.imports.filter(i=>i.static&&(i.spec.endsWith('.'+name)||i.spec.endsWith('.*'))).map(i=>lookupMethod(classes.get(i.spec.split('.').slice(0,-1).join('.')),name,arity)).filter(Boolean);if(candidates.length===1)target=candidates[0]}}
39
42
  if(constructed&&!fluent){const methodName=/\.([\w$]+)$/.exec(chain)?.[1];if(methodName)target=lookupMethod(constructed,methodName,arity)}
40
43
  if(target&&calls.length<MAX_EDGES)calls.push({source:(owner||cls.symbol).id,target:target.id,line:invoke.location.startLine,kind:'call',resolution:'static'});
41
44
  else if(unresolved.length<1000)unresolved.push({path:unit.path,line:invoke.location.startLine,expression:chain.slice(0,140),reason:'External/dynamic type, interface without implementation, or ambiguous overload in the Java snapshot.'});
package/code_graph_js.cjs CHANGED
@@ -25,7 +25,19 @@ function candidate(base) {
25
25
  const variants=[base,base.replace(/\.[cm]?jsx?$/,'.ts'),base.replace(/\.jsx?$/,'.tsx'),
26
26
  ...['.ts','.tsx','.js','.jsx','.mts','.cts','.mjs','.cjs'].map(e=>base+e),
27
27
  ...['.ts','.tsx','.js','.jsx'].map(e=>base+'/index'+e)];
28
- return variants.find(name=>knownFiles.has(clean(name)));
28
+ const found=variants.find(name=>knownFiles.has(clean(name)));
29
+ return found&&clean(found);
30
+ }
31
+ const workspaces=new Map();
32
+ for(const [name,file] of docs)if(name.endsWith('/package.json')&&!name.includes('/node_modules/')){try{const pkg=JSON.parse(file.text);if(typeof pkg.name==='string'&&pkg.name)workspaces.set(pkg.name,{dir:path.dirname(name),entries:[pkg.source,pkg.main,pkg.module].filter(e=>typeof e==='string')})}catch{}}
33
+ function workspace(spec){
34
+ for(const [name,pkg] of workspaces){
35
+ if(spec!==name&&!spec.startsWith(name+'/'))continue;
36
+ const sub=spec.slice(name.length+1);
37
+ if(sub)return candidate(path.join(pkg.dir,'src',sub))||candidate(path.join(pkg.dir,sub));
38
+ return candidate(path.join(pkg.dir,'src/index'))||candidate(path.join(pkg.dir,'index'))||pkg.entries.map(e=>candidate(path.join(pkg.dir,e))).find(Boolean);
39
+ }
40
+ return undefined;
29
41
  }
30
42
  function resolve(spec, from) {
31
43
  if (spec.startsWith('.')) return candidate(clean(path.join(path.dirname(from),spec)));
@@ -39,6 +51,8 @@ function resolve(spec, from) {
39
51
  }
40
52
  }
41
53
  }
54
+ const local=workspace(spec);
55
+ if(local)return local;
42
56
  if(config.baseUrl)return candidate(clean(path.join(baseUrl,spec)));
43
57
  return undefined;
44
58
  }