import { readdir, readFile, realpath } from 'node:fs/promises' import { dirname, extname, isAbsolute, join, relative, resolve, sep } from 'node:path' import { scanSource, type SourceFile } from './source.js' const ignoredDirectories = new Set([ '.astrale', '.dist', '.domain-studio', '.git', '.output', '.turbo', '.wrangler', 'coverage', 'dist', 'dist-client', 'node_modules', ]) const sourceExtensions = new Set(['.cjs', '.cts', '.js', '.jsx', '.mjs', '.mts', '.ts', '.tsx']) export type DomainProject = { root: string files: readonly SourceFile[] filesByPath: ReadonlyMap packageScopes: readonly PackageScope[] } type PackageScope = { directory: string imports: ReadonlyMap } type PackageJson = { imports?: Record } export async function discoverProject(root: string): Promise { const projectRoot = await realpath(resolve(root)) const paths: string[] = [] const manifests: string[] = [] await collectProjectPaths(projectRoot, paths, manifests) paths.sort() const files = await Promise.all( paths.map(async (path) => { const text = await readFile(path, 'utf8') return scanSource(path, normalizePath(relative(projectRoot, path)), text) }), ) return { root: projectRoot, files, filesByPath: new Map(files.map((file) => [file.path, file])), packageScopes: await loadPackageScopes(manifests), } } async function collectProjectPaths( directory: string, sources: string[], manifests: string[], ): Promise { const entries = await readdir(directory, { withFileTypes: true }) await Promise.all( entries.map(async (entry) => { if (entry.isSymbolicLink()) return const path = join(directory, entry.name) if (entry.isDirectory()) { if (!ignoredDirectories.has(entry.name)) { await collectProjectPaths(path, sources, manifests) } return } if (entry.isFile() && entry.name === 'package.json') manifests.push(path) if (!entry.isFile() || !sourceExtensions.has(extname(entry.name))) return if (entry.name.endsWith('.d.ts') || entry.name.includes('.gen.')) return sources.push(path) }), ) } async function loadPackageScopes(manifests: readonly string[]): Promise { const scopes = await Promise.all( manifests.map(async (manifest): Promise => { let packageJson: PackageJson try { packageJson = JSON.parse(await readFile(manifest, 'utf8')) as PackageJson } catch { return undefined } const imports = new Map() for (const [specifier, target] of Object.entries(packageJson.imports ?? {})) { if (specifier.startsWith('#') && typeof target === 'string') imports.set(specifier, target) } return { directory: dirname(manifest), imports } }), ) return scopes .filter((scope): scope is PackageScope => scope !== undefined) .sort((a, b) => b.directory.length - a.directory.length) } export function normalizePath(path: string): string { return sep === '/' ? path : path.split(sep).join('/') } export function isProductionSource(relativePath: string): boolean { const normalized = normalizePath(relativePath) return ( !normalized.split('/').includes('__tests__') && !/\.(?:test|spec|bench|perf)\.[cm]?[jt]sx?$/.test(normalized) && !normalized.includes('.gen.') ) } export function isProductionCore(relativePath: string): boolean { const normalized = normalizePath(relativePath) return (normalized === 'core' || normalized.startsWith('core/')) && isProductionSource(normalized) } export function resolveProjectImport( project: DomainProject, importer: SourceFile, specifier: string, ): SourceFile | undefined { const unresolved = specifier.startsWith('.') ? resolve(dirname(importer.path), specifier) : resolvePackageImport(project, importer, specifier) if (!unresolved) return undefined for (const candidate of importCandidates(unresolved)) { const file = project.filesByPath.get(candidate) if (file) return file } return undefined } function resolvePackageImport( project: DomainProject, importer: SourceFile, specifier: string, ): string | undefined { if (!specifier.startsWith('#')) return undefined const scope = project.packageScopes.find(({ directory }) => isWithin(directory, importer.path)) if (!scope) return undefined const target = scope.imports.get(specifier) ?? matchingImportTarget(scope.imports, specifier) if (!target?.startsWith('./')) return undefined const path = resolve(scope.directory, target) return isWithin(scope.directory, path) ? path : undefined } function matchingImportTarget( imports: ReadonlyMap, specifier: string, ): string | undefined { let best: { specificity: number; target: string } | undefined for (const [pattern, target] of imports) { const star = pattern.indexOf('*') if (star < 0 || star !== pattern.lastIndexOf('*')) continue const prefix = pattern.slice(0, star) const suffix = pattern.slice(star + 1) if (!specifier.startsWith(prefix) || !specifier.endsWith(suffix)) continue const matchEnd = suffix.length === 0 ? specifier.length : specifier.length - suffix.length const match = specifier.slice(prefix.length, matchEnd) const specificity = prefix.length + suffix.length if (!best || specificity > best.specificity) { best = { specificity, target: target.replace('*', match) } } } return best?.target } function isWithin(directory: string, path: string): boolean { const child = relative(directory, path) return child === '' || (!child.startsWith('..') && !isAbsolute(child)) } function importCandidates(path: string): string[] { const extension = extname(path) const candidates = [path] if (extension === '.js' || extension === '.jsx' || extension === '.mjs' || extension === '.cjs') { const stem = path.slice(0, -extension.length) candidates.push(`${stem}.ts`, `${stem}.tsx`, `${stem}.mts`, `${stem}.cts`) } if (!extension) { candidates.push(`${path}.ts`, `${path}.tsx`, `${path}.mts`, `${path}.cts`) candidates.push(join(path, 'index.ts'), join(path, 'index.tsx')) } return candidates.map((candidate) => (isAbsolute(candidate) ? candidate : resolve(candidate))) }