From bc5167ee740f9c056cf12f3f1acb66cfb12af1d2 Mon Sep 17 00:00:00 2001 From: Asish Kumar Date: Sun, 12 Apr 2026 05:05:03 +0000 Subject: [PATCH] fix(cli): detect workspace dependencies in skills suggest --- .changeset/quiet-dragons-suggest.md | 5 + packages/cli/src/__tests__/deps.test.ts | 149 +++++++++++++++++ packages/cli/src/utils/deps.ts | 213 +++++++++++++++++++++++- 3 files changed, 364 insertions(+), 3 deletions(-) create mode 100644 .changeset/quiet-dragons-suggest.md create mode 100644 packages/cli/src/__tests__/deps.test.ts diff --git a/.changeset/quiet-dragons-suggest.md b/.changeset/quiet-dragons-suggest.md new file mode 100644 index 000000000..af5805d63 --- /dev/null +++ b/.changeset/quiet-dragons-suggest.md @@ -0,0 +1,5 @@ +--- +"ctx7": patch +--- + +Detect JavaScript workspace package dependencies when running `ctx7 skills suggest` from a monorepo root. diff --git a/packages/cli/src/__tests__/deps.test.ts b/packages/cli/src/__tests__/deps.test.ts new file mode 100644 index 000000000..f18ad7c2c --- /dev/null +++ b/packages/cli/src/__tests__/deps.test.ts @@ -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 { + 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"]); + }); +}); diff --git a/packages/cli/src/utils/deps.ts b/packages/cli/src/utils/deps.ts index 18d48e504..ade088a55 100644 --- a/packages/cli/src/utils/deps.ts +++ b/packages/cli/src/utils/deps.ts @@ -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 { try { @@ -35,6 +35,210 @@ async function parsePackageJson(cwd: string): Promise { } } +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 { + const content = await readFileOrNull(join(cwd, "pnpm-workspace.yaml")); + if (!content) return []; + return parsePnpmWorkspacePatterns(content); +} + +async function getWorkspacePatternsFromPackageJson(cwd: string): Promise { + 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 { + 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 { + 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(); + + 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 { const content = await readFileOrNull(join(cwd, "requirements.txt")); if (!content) return []; @@ -98,5 +302,8 @@ export async function detectProjectDependencies(cwd: string): Promise 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()])]; }