-
Notifications
You must be signed in to change notification settings - Fork 2.6k
feat(core): let grep results satisfy prior-read checks #5043
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,125 @@ | ||
| /** | ||
| * @license | ||
| * Copyright 2026 Qwen Team | ||
| * SPDX-License-Identifier: Apache-2.0 | ||
| */ | ||
|
|
||
| import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; | ||
| import fs from 'node:fs/promises'; | ||
| import os from 'node:os'; | ||
| import path from 'node:path'; | ||
| import type { Config } from '../config/config.js'; | ||
| import { FileReadCache } from '../services/fileReadCache.js'; | ||
| import { recordGrepResultFileReads } from './grepReadTracking.js'; | ||
|
|
||
| describe('recordGrepResultFileReads', () => { | ||
| let tempRootDir: string; | ||
| let fileReadCache: FileReadCache; | ||
|
|
||
| beforeEach(async () => { | ||
| tempRootDir = await fs.mkdtemp(path.join(os.tmpdir(), 'grep-read-cache-')); | ||
| fileReadCache = new FileReadCache(); | ||
| }); | ||
|
|
||
| afterEach(async () => { | ||
| vi.restoreAllMocks(); | ||
| await fs.rm(tempRootDir, { recursive: true, force: true }); | ||
| }); | ||
|
|
||
| function mockConfig( | ||
| cache: FileReadCache | undefined = fileReadCache, | ||
| disabled = false, | ||
| ): Config { | ||
| return { | ||
| getFileReadCacheDisabled: () => disabled, | ||
| getFileReadCache: () => cache, | ||
| } as unknown as Config; | ||
| } | ||
|
|
||
| it('does nothing when the cache is disabled', async () => { | ||
| const filePath = path.join(tempRootDir, 'result.ts'); | ||
| await fs.writeFile(filePath, 'match'); | ||
| const stats = await fs.stat(filePath); | ||
|
|
||
| await recordGrepResultFileReads(mockConfig(fileReadCache, true), [ | ||
| filePath, | ||
| ]); | ||
|
|
||
| expect(fileReadCache.check(stats).state).toBe('unknown'); | ||
| }); | ||
|
|
||
| it('does nothing when the config has no cache', async () => { | ||
| const filePath = path.join(tempRootDir, 'result.ts'); | ||
| await fs.writeFile(filePath, 'match'); | ||
|
|
||
| await expect( | ||
| recordGrepResultFileReads(mockConfig(undefined), [filePath]), | ||
| ).resolves.toBeUndefined(); | ||
| }); | ||
|
|
||
| it('records text grep results as partial cacheable reads', async () => { | ||
| const filePath = path.join(tempRootDir, 'result.ts'); | ||
| await fs.writeFile(filePath, 'const value = "match";'); | ||
| const stats = await fs.stat(filePath); | ||
|
|
||
| await recordGrepResultFileReads(mockConfig(), [filePath]); | ||
|
|
||
| const result = fileReadCache.check(stats); | ||
| expect(result.state).toBe('fresh'); | ||
| if (result.state === 'fresh') { | ||
| expect(result.entry.lastReadWasFull).toBe(false); | ||
| expect(result.entry.lastReadCacheable).toBe(true); | ||
| } | ||
| }); | ||
|
|
||
| it('does not make notebook grep results cacheable', async () => { | ||
| const filePath = path.join(tempRootDir, 'notebook.ipynb'); | ||
| await fs.writeFile(filePath, '{"cells":[{"source":"match"}]}'); | ||
| const stats = await fs.stat(filePath); | ||
|
|
||
| await recordGrepResultFileReads(mockConfig(), [filePath]); | ||
|
|
||
| const result = fileReadCache.check(stats); | ||
| expect(result.state).toBe('fresh'); | ||
| if (result.state === 'fresh') { | ||
| expect(result.entry.lastReadWasFull).toBe(false); | ||
| expect(result.entry.lastReadCacheable).toBe(false); | ||
| } | ||
| }); | ||
|
|
||
| it('ignores non-file grep result paths', async () => { | ||
| const dirPath = path.join(tempRootDir, 'nested'); | ||
| await fs.mkdir(dirPath); | ||
| const stats = await fs.stat(dirPath); | ||
|
|
||
| await recordGrepResultFileReads(mockConfig(), [dirPath]); | ||
|
|
||
| expect(fileReadCache.check(stats).state).toBe('unknown'); | ||
| }); | ||
|
|
||
| it('ignores result paths that disappear before stat', async () => { | ||
| await expect( | ||
| recordGrepResultFileReads(mockConfig(), [ | ||
| path.join(tempRootDir, 'missing.ts'), | ||
| ]), | ||
| ).resolves.toBeUndefined(); | ||
| }); | ||
|
|
||
| it('continues after a stat failure', async () => { | ||
| const filePath = path.join(tempRootDir, 'result.ts'); | ||
| const blockedPath = path.join(tempRootDir, 'blocked.ts'); | ||
| await fs.writeFile(filePath, 'match'); | ||
| const stats = await fs.stat(filePath); | ||
| const actualStat = fs.stat.bind(fs); | ||
| vi.spyOn(fs, 'stat').mockImplementation(async (target) => { | ||
| if (target === blockedPath) { | ||
| throw Object.assign(new Error('denied'), { code: 'EACCES' }); | ||
| } | ||
| return actualStat(target); | ||
| }); | ||
|
|
||
| await recordGrepResultFileReads(mockConfig(), [blockedPath, filePath]); | ||
|
|
||
| expect(fileReadCache.check(stats).state).toBe('fresh'); | ||
| }); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,60 @@ | ||
| /** | ||
| * @license | ||
| * Copyright 2026 Qwen Team | ||
| * SPDX-License-Identifier: Apache-2.0 | ||
| */ | ||
|
|
||
| import fs from 'node:fs/promises'; | ||
| import path from 'node:path'; | ||
| import type { Config } from '../config/config.js'; | ||
| import { createDebugLogger } from '../utils/debugLogger.js'; | ||
|
|
||
| const STAT_BATCH_SIZE = 50; | ||
| const NON_CACHEABLE_GREP_EXTENSIONS = new Set(['.ipynb']); | ||
| const debugLogger = createDebugLogger('GREP_READ_TRACKING'); | ||
|
|
||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [Suggestion] No dedicated — qwen3.7-max via Qwen Code /review |
||
| export async function recordGrepResultFileReads( | ||
| config: Config, | ||
| filePaths: string[], | ||
| ): Promise<void> { | ||
| if (config.getFileReadCacheDisabled?.()) { | ||
| return; | ||
| } | ||
| const cache = config.getFileReadCache?.(); | ||
| if (!cache) { | ||
| return; | ||
| } | ||
|
|
||
| const uniqueFilePaths = Array.from(new Set(filePaths)); | ||
| for (let i = 0; i < uniqueFilePaths.length; i += STAT_BATCH_SIZE) { | ||
| const batch = uniqueFilePaths.slice(i, i + STAT_BATCH_SIZE); | ||
| await Promise.all( | ||
| batch.map(async (filePath) => { | ||
| try { | ||
| const stats = await fs.stat(filePath); | ||
| if (!stats.isFile()) { | ||
| return; | ||
| } | ||
| cache.recordRead(filePath, stats, { | ||
| full: false, | ||
| cacheable: isGrepResultCacheable(filePath), | ||
| }); | ||
| } catch (error) { | ||
| if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { | ||
| debugLogger.debug( | ||
| 'Failed to stat grep result path', | ||
| filePath, | ||
| error, | ||
| ); | ||
| } | ||
| } | ||
| }), | ||
| ); | ||
| } | ||
| } | ||
|
|
||
| function isGrepResultCacheable(filePath: string): boolean { | ||
| return !NON_CACHEABLE_GREP_EXTENSIONS.has( | ||
| path.extname(filePath).toLowerCase(), | ||
| ); | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[Suggestion] These assertions only verify
state === 'fresh'but do not checklastReadWasFull === falseorlastReadCacheable === true. The{full: false, cacheable: true}contract — which is the entire semantic point of this PR — is unverified. If someone accidentally flippedfulltotrue, these tests would still pass but the Read tool would incorrectly returnfile_unchangedplaceholders.— qwen3.7-max via Qwen Code /review