Skip to content
Merged
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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,7 @@ Options:
--respect-gitignore skip files ignored by git
--config <path> path to debtlens.config.json
--cwd <path> working directory
--package <name> 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
Expand Down
2 changes: 1 addition & 1 deletion docs/rule-packs.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).

Expand Down
10 changes: 9 additions & 1 deletion src/cli/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -47,6 +48,7 @@ program.command("scan")
.option("--respect-gitignore", "skip files ignored by git")
.option("--config <path>", "path to debtlens.config.json")
.option("--cwd <path>", "working directory", process.cwd())
.option("--package <name>", "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")
Expand Down Expand Up @@ -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),
Expand Down
83 changes: 83 additions & 0 deletions src/config/workspaces.ts
Original file line number Diff line number Diff line change
@@ -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 ?? [];
}
68 changes: 68 additions & 0 deletions tests/fixtures/monorepo/monorepo.test.ts
Original file line number Diff line number Diff line change
@@ -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/);
});
});
6 changes: 6 additions & 0 deletions tests/fixtures/monorepo/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"name": "debtlens-fixture-monorepo",
"private": true,
"type": "module",
"workspaces": ["packages/*"]
}
5 changes: 5 additions & 0 deletions tests/fixtures/monorepo/packages/pkg-a/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"name": "pkg-a",
"version": "1.0.0",
"private": true
}
2 changes: 2 additions & 0 deletions tests/fixtures/monorepo/packages/pkg-a/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
// TODO: pkg-a only marker
export const value = 1;
5 changes: 5 additions & 0 deletions tests/fixtures/monorepo/packages/pkg-b/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"name": "pkg-b",
"version": "1.0.0",
"private": true
}
1 change: 1 addition & 0 deletions tests/fixtures/monorepo/packages/pkg-b/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export const value = 2;