@danypops/pi-lector 0.12.0 → 0.12.2

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,6 @@
1
- import { existsSync } from "node:fs";
2
- import { dirname, join, parse } from "node:path";
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { dirname, join, parse, relative } from "node:path";
3
+ import picomatch from "picomatch";
3
4
 
4
5
  /**
5
6
  * The bare filesystem root is never a legitimate discovered project root, even if it happens
@@ -69,3 +70,61 @@ export function nearestGitRoot(startDirectory: string, exists: (path: string) =>
69
70
  export function nearestProjectRoot(startDirectory: string, rootMarkers: readonly string[], exists: (path: string) => boolean = existsSync): string | undefined {
70
71
  return walkUpForMarkers(startDirectory, [...rootMarkers, ".git"], exists);
71
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
+ }
@@ -1,11 +1,19 @@
1
- import type { OperationOutputs } from "@danypops/lector";
2
- import { lectorClient, withWorkspace, workspaceForCodeIntelligencePath } from "../lector-client.ts";
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";
3
4
 
4
5
  /**
5
6
  * Thin wrapper over Lector's non-LSP reference-based rename: moves a file and rewrites every
6
7
  * static import/export specifier the workspace's own populated symbol graph knows references it.
7
8
  * `fromPath` resolves its own workspace (workspaceForCodeIntelligencePath -- this spawns a real
8
9
  * language server), matching every other code-intelligence operation's convention.
10
+ *
11
+ * On ReferenceBasedRenameRequiresFreshGraph (the narrow per-file project was never populated),
12
+ * retries once against the nearest ANCESTOR whose own package.json "workspaces" field actually
13
+ * declares that project as a member -- never an arbitrary ancestor. This lets a caller populate
14
+ * the whole declared monorepo once (workspace_cache pointed at the repo root) and still rename a
15
+ * file inside one of its member packages, without collapsing genuinely unrelated sibling projects
16
+ * that were never declared together into one workspace identity.
9
17
  */
10
18
  export interface ReferenceBasedRenameOperations {
11
19
  rename(fromPath: string, toPath: string, maxFiles: number, maxSymbolsPerFile: number): Promise<OperationOutputs["workspace.referenceBasedRename"]>;
@@ -14,13 +22,20 @@ export interface ReferenceBasedRenameOperations {
14
22
  export function createReferenceBasedRenameOperations(): ReferenceBasedRenameOperations {
15
23
  return {
16
24
  async rename(fromPath, toPath, maxFiles, maxSymbolsPerFile) {
17
- return withWorkspace(
18
- () => workspaceForCodeIntelligencePath(fromPath),
19
- async ({ workspaceId }) => {
20
- const client = await lectorClient();
21
- return client.callOnce("workspace.referenceBasedRename", { workspaceId, fromPath, toPath, maxFiles, maxSymbolsPerFile });
22
- },
23
- );
25
+ const performRename = async ({ workspaceId }: ResolvedWorkspace) => {
26
+ const client = await lectorClient();
27
+ return client.callOnce("workspace.referenceBasedRename", { workspaceId, fromPath, toPath, maxFiles, maxSymbolsPerFile });
28
+ };
29
+
30
+ try {
31
+ return await withWorkspace(() => workspaceForCodeIntelligencePath(fromPath), performRename);
32
+ } catch (error) {
33
+ 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);
38
+ }
24
39
  },
25
40
  };
26
41
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@danypops/pi-lector",
3
- "version": "0.12.0",
3
+ "version": "0.12.2",
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",
@@ -19,8 +19,9 @@
19
19
  },
20
20
  "dependencies": {
21
21
  "@danypops/vehicle-client": "^0.2.0",
22
- "@danypops/lector": "^0.16.0",
23
- "malevich-tui-components": "^0.19.0"
22
+ "@danypops/lector": "^0.17.0",
23
+ "malevich-tui-components": "^0.19.0",
24
+ "picomatch": "^4.0.5"
24
25
  },
25
26
  "devDependencies": {
26
27
  "@danypops/pi-extension-harness": "^0.2.0",
@@ -28,6 +29,7 @@
28
29
  "@earendil-works/pi-ai": "^0.81.1",
29
30
  "@earendil-works/pi-coding-agent": "^0.81.1",
30
31
  "@earendil-works/pi-tui": "^0.81.1",
32
+ "@types/picomatch": "^4.0.3",
31
33
  "bun-types": "latest",
32
34
  "typebox": "^1.3.6",
33
35
  "typescript": "^5.7.3"