From 67f78a73142652ccdc81ed8ba95527a9419fc090 Mon Sep 17 00:00:00 2001 From: Jean-Dominique Stepek Date: Fri, 27 Mar 2026 11:55:00 -0400 Subject: [PATCH] fix: detect monorepo roots in project discovery to prevent workspace fragmentation (#2849) When devRoot pointed at a monorepo, discoverProjects scanned one level deep and listed each workspace/package as a separate project. Now it checks for monorepo markers (pnpm-workspace.yaml, lerna.json, turbo.json, nx.json, rush.json, package.json workspaces) before scanning children. If the root is a monorepo, it returns it as a single project entry. - Add detectMonorepo() to bridge-service with support for 6 monorepo formats - Add isMonorepo signal to ProjectDetectionSignals - Update discoverProjects to short-circuit when root is a monorepo - Show 'Monorepo' tag in project list UI - Add 24 tests covering all monorepo detection scenarios --- .../web-project-discovery-contract.test.ts | 151 +++++++++++++++++- src/web/bridge-service.ts | 44 +++++ src/web/project-discovery-service.ts | 28 +++- .../gsd/onboarding/step-project.tsx | 2 + web/components/gsd/projects-view.tsx | 2 + web/lib/gsd-workspace-store.tsx | 1 + 6 files changed, 223 insertions(+), 5 deletions(-) diff --git a/src/tests/web-project-discovery-contract.test.ts b/src/tests/web-project-discovery-contract.test.ts index 351a75426..cd2c52fdd 100644 --- a/src/tests/web-project-discovery-contract.test.ts +++ b/src/tests/web-project-discovery-contract.test.ts @@ -2,12 +2,13 @@ import test, { after, describe } from "node:test"; import assert from "node:assert/strict"; import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; -import { join } from "node:path"; +import { basename, join } from "node:path"; import { discoverProjects } from "../web/project-discovery-service.ts"; +import { detectMonorepo } from "../web/bridge-service.ts"; // --------------------------------------------------------------------------- -// Fixture setup +// Fixture setup — standard multi-project root // --------------------------------------------------------------------------- const tempRoot = mkdtempSync(join(tmpdir(), "gsd-project-discovery-")); @@ -38,16 +39,73 @@ mkdirSync(join(tempRoot, ".hidden")); // node_modules: should be excluded mkdirSync(join(tempRoot, "node_modules")); +// --------------------------------------------------------------------------- +// Fixture setup — monorepo roots +// --------------------------------------------------------------------------- + +// monorepo-pnpm: detected via pnpm-workspace.yaml +const monorepoPnpm = mkdtempSync(join(tmpdir(), "gsd-mono-pnpm-")); +mkdirSync(join(monorepoPnpm, ".git")); +writeFileSync(join(monorepoPnpm, "package.json"), '{"name":"my-monorepo"}'); +writeFileSync(join(monorepoPnpm, "pnpm-workspace.yaml"), 'packages:\n - "packages/*"'); +mkdirSync(join(monorepoPnpm, "packages")); +mkdirSync(join(monorepoPnpm, "packages", "pkg-a")); +mkdirSync(join(monorepoPnpm, "packages", "pkg-b")); + +// monorepo-lerna: detected via lerna.json +const monorepoLerna = mkdtempSync(join(tmpdir(), "gsd-mono-lerna-")); +mkdirSync(join(monorepoLerna, ".git")); +writeFileSync(join(monorepoLerna, "package.json"), '{"name":"lerna-mono"}'); +writeFileSync(join(monorepoLerna, "lerna.json"), '{"version":"1.0.0"}'); +mkdirSync(join(monorepoLerna, "backend")); +mkdirSync(join(monorepoLerna, "frontend")); + +// monorepo-workspaces: detected via package.json workspaces field +const monorepoWorkspaces = mkdtempSync(join(tmpdir(), "gsd-mono-ws-")); +mkdirSync(join(monorepoWorkspaces, ".git")); +writeFileSync(join(monorepoWorkspaces, "package.json"), '{"name":"ws-mono","workspaces":["packages/*"]}'); +mkdirSync(join(monorepoWorkspaces, "packages")); +mkdirSync(join(monorepoWorkspaces, "packages", "core")); +mkdirSync(join(monorepoWorkspaces, "packages", "ui")); + +// monorepo-turbo: detected via turbo.json +const monorepoTurbo = mkdtempSync(join(tmpdir(), "gsd-mono-turbo-")); +mkdirSync(join(monorepoTurbo, ".git")); +writeFileSync(join(monorepoTurbo, "package.json"), '{"name":"turbo-mono"}'); +writeFileSync(join(monorepoTurbo, "turbo.json"), '{"pipeline":{}}'); +mkdirSync(join(monorepoTurbo, "apps")); +mkdirSync(join(monorepoTurbo, "packages")); + +// monorepo-nx: detected via nx.json +const monorepoNx = mkdtempSync(join(tmpdir(), "gsd-mono-nx-")); +mkdirSync(join(monorepoNx, ".git")); +writeFileSync(join(monorepoNx, "package.json"), '{"name":"nx-mono"}'); +writeFileSync(join(monorepoNx, "nx.json"), '{}'); +mkdirSync(join(monorepoNx, "libs")); +mkdirSync(join(monorepoNx, "apps")); + +// non-monorepo: plain project with package.json (no workspaces, no marker files) +const plainProject = mkdtempSync(join(tmpdir(), "gsd-plain-project-")); +mkdirSync(join(plainProject, ".git")); +writeFileSync(join(plainProject, "package.json"), '{"name":"plain","dependencies":{}}'); +mkdirSync(join(plainProject, "src")); + // --------------------------------------------------------------------------- // Teardown // --------------------------------------------------------------------------- after(() => { rmSync(tempRoot, { recursive: true, force: true }); + rmSync(monorepoPnpm, { recursive: true, force: true }); + rmSync(monorepoLerna, { recursive: true, force: true }); + rmSync(monorepoWorkspaces, { recursive: true, force: true }); + rmSync(monorepoTurbo, { recursive: true, force: true }); + rmSync(monorepoNx, { recursive: true, force: true }); + rmSync(plainProject, { recursive: true, force: true }); }); // --------------------------------------------------------------------------- -// Tests +// Tests — standard multi-project root // --------------------------------------------------------------------------- describe("project-discovery", () => { @@ -122,3 +180,90 @@ describe("project-discovery", () => { assert.deepStrictEqual(results, []); }); }); + +// --------------------------------------------------------------------------- +// Tests — monorepo detection +// --------------------------------------------------------------------------- + +describe("detectMonorepo", () => { + test("detects pnpm-workspace.yaml", () => { + assert.ok(detectMonorepo(monorepoPnpm)); + }); + + test("detects lerna.json", () => { + assert.ok(detectMonorepo(monorepoLerna)); + }); + + test("detects package.json with workspaces field", () => { + assert.ok(detectMonorepo(monorepoWorkspaces)); + }); + + test("detects turbo.json", () => { + assert.ok(detectMonorepo(monorepoTurbo)); + }); + + test("detects nx.json", () => { + assert.ok(detectMonorepo(monorepoNx)); + }); + + test("does not detect plain project as monorepo", () => { + assert.ok(!detectMonorepo(plainProject)); + }); + + test("does not detect empty directory as monorepo", () => { + assert.ok(!detectMonorepo(tempRoot)); + }); +}); + +// --------------------------------------------------------------------------- +// Tests — monorepo root as devRoot returns single entry +// --------------------------------------------------------------------------- + +describe("project-discovery with monorepo root as devRoot", () => { + test("pnpm monorepo root returns single project entry", () => { + const results = discoverProjects(monorepoPnpm); + assert.equal(results.length, 1, `Expected 1 project, got ${results.length}: ${results.map(r => r.name).join(", ")}`); + assert.equal(results[0].path, monorepoPnpm); + assert.equal(results[0].name, basename(monorepoPnpm)); + assert.equal(results[0].signals.isMonorepo, true); + }); + + test("lerna monorepo root returns single project entry", () => { + const results = discoverProjects(monorepoLerna); + assert.equal(results.length, 1); + assert.equal(results[0].path, monorepoLerna); + assert.equal(results[0].signals.isMonorepo, true); + }); + + test("npm/yarn workspaces monorepo root returns single project entry", () => { + const results = discoverProjects(monorepoWorkspaces); + assert.equal(results.length, 1); + assert.equal(results[0].path, monorepoWorkspaces); + assert.equal(results[0].signals.isMonorepo, true); + }); + + test("turbo monorepo root returns single project entry", () => { + const results = discoverProjects(monorepoTurbo); + assert.equal(results.length, 1); + assert.equal(results[0].path, monorepoTurbo); + }); + + test("nx monorepo root returns single project entry", () => { + const results = discoverProjects(monorepoNx); + assert.equal(results.length, 1); + assert.equal(results[0].path, monorepoNx); + }); + + test("plain project (not monorepo) scans children normally", () => { + // plainProject has .git, package.json, src/ — not a monorepo + // Should scan children: just "src" + const results = discoverProjects(plainProject); + assert.ok(results.length >= 1, "should scan children for non-monorepo"); + assert.ok(results.some(r => r.name === "src"), "should find src directory"); + }); + + test("monorepo entry has correct kind (brownfield when no .gsd)", () => { + const results = discoverProjects(monorepoPnpm); + assert.equal(results[0].kind, "brownfield"); + }); +}); diff --git a/src/web/bridge-service.ts b/src/web/bridge-service.ts index c355086e8..f1faac3aa 100644 --- a/src/web/bridge-service.ts +++ b/src/web/bridge-service.ts @@ -526,6 +526,8 @@ export interface ProjectDetectionSignals { hasCargo?: boolean; hasGoMod?: boolean; hasPyproject?: boolean; + /** True when the directory looks like a monorepo root (workspaces, lerna, pnpm-workspace, etc.) */ + isMonorepo?: boolean; fileCount: number; } @@ -534,6 +536,46 @@ export interface ProjectDetection { signals: ProjectDetectionSignals; } +/** + * Detect whether a directory looks like a monorepo root. + * + * Checks for common monorepo indicators: + * - `pnpm-workspace.yaml` (pnpm workspaces) + * - `lerna.json` (Lerna) + * - `package.json` with a `workspaces` field (npm/yarn workspaces) + * - `rush.json` (Rush) + * - `nx.json` (Nx) + * - `turbo.json` (Turborepo) + * + * This is intentionally cheap — file existence checks only, with a single + * JSON parse for `package.json` workspaces (which we're already reading + * in many code paths). No deep directory scanning. + */ +export function detectMonorepo(dirPath: string, checkExists?: (path: string) => boolean): boolean { + const exists = checkExists ?? (getBridgeDeps().existsSync ?? existsSync); + + // Fast checks — file existence only + if (exists(join(dirPath, "pnpm-workspace.yaml"))) return true; + if (exists(join(dirPath, "lerna.json"))) return true; + if (exists(join(dirPath, "rush.json"))) return true; + if (exists(join(dirPath, "nx.json"))) return true; + if (exists(join(dirPath, "turbo.json"))) return true; + + // Check package.json for workspaces field (npm/yarn workspaces) + const packageJsonPath = join(dirPath, "package.json"); + if (exists(packageJsonPath)) { + try { + const raw = readFileSync(packageJsonPath, "utf-8"); + const pkg = JSON.parse(raw) as Record; + if (pkg.workspaces != null) return true; + } catch { + // Malformed JSON or unreadable — not a monorepo indicator + } + } + + return false; +} + export function detectProjectKind(projectCwd: string): ProjectDetection { const checkExists = getBridgeDeps().existsSync ?? existsSync; @@ -544,6 +586,7 @@ export function detectProjectKind(projectCwd: string): ProjectDetection { const hasCargo = checkExists(join(projectCwd, "Cargo.toml")); const hasGoMod = checkExists(join(projectCwd, "go.mod")); const hasPyproject = checkExists(join(projectCwd, "pyproject.toml")); + const isMonorepo = detectMonorepo(projectCwd, checkExists); // Count top-level non-dot entries (cheap heuristic for "has code") let fileCount = 0; @@ -562,6 +605,7 @@ export function detectProjectKind(projectCwd: string): ProjectDetection { hasCargo, hasGoMod, hasPyproject, + isMonorepo, fileCount, }; diff --git a/src/web/project-discovery-service.ts b/src/web/project-discovery-service.ts index c2b450e6c..86c468de4 100644 --- a/src/web/project-discovery-service.ts +++ b/src/web/project-discovery-service.ts @@ -1,7 +1,7 @@ import { readdirSync, readFileSync, statSync } from "node:fs"; -import { join } from "node:path"; +import { basename, join } from "node:path"; import type { ProjectDetectionKind, ProjectDetectionSignals } from "./bridge-service.ts"; -import { detectProjectKind } from "./bridge-service.ts"; +import { detectMonorepo, detectProjectKind } from "./bridge-service.ts"; // ─── Project Discovery ───────────────────────────────────────────────────── @@ -72,11 +72,35 @@ export function readProjectProgress(projectPath: string): ProjectProgressInfo | * discovered project directory. Hidden dirs (starting with `.`), `node_modules`, * and `.git` are excluded. * + * **Monorepo detection:** If `devRootPath` itself looks like a project root + * (has `.git`, `package.json`, monorepo markers like `pnpm-workspace.yaml` / + * `lerna.json` / `workspaces` in `package.json`), it is returned as a single + * project entry instead of scanning its children. This prevents monorepo + * subdirectories from being listed as independent projects. + * * Returns an empty array if `devRootPath` doesn't exist or isn't readable. * Results are sorted alphabetically by name. */ export function discoverProjects(devRootPath: string, includeProgress?: boolean): ProjectMetadata[] { try { + // ── Check if the root itself is a project/monorepo ────────────── + // If the devRoot has a .git repo AND looks like a monorepo (pnpm-workspace, + // lerna, workspaces, etc.) or looks like a standalone project root (has + // .gsd, or is a recognizable project), return it as a single entry. + const rootDetection = detectProjectKind(devRootPath); + if (rootDetection.signals.isMonorepo) { + const stat = statSync(devRootPath); + return [{ + name: basename(devRootPath), + path: devRootPath, + kind: rootDetection.kind, + signals: rootDetection.signals, + lastModified: stat.mtimeMs, + ...(includeProgress ? { progress: readProjectProgress(devRootPath) } : {}), + }]; + } + + // ── Standard multi-project scan ───────────────────────────────── const entries = readdirSync(devRootPath, { withFileTypes: true }); const projects: ProjectMetadata[] = []; diff --git a/web/components/gsd/onboarding/step-project.tsx b/web/components/gsd/onboarding/step-project.tsx index 6b783c2b5..6eeba3696 100644 --- a/web/components/gsd/onboarding/step-project.tsx +++ b/web/components/gsd/onboarding/step-project.tsx @@ -33,6 +33,7 @@ interface ProjectDetectionSignals { hasCargo?: boolean hasGoMod?: boolean hasPyproject?: boolean + isMonorepo?: boolean } interface ProjectProgressInfo { @@ -64,6 +65,7 @@ const KIND_STYLE: Record