@danypops/pi-lector 0.2.2 → 0.4.0

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,8 +1,9 @@
1
1
  import { existsSync, statSync } from "node:fs";
2
- import { dirname, parse } from "node:path";
2
+ import { dirname, extname, parse } from "node:path";
3
3
  import { createRetryingClient, type RetryingClient } from "@danypops/daemon-kit/pi-client";
4
4
  import {
5
5
  connectLectorClient,
6
+ descriptorForExtension,
6
7
  type LectorClient,
7
8
  type OperationInputs,
8
9
  type OperationName,
@@ -10,7 +11,7 @@ import {
10
11
  remoteErrorIs,
11
12
  type WorkspaceId,
12
13
  } from "@danypops/lector";
13
- import { nearestGitRoot } from "./nearest-workspace-root.ts";
14
+ import { nearestGitRoot, nearestProjectRoot } from "./nearest-workspace-root.ts";
14
15
 
15
16
  /**
16
17
  * Lazily connects to a running Lector daemon and caches, per project root,
@@ -38,14 +39,26 @@ let connector: ClientConnector = () => connectLectorClient();
38
39
  const retryingClient: RetryingClient<LectorClient> = createRetryingClient(() => connector(), { label: "Lector" });
39
40
  const workspaceIdByRoot = new Map<string, WorkspaceId>();
40
41
 
42
+ /**
43
+ * Fires exactly once per distinct root, the moment it's first registered in this process --
44
+ * never on a later call that reuses the cached workspaceId. The single choke point every
45
+ * resolver (workspaceForPath, workspaceForDirectory, workspaceForCodeIntelligencePath,
46
+ * workspaceForPathOrDirectory) funnels through, so this is genuinely "the first time any tool
47
+ * call resolves this workspace," not just the one cwd workspace at session start.
48
+ */
49
+ let onNewWorkspace: ((root: string) => void) | undefined;
50
+
51
+ export function setNewWorkspaceObserver(observer: ((root: string) => void) | undefined): void {
52
+ onNewWorkspace = observer;
53
+ }
54
+
41
55
  export interface RetryingLectorClient {
42
56
  call<Name extends OperationName>(operation: Name, input: OperationInputs[Name]): Promise<OperationOutputs[Name]>;
43
57
  }
44
58
 
45
59
  // Kept async even though its own body has no await: every call site across this package does
46
- // `await lectorClient()`, and dropping async here (just to satisfy require-await) would turn an
47
- // internal implementation detail into a signature change rippling through every one of them.
48
- // eslint-disable-next-line @typescript-eslint/require-await
60
+ // `await lectorClient()`, and dropping async here would turn an internal implementation detail
61
+ // into a signature change rippling through every one of them.
49
62
  export async function lectorClient(): Promise<RetryingLectorClient> {
50
63
  return {
51
64
  call: (operation, input) => retryingClient.call((client) => client.call(operation, input)),
@@ -64,6 +77,7 @@ async function workspaceForRoot(root: string): Promise<ResolvedWorkspace> {
64
77
  const client = await lectorClient();
65
78
  const { workspaceId } = await client.call("workspace.registerPath", { path: root });
66
79
  workspaceIdByRoot.set(root, workspaceId);
80
+ onNewWorkspace?.(root);
67
81
  return { workspaceId, root };
68
82
  }
69
83
 
@@ -107,9 +121,17 @@ export function workspaceForDirectory(directory: string): Promise<ResolvedWorksp
107
121
  * whose filesystem-root fallback would point a real server at scanning the
108
122
  * whole disk. Falls back to the file's own containing directory instead,
109
123
  * same bound as workspaceForDirectory.
124
+ *
125
+ * Unlike workspaceForDirectory, prefers the file's own language's root markers
126
+ * (tsconfig.json, go.mod, Cargo.toml, ...) over the nearest .git when both exist --
127
+ * a monorepo subproject's own root marker is nearer and wins, so its language server
128
+ * gets that subproject's rootUri instead of the whole repo's.
110
129
  */
111
130
  export function workspaceForCodeIntelligencePath(absolutePath: string): Promise<ResolvedWorkspace> {
112
- return workspaceForDirectory(dirname(absolutePath));
131
+ const directory = dirname(absolutePath);
132
+ const descriptor = descriptorForExtension(extname(absolutePath));
133
+ const root = descriptor ? (nearestProjectRoot(directory, descriptor.rootMarkers) ?? directory) : (nearestGitRoot(directory) ?? directory);
134
+ return workspaceForRoot(root);
113
135
  }
114
136
 
115
137
  /**
@@ -0,0 +1,34 @@
1
+ import type { MutationHistoryEntry } from "@danypops/lector";
2
+ import { lectorClient, withWorkspace, workspaceForPath } from "./lector-client.ts";
3
+ import { toWorkspaceRelativePath } from "./workspace-relative-path.ts";
4
+
5
+ /** Thin wrapper over Lector's mutation history: every successful edit is recorded, and any entry can be reverted -- guarded the same way every other Lector write is. */
6
+ export interface MutationHistoryOperations {
7
+ list(absolutePath: string, maxResults: number): Promise<readonly MutationHistoryEntry[]>;
8
+ revert(absolutePath: string, entryId: string): Promise<{ path: string; newHash: string | null }>;
9
+ }
10
+
11
+ export function createMutationHistoryOperations(): MutationHistoryOperations {
12
+ return {
13
+ list(absolutePath, maxResults) {
14
+ return withWorkspace(
15
+ () => workspaceForPath(absolutePath),
16
+ async ({ workspaceId, root }) => {
17
+ const client = await lectorClient();
18
+ const path = toWorkspaceRelativePath(root, absolutePath);
19
+ const { entries } = await client.call("workspace.mutationHistory", { workspaceId, path, maxResults });
20
+ return entries;
21
+ },
22
+ );
23
+ },
24
+ revert(absolutePath, entryId) {
25
+ return withWorkspace(
26
+ () => workspaceForPath(absolutePath),
27
+ async ({ workspaceId }) => {
28
+ const client = await lectorClient();
29
+ return client.call("workspace.revertMutation", { workspaceId, entryId });
30
+ },
31
+ );
32
+ },
33
+ };
34
+ }
@@ -21,14 +21,30 @@ import { dirname, join, parse } from "node:path";
21
21
  * workspace root" error -- discovered live, in a separate session, working
22
22
  * against a completely different, unrelated repository.)
23
23
  */
24
- export function nearestGitRoot(startDirectory: string): string | undefined {
24
+ function walkUpForMarkers(startDirectory: string, markers: readonly string[]): string | undefined {
25
25
  let dir = startDirectory;
26
26
  const fsRoot = parse(dir).root;
27
27
  while (dir !== fsRoot) {
28
- if (existsSync(join(dir, ".git"))) return dir;
28
+ if (markers.some((marker) => existsSync(join(dir, marker)))) return dir;
29
29
  const parent = dirname(dir);
30
30
  if (parent === dir) break; // defensive: dirname must be strictly ascending
31
31
  dir = parent;
32
32
  }
33
- return existsSync(join(fsRoot, ".git")) ? fsRoot : undefined;
33
+ return markers.some((marker) => existsSync(join(fsRoot, marker))) ? fsRoot : undefined;
34
+ }
35
+
36
+ export function nearestGitRoot(startDirectory: string): string | undefined {
37
+ return walkUpForMarkers(startDirectory, [".git"]);
38
+ }
39
+
40
+ /**
41
+ * Same nearest-enclosing-root walk as nearestGitRoot, but also checks a language's own root
42
+ * markers (tsconfig.json, go.mod, Cargo.toml, ...) at each directory, nearest first -- so a
43
+ * monorepo subproject with its own root marker resolves to itself, not the outer repo's .git.
44
+ * Found via @arvoretech/pi-lsp comparison: without this, a file inside a monorepo subproject
45
+ * misattributes its whole project to the repo root, handing the language server the wrong
46
+ * rootUri (and, for TypeScript, the wrong tsconfig.json) even though a closer one exists.
47
+ */
48
+ export function nearestProjectRoot(startDirectory: string, rootMarkers: readonly string[]): string | undefined {
49
+ return walkUpForMarkers(startDirectory, [...rootMarkers, ".git"]);
34
50
  }
@@ -0,0 +1,26 @@
1
+ import type { OperationOutputs } from "@danypops/lector";
2
+ import { lectorClient, withWorkspace, workspaceForCodeIntelligencePath } from "./lector-client.ts";
3
+
4
+ /**
5
+ * Thin wrapper over Lector's non-LSP reference-based rename: moves a file and rewrites every
6
+ * static import/export specifier the workspace's own populated symbol graph knows references it.
7
+ * `fromPath` resolves its own workspace (workspaceForCodeIntelligencePath -- this spawns a real
8
+ * language server), matching every other code-intelligence operation's convention.
9
+ */
10
+ export interface ReferenceBasedRenameOperations {
11
+ rename(fromPath: string, toPath: string, maxFiles: number, maxSymbolsPerFile: number): Promise<OperationOutputs["workspace.referenceBasedRename"]>;
12
+ }
13
+
14
+ export function createReferenceBasedRenameOperations(): ReferenceBasedRenameOperations {
15
+ return {
16
+ async rename(fromPath, toPath, maxFiles, maxSymbolsPerFile) {
17
+ return withWorkspace(
18
+ () => workspaceForCodeIntelligencePath(fromPath),
19
+ async ({ workspaceId }) => {
20
+ const client = await lectorClient();
21
+ return client.call("workspace.referenceBasedRename", { workspaceId, fromPath, toPath, maxFiles, maxSymbolsPerFile });
22
+ },
23
+ );
24
+ },
25
+ };
26
+ }
@@ -0,0 +1,36 @@
1
+ import type { OperationOutputs } from "@danypops/lector";
2
+ import { lectorClient, withWorkspace, workspaceForCodeIntelligencePath } from "./lector-client.ts";
3
+
4
+ /**
5
+ * Thin wrappers over Lector's LSP-driven prepareRename/rename -- position-based (path + 1-indexed
6
+ * line + character), matching every other code-intelligence operation's convention. `path`
7
+ * resolves its own workspace per call (workspaceForCodeIntelligencePath -- spawns a real
8
+ * language server).
9
+ */
10
+ export interface RenameOperations {
11
+ prepareRename(path: string, line: number, character: number): Promise<OperationOutputs["workspace.prepareRename"]>;
12
+ rename(path: string, line: number, character: number, newName: string): Promise<OperationOutputs["workspace.rename"]>;
13
+ }
14
+
15
+ export function createRenameOperations(): RenameOperations {
16
+ return {
17
+ async prepareRename(path, line, character) {
18
+ return withWorkspace(
19
+ () => workspaceForCodeIntelligencePath(path),
20
+ async ({ workspaceId }) => {
21
+ const client = await lectorClient();
22
+ return client.call("workspace.prepareRename", { workspaceId, path, line, character });
23
+ },
24
+ );
25
+ },
26
+ async rename(path, line, character, newName) {
27
+ return withWorkspace(
28
+ () => workspaceForCodeIntelligencePath(path),
29
+ async ({ workspaceId }) => {
30
+ const client = await lectorClient();
31
+ return client.call("workspace.rename", { workspaceId, path, line, character, newName });
32
+ },
33
+ );
34
+ },
35
+ };
36
+ }
@@ -0,0 +1,24 @@
1
+ import type { CachedRepositoryPage } from "@danypops/lector";
2
+ import { lectorClient } from "./lector-client.ts";
3
+
4
+ /**
5
+ * Thin wrapper over repo.listCache -- no network, no cache mutation, no `directory`/
6
+ * workspaceForDirectory resolution (matching repo-fetch-operations.ts: this queries the
7
+ * daemon-wide fetch cache, not a workspace-scoped concept).
8
+ */
9
+ export interface RepoCacheListOperations {
10
+ list(
11
+ filters: { text?: string; host?: string; owner?: string; repo?: string; ref?: string },
12
+ maxResults: number,
13
+ cursor?: string,
14
+ ): Promise<CachedRepositoryPage>;
15
+ }
16
+
17
+ export function createRepoCacheListOperations(): RepoCacheListOperations {
18
+ return {
19
+ async list(filters, maxResults, cursor) {
20
+ const client = await lectorClient();
21
+ return client.call("repo.listCache", { ...filters, maxResults, cursor });
22
+ },
23
+ };
24
+ }
@@ -25,7 +25,7 @@ export interface SymbolAnnotationOperations {
25
25
  get(path: string, id: string): Promise<OperationOutputs["workspace.getAnnotation"]>;
26
26
  list(
27
27
  path: string,
28
- options?: { subtype?: string; status?: OperationInputs["workspace.listAnnotations"]["status"]; maxResults?: number },
28
+ options?: { subtype?: string; status?: OperationInputs["workspace.listAnnotations"]["status"]; maxResults?: number; query?: string },
29
29
  ): Promise<OperationOutputs["workspace.listAnnotations"]>;
30
30
  refresh(
31
31
  path: string,
@@ -37,6 +37,9 @@ export interface SymbolAnnotationOperations {
37
37
  ): Promise<OperationOutputs["workspace.refreshAnnotation"]>;
38
38
  scrub(path: string, id: string): Promise<OperationOutputs["workspace.scrubAnnotation"]>;
39
39
  restore(path: string, id: string): Promise<OperationOutputs["workspace.restoreAnnotation"]>;
40
+ contain(path: string, parentId: string, childId: string): Promise<OperationOutputs["workspace.containAnnotation"]>;
41
+ uncontain(path: string, parentId: string, childId: string): Promise<OperationOutputs["workspace.uncontainAnnotation"]>;
42
+ tree(path: string, rootId: string, maxDepth: number): Promise<OperationOutputs["workspace.annotationTree"]>;
40
43
  }
41
44
 
42
45
  export function createLectorSymbolAnnotationOperations(): SymbolAnnotationOperations {
@@ -64,7 +67,13 @@ export function createLectorSymbolAnnotationOperations(): SymbolAnnotationOperat
64
67
  () => workspaceForCodeIntelligencePath(path),
65
68
  async ({ workspaceId }) => {
66
69
  const client = await lectorClient();
67
- return client.call("workspace.listAnnotations", { workspaceId, subtype: options.subtype, status: options.status, maxResults: options.maxResults });
70
+ return client.call("workspace.listAnnotations", {
71
+ workspaceId,
72
+ subtype: options.subtype,
73
+ status: options.status,
74
+ maxResults: options.maxResults,
75
+ query: options.query,
76
+ });
68
77
  },
69
78
  );
70
79
  },
@@ -95,5 +104,32 @@ export function createLectorSymbolAnnotationOperations(): SymbolAnnotationOperat
95
104
  },
96
105
  );
97
106
  },
107
+ async contain(path, parentId, childId) {
108
+ return withWorkspace(
109
+ () => workspaceForCodeIntelligencePath(path),
110
+ async ({ workspaceId }) => {
111
+ const client = await lectorClient();
112
+ return client.call("workspace.containAnnotation", { workspaceId, parentId, childId });
113
+ },
114
+ );
115
+ },
116
+ async uncontain(path, parentId, childId) {
117
+ return withWorkspace(
118
+ () => workspaceForCodeIntelligencePath(path),
119
+ async ({ workspaceId }) => {
120
+ const client = await lectorClient();
121
+ return client.call("workspace.uncontainAnnotation", { workspaceId, parentId, childId });
122
+ },
123
+ );
124
+ },
125
+ async tree(path, rootId, maxDepth) {
126
+ return withWorkspace(
127
+ () => workspaceForCodeIntelligencePath(path),
128
+ async ({ workspaceId }) => {
129
+ const client = await lectorClient();
130
+ return client.call("workspace.annotationTree", { workspaceId, rootId, maxDepth });
131
+ },
132
+ );
133
+ },
98
134
  };
99
135
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@danypops/pi-lector",
3
- "version": "0.2.2",
3
+ "version": "0.4.0",
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,8 +18,8 @@
18
18
  "typebox": "*"
19
19
  },
20
20
  "dependencies": {
21
- "@danypops/daemon-kit": "^0.4.0",
22
- "@danypops/lector": "^0.2.0"
21
+ "@danypops/daemon-kit": "^0.22.1",
22
+ "@danypops/lector": "^0.5.0"
23
23
  },
24
24
  "devDependencies": {
25
25
  "@earendil-works/pi-ai": "^0.81.1",