@allansantos-dev/smart-tool 0.9.2 → 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 +62 -0
- package/README.md +27 -2
- package/affected_tests.py +292 -0
- package/client_hooks.py +2 -2
- package/code_graph.py +75 -14
- package/code_graph_java.mjs +4 -1
- package/code_graph_js.cjs +15 -1
- package/code_impact.py +132 -0
- package/config.py +22 -0
- package/doc_check.py +256 -0
- package/duplicates.py +148 -13
- package/edit_preview.py +92 -0
- package/hook_decision.py +96 -2
- package/index_profile.py +1 -1
- package/package.json +1 -1
- package/router.py +4 -2
- package/setup_ui.py +53 -5
- package/smart_tool_daemon.py +108 -21
- package/version.py +1 -1
- package/web_adapters/browser/fetch_camoufox.py +16 -8
- package/web_adapters/browser/ranged_download.py +78 -0
- package/web_fetch.py +60 -3
- package/web_search_health.py +12 -4
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,67 @@
|
|
|
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
|
+
|
|
3
65
|
## 0.9.2 - beta
|
|
4
66
|
|
|
5
67
|
- 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,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(
|
|
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
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
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
|
-
|
|
253
|
-
|
|
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
|
-
|
|
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
|
package/code_graph_java.mjs
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
}
|