@danypops/pi-lector 0.9.5 → 0.9.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { SymbolSearchResult, TextSearchResult, WorkspaceQueryOutcome } from "@danypops/lector";
|
|
2
|
-
import { lectorClient,
|
|
2
|
+
import { lectorClient, 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,34 +10,79 @@ import { lectorClient, workspaceForDirectory } from "./lector-client.ts";
|
|
|
10
10
|
* required, same "no implicit fallback" convention as find_symbols/search_code.
|
|
11
11
|
*/
|
|
12
12
|
export interface CrossWorkspaceSearchOperations {
|
|
13
|
-
findSymbols(query: string, directories: readonly string[], timeoutMs?: number): Promise<readonly
|
|
13
|
+
findSymbols(query: string, directories: readonly string[], timeoutMs?: number): Promise<readonly CrossWorkspaceOutcome<SymbolSearchResult>[]>;
|
|
14
14
|
searchText(
|
|
15
15
|
query: string,
|
|
16
16
|
directories: readonly string[],
|
|
17
17
|
maxMatches: number,
|
|
18
18
|
maxBytes: number,
|
|
19
19
|
timeoutMs?: number,
|
|
20
|
-
): Promise<readonly
|
|
20
|
+
): Promise<readonly CrossWorkspaceOutcome<TextSearchResult>[]>;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* One caller-supplied directory's own outcome, labeled back by that literal directory (never
|
|
25
|
+
* just a workspaceId hash) so a caller can tell which of their own inputs a result belongs to.
|
|
26
|
+
* `collapsedWith` lists any OTHER requested directories that resolved to this same workspaceId --
|
|
27
|
+
* empty when this directory got its own distinct scope, as it should for a real monorepo
|
|
28
|
+
* subproject. A caller must be able to tell "two of my inputs turned out to be one workspace"
|
|
29
|
+
* apart from "these are genuinely two separate results" -- silently duplicating one payload
|
|
30
|
+
* under two different-looking entries is exactly the bug this exists to prevent.
|
|
31
|
+
*/
|
|
32
|
+
export interface CrossWorkspaceOutcome<T> {
|
|
33
|
+
readonly directory: string;
|
|
34
|
+
readonly workspaceId: string;
|
|
35
|
+
readonly collapsedWith: readonly string[];
|
|
36
|
+
readonly outcome: WorkspaceQueryOutcome<T>;
|
|
21
37
|
}
|
|
22
38
|
|
|
23
39
|
async function resolveWorkspaceIds(directories: readonly string[]): Promise<readonly string[]> {
|
|
24
|
-
const resolved = await Promise.all(directories.map((directory) =>
|
|
40
|
+
const resolved = await Promise.all(directories.map((directory) => workspaceForProjectDirectory(directory)));
|
|
25
41
|
return resolved.map((r) => r.workspaceId);
|
|
26
42
|
}
|
|
27
43
|
|
|
44
|
+
/**
|
|
45
|
+
* Zips the daemon's own outcomes back onto the literal directories that produced them, and
|
|
46
|
+
* computes collapsedWith. The daemon's search.symbols/search.text handlers map workspaceIds to
|
|
47
|
+
* results 1:1, in order, with no deduplication of their own (confirmed by reading service.ts's
|
|
48
|
+
* crossFindSymbols/crossSearchText: `targets.map(...)` over the exact `workspaceIds` array
|
|
49
|
+
* given) -- so a length mismatch here means that contract broke, not a normal runtime condition
|
|
50
|
+
* to paper over with an unsafe cast.
|
|
51
|
+
*/
|
|
52
|
+
function zipOutcomes<T>(
|
|
53
|
+
directories: readonly string[],
|
|
54
|
+
workspaceIds: readonly string[],
|
|
55
|
+
outcomes: readonly WorkspaceQueryOutcome<T>[],
|
|
56
|
+
): readonly CrossWorkspaceOutcome<T>[] {
|
|
57
|
+
if (outcomes.length !== directories.length) {
|
|
58
|
+
throw new Error(
|
|
59
|
+
`Lector's search fan-out returned ${outcomes.length} outcome(s) for ${directories.length} requested directories -- expected exactly one outcome per directory, in order`,
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
return directories.map((directory, index) => {
|
|
63
|
+
const workspaceId = workspaceIds[index];
|
|
64
|
+
const outcome = outcomes[index];
|
|
65
|
+
if (workspaceId === undefined || outcome === undefined) {
|
|
66
|
+
throw new Error(`Lector's search fan-out is missing a workspaceId/outcome for directory "${directory}"`);
|
|
67
|
+
}
|
|
68
|
+
const collapsedWith = directories.filter((_, otherIndex) => otherIndex !== index && workspaceIds[otherIndex] === workspaceId);
|
|
69
|
+
return { directory, workspaceId, collapsedWith, outcome };
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
|
|
28
73
|
export function createLectorCrossWorkspaceSearchOperations(): CrossWorkspaceSearchOperations {
|
|
29
74
|
return {
|
|
30
75
|
async findSymbols(query, directories, timeoutMs) {
|
|
31
76
|
const workspaceIds = await resolveWorkspaceIds(directories);
|
|
32
77
|
const client = await lectorClient();
|
|
33
78
|
const { results } = await client.call("search.symbols", { query, workspaceIds, timeoutMs });
|
|
34
|
-
return results;
|
|
79
|
+
return zipOutcomes(directories, workspaceIds, results);
|
|
35
80
|
},
|
|
36
81
|
async searchText(query, directories, maxMatches, maxBytes, timeoutMs) {
|
|
37
82
|
const workspaceIds = await resolveWorkspaceIds(directories);
|
|
38
83
|
const client = await lectorClient();
|
|
39
84
|
const { results } = await client.call("search.text", { query, maxMatches, maxBytes, workspaceIds, timeoutMs });
|
|
40
|
-
return results;
|
|
85
|
+
return zipOutcomes(directories, workspaceIds, results);
|
|
41
86
|
},
|
|
42
87
|
};
|
|
43
88
|
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
import type { SymbolSearchResult, TextSearchResult
|
|
1
|
+
import type { SymbolSearchResult, TextSearchResult } from "@danypops/lector";
|
|
2
2
|
import { keyHint } from "@earendil-works/pi-coding-agent";
|
|
3
3
|
import { renderTruncatedList } from "malevich-tui-components";
|
|
4
|
+
import type { CrossWorkspaceOutcome } from "./cross-workspace-search-operations.ts";
|
|
4
5
|
import { describeFindSymbolSources } from "./find-symbols-rendering.ts";
|
|
5
6
|
import type { LectorTheme } from "./lector-tui-theme.ts";
|
|
6
7
|
|
|
@@ -12,21 +13,31 @@ export function formatCrossWorkspaceCall(args: { directories?: unknown; query?:
|
|
|
12
13
|
return `${theme.fg("accent", `"${query}"`)} ${theme.fg("dim", `across ${directories.length} project(s)`)}`;
|
|
13
14
|
}
|
|
14
15
|
|
|
15
|
-
function formatOutcomeHeader(
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
16
|
+
function formatOutcomeHeader(entry: CrossWorkspaceOutcome<unknown>, theme: LectorTheme): string {
|
|
17
|
+
const { outcome } = entry;
|
|
18
|
+
const label = theme.fg("accent", entry.directory);
|
|
19
|
+
const lines: string[] = [];
|
|
20
|
+
if (outcome.status === "ready") lines.push(label);
|
|
21
|
+
else if (outcome.status === "loading") lines.push(`${label} ${theme.fg("warning", `-- still loading: ${outcome.message}`)}`);
|
|
22
|
+
else lines.push(`${label} ${theme.fg("error", `-- ${outcome.message}`)}`);
|
|
23
|
+
// Surfaced explicitly, never silently -- two distinct inputs resolving to one workspace means
|
|
24
|
+
// one of their own result payloads below is a real duplicate of the other's, not two independent answers.
|
|
25
|
+
if (entry.collapsedWith.length > 0) {
|
|
26
|
+
lines.push(theme.fg("warning", ` resolved to the same workspace as: ${entry.collapsedWith.join(", ")}`));
|
|
27
|
+
}
|
|
28
|
+
return lines.join("\n");
|
|
19
29
|
}
|
|
20
30
|
|
|
21
31
|
export function formatFindSymbolsAcrossProjectsResult(
|
|
22
|
-
results: readonly
|
|
32
|
+
results: readonly CrossWorkspaceOutcome<SymbolSearchResult>[] | undefined,
|
|
23
33
|
expanded: boolean,
|
|
24
34
|
theme: LectorTheme,
|
|
25
35
|
): string {
|
|
26
36
|
if (!results || results.length === 0) return theme.fg("dim", "No projects to search.");
|
|
27
37
|
const lines: string[] = [];
|
|
28
|
-
for (const
|
|
29
|
-
lines.push(formatOutcomeHeader(
|
|
38
|
+
for (const entry of results) {
|
|
39
|
+
lines.push(formatOutcomeHeader(entry, theme));
|
|
40
|
+
const { outcome } = entry;
|
|
30
41
|
if (outcome.status !== "ready") continue;
|
|
31
42
|
lines.push(
|
|
32
43
|
theme.fg("muted", ` ${outcome.result.provenance.fidelity} via ${outcome.result.provenance.backend}${outcome.result.truncated ? " (truncated)" : ""}`),
|
|
@@ -50,14 +61,15 @@ export function formatFindSymbolsAcrossProjectsResult(
|
|
|
50
61
|
}
|
|
51
62
|
|
|
52
63
|
export function formatSearchTextAcrossProjectsResult(
|
|
53
|
-
results: readonly
|
|
64
|
+
results: readonly CrossWorkspaceOutcome<TextSearchResult>[] | undefined,
|
|
54
65
|
expanded: boolean,
|
|
55
66
|
theme: LectorTheme,
|
|
56
67
|
): string {
|
|
57
68
|
if (!results || results.length === 0) return theme.fg("dim", "No projects to search.");
|
|
58
69
|
const lines: string[] = [];
|
|
59
|
-
for (const
|
|
60
|
-
lines.push(formatOutcomeHeader(
|
|
70
|
+
for (const entry of results) {
|
|
71
|
+
lines.push(formatOutcomeHeader(entry, theme));
|
|
72
|
+
const { outcome } = entry;
|
|
61
73
|
if (outcome.status !== "ready") continue;
|
|
62
74
|
if (outcome.result.matches.length === 0) {
|
|
63
75
|
lines.push(theme.fg("dim", " no matches"));
|
package/extension/src/index.ts
CHANGED
|
@@ -27,7 +27,6 @@ import type {
|
|
|
27
27
|
WorkspaceCacheStatus,
|
|
28
28
|
WorkspaceLocation,
|
|
29
29
|
WorkspaceMapResult,
|
|
30
|
-
WorkspaceQueryOutcome,
|
|
31
30
|
} from "@danypops/lector";
|
|
32
31
|
import { DEFAULT_EXTERNAL_SEARCH_MAX_RESULTS, PACKAGE_ECOSYSTEMS } from "@danypops/lector";
|
|
33
32
|
import {
|
|
@@ -68,7 +67,7 @@ import {
|
|
|
68
67
|
formatWorkspaceMapCall,
|
|
69
68
|
formatWorkspaceMapResult,
|
|
70
69
|
} from "./code-intelligence-rendering.ts";
|
|
71
|
-
import { createLectorCrossWorkspaceSearchOperations } from "./cross-workspace-search-operations.ts";
|
|
70
|
+
import { type CrossWorkspaceOutcome, createLectorCrossWorkspaceSearchOperations } from "./cross-workspace-search-operations.ts";
|
|
72
71
|
import { formatCrossWorkspaceCall, formatFindSymbolsAcrossProjectsResult, formatSearchTextAcrossProjectsResult } from "./cross-workspace-search-rendering.ts";
|
|
73
72
|
import { createLectorEditOperations } from "./edit-operations.ts";
|
|
74
73
|
import { createExternalSearchOperations } from "./external-search-operations.ts";
|
|
@@ -1725,7 +1724,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
1725
1724
|
name: "find_symbols_across_projects",
|
|
1726
1725
|
label: "Find Symbols Across Projects",
|
|
1727
1726
|
description:
|
|
1728
|
-
"Fans out a symbol-name search across several explicitly-named project directories at once (e.g. several fetched repos, or a handful of related local projects) and reports one outcome per project -- ready with real results, loading (a project's language server is still cold-starting; retry shortly), or error. Directories are required and explicit -- never every project this daemon happens to have registered, which can include unrelated projects from other concurrent sessions.",
|
|
1727
|
+
"Fans out a symbol-name search across several explicitly-named project directories at once (e.g. several fetched repos, or a handful of related local projects) and reports one outcome per project -- ready with real results, loading (a project's language server is still cold-starting; retry shortly), or error. Directories are required and explicit -- never every project this daemon happens to have registered, which can include unrelated projects from other concurrent sessions. Each directory resolves to its OWN nearest project root (package.json/tsconfig.json/go.mod/Cargo.toml/...), not the outer repo's git root -- sibling packages under one monorepo stay distinct scopes rather than collapsing into one. A result's collapsedWith lists any other requested directories that genuinely did resolve to the same workspace; empty means it got its own.",
|
|
1729
1728
|
promptSnippet: "Search for a symbol name across several projects at once",
|
|
1730
1729
|
parameters: Type.Object({
|
|
1731
1730
|
directories: Type.Array(Type.String(), { description: "Project directories to search, each absolute or relative to the current working directory" }),
|
|
@@ -1751,7 +1750,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
1751
1750
|
.join("\n");
|
|
1752
1751
|
return new Text(theme.fg("error", errorText || "find_symbols_across_projects failed"), 0, 0);
|
|
1753
1752
|
}
|
|
1754
|
-
const details = result.details as { results?: readonly
|
|
1753
|
+
const details = result.details as { results?: readonly CrossWorkspaceOutcome<SymbolSearchResult>[] } | undefined;
|
|
1755
1754
|
const text = context.lastComponent instanceof Text ? context.lastComponent : new Text("", 0, 0);
|
|
1756
1755
|
text.setText(formatFindSymbolsAcrossProjectsResult(details?.results, expanded, theme));
|
|
1757
1756
|
return text;
|
|
@@ -1762,7 +1761,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
1762
1761
|
name: "search_code_across_projects",
|
|
1763
1762
|
label: "Search Code Across Projects",
|
|
1764
1763
|
description:
|
|
1765
|
-
"Fans out a ripgrep-backed text/regex search across several explicitly-named project directories at once and reports one outcome per project. Directories are required and explicit -- never every project this daemon happens to have registered, which can include unrelated projects from other concurrent sessions.",
|
|
1764
|
+
"Fans out a ripgrep-backed text/regex search across several explicitly-named project directories at once and reports one outcome per project. Directories are required and explicit -- never every project this daemon happens to have registered, which can include unrelated projects from other concurrent sessions. Each directory resolves to its OWN nearest project root (package.json/tsconfig.json/go.mod/Cargo.toml/...), not the outer repo's git root -- sibling packages under one monorepo stay distinct scopes rather than collapsing into one. A result's collapsedWith lists any other requested directories that genuinely did resolve to the same workspace; empty means it got its own.",
|
|
1766
1765
|
promptSnippet: "Search for a pattern across several projects at once",
|
|
1767
1766
|
parameters: Type.Object({
|
|
1768
1767
|
directories: Type.Array(Type.String(), { description: "Project directories to search, each absolute or relative to the current working directory" }),
|
|
@@ -1790,7 +1789,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
1790
1789
|
.join("\n");
|
|
1791
1790
|
return new Text(theme.fg("error", errorText || "search_code_across_projects failed"), 0, 0);
|
|
1792
1791
|
}
|
|
1793
|
-
const details = result.details as { results?: readonly
|
|
1792
|
+
const details = result.details as { results?: readonly CrossWorkspaceOutcome<TextSearchResult>[] } | undefined;
|
|
1794
1793
|
const text = context.lastComponent instanceof Text ? context.lastComponent : new Text("", 0, 0);
|
|
1795
1794
|
text.setText(formatSearchTextAcrossProjectsResult(details?.results, expanded, theme));
|
|
1796
1795
|
return text;
|
|
@@ -3,6 +3,7 @@ import { dirname, extname, parse } from "node:path";
|
|
|
3
3
|
import {
|
|
4
4
|
connectLectorClient,
|
|
5
5
|
descriptorForExtension,
|
|
6
|
+
LANGUAGE_SERVER_DESCRIPTORS,
|
|
6
7
|
type LectorClient,
|
|
7
8
|
type OperationInputs,
|
|
8
9
|
type OperationName,
|
|
@@ -142,6 +143,30 @@ export function workspaceForCodeIntelligencePath(absolutePath: string): Promise<
|
|
|
142
143
|
return workspaceForRoot(root);
|
|
143
144
|
}
|
|
144
145
|
|
|
146
|
+
/** Every known language's own rootMarkers, deduplicated -- see workspaceForProjectDirectory. */
|
|
147
|
+
const ALL_PROJECT_ROOT_MARKERS: readonly string[] = [...new Set(LANGUAGE_SERVER_DESCRIPTORS.flatMap((descriptor) => descriptor.rootMarkers))];
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Resolves a caller-supplied directory to its OWN nearest project root -- never the outer repo's
|
|
151
|
+
* git root -- so distinct sibling packages under one monorepo stay distinct workspaces. Unlike
|
|
152
|
+
* workspaceForDirectory (used by find_symbols/read/write, where one canonical workspaceId per
|
|
153
|
+
* repo is exactly the point), this is for a tool whose entire premise is comparing *different*
|
|
154
|
+
* scopes (find_symbols_across_projects, search_code_across_projects): collapsing two sibling
|
|
155
|
+
* packages into the same workspaceId there silently duplicates one package's own results under
|
|
156
|
+
* the other's name, with no error at all -- confirmed live against this monorepo
|
|
157
|
+
* (packages/lector and packages/pi-lector both resolved to the same workspaceId).
|
|
158
|
+
*
|
|
159
|
+
* Unlike workspaceForCodeIntelligencePath, there is no single file (and therefore no known
|
|
160
|
+
* extension) to pick one specific language's markers from -- a caller-supplied directory could
|
|
161
|
+
* be any language, so this checks the union of every known language's rootMarkers. Falls back to
|
|
162
|
+
* the nearest git root, then the directory itself, exactly as nearestProjectRoot already does
|
|
163
|
+
* internally (it appends ".git" to whatever marker list it's given).
|
|
164
|
+
*/
|
|
165
|
+
export function workspaceForProjectDirectory(directory: string): Promise<ResolvedWorkspace> {
|
|
166
|
+
const root = nearestProjectRoot(directory, ALL_PROJECT_ROOT_MARKERS) ?? directory;
|
|
167
|
+
return workspaceForRoot(root);
|
|
168
|
+
}
|
|
169
|
+
|
|
145
170
|
/**
|
|
146
171
|
* For an operation whose `path` genuinely means "the project/workspace itself"
|
|
147
172
|
* (populateSymbolGraph, workspaceMap, hasWarmIndex) rather than one specific file
|