11import path from 'node:path' ;
2- import fs from 'node:fs/promises' ;
2+ import fs from 'node:fs' ;
3+ import ts from 'typescript' ;
34import { getTsconfig , createFilesMatcher } from 'get-tsconfig' ;
4- import { tsconfigDefaults as tsConfig , cacheDirName } from './constants.js' ;
5+ import { tsconfigDefaults } from './constants.js' ;
6+
7+ const createInMemoryProgram = ( files : string [ ] , cwd : string ) : ts . Program | undefined => {
8+ if ( files . length === 0 ) {
9+ return undefined ;
10+ }
11+
12+ try {
13+ const compilerOptions = getFallbackCompilerOptions ( cwd ) ;
14+ const program = ts . createProgram ( files , { ...compilerOptions } ) ;
15+ Object . defineProperty ( program , 'toJSON' , {
16+ value : ( ) => ( {
17+ __type : 'TypeScriptProgram' ,
18+ files : files . map ( file => path . relative ( cwd , file ) ) ,
19+ } ) ,
20+ configurable : true ,
21+ } ) ;
22+
23+ return program ;
24+ } catch ( error ) {
25+ console . warn (
26+ 'XO: Failed to create TypeScript Program for type-aware linting. Continuing without type information for unincluded files.' ,
27+ error instanceof Error ? error . message : String ( error ) ,
28+ ) ;
29+ return undefined ;
30+ }
31+ } ;
32+
33+ const fallbackCompilerOptionsCache = new Map < string , ts . CompilerOptions > ( ) ;
34+
35+ const getFallbackCompilerOptions = ( cwd : string ) : ts . CompilerOptions => {
36+ const cacheKey = path . resolve ( cwd ) ;
37+ const cached = fallbackCompilerOptionsCache . get ( cacheKey ) ;
38+
39+ if ( cached ) {
40+ return cached ;
41+ }
42+
43+ const compilerOptionsResult = ts . convertCompilerOptionsFromJson (
44+ tsconfigDefaults . compilerOptions ?? { } ,
45+ cacheKey ,
46+ ) ;
47+
48+ if ( compilerOptionsResult . errors . length > 0 ) {
49+ throw new Error ( 'XO: Invalid default TypeScript compiler options' ) ;
50+ }
51+
52+ const compilerOptions : ts . CompilerOptions = {
53+ ...compilerOptionsResult . options ,
54+ esModuleInterop : true ,
55+ resolveJsonModules : true ,
56+ allowJs : true ,
57+ skipLibCheck : true ,
58+ skipDefaultLibCheck : true ,
59+ } ;
60+
61+ fallbackCompilerOptionsCache . set ( cacheKey , compilerOptions ) ;
62+ return compilerOptions ;
63+ } ;
564
665/**
766This function checks if the files are matched by the tsconfig include, exclude, and it returns the unmatched files.
867
9- If no tsconfig is found, it will create a fallback tsconfig file in the `node_modules/.cache/xo` directory .
68+ If no tsconfig is found, it will create an in-memory TypeScript Program for type-aware linting .
1069
1170@param options
12- @returns The unmatched files.
71+ @returns The unmatched files and an in-memory TypeScript Program .
1372*/
14- export async function handleTsconfig ( { cwd, files } : { cwd : string ; files : string [ ] } ) {
73+ export function handleTsconfig ( { files , cwd, cacheLocation } : { files : string [ ] ; cwd : string ; cacheLocation ? : string } ) {
1574 const unincludedFiles : string [ ] = [ ] ;
75+ const filesMatcherCache = new Map < string , ReturnType < typeof createFilesMatcher > > ( ) ;
1676
1777 for ( const filePath of files ) {
1878 const result = getTsconfig ( filePath ) ;
@@ -22,7 +82,13 @@ export async function handleTsconfig({cwd, files}: {cwd: string; files: string[]
2282 continue ;
2383 }
2484
25- const filesMatcher = createFilesMatcher ( result ) ;
85+ const cacheKey = result . path ? path . resolve ( result . path ) : filePath ;
86+ let filesMatcher = filesMatcherCache . get ( cacheKey ) ;
87+
88+ if ( ! filesMatcher ) {
89+ filesMatcher = createFilesMatcher ( result ) ;
90+ filesMatcherCache . set ( cacheKey , filesMatcher ) ;
91+ }
2692
2793 if ( filesMatcher ( filePath ) ) {
2894 continue ;
@@ -31,17 +97,47 @@ export async function handleTsconfig({cwd, files}: {cwd: string; files: string[]
3197 unincludedFiles . push ( filePath ) ;
3298 }
3399
34- const fallbackTsConfigPath = path . join ( cwd , 'node_modules' , '.cache' , cacheDirName , 'tsconfig.xo.json' ) ;
100+ if ( unincludedFiles . length === 0 ) {
101+ return { existingFiles : [ ] , virtualFiles : [ ] , program : undefined } ;
102+ }
103+
104+ // Separate real files from virtual/cache files
105+ // Virtual files include: stdin files (in cache dir), non-existent files
106+ // TypeScript will surface opaque diagnostics for missing files; pre-filter so we only pay the program cost for real files.
107+ const existingFiles : string [ ] = [ ] ;
108+ const virtualFiles : string [ ] = [ ] ;
35109
36- tsConfig . files = unincludedFiles ;
110+ for ( const file of unincludedFiles ) {
111+ const fileExists = fs . existsSync ( file ) ;
37112
38- if ( unincludedFiles . length > 0 ) {
39- try {
40- await fs . writeFile ( fallbackTsConfigPath , JSON . stringify ( tsConfig , null , 2 ) ) ;
41- } catch ( error ) {
42- console . error ( error ) ;
113+ // Files that don't exist are always virtual
114+ if ( ! fileExists ) {
115+ virtualFiles . push ( file ) ;
116+ continue ;
117+ }
118+
119+ // Check if file is in cache directory (like stdin files)
120+ // These need tsconfig treatment even though they exist on disk
121+ if ( cacheLocation ) {
122+ const absolutePath = path . resolve ( file ) ;
123+ const cacheRoot = path . resolve ( cacheLocation ) ;
124+ const relativeToCache = path . relative ( cacheRoot , absolutePath ) ;
125+
126+ // File is inside cache if relative path doesn't escape (no '..')
127+ const isInCache = ! relativeToCache . startsWith ( '..' ) && ! path . isAbsolute ( relativeToCache ) ;
128+
129+ if ( isInCache ) {
130+ virtualFiles . push ( file ) ;
131+ continue ;
132+ }
43133 }
134+
135+ existingFiles . push ( file ) ;
44136 }
45137
46- return { unincludedFiles, fallbackTsConfigPath} ;
138+ return {
139+ existingFiles,
140+ virtualFiles,
141+ program : createInMemoryProgram ( existingFiles , cwd ) ,
142+ } ;
47143}
0 commit comments