Files
deepseek-harness/scripts/source-parse-uses.ts
T
2026-10-24 14:58:40 +08:00

227 lines
9.7 KiB
TypeScript

/** Source-text parsing shared by the package dependency and client package gates. */
import { globSync, readFileSync } from 'node:fs'
import { isBuiltin } from 'node:module'
import { resolve, sep } from 'node:path'
import ts from 'typescript'
import type { PackageDependencyManifest, WorkspacePackageManifest } from './verify-package-dependencies.ts'
export type DependencySection = 'dependencies' | 'devDependencies' | 'optionalDependencies' | 'peerDependencies'
export const ROOT_TYPE_PACKAGES: ReadonlySet<string> = new Set(['@types/react', '@types/react-dom'])
/**
* Report whether a specifier names a bare package rather than a path or subpath import.
* @param specifier - module specifier text.
* @returns whether the specifier resolves outside this package.
*/
function isBareSpecifier(specifier: string): boolean {
return !specifier.startsWith('.') && !specifier.startsWith('/') && !specifier.startsWith('#')
}
/**
* Read the leading package segments of one bare module specifier.
* @param specifier - module specifier text that already passed {@link isBareSpecifier}.
* @returns the package name this specifier belongs to.
*/
function packageNameForUses(specifier: string): string {
const segments = specifier.split('/')
return segments.slice(0, specifier.startsWith('@') ? 2 : 1).join('/')
}
/**
* Read the bare package name of one module specifier.
* @param specifier - module specifier text.
* @returns the package name, or undefined for paths, builtins, and non-package specifiers.
*/
export function packageNameOf(specifier: string): string | undefined {
if (isBuiltin(specifier) || specifier.startsWith('.') || specifier.startsWith('/') || specifier.startsWith('#')
|| specifier.includes(':') || specifier.includes('*')) {
return undefined
}
const parts = specifier.split('/')
return specifier.startsWith('@') ? parts.length >= 2 ? `${parts[0]}/${parts[1]}` : undefined : parts[0]
}
/**
* Read package manifests used for scope discovery and workspace-name checks.
* @param root - Repository containing the workspace manifests.
* @returns Every workspace package, the non-experimental release packages, and the packages the policy covers.
*/
export function addUse(target: Map<string, string[]>, name: string, path: string): void {
const paths = target.get(name) ?? []
if (!paths.includes(path)) paths.push(path)
target.set(name, paths)
}
export function section(manifest: PackageDependencyManifest, name: DependencySection): Record<string, string> {
return manifest[name] ?? {}
}
export function mutableSection(manifest: PackageDependencyManifest, name: DependencySection): Record<string, string> {
manifest[name] ??= {}
return manifest[name]
}
export function declaredSections(manifest: PackageDependencyManifest, name: string): DependencySection[] {
return (['dependencies', 'devDependencies', 'optionalDependencies', 'peerDependencies'] as const)
.filter(sectionName => section(manifest, sectionName)[name] !== undefined)
}
function importCarriesRuntimeValue(node: ts.ImportDeclaration): boolean {
const clause = node.importClause
if (clause === undefined) return true
if (clause.phaseModifier === ts.SyntaxKind.TypeKeyword) return false
const bindings = clause.namedBindings
return clause.name !== undefined
|| bindings === undefined
|| ts.isNamespaceImport(bindings)
|| 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)
}
export function collectSourceFileUses(
sourceFile: ts.SourceFile,
runtimeOnly: boolean,
key: 'local' | 'package' | 'specifier',
includeRootRelative = false,
): Set<string> {
const uses = new Set<string>()
const add = (specifier: ts.Expression | undefined): void => {
if (specifier === undefined || !ts.isStringLiteralLike(specifier)) return
if (key === 'local') {
if (specifier.text.startsWith('.') || includeRootRelative && specifier.text.startsWith('/')) uses.add(specifier.text)
return
}
if (!isBareSpecifier(specifier.text)) return
uses.add(key === 'package' ? packageNameForUses(specifier.text) : specifier.text)
}
const visit = (node: ts.Node): void => {
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)) {
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 (!runtimeOnly && key !== 'local' && ts.isModuleDeclaration(node) && ts.isStringLiteral(node.name)) {
add(node.name)
} else if (key !== 'local'
&& (ts.isJsxElement(node) || ts.isJsxSelfClosingElement(node) || ts.isJsxFragment(node))) {
uses.add('react')
}
ts.forEachChild(node, visit)
}
visit(sourceFile)
return uses
}
/**
* Read browser-module declarations from workspace manifests.
* @param root - Absolute repository root.
* @returns Declarations and malformed dsh.client fields.
*/
export function collectSourcePackageUses(path: string, source: string): Set<string> {
const sourceFile = ts.createSourceFile(path, source, ts.ScriptTarget.Latest, true)
return collectSourceFileUses(sourceFile, false, 'package')
}
/**
* 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 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')
}
/**
* Collect relative module specifiers used to follow one source entry's local closure.
* @param path - File path used to select TypeScript's parser mode.
* @param source - Source text to inspect.
* @returns Relative imports, exports, requires, and import types.
*/
export function collectLocalSourceSpecifiers(path: string, source: string): Set<string> {
const sourceFile = ts.createSourceFile(path, source, ts.ScriptTarget.Latest, true)
return collectSourceFileUses(sourceFile, false, 'local')
}
/**
* Collect local module specifiers retained by one production source file.
* @param path - File path used to select TypeScript's parser mode.
* @param source - Source text to inspect.
* @param includeRootRelative - Include absolute paths, such as Vite's Web-root imports.
* @returns Local imports, exports, and requires that survive compilation.
*/
export function collectRuntimeLocalSourceSpecifiers(path: string, source: string, includeRootRelative = false): Set<string> {
const sourceFile = ts.createSourceFile(path, source, ts.ScriptTarget.Latest, true)
return collectSourceFileUses(sourceFile, true, 'local', includeRootRelative)
}
/**
* Collect package-asset module 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 Relative runtime imports, re-exports, and requires whose extension has a package-asset media type.
*/
/**
* Normalize a repository path to forward slashes.
* @param path - platform path inside the repository.
* @returns the same path with POSIX separators.
*/
export function normalizePath(path: string): string {
return path.split(sep).join('/')
}
export function readAllSourceUses(root: string, pkg: WorkspacePackageManifest): Map<string, string[]> {
const uses = new Map<string, string[]>()
for (const sourcePath of globSync('src/**/*.{ts,tsx,mts,cts}', { cwd: resolve(root, pkg.dir) }).sort()) {
const source = readFileSync(resolve(root, pkg.dir, sourcePath), 'utf8')
const displayPath = `${pkg.dir}/${normalizePath(sourcePath)}`
let runtimeUses: Set<string> | undefined
for (const specifier of collectSourcePackageUses(sourcePath, source)) {
const name = packageNameOf(specifier)
if (name === undefined) continue
const typesName = `@types/${name.replace(/^@/, '').replace('/', '__')}`
if (!ROOT_TYPE_PACKAGES.has(typesName)
&& declaredSections(pkg.manifest, name).length === 0
&& declaredSections(pkg.manifest, typesName).length > 0) {
runtimeUses ??= collectRuntimeSourcePackageUses(sourcePath, source)
if (!runtimeUses.has(name)) {
addUse(uses, typesName, displayPath)
continue
}
}
addUse(uses, name, displayPath)
}
}
return uses
}