From d6344f460a7461dd9b0f1a3945ed1f7f634a1804 Mon Sep 17 00:00:00 2001 From: LadyBluenotes Date: Mon, 23 Mar 2026 10:27:19 -0700 Subject: [PATCH 01/11] 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 02/11] 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 03/11] 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 1dd418d607aab240c0af044e387a7ec1c32e6ab0 Mon Sep 17 00:00:00 2001 From: LadyBluenotes Date: Mon, 23 Mar 2026 11:28:18 -0700 Subject: [PATCH 04/11] feat: add project context resolution and associated tests --- packages/intent/src/core/project-context.ts | 112 +++++++++++++++++ packages/intent/tests/project-context.test.ts | 113 ++++++++++++++++++ 2 files changed, 225 insertions(+) create mode 100644 packages/intent/src/core/project-context.ts create mode 100644 packages/intent/tests/project-context.test.ts diff --git a/packages/intent/src/core/project-context.ts b/packages/intent/src/core/project-context.ts new file mode 100644 index 0000000..7eb0439 --- /dev/null +++ b/packages/intent/src/core/project-context.ts @@ -0,0 +1,112 @@ +import { existsSync, statSync } from 'node:fs' +import { dirname, isAbsolute, 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 +} + +export function resolveProjectContext({ + cwd, + targetPath, +}: { + cwd: string + targetPath?: string +}): ProjectContext { + const resolvedCwd = resolve(cwd) + const resolvedTargetPath = resolveTargetPath(resolvedCwd, targetPath) + 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 resolveTargetPath(cwd: string, targetPath?: string): string { + if (!targetPath) { + return cwd + } + + return isAbsolute(targetPath) ? resolve(targetPath) : resolve(cwd, targetPath) +} + +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 + } + + try { + return statSync(path).isDirectory() ? path : dirname(path) + } catch { + return path + } +} + +function resolveTargetSkillsDir( + targetPath: string, + packageRoot: string | null, +): string | null { + if (!packageRoot) { + return null + } + + const packageSkillsDir = join(packageRoot, 'skills') + if (targetPath === packageSkillsDir) { + return packageSkillsDir + } + + if (isWithinDirectory(targetPath, packageSkillsDir)) { + return packageSkillsDir + } + + if (targetPath === packageRoot && existsSync(packageSkillsDir)) { + return packageSkillsDir + } + + return null +} + +function isWithinDirectory(path: string, parentDir: string): boolean { + const pathRelative = relative(parentDir, path) + return pathRelative !== '' && !pathRelative.startsWith('..') +} diff --git a/packages/intent/tests/project-context.test.ts b/packages/intent/tests/project-context.test.ts new file mode 100644 index 0000000..000c982 --- /dev/null +++ b/packages/intent/tests/project-context.test.ts @@ -0,0 +1,113 @@ +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 { resolveProjectContext } from '../src/core/project-context.js' + +const roots: Array = [] + +function createRoot(): string { + const root = 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('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) + }) +}) From e19a52314fa5875c046c6131db986c64e30b94d3 Mon Sep 17 00:00:00 2001 From: LadyBluenotes Date: Mon, 23 Mar 2026 11:31:18 -0700 Subject: [PATCH 05/11] feat: refactor validation logic to utilize project context and improve packaging warnings --- packages/intent/src/commands/validate.ts | 59 ++++++------------------ packages/intent/tests/cli.test.ts | 35 ++++++++++++++ 2 files changed, 50 insertions(+), 44 deletions(-) diff --git a/packages/intent/src/commands/validate.ts b/packages/intent/src/commands/validate.ts index 636256d..f2198ef 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}`) @@ -238,7 +209,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/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) From ed552c632e8c0cc16dd6a504e9083676a5a45ec2 Mon Sep 17 00:00:00 2001 From: LadyBluenotes Date: Mon, 23 Mar 2026 11:34:54 -0700 Subject: [PATCH 06/11] feat: enhance package.json editing by integrating project context and improving monorepo detection --- packages/intent/src/setup.ts | 30 ++++++------------------- packages/intent/tests/setup.test.ts | 34 +++++++++++++++++++++++++++++ 2 files changed, 41 insertions(+), 23 deletions(-) diff --git a/packages/intent/src/setup.ts b/packages/intent/src/setup.ts index 7cae774..998eade 100644 --- a/packages/intent/src/setup.ts +++ b/packages/intent/src/setup.ts @@ -11,6 +11,7 @@ import { findWorkspaceRoot, readWorkspacePatterns, } from './workspace-patterns.js' +import { resolveProjectContext } from './core/project-context.js' export { findPackagesWithSkills, @@ -277,10 +278,13 @@ 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 = + context.targetPackageJsonPath ?? 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 } @@ -319,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'] 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'), From 6471af56b9a63cb844f96aadf634e1ae093533ca Mon Sep 17 00:00:00 2001 From: LadyBluenotes Date: Mon, 23 Mar 2026 11:36:40 -0700 Subject: [PATCH 07/11] changeset --- .changeset/red-regions-shine.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/red-regions-shine.md 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. From 8835975cc565d03378ce8c68c459d8848303d26a Mon Sep 17 00:00:00 2001 From: LadyBluenotes Date: Mon, 23 Mar 2026 11:41:28 -0700 Subject: [PATCH 08/11] 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 63273a5123b945f4344da8f1947ffdd045e47ad6 Mon Sep 17 00:00:00 2001 From: Sarah Gerrard Date: Mon, 23 Mar 2026 11:37:24 -0700 Subject: [PATCH 09/11] 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 bc93b25..9bc90ea 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 094609ee8d0779133bbde5520e124d15977d6d07 Mon Sep 17 00:00:00 2001 From: Kyle Mathews Date: Tue, 24 Mar 2026 17:55:29 -0600 Subject: [PATCH 10/11] fix: address review findings for workspace refactor - Add process.exitCode = 1 on JSON parse failure in setup.ts (was silently returning success) - Skip _artifacts validation for monorepo packages in validate.ts (artifacts live at workspace root) - Add JSDoc to resolveProjectContext explaining dual-fallback resolution strategy - Add comment to resolveWorkspacePatternSegments describing glob matching behavior - Add standalone (non-monorepo) project test for resolveProjectContext Co-Authored-By: Claude Opus 4.6 --- packages/intent/src/commands/validate.ts | 3 +- packages/intent/src/core/project-context.ts | 36 +++++++----------- packages/intent/src/setup.ts | 4 +- packages/intent/src/workspace-patterns.ts | 38 ++++++++----------- packages/intent/tests/project-context.test.ts | 22 ++++++++++- 5 files changed, 54 insertions(+), 49 deletions(-) diff --git a/packages/intent/src/commands/validate.ts b/packages/intent/src/commands/validate.ts index f2198ef..a973d3d 100644 --- a/packages/intent/src/commands/validate.ts +++ b/packages/intent/src/commands/validate.ts @@ -168,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', diff --git a/packages/intent/src/core/project-context.ts b/packages/intent/src/core/project-context.ts index 7eb0439..6768db7 100644 --- a/packages/intent/src/core/project-context.ts +++ b/packages/intent/src/core/project-context.ts @@ -1,5 +1,5 @@ import { existsSync, statSync } from 'node:fs' -import { dirname, isAbsolute, join, relative, resolve } from 'node:path' +import { dirname, join, relative, resolve } from 'node:path' import { findWorkspaceRoot, readWorkspacePatterns, @@ -15,6 +15,11 @@ export type ProjectContext = { 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, @@ -23,7 +28,9 @@ export function resolveProjectContext({ targetPath?: string }): ProjectContext { const resolvedCwd = resolve(cwd) - const resolvedTargetPath = resolveTargetPath(resolvedCwd, targetPath) + const resolvedTargetPath = targetPath + ? resolve(resolvedCwd, targetPath) + : resolvedCwd const packageRoot = findOwningPackageRoot(resolvedTargetPath) const workspaceRoot = findWorkspaceRoot(packageRoot ?? resolvedTargetPath) ?? @@ -45,14 +52,6 @@ export function resolveProjectContext({ } } -function resolveTargetPath(cwd: string, targetPath?: string): string { - if (!targetPath) { - return cwd - } - - return isAbsolute(targetPath) ? resolve(targetPath) : resolve(cwd, targetPath) -} - function findOwningPackageRoot(startPath: string): string | null { let dir = toSearchDir(startPath) @@ -75,11 +74,7 @@ function toSearchDir(path: string): string { return path } - try { - return statSync(path).isDirectory() ? path : dirname(path) - } catch { - return path - } + return statSync(path).isDirectory() ? path : dirname(path) } function resolveTargetSkillsDir( @@ -91,11 +86,8 @@ function resolveTargetSkillsDir( } const packageSkillsDir = join(packageRoot, 'skills') - if (targetPath === packageSkillsDir) { - return packageSkillsDir - } - if (isWithinDirectory(targetPath, packageSkillsDir)) { + if (isWithinOrEqual(targetPath, packageSkillsDir)) { return packageSkillsDir } @@ -106,7 +98,7 @@ function resolveTargetSkillsDir( return null } -function isWithinDirectory(path: string, parentDir: string): boolean { - const pathRelative = relative(parentDir, path) - return pathRelative !== '' && !pathRelative.startsWith('..') +function isWithinOrEqual(path: string, parentDir: string): boolean { + const rel = relative(parentDir, path) + return rel === '' || (!rel.startsWith('..') && !rel.startsWith('/')) } diff --git a/packages/intent/src/setup.ts b/packages/intent/src/setup.ts index 998eade..4f922e2 100644 --- a/packages/intent/src/setup.ts +++ b/packages/intent/src/setup.ts @@ -280,8 +280,7 @@ export function runEditPackageJson(root: string): EditPackageJsonResult { const result: EditPackageJsonResult = { added: [], alreadyPresent: [] } const context = resolveProjectContext({ cwd: root }) const packageRoot = context.packageRoot ?? root - const pkgPath = - context.targetPackageJsonPath ?? join(packageRoot, 'package.json') + const pkgPath = join(packageRoot, 'package.json') if (!existsSync(pkgPath)) { console.error('No package.json found in ' + packageRoot) @@ -296,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 } diff --git a/packages/intent/src/workspace-patterns.ts b/packages/intent/src/workspace-patterns.ts index 9bc90ea..8afa392 100644 --- a/packages/intent/src/workspace-patterns.ts +++ b/packages/intent/src/workspace-patterns.ts @@ -1,6 +1,5 @@ import { existsSync, readFileSync, readdirSync } from 'node:fs' -import type { Dirent } from 'node:fs' -import { join } from 'node:path' +import { dirname, join } from 'node:path' import { parse as parseYaml } from 'yaml' import { findSkillFiles } from './utils.js' @@ -51,14 +50,11 @@ export function readWorkspacePatterns(root: string): Array | null { 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 + const patterns = + parseWorkspacePatterns(pkg.workspaces) ?? + parseWorkspacePatterns(pkg.workspaces?.packages) + if (patterns) { + return patterns } } catch (err: unknown) { console.error( @@ -95,6 +91,7 @@ export function resolveWorkspacePackages( .sort((a, b) => a.localeCompare(b)) } +/** Recursively matches path segments: `*` matches one level, `**` matches zero or more levels. */ function resolveWorkspacePatternSegments( dir: string, segments: Array, @@ -134,24 +131,21 @@ function resolveWorkspacePatternSegments( } function readChildDirectories(dir: string): Array { - let entries: Array try { - entries = readdirSync(dir, { withFileTypes: true }) + 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 [] } - - 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 { @@ -162,7 +156,7 @@ export function findWorkspaceRoot(start: string): string | null { return dir } - const next = join(dir, '..') + const next = dirname(dir) if (next === dir) return null dir = next } diff --git a/packages/intent/tests/project-context.test.ts b/packages/intent/tests/project-context.test.ts index 000c982..2a41ccc 100644 --- a/packages/intent/tests/project-context.test.ts +++ b/packages/intent/tests/project-context.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' @@ -7,7 +7,7 @@ import { resolveProjectContext } from '../src/core/project-context.js' const roots: Array = [] function createRoot(): string { - const root = mkdtempSync(join(tmpdir(), 'project-context-test-')) + const root = realpathSync(mkdtempSync(join(tmpdir(), 'project-context-test-'))) roots.push(root) return root } @@ -95,6 +95,24 @@ describe('resolveProjectContext', () => { }) }) + 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) From 667e06f2bbe6ac6990fff68a66e59e63108a18ad Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Tue, 24 Mar 2026 23:56:53 +0000 Subject: [PATCH 11/11] ci: apply automated fixes --- packages/intent/tests/project-context.test.ts | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/packages/intent/tests/project-context.test.ts b/packages/intent/tests/project-context.test.ts index 2a41ccc..c16b64e 100644 --- a/packages/intent/tests/project-context.test.ts +++ b/packages/intent/tests/project-context.test.ts @@ -1,4 +1,10 @@ -import { mkdirSync, mkdtempSync, realpathSync, 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' @@ -7,7 +13,9 @@ import { resolveProjectContext } from '../src/core/project-context.js' const roots: Array = [] function createRoot(): string { - const root = realpathSync(mkdtempSync(join(tmpdir(), 'project-context-test-'))) + const root = realpathSync( + mkdtempSync(join(tmpdir(), 'project-context-test-')), + ) roots.push(root) return root }