From 7f90e6e2c2e40f6c21126f9869042b2ddf181834 Mon Sep 17 00:00:00 2001 From: ColumbusLabs <287001685+ColumbusLabs@users.noreply.github.com> Date: Sat, 6 Jun 2026 17:36:49 -0400 Subject: [PATCH] Add monorepo --package MVP for single-level workspaces. Enables scanning one npm workspace package by name with packages/* layout support. --- README.md | 1 + docs/rule-packs.md | 2 +- src/cli/index.ts | 10 ++- src/config/workspaces.ts | 83 +++++++++++++++++++ tests/fixtures/monorepo/monorepo.test.ts | 68 +++++++++++++++ tests/fixtures/monorepo/package.json | 6 ++ .../monorepo/packages/pkg-a/package.json | 5 ++ .../monorepo/packages/pkg-a/src/index.ts | 2 + .../monorepo/packages/pkg-b/package.json | 5 ++ .../monorepo/packages/pkg-b/src/index.ts | 1 + 10 files changed, 181 insertions(+), 2 deletions(-) create mode 100644 src/config/workspaces.ts create mode 100644 tests/fixtures/monorepo/monorepo.test.ts create mode 100644 tests/fixtures/monorepo/package.json create mode 100644 tests/fixtures/monorepo/packages/pkg-a/package.json create mode 100644 tests/fixtures/monorepo/packages/pkg-a/src/index.ts create mode 100644 tests/fixtures/monorepo/packages/pkg-b/package.json create mode 100644 tests/fixtures/monorepo/packages/pkg-b/src/index.ts diff --git a/README.md b/README.md index 8a66a75..c5535d0 100644 --- a/README.md +++ b/README.md @@ -118,6 +118,7 @@ Options: --respect-gitignore skip files ignored by git --config path to debtlens.config.json --cwd working directory +--package scan a single npm workspace package (MVP: `packages/*` layouts) --no-color disable terminal color -q, --quiet terminal only: suppress per-finding detail ``` diff --git a/docs/rule-packs.md b/docs/rule-packs.md index 155fffa..1223b1e 100644 --- a/docs/rule-packs.md +++ b/docs/rule-packs.md @@ -62,7 +62,7 @@ separate rule IDs yet. | `next` | App Router boundaries, server/client splits, data loading | Planned | | `node` | Express/Fastify handlers, middleware depth, route sprawl | Planned | | `expo` | Expo config and module boundaries | Planned | -| `monorepo` | Per-package configs, workspace-aware `--changed` | Planned ([#23](https://github.com/ColumbusLabs/DebtLens/issues/23)) | +| `monorepo` | `--package` for single-level npm workspaces (`packages/*`); per-package configs planned | Partial ([#23](https://github.com/ColumbusLabs/DebtLens/issues/23)) | Vue and Svelte are planned JS framework packs. See [`ROADMAP.md`](../ROADMAP.md). diff --git a/src/cli/index.ts b/src/cli/index.ts index f83ce56..4814235 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -5,6 +5,7 @@ import { Command } from "commander"; import { loadConfig } from "../config/loadConfig.js"; import { mergeConfig } from "../config/mergeConfig.js"; import { listRulePacks, RULE_PACK_IDS } from "../config/packs.js"; +import { resolveWorkspacePackage } from "../config/workspaces.js"; import { DEFAULT_BASELINE_FILENAME, applyBaseline, createBaseline, loadBaseline, writeBaseline } from "../core/baseline.js"; import { scan } from "../core/scan.js"; import { getChangedFiles, getStagedFiles } from "../utils/git.js"; @@ -46,6 +47,7 @@ program.command("scan") .option("--respect-gitignore", "skip files ignored by git") .option("--config ", "path to debtlens.config.json") .option("--cwd ", "working directory", process.cwd()) + .option("--package ", "scan a single workspace package by name") .option("--no-color", "disable ANSI color in terminal output") .option("-q, --quiet", "print only the summary line, suppress individual findings") .action(async (target: string, rawOptions: Record) => { @@ -81,7 +83,13 @@ program.command("scan") } } - const options = mergeConfig(target, fileConfig, { + let scanTarget = target; + if (rawOptions.package) { + const workspacePackage = resolveWorkspacePackage(cwd, String(rawOptions.package)); + scanTarget = workspacePackage.directory; + } + + const options = mergeConfig(scanTarget, fileConfig, { cwd, include: parseCommaList(rawOptions.include as string | undefined), exclude: parseCommaList(rawOptions.exclude as string | undefined), diff --git a/src/config/workspaces.ts b/src/config/workspaces.ts new file mode 100644 index 0000000..0a51896 --- /dev/null +++ b/src/config/workspaces.ts @@ -0,0 +1,83 @@ +import { existsSync, readFileSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import fg from "fast-glob"; + +export interface WorkspacePackage { + name: string; + directory: string; +} + +interface PackageJson { + name?: string; + workspaces?: string[] | { packages?: string[] }; +} + +/** Walk upward from cwd to find a package.json with a workspaces field. */ +export function findWorkspaceRoot(cwd: string): string | undefined { + let current = resolve(cwd); + while (true) { + const packageJsonPath = resolve(current, "package.json"); + if (existsSync(packageJsonPath)) { + const parsed = JSON.parse(readFileSync(packageJsonPath, "utf8")) as PackageJson; + if (normalizeWorkspacePatterns(parsed.workspaces).length > 0) { + return current; + } + } + const parent = dirname(current); + if (parent === current) break; + current = parent; + } + return undefined; +} + +export function listWorkspacePackages(workspaceRoot: string): WorkspacePackage[] { + const packageJsonPath = resolve(workspaceRoot, "package.json"); + const parsed = JSON.parse(readFileSync(packageJsonPath, "utf8")) as PackageJson; + const patterns = normalizeWorkspacePatterns(parsed.workspaces); + const packages: WorkspacePackage[] = []; + + for (const pattern of patterns) { + if (!pattern.endsWith("/*")) { + continue; + } + const directories = fg.sync(pattern, { + cwd: workspaceRoot, + onlyDirectories: true, + absolute: true, + }); + for (const directory of directories) { + const childPath = resolve(directory, "package.json"); + if (!existsSync(childPath)) continue; + const child = JSON.parse(readFileSync(childPath, "utf8")) as PackageJson; + if (!child.name) continue; + packages.push({ name: child.name, directory }); + } + } + + return packages.sort((left, right) => left.name.localeCompare(right.name)); +} + +export function resolveWorkspacePackage( + cwd: string, + packageName: string, +): { workspaceRoot: string; directory: string } { + const workspaceRoot = findWorkspaceRoot(cwd); + if (!workspaceRoot) { + throw new Error("No npm/yarn/pnpm workspace found from the current directory."); + } + + const packages = listWorkspacePackages(workspaceRoot); + const match = packages.find((pkg) => pkg.name === packageName); + if (!match) { + const available = packages.map((pkg) => pkg.name).join(", "); + throw new Error(`Workspace package "${packageName}" not found.${available ? ` Available: ${available}` : ""}`); + } + + return { workspaceRoot, directory: match.directory }; +} + +function normalizeWorkspacePatterns(workspaces: PackageJson["workspaces"]): string[] { + if (!workspaces) return []; + if (Array.isArray(workspaces)) return workspaces; + return workspaces.packages ?? []; +} diff --git a/tests/fixtures/monorepo/monorepo.test.ts b/tests/fixtures/monorepo/monorepo.test.ts new file mode 100644 index 0000000..8051a5a --- /dev/null +++ b/tests/fixtures/monorepo/monorepo.test.ts @@ -0,0 +1,68 @@ +import assert from "node:assert/strict"; +import { execFileSync } from "node:child_process"; +import { spawnSync } from "node:child_process"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { describe, it } from "node:test"; +import { listWorkspacePackages, resolveWorkspacePackage } from "../../../src/config/workspaces.js"; + +const repoRoot = join(dirname(fileURLToPath(import.meta.url)), "..", "..", ".."); +const fixtureRoot = join(dirname(fileURLToPath(import.meta.url))); +const cliEntrypoint = join(repoRoot, "src", "cli", "index.ts"); + +function runScan(args: string[]) { + return spawnSync(process.execPath, ["--import", "tsx", cliEntrypoint, "scan", ...args], { + cwd: repoRoot, + encoding: "utf8", + }); +} + +describe("workspace package resolution", () => { + it("lists workspace packages from a fixture monorepo", () => { + const packages = listWorkspacePackages(fixtureRoot); + assert.deepEqual(packages.map((pkg) => pkg.name), ["pkg-a", "pkg-b"]); + }); + + it("resolves a package directory by name", () => { + const resolved = resolveWorkspacePackage(fixtureRoot, "pkg-a"); + assert.match(resolved.directory, /packages\/pkg-a$/); + }); +}); + +describe("monorepo scan --package", () => { + it("scans only the selected workspace package", () => { + const result = runScan([ + ".", + "--cwd", + fixtureRoot, + "--package", + "pkg-a", + "--rules", + "todo-comment", + "--format", + "json", + ]); + const parsed = JSON.parse(result.stdout); + + assert.equal(result.status, 0); + assert.equal(parsed.summary.totalIssues, 1); + assert.match(parsed.issues[0].file, /^src\/index\.ts$/); + }); + + it("scans all workspace packages when --package is omitted", () => { + const result = runScan([ + ".", + "--cwd", + fixtureRoot, + "--rules", + "todo-comment", + "--format", + "json", + ]); + const parsed = JSON.parse(result.stdout); + + assert.equal(result.status, 0); + assert.equal(parsed.summary.totalIssues, 1); + assert.match(parsed.issues[0].file, /pkg-a/); + }); +}); diff --git a/tests/fixtures/monorepo/package.json b/tests/fixtures/monorepo/package.json new file mode 100644 index 0000000..a47a3fc --- /dev/null +++ b/tests/fixtures/monorepo/package.json @@ -0,0 +1,6 @@ +{ + "name": "debtlens-fixture-monorepo", + "private": true, + "type": "module", + "workspaces": ["packages/*"] +} diff --git a/tests/fixtures/monorepo/packages/pkg-a/package.json b/tests/fixtures/monorepo/packages/pkg-a/package.json new file mode 100644 index 0000000..b87254a --- /dev/null +++ b/tests/fixtures/monorepo/packages/pkg-a/package.json @@ -0,0 +1,5 @@ +{ + "name": "pkg-a", + "version": "1.0.0", + "private": true +} diff --git a/tests/fixtures/monorepo/packages/pkg-a/src/index.ts b/tests/fixtures/monorepo/packages/pkg-a/src/index.ts new file mode 100644 index 0000000..e44a482 --- /dev/null +++ b/tests/fixtures/monorepo/packages/pkg-a/src/index.ts @@ -0,0 +1,2 @@ +// TODO: pkg-a only marker +export const value = 1; diff --git a/tests/fixtures/monorepo/packages/pkg-b/package.json b/tests/fixtures/monorepo/packages/pkg-b/package.json new file mode 100644 index 0000000..f1b81ea --- /dev/null +++ b/tests/fixtures/monorepo/packages/pkg-b/package.json @@ -0,0 +1,5 @@ +{ + "name": "pkg-b", + "version": "1.0.0", + "private": true +} diff --git a/tests/fixtures/monorepo/packages/pkg-b/src/index.ts b/tests/fixtures/monorepo/packages/pkg-b/src/index.ts new file mode 100644 index 0000000..44439d5 --- /dev/null +++ b/tests/fixtures/monorepo/packages/pkg-b/src/index.ts @@ -0,0 +1 @@ +export const value = 2;