@danypops/pi-lector 0.12.5 → 0.12.7

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.
@@ -0,0 +1,46 @@
1
+ import type { RetryingClientDiagnosticEvent } from "@danypops/vehicle-client/daemon-client";
2
+
3
+ /**
4
+ * Formats one createRetryingClient diagnostic event as a single, compact line -- the same real
5
+ * RCA gap @danypops/vehicle-client's own onEvent hook was added to close: a scrubbed "connector
6
+ * unavailable" error at the tool-call boundary otherwise gives no way to tell, after the fact,
7
+ * whether it was a genuine fresh connect() failure, a circuit-breaker short-circuit (no connect
8
+ * attempted at all), or an in-flight operation's stale-connection retry. Never includes a stack
9
+ * trace -- name and message only, matching this house's other client-diagnostic channels
10
+ * (@danypops/vehicle-client-pi's own client-diagnostics.ts).
11
+ */
12
+ /** Never risks Object's default `[object Object]` stringification for a non-Error, non-string throw. */
13
+ function describeError(error: unknown): { name: string; message: string } {
14
+ if (error instanceof Error) return { name: error.name, message: error.message };
15
+ if (typeof error === "string") return { name: "string", message: error };
16
+ if (typeof error === "number" || typeof error === "boolean" || typeof error === "bigint") return { name: typeof error, message: String(error) };
17
+ return { name: typeof error, message: "(unprintable value)" };
18
+ }
19
+
20
+ export function formatClientDiagnosticEvent(event: RetryingClientDiagnosticEvent): string {
21
+ const parts = [`[lector-client] ${event.type}`];
22
+ if (event.attempt !== undefined) parts.push(`attempt=${event.attempt}`);
23
+ if (event.consecutiveFailures !== undefined) parts.push(`consecutiveFailures=${event.consecutiveFailures}`);
24
+ if (event.operationId !== undefined) parts.push(`operationId=${event.operationId}`);
25
+ if (event.error !== undefined) {
26
+ const { name, message } = describeError(event.error);
27
+ parts.push(`error=${name}: ${message}`);
28
+ }
29
+ return parts.join(" ");
30
+ }
31
+
32
+ /**
33
+ * Logs a createRetryingClient diagnostic event to stderr, only when LECTOR_CLIENT_DIAG is set --
34
+ * zero cost and zero output for every session that never opts in, matching the env-gated
35
+ * convention @danypops/vehicle-client-pi's own VEHICLE_CLIENT_DIAG already established in this
36
+ * house. Never throws: onEvent's own contract requires it stay side-effect-light, and a broken
37
+ * local console (or a formatting bug) must not take down a real client call.
38
+ */
39
+ export function logClientDiagnosticEvent(event: RetryingClientDiagnosticEvent): void {
40
+ if (!process.env.LECTOR_CLIENT_DIAG) return;
41
+ try {
42
+ console.error(formatClientDiagnosticEvent(event));
43
+ } catch {
44
+ // best-effort only -- see doc comment above.
45
+ }
46
+ }
@@ -10,7 +10,13 @@ import { forgetWorkspaceId, lectorClient, type ResolvedWorkspace, workspaceForPr
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[],
@@ -126,11 +132,11 @@ async function withCrossWorkspaceRestartRecovery<T>(
126
132
 
127
133
  export function createLectorCrossWorkspaceSearchOperations(): CrossWorkspaceSearchOperations {
128
134
  return {
129
- findSymbols(query, directories, timeoutMs) {
135
+ findSymbols(query, directories, timeoutMs, maxResults) {
130
136
  return withCrossWorkspaceRestartRecovery(directories, async (resolved) => {
131
137
  const workspaceIds = resolved.map((r) => r.workspaceId);
132
138
  const client = await lectorClient();
133
- const { results } = await client.call("search.symbols", { query, workspaceIds, timeoutMs });
139
+ const { results } = await client.call("search.symbols", { query, workspaceIds, timeoutMs, ...(maxResults !== undefined ? { maxResults } : {}) });
134
140
  return zipOutcomes(directories, workspaceIds, results);
135
141
  });
136
142
  },
@@ -1,3 +1,5 @@
1
+ import { readdirSync } from "node:fs";
2
+ import { homedir } from "node:os";
1
3
  import { resolve } from "node:path";
2
4
  import type {
3
5
  CachedRepositoryPage,
@@ -44,6 +46,7 @@ import { Type } from "typebox";
44
46
  /** Real ANSI-aware measurement for Table -- Malevich's own default is ASCII-only, unsafe against theme-styled cell/header text. */
45
47
  const tableMeasure: TextMeasure = { visibleWidth, truncateToWidth };
46
48
 
49
+ import { classifyAutoPopulationRoot, isFilesystemRoot } from "@danypops/lector";
47
50
  import { createLectorApplyPatchOperations } from "./apply-patch/operations.ts";
48
51
  import { formatApplyPatchCall, formatApplyPatchResult } from "./apply-patch/rendering.ts";
49
52
  import { createLectorCodeIntelligenceOperations } from "./code-intelligence/operations.ts";
@@ -89,11 +92,10 @@ import { createLectorFindSymbolsOperations } from "./find-symbols/operations.ts"
89
92
  import { describeFindSymbolSources, formatFindSymbolsCall, formatFindSymbolsResult } from "./find-symbols/rendering.ts";
90
93
  import { createLectorGitOperations } from "./git/operations.ts";
91
94
  import { formatGitCall, formatGitResult, type GitToolDetails } from "./git/rendering.ts";
92
- import { setNewWorkspaceObserver } from "./lector-client.ts";
95
+ import { nearestGitWorkspaceRoot, setNewWorkspaceObserver } from "./lector-client.ts";
93
96
  import { createLectorLineEditOperations } from "./line-edit/operations.ts";
94
97
  import { formatLineEditCall, formatLineEditResult } from "./line-edit/rendering.ts";
95
98
  import { createMutationHistoryOperations } from "./mutation-history/operations.ts";
96
- import { isFilesystemRoot, nearestGitRoot } from "./nearest-workspace-root.ts";
97
99
  import { createLectorPackageSourceOperations, type PackageSourceListPage } from "./package-source/operations.ts";
98
100
  import {
99
101
  buildPackageSourceListTableRows,
@@ -214,10 +216,23 @@ export default function (pi: ExtensionAPI) {
214
216
  * intentional fallback for a raw read/write of a file outside any git repo can register
215
217
  * exactly this as a "new workspace", and auto-populating it would attempt a full
216
218
  * filesystem-wide symbol-graph scan -- confirmed live as a real, previously-shipped bug.
219
+ *
220
+ * Also short-circuits a broad host directory (home directory, an XDG config/cache/data
221
+ * root, a dotfile directory) the exact same way workspace.populateSymbolGraph's own
222
+ * server-side gate would refuse it -- avoids the round trip (and the queue-behind-real-
223
+ * projects ergonomics this caused live for ~/.pi/agent) entirely, using the identical
224
+ * classification Lector itself uses so the two never drift. A readdir failure (permission,
225
+ * race) is treated as "can't tell, don't block" -- the server-side gate is still authoritative.
217
226
  */
218
227
  function startMonitoringRoot(root: string, ctx: Parameters<Parameters<ExtensionAPI["on"]>[1]>[1]): void {
219
228
  if (isFilesystemRoot(root)) return;
220
229
  if (monitoringRoots.has(root)) return;
230
+ try {
231
+ const topLevelEntries = readdirSync(root);
232
+ if (classifyAutoPopulationRoot({ rootPath: root, homeDir: homedir(), topLevelEntries }) === "broad-non-project") return;
233
+ } catch {
234
+ // Can't read it here -- let the server-side gate be the authoritative answer.
235
+ }
221
236
  monitoringRoots.add(root);
222
237
  const thisGeneration = sessionGeneration;
223
238
  void monitorWorkspaceCache(cacheOperations, {
@@ -279,9 +294,17 @@ export default function (pi: ExtensionAPI) {
279
294
  lastInjectedSummary = undefined;
280
295
  uiContext = ctx;
281
296
  setNewWorkspaceObserver((root) => startMonitoringRoot(root, ctx));
282
- const projectRoot = nearestGitRoot(cwd);
283
- if (projectRoot) startMonitoringRoot(projectRoot, ctx);
284
- else ctx.ui.setStatus("lector-cache", undefined);
297
+ const thisGeneration = sessionGeneration;
298
+ void nearestGitWorkspaceRoot(cwd)
299
+ .then((projectRoot) => {
300
+ if (sessionGeneration !== thisGeneration) return;
301
+ if (projectRoot) startMonitoringRoot(projectRoot, ctx);
302
+ else ctx.ui.setStatus("lector-cache", undefined);
303
+ })
304
+ .catch(() => {
305
+ // A daemon that isn't running yet is not a session_start failure -- the first real
306
+ // tool call surfaces that clearly instead.
307
+ });
285
308
 
286
309
  pi.registerTool(createReadToolDefinition(cwd, { operations: createLectorReadOperations() }));
287
310
  pi.registerTool(createWriteToolDefinition(cwd, { operations: createLectorWriteOperations() }));
@@ -1155,14 +1178,20 @@ export default function (pi: ExtensionAPI) {
1155
1178
  if (!Number.isSafeInteger(waitMs) || waitMs < 1 || waitMs > 300_000)
1156
1179
  throw new Error("workspace_cache action=wait waitMs must be an integer from 1 to 300000");
1157
1180
  const pollIntervalMs = 5_000;
1158
- const completed = await waitForJobCompletion(cacheOperations, params.jobId, {
1181
+ const outcome = await waitForJobCompletion(cacheOperations, params.jobId, {
1159
1182
  pollIntervalMs,
1160
1183
  maxPolls: Math.ceil(waitMs / pollIntervalMs),
1161
1184
  shouldContinue: () => !signal?.aborted,
1162
1185
  signal,
1163
1186
  });
1164
1187
  if (signal?.aborted) throw new DOMException("workspace cache wait canceled", "AbortError");
1165
- const job = completed ?? (await cacheOperations.jobStatus(params.jobId));
1188
+ if (outcome.kind === "transport-failed") throw outcome.error instanceof Error ? outcome.error : new Error(String(outcome.error));
1189
+ if (outcome.kind === "job-not-found") {
1190
+ throw new Error(
1191
+ `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`,
1192
+ );
1193
+ }
1194
+ const job = outcome.kind === "terminal" ? outcome.job : await cacheOperations.jobStatus(params.jobId);
1166
1195
  return { content: [{ type: "text", text: JSON.stringify(job) }], details: { action: "wait", job } };
1167
1196
  }
1168
1197
  if (!params.directory) throw new Error(`workspace_cache action=${params.action} requires directory`);
@@ -1810,10 +1839,13 @@ export default function (pi: ExtensionAPI) {
1810
1839
  directories: Type.Array(Type.String(), { description: "Project directories to search, each absolute or relative to the current working directory" }),
1811
1840
  query: Type.String({ description: "Symbol name (or substring) to search for" }),
1812
1841
  timeoutMs: Type.Optional(Type.Number({ description: "How long to wait per project before reporting it as still-loading; defaults to 3000" })),
1842
+ maxResults: Type.Optional(
1843
+ Type.Number({ description: "Maximum matches to return per project (not total across every project); defaults to a conservative per-project bound" }),
1844
+ ),
1813
1845
  }),
1814
1846
  async execute(_toolCallId, params) {
1815
1847
  const directories = params.directories.map((directory) => resolve(cwd, directory));
1816
- const results = await crossWorkspaceSearchOperations.findSymbols(params.query, directories, params.timeoutMs);
1848
+ const results = await crossWorkspaceSearchOperations.findSymbols(params.query, directories, params.timeoutMs, params.maxResults);
1817
1849
  return { content: [{ type: "text", text: JSON.stringify(results) }], details: { results } };
1818
1850
  },
1819
1851
  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,32 @@ 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";
14
+ import { logClientDiagnosticEvent } from "./client-diagnostics.ts";
17
15
 
18
16
  /**
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.
17
+ * Lazily connects to a running Lector daemon and caches, per resolution request, the
18
+ * workspace it resolves to. Never auto-spawns the daemon: a clear "start it with `lector serve`"
19
+ * error is preferable to guessing at a lifecycle the user didn't ask for. A failed connection
20
+ * attempt is not cached, so the very next tool call retries once the daemon is actually running.
25
21
  *
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().
22
+ * The daemon binds a new random port on every restart. A client resolved once and cached for
23
+ * the rest of the session would otherwise point at a dead port after any later restart --
24
+ * daemon-kit's createRetryingClient detects that on the failing call itself (not just the first
25
+ * connection attempt) and retries once against a freshly re-resolved client, the same policy
26
+ * this file used to hand-roll and now shares with web-spider's callWebSpider(), papyrus's
27
+ * callService(), and pi-packed's createNatives().
28
+ *
29
+ * Workspace-root resolution itself (which file belongs to which project) is Lector's own
30
+ * server-side concern -- see @danypops/lector's workspace.resolvePath and its own
31
+ * resolveWorkspacePath. This module used to reimplement that same filesystem walk-up locally
32
+ * (nearestGitRoot/nearestProjectRoot/nearestDeclaredWorkspaceRoot); two real, previously-shipped
33
+ * bugs (a project's own root directory silently resolving to its parent, and two sibling
34
+ * monorepo packages in this very repo collapsing onto the same workspaceId) traced directly to
35
+ * that logic living in the wrong process. Every workspaceForXxx below is now a thin RPC wrapper
36
+ * over workspace.resolvePath.
33
37
  */
34
38
 
35
39
  type ClientConnector = () => Promise<LectorClient>;
@@ -41,15 +45,19 @@ let connector: ClientConnector = () => connectLectorClient();
41
45
  const retryingClient: RetryingClient<LectorClient> = createRetryingClient(() => connector(), {
42
46
  label: "Lector",
43
47
  isStaleConnectionError: (error) => error instanceof LectorDaemonUnavailable || isLikelyStaleConnectionError(error),
48
+ // Purely observational, opt-in via LECTOR_CLIENT_DIAG -- see client-diagnostics.ts's own doc
49
+ // comment for the exact RCA gap this closes.
50
+ onEvent: logClientDiagnosticEvent,
44
51
  });
45
- const workspaceIdByRoot = new Map<string, WorkspaceId>();
46
52
 
47
53
  /**
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
54
+ * Fires exactly once per distinct root, the moment the daemon itself first registers it --
55
+ * never on a later call that resolves an already-registered root. The single choke point every
50
56
  * resolver (workspaceForPath, workspaceForDirectory, workspaceForCodeIntelligencePath,
51
57
  * 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.
58
+ * call resolves this workspace," not just the one cwd workspace at session start. Driven by
59
+ * workspace.resolvePath's own authoritative `created` flag, not a local cache -- correct even
60
+ * when a different process registered the same root moments earlier.
53
61
  */
54
62
  let onNewWorkspace: ((root: string) => void) | undefined;
55
63
 
@@ -80,18 +88,41 @@ export async function lectorClient(): Promise<RetryingLectorClient> {
80
88
 
81
89
  export interface ResolvedWorkspace {
82
90
  workspaceId: WorkspaceId;
83
- /** The registered root the target path is relative to -- a git root or the filesystem root, never a fixed session cwd. */
91
+ /** The root workspace.resolvePath actually registered -- a git root, a language project root, or the filesystem root, never a fixed session cwd. */
84
92
  root: string;
85
93
  }
86
94
 
87
- async function workspaceForRoot(root: string): Promise<ResolvedWorkspace> {
88
- const cached = workspaceIdByRoot.get(root);
89
- if (cached) return { workspaceId: cached, root };
95
+ /**
96
+ * Caches by the exact resolution request, not by the discovered root (this process no longer
97
+ * discovers roots itself) -- a repeated call with the identical path+strategy avoids a network
98
+ * round trip; two different files under the same repo each pay one round trip the first time,
99
+ * same as workspace.registerPath's own idempotent registration would cost anyway. A daemon
100
+ * restart wipes its in-memory registry; withWorkspace's own UnknownWorkspace retry (unchanged
101
+ * below) evicts exactly the stale entry this cache produced, the same recovery it always gave.
102
+ */
103
+ const resolutionCache = new Map<string, ResolvedWorkspace>();
104
+
105
+ function requestCacheKey(request: WorkspaceResolutionRequest): string {
106
+ return JSON.stringify(request, Object.keys(request).sort());
107
+ }
108
+
109
+ async function resolveWorkspace(request: WorkspaceResolutionRequest): Promise<ResolvedWorkspace> {
110
+ const key = requestCacheKey(request);
111
+ const cached = resolutionCache.get(key);
112
+ if (cached) return cached;
90
113
  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 };
114
+ const outcome = await client.callOnce("workspace.resolvePath", request);
115
+ if (!outcome.found) {
116
+ // Every strategy this function is used for (git-root/language-project-root with an
117
+ // explicit fallback, path-or-directory) always resolves to something server-side --
118
+ // declared-monorepo-root (the one strategy that can legitimately report not found) is
119
+ // never routed through this function, see resolveDeclaredMonorepoRoot below.
120
+ throw new Error(`workspace.resolvePath unexpectedly reported not-found for a fallback-guaranteed strategy: ${key}`);
121
+ }
122
+ const resolved: ResolvedWorkspace = { workspaceId: outcome.workspaceId, root: outcome.root };
123
+ resolutionCache.set(key, resolved);
124
+ if (outcome.created) onNewWorkspace?.(outcome.root);
125
+ return resolved;
95
126
  }
96
127
 
97
128
  /**
@@ -99,9 +130,8 @@ async function workspaceForRoot(root: string): Promise<ResolvedWorkspace> {
99
130
  * contains this absolute FILE path -- never a session's original cwd.
100
131
  * Files under the same repo share one cached workspace+id; a path under a
101
132
  * 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.
133
+ * on demand. This is what makes read/write/edit work for *any* absolute
134
+ * path in one session, exactly like Pi's built-in tools always have.
105
135
  *
106
136
  * Falls back to the filesystem root when no enclosing git repo exists:
107
137
  * unlike workspaceForDirectory, any absolute path is fair game here (a
@@ -110,9 +140,7 @@ async function workspaceForRoot(root: string): Promise<ResolvedWorkspace> {
110
140
  * built-in read/write/edit already allow.
111
141
  */
112
142
  export function workspaceForPath(absolutePath: string): Promise<ResolvedWorkspace> {
113
- const directory = dirname(absolutePath);
114
- const root = nearestGitRoot(directory) ?? parse(directory).root;
115
- return workspaceForRoot(root);
143
+ return resolveWorkspace({ strategy: "git-root", path: dirname(absolutePath), fallback: "filesystem-root" });
116
144
  }
117
145
 
118
146
  /**
@@ -124,8 +152,19 @@ export function workspaceForPath(absolutePath: string): Promise<ResolvedWorkspac
124
152
  * outside the project) and unbounded (scanning the whole disk).
125
153
  */
126
154
  export function workspaceForDirectory(directory: string): Promise<ResolvedWorkspace> {
127
- const root = nearestGitRoot(directory) ?? directory;
128
- return workspaceForRoot(root);
155
+ return resolveWorkspace({ strategy: "git-root", path: directory, fallback: "given-directory" });
156
+ }
157
+
158
+ /**
159
+ * Honest "does a real git repo exist here at all" -- unlike workspaceForDirectory, no fallback
160
+ * masks a non-project directory as its own root. Used by session_start to decide whether cwd
161
+ * looks like a real project worth auto-populating a cache for, never a bare scratch/home
162
+ * directory.
163
+ */
164
+ export async function nearestGitWorkspaceRoot(directory: string): Promise<string | undefined> {
165
+ const client = await lectorClient();
166
+ const outcome = await client.call("workspace.resolvePath", { strategy: "git-root", path: directory });
167
+ return outcome.found ? outcome.root : undefined;
129
168
  }
130
169
 
131
170
  /**
@@ -141,15 +180,14 @@ export function workspaceForDirectory(directory: string): Promise<ResolvedWorksp
141
180
  * gets that subproject's rootUri instead of the whole repo's.
142
181
  */
143
182
  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);
183
+ return resolveWorkspace({
184
+ strategy: "language-project-root",
185
+ path: dirname(absolutePath),
186
+ fallback: "given-directory",
187
+ extension: extname(absolutePath),
188
+ });
148
189
  }
149
190
 
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
191
  /**
154
192
  * Resolves a caller-supplied directory to its OWN nearest project root -- never the outer repo's
155
193
  * git root -- so distinct sibling packages under one monorepo stay distinct workspaces. Unlike
@@ -162,13 +200,11 @@ const ALL_PROJECT_ROOT_MARKERS: readonly string[] = [...new Set(LANGUAGE_SERVER_
162
200
  *
163
201
  * Unlike workspaceForCodeIntelligencePath, there is no single file (and therefore no known
164
202
  * 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).
203
+ * be any language, so the daemon checks the union of every known language's rootMarkers when no
204
+ * extension is given.
168
205
  */
169
206
  export function workspaceForProjectDirectory(directory: string): Promise<ResolvedWorkspace> {
170
- const root = nearestProjectRoot(directory, ALL_PROJECT_ROOT_MARKERS) ?? directory;
171
- return workspaceForRoot(root);
207
+ return resolveWorkspace({ strategy: "language-project-root", path: directory, fallback: "given-directory" });
172
208
  }
173
209
 
174
210
  /**
@@ -180,19 +216,67 @@ export function workspaceForProjectDirectory(directory: string): Promise<Resolve
180
216
  * dirname() strips its final segment, silently resolving to the *parent*
181
217
  * directory's own nearest git root instead -- for a project nested one level
182
218
  * 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).
219
+ * project's own graph, with no error at all. The daemon checks whether the
220
+ * path is itself a real, existing directory first; only takes dirname() when
221
+ * it is not (a file, or a not-yet-existing path).
186
222
  */
187
223
  export function workspaceForPathOrDirectory(path: string): Promise<ResolvedWorkspace> {
188
- const isRealDirectory = existsSync(path) && statSync(path).isDirectory();
189
- return workspaceForDirectory(isRealDirectory ? path : dirname(path));
224
+ return resolveWorkspace({ strategy: "path-or-directory", path });
225
+ }
226
+
227
+ /** Raised by workspaceForAnnotationPath for a path that does not exist on disk at all -- a distinct, explicit failure the caller must handle, never silently guessed as "must be a file, take its dirname()." */
228
+ export class AnnotationPathDoesNotExist extends Error {
229
+ constructor(readonly path: string) {
230
+ super(`"${path}" does not exist -- a symbol-annotation scope must be a real project directory or an existing source file`);
231
+ this.name = "AnnotationPathDoesNotExist";
232
+ }
233
+ }
234
+
235
+ /**
236
+ * For symbol-annotation's own `path` parameter, which genuinely means "which workspace does this
237
+ * belong to" and can honestly be either an existing project directory or one specific source
238
+ * file -- unlike workspaceForCodeIntelligencePath (files only, dirname() unconditional), a real
239
+ * project directory resolves via its own language markers, not its parent's. A genuinely
240
+ * nonexistent path throws AnnotationPathDoesNotExist rather than being silently treated as a file.
241
+ */
242
+ export async function workspaceForAnnotationPath(path: string): Promise<ResolvedWorkspace> {
243
+ const request: WorkspaceResolutionRequest = { strategy: "code-intelligence-path-or-directory", path };
244
+ const key = requestCacheKey(request);
245
+ const cached = resolutionCache.get(key);
246
+ if (cached) return cached;
247
+ const client = await lectorClient();
248
+ const outcome = await client.callOnce("workspace.resolvePath", request);
249
+ if (!outcome.found) {
250
+ if (outcome.reason === "nonexistent-path") throw new AnnotationPathDoesNotExist(path);
251
+ throw new Error(`workspace.resolvePath unexpectedly reported not-found for code-intelligence-path-or-directory: ${path}`);
252
+ }
253
+ const resolved: ResolvedWorkspace = { workspaceId: outcome.workspaceId, root: outcome.root };
254
+ resolutionCache.set(key, resolved);
255
+ if (outcome.created) onNewWorkspace?.(outcome.root);
256
+ return resolved;
257
+ }
258
+
259
+ /**
260
+ * The nearest ancestor of an already-resolved project root whose own package.json declares that
261
+ * project as a workspace member via npm/yarn/bun's "workspaces" field -- undefined (no
262
+ * directory-itself/filesystem-root fallback) when no such ancestor exists, a real and expected
263
+ * outcome for a plain single-package repo. Used only by reference-based-rename's own
264
+ * widen-and-retry: on ReferenceBasedRenameRequiresFreshGraph, retry once against the declared
265
+ * monorepo root instead of the narrower project the rename was first attempted against.
266
+ * Deliberately uncached (a rare retry path, not a hot loop).
267
+ */
268
+ export async function workspaceForDeclaredMonorepoRoot(projectRoot: string): Promise<ResolvedWorkspace | undefined> {
269
+ const client = await lectorClient();
270
+ const outcome = await client.callOnce("workspace.resolvePath", { strategy: "declared-monorepo-root", path: projectRoot });
271
+ if (!outcome.found) return undefined;
272
+ if (outcome.created) onNewWorkspace?.(outcome.root);
273
+ return { workspaceId: outcome.workspaceId, root: outcome.root };
190
274
  }
191
275
 
192
276
  /**
193
277
  * Resolves a workspace via `resolve`, then calls `perform` with it. A daemon
194
278
  * restart wipes its in-memory workspace registry (workspace ids are not
195
- * persisted across restarts by design), but this module's own workspaceId
279
+ * persisted across restarts by design), but this module's own resolution
196
280
  * cache does not know that on its own -- a call through a stale cached id
197
281
  * fails with UnknownWorkspace even though the underlying files on disk
198
282
  * never changed. On exactly that failure, the stale cache entry is dropped
@@ -208,30 +292,34 @@ export async function withWorkspace<T>(resolve: () => Promise<ResolvedWorkspace>
208
292
  return await perform(resolved);
209
293
  } catch (error) {
210
294
  if (attempt === 1 || !remoteErrorIs(error, "UnknownWorkspace")) throw error;
211
- workspaceIdByRoot.delete(resolved.root);
295
+ forgetWorkspaceId(resolved.root);
212
296
  }
213
297
  }
214
298
  throw new Error("Lector workspace resolution retry exhausted");
215
299
  }
216
300
 
217
301
  /**
218
- * Drops one root's cached workspaceId without retrying anything itself -- the batch sibling of
219
- * withWorkspace's own single-workspace `workspaceIdByRoot.delete(resolved.root)` recovery, for a
220
- * caller (cross-workspace search's fan-out) that resolves many roots at once and needs to evict
221
- * only the specific ones a daemon restart actually invalidated, not the whole cache.
302
+ * Drops every cache entry resolved to this root without retrying anything itself -- the batch
303
+ * sibling of withWorkspace's own single-workspace recovery, for a caller (cross-workspace
304
+ * search's fan-out) that resolves many roots at once and needs to evict only the specific ones a
305
+ * daemon restart actually invalidated, not the whole cache. A root can appear under more than one
306
+ * cache key (workspaceForPath and workspaceForDirectory can each independently resolve to the
307
+ * same root for related paths), so this scans by value, not a single key lookup.
222
308
  */
223
309
  export function forgetWorkspaceId(root: string): void {
224
- workspaceIdByRoot.delete(root);
310
+ for (const [key, resolved] of resolutionCache) {
311
+ if (resolved.root === root) resolutionCache.delete(key);
312
+ }
225
313
  }
226
314
 
227
315
  export function setLectorClientConnectorForTests(value: ClientConnector): void {
228
316
  retryingClient.reset();
229
- workspaceIdByRoot.clear();
317
+ resolutionCache.clear();
230
318
  connector = value;
231
319
  }
232
320
 
233
321
  export function resetLectorClientForTests(): void {
234
322
  retryingClient.reset();
235
- workspaceIdByRoot.clear();
323
+ resolutionCache.clear();
236
324
  connector = () => connectLectorClient();
237
325
  }
@@ -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,5 +1,5 @@
1
1
  import type { OperationInputs, OperationOutputs } from "@danypops/lector";
2
- import { lectorClient, withWorkspace, workspaceForCodeIntelligencePath } from "../lector-client.ts";
2
+ import { lectorClient, withWorkspace, workspaceForAnnotationPath } from "../lector-client.ts";
3
3
 
4
4
  /** A bare symbol position an anchor is given as -- symbolNodeId and the anchor's baseline file hash are derived server-side, never supplied by the caller. */
5
5
  export interface AnnotationAnchorInput {
@@ -9,10 +9,12 @@ export interface AnnotationAnchorInput {
9
9
  }
10
10
 
11
11
  /**
12
- * Thin wrappers over Lector's annotation operations. Anchored to a workspace
13
- * via the first anchor's own path (workspaceForCodeIntelligencePath) --
14
- * every operation that needs a workspace already has at least one real
15
- * anchor position or an id whose workspace the caller already knows.
12
+ * Thin wrappers over Lector's annotation operations. Every operation resolves its workspace from
13
+ * its own `path` parameter via workspaceForAnnotationPath -- a real project directory or an
14
+ * existing source file, never dirname()'d unconditionally the way a plain code-intelligence
15
+ * path is. `path` here means "which workspace this call belongs to," not necessarily one of the
16
+ * operation's own anchors (create/refresh's anchors carry their own, separately-validated paths
17
+ * server-side).
16
18
  */
17
19
  export interface SymbolAnnotationOperations {
18
20
  create(
@@ -46,7 +48,7 @@ export function createLectorSymbolAnnotationOperations(): SymbolAnnotationOperat
46
48
  return {
47
49
  async create(path, subtype, title, body, anchors) {
48
50
  return withWorkspace(
49
- () => workspaceForCodeIntelligencePath(path),
51
+ () => workspaceForAnnotationPath(path),
50
52
  async ({ workspaceId }) => {
51
53
  const client = await lectorClient();
52
54
  return client.callOnce("workspace.createAnnotation", { workspaceId, subtype, title, body, anchors });
@@ -55,7 +57,7 @@ export function createLectorSymbolAnnotationOperations(): SymbolAnnotationOperat
55
57
  },
56
58
  async get(path, id) {
57
59
  return withWorkspace(
58
- () => workspaceForCodeIntelligencePath(path),
60
+ () => workspaceForAnnotationPath(path),
59
61
  async ({ workspaceId }) => {
60
62
  const client = await lectorClient();
61
63
  return client.call("workspace.getAnnotation", { workspaceId, id });
@@ -64,7 +66,7 @@ export function createLectorSymbolAnnotationOperations(): SymbolAnnotationOperat
64
66
  },
65
67
  async list(path, options = {}) {
66
68
  return withWorkspace(
67
- () => workspaceForCodeIntelligencePath(path),
69
+ () => workspaceForAnnotationPath(path),
68
70
  async ({ workspaceId }) => {
69
71
  const client = await lectorClient();
70
72
  return client.call("workspace.listAnnotations", {
@@ -79,7 +81,7 @@ export function createLectorSymbolAnnotationOperations(): SymbolAnnotationOperat
79
81
  },
80
82
  async refresh(path, id, subtype, title, body, anchors) {
81
83
  return withWorkspace(
82
- () => workspaceForCodeIntelligencePath(path),
84
+ () => workspaceForAnnotationPath(path),
83
85
  async ({ workspaceId }) => {
84
86
  const client = await lectorClient();
85
87
  return client.callOnce("workspace.refreshAnnotation", { workspaceId, id, subtype, title, body, anchors });
@@ -88,7 +90,7 @@ export function createLectorSymbolAnnotationOperations(): SymbolAnnotationOperat
88
90
  },
89
91
  async scrub(path, id) {
90
92
  return withWorkspace(
91
- () => workspaceForCodeIntelligencePath(path),
93
+ () => workspaceForAnnotationPath(path),
92
94
  async ({ workspaceId }) => {
93
95
  const client = await lectorClient();
94
96
  return client.callOnce("workspace.scrubAnnotation", { workspaceId, id });
@@ -97,7 +99,7 @@ export function createLectorSymbolAnnotationOperations(): SymbolAnnotationOperat
97
99
  },
98
100
  async restore(path, id) {
99
101
  return withWorkspace(
100
- () => workspaceForCodeIntelligencePath(path),
102
+ () => workspaceForAnnotationPath(path),
101
103
  async ({ workspaceId }) => {
102
104
  const client = await lectorClient();
103
105
  return client.callOnce("workspace.restoreAnnotation", { workspaceId, id });
@@ -106,7 +108,7 @@ export function createLectorSymbolAnnotationOperations(): SymbolAnnotationOperat
106
108
  },
107
109
  async contain(path, parentId, childId) {
108
110
  return withWorkspace(
109
- () => workspaceForCodeIntelligencePath(path),
111
+ () => workspaceForAnnotationPath(path),
110
112
  async ({ workspaceId }) => {
111
113
  const client = await lectorClient();
112
114
  return client.callOnce("workspace.containAnnotation", { workspaceId, parentId, childId });
@@ -115,7 +117,7 @@ export function createLectorSymbolAnnotationOperations(): SymbolAnnotationOperat
115
117
  },
116
118
  async uncontain(path, parentId, childId) {
117
119
  return withWorkspace(
118
- () => workspaceForCodeIntelligencePath(path),
120
+ () => workspaceForAnnotationPath(path),
119
121
  async ({ workspaceId }) => {
120
122
  const client = await lectorClient();
121
123
  return client.callOnce("workspace.uncontainAnnotation", { workspaceId, parentId, childId });
@@ -124,7 +126,7 @@ export function createLectorSymbolAnnotationOperations(): SymbolAnnotationOperat
124
126
  },
125
127
  async tree(path, rootId, maxDepth) {
126
128
  return withWorkspace(
127
- () => workspaceForCodeIntelligencePath(path),
129
+ () => workspaceForAnnotationPath(path),
128
130
  async ({ workspaceId }) => {
129
131
  const client = await lectorClient();
130
132
  return client.call("workspace.annotationTree", { workspaceId, rootId, maxDepth });
@@ -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.5",
3
+ "version": "0.12.7",
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",
@@ -18,9 +18,9 @@
18
18
  "typebox": "*"
19
19
  },
20
20
  "dependencies": {
21
- "@danypops/vehicle-client": "^0.2.0",
21
+ "@danypops/vehicle-client": "^0.8.2",
22
22
  "@danypops/lector": "^0.18.0",
23
- "malevich-tui-components": "^0.19.0",
23
+ "malevich-tui-components": "^0.25.0",
24
24
  "picomatch": "^4.0.5"
25
25
  },
26
26
  "devDependencies": {
@@ -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
- }