diff --git a/README.md b/README.md index 46b5275..2bf71b4 100644 --- a/README.md +++ b/README.md @@ -119,6 +119,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 --profile print per-rule timing to stderr without changing findings 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 e5848d5..90bc200 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, getRefSnapshot, getStagedFiles } from "../utils/git.js"; @@ -47,6 +48,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") .option("--profile", "print per-rule timing without changing findings") @@ -83,7 +85,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;