@akira-tl/forgerelay 0.4.5 → 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 +13 -0
- package/README.md +29 -10
- package/capabilities/code-intelligence/GUIDE.md +3 -1
- package/dist/lsp/test-support/server-fixture.js +6 -1
- package/docs/configuration.md +10 -2
- package/docs/roadmap.md +7 -0
- package/docs/versioning.md +15 -2
- package/examples/language-servers.json +30 -0
- package/package.json +4 -2
- 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/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,19 @@ All notable ForgeRelay changes are documented here.
|
|
|
4
4
|
|
|
5
5
|
## [Unreleased]
|
|
6
6
|
|
|
7
|
+
## [0.4.6] - 2026-08-11
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
- Added published `npm run lsp:interop` acceptance for `typescript-language-server`, Pyright, `rust-analyzer`, `gopls`, and `clangd`: detected executables run through built-in discovery and real stdio LSP, while missing external servers are reported as explicit skips without automatic installation.
|
|
12
|
+
- Extended the real 7677 HTTP/OAuth/MCP acceptance path to execute all six `code.intelligence` v1 operations, normalized results, a stable error path, and Language-service `shutdown -> exit` behavior while keeping exactly nine Core MCP tools.
|
|
13
|
+
|
|
14
|
+
### Changed
|
|
15
|
+
|
|
16
|
+
- Cloud release CI now runs optional real-server interoperability after the normal deterministic fake-LSP test/build gate on Linux, macOS, and Windows. Executable preflight now distinguishes a runnable Language server from PATH shims/proxies that exist but fail to launch, so incomplete rustup components are skipped rather than misreported as installed servers.
|
|
17
|
+
- Local `release:verify` now includes an isolated Node 22.19.0 parity sandbox with its own `npm ci` and focused LSP/release regression suite; cloud CI and publication use the same Node 22.19.0 runtime to reduce local/cloud drift, while Windows test cleanup retries transient locked-directory removal.
|
|
18
|
+
- Completed the 0.4 LSP v1 user, configuration, roadmap, contributor, and explicit Language-server example documentation, and hardened cross-platform/concurrency acceptance timing exposed during final release validation.
|
|
19
|
+
|
|
7
20
|
## [0.4.5] - 2026-08-11
|
|
8
21
|
|
|
9
22
|
### Added
|
package/README.md
CHANGED
|
@@ -112,6 +112,22 @@ reports a semantic capability but the Host still shows an older tool snapshot,
|
|
|
112
112
|
refresh or reconnect the integration rather than assuming the capability is
|
|
113
113
|
missing from ForgeRelay.
|
|
114
114
|
|
|
115
|
+
## LSP code intelligence
|
|
116
|
+
|
|
117
|
+
ForgeRelay 0.4 LSP v1 exposes semantic code navigation through the
|
|
118
|
+
`code.intelligence` Capability without adding language-specific top-level MCP tools.
|
|
119
|
+
The v1 operations are definition, hover/type information, references, document
|
|
120
|
+
symbols, workspace symbols, and diagnostics. Results use ForgeRelay-owned normalized
|
|
121
|
+
locations, ranges, symbols, hover content, and diagnostic shapes rather than raw LSP
|
|
122
|
+
wire unions.
|
|
123
|
+
|
|
124
|
+
Language servers remain external dependencies. ForgeRelay can discover
|
|
125
|
+
`typescript-language-server`, `pyright-langserver`, `rust-analyzer`, `gopls`, and
|
|
126
|
+
`clangd` when they already exist on `PATH`, or use structured project/global
|
|
127
|
+
configuration, but it never installs a server automatically. See
|
|
128
|
+
[Configuration Reference](docs/configuration.md#lsp-code-intelligence) and
|
|
129
|
+
[`examples/language-servers.json`](examples/language-servers.json).
|
|
130
|
+
|
|
115
131
|
## Worktrees without the usual cleanup mess
|
|
116
132
|
|
|
117
133
|
A new managed worktree gets its own `forgerelay/*` branch instead of a detached
|
|
@@ -247,13 +263,13 @@ forgerelay doctor
|
|
|
247
263
|
|
|
248
264
|
## Where ForgeRelay is going
|
|
249
265
|
|
|
250
|
-
|
|
251
|
-
|
|
266
|
+
With LSP code intelligence established in the 0.4 line, the next additions remain
|
|
267
|
+
focused on making the local execution layer more useful, not on turning ForgeRelay
|
|
268
|
+
into another all-in-one agent framework:
|
|
252
269
|
|
|
253
|
-
1.
|
|
254
|
-
2.
|
|
255
|
-
3.
|
|
256
|
-
4. checkpoint/rewind and retention improvements.
|
|
270
|
+
1. first-class MCP subagent delegation;
|
|
271
|
+
2. stronger worktree verification and recovery;
|
|
272
|
+
3. checkpoint/rewind and retention improvements.
|
|
257
273
|
|
|
258
274
|
ForgeRelay does not plan to add its own shell sandbox, long-term memory system,
|
|
259
275
|
or plugin marketplace. Conversation, planning, web access, and other host-native
|
|
@@ -277,10 +293,13 @@ npm run release:verify
|
|
|
277
293
|
```
|
|
278
294
|
|
|
279
295
|
Daily branch pushes do not run cloud CI. When preparing a release, run the full
|
|
280
|
-
local release verification first.
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
296
|
+
local release verification first. `release:verify` includes a focused parity pass
|
|
297
|
+
in an isolated Node 22.19.0 environment with its own `npm ci`, matching the cloud
|
|
298
|
+
CI runtime for native addons and high-risk LSP lifecycle tests. Pushing a matching
|
|
299
|
+
`vX.Y.Z` tag to `Akira-TL/forgerelay` is the only cloud CI and publish trigger:
|
|
300
|
+
GitHub Actions runs the reusable multi-platform CI, then publishes
|
|
301
|
+
`@akira-tl/forgerelay` and creates the matching GitHub Release only after CI
|
|
302
|
+
succeeds.
|
|
284
303
|
|
|
285
304
|
See [Versioning and Release Management](docs/versioning.md) for the bootstrap and
|
|
286
305
|
Trusted Publishing setup.
|
|
@@ -4,7 +4,7 @@ Use the `code.intelligence` Capability for read-only semantic code navigation ba
|
|
|
4
4
|
|
|
5
5
|
ForgeRelay does not install Language servers. It discovers supported executables when available and accepts explicit definitions from the global ForgeRelay config or `<workspace>/.forgerelay/language-servers.json`. Project definitions override global definitions, and global definitions override built-in discovery. An explicit definition may disable discovery with `enabled: false`.
|
|
6
6
|
|
|
7
|
-
ForgeRelay 0.4
|
|
7
|
+
ForgeRelay 0.4 LSP v1 exposes `definition`, `hover`, `references`, `documentSymbols`, `workspaceSymbols`, and `diagnostics`. Position-based operations accept a workspace-relative source `path` plus 1-based `line` and `column` values. `documentSymbols` needs only `path` and an optional bounded `limit`. `workspaceSymbols` uses `path` to select the Language project/service and accepts a `query` plus optional `limit`; it does not merge multiple nested Language services. `diagnostics` uses `path` plus an optional bounded `limit`. Columns are Unicode code-point positions; ForgeRelay converts them to the position encoding negotiated with the Language server.
|
|
8
8
|
|
|
9
9
|
Code-intelligence results use ForgeRelay-owned shapes rather than raw LSP wire types. Definition returns normalized locations. Hover returns one `contents` string, an optional legacy `language`, and an optional ForgeRelay-normalized `range`; plaintext, Markdown `MarkupContent`, and supported legacy `MarkedString` payloads are normalized before reaching the Agent. References uses the same normalized location shape, defaults to `limit: 100`, and accepts limits up to 1000. Its result reports `returned`, `truncated`, and `total` when the complete Language-server response makes the total known.
|
|
10
10
|
|
|
@@ -21,3 +21,5 @@ Semantic Language-server requests use an internal bounded deadline; the Agent co
|
|
|
21
21
|
An unexpected Language-server process exit fails its pending JSON-RPC work immediately instead of waiting for the semantic deadline. ForgeRelay discards that service and retries the current semantic operation at most once. A second consecutive crash for the same Language project/server-definition fingerprint enters a short cooldown before another process may be started; a successful retry clears the crash state. Effective server definitions are fingerprinted, so a changed project/global/built-in definition selects a new service identity on the next code-intelligence resolution and retires only an idle service with the same project root/server id but the old fingerprint. ForgeRelay does not add a recursive config watcher.
|
|
22
22
|
|
|
23
23
|
Language services are shared by physical Language project identity rather than logical Workspace id, so multiple conversations over the same checkout do not multiply server processes. Idle services are reclaimed after a bounded TTL, and the global service cap evicts the least-recently-used truly idle service; a request that has returned to the Host but whose server ignored cancellation still counts as active until the underlying LSP request settles. Managed-worktree finalization releases idle Language services rooted in that worktree before removing it and refuses finalization while semantic work is still active. Debug runtime telemetry reports only aggregate service/process/request/document/diagnostic/stderr counts and never source contents.
|
|
24
|
+
|
|
25
|
+
The built-in discovery candidates are `typescript-language-server`, `pyright-langserver`, `rust-analyzer`, `gopls`, and `clangd`. Contributors can run `npm run lsp:interop` to exercise any of those executables that are already present on `PATH`; each detected server is tested through built-in discovery and real stdio LSP, while absent servers are reported as explicit skips. The interoperability check never downloads or installs a Language server.
|
|
@@ -51,7 +51,12 @@ export async function createCodeIntelligenceServerFixture(t, options = {}) {
|
|
|
51
51
|
};
|
|
52
52
|
t.after(async () => {
|
|
53
53
|
await close();
|
|
54
|
-
await rm(root, {
|
|
54
|
+
await rm(root, {
|
|
55
|
+
recursive: true,
|
|
56
|
+
force: true,
|
|
57
|
+
maxRetries: 8,
|
|
58
|
+
retryDelay: 100,
|
|
59
|
+
});
|
|
55
60
|
});
|
|
56
61
|
return { client, project, codeIntelligence, close };
|
|
57
62
|
}
|
package/docs/configuration.md
CHANGED
|
@@ -154,7 +154,7 @@ force a Host to invalidate its cached schema.
|
|
|
154
154
|
### LSP code intelligence
|
|
155
155
|
|
|
156
156
|
ForgeRelay advertises `code.intelligence` through the Capability Gateway; it does
|
|
157
|
-
not add language-specific top-level MCP tools. ForgeRelay 0.4
|
|
157
|
+
not add language-specific top-level MCP tools. ForgeRelay 0.4 LSP v1 supports
|
|
158
158
|
`definition`, `hover`, `references`, `documentSymbols`, `workspaceSymbols`, and
|
|
159
159
|
`diagnostics`.
|
|
160
160
|
Position-based operations accept the same workspace-relative source position. Hover
|
|
@@ -173,7 +173,10 @@ snapshot. Push and pull use one normalized result shape with `provider`,
|
|
|
173
173
|
filesystem document version. Bounded collection results report `returned`, `truncated`,
|
|
174
174
|
and the real `total` when the complete Language-server response makes it known. Language Servers are external
|
|
175
175
|
dependencies: ForgeRelay may discover an executable already installed on the
|
|
176
|
-
machine, but it never downloads or installs one automatically.
|
|
176
|
+
machine, but it never downloads or installs one automatically. Built-in discovery
|
|
177
|
+
knows `typescript-language-server`, `pyright-langserver`, `rust-analyzer`, `gopls`,
|
|
178
|
+
and `clangd`. See `examples/language-servers.json` for copyable explicit TypeScript
|
|
179
|
+
and Pyright definitions.
|
|
177
180
|
|
|
178
181
|
ForgeRelay 0.4.5 hardens this shared Language-service runtime. Semantic requests
|
|
179
182
|
have one internal bounded deadline, Host cancellation propagates to LSP cancellation,
|
|
@@ -243,6 +246,11 @@ not recursively scan the Workspace.
|
|
|
243
246
|
|
|
244
247
|
Code-intelligence input positions are 1-based line and 1-based Unicode code-point
|
|
245
248
|
column values. The Workspace filesystem is the only v1 document source of truth.
|
|
249
|
+
|
|
250
|
+
For contributor/release interoperability checks, run `npm run lsp:interop`. The
|
|
251
|
+
command tests each supported real Language server that is already on `PATH` through
|
|
252
|
+
ForgeRelay built-in discovery and stdio LSP, reports a clear skip when an executable
|
|
253
|
+
is absent, and never installs external dependencies.
|
|
246
254
|
Definition results may point outside the Workspace and are then marked
|
|
247
255
|
`external: true`; this is informational only and does not expand allowed roots or
|
|
248
256
|
file-tool authority.
|
package/docs/roadmap.md
CHANGED
|
@@ -240,6 +240,13 @@ and verify publication before work begins on the next boundary:
|
|
|
240
240
|
- **0.4.6** — optional real-server interoperability, cross-platform checks, fresh Host
|
|
241
241
|
acceptance, documentation, and final LSP v1 closure.
|
|
242
242
|
|
|
243
|
+
The shipping 0.4 LSP v1 contract keeps the canonical nine Core MCP tools unchanged
|
|
244
|
+
and exposes semantic operations only through `code.intelligence`. Deterministic
|
|
245
|
+
fake-LSP coverage remains the primary cross-platform protocol/lifecycle gate, while
|
|
246
|
+
`npm run lsp:interop` exercises `typescript-language-server`, Pyright,
|
|
247
|
+
`rust-analyzer`, `gopls`, and `clangd` only when those external executables are
|
|
248
|
+
already present and otherwise reports explicit skips without installing them.
|
|
249
|
+
|
|
243
250
|
## 0.5 — First-class subagent MCP
|
|
244
251
|
|
|
245
252
|
ForgeRelay already owns provider adapters and resumable local agent sessions.
|
package/docs/versioning.md
CHANGED
|
@@ -85,6 +85,18 @@ Run the full local release gate with:
|
|
|
85
85
|
npm run release:verify
|
|
86
86
|
```
|
|
87
87
|
|
|
88
|
+
`release:verify` checks the current development runtime and then runs a focused
|
|
89
|
+
`release:parity` gate in an isolated Node 22.19.0 sandbox. The parity sandbox
|
|
90
|
+
performs its own `npm ci` so native addons use the same Node ABI as cloud CI,
|
|
91
|
+
then reruns the LSP/release tests most sensitive to event-loop timing, process
|
|
92
|
+
lifecycle, path canonicalization, executable discovery, and cleanup behavior.
|
|
93
|
+
It also tests that a command which exists on `PATH` but fails its `--version`
|
|
94
|
+
preflight is treated as unavailable rather than as an installed Language server.
|
|
95
|
+
|
|
96
|
+
Cloud verification and the publication job are both pinned to Node 22.19.0, the
|
|
97
|
+
minimum supported Node release, so local parity and the release runners use the
|
|
98
|
+
same runtime instead of drifting across separate Node 22/24 variants.
|
|
99
|
+
|
|
88
100
|
Validate a specific tag with:
|
|
89
101
|
|
|
90
102
|
```bash
|
|
@@ -158,8 +170,9 @@ npm publishing token.
|
|
|
158
170
|
3. Run the appropriate `release:patch`, `release:minor`, or `release:major`
|
|
159
171
|
command.
|
|
160
172
|
4. Review the generated version and changelog diff.
|
|
161
|
-
5. Run `npm run release:verify` locally. This full local gate
|
|
162
|
-
|
|
173
|
+
5. Run `npm run release:verify` locally. This full local gate includes the isolated
|
|
174
|
+
Node 22.19.0 parity sandbox and is a release-time operation; ordinary development
|
|
175
|
+
pushes do not need to run the full release gate.
|
|
163
176
|
6. Commit the release-ready code and metadata and push `main`.
|
|
164
177
|
7. Create the exact version tag, for example:
|
|
165
178
|
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
{
|
|
2
|
+
"typescript": {
|
|
3
|
+
"command": "typescript-language-server",
|
|
4
|
+
"args": ["--stdio"],
|
|
5
|
+
"env": {},
|
|
6
|
+
"languages": ["typescript", "typescriptreact", "javascript", "javascriptreact"],
|
|
7
|
+
"extensions": [".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs"],
|
|
8
|
+
"languageIdByExtension": {
|
|
9
|
+
".ts": "typescript",
|
|
10
|
+
".tsx": "typescriptreact",
|
|
11
|
+
".js": "javascript",
|
|
12
|
+
".jsx": "javascriptreact",
|
|
13
|
+
".mjs": "javascript",
|
|
14
|
+
".cjs": "javascript"
|
|
15
|
+
},
|
|
16
|
+
"projectMarkers": ["tsconfig.json", "jsconfig.json", "package.json"]
|
|
17
|
+
},
|
|
18
|
+
"pyright": {
|
|
19
|
+
"command": "pyright-langserver",
|
|
20
|
+
"args": ["--stdio"],
|
|
21
|
+
"env": {},
|
|
22
|
+
"languages": ["python"],
|
|
23
|
+
"extensions": [".py", ".pyi"],
|
|
24
|
+
"languageIdByExtension": {
|
|
25
|
+
".py": "python",
|
|
26
|
+
".pyi": "python"
|
|
27
|
+
},
|
|
28
|
+
"projectMarkers": ["pyrightconfig.json", "pyproject.toml", "setup.cfg", "setup.py"]
|
|
29
|
+
}
|
|
30
|
+
}
|
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,6 +40,8 @@
|
|
|
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
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",
|
|
@@ -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
|
+
}
|