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. 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/setup.ts b/packages/intent/src/setup.ts index 0a2ae23..874486f 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 @@ -320,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, '..') @@ -351,148 +363,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..8e4dd24 --- /dev/null +++ b/packages/intent/src/workspace-patterns.ts @@ -0,0 +1,187 @@ +import { existsSync, readFileSync, readdirSync } from 'node:fs' +import type { Dirent } from 'node:fs' +import { isAbsolute, 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(/\/+$/, '') +} + +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 + } + + 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)) { + 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 +} + +/** Resolves workspace glob patterns to package directories. Supports `*`, `**`, and `!` exclusion patterns. */ +export function resolveWorkspacePackages( + root: string, + patterns: Array, +): Array { + const includedDirs = new Set() + const excludedDirs = new Set() + + for (const pattern of normalizeWorkspacePatterns(patterns)) { + if (pattern.startsWith('!')) { + resolveWorkspacePatternSegments( + root, + pattern.slice(1).split('/'), + excludedDirs, + ) + continue + } + + resolveWorkspacePatternSegments(root, pattern.split('/'), includedDirs) + } + + return [...includedDirs] + .filter((dir) => !excludedDirs.has(dir)) + .sort((a, b) => a.localeCompare(b)) +} + +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 }) + } catch (err: unknown) { + console.error( + `Warning: could not read directory ${dir}: ${err instanceof Error ? err.message : err}`, + ) + return [] + } + + return entries + .filter( + (entry) => + entry.isDirectory() && + entry.name !== 'node_modules' && + !entry.name.startsWith('.'), + ) + .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) { + if (readWorkspacePatterns(dir)) { + return dir + } + + const next = join(dir, '..') + if (next === dir) return null + dir = next + } +} + +/** Finds 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 + }) +} 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 new file mode 100644 index 0000000..2c90ddc --- /dev/null +++ b/packages/intent/tests/workspace-patterns.test.ts @@ -0,0 +1,275 @@ +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' +import { + findPackagesWithSkills, + findWorkspaceRoot, + readWorkspacePatterns, + resolveWorkspacePackages, +} from '../src/workspace-patterns.js' + +const roots: Array = [] +const cwdStack: Array = [] + +function createRoot(): string { + const root = realpathSync(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('/') }), + ) +} + +function writeDir(root: string, ...parts: Array): void { + mkdirSync(join(root, ...parts), { recursive: true }) +} + +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() + + writeFileSync( + join(root, 'package.json'), + JSON.stringify({ + workspaces: [ + '', + './apps/*/packages/*/', + './packages/*/', + 'apps/*', + 'packages\\*', + 'apps/*', + ], + }), + ) + + expect(readWorkspacePatterns(root)).toEqual([ + 'apps/*', + 'apps/*/packages/*', + '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', () => { + 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'), + ]) + }) + + 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')]) + }) + + 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')]) + }) + + 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', () => { + 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'), + ]) + }) +})