opencode-agent-skill 15.0.0 → 15.1.0
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/README.md +5 -5
- package/lib/affected-tests.mjs +37 -4
- package/lib/code-intelligence/index.mjs +3 -2
- package/lib/code-intelligence/lsp-provider.mjs +326 -52
- package/lib/compaction-resume-guard.mjs +255 -0
- package/lib/context-engine-v11.mjs +20 -7
- package/lib/execution-contract.mjs +29 -16
- package/lib/memory-engine.mjs +189 -16
- package/lib/performance-fabric.mjs +262 -107
- package/lib/subagent-artifacts.mjs +182 -0
- package/lib/verification-broker.mjs +32 -27
- package/lib/workspace-fingerprint.mjs +43 -8
- package/lib/workspace-hygiene.mjs +481 -0
- package/package.json +1 -1
- package/pi/extensions/ues-child-runtime.ts +25 -2
- package/pi/extensions/ues.ts +290 -15
package/README.md
CHANGED
|
@@ -4,9 +4,9 @@
|
|
|
4
4
|
|
|
5
5
|
**Package:** <code>opencode-agent-skill</code>
|
|
6
6
|
**Host chính:** Pi Agent
|
|
7
|
-
**Phiên bản package hiện tại:** <code>15.
|
|
7
|
+
**Phiên bản package hiện tại:** <code>15.1.0</code>
|
|
8
8
|
**Nhánh phát triển hiện tại:** V15.19 Finalization Hardening
|
|
9
|
-
**Stable npm hiện tại:** <code>15.
|
|
9
|
+
**Stable npm hiện tại:** <code>15.1.0</code>
|
|
10
10
|
**Runtime:** Node.js 22.19+
|
|
11
11
|
**License:** MIT
|
|
12
12
|
|
|
@@ -1431,15 +1431,15 @@ npm pack
|
|
|
1431
1431
|
Package version hiện tại trên nhánh <code>main</code> là:
|
|
1432
1432
|
|
|
1433
1433
|
~~~text
|
|
1434
|
-
15.
|
|
1434
|
+
15.1.0
|
|
1435
1435
|
~~~
|
|
1436
1436
|
|
|
1437
|
-
Stable npm public hiện vẫn là <code>
|
|
1437
|
+
Stable npm public hiện vẫn là <code>15.1.0</code>. V15.3 DEEP Speed giảm exploration trùng lặp ở long-horizon bằng diagnosis deduplication và bounded specialist exploration, nhưng giữ nguyên plan/verifier/integration gates. V15.4 ACP-safe child runtime ngăn UES spawn lại ACP/Zed entrypoint; child specialist luôn được chạy bằng Pi CLI thật. V15.5 Per-Leaf Turbo cho phép leaf task nhỏ trong DEEP plan tự xuống FAST, giữ failure delta task-local, và tái sử dụng context theo root namespace + workspace fingerprint để giảm retry/rebuild latency. V15.6 Fast Planning đặt soft-steer/hard-idle budget riêng cho architect/plan-checker, recovery từ warm context thay vì scan lại, và lease/cleanup sandbox orphan an toàn sau crash/Stop. V15.7 Lightweight Sandbox Cleanup thu hồi worktree theo trace ngay khi /ues-run kết thúc, dọn metadata mồ côi, giảm legacy grace xuống 30 phút và cung cấp /ues-clean để dọn stale artifacts an toàn mà không đụng sandbox đang active. V15.8 Plan Gate Recovery bổ sung soft-steer + bounded recovery cho plan-checker và quét cả detached physical sandbox folders mà Git worktree registry đã quên. V15.9 Runtime Artifact Isolation loại .ues-traces/.ues-cache/.ues-services/.ues-work và runtime state khác khỏi task delta, write-scope conflict và sandbox integration, nhưng vẫn giữ safety gate cho source/config thật như apps/mobile/package.json. V15.10 Adaptive Stability Runtime thay hard-timeout tuyệt đối bằng activity-aware bounded deadlines, giữ absolute cap chống treo, salvage plan JSON đã validate từ partial output, phát graph trước prose, và role-bound context cho read-only planner trong task high-risk mà không giảm evidence budget của executor/verifier. V15.1 bắt đầu bằng deterministic <code>/ues-run</code> admission và Managed Background Services để model yếu không thể bỏ qua controller và không bị treo bởi dev server foreground.
|
|
1438
1438
|
|
|
1439
1439
|
Test file packed với Pi:
|
|
1440
1440
|
|
|
1441
1441
|
~~~cmd
|
|
1442
|
-
pi install .\opencode-agent-skill-
|
|
1442
|
+
pi install .\opencode-agent-skill-15.1.0.tgz
|
|
1443
1443
|
pi list
|
|
1444
1444
|
~~~
|
|
1445
1445
|
|
package/lib/affected-tests.mjs
CHANGED
|
@@ -9,6 +9,7 @@ const SOURCE_EXT = new Set([".js",".jsx",".mjs",".cjs",".ts",".tsx",".py",".java
|
|
|
9
9
|
const SKIP = new Set([".git","node_modules",".next","dist","build","coverage",".venv","venv","target","Pods","DerivedData",".gradle",".ues-cache",".ues-work",".ues-sandboxes",".ues-traces",".ues-learning",".ues-dashboard",".ues-memory",".ues-evals",".ues-services"])
|
|
10
10
|
const AFFECTED_TEST_CACHE = new Map()
|
|
11
11
|
const AFFECTED_TEST_INFLIGHT = new Map()
|
|
12
|
+
const TEST_CONTENT_CACHE = new Map()
|
|
12
13
|
|
|
13
14
|
function git(root, args) {
|
|
14
15
|
return spawnSync("git", args, { cwd: root, encoding: "utf8", maxBuffer: 8 * 1024 * 1024 })
|
|
@@ -47,6 +48,37 @@ async function walk(root, options = {}) {
|
|
|
47
48
|
return rows
|
|
48
49
|
}
|
|
49
50
|
|
|
51
|
+
async function projectFiles(root, options = {}) {
|
|
52
|
+
const indexed = git(root, ["ls-files", "-co", "--exclude-standard", "-z"])
|
|
53
|
+
if (indexed.status === 0) {
|
|
54
|
+
const files = String(indexed.stdout || "")
|
|
55
|
+
.split("\0")
|
|
56
|
+
.map((value) => normalize(value))
|
|
57
|
+
.filter(Boolean)
|
|
58
|
+
.filter((relative) => !relative.split("/").some((part) => SKIP.has(part)))
|
|
59
|
+
.map((relative) => path.resolve(root, relative))
|
|
60
|
+
return { files: [...new Set(files)], source: "git-index" }
|
|
61
|
+
}
|
|
62
|
+
return { files: await walk(root, options), source: "filesystem-walk" }
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async function cachedTestContent(file, info) {
|
|
66
|
+
if (!info?.size || info.size > 512 * 1024) return ""
|
|
67
|
+
const key = path.resolve(file)
|
|
68
|
+
const signature = String(info.size) + ":" + String(Math.trunc(Number(info.mtimeMs || 0)))
|
|
69
|
+
const cached = TEST_CONTENT_CACHE.get(key)
|
|
70
|
+
if (cached?.signature === signature) return cached.content
|
|
71
|
+
const content = await readFile(key, "utf8").catch(() => "")
|
|
72
|
+
TEST_CONTENT_CACHE.delete(key)
|
|
73
|
+
TEST_CONTENT_CACHE.set(key, { signature, content })
|
|
74
|
+
while (TEST_CONTENT_CACHE.size > 1200) {
|
|
75
|
+
const oldest = TEST_CONTENT_CACHE.keys().next().value
|
|
76
|
+
if (!oldest) break
|
|
77
|
+
TEST_CONTENT_CACHE.delete(oldest)
|
|
78
|
+
}
|
|
79
|
+
return content
|
|
80
|
+
}
|
|
81
|
+
|
|
50
82
|
export function gitChangedFiles(root = process.cwd()) {
|
|
51
83
|
root = path.resolve(root)
|
|
52
84
|
const inside = git(root, ["rev-parse", "--is-inside-work-tree"])
|
|
@@ -190,7 +222,8 @@ export async function resolveAffectedTests(root = process.cwd(), options = {}) {
|
|
|
190
222
|
}
|
|
191
223
|
|
|
192
224
|
const compute = async () => {
|
|
193
|
-
const
|
|
225
|
+
const inventory = await projectFiles(root, options)
|
|
226
|
+
const files = inventory.files
|
|
194
227
|
const tests = files
|
|
195
228
|
.map((file) => normalize(path.relative(root, file)))
|
|
196
229
|
.filter((file) => TEST_RE.test(file))
|
|
@@ -198,9 +231,7 @@ export async function resolveAffectedTests(root = process.cwd(), options = {}) {
|
|
|
198
231
|
const ranked = []
|
|
199
232
|
for (const testFile of tests) {
|
|
200
233
|
const info = await stat(path.join(root, testFile)).catch(() => null)
|
|
201
|
-
const content =
|
|
202
|
-
? await readFile(path.join(root, testFile), "utf8").catch(() => "")
|
|
203
|
-
: ""
|
|
234
|
+
const content = await cachedTestContent(path.join(root, testFile), info)
|
|
204
235
|
let best = { score: 0, reasons: [], changedFile: null }
|
|
205
236
|
for (const changedFile of changed) {
|
|
206
237
|
const current = scoreTest(testFile, changedFile, content)
|
|
@@ -232,6 +263,7 @@ export async function resolveAffectedTests(root = process.cwd(), options = {}) {
|
|
|
232
263
|
suggestedCommands,
|
|
233
264
|
truncated: ranked.length > selected.length,
|
|
234
265
|
cacheHit: false,
|
|
266
|
+
inventorySource: inventory.source,
|
|
235
267
|
}
|
|
236
268
|
}
|
|
237
269
|
|
|
@@ -257,4 +289,5 @@ export async function resolveAffectedTests(root = process.cwd(), options = {}) {
|
|
|
257
289
|
export function clearAffectedTestCache() {
|
|
258
290
|
AFFECTED_TEST_CACHE.clear()
|
|
259
291
|
AFFECTED_TEST_INFLIGHT.clear()
|
|
292
|
+
TEST_CONTENT_CACHE.clear()
|
|
260
293
|
}
|
|
@@ -3,7 +3,7 @@ import { spawnSync } from "node:child_process"
|
|
|
3
3
|
import path from "node:path"
|
|
4
4
|
import { querySemanticIndex } from "../semantic-index.mjs"
|
|
5
5
|
import { anchoredLines, applyAnchoredEdits } from "./edit-anchor.mjs"
|
|
6
|
-
import { diagnoseCode, lspProviderStatus } from "./lsp-provider.mjs"
|
|
6
|
+
import { diagnoseCode, lspOperation, lspProviderStatus, LSP_OPERATIONS } from "./lsp-provider.mjs"
|
|
7
7
|
|
|
8
8
|
const AST_COMMANDS = ["ast-grep", "sg"]
|
|
9
9
|
const commandCache = new Map()
|
|
@@ -40,6 +40,7 @@ export function probeCodeIntelligence(file = "") {
|
|
|
40
40
|
semanticIndex: true,
|
|
41
41
|
astProvider,
|
|
42
42
|
lsp: lspProviderStatus(file),
|
|
43
|
+
lspOperations: [...LSP_OPERATIONS],
|
|
43
44
|
}
|
|
44
45
|
}
|
|
45
46
|
|
|
@@ -92,4 +93,4 @@ export async function searchCodeIntelligence(root, query, options = {}) {
|
|
|
92
93
|
}
|
|
93
94
|
}
|
|
94
95
|
|
|
95
|
-
export { diagnoseCode, lspProviderStatus } from "./lsp-provider.mjs"
|
|
96
|
+
export { diagnoseCode, lspOperation, lspProviderStatus, LSP_OPERATIONS } from "./lsp-provider.mjs"
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { spawn, spawnSync } from "node:child_process"
|
|
2
2
|
import { lstat, readFile, realpath } from "node:fs/promises"
|
|
3
3
|
import path from "node:path"
|
|
4
|
-
import { pathToFileURL } from "node:url"
|
|
4
|
+
import { fileURLToPath, pathToFileURL } from "node:url"
|
|
5
5
|
|
|
6
6
|
const PROVIDERS = [
|
|
7
7
|
{ id: "typescript-language-server", extensions: new Set([".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs"]), commands: [["typescript-language-server", ["--stdio"]]], languageId: (ext) => [".ts", ".tsx"].includes(ext) ? "typescript" : "javascript" },
|
|
@@ -11,6 +11,17 @@ const PROVIDERS = [
|
|
|
11
11
|
{ id: "clangd", extensions: new Set([".c", ".cc", ".cpp", ".cxx", ".h", ".hpp"]), commands: [["clangd", []]], languageId: (ext) => [".c", ".h"].includes(ext) ? "c" : "cpp" },
|
|
12
12
|
]
|
|
13
13
|
|
|
14
|
+
export const LSP_OPERATIONS = Object.freeze([
|
|
15
|
+
"diagnostics",
|
|
16
|
+
"definition",
|
|
17
|
+
"references",
|
|
18
|
+
"symbols",
|
|
19
|
+
"hover",
|
|
20
|
+
"rename-preview",
|
|
21
|
+
"incoming-calls",
|
|
22
|
+
"outgoing-calls",
|
|
23
|
+
])
|
|
24
|
+
|
|
14
25
|
const commandCache = new Map()
|
|
15
26
|
const COMMAND_TTL_MS = 60_000
|
|
16
27
|
|
|
@@ -31,11 +42,11 @@ function inside(root, target) {
|
|
|
31
42
|
async function safeFile(root, relative) {
|
|
32
43
|
const base = await realpath(path.resolve(root)).catch(() => path.resolve(root))
|
|
33
44
|
const requested = path.resolve(base, String(relative || ""))
|
|
34
|
-
if (!inside(base, requested)) throw new Error("
|
|
45
|
+
if (!inside(base, requested)) throw new Error("LSP path escapes workspace root")
|
|
35
46
|
const info = await lstat(requested)
|
|
36
|
-
if (!info.isFile()) throw new Error("
|
|
47
|
+
if (!info.isFile()) throw new Error("LSP path is not a file")
|
|
37
48
|
const actual = await realpath(requested)
|
|
38
|
-
if (!inside(base, actual)) throw new Error("
|
|
49
|
+
if (!inside(base, actual)) throw new Error("LSP symlink escapes workspace root")
|
|
39
50
|
return { base, file: actual, info }
|
|
40
51
|
}
|
|
41
52
|
|
|
@@ -59,7 +70,13 @@ export function lspProviderStatus(file = "") {
|
|
|
59
70
|
const selected = provider.commands.find(([command]) => commandExists(command)) || null
|
|
60
71
|
return { id: provider.id, available: Boolean(selected), command: selected?.[0] || null, extensions: [...provider.extensions] }
|
|
61
72
|
})
|
|
62
|
-
return {
|
|
73
|
+
return {
|
|
74
|
+
schemaVersion: 2,
|
|
75
|
+
extension: ext,
|
|
76
|
+
available: providers.some((row) => row.available),
|
|
77
|
+
operations: [...LSP_OPERATIONS],
|
|
78
|
+
providers,
|
|
79
|
+
}
|
|
63
80
|
}
|
|
64
81
|
|
|
65
82
|
function encodeMessage(payload) {
|
|
@@ -67,6 +84,23 @@ function encodeMessage(payload) {
|
|
|
67
84
|
return Buffer.concat([Buffer.from("Content-Length: " + body.length + "\r\n\r\n", "ascii"), body])
|
|
68
85
|
}
|
|
69
86
|
|
|
87
|
+
function relativeUri(base, uri) {
|
|
88
|
+
try {
|
|
89
|
+
if (!String(uri || "").startsWith("file:")) return String(uri || "")
|
|
90
|
+
const file = fileURLToPath(uri)
|
|
91
|
+
if (!inside(base, file)) return String(uri || "")
|
|
92
|
+
return path.relative(base, file).replaceAll("\\", "/")
|
|
93
|
+
} catch {
|
|
94
|
+
return String(uri || "")
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function position(options = {}) {
|
|
99
|
+
const line = Math.max(1, Math.trunc(Number(options.line || 1)))
|
|
100
|
+
const character = Math.max(1, Math.trunc(Number(options.character || 1)))
|
|
101
|
+
return { line: line - 1, character: character - 1 }
|
|
102
|
+
}
|
|
103
|
+
|
|
70
104
|
function sanitizeDiagnostic(value = {}) {
|
|
71
105
|
return {
|
|
72
106
|
range: value.range || null,
|
|
@@ -77,73 +111,214 @@ function sanitizeDiagnostic(value = {}) {
|
|
|
77
111
|
}
|
|
78
112
|
}
|
|
79
113
|
|
|
80
|
-
|
|
114
|
+
function sanitizeLocations(base, value, limit = 120) {
|
|
115
|
+
const list = Array.isArray(value) ? value : value ? [value] : []
|
|
116
|
+
return list.slice(0, limit).map((item) => {
|
|
117
|
+
if (item?.targetUri) {
|
|
118
|
+
return {
|
|
119
|
+
file: relativeUri(base, item.targetUri),
|
|
120
|
+
range: item.targetRange || null,
|
|
121
|
+
selectionRange: item.targetSelectionRange || null,
|
|
122
|
+
originSelectionRange: item.originSelectionRange || null,
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
return {
|
|
126
|
+
file: relativeUri(base, item?.uri),
|
|
127
|
+
range: item?.range || null,
|
|
128
|
+
}
|
|
129
|
+
})
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function flattenSymbols(base, values, output, parent = null, limit = 160) {
|
|
133
|
+
for (const item of Array.isArray(values) ? values : []) {
|
|
134
|
+
if (output.length >= limit) break
|
|
135
|
+
output.push({
|
|
136
|
+
name: String(item?.name || "").slice(0, 300),
|
|
137
|
+
detail: item?.detail == null ? null : String(item.detail).slice(0, 500),
|
|
138
|
+
kind: item?.kind ?? null,
|
|
139
|
+
containerName: item?.containerName || parent,
|
|
140
|
+
file: item?.location?.uri ? relativeUri(base, item.location.uri) : null,
|
|
141
|
+
range: item?.range || item?.location?.range || null,
|
|
142
|
+
selectionRange: item?.selectionRange || null,
|
|
143
|
+
})
|
|
144
|
+
if (Array.isArray(item?.children)) flattenSymbols(base, item.children, output, item?.name || parent, limit)
|
|
145
|
+
}
|
|
146
|
+
return output
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function hoverText(contents) {
|
|
150
|
+
if (contents == null) return ""
|
|
151
|
+
if (typeof contents === "string") return contents
|
|
152
|
+
if (Array.isArray(contents)) {
|
|
153
|
+
return contents.map((item) => typeof item === "string" ? item : item?.value || "").filter(Boolean).join("\n")
|
|
154
|
+
}
|
|
155
|
+
if (typeof contents === "object") return String(contents.value || "")
|
|
156
|
+
return String(contents)
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function sanitizeWorkspaceEdit(base, edit, limit = 240) {
|
|
160
|
+
const rows = []
|
|
161
|
+
for (const [uri, edits] of Object.entries(edit?.changes || {})) {
|
|
162
|
+
for (const item of Array.isArray(edits) ? edits : []) {
|
|
163
|
+
rows.push({ file: relativeUri(base, uri), range: item.range || null, newText: String(item.newText || "").slice(0, 4000) })
|
|
164
|
+
if (rows.length >= limit) return { edits: rows, truncated: true }
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
for (const change of Array.isArray(edit?.documentChanges) ? edit.documentChanges : []) {
|
|
168
|
+
const uri = change?.textDocument?.uri
|
|
169
|
+
for (const item of Array.isArray(change?.edits) ? change.edits : []) {
|
|
170
|
+
rows.push({ file: relativeUri(base, uri), range: item.range || null, newText: String(item.newText || "").slice(0, 4000) })
|
|
171
|
+
if (rows.length >= limit) return { edits: rows, truncated: true }
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
return { edits: rows, truncated: false }
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function sanitizeHierarchyItem(base, item) {
|
|
178
|
+
if (!item) return null
|
|
179
|
+
return {
|
|
180
|
+
name: String(item.name || "").slice(0, 300),
|
|
181
|
+
detail: item.detail == null ? null : String(item.detail).slice(0, 500),
|
|
182
|
+
kind: item.kind ?? null,
|
|
183
|
+
file: relativeUri(base, item.uri),
|
|
184
|
+
range: item.range || null,
|
|
185
|
+
selectionRange: item.selectionRange || null,
|
|
186
|
+
_raw: item,
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function sanitizeHierarchyCalls(base, values, direction, limit = 120) {
|
|
191
|
+
return (Array.isArray(values) ? values : []).slice(0, limit).map((call) => {
|
|
192
|
+
const item = direction === "incoming" ? call.from : call.to
|
|
193
|
+
const clean = sanitizeHierarchyItem(base, item)
|
|
194
|
+
if (clean) delete clean._raw
|
|
195
|
+
return {
|
|
196
|
+
item: clean,
|
|
197
|
+
ranges: direction === "incoming" ? (call.fromRanges || []) : (call.fromRanges || []),
|
|
198
|
+
}
|
|
199
|
+
})
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
async function withLspSession(root, relative, options, operation) {
|
|
81
203
|
const target = await safeFile(root, relative)
|
|
82
204
|
const provider = providerFor(target.file)
|
|
83
205
|
const relativeFile = path.relative(target.base, target.file).replaceAll("\\", "/")
|
|
84
206
|
if (!provider?.command) {
|
|
85
|
-
return {
|
|
207
|
+
return {
|
|
208
|
+
schemaVersion: 2,
|
|
209
|
+
file: relativeFile,
|
|
210
|
+
available: false,
|
|
211
|
+
provider: provider?.id || null,
|
|
212
|
+
operation: options.operation || null,
|
|
213
|
+
reason: provider ? "lsp-command-unavailable" : "unsupported-extension",
|
|
214
|
+
result: null,
|
|
215
|
+
}
|
|
86
216
|
}
|
|
87
217
|
|
|
88
218
|
const maxBytes = Math.max(8 * 1024, Math.min(4 * 1024 * 1024, Number(options.maxBytes || 1024 * 1024)))
|
|
89
|
-
if (target.info.size > maxBytes) throw new Error("
|
|
219
|
+
if (target.info.size > maxBytes) throw new Error("LSP file exceeds limit (" + target.info.size + " > " + maxBytes + " bytes)")
|
|
90
220
|
const source = await readFile(target.file, "utf8")
|
|
91
221
|
const uri = pathToFileURL(target.file).href
|
|
92
222
|
const rootUri = pathToFileURL(target.base + path.sep).href
|
|
93
|
-
const timeoutMs = Math.max(500, Math.min(
|
|
94
|
-
const maxDiagnostics = Math.max(1, Math.min(500, Number(options.maxDiagnostics || 100)))
|
|
223
|
+
const timeoutMs = Math.max(500, Math.min(15_000, Number(options.timeoutMs || 5000)))
|
|
95
224
|
|
|
96
225
|
return await new Promise((resolve) => {
|
|
97
|
-
const child = spawn(provider.command, provider.args, {
|
|
226
|
+
const child = spawn(provider.command, provider.args, {
|
|
227
|
+
cwd: target.base,
|
|
228
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
229
|
+
windowsHide: true,
|
|
230
|
+
shell: false,
|
|
231
|
+
})
|
|
98
232
|
let buffer = Buffer.alloc(0)
|
|
99
233
|
let stderr = ""
|
|
100
234
|
let settled = false
|
|
101
|
-
let
|
|
102
|
-
|
|
235
|
+
let nextId = 1
|
|
236
|
+
const pending = new Map()
|
|
237
|
+
const notificationWaiters = new Map()
|
|
238
|
+
const recentNotifications = new Map()
|
|
103
239
|
let timer = null
|
|
104
240
|
|
|
105
|
-
const finish = (reason, error = null) => {
|
|
241
|
+
const finish = (reason, result = null, error = null) => {
|
|
106
242
|
if (settled) return
|
|
107
243
|
settled = true
|
|
108
244
|
if (timer) clearTimeout(timer)
|
|
245
|
+
for (const waiter of pending.values()) waiter.reject(new Error("LSP session settled: " + reason))
|
|
246
|
+
pending.clear()
|
|
109
247
|
try {
|
|
110
|
-
if (
|
|
111
|
-
child.stdin.write(encodeMessage({ jsonrpc: "2.0", id: 2, method: "shutdown", params: null }))
|
|
112
|
-
child.stdin.write(encodeMessage({ jsonrpc: "2.0", method: "exit", params: null }))
|
|
113
|
-
}
|
|
248
|
+
if (child.stdin.writable) child.stdin.write(encodeMessage({ jsonrpc: "2.0", method: "exit", params: null }))
|
|
114
249
|
} catch {}
|
|
115
250
|
setTimeout(() => { try { child.kill() } catch {} }, 50).unref?.()
|
|
116
251
|
resolve({
|
|
117
|
-
schemaVersion:
|
|
252
|
+
schemaVersion: 2,
|
|
118
253
|
file: relativeFile,
|
|
119
254
|
available: true,
|
|
120
255
|
provider: provider.id,
|
|
121
256
|
command: provider.command,
|
|
122
|
-
|
|
257
|
+
operation: options.operation || null,
|
|
123
258
|
reason,
|
|
259
|
+
result,
|
|
124
260
|
stderr: stderr.trim().slice(0, 2000) || null,
|
|
125
|
-
error: error ? String(error instanceof Error ? error.message : error).slice(0,
|
|
261
|
+
error: error ? String(error instanceof Error ? error.message : error).slice(0, 1200) : null,
|
|
126
262
|
})
|
|
127
263
|
}
|
|
128
264
|
|
|
129
|
-
const
|
|
130
|
-
if (
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
265
|
+
const send = (payload) => {
|
|
266
|
+
if (settled || !child.stdin.writable) throw new Error("LSP stdin unavailable")
|
|
267
|
+
child.stdin.write(encodeMessage(payload))
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
const request = (method, params) => new Promise((res, rej) => {
|
|
271
|
+
const id = nextId++
|
|
272
|
+
pending.set(id, { resolve: res, reject: rej, method })
|
|
273
|
+
try { send({ jsonrpc: "2.0", id, method, params }) }
|
|
274
|
+
catch (error) { pending.delete(id); rej(error) }
|
|
275
|
+
})
|
|
276
|
+
|
|
277
|
+
const notify = (method, params) => send({ jsonrpc: "2.0", method, params })
|
|
278
|
+
|
|
279
|
+
const waitForNotification = (method) => {
|
|
280
|
+
if (recentNotifications.has(method)) {
|
|
281
|
+
const value = recentNotifications.get(method)
|
|
282
|
+
recentNotifications.delete(method)
|
|
283
|
+
return Promise.resolve(value)
|
|
284
|
+
}
|
|
285
|
+
return new Promise((res) => {
|
|
286
|
+
const list = notificationWaiters.get(method) || []
|
|
287
|
+
list.push(res)
|
|
288
|
+
notificationWaiters.set(method, list)
|
|
289
|
+
})
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
const dispatch = (message) => {
|
|
293
|
+
if (message?.id != null && pending.has(message.id)) {
|
|
294
|
+
const waiter = pending.get(message.id)
|
|
295
|
+
pending.delete(message.id)
|
|
296
|
+
if (message.error) waiter.reject(new Error(String(message.error.message || "LSP request failed")))
|
|
297
|
+
else waiter.resolve(message.result)
|
|
142
298
|
return
|
|
143
299
|
}
|
|
144
|
-
if (message?.method
|
|
145
|
-
|
|
146
|
-
|
|
300
|
+
if (message?.method) {
|
|
301
|
+
// Language servers may issue configuration/capability requests. Reply
|
|
302
|
+
// deterministically so the server does not stall waiting on a client UI.
|
|
303
|
+
if (message.id != null) {
|
|
304
|
+
try {
|
|
305
|
+
const result = message.method === "workspace/configuration"
|
|
306
|
+
? (Array.isArray(message.params?.items) ? message.params.items.map(() => null) : [])
|
|
307
|
+
: null
|
|
308
|
+
send({ jsonrpc: "2.0", id: message.id, result })
|
|
309
|
+
} catch {}
|
|
310
|
+
}
|
|
311
|
+
const waiters = notificationWaiters.get(message.method) || []
|
|
312
|
+
if (waiters.length) {
|
|
313
|
+
notificationWaiters.delete(message.method)
|
|
314
|
+
for (const waiter of waiters) waiter(message.params)
|
|
315
|
+
} else {
|
|
316
|
+
recentNotifications.set(message.method, message.params)
|
|
317
|
+
if (recentNotifications.size > 16) {
|
|
318
|
+
const oldest = recentNotifications.keys().next().value
|
|
319
|
+
if (oldest) recentNotifications.delete(oldest)
|
|
320
|
+
}
|
|
321
|
+
}
|
|
147
322
|
}
|
|
148
323
|
}
|
|
149
324
|
|
|
@@ -159,31 +334,130 @@ export async function diagnoseCode(root, relative, options = {}) {
|
|
|
159
334
|
if (buffer.length < bodyStart + length) return
|
|
160
335
|
const body = buffer.subarray(bodyStart, bodyStart + length).toString("utf8")
|
|
161
336
|
buffer = buffer.subarray(bodyStart + length)
|
|
162
|
-
try {
|
|
337
|
+
try { dispatch(JSON.parse(body)) } catch {}
|
|
163
338
|
}
|
|
164
339
|
}
|
|
165
340
|
|
|
166
341
|
child.stdout.on("data", (chunk) => { buffer = Buffer.concat([buffer, Buffer.from(chunk)]); parse() })
|
|
167
342
|
child.stderr.on("data", (chunk) => { if (stderr.length < 64 * 1024) stderr += chunk.toString() })
|
|
168
|
-
child.on("error", (error) => finish("spawn-error", error))
|
|
343
|
+
child.on("error", (error) => finish("spawn-error", null, error))
|
|
169
344
|
child.on("close", (code) => { if (!settled) finish("server-exit:" + (code ?? "unknown")) })
|
|
170
345
|
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
346
|
+
timer = setTimeout(() => finish("timeout"), timeoutMs)
|
|
347
|
+
timer.unref?.()
|
|
348
|
+
|
|
349
|
+
;(async () => {
|
|
350
|
+
try {
|
|
351
|
+
await request("initialize", {
|
|
177
352
|
processId: process.pid,
|
|
178
353
|
rootUri,
|
|
179
|
-
capabilities: {
|
|
354
|
+
capabilities: {
|
|
355
|
+
textDocument: {
|
|
356
|
+
publishDiagnostics: { relatedInformation: true },
|
|
357
|
+
definition: { linkSupport: true },
|
|
358
|
+
references: {},
|
|
359
|
+
documentSymbol: { hierarchicalDocumentSymbolSupport: true },
|
|
360
|
+
hover: { contentFormat: ["markdown", "plaintext"] },
|
|
361
|
+
rename: { prepareSupport: true },
|
|
362
|
+
callHierarchy: {},
|
|
363
|
+
},
|
|
364
|
+
workspace: { workspaceFolders: true, applyEdit: false },
|
|
365
|
+
},
|
|
180
366
|
workspaceFolders: [{ uri: rootUri, name: path.basename(target.base) || "workspace" }],
|
|
181
|
-
}
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
367
|
+
})
|
|
368
|
+
notify("initialized", {})
|
|
369
|
+
notify("textDocument/didOpen", {
|
|
370
|
+
textDocument: { uri, languageId: provider.languageId, version: 1, text: source },
|
|
371
|
+
})
|
|
372
|
+
const result = await operation({
|
|
373
|
+
request,
|
|
374
|
+
notify,
|
|
375
|
+
waitForNotification,
|
|
376
|
+
uri,
|
|
377
|
+
base: target.base,
|
|
378
|
+
file: relativeFile,
|
|
379
|
+
})
|
|
380
|
+
finish("ok", result)
|
|
381
|
+
} catch (error) {
|
|
382
|
+
finish("request-error", null, error)
|
|
383
|
+
}
|
|
384
|
+
})()
|
|
385
|
+
})
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
export async function lspOperation(root, relative, operation, options = {}) {
|
|
389
|
+
const op = String(operation || "")
|
|
390
|
+
if (!LSP_OPERATIONS.includes(op)) throw new Error("unsupported LSP operation: " + op)
|
|
391
|
+
const maxResults = Math.max(1, Math.min(500, Number(options.maxResults || 120)))
|
|
392
|
+
return withLspSession(root, relative, { ...options, operation: op }, async (session) => {
|
|
393
|
+
const pos = position(options)
|
|
394
|
+
const textDocument = { uri: session.uri }
|
|
395
|
+
|
|
396
|
+
if (op === "diagnostics") {
|
|
397
|
+
const params = await session.waitForNotification("textDocument/publishDiagnostics")
|
|
398
|
+
const values = Array.isArray(params?.diagnostics) ? params.diagnostics : []
|
|
399
|
+
return { diagnostics: values.slice(0, maxResults).map(sanitizeDiagnostic) }
|
|
185
400
|
}
|
|
186
|
-
|
|
187
|
-
|
|
401
|
+
if (op === "definition") {
|
|
402
|
+
const result = await session.request("textDocument/definition", { textDocument, position: pos })
|
|
403
|
+
return { locations: sanitizeLocations(session.base, result, maxResults) }
|
|
404
|
+
}
|
|
405
|
+
if (op === "references") {
|
|
406
|
+
const result = await session.request("textDocument/references", {
|
|
407
|
+
textDocument,
|
|
408
|
+
position: pos,
|
|
409
|
+
context: { includeDeclaration: options.includeDeclaration !== false },
|
|
410
|
+
})
|
|
411
|
+
return { locations: sanitizeLocations(session.base, result, maxResults) }
|
|
412
|
+
}
|
|
413
|
+
if (op === "symbols") {
|
|
414
|
+
const result = await session.request("textDocument/documentSymbol", { textDocument })
|
|
415
|
+
return { symbols: flattenSymbols(session.base, result, [], null, maxResults) }
|
|
416
|
+
}
|
|
417
|
+
if (op === "hover") {
|
|
418
|
+
const result = await session.request("textDocument/hover", { textDocument, position: pos })
|
|
419
|
+
return {
|
|
420
|
+
range: result?.range || null,
|
|
421
|
+
markdown: hoverText(result?.contents).slice(0, Number(options.maxHoverChars || 12000)),
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
if (op === "rename-preview") {
|
|
425
|
+
const newName = String(options.newName || "").trim()
|
|
426
|
+
if (!newName) throw new Error("rename-preview requires newName")
|
|
427
|
+
let prepared = null
|
|
428
|
+
try {
|
|
429
|
+
prepared = await session.request("textDocument/prepareRename", { textDocument, position: pos })
|
|
430
|
+
} catch {}
|
|
431
|
+
const result = await session.request("textDocument/rename", { textDocument, position: pos, newName })
|
|
432
|
+
return {
|
|
433
|
+
prepared: prepared || null,
|
|
434
|
+
newName,
|
|
435
|
+
...sanitizeWorkspaceEdit(session.base, result, maxResults),
|
|
436
|
+
applied: false,
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
if (op === "incoming-calls" || op === "outgoing-calls") {
|
|
440
|
+
const prepared = await session.request("textDocument/prepareCallHierarchy", { textDocument, position: pos })
|
|
441
|
+
const item = Array.isArray(prepared) ? prepared[0] : prepared
|
|
442
|
+
if (!item) return { root: null, calls: [] }
|
|
443
|
+
const method = op === "incoming-calls" ? "callHierarchy/incomingCalls" : "callHierarchy/outgoingCalls"
|
|
444
|
+
const result = await session.request(method, { item })
|
|
445
|
+
const rootItem = sanitizeHierarchyItem(session.base, item)
|
|
446
|
+
if (rootItem) delete rootItem._raw
|
|
447
|
+
return {
|
|
448
|
+
root: rootItem,
|
|
449
|
+
calls: sanitizeHierarchyCalls(session.base, result, op === "incoming-calls" ? "incoming" : "outgoing", maxResults),
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
return null
|
|
188
453
|
})
|
|
189
454
|
}
|
|
455
|
+
|
|
456
|
+
export async function diagnoseCode(root, relative, options = {}) {
|
|
457
|
+
const result = await lspOperation(root, relative, "diagnostics", options)
|
|
458
|
+
return {
|
|
459
|
+
...result,
|
|
460
|
+
diagnostics: result?.result?.diagnostics || [],
|
|
461
|
+
result: undefined,
|
|
462
|
+
}
|
|
463
|
+
}
|