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
2 changes: 1 addition & 1 deletion __tests__/foundation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -282,7 +282,7 @@ describe('Database Connection', () => {

const version = db.getSchemaVersion();
expect(version).not.toBeNull();
expect(version?.version).toBe(5);
expect(version?.version).toBe(6);

db.close();
});
Expand Down
99 changes: 99 additions & 0 deletions __tests__/index-command.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
import { CodeGraph } from '../src';
import { getCodeGraphDir } from '../src/directory';
import { FileLock } from '../src/utils';

const BIN = path.resolve(__dirname, '../dist/bin/codegraph.js');

Expand Down Expand Up @@ -104,4 +106,101 @@ describe('codegraph index — full re-index keeps the graph populated (#874)', (
expect(afterIndex.nodes).toBe(afterInit.nodes);
expect(afterIndex.edges).toBe(afterInit.edges);
});

it('does not reset the DB when the write lock is unavailable', async () => {
runCodegraph(['init'], tempDir);
const before = graphCounts(tempDir);

const lock = new FileLock(path.join(getCodeGraphDir(tempDir), 'codegraph.lock'));
lock.acquire();
try {
const cg = await CodeGraph.open(tempDir);
try {
const result = await cg.reindexAll();
expect(result.success).toBe(false);
expect(result.errors[0]?.message).toMatch(/Could not acquire file lock/);
} finally {
cg.close();
}
} finally {
lock.release();
}

const after = graphCounts(tempDir);
expect(after.nodes).toBe(before.nodes);
expect(after.edges).toBe(before.edges);
});

it('resumes a parsed-but-unresolved full index instead of parsing everything again', async () => {
const cg = CodeGraph.initSync(tempDir);
const q = (cg as unknown as { queries: any }).queries;
const now = Date.now();

q.upsertFile({
path: 'a.ts',
contentHash: 'parsed-before-crash',
language: 'typescript',
size: 1,
modifiedAt: now,
indexedAt: now,
nodeCount: 2,
});
q.insertNodes([
{
id: 'a.ts::caller',
kind: 'function',
name: 'caller',
qualifiedName: 'caller',
filePath: 'a.ts',
language: 'typescript',
startLine: 1,
endLine: 1,
startColumn: 0,
endColumn: 0,
updatedAt: now,
},
{
id: 'a.ts::target',
kind: 'function',
name: 'target',
qualifiedName: 'target',
filePath: 'a.ts',
language: 'typescript',
startLine: 2,
endLine: 2,
startColumn: 0,
endColumn: 0,
updatedAt: now,
},
]);
q.insertUnresolvedRefsBatch([
{
fromNodeId: 'a.ts::caller',
referenceName: 'target',
referenceKind: 'calls',
line: 1,
column: 0,
filePath: 'a.ts',
language: 'typescript',
},
]);

const orchestrator = (cg as unknown as { orchestrator: { indexAll: () => Promise<never> } }).orchestrator;
orchestrator.indexAll = async () => {
throw new Error('resume path should not parse');
};

try {
const result = await cg.reindexAll();
expect(result.success).toBe(true);
expect(result.filesIndexed).toBe(1);
expect(result.nodesCreated).toBe(2);
expect(result.edgesCreated).toBeGreaterThan(0);
expect(q.getUnresolvedReferencesCount()).toBe(0);
expect(q.getMetadata('indexed_with_version')).not.toBeNull();
expect(q.getMetadata('index_phase')).toBeNull();
} finally {
cg.close();
}
});
});
14 changes: 14 additions & 0 deletions __tests__/iterate-nodes-by-kind.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,4 +59,18 @@ describe('iterateNodesByKind (#610 streaming)', () => {
}
expect(seen).toBe(q.getNodesByKind('function').length);
});

it('does not materialize every distinct node name before resolving', () => {
const q = (cg as unknown as { queries: any }).queries;
const original = q.getAllNodeNames.bind(q);
q.getAllNodeNames = () => {
throw new Error('resolver should use indexed name-exists lookups');
};

try {
expect(() => cg.resolveReferences()).not.toThrow();
} finally {
q.getAllNodeNames = original;
}
});
});
2 changes: 1 addition & 1 deletion __tests__/pr19-improvements.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -299,7 +299,7 @@ describe('Best-Candidate Resolution', () => {
describe('Schema v2 Migration', () => {
it.skipIf(!HAS_SQLITE)('should have correct current schema version', async () => {
const { CURRENT_SCHEMA_VERSION } = await import('../src/db/migrations');
expect(CURRENT_SCHEMA_VERSION).toBe(5);
expect(CURRENT_SCHEMA_VERSION).toBe(6);
});

it.skipIf(!HAS_SQLITE)('should have migration for version 2', async () => {
Expand Down
24 changes: 24 additions & 0 deletions __tests__/react-native-bridge.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,9 @@ function makeContext(nodes: Node[], fileContents: Record<string, string> = {}):
getNodesByName: (name) => byName.get(name) ?? [],
getNodesByQualifiedName: () => { throw new Error('not used'); },
getNodesByKind: (kind) => nodes.filter((n) => n.kind === kind),
iterateNodesByKind: function* (kind) {
yield* nodes.filter((n) => n.kind === kind);
},
getNodesByLowerName: () => { throw new Error('not used'); },
fileExists: (fp) => allFiles.has(fp),
readFile: (fp) => fileContents[fp] ?? null,
Expand Down Expand Up @@ -121,6 +124,27 @@ describe('React Native bridge resolver', () => {
expect(result?.resolvedBy).toBe('framework');
});

it('streams method nodes when building the bridge map instead of materializing every method', () => {
const native = method('getCurrentPosition:', 'objc', 'RCTGeolocation.m');
const ctx = {
...makeContext([native], {
'package.json': '{"dependencies":{"react-native":"^0.73"}}',
'RCTGeolocation.m':
'@implementation RCTGeolocation\n' +
'RCT_EXPORT_MODULE()\n' +
'RCT_EXPORT_METHOD(getCurrentPosition:(RCTResponseSenderBlock)cb) {}\n' +
'@end',
}),
getNodesByKind: () => { throw new Error('full method scan should not be used'); },
} satisfies ResolutionContext;

const result = reactNativeBridgeResolver.resolve(
ref('getCurrentPosition', 'javascript', 'App.js'),
ctx
);
expect(result?.targetNodeId).toBe(native.id);
});

it('resolves via explicit module name in RCT_EXPORT_MODULE(name)', () => {
const native = method('startScan:', 'objc', 'Bluetooth.m');
const ctx = makeContext([native], {
Expand Down
73 changes: 73 additions & 0 deletions __tests__/resolution.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1307,6 +1307,79 @@ func main() {
const result = matchReference(ref, baseContext([variable, decorator]));
expect(result?.targetNodeId).toBe('func:di.ts:Inject:10');
});

it('uses filtered name lookup for exact call refs instead of materializing all same-name nodes', () => {
const target: Node = {
id: 'func:src/app.ts:main:10', kind: 'function', name: 'main',
qualifiedName: 'src/app.ts::main', filePath: 'src/app.ts', language: 'typescript',
startLine: 10, endLine: 20, startColumn: 0, endColumn: 0, updatedAt: Date.now(),
};
const ref = {
fromNodeId: 'func:src/app.ts:bootstrap:1',
referenceName: 'main',
referenceKind: 'calls' as const,
line: 5, column: 0, filePath: 'src/app.ts', language: 'typescript' as const,
};
const context: ResolutionContext = {
getNodesInFile: () => [],
getNodesByName: () => { throw new Error('full name lookup should not be used'); },
getNodesByNameFiltered: (name, filters = {}) => {
expect(name).toBe('main');
expect(filters.kinds).toContain('function');
return [target];
},
getNodesByQualifiedName: () => [],
getNodesByKind: () => [],
fileExists: () => true,
readFile: () => null,
getProjectRoot: () => '/test',
getAllFiles: () => [],
getNodesByLowerName: () => [],
getImportMappings: () => [],
};

const result = matchReference(ref, context);
expect(result?.targetNodeId).toBe(target.id);
});

it('uses filtered lookup for method-call fallback instead of loading every same-name method', () => {
const target: Node = {
id: 'method:src/service.ts:PermissionEngine::check:10', kind: 'method', name: 'check',
qualifiedName: 'src/service.ts::PermissionEngine::check', filePath: 'src/service.ts',
language: 'typescript', startLine: 10, endLine: 20, startColumn: 0, endColumn: 0,
updatedAt: Date.now(),
};
const ref = {
fromNodeId: 'func:src/app.ts:run:1',
referenceName: 'permissionEngine.check',
referenceKind: 'calls' as const,
line: 5, column: 0, filePath: 'src/app.ts', language: 'typescript' as const,
};
const context: ResolutionContext = {
getNodesInFile: () => [],
getNodesByName: () => { throw new Error('full name lookup should not be used'); },
getNodesByNameFiltered: (name, filters = {}) => {
if (name === 'permissionEngine' || name === 'PermissionEngine') return [];
if (filters.qualifiedNameSuffix) return [];
expect(name).toBe('check');
expect(filters.kinds).toEqual(['method']);
expect(filters.language).toBe('typescript');
return [target];
},
getNodesByQualifiedName: () => [],
getNodesByKind: () => [],
fileExists: () => true,
readFile: () => null,
getProjectRoot: () => '/test',
getAllFiles: () => [],
getNodesByLowerName: () => [],
getImportMappings: () => [],
};

const result = matchReference(ref, context);
expect(result?.targetNodeId).toBe(target.id);
expect(result?.resolvedBy).toBe('instance-method');
});
});

describe('tsconfig path aliases', () => {
Expand Down
17 changes: 17 additions & 0 deletions __tests__/swift-objc-bridge-resolver.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,9 @@ function makeContext(nodes: Node[], fileContents: Record<string, string> = {}):
getNodesByName: (name) => byName.get(name) ?? [],
getNodesByQualifiedName: () => { throw new Error('not used'); },
getNodesByKind: (kind) => nodes.filter((n) => n.kind === kind),
iterateNodesByKind: function* (kind) {
yield* nodes.filter((n) => n.kind === kind);
},
getNodesByLowerName: () => { throw new Error('not used'); },
fileExists: (fp) => allFiles.has(fp),
readFile: (fp) => fileContents[fp] ?? null,
Expand Down Expand Up @@ -113,6 +116,20 @@ describe('swiftObjcBridgeResolver integration', () => {
expect(result?.confidence).toBe(0.6);
});

it('streams ObjC method nodes when building the bridge map', () => {
const objcTarget = method('fetchEntryForKey:', 'objc', 'Cache.m');
const ctx = {
...makeContext([objcTarget]),
getNodesByKind: () => { throw new Error('full method scan should not be used'); },
} satisfies ResolutionContext;

const result = swiftObjcBridgeResolver.resolve(
ref('fetchEntry', 'swift', 'Caller.swift'),
ctx
);
expect(result?.targetNodeId).toBe(objcTarget.id);
});

it('does NOT bridge generic Cocoa names like "init" or "description"', () => {
// Bridging Swift `init()` calls to arbitrary ObjC `init*:` methods is
// noise — every NSObject subclass has them. The regular name-matcher
Expand Down
18 changes: 6 additions & 12 deletions src/bin/codegraph.ts
Original file line number Diff line number Diff line change
Expand Up @@ -628,10 +628,7 @@ program
const cg = await CodeGraph.open(projectPath);

if (options.quiet) {
// Quiet mode: no UI, just run. `index` is a full re-index, so clear the
// existing graph and rebuild from scratch (see the note below — #874).
cg.clear();
const result = await cg.indexAll();
const result = await cg.reindexAll();
if (!result.success) process.exit(1);
cg.destroy();
return;
Expand All @@ -640,24 +637,21 @@ program
const clack = await importESM('@clack/prompts');
clack.intro('Indexing project');

// `index` is a FULL re-index: clear the existing graph and rebuild it from
// scratch so the result is identical to a fresh `init`. Without the clear,
// indexAll() skips every unchanged file by its content hash and reports
// "0 nodes, 0 edges" against the already-populated graph — which reads as
// "index wiped my index" (#874). For fast incremental updates use `sync`.
cg.clear();
// `index` is a FULL re-index: drop and recreate the DB from scratch so
// the result is identical to a fresh `init` (much faster than row-by-row
// DELETE on a large DB — see #874). For fast incremental updates use `sync`.

let result: IndexResult;

if (options.verbose) {
result = await cg.indexAll({
result = await cg.reindexAll({
onProgress: createVerboseProgress(),
verbose: true,
});
} else {
process.stdout.write(`${colors.dim}${getGlyphs().rail}${colors.reset}\n`);
const progress = createShimmerProgress();
result = await cg.indexAll({
result = await cg.reindexAll({
onProgress: progress.onProgress,
});
await progress.stop();
Expand Down
13 changes: 12 additions & 1 deletion src/db/migrations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import { SqliteDatabase } from './sqlite-adapter';
/**
* Current schema version
*/
export const CURRENT_SCHEMA_VERSION = 5;
export const CURRENT_SCHEMA_VERSION = 6;

/**
* Migration definition
Expand Down Expand Up @@ -75,6 +75,17 @@ const migrations: Migration[] = [
`);
},
},
{
version: 6,
description:
'Add composite node lookup indexes for memory-bounded reference resolution',
up: (db) => {
db.exec(`
CREATE INDEX IF NOT EXISTS idx_nodes_name_language_kind ON nodes(name, language, kind);
CREATE INDEX IF NOT EXISTS idx_nodes_name_language_file ON nodes(name, language, file_path);
`);
},
},
];

/**
Expand Down
Loading