@mrclrchtr/supi-lsp 11.3.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.
package/README.md CHANGED
@@ -100,7 +100,7 @@ For example, select a compiler installed as `my-ts6` without changing the TLS co
100
100
  - `code_health` and `/supi-ci-status` show the `native` or `tsserver` backend and resolved compiler version when known, separately from wrapper version. Manual/legacy-fallback launches have no inferred compiler identity. Missing versions remain unknown. Selection failures identify the package/root and a corrective action.
101
101
  - Topmost-root collapsing remains unchanged. Mixed nested backends, Windows live qualification, dependency watchers, performance benchmarking, and full TLS/native feature parity are outside this change. Existing legacy TLS support is not deliberately removed.
102
102
 
103
- Focused qualification pins native TypeScript **7.0.2**, TLS **6.0.2**, ordinary TypeScript **6.0.3**, and `@typescript/typescript6` **6.0.2** backed by `@typescript/old` → TypeScript **6.0.3**. The wrapper's dependency range does not guarantee that compiler version; the lockfile and [prerequisite pins](../../scripts/language-baseline/pins.json) fix these combinations. SuPi retains TS6 for its compiler-API dependency and legacy-server tests. Workspace typechecking and declaration builds use the native TS7 launcher directly to avoid ambiguity between the installed `tsc` binaries.
103
+ Focused qualification uses the native TypeScript 7 alias (`@typescript/native`), TLS, ordinary TypeScript 6, and `@typescript/typescript6` backed by `@typescript/old` → TypeScript 6. Exact versions come from the root `package.json` and lockfile; the wrapper's dependency range does not fix its compiler version, so the lockfile does. SuPi retains TS6 for its compiler-API dependency and legacy-server tests. Workspace typechecking and declaration builds use the native TS7 launcher directly to avoid ambiguity between the installed `tsc` binaries.
104
104
 
105
105
  The required [Linux/macOS CI matrix](../../.github/workflows/ci.yml) keeps [selection/layout tests](__tests__/unit/typescript-launch.test.ts), [real native lifecycle tests](__tests__/integration/workspace-runtime.integration.typescript-native.test.ts), and [wrapper lifecycle tests](__tests__/integration/workspace-runtime.integration.typescript-launch.test.ts) separate from the [public TS/JS semantic matrix](../supi-code-intelligence/__tests__/acceptance/language-baseline-harness.ts). Required missing/unavailable behavior fails rather than skips. The matrix covers hover, definitions, references, meaningful implementations, document/workspace symbols, confirmed error → clean diagnostics, refresh, cross-file invalidation, Unicode-safe rename plan/apply and stale-plan rejection, and precise organize-imports. The TypeScript public matrix also checks unused-import removal, unchanged source during planning, stale-plan rejection, and preservation of unrelated text; native support is imports-only. Native TSX/JSX and module suffixes receive routing/synchronization checks rather than duplicate matrices. Existing stale-result and edit-safety suites remain in place.
106
106
 
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mrclrchtr/supi-code-runtime",
3
- "version": "11.3.0",
3
+ "version": "11.4.0",
4
4
  "description": "Shared workspace context and capability contracts for code intelligence",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -14,6 +14,7 @@ export type {
14
14
  StructuralProvider,
15
15
  StructuralResult,
16
16
  } from "./capability/types.ts";
17
+ export { childDeclarationLevel } from "./declaration-level.ts";
17
18
  export type {
18
19
  CFamilyExtension,
19
20
  CFamilyFileAssociationResolver,
@@ -50,7 +51,7 @@ export type {
50
51
  CodeResult,
51
52
  CodeSymbol,
52
53
  ConfidenceMode,
53
- DeclarationNesting,
54
+ DeclarationLevel,
54
55
  DisambiguationCandidate,
55
56
  DocumentCodeSymbol,
56
57
  DocumentEditPrecondition,
@@ -0,0 +1,34 @@
1
+ import type { DeclarationLevel } from "./types.ts";
2
+
3
+ /** Declarations whose body holds statements, so nested declarations are local to it. */
4
+ const FUNCTION_LIKE_KINDS = new Set(["function", "method", "constructor"]);
5
+ /** Bindings whose value may be a function (locals) or an object literal (members). */
6
+ const VALUE_BINDING_KINDS = new Set(["variable", "constant", "property", "field"]);
7
+ /** Kinds that name an entry of an object, type, or enum rather than a statement-level binding. */
8
+ const MEMBER_ENTRY_KINDS = new Set(["property", "field", "method", "enummember", "key", "event"]);
9
+
10
+ /**
11
+ * Level of a declaration reported directly inside `parent`.
12
+ *
13
+ * Inside a local declaration everything stays local. A function, method, or
14
+ * constructor makes its nested declarations local. A variable, constant,
15
+ * property, or field holds either a function body (nested bindings are local)
16
+ * or an object literal (nested properties and methods are members). Every
17
+ * other container — class, interface, enum, struct, namespace, module, or a
18
+ * document section — makes its nested declarations members.
19
+ *
20
+ * Kinds compare case-insensitively, so LSP SymbolKind names and Tree-sitter
21
+ * outline kinds share one rule.
22
+ */
23
+ export function childDeclarationLevel(
24
+ parent: { readonly kind: string | null; readonly level: DeclarationLevel },
25
+ childKind: string | null,
26
+ ): Exclude<DeclarationLevel, "top-level" | "unknown"> {
27
+ if (parent.level === "local") return "local";
28
+ const parentKind = parent.kind?.toLowerCase() ?? "";
29
+ if (FUNCTION_LIKE_KINDS.has(parentKind)) return "local";
30
+ if (VALUE_BINDING_KINDS.has(parentKind)) {
31
+ return MEMBER_ENTRY_KINDS.has(childKind?.toLowerCase() ?? "") ? "member" : "local";
32
+ }
33
+ return "member";
34
+ }
@@ -34,8 +34,16 @@ export interface SymbolAnchor {
34
34
  character: number;
35
35
  }
36
36
 
37
- /** Provider-backed nesting evidence for a declaration reported within one document. */
38
- export type DeclarationNesting = "top-level" | "nested" | "unknown";
37
+ /**
38
+ * Where a declaration sits within its file, as reported by a provider.
39
+ *
40
+ * - `top-level`: directly in the file, not inside another declaration.
41
+ * - `member`: directly inside a class, interface, enum, struct, namespace,
42
+ * module, or object literal (methods, fields, properties, enum members).
43
+ * - `local`: inside the body of a function, method, or constructor.
44
+ * - `unknown`: the provider reported a flat symbol list without hierarchy.
45
+ */
46
+ export type DeclarationLevel = "top-level" | "member" | "local" | "unknown";
39
47
 
40
48
  /**
41
49
  * A discovered symbol / declaration.
@@ -54,7 +62,7 @@ export interface CodeSymbol {
54
62
  file: string;
55
63
  declarationAnchor: SymbolAnchor;
56
64
  nameAnchor?: SymbolAnchor;
57
- /** Named symbolic container when reported; absence does not prove top-level nesting. */
65
+ /** Named enclosing declaration when reported; absence does not prove a top-level declaration. */
58
66
  container?: string | null;
59
67
  }
60
68
 
@@ -63,10 +71,10 @@ export interface CodeSymbol {
63
71
  *
64
72
  * `unknown` means the provider returned a flat observation without an actual
65
73
  * hierarchy. A reported container name remains metadata and does not promote
66
- * a flat observation to known nesting.
74
+ * a flat observation to a known level.
67
75
  */
68
76
  export interface DocumentCodeSymbol extends CodeSymbol {
69
- nesting: DeclarationNesting;
77
+ level: DeclarationLevel;
70
78
  }
71
79
 
72
80
  // ── Result types ───────────────────────────────────────────────────────
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mrclrchtr/supi-core",
3
- "version": "11.3.0",
3
+ "version": "11.4.0",
4
4
  "description": "Shared settings, configuration, reporting, and session infrastructure",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -119,7 +119,7 @@ class Range {
119
119
  debug('hyphen replace', range)
120
120
 
121
121
  // `> 1.2.3 < 1.2.5` => `>1.2.3 <1.2.5`
122
- range = range.replace(re[t.COMPARATORTRIM], comparatorTrimReplace)
122
+ range = trimComparatorWhitespace(range)
123
123
  debug('comparator trim', range)
124
124
 
125
125
  // `~ 1.2.3` => `~1.2.3`
@@ -224,11 +224,11 @@ const parseOptions = require('../internal/parse-options')
224
224
  const Comparator = require('./comparator')
225
225
  const debug = require('../internal/debug')
226
226
  const SemVer = require('./semver')
227
+ const trimComparatorWhitespace = require('../internal/trim-comparator-whitespace')
227
228
  const {
228
229
  safeRe: re,
229
230
  src,
230
231
  t,
231
- comparatorTrimReplace,
232
232
  tildeTrimReplace,
233
233
  caretTrimReplace,
234
234
  } = require('../internal/re')
@@ -15,6 +15,35 @@ const MAX_SAFE_COMPONENT_LENGTH = 16
15
15
  // the shortest version with a build 0.0.0+BUILD.
16
16
  const MAX_SAFE_BUILD_LENGTH = MAX_LENGTH - 6
17
17
 
18
+ // ECMAScript whitespace and line terminators matched by \s.
19
+ const WHITESPACE_CHARACTERS = [
20
+ '\t',
21
+ '\n',
22
+ '\v',
23
+ '\f',
24
+ '\r',
25
+ ' ',
26
+ '\u00a0',
27
+ '\u1680',
28
+ '\u2000',
29
+ '\u2001',
30
+ '\u2002',
31
+ '\u2003',
32
+ '\u2004',
33
+ '\u2005',
34
+ '\u2006',
35
+ '\u2007',
36
+ '\u2008',
37
+ '\u2009',
38
+ '\u200a',
39
+ '\u2028',
40
+ '\u2029',
41
+ '\u202f',
42
+ '\u205f',
43
+ '\u3000',
44
+ '\ufeff',
45
+ ]
46
+
18
47
  const RELEASE_TYPES = [
19
48
  'major',
20
49
  'premajor',
@@ -30,6 +59,7 @@ module.exports = {
30
59
  MAX_SAFE_COMPONENT_LENGTH,
31
60
  MAX_SAFE_BUILD_LENGTH,
32
61
  MAX_SAFE_INTEGER,
62
+ WHITESPACE_CHARACTERS,
33
63
  RELEASE_TYPES,
34
64
  SEMVER_SPEC_VERSION,
35
65
  FLAG_INCLUDE_PRERELEASE: 0b001,
@@ -0,0 +1,438 @@
1
+ // @ts-check
2
+ 'use strict'
3
+
4
+ // Reproduce safeRe[COMPARATORTRIM] replacement boundaries, not version
5
+ // validation. Each end-index finder returns an exclusive end, or its start
6
+ // on no match.
7
+ // Positions count from zero. An end of 3 means characters 0, 1, and 2 were read;
8
+ // range.slice(start, end) gives the matched text, not a converted number.
9
+ const {
10
+ MAX_LENGTH,
11
+ MAX_SAFE_BUILD_LENGTH,
12
+ WHITESPACE_CHARACTERS,
13
+ } = require('./constants')
14
+
15
+ /**
16
+ * Remembers the last two rows of digits so their characters need not be reread.
17
+ * Positions start at -1 until a row has been found.
18
+ *
19
+ * @typedef {object} DigitRunCache
20
+ * @property {number} start The first character of the most recently found row.
21
+ * @property {number} end The position after that row's last character.
22
+ * @property {number} previousStart The first character of the previous row.
23
+ * @property {number} previousEnd The position after the previous row.
24
+ */
25
+
26
+ // One entry per UTF-16 code unit keeps membership checks constant-time.
27
+ const whitespace = new Uint8Array(0x10000)
28
+ for (const char of WHITESPACE_CHARACTERS) {
29
+ whitespace[char.charCodeAt(0)] = 1
30
+ }
31
+
32
+ /**
33
+ * Checks whether a character is whitespace, like a space, tab, or newline.
34
+ * Looking past the end of the string returns false.
35
+ *
36
+ * @param {string|undefined} char One character, or undefined past the string's end.
37
+ * @returns {boolean} Whether the character is whitespace.
38
+ * @example isWhitespace(' ') // true
39
+ * @example isWhitespace('1') // false
40
+ */
41
+ const isWhitespace = char =>
42
+ char !== undefined && whitespace[char.charCodeAt(0)] === 1
43
+
44
+ /**
45
+ * Checks for v, =, or whitespace before the version number.
46
+ * These are historical version prefixes, not range operators:
47
+ * < and > are handled separately, as are tilde and caret ranges.
48
+ *
49
+ * @param {string|undefined} char One character to check.
50
+ * @returns {boolean} Whether the character can belong to a version prefix.
51
+ * @example isVersionPrefix('=') // true
52
+ * @example isVersionPrefix('>') // false
53
+ */
54
+ const isVersionPrefix = char =>
55
+ char === 'v' ||
56
+ char === '=' ||
57
+ isWhitespace(char)
58
+
59
+ /**
60
+ * Checks whether a character is one of the digits 0 through 9.
61
+ *
62
+ * @param {string|undefined} char One character to check.
63
+ * @returns {boolean} Whether the character is an ASCII digit.
64
+ * @example isDigit('7') // true
65
+ * @example isDigit('.') // false
66
+ */
67
+ const isDigit = char => char !== undefined && char >= '0' && char <= '9'
68
+
69
+ /**
70
+ * Checks whether a character is an English letter, uppercase or lowercase.
71
+ *
72
+ * @param {string|undefined} char One character to check.
73
+ * @returns {boolean} Whether the character is an ASCII letter.
74
+ * @example isLetter('b') // true
75
+ * @example isLetter('7') // false
76
+ */
77
+ const isLetter = char =>
78
+ char !== undefined && (
79
+ (char >= 'a' && char <= 'z') ||
80
+ (char >= 'A' && char <= 'Z')
81
+ )
82
+
83
+ /**
84
+ * Checks for a letter, digit, or hyphen: the characters allowed inside
85
+ * prerelease and build names such as beta-1.
86
+ *
87
+ * @param {string|undefined} char One character to check.
88
+ * @returns {boolean} Whether the character can be part of an identifier.
89
+ * @example isIdentifierCharacter('-') // true
90
+ * @example isIdentifierCharacter('.') // false; dots separate identifiers
91
+ */
92
+ const isIdentifierCharacter = char => isDigit(char) || isLetter(char) || char === '-'
93
+
94
+ /**
95
+ * Checks whether a version component can begin here: with a digit or
96
+ * a wildcard such as x, X, or *.
97
+ *
98
+ * @param {string|undefined} char One character to check.
99
+ * @returns {boolean} Whether a number or wildcard can start with this character.
100
+ * @example isVersionStart('x') // true
101
+ * @example isVersionStart('v') // false; v belongs to the prefix
102
+ */
103
+ const isVersionStart = char =>
104
+ isDigit(char) ||
105
+ char === 'x' ||
106
+ char === 'X' ||
107
+ char === '*'
108
+
109
+ /**
110
+ * Finds the end of a row of digits, but returns no more than limit digits.
111
+ * Remembers the last two rows so looking at them again does not reread them.
112
+ *
113
+ * @param {string} range The text to read; it is not changed.
114
+ * @param {number} start The position of the first character to check.
115
+ * @param {number} limit The most digit characters the returned match can include.
116
+ * @param {DigitRunCache} digitRuns Shared cache of digit-run positions, updated in place.
117
+ * @returns {number} The position after the matched digits, or start if none match.
118
+ * @example findDigitsEndIndex('123abc', 0, 10, digitRuns) // 3, pointing at a
119
+ * @example findDigitsEndIndex('123abc', 0, 2, digitRuns) // 2, pointing at the unread 3
120
+ */
121
+ const findDigitsEndIndex = (range, start, limit, digitRuns) => {
122
+ if (!isDigit(range[start])) {
123
+ return start
124
+ }
125
+ if (start >= digitRuns.start && start < digitRuns.end) {
126
+ return Math.min(start + limit, digitRuns.end)
127
+ }
128
+ if (start >= digitRuns.previousStart && start < digitRuns.previousEnd) {
129
+ return Math.min(start + limit, digitRuns.previousEnd)
130
+ }
131
+ let end = start + 1
132
+ while (isDigit(range[end])) {
133
+ end++
134
+ }
135
+ // A failed full version can rewind from its second run to its first.
136
+ digitRuns.previousStart = digitRuns.start
137
+ digitRuns.previousEnd = digitRuns.end
138
+ digitRuns.start = start
139
+ digitRuns.end = end
140
+ return Math.min(start + limit, end)
141
+ }
142
+
143
+ /**
144
+ * Reads a number using the old regex's length and leading-zero rules.
145
+ * Strict mode reads just the first zero in 012; loose mode can read all three.
146
+ *
147
+ * @param {string} range The text to read; it is not changed.
148
+ * @param {number} start The position where the number should begin.
149
+ * @param {boolean} loose Whether to allow leading zeros.
150
+ * @param {DigitRunCache} digitRuns Shared digit-run cache, updated as needed.
151
+ * @returns {number} The position after the matched digits, not their numeric value.
152
+ * @example findVersionNumberEndIndex('012', 0, false, digitRuns) // 1
153
+ * @example findVersionNumberEndIndex('012', 0, true, digitRuns) // 3
154
+ */
155
+ const findVersionNumberEndIndex = (range, start, loose, digitRuns) => {
156
+ if (!loose && range[start] === '0') {
157
+ return start + 1
158
+ }
159
+ // Strict syntax allows an initial nonzero digit plus MAX_LENGTH digits.
160
+ return findDigitsEndIndex(range, start, loose ? MAX_LENGTH : MAX_LENGTH + 1, digitRuns)
161
+ }
162
+
163
+ /**
164
+ * Reads one piece of a prerelease name, such as beta or 1 in beta.1.
165
+ * Tries the letter-or-hyphen form first, then the number-only form.
166
+ *
167
+ * @param {string} range The text to read; it is not changed.
168
+ * @param {number} start The position where the identifier should begin.
169
+ * @param {boolean} loose Whether numeric identifiers may have leading zeros.
170
+ * @param {DigitRunCache} digitRuns Shared digit-run cache, updated as needed.
171
+ * @returns {number} The position after the identifier, or start if none matches.
172
+ * @example findPrereleaseIdentifierEndIndex('beta.1', 0, false, digitRuns) // 4, at the dot
173
+ * @example findPrereleaseIdentifierEndIndex('001beta', 0, false, digitRuns) // 7
174
+ */
175
+ const findPrereleaseIdentifierEndIndex = (range, start, loose, digitRuns) => {
176
+ const digitsEnd = findDigitsEndIndex(range, start, MAX_LENGTH, digitRuns)
177
+ // The legacy regex tries a nonnumeric identifier before a numeric one.
178
+ if (isLetter(range[digitsEnd]) || range[digitsEnd] === '-') {
179
+ let end = digitsEnd + 1
180
+ const limit = end + MAX_SAFE_BUILD_LENGTH
181
+ while (end < limit && isIdentifierCharacter(range[end])) {
182
+ end++
183
+ }
184
+ return end
185
+ }
186
+ return findVersionNumberEndIndex(range, start, loose, digitRuns)
187
+ }
188
+
189
+ /**
190
+ * Reads a prerelease suffix such as -beta.1, one dot-separated piece at a time.
191
+ * Loose mode also allows the name without its opening hyphen.
192
+ *
193
+ * @param {string} range The text to read; it is not changed.
194
+ * @param {number} start The position where the suffix should begin, including -.
195
+ * @param {boolean} loose Whether the opening hyphen is optional and zeros allowed.
196
+ * @param {DigitRunCache} digitRuns Shared digit-run cache, updated as needed.
197
+ * @returns {number} The position after the matched suffix, or start if absent.
198
+ * @example findPrereleaseEndIndex('-beta.1+sha', 0, false, digitRuns) // 7, at the plus
199
+ * @example findPrereleaseEndIndex('beta', 0, false, digitRuns) // 0; no opening hyphen
200
+ */
201
+ const findPrereleaseEndIndex = (range, start, loose, digitRuns) => {
202
+ const hyphen = range[start] === '-'
203
+ if (!loose && !hyphen) {
204
+ return start
205
+ }
206
+ const identifierStart = start + (hyphen ? 1 : 0)
207
+ let end = findPrereleaseIdentifierEndIndex(range, identifierStart, loose, digitRuns)
208
+ if (end === identifierStart) {
209
+ if (!loose || !hyphen) {
210
+ return start
211
+ }
212
+ // In loose syntax, a lone hyphen can itself be the identifier.
213
+ end = findPrereleaseIdentifierEndIndex(range, start, loose, digitRuns)
214
+ }
215
+ while (range[end] === '.') {
216
+ const next = findPrereleaseIdentifierEndIndex(range, end + 1, loose, digitRuns)
217
+ if (next === end + 1) {
218
+ break
219
+ }
220
+ end = next
221
+ }
222
+ return end
223
+ }
224
+
225
+ /**
226
+ * Reads one build-name piece, such as sha or 123 in +sha.123.
227
+ * Stops at a disallowed character or the old regex's length limit.
228
+ *
229
+ * @param {string} range The text to read; it is not changed.
230
+ * @param {number} start The position where the identifier should begin.
231
+ * @returns {number} The position after the identifier, or start if none matches.
232
+ * @example findBuildIdentifierEndIndex('sha.123', 0) // 3, at the dot
233
+ * @example findBuildIdentifierEndIndex('001', 0) // 3; leading zeros are allowed
234
+ */
235
+ const findBuildIdentifierEndIndex = (range, start) => {
236
+ let end = start
237
+ while (end - start < MAX_SAFE_BUILD_LENGTH && isIdentifierCharacter(range[end])) {
238
+ end++
239
+ }
240
+ return end
241
+ }
242
+
243
+ /**
244
+ * Reads optional build metadata such as +sha.123.
245
+ * Leaves the position unchanged if there is no plus sign followed by a name.
246
+ *
247
+ * @param {string} range The text to read; it is not changed.
248
+ * @param {number} start The position where the opening + should be.
249
+ * @returns {number} The position after the matched metadata, or start if absent.
250
+ * @example findBuildEndIndex('+sha.123 <2', 0) // 8, at the space
251
+ * @example findBuildEndIndex('+', 0) // 0; a plus sign alone is not a match
252
+ */
253
+ const findBuildEndIndex = (range, start) => {
254
+ if (range[start] !== '+') {
255
+ return start
256
+ }
257
+ let end = findBuildIdentifierEndIndex(range, start + 1)
258
+ if (end === start + 1) {
259
+ return start
260
+ }
261
+ while (range[end] === '.') {
262
+ const next = findBuildIdentifierEndIndex(range, end + 1)
263
+ if (next === end + 1) {
264
+ break
265
+ }
266
+ end = next
267
+ }
268
+ return end
269
+ }
270
+
271
+ /**
272
+ * Reads three numbers separated by dots, then any prerelease and build suffixes.
273
+ * Uses loose rules, so forms such as 01.02.03beta can match here.
274
+ *
275
+ * @param {string} range The text to read; it is not changed.
276
+ * @param {number} start The position of the first number, after any prefix.
277
+ * @param {DigitRunCache} digitRuns Shared digit-run cache, updated as needed.
278
+ * @returns {number} The position after the version match, or start if none matches.
279
+ * @example findLooseFullVersionEndIndex('1.2.3-beta+sha <2', 0, digitRuns) // 14
280
+ * @example findLooseFullVersionEndIndex('1.2', 0, digitRuns) // 0; needs three numbers
281
+ */
282
+ const findLooseFullVersionEndIndex = (range, start, digitRuns) => {
283
+ let end = start
284
+ for (let component = 0; component < 3; component++) {
285
+ const next = findVersionNumberEndIndex(range, end, true, digitRuns)
286
+ if (next === end) {
287
+ return start
288
+ }
289
+ end = next
290
+ if (component < 2) {
291
+ if (range[end] !== '.') {
292
+ return start
293
+ }
294
+ end++
295
+ }
296
+ }
297
+ const prereleaseEnd = findPrereleaseEndIndex(range, end, true, digitRuns)
298
+ return findBuildEndIndex(range, prereleaseEnd)
299
+ }
300
+
301
+ /**
302
+ * Reads one component of a partial or wildcard version, such as 2 or x in 2.x.
303
+ * Numbers use strict leading-zero rules; wildcards take up one character.
304
+ *
305
+ * @param {string} range The text to read; it is not changed.
306
+ * @param {number} start The position where the component should begin.
307
+ * @param {DigitRunCache} digitRuns Shared digit-run cache, updated as needed.
308
+ * @returns {number} The position after the component, or start if none matches.
309
+ * @example findPartialVersionComponentEndIndex('2.x', 0, digitRuns) // 1, at the dot
310
+ * @example findPartialVersionComponentEndIndex('2.x', 2, digitRuns) // 3, after x
311
+ */
312
+ const findPartialVersionComponentEndIndex = (range, start, digitRuns) => {
313
+ if (isDigit(range[start])) {
314
+ return findVersionNumberEndIndex(range, start, false, digitRuns)
315
+ }
316
+ return isVersionStart(range[start]) ? start + 1 : start
317
+ }
318
+
319
+ /**
320
+ * Reads up to three components, allowing forms such as 1, 1.2, or 1.x.
321
+ * After the third component, also reads any strict prerelease and build suffixes.
322
+ *
323
+ * @param {string} range The text to read; it is not changed.
324
+ * @param {number} start The position of a number or wildcard, after any prefix.
325
+ * @param {DigitRunCache} digitRuns Shared digit-run cache, updated as needed.
326
+ * @returns {number} The position after the matched version text.
327
+ * @example findPartialVersionEndIndex('1.x <2', 0, digitRuns) // 3, at the space
328
+ * @example findPartialVersionEndIndex('1.', 0, digitRuns) // 1; the trailing dot is unread
329
+ */
330
+ const findPartialVersionEndIndex = (range, start, digitRuns) => {
331
+ let end = findPartialVersionComponentEndIndex(range, start, digitRuns)
332
+ for (let component = 0; component < 2; component++) {
333
+ if (range[end] !== '.') {
334
+ return end
335
+ }
336
+ const next = findPartialVersionComponentEndIndex(range, end + 1, digitRuns)
337
+ if (next === end + 1) {
338
+ return end
339
+ }
340
+ end = next
341
+ }
342
+ const prereleaseEnd = findPrereleaseEndIndex(range, end, false, digitRuns)
343
+ return findBuildEndIndex(range, prereleaseEnd)
344
+ }
345
+
346
+ /**
347
+ * Finds where a version match ends, without deciding whether the range is valid.
348
+ * Tries a loose full version first, then a partial or wildcard version, just
349
+ * like the old regex's LOOSEPLAIN and XRANGEPLAIN alternatives.
350
+ *
351
+ * @param {string} range The text to read; it is not changed.
352
+ * @param {number} start The position of a number or wildcard, after any prefix.
353
+ * @param {DigitRunCache} digitRuns Shared digit-run cache, updated as needed.
354
+ * @returns {number} The position where scanning the next candidate can resume.
355
+ * @example findVersionEndIndex('1.2.3 <2', 0, digitRuns) // 5, at the space
356
+ * @example findVersionEndIndex('1.x <2', 0, digitRuns) // 3, at the space
357
+ */
358
+ const findVersionEndIndex = (range, start, digitRuns) => {
359
+ const fullVersionEnd = findLooseFullVersionEndIndex(range, start, digitRuns)
360
+ return fullVersionEnd !== start
361
+ ? fullVersionEnd
362
+ : findPartialVersionEndIndex(range, start, digitRuns)
363
+ }
364
+
365
+ /**
366
+ * Finds the one whitespace character the old replacement would remove.
367
+ * Keeps leading whitespace, skips an optional operator, and checks immediately
368
+ * after it. Returns -1 if there is nothing to remove.
369
+ *
370
+ * @param {string} range The text containing a candidate already found by the caller.
371
+ * @param {number} matchStart The candidate's position, including leading whitespace.
372
+ * @returns {number} The position of the removable whitespace, or -1 if absent.
373
+ * @example findRemovableWhitespaceIndex(' > 1', 0) // 2; keep the space at position 0
374
+ * @example findRemovableWhitespaceIndex('>1', 0) // -1
375
+ */
376
+ const findRemovableWhitespaceIndex = (range, matchStart) => {
377
+ let position = matchStart
378
+ if (isWhitespace(range[position])) {
379
+ position++
380
+ }
381
+ if (range[position] === '<' || range[position] === '>') {
382
+ position++
383
+ }
384
+ if (range[position] === '=') {
385
+ position++
386
+ }
387
+ return isWhitespace(range[position]) ? position : -1
388
+ }
389
+
390
+ /**
391
+ * Joins an operator to its version, turning > 1.2.3 into >1.2.3.
392
+ * Copies everything else according to the old regex's matching rules.
393
+ * Moves forward through prefixes and whole versions to avoid repeatedly
394
+ * searching the same characters on long inputs.
395
+ *
396
+ * @param {string} range The range text to process; the original string is unchanged.
397
+ * @returns {string} The range with matched operator whitespace removed.
398
+ * @example trimComparatorWhitespace('> 1.2.3 < 2') // '>1.2.3 <2'
399
+ * @example trimComparatorWhitespace('> invalid') // '> invalid'
400
+ */
401
+ const trimComparatorWhitespace = range => {
402
+ let result = ''
403
+ let copyFrom = 0
404
+ let cursor = 0
405
+ const digitRuns = { start: -1, end: -1, previousStart: -1, previousEnd: -1 }
406
+
407
+ while (cursor < range.length) {
408
+ const matchStart = cursor
409
+ if (range[cursor] === '<' || range[cursor] === '>') {
410
+ cursor++
411
+ } else if (!isVersionPrefix(range[cursor]) && !isVersionStart(range[cursor])) {
412
+ cursor++
413
+ continue
414
+ }
415
+
416
+ // Consume a v/=/whitespace run once, even when no version follows it.
417
+ while (cursor < range.length && isVersionPrefix(range[cursor])) {
418
+ cursor++
419
+ }
420
+ if (!isVersionStart(range[cursor])) {
421
+ continue
422
+ }
423
+
424
+ const whitespacePosition = findRemovableWhitespaceIndex(range, matchStart)
425
+ if (whitespacePosition !== -1) {
426
+ result += range.slice(copyFrom, whitespacePosition)
427
+ copyFrom = whitespacePosition + 1
428
+ }
429
+
430
+ // Skip the entire match, so a prerelease's trailing v cannot be
431
+ // reconsidered as the prefix of another version.
432
+ cursor = findVersionEndIndex(range, cursor, digitRuns)
433
+ }
434
+
435
+ return result + range.slice(copyFrom)
436
+ }
437
+
438
+ module.exports = trimComparatorWhitespace
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "semver",
3
- "version": "7.8.5",
3
+ "version": "7.8.6",
4
4
  "description": "The semantic version parser used by npm.",
5
5
  "main": "index.js",
6
6
  "scripts": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mrclrchtr/supi-lsp",
3
- "version": "11.3.0",
3
+ "version": "11.4.0",
4
4
  "description": "Language Server Protocol runtime for SuPi code intelligence",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -38,8 +38,8 @@
38
38
  "vscode-jsonrpc": "^10.0.0",
39
39
  "vscode-languageserver-protocol": "^3.18.5",
40
40
  "vscode-languageserver-types": "^3.18.5",
41
- "@mrclrchtr/supi-code-runtime": "11.3.0",
42
- "@mrclrchtr/supi-core": "11.3.0"
41
+ "@mrclrchtr/supi-code-runtime": "11.4.0",
42
+ "@mrclrchtr/supi-core": "11.4.0"
43
43
  },
44
44
  "bundledDependencies": [
45
45
  "@mrclrchtr/supi-code-runtime",
@@ -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 === "..";
@@ -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
+ }
@@ -128,6 +128,13 @@ import type {
128
128
  OutstandingDiagnosticSummaryEntry,
129
129
  ServerStatus,
130
130
  } from "./manager-types.ts";
131
+ import {
132
+ discoverTypeScriptProjectConfigs,
133
+ findSkippedImplementationProjects,
134
+ findUnloadedTypeScriptProjects,
135
+ singleProjectImplementationReason,
136
+ typeScriptCoverageGapReason,
137
+ } from "./manager-typescript-coverage.ts";
131
138
  import {
132
139
  recoverWorkspaceDiagnostics as recoverWorkspaceDiagnosticsImpl,
133
140
  type WorkspaceRecoveryResult,
@@ -141,6 +148,7 @@ import {
141
148
  workspaceSymbolCollectionResult,
142
149
  workspaceSymbolCoverageGap,
143
150
  } from "./manager-workspace-symbol.ts";
151
+ import { isFileLocalTopLevelDeclaration } from "./typescript-file-local.ts";
144
152
 
145
153
  type UnavailableReason = "missing-command" | "start-failed" | "runtime-error";
146
154
 
@@ -412,6 +420,8 @@ export class LspManager {
412
420
  private warmedSemanticProjects = new Set<string>();
413
421
  /** In-flight warm-up probes keyed by project key so concurrent callers share one probe. */
414
422
  private pendingWarmProbes = new Map<string, Promise<void>>();
423
+ /** Loadable TypeScript project configs per route root; cleared when a config changes. */
424
+ private typeScriptProjectConfigs = new Map<string, string[]>();
415
425
  /** One process-crash recovery budget and shared replacement per route. */
416
426
  private processCrashRecoveries = new Map<string, ProcessCrashRecoveryState>();
417
427
  /** Client generations that completed the initialize handshake. */
@@ -572,6 +582,53 @@ export class LspManager {
572
582
  );
573
583
  }
574
584
 
585
+ /**
586
+ * Coverage gap for a project-wide TypeScript request answered by `client`.
587
+ *
588
+ * Returns a partial-result reason when a TypeScript project that includes
589
+ * `filePath` owns neither an open file nor a file in `resultFiles`, so
590
+ * tsserver may not have searched it; otherwise null. Non-TypeScript routes
591
+ * always return null.
592
+ *
593
+ * Implementation requests not known to use the native backend report every
594
+ * other project instead: tsserver searches only the file's own project.
595
+ *
596
+ * No gap applies when `position` (zero-based) names a top-level declaration
597
+ * its module does not export: no other project can reference it.
598
+ */
599
+ typeScriptProjectCoverageGap(
600
+ client: LspClient,
601
+ filePath: string,
602
+ request: "references" | "implementation",
603
+ evidence: {
604
+ readonly resultFiles?: readonly string[];
605
+ readonly position?: { readonly line: number; readonly character: number };
606
+ } = {},
607
+ ): string | null {
608
+ const { resultFiles = [], position } = evidence;
609
+ if (client.name !== "typescript") return null;
610
+ const rootKey = path.resolve(client.root);
611
+ let configs = this.typeScriptProjectConfigs.get(rootKey);
612
+ if (!configs) {
613
+ configs = discoverTypeScriptProjectConfigs(rootKey, this.automaticPathPolicy);
614
+ this.typeScriptProjectConfigs.set(rootKey, configs);
615
+ }
616
+ const file = resolveSessionPath(this.cwd, filePath);
617
+ if (position && isFileLocalTopLevelDeclaration(file, position)) return null;
618
+ if (request === "implementation" && client.typescriptSelection?.backend !== "native") {
619
+ const skipped = findSkippedImplementationProjects({ file, cwd: this.cwd, configs });
620
+ return skipped.length > 0 ? singleProjectImplementationReason(skipped) : null;
621
+ }
622
+ const unloaded = findUnloadedTypeScriptProjects({
623
+ file,
624
+ cwd: this.cwd,
625
+ openFiles: client.openFiles,
626
+ resultFiles,
627
+ configs,
628
+ });
629
+ return unloaded.length > 0 ? typeScriptCoverageGapReason(unloaded, this.cwd) : null;
630
+ }
631
+
575
632
  /** Get or create an LSP client for the given file. */
576
633
  async getClientForFile(
577
634
  filePath: string,
@@ -2397,7 +2454,7 @@ export class LspManager {
2397
2454
  */
2398
2455
  noteWorkspaceChanges(changes: FileEvent[]): void {
2399
2456
  if (changes.length > 0) this.invalidationEpoch++;
2400
- invalidateProjectConfigCaches(changes);
2457
+ if (invalidateProjectConfigCaches(changes)) this.typeScriptProjectConfigs.clear();
2401
2458
  this.notifyWorkspaceFileChanges(changes);
2402
2459
  }
2403
2460
 
@@ -2586,6 +2643,7 @@ export class LspManager {
2586
2643
  this.warmedWorkspaceSymbolFiles.clear();
2587
2644
  this.warmedSemanticProjects.clear();
2588
2645
  this.pendingWarmProbes.clear();
2646
+ this.typeScriptProjectConfigs.clear();
2589
2647
  this.pendingRestarts.clear();
2590
2648
  }
2591
2649
  /** Get status of all servers. */
@@ -3437,15 +3495,19 @@ export class LspManager {
3437
3495
  }
3438
3496
  }
3439
3497
 
3440
- function invalidateProjectConfigCaches(changes: readonly FileEvent[]): void {
3498
+ /** Invalidate parsed project configs; returns whether any config changed. */
3499
+ function invalidateProjectConfigCaches(changes: readonly FileEvent[]): boolean {
3500
+ let changed = false;
3441
3501
  for (const change of changes) {
3442
3502
  const configPath = uriToFile(change.uri);
3443
3503
  if (!isProjectConfigFileName(path.basename(configPath))) continue;
3504
+ changed = true;
3444
3505
  invalidateTsconfigCacheForConfig(configPath);
3445
3506
  if (change.type === FileChangeType.Created) {
3446
3507
  invalidateTsconfigCacheForConfigDir(path.dirname(configPath));
3447
3508
  }
3448
3509
  }
3510
+ return changed;
3449
3511
  }
3450
3512
 
3451
3513
  function collectClientDiagnosticEvidenceDocuments(
@@ -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";
@@ -193,9 +195,14 @@ class DefaultWorkspaceLspRuntime implements WorkspaceLspRuntime {
193
195
  control,
194
196
  });
195
197
  if (!client) return unavailableFileQuery("references", resolvedPath);
196
- return mapCodeQueryResult(
197
- await client.references(resolvedPath, position, control),
198
- (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
+ ),
199
206
  );
200
207
  }
201
208
 
@@ -211,7 +218,30 @@ class DefaultWorkspaceLspRuntime implements WorkspaceLspRuntime {
211
218
  control,
212
219
  });
213
220
  if (!client) return unavailableFileQuery("implementation", resolvedPath);
214
- 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;
215
245
  }
216
246
 
217
247
  async documentSymbols(
@@ -605,6 +635,21 @@ export async function waitForWorkspaceLspRuntime(
605
635
  return state;
606
636
  }
607
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
+
608
653
  /** Remove the LSP service state for a session cwd. */
609
654
  export function clearWorkspaceLspRuntime(cwd: string): void {
610
655
  registry.clear(cwd);