/** * [v2.2.311] 제2뇌 상주 캐시 계층 — 워처 세대 기반 stat 생략 + 파일 목록 캐시. * * 핵심 계약: * 1. 워처 세대가 그대로면 getBrainTokenIndex 는 파일별 statSync 를 생략한다. * 2. 세대가 바뀌면(파일시스템 변경) 다시 stat 을 거쳐 변경분을 재색인한다. * 3. 부분(스코프) 목록으로 검증된 상태에서 전체 목록 질의가 와도, 검증 안 된 * 파일은 신뢰 경로로 새지 않는다 (파일 단위 검증 장부). * 4. 워처 불가 환경(gen === -1)에서는 종전과 동일하게 매번 stat. */ import * as fs from 'fs'; import * as os from 'os'; import * as path from 'path'; // brainWatch 를 결정적으로 제어 — 실제 fs.watch 이벤트 타이밍에 의존하지 않는다. let mockGen = 0; jest.mock('../src/retrieval/brainWatch', () => ({ ensureBrainWatcher: jest.fn(), getBrainGeneration: jest.fn(() => mockGen), listBrainFilesCached: jest.fn(), warmBrainCache: jest.fn(), disposeBrainWatcher: jest.fn(), })); import { getBrainTokenIndex } from '../src/retrieval/brainIndex'; function makeBrain(files: Record): { root: string; paths: string[] } { const root = fs.mkdtempSync(path.join(os.tmpdir(), 'astra-brain-')); const paths: string[] = []; for (const [name, content] of Object.entries(files)) { const p = path.join(root, name); fs.writeFileSync(p, content, 'utf8'); // RECENT_MTIME_GUARD(3초): 갓 수정된 파일은 신뢰 명부에 오르지 않으므로, // "변경 없음" 시나리오를 테스트하려면 mtime 을 과거로 되돌린다. const past = new Date(Date.now() - 60_000); fs.utimesSync(p, past, past); paths.push(p); } return { root, paths }; } function countStatsFor(paths: string[], spy: jest.SpyInstance): number { return spy.mock.calls.filter((c) => paths.includes(String(c[0]))).length; } describe('getBrainTokenIndex — 워처 세대 기반 stat 생략', () => { afterEach(() => jest.restoreAllMocks()); test('같은 세대의 2번째 호출은 statSync 0회 (캐시 신뢰)', () => { mockGen = 0; const { root, paths } = makeBrain({ 'a.md': '알파 내용', 'b.md': '베타 내용' }); expect(getBrainTokenIndex(root, paths)).toHaveLength(2); const spy = jest.spyOn(fs, 'statSync'); const r2 = getBrainTokenIndex(root, paths); expect(r2).toHaveLength(2); expect(countStatsFor(paths, spy)).toBe(0); expect(r2.map((d) => d.title).sort()).toEqual(['a', 'b']); }); test('세대가 바뀌면 재-stat 후 변경 내용을 재색인한다', () => { mockGen = 0; const { root, paths } = makeBrain({ 'a.md': '원본 토큰들' }); getBrainTokenIndex(root, paths); // 파일 변경 + 세대 bump (mtime 차이를 보장하기 위해 utimesSync 로 강제) fs.writeFileSync(paths[0], '완전히 새로운 내용입니다', 'utf8'); const future = new Date(Date.now() + 5000); fs.utimesSync(paths[0], future, future); mockGen = 1; const spy = jest.spyOn(fs, 'statSync'); const r = getBrainTokenIndex(root, paths); expect(countStatsFor(paths, spy)).toBeGreaterThan(0); expect(r[0].tokens).toEqual(expect.arrayContaining(['새로운'])); }); test('스코프 검증 후 전체 질의 — 미검증 파일은 신뢰 경로로 새지 않는다', () => { mockGen = 0; const { root, paths } = makeBrain({ 'in-scope.md': '스코프 안', 'out-scope.md': '스코프 밖' }); const [inScope, outScope] = paths; getBrainTokenIndex(root, [inScope]); // 부분 목록만 검증됨 const spy = jest.spyOn(fs, 'statSync'); getBrainTokenIndex(root, paths); // 전체 질의 expect(countStatsFor([inScope], spy)).toBe(0); // 검증된 파일은 생략 expect(countStatsFor([outScope], spy)).toBeGreaterThan(0); // 미검증 파일은 stat }); test('워처 불가(gen=-1) 환경은 매번 stat (종전 동작)', () => { mockGen = -1; const { root, paths } = makeBrain({ 'a.md': '내용' }); getBrainTokenIndex(root, paths); const spy = jest.spyOn(fs, 'statSync'); getBrainTokenIndex(root, paths); expect(countStatsFor(paths, spy)).toBeGreaterThan(0); }); });