@banbolee/dsh-lsp-diagnostics 0.1.1 → 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 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, the tool result still succeeds and the next model inference carries
6
- a single persistent LSP diagnostics plugin notice. It also registers the
7
- model-callable `lsp_diagnostics(file_path)` tool for explicit diagnosis.
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 the bundle into any DSH profile from the repository root. The
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, `.go` → go/go, `.c` → clangd/c,
63
- `.cc`/`.cpp`/`.cxx` → clangd/cpp, `.h`/`.hh`/`.hpp`/`.hxx` → clangd/cpp,
64
- `.rs` → rust/rust, and `.py`/`.pyi` → python/python; any other extension key,
65
- missing canonical provider entry, cross-provider collision, or rewritten
66
- language id fails loud at load time, while other extensions at runtime are
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 exactly equals that
75
- provider's canonical mapping; it never defines new routes.
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
- Only a non-empty `session.header.cwd` that canonicalizes to a directory and
85
- contains the target is eligible; missing or empty cwd, non-directory
86
- canonicalization, and outside-workspace targets are ineligible and silently
87
- ignored — never a `workspace unavailable` or `outside workspace` notice.
88
- Workspace, cwd, and URI are always derived through the public `ctx.fs` API
89
- (`resolve`/`stat`/`contains`/`targetKey`/`processPath`/`fileUrl`), never from
90
- `displayPath` or the host `process.cwd()`.
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: the session cwd is only the LSP project root, so an
191
- explicit direct call may diagnose a readable file outside that root. It never
192
- creates, edits, or deletes a file. Invalid requests fail explicitly with
193
- `file_path must be a non-empty string`, `session workspace cwd`, `session
194
- workspace is not an existing directory`, `target does not exist`, `target is
195
- not a regular file`, `no configured diagnostics provider`, or `target changed
196
- during diagnosis`.
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 and no project-root probing — the session cwd is the
235
- workspace root.
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-workspace targets are silently out of scope
238
- and never produce a notice. Direct calls use official `read`-equivalent path
239
- authority instead: a readable file outside the session cwd is eligible, while
240
- that cwd remains the LSP project root.
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 services
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. Workspace root from the session header; missing/empty is ineligible.
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 workspaceRoot = execRecord?.agent?.session?.header?.cwd
388
- if (typeof workspaceRoot !== 'string' || workspaceRoot.length === 0) {
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
- // 3. Canonicalize the workspace exactly once.
397
- let workspaceTarget
400
+ let sessionWorkspace
398
401
  try {
399
- workspaceTarget = await fs.resolve(workspaceRoot, { signal })
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 workspaceInfo
411
+ let sessionWorkspaceInfo
409
412
  try {
410
- workspaceInfo = await fs.stat(workspaceTarget, signal)
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 (workspaceInfo === undefined || workspaceInfo.type !== 'directory') {
422
+ if (sessionWorkspaceInfo === undefined || sessionWorkspaceInfo.type !== 'directory') {
420
423
  retireAll()
421
424
  return decision
422
425
  }
423
- // 4. Per-target eligibility: contains with pre/post abort checks, then
424
- // freeze renderPath + canonicalUri exactly once. Every early exit and
425
- // abort break retires the remaining taken candidates.
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(workspaceTarget, candidate.target) === true
436
+ contained = fs.contains(sessionWorkspace, candidate.target) === true
434
437
  } catch {
435
438
  contained = false
436
439
  }
437
440
  if (signal.aborted) break
438
- if (!contained) {
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
- routes.size !== expectedExtensions.length
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.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 workspace = await fs.resolve(workspaceRoot, { signal })
357
+ const sessionWorkspace = await fs.resolve(workspaceRoot, { signal })
355
358
  early = abortOutcome(operation)
356
359
  if (early !== undefined) return early
357
- const workspaceInfo = await fs.stat(workspace, signal)
360
+ const sessionWorkspaceInfo = await fs.stat(sessionWorkspace, signal)
358
361
  early = abortOutcome(operation)
359
362
  if (early !== undefined) return early
360
- if (workspaceInfo === undefined || workspaceInfo.type !== 'directory') {
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
+ }