@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,87 @@
1
+ import * as fs from "node:fs";
2
+ import ts from "typescript";
3
+
4
+ /**
5
+ * Whether the identifier at `position` names a top-level declaration that only
6
+ * its own file can reference: the file is an ES module and the declaration is
7
+ * neither exported by a modifier nor by an export clause or assignment.
8
+ *
9
+ * Other TypeScript projects can then hold no references or implementations,
10
+ * so a project coverage gap cannot apply. Script files (no import or export),
11
+ * members, unreadable files, and non-declaration positions return false.
12
+ *
13
+ * @param position Zero-based LSP position.
14
+ */
15
+ export function isFileLocalTopLevelDeclaration(
16
+ filePath: string,
17
+ position: { readonly line: number; readonly character: number },
18
+ ): boolean {
19
+ let text: string;
20
+ try {
21
+ text = fs.readFileSync(filePath, "utf8");
22
+ } catch {
23
+ return false;
24
+ }
25
+ const sourceFile = ts.createSourceFile(filePath, text, ts.ScriptTarget.Latest, true);
26
+ if (!ts.isExternalModule(sourceFile)) return false;
27
+ let offset: number;
28
+ try {
29
+ offset = ts.getPositionOfLineAndCharacter(sourceFile, position.line, position.character);
30
+ } catch {
31
+ return false;
32
+ }
33
+ const statement = sourceFile.statements.find(
34
+ (candidate) => candidate.getStart(sourceFile) <= offset && offset < candidate.end,
35
+ );
36
+ if (!statement) return false;
37
+ const name = topLevelDeclarationNameAt(statement, offset, sourceFile);
38
+ return name !== null && !hasExportModifier(statement) && !exportedByName(sourceFile, name);
39
+ }
40
+
41
+ /** Name of the declaration `statement` declares at `offset`, or null. */
42
+ function topLevelDeclarationNameAt(
43
+ statement: ts.Statement,
44
+ offset: number,
45
+ sourceFile: ts.SourceFile,
46
+ ): string | null {
47
+ const names: ts.Identifier[] = [];
48
+ if (
49
+ ts.isFunctionDeclaration(statement) ||
50
+ ts.isClassDeclaration(statement) ||
51
+ ts.isInterfaceDeclaration(statement) ||
52
+ ts.isTypeAliasDeclaration(statement) ||
53
+ ts.isEnumDeclaration(statement)
54
+ ) {
55
+ if (statement.name) names.push(statement.name);
56
+ } else if (ts.isVariableStatement(statement)) {
57
+ for (const declaration of statement.declarationList.declarations) {
58
+ if (ts.isIdentifier(declaration.name)) names.push(declaration.name);
59
+ }
60
+ }
61
+ const hit = names.find((name) => name.getStart(sourceFile) <= offset && offset <= name.end);
62
+ return hit ? hit.text : null;
63
+ }
64
+
65
+ function hasExportModifier(statement: ts.Statement): boolean {
66
+ return (
67
+ ts.canHaveModifiers(statement) &&
68
+ (ts.getModifiers(statement) ?? []).some(
69
+ (modifier) =>
70
+ modifier.kind === ts.SyntaxKind.ExportKeyword ||
71
+ modifier.kind === ts.SyntaxKind.DeclareKeyword,
72
+ )
73
+ );
74
+ }
75
+
76
+ /** `export { name }`, `export default name`, or `export = name` in this file. */
77
+ function exportedByName(sourceFile: ts.SourceFile, name: string): boolean {
78
+ return sourceFile.statements.some((statement) => {
79
+ if (ts.isExportAssignment(statement)) {
80
+ return ts.isIdentifier(statement.expression) && statement.expression.text === name;
81
+ }
82
+ if (!ts.isExportDeclaration(statement) || statement.moduleSpecifier) return false;
83
+ const clause = statement.exportClause;
84
+ if (!clause || !ts.isNamedExports(clause)) return false;
85
+ return clause.elements.some((element) => (element.propertyName ?? element.name).text === name);
86
+ });
87
+ }
@@ -2,10 +2,11 @@ import { readFile } from "node:fs/promises";
2
2
  import type {
3
3
  CodeLocation,
4
4
  CodeSymbol,
5
- DeclarationNesting,
5
+ DeclarationLevel,
6
6
  DocumentCodeSymbol,
7
7
  SourceRange,
8
8
  } from "@mrclrchtr/supi-code-runtime/api";
9
+ import { childDeclarationLevel } from "@mrclrchtr/supi-code-runtime/api";
9
10
  import { uriToFile } from "@mrclrchtr/supi-core/path";
10
11
  import type {
11
12
  DocumentSymbol,
@@ -100,15 +101,26 @@ export async function mapDocumentSymbols(
100
101
  ): Promise<DocumentCodeSymbol[]> {
101
102
  return flattenDocumentSymbols(symbols, filePath, null, {
102
103
  sourceLines: await readSourceLines(filePath),
103
- nesting: "top-level",
104
+ parent: null,
104
105
  });
105
106
  }
106
107
 
108
+ /** Level of a symbol reported inside `parent`, or top-level without one. */
109
+ function levelWithin(
110
+ parent: { readonly kind: string; readonly level: DeclarationLevel } | null,
111
+ kind: string,
112
+ ): DeclarationLevel {
113
+ return parent ? childDeclarationLevel(parent, kind) : "top-level";
114
+ }
115
+
107
116
  function flattenDocumentSymbols(
108
117
  symbols: DocumentSymbol[] | SymbolInformation[],
109
118
  filePath: string,
110
119
  container: string | null,
111
- context: { sourceLines: readonly string[] | null; nesting: DeclarationNesting },
120
+ context: {
121
+ sourceLines: readonly string[] | null;
122
+ parent: { readonly kind: string; readonly level: DeclarationLevel } | null;
123
+ },
112
124
  ): DocumentCodeSymbol[] {
113
125
  const result: DocumentCodeSymbol[] = [];
114
126
  for (const sym of symbols) {
@@ -116,15 +128,13 @@ function flattenDocumentSymbols(
116
128
  let document: DocumentSymbol | null = null;
117
129
  if (isSymbolInformation(sym)) information = sym;
118
130
  else document = sym;
131
+ const kind = symbolKindName(sym.kind);
132
+ const level = information ? "unknown" : levelWithin(context.parent, kind);
133
+ const childContext = { sourceLines: context.sourceLines, parent: { kind, level } };
119
134
  const declarationRange = toValidRange(document?.range ?? information?.location.range);
120
135
  if (!declarationRange) {
121
136
  if (document?.children?.length) {
122
- result.push(
123
- ...flattenDocumentSymbols(document.children, filePath, sym.name, {
124
- sourceLines: context.sourceLines,
125
- nesting: "nested",
126
- }),
127
- );
137
+ result.push(...flattenDocumentSymbols(document.children, filePath, sym.name, childContext));
128
138
  }
129
139
  continue;
130
140
  }
@@ -134,23 +144,18 @@ function flattenDocumentSymbols(
134
144
  : null;
135
145
  const symbol: DocumentCodeSymbol = {
136
146
  name: sym.name,
137
- kind: symbolKindName(sym.kind),
147
+ kind,
138
148
  file: filePath,
139
149
  declarationAnchor: { line: declStart.line + 1, character: declStart.character + 1 },
140
150
  container: information?.containerName ?? container,
141
- nesting: information ? "unknown" : context.nesting,
151
+ level,
142
152
  };
143
153
  if (nameStart) {
144
154
  symbol.nameAnchor = { line: nameStart.line + 1, character: nameStart.character + 1 };
145
155
  }
146
156
  result.push(symbol);
147
157
  if (document?.children?.length) {
148
- result.push(
149
- ...flattenDocumentSymbols(document.children, filePath, sym.name, {
150
- sourceLines: context.sourceLines,
151
- nesting: "nested",
152
- }),
153
- );
158
+ result.push(...flattenDocumentSymbols(document.children, filePath, sym.name, childContext));
154
159
  }
155
160
  }
156
161
  return result;
@@ -1,11 +1,13 @@
1
1
  // Shared session-scoped LSP service registry reused by peer extensions.
2
2
 
3
3
  // biome-ignore lint/style/noExcessiveLinesPerFile: session registry, runtime recovery, and telemetry stay in one file; splitting would hide the recovery contract.
4
+ import { fileURLToPath } from "node:url";
4
5
  import {
5
6
  type CodeQueryResult,
6
7
  type CodeRequestControl,
7
8
  isCodeRequestInterruption,
8
9
  mapCodeQueryResult,
10
+ partialCodeQuery,
9
11
  throwIfCodeRequestInterrupted,
10
12
  unavailableCodeQuery,
11
13
  } from "@mrclrchtr/supi-code-runtime/api";
@@ -139,6 +141,8 @@ function recordScopeDecisionEvent(manager: LspManager): void {
139
141
  }
140
142
  }
141
143
 
144
+ const FILE_STARTUP_FAILURE_REASON = "The routed LSP client could not be started for this file";
145
+
142
146
  class DefaultWorkspaceLspRuntime implements WorkspaceLspRuntime {
143
147
  constructor(private readonly manager: LspManager) {}
144
148
 
@@ -157,6 +161,7 @@ class DefaultWorkspaceLspRuntime implements WorkspaceLspRuntime {
157
161
  const resolvedPath = this.resolveFilePath(filePath);
158
162
  const client = await this.manager.ensureFileOpen(resolvedPath, {
159
163
  recoverProcessCrash: true,
164
+ protectInputs: true,
160
165
  control,
161
166
  });
162
167
  if (!client) return unavailableFileQuery("hover", resolvedPath);
@@ -171,6 +176,7 @@ class DefaultWorkspaceLspRuntime implements WorkspaceLspRuntime {
171
176
  const resolvedPath = this.resolveFilePath(filePath);
172
177
  const client = await this.manager.ensureFileOpen(resolvedPath, {
173
178
  recoverProcessCrash: true,
179
+ protectInputs: true,
174
180
  control,
175
181
  });
176
182
  if (!client) return unavailableFileQuery("definition", resolvedPath);
@@ -185,12 +191,18 @@ class DefaultWorkspaceLspRuntime implements WorkspaceLspRuntime {
185
191
  const resolvedPath = this.resolveFilePath(filePath);
186
192
  const client = await this.manager.ensureFileOpen(resolvedPath, {
187
193
  recoverProcessCrash: true,
194
+ protectInputs: true,
188
195
  control,
189
196
  });
190
197
  if (!client) return unavailableFileQuery("references", resolvedPath);
191
- return mapCodeQueryResult(
192
- await client.references(resolvedPath, position, control),
193
- (data) => data ?? [],
198
+ return this.withProjectCoverage(
199
+ client,
200
+ { file: resolvedPath, position },
201
+ "references",
202
+ mapCodeQueryResult(
203
+ await client.references(resolvedPath, position, control),
204
+ (data) => data ?? [],
205
+ ),
194
206
  );
195
207
  }
196
208
 
@@ -202,10 +214,34 @@ class DefaultWorkspaceLspRuntime implements WorkspaceLspRuntime {
202
214
  const resolvedPath = this.resolveFilePath(filePath);
203
215
  const client = await this.manager.ensureFileOpen(resolvedPath, {
204
216
  recoverProcessCrash: true,
217
+ protectInputs: true,
205
218
  control,
206
219
  });
207
220
  if (!client) return unavailableFileQuery("implementation", resolvedPath);
208
- return client.implementation(resolvedPath, position, control);
221
+ return this.withProjectCoverage(
222
+ client,
223
+ { file: resolvedPath, position },
224
+ "implementation",
225
+ await client.implementation(resolvedPath, position, control),
226
+ );
227
+ }
228
+
229
+ /**
230
+ * Downgrade a completed project-wide answer to partial when the TypeScript
231
+ * server may not have searched every relevant project.
232
+ */
233
+ private withProjectCoverage<T extends Location[] | Location | LocationLink[] | null>(
234
+ client: NonNullable<Awaited<ReturnType<LspManager["ensureFileOpen"]>>>,
235
+ target: { readonly file: string; readonly position: Position },
236
+ request: "references" | "implementation",
237
+ result: CodeQueryResult<T>,
238
+ ): CodeQueryResult<T> {
239
+ if (result.kind !== "completed") return result;
240
+ const gap = this.manager.typeScriptProjectCoverageGap(client, target.file, request, {
241
+ resultFiles: locationFiles(result.data),
242
+ position: target.position,
243
+ });
244
+ return gap ? partialCodeQuery(result.data, gap) : result;
209
245
  }
210
246
 
211
247
  async documentSymbols(
@@ -215,6 +251,7 @@ class DefaultWorkspaceLspRuntime implements WorkspaceLspRuntime {
215
251
  const resolvedPath = this.resolveFilePath(filePath);
216
252
  const client = await this.manager.ensureFileOpen(resolvedPath, {
217
253
  recoverProcessCrash: true,
254
+ protectInputs: true,
218
255
  control,
219
256
  });
220
257
  if (!client) return unavailableFileQuery("document symbols", resolvedPath);
@@ -260,6 +297,7 @@ class DefaultWorkspaceLspRuntime implements WorkspaceLspRuntime {
260
297
  const resolvedPath = this.resolveFilePath(filePath);
261
298
  const client = await this.manager.ensureFileOpen(resolvedPath, {
262
299
  recoverProcessCrash: true,
300
+ protectInputs: true,
263
301
  control,
264
302
  });
265
303
  if (!client) return null;
@@ -309,7 +347,7 @@ class DefaultWorkspaceLspRuntime implements WorkspaceLspRuntime {
309
347
  const processCrashRecovery = this.manager.getProcessCrashRecoveryReportForFile(resolvedPath);
310
348
  return {
311
349
  kind: "unavailable",
312
- reason: "No LSP client can serve this file",
350
+ reason: this.fileUnavailableReason(resolvedPath, "No LSP client can serve this file"),
313
351
  ...(processCrashRecovery ? { processCrashRecovery } : {}),
314
352
  };
315
353
  }
@@ -347,7 +385,7 @@ class DefaultWorkspaceLspRuntime implements WorkspaceLspRuntime {
347
385
  if (!client) {
348
386
  return {
349
387
  kind: "unavailable",
350
- reason: "The routed LSP client could not be started for this file",
388
+ reason: this.fileUnavailableReason(resolvedPath, FILE_STARTUP_FAILURE_REASON),
351
389
  ...startup,
352
390
  ...recovery,
353
391
  };
@@ -355,6 +393,15 @@ class DefaultWorkspaceLspRuntime implements WorkspaceLspRuntime {
355
393
  return { kind: "ready", ...startup, ...recovery };
356
394
  }
357
395
 
396
+ private fileUnavailableReason(filePath: string, configuredRouteReason: string): string {
397
+ if (!this.manager.hasConfiguredServerForFile(filePath)) {
398
+ return "No compatible LSP server is configured for this file";
399
+ }
400
+ return this.manager.hasFailedStartupForFile(filePath)
401
+ ? FILE_STARTUP_FAILURE_REASON
402
+ : configuredRouteReason;
403
+ }
404
+
358
405
  /**
359
406
  * Wait until all started LSP clients are ready for semantic queries.
360
407
  * Performs one representative warm-up probe per client/root.
@@ -588,6 +635,21 @@ export async function waitForWorkspaceLspRuntime(
588
635
  return state;
589
636
  }
590
637
 
638
+ /** Local file paths of LSP locations or location links; non-file URIs are skipped. */
639
+ function locationFiles(data: Location[] | Location | LocationLink[] | null): string[] {
640
+ const items: (Location | LocationLink)[] =
641
+ data === null ? [] : Array.isArray(data) ? data : [data];
642
+ return items.flatMap((item) => {
643
+ const uri = "targetUri" in item ? item.targetUri : item.uri;
644
+ if (!uri.startsWith("file:")) return [];
645
+ try {
646
+ return [fileURLToPath(uri)];
647
+ } catch {
648
+ return [];
649
+ }
650
+ });
651
+ }
652
+
591
653
  /** Remove the LSP service state for a session cwd. */
592
654
  export function clearWorkspaceLspRuntime(cwd: string): void {
593
655
  registry.clear(cwd);
@@ -21,6 +21,7 @@ export async function runRoutedRename(options: {
21
21
  const { manager, file, position, newName, control } = options;
22
22
  const client = await manager.ensureFileOpen(file, {
23
23
  recoverProcessCrash: true,
24
+ protectInputs: true,
24
25
  control,
25
26
  });
26
27
  if (!client) return null;
@@ -143,7 +143,11 @@ export interface WorkspaceLspRuntime extends WorkspaceLspDiagnosticSurface {
143
143
  filePath: string,
144
144
  control?: CodeRequestControl,
145
145
  ): Promise<CodeQueryResult<DocumentSymbol[] | SymbolInformation[]>>;
146
- /** Collect symbols from routes that can contribute within the optional path scopes. */
146
+ /**
147
+ * Collect symbols from contributing routes; completeness is relative to scopes.
148
+ * Data may include out-of-scope rows, which callers must filter themselves.
149
+ * Only policy exclusions proven outside every scope are irrelevant to completeness.
150
+ */
147
151
  workspaceSymbol(
148
152
  query: string,
149
153
  control?: CodeRequestControl,