From 6e77499326e225d53d856a793ce0cb8e27879fde Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Fri, 14 Aug 2026 20:18:35 +0800 Subject: [PATCH] feat(scripts): gate browser-only dependencies out of the install face verify-client-runtime-deps walks each package's host and browser faces from the entries its manifest publishes, over the bound host and client Programs, and reports an external package no host-face reference reaches. Wired into hygiene. A workspace name is out of scope: it also states which package supplies an injected service or a mounted Remote contribution, and the app installs it either way. A package whose published Node entry has no source counterpart is skipped and named, because a generated artifact carries imports no source states. --- package.json | 3 +- scripts/verify-client-runtime-deps.ts | 368 ++++++++++++++++++++++++++ 2 files changed, 370 insertions(+), 1 deletion(-) create mode 100644 scripts/verify-client-runtime-deps.ts diff --git a/package.json b/package.json index 517d0c56d1..6f2346e2e6 100644 --- a/package.json +++ b/package.json @@ -102,6 +102,7 @@ "rescope-vendor": "tsx scripts/rescope-vendor.ts", "rescope-vendor:check": "tsx scripts/rescope-vendor.ts --check", "verify-client-domain-graph": "tsx scripts/verify-client-domain-graph.ts", + "verify-client-runtime-deps": "tsx scripts/verify-client-runtime-deps.ts", "gen-cordis-catalog": "tsx scripts/gen-cordis-catalog.ts", "verify-cordis-catalog": "tsx scripts/gen-cordis-catalog.ts --check", "gen-cordis-api": "tsx scripts/gen-cordis-api.ts", @@ -126,7 +127,7 @@ "verify-module-graph": "tsx scripts/gen-module-graph.ts --check", "constraints": "tsx scripts/check-workspace-constraints.ts", "doc-sync": "tsx scripts/run-gates.ts doc-sync", - "hygiene": "pnpm run rescope-vendor:check && pnpm run knip && pnpm run publint && pnpm run constraints && pnpm run verify-dsh-package-licenses && pnpm run verify-package-invariants && pnpm run verify-built-package-invariants && pnpm run verify-cordis-config && pnpm run verify-node-next-types && pnpm run verify-optional-dependency-imports && pnpm run verify-runtime-closure && pnpm run verify-vendored-links", + "hygiene": "pnpm run rescope-vendor:check && pnpm run knip && pnpm run publint && pnpm run constraints && pnpm run verify-dsh-package-licenses && pnpm run verify-package-invariants && pnpm run verify-built-package-invariants && pnpm run verify-cordis-config && pnpm run verify-node-next-types && pnpm run verify-optional-dependency-imports && pnpm run verify-runtime-closure && pnpm run verify-client-runtime-deps && pnpm run verify-vendored-links", "publish:npm-baseline": "tsx scripts/publish-npm-baseline.ts", "release:dsh": "tsx scripts/release/bump.ts --family dsh", "release:vendor": "tsx scripts/release/bump.ts --family vendor", diff --git a/scripts/verify-client-runtime-deps.ts b/scripts/verify-client-runtime-deps.ts new file mode 100644 index 0000000000..0d42f8c135 --- /dev/null +++ b/scripts/verify-client-runtime-deps.ts @@ -0,0 +1,368 @@ +/** + * 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 + dependencies?: Record + peerDependencies?: Record + peerDependenciesMeta?: Record +} + +/** 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 = { + 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() +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() +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 { + const found = new Set() + const seen = new Set() + 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.`)