weavatrix 0.2.3 → 0.2.5

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 (36) hide show
  1. package/README.md +91 -20
  2. package/SECURITY.md +18 -0
  3. package/package.json +3 -1
  4. package/skill/SKILL.md +49 -13
  5. package/src/analysis/dead-check.js +48 -2
  6. package/src/analysis/dead-code-review.js +22 -8
  7. package/src/analysis/duplicates.compute.js +11 -6
  8. package/src/analysis/git-ref-graph.js +10 -2
  9. package/src/analysis/hot-path-review.js +228 -0
  10. package/src/analysis/internal-audit.run.js +36 -0
  11. package/src/analysis/source-complexity.report.js +5 -0
  12. package/src/analysis/source-complexity.walk.js +64 -10
  13. package/src/build-graph.js +49 -10
  14. package/src/graph/build-worker.js +21 -1
  15. package/src/graph/builder/lang-java.js +1 -0
  16. package/src/graph/builder/lang-js.js +24 -0
  17. package/src/graph/builder/lang-python.js +161 -4
  18. package/src/graph/freshness-probe.js +3 -3
  19. package/src/graph/incremental-refresh.js +1 -1
  20. package/src/graph/internal-builder.barrels.js +33 -4
  21. package/src/graph/internal-builder.build.js +52 -8
  22. package/src/mcp/catalog.mjs +14 -11
  23. package/src/mcp/graph-context.mjs +143 -14
  24. package/src/mcp/sync-payload.mjs +2 -1
  25. package/src/mcp/tools-actions.mjs +59 -20
  26. package/src/mcp/tools-company.mjs +11 -3
  27. package/src/mcp/tools-graph.mjs +12 -6
  28. package/src/mcp/tools-health.mjs +112 -11
  29. package/src/mcp/tools-impact.mjs +24 -6
  30. package/src/mcp/tools-source.mjs +237 -0
  31. package/src/mcp-server.mjs +99 -11
  32. package/src/path-classification.js +2 -2
  33. package/src/precision/lsp-client.js +682 -0
  34. package/src/precision/lsp-overlay.js +872 -0
  35. package/src/precision/symbol-query.js +137 -0
  36. package/src/precision/typescript-lsp-provider.js +682 -0
@@ -0,0 +1,682 @@
1
+ import {createHash} from 'node:crypto'
2
+ import {existsSync, opendirSync, readFileSync, realpathSync, statSync} from 'node:fs'
3
+ import {dirname, extname, isAbsolute, join, relative, resolve} from 'node:path'
4
+ import {createRequire} from 'node:module'
5
+ import {startStdioLspClient} from './lsp-client.js'
6
+ import {isPathInside} from '../repo-path.js'
7
+
8
+ const requireFromWeavatrix = createRequire(import.meta.url)
9
+ const PROVIDER = 'typescript-language-server'
10
+ export const TYPESCRIPT_LSP_CAPABILITY_CONTRACT = 'typescript-references-v3'
11
+ const WEAVATRIX_VERSION = String(requireFromWeavatrix('../../package.json').version || 'unknown')
12
+ const MAX_PROJECT_FILES = 8_192
13
+ const MAX_CONFIG_FILES = 128
14
+ const MAX_CONFIG_BYTES = 8 * 1024 * 1024
15
+ const MAX_EXTRA_INPUT_BYTES = 64 * 1024 * 1024
16
+ const MAX_PROJECT_INPUT_BYTES = 128 * 1024 * 1024
17
+ const MAX_SINGLE_INPUT_BYTES = 4 * 1024 * 1024
18
+ const MAX_DIRECTORY_ENTRIES = 32_768
19
+ const MAX_DIRECTORIES = 4_096
20
+ const DEFAULT_SAFETY_TIMEOUT_MS = 5_000
21
+ const norm = (value) => String(value || '').replace(/\\/g, '/').replace(/^\.\//, '')
22
+
23
+ let discoveredProvider = null
24
+
25
+ function resolveOwn(specifier) {
26
+ const resolved = requireFromWeavatrix.resolve(specifier)
27
+ if (!isAbsolute(resolved)) throw new Error(`Resolved dependency path is not absolute: ${specifier}`)
28
+ return realpathSync.native(resolved)
29
+ }
30
+
31
+ function packageInfoFrom(startPath, expectedName) {
32
+ let directory = dirname(startPath)
33
+ for (let depth = 0; depth < 10; depth++) {
34
+ const packagePath = join(directory, 'package.json')
35
+ if (existsSync(packagePath)) {
36
+ try {
37
+ const manifest = JSON.parse(readFileSync(packagePath, 'utf8'))
38
+ if (manifest.name === expectedName) return {directory, manifest, packagePath: realpathSync.native(packagePath)}
39
+ } catch {
40
+ // Continue upward; this may be an unrelated or malformed nested package.
41
+ }
42
+ }
43
+ const parent = dirname(directory)
44
+ if (parent === directory) break
45
+ directory = parent
46
+ }
47
+ throw new Error(`Could not locate ${expectedName} package metadata`)
48
+ }
49
+
50
+ function resolveServerCli() {
51
+ const candidates = [
52
+ 'typescript-language-server',
53
+ 'typescript-language-server/lib/cli.mjs',
54
+ 'typescript-language-server/lib/cli.js',
55
+ ]
56
+ let lastError
57
+ for (const candidate of candidates) {
58
+ try {
59
+ const path = resolveOwn(candidate)
60
+ if (existsSync(path)) return path
61
+ } catch (error) {
62
+ lastError = error
63
+ }
64
+ }
65
+ throw lastError || new Error('typescript-language-server CLI was not found')
66
+ }
67
+
68
+ function resolveTypeScriptServer() {
69
+ const candidates = ['typescript/lib/tsserver.js', 'typescript/lib/_tsserver.js']
70
+ let lastError
71
+ for (const candidate of candidates) {
72
+ try {
73
+ const path = resolveOwn(candidate)
74
+ if (existsSync(path)) return path
75
+ } catch (error) {
76
+ lastError = error
77
+ }
78
+ }
79
+ try {
80
+ const typescriptEntry = resolveOwn('typescript')
81
+ for (const name of ['tsserver.js', '_tsserver.js']) {
82
+ const candidate = join(dirname(typescriptEntry), name)
83
+ if (existsSync(candidate)) return realpathSync.native(candidate)
84
+ }
85
+ } catch (error) {
86
+ lastError = error
87
+ }
88
+ throw lastError || new Error('Bundled TypeScript tsserver was not found')
89
+ }
90
+
91
+ function discover() {
92
+ if (discoveredProvider) return discoveredProvider
93
+ const cliPath = resolveServerCli()
94
+ const tsserverPath = resolveTypeScriptServer()
95
+ const serverPackage = packageInfoFrom(cliPath, PROVIDER)
96
+ const typescriptPackage = packageInfoFrom(tsserverPath, 'typescript')
97
+ discoveredProvider = Object.freeze({
98
+ available: true,
99
+ provider: PROVIDER,
100
+ version: String(serverPackage.manifest.version || 'unknown'),
101
+ typescriptVersion: String(typescriptPackage.manifest.version || 'unknown'),
102
+ cliPath,
103
+ tsserverPath,
104
+ })
105
+ return discoveredProvider
106
+ }
107
+
108
+ export function typeScriptLspContract() {
109
+ const availability = typeScriptLspAvailability()
110
+ return [
111
+ TYPESCRIPT_LSP_CAPABILITY_CONTRACT,
112
+ `${PROVIDER}@${availability.version || 'unavailable'}`,
113
+ `typescript@${availability.typescriptVersion || 'unavailable'}`,
114
+ `runtime@${process.platform}-${process.arch}-node${String(process.versions.node || '0').split('.')[0]}`,
115
+ ].join('|')
116
+ }
117
+
118
+ export function typeScriptLspAvailability() {
119
+ try {
120
+ const result = discover()
121
+ return {
122
+ available: true,
123
+ provider: result.provider,
124
+ version: result.version,
125
+ typescriptVersion: result.typescriptVersion,
126
+ }
127
+ } catch (error) {
128
+ return {
129
+ available: false,
130
+ provider: PROVIDER,
131
+ version: null,
132
+ typescriptVersion: null,
133
+ reason: error?.code === 'MODULE_NOT_FOUND' ? 'DEPENDENCY_NOT_INSTALLED' : 'DISCOVERY_FAILED',
134
+ }
135
+ }
136
+ }
137
+
138
+ export function typeScriptLanguageId(filePath) {
139
+ const extension = extname(String(filePath)).toLowerCase()
140
+ if (extension === '.ts' || extension === '.mts' || extension === '.cts') return 'typescript'
141
+ if (extension === '.tsx') return 'typescriptreact'
142
+ if (extension === '.js' || extension === '.mjs' || extension === '.cjs') return 'javascript'
143
+ if (extension === '.jsx') return 'javascriptreact'
144
+ return null
145
+ }
146
+
147
+ function typeScriptScriptKind(ts, filePath) {
148
+ const extension = extname(String(filePath)).toLowerCase()
149
+ if (extension === '.tsx') return ts.ScriptKind.TSX
150
+ if (extension === '.jsx') return ts.ScriptKind.JSX
151
+ if (extension === '.js' || extension === '.mjs' || extension === '.cjs') return ts.ScriptKind.JS
152
+ return ts.ScriptKind.TS
153
+ }
154
+
155
+ // Classify an exact LSP reference without consulting repository dependencies or executing project
156
+ // configuration. Unknown is deliberately fail-closed: callers must not promote it to runtime.
157
+ export function classifyTypeScriptReferenceUsage(filePath, text, position) {
158
+ if (!Number.isInteger(position?.line) || !Number.isInteger(position?.character)) return 'unknown'
159
+ let ts
160
+ try { ts = requireFromWeavatrix('typescript') } catch { return 'unknown' }
161
+ let sourceFile
162
+ let offset
163
+ try {
164
+ sourceFile = ts.createSourceFile(
165
+ String(filePath || 'source.ts'),
166
+ String(text || ''),
167
+ ts.ScriptTarget.Latest,
168
+ true,
169
+ typeScriptScriptKind(ts, filePath),
170
+ )
171
+ if (sourceFile.parseDiagnostics?.some((diagnostic) => diagnostic.category === ts.DiagnosticCategory.Error)) {
172
+ return 'unknown'
173
+ }
174
+ offset = sourceFile.getPositionOfLineAndCharacter(position.line, position.character)
175
+ } catch {
176
+ return 'unknown'
177
+ }
178
+ let token
179
+ try { token = ts.getTokenAtPosition(sourceFile, offset) } catch { return 'unknown' }
180
+ if (!token || (!ts.isIdentifier(token) && !ts.isPrivateIdentifier(token))) return 'unknown'
181
+ if (offset < token.getStart(sourceFile) || offset >= token.getEnd()) return 'unknown'
182
+
183
+ for (let current = token; current && current !== sourceFile; current = current.parent) {
184
+ if ((ts.isImportSpecifier(current) || ts.isImportClause(current)
185
+ || ts.isExportSpecifier(current) || ts.isExportDeclaration(current))
186
+ && current.isTypeOnly === true) return 'type'
187
+
188
+ // TypeScript models both class `extends` and `implements` as a type node. The base
189
+ // expression of a class extends clause is evaluated at runtime; implements, interface
190
+ // heritage, and generic arguments remain type-only.
191
+ if (ts.isExpressionWithTypeArguments(current) && ts.isHeritageClause(current.parent)) {
192
+ const heritage = current.parent
193
+ if (heritage.token === ts.SyntaxKind.ExtendsKeyword && ts.isClassLike(heritage.parent)) {
194
+ const expression = current.expression
195
+ if (offset >= expression.getStart(sourceFile) && offset < expression.getEnd()) return 'value'
196
+ }
197
+ return 'type'
198
+ }
199
+ if (ts.isTypeNode(current)) return 'type'
200
+ }
201
+ return 'value'
202
+ }
203
+
204
+ function canonicalKey(path, caseSensitive) {
205
+ let canonical
206
+ try { canonical = realpathSync.native(path) } catch { return null }
207
+ return caseSensitive ? canonical : canonical.toLowerCase()
208
+ }
209
+
210
+ function typeScriptRepoContext(repoRoot) {
211
+ let root
212
+ let ts
213
+ try {
214
+ root = realpathSync.native(repoRoot)
215
+ ts = requireFromWeavatrix('typescript')
216
+ } catch {
217
+ return null
218
+ }
219
+ return {root, ts, caseSensitive: Boolean(ts.sys.useCaseSensitiveFileNames)}
220
+ }
221
+
222
+ function guardedExistingPath(root, candidate, state = null) {
223
+ const input = String(candidate || '')
224
+ const lexical = isAbsolute(input) ? resolve(input) : resolve(root, input)
225
+ if (!isPathInside(root, lexical)) {
226
+ if (state) state.outsideAccess = true
227
+ return null
228
+ }
229
+ try {
230
+ const canonical = realpathSync.native(lexical)
231
+ if (!isPathInside(root, canonical)) {
232
+ if (state) state.outsideAccess = true
233
+ return null
234
+ }
235
+ return canonical
236
+ } catch {
237
+ return null
238
+ }
239
+ }
240
+
241
+ function nearestTypeScriptConfig(context, absoluteFile) {
242
+ const {root} = context
243
+ let directory = dirname(absoluteFile)
244
+ while (isPathInside(root, directory)) {
245
+ for (const name of ['tsconfig.json', 'jsconfig.json']) {
246
+ const candidate = join(directory, name)
247
+ if (!existsSync(candidate)) continue
248
+ const configPath = guardedExistingPath(root, candidate)
249
+ return configPath
250
+ ? {ok: true, configPath}
251
+ : {ok: false, reason: 'CONFIG_OUTSIDE_REPOSITORY'}
252
+ }
253
+ if (directory === root) break
254
+ const parent = dirname(directory)
255
+ if (parent === directory) break
256
+ directory = parent
257
+ }
258
+ return {ok: true, configPath: null}
259
+ }
260
+
261
+ function safetyBudget(options = {}) {
262
+ const requestedDeadline = Number(options.deadline)
263
+ const requestedTimeout = Number(options.timeoutMs)
264
+ const timeout = Number.isFinite(requestedTimeout)
265
+ ? Math.max(100, Math.min(60_000, Math.floor(requestedTimeout)))
266
+ : DEFAULT_SAFETY_TIMEOUT_MS
267
+ return {
268
+ deadline: Number.isFinite(requestedDeadline) ? requestedDeadline : Date.now() + timeout,
269
+ maxEntries: Math.max(1, Math.min(MAX_DIRECTORY_ENTRIES,
270
+ Number.isFinite(Number(options.maxDirectoryEntries)) ? Math.floor(Number(options.maxDirectoryEntries)) : MAX_DIRECTORY_ENTRIES)),
271
+ maxDirectories: Math.max(1, Math.min(MAX_DIRECTORIES,
272
+ Number.isFinite(Number(options.maxDirectories)) ? Math.floor(Number(options.maxDirectories)) : MAX_DIRECTORIES)),
273
+ entries: 0,
274
+ directories: 0,
275
+ configBytes: 0,
276
+ configFiles: new Set(),
277
+ reason: null,
278
+ }
279
+ }
280
+
281
+ function safetyLimitReached(budget, reason = 'PROJECT_INPUT_LIMIT') {
282
+ if (!budget.reason && Date.now() >= budget.deadline) budget.reason = 'SAFETY_DEADLINE'
283
+ if (!budget.reason && reason) budget.reason = reason
284
+ return Boolean(budget.reason)
285
+ }
286
+
287
+ function boundedReadDirectory(context, state, budget, candidate, extensions, excludes, includes, depth) {
288
+ const {root, ts, caseSensitive} = context
289
+ const path = guardedExistingPath(root, candidate, state)
290
+ if (!path || safetyLimitReached(budget, null)) return []
291
+ const getFileSystemEntries = (directoryPath) => {
292
+ if (safetyLimitReached(budget, null)) return {files: [], directories: []}
293
+ const directory = guardedExistingPath(root, directoryPath, state)
294
+ if (!directory) return {files: [], directories: []}
295
+ budget.directories++
296
+ if (budget.directories > budget.maxDirectories) {
297
+ safetyLimitReached(budget)
298
+ return {files: [], directories: []}
299
+ }
300
+ const files = []
301
+ const directories = []
302
+ let handle
303
+ try {
304
+ handle = opendirSync(directory)
305
+ while (!safetyLimitReached(budget, null)) {
306
+ const entry = handle.readSync()
307
+ if (!entry) break
308
+ budget.entries++
309
+ if (budget.entries > budget.maxEntries) {
310
+ safetyLimitReached(budget)
311
+ break
312
+ }
313
+ if (entry.isFile()) files.push(entry.name)
314
+ else if (entry.isDirectory()) directories.push(entry.name)
315
+ else if (entry.isSymbolicLink()) {
316
+ // Follow only links whose canonical target remains inside the repository. The
317
+ // matcher also receives a guarded realpath callback to prevent cycles.
318
+ const linked = guardedExistingPath(root, join(directory, entry.name), state)
319
+ if (!linked) continue
320
+ let stats
321
+ try { stats = statSync(linked) } catch { continue }
322
+ if (stats.isFile()) files.push(entry.name)
323
+ else if (stats.isDirectory()) directories.push(entry.name)
324
+ }
325
+ }
326
+ } catch {
327
+ return {files: [], directories: []}
328
+ } finally {
329
+ try { handle?.closeSync() } catch {}
330
+ }
331
+ return {files, directories}
332
+ }
333
+ const guardedRealpath = (entry) => guardedExistingPath(root, entry, state) || resolve(root, '.weavatrix-invalid-path')
334
+ try {
335
+ return ts.matchFiles(
336
+ path,
337
+ extensions,
338
+ excludes,
339
+ includes,
340
+ caseSensitive,
341
+ root,
342
+ depth,
343
+ getFileSystemEntries,
344
+ guardedRealpath,
345
+ )
346
+ } catch {
347
+ state.limitExceeded = true
348
+ return []
349
+ }
350
+ }
351
+
352
+ function parseRepoConfig(context, configPath, budget) {
353
+ const {root, ts, caseSensitive} = context
354
+ const state = {
355
+ outsideAccess: false,
356
+ limitExceeded: false,
357
+ configuredPlugins: false,
358
+ bytes: 0,
359
+ records: new Map(),
360
+ }
361
+ const diagnostics = []
362
+ const host = {
363
+ useCaseSensitiveFileNames: caseSensitive,
364
+ getCurrentDirectory: () => root,
365
+ fileExists(candidate) {
366
+ const path = guardedExistingPath(root, candidate, state)
367
+ return Boolean(path && ts.sys.fileExists(path))
368
+ },
369
+ readFile(candidate) {
370
+ if (safetyLimitReached(budget, null)) return undefined
371
+ const path = guardedExistingPath(root, candidate, state)
372
+ if (!path) return undefined
373
+ let body
374
+ try {
375
+ const size = statSync(path).size
376
+ if (size > MAX_CONFIG_BYTES || state.bytes + size > MAX_CONFIG_BYTES) {
377
+ state.limitExceeded = true
378
+ return undefined
379
+ }
380
+ body = readFileSync(path)
381
+ } catch {
382
+ return undefined
383
+ }
384
+ const rel = norm(relative(root, path))
385
+ if (!state.records.has(rel)) {
386
+ if (!budget.configFiles.has(rel)
387
+ && (budget.configFiles.size >= MAX_CONFIG_FILES
388
+ || budget.configBytes + body.byteLength > MAX_CONFIG_BYTES)) {
389
+ state.limitExceeded = true
390
+ safetyLimitReached(budget, 'CONFIG_INPUT_LIMIT')
391
+ return undefined
392
+ }
393
+ if (!budget.configFiles.has(rel)) {
394
+ budget.configFiles.add(rel)
395
+ budget.configBytes += body.byteLength
396
+ }
397
+ state.records.set(rel, createHash('sha256').update(body).digest('hex'))
398
+ state.bytes += body.byteLength
399
+ }
400
+ // TypeScript currently merges compilerOptions.plugins, but audit every config body it
401
+ // reads as well. This keeps the pre-spawn guard fail-closed across TypeScript versions
402
+ // and catches plugins hidden in an extends chain before tsserver can see the project.
403
+ try {
404
+ const raw = ts.parseConfigFileTextToJson(path, body.toString('utf8')).config
405
+ if (Array.isArray(raw?.compilerOptions?.plugins) && raw.compilerOptions.plugins.length) {
406
+ state.configuredPlugins = true
407
+ }
408
+ } catch {
409
+ // The parser's diagnostics below decide whether malformed configuration is safe.
410
+ }
411
+ return body.toString('utf8')
412
+ },
413
+ readDirectory(candidate, extensions, excludes, includes, depth) {
414
+ return boundedReadDirectory(context, state, budget, candidate, extensions, excludes, includes, depth)
415
+ },
416
+ onUnRecoverableConfigFileDiagnostic(diagnostic) { diagnostics.push(diagnostic) },
417
+ trace() {},
418
+ }
419
+ let parsed
420
+ try { parsed = ts.getParsedCommandLineOfConfigFile(configPath, {}, host) } catch {
421
+ return {complete: false, reason: 'CONFIG_PARSE_FAILED'}
422
+ }
423
+ const errors = [...diagnostics, ...(parsed?.errors || [])]
424
+ if (state.configuredPlugins || (Array.isArray(parsed?.options?.plugins) && parsed.options.plugins.length)) {
425
+ return {complete: false, reason: 'CONFIGURED_TSSERVER_PLUGINS'}
426
+ }
427
+ if (budget.reason) return {complete: false, reason: budget.reason}
428
+ if (!parsed || state.outsideAccess || state.limitExceeded
429
+ || errors.some((diagnostic) => diagnostic?.category === ts.DiagnosticCategory.Error)) {
430
+ return {
431
+ complete: false,
432
+ reason: state.outsideAccess
433
+ ? 'CONFIG_OUTSIDE_REPOSITORY'
434
+ : state.limitExceeded ? 'CONFIG_INPUT_LIMIT' : 'CONFIG_PARSE_FAILED',
435
+ }
436
+ }
437
+ const projectFiles = []
438
+ const projectKeys = new Set()
439
+ for (const file of parsed.fileNames) {
440
+ const canonical = guardedExistingPath(root, file, state)
441
+ if (!canonical) return {complete: false, reason: 'PROJECT_INPUT_OUTSIDE_REPOSITORY'}
442
+ const key = caseSensitive ? canonical : canonical.toLowerCase()
443
+ if (projectKeys.has(key)) continue
444
+ projectKeys.add(key)
445
+ projectFiles.push(norm(relative(root, canonical)))
446
+ if (projectFiles.length > MAX_PROJECT_FILES) return {complete: false, reason: 'PROJECT_INPUT_LIMIT'}
447
+ }
448
+ projectFiles.sort()
449
+ return {
450
+ complete: true,
451
+ parsed,
452
+ projectFiles,
453
+ projectKeys,
454
+ configRecords: state.records,
455
+ plugins: [],
456
+ }
457
+ }
458
+
459
+ function referenceConfigPath(context, reference) {
460
+ const {root, ts} = context
461
+ let candidate
462
+ try {
463
+ candidate = typeof ts.resolveProjectReferencePath === 'function'
464
+ ? ts.resolveProjectReferencePath(reference)
465
+ : reference?.path
466
+ } catch {
467
+ return null
468
+ }
469
+ if (!candidate) return null
470
+ return guardedExistingPath(root, candidate)
471
+ }
472
+
473
+ // This audit runs before spawning tsserver. It uses only bundled TypeScript and a repo-contained
474
+ // config host, rejects every configured plugin, recursively inspects project references, and binds
475
+ // ignored config/project inputs into a bounded digest for cache and post-LSP freshness checks.
476
+ export function typeScriptProjectSafety(repoRoot, relFiles = [], options = {}) {
477
+ const context = typeScriptRepoContext(repoRoot)
478
+ if (!context) return {safe: false, reason: 'TYPESCRIPT_UNAVAILABLE', fingerprint: null}
479
+ const {root} = context
480
+ const budget = safetyBudget(options)
481
+ const files = [...new Set((relFiles || []).map(norm).filter(Boolean))].sort()
482
+ if (files.length > MAX_PROJECT_FILES) return {safe: false, reason: 'PROJECT_INPUT_LIMIT', fingerprint: null}
483
+ const graphFiles = new Set(files)
484
+ const mappings = []
485
+ const fileConfigs = {}
486
+ const queue = []
487
+ const queued = new Set()
488
+ for (const file of files) {
489
+ const absolute = guardedExistingPath(root, file)
490
+ if (!absolute) return {safe: false, reason: 'UNREADABLE_PROJECT_INPUT', fingerprint: null}
491
+ const nearest = nearestTypeScriptConfig(context, absolute)
492
+ if (!nearest.ok) return {safe: false, reason: nearest.reason, fingerprint: null}
493
+ const configRel = nearest.configPath ? norm(relative(root, nearest.configPath)) : '<inferred>'
494
+ mappings.push(`${file}=>${configRel}`)
495
+ fileConfigs[file] = nearest.configPath ? configRel : null
496
+ if (nearest.configPath && !queued.has(nearest.configPath)) {
497
+ queued.add(nearest.configPath)
498
+ queue.push(nearest.configPath)
499
+ }
500
+ }
501
+
502
+ const configRecords = new Map()
503
+ const projectFiles = new Set()
504
+ const projects = {}
505
+ for (let cursor = 0; cursor < queue.length; cursor++) {
506
+ if (safetyLimitReached(budget, null)) return {safe: false, reason: budget.reason, fingerprint: null}
507
+ if (queue.length > MAX_CONFIG_FILES) return {safe: false, reason: 'CONFIG_INPUT_LIMIT', fingerprint: null}
508
+ const configPath = queue[cursor]
509
+ const parsed = parseRepoConfig(context, configPath, budget)
510
+ if (!parsed.complete) return {safe: false, reason: parsed.reason, fingerprint: null}
511
+ const configRel = norm(relative(root, configPath))
512
+ projects[configRel] = {
513
+ projectFiles: parsed.projectFiles,
514
+ configFiles: [...parsed.configRecords.keys()].sort(),
515
+ }
516
+ for (const [file, digest] of parsed.configRecords) {
517
+ configRecords.set(file, digest)
518
+ if (configRecords.size > MAX_CONFIG_FILES) return {safe: false, reason: 'CONFIG_INPUT_LIMIT', fingerprint: null}
519
+ }
520
+ for (const file of parsed.projectFiles) {
521
+ projectFiles.add(file)
522
+ if (projectFiles.size > MAX_PROJECT_FILES) return {safe: false, reason: 'PROJECT_INPUT_LIMIT', fingerprint: null}
523
+ }
524
+ for (const reference of parsed.parsed.projectReferences || []) {
525
+ const referenced = referenceConfigPath(context, reference)
526
+ if (!referenced) return {safe: false, reason: 'PROJECT_REFERENCE_UNRESOLVED', fingerprint: null}
527
+ if (!queued.has(referenced)) {
528
+ queued.add(referenced)
529
+ queue.push(referenced)
530
+ }
531
+ }
532
+ }
533
+
534
+ const extraRecords = []
535
+ let projectBytes = 0
536
+ let extraBytes = 0
537
+ for (const file of [...projectFiles].sort()) {
538
+ if (safetyLimitReached(budget, null)) return {safe: false, reason: budget.reason, fingerprint: null}
539
+ const absolute = guardedExistingPath(root, file)
540
+ if (!absolute) return {safe: false, reason: 'UNREADABLE_PROJECT_INPUT', fingerprint: null}
541
+ let size
542
+ try {
543
+ const stats = statSync(absolute)
544
+ if (!stats.isFile()) return {safe: false, reason: 'UNREADABLE_PROJECT_INPUT', fingerprint: null}
545
+ size = stats.size
546
+ if (size > MAX_SINGLE_INPUT_BYTES || projectBytes + size > MAX_PROJECT_INPUT_BYTES) {
547
+ return {safe: false, reason: 'PROJECT_INPUT_LIMIT', fingerprint: null}
548
+ }
549
+ } catch {
550
+ return {safe: false, reason: 'UNREADABLE_PROJECT_INPUT', fingerprint: null}
551
+ }
552
+ projectBytes += size
553
+ if (graphFiles.has(file)) continue
554
+ if (extraBytes + size > MAX_EXTRA_INPUT_BYTES) {
555
+ return {safe: false, reason: 'PROJECT_INPUT_LIMIT', fingerprint: null}
556
+ }
557
+ let body
558
+ try { body = readFileSync(absolute) } catch {
559
+ return {safe: false, reason: 'UNREADABLE_PROJECT_INPUT', fingerprint: null}
560
+ }
561
+ extraBytes += body.byteLength
562
+ extraRecords.push(`${file}:${createHash('sha256').update(body).digest('hex')}`)
563
+ }
564
+ const fingerprint = createHash('sha256').update([
565
+ ...mappings.map((item) => `map:${item}`),
566
+ ...[...configRecords.entries()].sort(([a], [b]) => a.localeCompare(b)).map(([file, digest]) => `config:${file}:${digest}`),
567
+ ...[...projectFiles].sort().map((file) => `project:${file}`),
568
+ ...extraRecords.map((item) => `extra:${item}`),
569
+ ].join('\n')).digest('hex')
570
+ return {
571
+ safe: true,
572
+ reason: null,
573
+ fingerprint,
574
+ configFiles: [...configRecords.keys()].sort(),
575
+ projectFiles: [...projectFiles].sort(),
576
+ fileConfigs,
577
+ projects,
578
+ }
579
+ }
580
+
581
+ // An empty references response is useful dead-code evidence only when TypeScript itself confirms
582
+ // that the declaration belongs to a complete configured project. Merely finding an ancestor
583
+ // tsconfig is not enough: include/exclude/files can leave the target in an inferred project.
584
+ export function typeScriptConfiguredProjectMembership(repoRoot, relFile) {
585
+ const context = typeScriptRepoContext(repoRoot)
586
+ if (!context) return {complete: false, member: false, reason: 'TYPESCRIPT_UNAVAILABLE'}
587
+ const {root, caseSensitive} = context
588
+ const target = guardedExistingPath(root, relFile)
589
+ if (!target) {
590
+ return {complete: false, member: false, reason: 'UNREADABLE_PATH'}
591
+ }
592
+ const nearest = nearestTypeScriptConfig(context, target)
593
+ if (!nearest.ok) return {complete: false, member: false, reason: nearest.reason}
594
+ const configPath = nearest.configPath
595
+ if (!configPath) return {complete: false, member: false, reason: 'NO_CONFIGURED_PROJECT'}
596
+ const parsed = parseRepoConfig(context, configPath, safetyBudget())
597
+ if (!parsed.complete) return {complete: false, member: false, reason: parsed.reason}
598
+ const targetKey = canonicalKey(target, caseSensitive)
599
+ const member = parsed.projectKeys.has(targetKey)
600
+ return {
601
+ complete: true,
602
+ member,
603
+ projectFiles: parsed.projectFiles,
604
+ configFiles: [...parsed.configRecords.keys()].sort(),
605
+ configFile: norm(relative(root, configPath)),
606
+ reason: member ? null : 'NOT_IN_CONFIGURED_PROJECT',
607
+ }
608
+ }
609
+
610
+ /**
611
+ * Starts Weavatrix's own bundled TypeScript language server. Resolution is anchored to this
612
+ * module, never to the repository being analyzed; no repository command, package script, or npx
613
+ * executable is invoked.
614
+ */
615
+ export async function createTypeScriptLspClient({repoRoot, timeoutMs = 10_000} = {}) {
616
+ const discovered = discover()
617
+ const absoluteRepoRoot = resolve(repoRoot)
618
+ let client
619
+ let reportedTypeScript = null
620
+ try {
621
+ client = await startStdioLspClient({
622
+ repoRoot: absoluteRepoRoot,
623
+ executablePath: process.execPath,
624
+ args: [discovered.cliPath, '--stdio'],
625
+ requestTimeoutMs: timeoutMs,
626
+ onNotification(method, params) {
627
+ if (method === '$/typescriptVersion' && params && typeof params === 'object') {
628
+ reportedTypeScript = {
629
+ version: typeof params.version === 'string' ? params.version : null,
630
+ source: typeof params.source === 'string' ? params.source : null,
631
+ }
632
+ }
633
+ },
634
+ })
635
+ await client.initialize({
636
+ clientInfo: {name: 'weavatrix', version: WEAVATRIX_VERSION},
637
+ capabilities: {
638
+ workspace: {configuration: true, workspaceFolders: true},
639
+ textDocument: {
640
+ definition: {linkSupport: true},
641
+ references: {},
642
+ publishDiagnostics: {relatedInformation: false},
643
+ },
644
+ },
645
+ initializationOptions: {
646
+ hostInfo: 'weavatrix',
647
+ disableAutomaticTypingAcquisition: true,
648
+ tsserver: {path: discovered.tsserverPath},
649
+ },
650
+ })
651
+ } catch (error) {
652
+ client?.kill(error)
653
+ throw error
654
+ }
655
+
656
+ return Object.freeze({
657
+ provider: discovered.provider,
658
+ version: discovered.version,
659
+ providerContract: typeScriptLspContract(),
660
+ get typescriptVersion() { return reportedTypeScript?.version || discovered.typescriptVersion },
661
+ get typescriptSource() { return reportedTypeScript?.source || 'configured-bundled-path' },
662
+ async openDocument(relPath, text, languageId = typeScriptLanguageId(relPath)) {
663
+ if (!languageId) throw new TypeError(`Unsupported TypeScript LSP document extension: ${relPath}`)
664
+ return client.openDocument({filePath: relPath, text, languageId})
665
+ },
666
+ references(relPath, position, includeDeclaration = true, referenceTimeoutMs = timeoutMs) {
667
+ return client.references({filePath: relPath, position, includeDeclaration, timeoutMs: referenceTimeoutMs})
668
+ },
669
+ definition(relPath, position) {
670
+ return client.definition({filePath: relPath, position})
671
+ },
672
+ closeDocument(relPath) {
673
+ return client.closeDocument(relPath)
674
+ },
675
+ close(shutdownTimeoutMs = timeoutMs) {
676
+ return client.shutdown({timeoutMs: shutdownTimeoutMs})
677
+ },
678
+ kill() {
679
+ client.kill()
680
+ },
681
+ })
682
+ }