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: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ The public API surface is `src/index.ts` — the `CodeGraph` class wires all the
- `src/extraction/` — `ExtractionOrchestrator`, tree-sitter wrappers, per-language extractors under `languages/` (one file per language), plus standalone extractors for non-tree-sitter formats (`svelte-extractor.ts`, `vue-extractor.ts`, `liquid-extractor.ts`, `dfm-extractor.ts` for Delphi). `parse-worker.ts` runs heavy parsing off the main thread.
- `src/resolution/` — `ReferenceResolver` orchestrates `import-resolver.ts` (with `path-aliases.ts` for tsconfig path aliases + cargo workspace member globs), `name-matcher.ts`, and `frameworks/` (Express, Laravel, Rails, FastAPI, Django, Flask, Spring, Gin, Axum, ASP.NET, Vapor, React Router, Next.js — `nextjs.ts`: pages and `route.ts` handlers from files, `router.push` / `redirect` / `NextResponse.redirect` as `navigates` edges, with `next-router-synthesizer.ts` for `<Link href>` — Expo Router, SvelteKit, Vue/Nuxt, Cargo workspaces). Frameworks emit `route` nodes and `references` edges. `callback-synthesizer.ts` holds the whole-graph synthesis passes (`SYNTH_PASSES`, merged in registry order — first-seen wins a duplicate pair) with the language gates; `tier-synthesizer.ts` is the cross-tier pass (a client's literal `fetch`/`axios` path onto its own route, a queue job onto its consumer, a bus / socket event onto its handler — `channel`, `tier`, `registeredAt` on every edge; registered before the in-process emitter pass so its more specific edge wins); `synth-utils.ts` has the helpers they share (`enclosingFn`, `enclosingValue`, `makeLineAt`). Express's `postExtract` composes `app.use('/prefix', router)` mounts onto a mounted file's route names, idempotently (the original path stays in `qualifiedName`).
- `src/graph/` — `GraphTraverser` (BFS/DFS, impact radius, path finding) and `GraphQueryManager` (high-level queries), plus the shared query-time derivations more than one surface renders: `named-symbol-flow.ts` (the one path finder, behind `codegraph_explore`'s Flow section and the viewer's Flow strip), `dynamic-boundary-report.ts` (where the graph stops), `type-hierarchy.ts` (ancestors/subtypes and the implementation count explore prints and the viewer draws),
`dead-code.ts` (unreferenced symbols, and every reason a candidate is NOT claimed). A derivation that two callers render must live here, not in `ToolHandler` — two derivations eventually disagree.
`dead-code.ts` (unreferenced symbols, and every reason a candidate is NOT claimed), `synthesized-hop.ts` (how a synthesized hop reads, in explore's Flow, the `codegraph_node` trail and ContextBuilder's call paths — a new synthesizer's wording goes here). A derivation that two callers render must live here, not in `ToolHandler` — two derivations eventually disagree.
- `src/context/` — `ContextBuilder` + formatter for markdown/JSON output.
- `src/search/` — full-text query parser and helpers for FTS5.
- `src/sync/` — `FileWatcher` (native FSEvents/inotify/RDCW) with debounce + filter, and git-hook helpers.
Expand Down
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
- 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)
- Indexing a project that ships a large minified JavaScript file, such as the GraphiQL bundle inside go-ethereum, no longer stalls at "Resolving refs" when CodeGraph runs on Node 22, and linking such a file is faster on any Node version. The graph it builds is unchanged.
- 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)
- The call paths `codegraph context` prints now say what bridges each dynamic-dispatch hop, in the same words `codegraph_explore` uses: a call through an interface reads `interface → impl`, and a C++ override, a Redux thunk or a Gin middleware chain is named as one. Before, every hop of a kind it didn't know was labeled an "event", so a call through an interface looked like an event being emitted.
- In Rust, code that uses an enum only through its variants, like `mode::Mode::A` in an expression, a `Mode::B =>` match arm or `Self::A` inside the enum's own `impl`, now shows up in that enum's callers and impact, linked to the enum that actually declares the variant rather than to a same-named type elsewhere. Standard-library variants like `Ordering::Less` or `Option::Some` and associated items like `Foo::new()` or `Foo::MAX` don't count as using a project enum. Re-index Rust projects after upgrading. Thanks @mg-mg-mg for the report and @danusha2345. (#2328)
- Axum routes now link to their handler when rustfmt wraps the `.route(` call so the handler sits on a later line, and when the handler's name also appears elsewhere on the line, as in `delete(handlers::user::delete)` or `.route("/login", post(handlers::user::login))`; Actix's `web::resource(…)` and `.route(…)` registrations get the same fix. A call inside a closure handler is no longer mistaken for a route, and an Actix resource no longer takes the next route's handler as its own. Thanks @mg-mg-mg for the report and @danusha2345. (#2326)
- In JavaScript and TypeScript, the functions written inside a named object literal are now symbols of their own even when the object isn't exported: a plain `const api = { load() {…} }`, an object declared inside an IIFE or a function, and a namespace hung on the page or on another object, like `window.App = { init() {…} }`, `App.utils = {…}` or `dw_page = {…}`. Calls such as `api.load()`, `window.App.init()`, `App.utils.pad()`, a sibling's `this.render()`, and `App.init()` from another script on the page now reach them, the calls made inside a member belong to that member instead of the object, and a bare `init()` is no longer taken for `App.init`, so script-tag apps no longer lose most of their code from `callers` and impact. A member's qualified name now carries its object, exported or not (`api::load`, `window.App::init`), so asking for `App.init` finds it. Re-index JavaScript and TypeScript projects after upgrading. Thanks @tkhoaaa for the report and @danusha2345. (#2300)
Expand Down
119 changes: 119 additions & 0 deletions __tests__/synthesized-hop.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
/**
* One wording for a synthesized (dynamic-dispatch) hop, on both surfaces that
* print one: codegraph_explore's Flow and the codegraph_node trail
* (`dynamic: …`), and ContextBuilder's "## Call paths" (`→[…]`, what
* `buildContext` and `codegraph context` print). The call paths used to keep
* their own list, and every synthesizer missing from it — interface dispatch,
* a C++ override, a redux thunk — read as an "event".
*/
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import CodeGraph from '../src/index';
import { ToolHandler } from '../src/mcp/tools';
import { describeSynthesizedHop } from '../src/graph/synthesized-hop';
import type { Edge } from '../src/types';

describe('an interface-impl hop on both surfaces', () => {
const query = 'runExport exportReport renderPdf';
let dir: string;
let cg: CodeGraph;
// Assertions read RAW codegraph_explore output; managed offload would replace it.
let prevOffloadDisable: string | undefined;

beforeAll(async () => {
prevOffloadDisable = process.env.CODEGRAPH_OFFLOAD_DISABLE;
process.env.CODEGRAPH_OFFLOAD_DISABLE = '1';
dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-synth-hop-'));
// One file each: ContextBuilder keeps only a few nodes per file, and with
// everything in one file the interface method is the node it drops.
const files: Record<string, string> = {
'src/exporter.ts': `export interface Exporter {
exportReport(data: string): string;
}
`,
'src/pdf-exporter.ts': `import type { Exporter } from './exporter';
import { renderPdf } from './render';

export class PdfExporter implements Exporter {
exportReport(data: string): string {
return renderPdf(data);
}
}
`,
'src/render.ts': `export function renderPdf(data: string): string {
return '%PDF ' + data;
}
`,
'src/run.ts': `import type { Exporter } from './exporter';

export function runExport(exporter: Exporter): string {
return exporter.exportReport('q3');
}
`,
};
for (const [rel, body] of Object.entries(files)) {
fs.mkdirSync(path.dirname(path.join(dir, rel)), { recursive: true });
fs.writeFileSync(path.join(dir, rel), body);
}
cg = CodeGraph.initSync(dir);
await cg.indexAll();
});

afterAll(() => {
cg?.close();
if (dir) fs.rmSync(dir, { recursive: true, force: true });
if (prevOffloadDisable === undefined) delete process.env.CODEGRAPH_OFFLOAD_DISABLE;
else process.env.CODEGRAPH_OFFLOAD_DISABLE = prevOffloadDisable;
});

it('ContextBuilder call paths label it interface dispatch, not "event"', async () => {
// Precondition: the hop is a real synthesized edge, not a static call.
const [iface] = cg.getNodesByName('exportReport').filter((n) => n.qualifiedName === 'Exporter::exportReport');
expect(cg.getOutgoingEdges(iface!.id).map((e) => e.metadata?.synthesizedBy)).toContain('interface-impl');

const md = String(await cg.buildContext(query, { format: 'markdown' }));
expect(md).toContain('## Call paths');
const callPaths = md.slice(md.indexOf('## Call paths'));
expect(callPaths).toContain(
'- runExport → exportReport →[interface → impl @src/pdf-exporter.ts:5] exportReport → renderPdf'
);
expect(callPaths).not.toContain('→[event');
});

it('codegraph_explore tags the same hop with the same words', async () => {
const res = await new ToolHandler(cg).execute('codegraph_explore', { query });
expect(res.content?.[0]?.text).toContain('↓ dynamic: interface → impl @src/pdf-exporter.ts:5');
});
});

describe('describeSynthesizedHop', () => {
const at = 'src/wire.ts:7';
const edge = (metadata: Record<string, unknown>): Edge =>
({ source: 'a', target: 'b', kind: 'calls', provenance: 'heuristic', metadata: { registeredAt: at, ...metadata } });

it.each([
// Synthesizers the call paths used to call an "event".
[{ synthesizedBy: 'interface-impl', via: 'handle' }, 'interface → impl', ''],
[{ synthesizedBy: 'interface-impl', via: 'Report', promotedInto: 'scrapeLoop' }, 'interface → method promoted into scrapeLoop', ''],
[{ synthesizedBy: 'closure-collection', field: 'validators' }, 'runs `validators` handlers', ''],
[{ synthesizedBy: 'cpp-override', via: 'Run' }, 'cpp override', ''],
[{ synthesizedBy: 'redux-thunk', via: 'fetchUser' }, 'redux thunk', ''],
[{ synthesizedBy: 'gin-middleware-chain', via: 'Auth' }, 'gin middleware chain', ''],
[{ synthesizedBy: 'event-bus', channel: 'event', event: 'user.created' }, 'bus event `user.created`', ''],
// The detail only the call paths print.
[{ synthesizedBy: 'queue-job', event: 'send-email', queue: 'emails' }, 'queue job `send-email`', ' on `emails`'],
[{ synthesizedBy: 'event-bus', channel: 'socket', event: 'chat', tier: 'client→server' }, 'socket message `chat`', ' → server'],
[{ synthesizedBy: 'http-client', method: 'POST', href: '/api/users' }, 'HTTP POST /api/users', " — the client's call onto its own route"],
])('%o reads "%s"', (metadata, summary, detail) => {
expect(describeSynthesizedHop(edge(metadata))).toEqual({ summary, detail, registeredAt: at });
});

it('is null for a static edge and for a heuristic edge no synthesizer made', () => {
expect(describeSynthesizedHop({ source: 'a', target: 'b', kind: 'calls', provenance: 'tree-sitter' })).toBeNull();
expect(describeSynthesizedHop({ ...edge({ synthesizedBy: 'callback', via: 'on' }), provenance: undefined })).toBeNull();
expect(describeSynthesizedHop(edge({ confidence: 0.5 }))).toBeNull();
expect(describeSynthesizedHop(null)).toBeNull();
});
});
35 changes: 11 additions & 24 deletions src/context/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import { validatePathWithinRoot, isConfigLeafNode } from '../utils';
import { isTestFile, extractSearchTerms, scorePathRelevance, getStemVariants, isDistinctiveIdentifier } from '../search/query-utils';
import { LOW_CONFIDENCE_MARKER } from './markers';
import { findNamedCopybooks, isCopybookInclude } from '../graph/cobol-copybooks';
import { describeSynthesizedHop } from '../graph/synthesized-hop';

/**
* Most top-level declarations of one copybook that become entry points — its
Expand Down Expand Up @@ -403,31 +404,17 @@ export class ContextBuilder {
const name = (id: string): string => subgraph.nodes.get(id)?.name ?? id;

// Synthesized (dynamic-dispatch) hops are real `calls` edges but invisible to
// static parsing — mark them inline so the agent sees WHERE the callback was
// wired up (`registered @file:line`) instead of grepping for it. Keyed by
// "source>target".
// static parsing — mark them inline so the agent sees WHAT bridged the hop
// and WHERE it was wired up (`@file:line`) instead of grepping for it. The
// wording is `graph/synthesized-hop.ts`'s, the same explore's Flow uses, plus
// the detail this roomier line has space for. Keyed by "source>target".
const synthByPair = new Map<string, string>();
for (const e of subgraph.edges) {
if (e.kind !== 'calls' || e.provenance !== 'heuristic') continue;
const m = e.metadata as Record<string, unknown> | undefined;
if (!m?.synthesizedBy) continue;
const at = typeof m.registeredAt === 'string' ? ` @${m.registeredAt}` : '';
const label = m.synthesizedBy === 'callback'
? `callback via ${m.via ? `\`${String(m.via)}\`` : 'registrar'}${at}`
: m.synthesizedBy === 'react-render'
? `React re-render via setState${at}`
: m.synthesizedBy === 'jsx-render'
? `renders <${String(m.via || 'child')}>`
: m.synthesizedBy === 'vue-handler'
? `Vue @${String(m.event || 'event')} handler`
: m.synthesizedBy === 'http-client'
? `HTTP ${String(m.method || 'GET')} ${String(m.href || '')} — the client's call onto its own route${at}`
: m.synthesizedBy === 'queue-job'
? `queue job ${m.event ? `\`${String(m.event)}\`` : ''}${m.queue ? ` on \`${String(m.queue)}\`` : ''}${at}`
: m.synthesizedBy === 'event-bus' && m.channel === 'socket'
? `socket message ${m.event ? `\`${String(m.event)}\`` : ''}${m.tier === 'client→server' ? ' → server' : m.tier === 'server→client' ? ' → client' : ''}${at}`
: `event ${m.event ? `\`${String(m.event)}\`` : ''}${at}`;
synthByPair.set(`${e.source}>${e.target}`, label);
if (e.kind !== 'calls') continue;
const hop = describeSynthesizedHop(e);
if (!hop) continue;
const at = hop.registeredAt ? ` @${hop.registeredAt}` : '';
synthByPair.set(`${e.source}>${e.target}`, `${hop.summary}${hop.detail}${at}`);
}
const renderChain = (c: string[]): string => {
let s = name(c[0]!);
Expand All @@ -447,7 +434,7 @@ export class ContextBuilder {
...kept.map((c) => `- ${renderChain(c)}`),
'',
hasSynth
? '_Hops marked `[callback/event …]` are dynamic dispatch bridged by codegraph (with the registration site); the rest are direct calls. codegraph_node any symbol for its body._'
? '_Hops marked `[…]` are dynamic dispatch bridged by codegraph (with the registration site); the rest are direct calls. codegraph_node any symbol for its body._'
: '_codegraph_node any symbol above for its source + its own callers/callees._',
];
return '\n' + lines.join('\n') + '\n';
Expand Down
Loading