@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.
Files changed (37) hide show
  1. package/dist/core/auth/env-provider.js +1 -1
  2. package/dist/core/context/markdown-traverse.js +1 -1
  3. package/dist/core/credentials.js +1 -1
  4. package/dist/core/engine/anvil-client.js +63 -0
  5. package/dist/core/engine/native-pugi.js +1 -1
  6. package/dist/core/engine/tool-bridge.js +436 -0
  7. package/dist/core/hooks/events.js +3 -1
  8. package/dist/core/hooks/registry.js +3 -0
  9. package/dist/core/hooks/worktree-events.js +158 -0
  10. package/dist/core/lsp/client.js +453 -0
  11. package/dist/core/lsp/server-detect.js +173 -0
  12. package/dist/core/lsp/symbol-cache.js +162 -0
  13. package/dist/core/lsp/symbol-tools.js +296 -4
  14. package/dist/core/mcp/server.js +1 -1
  15. package/dist/core/repl/ask.js +1 -1
  16. package/dist/core/repl/session.js +3 -3
  17. package/dist/core/repl/slash-commands.js +1 -1
  18. package/dist/core/settings.js +26 -0
  19. package/dist/core/worktree/include-parser.js +249 -0
  20. package/dist/runtime/cli.js +108 -8
  21. package/dist/runtime/commands/agents.js +1 -1
  22. package/dist/runtime/commands/hooks.js +3 -0
  23. package/dist/runtime/commands/review-consensus.js +1 -1
  24. package/dist/runtime/version.js +1 -1
  25. package/dist/runtime/worktree-bootstrap.js +579 -0
  26. package/dist/tools/lsp-tools.js +377 -1
  27. package/dist/tools/registry.js +23 -0
  28. package/dist/tui/input-box.js +1 -1
  29. package/dist/tui/render.js +1 -1
  30. package/dist/tui/repl.js +1 -1
  31. package/dist/tui/status-bar.js +1 -1
  32. package/dist/tui/update-banner.js +1 -1
  33. package/package.json +3 -3
  34. package/test/scenarios/compact-force.scenario.txt +3 -2
  35. package/test/scenarios/identity.scenario.txt +6 -5
  36. package/test/scenarios/persona-handoff.scenario.txt +2 -1
  37. 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
@@ -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