mirror of
https://github.com/deepseek-ai/deepseek-harness.git
synced 2026-08-29 04:26:38 +00:00
fix(client): validate exact external specifiers
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user