oh-my-agent 15.0.3 → 15.0.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/assets/serena/dart_check.py +129 -0
- package/assets/serena/install.py +82 -0
- package/assets/serena/oma_dart.py +277 -0
- package/assets/serena/oma_symbol_cache.py +133 -0
- package/bin/cli.js +870 -861
- package/package.json +3 -2
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
"""Read-only hook client for the project's already-running Serena daemon."""
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import asyncio
|
|
5
|
+
import json
|
|
6
|
+
import subprocess
|
|
7
|
+
import sys
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from mcp import ClientSession
|
|
11
|
+
from mcp.client.streamable_http import streamablehttp_client
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def git(root, *args):
|
|
15
|
+
return subprocess.check_output(["git", "-C", str(root), *args])
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def check_index(root, package):
|
|
19
|
+
# Whole-project diagnostics are only an index check when that whole scope
|
|
20
|
+
# matches disk. Never stash/rewrite a concurrent worker's files.
|
|
21
|
+
changes = git(root, "diff", "--name-only", "-z", "--", package)
|
|
22
|
+
untracked = git(
|
|
23
|
+
root, "ls-files", "--others", "--exclude-standard", "-z", "--", package
|
|
24
|
+
)
|
|
25
|
+
relevant = [
|
|
26
|
+
p.decode()
|
|
27
|
+
for p in (changes + untracked).split(b"\0")
|
|
28
|
+
if p and (p.endswith((b".dart", b".yaml", b".yml", b".lock")))
|
|
29
|
+
]
|
|
30
|
+
if relevant:
|
|
31
|
+
raise ValueError(
|
|
32
|
+
"Mobile analysis inputs differ from the index: " + ", ".join(relevant[:10])
|
|
33
|
+
)
|
|
34
|
+
return git(root, "ls-files", "--stage", "-z", "--", package)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def validate_result(result, package, expected_sdk):
|
|
38
|
+
if result.get("complete") is not True or result.get("scope") != package:
|
|
39
|
+
raise ValueError(
|
|
40
|
+
"Serena did not return completed analysis for the requested package"
|
|
41
|
+
)
|
|
42
|
+
if Path(result.get("sdk_executable", "")).resolve() != expected_sdk.resolve():
|
|
43
|
+
raise ValueError(
|
|
44
|
+
"Serena uses a different Dart SDK; restart it with the project SDK"
|
|
45
|
+
)
|
|
46
|
+
diagnostics = result.get("diagnostics")
|
|
47
|
+
if not isinstance(diagnostics, dict):
|
|
48
|
+
raise ValueError("Malformed Serena diagnostics response")
|
|
49
|
+
return diagnostics
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
async def run(args):
|
|
53
|
+
root = Path(args.root).resolve() if args.root else Path(
|
|
54
|
+
git(Path.cwd(), "rev-parse", "--show-toplevel").decode().strip()
|
|
55
|
+
).resolve()
|
|
56
|
+
package = str((root / args.project).resolve().relative_to(root))
|
|
57
|
+
sdk = Path(args.dart_sdk).expanduser().resolve(strict=True)
|
|
58
|
+
index = check_index(root, package) if args.staged else None
|
|
59
|
+
url = args.url
|
|
60
|
+
async with streamablehttp_client(
|
|
61
|
+
url, timeout=args.timeout, sse_read_timeout=args.timeout
|
|
62
|
+
) as (read, write, _):
|
|
63
|
+
async with ClientSession(read, write) as session:
|
|
64
|
+
await session.initialize()
|
|
65
|
+
response = await session.call_tool(
|
|
66
|
+
"get_dart_project_diagnostics",
|
|
67
|
+
{
|
|
68
|
+
"relative_path": package,
|
|
69
|
+
"timeout_seconds": args.timeout,
|
|
70
|
+
},
|
|
71
|
+
)
|
|
72
|
+
if response.isError:
|
|
73
|
+
raise ValueError("Serena analysis failed: " + str(response.content))
|
|
74
|
+
blocks = [
|
|
75
|
+
c.text for c in response.content if getattr(c, "type", "") == "text"
|
|
76
|
+
]
|
|
77
|
+
if len(blocks) != 1:
|
|
78
|
+
raise ValueError("Unexpected Serena response")
|
|
79
|
+
result = json.loads(blocks[0])
|
|
80
|
+
diagnostics = validate_result(result, package, sdk)
|
|
81
|
+
if result.get("project_root") != str(root):
|
|
82
|
+
raise ValueError("Serena returned diagnostics for a different repository")
|
|
83
|
+
if args.staged and index != check_index(root, package):
|
|
84
|
+
raise ValueError("Git index changed during analysis; retry")
|
|
85
|
+
issues = 0
|
|
86
|
+
for path, entries in diagnostics.items():
|
|
87
|
+
for entry in entries:
|
|
88
|
+
if entry.get("severity", 1) <= 3:
|
|
89
|
+
issues += 1
|
|
90
|
+
line = entry["range"]["start"]["line"] + 1
|
|
91
|
+
print(
|
|
92
|
+
f"{path}:{line}: {entry.get('code', 'diagnostic')}: "
|
|
93
|
+
f"{entry['message']}"
|
|
94
|
+
)
|
|
95
|
+
print(f"Serena Dart: analysis complete, {issues} issue(s); {result['sdk_version']}")
|
|
96
|
+
return 1 if issues else 0
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def main():
|
|
100
|
+
parser = argparse.ArgumentParser(description=__doc__)
|
|
101
|
+
parser.add_argument("--project", default="apps/mobile")
|
|
102
|
+
parser.add_argument("--root")
|
|
103
|
+
parser.add_argument("--url", required=True)
|
|
104
|
+
parser.add_argument(
|
|
105
|
+
"--dart-sdk", required=True, help="Project Dart executable (not a mise shim)"
|
|
106
|
+
)
|
|
107
|
+
parser.add_argument("--timeout", type=int, default=60)
|
|
108
|
+
parser.add_argument("--staged", action="store_true")
|
|
109
|
+
args = parser.parse_args()
|
|
110
|
+
if not 1 <= args.timeout <= 120:
|
|
111
|
+
parser.error("--timeout must be between 1 and 120")
|
|
112
|
+
try:
|
|
113
|
+
return asyncio.run(asyncio.wait_for(run(args), timeout=args.timeout))
|
|
114
|
+
except (Exception, KeyboardInterrupt) as error:
|
|
115
|
+
|
|
116
|
+
def details(exc):
|
|
117
|
+
children = getattr(exc, "exceptions", None)
|
|
118
|
+
return (
|
|
119
|
+
"; ".join(details(child) for child in children)
|
|
120
|
+
if children
|
|
121
|
+
else str(exc) or type(exc).__name__
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
print(f"Serena Dart check incomplete: {details(error)}", file=sys.stderr)
|
|
125
|
+
return 2
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
if __name__ == "__main__":
|
|
129
|
+
sys.exit(main())
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"""Reconcile OMA's adapter with the interpreter that owns the Serena executable."""
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import hashlib
|
|
5
|
+
import importlib.metadata
|
|
6
|
+
import importlib.util
|
|
7
|
+
import json
|
|
8
|
+
import os
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
import tempfile
|
|
11
|
+
|
|
12
|
+
IMPORT = "from .oma_dart import GetDartProjectDiagnosticsTool\n"
|
|
13
|
+
CACHE_IMPORT = "from .oma_symbol_cache import install_symbol_cache as _install_symbol_cache\n_install_symbol_cache()\n"
|
|
14
|
+
SUPPORTED = {"1.7.0"}
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def atomic_write(path, content):
|
|
18
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
19
|
+
fd, temporary = tempfile.mkstemp(dir=path.parent, prefix=".oma-")
|
|
20
|
+
try:
|
|
21
|
+
with os.fdopen(fd, "w") as output:
|
|
22
|
+
output.write(content)
|
|
23
|
+
if path.exists():
|
|
24
|
+
os.chmod(temporary, path.stat().st_mode & 0o777)
|
|
25
|
+
os.replace(temporary, path)
|
|
26
|
+
finally:
|
|
27
|
+
Path(temporary).unlink(missing_ok=True)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def reconcile(package, version, source, check_only=False):
|
|
31
|
+
if version not in SUPPORTED:
|
|
32
|
+
return {
|
|
33
|
+
"status": "unsupported",
|
|
34
|
+
"version": version,
|
|
35
|
+
"error": f"Serena {version} is not supported by this OMA adapter (tested: 1.7.0)",
|
|
36
|
+
}
|
|
37
|
+
payload = (source / "oma_dart.py").read_text()
|
|
38
|
+
cache_payload = (source / "oma_symbol_cache.py").read_text()
|
|
39
|
+
init = package / "tools/__init__.py"
|
|
40
|
+
text = init.read_text()
|
|
41
|
+
updated = text if IMPORT in text else text.rstrip() + "\n" + IMPORT
|
|
42
|
+
if CACHE_IMPORT not in updated:
|
|
43
|
+
updated = updated.rstrip() + "\n" + CACHE_IMPORT
|
|
44
|
+
target = package / "tools/oma_dart.py"
|
|
45
|
+
cache_target = package / "tools/oma_symbol_cache.py"
|
|
46
|
+
changed = (
|
|
47
|
+
updated != text
|
|
48
|
+
or not target.is_file()
|
|
49
|
+
or target.read_text() != payload
|
|
50
|
+
or not cache_target.is_file()
|
|
51
|
+
or cache_target.read_text() != cache_payload
|
|
52
|
+
)
|
|
53
|
+
if changed and not check_only:
|
|
54
|
+
atomic_write(target, payload)
|
|
55
|
+
atomic_write(cache_target, cache_payload)
|
|
56
|
+
if updated != text:
|
|
57
|
+
atomic_write(init, updated)
|
|
58
|
+
revision = hashlib.sha256((version + payload + cache_payload).encode()).hexdigest()
|
|
59
|
+
return {
|
|
60
|
+
"status": "missing" if check_only and changed else "ready",
|
|
61
|
+
"version": version,
|
|
62
|
+
"changed": changed and not check_only,
|
|
63
|
+
"revision": revision,
|
|
64
|
+
"package": str(package),
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def main():
|
|
69
|
+
parser = argparse.ArgumentParser(description=__doc__)
|
|
70
|
+
parser.add_argument("--check", action="store_true")
|
|
71
|
+
args = parser.parse_args()
|
|
72
|
+
try:
|
|
73
|
+
version = importlib.metadata.version("serena-agent")
|
|
74
|
+
package = Path(importlib.util.find_spec("serena").origin).parent
|
|
75
|
+
result = reconcile(package, version, Path(__file__).parent, args.check)
|
|
76
|
+
except Exception as error:
|
|
77
|
+
result = {"status": "error", "error": str(error)}
|
|
78
|
+
print(json.dumps(result))
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
if __name__ == "__main__":
|
|
82
|
+
main()
|
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
"""Dart project diagnostics on Serena's existing language server (Serena 1.7).
|
|
2
|
+
|
|
3
|
+
Installed as serena.tools.oma_dart. No second analysis server is launched.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import hashlib
|
|
7
|
+
import json
|
|
8
|
+
import os
|
|
9
|
+
import subprocess
|
|
10
|
+
import threading
|
|
11
|
+
import time
|
|
12
|
+
from contextvars import ContextVar
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from urllib.parse import unquote, urlparse
|
|
15
|
+
|
|
16
|
+
from serena.tools.tools_base import Tool, ToolMarkerSymbolicRead
|
|
17
|
+
from solidlsp.language_servers.dart_language_server import DartLanguageServer
|
|
18
|
+
from solidlsp.ls_config import LanguageServerId
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def snapshot(root):
|
|
22
|
+
"""Detect edits during analysis, including new/deleted files and options."""
|
|
23
|
+
result = {}
|
|
24
|
+
for directory, dirs, files in os.walk(root, followlinks=False):
|
|
25
|
+
dirs[:] = [
|
|
26
|
+
d
|
|
27
|
+
for d in dirs
|
|
28
|
+
if d
|
|
29
|
+
not in {
|
|
30
|
+
".git",
|
|
31
|
+
".dart_tool",
|
|
32
|
+
"build",
|
|
33
|
+
"node_modules",
|
|
34
|
+
".venv",
|
|
35
|
+
"android",
|
|
36
|
+
"ios",
|
|
37
|
+
"macos",
|
|
38
|
+
"windows",
|
|
39
|
+
"linux",
|
|
40
|
+
}
|
|
41
|
+
and not Path(directory, d).is_symlink()
|
|
42
|
+
]
|
|
43
|
+
for name in files:
|
|
44
|
+
path = Path(directory, name)
|
|
45
|
+
if (
|
|
46
|
+
path.suffix in {".dart", ".yaml", ".yml", ".lock"}
|
|
47
|
+
or name == "package_config.json"
|
|
48
|
+
):
|
|
49
|
+
result[str(path.relative_to(root))] = hashlib.sha256(
|
|
50
|
+
path.read_bytes()
|
|
51
|
+
).hexdigest()
|
|
52
|
+
package_config = root / ".dart_tool/package_config.json"
|
|
53
|
+
if package_config.is_file():
|
|
54
|
+
result[".dart_tool/package_config.json"] = hashlib.sha256(
|
|
55
|
+
package_config.read_bytes()
|
|
56
|
+
).hexdigest()
|
|
57
|
+
return result
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
_original_setup = DartLanguageServer._setup_runtime_dependencies
|
|
61
|
+
_original_init = DartLanguageServer.__init__
|
|
62
|
+
_original_observe = DartLanguageServer._observe_server_notification
|
|
63
|
+
_sdk_root = ContextVar("oma_dart_project_root", default=None)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class AnalysisBarrier:
|
|
67
|
+
"""Require a new start/end pair, as the Dart 3.13 reanalyze test does."""
|
|
68
|
+
|
|
69
|
+
def __init__(self):
|
|
70
|
+
self.condition = threading.Condition()
|
|
71
|
+
self.started = 0
|
|
72
|
+
self.completed = 0
|
|
73
|
+
self.busy = False
|
|
74
|
+
|
|
75
|
+
def notify(self, busy):
|
|
76
|
+
with self.condition:
|
|
77
|
+
if busy:
|
|
78
|
+
self.started += 1
|
|
79
|
+
else:
|
|
80
|
+
self.completed = self.started
|
|
81
|
+
self.busy = busy
|
|
82
|
+
self.condition.notify_all()
|
|
83
|
+
|
|
84
|
+
def wait(self, after, timeout):
|
|
85
|
+
with self.condition:
|
|
86
|
+
if not self.condition.wait_for(
|
|
87
|
+
lambda: (
|
|
88
|
+
self.started > after
|
|
89
|
+
and self.completed == self.started
|
|
90
|
+
and not self.busy
|
|
91
|
+
),
|
|
92
|
+
timeout=timeout,
|
|
93
|
+
):
|
|
94
|
+
raise TimeoutError("Dart did not report a new completed analysis cycle")
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _init(self, *args, **kwargs):
|
|
98
|
+
self._oma_analysis = AnalysisBarrier()
|
|
99
|
+
root = args[1] if len(args) > 1 else kwargs["repository_root_path"]
|
|
100
|
+
token = _sdk_root.set(root)
|
|
101
|
+
try:
|
|
102
|
+
_original_init(self, *args, **kwargs)
|
|
103
|
+
finally:
|
|
104
|
+
_sdk_root.reset(token)
|
|
105
|
+
command = self._get_process_launch_info().cmd
|
|
106
|
+
self._oma_sdk_executable = command[0] if isinstance(command, list) else None
|
|
107
|
+
self.server.on_notification("$/analyzerStatus", lambda _: None)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _observe(self, method, params):
|
|
111
|
+
_original_observe(self, method, params)
|
|
112
|
+
if method == "$/analyzerStatus" and isinstance(params, dict):
|
|
113
|
+
busy = params.get("isAnalyzing")
|
|
114
|
+
if isinstance(busy, bool):
|
|
115
|
+
self._oma_analysis.notify(busy)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _setup(cls, settings):
|
|
119
|
+
executable = settings.get_ls_specific_settings(LanguageServerId.DART).get(
|
|
120
|
+
"dart_executable"
|
|
121
|
+
)
|
|
122
|
+
if not executable:
|
|
123
|
+
return _original_setup(settings)
|
|
124
|
+
if executable == "mise":
|
|
125
|
+
try:
|
|
126
|
+
flutter = subprocess.check_output(
|
|
127
|
+
["mise", "which", "flutter"], cwd=_sdk_root.get(),
|
|
128
|
+
text=True, stderr=subprocess.PIPE, timeout=5,
|
|
129
|
+
).strip()
|
|
130
|
+
executable = str(Path(flutter).parent / "cache/dart-sdk/bin/dart")
|
|
131
|
+
if not Path(executable).is_file():
|
|
132
|
+
raise FileNotFoundError(executable)
|
|
133
|
+
except (subprocess.SubprocessError, FileNotFoundError):
|
|
134
|
+
executable = subprocess.check_output(
|
|
135
|
+
["mise", "which", "dart"], cwd=_sdk_root.get(),
|
|
136
|
+
text=True, timeout=5,
|
|
137
|
+
).strip()
|
|
138
|
+
path = Path(executable).expanduser().resolve(strict=True)
|
|
139
|
+
if not path.is_file() or not os.access(path, os.X_OK):
|
|
140
|
+
raise ValueError(f"Not an executable Dart SDK: {path}")
|
|
141
|
+
# An argv list also supports SDK paths containing spaces.
|
|
142
|
+
return [str(path), "language-server", "--client-id", "oma.serena"]
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
DartLanguageServer._setup_runtime_dependencies = classmethod(_setup)
|
|
146
|
+
DartLanguageServer.__init__ = _init
|
|
147
|
+
DartLanguageServer._observe_server_notification = _observe
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def refresh(ls, timeout):
|
|
151
|
+
barrier = ls._oma_analysis
|
|
152
|
+
baseline = barrier.started
|
|
153
|
+
started_at = time.monotonic()
|
|
154
|
+
ls.server.send_request("dart/reanalyze")
|
|
155
|
+
barrier.wait(baseline, max(0, timeout - (time.monotonic() - started_at)))
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def file_diagnostics(
|
|
159
|
+
self, relative_file_path, start_line=0, end_line=-1, min_severity=4
|
|
160
|
+
):
|
|
161
|
+
uri = self._validate_text_document_diagnostics_request(
|
|
162
|
+
relative_file_path,
|
|
163
|
+
start_line,
|
|
164
|
+
end_line,
|
|
165
|
+
min_severity,
|
|
166
|
+
)
|
|
167
|
+
with self.open_file(relative_file_path):
|
|
168
|
+
refresh(self, 60)
|
|
169
|
+
diagnostics = self._get_cached_published_diagnostics(uri) or []
|
|
170
|
+
return self._filter_diagnostics(diagnostics, start_line, end_line, min_severity)
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
DartLanguageServer.request_text_document_diagnostics = file_diagnostics
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def collect(ls, package, repository, timeout):
|
|
177
|
+
"""A missing completion method/timeout is an error, never empty diagnostics."""
|
|
178
|
+
before = snapshot(package)
|
|
179
|
+
candidates = sorted(name for name in before if name.endswith(".dart"))
|
|
180
|
+
if not candidates:
|
|
181
|
+
raise ValueError("No Dart source files in the requested package")
|
|
182
|
+
entry = (
|
|
183
|
+
"lib/main.dart"
|
|
184
|
+
if "lib/main.dart" in candidates
|
|
185
|
+
else next(
|
|
186
|
+
(name for name in candidates if name.startswith("lib/")), candidates[0]
|
|
187
|
+
)
|
|
188
|
+
)
|
|
189
|
+
anchor = str((package / entry).relative_to(repository))
|
|
190
|
+
# Keep one real file open per package, preserving its analysis context
|
|
191
|
+
# between hook calls. open_file refreshes an existing buffer from disk.
|
|
192
|
+
anchors = getattr(ls, "_oma_dart_anchors", {})
|
|
193
|
+
for name in list(anchors):
|
|
194
|
+
if not (repository / name).is_file():
|
|
195
|
+
anchors.pop(name).__exit__(None, None, None)
|
|
196
|
+
if anchor not in anchors:
|
|
197
|
+
context = ls.open_file(anchor)
|
|
198
|
+
context.__enter__()
|
|
199
|
+
anchors[anchor] = context
|
|
200
|
+
ls._oma_dart_anchors = anchors
|
|
201
|
+
old_timeout = ls.server._request_timeout
|
|
202
|
+
try:
|
|
203
|
+
ls.server._request_timeout = timeout
|
|
204
|
+
with ls.open_file(anchor):
|
|
205
|
+
# The server watches disk asynchronously; explicitly refresh before
|
|
206
|
+
# asking for its completion barrier, including package/config edits.
|
|
207
|
+
# Dart 3.13.4 does not yet implement workspace/analysis/complete.
|
|
208
|
+
# Do not mistake an old idle event for completion of this refresh.
|
|
209
|
+
refresh(ls, timeout)
|
|
210
|
+
diagnostics = {}
|
|
211
|
+
with ls._published_diagnostics_condition:
|
|
212
|
+
for uri, entries in ls._published_diagnostics.items():
|
|
213
|
+
path = Path(unquote(urlparse(uri).path))
|
|
214
|
+
if path.is_relative_to(package) and path.is_file() and entries:
|
|
215
|
+
diagnostics[str(path.relative_to(repository))] = entries.copy()
|
|
216
|
+
finally:
|
|
217
|
+
ls.server._request_timeout = old_timeout
|
|
218
|
+
if before != snapshot(package):
|
|
219
|
+
raise RuntimeError(
|
|
220
|
+
"Project changed during analysis; retry on a stable working tree"
|
|
221
|
+
)
|
|
222
|
+
return diagnostics
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
class GetDartProjectDiagnosticsTool(Tool, ToolMarkerSymbolicRead):
|
|
226
|
+
"""Wait for Dart project analysis and return all published diagnostics."""
|
|
227
|
+
|
|
228
|
+
def apply(self, relative_path: str, timeout_seconds: int = 60) -> str:
|
|
229
|
+
"""Analyze a pub package using the existing Dart LSP.
|
|
230
|
+
|
|
231
|
+
:param relative_path: package directory containing pubspec.yaml.
|
|
232
|
+
:param timeout_seconds: maximum wait per analysis request (1..120).
|
|
233
|
+
:return: complete diagnostics, SDK identity, and package scope.
|
|
234
|
+
"""
|
|
235
|
+
if not 1 <= timeout_seconds <= 120:
|
|
236
|
+
raise ValueError("timeout_seconds must be between 1 and 120")
|
|
237
|
+
repository = Path(self.project.project_root).resolve()
|
|
238
|
+
package = (repository / relative_path).resolve()
|
|
239
|
+
if (
|
|
240
|
+
not package.is_relative_to(repository)
|
|
241
|
+
or not (package / "pubspec.yaml").is_file()
|
|
242
|
+
):
|
|
243
|
+
raise ValueError("Expected a pub package inside the active project")
|
|
244
|
+
self.project.ls_sync_file_system_changes()
|
|
245
|
+
sources = sorted(package.glob("lib/**/*.dart")) or sorted(
|
|
246
|
+
package.glob("*.dart")
|
|
247
|
+
)
|
|
248
|
+
if not sources:
|
|
249
|
+
raise ValueError("Package has no Dart entry file")
|
|
250
|
+
ls = self.create_language_server_symbol_retriever().get_language_server(
|
|
251
|
+
str(sources[0].relative_to(repository))
|
|
252
|
+
)
|
|
253
|
+
if not isinstance(ls, DartLanguageServer):
|
|
254
|
+
raise ValueError("Requested file is not managed by Dart LSP")
|
|
255
|
+
executable = ls.custom_settings.get("dart_executable")
|
|
256
|
+
if not executable:
|
|
257
|
+
raise ValueError(
|
|
258
|
+
"Set ls_specific_settings.dart.dart_executable to the project SDK, "
|
|
259
|
+
"then restart Serena"
|
|
260
|
+
)
|
|
261
|
+
if executable == "mise":
|
|
262
|
+
# Query the actual launched argv, not the possibly changed PATH.
|
|
263
|
+
executable = ls._oma_sdk_executable
|
|
264
|
+
version = subprocess.check_output(
|
|
265
|
+
[executable, "--version"], text=True, timeout=5
|
|
266
|
+
).strip()
|
|
267
|
+
diagnostics = collect(ls, package, repository, timeout_seconds)
|
|
268
|
+
return json.dumps(
|
|
269
|
+
{
|
|
270
|
+
"complete": True,
|
|
271
|
+
"project_root": str(repository),
|
|
272
|
+
"scope": str(package.relative_to(repository)),
|
|
273
|
+
"sdk_executable": str(Path(executable).resolve()),
|
|
274
|
+
"sdk_version": version,
|
|
275
|
+
"diagnostics": diagnostics,
|
|
276
|
+
}
|
|
277
|
+
)
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
"""File-granular symbol caching for Serena 1.7.0's long-lived Python process.
|
|
2
|
+
|
|
3
|
+
Legacy pickles eagerly restore every file and retain mutable workspace trees.
|
|
4
|
+
SQLite keeps cold files on disk; serialized entries isolate request mutations.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import hashlib
|
|
8
|
+
import logging
|
|
9
|
+
import pickle
|
|
10
|
+
import sqlite3
|
|
11
|
+
import threading
|
|
12
|
+
from collections.abc import MutableMapping
|
|
13
|
+
|
|
14
|
+
log = logging.getLogger(__name__)
|
|
15
|
+
FORMAT_VERSION = 1
|
|
16
|
+
MAX_ENTRY_BYTES = 16 * 1024 * 1024
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class DiskSymbolCache(MutableMapping):
|
|
20
|
+
def __init__(self, directory, kind, version):
|
|
21
|
+
self._lock = threading.RLock()
|
|
22
|
+
self._connection = None
|
|
23
|
+
digest = hashlib.sha256(pickle.dumps((FORMAT_VERSION, version))).hexdigest()[
|
|
24
|
+
:24
|
|
25
|
+
]
|
|
26
|
+
self.path = directory / f"oma-{kind}-{digest}.sqlite3"
|
|
27
|
+
try:
|
|
28
|
+
self._connection = sqlite3.connect(
|
|
29
|
+
self.path, timeout=2, isolation_level=None, check_same_thread=False
|
|
30
|
+
)
|
|
31
|
+
self._connection.execute("PRAGMA journal_mode=WAL")
|
|
32
|
+
self._connection.execute("PRAGMA cache_size=-2048")
|
|
33
|
+
self._connection.execute("PRAGMA mmap_size=0")
|
|
34
|
+
self._connection.execute(
|
|
35
|
+
"CREATE TABLE IF NOT EXISTS symbols (path TEXT PRIMARY KEY, value BLOB NOT NULL)"
|
|
36
|
+
)
|
|
37
|
+
except (OSError, sqlite3.Error) as error:
|
|
38
|
+
self._disable(error)
|
|
39
|
+
|
|
40
|
+
def _disable(self, error):
|
|
41
|
+
# A disposable cache must never make code intelligence unavailable.
|
|
42
|
+
log.warning(
|
|
43
|
+
"OMA symbol cache unavailable at %s; using uncached requests: %s",
|
|
44
|
+
self.path,
|
|
45
|
+
error,
|
|
46
|
+
)
|
|
47
|
+
self.close()
|
|
48
|
+
|
|
49
|
+
def _query(self, sql, params=()):
|
|
50
|
+
with self._lock:
|
|
51
|
+
if self._connection is None:
|
|
52
|
+
return []
|
|
53
|
+
try:
|
|
54
|
+
return self._connection.execute(sql, params).fetchall()
|
|
55
|
+
except sqlite3.Error as error:
|
|
56
|
+
self._disable(error)
|
|
57
|
+
return []
|
|
58
|
+
|
|
59
|
+
def __getitem__(self, key):
|
|
60
|
+
rows = self._query("SELECT value FROM symbols WHERE path=?", (key,))
|
|
61
|
+
if not rows:
|
|
62
|
+
raise KeyError(key)
|
|
63
|
+
try:
|
|
64
|
+
# Each request owns its decoded graph. Attaching a parent package
|
|
65
|
+
# in request_full_symbol_tree cannot mutate the persistent cache.
|
|
66
|
+
return pickle.loads(rows[0][0])
|
|
67
|
+
except Exception:
|
|
68
|
+
self._query("DELETE FROM symbols WHERE path=?", (key,))
|
|
69
|
+
raise KeyError(key) from None
|
|
70
|
+
|
|
71
|
+
def __setitem__(self, key, value):
|
|
72
|
+
with self._lock:
|
|
73
|
+
if self._connection is None:
|
|
74
|
+
return
|
|
75
|
+
try:
|
|
76
|
+
payload = pickle.dumps(value, protocol=pickle.HIGHEST_PROTOCOL)
|
|
77
|
+
except Exception:
|
|
78
|
+
return
|
|
79
|
+
if len(payload) > MAX_ENTRY_BYTES:
|
|
80
|
+
self._query("DELETE FROM symbols WHERE path=?", (key,))
|
|
81
|
+
return
|
|
82
|
+
self._query("INSERT OR REPLACE INTO symbols VALUES (?, ?)", (key, payload))
|
|
83
|
+
|
|
84
|
+
def __delitem__(self, key):
|
|
85
|
+
with self._lock:
|
|
86
|
+
if not self._query("SELECT 1 FROM symbols WHERE path=?", (key,)):
|
|
87
|
+
raise KeyError(key)
|
|
88
|
+
self._query("DELETE FROM symbols WHERE path=?", (key,))
|
|
89
|
+
|
|
90
|
+
def __iter__(self):
|
|
91
|
+
return iter(row[0] for row in self._query("SELECT path FROM symbols"))
|
|
92
|
+
|
|
93
|
+
def __len__(self):
|
|
94
|
+
rows = self._query("SELECT count(*) FROM symbols")
|
|
95
|
+
return rows[0][0] if rows else 0
|
|
96
|
+
|
|
97
|
+
def close(self):
|
|
98
|
+
with self._lock:
|
|
99
|
+
if self._connection is not None:
|
|
100
|
+
self._connection.close()
|
|
101
|
+
self._connection = None
|
|
102
|
+
|
|
103
|
+
def __del__(self):
|
|
104
|
+
self.close()
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _load_raw(self):
|
|
108
|
+
self._raw_document_symbols_cache = DiskSymbolCache(
|
|
109
|
+
self.cache_dir, "raw-symbols", self._raw_document_symbols_cache_version()
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _load_documents(self):
|
|
114
|
+
self._document_symbols_cache = DiskSymbolCache(
|
|
115
|
+
self.cache_dir, "document-symbols", self._document_symbols_cache_version()
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _save_raw(self):
|
|
120
|
+
self._raw_document_symbols_cache_is_modified = False
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _save_documents(self):
|
|
124
|
+
self._document_symbols_cache_is_modified = False
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def install_symbol_cache():
|
|
128
|
+
from solidlsp.ls import SolidLanguageServer
|
|
129
|
+
|
|
130
|
+
SolidLanguageServer._load_raw_document_symbols_cache = _load_raw
|
|
131
|
+
SolidLanguageServer._load_document_symbols_cache = _load_documents
|
|
132
|
+
SolidLanguageServer._save_raw_document_symbols_cache = _save_raw
|
|
133
|
+
SolidLanguageServer._save_document_symbols_cache = _save_documents
|