Status: proposed
English | 中文
A browser artifact resolves nothing on the user's machine:
ui-* plugin package's browser artifact is lib/client.js, where tsdown inlines every non-platform specifier (noExternal in packages/client/tsdown.client.ts). The specifiers that survive are answered by the loader's frozen module table, because require inside that bundle is a parameter the loader injects, not Node's.PLATFORM_MODULES) come from the shell dist, never from Node resolution.@deepseek-ai/dsh-web-frontend's published dist; that package ships dist alone and has no . export.Every browser code path is therefore a build product, served as an asset or baked into dist. Yet the packages those artifacts are built from — react, react-dom, shiki, katex, clsx, the micromark and mdast families — sit in dependencies and non-optional peerDependencies, which npm installs for every consumer of the published package. Across the repository that is 79 such external declarations in 38 packages, downloaded by users who never load them.
An external package only a browser artifact reaches belongs in devDependencies. Two deliberate omissions are as much part of the rule:
@deepseek-ai/* name stays where its manifest puts it. Such a declaration also states which package supplies an injected service, which Remote contribution an assembly mounts, or which Loader row must resolve; verify-runtime-closure and the Loader read it, and the app installs the package regardless — so moving one removes meaning without removing a download.Faces are walked from the entries a manifest publishes, not by a directory rule, so a module under src/ that only the browser entry reaches counts as browser source:
| kind | test | host face entries |
|---|---|---|
bundle-half |
has a ./client export |
every export target except ./client |
browser-library |
under packages/client/ with no ./client export |
src/invariant.ts alone — the companion the host mounts; . is browser code |
prebuilt-dist |
no . export, ships a dist |
none: the package offers Node no entry |
scripts/verify-client-runtime-deps.tsWired into pnpm run hygiene, about 35 seconds — the cost of two bound Programs, the same order as verify-optional-dependency-imports in that lane. It reuses the repository's tooling rather than growing its own: TypeScriptProject (scripts/ts-project.ts) binds the host and client compiler faces separately (that file states why the two cannot share one program — the cordis Context merges collide), ts.resolveModuleName resolves relative specifiers, and the walk stops at the package boundary.
Three findings decided the mechanism, after a first pass that scanned string literals:
react substring inside '@deepseek-ai/dsh-client-web-react' silently swallowed react../client is the tsdown browser bundle is keyed on the artifact path (./lib/client.js), not the subpath name — dsh-goal publishes ./client as ./lib/types/client.js, a plain tsc-emitted browser-shared module.require, require.resolve, and dynamic import() on a literal each reach a package; require.resolve('@deepseek-ai/dsh-web-frontend/dist/index.html') is a real host resolution path.Two classes, both reported per entry:
| class | count | test |
|---|---|---|
browser |
74 | only a browser artifact reaches it |
nothing |
5 | no reference names it: client-runtime's react (which contradicts its own React-free layering red line), the peer react of ui-settings and ui-theme, ui-trajectory's peer react-dom, and ui-primitives' @types/mdast |
Each conservative rule below answers a false report or a semantic loss observed while building it:
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' in src/invariant.ts is erased at run time, yet it states which package supplies the service that companion registers — verify-runtime-closure's relation.dsh-goal's ./typert -> ./lib/typert.host.js is emitted by the typert generator and carries its own import { z } from 'zod', which no source states. Getting this test right removed four false reports, among them api-gateway's typert-registry.cordis*.yml the package owns counts as host face: a Loader row names its plugin instead of importing it.@deepseek-ai/cordis is exempt — check-workspace-constraints requires it as both peer and dev everywhere.--json output feeds the bulk edit and the install measurement.
Measured against a real npm install of the published CLI, with tarball bytes read from an isolated cache: 103 external tarballs stop being downloaded, 6.05 MB in total.
| group | packages | saved |
|---|---|---|
| syntax highlighting and math (shiki family, oniguruma family, katex) | 16 | 3.93 MB |
react and view libraries (react, react-dom, scheduler, immer, zustand, @tanstack/*, clsx, use-sync-external-store) |
11 | 1.47 MB |
markdown and ansi pipeline plus odds and ends (micromark, mdast, hast families, anser, a few @types/*) |
76 | 0.65 MB |
Our own six browser-library packages (ui-primitives, ui-slots, web-react, ui-attachment, schema-form, client-web — 0.20 MB together) stay installed: code names them, and the rule above leaves those declarations alone.
packages/client/AGENTS.md, and one clause in the new-plugin-package checklist.scripts/verify-client-runtime-deps.ts, its package.json script, its place in hygiene, and a counterexample spec.devDependencies entry; the rest already carry one, so the change is a deleted line.lib/**/*.js) instead of source: that is Node's own view, but the gate would then depend on pnpm run build, and it still cannot judge a browser-library's lib/index.js (node platform, browser content), so the face test stays either way.verify-optional-dependency-imports does): tried, and it also judged 83 node-face type-only declarations movable — no download saved for a real loss of meaning, 53 of them dsh-invariants. This gate needs to know whether a reference exists, not whether it is a value.peerDependenciesMeta.optional instead of devDependencies: npm does skip an optional peer, but the meaning is "a consumer may supply this", and there is no run-time consumer at all. The repository must install it to build, which is what devDependencies says.nothing entries overlap.optionalDependencies: wrong meaning — it says "skip this if it cannot be installed".pnpm run hygiene includes verify-client-runtime-deps and passes; a counterexample spec proves one dependencies.react is rejected.pnpm run build, pnpm run test:gui, and DSH_SNAPSHOT=replay pnpm run test:web pass — the move changes no build input, so artifacts stay byte-identical.ui-primitives/lib/index.js is a rolldown artifact and still reads from "anser", while anser is now dev-only. It is inert — only our Vite build reads that file, and no loader exists for it on a user's machine (verified: only browser code imports those packages, never the host). Retiring their publication is the way to erase it; see Alternatives.@types/* go unreported: source never names them, so the rule cannot see them. @types/mdast was caught only because nothing referenced it either. They belong in dev regardless, and closing that gap is follow-up work.dsh-goal is skipped whole for its generated entry, so its browser-side declarations are now nobody's business. Reading a generated artifact's own run-time imports is what would let the exemption be withdrawn.require, require.resolve, and dynamic import() count as references; a package's own cordis*.yml counts as host face; and no @deepseek-ai/* name is subject at all.