@banbolee/dsh-lsp-diagnostics 0.1.0 → 0.1.2
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 +45 -32
- package/coordinator.js +39 -18
- package/index.js +3 -5
- package/package.json +2 -1
- package/tool.js +28 -3
- package/workspace-root.js +155 -0
package/README.md
CHANGED
|
@@ -2,9 +2,10 @@
|
|
|
2
2
|
|
|
3
3
|
Host-plane Cordis bundle plugin for DeepSeek Harness: after an official
|
|
4
4
|
`write`/`edit`/`str_replace_editor` mutation lands inside the session
|
|
5
|
-
workspace,
|
|
6
|
-
|
|
7
|
-
|
|
5
|
+
workspace, or inside a marker-rooted sibling worktree/project, the tool result
|
|
6
|
+
still succeeds and the next model inference carries a single persistent LSP
|
|
7
|
+
diagnostics plugin notice. It also registers the model-callable
|
|
8
|
+
`lsp_diagnostics(file_path)` tool for explicit diagnosis.
|
|
8
9
|
|
|
9
10
|
The plugin is a named namespace function plugin: it exports `name`, `inject`,
|
|
10
11
|
`Config`, and `apply`, and has no default export. The real Loader reads the
|
|
@@ -16,7 +17,13 @@ monkey-patches official objects.
|
|
|
16
17
|
|
|
17
18
|
## Usage
|
|
18
19
|
|
|
19
|
-
Install
|
|
20
|
+
**Install from npm (recommended)** — no clone needed:
|
|
21
|
+
|
|
22
|
+
```sh
|
|
23
|
+
dsh plugin --profile <name> add @banbolee/dsh-lsp-diagnostics
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
For local development, install the bundle into any DSH profile from the repository root. The
|
|
20
27
|
workspace-root flag `-w` is required: without it pnpm cannot resolve the local
|
|
21
28
|
package inside the profile's generated workspace.
|
|
22
29
|
|
|
@@ -59,20 +66,22 @@ deviation from the closed extension route fail loud at load time.
|
|
|
59
66
|
| `servers` | TypeScript + Go | Closed provider catalog; TypeScript and Go are enabled by default, while `clangd`, `rust`, and `python` are opt-in. |
|
|
60
67
|
|
|
61
68
|
The extension route is closed: `.ts` → typescript/typescript, `.tsx` →
|
|
62
|
-
typescript/typescriptreact,
|
|
63
|
-
`.
|
|
64
|
-
`.
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
silently ignored.
|
|
69
|
+
typescript/typescriptreact, optional explicit `.js` → typescript/javascript,
|
|
70
|
+
`.go` → go/go, `.c` → clangd/c, `.cc`/`.cpp`/`.cxx` → clangd/cpp,
|
|
71
|
+
`.h`/`.hh`/`.hpp`/`.hxx` → clangd/cpp, `.rs` → rust/rust, and
|
|
72
|
+
`.py`/`.pyi` → python/python; any other extension key, missing canonical
|
|
73
|
+
provider entry, cross-provider collision, or rewritten language id fails loud at
|
|
74
|
+
load time, while other extensions at runtime are silently ignored.
|
|
68
75
|
|
|
69
76
|
The `servers` block is a provider-level partial overlay: omitting `servers` or
|
|
70
77
|
supplying `servers: {}` keeps exactly the TypeScript and Go defaults, while
|
|
71
78
|
supplying a known optional `clangd`, `rust`, or `python` key activates that
|
|
72
79
|
provider. For example, `servers: { go: { command: '/path/trae-gopls' } }`
|
|
73
80
|
changes only Go without requiring a TypeScript block. The legacy
|
|
74
|
-
`extensionToLanguage` field is accepted only when it
|
|
75
|
-
|
|
81
|
+
`extensionToLanguage` field is accepted only when it contains that provider's
|
|
82
|
+
canonical mapping and only supported provider-owned extras. `.js` is accepted
|
|
83
|
+
for compatibility with explicit TypeScript configs, but it is not enabled by
|
|
84
|
+
default.
|
|
76
85
|
|
|
77
86
|
Default commands are `typescript-language-server --stdio`, `gopls`, `clangd`,
|
|
78
87
|
`rust-analyzer`, and `pyright-langserver --stdio`. clangd, rust-analyzer, and
|
|
@@ -81,13 +90,16 @@ gopls receive no default arguments. Per-server `env`, `configuration`, and
|
|
|
81
90
|
|
|
82
91
|
## Behavior
|
|
83
92
|
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
`
|
|
93
|
+
A non-empty `session.header.cwd` that canonicalizes to a directory is required.
|
|
94
|
+
Automatic feedback first keeps the old behavior for targets contained by that
|
|
95
|
+
workspace; targets outside it are eligible only when a target-local `.git` file
|
|
96
|
+
or directory identifies their own git worktree root. Missing or empty cwd,
|
|
97
|
+
non-directory canonicalization, and outside targets without a marker-rooted
|
|
98
|
+
git worktree are silently ignored — never a `workspace unavailable` or
|
|
99
|
+
`outside workspace` notice. Workspace, cwd, and URI are always
|
|
100
|
+
derived through the public `ctx.fs` API (`resolve`/`stat`/`contains`/`targetKey`/
|
|
101
|
+
`processPath`/`fileUrl`), never from raw `displayPath` or the host
|
|
102
|
+
`process.cwd()`.
|
|
91
103
|
|
|
92
104
|
Fail-open is plugin-owned post-processing failure only: after the downstream
|
|
93
105
|
`await next()` decision succeeded, a diagnostics error, missing or crashed
|
|
@@ -187,13 +199,14 @@ natural-close wait → conditional `handle.terminate()` (only while still alive)
|
|
|
187
199
|
existing file that `ctx.fs` permits the session to read. It accepts a
|
|
188
200
|
workspace-relative or absolute path and uses every configured provider and the
|
|
189
201
|
closed extension route documented above. It has the same path authority as the
|
|
190
|
-
official `read` tool:
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
202
|
+
official `read` tool: for files contained by `session.header.cwd`, that cwd
|
|
203
|
+
remains the LSP project root; for a readable file outside the session cwd, the
|
|
204
|
+
tool prefers the target's marker-rooted project/worktree root and then falls
|
|
205
|
+
back to the session cwd. It never creates, edits, or deletes a file. Invalid
|
|
206
|
+
requests fail explicitly with `file_path must be a non-empty string`, `session
|
|
207
|
+
workspace cwd`, `session workspace is not an existing directory`, `target does
|
|
208
|
+
not exist`, `target is not a regular file`, `no configured diagnostics
|
|
209
|
+
provider`, or `target changed during diagnosis`.
|
|
197
210
|
|
|
198
211
|
The direct tool has exactly three canonical outcomes: `diagnostics` with the
|
|
199
212
|
normalized list sorted by the same comparator as automatic rendering and sliced
|
|
@@ -231,13 +244,13 @@ unavailable`.
|
|
|
231
244
|
- shell redirection, `sed -i`, scripts, formatters, generators, and external
|
|
232
245
|
editors that bypass `ctx.fs`/`fs/observed` are out of scope for automatic
|
|
233
246
|
feedback; the model may call `lsp_diagnostics(file_path)` afterward. There is no
|
|
234
|
-
filesystem watcher
|
|
235
|
-
|
|
247
|
+
filesystem watcher; project-root probing is marker-based and only runs for
|
|
248
|
+
supported source files.
|
|
236
249
|
- For automatic feedback, agentless tool execution, missing/empty or
|
|
237
|
-
non-directory cwd, and outside
|
|
238
|
-
and never produce a notice. Direct calls use official
|
|
239
|
-
authority instead: a readable file outside the session
|
|
240
|
-
|
|
250
|
+
non-directory cwd, and outside targets without a marker-rooted git worktree are
|
|
251
|
+
silently out of scope and never produce a notice. Direct calls use official
|
|
252
|
+
`read`-equivalent path authority instead: a readable file outside the session
|
|
253
|
+
cwd is eligible, using its marker-rooted project/worktree root when available.
|
|
241
254
|
- The plugin does not install or download `typescript-language-server`,
|
|
242
255
|
`gopls`, `clangd`, `rust-analyzer`, or `pyright-langserver`; install requested
|
|
243
256
|
executables yourself and configure their paths. A missing server fails open
|
package/coordinator.js
CHANGED
|
@@ -17,13 +17,14 @@
|
|
|
17
17
|
|
|
18
18
|
import { boundContextSummary, createUserMessage } from '@deepseek-ai/dsh-llm'
|
|
19
19
|
import { compareEligibleTargets, renderDiagnostics, sanitizeDisplayPath } from './render.js'
|
|
20
|
+
import { resolveWorkspaceRoot } from './workspace-root.js'
|
|
20
21
|
|
|
21
22
|
/**
|
|
22
23
|
* Loose structural seams, mirroring the runtime's own public shapes. The
|
|
23
24
|
* coordinator only consumes the documented surface (`take/isCurrent/
|
|
24
25
|
* retireIfCurrent`, `diagnose/stopAdmission/dispose`, `resolve/stat/
|
|
25
|
-
* contains/fileUrl`); keeping these structural keeps both the real
|
|
26
|
-
* and the deterministic test fakes assignable.
|
|
26
|
+
* contains/fileUrl/processPath`); keeping these structural keeps both the real
|
|
27
|
+
* services and the deterministic test fakes assignable.
|
|
27
28
|
* @typedef {object} CollectorSeam
|
|
28
29
|
* @property {(exec: any, target: any, observation: any) => void} observe
|
|
29
30
|
* @property {(exec: any) => import('./collector.js').MutationCandidate[]} take
|
|
@@ -44,6 +45,7 @@ import { compareEligibleTargets, renderDiagnostics, sanitizeDisplayPath } from '
|
|
|
44
45
|
* @property {(target: any, signal?: AbortSignal) => Promise<{ version: string, type: string, size?: number } | undefined>} stat
|
|
45
46
|
* @property {(parent: any, child: any) => boolean} contains
|
|
46
47
|
* @property {(target: any) => string} fileUrl
|
|
48
|
+
* @property {(target: any) => string} processPath
|
|
47
49
|
*/
|
|
48
50
|
|
|
49
51
|
/**
|
|
@@ -382,10 +384,12 @@ export function createDiagnosticsCoordinator({ collector, runtime, config, fs, n
|
|
|
382
384
|
retireAll()
|
|
383
385
|
return decision
|
|
384
386
|
}
|
|
385
|
-
// 2.
|
|
387
|
+
// 2. Canonicalize the session workspace once. Files inside it keep the
|
|
388
|
+
// original behavior; files outside it may still resolve to their own
|
|
389
|
+
// project/worktree root.
|
|
386
390
|
const execRecord = /** @type {{ agent?: { session?: { header?: { cwd?: unknown } } } }} */ (exec)
|
|
387
|
-
const
|
|
388
|
-
if (typeof
|
|
391
|
+
const sessionRoot = execRecord?.agent?.session?.header?.cwd
|
|
392
|
+
if (typeof sessionRoot !== 'string' || sessionRoot.length === 0) {
|
|
389
393
|
retireAll()
|
|
390
394
|
return decision
|
|
391
395
|
}
|
|
@@ -393,10 +397,9 @@ export function createDiagnosticsCoordinator({ collector, runtime, config, fs, n
|
|
|
393
397
|
retireAll()
|
|
394
398
|
return decision
|
|
395
399
|
}
|
|
396
|
-
|
|
397
|
-
let workspaceTarget
|
|
400
|
+
let sessionWorkspace
|
|
398
401
|
try {
|
|
399
|
-
|
|
402
|
+
sessionWorkspace = await fs.resolve(sessionRoot, { signal })
|
|
400
403
|
} catch {
|
|
401
404
|
retireAll()
|
|
402
405
|
return decision
|
|
@@ -405,9 +408,9 @@ export function createDiagnosticsCoordinator({ collector, runtime, config, fs, n
|
|
|
405
408
|
retireAll()
|
|
406
409
|
return decision
|
|
407
410
|
}
|
|
408
|
-
let
|
|
411
|
+
let sessionWorkspaceInfo
|
|
409
412
|
try {
|
|
410
|
-
|
|
413
|
+
sessionWorkspaceInfo = await fs.stat(sessionWorkspace, signal)
|
|
411
414
|
} catch {
|
|
412
415
|
retireAll()
|
|
413
416
|
return decision
|
|
@@ -416,26 +419,43 @@ export function createDiagnosticsCoordinator({ collector, runtime, config, fs, n
|
|
|
416
419
|
retireAll()
|
|
417
420
|
return decision
|
|
418
421
|
}
|
|
419
|
-
if (
|
|
422
|
+
if (sessionWorkspaceInfo === undefined || sessionWorkspaceInfo.type !== 'directory') {
|
|
420
423
|
retireAll()
|
|
421
424
|
return decision
|
|
422
425
|
}
|
|
423
|
-
//
|
|
424
|
-
//
|
|
425
|
-
//
|
|
426
|
-
/** @type {{ candidate: import('./collector.js').MutationCandidate, renderPath: string, targetKey: string, canonicalUri: string }[]} */
|
|
426
|
+
// 3. Per-target eligibility: use session cwd when it contains the target;
|
|
427
|
+
// otherwise resolve the LSP workspace from target-local markers
|
|
428
|
+
// (go.mod/.git/package markers). Then freeze renderPath + canonicalUri.
|
|
429
|
+
/** @type {{ candidate: import('./collector.js').MutationCandidate, workspaceTarget: any, renderPath: string, targetKey: string, canonicalUri: string }[]} */
|
|
427
430
|
const eligible = []
|
|
428
431
|
for (const route of routed) {
|
|
429
432
|
const { candidate, canonicalUri } = route
|
|
430
433
|
if (signal.aborted) break
|
|
431
434
|
let contained = false
|
|
432
435
|
try {
|
|
433
|
-
contained = fs.contains(
|
|
436
|
+
contained = fs.contains(sessionWorkspace, candidate.target) === true
|
|
434
437
|
} catch {
|
|
435
438
|
contained = false
|
|
436
439
|
}
|
|
437
440
|
if (signal.aborted) break
|
|
438
|
-
|
|
441
|
+
let workspaceTarget = contained ? sessionWorkspace : undefined
|
|
442
|
+
if (workspaceTarget === undefined) {
|
|
443
|
+
try {
|
|
444
|
+
workspaceTarget = await resolveWorkspaceRoot({
|
|
445
|
+
fs,
|
|
446
|
+
target: candidate.target,
|
|
447
|
+
extension: extensionOf(canonicalUri),
|
|
448
|
+
sessionRoot,
|
|
449
|
+
signal,
|
|
450
|
+
sessionFallback: 'never',
|
|
451
|
+
markerMode: 'git',
|
|
452
|
+
})
|
|
453
|
+
} catch {
|
|
454
|
+
workspaceTarget = undefined
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
if (signal.aborted) break
|
|
458
|
+
if (workspaceTarget === undefined) {
|
|
439
459
|
collector.retireIfCurrent(candidate)
|
|
440
460
|
continue
|
|
441
461
|
}
|
|
@@ -448,6 +468,7 @@ export function createDiagnosticsCoordinator({ collector, runtime, config, fs, n
|
|
|
448
468
|
}
|
|
449
469
|
eligible.push({
|
|
450
470
|
candidate,
|
|
471
|
+
workspaceTarget,
|
|
451
472
|
renderPath,
|
|
452
473
|
targetKey: String(candidate.target.targetKey ?? ''),
|
|
453
474
|
canonicalUri,
|
|
@@ -470,7 +491,7 @@ export function createDiagnosticsCoordinator({ collector, runtime, config, fs, n
|
|
|
470
491
|
/** @type {import('./runtime.js').DiagnosisOutcome} */
|
|
471
492
|
let outcome
|
|
472
493
|
try {
|
|
473
|
-
outcome = await runtime.diagnose(target.candidate, workspaceTarget, target.canonicalUri, signal)
|
|
494
|
+
outcome = await runtime.diagnose(target.candidate, target.workspaceTarget, target.canonicalUri, signal)
|
|
474
495
|
} catch (error) {
|
|
475
496
|
if (signal.aborted) break
|
|
476
497
|
// Plugin-owned failure after eligibility: bounded unavailable with
|
package/index.js
CHANGED
|
@@ -90,6 +90,7 @@ const DEFAULT_ENABLED_PROVIDERS = Object.freeze(['typescript', 'go'])
|
|
|
90
90
|
* @type {Readonly<Record<string, Readonly<{ provider: ProviderId, language: string }>>>}
|
|
91
91
|
*/
|
|
92
92
|
const CANONICAL_ROUTE = Object.freeze({
|
|
93
|
+
'.js': Object.freeze({ provider: 'typescript', language: 'javascript' }),
|
|
93
94
|
'.ts': Object.freeze({ provider: 'typescript', language: 'typescript' }),
|
|
94
95
|
'.tsx': Object.freeze({ provider: 'typescript', language: 'typescriptreact' }),
|
|
95
96
|
'.go': Object.freeze({ provider: 'go', language: 'go' }),
|
|
@@ -290,11 +291,8 @@ function assertExtensionToLanguage(map, provider) {
|
|
|
290
291
|
if (defaults === undefined) fail(`provider ${provider} has no catalog defaults`)
|
|
291
292
|
const expected = defaults.extensionToLanguage
|
|
292
293
|
const expectedExtensions = Object.keys(expected)
|
|
293
|
-
if (
|
|
294
|
-
|
|
295
|
-
|| !expectedExtensions.every((extension) => routes.get(extension) === expected[extension])
|
|
296
|
-
) {
|
|
297
|
-
fail(`servers.${provider}.extensionToLanguage must equal the canonical provider mapping`)
|
|
294
|
+
if (!expectedExtensions.every((extension) => routes.get(extension) === expected[extension])) {
|
|
295
|
+
fail(`servers.${provider}.extensionToLanguage must include the canonical provider mapping`)
|
|
298
296
|
}
|
|
299
297
|
return Object.fromEntries(routes)
|
|
300
298
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@banbolee/dsh-lsp-diagnostics",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.2",
|
|
4
4
|
"description": "Automatic post-write and model-callable read-only LSP diagnostics for existing files in DeepSeek Harness.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "index.js",
|
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
"tool.js",
|
|
17
17
|
"render.js",
|
|
18
18
|
"coordinator.js",
|
|
19
|
+
"workspace-root.js",
|
|
19
20
|
"cordis.patch.yml",
|
|
20
21
|
"README.md"
|
|
21
22
|
],
|
package/tool.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { defineTool } from '@deepseek-ai/dsh-tools'
|
|
2
2
|
import { compareDiagnostics, sanitizeDisplayPath } from './render.js'
|
|
3
|
+
import { resolveWorkspaceRoot } from './workspace-root.js'
|
|
3
4
|
|
|
4
5
|
/** Closed runtime reasons exposed by the canonical tool output. */
|
|
5
6
|
const UNAVAILABLE_REASONS = /** @type {const} */ ([
|
|
@@ -105,7 +106,9 @@ const OUTPUT_SCHEMA = /** @type {const} */ ({
|
|
|
105
106
|
* @typedef {object} FsSeam
|
|
106
107
|
* @property {(path: string, opts?: { cwd?: string, signal?: AbortSignal }) => Promise<import('@deepseek-ai/dsh-fs').FsTarget>} resolve
|
|
107
108
|
* @property {(target: import('@deepseek-ai/dsh-fs').FsTarget, signal?: AbortSignal) => Promise<import('@deepseek-ai/dsh-fs').FsInfo | undefined>} stat
|
|
109
|
+
* @property {(parent: import('@deepseek-ai/dsh-fs').FsTarget, child: import('@deepseek-ai/dsh-fs').FsTarget) => boolean} contains
|
|
108
110
|
* @property {(target: import('@deepseek-ai/dsh-fs').FsTarget) => string} fileUrl
|
|
111
|
+
* @property {(target: import('@deepseek-ai/dsh-fs').FsTarget) => string} processPath
|
|
109
112
|
*/
|
|
110
113
|
|
|
111
114
|
/**
|
|
@@ -351,13 +354,13 @@ export function createDiagnosticsTool({ fs, runtime, config, now = () => perform
|
|
|
351
354
|
let early = abortOutcome(operation)
|
|
352
355
|
if (early !== undefined) return early
|
|
353
356
|
const signal = operation.controller.signal
|
|
354
|
-
const
|
|
357
|
+
const sessionWorkspace = await fs.resolve(workspaceRoot, { signal })
|
|
355
358
|
early = abortOutcome(operation)
|
|
356
359
|
if (early !== undefined) return early
|
|
357
|
-
const
|
|
360
|
+
const sessionWorkspaceInfo = await fs.stat(sessionWorkspace, signal)
|
|
358
361
|
early = abortOutcome(operation)
|
|
359
362
|
if (early !== undefined) return early
|
|
360
|
-
if (
|
|
363
|
+
if (sessionWorkspaceInfo === undefined || sessionWorkspaceInfo.type !== 'directory') {
|
|
361
364
|
throw new Error('lsp_diagnostics: session workspace is not an existing directory')
|
|
362
365
|
}
|
|
363
366
|
|
|
@@ -379,6 +382,28 @@ export function createDiagnosticsTool({ fs, runtime, config, now = () => perform
|
|
|
379
382
|
const label = extension.length === 0 ? '(none)' : extension
|
|
380
383
|
throw new Error(`lsp_diagnostics: no configured diagnostics provider for extension ${label}`)
|
|
381
384
|
}
|
|
385
|
+
/** @type {import('@deepseek-ai/dsh-fs').FsTarget | undefined} */
|
|
386
|
+
let workspace = sessionWorkspace
|
|
387
|
+
try {
|
|
388
|
+
if (fs.contains(sessionWorkspace, target) !== true) workspace = undefined
|
|
389
|
+
} catch {
|
|
390
|
+
workspace = undefined
|
|
391
|
+
}
|
|
392
|
+
if (workspace === undefined) {
|
|
393
|
+
workspace = await resolveWorkspaceRoot({
|
|
394
|
+
fs,
|
|
395
|
+
target,
|
|
396
|
+
extension,
|
|
397
|
+
sessionRoot: workspaceRoot,
|
|
398
|
+
signal,
|
|
399
|
+
sessionFallback: 'always',
|
|
400
|
+
})
|
|
401
|
+
}
|
|
402
|
+
early = abortOutcome(operation)
|
|
403
|
+
if (early !== undefined) return early
|
|
404
|
+
if (workspace === undefined) {
|
|
405
|
+
throw new Error('lsp_diagnostics: workspace root could not be resolved')
|
|
406
|
+
}
|
|
382
407
|
|
|
383
408
|
const outcome = await runtime.diagnoseTarget(
|
|
384
409
|
target,
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import { dirname, isAbsolute, join } from 'node:path'
|
|
2
|
+
|
|
3
|
+
/** @typedef {'always' | 'if-contained' | 'never'} SessionFallback */
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* @typedef {object} FsSeam
|
|
7
|
+
* @property {(path: string, opts?: { cwd?: string, signal?: AbortSignal }) => Promise<any>} resolve
|
|
8
|
+
* @property {(statTarget: any, signal?: AbortSignal) => Promise<{ version: string, type: string, size?: number } | undefined>} stat
|
|
9
|
+
* @property {(parentTarget: any, childTarget: any) => boolean} contains
|
|
10
|
+
* @property {(processTarget: any) => string} processPath
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
const GO_MARKERS = /** @type {const} */ (['go.work', 'go.mod', '.git'])
|
|
14
|
+
const TYPESCRIPT_MARKERS = /** @type {const} */ (['tsconfig.json', 'jsconfig.json', 'package.json', '.git'])
|
|
15
|
+
const DEFAULT_MARKERS = /** @type {const} */ (['.git'])
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Return project markers for the already-routed source extension.
|
|
19
|
+
* @param {string} extension
|
|
20
|
+
* @returns {readonly string[]}
|
|
21
|
+
*/
|
|
22
|
+
function markersForExtension(extension) {
|
|
23
|
+
if (extension === '.go') return GO_MARKERS
|
|
24
|
+
if (extension === '.ts' || extension === '.tsx') return TYPESCRIPT_MARKERS
|
|
25
|
+
return DEFAULT_MARKERS
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Stat one marker path. Missing or inaccessible markers are simply not roots.
|
|
30
|
+
* @param {FsSeam} fs
|
|
31
|
+
* @param {string} markerPath
|
|
32
|
+
* @param {AbortSignal | undefined} signal
|
|
33
|
+
* @returns {Promise<boolean>}
|
|
34
|
+
*/
|
|
35
|
+
async function markerExists(fs, markerPath, signal) {
|
|
36
|
+
try {
|
|
37
|
+
if (signal?.aborted) return false
|
|
38
|
+
const marker = await fs.resolve(markerPath, { signal })
|
|
39
|
+
if (signal?.aborted) return false
|
|
40
|
+
const info = await fs.stat(marker, signal)
|
|
41
|
+
if (signal?.aborted) return false
|
|
42
|
+
return info?.type === 'file' || info?.type === 'directory'
|
|
43
|
+
} catch {
|
|
44
|
+
return false
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Resolve an existing directory root candidate.
|
|
50
|
+
* @param {FsSeam} fs
|
|
51
|
+
* @param {string} directory
|
|
52
|
+
* @param {AbortSignal | undefined} signal
|
|
53
|
+
* @returns {Promise<any | undefined>}
|
|
54
|
+
*/
|
|
55
|
+
async function resolveDirectory(fs, directory, signal) {
|
|
56
|
+
try {
|
|
57
|
+
if (signal?.aborted) return undefined
|
|
58
|
+
const root = await fs.resolve(directory, { signal })
|
|
59
|
+
if (signal?.aborted) return undefined
|
|
60
|
+
const info = await fs.stat(root, signal)
|
|
61
|
+
if (signal?.aborted) return undefined
|
|
62
|
+
return info?.type === 'directory' ? root : undefined
|
|
63
|
+
} catch {
|
|
64
|
+
return undefined
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Walk from the target's directory to the filesystem root looking for project markers.
|
|
70
|
+
* @param {FsSeam} fs
|
|
71
|
+
* @param {string} startDirectory
|
|
72
|
+
* @param {readonly string[]} markers
|
|
73
|
+
* @param {AbortSignal | undefined} signal
|
|
74
|
+
* @returns {Promise<any | undefined>}
|
|
75
|
+
*/
|
|
76
|
+
async function nearestMarkedDirectory(fs, startDirectory, markers, signal) {
|
|
77
|
+
let directory = startDirectory
|
|
78
|
+
for (;;) {
|
|
79
|
+
for (const marker of markers) {
|
|
80
|
+
if (await markerExists(fs, join(directory, marker), signal)) {
|
|
81
|
+
return resolveDirectory(fs, directory, signal)
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
const parent = dirname(directory)
|
|
85
|
+
if (parent === directory) return undefined
|
|
86
|
+
directory = parent
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Resolve the session root fallback and optionally require it to contain target.
|
|
92
|
+
* @param {FsSeam} fs
|
|
93
|
+
* @param {string} sessionRoot
|
|
94
|
+
* @param {any} target
|
|
95
|
+
* @param {SessionFallback} fallback
|
|
96
|
+
* @param {AbortSignal | undefined} signal
|
|
97
|
+
* @returns {Promise<any | undefined>}
|
|
98
|
+
*/
|
|
99
|
+
async function resolveSessionFallback(fs, sessionRoot, target, fallback, signal) {
|
|
100
|
+
if (fallback === 'never') return undefined
|
|
101
|
+
if (typeof sessionRoot !== 'string' || sessionRoot.trim().length === 0) return undefined
|
|
102
|
+
const workspace = await resolveDirectory(fs, sessionRoot, signal)
|
|
103
|
+
if (workspace === undefined) return undefined
|
|
104
|
+
if (fallback === 'always') return workspace
|
|
105
|
+
try {
|
|
106
|
+
return fs.contains(workspace, target) === true ? workspace : undefined
|
|
107
|
+
} catch {
|
|
108
|
+
return undefined
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Resolve the LSP workspace for a target. Prefer a project marker rooted at the
|
|
114
|
+
* target itself (so sibling git worktrees work), then fall back to the session
|
|
115
|
+
* workspace according to the caller's policy.
|
|
116
|
+
*
|
|
117
|
+
* @param {object} input
|
|
118
|
+
* @param {FsSeam} input.fs
|
|
119
|
+
* @param {any} input.target
|
|
120
|
+
* @param {string} input.extension
|
|
121
|
+
* @param {string} input.sessionRoot
|
|
122
|
+
* @param {AbortSignal} [input.signal]
|
|
123
|
+
* @param {SessionFallback} [input.sessionFallback]
|
|
124
|
+
* @param {'language' | 'git'} [input.markerMode]
|
|
125
|
+
* @returns {Promise<any | undefined>}
|
|
126
|
+
*/
|
|
127
|
+
export async function resolveWorkspaceRoot({
|
|
128
|
+
fs,
|
|
129
|
+
target,
|
|
130
|
+
extension,
|
|
131
|
+
sessionRoot,
|
|
132
|
+
signal,
|
|
133
|
+
sessionFallback = 'if-contained',
|
|
134
|
+
markerMode = 'language',
|
|
135
|
+
}) {
|
|
136
|
+
if (sessionFallback === 'if-contained') {
|
|
137
|
+
const sessionWorkspace = await resolveSessionFallback(fs, sessionRoot, target, sessionFallback, signal)
|
|
138
|
+
if (sessionWorkspace !== undefined) return sessionWorkspace
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
try {
|
|
142
|
+
if (signal?.aborted) return undefined
|
|
143
|
+
const targetPath = fs.processPath(target)
|
|
144
|
+
if (typeof targetPath === 'string' && isAbsolute(targetPath)) {
|
|
145
|
+
const markers = markerMode === 'git' ? DEFAULT_MARKERS : markersForExtension(extension)
|
|
146
|
+
const marked = await nearestMarkedDirectory(fs, dirname(targetPath), markers, signal)
|
|
147
|
+
if (marked !== undefined) return marked
|
|
148
|
+
}
|
|
149
|
+
} catch {
|
|
150
|
+
// Fall through to the configured session fallback.
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
if (sessionFallback === 'if-contained') return undefined
|
|
154
|
+
return resolveSessionFallback(fs, sessionRoot, target, sessionFallback, signal)
|
|
155
|
+
}
|