Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/busy-peas-fail.md
Original file line number Diff line number Diff line change
@@ -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.
5 changes: 5 additions & 0 deletions .changeset/red-regions-shine.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 1 addition & 1 deletion packages/intent/src/cli-support.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
62 changes: 17 additions & 45 deletions packages/intent/src/commands/validate.ts
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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<string> {
if (!context.packageRoot || !context.targetPackageJsonPath) return []

function collectPackagingWarnings(root: string): Array<string> {
const pkgJsonPath = join(root, 'package.json')
const pkgJsonPath = context.targetPackageJsonPath
if (!existsSync(pkgJsonPath)) return []

let pkgJson: Record<string, unknown>
Expand Down Expand Up @@ -81,9 +68,7 @@ function collectPackagingWarnings(root: string): Array<string> {

// 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',
)
Comment on lines 69 to 74
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

context.isMonorepo is too broad for the _artifacts gate.

resolveProjectContext marks the workspace root package as monorepo too, so these branches now skip the local skills/_artifacts validation and !skills/_artifacts warning for a root package that still needs them. Gate this on “target package is a workspace sub-package” instead of “a workspace root exists”.

Suggested direction
+const isWorkspaceSubpackage =
+  context.packageRoot !== null &&
+  context.workspaceRoot !== null &&
+  context.packageRoot !== context.workspaceRoot
+
 // In monorepos, _artifacts lives at repo root, not under packages —
 // the negation pattern is a no-op and shouldn't be added.
-    if (!context.isMonorepo && !files.includes('!skills/_artifacts')) {
+    if (!isWorkspaceSubpackage && !files.includes('!skills/_artifacts')) {
       warnings.push(
         '"!skills/_artifacts" is not in the "files" array — artifacts will be published unnecessarily',
       )
     }
@@
 const artifactsDir = join(skillsDir, '_artifacts')
-if (!context.isMonorepo && existsSync(artifactsDir)) {
+if (!isWorkspaceSubpackage && existsSync(artifactsDir)) {
Based on learnings: `_artifacts` handling should answer whether the target package is a sub-package inside a monorepo, not merely whether the package belongs to any workspace.

Also applies to: 171-173

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/intent/src/commands/validate.ts` around lines 69 - 74, The check for
`_artifacts` uses context.isMonorepo which is too broad; change the gate to
detect whether the target package is a workspace sub-package (i.e., not the
workspace root). Replace occurrences that read `!context.isMonorepo` (used with
`files.includes('!skills/_artifacts')` and the duplicate block at 171-173) with
a condition that checks the package is inside a workspace but not the workspace
root, e.g. verify `context.workspaceRoot && context.workspaceRoot !==
context.packageRoot` (or use an explicit `context.isWorkspaceSubpackage` boolean
if available) so only sub-packages skip/require the `!skills/_artifacts`
validation and warning.

Expand All @@ -93,31 +78,17 @@ function collectPackagingWarnings(root: string): Array<string> {
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<void> {
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}`)
Expand Down Expand Up @@ -197,8 +168,9 @@ export async function runValidateCommand(dir?: string): Promise<void> {
}
}

// 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',
Expand Down Expand Up @@ -238,7 +210,7 @@ export async function runValidateCommand(dir?: string): Promise<void> {
}
}

const warnings = collectPackagingWarnings(packageRoot)
const warnings = collectPackagingWarnings(context)

if (errors.length > 0) {
fail(buildValidationFailure(errors, warnings))
Expand Down
104 changes: 104 additions & 0 deletions packages/intent/src/core/project-context.ts
Original file line number Diff line number Diff line change
@@ -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<string>
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,
Comment on lines +35 to +47
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Don’t mark the workspace root package as isMonorepo.

workspaceRoot !== null conflates “a workspace exists” with “this package is inside the workspace”. When packageRoot === workspaceRoot, callers like validate and runEditPackageJson will treat the root package like a sub-package and skip the !skills/_artifacts handling that still needs to happen there.

[suggested fix]

🐛 Suggested fix
   const workspaceRoot =
     findWorkspaceRoot(packageRoot ?? resolvedTargetPath) ??
     findWorkspaceRoot(resolvedCwd)
+  const isMonorepo =
+    packageRoot !== null &&
+    workspaceRoot !== null &&
+    workspaceRoot !== packageRoot
   const workspacePatterns = workspaceRoot
     ? (readWorkspacePatterns(workspaceRoot) ?? [])
     : []

   return {
     cwd: resolvedCwd,
     workspaceRoot,
     packageRoot,
-    isMonorepo: workspaceRoot !== null,
+    isMonorepo,

Based on learnings: packaging-related monorepo checks should answer whether the package is inside the workspace, not whether the package root itself is the workspace root.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const workspaceRoot =
findWorkspaceRoot(packageRoot ?? resolvedTargetPath) ??
findWorkspaceRoot(resolvedCwd)
const workspacePatterns = workspaceRoot
? (readWorkspacePatterns(workspaceRoot) ?? [])
: []
return {
cwd: resolvedCwd,
workspaceRoot,
packageRoot,
isMonorepo: workspaceRoot !== null,
workspacePatterns,
const workspaceRoot =
findWorkspaceRoot(packageRoot ?? resolvedTargetPath) ??
findWorkspaceRoot(resolvedCwd)
const isMonorepo =
packageRoot !== null &&
workspaceRoot !== null &&
workspaceRoot !== packageRoot
const workspacePatterns = workspaceRoot
? (readWorkspacePatterns(workspaceRoot) ?? [])
: []
return {
cwd: resolvedCwd,
workspaceRoot,
packageRoot,
isMonorepo,
workspacePatterns,
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/intent/src/core/project-context.ts` around lines 35 - 47, The
isMonorepo flag incorrectly uses workspaceRoot !== null; change it so it only
becomes true when a workspace exists AND the current package is inside it (i.e.
packageRoot is not the workspace root). Update the returned isMonorepo
expression in project-context.ts to check both workspaceRoot !== null and
packageRoot !== workspaceRoot (or equivalent null-safe comparison) so the root
package is not treated as a sub-package by callers like validate and
runEditPackageJson.

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('/'))
}
2 changes: 1 addition & 1 deletion packages/intent/src/scanner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import {
findWorkspaceRoot,
readWorkspacePatterns,
resolveWorkspacePackages,
} from './setup.js'
} from './workspace-patterns.js'
import type {
InstalledVariant,
IntentConfig,
Expand Down
Loading
Loading