From 7252e5823b438bb033ceec64c84275d247df88a7 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Mon, 21 Sep 2026 13:46:21 +0800 Subject: [PATCH] fix(boot): keep linked-root removal independent of relinking --- .../src/profile-resolution/resolver.ts | 38 +-- .../src/profile-resolution/service.ts | 5 +- .../tests/profile-resolution-service.spec.ts | 65 ++++ .../app-boot/tests/profile-resolution.spec.ts | 304 ++++++++++++++++++ 4 files changed, 392 insertions(+), 20 deletions(-) diff --git a/packages/boot/app-boot/src/profile-resolution/resolver.ts b/packages/boot/app-boot/src/profile-resolution/resolver.ts index 215c82b0d0..ea931a5087 100644 --- a/packages/boot/app-boot/src/profile-resolution/resolver.ts +++ b/packages/boot/app-boot/src/profile-resolution/resolver.ts @@ -7,7 +7,7 @@ import { fileURLToPath, pathToFileURL } from 'node:url' import { getEnvironmentData, setEnvironmentData } from 'node:worker_threads' import type { ModuleLoaderV1, ModuleLoaderV2, ResolveResult } from '@deepseek-ai/cordis-plugin-loader' import { imports as resolvePackageImports, type Package as ResolvePackageManifest } from 'resolve.exports' -import type { LinkedRoot, RuntimeResolutionEntry, RuntimeResolution } from '../profile.ts' +import type { RuntimeResolutionEntry, RuntimeResolution } from '../profile.ts' const WORKER_RESOLUTION_KEY = '@deepseek-ai/dsh-app-boot/profile-resolution' const EMPTY_ATTRIBUTES: ImportAttributes = Object.freeze({}) @@ -91,11 +91,6 @@ type InterceptionLayer = | LayerPositions & { readonly kind: 'profile'; readonly active: boolean } | { readonly kind: 'linked' } -interface CompiledLinkedRoot { - readonly root: LinkedRoot - readonly paths: readonly string[] -} - interface ParentRoutes { readonly parent: string readonly layer: InterceptionLayer @@ -112,7 +107,7 @@ interface CompiledResolution { readonly profilePaths: readonly string[] readonly profile: readonly string[] readonly installationPaths: readonly string[] - readonly linkedRoots: readonly CompiledLinkedRoot[] + readonly linkedPaths: readonly string[] readonly localPackageNames: ReadonlySet readonly esmRoutes: ResolutionRoutes readonly cjsRoutes: ResolutionRoutes @@ -129,9 +124,11 @@ export interface RuntimeInterception { */ packageDir(specifier: string, parentURL: string): string | undefined /** - * Atomically publish an additive package table and fresh caches. - * @param successor - fully constructed additive successor generation. - * @throws when the profile scope, an existing package mapping, or a linked root's real directory changes. + * Atomically publish a complete successor and fresh caches. Linked roots may be added or removed. + * Removed roots stop intercepting uncovered directories; existing modules and Node caches remain intact. + * @param successor - fully constructed generation retaining existing package mappings and local names. + * @throws when the profile scope or existing mappings change, local names are removed or override + * existing mappings, or a previously published link name selects a different real directory. */ replace(successor: RuntimeResolution): void /** Restore the native resolver methods. Interceptions dispose in reverse order. */ @@ -178,7 +175,7 @@ function compileResolution(resolution: RuntimeResolution): CompiledResolution { installationPaths: [...new Set(resolution.entries .filter(entry => entry.scope === 'installation') .flatMap(entry => prefixes(entry.packageDir)))], - linkedRoots: resolution.linkedRoots.map(root => ({ root, paths: prefixes(root.realPath) })), + linkedPaths: resolution.linkedRoots.flatMap(root => prefixes(root.realPath)), localPackageNames: new Set(resolution.localPackageNames), esmRoutes: new Map(), cjsRoutes: new Map(), @@ -205,7 +202,7 @@ function findInterceptionLayer(path: string, resolution: CompiledResolution): In const activeProfile = resolution.profile.find(prefix => path.startsWith(prefix)) const dir = treeRoot !== undefined ? profileChild(path, treeRoot) : activeProfile?.slice(0, -1) if (dir !== undefined) return computeProfileLayer(dir, activeProfile !== undefined) - if (!resolution.linkedRoots.some(candidate => startsWithin(path, candidate.paths))) return undefined + if (!startsWithin(path, resolution.linkedPaths)) return undefined if (startsWithin(path, resolution.installationPaths)) return undefined return { kind: 'linked' } } @@ -366,9 +363,11 @@ function sameResolution(left: string, right: string): boolean { /** One mutable pointer to the immutable runtime resolution. */ class ResolutionRouter { private current: CompiledResolution + private readonly linkedTargets: Map constructor(resolution: RuntimeResolution, private readonly nodeModulePaths: (directory: string) => string[]) { this.current = compileResolution(resolution) + this.linkedTargets = new Map(resolution.linkedRoots.map(root => [root.name, root.realPath])) } replace(successor: RuntimeResolution): void { @@ -398,15 +397,18 @@ class ResolutionRouter { throw new Error(`profile resolution: overriding ${JSON.stringify(name)} locally requires a process restart`) } } - // Node memoizes real paths for the process lifetime, so a link that now points elsewhere would keep - // answering from its former directory. - for (const { root } of this.current.linkedRoots) { - const next = successor.linkedRoots.find(candidate => candidate.name === root.name) - if (next !== undefined && !sameResolution(root.realPath, next.realPath)) { + // Node retains realpath caches after a root leaves the interception scope. + for (const root of successor.linkedRoots) { + const previous = this.linkedTargets.get(root.name) + if (previous !== undefined && !sameResolution(previous, root.realPath)) { throw new Error(`profile resolution: relinking ${JSON.stringify(root.name)} requires a process restart`) } } - this.current = compileResolution(successor) + const next = compileResolution(successor) + for (const { name, realPath } of successor.linkedRoots) { + if (!this.linkedTargets.has(name)) this.linkedTargets.set(name, realPath) + } + this.current = next } /** Whether a module path has an interception layer: it lies in the profiles tree, the active profile, or a linked root. */ diff --git a/packages/boot/app-boot/src/profile-resolution/service.ts b/packages/boot/app-boot/src/profile-resolution/service.ts index fad03a2582..5523b51eaf 100644 --- a/packages/boot/app-boot/src/profile-resolution/service.ts +++ b/packages/boot/app-boot/src/profile-resolution/service.ts @@ -75,8 +75,9 @@ export class PluginPackages extends Service { } /** - * Publish an additive successor generation for this process and subsequently created Workers. - * @param successor - fully constructed successor generation. + * Publish a complete successor generation for this process and subsequently created Workers. + * Linked roots may be removed without unloading modules or clearing Node caches. + * @param successor - fully constructed generation accepted by {@link RuntimeInterception.replace}. */ replace(successor: RuntimeResolution): void { if (this.interception === undefined) throw new Error('plugin-packages: runtime resolution is not installed') diff --git a/packages/boot/app-boot/tests/profile-resolution-service.spec.ts b/packages/boot/app-boot/tests/profile-resolution-service.spec.ts index 2ba557170c..d066329d36 100644 --- a/packages/boot/app-boot/tests/profile-resolution-service.spec.ts +++ b/packages/boot/app-boot/tests/profile-resolution-service.spec.ts @@ -161,6 +161,71 @@ describe('profile package metadata service', () => { )).toBeUndefined() }) + it('removes and restores linked roots while rejecting changes to their historical targets', async () => { + const root = realpathSync.native(mkdtempSync(join(tmpdir(), 'dsh-linked-package-service-'))) + roots.push(root) + const profilesDir = join(root, 'profiles') + const profileDir = join(profilesDir, 'test') + const runtime = join(root, 'runtime') + const runtimeAnchor = pkg(runtime, '1.0.0') + const linkedA = join(root, 'work', 'a') + const linkedB = join(root, 'work', 'b') + for (const dir of [linkedA, linkedB]) { + file(join(dir, 'package.json'), JSON.stringify({ + name: 'linked-plugin', peerDependencies: { 'metadata-lib': '*' }, + })) + } + const local = join(linkedA, 'node_modules', 'metadata-lib') + pkg(local, '2.0.0') + const parentPath = join(linkedA, 'entry.mjs') + file(parentPath, '') + const parentURL = pathToFileURL(parentPath).href + const require = createRequire(parentURL) + const linked = { name: 'linked-plugin', realPath: linkedA } + const initial: RuntimeResolution = { + ...resolution(profilesDir, profileDir, runtime, runtimeAnchor, '1.0.0'), + linkedRoots: [linked], + } + const removed: RuntimeResolution = { ...initial, linkedRoots: [] } + const relinked: RuntimeResolution = { ...initial, linkedRoots: [{ ...linked, realPath: linkedB }] } + const key = '@deepseek-ai/dsh-app-boot/profile-resolution' + const previous = getEnvironmentData(key) + const ctx = new Context() + contexts.push(ctx) + await ctx.plugin(PluginPackages, { resolution: initial }) + + const expectSelectedPackage = (dir: string, version: string): void => { + expect(ctx.pluginPackages.packageOf('metadata-lib', parentURL)).toMatchObject({ + name: 'metadata-lib', version, dir, + }) + expect(require.resolve('metadata-lib')).toBe(realpathSync.native(join(dir, 'index.cjs'))) + } + expectSelectedPackage(runtime, '1.0.0') + expect(getEnvironmentData(key)).toEqual({ resolution: initial }) + + ctx.pluginPackages.replace(removed) + expectSelectedPackage(local, '2.0.0') + expect(getEnvironmentData(key)).toEqual({ resolution: removed }) + + ctx.pluginPackages.replace(initial) + expectSelectedPackage(runtime, '1.0.0') + expect(getEnvironmentData(key)).toEqual({ resolution: initial }) + + ctx.pluginPackages.replace(removed) + expectSelectedPackage(local, '2.0.0') + const removedWorkerData = getEnvironmentData(key) + expect(removedWorkerData).toEqual({ resolution: removed }) + expect(() => { ctx.pluginPackages.replace(relinked) }).toThrow( + 'profile resolution: relinking "linked-plugin" requires a process restart', + ) + expectSelectedPackage(local, '2.0.0') + expect(getEnvironmentData(key)).toBe(removedWorkerData) + + await ctx.fiber.dispose() + contexts.pop() + expect(getEnvironmentData(key)).toBe(previous) + }) + it('publishes additive generations to the process and future Workers', async () => { const root = mkdtempSync(join(tmpdir(), 'dsh-package-service-resolution-')) roots.push(root) diff --git a/packages/boot/app-boot/tests/profile-resolution.spec.ts b/packages/boot/app-boot/tests/profile-resolution.spec.ts index 7d0a78f2d3..493202fd69 100644 --- a/packages/boot/app-boot/tests/profile-resolution.spec.ts +++ b/packages/boot/app-boot/tests/profile-resolution.spec.ts @@ -1876,6 +1876,310 @@ describe('runtime resolution', { concurrent: false }, () => { return { f, linkedRoot, helper, writeManifest } } + describe('linked root generations', () => { + const peer = '@deepseek-ai/dsh-core' + + function unsavedFixture(): ReturnType { + const result = linkedPluginFixture() + file(join(result.f.profile.dir, 'package.json'), JSON.stringify({ + name: 'dsh-profile-web', private: true, dependencies: {}, + })) + return result + } + + function expectPeerLookup( + registration: RuntimeInterception, require: NodeJS.Require, parent: string, selected: string, + ): void { + expect(require.resolve(peer)).toBe(join(selected, 'index.cjs')) + expect(resolveFrom(peer, parent)).toBe(pathToFileURL(join(selected, 'index.js')).href) + expect(registration.packageDir(peer, parent)).toBe(selected) + } + + it('returns an unlinked importer to native lookup without changing its first generation', async () => { + const { f, linkedRoot } = unsavedFixture() + const link = join(f.profile.dir, 'node_modules', 'my-plugin') + const local = join(linkedRoot, 'node_modules', peer) + const require = createRequire(join(linkedRoot, 'entry.cjs')) + const parent = pathToFileURL(join(linkedRoot, 'entry.mjs')).href + const first = await resolutionOf(f) + expect(first.localPackageNames).toEqual([]) + expect(first.linkedRoots).toEqual([{ name: 'my-plugin', realPath: linkedRoot }]) + const registration = installRuntimeInterception(first) + registrations.push(registration) + try { + expectPeerLookup(registration, require, parent, f.installed) + unlinkSync(link) + const second = await resolutionOf(f) + expect(second.localPackageNames).toEqual([]) + expect(second.linkedRoots).toEqual([]) + registration.replace(second) + expectPeerLookup(registration, require, parent, local) + expect(first.linkedRoots).toEqual([{ name: 'my-plugin', realPath: linkedRoot }]) + } finally { + registration.dispose() + registrations.pop() + } + }) + + it('reports native misses after unlinking when no physical peer remains', async () => { + const { f, linkedRoot } = unsavedFixture() + rmSync(join(linkedRoot, 'node_modules', peer), { recursive: true }) + const require = createRequire(join(linkedRoot, 'entry.cjs')) + const parent = pathToFileURL(join(linkedRoot, 'entry.mjs')).href + const first = await resolutionOf(f) + expect(first.localPackageNames).toEqual([]) + const registration = installRuntimeInterception(first) + registrations.push(registration) + try { + expectPeerLookup(registration, require, parent, f.installed) + unlinkSync(join(f.profile.dir, 'node_modules', 'my-plugin')) + const second = await resolutionOf(f) + expect(second.linkedRoots).toEqual([]) + registration.replace(second) + expect(thrownError(() => require.resolve(peer))).toMatchObject({ code: 'MODULE_NOT_FOUND' }) + expect(thrownError(() => require(peer))).toMatchObject({ code: 'MODULE_NOT_FOUND' }) + expect(thrownError(() => resolveFrom(peer, parent))).toMatchObject({ code: 'ERR_MODULE_NOT_FOUND' }) + await expect(importFrom(peer, parent)).rejects.toMatchObject({ code: 'ERR_MODULE_NOT_FOUND' }) + expect(registration.packageDir(peer, parent)).toBeUndefined() + } finally { + registration.dispose() + registrations.pop() + } + }) + + it('restores the same link target and admits an additional root name', async () => { + const { f, linkedRoot } = unsavedFixture() + const link = join(f.profile.dir, 'node_modules', 'my-plugin') + const require = createRequire(join(linkedRoot, 'entry.cjs')) + const parent = pathToFileURL(join(linkedRoot, 'entry.mjs')).href + const first = await resolutionOf(f) + expect(first.localPackageNames).toEqual([]) + const registration = installRuntimeInterception(first) + registrations.push(registration) + try { + expectPeerLookup(registration, require, parent, f.installed) + unlinkSync(link) + registration.replace(await resolutionOf(f)) + expectPeerLookup(registration, require, parent, join(linkedRoot, 'node_modules', peer)) + const linkType = process.platform === 'win32' ? 'junction' : 'dir' + symlinkSync(linkedRoot, link, linkType) + registration.replace(await resolutionOf(f)) + expectPeerLookup(registration, require, parent, f.installed) + + const additional = join(f.root, 'work', 'another-plugin') + pkg(additional, 'another-plugin', 0, {}, { [peer]: '*' }) + pkg(join(additional, 'node_modules', peer), peer, 31) + symlinkSync(additional, join(f.profile.dir, 'node_modules', 'another-plugin'), linkType) + const successor = await resolutionOf(f) + expect(successor.localPackageNames).toEqual([]) + expect(successor.linkedRoots).toEqual([ + { name: 'another-plugin', realPath: additional }, + { name: 'my-plugin', realPath: linkedRoot }, + ]) + registration.replace(successor) + expectPeerLookup(registration, require, parent, f.installed) + expectPeerLookup(registration, createRequire(join(additional, 'entry.cjs')), + pathToFileURL(join(additional, 'entry.mjs')).href, f.installed) + } finally { + registration.dispose() + registrations.pop() + } + }) + + it.each(['inner', 'outer'] as const)( + 'keeps an overlapping importer proxied after removing the %s root until the last root is removed', async (removed) => { + const { f, linkedRoot } = unsavedFixture() + const innerLink = join(f.profile.dir, 'node_modules', 'my-plugin') + const outer = dirname(linkedRoot) + const outerLink = join(f.profile.dir, 'node_modules', 'workspace') + symlinkSync(outer, outerLink, process.platform === 'win32' ? 'junction' : 'dir') + const require = createRequire(join(linkedRoot, 'entry.cjs')) + const parent = pathToFileURL(join(linkedRoot, 'entry.mjs')).href + const first = await resolutionOf(f) + expect(first.localPackageNames).toEqual([]) + expect(first.linkedRoots).toEqual([ + { name: 'my-plugin', realPath: linkedRoot }, + { name: 'workspace', realPath: outer }, + ]) + const registration = installRuntimeInterception(first) + registrations.push(registration) + try { + expectPeerLookup(registration, require, parent, f.installed) + unlinkSync(removed === 'inner' ? innerLink : outerLink) + const second = await resolutionOf(f) + expect(second.linkedRoots).toEqual(removed === 'inner' + ? [{ name: 'workspace', realPath: outer }] + : [{ name: 'my-plugin', realPath: linkedRoot }]) + registration.replace(second) + expectPeerLookup(registration, require, parent, f.installed) + unlinkSync(removed === 'inner' ? outerLink : innerLink) + const third = await resolutionOf(f) + expect(third.linkedRoots).toEqual([]) + registration.replace(third) + expectPeerLookup(registration, require, parent, join(linkedRoot, 'node_modules', peer)) + } finally { + registration.dispose() + registrations.pop() + } + }, + ) + + it('uses native fresh subpaths from a loaded caller while held modules keep their values', async () => { + const { f, linkedRoot } = unsavedFixture() + const local = join(linkedRoot, 'node_modules', peer) + for (const [dir, marker] of [[f.installed, 1], [local, 21]] as const) { + file(join(dir, 'package.json'), JSON.stringify({ + name: peer, version: `${String(marker)}.0.0`, type: 'module', + exports: { + '.': { import: './index.js', require: './index.cjs' }, + './fresh': { import: './fresh.js', require: './fresh.cjs' }, + }, + })) + file(join(dir, 'fresh.js'), `export const marker = ${String(marker)}\n`) + file(join(dir, 'fresh.cjs'), `module.exports = { marker: ${String(marker)} }\n`) + } + // The fresh subpath has no completed Node resolution; root removal does not replace held module references. + file(join(linkedRoot, 'caller.js'), `import * as held from '${peer}'\nexport { held }\n` + + `export async function fresh() { return await import('${peer}/fresh') }\n`) + file(join(linkedRoot, 'caller.cjs'), `module.exports = { held: require('${peer}'), ` + + `fresh: () => require('${peer}/fresh') }\n`) + const require = createRequire(join(linkedRoot, 'entry.cjs')) + const parent = pathToFileURL(join(linkedRoot, 'entry.mjs')).href + const first = await resolutionOf(f) + expect(first.localPackageNames).toEqual([]) + const registration = installRuntimeInterception(first) + registrations.push(registration) + try { + const cjsCaller = require('./caller.cjs') as { held: { marker: number }; fresh(): { marker: number } } + const esmCaller = await importFrom('./caller.js', parent) as { + held: { marker: number } + fresh(): Promise<{ marker: number }> + } + const heldCjs = cjsCaller.held + const heldEsm = esmCaller.held + expect(heldCjs).toEqual({ marker: 1 }) + expect(heldEsm).toMatchObject({ marker: 1 }) + unlinkSync(join(f.profile.dir, 'node_modules', 'my-plugin')) + const second = await resolutionOf(f) + expect(second.linkedRoots).toEqual([]) + registration.replace(second) + expect(cjsCaller.fresh()).toEqual({ marker: 21 }) + await expect(esmCaller.fresh()).resolves.toMatchObject({ marker: 21 }) + expect(cjsCaller.held).toBe(heldCjs) + expect(esmCaller.held).toBe(heldEsm) + expect(heldCjs).toEqual({ marker: 1 }) + expect(heldEsm).toMatchObject({ marker: 1 }) + } finally { + registration.dispose() + registrations.pop() + } + }) + + it.each(['initial', 'added'] as const)( + 'rejects a removed %s root retarget without reactivating either directory', async (generation) => { + const { f, linkedRoot } = unsavedFixture() + const link = join(f.profile.dir, 'node_modules', 'my-plugin') + const linkType = process.platform === 'win32' ? 'junction' : 'dir' + const require = createRequire(join(linkedRoot, 'entry.cjs')) + const parent = pathToFileURL(join(linkedRoot, 'entry.mjs')).href + if (generation === 'added') unlinkSync(link) + const first = await resolutionOf(f) + expect(first.localPackageNames).toEqual([]) + expect(first.linkedRoots).toEqual(generation === 'added' + ? [] : [{ name: 'my-plugin', realPath: linkedRoot }]) + const registration = installRuntimeInterception(first) + registrations.push(registration) + try { + if (generation === 'added') { + symlinkSync(linkedRoot, link, linkType) + registration.replace(await resolutionOf(f)) + } + expectPeerLookup(registration, require, parent, f.installed) + unlinkSync(link) + const removed = await resolutionOf(f) + expect(removed.linkedRoots).toEqual([]) + registration.replace(removed) + expectPeerLookup(registration, require, parent, join(linkedRoot, 'node_modules', peer)) + + const target = join(f.root, 'work', 'retargeted-plugin') + pkg(target, 'my-plugin', 0, {}, { [peer]: '*' }) + pkg(join(target, 'node_modules', peer), peer, 22) + symlinkSync(target, link, linkType) + const successor = await resolutionOf(f) + expect(successor.localPackageNames).toEqual([]) + expect(successor.linkedRoots).toEqual([{ name: 'my-plugin', realPath: target }]) + expect(() => { registration.replace(successor) }) + .toThrow(/^profile resolution: relinking "my-plugin" requires a process restart$/u) + expectPeerLookup(registration, require, parent, join(linkedRoot, 'node_modules', peer)) + expectPeerLookup(registration, createRequire(join(target, 'entry.cjs')), + pathToFileURL(join(target, 'entry.mjs')).href, join(target, 'node_modules', peer)) + } finally { + registration.dispose() + registrations.pop() + } + }, + ) + + it('does not retain targets from a rejected successor', async () => { + const { f, linkedRoot } = unsavedFixture() + const modules = join(f.profile.dir, 'node_modules') + const originalLink = join(modules, 'my-plugin') + const newLink = join(modules, 'a-new-plugin') + const replacement = join(f.root, 'work', 'replacement') + const rejectedTarget = join(f.root, 'work', 'rejected-target') + const acceptedTarget = join(f.root, 'work', 'accepted-target') + for (const [dir, name, marker] of [ + [replacement, 'my-plugin', 22], + [rejectedTarget, 'a-new-plugin', 31], + [acceptedTarget, 'a-new-plugin', 32], + ] as const) { + pkg(dir, name, 0, {}, { [peer]: '*' }) + pkg(join(dir, 'node_modules', peer), peer, marker) + } + const require = createRequire(join(linkedRoot, 'entry.cjs')) + const parent = pathToFileURL(join(linkedRoot, 'entry.mjs')).href + const first = await resolutionOf(f) + expect(first.localPackageNames).toEqual([]) + const registration = installRuntimeInterception(first) + registrations.push(registration) + try { + expectPeerLookup(registration, require, parent, f.installed) + unlinkSync(originalLink) + const removed = await resolutionOf(f) + expect(removed.linkedRoots).toEqual([]) + registration.replace(removed) + expectPeerLookup(registration, require, parent, join(linkedRoot, 'node_modules', peer)) + + const linkType = process.platform === 'win32' ? 'junction' : 'dir' + symlinkSync(replacement, originalLink, linkType) + symlinkSync(rejectedTarget, newLink, linkType) + const rejected = await resolutionOf(f) + expect(rejected.linkedRoots).toEqual([ + { name: 'a-new-plugin', realPath: rejectedTarget }, + { name: 'my-plugin', realPath: replacement }, + ]) + expect(() => { registration.replace(rejected) }) + .toThrow(/^profile resolution: relinking "my-plugin" requires a process restart$/u) + expectPeerLookup(registration, require, parent, join(linkedRoot, 'node_modules', peer)) + expectPeerLookup(registration, createRequire(join(rejectedTarget, 'entry.cjs')), + pathToFileURL(join(rejectedTarget, 'entry.mjs')).href, join(rejectedTarget, 'node_modules', peer)) + + unlinkSync(originalLink) + unlinkSync(newLink) + symlinkSync(acceptedTarget, newLink, linkType) + const accepted = await resolutionOf(f) + expect(accepted.linkedRoots).toEqual([{ name: 'a-new-plugin', realPath: acceptedTarget }]) + registration.replace(accepted) + expectPeerLookup(registration, createRequire(join(acceptedTarget, 'entry.cjs')), + pathToFileURL(join(acceptedTarget, 'entry.mjs')).href, f.installed) + expectPeerLookup(registration, require, parent, join(linkedRoot, 'node_modules', peer)) + } finally { + registration.dispose() + registrations.pop() + } + }) + }) + it.each(['src', 'dist'] as const)( 'keeps intermediate %s/node_modules ahead of linked-root peers and ancestor packages', async (directory) => { const { f, linkedRoot } = linkedPluginFixture()