Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
132 changes: 132 additions & 0 deletions __tests__/file-list-cache.test.ts
Original file line number Diff line number Diff line change
@@ -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<CodeGraph> {
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 });
}
});
});
29 changes: 29 additions & 0 deletions __tests__/package-dependency-cache.test.ts
Original file line number Diff line number Diff line change
@@ -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 });
}
});
});
11 changes: 9 additions & 2 deletions src/db/queries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -255,6 +255,7 @@ export class QueryBuilder {
// Node cache for frequently accessed nodes (LRU-style, max 1000 entries)
private nodeCache: Map<string, Node> = 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
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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;
}

/**
Expand Down
10 changes: 5 additions & 5 deletions src/resolution/frameworks/package-deps.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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<ResolutionContext, { files: number; names: Set<string> }>();
const cache = new WeakMap<ResolutionContext, { files: readonly string[]; names: Set<string> }>();

/** Every dependency name declared in the manifests read (see the file comment), de-duplicated. */
export function declaredDependencies(context: ResolutionContext): Set<string> {
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<string>();
// The index lists source files, never manifests: the candidate directories
// are the first one or two segments of what IS indexed, probed on disk.
Expand Down Expand Up @@ -76,7 +76,7 @@ export function declaredDependencies(context: ResolutionContext): Set<string> {
// Not JSON — a template, a broken manifest; nothing to read.
}
}
cache.set(context, { files: files.length, names });
cache.set(context, { files, names });
return names;
}

Expand Down
2 changes: 1 addition & 1 deletion src/resolution/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[];
/**
Expand Down