Просмотр исходного кода

ci(web): verify composed profiles, live registries, and bundle inputs

Tianyi Cui 1 месяц назад
Родитель
Сommit
8f14f98a1a

+ 2 - 2
.agents/notes/implemented/process/2026-09-12-default-product-experimental-isolation.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 .agents/notes/implemented/process/2026-09-12-default-product-experimental-isolation.md
-2026-09-12-default-product-experimental-isolation.md: 66341e95297267b97be9c41f3edce81bbd5f9c8c
-2026-09-12-default-product-experimental-isolation.zh.md: 207d7f5c05d96665df98f1a9875db68315b9bf03
+2026-09-12-default-product-experimental-isolation.md: a72a367e3f766df9baf90a98a15e8e28a175a66f
+2026-09-12-default-product-experimental-isolation.zh.md: b929e85a8a742cea64af4790b9e3c0cd6f2ac225

+ 8 - 2
.agents/notes/implemented/process/2026-09-12-default-product-experimental-isolation.md

@@ -12,10 +12,14 @@ Public npm availability does not make an experimental package part of the defaul
 
 [`verify-default-product-isolation`](../../../../scripts/verify-default-product-isolation.ts) runs in static CI and package hygiene. It follows runtime dependencies, optional dependencies, and peers from every app and the Python runtime, resolves workspace and npm aliases, and identifies experimental packages by their npm prefix or repository directory. Publication denylist membership has no effect on this classification.
 
-The source check also reads runtime imports in the selected packages, installation-owned profile bundle lists, bundle patches, shipped agent presets, and declared configuration trees. Loader groups, inserts, Include patches, and disabled plugin rows remain checked. Ordinary plugin configuration data is not interpreted as another Loader entry list. Missing default roots fail the check.
+The source check also reads runtime imports in the selected packages, installation-owned profile bundle lists, bundle patches, shipped agent presets, and declared configuration trees. It loads the default Web layers with the production patch parser and composes them with the same patch engine used at boot. The effective rows and patched Include trees are checked, so an id-only patch cannot hide a replacement group's plugins. Disabled plugin rows remain checked; ordinary plugin configuration data is not interpreted as another Loader entry list. Missing default roots fail the check.
 
 The default Web source graph starts at the module scripts in its actual HTML entry, including inline modules and locally referenced Worker entries. The separate experimental preview can exist without joining this graph. Importing it from the default entry fails the check. Source references to Cordis configuration files include the Desktop patch in the same proof.
 
+The [Host startup smoke](../../../../apps/cli/tests/profiles/web/tests/web-default-isolation.expected.e2e.ts) launches the real Web profile from built `dsh` exports in a private Harness home. Its test-only observer reads actual Loader rows, registered fibers, modules associated with registered callbacks, and Node's loaded-module cache after readiness. The [Chromium smoke](../../../../apps/web/tests/default-product-isolation.e2e.ts) boots the delivered page and reads the actual Client Loader, registry, and module cache, requiring every delivered entry to be active. Both checks reject a real experimental plugin mounted by their explicit negative-control command. The existing expected-output and Web CI lanes own these tests after the complete build.
+
+Build-time input checks cover both browser bundling stages. The [client preset](../../../../packages/client/tsdown.client.ts) rejects experimental inputs in non-experimental outputs before their original paths disappear inside `lib/client.js`. The [Web graph check](../../../../scripts/web-product-bundle-isolation.ts) follows actual Vite output edges from `index.html`, including lazy chunks, workers, CSS dependencies, and assets. The separate preview remains outside the product graph. The build fails when required module or asset input records are missing; a failed Web build cannot produce a successful complete client-build record. The notices generator uses an explicit analysis marker for its partial graph; that marker requires output writing to be disabled. Other in-memory product builds retain the checks.
+
 [`verify-packed-install`](../../../../scripts/release/verify-packed-install.ts) follows the installed dependency graph from `@deepseek-ai/dsh`, using resolved manifest names to detect aliases and external transitive dependencies. Development dependencies and unrelated tarballs installed beside the product are excluded. Missing required dependencies fail; omitted optional dependencies remain allowed unless they name an experimental package.
 
 This check enforces the existing [experimental dependency isolation rules](../architecture/2026-08-18-experimental-agent-teams-packages.md). The [publication policy](2026-09-12-experimental-publication-denylist.md) independently determines which experimental packages explicit consumers may install.
@@ -28,6 +32,8 @@ This check enforces the existing [experimental dependency isolation rules](../ar
 
 **Scan every Web source as a default entry.** This would reject the separate experimental preview even when the default HTML and runtime imports never reach it.
 
+**Check only the final Vite module paths.** A stable `lib/client.js` path can contain code bundled earlier from an experimental package. Each bundling stage must check its actual inputs.
+
 ## Consequences
 
-Experimental packages may publish without joining default installations or compositions. The source proof covers declared and literal runtime references; the installed check additionally validates the package-manager-resolved dependency graph. Arbitrary runtime-generated module names remain subject to composition review and runtime tests rather than static evaluation.
+Experimental packages may publish without joining default installations or compositions. Source, effective composition, build-input, installed-dependency, and runtime-registry checks provide independent evidence. Runtime smokes observe startup and Client activation; they do not replace tests for every later user-triggered execution path or for explicitly installed profile extensions.

+ 8 - 2
.agents/notes/implemented/process/2026-09-12-default-product-experimental-isolation.zh.md

@@ -12,10 +12,14 @@ Status: implemented
 
 [`verify-default-product-isolation`](../../../../scripts/verify-default-product-isolation.ts) 在静态 CI 和包 hygiene 中运行。它从所有应用与 Python runtime 出发,遍历运行时依赖、可选依赖和 peer,解析 workspace 与 npm 别名,并按 npm 前缀或仓库目录识别实验包。发布 denylist 的成员关系不影响此分类。
 
-源码检查还读取所选包的运行时导入、安装自带的 profile bundle 列表、bundle patch、随产品提供的 Agent preset,以及声明的配置树。Loader group、insert、Include patch 和禁用的插件行均纳入检查。普通插件配置数据不会被解释为另一个 Loader entry 列表。默认入口缺失会使检查失败。
+源码检查还读取所选包的运行时导入、安装自带的 profile bundle 列表、bundle patch、随产品提供的 Agent preset,以及声明的配置树。它使用生产 patch 解析器加载默认 Web 各层,并使用启动时的同一个 patch 引擎完成组合。检查对象包括最终 entry 和应用 patch 后的 Include 树,因此仅按 id 覆盖 group 的 patch 也无法隐藏替换后的插件。禁用的插件行仍纳入检查;普通插件配置数据不会被解释为另一个 Loader entry 列表。默认入口缺失会使检查失败。
 
 默认 Web 源码图从实际 HTML 入口中的 module script 出发,包括内联模块和本地引用的 Worker 入口。独立的实验预览可以存在而不加入此图;默认入口导入它时检查会失败。源码中的 Cordis 配置文件引用会将 Desktop patch 纳入同一证明。
 
+[Host 启动 smoke](../../../../apps/cli/tests/profiles/web/tests/web-default-isolation.expected.e2e.ts) 在独立 Harness home 中通过构建后的 `dsh` 导出启动真实 Web profile。测试专用观察器在就绪后读取实际 Loader 行、注册的 fiber、注册回调所属模块和 Node 已加载模块缓存。[Chromium smoke](../../../../apps/web/tests/default-product-isolation.e2e.ts) 启动实际交付的页面,读取真实 Client Loader、registry 和模块缓存,并要求所有交付 entry 均已激活。两项检查都会拒绝其显式负例命令挂载的真实实验插件。现有 expected-output 和 Web CI lane 在完整构建后执行这些测试。
+
+构建输入检查覆盖浏览器打包的两个阶段。[Client preset](../../../../packages/client/tsdown.client.ts) 在原始路径被折叠进 `lib/client.js` 前,拒绝非实验输出中的实验输入。[Web 图检查](../../../../scripts/web-product-bundle-isolation.ts) 从 `index.html` 沿真实 Vite 输出边遍历,覆盖延迟加载 chunk、Worker、CSS 依赖和资源。独立 preview 不属于产品图。缺少必需的模块或资源输入记录会使构建失败;失败的 Web 构建无法产生成功的完整 Client 构建记录。Notices 生成器通过显式分析标记检查其部分依赖图,该标记要求禁用输出写入。其他内存内产品构建仍执行这些检查。
+
 [`verify-packed-install`](../../../../scripts/release/verify-packed-install.ts) 从 `@deepseek-ai/dsh` 遍历已安装依赖图,通过解析后的 manifest 名称识别别名和外部传递依赖。开发依赖以及安装在产品旁边的不相关 tarball 不参与遍历。缺失必需依赖会失败;允许省略可选依赖,但其名称不得指向实验包。
 
 此检查执行现有的[实验包依赖隔离规则](../architecture/2026-08-18-experimental-agent-teams-packages.zh.md)。[发布策略](2026-09-12-experimental-publication-denylist.zh.md) 独立决定显式消费者可以安装哪些实验包。
@@ -28,6 +32,8 @@ Status: implemented
 
 **将全部 Web 源码视为默认入口。** 即使默认 HTML 和运行时导入从未到达独立的实验预览,这也会错误拒绝它。
 
+**只检查最终 Vite 模块路径。** 稳定的 `lib/client.js` 路径可能包含前一阶段从实验包打入的代码。每个打包阶段都必须检查实际输入。
+
 ## Consequences
 
-实验包可以发布而不加入默认安装或组合。源码证明覆盖声明和字面量运行时引用;安装检查进一步验证包管理器解析出的依赖图。任意运行时生成的模块名称仍由组合评审和运行时测试约束,不做静态求值。
+实验包可以发布而不加入默认安装或组合。源码、最终组合、构建输入、安装依赖和运行时 registry 检查提供相互独立的证据。运行时 smoke 观察启动和 Client 激活;它们不替代后续每条用户触发路径的测试,也不替代显式安装的 profile 扩展测试。

+ 185 - 0
apps/cli/tests/profiles/web/tests/default-web-process.ts

@@ -0,0 +1,185 @@
+/** Built-process owner shared by the Host and browser default-Web isolation smokes. */
+
+import { spawn } from 'node:child_process'
+import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
+import { tmpdir } from 'node:os'
+import { dirname, join } from 'node:path'
+import { fileURLToPath, pathToFileURL } from 'node:url'
+import { get } from 'node:http'
+import type { IncomingHttpHeaders } from 'node:http'
+import { resolveExampleLaunch } from '@deepseek-ai/dsh-loader-smoke'
+import ts from 'typescript'
+import { expect } from 'vitest'
+import type { TestContext } from 'vitest'
+import { PROCESS_SHUTDOWN_TIMEOUT_MS } from '../../../../src/process-shutdown.ts'
+import type { RuntimeRoster } from './runtime-roster.ts'
+
+const repoRoot = fileURLToPath(new URL('../../../../../../', import.meta.url))
+
+/** Live process whose IPC observer reports independently collected runtime state. */
+interface DefaultWeb {
+  url: string
+  request: (command: 'roster' | 'mount-experimental' | 'mount-experimental-entry') => Promise<RuntimeRoster>
+}
+
+/**
+ * Boot the built Web profile under plain Node and dispose it to quiescence after an assertion callback.
+ * @param test - owning Vitest case, including its timeout, cancellation, and cleanup hooks.
+ * @param inspect - assertions against the running process and its ephemeral loopback URL.
+ */
+export async function withDefaultWeb(test: TestContext, inspect: (app: DefaultWeb) => Promise<void>): Promise<void> {
+  const root = await mkdtemp(join(tmpdir(), 'dsh-web-default-isolation-'))
+  let removal: Promise<void> | undefined
+  const removeRoot = (): Promise<void> => removal ??= rm(root, { recursive: true, force: true })
+  test.onTestFinished(removeRoot)
+  try {
+    await writeFile(join(root, 'package.json'), JSON.stringify({ type: 'module' }) + '\n')
+    for (const relative of ['runtime-roster.ts', 'fixtures/runtime-roster-observer.ts']) {
+      const source = await readFile(new URL(relative, import.meta.url), 'utf8')
+      const output = ts.transpileModule(source, {
+        fileName: relative,
+        compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.ESNext, rewriteRelativeImportExtensions: true },
+      }).outputText
+      const path = join(root, relative.replace(/\.ts$/, '.js'))
+      await mkdir(dirname(path), { recursive: true })
+      await writeFile(path, output)
+    }
+    const patch = join(root, 'observer.patch.yml')
+    await writeFile(patch, JSON.stringify([{ insert: [{
+      id: 'runtime-roster-observer',
+      name: pathToFileURL(join(root, 'fixtures/runtime-roster-observer.js')).href,
+      config: { negativeControl: pathToFileURL(join(repoRoot, 'packages/experimental/client-ui-agent-team/lib/index.js')).href },
+    }] }]) + '\n')
+    const launch = resolveExampleLaunch({
+      srcBin: join(repoRoot, 'apps/cli/src/bin.ts'),
+      mode: 'lib',
+      configArgs: ['--profile', 'web', '--patch', patch, '--host', '127.0.0.1', '--port', '0', '--no-open'],
+      env: {
+        NODE_OPTIONS: undefined,
+        NODE_PATH: undefined,
+        TSX_TSCONFIG_PATH: undefined,
+        DSH_HOME: join(root, 'home'),
+        DSH_AGENTS_HOME: join(root, '.agents'),
+        DSH_TELEMETRY_DISABLED: '1',
+        DEEPSEEK_API_KEY: 'keyless-default-web-no-call',
+        NODE_NO_WARNINGS: '1',
+      },
+    })
+    test.signal.throwIfAborted()
+    const args = ['--no-experimental-strip-types', ...launch.args]
+    const child = spawn(launch.command, args, {
+      cwd: root,
+      env: { ...process.env, ...launch.env },
+      stdio: ['ignore', 'pipe', 'pipe', 'ipc'],
+    })
+    let stdout = ''
+    let stderr = ''
+    child.stdout!.setEncoding('utf8').on('data', (chunk: string) => { stdout += chunk })
+    child.stderr!.setEncoding('utf8').on('data', (chunk: string) => { stderr += chunk })
+    const completion = Promise.withResolvers<{ code: number | null; signal: NodeJS.Signals | null }>()
+    let exited = false
+    let processError: Error | undefined
+    const pending = new Map<string, ReturnType<typeof Promise.withResolvers<RuntimeRoster>>>()
+    const rejectPending = (error: Error): void => {
+      for (const request of pending.values()) request.reject(error)
+      pending.clear()
+    }
+    child.once('error', (error) => { processError = error; rejectPending(error) })
+    child.once('close', (code, signal) => {
+      exited = true
+      rejectPending(new Error(`Web exited (code ${String(code)}, signal ${String(signal)})\n${stdout}\n${stderr}`))
+      completion.resolve({ code, signal })
+    })
+    child.once('disconnect', () => { rejectPending(new Error(`Web IPC disconnected\n${stdout}\n${stderr}`)) })
+    child.on('message', (message: { command?: string; roster?: RuntimeRoster; error?: string }) => {
+      if (message.command === undefined) return
+      const request = pending.get(message.command)
+      if (request === undefined) return
+      pending.delete(message.command)
+      if (message.error !== undefined) request.reject(new Error(message.error))
+      else if (message.roster !== undefined) request.resolve(message.roster)
+      else request.reject(new Error('Web observer returned no runtime roster'))
+    })
+    const send = (command: string): Promise<void> => new Promise((resolve, reject) => {
+      child.send(command, (error: Error | null) => { if (error) reject(error); else resolve() })
+    })
+    const request = async (command: string): Promise<RuntimeRoster> => {
+      test.signal.throwIfAborted()
+      if (exited || !child.connected) throw new Error(`Web is not connected during ${command}`)
+      const reply = Promise.withResolvers<RuntimeRoster>()
+      pending.set(command, reply)
+      void send(command).catch((error: unknown) => {
+        if (pending.get(command) !== reply) return
+        pending.delete(command)
+        reply.reject(error)
+      })
+      return reply.promise
+    }
+    let forced = false
+    let closing: Promise<Awaited<typeof completion.promise>> | undefined
+    const close = (): Promise<Awaited<typeof completion.promise>> => closing ??= (async () => {
+      const force = (): void => {
+        if (exited || child.pid === undefined) return
+        forced = true
+        child.kill('SIGKILL')
+      }
+      // The CLI owns its 5-second graceful deadline; the outer watchdog also allows that process exit to settle.
+      const kill = setTimeout(force, PROCESS_SHUTDOWN_TIMEOUT_MS * 2)
+      try {
+        if (!exited && child.connected) {
+          try { await send('stop') } catch (_closedChannel: unknown) { force() }
+        } else force()
+        return await completion.promise
+      } finally { clearTimeout(kill) }
+    })()
+    const abort = (): void => {
+      rejectPending(new Error('Web isolation test was cancelled', { cause: test.signal.reason }))
+      void close()
+    }
+    test.signal.addEventListener('abort', abort, { once: true })
+    test.onTestFinished(async () => { await close() })
+    try {
+      await expect.poll(() => {
+        test.signal.throwIfAborted()
+        if (exited) throw new Error(`Web exited before readiness\n${stdout}\n${stderr}`)
+        return /dsh web: (http:\/\/[^\s]+)/u.exec(stdout)?.[1]
+      }, { timeout: test.task.timeout }).toBeDefined()
+      const url = /dsh web: (http:\/\/[^\s]+)/u.exec(stdout)![1]!
+      await inspect({ url, request })
+    } finally {
+      const result = await close()
+      test.signal.removeEventListener('abort', abort)
+      expect(test.signal.aborted, stderr).toBe(false)
+      expect(processError, stderr).toBeUndefined()
+      expect(forced, stderr).toBe(false)
+      expect(result.signal, stderr).toBeNull()
+      expect(result.code, stderr).toBe(0)
+    }
+  } finally {
+    await removeRoot()
+  }
+}
+
+/**
+ * Read an owned loopback response without inheriting Node's process-start proxy dispatcher.
+ * @param url - URL of the test-owned Web process.
+ * @param signal - owning test cancellation signal.
+ * @param headers - optional authentication cookie.
+ * @returns complete response after its stream ends.
+ */
+export function webGet(url: string | URL, signal: AbortSignal, headers: Record<string, string> = {}): Promise<{
+  status: number | undefined
+  headers: IncomingHttpHeaders
+  text: string
+}> {
+  return new Promise((resolve, reject) => {
+    const request = get(url, { headers, agent: false, signal }, (response) => {
+      response.setEncoding('utf8')
+      let text = ''
+      response.on('data', (chunk: string) => { text += chunk })
+      response.once('error', reject)
+      response.once('end', () => { resolve({ status: response.statusCode, headers: response.headers, text }) })
+    })
+    request.once('error', reject)
+  })
+}

+ 38 - 0
apps/cli/tests/profiles/web/tests/fixtures/runtime-roster-observer.ts

@@ -0,0 +1,38 @@
+/** Test-only IPC observer mounted beside the unchanged default Web composition. */
+
+import type { Context } from '@deepseek-ai/cordis'
+import { runtimeRoster } from '../runtime-roster.ts'
+
+/** Services whose live state the observer reads. */
+export const inject = ['loader', 'clientModules']
+
+/**
+ * Observe the shipped process and optionally mount the real negative-control plugin dynamically.
+ * @param ctx - context in the real Loader tree.
+ * @param config - absolute module URL used only by the explicit negative-control command.
+ */
+export function apply(ctx: Context, config: { negativeControl: string }): void {
+  if (process.send === undefined) throw new Error('Runtime roster observer requires an IPC channel')
+  const receive = (message: unknown): void => {
+    if (message === 'stop') {
+      process.emit('SIGTERM')
+      return
+    }
+    if (message !== 'roster' && message !== 'mount-experimental' && message !== 'mount-experimental-entry') return
+    void (async () => {
+      if (message === 'mount-experimental') {
+        const namespace: unknown = await import(config.negativeControl)
+        await ctx.plugin(ctx.loader.unwrapExports(namespace))
+      }
+      if (message === 'mount-experimental-entry') await ctx.loader.create({ name: config.negativeControl })
+      await ctx.loader.await()
+      process.send!({ command: message, roster: runtimeRoster(ctx) })
+    })().catch((error: unknown) => {
+      process.send!({ command: message, error: error instanceof Error ? error.stack : String(error) })
+    })
+  }
+  ctx.effect(() => {
+    process.on('message', receive)
+    return () => { process.off('message', receive) }
+  })
+}

+ 95 - 0
apps/cli/tests/profiles/web/tests/runtime-roster.ts

@@ -0,0 +1,95 @@
+/** Runtime evidence collected from the real Web process for default-product isolation. */
+
+import { existsSync, readFileSync } from 'node:fs'
+import { createRequire } from 'node:module'
+import { dirname, join } from 'node:path'
+import { fileURLToPath, pathToFileURL } from 'node:url'
+import type { Context, Plugin } from '@deepseek-ai/cordis'
+import type {} from '@deepseek-ai/cordis-plugin-loader'
+import type { WebBootGraph } from '@deepseek-ai/dsh-client-modules/client'
+import type {} from '@deepseek-ai/dsh-client-modules'
+
+/** Observed Loader entries, registered plugin instances, loaded modules, and delivered Client entries. */
+export interface RuntimeRoster {
+  entries: Array<{ id: string; name: string; state: number | undefined }>
+  plugins: Array<{ name: string; owner: string | undefined; state: number; modules: string[] }>
+  modules: string[]
+  client: WebBootGraph
+}
+
+/**
+ * Find a loaded file's npm package without importing additional application code.
+ * @param url - file module URL from the running process.
+ * @returns owning npm package name, if the module belongs to a package.
+ */
+export function modulePackage(url: string): string | undefined {
+  if (!url.startsWith('file:')) return undefined
+  let directory = dirname(fileURLToPath(url))
+  for (;;) {
+    const manifest = join(directory, 'package.json')
+    if (existsSync(manifest)) {
+      const parsed = JSON.parse(readFileSync(manifest, 'utf8')) as { name?: string }
+      return parsed.name
+    }
+    const parent = dirname(directory)
+    if (parent === directory) return undefined
+    directory = parent
+  }
+}
+
+/**
+ * Collect the settled process's actual Loader, Cordis registry, and Node module cache.
+ * @param ctx - observer context inside the shipped Web profile.
+ * @returns independently inspectable runtime records; unavailable module introspection fails the smoke.
+ */
+export function runtimeRoster(ctx: Context): RuntimeRoster {
+  const internal = ctx.loader.internal
+  if (!internal) throw new Error('Web isolation smoke requires Node module-cache introspection')
+  const modules = new Set<string>([...internal.loadCache.keys()])
+  const callbacks = new Map<object, string[]>()
+  for (const [url, records] of internal.loadCache) {
+    for (const job of Object.values(records)) {
+      if (!job?.module) continue
+      const namespace: unknown = job.module.getNamespace()
+      if (typeof namespace !== 'object' || namespace === null) continue
+      for (const value of Object.values(namespace)) {
+        if (typeof value !== 'function' && (typeof value !== 'object' || value === null)) continue
+        const callback = ctx.registry.resolve(value as Plugin)
+        if (callback === undefined) continue
+        const urls = callbacks.get(callback) ?? []
+        if (!urls.includes(url)) urls.push(url)
+        callbacks.set(callback, urls)
+      }
+    }
+  }
+  for (const path of Object.keys(createRequire(import.meta.url).cache)) modules.add(pathToFileURL(path).href)
+  return {
+    entries: [...ctx.loader.entries()].map(entry => ({ id: entry.id, name: entry.options.name, state: entry.fiber?.state })),
+    plugins: [...ctx.registry.values()].flatMap(runtime => [...runtime.fibers].map(fiber => ({
+      name: runtime.name ?? runtime.callback.name,
+      owner: fiber.entry?.options.name,
+      state: fiber.state,
+      modules: callbacks.get(runtime.callback) ?? [],
+    }))),
+    modules: [...modules].sort(),
+    client: ctx.clientModules.graph(),
+  }
+}
+
+/**
+ * Locate experimental package identities and experimental source/artifact paths in runtime evidence.
+ * @param roster - independently collected Web runtime records.
+ * @returns diagnostic evidence for every forbidden package reference.
+ */
+export function experimentalRuntimeReferences(roster: RuntimeRoster): string[] {
+  const references = new Set([
+    ...roster.entries.map(entry => entry.name),
+    ...roster.plugins.flatMap(plugin => [plugin.owner ?? '', ...plugin.modules]),
+    ...roster.modules,
+    ...roster.client.entries.flatMap(entry => [entry.id, ...entry.inject ?? [], ...entry.external ?? []]),
+    ...roster.client.batches.flatMap(batch => batch.entries),
+  ])
+  return [...references].filter(reference => reference.includes('@deepseek-ai/dsh-experimental-')
+    || (reference.startsWith('file:') && (fileURLToPath(reference).replaceAll('\\', '/').includes('/packages/experimental/')
+      || modulePackage(reference)?.startsWith('@deepseek-ai/dsh-experimental-')))).sort()
+}

+ 42 - 0
apps/cli/tests/profiles/web/tests/web-default-isolation.expected.e2e.ts

@@ -0,0 +1,42 @@
+/** Real Web startup, mounted plugin package identities, and delivered Client graph isolation. */
+
+import { FiberState } from '@deepseek-ai/cordis'
+import type { WebBootGraph } from '@deepseek-ai/dsh-client-modules/client'
+import { expect, it } from 'vitest'
+import { experimentalRuntimeReferences, modulePackage } from './runtime-roster.ts'
+import { withDefaultWeb, webGet } from './default-web-process.ts'
+
+const experimentalName = '@deepseek-ai/dsh-experimental-client-ui-agent-team'
+
+it('boots the default Web profile without experimental Host modules, mounted plugins, or Client entries', async (test) => {
+  await withDefaultWeb(test, async ({ url, request }) => {
+    const auth = await webGet(url, test.signal)
+    const cookie = auth.headers['set-cookie']?.[0]?.split(';', 1)[0]
+    expect(cookie).toBeDefined()
+    const page = await webGet(new URL('/', url), test.signal, { cookie: cookie! })
+    expect(page.status).toBe(200)
+    const html = page.text
+    const rawBoot = /globalThis\["__DSH_BOOT__"\] = ([\s\S]*?)<\/script>/u.exec(html)?.[1]
+    expect(rawBoot, html).toBeDefined()
+    const delivered = JSON.parse(rawBoot!) as WebBootGraph
+    const roster = await request('roster')
+    expect(roster.client).toEqual(delivered)
+    expect(roster.entries).toEqual(expect.arrayContaining([
+      expect.objectContaining({ name: '@deepseek-ai/dsh-host-webserver', state: FiberState.ACTIVE }),
+      expect.objectContaining({ name: '@deepseek-ai/dsh-client-modules', state: FiberState.ACTIVE }),
+    ]))
+    expect(roster.entries.some(entry => entry.name.endsWith('/runtime-roster-observer.js') && entry.state === FiberState.ACTIVE)).toBe(true)
+    expect(roster.plugins.length).toBeGreaterThan(roster.entries.length)
+    expect(roster.modules.some(url => modulePackage(url) === '@deepseek-ai/dsh')).toBe(true)
+    expect(roster.client.entries.length).toBeGreaterThan(0)
+    expect(experimentalRuntimeReferences(roster)).toEqual([])
+
+    const contaminated = await request('mount-experimental')
+    expect(contaminated.entries.some(entry => entry.name === experimentalName)).toBe(false)
+    const mounted = contaminated.plugins.filter(plugin => plugin.modules.some(url => modulePackage(url) === experimentalName))
+    expect(mounted).toEqual([expect.objectContaining({ state: FiberState.ACTIVE })])
+    expect(experimentalRuntimeReferences(contaminated)).toEqual(expect.arrayContaining([
+      expect.stringContaining('/packages/experimental/client-ui-agent-team/'),
+    ]))
+  })
+})

+ 100 - 0
apps/web/product-isolation.ts

@@ -0,0 +1,100 @@
+/** Vite adapters for recording product chunk, asset, CSS, and worker inputs. */
+
+import type { Plugin } from 'vite'
+import { WebProductBundleIsolation } from '../../scripts/web-product-bundle-isolation.ts'
+
+const DEPENDENCY_ANALYSIS_PLUGIN = 'dsh-browser-dependency-analysis'
+
+/**
+ * Identify a dependency-disclosure walk that deliberately leaves third-party imports external.
+ * @returns Analysis intent that refuses builds which can write output.
+ */
+export function browserDependencyAnalysis(): Plugin {
+  return {
+    name: DEPENDENCY_ANALYSIS_PLUGIN,
+    apply: 'build',
+    configResolved(config) { requireNonWritingAnalysis(config.build.write) },
+  }
+}
+
+function requireNonWritingAnalysis(write: boolean): void {
+  if (write !== false) throw new Error('browser dependency analysis requires build.write: false')
+}
+
+/**
+ * Reject experimental inputs reachable from the built default Web page.
+ * @param repository - repository root supplying package ownership.
+ * @param webRoot - Vite root containing index.html and public assets.
+ * @returns Build plugins that preserve Vite's output and fail before publication on a violation.
+ */
+export function productWebBundleIsolation(repository: string, webRoot: string): Plugin[] {
+  const inputs = new WebProductBundleIsolation(repository, webRoot)
+  let dependencyAnalysis = false
+  return [{
+    name: 'dsh-product-web-chunk-inputs',
+    apply: 'build',
+    generateBundle: { order: 'pre', handler(_options, bundle) { inputs.captureChunks(bundle) } },
+  }, {
+    name: 'dsh-product-web-bundle-isolation',
+    apply: 'build',
+    enforce: 'post',
+    config(config) {
+      const renderBuiltUrl = config.experimental?.renderBuiltUrl
+      const workerPlugins = config.worker?.plugins
+      return {
+        experimental: {
+          renderBuiltUrl(filename, context) {
+            inputs.assetReference(filename, context.hostId, context.type)
+            return renderBuiltUrl?.(filename, context)
+          },
+        },
+        worker: {
+          plugins: () => [...workerPlugins?.() ?? [], {
+            name: 'dsh-worker-build-inputs',
+            generateBundle: {
+              order: 'post',
+              handler(_options, bundle) { inputs.workerBundle(bundle, this.getWatchFiles()) },
+            },
+          }],
+        },
+      }
+    },
+    configResolved(config) {
+      dependencyAnalysis = config.plugins.some(plugin => plugin.name === DEPENDENCY_ANALYSIS_PLUGIN)
+      if (dependencyAnalysis) {
+        requireNonWritingAnalysis(config.build.write)
+        return
+      }
+      const cssPlugins = config.plugins.filter(plugin => plugin.name === 'vite:css')
+      const css = cssPlugins[0]
+      if (cssPlugins.length !== 1 || css?.transform === undefined) {
+        throw new Error('Web product isolation: Vite CSS input instrumentation is unavailable')
+      }
+      const transform = typeof css.transform === 'function' ? css.transform : css.transform.handler
+      css.transform = {
+        ...typeof css.transform === 'function' ? {} : css.transform,
+        handler(code, id, options) {
+          inputs.cssTransform(id)
+          const context = new Proxy(this, {
+            get(target, property) {
+              if (property === 'addWatchFile') return (file: string): void => {
+                inputs.cssDependency(id, file)
+                target.addWatchFile(file)
+              }
+              const value: unknown = Reflect.get(target, property, target)
+              return typeof value === 'function' ? value.bind(target) : value
+            },
+          })
+          return transform.call(context, code, id, options)
+        },
+      }
+    },
+    buildStart() { inputs.reset() },
+    generateBundle: {
+      order: 'post',
+      handler(_options, bundle) {
+        if (!dependencyAnalysis) inputs.verify(bundle, id => this.getModuleInfo(id))
+      },
+    },
+  }]
+}

+ 112 - 0
apps/web/tests/default-product-isolation.e2e.ts

@@ -0,0 +1,112 @@
+/** Chromium acceptance of the shipped Web profile's actual Client plugin and module registries. */
+
+import { FiberState } from '@deepseek-ai/cordis'
+import type { Context, Plugin, RegistryService } from '@deepseek-ai/cordis'
+import type { ClientModuleLoader, ClientModuleLoaderTarget } from '@deepseek-ai/dsh-client-modules/client'
+import { chromium } from 'playwright'
+import { expect, it } from 'vitest'
+import { withDefaultWeb } from '../../cli/tests/profiles/web/tests/default-web-process.ts'
+import { newEnglishPage } from './support.ts'
+
+interface ClientObservation {
+  ctx?: Context
+  modules?: ClientModuleLoader
+}
+
+async function readClientRoster() {
+  const observation = Reflect.get(globalThis, '__dshIsolationObservation') as ClientObservation
+  const { ctx, modules } = observation
+  if (ctx === undefined || modules === undefined) throw new Error('Real Client registry was not observed')
+  await ctx.loader.await()
+  const callbacks = new Map<object, string[]>()
+  for (const [id, record] of modules.loadCache) {
+    if (typeof record.exports !== 'object' || record.exports === null) continue
+    for (const value of Object.values(record.exports)) {
+      if (typeof value !== 'function' && (typeof value !== 'object' || value === null)) continue
+      const callback = ctx.registry.resolve(value as Plugin)
+      if (callback !== undefined) callbacks.set(callback, [...callbacks.get(callback) ?? [], id])
+    }
+  }
+  return {
+    entries: [...ctx.loader.entries()].map(entry => ({ name: entry.options.name, state: entry.fiber?.state })),
+    plugins: [...ctx.registry.values()].flatMap(runtime => [...runtime.fibers].map(fiber => ({
+      name: runtime.name ?? runtime.callback.name,
+      owner: fiber.entry?.options.name,
+      state: fiber.state,
+      modules: callbacks.get(runtime.callback) ?? [],
+    }))),
+    modules: [...modules.loadCache].flatMap(([id, record]) => [id, ...record.edges]),
+  }
+}
+
+function experimentalClientReferences(roster: Awaited<ReturnType<typeof readClientRoster>>): string[] {
+  return [
+    ...roster.entries.map(entry => entry.name),
+    ...roster.plugins.flatMap(plugin => [plugin.owner ?? '', ...plugin.modules]),
+    ...roster.modules,
+  ].filter(name => name.startsWith('@deepseek-ai/dsh-experimental-'))
+}
+
+it('activates the actual default Client registry without experimental packages', async (test) => {
+  await withDefaultWeb(test, async ({ url, request }) => {
+    const browser = await chromium.launch({ timeout: test.task.timeout })
+    test.onTestFinished(async () => { await browser.close() })
+    try {
+      const page = await newEnglishPage(browser)
+      page.setDefaultTimeout(test.task.timeout)
+      const errors: string[] = []
+      page.on('pageerror', error => errors.push(error.message))
+      await page.addInitScript(() => {
+        const observation: ClientObservation = {}
+        Reflect.set(globalThis, '__dshIsolationObservation', observation)
+        Object.defineProperty(globalThis, '__ModuleLoader__', {
+          configurable: true,
+          set(target: ClientModuleLoaderTarget) {
+            Object.defineProperty(globalThis, '__ModuleLoader__', { configurable: true, writable: true, value: target })
+            const create = target.create.bind(target)
+            target.create = function (options) {
+              const cordis = options.staticModules['@deepseek-ai/cordis'] as { RegistryService: typeof RegistryService }
+              const prototype = cordis.RegistryService.prototype
+              // eslint-disable-next-line @typescript-eslint/unbound-method -- apply() preserves the runtime registry receiver.
+              const plugin = prototype.plugin
+              // Capture the first real root and immediately restore the registry method.
+              prototype.plugin = function (...args: Parameters<RegistryService['plugin']>) {
+                prototype.plugin = plugin
+                observation.ctx = this.ctx.root
+                return plugin.apply(this, args)
+              }
+              const modules = create(options)
+              observation.modules = modules
+              return modules
+            }
+          },
+        })
+      })
+      const navigation = await page.goto(url)
+      expect(navigation?.status()).toBe(200)
+      await page.getByRole('tree', { name: 'Sessions' }).waitFor({ state: 'visible' })
+      const roster = await page.evaluate(readClientRoster)
+      const host = await request('roster')
+      expect(roster.entries.map(entry => entry.name).sort()).toEqual(host.client.entries.map(entry => entry.id).sort())
+      expect(roster.entries.every(entry => entry.state === FiberState.ACTIVE)).toBe(true)
+      expect(roster.plugins.length).toBeGreaterThan(roster.entries.length)
+      expect(roster.plugins.some(plugin => plugin.modules.includes('@deepseek-ai/dsh-client-ui-layout'))).toBe(true)
+      expect(experimentalClientReferences(roster)).toEqual([])
+      const contaminatedHost = await request('mount-experimental-entry')
+      const experimentalName = '@deepseek-ai/dsh-experimental-client-ui-agent-team'
+      expect(contaminatedHost.client.entries.map(entry => entry.id)).toContain(experimentalName)
+      await page.reload()
+      await expect.poll(async () => {
+        const current = await page.evaluate(readClientRoster)
+        return current.entries.some(entry => entry.name === experimentalName && entry.state === FiberState.ACTIVE)
+      }, { timeout: test.task.timeout }).toBe(true)
+      const contaminated = await page.evaluate(readClientRoster)
+      expect(contaminated.plugins.some(plugin => plugin.modules.includes(experimentalName)
+        && plugin.state === FiberState.ACTIVE)).toBe(true)
+      expect(experimentalClientReferences(contaminated)).toContain(experimentalName)
+      expect(errors).toEqual([])
+    } finally {
+      await browser.close()
+    }
+  })
+})

+ 1 - 0
apps/web/tsconfig.json

@@ -22,6 +22,7 @@
   // this client-registered project must not also hold them (one program
   // cannot see both sides of the cordis Context merges).
   "exclude": [
+    "tests/default-product-isolation.e2e.ts",
     "tests/scaffold.ts",
     "tests/auto-review-fixture.ts",
     "tests/scaffold-generation.spec.ts",

+ 17 - 6
apps/web/vite.config.ts

@@ -1,9 +1,11 @@
 import { readFile, writeFile } from 'node:fs/promises'
+import { resolve } from 'node:path'
 import { fileURLToPath } from 'node:url'
 import { defineConfig } from 'vite'
 import type { Plugin } from 'vite'
 import react from '@vitejs/plugin-react'
 import { clientBuildEnvironmentDefines } from '../../scripts/client-build-environment.ts'
+import { productWebBundleIsolation } from './product-isolation.ts'
 
 const src = (rel: string): string => fileURLToPath(new URL(rel, import.meta.url))
 const STANDALONE_ERROR = 'apps/web is not a standalone application: bare Vite cannot inject window.__DSH_BOOT__. '
@@ -47,10 +49,17 @@ function rejectStandaloneServe(): Plugin {
 function emitPreviewPage(): Plugin {
   let bootstrapFile: string | undefined
   let write = true
+  let written = false
+  let outputDirectory = ''
   return {
     name: 'dsh-emit-preview-page',
     configResolved(config) {
       write = config.build.write
+      outputDirectory = resolve(config.root, config.build.outDir)
+    },
+    buildStart() {
+      bootstrapFile = undefined
+      written = false
     },
     generateBundle(_options, bundle) {
       if (!write) return
@@ -59,15 +68,14 @@ function emitPreviewPage(): Plugin {
       }
       if (bootstrapFile === undefined) throw new Error('vite: preview bootstrap entry missing from the bundle')
     },
+    writeBundle() { written = true },
     async closeBundle() {
-      if (!write) return
-      // A build that failed before generateBundle has no page to splice.
-      if (bootstrapFile === undefined) return
-      const page = await readFile(src('./dist/index.html'), 'utf8')
+      if (!write || !written || bootstrapFile === undefined) return
+      const page = await readFile(resolve(outputDirectory, 'index.html'), 'utf8')
       const anchor = page.indexOf('<script type="module"')
       if (anchor === -1) throw new Error('vite: built index.html lost its module entry tag')
       const tag = `<script type="module" crossorigin src="./${bootstrapFile}"></script>`
-      await writeFile(src('./dist/preview.html'), `${page.slice(0, anchor)}${tag}${page.slice(anchor)}`)
+      await writeFile(resolve(outputDirectory, 'preview.html'), `${page.slice(0, anchor)}${tag}${page.slice(anchor)}`)
     },
   }
 }
@@ -147,7 +155,10 @@ export default defineConfig({
   // Relative asset URLs: preview.html mounts the same output under any base
   // directory, and the served index resolves identically from the site root.
   base: './',
-  plugins: [rejectStandaloneServe(), clientDocumentTitle(), react(), emitPreviewPage()],
+  plugins: [
+    rejectStandaloneServe(), clientDocumentTitle(), react(), emitPreviewPage(),
+    productWebBundleIsolation(src('../..'), src('.')),
+  ],
   build: {
     // The worker bootstrap holds its page at top-level await; Vite's default
     // `modules` target (es2020-era) rejects that syntax.

+ 70 - 5
packages/client/tsdown.client.ts

@@ -7,17 +7,20 @@
  * and injects a tagged style at factory execution, while `x.css?inline`
  * exports compiled text for a plugin-owned lifecycle effect. The virtual
  * loaders register each real stylesheet as a watch dependency.
+ * Non-experimental client outputs reject experimental module and stylesheet
+ * inputs, including origins recorded by chained source maps.
  */
 import { readFile } from 'node:fs/promises'
 import { existsSync, globSync, readFileSync } from 'node:fs'
 import { isBuiltin } from 'node:module'
 import { basename, dirname, isAbsolute, relative, resolve as resolvePath, sep } from 'node:path'
 import { fileURLToPath } from 'node:url'
-import type { UserConfig } from 'tsdown'
+import type { TsdownPlugin, UserConfig } from 'tsdown'
 import { transform } from 'lightningcss'
 import { optionalStringArray } from './modules/src/client/manifest.ts'
 import { PLATFORM_MODULES, PRELOADED_CLIENT_EXTERNALS } from './web/src/platform.ts'
 import { clientBuildEnvironmentDefines } from '../../scripts/client-build-environment.ts'
+import { BundleInputIsolation, physicalBundleInput } from '../../scripts/bundle-input-isolation.ts'
 
 /**
  * Virtual-id wrapper keeping module CSS away from tsdown's own css pipeline
@@ -255,6 +258,7 @@ interface AssetEmitter {
 
 function staticLinkedConfig(id: string, entry: string, outputName = basename(entry, '.js')): UserConfig {
   const emitted = new Set<string>()
+  const isolation = clientInputIsolation(id)
   return {
     name: id,
     entry: { [outputName]: entry },
@@ -268,7 +272,10 @@ function staticLinkedConfig(id: string, entry: string, outputName = basename(ent
     // The shell compiles this artifact, so its map is the only path from a
     // browser stack frame back to the TSX (tsc emits the lib/types half).
     sourcemap: true,
-    outputOptions: { sourcemapExcludeSources: false },
+    outputOptions: {
+      sourcemapExcludeSources: false,
+      sourcemapPathTransform: isolation.sourcePath,
+    },
     plugins: [{
       // Contract 1. `pre` because tsdown's own deps plugin would otherwise
       // resolve and inline every specifier missing from the npm production
@@ -283,7 +290,7 @@ function staticLinkedConfig(id: string, entry: string, outputName = basename(ent
           return isBareSpecifier(source) ? { id: source, external: true } : null
         },
       },
-    }, tscSourceMapPlugin(), {
+    }, tscSourceMapPlugin(), isolation.plugin, {
       // Contract 4. The import survives verbatim and the sheet lands beside the
       // JavaScript, so the shell's CSS Modules pipeline sees a real stylesheet.
       name: 'dsh-css-asset',
@@ -427,6 +434,7 @@ function matchesSpecifier(patterns: readonly RegExp[], specifier: string): boole
 
 function clientConfig(id: string, entry: string): UserConfig {
   const isRequested = (specifier: string): boolean => clientExternals(id).has(specifier)
+  const isolation = clientInputIsolation(id)
   return {
     name: `${id}/client`,
     entry: { client: entry },
@@ -498,7 +506,7 @@ function clientConfig(id: string, entry: string): UserConfig {
           + '(type-only imports are erased and never reach this gate)',
         )
       },
-    }, tscSourceMapPlugin(), {
+    }, tscSourceMapPlugin(), isolation.plugin, {
       name: 'dsh-css-modules-inline',
       resolveId(source: string, importer: string | undefined) {
         if (!source.endsWith('.module.css')) return null
@@ -562,7 +570,10 @@ function clientConfig(id: string, entry: string): UserConfig {
       // browser resolves its local sources back into URLs that mirror the
       // /packages/<group>/<package>/src directories; sourcesContent keeps them usable
       // without exposing that tree as an HTTP route.
-      sourcemapPathTransform: browserSourcePath,
+      sourcemapPathTransform(source, mapPath) {
+        isolation.sourcePath(source, mapPath)
+        return browserSourcePath(source, mapPath)
+      },
       banner: `window.__ModuleLoader__.load({ id: ${JSON.stringify(id)}, factory: (require) => {`,
       footer: 'return module.exports; } });',
       intro: 'var module = { exports: {} }; var exports = module.exports;',
@@ -570,6 +581,60 @@ function clientConfig(id: string, entry: string): UserConfig {
   }
 }
 
+/** Check browser bundle inputs before their original package identity is folded into an artifact. */
+function clientInputIsolation(id: string): {
+  plugin: TsdownPlugin
+  sourcePath: (source: string, mapPath: string) => string
+} {
+  const experimental = id.startsWith('@deepseek-ai/dsh-experimental-')
+  const inputs = new BundleInputIsolation(REPOSITORY_ROOT, `client bundle isolation (${id})`)
+  return {
+    plugin: {
+      name: 'dsh-client-input-isolation',
+      buildStart() { inputs.reset() },
+      generateBundle(_options, bundle) {
+        if (experimental) return
+        for (const output of Object.values(bundle)) {
+          if (output.type === 'chunk') {
+            for (const module of Object.keys(output.modules)) {
+              inputs.assertInput(clientInputFile(module))
+              const info = this.getModuleInfo(module)
+              if (info === null) {
+                // Rolldown's runtime helper is compiler-generated and has no source module record.
+                if (module === '\0rolldown/runtime.js') continue
+                throw new Error(`client bundle isolation (${id}): module ${module} has no bundler module record`)
+              }
+              for (const dependency of [...info.importedIds, ...info.dynamicallyImportedIds]) {
+                inputs.assertInput(clientInputFile(dependency))
+              }
+            }
+            for (const external of [...output.imports, ...output.dynamicImports]) {
+              if (!(external in bundle)) inputs.assertInput(external)
+            }
+          } else {
+            for (const original of output.originalFileNames) inputs.assertInput(original)
+          }
+        }
+      },
+    },
+    sourcePath(source, mapPath) {
+      if (!experimental) {
+        const decoded = clientInputFile(source)
+        const file = physicalBundleInput(decoded) ?? resolvePath(dirname(mapPath), decoded)
+        inputs.assertSourceMapInput(file)
+      }
+      return source
+    },
+  }
+}
+
+/** CSS loader ids append a JavaScript suffix to the physical stylesheet path. */
+function clientInputFile(id: string): string {
+  const prefix = [CSS_VIRTUAL_PREFIX, GLOBAL_CSS_VIRTUAL_PREFIX, INLINE_CSS_VIRTUAL_PREFIX]
+    .find(prefix => id.startsWith(prefix))
+  return prefix === undefined ? id : id.slice(prefix.length, -CSS_VIRTUAL_SUFFIX.length)
+}
+
 /** Chain tsc's emitted maps into any Client bundle that consumes `lib/types`. */
 function tscSourceMapPlugin() {
   return {

+ 10 - 3
scripts/browser-bundled-externals.spec.ts

@@ -99,9 +99,16 @@ describe('browser dependency discovery', () => {
     symlinkSync(resolve(repositoryRoot, 'apps/web/node_modules'), join(app, 'node_modules'), 'junction')
     write(root, 'apps/web/index.html', '<script type="module" src="./main.ts"></script>')
     write(root, 'apps/web/main.ts', 'import { output, lazy } from "@fixture/static"; console.log(output); void lazy()')
-    write(root, 'apps/web/vite.config.ts', `export default {
-      build: { rollupOptions: { input: { index: ${JSON.stringify(join(app, 'index.html'))}, preview: "missing-preview.ts" } } }
-    }`)
+    write(root, 'apps/web/vite.config.ts', `
+      import { productWebBundleIsolation } from ${JSON.stringify(resolve(repositoryRoot, 'apps/web/product-isolation.ts').replaceAll('\\', '/'))}
+      export default ({ mode }) => {
+        if (mode !== 'production') throw new Error('notice discovery must use the production build mode')
+        return {
+          plugins: productWebBundleIsolation(${JSON.stringify(root)}, ${JSON.stringify(app)}),
+          build: { rollupOptions: { input: { index: ${JSON.stringify(join(app, 'index.html'))}, preview: "missing-preview.ts" } } }
+        }
+      }
+    `)
     write(root, 'apps/web/dist/sentinel.txt', 'untouched')
 
     const scanRoot = linked ? join(fixture(), 'linked') : root

+ 6 - 3
scripts/browser-bundled-externals.ts

@@ -6,6 +6,7 @@ import { dirname, resolve } from 'node:path'
 import { pathToFileURL } from 'node:url'
 import { Rolldown, type UserConfigExport } from 'tsdown'
 import ts from 'typescript'
+import { browserDependencyAnalysis } from '../apps/web/product-isolation.ts'
 
 interface Manifest {
   name: string
@@ -117,7 +118,9 @@ interface ShellConfig {
 }
 
 interface ViteApi {
-  resolveConfig(config: Record<string, unknown>, command: 'build'): Promise<ShellConfig>
+  resolveConfig(
+    config: Record<string, unknown>, command: 'build', defaultMode: string, defaultNodeEnv: string,
+  ): Promise<ShellConfig>
   build(config: Record<string, unknown>): Promise<unknown>
 }
 
@@ -132,7 +135,7 @@ async function collectShell(
     if (manifest.private === true || manifest.exports?.['./dist/*'] === undefined) continue
     const vitePath = createRequire(resolve(dir, 'package.json')).resolve('vite')
     const vite = await import(pathToFileURL(vitePath).href) as ViteApi
-    const config = await vite.resolveConfig({ root: dir, logLevel: 'error' }, 'build')
+    const config = await vite.resolveConfig({ root: dir, logLevel: 'error' }, 'build', 'production', 'production')
     const input = config.build.rollupOptions?.input
     const entries = typeof input === 'string' ? [input] : Object.values(input ?? {})
     const pages = entries.filter(entry => entry.endsWith('.html'))
@@ -140,7 +143,7 @@ async function collectShell(
     await vite.build({
       root: dir,
       logLevel: 'error',
-      plugins: [recorder(seen, workspaceNames, true)],
+      plugins: [browserDependencyAnalysis(), recorder(seen, workspaceNames, true)],
       resolve: { alias: browserSourceAliases(root) },
       build: {
         write: false,

+ 95 - 0
scripts/bundle-input-isolation.ts

@@ -0,0 +1,95 @@
+/** Experimental ownership of the actual filesystem and package inputs supplied to a bundler. */
+
+import { existsSync, readFileSync, realpathSync } from 'node:fs'
+import { dirname, isAbsolute, relative, resolve, sep } from 'node:path'
+import { fileURLToPath } from 'node:url'
+
+/** Package identities are cached only within one build. */
+export class BundleInputIsolation {
+  private readonly packages = new Map<string, string | undefined>()
+  private readonly repository: string
+  private readonly label: string
+
+  constructor(repository: string, label: string) {
+    this.repository = repository
+    this.label = label
+  }
+
+  /** Discard package metadata before a new build or watch rebuild. */
+  reset(): void { this.packages.clear() }
+
+  /**
+   * Require a recorded input to have non-experimental physical and package ownership.
+   * @param id - bundler module id, filesystem path, or filesystem URL.
+   */
+  assertInput(id: string): void {
+    this.checkInput(id, false)
+  }
+
+  /**
+   * Check declared source-map ownership; dependency tarballs may omit their upstream sources.
+   * @param id - source-map source resolved relative to its map.
+   */
+  assertSourceMapInput(id: string): void {
+    this.checkInput(id, true)
+  }
+
+  private checkInput(id: string, allowMissing: boolean): void {
+    if (/(?:^|[/:\u0000])@deepseek-ai\/dsh-experimental-[^/?#]+/.test(id)) {
+      throw new Error(`${this.label}: experimental input ${id}`)
+    }
+    const file = physicalBundleInput(id)
+    if (file === undefined) return
+    const lexical = relative(resolve(this.repository, 'packages/experimental'), file)
+    if (lexical === '' || lexical !== '..' && !lexical.startsWith(`..${sep}`) && !isAbsolute(lexical)) {
+      throw new Error(`${this.label}: experimental input ${id}`)
+    }
+    let existing = file
+    if (!allowMissing && !existsSync(file)) throw new Error(`${this.label}: input ${id} is missing`)
+    while (!existsSync(existing)) {
+      const parent = dirname(existing)
+      if (parent === existing) throw new Error(`${this.label}: input ${id} has no existing filesystem root`)
+      existing = parent
+    }
+    const canonical = resolve(realpathSync(existing), relative(existing, file))
+    if (canonical !== file) this.checkInput(canonical, allowMissing)
+    const name = this.packageName(dirname(canonical))
+    if (name?.startsWith('@deepseek-ai/dsh-experimental-')) {
+      throw new Error(`${this.label}: ${id} belongs to experimental package ${name}`)
+    }
+  }
+
+  private packageName(directory: string): string | undefined {
+    if (this.packages.has(directory)) return this.packages.get(directory)
+    const manifest = resolve(directory, 'package.json')
+    let name: string | undefined
+    if (existsSync(manifest)) {
+      const value: unknown = JSON.parse(readFileSync(manifest, 'utf8'))
+      if (value !== null && typeof value === 'object' && 'name' in value && typeof value.name === 'string') name = value.name
+    }
+    if (name === undefined && dirname(directory) !== directory) name = this.packageName(dirname(directory))
+    this.packages.set(directory, name)
+    return name
+  }
+}
+
+/**
+ * Recover physical ownership from queries, filesystem URLs, and virtual path wrappers.
+ * @param id - bundler module id or recorded filesystem input.
+ * @returns absolute file path, or undefined for a virtual id without a physical path.
+ */
+export function physicalBundleInput(id: string): string | undefined {
+  let clean = id.replaceAll('\\', '/').split(/[?#]/, 1)[0] ?? ''
+  clean = clean.replace(/^\u0000/, '')
+  if (clean.startsWith('file://')) return fileURLToPath(clean)
+  if (clean.startsWith('/@fs/')) {
+    clean = clean.slice('/@fs/'.length)
+    if (!isAbsolute(clean) && !/^[a-zA-Z]:\//.test(clean)) clean = `/${clean}`
+  }
+  if (!isAbsolute(clean) && !/^[a-zA-Z]:\//.test(clean)) {
+    const wrapped = /:(\/.*|[a-zA-Z]:\/.*)$/.exec(clean)?.[1]
+    if (wrapped === undefined) return undefined
+    clean = wrapped
+  }
+  return resolve(clean)
+}

+ 186 - 4
scripts/client-bundle-purity.spec.ts

@@ -2,12 +2,13 @@
  * Pins shared client-bundle preset rules: module-edge purity, source-map
  * chaining, and physical watch dependencies hidden behind virtual CSS Modules.
  */
-import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
+import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
 import { tmpdir } from 'node:os'
-import { join } from 'node:path'
+import { dirname, join, relative } from 'node:path'
 import { fileURLToPath } from 'node:url'
-import { describe, expect, it, vi } from 'vitest'
-import { clientBundle, requestedExternals } from '../packages/client/tsdown.client.ts'
+import { build, type TsdownBundle, type UserConfig } from 'tsdown'
+import { describe, expect, it, onTestFinished, vi } from 'vitest'
+import { clientBundle, requestedExternals, staticLinked } from '../packages/client/tsdown.client.ts'
 
 type ResolveId = (source: string) => null | { id: string; external: boolean }
 
@@ -22,6 +23,18 @@ interface SourceMapPlugin {
   load?: (id: string) => Promise<unknown>
 }
 
+interface InputIsolationPlugin {
+  name: string
+  generateBundle: (this: {
+    getModuleInfo(id: string): { importedIds: string[]; dynamicallyImportedIds: string[] } | null
+  }, options: unknown, bundle: Record<string, {
+      type: 'chunk'
+      modules: Record<string, object>
+      imports: string[]
+      dynamicImports: string[]
+    }>) => void
+}
+
 /** A representative dynamic bundle using the shared client baseline. */
 const REQUESTING_PACKAGE = '@deepseek-ai/dsh-client-ui-conversation'
 
@@ -147,6 +160,175 @@ describe('client bundle purity gate', () => {
   })
 })
 
+describe('client bundle experimental input isolation', () => {
+  const experimental = '@deepseek-ai/dsh-experimental-client-ui-agent-team'
+
+  function fixture() {
+    const root = mkdtempSync(join(tmpdir(), 'dsh-client-inputs-'))
+    onTestFinished(() => { rmSync(root, { recursive: true, force: true }) })
+    const owner = join(root, 'client')
+    const entry = join(owner, 'lib/types/client/index.js')
+    const prototype = join(root, 'prototype/src/index.js')
+    for (const file of [entry, prototype]) mkdirSync(dirname(file), { recursive: true })
+    writeFileSync(join(owner, 'package.json'), JSON.stringify({ name: REQUESTING_PACKAGE, type: 'module' }))
+    writeFileSync(join(root, 'prototype/package.json'), JSON.stringify({ name: experimental, type: 'module' }))
+    writeFileSync(prototype, 'export const marker = "experimental sentinel"\n')
+    return { root, owner, entry, prototype }
+  }
+
+  function config(kind: 'static' | 'dynamic', id = REQUESTING_PACKAGE): UserConfig {
+    const configs = kind === 'static'
+      ? staticLinked(id, ['lib/types/client/index.js'])({ env: { DSH_BUILD_FACE: 'client' } })
+      : clientConfigs(id)
+    const browser = configs.find(config => config.platform === 'browser')
+    if (browser === undefined) throw new Error('client config missing')
+    return browser
+  }
+
+  async function bundle(owner: string, config: UserConfig): Promise<string> {
+    let builds: TsdownBundle[] = []
+    try {
+      builds = await build({
+        ...config, cwd: owner, config: false, tsconfig: false,
+        write: false, clean: false, exports: false, report: false, logLevel: 'silent',
+      })
+      return builds.flatMap(build => build.chunks.filter(chunk => chunk.type === 'chunk').map(chunk => chunk.code)).join('\n')
+    } finally {
+      for (const build of builds) await build[Symbol.asyncDispose]()
+    }
+  }
+
+  function importPath(from: string, to: string): string {
+    return relative(dirname(from), to).replaceAll('\\', '/')
+  }
+
+  function checkCompilerModule(module: string, imports?: string[]): void {
+    const plugins = config('dynamic').plugins as InputIsolationPlugin[]
+    const plugin = plugins.find(plugin => plugin.name === 'dsh-client-input-isolation')
+    if (plugin === undefined) throw new Error('client input isolation plugin missing')
+    plugin.generateBundle.call({
+      getModuleInfo: () => imports === undefined ? null : { importedIds: imports, dynamicallyImportedIds: [] },
+    }, {}, {
+      'client.js': { type: 'chunk', modules: { [module]: {} }, imports: [], dynamicImports: [] },
+    })
+  }
+
+  it('accepts the exact compiler runtime helper without a source module record', () => {
+    expect(() => { checkCompilerModule('\0rolldown/runtime.js') }).not.toThrow()
+  })
+
+  it.each(['\0rolldown/other.js', '\0rolldown/runtime.js?user', '\0other/runtime.js'])(
+    'rejects the unrecorded module %j',
+    (module) => {
+      expect(() => { checkCompilerModule(module) }).toThrow(/has no bundler module record/)
+    },
+  )
+
+  it('checks runtime helper dependencies when the compiler supplies a module record', () => {
+    expect(() => { checkCompilerModule('\0rolldown/runtime.js', [experimental]) })
+      .toThrow(/client bundle isolation.*experimental/)
+  })
+
+  it.each(['static', 'dynamic'] as const)('rejects experimental files folded into a %s client artifact', async (kind) => {
+    const { owner, entry, prototype } = fixture()
+    writeFileSync(entry, `export { marker } from ${JSON.stringify(importPath(entry, prototype))}\n`)
+
+    await expect(bundle(owner, config(kind))).rejects.toThrow(/client bundle isolation.*experimental/)
+    expect(existsSync(join(owner, 'lib/client.js'))).toBe(false)
+    expect(existsSync(join(owner, 'lib/index.js'))).toBe(false)
+  })
+
+  it('proves the static library otherwise hides the experimental input in its emitted JavaScript', async () => {
+    const { owner, entry, prototype } = fixture()
+    writeFileSync(entry, `export { marker } from ${JSON.stringify(importPath(entry, prototype))}\n`)
+    const guarded = config('static')
+    const plugins = guarded.plugins as Array<{ name: string }>
+    const unguarded: UserConfig = {
+      ...guarded,
+      plugins: plugins.filter(plugin => plugin.name !== 'dsh-client-input-isolation'),
+      outputOptions: { sourcemapExcludeSources: false },
+    }
+
+    await expect(bundle(owner, unguarded)).resolves.toContain('experimental sentinel')
+    await expect(bundle(owner, guarded)).rejects.toThrow(/client bundle isolation.*experimental/)
+  })
+
+  it.each(['static', 'dynamic'] as const)('allows an experimental %s artifact to keep its own inputs', async (kind) => {
+    const { owner, entry, prototype } = fixture()
+    writeFileSync(entry, `export { marker } from ${JSON.stringify(importPath(entry, prototype))}\n`)
+
+    await expect(bundle(owner, config(kind, experimental))).resolves.toContain('experimental sentinel')
+  })
+
+  it('rejects experimental ownership preserved only by an emitted compiler source map', async () => {
+    const { owner, entry, prototype } = fixture()
+    writeFileSync(entry, 'export const marker = "experimental sentinel"\n//# sourceMappingURL=index.js.map\n')
+    writeFileSync(`${entry}.map`, JSON.stringify({
+      version: 3, names: [], mappings: 'AAAA', sources: [importPath(entry, prototype)],
+      sourcesContent: ['export const marker = "experimental sentinel"\n'],
+    }))
+
+    await expect(bundle(owner, config('static'))).rejects.toThrow(/client bundle isolation.*experimental/)
+  })
+
+  it('checks the resolved module when an import alias conceals its experimental package', async () => {
+    const { owner, entry, prototype } = fixture()
+    writeFileSync(entry, 'export { marker } from "./ordinary.js"\n')
+
+    await expect(bundle(owner, { ...config('static'), alias: { './ordinary.js': prototype } }))
+      .rejects.toThrow(/client bundle isolation.*experimental/)
+  })
+
+  it('rejects an experimental runtime import left external by a static client library', async () => {
+    const { owner, entry } = fixture()
+    writeFileSync(entry, `export * from ${JSON.stringify(experimental)}\n`)
+
+    await expect(bundle(owner, config('static'))).rejects.toThrow(/client bundle isolation.*experimental/)
+  })
+
+  it.each([false, true])('checks omitted source-map files against their package owner (experimental: %s)', async (experimentalSource) => {
+    const { root, owner, entry } = fixture()
+    const dependency = join(root, 'dependency')
+    mkdirSync(dependency)
+    writeFileSync(join(dependency, 'package.json'), JSON.stringify({ name: experimentalSource ? experimental : 'ordinary-library' }))
+    writeFileSync(entry, 'export const marker = "mapped sentinel"\n//# sourceMappingURL=index.js.map\n')
+    writeFileSync(`${entry}.map`, JSON.stringify({
+      version: 3, names: [], mappings: 'AAAA', sourceRoot: importPath(entry, dependency),
+      sources: ['unshipped.ts'], sourcesContent: ['export const marker = "mapped sentinel"\n'],
+    }))
+
+    const result = bundle(owner, config('static'))
+    if (experimentalSource) await expect(result).rejects.toThrow(/client bundle isolation.*experimental/)
+    else await expect(result).resolves.toContain('mapped sentinel')
+  })
+
+  it.each(['.css', '.module.css', '.css?inline'])('rejects experimental %s inputs behind client CSS virtual loaders', async (extension) => {
+    const { owner, entry, prototype } = fixture()
+    const css = join(dirname(prototype), `style${extension.replace('?inline', '')}`)
+    writeFileSync(css, 'body { color: red; }\n')
+    const specifier = `${importPath(entry, css)}${extension.endsWith('?inline') ? '?inline' : ''}`
+    writeFileSync(entry, extension === '.css'
+      ? `import ${JSON.stringify(specifier)}\nexport const marker = true\n`
+      : `export { default as style } from ${JSON.stringify(specifier)}\n`)
+
+    await expect(bundle(owner, config('dynamic'))).rejects.toThrow(/client bundle isolation.*experimental/)
+  })
+
+  it.each(['static', 'dynamic'] as const)('keeps ordinary %s bundles and source maps buildable', async (kind) => {
+    const { owner, entry } = fixture()
+    const source = join(owner, 'src/client/index.ts')
+    mkdirSync(dirname(source), { recursive: true })
+    writeFileSync(source, 'export const marker = "stable sentinel"\n')
+    writeFileSync(entry, 'export const marker = "stable sentinel"\n//# sourceMappingURL=index.js.map\n')
+    writeFileSync(`${entry}.map`, JSON.stringify({
+      version: 3, names: [], mappings: 'AAAA', sources: [importPath(entry, source)],
+      sourcesContent: ['export const marker = "stable sentinel"\n'],
+    }))
+
+    await expect(bundle(owner, config(kind))).resolves.toContain('stable sentinel')
+  })
+})
+
 describe('client bundle module requests', () => {
   it('requests what the declaration lists', () => {
     const requests = requestedExternals('@deepseek-ai/dsh-client-fixture', {

+ 45 - 3
scripts/verify-default-product-isolation.spec.ts

@@ -3,6 +3,7 @@
 import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
 import { tmpdir } from 'node:os'
 import { dirname, join } from 'node:path'
+import { pathToFileURL } from 'node:url'
 import { afterEach, describe, expect, it } from 'vitest'
 import { verifyDefaultProductIsolation } from './verify-default-product-isolation.ts'
 
@@ -39,7 +40,7 @@ function fixture(): string {
   write(root, 'packages/bundle/base/package.json', { name: base, dsh: { bundle: { patch: './cordis.patch.yml' } } })
   write(root, patch, [{ insert: [{ name: core }] }])
   write(root, preset, [{ name: core }])
-  write(root, profile, `export const PROFILE_TEMPLATES = { headless: { bundles: ['${base}'], patchReload: 'startup' } }\n`
+  write(root, profile, `export const PROFILE_TEMPLATES = { web: { bundles: ['${base}'], patchReload: 'live' } }\n`
     + `export const DEFAULT_PROFILE_BUNDLES = ['${base}']\n`)
   write(root, 'packages/experimental/prototype/package.json', { name: experimental })
   return root
@@ -140,7 +141,7 @@ describe('default product isolation', () => {
 
   it('follows Include files while ignoring ordinary plugin config data', () => {
     const root = fixture()
-    write(root, patch, [{ name: core, config: { name: experimental, insert: [{ name: experimental }] } }])
+    write(root, patch, [{ insert: [{ name: core, config: { name: experimental, insert: [{ name: experimental }] } }] }])
     expect(verifyDefaultProductIsolation(root).failures).toEqual([])
     write(root, patch, [{ name: '@deepseek-ai/cordis-plugin-include', config: { path: './nested.yml' } }])
     write(root, 'packages/bundle/base/nested.yml', [{ name: experimental }])
@@ -149,7 +150,7 @@ describe('default product isolation', () => {
 
   it('checks default profile bundle names and declared config trees', () => {
     const root = fixture()
-    write(root, profile, `export const PROFILE_TEMPLATES = { test: { bundles: ['${experimental}'] } }\n`
+    write(root, profile, `export const PROFILE_TEMPLATES = { web: { bundles: ['${experimental}'] } }\n`
       + `export const DEFAULT_PROFILE_BUNDLES = ['${base}']\n`)
     manifest(root, 'apps/cli/package.json', { dsh: { configTrees: [{ path: './config' }] } })
     write(root, 'apps/cli/config/extra.cordis.yml', [{ name: experimental }])
@@ -167,6 +168,47 @@ describe('default product isolation', () => {
     expect(verifyDefaultProductIsolation(root).failures.join('\n')).toContain(experimental)
   })
 
+  it('checks group contents after an id-only patch changes the composed Web tree', () => {
+    const root = fixture()
+    const web = '@deepseek-ai/dsh-web-app'
+    write(root, 'packages/bundle/web-app/package.json', { name: web, dsh: { bundle: { patch: './cordis.patch.yml' } } })
+    write(root, patch, [{ insert: [{ id: 'feature-group', group: true, config: [{ name: core }] }] }])
+    write(root, 'packages/bundle/web-app/cordis.patch.yml', [
+      { id: 'feature-group', config: [{ name: experimental }] },
+    ])
+    write(root, profile, `export const PROFILE_TEMPLATES = { web: { bundles: ['${base}', '${web}'] } }\n`
+      + `export const DEFAULT_PROFILE_BUNDLES = ['${base}']\n`)
+
+    expect(verifyDefaultProductIsolation(root).failures.join('\n')).toContain(`${profile} -> ${experimental}`)
+  })
+
+  it('composes Include patches before checking their effective plugin rows', () => {
+    const root = fixture()
+    const included = join(root, 'included tree', 'cordis.yml')
+    write(root, 'included tree/cordis.yml', [{ id: 'feature-group', group: true, config: [{ name: core }] }])
+    write(root, patch, [{ insert: [{ name: 'cordis:include', config: {
+      path: pathToFileURL(included).href,
+      patches: [{ id: 'feature-group', config: [{ name: experimental }] }],
+    } }] }])
+
+    expect(verifyDefaultProductIsolation(root).failures.join('\n')).toContain(`included tree/cordis.yml -> ${experimental}`)
+  })
+
+  it('reports an empty effective Web tree even when the raw patch names a package', () => {
+    const root = fixture()
+    write(root, patch, [{ id: 'missing-target', name: core }])
+
+    expect(verifyDefaultProductIsolation(root).failures).toContain(`${profile}: composed Web profile contains no plugins`)
+  })
+
+  it('rejects a file-URL plugin name anchored by the real patch loader', () => {
+    const root = fixture()
+    write(root, patch, [{ insert: [{ name: '../../experimental/prototype/src/index.ts' }] }])
+    write(root, 'packages/experimental/prototype/src/index.ts', 'export {}\n')
+
+    expect(verifyDefaultProductIsolation(root).failures.join('\n')).toContain(`${profile} -> ${experimental}`)
+  })
+
   it('checks installation-owned profile tuples as well as new-profile defaults', () => {
     const root = fixture()
     const source = readFileSync(join(root, profile), 'utf8')

+ 78 - 24
scripts/verify-default-product-isolation.ts

@@ -2,8 +2,13 @@
 
 import { existsSync, globSync, readFileSync, statSync } from 'node:fs'
 import { dirname, relative, resolve } from 'node:path'
+import { fileURLToPath } from 'node:url'
 import { JSDOM } from 'jsdom'
 import ts from 'typescript'
+import { applyEntryPatches, type PatchOptions } from '@deepseek-ai/cordis-plugin-include'
+import type { EntryOptions } from '@deepseek-ai/cordis-plugin-loader'
+import { loadOverlayPatches } from '../packages/boot/app-boot/src/index.ts'
+import { composeEntries } from '../packages/boot/app-boot/src/profile.ts'
 import { isCordisGroupEntry, loadCordisYaml } from './cordis-yaml.ts'
 import {
   collectRuntimeLocalSourceSpecifiers,
@@ -35,6 +40,7 @@ export interface ProductIsolationResult {
   packageCount: number
   sourceCount: number
   configCount: number
+  webPluginCount: number
 }
 
 /**
@@ -70,6 +76,7 @@ export function verifyDefaultProductIsolation(root: string): ProductIsolationRes
   const visited = new Set<string>()
   const sources = new Set<string>()
   const configs = new Set<string>()
+  let webPluginCount = 0
   const isExperimental = (pkg: Package): boolean => pkg.manifest.name.startsWith(EXPERIMENTAL_PREFIX)
     || display(pkg.directory).startsWith('packages/experimental/')
   const add = (pkg: Package, origin: string): void => {
@@ -92,7 +99,8 @@ export function verifyDefaultProductIsolation(root: string): ProductIsolationRes
   }
   const reference = (name: string, origin: string, owner?: Package): void => {
     if (name.startsWith('.') || name.startsWith('/') || /^(?:file|link):/.test(name)) {
-      const target = resolve(owner?.directory ?? root, name.replace(/^(?:file|link):/, ''))
+      const target = name.startsWith('file://') ? fileURLToPath(name)
+        : resolve(owner?.directory ?? root, name.replace(/^(?:file|link):/, ''))
       const pkg = directories.get(target) ?? ownerOf(target)
       if (pkg !== undefined) add(pkg, origin)
       return
@@ -137,17 +145,13 @@ export function verifyDefaultProductIsolation(root: string): ProductIsolationRes
       if (followLocal && target !== undefined) scanSource(target, true)
     }
   }
-  const scanConfig = (path: string): void => {
-    if (configs.has(path)) return
-    configs.add(path)
-    const document = loadCordisYaml(readFileSync(path, 'utf8'))
-    if (!Array.isArray(document)) {
-      failures.push(`${display(path)}: shipped composition must contain an entry array`)
-      return
-    }
+  const scanEntries = (
+    document: unknown[], path: string, composedWeb = false, includeStack: ReadonlySet<string> = new Set(),
+  ): void => {
     const visit = (entry: unknown): void => {
       if (!isRecord(entry)) return
       if (typeof entry.name === 'string') {
+        if (composedWeb) webPluginCount += 1
         const owner = ownerOf(path)
         if (entry.name.startsWith('.')) {
           const target = resolve(dirname(path), entry.name)
@@ -155,21 +159,48 @@ export function verifyDefaultProductIsolation(root: string): ProductIsolationRes
           if (targetOwner !== undefined) add(targetOwner, display(path))
         } else reference(entry.name, display(path), owner)
       }
-      if (isCordisGroupEntry(entry)) entry.config.forEach(visit)
+      if (isCordisGroupEntry(entry) || entry.name === 'cordis:group' && Array.isArray(entry.config)) {
+        (entry.config as unknown[]).forEach(visit)
+      }
       if (Array.isArray(entry.insert)) entry.insert.forEach(visit)
-      if (entry.name === '@deepseek-ai/cordis-plugin-include' && isRecord(entry.config)) {
-        if (Array.isArray(entry.config.patches)) entry.config.patches.forEach(visit)
+      if ((entry.name === '@deepseek-ai/cordis-plugin-include' || entry.name === 'cordis:include') && isRecord(entry.config)) {
+        if (!composedWeb && Array.isArray(entry.config.patches)) entry.config.patches.forEach(visit)
         const paths = typeof entry.config.path === 'string' ? [entry.config.path] : entry.config.path
         if (Array.isArray(paths)) {
           for (const included of paths) {
             if (typeof included !== 'string') continue
-            scanConfig(resolve(dirname(path), included))
+            const filename = included.startsWith('file:') ? fileURLToPath(included) : resolve(dirname(path), included)
+            if (!composedWeb) {
+              scanConfig(filename)
+              continue
+            }
+            if (includeStack.has(filename)) {
+              failures.push(`${display(path)}: cyclic Include path ${display(filename)}`)
+              continue
+            }
+            const content = loadCordisYaml(readFileSync(filename, 'utf8'))
+            if (!Array.isArray(content)) {
+              failures.push(`${display(filename)}: included composition must contain an entry array`)
+              continue
+            }
+            const entries = applyEntryPatches(content as EntryOptions[], entry.config.patches as PatchOptions[] | undefined, () => {})
+            scanEntries(entries, filename, true, new Set([...includeStack, filename]))
           }
         }
       }
     }
     document.forEach(visit)
   }
+  const scanConfig = (path: string): void => {
+    if (configs.has(path)) return
+    configs.add(path)
+    const document = loadCordisYaml(readFileSync(path, 'utf8'))
+    if (!Array.isArray(document)) {
+      failures.push(`${display(path)}: shipped composition must contain an entry array`)
+      return
+    }
+    scanEntries(document, path)
+  }
 
   for (const pkg of packages.values()) {
     const path = display(pkg.directory)
@@ -177,12 +208,26 @@ export function verifyDefaultProductIsolation(root: string): ProductIsolationRes
   }
   const profilePath = resolve(root, PROFILE_SOURCE)
   if (existsSync(profilePath)) {
-    for (const name of profilePackages(readFileSync(profilePath, 'utf8'))) {
+    const selection = profilePackages(readFileSync(profilePath, 'utf8'))
+    for (const name of selection.packages) {
       reference(name, PROFILE_SOURCE)
       if (packages.get(name)?.manifest.dsh?.bundle?.patch === undefined) {
         failures.push(`${PROFILE_SOURCE}: default bundle ${name} must declare dsh.bundle.patch`)
       }
     }
+    const webLayers = selection.webBundles.flatMap((name) => {
+      const pkg = packages.get(name)
+      const patch = pkg?.manifest.dsh?.bundle?.patch
+      if (pkg === undefined || patch === undefined) return []
+      return [loadOverlayPatches('verify-default-product-isolation', resolve(pkg.directory, patch))]
+    })
+    if (webLayers.length !== selection.webBundles.length) {
+      failures.push(`${PROFILE_SOURCE}: default Web bundle layers are incomplete`)
+    } else {
+      const entries = composeEntries(webLayers)
+      scanEntries(entries, profilePath, true)
+      if (webPluginCount === 0) failures.push(`${PROFILE_SOURCE}: composed Web profile contains no plugins`)
+    }
   } else failures.push(`missing default profile source ${PROFILE_SOURCE}`)
   const presets = globSync(PRESET_PATTERN, { cwd: root }).sort()
   if (presets.length === 0) failures.push(`no shipped presets matched ${PRESET_PATTERN}`)
@@ -230,7 +275,7 @@ export function verifyDefaultProductIsolation(root: string): ProductIsolationRes
     for (const path of files) scanSource(resolve(pkg.directory, path))
   }
   return { failures: [...new Set(failures)], packageCount: visited.size,
-    sourceCount: sources.size, configCount: configs.size }
+    sourceCount: sources.size, configCount: configs.size, webPluginCount }
 }
 
 function barePackageName(specifier: string): string {
@@ -264,19 +309,26 @@ function isRecord(value: unknown): value is Record<string, unknown> {
 }
 
 /** Read the literal package lists that define installation-owned profile defaults. */
-function profilePackages(source: string): string[] {
+function profilePackages(source: string): { packages: string[]; webBundles: string[] } {
   const file = ts.createSourceFile(PROFILE_SOURCE, source, ts.ScriptTarget.Latest, true)
   const required = new Set(['PROFILE_TEMPLATES', 'DEFAULT_PROFILE_BUNDLES'])
   const found = new Set<string>()
   const packages: string[] = []
-  const literals = (node: ts.Node): void => {
+  const webBundles: string[] = []
+  const literals = (node: ts.Node, path: string[]): void => {
     if (ts.isStringLiteralLike(node)) {
       if (node.text.startsWith('@')) packages.push(node.text)
-    } else if (ts.isArrayLiteralExpression(node)) node.elements.forEach(literals)
-    else if (ts.isObjectLiteralExpression(node)) node.properties.forEach(literals)
-    else if (ts.isPropertyAssignment(node)) literals(node.initializer)
+      if (path.join('.') === 'PROFILE_TEMPLATES.web.bundles') webBundles.push(node.text)
+    } else if (ts.isArrayLiteralExpression(node)) node.elements.forEach((child) => { literals(child, path) })
+    else if (ts.isObjectLiteralExpression(node)) node.properties.forEach((child) => { literals(child, path) })
+    else if (ts.isPropertyAssignment(node)) {
+      if (!ts.isIdentifier(node.name) && !ts.isStringLiteralLike(node.name)) {
+        throw new Error(`${PROFILE_SOURCE}: default profile keys must be literal names`)
+      }
+      literals(node.initializer, [...path, node.name.text])
+    }
     else if (ts.isAsExpression(node) || ts.isSatisfiesExpression(node) || ts.isParenthesizedExpression(node)) {
-      literals(node.expression)
+      literals(node.expression, path)
     } else throw new Error(`${PROFILE_SOURCE}: default profile packages must use static literal lists`)
   }
   for (const statement of file.statements) {
@@ -287,12 +339,13 @@ function profilePackages(source: string): string[] {
       if (declaration.initializer === undefined) continue
       if (required.has(declaration.name.text)) found.add(declaration.name.text)
       const before = packages.length
-      literals(declaration.initializer)
+      literals(declaration.initializer, [declaration.name.text])
       if (packages.length === before) throw new Error(`${PROFILE_SOURCE}: ${declaration.name.text} has no default bundles`)
     }
   }
   if (found.size !== required.size) throw new Error(`${PROFILE_SOURCE}: missing default profile declarations`)
-  return packages
+  if (webBundles.length === 0) throw new Error(`${PROFILE_SOURCE}: missing default Web bundle list`)
+  return { packages, webBundles }
 }
 
 if (import.meta.main) {
@@ -302,6 +355,7 @@ if (import.meta.main) {
     process.exitCode = 1
   } else {
     console.log(`verify-default-product-isolation: ${String(result.packageCount)} packages, `
-      + `${String(result.sourceCount)} runtime sources, ${String(result.configCount)} configurations exclude experimental packages.`)
+      + `${String(result.sourceCount)} runtime sources, ${String(result.configCount)} configurations, `
+      + `${String(result.webPluginCount)} composed Web plugins exclude experimental packages.`)
   }
 }

+ 252 - 0
scripts/web-product-bundle-isolation.spec.ts

@@ -0,0 +1,252 @@
+/** Exercise real Vite build inputs, including files absent from ordinary chunk imports. */
+
+import { existsSync, mkdtempSync, mkdirSync, realpathSync, rmSync, symlinkSync, unlinkSync, writeFileSync } from 'node:fs'
+import { createRequire } from 'node:module'
+import { tmpdir } from 'node:os'
+import { dirname, join } from 'node:path'
+import { pathToFileURL } from 'node:url'
+import { describe, expect, it, onTestFinished, vi } from 'vitest'
+import { browserDependencyAnalysis, productWebBundleIsolation } from '../apps/web/product-isolation.ts'
+import { WebProductBundleIsolation } from './web-product-bundle-isolation.ts'
+import { BundleInputIsolation } from './bundle-input-isolation.ts'
+
+const filesystem = vi.hoisted(() => ({ missingRoot: false }))
+vi.mock('node:fs', async (importOriginal) => {
+  const actual = await importOriginal<typeof import('node:fs')>()
+  return {
+    ...actual,
+    existsSync: (path: Parameters<typeof actual.existsSync>[0]) => !filesystem.missingRoot && actual.existsSync(path),
+  }
+})
+
+// Vite is owned by the Web app rather than the repository's scripting dependencies.
+const vite = createRequire(new URL('../apps/web/package.json', import.meta.url))('vite') as {
+  build(config: Record<string, unknown>): Promise<unknown>
+}
+
+function fixture() {
+  const root = realpathSync(mkdtempSync(join(tmpdir(), 'dsh-web-bundle-isolation-')))
+  const web = join(root, 'apps/web')
+  const links: string[] = []
+  onTestFinished(() => {
+    for (const path of links) unlinkSync(path)
+    rmSync(root, { recursive: true, force: true })
+  })
+  const write = (path: string, content: string): void => {
+    mkdirSync(dirname(join(root, path)), { recursive: true })
+    writeFileSync(join(root, path), content)
+  }
+  write('package.json', '{"name":"isolation-fixture","type":"module"}')
+  write('apps/web/package.json', '{"name":"@deepseek-ai/dsh-web-frontend","type":"module"}')
+  write('apps/web/index.html', '<script type="module" src="/src/main.js"></script>')
+  write('apps/web/src/main.js', 'globalThis.product = true')
+  write('apps/web/src/preview.js', 'import "../../../packages/experimental/prototype/index.js"')
+  write('packages/experimental/prototype/package.json', '{"name":"@deepseek-ai/dsh-experimental-prototype","type":"module"}')
+  write('packages/experimental/prototype/index.js', 'globalThis.experimental = true')
+  write('packages/experimental/prototype/style.css', '.experimental { color: red }')
+  write('packages/experimental/prototype/tiny.svg', '<svg xmlns="http://www.w3.org/2000/svg"/>')
+  const run = (extra: Record<string, unknown> = {}): Promise<unknown> => vite.build({
+    configFile: false,
+    root: web,
+    logLevel: 'silent',
+    plugins: productWebBundleIsolation(root, web),
+    build: {
+      write: false,
+      minify: false,
+      rollupOptions: {
+        input: { index: join(web, 'index.html'), bootstrap: join(web, 'src/preview.js') },
+      },
+    },
+    ...extra,
+  })
+  return { root, web, write, run, links }
+}
+
+describe('default Web bundle input isolation', () => {
+  it('requires explicit analysis intent before allowing externalized inputs in a non-writing build', async () => {
+    const test = fixture()
+    test.write('apps/web/src/main.js', 'import "external-lib"; globalThis.product = true')
+    const externalize = {
+      name: 'fixture-external-analysis',
+      resolveId(id: string) { return id === 'external-lib' ? { id, external: true } : null },
+    }
+    await expect(test.run({ plugins: [...productWebBundleIsolation(test.root, test.web), externalize] }))
+      .rejects.toThrow(/external module external-lib has no bundled input proof/)
+    await expect(test.run({ plugins: [
+      ...productWebBundleIsolation(test.root, test.web), browserDependencyAnalysis(), externalize,
+    ] })).resolves.toBeDefined()
+    expect(existsSync(join(test.web, 'dist'))).toBe(false)
+  })
+
+  it('refuses output writing when dependency analysis bypasses the product check', async () => {
+    const test = fixture()
+    const output = join(test.root, 'analysis-output')
+    await expect(test.run({
+      plugins: [...productWebBundleIsolation(test.root, test.web), browserDependencyAnalysis()],
+      build: { write: true, outDir: output },
+    })).rejects.toThrow(/browser dependency analysis requires build.write: false/)
+    expect(existsSync(output)).toBe(false)
+  })
+
+  it('allows experimental code in the separately emitted preview entry', async () => {
+    await expect(fixture().run()).resolves.toBeDefined()
+  })
+
+  it.each([
+    ['static import', 'import "../../../packages/experimental/prototype/index.js"'],
+    ['dynamic import', 'void import("../../../packages/experimental/prototype/index.js")'],
+    ['raw query import', 'import text from "../../../packages/experimental/prototype/index.js?raw"; console.log(text)'],
+  ])('rejects a product %s of experimental input', async (_name, source) => {
+    const test = fixture()
+    test.write('apps/web/src/main.js', source)
+    await expect(test.run()).rejects.toThrow(/Web product isolation: experimental input/)
+  })
+
+  it('checks inline HTML modules after Vite resolves and bundles them', async () => {
+    const test = fixture()
+    test.write('apps/web/index.html', '<script type="module">import "../../packages/experimental/prototype/index.js"</script>')
+    await expect(test.run()).rejects.toThrow(/Web product isolation: experimental input/)
+  })
+
+  it('checks resolver aliases by the actual file owner', async () => {
+    const test = fixture()
+    test.write('apps/web/src/main.js', 'import "feature"')
+    await expect(test.run({ resolve: { alias: { feature: join(test.root, 'packages/experimental/prototype/index.js') } } }))
+      .rejects.toThrow(/Web product isolation: experimental input/)
+  })
+
+  it('checks npm alias package identity independently of its directory name', async () => {
+    const test = fixture()
+    test.write('apps/web/src/main.js', 'import "ordinary-name"')
+    test.write('apps/web/node_modules/ordinary-name/package.json',
+      '{"name":"@deepseek-ai/dsh-experimental-aliased","type":"module","main":"index.js"}')
+    test.write('apps/web/node_modules/ordinary-name/index.js', 'globalThis.aliased = true')
+    await expect(test.run()).rejects.toThrow(/belongs to experimental package/)
+  })
+
+  it('follows virtual module edges to their resolved inputs', async () => {
+    const test = fixture()
+    test.write('apps/web/src/main.js', 'import "virtual:feature"')
+    await expect(test.run({ plugins: [...productWebBundleIsolation(test.root, test.web), {
+      name: 'fixture-virtual-module',
+      resolveId(id: string) { return id === 'virtual:feature' ? '\u0000virtual:feature' : null },
+      load(id: string) {
+        return id === '\u0000virtual:feature'
+          ? `import ${JSON.stringify(join(test.root, 'packages/experimental/prototype/index.js'))}` : null
+      },
+    }] })).rejects.toThrow(/Web product isolation: experimental input/)
+  })
+
+  it('rejects experimental inputs merged into a product-reachable shared chunk', async () => {
+    const test = fixture()
+    test.write('apps/web/src/main.js', 'import "./shared.js"; globalThis.product = true')
+    test.write('apps/web/src/shared.js', 'globalThis.shared = true')
+    await expect(test.run({ build: {
+      write: false,
+      minify: false,
+      rollupOptions: {
+        input: { index: join(test.web, 'index.html'), bootstrap: join(test.web, 'src/preview.js') },
+        output: { manualChunks: (id: string) => id.endsWith('/shared.js') || id.includes('/packages/experimental/')
+          ? 'shared' : undefined },
+      },
+    } })).rejects.toThrow(/Web product isolation: experimental input/)
+  })
+
+  it('checks symlinked package ownership', async () => {
+    const test = fixture()
+    test.write('apps/web/src/main.js', 'import "ordinary-name/index.js"')
+    const link = join(test.web, 'node_modules/ordinary-name')
+    mkdirSync(dirname(link), { recursive: true })
+    symlinkSync(join(test.root, 'packages/experimental/prototype'), link, process.platform === 'win32' ? 'junction' : 'dir')
+    test.links.push(link)
+    await expect(test.run({ resolve: { preserveSymlinks: true } }))
+      .rejects.toThrow(/Web product isolation: experimental input/)
+  })
+
+  it.each([
+    ['stylesheet import', '@import "../../../packages/experimental/prototype/style.css";'],
+    ['inlined image', '.product { background: url("../../../packages/experimental/prototype/tiny.svg") }'],
+    ['emitted image', '.product { background: url("../../../packages/experimental/prototype/tiny.svg?no-inline") }'],
+  ])('checks the actual CSS transform inputs for %s', async (_name, css) => {
+    const test = fixture()
+    test.write('apps/web/src/main.js', 'import "./style.css"; globalThis.product = true')
+    test.write('apps/web/src/style.css', css)
+    await expect(test.run()).rejects.toThrow(/Web product isolation: experimental input/)
+  })
+
+  it('allows experimental CSS only used by preview', async () => {
+    const test = fixture()
+    test.write('apps/web/src/preview.js', 'import "./preview.css"; globalThis.preview = true')
+    test.write('apps/web/src/preview.css', '@import "../../../packages/experimental/prototype/style.css";')
+    await expect(test.run()).resolves.toBeDefined()
+  })
+
+  it.each([
+    ['URL worker', 'new Worker(new URL("./worker.js", import.meta.url), { type: "module" })'],
+    ['inline worker', 'import Worker from "./worker.js?worker&inline"; new Worker()'],
+  ])('checks %s subbuild inputs when the product loads it', async (_name, source) => {
+    const test = fixture()
+    test.write('apps/web/src/main.js', source)
+    test.write('apps/web/src/worker.js', 'import "../../../packages/experimental/prototype/index.js"')
+    await expect(test.run()).rejects.toThrow(/Web product isolation: experimental input/)
+  })
+
+  it('allows an experimental worker loaded only by preview', async () => {
+    const test = fixture()
+    test.write('apps/web/src/preview.js', 'new Worker(new URL("./worker.js", import.meta.url), { type: "module" })')
+    test.write('apps/web/src/worker.js', 'import "../../../packages/experimental/prototype/index.js"')
+    await expect(test.run()).resolves.toBeDefined()
+  })
+
+  it('rejects an emitted entry without its original HTML input', () => {
+    const test = fixture()
+    const inputs = new WebProductBundleIsolation(test.root, test.web)
+    expect(() => { inputs.verify({}, () => null) }).toThrow(/index.html is missing its original HTML input/)
+  })
+
+  it('rejects a worker asset whose subbuild was not observed', () => {
+    const test = fixture()
+    const inputs = new WebProductBundleIsolation(test.root, test.web)
+    inputs.assetReference('worker.js', 'index.html', 'asset')
+    expect(() => { inputs.verify({
+      'index.html': { type: 'asset', fileName: 'index.html', names: ['index.html'], originalFileNames: ['index.html'] },
+      'worker.js': { type: 'asset', fileName: 'worker.js', names: [], originalFileNames: [] },
+    }, () => null) }).toThrow(/worker.js has no recorded original files/)
+  })
+})
+
+describe('bundler input ownership', () => {
+  it('refuses source-map paths whose filesystem root is unavailable', () => {
+    const test = fixture()
+    const ownership = new BundleInputIsolation(test.root, 'fixture')
+    filesystem.missingRoot = true
+    try {
+      expect(() => { ownership.assertSourceMapInput(join(test.web, 'missing/source.js')) })
+        .toThrow(/has no existing filesystem root/)
+    } finally {
+      filesystem.missingRoot = false
+    }
+  })
+
+  it('retains ownership through queries, filesystem URLs, and virtual path wrappers', () => {
+    const test = fixture()
+    const ownership = new BundleInputIsolation(test.root, 'fixture')
+    const file = join(test.root, 'packages/experimental/prototype/index.js')
+    for (const id of [file, `${file}?raw`, `${pathToFileURL(file).href}?import`, `\u0000${file}?commonjs-proxy`,
+      `virtual:${file}`, `/@fs/${file.replaceAll('\\', '/')}`, file.replaceAll('/', '\\')]) {
+      expect(() => { ownership.assertInput(id) }, id).toThrow(/experimental input/)
+    }
+  })
+
+  it('distinguishes missing actual inputs from omitted upstream source-map files', () => {
+    const test = fixture()
+    const ownership = new BundleInputIsolation(test.root, 'fixture')
+    const file = join(test.web, 'node_modules/upstream/src/omitted.js')
+    test.write('apps/web/node_modules/upstream/package.json', '{"name":"upstream"}')
+    expect(() => { ownership.assertInput(file) }).toThrow(/is missing/)
+    expect(() => { ownership.assertSourceMapInput(file) }).not.toThrow()
+    test.write('apps/web/node_modules/upstream/package.json', '{"name":"@deepseek-ai/dsh-experimental-upstream"}')
+    ownership.reset()
+    expect(() => { ownership.assertSourceMapInput(file) }).toThrow(/belongs to experimental package/)
+  })
+})

+ 199 - 0
scripts/web-product-bundle-isolation.ts

@@ -0,0 +1,199 @@
+/** Experimental-package isolation over Vite's emitted product graph and recorded file inputs. */
+
+import { basename, resolve } from 'node:path'
+import { BundleInputIsolation, physicalBundleInput } from './bundle-input-isolation.ts'
+
+/** The emitted chunk fields used to follow the product's actual output graph. */
+export interface WebOutputChunk {
+  type: 'chunk'
+  fileName: string
+  preliminaryFileName?: string
+  facadeModuleId: string | null
+  modules: Record<string, unknown>
+  imports: string[]
+  dynamicImports: string[]
+  implicitlyLoadedBefore: string[]
+  referencedFiles: string[]
+  viteMetadata?: { importedCss: Set<string>; importedAssets: Set<string> }
+}
+
+/** Original file names provide ownership for emitted, non-CSS assets. */
+export interface WebOutputAsset {
+  type: 'asset'
+  fileName: string
+  names: string[]
+  originalFileNames: string[]
+}
+
+/** Vite's output bundle, restricted to the fields needed by this check. */
+export type WebOutputBundle = Record<string, WebOutputChunk | WebOutputAsset>
+
+/** Retained source edges, including CSS modules whose pure output chunks Vite removes. */
+export interface WebModuleInfo {
+  importedIds: readonly string[]
+  dynamicallyImportedIds: readonly string[]
+  isExternal: boolean
+  isIncluded: boolean | null
+}
+
+interface AssetReference {
+  host: string
+  file: string
+  type: 'asset' | 'public'
+}
+
+/** Output graphs are rebuilt each time; cached transforms retain their file-input observations. */
+export class WebProductBundleIsolation {
+  private readonly cssInputs = new Map<string, Set<string>>()
+  private readonly chunks = new Map<string, WebOutputChunk>()
+  private readonly references: AssetReference[] = []
+  private readonly workerInputs = new Map<string, Set<string>>()
+  private readonly inputs: BundleInputIsolation
+  private readonly webRoot: string
+
+  constructor(repository: string, webRoot: string) {
+    this.webRoot = webRoot
+    this.inputs = new BundleInputIsolation(repository, 'Web product isolation')
+  }
+
+  /** Discard output graphs and package metadata before a build or watch rebuild. */
+  reset(): void {
+    this.chunks.clear()
+    this.references.length = 0
+    this.workerInputs.clear()
+    this.inputs.reset()
+  }
+
+  /** Replace dependency observations whenever Vite transforms a module again. */
+  cssTransform(module: string): void { this.cssInputs.set(module, new Set<string>()) }
+
+  /** Record the files the actual CSS transform asks Rollup to watch. */
+  cssDependency(module: string, file: string): void {
+    const inputs = this.cssInputs.get(module) ?? new Set<string>()
+    inputs.add(file)
+    this.cssInputs.set(module, inputs)
+  }
+
+  /** Record Vite's asset URL edges without changing their rendered values. */
+  assetReference(file: string, host: string, type: 'asset' | 'public'): void {
+    this.references.push({ file, host, type })
+  }
+
+  /** Preserve pure CSS chunk inputs before Vite removes their JavaScript wrappers. */
+  captureChunks(bundle: WebOutputBundle): void {
+    for (const item of Object.values(bundle)) {
+      if (item.type !== 'chunk') continue
+      this.chunks.set(item.fileName, {
+        ...item,
+        modules: { ...item.modules },
+        imports: [...item.imports],
+        dynamicImports: [...item.dynamicImports],
+        implicitlyLoadedBefore: [...item.implicitlyLoadedBefore],
+        referencedFiles: [...item.referencedFiles],
+        ...(item.viteMetadata === undefined ? {} : { viteMetadata: {
+          importedCss: new Set(item.viteMetadata.importedCss),
+          importedAssets: new Set(item.viteMetadata.importedAssets),
+        } }),
+      })
+    }
+  }
+
+  /** Each Vite worker subbuild has one entry and owns its complete watched input set. */
+  workerBundle(bundle: WebOutputBundle, watchedFiles: readonly string[]): void {
+    const inputs = new Set(watchedFiles)
+    const entries: string[] = []
+    for (const item of Object.values(bundle)) {
+      if (item.type === 'chunk') {
+        for (const id of Object.keys(item.modules)) inputs.add(id)
+        if (item.facadeModuleId !== null) entries.push(physicalBundleInput(item.facadeModuleId) ?? item.facadeModuleId)
+      }
+    }
+    for (const file of [...Object.keys(bundle), ...entries]) {
+      this.workerInputs.set(file, new Set([...this.workerInputs.get(file) ?? [], ...inputs]))
+    }
+  }
+
+  /** Reject experimental ownership in everything the emitted index page can load. */
+  verify(bundle: WebOutputBundle, moduleInfo: (id: string) => WebModuleInfo | null): void {
+    const html = bundle['index.html']
+    if (html?.type !== 'asset' || !html.originalFileNames.some(file =>
+      resolve(this.webRoot, file) === resolve(this.webRoot, 'index.html'))) {
+      throw new Error('Web product isolation: emitted index.html is missing its original HTML input')
+    }
+    const outputs = new Map(Object.entries(bundle))
+    const aliases = new Map<string, string>()
+    const cssOwners = new Map<string, Set<string>>()
+    for (const [file, chunk] of this.chunks) {
+      aliases.set(chunk.preliminaryFileName ?? file, file)
+      for (const css of chunk.viteMetadata?.importedCss ?? []) {
+        const owners = cssOwners.get(css) ?? new Set<string>()
+        for (const id of Object.keys(chunk.modules)) owners.add(id)
+        cssOwners.set(css, owners)
+      }
+    }
+    for (const item of Object.values(bundle)) {
+      if (item.type === 'asset') {
+        for (const name of item.names) aliases.set(name, item.fileName)
+      }
+    }
+    const outputName = (name: string): string => outputs.has(name) ? name
+      : aliases.get(name) ?? aliases.get(basename(name)) ?? name
+    const references = new Map<string, AssetReference[]>()
+    for (const reference of this.references) {
+      const host = outputName(reference.host)
+      references.set(host, [...references.get(host) ?? [], reference])
+    }
+    const visitedOutputs = new Set<string>()
+    const visitedModules = new Set<string>()
+    const queue = ['index.html']
+    const checkWorker = (key: string): void => {
+      const inputs = this.workerInputs.get(key)
+      if (inputs === undefined) throw new Error(`Web product isolation: worker ${key} has no recorded build inputs`)
+      for (const input of inputs) this.inputs.assertInput(input)
+    }
+    const checkModule = (id: string): void => {
+      if (visitedModules.has(id)) return
+      visitedModules.add(id)
+      this.inputs.assertInput(id)
+      const info = moduleInfo(id)
+      if (info === null) throw new Error(`Web product isolation: module ${id} has no Rollup module record`)
+      if (info.isExternal) throw new Error(`Web product isolation: external module ${id} has no bundled input proof`)
+      if (/\.(?:css|less|sass|scss|styl|stylus|pcss|postcss|sss)(?:$|\?)/i.test(id) && !this.cssInputs.has(id)) {
+        throw new Error(`Web product isolation: stylesheet ${id} has no recorded transform inputs`)
+      }
+      if (/[?&](?:sharedworker|worker)(?:&|$)/.test(id)) checkWorker(physicalBundleInput(id) ?? id)
+      for (const file of this.cssInputs.get(id) ?? []) this.inputs.assertInput(file)
+      for (const child of [...info.importedIds, ...info.dynamicallyImportedIds]) {
+        const childInfo = moduleInfo(child)
+        if (childInfo === null) throw new Error(`Web product isolation: module ${child} has no Rollup module record`)
+        if (childInfo.isIncluded || childInfo.isExternal) checkModule(child)
+      }
+    }
+    for (const name of queue) {
+      const file = outputName(name)
+      if (visitedOutputs.has(file)) continue
+      visitedOutputs.add(file)
+      const item = outputs.get(file)
+      if (item === undefined) throw new Error(`Web product isolation: referenced output ${file} is missing`)
+      if (this.workerInputs.has(file)) checkWorker(file)
+      else if (item.type === 'chunk') {
+        for (const id of Object.keys(item.modules)) checkModule(id)
+        queue.push(...item.imports, ...item.dynamicImports, ...item.implicitlyLoadedBefore, ...item.referencedFiles)
+        queue.push(...item.viteMetadata?.importedCss ?? [], ...item.viteMetadata?.importedAssets ?? [])
+      } else if (cssOwners.has(file)) {
+        for (const id of cssOwners.get(file) ?? []) checkModule(id)
+      } else {
+        if (item.originalFileNames.length === 0) {
+          throw new Error(`Web product isolation: asset ${file} has no recorded original files`)
+        }
+        for (const original of item.originalFileNames) this.inputs.assertInput(resolve(this.webRoot, original))
+      }
+      for (const reference of references.get(file) ?? []) {
+        if (reference.type === 'public') this.inputs.assertInput(resolve(this.webRoot, 'public', reference.file))
+        else queue.push(reference.file)
+      }
+    }
+    if (visitedModules.size === 0) throw new Error('Web product isolation: index.html has no recorded reachable modules')
+  }
+
+}

+ 1 - 0
tsconfig.client.json

@@ -30,6 +30,7 @@
     "packages/extensions/ui-cordis/src/css-modules.d.ts",
     "packages/client/tsdown.client.ts",
     "scripts/client-build-environment.ts",
+    "scripts/bundle-input-isolation.ts",
     "scripts/*.client.spec.ts",
     "scripts/client-bundle-css.spec.ts",
     "scripts/client-bundle-purity.spec.ts"

+ 2 - 0
tsconfig.host.json

@@ -8,6 +8,8 @@
     "rewriteRelativeImportExtensions": false
   },
   "include": [
+    "apps/web/product-isolation.ts",
+    "apps/web/tests/default-product-isolation.e2e.ts",
     "apps/web/tests/scaffold.ts",
     "apps/web/tests/auto-review-fixture.ts",
     "apps/web/tests/auto-review-denial.e2e.ts",