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:
parent
2bc92afa6b
commit
67f78a7314
6 changed files with 223 additions and 5 deletions
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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[] = [];
|
||||
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -314,6 +314,7 @@ export interface ProjectDetectionSignals {
|
|||
hasPlanningFolder: boolean
|
||||
hasGitRepo: boolean
|
||||
hasPackageJson: boolean
|
||||
isMonorepo?: boolean
|
||||
fileCount: number
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue