/** 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 = 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, 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 { return manifest[name] ?? {} } export function mutableSection(manifest: PackageDependencyManifest, name: DependencySection): Record { 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 { const uses = new Set() 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 { 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 { 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 { 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 { 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 { 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 { const uses = new Map() 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 | 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 }