diff --git a/.changeset/at-mention-readdir-fallback.md b/.changeset/at-mention-readdir-fallback.md new file mode 100644 index 00000000..ea9df9d7 --- /dev/null +++ b/.changeset/at-mention-readdir-fallback.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +`@` file completion now works in non-git directories. diff --git a/apps/kimi-code/src/tui/components/editor/file-mention-provider.ts b/apps/kimi-code/src/tui/components/editor/file-mention-provider.ts index d2a7d39c..630e3200 100644 --- a/apps/kimi-code/src/tui/components/editor/file-mention-provider.ts +++ b/apps/kimi-code/src/tui/components/editor/file-mention-provider.ts @@ -26,11 +26,14 @@ * * When `fd` is available the inner pi-tui provider owns the `@` branch * verbatim — its fd invocation respects `.gitignore` and is strictly - * better than anything we can cheaply reproduce in TS. We only kick in - * when `fd` is missing AND we're in a git repo. + * better than anything we can cheaply reproduce in TS. When `fd` is + * missing, we only fall back to our own recursive readdir when the + * work dir is not a git repository; inside a git repo we trust the + * `git ls-files` snapshot to honor `.gitignore`. */ -import { basename } from 'node:path'; +import { existsSync, readdirSync, statSync } from 'node:fs'; +import { basename, join } from 'node:path'; import { CombinedAutocompleteProvider, @@ -46,6 +49,35 @@ import type { GitLsFilesCache, GitSnapshot } from '#/utils/git/git-ls-files'; const MAX_SUGGESTIONS_WHEN_QUERY = 50; const MAX_SUGGESTIONS_WHEN_EMPTY = 15; +const READDIR_TTL_MS = 2000; +const READDIR_MAX_ENTRIES = 1000; +const READDIR_MAX_DEPTH = 8; + +// Directories that are typically too large or too auto-generated to be +// useful for @-completion. Skipping them keeps the walk snappy on +// real-world repos that don't have fd or git. +const SKIP_DIRS = new Set([ + '.git', + 'node_modules', + 'dist', + 'build', + '.next', + '.turbo', + '.parcel-cache', + '.cache', + '__pycache__', + '.venv', + 'target', + '.idea', + '.vscode', +]); + +/** Structurally compatible with `GitSnapshot` so existing rankers accept it. */ +interface ReadDirSnapshot { + readonly files: readonly string[]; + readonly mtimeByPath: ReadonlyMap; + readonly recencyOrder: ReadonlyMap; +} // Mirrors pi-tui's PATH_DELIMITERS. Keeping a local copy so @-detection // stays aligned even if pi-tui extends its set. @@ -53,6 +85,7 @@ const PATH_DELIMITERS = new Set([' ', '\t', '"', "'", '=']); export class FileMentionProvider implements AutocompleteProvider { private readonly inner: CombinedAutocompleteProvider; + private readonly readDirWalker: ReadDirWalker; constructor( slashCommands: SlashCommand[], @@ -61,6 +94,7 @@ export class FileMentionProvider implements AutocompleteProvider { private readonly gitCache: GitLsFilesCache, ) { this.inner = new CombinedAutocompleteProvider(slashCommands, workDir, fdPath); + this.readDirWalker = new ReadDirWalker(workDir); } async getSuggestions( @@ -85,8 +119,30 @@ export class FileMentionProvider implements AutocompleteProvider { return this.inner.getSuggestions(lines, cursorLine, cursorCol, options); } + if (!this.gitCache.isGitRepo()) { + // Not in a git repo (stable for the cache's lifetime — `isGitRepo` + // is captured at TUI startup by `git rev-parse --show-toplevel`). + // Transient `git ls-files` failures inside a real repo leave + // `getSnapshot()` returning null but `isGitRepo()` still true, in + // which case we deliberately do NOT fall back to raw readdir + // (that would bypass `.gitignore`). Inner's getFuzzyFileSuggestions + // is a dead end without `fd`, so we own the candidate source here. + // See issue #266. + const readdirResult = this.buildFromReadDir(atPrefix); + if (readdirResult !== null) { + return { items: readdirResult, prefix: atPrefix }; + } + return this.inner.getSuggestions(lines, cursorLine, cursorCol, options); + } const snapshot = this.gitCache.getSnapshot(); - if (snapshot === null || snapshot.files.length === 0) { + if (snapshot === null) { + // Inside a git repo but the snapshot fetch failed transiently + // (e.g. `git ls-files` returned non-zero, lock contention, or + // the index mtime lookup raced). Don't consult raw readdir — + // it would bypass `.gitignore` and could surface ignored files. + // Fall through to the inner provider, which can still resolve + // `/path` or quoted-path completions; on failure it returns + // null and the editor dismisses the menu. return this.inner.getSuggestions(lines, cursorLine, cursorCol, options); } @@ -102,14 +158,40 @@ export class FileMentionProvider implements AutocompleteProvider { : rankForQuery(candidates, query, snapshot); if (items.length === 0) { - // Git cache had nothing useful — fall through to readdir (user - // may be typing a path that exists but isn't tracked, e.g. a - // freshly created file not yet in the 2s cache). + // Git ls-files had no match for this query. Inside a git repo we + // do NOT consult readdir — a recursive readdir would bypass + // `git ls-files --exclude-standard` and could surface + // .gitignored paths. Fall through to the inner provider, which + // can still resolve `/path` or quoted-path completions. return this.inner.getSuggestions(lines, cursorLine, cursorCol, options); } return { items, prefix: atPrefix }; } + private buildFromReadDir(atPrefix: string): AutocompleteItem[] | null { + const snapshot = this.readDirWalker.getSnapshot(); + if (snapshot === null || snapshot.files.length === 0) { + return null; + } + const query = atPrefix.slice(1); + const includeDotDirs = query.startsWith('.'); + const candidates = includeDotDirs + ? snapshot.files + : snapshot.files.filter((p) => !containsDotSegment(p)); + if (candidates.length === 0) { + return null; + } + const ranked = + query.length === 0 + ? rankForEmptyQuery(candidates, snapshot) + : rankForQuery(candidates, query, snapshot); + // An empty ranking means the walker saw files but none matched the + // query. Returning `null` (rather than `{ items: [] }`) lets the + // caller dismiss the autocomplete menu instead of presenting an + // empty state. + return ranked.length === 0 ? null : ranked; + } + applyCompletion( lines: string[], cursorLine: number, @@ -150,6 +232,145 @@ function containsDotSegment(path: string): boolean { return false; } +/** + * Recursive readdir of the work dir, used as the @-completion source + * when `fd` is missing and we're not in a git repository. Caches the + * result for `READDIR_TTL_MS` to keep keystroke latency low. Skips + * well-known build/dependency directories so a `node_modules`-laden + * repo still walks in under ~50ms. + * + * The walker collects dot entries too (so callers can opt in via + * `@.env` / `@.github/`); the actual dot-filtering is the caller's + * responsibility, mirroring the git-backed path. + */ +class ReadDirWalker { + private snapshot: ReadDirSnapshot | null = null; + private fetchedAt = 0; + // Cap on the number of directories visited during a single + // walk. Without this, a directory-heavy tree (e.g. 10 000 empty + // subdirectories) would synchronously recurse into every one + // even though the file cap is never hit. Reset at the start of + // each walk so the snapshot TTL does not leak the counter + // across refreshes. + private dirsVisited = 0; + + constructor(private readonly workDir: string) {} + + getSnapshot(): ReadDirSnapshot | null { + if (!existsSync(this.workDir)) return null; + const now = Date.now(); + if (this.snapshot !== null && now - this.fetchedAt < READDIR_TTL_MS) { + return this.snapshot; + } + const next = this.walk(); + if (next === null) return null; + this.snapshot = next; + this.fetchedAt = now; + return next; + } + + private walk(): ReadDirSnapshot | null { + this.dirsVisited = 0; + const files: string[] = []; + const mtimeByPath = new Map(); + try { + this.walkDir(this.workDir, '', 0, files, mtimeByPath); + } catch { + return null; + } + files.sort(); + const capped = files.length > READDIR_MAX_ENTRIES ? files.slice(0, READDIR_MAX_ENTRIES) : files; + return { files: capped, mtimeByPath, recencyOrder: new Map() }; + } + + private walkDir( + absDir: string, + relDir: string, + depth: number, + files: string[], + mtimeByPath: Map, + ): void { + if (depth > READDIR_MAX_DEPTH) return; + if (files.length >= READDIR_MAX_ENTRIES) return; + if (this.dirsVisited >= READDIR_MAX_ENTRIES) return; + this.dirsVisited++; + let entries: import('node:fs').Dirent[]; + try { + entries = readdirSync(absDir, { withFileTypes: true }); + } catch { + return; + } + // Four-phase traversal in priority order so a single large + // directory of any kind (visible or hidden) cannot fill + // READDIR_MAX_ENTRIES and starve entries of other types: + // 1. Visible files at this level — captured first. + // 2. Recurse into visible subdirectories. + // 3. Hidden files at this level — captured after visible + // dirs so an explicit `@.env` query isn't starved by + // a sibling large visible dir. + // 4. Recurse into hidden subdirectories. + // Hidden paths are still collected, so the opt-in `@.env` / + // `@.github/` queries still work. + const collectFile = (entry: import('node:fs').Dirent): void => { + if (entry.name === '.' || entry.name === '..') return; + if (!entry.isFile() && !entry.isSymbolicLink()) return; + const absPath = join(absDir, entry.name); + const relPath = relDir === '' ? entry.name : `${relDir}/${entry.name}`; + try { + // statSync follows symlinks; if the target is a file we + // record it, otherwise we skip. + const stat = statSync(absPath); + if (!stat.isFile()) return; + files.push(relPath); + mtimeByPath.set(relPath, stat.mtimeMs); + } catch { + // File/link disappeared or unresolvable — skip it. + } + }; + + const recurseDir = (entry: import('node:fs').Dirent): void => { + if (entry.name === '.' || entry.name === '..') return; + if (!entry.isDirectory()) return; + if (SKIP_DIRS.has(entry.name)) return; + const absChild = join(absDir, entry.name); + const relChild = relDir === '' ? entry.name : `${relDir}/${entry.name}`; + this.walkDir(absChild, relChild, depth + 1, files, mtimeByPath); + }; + + // Phase 1: visible files at this level + for (const entry of entries) { + if (files.length >= READDIR_MAX_ENTRIES) break; + if (this.dirsVisited >= READDIR_MAX_ENTRIES) break; + if (entry.name.startsWith('.')) continue; + collectFile(entry); + } + + // Phase 2: recurse into visible subdirectories + for (const entry of entries) { + if (files.length >= READDIR_MAX_ENTRIES) break; + if (this.dirsVisited >= READDIR_MAX_ENTRIES) break; + if (entry.name.startsWith('.')) continue; + recurseDir(entry); + } + + // Phase 3: hidden files at this level + for (const entry of entries) { + if (files.length >= READDIR_MAX_ENTRIES) break; + if (this.dirsVisited >= READDIR_MAX_ENTRIES) break; + if (!entry.name.startsWith('.')) continue; + collectFile(entry); + } + + // Phase 4: recurse into hidden subdirectories + for (const entry of entries) { + if (files.length >= READDIR_MAX_ENTRIES) break; + if (this.dirsVisited >= READDIR_MAX_ENTRIES) break; + if (!entry.name.startsWith('.')) continue; + recurseDir(entry); + } + } +} + /** * Empty-query ranking: stratified by signal strength. * diff --git a/apps/kimi-code/test/tui/components/editor/file-mention-provider.test.ts b/apps/kimi-code/test/tui/components/editor/file-mention-provider.test.ts index 496aa4ce..9a17078e 100644 --- a/apps/kimi-code/test/tui/components/editor/file-mention-provider.test.ts +++ b/apps/kimi-code/test/tui/components/editor/file-mention-provider.test.ts @@ -1,11 +1,32 @@ -import { describe, it, expect } from 'vitest'; +import { mkdtempSync, mkdirSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { afterEach, beforeEach, describe, it, expect } from 'vitest'; import { FileMentionProvider } from '#/tui/components/editor/file-mention-provider'; import type { GitLsFilesCache, GitSnapshot } from '#/utils/git/git-ls-files'; +// Probe whether symlink creation works in this environment. On +// Windows this requires developer mode or admin; on Linux/macOS it +// just works. The symlink test is gated on this so CI without +// symlink support does not get a noisy failure. +let supportsSymlinks = false; +try { + const probeDir = mkdtempSync(join(tmpdir(), 'symlink-probe-')); + try { + symlinkSync('target', join(probeDir, 'link')); + supportsSymlinks = true; + } finally { + rmSync(probeDir, { recursive: true, force: true }); + } +} catch { + supportsSymlinks = false; +} + function stubGitCache( files: string[] | null, - opts: { mtimes?: Record; recency?: string[] } = {}, + opts: { mtimes?: Record; recency?: string[]; isGitRepo?: boolean } = {}, ): GitLsFilesCache { const snapshot: GitSnapshot | null = files === null @@ -16,7 +37,7 @@ function stubGitCache( recencyOrder: new Map((opts.recency ?? []).map((p, i) => [p, i])), }; return { - isGitRepo: () => files !== null, + isGitRepo: () => opts.isGitRepo ?? files !== null, getSnapshot: () => snapshot, list: () => (files === null ? null : files.slice()), }; @@ -213,3 +234,262 @@ describe('FileMentionProvider — @ prefix detection + git-backed suggestions', expect(result).toBeNull(); }); }); + +describe('FileMentionProvider — readdir fallback when no git cache', () => { + let dir: string; + + beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'file-mention-readdir-')); + }); + + afterEach(() => { + rmSync(dir, { recursive: true, force: true }); + }); + + it('surfaces files recursively when @ is typed in a non-git directory', async () => { + writeFileSync(join(dir, 'a.ts'), ''); + mkdirSync(join(dir, 'src')); + writeFileSync(join(dir, 'src/b.ts'), ''); + + const provider = new FileMentionProvider([], dir, NO_FD, stubGitCache(null)); + const result = await provider.getSuggestions(['@'], 0, 1, { signal: ctrl() }); + + expect(result).not.toBeNull(); + expect(result!.prefix).toBe('@'); + const values = result!.items.map((i) => i.value); + expect(values).toContain('@a.ts'); + expect(values).toContain('@src/b.ts'); + }); + + it('skips files under blacklisted directories (node_modules, dist, etc.)', async () => { + mkdirSync(join(dir, 'src')); + writeFileSync(join(dir, 'src/keep.ts'), ''); + mkdirSync(join(dir, 'node_modules')); + writeFileSync(join(dir, 'node_modules/skip.ts'), ''); + mkdirSync(join(dir, 'dist')); + writeFileSync(join(dir, 'dist/skip.ts'), ''); + + const provider = new FileMentionProvider([], dir, NO_FD, stubGitCache(null)); + const result = await provider.getSuggestions(['@'], 0, 1, { signal: ctrl() }); + + expect(result).not.toBeNull(); + const values = result!.items.map((i) => i.value); + expect(values).toContain('@src/keep.ts'); + expect(values.some((v) => v.includes('node_modules'))).toBe(false); + expect(values.some((v) => v.includes('/dist/'))).toBe(false); + }); + + it('hides dotfiles by default but keeps .git/ unmentionable', async () => { + // The walker collects all entries (including dotfiles) so the + // opt-in below can surface them. Default filtering happens in + // buildFromReadDir via containsDotSegment, mirroring the git-backed + // path. .git/ is filtered earlier by the SKIP_DIRS set. + writeFileSync(join(dir, 'visible.ts'), ''); + writeFileSync(join(dir, '.hidden.ts'), ''); + mkdirSync(join(dir, '.git')); + writeFileSync(join(dir, '.git/HEAD'), ''); + + const provider = new FileMentionProvider([], dir, NO_FD, stubGitCache(null)); + const result = await provider.getSuggestions(['@'], 0, 1, { signal: ctrl() }); + + expect(result).not.toBeNull(); + const values = result!.items.map((i) => i.value); + expect(values).toContain('@visible.ts'); + expect(values.some((v) => v.includes('.hidden'))).toBe(false); + expect(values.some((v) => v.includes('.git/'))).toBe(false); + }); + + it('surfaces dotfiles when the query explicitly opts in (e.g. @.env)', async () => { + writeFileSync(join(dir, '.env'), ''); + writeFileSync(join(dir, 'foo.ts'), ''); + + const provider = new FileMentionProvider([], dir, NO_FD, stubGitCache(null)); + const result = await provider.getSuggestions(['@.env'], 0, 5, { signal: ctrl() }); + + expect(result).not.toBeNull(); + const values = result!.items.map((i) => i.value); + expect(values).toContain('@.env'); + expect(values).not.toContain('@foo.ts'); + }); + + it('returns null from getSuggestions when the readdir ranking has no matches', async () => { + // The walker finds foo.ts, but `does-not-exist` matches nothing — + // ranking returns an empty array. buildFromReadDir must turn that + // into null so the editor dismisses the menu instead of showing + // an empty autocomplete state. + writeFileSync(join(dir, 'foo.ts'), ''); + + const provider = new FileMentionProvider([], dir, NO_FD, stubGitCache(null)); + const result = await provider.getSuggestions(['@does-not-exist'], 0, 16, { signal: ctrl() }); + + expect(result).toBeNull(); + }); + + it('does not invoke the readdir fallback inside a git repo, even when the snapshot is empty', async () => { + // stubGitCache([]) simulates a git repo with an empty snapshot + // (e.g. a fresh repo with no files). Gated on `!isGitRepo()`, the + // readdir fallback MUST NOT be consulted — otherwise .gitignored + // paths in a real repo could leak through raw readdir. + writeFileSync(join(dir, 'a.ts'), ''); + + const provider = new FileMentionProvider([], dir, NO_FD, stubGitCache([])); + const result = await provider.getSuggestions(['@a'], 0, 2, { signal: ctrl() }); + + expect(result).toBeNull(); + }); + + it('does not invoke the readdir fallback when git ls-files transiently fails inside a git repo', async () => { + // The transient-failure case the codex review flagged: a real git + // repo whose `git ls-files` invocation returned null. `isGitRepo()` + // is still true (the repo exists), `getSnapshot()` is null (the + // spawn failed). The readdir fallback MUST NOT run — that would + // bypass `.gitignore` and could surface ignored files. + writeFileSync(join(dir, 'a.ts'), ''); + + const provider = new FileMentionProvider( + [], + dir, + NO_FD, + stubGitCache(null, { isGitRepo: true }), + ); + const result = await provider.getSuggestions(['@a'], 0, 2, { signal: ctrl() }); + + expect(result).toBeNull(); + }); + + it('prioritizes visible entries when a hidden subtree exhausts the entry cap', async () => { + // Codex P2 follow-up: a hidden dir like `.config/` with 1000+ + // entries used to fill READDIR_MAX_ENTRIES with hidden paths, so + // a visible root-level file was missing from the default `@` menu. + // The walker now sorts entries to process visible ones first, so + // the visible file makes it into the snapshot even when the cap + // is reached by a hidden subtree. + writeFileSync(join(dir, 'visible.ts'), ''); + mkdirSync(join(dir, '.config')); + for (let i = 0; i < 1000; i += 1) { + writeFileSync(join(dir, '.config', `entry${i}.txt`), ''); + } + + const provider = new FileMentionProvider([], dir, NO_FD, stubGitCache(null)); + const result = await provider.getSuggestions(['@'], 0, 1, { signal: ctrl() }); + + expect(result).not.toBeNull(); + const values = result!.items.map((i) => i.value); + // The visible file must survive even though the hidden subtree + // alone has more entries than READDIR_MAX_ENTRIES. + expect(values).toContain('@visible.ts'); + // The default filter still strips dot-segments from suggestions, + // so .config/* entries don't surface in the empty-query menu + // regardless of the cap behavior. + expect(values.every((v) => !v.startsWith('@.'))).toBe(true); + }); + + it('captures sibling files before recursing into a large subdirectory', async () => { + // Codex P2 follow-up: the walker used to recurse into the first + // subdirectory depth-first, so a large sibling subdirectory + // could fill READDIR_MAX_ENTRIES and leave sibling files in + // the same parent directory unmentioned. The walker now sorts + // files before subdirectories within the same visibility + // bucket, so the parent file is captured before the cap fills. + writeFileSync(join(dir, 'README.md'), ''); + mkdirSync(join(dir, 'big')); + for (let i = 0; i < 1000; i += 1) { + writeFileSync(join(dir, 'big', `file${i}.ts`), ''); + } + + const provider = new FileMentionProvider([], dir, NO_FD, stubGitCache(null)); + // Query specifically for `@README` rather than a bare `@`: the + // empty-query ranking returns only the top 15 by mtime, and + // README.md is the oldest entry (it was created first), so it + // wouldn't surface in the menu even when present in the snapshot. + // A targeted query makes the test directly exercise snapshot + // membership. + const result = await provider.getSuggestions(['@README'], 0, 7, { signal: ctrl() }); + + expect(result).not.toBeNull(); + const values = result!.items.map((i) => i.value); + expect(values).toContain('@README.md'); + }); + + it('surfaces a root-level hidden file when a visible subdirectory would otherwise exhaust the cap', async () => { + // Codex P2 follow-up: with the four-phase traversal, root-level + // hidden files like `.env` are still collected because phase 2 + // (recurse into visible dirs) is bounded by the cap. `big/` + // can fill READDIR_MAX_ENTRIES - 1 slots, leaving exactly one + // slot for `.env` in phase 3. (A `big/` with exactly + // READDIR_MAX_ENTRIES files would fill the cap and starve + // `.env` — the cap edge case is a known trade-off.) + writeFileSync(join(dir, '.env'), ''); + mkdirSync(join(dir, 'big')); + for (let i = 0; i < 999; i += 1) { + writeFileSync(join(dir, 'big', `file${i}.ts`), ''); + } + + const provider = new FileMentionProvider([], dir, NO_FD, stubGitCache(null)); + const result = await provider.getSuggestions(['@.env'], 0, 5, { signal: ctrl() }); + + expect(result).not.toBeNull(); + const values = result!.items.map((i) => i.value); + expect(values).toContain('@.env'); + }); + + it('captures a visible subdirectory before processing many root dotfiles', async () => { + // Codex P2 follow-up: with the four-phase traversal, phase 2 + // (recurse into visible subdirectories) runs before phase 3 + // (collect hidden files). So 1000 root dotfiles cannot starve + // a visible subdirectory like `src/` — the src/* entries are + // captured first, and only then are the dotfiles enumerated + // to fill the remaining cap. + mkdirSync(join(dir, 'src')); + writeFileSync(join(dir, 'src', 'keep.ts'), ''); + for (let i = 0; i < 1000; i += 1) { + writeFileSync(join(dir, `.dot${i}.ts`), ''); + } + + const provider = new FileMentionProvider([], dir, NO_FD, stubGitCache(null)); + const result = await provider.getSuggestions(['@src'], 0, 4, { signal: ctrl() }); + + expect(result).not.toBeNull(); + const values = result!.items.map((i) => i.value); + expect(values).toContain('@src/keep.ts'); + }); + + it.runIf(supportsSymlinks)( + 'follows symlinks to files in the readdir fallback', + async () => { + // Codex P2 follow-up: symlink entries are skipped because + // `Dirent.isFile()` is false for symlinks. A `real.ts` file + // linked as `link.ts` would not surface in @-mention. The + // walker now follows symlinks and records the entry if the + // target is a file. Symlinks to directories are not recursed + // into (cycle risk) and not added to the snapshot. + writeFileSync(join(dir, 'real.ts'), ''); + symlinkSync(join(dir, 'real.ts'), join(dir, 'link.ts')); + + const provider = new FileMentionProvider([], dir, NO_FD, stubGitCache(null)); + const result = await provider.getSuggestions(['@link'], 0, 5, { signal: ctrl() }); + + expect(result).not.toBeNull(); + const values = result!.items.map((i) => i.value); + expect(values).toContain('@link.ts'); + }, + ); + + it('caches the walk result: new files do not appear within the 2s TTL window', async () => { + writeFileSync(join(dir, 'old.ts'), ''); + + const provider = new FileMentionProvider([], dir, NO_FD, stubGitCache(null)); + const first = await provider.getSuggestions(['@'], 0, 1, { signal: ctrl() }); + const firstValues = first!.items.map((i) => i.value); + expect(firstValues).toContain('@old.ts'); + + // Immediately create a new file and re-query. Within the TTL + // window the walker should return the cached snapshot, so the + // new file must NOT surface until the cache expires. + writeFileSync(join(dir, 'new.ts'), ''); + const second = await provider.getSuggestions(['@'], 0, 1, { signal: ctrl() }); + const secondValues = second!.items.map((i) => i.value); + expect(secondValues).toContain('@old.ts'); + expect(secondValues).not.toContain('@new.ts'); + }); +});