@akira-tl/forgerelay 0.3.7 → 0.4.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.
- package/CHANGELOG.md +19 -0
- package/capabilities/code-intelligence/GUIDE.md +11 -0
- package/dist/capabilities.js +6 -0
- package/dist/capability-registry.js +20 -0
- package/dist/config.js +1 -0
- package/dist/lsp/code-intelligence-error.js +8 -0
- package/dist/lsp/code-intelligence.js +550 -0
- package/dist/lsp/language-server-config.js +313 -0
- package/dist/lsp/position-encoding.js +88 -0
- package/dist/server.js +22 -2
- package/docs/configuration.md +62 -0
- package/docs/roadmap.md +15 -0
- package/package.json +4 -2
- package/scripts/debug/accept.mjs +15 -0
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,25 @@ All notable ForgeRelay changes are documented here.
|
|
|
4
4
|
|
|
5
5
|
## [Unreleased]
|
|
6
6
|
|
|
7
|
+
## [0.4.0] - 2026-08-11
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
- Added `code.intelligence` as a Capability Gateway-only LSP code-intelligence surface. ForgeRelay 0.4.0 ships the first complete `definition` tracer bullet without changing the canonical nine Core MCP tools.
|
|
12
|
+
- Added Language-server definitions with project (`.forgerelay/language-servers.json`), global (`~/.forgerelay/config.json`), and built-in discovery precedence. Common built-ins cover TypeScript/JavaScript, Pyright, rust-analyzer, gopls, and clangd when those executables are already installed.
|
|
13
|
+
- Added a deterministic child-process fake LSP server and MCP-level regression seam covering initialize/shutdown, document synchronization, definition normalization, shared Language-service identity, capacity limits, and server-initiated edit rejection.
|
|
14
|
+
|
|
15
|
+
### Changed
|
|
16
|
+
|
|
17
|
+
- Code-intelligence positions use ForgeRelay's 1-based line and Unicode code-point column contract and are converted internally to the position encoding negotiated with the Language Server.
|
|
18
|
+
- Language services are shared by canonical Language project root plus effective server-definition fingerprint rather than logical workspace ID, remain capacity/idle bounded, and use structured no-shell process launch over Microsoft's `vscode-jsonrpc` / `vscode-languageserver-protocol` substrate.
|
|
19
|
+
- Language-server configuration can explicitly disable built-in discovery; nested Language projects resolve by walking ancestors of the requested source path instead of recursively scanning the Workspace.
|
|
20
|
+
|
|
21
|
+
### Fixed
|
|
22
|
+
|
|
23
|
+
- External LSP definition targets are marked as informational external locations without expanding ForgeRelay file-read authority, including symlink-escape protection and canonical Workspace-root handling.
|
|
24
|
+
- Language-server startup failure/timeout, unsupported operations, invalid positions, configuration ambiguity, capacity exhaustion, and other policy failures now use stable ForgeRelay `code.*` errors instead of leaking raw JSON-RPC failures.
|
|
25
|
+
|
|
7
26
|
## [0.3.7] - 2026-08-10
|
|
8
27
|
|
|
9
28
|
### Added
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# Code Intelligence
|
|
2
|
+
|
|
3
|
+
Use the `code.intelligence` Capability for read-only semantic code navigation backed by Language servers that are already installed on the user's machine or explicitly configured for the project.
|
|
4
|
+
|
|
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
|
+
|
|
7
|
+
For 0.4.0, the supported operation is `definition`. Pass 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.
|
|
8
|
+
|
|
9
|
+
Code-intelligence results use ForgeRelay-normalized locations rather than raw LSP wire types. A result 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.
|
|
10
|
+
|
|
11
|
+
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.
|
package/dist/capabilities.js
CHANGED
|
@@ -34,6 +34,11 @@ const CAPABILITY_GUIDE_DEFINITIONS = [
|
|
|
34
34
|
description: "Long-running bash processes, processId interaction, PTY, and platform edges.",
|
|
35
35
|
whenToRead: "Read for running or interactive command issues.",
|
|
36
36
|
},
|
|
37
|
+
{
|
|
38
|
+
name: "code-intelligence",
|
|
39
|
+
description: "Read-only semantic code navigation backed by external Language servers.",
|
|
40
|
+
whenToRead: "Read before using code.intelligence or configuring Language servers.",
|
|
41
|
+
},
|
|
37
42
|
];
|
|
38
43
|
function capabilityGuidesDir() {
|
|
39
44
|
return fileURLToPath(new URL("../capabilities", import.meta.url));
|
|
@@ -75,6 +80,7 @@ export function buildCapabilityFingerprint(config, version, context = {}) {
|
|
|
75
80
|
"process.lifecycle",
|
|
76
81
|
"hooks.lifecycle",
|
|
77
82
|
"capability-guides.read",
|
|
83
|
+
"code.intelligence",
|
|
78
84
|
];
|
|
79
85
|
if (config.subagents) {
|
|
80
86
|
capabilities.push("subagent.profiles");
|
|
@@ -115,6 +115,12 @@ export class CapabilityRegistry {
|
|
|
115
115
|
}
|
|
116
116
|
export function createCapabilityRegistry(dependencies) {
|
|
117
117
|
const hooksCheckInput = z.object({}).strict();
|
|
118
|
+
const codeIntelligenceInput = z.object({
|
|
119
|
+
operation: z.literal("definition"),
|
|
120
|
+
path: z.string().min(1),
|
|
121
|
+
line: z.number().int(),
|
|
122
|
+
column: z.number().int(),
|
|
123
|
+
}).strict();
|
|
118
124
|
return new CapabilityRegistry([
|
|
119
125
|
{
|
|
120
126
|
name: "hooks.check",
|
|
@@ -144,6 +150,20 @@ export function createCapabilityRegistry(dependencies) {
|
|
|
144
150
|
run: async (_input, context) => dependencies.reviewChanges.run(context),
|
|
145
151
|
}]
|
|
146
152
|
: []),
|
|
153
|
+
...(dependencies.codeIntelligence
|
|
154
|
+
? [{
|
|
155
|
+
name: "code.intelligence",
|
|
156
|
+
description: "Read semantic code information through an available Language server without changing the Workspace.",
|
|
157
|
+
guideName: "code-intelligence",
|
|
158
|
+
readGuideBeforeFirstUse: true,
|
|
159
|
+
inputSchema: codeIntelligenceInput,
|
|
160
|
+
availability: () => ({
|
|
161
|
+
available: dependencies.codeIntelligence?.available ?? false,
|
|
162
|
+
reason: dependencies.codeIntelligence?.unavailableReason,
|
|
163
|
+
}),
|
|
164
|
+
run: async (input, context) => dependencies.codeIntelligence.run(input, context),
|
|
165
|
+
}]
|
|
166
|
+
: []),
|
|
147
167
|
...(dependencies.downloadArtifact
|
|
148
168
|
? [{
|
|
149
169
|
name: "artifact.download",
|
package/dist/config.js
CHANGED
|
@@ -223,6 +223,7 @@ export function loadConfig(env = process.env) {
|
|
|
223
223
|
subagents: productEnv(env, "SUBAGENTS") === undefined
|
|
224
224
|
? files.config.subagents === true
|
|
225
225
|
: parseBoolean(productEnv(env, "SUBAGENTS")),
|
|
226
|
+
languageServers: files.config.languageServers ?? {},
|
|
226
227
|
agentDir: resolve(expandHomePath(productEnv(env, "AGENT_DIR") ?? files.config.agentDir ?? defaultAgentDir())),
|
|
227
228
|
systemInstructionsPath: parseSystemInstructionsPath(productEnv(env, "SYSTEM_INSTRUCTIONS_PATH") ?? files.config.systemInstructionsPath),
|
|
228
229
|
hooks: mergeHookConfigs(parseHookConfig(files.config.hooks), parseHookConfig(files.hooks), files.hookFiles),
|
|
@@ -0,0 +1,550 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
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";
|
|
5
|
+
import { createMessageConnection } from "vscode-jsonrpc/node";
|
|
6
|
+
import { DefinitionRequest, DidChangeTextDocumentNotification, DidCloseTextDocumentNotification, DidOpenTextDocumentNotification, ExitNotification, InitializeRequest, InitializedNotification, PositionEncodingKind, ShutdownRequest, TextDocumentSyncKind, } from "vscode-languageserver-protocol";
|
|
7
|
+
import { LanguageServerConfigurationError, resolveLanguageProject, } from "./language-server-config.js";
|
|
8
|
+
import { terminateProcessTree } from "../process-platform.js";
|
|
9
|
+
import { CodeIntelligenceError } from "./code-intelligence-error.js";
|
|
10
|
+
import { lspPositionFromUser, rangeFromLsp, wholeDocumentRange } from "./position-encoding.js";
|
|
11
|
+
export { CodeIntelligenceError } from "./code-intelligence-error.js";
|
|
12
|
+
const LANGUAGE_SERVICE_IDLE_MS = 10 * 60 * 1_000;
|
|
13
|
+
const LANGUAGE_SERVICE_CLEANUP_INTERVAL_MS = 60 * 1_000;
|
|
14
|
+
const MAX_LANGUAGE_SERVICES = 16;
|
|
15
|
+
const LANGUAGE_SERVICE_START_TIMEOUT_MS = 15_000;
|
|
16
|
+
const LANGUAGE_REQUEST_TIMEOUT_MS = 10_000;
|
|
17
|
+
const LANGUAGE_SERVICE_SHUTDOWN_TIMEOUT_MS = 2_000;
|
|
18
|
+
const STDERR_TAIL_BYTES = 64 * 1024;
|
|
19
|
+
class LanguageService {
|
|
20
|
+
workspaceRoot;
|
|
21
|
+
project;
|
|
22
|
+
policy;
|
|
23
|
+
key;
|
|
24
|
+
lastUsedAt = Date.now();
|
|
25
|
+
inFlight = 0;
|
|
26
|
+
child;
|
|
27
|
+
connection;
|
|
28
|
+
initializePromise;
|
|
29
|
+
positionEncoding = PositionEncodingKind.UTF16;
|
|
30
|
+
capabilities;
|
|
31
|
+
documents = new Map();
|
|
32
|
+
stderrTail = Buffer.alloc(0);
|
|
33
|
+
closed = false;
|
|
34
|
+
constructor(workspaceRoot, project, policy) {
|
|
35
|
+
this.workspaceRoot = workspaceRoot;
|
|
36
|
+
this.project = project;
|
|
37
|
+
this.policy = policy;
|
|
38
|
+
this.key = languageServiceKey(project);
|
|
39
|
+
}
|
|
40
|
+
acquire() {
|
|
41
|
+
this.inFlight += 1;
|
|
42
|
+
this.lastUsedAt = Date.now();
|
|
43
|
+
}
|
|
44
|
+
release() {
|
|
45
|
+
this.inFlight = Math.max(0, this.inFlight - 1);
|
|
46
|
+
this.lastUsedAt = Date.now();
|
|
47
|
+
}
|
|
48
|
+
async definition(input) {
|
|
49
|
+
try {
|
|
50
|
+
await this.ensureStarted();
|
|
51
|
+
if (!this.capabilities?.definitionProvider) {
|
|
52
|
+
throw new CodeIntelligenceError("code.operation_unsupported", `Language server ${this.project.definition.id} does not advertise definition support.`);
|
|
53
|
+
}
|
|
54
|
+
const sourcePath = await workspaceSourcePath(this.workspaceRoot, input.path);
|
|
55
|
+
const document = await this.syncDocument(sourcePath);
|
|
56
|
+
const position = lspPositionFromUser(document.text, input.line, input.column, this.positionEncoding);
|
|
57
|
+
const response = await withTimeout(this.connection.sendRequest(DefinitionRequest.type, {
|
|
58
|
+
textDocument: { uri: document.uri },
|
|
59
|
+
position,
|
|
60
|
+
}), this.policy.requestTimeoutMs, () => new CodeIntelligenceError("code.request_timeout", `Definition request timed out for ${input.path}.`));
|
|
61
|
+
const locations = await normalizeDefinitionResponse(response, this.workspaceRoot, this.positionEncoding);
|
|
62
|
+
return {
|
|
63
|
+
operation: "definition",
|
|
64
|
+
selectedServer: this.project.definition.id,
|
|
65
|
+
projectRoot: workspaceDisplayPath(this.workspaceRoot, this.project.projectRoot),
|
|
66
|
+
locations,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
catch (error) {
|
|
70
|
+
if (error instanceof CodeIntelligenceError)
|
|
71
|
+
throw error;
|
|
72
|
+
if (error instanceof LanguageServerConfigurationError) {
|
|
73
|
+
throw new CodeIntelligenceError(error.code, error.message);
|
|
74
|
+
}
|
|
75
|
+
throw new CodeIntelligenceError("code.server_crashed", `Language server ${this.project.definition.id} failed: ${errorMessage(error)}${this.stderrSuffix()}`);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
async shutdown() {
|
|
79
|
+
if (this.closed)
|
|
80
|
+
return;
|
|
81
|
+
this.closed = true;
|
|
82
|
+
const connection = this.connection;
|
|
83
|
+
const child = this.child;
|
|
84
|
+
if (connection) {
|
|
85
|
+
for (const document of this.documents.values()) {
|
|
86
|
+
if (!document.openNotified)
|
|
87
|
+
continue;
|
|
88
|
+
try {
|
|
89
|
+
await connection.sendNotification(DidCloseTextDocumentNotification.type, {
|
|
90
|
+
textDocument: { uri: document.uri },
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
catch {
|
|
94
|
+
// The server may already be gone.
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
try {
|
|
98
|
+
await withTimeout(connection.sendRequest(ShutdownRequest.type), this.policy.shutdownTimeoutMs, () => new Error("Language-server shutdown timed out."));
|
|
99
|
+
await connection.sendNotification(ExitNotification.type);
|
|
100
|
+
if (child) {
|
|
101
|
+
await waitForChildExit(child, this.policy.shutdownTimeoutMs);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
catch {
|
|
105
|
+
// Fall through to process-tree termination below.
|
|
106
|
+
}
|
|
107
|
+
connection.dispose();
|
|
108
|
+
}
|
|
109
|
+
this.documents.clear();
|
|
110
|
+
if (child && child.exitCode === null && child.signalCode === null) {
|
|
111
|
+
terminateProcessTree(child, "SIGTERM", process.platform !== "win32");
|
|
112
|
+
}
|
|
113
|
+
this.child = undefined;
|
|
114
|
+
this.connection = undefined;
|
|
115
|
+
this.initializePromise = undefined;
|
|
116
|
+
}
|
|
117
|
+
async ensureStarted() {
|
|
118
|
+
if (this.closed) {
|
|
119
|
+
throw new CodeIntelligenceError("code.server_crashed", `Language service ${this.project.definition.id} is already closed.`);
|
|
120
|
+
}
|
|
121
|
+
if (!this.initializePromise)
|
|
122
|
+
this.initializePromise = this.start();
|
|
123
|
+
try {
|
|
124
|
+
const result = await this.initializePromise;
|
|
125
|
+
this.capabilities = result.capabilities;
|
|
126
|
+
this.positionEncoding = result.capabilities.positionEncoding ?? PositionEncodingKind.UTF16;
|
|
127
|
+
}
|
|
128
|
+
catch (error) {
|
|
129
|
+
this.initializePromise = undefined;
|
|
130
|
+
this.cleanupFailedStart();
|
|
131
|
+
if (error instanceof CodeIntelligenceError)
|
|
132
|
+
throw error;
|
|
133
|
+
throw new CodeIntelligenceError("code.language_service_start_failed", `Unable to initialize Language server ${this.project.definition.id}: ${errorMessage(error)}${this.stderrSuffix()}`);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
async start() {
|
|
137
|
+
const definition = this.project.definition;
|
|
138
|
+
const detached = process.platform !== "win32";
|
|
139
|
+
let child;
|
|
140
|
+
try {
|
|
141
|
+
child = spawn(definition.command, definition.args, {
|
|
142
|
+
cwd: this.project.projectRoot,
|
|
143
|
+
env: { ...process.env, ...definition.env },
|
|
144
|
+
stdio: "pipe",
|
|
145
|
+
windowsHide: true,
|
|
146
|
+
detached,
|
|
147
|
+
shell: false,
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
catch (error) {
|
|
151
|
+
throw new CodeIntelligenceError("code.language_service_start_failed", `Unable to start Language server ${definition.id}: ${errorMessage(error)}.`);
|
|
152
|
+
}
|
|
153
|
+
this.child = child;
|
|
154
|
+
child.stderr.on("data", (chunk) => this.appendStderr(chunk));
|
|
155
|
+
await waitForChildSpawn(child, definition.id);
|
|
156
|
+
const connection = createMessageConnection(child.stdout, child.stdin);
|
|
157
|
+
this.connection = connection;
|
|
158
|
+
this.registerClientHandlers(connection);
|
|
159
|
+
connection.listen();
|
|
160
|
+
let onExitDuringInitialization;
|
|
161
|
+
const startupFailure = new Promise((_resolve, reject) => {
|
|
162
|
+
onExitDuringInitialization = (code, signal) => reject(new CodeIntelligenceError("code.language_service_start_failed", `Language server ${definition.id} exited during initialization (${signal ?? code ?? "unknown"}).${this.stderrSuffix()}`));
|
|
163
|
+
child.once("exit", onExitDuringInitialization);
|
|
164
|
+
});
|
|
165
|
+
const rootUri = pathToFileURL(this.project.projectRoot).href;
|
|
166
|
+
const initializeParams = {
|
|
167
|
+
processId: process.pid,
|
|
168
|
+
clientInfo: { name: "forgerelay" },
|
|
169
|
+
rootUri,
|
|
170
|
+
workspaceFolders: [{ uri: rootUri, name: basename(this.project.projectRoot) }],
|
|
171
|
+
capabilities: {
|
|
172
|
+
general: {
|
|
173
|
+
positionEncodings: [
|
|
174
|
+
PositionEncodingKind.UTF8,
|
|
175
|
+
PositionEncodingKind.UTF16,
|
|
176
|
+
PositionEncodingKind.UTF32,
|
|
177
|
+
],
|
|
178
|
+
},
|
|
179
|
+
workspace: {
|
|
180
|
+
workspaceFolders: true,
|
|
181
|
+
configuration: true,
|
|
182
|
+
},
|
|
183
|
+
textDocument: {
|
|
184
|
+
synchronization: {
|
|
185
|
+
dynamicRegistration: false,
|
|
186
|
+
didSave: false,
|
|
187
|
+
},
|
|
188
|
+
definition: {
|
|
189
|
+
dynamicRegistration: false,
|
|
190
|
+
linkSupport: true,
|
|
191
|
+
},
|
|
192
|
+
},
|
|
193
|
+
},
|
|
194
|
+
};
|
|
195
|
+
const initialize = connection.sendRequest(InitializeRequest.type, initializeParams);
|
|
196
|
+
try {
|
|
197
|
+
const result = await withTimeout(Promise.race([initialize, startupFailure]), this.policy.startTimeoutMs, () => new CodeIntelligenceError("code.language_service_start_timeout", `Language server ${definition.id} did not initialize within ${this.policy.startTimeoutMs}ms.`));
|
|
198
|
+
await connection.sendNotification(InitializedNotification.type, {});
|
|
199
|
+
return result;
|
|
200
|
+
}
|
|
201
|
+
finally {
|
|
202
|
+
if (onExitDuringInitialization)
|
|
203
|
+
child.off("exit", onExitDuringInitialization);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
cleanupFailedStart() {
|
|
207
|
+
this.connection?.dispose();
|
|
208
|
+
const child = this.child;
|
|
209
|
+
if (child && child.exitCode === null && child.signalCode === null) {
|
|
210
|
+
terminateProcessTree(child, "SIGTERM", process.platform !== "win32");
|
|
211
|
+
}
|
|
212
|
+
this.connection = undefined;
|
|
213
|
+
this.child = undefined;
|
|
214
|
+
this.documents.clear();
|
|
215
|
+
}
|
|
216
|
+
registerClientHandlers(connection) {
|
|
217
|
+
connection.onRequest("workspace/configuration", (params) => Array.isArray(params?.items) ? params.items.map(() => null) : []);
|
|
218
|
+
connection.onRequest("workspace/workspaceFolders", () => [{
|
|
219
|
+
uri: pathToFileURL(this.project.projectRoot).href,
|
|
220
|
+
name: basename(this.project.projectRoot),
|
|
221
|
+
}]);
|
|
222
|
+
connection.onRequest("window/showMessageRequest", () => null);
|
|
223
|
+
connection.onNotification("window/logMessage", () => undefined);
|
|
224
|
+
connection.onNotification("window/showMessage", () => undefined);
|
|
225
|
+
}
|
|
226
|
+
async syncDocument(sourcePath) {
|
|
227
|
+
const uri = pathToFileURL(sourcePath).href;
|
|
228
|
+
const text = await readFile(sourcePath, "utf8");
|
|
229
|
+
const existing = this.documents.get(uri);
|
|
230
|
+
const languageId = languageIdForPath(this.project.definition, sourcePath);
|
|
231
|
+
const synchronization = textDocumentSynchronization(this.capabilities?.textDocumentSync);
|
|
232
|
+
if (!existing) {
|
|
233
|
+
const document = { uri, languageId, version: 1, text, openNotified: false };
|
|
234
|
+
this.documents.set(uri, document);
|
|
235
|
+
if (synchronization.openClose) {
|
|
236
|
+
await this.connection.sendNotification(DidOpenTextDocumentNotification.type, {
|
|
237
|
+
textDocument: {
|
|
238
|
+
uri: document.uri,
|
|
239
|
+
languageId: document.languageId,
|
|
240
|
+
version: document.version,
|
|
241
|
+
text: document.text,
|
|
242
|
+
},
|
|
243
|
+
});
|
|
244
|
+
document.openNotified = true;
|
|
245
|
+
}
|
|
246
|
+
return document;
|
|
247
|
+
}
|
|
248
|
+
if (existing.text !== text) {
|
|
249
|
+
const previousText = existing.text;
|
|
250
|
+
existing.version += 1;
|
|
251
|
+
existing.text = text;
|
|
252
|
+
if (synchronization.change === TextDocumentSyncKind.Full) {
|
|
253
|
+
await this.connection.sendNotification(DidChangeTextDocumentNotification.type, {
|
|
254
|
+
textDocument: { uri, version: existing.version },
|
|
255
|
+
contentChanges: [{ text }],
|
|
256
|
+
});
|
|
257
|
+
}
|
|
258
|
+
else if (synchronization.change === TextDocumentSyncKind.Incremental) {
|
|
259
|
+
await this.connection.sendNotification(DidChangeTextDocumentNotification.type, {
|
|
260
|
+
textDocument: { uri, version: existing.version },
|
|
261
|
+
contentChanges: [{
|
|
262
|
+
range: wholeDocumentRange(previousText, this.positionEncoding),
|
|
263
|
+
text,
|
|
264
|
+
}],
|
|
265
|
+
});
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
return existing;
|
|
269
|
+
}
|
|
270
|
+
appendStderr(chunk) {
|
|
271
|
+
this.stderrTail = Buffer.concat([this.stderrTail, chunk]);
|
|
272
|
+
if (this.stderrTail.length > STDERR_TAIL_BYTES) {
|
|
273
|
+
this.stderrTail = this.stderrTail.subarray(this.stderrTail.length - STDERR_TAIL_BYTES);
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
stderrSuffix() {
|
|
277
|
+
const text = this.stderrTail.toString("utf8").trim();
|
|
278
|
+
return text ? ` Server stderr: ${text}` : "";
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
export class CodeIntelligenceManager {
|
|
282
|
+
config;
|
|
283
|
+
services = new Map();
|
|
284
|
+
serviceCreations = new Map();
|
|
285
|
+
serviceCreationQueue = Promise.resolve();
|
|
286
|
+
cleanupTimer;
|
|
287
|
+
policy;
|
|
288
|
+
constructor(config, options = {}) {
|
|
289
|
+
this.config = config;
|
|
290
|
+
this.policy = {
|
|
291
|
+
idleMs: positiveInteger(options.idleMs, LANGUAGE_SERVICE_IDLE_MS, "idleMs"),
|
|
292
|
+
cleanupIntervalMs: positiveInteger(options.cleanupIntervalMs, LANGUAGE_SERVICE_CLEANUP_INTERVAL_MS, "cleanupIntervalMs"),
|
|
293
|
+
maxServices: positiveInteger(options.maxServices, MAX_LANGUAGE_SERVICES, "maxServices"),
|
|
294
|
+
startTimeoutMs: positiveInteger(options.startTimeoutMs, LANGUAGE_SERVICE_START_TIMEOUT_MS, "startTimeoutMs"),
|
|
295
|
+
requestTimeoutMs: positiveInteger(options.requestTimeoutMs, LANGUAGE_REQUEST_TIMEOUT_MS, "requestTimeoutMs"),
|
|
296
|
+
shutdownTimeoutMs: positiveInteger(options.shutdownTimeoutMs, LANGUAGE_SERVICE_SHUTDOWN_TIMEOUT_MS, "shutdownTimeoutMs"),
|
|
297
|
+
};
|
|
298
|
+
this.cleanupTimer = setInterval(() => {
|
|
299
|
+
void this.closeIdle();
|
|
300
|
+
}, this.policy.cleanupIntervalMs);
|
|
301
|
+
this.cleanupTimer.unref();
|
|
302
|
+
}
|
|
303
|
+
async definition(workspaceRoot, input) {
|
|
304
|
+
let project;
|
|
305
|
+
let canonicalWorkspaceRoot;
|
|
306
|
+
try {
|
|
307
|
+
canonicalWorkspaceRoot = await realpath(resolve(workspaceRoot));
|
|
308
|
+
project = await resolveLanguageProject({
|
|
309
|
+
workspaceRoot: canonicalWorkspaceRoot,
|
|
310
|
+
sourcePath: input.path,
|
|
311
|
+
globalConfig: this.config.languageServers,
|
|
312
|
+
});
|
|
313
|
+
}
|
|
314
|
+
catch (error) {
|
|
315
|
+
if (error instanceof LanguageServerConfigurationError) {
|
|
316
|
+
throw new CodeIntelligenceError(error.code, error.message);
|
|
317
|
+
}
|
|
318
|
+
throw error;
|
|
319
|
+
}
|
|
320
|
+
const service = await this.acquireService(canonicalWorkspaceRoot, project);
|
|
321
|
+
try {
|
|
322
|
+
return await service.definition(input);
|
|
323
|
+
}
|
|
324
|
+
finally {
|
|
325
|
+
service.release();
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
async shutdown() {
|
|
329
|
+
clearInterval(this.cleanupTimer);
|
|
330
|
+
await Promise.allSettled(this.serviceCreations.values());
|
|
331
|
+
this.serviceCreations.clear();
|
|
332
|
+
const services = [...this.services.values()];
|
|
333
|
+
this.services.clear();
|
|
334
|
+
await Promise.allSettled(services.map((service) => service.shutdown()));
|
|
335
|
+
}
|
|
336
|
+
get size() {
|
|
337
|
+
return this.services.size;
|
|
338
|
+
}
|
|
339
|
+
async acquireService(workspaceRoot, project) {
|
|
340
|
+
const key = languageServiceKey(project);
|
|
341
|
+
const existing = this.services.get(key);
|
|
342
|
+
if (existing) {
|
|
343
|
+
existing.acquire();
|
|
344
|
+
return existing;
|
|
345
|
+
}
|
|
346
|
+
const pending = this.serviceCreations.get(key);
|
|
347
|
+
if (pending) {
|
|
348
|
+
const service = await pending;
|
|
349
|
+
service.acquire();
|
|
350
|
+
return service;
|
|
351
|
+
}
|
|
352
|
+
const creation = this.withServiceCreationLock(async () => {
|
|
353
|
+
const current = this.services.get(key);
|
|
354
|
+
if (current) {
|
|
355
|
+
current.acquire();
|
|
356
|
+
return current;
|
|
357
|
+
}
|
|
358
|
+
await this.ensureCapacity();
|
|
359
|
+
const service = new LanguageService(workspaceRoot, project, this.policy);
|
|
360
|
+
service.acquire();
|
|
361
|
+
this.services.set(key, service);
|
|
362
|
+
return service;
|
|
363
|
+
});
|
|
364
|
+
this.serviceCreations.set(key, creation);
|
|
365
|
+
try {
|
|
366
|
+
return await creation;
|
|
367
|
+
}
|
|
368
|
+
finally {
|
|
369
|
+
if (this.serviceCreations.get(key) === creation) {
|
|
370
|
+
this.serviceCreations.delete(key);
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
async withServiceCreationLock(operation) {
|
|
375
|
+
const previous = this.serviceCreationQueue;
|
|
376
|
+
let release = () => undefined;
|
|
377
|
+
this.serviceCreationQueue = new Promise((resolvePromise) => {
|
|
378
|
+
release = resolvePromise;
|
|
379
|
+
});
|
|
380
|
+
await previous;
|
|
381
|
+
try {
|
|
382
|
+
return await operation();
|
|
383
|
+
}
|
|
384
|
+
finally {
|
|
385
|
+
release();
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
async closeIdle(now = Date.now()) {
|
|
389
|
+
const stale = [...this.services.entries()].filter(([, service]) => service.inFlight === 0 && now - service.lastUsedAt >= this.policy.idleMs);
|
|
390
|
+
for (const [key, service] of stale) {
|
|
391
|
+
this.services.delete(key);
|
|
392
|
+
await service.shutdown();
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
async ensureCapacity() {
|
|
396
|
+
if (this.services.size < this.policy.maxServices)
|
|
397
|
+
return;
|
|
398
|
+
const idle = [...this.services.entries()]
|
|
399
|
+
.filter(([, service]) => service.inFlight === 0)
|
|
400
|
+
.sort((left, right) => left[1].lastUsedAt - right[1].lastUsedAt);
|
|
401
|
+
const candidate = idle[0];
|
|
402
|
+
if (!candidate) {
|
|
403
|
+
throw new CodeIntelligenceError("code.language_service_capacity", `Language service capacity reached (${this.policy.maxServices}) with no idle service available for eviction.`);
|
|
404
|
+
}
|
|
405
|
+
this.services.delete(candidate[0]);
|
|
406
|
+
await candidate[1].shutdown();
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
function languageServiceKey(project) {
|
|
410
|
+
return JSON.stringify([
|
|
411
|
+
resolve(project.projectRoot),
|
|
412
|
+
project.definition.id,
|
|
413
|
+
project.definition.fingerprint,
|
|
414
|
+
]);
|
|
415
|
+
}
|
|
416
|
+
function languageIdForPath(definition, path) {
|
|
417
|
+
const extension = path.slice(path.lastIndexOf(".")).toLowerCase();
|
|
418
|
+
return definition.languageIdByExtension[extension] ?? definition.languages[0];
|
|
419
|
+
}
|
|
420
|
+
function textDocumentSynchronization(value) {
|
|
421
|
+
if (typeof value === "number") {
|
|
422
|
+
return {
|
|
423
|
+
openClose: value !== TextDocumentSyncKind.None,
|
|
424
|
+
change: value,
|
|
425
|
+
};
|
|
426
|
+
}
|
|
427
|
+
return {
|
|
428
|
+
openClose: value?.openClose === true,
|
|
429
|
+
change: value?.change ?? TextDocumentSyncKind.None,
|
|
430
|
+
};
|
|
431
|
+
}
|
|
432
|
+
async function workspaceSourcePath(workspaceRoot, inputPath) {
|
|
433
|
+
const root = resolve(workspaceRoot);
|
|
434
|
+
const path = resolve(root, inputPath);
|
|
435
|
+
if (!isWithin(root, path)) {
|
|
436
|
+
throw new CodeIntelligenceError("code.language_service_unavailable", `Code-intelligence source path must remain inside the Workspace: ${inputPath}`);
|
|
437
|
+
}
|
|
438
|
+
try {
|
|
439
|
+
const [canonicalRoot, canonicalPath] = await Promise.all([realpath(root), realpath(path)]);
|
|
440
|
+
if (!isWithin(canonicalRoot, canonicalPath)) {
|
|
441
|
+
throw new CodeIntelligenceError("code.language_service_unavailable", `Code-intelligence source path resolves outside the Workspace: ${inputPath}`);
|
|
442
|
+
}
|
|
443
|
+
await readFile(canonicalPath, "utf8");
|
|
444
|
+
return canonicalPath;
|
|
445
|
+
}
|
|
446
|
+
catch (error) {
|
|
447
|
+
if (error instanceof CodeIntelligenceError)
|
|
448
|
+
throw error;
|
|
449
|
+
throw new CodeIntelligenceError("code.language_service_unavailable", `Unable to read code-intelligence source ${inputPath}: ${errorMessage(error)}`);
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
async function normalizeDefinitionResponse(response, workspaceRoot, encoding) {
|
|
453
|
+
if (!response)
|
|
454
|
+
return [];
|
|
455
|
+
const entries = Array.isArray(response) ? response : [response];
|
|
456
|
+
return Promise.all(entries.map(async (entry) => {
|
|
457
|
+
const uri = isLocationLink(entry) ? entry.targetUri : entry.uri;
|
|
458
|
+
const range = isLocationLink(entry) ? entry.targetRange : entry.range;
|
|
459
|
+
if (!uri.startsWith("file:")) {
|
|
460
|
+
throw new CodeIntelligenceError("code.result_outside_policy", `Language server returned a non-file definition URI: ${uri}`);
|
|
461
|
+
}
|
|
462
|
+
const targetPath = fileURLToPath(uri);
|
|
463
|
+
const root = resolve(workspaceRoot);
|
|
464
|
+
let resolvedTarget;
|
|
465
|
+
let text;
|
|
466
|
+
try {
|
|
467
|
+
resolvedTarget = await realpath(targetPath);
|
|
468
|
+
text = await readFile(resolvedTarget, "utf8");
|
|
469
|
+
}
|
|
470
|
+
catch (error) {
|
|
471
|
+
throw new CodeIntelligenceError("code.result_outside_policy", `Unable to normalize definition location ${targetPath}: ${errorMessage(error)}`);
|
|
472
|
+
}
|
|
473
|
+
const external = !isWithin(root, resolvedTarget);
|
|
474
|
+
return {
|
|
475
|
+
path: external ? resolvedTarget : workspaceDisplayPath(root, resolvedTarget),
|
|
476
|
+
external,
|
|
477
|
+
range: rangeFromLsp(text, range, encoding),
|
|
478
|
+
};
|
|
479
|
+
}));
|
|
480
|
+
}
|
|
481
|
+
function isLocationLink(value) {
|
|
482
|
+
return "targetUri" in value;
|
|
483
|
+
}
|
|
484
|
+
function workspaceDisplayPath(workspaceRoot, path) {
|
|
485
|
+
const rel = relative(resolve(workspaceRoot), resolve(path));
|
|
486
|
+
if (!rel)
|
|
487
|
+
return ".";
|
|
488
|
+
return rel.split(sep).join("/");
|
|
489
|
+
}
|
|
490
|
+
function isWithin(root, candidate) {
|
|
491
|
+
const rel = relative(root, candidate);
|
|
492
|
+
return rel === "" || (!rel.startsWith(`..${sep}`) && rel !== ".." && !isAbsolute(rel));
|
|
493
|
+
}
|
|
494
|
+
async function withTimeout(promise, timeoutMs, timeoutError) {
|
|
495
|
+
let timer;
|
|
496
|
+
try {
|
|
497
|
+
return await Promise.race([
|
|
498
|
+
promise,
|
|
499
|
+
new Promise((_resolve, reject) => {
|
|
500
|
+
timer = setTimeout(() => reject(timeoutError()), timeoutMs);
|
|
501
|
+
timer.unref();
|
|
502
|
+
}),
|
|
503
|
+
]);
|
|
504
|
+
}
|
|
505
|
+
finally {
|
|
506
|
+
if (timer)
|
|
507
|
+
clearTimeout(timer);
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
async function waitForChildSpawn(child, serverId) {
|
|
511
|
+
await new Promise((resolvePromise, rejectPromise) => {
|
|
512
|
+
const onSpawn = () => {
|
|
513
|
+
child.off("error", onError);
|
|
514
|
+
resolvePromise();
|
|
515
|
+
};
|
|
516
|
+
const onError = (error) => {
|
|
517
|
+
child.off("spawn", onSpawn);
|
|
518
|
+
rejectPromise(new CodeIntelligenceError("code.language_service_start_failed", `Unable to start Language server ${serverId}: ${error.message}.`));
|
|
519
|
+
};
|
|
520
|
+
child.once("spawn", onSpawn);
|
|
521
|
+
child.once("error", onError);
|
|
522
|
+
});
|
|
523
|
+
}
|
|
524
|
+
async function waitForChildExit(child, timeoutMs) {
|
|
525
|
+
if (child.exitCode !== null || child.signalCode !== null)
|
|
526
|
+
return true;
|
|
527
|
+
return new Promise((resolvePromise) => {
|
|
528
|
+
let timer;
|
|
529
|
+
const finish = (exited) => {
|
|
530
|
+
child.off("exit", onExit);
|
|
531
|
+
if (timer)
|
|
532
|
+
clearTimeout(timer);
|
|
533
|
+
resolvePromise(exited);
|
|
534
|
+
};
|
|
535
|
+
const onExit = () => finish(true);
|
|
536
|
+
child.once("exit", onExit);
|
|
537
|
+
timer = setTimeout(() => finish(false), timeoutMs);
|
|
538
|
+
timer.unref();
|
|
539
|
+
});
|
|
540
|
+
}
|
|
541
|
+
function positiveInteger(value, fallback, label) {
|
|
542
|
+
const resolvedValue = value ?? fallback;
|
|
543
|
+
if (!Number.isInteger(resolvedValue) || resolvedValue < 1) {
|
|
544
|
+
throw new Error(`Code-intelligence ${label} must be a positive integer.`);
|
|
545
|
+
}
|
|
546
|
+
return resolvedValue;
|
|
547
|
+
}
|
|
548
|
+
function errorMessage(error) {
|
|
549
|
+
return error instanceof Error ? error.message : String(error);
|
|
550
|
+
}
|
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
import { access, readFile, realpath } from "node:fs/promises";
|
|
2
|
+
import { constants } from "node:fs";
|
|
3
|
+
import { createHash } from "node:crypto";
|
|
4
|
+
import { delimiter, dirname, extname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
5
|
+
import { z } from "zod";
|
|
6
|
+
export class LanguageServerConfigurationError extends Error {
|
|
7
|
+
code;
|
|
8
|
+
constructor(code, message) {
|
|
9
|
+
super(message);
|
|
10
|
+
this.code = code;
|
|
11
|
+
this.name = "LanguageServerConfigurationError";
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
const definitionSchema = z.object({
|
|
15
|
+
enabled: z.boolean().optional(),
|
|
16
|
+
command: z.string().min(1).optional(),
|
|
17
|
+
args: z.array(z.string()).optional(),
|
|
18
|
+
env: z.record(z.string(), z.string()).optional(),
|
|
19
|
+
languages: z.array(z.string().min(1)).min(1).optional(),
|
|
20
|
+
extensions: z.array(z.string().regex(/^\./)).min(1).optional(),
|
|
21
|
+
languageIdByExtension: z.record(z.string().regex(/^\./), z.string().min(1)).optional(),
|
|
22
|
+
projectMarkers: z.array(z.string().min(1)).optional(),
|
|
23
|
+
}).strict();
|
|
24
|
+
const configSchema = z.record(z.string().min(1), definitionSchema);
|
|
25
|
+
const BUILTIN_DEFINITIONS = {
|
|
26
|
+
typescript: {
|
|
27
|
+
executableCandidates: ["typescript-language-server"],
|
|
28
|
+
args: ["--stdio"],
|
|
29
|
+
languages: ["typescript", "typescriptreact", "javascript", "javascriptreact"],
|
|
30
|
+
extensions: [".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs"],
|
|
31
|
+
languageIdByExtension: {
|
|
32
|
+
".ts": "typescript",
|
|
33
|
+
".tsx": "typescriptreact",
|
|
34
|
+
".js": "javascript",
|
|
35
|
+
".jsx": "javascriptreact",
|
|
36
|
+
".mjs": "javascript",
|
|
37
|
+
".cjs": "javascript",
|
|
38
|
+
},
|
|
39
|
+
projectMarkers: ["tsconfig.json", "jsconfig.json", "package.json"],
|
|
40
|
+
},
|
|
41
|
+
pyright: {
|
|
42
|
+
executableCandidates: ["pyright-langserver"],
|
|
43
|
+
args: ["--stdio"],
|
|
44
|
+
languages: ["python"],
|
|
45
|
+
extensions: [".py", ".pyi"],
|
|
46
|
+
languageIdByExtension: { ".py": "python", ".pyi": "python" },
|
|
47
|
+
projectMarkers: ["pyrightconfig.json", "pyproject.toml", "setup.cfg", "setup.py"],
|
|
48
|
+
},
|
|
49
|
+
"rust-analyzer": {
|
|
50
|
+
executableCandidates: ["rust-analyzer"],
|
|
51
|
+
languages: ["rust"],
|
|
52
|
+
extensions: [".rs"],
|
|
53
|
+
languageIdByExtension: { ".rs": "rust" },
|
|
54
|
+
projectMarkers: ["Cargo.toml"],
|
|
55
|
+
},
|
|
56
|
+
gopls: {
|
|
57
|
+
executableCandidates: ["gopls"],
|
|
58
|
+
languages: ["go"],
|
|
59
|
+
extensions: [".go"],
|
|
60
|
+
languageIdByExtension: { ".go": "go" },
|
|
61
|
+
projectMarkers: ["go.work", "go.mod"],
|
|
62
|
+
},
|
|
63
|
+
clangd: {
|
|
64
|
+
executableCandidates: ["clangd"],
|
|
65
|
+
languages: ["c", "cpp", "objective-c", "objective-cpp"],
|
|
66
|
+
extensions: [".c", ".cc", ".cpp", ".cxx", ".h", ".hh", ".hpp", ".hxx", ".m", ".mm"],
|
|
67
|
+
languageIdByExtension: {
|
|
68
|
+
".c": "c",
|
|
69
|
+
".cc": "cpp",
|
|
70
|
+
".cpp": "cpp",
|
|
71
|
+
".cxx": "cpp",
|
|
72
|
+
".h": "cpp",
|
|
73
|
+
".hh": "cpp",
|
|
74
|
+
".hpp": "cpp",
|
|
75
|
+
".hxx": "cpp",
|
|
76
|
+
".m": "objective-c",
|
|
77
|
+
".mm": "objective-cpp",
|
|
78
|
+
},
|
|
79
|
+
projectMarkers: ["compile_commands.json", "compile_flags.txt", ".clangd"],
|
|
80
|
+
},
|
|
81
|
+
};
|
|
82
|
+
export async function resolveLanguageProject(input) {
|
|
83
|
+
const workspaceRoot = await canonicalWorkspaceRoot(input.workspaceRoot);
|
|
84
|
+
const sourcePath = await resolveWorkspaceSourcePath(workspaceRoot, input.sourcePath);
|
|
85
|
+
const projectConfig = await loadProjectLanguageServerConfig(workspaceRoot);
|
|
86
|
+
const globalConfig = parseLanguageServerConfig(input.globalConfig ?? {}, "global ForgeRelay config");
|
|
87
|
+
const definitions = await effectiveDefinitions(globalConfig, projectConfig, input.env ?? process.env);
|
|
88
|
+
const extension = extname(sourcePath).toLowerCase();
|
|
89
|
+
const candidates = [];
|
|
90
|
+
for (const definition of definitions) {
|
|
91
|
+
if (!definition.extensions.includes(extension))
|
|
92
|
+
continue;
|
|
93
|
+
const projectRoot = await findLanguageProjectRoot(workspaceRoot, dirname(sourcePath), definition.projectMarkers);
|
|
94
|
+
if (!projectRoot)
|
|
95
|
+
continue;
|
|
96
|
+
candidates.push({ definition, projectRoot });
|
|
97
|
+
}
|
|
98
|
+
if (candidates.length === 0) {
|
|
99
|
+
throw new LanguageServerConfigurationError("code.language_service_unavailable", `No available Language-server definition matches ${relative(workspaceRoot, sourcePath) || "."}.`);
|
|
100
|
+
}
|
|
101
|
+
const sourceRank = { builtin: 0, global: 1, project: 2 };
|
|
102
|
+
const highestRank = Math.max(...candidates.map((candidate) => sourceRank[candidate.definition.source]));
|
|
103
|
+
const highest = candidates.filter((candidate) => sourceRank[candidate.definition.source] === highestRank);
|
|
104
|
+
const deepestLength = Math.max(...highest.map((candidate) => candidate.projectRoot.length));
|
|
105
|
+
const nearest = highest.filter((candidate) => candidate.projectRoot.length === deepestLength);
|
|
106
|
+
if (nearest.length !== 1) {
|
|
107
|
+
throw new LanguageServerConfigurationError("code.configuration_ambiguous", `Multiple Language-server definitions match ${relative(workspaceRoot, sourcePath)} at the same priority: ${nearest.map((candidate) => candidate.definition.id).join(", ")}.`);
|
|
108
|
+
}
|
|
109
|
+
return nearest[0];
|
|
110
|
+
}
|
|
111
|
+
export async function loadProjectLanguageServerConfig(workspaceRoot) {
|
|
112
|
+
const path = join(workspaceRoot, ".forgerelay", "language-servers.json");
|
|
113
|
+
try {
|
|
114
|
+
return parseLanguageServerConfig(JSON.parse(await readFile(path, "utf8")), path);
|
|
115
|
+
}
|
|
116
|
+
catch (error) {
|
|
117
|
+
if (isMissingFile(error))
|
|
118
|
+
return {};
|
|
119
|
+
if (error instanceof LanguageServerConfigurationError)
|
|
120
|
+
throw error;
|
|
121
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
122
|
+
throw new LanguageServerConfigurationError("code.configuration_invalid", `Unable to load Language-server configuration at ${path}: ${reason}`);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
export function parseLanguageServerConfig(value, label) {
|
|
126
|
+
const parsed = configSchema.safeParse(value ?? {});
|
|
127
|
+
if (!parsed.success) {
|
|
128
|
+
const details = parsed.error.issues
|
|
129
|
+
.map((issue) => `${issue.path.join(".") || "config"}: ${issue.message}`)
|
|
130
|
+
.join("; ");
|
|
131
|
+
throw new LanguageServerConfigurationError("code.configuration_invalid", `Invalid Language-server configuration in ${label}: ${details}`);
|
|
132
|
+
}
|
|
133
|
+
return parsed.data;
|
|
134
|
+
}
|
|
135
|
+
async function effectiveDefinitions(globalConfig, projectConfig, env) {
|
|
136
|
+
const ids = new Set([
|
|
137
|
+
...Object.keys(BUILTIN_DEFINITIONS),
|
|
138
|
+
...Object.keys(globalConfig),
|
|
139
|
+
...Object.keys(projectConfig),
|
|
140
|
+
]);
|
|
141
|
+
const definitions = [];
|
|
142
|
+
for (const id of ids) {
|
|
143
|
+
const builtin = BUILTIN_DEFINITIONS[id];
|
|
144
|
+
const global = globalConfig[id];
|
|
145
|
+
const project = projectConfig[id];
|
|
146
|
+
const source = project
|
|
147
|
+
? "project"
|
|
148
|
+
: global
|
|
149
|
+
? "global"
|
|
150
|
+
: "builtin";
|
|
151
|
+
const merged = {
|
|
152
|
+
id,
|
|
153
|
+
source,
|
|
154
|
+
...(builtin ?? {}),
|
|
155
|
+
...(global ?? {}),
|
|
156
|
+
...(project ?? {}),
|
|
157
|
+
env: {
|
|
158
|
+
...(builtin?.env ?? {}),
|
|
159
|
+
...(global?.env ?? {}),
|
|
160
|
+
...(project?.env ?? {}),
|
|
161
|
+
},
|
|
162
|
+
languageIdByExtension: {
|
|
163
|
+
...(builtin?.languageIdByExtension ?? {}),
|
|
164
|
+
...(global?.languageIdByExtension ?? {}),
|
|
165
|
+
...(project?.languageIdByExtension ?? {}),
|
|
166
|
+
},
|
|
167
|
+
};
|
|
168
|
+
if (merged.enabled === false)
|
|
169
|
+
continue;
|
|
170
|
+
const languages = merged.languages ?? [];
|
|
171
|
+
const extensions = merged.extensions?.map((entry) => entry.toLowerCase()) ?? [];
|
|
172
|
+
if (languages.length === 0 || extensions.length === 0)
|
|
173
|
+
continue;
|
|
174
|
+
const languageIdByExtension = normalizeLanguageIds(id, merged, languages, extensions);
|
|
175
|
+
let command = merged.command;
|
|
176
|
+
if (!command && builtin?.executableCandidates) {
|
|
177
|
+
command = await findExecutable(builtin.executableCandidates, env);
|
|
178
|
+
if (!command)
|
|
179
|
+
continue;
|
|
180
|
+
}
|
|
181
|
+
if (!command) {
|
|
182
|
+
throw new LanguageServerConfigurationError("code.configuration_invalid", `Language-server definition ${id} requires a command.`);
|
|
183
|
+
}
|
|
184
|
+
const normalized = {
|
|
185
|
+
id,
|
|
186
|
+
command,
|
|
187
|
+
args: merged.args ?? [],
|
|
188
|
+
env: merged.env ?? {},
|
|
189
|
+
languages,
|
|
190
|
+
extensions,
|
|
191
|
+
languageIdByExtension,
|
|
192
|
+
projectMarkers: merged.projectMarkers ?? [],
|
|
193
|
+
source,
|
|
194
|
+
};
|
|
195
|
+
definitions.push({
|
|
196
|
+
...normalized,
|
|
197
|
+
fingerprint: createHash("sha256").update(JSON.stringify(normalized)).digest("hex"),
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
return definitions;
|
|
201
|
+
}
|
|
202
|
+
function normalizeLanguageIds(id, definition, languages, extensions) {
|
|
203
|
+
const mapping = Object.fromEntries(Object.entries(definition.languageIdByExtension ?? {})
|
|
204
|
+
.map(([extension, languageId]) => [extension.toLowerCase(), languageId]));
|
|
205
|
+
for (let index = 0; index < extensions.length; index += 1) {
|
|
206
|
+
const extension = extensions[index];
|
|
207
|
+
if (mapping[extension])
|
|
208
|
+
continue;
|
|
209
|
+
if (languages.length === 1) {
|
|
210
|
+
mapping[extension] = languages[0];
|
|
211
|
+
continue;
|
|
212
|
+
}
|
|
213
|
+
if (languages.length === extensions.length) {
|
|
214
|
+
mapping[extension] = languages[index];
|
|
215
|
+
continue;
|
|
216
|
+
}
|
|
217
|
+
throw new LanguageServerConfigurationError("code.configuration_invalid", `Language-server definition ${id} must map extension ${extension} to a languageId when multiple language IDs do not align one-to-one with extensions.`);
|
|
218
|
+
}
|
|
219
|
+
for (const [extension, languageId] of Object.entries(mapping)) {
|
|
220
|
+
if (!extensions.includes(extension))
|
|
221
|
+
continue;
|
|
222
|
+
if (!languages.includes(languageId)) {
|
|
223
|
+
throw new LanguageServerConfigurationError("code.configuration_invalid", `Language-server definition ${id} maps ${extension} to unknown languageId ${languageId}.`);
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
return mapping;
|
|
227
|
+
}
|
|
228
|
+
async function findLanguageProjectRoot(workspaceRoot, startDirectory, markers) {
|
|
229
|
+
if (markers.length === 0)
|
|
230
|
+
return workspaceRoot;
|
|
231
|
+
let current = startDirectory;
|
|
232
|
+
while (isWithin(workspaceRoot, current)) {
|
|
233
|
+
for (const marker of markers) {
|
|
234
|
+
try {
|
|
235
|
+
await access(join(current, marker));
|
|
236
|
+
return current;
|
|
237
|
+
}
|
|
238
|
+
catch {
|
|
239
|
+
// Try the next marker or parent directory.
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
if (current === workspaceRoot)
|
|
243
|
+
break;
|
|
244
|
+
current = dirname(current);
|
|
245
|
+
}
|
|
246
|
+
return undefined;
|
|
247
|
+
}
|
|
248
|
+
async function canonicalWorkspaceRoot(inputPath) {
|
|
249
|
+
try {
|
|
250
|
+
return await realpath(resolve(inputPath));
|
|
251
|
+
}
|
|
252
|
+
catch {
|
|
253
|
+
throw new LanguageServerConfigurationError("code.language_service_unavailable", `Code-intelligence Workspace root does not exist: ${inputPath}`);
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
async function resolveWorkspaceSourcePath(workspaceRoot, inputPath) {
|
|
257
|
+
const candidate = resolve(workspaceRoot, inputPath);
|
|
258
|
+
if (!isWithin(workspaceRoot, candidate)) {
|
|
259
|
+
throw new LanguageServerConfigurationError("code.language_service_unavailable", `Code-intelligence source path must remain inside the Workspace: ${inputPath}`);
|
|
260
|
+
}
|
|
261
|
+
try {
|
|
262
|
+
const [canonicalRoot, canonicalCandidate] = await Promise.all([
|
|
263
|
+
realpath(workspaceRoot),
|
|
264
|
+
realpath(candidate),
|
|
265
|
+
]);
|
|
266
|
+
if (!isWithin(canonicalRoot, canonicalCandidate)) {
|
|
267
|
+
throw new LanguageServerConfigurationError("code.language_service_unavailable", `Code-intelligence source path resolves outside the Workspace: ${inputPath}`);
|
|
268
|
+
}
|
|
269
|
+
return canonicalCandidate;
|
|
270
|
+
}
|
|
271
|
+
catch (error) {
|
|
272
|
+
if (error instanceof LanguageServerConfigurationError)
|
|
273
|
+
throw error;
|
|
274
|
+
throw new LanguageServerConfigurationError("code.language_service_unavailable", `Code-intelligence source path does not exist: ${inputPath}`);
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
async function findExecutable(candidates, env) {
|
|
278
|
+
for (const candidate of candidates) {
|
|
279
|
+
if (isAbsolute(candidate)) {
|
|
280
|
+
if (await executable(candidate))
|
|
281
|
+
return candidate;
|
|
282
|
+
continue;
|
|
283
|
+
}
|
|
284
|
+
const pathEntries = (env.PATH ?? "").split(delimiter).filter(Boolean);
|
|
285
|
+
const extensions = process.platform === "win32"
|
|
286
|
+
? (env.PATHEXT ?? ".EXE;.CMD;.BAT;.COM").split(";")
|
|
287
|
+
: [""];
|
|
288
|
+
for (const directory of pathEntries) {
|
|
289
|
+
for (const extension of extensions) {
|
|
290
|
+
const path = join(directory, process.platform === "win32" ? `${candidate}${extension}` : candidate);
|
|
291
|
+
if (await executable(path))
|
|
292
|
+
return path;
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
return undefined;
|
|
297
|
+
}
|
|
298
|
+
async function executable(path) {
|
|
299
|
+
try {
|
|
300
|
+
await access(path, process.platform === "win32" ? constants.F_OK : constants.X_OK);
|
|
301
|
+
return true;
|
|
302
|
+
}
|
|
303
|
+
catch {
|
|
304
|
+
return false;
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
function isWithin(root, candidate) {
|
|
308
|
+
const rel = relative(root, candidate);
|
|
309
|
+
return rel === "" || (!rel.startsWith(`..${sep}`) && rel !== ".." && !isAbsolute(rel));
|
|
310
|
+
}
|
|
311
|
+
function isMissingFile(error) {
|
|
312
|
+
return error instanceof Error && "code" in error && error.code === "ENOENT";
|
|
313
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { PositionEncodingKind } from "vscode-languageserver-protocol";
|
|
2
|
+
import { CodeIntelligenceError } from "./code-intelligence-error.js";
|
|
3
|
+
export function lspPositionFromUser(text, line, column, encoding) {
|
|
4
|
+
if (!Number.isInteger(line) || line < 1 || !Number.isInteger(column) || column < 1) {
|
|
5
|
+
throw new CodeIntelligenceError("code.invalid_position", `Code-intelligence positions are 1-based positive integers; received line=${line}, column=${column}.`);
|
|
6
|
+
}
|
|
7
|
+
const lines = text.split(/\r?\n/);
|
|
8
|
+
const sourceLine = lines[line - 1];
|
|
9
|
+
if (sourceLine === undefined) {
|
|
10
|
+
throw new CodeIntelligenceError("code.invalid_position", `Line ${line} is outside the document (${lines.length} lines).`);
|
|
11
|
+
}
|
|
12
|
+
const codePoints = Array.from(sourceLine);
|
|
13
|
+
const codePointIndex = column - 1;
|
|
14
|
+
if (codePointIndex > codePoints.length) {
|
|
15
|
+
throw new CodeIntelligenceError("code.invalid_position", `Column ${column} is outside line ${line} (${codePoints.length + 1} valid insertion positions).`);
|
|
16
|
+
}
|
|
17
|
+
const prefix = codePoints.slice(0, codePointIndex).join("");
|
|
18
|
+
return {
|
|
19
|
+
line: line - 1,
|
|
20
|
+
character: encodedLength(prefix, encoding),
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
export function wholeDocumentRange(text, encoding) {
|
|
24
|
+
const lines = text.split(/\r?\n/);
|
|
25
|
+
const lastLine = Math.max(0, lines.length - 1);
|
|
26
|
+
return {
|
|
27
|
+
start: { line: 0, character: 0 },
|
|
28
|
+
end: {
|
|
29
|
+
line: lastLine,
|
|
30
|
+
character: encodedLength(lines[lastLine] ?? "", encoding),
|
|
31
|
+
},
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
export function rangeFromLsp(text, range, encoding) {
|
|
35
|
+
return {
|
|
36
|
+
start: positionFromLsp(text, range.start, encoding),
|
|
37
|
+
end: positionFromLsp(text, range.end, encoding),
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
function positionFromLsp(text, position, encoding) {
|
|
41
|
+
const lines = text.split(/\r?\n/);
|
|
42
|
+
const sourceLine = lines[position.line];
|
|
43
|
+
if (sourceLine === undefined) {
|
|
44
|
+
throw new CodeIntelligenceError("code.invalid_position", `Language server returned line ${position.line} outside a ${lines.length}-line document.`);
|
|
45
|
+
}
|
|
46
|
+
return {
|
|
47
|
+
line: position.line + 1,
|
|
48
|
+
column: decodedCodePointOffset(sourceLine, position.character, encoding) + 1,
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
function encodedLength(text, encoding) {
|
|
52
|
+
if (encoding === PositionEncodingKind.UTF8)
|
|
53
|
+
return Buffer.byteLength(text, "utf8");
|
|
54
|
+
if (encoding === PositionEncodingKind.UTF32)
|
|
55
|
+
return Array.from(text).length;
|
|
56
|
+
return text.length;
|
|
57
|
+
}
|
|
58
|
+
function decodedCodePointOffset(text, encodedOffset, encoding) {
|
|
59
|
+
if (!Number.isInteger(encodedOffset) || encodedOffset < 0) {
|
|
60
|
+
throw new CodeIntelligenceError("code.invalid_position", `Language server returned invalid character offset ${encodedOffset}.`);
|
|
61
|
+
}
|
|
62
|
+
if (encoding === PositionEncodingKind.UTF32) {
|
|
63
|
+
if (encodedOffset > Array.from(text).length) {
|
|
64
|
+
throw new CodeIntelligenceError("code.invalid_position", `Language server returned character offset ${encodedOffset} outside its line.`);
|
|
65
|
+
}
|
|
66
|
+
return encodedOffset;
|
|
67
|
+
}
|
|
68
|
+
if (encoding === PositionEncodingKind.UTF16) {
|
|
69
|
+
if (encodedOffset > text.length) {
|
|
70
|
+
throw new CodeIntelligenceError("code.invalid_position", `Language server returned character offset ${encodedOffset} outside its line.`);
|
|
71
|
+
}
|
|
72
|
+
return Array.from(text.slice(0, encodedOffset)).length;
|
|
73
|
+
}
|
|
74
|
+
let bytes = 0;
|
|
75
|
+
let codePoints = 0;
|
|
76
|
+
for (const character of text) {
|
|
77
|
+
if (bytes === encodedOffset)
|
|
78
|
+
return codePoints;
|
|
79
|
+
bytes += Buffer.byteLength(character, "utf8");
|
|
80
|
+
codePoints += 1;
|
|
81
|
+
if (bytes > encodedOffset) {
|
|
82
|
+
throw new CodeIntelligenceError("code.invalid_position", `Language server returned UTF-8 offset ${encodedOffset} inside a code point.`);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
if (bytes === encodedOffset)
|
|
86
|
+
return codePoints;
|
|
87
|
+
throw new CodeIntelligenceError("code.invalid_position", `Language server returned character offset ${encodedOffset} outside its line.`);
|
|
88
|
+
}
|
package/dist/server.js
CHANGED
|
@@ -21,6 +21,7 @@ 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
25
|
import { attachHookReports, HookRunner, runToolWithHooks } from "./hooks.js";
|
|
25
26
|
import { checkHookConfiguration } from "./hook-cli.js";
|
|
26
27
|
import { buildServerInstructions, buildShellMutationPolicy, buildToolDescriptions, toolNames, } from "./mcp/server-instructions.js";
|
|
@@ -743,7 +744,7 @@ function registerProcessTools(server, config, workspaces, processSessions, hooks
|
|
|
743
744
|
});
|
|
744
745
|
});
|
|
745
746
|
}
|
|
746
|
-
export function createMcpServer(config, workspaces, reviewCheckpoints, processSessions, localAgentProviders, incomingArtifactAdapters) {
|
|
747
|
+
export function createMcpServer(config, workspaces, reviewCheckpoints, processSessions, localAgentProviders, incomingArtifactAdapters, codeIntelligence) {
|
|
747
748
|
const toolDescriptions = buildToolDescriptions(config);
|
|
748
749
|
const hooks = new HookRunner(config.hooks, config.logging, process.env, (workspaceId, result) => attachCompletedProcessNotices(processSessions, workspaceId, result));
|
|
749
750
|
const incomingArtifactRegistry = new IncomingArtifactAdapterRegistry(incomingArtifactAdapters);
|
|
@@ -751,6 +752,22 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
|
|
|
751
752
|
const reviewChangesAvailable = config.widgets === "changes";
|
|
752
753
|
const capabilityRegistry = createCapabilityRegistry({
|
|
753
754
|
inspectHooks: (workspaceRoot) => checkHookConfiguration(workspaceRoot, config.hooks),
|
|
755
|
+
codeIntelligence: {
|
|
756
|
+
available: true,
|
|
757
|
+
run: async (input, context) => {
|
|
758
|
+
try {
|
|
759
|
+
return {
|
|
760
|
+
value: await codeIntelligence.definition(context.workspaceRoot, input),
|
|
761
|
+
};
|
|
762
|
+
}
|
|
763
|
+
catch (error) {
|
|
764
|
+
if (error instanceof CodeIntelligenceError) {
|
|
765
|
+
throw new CapabilityError(error.code, error.message);
|
|
766
|
+
}
|
|
767
|
+
throw error;
|
|
768
|
+
}
|
|
769
|
+
},
|
|
770
|
+
},
|
|
754
771
|
reviewChanges: {
|
|
755
772
|
available: reviewChangesAvailable,
|
|
756
773
|
unavailableReason: reviewChangesAvailable
|
|
@@ -2108,6 +2125,7 @@ export function createServer(config = loadConfig(), options = {}) {
|
|
|
2108
2125
|
const workspaces = new WorkspaceRegistry(config, workspaceStore);
|
|
2109
2126
|
const reviewCheckpoints = createReviewCheckpointManager();
|
|
2110
2127
|
const processSessions = new ProcessManager();
|
|
2128
|
+
const codeIntelligence = new CodeIntelligenceManager(config);
|
|
2111
2129
|
const localAgentProviders = config.subagents
|
|
2112
2130
|
? getLocalAgentProviderAvailabilitySnapshot()
|
|
2113
2131
|
: [];
|
|
@@ -2154,6 +2172,7 @@ export function createServer(config = loadConfig(), options = {}) {
|
|
|
2154
2172
|
processesCompleted: processStats.completed,
|
|
2155
2173
|
cachedWorkspaces: workspaces.cachedWorkspaceCount,
|
|
2156
2174
|
reviewStates: reviewCheckpoints.stateCount,
|
|
2175
|
+
languageServices: codeIntelligence.size,
|
|
2157
2176
|
});
|
|
2158
2177
|
};
|
|
2159
2178
|
const transportCleanupTimer = setInterval(() => {
|
|
@@ -2276,7 +2295,7 @@ export function createServer(config = loadConfig(), options = {}) {
|
|
|
2276
2295
|
});
|
|
2277
2296
|
}
|
|
2278
2297
|
};
|
|
2279
|
-
const server = createMcpServer(config, workspaces, reviewCheckpoints, processSessions, localAgentProviders, incomingArtifactAdapters);
|
|
2298
|
+
const server = createMcpServer(config, workspaces, reviewCheckpoints, processSessions, localAgentProviders, incomingArtifactAdapters, codeIntelligence);
|
|
2280
2299
|
await server.connect(transport);
|
|
2281
2300
|
}
|
|
2282
2301
|
else {
|
|
@@ -2306,6 +2325,7 @@ export function createServer(config = loadConfig(), options = {}) {
|
|
|
2306
2325
|
const results = await transports.closeAll();
|
|
2307
2326
|
logTransportCloseResults("server_shutdown", results);
|
|
2308
2327
|
processSessions.shutdown();
|
|
2328
|
+
await codeIntelligence.shutdown();
|
|
2309
2329
|
oauthProvider.close();
|
|
2310
2330
|
workspaceStore.close?.();
|
|
2311
2331
|
})();
|
package/docs/configuration.md
CHANGED
|
@@ -151,6 +151,68 @@ snapshot, treat that as stale Host MCP metadata: reconnect/refresh the integrati
|
|
|
151
151
|
or use a Host context that reloads `tools/list`. The ForgeRelay process cannot
|
|
152
152
|
force a Host to invalidate its cached schema.
|
|
153
153
|
|
|
154
|
+
### LSP code intelligence
|
|
155
|
+
|
|
156
|
+
ForgeRelay advertises `code.intelligence` through the Capability Gateway; it does
|
|
157
|
+
not add language-specific top-level MCP tools. ForgeRelay 0.4.0 supports the
|
|
158
|
+
`definition` operation. Language Servers are external dependencies: ForgeRelay may
|
|
159
|
+
discover an executable already installed on the machine, but it never downloads or
|
|
160
|
+
installs one automatically.
|
|
161
|
+
|
|
162
|
+
Effective Language-server definitions resolve in this order:
|
|
163
|
+
|
|
164
|
+
1. project configuration in `.forgerelay/language-servers.json`;
|
|
165
|
+
2. global definitions from the `languageServers` object in
|
|
166
|
+
`~/.forgerelay/config.json`;
|
|
167
|
+
3. built-in discovery for known executables.
|
|
168
|
+
|
|
169
|
+
A project configuration file is an object keyed by definition name. Definitions
|
|
170
|
+
use structured process launch and never go through a shell:
|
|
171
|
+
|
|
172
|
+
```json
|
|
173
|
+
{
|
|
174
|
+
"typescript": {
|
|
175
|
+
"command": "typescript-language-server",
|
|
176
|
+
"args": ["--stdio"],
|
|
177
|
+
"env": {},
|
|
178
|
+
"languages": ["typescript", "typescriptreact", "javascript", "javascriptreact"],
|
|
179
|
+
"extensions": [".ts", ".tsx", ".js", ".jsx"],
|
|
180
|
+
"languageIdByExtension": {
|
|
181
|
+
".ts": "typescript",
|
|
182
|
+
".tsx": "typescriptreact",
|
|
183
|
+
".js": "javascript",
|
|
184
|
+
".jsx": "javascriptreact"
|
|
185
|
+
},
|
|
186
|
+
"projectMarkers": ["tsconfig.json", "jsconfig.json"]
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
Global configuration uses the same definition shape under `languageServers`:
|
|
192
|
+
|
|
193
|
+
```json
|
|
194
|
+
{
|
|
195
|
+
"languageServers": {
|
|
196
|
+
"typescript": {
|
|
197
|
+
"command": "/absolute/path/to/typescript-language-server",
|
|
198
|
+
"args": ["--stdio"]
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
Explicit configuration can set `"enabled": false` to suppress the matching
|
|
205
|
+
built-in definition. Project values override global values, and both override
|
|
206
|
+
built-in defaults. ForgeRelay resolves a Language project by walking ancestors of
|
|
207
|
+
the requested source file according to that definition's `projectMarkers`; it does
|
|
208
|
+
not recursively scan the Workspace.
|
|
209
|
+
|
|
210
|
+
Code-intelligence input positions are 1-based line and 1-based Unicode code-point
|
|
211
|
+
column values. The Workspace filesystem is the only v1 document source of truth.
|
|
212
|
+
Definition results may point outside the Workspace and are then marked
|
|
213
|
+
`external: true`; this is informational only and does not expand allowed roots or
|
|
214
|
+
file-tool authority.
|
|
215
|
+
|
|
154
216
|
`rename` is the canonical move/rename primitive for files and directories; there
|
|
155
217
|
is no separate `move` MCP tool.
|
|
156
218
|
|
package/docs/roadmap.md
CHANGED
|
@@ -225,6 +225,21 @@ Candidate servers include `typescript-language-server`/tsserver, Pyright,
|
|
|
225
225
|
`rust-analyzer`, `gopls`, and `clangd`, but ForgeRelay should treat server
|
|
226
226
|
commands/configuration as external dependencies.
|
|
227
227
|
|
|
228
|
+
0.4 is delivered as independently published patch releases rather than one large
|
|
229
|
+
0.4.0 batch. Every boundary must complete local acceptance, update release metadata,
|
|
230
|
+
create and push its matching tag, pass cloud CI, publish npm and the GitHub Release,
|
|
231
|
+
and verify publication before work begins on the next boundary:
|
|
232
|
+
|
|
233
|
+
- **0.4.0** — Language-service foundation plus the complete `definition` tracer bullet;
|
|
234
|
+
- **0.4.1** — hover/type information;
|
|
235
|
+
- **0.4.2** — references and bounded semantic-location results;
|
|
236
|
+
- **0.4.3** — hierarchical document symbols and bounded workspace symbols;
|
|
237
|
+
- **0.4.4** — push/pull diagnostics and Diagnostic snapshots;
|
|
238
|
+
- **0.4.5** — cancellation, deadlines, crash recovery/config invalidation, concurrency,
|
|
239
|
+
and full Language-service resource/lifecycle hardening;
|
|
240
|
+
- **0.4.6** — optional real-server interoperability, cross-platform checks, fresh Host
|
|
241
|
+
acceptance, documentation, and final LSP v1 closure.
|
|
242
|
+
|
|
228
243
|
## 0.5 — First-class subagent MCP
|
|
229
244
|
|
|
230
245
|
ForgeRelay already owns provider adapters and resumable local agent sessions.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@akira-tl/forgerelay",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
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/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/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/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",
|
|
@@ -79,6 +79,8 @@
|
|
|
79
79
|
"react": "^19.2.6",
|
|
80
80
|
"react-dom": "^19.2.6",
|
|
81
81
|
"semver": "^7.8.4",
|
|
82
|
+
"vscode-jsonrpc": "^9.0.1",
|
|
83
|
+
"vscode-languageserver-protocol": "^3.18.2",
|
|
82
84
|
"yaml": "^2.9.0",
|
|
83
85
|
"zod": "^4.4.3"
|
|
84
86
|
},
|
package/scripts/debug/accept.mjs
CHANGED
|
@@ -244,6 +244,7 @@ try {
|
|
|
244
244
|
"process.lifecycle",
|
|
245
245
|
"hooks.lifecycle",
|
|
246
246
|
"capability-guides.read",
|
|
247
|
+
"code.intelligence",
|
|
247
248
|
...(process.platform === "linux" ? ["artifact.native-download"] : []),
|
|
248
249
|
"ui.mcp-app",
|
|
249
250
|
"review.changes",
|
|
@@ -253,6 +254,7 @@ try {
|
|
|
253
254
|
assert.deepEqual(capabilityCatalog.map((entry) => entry.name), [
|
|
254
255
|
"hooks.check",
|
|
255
256
|
"review.changes",
|
|
257
|
+
"code.intelligence",
|
|
256
258
|
...(process.platform === "linux" ? ["artifact.download"] : []),
|
|
257
259
|
]);
|
|
258
260
|
assert.equal(capabilityCatalog[0].available, true);
|
|
@@ -323,6 +325,18 @@ try {
|
|
|
323
325
|
assert.equal(describedCapability.isError, undefined);
|
|
324
326
|
assert.equal(describedCapability.structuredContent.capability.guide.name, "lifecycle-hooks");
|
|
325
327
|
assert.equal(describedCapability.structuredContent.capability.inputSchema.type, "object");
|
|
328
|
+
const describedCodeIntelligence = callTool(oauth.accessToken, sessionId, 88, "capability", {
|
|
329
|
+
workspaceId,
|
|
330
|
+
name: "code.intelligence",
|
|
331
|
+
action: "describe",
|
|
332
|
+
});
|
|
333
|
+
assert.equal(describedCodeIntelligence.isError, undefined);
|
|
334
|
+
assert.equal(describedCodeIntelligence.structuredContent.capability.guide.name, "code-intelligence");
|
|
335
|
+
assert.equal(describedCodeIntelligence.structuredContent.capability.inputSchema.type, "object");
|
|
336
|
+
assert.equal(
|
|
337
|
+
describedCodeIntelligence.structuredContent.capability.inputSchema.properties.operation.const,
|
|
338
|
+
"definition",
|
|
339
|
+
);
|
|
326
340
|
if (process.platform === "linux") {
|
|
327
341
|
const describedArtifact = callTool(oauth.accessToken, sessionId, 82, "capability", {
|
|
328
342
|
workspaceId,
|
|
@@ -350,6 +364,7 @@ try {
|
|
|
350
364
|
"artifacts-review",
|
|
351
365
|
"host-integration",
|
|
352
366
|
"shell-processes",
|
|
367
|
+
"code-intelligence",
|
|
353
368
|
]);
|
|
354
369
|
const hooksGuide = callTool(oauth.accessToken, sessionId, 78, "read", {
|
|
355
370
|
workspaceId,
|