Revert "fix(client): declare browser-only externals as devDependencies"

This commit is contained in:
imccyu
2026-08-15 02:19:09 +08:00
committed by GitHub
parent 6c18b04a6b
commit f2830fec6d
54 changed files with 293 additions and 1096 deletions

View File

@@ -1,230 +0,0 @@
/**
* The external packages a published browser artifact carries a copy of.
*
* Read from the real build configurations rather than declared by hand: each
* `lib/client.js` plugin bundle is driven through its own `tsdown.config.ts`, and
* the shell `dist` through `apps/web`'s Vite config. A recording plugin resolves
* every bare specifier as external and notes it, so the pass walks our own source
* and stops at the package boundary — which is both fast (about two seconds for
* the whole repository) and exactly the direct-dependency granularity
* THIRD_PARTY_NOTICES.md discloses. Erased type imports never appear, because the
* transform drops them before resolution.
*
* Workspace names are followed only on the Vite side, where the shell's aliases
* map them to source: that is how a browser-only library's own third-party
* imports — katex and shiki through `ui-primitives`, for one — become visible. A
* plugin bundle keeps them external, matching the frozen module table it is built
* against; the wire layers it inlines are host packages that declare their own
* dependencies, so nothing goes undisclosed.
*
* A specifier is recorded only once the host resolves it to a file inside a
* package. A bundler's own virtual module has no package behind it —
* `vite/modulepreload-polyfill` is generated by a Vite plugin rather than shipped
* as a file, so the polyfill in the published `dist` is build glue in the same
* category as an emitted TypeScript helper, not a redistributed copy of Vite.
*
* The pass runs on a clean tree, as a static gate must. The shell's Vite config
* aliases a few workspace packages to source; every other workspace name would
* resolve through `node_modules` to a `lib/` entry the real build has emitted but
* a clean checkout has not, so this module resolves those names to their own
* source instead. `lib/` is compiled from `src/`, so the third-party edges the
* pass records are the same either way.
*
* rolldown is resolved through tsdown deliberately: the dry run must use the
* exact bundler the real build uses, which a separate root pin could drift from.
*/
import { existsSync, globSync, readFileSync } from 'node:fs'
import { createRequire } from 'node:module'
import { dirname, join } from 'node:path'
/** The plugin-context member the recorder needs to resolve before recording. */
interface ResolveContext {
resolve: (
source: string,
importer: string,
options: { skipSelf: boolean },
) => Promise<{ id: string } | null>
}
/** A rolldown/Vite plugin shape, narrowed to what the recorder needs. */
interface RecorderPlugin {
name: string
enforce?: 'pre'
resolveId: (
this: ResolveContext,
source: string,
importer: string | undefined,
) => Promise<{ id: string; external: true } | null>
}
/**
* The package a resolved module file belongs to.
* @param file - absolute path of a resolved module.
* @returns the package name, or undefined when the file is not inside a package.
*/
function packageOfFile(file: string): string | undefined {
const marker = file.lastIndexOf('node_modules/')
if (marker < 0) return undefined
const rest = file.slice(marker + 'node_modules/'.length)
const parts = rest.split('/')
return rest.startsWith('@') ? parts.slice(0, 2).join('/') : parts[0]
}
/**
* Source aliases for the workspace packages the shell does not already alias.
*
* A clean checkout has no `lib/`, so a workspace name would otherwise resolve
* through `node_modules` to an entry that does not exist yet. Aliases are the
* right seam rather than a plugin hook, because Vite resolves a stylesheet
* `@import` through them too — the theme package publishes its stylesheets from
* `lib/styles/`. `lib/` is compiled from `src/`, so the third-party edges the
* pass records are the same either way.
* @param root - repository root.
* @param existing - the shell's own alias patterns, whose entry choices win.
* @returns alias entries mapping each remaining workspace name to its source.
*/
function workspaceSourceAliases(root: string, existing: readonly string[]): { find: RegExp | string; replacement: string }[] {
const aliases: { find: RegExp | string; replacement: string }[] = []
for (const pattern of ['packages/*/*/package.json', 'vendor/*/package.json']) {
for (const relative of globSync(pattern, { cwd: root })) {
const dir = join(root, dirname(relative))
const manifest = JSON.parse(readFileSync(join(root, relative), 'utf8')) as Manifest & { name?: string }
const name = manifest.name
if (name === undefined || !existsSync(join(dir, 'src'))) continue
if (existing.some(find => find.includes(name))) continue
const root_ = manifest.exports?.['.']
const target = typeof root_ === 'string' ? root_ : root_?.default
const stem = (target ?? './lib/index.js')
.replace(/^\.\/lib\/types\//, '').replace(/^\.\/lib\//, '').replace(/\.js$/, '')
const entry = [`${stem}.ts`, `${stem}.tsx`, `${stem}/index.ts`, `${stem}/index.tsx`]
.map(candidate => join(dir, 'src', candidate))
.find(candidate => existsSync(candidate))
// The subpath prefix carries `./client`, `./types`, and `./styles/*` alike:
// each published subpath mirrors a path under `src/`.
aliases.push({ find: `${name}/`, replacement: `${join(dir, 'src')}/` })
if (entry !== undefined) aliases.push({ find: new RegExp(`^${name.replaceAll('/', '\\/')}$`), replacement: entry })
}
}
return aliases
}
/**
* Build the plugin that records bare specifiers and stops the walk at them.
* @param seen - set the recorder adds package names to.
* @returns the recording plugin.
*/
function recorder(seen: Set<string>): RecorderPlugin {
return {
name: 'dsh-record-direct-externals',
enforce: 'pre',
async resolveId(source, importer) {
if (importer === undefined) return null // the entry itself
if (source.startsWith('.') || source.startsWith('/') || source.startsWith('\0')) return null
if (source.startsWith('virtual:') || source.includes('?')) return null
// A workspace name that reaches here is one no alias mapped to source, so
// nothing of ours is left to walk; it is never a third-party disclosure.
if (source.startsWith('@deepseek-ai/')) return { id: source, external: true }
if (source.startsWith('node:')) return { id: source, external: true }
if (!source.startsWith('@deepseek-ai/')) {
const resolved = await this.resolve(source, importer, { skipSelf: true })
const name = resolved === null ? undefined : packageOfFile(resolved.id)
if (name !== undefined) seen.add(name)
}
return { id: source, external: true }
},
}
}
interface Manifest {
exports?: Record<string, { default?: string } | string | null>
files?: string[]
}
/** Read one workspace manifest. */
function manifestOf(dir: string): Manifest {
return JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8')) as Manifest
}
/** Whether a manifest publishes a tsdown browser bundle at `lib/client.js`. */
function publishesClientBundle(manifest: Manifest): boolean {
const target = manifest.exports?.['./client']
return typeof target === 'object' && target !== null && target.default === './lib/client.js'
}
/**
* Record every external package the plugin client bundles carry.
* @param root - repository root.
* @param seen - set the recorder adds package names to.
*/
async function collectFromClientBundles(root: string, seen: Set<string>): Promise<void> {
const requireFromTsdown = createRequire(createRequire(import.meta.url).resolve('tsdown'))
const { rolldown } = await import(requireFromTsdown.resolve('rolldown')) as {
rolldown: (options: Record<string, unknown>) => Promise<{
generate: (output: Record<string, unknown>) => Promise<unknown>
close: () => Promise<void>
}>
}
for (const relative of globSync('packages/*/*/tsdown.config.ts', { cwd: root }).sort()) {
const dir = join(root, dirname(relative))
if (!publishesClientBundle(manifestOf(dir))) continue
const loaded = await import(join(root, relative)) as { default: unknown }
const factory = loaded.default
const configs = (typeof factory === 'function'
? (factory as (inline: { env: Record<string, string> }) => unknown[])({ env: {} })
: [factory]) as { name?: string; entry?: unknown; plugins?: unknown[] }[]
// The `/client` config is the browser bundle; its siblings emit the node half.
const client = configs.find(config => config.name?.endsWith('/client') === true)
if (client === undefined) continue
const bundle = await rolldown({
cwd: dir,
input: client.entry,
plugins: [recorder(seen), ...(client.plugins ?? [])],
platform: 'browser',
})
await bundle.generate({ format: 'cjs', minify: false, sourcemap: false })
await bundle.close()
}
}
/**
* Record every external package the prebuilt shell bundle carries.
* @param root - repository root.
* @param seen - set the recorder adds package names to.
*/
async function collectFromShellBundle(root: string, seen: Set<string>): Promise<void> {
for (const relative of globSync('apps/*/vite.config.ts', { cwd: root }).sort()) {
const dir = join(root, dirname(relative))
// Vite belongs to the app that builds with it, so it resolves from there.
const { build, resolveConfig } = await import(createRequire(join(dir, 'package.json')).resolve('vite')) as {
build: (options: Record<string, unknown>) => Promise<unknown>
resolveConfig: (options: Record<string, unknown>, command: string) => Promise<{
resolve: { alias: { find: string | RegExp }[] }
}>
}
// The shell already aliases some workspace names to source, and its entry
// choices win: a stylesheet `@import` resolves through aliases rather than a
// plugin hook, so only the names it leaves out get one from here.
const resolved = await resolveConfig({ root: dir, logLevel: 'error' }, 'build')
await build({
root: dir,
logLevel: 'error',
plugins: [recorder(seen)],
resolve: { alias: workspaceSourceAliases(root, resolved.resolve.alias.map(entry => String(entry.find))) },
build: { write: false, minify: false, sourcemap: false, reportCompressedSize: false },
})
}
}
/**
* The external packages a published browser artifact carries a copy of.
* @param root - repository root.
* @returns package names, workspace names excluded.
*/
export async function browserBundledExternals(root: string): Promise<Set<string>> {
const seen = new Set<string>()
await collectFromClientBundles(root, seen)
await collectFromShellBundle(root, seen)
return seen
}

View File

@@ -24,12 +24,11 @@ describe('THIRD_PARTY_NOTICES.md', () => {
// already runs in the test lane, so the check costs no extra CI process.
// Pre-commit regenerates the file whenever a manifest is staged, so reaching
// this assertion means the notices were committed without that hook.
it('matches what the generator produces from the current manifests', async () => {
const generated = await render()
it('matches what the generator produces from the current manifests', () => {
const generated = render()
expect(generated).toContain('It depends on the third-party software listed below.')
expect(readFileSync(resolve(root, 'THIRD_PARTY_NOTICES.md'), 'utf8'), 'stale notices — run `pnpm run gen-third-party-notices`').toBe(generated)
// Driving the two real bundlers to learn what ships costs a few seconds.
}, 60_000)
})
})
/** Build the (manifests, names) pair `tierExternalDeps` consumes. */
@@ -68,23 +67,6 @@ describe('tierExternalDeps', () => {
]))
})
it('keeps a devDependency runtime when a published browser artifact carries it', () => {
const { manifests, names } = workspace({
// The client build inlines these, so a copy ships even though no manifest
// resolves the specifier at run time.
'packages/client/ui-primitives/package.json': {
name: '@deepseek-ai/dsh-client-ui-primitives',
devDependencies: { katex: '^0.16', 'test-only-helper': '^1' },
},
})
expect(tierExternalDeps(manifests, names, new Set(['katex']))).toEqual(new Map([
['tsx', true],
['katex', true],
['test-only-helper', false],
]))
})
it('keeps a package runtime when any shipping area declares it, and excludes workspace links', () => {
const { manifests, names } = workspace({
'package.json': { devDependencies: { shared: '^1' } },

View File

@@ -13,7 +13,6 @@ import { resolve } from 'node:path'
import * as yaml from 'js-yaml'
import { parse as parseToml, type TomlTableWithoutBigInt, type TomlValueWithoutBigInt } from 'smol-toml'
import parseSpdx from 'spdx-expression-parse'
import { browserBundledExternals } from './browser-bundled-externals.ts'
const root = resolve(import.meta.dirname, '..')
const OUT = 'THIRD_PARTY_NOTICES.md'
@@ -348,17 +347,15 @@ function normalizeRepo(raw: string | undefined): string | undefined {
}
/**
* External npm dependencies, tiered by what reaches a user: a package is runtime
* when any manifest outside `DEV_ONLY_AREAS` names it in
* `dependencies`/`optionalDependencies`, or when a published browser artifact
* carries a copy of it. A package declared only by tooling, test infrastructure,
* the website, or the demo leaves — whatever the declaring section is called, and
* with no shipped artifact carrying it — is development-only.
* @returns every external dependency with its tier and metadata.
* External npm dependencies, tiered by which workspace area declares them at
* runtime: a package is runtime when any manifest outside `DEV_ONLY_AREAS`
* names it in `dependencies`/`optionalDependencies`. A package declared only
* by tooling, test infrastructure, the website, or the demo leaves — whatever
* the declaring section is called — is development-only.
*/
async function collectNpmDeps(): Promise<ExternalDep[]> {
function collectNpmDeps(): ExternalDep[] {
const { manifests, names } = loadWorkspaceManifests()
return [...tierExternalDeps(manifests, names, await browserBundledExternals(root))]
return [...tierExternalDeps(manifests, names)]
.filter(([name]) => !FIRST_PARTY.has(name))
.sort(([a], [b]) => a.localeCompare(b))
.map(([name, runtime]) => ({ name, ...installedMetadata(name), runtime }))
@@ -366,23 +363,11 @@ async function collectNpmDeps(): Promise<ExternalDep[]> {
/**
* Tier every external dependency the workspace declares.
*
* A package a published browser artifact carries is runtime whatever section
* declares it: the client build inlines its code, or the shell `dist` answers it
* from the frozen module table, so a copy is redistributed even though nothing on
* a user's machine resolves the specifier. Those packages are declared as
* `devDependencies` — `verify-client-runtime-deps` owns that rule — and tiering
* them by section alone would understate the notice.
* @param manifests - workspace manifests keyed by repository-relative path.
* @param names - every workspace package name, which never counts as external.
* @param bundled - external packages a published browser artifact carries.
* @returns each external package mapped to whether it is a runtime dependency.
*/
export function tierExternalDeps(
manifests: Map<string, Manifest>,
names: Set<string>,
bundled: ReadonlySet<string> = new Set(),
): Map<string, boolean> {
export function tierExternalDeps(manifests: Map<string, Manifest>, names: Set<string>): Map<string, boolean> {
const tiers = new Map<string, boolean>()
// `tsx` is runtime by fiat: the root source-run scripts execute through its ESM hook.
tiers.set('tsx', true)
@@ -391,7 +376,7 @@ export function tierExternalDeps(
for (const kind of ALL_KINDS) {
for (const [dep, range] of Object.entries(manifest[kind] ?? {})) {
if (names.has(dep) || range.startsWith('workspace:')) continue
const runtime = bundled.has(dep) || (!devOnly && (RUNTIME_KINDS as readonly string[]).includes(kind))
const runtime = !devOnly && (RUNTIME_KINDS as readonly string[]).includes(kind)
tiers.set(dep, (tiers.get(dep) ?? false) || runtime)
}
}
@@ -675,9 +660,9 @@ ${rows.join('\n')}
* Render the complete notices document.
* @returns the exact bytes `THIRD_PARTY_NOTICES.md` must hold.
*/
export async function render(): Promise<string> {
export function render(): string {
verifyBuildTimePins()
const npm = await collectNpmDeps()
const npm = collectNpmDeps()
const runtimeDeps = npm.filter(dep => dep.runtime)
const devDeps = npm.filter(dep => !dep.runtime)
const vendored = collectVendored()
@@ -722,7 +707,7 @@ ${vendored.map(row => `| \`${row.npmName}\` | \`${row.upstreamName}\` | [${row.u
## Runtime npm dependencies
External packages that reach a user: a workspace package resolves them at runtime, or a published browser artifact carries a copy of their code. The tier covers every plugin a user can mount from \`cordis.yml\` — not only what the \`dsh\` CLI, Web UI, and Python SDK runtime load by default — and the packages the client build inlines into a plugin bundle or the shell \`dist\`, which are declared as \`devDependencies\` because nothing on a user's machine resolves their specifiers.
External packages that a workspace package resolves at runtime. The tier covers every plugin a user can mount from \`cordis.yml\` — not only what the \`dsh\` CLI, Web UI, and Python SDK runtime load by default.
${renderNpmTable(runtimeDeps)}
@@ -733,7 +718,7 @@ ${renderClaudeDistribution(claudeDistribution)}
## Development-only npm dependencies
External packages **directly declared** only by repository tooling, test infrastructure, the documentation site, the demo leaves, or the native launcher's build workspace, and carried by no published artifact. No shipped surface names them itself. A package here may still be pulled in transitively by a runtime dependency — \`pnpm-lock.yaml\` is the authority on the full closure — so this tier records who declares a package, not what a build ultimately bundles.
External packages **directly declared** only by repository tooling, test infrastructure, the documentation site, the demo leaves, or the native launcher's build workspace. No shipped surface names them itself. A package here may still be pulled in transitively by a runtime dependency — \`pnpm-lock.yaml\` is the authority on the full closure — so this tier records who declares a package, not what a build ultimately bundles.
${renderNpmTable(devDeps)}
${renderNonPermissiveNote(nonPermissiveDev)}
@@ -761,8 +746,8 @@ ${BUILD_TIME_TOOLS.map(tool => `| [\`${tool.name}\`](${tool.repo}) | ${tool.lice
/** CLI entry: default writes the notices, `--check` fails if the committed copy
* is stale. Guarded behind an entry-point check so importing this module for
* tests neither regenerates the committed file nor calls process.exit. */
async function main(): Promise<void> {
const content = await render()
function main(): void {
const content = render()
if (process.argv.includes('--check')) {
let committed: string | null = null
try {
@@ -786,5 +771,5 @@ async function main(): Promise<void> {
// Run only when invoked as a script, not when imported by a test.
if (process.argv[1] !== undefined && import.meta.filename === resolve(process.argv[1])) {
await main()
main()
}

View File

@@ -1,368 +0,0 @@
/**
* Keep browser-only external packages out of installed dependency sections.
*
* A browser artifact resolves nothing on the user's machine: tsdown inlines
* every non-platform specifier into `lib/client.js`, the shell `dist` answers
* `PLATFORM_MODULES` from its frozen module table, and Vite inlines the shell's
* own imports into that published `dist`. A specifier only browser source
* reaches is therefore a build-time input and belongs in `devDependencies`,
* because npm installs `dependencies` and non-optional `peerDependencies` for
* every consumer of the published package.
*
* Each face is walked from the entries the manifest publishes, not by a
* directory rule, so a module under `src/` that only the browser entry reaches
* counts as browser source:
*
* `./client` is `lib/client.js` host: the other export targets; browser: the bundle
* `packages/client/*` with no browser-only library: host is `src/invariant.ts`,
* `./client` export the companion the host mounts; `.` is browser code
* no `.` export, ships a `dist` prebuilt browser bundle: no host face at all
*
* Only external packages are subject: they are what an install downloads. A
* workspace name stays where its manifest puts it, because that declaration also
* states which package supplies an injected service or a mounted Remote
* contribution, and the app installs it either way. A reference from the host
* face, an erased type import included, likewise keeps a declaration in place.
*
* Run: pnpm exec tsx scripts/verify-client-runtime-deps.ts [--json]
*/
import { existsSync, globSync, readFileSync } from 'node:fs'
import { dirname, join, resolve } from 'node:path'
import ts from 'typescript'
import { TypeScriptProject, type CompilerFace } from './ts-project.ts'
const root = resolve(import.meta.dirname, '..')
/**
* `@deepseek-ai/cordis` placement belongs to check-workspace-constraints, which
* requires it as a peerDependency plus devDependency of every harness package
* regardless of face.
*/
const PLACEMENT_OWNED_ELSEWHERE = new Set(['@deepseek-ai/cordis'])
/** Dependency sections npm installs for a consumer of the published package. */
const INSTALLED_SECTIONS = ['dependencies', 'peerDependencies'] as const
type Section = (typeof INSTALLED_SECTIONS)[number]
interface Manifest {
name?: string
files?: string[]
exports?: Record<string, unknown>
dependencies?: Record<string, string>
peerDependencies?: Record<string, string>
peerDependenciesMeta?: Record<string, { optional?: boolean }>
}
/** How a package reaches the browser, which fixes the entries Node can load. */
type Kind = 'bundle-half' | 'browser-library' | 'prebuilt-dist'
/** What settles an external specifier as build-time only. */
type Reached = 'browser' | 'nothing'
interface Violation {
readonly section: Section
readonly dep: string
readonly reached: Reached
/** Whether the browser face names it, which decides dev-move versus deletion. */
readonly browserReferenced: boolean
}
interface Offender {
readonly name: string
readonly dir: string
readonly kind: Kind
readonly violations: Violation[]
}
/** Why each class needs no install, for the failure report. */
const REASON: Record<Reached, string> = {
browser: 'only a browser artifact reaches it, and that resolves nothing on the user machine',
nothing: 'no reference names it at all',
}
/**
* Classify a package by the browser artifact it produces.
* @param dir - repository-relative package directory.
* @param manifest - the package manifest.
* @returns the package kind, or undefined when the package has no browser face.
*/
function kindOf(dir: string, manifest: Manifest): Kind | undefined {
if (manifest.exports?.['./client'] !== undefined) return 'bundle-half'
if (dir.startsWith('packages/client/')) return 'browser-library'
const shipsDist = (manifest.files ?? []).some(entry => entry === 'dist' || entry.startsWith('dist/'))
if (shipsDist && manifest.exports?.['.'] === undefined) return 'prebuilt-dist'
return undefined
}
/** The bare package name a specifier names, keeping a leading scope. */
function packageOf(specifier: string): string {
const parts = specifier.split('/')
return specifier.startsWith('@') ? parts.slice(0, 2).join('/') : parts[0] ?? specifier
}
/** One compiler face's bound program plus its module resolution state. */
interface Face {
readonly project: TypeScriptProject
readonly host: ts.CompilerHost
readonly cache: ts.ModuleResolutionCache
}
const faces = new Map<CompilerFace, Face>()
for (const face of ['host', 'client'] as const) {
const project = new TypeScriptProject(root, face)
const options = project.program.getCompilerOptions()
faces.set(face, {
project,
host: ts.createCompilerHost(options, false),
cache: ts.createModuleResolutionCache(root, fileName => fileName, options),
})
}
/** Which face's program bound each workspace module, keyed by absolute path. */
const boundIn = new Map<string, CompilerFace>()
for (const [face, { project }] of faces) {
for (const sourceFile of project.sourceFiles()) {
if (sourceFile.isDeclarationFile) continue
if (!boundIn.has(sourceFile.fileName)) boundIn.set(sourceFile.fileName, face)
}
}
/**
* Read every module specifier one source file names.
*
* An import clause is not the only way to reach a package: `require`,
* `require.resolve`, and a dynamic `import()` on a literal each name one, and a
* type-only import still names a package the build must resolve.
* @param sourceFile - a bound source file.
* @returns every specifier, relative ones included.
*/
function specifiersOf(sourceFile: ts.SourceFile): string[] {
const specifiers: string[] = []
const visit = (node: ts.Node): void => {
if (ts.isImportDeclaration(node) || ts.isExportDeclaration(node) || ts.isImportEqualsDeclaration(node)) {
const specifier = ts.isImportEqualsDeclaration(node)
? (ts.isExternalModuleReference(node.moduleReference) ? node.moduleReference.expression : undefined)
: node.moduleSpecifier
if (specifier !== undefined && ts.isStringLiteralLike(specifier)) specifiers.push(specifier.text)
} else if (ts.isCallExpression(node)) {
const target = node.expression
const isRequire = ts.isIdentifier(target) && target.text === 'require'
const isRequireResolve = ts.isPropertyAccessExpression(target)
&& ts.isIdentifier(target.expression) && target.expression.text === 'require'
&& target.name.text === 'resolve'
const argument = node.arguments[0]
if ((isRequire || isRequireResolve || target.kind === ts.SyntaxKind.ImportKeyword)
&& argument !== undefined && ts.isStringLiteralLike(argument)) {
specifiers.push(argument.text)
}
}
ts.forEachChild(node, visit)
}
visit(sourceFile)
return specifiers
}
/**
* Walk one face from its entries and collect the packages it names.
* @param entries - absolute entry module paths.
* @param packageDir - absolute package directory; the walk stops at its edge.
* @returns package names the walk reaches.
*/
function walk(entries: readonly string[], packageDir: string): Set<string> {
const found = new Set<string>()
const seen = new Set<string>()
const queue = entries.filter(entry => boundIn.has(entry))
while (queue.length > 0) {
const file = queue.pop()
if (file === undefined || seen.has(file)) continue
seen.add(file)
const faceName = boundIn.get(file)
const face = faceName === undefined ? undefined : faces.get(faceName)
const sourceFile = face?.project.program.getSourceFile(file)
if (face === undefined || sourceFile === undefined) continue
for (const specifier of specifiersOf(sourceFile)) {
if (!specifier.startsWith('.')) {
if (!specifier.startsWith('node:')) found.add(packageOf(specifier))
continue
}
const resolved = ts.resolveModuleName(
specifier, file, face.project.program.getCompilerOptions(), face.host, face.cache,
).resolvedModule?.resolvedFileName
// A relative specifier resolving outside the package is a packaging error
// verify-package-paths owns; either way it is not this package's own module.
if (resolved !== undefined && resolved.startsWith(`${packageDir}/`)) queue.push(resolved)
}
}
return found
}
/**
* The source module behind one published JavaScript export target.
*
* `lib/` holds the tsdown bundles and `lib/types/` the tsc emit, so both
* prefixes lead back to one `src` module.
* @param dir - absolute package directory.
* @param emitted - the export target, as written in the manifest.
* @returns the absolute source path, or undefined when nothing in `src` emits it.
*/
function sourceBehind(dir: string, emitted: string): string | undefined {
const stem = emitted.replace(/^\.\/lib\/types\//, '').replace(/^\.\/lib\//, '').replace(/\.js$/, '')
return [`src/${stem}.ts`, `src/${stem}.tsx`, `src/${stem}/index.ts`, `src/${stem}/index.tsx`]
.map(candidate => join(dir, candidate))
.find(candidate => existsSync(candidate))
}
interface Entries {
readonly host: string[]
readonly browser: string[]
/**
* Published JavaScript entries no `src` module emits — a generated artifact
* such as `lib/typert.host.js`, whose own runtime imports are invisible here.
*/
readonly generated: string[]
}
/**
* The entry modules of each face, derived from what the manifest publishes.
* @param dir - absolute package directory.
* @param manifest - the package manifest.
* @param kind - the package kind.
* @returns absolute entry module paths per face, plus unmapped published entries.
*/
function faceEntries(dir: string, manifest: Manifest, kind: Kind): Entries {
if (kind === 'prebuilt-dist') return { host: [], browser: [], generated: [] }
if (kind === 'browser-library') {
return { host: [join(dir, 'src/invariant.ts')], browser: [join(dir, 'src/index.ts')], generated: [] }
}
const host = [join(dir, 'src/index.ts'), join(dir, 'src/invariant.ts')]
const browser: string[] = []
const generated: string[] = []
for (const [key, target] of Object.entries(manifest.exports ?? {})) {
if (key === '.' || key === './package.json' || key.includes('*')) continue
const emitted = typeof target === 'string' ? target : (target as { default?: unknown }).default
if (typeof emitted !== 'string' || !emitted.endsWith('.js')) continue
// Keyed on the artifact path, not the subpath name: `./client` is the tsdown
// browser bundle only when it resolves to lib/client.js, while other packages
// publish a plain browser-shared module under the same subpath.
const source = emitted === './lib/client.js'
? sourceBehind(dir, './lib/client/index.js')
: sourceBehind(dir, emitted)
if (source === undefined) generated.push(`${key} -> ${emitted}`)
else if (key === './client') browser.push(source)
else host.push(source)
}
return { host, browser, generated }
}
/** Every installed dependency of a manifest, paired with its section. */
function installedDeps(manifest: Manifest): { section: Section; dep: string }[] {
const deps: { section: Section; dep: string }[] = []
for (const section of INSTALLED_SECTIONS) {
for (const dep of Object.keys(manifest[section] ?? {})) {
if (section === 'peerDependencies' && manifest.peerDependenciesMeta?.[dep]?.optional === true) continue
if (PLACEMENT_OWNED_ELSEWHERE.has(dep)) continue
deps.push({ section, dep })
}
}
return deps
}
/**
* Test whether a Loader config names a package as a whole word.
* @param text - raw config text.
* @param dep - package name to look for.
* @returns true when the name appears outside a longer specifier.
*/
function namesPackage(text: string, dep: string): boolean {
const escaped = dep.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
return new RegExp(`(^|[^\\w@/.-])${escaped}(?![\\w.-])`).test(text)
}
interface Candidate {
readonly name: string
readonly relativeDir: string
readonly manifest: Manifest
readonly kind: Kind
}
const candidates: Candidate[] = []
for (const path of [
...globSync('packages/*/*/package.json', { cwd: root }),
...globSync('apps/*/package.json', { cwd: root }),
].sort()) {
const relativeDir = dirname(path)
const manifest = JSON.parse(readFileSync(join(root, path), 'utf8')) as Manifest
if (manifest.name === undefined) continue
const kind = kindOf(relativeDir, manifest)
if (kind !== undefined) candidates.push({ name: manifest.name, relativeDir, manifest, kind })
}
const offenders: Offender[] = []
const unchecked: string[] = []
for (const { name, relativeDir, manifest, kind } of candidates) {
const dir = join(root, relativeDir)
const entries = faceEntries(dir, manifest, kind)
// A generated Node entry carries runtime imports of its own that no source
// states, so this package's declarations cannot be judged from `src` alone.
if (entries.generated.length > 0) {
unchecked.push(`${name}: generated entry ${entries.generated.join(', ')}`)
continue
}
const host = walk(entries.host, dir)
const browser = walk(entries.browser, dir)
// A Loader row names its plugin package instead of importing it, so a config
// the package owns is part of its host face. YAML keys carry no quotes, so
// these are matched as whole names against the raw text.
const configs = globSync('cordis*.yml', { cwd: dir }).map(config => readFileSync(join(dir, config), 'utf8'))
const violations = installedDeps(manifest)
// A workspace name stays where the 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; the app installs the
// package regardless, so moving one saves no download while deleting what
// verify-runtime-closure and the Loader read. External packages are the
// download, and this gate is about the download.
.filter(({ dep }) => !dep.startsWith('@deepseek-ai/'))
.filter(({ dep }) => !host.has(dep) && !configs.some(text => namesPackage(text, dep)))
.map(({ section, dep }) => ({
section,
dep,
browserReferenced: browser.has(dep),
// A prebuilt bundle publishes no Node entry, so everything it declares is
// build-time by construction, named in its Vite graph rather than in src.
reached: kind === 'prebuilt-dist' || browser.has(dep) ? 'browser' as const : 'nothing' as const,
}))
if (violations.length > 0) offenders.push({ name, dir: relativeDir, kind, violations })
}
if (process.argv.includes('--json')) {
console.log(JSON.stringify(offenders, null, 2))
process.exit(0)
}
if (unchecked.length > 0) {
console.log(`verify-client-runtime-deps: ${String(unchecked.length)} package(s) not checked, no source states their entry's imports:`)
for (const entry of unchecked) console.log(` ${entry}`)
}
if (offenders.length > 0) {
const all = offenders.flatMap(offender => offender.violations)
console.error(`verify-client-runtime-deps: ${String(all.length)} build-time specifier(s) in installed sections:`)
for (const { name, dir, kind, violations } of offenders) {
console.error(` ${name} (${dir}, ${kind})`)
for (const { section, dep, reached } of violations) {
console.error(` ${section}.${dep} -> devDependencies [${reached}]`)
}
}
console.error('')
for (const reached of ['browser', 'nothing'] as const) {
const count = all.filter(violation => violation.reached === reached).length
if (count > 0) console.error(` ${String(count).padStart(4)} ${reached}: ${REASON[reached]}`)
}
console.error('\nDeclaration rules: packages/client/AGENTS.md.')
process.exit(1)
}
console.log(`verify-client-runtime-deps: browser-face specifiers are dev-only across ${String(candidates.length)} browser-facing packages.`)