Parcourir la source

fix(boot): cover profile resolution edge cases

imccyu il y a 1 semaine
Parent
commit
c75f667822

+ 142 - 17
packages/boot/app-boot/src/profile-resolution/resolver.ts

@@ -34,6 +34,7 @@ interface CommonJsModule {
 
 interface InternalModules {
   esm: ModuleLoaderV1 | ModuleLoaderV2
+  esmConditions: readonly string[]
   cjs: CommonJsModule
   cjsConditions: ReadonlySet<string>
   modern: boolean
@@ -227,6 +228,7 @@ function packageImportsTarget(
         return undefined
       }
     }
+    if (basename(current) === 'node_modules') return undefined
     const next = dirname(current)
     /* v8 ignore next -- a MODULE_NOT_FOUND package-import target always has an owning package scope */
     if (next === current) return undefined
@@ -234,6 +236,18 @@ function packageImportsTarget(
   }
 }
 
+function packageSearchPaths(
+  entry: ProfileResolutionEntry, request: string, cjs: CommonJsModule,
+): string[] {
+  const name = barePackageName(request)
+  /* v8 ignore next -- fallback routes are created only for bare package requests */
+  if (name === undefined) return cjs._nodeModulePaths(dirname(entry.declarer))
+  const suffix = sep + name.split('/').join(sep)
+  return entry.packageDir.endsWith(suffix)
+    ? [entry.packageDir.slice(0, -suffix.length)]
+    : cjs._nodeModulePaths(dirname(entry.declarer))
+}
+
 function localCandidateOwnsResolution(candidate: string, resolved: string, request: string, name: string): boolean {
   if (startsWithin(resolved, prefixes(candidate))) return true
   if (sameResolution(candidate, resolved)) return true
@@ -497,6 +511,9 @@ function internalModules(): InternalModules {
   const cjsHelpers = addon.requireBuiltin('internal/modules/helpers') as {
     getCjsConditions(): ReadonlySet<string>
   }
+  const esmUtils = addon.requireBuiltin('internal/modules/esm/utils') as {
+    getDefaultConditions(): readonly string[]
+  }
   const esm = esmModule.getOrInitializeCascadedLoader()
   const modern = 'getOrCreateModuleJob' in esm
   /* v8 ignore start -- the supported Node 22/24/26 matrix validates each available Internal interface */
@@ -504,12 +521,14 @@ function internalModules(): InternalModules {
     || typeof Reflect.get(esm, modern ? 'getOrCreateModuleJob' : 'getModuleJobForImport') !== 'function'
     || (!modern && typeof Reflect.get(esm, 'resolve') !== 'function')
     || typeof cjsModule.Module._resolveFilename !== 'function'
-    || typeof cjsHelpers.getCjsConditions !== 'function') {
+    || typeof cjsHelpers.getCjsConditions !== 'function'
+    || typeof esmUtils.getDefaultConditions !== 'function') {
     throw new Error('profile resolution: unsupported Node module loader')
   }
   /* v8 ignore stop */
   return {
     esm,
+    esmConditions: esmUtils.getDefaultConditions(),
     cjs: cjsModule.Module,
     cjsConditions: cjsHelpers.getCjsConditions(),
     modern,
@@ -531,6 +550,29 @@ function throwWithImporter(error: unknown, routedParent: string, parent: string)
   throw error
 }
 
+function throwWithoutCjsAnchor(error: unknown, anchor: string): never {
+  const resolved = error as NodeJS.ErrnoException & { requireStack?: string[] }
+  const requireStack = resolved.requireStack
+  if (error instanceof Error
+    && resolved.code === 'MODULE_NOT_FOUND'
+    && requireStack?.[0] !== undefined
+    && sameResolution(requireStack[0], anchor)) {
+    const originalMessage = error.message
+    const originalBlock = `\nRequire stack:\n${requireStack.map(path => `- ${path}`).join('\n')}`
+    const remaining = requireStack.slice(1)
+    /* v8 ignore next -- routed calls always retain the original importing module */
+    const replacement = remaining.length === 0
+      ? ''
+      : `\nRequire stack:\n${remaining.map(path => `- ${path}`).join('\n')}`
+    error.message = originalMessage.replace(originalBlock, replacement)
+    resolved.requireStack = remaining
+    const stack = error.stack
+    /* v8 ignore next -- Node's resolver errors always carry a stack */
+    if (stack !== undefined) error.stack = stack.replace(originalMessage, error.message)
+  }
+  throw error
+}
+
 function assertEquivalent(actual: string, expected: string, request: string, parent: string): void {
   if (sameResolution(actual, expected)) return
   throw new Error(
@@ -538,6 +580,16 @@ function assertEquivalent(actual: string, expected: string, request: string, par
   )
 }
 
+function assertOptionalEquivalent(
+  actual: string | undefined, expected: string | undefined, request: string, parent: string,
+): void {
+  if (actual === undefined && expected === undefined) return
+  if (actual !== undefined && expected !== undefined && sameResolution(actual, expected)) return
+  throw new Error(
+    `profile resolution mismatch for ${JSON.stringify(request)} from ${parent}: disk selected ${actual ?? 'nothing'}, generation selected ${expected ?? 'nothing'}`,
+  )
+}
+
 /**
  * Install one profile generation on Node's default ESM and CommonJS resolvers.
  * @param generation - complete package table and profile scope.
@@ -549,12 +601,13 @@ export function installProfileResolution(
   behavior: ProfileResolutionBehavior = 'enforce',
 ): ProfileResolutionRegistration {
   const router = new ResolutionRouter(generation)
-  const { esm, cjs, cjsConditions, modern } = internalModules()
+  const { esm, esmConditions, cjs, cjsConditions, modern } = internalModules()
   const esmScope = new Map<string, boolean>()
-  const profileUrls = [
+  const profilePaths = [
     ...prefixes(generation.profilesDir),
     ...(generation.profileDir === undefined ? [] : prefixes(generation.profileDir)),
-  ].map(path => pathToFileURL(path).href)
+  ]
+  const profileUrls = profilePaths.map(path => pathToFileURL(path).href)
   let recentEsmParent: string | undefined
   let recentEsmScoped = false
   let delegatedEsm: { parent: string | undefined; request: string } | undefined
@@ -578,7 +631,38 @@ export function installProfileResolution(
       }
       if (!scoped) return native(request, parent, attributes)
       const state = router.routeUrl(request, parent)
-      if (state === undefined) return native(request, parent, attributes)
+      if (state === undefined) {
+        const target = request[0] === '#'
+          ? packageImportsTarget(fileURLToPath(parent), request, esmConditions)
+          : undefined
+        const recoverPackageImport = (error: unknown): ResolveResult | Promise<ResolveResult> => {
+          if ((error as NodeJS.ErrnoException).code !== 'ERR_MODULE_NOT_FOUND' || target === undefined) throw error
+          return adapted(target, parent, attributes)
+        }
+        const verifyPackageImport = (resolved: ResolveResult): ResolveResult | Promise<ResolveResult> => {
+          if (behavior !== 'verify' || target === undefined) return resolved
+          const expected = adapted(target, parent, attributes)
+          /* v8 ignore start -- Node 22 is the asynchronous adapter and is covered by the external version matrix */
+          if (expected instanceof Promise) {
+            return expected.then((wanted) => {
+              assertEquivalent(resolved.url, wanted.url, request, parent)
+              return resolved
+            })
+          }
+          /* v8 ignore stop */
+          assertEquivalent(resolved.url, expected.url, request, parent)
+          return resolved
+        }
+        try {
+          const result = native(request, parent, attributes)
+          /* v8 ignore next -- Node 22 is the asynchronous adapter and is covered by the external version matrix */
+          return result instanceof Promise
+            ? result.then(verifyPackageImport, recoverPackageImport)
+            : verifyPackageImport(result)
+        } catch (error) {
+          return recoverPackageImport(error)
+        }
+      }
       const cacheable = attributes === EMPTY_ATTRIBUTES || Object.keys(attributes).length === 0
       if (cacheable && state.esm !== undefined) return state.esm
       const route = state.route
@@ -692,9 +776,13 @@ export function installProfileResolution(
     synthetic.parent = parent
     synthetic.filename = anchor
     synthetic.paths = routed.kind === 'fallback'
-      ? [dirname(routed.entry.packageDir)]
+      ? packageSearchPaths(routed.entry, request, cjs)
       : cjs._nodeModulePaths(dirname(anchor))
-    return originalFilename.call(cjs, request, synthetic, main, options)
+    try {
+      return originalFilename.call(cjs, request, synthetic, main, options)
+    } catch (error) {
+      return throwWithoutCjsAnchor(error, anchor)
+    }
   }
   const wrappedFilename: CommonJsModule['_resolveFilename'] = (request, parent, main, options) => {
     if (delegatedCjs || !parent?.filename) {
@@ -727,13 +815,19 @@ export function installProfileResolution(
       cacheable,
     )
     if (state === undefined) {
+      const scoped = startsWithin(parent.filename, profilePaths)
+      const conditions = options?.conditions ?? cjsConditions
+      const target = request[0] === '#' && scoped
+        ? packageImportsTarget(parent.filename, request, conditions)
+        : undefined
       try {
-        return originalFilename.call(cjs, request, parent, main, options)
+        const result = originalFilename.call(cjs, request, parent, main, options)
+        if (behavior === 'verify' && target !== undefined) {
+          assertEquivalent(result, wrappedFilename(target, parent, main, options), request, parent.filename)
+        }
+        return result
       } catch (error) {
-        if ((error as NodeJS.ErrnoException).code !== 'MODULE_NOT_FOUND' || request[0] !== '#') throw error
-        const conditions = options?.conditions ?? cjsConditions
-        const target = packageImportsTarget(parent.filename, request, conditions)
-        if (target === undefined) throw error
+        if ((error as NodeJS.ErrnoException).code !== 'MODULE_NOT_FOUND' || target === undefined) throw error
         return wrappedFilename(target, parent, main, options)
       }
     }
@@ -764,10 +858,27 @@ export function installProfileResolution(
       try {
         expected = resolveRoutedCjs(request, route, parent, main, routedOptions)
       } catch (error) {
-        if (route.kind !== 'fallback' || (error as NodeJS.ErrnoException).code !== 'MODULE_NOT_FOUND') throw error
-        expected = resolveRoutedCjs(
-          request, { kind: 'after-fallback', parent: route.after }, parent, main, routedOptions,
-        )
+        if (route.kind !== 'fallback'
+          || (error as NodeJS.ErrnoException).code !== 'MODULE_NOT_FOUND'
+          || packageHasExports(route.entry.packageDir)) throw error
+        try {
+          expected = resolveRoutedCjs(
+            request, { kind: 'after-fallback', parent: route.after }, parent, main, routedOptions,
+          )
+        } catch (afterError) {
+          const remaining = explicit === undefined || explicitPaths === undefined
+            ? []
+            : explicitPaths.slice(explicit.index + 1)
+          if ((afterError as NodeJS.ErrnoException).code !== 'MODULE_NOT_FOUND' || remaining.length === 0) {
+            throw afterError
+          }
+          delegatedCjs--
+          try {
+            return wrappedFilename(request, parent, main, { ...options, paths: remaining })
+          } finally {
+            delegatedCjs++
+          }
+        }
       }
       if (behavior === 'enforce') {
         if (cacheable) state.cjs = expected
@@ -784,7 +895,21 @@ export function installProfileResolution(
   cjs._resolveFilename = wrappedFilename
 
   return {
-    packageDir(specifier, parentURL) { return router.packageDir(specifier, parentURL) },
+    packageDir(specifier, parentURL) {
+      const expected = router.packageDir(specifier, parentURL)
+      if (behavior !== 'verify' || !startsWithin(parentURL, profileUrls)) return expected
+      const name = barePackageName(specifier)
+      if (name === undefined) return expected
+      let parent: string
+      try {
+        parent = fileURLToPath(parentURL)
+      } catch {
+        return expected
+      }
+      const actual = nativePackageDir(parent, name)
+      assertOptionalEquivalent(actual, expected, specifier, parentURL)
+      return expected
+    },
     replace(next) { router.replace(next) },
     dispose() {
       /* v8 ignore else -- registrations are disposed in reverse installation order */

+ 12 - 1
packages/boot/app-boot/tests/profile-resolution-service.spec.ts

@@ -1,6 +1,6 @@
 /** Package metadata queries share the active profile resolution generation. */
 
-import { existsSync, mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from 'node:fs'
+import { existsSync, mkdirSync, mkdtempSync, realpathSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'
 import { createRequire } from 'node:module'
 import { tmpdir } from 'node:os'
 import { dirname, join } from 'node:path'
@@ -158,6 +158,12 @@ describe('profile package metadata service', () => {
     const first = join(root, 'first')
     const firstAnchor = pkg(first, '1.0.0')
     const initial = generation(profilesDir, profileDir, first, firstAnchor, '1.0.0')
+    mkdirSync(join(profilesDir, 'node_modules'), { recursive: true })
+    symlinkSync(
+      first,
+      join(profilesDir, 'node_modules', 'metadata-lib'),
+      process.platform === 'win32' ? 'junction' : 'dir',
+    )
     const key = '@deepseek-ai/dsh-app-boot/profile-resolution'
     const previous = getEnvironmentData(key)
     const ctx = new Context()
@@ -178,6 +184,11 @@ describe('profile package metadata service', () => {
 
     const added = join(root, 'added')
     const addedAnchor = pkg(added, '2.0.0', 'added-metadata')
+    symlinkSync(
+      added,
+      join(profilesDir, 'node_modules', 'added-metadata'),
+      process.platform === 'win32' ? 'junction' : 'dir',
+    )
     const next = {
       ...initial,
       entries: [...initial.entries, {

+ 216 - 5
packages/boot/app-boot/tests/profile-resolution.spec.ts

@@ -118,6 +118,16 @@ function thrownMessage(callback: () => unknown): string {
   throw new Error('expected callback to throw')
 }
 
+function thrownError(callback: () => unknown): Error & { code?: string; requireStack?: string[] } {
+  try {
+    callback()
+  } catch (error) {
+    if (error instanceof Error) return error
+    throw error
+  }
+  throw new Error('expected callback to throw')
+}
+
 function fixture(name = 'resolution-lib'): {
   root: string
   installAnchor: string
@@ -272,6 +282,42 @@ describe('profile resolution generation', { concurrent: false }, () => {
     expect(await importFrom('resolution-lib', parent)).toMatchObject({ marker: 1 })
   })
 
+  it('routes a scoped CommonJS package through its containing node_modules directory', async () => {
+    const f = fixture('@scope/resolution-lib')
+    const registration = installProfileResolution(await generationOf(f))
+    registrations.push(registration)
+    const require = createRequire(join(f.profile.dir, 'entry.cjs'))
+
+    expect(require('@scope/resolution-lib')).toEqual({ marker: 1 })
+    expect(require.resolve('@scope/resolution-lib')).toBe(join(f.installed, 'index.cjs'))
+  })
+
+  it('routes a CommonJS npm alias through its declaring package', async () => {
+    const f = fixture('aliased-lib')
+    const target = join(f.root, 'store', 'real-lib')
+    pkg(target, 'real-lib', 5)
+    rmSync(f.installed, { recursive: true })
+    symlinkSync(target, f.installed, process.platform === 'win32' ? 'junction' : 'dir')
+    const registration = installProfileResolution(await generationOf(f))
+    registrations.push(registration)
+
+    expect(createRequire(join(f.profile.dir, 'entry.cjs'))('aliased-lib')).toEqual({ marker: 5 })
+  })
+
+  it('verifies a materialized scoped CommonJS package against the generation', async () => {
+    const f = fixture('@scope/resolution-lib')
+    const generation = await healProfilesModuleFallback({
+      installAnchor: f.installAnchor,
+      profile: f.profile,
+      home: f.root,
+    })
+    const registration = installProfileResolution(generation, 'verify')
+    registrations.push(registration)
+
+    expect(createRequire(join(f.profile.dir, 'dual-entry.cjs')).resolve('@scope/resolution-lib'))
+      .toBe(realpathSync(join(f.installed, 'index.cjs')))
+  })
+
   it('routes an application-owned profile outside the shared profiles directory', async () => {
     const f = fixture()
     const profileDir = join(f.root, 'application-profile')
@@ -363,7 +409,7 @@ describe('profile resolution generation', { concurrent: false }, () => {
     expect(generation.localPackageNames).toEqual(['alias'])
     const require = createRequire(join(alias, 'inside.cjs'))
     expect(require.resolve('real-name')).toBe(join(alias, 'index.cjs'))
-    expect(resolveFrom('real-name', pathToFileURL(join(alias, 'inside.mjs')).href)).toBe(
+    expect(resolveFrom('real-name', pathToFileURL(join(alias, 'inside-link.mjs')).href)).toBe(
       pathToFileURL(join(alias, 'index.js')).href,
     )
     const invalidScope = join(f.profile.dir, 'node_modules', 'invalid-scope')
@@ -377,7 +423,7 @@ describe('profile resolution generation', { concurrent: false }, () => {
     const registration = installProfileResolution(generation)
     registrations.push(registration)
     expect(require.resolve('real-name')).toBe(join(alias, 'index.cjs'))
-    expect(resolveFrom('real-name', pathToFileURL(join(alias, 'inside.mjs')).href)).toBe(
+    expect(resolveFrom('real-name', pathToFileURL(join(alias, 'inside-runtime.mjs')).href)).toBe(
       pathToFileURL(join(alias, 'index.js')).href,
     )
     expect(() => createRequire(join(invalidScope, 'inside.cjs')).resolve('resolution-lib'))
@@ -400,15 +446,38 @@ describe('profile resolution generation', { concurrent: false }, () => {
     })
     const nested = join(f.profile.dir, 'nested')
     const require = createRequire(join(nested, 'entry.cjs'))
-    const parent = pathToFileURL(join(nested, 'entry.mjs')).href
+    const linkParent = pathToFileURL(join(nested, 'entry-link.mjs')).href
     expect(require.resolve('#resolution-lib')).toBe(join(f.installed, 'index.cjs'))
-    expect(resolveFrom('#resolution-lib', parent)).toBe(pathToFileURL(join(f.installed, 'index.js')).href)
+    expect(resolveFrom('#resolution-lib', linkParent)).toBe(pathToFileURL(join(f.installed, 'index.js')).href)
     unlinkSync(join(generation.profilesDir, 'node_modules', 'resolution-lib'))
 
     const registration = installProfileResolution(generation)
     registrations.push(registration)
+    const runtimeParent = pathToFileURL(join(nested, 'entry-runtime.mjs')).href
     expect(require.resolve('#resolution-lib')).toBe(join(f.installed, 'index.cjs'))
-    expect(resolveFrom('#resolution-lib', parent)).toBe(pathToFileURL(join(f.installed, 'index.js')).href)
+    expect(resolveFrom('#resolution-lib', runtimeParent)).toBe(pathToFileURL(join(f.installed, 'index.js')).href)
+  })
+
+  it('verifies package imports aliases against the generation', async () => {
+    const f = fixture()
+    file(join(f.profile.dir, 'package.json'), JSON.stringify({
+      name: 'test-profile',
+      private: true,
+      imports: { '#resolution-lib': 'resolution-lib' },
+    }))
+    const generation = await healProfilesModuleFallback({
+      installAnchor: f.installAnchor,
+      profile: f.profile,
+      home: f.root,
+    })
+    const registration = installProfileResolution(generation, 'verify')
+    registrations.push(registration)
+
+    expect(createRequire(join(f.profile.dir, 'entry.cjs')).resolve('#resolution-lib'))
+      .toBe(realpathSync(join(f.installed, 'index.cjs')))
+    expect(resolveFrom(
+      '#resolution-lib', pathToFileURL(join(f.profile.dir, 'entry-dual.mjs')).href,
+    )).toBe(pathToFileURL(realpathSync(join(f.installed, 'index.js'))).href)
   })
 
   it('leaves relative package imports targets and their diagnostics to Node', async () => {
@@ -435,6 +504,35 @@ describe('profile resolution generation', { concurrent: false }, () => {
     expect(thrownMessage(() => resolveFrom('#missing-relative', parent))).toBe(esmMessage)
   })
 
+  it('leaves package imports outside the profile scope to Node', async () => {
+    const f = fixture()
+    const outside = join(f.root, 'outside')
+    file(join(outside, 'package.json'), JSON.stringify({
+      name: 'outside', private: true, imports: { '#missing': 'missing-target' },
+    }))
+    const require = createRequire(join(outside, 'entry.cjs'))
+    const linkMessage = thrownMessage(() => require.resolve('#missing'))
+    const registration = installProfileResolution(await generationOf(f))
+    registrations.push(registration)
+
+    expect(thrownMessage(() => require.resolve('#missing'))).toBe(linkMessage)
+  })
+
+  it('does not inherit package imports across node_modules', async () => {
+    const f = fixture()
+    file(join(f.profile.dir, 'package.json'), JSON.stringify({
+      name: 'test-profile', private: true, imports: { '#resolution-lib': 'resolution-lib' },
+    }))
+    const nested = join(f.profile.dir, 'node_modules', 'manifestless', 'entry.cjs')
+    file(nested, '')
+    const require = createRequire(nested)
+    const linkMessage = thrownMessage(() => require.resolve('#resolution-lib'))
+    const registration = installProfileResolution(await generationOf(f))
+    registrations.push(registration)
+
+    expect(thrownMessage(() => require.resolve('#resolution-lib'))).toBe(linkMessage)
+  })
+
   it('falls through a missing local CommonJS subpath to the generation', async () => {
     const f = fixture()
     file(join(f.profile.dir, 'package.json'), JSON.stringify({
@@ -680,6 +778,22 @@ describe('profile resolution generation', { concurrent: false }, () => {
     expect(require.resolve('resolution-lib/sub.cjs')).toBe(ancestorSubpath)
   })
 
+  it('continues explicit CommonJS paths after a generation subpath miss', async () => {
+    const f = fixture()
+    file(join(f.installed, 'package.json'), JSON.stringify({
+      name: 'resolution-lib', version: '1.0.0', type: 'module', main: './index.cjs',
+    }))
+    const alternative = join(f.root, 'alternative')
+    const alternativeSubpath = join(alternative, 'node_modules', 'resolution-lib', 'sub.cjs')
+    file(alternativeSubpath, 'module.exports = { marker: 4 }\n')
+    const registration = installProfileResolution(await generationOf(f))
+    registrations.push(registration)
+
+    expect(createRequire(join(f.profile.dir, 'entry.cjs')).resolve('resolution-lib/sub.cjs', {
+      paths: [f.profile.dir, alternative],
+    })).toBe(alternativeSubpath)
+  })
+
   it('skips stale shared and profile-owned fallback entries when the generation misses', async () => {
     const f = fixture()
     pkg(join(f.root, 'profiles', 'node_modules', 'stale-shared'), 'stale-shared', 9)
@@ -815,6 +929,31 @@ describe('profile resolution generation', { concurrent: false }, () => {
     expect(parent.children).toEqual(originalChildren)
   })
 
+  it('reports routed CommonJS failures with the native require stack', async () => {
+    const f = fixture()
+    file(join(f.installed, 'package.json'), JSON.stringify({
+      name: 'resolution-lib', version: '1.0.0', type: 'module', main: './index.cjs',
+    }))
+    const generation = await healProfilesModuleFallback({
+      installAnchor: f.installAnchor,
+      profile: f.profile,
+      home: f.root,
+    })
+    const linkRequire = createRequire(join(f.profile.dir, 'entry-link.cjs'))
+    const linkError = thrownError(() => linkRequire.resolve('resolution-lib/missing.cjs'))
+    unlinkSync(join(generation.profilesDir, 'node_modules', 'resolution-lib'))
+    const registration = installProfileResolution(generation)
+    registrations.push(registration)
+    const runtimeRequire = createRequire(join(f.profile.dir, 'entry-runtime.cjs'))
+    const runtimeError = thrownError(() => runtimeRequire.resolve('resolution-lib/missing.cjs'))
+
+    expect(runtimeError.requireStack).toEqual([
+      join(f.profile.dir, 'entry-runtime.cjs'),
+    ])
+    expect(runtimeError.message).not.toContain(f.installAnchor)
+    expect(linkError.requireStack).toEqual([join(f.profile.dir, 'entry-link.cjs')])
+  })
+
   it('reports routed ESM failures from the original importer', async () => {
     const f = fixture()
     const parent = pathToFileURL(join(f.profile.dir, 'entry.mjs')).href
@@ -865,6 +1004,19 @@ describe('profile resolution generation', { concurrent: false }, () => {
     )).rejects.toThrow(/Cannot find module|Cannot find package/u)
   })
 
+  it('does not fall back after the generation selects a missing exports target', async () => {
+    const f = fixture()
+    file(join(f.installed, 'package.json'), JSON.stringify({
+      name: 'resolution-lib', version: '1.0.0', type: 'module', exports: './missing.cjs',
+    }))
+    pkg(join(f.root, 'node_modules', 'resolution-lib'), 'resolution-lib', 2)
+    const registration = installProfileResolution(await generationOf(f))
+    registrations.push(registration)
+
+    expect(() => createRequire(join(f.profile.dir, 'entry.cjs')).resolve('resolution-lib'))
+      .toThrow(/Cannot find module/u)
+  })
+
   it('detects a dual-mode mismatch instead of accepting another package', async () => {
     const f = fixture()
     const disk = await healProfilesModuleFallback({
@@ -889,6 +1041,58 @@ describe('profile resolution generation', { concurrent: false }, () => {
     )).rejects.toThrow(/profile resolution mismatch/u)
   })
 
+  it('detects a dual-mode package metadata mismatch before import', async () => {
+    const f = fixture()
+    const disk = await healProfilesModuleFallback({
+      installAnchor: f.installAnchor,
+      profile: f.profile,
+      home: f.root,
+    })
+    const second = join(f.root, 'second')
+    pkg(second, 'resolution-lib', 2)
+    const mismatched = {
+      ...disk,
+      entries: disk.entries.map(entry => entry.name === 'resolution-lib'
+        ? { ...entry, packageDir: second, declarer: join(second, 'package.json') }
+        : entry),
+    }
+    const registration = installProfileResolution(mismatched, 'verify')
+    registrations.push(registration)
+
+    expect(() => registration.packageDir(
+      'resolution-lib', pathToFileURL(join(f.profile.dir, 'metadata.mjs')).href,
+    )).toThrow(/profile resolution mismatch/u)
+  })
+
+  it('detects a dual-mode package metadata hit present on only one backend', async () => {
+    const f = fixture()
+    const generation = await generationOf(f)
+    const registration = installProfileResolution(generation, 'verify')
+    registrations.push(registration)
+
+    expect(() => registration.packageDir(
+      'resolution-lib', pathToFileURL(join(f.profile.dir, 'metadata.mjs')).href,
+    )).toThrow(/disk selected nothing, generation selected/u)
+  })
+
+  it('detects package metadata present only in the dual-mode disk backend', async () => {
+    const f = fixture()
+    const disk = await healProfilesModuleFallback({
+      installAnchor: f.installAnchor,
+      profile: f.profile,
+      home: f.root,
+    })
+    const registration = installProfileResolution({
+      ...disk,
+      entries: disk.entries.filter(entry => entry.name !== 'resolution-lib'),
+    }, 'verify')
+    registrations.push(registration)
+
+    expect(() => registration.packageDir(
+      'resolution-lib', pathToFileURL(join(f.profile.dir, 'metadata.mjs')).href,
+    )).toThrow(/disk selected .*generation selected nothing/u)
+  })
+
   it('reports a missing dual-mode generation target from the original importer', async () => {
     const f = fixture()
     const disk = await healProfilesModuleFallback({
@@ -923,6 +1127,13 @@ describe('profile resolution generation', { concurrent: false }, () => {
     expect(require.resolve('resolution-lib')).toBe(join(f.installed, 'index.cjs'))
     expect(await importFrom('resolution-lib', pathToFileURL(join(f.profile.dir, 'entry.mjs')).href))
       .toMatchObject({ marker: 1 })
+    const parent = pathToFileURL(join(f.profile.dir, 'metadata.mjs')).href
+    expect(registration.packageDir('resolution-lib', parent)).toBe(f.installed)
+    expect(registration.packageDir('missing-metadata', parent)).toBeUndefined()
+    expect(registration.packageDir('node:fs', parent)).toBeUndefined()
+    expect(registration.packageDir(
+      'missing-metadata', `${pathToFileURL(f.profile.dir).href}/%ZZ`,
+    )).toBeUndefined()
   })
 
   it('publishes an additive generation and replaces its miss cache atomically', async () => {

+ 3 - 0
packages/boot/app-boot/tsdown.config.ts

@@ -27,5 +27,8 @@ export default defineConfig([
     fixedExtension: false,
     dts: false,
     clean: false,
+    deps: {
+      alwaysBundle: ['resolve.exports'],
+    },
   },
 ])

+ 1 - 1
packages/client/AGENTS.md

@@ -136,7 +136,7 @@ If `test:gui` is red on code you did not touch, neither silently fix nor ignore
 Bringing up a new `packages/client/<name>` plugin package (ui-workspace is a complete example; ui-sidebar/ui-user-questions are minimal skeletons):
 
 1. **Package skeleton**: `package.json` (`@deepseek-ai/dsh-client-<name>`, exports `.`/`./client`/`./src/*`/`./package.json`, optional `./invariant` only for an independent runtime relationship, `dsh.client` manifest, `files` list), `tsconfig.json` (extends `tsconfig.base.client.json`, one `references` entry per workspace dependency), `tsdown.config.ts` (`clientBundle(id, ['lib/types/index.js'])`, plus `lib/types/invariant.js` only when published), `src/index.ts` (empty node-half apply), optional `src/invariant.ts`, `src/css-modules.d.ts` when using CSS Modules, and `README.md` with the Model Experience section and the reason when no invariant is published.
-2. **Three registration surfaces, all required** (missing any one fails at a different, later point): the `tsconfig.client.json` aggregate `references` entry; a `dsh.client` row in `packages/bundle/web-app/cordis.patch.yml`; a `packages/bundle/web-app/package.json` dependency (profile boots resolve bare row names through the runtime generation computed from the app's and each bundle's declared dependencies — a row whose package no manifest declares fails to import). `pnpm-workspace.yaml` already globs `packages/*/*`.
+2. **Three registration surfaces, all required** (missing any one fails at a different, later point): the `tsconfig.client.json` aggregate `references` entry; a `dsh.client` row in `packages/bundle/web-app/cordis.patch.yml`; a `packages/bundle/web-app/package.json` dependency (profile boots resolve bare row names through the computed profile resolution generation, which the default link mode materializes on disk — a row whose package no manifest declares fails to import). `pnpm-workspace.yaml` already globs `packages/*/*`.
 3. **dsh.client manifest semantics**: `platform: 'web'` always, and the declaration requires a `./client` export (the scan throws without one); `immediately: true` only for stage-one-prefetch infrastructure rows. `inject` lists package-name dependency edges — they are **informational only** (preflight display, HMR diffing); they do not sequence entry activation or apply order. Activation order is Cordis fiber inject waiting on *services*, nothing else. A non-baseline `external` request sequences its dynamic supplier ahead of the consumer — see [shared modules](#shared-modules-and-the-module-graph).
 4. **Registering into another package's slot**: apply order is unconstrained, and a business service is not a declaration barrier. Use `ctx.slots.inject(name, () => ctx.slots.register(...))`; it waits on the actual declaration, removes the contribution when that declaration collapses, reruns after redeclaration, and leaves with the caller's plugin fiber. Return a generator yielding each registration when several contributions must install and roll back atomically. A bare `slots.register` into an undeclared slot remains an error; keep service edges only for services the contribution actually reads.
 5. Rebuild the bundle (`pnpm --filter <pkg> bundle`) before probing a live `dsh web` server — the registry serves `lib/client.js`, not sources.

+ 2 - 2
packages/test-support/loader-smoke/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/test-support/loader-smoke/README.md
-README.md: e7096aadda968c0724233457eade06d7391d82cd
-README.zh.md: cff7fc96a884664941e2a583b8f7a6de8e69667b
+README.md: 2b903a1a4ecd558b8839dba082b9c3ee0b9fdaa4
+README.zh.md: a207804e158745adcc5b2a41fa0892b60ad33d84

+ 1 - 1
packages/test-support/loader-smoke/README.md

@@ -45,7 +45,7 @@ Set `expectedExitCode` when the scenario pins a designed failure surface — a o
 
 ### Testing a shipped profile
 
-Profile integration drivers use the repository-only `tests/fixtures/production-profile.ts` helper. It loads the named shipped profile and its bundle patches through `loadProfile`, materializes the retained link-mode fallback, and passes the bundle patches followed by the test's `*.patch.yml` files to the root `cordis:include` mounted by `boot`. Those patches should contain only the test provider or model, isolated persistence paths, and subject-specific changes. Package-level unit tests that need an agent loop without profile integration mount `dsh-agent-loop-testkit` locally instead.
+Profile integration drivers use the repository-only `tests/fixtures/production-profile.ts` helper. It loads the named shipped profile and its bundle patches through `loadProfile`, materializes the retained link-mode fallback, mounts `PluginPackages` with native lookup as the link-mode launcher does, and passes the bundle patches followed by the test's `*.patch.yml` files to the root `cordis:include` mounted by `boot`. Those patches should contain only the test provider or model, isolated persistence paths, and subject-specific changes. Package-level unit tests that need an agent loop without profile integration mount `dsh-agent-loop-testkit` locally instead.
 
 ### Driving a fixture turn
 

+ 1 - 1
packages/test-support/loader-smoke/README.zh.md

@@ -45,7 +45,7 @@ const result = await runLoaderSmoke({
 
 ### 测试交付 profile
 
-Profile 集成 driver 使用仅限仓库内部的 `tests/fixtures/production-profile.ts` helper。它通过 `loadProfile` 加载指定的已交付 profile 及其组合包 patch,物化保留的 link-mode fallback,然后把组合包 patch 与测试 `*.patch.yml` 文件依次交给 `boot` 挂载的根 `cordis:include`。这些 patch 应只包含测试提供方或模型、隔离持久化路径及被测对象专用变更。只需要 agent loop 而不测试 profile 集成的包级单元测试改为在本地挂载 `dsh-agent-loop-testkit`。
+Profile 集成 driver 使用仅限仓库内部的 `tests/fixtures/production-profile.ts` helper。它通过 `loadProfile` 加载指定的已交付 profile 及其组合包 patch,物化保留的 link-mode fallback,并像 link-mode launcher 一样挂载使用原生查询的 `PluginPackages`,然后把组合包 patch 与测试 `*.patch.yml` 文件依次交给 `boot` 挂载的根 `cordis:include`。这些 patch 应只包含测试提供方或模型、隔离持久化路径及被测对象专用变更。只需要 agent loop 而不测试 profile 集成的包级单元测试改为在本地挂载 `dsh-agent-loop-testkit`。
 
 ### 驱动 fixture 轮次
 

+ 5 - 1
packages/test-support/loader-smoke/tests/fixtures/production-profile.ts

@@ -12,6 +12,7 @@ import {
   healProfilesModuleFallback,
   loadOverlayPatches,
   loadProfile,
+  PluginPackages,
   type ProfileLayer,
 } from '@deepseek-ai/dsh-app-boot'
 
@@ -96,6 +97,9 @@ export async function bootProductionProfile(options: ProductionProfileOptions):
       ...profile.layers.flatMap(layer => layer.patches),
       ...overlays.flat(),
     ],
-    options.prepare,
+    async (ctx) => {
+      await ctx.plugin(PluginPackages, {})
+      await options.prepare?.(ctx)
+    },
   )
 }