@pugi/cli 0.1.0-beta.88 → 0.1.0-beta.89
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/dist/core/auth/env-provider.js +1 -1
- package/dist/core/context/markdown-traverse.js +1 -1
- package/dist/core/credentials.js +1 -1
- package/dist/core/engine/anvil-client.js +63 -0
- package/dist/core/engine/native-pugi.js +1 -1
- package/dist/core/engine/tool-bridge.js +436 -0
- package/dist/core/hooks/events.js +3 -1
- package/dist/core/hooks/registry.js +3 -0
- package/dist/core/hooks/worktree-events.js +158 -0
- package/dist/core/lsp/client.js +453 -0
- package/dist/core/lsp/server-detect.js +173 -0
- package/dist/core/lsp/symbol-cache.js +162 -0
- package/dist/core/lsp/symbol-tools.js +296 -4
- package/dist/core/mcp/server.js +1 -1
- package/dist/core/repl/ask.js +1 -1
- package/dist/core/repl/session.js +3 -3
- package/dist/core/repl/slash-commands.js +1 -1
- package/dist/core/settings.js +26 -0
- package/dist/core/worktree/include-parser.js +249 -0
- package/dist/runtime/cli.js +108 -8
- package/dist/runtime/commands/agents.js +1 -1
- package/dist/runtime/commands/hooks.js +3 -0
- package/dist/runtime/commands/review-consensus.js +1 -1
- package/dist/runtime/version.js +1 -1
- package/dist/runtime/worktree-bootstrap.js +579 -0
- package/dist/tools/lsp-tools.js +377 -1
- package/dist/tools/registry.js +23 -0
- package/dist/tui/input-box.js +1 -1
- package/dist/tui/render.js +1 -1
- package/dist/tui/repl.js +1 -1
- package/dist/tui/status-bar.js +1 -1
- package/dist/tui/update-banner.js +1 -1
- package/package.json +3 -3
- package/test/scenarios/compact-force.scenario.txt +3 -2
- package/test/scenarios/identity.scenario.txt +6 -5
- package/test/scenarios/persona-handoff.scenario.txt +2 -1
- package/test/scenarios/walkback.scenario.txt +6 -6
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Worktree lifecycle hooks - PUGI-487.
|
|
3
|
+
*
|
|
4
|
+
* Extends the existing hook lifecycle (`SessionStart` / `PreToolUse` /
|
|
5
|
+
* etc.) with two new events targeted at the user-facing `--worktree`
|
|
6
|
+
* flag:
|
|
7
|
+
*
|
|
8
|
+
* - `WorktreeCreate`: fired when the operator runs
|
|
9
|
+
* `pugi --worktree <name>` BEFORE git has touched the filesystem.
|
|
10
|
+
* Receives a JSON document on stdin describing the requested
|
|
11
|
+
* worktree. If the hook's stdout has a JSON envelope with a
|
|
12
|
+
* `directory` field, the runtime uses that path instead of the
|
|
13
|
+
* default `.claude/worktrees/<name>/` location. Non-zero exit aborts
|
|
14
|
+
* worktree creation when `blocking: true` is set.
|
|
15
|
+
*
|
|
16
|
+
* - `WorktreeRemove`: fired when the operator (or the cleanup sweep)
|
|
17
|
+
* is about to delete a worktree. Receives a JSON document on stdin
|
|
18
|
+
* describing the target. Non-zero exit aborts removal when
|
|
19
|
+
* `blocking: true`.
|
|
20
|
+
*
|
|
21
|
+
* Both events follow the JSON-on-stdin pattern of the existing v2
|
|
22
|
+
* hook contract. The dispatcher is intentionally small because the
|
|
23
|
+
* heavy lifting (file load, schema validation, timeout enforcement)
|
|
24
|
+
* lives in `core/hooks/registry.ts` and `core/hooks/runner.ts`.
|
|
25
|
+
*
|
|
26
|
+
* Brand voice: ASCII only, no emoji, no banned words.
|
|
27
|
+
*/
|
|
28
|
+
import { spawnSync } from 'node:child_process';
|
|
29
|
+
import { DEFAULT_HOOK_TIMEOUT_MS } from './registry.js';
|
|
30
|
+
/**
|
|
31
|
+
* Fire every hook configured for the given worktree event. Returns the
|
|
32
|
+
* aggregated outcome including any directory override and the
|
|
33
|
+
* blocking-failure short circuit.
|
|
34
|
+
*
|
|
35
|
+
* Each hook receives the JSON payload on stdin and is expected to
|
|
36
|
+
* produce its response on stdout. Hooks are run sequentially (not in
|
|
37
|
+
* parallel) so that ordering across a multi-hook chain stays
|
|
38
|
+
* deterministic - the first directory override wins.
|
|
39
|
+
*/
|
|
40
|
+
export function fireWorktreeHooks(config, event, payload, options = {}) {
|
|
41
|
+
const entries = config.list(event);
|
|
42
|
+
const spawn = options.spawn ?? defaultSpawn;
|
|
43
|
+
const results = [];
|
|
44
|
+
let directoryOverride;
|
|
45
|
+
let anyBlocked = false;
|
|
46
|
+
const json = JSON.stringify(payload);
|
|
47
|
+
for (const entry of entries) {
|
|
48
|
+
const start = Date.now();
|
|
49
|
+
const ret = spawn(entry.command, {
|
|
50
|
+
input: json,
|
|
51
|
+
encoding: 'utf8',
|
|
52
|
+
shell: '/bin/sh',
|
|
53
|
+
timeout: entry.timeoutMs ?? DEFAULT_HOOK_TIMEOUT_MS,
|
|
54
|
+
maxBuffer: 1 * 1024 * 1024,
|
|
55
|
+
});
|
|
56
|
+
const elapsedMs = Date.now() - start;
|
|
57
|
+
const stdout = bufToString(ret.stdout);
|
|
58
|
+
const stderr = bufToString(ret.stderr);
|
|
59
|
+
const ok = ret.status === 0;
|
|
60
|
+
const blocked = !ok && entry.blocking === true;
|
|
61
|
+
if (blocked)
|
|
62
|
+
anyBlocked = true;
|
|
63
|
+
let dirOverride;
|
|
64
|
+
if (event === 'WorktreeCreate' && ok && stdout.length > 0) {
|
|
65
|
+
dirOverride = extractDirectoryOverride(stdout);
|
|
66
|
+
if (dirOverride && !directoryOverride) {
|
|
67
|
+
directoryOverride = dirOverride;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
results.push({
|
|
71
|
+
command: entry.command.slice(0, 200),
|
|
72
|
+
exitCode: ret.status,
|
|
73
|
+
stdout,
|
|
74
|
+
stderr,
|
|
75
|
+
elapsedMs,
|
|
76
|
+
ok,
|
|
77
|
+
blocked,
|
|
78
|
+
...(dirOverride ? { directoryOverride: dirOverride } : {}),
|
|
79
|
+
});
|
|
80
|
+
if (blocked)
|
|
81
|
+
break;
|
|
82
|
+
}
|
|
83
|
+
return {
|
|
84
|
+
event,
|
|
85
|
+
results,
|
|
86
|
+
anyBlocked,
|
|
87
|
+
...(directoryOverride ? { directoryOverride } : {}),
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Extract a `directory` override from hook stdout. Two accepted shapes:
|
|
92
|
+
*
|
|
93
|
+
* 1. JSON object with `{ "directory": "<path>" }`.
|
|
94
|
+
* 2. Bare path string (trimmed first non-empty line) when the hook
|
|
95
|
+
* writes plain text. We accept the trimmed line as the override
|
|
96
|
+
* ONLY if it looks like a non-flag path (no leading `-`, no `\n`,
|
|
97
|
+
* no shell metacharacters).
|
|
98
|
+
*
|
|
99
|
+
* Returns undefined when no override is detected.
|
|
100
|
+
*/
|
|
101
|
+
export function extractDirectoryOverride(stdout) {
|
|
102
|
+
const trimmed = stdout.trim();
|
|
103
|
+
if (trimmed.length === 0)
|
|
104
|
+
return undefined;
|
|
105
|
+
if (trimmed.startsWith('{')) {
|
|
106
|
+
try {
|
|
107
|
+
const parsed = JSON.parse(trimmed);
|
|
108
|
+
if (parsed &&
|
|
109
|
+
typeof parsed === 'object' &&
|
|
110
|
+
!Array.isArray(parsed) &&
|
|
111
|
+
typeof parsed.directory === 'string') {
|
|
112
|
+
const dir = parsed.directory.trim();
|
|
113
|
+
if (isSafePathToken(dir))
|
|
114
|
+
return dir;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
catch {
|
|
118
|
+
// fall through to bare-path detection
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
const firstLine = trimmed.split(/\r?\n/, 1)[0] ?? '';
|
|
122
|
+
if (firstLine.length === 0)
|
|
123
|
+
return undefined;
|
|
124
|
+
if (isSafePathToken(firstLine))
|
|
125
|
+
return firstLine;
|
|
126
|
+
return undefined;
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* Cheap defence-in-depth check: a directory override must not look
|
|
130
|
+
* like a shell injection vector. The override is later validated for
|
|
131
|
+
* containment (the runtime refuses paths that escape the repo root),
|
|
132
|
+
* but rejecting obvious metacharacters here surfaces a cleaner error.
|
|
133
|
+
*/
|
|
134
|
+
function isSafePathToken(value) {
|
|
135
|
+
if (value.length === 0)
|
|
136
|
+
return false;
|
|
137
|
+
if (value.startsWith('-'))
|
|
138
|
+
return false;
|
|
139
|
+
if (/[;`$&|<>\n\r\t]/.test(value))
|
|
140
|
+
return false;
|
|
141
|
+
return true;
|
|
142
|
+
}
|
|
143
|
+
function defaultSpawn(command, options) {
|
|
144
|
+
const result = spawnSync(command, [], options);
|
|
145
|
+
return {
|
|
146
|
+
status: result.status,
|
|
147
|
+
stdout: result.stdout ?? '',
|
|
148
|
+
stderr: result.stderr ?? '',
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
function bufToString(v) {
|
|
152
|
+
if (v === undefined || v === null)
|
|
153
|
+
return '';
|
|
154
|
+
if (typeof v === 'string')
|
|
155
|
+
return v;
|
|
156
|
+
return v.toString('utf8');
|
|
157
|
+
}
|
|
158
|
+
//# sourceMappingURL=worktree-events.js.map
|
package/dist/core/lsp/client.js
CHANGED
|
@@ -206,6 +206,189 @@ export class LspClient {
|
|
|
206
206
|
return { ok: true, value: locations };
|
|
207
207
|
});
|
|
208
208
|
}
|
|
209
|
+
/**
|
|
210
|
+
* PUGI-78 Phase 1: document outline. Returns the LSP raw shape (either
|
|
211
|
+
* `DocumentSymbol[]` hierarchical or `SymbolInformation[]` flat); the
|
|
212
|
+
* symbol-tools layer normalizes both to the agent-facing flat form.
|
|
213
|
+
*/
|
|
214
|
+
async documentSymbols(file, token) {
|
|
215
|
+
return this.withDocument(file, async (uri) => {
|
|
216
|
+
const raw = await this.sendRequest('textDocument/documentSymbol', { textDocument: { uri } }, token);
|
|
217
|
+
if (!Array.isArray(raw))
|
|
218
|
+
return { ok: true, value: [] };
|
|
219
|
+
return { ok: true, value: raw };
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
/**
|
|
223
|
+
* PUGI-78 Phase 1: workspace-wide symbol fuzzy search. The query is
|
|
224
|
+
* server-defined (substring / fuzzy / prefix); we forward verbatim.
|
|
225
|
+
* Files outside the workspace are kept as raw URIs — the symbol-tools
|
|
226
|
+
* layer surfaces them to the operator as-is.
|
|
227
|
+
*/
|
|
228
|
+
async workspaceSymbols(query, token) {
|
|
229
|
+
// workspace/symbol does not need a document open, but we still
|
|
230
|
+
// honor the cancellation token. We call sendRequest directly via
|
|
231
|
+
// the typed internal accessor used by the handshake.
|
|
232
|
+
try {
|
|
233
|
+
const internal = this;
|
|
234
|
+
const raw = await internal.sendRequest('workspace/symbol', { query }, token);
|
|
235
|
+
if (!Array.isArray(raw))
|
|
236
|
+
return { ok: true, value: [] };
|
|
237
|
+
return { ok: true, value: raw };
|
|
238
|
+
}
|
|
239
|
+
catch (error) {
|
|
240
|
+
if (error instanceof OperatorAbortedError) {
|
|
241
|
+
return { ok: false, reason: 'operator_aborted', detail: error.message };
|
|
242
|
+
}
|
|
243
|
+
if (error instanceof Error && error.message === 'request_timeout') {
|
|
244
|
+
return { ok: false, reason: 'request_timeout', detail: 'workspace/symbol timed out' };
|
|
245
|
+
}
|
|
246
|
+
return {
|
|
247
|
+
ok: false,
|
|
248
|
+
reason: 'lsp_error',
|
|
249
|
+
detail: error instanceof Error ? error.message : String(error),
|
|
250
|
+
};
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
/**
|
|
254
|
+
* PUGI-78 Phase 1: implementations of an interface / abstract method.
|
|
255
|
+
* LSP `textDocument/implementation` returns the same `Location[]` shape
|
|
256
|
+
* as `definition`, so we re-use `normalizeLocations`.
|
|
257
|
+
*/
|
|
258
|
+
async implementations(file, pos, token) {
|
|
259
|
+
return this.withDocument(file, async (uri) => {
|
|
260
|
+
const raw = await this.sendRequest('textDocument/implementation', { textDocument: { uri }, position: pos }, token);
|
|
261
|
+
const locations = normalizeLocations(raw, this.cwd);
|
|
262
|
+
return { ok: true, value: locations };
|
|
263
|
+
});
|
|
264
|
+
}
|
|
265
|
+
/**
|
|
266
|
+
* PUGI-78 Phase 1: type-definition (vs value-definition). Same wire
|
|
267
|
+
* shape as `definition` — server reports the location of the symbol's
|
|
268
|
+
* type declaration, not its instantiation site.
|
|
269
|
+
*/
|
|
270
|
+
async typeDefinition(file, pos, token) {
|
|
271
|
+
return this.withDocument(file, async (uri) => {
|
|
272
|
+
const raw = await this.sendRequest('textDocument/typeDefinition', { textDocument: { uri }, position: pos }, token);
|
|
273
|
+
const locations = normalizeLocations(raw, this.cwd);
|
|
274
|
+
return { ok: true, value: locations };
|
|
275
|
+
});
|
|
276
|
+
}
|
|
277
|
+
/**
|
|
278
|
+
* PUGI-78 Phase 1: signature help at a call site. Returns the active
|
|
279
|
+
* overload's label, parameters, and active-parameter index. Null when
|
|
280
|
+
* the server reports no signature (cursor is not inside a call).
|
|
281
|
+
*/
|
|
282
|
+
async signatureHelp(file, pos, token) {
|
|
283
|
+
return this.withDocument(file, async (uri) => {
|
|
284
|
+
const raw = await this.sendRequest('textDocument/signatureHelp', { textDocument: { uri }, position: pos }, token);
|
|
285
|
+
return { ok: true, value: normalizeSignatureHelp(raw) };
|
|
286
|
+
});
|
|
287
|
+
}
|
|
288
|
+
/**
|
|
289
|
+
* PUGI-78 Phase 1: prepare a rename. Returns the range LSP says the
|
|
290
|
+
* symbol occupies; the dispatcher uses this to confirm the cursor is
|
|
291
|
+
* actually on a renameable token before issuing the full rename.
|
|
292
|
+
*/
|
|
293
|
+
async prepareRename(file, pos, token) {
|
|
294
|
+
return this.withDocument(file, async (uri) => {
|
|
295
|
+
try {
|
|
296
|
+
const raw = await this.sendRequest('textDocument/prepareRename', { textDocument: { uri }, position: pos }, token);
|
|
297
|
+
if (!raw)
|
|
298
|
+
return { ok: true, value: null };
|
|
299
|
+
// LSP allows { range, placeholder } OR a raw Range; surface
|
|
300
|
+
// either as a flat Range.
|
|
301
|
+
if (typeof raw === 'object' && raw && 'start' in raw && 'end' in raw) {
|
|
302
|
+
const range = parseRange(raw);
|
|
303
|
+
return { ok: true, value: range ?? null };
|
|
304
|
+
}
|
|
305
|
+
if (typeof raw === 'object' && raw && 'range' in raw) {
|
|
306
|
+
const range = parseRange(raw.range);
|
|
307
|
+
return { ok: true, value: range ?? null };
|
|
308
|
+
}
|
|
309
|
+
return { ok: true, value: null };
|
|
310
|
+
}
|
|
311
|
+
catch (error) {
|
|
312
|
+
// Some servers (pyright pre-1.1) lack prepareRename support and
|
|
313
|
+
// return a JSON-RPC error. The dispatcher should still be able
|
|
314
|
+
// to attempt the rename — surface null instead of bubbling the
|
|
315
|
+
// failure as the agent surface treats null as "skip prepare".
|
|
316
|
+
if (error instanceof Error && /method not (found|supported)/i.test(error.message)) {
|
|
317
|
+
return { ok: true, value: null };
|
|
318
|
+
}
|
|
319
|
+
throw error;
|
|
320
|
+
}
|
|
321
|
+
});
|
|
322
|
+
}
|
|
323
|
+
/**
|
|
324
|
+
* PUGI-78 Phase 1: atomic rename refactor. Returns the workspace edit
|
|
325
|
+
* the server proposes; the dispatcher previews + applies in a future
|
|
326
|
+
* ticket (Phase 2). For Phase 1 the agent surface lists affected files
|
|
327
|
+
* + line:character of each edit so the model can summarize the impact.
|
|
328
|
+
*/
|
|
329
|
+
async rename(file, pos, newName, token) {
|
|
330
|
+
return this.withDocument(file, async (uri) => {
|
|
331
|
+
const raw = await this.sendRequest('textDocument/rename', { textDocument: { uri }, position: pos, newName }, token);
|
|
332
|
+
return { ok: true, value: normalizeWorkspaceEdit(raw, this.cwd) };
|
|
333
|
+
});
|
|
334
|
+
}
|
|
335
|
+
/**
|
|
336
|
+
* PUGI-78 Phase 1: quick-fix list at a range. The server returns either
|
|
337
|
+
* `Command[]` (legacy) or `CodeAction[]` (modern); we normalize to the
|
|
338
|
+
* union shape so the surface stays stable.
|
|
339
|
+
*/
|
|
340
|
+
async codeActions(file, range, token) {
|
|
341
|
+
return this.withDocument(file, async (uri) => {
|
|
342
|
+
const raw = await this.sendRequest('textDocument/codeAction', {
|
|
343
|
+
textDocument: { uri },
|
|
344
|
+
range,
|
|
345
|
+
context: { diagnostics: [] },
|
|
346
|
+
}, token);
|
|
347
|
+
return { ok: true, value: normalizeCodeActions(raw) };
|
|
348
|
+
});
|
|
349
|
+
}
|
|
350
|
+
/**
|
|
351
|
+
* PUGI-78 Phase 1: formatter. Returns the text edits the server would
|
|
352
|
+
* apply for `textDocument/formatting`. The dispatcher composes them
|
|
353
|
+
* into a single rewrite in a future ticket; Phase 1 surfaces the edits
|
|
354
|
+
* for inspection.
|
|
355
|
+
*/
|
|
356
|
+
async formatting(file, token, options) {
|
|
357
|
+
return this.withDocument(file, async (uri) => {
|
|
358
|
+
const raw = await this.sendRequest('textDocument/formatting', {
|
|
359
|
+
textDocument: { uri },
|
|
360
|
+
options: {
|
|
361
|
+
tabSize: options?.tabSize ?? 2,
|
|
362
|
+
insertSpaces: options?.insertSpaces ?? true,
|
|
363
|
+
},
|
|
364
|
+
}, token);
|
|
365
|
+
return { ok: true, value: normalizeTextEdits(raw) };
|
|
366
|
+
});
|
|
367
|
+
}
|
|
368
|
+
/**
|
|
369
|
+
* PUGI-78 Phase 1: call hierarchy at a symbol position. The LSP wire
|
|
370
|
+
* is a two-step protocol — first `prepareCallHierarchy` to get the
|
|
371
|
+
* item handle, then `incomingCalls` and `outgoingCalls`. We surface
|
|
372
|
+
* both edges in a single call.
|
|
373
|
+
*/
|
|
374
|
+
async callHierarchy(file, pos, token) {
|
|
375
|
+
return this.withDocument(file, async (uri) => {
|
|
376
|
+
const prepared = await this.sendRequest('textDocument/prepareCallHierarchy', { textDocument: { uri }, position: pos }, token);
|
|
377
|
+
if (!Array.isArray(prepared) || prepared.length === 0) {
|
|
378
|
+
return { ok: true, value: { incoming: [], outgoing: [] } };
|
|
379
|
+
}
|
|
380
|
+
const item = prepared[0];
|
|
381
|
+
const incomingRaw = await this.sendRequest('callHierarchy/incomingCalls', { item }, token);
|
|
382
|
+
const outgoingRaw = await this.sendRequest('callHierarchy/outgoingCalls', { item }, token);
|
|
383
|
+
return {
|
|
384
|
+
ok: true,
|
|
385
|
+
value: {
|
|
386
|
+
incoming: normalizeCallHierarchyEdges(incomingRaw, 'from', this.cwd),
|
|
387
|
+
outgoing: normalizeCallHierarchyEdges(outgoingRaw, 'to', this.cwd),
|
|
388
|
+
},
|
|
389
|
+
};
|
|
390
|
+
});
|
|
391
|
+
}
|
|
209
392
|
/**
|
|
210
393
|
* Diagnostics in LSP arrive as PUSH (`textDocument/publishDiagnostics`)
|
|
211
394
|
* not pull. We open the document, wait one short tick for the server
|
|
@@ -632,6 +815,27 @@ async function initializeHandshake(client, cwd) {
|
|
|
632
815
|
definition: { linkSupport: false },
|
|
633
816
|
references: {},
|
|
634
817
|
publishDiagnostics: { relatedInformation: false },
|
|
818
|
+
// PUGI-78 Phase 1: full 13-tool symbol surface. Each block
|
|
819
|
+
// mirrors the LSP §3.17 dynamic capability advertisement so
|
|
820
|
+
// the server enables the corresponding feature (e.g. pyright
|
|
821
|
+
// gates `workspace/symbol` on this flag). `dynamicRegistration`
|
|
822
|
+
// stays false for every entry — we never register a capability
|
|
823
|
+
// post-initialize; the static block is the only handshake.
|
|
824
|
+
documentSymbol: {
|
|
825
|
+
hierarchicalDocumentSymbolSupport: true,
|
|
826
|
+
symbolKind: { valueSet: Array.from({ length: 26 }, (_, i) => i + 1) },
|
|
827
|
+
},
|
|
828
|
+
signatureHelp: { signatureInformation: { documentationFormat: ['plaintext', 'markdown'] } },
|
|
829
|
+
implementation: { linkSupport: false },
|
|
830
|
+
typeDefinition: { linkSupport: false },
|
|
831
|
+
rename: { prepareSupport: true },
|
|
832
|
+
codeAction: { codeActionLiteralSupport: { codeActionKind: { valueSet: ['', 'quickfix', 'refactor', 'source'] } } },
|
|
833
|
+
formatting: {},
|
|
834
|
+
callHierarchy: {},
|
|
835
|
+
},
|
|
836
|
+
workspace: {
|
|
837
|
+
symbol: { symbolKind: { valueSet: Array.from({ length: 26 }, (_, i) => i + 1) } },
|
|
838
|
+
workspaceEdit: { documentChanges: true },
|
|
635
839
|
},
|
|
636
840
|
},
|
|
637
841
|
workspaceFolders: [{ uri: rootUri, name: 'pugi-workspace' }],
|
|
@@ -762,6 +966,249 @@ function normalizeDiagnostic(raw) {
|
|
|
762
966
|
};
|
|
763
967
|
return out;
|
|
764
968
|
}
|
|
969
|
+
/**
|
|
970
|
+
* PUGI-78 Phase 1: collapse a `textDocument/signatureHelp` response to
|
|
971
|
+
* one active overload. LSP returns `{ signatures, activeSignature,
|
|
972
|
+
* activeParameter }`; we surface the active overload's label, params,
|
|
973
|
+
* and the active-param index. Returns null when the server reports no
|
|
974
|
+
* signatures (cursor not at a call site).
|
|
975
|
+
*/
|
|
976
|
+
function normalizeSignatureHelp(raw) {
|
|
977
|
+
if (!raw || typeof raw !== 'object')
|
|
978
|
+
return null;
|
|
979
|
+
const obj = raw;
|
|
980
|
+
if (!Array.isArray(obj.signatures) || obj.signatures.length === 0)
|
|
981
|
+
return null;
|
|
982
|
+
const idx = typeof obj.activeSignature === 'number' && obj.activeSignature >= 0
|
|
983
|
+
? Math.min(obj.activeSignature, obj.signatures.length - 1)
|
|
984
|
+
: 0;
|
|
985
|
+
const sig = obj.signatures[idx];
|
|
986
|
+
if (!sig || typeof sig !== 'object')
|
|
987
|
+
return null;
|
|
988
|
+
const sigObj = sig;
|
|
989
|
+
if (typeof sigObj.label !== 'string')
|
|
990
|
+
return null;
|
|
991
|
+
const params = [];
|
|
992
|
+
if (Array.isArray(sigObj.parameters)) {
|
|
993
|
+
for (const p of sigObj.parameters) {
|
|
994
|
+
if (!p || typeof p !== 'object')
|
|
995
|
+
continue;
|
|
996
|
+
const po = p;
|
|
997
|
+
// LSP allows `label` as string OR [start,end] tuple of offsets;
|
|
998
|
+
// we surface the string form, and for tuple form we slice the
|
|
999
|
+
// signature label.
|
|
1000
|
+
let label = '';
|
|
1001
|
+
if (typeof po.label === 'string')
|
|
1002
|
+
label = po.label;
|
|
1003
|
+
else if (Array.isArray(po.label) && po.label.length === 2 && typeof po.label[0] === 'number' && typeof po.label[1] === 'number') {
|
|
1004
|
+
label = sigObj.label.slice(po.label[0], po.label[1]);
|
|
1005
|
+
}
|
|
1006
|
+
if (!label)
|
|
1007
|
+
continue;
|
|
1008
|
+
const doc = extractMarkupString(po.documentation);
|
|
1009
|
+
params.push({ label, ...(doc ? { documentation: doc } : {}) });
|
|
1010
|
+
}
|
|
1011
|
+
}
|
|
1012
|
+
const docTop = extractMarkupString(sigObj.documentation);
|
|
1013
|
+
const out = {
|
|
1014
|
+
label: sigObj.label,
|
|
1015
|
+
parameters: params,
|
|
1016
|
+
raw,
|
|
1017
|
+
...(docTop ? { documentation: docTop } : {}),
|
|
1018
|
+
...(typeof obj.activeParameter === 'number' ? { activeParameter: obj.activeParameter } : {}),
|
|
1019
|
+
};
|
|
1020
|
+
return out;
|
|
1021
|
+
}
|
|
1022
|
+
/**
|
|
1023
|
+
* LSP `MarkupContent | string` -> string. Used by both signature help
|
|
1024
|
+
* (top-level docs + per-param docs) and hover normalization.
|
|
1025
|
+
*/
|
|
1026
|
+
function extractMarkupString(raw) {
|
|
1027
|
+
if (typeof raw === 'string')
|
|
1028
|
+
return raw.length > 0 ? raw : undefined;
|
|
1029
|
+
if (raw && typeof raw === 'object' && 'value' in raw) {
|
|
1030
|
+
const value = raw.value;
|
|
1031
|
+
if (typeof value === 'string' && value.length > 0)
|
|
1032
|
+
return value;
|
|
1033
|
+
}
|
|
1034
|
+
return undefined;
|
|
1035
|
+
}
|
|
1036
|
+
/**
|
|
1037
|
+
* PUGI-78 Phase 1: collapse a `textDocument/rename` `WorkspaceEdit` to
|
|
1038
|
+
* the agent-facing flat list. Handles BOTH shapes:
|
|
1039
|
+
* - legacy `changes: { [uri]: TextEdit[] }`
|
|
1040
|
+
* - modern `documentChanges: (TextDocumentEdit | FileOp)[]`
|
|
1041
|
+
*
|
|
1042
|
+
* File-op entries (`CreateFile`, `RenameFile`, `DeleteFile`) are NOT
|
|
1043
|
+
* surfaced in Phase 1 — the only renames Pugi enables out of the box
|
|
1044
|
+
* are symbol-level. Phase 2 wires the file-op variants.
|
|
1045
|
+
*/
|
|
1046
|
+
function normalizeWorkspaceEdit(raw, cwd) {
|
|
1047
|
+
if (!raw || typeof raw !== 'object')
|
|
1048
|
+
return null;
|
|
1049
|
+
const obj = raw;
|
|
1050
|
+
const edits = [];
|
|
1051
|
+
const fileSet = new Set();
|
|
1052
|
+
const pushEdit = (uri, edit) => {
|
|
1053
|
+
if (!edit || typeof edit !== 'object')
|
|
1054
|
+
return;
|
|
1055
|
+
const editObj = edit;
|
|
1056
|
+
const range = parseRange(editObj.range);
|
|
1057
|
+
if (!range)
|
|
1058
|
+
return;
|
|
1059
|
+
const newText = typeof editObj.newText === 'string' ? editObj.newText : '';
|
|
1060
|
+
const file = uriToWorkspacePath(uri, cwd);
|
|
1061
|
+
if (!file)
|
|
1062
|
+
return;
|
|
1063
|
+
fileSet.add(file);
|
|
1064
|
+
edits.push({ file, line: range.start.line, character: range.start.character, newText });
|
|
1065
|
+
};
|
|
1066
|
+
if (obj.changes && typeof obj.changes === 'object') {
|
|
1067
|
+
for (const [uri, list] of Object.entries(obj.changes)) {
|
|
1068
|
+
if (!Array.isArray(list))
|
|
1069
|
+
continue;
|
|
1070
|
+
for (const edit of list)
|
|
1071
|
+
pushEdit(uri, edit);
|
|
1072
|
+
}
|
|
1073
|
+
}
|
|
1074
|
+
if (Array.isArray(obj.documentChanges)) {
|
|
1075
|
+
for (const docChange of obj.documentChanges) {
|
|
1076
|
+
if (!docChange || typeof docChange !== 'object')
|
|
1077
|
+
continue;
|
|
1078
|
+
const dc = docChange;
|
|
1079
|
+
// Skip file-op entries — they have a `kind` discriminator.
|
|
1080
|
+
if (typeof dc.kind === 'string' && dc.kind !== '')
|
|
1081
|
+
continue;
|
|
1082
|
+
const td = dc.textDocument;
|
|
1083
|
+
if (!td || typeof td !== 'object')
|
|
1084
|
+
continue;
|
|
1085
|
+
const uri = td.uri;
|
|
1086
|
+
if (typeof uri !== 'string')
|
|
1087
|
+
continue;
|
|
1088
|
+
if (Array.isArray(dc.edits)) {
|
|
1089
|
+
for (const edit of dc.edits)
|
|
1090
|
+
pushEdit(uri, edit);
|
|
1091
|
+
}
|
|
1092
|
+
}
|
|
1093
|
+
}
|
|
1094
|
+
if (fileSet.size === 0)
|
|
1095
|
+
return null;
|
|
1096
|
+
return { files: Array.from(fileSet).sort(), edits, raw };
|
|
1097
|
+
}
|
|
1098
|
+
/**
|
|
1099
|
+
* Convert an LSP `file://` URI to a workspace-relative path. Empty
|
|
1100
|
+
* string when the URI escapes the workspace; the caller decides whether
|
|
1101
|
+
* to drop the edit or surface the raw URI.
|
|
1102
|
+
*/
|
|
1103
|
+
function uriToWorkspacePath(uri, cwd) {
|
|
1104
|
+
try {
|
|
1105
|
+
const url = new URL(uri);
|
|
1106
|
+
if (url.protocol !== 'file:')
|
|
1107
|
+
return uri;
|
|
1108
|
+
const abs = decodeURIComponent(url.pathname);
|
|
1109
|
+
if (abs === cwd)
|
|
1110
|
+
return '.';
|
|
1111
|
+
if (abs.startsWith(cwd + sep))
|
|
1112
|
+
return abs.slice(cwd.length + 1);
|
|
1113
|
+
return uri;
|
|
1114
|
+
}
|
|
1115
|
+
catch {
|
|
1116
|
+
return '';
|
|
1117
|
+
}
|
|
1118
|
+
}
|
|
1119
|
+
/**
|
|
1120
|
+
* PUGI-78 Phase 1: parse `textDocument/codeAction` response. Server
|
|
1121
|
+
* returns either `Command[]` or `CodeAction[]`. Both have `title`;
|
|
1122
|
+
* `CodeAction` additionally carries `kind` + `isPreferred` + `edit`.
|
|
1123
|
+
*/
|
|
1124
|
+
function normalizeCodeActions(raw) {
|
|
1125
|
+
if (!Array.isArray(raw))
|
|
1126
|
+
return [];
|
|
1127
|
+
const out = [];
|
|
1128
|
+
for (const item of raw) {
|
|
1129
|
+
if (!item || typeof item !== 'object')
|
|
1130
|
+
continue;
|
|
1131
|
+
const obj = item;
|
|
1132
|
+
if (typeof obj.title !== 'string' || obj.title.length === 0)
|
|
1133
|
+
continue;
|
|
1134
|
+
out.push({
|
|
1135
|
+
title: obj.title,
|
|
1136
|
+
...(typeof obj.kind === 'string' ? { kind: obj.kind } : {}),
|
|
1137
|
+
...(typeof obj.isPreferred === 'boolean' ? { isPreferred: obj.isPreferred } : {}),
|
|
1138
|
+
});
|
|
1139
|
+
}
|
|
1140
|
+
return out;
|
|
1141
|
+
}
|
|
1142
|
+
/**
|
|
1143
|
+
* PUGI-78 Phase 1: parse a `TextEdit[]` payload returned by the
|
|
1144
|
+
* formatter. Skips rows with missing range or non-string newText.
|
|
1145
|
+
*/
|
|
1146
|
+
function normalizeTextEdits(raw) {
|
|
1147
|
+
if (!Array.isArray(raw))
|
|
1148
|
+
return [];
|
|
1149
|
+
const out = [];
|
|
1150
|
+
for (const item of raw) {
|
|
1151
|
+
if (!item || typeof item !== 'object')
|
|
1152
|
+
continue;
|
|
1153
|
+
const obj = item;
|
|
1154
|
+
const range = parseRange(obj.range);
|
|
1155
|
+
if (!range)
|
|
1156
|
+
continue;
|
|
1157
|
+
const newText = typeof obj.newText === 'string' ? obj.newText : '';
|
|
1158
|
+
out.push({ range, newText });
|
|
1159
|
+
}
|
|
1160
|
+
return out;
|
|
1161
|
+
}
|
|
1162
|
+
/**
|
|
1163
|
+
* PUGI-78 Phase 1: collapse `callHierarchy/{incoming,outgoing}Calls`.
|
|
1164
|
+
* The two response shapes share the same `from`/`to` field for the
|
|
1165
|
+
* counterpart item; `fromRanges`/`toRanges` flatten to `callRanges`.
|
|
1166
|
+
*/
|
|
1167
|
+
function normalizeCallHierarchyEdges(raw, itemKey, cwd) {
|
|
1168
|
+
if (!Array.isArray(raw))
|
|
1169
|
+
return [];
|
|
1170
|
+
const out = [];
|
|
1171
|
+
const rangeKey = itemKey === 'from' ? 'fromRanges' : 'toRanges';
|
|
1172
|
+
for (const row of raw) {
|
|
1173
|
+
if (!row || typeof row !== 'object')
|
|
1174
|
+
continue;
|
|
1175
|
+
const rowObj = row;
|
|
1176
|
+
const item = rowObj[itemKey];
|
|
1177
|
+
if (!item || typeof item !== 'object')
|
|
1178
|
+
continue;
|
|
1179
|
+
const itemObj = item;
|
|
1180
|
+
if (typeof itemObj.name !== 'string' || itemObj.name.length === 0)
|
|
1181
|
+
continue;
|
|
1182
|
+
const kind = typeof itemObj.kind === 'number' ? itemObj.kind : 0;
|
|
1183
|
+
if (!kind)
|
|
1184
|
+
continue;
|
|
1185
|
+
const uri = typeof itemObj.uri === 'string' ? itemObj.uri : '';
|
|
1186
|
+
if (!uri)
|
|
1187
|
+
continue;
|
|
1188
|
+
const file = uriToWorkspacePath(uri, cwd);
|
|
1189
|
+
const selRange = parseRange(itemObj.selectionRange) ?? parseRange(itemObj.range);
|
|
1190
|
+
if (!selRange)
|
|
1191
|
+
continue;
|
|
1192
|
+
const rangesRaw = rowObj[rangeKey];
|
|
1193
|
+
const callRanges = [];
|
|
1194
|
+
if (Array.isArray(rangesRaw)) {
|
|
1195
|
+
for (const r of rangesRaw) {
|
|
1196
|
+
const parsed = parseRange(r);
|
|
1197
|
+
if (parsed)
|
|
1198
|
+
callRanges.push(parsed);
|
|
1199
|
+
}
|
|
1200
|
+
}
|
|
1201
|
+
out.push({
|
|
1202
|
+
name: itemObj.name,
|
|
1203
|
+
kind,
|
|
1204
|
+
file,
|
|
1205
|
+
line: selRange.start.line,
|
|
1206
|
+
character: selRange.start.character,
|
|
1207
|
+
callRanges,
|
|
1208
|
+
});
|
|
1209
|
+
}
|
|
1210
|
+
return out;
|
|
1211
|
+
}
|
|
765
1212
|
/**
|
|
766
1213
|
* Test-only surface so specs can hand-craft an `LspClient` over a mock
|
|
767
1214
|
* stdio pipe without paying for the real `startLspClient` spawn cost.
|
|
@@ -772,5 +1219,11 @@ export const __test__ = {
|
|
|
772
1219
|
normalizeHover,
|
|
773
1220
|
normalizeLocations,
|
|
774
1221
|
normalizeDiagnostic,
|
|
1222
|
+
normalizeSignatureHelp,
|
|
1223
|
+
normalizeWorkspaceEdit,
|
|
1224
|
+
normalizeCodeActions,
|
|
1225
|
+
normalizeTextEdits,
|
|
1226
|
+
normalizeCallHierarchyEdges,
|
|
1227
|
+
uriToWorkspacePath,
|
|
775
1228
|
};
|
|
776
1229
|
//# sourceMappingURL=client.js.map
|