fix(client): harden bootstrap invariants

This commit is contained in:
imccyu
2026-08-18 01:34:55 +08:00
parent 1c94446a56
commit 8088d2a6a0
13 changed files with 368 additions and 67 deletions
+129 -36
View File
@@ -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<Record<string, readonly string[]>>
/** Production source locations grouped by runtime-imported package name. */
readonly runtimeSourceUses: Readonly<Record<string, readonly string[]>>
/** Installed implementation dependencies. */
readonly dependencies: Readonly<Record<string, string>>
/** 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<string> {
const sourceFile = ts.createSourceFile(path, source, ts.ScriptTarget.Latest, true)
return collectSourceFilePackageUses(sourceFile)
return collectSourceFilePackageUses(sourceFile, false)
}
function collectSourceFilePackageUses(sourceFile: ts.SourceFile): Set<string> {
/**
* 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<string> {
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<string> {
const uses = new Set<string>()
const add = (specifier: ts.Expression | undefined): void => {
@@ -89,17 +123,19 @@ function collectSourceFilePackageUses(sourceFile: ts.SourceFile): Set<string> {
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, ReadonlySet<string>>,
): 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<string>
}
@@ -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<string>)
const expected = new Map<string, ExpectedRule>([
[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<Set<string>> {
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<string, string>()
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<ClientPackageFacts> {
@@ -694,17 +776,23 @@ async function readFacts(root: string): Promise<ClientPackageFacts> {
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<string, Set<string>>()
const runtimeSourceUses = new Map<string, Set<string>>()
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<string>()
locations.add(file)
sourceUses.set(name, locations)
}
for (const name of collectSourceFilePackageUses(sourceFile, true)) {
const locations = runtimeSourceUses.get(name) ?? new Set<string>()
locations.add(file)
runtimeSourceUses.set(name, locations)
}
}
packages.push({
...declaration,
@@ -713,6 +801,10 @@ async function readFacts(root: string): Promise<ClientPackageFacts> {
[...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<ClientPackageFacts> {
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,
}
}