Skip to content

Commit d787ddc

Browse files
fix: discover transitive skills under pnpm's isolated linker (#154)
* discover transitive skills under pnpm's isolated linker * changeset * fix
1 parent ccc3fea commit d787ddc

3 files changed

Lines changed: 175 additions & 1 deletion

File tree

.changeset/tangy-plants-hunt.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@tanstack/intent': patch
3+
---
4+
5+
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.

packages/intent/src/discovery/walk.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,8 @@ export function createDependencyWalker(opts: CreateDependencyWalkerOptions) {
5858
}
5959

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

73-
walkDepsOf(pkgJson, pkgDir)
75+
walkDepsOf(pkgJson, pkgKey)
7476
}
7577

7678
function walkKnownPackages(): void {

packages/intent/tests/scanner.test.ts

Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -194,6 +194,173 @@ describe('scanForIntents', () => {
194194
expect(result.packages[0]!.name).toBe('my-lib')
195195
})
196196

197+
it('discovers transitive skills of a skill-bearing direct dep under pnpm isolated linker (#153)', () => {
198+
// pnpm isolated layout: a store-only transitive dep (start-core) reached
199+
// only through its skill-bearing parent's (react-start) store dir.
200+
writeFileSync(join(root, 'pnpm-lock.yaml'), '')
201+
writeJson(join(root, 'package.json'), {
202+
name: 'consumer',
203+
version: '1.0.0',
204+
dependencies: { '@scope/react-start': '1.0.0' },
205+
})
206+
207+
const pnpmDir = join(root, 'node_modules', '.pnpm')
208+
209+
const startCoreStore = createDir(
210+
pnpmDir,
211+
'@scope+start-core@1.0.0',
212+
'node_modules',
213+
'@scope',
214+
'start-core',
215+
)
216+
writeJson(join(startCoreStore, 'package.json'), {
217+
name: '@scope/start-core',
218+
version: '1.0.0',
219+
intent: { version: 1, repo: 'scope/start-core', docs: 'docs/' },
220+
})
221+
writeSkillMd(createDir(startCoreStore, 'skills', 'start-core'), {
222+
name: 'start-core',
223+
description: 'Start core skill',
224+
type: 'core',
225+
})
226+
227+
const reactStartStore = createDir(
228+
pnpmDir,
229+
'@scope+react-start@1.0.0',
230+
'node_modules',
231+
'@scope',
232+
'react-start',
233+
)
234+
writeJson(join(reactStartStore, 'package.json'), {
235+
name: '@scope/react-start',
236+
version: '1.0.0',
237+
intent: { version: 1, repo: 'scope/react-start', docs: 'docs/' },
238+
dependencies: { '@scope/start-core': '1.0.0' },
239+
})
240+
writeSkillMd(createDir(reactStartStore, 'skills', 'react-start'), {
241+
name: 'react-start',
242+
description: 'React start skill',
243+
type: 'core',
244+
})
245+
246+
// start-core symlinked as a sibling inside react-start's store dir only.
247+
createDir(pnpmDir, '@scope+react-start@1.0.0', 'node_modules', '@scope')
248+
symlinkSync(
249+
startCoreStore,
250+
join(
251+
pnpmDir,
252+
'@scope+react-start@1.0.0',
253+
'node_modules',
254+
'@scope',
255+
'start-core',
256+
),
257+
)
258+
259+
// react-start hoisted to the top-level node_modules; start-core is not.
260+
createDir(root, 'node_modules', '@scope')
261+
symlinkSync(
262+
reactStartStore,
263+
join(root, 'node_modules', '@scope', 'react-start'),
264+
)
265+
266+
const result = scanForIntents(root)
267+
268+
const names = result.packages.map((p) => p.name)
269+
expect(names).toContain('@scope/react-start')
270+
expect(names).toContain('@scope/start-core')
271+
272+
const startCore = result.packages.find(
273+
(p) => p.name === '@scope/start-core',
274+
)
275+
expect(startCore!.skills.map((s) => s.name)).toContain('start-core')
276+
277+
// One installed version must not be reported as a version conflict.
278+
expect(result.conflicts).toEqual([])
279+
})
280+
281+
it('discovers transitive skills when the dep resolves through a second symlink hop (#153 residual risk)', () => {
282+
// The transitive dep is reached through two symlink hops; realpathSync must
283+
// collapse the whole chain, not just one hop.
284+
writeFileSync(join(root, 'pnpm-lock.yaml'), '')
285+
writeJson(join(root, 'package.json'), {
286+
name: 'consumer',
287+
version: '1.0.0',
288+
dependencies: { '@scope/react-start': '1.0.0' },
289+
})
290+
291+
const pnpmDir = join(root, 'node_modules', '.pnpm')
292+
293+
const startCoreReal = createDir(
294+
pnpmDir,
295+
'@scope+start-core@1.0.0',
296+
'node_modules',
297+
'@scope',
298+
'start-core',
299+
)
300+
writeJson(join(startCoreReal, 'package.json'), {
301+
name: '@scope/start-core',
302+
version: '1.0.0',
303+
intent: { version: 1, repo: 'scope/start-core', docs: 'docs/' },
304+
})
305+
writeSkillMd(createDir(startCoreReal, 'skills', 'start-core'), {
306+
name: 'start-core',
307+
description: 'Start core skill',
308+
type: 'core',
309+
})
310+
311+
// Intermediate symlink hop: a separate link that targets the real store dir.
312+
const intermediateScope = createDir(root, '.intermediate', '@scope')
313+
const intermediateStartCore = join(intermediateScope, 'start-core')
314+
symlinkSync(startCoreReal, intermediateStartCore)
315+
316+
const reactStartStore = createDir(
317+
pnpmDir,
318+
'@scope+react-start@1.0.0',
319+
'node_modules',
320+
'@scope',
321+
'react-start',
322+
)
323+
writeJson(join(reactStartStore, 'package.json'), {
324+
name: '@scope/react-start',
325+
version: '1.0.0',
326+
intent: { version: 1, repo: 'scope/react-start', docs: 'docs/' },
327+
dependencies: { '@scope/start-core': '1.0.0' },
328+
})
329+
writeSkillMd(createDir(reactStartStore, 'skills', 'react-start'), {
330+
name: 'react-start',
331+
description: 'React start skill',
332+
type: 'core',
333+
})
334+
335+
// react-start's sibling link -> intermediate link -> real store dir.
336+
createDir(pnpmDir, '@scope+react-start@1.0.0', 'node_modules', '@scope')
337+
symlinkSync(
338+
intermediateStartCore,
339+
join(
340+
pnpmDir,
341+
'@scope+react-start@1.0.0',
342+
'node_modules',
343+
'@scope',
344+
'start-core',
345+
),
346+
)
347+
348+
createDir(root, 'node_modules', '@scope')
349+
symlinkSync(
350+
reactStartStore,
351+
join(root, 'node_modules', '@scope', 'react-start'),
352+
)
353+
354+
const result = scanForIntents(root)
355+
356+
const startCore = result.packages.find(
357+
(p) => p.name === '@scope/start-core',
358+
)
359+
expect(startCore).toBeDefined()
360+
expect(startCore!.skills.map((s) => s.name)).toContain('start-core')
361+
expect(result.conflicts).toEqual([])
362+
})
363+
197364
it('discovers sub-skills', () => {
198365
const pkgDir = createDir(root, 'node_modules', '@tanstack', 'db')
199366
writeJson(join(pkgDir, 'package.json'), {

0 commit comments

Comments
 (0)