/ utils / plugins / orphanedPluginFilter.ts
orphanedPluginFilter.ts
  1  /**
  2   * Provides ripgrep glob exclusion patterns for orphaned plugin versions.
  3   *
  4   * When plugin versions are updated, old versions are marked with a
  5   * `.orphaned_at` file but kept on disk for 7 days (since concurrent
  6   * sessions might still reference them). During this window, Grep/Glob
  7   * could return files from orphaned versions, causing Claude to use
  8   * outdated plugin code.
  9   *
 10   * We find `.orphaned_at` markers via a single ripgrep call and generate
 11   * `--glob '!<dir>/**'` patterns for their parent directories. The cache
 12   * is warmed in main.tsx AFTER cleanupOrphanedPluginVersionsInBackground
 13   * settles disk state. Once populated, the exclusion list is frozen for
 14   * the session unless /reload-plugins is called; subsequent disk mutations
 15   * (autoupdate, concurrent sessions) don't affect it.
 16   */
 17  
 18  import { dirname, isAbsolute, join, normalize, relative, sep } from 'path'
 19  import { ripGrep } from '../ripgrep.js'
 20  import { getPluginsDirectory } from './pluginDirectories.js'
 21  
 22  // Inlined from cacheUtils.ts to avoid a circular dep through commands.js.
 23  const ORPHANED_AT_FILENAME = '.orphaned_at'
 24  
 25  /** Session-scoped cache. Frozen once computed — only cleared by explicit /reload-plugins. */
 26  let cachedExclusions: string[] | null = null
 27  
 28  /**
 29   * Get ripgrep glob exclusion patterns for orphaned plugin versions.
 30   *
 31   * @param searchPath - When provided, exclusions are only returned if the
 32   *   search overlaps the plugin cache directory (avoids unnecessary --glob
 33   *   args for searches outside the cache).
 34   *
 35   * Warmed eagerly in main.tsx after orphan GC; the lazy-compute path here
 36   * is a fallback. Best-effort: returns empty array if anything goes wrong.
 37   */
 38  export async function getGlobExclusionsForPluginCache(
 39    searchPath?: string,
 40  ): Promise<string[]> {
 41    const cachePath = normalize(join(getPluginsDirectory(), 'cache'))
 42  
 43    if (searchPath && !pathsOverlap(searchPath, cachePath)) {
 44      return []
 45    }
 46  
 47    if (cachedExclusions !== null) {
 48      return cachedExclusions
 49    }
 50  
 51    try {
 52      // Find all .orphaned_at files within the plugin cache directory.
 53      // --hidden: marker is a dotfile. --no-ignore: don't let a stray
 54      // .gitignore hide it. --max-depth 4: marker is always at
 55      // cache/<marketplace>/<plugin>/<version>/.orphaned_at — don't recurse
 56      // into plugin contents (node_modules, etc.). Never-aborts signal: no
 57      // caller signal to thread.
 58      const markers = await ripGrep(
 59        [
 60          '--files',
 61          '--hidden',
 62          '--no-ignore',
 63          '--max-depth',
 64          '4',
 65          '--glob',
 66          ORPHANED_AT_FILENAME,
 67        ],
 68        cachePath,
 69        new AbortController().signal,
 70      )
 71  
 72      cachedExclusions = markers.map(markerPath => {
 73        // ripgrep may return absolute or relative — normalize to relative.
 74        const versionDir = dirname(markerPath)
 75        const rel = isAbsolute(versionDir)
 76          ? relative(cachePath, versionDir)
 77          : versionDir
 78        // ripgrep glob patterns always use forward slashes, even on Windows
 79        const posixRelative = rel.replace(/\\/g, '/')
 80        return `!**/${posixRelative}/**`
 81      })
 82      return cachedExclusions
 83    } catch {
 84      // Best-effort — don't break core search tools if ripgrep fails here
 85      cachedExclusions = []
 86      return cachedExclusions
 87    }
 88  }
 89  
 90  export function clearPluginCacheExclusions(): void {
 91    cachedExclusions = null
 92  }
 93  
 94  /**
 95   * One path is a prefix of the other. Special-cases root (normalize('/') + sep
 96   * = '//'). Case-insensitive on win32 since normalize() doesn't lowercase
 97   * drive letters and CLAUDE_CODE_PLUGIN_CACHE_DIR may disagree with resolved.
 98   */
 99  function pathsOverlap(a: string, b: string): boolean {
100    const na = normalizeForCompare(a)
101    const nb = normalizeForCompare(b)
102    return (
103      na === nb ||
104      na === sep ||
105      nb === sep ||
106      na.startsWith(nb + sep) ||
107      nb.startsWith(na + sep)
108    )
109  }
110  
111  function normalizeForCompare(p: string): string {
112    const n = normalize(p)
113    return process.platform === 'win32' ? n.toLowerCase() : n
114  }