test(core): cache reset hooks + regression-guards from audit

Self-review pass on the previous two perf commits surfaced a few
follow-ups worth pinning down before they bite:

- Module-level caches (paths.isDirectoryCache, ripGrep dirIsDir/qwen-
  Ignore, jsonl-utils.ensuredDirs) persisted across vitest cases
  silently. Added underscore-prefixed `_reset*ForTest` exports and
  wired one into the validatePath describe block so future cases
  mutating the same absolute paths can't pass by accident.
- Documented the parentUuid-chain tradeoff on chatRecordingService
  .appendRecord: when the async write rejects, lastRecordUuid was
  already set sync, so subsequent records reference an absent
  ancestor — readers like sessionService.reconstructHistory then
  silently drop those descendants. Same observable failure mode as
  the prior sync code's caught-and-logged throw.
- Documented the dir<->file mutation and mid-session .qwenignore
  staleness windows for the validatePath / ripGrep caches.
- Added regression tests:
    * validatePath does NOT cache ENOENT (Edit-then-Read works)
    * validatePath skips re-stat on cache hit (perf assertion)
    * flush() resolves immediately on a fresh service
    * a rejected writeLine does not block the next record

Full core suite: 6061 pass, 2 skipped — no regressions.
This commit is contained in:
wenshao 2026-04-24 14:40:18 +08:00
parent 8fa7f4c498
commit d6485964c3
6 changed files with 121 additions and 4 deletions

View file

@ -7,7 +7,15 @@
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest';
import {
describe,
it,
expect,
beforeAll,
beforeEach,
afterAll,
vi,
} from 'vitest';
import {
escapePath,
resolvePath,
@ -18,6 +26,7 @@ import {
shortenPath,
tildeifyPath,
getProjectHash,
_resetValidatePathCacheForTest,
} from './paths.js';
import type { Config } from '../config/config.js';
@ -431,6 +440,14 @@ describe('validatePath', () => {
});
});
beforeEach(() => {
// Module-level isDirectory cache persists across tests; tests here
// mutate the same absolute paths between cases (create file, remove,
// re-create as potentially-different type) so we reset to avoid stale
// lookups masking regressions.
_resetValidatePathCacheForTest();
});
afterAll(() => {
fs.rmSync(workspaceRoot, { recursive: true, force: true });
});
@ -482,6 +499,36 @@ describe('validatePath', () => {
expect(() => validatePath(config, workspaceRoot)).not.toThrow();
});
it('does not cache ENOENT — recreating the path between calls succeeds', () => {
// Regression guard: a path that's missing at first-check, then created,
// must NOT be rejected on the second call. Positive stats are cached;
// ENOENT paths are not. This lets the model create a file with Edit
// and then have the next tool call see it.
const ephemeralDir = path.join(workspaceRoot, 'late-created');
expect(() => validatePath(config, ephemeralDir)).toThrowError(
/Path does not exist:/,
);
fs.mkdirSync(ephemeralDir);
try {
expect(() => validatePath(config, ephemeralDir)).not.toThrow();
} finally {
fs.rmSync(ephemeralDir, { recursive: true, force: true });
}
});
it('caches positive isDirectory — repeat call does not re-stat', () => {
const spy = vi.spyOn(fs, 'statSync');
const dir = path.join(workspaceRoot, 'subdir');
try {
validatePath(config, dir);
const afterFirst = spy.mock.calls.length;
validatePath(config, dir);
expect(spy.mock.calls.length).toBe(afterFirst);
} finally {
spy.mockRestore();
}
});
it('validates paths in allowed directories', () => {
const extraDir = fs.mkdtempSync(path.join(os.tmpdir(), 'validate-extra-'));
try {