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)