acuvo-code 0.2.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.
Files changed (154) hide show
  1. package/CHANGELOG.md +328 -0
  2. package/ENTERPRISE.md +927 -0
  3. package/LICENSE +120 -0
  4. package/README.md +1245 -0
  5. package/ROADMAP.md +556 -0
  6. package/bin/acuvo-mcp.mjs +208 -0
  7. package/bin/acuvo.mjs +3108 -0
  8. package/lib/acceptance-consent.mjs +168 -0
  9. package/lib/acceptance.mjs +859 -0
  10. package/lib/account.mjs +226 -0
  11. package/lib/acuvo-dir.mjs +72 -0
  12. package/lib/acuvo-models.mjs +141 -0
  13. package/lib/apply-patch.mjs +570 -0
  14. package/lib/ask-user.mjs +173 -0
  15. package/lib/audit.mjs +530 -0
  16. package/lib/auto-lease.mjs +174 -0
  17. package/lib/background.mjs +842 -0
  18. package/lib/best-of.mjs +334 -0
  19. package/lib/board.mjs +232 -0
  20. package/lib/breaker.mjs +93 -0
  21. package/lib/budget.mjs +1375 -0
  22. package/lib/builtin-skills.mjs +135 -0
  23. package/lib/cache-floor.mjs +204 -0
  24. package/lib/chain.mjs +303 -0
  25. package/lib/changed-paths.mjs +84 -0
  26. package/lib/chat.mjs +434 -0
  27. package/lib/checkpoint.mjs +637 -0
  28. package/lib/child-lifetime.mjs +71 -0
  29. package/lib/cli-args.mjs +1255 -0
  30. package/lib/code-review.mjs +1382 -0
  31. package/lib/colour.mjs +82 -0
  32. package/lib/command.mjs +2847 -0
  33. package/lib/compact.mjs +1151 -0
  34. package/lib/completion.mjs +515 -0
  35. package/lib/creative-engines.mjs +779 -0
  36. package/lib/db-inspect.mjs +1624 -0
  37. package/lib/delete.mjs +111 -0
  38. package/lib/design-loop.mjs +570 -0
  39. package/lib/diff-preview.mjs +1044 -0
  40. package/lib/doctor.mjs +2139 -0
  41. package/lib/dropped.mjs +216 -0
  42. package/lib/edit-diagnostics.mjs +277 -0
  43. package/lib/edit.mjs +460 -0
  44. package/lib/env-file.mjs +250 -0
  45. package/lib/escalate.mjs +702 -0
  46. package/lib/evaluate.mjs +284 -0
  47. package/lib/fetch-text.mjs +952 -0
  48. package/lib/fleet-budget.mjs +256 -0
  49. package/lib/gh.mjs +1536 -0
  50. package/lib/git.mjs +1341 -0
  51. package/lib/github.mjs +261 -0
  52. package/lib/h2.mjs +194 -0
  53. package/lib/handoff.mjs +417 -0
  54. package/lib/hooks.mjs +626 -0
  55. package/lib/http-probe.mjs +907 -0
  56. package/lib/image-director.mjs +322 -0
  57. package/lib/image-edit.mjs +522 -0
  58. package/lib/imagegen.mjs +998 -0
  59. package/lib/interrupt.mjs +234 -0
  60. package/lib/learned.mjs +353 -0
  61. package/lib/lease-watch.mjs +115 -0
  62. package/lib/lease.mjs +868 -0
  63. package/lib/localize.mjs +834 -0
  64. package/lib/log-tail.mjs +1052 -0
  65. package/lib/login.mjs +157 -0
  66. package/lib/lsp.mjs +1613 -0
  67. package/lib/mcp-consent.mjs +377 -0
  68. package/lib/mcp-defaults.mjs +780 -0
  69. package/lib/mcp-server.mjs +1343 -0
  70. package/lib/mcp.mjs +1263 -0
  71. package/lib/media.mjs +1283 -0
  72. package/lib/memory-workspace.mjs +179 -0
  73. package/lib/model-json.mjs +132 -0
  74. package/lib/model-tier.mjs +171 -0
  75. package/lib/model.mjs +1445 -0
  76. package/lib/parallel.mjs +144 -0
  77. package/lib/perchance.mjs +210 -0
  78. package/lib/plan-coherence.mjs +1461 -0
  79. package/lib/plan-ledger.mjs +981 -0
  80. package/lib/plan.mjs +461 -0
  81. package/lib/policy.mjs +783 -0
  82. package/lib/prefix-order.mjs +38 -0
  83. package/lib/project-memory.mjs +127 -0
  84. package/lib/prompt.mjs +109 -0
  85. package/lib/python.mjs +862 -0
  86. package/lib/rcfile.mjs +853 -0
  87. package/lib/read-window.mjs +743 -0
  88. package/lib/refute-tools.mjs +34 -0
  89. package/lib/refute.mjs +806 -0
  90. package/lib/repl-driver.mjs +264 -0
  91. package/lib/repl.mjs +324 -0
  92. package/lib/replay.mjs +1218 -0
  93. package/lib/repo-map.mjs +1101 -0
  94. package/lib/report.mjs +419 -0
  95. package/lib/search-rank.mjs +99 -0
  96. package/lib/search.mjs +659 -0
  97. package/lib/secret-paths.mjs +54 -0
  98. package/lib/session.mjs +1017 -0
  99. package/lib/skills.mjs +703 -0
  100. package/lib/slash.mjs +356 -0
  101. package/lib/spawn-argv.mjs +1151 -0
  102. package/lib/spend.mjs +250 -0
  103. package/lib/steer.mjs +280 -0
  104. package/lib/stream.mjs +253 -0
  105. package/lib/stuck.mjs +712 -0
  106. package/lib/subagent.mjs +749 -0
  107. package/lib/terminal-graphics.mjs +171 -0
  108. package/lib/tool-prefix.mjs +226 -0
  109. package/lib/tool-shortlist.mjs +162 -0
  110. package/lib/tools.mjs +2333 -0
  111. package/lib/tsserver.mjs +423 -0
  112. package/lib/turn.mjs +5672 -0
  113. package/lib/untrusted-block.mjs +271 -0
  114. package/lib/verify-claim.mjs +299 -0
  115. package/lib/vision.mjs +330 -0
  116. package/lib/voice-task.mjs +561 -0
  117. package/lib/warm-provider.mjs +255 -0
  118. package/lib/websearch.mjs +401 -0
  119. package/lib/workspace.mjs +928 -0
  120. package/lib/write-approval.mjs +235 -0
  121. package/lib/write-many.mjs +162 -0
  122. package/package.json +62 -0
  123. package/scripts/bundle.mjs +768 -0
  124. package/scripts/cache-floor.mjs +176 -0
  125. package/scripts/machine.mjs +226 -0
  126. package/scripts/test.mjs +139 -0
  127. package/skills/accessibility.md +87 -0
  128. package/skills/acuvo-design-system.md +123 -0
  129. package/skills/animation.md +84 -0
  130. package/skills/api-design.md +82 -0
  131. package/skills/auth-and-sessions.md +78 -0
  132. package/skills/build-with-a-framework.md +101 -0
  133. package/skills/colour-and-contrast.md +112 -0
  134. package/skills/creative-engines.md +81 -0
  135. package/skills/css-layout.md +85 -0
  136. package/skills/data-and-charts.md +77 -0
  137. package/skills/debugging.md +76 -0
  138. package/skills/designing-by-looking.md +84 -0
  139. package/skills/error-handling.md +78 -0
  140. package/skills/forms-and-validation.md +93 -0
  141. package/skills/nextjs-app-router.md +75 -0
  142. package/skills/page-composition.md +103 -0
  143. package/skills/performance.md +77 -0
  144. package/skills/plan-before-building.md +52 -0
  145. package/skills/planning-and-delegating.md +72 -0
  146. package/skills/refactoring.md +70 -0
  147. package/skills/security-basics.md +76 -0
  148. package/skills/state-management.md +73 -0
  149. package/skills/supabase-multitenant.md +72 -0
  150. package/skills/typescript-strict.md +90 -0
  151. package/skills/typography.md +135 -0
  152. package/skills/verify-your-own-work.md +62 -0
  153. package/skills/web-app-quality.md +62 -0
  154. package/skills/working-in-the-background.md +64 -0
@@ -0,0 +1,423 @@
1
+ /**
2
+ * ── ⭐⭐ THE FOUR NAVIGATION TOOLS WERE DARK ON EVERY PROJECT ────────────────
3
+ *
4
+ * `find_definition`, `find_references`, `list_symbols` and `check_types` are
5
+ * gated on `lspAvailable`, which needs **`typescript-language-server`** — a
6
+ * separate npm package almost nobody installs. Measured 2026-08-12 against a
7
+ * real Next.js app and a real API server: `lspAvailable` was **false for both**.
8
+ * Four of the most valuable tools a coding agent can have, shipped, tested, and
9
+ * unreachable on every machine including the author's.
10
+ *
11
+ * ⭐ AND THE THING THAT MAKES THEM REACHABLE IS ALREADY THERE. The `typescript`
12
+ * package — which every TypeScript project has, because it is what compiles the
13
+ * project — ships **`lib/tsserver.js`**, the same server VS Code drives. It was
14
+ * present in both projects above. Nothing needed installing; something needed
15
+ * writing.
16
+ *
17
+ * ── ⚠️ IT IS NOT LSP, AND THAT IS THE WHOLE REASON THIS FILE EXISTS ────────
18
+ *
19
+ * `lsp.mjs` speaks Language Server Protocol. tsserver speaks its own older
20
+ * protocol, and the differences are not cosmetic:
21
+ *
22
+ * · requests are ONE LINE of JSON terminated by \n — no Content-Length header
23
+ * on the way IN (there is one on the way out, which is the asymmetry that
24
+ * catches people);
25
+ * · positions are **1-based `line` and `offset`**, where LSP is 0-based
26
+ * `line` and `character`. An off-by-one here does not error, it silently
27
+ * answers about the wrong token;
28
+ * · a file must be `open`ed before anything can be asked about it;
29
+ * · errors arrive as `success:false` on the response, not as a JSON-RPC
30
+ * `error` member.
31
+ *
32
+ * So this could not be a flag on `lsp.mjs`. What it CAN do — and does — is
33
+ * return **exactly the shapes `lsp.mjs` returns**, so `formatLspForModel`, the
34
+ * tool schemas and the model's experience are identical no matter which server
35
+ * answered. The user should never learn which one they got.
36
+ */
37
+
38
+ import { spawn } from 'node:child_process';
39
+ import { exitIsDeferred } from './interrupt.mjs';
40
+ import { detachChild } from './child-lifetime.mjs';
41
+ import { existsSync, statSync, readFileSync } from 'node:fs';
42
+ import { join, dirname, resolve, relative, sep } from 'node:path';
43
+
44
+ import { resolveInWorkspace } from './workspace.mjs';
45
+
46
+ /** How far up to look for a `node_modules/typescript`. Deep enough for a monorepo. */
47
+ export const MAX_WALK_UP = 8;
48
+
49
+ /** tsserver is fast; a slow answer here means something is wrong, not busy. */
50
+ export const REQUEST_TIMEOUT_MS = 20_000;
51
+
52
+ /** Same caps as `lsp.mjs`, so the two cannot disagree about what "too many" is. */
53
+ export const MAX_LOCATIONS = 40;
54
+ export const MAX_DIAGNOSTICS = 40;
55
+ export const MAX_SYMBOLS = 200;
56
+
57
+ /** The extensions tsserver can answer about. JS included — it handles both. */
58
+ export const TS_EXTENSIONS = new Set(['.ts', '.tsx', '.mts', '.cts', '.js', '.jsx', '.mjs', '.cjs']);
59
+
60
+ export function handlesFile(file) {
61
+ const m = /\.[a-z]+$/i.exec(String(file ?? ''));
62
+ return m ? TS_EXTENSIONS.has(m[0].toLowerCase()) : false;
63
+ }
64
+
65
+ /**
66
+ * Where is tsserver? Walks up looking for `node_modules/typescript/lib/tsserver.js`.
67
+ *
68
+ * ⚠️ NEVER SPAWNS ANYTHING. This runs on the tool-offer path of every
69
+ * multi-round session, so it is one `existsSync` per level and nothing else.
70
+ */
71
+ export function findTsserver(root, { maxUp = MAX_WALK_UP } = {}) {
72
+ if (typeof root !== 'string' || root === '') return null;
73
+ let dir = resolve(root);
74
+ for (let i = 0; i < maxUp; i += 1) {
75
+ const candidate = join(dir, 'node_modules', 'typescript', 'lib', 'tsserver.js');
76
+ try {
77
+ if (existsSync(candidate) && statSync(candidate).isFile()) return candidate;
78
+ } catch { /* unreadable level */ }
79
+ const parent = dirname(dir);
80
+ if (parent === dir) break;
81
+ dir = parent;
82
+ }
83
+ return null;
84
+ }
85
+
86
+ /** Is the tsserver path available for this workspace? Cheap, for the offer path. */
87
+ export function tsserverAvailable(root) {
88
+ return findTsserver(root) !== null;
89
+ }
90
+
91
+ // ── the protocol ───────────────────────────────────────────────────────────
92
+
93
+ const live = new Set();
94
+ let hooked = false;
95
+ function installExitHooks() {
96
+ if (hooked) return;
97
+ hooked = true;
98
+ const killAll = () => { for (const s of [...live]) hardStop(s); };
99
+ process.once('exit', killAll);
100
+ for (const [sig, code] of [['SIGINT', 130], ['SIGTERM', 143], ['SIGBREAK', 149]]) {
101
+ try { process.once(sig, () => { killAll(); if (!exitIsDeferred()) process.exit(code); }); } catch { /* no SIGBREAK off Windows */ }
102
+ }
103
+ }
104
+
105
+ function hardStop(session) {
106
+ live.delete(session);
107
+ try { session.child.kill('SIGKILL'); } catch { /* already gone */ }
108
+ }
109
+
110
+ /**
111
+ * ⚠️ RESPONSES ARE Content-Length FRAMED EVEN THOUGH REQUESTS ARE NOT. Splitting
112
+ * the output on newlines — the symmetric-looking assumption — tears JSON bodies
113
+ * apart the first time one contains a newline in a string, which a diagnostic
114
+ * message routinely does.
115
+ */
116
+ function makeReader(onMessage) {
117
+ let buffer = '';
118
+ return (chunk) => {
119
+ buffer += chunk;
120
+ for (;;) {
121
+ const headerEnd = buffer.indexOf('\r\n\r\n');
122
+ if (headerEnd === -1) return;
123
+ const header = buffer.slice(0, headerEnd);
124
+ const m = /Content-Length:\s*(\d+)/i.exec(header);
125
+ if (!m) { buffer = buffer.slice(headerEnd + 4); continue; }
126
+ const length = Number(m[1]);
127
+ const start = headerEnd + 4;
128
+ if (buffer.length < start + length) return;
129
+ const body = buffer.slice(start, start + length);
130
+ buffer = buffer.slice(start + length);
131
+ try { onMessage(JSON.parse(body)); } catch { /* a malformed frame is not fatal */ }
132
+ }
133
+ };
134
+ }
135
+
136
+ /** Start tsserver for this workspace. */
137
+ export function startTsserver(root, { file = null, spawnImpl = spawn } = {}) {
138
+ const server = file ?? findTsserver(root);
139
+ if (!server) {
140
+ return {
141
+ ok: false,
142
+ error: 'no TypeScript found in this workspace — semantic navigation needs `typescript` installed '
143
+ + '(npm i -D typescript). Use search_text and read_file instead.',
144
+ };
145
+ }
146
+
147
+ const child = spawnImpl(process.execPath, [server, '--disableAutomaticTypingAcquisition'], {
148
+ cwd: root,
149
+ stdio: ['pipe', 'pipe', 'pipe'],
150
+ windowsHide: true,
151
+ });
152
+
153
+ const pending = new Map();
154
+ /** @type {Map<string, object[]>} */
155
+ const events = new Map();
156
+ let seq = 0;
157
+
158
+ const session = { child, root, server, ok: true, label: 'tsserver' };
159
+
160
+ const read = makeReader((msg) => {
161
+ if (msg.type === 'response' && typeof msg.request_seq === 'number') {
162
+ const entry = pending.get(msg.request_seq);
163
+ if (entry) { pending.delete(msg.request_seq); clearTimeout(entry.timer); entry.resolve(msg); }
164
+ return;
165
+ }
166
+ if (msg.type === 'event' && msg.event) {
167
+ if (!events.has(msg.event)) events.set(msg.event, []);
168
+ events.get(msg.event).push(msg.body);
169
+ }
170
+ });
171
+
172
+ child.stdout?.setEncoding?.('utf8');
173
+ child.stdout?.on?.('data', read);
174
+ child.stderr?.setEncoding?.('utf8');
175
+ child.stderr?.on?.('data', () => { /* tsserver logs noise here; not an error channel */ });
176
+ child.on?.('exit', () => {
177
+ for (const [, entry] of pending) { clearTimeout(entry.timer); entry.resolve({ success: false, message: 'tsserver exited' }); }
178
+ pending.clear();
179
+ live.delete(session);
180
+ });
181
+
182
+ /**
183
+ * ⚠️ Without this, a session that used type navigation cannot exit — and it is
184
+ * not theoretical: this is what made the public repo's suite exit 1 on a fresh
185
+ * clone while passing in isolation. See lib/child-lifetime.mjs.
186
+ */
187
+ detachChild(child);
188
+
189
+ /**
190
+ * ⚠️ ONE LINE, NEWLINE-TERMINATED, NO HEADER. The response framing is
191
+ * Content-Length; the request framing is not. Sending a header here makes
192
+ * tsserver silently ignore every request — it does not complain, it just never
193
+ * answers, which reads exactly like a hang.
194
+ */
195
+ session.request = (command, args, timeoutMs = REQUEST_TIMEOUT_MS) => {
196
+ seq += 1;
197
+ const id = seq;
198
+ const payload = `${JSON.stringify({ seq: id, type: 'request', command, arguments: args })}\n`;
199
+ return new Promise((resolveP) => {
200
+ const timer = setTimeout(() => {
201
+ pending.delete(id);
202
+ resolveP({ success: false, message: `tsserver did not answer "${command}" within ${Math.round(timeoutMs / 1000)}s` });
203
+ }, timeoutMs);
204
+ pending.set(id, { resolve: resolveP, timer });
205
+ try { child.stdin.write(payload); } catch (e) {
206
+ clearTimeout(timer); pending.delete(id);
207
+ resolveP({ success: false, message: `could not talk to tsserver: ${e?.message ?? e}` });
208
+ }
209
+ });
210
+ };
211
+
212
+ session.events = events;
213
+ session.stop = () => hardStop(session);
214
+
215
+ installExitHooks();
216
+ live.add(session);
217
+ return session;
218
+ }
219
+
220
+ /** Resolve a model-supplied path and open it — tsserver answers nothing about a closed file. */
221
+ async function openFile(session, root, file) {
222
+ const r = resolveInWorkspace(root, file, 'read');
223
+ if (!r.ok) return { ok: false, error: r.reason };
224
+ if (!existsSync(r.absolute)) return { ok: false, error: `${r.relative} does not exist` };
225
+ let text;
226
+ try { text = readFileSync(r.absolute, 'utf8'); } catch (e) { return { ok: false, error: `could not read ${r.relative}: ${e?.message ?? e}` }; }
227
+ await session.request('open', { file: r.absolute, fileContent: text, scriptKindName: /\.tsx?$/.test(r.absolute) ? 'TS' : 'JS' });
228
+ return { ok: true, absolute: r.absolute, relative: r.relative };
229
+ }
230
+
231
+ /** One excerpt reader per call, so a file read twice is read once. */
232
+ function makeExcerptReader() {
233
+ const cache = new Map();
234
+ return (absolute, line) => {
235
+ if (!cache.has(absolute)) {
236
+ try { cache.set(absolute, readFileSync(absolute, 'utf8').split(/\r?\n/)); } catch { cache.set(absolute, null); }
237
+ }
238
+ const lines = cache.get(absolute);
239
+ if (!lines) return null;
240
+ const text = lines[line - 1];
241
+ return typeof text === 'string' ? text.trim().slice(0, 200) : null;
242
+ };
243
+ }
244
+
245
+ /**
246
+ * ⚠️ IDENTICAL TO `lsp.mjs`'s NORMALISER, DELIBERATELY. A definition inside
247
+ * node_modules is often the CORRECT answer, so it is reported rather than
248
+ * refused — shown relative when inside the workspace and absolute when not, so
249
+ * the model can tell at a glance whether it can open it.
250
+ */
251
+ function normalize(root, spans) {
252
+ const excerptFor = makeExcerptReader();
253
+ const out = [];
254
+ for (const s of spans ?? []) {
255
+ const absolute = s?.file;
256
+ const line = s?.start?.line;
257
+ const column = s?.start?.offset;
258
+ if (!absolute || !Number.isInteger(line)) continue;
259
+ const rel = relative(root, absolute);
260
+ const inside = rel !== '' && !rel.startsWith('..') && !/^[A-Za-z]:/.test(rel);
261
+ out.push({
262
+ path: inside ? rel.split(sep).join('/') : absolute,
263
+ inWorkspace: inside,
264
+ line,
265
+ column: column ?? 1,
266
+ excerpt: excerptFor(absolute, line),
267
+ });
268
+ }
269
+ return out;
270
+ }
271
+
272
+ function capped(list, max) {
273
+ return { shown: list.slice(0, max), truncated: list.length > max };
274
+ }
275
+
276
+ /** Run one verb and guarantee the server dies afterwards. */
277
+ async function withServer(root, fn, { session = null } = {}) {
278
+ const s = session ?? startTsserver(root);
279
+ if (s.ok === false) return s;
280
+ try {
281
+ return await fn(s);
282
+ } finally {
283
+ if (!session) s.stop();
284
+ }
285
+ }
286
+
287
+ // ── the four verbs, returning `lsp.mjs`'s shapes exactly ───────────────────
288
+
289
+ export async function definition(root, file, line, column = 1, opts = {}) {
290
+ return withServer(root, async (s) => {
291
+ const open = await openFile(s, root, file);
292
+ if (!open.ok) return open;
293
+ const res = await s.request('definition', { file: open.absolute, line, offset: column });
294
+ if (res?.success === false) return { ok: false, error: `tsserver: ${res.message ?? 'definition failed'}` };
295
+ const all = normalize(root, res?.body);
296
+ const { shown, truncated } = capped(all, MAX_LOCATIONS);
297
+ if (all.length === 0) {
298
+ return {
299
+ ok: true, kind: 'definition', path: open.relative, count: 0, shown: 0, truncated: false, locations: [],
300
+ // ⚠️ Same note as lsp.mjs: "no definition" and "you pointed at
301
+ // whitespace" look identical to the caller, and the second is commoner.
302
+ note: `tsserver found no definition at ${open.relative}:${line}:${column}. Check the position is on the symbol itself — the column is 1-based and counts characters, not tabs-as-spaces.`,
303
+ };
304
+ }
305
+ return { ok: true, kind: 'definition', path: open.relative, count: all.length, shown: shown.length, truncated, locations: shown };
306
+ }, opts);
307
+ }
308
+
309
+ export async function references(root, file, line, column = 1, opts = {}) {
310
+ return withServer(root, async (s) => {
311
+ const open = await openFile(s, root, file);
312
+ if (!open.ok) return open;
313
+ const res = await s.request('references', { file: open.absolute, line, offset: column });
314
+ if (res?.success === false) return { ok: false, error: `tsserver: ${res.message ?? 'references failed'}` };
315
+ const refs = (res?.body?.refs ?? []).map((r) => ({ file: r.file, start: r.start }));
316
+ const all = normalize(root, refs);
317
+ const { shown, truncated } = capped(all, MAX_LOCATIONS);
318
+ if (all.length === 0) {
319
+ return {
320
+ ok: true, kind: 'references', path: open.relative, count: 0, shown: 0, truncated: false, locations: [],
321
+ note: `tsserver found no references to the symbol at ${open.relative}:${line}:${column}.`,
322
+ };
323
+ }
324
+ return { ok: true, kind: 'references', path: open.relative, count: all.length, shown: shown.length, truncated, locations: shown };
325
+ }, opts);
326
+ }
327
+
328
+ const CATEGORY_RANK = { error: 1, warning: 2, suggestion: 3, message: 3 };
329
+
330
+ export async function diagnostics(root, file, opts = {}) {
331
+ return withServer(root, async (s) => {
332
+ const open = await openFile(s, root, file);
333
+ if (!open.ok) return open;
334
+
335
+ /**
336
+ * ⚠️ BOTH KINDS, AND SEMANTIC IS THE ONE THAT MATTERS. `syntacticDiagnosticsSync`
337
+ * catches a missing brace; `semanticDiagnosticsSync` catches the type error
338
+ * that is the entire reason to ask. Returning only the first would answer
339
+ * "no problems" about a file that does not typecheck.
340
+ */
341
+ const [syn, sem] = await Promise.all([
342
+ s.request('syntacticDiagnosticsSync', { file: open.absolute }),
343
+ s.request('semanticDiagnosticsSync', { file: open.absolute }),
344
+ ]);
345
+ if (syn?.success === false && sem?.success === false) {
346
+ return { ok: false, error: `tsserver: ${sem.message ?? syn.message ?? 'diagnostics failed'}` };
347
+ }
348
+
349
+ const raw = [...(syn?.body ?? []), ...(sem?.body ?? [])];
350
+ const items = raw.map((d) => ({
351
+ line: d?.start?.line ?? 1,
352
+ column: d?.start?.offset ?? 1,
353
+ severity: d?.category ?? 'error',
354
+ code: d?.code ?? null,
355
+ message: String(d?.text ?? '').slice(0, 300),
356
+ }));
357
+ items.sort((a, b) => (CATEGORY_RANK[a.severity] ?? 4) - (CATEGORY_RANK[b.severity] ?? 4) || a.line - b.line);
358
+
359
+ const counts = { error: 0, warning: 0, information: 0, hint: 0 };
360
+ for (const d of items) {
361
+ if (d.severity === 'error') counts.error += 1;
362
+ else if (d.severity === 'warning') counts.warning += 1;
363
+ else counts.information += 1;
364
+ }
365
+ const { shown, truncated } = capped(items, MAX_DIAGNOSTICS);
366
+ return {
367
+ ok: true, kind: 'diagnostics', path: open.relative, counts, count: items.length,
368
+ shown: shown.length, truncated, items: shown,
369
+ note: items.length === 0 ? `tsserver reports no problems in ${open.relative}.` : null,
370
+ };
371
+ }, opts);
372
+ }
373
+
374
+ export async function documentSymbols(root, file, opts = {}) {
375
+ return withServer(root, async (s) => {
376
+ const open = await openFile(s, root, file);
377
+ if (!open.ok) return open;
378
+ const res = await s.request('navtree', { file: open.absolute });
379
+ if (res?.success === false) return { ok: false, error: `tsserver: ${res.message ?? 'navtree failed'}` };
380
+
381
+ const flat = [];
382
+ const walk = (node, depth) => {
383
+ for (const child of node?.childItems ?? []) {
384
+ const line = child?.spans?.[0]?.start?.line;
385
+ if (child?.text && Number.isInteger(line)) {
386
+ flat.push({
387
+ name: String(child.text).slice(0, 120),
388
+ kind: String(child.kind ?? 'symbol'),
389
+ line,
390
+ depth,
391
+ detail: child.kindModifiers ? String(child.kindModifiers).slice(0, 100) : null,
392
+ });
393
+ }
394
+ walk(child, depth + 1);
395
+ }
396
+ };
397
+ // ⚠️ The ROOT node is the file itself and is not a symbol in it — starting
398
+ // the walk at its children keeps `list_symbols` from reporting the filename
399
+ // as a declaration.
400
+ walk(res?.body, 0);
401
+ flat.sort((a, b) => a.line - b.line || a.depth - b.depth);
402
+ const { shown, truncated } = capped(flat, MAX_SYMBOLS);
403
+ return {
404
+ ok: true, kind: 'symbols', path: open.relative, count: flat.length,
405
+ shown: shown.length, truncated, symbols: shown,
406
+ note: truncated ? `showing the first ${shown.length} of ${flat.length} symbols` : null,
407
+ };
408
+ }, opts);
409
+ }
410
+
411
+ /** Same dispatcher shape as `runLspTool`, so `tools.mjs` can pick either. */
412
+ export async function runTsserverTool(root, name, args = {}, opts = {}) {
413
+ if (!handlesFile(args.file)) {
414
+ return { ok: false, error: `tsserver handles TypeScript and JavaScript files; ${args.file} is neither. Use search_text instead.` };
415
+ }
416
+ switch (name) {
417
+ case 'find_definition': return definition(root, args.file, args.line, args.column ?? 1, opts);
418
+ case 'find_references': return references(root, args.file, args.line, args.column ?? 1, opts);
419
+ case 'check_types': return diagnostics(root, args.file, opts);
420
+ case 'list_symbols': return documentSymbols(root, args.file, opts);
421
+ default: return { ok: false, error: `"${name}" is not a navigation tool` };
422
+ }
423
+ }