From d6344f460a7461dd9b0f1a3945ed1f7f634a1804 Mon Sep 17 00:00:00 2001 From: LadyBluenotes Date: Mon, 23 Mar 2026 10:27:19 -0700 Subject: [PATCH 1/6] feat: implement workspace patterns management and package resolution --- packages/intent/src/setup.ts | 156 ++--------------- packages/intent/src/workspace-patterns.ts | 159 ++++++++++++++++++ .../intent/tests/workspace-patterns.test.ts | 66 ++++++++ 3 files changed, 237 insertions(+), 144 deletions(-) create mode 100644 packages/intent/src/workspace-patterns.ts create mode 100644 packages/intent/tests/workspace-patterns.test.ts diff --git a/packages/intent/src/setup.ts b/packages/intent/src/setup.ts index 0a2ae23..7cae774 100644 --- a/packages/intent/src/setup.ts +++ b/packages/intent/src/setup.ts @@ -6,8 +6,18 @@ import { writeFileSync, } from 'node:fs' import { basename, join, relative } from 'node:path' -import { parse as parseYaml } from 'yaml' -import { findSkillFiles } from './utils.js' +import { + findPackagesWithSkills, + findWorkspaceRoot, + readWorkspacePatterns, +} from './workspace-patterns.js' + +export { + findPackagesWithSkills, + findWorkspaceRoot, + readWorkspacePatterns, + resolveWorkspacePackages, +} from './workspace-patterns.js' // --------------------------------------------------------------------------- // Types @@ -351,148 +361,6 @@ export function runEditPackageJson(root: string): EditPackageJsonResult { return result } -// --------------------------------------------------------------------------- -// Monorepo workspace resolution -// --------------------------------------------------------------------------- - -export function readWorkspacePatterns(root: string): Array | null { - // pnpm-workspace.yaml - const pnpmWs = join(root, 'pnpm-workspace.yaml') - if (existsSync(pnpmWs)) { - try { - const config = parseYaml(readFileSync(pnpmWs, 'utf8')) as Record< - string, - unknown - > - if (Array.isArray(config.packages)) { - return config.packages as Array - } - } catch (err: unknown) { - console.error( - `Warning: failed to parse ${pnpmWs}: ${err instanceof Error ? err.message : err}`, - ) - } - } - - // package.json workspaces - const pkgPath = join(root, 'package.json') - if (existsSync(pkgPath)) { - try { - const pkg = JSON.parse(readFileSync(pkgPath, 'utf8')) - if (Array.isArray(pkg.workspaces)) { - return pkg.workspaces - } - if (Array.isArray(pkg.workspaces?.packages)) { - return pkg.workspaces.packages - } - } catch (err: unknown) { - console.error( - `Warning: failed to parse ${pkgPath}: ${err instanceof Error ? err.message : err}`, - ) - } - } - - return null -} - -/** - * Resolve workspace glob patterns to actual package directories. - * Handles simple patterns like "packages/*" and "packages/**". - * Each resolved directory must contain a package.json. - */ -export function resolveWorkspacePackages( - root: string, - patterns: Array, -): Array { - const dirs: Array = [] - - for (const pattern of patterns) { - // Strip trailing /* or /**/* for directory resolution - const base = pattern.replace(/\/\*\*?(\/\*)?$/, '') - const baseDir = join(root, base) - if (!existsSync(baseDir)) continue - - if (pattern.includes('**')) { - // Recursive: walk all subdirectories - collectPackageDirs(baseDir, dirs) - } else if (pattern.endsWith('/*')) { - // Single level: direct children - let entries: Array - try { - entries = readdirSync(baseDir, { withFileTypes: true }) - } catch { - continue - } - for (const entry of entries) { - if (!entry.isDirectory()) continue - const dir = join(baseDir, entry.name) - if (existsSync(join(dir, 'package.json'))) { - dirs.push(dir) - } - } - } else { - // Exact path - const dir = join(root, pattern) - if (existsSync(join(dir, 'package.json'))) { - dirs.push(dir) - } - } - } - - return dirs -} - -function collectPackageDirs(dir: string, result: Array): void { - if (existsSync(join(dir, 'package.json'))) { - result.push(dir) - } - let entries: Array - try { - entries = readdirSync(dir, { withFileTypes: true }) - } catch (err: unknown) { - console.error( - `Warning: could not read directory ${dir}: ${err instanceof Error ? err.message : err}`, - ) - return - } - for (const entry of entries) { - if ( - !entry.isDirectory() || - entry.name === 'node_modules' || - entry.name.startsWith('.') - ) - continue - collectPackageDirs(join(dir, entry.name), result) - } -} - -export function findWorkspaceRoot(start: string): string | null { - let dir = start - - while (true) { - if (readWorkspacePatterns(dir)) { - return dir - } - - const next = join(dir, '..') - if (next === dir) return null - dir = next - } -} - -/** - * Find workspace packages that contain at least one SKILL.md file. - */ -export function findPackagesWithSkills(root: string): Array { - const patterns = readWorkspacePatterns(root) - if (!patterns) return [] - - return resolveWorkspacePackages(root, patterns).filter((dir) => { - const skillsDir = join(dir, 'skills') - return existsSync(skillsDir) && findSkillFiles(skillsDir).length > 0 - }) -} - // --------------------------------------------------------------------------- // Monorepo-aware command runner // --------------------------------------------------------------------------- diff --git a/packages/intent/src/workspace-patterns.ts b/packages/intent/src/workspace-patterns.ts new file mode 100644 index 0000000..5ce2596 --- /dev/null +++ b/packages/intent/src/workspace-patterns.ts @@ -0,0 +1,159 @@ +import { existsSync, readFileSync, readdirSync, type Dirent } from 'node:fs' +import { join } from 'node:path' +import { parse as parseYaml } from 'yaml' +import { findSkillFiles } from './utils.js' + +function normalizeWorkspacePattern(pattern: string): string { + return pattern.replace(/\\/g, '/').replace(/^\.\//, '').replace(/\/+$/, '') +} + +export function normalizeWorkspacePatterns( + patterns: Array, +): Array { + return [ + ...new Set(patterns.map(normalizeWorkspacePattern).filter(Boolean)), + ].sort((a, b) => a.localeCompare(b)) +} + +function parseWorkspacePatterns(value: unknown): Array | null { + if (!Array.isArray(value)) { + return null + } + + return normalizeWorkspacePatterns( + value.filter((pattern): pattern is string => typeof pattern === 'string'), + ) +} + +function hasPackageJson(dir: string): boolean { + return existsSync(join(dir, 'package.json')) +} + +export function readWorkspacePatterns(root: string): Array | null { + const pnpmWs = join(root, 'pnpm-workspace.yaml') + if (existsSync(pnpmWs)) { + try { + const config = parseYaml(readFileSync(pnpmWs, 'utf8')) as Record< + string, + unknown + > + const patterns = parseWorkspacePatterns(config.packages) + if (patterns) { + return patterns + } + } catch (err: unknown) { + console.error( + `Warning: failed to parse ${pnpmWs}: ${err instanceof Error ? err.message : err}`, + ) + } + } + + const pkgPath = join(root, 'package.json') + if (existsSync(pkgPath)) { + try { + const pkg = JSON.parse(readFileSync(pkgPath, 'utf8')) + const workspaces = parseWorkspacePatterns(pkg.workspaces) + if (workspaces) { + return workspaces + } + + const workspacePackages = parseWorkspacePatterns(pkg.workspaces?.packages) + if (workspacePackages) { + return workspacePackages + } + } catch (err: unknown) { + console.error( + `Warning: failed to parse ${pkgPath}: ${err instanceof Error ? err.message : err}`, + ) + } + } + + return null +} + +export function resolveWorkspacePackages( + root: string, + patterns: Array, +): Array { + const dirs = new Set() + + for (const pattern of normalizeWorkspacePatterns(patterns)) { + const base = pattern.replace(/\/\*\*?(\/\*)?$/, '') + const baseDir = join(root, base) + if (!existsSync(baseDir)) continue + + if (pattern.includes('**')) { + collectPackageDirs(baseDir, dirs) + } else if (pattern.endsWith('/*')) { + let entries: Array + try { + entries = readdirSync(baseDir, { withFileTypes: true }) + } catch { + continue + } + for (const entry of entries) { + if (!entry.isDirectory()) continue + const dir = join(baseDir, entry.name) + if (hasPackageJson(dir)) { + dirs.add(dir) + } + } + } else { + const dir = join(root, pattern) + if (hasPackageJson(dir)) { + dirs.add(dir) + } + } + } + + return [...dirs].sort((a, b) => a.localeCompare(b)) +} + +function collectPackageDirs(dir: string, result: Set): void { + if (hasPackageJson(dir)) { + result.add(dir) + } + let entries: Array + try { + entries = readdirSync(dir, { withFileTypes: true }) + } catch (err: unknown) { + console.error( + `Warning: could not read directory ${dir}: ${err instanceof Error ? err.message : err}`, + ) + return + } + for (const entry of entries) { + if ( + !entry.isDirectory() || + entry.name === 'node_modules' || + entry.name.startsWith('.') + ) { + continue + } + collectPackageDirs(join(dir, entry.name), result) + } +} + +export function findWorkspaceRoot(start: string): string | null { + let dir = start + + while (true) { + if (readWorkspacePatterns(dir)) { + return dir + } + + const next = join(dir, '..') + if (next === dir) return null + dir = next + } +} + +export function findPackagesWithSkills(root: string): Array { + const patterns = readWorkspacePatterns(root) + if (!patterns) return [] + + return resolveWorkspacePackages(root, patterns).filter((dir) => { + const skillsDir = join(dir, 'skills') + return existsSync(skillsDir) && findSkillFiles(skillsDir).length > 0 + }) +} diff --git a/packages/intent/tests/workspace-patterns.test.ts b/packages/intent/tests/workspace-patterns.test.ts new file mode 100644 index 0000000..9e874c3 --- /dev/null +++ b/packages/intent/tests/workspace-patterns.test.ts @@ -0,0 +1,66 @@ +import { mkdirSync, mkdtempSync, writeFileSync, rmSync } from 'node:fs' +import { join } from 'node:path' +import { tmpdir } from 'node:os' +import { afterEach, describe, expect, it } from 'vitest' +import { + normalizeWorkspacePatterns, + resolveWorkspacePackages, +} from '../src/workspace-patterns.js' + +const roots: Array = [] + +function createRoot(): string { + const root = mkdtempSync(join(tmpdir(), 'workspace-patterns-test-')) + roots.push(root) + return root +} + +function writePackage(root: string, ...parts: Array): void { + const dir = join(root, ...parts) + mkdirSync(dir, { recursive: true }) + writeFileSync( + join(dir, 'package.json'), + JSON.stringify({ name: parts.join('/') }), + ) +} + +afterEach(() => { + for (const root of roots.splice(0)) { + rmSync(root, { recursive: true, force: true }) + } +}) + +describe('normalizeWorkspacePatterns', () => { + it('normalizes, drops empty patterns, dedupes, and sorts them', () => { + expect( + normalizeWorkspacePatterns([ + '', + './apps/*/packages/*/', + './packages/*/', + 'apps/*', + 'packages\\*', + 'apps/*', + ]), + ).toEqual(['apps/*', 'apps/*/packages/*', 'packages/*']) + }) +}) + +describe('resolveWorkspacePackages', () => { + it('dedupes and sorts resolved package roots for simple patterns', () => { + const root = createRoot() + + writePackage(root, 'packages', 'b-lib') + writePackage(root, 'packages', 'a-lib') + + expect( + resolveWorkspacePackages(root, [ + './packages/*/', + 'packages\\*', + 'packages/*', + ]), + ).toEqual([ + join(root, 'packages', 'a-lib'), + join(root, 'packages', 'b-lib'), + ]) + }) +}) From 9147184bf7ce4a1c8c9f1270456fe39ca2748848 Mon Sep 17 00:00:00 2001 From: LadyBluenotes Date: Mon, 23 Mar 2026 10:45:58 -0700 Subject: [PATCH 2/6] feat: refactor workspace patterns handling and improve package resolution logic --- packages/intent/src/cli-support.ts | 2 +- packages/intent/src/scanner.ts | 2 +- packages/intent/src/workspace-patterns.ts | 93 +++++++++-------- .../intent/tests/workspace-patterns.test.ts | 99 ++++++++++++++++--- 4 files changed, 138 insertions(+), 58 deletions(-) diff --git a/packages/intent/src/cli-support.ts b/packages/intent/src/cli-support.ts index 1535d40..84db99e 100644 --- a/packages/intent/src/cli-support.ts +++ b/packages/intent/src/cli-support.ts @@ -60,7 +60,7 @@ export async function resolveStaleTargets( } const { findPackagesWithSkills, findWorkspaceRoot } = - await import('./setup.js') + await import('./workspace-patterns.js') const workspaceRoot = findWorkspaceRoot(resolvedRoot) if (workspaceRoot) { const packageDirs = findPackagesWithSkills(workspaceRoot) diff --git a/packages/intent/src/scanner.ts b/packages/intent/src/scanner.ts index f61e129..ca15745 100644 --- a/packages/intent/src/scanner.ts +++ b/packages/intent/src/scanner.ts @@ -11,7 +11,7 @@ import { findWorkspaceRoot, readWorkspacePatterns, resolveWorkspacePackages, -} from './setup.js' +} from './workspace-patterns.js' import type { InstalledVariant, IntentConfig, diff --git a/packages/intent/src/workspace-patterns.ts b/packages/intent/src/workspace-patterns.ts index 5ce2596..d6bbe99 100644 --- a/packages/intent/src/workspace-patterns.ts +++ b/packages/intent/src/workspace-patterns.ts @@ -7,9 +7,7 @@ function normalizeWorkspacePattern(pattern: string): string { return pattern.replace(/\\/g, '/').replace(/^\.\//, '').replace(/\/+$/, '') } -export function normalizeWorkspacePatterns( - patterns: Array, -): Array { +function normalizeWorkspacePatterns(patterns: Array): Array { return [ ...new Set(patterns.map(normalizeWorkspacePattern).filter(Boolean)), ].sort((a, b) => a.localeCompare(b)) @@ -78,41 +76,51 @@ export function resolveWorkspacePackages( const dirs = new Set() for (const pattern of normalizeWorkspacePatterns(patterns)) { - const base = pattern.replace(/\/\*\*?(\/\*)?$/, '') - const baseDir = join(root, base) - if (!existsSync(baseDir)) continue - - if (pattern.includes('**')) { - collectPackageDirs(baseDir, dirs) - } else if (pattern.endsWith('/*')) { - let entries: Array - try { - entries = readdirSync(baseDir, { withFileTypes: true }) - } catch { - continue - } - for (const entry of entries) { - if (!entry.isDirectory()) continue - const dir = join(baseDir, entry.name) - if (hasPackageJson(dir)) { - dirs.add(dir) - } - } - } else { - const dir = join(root, pattern) - if (hasPackageJson(dir)) { - dirs.add(dir) - } - } + resolveWorkspacePatternSegments(root, pattern.split('/'), dirs) } return [...dirs].sort((a, b) => a.localeCompare(b)) } -function collectPackageDirs(dir: string, result: Set): void { - if (hasPackageJson(dir)) { - result.add(dir) +function resolveWorkspacePatternSegments( + dir: string, + segments: Array, + result: Set, +): void { + if (segments.length === 0) { + if (hasPackageJson(dir)) { + result.add(dir) + } + return + } + + const segment = segments[0]! + const remainingSegments = segments.slice(1) + + if (segment === '**') { + resolveWorkspacePatternSegments(dir, remainingSegments, result) + for (const childDir of readChildDirectories(dir)) { + resolveWorkspacePatternSegments(childDir, segments, result) + } + return + } + + if (segment === '*') { + for (const childDir of readChildDirectories(dir)) { + resolveWorkspacePatternSegments(childDir, remainingSegments, result) + } + return } + + const nextDir = join(dir, segment) + if (!existsSync(nextDir)) { + return + } + + resolveWorkspacePatternSegments(nextDir, remainingSegments, result) +} + +function readChildDirectories(dir: string): Array { let entries: Array try { entries = readdirSync(dir, { withFileTypes: true }) @@ -120,18 +128,17 @@ function collectPackageDirs(dir: string, result: Set): void { console.error( `Warning: could not read directory ${dir}: ${err instanceof Error ? err.message : err}`, ) - return - } - for (const entry of entries) { - if ( - !entry.isDirectory() || - entry.name === 'node_modules' || - entry.name.startsWith('.') - ) { - continue - } - collectPackageDirs(join(dir, entry.name), result) + return [] } + + return entries + .filter( + (entry) => + entry.isDirectory() && + entry.name !== 'node_modules' && + !entry.name.startsWith('.'), + ) + .map((entry) => join(dir, entry.name)) } export function findWorkspaceRoot(start: string): string | null { diff --git a/packages/intent/tests/workspace-patterns.test.ts b/packages/intent/tests/workspace-patterns.test.ts index 9e874c3..ecdb5da 100644 --- a/packages/intent/tests/workspace-patterns.test.ts +++ b/packages/intent/tests/workspace-patterns.test.ts @@ -1,9 +1,9 @@ -import { mkdirSync, mkdtempSync, writeFileSync, rmSync } from 'node:fs' +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' import { join } from 'node:path' import { tmpdir } from 'node:os' import { afterEach, describe, expect, it } from 'vitest' import { - normalizeWorkspacePatterns, + readWorkspacePatterns, resolveWorkspacePackages, } from '../src/workspace-patterns.js' @@ -24,24 +24,39 @@ function writePackage(root: string, ...parts: Array): void { ) } +function writeDir(root: string, ...parts: Array): void { + mkdirSync(join(root, ...parts), { recursive: true }) +} + afterEach(() => { for (const root of roots.splice(0)) { rmSync(root, { recursive: true, force: true }) } }) -describe('normalizeWorkspacePatterns', () => { +describe('readWorkspacePatterns', () => { it('normalizes, drops empty patterns, dedupes, and sorts them', () => { - expect( - normalizeWorkspacePatterns([ - '', - './apps/*/packages/*/', - './packages/*/', - 'apps/*', - 'packages\\*', - 'apps/*', - ]), - ).toEqual(['apps/*', 'apps/*/packages/*', 'packages/*']) + const root = createRoot() + + writeFileSync( + join(root, 'package.json'), + JSON.stringify({ + workspaces: [ + '', + './apps/*/packages/*/', + './packages/*/', + 'apps/*', + 'packages\\*', + 'apps/*', + ], + }), + ) + + expect(readWorkspacePatterns(root)).toEqual([ + 'apps/*', + 'apps/*/packages/*', + 'packages/*', + ]) }) }) @@ -63,4 +78,62 @@ describe('resolveWorkspacePackages', () => { join(root, 'packages', 'b-lib'), ]) }) + + it('resolves nested workspace patterns segment by segment', () => { + const root = createRoot() + + writePackage(root, 'apps', 'mobile', 'packages', 'native') + writePackage(root, 'apps', 'web', 'packages', 'app') + writePackage(root, 'apps', 'web', 'packages', 'ui') + writeDir(root, 'apps', 'docs', 'packages', 'guides') + + expect(resolveWorkspacePackages(root, ['apps/*/packages/*'])).toEqual([ + join(root, 'apps', 'mobile', 'packages', 'native'), + join(root, 'apps', 'web', 'packages', 'app'), + join(root, 'apps', 'web', 'packages', 'ui'), + ]) + }) + + it('treats normalized nested patterns identically', () => { + const root = createRoot() + + writePackage(root, 'apps', 'web', 'packages', 'app') + + expect( + resolveWorkspacePackages(root, [ + './apps/*/packages/*/', + 'apps\\*\\packages\\*', + ]), + ).toEqual([join(root, 'apps', 'web', 'packages', 'app')]) + }) + + it('supports recursive ** segments across multiple directory levels', () => { + const root = createRoot() + + writePackage(root, 'apps', 'mobile', 'packages', 'native') + writePackage(root, 'apps', 'web', 'features', 'packages', 'charts') + writePackage(root, 'apps', 'web', 'packages', 'app') + writeDir(root, 'apps', 'web', 'drafts', 'packages', 'notes') + + expect(resolveWorkspacePackages(root, ['apps/**/packages/*'])).toEqual([ + join(root, 'apps', 'mobile', 'packages', 'native'), + join(root, 'apps', 'web', 'features', 'packages', 'charts'), + join(root, 'apps', 'web', 'packages', 'app'), + ]) + }) + + it('ignores nonexistent literal segments and directories without package.json', () => { + const root = createRoot() + + writePackage(root, 'apps', 'web', 'packages', 'app') + writeDir(root, 'apps', 'web', 'packages', 'docs') + + expect( + resolveWorkspacePackages(root, [ + '', + 'apps/admin/packages/*', + 'apps/web/packages/*', + ]), + ).toEqual([join(root, 'apps', 'web', 'packages', 'app')]) + }) }) From 6317916a2ca162ed3f0c69940f4d04885d6231df Mon Sep 17 00:00:00 2001 From: LadyBluenotes Date: Mon, 23 Mar 2026 11:16:20 -0700 Subject: [PATCH 3/6] changeset --- .changeset/busy-peas-fail.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/busy-peas-fail.md diff --git a/.changeset/busy-peas-fail.md b/.changeset/busy-peas-fail.md new file mode 100644 index 0000000..e86d7cf --- /dev/null +++ b/.changeset/busy-peas-fail.md @@ -0,0 +1,5 @@ +--- +'@tanstack/intent': patch +--- + +Fix workspace package discovery for nested glob patterns, including support for `*` and `**`. Workspace patterns and resolved package roots are now normalized, deduped, and sorted, and the shared resolver has been extracted for reuse by internal workspace scanning. From 29fa591b35f2070801689a07455a16994961703d Mon Sep 17 00:00:00 2001 From: Sarah Gerrard Date: Mon, 23 Mar 2026 11:37:24 -0700 Subject: [PATCH 4/6] Update packages/intent/src/workspace-patterns.ts Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- packages/intent/src/workspace-patterns.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/intent/src/workspace-patterns.ts b/packages/intent/src/workspace-patterns.ts index d6bbe99..5c741e6 100644 --- a/packages/intent/src/workspace-patterns.ts +++ b/packages/intent/src/workspace-patterns.ts @@ -1,4 +1,5 @@ -import { existsSync, readFileSync, readdirSync, type Dirent } from 'node:fs' +import { existsSync, readFileSync, readdirSync } from 'node:fs' +import type { Dirent } from 'node:fs' import { join } from 'node:path' import { parse as parseYaml } from 'yaml' import { findSkillFiles } from './utils.js' From 087f114878aecfc9dc60bacd6d18d5b4e681bfcd Mon Sep 17 00:00:00 2001 From: LadyBluenotes Date: Mon, 23 Mar 2026 11:41:28 -0700 Subject: [PATCH 5/6] feat: enhance workspace patterns handling with exclusion support and add related tests --- packages/intent/src/workspace-patterns.ts | 18 +++++- .../intent/tests/workspace-patterns.test.ts | 56 +++++++++++++++++++ 2 files changed, 71 insertions(+), 3 deletions(-) diff --git a/packages/intent/src/workspace-patterns.ts b/packages/intent/src/workspace-patterns.ts index d6bbe99..bc93b25 100644 --- a/packages/intent/src/workspace-patterns.ts +++ b/packages/intent/src/workspace-patterns.ts @@ -73,13 +73,25 @@ export function resolveWorkspacePackages( root: string, patterns: Array, ): Array { - const dirs = new Set() + const includedDirs = new Set() + const excludedDirs = new Set() for (const pattern of normalizeWorkspacePatterns(patterns)) { - resolveWorkspacePatternSegments(root, pattern.split('/'), dirs) + if (pattern.startsWith('!')) { + resolveWorkspacePatternSegments( + root, + pattern.slice(1).split('/'), + excludedDirs, + ) + continue + } + + resolveWorkspacePatternSegments(root, pattern.split('/'), includedDirs) } - return [...dirs].sort((a, b) => a.localeCompare(b)) + return [...includedDirs] + .filter((dir) => !excludedDirs.has(dir)) + .sort((a, b) => a.localeCompare(b)) } function resolveWorkspacePatternSegments( diff --git a/packages/intent/tests/workspace-patterns.test.ts b/packages/intent/tests/workspace-patterns.test.ts index ecdb5da..9176d38 100644 --- a/packages/intent/tests/workspace-patterns.test.ts +++ b/packages/intent/tests/workspace-patterns.test.ts @@ -3,11 +3,14 @@ import { join } from 'node:path' import { tmpdir } from 'node:os' import { afterEach, describe, expect, it } from 'vitest' import { + findPackagesWithSkills, + findWorkspaceRoot, readWorkspacePatterns, resolveWorkspacePackages, } from '../src/workspace-patterns.js' const roots: Array = [] +const cwdStack: Array = [] function createRoot(): string { const root = mkdtempSync(join(tmpdir(), 'workspace-patterns-test-')) @@ -29,11 +32,20 @@ function writeDir(root: string, ...parts: Array): void { } afterEach(() => { + if (cwdStack.length > 0) { + process.chdir(cwdStack.pop()!) + } + for (const root of roots.splice(0)) { rmSync(root, { recursive: true, force: true }) } }) +function withCwd(dir: string): void { + cwdStack.push(process.cwd()) + process.chdir(dir) +} + describe('readWorkspacePatterns', () => { it('normalizes, drops empty patterns, dedupes, and sorts them', () => { const root = createRoot() @@ -136,4 +148,48 @@ describe('resolveWorkspacePackages', () => { ]), ).toEqual([join(root, 'apps', 'web', 'packages', 'app')]) }) + + it('applies exclusion patterns from pnpm-workspace.yaml', () => { + const root = createRoot() + + writePackage(root, 'packages', 'alpha') + writePackage(root, 'packages', 'excluded') + + expect( + resolveWorkspacePackages(root, ['packages/*', '!packages/excluded']), + ).toEqual([join(root, 'packages', 'alpha')]) + }) +}) + +describe('workspace helpers', () => { + it('resolves pnpm workspace roots and returns only packages with skills', () => { + const root = createRoot() + + writeFileSync( + join(root, 'pnpm-workspace.yaml'), + ['packages:', ' - packages/*', " - '!packages/excluded'"].join('\n'), + ) + writePackage(root, 'packages', 'alpha') + writePackage(root, 'packages', 'beta') + writePackage(root, 'packages', 'excluded') + writeDir(root, 'packages', 'alpha', 'skills', 'core', 'setup') + writeDir(root, 'packages', 'excluded', 'skills', 'core', 'setup') + writeFileSync( + join(root, 'packages', 'alpha', 'skills', 'core', 'setup', 'SKILL.md'), + '# alpha skill\n', + ) + writeFileSync( + join(root, 'packages', 'excluded', 'skills', 'core', 'setup', 'SKILL.md'), + '# excluded skill\n', + ) + + const nestedDir = join(root, 'packages', 'alpha', 'src', 'nested') + writeDir(root, 'packages', 'alpha', 'src', 'nested') + withCwd(nestedDir) + + expect(findWorkspaceRoot(process.cwd())).toBe(root) + expect(findPackagesWithSkills(root)).toEqual([ + join(root, 'packages', 'alpha'), + ]) + }) }) From 5f38aa72e324edba6bde7088eca363b4aabd6749 Mon Sep 17 00:00:00 2001 From: Kyle Mathews Date: Tue, 24 Mar 2026 18:30:31 -0600 Subject: [PATCH 6/6] fix: address review findings for workspace patterns extraction - Return null from parseWorkspacePatterns when normalized result is empty (prevents empty workspace configs from being treated as valid monorepo roots) - Replace empty catch {} in setup.ts monorepo detection with console.error warning - Add absolute-path guard to findWorkspaceRoot - Add JSDoc to all exported functions in workspace-patterns.ts - Add tests: pnpm-workspace.yaml reading, YAML precedence, Yarn classic format, null-return for non-workspace dirs, exclusion normalization, corrupted parent package.json warning Co-Authored-By: Claude Opus 4.6 --- packages/intent/src/setup.ts | 4 +- packages/intent/src/workspace-patterns.ts | 12 ++- packages/intent/tests/setup.test.ts | 25 +++++- .../intent/tests/workspace-patterns.test.ts | 84 ++++++++++++++++++- 4 files changed, 119 insertions(+), 6 deletions(-) diff --git a/packages/intent/src/setup.ts b/packages/intent/src/setup.ts index 7cae774..874486f 100644 --- a/packages/intent/src/setup.ts +++ b/packages/intent/src/setup.ts @@ -330,7 +330,9 @@ export function runEditPackageJson(root: string): EditPackageJsonResult { if (Array.isArray(parent.workspaces) || parent.workspaces?.packages) { return true } - } catch {} + } catch (err) { + console.error(`Warning: could not read ${parentPkg}: ${err instanceof Error ? err.message : err}`) + } return false } const next = join(dir, '..') diff --git a/packages/intent/src/workspace-patterns.ts b/packages/intent/src/workspace-patterns.ts index 9bc90ea..8e4dd24 100644 --- a/packages/intent/src/workspace-patterns.ts +++ b/packages/intent/src/workspace-patterns.ts @@ -1,6 +1,6 @@ import { existsSync, readFileSync, readdirSync } from 'node:fs' import type { Dirent } from 'node:fs' -import { join } from 'node:path' +import { isAbsolute, join } from 'node:path' import { parse as parseYaml } from 'yaml' import { findSkillFiles } from './utils.js' @@ -19,15 +19,17 @@ function parseWorkspacePatterns(value: unknown): Array | null { return null } - return normalizeWorkspacePatterns( + const normalized = normalizeWorkspacePatterns( value.filter((pattern): pattern is string => typeof pattern === 'string'), ) + return normalized.length > 0 ? normalized : null } function hasPackageJson(dir: string): boolean { return existsSync(join(dir, 'package.json')) } +/** Reads workspace patterns from pnpm-workspace.yaml (preferred) or package.json workspaces. Returns null if no workspace config is found. */ export function readWorkspacePatterns(root: string): Array | null { const pnpmWs = join(root, 'pnpm-workspace.yaml') if (existsSync(pnpmWs)) { @@ -70,6 +72,7 @@ export function readWorkspacePatterns(root: string): Array | null { return null } +/** Resolves workspace glob patterns to package directories. Supports `*`, `**`, and `!` exclusion patterns. */ export function resolveWorkspacePackages( root: string, patterns: Array, @@ -154,7 +157,11 @@ function readChildDirectories(dir: string): Array { .map((entry) => join(dir, entry.name)) } +/** Walks up from `start` to find the nearest directory with a workspace config. */ export function findWorkspaceRoot(start: string): string | null { + if (!isAbsolute(start)) { + throw new Error(`findWorkspaceRoot requires an absolute path, got: ${start}`) + } let dir = start while (true) { @@ -168,6 +175,7 @@ export function findWorkspaceRoot(start: string): string | null { } } +/** Finds workspace packages that contain at least one SKILL.md file. */ export function findPackagesWithSkills(root: string): Array { const patterns = readWorkspacePatterns(root) if (!patterns) return [] diff --git a/packages/intent/tests/setup.test.ts b/packages/intent/tests/setup.test.ts index 0e37ad2..27c20b9 100644 --- a/packages/intent/tests/setup.test.ts +++ b/packages/intent/tests/setup.test.ts @@ -8,7 +8,7 @@ import { } from 'node:fs' import { join } from 'node:path' import { tmpdir } from 'node:os' -import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { runEditPackageJson, runEditPackageJsonAll, @@ -185,6 +185,29 @@ describe('runEditPackageJson', () => { rmSync(monoRoot, { recursive: true, force: true }) }) + it('warns when parent package.json is corrupted during monorepo detection', () => { + const monoRoot = mkdtempSync(join(tmpdir(), 'mono-root-')) + const pkgDir = join(monoRoot, 'packages', 'my-lib') + mkdirSync(pkgDir, { recursive: true }) + writeFileSync(join(monoRoot, 'package.json'), '{invalid json') + writeFileSync( + join(pkgDir, 'package.json'), + JSON.stringify({ name: '@scope/my-lib' }, null, 2), + ) + + const spy = vi.spyOn(console, 'error').mockImplementation(() => {}) + try { + runEditPackageJson(pkgDir) + expect(spy).toHaveBeenCalledWith( + expect.stringContaining(join(monoRoot, 'package.json')), + ) + } finally { + spy.mockRestore() + } + + rmSync(monoRoot, { recursive: true, force: true }) + }) + it('preserves 4-space indentation', () => { writeFileSync( join(root, 'package.json'), diff --git a/packages/intent/tests/workspace-patterns.test.ts b/packages/intent/tests/workspace-patterns.test.ts index 9176d38..2c90ddc 100644 --- a/packages/intent/tests/workspace-patterns.test.ts +++ b/packages/intent/tests/workspace-patterns.test.ts @@ -1,4 +1,4 @@ -import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from 'node:fs' import { join } from 'node:path' import { tmpdir } from 'node:os' import { afterEach, describe, expect, it } from 'vitest' @@ -13,7 +13,7 @@ const roots: Array = [] const cwdStack: Array = [] function createRoot(): string { - const root = mkdtempSync(join(tmpdir(), 'workspace-patterns-test-')) + const root = realpathSync(mkdtempSync(join(tmpdir(), 'workspace-patterns-test-'))) roots.push(root) return root } @@ -70,6 +70,59 @@ describe('readWorkspacePatterns', () => { 'packages/*', ]) }) + + it('reads and normalizes patterns from pnpm-workspace.yaml', () => { + const root = createRoot() + + writeFileSync( + join(root, 'pnpm-workspace.yaml'), + 'packages:\n - ./packages/*/\n - apps/*\n - ./packages/*/\n', + ) + + expect(readWorkspacePatterns(root)).toEqual(['apps/*', 'packages/*']) + }) + + it('prefers pnpm-workspace.yaml over package.json workspaces', () => { + const root = createRoot() + + writeFileSync( + join(root, 'pnpm-workspace.yaml'), + 'packages:\n - libs/*\n', + ) + writeFileSync( + join(root, 'package.json'), + JSON.stringify({ workspaces: ['packages/*'] }), + ) + + expect(readWorkspacePatterns(root)).toEqual(['libs/*']) + }) + + it('reads patterns from Yarn classic workspaces.packages format', () => { + const root = createRoot() + + writeFileSync( + join(root, 'package.json'), + JSON.stringify({ workspaces: { packages: ['packages/*', 'apps/*'] } }), + ) + + expect(readWorkspacePatterns(root)).toEqual(['apps/*', 'packages/*']) + }) + + it('returns null when no workspace config exists', () => { + const root = createRoot() + expect(readWorkspacePatterns(root)).toBeNull() + }) + + it('returns null when all patterns normalize to empty strings', () => { + const root = createRoot() + + writeFileSync( + join(root, 'package.json'), + JSON.stringify({ workspaces: ['', ''] }), + ) + + expect(readWorkspacePatterns(root)).toBeNull() + }) }) describe('resolveWorkspacePackages', () => { @@ -159,6 +212,33 @@ describe('resolveWorkspacePackages', () => { resolveWorkspacePackages(root, ['packages/*', '!packages/excluded']), ).toEqual([join(root, 'packages', 'alpha')]) }) + + it('normalizes exclusion patterns with ./ and backslash prefixes', () => { + const root = createRoot() + + writePackage(root, 'packages', 'alpha') + writePackage(root, 'packages', 'beta') + writePackage(root, 'packages', 'excluded') + + expect( + resolveWorkspacePackages(root, ['packages/*', '!./packages/excluded']), + ).toEqual([join(root, 'packages', 'alpha'), join(root, 'packages', 'beta')]) + + expect( + resolveWorkspacePackages(root, ['packages/*', '!packages\\excluded']), + ).toEqual([join(root, 'packages', 'alpha'), join(root, 'packages', 'beta')]) + }) +}) + +describe('findWorkspaceRoot', () => { + it('returns null when no workspace config exists in any ancestor', () => { + const root = createRoot() + expect(findWorkspaceRoot(root)).toBeNull() + }) + + it('throws on relative paths', () => { + expect(() => findWorkspaceRoot('relative/path')).toThrow() + }) }) describe('workspace helpers', () => {