@mrclrchtr/supi-lsp 11.2.0 → 11.3.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.
- package/README.md +39 -2
- package/node_modules/@mrclrchtr/supi-code-runtime/package.json +1 -1
- package/node_modules/@mrclrchtr/supi-code-runtime/src/capability/types.ts +3 -1
- package/node_modules/@mrclrchtr/supi-code-runtime/src/query-result.ts +9 -0
- package/node_modules/@mrclrchtr/supi-core/package.json +1 -1
- package/node_modules/@mrclrchtr/supi-core/src/child-web-tools.ts +110 -0
- package/node_modules/@mrclrchtr/supi-core/src/session.ts +11 -0
- package/package.json +3 -3
- package/src/client/client-diagnostic-collection.ts +11 -4
- package/src/client/client-diagnostics.ts +77 -6
- package/src/client/client-input-admission.ts +149 -0
- package/src/client/client-typescript-project.ts +40 -0
- package/src/client/client.ts +151 -44
- package/src/diagnostics/evidence.ts +2 -2
- package/src/manager/manager-workspace-symbol.ts +157 -23
- package/src/manager/manager.ts +254 -56
- package/src/session/runtime-registry.ts +19 -2
- package/src/session/runtime-rename.ts +1 -0
- package/src/session/workspace-lsp-runtime.ts +5 -1
package/src/client/client.ts
CHANGED
|
@@ -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(
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
1463
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1655
|
-
|
|
1656
|
-
|
|
1657
|
-
|
|
1658
|
-
|
|
1659
|
-
|
|
1660
|
-
|
|
1661
|
-
|
|
1662
|
-
|
|
1663
|
-
|
|
1664
|
-
|
|
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 {
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
/**
|
|
1
|
+
/** Explain the lack of request-based confirmation when only ambient push evidence is available. */
|
|
2
2
|
export const TENTATIVE_PUSH_UNAVAILABLE_REASON =
|
|
3
|
-
"
|
|
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";
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { statSync } from "node:fs";
|
|
1
|
+
import { type Dirent, realpathSync, statSync } from "node:fs";
|
|
2
2
|
import * as path from "node:path";
|
|
3
3
|
import {
|
|
4
4
|
type CodeQueryResult,
|
|
@@ -25,6 +25,10 @@ type WorkspaceSymbolLike = SymbolInformation | WorkspaceSymbol;
|
|
|
25
25
|
|
|
26
26
|
const DEFAULT_WARM_FILE_DEPTH = 4;
|
|
27
27
|
const DEFAULT_MARKER_SCAN_DEPTH = 6;
|
|
28
|
+
/** Candidates per marker root; later candidates help only when earlier ones land outside the project. */
|
|
29
|
+
const WARM_FILES_PER_ROOT = 3;
|
|
30
|
+
/** Conventional source directories tried before other top-level entries. */
|
|
31
|
+
const PREFERRED_SOURCE_DIRECTORIES = ["src", "lib", "app"];
|
|
28
32
|
|
|
29
33
|
export interface WorkspaceSymbolWarmTarget {
|
|
30
34
|
projectRoot: string;
|
|
@@ -63,6 +67,26 @@ export interface WorkspaceSymbolCollection {
|
|
|
63
67
|
excludedCount?: number;
|
|
64
68
|
/** Bounded paths removed by the automatic path policy. */
|
|
65
69
|
excludedExamples?: string[];
|
|
70
|
+
/** Known coverage limits: the server may not have loaded every project in scope. */
|
|
71
|
+
coverageGap?: string;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Mark a collection whose warm-up never reached a configured project.
|
|
76
|
+
* The server answered only from inferred projects of the opened probe files,
|
|
77
|
+
* so an empty or short result does not prove a symbol is absent.
|
|
78
|
+
*/
|
|
79
|
+
export function workspaceSymbolCoverageGap(
|
|
80
|
+
collection: WorkspaceSymbolCollection,
|
|
81
|
+
inferredProbeCount: number,
|
|
82
|
+
): WorkspaceSymbolCollection {
|
|
83
|
+
const noun = `file${inferredProbeCount === 1 ? "" : "s"}`;
|
|
84
|
+
return {
|
|
85
|
+
...collection,
|
|
86
|
+
coverageGap:
|
|
87
|
+
`Workspace-symbol coverage is limited: ${inferredProbeCount} opened warm-up ${noun} ` +
|
|
88
|
+
"loaded only inferred projects, not a configured project. Open a project source file and retry.",
|
|
89
|
+
};
|
|
66
90
|
}
|
|
67
91
|
|
|
68
92
|
export async function collectWorkspaceSymbols(
|
|
@@ -96,10 +120,8 @@ function workspaceSymbolExclusionReason(collection: WorkspaceSymbolCollection):
|
|
|
96
120
|
const excludedCount = collection.excludedCount ?? 0;
|
|
97
121
|
if (excludedCount === 0) return null;
|
|
98
122
|
const noun = `workspace-symbol result${excludedCount === 1 ? "" : "s"}`;
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
: "";
|
|
102
|
-
return `Automatic path policy excluded ${excludedCount} ${noun}.${examples}`;
|
|
123
|
+
// Paths belong to typed metadata so consumers can distinguish observed aliases from navigation.
|
|
124
|
+
return `Automatic path policy excluded ${excludedCount} ${noun}.`;
|
|
103
125
|
}
|
|
104
126
|
|
|
105
127
|
/** Project one multi-client collection into the shared typed query contract. */
|
|
@@ -115,8 +137,9 @@ export function workspaceSymbolCollectionResult(
|
|
|
115
137
|
);
|
|
116
138
|
}
|
|
117
139
|
const exclusion = workspaceSymbolExclusionReason(collection);
|
|
118
|
-
if (collection.failures.length > 0 || exclusion) {
|
|
140
|
+
if (collection.failures.length > 0 || exclusion || collection.coverageGap) {
|
|
119
141
|
const excludedCount = collection.excludedCount ?? 0;
|
|
142
|
+
const exclusionOnly = collection.failures.length === 0 && !collection.coverageGap;
|
|
120
143
|
const metadata =
|
|
121
144
|
excludedCount > 0
|
|
122
145
|
? {
|
|
@@ -124,17 +147,30 @@ export function workspaceSymbolCollectionResult(
|
|
|
124
147
|
excludedCount,
|
|
125
148
|
examples: [...(collection.excludedExamples ?? [])],
|
|
126
149
|
},
|
|
150
|
+
...(exclusionOnly ? { exclusionOnly: true } : {}),
|
|
127
151
|
}
|
|
128
152
|
: undefined;
|
|
129
153
|
return partialCodeQuery(
|
|
130
154
|
collection.results,
|
|
131
|
-
[collection.failures.join("; "), exclusion]
|
|
155
|
+
[collection.failures.join("; "), collection.coverageGap, exclusion]
|
|
156
|
+
.filter(Boolean)
|
|
157
|
+
.join("; "),
|
|
132
158
|
metadata,
|
|
133
159
|
);
|
|
134
160
|
}
|
|
135
161
|
return completedCodeQuery(collection.results);
|
|
136
162
|
}
|
|
137
163
|
|
|
164
|
+
/**
|
|
165
|
+
* Choose files to open so a workspace-symbol server loads the projects under `root`.
|
|
166
|
+
*
|
|
167
|
+
* Returns up to three candidates per marker root, interleaved so every root's
|
|
168
|
+
* best candidate comes before any root's fallback; nested roots first. Within a
|
|
169
|
+
* root, conventional source directories come first and hidden entries last:
|
|
170
|
+
* root-level dotfiles and tool configs are often outside the configured program
|
|
171
|
+
* (for example `tsconfig.json` `include`), and opening only such a file loads an
|
|
172
|
+
* unrelated project. Callers try candidates in order.
|
|
173
|
+
*/
|
|
138
174
|
export function findWorkspaceSymbolWarmTargets(
|
|
139
175
|
root: string,
|
|
140
176
|
rootMarkers: string[],
|
|
@@ -158,7 +194,7 @@ export function findWorkspaceSymbolWarmTargets(
|
|
|
158
194
|
return searchRoot
|
|
159
195
|
? findWorkspaceSymbolWarmTargets(searchRoot, rootMarkers, fileTypes, {
|
|
160
196
|
...options,
|
|
161
|
-
policy,
|
|
197
|
+
policy: explicitScopePolicy(policy, scope),
|
|
162
198
|
scopes: undefined,
|
|
163
199
|
})
|
|
164
200
|
: [];
|
|
@@ -174,26 +210,76 @@ export function findWorkspaceSymbolWarmTargets(
|
|
|
174
210
|
}
|
|
175
211
|
|
|
176
212
|
const markerRoots = collectMarkerRoots(resolvedRoot, rootMarkers, maxMarkerDepth, policy);
|
|
177
|
-
const
|
|
178
|
-
.map((entry) => {
|
|
179
|
-
const file = findWarmFileRecursive(entry.root, allowed, maxFileDepth, policy);
|
|
180
|
-
return file ? { projectRoot: entry.root, file, priority: entry.priority } : null;
|
|
181
|
-
})
|
|
182
|
-
.filter((entry): entry is { projectRoot: string; file: string; priority: number } =>
|
|
183
|
-
Boolean(entry),
|
|
184
|
-
)
|
|
213
|
+
const perRoot = markerRoots
|
|
185
214
|
.sort(
|
|
186
215
|
(a, b) =>
|
|
187
|
-
a.priority - b.priority ||
|
|
188
|
-
a.projectRoot.length - b.projectRoot.length ||
|
|
189
|
-
a.projectRoot.localeCompare(b.projectRoot),
|
|
216
|
+
a.priority - b.priority || a.root.length - b.root.length || a.root.localeCompare(b.root),
|
|
190
217
|
)
|
|
191
|
-
.map((
|
|
218
|
+
.map((entry) =>
|
|
219
|
+
findWarmFiles(entry.root, allowed, maxFileDepth, policy).map((file) => ({
|
|
220
|
+
projectRoot: entry.root,
|
|
221
|
+
file,
|
|
222
|
+
})),
|
|
223
|
+
);
|
|
224
|
+
// Interleave by rank so each root's best candidate precedes any root's fallback.
|
|
225
|
+
const targets = Array.from({ length: WARM_FILES_PER_ROOT }, (_, rank) =>
|
|
226
|
+
perRoot.flatMap((candidates) => candidates[rank] ?? []),
|
|
227
|
+
).flat();
|
|
228
|
+
|
|
229
|
+
if (targets.length > 0) {
|
|
230
|
+
// Keep the first, most specific root for a file a parent root also reaches.
|
|
231
|
+
const seen = new Set<string>();
|
|
232
|
+
return targets.filter((target) => !seen.has(target.file) && Boolean(seen.add(target.file)));
|
|
233
|
+
}
|
|
192
234
|
|
|
193
|
-
|
|
235
|
+
return findWarmFiles(resolvedRoot, allowed, maxFileDepth, policy).map((file) => ({
|
|
236
|
+
projectRoot: resolvedRoot,
|
|
237
|
+
file,
|
|
238
|
+
}));
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* True when a reported file is inside a scope by spelling or physical identity.
|
|
243
|
+
* Servers may report realpaths for files requested through symlinked roots.
|
|
244
|
+
*/
|
|
245
|
+
export function isWithinAnyScope(file: string, scopes: readonly string[]): boolean {
|
|
246
|
+
if (scopes.some((scope) => isWithinOrEqual(scope, file))) return true;
|
|
247
|
+
try {
|
|
248
|
+
const physicalFile = realpathSync(file);
|
|
249
|
+
return scopes.some((scope) => {
|
|
250
|
+
try {
|
|
251
|
+
return isWithinOrEqual(realpathSync(scope), physicalFile);
|
|
252
|
+
} catch {
|
|
253
|
+
return false;
|
|
254
|
+
}
|
|
255
|
+
});
|
|
256
|
+
} catch {
|
|
257
|
+
return false;
|
|
258
|
+
}
|
|
259
|
+
}
|
|
194
260
|
|
|
195
|
-
|
|
196
|
-
|
|
261
|
+
/**
|
|
262
|
+
* Keep an explicitly selected excluded file or directory warmable, matching the
|
|
263
|
+
* rows workspace-symbol filtering allows for that scope. Exclusions nested in an
|
|
264
|
+
* eligible directory scope stay excluded.
|
|
265
|
+
*/
|
|
266
|
+
function explicitScopePolicy(
|
|
267
|
+
policy: AutomaticLspPathPolicy,
|
|
268
|
+
scope: string,
|
|
269
|
+
): AutomaticLspPathPolicy {
|
|
270
|
+
const kind = statSync(scope, { throwIfNoEntry: false });
|
|
271
|
+
if (!kind) return policy;
|
|
272
|
+
const selected = kind.isFile()
|
|
273
|
+
? (candidate: string) => path.resolve(candidate) === path.resolve(scope)
|
|
274
|
+
: policy.isEligible(scope, "directory")
|
|
275
|
+
? null
|
|
276
|
+
: (candidate: string) => isWithinOrEqual(scope, candidate);
|
|
277
|
+
if (!selected) return policy;
|
|
278
|
+
return {
|
|
279
|
+
...policy,
|
|
280
|
+
isEligible: (candidate, candidateKind) =>
|
|
281
|
+
selected(candidate) ? true : policy.isEligible(candidate, candidateKind),
|
|
282
|
+
};
|
|
197
283
|
}
|
|
198
284
|
|
|
199
285
|
interface MarkerRootEntry {
|
|
@@ -234,6 +320,54 @@ function collectMarkerRoots(
|
|
|
234
320
|
}));
|
|
235
321
|
}
|
|
236
322
|
|
|
323
|
+
/**
|
|
324
|
+
* Pick at most {@link WARM_FILES_PER_ROOT} files under `root`, one per top-level
|
|
325
|
+
* group: preferred source directories, other directories, root files, then
|
|
326
|
+
* hidden entries.
|
|
327
|
+
*/
|
|
328
|
+
function findWarmFiles(
|
|
329
|
+
root: string,
|
|
330
|
+
allowed: Set<string>,
|
|
331
|
+
depth: number,
|
|
332
|
+
policy: AutomaticLspPathPolicy,
|
|
333
|
+
): string[] {
|
|
334
|
+
let topLevel: readonly Dirent[] = [];
|
|
335
|
+
walkAutomaticLspTree(policy, root, 0, (_directory, entries) => {
|
|
336
|
+
topLevel = entries;
|
|
337
|
+
return true;
|
|
338
|
+
});
|
|
339
|
+
const isAllowedFile = (entry: Dirent) =>
|
|
340
|
+
(entry.isFile() || entry.isSymbolicLink()) &&
|
|
341
|
+
allowed.has(path.extname(entry.name).slice(1).toLowerCase());
|
|
342
|
+
const rank = (entry: Dirent) => {
|
|
343
|
+
const hidden = entry.name.startsWith(".") ? 3 : 0;
|
|
344
|
+
if (!entry.isDirectory()) return hidden + 2;
|
|
345
|
+
return hidden + (PREFERRED_SOURCE_DIRECTORIES.includes(entry.name) ? 0 : 1);
|
|
346
|
+
};
|
|
347
|
+
const ordered = topLevel
|
|
348
|
+
.filter((entry) => entry.isDirectory() || isAllowedFile(entry))
|
|
349
|
+
.map((entry, index) => ({ entry, index, rank: rank(entry) }))
|
|
350
|
+
.sort((a, b) => a.rank - b.rank || a.index - b.index);
|
|
351
|
+
|
|
352
|
+
const files: string[] = [];
|
|
353
|
+
const rootFileRanks = new Set<number>();
|
|
354
|
+
for (const { entry, rank: entryRank } of ordered) {
|
|
355
|
+
if (files.length >= WARM_FILES_PER_ROOT) break;
|
|
356
|
+
const entryPath = path.join(root, entry.name);
|
|
357
|
+
if (!entry.isDirectory()) {
|
|
358
|
+
// One root file per visibility group is enough evidence for that group.
|
|
359
|
+
if (rootFileRanks.has(entryRank)) continue;
|
|
360
|
+
rootFileRanks.add(entryRank);
|
|
361
|
+
files.push(entryPath);
|
|
362
|
+
continue;
|
|
363
|
+
}
|
|
364
|
+
if (depth <= 0) continue;
|
|
365
|
+
const file = findWarmFileRecursive(entryPath, allowed, depth - 1, policy);
|
|
366
|
+
if (file) files.push(file);
|
|
367
|
+
}
|
|
368
|
+
return files;
|
|
369
|
+
}
|
|
370
|
+
|
|
237
371
|
function findWarmFileRecursive(
|
|
238
372
|
directory: string,
|
|
239
373
|
allowed: Set<string>,
|