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/.changeset/red-regions-shine.md b/.changeset/red-regions-shine.md new file mode 100644 index 0000000..c7e11a1 --- /dev/null +++ b/.changeset/red-regions-shine.md @@ -0,0 +1,5 @@ +--- +'@tanstack/intent': patch +--- + +Refactor @tanstack/intent to use a shared project context resolver for workspace and package detection. This fixes monorepo targeting bugs in validate and edit-package-json, including pnpm workspaces defined only by pnpm-workspace.yaml. 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/commands/validate.ts b/packages/intent/src/commands/validate.ts index 636256d..a973d3d 100644 --- a/packages/intent/src/commands/validate.ts +++ b/packages/intent/src/commands/validate.ts @@ -1,7 +1,11 @@ import { existsSync, readFileSync } from 'node:fs' -import { dirname, join, relative, sep } from 'node:path' +import { join, relative, resolve, sep } from 'node:path' import { fail } from '../cli-error.js' import { printWarnings } from '../cli-support.js' +import { + type ProjectContext, + resolveProjectContext, +} from '../core/project-context.js' interface ValidationError { file: string @@ -28,27 +32,10 @@ function buildValidationFailure( return lines.join('\n') } -function isInsideMonorepo(root: string): boolean { - let dir = join(root, '..') - for (let i = 0; i < 5; i++) { - const parentPkg = join(dir, 'package.json') - if (existsSync(parentPkg)) { - try { - const parent = JSON.parse(readFileSync(parentPkg, 'utf8')) - return Array.isArray(parent.workspaces) || parent.workspaces?.packages - } catch { - return false - } - } - const next = dirname(dir) - if (next === dir) break - dir = next - } - return false -} +function collectPackagingWarnings(context: ProjectContext): Array { + if (!context.packageRoot || !context.targetPackageJsonPath) return [] -function collectPackagingWarnings(root: string): Array { - const pkgJsonPath = join(root, 'package.json') + const pkgJsonPath = context.targetPackageJsonPath if (!existsSync(pkgJsonPath)) return [] let pkgJson: Record @@ -81,9 +68,7 @@ function collectPackagingWarnings(root: string): Array { // In monorepos, _artifacts lives at repo root, not under packages — // the negation pattern is a no-op and shouldn't be added. - const isMonorepoPkg = isInsideMonorepo(root) - - if (!isMonorepoPkg && !files.includes('!skills/_artifacts')) { + if (!context.isMonorepo && !files.includes('!skills/_artifacts')) { warnings.push( '"!skills/_artifacts" is not in the "files" array — artifacts will be published unnecessarily', ) @@ -93,31 +78,17 @@ function collectPackagingWarnings(root: string): Array { return warnings } -function resolvePackageRoot(startDir: string): string { - let dir = startDir - - while (true) { - if (existsSync(join(dir, 'package.json'))) { - return dir - } - - const next = dirname(dir) - if (next === dir) { - return startDir - } - - dir = next - } -} - export async function runValidateCommand(dir?: string): Promise { const [{ parse: parseYaml }, { findSkillFiles }] = await Promise.all([ import('yaml'), import('../utils.js'), ]) const targetDir = dir ?? 'skills' - const skillsDir = join(process.cwd(), targetDir) - const packageRoot = resolvePackageRoot(skillsDir) + const context = resolveProjectContext({ + cwd: process.cwd(), + targetPath: targetDir, + }) + const skillsDir = context.targetSkillsDir ?? resolve(process.cwd(), targetDir) if (!existsSync(skillsDir)) { fail(`Skills directory not found: ${skillsDir}`) @@ -197,8 +168,9 @@ export async function runValidateCommand(dir?: string): Promise { } } + // In monorepos, _artifacts lives at the workspace root, not under each package's skills/ dir. const artifactsDir = join(skillsDir, '_artifacts') - if (existsSync(artifactsDir)) { + if (!context.isMonorepo && existsSync(artifactsDir)) { const requiredArtifacts = [ 'domain_map.yaml', 'skill_spec.md', @@ -238,7 +210,7 @@ export async function runValidateCommand(dir?: string): Promise { } } - const warnings = collectPackagingWarnings(packageRoot) + const warnings = collectPackagingWarnings(context) if (errors.length > 0) { fail(buildValidationFailure(errors, warnings)) diff --git a/packages/intent/src/core/project-context.ts b/packages/intent/src/core/project-context.ts new file mode 100644 index 0000000..6768db7 --- /dev/null +++ b/packages/intent/src/core/project-context.ts @@ -0,0 +1,104 @@ +import { existsSync, statSync } from 'node:fs' +import { dirname, join, relative, resolve } from 'node:path' +import { + findWorkspaceRoot, + readWorkspacePatterns, +} from '../workspace-patterns.js' + +export type ProjectContext = { + cwd: string + workspaceRoot: string | null + packageRoot: string | null + isMonorepo: boolean + workspacePatterns: Array + targetPackageJsonPath: string | null + targetSkillsDir: string | null +} + +/** + * Resolves project structure by walking up from targetPath (or cwd) to find the + * owning package.json, then searches for a workspace root from the package root. + * Falls back to searching from cwd when targetPath points deep into a package. + */ +export function resolveProjectContext({ + cwd, + targetPath, +}: { + cwd: string + targetPath?: string +}): ProjectContext { + const resolvedCwd = resolve(cwd) + const resolvedTargetPath = targetPath + ? resolve(resolvedCwd, targetPath) + : resolvedCwd + const packageRoot = findOwningPackageRoot(resolvedTargetPath) + const workspaceRoot = + findWorkspaceRoot(packageRoot ?? resolvedTargetPath) ?? + findWorkspaceRoot(resolvedCwd) + const workspacePatterns = workspaceRoot + ? (readWorkspacePatterns(workspaceRoot) ?? []) + : [] + + return { + cwd: resolvedCwd, + workspaceRoot, + packageRoot, + isMonorepo: workspaceRoot !== null, + workspacePatterns, + targetPackageJsonPath: packageRoot + ? join(packageRoot, 'package.json') + : null, + targetSkillsDir: resolveTargetSkillsDir(resolvedTargetPath, packageRoot), + } +} + +function findOwningPackageRoot(startPath: string): string | null { + let dir = toSearchDir(startPath) + + while (true) { + if (existsSync(join(dir, 'package.json'))) { + return dir + } + + const next = dirname(dir) + if (next === dir) { + return null + } + + dir = next + } +} + +function toSearchDir(path: string): string { + if (!existsSync(path)) { + return path + } + + return statSync(path).isDirectory() ? path : dirname(path) +} + +function resolveTargetSkillsDir( + targetPath: string, + packageRoot: string | null, +): string | null { + if (!packageRoot) { + return null + } + + const packageSkillsDir = join(packageRoot, 'skills') + + if (isWithinOrEqual(targetPath, packageSkillsDir)) { + return packageSkillsDir + } + + if (targetPath === packageRoot && existsSync(packageSkillsDir)) { + return packageSkillsDir + } + + return null +} + +function isWithinOrEqual(path: string, parentDir: string): boolean { + const rel = relative(parentDir, path) + return rel === '' || (!rel.startsWith('..') && !rel.startsWith('/')) +} 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..4f922e2 100644 --- a/packages/intent/src/setup.ts +++ b/packages/intent/src/setup.ts @@ -6,8 +6,19 @@ 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' +import { resolveProjectContext } from './core/project-context.js' + +export { + findPackagesWithSkills, + findWorkspaceRoot, + readWorkspacePatterns, + resolveWorkspacePackages, +} from './workspace-patterns.js' // --------------------------------------------------------------------------- // Types @@ -267,10 +278,12 @@ function copyTemplates( export function runEditPackageJson(root: string): EditPackageJsonResult { const result: EditPackageJsonResult = { added: [], alreadyPresent: [] } - const pkgPath = join(root, 'package.json') + const context = resolveProjectContext({ cwd: root }) + const packageRoot = context.packageRoot ?? root + const pkgPath = join(packageRoot, 'package.json') if (!existsSync(pkgPath)) { - console.error('No package.json found in ' + root) + console.error('No package.json found in ' + packageRoot) process.exitCode = 1 return result } @@ -282,6 +295,7 @@ export function runEditPackageJson(root: string): EditPackageJsonResult { } catch (err) { const detail = err instanceof SyntaxError ? err.message : String(err) console.error(`Failed to parse ${pkgPath}: ${detail}`) + process.exitCode = 1 return result } @@ -309,27 +323,7 @@ export function runEditPackageJson(root: string): EditPackageJsonResult { // In monorepos, _artifacts lives at repo root, not under packages — // the negation pattern is a no-op and shouldn't be added. - // Detect monorepo by walking up to find a parent package.json with workspaces. - const isMonorepo = (() => { - let dir = join(root, '..') - for (let i = 0; i < 5; i++) { - const parentPkg = join(dir, 'package.json') - if (existsSync(parentPkg)) { - try { - const parent = JSON.parse(readFileSync(parentPkg, 'utf8')) - if (Array.isArray(parent.workspaces) || parent.workspaces?.packages) { - return true - } - } catch {} - return false - } - const next = join(dir, '..') - if (next === dir) break - dir = next - } - return false - })() - const requiredFiles = isMonorepo + const requiredFiles = context.isMonorepo ? ['skills'] : ['skills', '!skills/_artifacts'] @@ -351,148 +345,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..8afa392 --- /dev/null +++ b/packages/intent/src/workspace-patterns.ts @@ -0,0 +1,173 @@ +import { existsSync, readFileSync, readdirSync } from 'node:fs' +import { dirname, 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 + } + + 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 patterns = + parseWorkspacePatterns(pkg.workspaces) ?? + parseWorkspacePatterns(pkg.workspaces?.packages) + if (patterns) { + return patterns + } + } 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 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)) +} + +/** Recursively matches path segments: `*` matches one level, `**` matches zero or more levels. */ +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 { + try { + return readdirSync(dir, { withFileTypes: true }) + .filter( + (entry) => + entry.isDirectory() && + entry.name !== 'node_modules' && + !entry.name.startsWith('.'), + ) + .map((entry) => join(dir, entry.name)) + } catch (err: unknown) { + console.error( + `Warning: could not read directory ${dir}: ${err instanceof Error ? err.message : err}`, + ) + return [] + } +} + +export function findWorkspaceRoot(start: string): string | null { + let dir = start + + while (true) { + if (readWorkspacePatterns(dir)) { + return dir + } + + const next = dirname(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/cli.test.ts b/packages/intent/tests/cli.test.ts index 252b081..1f2e2e8 100644 --- a/packages/intent/tests/cli.test.ts +++ b/packages/intent/tests/cli.test.ts @@ -372,6 +372,41 @@ describe('cli commands', () => { expect(output).not.toContain('@tanstack/intent is not in devDependencies') }) + it('validates pnpm workspace package skills from repo root without false packaging warnings', async () => { + const root = mkdtempSync(join(realTmpdir, 'intent-cli-validate-pnpm-')) + tempDirs.push(root) + + writeJson(join(root, 'package.json'), { + private: true, + }) + writeFileSync( + join(root, 'pnpm-workspace.yaml'), + 'packages:\n - packages/*\n', + ) + writeJson(join(root, 'packages', 'router', 'package.json'), { + name: '@tanstack/router', + devDependencies: { '@tanstack/intent': '^0.0.18' }, + keywords: ['tanstack-intent'], + files: ['skills'], + }) + writeSkillMd(join(root, 'packages', 'router', 'skills', 'db-core'), { + name: 'db-core', + description: 'Core database concepts', + }) + + process.chdir(root) + + const exitCode = await main(['validate', 'packages/router/skills']) + const output = logSpy.mock.calls.flat().join('\n') + + expect(exitCode).toBe(0) + expect(output).toContain('✅ Validated 1 skill files — all passed') + expect(output).not.toContain('@tanstack/intent is not in devDependencies') + expect(output).not.toContain( + '"!skills/_artifacts" is not in the "files" array', + ) + }) + it('fails cleanly when validate is run without a skills directory', async () => { const root = mkdtempSync(join(realTmpdir, 'intent-cli-missing-skills-')) tempDirs.push(root) diff --git a/packages/intent/tests/project-context.test.ts b/packages/intent/tests/project-context.test.ts new file mode 100644 index 0000000..c16b64e --- /dev/null +++ b/packages/intent/tests/project-context.test.ts @@ -0,0 +1,139 @@ +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 { resolveProjectContext } from '../src/core/project-context.js' + +const roots: Array = [] + +function createRoot(): string { + const root = realpathSync( + mkdtempSync(join(tmpdir(), 'project-context-test-')), + ) + roots.push(root) + return root +} + +function writeJson(path: string, data: unknown): void { + mkdirSync(join(path, '..'), { recursive: true }) + writeFileSync(path, JSON.stringify(data, null, 2)) +} + +function createPnpmWorkspaceRoot(root: string): void { + writeJson(join(root, 'package.json'), { name: 'repo-root', private: true }) + writeFileSync( + join(root, 'pnpm-workspace.yaml'), + 'packages:\n - packages/*\n', + ) +} + +function createWorkspacePackage(root: string, name: string): string { + const packageRoot = join(root, 'packages', name) + writeJson(join(packageRoot, 'package.json'), { name: `@scope/${name}` }) + mkdirSync(join(packageRoot, 'skills'), { recursive: true }) + return packageRoot +} + +afterEach(() => { + for (const root of roots.splice(0)) { + rmSync(root, { recursive: true, force: true }) + } +}) + +describe('resolveProjectContext', () => { + it('resolves the workspace root from the workspace cwd', () => { + const root = createRoot() + createPnpmWorkspaceRoot(root) + + const context = resolveProjectContext({ cwd: root }) + + expect(context).toEqual({ + cwd: root, + workspaceRoot: root, + packageRoot: root, + isMonorepo: true, + workspacePatterns: ['packages/*'], + targetPackageJsonPath: join(root, 'package.json'), + targetSkillsDir: null, + }) + }) + + it('resolves the owning workspace package from a package cwd', () => { + const root = createRoot() + createPnpmWorkspaceRoot(root) + const packageRoot = createWorkspacePackage(root, 'router') + + const context = resolveProjectContext({ cwd: packageRoot }) + + expect(context).toEqual({ + cwd: packageRoot, + workspaceRoot: root, + packageRoot, + isMonorepo: true, + workspacePatterns: ['packages/*'], + targetPackageJsonPath: join(packageRoot, 'package.json'), + targetSkillsDir: join(packageRoot, 'skills'), + }) + }) + + it('resolves an explicit skills dir path back to the owning package', () => { + const root = createRoot() + createPnpmWorkspaceRoot(root) + const packageRoot = createWorkspacePackage(root, 'query') + + const context = resolveProjectContext({ + cwd: root, + targetPath: 'packages/query/skills', + }) + + expect(context).toEqual({ + cwd: root, + workspaceRoot: root, + packageRoot, + isMonorepo: true, + workspacePatterns: ['packages/*'], + targetPackageJsonPath: join(packageRoot, 'package.json'), + targetSkillsDir: join(packageRoot, 'skills'), + }) + }) + + it('resolves a standalone project with no workspace', () => { + const root = createRoot() + writeJson(join(root, 'package.json'), { name: 'standalone-pkg' }) + mkdirSync(join(root, 'skills'), { recursive: true }) + + const context = resolveProjectContext({ cwd: root }) + + expect(context).toEqual({ + cwd: root, + workspaceRoot: null, + packageRoot: root, + isMonorepo: false, + workspacePatterns: [], + targetPackageJsonPath: join(root, 'package.json'), + targetSkillsDir: join(root, 'skills'), + }) + }) + + it('detects pnpm workspaces without package.json workspaces', () => { + const root = createRoot() + createPnpmWorkspaceRoot(root) + const packageRoot = createWorkspacePackage(root, 'table') + + const context = resolveProjectContext({ + cwd: root, + targetPath: 'packages/table', + }) + + expect(context.workspaceRoot).toBe(root) + expect(context.workspacePatterns).toEqual(['packages/*']) + expect(context.isMonorepo).toBe(true) + expect(context.packageRoot).toBe(packageRoot) + }) +}) diff --git a/packages/intent/tests/setup.test.ts b/packages/intent/tests/setup.test.ts index 0e37ad2..6911a8c 100644 --- a/packages/intent/tests/setup.test.ts +++ b/packages/intent/tests/setup.test.ts @@ -185,6 +185,40 @@ describe('runEditPackageJson', () => { rmSync(monoRoot, { recursive: true, force: true }) }) + it('skips !skills/_artifacts in pnpm workspace packages', () => { + const monoRoot = createMonorepo() + const pkgDir = join(monoRoot, 'packages', 'lib-a') + + const result = runEditPackageJson(pkgDir) + + expect(result.added).toContain('files: "skills"') + expect(result.added).not.toEqual( + expect.arrayContaining([expect.stringContaining('!skills/_artifacts')]), + ) + + const pkg = JSON.parse(readFileSync(join(pkgDir, 'package.json'), 'utf8')) + expect(pkg.files).toContain('skills') + expect(pkg.files).not.toContain('!skills/_artifacts') + + rmSync(monoRoot, { recursive: true, force: true }) + }) + + it('updates the owning package when given a workspace skills dir path', () => { + const monoRoot = createMonorepo() + const pkgDir = join(monoRoot, 'packages', 'lib-a') + const skillsDir = join(pkgDir, 'skills') + + const result = runEditPackageJson(skillsDir) + + expect(result.added).toContain('files: "skills"') + + const pkg = JSON.parse(readFileSync(join(pkgDir, 'package.json'), 'utf8')) + expect(pkg.files).toContain('skills') + expect(pkg.files).not.toContain('!skills/_artifacts') + + 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..9176d38 --- /dev/null +++ b/packages/intent/tests/workspace-patterns.test.ts @@ -0,0 +1,195 @@ +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 { + 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-')) + 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/*', + ]) + }) +}) + +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')]) + }) +}) + +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'), + ]) + }) +})