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
This commit is contained in:
Jean-Dominique Stepek 2026-03-27 11:55:00 -04:00 committed by GitHub
parent 2bc92afa6b
commit 67f78a7314
6 changed files with 223 additions and 5 deletions

View file

@ -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");
});
});

View file

@ -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<string, unknown>;
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,
};

View file

@ -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[] = [];

View file

@ -33,6 +33,7 @@ interface ProjectDetectionSignals {
hasCargo?: boolean
hasGoMod?: boolean
hasPyproject?: boolean
isMonorepo?: boolean
}
interface ProjectProgressInfo {
@ -64,6 +65,7 @@ const KIND_STYLE: Record<ProjectDetectionKind, { label: string; color: string; i
function techStack(signals: ProjectDetectionSignals): string[] {
const tags: string[] = []
if (signals.isMonorepo) tags.push("Monorepo")
if (signals.hasGitRepo) tags.push("Git")
if (signals.hasPackageJson) tags.push("Node.js")
if (signals.hasCargo) tags.push("Rust")

View file

@ -65,6 +65,7 @@ interface ProjectDetectionSignals {
hasCargo?: boolean
hasGoMod?: boolean
hasPyproject?: boolean
isMonorepo?: boolean
}
interface ProjectProgressInfo {
@ -121,6 +122,7 @@ const KIND_STYLE: Record<ProjectDetectionKind, { label: string; color: string; b
function techStack(signals: ProjectDetectionSignals): string[] {
const tags: string[] = []
if (signals.isMonorepo) tags.push("Monorepo")
if (signals.hasGitRepo) tags.push("Git")
if (signals.hasPackageJson) tags.push("Node.js")
if (signals.hasCargo) tags.push("Rust")

View file

@ -314,6 +314,7 @@ export interface ProjectDetectionSignals {
hasPlanningFolder: boolean
hasGitRepo: boolean
hasPackageJson: boolean
isMonorepo?: boolean
fileCount: number
}