@akira-tl/forgerelay 0.4.4 → 0.4.6
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 +26 -0
- package/README.md +29 -10
- package/capabilities/code-intelligence/GUIDE.md +9 -1
- package/dist/capability-registry.js +2 -2
- package/dist/logger.js +11 -1
- package/dist/lsp/code-intelligence.js +69 -97
- package/dist/lsp/runtime/diagnostic-snapshots.js +8 -0
- package/dist/lsp/runtime/document-synchronizer.js +102 -0
- package/dist/lsp/runtime/manager.js +203 -22
- package/dist/lsp/runtime/semantic-requests.js +116 -0
- package/dist/lsp/test-support/server-fixture.js +7 -2
- package/dist/server.js +27 -6
- package/docs/configuration.md +28 -2
- package/docs/roadmap.md +7 -0
- package/docs/versioning.md +15 -2
- package/examples/language-servers.json +30 -0
- package/package.json +5 -3
- package/scripts/debug/accept.mjs +27 -3
- package/scripts/debug/code-intelligence-accept.mjs +169 -0
- package/scripts/lsp-interop-support.mjs +49 -0
- package/scripts/lsp-interop-support.test.mjs +40 -0
- package/scripts/lsp-interop.mjs +180 -0
- package/scripts/release-parity.mjs +85 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@akira-tl/forgerelay",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.6",
|
|
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",
|
|
@@ -40,9 +40,11 @@
|
|
|
40
40
|
"dev": "node scripts/debug/serve.mjs",
|
|
41
41
|
"debug:serve": "node scripts/debug/serve.mjs",
|
|
42
42
|
"debug:accept": "node scripts/debug/accept.mjs",
|
|
43
|
+
"lsp:interop": "node scripts/lsp-interop.mjs",
|
|
44
|
+
"release:parity": "node scripts/release-parity.mjs",
|
|
43
45
|
"postinstall": "node scripts/fix-node-pty-permissions.mjs",
|
|
44
46
|
"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/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",
|
|
47
|
+
"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/lsp/operations/request-hardening.server.test.ts && tsx src/lsp/operations/recovery.server.test.ts && tsx src/lsp/operations/lifecycle.server.test.ts && tsx src/lsp/runtime/semantic-requests.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
48
|
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
47
49
|
"release:check": "node scripts/release-version.mjs check",
|
|
48
50
|
"release:tag-check": "node scripts/release-version.mjs tag",
|
|
@@ -50,7 +52,7 @@
|
|
|
50
52
|
"release:patch": "node scripts/release-version.mjs next patch",
|
|
51
53
|
"release:minor": "node scripts/release-version.mjs next minor",
|
|
52
54
|
"release:major": "node scripts/release-version.mjs next major",
|
|
53
|
-
"release:verify": "npm run release:check && npm run typecheck && npm test && npm run build"
|
|
55
|
+
"release:verify": "npm run release:check && npm run typecheck && npm test && npm run build && npm run lsp:interop && npm run release:parity"
|
|
54
56
|
},
|
|
55
57
|
"keywords": [
|
|
56
58
|
"mcp",
|
package/scripts/debug/accept.mjs
CHANGED
|
@@ -20,6 +20,11 @@ import {
|
|
|
20
20
|
debugRoot,
|
|
21
21
|
repoRoot,
|
|
22
22
|
} from "./runtime.mjs";
|
|
23
|
+
import {
|
|
24
|
+
assertCodeIntelligenceShutdown as assertCodeIntelligenceAcceptanceShutdown,
|
|
25
|
+
exerciseCodeIntelligence as exerciseCodeIntelligenceAcceptance,
|
|
26
|
+
setupCodeIntelligenceProject as setupCodeIntelligenceAcceptanceProject,
|
|
27
|
+
} from "./code-intelligence-accept.mjs";
|
|
23
28
|
|
|
24
29
|
const packageJson = JSON.parse(readFileSync(join(repoRoot, "package.json"), "utf8"));
|
|
25
30
|
const acceptanceRoot = resolve(debugRoot, "acceptance");
|
|
@@ -27,6 +32,8 @@ const stateDir = resolve(acceptanceRoot, "state");
|
|
|
27
32
|
const worktreeRoot = resolve(acceptanceRoot, "worktrees");
|
|
28
33
|
const hookLog = resolve(acceptanceRoot, "hooks.jsonl");
|
|
29
34
|
const checkoutWorkspace = resolve(acceptanceRoot, "workspace");
|
|
35
|
+
const codeIntelligenceLog = resolve(acceptanceRoot, "code-intelligence-lsp.jsonl");
|
|
36
|
+
const fakeLanguageServer = resolve(repoRoot, "src", "lsp", "test-fixtures", "fake-lsp-server.mjs");
|
|
30
37
|
const gitProject = resolve(acceptanceRoot, "git-project");
|
|
31
38
|
const releaseProject = resolve(acceptanceRoot, "release-project");
|
|
32
39
|
const releaseRemote = resolve(acceptanceRoot, "release-remote.git");
|
|
@@ -38,6 +45,11 @@ await assertDebugPortFree();
|
|
|
38
45
|
rmSync(acceptanceRoot, { recursive: true, force: true });
|
|
39
46
|
mkdirSync(acceptanceRoot, { recursive: true });
|
|
40
47
|
setupGitProject(checkoutWorkspace);
|
|
48
|
+
setupCodeIntelligenceAcceptanceProject({
|
|
49
|
+
root: checkoutWorkspace,
|
|
50
|
+
fakeLanguageServer,
|
|
51
|
+
logPath: codeIntelligenceLog,
|
|
52
|
+
});
|
|
41
53
|
|
|
42
54
|
const { env } = createDebugEnvironment({
|
|
43
55
|
ownerToken,
|
|
@@ -64,6 +76,7 @@ const server = spawn(process.execPath, ["--import", "tsx", "src/cli.ts", "serve"
|
|
|
64
76
|
env,
|
|
65
77
|
stdio: ["ignore", "inherit", "inherit"],
|
|
66
78
|
});
|
|
79
|
+
let acceptanceCompleted = false;
|
|
67
80
|
|
|
68
81
|
try {
|
|
69
82
|
const health = await waitForHealth(server);
|
|
@@ -349,6 +362,13 @@ try {
|
|
|
349
362
|
(variant) => variant.properties.operation.const === "workspaceSymbols",
|
|
350
363
|
);
|
|
351
364
|
assert.ok(workspaceSymbolsSchema.required.includes("query"));
|
|
365
|
+
exerciseCodeIntelligenceAcceptance({
|
|
366
|
+
callTool,
|
|
367
|
+
accessToken: oauth.accessToken,
|
|
368
|
+
sessionId,
|
|
369
|
+
workspaceId,
|
|
370
|
+
pass,
|
|
371
|
+
});
|
|
352
372
|
if (process.platform === "linux") {
|
|
353
373
|
const describedArtifact = callTool(oauth.accessToken, sessionId, 82, "capability", {
|
|
354
374
|
workspaceId,
|
|
@@ -569,9 +589,7 @@ try {
|
|
|
569
589
|
headers: mcpHeaders(oauth.accessToken, sessionId),
|
|
570
590
|
});
|
|
571
591
|
assert.ok([200, 202, 204].includes(deleteSession.status));
|
|
572
|
-
|
|
573
|
-
console.log("\nForgeRelay 7677 acceptance passed.");
|
|
574
|
-
console.log(`Artifacts: ${acceptanceRoot}`);
|
|
592
|
+
acceptanceCompleted = true;
|
|
575
593
|
} catch (error) {
|
|
576
594
|
console.error("\nForgeRelay 7677 acceptance failed.");
|
|
577
595
|
throw error;
|
|
@@ -580,6 +598,12 @@ try {
|
|
|
580
598
|
await stopServer(server);
|
|
581
599
|
}
|
|
582
600
|
|
|
601
|
+
if (acceptanceCompleted) {
|
|
602
|
+
assertCodeIntelligenceAcceptanceShutdown({ logPath: codeIntelligenceLog, pass });
|
|
603
|
+
console.log("\nForgeRelay 7677 acceptance passed.");
|
|
604
|
+
console.log(`Artifacts: ${acceptanceRoot}`);
|
|
605
|
+
}
|
|
606
|
+
|
|
583
607
|
function authorizeDebugClient(metadata) {
|
|
584
608
|
const redirectUri = `${debugBaseUrl}/debug/callback`;
|
|
585
609
|
const registration = jsonRequest(metadata.registration_endpoint, {
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
|
|
5
|
+
export function setupCodeIntelligenceProject({ root, fakeLanguageServer, logPath }) {
|
|
6
|
+
mkdirSync(join(root, ".forgerelay"), { recursive: true });
|
|
7
|
+
mkdirSync(join(root, "src"), { recursive: true });
|
|
8
|
+
writeFileSync(join(root, "tsconfig.json"), "{}\n", "utf8");
|
|
9
|
+
writeFileSync(
|
|
10
|
+
join(root, "src", "main.ts"),
|
|
11
|
+
[
|
|
12
|
+
"const value = target();",
|
|
13
|
+
"class Widget {",
|
|
14
|
+
" runMethod() {}",
|
|
15
|
+
" stopMethod() {}",
|
|
16
|
+
"}",
|
|
17
|
+
"",
|
|
18
|
+
].join("\n"),
|
|
19
|
+
"utf8",
|
|
20
|
+
);
|
|
21
|
+
writeFileSync(join(root, "src", "target.ts"), "export const target = () => 1;\n", "utf8");
|
|
22
|
+
writeFileSync(
|
|
23
|
+
join(root, ".forgerelay", "language-servers.json"),
|
|
24
|
+
`${JSON.stringify({
|
|
25
|
+
"debug-fake": {
|
|
26
|
+
command: process.execPath,
|
|
27
|
+
args: [fakeLanguageServer],
|
|
28
|
+
env: {
|
|
29
|
+
FORGERELAY_FAKE_LSP_LOG: logPath,
|
|
30
|
+
FORGERELAY_FAKE_LSP_HOVER_TEXT: "**debug target**: `() => void`",
|
|
31
|
+
FORGERELAY_FAKE_LSP_REFERENCE_COUNT: "2",
|
|
32
|
+
FORGERELAY_FAKE_LSP_DIAGNOSTICS_MODE: "pull",
|
|
33
|
+
FORGERELAY_FAKE_LSP_DIAGNOSTIC_COUNT: "2",
|
|
34
|
+
},
|
|
35
|
+
languages: ["typescript"],
|
|
36
|
+
extensions: [".ts"],
|
|
37
|
+
projectMarkers: ["tsconfig.json"],
|
|
38
|
+
},
|
|
39
|
+
}, null, 2)}\n`,
|
|
40
|
+
"utf8",
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function exerciseCodeIntelligence({ callTool, accessToken, sessionId, workspaceId, pass }) {
|
|
45
|
+
const run = (id, argumentsValue) => callTool(accessToken, sessionId, id, "capability", {
|
|
46
|
+
workspaceId,
|
|
47
|
+
name: "code.intelligence",
|
|
48
|
+
action: "run",
|
|
49
|
+
arguments: argumentsValue,
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
const definition = run(90, {
|
|
53
|
+
operation: "definition",
|
|
54
|
+
path: "src/main.ts",
|
|
55
|
+
line: 1,
|
|
56
|
+
column: 15,
|
|
57
|
+
});
|
|
58
|
+
assert.equal(definition.isError, undefined);
|
|
59
|
+
const definitionResult = definition.structuredContent.result;
|
|
60
|
+
assert.equal(definitionResult.operation, "definition");
|
|
61
|
+
assert.equal(definitionResult.selectedServer, "debug-fake");
|
|
62
|
+
assert.deepEqual(definitionResult.locations, [{
|
|
63
|
+
path: "src/target.ts",
|
|
64
|
+
external: false,
|
|
65
|
+
range: {
|
|
66
|
+
start: { line: 1, column: 8 },
|
|
67
|
+
end: { line: 1, column: 14 },
|
|
68
|
+
},
|
|
69
|
+
}]);
|
|
70
|
+
|
|
71
|
+
const hover = run(91, {
|
|
72
|
+
operation: "hover",
|
|
73
|
+
path: "src/main.ts",
|
|
74
|
+
line: 1,
|
|
75
|
+
column: 15,
|
|
76
|
+
});
|
|
77
|
+
assert.equal(hover.isError, undefined);
|
|
78
|
+
assert.equal(hover.structuredContent.result.operation, "hover");
|
|
79
|
+
assert.equal(hover.structuredContent.result.contents, "**debug target**: `() => void`");
|
|
80
|
+
assert.deepEqual(hover.structuredContent.result.range, {
|
|
81
|
+
start: { line: 1, column: 15 },
|
|
82
|
+
end: { line: 1, column: 21 },
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
const references = run(92, {
|
|
86
|
+
operation: "references",
|
|
87
|
+
path: "src/main.ts",
|
|
88
|
+
line: 1,
|
|
89
|
+
column: 15,
|
|
90
|
+
});
|
|
91
|
+
assert.equal(references.isError, undefined);
|
|
92
|
+
assert.equal(references.structuredContent.result.operation, "references");
|
|
93
|
+
assert.equal(references.structuredContent.result.returned, 2);
|
|
94
|
+
assert.equal(references.structuredContent.result.total, 2);
|
|
95
|
+
assert.equal(references.structuredContent.result.truncated, false);
|
|
96
|
+
assert.ok(references.structuredContent.result.locations.every((location) =>
|
|
97
|
+
location.path === "src/target.ts" && location.external === false
|
|
98
|
+
));
|
|
99
|
+
|
|
100
|
+
const documentSymbols = run(93, {
|
|
101
|
+
operation: "documentSymbols",
|
|
102
|
+
path: "src/main.ts",
|
|
103
|
+
});
|
|
104
|
+
assert.equal(documentSymbols.isError, undefined);
|
|
105
|
+
const documentSymbolsResult = documentSymbols.structuredContent.result;
|
|
106
|
+
assert.equal(documentSymbolsResult.operation, "documentSymbols");
|
|
107
|
+
assert.equal(documentSymbolsResult.hierarchical, true);
|
|
108
|
+
assert.equal(documentSymbolsResult.returned, 3);
|
|
109
|
+
assert.equal(documentSymbolsResult.symbols[0].name, "Widget");
|
|
110
|
+
assert.deepEqual(
|
|
111
|
+
documentSymbolsResult.symbols[0].children.map((symbol) => symbol.name),
|
|
112
|
+
["run", "stop"],
|
|
113
|
+
);
|
|
114
|
+
|
|
115
|
+
const workspaceSymbols = run(94, {
|
|
116
|
+
operation: "workspaceSymbols",
|
|
117
|
+
path: "src/main.ts",
|
|
118
|
+
query: "Target",
|
|
119
|
+
});
|
|
120
|
+
assert.equal(workspaceSymbols.isError, undefined);
|
|
121
|
+
const workspaceSymbolsResult = workspaceSymbols.structuredContent.result;
|
|
122
|
+
assert.equal(workspaceSymbolsResult.operation, "workspaceSymbols");
|
|
123
|
+
assert.equal(workspaceSymbolsResult.returned, 1);
|
|
124
|
+
assert.equal(workspaceSymbolsResult.symbols[0].name, "Target");
|
|
125
|
+
assert.equal(workspaceSymbolsResult.symbols[0].location.path, "src/target.ts");
|
|
126
|
+
assert.equal(workspaceSymbolsResult.symbols[0].location.external, false);
|
|
127
|
+
|
|
128
|
+
const diagnostics = run(95, {
|
|
129
|
+
operation: "diagnostics",
|
|
130
|
+
path: "src/main.ts",
|
|
131
|
+
});
|
|
132
|
+
assert.equal(diagnostics.isError, undefined);
|
|
133
|
+
const diagnosticsResult = diagnostics.structuredContent.result;
|
|
134
|
+
assert.equal(diagnosticsResult.operation, "diagnostics");
|
|
135
|
+
assert.equal(diagnosticsResult.provider, "pull");
|
|
136
|
+
assert.equal(diagnosticsResult.returned, 2);
|
|
137
|
+
assert.equal(diagnosticsResult.total, 2);
|
|
138
|
+
assert.equal(diagnosticsResult.freshness.state, "fresh");
|
|
139
|
+
assert.deepEqual(
|
|
140
|
+
diagnosticsResult.diagnostics.map((diagnostic) => diagnostic.message),
|
|
141
|
+
["pulled diagnostic 1", "pulled diagnostic 2"],
|
|
142
|
+
);
|
|
143
|
+
|
|
144
|
+
const invalidPosition = run(96, {
|
|
145
|
+
operation: "definition",
|
|
146
|
+
path: "src/main.ts",
|
|
147
|
+
line: 99,
|
|
148
|
+
column: 1,
|
|
149
|
+
});
|
|
150
|
+
assert.equal(invalidPosition.isError, true);
|
|
151
|
+
assert.equal(invalidPosition.structuredContent.error.code, "code.invalid_position");
|
|
152
|
+
pass(
|
|
153
|
+
"code intelligence execution",
|
|
154
|
+
"definition + hover + references + document/workspace symbols + diagnostics + stable error over OAuth/MCP",
|
|
155
|
+
);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export function assertCodeIntelligenceShutdown({ logPath, pass }) {
|
|
159
|
+
const events = readFileSync(logPath, "utf8")
|
|
160
|
+
.trim()
|
|
161
|
+
.split(/\r?\n/)
|
|
162
|
+
.filter(Boolean)
|
|
163
|
+
.map((line) => JSON.parse(line));
|
|
164
|
+
const shutdownIndex = events.findIndex((event) => event.method === "shutdown");
|
|
165
|
+
const exitIndex = events.findIndex((event, index) => index > shutdownIndex && event.method === "exit");
|
|
166
|
+
assert.ok(shutdownIndex >= 0, "code-intelligence Language server did not receive shutdown");
|
|
167
|
+
assert.ok(exitIndex > shutdownIndex, "code-intelligence Language server did not receive exit after shutdown");
|
|
168
|
+
pass("code intelligence shutdown", "Language service received shutdown -> exit during ForgeRelay server shutdown");
|
|
169
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
import { accessSync, constants } from "node:fs";
|
|
3
|
+
import { delimiter, join } from "node:path";
|
|
4
|
+
|
|
5
|
+
export function findExecutable(
|
|
6
|
+
command,
|
|
7
|
+
env = process.env,
|
|
8
|
+
platform = process.platform,
|
|
9
|
+
) {
|
|
10
|
+
const pathValue = env.PATH ?? env.Path ?? "";
|
|
11
|
+
const pathEntries = pathValue.split(delimiter).filter(Boolean);
|
|
12
|
+
const extensions = platform === "win32"
|
|
13
|
+
? (env.PATHEXT ?? ".EXE;.CMD;.BAT;.COM").split(";").filter(Boolean)
|
|
14
|
+
: [""];
|
|
15
|
+
const accessMode = platform === "win32" ? constants.F_OK : constants.X_OK;
|
|
16
|
+
|
|
17
|
+
for (const directory of pathEntries) {
|
|
18
|
+
for (const extension of extensions) {
|
|
19
|
+
const candidate = join(directory, platform === "win32" ? `${command}${extension}` : command);
|
|
20
|
+
try {
|
|
21
|
+
accessSync(candidate, accessMode);
|
|
22
|
+
return candidate;
|
|
23
|
+
} catch {
|
|
24
|
+
// Continue searching PATH without invoking or installing anything.
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
return undefined;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function probeExecutable(
|
|
32
|
+
executable,
|
|
33
|
+
env = process.env,
|
|
34
|
+
platform = process.platform,
|
|
35
|
+
) {
|
|
36
|
+
const result = spawnSync(executable, ["--version"], {
|
|
37
|
+
encoding: "utf8",
|
|
38
|
+
env,
|
|
39
|
+
windowsHide: true,
|
|
40
|
+
timeout: 5_000,
|
|
41
|
+
...(platform === "win32" ? { shell: true } : {}),
|
|
42
|
+
});
|
|
43
|
+
if (result.status === 0) return { available: true };
|
|
44
|
+
const reason = result.error?.message ?? result.stderr?.trim() ?? result.stdout?.trim() ?? `exit ${result.status ?? "unknown"}`;
|
|
45
|
+
return {
|
|
46
|
+
available: false,
|
|
47
|
+
reason: reason.replace(/\s+/g, " ").slice(0, 240),
|
|
48
|
+
};
|
|
49
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { chmodSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { delimiter, join } from "node:path";
|
|
5
|
+
import test from "node:test";
|
|
6
|
+
import { findExecutable, probeExecutable } from "./lsp-interop-support.mjs";
|
|
7
|
+
|
|
8
|
+
const root = mkdtempSync(join(tmpdir(), "forgerelay-lsp-preflight-test-"));
|
|
9
|
+
|
|
10
|
+
try {
|
|
11
|
+
const env = { ...process.env, PATH: root };
|
|
12
|
+
if (process.platform === "win32") {
|
|
13
|
+
env.PATHEXT = ".CMD";
|
|
14
|
+
writeFileSync(join(root, "working.cmd"), "@echo off\r\nif \"%1\"==\"--version\" echo fake-lsp 1.0.0 & exit /b 0\r\n", "utf8");
|
|
15
|
+
writeFileSync(join(root, "broken.cmd"), "@echo off\r\necho error: Unknown binary 1>&2\r\nexit /b 1\r\n", "utf8");
|
|
16
|
+
} else {
|
|
17
|
+
writeFileSync(join(root, "working"), "#!/bin/sh\necho fake-lsp 1.0.0\nexit 0\n", "utf8");
|
|
18
|
+
writeFileSync(join(root, "broken"), "#!/bin/sh\necho 'error: Unknown binary' >&2\nexit 1\n", "utf8");
|
|
19
|
+
chmodSync(join(root, "working"), 0o755);
|
|
20
|
+
chmodSync(join(root, "broken"), 0o755);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
test("interop preflight distinguishes runnable executables from PATH proxies that fail", () => {
|
|
24
|
+
const working = findExecutable("working", env);
|
|
25
|
+
const broken = findExecutable("broken", env);
|
|
26
|
+
assert.ok(working);
|
|
27
|
+
assert.ok(broken);
|
|
28
|
+
assert.deepEqual(probeExecutable(working, env), { available: true });
|
|
29
|
+
const unavailable = probeExecutable(broken, env);
|
|
30
|
+
assert.equal(unavailable.available, false);
|
|
31
|
+
assert.match(unavailable.reason, /Unknown binary|exit 1/i);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test("interop preflight treats a missing command as unavailable without installation", () => {
|
|
35
|
+
assert.equal(findExecutable("missing-language-server", env), undefined);
|
|
36
|
+
assert.equal(env.PATH.split(delimiter).length, 1);
|
|
37
|
+
});
|
|
38
|
+
} finally {
|
|
39
|
+
process.on("exit", () => rmSync(root, { recursive: true, force: true }));
|
|
40
|
+
}
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { mkdirSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { dirname, join, resolve } from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
import { CodeIntelligenceManager } from "../dist/lsp/runtime/manager.js";
|
|
6
|
+
import { findExecutable, probeExecutable } from "./lsp-interop-support.mjs";
|
|
7
|
+
|
|
8
|
+
const repoRoot = resolve(fileURLToPath(new URL("..", import.meta.url)));
|
|
9
|
+
const interopRoot = resolve(repoRoot, ".forgerelay-debug", "lsp-interop");
|
|
10
|
+
const fixtures = [
|
|
11
|
+
{
|
|
12
|
+
id: "typescript",
|
|
13
|
+
command: "typescript-language-server",
|
|
14
|
+
sourcePath: "src/main.ts",
|
|
15
|
+
hover: { line: 1, column: 14 },
|
|
16
|
+
setup(root) {
|
|
17
|
+
write(root, "tsconfig.json", `${JSON.stringify({ compilerOptions: { strict: true } }, null, 2)}\n`);
|
|
18
|
+
write(root, "src/main.ts", [
|
|
19
|
+
"export class Widget {",
|
|
20
|
+
" run(): number { return 1; }",
|
|
21
|
+
" stop(): number { return 0; }",
|
|
22
|
+
"}",
|
|
23
|
+
"",
|
|
24
|
+
].join("\n"));
|
|
25
|
+
},
|
|
26
|
+
},
|
|
27
|
+
{
|
|
28
|
+
id: "pyright",
|
|
29
|
+
command: "pyright-langserver",
|
|
30
|
+
sourcePath: "main.py",
|
|
31
|
+
hover: { line: 1, column: 7 },
|
|
32
|
+
setup(root) {
|
|
33
|
+
write(root, "pyrightconfig.json", "{}\n");
|
|
34
|
+
write(root, "main.py", [
|
|
35
|
+
"class Widget:",
|
|
36
|
+
" def run(self) -> int:",
|
|
37
|
+
" return 1",
|
|
38
|
+
"",
|
|
39
|
+
].join("\n"));
|
|
40
|
+
},
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
id: "rust-analyzer",
|
|
44
|
+
command: "rust-analyzer",
|
|
45
|
+
sourcePath: "src/lib.rs",
|
|
46
|
+
hover: { line: 1, column: 12 },
|
|
47
|
+
setup(root) {
|
|
48
|
+
write(root, "Cargo.toml", [
|
|
49
|
+
"[package]",
|
|
50
|
+
'name = "forgerelay-interop"',
|
|
51
|
+
'version = "0.1.0"',
|
|
52
|
+
'edition = "2021"',
|
|
53
|
+
"",
|
|
54
|
+
"[lib]",
|
|
55
|
+
'path = "src/lib.rs"',
|
|
56
|
+
"",
|
|
57
|
+
].join("\n"));
|
|
58
|
+
write(root, "src/lib.rs", [
|
|
59
|
+
"pub struct Widget;",
|
|
60
|
+
"impl Widget {",
|
|
61
|
+
" pub fn run(&self) -> i32 { 1 }",
|
|
62
|
+
"}",
|
|
63
|
+
"",
|
|
64
|
+
].join("\n"));
|
|
65
|
+
},
|
|
66
|
+
},
|
|
67
|
+
{
|
|
68
|
+
id: "gopls",
|
|
69
|
+
command: "gopls",
|
|
70
|
+
sourcePath: "main.go",
|
|
71
|
+
hover: { line: 3, column: 6 },
|
|
72
|
+
setup(root) {
|
|
73
|
+
write(root, "go.mod", "module example.com/forgerelay-interop\n\ngo 1.22\n");
|
|
74
|
+
write(root, "main.go", [
|
|
75
|
+
"package interop",
|
|
76
|
+
"",
|
|
77
|
+
"type Widget struct{}",
|
|
78
|
+
"",
|
|
79
|
+
"func (Widget) Run() int { return 1 }",
|
|
80
|
+
"",
|
|
81
|
+
].join("\n"));
|
|
82
|
+
},
|
|
83
|
+
},
|
|
84
|
+
{
|
|
85
|
+
id: "clangd",
|
|
86
|
+
command: "clangd",
|
|
87
|
+
sourcePath: "main.cpp",
|
|
88
|
+
hover: { line: 1, column: 7 },
|
|
89
|
+
setup(root) {
|
|
90
|
+
write(root, "compile_flags.txt", "-std=c++17\n");
|
|
91
|
+
write(root, "main.cpp", [
|
|
92
|
+
"class Widget {",
|
|
93
|
+
" public:",
|
|
94
|
+
" int run() const { return 1; }",
|
|
95
|
+
"};",
|
|
96
|
+
"",
|
|
97
|
+
].join("\n"));
|
|
98
|
+
},
|
|
99
|
+
},
|
|
100
|
+
];
|
|
101
|
+
|
|
102
|
+
rmSync(interopRoot, { recursive: true, force: true });
|
|
103
|
+
|
|
104
|
+
let passed = 0;
|
|
105
|
+
let skipped = 0;
|
|
106
|
+
const failures = [];
|
|
107
|
+
|
|
108
|
+
for (const fixture of fixtures) {
|
|
109
|
+
const executable = findExecutable(fixture.command);
|
|
110
|
+
if (!executable) {
|
|
111
|
+
skipped += 1;
|
|
112
|
+
console.log(`SKIP ${fixture.id}: ${fixture.command} not found on PATH; ForgeRelay does not install Language servers.`);
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
const preflight = probeExecutable(executable);
|
|
116
|
+
if (!preflight.available) {
|
|
117
|
+
skipped += 1;
|
|
118
|
+
console.log(`SKIP ${fixture.id}: ${executable} is present but not runnable: ${preflight.reason}`);
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const root = join(interopRoot, fixture.id);
|
|
123
|
+
fixture.setup(root);
|
|
124
|
+
const manager = new CodeIntelligenceManager(
|
|
125
|
+
{ languageServers: {} },
|
|
126
|
+
{
|
|
127
|
+
startTimeoutMs: 30_000,
|
|
128
|
+
requestTimeoutMs: 30_000,
|
|
129
|
+
shutdownTimeoutMs: 5_000,
|
|
130
|
+
idleMs: 60_000,
|
|
131
|
+
cleanupIntervalMs: 60_000,
|
|
132
|
+
},
|
|
133
|
+
);
|
|
134
|
+
|
|
135
|
+
try {
|
|
136
|
+
const symbols = await manager.run(root, {
|
|
137
|
+
operation: "documentSymbols",
|
|
138
|
+
path: fixture.sourcePath,
|
|
139
|
+
limit: 20,
|
|
140
|
+
});
|
|
141
|
+
assert.equal(symbols.operation, "documentSymbols");
|
|
142
|
+
assert.equal(symbols.selectedServer, fixture.id);
|
|
143
|
+
assert.ok(symbols.returned > 0, `${fixture.id} returned no document symbols`);
|
|
144
|
+
|
|
145
|
+
const hover = await manager.run(root, {
|
|
146
|
+
operation: "hover",
|
|
147
|
+
path: fixture.sourcePath,
|
|
148
|
+
line: fixture.hover.line,
|
|
149
|
+
column: fixture.hover.column,
|
|
150
|
+
});
|
|
151
|
+
assert.equal(hover.operation, "hover");
|
|
152
|
+
assert.equal(hover.selectedServer, fixture.id);
|
|
153
|
+
|
|
154
|
+
passed += 1;
|
|
155
|
+
console.log(
|
|
156
|
+
`PASS ${fixture.id}: ${executable} -> documentSymbols(${symbols.returned}) + hover via built-in discovery and stdio LSP.`,
|
|
157
|
+
);
|
|
158
|
+
} catch (error) {
|
|
159
|
+
const message = error instanceof Error ? (error.stack ?? error.message) : String(error);
|
|
160
|
+
failures.push(`${fixture.id}: ${message}`);
|
|
161
|
+
console.error(`FAIL ${fixture.id}: ${message}`);
|
|
162
|
+
} finally {
|
|
163
|
+
await manager.shutdown();
|
|
164
|
+
assert.equal(manager.stats().servicesTotal, 0, `${fixture.id} retained a Language service after shutdown`);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
console.log(`LSP interoperability summary: ${passed} passed, ${skipped} skipped, ${failures.length} failed.`);
|
|
169
|
+
if (failures.length > 0) {
|
|
170
|
+
console.error(`Interop artifacts retained at ${interopRoot}`);
|
|
171
|
+
process.exitCode = 1;
|
|
172
|
+
} else {
|
|
173
|
+
rmSync(interopRoot, { recursive: true, force: true });
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function write(root, relativePath, content) {
|
|
177
|
+
const path = join(root, relativePath);
|
|
178
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
179
|
+
writeFileSync(path, content, "utf8");
|
|
180
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
import { cpSync, mkdirSync, mkdtempSync, rmSync } from "node:fs";
|
|
3
|
+
import { dirname, join, resolve } from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
|
|
6
|
+
const minimumNode = "22.19.0";
|
|
7
|
+
const npmVersion = "11";
|
|
8
|
+
const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
|
9
|
+
const debugRoot = join(repoRoot, ".forgerelay-debug");
|
|
10
|
+
const sandbox = mkdtempSync(join(ensureDirectory(debugRoot), "release-parity-node22-"));
|
|
11
|
+
const npx = process.platform === "win32" ? "npx.cmd" : "npx";
|
|
12
|
+
const highRiskTests = [
|
|
13
|
+
"scripts/lsp-interop-support.test.mjs",
|
|
14
|
+
"src/lsp/language-server-config.test.ts",
|
|
15
|
+
"src/lsp/runtime/semantic-requests.test.ts",
|
|
16
|
+
"src/lsp/operations/request-hardening.server.test.ts",
|
|
17
|
+
"src/lsp/operations/recovery.server.test.ts",
|
|
18
|
+
"src/lsp/operations/lifecycle.server.test.ts",
|
|
19
|
+
];
|
|
20
|
+
|
|
21
|
+
try {
|
|
22
|
+
copy("package.json");
|
|
23
|
+
copy("package-lock.json");
|
|
24
|
+
copy("tsconfig.json");
|
|
25
|
+
copy("src");
|
|
26
|
+
copy("scripts");
|
|
27
|
+
copy("capabilities");
|
|
28
|
+
|
|
29
|
+
run(
|
|
30
|
+
["--yes", "-p", `node@${minimumNode}`, "-p", `npm@${npmVersion}`, "--", "npm", "ci", "--no-audit", "--no-fund"],
|
|
31
|
+
`Node ${minimumNode} isolated npm ci`,
|
|
32
|
+
sandbox,
|
|
33
|
+
);
|
|
34
|
+
|
|
35
|
+
run(
|
|
36
|
+
[
|
|
37
|
+
"--yes",
|
|
38
|
+
`node@${minimumNode}`,
|
|
39
|
+
"--import",
|
|
40
|
+
"tsx",
|
|
41
|
+
"--test",
|
|
42
|
+
"--test-concurrency=1",
|
|
43
|
+
...highRiskTests,
|
|
44
|
+
],
|
|
45
|
+
`Node ${minimumNode} high-risk parity tests`,
|
|
46
|
+
sandbox,
|
|
47
|
+
);
|
|
48
|
+
|
|
49
|
+
console.log(`Release parity passed in an isolated Node ${minimumNode} sandbox.`);
|
|
50
|
+
} finally {
|
|
51
|
+
rmSync(sandbox, {
|
|
52
|
+
recursive: true,
|
|
53
|
+
force: true,
|
|
54
|
+
maxRetries: 8,
|
|
55
|
+
retryDelay: 100,
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function ensureDirectory(path) {
|
|
60
|
+
mkdirSync(path, { recursive: true });
|
|
61
|
+
return `${path}/`;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function copy(relativePath) {
|
|
65
|
+
cpSync(join(repoRoot, relativePath), join(sandbox, relativePath), {
|
|
66
|
+
recursive: true,
|
|
67
|
+
force: true,
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function run(args, label, cwd) {
|
|
72
|
+
console.log(`\n== ${label} ==`);
|
|
73
|
+
const result = spawnSync(npx, args, {
|
|
74
|
+
cwd,
|
|
75
|
+
env: process.env,
|
|
76
|
+
stdio: "inherit",
|
|
77
|
+
windowsHide: true,
|
|
78
|
+
});
|
|
79
|
+
if (result.error) throw result.error;
|
|
80
|
+
if (result.status !== 0) {
|
|
81
|
+
console.error(`${label} failed with exit ${result.status ?? "unknown"}.`);
|
|
82
|
+
process.exitCode = result.status ?? 1;
|
|
83
|
+
throw new Error(`${label} failed.`);
|
|
84
|
+
}
|
|
85
|
+
}
|