From 8088d2a6a084ff1543c57cbba8cdea06bf42bf57 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Mon, 17 Aug 2026 21:59:51 +0800 Subject: [PATCH] fix(client): harden bootstrap invariants --- ...2026-08-10-remote-event-delivery.i18n.yaml | 4 +- .../2026-08-10-remote-event-delivery.md | 2 +- .../2026-08-10-remote-event-delivery.zh.md | 2 +- ...ient-shells-and-dynamic-packages.i18n.yaml | 4 +- ...8-15-client-shells-and-dynamic-packages.md | 2 +- ...5-client-shells-and-dynamic-packages.zh.md | 2 +- packages/client/modules/src/client/system.ts | 20 ++- packages/client/modules/src/index.ts | 8 +- .../modules/tests/loader.client.spec.ts | 15 ++ packages/client/web/src/boot.ts | 28 +-- packages/client/web/tests/boot.client.spec.ts | 98 +++++++++++ scripts/verify-client-packages.spec.ts | 85 +++++++++ scripts/verify-client-packages.ts | 165 ++++++++++++++---- 13 files changed, 368 insertions(+), 67 deletions(-) create mode 100644 packages/client/web/tests/boot.client.spec.ts diff --git a/.agents/notes/implemented/architecture/2026-08-10-remote-event-delivery.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-10-remote-event-delivery.i18n.yaml index 2ab50fbd02..452705c2ba 100644 --- a/.agents/notes/implemented/architecture/2026-08-10-remote-event-delivery.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-10-remote-event-delivery.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-10-remote-event-delivery.md -2026-08-10-remote-event-delivery.md: 2b8c2b03b96c6337d1ec70ce1d91746abb4ef743 -2026-08-10-remote-event-delivery.zh.md: 00e580b7e6a71999fd0f202a1cfca2713df388ae +2026-08-10-remote-event-delivery.md: 9c2b5087772a5a343514d1766a14e90edb261813 +2026-08-10-remote-event-delivery.zh.md: 213715f5d9efcc11290059e5c5b0c06bbd7e255d diff --git a/.agents/notes/implemented/architecture/2026-08-10-remote-event-delivery.md b/.agents/notes/implemented/architecture/2026-08-10-remote-event-delivery.md index 2b8c2b03b9..9c2b508777 100644 --- a/.agents/notes/implemented/architecture/2026-08-10-remote-event-delivery.md +++ b/.agents/notes/implemented/architecture/2026-08-10-remote-event-delivery.md @@ -56,7 +56,7 @@ $on(event: Event, listener: Events[Event]): () `Events` resolves per program: the full Host vocabulary in the Host program, whatever the Client face can see in the Client program. The same predicate therefore holds on both sides without dragging Host declarations into the Client. -**The surface separates the consumer verb from the carrier handoff**: consumers subscribe with `$on`, and whoever owns the Host frame sink hands each decoded frame over with `$dispatch`. It cannot be a module-level function reaching across Client plugins — the client bundle purity gate (`packages/client/tsdown.client.ts`) admits value imports only from `CLIENT_EXTERNALS`, the `INLINE_SAFE` wire layer, and generated `/remote` contributions, and inlining around it would copy `ClientRemoteService` into the runtime bundle, making `instanceof` permanently false. A cordis service method is the collaboration shape that gate prescribes: +**The surface separates the consumer verb from the carrier handoff**: consumers subscribe with `$on`, and whoever owns the Host frame sink hands each decoded frame over with `$dispatch`. It cannot be a module-level function reaching across Client plugins — the client bundle purity gate (`packages/client/tsdown.client.ts`) admits value imports only from the implicit `PLATFORM_MODULES` plus `PRELOADED_CLIENT_EXTERNALS` baseline, the package's `dsh.client.external` requests, the `INLINE_SAFE` wire layer, and generated `/remote` contributions. Inlining around it would copy `ClientRemoteService` into the runtime bundle, making `instanceof` permanently false. A cordis service method is the collaboration shape that gate prescribes: ```ts ignore-check $dispatch(event: string, args: readonly unknown[]): void diff --git a/.agents/notes/implemented/architecture/2026-08-10-remote-event-delivery.zh.md b/.agents/notes/implemented/architecture/2026-08-10-remote-event-delivery.zh.md index 00e580b7e6..213715f5d9 100644 --- a/.agents/notes/implemented/architecture/2026-08-10-remote-event-delivery.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-10-remote-event-delivery.zh.md @@ -56,7 +56,7 @@ $on(event: Event, listener: Events[Event]): () `Events` 按程序解析:host 程序里是 host 事件全集,client 程序里是 client 编译面看得见的那些——同一个谓词在两侧各自成立,不需要把 host 声明拖进 client。 -**契约把消费动词与载体交接分开**:消费方用 `$on` 订阅,持有 host 帧 sink 的一方用 `$dispatch` 把解码后的帧交进来。它**不能**是一个跨插件的模块级函数:client bundle 纯度门禁(`packages/client/tsdown.client.ts`)只放行 `CLIENT_EXTERNALS`、`INLINE_SAFE` 那层 wire 契约与 `/remote` 生成物三类值导入,而靠 inline 绕过会把 `ClientRemoteService` 复制一份进 runtime bundle、令 `instanceof` 恒假。cordis 服务方法正是该门禁指定的协作形态: +**契约把消费动词与载体交接分开**:消费方用 `$on` 订阅,持有 host 帧 sink 的一方用 `$dispatch` 把解码后的帧交进来。它**不能**是一个跨插件的模块级函数:client bundle 纯度门禁(`packages/client/tsdown.client.ts`)只放行隐式的 `PLATFORM_MODULES` 加 `PRELOADED_CLIENT_EXTERNALS` 基座、包自身的 `dsh.client.external` 请求、`INLINE_SAFE` wire 层与 `/remote` 生成物值导入。靠 inline 绕过会把 `ClientRemoteService` 复制一份进 runtime bundle、令 `instanceof` 恒假。cordis 服务方法正是该门禁指定的协作形态: ```ts ignore-check $dispatch(event: string, args: readonly unknown[]): void diff --git a/.agents/notes/implemented/architecture/2026-08-15-client-shells-and-dynamic-packages.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-15-client-shells-and-dynamic-packages.i18n.yaml index 8a3ee33511..087c965963 100644 --- a/.agents/notes/implemented/architecture/2026-08-15-client-shells-and-dynamic-packages.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-15-client-shells-and-dynamic-packages.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-15-client-shells-and-dynamic-packages.md -2026-08-15-client-shells-and-dynamic-packages.md: d9e2ddfaa169b12aa4bcd23b9ba215da463f8358 -2026-08-15-client-shells-and-dynamic-packages.zh.md: 31cb4b88c4b161c52b1746756b6cabc6d089f63b +2026-08-15-client-shells-and-dynamic-packages.md: f71a53b65030106d9289931aac52c2dfce4be181 +2026-08-15-client-shells-and-dynamic-packages.zh.md: d185c62e8e7ac004f064b32f60f68327176009bc diff --git a/.agents/notes/implemented/architecture/2026-08-15-client-shells-and-dynamic-packages.md b/.agents/notes/implemented/architecture/2026-08-15-client-shells-and-dynamic-packages.md index d9e2ddfaa1..f71a53b650 100644 --- a/.agents/notes/implemented/architecture/2026-08-15-client-shells-and-dynamic-packages.md +++ b/.agents/notes/implemented/architecture/2026-08-15-client-shells-and-dynamic-packages.md @@ -61,7 +61,7 @@ Every client package keeps Cordis in matching `peerDependencies` and `devDepende Ordinary installed libraries remain `dependencies`: a dynamic build may bundle a private implementation, while a `staticLinked` library retains its bare import for the final host. Each build face decides externality independently from npm sections. Published file lists cover every runtime entry, relative asset, and declaration file reached by the artifact. -`verify-client-packages` enforces these classifications, build forms, shared-module requests, publication closure, and module-graph acyclicity. Its `--fix` mode repairs only unambiguous manifest and build-config drift. +`verify-client-packages` enforces these classifications, dependency sections, build forms, parser-preload alignment, shared-module requests, and module-graph acyclicity. The repository publint pass enforces publication closure. The verifier's `--fix` mode repairs only unambiguous manifest drift. ## Alternatives considered diff --git a/.agents/notes/implemented/architecture/2026-08-15-client-shells-and-dynamic-packages.zh.md b/.agents/notes/implemented/architecture/2026-08-15-client-shells-and-dynamic-packages.zh.md index 31cb4b88c4..d185c62e8e 100644 --- a/.agents/notes/implemented/architecture/2026-08-15-client-shells-and-dynamic-packages.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-15-client-shells-and-dynamic-packages.zh.md @@ -61,7 +61,7 @@ Modules Node 半按以下顺序向实际返回的 HTML 注入启动协议: 普通安装库仍放在 `dependencies`:动态构建可以内联私有实现,而 `staticLinked` 库会保留 bare import 交给最终宿主。各构建 face 独立决定 external,不由 npm 区段推导。发布文件列表覆盖产物实际可达的每个运行期入口、相对资产和声明文件。 -`verify-client-packages` 会检查这些分类、构建形态、共享模块请求、发布闭包和模块图无环性。其 `--fix` 模式只修复无歧义的 manifest 与构建配置漂移。 +`verify-client-packages` 会检查这些分类、依赖区段、构建形态、parser preload 对齐、共享模块请求和模块图无环性。仓库 publint pass 负责检查发布闭包。该验证器的 `--fix` 模式只修复无歧义的 manifest 漂移。 ## Alternatives considered diff --git a/packages/client/modules/src/client/system.ts b/packages/client/modules/src/client/system.ts index 79c3c0a2b8..0e4a7b322b 100644 --- a/packages/client/modules/src/client/system.ts +++ b/packages/client/modules/src/client/system.ts @@ -114,7 +114,7 @@ export class ClientModuleSystem implements ClientModuleLoader { return task } - /** Register every dynamic request before registering its consumer. */ + /** Register each unresolved dynamic request before registering its consumer. */ private async arriveGraphRow(row: BootModuleRow, open: readonly string[] = []): Promise { const cycleStart = open.indexOf(row.id) if (cycleStart !== -1) { @@ -125,6 +125,7 @@ export class ClientModuleSystem implements ClientModuleLoader { } const next = [...open, row.id] for (const request of row.external) { + if (this.seed.has(request) || this.bootstrapModuleKey(request) !== undefined) continue const dependency = this.graphRows.get(stripClientSuffix(request)) if (dependency !== undefined) await this.arriveGraphRow(dependency, next) } @@ -163,7 +164,8 @@ export class ClientModuleSystem implements ClientModuleLoader { return (spec: string): unknown => { edges.add(spec) if (this.seed.has(spec)) return this.seed.get(spec) - if (this.statics.has(spec)) return this.statics.get(spec) + const bootstrapKey = this.bootstrapModuleKey(spec) + if (bootstrapKey !== undefined) return this.statics.get(bootstrapKey) const id = stripClientSuffix(spec) const record = this.loadCache.get(id) if (record !== undefined) return record.exports @@ -179,8 +181,9 @@ export class ClientModuleSystem implements ClientModuleLoader { if (this.seed.has(specifier)) return this.seed.get(specifier) const existing = this.loadCache.get(specifier) if (existing !== undefined) return existing.exports - if (this.statics.has(specifier)) { - const exports = this.statics.get(specifier) + const bootstrapKey = this.bootstrapModuleKey(specifier) + if (bootstrapKey !== undefined) { + const exports = this.statics.get(bootstrapKey) this.loadCache.set(specifier, { id: specifier, exports, styles: [], edges: new Set() }) return exports } @@ -202,7 +205,7 @@ export class ClientModuleSystem implements ClientModuleLoader { } async prefetch(id: string): Promise { - if (this.statics.has(id)) return + if (this.bootstrapModuleKey(id) !== undefined) return const row = this.graphRows.get(id) if (row === undefined) throw new Error(`client-modules: prefetch("${id}") — not a graph entry`) await this.arriveGraphRow(row) @@ -212,4 +215,11 @@ export class ClientModuleSystem implements ClientModuleLoader { this.factories.delete(id) this.loadCache.delete(id) } + + /** Resolve a bootstrap package name or its client entrypoint onto the registered package row. */ + private bootstrapModuleKey(specifier: string): string | undefined { + if (this.statics.has(specifier)) return specifier + const id = stripClientSuffix(specifier) + return id !== specifier && this.statics.has(id) ? id : undefined + } } diff --git a/packages/client/modules/src/index.ts b/packages/client/modules/src/index.ts index ac030ea37f..2381423280 100644 --- a/packages/client/modules/src/index.ts +++ b/packages/client/modules/src/index.ts @@ -506,10 +506,10 @@ export class ClientModuleRegistry extends Service { try { composed = this.compose() } catch (error) { - // An unorderable module graph (cycle, or one specifier claimed by two - // providers) is a property of the whole table, not of the arriving - // package, so it surfaces here: aggregated into the activation throw, or - // warned in steady state while the last orderable graph stays served. + // An unorderable module graph is a property of the whole table, not of + // the arriving package, so it surfaces here: aggregated into the + // activation throw, or warned in steady state while the last orderable + // graph stays served. onError(error as Error) return } diff --git a/packages/client/modules/tests/loader.client.spec.ts b/packages/client/modules/tests/loader.client.spec.ts index e43ea86b37..00f11b4450 100644 --- a/packages/client/modules/tests/loader.client.spec.ts +++ b/packages/client/modules/tests/loader.client.spec.ts @@ -218,6 +218,21 @@ describe('static registry', () => { expect(b.fetched).toEqual(['/plugins/a/client.js?rev=0']) }) + it('satisfies a graph request from a bootstrap package without reloading its row', async () => { + const shell = { marker: 'app-shell' } + const b = bench([ + row('consumer', { external: ['app-shell/client'] }), + row('app-shell'), + ], { + consumer: req => ({ dep: req('app-shell/client') }), + }) + b.loader.registerStatic('app-shell', shell) + const exports = await b.loader.import('consumer', '', {}) as { dep: unknown } + expect(exports.dep).toBe(shell) + expect(await b.loader.import('app-shell/client', '', {})).toBe(shell) + expect(b.fetched).toEqual(['/plugins/consumer/client.js?rev=0']) + }) + it('duplicate static registration is loud', () => { const b = bench([]) b.loader.registerStatic('app-shell', {}) diff --git a/packages/client/web/src/boot.ts b/packages/client/web/src/boot.ts index 28004033e5..d2aa89a086 100644 --- a/packages/client/web/src/boot.ts +++ b/packages/client/web/src/boot.ts @@ -83,21 +83,21 @@ export class AppWebEntry { * @returns Resolves after application mount or failure rendering. */ async run(): Promise { - const win = globalThis as DshWindow - const modulesClient = claimModulesClient(win) - this.manifest = modulesClient.parseBootManifest(win.__DSH_BOOT__) - this.modules = new modulesClient.ClientModuleSystem({ - modules: this.manifest.modules, - staticModules: getStaticModules(), - ...this.seams, - }) - this.modules.registerStatic(MODULES_ID, modulesClient) - win.__DSH_MODULES__ = this.modules - - const prefetching = this.prefetchImmediateTier() - const ctx = new Context() - this.ctx = ctx try { + const win = globalThis as DshWindow + const modulesClient = claimModulesClient(win) + this.manifest = modulesClient.parseBootManifest(win.__DSH_BOOT__) + this.modules = new modulesClient.ClientModuleSystem({ + modules: this.manifest.modules, + staticModules: getStaticModules(), + ...this.seams, + }) + this.modules.registerStatic(MODULES_ID, modulesClient) + win.__DSH_MODULES__ = this.modules + + const prefetching = this.prefetchImmediateTier() + const ctx = new Context() + this.ctx = ctx await this.runPluginBoot(ctx, prefetching) await this.mountApp(ctx) } catch (reason) { diff --git a/packages/client/web/tests/boot.client.spec.ts b/packages/client/web/tests/boot.client.spec.ts new file mode 100644 index 0000000000..6632d9a0f6 --- /dev/null +++ b/packages/client/web/tests/boot.client.spec.ts @@ -0,0 +1,98 @@ +// @vitest-environment jsdom +import * as modulesClient from '@deepseek-ai/dsh-client-modules/client' +import type { + ClientModuleHandoffQueue, ClientPluginHandoff, DshWindow, +} from '@deepseek-ai/dsh-client-modules/client' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { AppWebEntry } from '../src/boot.ts' + +const MODULES_ID = '@deepseek-ai/dsh-client-modules' +const win = globalThis as DshWindow +const moduleFace = modulesClient as unknown as Record + +afterEach(() => { + vi.restoreAllMocks() + delete win.__DSH_BOOT__ + delete win.__DSH_MODULES__ + delete win.__ModuleLoader__ + document.body.innerHTML = '' +}) + +function installQueue(factory: ClientPluginHandoff['factory']): void { + const handoffs: ClientPluginHandoff[] = [{ id: MODULES_ID, factory }] + const queue: ClientModuleHandoffQueue = { + mode: 'queue', + handoffs, + load: (handoff) => { handoffs.push(handoff) }, + } + win.__ModuleLoader__ = queue +} + +async function expectBootFailure(setup: () => void, message: string): Promise { + const error = vi.spyOn(console, 'error').mockImplementation(() => {}) + const container = document.createElement('div') + document.body.append(container) + setup() + const entry = new AppWebEntry(container) + await entry.run() + expect(container.textContent).toContain(message) + expect(error).toHaveBeenCalledOnce() + await entry.dispose() +} + +describe('bootstrap failure rendering', () => { + it('renders a missing bootstrap queue', async () => { + await expectBootFailure( + () => { delete win.__ModuleLoader__ }, + 'window.__ModuleLoader__ bootstrap queue is missing', + ) + }) + + it('renders an already-live bootstrap target', async () => { + await expectBootFailure( + () => { win.__ModuleLoader__ = { mode: 'live', load: () => {} } }, + 'window.__ModuleLoader__ bootstrap queue is missing', + ) + }) + + it('renders a missing modules handoff', async () => { + await expectBootFailure(() => { + installQueue(() => moduleFace) + const queue = win.__ModuleLoader__ as ClientModuleHandoffQueue + queue.handoffs.splice(0) + }, `HTML did not preload ${MODULES_ID}/client.js`) + }) + + it('renders a bootstrap runtime external', async () => { + await expectBootFailure(() => { + installQueue((require) => { + require('react') + return moduleFace + }) + }, `${MODULES_ID}/client.js requested external "react"`) + }) + + it.each(['ClientModuleSystem', 'parseBootManifest', 'apply'] as const)( + 'renders a modules handoff missing %s', + async (missing) => { + await expectBootFailure(() => { + installQueue(() => ({ ...moduleFace, [missing]: undefined })) + }, `${MODULES_ID}/client.js did not export the bootstrap module face`) + }, + ) + + it('renders a malformed boot manifest', async () => { + await expectBootFailure(() => { + installQueue(() => moduleFace) + delete win.__DSH_BOOT__ + }, 'window.__DSH_BOOT__ is missing or not an object') + }) + + it('renders a module-system construction failure', async () => { + await expectBootFailure(() => { + installQueue(() => moduleFace) + const duplicate = { id: 'duplicate', url: '/duplicate/client.js', rev: '1' } + win.__DSH_BOOT__ = { rev: 'graph', entries: [duplicate, duplicate] } + }, 'duplicate graph entry "duplicate"') + }) +}) diff --git a/scripts/verify-client-packages.spec.ts b/scripts/verify-client-packages.spec.ts index a35d60ebc3..ca378f4b59 100644 --- a/scripts/verify-client-packages.spec.ts +++ b/scripts/verify-client-packages.spec.ts @@ -6,6 +6,7 @@ import { dirname, join } from 'node:path' import { afterEach, describe, expect, it } from 'vitest' import { collectClientPackageViolations, + collectRuntimeSourcePackageUses, collectSourcePackageUses, fixClientPackageManifests, readClientDeclarations, @@ -43,6 +44,7 @@ function pkg( ...declaration(short), staticLinked: false, sourceUses: {}, + runtimeSourceUses: {}, dependencies: {}, peerDependencies: { [CORDIS]: 'workspace:^' }, devDependencies: { [CORDIS]: 'workspace:^' }, @@ -62,6 +64,8 @@ function facts( ), platformModules: options.platformModules ?? [], preloadedExternals: options.preloadedExternals ?? [], + parserPreloadIds: options.parserPreloadIds + ?? (options.preloadedExternals ?? []).map(value => value.replace(/\/client$/, '')), malformed: options.malformed ?? [], } } @@ -82,6 +86,15 @@ describe('source package uses', () => { '@deepseek-ai/dsh-client-ui-slots', 'react', ]) + expect([...collectRuntimeSourcePackageUses('feature.tsx', [ + "import type { A } from '@deepseek-ai/dsh-a/subpath'", + "declare module '@deepseek-ai/dsh-client-ui-slots' {}", + "const load = () => import('@deepseek-ai/dsh-b')", + 'export const view =
', + ].join('\n'))].sort()).toEqual([ + '@deepseek-ai/dsh-b', + 'react', + ]) }) }) @@ -113,6 +126,19 @@ describe('package modes', () => { expect(found.join('\n')).toContain('does not use the staticLinked preset') expect(found.join('\n')).toContain('has no dynamic dsh.client row') }) + + it('requires every preloaded external to have a parser preload row', () => { + const runtime = declaration('runtime') + expect(collectClientPackageViolations(facts([], { + declarations: [runtime], + preloadedExternals: [runtime.name + '/client'], + parserPreloadIds: [], + }))).toEqual([ + 'packages/client/web/src/platform.ts: parser-preloaded external ' + + '"@deepseek-ai/dsh-client-runtime/client" has no matching PARSER_PRELOAD_IDS row in ' + + 'packages/client/modules/src/index.ts', + ]) + }) }) describe('dependency sections', () => { @@ -172,6 +198,39 @@ describe('dependency sections', () => { ]) }) + it('requires statically linked third-party runtime imports in dependencies', () => { + const primitives = pkg('ui-primitives', { + dynamic: false, + staticLinked: true, + runtimeSourceUses: { shiki: ['packages/client/ui-primitives/src/highlight.ts'] }, + devDependencies: { [CORDIS]: 'workspace:^', shiki: '^4.3.1' }, + }) + const found = collectClientPackageViolations(facts([primitives])) + expect(found).toHaveLength(1) + expect(found[0]).toContain('runtime import retained by a statically linked artifact') + expect(found[0]).toContain('declare it only in dependencies') + + const valid = { ...primitives, dependencies: { shiki: '^4.3.1' }, devDependencies: { [CORDIS]: 'workspace:^' } } + expect(collectClientPackageViolations(facts([valid]))).toEqual([]) + }) + + it('keeps the web shell runtime inputs development-only', () => { + const web = pkg('web', { + dynamic: false, + staticLinked: true, + runtimeSourceUses: { + '@deepseek-ai/cordis-plugin-loader': ['packages/client/web/src/boot.ts'], + react: ['packages/client/web/src/seed.ts'], + }, + devDependencies: { + [CORDIS]: 'workspace:^', + '@deepseek-ai/cordis-plugin-loader': 'workspace:^', + react: '^18.2.0', + }, + }) + expect(collectClientPackageViolations(facts([web]))).toEqual([]) + }) + it('allows npm dependency cycles', () => { const a = pkg('a', { peerDependencies: { [CORDIS]: 'workspace:^', '@deepseek-ai/dsh-client-b': 'workspace:^' }, @@ -314,4 +373,30 @@ describe('manifest declarations', () => { '@deepseek-ai/cordis-plugin-loader': 'workspace:^', }) }) + + it('fixes a statically linked runtime import into dependencies', () => { + const root = mkdtempSync(join(tmpdir(), 'client-packages-static-fix-')) + roots.push(root) + const subject = pkg('ui-primitives', { + dynamic: false, + staticLinked: true, + runtimeSourceUses: { shiki: ['packages/client/ui-primitives/src/highlight.ts'] }, + devDependencies: { [CORDIS]: 'workspace:^', shiki: '^4.3.1' }, + }) + mkdirSync(dirname(join(root, subject.manifest)), { recursive: true }) + writeFileSync(join(root, subject.manifest), JSON.stringify({ + name: subject.name, + peerDependencies: subject.peerDependencies, + devDependencies: subject.devDependencies, + })) + writeFileSync(join(root, 'package.json'), JSON.stringify({ private: true })) + + expect(fixClientPackageManifests(root, facts([subject]))).toEqual([subject.manifest]) + const fixed = JSON.parse(readFileSync(join(root, subject.manifest), 'utf8')) as { + dependencies: Record + devDependencies: Record + } + expect(fixed.dependencies).toEqual({ shiki: '^4.3.1' }) + expect(fixed.devDependencies).toEqual({ [CORDIS]: 'workspace:^' }) + }) }) diff --git a/scripts/verify-client-packages.ts b/scripts/verify-client-packages.ts index a5533b1489..cc1b56b5d4 100644 --- a/scripts/verify-client-packages.ts +++ b/scripts/verify-client-packages.ts @@ -14,9 +14,11 @@ const CLIENT_MANIFEST_GLOB = 'packages/client/*/package.json' const MANIFEST_GLOBS = ['packages/*/*/package.json', 'apps/*/package.json', 'vendor/*/package.json'] const CONFIG_GLOB = 'packages/*/*/tsdown.config.ts' const PLATFORM_SOURCE = 'packages/client/web/src/platform.ts' +const PARSER_PRELOAD_SOURCE = 'packages/client/modules/src/index.ts' const STATIC_PRESET_SOURCE = 'packages/client/tsdown.client.ts' const CORDIS = '@deepseek-ai/cordis' const DSH_PREFIX = '@deepseek-ai/dsh-' +const CLIENT_WEB = '@deepseek-ai/dsh-client-web' /** One workspace package's browser-module declaration. */ export interface ClientDeclaration { @@ -38,6 +40,8 @@ export interface ClientPackage extends ClientDeclaration { readonly staticLinked: boolean /** Production source locations grouped by imported package name. */ readonly sourceUses: Readonly> + /** Production source locations grouped by runtime-imported package name. */ + readonly runtimeSourceUses: Readonly> /** Installed implementation dependencies. */ readonly dependencies: Readonly> /** Consumer-supplied dependencies. */ @@ -58,6 +62,8 @@ export interface ClientPackageFacts { readonly platformModules: readonly string[] /** Dynamic factories the HTML parser loads before shell boot. */ readonly preloadedExternals: readonly string[] + /** Package rows whose bundles the HTML parser executes before shell boot. */ + readonly parserPreloadIds: readonly string[] /** Manifest field errors found while reading declarations. */ readonly malformed: readonly string[] } @@ -78,10 +84,38 @@ export interface ClientDeclarations { */ export function collectSourcePackageUses(path: string, source: string): Set { const sourceFile = ts.createSourceFile(path, source, ts.ScriptTarget.Latest, true) - return collectSourceFilePackageUses(sourceFile) + return collectSourceFilePackageUses(sourceFile, false) } -function collectSourceFilePackageUses(sourceFile: ts.SourceFile): Set { +/** + * Collect bare packages whose values one production source file reaches at runtime. + * @param path - File path used to select TypeScript's parser mode. + * @param source - Source text to inspect. + * @returns Bare package names retained by runtime imports, exports, requires, or JSX. + */ +export function collectRuntimeSourcePackageUses(path: string, source: string): Set { + const sourceFile = ts.createSourceFile(path, source, ts.ScriptTarget.Latest, true) + return collectSourceFilePackageUses(sourceFile, true) +} + +function importCarriesRuntimeValue(node: ts.ImportDeclaration): boolean { + const clause = node.importClause + if (clause === undefined) return true + if (clause.phaseModifier === ts.SyntaxKind.TypeKeyword) return false + if (clause.name !== undefined) return true + const bindings = clause.namedBindings + if (bindings === undefined || ts.isNamespaceImport(bindings)) return true + return bindings.elements.length === 0 || bindings.elements.some(element => !element.isTypeOnly) +} + +function exportCarriesRuntimeValue(node: ts.ExportDeclaration): boolean { + if (node.isTypeOnly) return false + const clause = node.exportClause + if (clause === undefined || ts.isNamespaceExport(clause)) return true + return clause.elements.length === 0 || clause.elements.some(element => !element.isTypeOnly) +} + +function collectSourceFilePackageUses(sourceFile: ts.SourceFile, runtimeOnly: boolean): Set { const uses = new Set() const add = (specifier: ts.Expression | undefined): void => { @@ -89,17 +123,19 @@ function collectSourceFilePackageUses(sourceFile: ts.SourceFile): Set { uses.add(packageNameOf(specifier.text)) } const visit = (node: ts.Node): void => { - if (ts.isImportDeclaration(node) || ts.isExportDeclaration(node)) { - add(node.moduleSpecifier) + if (ts.isImportDeclaration(node)) { + if (!runtimeOnly || importCarriesRuntimeValue(node)) add(node.moduleSpecifier) + } else if (ts.isExportDeclaration(node)) { + if (!runtimeOnly || exportCarriesRuntimeValue(node)) add(node.moduleSpecifier) } else if (ts.isImportEqualsDeclaration(node) && ts.isExternalModuleReference(node.moduleReference)) { - add(node.moduleReference.expression) - } else if (ts.isImportTypeNode(node) && ts.isLiteralTypeNode(node.argument)) { + if (!runtimeOnly || !node.isTypeOnly) add(node.moduleReference.expression) + } else if (!runtimeOnly && ts.isImportTypeNode(node) && ts.isLiteralTypeNode(node.argument)) { add(node.argument.literal) } else if (ts.isCallExpression(node) && (node.expression.kind === ts.SyntaxKind.ImportKeyword || ts.isIdentifier(node.expression) && node.expression.text === 'require')) { add(node.arguments[0]) - } else if (ts.isModuleDeclaration(node) && ts.isStringLiteral(node.name)) { + } else if (!runtimeOnly && ts.isModuleDeclaration(node) && ts.isStringLiteral(node.name)) { add(node.name) } else if (ts.isJsxElement(node) || ts.isJsxSelfClosingElement(node) || ts.isJsxFragment(node)) { uses.add('react') @@ -193,9 +229,11 @@ export function fixClientPackageManifests(root: string, facts: ClientPackageFact for (const [name, rule] of expected) { const range = preferredRange(target.manifest, name, rule.kind, inferredRanges) if (range === undefined) continue - target.changed = rule.kind === 'dev' - ? ensureDevOnly(target.manifest, name, range) || target.changed - : ensurePeerDev(target.manifest, name, range) || target.changed + target.changed = rule.kind === 'dependency' + ? ensureDependencyOnly(target.manifest, name, range) || target.changed + : rule.kind === 'dev' + ? ensureDevOnly(target.manifest, name, range) || target.changed + : ensurePeerDev(target.manifest, name, range) || target.changed } if (pkg.dynamic) { @@ -263,6 +301,12 @@ function ensureDevOnly(manifest: Manifest, name: string, range: string): boolean return setDependency(manifest, 'devDependencies', name, range) || changed } +function ensureDependencyOnly(manifest: Manifest, name: string, range: string): boolean { + let changed = deleteDependency(manifest, 'peerDependencies', name) + changed = deleteDependency(manifest, 'devDependencies', name) || changed + return setDependency(manifest, 'dependencies', name, range) || changed +} + function ensurePeerDev(manifest: Manifest, name: string, range: string): boolean { let changed = deleteDependency(manifest, 'dependencies', name) changed = setDependency(manifest, 'peerDependencies', name, range) || changed @@ -301,9 +345,11 @@ function preferredRange( kind: ExpectedRule['kind'], inferred: ReadonlyMap>, ): string | undefined { - const order: readonly DependencySection[] = kind === 'dev' - ? ['devDependencies', 'peerDependencies', 'dependencies'] - : ['peerDependencies', 'devDependencies', 'dependencies'] + const order: readonly DependencySection[] = kind === 'dependency' + ? ['dependencies', 'devDependencies', 'peerDependencies'] + : kind === 'dev' + ? ['devDependencies', 'peerDependencies', 'dependencies'] + : ['peerDependencies', 'devDependencies', 'dependencies'] for (const field of order) { const range = section(manifest, field)[name] if (range !== undefined) return range @@ -373,17 +419,24 @@ function collectModeViolations(facts: ClientPackageFacts): string[] { const rows = rowNames(facts.declarations) for (const specifier of facts.preloadedExternals) { - if (rowPackageOf(specifier, rows) !== undefined) continue - violations.push( - PLATFORM_SOURCE + ': parser-preloaded external ' + JSON.stringify(specifier) - + ' has no dynamic dsh.client row', - ) + if (rowPackageOf(specifier, rows) === undefined) { + violations.push( + PLATFORM_SOURCE + ': parser-preloaded external ' + JSON.stringify(specifier) + + ' has no dynamic dsh.client row', + ) + } + if (!facts.parserPreloadIds.includes(stripClientSuffix(specifier))) { + violations.push( + PLATFORM_SOURCE + ': parser-preloaded external ' + JSON.stringify(specifier) + + ' has no matching PARSER_PRELOAD_IDS row in ' + PARSER_PRELOAD_SOURCE, + ) + } } return violations } interface ExpectedRule { - readonly kind: 'dev' | 'peer-dev' + readonly kind: 'dependency' | 'dev' | 'peer-dev' readonly origins: Set } @@ -399,6 +452,15 @@ function collectDependencyViolations(facts: ClientPackageFacts): string[] { const expected = expectedSections(pkg, staticInputs) for (const [name, rule] of [...expected].sort(([left], [right]) => left.localeCompare(right))) { const actual = declaredSections(pkg, name) + if (rule.kind === 'dependency') { + if (actual.length === 1 && actual[0] === 'dependencies') continue + violations.push( + pkg.manifest + ': ' + name + ' (' + describeOrigins(rule.origins) + ') is a runtime import' + + ' retained by a statically linked artifact; declare it only in dependencies, found ' + + describeSections(actual), + ) + continue + } if (rule.kind === 'dev') { if (actual.length === 1 && actual[0] === 'devDependencies') continue violations.push( @@ -457,7 +519,14 @@ function expectedSections(pkg: ClientPackage, staticInputs: ReadonlySet) const expected = new Map([ [CORDIS, { kind: 'peer-dev', origins: new Set(['client package baseline']) }], ]) - if (!pkg.dynamic) return expected + if (!pkg.dynamic) { + if (pkg.name === CLIENT_WEB) return expected + for (const [name, locations] of Object.entries(pkg.runtimeSourceUses)) { + if (name === pkg.name || name === CORDIS || isInternalDsh(name)) continue + expected.set(name, { kind: 'dependency', origins: new Set(locations) }) + } + return expected + } const add = (name: string, origin: string): void => { if (name === pkg.name) return @@ -483,7 +552,6 @@ interface ModuleEdge { function collectModuleViolations(facts: ClientPackageFacts): string[] { const violations: string[] = [] const baseline = new Set([...facts.platformModules, ...facts.preloadedExternals]) - const staticModules = new Set(facts.platformModules) const rows = rowNames(facts.declarations) const byName = new Map(facts.declarations.map(entry => [entry.name, entry])) const edges: ModuleEdge[] = [] @@ -509,7 +577,6 @@ function collectModuleViolations(facts: ClientPackageFacts): string[] { ) continue } - if (staticModules.has(specifier)) continue const supplier = rowPackageOf(specifier, rows) if (supplier === pkg.name) { violations.push(pkg.manifest + ': dsh.client.external names its own row ' + JSON.stringify(specifier)) @@ -657,28 +724,43 @@ async function readStaticLinkedRoster(root: string): Promise> { return roster } -function readStringLiteralArray(root: string, name: string): string[] { - const path = resolve(root, PLATFORM_SOURCE) +function unwrapExpression(expression: ts.Expression): ts.Expression { + let current = expression + while (ts.isAsExpression(current) || ts.isSatisfiesExpression(current) || ts.isParenthesizedExpression(current)) { + current = current.expression + } + return current +} + +function readStringLiteralArray(root: string, sourcePath: string, name: string): string[] { + const path = resolve(root, sourcePath) const source = ts.createSourceFile(path, readFileSync(path, 'utf8'), ts.ScriptTarget.Latest, false, ts.ScriptKind.TS) + const constants = new Map() + for (const statement of source.statements) { + if (!ts.isVariableStatement(statement)) continue + for (const declaration of statement.declarationList.declarations) { + if (!ts.isIdentifier(declaration.name) || declaration.initializer === undefined) continue + const initializer = unwrapExpression(declaration.initializer) + if (ts.isStringLiteral(initializer)) constants.set(declaration.name.text, initializer.text) + } + } for (const statement of source.statements) { if (!ts.isVariableStatement(statement)) continue for (const declaration of statement.declarationList.declarations) { if (!ts.isIdentifier(declaration.name) || declaration.name.text !== name) continue - const expression = declaration.initializer !== undefined && ts.isAsExpression(declaration.initializer) - ? declaration.initializer.expression - : declaration.initializer + const expression = declaration.initializer === undefined ? undefined : unwrapExpression(declaration.initializer) if (expression === undefined || !ts.isArrayLiteralExpression(expression)) { - throw new Error(GATE + ': ' + name + ' in ' + PLATFORM_SOURCE + ' must be an array literal') + throw new Error(GATE + ': ' + name + ' in ' + sourcePath + ' must be an array literal') } return expression.elements.map((element) => { - if (!ts.isStringLiteral(element)) { - throw new Error(GATE + ': ' + name + ' in ' + PLATFORM_SOURCE + ' must contain only string literals') - } - return element.text + const value = unwrapExpression(element) + if (ts.isStringLiteral(value)) return value.text + if (ts.isIdentifier(value) && constants.has(value.text)) return constants.get(value.text) as string + throw new Error(GATE + ': ' + name + ' in ' + sourcePath + ' must contain only string constants') }) } } - throw new Error(GATE + ': ' + PLATFORM_SOURCE + ' declares no ' + name) + throw new Error(GATE + ': ' + sourcePath + ' declares no ' + name) } async function readFacts(root: string): Promise { @@ -694,17 +776,23 @@ async function readFacts(root: string): Promise { const manifest = JSON.parse(readFileSync(resolve(root, manifestPath), 'utf8')) as Manifest if (typeof manifest.name !== 'string') throw new Error(GATE + ': ' + manifestPath + ' has no package name') const sourceUses = new Map>() + const runtimeSourceUses = new Map>() const packageDirectory = dirname(manifestPath) const sourcePrefix = packageDirectory + '/src/' for (const sourceFile of project.sourceFiles()) { if (sourceFile.isDeclarationFile) continue const file = project.relativePath(sourceFile) if (!file.startsWith(sourcePrefix)) continue - for (const name of collectSourceFilePackageUses(sourceFile)) { + for (const name of collectSourceFilePackageUses(sourceFile, false)) { const locations = sourceUses.get(name) ?? new Set() locations.add(file) sourceUses.set(name, locations) } + for (const name of collectSourceFilePackageUses(sourceFile, true)) { + const locations = runtimeSourceUses.get(name) ?? new Set() + locations.add(file) + runtimeSourceUses.set(name, locations) + } } packages.push({ ...declaration, @@ -713,6 +801,10 @@ async function readFacts(root: string): Promise { [...sourceUses].sort(([left], [right]) => left.localeCompare(right)) .map(([name, locations]) => [name, [...locations].sort()]), ), + runtimeSourceUses: Object.fromEntries( + [...runtimeSourceUses].sort(([left], [right]) => left.localeCompare(right)) + .map(([name, locations]) => [name, [...locations].sort()]), + ), dependencies: manifest.dependencies ?? {}, peerDependencies: manifest.peerDependencies ?? {}, devDependencies: manifest.devDependencies ?? {}, @@ -723,8 +815,9 @@ async function readFacts(root: string): Promise { packages, declarations, staticLinkedPackages, - platformModules: readStringLiteralArray(root, 'PLATFORM_MODULES'), - preloadedExternals: readStringLiteralArray(root, 'PRELOADED_CLIENT_EXTERNALS'), + platformModules: readStringLiteralArray(root, PLATFORM_SOURCE, 'PLATFORM_MODULES'), + preloadedExternals: readStringLiteralArray(root, PLATFORM_SOURCE, 'PRELOADED_CLIENT_EXTERNALS'), + parserPreloadIds: readStringLiteralArray(root, PARSER_PRELOAD_SOURCE, 'PARSER_PRELOAD_IDS'), malformed, } }