diff --git a/scripts/verify-client-packages.spec.ts b/scripts/verify-client-packages.spec.ts
index 83b1df437e..aa39b84316 100644
--- a/scripts/verify-client-packages.spec.ts
+++ b/scripts/verify-client-packages.spec.ts
@@ -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 =
',
"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 = ',
+ ].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)
diff --git a/scripts/verify-client-packages.ts b/scripts/verify-client-packages.ts
index 4c3c2da685..294cba6328 100644
--- a/scripts/verify-client-packages.ts
+++ b/scripts/verify-client-packages.ts
@@ -27,6 +27,8 @@ export interface ClientDeclaration {
readonly dynamic: boolean
readonly external: readonly string[]
readonly runtimeSourceUses: Readonly>
+ /** Exact runtime specifiers used to validate `dsh.client.external` declarations. */
+ readonly runtimeSourceSpecifiers: Readonly>
/** 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 {
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 {
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 {
+ 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 {
+function collectSourceFileUses(
+ sourceFile: ts.SourceFile,
+ runtimeOnly: boolean,
+ key: 'package' | 'specifier',
+): Set {
const uses = new Set()
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 {
const sourceFiles = project.sourceFiles()
const declarations = bareDeclarations.map((declaration): ClientDeclaration => {
const runtimeSourceUses = new Map>()
+ const runtimeSourceSpecifiers = new Map>()
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()
locations.add(file)
runtimeSourceUses.set(name, locations)
}
+ for (const specifier of collectSourceFileUses(sourceFile, true, 'specifier')) {
+ const locations = runtimeSourceSpecifiers.get(specifier) ?? new Set()
+ locations.add(file)
+ runtimeSourceSpecifiers.set(specifier, locations)
+ }
}
return {
...declaration,
@@ -790,6 +815,10 @@ async function readFacts(root: string): Promise {
[...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 {
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()
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()
locations.add(file)
runtimeSourceUses.set(name, locations)