Skip to content
Merged
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: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,8 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
- In Dart, a type is now linked from more of the places it is written, not only from parameters and return types: the type an `extension … on` targets, a field's type, a top-level variable's type, generic arguments such as `Future<Report?>.value(null)` or Riverpod's `final reportProvider = Family<Report?, String>()`, and a local variable's type, a cast or a type check inside a function. So `callers` and impact for a model class now reach the extensions, models and providers that use it. Thanks @mg-mg-mg for the report. (#2327)
- In Go, calls through a method receiver or parameter of an unexported type, the usual shape of gRPC and HTTP handlers (`s.service.AddItem()` inside `func (s *server) Create()`), now resolve, and always within that type's own package: another package's `server` with a same-named method no longer takes the call, and a method the type gets from a struct or interface it embeds is found too. Re-index Go projects after upgrading. Thanks @GoDiao for the report and the fix. (#2323)
- In Go, calls into a module whose `go.mod` is not at the project root now resolve, whether it is a `server/` backend next to a `web/` frontend or one of several modules side by side as in etcd, so calls like `store.New()` and `s.db.CreateItem()` find their targets instead of being treated as calls into a third-party package. A name written through a package, like a `job.OPCommand` result type or a field of type `artifact.Manager`, now links to that package's symbol rather than a same-named one elsewhere, which also corrects links in projects with a single `go.mod`. Re-index Go projects after upgrading. Thanks @GoDiao for the report and @danusha2345 for the fix. (#2322)
- Indexing a project that includes large bundled JavaScript files, such as a copy of pdf.js or d3, is fast again: since 1.6.2, resolving the calls in a JavaScript or TypeScript file re-read the file's text above each call, so a single bundled library could add many seconds to an index. The graph it builds is unchanged. Thanks @bompus for the report. (#2334)
- The time `codegraph init` and `codegraph index` print beside the node and edge counts now covers the whole run, resolving references and linking included, and `codegraph sync` reports its whole run the same way. Before, it counted only reading and parsing the files, which can be a small part of an index, so a slow index looked fast. Thanks @bompus for the report. (#2334)

## [1.6.2] - 2026-10-03

Expand Down
51 changes: 50 additions & 1 deletion __tests__/integration/full-pipeline.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
* crawl).
*/

import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
Expand Down Expand Up @@ -269,4 +269,53 @@ describe('Integration: full pipeline', () => {
cg.destroy();
}
}, 30_000);

it('reports durationMs for the whole run, resolution and linking included (#2334)', async () => {
// The CLI summary printed the orchestrator's time — extraction alone —
// beside the totals of the whole run, so slow resolution never showed.
generateSyntheticProject(tempDir, 10);
const cg = await CodeGraph.init(tempDir, {
config: { include: ['**/*.ts'], exclude: [] },
});
const internals = cg as unknown as {
orchestrator: Record<'indexAll' | 'sync', (...args: unknown[]) => Promise<{ durationMs: number }>>;
resolver: { resolveDeferredThisMemberRefs: () => Promise<number> };
};
// What the orchestrator measured; and a linking pass that takes a known while.
let extraction = -1;
const measure = (method: 'indexAll' | 'sync') => {
const original = internals.orchestrator[method].bind(internals.orchestrator);
vi.spyOn(internals.orchestrator, method).mockImplementation(async (...args: unknown[]) => {
const result = await original(...args);
extraction = result.durationMs;
return result;
});
};
const LINKING_MS = 250;
const linking = internals.resolver.resolveDeferredThisMemberRefs.bind(internals.resolver);
vi.spyOn(internals.resolver, 'resolveDeferredThisMemberRefs').mockImplementation(async () => {
const until = Date.now() + LINKING_MS;
while (Date.now() < until) await new Promise((r) => setTimeout(r, 10));
return linking();
});

try {
measure('indexAll');
const indexed = await cg.indexAll();
expect(extraction).toBeGreaterThanOrEqual(0);
expect(indexed.durationMs).toBeGreaterThanOrEqual(extraction + LINKING_MS);

// Likewise a sync that re-resolves a changed file.
fs.appendFileSync(path.join(tempDir, 'src', 'index.ts'), 'export function again(): number { return entry(); }\n');
extraction = -1;
measure('sync');
const synced = await cg.sync();
expect(synced.filesModified).toBe(1);
expect(extraction).toBeGreaterThanOrEqual(0);
expect(synced.durationMs).toBeGreaterThanOrEqual(extraction + LINKING_MS);
} finally {
vi.restoreAllMocks();
cg.destroy();
}
}, 30_000);
});
84 changes: 84 additions & 0 deletions __tests__/js-resolution-work.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { CodeGraph } from '../src';
import { stripCommentsForRegex } from '../src/resolution/strip-comments';

// Pass-through, so a test can count how often text is stripped.
vi.mock('../src/resolution/strip-comments', async (importOriginal) => {
const actual = await importOriginal<typeof import('../src/resolution/strip-comments')>();
return { ...actual, stripCommentsForRegex: vi.fn(actual.stripCommentsForRegex) };
});

/**
* #2334: resolving JS/TS does a bounded amount of work per file. In a file
* with destructuring, the check for a call through a destructured name
* stripped and scanned every line above each bare call; the check for a name
* the calling function binds itself stripped that function's lines above each
* reference again. A bundled library took time in the square of its size.
* Counted, never timed.
*/
describe('JS resolution work (#2334)', () => {
let tmpDir: string | undefined;
let cg: CodeGraph | undefined;

afterEach(() => {
vi.mocked(stripCommentsForRegex).mockClear();
cg?.close();
cg = undefined;
if (tmpDir) fs.rmSync(tmpDir, { recursive: true, force: true, maxRetries: 5 });
tmpDir = undefined;
});

async function index(files: Record<string, string>): Promise<CodeGraph> {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-js-work-'));
for (const [file, content] of Object.entries(files)) {
fs.mkdirSync(path.dirname(path.join(tmpDir, file)), { recursive: true });
fs.writeFileSync(path.join(tmpDir, file), content);
}
cg = CodeGraph.initSync(tmpDir);
await cg.indexAll();
return cg;
}

/** How often a non-empty start of `text` was stripped as `lang`. */
const strips = (lang: string, text: string) => vi.mocked(stripCommentsForRegex).mock.calls
.filter(([stripped, as]) => as === lang && stripped.length > 0 && text.startsWith(stripped)).length;

const callers = (graph: CodeGraph, file: string, name: string) => {
const target = graph.getNodesByName(name).find((n) => n.filePath === file && n.kind === 'function')!;
return [...new Set(graph.getIncomingEdges(target.id).filter((e) => e.kind === 'calls')
.map((e) => graph.getNode(e.source)?.name))];
};

it('reads a file with destructuring once, not once per call', async () => {
const CALLS = 40;
const app = `import { useAuth } from './auth';\n` +
`const { login } = useAuth();\n` +
Array.from({ length: CALLS }, (_, i) => `function step${i}() { return ${i}; }\n`).join('') +
`export function run() {\n` +
Array.from({ length: CALLS }, (_, i) => ` step${i}();\n`).join('') +
// A division the blanking reads as a regex literal around the call.
` return (${CALLS}) / login() / 2;\n}\n`;
const graph = await index({
'auth.js': 'export function useAuth() {\n function login() {\n return 1;\n }\n return { login };\n}\n',
'app.js': app,
});
// The call through the destructured name reaches the function the hook returns.
expect(callers(graph, 'auth.js', 'login')).toEqual(['run']);
expect(callers(graph, 'app.js', 'step7')).toEqual(['run']);
// Each bare call was checked; the file was stripped for that once, not once per call.
expect(strips('typescript', app)).toBeLessThan(CALLS / 4);
});

it("strips a function's lines once, not once per reference", async () => {
const LINES = 40;
const host = `export function host() {\n` +
Array.from({ length: LINES }, (_, i) => ` helper(${i});\n`).join('') + `}\n`;
const graph = await index({ 'lib.js': `export function helper(n) {\n return n;\n}\n${host}` });
// Each call resolved, so each asked whether `host` binds `helper` itself.
expect(callers(graph, 'lib.js', 'helper')).toEqual(['host']);
expect(strips('javascript', host)).toBeLessThan(LINES / 4);
});
});
11 changes: 11 additions & 0 deletions src/extraction/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,12 @@ export interface IndexResult {
nodesCreated: number;
edgesCreated: number;
errors: ExtractionError[];
/**
* Wall time in milliseconds. `CodeGraph.indexAll` reports the whole run —
* scanning, parsing and storing, then resolving references and linking —
* as it does the node and edge totals; the orchestrator's own result
* covers only the files' extraction.
*/
durationMs: number;
}

Expand All @@ -132,6 +138,11 @@ export interface SyncResult {
filesModified: number;
filesRemoved: number;
nodesUpdated: number;
/**
* Wall time in milliseconds. `CodeGraph.sync` reports the whole sync,
* resolution and linking included; the orchestrator's own result covers
* only reconciling and re-extracting the files.
*/
durationMs: number;
changedFilePaths?: string[];
/** Paths not absorbed because reading or extraction failed; retain for status/retry. */
Expand Down
8 changes: 8 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -514,6 +514,7 @@ export class CodeGraph {
*/
async indexAll(options: IndexOptions = {}): Promise<IndexResult> {
return this.indexMutex.withLock(async () => {
const startedAt = Date.now();
try {
this.fileLock.acquire();
} catch {
Expand Down Expand Up @@ -775,6 +776,10 @@ export class CodeGraph {
}
} catch { /* metadata is advisory — never fail an index over it */ }

// The time covers the whole run, like the totals above. The
// orchestrator's covers extraction alone, and the summary printed it
// as the run's, leaving out resolution and linking (#2334).
result.durationMs = Date.now() - startedAt;
return result;
} finally {
// Restore the auto-checkpoint interval AFTER the fold-up above so the
Expand Down Expand Up @@ -827,6 +832,7 @@ export class CodeGraph {
*/
async sync(options: IndexOptions = {}): Promise<SyncResult> {
return this.indexMutex.withLock(async () => {
const startedAt = Date.now();
try {
this.fileLock.acquire();
} catch (err) {
Expand Down Expand Up @@ -1146,6 +1152,8 @@ export class CodeGraph {
this.orchestrator.finishGitIndexState(gitState, fullReconcile, result.failedFilePaths);

if (fullReconcile && result.filesChecked > 0) this.pendingFullReconcile = false;
// The whole sync, as for indexAll: resolution and linking included (#2334).
result.durationMs = Date.now() - startedAt;
return result;
} finally {
// Mirror indexAll's teardown: stop the valve, then restore the
Expand Down
Loading