@mrclrchtr/supi-lsp 11.2.0 → 11.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (30) hide show
  1. package/README.md +40 -3
  2. package/node_modules/@mrclrchtr/supi-code-runtime/package.json +1 -1
  3. package/node_modules/@mrclrchtr/supi-code-runtime/src/api.ts +2 -1
  4. package/node_modules/@mrclrchtr/supi-code-runtime/src/capability/types.ts +3 -1
  5. package/node_modules/@mrclrchtr/supi-code-runtime/src/declaration-level.ts +34 -0
  6. package/node_modules/@mrclrchtr/supi-code-runtime/src/query-result.ts +9 -0
  7. package/node_modules/@mrclrchtr/supi-code-runtime/src/types.ts +13 -5
  8. package/node_modules/@mrclrchtr/supi-core/package.json +1 -1
  9. package/node_modules/@mrclrchtr/supi-core/src/child-web-tools.ts +110 -0
  10. package/node_modules/@mrclrchtr/supi-core/src/session.ts +11 -0
  11. package/node_modules/semver/classes/range.js +2 -2
  12. package/node_modules/semver/internal/constants.js +30 -0
  13. package/node_modules/semver/internal/trim-comparator-whitespace.js +438 -0
  14. package/node_modules/semver/package.json +1 -1
  15. package/package.json +3 -3
  16. package/src/client/client-diagnostic-collection.ts +11 -4
  17. package/src/client/client-diagnostics.ts +77 -6
  18. package/src/client/client-input-admission.ts +149 -0
  19. package/src/client/client-typescript-project.ts +40 -0
  20. package/src/client/client.ts +151 -44
  21. package/src/config/tsconfig-scope.ts +10 -0
  22. package/src/diagnostics/evidence.ts +2 -2
  23. package/src/manager/manager-typescript-coverage.ts +118 -0
  24. package/src/manager/manager-workspace-symbol.ts +157 -23
  25. package/src/manager/manager.ts +318 -58
  26. package/src/manager/typescript-file-local.ts +87 -0
  27. package/src/provider/semantic-symbol-mapper.ts +22 -17
  28. package/src/session/runtime-registry.ts +68 -6
  29. package/src/session/runtime-rename.ts +1 -0
  30. package/src/session/workspace-lsp-runtime.ts +5 -1
@@ -0,0 +1,149 @@
1
+ import {
2
+ type CodeRequestControl,
3
+ CodeRequestDeadlineError,
4
+ throwIfCodeRequestInterrupted,
5
+ } from "@mrclrchtr/supi-code-runtime/api";
6
+
7
+ const MAX_PENDING_INPUT_OPERATIONS = 32;
8
+
9
+ type InputOperationKind = "request" | "enrollment" | "request-enrollment";
10
+
11
+ interface PendingInputOperation {
12
+ readonly uri: string | undefined;
13
+ readonly kind: InputOperationKind;
14
+ start(): void;
15
+ cancel(reason: unknown): void;
16
+ }
17
+
18
+ /**
19
+ * Admit first opens between semantic query and refactor-planning attempts on one client.
20
+ * Consecutive readers run concurrently; FIFO admission prevents either new
21
+ * readers or enrollments from overtaking a waiting operation. This is not
22
+ * diagnostic transport ownership: an interrupted semantic attempt can release
23
+ * its admission even when the server has not yet acknowledged cancellation.
24
+ */
25
+ export class SemanticInputAdmission {
26
+ readonly #pending: PendingInputOperation[] = [];
27
+ #readers = 0;
28
+ #enrolling = false;
29
+ #disposed = false;
30
+
31
+ /** Cover one attempt's synchronization, RPCs and acceptance, never its retry. */
32
+ request<T>(run: () => Promise<T>, control?: CodeRequestControl, uri?: string): Promise<T> {
33
+ return this.#run("request", uri, run, control);
34
+ }
35
+
36
+ /**
37
+ * Commit a first open after earlier attempts leave. Demand with original
38
+ * semantic/refactor parameters opts into input invalidation while waiting.
39
+ */
40
+ enroll(
41
+ uri: string,
42
+ run: () => void,
43
+ control?: CodeRequestControl,
44
+ protectInputs = false,
45
+ ): Promise<void> {
46
+ return this.#run(protectInputs ? "request-enrollment" : "enrollment", uri, run, control);
47
+ }
48
+
49
+ /** A close cancels queued opens and queries for that document, before dispatch. */
50
+ close(uri: string): void {
51
+ const cancelled = this.#pending.filter((pending) => pending.uri === uri);
52
+ for (const pending of cancelled) this.#pending.splice(this.#pending.indexOf(pending), 1);
53
+ for (const pending of cancelled)
54
+ pending.cancel(new Error("Document closed before input admission."));
55
+ }
56
+
57
+ /** Discard queued request parameters after a known non-enrollment input change. */
58
+ invalidateRequests(): void {
59
+ const cancelled = this.#pending.filter((pending) => pending.kind !== "enrollment");
60
+ for (const pending of cancelled) this.#pending.splice(this.#pending.indexOf(pending), 1);
61
+ for (const pending of cancelled)
62
+ pending.cancel(new Error("Semantic inputs changed before request admission."));
63
+ }
64
+
65
+ /** Reject queued work without confusing caller completion with transport settlement. */
66
+ dispose(): void {
67
+ this.#disposed = true;
68
+ for (const pending of [...this.#pending]) {
69
+ pending.cancel(new Error("LSP client stopped before input admission."));
70
+ }
71
+ }
72
+
73
+ #run<T>(
74
+ kind: InputOperationKind,
75
+ uri: string | undefined,
76
+ run: () => T | Promise<T>,
77
+ control: CodeRequestControl | undefined,
78
+ ): Promise<T> {
79
+ throwIfCodeRequestInterrupted(control);
80
+ if (this.#disposed) return Promise.reject(new Error("LSP input admission is disposed."));
81
+ if (this.#pending.length >= MAX_PENDING_INPUT_OPERATIONS) {
82
+ return Promise.reject(new Error("LSP input admission queue is full."));
83
+ }
84
+ return new Promise<T>((resolve, reject) => {
85
+ let timer: ReturnType<typeof setTimeout> | undefined;
86
+ let waiting = true;
87
+ const cleanup = () => {
88
+ if (timer !== undefined) clearTimeout(timer);
89
+ control?.signal?.removeEventListener("abort", abort);
90
+ };
91
+ const operation: PendingInputOperation = {
92
+ uri,
93
+ kind,
94
+ cancel: (reason) => {
95
+ if (!waiting) return;
96
+ waiting = false;
97
+ cleanup();
98
+ const index = this.#pending.indexOf(operation);
99
+ if (index !== -1) this.#pending.splice(index, 1);
100
+ reject(reason);
101
+ this.#drain();
102
+ },
103
+ start: () => {
104
+ waiting = false;
105
+ cleanup();
106
+ if (kind !== "request") this.#enrolling = true;
107
+ else this.#readers++;
108
+ const finish = () => {
109
+ if (kind !== "request") this.#enrolling = false;
110
+ else this.#readers--;
111
+ this.#drain();
112
+ };
113
+ try {
114
+ throwIfCodeRequestInterrupted(control);
115
+ Promise.resolve(run()).then(resolve, reject).finally(finish);
116
+ } catch (error) {
117
+ reject(error);
118
+ finish();
119
+ }
120
+ },
121
+ };
122
+ const abort = () => {
123
+ try {
124
+ throwIfCodeRequestInterrupted(control);
125
+ } catch (error) {
126
+ operation.cancel(error);
127
+ }
128
+ };
129
+ this.#pending.push(operation);
130
+ control?.signal?.addEventListener("abort", abort, { once: true });
131
+ if (control?.deadline !== undefined) {
132
+ timer = setTimeout(
133
+ () => operation.cancel(new CodeRequestDeadlineError()),
134
+ Math.max(0, control.deadline - Date.now()),
135
+ );
136
+ }
137
+ this.#drain();
138
+ });
139
+ }
140
+
141
+ #drain(): void {
142
+ while (!this.#disposed && !this.#enrolling && this.#pending.length > 0) {
143
+ const next = this.#pending[0];
144
+ if (next.kind !== "request" && this.#readers > 0) return;
145
+ this.#pending.shift();
146
+ next.start();
147
+ }
148
+ }
149
+ }
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Whether tsserver placed an open file in a configured project.
3
+ *
4
+ * - `configured`: a `tsconfig.json` / `jsconfig.json` program owns the file.
5
+ * - `inferred`: tsserver created an inferred project for the file alone, so
6
+ * project-wide requests such as `workspace/symbol` do not cover the
7
+ * configured program.
8
+ * - `unknown`: the route is not TypeScript or tsserver could not answer.
9
+ */
10
+ export type ProjectMembership = "configured" | "inferred" | "unknown";
11
+
12
+ /** Build `workspace/executeCommand` params for tsserver `projectInfo`. */
13
+ export function typeScriptProjectInfoParams(uri: string): unknown {
14
+ return {
15
+ command: "typescript.tsserverRequest",
16
+ arguments: [
17
+ "projectInfo",
18
+ { file: uri, needFileNameList: false },
19
+ // Semantic execution target; the project must be loaded to answer.
20
+ { executionTarget: 0, expectsResult: true, isAsync: false },
21
+ ],
22
+ };
23
+ }
24
+
25
+ /**
26
+ * Read project membership from a tsserver `projectInfo` response.
27
+ * tsserver names projects without a config file `/dev/null/<kind>Project<N>*`.
28
+ */
29
+ export function readTypeScriptProjectMembership(response: unknown): ProjectMembership {
30
+ if (!isRecord(response) || response.type !== "response" || response.success !== true) {
31
+ return "unknown";
32
+ }
33
+ const body = response.body;
34
+ if (!isRecord(body) || typeof body.configFileName !== "string") return "unknown";
35
+ return body.configFileName.startsWith("/dev/null/") ? "inferred" : "configured";
36
+ }
37
+
38
+ function isRecord(value: unknown): value is Record<string, unknown> {
39
+ return typeof value === "object" && value !== null;
40
+ }
@@ -80,6 +80,11 @@ import type {
80
80
  SemanticInputSnapshot,
81
81
  } from "./client-semantic-input-barrier.ts";
82
82
  import { SemanticInputSynchronizationError } from "./client-semantic-input-errors.ts";
83
+ import {
84
+ type ProjectMembership,
85
+ readTypeScriptProjectMembership,
86
+ typeScriptProjectInfoParams,
87
+ } from "./client-typescript-project.ts";
83
88
  import { JsonRpcClient, JsonRpcRequestError } from "./transport.ts";
84
89
 
85
90
  const SHUTDOWN_TIMEOUT_MS = 5_000;
@@ -89,6 +94,12 @@ const CONTENT_MODIFIED_RETRY_DELAY_MS = 100;
89
94
 
90
95
  /** Bounded stderr tail kept for startup-failure error messages. */
91
96
  const STDERR_TAIL_MAX_CHARS = 4_000;
97
+ /**
98
+ * Bound for draining a failed startup's stdio. An initialize write can fail
99
+ * (`EPIPE`) before the exiting server's stderr is read; waiting for `close`
100
+ * lets the failure report the server's own reason instead of the transport error.
101
+ */
102
+ const STARTUP_FAILURE_DRAIN_MS = 500;
92
103
 
93
104
  interface SemanticRequestAttemptState {
94
105
  inputRevision: number | undefined;
@@ -492,6 +503,20 @@ function killFailedStartupProcess(child: ChildProcess): void {
492
503
  child.kill();
493
504
  }
494
505
 
506
+ /** Resolve once `closed()` holds or the child emits `close`, bounded by `ms`. */
507
+ function waitForStdioClose(child: ChildProcess, closed: () => boolean, ms: number): Promise<void> {
508
+ if (closed()) return Promise.resolve();
509
+ return new Promise((resolve) => {
510
+ const done = () => {
511
+ clearTimeout(timer);
512
+ child.off("close", done);
513
+ resolve();
514
+ };
515
+ const timer = setTimeout(done, ms);
516
+ child.once("close", done);
517
+ });
518
+ }
519
+
495
520
  // ── Types ─────────────────────────────────────────────────────────────
496
521
  export type ClientStatus = "initializing" | "running" | "error" | "shutdown";
497
522
 
@@ -726,19 +751,24 @@ export class LspClient {
726
751
  });
727
752
  this.rpc.onRequest((method, params) => this.handleServerRequest(method, params));
728
753
 
729
- // Handle crashes
754
+ // Handle crashes. A failing initialize reports its own detailed failure.
755
+ let reportingStartupFailure = false;
730
756
  this.process.on("exit", (_code) => {
731
- this.handleProcessFailure(new Error("Client crashed"));
757
+ if (!reportingStartupFailure) this.handleProcessFailure(new Error("Client crashed"));
732
758
  });
733
759
 
734
760
  this.process.on("error", (_err) => {
735
- this.handleProcessFailure(new Error("Client process error"));
761
+ if (!reportingStartupFailure) this.handleProcessFailure(new Error("Client process error"));
736
762
  });
737
763
 
738
764
  // Capture a bounded stderr tail (discarded otherwise) so startup
739
765
  // failures can surface the server's own exit reason — expired builds,
740
766
  // missing runtimes, or invalid flags. Stderr never reaches the agent.
741
767
  let stderrTail = "";
768
+ let stdioClosed = false;
769
+ this.process.once("close", () => {
770
+ stdioClosed = true;
771
+ });
742
772
  this.process.stderr?.on("data", (chunk: Buffer | string) => {
743
773
  stderrTail = `${stderrTail}${chunk}`.slice(-STDERR_TAIL_MAX_CHARS);
744
774
  });
@@ -777,12 +807,16 @@ export class LspClient {
777
807
 
778
808
  this.armNoProgressTimer();
779
809
  } catch (err) {
810
+ const child = this.process;
811
+ reportingStartupFailure = true;
812
+ killFailedStartupProcess(child);
813
+ // Let already-written stderr drain before reporting the failure.
814
+ await waitForStdioClose(child, () => stdioClosed, STARTUP_FAILURE_DRAIN_MS);
780
815
  const stderrDetail = stderrTail.trim() ? `\n${this.name} stderr:\n${stderrTail.trim()}` : "";
781
816
  const failure = new Error(`${this.name}: initialize failed: ${err}${stderrDetail}`, {
782
817
  cause: err,
783
818
  });
784
819
  this.handleProcessFailure(failure);
785
- killFailedStartupProcess(this.process);
786
820
  throw failure;
787
821
  }
788
822
  }
@@ -879,6 +913,33 @@ export class LspClient {
879
913
  }
880
914
 
881
915
  // ── Document Synchronization and Diagnostics ────────────────────────
916
+ /**
917
+ * Admit a first open between semantic attempts, reading content after the wait.
918
+ * Semantic/refactor callers protect their original inputs; ordinary tracking
919
+ * can adopt fresh content after a known change instead.
920
+ */
921
+ async openDocument(
922
+ filePath: string,
923
+ readContent: () => string,
924
+ control?: CodeRequestControl,
925
+ protectInputs = false,
926
+ ): Promise<void> {
927
+ const trackedCount = this.openFiles.length;
928
+ await this.diagnostics.openDocument(filePath, readContent, control, protectInputs);
929
+ this.publishTrackedFileChange(trackedCount);
930
+ }
931
+
932
+ /** Restore retained state without resurrecting a closed or replaced document. */
933
+ async restoreDocument(
934
+ filePath: string,
935
+ readContent: () => string,
936
+ isCurrent: () => boolean,
937
+ ): Promise<void> {
938
+ const trackedCount = this.openFiles.length;
939
+ await this.diagnostics.restoreDocument(filePath, readContent, isCurrent);
940
+ this.publishTrackedFileChange(trackedCount);
941
+ }
942
+
882
943
  /** Open a document, or update it when it is already open. */
883
944
  didOpen(filePath: string, content: string): void {
884
945
  const trackedCount = this.openFiles.length;
@@ -1161,6 +1222,23 @@ export class LspClient {
1161
1222
  );
1162
1223
  }
1163
1224
 
1225
+ /**
1226
+ * Report whether the server placed an open file in a configured project.
1227
+ * Only the TypeScript route can answer; other routes return `unknown`.
1228
+ */
1229
+ async projectMembership(
1230
+ filePath: string,
1231
+ control?: CodeRequestControl,
1232
+ ): Promise<ProjectMembership> {
1233
+ if (!this.hasTypeScriptRequestCommand()) return "unknown";
1234
+ const result = await this.query<unknown>(
1235
+ "workspace/executeCommand",
1236
+ typeScriptProjectInfoParams(fileToUri(filePath)),
1237
+ control,
1238
+ );
1239
+ return result.kind === "completed" ? readTypeScriptProjectMembership(result.data) : "unknown";
1240
+ }
1241
+
1164
1242
  async workspaceSymbol(
1165
1243
  query: string,
1166
1244
  control?: CodeRequestControl,
@@ -1258,6 +1336,7 @@ export class LspClient {
1258
1336
  | undefined;
1259
1337
  },
1260
1338
  {
1339
+ documentUri: params.textDocument.uri,
1261
1340
  inputRevision: options.inputRevision,
1262
1341
  onVerifiedSnapshot: (snapshot) => {
1263
1342
  inputRevision = snapshot.revision;
@@ -1309,17 +1388,22 @@ export class LspClient {
1309
1388
  triggerKind: context.triggerKind ?? CodeActionTriggerKind.Invoked,
1310
1389
  ...(context.only ? { only: [...context.only] } : {}),
1311
1390
  };
1312
- return this.runRefactorTransaction("textDocument/codeAction", control, async (transaction) => {
1313
- const raw = await transaction.rpc.sendRequest(
1314
- "textDocument/codeAction",
1315
- { textDocument: { uri: fileToUri(filePath) }, range, context: requestContext },
1316
- control,
1317
- );
1318
- if (raw === null || raw === undefined) return null;
1319
- if (!Array.isArray(raw)) throw new Error("LSP server returned malformed code actions.");
1391
+ return this.runRefactorTransaction(
1392
+ "textDocument/codeAction",
1393
+ control,
1394
+ async (transaction) => {
1395
+ const raw = await transaction.rpc.sendRequest(
1396
+ "textDocument/codeAction",
1397
+ { textDocument: { uri: fileToUri(filePath) }, range, context: requestContext },
1398
+ control,
1399
+ );
1400
+ if (raw === null || raw === undefined) return null;
1401
+ if (!Array.isArray(raw)) throw new Error("LSP server returned malformed code actions.");
1320
1402
 
1321
- return this.resolveCodeActions(raw, transaction, control, requestContext.only);
1322
- });
1403
+ return this.resolveCodeActions(raw, transaction, control, requestContext.only);
1404
+ },
1405
+ { documentUri: fileToUri(filePath) },
1406
+ );
1323
1407
  }
1324
1408
 
1325
1409
  async implementation(
@@ -1384,9 +1468,10 @@ export class LspClient {
1384
1468
  control: CodeRequestControl | undefined,
1385
1469
  operation: (transaction: RefactorTransaction) => Promise<T | null | undefined>,
1386
1470
  options: {
1471
+ readonly documentUri: string;
1387
1472
  readonly onVerifiedSnapshot?: (snapshot: SemanticInputSnapshot) => void;
1388
1473
  readonly inputRevision?: number;
1389
- } = {},
1474
+ },
1390
1475
  ): Promise<CodeQueryResult<T | null>> {
1391
1476
  throwIfCodeRequestInterrupted(control);
1392
1477
  if (!this.rpc || this._status !== "running") {
@@ -1405,6 +1490,7 @@ export class LspClient {
1405
1490
  retryGuard.assertCurrent();
1406
1491
  const data = await this.runRefactorTransactionAttempt({
1407
1492
  method,
1493
+ documentUri: options.documentUri,
1408
1494
  control,
1409
1495
  operation,
1410
1496
  retryGuard,
@@ -1446,9 +1532,10 @@ export class LspClient {
1446
1532
  }
1447
1533
  }
1448
1534
 
1449
- /** Run the complete prepare/request/resolve transaction for one input snapshot. */
1535
+ /** Admit one prepare/request/resolve attempt, not retries or rename's own enrollment. */
1450
1536
  private async runRefactorTransactionAttempt<T>(options: {
1451
1537
  method: string;
1538
+ documentUri: string;
1452
1539
  control?: CodeRequestControl;
1453
1540
  operation: (transaction: RefactorTransaction) => Promise<T | null | undefined>;
1454
1541
  retryGuard: SemanticInputRequestRetryGuard;
@@ -1459,27 +1546,36 @@ export class LspClient {
1459
1546
  throwIfCodeRequestInterrupted(control);
1460
1547
  retryGuard.assertCurrent();
1461
1548
  await this.getReady(control);
1462
- const inputSnapshot = await this.diagnostics.synchronizeSemanticInputs(control);
1463
- attempt.inputRevision = inputSnapshot.revision;
1464
- throwIfCodeRequestInterrupted(control);
1465
- retryGuard.assertCurrent();
1466
- const rpc = this.rpc;
1467
- if (!rpc || this._status !== "running") {
1468
- throw new Error(`LSP request ${method} is unavailable because the client is not running.`);
1469
- }
1470
- const data = await operation({
1471
- rpc,
1472
- assertCurrent: async () => {
1549
+ return this.diagnostics.withSemanticRequest(
1550
+ async () => {
1473
1551
  retryGuard.assertCurrent();
1474
- return this.diagnostics.assertSemanticInputsCurrent(inputSnapshot, control);
1552
+ const inputSnapshot = await this.diagnostics.synchronizeSemanticInputs(control);
1553
+ attempt.inputRevision = inputSnapshot.revision;
1554
+ throwIfCodeRequestInterrupted(control);
1555
+ retryGuard.assertCurrent();
1556
+ const rpc = this.rpc;
1557
+ if (!rpc || this._status !== "running") {
1558
+ throw new Error(
1559
+ `LSP request ${method} is unavailable because the client is not running.`,
1560
+ );
1561
+ }
1562
+ const data = await operation({
1563
+ rpc,
1564
+ assertCurrent: async () => {
1565
+ retryGuard.assertCurrent();
1566
+ return this.diagnostics.assertSemanticInputsCurrent(inputSnapshot, control);
1567
+ },
1568
+ });
1569
+ const verifiedSnapshot = await this.diagnostics.assertSemanticInputsCurrent(
1570
+ inputSnapshot,
1571
+ control,
1572
+ );
1573
+ onVerifiedSnapshot?.(verifiedSnapshot);
1574
+ return data ?? null;
1475
1575
  },
1476
- });
1477
- const verifiedSnapshot = await this.diagnostics.assertSemanticInputsCurrent(
1478
- inputSnapshot,
1479
1576
  control,
1577
+ options.documentUri,
1480
1578
  );
1481
- onVerifiedSnapshot?.(verifiedSnapshot);
1482
- return data ?? null;
1483
1579
  }
1484
1580
 
1485
1581
  private async query<T>(
@@ -1651,17 +1747,28 @@ export class LspClient {
1651
1747
  throwIfCodeRequestInterrupted(control);
1652
1748
  retryGuard.assertCurrent();
1653
1749
  await this.getReady(control);
1654
- const inputSnapshot = await this.diagnostics.synchronizeSemanticInputs(control);
1655
- attempt.inputRevision = inputSnapshot.revision;
1656
- throwIfCodeRequestInterrupted(control);
1657
- retryGuard.assertCurrent();
1658
- const rpc = this.rpc;
1659
- if (!rpc || this._status !== "running") {
1660
- throw new Error(`LSP request ${method} is unavailable because the client is not running.`);
1661
- }
1662
- const data = (await rpc.sendRequest(method, params, control)) as T | null | undefined;
1663
- await this.diagnostics.assertSemanticInputsCurrent(inputSnapshot, control);
1664
- return data ?? null;
1750
+ // Local query methods construct the standard TextDocumentIdentifier envelope.
1751
+ const documentUri = (params as { textDocument?: { uri: string } } | null)?.textDocument?.uri;
1752
+ return this.diagnostics.withSemanticRequest(
1753
+ async () => {
1754
+ retryGuard.assertCurrent();
1755
+ const inputSnapshot = await this.diagnostics.synchronizeSemanticInputs(control);
1756
+ attempt.inputRevision = inputSnapshot.revision;
1757
+ throwIfCodeRequestInterrupted(control);
1758
+ retryGuard.assertCurrent();
1759
+ const rpc = this.rpc;
1760
+ if (!rpc || this._status !== "running") {
1761
+ throw new Error(
1762
+ `LSP request ${method} is unavailable because the client is not running.`,
1763
+ );
1764
+ }
1765
+ const data = (await rpc.sendRequest(method, params, control)) as T | null | undefined;
1766
+ await this.diagnostics.assertSemanticInputsCurrent(inputSnapshot, control);
1767
+ return data ?? null;
1768
+ },
1769
+ control,
1770
+ documentUri,
1771
+ );
1665
1772
  }
1666
1773
 
1667
1774
  private handleServerRequest(method: string, params: unknown): unknown {
@@ -162,6 +162,16 @@ export function getFileScopeDecision(filePath: string, cwd: string): FileScopeDe
162
162
  };
163
163
  }
164
164
 
165
+ /**
166
+ * Whether a project config's program includes a file, by its own file set or
167
+ * include/exclude rules. Unlike {@link getFileScopeDecision}, the config need
168
+ * not be the file's nearest one (e.g. a test config including `../src`).
169
+ */
170
+ export function projectConfigIncludesFile(configPath: string, filePath: string): boolean {
171
+ const parsed = parseProjectConfig(configPath);
172
+ return parsed !== null && decideFileScope(parsed, path.resolve(filePath)).included;
173
+ }
174
+
165
175
  function isOutOfTree(cwd: string, absolutePath: string): boolean {
166
176
  const relative = path.relative(path.resolve(cwd), absolutePath);
167
177
  return relative.startsWith(`..${path.sep}`) || relative === "..";
@@ -1,6 +1,6 @@
1
- /** Reason for a partial or unavailable single-file result when the push stays tentative. */
1
+ /** Explain the lack of request-based confirmation when only ambient push evidence is available. */
2
2
  export const TENTATIVE_PUSH_UNAVAILABLE_REASON =
3
- "A diagnostic publication is ambient evidence only; it may show issues, but an empty publication cannot confirm that the document is clean.";
3
+ "Only ambient push diagnostics are available for this file; completion of the diagnostic check cannot be confirmed. An empty publication cannot confirm that the document is clean.";
4
4
 
5
5
  /** A final evidence state for one tracked document. */
6
6
  export type DiagnosticEvidenceStatus = "confirmed" | "unconfirmed" | "failed" | "removed";
@@ -0,0 +1,118 @@
1
+ import * as path from "node:path";
2
+ import { collectExtendedProjectConfigs } from "../config/tsconfig-extends.ts";
3
+ import { normalizeTsconfigPath } from "../config/tsconfig-path.ts";
4
+ import { getFileScopeDecision, projectConfigIncludesFile } from "../config/tsconfig-scope.ts";
5
+ import { type AutomaticLspPathPolicy, walkAutomaticLspTree } from "../workspace-path-policy.ts";
6
+
7
+ /** Directory depth searched for project configs below a TypeScript route root. */
8
+ const PROJECT_CONFIG_SCAN_DEPTH = 8;
9
+ /** Project config paths named in one coverage reason. */
10
+ const MAX_NAMED_PROJECTS = 3;
11
+ /** File names tsserver loads as projects; `tsconfig.*.json` variants are never auto-loaded. */
12
+ const LOADABLE_PROJECT_CONFIGS = new Set(["tsconfig.json", "jsconfig.json"]);
13
+
14
+ /** Find loadable project configs under a route root, honoring the automatic path policy. */
15
+ export function discoverTypeScriptProjectConfigs(
16
+ root: string,
17
+ policy: AutomaticLspPathPolicy,
18
+ ): string[] {
19
+ const configs: string[] = [];
20
+ walkAutomaticLspTree(policy, root, PROJECT_CONFIG_SCAN_DEPTH, (directory, entries) => {
21
+ for (const entry of entries) {
22
+ if (entry.isFile() && LOADABLE_PROJECT_CONFIGS.has(entry.name)) {
23
+ configs.push(path.join(directory, entry.name));
24
+ }
25
+ }
26
+ return false;
27
+ });
28
+ return configs;
29
+ }
30
+
31
+ /**
32
+ * Project configs whose program includes `file` but show no sign of being loaded.
33
+ *
34
+ * tsserver answers project-wide requests such as references and
35
+ * implementations only from loaded projects, and loads a project only when one
36
+ * of its files is open. An unloaded including project (typically a test
37
+ * config that includes `../src`) can hold matches the answer omits.
38
+ *
39
+ * A project counts as loaded when it is the nearest config of an open file or
40
+ * of a file in the answer (`resultFiles`): tsserver also loads projects on its
41
+ * own during a search, and a match from a project's own file proves it was searched.
42
+ *
43
+ * Base configs extended by another discovered config are skipped: their
44
+ * default include usually spans the whole tree while they own only root
45
+ * tooling files, so counting them would mark nearly every answer partial.
46
+ */
47
+ export function findUnloadedTypeScriptProjects(options: {
48
+ readonly file: string;
49
+ readonly cwd: string;
50
+ readonly openFiles: readonly string[];
51
+ readonly resultFiles?: readonly string[];
52
+ readonly configs: readonly string[];
53
+ }): string[] {
54
+ const baseConfigs = new Set(
55
+ options.configs.flatMap((config) => [...collectExtendedProjectConfigs(config)]),
56
+ );
57
+ const loaded = new Set<string>();
58
+ for (const loadedFile of [...options.openFiles, ...(options.resultFiles ?? [])]) {
59
+ const decision = getFileScopeDecision(loadedFile, options.cwd);
60
+ if (decision.status === "included" && decision.configPath) {
61
+ loaded.add(normalizeTsconfigPath(decision.configPath));
62
+ }
63
+ }
64
+ return options.configs.filter((config) => {
65
+ const key = normalizeTsconfigPath(config);
66
+ return (
67
+ !loaded.has(key) && !baseConfigs.has(key) && projectConfigIncludesFile(config, options.file)
68
+ );
69
+ });
70
+ }
71
+
72
+ /**
73
+ * Other projects that tsserver's implementation search skips for `file`.
74
+ *
75
+ * tsserver 6 and earlier answer `textDocument/implementation` from the file's
76
+ * default project only, unlike references, which span loaded projects. Any
77
+ * other non-base project may implement the target through an import, and
78
+ * opening its files does not change the answer.
79
+ */
80
+ export function findSkippedImplementationProjects(options: {
81
+ readonly file: string;
82
+ readonly cwd: string;
83
+ readonly configs: readonly string[];
84
+ }): string[] {
85
+ const baseConfigs = new Set(
86
+ options.configs.flatMap((config) => [...collectExtendedProjectConfigs(config)]),
87
+ );
88
+ const decision = getFileScopeDecision(options.file, options.cwd);
89
+ const own = decision.configPath ? normalizeTsconfigPath(decision.configPath) : undefined;
90
+ return options.configs.filter((config) => {
91
+ const key = normalizeTsconfigPath(config);
92
+ return key !== own && !baseConfigs.has(key);
93
+ });
94
+ }
95
+
96
+ /** Partial-result reason for tsserver's single-project implementation search. */
97
+ export function singleProjectImplementationReason(skipped: readonly string[]): string {
98
+ const noun = skipped.length === 1 ? "1 other project is" : `${skipped.length} other projects are`;
99
+ return (
100
+ "TypeScript coverage is limited: the TypeScript 6 or earlier server (tsserver) " +
101
+ `searches implementations only in this file's project; ${noun} not searched. ` +
102
+ "Use references to find uses in other projects, or use TypeScript 7."
103
+ );
104
+ }
105
+
106
+ /** Actionable partial-result reason naming a bounded set of unloaded projects. */
107
+ export function typeScriptCoverageGapReason(unloaded: readonly string[], cwd: string): string {
108
+ const named = unloaded
109
+ .slice(0, MAX_NAMED_PROJECTS)
110
+ .map((config) => path.relative(cwd, config) || path.basename(config));
111
+ const more = unloaded.length > named.length ? `, +${unloaded.length - named.length} more` : "";
112
+ const noun = unloaded.length === 1 ? "project that includes" : "projects that include";
113
+ return (
114
+ `TypeScript coverage is limited: ${unloaded.length} ${noun} this file ` +
115
+ `${unloaded.length === 1 ? "is" : "are"} not loaded (${named.join(", ")}${more}). ` +
116
+ "Open a file from each project and retry."
117
+ );
118
+ }