1
0
Эх сурвалжийг харах

refactor(gui): one plugin-package shape — dshClient manifests, clientBundle preset, purity gate over all nine

Every client plugin package carries dshClient ({platform, inject,
immediately?}) and emits lib/client.js through the shared clientBundle
preset; exports["./client"] points at the bundle. The infrastructure
tier (connection, runtime, ui-theme, i18n, hmr) declares immediately: true
in its manifest — absent means lazy. The bundle purity gate covers all
nine packages: platform modules stay external, INLINE_SAFE wire layers
inline, any other cross-plugin value import is a build error. Migrations
that rule forced: scopeOf became a SessionsService method and
transportError moved into dsh-host-apiproxy's wire layer; the store
engine stays in runtime under a documented temporary exemption
(TODO(webload/store-rehome)).
imccyu 2 сар өмнө
parent
commit
c2e0c16d6a

+ 4 - 14
packages/client/connection/src/client/api.ts

@@ -14,7 +14,10 @@ export type {
   RpcRequest, RpcResponse, RpcResult, RpcError, RpcErrorCode,
   ClientRequest, ServerResponse, ServerRequest, ClientResponse, RpcMessage, RpcReceipt,
 } from '@deepseek-ai/dsh-host-apiproxy/api'
-export { RpcId } from '@deepseek-ai/dsh-host-apiproxy/api'
+// transportError moved down to the apiproxy api layer (it belongs beside
+// RpcResult, its subject); re-exported here so connection consumers keep one
+// contract entry point.
+export { RpcId, transportError } from '@deepseek-ai/dsh-host-apiproxy/api'
 export { AbstractApiClient } from '@deepseek-ai/dsh-host-apiproxy/client'
 export type { IApiClient } from '@deepseek-ai/dsh-host-apiproxy/client'
 export type { SessionId, SessionEvent } from '@deepseek-ai/dsh-session/types'
@@ -31,16 +34,3 @@ import type { RpcResponse, RpcResult } from '@deepseek-ai/dsh-host-apiproxy/api'
 export function resultOf<T>(response: RpcResponse<T>): RpcResult<T> {
   return response.result
 }
-
-/**
- * Fold a transport exception into the RpcResult error branch (unified error
- * surface; 'internal' as the catch-all code).
- * @param error - the thrown value from the carrier.
- * @returns the error branch of an RpcResult.
- */
-export function transportError<T>(error: unknown): RpcResult<T> {
-  return {
-    ok: false,
-    error: { code: 'internal', message: error instanceof Error ? error.message : String(error), details: {} },
-  }
-}

+ 0 - 4
packages/client/i18n/package.json

@@ -27,10 +27,6 @@
     "platform": "web",
     "immediately": true
   },
-  "scripts": {
-    "bundle": "tsdown",
-    "watch": "tsdown --watch"
-  },
   "license": "BSD-3-Clause",
   "dependencies": {
     "@deepseek-ai/dsh-client-runtime": "workspace:^"

+ 3 - 1
packages/client/runtime/src/client/sessions/manager.ts

@@ -3,7 +3,9 @@
 // List data never enters zustand; React connects via subscribe/getListSnapshot.
 
 import type { IApiClient, HostFrame, MuxFrame, RpcError, RpcRequest, RpcResult, SessionId, SessionSummary } from '@deepseek-ai/dsh-client-connection/client'
-import { transportError } from '@deepseek-ai/dsh-client-connection/client'
+// Value import from the inline-safe wire layer (not the connection plugin):
+// plugin-to-plugin value imports are a bundle purity error.
+import { transportError } from '@deepseek-ai/dsh-host-apiproxy/api'
 import type { SessionListEntry, TitledSessionSummary } from './lineage.ts'
 import { flattenLineage } from './lineage.ts'
 import { Notifier } from './notifier.ts'

+ 12 - 0
packages/client/runtime/src/client/sessions/service.ts

@@ -168,6 +168,18 @@ export class SessionsService {
     return this.resolve(id)?.ctx
   }
 
+  /**
+   * Read the session scope tag off a context. Service-method seam: fetch
+   * bundles must reach scope resolution through ctx.sessions — a cross-bundle
+   * value import of the standalone helper would inline a second module
+   * instance whose private tag Symbol never matches.
+   * @param ctx - any client context.
+   * @returns the session id, or undefined on root contexts.
+   */
+  scopeOf(ctx: Context): SessionId | undefined {
+    return scopeOf(ctx)
+  }
+
   /**
    * Resolve the stable session binding (scope-addressed assembly feed). Pure
    * resolution — no staging, no window side effects.

+ 3 - 1
packages/client/runtime/src/client/sessions/session.ts

@@ -9,7 +9,9 @@ import type {
   HistoryEntry, IApiClient, MuxFrame, RpcError, RpcId, RpcResult,
   SessionId, ToolEventView,
 } from '@deepseek-ai/dsh-client-connection/client'
-import { transportError } from '@deepseek-ai/dsh-client-connection/client'
+// Value import from the inline-safe wire layer (not the connection plugin):
+// plugin-to-plugin value imports are a bundle purity error.
+import { transportError } from '@deepseek-ai/dsh-host-apiproxy/api'
 import type { ObservableSnapshot } from '../contract/store.ts'
 import type {
   ConversationNode, ConversationSnapshot, OpenState, PromptError, RunningToolCall,

+ 29 - 34
packages/client/tsdown.client.ts

@@ -1,6 +1,6 @@
 /**
  * Shared tsdown preset for UI plugin client bundles. Emits a closure-factory
- * artifact: the bundle calls window.DSHClientProxy.loadPlugin({id, factory})
+ * artifact: the bundle calls window.__ModuleLoader__.load({id, factory})
  * and resolves externals through the injected require (loader module table —
  * cordis DI entities, no globals, no import map). CSS Modules are compiled by
  * lightningcss inside the bundle: importing `x.module.css` yields the
@@ -11,6 +11,7 @@ import { readFile } from 'node:fs/promises'
 import { basename, dirname, resolve as resolvePath } from 'node:path'
 import type { UserConfig } from 'tsdown'
 import { transform } from 'lightningcss'
+import { PLATFORM_MODULES } from './web/src/platform.ts'
 
 /**
  * Virtual-id wrapper keeping module CSS away from tsdown's own css pipeline
@@ -28,22 +29,20 @@ const CSS_VIRTUAL_SUFFIX = '.mjs'
  */
 export const INLINE_SAFE = /^@deepseek-ai\/dsh-(host-apiproxy|session|llm|tools|brand)(\/|$)/
 
-/** Externals resolved from the loader module table (keep in sync with the shell's seeding list). */
-export const CLIENT_EXTERNALS = [
-  'react',
-  'react-dom',
-  'react/jsx-runtime',
-  'cordis',
-  '@deepseek-ai/dsh-client-ui-slots',
-  '@deepseek-ai/dsh-client-web-react',
-  '@deepseek-ai/dsh-client-ui-primitives',
-  '@deepseek-ai/dsh-client-connection/client',
-  '@deepseek-ai/dsh-client-runtime/client',
-  '@deepseek-ai/dsh-client-ui-layout/client',
-  '@deepseek-ai/dsh-client-ui-conversation/client',
-  '@deepseek-ai/dsh-client-ui-theme/client',
-  '@deepseek-ai/dsh-client-i18n/client',
-]
+/**
+ * Documented TEMPORARY exemption, not a platform module (hence not in
+ * platform.ts): the snapshot-store engine (createSnapshotStore/defineStore/
+ * shallowEqual) lives in runtime pending its promotion-time rehoming, and
+ * five importers (i18n, ui-layout, ui-conversation ×3) ride this single
+ * exemption. At runtime the lazy CJS table answers the require natively:
+ * runtime is an immediately-tier row, its factory is registered before any
+ * dependent bundle materializes. TODO(webload/store-rehome): remove with the
+ * store-engine relocation follow-up.
+ */
+const RUNTIME_STORE_EXEMPTION = '@deepseek-ai/dsh-client-runtime/client'
+
+/** Externals resolved from the loader module table: the platform seed entries plus the documented runtime exemption. */
+export const CLIENT_EXTERNALS: readonly string[] = [...PLATFORM_MODULES, RUNTIME_STORE_EXEMPTION]
 
 /**
  * Build the tsdown config for one UI plugin package: the node-half lib build
@@ -51,8 +50,8 @@ export const CLIENT_EXTERNALS = [
  * the root workspace shape, so the lib half must be restated here — dropping
  * it leaves the package without lib/index.js and the host Loader cannot
  * import its node half.
- * @param id - plugin id (package name), stamped into the loadPlugin handoff
- * and onto the injected style tags.
+ * @param id - plugin id (package name), stamped into the __ModuleLoader__.load
+ * handoff and onto the injected style tags.
  * @param libEntry - node-half entries, spelled at the call site so the
  * package-invariants gate can see `lib/types/invariant.js` in each package's
  * own tsdown.config.ts (a preset-side glob hides it from the mechanical check).
@@ -79,7 +78,7 @@ export function clientBundle(id: string, libEntry: readonly string[]): UserConfi
     // Types ship from lib/types (tsc); dts here would wrap the banner/footer into .d.cts and break parsing.
     dts: false,
     clean: false,
-    external: CLIENT_EXTERNALS,
+    external: [...CLIENT_EXTERNALS],
     // Browser bundles inline node-idiom deps (zustand/immer read
     // process.env.NODE_ENV; zustand's esm build also probes
     // import.meta.env.MODE, which a CJS output cannot carry — rolldown flags
@@ -102,24 +101,20 @@ export function clientBundle(id: string, libEntry: readonly string[]): UserConfi
     // opinion for table entries (external above wins), bundle everything else.
     noExternal: (id: string) => (CLIENT_EXTERNALS.includes(id) ? undefined : true),
     plugins: [{
-      // Bundle purity gate: a bare-name import of a module-table package would
-      // slip past CLIENT_EXTERNALS (which lists the /client form) and INLINE a
-      // second copy of that package — duplicate runtime identity (a second
-      // scope Symbol was tonight's white-screen root cause). Resolve-time is
-      // the earliest, most precise interception: rewrite bare table names to
-      // their /client form (the loader registers both specifiers), and reject
-      // any other @deepseek-ai/* leak that is not an inline-safe wire layer.
+      // Bundle purity gate (build-time mirror of the module-edge rules):
+      // platform seed entries stay external, inline-safe wire layers inline,
+      // and every other @deepseek-ai value import is a build error — a
+      // cross-plugin value import either inlines a duplicate runtime instance
+      // or requires a specifier the frozen module table cannot answer.
+      // Cross-plugin collaboration goes through cordis services instead.
       name: 'dsh-client-bundle-purity',
       resolveId(source: string) {
         if (!source.startsWith('@deepseek-ai/')) return null
-        if (CLIENT_EXTERNALS.includes(source)) return null // external wins
-        if (CLIENT_EXTERNALS.includes(`${source}/client`)) {
-          return { id: `${source}/client`, external: true }
-        }
+        if (CLIENT_EXTERNALS.includes(source)) return null // platform module: external wins
         if (INLINE_SAFE.test(source)) return null // wire/type layer: inline is the point
         throw new Error(
-          `client bundle purity: "${source}" is not in CLIENT_EXTERNALS and not an inline-safe wire layer — `
-          + 'import the /client form, add it to the module table, or it inlines a duplicate runtime instance',
+          `client bundle purity: "${source}" is not a platform module (CLIENT_EXTERNALS) and not an inline-safe wire layer — `
+          + 'cross-plugin value imports are forbidden; collaborate through cordis services (type-only imports are erased and never reach this gate)',
         )
       },
     }, {
@@ -158,7 +153,7 @@ export function clientBundle(id: string, libEntry: readonly string[]): UserConfi
     }],
     outputOptions: {
       entryFileNames: 'client.js',
-      banner: `window.DSHClientProxy.loadPlugin({ id: ${JSON.stringify(id)}, factory: (require) => {`,
+      banner: `window.__ModuleLoader__.load({ id: ${JSON.stringify(id)}, factory: (require) => {`,
       footer: `return module.exports; } });`,
       intro: 'var module = { exports: {} }; var exports = module.exports;',
     },

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

@@ -24,6 +24,8 @@
   },
   "dshClient": {
     "inject": [
+      "@deepseek-ai/dsh-client-i18n",
+      "@deepseek-ai/dsh-client-runtime",
       "@deepseek-ai/dsh-client-ui-layout"
     ],
     "platform": "web"
@@ -34,21 +36,27 @@
   },
   "license": "BSD-3-Clause",
   "dependencies": {
-    "@deepseek-ai/dsh-client-runtime": "workspace:^",
-    "@deepseek-ai/dsh-client-ui-layout": "workspace:^",
-    "@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
-    "@deepseek-ai/dsh-client-ui-slots": "workspace:^",
-    "clsx": "^2.0.0",
-    "react": "^18.2.0"
+    "clsx": "^2.0.0"
   },
   "peerDependencies": {
+    "@deepseek-ai/dsh-client-i18n": "^0.0.1",
+    "@deepseek-ai/dsh-client-runtime": "^0.0.1",
+    "@deepseek-ai/dsh-client-ui-primitives": "^0.0.1",
+    "@deepseek-ai/dsh-client-ui-slots": "^0.0.1",
     "@deepseek-ai/dsh-invariants": "^0.0.1",
-    "cordis": "^4.0.0-rc.7"
+    "cordis": "^4.0.0-rc.7",
+    "react": "^18.2.0"
   },
   "devDependencies": {
+    "@deepseek-ai/dsh-client-i18n": "workspace:^",
+    "@deepseek-ai/dsh-client-runtime": "workspace:^",
+    "@deepseek-ai/dsh-client-ui-layout": "workspace:^",
+    "@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
+    "@deepseek-ai/dsh-client-ui-slots": "workspace:^",
     "@deepseek-ai/dsh-invariants": "workspace:^",
     "@types/react": "~18.3.1",
-    "cordis": "^4.0.0-rc.7"
+    "cordis": "^4.0.0-rc.7",
+    "react": "^18.2.0"
   },
   "files": [
     "lib/index.js",

+ 12 - 10
packages/client/ui-conversation/src/client/service.ts

@@ -15,14 +15,10 @@
  */
 import { Service } from 'cordis'
 import type { Context } from 'cordis'
-// Value import MUST use the /client subpath: only that specifier is in the
-// bundle externals (CLIENT_EXTERNALS), so it resolves to the shared runtime
-// module at load time. A bare-specifier value import gets INLINED as a second
-// module instance whose private scope-tag Symbol never matches the one
-// SessionsService tags contexts with — scopeOf then always returns undefined
-// in the browser while unit tests (single-instance path resolution) stay green.
-import { scopeOf } from '@deepseek-ai/dsh-client-runtime/client'
-import type { Session, SessionsService } from '@deepseek-ai/dsh-client-runtime/client'
+// Type-only imports: a plugin-to-plugin value import is a bundle purity
+// error, so scope resolution goes through the sessions service (scopeOf
+// method) instead of the standalone helper.
+import type { Session, SessionId, SessionsService } from '@deepseek-ai/dsh-client-runtime/client'
 
 /** Scope-addressed conversation service (root singleton, provided as `conversation`). */
 export class ConversationService extends Service {
@@ -83,11 +79,17 @@ export class ConversationService extends Service {
 
   /** Resolve the caller scope's Session or throw on root contexts. */
   private scopedSession(op: string): Session {
-    const id = scopeOf(this.ctx)
+    const id = this.scopeId(op)
+    return this.requireSessions().manager.get(id)
+  }
+
+  /** Read the caller's session scope tag via the sessions service; root contexts fail loud. */
+  private scopeId(op: string): SessionId {
+    const id = this.requireSessions().scopeOf(this.ctx)
     if (id === undefined) {
       throw new Error(`conversation.${op} requires a session scope — address one via ctx.sessions.scope(id).conversation`)
     }
-    return this.requireSessions().manager.get(id)
+    return id
   }
 
   private requireSessions(): SessionsService {

+ 1 - 0
packages/client/ui-conversation/tests/apply-inject.spec.tsx

@@ -76,6 +76,7 @@ async function bench() {
     manager: { get: () => sessionFake },
     scope: (id: SessionId) => mint(id),
     cell: () => undefined,
+    scopeOf,
     create: vi.fn(() => Promise.resolve(ROOT)),
     open: vi.fn(),
   }

+ 1 - 0
packages/client/ui-conversation/tests/service-orchestration.spec.ts

@@ -67,6 +67,7 @@ async function bench(opts?: { sessions?: boolean }) {
     create: createMock,
     open: openMock,
     scope: (id: SessionId) => (id === sid('new-1') ? mint(id) : scopes.get(id)),
+    scopeOf,
   } as unknown as SessionsService
   if (opts?.sessions !== false) ctx.provide('sessions', sessionsFake)
   // Class-plugin mount — the same form apply.ts uses in production.

+ 8 - 7
packages/client/ui-layout/package.json

@@ -33,19 +33,20 @@
     "watch": "tsdown --watch"
   },
   "license": "BSD-3-Clause",
-  "dependencies": {
-    "@deepseek-ai/dsh-client-runtime": "workspace:^",
-    "@deepseek-ai/dsh-client-ui-slots": "workspace:^",
-    "react": "^18.2.0"
-  },
   "peerDependencies": {
+    "@deepseek-ai/dsh-client-runtime": "^0.0.1",
+    "@deepseek-ai/dsh-client-ui-slots": "^0.0.1",
     "@deepseek-ai/dsh-invariants": "^0.0.1",
-    "cordis": "^4.0.0-rc.7"
+    "cordis": "^4.0.0-rc.7",
+    "react": "^18.2.0"
   },
   "devDependencies": {
+    "@deepseek-ai/dsh-client-runtime": "workspace:^",
+    "@deepseek-ai/dsh-client-ui-slots": "workspace:^",
     "@deepseek-ai/dsh-invariants": "workspace:^",
     "@types/react": "~18.3.1",
-    "cordis": "^4.0.0-rc.7"
+    "cordis": "^4.0.0-rc.7",
+    "react": "^18.2.0"
   },
   "files": [
     "lib/index.js",

+ 13 - 8
packages/client/ui-sidebar/package.json

@@ -24,6 +24,7 @@
   },
   "dshClient": {
     "inject": [
+      "@deepseek-ai/dsh-client-runtime",
       "@deepseek-ai/dsh-client-ui-layout"
     ],
     "platform": "web"
@@ -34,21 +35,25 @@
   },
   "license": "BSD-3-Clause",
   "dependencies": {
-    "@deepseek-ai/dsh-client-runtime": "workspace:^",
-    "@deepseek-ai/dsh-client-ui-layout": "workspace:^",
-    "@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
-    "@deepseek-ai/dsh-client-ui-slots": "workspace:^",
-    "clsx": "^2.0.0",
-    "react": "^18.2.0"
+    "clsx": "^2.0.0"
   },
   "peerDependencies": {
+    "@deepseek-ai/dsh-client-runtime": "^0.0.1",
+    "@deepseek-ai/dsh-client-ui-primitives": "^0.0.1",
+    "@deepseek-ai/dsh-client-ui-slots": "^0.0.1",
     "@deepseek-ai/dsh-invariants": "^0.0.1",
-    "cordis": "^4.0.0-rc.7"
+    "cordis": "^4.0.0-rc.7",
+    "react": "^18.2.0"
   },
   "devDependencies": {
+    "@deepseek-ai/dsh-client-runtime": "workspace:^",
+    "@deepseek-ai/dsh-client-ui-layout": "workspace:^",
+    "@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
+    "@deepseek-ai/dsh-client-ui-slots": "workspace:^",
     "@deepseek-ai/dsh-invariants": "workspace:^",
     "@types/react": "~18.3.1",
-    "cordis": "^4.0.0-rc.7"
+    "cordis": "^4.0.0-rc.7",
+    "react": "^18.2.0"
   },
   "files": [
     "lib/index.js",

+ 0 - 4
packages/client/ui-theme/package.json

@@ -28,10 +28,6 @@
     "platform": "web",
     "immediately": true
   },
-  "scripts": {
-    "bundle": "tsdown",
-    "watch": "tsdown --watch"
-  },
   "license": "BSD-3-Clause",
   "peerDependencies": {
     "@deepseek-ai/dsh-invariants": "^0.0.1",

+ 6 - 6
packages/client/ui-trajectory/package.json

@@ -33,19 +33,19 @@
     "watch": "tsdown --watch"
   },
   "license": "BSD-3-Clause",
-  "dependencies": {
-    "@deepseek-ai/dsh-client-ui-conversation": "workspace:^",
-    "react": "^18.2.0"
-  },
   "peerDependencies": {
     "@deepseek-ai/dsh-invariants": "^0.0.1",
-    "cordis": "^4.0.0-rc.7"
+    "cordis": "^4.0.0-rc.7",
+    "react": "^18.2.0"
   },
   "devDependencies": {
     "@deepseek-ai/dsh-client-runtime": "workspace:^",
+    "@deepseek-ai/dsh-client-ui-conversation": "workspace:^",
+    "@deepseek-ai/dsh-client-ui-slots": "workspace:^",
     "@deepseek-ai/dsh-invariants": "workspace:^",
     "@types/react": "~18.3.1",
-    "cordis": "^4.0.0-rc.7"
+    "cordis": "^4.0.0-rc.7",
+    "react": "^18.2.0"
   },
   "files": [
     "lib/index.js",

+ 4 - 4
packages/client/ui-trajectory/tests/client-bundle.spec.ts

@@ -1,7 +1,7 @@
 // @vitest-environment jsdom
 /**
  * Real tsdown artifact shape: lib/client.js hands off through
- * window.DSHClientProxy.loadPlugin, resolves externals through the injected
+ * window.__ModuleLoader__.load, resolves externals through the injected
  * require, returns the export surface (apply + inject), and a mounted apply
  * registers both view tabs into a real SlotsService ring. Skips when dist/ is
  * not built (`pnpm --filter @deepseek-ai/dsh-client-ui-trajectory bundle`).
@@ -15,7 +15,7 @@ import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client'
 const PLUGIN_ID = '@deepseek-ai/dsh-client-ui-trajectory'
 
 interface Handoff { id: string; factory: (require: (spec: string) => unknown) => Record<string, unknown> }
-type Win = { DSHClientProxy?: { loadPlugin(h: Handoff): void } }
+type Win = { __ModuleLoader__?: { load(h: Handoff): void } }
 
 function readBundle(): string | undefined {
   try {
@@ -28,7 +28,7 @@ function readBundle(): string | undefined {
 }
 
 afterEach(() => {
-  delete (window as Win).DSHClientProxy
+  delete (window as Win).__ModuleLoader__
   for (const el of document.querySelectorAll('style')) el.remove()
 })
 
@@ -37,7 +37,7 @@ describe('tsdown client artifact', () => {
 
   async function loadArtifact() {
     let handoff: Handoff | undefined
-    ;(window as Win).DSHClientProxy = { loadPlugin: (h) => { handoff = h } }
+    ;(window as Win).__ModuleLoader__ = { load: (h) => { handoff = h } }
     // Same execution form the loader uses (inline script eval, window scope) —
     // the implied-eval ban targets accidental string execution, not this
     // deliberate bundle-execution fixture.

+ 1 - 1
packages/host/apiproxy/src/api/index.ts

@@ -39,7 +39,7 @@ export type {
 } from './rpc.ts'
 
 // ---- Errors and ids ----
-export { RpcId } from './rpc.ts'
+export { RpcId, transportError } from './rpc.ts'
 export type { RpcError, RpcErrorCode, RpcErrorDetailsMap, RpcResult } from './rpc.ts'
 
 // ---- Method registry and derived generics ----

+ 14 - 0
packages/host/apiproxy/src/api/rpc.ts

@@ -50,6 +50,20 @@ export type RpcError = {
 /** Business success/failure result: the result slot of a unary response; methods never throw business errors. */
 export type RpcResult<T> = { ok: true; value: T } | { ok: false; error: RpcError }
 
+/**
+ * Fold a transport exception into the RpcResult error branch (unified error
+ * surface; 'internal' as the catch-all code). Lives with RpcResult so every
+ * carrier consumer folds the same way.
+ * @param error - the thrown value from the carrier.
+ * @returns the error branch of an RpcResult.
+ */
+export function transportError<T>(error: unknown): RpcResult<T> {
+  return {
+    ok: false,
+    error: { code: 'internal', message: error instanceof Error ? error.message : String(error), details: {} },
+  }
+}
+
 /**
  * Signature-layer narrow form, request side (domain-interface view, shared by
  * both directions): rpcId is explicit in the signature, never mixed into the

+ 22 - 20
scripts/client-bundle-purity.spec.ts

@@ -1,9 +1,10 @@
 /**
- * Pins the client-bundle purity gate (tsdown preset resolveId classifier):
- * a bare-name import of a module-table package must rewrite to its /client
- * external form (inlining it duplicates runtime identity — the P0
-/* leak that is not an
- * inline-safe wire layer must fail the build loudly.
+ * Pins the client-bundle purity gate (tsdown preset resolveId classifier),
+ * the build-time mirror of the module-edge rules: platform module-table
+ * entries stay external, inline-safe wire layers inline, and every other
+ * @deepseek-ai value import — including a bare plugin-package name and a
+ * cross-plugin /client subpath — must fail the build loudly (cross-plugin
+ * collaboration goes through cordis services, never module imports).
  */
 import { describe, expect, it } from 'vitest'
 import { CLIENT_EXTERNALS, clientBundle } from '../packages/client/tsdown.client.ts'
@@ -23,22 +24,16 @@ function purityResolveId(): ResolveId {
 describe('client bundle purity gate', () => {
   const resolveId = purityResolveId()
 
-  it('leaves table entries and non-scoped specifiers alone', () => {
+  it('leaves platform table entries and non-scoped specifiers alone', () => {
     expect(resolveId('@deepseek-ai/dsh-client-ui-slots')).toBeNull()
-    expect(resolveId('@deepseek-ai/dsh-client-runtime/client')).toBeNull()
+    expect(resolveId('@deepseek-ai/dsh-client-web-react')).toBeNull()
+    expect(resolveId('@deepseek-ai/dsh-client-ui-primitives')).toBeNull()
     expect(resolveId('react')).toBeNull()
     expect(resolveId('zod')).toBeNull()
   })
 
-  it('rewrites a bare table-package name to its external /client form (duplicate-instance prevention)', () => {
-    expect(resolveId('@deepseek-ai/dsh-client-connection')).toEqual({
-      id: '@deepseek-ai/dsh-client-connection/client',
-      external: true,
-    })
-    expect(resolveId('@deepseek-ai/dsh-client-ui-layout')).toEqual({
-      id: '@deepseek-ai/dsh-client-ui-layout/client',
-      external: true,
-    })
+  it('rejects retired table entries (web-react/store left the 8-entry seed)', () => {
+    expect(() => resolveId('@deepseek-ai/dsh-client-web-react/store')).toThrow(/purity/)
   })
 
   it('lets inline-safe wire layers inline', () => {
@@ -52,9 +47,16 @@ describe('client bundle purity gate', () => {
     expect(() => resolveId('@deepseek-ai/dsh-client-web')).toThrow(/purity/)
   })
 
-  it('every /client external has no bare-name twin in the table (the rewrite assumption)', () => {
-    for (const entry of CLIENT_EXTERNALS) {
-      if (entry.endsWith('/client')) expect(CLIENT_EXTERNALS).not.toContain(entry.slice(0, -'/client'.length))
-    }
+  it('throws on cross-plugin value imports — bare plugin names and /client subpaths alike (the rewrite arm is gone)', () => {
+    expect(() => resolveId('@deepseek-ai/dsh-client-connection')).toThrow(/purity/)
+    expect(() => resolveId('@deepseek-ai/dsh-client-runtime')).toThrow(/purity/)
+    expect(() => resolveId('@deepseek-ai/dsh-client-ui-layout/client')).toThrow(/purity/)
+  })
+
+  it('carries exactly one documented temporary exemption: runtime/client (store engine pending rehoming)', () => {
+    expect(resolveId('@deepseek-ai/dsh-client-runtime/client')).toBeNull()
+    const dshClientChannels = CLIENT_EXTERNALS.filter(
+      entry => entry.startsWith('@deepseek-ai/') && entry.endsWith('/client'))
+    expect(dshClientChannels).toEqual(['@deepseek-ai/dsh-client-runtime/client'])
   })
 })