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/tangy-plants-hunt.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@tanstack/intent': patch
---

Fix transitive skill discovery under pnpm's isolated linker. Skills shipped by a transitive dependency of a skill-bearing direct dependency were not discovered… Each package's dependencies are now resolved from its realpath, where pnpm resolution succeeds. Hoisted (npm/yarn/bun) layouts are unaffected.
4 changes: 3 additions & 1 deletion packages/intent/src/discovery/walk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,8 @@ export function createDependencyWalker(opts: CreateDependencyWalkerOptions) {
}

function walkDeps(pkgDir: string, pkgName: string): void {
// Resolve from the realpath: a pnpm symlink path can't resolve store-only
// transitive deps, and walkVisited dedups on realpath so no later retry.
const pkgKey = opts.getFsIdentity(pkgDir)
if (walkVisited.has(pkgKey)) return
walkVisited.add(pkgKey)
Expand All @@ -70,7 +72,7 @@ export function createDependencyWalker(opts: CreateDependencyWalkerOptions) {
return
}

walkDepsOf(pkgJson, pkgDir)
walkDepsOf(pkgJson, pkgKey)
}

function walkKnownPackages(): void {
Expand Down
167 changes: 167 additions & 0 deletions packages/intent/tests/scanner.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,173 @@ describe('scanForIntents', () => {
expect(result.packages[0]!.name).toBe('my-lib')
})

it('discovers transitive skills of a skill-bearing direct dep under pnpm isolated linker (#153)', () => {
// pnpm isolated layout: a store-only transitive dep (start-core) reached
// only through its skill-bearing parent's (react-start) store dir.
writeFileSync(join(root, 'pnpm-lock.yaml'), '')
writeJson(join(root, 'package.json'), {
name: 'consumer',
version: '1.0.0',
dependencies: { '@scope/react-start': '1.0.0' },
})

const pnpmDir = join(root, 'node_modules', '.pnpm')

const startCoreStore = createDir(
pnpmDir,
'@scope+start-core@1.0.0',
'node_modules',
'@scope',
'start-core',
)
writeJson(join(startCoreStore, 'package.json'), {
name: '@scope/start-core',
version: '1.0.0',
intent: { version: 1, repo: 'scope/start-core', docs: 'docs/' },
})
writeSkillMd(createDir(startCoreStore, 'skills', 'start-core'), {
name: 'start-core',
description: 'Start core skill',
type: 'core',
})

const reactStartStore = createDir(
pnpmDir,
'@scope+react-start@1.0.0',
'node_modules',
'@scope',
'react-start',
)
writeJson(join(reactStartStore, 'package.json'), {
name: '@scope/react-start',
version: '1.0.0',
intent: { version: 1, repo: 'scope/react-start', docs: 'docs/' },
dependencies: { '@scope/start-core': '1.0.0' },
})
writeSkillMd(createDir(reactStartStore, 'skills', 'react-start'), {
name: 'react-start',
description: 'React start skill',
type: 'core',
})

// start-core symlinked as a sibling inside react-start's store dir only.
createDir(pnpmDir, '@scope+react-start@1.0.0', 'node_modules', '@scope')
symlinkSync(
startCoreStore,
join(
pnpmDir,
'@scope+react-start@1.0.0',
'node_modules',
'@scope',
'start-core',
),
)

// react-start hoisted to the top-level node_modules; start-core is not.
createDir(root, 'node_modules', '@scope')
symlinkSync(
reactStartStore,
join(root, 'node_modules', '@scope', 'react-start'),
)

const result = scanForIntents(root)

const names = result.packages.map((p) => p.name)
expect(names).toContain('@scope/react-start')
expect(names).toContain('@scope/start-core')

const startCore = result.packages.find(
(p) => p.name === '@scope/start-core',
)
expect(startCore!.skills.map((s) => s.name)).toContain('start-core')

// One installed version must not be reported as a version conflict.
expect(result.conflicts).toEqual([])
})

it('discovers transitive skills when the dep resolves through a second symlink hop (#153 residual risk)', () => {
// The transitive dep is reached through two symlink hops; realpathSync must
// collapse the whole chain, not just one hop.
writeFileSync(join(root, 'pnpm-lock.yaml'), '')
writeJson(join(root, 'package.json'), {
name: 'consumer',
version: '1.0.0',
dependencies: { '@scope/react-start': '1.0.0' },
})

const pnpmDir = join(root, 'node_modules', '.pnpm')

const startCoreReal = createDir(
pnpmDir,
'@scope+start-core@1.0.0',
'node_modules',
'@scope',
'start-core',
)
writeJson(join(startCoreReal, 'package.json'), {
name: '@scope/start-core',
version: '1.0.0',
intent: { version: 1, repo: 'scope/start-core', docs: 'docs/' },
})
writeSkillMd(createDir(startCoreReal, 'skills', 'start-core'), {
name: 'start-core',
description: 'Start core skill',
type: 'core',
})

// Intermediate symlink hop: a separate link that targets the real store dir.
const intermediateScope = createDir(root, '.intermediate', '@scope')
const intermediateStartCore = join(intermediateScope, 'start-core')
symlinkSync(startCoreReal, intermediateStartCore)

const reactStartStore = createDir(
pnpmDir,
'@scope+react-start@1.0.0',
'node_modules',
'@scope',
'react-start',
)
writeJson(join(reactStartStore, 'package.json'), {
name: '@scope/react-start',
version: '1.0.0',
intent: { version: 1, repo: 'scope/react-start', docs: 'docs/' },
dependencies: { '@scope/start-core': '1.0.0' },
})
writeSkillMd(createDir(reactStartStore, 'skills', 'react-start'), {
name: 'react-start',
description: 'React start skill',
type: 'core',
})

// react-start's sibling link -> intermediate link -> real store dir.
createDir(pnpmDir, '@scope+react-start@1.0.0', 'node_modules', '@scope')
symlinkSync(
intermediateStartCore,
join(
pnpmDir,
'@scope+react-start@1.0.0',
'node_modules',
'@scope',
'start-core',
),
)

createDir(root, 'node_modules', '@scope')
symlinkSync(
reactStartStore,
join(root, 'node_modules', '@scope', 'react-start'),
)

const result = scanForIntents(root)

const startCore = result.packages.find(
(p) => p.name === '@scope/start-core',
)
expect(startCore).toBeDefined()
expect(startCore!.skills.map((s) => s.name)).toContain('start-core')
expect(result.conflicts).toEqual([])
})

it('discovers sub-skills', () => {
const pkgDir = createDir(root, 'node_modules', '@tanstack', 'db')
writeJson(join(pkgDir, 'package.json'), {
Expand Down
Loading