semantic-js-mcp 0.9.0 → 0.10.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.
Files changed (41) hide show
  1. package/.codex-plugin/plugin.json +1 -1
  2. package/.prettierrc.json +1 -0
  3. package/CHANGELOG.md +18 -0
  4. package/CONTRIBUTING.md +6 -2
  5. package/README.md +13 -3
  6. package/ROADMAP.md +1 -1
  7. package/docs/distribution.md +8 -0
  8. package/docs/getting-started.md +72 -0
  9. package/docs/result-schema-migration.md +11 -0
  10. package/lib/doctor.mjs +3 -2
  11. package/lib/file-identity.mjs +18 -0
  12. package/lib/pending-requests.mjs +38 -0
  13. package/lib/runtime.mjs +4 -2
  14. package/lib/semantic-evidence.mjs +44 -0
  15. package/lib/stable-collection.mjs +10 -0
  16. package/lib/temporary-directory.mjs +15 -0
  17. package/package.json +9 -4
  18. package/protocol.mjs +32 -4
  19. package/scripts/agent-evaluation-contract.mjs +24 -1
  20. package/scripts/check-protocol-literals.mjs +10 -0
  21. package/scripts/ci-smoke.mjs +58 -1
  22. package/scripts/distribution-policy.mjs +0 -7
  23. package/scripts/distribution-smoke.mjs +17 -13
  24. package/scripts/doctor-smoke.mjs +40 -0
  25. package/scripts/documentation-contract.mjs +39 -0
  26. package/scripts/documentation-gate-smoke.mjs +55 -0
  27. package/scripts/documentation-gate.mjs +110 -0
  28. package/scripts/generate-protocol-reference.mjs +10 -0
  29. package/scripts/lifecycle-memory-benchmark.mjs +300 -0
  30. package/scripts/lifecycle-memory-contract.mjs +53 -0
  31. package/scripts/lifecycle-memory-observer.mjs +29 -0
  32. package/scripts/lifecycle-smoke.mjs +309 -0
  33. package/scripts/negative-verification-smoke.mjs +243 -0
  34. package/scripts/postpublication-smoke.mjs +14 -15
  35. package/scripts/release-contract.mjs +8 -0
  36. package/scripts/semantic-js-mcp-ci.mjs +35 -19
  37. package/scripts/smoke.mjs +87 -14
  38. package/scripts/vue-smoke.mjs +32 -2
  39. package/server.mjs +306 -124
  40. package/skills/semantic-navigation/SKILL.md +2 -0
  41. package/skills/semantic-navigation/references/protocol-literals.md +32 -4
@@ -0,0 +1,300 @@
1
+ #!/usr/bin/env node
2
+
3
+ import {spawn} from "node:child_process";
4
+ import path from "node:path";
5
+ import {mkdtemp, mkdir, rm, writeFile} from "node:fs/promises";
6
+ import {tmpdir} from "node:os";
7
+ import {fileURLToPath} from "node:url";
8
+ import {Client} from "@modelcontextprotocol/sdk/client/index.js";
9
+ import {StdioClientTransport} from "@modelcontextprotocol/sdk/client/stdio.js";
10
+ import {ENVIRONMENT_VARIABLE, NODE_EVENT, PRODUCT, TOOL} from "../protocol.mjs";
11
+ import {
12
+ LIFECYCLE_MEMORY_BENCHMARK as BENCHMARK,
13
+ LIFECYCLE_MEMORY_EXIT_CODE,
14
+ LIFECYCLE_MEMORY_FIELD,
15
+ LIFECYCLE_MEMORY_METHOD,
16
+ LIFECYCLE_MEMORY_OBSERVER,
17
+ LIFECYCLE_MEMORY_REASON,
18
+ LIFECYCLE_MEMORY_STATUS,
19
+ LIFECYCLE_MEMORY_SUPPORTED_PLATFORMS,
20
+ } from "./lifecycle-memory-contract.mjs";
21
+
22
+ const PROCESS_ERROR = Object.freeze({COMMAND_NOT_FOUND: "ENOENT", NOT_FOUND: "ESRCH"});
23
+ const PROCESS_EVENT = Object.freeze({DATA: "data"});
24
+ const PROCESS_SIGNAL = Object.freeze({EXISTS: 0});
25
+ const SUPPORTED_PLATFORM = new Set(LIFECYCLE_MEMORY_SUPPORTED_PLATFORMS);
26
+ const PROVIDER_START_PATTERN = /starting provider (\d+)/;
27
+ const pluginRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
28
+
29
+ function assert(condition, message) {
30
+ if (!condition) throw new Error(message);
31
+ }
32
+
33
+ const delay = (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds));
34
+
35
+ async function waitUntil(predicate, message) {
36
+ for (let attempt = 0; attempt < BENCHMARK.OBSERVATION_ATTEMPTS; attempt += 1) {
37
+ const value = await predicate();
38
+ if (value) return value;
39
+ await delay(BENCHMARK.OBSERVATION_INTERVAL_MS);
40
+ }
41
+ throw new Error(message);
42
+ }
43
+
44
+ function processExists(pid) {
45
+ try {
46
+ process.kill(pid, PROCESS_SIGNAL.EXISTS);
47
+ return true;
48
+ } catch (error) {
49
+ if (error?.code === PROCESS_ERROR.NOT_FOUND) return false;
50
+ throw error;
51
+ }
52
+ }
53
+
54
+ function readResidentSetBytes(pid) {
55
+ return new Promise((resolve, reject) => {
56
+ const child = spawn("ps", ["-o", "rss=", "-p", String(pid)], {stdio: ["ignore", "pipe", "pipe"]});
57
+ const stdout = [];
58
+ const stderr = [];
59
+ child.stdout.on(PROCESS_EVENT.DATA, (chunk) => stdout.push(chunk));
60
+ child.stderr.on(PROCESS_EVENT.DATA, (chunk) => stderr.push(chunk));
61
+ child.on(NODE_EVENT.ERROR, reject);
62
+ child.on(NODE_EVENT.CLOSE, (exitCode) => {
63
+ const kibibytes = Number(Buffer.concat(stdout).toString("utf8").trim());
64
+ if (exitCode === 0 && Number.isFinite(kibibytes)) {
65
+ resolve(kibibytes * 1024);
66
+ return;
67
+ }
68
+ const message = Buffer.concat(stderr).toString("utf8").trim() || `ps exited ${exitCode}`;
69
+ reject(new Error(`Could not read provider RSS: ${message}`));
70
+ });
71
+ });
72
+ }
73
+
74
+ function median(values) {
75
+ const ordered = [...values].sort((left, right) => left - right);
76
+ const middle = Math.floor(ordered.length / 2);
77
+ return ordered.length % 2 === 0 ? (ordered[middle - 1] + ordered[middle]) / 2 : ordered[middle];
78
+ }
79
+
80
+ function linearSlope(values) {
81
+ const center = (values.length - 1) / 2;
82
+ let numerator = 0;
83
+ let denominator = 0;
84
+ for (let index = 0; index < values.length; index += 1) {
85
+ const distance = index - center;
86
+ numerator += distance * values[index];
87
+ denominator += distance * distance;
88
+ }
89
+ return denominator === 0 ? 0 : numerator / denominator;
90
+ }
91
+
92
+ function summarize(values) {
93
+ return {
94
+ minimumBytes: Math.min(...values),
95
+ medianBytes: Math.round(median(values)),
96
+ maximumBytes: Math.max(...values),
97
+ };
98
+ }
99
+
100
+ function assessTrend(samples, field, {medianMinimumBytes, medianRatio, slopeMinimumBytes, slopeRatio}) {
101
+ const values = samples.map((sample) => sample[field]);
102
+ const firstMedian = median(values.slice(0, BENCHMARK.COMPARISON_WINDOW));
103
+ const lastMedian = median(values.slice(-BENCHMARK.COMPARISON_WINDOW));
104
+ const medianGrowthBytes = lastMedian - firstMedian;
105
+ const slopeBytesPerCycle = linearSlope(values);
106
+ const maximumMedianGrowthBytes = Math.max(medianMinimumBytes, firstMedian * medianRatio);
107
+ const maximumSlopeBytesPerCycle = Math.max(slopeMinimumBytes, firstMedian * slopeRatio);
108
+ return {
109
+ status:
110
+ medianGrowthBytes <= maximumMedianGrowthBytes && slopeBytesPerCycle <= maximumSlopeBytesPerCycle
111
+ ? LIFECYCLE_MEMORY_STATUS.PASS
112
+ : LIFECYCLE_MEMORY_STATUS.FAIL,
113
+ firstWindowMedianBytes: Math.round(firstMedian),
114
+ lastWindowMedianBytes: Math.round(lastMedian),
115
+ medianGrowthBytes: Math.round(medianGrowthBytes),
116
+ maximumMedianGrowthBytes: Math.round(maximumMedianGrowthBytes),
117
+ slopeBytesPerCycle: Math.round(slopeBytesPerCycle),
118
+ maximumSlopeBytesPerCycle: Math.round(maximumSlopeBytesPerCycle),
119
+ };
120
+ }
121
+
122
+ function assertAssessmentContract() {
123
+ const limits = {
124
+ medianMinimumBytes: BENCHMARK.SYNTHETIC_MEDIAN_LIMIT_BYTES,
125
+ medianRatio: 0,
126
+ slopeMinimumBytes: BENCHMARK.SYNTHETIC_SLOPE_LIMIT_BYTES,
127
+ slopeRatio: 0,
128
+ };
129
+ const stable = Array.from({length: BENCHMARK.SYNTHETIC_SAMPLE_COUNT}, (_, index) => ({
130
+ [LIFECYCLE_MEMORY_FIELD.SYNTHETIC_VALUE]: BENCHMARK.SYNTHETIC_BASE_BYTES + (index % BENCHMARK.SYNTHETIC_STABLE_VARIATION_BYTES),
131
+ }));
132
+ const growing = Array.from({length: BENCHMARK.SYNTHETIC_SAMPLE_COUNT}, (_, index) => ({
133
+ [LIFECYCLE_MEMORY_FIELD.SYNTHETIC_VALUE]: BENCHMARK.SYNTHETIC_BASE_BYTES + index * BENCHMARK.SYNTHETIC_GROWTH_BYTES_PER_CYCLE,
134
+ }));
135
+ assert(
136
+ assessTrend(stable, LIFECYCLE_MEMORY_FIELD.SYNTHETIC_VALUE, limits).status === LIFECYCLE_MEMORY_STATUS.PASS,
137
+ "Stable trend failed assessment",
138
+ );
139
+ assert(
140
+ assessTrend(growing, LIFECYCLE_MEMORY_FIELD.SYNTHETIC_VALUE, limits).status === LIFECYCLE_MEMORY_STATUS.FAIL,
141
+ "Growing trend passed assessment",
142
+ );
143
+ }
144
+
145
+ async function createWorkspace() {
146
+ const workspace = await mkdtemp(path.join(tmpdir(), `${PRODUCT.NAME}-lifecycle-memory-`));
147
+ const sourceDirectory = path.join(workspace, "src");
148
+ await mkdir(sourceDirectory, {recursive: true});
149
+ await writeFile(path.join(workspace, "package.json"), JSON.stringify({private: true, type: "module"}));
150
+ await writeFile(
151
+ path.join(workspace, "tsconfig.json"),
152
+ JSON.stringify({
153
+ compilerOptions: {strict: true, target: "ES2022", module: "ESNext", moduleResolution: "Bundler"},
154
+ include: ["src/**/*.ts"],
155
+ }),
156
+ );
157
+ const file = path.join(sourceDirectory, "target.ts");
158
+ await writeFile(file, 'export function lifecycleMemoryTarget(): string { return "memory"; }\n');
159
+ return {file, workspace};
160
+ }
161
+
162
+ function blockedOutput(reason) {
163
+ return {
164
+ product: PRODUCT.NAME,
165
+ status: LIFECYCLE_MEMORY_STATUS.BLOCKED,
166
+ reason,
167
+ platform: {current: process.platform, supported: [...SUPPORTED_PLATFORM]},
168
+ };
169
+ }
170
+
171
+ async function runBenchmark() {
172
+ const fixture = await createWorkspace();
173
+ const providerPids = [];
174
+ const memorySnapshots = [];
175
+ let stderrBuffer = "";
176
+ const client = new Client({name: `${PRODUCT.NAME}-lifecycle-memory`, version: "1.0.0"});
177
+ const transport = new StdioClientTransport({
178
+ command: process.execPath,
179
+ args: ["--expose-gc", path.join(pluginRoot, "scripts", "lifecycle-memory-observer.mjs")],
180
+ cwd: pluginRoot,
181
+ stderr: "pipe",
182
+ env: {
183
+ ...process.env,
184
+ [ENVIRONMENT_VARIABLE.CLIENT_IDLE_TIMEOUT_MS]: String(BENCHMARK.CLIENT_IDLE_TIMEOUT_MS),
185
+ },
186
+ });
187
+
188
+ transport.stderr?.on(PROCESS_EVENT.DATA, (chunk) => {
189
+ stderrBuffer += chunk.toString();
190
+ const lines = stderrBuffer.split("\n");
191
+ stderrBuffer = lines.pop() || "";
192
+ for (const line of lines) {
193
+ const providerMatch = PROVIDER_START_PATTERN.exec(line);
194
+ if (providerMatch) providerPids.push(Number(providerMatch[1]));
195
+ const memoryOffset = line.indexOf(LIFECYCLE_MEMORY_OBSERVER.PREFIX);
196
+ if (memoryOffset < 0) continue;
197
+ try {
198
+ memorySnapshots.push(JSON.parse(line.slice(memoryOffset + LIFECYCLE_MEMORY_OBSERVER.PREFIX.length)));
199
+ } catch {
200
+ // A malformed observer line cannot establish memory evidence.
201
+ }
202
+ }
203
+ });
204
+
205
+ const cycles = [];
206
+ try {
207
+ await client.connect(transport);
208
+ await waitUntil(() => memorySnapshots.at(-1), "Initial MCP memory snapshot was not observed");
209
+
210
+ for (let cycle = 0; cycle < BENCHMARK.CYCLES; cycle += 1) {
211
+ const response = await client.callTool({
212
+ name: TOOL.DOCUMENT_SYMBOLS,
213
+ arguments: {file: fixture.file, root: fixture.workspace},
214
+ });
215
+ assert(!response.isError, response.content?.[0]?.text || `${TOOL.DOCUMENT_SYMBOLS} failed`);
216
+ const providerPid = await waitUntil(() => providerPids[cycle], `Provider was not observed for cycle ${cycle + 1}`);
217
+ const providerResidentSetBytes = await readResidentSetBytes(providerPid);
218
+ await waitUntil(() => !processExists(providerPid), `Provider was not disposed for cycle ${cycle + 1}`);
219
+ const disposedAt = Date.now();
220
+ const memory = await waitUntil(
221
+ () => memorySnapshots.findLast((snapshot) => snapshot.recordedAtEpochMilliseconds > disposedAt),
222
+ `Post-disposal MCP memory was not observed for cycle ${cycle + 1}`,
223
+ );
224
+ cycles.push({cycle: cycle + 1, providerResidentSetBytes, providerDisposed: true, mcp: memory});
225
+ }
226
+
227
+ const measured = cycles.slice(BENCHMARK.WARMUP_CYCLES).map((cycle) => cycle.mcp);
228
+ const heap = assessTrend(measured, LIFECYCLE_MEMORY_FIELD.HEAP_USED_BYTES, {
229
+ medianMinimumBytes: BENCHMARK.HEAP_MEDIAN_GROWTH_MINIMUM_BYTES,
230
+ medianRatio: BENCHMARK.HEAP_MEDIAN_GROWTH_RATIO,
231
+ slopeMinimumBytes: BENCHMARK.HEAP_SLOPE_MINIMUM_BYTES_PER_CYCLE,
232
+ slopeRatio: BENCHMARK.HEAP_SLOPE_RATIO_PER_CYCLE,
233
+ });
234
+ const residentSet = assessTrend(measured, LIFECYCLE_MEMORY_FIELD.RESIDENT_SET_BYTES, {
235
+ medianMinimumBytes: BENCHMARK.RSS_MEDIAN_GROWTH_MINIMUM_BYTES,
236
+ medianRatio: BENCHMARK.RSS_MEDIAN_GROWTH_RATIO,
237
+ slopeMinimumBytes: BENCHMARK.RSS_SLOPE_MINIMUM_BYTES_PER_CYCLE,
238
+ slopeRatio: BENCHMARK.RSS_SLOPE_RATIO_PER_CYCLE,
239
+ });
240
+ const status =
241
+ heap.status === LIFECYCLE_MEMORY_STATUS.PASS && residentSet.status === LIFECYCLE_MEMORY_STATUS.PASS
242
+ ? LIFECYCLE_MEMORY_STATUS.PASS
243
+ : LIFECYCLE_MEMORY_STATUS.FAIL;
244
+ return {
245
+ product: PRODUCT.NAME,
246
+ status,
247
+ method: {
248
+ cycles: BENCHMARK.CYCLES,
249
+ warmupCyclesExcluded: BENCHMARK.WARMUP_CYCLES,
250
+ comparisonWindowCycles: BENCHMARK.COMPARISON_WINDOW,
251
+ garbageCollection: LIFECYCLE_MEMORY_METHOD.GARBAGE_COLLECTION,
252
+ providerMemory: LIFECYCLE_MEMORY_METHOD.PROVIDER_MEMORY,
253
+ allocatorRetention: LIFECYCLE_MEMORY_METHOD.ALLOCATOR_RETENTION,
254
+ platform: {current: process.platform, supported: [...SUPPORTED_PLATFORM]},
255
+ },
256
+ assessment: {heap, residentSet},
257
+ summaries: {
258
+ mcpHeapUsed: summarize(measured.map((sample) => sample.heapUsedBytes)),
259
+ mcpResidentSet: summarize(measured.map((sample) => sample.residentSetBytes)),
260
+ mcpAllocatorAndNativeResidentProxy: summarize(measured.map((sample) => sample.allocatorAndNativeResidentProxyBytes)),
261
+ childProviderResidentSet: summarize(cycles.map((cycle) => cycle.providerResidentSetBytes)),
262
+ },
263
+ samples: cycles.map((cycle) => ({
264
+ cycle: cycle.cycle,
265
+ providerResidentSetBytes: cycle.providerResidentSetBytes,
266
+ providerDisposed: cycle.providerDisposed,
267
+ mcpHeapUsedBytes: cycle.mcp.heapUsedBytes,
268
+ mcpResidentSetBytes: cycle.mcp.residentSetBytes,
269
+ mcpAllocatorAndNativeResidentProxyBytes: cycle.mcp.allocatorAndNativeResidentProxyBytes,
270
+ })),
271
+ };
272
+ } finally {
273
+ await client.close().catch(() => undefined);
274
+ await rm(fixture.workspace, {recursive: true, force: true});
275
+ }
276
+ }
277
+
278
+ assertAssessmentContract();
279
+ let output;
280
+ if (!SUPPORTED_PLATFORM.has(process.platform)) {
281
+ output = blockedOutput(LIFECYCLE_MEMORY_REASON.PLATFORM_UNSUPPORTED);
282
+ } else {
283
+ try {
284
+ await readResidentSetBytes(process.pid);
285
+ } catch (error) {
286
+ output = blockedOutput(
287
+ error?.code === PROCESS_ERROR.COMMAND_NOT_FOUND
288
+ ? LIFECYCLE_MEMORY_REASON.PROCESS_COMMAND_UNAVAILABLE
289
+ : LIFECYCLE_MEMORY_REASON.PROCESS_OBSERVATION_UNAVAILABLE,
290
+ );
291
+ }
292
+ if (!output) output = await runBenchmark();
293
+ }
294
+ process.stdout.write(`${JSON.stringify(output, null, 2)}\n`);
295
+ process.exitCode =
296
+ output.status === LIFECYCLE_MEMORY_STATUS.PASS
297
+ ? LIFECYCLE_MEMORY_EXIT_CODE.PASS
298
+ : output.status === LIFECYCLE_MEMORY_STATUS.BLOCKED
299
+ ? LIFECYCLE_MEMORY_EXIT_CODE.BLOCKED
300
+ : LIFECYCLE_MEMORY_EXIT_CODE.FAIL;
@@ -0,0 +1,53 @@
1
+ export const LIFECYCLE_MEMORY_STATUS = Object.freeze({BLOCKED: "blocked", FAIL: "fail", PASS: "pass"});
2
+
3
+ export const LIFECYCLE_MEMORY_EXIT_CODE = Object.freeze({PASS: 0, FAIL: 1, BLOCKED: 2});
4
+
5
+ export const LIFECYCLE_MEMORY_REASON = Object.freeze({
6
+ PLATFORM_UNSUPPORTED: "provider-rss-observation-requires-posix-ps",
7
+ PROCESS_COMMAND_UNAVAILABLE: "posix-ps-command-unavailable",
8
+ PROCESS_OBSERVATION_UNAVAILABLE: "provider-rss-observation-unavailable",
9
+ });
10
+
11
+ export const LIFECYCLE_MEMORY_METHOD = Object.freeze({
12
+ GARBAGE_COLLECTION: "explicit-before-each-mcp-snapshot",
13
+ PROVIDER_MEMORY: "posix-ps-rss",
14
+ ALLOCATOR_RETENTION: "rss-minus-v8-heap-total-proxy-includes-native-memory",
15
+ });
16
+
17
+ export const LIFECYCLE_MEMORY_FIELD = Object.freeze({
18
+ HEAP_USED_BYTES: "heapUsedBytes",
19
+ RESIDENT_SET_BYTES: "residentSetBytes",
20
+ SYNTHETIC_VALUE: "value",
21
+ });
22
+
23
+ export const LIFECYCLE_MEMORY_OBSERVER = Object.freeze({
24
+ INTERVAL_MILLISECONDS: 100,
25
+ PREFIX: "semantic-js-mcp-memory:",
26
+ });
27
+
28
+ const MEBIBYTE = 1024 * 1024;
29
+
30
+ export const LIFECYCLE_MEMORY_BENCHMARK = Object.freeze({
31
+ CYCLES: 12,
32
+ WARMUP_CYCLES: 2,
33
+ COMPARISON_WINDOW: 3,
34
+ CLIENT_IDLE_TIMEOUT_MS: 300,
35
+ OBSERVATION_ATTEMPTS: 200,
36
+ OBSERVATION_INTERVAL_MS: 50,
37
+ HEAP_MEDIAN_GROWTH_MINIMUM_BYTES: 4 * MEBIBYTE,
38
+ HEAP_MEDIAN_GROWTH_RATIO: 0.15,
39
+ HEAP_SLOPE_MINIMUM_BYTES_PER_CYCLE: 256 * 1024,
40
+ HEAP_SLOPE_RATIO_PER_CYCLE: 0.01,
41
+ RSS_MEDIAN_GROWTH_MINIMUM_BYTES: 16 * MEBIBYTE,
42
+ RSS_MEDIAN_GROWTH_RATIO: 0.15,
43
+ RSS_SLOPE_MINIMUM_BYTES_PER_CYCLE: MEBIBYTE,
44
+ RSS_SLOPE_RATIO_PER_CYCLE: 0.01,
45
+ SYNTHETIC_SAMPLE_COUNT: 10,
46
+ SYNTHETIC_BASE_BYTES: 1_000,
47
+ SYNTHETIC_STABLE_VARIATION_BYTES: 2,
48
+ SYNTHETIC_GROWTH_BYTES_PER_CYCLE: 50,
49
+ SYNTHETIC_MEDIAN_LIMIT_BYTES: 100,
50
+ SYNTHETIC_SLOPE_LIMIT_BYTES: 10,
51
+ });
52
+
53
+ export const LIFECYCLE_MEMORY_SUPPORTED_PLATFORMS = Object.freeze(["darwin", "linux"]);
@@ -0,0 +1,29 @@
1
+ #!/usr/bin/env node
2
+
3
+ import {LIFECYCLE_MEMORY_OBSERVER} from "./lifecycle-memory-contract.mjs";
4
+
5
+ if (typeof global.gc !== "function") {
6
+ throw new Error("Lifecycle memory observation requires Node.js --expose-gc");
7
+ }
8
+
9
+ function reportMemory() {
10
+ global.gc();
11
+ const usage = process.memoryUsage();
12
+ process.stderr.write(
13
+ `${LIFECYCLE_MEMORY_OBSERVER.PREFIX}${JSON.stringify({
14
+ recordedAtEpochMilliseconds: Date.now(),
15
+ heapUsedBytes: usage.heapUsed,
16
+ heapTotalBytes: usage.heapTotal,
17
+ residentSetBytes: usage.rss,
18
+ externalBytes: usage.external,
19
+ arrayBufferBytes: usage.arrayBuffers,
20
+ allocatorAndNativeResidentProxyBytes: Math.max(0, usage.rss - usage.heapTotal),
21
+ })}\n`,
22
+ );
23
+ }
24
+
25
+ const observationTimer = setInterval(reportMemory, LIFECYCLE_MEMORY_OBSERVER.INTERVAL_MILLISECONDS);
26
+ observationTimer.unref();
27
+ reportMemory();
28
+
29
+ await import("../server.mjs");
@@ -0,0 +1,309 @@
1
+ #!/usr/bin/env node
2
+
3
+ import path from "node:path";
4
+ import {mkdtemp, mkdir, writeFile} from "node:fs/promises";
5
+ import {tmpdir} from "node:os";
6
+ import {fileURLToPath} from "node:url";
7
+ import {Client} from "@modelcontextprotocol/sdk/client/index.js";
8
+ import {StdioClientTransport} from "@modelcontextprotocol/sdk/client/stdio.js";
9
+ import {PendingRequestRegistry} from "../lib/pending-requests.mjs";
10
+ import {removeTemporaryDirectory} from "../lib/temporary-directory.mjs";
11
+ import {ENVIRONMENT_VARIABLE, PRODUCT, TOOL} from "../protocol.mjs";
12
+
13
+ const CHECK_STATUS = Object.freeze({OK: "ok"});
14
+ const PROCESS_ERROR = Object.freeze({NOT_FOUND: "ESRCH"});
15
+ const PROCESS_EVENT = Object.freeze({DATA: "data"});
16
+ const PROCESS_SIGNAL = Object.freeze({EXISTS: 0, TERMINATE: "SIGTERM"});
17
+ const LIFECYCLE_PHASE = Object.freeze({
18
+ CONNECT: "connect",
19
+ IDLE_DISPOSAL: "idle-disposal",
20
+ LRU_EVICTION: "lru-eviction",
21
+ PROVIDER_RECOVERY: "provider-recovery",
22
+ VUE_BRIDGE_RECOVERY: "vue-bridge-recovery",
23
+ });
24
+ const TIMING = Object.freeze({
25
+ CLIENT_IDLE_TIMEOUT_MS: 300,
26
+ CLIENT_MINIMUM_EVICTION_AGE_MS: 50,
27
+ EVICTION_AGE_WAIT_MS: 75,
28
+ MAXIMUM_ACTIVE_CLIENTS: 1,
29
+ OBSERVATION_ATTEMPTS: 160,
30
+ OBSERVATION_INTERVAL_MS: 50,
31
+ PENDING_SETTLEMENT_TIMEOUT_MS: 1_000,
32
+ REGISTRY_OBSERVATION_WAIT_MS: 75,
33
+ REGISTRY_TIMEOUT_MS: 25,
34
+ REQUEST_START_WAIT_MS: 25,
35
+ });
36
+ const PROVIDER_START_PATTERN = /starting provider (\d+)/;
37
+ const BRIDGE_START_PATTERN = /starting bridge (\d+)/;
38
+ const pluginRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
39
+
40
+ function assert(condition, message) {
41
+ if (!condition) throw new Error(message);
42
+ }
43
+
44
+ const delay = (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds));
45
+
46
+ async function waitUntil(predicate, message) {
47
+ for (let attempt = 0; attempt < TIMING.OBSERVATION_ATTEMPTS; attempt += 1) {
48
+ if (await predicate()) return;
49
+ await delay(TIMING.OBSERVATION_INTERVAL_MS);
50
+ }
51
+ throw new Error(message);
52
+ }
53
+
54
+ function processExists(pid) {
55
+ try {
56
+ process.kill(pid, PROCESS_SIGNAL.EXISTS);
57
+ return true;
58
+ } catch (error) {
59
+ if (error?.code === PROCESS_ERROR.NOT_FOUND) return false;
60
+ throw error;
61
+ }
62
+ }
63
+
64
+ async function createWorkspace(name, functionName) {
65
+ const workspace = await mkdtemp(path.join(tmpdir(), `semantic-js-mcp-lifecycle-${name}-`));
66
+ const sourceDirectory = path.join(workspace, "src");
67
+ await mkdir(sourceDirectory, {recursive: true});
68
+ await writeFile(path.join(workspace, "package.json"), JSON.stringify({private: true, type: "module"}));
69
+ await writeFile(
70
+ path.join(workspace, "tsconfig.json"),
71
+ JSON.stringify({
72
+ compilerOptions: {strict: true, target: "ES2022", module: "ESNext", moduleResolution: "Bundler"},
73
+ include: ["src/**/*.ts"],
74
+ }),
75
+ );
76
+ const file = path.join(sourceDirectory, "target.ts");
77
+ await writeFile(file, `export function ${functionName}(): string { return "${name}"; }\n`);
78
+ return {file, functionName, workspace};
79
+ }
80
+
81
+ async function createVueWorkspace() {
82
+ const fixture = await createWorkspace("vue", "bridgeLifecycleTarget");
83
+ const vueFile = path.join(path.dirname(fixture.file), "target.vue");
84
+ await writeFile(
85
+ path.join(fixture.workspace, "tsconfig.json"),
86
+ JSON.stringify({
87
+ compilerOptions: {strict: true, target: "ES2022", module: "ESNext", moduleResolution: "Bundler"},
88
+ include: ["src/**/*.vue"],
89
+ }),
90
+ );
91
+ await writeFile(
92
+ vueFile,
93
+ [
94
+ '<script setup lang="ts">',
95
+ "function bridgeLifecycleTarget(): number { return 1; }",
96
+ 'const unresolvedText = "bridgeLifecycleTarget";',
97
+ "</script>",
98
+ "<template>{{ bridgeLifecycleTarget() }} {{ unresolvedText }}</template>",
99
+ ].join("\n"),
100
+ );
101
+ return {...fixture, file: vueFile};
102
+ }
103
+
104
+ async function assertDocumentSymbol(client, fixture) {
105
+ const response = await client.callTool({
106
+ name: TOOL.DOCUMENT_SYMBOLS,
107
+ arguments: {file: fixture.file, root: fixture.workspace},
108
+ });
109
+ assert(!response.isError, response.content?.[0]?.text || `${TOOL.DOCUMENT_SYMBOLS} failed`);
110
+ assert(
111
+ response.structuredContent?.result?.symbols?.some((symbol) => symbol.name === fixture.functionName),
112
+ `Document symbol missing: ${fixture.functionName}`,
113
+ );
114
+ }
115
+
116
+ async function callUntilRecovered(client, fixture) {
117
+ let lastError;
118
+ for (let attempt = 0; attempt < TIMING.OBSERVATION_ATTEMPTS; attempt += 1) {
119
+ try {
120
+ await assertDocumentSymbol(client, fixture);
121
+ return;
122
+ } catch (error) {
123
+ lastError = error;
124
+ await delay(TIMING.OBSERVATION_INTERVAL_MS);
125
+ }
126
+ }
127
+ throw lastError || new Error("Provider did not recover");
128
+ }
129
+
130
+ async function auditUntilBridgeRecovered(client, fixture) {
131
+ let lastError;
132
+ for (let attempt = 0; attempt < TIMING.OBSERVATION_ATTEMPTS; attempt += 1) {
133
+ try {
134
+ const response = await client.callTool({
135
+ name: TOOL.AUDIT_NAMED_SYMBOL,
136
+ arguments: {root: fixture.workspace, symbol: fixture.functionName},
137
+ });
138
+ if (!response.isError) return;
139
+ lastError = new Error(response.content?.[0]?.text || `${TOOL.AUDIT_NAMED_SYMBOL} failed after bridge exit`);
140
+ } catch (error) {
141
+ lastError = error;
142
+ }
143
+ await delay(TIMING.OBSERVATION_INTERVAL_MS);
144
+ }
145
+ throw lastError || new Error("Vue tsserver bridge did not recover");
146
+ }
147
+
148
+ async function settlesWithin(promise, milliseconds) {
149
+ let timer;
150
+ const timeout = new Promise((resolve) => {
151
+ timer = setTimeout(() => resolve(false), milliseconds);
152
+ });
153
+ try {
154
+ return await Promise.race([
155
+ promise.then(
156
+ () => true,
157
+ () => true,
158
+ ),
159
+ timeout,
160
+ ]);
161
+ } finally {
162
+ clearTimeout(timer);
163
+ }
164
+ }
165
+
166
+ async function assertPendingRequestRegistry() {
167
+ const registry = new PendingRequestRegistry({
168
+ timeoutMilliseconds: TIMING.REGISTRY_TIMEOUT_MS,
169
+ timeoutMessage: (operation) => `timed out: ${operation}`,
170
+ });
171
+ const timeoutResult = await registry.create("timeout", "timeout-operation").then(
172
+ () => undefined,
173
+ (error) => error,
174
+ );
175
+ assert(timeoutResult?.message === "timed out: timeout-operation", "Pending request timeout used the wrong error");
176
+ assert(registry.size === 0, "Timed-out request remained registered");
177
+
178
+ const resolved = registry.create("resolved", "resolved-operation");
179
+ registry.take("resolved")?.resolve("resolved-value");
180
+ assert((await resolved) === "resolved-value", "Taken request did not resolve");
181
+ await delay(TIMING.REGISTRY_OBSERVATION_WAIT_MS);
182
+ assert(registry.size === 0, "Resolved request retained its timeout");
183
+
184
+ const rejected = registry.create("rejected", "rejected-operation").then(
185
+ () => undefined,
186
+ (error) => error,
187
+ );
188
+ const rejection = new Error("provider exited");
189
+ registry.rejectAll(rejection);
190
+ assert((await rejected) === rejection, "Bulk rejection did not preserve the provider error");
191
+ await delay(TIMING.REGISTRY_OBSERVATION_WAIT_MS);
192
+ assert(registry.size === 0, "Bulk rejection retained pending requests or timers");
193
+ }
194
+
195
+ const fixtureA = await createWorkspace("a", "lifecycleTargetA");
196
+ const fixtureB = await createWorkspace("b", "lifecycleTargetB");
197
+ const vueFixture = await createVueWorkspace();
198
+ const providerPids = [];
199
+ const bridgePids = [];
200
+ let lifecyclePhase = LIFECYCLE_PHASE.CONNECT;
201
+ let serverStderr = "";
202
+ let stderrLineBuffer = "";
203
+ const client = new Client({name: "semantic-js-mcp-lifecycle-smoke", version: "1.0.0"});
204
+ const transport = new StdioClientTransport({
205
+ command: process.execPath,
206
+ args: [path.join(pluginRoot, "server.mjs")],
207
+ cwd: pluginRoot,
208
+ stderr: "pipe",
209
+ env: {
210
+ ...process.env,
211
+ [ENVIRONMENT_VARIABLE.CLIENT_IDLE_TIMEOUT_MS]: String(TIMING.CLIENT_IDLE_TIMEOUT_MS),
212
+ [ENVIRONMENT_VARIABLE.CLIENT_MINIMUM_EVICTION_AGE_MS]: String(TIMING.CLIENT_MINIMUM_EVICTION_AGE_MS),
213
+ [ENVIRONMENT_VARIABLE.MAXIMUM_ACTIVE_CLIENTS]: String(TIMING.MAXIMUM_ACTIVE_CLIENTS),
214
+ },
215
+ });
216
+
217
+ transport.stderr?.on(PROCESS_EVENT.DATA, (chunk) => {
218
+ const text = chunk.toString();
219
+ serverStderr += text;
220
+ stderrLineBuffer += text;
221
+ const lines = stderrLineBuffer.split("\n");
222
+ stderrLineBuffer = lines.pop() || "";
223
+ for (const line of lines) {
224
+ const match = PROVIDER_START_PATTERN.exec(line);
225
+ if (match) providerPids.push(Number(match[1]));
226
+ const bridgeMatch = BRIDGE_START_PATTERN.exec(line);
227
+ if (bridgeMatch) bridgePids.push(Number(bridgeMatch[1]));
228
+ }
229
+ });
230
+
231
+ try {
232
+ await assertPendingRequestRegistry();
233
+ await client.connect(transport);
234
+
235
+ lifecyclePhase = LIFECYCLE_PHASE.IDLE_DISPOSAL;
236
+ await assertDocumentSymbol(client, fixtureA);
237
+ await waitUntil(() => providerPids.length >= 1, "Cold-start provider was not observed");
238
+ const initialProvider = providerPids[0];
239
+ await waitUntil(() => !processExists(initialProvider), "Idle provider was not disposed after its TTL");
240
+
241
+ await assertDocumentSymbol(client, fixtureA);
242
+ await waitUntil(() => providerPids.length >= 2, "Provider was not restarted after idle disposal");
243
+ const replacementAfterIdle = providerPids[1];
244
+ assert(replacementAfterIdle !== initialProvider, "Idle disposal reused the exited provider process");
245
+
246
+ lifecyclePhase = LIFECYCLE_PHASE.LRU_EVICTION;
247
+ await delay(TIMING.EVICTION_AGE_WAIT_MS);
248
+ await assertDocumentSymbol(client, fixtureB);
249
+ await waitUntil(() => providerPids.length >= 3, "Second workspace provider was not observed");
250
+ await waitUntil(() => !processExists(replacementAfterIdle), "Least-recently-used provider was not evicted at capacity");
251
+ const providerBeforeExit = providerPids[2];
252
+
253
+ lifecyclePhase = LIFECYCLE_PHASE.PROVIDER_RECOVERY;
254
+ const pendingDiagnostics = client.callTool({
255
+ name: TOOL.DIAGNOSTICS,
256
+ arguments: {file: fixtureB.file, root: fixtureB.workspace},
257
+ });
258
+ await delay(TIMING.REQUEST_START_WAIT_MS);
259
+ process.kill(providerBeforeExit, PROCESS_SIGNAL.TERMINATE);
260
+ await waitUntil(() => !processExists(providerBeforeExit), "Provider did not exit after the test signal");
261
+ assert(
262
+ await settlesWithin(pendingDiagnostics, TIMING.PENDING_SETTLEMENT_TIMEOUT_MS),
263
+ "Pending diagnostics did not settle promptly after provider exit",
264
+ );
265
+ await callUntilRecovered(client, fixtureB);
266
+ await waitUntil(() => providerPids.length >= 4, "Unexpected provider exit did not create a replacement");
267
+ assert(providerPids[3] !== providerBeforeExit, "Provider recovery reused the exited process");
268
+
269
+ lifecyclePhase = LIFECYCLE_PHASE.VUE_BRIDGE_RECOVERY;
270
+ await delay(TIMING.EVICTION_AGE_WAIT_MS);
271
+ await assertDocumentSymbol(client, vueFixture);
272
+ await waitUntil(() => bridgePids.length >= 1, "Vue tsserver bridge was not observed");
273
+ const bridgeBeforeExit = bridgePids[0];
274
+ process.kill(bridgeBeforeExit, PROCESS_SIGNAL.TERMINATE);
275
+ await waitUntil(() => !processExists(bridgeBeforeExit), "Vue tsserver bridge did not exit after the test signal");
276
+ await auditUntilBridgeRecovered(client, vueFixture);
277
+ await waitUntil(() => bridgePids.length >= 2, "Vue tsserver bridge exit did not create a replacement");
278
+ assert(bridgePids[1] !== bridgeBeforeExit, "Vue tsserver bridge recovery reused the exited process");
279
+
280
+ process.stdout.write(
281
+ `${JSON.stringify(
282
+ {
283
+ product: PRODUCT.NAME,
284
+ deterministicRequestTimeout: CHECK_STATUS.OK,
285
+ coldStart: CHECK_STATUS.OK,
286
+ idleClientDisposal: CHECK_STATUS.OK,
287
+ leastRecentlyUsedClientEviction: CHECK_STATUS.OK,
288
+ pendingWorkSettlement: CHECK_STATUS.OK,
289
+ providerExitRecovery: CHECK_STATUS.OK,
290
+ vueTsserverBridgeRecovery: CHECK_STATUS.OK,
291
+ },
292
+ null,
293
+ 2,
294
+ )}\n`,
295
+ );
296
+ } catch (error) {
297
+ const recentServerStderr = serverStderr.trim().split("\n").slice(-40).join("\n");
298
+ throw new Error(
299
+ `${error instanceof Error ? error.message : String(error)}\nLifecycle phase: ${lifecyclePhase}\nServer stderr:\n${recentServerStderr || "(empty)"}`,
300
+ {cause: error},
301
+ );
302
+ } finally {
303
+ await client.close().catch(() => undefined);
304
+ await Promise.all([
305
+ removeTemporaryDirectory(fixtureA.workspace),
306
+ removeTemporaryDirectory(fixtureB.workspace),
307
+ removeTemporaryDirectory(vueFixture.workspace),
308
+ ]);
309
+ }