@@ -16,7 +16,10 @@ interface FileIndexEntry {
1616let _fileIndexCache : Map < string , FileIndexEntry [ ] > | null = null ;
1717let _fileIndexCatalogDir : string | null = null ;
1818let _matterCache : Map < string , matter . GrayMatterFile < string > > | null = null ;
19- let _fileIndexMtimeMs : number = 0 ;
19+ // Tracks the creation time (birthtimeMs) of the catalog directory at last cache build.
20+ // birthtimeMs only changes when the directory is deleted and recreated (e.g. test teardown),
21+ // making it a reliable guard that avoids spurious rebuilds from nested write operations.
22+ let _fileIndexDirBirthtimeMs : number = 0 ;
2023
2124function buildFileCache ( catalogDir : string ) : void {
2225 const files = globSync ( '**/index.{md,mdx}' , {
@@ -53,9 +56,9 @@ function buildFileCache(catalogDir: string): void {
5356 _fileIndexCatalogDir = catalogDir ;
5457 _matterCache = matterResults ;
5558 try {
56- _fileIndexMtimeMs = fsSync . statSync ( catalogDir ) . mtimeMs ;
59+ _fileIndexDirBirthtimeMs = fsSync . statSync ( catalogDir ) . birthtimeMs ;
5760 } catch {
58- _fileIndexMtimeMs = 0 ;
61+ _fileIndexDirBirthtimeMs = 0 ;
5962 }
6063}
6164
@@ -64,10 +67,12 @@ function ensureFileCache(catalogDir: string): void {
6467 buildFileCache ( catalogDir ) ;
6568 return ;
6669 }
67- // Check if catalog dir was recreated (e.g. tests wiping and recreating)
70+ // Rebuild if the catalog directory was deleted and recreated (birthtimeMs changes on recreation).
71+ // Unlike mtimeMs, birthtimeMs is unaffected by nested file/directory writes, so it won't
72+ // trigger spurious rebuilds during normal catalog operations.
6873 try {
69- const currentMtime = fsSync . statSync ( catalogDir ) . mtimeMs ;
70- if ( currentMtime !== _fileIndexMtimeMs ) {
74+ const currentBirthtime = fsSync . statSync ( catalogDir ) . birthtimeMs ;
75+ if ( currentBirthtime !== _fileIndexDirBirthtimeMs ) {
7176 buildFileCache ( catalogDir ) ;
7277 }
7378 } catch {
@@ -198,7 +203,48 @@ export const findFileById = async (catalogDir: string, id: string, version?: str
198203 return undefined ;
199204} ;
200205
206+ /**
207+ * Converts a glob pattern to a RegExp. Handles `**`, `*`, `{a,b}` and `.` escaping.
208+ * Sufficient for the limited patterns used in getFiles.
209+ */
210+ function globToRegex ( pattern : string ) : RegExp {
211+ const normalized = pattern . replace ( / \\ / g, '/' ) ;
212+ const regexStr = normalized
213+ . replace ( / [ . + ^ $ { } ( ) | [ \] \\ ] / g, ( ch ) => {
214+ // Keep { } and handle them specially below; escape everything else
215+ if ( ch === '{' || ch === '}' ) return ch ;
216+ return `\\${ ch } ` ;
217+ } )
218+ . replace ( / \{ ( [ ^ } ] + ) \} / g, ( _ , choices ) => `(${ choices . split ( ',' ) . join ( '|' ) } )` )
219+ . replace ( / \* \* / g, '\u0000' ) // temp placeholder
220+ . replace ( / \* / g, '[^/]*' )
221+ . replace ( / \u0000 \/ / g, '(?:.+/)?' ) // **/ → optional nested path
222+ . replace ( / \u0000 / g, '.*' ) ; // remaining ** (at end)
223+ return new RegExp ( `^${ regexStr } $` , 'i' ) ;
224+ }
225+
201226export const getFiles = async ( pattern : string , ignore : string | string [ ] = '' ) => {
227+ // Fast path: if the file index cache is warm for this catalog dir, filter cached
228+ // paths by the pattern instead of performing an expensive glob on the file system.
229+ // Only applies when the pattern targets index.{md,mdx} files — the cache only
230+ // stores those files, so non-index patterns (e.g. teams/*.md) must fall through.
231+ if ( _fileIndexCache && _matterCache && _fileIndexCatalogDir ) {
232+ const normalizedCatalogDir = normalize ( _fileIndexCatalogDir ) . replace ( / \\ / g, '/' ) ;
233+ const normalizedPattern = normalize ( pattern ) . replace ( / \\ / g, '/' ) ;
234+ if (
235+ normalizedPattern . startsWith ( normalizedCatalogDir ) &&
236+ normalizedPattern . includes ( 'index.{md,mdx}' )
237+ ) {
238+ const ignoreList = ( Array . isArray ( ignore ) ? ignore : [ ignore ] ) . filter ( Boolean ) ;
239+ const matchRegex = globToRegex ( normalizedPattern ) ;
240+ const ignoreRegexes = ignoreList . map ( ( ig ) => globToRegex ( ig . replace ( / \\ / g, '/' ) ) ) ;
241+ return Array . from ( _matterCache . keys ( ) )
242+ . map ( ( p ) => p . replace ( / \\ / g, '/' ) )
243+ . filter ( ( p ) => matchRegex . test ( p ) && ! ignoreRegexes . some ( ( ig ) => ig . test ( p ) ) )
244+ . map ( normalize ) ;
245+ }
246+ }
247+
202248 try {
203249 // 1. Normalize the input pattern to handle mixed separators potentially
204250 const normalizedInputPattern = normalize ( pattern ) ;
@@ -252,10 +298,27 @@ export const readMdxFile = async (path: string) => {
252298} ;
253299
254300export const searchFilesForId = async ( files : string [ ] , id : string , version ?: string ) => {
255- // Escape the id to avoid regex issues
301+ // Fast path: if the file index cache is warm we can resolve by id directly
302+ // without reading any files from disk — O(1) map lookup + set intersection.
303+ if ( _fileIndexCache ) {
304+ const entries = _fileIndexCache . get ( id ) ;
305+ if ( entries ) {
306+ const filesSet = new Set ( files . map ( normalize ) ) ;
307+ return entries
308+ . filter ( ( e ) => {
309+ if ( ! filesSet . has ( e . path ) ) return false ;
310+ if ( version && e . version !== version ) return false ;
311+ return true ;
312+ } )
313+ . map ( ( e ) => e . path ) ;
314+ }
315+ // id not found in cache means no match in these files
316+ return [ ] ;
317+ }
318+
319+ // Slow path: read each file from disk and match by id/version regex
256320 const escapedId = id . replace ( / [ . * + ? ^ $ { } ( ) | [ \] \\ ] / g, '\\$&' ) ;
257321 const idRegex = new RegExp ( `^id:\\s*(['"]|>-)?\\s*${ escapedId } ['"]?\\s*$` , 'm' ) ;
258-
259322 const versionRegex = new RegExp ( `^version:\\s*['"]?${ version } ['"]?\\s*$` , 'm' ) ;
260323
261324 const matches = files . map ( ( file ) => {
0 commit comments