import { realpathSync } from 'node:fs' import type ts from 'typescript' import { createRule, ensureParserWithTypeInformation } from '../rule.ts' export interface Options { ignore: string[] } const TYPESCRIPT_SOURCE_FILE = /\.(?:[cm]?ts|tsx)$/i const DECLARATION_FILE = /\.d\.(?:[cm]?ts|ts)$/i const programSources = new WeakMap() export default createRule({ name: 'require-project-reference', meta: { type: 'problem', docs: { description: 'Require project references for local package imports', requiresTypeChecking: true, }, schema: [ { type: 'object', properties: { ignore: { type: 'array', items: { type: 'string' }, uniqueItems: true }, }, additionalProperties: false, }, ], messages: { missingReference: 'This local package is not covered by a project reference. Add its project to this tsconfig.json references and enable declarationMap.', }, defaultOptions: [{ ignore: [] }] as [Options], }, create(context, [{ ignore }]) { ensureParserWithTypeInformation(context.sourceCode.parserServices) const { esTreeNodeToTSNodeMap, program } = context.sourceCode.parserServices return { ImportDeclaration(node) { if (!isPackageImport(node.source.value)) return if (ignore.some((prefix) => node.source.value.startsWith(prefix))) return const moduleSpecifier = esTreeNodeToTSNodeMap.get(node.source) const resolvedModule = getProgramNavigation(program).getResolvedModuleFromModuleSpecifier( moduleSpecifier as ts.StringLiteralLike, )?.resolvedModule if (!resolvedModule) return const resolvedFileName = realpath(resolvedModule.resolvedFileName) if (isInNodeModules(resolvedFileName)) return if (isCurrentProjectSource(program, resolvedFileName)) return if (DECLARATION_FILE.test(resolvedFileName)) { const redirect = getProjectReferenceRedirect(program, resolvedModule.resolvedFileName) if ( redirect && TYPESCRIPT_SOURCE_FILE.test(redirect.source) && isProjectReferenceSource(program, realpath(redirect.source)) ) { return } } else if (TYPESCRIPT_SOURCE_FILE.test(resolvedFileName)) { if (isProjectReferenceSource(program, resolvedFileName)) return } else { return } context.report({ node: node.source, messageId: 'missingReference' }) }, } }, }) function isPackageImport(specifier: string) { return !specifier.startsWith('.') && !specifier.startsWith('/') && !specifier.startsWith('node:') } function isCurrentProjectSource(program: ts.Program, fileName: string) { return getProgramSources(program).rootFiles.has(fileName) } function realpath(fileName: string) { try { return realpathSync.native(fileName) } catch { return fileName } } function isInNodeModules(fileName: string) { return fileName.split(/[\\/]/).includes('node_modules') } function getProjectReferenceRedirect(program: ts.Program, fileName: string) { const navigation = getProgramNavigation(program) return navigation.getRedirectFromOutput(navigation.getCanonicalFileName(fileName)) } function isProjectReferenceSource(program: ts.Program, fileName: string) { return getProgramSources(program).projectReferenceFiles.has(fileName) } function getProgramSources(program: ts.Program) { const cached = programSources.get(program) if (cached) return cached const sources = { rootFiles: new Set(program.getRootFileNames().map(realpath)), projectReferenceFiles: new Set( program .getResolvedProjectReferences() ?.flatMap((reference) => reference?.commandLine.fileNames.map(realpath) ?? []) ?? [], ), } programSources.set(program, sources) return sources } function getProgramNavigation(program: ts.Program): ProgramWithNavigation { return program as ts.Program & ProgramWithNavigation } interface ProgramWithNavigation { getResolvedModuleFromModuleSpecifier( moduleSpecifier: ts.StringLiteralLike, ): ts.ResolvedModuleWithFailedLookupLocations | undefined getCanonicalFileName(fileName: string): string getRedirectFromOutput(fileName: string): ProjectReferenceRedirect | undefined } interface ProjectReferenceRedirect { source: string } interface ProgramSources { rootFiles: Set projectReferenceFiles: Set }