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
5 changes: 5 additions & 0 deletions .changeset/quiet-dragons-suggest.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"ctx7": patch
---

Detect JavaScript workspace package dependencies when running `ctx7 skills suggest` from a monorepo root.
149 changes: 149 additions & 0 deletions packages/cli/src/__tests__/deps.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
import { describe, test, expect, beforeEach, afterEach } from "vitest";
import { mkdtemp, mkdir, rm, writeFile } from "fs/promises";
import { dirname, join } from "path";
import { tmpdir } from "os";

import { detectProjectDependencies } from "../utils/deps.js";

async function writeJson(path: string, value: unknown): Promise<void> {
await mkdir(dirname(path), { recursive: true });
await writeFile(path, JSON.stringify(value, null, 2), "utf-8");
}

describe("detectProjectDependencies", () => {
let tempDir: string;

beforeEach(async () => {
tempDir = await mkdtemp(join(tmpdir(), "ctx7-deps-test-"));
});

afterEach(async () => {
await rm(tempDir, { recursive: true, force: true });
});

test("reads root JavaScript and Python dependency files", async () => {
await writeJson(join(tempDir, "package.json"), {
dependencies: {
react: "^19.0.0",
"@types/node": "^22.0.0",
},
devDependencies: {
vitest: "^4.0.0",
},
});
await writeFile(
join(tempDir, "requirements.txt"),
"fastapi==0.115.0\n# ignored\n-r other.txt\nrequests>=2\n",
"utf-8"
);
await writeFile(
join(tempDir, "pyproject.toml"),
'[project]\ndependencies = ["pydantic>=2", "fastapi>=0.115"]\n',
"utf-8"
);

await expect(detectProjectDependencies(tempDir)).resolves.toEqual([
"react",
"vitest",
"fastapi",
"requests",
"pydantic",
]);
});

test("aggregates dependencies from pnpm workspace packages", async () => {
await writeJson(join(tempDir, "package.json"), {
private: true,
devDependencies: {
turbo: "^2.0.0",
typescript: "^5.7.0",
},
});
await writeFile(
join(tempDir, "pnpm-workspace.yaml"),
[
"packages:",
' - "packages/*"',
" - apps/* # app packages",
"",
"catalog:",
' react: "^19.0.0"',
].join("\n"),
"utf-8"
);

await writeJson(join(tempDir, "packages", "api", "package.json"), {
dependencies: {
zod: "catalog:",
"@orpc/server": "^1.0.0",
"drizzle-orm": "^0.38.0",
},
});
await writeJson(join(tempDir, "packages", "db", "package.json"), {
dependencies: {
"drizzle-orm": "^0.38.0",
},
devDependencies: {
"drizzle-kit": "^0.30.0",
},
});
await writeJson(join(tempDir, "apps", "web", "package.json"), {
dependencies: {
react: "catalog:",
"@tanstack/react-query": "^5.0.0",
},
});

await expect(detectProjectDependencies(tempDir)).resolves.toEqual([
"turbo",
"typescript",
"zod",
"@orpc/server",
"drizzle-orm",
"drizzle-kit",
"react",
"@tanstack/react-query",
]);
});

test("supports package.json workspace arrays and excludes negated patterns", async () => {
await writeJson(join(tempDir, "package.json"), {
workspaces: ["packages/*", "!packages/ignored"],
dependencies: {
next: "^15.0.0",
},
});
await writeJson(join(tempDir, "packages", "kept", "package.json"), {
dependencies: {
prisma: "^6.0.0",
},
});
await writeJson(join(tempDir, "packages", "ignored", "package.json"), {
dependencies: {
shouldNotAppear: "^1.0.0",
},
});
await writeJson(join(tempDir, "packages", "kept", "nested", "package.json"), {
dependencies: {
alsoIgnored: "^1.0.0",
},
});

await expect(detectProjectDependencies(tempDir)).resolves.toEqual(["next", "prisma"]);
});

test("supports package.json workspaces object syntax", async () => {
await writeJson(join(tempDir, "package.json"), {
workspaces: {
packages: ["tools/*"],
},
});
await writeJson(join(tempDir, "tools", "cli", "package.json"), {
dependencies: {
commander: "^13.0.0",
},
});

await expect(detectProjectDependencies(tempDir)).resolves.toEqual(["commander"]);
});
});
213 changes: 210 additions & 3 deletions packages/cli/src/utils/deps.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { readFile } from "fs/promises";
import { join } from "path";
import { readdir, readFile } from "fs/promises";
import { join, relative, resolve, sep } from "path";

async function readFileOrNull(path: string): Promise<string | null> {
try {
Expand Down Expand Up @@ -35,6 +35,210 @@ async function parsePackageJson(cwd: string): Promise<string[]> {
}
}

function stripInlineComment(value: string): string {
let quote: string | null = null;
for (let i = 0; i < value.length; i++) {
const char = value[i];
if ((char === '"' || char === "'") && value[i - 1] !== "\\") {
quote = quote === char ? null : (quote ?? char);
}
if (char === "#" && !quote) {
return value.slice(0, i).trim();
}
}
return value.trim();
}

function parseYamlStringList(value: string): string[] {
const trimmed = stripInlineComment(value).trim();
if (!trimmed) return [];

if (trimmed.startsWith("[") && trimmed.endsWith("]")) {
return trimmed
.slice(1, -1)
.split(",")
.map((item) => item.trim().replace(/^["']|["']$/g, ""))
.filter(Boolean);
}

return [trimmed.replace(/^["']|["']$/g, "")].filter(Boolean);
}

function parsePnpmWorkspacePatterns(content: string): string[] {
const patterns: string[] = [];
const lines = content.split("\n");
let packagesIndent: number | null = null;

for (const line of lines) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith("#")) continue;

const indent = line.length - line.trimStart().length;
if (packagesIndent === null) {
const match = trimmed.match(/^packages\s*:\s*(.*)$/);
if (!match) continue;

packagesIndent = indent;
patterns.push(...parseYamlStringList(match[1]));
continue;
}

if (indent <= packagesIndent && !trimmed.startsWith("-")) {
break;
}

if (trimmed.startsWith("- ")) {
patterns.push(...parseYamlStringList(trimmed.slice(2)));
}
}

return patterns;
}

async function getWorkspacePatternsFromPnpm(cwd: string): Promise<string[]> {
const content = await readFileOrNull(join(cwd, "pnpm-workspace.yaml"));
if (!content) return [];
return parsePnpmWorkspacePatterns(content);
}

async function getWorkspacePatternsFromPackageJson(cwd: string): Promise<string[]> {
const content = await readFileOrNull(join(cwd, "package.json"));
if (!content) return [];

try {
const pkg = JSON.parse(content) as {
workspaces?: string[] | { packages?: string[] };
};
if (Array.isArray(pkg.workspaces)) return pkg.workspaces;
if (Array.isArray(pkg.workspaces?.packages)) return pkg.workspaces.packages;
} catch {
return [];
}

return [];
}

function normalizeWorkspacePattern(pattern: string): string {
return pattern
.replace(/\\/g, "/")
.replace(/^\.?\//, "")
.replace(/\/package\.json$/, "")
.replace(/\/+$/, "");
}

function getPatternBaseDir(cwd: string, pattern: string): string {
const normalized = normalizeWorkspacePattern(pattern);
const parts = normalized.split("/");
const globIndex = parts.findIndex((part) => part.includes("*"));
const baseParts = globIndex === -1 ? parts : parts.slice(0, globIndex);
return resolve(cwd, baseParts.length === 0 ? "." : baseParts.join("/"));
}

function escapeRegex(value: string): string {
return value.replace(/[|\\{}()[\]^$+?.]/g, "\\$&");
}

function workspacePatternToRegex(pattern: string): RegExp {
const normalized = normalizeWorkspacePattern(pattern);
const segments = normalized.split("/").filter(Boolean);
let source = "^";

if (segments.length === 0) {
source += "$";
return new RegExp(source);
}

segments.forEach((segment, index) => {
if (index > 0) source += "/";

if (segment === "**") {
source += "(?:[^/]+/)*[^/]*";
return;
}

let segmentSource = "";
for (const char of segment) {
segmentSource += char === "*" ? "[^/]*" : escapeRegex(char);
}
source += segmentSource;
});

source += "$";
return new RegExp(source);
}

async function findPackageJsonDirs(dir: string): Promise<string[]> {
let entries;
try {
entries = await readdir(dir, { withFileTypes: true });
} catch {
return [];
}

const dirs: string[] = [];
if (entries.some((entry) => entry.isFile() && entry.name === "package.json")) {
dirs.push(dir);
}

const ignoredDirs = new Set([
".git",
".hg",
".svn",
"node_modules",
"dist",
"build",
"coverage",
".next",
".nuxt",
".turbo",
".vercel",
]);

const childDirs = entries.filter((entry) => entry.isDirectory() && !ignoredDirs.has(entry.name));
const childResults = await Promise.all(
childDirs.map((entry) => findPackageJsonDirs(join(dir, entry.name)))
);

return [...dirs, ...childResults.flat()];
}

async function getWorkspaceDirs(cwd: string): Promise<string[]> {
const rawPatterns = [
...(await getWorkspacePatternsFromPnpm(cwd)),
...(await getWorkspacePatternsFromPackageJson(cwd)),
];

const includePatterns = rawPatterns
.filter((pattern) => !pattern.trim().startsWith("!"))
.map(normalizeWorkspacePattern)
.filter(Boolean);
const excludePatterns = rawPatterns
.filter((pattern) => pattern.trim().startsWith("!"))
.map((pattern) => normalizeWorkspacePattern(pattern.trim().slice(1)))
.filter(Boolean);

if (includePatterns.length === 0) return [];

const includeRegexes = includePatterns.map(workspacePatternToRegex);
const excludeRegexes = excludePatterns.map(workspacePatternToRegex);
const searchRoots = [
...new Set(includePatterns.map((pattern) => getPatternBaseDir(cwd, pattern))),
];

const candidates = (await Promise.all(searchRoots.map(findPackageJsonDirs))).flat();
const workspaceDirs = new Set<string>();

for (const candidate of candidates) {
const rel = relative(cwd, candidate).split(sep).join("/");
if (!rel || rel.startsWith("..")) continue;
if (!includeRegexes.some((regex) => regex.test(rel))) continue;
if (excludeRegexes.some((regex) => regex.test(rel))) continue;
workspaceDirs.add(candidate);
}

return [...workspaceDirs];
}

async function parseRequirementsTxt(cwd: string): Promise<string[]> {
const content = await readFileOrNull(join(cwd, "requirements.txt"));
if (!content) return [];
Expand Down Expand Up @@ -98,5 +302,8 @@ export async function detectProjectDependencies(cwd: string): Promise<string[]>
parsePyprojectToml(cwd),
]);

return [...new Set(results.flat())];
const workspaceDirs = await getWorkspaceDirs(cwd);
const workspaceResults = await Promise.all(workspaceDirs.map((dir) => parsePackageJson(dir)));

return [...new Set([...results.flat(), ...workspaceResults.flat()])];
}