From b8db96e9fe77d1ccaa06ceedfcd0f3a574f6592d Mon Sep 17 00:00:00 2001 From: Sami Jawhar Date: Sun, 16 Aug 2026 16:55:42 +0000 Subject: [PATCH] fix: resolve workspace-member imports in installed git-dep monorepo plugins A plugin installed as a bun git dependency contains its full workspace tree but no node_modules links: bun materializes a git dep's regular npm dependencies into the host tree and skips workspace:*/file: edges. Bare imports between workspace siblings inside such a plugin therefore failed with 'Cannot find module' - the extension host's package walk only looked at node_modules dirs. Teach findNodePackageRoot to also recognize workspace roots on the walk-up: when a directory's package.json declares workspaces (array or yarn-style { packages }), scan member manifests for the requested name. node_modules candidates at the same level still win, so explicitly installed copies shadow workspace members. Exports conditions (bun -> source) apply to the member manifest as usual, so unbuilt source-only members resolve. Contract tests: a fake installed monorepo tree resolves a bare member import to the member's bun-condition source entry; an installed node_modules copy shadows the member. --- packages/coding-agent/CHANGELOG.md | 4 + .../extensibility/plugins/legacy-pi-compat.ts | 47 +++++++++ ...nsion-workspace-package-resolution.test.ts | 98 +++++++++++++++++++ 3 files changed, 149 insertions(+) create mode 100644 packages/coding-agent/test/extension-workspace-package-resolution.test.ts diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index f61f4fc3d..0395bf78d 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Fixed + +- Extension bare imports of workspace members now resolve inside installed git-dependency monorepo plugins (the walk recognizes `workspaces` roots; installed node_modules copies still shadow members) + ### Changed - Routed paid xAI models (`XAI_API_KEY` / `xai/…`) through the Responses API used by SuperGrok OAuth instead of Chat Completions. diff --git a/packages/coding-agent/src/extensibility/plugins/legacy-pi-compat.ts b/packages/coding-agent/src/extensibility/plugins/legacy-pi-compat.ts index d78a8dbe8..cd4b79bb5 100644 --- a/packages/coding-agent/src/extensibility/plugins/legacy-pi-compat.ts +++ b/packages/coding-agent/src/extensibility/plugins/legacy-pi-compat.ts @@ -1351,6 +1351,10 @@ async function findNodePackageRootUncached(packageName: string, importerPath: st if (await pathExists(path.join(candidate, "package.json"))) { return candidate; } + const workspaceMember = await findWorkspaceMemberPackageRoot(dir, packageName); + if (workspaceMember) { + return workspaceMember; + } const parent = path.dirname(dir); if (parent === dir) { return null; @@ -1359,6 +1363,49 @@ async function findNodePackageRootUncached(packageName: string, importerPath: st } } +/** + * Resolve `packageName` as a workspace member when `dir` is a workspace root. + * + * An installed git dependency of a monorepo plugin contains the full + * workspace tree but no node_modules links: `bun install` materializes a git + * dependency's regular npm dependencies into the host tree and skips its + * `workspace:*` / `file:` edges. Bare imports between workspace siblings + * therefore never resolve through the node_modules walk above. When a + * directory on that walk declares `workspaces` (array form or the yarn-style + * `{ packages: [...] }` object), scan the member manifests for the requested + * package name. node_modules candidates at the same level win, so an + * explicitly installed copy still shadows the workspace member. + */ +async function findWorkspaceMemberPackageRoot(dir: string, packageName: string): Promise { + if (!(await pathExists(path.join(dir, "package.json")))) { + return null; + } + const manifest = await readPackageManifest(dir); + const rawWorkspaces = manifest?.workspaces; + const patterns = Array.isArray(rawWorkspaces) + ? rawWorkspaces + : isRecord(rawWorkspaces) && Array.isArray(rawWorkspaces.packages) + ? rawWorkspaces.packages + : null; + if (!patterns) { + return null; + } + for (const pattern of patterns) { + if (typeof pattern !== "string" || pattern.startsWith("!")) { + continue; + } + const glob = new Bun.Glob(path.join(pattern, "package.json")); + for await (const match of glob.scan({ cwd: dir, onlyFiles: true })) { + const memberRoot = path.dirname(path.join(dir, match)); + const memberManifest = await readPackageManifest(memberRoot); + if (memberManifest?.name === packageName) { + return memberRoot; + } + } + } + return null; +} + async function readPackageManifest(packageRoot: string): Promise | null> { const cached = packageManifestCache.get(packageRoot); if (cached) return cached; diff --git a/packages/coding-agent/test/extension-workspace-package-resolution.test.ts b/packages/coding-agent/test/extension-workspace-package-resolution.test.ts new file mode 100644 index 000000000..1d618598e --- /dev/null +++ b/packages/coding-agent/test/extension-workspace-package-resolution.test.ts @@ -0,0 +1,98 @@ +import { afterEach, expect, mock, spyOn, test } from "bun:test"; +import * as fs from "node:fs/promises"; +import * as os from "node:os"; +import * as path from "node:path"; +import { __rewriteLegacyExtensionSourceForTests } from "@oh-my-pi/pi-coding-agent/extensibility/plugins/legacy-pi-compat"; +import { removeWithRetries } from "@oh-my-pi/pi-utils"; + +const tempRoots: string[] = []; + +afterEach(async () => { + mock.restore(); + for (const root of tempRoots.splice(0)) { + await removeWithRetries(root); + } +}); + +async function writeJson(filePath: string, value: unknown): Promise { + await Bun.write(filePath, `${JSON.stringify(value)}\n`); +} + +/** + * Regression: an extension inside an installed git-dep monorepo (full + * workspace tree, no node_modules links for `workspace:*` siblings) must + * resolve bare imports of workspace members through the workspace root's + * `workspaces` globs, honoring the member's exports conditions. + */ +test("bare workspace-member imports resolve through the workspace root manifest", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "omp-workspace-resolve-")); + tempRoots.push(root); + const repoRoot = path.join(root, "plugins", "node_modules", "monorepo-plugin"); + const importer = path.join(repoRoot, "packages", "extension", "extensions", "entry.ts"); + const memberRoot = path.join(repoRoot, "packages", "contracts"); + await fs.mkdir(path.dirname(importer), { recursive: true }); + await fs.mkdir(path.join(memberRoot, "src"), { recursive: true }); + await Bun.write(importer, "export {};\n"); + await writeJson(path.join(repoRoot, "package.json"), { + name: "monorepo-plugin", + version: "1.0.0", + workspaces: ["packages/*"], + }); + await writeJson(path.join(memberRoot, "package.json"), { + name: "@monorepo/contracts", + version: "1.0.0", + main: "dist/index.js", + exports: { ".": { bun: "./src/index.ts", default: "./dist/index.js" } }, + }); + await Bun.write(path.join(memberRoot, "src", "index.ts"), "export const marker = 1;\n"); + + spyOn(Bun, "resolveSync").mockImplementation(() => { + throw new Error("compiled fallback"); + }); + + const rewritten = await __rewriteLegacyExtensionSourceForTests( + 'import { marker } from "@monorepo/contracts";', + importer, + ); + + expect(rewritten).toContain(path.join("packages", "contracts", "src", "index.ts")); +}); + +test("installed node_modules copies shadow workspace members at the same level", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "omp-workspace-shadow-")); + tempRoots.push(root); + const repoRoot = path.join(root, "monorepo-plugin"); + const importer = path.join(repoRoot, "packages", "extension", "entry.ts"); + const memberRoot = path.join(repoRoot, "packages", "dep"); + const installedRoot = path.join(repoRoot, "node_modules", "@monorepo", "dep"); + await fs.mkdir(path.dirname(importer), { recursive: true }); + await fs.mkdir(memberRoot, { recursive: true }); + await fs.mkdir(installedRoot, { recursive: true }); + await Bun.write(importer, "export {};\n"); + await writeJson(path.join(repoRoot, "package.json"), { + name: "monorepo-plugin", + version: "1.0.0", + workspaces: ["packages/*"], + }); + await writeJson(path.join(memberRoot, "package.json"), { + name: "@monorepo/dep", + version: "1.0.0", + main: "member.js", + }); + await Bun.write(path.join(memberRoot, "member.js"), "export default 1;\n"); + await writeJson(path.join(installedRoot, "package.json"), { + name: "@monorepo/dep", + version: "2.0.0", + main: "installed.js", + }); + await Bun.write(path.join(installedRoot, "installed.js"), "export default 2;\n"); + + spyOn(Bun, "resolveSync").mockImplementation(() => { + throw new Error("compiled fallback"); + }); + + const rewritten = await __rewriteLegacyExtensionSourceForTests('import dep from "@monorepo/dep";', importer); + + expect(rewritten).toContain("installed.js"); + expect(rewritten).not.toContain("member.js"); +});