fix(client): validate exact external specifiers

This commit is contained in:
imccyu
2026-08-23 23:54:03 +08:00
parent cad09dbcb7
commit b19752fda4
2 changed files with 75 additions and 12 deletions
+35 -1
View File
@@ -7,6 +7,7 @@ import { afterEach, describe, expect, it } from 'vitest'
import {
collectClientPackageViolations,
collectRuntimeSourcePackageUses,
collectRuntimeSourceSpecifiers,
collectSourcePackageUses,
fixClientPackageManifests,
readClientDeclarations,
@@ -33,6 +34,7 @@ function declaration(
external: [],
inject: [],
runtimeSourceUses: {},
runtimeSourceSpecifiers: {},
...fields,
}
}
@@ -76,7 +78,7 @@ describe('source package uses', () => {
const uses = collectSourcePackageUses('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')",
"const load = () => import('@deepseek-ai/dsh-b/remote')",
'export const view = <div />',
"export type { Local } from './local.ts'",
].join('\n'))
@@ -96,6 +98,14 @@ describe('source package uses', () => {
'@deepseek-ai/dsh-b',
'react',
])
expect([...collectRuntimeSourceSpecifiers('feature.tsx', [
"import type { A } from '@deepseek-ai/dsh-a/subpath'",
"const load = () => import('@deepseek-ai/dsh-b/remote')",
'export const view = <div />',
].join('\n'))].sort()).toEqual([
'@deepseek-ai/dsh-b/remote',
'react',
])
})
})
@@ -272,6 +282,9 @@ describe('module requests', () => {
runtimeSourceUses: {
'@deepseek-ai/dsh-api-gateway': ['packages/api/live/src/client/index.ts'],
},
runtimeSourceSpecifiers: {
'@deepseek-ai/dsh-api-gateway/client': ['packages/api/live/src/client/index.ts'],
},
}), manifest: 'packages/api/live/package.json' }
expect(collectClientPackageViolations(facts([], {
declarations: [gateway, stale, live],
@@ -281,6 +294,25 @@ describe('module requests', () => {
])
})
it('requires the exact external subpath to be imported at runtime', () => {
const gateway = {
...declaration('@deepseek-ai/dsh-api-gateway'), manifest: 'packages/api/gateway/package.json',
}
const subject = { ...declaration('@deepseek-ai/dsh-api-session-controller', {
external: ['@deepseek-ai/dsh-api-gateway/client'],
runtimeSourceUses: {
'@deepseek-ai/dsh-api-gateway': ['packages/api/session-controller/src/client/index.ts'],
},
runtimeSourceSpecifiers: {
'@deepseek-ai/dsh-api-gateway/remote': ['packages/api/session-controller/src/client/index.ts'],
},
}), manifest: 'packages/api/session-controller/package.json' }
expect(collectClientPackageViolations(facts([], { declarations: [gateway, subject] }))).toEqual([
subject.manifest + ': dsh.client.external "@deepseek-ai/dsh-api-gateway/client"'
+ ' has no runtime import or re-export in production source; remove the stale declaration',
])
})
it('rejects an explicit baseline request', () => {
const ui = declaration('ui', { external: ['react'] })
expect(collectClientPackageViolations(facts([], {
@@ -309,11 +341,13 @@ describe('module requests', () => {
external: ['@deepseek-ai/dsh-api-b'],
inject: ['@deepseek-ai/dsh-api-b'],
runtimeSourceUses: { '@deepseek-ai/dsh-api-b': ['packages/api/a/src/client.ts'] },
runtimeSourceSpecifiers: { '@deepseek-ai/dsh-api-b': ['packages/api/a/src/client.ts'] },
}), manifest: 'packages/api/a/package.json' }
const b = { ...declaration('@deepseek-ai/dsh-api-b', {
external: ['@deepseek-ai/dsh-api-a'],
inject: ['@deepseek-ai/dsh-api-a'],
runtimeSourceUses: { '@deepseek-ai/dsh-api-a': ['packages/client/b/src/client.ts'] },
runtimeSourceSpecifiers: { '@deepseek-ai/dsh-api-a': ['packages/client/b/src/client.ts'] },
}), manifest: 'packages/api/b/package.json' }
const found = collectClientPackageViolations(facts([], { declarations: [a, b] }))
expect(found).toHaveLength(1)
+40 -11
View File
@@ -27,6 +27,8 @@ export interface ClientDeclaration {
readonly dynamic: boolean
readonly external: readonly string[]
readonly runtimeSourceUses: Readonly<Record<string, readonly string[]>>
/** Exact runtime specifiers used to validate `dsh.client.external` declarations. */
readonly runtimeSourceSpecifiers: Readonly<Record<string, readonly string[]>>
/** Informational package dependencies declared by the row. */
readonly inject: readonly string[]
}
@@ -65,7 +67,7 @@ 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, false)
return collectSourceFileUses(sourceFile, false, 'package')
}
/**
@@ -76,7 +78,18 @@ export function collectSourcePackageUses(path: string, source: string): Set<stri
*/
export function collectRuntimeSourcePackageUses(path: string, source: string): Set<string> {
const sourceFile = ts.createSourceFile(path, source, ts.ScriptTarget.Latest, true)
return collectSourceFilePackageUses(sourceFile, true)
return collectSourceFileUses(sourceFile, true, 'package')
}
/**
* Collect exact bare specifiers retained by one production source file.
* @param path - File path used to select TypeScript's parser mode.
* @param source - Source text to inspect.
* @returns Exact specifiers retained by runtime imports, exports, requires, or JSX.
*/
export function collectRuntimeSourceSpecifiers(path: string, source: string): Set<string> {
const sourceFile = ts.createSourceFile(path, source, ts.ScriptTarget.Latest, true)
return collectSourceFileUses(sourceFile, true, 'specifier')
}
function importCarriesRuntimeValue(node: ts.ImportDeclaration): boolean {
@@ -98,12 +111,16 @@ function exportCarriesRuntimeValue(node: ts.ExportDeclaration): boolean {
return clause.elements.length === 0 || clause.elements.some(element => !element.isTypeOnly)
}
function collectSourceFilePackageUses(sourceFile: ts.SourceFile, runtimeOnly: boolean): Set<string> {
function collectSourceFileUses(
sourceFile: ts.SourceFile,
runtimeOnly: boolean,
key: 'package' | 'specifier',
): Set<string> {
const uses = new Set<string>()
const add = (specifier: ts.Expression | undefined): void => {
if (specifier === undefined || !ts.isStringLiteral(specifier) || !isBareSpecifier(specifier.text)) return
uses.add(packageNameOf(specifier.text))
uses.add(key === 'package' ? packageNameOf(specifier.text) : specifier.text)
}
const visit = (node: ts.Node): void => {
if (ts.isImportDeclaration(node)) {
@@ -571,8 +588,7 @@ function collectModuleViolations(facts: ClientPackageFacts): string[] {
)
continue
}
const owner = packageNameOf(specifier)
if (pkg.runtimeSourceUses[owner] === undefined) {
if (pkg.runtimeSourceSpecifiers[specifier] === undefined) {
violations.push(
pkg.manifest + ': dsh.client.external ' + JSON.stringify(specifier)
+ ' has no runtime import or re-export in production source; remove the stale declaration',
@@ -670,13 +686,15 @@ function readDeclaration(
const rawClient = dsh?.client
if (rawClient === undefined) {
return {
name: manifest.name, manifest: manifestPath, dynamic: false, external: [], inject: [], runtimeSourceUses: {},
name: manifest.name, manifest: manifestPath, dynamic: false, external: [], inject: [],
runtimeSourceUses: {}, runtimeSourceSpecifiers: {},
}
}
if (!isRecord(rawClient)) {
malformed.push(manifestPath + ': ' + manifest.name + ' dsh.client must be an object')
return {
name: manifest.name, manifest: manifestPath, dynamic: false, external: [], inject: [], runtimeSourceUses: {},
name: manifest.name, manifest: manifestPath, dynamic: false, external: [], inject: [],
runtimeSourceUses: {}, runtimeSourceSpecifiers: {},
}
}
return {
@@ -686,6 +704,7 @@ function readDeclaration(
external: stringArray(rawClient.external, manifest.name, manifestPath, 'external', malformed),
inject: stringArray(rawClient.inject, manifest.name, manifestPath, 'inject', malformed),
runtimeSourceUses: {},
runtimeSourceSpecifiers: {},
}
}
@@ -773,16 +792,22 @@ async function readFacts(root: string): Promise<ClientPackageFacts> {
const sourceFiles = project.sourceFiles()
const declarations = bareDeclarations.map((declaration): ClientDeclaration => {
const runtimeSourceUses = new Map<string, Set<string>>()
const runtimeSourceSpecifiers = new Map<string, Set<string>>()
const sourcePrefix = dirname(declaration.manifest) + '/src/'
for (const sourceFile of sourceFiles) {
if (sourceFile.isDeclarationFile) continue
const file = project.relativePath(sourceFile)
if (!file.startsWith(sourcePrefix)) continue
for (const name of collectSourceFilePackageUses(sourceFile, true)) {
for (const name of collectSourceFileUses(sourceFile, true, 'package')) {
const locations = runtimeSourceUses.get(name) ?? new Set<string>()
locations.add(file)
runtimeSourceUses.set(name, locations)
}
for (const specifier of collectSourceFileUses(sourceFile, true, 'specifier')) {
const locations = runtimeSourceSpecifiers.get(specifier) ?? new Set<string>()
locations.add(file)
runtimeSourceSpecifiers.set(specifier, locations)
}
}
return {
...declaration,
@@ -790,6 +815,10 @@ async function readFacts(root: string): Promise<ClientPackageFacts> {
[...runtimeSourceUses].sort(([left], [right]) => left.localeCompare(right))
.map(([name, locations]) => [name, [...locations].sort()]),
),
runtimeSourceSpecifiers: Object.fromEntries(
[...runtimeSourceSpecifiers].sort(([left], [right]) => left.localeCompare(right))
.map(([specifier, locations]) => [specifier, [...locations].sort()]),
),
}
})
const byManifest = new Map(declarations.map(entry => [entry.manifest, entry]))
@@ -808,12 +837,12 @@ async function readFacts(root: string): Promise<ClientPackageFacts> {
if (sourceFile.isDeclarationFile) continue
const file = project.relativePath(sourceFile)
if (!file.startsWith(sourcePrefix)) continue
for (const name of collectSourceFilePackageUses(sourceFile, false)) {
for (const name of collectSourceFileUses(sourceFile, false, 'package')) {
const locations = sourceUses.get(name) ?? new Set<string>()
locations.add(file)
sourceUses.set(name, locations)
}
for (const name of collectSourceFilePackageUses(sourceFile, true)) {
for (const name of collectSourceFileUses(sourceFile, true, 'package')) {
const locations = runtimeSourceUses.get(name) ?? new Set<string>()
locations.add(file)
runtimeSourceUses.set(name, locations)