@akira-tl/forgerelay 0.4.2 → 0.4.4
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 +24 -0
- package/capabilities/code-intelligence/GUIDE.md +5 -1
- package/dist/capability-registry.js +16 -0
- package/dist/lsp/code-intelligence.js +117 -1
- package/dist/lsp/normalization/diagnostics.js +29 -0
- package/dist/lsp/normalization/symbols.js +90 -0
- package/dist/lsp/runtime/diagnostic-snapshots.js +105 -0
- package/dist/lsp/runtime/manager.js +10 -0
- package/docs/configuration.md +20 -10
- package/package.json +2 -2
- package/scripts/debug/accept.mjs +11 -5
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.4] - 2026-08-11
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
- Added `code.intelligence` `diagnostics` with one normalized Agent-facing contract for traditional push diagnostics and LSP 3.17 pull diagnostics.
|
|
12
|
+
- Added bounded latest Diagnostic snapshots with per-document freshness/version metadata, replacement/clear semantics, and no historical accumulation.
|
|
13
|
+
|
|
14
|
+
### Changed
|
|
15
|
+
|
|
16
|
+
- Pull diagnostics are preferred when a Language server advertises `diagnosticProvider`; ForgeRelay sends the previous `resultId`, handles `unchanged` reports, and keeps pull state independent from asynchronous push snapshots on mixed-capability servers.
|
|
17
|
+
- Diagnostic collections reuse the 100 default / 1000 hard request limit while runtime caches independently bound document count and retained diagnostics per document. Filesystem synchronization makes stale push snapshots explicit and pull reports fresh against the synchronized document version.
|
|
18
|
+
|
|
19
|
+
## [0.4.3] - 2026-08-11
|
|
20
|
+
|
|
21
|
+
### Added
|
|
22
|
+
|
|
23
|
+
- Added `documentSymbols` code intelligence with hierarchy-preserving normalization for LSP `DocumentSymbol` trees and flat handling for legacy `SymbolInformation` responses.
|
|
24
|
+
- Added bounded `workspaceSymbols` semantic search over one selected Language service, with normalized flat symbol metadata and External location handling.
|
|
25
|
+
|
|
26
|
+
### Changed
|
|
27
|
+
|
|
28
|
+
- 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.
|
|
29
|
+
- 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.
|
|
30
|
+
|
|
7
31
|
## [0.4.2] - 2026-08-11
|
|
8
32
|
|
|
9
33
|
### Added
|
|
@@ -4,10 +4,14 @@ 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.
|
|
7
|
+
ForgeRelay 0.4.4 adds `diagnostics` alongside `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. `diagnostics` uses `path` plus an optional bounded `limit`. Columns are Unicode code-point positions; ForgeRelay converts them to the position encoding negotiated with the Language server.
|
|
8
8
|
|
|
9
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
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
|
+
Diagnostics use the same normalized result contract for push and pull providers. When a Language server advertises LSP pull diagnostics, ForgeRelay prefers pull, supplies the previous `resultId` when available, and treats an `unchanged` report as a refresh of the same bounded snapshot for the newly synchronized filesystem version. Otherwise ForgeRelay uses traditional `publishDiagnostics` snapshots. Push diagnostics retain only the latest snapshot for each synchronized document: no diagnostic history is accumulated. A result reports `provider`, bounded normalized diagnostics, `returned`/`truncated`/`total`, and `freshness`. `freshness.state` distinguishes fresh, stale, missing, and unknown snapshots; filesystem changes advance ForgeRelay's synchronized document version, making an older push snapshot detectably stale until the Language server publishes a replacement. Pull and push state are bounded independently so a mixed-capability server cannot overwrite pull `resultId` state with an asynchronous push. All diagnostic state is released with the Language service.
|
|
14
|
+
|
|
11
15
|
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.
|
|
12
16
|
|
|
13
17
|
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.
|
|
@@ -129,6 +129,22 @@ export function createCapabilityRegistry(dependencies) {
|
|
|
129
129
|
...positionInput,
|
|
130
130
|
limit: z.number().int().min(1).max(MAX_CODE_INTELLIGENCE_RESULT_LIMIT).optional(),
|
|
131
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
|
+
z.object({
|
|
144
|
+
operation: z.literal("diagnostics"),
|
|
145
|
+
path: z.string().min(1),
|
|
146
|
+
limit: z.number().int().min(1).max(MAX_CODE_INTELLIGENCE_RESULT_LIMIT).optional(),
|
|
147
|
+
}).strict(),
|
|
132
148
|
]);
|
|
133
149
|
return new CapabilityRegistry([
|
|
134
150
|
{
|
|
@@ -3,14 +3,16 @@ import { readFile, realpath } from "node:fs/promises";
|
|
|
3
3
|
import { basename, resolve } from "node:path";
|
|
4
4
|
import { pathToFileURL } from "node:url";
|
|
5
5
|
import { createMessageConnection } from "vscode-jsonrpc/node";
|
|
6
|
-
import { DefinitionRequest, DidChangeTextDocumentNotification, DidCloseTextDocumentNotification, DidOpenTextDocumentNotification, ExitNotification, HoverRequest, InitializeRequest, ReferencesRequest, InitializedNotification, MarkupKind, PositionEncodingKind, ShutdownRequest, TextDocumentSyncKind, } from "vscode-languageserver-protocol";
|
|
6
|
+
import { DefinitionRequest, DocumentDiagnosticReportKind, DocumentDiagnosticRequest, DocumentSymbolRequest, DidChangeTextDocumentNotification, DidCloseTextDocumentNotification, DidOpenTextDocumentNotification, ExitNotification, HoverRequest, InitializeRequest, ReferencesRequest, InitializedNotification, MarkupKind, PositionEncodingKind, PublishDiagnosticsNotification, ShutdownRequest, TextDocumentSyncKind, WorkspaceSymbolRequest, } from "vscode-languageserver-protocol";
|
|
7
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
10
|
import { DEFAULT_CODE_INTELLIGENCE_RESULT_LIMIT } from "./code-intelligence-types.js";
|
|
11
11
|
import { normalizeHoverContents } from "./normalization/hover.js";
|
|
12
|
+
import { normalizeDocumentSymbols, normalizeWorkspaceSymbols, } from "./normalization/symbols.js";
|
|
12
13
|
import { isWithin, locationEntries, normalizeLocations, workspaceDisplayPath, } from "./normalization/locations.js";
|
|
13
14
|
import { lspPositionFromUser, rangeFromLsp, wholeDocumentRange } from "./position-encoding.js";
|
|
15
|
+
import { DiagnosticSnapshotStore } from "./runtime/diagnostic-snapshots.js";
|
|
14
16
|
export { CodeIntelligenceError } from "./code-intelligence-error.js";
|
|
15
17
|
const STDERR_TAIL_BYTES = 64 * 1024;
|
|
16
18
|
export class LanguageService {
|
|
@@ -26,6 +28,7 @@ export class LanguageService {
|
|
|
26
28
|
positionEncoding = PositionEncodingKind.UTF16;
|
|
27
29
|
capabilities;
|
|
28
30
|
documents = new Map();
|
|
31
|
+
diagnosticSnapshots;
|
|
29
32
|
stderrTail = Buffer.alloc(0);
|
|
30
33
|
closed = false;
|
|
31
34
|
constructor(workspaceRoot, project, policy) {
|
|
@@ -33,6 +36,7 @@ export class LanguageService {
|
|
|
33
36
|
this.project = project;
|
|
34
37
|
this.policy = policy;
|
|
35
38
|
this.key = languageServiceKey(project);
|
|
39
|
+
this.diagnosticSnapshots = new DiagnosticSnapshotStore(policy.maxDiagnosticDocuments, policy.maxDiagnosticsPerDocument);
|
|
36
40
|
}
|
|
37
41
|
acquire() {
|
|
38
42
|
this.inFlight += 1;
|
|
@@ -147,6 +151,102 @@ export class LanguageService {
|
|
|
147
151
|
throw new CodeIntelligenceError("code.server_crashed", `Language server ${this.project.definition.id} failed: ${errorMessage(error)}${this.stderrSuffix()}`);
|
|
148
152
|
}
|
|
149
153
|
}
|
|
154
|
+
async documentSymbols(input) {
|
|
155
|
+
try {
|
|
156
|
+
await this.ensureStarted();
|
|
157
|
+
if (!this.capabilities?.documentSymbolProvider) {
|
|
158
|
+
throw new CodeIntelligenceError("code.operation_unsupported", `Language server ${this.project.definition.id} does not advertise document-symbol support.`);
|
|
159
|
+
}
|
|
160
|
+
const sourcePath = await workspaceSourcePath(this.workspaceRoot, input.path);
|
|
161
|
+
const document = await this.syncDocument(sourcePath);
|
|
162
|
+
const response = await withTimeout(this.connection.sendRequest(DocumentSymbolRequest.type, {
|
|
163
|
+
textDocument: { uri: document.uri },
|
|
164
|
+
}), this.policy.requestTimeoutMs, () => new CodeIntelligenceError("code.request_timeout", `Document-symbol request timed out for ${input.path}.`));
|
|
165
|
+
const normalized = normalizeDocumentSymbols(response, document.text, this.positionEncoding, input.limit ?? DEFAULT_CODE_INTELLIGENCE_RESULT_LIMIT);
|
|
166
|
+
return {
|
|
167
|
+
operation: "documentSymbols",
|
|
168
|
+
selectedServer: this.project.definition.id,
|
|
169
|
+
projectRoot: workspaceDisplayPath(this.workspaceRoot, this.project.projectRoot),
|
|
170
|
+
...normalized,
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
catch (error) {
|
|
174
|
+
if (error instanceof CodeIntelligenceError)
|
|
175
|
+
throw error;
|
|
176
|
+
throw new CodeIntelligenceError("code.server_crashed", `Language server ${this.project.definition.id} failed: ${errorMessage(error)}${this.stderrSuffix()}`);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
async workspaceSymbols(input) {
|
|
180
|
+
try {
|
|
181
|
+
await this.ensureStarted();
|
|
182
|
+
if (!this.capabilities?.workspaceSymbolProvider) {
|
|
183
|
+
throw new CodeIntelligenceError("code.operation_unsupported", `Language server ${this.project.definition.id} does not advertise workspace-symbol support.`);
|
|
184
|
+
}
|
|
185
|
+
const sourcePath = await workspaceSourcePath(this.workspaceRoot, input.path);
|
|
186
|
+
await this.syncDocument(sourcePath);
|
|
187
|
+
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)}.`));
|
|
188
|
+
const normalized = await normalizeWorkspaceSymbols(response, this.workspaceRoot, this.positionEncoding, input.limit ?? DEFAULT_CODE_INTELLIGENCE_RESULT_LIMIT);
|
|
189
|
+
return {
|
|
190
|
+
operation: "workspaceSymbols",
|
|
191
|
+
selectedServer: this.project.definition.id,
|
|
192
|
+
projectRoot: workspaceDisplayPath(this.workspaceRoot, this.project.projectRoot),
|
|
193
|
+
...normalized,
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
catch (error) {
|
|
197
|
+
if (error instanceof CodeIntelligenceError)
|
|
198
|
+
throw error;
|
|
199
|
+
throw new CodeIntelligenceError("code.server_crashed", `Language server ${this.project.definition.id} failed: ${errorMessage(error)}${this.stderrSuffix()}`);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
async diagnostics(input) {
|
|
203
|
+
try {
|
|
204
|
+
await this.ensureStarted();
|
|
205
|
+
const sourcePath = await workspaceSourcePath(this.workspaceRoot, input.path);
|
|
206
|
+
const document = await this.syncDocument(sourcePath);
|
|
207
|
+
const limit = input.limit ?? DEFAULT_CODE_INTELLIGENCE_RESULT_LIMIT;
|
|
208
|
+
const common = {
|
|
209
|
+
operation: "diagnostics",
|
|
210
|
+
selectedServer: this.project.definition.id,
|
|
211
|
+
projectRoot: workspaceDisplayPath(this.workspaceRoot, this.project.projectRoot),
|
|
212
|
+
path: workspaceDisplayPath(this.workspaceRoot, sourcePath),
|
|
213
|
+
};
|
|
214
|
+
const diagnosticProvider = this.capabilities?.diagnosticProvider;
|
|
215
|
+
if (diagnosticProvider) {
|
|
216
|
+
const previousResultId = this.diagnosticSnapshots.previousPullResultId(document.uri);
|
|
217
|
+
const response = await withTimeout(this.connection.sendRequest(DocumentDiagnosticRequest.type, {
|
|
218
|
+
textDocument: { uri: document.uri },
|
|
219
|
+
...(diagnosticProvider.identifier === undefined ? {} : { identifier: diagnosticProvider.identifier }),
|
|
220
|
+
...(previousResultId === undefined ? {} : { previousResultId }),
|
|
221
|
+
}), this.policy.requestTimeoutMs, () => new CodeIntelligenceError("code.request_timeout", `Diagnostic request timed out for ${input.path}.`));
|
|
222
|
+
if (response.kind === DocumentDiagnosticReportKind.Full) {
|
|
223
|
+
this.diagnosticSnapshots.capturePull(response.items, document, this.positionEncoding, response.resultId);
|
|
224
|
+
}
|
|
225
|
+
else if (!this.diagnosticSnapshots.markPullUnchanged(document, response.resultId)) {
|
|
226
|
+
throw new CodeIntelligenceError("code.result_outside_policy", `Language server ${this.project.definition.id} returned unchanged diagnostics without a previous full report.`);
|
|
227
|
+
}
|
|
228
|
+
return {
|
|
229
|
+
...common,
|
|
230
|
+
provider: "pull",
|
|
231
|
+
...this.diagnosticSnapshots.readPull(document, limit),
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
const snapshot = this.diagnosticSnapshots.readPush(document, limit);
|
|
235
|
+
if (snapshot.freshness.state === "missing" && !this.diagnosticSnapshots.hasObservedPushDiagnostics()) {
|
|
236
|
+
throw new CodeIntelligenceError("code.operation_unsupported", `Language server ${this.project.definition.id} does not provide pull diagnostics and has not published push diagnostics.`);
|
|
237
|
+
}
|
|
238
|
+
return {
|
|
239
|
+
...common,
|
|
240
|
+
provider: "push",
|
|
241
|
+
...snapshot,
|
|
242
|
+
};
|
|
243
|
+
}
|
|
244
|
+
catch (error) {
|
|
245
|
+
if (error instanceof CodeIntelligenceError)
|
|
246
|
+
throw error;
|
|
247
|
+
throw new CodeIntelligenceError("code.server_crashed", `Language server ${this.project.definition.id} failed: ${errorMessage(error)}${this.stderrSuffix()}`);
|
|
248
|
+
}
|
|
249
|
+
}
|
|
150
250
|
async shutdown() {
|
|
151
251
|
if (this.closed)
|
|
152
252
|
return;
|
|
@@ -179,6 +279,7 @@ export class LanguageService {
|
|
|
179
279
|
connection.dispose();
|
|
180
280
|
}
|
|
181
281
|
this.documents.clear();
|
|
282
|
+
this.diagnosticSnapshots.clear();
|
|
182
283
|
if (child && child.exitCode === null && child.signalCode === null) {
|
|
183
284
|
terminateProcessTree(child, "SIGTERM", process.platform !== "win32");
|
|
184
285
|
}
|
|
@@ -251,6 +352,9 @@ export class LanguageService {
|
|
|
251
352
|
workspace: {
|
|
252
353
|
workspaceFolders: true,
|
|
253
354
|
configuration: true,
|
|
355
|
+
symbol: {
|
|
356
|
+
dynamicRegistration: false,
|
|
357
|
+
},
|
|
254
358
|
},
|
|
255
359
|
textDocument: {
|
|
256
360
|
synchronization: {
|
|
@@ -268,6 +372,14 @@ export class LanguageService {
|
|
|
268
372
|
references: {
|
|
269
373
|
dynamicRegistration: false,
|
|
270
374
|
},
|
|
375
|
+
documentSymbol: {
|
|
376
|
+
dynamicRegistration: false,
|
|
377
|
+
hierarchicalDocumentSymbolSupport: true,
|
|
378
|
+
},
|
|
379
|
+
diagnostic: {
|
|
380
|
+
dynamicRegistration: false,
|
|
381
|
+
relatedDocumentSupport: false,
|
|
382
|
+
},
|
|
271
383
|
},
|
|
272
384
|
},
|
|
273
385
|
};
|
|
@@ -291,6 +403,7 @@ export class LanguageService {
|
|
|
291
403
|
this.connection = undefined;
|
|
292
404
|
this.child = undefined;
|
|
293
405
|
this.documents.clear();
|
|
406
|
+
this.diagnosticSnapshots.clear();
|
|
294
407
|
}
|
|
295
408
|
registerClientHandlers(connection) {
|
|
296
409
|
connection.onRequest("workspace/configuration", (params) => Array.isArray(params?.items) ? params.items.map(() => null) : []);
|
|
@@ -301,6 +414,9 @@ export class LanguageService {
|
|
|
301
414
|
connection.onRequest("window/showMessageRequest", () => null);
|
|
302
415
|
connection.onNotification("window/logMessage", () => undefined);
|
|
303
416
|
connection.onNotification("window/showMessage", () => undefined);
|
|
417
|
+
connection.onNotification(PublishDiagnosticsNotification.type, (params) => {
|
|
418
|
+
this.diagnosticSnapshots.capturePush(params, this.documents.get(params.uri), this.positionEncoding);
|
|
419
|
+
});
|
|
304
420
|
}
|
|
305
421
|
async syncDocument(sourcePath) {
|
|
306
422
|
const uri = pathToFileURL(sourcePath).href;
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { rangeFromLsp } from "../position-encoding.js";
|
|
2
|
+
export function normalizeDiagnostic(diagnostic, documentText, encoding) {
|
|
3
|
+
return {
|
|
4
|
+
range: rangeFromLsp(documentText, diagnostic.range, encoding),
|
|
5
|
+
...(diagnostic.severity === undefined ? {} : { severity: diagnosticSeverityName(diagnostic.severity) }),
|
|
6
|
+
...(diagnostic.code === undefined ? {} : { code: diagnostic.code }),
|
|
7
|
+
...(diagnostic.source === undefined ? {} : { source: diagnostic.source }),
|
|
8
|
+
message: typeof diagnostic.message === "string" ? diagnostic.message : diagnostic.message.value,
|
|
9
|
+
...(diagnostic.tags?.length
|
|
10
|
+
? { tags: diagnostic.tags.map(diagnosticTagName) }
|
|
11
|
+
: {}),
|
|
12
|
+
};
|
|
13
|
+
}
|
|
14
|
+
function diagnosticSeverityName(value) {
|
|
15
|
+
switch (value) {
|
|
16
|
+
case 1: return "error";
|
|
17
|
+
case 2: return "warning";
|
|
18
|
+
case 3: return "information";
|
|
19
|
+
case 4: return "hint";
|
|
20
|
+
default: return `unknown:${value}`;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
function diagnosticTagName(value) {
|
|
24
|
+
switch (value) {
|
|
25
|
+
case 1: return "unnecessary";
|
|
26
|
+
case 2: return "deprecated";
|
|
27
|
+
default: return `unknown:${value}`;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
@@ -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,105 @@
|
|
|
1
|
+
import { normalizeDiagnostic } from "../normalization/diagnostics.js";
|
|
2
|
+
export class DiagnosticSnapshotStore {
|
|
3
|
+
maxDocuments;
|
|
4
|
+
maxDiagnosticsPerDocument;
|
|
5
|
+
pushSnapshots = new Map();
|
|
6
|
+
pullSnapshots = new Map();
|
|
7
|
+
pushObserved = false;
|
|
8
|
+
constructor(maxDocuments, maxDiagnosticsPerDocument) {
|
|
9
|
+
this.maxDocuments = maxDocuments;
|
|
10
|
+
this.maxDiagnosticsPerDocument = maxDiagnosticsPerDocument;
|
|
11
|
+
}
|
|
12
|
+
capturePush(params, document, encoding) {
|
|
13
|
+
this.pushObserved = true;
|
|
14
|
+
if (!document)
|
|
15
|
+
return;
|
|
16
|
+
const snapshot = this.normalizeSnapshot(params.diagnostics, document, encoding, params.version === undefined ? {} : { publishedVersion: params.version });
|
|
17
|
+
this.setBounded(this.pushSnapshots, params.uri, snapshot);
|
|
18
|
+
}
|
|
19
|
+
capturePull(diagnostics, document, encoding, resultId) {
|
|
20
|
+
const snapshot = this.normalizeSnapshot(diagnostics, document, encoding, resultId === undefined ? {} : { resultId });
|
|
21
|
+
this.setBounded(this.pullSnapshots, document.uri, snapshot);
|
|
22
|
+
}
|
|
23
|
+
markPullUnchanged(document, resultId) {
|
|
24
|
+
const previous = this.pullSnapshots.get(document.uri);
|
|
25
|
+
if (!previous)
|
|
26
|
+
return false;
|
|
27
|
+
const snapshot = {
|
|
28
|
+
...previous,
|
|
29
|
+
snapshotDocumentVersion: document.version,
|
|
30
|
+
resultId,
|
|
31
|
+
};
|
|
32
|
+
this.setBounded(this.pullSnapshots, document.uri, snapshot);
|
|
33
|
+
return true;
|
|
34
|
+
}
|
|
35
|
+
previousPullResultId(uri) {
|
|
36
|
+
return this.pullSnapshots.get(uri)?.resultId;
|
|
37
|
+
}
|
|
38
|
+
readPush(document, limit) {
|
|
39
|
+
return this.read(this.pushSnapshots, document, limit, true);
|
|
40
|
+
}
|
|
41
|
+
readPull(document, limit) {
|
|
42
|
+
return this.read(this.pullSnapshots, document, limit, false);
|
|
43
|
+
}
|
|
44
|
+
hasObservedPushDiagnostics() {
|
|
45
|
+
return this.pushObserved;
|
|
46
|
+
}
|
|
47
|
+
clear() {
|
|
48
|
+
this.pushSnapshots.clear();
|
|
49
|
+
this.pullSnapshots.clear();
|
|
50
|
+
this.pushObserved = false;
|
|
51
|
+
}
|
|
52
|
+
get size() {
|
|
53
|
+
return this.pushSnapshots.size + this.pullSnapshots.size;
|
|
54
|
+
}
|
|
55
|
+
normalizeSnapshot(diagnostics, document, encoding, metadata) {
|
|
56
|
+
return {
|
|
57
|
+
diagnostics: diagnostics
|
|
58
|
+
.slice(0, this.maxDiagnosticsPerDocument)
|
|
59
|
+
.map((diagnostic) => normalizeDiagnostic(diagnostic, document.text, encoding)),
|
|
60
|
+
total: diagnostics.length,
|
|
61
|
+
snapshotDocumentVersion: document.version,
|
|
62
|
+
...metadata,
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
setBounded(snapshots, uri, snapshot) {
|
|
66
|
+
snapshots.delete(uri);
|
|
67
|
+
snapshots.set(uri, snapshot);
|
|
68
|
+
while (snapshots.size > this.maxDocuments) {
|
|
69
|
+
const oldestUri = snapshots.keys().next().value;
|
|
70
|
+
if (!oldestUri)
|
|
71
|
+
break;
|
|
72
|
+
snapshots.delete(oldestUri);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
read(snapshots, document, limit, usePublishedVersion) {
|
|
76
|
+
const snapshot = snapshots.get(document.uri);
|
|
77
|
+
if (!snapshot) {
|
|
78
|
+
return {
|
|
79
|
+
diagnostics: [],
|
|
80
|
+
returned: 0,
|
|
81
|
+
truncated: false,
|
|
82
|
+
freshness: {
|
|
83
|
+
state: "missing",
|
|
84
|
+
documentVersion: document.version,
|
|
85
|
+
},
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
const diagnostics = snapshot.diagnostics.slice(0, limit);
|
|
89
|
+
const freshness = usePublishedVersion && snapshot.publishedVersion !== undefined
|
|
90
|
+
? (snapshot.publishedVersion === document.version ? "fresh" : "stale")
|
|
91
|
+
: (snapshot.snapshotDocumentVersion === document.version ? "fresh" : "stale");
|
|
92
|
+
return {
|
|
93
|
+
diagnostics,
|
|
94
|
+
returned: diagnostics.length,
|
|
95
|
+
truncated: snapshot.total > diagnostics.length,
|
|
96
|
+
total: snapshot.total,
|
|
97
|
+
freshness: {
|
|
98
|
+
state: freshness,
|
|
99
|
+
documentVersion: document.version,
|
|
100
|
+
snapshotDocumentVersion: snapshot.snapshotDocumentVersion,
|
|
101
|
+
...(snapshot.publishedVersion === undefined ? {} : { publishedVersion: snapshot.publishedVersion }),
|
|
102
|
+
},
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
}
|
|
@@ -8,6 +8,8 @@ const MAX_LANGUAGE_SERVICES = 16;
|
|
|
8
8
|
const LANGUAGE_SERVICE_START_TIMEOUT_MS = 15_000;
|
|
9
9
|
const LANGUAGE_REQUEST_TIMEOUT_MS = 10_000;
|
|
10
10
|
const LANGUAGE_SERVICE_SHUTDOWN_TIMEOUT_MS = 2_000;
|
|
11
|
+
const MAX_DIAGNOSTIC_DOCUMENTS = 128;
|
|
12
|
+
const MAX_DIAGNOSTICS_PER_DOCUMENT = 1000;
|
|
11
13
|
export class CodeIntelligenceManager {
|
|
12
14
|
config;
|
|
13
15
|
services = new Map();
|
|
@@ -24,6 +26,8 @@ export class CodeIntelligenceManager {
|
|
|
24
26
|
startTimeoutMs: positiveInteger(options.startTimeoutMs, LANGUAGE_SERVICE_START_TIMEOUT_MS, "startTimeoutMs"),
|
|
25
27
|
requestTimeoutMs: positiveInteger(options.requestTimeoutMs, LANGUAGE_REQUEST_TIMEOUT_MS, "requestTimeoutMs"),
|
|
26
28
|
shutdownTimeoutMs: positiveInteger(options.shutdownTimeoutMs, LANGUAGE_SERVICE_SHUTDOWN_TIMEOUT_MS, "shutdownTimeoutMs"),
|
|
29
|
+
maxDiagnosticDocuments: positiveInteger(options.maxDiagnosticDocuments, MAX_DIAGNOSTIC_DOCUMENTS, "maxDiagnosticDocuments"),
|
|
30
|
+
maxDiagnosticsPerDocument: positiveInteger(options.maxDiagnosticsPerDocument, MAX_DIAGNOSTICS_PER_DOCUMENT, "maxDiagnosticsPerDocument"),
|
|
27
31
|
};
|
|
28
32
|
this.cleanupTimer = setInterval(() => {
|
|
29
33
|
void this.closeIdle();
|
|
@@ -56,6 +60,12 @@ export class CodeIntelligenceManager {
|
|
|
56
60
|
return await service.hover(input);
|
|
57
61
|
case "references":
|
|
58
62
|
return await service.references(input);
|
|
63
|
+
case "documentSymbols":
|
|
64
|
+
return await service.documentSymbols(input);
|
|
65
|
+
case "workspaceSymbols":
|
|
66
|
+
return await service.workspaceSymbols(input);
|
|
67
|
+
case "diagnostics":
|
|
68
|
+
return await service.diagnostics(input);
|
|
59
69
|
}
|
|
60
70
|
}
|
|
61
71
|
finally {
|
package/docs/configuration.md
CHANGED
|
@@ -154,16 +154,26 @@ 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.
|
|
158
|
-
`definition`, `hover`,
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
157
|
+
not add language-specific top-level MCP tools. ForgeRelay 0.4.4 supports
|
|
158
|
+
`definition`, `hover`, `references`, `documentSymbols`, `workspaceSymbols`, and
|
|
159
|
+
`diagnostics`.
|
|
160
|
+
Position-based operations accept the same workspace-relative source position. Hover
|
|
161
|
+
results normalize plaintext, Markdown, and supported legacy LSP payloads into one
|
|
162
|
+
`contents` string with optional `language` and normalized `range` metadata.
|
|
163
|
+
References use the same normalized location shape as definition, default to 100
|
|
164
|
+
returned locations, and accept a `limit` from 1 through 1000. Document symbols use
|
|
165
|
+
`path` plus optional `limit`, preserve server hierarchy when present, and keep flat
|
|
166
|
+
legacy symbol responses flat. Workspace symbols use `path` to select the Language
|
|
167
|
+
project/service, then apply a `query` with an optional bounded `limit`; ForgeRelay
|
|
168
|
+
does not silently merge results from multiple nested Language services. Diagnostics
|
|
169
|
+
use `path` plus optional `limit`, prefer LSP pull diagnostics when the selected server
|
|
170
|
+
advertises them, and otherwise consume the latest bounded `publishDiagnostics`
|
|
171
|
+
snapshot. Push and pull use one normalized result shape with `provider`,
|
|
172
|
+
`returned`/`truncated`/`total`, and freshness metadata tied to ForgeRelay's synchronized
|
|
173
|
+
filesystem document version. Bounded collection results report `returned`, `truncated`,
|
|
174
|
+
and the real `total` when the complete Language-server response makes it known. Language Servers are external
|
|
175
|
+
dependencies: ForgeRelay may discover an executable already installed on the
|
|
176
|
+
machine, but it never downloads or installs one automatically.
|
|
167
177
|
|
|
168
178
|
Effective Language-server definitions resolve in this order:
|
|
169
179
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@akira-tl/forgerelay",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.4",
|
|
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/lsp/references.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",
|
|
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/lsp/operations/diagnostics-push.server.test.ts && tsx src/lsp/operations/diagnostics-pull.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",
|
package/scripts/debug/accept.mjs
CHANGED
|
@@ -336,13 +336,19 @@ try {
|
|
|
336
336
|
assert.ok(Array.isArray(codeIntelligenceSchema.oneOf));
|
|
337
337
|
assert.deepEqual(
|
|
338
338
|
codeIntelligenceSchema.oneOf.map((variant) => variant.properties.operation.const),
|
|
339
|
-
["definition", "hover", "references"],
|
|
339
|
+
["definition", "hover", "references", "documentSymbols", "workspaceSymbols", "diagnostics"],
|
|
340
340
|
);
|
|
341
|
-
const
|
|
342
|
-
|
|
341
|
+
for (const operation of ["references", "documentSymbols", "workspaceSymbols", "diagnostics"]) {
|
|
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",
|
|
343
350
|
);
|
|
344
|
-
assert.
|
|
345
|
-
assert.equal(referencesSchema.properties.limit.maximum, 1000);
|
|
351
|
+
assert.ok(workspaceSymbolsSchema.required.includes("query"));
|
|
346
352
|
if (process.platform === "linux") {
|
|
347
353
|
const describedArtifact = callTool(oauth.accessToken, sessionId, 82, "capability", {
|
|
348
354
|
workspaceId,
|