Explorar el Código

fix(client): keep browser dependencies out of production installs

imccyu hace 2 días
padre
commit
f39a37a523
Se han modificado 33 ficheros con 1147 adiciones y 297 borrados
  1. 4 4
      THIRD_PARTY_NOTICES.md
  2. 6 0
      apps/web/vite.config.ts
  3. 1 1
      lefthook.yml
  4. 3 5
      packages/client/store/package.json
  5. 2 4
      packages/client/ui-attachment/package.json
  6. 2 4
      packages/client/ui-commands/package.json
  7. 8 8
      packages/client/ui-conversation/package.json
  8. 2 4
      packages/client/ui-directory-picker-browse/package.json
  9. 3 6
      packages/client/ui-dockkit/package.json
  10. 2 4
      packages/client/ui-input-trigger/package.json
  11. 3 5
      packages/client/ui-model-selection/package.json
  12. 17 19
      packages/client/ui-primitives/package.json
  13. 2 4
      packages/client/ui-renderer/package.json
  14. 3 3
      packages/client/ui-settings-general/package.json
  15. 3 5
      packages/client/ui-settings-plugins/package.json
  16. 4 6
      packages/client/ui-sidebar-files/package.json
  17. 5 7
      packages/client/ui-sidebar-right/package.json
  18. 4 6
      packages/client/ui-sidebar-textpreview/package.json
  19. 2 4
      packages/client/ui-sidebar/package.json
  20. 2 2
      packages/client/ui-theme/package.json
  21. 2 4
      packages/client/ui-tool/package.json
  22. 3 5
      packages/client/ui-trajectory/package.json
  23. 2 4
      packages/client/ui-user-questions/package.json
  24. 2 4
      packages/client/ui-workspace/package.json
  25. 101 119
      pnpm-lock.yaml
  26. 127 0
      scripts/browser-bundled-externals.spec.ts
  27. 178 0
      scripts/browser-bundled-externals.ts
  28. 37 2
      scripts/gen-third-party-notices.spec.ts
  29. 35 26
      scripts/gen-third-party-notices.ts
  30. 13 0
      scripts/verify-npm-install-layout.spec.ts
  31. 3 0
      scripts/verify-npm-install-layout.ts
  32. 420 3
      scripts/verify-package-dependencies.spec.ts
  33. 146 29
      scripts/verify-package-dependencies.ts

+ 4 - 4
THIRD_PARTY_NOTICES.md

@@ -27,7 +27,7 @@ The Cordis framework and its foundation libraries are source-vendored into this
 
 ## Runtime npm dependencies
 
-External packages that a workspace package resolves at runtime. The tier covers every plugin a user can mount from `cordis.yml` — not only what the `dsh` CLI, Web UI, and Python SDK runtime load by default.
+External packages installed for runtime use or distributed inside the prebuilt browser artifacts. Browser inputs are resolved through the shipping tsdown and Vite configurations, independently of npm dependency sections. The tier covers every plugin a user can mount from `cordis.yml` — not only what the `dsh` CLI, Web UI, and Python SDK runtime load by default.
 
 | Package | License |
 | --- | --- |
@@ -55,7 +55,6 @@ External packages that a workspace package resolves at runtime. The tier covers
 | [`@shikijs/langs`](https://github.com/shikijs/shiki) | MIT |
 | [`@standard-schema/spec`](https://github.com/standard-schema/standard-schema) | MIT |
 | [`@tanstack/react-virtual`](https://github.com/TanStack/virtual) | MIT |
-| [`@types/mdast`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT |
 | [`@vscode/ripgrep`](https://github.com/microsoft/vscode-ripgrep) | MIT |
 | [`@xterm/headless`](https://github.com/xtermjs/xterm.js) | MIT |
 | [`@yarnpkg/parsers`](https://github.com/yarnpkg/berry) | BSD-2-Clause |
@@ -88,7 +87,6 @@ External packages that a workspace package resolves at runtime. The tier covers
 | [`micromark-util-classify-character`](https://github.com/micromark/micromark/tree/main/packages/micromark-util-classify-character) | MIT |
 | [`micromark-util-sanitize-uri`](https://github.com/micromark/micromark/tree/main/packages/micromark-util-sanitize-uri) | MIT |
 | [`micromark-util-symbol`](https://github.com/micromark/micromark/tree/main/packages/micromark-util-symbol) | MIT |
-| [`micromark-util-types`](https://github.com/micromark/micromark/tree/main/packages/micromark-util-types) | MIT |
 | [`mime-types`](https://github.com/jshttp/mime-types) | MIT |
 | [`negotiator`](https://github.com/jshttp/negotiator) | MIT |
 | [`node-addon-require-builtin`](https://www.npmjs.com/package/node-addon-require-builtin) | MIT |
@@ -138,7 +136,7 @@ The installed SDK 0.3.263 declares the following optional platform packages. Eac
 
 ## Development-only npm dependencies
 
-External packages **directly declared** only by repository tooling, test infrastructure, the documentation site, the demo leaves, or the native launcher's build workspace. No shipped surface names them itself. A package here may still be pulled in transitively by a runtime dependency — `pnpm-lock.yaml` is the authority on the full closure — so this tier records who declares a package, not what a build ultimately bundles.
+External packages **directly declared** for development, tests, types, or tooling, without a runtime installation or browser-build relationship. A package here may still be pulled in transitively by a runtime dependency — `pnpm-lock.yaml` is the authority on that full closure.
 
 | Package | License |
 | --- | --- |
@@ -155,6 +153,7 @@ External packages **directly declared** only by repository tooling, test infrast
 | [`@types/compression`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT |
 | [`@types/js-yaml`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT |
 | [`@types/jsdom`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT |
+| [`@types/mdast`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT |
 | [`@types/mime-types`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT |
 | [`@types/negotiator`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT |
 | [`@types/node`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT |
@@ -190,6 +189,7 @@ External packages **directly declared** only by repository tooling, test infrast
 | [`lefthook`](https://github.com/evilmartians/lefthook) | MIT |
 | [`lightningcss`](https://github.com/parcel-bundler/lightningcss) | MPL-2.0 |
 | [`mermaid`](https://github.com/mermaid-js/mermaid) | MIT |
+| [`micromark-util-types`](https://github.com/micromark/micromark/tree/main/packages/micromark-util-types) | MIT |
 | [`msgpackr`](http://github.com/kriszyp/msgpackr) | MIT |
 | [`oxlint`](https://github.com/oxc-project/oxc) | MIT |
 | [`oxlint-tsgolint`](https://github.com/oxc-project/tsgolint) | MIT |

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

@@ -46,15 +46,21 @@ function rejectStandaloneServe(): Plugin {
  */
 function emitPreviewPage(): Plugin {
   let bootstrapFile: string | undefined
+  let write = true
   return {
     name: 'dsh-emit-preview-page',
+    configResolved(config) {
+      write = config.build.write
+    },
     generateBundle(_options, bundle) {
+      if (!write) return
       for (const item of Object.values(bundle)) {
         if (item.type === 'chunk' && item.isEntry && item.name === 'bootstrap') bootstrapFile = item.fileName
       }
       if (bootstrapFile === undefined) throw new Error('vite: preview bootstrap entry missing from the bundle')
     },
     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')

+ 1 - 1
lefthook.yml

@@ -28,7 +28,7 @@ pre-commit:
     # lefthook only inspects files present on disk — so that one case still
     # falls through to the freshness assertion in the test lane.
     - name: third-party notices (staged)
-      glob: '{package.json,*/package.json,*/*/package.json,*/*/*/package.json,*/*/*/*/package.json,pnpm-workspace.yaml,*/*/pnpm-workspace.yaml,pnpm-lock.yaml,vendor/README.md,python/*/pyproject.toml,scripts/gen-third-party-notices.ts,scripts/build-exe-for-python-sdk.ts}'
+      glob: '{package.json,*/package.json,*/*/package.json,*/*/*/package.json,*/*/*/*/package.json,pnpm-workspace.yaml,*/*/pnpm-workspace.yaml,pnpm-lock.yaml,vendor/README.md,python/*/pyproject.toml,scripts/gen-third-party-notices.ts,scripts/browser-bundled-externals.ts,scripts/build-exe-for-python-sdk.ts,tsconfig.base*.json,packages/*/*/src/**/*,packages/*/*/tsdown.config.ts,packages/client/tsdown.client.ts,apps/*/src/**/*,apps/*/vite.config.ts}'
       run: node_modules/.bin/tsx scripts/gen-third-party-notices.ts && git add THIRD_PARTY_NOTICES.md
 
     - name: whitespace (staged)

+ 3 - 5
packages/client/store/package.json

@@ -22,15 +22,13 @@
     "./package.json": "./package.json"
   },
   "license": "MIT",
-  "dependencies": {
-    "immer": "^10.1.1",
-    "zustand": "~4.4.7"
-  },
   "peerDependencies": {
     "@deepseek-ai/cordis": "workspace:^"
   },
   "devDependencies": {
-    "@deepseek-ai/cordis": "workspace:^"
+    "@deepseek-ai/cordis": "workspace:^",
+    "zustand": "~4.4.7",
+    "immer": "^10.1.1"
   },
   "files": [
     "lib/index.js",

+ 2 - 4
packages/client/ui-attachment/package.json

@@ -42,9 +42,6 @@
     "watch": "tsdown --watch"
   },
   "license": "MIT",
-  "dependencies": {
-    "clsx": "^2.0.0"
-  },
   "devDependencies": {
     "@deepseek-ai/cordis": "workspace:^",
     "@types/react": "~18.3.1",
@@ -58,7 +55,8 @@
     "react": "^18.2.0",
     "react-dom": "^18.2.0",
     "@deepseek-ai/dsh-attachment": "workspace:^",
-    "@deepseek-ai/dsh-client-ui-tool": "workspace:^"
+    "@deepseek-ai/dsh-client-ui-tool": "workspace:^",
+    "clsx": "^2.0.0"
   },
   "files": [
     "lib/index.js",

+ 2 - 4
packages/client/ui-commands/package.json

@@ -41,9 +41,6 @@
     "watch": "tsdown --watch"
   },
   "license": "MIT",
-  "dependencies": {
-    "clsx": "^2.0.0"
-  },
   "peerDependencies": {
     "@deepseek-ai/cordis": "workspace:^"
   },
@@ -64,7 +61,8 @@
     "@deepseek-ai/dsh-client-store": "workspace:^",
     "@deepseek-ai/dsh-session": "workspace:^",
     "@deepseek-ai/dsh-client-ui-renderer": "workspace:^",
-    "@deepseek-ai/dsh-client-ui-session": "workspace:^"
+    "@deepseek-ai/dsh-client-ui-session": "workspace:^",
+    "clsx": "^2.0.0"
   },
   "files": [
     "lib/index.js",

+ 8 - 8
packages/client/ui-conversation/package.json

@@ -46,13 +46,7 @@
   },
   "license": "MIT",
   "dependencies": {
-    "clsx": "^2.0.0",
-    "@deepseek-ai/schemastery": "workspace:^",
-    "@lexical/history": "^0.49.0",
-    "@lexical/plain-text": "^0.49.0",
-    "@lexical/text": "^0.49.0",
-    "@lexical/utils": "^0.49.0",
-    "lexical": "^0.49.0"
+    "@deepseek-ai/schemastery": "workspace:^"
   },
   "peerDependencies": {
     "@deepseek-ai/cordis": "workspace:^"
@@ -93,7 +87,13 @@
     "react-dom": "^18.2.0",
     "@types/react-dom": "~18.3.0",
     "zod": "^4.4.3",
-    "@deepseek-ai/dsh-settings": "workspace:^"
+    "@deepseek-ai/dsh-settings": "workspace:^",
+    "lexical": "^0.49.0",
+    "clsx": "^2.0.0",
+    "@lexical/utils": "^0.49.0",
+    "@lexical/text": "^0.49.0",
+    "@lexical/plain-text": "^0.49.0",
+    "@lexical/history": "^0.49.0"
   },
   "files": [
     "lib/index.js",

+ 2 - 4
packages/client/ui-directory-picker-browse/package.json

@@ -41,9 +41,6 @@
     "watch": "tsdown --watch"
   },
   "license": "MIT",
-  "dependencies": {
-    "clsx": "^2.0.0"
-  },
   "peerDependencies": {
     "@deepseek-ai/cordis": "workspace:^"
   },
@@ -59,7 +56,8 @@
     "@types/react": "~18.3.1",
     "@deepseek-ai/cordis": "workspace:^",
     "react": "^18.2.0",
-    "react-dom": "^18.2.0"
+    "react-dom": "^18.2.0",
+    "clsx": "^2.0.0"
   },
   "files": [
     "lib/index.js",

+ 3 - 6
packages/client/ui-dockkit/package.json

@@ -22,18 +22,15 @@
     "./package.json": "./package.json"
   },
   "license": "MIT",
-  "dependencies": {
-    "clsx": "^2.0.0",
-    "react": "^18.2.0",
-    "react-dom": "^18.2.0"
-  },
   "devDependencies": {
     "@deepseek-ai/cordis": "workspace:^",
     "@deepseek-ai/dsh-brand": "workspace:^",
     "@testing-library/react": "^16.1.0",
     "@types/react": "~18.3.1",
     "@types/react-dom": "~18.3.0",
-    "react-dom": "^18.2.0"
+    "react-dom": "^18.2.0",
+    "react": "^18.2.0",
+    "clsx": "^2.0.0"
   },
   "files": [
     "lib/index.js",

+ 2 - 4
packages/client/ui-input-trigger/package.json

@@ -41,9 +41,6 @@
     "watch": "tsdown --watch"
   },
   "license": "MIT",
-  "dependencies": {
-    "clsx": "^2.0.0"
-  },
   "peerDependencies": {
     "@deepseek-ai/cordis": "workspace:^"
   },
@@ -61,7 +58,8 @@
     "@deepseek-ai/dsh-api-session-controller": "workspace:^",
     "@deepseek-ai/dsh-client-store": "workspace:^",
     "@deepseek-ai/dsh-session": "workspace:^",
-    "@deepseek-ai/dsh-client-ui-session": "workspace:^"
+    "@deepseek-ai/dsh-client-ui-session": "workspace:^",
+    "clsx": "^2.0.0"
   },
   "files": [
     "lib/index.js",

+ 3 - 5
packages/client/ui-model-selection/package.json

@@ -63,14 +63,12 @@
     "@deepseek-ai/dsh-client-store": "workspace:^",
     "@deepseek-ai/dsh-session": "workspace:^",
     "@deepseek-ai/dsh-client-ui-renderer": "workspace:^",
-    "@deepseek-ai/dsh-client-ui-session": "workspace:^"
+    "@deepseek-ai/dsh-client-ui-session": "workspace:^",
+    "clsx": "^2.1.1"
   },
   "files": [
     "lib/index.js",
     "lib/client.js",
     "lib/types/**/*.d.ts"
-  ],
-  "dependencies": {
-    "clsx": "^2.1.1"
-  }
+  ]
 }

+ 17 - 19
packages/client/ui-primitives/package.json

@@ -22,32 +22,30 @@
     "./package.json": "./package.json"
   },
   "license": "MIT",
-  "dependencies": {
-    "@shikijs/langs": "^4.3.1",
-    "@types/mdast": "^4.0.4",
-    "anser": "^2.3.5",
+  "devDependencies": {
+    "@types/react": "~18.3.1",
+    "@types/react-dom": "~18.3.0",
+    "@deepseek-ai/cordis": "workspace:^",
+    "react": "^18.2.0",
     "clsx": "^2.0.0",
+    "react-dom": "^18.2.0",
+    "anser": "^2.3.5",
     "katex": "^0.16.47",
-    "mdast-util-from-markdown": "^2.0.3",
-    "mdast-util-gfm": "^3.1.0",
-    "mdast-util-math": "^3.0.0",
     "micromark-core-commonmark": "^2.0.3",
-    "micromark-extension-gfm": "^3.0.0",
-    "micromark-extension-math": "^3.1.0",
-    "micromark-factory-space": "^2.0.1",
     "micromark-util-character": "^2.1.1",
     "micromark-util-classify-character": "^2.0.1",
-    "micromark-util-sanitize-uri": "^2.0.1",
     "micromark-util-symbol": "^2.0.1",
     "micromark-util-types": "^2.0.2",
-    "react": "^18.2.0",
-    "react-dom": "^18.2.0",
-    "shiki": "^4.3.1"
-  },
-  "devDependencies": {
-    "@types/react": "~18.3.1",
-    "@types/react-dom": "~18.3.0",
-    "@deepseek-ai/cordis": "workspace:^"
+    "shiki": "^4.3.1",
+    "@shikijs/langs": "^4.3.1",
+    "@types/mdast": "^4.0.4",
+    "micromark-factory-space": "^2.0.1",
+    "micromark-extension-math": "^3.1.0",
+    "mdast-util-from-markdown": "^2.0.3",
+    "mdast-util-gfm": "^3.1.0",
+    "mdast-util-math": "^3.0.0",
+    "micromark-extension-gfm": "^3.0.0",
+    "micromark-util-sanitize-uri": "^2.0.1"
   },
   "files": [
     "lib/index.js",

+ 2 - 4
packages/client/ui-renderer/package.json

@@ -40,9 +40,6 @@
     "watch": "tsdown --watch"
   },
   "license": "MIT",
-  "dependencies": {
-    "use-sync-external-store": "1.2.0"
-  },
   "peerDependencies": {
     "@deepseek-ai/cordis": "workspace:^"
   },
@@ -55,7 +52,8 @@
     "@types/react-dom": "~18.3.0",
     "@types/use-sync-external-store": "^1.5.0",
     "react": "^18.2.0",
-    "react-dom": "^18.2.0"
+    "react-dom": "^18.2.0",
+    "use-sync-external-store": "1.2.0"
   },
   "files": [
     "lib/index.js",

+ 3 - 3
packages/client/ui-settings-general/package.json

@@ -43,8 +43,7 @@
   },
   "license": "MIT",
   "dependencies": {
-    "@deepseek-ai/schemastery": "workspace:^",
-    "clsx": "^2.0.0"
+    "@deepseek-ai/schemastery": "workspace:^"
   },
   "peerDependencies": {
     "@deepseek-ai/cordis": "workspace:^"
@@ -64,7 +63,8 @@
     "react": "^18.2.0",
     "@deepseek-ai/dsh-client-ui-renderer": "workspace:^",
     "@deepseek-ai/dsh-client-ui-session": "workspace:^",
-    "@deepseek-ai/dsh-settings": "workspace:^"
+    "@deepseek-ai/dsh-settings": "workspace:^",
+    "clsx": "^2.0.0"
   },
   "files": [
     "lib/index.js",

+ 3 - 5
packages/client/ui-settings-plugins/package.json

@@ -54,14 +54,12 @@
     "@deepseek-ai/dsh-client-ui-slots": "workspace:^",
     "@types/react": "~18.3.1",
     "react": "^18.2.0",
-    "@deepseek-ai/dsh-client-ui-renderer": "workspace:^"
+    "@deepseek-ai/dsh-client-ui-renderer": "workspace:^",
+    "clsx": "^2.0.0"
   },
   "files": [
     "lib/index.js",
     "lib/client.js",
     "lib/types/**/*.d.ts"
-  ],
-  "dependencies": {
-    "clsx": "^2.0.0"
-  }
+  ]
 }

+ 4 - 6
packages/client/ui-sidebar-files/package.json

@@ -41,11 +41,6 @@
     "watch": "tsdown --watch"
   },
   "license": "MIT",
-  "dependencies": {
-    "clsx": "^2.0.0",
-    "react": "^18.2.0",
-    "react-dom": "^18.2.0"
-  },
   "peerDependencies": {
     "@deepseek-ai/cordis": "workspace:^"
   },
@@ -66,7 +61,10 @@
     "@deepseek-ai/dsh-util-workspace-path": "workspace:^",
     "@testing-library/react": "^16.1.0",
     "@types/react": "~18.3.1",
-    "@types/react-dom": "~18.3.0"
+    "@types/react-dom": "~18.3.0",
+    "react": "^18.2.0",
+    "clsx": "^2.0.0",
+    "react-dom": "^18.2.0"
   },
   "files": [
     "lib/index.js",

+ 5 - 7
packages/client/ui-sidebar-right/package.json

@@ -42,12 +42,6 @@
     "watch": "tsdown --watch"
   },
   "license": "MIT",
-  "dependencies": {
-    "clsx": "^2.0.0",
-    "picomatch": "^4.0.4",
-    "react": "^18.2.0",
-    "react-dom": "^18.2.0"
-  },
   "peerDependencies": {
     "@deepseek-ai/cordis": "workspace:^"
   },
@@ -69,7 +63,11 @@
     "@testing-library/react": "^16.1.0",
     "@types/picomatch": "^4.0.2",
     "@types/react": "~18.3.1",
-    "@types/react-dom": "~18.3.0"
+    "@types/react-dom": "~18.3.0",
+    "react": "^18.2.0",
+    "react-dom": "^18.2.0",
+    "picomatch": "^4.0.4",
+    "clsx": "^2.0.0"
   },
   "files": [
     "lib/index.js",

+ 4 - 6
packages/client/ui-sidebar-textpreview/package.json

@@ -41,11 +41,6 @@
     "watch": "tsdown --watch"
   },
   "license": "MIT",
-  "dependencies": {
-    "clsx": "^2.0.0",
-    "react": "^18.2.0",
-    "react-dom": "^18.2.0"
-  },
   "peerDependencies": {
     "@deepseek-ai/cordis": "workspace:^"
   },
@@ -67,7 +62,10 @@
     "@deepseek-ai/dsh-util-workspace-path": "workspace:^",
     "@testing-library/react": "^16.1.0",
     "@types/react": "~18.3.1",
-    "@types/react-dom": "~18.3.0"
+    "@types/react-dom": "~18.3.0",
+    "react": "^18.2.0",
+    "clsx": "^2.0.0",
+    "react-dom": "^18.2.0"
   },
   "files": [
     "lib/index.js",

+ 2 - 4
packages/client/ui-sidebar/package.json

@@ -43,9 +43,6 @@
     "watch": "tsdown --watch"
   },
   "license": "MIT",
-  "dependencies": {
-    "clsx": "^2.0.0"
-  },
   "peerDependencies": {
     "@deepseek-ai/cordis": "workspace:^"
   },
@@ -61,7 +58,8 @@
     "@deepseek-ai/dsh-client-ui-workspace": "workspace:^",
     "@types/react": "~18.3.1",
     "@deepseek-ai/cordis": "workspace:^",
-    "react": "^18.2.0"
+    "react": "^18.2.0",
+    "clsx": "^2.0.0"
   },
   "files": [
     "lib/index.js",

+ 2 - 2
packages/client/ui-theme/package.json

@@ -56,7 +56,8 @@
     "@deepseek-ai/dsh-host-webserver": "workspace:^",
     "@types/react": "~18.3.1",
     "react": "^18.2.0",
-    "@deepseek-ai/dsh-settings": "workspace:^"
+    "@deepseek-ai/dsh-settings": "workspace:^",
+    "clsx": "^2.0.0"
   },
   "files": [
     "lib/index.js",
@@ -69,7 +70,6 @@
     "watch": "tsdown --watch"
   },
   "dependencies": {
-    "clsx": "^2.0.0",
     "@deepseek-ai/schemastery": "workspace:^"
   }
 }

+ 2 - 4
packages/client/ui-tool/package.json

@@ -41,9 +41,6 @@
     "watch": "tsdown --watch"
   },
   "license": "MIT",
-  "dependencies": {
-    "clsx": "^2.0.0"
-  },
   "peerDependencies": {
     "@deepseek-ai/cordis": "workspace:^"
   },
@@ -73,7 +70,8 @@
     "@deepseek-ai/dsh-client-ui-renderer": "workspace:^",
     "@deepseek-ai/dsh-client-ui-session": "workspace:^",
     "@deepseek-ai/dsh-util-workspace-path": "workspace:^",
-    "@deepseek-ai/dsh-attachment": "workspace:^"
+    "@deepseek-ai/dsh-attachment": "workspace:^",
+    "clsx": "^2.0.0"
   },
   "files": [
     "lib/index.js",

+ 3 - 5
packages/client/ui-trajectory/package.json

@@ -42,10 +42,6 @@
     "watch": "tsdown --watch"
   },
   "license": "MIT",
-  "dependencies": {
-    "@tanstack/react-virtual": "^3.14.9",
-    "diff": "^9.0.0"
-  },
   "peerDependencies": {
     "@deepseek-ai/cordis": "workspace:^"
   },
@@ -70,7 +66,9 @@
     "@deepseek-ai/dsh-client-ui-renderer": "workspace:^",
     "@deepseek-ai/dsh-client-ui-session": "workspace:^",
     "@deepseek-ai/dsh-llm": "workspace:^",
-    "@deepseek-ai/dsh-attachment": "workspace:^"
+    "@deepseek-ai/dsh-attachment": "workspace:^",
+    "@tanstack/react-virtual": "^3.14.9",
+    "diff": "^9.0.0"
   },
   "files": [
     "lib/index.js",

+ 2 - 4
packages/client/ui-user-questions/package.json

@@ -43,9 +43,6 @@
     "watch": "tsdown --watch"
   },
   "license": "MIT",
-  "dependencies": {
-    "clsx": "^2.0.0"
-  },
   "peerDependencies": {
     "@deepseek-ai/cordis": "workspace:^"
   },
@@ -68,7 +65,8 @@
     "react": "^18.2.0",
     "@deepseek-ai/dsh-client-ui-conversation": "workspace:^",
     "@deepseek-ai/dsh-client-connection": "workspace:^",
-    "@deepseek-ai/dsh-session": "workspace:^"
+    "@deepseek-ai/dsh-session": "workspace:^",
+    "clsx": "^2.0.0"
   },
   "files": [
     "lib/index.js",

+ 2 - 4
packages/client/ui-workspace/package.json

@@ -46,9 +46,6 @@
     "watch": "tsdown --watch"
   },
   "license": "MIT",
-  "dependencies": {
-    "clsx": "^2.0.0"
-  },
   "peerDependencies": {
     "@deepseek-ai/cordis": "workspace:^"
   },
@@ -72,7 +69,8 @@
     "@deepseek-ai/dsh-util-workspace-path": "workspace:^",
     "@types/react": "~18.3.1",
     "@deepseek-ai/cordis": "workspace:^",
-    "react": "^18.2.0"
+    "react": "^18.2.0",
+    "clsx": "^2.0.0"
   },
   "files": [
     "lib/index.js",

+ 101 - 119
pnpm-lock.yaml

@@ -2192,17 +2192,16 @@ importers:
         version: link:../../typert/protocol
 
   packages/client/store:
-    dependencies:
+    devDependencies:
+      '@deepseek-ai/cordis':
+        specifier: workspace:^
+        version: link:../../../vendor/cordis
       immer:
         specifier: ^10.1.1
         version: 10.2.0
       zustand:
         specifier: ~4.4.7
         version: 4.4.7(@types/react@18.3.31)(immer@10.2.0)(react@18.3.1)
-    devDependencies:
-      '@deepseek-ai/cordis':
-        specifier: workspace:^
-        version: link:../../../vendor/cordis
 
   packages/client/ui-agent-preset:
     devDependencies:
@@ -2307,10 +2306,6 @@ importers:
         version: 18.3.1
 
   packages/client/ui-attachment:
-    dependencies:
-      clsx:
-        specifier: ^2.0.0
-        version: 2.1.1
     devDependencies:
       '@deepseek-ai/cordis':
         specifier: workspace:^
@@ -2345,6 +2340,9 @@ importers:
       '@types/react-dom':
         specifier: ~18.3.0
         version: 18.3.7(@types/react@18.3.31)
+      clsx:
+        specifier: ^2.0.0
+        version: 2.1.1
       react:
         specifier: ^18.2.0
         version: 18.3.1
@@ -2492,10 +2490,6 @@ importers:
         version: 18.3.1(react@18.3.1)
 
   packages/client/ui-commands:
-    dependencies:
-      clsx:
-        specifier: ^2.0.0
-        version: 2.1.1
     devDependencies:
       '@deepseek-ai/cordis':
         specifier: workspace:^
@@ -2545,6 +2539,9 @@ importers:
       '@types/react':
         specifier: ~18.3.1
         version: 18.3.31
+      clsx:
+        specifier: ^2.0.0
+        version: 2.1.1
       react:
         specifier: ^18.2.0
         version: 18.3.1
@@ -2554,24 +2551,6 @@ importers:
       '@deepseek-ai/schemastery':
         specifier: link:../../../vendor/schemastery
         version: link:../../../vendor/schemastery
-      '@lexical/history':
-        specifier: ^0.49.0
-        version: 0.49.0(typescript@6.0.3)
-      '@lexical/plain-text':
-        specifier: ^0.49.0
-        version: 0.49.0(typescript@6.0.3)
-      '@lexical/text':
-        specifier: ^0.49.0
-        version: 0.49.0(typescript@6.0.3)
-      '@lexical/utils':
-        specifier: ^0.49.0
-        version: 0.49.0(typescript@6.0.3)
-      clsx:
-        specifier: ^2.0.0
-        version: 2.1.1
-      lexical:
-        specifier: ^0.49.0
-        version: 0.49.0(typescript@6.0.3)
     devDependencies:
       '@deepseek-ai/cordis':
         specifier: workspace:^
@@ -2666,12 +2645,30 @@ importers:
       '@lexical/headless':
         specifier: ^0.49.0
         version: 0.49.0(typescript@6.0.3)
+      '@lexical/history':
+        specifier: ^0.49.0
+        version: 0.49.0(typescript@6.0.3)
+      '@lexical/plain-text':
+        specifier: ^0.49.0
+        version: 0.49.0(typescript@6.0.3)
+      '@lexical/text':
+        specifier: ^0.49.0
+        version: 0.49.0(typescript@6.0.3)
+      '@lexical/utils':
+        specifier: ^0.49.0
+        version: 0.49.0(typescript@6.0.3)
       '@types/react':
         specifier: ~18.3.1
         version: 18.3.31
       '@types/react-dom':
         specifier: ~18.3.0
         version: 18.3.7(@types/react@18.3.31)
+      clsx:
+        specifier: ^2.0.0
+        version: 2.1.1
+      lexical:
+        specifier: ^0.49.0
+        version: 0.49.0(typescript@6.0.3)
       react:
         specifier: ^18.2.0
         version: 18.3.1
@@ -2731,10 +2728,6 @@ importers:
         version: 18.3.1
 
   packages/client/ui-directory-picker-browse:
-    dependencies:
-      clsx:
-        specifier: ^2.0.0
-        version: 2.1.1
     devDependencies:
       '@deepseek-ai/cordis':
         specifier: workspace:^
@@ -2766,6 +2759,9 @@ importers:
       '@types/react':
         specifier: ~18.3.1
         version: 18.3.31
+      clsx:
+        specifier: ^2.0.0
+        version: 2.1.1
       react:
         specifier: ^18.2.0
         version: 18.3.1
@@ -2798,16 +2794,6 @@ importers:
         version: 18.3.1(react@18.3.1)
 
   packages/client/ui-dockkit:
-    dependencies:
-      clsx:
-        specifier: ^2.0.0
-        version: 2.1.1
-      react:
-        specifier: ^18.2.0
-        version: 18.3.1
-      react-dom:
-        specifier: ^18.2.0
-        version: 18.3.1(react@18.3.1)
     devDependencies:
       '@deepseek-ai/cordis':
         specifier: workspace:^
@@ -2824,6 +2810,15 @@ importers:
       '@types/react-dom':
         specifier: ~18.3.0
         version: 18.3.7(@types/react@18.3.31)
+      clsx:
+        specifier: ^2.0.0
+        version: 2.1.1
+      react:
+        specifier: ^18.2.0
+        version: 18.3.1
+      react-dom:
+        specifier: ^18.2.0
+        version: 18.3.1(react@18.3.1)
 
   packages/client/ui-goal:
     devDependencies:
@@ -2886,10 +2881,6 @@ importers:
         version: 18.3.1(react@18.3.1)
 
   packages/client/ui-input-trigger:
-    dependencies:
-      clsx:
-        specifier: ^2.0.0
-        version: 2.1.1
     devDependencies:
       '@deepseek-ai/cordis':
         specifier: workspace:^
@@ -2930,6 +2921,9 @@ importers:
       '@types/react':
         specifier: ~18.3.1
         version: 18.3.31
+      clsx:
+        specifier: ^2.0.0
+        version: 2.1.1
       react:
         specifier: ^18.2.0
         version: 18.3.1
@@ -3061,10 +3055,6 @@ importers:
         version: 18.3.1(react@18.3.1)
 
   packages/client/ui-model-selection:
-    dependencies:
-      clsx:
-        specifier: ^2.1.1
-        version: 2.1.1
     devDependencies:
       '@deepseek-ai/cordis':
         specifier: workspace:^
@@ -3117,6 +3107,9 @@ importers:
       '@types/react-dom':
         specifier: ~18.3.0
         version: 18.3.7(@types/react@18.3.31)
+      clsx:
+        specifier: ^2.1.1
+        version: 2.1.1
       react:
         specifier: ^18.2.0
         version: 18.3.1
@@ -3263,13 +3256,22 @@ importers:
         version: 18.3.1
 
   packages/client/ui-primitives:
-    dependencies:
+    devDependencies:
+      '@deepseek-ai/cordis':
+        specifier: workspace:^
+        version: link:../../../vendor/cordis
       '@shikijs/langs':
         specifier: ^4.3.1
         version: 4.3.1
       '@types/mdast':
         specifier: ^4.0.4
         version: 4.0.4
+      '@types/react':
+        specifier: ~18.3.1
+        version: 18.3.31
+      '@types/react-dom':
+        specifier: ~18.3.0
+        version: 18.3.7(@types/react@18.3.31)
       anser:
         specifier: ^2.3.5
         version: 2.3.5
@@ -3324,16 +3326,6 @@ importers:
       shiki:
         specifier: ^4.3.1
         version: 4.3.1
-    devDependencies:
-      '@deepseek-ai/cordis':
-        specifier: workspace:^
-        version: link:../../../vendor/cordis
-      '@types/react':
-        specifier: ~18.3.1
-        version: 18.3.31
-      '@types/react-dom':
-        specifier: ~18.3.0
-        version: 18.3.7(@types/react@18.3.31)
 
   packages/client/ui-reference:
     devDependencies:
@@ -3378,10 +3370,6 @@ importers:
         version: link:../../util/workspace-path
 
   packages/client/ui-renderer:
-    dependencies:
-      use-sync-external-store:
-        specifier: 1.2.0
-        version: 1.2.0(react@18.3.1)
     devDependencies:
       '@deepseek-ai/cordis':
         specifier: workspace:^
@@ -3410,6 +3398,9 @@ importers:
       react-dom:
         specifier: ^18.2.0
         version: 18.3.1(react@18.3.1)
+      use-sync-external-store:
+        specifier: 1.2.0
+        version: 1.2.0(react@18.3.1)
 
   packages/client/ui-schedule:
     devDependencies:
@@ -3527,9 +3518,6 @@ importers:
       '@deepseek-ai/schemastery':
         specifier: link:../../../vendor/schemastery
         version: link:../../../vendor/schemastery
-      clsx:
-        specifier: ^2.0.0
-        version: 2.1.1
     devDependencies:
       '@deepseek-ai/cordis':
         specifier: workspace:^
@@ -3573,6 +3561,9 @@ importers:
       '@types/react':
         specifier: ~18.3.1
         version: 18.3.31
+      clsx:
+        specifier: ^2.0.0
+        version: 2.1.1
       react:
         specifier: ^18.2.0
         version: 18.3.1
@@ -3662,10 +3653,6 @@ importers:
         version: 18.3.1(react@18.3.1)
 
   packages/client/ui-settings-plugins:
-    dependencies:
-      clsx:
-        specifier: ^2.0.0
-        version: 2.1.1
     devDependencies:
       '@deepseek-ai/cordis':
         specifier: workspace:^
@@ -3697,15 +3684,14 @@ importers:
       '@types/react':
         specifier: ~18.3.1
         version: 18.3.31
+      clsx:
+        specifier: ^2.0.0
+        version: 2.1.1
       react:
         specifier: ^18.2.0
         version: 18.3.1
 
   packages/client/ui-sidebar:
-    dependencies:
-      clsx:
-        specifier: ^2.0.0
-        version: 2.1.1
     devDependencies:
       '@deepseek-ai/cordis':
         specifier: workspace:^
@@ -3740,21 +3726,14 @@ importers:
       '@types/react':
         specifier: ~18.3.1
         version: 18.3.31
-      react:
-        specifier: ^18.2.0
-        version: 18.3.1
-
-  packages/client/ui-sidebar-files:
-    dependencies:
       clsx:
         specifier: ^2.0.0
         version: 2.1.1
       react:
         specifier: ^18.2.0
         version: 18.3.1
-      react-dom:
-        specifier: ^18.2.0
-        version: 18.3.1(react@18.3.1)
+
+  packages/client/ui-sidebar-files:
     devDependencies:
       '@deepseek-ai/cordis':
         specifier: workspace:^
@@ -3807,21 +3786,17 @@ importers:
       '@types/react-dom':
         specifier: ~18.3.0
         version: 18.3.7(@types/react@18.3.31)
-
-  packages/client/ui-sidebar-right:
-    dependencies:
       clsx:
         specifier: ^2.0.0
         version: 2.1.1
-      picomatch:
-        specifier: ^4.0.4
-        version: 4.0.4
       react:
         specifier: ^18.2.0
         version: 18.3.1
       react-dom:
         specifier: ^18.2.0
         version: 18.3.1(react@18.3.1)
+
+  packages/client/ui-sidebar-right:
     devDependencies:
       '@deepseek-ai/cordis':
         specifier: workspace:^
@@ -3877,18 +3852,20 @@ importers:
       '@types/react-dom':
         specifier: ~18.3.0
         version: 18.3.7(@types/react@18.3.31)
-
-  packages/client/ui-sidebar-textpreview:
-    dependencies:
       clsx:
         specifier: ^2.0.0
         version: 2.1.1
+      picomatch:
+        specifier: ^4.0.4
+        version: 4.0.4
       react:
         specifier: ^18.2.0
         version: 18.3.1
       react-dom:
         specifier: ^18.2.0
         version: 18.3.1(react@18.3.1)
+
+  packages/client/ui-sidebar-textpreview:
     devDependencies:
       '@deepseek-ai/cordis':
         specifier: workspace:^
@@ -3944,6 +3921,15 @@ importers:
       '@types/react-dom':
         specifier: ~18.3.0
         version: 18.3.7(@types/react@18.3.31)
+      clsx:
+        specifier: ^2.0.0
+        version: 2.1.1
+      react:
+        specifier: ^18.2.0
+        version: 18.3.1
+      react-dom:
+        specifier: ^18.2.0
+        version: 18.3.1(react@18.3.1)
 
   packages/client/ui-skill:
     devDependencies:
@@ -4070,9 +4056,6 @@ importers:
       '@deepseek-ai/schemastery':
         specifier: link:../../../vendor/schemastery
         version: link:../../../vendor/schemastery
-      clsx:
-        specifier: ^2.0.0
-        version: 2.1.1
     devDependencies:
       '@deepseek-ai/cordis':
         specifier: workspace:^
@@ -4113,15 +4096,14 @@ importers:
       '@types/react':
         specifier: ~18.3.1
         version: 18.3.31
+      clsx:
+        specifier: ^2.0.0
+        version: 2.1.1
       react:
         specifier: ^18.2.0
         version: 18.3.1
 
   packages/client/ui-tool:
-    dependencies:
-      clsx:
-        specifier: ^2.0.0
-        version: 2.1.1
     devDependencies:
       '@deepseek-ai/cordis':
         specifier: workspace:^
@@ -4195,6 +4177,9 @@ importers:
       '@types/react':
         specifier: ~18.3.1
         version: 18.3.31
+      clsx:
+        specifier: ^2.0.0
+        version: 2.1.1
       react:
         specifier: ^18.2.0
         version: 18.3.1
@@ -4203,13 +4188,6 @@ importers:
         version: 18.3.1(react@18.3.1)
 
   packages/client/ui-trajectory:
-    dependencies:
-      '@tanstack/react-virtual':
-        specifier: ^3.14.9
-        version: 3.14.9(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
-      diff:
-        specifier: ^9.0.0
-        version: 9.0.0
     devDependencies:
       '@deepseek-ai/cordis':
         specifier: workspace:^
@@ -4262,12 +4240,18 @@ importers:
       '@deepseek-ai/dsh-tools':
         specifier: workspace:^
         version: link:../../core/tools
+      '@tanstack/react-virtual':
+        specifier: ^3.14.9
+        version: 3.14.9(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
       '@types/react':
         specifier: ~18.3.1
         version: 18.3.31
       '@types/react-dom':
         specifier: ~18.3.0
         version: 18.3.7(@types/react@18.3.31)
+      diff:
+        specifier: ^9.0.0
+        version: 9.0.0
       react:
         specifier: ^18.2.0
         version: 18.3.1
@@ -4276,10 +4260,6 @@ importers:
         version: 18.3.1(react@18.3.1)
 
   packages/client/ui-user-questions:
-    dependencies:
-      clsx:
-        specifier: ^2.0.0
-        version: 2.1.1
     devDependencies:
       '@deepseek-ai/cordis':
         specifier: workspace:^
@@ -4335,6 +4315,9 @@ importers:
       '@types/react':
         specifier: ~18.3.1
         version: 18.3.31
+      clsx:
+        specifier: ^2.0.0
+        version: 2.1.1
       react:
         specifier: ^18.2.0
         version: 18.3.1
@@ -4391,10 +4374,6 @@ importers:
         version: 18.3.1
 
   packages/client/ui-workspace:
-    dependencies:
-      clsx:
-        specifier: ^2.0.0
-        version: 2.1.1
     devDependencies:
       '@deepseek-ai/cordis':
         specifier: workspace:^
@@ -4453,6 +4432,9 @@ importers:
       '@types/react':
         specifier: ~18.3.1
         version: 18.3.31
+      clsx:
+        specifier: ^2.0.0
+        version: 2.1.1
       react:
         specifier: ^18.2.0
         version: 18.3.1

+ 127 - 0
scripts/browser-bundled-externals.spec.ts

@@ -0,0 +1,127 @@
+import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'
+import { tmpdir } from 'node:os'
+import { dirname, join, resolve } from 'node:path'
+import { afterEach, describe, expect, it } from 'vitest'
+import { browserBundledExternals, browserPackageOfFile, browserSourceAliases } from './browser-bundled-externals.ts'
+
+const roots: string[] = []
+const repositoryRoot = resolve(import.meta.dirname, '..')
+
+afterEach(() => {
+  for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true })
+})
+
+function fixture(): string {
+  const root = mkdtempSync(join(tmpdir(), 'dsh-browser-notices-'))
+  roots.push(root)
+  write(root, 'package.json', '{"type":"module"}')
+  write(root, 'tsconfig.base.json', JSON.stringify({
+    compilerOptions: {
+      module: 'esnext', target: 'es2022', jsx: 'react-jsx',
+      paths: { '@fixture/static': ['./packages/client/static/src/index.ts'] },
+    },
+  }))
+  write(root, 'tsconfig.base.client.json', '{"extends":"./tsconfig.base.json"}')
+  return root
+}
+
+function write(root: string, path: string, text: string): void {
+  const target = join(root, path)
+  mkdirSync(dirname(target), { recursive: true })
+  writeFileSync(target, text)
+}
+
+function library(root: string, name: string, source = 'export const value = 1'): void {
+  write(root, `node_modules/${name}/package.json`, JSON.stringify({ name, type: 'module', exports: './index.js' }))
+  write(root, `node_modules/${name}/index.js`, source)
+}
+
+function dynamicPlugin(root: string, source: string): void {
+  write(root, 'packages/client/dynamic/package.json', JSON.stringify({
+    name: '@fixture/dynamic', dsh: { client: { platform: 'web' } },
+  }))
+  write(root, 'packages/client/dynamic/tsdown.config.ts', [
+    'export default {',
+    '  name: "@fixture/dynamic/client",',
+    '  entry: { client: "src/client/index.ts" },',
+    '  inputOptions: { resolve: { conditionNames: ["browser", "import", "default"] } },',
+    '}',
+  ].join('\n'))
+  write(root, 'packages/client/dynamic/src/client/index.ts', source)
+}
+
+describe('browser dependency discovery', () => {
+  it('records runtime imports through the client config without following upstream dependencies or erased types', async () => {
+    const root = fixture()
+    library(root, 'browser-lib', 'export { value } from "transitive-lib"')
+    library(root, 'transitive-lib')
+    dynamicPlugin(root, [
+      'import type { MissingType } from "type-only-lib"',
+      'import { value } from "browser-lib"',
+      'export const output: MissingType = value',
+    ].join('\n'))
+
+    expect(await browserBundledExternals(root)).toEqual(new Set(['browser-lib']))
+    expect(existsSync(join(root, 'packages/client/dynamic/lib'))).toBe(false)
+  })
+
+  it('rejects an unresolved runtime library', async () => {
+    const root = fixture()
+    dynamicPlugin(root, 'export { value } from "missing-browser-lib"')
+    await expect(browserBundledExternals(root)).rejects.toThrow('cannot resolve missing-browser-lib')
+  })
+
+  it('rejects a declared client without its browser config', async () => {
+    const root = fixture()
+    dynamicPlugin(root, 'export const value = 1')
+    write(root, 'packages/client/dynamic/tsdown.config.ts', 'export default { name: "@fixture/dynamic", entry: "src/index.ts" }')
+    await expect(browserBundledExternals(root)).rejects.toThrow('has no browser build config')
+  })
+
+  it('follows shell workspace aliases, CSS assets and lazy imports without writing output', async () => {
+    const root = fixture()
+    library(root, 'shell-lib')
+    library(root, 'lazy-lib')
+    library(root, 'asset-lib')
+    write(root, 'node_modules/asset-lib/package.json', JSON.stringify({
+      name: 'asset-lib', exports: { './theme.css': './theme.css' },
+    }))
+    write(root, 'node_modules/asset-lib/theme.css', '.fixture { color: red }')
+    write(root, 'packages/client/static/package.json', '{"name":"@fixture/static"}')
+    write(root, 'packages/client/static/src/index.ts', [
+      'import { value } from "shell-lib"',
+      'import "asset-lib/theme.css"',
+      'export const output = value',
+      'export const lazy = () => import("lazy-lib")',
+    ].join('\n'))
+    const app = join(root, 'apps/web')
+    write(root, 'apps/web/package.json', '{"name":"@fixture/web","type":"module","exports":{"./dist/*":"./dist/*"}}')
+    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/dist/sentinel.txt', 'untouched')
+
+    expect(await browserBundledExternals(root)).toEqual(new Set(['shell-lib', 'lazy-lib', 'asset-lib']))
+    expect(readFileSync(join(app, 'dist/sentinel.txt'), 'utf8')).toBe('untouched')
+    expect(existsSync(join(app, 'dist/index.html'))).toBe(false)
+    expect(existsSync(join(root, 'packages/client/static/lib'))).toBe(false)
+  })
+
+  it('normalizes installed module ids and excludes virtual/workspace modules', () => {
+    expect(browserPackageOfFile('/repo/node_modules/.pnpm/pkg@1/node_modules/pkg/a.js')).toBe('pkg')
+    expect(browserPackageOfFile('C:\\repo\\node_modules\\@scope\\pkg\\a.css?url')).toBe('@scope/pkg')
+    expect(browserPackageOfFile('/repo/packages/client/a.ts')).toBeUndefined()
+    expect(browserPackageOfFile('\0vite/modulepreload-polyfill')).toBeUndefined()
+  })
+
+  it('maps exact names and subpaths from the source facade', () => {
+    const root = fixture()
+    const aliases = browserSourceAliases(root)
+    expect('@fixture/static'.replace(aliases[0]!.find, aliases[0]!.replacement))
+      .toBe(join(root, 'packages/client/static/src/index.ts'))
+    expect(aliases[0]!.find.test('@fixture/static-extra')).toBe(false)
+  })
+})

+ 178 - 0
scripts/browser-bundled-externals.ts

@@ -0,0 +1,178 @@
+/** Resolve direct third-party browser inputs through the shipping build configurations, without emitting files. */
+
+import { globSync, readFileSync } from 'node:fs'
+import { createRequire } from 'node:module'
+import { dirname, resolve } from 'node:path'
+import { pathToFileURL } from 'node:url'
+import { Rolldown, type UserConfigExport } from 'tsdown'
+import ts from 'typescript'
+
+interface Manifest {
+  name: string
+  private?: boolean
+  dsh?: { client?: unknown }
+  exports?: Record<string, unknown>
+}
+
+interface ResolveContext {
+  resolve(source: string, importer: string, options: { skipSelf: boolean }): Promise<{ id: string } | null>
+}
+
+/**
+ * Name of the installed package owning a bundler-resolved file.
+ * @param file - Resolved module or asset id, including any loader query.
+ * @returns Package name, or undefined for workspace and virtual modules.
+ */
+export function browserPackageOfFile(file: string): string | undefined {
+  const normalized = file.replaceAll('\\', '/')
+  const marker = normalized.lastIndexOf('/node_modules/')
+  if (marker < 0) return undefined
+  const parts = normalized.slice(marker + '/node_modules/'.length).split('/')
+  return parts[0]?.startsWith('@') ? parts.slice(0, 2).join('/') : parts[0]
+}
+
+function recorder(seen: Set<string>, workspaceNames: ReadonlySet<string>, followWorkspace = false) {
+  return {
+    name: 'dsh-browser-direct-dependencies',
+    enforce: 'pre' as const,
+    resolveId: {
+      order: 'pre' as const,
+      async handler(this: ResolveContext, source: string, importer: string | undefined) {
+        if (importer === undefined || source.startsWith('.') || source.startsWith('/')
+          || source.startsWith('\0') || source.startsWith('node:')) return null
+        const parts = source.split('/')
+        const name = source.startsWith('@') ? parts.slice(0, 2).join('/') : parts[0]
+        if (name !== undefined && workspaceNames.has(name)) {
+          return followWorkspace ? null : { id: source, external: true }
+        }
+        const resolved = await this.resolve(source, importer, { skipSelf: true })
+        if (resolved === null) throw new Error(`browser notices: cannot resolve ${source} from ${importer}`)
+        const owner = browserPackageOfFile(resolved.id)
+        if (owner === undefined) return resolved
+        if (browserPackageOfFile(importer) === undefined) seen.add(owner)
+        // Notices disclose direct dependencies; upstream implementation imports stay in the lockfile.
+        return { id: source, external: true }
+      },
+    },
+  }
+}
+
+function readManifest(path: string): Manifest {
+  return JSON.parse(readFileSync(path, 'utf8')) as Manifest
+}
+
+/**
+ * Source aliases shared with the repository's source-plane TypeScript programs.
+ * @param root - Repository root containing tsconfig.base.json.
+ * @returns Exact and wildcard aliases for the Vite dependency walk.
+ */
+export function browserSourceAliases(root: string): { find: RegExp; replacement: string }[] {
+  const path = resolve(root, 'tsconfig.base.json')
+  const config = ts.readConfigFile(path, file => ts.sys.readFile(file))
+  if (config.error !== undefined) throw new Error(ts.flattenDiagnosticMessageText(config.error.messageText, '\n'))
+  const parsed = ts.parseJsonConfigFileContent(config.config, ts.sys, root)
+  return Object.entries(parsed.options.paths ?? {}).map(([name, targets]) => {
+    const target = targets[0]
+    if (target === undefined) throw new Error(`browser notices: ${name} has no source target in ${path}`)
+    const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&').replace('\\*', '(.*)')
+    return { find: new RegExp(`^${escaped}$`), replacement: resolve(root, target).replace('*', '$1') }
+  })
+}
+
+async function collectClientBundles(
+  root: string,
+  manifests: ReadonlyMap<string, Manifest>,
+  workspaceNames: ReadonlySet<string>,
+  seen: Set<string>,
+): Promise<void> {
+  for (const [manifestPath, manifest] of manifests) {
+    if (manifest.private === true || manifest.dsh?.client === undefined) continue
+    const dir = dirname(manifestPath)
+    const loaded = await import(pathToFileURL(resolve(dir, 'tsdown.config.ts')).href) as { default: UserConfigExport }
+    const factory = await loaded.default
+    const configured = typeof factory === 'function' ? await factory({ env: {} }, { ci: false }) : factory
+    const configs = Array.isArray(configured) ? configured : [configured]
+    const client = configs.find(config => config.name === `${manifest.name}/client`)
+    if (client === undefined) throw new Error(`browser notices: ${manifest.name} has no browser build config`)
+    if (typeof client.inputOptions === 'function') throw new Error(`browser notices: ${manifest.name} needs resolved input options`)
+    const bundle = await Rolldown.rolldown({
+      ...client.inputOptions,
+      cwd: dir,
+      input: client.entry as Rolldown.InputOption,
+      platform: 'browser',
+      transform: client.define === undefined ? {} : { define: client.define },
+      plugins: [recorder(seen, workspaceNames), client.plugins ?? []] as NonNullable<Rolldown.InputOptions['plugins']>,
+      tsconfig: resolve(root, 'tsconfig.base.client.json'),
+    })
+    try {
+      await bundle.generate({ format: 'cjs', sourcemap: false })
+    } finally {
+      await bundle.close()
+    }
+  }
+}
+
+interface ShellConfig {
+  build: { rollupOptions?: { input?: string | string[] | Record<string, string> } }
+}
+
+interface ViteApi {
+  resolveConfig(config: Record<string, unknown>, command: 'build'): Promise<ShellConfig>
+  build(config: Record<string, unknown>): Promise<unknown>
+}
+
+async function collectShell(
+  root: string,
+  workspaceNames: ReadonlySet<string>,
+  seen: Set<string>,
+): Promise<void> {
+  for (const path of globSync('apps/*/vite.config.ts', { cwd: root }).sort()) {
+    const dir = dirname(resolve(root, path))
+    const manifest = readManifest(resolve(dir, 'package.json'))
+    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 input = config.build.rollupOptions?.input
+    const entries = typeof input === 'string' ? [input] : Object.values(input ?? {})
+    const pages = entries.filter(entry => entry.endsWith('.html'))
+    if (pages.length === 0) throw new Error(`browser notices: ${manifest.name} has no HTML build entry`)
+    await vite.build({
+      root: dir,
+      logLevel: 'error',
+      plugins: [recorder(seen, workspaceNames, true)],
+      resolve: { alias: browserSourceAliases(root) },
+      build: {
+        write: false,
+        minify: false,
+        sourcemap: false,
+        reportCompressedSize: false,
+        rollupOptions: {
+          input: pages.length === 1 ? pages[0] : pages,
+          // Chunk coloring expects full third-party bodies; the disclosure walk stops at their imports.
+          output: { manualChunks: () => undefined },
+        },
+      },
+    })
+  }
+}
+
+/**
+ * Direct third-party packages resolved by published browser builds.
+ * @param root - Repository root with installed build dependencies; lib/ is not required.
+ * @returns Names of distributed browser inputs, excluding workspace packages and erased types.
+ */
+export async function browserBundledExternals(root: string): Promise<Set<string>> {
+  const manifests = new Map<string, Manifest>()
+  for (const glob of ['packages/*/*/package.json', 'vendor/*/package.json']) {
+    for (const path of globSync(glob, { cwd: root }).sort()) {
+      const absolute = resolve(root, path)
+      manifests.set(absolute, readManifest(absolute))
+    }
+  }
+  const names = new Set([...manifests.values()].map(manifest => manifest.name))
+  const seen = new Set<string>()
+  await collectClientBundles(root, manifests, names, seen)
+  await collectShell(root, names, seen)
+  return seen
+}

+ 37 - 2
scripts/gen-third-party-notices.spec.ts

@@ -4,6 +4,7 @@ import { tmpdir } from 'node:os'
 import { describe, expect, it } from 'vitest'
 import {
   CLAUDE_AGENT_SDK_PACKAGE,
+  assertRuntimeLicenses,
   claudeDistributionFromManifest,
   collectPythonDependencies,
   isOwnerAuthorizedRuntime,
@@ -24,8 +25,11 @@ describe('THIRD_PARTY_NOTICES.md', () => {
   // already runs in the test lane, so the check costs no extra CI process.
   // Pre-commit regenerates the file whenever a manifest is staged, so reaching
   // this assertion means the notices were committed without that hook.
-  it('matches what the generator produces from the current manifests', () => {
-    const generated = render()
+  // This case resolves all browser build graphs as well as installed license metadata.
+  it('matches what the generator produces from the current manifests', {
+    timeout: 120_000,
+  }, async () => {
+    const generated = await render()
     expect(generated).toContain('It depends on the third-party software listed below.')
     expect(readFileSync(resolve(root, 'THIRD_PARTY_NOTICES.md'), 'utf8'), 'stale notices — run `pnpm run gen-third-party-notices`').toBe(generated)
   })
@@ -42,6 +46,37 @@ function workspace(entries: Record<string, Manifest>): { manifests: Map<string,
 }
 
 describe('tierExternalDeps', () => {
+  it('keeps license rejection active when a browser library is declared for development', () => {
+    const { manifests, names } = workspace({
+      'packages/client/ui/package.json': { devDependencies: { 'browser-lib': '^1', 'test-tool': '^1' } },
+    })
+    const tiers = tierExternalDeps(manifests, names, new Set(['browser-lib']))
+    const dependencies = [{ name: 'browser-lib', license: 'GPL-3.0-only' }, { name: 'test-tool', license: 'GPL-3.0-only' }]
+      .filter(dep => tiers.get(dep.name))
+    expect(dependencies.map(dep => dep.name)).toEqual(['browser-lib'])
+    expect(() => { assertRuntimeLicenses(dependencies) }).toThrow('browser-lib (GPL-3.0-only)')
+    expect(() => { assertRuntimeLicenses([{ name: 'browser-lib', license: 'MIT' }]) }).not.toThrow()
+    expect(() => { assertRuntimeLicenses([{ name: CLAUDE_AGENT_SDK_PACKAGE, license: 'SEE LICENSE IN README.md' }]) })
+      .not.toThrow()
+  })
+
+  it('keeps browser-bundled development dependencies in runtime disclosures', () => {
+    const { manifests, names } = workspace({
+      'packages/client/ui/package.json': {
+        name: '@fixture/ui', devDependencies: { react: '^18', 'browser-lib': '^1', 'type-only': '^1' },
+      },
+    })
+    expect(tierExternalDeps(manifests, names, new Set(['react', 'browser-lib']))).toEqual(new Map([
+      ['tsx', true], ['react', true], ['browser-lib', true], ['type-only', false],
+    ]))
+  })
+
+  it('rejects a browser library missing from the disclosed declarations', () => {
+    const { manifests, names } = workspace({})
+    expect(() => tierExternalDeps(manifests, names, new Set(['missing-lib'])))
+      .toThrow('browser package missing-lib has no workspace dependency declaration')
+  })
+
   it('tiers by declaring area, not by the declaring section name', () => {
     const { manifests, names } = workspace({
       // Root tooling and test infrastructure never ship, whichever section declares them.

+ 35 - 26
scripts/gen-third-party-notices.ts

@@ -13,6 +13,7 @@ import { dirname, resolve } from 'node:path'
 import * as yaml from 'js-yaml'
 import { parse as parseToml, type TomlTableWithoutBigInt, type TomlValueWithoutBigInt } from 'smol-toml'
 import parseSpdx from 'spdx-expression-parse'
+import { browserBundledExternals } from './browser-bundled-externals.ts'
 
 const root = resolve(import.meta.dirname, '..')
 const OUT = 'THIRD_PARTY_NOTICES.md'
@@ -371,14 +372,11 @@ function normalizeRepo(raw: string | undefined): string | undefined {
 }
 
 /**
- * External npm dependencies, tiered by which workspace area declares them at
- * runtime: a package is runtime when any manifest outside `DEV_ONLY_AREAS`
- * names it in `dependencies`/`optionalDependencies`. A package declared only
- * by tooling, test infrastructure, the website, or the demo leaves — whatever
- * the declaring section is called — is development-only.
+ * Direct npm dependencies distributed through installed runtime libraries or
+ * browser builds. Tooling declarations alone do not imply distribution.
  */
-function collectNpmDeps(manifests: Map<string, Manifest>, names: Set<string>): ExternalDep[] {
-  return [...tierExternalDeps(manifests, names)]
+function collectNpmDeps(manifests: Map<string, Manifest>, names: Set<string>, browser: ReadonlySet<string>): ExternalDep[] {
+  return [...tierExternalDeps(manifests, names, browser)]
     .filter(([name]) => !FIRST_PARTY.has(name))
     .sort(([a], [b]) => a.localeCompare(b))
     .map(([name, runtime]) => ({ name, ...installedMetadata(name, manifests), runtime }))
@@ -388,9 +386,12 @@ function collectNpmDeps(manifests: Map<string, Manifest>, names: Set<string>): E
  * Tier every external dependency the workspace declares.
  * @param manifests - workspace manifests keyed by repository-relative path.
  * @param names - every workspace package name, which never counts as external.
+ * @param browser - Direct third-party packages resolved by the browser builds.
  * @returns each external package mapped to whether it is a runtime dependency.
  */
-export function tierExternalDeps(manifests: Map<string, Manifest>, names: Set<string>): Map<string, boolean> {
+export function tierExternalDeps(
+  manifests: Map<string, Manifest>, names: Set<string>, browser: ReadonlySet<string> = new Set(),
+): Map<string, boolean> {
   const tiers = new Map<string, boolean>()
   // `tsx` is runtime by fiat: the root source-run scripts execute through its ESM hook.
   tiers.set('tsx', true)
@@ -399,11 +400,14 @@ export function tierExternalDeps(manifests: Map<string, Manifest>, names: Set<st
     for (const kind of ALL_KINDS) {
       for (const [dep, range] of Object.entries(manifest[kind] ?? {})) {
         if (names.has(dep) || range.startsWith('workspace:')) continue
-        const runtime = !devOnly && (RUNTIME_KINDS as readonly string[]).includes(kind)
+        const runtime = browser.has(dep) || !devOnly && (RUNTIME_KINDS as readonly string[]).includes(kind)
         tiers.set(dep, (tiers.get(dep) ?? false) || runtime)
       }
     }
   }
+  for (const name of browser) {
+    if (!names.has(name) && !tiers.has(name)) throw new Error(`gen-third-party-notices: browser package ${name} has no workspace dependency declaration`)
+  }
   return tiers
 }
 
@@ -629,6 +633,18 @@ export function isPermissive(license: string): boolean {
   }
 }
 
+/**
+ * Reject unapproved non-permissive licenses on installed or browser-bundled code.
+ * @param dependencies - Disclosed runtime package identities and declared licenses.
+ * @throws When a runtime package has no permissive license or exact owner authorization.
+ */
+export function assertRuntimeLicenses(dependencies: readonly { name: string; license: string }[]): void {
+  const rejected = dependencies.filter(dep => !isPermissive(dep.license) && !isOwnerAuthorizedRuntime(dep.name))
+  if (rejected.length > 0) {
+    throw new Error(`gen-third-party-notices: runtime ${rejected.map(dep => `${dep.name} (${dep.license})`).join(', ')} is not a permissive license; review the distribution terms and record the decision before regenerating.`)
+  }
+}
+
 /**
  * Render the sentence that isolates non-permissive development tooling, or
  * nothing at all when every development dependency is permissive.
@@ -671,14 +687,15 @@ ${rows.join('\n')}
 
 /**
  * Render the complete notices document.
- * @returns the exact bytes `THIRD_PARTY_NOTICES.md` must hold.
+ * @returns The exact bytes THIRD_PARTY_NOTICES.md must hold after resolving browser inputs.
  */
-export function render(): string {
+export async function render(): Promise<string> {
+  const browser = await browserBundledExternals(root)
   // The linked-manifest cache is keyed by name only, so it must not outlive
   // the manifests map it was resolved from; render() owns that single load.
   workspaceLinkedManifestCache.clear()
   const { manifests, names } = loadWorkspaceManifests()
-  const npm = collectNpmDeps(manifests, names)
+  const npm = collectNpmDeps(manifests, names, browser)
   const runtimeDeps = npm.filter(dep => dep.runtime)
   const devDeps = npm.filter(dep => !dep.runtime)
   const vendored = collectVendored()
@@ -690,15 +707,7 @@ export function render(): string {
     ? collectClaudeDistribution(manifests)
     : undefined
   const nonPermissiveDev = devDeps.filter(dep => !isPermissive(dep.license))
-  // A copyleft license reaching a shipped surface is a distribution decision,
-  // not a rendering detail; the notices cannot quietly absorb it.
-  const nonPermissiveRuntime = runtimeDeps.filter(dep =>
-    !isPermissive(dep.license)
-    && !isOwnerAuthorizedRuntime(dep.name),
-  )
-  if (nonPermissiveRuntime.length > 0) {
-    throw new Error(`gen-third-party-notices: runtime ${nonPermissiveRuntime.map(dep => `${dep.name} (${dep.license})`).join(', ')} is not a permissive license; review the distribution terms and record the decision before regenerating.`)
-  }
+  assertRuntimeLicenses(runtimeDeps)
   const patchedLines = patched.map(({ spec, patch }) => `- \`${spec}\` — [\`${patch}\`](${patch})`)
 
   return `<!-- Generated by scripts/gen-third-party-notices.ts — do not edit by hand.
@@ -722,7 +731,7 @@ ${vendored.map(row => `| \`${row.npmName}\` | \`${row.upstreamName}\` | [${row.u
 
 ## Runtime npm dependencies
 
-External packages that a workspace package resolves at runtime. The tier covers every plugin a user can mount from \`cordis.yml\` — not only what the \`dsh\` CLI, Web UI, and Python SDK runtime load by default.
+External packages installed for runtime use or distributed inside the prebuilt browser artifacts. Browser inputs are resolved through the shipping tsdown and Vite configurations, independently of npm dependency sections. The tier covers every plugin a user can mount from \`cordis.yml\` — not only what the \`dsh\` CLI, Web UI, and Python SDK runtime load by default.
 
 ${renderNpmTable(runtimeDeps)}
 
@@ -733,7 +742,7 @@ ${renderClaudeDistribution(claudeDistribution)}
 
 ## Development-only npm dependencies
 
-External packages **directly declared** only by repository tooling, test infrastructure, the documentation site, the demo leaves, or the native launcher's build workspace. No shipped surface names them itself. A package here may still be pulled in transitively by a runtime dependency — \`pnpm-lock.yaml\` is the authority on the full closure — so this tier records who declares a package, not what a build ultimately bundles.
+External packages **directly declared** for development, tests, types, or tooling, without a runtime installation or browser-build relationship. A package here may still be pulled in transitively by a runtime dependency — \`pnpm-lock.yaml\` is the authority on that full closure.
 
 ${renderNpmTable(devDeps)}
 ${renderNonPermissiveNote(nonPermissiveDev)}
@@ -755,8 +764,8 @@ ${python.map(dep => `| [\`${dep.name}\`](${dep.repo}) | ${dep.license} | ${dep.r
 /** CLI entry: default writes the notices, `--check` fails if the committed copy
  * is stale. Guarded behind an entry-point check so importing this module for
  * tests neither regenerates the committed file nor calls process.exit. */
-function main(): void {
-  const content = render()
+async function main(): Promise<void> {
+  const content = await render()
   if (process.argv.includes('--check')) {
     let committed: string | null = null
     try {
@@ -779,5 +788,5 @@ function main(): void {
 }
 
 if (process.argv[1] !== undefined && import.meta.filename === resolve(process.argv[1])) {
-  main()
+  await main()
 }

+ 13 - 0
scripts/verify-npm-install-layout.spec.ts

@@ -77,6 +77,19 @@ describe('npm install layout verifier', () => {
     })
   })
 
+  it.each([
+    ['react', 'node_modules/react'],
+    ['react-dom', 'node_modules/react-dom'],
+    ['react', 'node_modules/dsh-previous/node_modules/react'],
+    ['react-dom', 'node_modules/dsh-previous/node_modules/react-dom'],
+  ])('rejects browser runtime %s installed at %s in the DSH-only consumer', (name, path) => {
+    const layout = validLayout()
+    const packages = { ...layout.packages, [path]: { version: '18.3.1' } }
+    expect(() => assertDualDshInstallLayout({ ...layout, packages })).toThrow(
+      `${path}: ${name} is a browser build input`,
+    )
+  })
+
   it('rejects an internal edge that crosses release versions', () => {
     const layout = validLayout()
     const packages = { ...layout.packages }

+ 3 - 0
scripts/verify-npm-install-layout.ts

@@ -126,6 +126,9 @@ export function assertDualDshInstallLayout(packageLock: NpmPackageLock): DshInst
 
   for (const [path, manifest] of installed) {
     const name = packageNameAtPath(path, manifest)
+    if (name === 'react' || name === 'react-dom') {
+      errors.push(`${path}: ${name} is a browser build input, not a dependency of the synthetic DSH-only consumer`)
+    }
     if (name === undefined || !isDshPackage(name)) continue
     const version = manifest.version
     if (version !== nestedVersion && version !== rootVersion) {

+ 420 - 3
scripts/verify-package-dependencies.spec.ts

@@ -1,4 +1,4 @@
-import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
+import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
 import { tmpdir } from 'node:os'
 import { dirname, join } from 'node:path'
 import { afterEach, describe, expect, it } from 'vitest'
@@ -11,13 +11,16 @@ import {
   collectPackageDependencyViolations,
   collectRuntimeSourceExportUses,
   discoverPackageDependencyScope,
+  expectedPackageDependencies,
   fixPackageDependencies,
   formatManagedRuntimeDependencies,
   formatPeerRequiredRuntimeDependencies,
   readPackageDependencyFacts,
+  readPackageDependencyState,
   repairPackageDependencyManifest,
   type PackageDependencyFacts,
   type PackageDependencyManifest,
+  type PackageDependencyRole,
   type WorkspacePackageManifest,
 } from './verify-package-dependencies.ts'
 
@@ -87,6 +90,63 @@ function facts(manifest: PackageDependencyManifest): PackageDependencyFacts {
   }
 }
 
+function sourceFacts(
+  files: Readonly<Record<string, string>>,
+  manifest: Partial<PackageDependencyManifest> = {},
+  role: PackageDependencyRole = 'client-host',
+): PackageDependencyFacts {
+  const root = mkdtempSync(join(tmpdir(), 'dsh-dependency-source-'))
+  roots.push(root)
+  const subject = pkg('@f/probe', 'packages/g/probe/package.json', manifest)
+  for (const [path, source] of Object.entries(files)) {
+    const absolute = join(root, subject.dir, path)
+    mkdirSync(dirname(absolute), { recursive: true })
+    writeFileSync(absolute, source)
+  }
+  return readPackageDependencyFacts(root, subject, role, new Set([CORDIS, subject.name]), policy())
+}
+
+function generatedHostFixture(mode: 'schema' | 'object'): { root: string; manifestPath: string; source: string } {
+  const root = mkdtempSync(join(tmpdir(), 'dsh-generated-host-dependencies-'))
+  roots.push(root)
+  const manifestPath = 'packages/client/probe/package.json'
+  const source = `/** @typert ${mode} */\nexport interface Payload { value: string }\n`
+  const manifest = {
+    name: '@fixture/generated',
+    type: 'module',
+    dsh: { client: {} },
+    exports: {
+      '.': { types: './lib/types/index.d.ts', default: './lib/index.js' },
+      './typert': { types: './lib/typert.host.d.ts', default: './lib/typert.host.js' },
+    },
+    files: ['lib/typert.host.js', 'lib/typert.host.d.ts'],
+    dependencies: { zod: '^4.0.0' },
+    devDependencies: { [CORDIS]: 'workspace:^' },
+    peerDependencies: { [CORDIS]: 'workspace:^' },
+  }
+  const files = {
+    'tsconfig.base.json': JSON.stringify({
+      compilerOptions: {
+        target: 'ES2024', module: 'ESNext', moduleResolution: 'Bundler', strict: true,
+        composite: true, noEmit: true, types: [], skipLibCheck: true,
+      },
+    }),
+    'tsconfig.host.json': JSON.stringify({
+      extends: './tsconfig.base.json', files: [], references: [{ path: './packages/client/probe' }],
+    }),
+    'packages/client/probe/tsconfig.json': JSON.stringify({
+      extends: '../../../tsconfig.base.json', compilerOptions: { rootDir: 'src' }, include: ['src'],
+    }),
+    [manifestPath]: JSON.stringify(manifest),
+    'packages/client/probe/src/index.ts': source,
+  }
+  for (const [path, content] of Object.entries(files)) {
+    mkdirSync(dirname(join(root, path)), { recursive: true })
+    writeFileSync(join(root, path), content)
+  }
+  return { root, manifestPath, source }
+}
+
 function hostRuntimeFixture(): {
   provider: WorkspacePackageManifest
   workspaceNames: Set<string>
@@ -254,6 +314,231 @@ describe('package dependency scope', () => {
 })
 
 describe('face-aware source classification', () => {
+  it('keeps generated Host schema imports in dependencies without reading or writing lib', () => {
+    const { root, manifestPath, source } = generatedHostFixture('schema')
+    const before = readFileSync(join(root, manifestPath), 'utf8')
+    const state = readPackageDependencyState(root, policy())
+    const subject = state.facts[0]
+    if (subject === undefined) throw new Error('generated Host fixture was not classified')
+
+    expect(subject.allSourceUses.has('zod')).toBe(false)
+    expect(subject.hostRuntimeExportUses).toContainEqual(expect.objectContaining({
+      packageName: 'zod', specifier: 'zod', exportName: 'z',
+      sourcePath: 'packages/client/probe/lib/typert.host.js',
+    }))
+    const standalone = readPackageDependencyFacts(root, pkg('@fixture/generated', manifestPath, subject.manifest),
+      subject.role, state.workspaceNames, policy())
+    expect(standalone.hostRuntimeExportUses).toEqual(subject.hostRuntimeExportUses)
+    expect(collectPackageDependencyViolations(state)).toEqual([])
+    repairPackageDependencyManifest(subject)
+    expect(subject.manifest.dependencies?.zod).toBe('^4.0.0')
+    expect(subject.manifest.devDependencies?.zod).toBeUndefined()
+
+    delete subject.manifest.dependencies?.zod
+    subject.manifest.devDependencies = { ...subject.manifest.devDependencies, zod: '^4.0.0' }
+    expect(collectPackageDependencyViolations(state)).toContainEqual(
+      expect.stringContaining('must be dependencies-only; found devDependencies'),
+    )
+    repairPackageDependencyManifest(subject)
+    expect(subject.manifest.dependencies?.zod).toBe('^4.0.0')
+    expect(subject.manifest.devDependencies?.zod).toBeUndefined()
+
+    delete subject.manifest.dependencies?.zod
+    expect(() => { repairPackageDependencyManifest(subject) }).toThrow('undeclared third-party dependency zod')
+    expect(existsSync(join(root, 'packages/client/probe/lib'))).toBe(false)
+    expect(readFileSync(join(root, manifestPath), 'utf8')).toBe(before)
+    expect(readFileSync(join(root, 'packages/client/probe/src/index.ts'), 'utf8')).toBe(source)
+  })
+
+  it('does not infer a zod runtime dependency from a metadata-only Typert export', () => {
+    const { root } = generatedHostFixture('object')
+    const state = readPackageDependencyState(root, policy())
+    const subject = state.facts[0]
+    if (subject === undefined) throw new Error('generated Host fixture was not classified')
+
+    expect(subject.hostRuntimeSourceUses.has('zod')).toBe(false)
+    expect(expectedPackageDependencies(subject).get('zod')?.section).toBe('devDependencies')
+    repairPackageDependencyManifest(subject)
+    expect(subject.manifest.dependencies?.zod).toBeUndefined()
+    expect(subject.manifest.devDependencies?.zod).toBe('^4.0.0')
+    expect(existsSync(join(root, 'packages/client/probe/lib'))).toBe(false)
+  })
+
+  it('rejects a declared Host Typert module absent from the Host program', () => {
+    const { root } = generatedHostFixture('schema')
+    rmSync(join(root, 'tsconfig.host.json'))
+
+    expect(() => readPackageDependencyState(root, policy())).toThrow(
+      'packages/client/probe/package.json: declared Host Typert export has no generated module',
+    )
+    expect(existsSync(join(root, 'packages/client/probe/lib'))).toBe(false)
+  })
+
+  it('propagates generator publication errors without writing or repairing manifests', () => {
+    const { root, manifestPath } = generatedHostFixture('schema')
+    const manifest = JSON.parse(readFileSync(join(root, manifestPath), 'utf8')) as { files: string[] }
+    manifest.files = []
+    const before = JSON.stringify(manifest)
+    writeFileSync(join(root, manifestPath), before)
+
+    expect(() => readPackageDependencyState(root, policy())).toThrow(
+      'package files must include lib/typert.host.js',
+    )
+    expect(readFileSync(join(root, manifestPath), 'utf8')).toBe(before)
+    expect(existsSync(join(root, 'packages/client/probe/lib'))).toBe(false)
+  })
+
+  it('counts browser imports, JSX, type-only references, and augmentations as development inputs', () => {
+    const subject = sourceFacts({
+      'src/index.ts': [
+        "import { readFile } from 'node:fs'",
+        "import { join } from 'path'",
+        "import type { HostType } from 'host-types'",
+        "import { type MixedType } from 'mixed-types'",
+        "import type { Hidden } from './type-helper.ts'",
+      ].join('\n'),
+      'src/type-helper.ts': "import { hidden } from 'hidden-value'; export type Hidden = typeof hidden",
+      'src/client/index.tsx': [
+        "import { browser } from '@browser/kit/subpath'",
+        "import 'react-dom/client'",
+        "import '#local'",
+        "import 'https://example.test/browser.js'",
+        'export const view = <div />',
+      ].join('\n'),
+      'src/client/augmentation.d.ts': [
+        "declare module 'augmented' { interface Extra {} }",
+        "declare module '*.css' {}",
+        "declare module '*.module.css' {}",
+      ].join('\n'),
+    })
+
+    expect([...subject.hostRuntimeSourceUses]).toEqual([])
+    expect([...expectedPackageDependencies(subject)].map(([name, rule]) => [name, rule.section]).sort()).toEqual([
+      ['@browser/kit', 'devDependencies'],
+      [CORDIS, 'peer-dev'],
+      ['augmented', 'devDependencies'],
+      ['hidden-value', 'devDependencies'],
+      ['host-types', 'devDependencies'],
+      ['mixed-types', 'devDependencies'],
+      ['react', 'devDependencies'],
+      ['react-dom', 'devDependencies'],
+    ])
+  })
+
+  it.each(['client-host', 'configured-host'] as const)('retains Host and shared third-party values in dependencies for %s', (role) => {
+    const subject = sourceFacts({
+      'src/index.ts': "import 'host-only'; export { shared } from './nested.ts'",
+      'src/nested.ts': "export { shared } from 'shared-runtime'",
+      'src/client/index.ts': "import 'browser-only'; import 'shared-runtime'",
+    }, {}, role)
+
+    const expected = expectedPackageDependencies(subject)
+    expect(expected.get('browser-only')?.section).toBe('devDependencies')
+    expect(expected.get('host-only')?.section).toBe('dependencies')
+    expect(expected.get('shared-runtime')?.section).toBe('dependencies')
+  })
+
+  it('uses declared DefinitelyTyped providers only for erased source references', () => {
+    const subject = sourceFacts({
+      'src/index.ts': "import type { ReactNode } from 'react'",
+      'src/client/index.ts': "import type { Root } from 'mdast'; import type { Kind } from '@scope/types'",
+    }, {
+      dependencies: { '@types/mdast': '^4.0.0' },
+      devDependencies: { '@types/react': '^18.0.0', '@types/scope__types': '^1.0.0' },
+    })
+
+    expect([...subject.allSourceUses.keys()].sort()).toEqual(['@types/mdast', '@types/react', '@types/scope__types'])
+    expect([...subject.hostRuntimeSourceUses]).toEqual([])
+    repairPackageDependencyManifest(subject)
+    expect(subject.manifest.devDependencies?.['@types/mdast']).toBe('^4.0.0')
+    expect(subject.manifest.dependencies?.['@types/mdast']).toBeUndefined()
+    expect(subject.manifest.devDependencies?.mdast).toBeUndefined()
+  })
+
+  it.each(["import 'runtime-library'", 'export const view = <div />'])('does not let type providers satisfy runtime imports or JSX: %s', (source) => {
+    const name = source.includes('<div') ? 'react' : 'runtime-library'
+    const subject = sourceFacts({
+      'src/index.ts': 'export function apply() {}',
+      'src/client/index.tsx': source,
+    }, { devDependencies: { [`@types/${name}`]: '^1.0.0' } })
+
+    expect(subject.allSourceUses.has(name)).toBe(true)
+    expect(() => { repairPackageDependencyManifest(subject) }).toThrow(`undeclared third-party dependency ${name}`)
+  })
+
+  it('does not treat static browser library entries as Host modules', () => {
+    const subject = sourceFacts({
+      'src/index.tsx': "import 'static-input'; export const view = <div />",
+      'src/invariant.ts': "import 'browser-companion'",
+    }, {
+      exports: {
+        '.': { types: './lib/types/index.d.ts', default: './lib/index.js' },
+        './invariant': { types: './lib/types/invariant.d.ts', default: './lib/invariant.js' },
+      },
+    }, 'client-only')
+
+    expect([...subject.hostRuntimeSourceUses]).toEqual([])
+    for (const name of ['static-input', 'react', 'browser-companion']) {
+      expect(expectedPackageDependencies(subject).get(name)?.section).toBe('devDependencies')
+    }
+  })
+
+  it('scans published Node companions, conditional entries, and emitted-tree subpaths from source', () => {
+    const subject = sourceFacts({
+      'src/index.ts': 'export function apply() {}',
+      'src/invariant.ts': "import 'invariant-runtime'; import type { Kind } from 'invariant-types'",
+      'src/node/helper.ts': "export { helper } from 'node-helper'",
+      'src/node.mts': "import 'node-import'",
+      'src/node.cts': "require('node-require')",
+      'src/emitted.tsx': 'export const view = <div />',
+      'src/worker/one.ts': "import 'worker-one'",
+      'src/worker/two.ts': "import('worker-two')",
+      'src/client/index.ts': "import 'browser-only'",
+      'src/types-only.ts': "import 'type-export-only'",
+    }, {
+      exports: {
+        '.': { types: './lib/types/index.d.ts', default: './lib/index.js' },
+        './invariant': { types: './lib/types/invariant.d.ts', default: './lib/invariant.js' },
+        './renamed': { types: './lib/types/node/helper.d.ts', default: './lib/node-bundle.js' },
+        './conditional': { browser: './lib/browser.js', node: { import: './lib/node.mjs', require: './lib/node.cjs' } },
+        './emitted': { types: './lib/types/emitted.d.ts', default: './lib/types/emitted.js' },
+        './worker/*': './lib/worker/*.js',
+        './client': { types: './lib/types/client/index.d.ts', default: './lib/client.js' },
+        './client/extra': './lib/missing-browser.js',
+        './types-only': { types: './lib/types/types-only.d.ts' },
+        './src/*': './src/*',
+        './package.json': './package.json',
+        './disabled': null,
+      },
+    })
+
+    expect([...subject.hostRuntimeSourceUses.keys()].sort()).toEqual([
+      'invariant-runtime', 'node-helper', 'node-import', 'node-require', 'react', 'worker-one', 'worker-two',
+    ])
+    const expected = expectedPackageDependencies(subject)
+    for (const name of subject.hostRuntimeSourceUses.keys()) expect(expected.get(name)?.section).toBe('dependencies')
+    for (const name of ['browser-only', 'invariant-types', 'type-export-only']) {
+      expect(expected.get(name)?.section).toBe('devDependencies')
+    }
+  })
+
+  it.each([
+    './lib/missing.js',
+    { types: './lib/types/missing.d.ts', default: './lib/renamed.js' },
+    ['./lib/missing.cjs'],
+    './lib/missing/*.js',
+  ])('rejects a published Node entry with no matching source: %j', (target) => {
+    expect(() => sourceFacts({ 'src/index.ts': 'export function apply() {}' }, {
+      exports: { './node': target },
+    })).toThrow('Host export ./node has no source entry')
+  })
+
+  it('rejects a Node export outside the source mapping', () => {
+    expect(() => sourceFacts({ 'src/index.ts': 'export function apply() {}' }, {
+      exports: { './node': './other/node.js' },
+    })).toThrow('Host export ./node cannot map ./other/node.js to a source entry')
+  })
+
   it('fails when a managed Host package has no Host entry', () => {
     const root = mkdtempSync(join(tmpdir(), 'dsh-package-missing-host-'))
     roots.push(root)
@@ -349,6 +634,131 @@ describe('face-aware source classification', () => {
 })
 
 describe('dependency sections', () => {
+  it.each(['client-only', 'client-host'] as const)('moves unused third-party and CSS inputs to development dependencies for %s', (role) => {
+    const subject = sourceFacts({
+      'src/index.ts': "import 'host-runtime'",
+    }, {
+      dependencies: { 'unused-browser-dep': '^1.2.3', '@fontsource/test-font': '~2.0.0', 'host-runtime': '^3.0.0' },
+      optionalDependencies: { 'unused-optional': '^4.0.0' },
+    }, role)
+
+    repairPackageDependencyManifest(subject)
+    expect(subject.manifest.devDependencies).toMatchObject({
+      'unused-browser-dep': '^1.2.3',
+      '@fontsource/test-font': '~2.0.0',
+      'unused-optional': '^4.0.0',
+    })
+    expect(subject.manifest.dependencies).toEqual(role === 'client-host' ? { 'host-runtime': '^3.0.0' } : undefined)
+    expect(subject.manifest.optionalDependencies).toBeUndefined()
+    const repaired = structuredClone(subject.manifest)
+    repairPackageDependencyManifest(subject)
+    expect(subject.manifest).toEqual(repaired)
+  })
+
+  it('preserves unreferenced third-party declarations in configured Host packages', () => {
+    const subject = sourceFacts({ 'src/index.ts': 'export function apply() {}' }, {
+      dependencies: { 'unused-host-dep': '^1.0.0' },
+      optionalDependencies: { 'unused-host-optional': '^2.0.0' },
+    }, 'configured-host')
+
+    repairPackageDependencyManifest(subject)
+    expect(subject.manifest.dependencies).toEqual({ 'unused-host-dep': '^1.0.0' })
+    expect(subject.manifest.optionalDependencies).toEqual({ 'unused-host-optional': '^2.0.0' })
+  })
+
+  it.each(['peerDependencies', 'optionalDependencies'] as const)('rejects browser-only imports declared in %s', (section) => {
+    const subject = sourceFacts({
+      'src/index.ts': 'export function apply() {}',
+      'src/client/index.ts': "import 'external'",
+    }, {
+      devDependencies: { [CORDIS]: 'workspace:^' },
+      peerDependencies: { [CORDIS]: 'workspace:^' },
+      [section]: { [CORDIS]: 'workspace:^', external: '~1.2.3' },
+      peerDependenciesMeta: { external: { optional: true } },
+    })
+    if (section === 'optionalDependencies') delete subject.manifest.optionalDependencies?.[CORDIS]
+    const state = { facts: [subject], packages: [], policyViolations: [], workspaceNames: subject.workspaceNames }
+
+    expect(collectPackageDependencyViolations(state)).toContainEqual(
+      expect.stringContaining(`must be devDependencies-only; found ${section}`),
+    )
+    repairPackageDependencyManifest(subject)
+    expect(subject.manifest.devDependencies?.external).toBe('~1.2.3')
+    expect(subject.manifest[section]?.external).toBeUndefined()
+    expect(subject.manifest.peerDependenciesMeta).toBeUndefined()
+    expect(collectPackageDependencyViolations(state)).toEqual([])
+  })
+
+  it('rejects a missing third-party declaration and leaves the in-memory manifest unchanged', () => {
+    const subject = sourceFacts({
+      'src/index.ts': 'export function apply() {}',
+      'src/client/index.ts': "import 'undeclared'",
+    })
+    const before = structuredClone(subject.manifest)
+    const state = { facts: [subject], packages: [], policyViolations: [], workspaceNames: subject.workspaceNames }
+
+    expect(collectPackageDependencyViolations(state)).toContain(
+      'packages/g/probe/package.json: undeclared (packages/g/probe/src/client/index.ts) '
+      + 'must be devDependencies-only; found no dependency section',
+    )
+    expect(() => { repairPackageDependencyManifest(subject) }).toThrow(
+      'packages/g/probe/package.json: cannot repair undeclared third-party dependency undeclared; declare its version range first',
+    )
+    expect(subject.manifest).toEqual(before)
+  })
+
+  it('validates every third-party range before writing any manifest in a repair batch', () => {
+    const root = mkdtempSync(join(tmpdir(), 'dsh-dependency-batch-'))
+    roots.push(root)
+    const valid = { ...facts({ name: '@deepseek-ai/dsh-first' }), manifestPath: 'first.json' }
+    const base = facts({ name: '@deepseek-ai/dsh-second' })
+    const invalid: PackageDependencyFacts = {
+      ...base,
+      manifestPath: 'second.json',
+      allSourceUses: new Map([...base.allSourceUses, ['undeclared', ['src/client/index.ts']]]),
+    }
+    const subjects = [valid, invalid]
+    const originals = subjects.map(subject => ({ subject, content: `${JSON.stringify(subject.manifest)}\n` }))
+    for (const { subject, content } of originals) writeFileSync(join(root, subject.manifestPath), content)
+    const state = { facts: subjects, packages: [], policyViolations: [], workspaceNames: valid.workspaceNames }
+
+    expect(fixPackageDependencies(root, { ...state, policyViolations: ['unclassified Host export'] })).toEqual([])
+    expect(() => fixPackageDependencies(root, state)).toThrow(
+      'second.json: cannot repair undeclared third-party dependency undeclared; declare its version range first',
+    )
+    for (const { subject, content } of originals) {
+      expect(readFileSync(join(root, subject.manifestPath), 'utf8')).toBe(content)
+      expect(`${JSON.stringify(subject.manifest)}\n`).toBe(content)
+    }
+  })
+
+  it('moves browser-only third-party imports to development dependencies without changing their ranges', () => {
+    const manifest: PackageDependencyManifest = {
+      name: '@deepseek-ai/dsh-probe',
+      dependencies: { '@deepseek-ai/dsh-runtime': 'workspace:^', external: '^1.2.3' },
+      devDependencies: { [CORDIS]: 'workspace:^', '@deepseek-ai/dsh-types': 'workspace:^' },
+      peerDependencies: { [CORDIS]: 'workspace:^' },
+    }
+    const base = facts(manifest)
+    const subject: PackageDependencyFacts = {
+      ...base,
+      allSourceUses: new Map([...base.allSourceUses, ['external', ['packages/core/probe/src/client/index.ts']]]),
+    }
+    const state = { facts: [subject], packages: [], policyViolations: [], workspaceNames: subject.workspaceNames }
+
+    expect(collectPackageDependencyViolations(state)).toEqual([
+      'packages/core/probe/package.json: external (packages/core/probe/src/client/index.ts) '
+      + 'must be devDependencies-only; found dependencies',
+    ])
+    repairPackageDependencyManifest(subject)
+    expect(manifest.dependencies?.external).toBeUndefined()
+    expect(manifest.devDependencies?.external).toBe('^1.2.3')
+    expect(collectPackageDependencyViolations(state)).toEqual([])
+    const repaired = structuredClone(manifest)
+    repairPackageDependencyManifest(subject)
+    expect(manifest).toEqual(repaired)
+  })
+
   it('does not leak repository configuration into captured dependency facts', () => {
     const manifest: PackageDependencyManifest = {
       name: '@deepseek-ai/dsh-client-locale',
@@ -380,22 +790,29 @@ describe('dependency sections', () => {
         ['@deepseek-ai/dsh-runtime', ['packages/core/probe/src/index.ts']],
         ['external', ['packages/core/probe/src/index.ts']],
       ]),
+      allSourceUses: new Map([
+        ...facts(manifest).allSourceUses,
+        ['external', ['packages/core/probe/src/client/index.ts']],
+      ]),
     }
     const state = {
       facts: [subject], packages: [], policyViolations: [], workspaceNames: subject.workspaceNames,
     }
 
     expect(collectPackageDependencyViolations(state)).toContain(
-      'packages/core/probe/package.json: external (packages/core/probe/src/index.ts) '
+      'packages/core/probe/package.json: external (packages/core/probe/src/client/index.ts, packages/core/probe/src/index.ts) '
       + 'must be dependencies-only; found devDependencies',
     )
     repairPackageDependencyManifest(subject)
     expect(manifest.dependencies?.external).toBe('^1.0.0')
     expect(manifest.devDependencies?.external).toBeUndefined()
+    const repaired = structuredClone(manifest)
+    repairPackageDependencyManifest(subject)
+    expect(manifest).toEqual(repaired)
 
     delete manifest.dependencies?.external
     expect(collectPackageDependencyViolations(state)).toContain(
-      'packages/core/probe/package.json: external (packages/core/probe/src/index.ts) '
+      'packages/core/probe/package.json: external (packages/core/probe/src/client/index.ts, packages/core/probe/src/index.ts) '
       + 'must be dependencies-only; found no dependency section',
     )
   })

+ 146 - 29
scripts/verify-package-dependencies.ts

@@ -2,8 +2,10 @@
 
 import { spawnSync } from 'node:child_process'
 import { existsSync, globSync, readFileSync, writeFileSync } from 'node:fs'
+import { isBuiltin } from 'node:module'
 import { dirname, extname, join, normalize, relative, resolve, sep } from 'node:path'
 import ts from 'typescript'
+import { WorkspaceTypertGenerator } from '../packages/typert/generator/src/workspace.ts'
 import { writeModuleGraph } from './gen-module-graph.ts'
 import {
   hasClientDeclaration,
@@ -12,6 +14,7 @@ import {
 } from './package-dependency-policy.ts'
 import {
   collectRuntimeLocalSourceSpecifiers,
+  collectRuntimeSourcePackageUses,
   collectSourcePackageUses,
 } from './verify-client-packages.ts'
 
@@ -63,7 +66,7 @@ export interface PackageDependencyFacts {
   readonly clientInject: ReadonlySet<string>
 }
 
-/** One runtime export reached from a package's Host source closure. */
+/** One runtime export used by an authored or generated Host module. */
 export interface HostRuntimeExportUse {
   readonly packageName: string
   readonly specifier: string
@@ -92,7 +95,8 @@ function normalizePath(path: string): string {
 }
 
 function packageNameOf(specifier: string): string | undefined {
-  if (specifier.startsWith('.') || specifier.startsWith('/') || specifier.startsWith('#') || specifier.includes(':')) {
+  if (isBuiltin(specifier) || specifier.startsWith('.') || specifier.startsWith('/') || specifier.startsWith('#')
+    || specifier.includes(':') || specifier.includes('*')) {
     return undefined
   }
   const parts = specifier.split('/')
@@ -284,40 +288,105 @@ function resolveLocal(importer: string, specifier: string): string | undefined {
   const raw = resolve(dirname(importer), specifier)
   const candidates = extname(raw) === ''
     ? [`${raw}.ts`, `${raw}.tsx`, `${raw}.mts`, `${raw}.cts`, join(raw, 'index.ts'), join(raw, 'index.tsx')]
-    : [raw, raw.replace(/\.js$/, '.ts'), raw.replace(/\.jsx$/, '.tsx'), raw.replace(/\.mjs$/, '.mts'), raw.replace(/\.cjs$/, '.cts')]
+    : [raw, raw.replace(/\.js$/, '.ts'), raw.replace(/\.jsx?$/, '.tsx'), raw.replace(/\.mjs$/, '.mts'), raw.replace(/\.cjs$/, '.cts')]
   return candidates.find(candidate => existsSync(candidate))
 }
 
-function readHostRuntimeUses(root: string, pkg: WorkspacePackageManifest): {
+function nodeExportTargets(value: unknown): string[] {
+  if (typeof value === 'string') return /\.[cm]?js$/.test(value) ? [value] : []
+  if (Array.isArray(value)) return value.flatMap(nodeExportTargets)
+  if (value === null || typeof value !== 'object') return []
+  return Object.entries(value)
+    .filter(([condition]) => ['node', 'import', 'require', 'default'].includes(condition))
+    .flatMap(([, target]) => nodeExportTargets(target))
+}
+
+function hasGeneratedHostExport(pkg: WorkspacePackageManifest): boolean {
+  const exports = pkg.manifest.exports
+  return exports !== null && typeof exports === 'object'
+    && nodeExportTargets((exports as Record<string, unknown>)['./typert']).includes('./lib/typert.host.js')
+}
+
+/** Generate Host modules in memory, without the build plugin's artifact writes. */
+function generatedHostSources(root: string, packages: readonly WorkspacePackageManifest[]): ReadonlyMap<string, string> {
+  const selected = packages.filter(hasGeneratedHostExport)
+  if (selected.length === 0) return new Map()
+  const artifacts = new WorkspaceTypertGenerator(root).generate(selected.map(pkg => pkg.name), ['host'])
+  const sources = new Map(artifacts.map(artifact => [artifact.package, artifact.js]))
+  for (const pkg of selected) {
+    if (!sources.has(pkg.name)) throw new Error(`${pkg.manifestPath}: declared Host Typert export has no generated module`)
+  }
+  return sources
+}
+
+/** Source-backed Node exports; generated Typert artifacts have no standalone source entry. */
+function hostSourceEntries(root: string, pkg: WorkspacePackageManifest): string[] {
+  const entry = resolve(root, pkg.dir, 'src/index.ts')
+  if (!existsSync(entry)) {
+    throw new Error(`${pkg.manifestPath}: Host runtime entry ${normalizePath(relative(root, entry))} does not exist`)
+  }
+  const entries = new Set([entry])
+  const exports = pkg.manifest.exports
+  if (exports === null || typeof exports !== 'object') return [...entries]
+  for (const [subpath, declaration] of Object.entries(exports as Record<string, unknown>)) {
+    if (subpath === '.' || subpath === './package.json' || /^\.\/(?:client|src)(?:\/|$)/.test(subpath)) continue
+    const types = declaration !== null && typeof declaration === 'object' && 'types' in declaration
+      ? declaration.types
+      : undefined
+    for (const runtime of nodeExportTargets(declaration)) {
+      const generatedFace = subpath === './typert' ? 'host' : subpath === './remote' ? 'remote-client' : undefined
+      if (generatedFace !== undefined
+        && runtime === `./lib/typert.${generatedFace}.js`
+        && types === `./lib/typert.${generatedFace}.d.ts`) continue
+      const target = typeof types === 'string' && types.startsWith('./lib/types/') ? types : runtime
+      if (!target.startsWith('./lib/')) {
+        throw new Error(`${pkg.manifestPath}: Host export ${subpath} cannot map ${target} to a source entry`)
+      }
+      const source = target.replace(/^\.\/lib\/(?:types\/)?/, './src/').replace(/\.d\.([cm]?)ts$/, '.$1js')
+      const matched = source.includes('*')
+        ? globSync(source.replace(/\.[cm]?js$/, '.{ts,tsx,mts,cts}'), { cwd: resolve(root, pkg.dir) })
+          .map(path => resolve(root, pkg.dir, path))
+        : [resolveLocal(resolve(root, pkg.manifestPath), source)].filter(path => path !== undefined)
+      if (matched.length === 0) {
+        throw new Error(`${pkg.manifestPath}: Host export ${subpath} has no source entry for ${target}`)
+      }
+      for (const path of matched) entries.add(path)
+    }
+  }
+  return [...entries].sort()
+}
+
+function readHostRuntimeUses(root: string, pkg: WorkspacePackageManifest, generatedHostSource?: string): {
   packageUses: Map<string, string[]>
   exportUses: HostRuntimeExportUse[]
 } {
   const packageUses = new Map<string, string[]>()
   const exportUses = new Map<string, HostRuntimeExportUse>()
   const seen = new Set<string>()
-  const visit = (path: string): void => {
-    const normalized = normalize(path)
-    if (seen.has(normalized)) return
-    seen.add(normalized)
-    const source = readFileSync(normalized, 'utf8')
-    const displayPath = normalizePath(relative(root, normalized))
-    for (const use of collectRuntimeSourceExportUses(normalized, source)) {
+  const collect = (displayPath: string, source: string): void => {
+    for (const use of collectRuntimeSourceExportUses(displayPath, source)) {
       const name = packageNameOf(use.specifier)
       if (name === undefined) continue
       addUse(packageUses, name, displayPath)
       const fact = { packageName: name, ...use, sourcePath: displayPath }
       exportUses.set(`${use.specifier}\0${use.exportName}\0${displayPath}\0${String(use.line)}\0${String(use.column)}`, fact)
     }
+  }
+  const visit = (path: string): void => {
+    const normalized = normalize(path)
+    if (seen.has(normalized)) return
+    seen.add(normalized)
+    const source = readFileSync(normalized, 'utf8')
+    const displayPath = normalizePath(relative(root, normalized))
+    collect(displayPath, source)
     for (const specifier of collectRuntimeLocalSourceSpecifiers(normalized, source)) {
       const target = resolveLocal(normalized, specifier)
       if (target !== undefined) visit(target)
     }
   }
-  const entry = resolve(root, pkg.dir, 'src/index.ts')
-  if (!existsSync(entry)) {
-    throw new Error(`${pkg.manifestPath}: Host runtime entry ${normalizePath(relative(root, entry))} does not exist`)
-  }
-  visit(entry)
+  for (const entry of hostSourceEntries(root, pkg)) visit(entry)
+  const generated = generatedHostSource ?? generatedHostSources(root, [pkg]).get(pkg.name)
+  if (generated !== undefined) collect(`${normalizePath(pkg.dir)}/lib/typert.host.js`, generated)
   return {
     packageUses,
     exportUses: [...exportUses.values()].sort((left, right) =>
@@ -335,23 +404,46 @@ function readAllSourceUses(root: string, pkg: WorkspacePackageManifest): Map<str
   for (const sourcePath of globSync('src/**/*.{ts,tsx,mts,cts}', { cwd: resolve(root, pkg.dir) }).sort()) {
     const source = readFileSync(resolve(root, pkg.dir, sourcePath), 'utf8')
     const displayPath = `${pkg.dir}/${normalizePath(sourcePath)}`
-    for (const name of collectSourcePackageUses(sourcePath, source)) addUse(uses, name, displayPath)
+    let runtimeUses: Set<string> | undefined
+    for (const specifier of collectSourcePackageUses(sourcePath, source)) {
+      const name = packageNameOf(specifier)
+      if (name === undefined) continue
+      const typesName = `@types/${name.replace(/^@/, '').replace('/', '__')}`
+      if (declaredSections(pkg.manifest, name).length === 0 && declaredSections(pkg.manifest, typesName).length > 0) {
+        runtimeUses ??= collectRuntimeSourcePackageUses(sourcePath, source)
+        if (!runtimeUses.has(name)) {
+          addUse(uses, typesName, displayPath)
+          continue
+        }
+      }
+      addUse(uses, name, displayPath)
+    }
   }
   return uses
 }
 
-/** Read source usage for one already-classified package. */
+/**
+ * Read authored and generated source usage for one already-classified package.
+ * @param root - Repository containing source files and face tsconfigs.
+ * @param pkg - Package manifest and directory.
+ * @param role - Selected dependency policy role.
+ * @param workspaceNames - Workspace package identities.
+ * @param policy - Reviewed Host export and configuration-only classifications.
+ * @param generatedHostSource - Host module already emitted in memory by a batched Typert pass.
+ * @returns Source-derived dependency facts without writing build artifacts.
+ */
 export function readPackageDependencyFacts(
   root: string,
   pkg: WorkspacePackageManifest,
   role: PackageDependencyRole,
   workspaceNames: ReadonlySet<string>,
   policy: PackageDependencyPolicy = PACKAGE_DEPENDENCY_POLICY,
+  generatedHostSource?: string,
 ): PackageDependencyFacts {
   const inject = pkg.manifest.dsh?.client?.inject ?? []
   const hostRuntime = role === 'client-only'
     ? { packageUses: new Map<string, string[]>(), exportUses: [] }
-    : readHostRuntimeUses(root, pkg)
+    : readHostRuntimeUses(root, pkg, generatedHostSource)
   return {
     manifestPath: pkg.manifestPath,
     role,
@@ -445,8 +537,9 @@ export function readPackageDependencyState(
   const packages = readWorkspacePackageManifests(root)
   const workspaceNames = new Set(packages.all.map(pkg => pkg.name))
   const discovered = discoverPackageDependencyScope(packages.release, policy)
+  const generated = generatedHostSources(root, discovered.selected.filter(pkg => pkg.role !== 'client-only'))
   const facts = discovered.selected.map(pkg =>
-    readPackageDependencyFacts(root, pkg, pkg.role, workspaceNames, policy))
+    readPackageDependencyFacts(root, pkg, pkg.role, workspaceNames, policy, generated.get(pkg.name)))
   const selectedNames = new Set(facts.map(fact => fact.manifest.name))
   return {
     facts,
@@ -480,9 +573,15 @@ export function expectedPackageDependencies(
 
   expected.set(CORDIS, { section: 'peer-dev', origins: new Set(['shared Cordis runtime']) })
   for (const [name, paths] of facts.allSourceUses) {
-    if (!facts.workspaceNames.has(name)) continue
     for (const path of paths) add(name, 'devDependencies', path)
   }
+  if (facts.role !== 'configured-host') {
+    for (const sectionName of ['dependencies', 'optionalDependencies'] as const) {
+      for (const name of Object.keys(facts.manifest[sectionName] ?? {})) {
+        if (!facts.workspaceNames.has(name)) add(name, 'devDependencies', 'declared browser build input')
+      }
+    }
+  }
   for (const name of facts.clientInject) {
     if (facts.workspaceNames.has(name)) add(name, 'devDependencies', 'dsh.client.inject')
   }
@@ -642,17 +741,28 @@ function preferredRange(
   facts: PackageDependencyFacts,
   name: string,
   target: ExpectedPackageDependency['section'],
-): string | undefined {
-  if (facts.workspaceNames.has(name)) return WORKSPACE_RANGE
+): string {
+  if (name === CORDIS || facts.workspaceNames.has(name)) return WORKSPACE_RANGE
   const order: readonly DependencySection[] = target === 'dependencies'
     ? ['dependencies', 'devDependencies', 'peerDependencies', 'optionalDependencies']
     : ['devDependencies', 'peerDependencies', 'dependencies', 'optionalDependencies']
-  return order.map(sectionName => section(facts.manifest, sectionName)[name]).find(value => value !== undefined)
+  const range = order.map(sectionName => section(facts.manifest, sectionName)[name]).find(value => value !== undefined)
+  if (range === undefined) {
+    throw new Error(`${facts.manifestPath}: cannot repair undeclared third-party dependency ${name}; declare its version range first`)
+  }
+  return range
 }
 
-/** Apply the dependency policy to one in-memory manifest. */
+/**
+ * Apply the dependency policy to one in-memory manifest.
+ * @param facts - Source uses and manifest to repair.
+ * @throws When a third-party dependency has no declared range; the manifest remains unchanged.
+ */
 export function repairPackageDependencyManifest(facts: PackageDependencyFacts): void {
-  for (const [name, rule] of expectedPackageDependencies(facts)) {
+  const repairs = [...expectedPackageDependencies(facts)].map(([name, rule]) => ({
+    name, rule, range: preferredRange(facts, name, rule.section),
+  }))
+  for (const { name, rule, range } of repairs) {
     if (rule.section === 'peer-dev') {
       for (const sectionName of ['dependencies', 'optionalDependencies'] as const) {
         deleteDependency(facts.manifest, sectionName, name)
@@ -662,8 +772,6 @@ export function repairPackageDependencyManifest(facts: PackageDependencyFacts):
       deletePeerMeta(facts.manifest, name)
       continue
     }
-    const range = preferredRange(facts, name, rule.section)
-    if (range === undefined) continue
     for (const sectionName of ['dependencies', 'devDependencies', 'optionalDependencies', 'peerDependencies'] as const) {
       if (sectionName !== rule.section) deleteDependency(facts.manifest, sectionName, name)
     }
@@ -680,9 +788,18 @@ export function repairPackageDependencyManifest(facts: PackageDependencyFacts):
   }
 }
 
-/** Repair every covered manifest and return repository-relative changed paths. */
+/**
+ * Repair every covered manifest after validating all dependency ranges.
+ * @param root - Repository containing the manifests.
+ * @param state - Classified packages and policy violations that block repair.
+ * @returns Repository-relative changed paths, or no paths when policy violations block repair.
+ * @throws When any third-party dependency is undeclared, before modifying any manifest.
+ */
 export function fixPackageDependencies(root: string, state: PackageDependencyState): string[] {
   if (state.policyViolations.length > 0) return []
+  for (const facts of state.facts) {
+    for (const [name, rule] of expectedPackageDependencies(facts)) preferredRange(facts, name, rule.section)
+  }
   const changed: string[] = []
   for (const facts of state.facts) {
     const before = `${JSON.stringify(facts.manifest, null, 2)}\n`