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)).
This commit is contained in:
imccyu
2026-07-23 21:55:59 +08:00
parent b58f0989f9
commit c2e0c16d6a
18 changed files with 152 additions and 125 deletions

View File

@@ -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: {} },
}
}

View File

@@ -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:^"

View File

@@ -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'

View File

@@ -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.

View File

@@ -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,

View File

@@ -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;',
},

View File

@@ -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": {
"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",
"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:^",
"clsx": "^2.0.0",
"react": "^18.2.0"
},
"peerDependencies": {
"@deepseek-ai/dsh-invariants": "^0.0.1",
"cordis": "^4.0.0-rc.7"
},
"devDependencies": {
"@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",

View File

@@ -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 {

View File

@@ -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(),
}

View File

@@ -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.

View File

@@ -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:^",
"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",
"react": "^18.2.0"
},
"peerDependencies": {
"@deepseek-ai/dsh-invariants": "^0.0.1",
"cordis": "^4.0.0-rc.7"
},
"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",

View File

@@ -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": {
"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",
"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:^",
"clsx": "^2.0.0",
"react": "^18.2.0"
},
"peerDependencies": {
"@deepseek-ai/dsh-invariants": "^0.0.1",
"cordis": "^4.0.0-rc.7"
},
"devDependencies": {
"@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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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.

View File

@@ -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 ----

View File

@@ -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

View File

@@ -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'])
})
})