From a528a479ca59ca459463bbaaff9c51d27ad734f1 Mon Sep 17 00:00:00 2001 From: n33 <1741170479@qq.com> Date: Mon, 12 Oct 2026 22:28:10 +0800 Subject: [PATCH] =?UTF-8?q?fix(cache):=20=E6=8C=89=E6=95=B0=E6=8D=AE?= =?UTF-8?q?=E5=BA=93=E5=8F=98=E6=9B=B4=E7=BB=B4=E6=8A=A4=E5=8F=AA=E8=AF=BB?= =?UTF-8?q?=E6=96=87=E4=BB=B6=E6=B8=85=E5=8D=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 4 + __tests__/file-list-cache.test.ts | 132 +++++++++++++++++++++ __tests__/package-dependency-cache.test.ts | 29 +++++ src/db/queries.ts | 11 +- src/resolution/frameworks/package-deps.ts | 10 +- src/resolution/types.ts | 2 +- 6 files changed, 180 insertions(+), 8 deletions(-) create mode 100644 __tests__/file-list-cache.test.ts create mode 100644 __tests__/package-dependency-cache.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index c750ed86e3..2bef4d7dfb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,10 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ### Fixes +- Framework dependency detection now notices file replacements that keep the indexed file count unchanged, and repeated file-list reads stay fresh across commits and rollbacks. + +### Fixes + - In VB.NET, every member of a `Structure` is now indexed, including its fields, properties, methods, constructors and nested enums. Before, only the first member was, so the rest could not be found and their callers looked empty. - In VB.NET and C#, what a property's `Get` and `Set` code calls, creates and reads now belongs to that property, as do C#'s `get => …` accessors and `=> …` property bodies. Before, it was dropped, so a method used only from a property looked unused. - In VB.NET, a field or property initializer like `= Compute()` or `As New List(Of Order)` now links what it calls and creates, and so do a `Custom Event`'s `AddHandler`, `RemoveHandler` and `RaiseEvent` blocks. Re-index VB.NET and C# projects after upgrading. diff --git a/__tests__/file-list-cache.test.ts b/__tests__/file-list-cache.test.ts new file mode 100644 index 0000000000..a5d33ce573 --- /dev/null +++ b/__tests__/file-list-cache.test.ts @@ -0,0 +1,132 @@ +import { describe, it, expect, afterEach, vi } from 'vitest'; +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; +import CodeGraph from '../src'; +import { QueryBuilder } from '../src/db/queries'; +import { DatabaseConnection, getDatabasePath } from '../src/db'; + +function queriesOf(cg: CodeGraph): QueryBuilder { + return (cg as unknown as { queries: QueryBuilder }).queries; +} + +/** `n` exported functions in one file — enough nodes for the name list to be non-trivial. */ +function funcs(prefix: string, n: number): string { + const lines: string[] = []; + for (let i = 0; i < n; i++) lines.push(`export function ${prefix}${i}(): number { return ${i}; }`); + return lines.join('\n') + '\n'; +} + +describe('whole-table read memos — one computation per database state', () => { + let dir: string; + const open: CodeGraph[] = []; + + afterEach(() => { + for (const cg of open.splice(0)) cg.close(); + vi.restoreAllMocks(); + if (dir) fs.rmSync(dir, { recursive: true, force: true }); + }); + + async function setup(): Promise { + dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-readmemo-')); + fs.mkdirSync(path.join(dir, 'core')); + fs.writeFileSync(path.join(dir, 'core', 'engine.ts'), funcs('engineStep', 5)); + fs.writeFileSync(path.join(dir, 'core', 'util.py'), 'def helper():\n return 1\n'); + const cg = await CodeGraph.init(dir, { index: true }); + open.push(cg); + return cg; + } + + it('returns the same array until something is written', async () => { + const q = queriesOf(await setup()); + const first = q.getAllFilePaths(); + expect(first).toContain('core/engine.ts'); + expect(q.getAllFilePaths()).toBe(first); + }); + + it('hands out values that cannot be mutated by a caller', async () => { + const q = queriesOf(await setup()); + const files = q.getAllFilePaths(); + expect(Object.isFrozen(files)).toBe(true); + expect(() => (files as string[]).push('bogus.ts')).toThrow(); + }); + + it('sees files added by a sync through this connection', async () => { + const cg = await setup(); + const q = queriesOf(cg); + expect(q.getAllFilePaths()).not.toContain('core/extra.ts'); + + fs.writeFileSync(path.join(dir, 'core', 'extra.ts'), funcs('extraStep', 3)); + await cg.sync(); + + expect(q.getAllFilePaths()).toContain('core/extra.ts'); + expect(q.getDistinctFileLanguages()).toContain('typescript'); + }); + + it('sees a swap that keeps the file count unchanged', async () => { + const cg = await setup(); + const q = queriesOf(cg); + const before = q.getAllFilePaths(); + expect(before).toContain('core/util.py'); + + // Same number of files, different names — a length-based key would call + // this a cache hit and hand the resolver a stale file list. + fs.rmSync(path.join(dir, 'core', 'util.py')); + fs.writeFileSync(path.join(dir, 'core', 'added.ts'), funcs('addedStep', 2)); + await cg.sync(); + + const after = q.getAllFilePaths(); + expect(after).not.toBe(before); + expect(after).not.toContain('core/util.py'); + expect(after).toContain('core/added.ts'); + expect(q.getDistinctFileLanguages()).not.toContain('python'); + }); + + it('sees a sync made through another connection (another process)', async () => { + const writer = await setup(); + const reader = await CodeGraph.open(dir); + open.push(reader); + expect(queriesOf(reader).getAllFilePaths()).not.toContain('core/other.ts'); + + fs.writeFileSync(path.join(dir, 'core', 'other.ts'), funcs('otherStep', 2)); + await writer.sync(); + + expect(queriesOf(reader).getAllFilePaths()).toContain('core/other.ts'); + }); + + it('does not keep a value read inside a transaction that is rolled back', async () => { + const cg = await setup(); + const q = queriesOf(cg); + const before = q.getAllFilePaths(); + const db = (cg as unknown as { db: DatabaseConnection }).db.getDb(); + db.exec('BEGIN'); + try { + db.exec('DELETE FROM files'); + db.exec('DELETE FROM nodes'); + // Inside the transaction the read must reflect the statement, not a memo. + expect(q.getAllFilePaths()).toEqual([]); + } finally { + db.exec('ROLLBACK'); + } + expect(q.getAllFilePaths()).toEqual(before); + }); + + it('forgets the values when rebound to another connection', async () => { + await setup(); + const other = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-readmemo-other-')); + const conns: DatabaseConnection[] = []; + try { + fs.writeFileSync(path.join(other, 'solo.ts'), funcs('soloStep', 2)); + (await CodeGraph.init(other, { index: true })).close(); + conns.push(DatabaseConnection.open(getDatabasePath(dir)), DatabaseConnection.open(getDatabasePath(other))); + const q = new QueryBuilder(conns[0]!.getDb()); + expect(q.getAllFilePaths()).toContain('core/engine.ts'); + q.rebind(conns[1]!.getDb()); + expect(q.getAllFilePaths()).toEqual(['solo.ts']); + expect(q.getDistinctFileLanguages()).toEqual(new Set(['typescript'])); + } finally { + for (const c of conns) c.close(); + fs.rmSync(other, { recursive: true, force: true }); + } + }); +}); diff --git a/__tests__/package-dependency-cache.test.ts b/__tests__/package-dependency-cache.test.ts new file mode 100644 index 0000000000..83419f9d6e --- /dev/null +++ b/__tests__/package-dependency-cache.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from 'vitest'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { declaredDependencies } from '../src/resolution/frameworks/package-deps'; +import type { ResolutionContext } from '../src/resolution/types'; +describe('dependency detection cache', () => { + it('notices an equal-size replacement of the file list', () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-dependencies-')); + try { + for (const [directory, dependency] of [['web', 'react'], ['server', 'express']]) { + fs.mkdirSync(path.join(root, directory)); + fs.writeFileSync(path.join(root, directory, 'package.json'), JSON.stringify({ dependencies: { [dependency!]: '*' } })); + } + let files: readonly string[] = Object.freeze(['web/app.ts']); + const context = { + getAllFiles: () => files, + getProjectRoot: () => root, + fileExists: (file: string) => fs.existsSync(path.join(root, file)), + readFile: (file: string) => { try { return fs.readFileSync(path.join(root, file), 'utf8'); } catch { return null; } }, + } as ResolutionContext; + expect(declaredDependencies(context)).toEqual(new Set(['react'])); + files = Object.freeze(['server/app.ts']); + expect(declaredDependencies(context)).toEqual(new Set(['express'])); + } finally { + fs.rmSync(root, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 }); + } + }); +}); diff --git a/src/db/queries.ts b/src/db/queries.ts index 9700e3d63f..f25eef7156 100644 --- a/src/db/queries.ts +++ b/src/db/queries.ts @@ -255,6 +255,7 @@ export class QueryBuilder { // Node cache for frequently accessed nodes (LRU-style, max 1000 entries) private nodeCache: Map = new Map(); private readonly maxCacheSize = 1000; + private filePathsMemo: { stamp: string; value: readonly string[] } | undefined; // getDominantFile()'s answer, tagged with the database change stamp it was // computed under (see getChangeStamp). Query-independent, so one value @@ -402,6 +403,7 @@ export class QueryBuilder { // different databases report the same one — the memo goes with the old // connection, or a worker following a rebuilt index keeps its answer (#1864). this.dominantFileMemo = undefined; + this.filePathsMemo = undefined; } private edgeKindStmt(sql: string): SqliteStatement { @@ -3747,12 +3749,17 @@ export class QueryBuilder { /** * Get all tracked file paths (lightweight — no full FileRecord objects) */ - getAllFilePaths(): string[] { + getAllFilePaths(): readonly string[] { + const stamp = this.db.inTransaction === false ? this.getChangeStamp() : null; + if (stamp !== null && this.filePathsMemo?.stamp === stamp) return this.filePathsMemo.value; + this.filePathsMemo = undefined; if (!this.stmts.getAllFilePaths) { this.stmts.getAllFilePaths = this.db.prepare('SELECT path FROM files ORDER BY path'); } const rows = this.stmts.getAllFilePaths.all() as Array<{ path: string }>; - return rows.map((r) => r.path); + const value = Object.freeze(rows.map(row => row.path)); + if (stamp !== null) this.filePathsMemo = { stamp, value }; + return value; } /** diff --git a/src/resolution/frameworks/package-deps.ts b/src/resolution/frameworks/package-deps.ts index 907c679431..dac665b7e3 100644 --- a/src/resolution/frameworks/package-deps.ts +++ b/src/resolution/frameworks/package-deps.ts @@ -21,7 +21,7 @@ const MAX_DEEP_PROBES = MAX_MANIFESTS * 8; const SCRIPT_FILE = /\.(?:[cm]?[jt]sx?|vue|svelte|astro)$/; /** - * Cached per context, keyed by how many files are indexed. + * Cached per context, keyed by the identity of the indexed file list. * * The resolver is constructed — and every `detect()` runs once — BEFORE any * file exists, so that first pass sees no directories to probe and reads only @@ -30,15 +30,15 @@ const SCRIPT_FILE = /\.(?:[cm]?[jt]sx?|vue|svelte|astro)$/; * framework whose dependency lives one directory down stayed undetected: a * proshop-shaped repo indexed its React Router routes (extraction is not * gated on detection) and then resolved none of its navigation. Re-reading - * when the file count changes costs one manifest sweep per index. + * when the file list changes costs one manifest sweep per index. */ -const cache = new WeakMap }>(); +const cache = new WeakMap }>(); /** Every dependency name declared in the manifests read (see the file comment), de-duplicated. */ export function declaredDependencies(context: ResolutionContext): Set { const files = context.getAllFiles(); const cached = cache.get(context); - if (cached && cached.files === files.length) return cached.names; + if (cached && cached.files === files) return cached.names; const names = new Set(); // The index lists source files, never manifests: the candidate directories // are the first one or two segments of what IS indexed, probed on disk. @@ -76,7 +76,7 @@ export function declaredDependencies(context: ResolutionContext): Set { // Not JSON — a template, a broken manifest; nothing to read. } } - cache.set(context, { files: files.length, names }); + cache.set(context, { files, names }); return names; } diff --git a/src/resolution/types.ts b/src/resolution/types.ts index d61c9a40b3..10f82403d1 100644 --- a/src/resolution/types.ts +++ b/src/resolution/types.ts @@ -143,7 +143,7 @@ export interface ResolutionContext { /** Get project root */ getProjectRoot(): string; /** Get all files */ - getAllFiles(): string[]; + getAllFiles(): readonly string[]; /** Get nodes by lowercase name (O(1) lookup for fuzzy matching) */ getNodesByLowerName(lowerName: string): Node[]; /**