@danypops/pi-lector 0.12.4 → 0.12.6

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.
@@ -1,5 +1,5 @@
1
- import type { SymbolSearchResult, TextSearchResult, WorkspaceQueryOutcome } from "@danypops/lector";
2
- import { lectorClient, workspaceForProjectDirectory } from "../lector-client.ts";
1
+ import { type SymbolSearchResult, type TextSearchResult, UnknownWorkspace, type WorkspaceQueryOutcome } from "@danypops/lector";
2
+ import { forgetWorkspaceId, lectorClient, type ResolvedWorkspace, workspaceForProjectDirectory } from "../lector-client.ts";
3
3
 
4
4
  /**
5
5
  * Fans out across explicitly-named directories only -- never the daemon's own "every registered
@@ -10,7 +10,13 @@ import { lectorClient, workspaceForProjectDirectory } from "../lector-client.ts"
10
10
  * required, same "no implicit fallback" convention as find_symbols/search_code.
11
11
  */
12
12
  export interface CrossWorkspaceSearchOperations {
13
- findSymbols(query: string, directories: readonly string[], timeoutMs?: number): Promise<readonly CrossWorkspaceOutcome<SymbolSearchResult>[]>;
13
+ /** maxResults, when given, applies per project (see workspace.cacheStatus's own OperationInputs note on search.symbols) -- omitted falls back to the daemon's own conservative default, never an unbounded per-project search.symbols call fanned out across every requested directory at once. */
14
+ findSymbols(
15
+ query: string,
16
+ directories: readonly string[],
17
+ timeoutMs?: number,
18
+ maxResults?: number,
19
+ ): Promise<readonly CrossWorkspaceOutcome<SymbolSearchResult>[]>;
14
20
  searchText(
15
21
  query: string,
16
22
  directories: readonly string[],
@@ -36,18 +42,23 @@ export interface CrossWorkspaceOutcome<T> {
36
42
  readonly outcome: WorkspaceQueryOutcome<T>;
37
43
  }
38
44
 
39
- async function resolveWorkspaceIds(directories: readonly string[]): Promise<readonly string[]> {
40
- const resolved = await Promise.all(directories.map((directory) => workspaceForProjectDirectory(directory)));
41
- return resolved.map((r) => r.workspaceId);
45
+ function resolveWorkspaces(directories: readonly string[]): Promise<readonly ResolvedWorkspace[]> {
46
+ return Promise.all(directories.map((directory) => workspaceForProjectDirectory(directory)));
42
47
  }
43
48
 
44
49
  /**
45
50
  * Zips the daemon's own outcomes back onto the literal directories that produced them, and
46
- * computes collapsedWith. The daemon's search.symbols/search.text handlers map workspaceIds to
47
- * results 1:1, in order, with no deduplication of their own (confirmed by reading service.ts's
48
- * crossFindSymbols/crossSearchText: `targets.map(...)` over the exact `workspaceIds` array
49
- * given) -- so a length mismatch here means that contract broke, not a normal runtime condition
50
- * to paper over with an unsafe cast.
51
+ * computes collapsedWith -- by workspaceId identity, never by array position. A real, reproduced
52
+ * live bug: the daemon's own response order can legitimately differ from the request's
53
+ * workspaceIds order (an immediate per-item error for an unregistered id is reported ahead of
54
+ * real results, for instance) -- a caller correlating positionally then hands one directory's
55
+ * result to a completely different directory. Every outcome already carries its own workspaceId;
56
+ * this groups outcomes by that id (preserving arrival order *within* one id's own group, since
57
+ * two directories can legitimately share one workspaceId -- a monorepo's unmarked siblings) and
58
+ * consumes exactly one outcome per requested (directory, workspaceId) pair in that group, in
59
+ * order. A missing, duplicated-beyond-what-was-asked, or wholly unrequested workspaceId in the
60
+ * response means the daemon's contract broke -- fails loud rather than silently mislabeling or
61
+ * dropping data.
51
62
  */
52
63
  function zipOutcomes<T>(
53
64
  directories: readonly string[],
@@ -56,33 +67,86 @@ function zipOutcomes<T>(
56
67
  ): readonly CrossWorkspaceOutcome<T>[] {
57
68
  if (outcomes.length !== directories.length) {
58
69
  throw new Error(
59
- `Lector's search fan-out returned ${outcomes.length} outcome(s) for ${directories.length} requested directories -- expected exactly one outcome per directory, in order`,
70
+ `Lector's search fan-out returned ${outcomes.length} outcome(s) for ${directories.length} requested directories -- expected exactly one outcome per directory`,
60
71
  );
61
72
  }
62
- return directories.map((directory, index) => {
73
+ const outcomesByWorkspaceId = new Map<string, WorkspaceQueryOutcome<T>[]>();
74
+ for (const outcome of outcomes) {
75
+ const bucket = outcomesByWorkspaceId.get(outcome.workspaceId);
76
+ if (bucket) bucket.push(outcome);
77
+ else outcomesByWorkspaceId.set(outcome.workspaceId, [outcome]);
78
+ }
79
+ const consumedByWorkspaceId = new Map<string, number>();
80
+ const results = directories.map((directory, index) => {
63
81
  const workspaceId = workspaceIds[index];
64
- const outcome = outcomes[index];
65
- if (workspaceId === undefined || outcome === undefined) {
66
- throw new Error(`Lector's search fan-out is missing a workspaceId/outcome for directory "${directory}"`);
82
+ if (workspaceId === undefined) throw new Error(`Lector's search fan-out is missing a resolved workspaceId for directory "${directory}"`);
83
+ const consumed = consumedByWorkspaceId.get(workspaceId) ?? 0;
84
+ const outcome = outcomesByWorkspaceId.get(workspaceId)?.[consumed];
85
+ if (!outcome) {
86
+ throw new Error(
87
+ `Lector's search fan-out returned no outcome for workspace "${workspaceId}" (directory "${directory}") -- the daemon's response no longer corresponds to the request`,
88
+ );
67
89
  }
90
+ consumedByWorkspaceId.set(workspaceId, consumed + 1);
68
91
  const collapsedWith = directories.filter((_, otherIndex) => otherIndex !== index && workspaceIds[otherIndex] === workspaceId);
69
92
  return { directory, workspaceId, collapsedWith, outcome };
70
93
  });
94
+ const totalConsumed = [...consumedByWorkspaceId.values()].reduce((sum, count) => sum + count, 0);
95
+ if (totalConsumed !== outcomes.length) {
96
+ throw new Error(
97
+ "Lector's search fan-out returned an outcome for a workspace nobody asked for -- the daemon's response no longer corresponds to the request",
98
+ );
99
+ }
100
+ return results;
101
+ }
102
+
103
+ /** True exactly for the daemon's own "this workspaceId is not registered" outcome for this request's own workspaceId -- never a string-matched guess against an unrelated error. */
104
+ function isUnknownWorkspaceOutcome<T>(outcome: WorkspaceQueryOutcome<T>, workspaceId: string): boolean {
105
+ return outcome.status === "error" && outcome.message === new UnknownWorkspace(workspaceId).message;
106
+ }
107
+
108
+ /**
109
+ * A daemon restart wipes its in-memory workspace registry, but this process's own workspaceId
110
+ * cache does not know that on its own -- a cross-workspace call through a stale cached id comes
111
+ * back with a real, correctly-correlated "no workspace registered" outcome for that one
112
+ * workspace, even though the underlying directory on disk never changed. On exactly that
113
+ * outcome, the stale cache entries are dropped and the whole fan-out (re-resolve every
114
+ * directory, then re-run) retries once -- the same bounded, idempotent recovery withWorkspace
115
+ * already gives single-workspace operations, extended to a batch. A genuine per-workspace error
116
+ * unrelated to registration (an unsupported language, a real internal failure) is never retried.
117
+ */
118
+ async function withCrossWorkspaceRestartRecovery<T>(
119
+ directories: readonly string[],
120
+ perform: (resolved: readonly ResolvedWorkspace[]) => Promise<readonly CrossWorkspaceOutcome<T>[]>,
121
+ ): Promise<readonly CrossWorkspaceOutcome<T>[]> {
122
+ const resolved = await resolveWorkspaces(directories);
123
+ const outcomes = await perform(resolved);
124
+ const stale = outcomes.filter((entry) => isUnknownWorkspaceOutcome(entry.outcome, entry.workspaceId));
125
+ if (stale.length === 0) return outcomes;
126
+ for (const entry of stale) {
127
+ const match = resolved.find((candidate) => candidate.workspaceId === entry.workspaceId);
128
+ if (match) forgetWorkspaceId(match.root);
129
+ }
130
+ return perform(await resolveWorkspaces(directories));
71
131
  }
72
132
 
73
133
  export function createLectorCrossWorkspaceSearchOperations(): CrossWorkspaceSearchOperations {
74
134
  return {
75
- async findSymbols(query, directories, timeoutMs) {
76
- const workspaceIds = await resolveWorkspaceIds(directories);
77
- const client = await lectorClient();
78
- const { results } = await client.call("search.symbols", { query, workspaceIds, timeoutMs });
79
- return zipOutcomes(directories, workspaceIds, results);
135
+ findSymbols(query, directories, timeoutMs, maxResults) {
136
+ return withCrossWorkspaceRestartRecovery(directories, async (resolved) => {
137
+ const workspaceIds = resolved.map((r) => r.workspaceId);
138
+ const client = await lectorClient();
139
+ const { results } = await client.call("search.symbols", { query, workspaceIds, timeoutMs, ...(maxResults !== undefined ? { maxResults } : {}) });
140
+ return zipOutcomes(directories, workspaceIds, results);
141
+ });
80
142
  },
81
- async searchText(query, directories, maxMatches, maxBytes, timeoutMs) {
82
- const workspaceIds = await resolveWorkspaceIds(directories);
83
- const client = await lectorClient();
84
- const { results } = await client.call("search.text", { query, maxMatches, maxBytes, workspaceIds, timeoutMs });
85
- return zipOutcomes(directories, workspaceIds, results);
143
+ searchText(query, directories, maxMatches, maxBytes, timeoutMs) {
144
+ return withCrossWorkspaceRestartRecovery(directories, async (resolved) => {
145
+ const workspaceIds = resolved.map((r) => r.workspaceId);
146
+ const client = await lectorClient();
147
+ const { results } = await client.call("search.text", { query, maxMatches, maxBytes, workspaceIds, timeoutMs });
148
+ return zipOutcomes(directories, workspaceIds, results);
149
+ });
86
150
  },
87
151
  };
88
152
  }
@@ -44,6 +44,7 @@ import { Type } from "typebox";
44
44
  /** Real ANSI-aware measurement for Table -- Malevich's own default is ASCII-only, unsafe against theme-styled cell/header text. */
45
45
  const tableMeasure: TextMeasure = { visibleWidth, truncateToWidth };
46
46
 
47
+ import { isFilesystemRoot } from "@danypops/lector";
47
48
  import { createLectorApplyPatchOperations } from "./apply-patch/operations.ts";
48
49
  import { formatApplyPatchCall, formatApplyPatchResult } from "./apply-patch/rendering.ts";
49
50
  import { createLectorCodeIntelligenceOperations } from "./code-intelligence/operations.ts";
@@ -89,11 +90,10 @@ import { createLectorFindSymbolsOperations } from "./find-symbols/operations.ts"
89
90
  import { describeFindSymbolSources, formatFindSymbolsCall, formatFindSymbolsResult } from "./find-symbols/rendering.ts";
90
91
  import { createLectorGitOperations } from "./git/operations.ts";
91
92
  import { formatGitCall, formatGitResult, type GitToolDetails } from "./git/rendering.ts";
92
- import { setNewWorkspaceObserver } from "./lector-client.ts";
93
+ import { nearestGitWorkspaceRoot, setNewWorkspaceObserver } from "./lector-client.ts";
93
94
  import { createLectorLineEditOperations } from "./line-edit/operations.ts";
94
95
  import { formatLineEditCall, formatLineEditResult } from "./line-edit/rendering.ts";
95
96
  import { createMutationHistoryOperations } from "./mutation-history/operations.ts";
96
- import { isFilesystemRoot, nearestGitRoot } from "./nearest-workspace-root.ts";
97
97
  import { createLectorPackageSourceOperations, type PackageSourceListPage } from "./package-source/operations.ts";
98
98
  import {
99
99
  buildPackageSourceListTableRows,
@@ -279,9 +279,17 @@ export default function (pi: ExtensionAPI) {
279
279
  lastInjectedSummary = undefined;
280
280
  uiContext = ctx;
281
281
  setNewWorkspaceObserver((root) => startMonitoringRoot(root, ctx));
282
- const projectRoot = nearestGitRoot(cwd);
283
- if (projectRoot) startMonitoringRoot(projectRoot, ctx);
284
- else ctx.ui.setStatus("lector-cache", undefined);
282
+ const thisGeneration = sessionGeneration;
283
+ void nearestGitWorkspaceRoot(cwd)
284
+ .then((projectRoot) => {
285
+ if (sessionGeneration !== thisGeneration) return;
286
+ if (projectRoot) startMonitoringRoot(projectRoot, ctx);
287
+ else ctx.ui.setStatus("lector-cache", undefined);
288
+ })
289
+ .catch(() => {
290
+ // A daemon that isn't running yet is not a session_start failure -- the first real
291
+ // tool call surfaces that clearly instead.
292
+ });
285
293
 
286
294
  pi.registerTool(createReadToolDefinition(cwd, { operations: createLectorReadOperations() }));
287
295
  pi.registerTool(createWriteToolDefinition(cwd, { operations: createLectorWriteOperations() }));
@@ -1155,14 +1163,20 @@ export default function (pi: ExtensionAPI) {
1155
1163
  if (!Number.isSafeInteger(waitMs) || waitMs < 1 || waitMs > 300_000)
1156
1164
  throw new Error("workspace_cache action=wait waitMs must be an integer from 1 to 300000");
1157
1165
  const pollIntervalMs = 5_000;
1158
- const completed = await waitForJobCompletion(cacheOperations, params.jobId, {
1166
+ const outcome = await waitForJobCompletion(cacheOperations, params.jobId, {
1159
1167
  pollIntervalMs,
1160
1168
  maxPolls: Math.ceil(waitMs / pollIntervalMs),
1161
1169
  shouldContinue: () => !signal?.aborted,
1162
1170
  signal,
1163
1171
  });
1164
1172
  if (signal?.aborted) throw new DOMException("workspace cache wait canceled", "AbortError");
1165
- const job = completed ?? (await cacheOperations.jobStatus(params.jobId));
1173
+ if (outcome.kind === "transport-failed") throw outcome.error instanceof Error ? outcome.error : new Error(String(outcome.error));
1174
+ if (outcome.kind === "job-not-found") {
1175
+ throw new Error(
1176
+ `workspace cache job "${params.jobId}" is no longer known -- it expired, was evicted, or belonged to a previous daemon process; check workspace_cache action=status instead`,
1177
+ );
1178
+ }
1179
+ const job = outcome.kind === "terminal" ? outcome.job : await cacheOperations.jobStatus(params.jobId);
1166
1180
  return { content: [{ type: "text", text: JSON.stringify(job) }], details: { action: "wait", job } };
1167
1181
  }
1168
1182
  if (!params.directory) throw new Error(`workspace_cache action=${params.action} requires directory`);
@@ -1810,10 +1824,13 @@ export default function (pi: ExtensionAPI) {
1810
1824
  directories: Type.Array(Type.String(), { description: "Project directories to search, each absolute or relative to the current working directory" }),
1811
1825
  query: Type.String({ description: "Symbol name (or substring) to search for" }),
1812
1826
  timeoutMs: Type.Optional(Type.Number({ description: "How long to wait per project before reporting it as still-loading; defaults to 3000" })),
1827
+ maxResults: Type.Optional(
1828
+ Type.Number({ description: "Maximum matches to return per project (not total across every project); defaults to a conservative per-project bound" }),
1829
+ ),
1813
1830
  }),
1814
1831
  async execute(_toolCallId, params) {
1815
1832
  const directories = params.directories.map((directory) => resolve(cwd, directory));
1816
- const results = await crossWorkspaceSearchOperations.findSymbols(params.query, directories, params.timeoutMs);
1833
+ const results = await crossWorkspaceSearchOperations.findSymbols(params.query, directories, params.timeoutMs, params.maxResults);
1817
1834
  return { content: [{ type: "text", text: JSON.stringify(results) }], details: { results } };
1818
1835
  },
1819
1836
  renderCall(args, theme, context) {
@@ -1,9 +1,6 @@
1
- import { existsSync, statSync } from "node:fs";
2
- import { dirname, extname, parse } from "node:path";
1
+ import { dirname, extname } from "node:path";
3
2
  import {
4
3
  connectLectorClient,
5
- descriptorForExtension,
6
- LANGUAGE_SERVER_DESCRIPTORS,
7
4
  type LectorClient,
8
5
  LectorDaemonUnavailable,
9
6
  type OperationInputs,
@@ -11,25 +8,31 @@ import {
11
8
  type OperationOutputs,
12
9
  remoteErrorIs,
13
10
  type WorkspaceId,
11
+ type WorkspaceResolutionRequest,
14
12
  } from "@danypops/lector";
15
13
  import { createRetryingClient, isLikelyStaleConnectionError, type RetryingClient } from "@danypops/vehicle-client/daemon-client";
16
- import { nearestGitRoot, nearestProjectRoot } from "./nearest-workspace-root.ts";
17
14
 
18
15
  /**
19
- * Lazily connects to a running Lector daemon and caches, per project root,
20
- * the workspaceId that root registers under. Never auto-spawns the daemon:
21
- * a clear "start it with `lector serve`" error is preferable to guessing
22
- * at a lifecycle the user didn't ask for. A failed connection attempt is
23
- * not cached, so the very next tool call retries once the daemon is
24
- * actually running.
16
+ * Lazily connects to a running Lector daemon and caches, per resolution request, the
17
+ * workspace it resolves to. Never auto-spawns the daemon: a clear "start it with `lector serve`"
18
+ * error is preferable to guessing at a lifecycle the user didn't ask for. A failed connection
19
+ * attempt is not cached, so the very next tool call retries once the daemon is actually running.
25
20
  *
26
- * The daemon binds a new random port on every restart. A client resolved
27
- * once and cached for the rest of the session would otherwise point at a
28
- * dead port after any later restart -- daemon-kit's createRetryingClient
29
- * detects that on the failing call itself (not just the first connection
30
- * attempt) and retries once against a freshly re-resolved client, the same
31
- * policy this file used to hand-roll and now shares with web-spider's
32
- * callWebSpider(), papyrus's callService(), and pi-packed's createNatives().
21
+ * The daemon binds a new random port on every restart. A client resolved once and cached for
22
+ * the rest of the session would otherwise point at a dead port after any later restart --
23
+ * daemon-kit's createRetryingClient detects that on the failing call itself (not just the first
24
+ * connection attempt) and retries once against a freshly re-resolved client, the same policy
25
+ * this file used to hand-roll and now shares with web-spider's callWebSpider(), papyrus's
26
+ * callService(), and pi-packed's createNatives().
27
+ *
28
+ * Workspace-root resolution itself (which file belongs to which project) is Lector's own
29
+ * server-side concern -- see @danypops/lector's workspace.resolvePath and its own
30
+ * resolveWorkspacePath. This module used to reimplement that same filesystem walk-up locally
31
+ * (nearestGitRoot/nearestProjectRoot/nearestDeclaredWorkspaceRoot); two real, previously-shipped
32
+ * bugs (a project's own root directory silently resolving to its parent, and two sibling
33
+ * monorepo packages in this very repo collapsing onto the same workspaceId) traced directly to
34
+ * that logic living in the wrong process. Every workspaceForXxx below is now a thin RPC wrapper
35
+ * over workspace.resolvePath.
33
36
  */
34
37
 
35
38
  type ClientConnector = () => Promise<LectorClient>;
@@ -42,14 +45,15 @@ const retryingClient: RetryingClient<LectorClient> = createRetryingClient(() =>
42
45
  label: "Lector",
43
46
  isStaleConnectionError: (error) => error instanceof LectorDaemonUnavailable || isLikelyStaleConnectionError(error),
44
47
  });
45
- const workspaceIdByRoot = new Map<string, WorkspaceId>();
46
48
 
47
49
  /**
48
- * Fires exactly once per distinct root, the moment it's first registered in this process --
49
- * never on a later call that reuses the cached workspaceId. The single choke point every
50
+ * Fires exactly once per distinct root, the moment the daemon itself first registers it --
51
+ * never on a later call that resolves an already-registered root. The single choke point every
50
52
  * resolver (workspaceForPath, workspaceForDirectory, workspaceForCodeIntelligencePath,
51
53
  * workspaceForPathOrDirectory) funnels through, so this is genuinely "the first time any tool
52
- * call resolves this workspace," not just the one cwd workspace at session start.
54
+ * call resolves this workspace," not just the one cwd workspace at session start. Driven by
55
+ * workspace.resolvePath's own authoritative `created` flag, not a local cache -- correct even
56
+ * when a different process registered the same root moments earlier.
53
57
  */
54
58
  let onNewWorkspace: ((root: string) => void) | undefined;
55
59
 
@@ -80,18 +84,41 @@ export async function lectorClient(): Promise<RetryingLectorClient> {
80
84
 
81
85
  export interface ResolvedWorkspace {
82
86
  workspaceId: WorkspaceId;
83
- /** The registered root the target path is relative to -- a git root or the filesystem root, never a fixed session cwd. */
87
+ /** The root workspace.resolvePath actually registered -- a git root, a language project root, or the filesystem root, never a fixed session cwd. */
84
88
  root: string;
85
89
  }
86
90
 
87
- async function workspaceForRoot(root: string): Promise<ResolvedWorkspace> {
88
- const cached = workspaceIdByRoot.get(root);
89
- if (cached) return { workspaceId: cached, root };
91
+ /**
92
+ * Caches by the exact resolution request, not by the discovered root (this process no longer
93
+ * discovers roots itself) -- a repeated call with the identical path+strategy avoids a network
94
+ * round trip; two different files under the same repo each pay one round trip the first time,
95
+ * same as workspace.registerPath's own idempotent registration would cost anyway. A daemon
96
+ * restart wipes its in-memory registry; withWorkspace's own UnknownWorkspace retry (unchanged
97
+ * below) evicts exactly the stale entry this cache produced, the same recovery it always gave.
98
+ */
99
+ const resolutionCache = new Map<string, ResolvedWorkspace>();
100
+
101
+ function requestCacheKey(request: WorkspaceResolutionRequest): string {
102
+ return JSON.stringify(request, Object.keys(request).sort());
103
+ }
104
+
105
+ async function resolveWorkspace(request: WorkspaceResolutionRequest): Promise<ResolvedWorkspace> {
106
+ const key = requestCacheKey(request);
107
+ const cached = resolutionCache.get(key);
108
+ if (cached) return cached;
90
109
  const client = await lectorClient();
91
- const { workspaceId } = await client.callOnce("workspace.registerPath", { path: root });
92
- workspaceIdByRoot.set(root, workspaceId);
93
- onNewWorkspace?.(root);
94
- return { workspaceId, root };
110
+ const outcome = await client.callOnce("workspace.resolvePath", request);
111
+ if (!outcome.found) {
112
+ // Every strategy this function is used for (git-root/language-project-root with an
113
+ // explicit fallback, path-or-directory) always resolves to something server-side --
114
+ // declared-monorepo-root (the one strategy that can legitimately report not found) is
115
+ // never routed through this function, see resolveDeclaredMonorepoRoot below.
116
+ throw new Error(`workspace.resolvePath unexpectedly reported not-found for a fallback-guaranteed strategy: ${key}`);
117
+ }
118
+ const resolved: ResolvedWorkspace = { workspaceId: outcome.workspaceId, root: outcome.root };
119
+ resolutionCache.set(key, resolved);
120
+ if (outcome.created) onNewWorkspace?.(outcome.root);
121
+ return resolved;
95
122
  }
96
123
 
97
124
  /**
@@ -99,9 +126,8 @@ async function workspaceForRoot(root: string): Promise<ResolvedWorkspace> {
99
126
  * contains this absolute FILE path -- never a session's original cwd.
100
127
  * Files under the same repo share one cached workspace+id; a path under a
101
128
  * different repo (or outside any repo entirely) gets its own, registered
102
- * on demand via workspace.registerPath. This is what makes read/write/edit
103
- * work for *any* absolute path in one session, exactly like Pi's built-in
104
- * tools always have -- not just paths under wherever the session started.
129
+ * on demand. This is what makes read/write/edit work for *any* absolute
130
+ * path in one session, exactly like Pi's built-in tools always have.
105
131
  *
106
132
  * Falls back to the filesystem root when no enclosing git repo exists:
107
133
  * unlike workspaceForDirectory, any absolute path is fair game here (a
@@ -110,9 +136,7 @@ async function workspaceForRoot(root: string): Promise<ResolvedWorkspace> {
110
136
  * built-in read/write/edit already allow.
111
137
  */
112
138
  export function workspaceForPath(absolutePath: string): Promise<ResolvedWorkspace> {
113
- const directory = dirname(absolutePath);
114
- const root = nearestGitRoot(directory) ?? parse(directory).root;
115
- return workspaceForRoot(root);
139
+ return resolveWorkspace({ strategy: "git-root", path: dirname(absolutePath), fallback: "filesystem-root" });
116
140
  }
117
141
 
118
142
  /**
@@ -124,8 +148,19 @@ export function workspaceForPath(absolutePath: string): Promise<ResolvedWorkspac
124
148
  * outside the project) and unbounded (scanning the whole disk).
125
149
  */
126
150
  export function workspaceForDirectory(directory: string): Promise<ResolvedWorkspace> {
127
- const root = nearestGitRoot(directory) ?? directory;
128
- return workspaceForRoot(root);
151
+ return resolveWorkspace({ strategy: "git-root", path: directory, fallback: "given-directory" });
152
+ }
153
+
154
+ /**
155
+ * Honest "does a real git repo exist here at all" -- unlike workspaceForDirectory, no fallback
156
+ * masks a non-project directory as its own root. Used by session_start to decide whether cwd
157
+ * looks like a real project worth auto-populating a cache for, never a bare scratch/home
158
+ * directory.
159
+ */
160
+ export async function nearestGitWorkspaceRoot(directory: string): Promise<string | undefined> {
161
+ const client = await lectorClient();
162
+ const outcome = await client.call("workspace.resolvePath", { strategy: "git-root", path: directory });
163
+ return outcome.found ? outcome.root : undefined;
129
164
  }
130
165
 
131
166
  /**
@@ -141,15 +176,14 @@ export function workspaceForDirectory(directory: string): Promise<ResolvedWorksp
141
176
  * gets that subproject's rootUri instead of the whole repo's.
142
177
  */
143
178
  export function workspaceForCodeIntelligencePath(absolutePath: string): Promise<ResolvedWorkspace> {
144
- const directory = dirname(absolutePath);
145
- const descriptor = descriptorForExtension(extname(absolutePath));
146
- const root = descriptor ? (nearestProjectRoot(directory, descriptor.rootMarkers) ?? directory) : (nearestGitRoot(directory) ?? directory);
147
- return workspaceForRoot(root);
179
+ return resolveWorkspace({
180
+ strategy: "language-project-root",
181
+ path: dirname(absolutePath),
182
+ fallback: "given-directory",
183
+ extension: extname(absolutePath),
184
+ });
148
185
  }
149
186
 
150
- /** Every known language's own rootMarkers, deduplicated -- see workspaceForProjectDirectory. */
151
- const ALL_PROJECT_ROOT_MARKERS: readonly string[] = [...new Set(LANGUAGE_SERVER_DESCRIPTORS.flatMap((descriptor) => descriptor.rootMarkers))];
152
-
153
187
  /**
154
188
  * Resolves a caller-supplied directory to its OWN nearest project root -- never the outer repo's
155
189
  * git root -- so distinct sibling packages under one monorepo stay distinct workspaces. Unlike
@@ -162,13 +196,11 @@ const ALL_PROJECT_ROOT_MARKERS: readonly string[] = [...new Set(LANGUAGE_SERVER_
162
196
  *
163
197
  * Unlike workspaceForCodeIntelligencePath, there is no single file (and therefore no known
164
198
  * extension) to pick one specific language's markers from -- a caller-supplied directory could
165
- * be any language, so this checks the union of every known language's rootMarkers. Falls back to
166
- * the nearest git root, then the directory itself, exactly as nearestProjectRoot already does
167
- * internally (it appends ".git" to whatever marker list it's given).
199
+ * be any language, so the daemon checks the union of every known language's rootMarkers when no
200
+ * extension is given.
168
201
  */
169
202
  export function workspaceForProjectDirectory(directory: string): Promise<ResolvedWorkspace> {
170
- const root = nearestProjectRoot(directory, ALL_PROJECT_ROOT_MARKERS) ?? directory;
171
- return workspaceForRoot(root);
203
+ return resolveWorkspace({ strategy: "language-project-root", path: directory, fallback: "given-directory" });
172
204
  }
173
205
 
174
206
  /**
@@ -180,19 +212,35 @@ export function workspaceForProjectDirectory(directory: string): Promise<Resolve
180
212
  * dirname() strips its final segment, silently resolving to the *parent*
181
213
  * directory's own nearest git root instead -- for a project nested one level
182
214
  * under a broader already-registered workspace, this mixes in every sibling
183
- * project's own graph, with no error at all. Checks whether the path is
184
- * itself a real, existing directory first; only takes dirname() when it is
185
- * not (a file, or a not-yet-existing path).
215
+ * project's own graph, with no error at all. The daemon checks whether the
216
+ * path is itself a real, existing directory first; only takes dirname() when
217
+ * it is not (a file, or a not-yet-existing path).
186
218
  */
187
219
  export function workspaceForPathOrDirectory(path: string): Promise<ResolvedWorkspace> {
188
- const isRealDirectory = existsSync(path) && statSync(path).isDirectory();
189
- return workspaceForDirectory(isRealDirectory ? path : dirname(path));
220
+ return resolveWorkspace({ strategy: "path-or-directory", path });
221
+ }
222
+
223
+ /**
224
+ * The nearest ancestor of an already-resolved project root whose own package.json declares that
225
+ * project as a workspace member via npm/yarn/bun's "workspaces" field -- undefined (no
226
+ * directory-itself/filesystem-root fallback) when no such ancestor exists, a real and expected
227
+ * outcome for a plain single-package repo. Used only by reference-based-rename's own
228
+ * widen-and-retry: on ReferenceBasedRenameRequiresFreshGraph, retry once against the declared
229
+ * monorepo root instead of the narrower project the rename was first attempted against.
230
+ * Deliberately uncached (a rare retry path, not a hot loop).
231
+ */
232
+ export async function workspaceForDeclaredMonorepoRoot(projectRoot: string): Promise<ResolvedWorkspace | undefined> {
233
+ const client = await lectorClient();
234
+ const outcome = await client.callOnce("workspace.resolvePath", { strategy: "declared-monorepo-root", path: projectRoot });
235
+ if (!outcome.found) return undefined;
236
+ if (outcome.created) onNewWorkspace?.(outcome.root);
237
+ return { workspaceId: outcome.workspaceId, root: outcome.root };
190
238
  }
191
239
 
192
240
  /**
193
241
  * Resolves a workspace via `resolve`, then calls `perform` with it. A daemon
194
242
  * restart wipes its in-memory workspace registry (workspace ids are not
195
- * persisted across restarts by design), but this module's own workspaceId
243
+ * persisted across restarts by design), but this module's own resolution
196
244
  * cache does not know that on its own -- a call through a stale cached id
197
245
  * fails with UnknownWorkspace even though the underlying files on disk
198
246
  * never changed. On exactly that failure, the stale cache entry is dropped
@@ -208,20 +256,34 @@ export async function withWorkspace<T>(resolve: () => Promise<ResolvedWorkspace>
208
256
  return await perform(resolved);
209
257
  } catch (error) {
210
258
  if (attempt === 1 || !remoteErrorIs(error, "UnknownWorkspace")) throw error;
211
- workspaceIdByRoot.delete(resolved.root);
259
+ forgetWorkspaceId(resolved.root);
212
260
  }
213
261
  }
214
262
  throw new Error("Lector workspace resolution retry exhausted");
215
263
  }
216
264
 
265
+ /**
266
+ * Drops every cache entry resolved to this root without retrying anything itself -- the batch
267
+ * sibling of withWorkspace's own single-workspace recovery, for a caller (cross-workspace
268
+ * search's fan-out) that resolves many roots at once and needs to evict only the specific ones a
269
+ * daemon restart actually invalidated, not the whole cache. A root can appear under more than one
270
+ * cache key (workspaceForPath and workspaceForDirectory can each independently resolve to the
271
+ * same root for related paths), so this scans by value, not a single key lookup.
272
+ */
273
+ export function forgetWorkspaceId(root: string): void {
274
+ for (const [key, resolved] of resolutionCache) {
275
+ if (resolved.root === root) resolutionCache.delete(key);
276
+ }
277
+ }
278
+
217
279
  export function setLectorClientConnectorForTests(value: ClientConnector): void {
218
280
  retryingClient.reset();
219
- workspaceIdByRoot.clear();
281
+ resolutionCache.clear();
220
282
  connector = value;
221
283
  }
222
284
 
223
285
  export function resetLectorClientForTests(): void {
224
286
  retryingClient.reset();
225
- workspaceIdByRoot.clear();
287
+ resolutionCache.clear();
226
288
  connector = () => connectLectorClient();
227
289
  }
@@ -1,6 +1,5 @@
1
1
  import { type OperationOutputs, remoteErrorIs } from "@danypops/lector";
2
- import { lectorClient, type ResolvedWorkspace, withWorkspace, workspaceForCodeIntelligencePath, workspaceForProjectDirectory } from "../lector-client.ts";
3
- import { nearestDeclaredWorkspaceRoot } from "../nearest-workspace-root.ts";
2
+ import { lectorClient, type ResolvedWorkspace, withWorkspace, workspaceForCodeIntelligencePath, workspaceForDeclaredMonorepoRoot } from "../lector-client.ts";
4
3
 
5
4
  /**
6
5
  * Thin wrapper over Lector's non-LSP reference-based rename: moves a file and rewrites every
@@ -31,10 +30,10 @@ export function createReferenceBasedRenameOperations(): ReferenceBasedRenameOper
31
30
  return await withWorkspace(() => workspaceForCodeIntelligencePath(fromPath), performRename);
32
31
  } catch (error) {
33
32
  if (!remoteErrorIs(error, "ReferenceBasedRenameRequiresFreshGraph")) throw error;
34
- const { root: narrowRoot } = await workspaceForCodeIntelligencePath(fromPath);
35
- const declaredRoot = nearestDeclaredWorkspaceRoot(narrowRoot);
36
- if (!declaredRoot) throw error;
37
- return withWorkspace(() => workspaceForProjectDirectory(declaredRoot), performRename);
33
+ const narrow = await workspaceForCodeIntelligencePath(fromPath);
34
+ const declared = await workspaceForDeclaredMonorepoRoot(narrow.root);
35
+ if (!declared) throw error;
36
+ return withWorkspace(() => Promise.resolve(declared), performRename);
38
37
  }
39
38
  },
40
39
  };
@@ -1,4 +1,11 @@
1
- import { type JobSnapshot, type PopulateSymbolGraphResult, resolveLectorDaemonConnection, type WorkspaceCacheStatus } from "@danypops/lector";
1
+ import {
2
+ type CacheResultCounts,
3
+ type JobSnapshot,
4
+ type PopulateSymbolGraphResult,
5
+ remoteErrorIs,
6
+ resolveLectorDaemonConnection,
7
+ type WorkspaceCacheStatus,
8
+ } from "@danypops/lector";
2
9
  import { connectPushChannel } from "@danypops/vehicle-client/daemon-client";
3
10
  import { lectorClient, withWorkspace, workspaceForProjectDirectory } from "../lector-client.ts";
4
11
 
@@ -79,7 +86,7 @@ export type CachePresentationState =
79
86
  | { readonly status: "not-cached"; readonly reason: string }
80
87
  | { readonly status: "caching"; readonly jobId: string }
81
88
  | { readonly status: "finished-caching"; readonly job: JobSnapshot<PopulateSymbolGraphResult> & { readonly status: "succeeded" } }
82
- | { readonly status: "partial"; readonly result: PopulateSymbolGraphResult }
89
+ | { readonly status: "partial"; readonly result: CacheResultCounts }
83
90
  | { readonly status: "cached" };
84
91
 
85
92
  export function describeCacheState(state: CachePresentationState): string {
@@ -117,12 +124,27 @@ export interface WaitForJobCompletionOptions {
117
124
  readonly sleep?: (ms: number) => Promise<void>;
118
125
  }
119
126
 
127
+ /**
128
+ * Exhaustive outcome of waiting on one job -- every path monitorWorkspaceCache must reconcile
129
+ * against authoritative workspace.cacheStatus, distinct from a genuine terminal result:
130
+ * "job-not-found" (the id expired, was evicted, or survives from a since-restarted daemon),
131
+ * "timed-out" (the bounded poll budget was spent and the job is still non-terminal), and
132
+ * "transport-failed" (the daemon couldn't be reached at all) all mean "we no longer know this
133
+ * job's real state" -- never treated as if the job were still "caching" forever.
134
+ */
135
+ export type JobCompletionOutcome =
136
+ | { readonly kind: "terminal"; readonly job: JobSnapshot<PopulateSymbolGraphResult> & { readonly status: "succeeded" | "failed" } }
137
+ | { readonly kind: "timed-out" }
138
+ | { readonly kind: "canceled" }
139
+ | { readonly kind: "job-not-found" }
140
+ | { readonly kind: "transport-failed"; readonly error: unknown };
141
+
120
142
  /** Waits on Vehicle push delivery and checks status on a bounded cadence when push is unavailable or disconnected. */
121
143
  export async function waitForJobCompletion(
122
144
  operations: WorkspaceCacheOperations,
123
145
  jobId: string,
124
146
  options: WaitForJobCompletionOptions,
125
- ): Promise<JobSnapshot<PopulateSymbolGraphResult> | undefined> {
147
+ ): Promise<JobCompletionOutcome> {
126
148
  const sleep = options.sleep ?? ((ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms)));
127
149
  let pushedResolve: ((job: JobSnapshot<PopulateSymbolGraphResult>) => void) | undefined;
128
150
  const pushed = new Promise<JobSnapshot<PopulateSymbolGraphResult>>((resolve) => {
@@ -135,17 +157,29 @@ export async function waitForJobCompletion(
135
157
  });
136
158
  const onAbort = () => resolveAbort();
137
159
  options.signal?.addEventListener("abort", onAbort, { once: true });
160
+
161
+ async function checkStatus(): Promise<JobCompletionOutcome | undefined> {
162
+ try {
163
+ const current = await operations.jobStatus(jobId);
164
+ if (current.status === "succeeded" || current.status === "failed") return { kind: "terminal", job: current };
165
+ return undefined;
166
+ } catch (error) {
167
+ if (remoteErrorIs(error, "JobNotFound")) return { kind: "job-not-found" };
168
+ return { kind: "transport-failed", error };
169
+ }
170
+ }
171
+
138
172
  try {
139
- const outcome = await operations.watchJob?.(jobId, (job) => pushedResolve?.(job));
140
- if (outcome?.status === "subscribed") watch = outcome.handle;
173
+ const subscription = await operations.watchJob?.(jobId, (job) => pushedResolve?.(job));
174
+ if (subscription?.status === "subscribed") watch = subscription.handle;
141
175
  for (let poll = 0; poll < options.maxPolls && options.shouldContinue() && !options.signal?.aborted; poll++) {
142
- const current = await operations.jobStatus(jobId);
143
- if (current.status === "succeeded" || current.status === "failed") return current;
176
+ const checked = await checkStatus();
177
+ if (checked) return checked;
144
178
  const next = await Promise.race([pushed, sleep(options.pollIntervalMs).then(() => undefined), aborted.then(() => undefined)]);
145
- if (next?.status === "succeeded" || next?.status === "failed") return next;
179
+ if (next?.status === "succeeded" || next?.status === "failed") return { kind: "terminal", job: next };
146
180
  }
147
- if (options.shouldContinue() && !options.signal?.aborted) return operations.jobStatus(jobId);
148
- return undefined;
181
+ if (!options.shouldContinue() || options.signal?.aborted) return { kind: "canceled" };
182
+ return (await checkStatus()) ?? { kind: "timed-out" };
149
183
  } finally {
150
184
  options.signal?.removeEventListener("abort", onAbort);
151
185
  watch?.close();
@@ -186,12 +220,31 @@ export async function monitorWorkspaceCache(operations: WorkspaceCacheOperations
186
220
  }
187
221
  options.onState({ status: "caching", jobId });
188
222
 
189
- const job = await waitForJobCompletion(operations, jobId, {
223
+ const outcome = await waitForJobCompletion(operations, jobId, {
190
224
  pollIntervalMs: options.pollIntervalMs,
191
225
  maxPolls: options.maxPolls,
192
226
  shouldContinue: options.shouldContinue,
193
227
  sleep,
194
228
  });
195
- if (job?.status === "failed") throw new Error(`${job.error.code}: ${job.error.message}`);
196
- if (job?.status === "succeeded") reportCompleted(job);
229
+ if (outcome.kind === "canceled") return;
230
+ if (outcome.kind === "terminal") {
231
+ if (outcome.job.status === "failed") throw new Error(`${outcome.job.error.code}: ${outcome.job.error.message}`);
232
+ reportCompleted(outcome.job);
233
+ return;
234
+ }
235
+ // job-not-found, timed-out, and transport-failed all mean the same thing here: this specific
236
+ // watch no longer knows the job's real state. Never leave the last-reported "caching" message
237
+ // standing -- ask the daemon's own authoritative record what is actually true right now.
238
+ const reconciled = await operations.status(options.directory, options.maxFiles, options.maxSymbolsPerFile);
239
+ if (reconciled.status === "cached") {
240
+ options.onState({ status: "cached" });
241
+ } else if (reconciled.status === "partial") {
242
+ options.onState({ status: "partial", result: reconciled.generation.result });
243
+ } else if (reconciled.status === "not-cached") {
244
+ options.onState({ status: "not-cached", reason: reconciled.reason });
245
+ } else {
246
+ // Still genuinely caching or queued behind resource admission under a fresh check --
247
+ // an accurate report, not a stale one, even though the presented status string repeats.
248
+ options.onState({ status: "caching", jobId: reconciled.jobId });
249
+ }
197
250
  }
@@ -1,4 +1,4 @@
1
- import type { JobSnapshot, PopulateSymbolGraphResult, WorkspaceCacheStatus } from "@danypops/lector";
1
+ import type { CacheResultCounts, JobSnapshot, PopulateSymbolGraphResult, WorkspaceCacheStatus } from "@danypops/lector";
2
2
  import type { LectorTheme } from "../lector-tui-theme.ts";
3
3
 
4
4
  type WorkspaceCacheAction = "status" | "populate" | "wait" | "job_status";
@@ -23,7 +23,7 @@ export function formatWorkspaceCacheCall(
23
23
  return `${label} ${theme.fg("accent", action)} ${theme.fg("dim", directory)}${bounds}`;
24
24
  }
25
25
 
26
- function formatResultCounts(result: PopulateSymbolGraphResult): string {
26
+ function formatResultCounts(result: CacheResultCounts): string {
27
27
  const failed = result.filesFailed > 0 ? `, ${result.filesFailed} failed` : "";
28
28
  return `${result.filesProcessed}/${result.filesAttempted} files${failed}, ${result.symbolsProcessed} symbols, ${result.nodesAdded} nodes, ${result.edgesAdded} edges`;
29
29
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@danypops/pi-lector",
3
- "version": "0.12.4",
3
+ "version": "0.12.6",
4
4
  "description": "Pi host adapter for Lector: overrides read/write/edit with a daemon-backed, hash-guarded filesystem",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -1,130 +0,0 @@
1
- import { existsSync, readFileSync } from "node:fs";
2
- import { dirname, join, parse, relative } from "node:path";
3
- import picomatch from "picomatch";
4
-
5
- /**
6
- * The bare filesystem root is never a legitimate discovered project root, even if it happens
7
- * to contain a marker file (a stray `git init /`, a leftover `package.json`) -- matches the
8
- * same convention already established elsewhere in this house (oculus/survey/rust_scanner.go's
9
- * findCrateRoot, oculus/locator/match.go's effectiveParent: reaching "/" during a walk-up means
10
- * "not found", never "found here"). Confirmed live: without this, a Lector daemon registered
11
- * "/" as a workspace and a background job attempted to symbol-graph the entire filesystem.
12
- * `exists` is injectable so a test can simulate "a marker exists at the filesystem root"
13
- * without ever touching the real one.
14
- */
15
- function walkUpForMarkers(startDirectory: string, markers: readonly string[], exists: (path: string) => boolean = existsSync): string | undefined {
16
- let dir = startDirectory;
17
- const fsRoot = parse(dir).root;
18
- while (dir !== fsRoot) {
19
- if (markers.some((marker) => exists(join(dir, marker)))) return dir;
20
- const parent = dirname(dir);
21
- if (parent === dir) break; // defensive: dirname must be strictly ascending
22
- dir = parent;
23
- }
24
- return undefined;
25
- }
26
-
27
- /**
28
- * The nearest enclosing git repository root starting from (and including) a given directory,
29
- * or undefined if none is found (e.g. /tmp scratch files, dotfiles outside any repo, or the
30
- * walk reaching the filesystem root without a match). Callers choose their own fallback -- see
31
- * lector-client.ts's workspaceForPath (falls back to the filesystem root: any absolute path is
32
- * fair game for read/write/edit, exactly as Pi's built-in tools already allow) vs.
33
- * workspaceForDirectory (falls back to the directory itself: widening a symbol-search scope all
34
- * the way to the entire filesystem when a project isn't a git repo would be absurd).
35
- *
36
- * This -- not a Pi session's original cwd -- is Lector's real workspace granularity. A session
37
- * routinely touches many unrelated repos, sibling projects, and scratch paths in one run; Pi's
38
- * built-in read/write/edit tools have never restricted which absolute path can be touched, and
39
- * Lector must not either. (Real, shipped bug this fixes: read/write/edit hard-locked to
40
- * whatever directory the session happened to start in, refusing every legitimate path outside
41
- * it with a "Lector-registered workspace root" error -- discovered live, in a separate session,
42
- * working against a completely different, unrelated repository.)
43
- *
44
- * `exists` is injectable for tests -- see walkUpForMarkers.
45
- */
46
- /**
47
- * True for the bare filesystem root itself ("/" on Linux/macOS, "C:\\" on Windows) -- the one
48
- * path a caller must never treat as a real project to auto-index. workspaceForPath's own
49
- * intentional fallback for a raw read/write of a file outside any git repo can still produce
50
- * this value; callers that trigger background work (auto-population, cache monitoring) off a
51
- * newly-registered workspace must check this explicitly rather than assuming
52
- * nearestGitRoot/nearestProjectRoot are the only paths that can hand them a workspace root.
53
- */
54
- export function isFilesystemRoot(path: string): boolean {
55
- return parse(path).root === path;
56
- }
57
-
58
- export function nearestGitRoot(startDirectory: string, exists: (path: string) => boolean = existsSync): string | undefined {
59
- return walkUpForMarkers(startDirectory, [".git"], exists);
60
- }
61
-
62
- /**
63
- * Same nearest-enclosing-root walk as nearestGitRoot, but also checks a language's own root
64
- * markers (tsconfig.json, go.mod, Cargo.toml, ...) at each directory, nearest first -- so a
65
- * monorepo subproject with its own root marker resolves to itself, not the outer repo's .git.
66
- * Found via @arvoretech/pi-lsp comparison: without this, a file inside a monorepo subproject
67
- * misattributes its whole project to the repo root, handing the language server the wrong
68
- * rootUri (and, for TypeScript, the wrong tsconfig.json) even though a closer one exists.
69
- */
70
- export function nearestProjectRoot(startDirectory: string, rootMarkers: readonly string[], exists: (path: string) => boolean = existsSync): string | undefined {
71
- return walkUpForMarkers(startDirectory, [...rootMarkers, ".git"], exists);
72
- }
73
-
74
- /** An npm/yarn/bun package.json's own "workspaces" field: either a bare glob array, or `{ packages: [...] }` (pnpm's own equivalent shape for the same field, embedded inside package.json rather than a separate pnpm-workspace.yaml). */
75
- interface WorkspacesManifest {
76
- workspaces?: string[] | { packages?: string[] };
77
- }
78
-
79
- function readWorkspaceGlobs(packageJsonPath: string, readFile: (path: string) => string): string[] | undefined {
80
- let parsed: unknown;
81
- try {
82
- parsed = JSON.parse(readFile(packageJsonPath));
83
- } catch {
84
- return undefined;
85
- }
86
- if (typeof parsed !== "object" || parsed === null) return undefined;
87
- const { workspaces } = parsed as WorkspacesManifest;
88
- const globs = Array.isArray(workspaces) ? workspaces : (workspaces?.packages ?? undefined);
89
- return Array.isArray(globs) ? globs.filter((entry): entry is string => typeof entry === "string") : undefined;
90
- }
91
-
92
- /**
93
- * The nearest ancestor of a real project root (as found by nearestProjectRoot) whose own
94
- * package.json declares that project as a workspace member via npm/yarn/bun's "workspaces"
95
- * field -- never an arbitrary ancestor that merely happens to have its own marker file. Walks
96
- * upward past ancestors with no "workspaces" field (or one that doesn't actually match this
97
- * project's relative path) rather than stopping at the first package.json found, since an
98
- * intermediate directory can be a plain package with no workspaces declaration of its own.
99
- *
100
- * Mirrors how mature language tooling handles this same monorepo shape: TypeScript's tsserver
101
- * only widens a file's project scope to an ancestor "solution" tsconfig that explicitly lists
102
- * the nearer project in its own `references`, and rust-analyzer treats Cargo's `[workspace]`
103
- * `members` list as the authoritative multi-crate boundary rather than inferring one from
104
- * directory structure. This is the same idea applied to npm/yarn/bun's own declared
105
- * "workspaces" glob instead of a language-specific manifest.
106
- *
107
- * Returns undefined (no declared ancestor) for a plain single-package repo, or when no ancestor's
108
- * "workspaces" globs actually match this project -- callers must not treat an arbitrary git root
109
- * as an implicit stand-in.
110
- */
111
- export function nearestDeclaredWorkspaceRoot(
112
- projectRoot: string,
113
- exists: (path: string) => boolean = existsSync,
114
- readFile: (path: string) => string = (path) => readFileSync(path, "utf8"),
115
- ): string | undefined {
116
- let dir = dirname(projectRoot);
117
- const fsRoot = parse(dir).root;
118
- while (dir !== fsRoot) {
119
- const packageJsonPath = join(dir, "package.json");
120
- if (exists(packageJsonPath)) {
121
- const globs = readWorkspaceGlobs(packageJsonPath, readFile);
122
- const relativePath = relative(dir, projectRoot);
123
- if (globs?.some((glob) => picomatch(glob)(relativePath))) return dir;
124
- }
125
- const parent = dirname(dir);
126
- if (parent === dir) break; // defensive: dirname must be strictly ascending
127
- dir = parent;
128
- }
129
- return undefined;
130
- }