@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.
- package/extension/src/cross-workspace-search/operations.ts +90 -26
- package/extension/src/index.ts +25 -8
- package/extension/src/lector-client.ts +121 -59
- package/extension/src/reference-based-rename/operations.ts +5 -6
- package/extension/src/workspace-cache/operations.ts +66 -13
- package/extension/src/workspace-cache/rendering.ts +2 -2
- package/package.json +1 -1
- package/extension/src/nearest-workspace-root.ts +0 -130
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import type
|
|
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
|
-
|
|
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
|
-
|
|
40
|
-
|
|
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
|
|
47
|
-
*
|
|
48
|
-
*
|
|
49
|
-
*
|
|
50
|
-
* to
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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
|
}
|
package/extension/src/index.ts
CHANGED
|
@@ -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
|
|
283
|
-
|
|
284
|
-
|
|
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
|
|
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
|
-
|
|
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 {
|
|
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
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
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
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
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
|
|
49
|
-
* never on a later call that
|
|
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
|
|
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
|
-
|
|
88
|
-
|
|
89
|
-
|
|
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
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
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
|
|
103
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
128
|
-
|
|
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
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
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
|
|
166
|
-
*
|
|
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
|
-
|
|
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.
|
|
184
|
-
* itself a real, existing directory first; only takes dirname() when
|
|
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
|
-
|
|
189
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
281
|
+
resolutionCache.clear();
|
|
220
282
|
connector = value;
|
|
221
283
|
}
|
|
222
284
|
|
|
223
285
|
export function resetLectorClientForTests(): void {
|
|
224
286
|
retryingClient.reset();
|
|
225
|
-
|
|
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,
|
|
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
|
|
35
|
-
const
|
|
36
|
-
if (!
|
|
37
|
-
return withWorkspace(() =>
|
|
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 {
|
|
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:
|
|
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<
|
|
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
|
|
140
|
-
if (
|
|
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
|
|
143
|
-
if (
|
|
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()
|
|
148
|
-
return
|
|
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
|
|
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 (
|
|
196
|
-
if (
|
|
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:
|
|
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,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
|
-
}
|