@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 CHANGED
@@ -1,5 +1,91 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.9.4 - beta
4
+
5
+ Found by using 0.9.3 on its own repository right after installing it.
6
+
7
+ - Impact (`graph` with `symbol`), docstring coverage (`docs`), `affected_tests` and the duplicate warning on edit now see
8
+ every file the index does not reflect yet: working-tree changes, indexed files changed on disk and files committed
9
+ after the last indexing. In the default `on_search` mode the index only updates on a search, so a function added
10
+ earlier in the session was "not found" by `graph`, and a copy of it was not flagged by the duplicate warning. Only
11
+ the current view gets these files; a pinned or older view is read as indexed.
12
+ - The code graph analysis is saved next to the index (`graph-cache/`, about 0.3 MB per view) and reloaded after a
13
+ daemon restart or when a project left the in-memory cache: 0.03-0.12 s instead of 7-11 s of analysis on remeda
14
+ and gson. An indexing job warms the graph when it ends. The duplicate warning, which never waits for an analysis,
15
+ used to skip the first edit after a restart or a reindex; it now checks it.
16
+ - Hook routing decides more Bash commands without the router model: a search verb after a single `|` filters the
17
+ output of the command before it (`python x.py | tail -5`, `npm test | grep FAIL`) and no longer sends the call to
18
+ the model, unless that command reads code or pages (`git grep`, `git ls-files`, `curl`, `gh api`, ...). Replayed on
19
+ 2,489 real Bash decisions that went to the model (median 1.5 s, 7.8% redirected): 438 become mechanical, 13 minutes
20
+ of waiting saved in 58 hours, 1 of 155 redirects lost. Search verbs after `do`/`then`/`else` and inside a quoted
21
+ `bash -c "..."` / `powershell -Command "..."` are now recognized.
22
+ - `affected_tests` no longer asks for the whole suite when only CI configuration or repository metadata changed
23
+ (`.github/`, `.gitlab-ci.yml`, `Jenkinsfile`, `.gitignore`, `.gitattributes`, `LICENSE`, ...).
24
+ - The docstring reminder on edit only applies to code files of registered projects: test files (whose test functions
25
+ normally have no docstring) and files outside any project are left out, as in the `docs` coverage.
26
+
27
+ ## 0.9.3 - beta
28
+
29
+ - Camoufox download (1.3 GB) on networks that cut long transfers (seen stopping at 500-530 MB on every attempt): it is
30
+ now fetched in 32 MB Range requests, each on a new connection, resuming from the last byte received after a dropped
31
+ or stalled connection, and gives up only after 6 attempts in a row without a new byte. camoufox still verifies the
32
+ sha256 published for the release before extracting. The downloader uses the standard library, which on Windows
33
+ trusts the system certificate store (including a corporate TLS inspection CA) and honors HTTPS_PROXY.
34
+ - `project_manage graph` with `symbol` (function, `Class.method`, class, or `path::name` when the name repeats): where
35
+ it is defined, its callers with line, what it calls, the tests that reach it through static calls (`depth` hops,
36
+ 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.
37
+ - Documentation on edit (setup, Agents): the hook checks the functions a Claude Code Edit/MultiEdit/Write or a Codex
38
+ apply_patch touches. Remind (default) tells the agent which ones have no docstring and which documented ones changed
39
+ their signature, with callers; Require blocks an edit that leaves a touched function without a
40
+ docstring; Off disables it. Body-only edits of documented functions stay silent. The hook matcher now includes the
41
+ edit tools: reinstall the hook from the setup screen (it shows as outdated).
42
+ - Documentation on edit asks only for public functions: private (`_name`, `#name`, `private`), nested, callback and
43
+ override (`@Override`, `@override`) functions are exempt, following pydocstyle, eslint-plugin-jsdoc and Checkstyle
44
+ defaults, and a JSDoc above TypeScript overload signatures documents the implementation. On remeda, gson, express
45
+ and click the former rule asked for docstrings on every overload implementation, callback and `@Override` method.
46
+ TypeScript, JavaScript and Java edits were never checked on Windows (the file was looked up by its absolute path in
47
+ the analyzer output, which normalizes it); they are now.
48
+ - Duplicate functions on edit (setup, Agents; Warn by default, Off): when an edit writes a function whose body is
49
+ identical (comments, spacing, docstrings ignored) or near-identical (only local names, strings and numbers changed)
50
+ to an indexed one, the agent is told where the original is so it can reuse it; the edit is never blocked. Measured
51
+ on click, express, remeda and gson: every exact and locally renamed copy found, no warning when each real function
52
+ is written as it is.
53
+ - `project_manage action=affected_tests`: tests to run for the uncommitted changes (or against `base`): every test
54
+ file importing a changed file, directly or transitively, plus changed tests, likeliest failures first (tests calling
55
+ a touched function, test files named after the function or module, import distance), with the command per runner
56
+ (pytest, unittest, vitest, jest, mocha, node --test, Maven, Gradle). `run_all` with the reason when configuration,
57
+ lockfiles or code outside the import graph changed. In fault-injection runs (30 faults each), selection by imports
58
+ caught every failing test file in express and all but timing-flaky ones in remeda; static calls alone, as the
59
+ `graph` impact lists them, found 2 to 37%.
60
+ 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
61
+ conftest.py depends on what that conftest imports (a change in click's `testing.py` selected 4 of the 20 failing
62
+ test files; now all 20).
63
+ - Near-duplicate detection (`action=duplicates` and the edit hook) keeps keywords, called names, attributes and types
64
+ and abstracts only local names, strings and numbers, compares functions of the same language only and skips
65
+ overloads and language idioms: parallel functions such as `get_text_stdin`/`get_text_stdout` or
66
+ `rotateLeft`/`rotateRight` are no longer reported (near pairs fell from 5-14% of functions to 0-1.6%).
67
+ - Code graph: `require('../')` resolved to a wrong absolute path, and workspace packages imported by name
68
+ (`import { x } from "my-lib"` in a monorepo) were treated as external; both now resolve to the package source. Vitest
69
+ type tests (`*.test-d.ts`, `*.spec-d.ts`) are classified as tests. The TypeScript and Java analyzers retry with a
70
+ 4 GB heap when 1 GB is not enough (larger projects ended with no symbols).
71
+ - `web_fetch` on pages over 60,000 characters (14.6% of cached pages, 10.4% of real calls) no longer keeps only the
72
+ start: the whole page is cached (up to 1,000,000 characters) and the model reads the 2,000-character pieces most
73
+ similar to the prompt, up to 15,000 characters, in page order. Measured on 18 real pages and 41 questions: answers
74
+ about text after the old cut 0/28 → 13/28. Pieces are embedded once per page and embedding model (first read of a
75
+ 75-256k page adds 2.6-6.8 s, later questions about 1 s); without a working `embedding_model` such a page fails
76
+ with the reason instead of being cut silently. Pages up to 60,000 characters are read whole, as before (selection
77
+ there tied on quality and added latency).
78
+ - Hook metrics: concurrent hook calls from Claude Code and Codex could be logged with each other's client name.
79
+ - Web search health log tells a provider 429 (`rate_limited`), a captcha and a self-imposed quota pause (`paused`, not
80
+ counted against the provider) apart from an ordinary failure.
81
+ - `project_manage action=docs`: docstring coverage of the indexed code (public functions without a docstring per file,
82
+ coverage percent; tests only with `include_tests`), for documenting a project that started without them.
83
+ - Impact results (`graph` with `symbol`) carry the first docstring line of the definition, callers, calls and tests.
84
+ - `project_manage` for agents: every action takes `project_root` instead of `project_id`; `list` returns one short line
85
+ per project (it returned the full jobs, scope and preview of every project, about 1 MB with 14 projects, more than
86
+ an agent can read; the projects screen still gets the full list); an index job's result is no longer repeated as
87
+ `stats`.
88
+
3
89
  ## 0.9.2 - beta
4
90
 
5
91
  - Installer reported success after a broken Camoufox download: camoufox 0.5.6 prints the error of `fetch` and exits 0.
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,301 @@
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_inventory
16
+ import index_profile
17
+ import index_scope
18
+
19
+ RUN_ALL = re.compile(
20
+ r"(^|/)(package\.json|package-lock\.json|npm-shrinkwrap\.json|yarn\.lock|pnpm-lock\.yaml|pnpm-workspace\.yaml|"
21
+ r"bun\.lockb?|pyproject\.toml|setup\.cfg|setup\.py|pytest\.ini|tox\.ini|requirements[^/]*\.txt|poetry\.lock|"
22
+ r"uv\.lock|Pipfile(\.lock)?|tsconfig[^/]*\.json|jsconfig\.json|(jest|vitest|vite|babel)\.config\.[^/]+|"
23
+ r"\.babelrc|\.mocharc[^/]*|pom\.xml|build\.gradle(\.kts)?|settings\.gradle(\.kts)?|gradle\.properties|"
24
+ r"\.env[^/]*)$")
25
+ CI_CONFIG = re.compile(r"(^|/)(\.github|\.circleci|\.buildkite)/|(^|/)(\.gitlab-ci\.ya?ml|azure-pipelines\.ya?ml|"
26
+ r"Jenkinsfile|\.travis\.ya?ml|bitbucket-pipelines\.ya?ml|\.gitignore|\.gitattributes|"
27
+ r"\.editorconfig|\.mailmap|CODEOWNERS|LICENSE(\.[^/]*)?|\.npmignore)$")
28
+ NOTE = ("Selection from the indexed import graph: tests that load code through strings (mock.patch targets, "
29
+ "importlib, require(variable), reflection, Spring scanning) or read data files are not seen. Run the full "
30
+ "suite before committing; run_all is true when a changed file can change every test (config, lockfile, "
31
+ "unanalyzed code). Changed files and files committed after the last indexing are read from disk, so new "
32
+ "files and imports count before reindexing; pytest fixtures link a test to the conftest.py that defines them.")
33
+ _HUNK = re.compile(r"^@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@", re.M)
34
+
35
+
36
+ def _git(root, *args):
37
+ result = subprocess.run(["git", *args], cwd=root, capture_output=True, timeout=60)
38
+ if result.returncode:
39
+ raise ValueError(f"git {' '.join(args[:2])} failed: {result.stderr.decode('utf-8', 'replace').strip()[:300]}")
40
+ return result.stdout.decode("utf-8", "replace")
41
+
42
+
43
+ def _changes(root, base):
44
+ """Project-relative changed paths with their status and the new-side changed lines (None = whole file)."""
45
+ top = _git(root, "rev-parse", "--show-toplevel").strip()
46
+ prefix = os.path.relpath(os.path.realpath(root), os.path.realpath(top)).replace(os.sep, "/")
47
+ prefix = "" if prefix == "." else prefix + "/"
48
+ _git(root, "rev-parse", "--verify", "--quiet", f"{base}^{{commit}}")
49
+ changes = {}
50
+ fields = _git(root, "diff", "--name-status", "-z", "--no-renames", base, "--", ".").split("\0")
51
+ for status, path in zip(fields[0::2], fields[1::2]):
52
+ if path.startswith(prefix):
53
+ changes[path[len(prefix):]] = {"status": "deleted" if status == "D" else "changed", "lines": set()}
54
+ for path in _git(root, "ls-files", "--others", "--exclude-standard", "-z", "--full-name", "--", ".").split("\0"):
55
+ if path and path.startswith(prefix):
56
+ changes[path[len(prefix):]] = {"status": "added", "lines": None}
57
+ diff = _git(root, "diff", "-U0", "--no-renames", "--relative", base, "--", ".")
58
+ for block in re.split(r"^diff --git ", diff, flags=re.M)[1:]:
59
+ target = re.search(r"^\+\+\+ b/(.+)$", block, re.M)
60
+ if target and target.group(1) in changes and changes[target.group(1)]["lines"] is not None:
61
+ for start, count in _HUNK.findall(block):
62
+ first, size = int(start), int(count or 1)
63
+ changes[target.group(1)]["lines"].update(range(first, first + max(size, 1)))
64
+ return changes
65
+
66
+
67
+ def _depths(start, reverse):
68
+ seen, frontier = {node: 0 for node in start}, deque(start)
69
+ while frontier:
70
+ node = frontier.popleft()
71
+ for nxt in reverse.get(node, ()):
72
+ if nxt not in seen:
73
+ seen[nxt] = seen[node] + 1
74
+ frontier.append(nxt)
75
+ return seen
76
+
77
+
78
+ def _nearest(root, start, names):
79
+ folder = os.path.dirname(start)
80
+ while True:
81
+ for name in names:
82
+ if os.path.isfile(os.path.join(root, folder, name)):
83
+ return folder, name
84
+ if not folder:
85
+ return None, None
86
+ folder = os.path.dirname(folder)
87
+
88
+
89
+ def _read(path):
90
+ try:
91
+ with open(path, encoding="utf-8") as stream:
92
+ return stream.read()
93
+ except (OSError, UnicodeDecodeError):
94
+ return ""
95
+
96
+
97
+ def _commands(root, tests):
98
+ """One command per runner and folder for the selected test files, or the reason none could be built."""
99
+ groups = defaultdict(list)
100
+ for path in tests:
101
+ lower = path.lower()
102
+ if lower.endswith(".py"):
103
+ folder, marker = _nearest(root, path, ("pytest.ini", "pyproject.toml", "setup.cfg", "tox.ini"))
104
+ pytest = (marker and "pytest" in _read(os.path.join(root, folder or "", marker))) \
105
+ or _nearest(root, path, ("conftest.py",))[1] \
106
+ or re.search(r"^\s*(import pytest|from pytest import)", _read(os.path.join(root, path)), re.M)
107
+ test_dir = os.path.dirname(path)
108
+ if pytest:
109
+ groups[(folder or "", "python -m pytest")].append(path)
110
+ elif os.path.isfile(os.path.join(root, test_dir, "__init__.py")):
111
+ groups[("", "unittest-package")].append(path)
112
+ else:
113
+ groups[(test_dir, "unittest-folder")].append(path)
114
+ elif lower.endswith((".js", ".jsx", ".ts", ".tsx", ".mjs", ".cjs", ".mts", ".cts")):
115
+ folder, _name = _nearest(root, path, ("package.json",))
116
+ try:
117
+ package = json.loads(_read(os.path.join(root, folder or "", "package.json")) or "{}")
118
+ except ValueError:
119
+ package = {}
120
+ tools = " ".join([str((package.get("scripts") or {}).get("test", ""))]
121
+ + list(package.get("devDependencies") or {}) + list(package.get("dependencies") or {}))
122
+ runner = next((cmd for key, cmd in (("vitest", "npx vitest run"), ("jest", "npx jest"),
123
+ ("mocha", "npx mocha"), ("node --test", "node --test"))
124
+ if key in tools), None)
125
+ groups[(folder or "", runner)].append(path)
126
+ elif lower.endswith(".java"):
127
+ folder, name = _nearest(root, path, ("pom.xml", "build.gradle", "build.gradle.kts"))
128
+ groups[(folder or "", "maven" if name == "pom.xml" else "gradle" if name else None)].append(path)
129
+ else:
130
+ groups[("", None)].append(path)
131
+ commands = []
132
+ for (folder, runner), files in sorted(groups.items(), key=lambda item: (item[0][0], str(item[0][1]))):
133
+ local = [os.path.relpath(f, folder or ".").replace(os.sep, "/") for f in files]
134
+ if runner is None:
135
+ commands.append({"cwd": folder or ".", "command": None, "files": local,
136
+ "reason": "No known test runner configured for these files."})
137
+ continue
138
+ if runner == "maven":
139
+ names = ",".join(sorted({os.path.basename(f).rsplit(".", 1)[0] for f in files}))
140
+ line = f"mvn test -Dtest={names} -Dsurefire.failIfNoSpecifiedTests=false"
141
+ elif runner == "unittest-package":
142
+ line = "python -m unittest " + " ".join(sorted(f[:-3].replace("/", ".") for f in files))
143
+ elif runner == "unittest-folder":
144
+ line = "python -m unittest " + " ".join(sorted(os.path.basename(f)[:-3] for f in files))
145
+ elif runner == "gradle":
146
+ wrapper = "./gradlew" if os.path.isfile(os.path.join(root, folder, "gradlew")) else "gradle"
147
+ line = wrapper + " test " + " ".join(f"--tests {os.path.basename(f).rsplit('.', 1)[0]}" for f in sorted(files))
148
+ else:
149
+ line = runner + " " + " ".join(sorted(local))
150
+ commands.append({"cwd": folder or ".", "command": line})
151
+ return commands
152
+
153
+
154
+ def _fixtures(source):
155
+ """Fixture names a conftest defines and whether one of them is autouse."""
156
+ names, autouse = set(), False
157
+ for node in ast.walk(ast.parse(source)):
158
+ if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
159
+ for decorator in node.decorator_list:
160
+ text = ast.unparse(decorator)
161
+ if "fixture" in text:
162
+ names.add(node.name)
163
+ autouse |= "autouse=True" in text.replace(" ", "")
164
+ return names, autouse
165
+
166
+
167
+ def _parameters(source):
168
+ return {arg.arg for node in ast.walk(ast.parse(source)) if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef))
169
+ for arg in node.args.posonlyargs + node.args.args + node.args.kwonlyargs}
170
+
171
+
172
+ def _fixture_users(root, test_files):
173
+ """conftest.py -> Python test files (and nested conftests) below it that take one of its fixtures as a parameter,
174
+ or all of them when it has an autouse fixture: pytest injects fixtures by name, without an import."""
175
+ users = defaultdict(set)
176
+ python = [p for p in test_files if p.endswith(".py")]
177
+ params = {}
178
+ for conftest in (p for p in python if os.path.basename(p) == "conftest.py"):
179
+ try:
180
+ names, autouse = _fixtures(_read(os.path.join(root, conftest)))
181
+ except SyntaxError:
182
+ continue
183
+ folder = os.path.dirname(conftest)
184
+ for path in python:
185
+ if path == conftest or folder and not path.startswith(folder + "/"):
186
+ continue
187
+ if path not in params:
188
+ try:
189
+ params[path] = _parameters(_read(os.path.join(root, path)))
190
+ except SyntaxError:
191
+ params[path] = set()
192
+ if autouse or names & params[path]:
193
+ users[conftest].add(path)
194
+ return users
195
+
196
+
197
+ def affected(root, base="HEAD", limit=30, view_id=None):
198
+ """Test files to run for the changes since base, most likely failures first, with the command to run them."""
199
+ if not isinstance(base, str) or not re.fullmatch(r"[\w./@^~{}-]{1,200}", base) or base.startswith("-"):
200
+ raise ValueError("base must be a git revision such as HEAD, main or origin/main.")
201
+ changes = _changes(root, base)
202
+ selected = index_inventory.inspect(root, view_id)["selected"] or {}
203
+ pending = code_graph.pending_changes(root, selected["path"]) if selected.get("current") else {}
204
+ data = code_graph.build_overlay(root, {**pending, **{path: change["status"] for path, change in changes.items()}},
205
+ view_id)
206
+ profile = index_profile.current((index_scope.load_scope(root) or {}).get("profile"))
207
+ kind = {}
208
+ for path in set(changes) | {f["path"] for f in data.get("files") or []}:
209
+ kind[path] = index_profile.kind(path, profile)
210
+ parsed = {f["path"] for f in data.get("files") or [] if f.get("analysis") == "parsed"}
211
+ test_files = {f["path"] for f in data.get("files") or [] if kind.get(f["path"]) == "test"}
212
+ importers, callers = defaultdict(set), defaultdict(set)
213
+ for dep in data.get("dependencies") or []:
214
+ if dep.get("target"):
215
+ importers[dep["target"]].add(dep["source"])
216
+ for call in data.get("calls") or []:
217
+ callers[call["target"]].add(call["source"])
218
+ for conftest, users in _fixture_users(root, test_files).items():
219
+ importers[conftest] |= users
220
+ by_id = {s["id"]: s for s in data.get("symbols") or []}
221
+ run_all, not_analyzed, changed_code, changed_tests, conftests = [], [], [], [], []
222
+ for path, change in sorted(changes.items()):
223
+ if CI_CONFIG.search(path):
224
+ continue
225
+ if RUN_ALL.search(path):
226
+ run_all.append(f"{path} changed (configuration or dependencies)")
227
+ elif os.path.basename(path) == "conftest.py":
228
+ conftests.append(path)
229
+ elif kind[path] == "test":
230
+ changed_tests.append(path)
231
+ elif kind[path] == "code":
232
+ changed_code.append(path)
233
+ if path not in parsed:
234
+ not_analyzed.append({"path": path, "status": change["status"],
235
+ "reason": "not analyzed (unsupported language, data file or outside the "
236
+ "index scope)"})
237
+ if not_analyzed:
238
+ run_all.append(f"{len(not_analyzed)} changed code file(s) outside the import graph; see not_analyzed")
239
+ touched = [s for s in by_id.values() if s["path"] in changes and s.get("kind") not in ("module", "class", "interface")
240
+ and (changes[s["path"]]["lines"] is None
241
+ or changes[s["path"]]["lines"].intersection(range(s["start_line"], s["end_line"] + 1)))]
242
+ touched = [s for s in touched if not any(o is not s and o["path"] == s["path"] and o in touched
243
+ and o["start_line"] >= s["start_line"] and o["end_line"] <= s["end_line"]
244
+ for o in touched)] or touched
245
+ import_depth = {f: d for f, d in _depths([p for p in changed_code if p in parsed], importers).items()
246
+ if f in test_files}
247
+ call_hops = {}
248
+ for sid, hops in _depths([s["id"] for s in touched], callers).items():
249
+ symbol = by_id.get(sid)
250
+ if symbol and symbol["path"] in test_files and hops:
251
+ call_hops[symbol["path"]] = min(hops, call_hops.get(symbol["path"], hops))
252
+ names = {s["name"].split("(")[0].split(".")[-1].lower() for s in touched}
253
+ names = {n for n in names if len(n) > 2 and n not in ("callback", "anonymous", "constructor")}
254
+ stems = {os.path.basename(p).split(".")[0].lower().lstrip("_") for p in changed_code}
255
+ selected = {p: "changed" for p in changed_tests}
256
+ for conftest in conftests:
257
+ folder = os.path.dirname(conftest)
258
+ for test in test_files:
259
+ if not folder or test.startswith(folder + "/"):
260
+ selected.setdefault(test, f"{conftest} changed")
261
+ for test in set(import_depth) | set(call_hops):
262
+ selected.setdefault(test, None)
263
+ support = {p for p in test_files if os.path.basename(p) == "conftest.py"
264
+ or any(source in test_files for source in importers.get(p, ()))}
265
+ selected = {test: reason for test, reason in selected.items() if test not in support}
266
+
267
+ def why(test):
268
+ if selected[test]:
269
+ return selected[test]
270
+ if test in call_hops:
271
+ return f"calls a changed function ({call_hops[test]} hop{'s' if call_hops[test] > 1 else ''})"
272
+ return f"imports a changed file ({import_depth[test]} step{'s' if import_depth[test] > 1 else ''})"
273
+
274
+ def rank(test):
275
+ base_name = os.path.basename(test).lower()
276
+ words = set(re.split(r"[^a-z0-9]+", base_name.rsplit(".", 1)[0]))
277
+ named = 0 if names & words or {n.replace("_", "") for n in names} & words else \
278
+ 1 if any(stem and stem in re.sub(r"[^a-z0-9]", "", base_name) for stem in stems) else 2
279
+ return (0 if selected[test] == "changed" else 1, call_hops.get(test, 99), named, import_depth.get(test, 99), test)
280
+
281
+ ordered = sorted(selected, key=rank)
282
+ result = {
283
+ "base": base,
284
+ "changed": {"code": changed_code[:limit], "tests": changed_tests[:limit],
285
+ "other": sorted(p for p in changes if p not in changed_code and p not in changed_tests)[:limit]},
286
+ "touched_functions": [f"{s['path']}::{s['name']}" for s in touched][:limit],
287
+ "tests": [{"path": t, "why": why(t)} for t in ordered[:limit]],
288
+ "counts": {"changed_files": len(changes), "tests": len(ordered), "test_files": len(test_files)},
289
+ "run_all": bool(run_all),
290
+ "run_all_reasons": run_all,
291
+ "not_analyzed": not_analyzed[:limit],
292
+ "commands": _commands(root, ordered) if ordered else [],
293
+ "diagnostics": [d.get("reason") for d in data.get("diagnostics") or [] if d.get("reason")],
294
+ "view": (data.get("selected") or {}).get("label"),
295
+ "note": NOTE,
296
+ }
297
+ if len(ordered) > limit:
298
+ result["omitted"] = {"tests": len(ordered) - limit}
299
+ if not changes:
300
+ result["note"] = "No changes since " + base + ". " + NOTE
301
+ 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",