@akira-tl/forgerelay 0.4.1 → 0.4.3

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/CHANGELOG.md CHANGED
@@ -4,6 +4,30 @@ All notable ForgeRelay changes are documented here.
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [0.4.3] - 2026-08-11
8
+
9
+ ### Added
10
+
11
+ - Added `documentSymbols` code intelligence with hierarchy-preserving normalization for LSP `DocumentSymbol` trees and flat handling for legacy `SymbolInformation` responses.
12
+ - Added bounded `workspaceSymbols` semantic search over one selected Language service, with normalized flat symbol metadata and External location handling.
13
+
14
+ ### Changed
15
+
16
+ - Symbol collection limits reuse the 100 default / 1000 hard maximum budget. Document-symbol limits count tree nodes while preserving required ancestors; workspace-symbol results expose `returned`, `truncated`, and known `total` like references.
17
+ - Workspace-symbol requests use a workspace-relative `path` to select and synchronize the Language project/service before applying the project-wide `query`; ForgeRelay does not silently merge nested Language services.
18
+
19
+ ## [0.4.2] - 2026-08-11
20
+
21
+ ### Added
22
+
23
+ - Added bounded `code.intelligence` `references` support through the shared Language-service path, using the same normalized location contract as definition results.
24
+
25
+ ### Changed
26
+
27
+ - References default to 100 returned locations, accept an explicit limit up to 1000, and report `returned`, `truncated`, and the real `total` when the complete Language-server response is known.
28
+ - Shared semantic-location normalization now preserves External code location metadata for both definition and references without expanding ForgeRelay file authority.
29
+ - Split Language-service management from the protocol runtime so later code-intelligence operations can grow without concentrating lifecycle and request logic in one module.
30
+
7
31
  ## [0.4.1] - 2026-08-11
8
32
 
9
33
  ### Added
@@ -4,8 +4,12 @@ Use the `code.intelligence` Capability for read-only semantic code navigation ba
4
4
 
5
5
  ForgeRelay does not install Language servers. It discovers supported executables when available and accepts explicit definitions from the global ForgeRelay config or `<workspace>/.forgerelay/language-servers.json`. Project definitions override global definitions, and global definitions override built-in discovery. An explicit definition may disable discovery with `enabled: false`.
6
6
 
7
- ForgeRelay 0.4.1 supports `definition` and `hover`. Both position-based operations accept a workspace-relative source `path` plus 1-based `line` and `column` values. Columns are Unicode code-point positions; ForgeRelay converts them to the position encoding negotiated with the Language server.
7
+ ForgeRelay 0.4.3 supports `definition`, `hover`, `references`, `documentSymbols`, and `workspaceSymbols`. Position-based operations accept a workspace-relative source `path` plus 1-based `line` and `column` values. `documentSymbols` needs only `path` and an optional bounded `limit`. `workspaceSymbols` uses `path` to select the Language project/service and accepts a `query` plus optional `limit`; it does not merge multiple nested Language services. Columns are Unicode code-point positions; ForgeRelay converts them to the position encoding negotiated with the Language server.
8
8
 
9
- Code-intelligence results use ForgeRelay-owned shapes rather than raw LSP wire types. Definition returns normalized locations. Hover returns one `contents` string, an optional legacy `language`, and an optional ForgeRelay-normalized `range`; plaintext, Markdown `MarkupContent`, and supported legacy `MarkedString` payloads are normalized before reaching the Agent. A definition may identify an External code location outside the Workspace, but that does not expand ForgeRelay's allowed roots or grant the file tools permission to read that path.
9
+ Code-intelligence results use ForgeRelay-owned shapes rather than raw LSP wire types. Definition returns normalized locations. Hover returns one `contents` string, an optional legacy `language`, and an optional ForgeRelay-normalized `range`; plaintext, Markdown `MarkupContent`, and supported legacy `MarkedString` payloads are normalized before reaching the Agent. References uses the same normalized location shape, defaults to `limit: 100`, and accepts limits up to 1000. Its result reports `returned`, `truncated`, and `total` when the complete Language-server response makes the total known.
10
+
11
+ Document symbols preserve hierarchical server responses as a tree and keep flat `SymbolInformation` responses flat. Names, stable symbol-kind names, ranges, selection ranges, details, container names, and children are normalized without exposing LSP union types. Workspace symbols are always returned as a flat list with stable symbol metadata and normalized locations. Symbol limits use the same default 100 / hard maximum 1000 collection budget; document-symbol limits count total tree nodes, while workspace-symbol results report the server response total directly when known.
12
+
13
+ Semantic locations may identify External code locations outside the Workspace, but that does not expand ForgeRelay's allowed roots or grant the file tools permission to read those paths.
10
14
 
11
15
  Language-server definitions use structured process configuration rather than shell command strings. A project configuration entry may contain `command`, `args`, `env`, `languages`, `extensions`, `languageIdByExtension`, `projectMarkers`, and `enabled` fields. Use `languageIdByExtension` when one server definition covers multiple language IDs whose extensions do not map one-to-one by array position. The server command is launched directly without a shell.
@@ -1,4 +1,5 @@
1
1
  import { z } from "zod";
2
+ import { MAX_CODE_INTELLIGENCE_RESULT_LIMIT, } from "./lsp/code-intelligence-types.js";
2
3
  export class CapabilityError extends Error {
3
4
  code;
4
5
  constructor(code, message) {
@@ -115,12 +116,31 @@ export class CapabilityRegistry {
115
116
  }
116
117
  export function createCapabilityRegistry(dependencies) {
117
118
  const hooksCheckInput = z.object({}).strict();
118
- const codeIntelligenceInput = z.object({
119
- operation: z.enum(["definition", "hover"]),
119
+ const positionInput = {
120
120
  path: z.string().min(1),
121
121
  line: z.number().int(),
122
122
  column: z.number().int(),
123
- }).strict();
123
+ };
124
+ const codeIntelligenceInput = z.discriminatedUnion("operation", [
125
+ z.object({ operation: z.literal("definition"), ...positionInput }).strict(),
126
+ z.object({ operation: z.literal("hover"), ...positionInput }).strict(),
127
+ z.object({
128
+ operation: z.literal("references"),
129
+ ...positionInput,
130
+ limit: z.number().int().min(1).max(MAX_CODE_INTELLIGENCE_RESULT_LIMIT).optional(),
131
+ }).strict(),
132
+ z.object({
133
+ operation: z.literal("documentSymbols"),
134
+ path: z.string().min(1),
135
+ limit: z.number().int().min(1).max(MAX_CODE_INTELLIGENCE_RESULT_LIMIT).optional(),
136
+ }).strict(),
137
+ z.object({
138
+ operation: z.literal("workspaceSymbols"),
139
+ path: z.string().min(1),
140
+ query: z.string(),
141
+ limit: z.number().int().min(1).max(MAX_CODE_INTELLIGENCE_RESULT_LIMIT).optional(),
142
+ }).strict(),
143
+ ]);
124
144
  return new CapabilityRegistry([
125
145
  {
126
146
  name: "hooks.check",
@@ -1 +1,2 @@
1
- export {};
1
+ export const DEFAULT_CODE_INTELLIGENCE_RESULT_LIMIT = 100;
2
+ export const MAX_CODE_INTELLIGENCE_RESULT_LIMIT = 1000;
@@ -1,23 +1,20 @@
1
1
  import { spawn } from "node:child_process";
2
2
  import { readFile, realpath } from "node:fs/promises";
3
- import { basename, isAbsolute, relative, resolve, sep } from "node:path";
4
- import { fileURLToPath, pathToFileURL } from "node:url";
3
+ import { basename, resolve } from "node:path";
4
+ import { pathToFileURL } from "node:url";
5
5
  import { createMessageConnection } from "vscode-jsonrpc/node";
6
- import { DefinitionRequest, DidChangeTextDocumentNotification, DidCloseTextDocumentNotification, DidOpenTextDocumentNotification, ExitNotification, HoverRequest, InitializeRequest, InitializedNotification, MarkupKind, PositionEncodingKind, ShutdownRequest, TextDocumentSyncKind, } from "vscode-languageserver-protocol";
7
- import { LanguageServerConfigurationError, resolveLanguageProject, } from "./language-server-config.js";
6
+ import { DefinitionRequest, DocumentSymbolRequest, DidChangeTextDocumentNotification, DidCloseTextDocumentNotification, DidOpenTextDocumentNotification, ExitNotification, HoverRequest, InitializeRequest, ReferencesRequest, InitializedNotification, MarkupKind, PositionEncodingKind, ShutdownRequest, TextDocumentSyncKind, WorkspaceSymbolRequest, } from "vscode-languageserver-protocol";
7
+ import { LanguageServerConfigurationError, } from "./language-server-config.js";
8
8
  import { terminateProcessTree } from "../process-platform.js";
9
9
  import { CodeIntelligenceError } from "./code-intelligence-error.js";
10
+ import { DEFAULT_CODE_INTELLIGENCE_RESULT_LIMIT } from "./code-intelligence-types.js";
10
11
  import { normalizeHoverContents } from "./normalization/hover.js";
12
+ import { normalizeDocumentSymbols, normalizeWorkspaceSymbols, } from "./normalization/symbols.js";
13
+ import { isWithin, locationEntries, normalizeLocations, workspaceDisplayPath, } from "./normalization/locations.js";
11
14
  import { lspPositionFromUser, rangeFromLsp, wholeDocumentRange } from "./position-encoding.js";
12
15
  export { CodeIntelligenceError } from "./code-intelligence-error.js";
13
- const LANGUAGE_SERVICE_IDLE_MS = 10 * 60 * 1_000;
14
- const LANGUAGE_SERVICE_CLEANUP_INTERVAL_MS = 60 * 1_000;
15
- const MAX_LANGUAGE_SERVICES = 16;
16
- const LANGUAGE_SERVICE_START_TIMEOUT_MS = 15_000;
17
- const LANGUAGE_REQUEST_TIMEOUT_MS = 10_000;
18
- const LANGUAGE_SERVICE_SHUTDOWN_TIMEOUT_MS = 2_000;
19
16
  const STDERR_TAIL_BYTES = 64 * 1024;
20
- class LanguageService {
17
+ export class LanguageService {
21
18
  workspaceRoot;
22
19
  project;
23
20
  policy;
@@ -59,7 +56,7 @@ class LanguageService {
59
56
  textDocument: { uri: document.uri },
60
57
  position,
61
58
  }), this.policy.requestTimeoutMs, () => new CodeIntelligenceError("code.request_timeout", `Definition request timed out for ${input.path}.`));
62
- const locations = await normalizeDefinitionResponse(response, this.workspaceRoot, this.positionEncoding);
59
+ const locations = await normalizeLocations(locationEntries(response), this.workspaceRoot, this.positionEncoding);
63
60
  return {
64
61
  operation: "definition",
65
62
  selectedServer: this.project.definition.id,
@@ -114,6 +111,91 @@ class LanguageService {
114
111
  throw new CodeIntelligenceError("code.server_crashed", `Language server ${this.project.definition.id} failed: ${errorMessage(error)}${this.stderrSuffix()}`);
115
112
  }
116
113
  }
114
+ async references(input) {
115
+ try {
116
+ await this.ensureStarted();
117
+ if (!this.capabilities?.referencesProvider) {
118
+ throw new CodeIntelligenceError("code.operation_unsupported", `Language server ${this.project.definition.id} does not advertise references support.`);
119
+ }
120
+ const sourcePath = await workspaceSourcePath(this.workspaceRoot, input.path);
121
+ const document = await this.syncDocument(sourcePath);
122
+ const position = lspPositionFromUser(document.text, input.line, input.column, this.positionEncoding);
123
+ const response = await withTimeout(this.connection.sendRequest(ReferencesRequest.type, {
124
+ textDocument: { uri: document.uri },
125
+ position,
126
+ context: { includeDeclaration: true },
127
+ }), this.policy.requestTimeoutMs, () => new CodeIntelligenceError("code.request_timeout", `References request timed out for ${input.path}.`));
128
+ const entries = locationEntries(response ?? []);
129
+ const limit = input.limit ?? DEFAULT_CODE_INTELLIGENCE_RESULT_LIMIT;
130
+ const selected = entries.slice(0, limit);
131
+ const locations = await normalizeLocations(selected, this.workspaceRoot, this.positionEncoding);
132
+ return {
133
+ operation: "references",
134
+ selectedServer: this.project.definition.id,
135
+ projectRoot: workspaceDisplayPath(this.workspaceRoot, this.project.projectRoot),
136
+ locations,
137
+ returned: locations.length,
138
+ truncated: entries.length > locations.length,
139
+ total: entries.length,
140
+ };
141
+ }
142
+ catch (error) {
143
+ if (error instanceof CodeIntelligenceError)
144
+ throw error;
145
+ if (error instanceof LanguageServerConfigurationError) {
146
+ throw new CodeIntelligenceError(error.code, error.message);
147
+ }
148
+ throw new CodeIntelligenceError("code.server_crashed", `Language server ${this.project.definition.id} failed: ${errorMessage(error)}${this.stderrSuffix()}`);
149
+ }
150
+ }
151
+ async documentSymbols(input) {
152
+ try {
153
+ await this.ensureStarted();
154
+ if (!this.capabilities?.documentSymbolProvider) {
155
+ throw new CodeIntelligenceError("code.operation_unsupported", `Language server ${this.project.definition.id} does not advertise document-symbol support.`);
156
+ }
157
+ const sourcePath = await workspaceSourcePath(this.workspaceRoot, input.path);
158
+ const document = await this.syncDocument(sourcePath);
159
+ const response = await withTimeout(this.connection.sendRequest(DocumentSymbolRequest.type, {
160
+ textDocument: { uri: document.uri },
161
+ }), this.policy.requestTimeoutMs, () => new CodeIntelligenceError("code.request_timeout", `Document-symbol request timed out for ${input.path}.`));
162
+ const normalized = normalizeDocumentSymbols(response, document.text, this.positionEncoding, input.limit ?? DEFAULT_CODE_INTELLIGENCE_RESULT_LIMIT);
163
+ return {
164
+ operation: "documentSymbols",
165
+ selectedServer: this.project.definition.id,
166
+ projectRoot: workspaceDisplayPath(this.workspaceRoot, this.project.projectRoot),
167
+ ...normalized,
168
+ };
169
+ }
170
+ catch (error) {
171
+ if (error instanceof CodeIntelligenceError)
172
+ throw error;
173
+ throw new CodeIntelligenceError("code.server_crashed", `Language server ${this.project.definition.id} failed: ${errorMessage(error)}${this.stderrSuffix()}`);
174
+ }
175
+ }
176
+ async workspaceSymbols(input) {
177
+ try {
178
+ await this.ensureStarted();
179
+ if (!this.capabilities?.workspaceSymbolProvider) {
180
+ throw new CodeIntelligenceError("code.operation_unsupported", `Language server ${this.project.definition.id} does not advertise workspace-symbol support.`);
181
+ }
182
+ const sourcePath = await workspaceSourcePath(this.workspaceRoot, input.path);
183
+ await this.syncDocument(sourcePath);
184
+ const response = await withTimeout(this.connection.sendRequest(WorkspaceSymbolRequest.type, { query: input.query }), this.policy.requestTimeoutMs, () => new CodeIntelligenceError("code.request_timeout", `Workspace-symbol request timed out for ${JSON.stringify(input.query)}.`));
185
+ const normalized = await normalizeWorkspaceSymbols(response, this.workspaceRoot, this.positionEncoding, input.limit ?? DEFAULT_CODE_INTELLIGENCE_RESULT_LIMIT);
186
+ return {
187
+ operation: "workspaceSymbols",
188
+ selectedServer: this.project.definition.id,
189
+ projectRoot: workspaceDisplayPath(this.workspaceRoot, this.project.projectRoot),
190
+ ...normalized,
191
+ };
192
+ }
193
+ catch (error) {
194
+ if (error instanceof CodeIntelligenceError)
195
+ throw error;
196
+ throw new CodeIntelligenceError("code.server_crashed", `Language server ${this.project.definition.id} failed: ${errorMessage(error)}${this.stderrSuffix()}`);
197
+ }
198
+ }
117
199
  async shutdown() {
118
200
  if (this.closed)
119
201
  return;
@@ -218,6 +300,9 @@ class LanguageService {
218
300
  workspace: {
219
301
  workspaceFolders: true,
220
302
  configuration: true,
303
+ symbol: {
304
+ dynamicRegistration: false,
305
+ },
221
306
  },
222
307
  textDocument: {
223
308
  synchronization: {
@@ -232,6 +317,13 @@ class LanguageService {
232
317
  dynamicRegistration: false,
233
318
  contentFormat: [MarkupKind.Markdown, MarkupKind.PlainText],
234
319
  },
320
+ references: {
321
+ dynamicRegistration: false,
322
+ },
323
+ documentSymbol: {
324
+ dynamicRegistration: false,
325
+ hierarchicalDocumentSymbolSupport: true,
326
+ },
235
327
  },
236
328
  },
237
329
  };
@@ -321,137 +413,7 @@ class LanguageService {
321
413
  return text ? ` Server stderr: ${text}` : "";
322
414
  }
323
415
  }
324
- export class CodeIntelligenceManager {
325
- config;
326
- services = new Map();
327
- serviceCreations = new Map();
328
- serviceCreationQueue = Promise.resolve();
329
- cleanupTimer;
330
- policy;
331
- constructor(config, options = {}) {
332
- this.config = config;
333
- this.policy = {
334
- idleMs: positiveInteger(options.idleMs, LANGUAGE_SERVICE_IDLE_MS, "idleMs"),
335
- cleanupIntervalMs: positiveInteger(options.cleanupIntervalMs, LANGUAGE_SERVICE_CLEANUP_INTERVAL_MS, "cleanupIntervalMs"),
336
- maxServices: positiveInteger(options.maxServices, MAX_LANGUAGE_SERVICES, "maxServices"),
337
- startTimeoutMs: positiveInteger(options.startTimeoutMs, LANGUAGE_SERVICE_START_TIMEOUT_MS, "startTimeoutMs"),
338
- requestTimeoutMs: positiveInteger(options.requestTimeoutMs, LANGUAGE_REQUEST_TIMEOUT_MS, "requestTimeoutMs"),
339
- shutdownTimeoutMs: positiveInteger(options.shutdownTimeoutMs, LANGUAGE_SERVICE_SHUTDOWN_TIMEOUT_MS, "shutdownTimeoutMs"),
340
- };
341
- this.cleanupTimer = setInterval(() => {
342
- void this.closeIdle();
343
- }, this.policy.cleanupIntervalMs);
344
- this.cleanupTimer.unref();
345
- }
346
- async run(workspaceRoot, input) {
347
- let project;
348
- let canonicalWorkspaceRoot;
349
- try {
350
- canonicalWorkspaceRoot = await realpath(resolve(workspaceRoot));
351
- project = await resolveLanguageProject({
352
- workspaceRoot: canonicalWorkspaceRoot,
353
- sourcePath: input.path,
354
- globalConfig: this.config.languageServers,
355
- });
356
- }
357
- catch (error) {
358
- if (error instanceof LanguageServerConfigurationError) {
359
- throw new CodeIntelligenceError(error.code, error.message);
360
- }
361
- throw error;
362
- }
363
- const service = await this.acquireService(canonicalWorkspaceRoot, project);
364
- try {
365
- return input.operation === "definition"
366
- ? await service.definition(input)
367
- : await service.hover(input);
368
- }
369
- finally {
370
- service.release();
371
- }
372
- }
373
- async shutdown() {
374
- clearInterval(this.cleanupTimer);
375
- await Promise.allSettled(this.serviceCreations.values());
376
- this.serviceCreations.clear();
377
- const services = [...this.services.values()];
378
- this.services.clear();
379
- await Promise.allSettled(services.map((service) => service.shutdown()));
380
- }
381
- get size() {
382
- return this.services.size;
383
- }
384
- async acquireService(workspaceRoot, project) {
385
- const key = languageServiceKey(project);
386
- const existing = this.services.get(key);
387
- if (existing) {
388
- existing.acquire();
389
- return existing;
390
- }
391
- const pending = this.serviceCreations.get(key);
392
- if (pending) {
393
- const service = await pending;
394
- service.acquire();
395
- return service;
396
- }
397
- const creation = this.withServiceCreationLock(async () => {
398
- const current = this.services.get(key);
399
- if (current) {
400
- current.acquire();
401
- return current;
402
- }
403
- await this.ensureCapacity();
404
- const service = new LanguageService(workspaceRoot, project, this.policy);
405
- service.acquire();
406
- this.services.set(key, service);
407
- return service;
408
- });
409
- this.serviceCreations.set(key, creation);
410
- try {
411
- return await creation;
412
- }
413
- finally {
414
- if (this.serviceCreations.get(key) === creation) {
415
- this.serviceCreations.delete(key);
416
- }
417
- }
418
- }
419
- async withServiceCreationLock(operation) {
420
- const previous = this.serviceCreationQueue;
421
- let release = () => undefined;
422
- this.serviceCreationQueue = new Promise((resolvePromise) => {
423
- release = resolvePromise;
424
- });
425
- await previous;
426
- try {
427
- return await operation();
428
- }
429
- finally {
430
- release();
431
- }
432
- }
433
- async closeIdle(now = Date.now()) {
434
- const stale = [...this.services.entries()].filter(([, service]) => service.inFlight === 0 && now - service.lastUsedAt >= this.policy.idleMs);
435
- for (const [key, service] of stale) {
436
- this.services.delete(key);
437
- await service.shutdown();
438
- }
439
- }
440
- async ensureCapacity() {
441
- if (this.services.size < this.policy.maxServices)
442
- return;
443
- const idle = [...this.services.entries()]
444
- .filter(([, service]) => service.inFlight === 0)
445
- .sort((left, right) => left[1].lastUsedAt - right[1].lastUsedAt);
446
- const candidate = idle[0];
447
- if (!candidate) {
448
- throw new CodeIntelligenceError("code.language_service_capacity", `Language service capacity reached (${this.policy.maxServices}) with no idle service available for eviction.`);
449
- }
450
- this.services.delete(candidate[0]);
451
- await candidate[1].shutdown();
452
- }
453
- }
454
- function languageServiceKey(project) {
416
+ export function languageServiceKey(project) {
455
417
  return JSON.stringify([
456
418
  resolve(project.projectRoot),
457
419
  project.definition.id,
@@ -494,48 +456,6 @@ async function workspaceSourcePath(workspaceRoot, inputPath) {
494
456
  throw new CodeIntelligenceError("code.language_service_unavailable", `Unable to read code-intelligence source ${inputPath}: ${errorMessage(error)}`);
495
457
  }
496
458
  }
497
- async function normalizeDefinitionResponse(response, workspaceRoot, encoding) {
498
- if (!response)
499
- return [];
500
- const entries = Array.isArray(response) ? response : [response];
501
- return Promise.all(entries.map(async (entry) => {
502
- const uri = isLocationLink(entry) ? entry.targetUri : entry.uri;
503
- const range = isLocationLink(entry) ? entry.targetRange : entry.range;
504
- if (!uri.startsWith("file:")) {
505
- throw new CodeIntelligenceError("code.result_outside_policy", `Language server returned a non-file definition URI: ${uri}`);
506
- }
507
- const targetPath = fileURLToPath(uri);
508
- const root = resolve(workspaceRoot);
509
- let resolvedTarget;
510
- let text;
511
- try {
512
- resolvedTarget = await realpath(targetPath);
513
- text = await readFile(resolvedTarget, "utf8");
514
- }
515
- catch (error) {
516
- throw new CodeIntelligenceError("code.result_outside_policy", `Unable to normalize definition location ${targetPath}: ${errorMessage(error)}`);
517
- }
518
- const external = !isWithin(root, resolvedTarget);
519
- return {
520
- path: external ? resolvedTarget : workspaceDisplayPath(root, resolvedTarget),
521
- external,
522
- range: rangeFromLsp(text, range, encoding),
523
- };
524
- }));
525
- }
526
- function isLocationLink(value) {
527
- return "targetUri" in value;
528
- }
529
- function workspaceDisplayPath(workspaceRoot, path) {
530
- const rel = relative(resolve(workspaceRoot), resolve(path));
531
- if (!rel)
532
- return ".";
533
- return rel.split(sep).join("/");
534
- }
535
- function isWithin(root, candidate) {
536
- const rel = relative(root, candidate);
537
- return rel === "" || (!rel.startsWith(`..${sep}`) && rel !== ".." && !isAbsolute(rel));
538
- }
539
459
  async function withTimeout(promise, timeoutMs, timeoutError) {
540
460
  let timer;
541
461
  try {
@@ -583,13 +503,6 @@ async function waitForChildExit(child, timeoutMs) {
583
503
  timer.unref();
584
504
  });
585
505
  }
586
- function positiveInteger(value, fallback, label) {
587
- const resolvedValue = value ?? fallback;
588
- if (!Number.isInteger(resolvedValue) || resolvedValue < 1) {
589
- throw new Error(`Code-intelligence ${label} must be a positive integer.`);
590
- }
591
- return resolvedValue;
592
- }
593
506
  function errorMessage(error) {
594
507
  return error instanceof Error ? error.message : String(error);
595
508
  }
@@ -0,0 +1,53 @@
1
+ import { readFile, realpath } from "node:fs/promises";
2
+ import { isAbsolute, relative, resolve, sep } from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+ import { CodeIntelligenceError } from "../code-intelligence-error.js";
5
+ import { rangeFromLsp } from "../position-encoding.js";
6
+ export function locationEntries(response) {
7
+ if (!response)
8
+ return [];
9
+ const entries = Array.isArray(response) ? response : [response];
10
+ return entries.map((entry) => isLocationLink(entry)
11
+ ? { uri: entry.targetUri, range: entry.targetRange }
12
+ : { uri: entry.uri, range: entry.range });
13
+ }
14
+ export async function normalizeLocations(entries, workspaceRoot, encoding) {
15
+ return Promise.all(entries.map(async ({ uri, range }) => {
16
+ if (!uri.startsWith("file:")) {
17
+ throw new CodeIntelligenceError("code.result_outside_policy", `Language server returned a non-file code location: ${uri}`);
18
+ }
19
+ const targetPath = fileURLToPath(uri);
20
+ const root = resolve(workspaceRoot);
21
+ let resolvedTarget;
22
+ let text;
23
+ try {
24
+ resolvedTarget = await realpath(targetPath);
25
+ text = await readFile(resolvedTarget, "utf8");
26
+ }
27
+ catch (error) {
28
+ throw new CodeIntelligenceError("code.result_outside_policy", `Unable to normalize code location ${targetPath}: ${errorMessage(error)}`);
29
+ }
30
+ const external = !isWithin(root, resolvedTarget);
31
+ return {
32
+ path: external ? resolvedTarget : workspaceDisplayPath(root, resolvedTarget),
33
+ external,
34
+ range: rangeFromLsp(text, range, encoding),
35
+ };
36
+ }));
37
+ }
38
+ export function workspaceDisplayPath(workspaceRoot, path) {
39
+ const rel = relative(resolve(workspaceRoot), resolve(path));
40
+ if (!rel)
41
+ return ".";
42
+ return rel.split(sep).join("/");
43
+ }
44
+ export function isWithin(root, candidate) {
45
+ const rel = relative(root, candidate);
46
+ return rel === "" || (!rel.startsWith(`..${sep}`) && rel !== ".." && !isAbsolute(rel));
47
+ }
48
+ function isLocationLink(value) {
49
+ return "targetUri" in value;
50
+ }
51
+ function errorMessage(error) {
52
+ return error instanceof Error ? error.message : String(error);
53
+ }
@@ -0,0 +1,90 @@
1
+ import { SymbolKind, } from "vscode-languageserver-protocol";
2
+ import { CodeIntelligenceError } from "../code-intelligence-error.js";
3
+ import { normalizeLocations } from "./locations.js";
4
+ import { rangeFromLsp } from "../position-encoding.js";
5
+ export function normalizeDocumentSymbols(response, text, encoding, limit) {
6
+ if (!response || response.length === 0) {
7
+ return { hierarchical: true, symbols: [], returned: 0, truncated: false, total: 0 };
8
+ }
9
+ if (isFlatSymbolInformation(response[0])) {
10
+ const flat = response;
11
+ const selected = flat.slice(0, limit).map((symbol) => ({
12
+ name: symbol.name,
13
+ kind: symbolKindName(symbol.kind),
14
+ ...(symbol.containerName ? { containerName: symbol.containerName } : {}),
15
+ range: rangeFromLsp(text, symbol.location.range, encoding),
16
+ }));
17
+ return {
18
+ hierarchical: false,
19
+ symbols: selected,
20
+ returned: selected.length,
21
+ truncated: flat.length > selected.length,
22
+ total: flat.length,
23
+ };
24
+ }
25
+ const hierarchical = response;
26
+ const total = countDocumentSymbols(hierarchical);
27
+ const budget = { remaining: limit, returned: 0 };
28
+ const symbols = takeDocumentSymbols(hierarchical, text, encoding, budget);
29
+ return {
30
+ hierarchical: true,
31
+ symbols,
32
+ returned: budget.returned,
33
+ truncated: total > budget.returned,
34
+ total,
35
+ };
36
+ }
37
+ function takeDocumentSymbols(symbols, text, encoding, budget) {
38
+ const normalized = [];
39
+ for (const symbol of symbols) {
40
+ if (budget.remaining <= 0)
41
+ break;
42
+ budget.remaining -= 1;
43
+ budget.returned += 1;
44
+ const children = symbol.children?.length
45
+ ? takeDocumentSymbols(symbol.children, text, encoding, budget)
46
+ : [];
47
+ normalized.push({
48
+ name: symbol.name,
49
+ kind: symbolKindName(symbol.kind),
50
+ ...(symbol.detail ? { detail: symbol.detail } : {}),
51
+ range: rangeFromLsp(text, symbol.range, encoding),
52
+ selectionRange: rangeFromLsp(text, symbol.selectionRange, encoding),
53
+ ...(children.length ? { children } : {}),
54
+ });
55
+ }
56
+ return normalized;
57
+ }
58
+ function countDocumentSymbols(symbols) {
59
+ return symbols.reduce((total, symbol) => total + 1 + (symbol.children ? countDocumentSymbols(symbol.children) : 0), 0);
60
+ }
61
+ function isFlatSymbolInformation(symbol) {
62
+ return "location" in symbol;
63
+ }
64
+ export async function normalizeWorkspaceSymbols(response, workspaceRoot, encoding, limit) {
65
+ const all = response ?? [];
66
+ const selected = all.slice(0, limit);
67
+ const locationEntries = selected.map((symbol) => {
68
+ if (!("range" in symbol.location)) {
69
+ throw new CodeIntelligenceError("code.result_outside_policy", `Language server returned unresolved workspace symbol ${symbol.name} without a range.`);
70
+ }
71
+ return { uri: symbol.location.uri, range: symbol.location.range };
72
+ });
73
+ const locations = await normalizeLocations(locationEntries, workspaceRoot, encoding);
74
+ const symbols = selected.map((symbol, index) => ({
75
+ name: symbol.name,
76
+ kind: symbolKindName(symbol.kind),
77
+ ...(symbol.containerName ? { containerName: symbol.containerName } : {}),
78
+ location: locations[index],
79
+ }));
80
+ return {
81
+ symbols,
82
+ returned: symbols.length,
83
+ truncated: all.length > symbols.length,
84
+ total: all.length,
85
+ };
86
+ }
87
+ export function symbolKindName(kind) {
88
+ const entry = Object.entries(SymbolKind).find(([, value]) => value === kind);
89
+ return entry ? entry[0].toLowerCase() : `unknown:${kind}`;
90
+ }
@@ -0,0 +1,156 @@
1
+ import { realpath } from "node:fs/promises";
2
+ import { resolve } from "node:path";
3
+ import { CodeIntelligenceError, LanguageService, languageServiceKey, } from "../code-intelligence.js";
4
+ import { LanguageServerConfigurationError, resolveLanguageProject, } from "../language-server-config.js";
5
+ const LANGUAGE_SERVICE_IDLE_MS = 10 * 60 * 1_000;
6
+ const LANGUAGE_SERVICE_CLEANUP_INTERVAL_MS = 60 * 1_000;
7
+ const MAX_LANGUAGE_SERVICES = 16;
8
+ const LANGUAGE_SERVICE_START_TIMEOUT_MS = 15_000;
9
+ const LANGUAGE_REQUEST_TIMEOUT_MS = 10_000;
10
+ const LANGUAGE_SERVICE_SHUTDOWN_TIMEOUT_MS = 2_000;
11
+ export class CodeIntelligenceManager {
12
+ config;
13
+ services = new Map();
14
+ serviceCreations = new Map();
15
+ serviceCreationQueue = Promise.resolve();
16
+ cleanupTimer;
17
+ policy;
18
+ constructor(config, options = {}) {
19
+ this.config = config;
20
+ this.policy = {
21
+ idleMs: positiveInteger(options.idleMs, LANGUAGE_SERVICE_IDLE_MS, "idleMs"),
22
+ cleanupIntervalMs: positiveInteger(options.cleanupIntervalMs, LANGUAGE_SERVICE_CLEANUP_INTERVAL_MS, "cleanupIntervalMs"),
23
+ maxServices: positiveInteger(options.maxServices, MAX_LANGUAGE_SERVICES, "maxServices"),
24
+ startTimeoutMs: positiveInteger(options.startTimeoutMs, LANGUAGE_SERVICE_START_TIMEOUT_MS, "startTimeoutMs"),
25
+ requestTimeoutMs: positiveInteger(options.requestTimeoutMs, LANGUAGE_REQUEST_TIMEOUT_MS, "requestTimeoutMs"),
26
+ shutdownTimeoutMs: positiveInteger(options.shutdownTimeoutMs, LANGUAGE_SERVICE_SHUTDOWN_TIMEOUT_MS, "shutdownTimeoutMs"),
27
+ };
28
+ this.cleanupTimer = setInterval(() => {
29
+ void this.closeIdle();
30
+ }, this.policy.cleanupIntervalMs);
31
+ this.cleanupTimer.unref();
32
+ }
33
+ async run(workspaceRoot, input) {
34
+ let project;
35
+ let canonicalWorkspaceRoot;
36
+ try {
37
+ canonicalWorkspaceRoot = await realpath(resolve(workspaceRoot));
38
+ project = await resolveLanguageProject({
39
+ workspaceRoot: canonicalWorkspaceRoot,
40
+ sourcePath: input.path,
41
+ globalConfig: this.config.languageServers,
42
+ });
43
+ }
44
+ catch (error) {
45
+ if (error instanceof LanguageServerConfigurationError) {
46
+ throw new CodeIntelligenceError(error.code, error.message);
47
+ }
48
+ throw error;
49
+ }
50
+ const service = await this.acquireService(canonicalWorkspaceRoot, project);
51
+ try {
52
+ switch (input.operation) {
53
+ case "definition":
54
+ return await service.definition(input);
55
+ case "hover":
56
+ return await service.hover(input);
57
+ case "references":
58
+ return await service.references(input);
59
+ case "documentSymbols":
60
+ return await service.documentSymbols(input);
61
+ case "workspaceSymbols":
62
+ return await service.workspaceSymbols(input);
63
+ }
64
+ }
65
+ finally {
66
+ service.release();
67
+ }
68
+ }
69
+ async shutdown() {
70
+ clearInterval(this.cleanupTimer);
71
+ await Promise.allSettled(this.serviceCreations.values());
72
+ this.serviceCreations.clear();
73
+ const services = [...this.services.values()];
74
+ this.services.clear();
75
+ await Promise.allSettled(services.map((service) => service.shutdown()));
76
+ }
77
+ get size() {
78
+ return this.services.size;
79
+ }
80
+ async acquireService(workspaceRoot, project) {
81
+ const key = languageServiceKey(project);
82
+ const existing = this.services.get(key);
83
+ if (existing) {
84
+ existing.acquire();
85
+ return existing;
86
+ }
87
+ const pending = this.serviceCreations.get(key);
88
+ if (pending) {
89
+ const service = await pending;
90
+ service.acquire();
91
+ return service;
92
+ }
93
+ const creation = this.withServiceCreationLock(async () => {
94
+ const current = this.services.get(key);
95
+ if (current) {
96
+ current.acquire();
97
+ return current;
98
+ }
99
+ await this.ensureCapacity();
100
+ const service = new LanguageService(workspaceRoot, project, this.policy);
101
+ service.acquire();
102
+ this.services.set(key, service);
103
+ return service;
104
+ });
105
+ this.serviceCreations.set(key, creation);
106
+ try {
107
+ return await creation;
108
+ }
109
+ finally {
110
+ if (this.serviceCreations.get(key) === creation) {
111
+ this.serviceCreations.delete(key);
112
+ }
113
+ }
114
+ }
115
+ async withServiceCreationLock(operation) {
116
+ const previous = this.serviceCreationQueue;
117
+ let release = () => undefined;
118
+ this.serviceCreationQueue = new Promise((resolvePromise) => {
119
+ release = resolvePromise;
120
+ });
121
+ await previous;
122
+ try {
123
+ return await operation();
124
+ }
125
+ finally {
126
+ release();
127
+ }
128
+ }
129
+ async closeIdle(now = Date.now()) {
130
+ const stale = [...this.services.entries()].filter(([, service]) => service.inFlight === 0 && now - service.lastUsedAt >= this.policy.idleMs);
131
+ for (const [key, service] of stale) {
132
+ this.services.delete(key);
133
+ await service.shutdown();
134
+ }
135
+ }
136
+ async ensureCapacity() {
137
+ if (this.services.size < this.policy.maxServices)
138
+ return;
139
+ const idle = [...this.services.entries()]
140
+ .filter(([, service]) => service.inFlight === 0)
141
+ .sort((left, right) => left[1].lastUsedAt - right[1].lastUsedAt);
142
+ const candidate = idle[0];
143
+ if (!candidate) {
144
+ throw new CodeIntelligenceError("code.language_service_capacity", `Language service capacity reached (${this.policy.maxServices}) with no idle service available for eviction.`);
145
+ }
146
+ this.services.delete(candidate[0]);
147
+ await candidate[1].shutdown();
148
+ }
149
+ }
150
+ function positiveInteger(value, fallback, label) {
151
+ const resolvedValue = value ?? fallback;
152
+ if (!Number.isInteger(resolvedValue) || resolvedValue < 1) {
153
+ throw new Error(`Code-intelligence ${label} must be a positive integer.`);
154
+ }
155
+ return resolvedValue;
156
+ }
@@ -0,0 +1,68 @@
1
+ import assert from "node:assert/strict";
2
+ import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { Client } from "@modelcontextprotocol/sdk/client/index.js";
6
+ import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
7
+ import { loadConfig } from "../../config.js";
8
+ import { createReviewCheckpointManager } from "../../review-checkpoints.js";
9
+ import { ProcessManager } from "../../process-sessions.js";
10
+ import { createMcpServer } from "../../server.js";
11
+ import { SqliteWorkspaceStore } from "../../workspace-store.js";
12
+ import { WorkspaceRegistry } from "../../workspaces.js";
13
+ import { CodeIntelligenceManager } from "../runtime/manager.js";
14
+ export async function createCodeIntelligenceServerFixture(t, options = {}) {
15
+ const root = await mkdtemp(join(tmpdir(), "forgerelay-code-intelligence-server-test-"));
16
+ const project = join(root, "project");
17
+ const agentDir = join(root, "agent");
18
+ const stateDir = join(root, ".state");
19
+ await mkdir(project, { recursive: true });
20
+ await mkdir(agentDir, { recursive: true });
21
+ await writeFile(join(agentDir, "AGENTS.md"), "global instructions\n");
22
+ await writeFile(join(project, "AGENTS.md"), "project instructions\n");
23
+ const config = loadConfig({
24
+ DEVSPACE_CONFIG_DIR: join(root, ".config"),
25
+ DEVSPACE_ALLOWED_ROOTS: root,
26
+ DEVSPACE_WORKTREE_ROOT: join(root, ".worktrees"),
27
+ DEVSPACE_AGENT_DIR: agentDir,
28
+ DEVSPACE_WIDGETS: "full",
29
+ DEVSPACE_TOOL_MODE: "full",
30
+ DEVSPACE_OAUTH_OWNER_TOKEN: "test-owner-token-that-is-long-enough",
31
+ PORT: "1",
32
+ });
33
+ const store = new SqliteWorkspaceStore(stateDir);
34
+ const workspaces = new WorkspaceRegistry(config, store);
35
+ const processSessions = new ProcessManager();
36
+ const codeIntelligence = new CodeIntelligenceManager(config, options.codeIntelligenceOptions);
37
+ const server = createMcpServer(config, workspaces, createReviewCheckpointManager(), processSessions, [], [], codeIntelligence);
38
+ const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
39
+ const client = new Client({ name: "forgerelay-code-intelligence-test-client", version: "1.0.0" });
40
+ await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]);
41
+ let closed = false;
42
+ const close = async () => {
43
+ if (closed)
44
+ return;
45
+ closed = true;
46
+ await client.close();
47
+ await server.close();
48
+ await codeIntelligence.shutdown();
49
+ processSessions.shutdown();
50
+ store.close();
51
+ };
52
+ t.after(async () => {
53
+ await close();
54
+ await rm(root, { recursive: true, force: true });
55
+ });
56
+ return { client, project, close };
57
+ }
58
+ export async function callOpen(client, path, conversationScopeId) {
59
+ return client.callTool({
60
+ name: "open_workspace",
61
+ arguments: { path },
62
+ _meta: { "openai/session": conversationScopeId },
63
+ });
64
+ }
65
+ export function structuredContent(result) {
66
+ assert.ok(result.structuredContent);
67
+ return result.structuredContent;
68
+ }
package/dist/server.js CHANGED
@@ -21,7 +21,8 @@ import { deletePath, renamePath } from "./file-mutations.js";
21
21
  import { downloadIncomingArtifact, isArtifactDownloadSupportedPlatform, } from "./artifact-tools.js";
22
22
  import { ArtifactError } from "./artifact-error.js";
23
23
  import { loadConfig } from "./config.js";
24
- import { CodeIntelligenceError, CodeIntelligenceManager } from "./lsp/code-intelligence.js";
24
+ import { CodeIntelligenceError } from "./lsp/code-intelligence.js";
25
+ import { CodeIntelligenceManager } from "./lsp/runtime/manager.js";
25
26
  import { attachHookReports, HookRunner, runToolWithHooks } from "./hooks.js";
26
27
  import { checkHookConfiguration } from "./hook-cli.js";
27
28
  import { buildServerInstructions, buildShellMutationPolicy, buildToolDescriptions, toolNames, } from "./mcp/server-instructions.js";
@@ -154,13 +154,21 @@ force a Host to invalidate its cached schema.
154
154
  ### LSP code intelligence
155
155
 
156
156
  ForgeRelay advertises `code.intelligence` through the Capability Gateway; it does
157
- not add language-specific top-level MCP tools. ForgeRelay 0.4.1 supports
158
- `definition` and `hover`. Both operations accept the same workspace-relative source
159
- position. Hover results normalize plaintext, Markdown, and supported legacy LSP
160
- payloads into one `contents` string with optional `language` and normalized `range`
161
- metadata. Language Servers are external dependencies: ForgeRelay may discover an
162
- executable already installed on the machine, but it never downloads or installs one
163
- automatically.
157
+ not add language-specific top-level MCP tools. ForgeRelay 0.4.3 supports
158
+ `definition`, `hover`, `references`, `documentSymbols`, and `workspaceSymbols`.
159
+ Position-based operations accept the same workspace-relative source position. Hover
160
+ results normalize plaintext, Markdown, and supported legacy LSP payloads into one
161
+ `contents` string with optional `language` and normalized `range` metadata.
162
+ References use the same normalized location shape as definition, default to 100
163
+ returned locations, and accept a `limit` from 1 through 1000. Document symbols use
164
+ `path` plus optional `limit`, preserve server hierarchy when present, and keep flat
165
+ legacy symbol responses flat. Workspace symbols use `path` to select the Language
166
+ project/service, then apply a `query` with an optional bounded `limit`; ForgeRelay
167
+ does not silently merge results from multiple nested Language services. Bounded
168
+ collection results report `returned`, `truncated`, and the real `total` when the
169
+ complete Language-server response makes it known. Language Servers are external
170
+ dependencies: ForgeRelay may discover an executable already installed on the
171
+ machine, but it never downloads or installs one automatically.
164
172
 
165
173
  Effective Language-server definitions resolve in this order:
166
174
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@akira-tl/forgerelay",
3
- "version": "0.4.1",
3
+ "version": "0.4.3",
4
4
  "description": "Local development control plane for MCP coding agents.",
5
5
  "type": "module",
6
6
  "homepage": "https://github.com/Akira-TL/forgerelay#readme",
@@ -42,7 +42,7 @@
42
42
  "debug:accept": "node scripts/debug/accept.mjs",
43
43
  "postinstall": "node scripts/fix-node-pty-permissions.mjs",
44
44
  "start": "node dist/cli.js serve",
45
- "test": "tsx src/config.test.ts && tsx src/lsp/language-server-config.test.ts && tsx src/lsp/normalization/hover.test.ts && tsx src/logger.test.ts && tsx src/proxy-trust.test.ts && tsx src/mcp-app-template.test.ts && tsx src/hooks.test.ts && tsx src/capability-registry.test.ts && tsx src/mcp/server-instructions.test.ts && tsx src/request-meta.test.ts && tsx src/incoming-artifacts.test.ts && tsx src/artifact-download.test.ts && tsx src/ui/card-types.test.ts && tsx src/ui/patch-display.test.ts && tsx src/ui/tool-display.test.ts && tsx src/apply-patch.test.ts && tsx src/process-platform.test.ts && tsx src/process-sessions.test.ts && tsx src/mcp-sessions.test.ts && tsx src/server-shutdown.test.ts && tsx src/local-agent-runtime.test.ts && tsx src/local-agent-adapters.test.ts && tsx src/local-agent-availability.test.ts && tsx src/local-agent-profiles.test.ts && tsx src/local-agent-targets.test.ts && tsx src/local-agent-store.test.ts && tsx src/roots.test.ts && tsx src/file-mutations.test.ts && tsx src/skills.test.ts && tsx src/workspace-store.test.ts && tsx src/workspaces.test.ts && tsx src/workspace-conversation.test.ts && tsx src/review-checkpoints.test.ts && tsx src/lsp/code-intelligence.server.test.ts && tsx src/server.test.ts && tsx src/oauth-store.test.ts && tsx src/cli.test.ts",
45
+ "test": "tsx src/config.test.ts && tsx src/lsp/language-server-config.test.ts && tsx src/lsp/normalization/hover.test.ts && tsx src/lsp/references.server.test.ts && tsx src/lsp/operations/document-symbols.server.test.ts && tsx src/lsp/operations/workspace-symbols.server.test.ts && tsx src/logger.test.ts && tsx src/proxy-trust.test.ts && tsx src/mcp-app-template.test.ts && tsx src/hooks.test.ts && tsx src/capability-registry.test.ts && tsx src/mcp/server-instructions.test.ts && tsx src/request-meta.test.ts && tsx src/incoming-artifacts.test.ts && tsx src/artifact-download.test.ts && tsx src/ui/card-types.test.ts && tsx src/ui/patch-display.test.ts && tsx src/ui/tool-display.test.ts && tsx src/apply-patch.test.ts && tsx src/process-platform.test.ts && tsx src/process-sessions.test.ts && tsx src/mcp-sessions.test.ts && tsx src/server-shutdown.test.ts && tsx src/local-agent-runtime.test.ts && tsx src/local-agent-adapters.test.ts && tsx src/local-agent-availability.test.ts && tsx src/local-agent-profiles.test.ts && tsx src/local-agent-targets.test.ts && tsx src/local-agent-store.test.ts && tsx src/roots.test.ts && tsx src/file-mutations.test.ts && tsx src/skills.test.ts && tsx src/workspace-store.test.ts && tsx src/workspaces.test.ts && tsx src/workspace-conversation.test.ts && tsx src/review-checkpoints.test.ts && tsx src/lsp/code-intelligence.server.test.ts && tsx src/server.test.ts && tsx src/oauth-store.test.ts && tsx src/cli.test.ts",
46
46
  "typecheck": "tsc -p tsconfig.json --noEmit",
47
47
  "release:check": "node scripts/release-version.mjs check",
48
48
  "release:tag-check": "node scripts/release-version.mjs tag",
@@ -332,11 +332,23 @@ try {
332
332
  });
333
333
  assert.equal(describedCodeIntelligence.isError, undefined);
334
334
  assert.equal(describedCodeIntelligence.structuredContent.capability.guide.name, "code-intelligence");
335
- assert.equal(describedCodeIntelligence.structuredContent.capability.inputSchema.type, "object");
335
+ const codeIntelligenceSchema = describedCodeIntelligence.structuredContent.capability.inputSchema;
336
+ assert.ok(Array.isArray(codeIntelligenceSchema.oneOf));
336
337
  assert.deepEqual(
337
- describedCodeIntelligence.structuredContent.capability.inputSchema.properties.operation.enum,
338
- ["definition", "hover"],
338
+ codeIntelligenceSchema.oneOf.map((variant) => variant.properties.operation.const),
339
+ ["definition", "hover", "references", "documentSymbols", "workspaceSymbols"],
339
340
  );
341
+ for (const operation of ["references", "documentSymbols", "workspaceSymbols"]) {
342
+ const boundedSchema = codeIntelligenceSchema.oneOf.find(
343
+ (variant) => variant.properties.operation.const === operation,
344
+ );
345
+ assert.equal(boundedSchema.properties.limit.minimum, 1);
346
+ assert.equal(boundedSchema.properties.limit.maximum, 1000);
347
+ }
348
+ const workspaceSymbolsSchema = codeIntelligenceSchema.oneOf.find(
349
+ (variant) => variant.properties.operation.const === "workspaceSymbols",
350
+ );
351
+ assert.ok(workspaceSymbolsSchema.required.includes("query"));
340
352
  if (process.platform === "linux") {
341
353
  const describedArtifact = callTool(oauth.accessToken, sessionId, 82, "capability", {
342
354
  workspaceId,