Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
136 changes: 136 additions & 0 deletions src/analyzer/__tests__/scopeDetector.monorepo.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import * as fs from "fs";
import * as os from "os";
import * as path from "path";
import {
isMonorepo,
detectMonorepoPackage,
detectScope,
} from "../scopeDetector";

describe("isMonorepo", () => {
let tmpDir: string;

beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "gitbun-test-"));
});

afterEach(() => {
fs.rmSync(tmpDir, { recursive: true, force: true });
});

it("returns true when nx.json is present", () => {
fs.writeFileSync(path.join(tmpDir, "nx.json"), "{}");
expect(isMonorepo(tmpDir)).toBe(true);
});

it("returns true when lerna.json is present", () => {
fs.writeFileSync(path.join(tmpDir, "lerna.json"), "{}");
expect(isMonorepo(tmpDir)).toBe(true);
});

it("returns true when turbo.json is present", () => {
fs.writeFileSync(path.join(tmpDir, "turbo.json"), "{}");
expect(isMonorepo(tmpDir)).toBe(true);
});

it("returns true when workspace.yaml is present", () => {
fs.writeFileSync(path.join(tmpDir, "workspace.yaml"), "packages:\n - apps/*");
expect(isMonorepo(tmpDir)).toBe(true);
});

it("returns true when pnpm-workspace.yaml is present", () => {
fs.writeFileSync(path.join(tmpDir, "pnpm-workspace.yaml"), "packages:\n - packages/*");
expect(isMonorepo(tmpDir)).toBe(true);
});

it("returns false when no monorepo marker is present", () => {
expect(isMonorepo(tmpDir)).toBe(false);
});
});

describe("detectMonorepoPackage", () => {
it("extracts package name from apps/", () => {
expect(detectMonorepoPackage("apps/web/src/auth/login.ts")).toBe("web");
});

it("extracts package name from packages/", () => {
expect(detectMonorepoPackage("packages/ui/Button.tsx")).toBe("ui");
});

it("extracts package name from libs/", () => {
expect(detectMonorepoPackage("libs/shared/utils/index.ts")).toBe("shared");
});

it("extracts package name from services/", () => {
expect(detectMonorepoPackage("services/auth-service/src/handler.ts")).toBe("auth-service");
});

it("returns null for paths with no monorepo root segment", () => {
expect(detectMonorepoPackage("src/analyzer/scopeDetector.ts")).toBeNull();
});

it("returns null for a bare filename", () => {
expect(detectMonorepoPackage("package.json")).toBeNull();
});

it("only detects monorepo packages at root level (index 0)", () => {
expect(detectMonorepoPackage("apps/web/src/index.ts")).toBe("web");
expect(detectMonorepoPackage("packages/ui/button.ts")).toBe("ui");
expect(detectMonorepoPackage("libs/shared/util.ts")).toBe("shared");
expect(detectMonorepoPackage("services/auth-service/index.ts")).toBe("auth-service");
});

it("returns null for nested monorepo segments (false positive prevention)", () => {
expect(detectMonorepoPackage("src/components/apps/web/button.ts")).toBeNull();
expect(detectMonorepoPackage("src/packages/ui/index.ts")).toBeNull();
expect(detectMonorepoPackage("random/libs/shared/test.ts")).toBeNull();
expect(detectMonorepoPackage("foo/bar/apps/web/index.ts")).toBeNull();
expect(detectMonorepoPackage("deep/nested/packages/core/util.ts")).toBeNull();
});

it("returns null when monorepo segment is at index 0 but has no package name", () => {
expect(detectMonorepoPackage("apps/")).toBeNull();
expect(detectMonorepoPackage("packages/")).toBeNull();
expect(detectMonorepoPackage("libs")).toBeNull();
});
});

describe("detectScope with filesystem-confirmed monorepo", () => {
let tmpDir: string;

beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "gitbun-mono-"));
});

afterEach(() => {
fs.rmSync(tmpDir, { recursive: true, force: true });
});

it("uses monorepo package scope when nx.json is present", () => {
fs.writeFileSync(path.join(tmpDir, "nx.json"), "{}");
const files = ["apps/dashboard/src/index.ts"];
expect(detectScope(files, tmpDir)).toBe("dashboard");
});

it("uses monorepo package scope when turbo.json is present", () => {
fs.writeFileSync(path.join(tmpDir, "turbo.json"), "{}");
const files = ["packages/config/index.ts", "packages/config/tsconfig.json"];
expect(detectScope(files, tmpDir)).toBe("config");
});

it("falls back to standard scope detection when rootDir is not a monorepo and no monorepo path pattern", () => {
const files = ["src/analyzer/scopeDetector.ts"];
expect(detectScope(files, tmpDir)).toBe("analyzer");
});

it("picks the most frequent monorepo package when files span multiple packages", () => {
fs.writeFileSync(path.join(tmpDir, "nx.json"), "{}");
const files = [
"apps/api/routes.ts",
"apps/api/server.ts",
"apps/web/pages/index.tsx",
];
expect(detectScope(files, tmpDir)).toBe("api");
});
});
63 changes: 58 additions & 5 deletions src/analyzer/scopeDetector.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,40 @@
import * as fs from "fs";
import * as path from "path";
import { getLanguageProfile } from "./languageAnalyzer";
import path from 'path';

const MONOREPO_MARKERS = [
"nx.json",
"lerna.json",
"turbo.json",
"workspace.yaml",
"pnpm-workspace.yaml",
];

const MONOREPO_ROOT_SEGMENTS = [
"apps",
"packages",
"libs",
"services",
];

export function isMonorepo(rootDir: string): boolean {
return MONOREPO_MARKERS.some((marker) =>
fs.existsSync(path.join(rootDir, marker))
);
}

export function detectMonorepoPackage(
file: string
): string | null {
const parts = file.split("/");
const firstSegment = parts[0];

if (MONOREPO_ROOT_SEGMENTS.includes(firstSegment) && parts[1]) {
return parts[1];
}

return null;
}

function detectFileScope(
file: string
Expand All @@ -25,18 +60,36 @@ const parts = normalizedFile.split(path.sep);
return null;
}

function hasMonorepoPathPattern(filePaths: string[]): boolean {
return filePaths.some((file) => {
const firstSegment = file.split("/")[0];
return MONOREPO_ROOT_SEGMENTS.includes(firstSegment);
});
}

export function detectScope(
filePaths: string[]
filePaths: string[],
rootDir: string = process.cwd()
): string {
const scopes: Record<string, number> = {};

const monorepoActive =
isMonorepo(rootDir) || hasMonorepoPathPattern(filePaths);

for (const file of filePaths) {
const scope = detectFileScope(file);
let scope: string | null = null;

if (monorepoActive) {
scope = detectMonorepoPackage(file);
}

if (!scope) {
scope = detectFileScope(file);
}

if (!scope) continue;

scopes[scope] =
(scopes[scope] || 0) + 1;
scopes[scope] = (scopes[scope] || 0) + 1;
}

if (Object.keys(scopes).length === 0) {
Expand Down
29 changes: 29 additions & 0 deletions src/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,35 @@ describe('detectScope', () => {
const files = ['src/analyzer/f1.ts', 'src/ui/f2.ts', 'src/ui/f3.ts'];
expect(detectScope(files)).toBe('ui');
});

it('should detect monorepo scope from apps/ directory', () => {
const files = ['apps/web/src/auth/login.ts'];
expect(detectScope(files)).toBe('web');
});

it('should detect monorepo scope from packages/ directory', () => {
const files = ['packages/ui/Button.tsx', 'packages/ui/Modal.tsx'];
expect(detectScope(files)).toBe('ui');
});

it('should detect monorepo scope from libs/ directory', () => {
const files = ['libs/shared/utils/index.ts'];
expect(detectScope(files)).toBe('shared');
});

it('should detect monorepo scope from services/ directory', () => {
const files = ['services/auth-service/src/index.ts'];
expect(detectScope(files)).toBe('auth-service');
});

it('should pick the most frequent monorepo package scope', () => {
const files = [
'libs/shared/utils.ts',
'apps/api/server.ts',
'apps/api/routes.ts',
];
expect(detectScope(files)).toBe('api');
});
});

describe('Errors', () => {
Expand Down
Loading