semantic-js-mcp 0.8.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.
@@ -0,0 +1,686 @@
1
+ #!/usr/bin/env node
2
+
3
+ import path from "node:path";
4
+ import {deepStrictEqual} from "node:assert";
5
+ import {mkdtemp, mkdir, readFile, 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 {parse as parseYaml} from "yaml";
11
+ import {
12
+ ACCOUNTING_STATUS,
13
+ COLLECTION_STATUS,
14
+ CONTENT_FRESHNESS,
15
+ DEFINITION_MATCH,
16
+ EVIDENCE_STATUS,
17
+ DIAGNOSTIC_FRESHNESS,
18
+ ENVIRONMENT_VARIABLE,
19
+ ERROR_CODE,
20
+ FORBIDDEN_PUBLIC_FIELD,
21
+ PRESENTATION_MODE,
22
+ PRODUCT,
23
+ REFERENCE_SET_CHANGE_TYPE,
24
+ RESULT_SCHEMA,
25
+ SERVER_VERSION,
26
+ SIGNATURE_SOURCE,
27
+ TOOL,
28
+ TOOL_ORDER,
29
+ UNRESOLVED_REFERENCE_REASON,
30
+ } from "../protocol.mjs";
31
+
32
+ const pluginRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
33
+ const expectedTools = [...TOOL_ORDER];
34
+ const forbiddenPublicKeys = new Set(FORBIDDEN_PUBLIC_FIELD);
35
+
36
+ function assert(condition, message) {
37
+ if (!condition) throw new Error(message);
38
+ }
39
+
40
+ function assertNoAmbiguousKeys(value, location = "structuredContent") {
41
+ if (Array.isArray(value)) {
42
+ value.forEach((item, index) => assertNoAmbiguousKeys(item, `${location}.${index}`));
43
+ return;
44
+ }
45
+ if (!value || typeof value !== "object") return;
46
+ for (const [key, item] of Object.entries(value)) {
47
+ assert(!forbiddenPublicKeys.has(key), `Ambiguous public key ${location}.${key}`);
48
+ assertNoAmbiguousKeys(item, `${location}.${key}`);
49
+ }
50
+ }
51
+
52
+ function assertResult(response, tool) {
53
+ assert(!response.isError, response.content?.[0]?.text || `${tool} failed`);
54
+ assert(response.structuredContent?.tool === tool, `${tool} omitted its canonical tool name`);
55
+ assert(response.structuredContent?.server?.name === PRODUCT.NAME, `${tool} omitted its canonical server name`);
56
+ assert(response.structuredContent?.server?.version === SERVER_VERSION, `${tool} omitted its canonical server version`);
57
+ assert(response.structuredContent?.resultSchema?.name === RESULT_SCHEMA.NAME, `${tool} omitted its canonical result schema name`);
58
+ assert(
59
+ response.structuredContent?.resultSchema?.version === RESULT_SCHEMA.VERSION,
60
+ `${tool} omitted its canonical result schema version`,
61
+ );
62
+ assert(response._meta?.resultSchema === RESULT_SCHEMA.NAME, `${tool} omitted result schema metadata`);
63
+ assert(response._meta?.resultSchemaVersion === RESULT_SCHEMA.VERSION, `${tool} returned the wrong result schema version`);
64
+ const yaml = response.content?.find((item) => item.type === "text")?.text || "";
65
+ assert(yaml.includes(`tool: ${tool}`), `${tool} did not provide YAML model text`);
66
+ assert(!yaml.trimStart().startsWith("{"), `${tool} rendered JSON instead of YAML model text`);
67
+ deepStrictEqual(parseYaml(yaml), response.structuredContent, `${tool} YAML and structured JSON differ`);
68
+ assertNoAmbiguousKeys(response.structuredContent);
69
+ return response.structuredContent;
70
+ }
71
+
72
+ function assertErrorResult(response, tool) {
73
+ assert(response.isError === true, `${tool} was expected to fail`);
74
+ const data = response.structuredContent;
75
+ assert(data?.tool === tool, `${tool} error omitted its canonical tool name`);
76
+ assert(data?.collection?.status === COLLECTION_STATUS.FAILED, `${tool} error omitted collection.status=failed`);
77
+ const yaml = response.content?.find((item) => item.type === "text")?.text || "";
78
+ deepStrictEqual(parseYaml(yaml), data, `${tool} error YAML and structured JSON differ`);
79
+ assertNoAmbiguousKeys(data);
80
+ return data;
81
+ }
82
+
83
+ const delay = (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds));
84
+
85
+ const workspace = await mkdtemp(path.join(tmpdir(), "semantic-js-mcp-generic-smoke-"));
86
+ const src = path.join(workspace, "src");
87
+ await mkdir(src, {recursive: true});
88
+ await writeFile(path.join(workspace, "package.json"), JSON.stringify({private: true, type: "module"}));
89
+ await writeFile(
90
+ path.join(workspace, "tsconfig.json"),
91
+ JSON.stringify({
92
+ compilerOptions: {strict: true, target: "ES2022", module: "ESNext", moduleResolution: "Bundler", experimentalDecorators: true},
93
+ include: ["src/**/*.ts"],
94
+ }),
95
+ );
96
+ const targetFile = path.join(src, "target.ts");
97
+ const usageFile = path.join(src, "usage.ts");
98
+ const unrelatedFile = path.join(src, "unrelated.ts");
99
+ const unresolvedFile = path.join(src, "unresolved.ts");
100
+ const futureReferenceFile = path.join(src, "future-reference.ts");
101
+ const decoratedFile = path.join(src, "decorated.ts");
102
+ const consumer = path.join(workspace, "packages", "consumer");
103
+ const consumerSrc = path.join(consumer, "src");
104
+ const consumerAliasFile = path.join(consumerSrc, "alias-usage.ts");
105
+ const moduleFile = path.join(src, "module.mjs");
106
+ await mkdir(consumerSrc, {recursive: true});
107
+ await writeFile(path.join(consumer, "package.json"), JSON.stringify({private: true, type: "module"}));
108
+ await writeFile(
109
+ path.join(consumer, "tsconfig.json"),
110
+ JSON.stringify({
111
+ compilerOptions: {
112
+ strict: true,
113
+ target: "ES2022",
114
+ module: "ESNext",
115
+ moduleResolution: "Bundler",
116
+ experimentalDecorators: true,
117
+ baseUrl: ".",
118
+ paths: {"@root/*": ["../../src/*"]},
119
+ },
120
+ include: ["src/**/*.ts", "../../src/**/*.ts"],
121
+ }),
122
+ );
123
+ await writeFile(
124
+ targetFile,
125
+ ["/** Returns the next integer. */", "export function repeatedTarget(value: number): number {", " return value + 1;", "}"].join("\n"),
126
+ );
127
+ const calls = Array.from({length: 250}, (_, index) => ` repeatedTarget(${index}),`).join("\n");
128
+ await writeFile(usageFile, ['import {repeatedTarget} from "./target.js";', "export const values = [", calls, "];"].join("\n"));
129
+ await writeFile(
130
+ unrelatedFile,
131
+ [
132
+ "export function repeatedTarget(value: string): string { return value; }",
133
+ 'export const unrelatedValue = repeatedTarget("separate symbol");',
134
+ ].join("\n"),
135
+ );
136
+ await writeFile(unresolvedFile, 'export const unresolvedText = "repeatedTarget";\n');
137
+ await writeFile(futureReferenceFile, "export const futureValue = 1;\n");
138
+ await writeFile(
139
+ decoratedFile,
140
+ [
141
+ "export function registered<T extends new (...args: any[]) => object>(target: T): T { return target; }",
142
+ "@registered",
143
+ "export class DecoratedService { run(): number { return 1; } }",
144
+ ].join("\n"),
145
+ );
146
+ await writeFile(
147
+ consumerAliasFile,
148
+ [
149
+ 'import {repeatedTarget} from "@root/target";',
150
+ 'import {DecoratedService} from "@root/decorated";',
151
+ "export const aliasValue = repeatedTarget(new DecoratedService().run());",
152
+ ].join("\n"),
153
+ );
154
+ await writeFile(moduleFile, "export function moduleFunction(value) { return value; }\n");
155
+
156
+ const client = new Client({name: "semantic-js-mcp-generic-smoke", version: "1.0.0"});
157
+ const transport = new StdioClientTransport({
158
+ command: process.execPath,
159
+ args: [path.join(pluginRoot, "server.mjs")],
160
+ cwd: pluginRoot,
161
+ env: {
162
+ ...process.env,
163
+ [ENVIRONMENT_VARIABLE.CLIENT_IDLE_TIMEOUT_MS]: "10000",
164
+ [ENVIRONMENT_VARIABLE.REFERENCE_SET_TTL_MS]: "2000",
165
+ [ENVIRONMENT_VARIABLE.MAXIMUM_CACHED_REFERENCE_LOCATIONS]: "300",
166
+ },
167
+ });
168
+
169
+ try {
170
+ await client.connect(transport);
171
+ const listed = await client.listTools();
172
+ assert(JSON.stringify(listed.tools.map((tool) => tool.name)) === JSON.stringify(expectedTools), "Tool order or tool set changed");
173
+
174
+ const symbols = assertResult(
175
+ await client.callTool({
176
+ name: "lsp_document_symbols",
177
+ arguments: {file: targetFile},
178
+ }),
179
+ "lsp_document_symbols",
180
+ );
181
+ assert(
182
+ symbols.result.symbols.some((symbol) => symbol.name === "repeatedTarget"),
183
+ "Document symbol missing",
184
+ );
185
+ assert(symbols.request.resultLimit.mode === "unlimited", "Omitted result limit must be represented as unlimited");
186
+
187
+ const moduleSymbols = assertResult(
188
+ await client.callTool({
189
+ name: TOOL.DOCUMENT_SYMBOLS,
190
+ arguments: {file: moduleFile, root: workspace},
191
+ }),
192
+ TOOL.DOCUMENT_SYMBOLS,
193
+ );
194
+ assert(
195
+ moduleSymbols.result.symbols.some((symbol) => symbol.name === "moduleFunction"),
196
+ "MJS document symbol missing",
197
+ );
198
+
199
+ const workspaceSymbols = assertResult(
200
+ await client.callTool({
201
+ name: "lsp_workspace_symbols",
202
+ arguments: {root: workspace, query: "repeatedTarget"},
203
+ }),
204
+ "lsp_workspace_symbols",
205
+ );
206
+ assert(workspaceSymbols.result.symbolsFound >= 2, "Expected both homonymous definitions");
207
+
208
+ const definition = assertResult(
209
+ await client.callTool({
210
+ name: "lsp_definition",
211
+ arguments: {file: usageFile, line: 3, column: 3},
212
+ }),
213
+ "lsp_definition",
214
+ );
215
+ assert(definition.result.definitionMatch === DEFINITION_MATCH.RESOLVED, "Definition was not resolved");
216
+ assert(
217
+ definition.result.definitions.some((item) => path.basename(item.file) === "target.ts"),
218
+ "Definition resolved to the wrong symbol",
219
+ );
220
+
221
+ const aliasDefinition = assertResult(
222
+ await client.callTool({
223
+ name: TOOL.DEFINITION,
224
+ arguments: {file: consumerAliasFile, root: workspace, line: 3, column: 27},
225
+ }),
226
+ TOOL.DEFINITION,
227
+ );
228
+ assert(
229
+ aliasDefinition.result.definitions.some((item) => path.basename(item.file) === "target.ts"),
230
+ "Cross-project path alias did not resolve to its definition",
231
+ );
232
+
233
+ const decoratedDefinition = assertResult(
234
+ await client.callTool({
235
+ name: TOOL.DEFINITION,
236
+ arguments: {file: consumerAliasFile, root: workspace, line: 2, column: 9},
237
+ }),
238
+ TOOL.DEFINITION,
239
+ );
240
+ assert(
241
+ decoratedDefinition.result.definitions.some((item) => path.basename(item.file) === "decorated.ts"),
242
+ "Decorated class imported through a path alias did not resolve",
243
+ );
244
+
245
+ const hover = assertResult(
246
+ await client.callTool({
247
+ name: "lsp_hover",
248
+ arguments: {file: targetFile, line: 2, column: 17},
249
+ }),
250
+ "lsp_hover",
251
+ );
252
+ assert(hover.result.typeAndDocumentation.includes("repeatedTarget"), "Hover omitted the function signature");
253
+
254
+ const initialDiagnostics = assertResult(
255
+ await client.callTool({
256
+ name: "lsp_diagnostics",
257
+ arguments: {file: targetFile},
258
+ }),
259
+ "lsp_diagnostics",
260
+ );
261
+ const initialDiagnosticsVerified = initialDiagnostics.result.evidence.status === EVIDENCE_STATUS.VERIFIED;
262
+ assert(
263
+ initialDiagnostics.collection.status === (initialDiagnosticsVerified ? COLLECTION_STATUS.COMPLETE : COLLECTION_STATUS.PARTIAL),
264
+ "Diagnostics evidence status and collection status disagree",
265
+ );
266
+ assert(initialDiagnostics.result.document.contentFingerprint.length === 64, "Diagnostics omitted the analyzed document fingerprint");
267
+ assert(
268
+ initialDiagnosticsVerified === (initialDiagnostics.result.diagnosticsForCurrentDocument !== null),
269
+ "Untrusted diagnostics appeared in the verified diagnostics field",
270
+ );
271
+ if (!initialDiagnosticsVerified) {
272
+ assert(initialDiagnostics.result.unconfirmedDiagnosticReport, "Untrusted diagnostics omitted their separated report");
273
+ }
274
+
275
+ const textCount = assertResult(
276
+ await client.callTool({
277
+ name: "lsp_count_text_matches",
278
+ arguments: {root: workspace, symbol: "repeatedTarget"},
279
+ }),
280
+ "lsp_count_text_matches",
281
+ );
282
+ assert(textCount.result.matchesFound >= 256, "Text count missed exact identifier matches");
283
+ assert(textCount.result.filesContainingMatches === 5, "Text count returned the wrong file count");
284
+ assert(textCount.result.semanticVerificationPerformed === false, "Text count claimed semantic verification");
285
+
286
+ const count = assertResult(
287
+ await client.callTool({
288
+ name: "lsp_count_named_symbol",
289
+ arguments: {root: workspace, symbol: "repeatedTarget", fileHint: "target.ts"},
290
+ }),
291
+ "lsp_count_named_symbol",
292
+ );
293
+ assert(count.presentation.mode === PRESENTATION_MODE.COUNT_ONLY, "Count tool returned locations");
294
+ assert(count.presentation.referenceLocationsReturned === 0, "Count tool returned reference locations");
295
+ assert(count.collection.status === COLLECTION_STATUS.PARTIAL, "Unresolved text match was not reported as partial");
296
+ assert(count.request.definitionLimit.mode === "unlimited", "Omitted definition limit was not represented as unlimited");
297
+ assert(count.request.candidateLimit.mode === "unlimited", "Omitted candidate limit was not represented as unlimited");
298
+ assert(count.result.definitions.length === 1, "fileHint did not select one homonymous definition");
299
+ const countedDefinition = count.result.definitions[0];
300
+ assert(countedDefinition.references.verifiedTotal >= 252, "Named count missed references");
301
+ assert(countedDefinition.textSearch.accountingStatus === ACCOUNTING_STATUS.COMPLETE, "Named count did not account for every text match");
302
+ assert(
303
+ countedDefinition.textSearch.matchesWhoseDefinitionCouldNotBeResolved >= 1,
304
+ "Unresolvable text match was incorrectly classified as a different symbol",
305
+ );
306
+ assert(
307
+ countedDefinition.references.verifiedFromOtherWorkspaces >= 1,
308
+ "Cross-project alias reference was not verified from its owning workspace",
309
+ );
310
+
311
+ const unresolvedPage = assertResult(
312
+ await client.callTool({
313
+ name: TOOL.UNRESOLVED_REFERENCE_PAGE,
314
+ arguments: {referenceSetId: countedDefinition.referenceSetId, pageSize: 10},
315
+ }),
316
+ TOOL.UNRESOLVED_REFERENCE_PAGE,
317
+ );
318
+ assert(unresolvedPage.result.candidates.length >= 1, "Unresolved-reference page omitted unresolved candidates");
319
+ assert(
320
+ unresolvedPage.result.candidates.some((candidate) => path.basename(candidate.file) === "unresolved.ts"),
321
+ "Unresolved-reference page omitted the source location",
322
+ );
323
+ assert(
324
+ unresolvedPage.result.candidates.every((candidate) => Object.values(UNRESOLVED_REFERENCE_REASON).includes(candidate.reason)),
325
+ "Unresolved-reference page returned an unknown reason",
326
+ );
327
+ assert(
328
+ unresolvedPage.result.candidates.every((candidate) => candidate.identifier === "repeatedTarget"),
329
+ "Unresolved-reference page omitted the textual identifier",
330
+ );
331
+
332
+ const audit = assertResult(
333
+ await client.callTool({
334
+ name: "lsp_audit_named_symbol",
335
+ arguments: {root: workspace, symbol: "repeatedTarget", fileHint: "target.ts"},
336
+ }),
337
+ "lsp_audit_named_symbol",
338
+ );
339
+ assert(audit.result.audits[0].collection.reusedPreviousCollection === true, "Audit did not reuse the compatible count collection");
340
+ assert(
341
+ audit.result.audits[0].referenceSetId === countedDefinition.referenceSetId,
342
+ "Audit changed the compatible reference-set identifier",
343
+ );
344
+
345
+ const positionCount = assertResult(
346
+ await client.callTool({
347
+ name: "lsp_count_references",
348
+ arguments: {file: targetFile, root: workspace, line: 2, column: 17},
349
+ }),
350
+ "lsp_count_references",
351
+ );
352
+ assert(positionCount.result.collection.reusedPreviousCollection === true, "Position count did not reuse the named collection");
353
+
354
+ const positionAudit = assertResult(
355
+ await client.callTool({
356
+ name: "lsp_audit_symbol",
357
+ arguments: {file: targetFile, root: workspace, line: 2, column: 17},
358
+ }),
359
+ "lsp_audit_symbol",
360
+ );
361
+ assert(positionAudit.result.collection.reusedPreviousCollection === true, "Position audit did not reuse the count collection");
362
+ assert(positionAudit.result.signature.length > 0, "Position audit omitted the resolved signature");
363
+ assert(
364
+ Object.values(SIGNATURE_SOURCE).includes(positionAudit.result.signatureSource),
365
+ "Position audit returned an unknown signature source",
366
+ );
367
+
368
+ const firstPage = assertResult(
369
+ await client.callTool({
370
+ name: "lsp_references",
371
+ arguments: {file: targetFile, root: workspace, line: 2, column: 17, pageSize: 25},
372
+ }),
373
+ "lsp_references",
374
+ );
375
+ assert(firstPage.collection.status === COLLECTION_STATUS.PARTIAL, "Reference collection did not preserve unresolved-match uncertainty");
376
+ assert(firstPage.presentation.mode === PRESENTATION_MODE.PAGE, "Reference response is not a page");
377
+ assert(firstPage.presentation.locationsReturned === 25, "Reference page size was not applied");
378
+ assert(firstPage.presentation.nextCursor === "25", "Reference page omitted its next cursor");
379
+ assert(
380
+ firstPage.result.locations.every((item) => item.discoveryMethod),
381
+ "Reference locations omitted literal discovery methods",
382
+ );
383
+
384
+ const secondPage = assertResult(
385
+ await client.callTool({
386
+ name: "lsp_reference_page",
387
+ arguments: {referenceSetId: firstPage.result.referenceSetId, cursor: firstPage.presentation.nextCursor, pageSize: 25},
388
+ }),
389
+ "lsp_reference_page",
390
+ );
391
+ assert(secondPage.presentation.offset === 25, "Second page used the wrong offset");
392
+ assert(secondPage.result.locations.length === 25, "Second page used the wrong size");
393
+ assert(secondPage.result.referenceSetId === firstPage.result.referenceSetId, "Pagination changed the reference set");
394
+
395
+ await writeFile(usageFile, `${await readFile(usageFile, "utf8")}\nexport const changedAfterCollection = true;\n`);
396
+ assertResult(
397
+ await client.callTool({
398
+ name: "lsp_document_symbols",
399
+ arguments: {file: usageFile},
400
+ }),
401
+ "lsp_document_symbols",
402
+ );
403
+ const stalePage = assertErrorResult(
404
+ await client.callTool({
405
+ name: "lsp_reference_page",
406
+ arguments: {referenceSetId: firstPage.result.referenceSetId, cursor: "0", pageSize: 1},
407
+ }),
408
+ "lsp_reference_page",
409
+ );
410
+ assert(
411
+ stalePage.error.code === ERROR_CODE.REFERENCE_SET_CONTENT_CHANGED,
412
+ "Changed reference set did not report a literal freshness failure",
413
+ );
414
+ assert(
415
+ stalePage.error.details.changedFiles.some((file) => path.basename(file) === path.basename(usageFile)),
416
+ `Freshness failure omitted the changed file: ${JSON.stringify(stalePage.error.details)}`,
417
+ );
418
+
419
+ const withoutDeclaration = assertResult(
420
+ await client.callTool({
421
+ name: TOOL.REFERENCES,
422
+ arguments: {file: usageFile, root: workspace, line: 3, column: 3, includeDeclaration: false, pageSize: 300},
423
+ }),
424
+ TOOL.REFERENCES,
425
+ );
426
+ assert(
427
+ withoutDeclaration.result.references.verifiedTotal ===
428
+ withoutDeclaration.result.references.foundByOwningWorkspaceLanguageServer +
429
+ withoutDeclaration.result.references.verifiedFromOtherWorkspaces,
430
+ "Reference source counts do not reconcile with the verified total",
431
+ );
432
+ assert(
433
+ withoutDeclaration.result.locations.some(
434
+ (location) =>
435
+ path.basename(location.file) === path.basename(usageFile) && location.range.start.line === 3 && location.range.start.column === 3,
436
+ ),
437
+ "includeDeclaration=false removed the originating usage",
438
+ );
439
+ assert(
440
+ !withoutDeclaration.result.locations.some(
441
+ (location) =>
442
+ path.basename(location.file) === path.basename(targetFile) && location.range.start.line === 2 && location.range.start.column === 17,
443
+ ),
444
+ "includeDeclaration=false retained the declaration",
445
+ );
446
+
447
+ const refreshedPage = assertResult(
448
+ await client.callTool({
449
+ name: "lsp_references",
450
+ arguments: {file: targetFile, root: workspace, line: 2, column: 17, pageSize: 25},
451
+ }),
452
+ "lsp_references",
453
+ );
454
+ assert(
455
+ refreshedPage.result.referenceSetId !== firstPage.result.referenceSetId,
456
+ "Fresh collection reused an obsolete reference-set identifier",
457
+ );
458
+ assert(
459
+ refreshedPage.collection.contentFreshness === CONTENT_FRESHNESS.VERIFIED_CURRENT,
460
+ "Fresh collection omitted content freshness evidence",
461
+ );
462
+
463
+ await writeFile(path.join(workspace, "jsconfig.json"), JSON.stringify({compilerOptions: {checkJs: true}}));
464
+ const staleAfterConfigurationCreation = assertErrorResult(
465
+ await client.callTool({
466
+ name: "lsp_reference_page",
467
+ arguments: {referenceSetId: refreshedPage.result.referenceSetId, cursor: "0", pageSize: 1},
468
+ }),
469
+ "lsp_reference_page",
470
+ );
471
+ assert(
472
+ staleAfterConfigurationCreation.error.code === ERROR_CODE.REFERENCE_SET_CONTENT_CHANGED,
473
+ "New workspace configuration did not invalidate semantic evidence",
474
+ );
475
+
476
+ const afterCreatedConfigurationPage = assertResult(
477
+ await client.callTool({
478
+ name: "lsp_references",
479
+ arguments: {file: targetFile, root: workspace, line: 2, column: 17, pageSize: 25},
480
+ }),
481
+ "lsp_references",
482
+ );
483
+
484
+ await writeFile(
485
+ path.join(workspace, "tsconfig.json"),
486
+ JSON.stringify({
487
+ compilerOptions: {strict: true, noEmit: true, target: "ES2022", module: "ESNext", moduleResolution: "Bundler"},
488
+ include: ["src/**/*.ts"],
489
+ }),
490
+ );
491
+ const staleAfterConfigurationChange = assertErrorResult(
492
+ await client.callTool({
493
+ name: "lsp_reference_page",
494
+ arguments: {referenceSetId: afterCreatedConfigurationPage.result.referenceSetId, cursor: "0", pageSize: 1},
495
+ }),
496
+ "lsp_reference_page",
497
+ );
498
+ assert(
499
+ staleAfterConfigurationChange.error.code === ERROR_CODE.REFERENCE_SET_CONTENT_CHANGED,
500
+ "Workspace configuration change did not invalidate semantic evidence",
501
+ );
502
+
503
+ const configurationRefreshedPage = assertResult(
504
+ await client.callTool({
505
+ name: "lsp_references",
506
+ arguments: {file: targetFile, root: workspace, line: 2, column: 17, pageSize: 25},
507
+ }),
508
+ "lsp_references",
509
+ );
510
+
511
+ await writeFile(
512
+ futureReferenceFile,
513
+ ['import {repeatedTarget} from "./target.js";', "export const futureValue = repeatedTarget(998);"].join("\n"),
514
+ );
515
+ const staleAfterPreviouslyUnrelatedFileChanged = assertErrorResult(
516
+ await client.callTool({
517
+ name: "lsp_reference_page",
518
+ arguments: {referenceSetId: configurationRefreshedPage.result.referenceSetId, cursor: "0", pageSize: 1},
519
+ }),
520
+ "lsp_reference_page",
521
+ );
522
+ assert(
523
+ staleAfterPreviouslyUnrelatedFileChanged.error.details.changeType === REFERENCE_SET_CHANGE_TYPE.REPOSITORY_SOURCE_INVENTORY_CHANGED,
524
+ "Previously unrelated source edit reported the wrong freshness reason",
525
+ );
526
+ assert(
527
+ staleAfterPreviouslyUnrelatedFileChanged.error.details.currentSourceFileCount ===
528
+ staleAfterPreviouslyUnrelatedFileChanged.error.details.previousSourceFileCount,
529
+ "Existing source edit unexpectedly changed inventory size",
530
+ );
531
+
532
+ const existingSourceRefreshedPage = assertResult(
533
+ await client.callTool({
534
+ name: "lsp_references",
535
+ arguments: {file: targetFile, root: workspace, line: 2, column: 17, pageSize: 25},
536
+ }),
537
+ "lsp_references",
538
+ );
539
+ assert(
540
+ existingSourceRefreshedPage.result.references.verifiedTotal > configurationRefreshedPage.result.references.verifiedTotal,
541
+ "Fresh collection did not include the reference added to an existing file",
542
+ );
543
+
544
+ await writeFile(
545
+ path.join(src, "new-reference.ts"),
546
+ ['import {repeatedTarget} from "./target.js";', "export const newReference = repeatedTarget(999);"].join("\n"),
547
+ );
548
+ const staleAfterNewSourceFile = assertErrorResult(
549
+ await client.callTool({
550
+ name: "lsp_reference_page",
551
+ arguments: {referenceSetId: existingSourceRefreshedPage.result.referenceSetId, cursor: "0", pageSize: 1},
552
+ }),
553
+ "lsp_reference_page",
554
+ );
555
+ assert(
556
+ staleAfterNewSourceFile.error.code === ERROR_CODE.REFERENCE_SET_CONTENT_CHANGED,
557
+ "New source file did not invalidate semantic evidence",
558
+ );
559
+ assert(
560
+ staleAfterNewSourceFile.error.details.changeType === REFERENCE_SET_CHANGE_TYPE.REPOSITORY_SOURCE_INVENTORY_CHANGED,
561
+ "New source file reported the wrong freshness reason",
562
+ );
563
+ assert(
564
+ staleAfterNewSourceFile.error.details.currentSourceFileCount > staleAfterNewSourceFile.error.details.previousSourceFileCount,
565
+ "New source file did not change the reported inventory size",
566
+ );
567
+
568
+ const sourceInventoryRefreshedPage = assertResult(
569
+ await client.callTool({
570
+ name: "lsp_references",
571
+ arguments: {file: targetFile, root: workspace, line: 2, column: 17, pageSize: 25},
572
+ }),
573
+ "lsp_references",
574
+ );
575
+ assert(
576
+ sourceInventoryRefreshedPage.result.references.verifiedTotal > configurationRefreshedPage.result.references.verifiedTotal,
577
+ "Fresh collection did not include the new source reference",
578
+ );
579
+
580
+ const limited = assertResult(
581
+ await client.callTool({
582
+ name: "lsp_count_references",
583
+ arguments: {file: targetFile, root: workspace, line: 2, column: 17, maxCandidates: 50},
584
+ }),
585
+ "lsp_count_references",
586
+ );
587
+ assert(limited.collection.status === COLLECTION_STATUS.LIMITED, "Explicit candidate limit was not reported as limited");
588
+ assert(limited.collection.stoppedByLimit === true, "Explicit candidate limit was not reported literally");
589
+ assert(
590
+ limited.result.textSearch.matchesFound > limited.result.textSearch.matchesChecked,
591
+ "Limited collection did not preserve the full text-match count",
592
+ );
593
+ assert(
594
+ limited.result.textSearch.accountingStatus === ACCOUNTING_STATUS.INCOMPLETE,
595
+ "Limited collection claimed to account for every match",
596
+ );
597
+
598
+ const evictedPage = assertErrorResult(
599
+ await client.callTool({
600
+ name: "lsp_reference_page",
601
+ arguments: {referenceSetId: sourceInventoryRefreshedPage.result.referenceSetId, cursor: "0", pageSize: 1},
602
+ }),
603
+ "lsp_reference_page",
604
+ );
605
+ assert(evictedPage.error.code === ERROR_CODE.REFERENCE_SET_NOT_FOUND_OR_EXPIRED, "Evicted reference set omitted its literal error code");
606
+
607
+ await delay(2200);
608
+ const expiredPage = assertErrorResult(
609
+ await client.callTool({
610
+ name: "lsp_reference_page",
611
+ arguments: {referenceSetId: limited.result.referenceSetId, cursor: "0", pageSize: 1},
612
+ }),
613
+ "lsp_reference_page",
614
+ );
615
+ assert(expiredPage.error.code === ERROR_CODE.REFERENCE_SET_NOT_FOUND_OR_EXPIRED, "Expired reference set omitted its literal error code");
616
+
617
+ await writeFile(
618
+ targetFile,
619
+ [
620
+ "/** Returns the next integer. */",
621
+ "export function repeatedTarget(value: number): number {",
622
+ " return value + missingAfterDiagnosticChange;",
623
+ "}",
624
+ ].join("\n"),
625
+ );
626
+ const changedDiagnostics = assertResult(
627
+ await client.callTool({
628
+ name: "lsp_diagnostics",
629
+ arguments: {file: targetFile},
630
+ }),
631
+ "lsp_diagnostics",
632
+ );
633
+ assert(
634
+ changedDiagnostics.result.document.version > initialDiagnostics.result.document.version,
635
+ "Changed diagnostics did not advance the open document version",
636
+ );
637
+ const changedReport = changedDiagnostics.result.diagnosticsForCurrentDocument || changedDiagnostics.result.unconfirmedDiagnosticReport;
638
+ assert(
639
+ changedReport.items.some((item) => item.message.includes("missingAfterDiagnosticChange")),
640
+ "Changed diagnostics did not report the introduced error",
641
+ );
642
+ if (changedDiagnostics.result.evidence.status === EVIDENCE_STATUS.UNTRUSTED) {
643
+ assert(changedDiagnostics.result.diagnosticsForCurrentDocument === null, "Untrusted changed diagnostics appeared as verified evidence");
644
+ assert(changedDiagnostics.collection.status === COLLECTION_STATUS.PARTIAL, "Untrusted changed diagnostics did not remain partial");
645
+ }
646
+
647
+ assertResult(
648
+ await client.callTool({
649
+ name: "lsp_document_symbols",
650
+ arguments: {file: targetFile},
651
+ }),
652
+ "lsp_document_symbols",
653
+ );
654
+
655
+ console.log(
656
+ JSON.stringify(
657
+ {
658
+ tools: expectedTools,
659
+ yamlRepresentation: "ok",
660
+ structuredJsonContract: "ok",
661
+ visibleServerAndSchemaVersion: "ok",
662
+ positionSignatureEvidence: "ok",
663
+ unlimitedCollection: "ok",
664
+ explicitLimitReporting: "ok",
665
+ countReuse: "ok",
666
+ cheapTextCount: "ok",
667
+ referencePagination: "ok",
668
+ referenceContentFreshness: "ok",
669
+ repositorySourceInventoryFreshness: "ok",
670
+ unresolvedClassification: "ok",
671
+ unresolvedCandidateEvidence: "ok",
672
+ crossProjectAliasAndDecorator: "ok",
673
+ declarationExclusionAccounting: "ok",
674
+ nodeModuleExtensions: "ok",
675
+ diagnosticVersionReporting: "ok",
676
+ automaticMemoryCleanup: "ok",
677
+ ambiguousPublicFields: "absent",
678
+ },
679
+ null,
680
+ 2,
681
+ ),
682
+ );
683
+ } finally {
684
+ await client.close().catch(() => undefined);
685
+ await rm(workspace, {recursive: true, force: true});
686
+ }