diff --git a/CHANGELOG.md b/CHANGELOG.md index 255b192a7..fbf4c5d4e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,7 +16,20 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ### New Features -- The `codegraph_node` MCP tool now accepts a file path on its own (no symbol) and returns that file's symbols plus which files depend on it — and the full source with `includeCode`. It's a drop-in upgrade for reading a source file: the same content, plus the file's blast radius, in one call. The agent-facing guidance was also retuned so assistants reach for codegraph while *implementing* a change (not only when answering questions), since one codegraph call returns more accurate context for fewer tokens than re-reading files. +- **Subagents and non-MCP agents can now reach CodeGraph.** Two new CLI commands — `codegraph explore ""` and `codegraph node ` — print exactly what the matching MCP tools return (relevant symbols' source + call paths; one symbol's source + callers; file reads with line numbers), so any agent with a shell can use the graph. And `codegraph install` now writes a small marker-fenced CodeGraph section into each agent's instructions file (`CLAUDE.md` / `AGENTS.md` / `GEMINI.md`) pointing at both surfaces — that file is what Task-tool subagents actually see, where the MCP server's own guidance only reaches the main agent. Measured on a delegated code-exploration task: subagents went from almost never using CodeGraph (~1 in 9 runs) to using it in every run, including runs with zero grep/file-reading fallback. The section is small, survives your own content, upgrades cleanly from the old long block, and `codegraph uninstall` removes it. Thanks @liuyao37511. (#704) +- **The MCP tool list is now a focused default of four** — `codegraph_explore`, `codegraph_node`, `codegraph_search`, and `codegraph_callers`. The other four (`codegraph_callees`, `codegraph_impact`, `codegraph_files`, `codegraph_status`) remain fully functional — the CLI and library API are unchanged, and `CODEGRAPH_MCP_TOOLS` re-enables any of them — but they're no longer listed to agents by default: measured agent behavior shows they're never or rarely picked, and the information they carry already arrives inline on the tools agents do use (explore's blast-radius section, node's dependents note, a symbol's own body as its callee list). A leaner list saves context tokens every session and steers agents to the right tool by presence alone. +- **CodeGraph now goes quiet instead of failing loudly in unindexed projects.** When an AI agent's session starts in a workspace that has no CodeGraph index, the MCP server now announces itself as inactive with a short note and lists no tools at all — instead of presenting the full toolset and erroring on every call, which taught agents to distrust CodeGraph even where it works. Querying another project that isn't indexed likewise returns clear guidance (use your regular tools for that codebase; the user can run `codegraph init` there to enable CodeGraph) instead of an error, and genuine internal errors now tell the agent to retry once rather than give up on CodeGraph entirely. Indexing stays your decision — agents are told not to run it themselves. (#769) +- **Astro projects are now indexed.** `.astro` files previously weren't parsed at all — on a typical Astro site that left most of the codebase invisible to search, impact, and `codegraph_explore`. CodeGraph now extracts the TypeScript frontmatter (functions, imports, `getStaticPaths`, …) and client-side ` + +`; + const result = extractFromSource('Guard.astro', code); + + const templateRefs = result.unresolvedReferences.filter( + (r) => r.referenceKind === 'references' && r.referenceName === 'FakeComponent' + ); + expect(templateRefs).toHaveLength(0); + + // maybeCall/scriptCall come from the delegated TS extraction (once), + // not double-counted by the template scanner + const maybeCalls = result.unresolvedReferences.filter( + (r) => r.referenceName === 'maybeCall' && r.referenceKind === 'calls' + ); + expect(maybeCalls.length).toBeLessThanOrEqual(1); + }); + + it('should extract +`; + const result = extractFromSource('Tracker.astro', code); + + const fn = result.nodes.find((n) => n.kind === 'function' && n.name === 'trackView'); + expect(fn).toBeDefined(); + expect(fn?.startLine).toBe(6); + expect(fn?.language).toBe('astro'); + }); + + it('should create component node for a frontmatter-less template-only file', () => { + const code = `
Static content
+`; + const result = extractFromSource('Static.astro', code); + + const componentNode = result.nodes.find((n) => n.kind === 'component'); + expect(componentNode).toBeDefined(); + expect(componentNode?.name).toBe('Static'); + expect(componentNode?.language).toBe('astro'); + }); + + it('should treat an unclosed frontmatter fence as no frontmatter', () => { + const code = `--- +const broken = true; +
never closed
+`; + const result = extractFromSource('Broken.astro', code); + + // No TS delegation happened (the fence never closes), but the component + // node still exists and nothing throws. + const componentNode = result.nodes.find((n) => n.kind === 'component'); + expect(componentNode).toBeDefined(); + expect(result.nodes.find((n) => n.name === 'broken')).toBeUndefined(); + }); + + it('should create containment edges from component to frontmatter nodes', () => { + const code = `--- +const value = 42; +--- +
{value}
+`; + const result = extractFromSource('Contained.astro', code); + + const componentNode = result.nodes.find((n) => n.kind === 'component'); + expect(componentNode).toBeDefined(); + + const containEdges = result.edges.filter( + (e) => e.source === componentNode!.id && e.kind === 'contains' + ); + expect(containEdges.length).toBeGreaterThan(0); + }); +}); + describe('Instantiates + Decorates edge extraction', () => { it('emits an instantiates ref for `new Foo()`', () => { const code = ` @@ -6265,6 +6757,34 @@ describe('Go cross-package composite literals (blast-radius recall)', () => { } }); + it('attributes a call inside a top-level closure (cobra RunE) to the var, not the file (#693)', async () => { + const dir = createTempDir(); + try { + fs.writeFileSync(path.join(dir, 'go.mod'), 'module example.com/proj\n\ngo 1.21\n'); + // Wire is called ONLY from the anonymous RunE closure inside a top-level + // `var rootCmd = &Cmd{...}` — previously the call leaked to the file node, + // so `callers(Wire)` surfaced a file (or read as "no caller"). It must now + // attribute to the enclosing var. + fs.writeFileSync(path.join(dir, 'factory.go'), `package main\n\nfunc Wire() error { return nil }\n`); + fs.writeFileSync( + path.join(dir, 'root.go'), + `package main\n\ntype Cmd struct{ RunE func() error }\n\nvar rootCmd = &Cmd{\n\tRunE: func() error { return Wire() },\n}\n` + ); + const cg = CodeGraph.initSync(dir, { config: { include: ['**/*.go'], exclude: [] } }); + await cg.indexAll(); + cg.resolveReferences(); + + const wire = cg.getNodesByName('Wire').find((n) => n.kind === 'function'); + expect(wire).toBeDefined(); + const callers = cg.getCallers(wire!.id).map((c) => c.node); + expect(callers.some((n) => n.kind === 'variable' && n.name === 'rootCmd')).toBe(true); + expect(callers.some((n) => n.kind === 'file')).toBe(false); + cg.destroy(); + } finally { + cleanupTempDir(dir); + } + }); + it('links a parenthesized pointer type conversion `(*T)(x)` to the type', async () => { const dir = createTempDir(); try { diff --git a/__tests__/foundation.test.ts b/__tests__/foundation.test.ts index 9933cf8c5..05fa79804 100644 --- a/__tests__/foundation.test.ts +++ b/__tests__/foundation.test.ts @@ -10,7 +10,7 @@ import * as path from 'path'; import * as os from 'os'; import { CodeGraph } from '../src'; import { Node, Edge } from '../src/types'; -import { isInitialized, getCodeGraphDir, validateDirectory } from '../src/directory'; +import { isInitialized, getCodeGraphDir, validateDirectory, codeGraphDirName, isCodeGraphDataDir } from '../src/directory'; import { DatabaseConnection, getDatabasePath } from '../src/db'; // Create a temporary directory for each test @@ -159,6 +159,46 @@ describe('CodeGraph Foundation', () => { expect(validation.valid).toBe(false); expect(validation.errors.length).toBeGreaterThan(0); }); + + it('upgrades a stale pre-wildcard .gitignore in place (issue #788)', () => { + const cg = CodeGraph.initSync(tempDir); + cg.close(); + + const gitignorePath = path.join(getCodeGraphDir(tempDir), '.gitignore'); + // A .gitignore written by an older version (<= 0.9.9): an explicit + // allowlist that never ignored daemon.pid, so the daemon's runtime + // pidfile got committed. + const staleV099 = + '# CodeGraph data files\n' + + '# These are local to each machine and should not be committed\n\n' + + '# Database\n*.db\n*.db-wal\n*.db-shm\n\n' + + '# Cache\ncache/\n\n# Logs\n*.log\n\n# Hook markers\n.dirty\n'; + fs.writeFileSync(gitignorePath, staleV099, 'utf-8'); + + // Opening the project runs validateDirectory, which self-heals. + const cg2 = CodeGraph.openSync(tempDir); + cg2.close(); + + const upgraded = fs.readFileSync(gitignorePath, 'utf-8'); + expect(upgraded).toContain('\n*\n'); // wildcard ignores everything… + expect(upgraded).toContain('!.gitignore'); // …except this file + expect(upgraded).not.toContain('.dirty'); // old explicit list is gone + }); + + it('leaves a user-customized .codegraph/.gitignore untouched', () => { + const cg = CodeGraph.initSync(tempDir); + cg.close(); + + const gitignorePath = path.join(getCodeGraphDir(tempDir), '.gitignore'); + // No CodeGraph header → user-authored → must not be rewritten. + const custom = '# my own rules\n*.db\n!keep-this.json\n'; + fs.writeFileSync(gitignorePath, custom, 'utf-8'); + + const cg2 = CodeGraph.openSync(tempDir); + cg2.close(); + + expect(fs.readFileSync(gitignorePath, 'utf-8')).toBe(custom); + }); }); describe('Uninitialize', () => { @@ -242,7 +282,7 @@ describe('Database Connection', () => { const version = db.getSchemaVersion(); expect(version).not.toBeNull(); - expect(version?.version).toBe(4); + expect(version?.version).toBe(5); db.close(); }); @@ -306,3 +346,93 @@ describe('Query Builder', () => { expect(files).toEqual([]); }); }); + +// Two environments that share one working tree (Windows-native + WSL) must not +// share one `.codegraph/`. CODEGRAPH_DIR overrides the data directory name so +// each side keeps its own index in the same tree (issue #636). +describe('CODEGRAPH_DIR override (#636)', () => { + const saved = process.env.CODEGRAPH_DIR; + let tempDir: string; + + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-dirname-')); + }); + afterEach(() => { + if (saved === undefined) delete process.env.CODEGRAPH_DIR; + else process.env.CODEGRAPH_DIR = saved; + fs.rmSync(tempDir, { recursive: true, force: true }); + }); + + describe('codeGraphDirName()', () => { + it('defaults to .codegraph when unset', () => { + delete process.env.CODEGRAPH_DIR; + expect(codeGraphDirName()).toBe('.codegraph'); + }); + + it('honors a valid override', () => { + process.env.CODEGRAPH_DIR = '.codegraph-win'; + expect(codeGraphDirName()).toBe('.codegraph-win'); + }); + + // Anything that isn't a plain segment could escape the project root or + // clobber it, so it's ignored in favor of the default. + it.each(['foo/bar', 'a\\b', '..', '../x', '.', '/abs/path', ' ', ''])( + 'falls back to .codegraph for invalid value %j', + (bad) => { + process.env.CODEGRAPH_DIR = bad; + expect(codeGraphDirName()).toBe('.codegraph'); + } + ); + }); + + describe('isCodeGraphDataDir()', () => { + it('matches the default, the active override, and .codegraph-* siblings', () => { + process.env.CODEGRAPH_DIR = '.codegraph-win'; + expect(isCodeGraphDataDir('.codegraph')).toBe(true); // the other env's dir + expect(isCodeGraphDataDir('.codegraph-win')).toBe(true); // active override + expect(isCodeGraphDataDir('.codegraph-wsl')).toBe(true); // any sibling + }); + + it('does not match unrelated directories', () => { + delete process.env.CODEGRAPH_DIR; + for (const name of ['src', 'node_modules', '.git', 'codegraph', '.codegraphextra']) { + expect(isCodeGraphDataDir(name)).toBe(false); + } + }); + }); + + it('init writes the index under the overridden directory, not .codegraph', () => { + process.env.CODEGRAPH_DIR = '.codegraph-win'; + const cg = CodeGraph.initSync(tempDir); + try { + expect(fs.existsSync(path.join(tempDir, '.codegraph-win', 'codegraph.db'))).toBe(true); + expect(fs.existsSync(path.join(tempDir, '.codegraph'))).toBe(false); + expect(getCodeGraphDir(tempDir)).toBe(path.join(tempDir, '.codegraph-win')); + expect(CodeGraph.isInitialized(tempDir)).toBe(true); + } finally { + cg.close(); + } + }); + + it('two index dirs coexist in one tree and the override side skips the sibling', async () => { + // WSL side: default `.codegraph`, with a source file. + delete process.env.CODEGRAPH_DIR; + fs.writeFileSync(path.join(tempDir, 'app.ts'), 'export function onlyReal() {}\n'); + const wsl = await CodeGraph.init(tempDir, { index: true }); + wsl.close(); + + // Windows side: override dir, same tree. Plant a decoy source file INSIDE + // the WSL data dir — the override-side index must not pick it up. + process.env.CODEGRAPH_DIR = '.codegraph-win'; + fs.writeFileSync(path.join(tempDir, '.codegraph', 'decoy.ts'), 'export function decoyLeak() {}\n'); + const win = await CodeGraph.init(tempDir, { index: true }); + try { + expect(fs.existsSync(path.join(tempDir, '.codegraph', 'codegraph.db'))).toBe(true); + expect(fs.existsSync(path.join(tempDir, '.codegraph-win', 'codegraph.db'))).toBe(true); + expect(win.searchNodes('onlyReal').length).toBeGreaterThan(0); + expect(win.searchNodes('decoyLeak')).toEqual([]); // sibling data dir not indexed + } finally { + win.close(); + } + }); +}); diff --git a/__tests__/frameworks.test.ts b/__tests__/frameworks.test.ts index c0e874908..ff1abb57b 100644 --- a/__tests__/frameworks.test.ts +++ b/__tests__/frameworks.test.ts @@ -1373,6 +1373,7 @@ func boot(routes: RoutesBuilder) throws { import { reactResolver } from '../src/resolution/frameworks/react'; import { svelteResolver } from '../src/resolution/frameworks/svelte'; +import { astroResolver } from '../src/resolution/frameworks/astro'; describe('reactResolver.extract — React Router', () => { it('extracts a v6 }>', () => { @@ -1428,6 +1429,77 @@ describe('svelteResolver.extract (smoke)', () => { }); }); +describe('astroResolver.extract — src/pages file-based routing', () => { + const routeNames = (filePath: string): string[] => + astroResolver.extract!(filePath, '').nodes.filter((n) => n.kind === 'route').map((n) => n.name); + + it('maps index.astro to /', () => { + expect(routeNames('src/pages/index.astro')).toEqual(['/']); + }); + + it('maps nested index and plain pages', () => { + expect(routeNames('src/pages/blog/index.astro')).toEqual(['/blog']); + expect(routeNames('src/pages/about.astro')).toEqual(['/about']); + }); + + it('converts [param] and [...rest] syntax', () => { + expect(routeNames('src/pages/blog/[slug].astro')).toEqual(['/blog/:slug']); + expect(routeNames('src/pages/[...path].astro')).toEqual(['/*path']); + }); + + it('maps .ts endpoints under src/pages to routes', () => { + expect(routeNames('src/pages/api/posts.ts')).toEqual(['/api/posts']); + expect(routeNames('src/pages/rss.xml.js')).toEqual(['/rss.xml']); + }); + + it('excludes underscore-prefixed segments and config files', () => { + expect(routeNames('src/pages/_partial.astro')).toEqual([]); + expect(routeNames('src/pages/blog/_components/Card.astro')).toEqual([]); + expect(routeNames('src/pages/vite.config.ts')).toEqual([]); + }); + + it('ignores .astro files outside src/pages', () => { + expect(routeNames('src/components/Button.astro')).toEqual([]); + expect(routeNames('docs/pages/guide.astro')).toEqual([]); + }); +}); + +describe('astroResolver.resolve — Astro global and virtual modules', () => { + const ctx = {} as never; + const baseRef = { + fromNodeId: 'component:a', + line: 1, + column: 0, + filePath: 'src/pages/index.astro', + language: 'astro', + }; + + it('claims Astro.* global references as framework-provided', () => { + const res = astroResolver.resolve( + { ...baseRef, referenceName: 'Astro.props', referenceKind: 'references' } as never, + ctx + ); + expect(res?.resolvedBy).toBe('framework'); + expect(res?.confidence).toBe(1.0); + }); + + it('claims astro:content virtual module imports', () => { + const res = astroResolver.resolve( + { ...baseRef, referenceName: 'astro:content', referenceKind: 'imports' } as never, + ctx + ); + expect(res?.resolvedBy).toBe('framework'); + }); + + it('leaves ordinary names alone', () => { + const res = astroResolver.resolve( + { ...baseRef, referenceName: 'astrolabe', referenceKind: 'calls' } as never, + { getNodesByName: () => [] } as never + ); + expect(res).toBeNull(); + }); +}); + // Regression tests: commented-out and docstring route examples must NOT // surface as phantom route nodes. These would have failed before the // strip-comments wiring (the regex would happily scan comments/docstrings). diff --git a/__tests__/function-ref.test.ts b/__tests__/function-ref.test.ts new file mode 100644 index 000000000..993b68614 --- /dev/null +++ b/__tests__/function-ref.test.ts @@ -0,0 +1,790 @@ +/** + * Function-as-value capture tests (#756) — registration-linking for callbacks. + * + * A function name used as a VALUE (passed as an argument, assigned to a + * field/function pointer, placed in a struct/object initializer or function + * table) must produce a `references` edge from the registration site to the + * function, so `callers`/`impact` surface where a callback is wired up. + * + * Safety properties verified here, per the dynamic-dispatch discipline + * ("a wrong edge is worse than none"): + * - decoy: an ambiguous cross-file name (no import, ≥2 definitions) → NO edge + * - same-file priority: a same-file definition beats a same-named decoy + * - kind filter: a class/variable passed as a value never gets a + * function-ref edge + * - self: a function passing itself → no self-loop + * - drain: all resolvable function_ref rows leave unresolved_refs (no + * batched-resolver runaway), and re-index is idempotent + */ + +import { describe, it, expect, beforeAll, afterEach } from 'vitest'; +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; +import { CodeGraph } from '../src'; +import type { Edge } from '../src/types'; +import { initGrammars, loadAllGrammars } from '../src/extraction/grammars'; + +beforeAll(async () => { + await initGrammars(); + await loadAllGrammars(); +}); + +/** Incoming edges to `name`'s node that came from function-as-value capture. */ +function fnRefEdgesInto(cg: CodeGraph, name: string): Edge[] { + const targets = cg.getNodesByName(name); + const edges: Edge[] = []; + for (const t of targets) { + for (const e of cg.getIncomingEdges(t.id)) { + if (e.kind === 'references' && e.metadata?.fnRef === true) { + edges.push(e); + } + } + } + return edges; +} + +/** Names of the source nodes of the given edges, sorted. */ +function sourceNames(cg: CodeGraph, edges: Edge[]): string[] { + const names: string[] = []; + for (const e of edges) { + const n = cg.getNode(e.source); + if (n) names.push(n.name); + } + return names.sort(); +} + +describe('Function-as-value capture (#756)', () => { + let tmpDir: string | undefined; + afterEach(() => { + if (tmpDir) fs.rmSync(tmpDir, { recursive: true, force: true }); + tmpDir = undefined; + }); + + it('C: registration sites produce references edges (the #756 scenario)', async () => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-fnref-c-')); + fs.writeFileSync( + path.join(tmpDir, 'driver.c'), + [ + 'struct ops { void (*recv_cb)(int); void (*send_cb)(int); };', + 'typedef void (*cb_t)(int);', + '', + 'static void my_recv_cb(int x) { (void)x; }', + 'static void my_send_cb(int x) { (void)x; }', + '', + 'void register_handler(void (*cb)(int)) { cb(1); }', + '', + 'void direct_caller(void) { my_recv_cb(5); }', + '', + 'void arg_registrar(void) { register_handler(my_recv_cb); }', + 'void addr_registrar(void) { register_handler(&my_recv_cb); }', + 'void assign_registrar(struct ops *o) { o->recv_cb = my_recv_cb; }', + '', + 'static struct ops global_ops = { .recv_cb = my_recv_cb, .send_cb = my_send_cb };', + 'static cb_t cb_table[] = { my_recv_cb, my_send_cb };', + ].join('\n') + ); + + const cg = CodeGraph.initSync(tmpDir); + try { + await cg.indexAll(); + + const intoRecv = fnRefEdgesInto(cg, 'my_recv_cb'); + expect(sourceNames(cg, intoRecv)).toEqual([ + 'addr_registrar', + 'arg_registrar', + 'assign_registrar', + 'driver.c', // file-scope: designated init + positional table (deduped per source) + ]); + + // The direct call is still a `calls` edge — unchanged by this feature. + const recv = cg.getNodesByName('my_recv_cb')[0]!; + const callEdges = cg + .getIncomingEdges(recv.id) + .filter((e) => e.kind === 'calls'); + expect(sourceNames(cg, callEdges)).toEqual(['direct_caller']); + } finally { + cg.destroy(); + tmpDir = undefined; + } + }); + + it('TypeScript: arg / object / array / member / assignment forms', async () => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-fnref-ts-')); + fs.writeFileSync( + path.join(tmpDir, 'main.ts'), + [ + 'export function targetCb(x: number): void { console.log(x); }', + 'function registerHandler(cb: (x: number) => void): void { cb(1); }', + '', + 'export function argRegistrar(): void { registerHandler(targetCb); }', + 'export function timerRegistrar(): void { setTimeout(targetCb, 100); }', + 'export function objRegistrar(): unknown { return { recv: targetCb }; }', + 'export function arrRegistrar(): unknown { return [targetCb]; }', + '', + 'class Emitter { cb: ((x: number) => void) | null = null; }', + 'export function assignRegistrar(e: Emitter): void { e.cb = targetCb; }', + '', + 'interface Btn { on(ev: string, cb: () => void): void; }', + 'export class Comp {', + ' handleClick(): void {}', + ' wire(btn: Btn): void { btn.on("click", this.handleClick); }', + '}', + ].join('\n') + ); + + const cg = CodeGraph.initSync(tmpDir); + try { + await cg.indexAll(); + + expect(sourceNames(cg, fnRefEdgesInto(cg, 'targetCb'))).toEqual([ + 'argRegistrar', + 'arrRegistrar', + 'assignRegistrar', + 'objRegistrar', + 'timerRegistrar', + ]); + // `this.handleClick` resolves class-scoped (#808): the target must be a + // method of the ENCLOSING class, in the same file. + expect(sourceNames(cg, fnRefEdgesInto(cg, 'handleClick'))).toEqual(['wire']); + } finally { + cg.destroy(); + tmpDir = undefined; + } + }); + + it('resolves an imported callback across files via its import', async () => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-fnref-import-')); + fs.writeFileSync( + path.join(tmpDir, 'handlers.ts'), + 'export function onMessage(x: number): void { console.log(x); }\n' + ); + fs.writeFileSync( + path.join(tmpDir, 'wiring.ts'), + [ + "import { onMessage } from './handlers';", + 'export function wire(bus: { on(cb: (x: number) => void): void }): void {', + ' bus.on(onMessage);', + '}', + ].join('\n') + ); + + const cg = CodeGraph.initSync(tmpDir); + try { + await cg.indexAll(); + const edges = fnRefEdgesInto(cg, 'onMessage'); + expect(sourceNames(cg, edges)).toContain('wire'); + // The edge must target the handlers.ts definition. + const target = cg.getNode(edges[0]!.target); + expect(target?.filePath.endsWith('handlers.ts')).toBe(true); + } finally { + cg.destroy(); + tmpDir = undefined; + } + }); + + it('DECOY: ambiguous cross-file name without an import resolves to NO edge', async () => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-fnref-decoy-')); + // Two same-named functions in different files… + fs.writeFileSync(path.join(tmpDir, 'a.ts'), 'export function process(x: number): void {}\n'); + fs.writeFileSync(path.join(tmpDir, 'b.ts'), 'export function process(x: number): void {}\n'); + // …and a registrar that names `process` WITHOUT importing it. The name + // still passes the extraction gate only if imported/defined here — it is + // neither, so this asserts the gate; even if it leaked through, the + // ambiguity rule (unique-only cross-file) must yield no edge. + fs.writeFileSync( + path.join(tmpDir, 'c.ts'), + 'export function wire(bus: { on(cb: unknown): void }, process: unknown): void { bus.on(process); }\n' + ); + + const cg = CodeGraph.initSync(tmpDir); + try { + await cg.indexAll(); + const edges = fnRefEdgesInto(cg, 'process'); + expect(sourceNames(cg, edges)).not.toContain('wire'); + } finally { + cg.destroy(); + tmpDir = undefined; + } + }); + + it('SAME-FILE PRIORITY: a same-file definition beats a same-named decoy elsewhere', async () => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-fnref-samefile-')); + fs.writeFileSync(path.join(tmpDir, 'decoy.c'), 'void my_cb(int x) { (void)x; }\n'); + fs.writeFileSync( + path.join(tmpDir, 'real.c'), + [ + 'static void my_cb(int x) { (void)x; }', + 'void register_handler(void (*cb)(int)) { cb(1); }', + 'void wire(void) { register_handler(my_cb); }', + ].join('\n') + ); + + const cg = CodeGraph.initSync(tmpDir); + try { + await cg.indexAll(); + const wires = fnRefEdgesInto(cg, 'my_cb').filter((e) => { + const src = cg.getNode(e.source); + return src?.name === 'wire'; + }); + expect(wires).toHaveLength(1); + const target = cg.getNode(wires[0]!.target); + expect(target?.filePath.endsWith('real.c')).toBe(true); + } finally { + cg.destroy(); + tmpDir = undefined; + } + }); + + it('KIND FILTER: a class passed as a value gets no function-ref edge', async () => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-fnref-kind-')); + fs.writeFileSync( + path.join(tmpDir, 'main.ts'), + [ + 'export class Strategy { run(): void {} }', + 'export function consume(x: unknown): void { void x; }', + 'export function wire(): void { consume(Strategy); }', + ].join('\n') + ); + + const cg = CodeGraph.initSync(tmpDir); + try { + await cg.indexAll(); + const strategy = cg.getNodesByName('Strategy').find((n) => n.kind === 'class')!; + const fnRef = cg + .getIncomingEdges(strategy.id) + .filter((e) => e.metadata?.fnRef === true); + expect(fnRef).toHaveLength(0); + } finally { + cg.destroy(); + tmpDir = undefined; + } + }); + + it('SELF: a function registering itself produces no self-loop', async () => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-fnref-self-')); + fs.writeFileSync( + path.join(tmpDir, 'main.ts'), + [ + 'declare function schedule(cb: () => void): void;', + 'export function retry(): void { schedule(retry); }', + ].join('\n') + ); + + const cg = CodeGraph.initSync(tmpDir); + try { + await cg.indexAll(); + const retry = cg.getNodesByName('retry')[0]!; + const selfLoops = cg + .getIncomingEdges(retry.id) + .filter((e) => e.source === retry.id && e.metadata?.fnRef === true); + expect(selfLoops).toHaveLength(0); + } finally { + cg.destroy(); + tmpDir = undefined; + } + }); + + it('C++: &Cls::method member pointers resolve scoped; bare ids are free-function-only', async () => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-fnref-cpp-')); + fs.writeFileSync( + path.join(tmpDir, 'widget.cpp'), + [ + 'struct Widget {', + ' void on_click(int x);', + '};', + 'void Widget::on_click(int x) { (void)x; }', + 'struct Decoy {', + ' void on_click(int x);', + '};', + 'void Decoy::on_click(int x) { (void)x; }', + 'void free_cb(int x) { (void)x; }', + 'void bare_fn(int x) { (void)x; }', + 'void reg(void* p) { (void)p; }', + 'void wire() {', + ' auto p = &Widget::on_click;', // qualified — must hit Widget, not Decoy + ' reg(p);', + ' reg(&free_cb);', // explicit address-of — captured + ' reg(bare_fn);', // bare id in args — NOT captured for C++ (addressOfOnly) + '}', + // A method named like a local: passing the LOCAL must not resolve to + // the method (cpp args accept only explicit & forms). + 'struct Buf { char* out(); };', + 'void copy_to(void* out_) { (void)out_; }', + 'void caller(char* out) { copy_to(out); }', + ].join('\n') + ); + + const cg = CodeGraph.initSync(tmpDir); + try { + await cg.indexAll(); + + // Qualified member pointer resolves to Widget::on_click specifically. + const onClicks = cg.getNodesByName('on_click'); + const widgetOnClick = onClicks.find((n) => n.qualifiedName.includes('Widget'))!; + const decoyOnClick = onClicks.find((n) => n.qualifiedName.includes('Decoy'))!; + const intoWidget = cg + .getIncomingEdges(widgetOnClick.id) + .filter((e) => e.metadata?.fnRef === true); + expect(intoWidget).toHaveLength(1); + expect(cg.getNode(intoWidget[0]!.source)?.name).toBe('wire'); + expect( + cg.getIncomingEdges(decoyOnClick.id).filter((e) => e.metadata?.fnRef === true) + ).toHaveLength(0); + + // Explicit &fn resolves; bare identifier in C++ args does NOT (the + // generic-name collision class: fmt's `begin`/`out`/`size` params). + expect(sourceNames(cg, fnRefEdgesInto(cg, 'free_cb'))).toContain('wire'); + expect(fnRefEdgesInto(cg, 'bare_fn')).toHaveLength(0); + + // The local `out` param must NOT produce an edge to Buf::out. + const outMethod = cg.getNodesByName('out').find((n) => n.kind === 'method'); + if (outMethod) { + expect( + cg.getIncomingEdges(outMethod.id).filter((e) => e.metadata?.fnRef === true) + ).toHaveLength(0); + } + } finally { + cg.destroy(); + tmpDir = undefined; + } + }); + + it('Pascal: := event wiring, @addr and bare args', async () => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-fnref-pas-')); + fs.writeFileSync( + path.join(tmpDir, 'main.pas'), + [ + 'unit Main;', + 'interface', + 'type', + ' TCallback = procedure(X: Integer);', + ' THolder = class', + ' public', + ' OnFire: TCallback;', + ' procedure Wire;', + ' end;', + 'procedure TargetCb(X: Integer);', + 'procedure RegisterHandler(Cb: TCallback);', + 'procedure ArgRegistrar;', + 'procedure AddrRegistrar;', + 'implementation', + 'procedure TargetCb(X: Integer);', + 'begin', + ' WriteLn(X);', + 'end;', + 'procedure RegisterHandler(Cb: TCallback);', + 'begin', + ' Cb(1);', + 'end;', + 'procedure ArgRegistrar;', + 'begin', + ' RegisterHandler(TargetCb);', + 'end;', + 'procedure AddrRegistrar;', + 'begin', + ' RegisterHandler(@TargetCb);', + 'end;', + 'procedure THolder.Wire;', + 'begin', + ' OnFire := TargetCb;', + 'end;', + 'end.', + ].join('\n') + ); + + const cg = CodeGraph.initSync(tmpDir); + try { + await cg.indexAll(); + expect(sourceNames(cg, fnRefEdgesInto(cg, 'TargetCb'))).toEqual([ + 'AddrRegistrar', + 'ArgRegistrar', + 'Wire', + ]); + } finally { + cg.destroy(); + tmpDir = undefined; + } + }); + + it('THIS-MEMBER SCOPING: this.X resolves only to the enclosing class, never elsewhere', async () => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-fnref-thisx-')); + fs.writeFileSync( + path.join(tmpDir, 'main.ts'), + [ + 'declare const bus: { on(ev: string, cb: () => void): void };', + // Decoy: a same-named method on an UNRELATED class. + 'export class Decoy { refresh(): void {} }', + 'export class Panel {', + ' views: number[] = [];', // property (post-#808), shares no name + ' refresh(): void {}', + ' wire(): void {', + ' bus.on("update", this.refresh);', // → Panel::refresh, not Decoy::refresh + ' bus.on("data", this.views as never);', // property → NO edge + ' bus.on("gone", this.missing as never);', // unknown member → NO edge + ' }', + '}', + ].join('\n') + ); + + const cg = CodeGraph.initSync(tmpDir); + try { + await cg.indexAll(); + + const refreshes = cg.getNodesByName('refresh'); + const panelRefresh = refreshes.find((n) => n.qualifiedName.includes('Panel'))!; + const decoyRefresh = refreshes.find((n) => n.qualifiedName.includes('Decoy'))!; + + const intoPanel = cg + .getIncomingEdges(panelRefresh.id) + .filter((e) => e.metadata?.fnRef === true); + expect(intoPanel).toHaveLength(1); + expect(cg.getNode(intoPanel[0]!.source)?.name).toBe('wire'); + expect( + cg.getIncomingEdges(decoyRefresh.id).filter((e) => e.metadata?.fnRef === true) + ).toHaveLength(0); + + // The property and the unknown member produce nothing. + const views = cg.getNodesByName('views').find((n) => n.kind === 'property'); + if (views) { + expect( + cg.getIncomingEdges(views.id).filter((e) => e.metadata?.fnRef === true) + ).toHaveLength(0); + } + } finally { + cg.destroy(); + tmpDir = undefined; + } + }); + + it('INHERITED this.X: resolves on a supertype via the second pass, never on unrelated classes', async () => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-fnref-inherit-')); + fs.writeFileSync( + path.join(tmpDir, 'base.ts'), + 'export class FormBase { handleSubmit(): void {} }\n' + ); + fs.writeFileSync( + path.join(tmpDir, 'unrelated.ts'), + 'export class Unrelated { handleSubmit(): void {} }\n' + ); + fs.writeFileSync( + path.join(tmpDir, 'login.ts'), + [ + "import { FormBase } from './base';", + 'declare const bus: { on(ev: string, cb: () => void): void };', + 'export class LoginForm extends FormBase {', + ' wire(): void { bus.on("submit", this.handleSubmit); }', + '}', + ].join('\n') + ); + + const cg = CodeGraph.initSync(tmpDir); + try { + await cg.indexAll(); + const handleSubmits = cg.getNodesByName('handleSubmit'); + const baseM = handleSubmits.find((n) => n.qualifiedName.includes('FormBase'))!; + const unrelatedM = handleSubmits.find((n) => n.qualifiedName.includes('Unrelated'))!; + + const intoBase = cg.getIncomingEdges(baseM.id).filter((e) => e.metadata?.fnRef === true); + expect(intoBase).toHaveLength(1); + expect(cg.getNode(intoBase[0]!.source)?.name).toBe('wire'); + expect( + cg.getIncomingEdges(unrelatedM.id).filter((e) => e.metadata?.fnRef === true) + ).toHaveLength(0); + } finally { + cg.destroy(); + tmpDir = undefined; + } + }); + + it('JAVA: Type::method cross-file, this::/super:: scoped, variable:: yields nothing', async () => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-fnref-java-')); + fs.writeFileSync( + path.join(tmpDir, 'Handlers.java'), + [ + 'package com.example;', + 'public class Handlers {', + ' public static void onMessage(int x) { System.out.println(x); }', + '}', + ].join('\n') + ); + fs.writeFileSync( + path.join(tmpDir, 'BaseForm.java'), + ['package com.example;', 'public class BaseForm {', ' void baseHandler(int x) {}', '}'].join('\n') + ); + fs.writeFileSync( + path.join(tmpDir, 'Main.java'), + [ + 'package com.example;', + 'import com.example.Handlers;', + 'import java.util.function.IntConsumer;', + 'public class Main extends BaseForm {', + ' static void registerHandler(IntConsumer cb) { cb.accept(1); }', + ' void run0() {}', + ' void crossFile() { registerHandler(Handlers::onMessage); }', + ' void thisRef() { registerHandler(this::run0); }', + ' void superRef() { registerHandler(super::baseHandler); }', + ' void varRef(Main m) { registerHandler(m::run0); }', + '}', + ].join('\n') + ); + + const cg = CodeGraph.initSync(tmpDir); + try { + await cg.indexAll(); + + expect(sourceNames(cg, fnRefEdgesInto(cg, 'onMessage'))).toEqual(['crossFile']); + expect(sourceNames(cg, fnRefEdgesInto(cg, 'baseHandler'))).toEqual(['superRef']); + // this::run0 resolves class-scoped; m::run0 (variable receiver) must NOT + // add a second edge — exactly one source. + expect(sourceNames(cg, fnRefEdgesInto(cg, 'run0'))).toEqual(['thisRef']); + } finally { + cg.destroy(); + tmpDir = undefined; + } + }); + + it('KOTLIN: companion-object refs resolve cross-file without imports; decoy companion untouched', async () => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-fnref-ktcomp-')); + // Same package, no imports — the Java/Kotlin reality the name gate can't + // see, which is why qualified `Type::member` candidates skip it. + fs.writeFileSync( + path.join(tmpDir, 'Handlers.kt'), + [ + 'class KtHandlers {', + ' companion object {', + ' fun handle(x: Int) {}', + ' }', + '}', + 'class Decoy {', + ' companion object {', + ' fun handle(x: Int) {}', + ' }', + '}', + ].join('\n') + ); + fs.writeFileSync( + path.join(tmpDir, 'Wirer.kt'), + [ + 'fun register(cb: Any) {}', + 'class Wirer {', + ' fun wire() { register(KtHandlers::handle) }', + '}', + ].join('\n') + ); + + const cg = CodeGraph.initSync(tmpDir); + try { + await cg.indexAll(); + const handles = cg.getNodesByName('handle'); + const target = handles.find((n) => n.qualifiedName.includes('KtHandlers'))!; + const decoy = handles.find((n) => n.qualifiedName.includes('Decoy'))!; + const into = cg.getIncomingEdges(target.id).filter((e) => e.metadata?.fnRef === true); + expect(into).toHaveLength(1); + expect(cg.getNode(into[0]!.source)?.name).toBe('wire'); + expect(cg.getIncomingEdges(decoy.id).filter((e) => e.metadata?.fnRef === true)).toHaveLength(0); + } finally { + cg.destroy(); + tmpDir = undefined; + } + }); + + it('SWIFT SCOPING: bare ids hit only the enclosing type’s methods; top-level bare hits functions only', async () => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-fnref-swiftscope-')); + fs.writeFileSync( + path.join(tmpDir, 'main.swift'), + [ + 'func register(_ cb: (Int) -> Void) { cb(1) }', + 'class Monitor {', + ' func report(_ x: Int) {}', + ' func wire() { register(report) }', // implicit self → Monitor::report + '}', + 'class Other {', + // `report` here is a PARAMETER; Monitor::report must not win. + ' func use(report: (Int) -> Void) { register(report) }', + '}', + 'func topLevel() { register(report) }', // no implicit self → no method target + ].join('\n') + ); + + const cg = CodeGraph.initSync(tmpDir); + try { + await cg.indexAll(); + const edges = fnRefEdgesInto(cg, 'report'); + expect(sourceNames(cg, edges)).toEqual(['wire']); + } finally { + cg.destroy(); + tmpDir = undefined; + } + }); + + it('C UNGATED TABLES: a command table names handlers defined in OTHER files (redis pattern)', async () => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-fnref-ctable-')); + // Handler defined in its own file… + fs.writeFileSync(path.join(tmpDir, 't_string.c'), 'void getCommand(int c) { (void)c; }\n'); + // …and registered in a table in ANOTHER file, with no import mechanism (C). + fs.writeFileSync( + path.join(tmpDir, 'server.c'), + [ + 'struct cmd { const char *name; void (*proc)(int); };', + 'static struct cmd commandTable[] = {', + ' { "get", getCommand },', + '};', + ].join('\n') + ); + // Ambiguity safety: two files define dupCmd; a third table references it → + // NO edge (unique-or-drop). + fs.writeFileSync(path.join(tmpDir, 'dup_a.c'), 'void dupCmd(int c) { (void)c; }\n'); + fs.writeFileSync(path.join(tmpDir, 'dup_b.c'), 'void dupCmd(int c) { (void)c; }\n'); + fs.writeFileSync( + path.join(tmpDir, 'other.c'), + [ + 'struct cmd2 { void (*proc)(int); };', + 'static struct cmd2 otherTable[] = { { dupCmd } };', + ].join('\n') + ); + + const cg = CodeGraph.initSync(tmpDir); + try { + await cg.indexAll(); + + // Cross-file unique handler resolves from the table's file. + const intoGet = fnRefEdgesInto(cg, 'getCommand'); + expect(sourceNames(cg, intoGet)).toEqual(['server.c']); + const target = cg.getNode(intoGet[0]!.target); + expect(target?.filePath.endsWith('t_string.c')).toBe(true); + + // Ambiguous handler resolves to NOTHING — silent beats wrong. + expect(fnRefEdgesInto(cg, 'dupCmd')).toHaveLength(0); + } finally { + cg.destroy(); + tmpDir = undefined; + } + }); + + it('PHP: HOF string callables, [$this,…] and [Cls::class,…] arrays; non-HOF strings ignored', async () => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-fnref-php-')); + fs.writeFileSync( + path.join(tmpDir, 'handlers.php'), + " $b; }\n" + ); + fs.writeFileSync( + path.join(tmpDir, 'main.php'), + [ + ' { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-fnref-rubyhooks-')); + fs.writeFileSync( + path.join(tmpDir, 'posts_controller.rb'), + [ + 'class ApplicationController', + ' def authenticate; end', + 'end', + '', + 'class PostsController < ApplicationController', + ' before_action :authenticate', // inherited → ApplicationController + ' after_save :reindex', + ' validates :title, presence: true', // attributes, NOT methods → no edge + ' rescue_from StandardError, with: :render_500', + '', + ' def reindex; end', + ' def render_500; end', + ' def title; end', + 'end', + ].join('\n') + ); + + const cg = CodeGraph.initSync(tmpDir); + try { + await cg.indexAll(); + + const auth = fnRefEdgesInto(cg, 'authenticate'); + expect(auth).toHaveLength(1); + expect(cg.getNode(auth[0]!.target)?.qualifiedName).toContain('ApplicationController'); + + expect(fnRefEdgesInto(cg, 'reindex')).toHaveLength(1); + expect(fnRefEdgesInto(cg, 'render_500')).toHaveLength(1); + // `validates :title` names an attribute — the same-named METHOD must + // get no registration edge. + expect(fnRefEdgesInto(cg, 'title')).toHaveLength(0); + } finally { + cg.destroy(); + tmpDir = undefined; + } + }); + + it('DRAIN: resolvable function_ref rows leave unresolved_refs; re-index is stable', async () => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-fnref-drain-')); + fs.writeFileSync( + path.join(tmpDir, 'main.c'), + [ + 'static void cb_a(int x) { (void)x; }', + 'void reg(void (*cb)(int)) { cb(1); }', + 'void wire(void) { reg(cb_a); }', + ].join('\n') + ); + + const cg = CodeGraph.initSync(tmpDir); + try { + await cg.indexAll(); + const stats1 = cg.getStats(); + + // No function_ref rows may linger for resolvable names — the batched + // resolver must have drained them (delete keyed on the ORIGINAL stored + // ref; the #760 runaway came from violating that). + const db = (cg as unknown as { db: { prepare(sql: string): { all(): unknown[] } } }).db; + let leftover: unknown[] = []; + try { + leftover = db + .prepare("SELECT * FROM unresolved_refs WHERE reference_kind = 'function_ref'") + .all(); + } catch { + // If internals aren't reachable this guard is covered by the edge + // assertions below. + } + expect(leftover).toHaveLength(0); + + // Re-index: identical node/edge counts (idempotent, no accumulation). + await cg.indexAll(); + const stats2 = cg.getStats(); + expect(stats2.totalNodes).toBe(stats1.totalNodes); + expect(stats2.totalEdges).toBe(stats1.totalEdges); + + expect(sourceNames(cg, fnRefEdgesInto(cg, 'cb_a'))).toEqual(['wire']); + } finally { + cg.destroy(); + tmpDir = undefined; + } + }); +}); diff --git a/__tests__/graph.test.ts b/__tests__/graph.test.ts index 5ddbd028f..bc25942ac 100644 --- a/__tests__/graph.test.ts +++ b/__tests__/graph.test.ts @@ -293,6 +293,25 @@ export { main }; expect(Array.isArray(callees)).toBe(true); }); + + it('treats class instantiation as a caller/callee of the class (#774)', () => { + // main() does `new DerivedClass(10, 'test')`. Constructing a class is + // calling its constructor, so main is a caller of DerivedClass and + // DerivedClass is a callee of main. Before #774 the `instantiates` edge + // was excluded from the caller/callee traversal, so `callers ` + // returned the importing file (or nothing) and missed every + // construction site. + const derived = cg.getNodesByKind('class').find((n) => n.name === 'DerivedClass'); + const main = cg.getNodesByKind('function').find((n) => n.name === 'main'); + expect(derived).toBeDefined(); + expect(main).toBeDefined(); + + const callerNames = cg.getCallers(derived!.id).map((c) => c.node.name); + expect(callerNames).toContain('main'); + + const calleeNames = cg.getCallees(main!.id).map((c) => c.node.name); + expect(calleeNames).toContain('DerivedClass'); + }); }); describe('getImpactRadius()', () => { diff --git a/__tests__/installer-targets.test.ts b/__tests__/installer-targets.test.ts index 27fcbd6e8..eed76515e 100644 --- a/__tests__/installer-targets.test.ts +++ b/__tests__/installer-targets.test.ts @@ -192,20 +192,23 @@ describe('Installer targets — partial-state idempotency', () => { fs.rmSync(tmpCwd, { recursive: true, force: true }); }); - it('codex: install writes config.toml but never an AGENTS.md instructions file (#529)', () => { + it('codex: install writes config.toml AND the AGENTS.md codegraph block (#704)', () => { const codex = getTarget('codex')!; const first = codex.install('global', { autoAllow: false }); const agentsMd = path.join(tmpHome, '.codex', 'AGENTS.md'); - // No instructions file is created, and no file action references it. - expect(fs.existsSync(agentsMd)).toBe(false); - expect(first.files.some((f) => f.path.endsWith('AGENTS.md'))).toBe(false); expect(first.files.some((f) => f.path.endsWith('config.toml'))).toBe(true); - // Re-install is fully unchanged (config.toml only, nothing to strip). + // The short instructions block IS written (subagents / non-MCP + // harnesses read AGENTS.md but never the MCP initialize instructions). + expect(fs.existsSync(agentsMd)).toBe(true); + const body = fs.readFileSync(agentsMd, 'utf-8'); + expect(body).toContain('## CodeGraph'); + expect(body).toContain('codegraph explore'); + // Re-install is fully unchanged (byte-equal block → idempotent). const second = codex.install('global', { autoAllow: false }); for (const f of second.files) expect(f.action).toBe('unchanged'); }); - it('codex: install strips a legacy AGENTS.md codegraph block, keeping user content (#529)', () => { + it('codex: install replaces a legacy AGENTS.md codegraph block with the current one, keeping user content', () => { const codex = getTarget('codex')!; const dir = path.join(tmpHome, '.codex'); fs.mkdirSync(dir, { recursive: true }); @@ -217,10 +220,11 @@ describe('Installer targets — partial-state idempotency', () => { const body = fs.readFileSync(agentsMd, 'utf-8'); expect(body).toContain('# My codex notes'); expect(body).toContain('Be terse.'); - expect(body).not.toContain('CODEGRAPH_START'); - // The strip is reported as a 'removed' action on AGENTS.md. + // Self-heal: the stale pre-#529 body is gone, the current block is in. + expect(body).not.toContain('Prefer `codegraph_search`'); + expect(body).toContain('codegraph explore'); const mdEntry = result.files.find((f) => f.path.endsWith('AGENTS.md')); - expect(mdEntry?.action).toBe('removed'); + expect(mdEntry?.action).toBe('updated'); }); it('opencode: prefers .jsonc when both .json and .jsonc exist', () => { @@ -290,15 +294,16 @@ describe('Installer targets — partial-state idempotency', () => { expect(fs.readFileSync(file, 'utf-8')).toBe(afterInstall); }); - it('opencode: install does NOT write an AGENTS.md instructions file (#529)', () => { + it('opencode: install writes the AGENTS.md codegraph block (#704)', () => { const opencode = getTarget('opencode')!; const result = opencode.install('global', { autoAllow: true }); const agentsMd = path.join(tmpHome, '.config', 'opencode', 'AGENTS.md'); - expect(fs.existsSync(agentsMd)).toBe(false); - expect(result.files.some((f) => f.path.endsWith('AGENTS.md'))).toBe(false); + expect(fs.existsSync(agentsMd)).toBe(true); + expect(fs.readFileSync(agentsMd, 'utf-8')).toContain('codegraph explore'); + expect(result.files.find((f) => f.path.endsWith('AGENTS.md'))?.action).toBe('created'); }); - it('opencode: install strips a legacy AGENTS.md codegraph block, preserving user content (#529)', () => { + it('opencode: install replaces a legacy AGENTS.md codegraph block, preserving user content', () => { const opencode = getTarget('opencode')!; const dir = path.join(tmpHome, '.config', 'opencode'); fs.mkdirSync(dir, { recursive: true }); @@ -310,8 +315,9 @@ describe('Installer targets — partial-state idempotency', () => { const body = fs.readFileSync(agentsMd, 'utf-8'); expect(body).toContain('# My personal opencode instructions'); expect(body).toContain('Always respond in pirate.'); - expect(body).not.toContain('CODEGRAPH_START'); - expect(result.files.find((f) => f.path.endsWith('AGENTS.md'))?.action).toBe('removed'); + expect(body).not.toContain('Prefer `codegraph_search`'); + expect(body).toContain('codegraph explore'); + expect(result.files.find((f) => f.path.endsWith('AGENTS.md'))?.action).toBe('updated'); }); it('opencode: uninstall strips a leftover codegraph block from AGENTS.md, keeping user content', () => { @@ -329,24 +335,25 @@ describe('Installer targets — partial-state idempotency', () => { expect(body).not.toContain('CODEGRAPH_START'); }); - it('opencode: local install writes ./opencode.jsonc and never an ./AGENTS.md (#529)', () => { + it('opencode: local install writes ./opencode.jsonc and the ./AGENTS.md block (#704)', () => { const opencode = getTarget('opencode')!; const result = opencode.install('local', { autoAllow: true }); const paths = result.files.map((f) => f.path.replace(/\\/g, '/')); // macOS realpath shenanigans (/var vs /private/var) — suffix match. expect(paths.some((p) => p.endsWith('/opencode.jsonc'))).toBe(true); - expect(paths.some((p) => p.endsWith('/AGENTS.md'))).toBe(false); - expect(fs.existsSync(path.join(process.cwd(), 'AGENTS.md'))).toBe(false); + expect(paths.some((p) => p.endsWith('/AGENTS.md'))).toBe(true); + expect(fs.existsSync(path.join(process.cwd(), 'AGENTS.md'))).toBe(true); }); - it('gemini: install writes settings.json (mcpServers.codegraph) and no GEMINI.md (#529)', () => { + it('gemini: install writes settings.json (mcpServers.codegraph) and the GEMINI.md block (#704)', () => { const gemini = getTarget('gemini')!; const result = gemini.install('global', { autoAllow: true }); const settings = path.join(tmpHome, '.gemini', 'settings.json'); const geminiMd = path.join(tmpHome, '.gemini', 'GEMINI.md'); expect(result.files.some((f) => f.path === settings)).toBe(true); - expect(result.files.some((f) => f.path === geminiMd)).toBe(false); - expect(fs.existsSync(geminiMd)).toBe(false); + expect(result.files.some((f) => f.path === geminiMd)).toBe(true); + expect(fs.existsSync(geminiMd)).toBe(true); + expect(fs.readFileSync(geminiMd, 'utf-8')).toContain('codegraph explore'); const cfg = JSON.parse(fs.readFileSync(settings, 'utf-8')); expect(cfg.mcpServers.codegraph).toEqual({ type: 'stdio', command: 'codegraph', args: ['serve', '--mcp'] }); @@ -383,13 +390,13 @@ describe('Installer targets — partial-state idempotency', () => { expect(after.mcpServers).toBeUndefined(); }); - it('gemini: local install writes ./.gemini/settings.json and never a ./GEMINI.md (#529)', () => { + it('gemini: local install writes ./.gemini/settings.json and the project-root ./GEMINI.md block (#704)', () => { const gemini = getTarget('gemini')!; const result = gemini.install('local', { autoAllow: true }); const paths = result.files.map((f) => f.path.replace(/\\/g, '/')); expect(paths.some((p) => p.endsWith('/.gemini/settings.json'))).toBe(true); - expect(paths.some((p) => p.endsWith('/GEMINI.md'))).toBe(false); - expect(fs.existsSync(path.join(process.cwd(), 'GEMINI.md'))).toBe(false); + expect(paths.some((p) => p.endsWith('/GEMINI.md'))).toBe(true); + expect(fs.existsSync(path.join(process.cwd(), 'GEMINI.md'))).toBe(true); }); it('gemini: uninstall strips a leftover GEMINI.md codegraph block, keeping user content', () => { @@ -880,15 +887,18 @@ describe('Installer targets — partial-state idempotency', () => { expect(cfg.mcpServers.codegraph).toBeDefined(); }); - it('claude: install does NOT create a CLAUDE.md instructions file (#529)', () => { + it('claude: install creates the CLAUDE.md codegraph block (#704)', () => { const claude = getTarget('claude')!; const result = claude.install('local', { autoAllow: false }); const claudeMd = path.join(tmpCwd, '.claude', 'CLAUDE.md'); - expect(fs.existsSync(claudeMd)).toBe(false); - expect(result.files.some((f) => f.path.endsWith('CLAUDE.md'))).toBe(false); + expect(fs.existsSync(claudeMd)).toBe(true); + const body = fs.readFileSync(claudeMd, 'utf-8'); + expect(body).toContain('## CodeGraph'); + expect(body).toContain('codegraph explore'); + expect(result.files.find((f) => f.path.endsWith('CLAUDE.md'))?.action).toBe('created'); }); - it('claude: install strips a legacy CLAUDE.md codegraph block, keeping user content (#529)', () => { + it('claude: install replaces a legacy CLAUDE.md codegraph block, keeping user content', () => { const claude = getTarget('claude')!; const claudeMd = path.join(tmpCwd, '.claude', 'CLAUDE.md'); fs.mkdirSync(path.dirname(claudeMd), { recursive: true }); @@ -899,8 +909,9 @@ describe('Installer targets — partial-state idempotency', () => { const body = fs.readFileSync(claudeMd, 'utf-8'); expect(body).toContain('# My project rules'); expect(body).toContain('Use tabs.'); - expect(body).not.toContain('CODEGRAPH_START'); - expect(result.files.find((f) => f.path.endsWith('CLAUDE.md'))?.action).toBe('removed'); + expect(body).not.toContain('Prefer `codegraph_search`'); + expect(body).toContain('codegraph explore'); + expect(result.files.find((f) => f.path.endsWith('CLAUDE.md'))?.action).toBe('updated'); }); it('claude: global install targets ~/.claude.json (user scope)', () => { diff --git a/__tests__/mcp-tool-allowlist.test.ts b/__tests__/mcp-tool-allowlist.test.ts index a9e7aff65..08067c918 100644 --- a/__tests__/mcp-tool-allowlist.test.ts +++ b/__tests__/mcp-tool-allowlist.test.ts @@ -17,13 +17,23 @@ describe('CODEGRAPH_MCP_TOOLS allowlist', () => { const listed = () => new ToolHandler(null).getTools().map(t => t.name).sort(); - it('exposes the full tool surface when unset', () => { + it('exposes the default 4-tool surface when unset', () => { delete process.env[ENV]; - const all = listed(); - expect(all).toContain('codegraph_explore'); - expect(all).not.toContain('codegraph_context'); - expect(all).not.toContain('codegraph_trace'); - expect(all.length).toBeGreaterThanOrEqual(8); + // The default set (see DEFAULT_MCP_TOOLS): explore + node are the + // validated workhorses, search the cheap lookup, callers the one + // irreplaceable enumerator. callees/impact/files/status stay defined + // and executable but unlisted — impact appeared in ZERO recorded runs. + expect(listed()).toEqual([ + 'codegraph_callers', + 'codegraph_explore', + 'codegraph_node', + 'codegraph_search', + ]); + }); + + it('re-enables an unlisted tool via the allowlist (impact)', () => { + process.env[ENV] = 'explore,impact'; + expect(listed()).toEqual(['codegraph_explore', 'codegraph_impact']); }); it('filters ListTools to the allowlisted short names', () => { @@ -36,9 +46,10 @@ describe('CODEGRAPH_MCP_TOOLS allowlist', () => { expect(listed()).toEqual(['codegraph_explore', 'codegraph_search']); }); - it('treats an empty/whitespace value as unset (full surface)', () => { + it('treats an empty/whitespace value as unset (default surface)', () => { process.env[ENV] = ' '; - expect(listed().length).toBeGreaterThanOrEqual(8); + expect(listed()).toHaveLength(4); + expect(listed()).toContain('codegraph_explore'); }); it('rejects a disabled tool on execute (defense in depth)', async () => { diff --git a/__tests__/mcp-unindexed.test.ts b/__tests__/mcp-unindexed.test.ts new file mode 100644 index 000000000..2b0019d6d --- /dev/null +++ b/__tests__/mcp-unindexed.test.ts @@ -0,0 +1,225 @@ +/** + * Unindexed-workspace session policy tests. + * + * An MCP session attached to a workspace with no .codegraph/ must go quiet + * rather than fail loudly: `initialize` returns the short "inactive" + * instructions variant (not the full playbook), `tools/list` returns an + * EMPTY list, and a tool call that still arrives (cross-project + * `projectPath`, or a host that skips tools/list) answers with a + * SUCCESS-shaped guidance message — never `isError: true`. One or two early + * isError responses teach an agent to abandon codegraph for the whole + * session; that observed failure mode is what this suite guards. + */ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { spawn, ChildProcessWithoutNullStreams } from 'child_process'; +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; +import { CodeGraph } from '../src'; +import { ToolHandler } from '../src/mcp/tools'; + +const BIN = path.resolve(__dirname, '../dist/bin/codegraph.js'); + +function spawnServer(cwd: string): ChildProcessWithoutNullStreams { + return spawn(process.execPath, [BIN, 'serve', '--mcp'], { + cwd, + stdio: ['pipe', 'pipe', 'pipe'], + // Direct (in-process) mode — the unindexed path never has a daemon + // anyway (the daemon socket lives in .codegraph/), and this keeps the + // suite from leaking a detached daemon in the indexed test. + // CODEGRAPH_WASM_RELAUNCHED skips the --liftoff-only re-exec: without + // it the server runs as a GRANDCHILD that survives child.kill() on + // Windows and holds the temp cwd/SQLite handles, failing teardown with + // EPERM no matter how long rmSync retries (the class documented for + // the mcp-initialize/mcp-roots suites). + env: { ...process.env, CODEGRAPH_NO_DAEMON: '1', CODEGRAPH_WASM_RELAUNCHED: '1' }, + }) as ChildProcessWithoutNullStreams; +} + +/** Send a JSON-RPC request and resolve with the response matching its id. */ +function request( + child: ChildProcessWithoutNullStreams, + msg: { id: number; method: string; params?: unknown }, + timeoutMs = 15000 +): Promise> { + return new Promise((resolve, reject) => { + let buf = ''; + const timer = setTimeout(() => { + child.stdout.off('data', onData); + reject(new Error(`timeout waiting for response id=${msg.id}`)); + }, timeoutMs); + const onData = (chunk: Buffer) => { + buf += chunk.toString(); + let idx: number; + while ((idx = buf.indexOf('\n')) !== -1) { + const line = buf.slice(0, idx).trim(); + buf = buf.slice(idx + 1); + if (!line) continue; + try { + const parsed = JSON.parse(line) as Record; + if (parsed.id === msg.id) { + clearTimeout(timer); + child.stdout.off('data', onData); + resolve(parsed); + return; + } + } catch { + // non-JSON noise on stdout — ignore + } + } + }; + child.stdout.on('data', onData); + child.stdin.write(JSON.stringify({ jsonrpc: '2.0', ...msg }) + '\n'); + }); +} + +function initializeParams(projectPath: string) { + return { + protocolVersion: '2025-11-25', + capabilities: {}, + clientInfo: { name: 'test', version: '0.0.0' }, + rootUri: `file://${projectPath}`, + }; +} + +describe('Unindexed-workspace session policy', () => { + let tempDir: string; + let child: ChildProcessWithoutNullStreams | null = null; + + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-unindexed-')); + }); + + afterEach(async () => { + if (child) { + // Wait for the child to actually exit before removing its cwd — on + // Windows a just-killed process briefly holds the directory/SQLite + // handles, and an immediate rmSync fails the teardown with EPERM + // (the documented file-locking class that fails the sibling + // mcp-initialize/mcp-roots suites). kill + await exit + retried + // removal keeps this suite green on Windows. + const exited = new Promise((resolve) => child!.once('exit', () => resolve())); + child.kill('SIGKILL'); + await Promise.race([exited, new Promise((r) => setTimeout(r, 3000))]); + child = null; + } + fs.rmSync(tempDir, { recursive: true, force: true, maxRetries: 10, retryDelay: 200 }); + }); + + it('initialize returns the short "inactive" instructions, not the playbook', async () => { + fs.writeFileSync(path.join(tempDir, 'index.ts'), 'export const x = 1;\n'); + child = spawnServer(tempDir); + + const res = await request(child, { id: 0, method: 'initialize', params: initializeParams(tempDir) }); + const instructions = (res.result as { instructions: string }).instructions; + + expect(instructions).toMatch(/inactive/i); + expect(instructions).toMatch(/codegraph init/); + // The full playbook must NOT be sent into a session where every call fails + expect(instructions).not.toMatch(/Tool selection by intent/); + expect(instructions).not.toMatch(/codegraph_explore/); + }); + + it('tools/list returns an EMPTY list when the workspace has no index', async () => { + child = spawnServer(tempDir); + await request(child, { id: 0, method: 'initialize', params: initializeParams(tempDir) }); + + const res = await request(child, { id: 1, method: 'tools/list' }); + expect((res.result as { tools: unknown[] }).tools).toEqual([]); + }); + + it('an INDEXED workspace still gets the full playbook and all tools', async () => { + fs.writeFileSync(path.join(tempDir, 'index.ts'), 'export function hello(): string { return "hi"; }\n'); + const cg = await CodeGraph.init(tempDir, { index: true }); + cg.close(); + + child = spawnServer(tempDir); + const init = await request(child, { id: 0, method: 'initialize', params: initializeParams(tempDir) }); + const instructions = (init.result as { instructions: string }).instructions; + expect(instructions).toMatch(/Tool selection by intent/); + expect(instructions).not.toMatch(/inactive/i); + + const list = await request(child, { id: 1, method: 'tools/list' }); + const tools = (list.result as { tools: Array<{ name: string }> }).tools; + // A 1-file project triggers the pre-existing tiny-repo tool gating (a + // reduced core set) — the contract under test is "indexed → tools are + // PRESENT", in contrast to the unindexed empty list above. + expect(tools.length).toBeGreaterThanOrEqual(3); + expect(tools.map((t) => t.name)).toContain('codegraph_explore'); + }); +}); + +describe('No-error policy on expected conditions', () => { + let tempDir: string; + + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-noerror-')); + }); + + afterEach(() => { + fs.rmSync(tempDir, { recursive: true, force: true }); + }); + + it('cross-project query to an unindexed path is SUCCESS-shaped guidance, not isError', async () => { + const res = await new ToolHandler(null).execute('codegraph_search', { + query: 'anything', + projectPath: tempDir, + }); + + expect(res.isError).toBeUndefined(); + expect(res.content[0]!.text).toMatch(/isn't indexed/); + expect(res.content[0]!.text).toMatch(/codegraph init/); + expect(res.content[0]!.text).toMatch(/built-in tools/); + }); + + it('no-default-project (working-directory detection miss) is SUCCESS-shaped guidance', async () => { + const res = await new ToolHandler(null).execute('codegraph_search', { query: 'anything' }); + + expect(res.isError).toBeUndefined(); + expect(res.content[0]!.text).toMatch(/No CodeGraph project is loaded/); + expect(res.content[0]!.text).toMatch(/projectPath/); + }); + + it.runIf(process.platform !== 'win32')( + 'sensitive-path refusal stays a hard error (no retry encouragement)', + async () => { + const res = await new ToolHandler(null).execute('codegraph_search', { + query: 'anything', + projectPath: '/etc', + }); + + expect(res.isError).toBe(true); + expect(res.content[0]!.text).not.toMatch(/retry the call once/); + } + ); +}); + +describe('search kind filter', () => { + let tempDir: string; + let cg: CodeGraph; + + beforeEach(async () => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-kind-')); + fs.writeFileSync( + path.join(tempDir, 'types.ts'), + 'export type PaymentMethod = { id: string };\nexport function pay(): void {}\n' + ); + cg = await CodeGraph.init(tempDir, { index: true }); + }); + + afterEach(() => { + cg.close(); + fs.rmSync(tempDir, { recursive: true, force: true }); + }); + + it("kind: 'type' (the advertised enum value) finds type aliases", async () => { + const res = await new ToolHandler(cg).execute('codegraph_search', { + query: 'PaymentMethod', + kind: 'type', + }); + + expect(res.isError).toBeUndefined(); + expect(res.content[0]!.text).toMatch(/PaymentMethod/); + expect(res.content[0]!.text).not.toMatch(/No results found/); + }); +}); diff --git a/__tests__/node-file-view.test.ts b/__tests__/node-file-view.test.ts index 316ed555d..7d2a5703c 100644 --- a/__tests__/node-file-view.test.ts +++ b/__tests__/node-file-view.test.ts @@ -1,7 +1,9 @@ /** - * codegraph_node FILE-VIEW mode: a bare `file` (no `symbol`) returns that file's - * symbol map + graph role (dependents), and verbatim bodies with includeCode — - * a Read replacement for a source file that also surfaces the blast radius. + * codegraph_node FILE READ mode: a `file` with no `symbol` reads that file like + * the Read tool — current source with `\t` numbering (byte-for-byte + * Read's shape), narrowable with offset/limit — plus a one-line blast-radius + * header. `symbolsOnly` returns the structural map instead. Config/data files + * are summarized by key, never dumped (#383). */ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import * as fs from 'fs'; @@ -24,9 +26,23 @@ describe('codegraph_node file-view (Read replacement)', () => { ); fs.writeFileSync( path.join(dir, 'src', 'b.ts'), - "import { helper } from './a';\nexport function useHelper() { return helper(2); }\n", + "import { helper } from './a';\n\n// a comment between symbols\nconst SETTING = 7;\nexport function useHelper() { return helper(2) + SETTING; }\n", ); - cg = CodeGraph.initSync(dir, { config: { include: ['**/*.ts'], exclude: [] } }); + // A config/data file (#383): its values may be secrets and must never be + // dumped verbatim by the file-view. + fs.writeFileSync( + path.join(dir, 'src', 'application.properties'), + 'spring.datasource.password=SUPERSECRET123\nserver.port=8080\n', + ); + // A large file: exceeds the file-view line budget, so it must be windowed + // honestly (not silently truncated). + fs.writeFileSync( + path.join(dir, 'src', 'big.ts'), + 'export function big() {\n' + + Array.from({ length: 2000 }, (_, i) => ` const v${i} = ${i};`).join('\n') + + '\n return 0;\n}\n', + ); + cg = CodeGraph.initSync(dir, { config: { include: ['**/*.ts', '**/*.properties'], exclude: [] } }); await cg.indexAll(); h = new ToolHandler(cg); }); @@ -39,21 +55,54 @@ describe('codegraph_node file-view (Read replacement)', () => { const text = async (args: Record): Promise => (await h.execute('codegraph_node', args)).content.map((c) => c.text).join('\n'); - it("a bare file (no symbol) returns the file's symbols + dependents", async () => { + it('reads a whole file like Read by default — `\\t` lines (no pad), imports + gaps included', async () => { + const out = await text({ file: 'b.ts' }); // no includeCode needed — content is the default + // Byte-for-byte Read shape: line 1 is "1import …", NOT space-padded. + expect(out).toMatch(/^1\timport \{ helper \} from '\.\/a';$/m); + expect(out).toContain('// a comment between symbols'); // inter-symbol gap (Read has it; old reconstruction dropped it) + expect(out).toContain('const SETTING = 7'); // top-level statement + expect(out).toContain('useHelper'); // the symbol body too + expect(out).not.toContain('```'); // Read has no code fence; neither do we + }); + + it('leads with a one-line blast-radius header (the value-add over Read)', async () => { const out = await text({ file: 'a.ts' }); - expect(out).toContain('src/a.ts'); - expect(out).toContain('helper'); - expect(out).toContain('Widget'); - expect(out).toMatch(/depended on by 1 file/i); - expect(out).toContain('src/b.ts'); // the dependent file (blast radius) + expect(out).toMatch(/used by 1 file: src\/b\.ts/); // a.ts is imported by b.ts + expect(out).toContain('return x + 1'); // still returns the source + }); + + it('offset/limit narrow the window exactly like Read', async () => { + const out = await text({ file: 'big.ts', offset: 1000, limit: 3 }); + // Window starts at the requested line, numbered exactly: "1000 const v998 = 998;" + expect(out).toMatch(/^1000\t {2}const v998 = 998;$/m); + expect(out).not.toMatch(/^1\t/m); // line 1 is NOT shown + expect(out).toMatch(/lines 1000[–-]1002 of \d+/); // honest pagination note + }); + + it('an offset past EOF is reported, not a crash', async () => { + const out = await text({ file: 'a.ts', offset: 9999 }); + expect(out).toMatch(/past the end/i); + }); + + it('paginates a large file honestly by default — "lines 1–N of TOTAL", never a silent truncate', async () => { + const out = await text({ file: 'big.ts' }); + expect(out).toMatch(/lines 1[–-]\d+ of \d+/); // explicit window note + expect(out).not.toContain('(output truncated)'); // not the generic 15k chop + expect(out).toMatch(/^1\texport function big/m); // the head of the window is real source }); - it('resolves by basename and returns verbatim bodies with includeCode', async () => { - const out = await text({ file: 'a.ts', includeCode: true }); - expect(out).toContain('return x + 1'); // helper body - expect(out).toContain('class Widget'); // class body, verbatim - // It must NOT steer the agent back to Read — it is the Read replacement. - expect(out.toLowerCase()).not.toContain('read `src/a.ts`'); + it('does NOT dump a config/data file (yaml/properties) — #383 secret safety', async () => { + const out = await text({ file: 'application.properties' }); + expect(out).not.toContain('SUPERSECRET123'); // the value never reaches the agent + expect(out.toLowerCase()).toMatch(/config|values withheld/); + }); + + it('symbolsOnly returns the structural map, not the source', async () => { + const out = await text({ file: 'a.ts', symbolsOnly: true }); + expect(out).toContain('### Symbols'); + expect(out).toContain('helper'); + expect(out).toContain('Widget'); + expect(out).not.toContain('return x + 1'); // bodies are NOT included in the map }); it('still works as a normal symbol lookup (no regression)', async () => { diff --git a/__tests__/pr19-improvements.test.ts b/__tests__/pr19-improvements.test.ts index eb5200919..8e8ca8177 100644 --- a/__tests__/pr19-improvements.test.ts +++ b/__tests__/pr19-improvements.test.ts @@ -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(4); + expect(CURRENT_SCHEMA_VERSION).toBe(5); }); it.skipIf(!HAS_SQLITE)('should have migration for version 2', async () => { diff --git a/__tests__/resolution.test.ts b/__tests__/resolution.test.ts index 347cb635c..3059392d4 100644 --- a/__tests__/resolution.test.ts +++ b/__tests__/resolution.test.ts @@ -12,7 +12,7 @@ import { CodeGraph } from '../src'; import { Node, UnresolvedReference } from '../src/types'; import { ReferenceResolver, createResolver, ResolutionContext } from '../src/resolution'; import { matchReference } from '../src/resolution/name-matcher'; -import { resolveImportPath, extractImportMappings, resolveJvmImport, loadCppIncludeDirs, clearCppIncludeDirCache } from '../src/resolution/import-resolver'; +import { resolveImportPath, extractImportMappings, resolveJvmImport, loadCppIncludeDirs, clearCppIncludeDirCache, isPhpIncludePathRef } from '../src/resolution/import-resolver'; import type { UnresolvedRef } from '../src/resolution/types'; import { detectFrameworks, getAllFrameworkResolvers } from '../src/resolution/frameworks'; import { QueryBuilder } from '../src/db/queries'; @@ -581,12 +581,23 @@ from ..services import auth_service line: 10, column: 5, filePath: 'src/App.tsx', - language: 'typescript' as const, + // Refs extracted from .tsx files carry language 'tsx' — component + // resolution is gated to JSX-capable refs (#764: PascalCase TYPE refs + // from plain .ts files were resolving to arbitrary same-named classes). + language: 'tsx' as const, }; const result = reactResolver!.resolve(ref, context); expect(result).not.toBeNull(); expect(result?.targetNodeId).toBe('component:src/Button.tsx:Button:5'); + + // The same PascalCase name referenced from a plain .ts file is a TYPE + // reference, not a component usage — component resolution must decline + // and leave it to proximity-aware name matching (#764: a .ts GraphQL + // types file's own `Account` alias was losing to an arbitrary same-named + // class in another monorepo package). + const tsRef = { ...ref, filePath: 'src/models.ts', language: 'typescript' as const }; + expect(reactResolver!.resolve(tsRef, context)).toBeNull(); }); it('should resolve custom hook references', () => { @@ -1427,6 +1438,47 @@ func main() { expect(callers.some((c) => c.node.filePath === 'src/Bar.svelte')).toBe(true); }); + it('links an .astro page to the component and TS util it uses (#768)', async () => { + // The canonical Astro shape: a page imports a layout/component in + // frontmatter and uses it as a template tag; the component's template + // calls an imported .ts util. Both hops must produce graph edges or + // an Astro project is invisible to callers/impact. + fs.mkdirSync(path.join(tempDir, 'src/components'), { recursive: true }); + fs.mkdirSync(path.join(tempDir, 'src/utils'), { recursive: true }); + fs.mkdirSync(path.join(tempDir, 'src/pages'), { recursive: true }); + fs.writeFileSync( + path.join(tempDir, 'src/utils/format.ts'), + `export function formatDate(d: Date): string { return d.toISOString(); }\n` + ); + fs.writeFileSync( + path.join(tempDir, 'src/components/PostCard.astro'), + `---\nimport { formatDate } from '../utils/format';\nconst { date } = Astro.props;\n---\n\n` + ); + fs.writeFileSync( + path.join(tempDir, 'src/pages/index.astro'), + `---\nimport PostCard from '../components/PostCard.astro';\n---\n\n` + ); + + cg = await CodeGraph.init(tempDir, { index: true }); + cg.resolveReferences(); + + // Hop 1: page → component (template tag through the frontmatter import) + const cardNode = cg + .getNodesByKind('component') + .find((n) => n.name === 'PostCard' && n.filePath === 'src/components/PostCard.astro'); + expect(cardNode).toBeDefined(); + const cardCallers = cg.getCallers(cardNode!.id); + expect(cardCallers.some((c) => c.node.filePath === 'src/pages/index.astro')).toBe(true); + + // Hop 2: component template call → .ts util + const fmtNode = cg + .getNodesByKind('function') + .find((n) => n.name === 'formatDate' && n.filePath === 'src/utils/format.ts'); + expect(fmtNode).toBeDefined(); + const fmtCallers = cg.getCallers(fmtNode!.id); + expect(fmtCallers.some((c) => c.node.filePath === 'src/components/PostCard.astro')).toBe(true); + }); + it('resolves a bare directory import (import { x } from "." / "./") to index.ts (#629)', async () => { // `import { helper } from '.'` (or './') must map to the // directory's index.ts before the re-export chase can run. The @@ -1918,4 +1970,1464 @@ func main() { } }); }); + + describe('PHP Include Resolution', () => { + it('isPhpIncludePathRef distinguishes include paths from namespace use (#660)', () => { + const mk = (name: string, over: Partial = {}): UnresolvedRef => ({ + fromNodeId: 'f', referenceName: name, referenceKind: 'imports', + line: 1, column: 0, filePath: 'x.php', language: 'php', ...over, + }); + // include paths: contain a slash or a file extension + expect(isPhpIncludePathRef(mk('lib.php'))).toBe(true); + expect(isPhpIncludePathRef(mk('inc/db.php'))).toBe(true); + expect(isPhpIncludePathRef(mk('../config.php'))).toBe(true); + // namespace use symbols: a bare class (Closure) or FQN — never a path, + // so they must NOT be treated as includes (would mis-connect to a + // same-named Closure.php / Bar.php file). + expect(isPhpIncludePathRef(mk('Closure'))).toBe(false); + expect(isPhpIncludePathRef(mk('PDO'))).toBe(false); + expect(isPhpIncludePathRef(mk('App\\Foo\\Bar'))).toBe(false); + // scoped to PHP imports only + expect(isPhpIncludePathRef(mk('lib.php', { language: 'c' }))).toBe(false); + expect(isPhpIncludePathRef(mk('lib.php', { referenceKind: 'calls' }))).toBe(false); + }); + + it('resolves require_once to a file→file imports edge (#660)', async () => { + const tempProject = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-php-e2e-')); + try { + fs.mkdirSync(path.join(tempProject, 'src'), { recursive: true }); + fs.writeFileSync( + path.join(tempProject, 'src', 'lib.php'), + `; + const resolved = rows.find( + (r) => r.dstKind === 'file' && r.dstPath === 'src/lib.php' + ); + expect(resolved, 'page.php → src/lib.php imports edge missing').toBeDefined(); + } finally { + fs.rmSync(tempProject, { recursive: true, force: true }); + } + }); + + it('resolves a subdirectory include path to the correct file (#660)', async () => { + const tempProject = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-php-subdir-')); + try { + fs.mkdirSync(path.join(tempProject, 'inc'), { recursive: true }); + fs.writeFileSync( + path.join(tempProject, 'inc', 'db.php'), + `; + expect( + rows.find((r) => r.dstKind === 'file' && r.dstPath === 'inc/db.php'), + 'index.php → inc/db.php imports edge missing' + ).toBeDefined(); + } finally { + fs.rmSync(tempProject, { recursive: true, force: true }); + } + }); + + it('does not mis-connect an unresolvable include to a same-named file elsewhere (#660)', async () => { + const tempProject = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-php-misresolve-')); + try { + // app/page.php's `require "inc/db.php"` resolves relative to app/, where + // inc/db.php does NOT exist. A same-named lib/inc/db.php exists elsewhere + // but is unrelated — no edge should be created (a wrong edge is worse + // than a missing one). + fs.mkdirSync(path.join(tempProject, 'app'), { recursive: true }); + fs.mkdirSync(path.join(tempProject, 'lib', 'inc'), { recursive: true }); + fs.writeFileSync( + path.join(tempProject, 'lib', 'inc', 'db.php'), + `; + expect( + rows.find((r) => r.dstKind === 'file' && r.dstPath === 'lib/inc/db.php'), + 'app/page.php must NOT mis-connect to unrelated lib/inc/db.php' + ).toBeUndefined(); + } finally { + fs.rmSync(tempProject, { recursive: true, force: true }); + } + }); + }); + + describe('C++ chained-call receiver resolution (#645)', () => { + async function indexCpp(files: Record): Promise { + for (const [name, content] of Object.entries(files)) { + fs.writeFileSync(path.join(tempDir, name), content); + } + cg = await CodeGraph.init(tempDir, { index: true }); + } + + function callerNamesOf(qualifiedName: string): string[] { + const target = cg.getNodesByKind('method').find((n) => n.qualifiedName === qualifiedName); + if (!target) return []; + const names = cg + .getIncomingEdges(target.id) + .filter((e) => e.kind === 'calls') + .map((e) => cg.getNode(e.source)?.name) + .filter((n): n is string => !!n); + return [...new Set(names)].sort(); + } + + it('resolves singleton chains and auto locals to the right class, never the first-sorted one', async () => { + // Two classes share writeLog; Logger sorts first so it wins any name-only + // tie. All three call forms target Metrics. + await indexCpp({ + 'logger.hpp': `#pragma once +#include +class Logger { public: static Logger& instance(); void writeLog(const std::string&); }; +class Metrics { public: static Metrics& instance(); void writeLog(const std::string&); }; +`, + 'impl.cpp': `#include "logger.hpp" +Logger& Logger::instance() { static Logger l; return l; } +Metrics& Metrics::instance() { static Metrics m; return m; } +void Logger::writeLog(const std::string&) {} +void Metrics::writeLog(const std::string&) {} +`, + 'app.cpp': `#include "logger.hpp" +void a() { Metrics::instance().writeLog("x"); } // chained singleton +void b() { auto& m = Metrics::instance(); m.writeLog("x"); } // stored in auto +void c() { Metrics& m = Metrics::instance(); m.writeLog("x"); } // explicit type +`, + }); + + expect(callerNamesOf('Metrics::writeLog')).toEqual(['a', 'b', 'c']); + expect(callerNamesOf('Logger::writeLog')).toEqual([]); + }); + + it('resolves factories, free-function factories, and member chains via the inner call return type', async () => { + await indexCpp({ + 'types.hpp': `#pragma once +#include +struct Widget { void draw(); }; +struct Session { void run(); }; +struct View { void render(); }; +class WidgetFactory { public: static Widget create(); }; +class Manager { public: View view(); }; +Session* openSession(); +// Decoy that sorts first and has all three methods — must never win. +struct Aaa { void draw(); void run(); void render(); }; +`, + 'impl.cpp': `#include "types.hpp" +void Widget::draw() {} +void Session::run() {} +void View::render() {} +void Aaa::draw() {} +void Aaa::run() {} +void Aaa::render() {} +Widget WidgetFactory::create() { return Widget(); } +View Manager::view() { return View(); } +Session* openSession() { return nullptr; } +`, + 'app.cpp': `#include "types.hpp" +void factory() { WidgetFactory::create().draw(); } // -> Widget::draw +void freefunc() { openSession()->run(); } // -> Session::run +void member() { Manager mgr; mgr.view().render(); } // -> View::render +void makeUnique() { auto w = std::make_unique(); w->draw(); } // -> Widget::draw +`, + }); + + expect(callerNamesOf('Widget::draw')).toEqual(['factory', 'makeUnique']); + expect(callerNamesOf('Session::run')).toEqual(['freefunc']); + expect(callerNamesOf('View::render')).toEqual(['member']); + // The first-sorted decoy never captures any of them. + expect(callerNamesOf('Aaa::draw')).toEqual([]); + expect(callerNamesOf('Aaa::run')).toEqual([]); + expect(callerNamesOf('Aaa::render')).toEqual([]); + }); + + it('creates NO edge when the inferred type lacks the method (silent miss, not a wrong edge)', async () => { + await indexCpp({ + 'types.hpp': `#pragma once +struct Widget { void draw(); }; +struct Other { void onlyOther(); }; +class WidgetFactory { public: static Widget create(); }; +`, + 'impl.cpp': `#include "types.hpp" +void Widget::draw() {} +void Other::onlyOther() {} +Widget WidgetFactory::create() { return Widget(); } +`, + 'app.cpp': `#include "types.hpp" +// Widget has no onlyOther() — must produce NO edge, never a wrong one to Other. +void wrong() { WidgetFactory::create().onlyOther(); } +`, + }); + + expect(callerNamesOf('Other::onlyOther')).toEqual([]); + }); + }); + + describe('PHP chained static-factory call resolution (#608)', () => { + function callerNamesOf(qualifiedName: string): string[] { + const target = cg.getNodesByKind('method').find((n) => n.qualifiedName === qualifiedName); + if (!target) return []; + const names = cg + .getIncomingEdges(target.id) + .filter((e) => e.kind === 'calls') + .map((e) => cg.getNode(e.source)?.name) + .filter((n): n is string => !!n); + return [...new Set(names)].sort(); + } + + it('resolves Cls::for($x)->method() via the factory\'s `: self` return (#608)', async () => { + fs.writeFileSync( + path.join(tempDir, 'ApiClient.php'), + `createOrder([]);\n }\n}\n` + ); + cg = await CodeGraph.init(tempDir, { index: true }); + // The chained call's edge attaches to the factory result's method. + expect(callerNamesOf('ApiClient::createOrder')).toContain('handle'); + }); + + it('creates NO edge when the factory result lacks the method (#608)', async () => { + fs.writeFileSync( + path.join(tempDir, 'lib.php'), + `onlyOther(); } }\n` + ); + cg = await CodeGraph.init(tempDir, { index: true }); + // ApiClient has no onlyOther — must not mis-attach to the same-named Other::onlyOther. + expect(callerNamesOf('Other::onlyOther')).toEqual([]); + }); + }); + + describe('Java chained static-factory call resolution (#645/#608 mechanism)', () => { + function callerNamesOf(qualifiedName: string): string[] { + const target = cg.getNodesByKind('method').find((n) => n.qualifiedName === qualifiedName); + if (!target) return []; + const names = cg + .getIncomingEdges(target.id) + .filter((e) => e.kind === 'calls') + .map((e) => cg.getNode(e.source)?.name) + .filter((n): n is string => !!n); + return [...new Set(names)].sort(); + } + + it('resolves Foo.getInstance().bar() via the factory return type, never a same-named decoy', async () => { + // Aaa sorts first and has a same-named bar() — it must never win the chain. + fs.writeFileSync( + path.join(tempDir, 'Main.java'), + `class Aaa { void bar() {} } +class Foo { + static Foo getInstance() { return new Foo(); } + void bar() {} +} +class Caller { + void run() { Foo.getInstance().bar(); } +} +` + ); + cg = await CodeGraph.init(tempDir, { index: true }); + expect(callerNamesOf('Foo::bar')).toEqual(['run']); + expect(callerNamesOf('Aaa::bar')).toEqual([]); + }); + + it('resolves a factory chain that passes arguments — Foo.create(cfg).build()', async () => { + // The factory call carries an argument; the extractor must normalize the + // receiver to empty parens (`Foo.create().build`) so the chain still splits. + fs.writeFileSync( + path.join(tempDir, 'Main.java'), + `class Config {} +class Foo { + static Foo create(Config c) { return new Foo(); } + void build() {} +} +class Caller { + void run() { Foo.create(new Config()).build(); } +} +` + ); + cg = await CodeGraph.init(tempDir, { index: true }); + expect(callerNamesOf('Foo::build')).toEqual(['run']); + }); + + it('creates NO edge when the factory return type lacks the method (silent miss, not a wrong edge)', async () => { + fs.writeFileSync( + path.join(tempDir, 'Main.java'), + `class Foo { + static Foo getInstance() { return new Foo(); } +} +class Other { void onlyOther() {} } +class Caller { + void run() { Foo.getInstance().onlyOther(); } +} +` + ); + cg = await CodeGraph.init(tempDir, { index: true }); + // Foo has no onlyOther() — must not mis-attach to the same-named Other::onlyOther. + expect(callerNamesOf('Other::onlyOther')).toEqual([]); + }); + }); + + describe('Kotlin chained companion-factory call resolution (#645/#608 mechanism)', () => { + function callerNamesOf(qualifiedName: string): string[] { + const target = cg.getNodesByKind('method').find((n) => n.qualifiedName === qualifiedName); + if (!target) return []; + const names = cg + .getIncomingEdges(target.id) + .filter((e) => e.kind === 'calls') + .map((e) => cg.getNode(e.source)?.name) + .filter((n): n is string => !!n); + return [...new Set(names)].sort(); + } + + it('resolves Foo.getInstance().bar() via the companion return type, never a same-named decoy', async () => { + // Aaa sorts first and has a same-named bar() — without the chain fix Kotlin + // dropped the receiver to a bare `bar` and attached to Aaa (a wrong edge). + fs.writeFileSync( + path.join(tempDir, 'Main.kt'), + `class Aaa { fun bar() {} } +class Foo { + companion object { + fun getInstance(): Foo = Foo() + } + fun bar() {} +} +class Caller { + fun run() { Foo.getInstance().bar() } +} +` + ); + cg = await CodeGraph.init(tempDir, { index: true }); + expect(callerNamesOf('Foo::bar')).toEqual(['run']); + expect(callerNamesOf('Aaa::bar')).toEqual([]); + }); + + it('resolves a companion factory chain that passes arguments — Foo.create(cfg).build()', async () => { + fs.writeFileSync( + path.join(tempDir, 'Main.kt'), + `class Config +class Foo { + companion object { + fun create(c: Config): Foo = Foo() + } + fun build() {} +} +class Caller { + fun run() { Foo.create(Config()).build() } +} +` + ); + cg = await CodeGraph.init(tempDir, { index: true }); + expect(callerNamesOf('Foo::build')).toEqual(['run']); + }); + + it('creates NO edge when the companion return type lacks the method (silent miss, not a wrong edge)', async () => { + fs.writeFileSync( + path.join(tempDir, 'Main.kt'), + `class Foo { + companion object { + fun getInstance(): Foo = Foo() + } +} +class Other { fun onlyOther() {} } +class Caller { + fun run() { Foo.getInstance().onlyOther() } +} +` + ); + cg = await CodeGraph.init(tempDir, { index: true }); + // Foo has no onlyOther() — must not mis-attach to the same-named Other::onlyOther. + expect(callerNamesOf('Other::onlyOther')).toEqual([]); + }); + }); + + describe('C# chained static-factory call resolution (#645/#608 mechanism)', () => { + function callerNamesOf(qualifiedName: string): string[] { + const target = cg.getNodesByKind('method').find((n) => n.qualifiedName === qualifiedName); + if (!target) return []; + const names = cg + .getIncomingEdges(target.id) + .filter((e) => e.kind === 'calls') + .map((e) => cg.getNode(e.source)?.name) + .filter((n): n is string => !!n); + return [...new Set(names)].sort(); + } + + it('resolves Foo.Create().Bar() via the factory return type, never a same-named decoy', async () => { + // Aaa sorts first and has a same-named Bar() — it must never win the chain. + fs.writeFileSync( + path.join(tempDir, 'Main.cs'), + `class Aaa { void Bar() {} } +class Foo { + static Foo Create() { return new Foo(); } + void Bar() {} +} +class Caller { + void Run() { Foo.Create().Bar(); } +} +` + ); + cg = await CodeGraph.init(tempDir, { index: true }); + expect(callerNamesOf('Foo::Bar')).toEqual(['Run']); + expect(callerNamesOf('Aaa::Bar')).toEqual([]); + }); + + it('resolves a factory chain that passes arguments — Foo.Make(cfg).Build()', async () => { + fs.writeFileSync( + path.join(tempDir, 'Main.cs'), + `class Config {} +class Foo { + static Foo Make(Config c) { return new Foo(); } + void Build() {} +} +class Caller { + void Run() { Foo.Make(new Config()).Build(); } +} +` + ); + cg = await CodeGraph.init(tempDir, { index: true }); + expect(callerNamesOf('Foo::Build')).toEqual(['Run']); + }); + + it('creates NO edge when the factory return type lacks the method (silent miss, not a wrong edge)', async () => { + fs.writeFileSync( + path.join(tempDir, 'Main.cs'), + `class Foo { + static Foo Create() { return new Foo(); } +} +class Other { void OnlyOther() {} } +class Caller { + void Run() { Foo.Create().OnlyOther(); } +} +` + ); + cg = await CodeGraph.init(tempDir, { index: true }); + // Foo has no OnlyOther() — must not mis-attach to the same-named Other::OnlyOther. + expect(callerNamesOf('Other::OnlyOther')).toEqual([]); + }); + }); + + describe('Swift chained static-factory call resolution (#645/#608 mechanism)', () => { + function callerNamesOf(qualifiedName: string): string[] { + const target = cg.getNodesByKind('method').find((n) => n.qualifiedName === qualifiedName); + if (!target) return []; + const names = cg + .getIncomingEdges(target.id) + .filter((e) => e.kind === 'calls') + .map((e) => cg.getNode(e.source)?.name) + .filter((n): n is string => !!n); + return [...new Set(names)].sort(); + } + + it('resolves Foo.make().draw() via the factory return type, never a same-named decoy', async () => { + // Aaa sorts first and has a same-named draw() — without the fix Swift dropped + // the receiver to a bare `draw` and attached to Aaa (a wrong edge). + fs.writeFileSync( + path.join(tempDir, 'Main.swift'), + `class Aaa { func draw() {} } +class Foo { + static func make() -> Foo { return Foo() } + func draw() {} +} +func runCaller() { Foo.make().draw() } +` + ); + cg = await CodeGraph.init(tempDir, { index: true }); + expect(callerNamesOf('Foo::draw')).toEqual(['runCaller']); + expect(callerNamesOf('Aaa::draw')).toEqual([]); + }); + + it('resolves a constructor chain Foo().draw() and an args factory chain Foo.build(c).render()', async () => { + fs.writeFileSync( + path.join(tempDir, 'Main.swift'), + `class Config {} +class Foo { + static func build(_ c: Config) -> Foo { return Foo() } + func draw() {} + func render() {} +} +func runCaller() { + Foo().draw() + Foo.build(Config()).render() +} +` + ); + cg = await CodeGraph.init(tempDir, { index: true }); + expect(callerNamesOf('Foo::draw')).toEqual(['runCaller']); + expect(callerNamesOf('Foo::render')).toEqual(['runCaller']); + }); + + it('creates NO edge when the factory return type lacks the method (silent miss, not a wrong edge)', async () => { + fs.writeFileSync( + path.join(tempDir, 'Main.swift'), + `class Foo { + static func make() -> Foo { return Foo() } +} +class Other { func onlyOther() {} } +func runCaller() { Foo.make().onlyOther() } +` + ); + cg = await CodeGraph.init(tempDir, { index: true }); + // Foo has no onlyOther() — must not mis-attach to the same-named Other::onlyOther. + expect(callerNamesOf('Other::onlyOther')).toEqual([]); + }); + }); + + describe('Chained call resolves a method on a supertype (conformance, #750)', () => { + function callerNamesOf(qualifiedName: string): string[] { + const target = cg.getNodesByKind('method').find((n) => n.qualifiedName === qualifiedName); + if (!target) return []; + const names = cg + .getIncomingEdges(target.id) + .filter((e) => e.kind === 'calls') + .map((e) => cg.getNode(e.source)?.name) + .filter((n): n is string => !!n); + return [...new Set(names)].sort(); + } + + it('resolves a chained method defined only on a SUPERCLASS the return type extends', async () => { + // draw() lives on Base; Widget (the factory's return type) has no draw() of + // its own. Decoy.draw must never win. Needs the conformance second pass. + fs.writeFileSync( + path.join(tempDir, 'Main.java'), + `class Base { void draw() {} } +class Widget extends Base {} +class Decoy { void draw() {} } +class Factory { static Widget create() { return new Widget(); } } +class Caller { + void run() { Factory.create().draw(); } +} +` + ); + cg = await CodeGraph.init(tempDir, { index: true }); + expect(callerNamesOf('Base::draw')).toEqual(['run']); + expect(callerNamesOf('Decoy::draw')).toEqual([]); + }); + + it('resolves a chained method defined on an INTERFACE the return type implements (default method)', async () => { + fs.writeFileSync( + path.join(tempDir, 'Main.java'), + `interface Drawable { default void draw() {} } +class Widget implements Drawable {} +class Decoy { void draw() {} } +class Factory { static Widget create() { return new Widget(); } } +class Caller { + void run() { Factory.create().draw(); } +} +` + ); + cg = await CodeGraph.init(tempDir, { index: true }); + expect(callerNamesOf('Drawable::draw')).toEqual(['run']); + expect(callerNamesOf('Decoy::draw')).toEqual([]); + }); + + it('still creates NO edge when no supertype has the method (safety preserved)', async () => { + fs.writeFileSync( + path.join(tempDir, 'Main.java'), + `class Base {} +class Widget extends Base {} +class Other { void onlyOther() {} } +class Factory { static Widget create() { return new Widget(); } } +class Caller { + void run() { Factory.create().onlyOther(); } +} +` + ); + cg = await CodeGraph.init(tempDir, { index: true }); + // Neither Widget nor Base has onlyOther() — must not attach to Other::onlyOther. + expect(callerNamesOf('Other::onlyOther')).toEqual([]); + }); + }); + + describe('Rust chained associated-function call resolution (#645/#608 mechanism)', () => { + function callerNamesOf(qualifiedName: string): string[] { + const target = cg.getNodesByKind('method').find((n) => n.qualifiedName === qualifiedName); + if (!target) return []; + const names = cg + .getIncomingEdges(target.id) + .filter((e) => e.kind === 'calls') + .map((e) => cg.getNode(e.source)?.name) + .filter((n): n is string => !!n); + return [...new Set(names)].sort(); + } + + it('resolves Foo::new().bar() (and a Self return) via the associated fn, never a same-named decoy', async () => { + fs.writeFileSync( + path.join(tempDir, 'main.rs'), + `struct Aaa { _x: i32 } +impl Aaa { fn bar(&self) {} } +struct Foo { _x: i32 } +impl Foo { + fn new() -> Foo { Foo { _x: 0 } } + fn make() -> Self { Foo { _x: 0 } } + fn bar(&self) {} +} +fn caller() { + Foo::new().bar(); + Foo::make().bar(); +} +` + ); + cg = await CodeGraph.init(tempDir, { index: true }); + expect(callerNamesOf('Foo::bar')).toEqual(['caller']); + expect(callerNamesOf('Aaa::bar')).toEqual([]); + }); + + it('resolves a chain that passes arguments — Foo::with(c).build()', async () => { + fs.writeFileSync( + path.join(tempDir, 'main.rs'), + `struct Config; +struct Foo { _x: i32 } +impl Foo { + fn with(c: Config) -> Foo { Foo { _x: 0 } } + fn build(&self) {} +} +fn caller() { Foo::with(Config).build(); } +` + ); + cg = await CodeGraph.init(tempDir, { index: true }); + expect(callerNamesOf('Foo::build')).toEqual(['caller']); + }); + + it('resolves a chained method from a trait the type implements (default method, via conformance)', async () => { + fs.writeFileSync( + path.join(tempDir, 'main.rs'), + `struct Foo { _x: i32 } +impl Foo { fn new() -> Foo { Foo { _x: 0 } } } +struct Decoy { _x: i32 } +impl Decoy { fn draw(&self) {} } +trait Drawable { fn draw(&self) {} } +impl Drawable for Foo {} +fn caller() { Foo::new().draw(); } +` + ); + cg = await CodeGraph.init(tempDir, { index: true }); + expect(callerNamesOf('Drawable::draw')).toEqual(['caller']); + expect(callerNamesOf('Decoy::draw')).toEqual([]); + }); + + it('creates NO edge when neither the type nor a supertype has the method (silent miss)', async () => { + fs.writeFileSync( + path.join(tempDir, 'main.rs'), + `struct Foo { _x: i32 } +impl Foo { fn new() -> Foo { Foo { _x: 0 } } } +struct Other { _x: i32 } +impl Other { fn only_other(&self) {} } +fn caller() { Foo::new().only_other(); } +` + ); + cg = await CodeGraph.init(tempDir, { index: true }); + // Foo has no only_other() — must not mis-attach to the same-named Other::only_other. + expect(callerNamesOf('Other::only_other')).toEqual([]); + }); + }); + + describe('Go chained factory-function call resolution (#645/#608 mechanism)', () => { + function callerNamesOf(qualifiedName: string): string[] { + const target = cg.getNodesByKind('method').find((n) => n.qualifiedName === qualifiedName); + if (!target) return []; + const names = cg + .getIncomingEdges(target.id) + .filter((e) => e.kind === 'calls') + .map((e) => cg.getNode(e.source)?.name) + .filter((n): n is string => !!n); + return [...new Set(names)].sort(); + } + + it('resolves New().Bar() via the factory return type (pointer), never a same-named decoy', async () => { + fs.writeFileSync( + path.join(tempDir, 'main.go'), + `package main +type Aaa struct{} +func (a *Aaa) Bar() {} +type Foo struct{} +func New() *Foo { return &Foo{} } +func (f *Foo) Bar() {} +func caller() { New().Bar() } +` + ); + cg = await CodeGraph.init(tempDir, { index: true }); + expect(callerNamesOf('Foo::Bar')).toEqual(['caller']); + expect(callerNamesOf('Aaa::Bar')).toEqual([]); + }); + + it('resolves an args chain and a multi-return factory — With(c).Build(), (*Foo, error)', async () => { + fs.writeFileSync( + path.join(tempDir, 'main.go'), + `package main +type Config struct{} +type Foo struct{} +func With(c Config) (*Foo, error) { return &Foo{}, nil } +func (f *Foo) Build() {} +func caller() { With(Config{}).Build() } +` + ); + cg = await CodeGraph.init(tempDir, { index: true }); + expect(callerNamesOf('Foo::Build')).toEqual(['caller']); + }); + + it('resolves a method provided by an embedded struct (via conformance)', async () => { + fs.writeFileSync( + path.join(tempDir, 'main.go'), + `package main +type Base struct{} +func (b *Base) Embedded() {} +type Decoy struct{} +func (d *Decoy) Embedded() {} +type Widget struct{ Base } +func NewWidget() *Widget { return &Widget{} } +func caller() { NewWidget().Embedded() } +` + ); + cg = await CodeGraph.init(tempDir, { index: true }); + expect(callerNamesOf('Base::Embedded')).toEqual(['caller']); + expect(callerNamesOf('Decoy::Embedded')).toEqual([]); + }); + + it('creates NO edge when neither the type nor an embedded type has the method (silent miss)', async () => { + fs.writeFileSync( + path.join(tempDir, 'main.go'), + `package main +type Foo struct{} +func New() *Foo { return &Foo{} } +type Other struct{} +func (o *Other) OnlyOther() {} +func caller() { New().OnlyOther() } +` + ); + cg = await CodeGraph.init(tempDir, { index: true }); + // Foo has no OnlyOther() — must not mis-attach to the same-named Other::OnlyOther. + expect(callerNamesOf('Other::OnlyOther')).toEqual([]); + }); + + it('falls back to bare-name resolution for a VARIABLE-inner chain without exploding the graph', async () => { + // `engine` is a package-level VARIABLE holding a func value, not a factory + // FUNCTION — so its return type can't be recovered and the chain falls back + // to bare-name resolution of the method (restoring the pre-re-encoding edge). + // Regression for the runaway this fallback originally caused: it resolved + // with a mutated `original.referenceName` (the bare `ServeHTTP`, not the + // stored `engine().ServeHTTP`), so the batched resolver's keyed delete + // no-oped, the offset-0 batch never drained, and edges inserted forever + // (5M edges / 1.4 GB on a 99-file repo). The fallback now ties the match to + // the original ref, and a non-progress guard backstops the loop. + fs.writeFileSync( + path.join(tempDir, 'main.go'), + `package main +type Server struct{} +func (s *Server) ServeHTTP() {} +var engine = func() *Server { return &Server{} } +func caller() { engine().ServeHTTP() } +` + ); + cg = await CodeGraph.init(tempDir, { index: true }); + // Recall: the variable-inner chain still finds the method by bare name. + expect(callerNamesOf('Server::ServeHTTP')).toEqual(['caller']); + // No runaway: a single call site yields a single edge, not millions. + const target = cg + .getNodesByKind('method') + .find((n) => n.qualifiedName === 'Server::ServeHTTP')!; + const rawCalls = cg + .getIncomingEdges(target.id) + .filter((e) => e.kind === 'calls'); + expect(rawCalls.length).toBeLessThan(5); + }); + }); + + describe('Scala chained static-factory call resolution (#645/#608 mechanism)', () => { + function callerNamesOf(qualifiedName: string): string[] { + const target = cg.getNodesByKind('method').find((n) => n.qualifiedName === qualifiedName); + if (!target) return []; + const names = cg + .getIncomingEdges(target.id) + .filter((e) => e.kind === 'calls') + .map((e) => cg.getNode(e.source)?.name) + .filter((n): n is string => !!n); + return [...new Set(names)].sort(); + } + + it('resolves a companion-factory chain Foo.create().doIt() to the return type, never a same-named decoy', async () => { + fs.writeFileSync( + path.join(tempDir, 'Main.scala'), + `object Foo { + def create(): Bar = new Bar() +} +class Bar { + def doIt(): Unit = {} +} +class Decoy { + def doIt(): Unit = {} +} +object Main { + def run(): Unit = { Foo.create().doIt() } +} +` + ); + cg = await CodeGraph.init(tempDir, { index: true }); + expect(callerNamesOf('Bar::doIt')).toEqual(['run']); + expect(callerNamesOf('Decoy::doIt')).toEqual([]); + }); + + it('resolves a case-class apply construction Point(x).dist() on the constructed class', async () => { + fs.writeFileSync( + path.join(tempDir, 'Main.scala'), + `class Point(x: Int) { + def dist(): Int = x +} +class Other { + def dist(): Int = 0 +} +object Main { + def run(): Unit = { Point(3).dist() } +} +` + ); + cg = await CodeGraph.init(tempDir, { index: true }); + expect(callerNamesOf('Point::dist')).toEqual(['run']); + expect(callerNamesOf('Other::dist')).toEqual([]); + }); + + it('resolves a chained method provided by a trait the return type extends (via conformance)', async () => { + fs.writeFileSync( + path.join(tempDir, 'Main.scala'), + `trait Base { + def shared(): Unit = {} +} +class Widget extends Base +class Decoy { + def shared(): Unit = {} +} +object Factory { + def make(): Widget = new Widget() +} +object Main { + def run(): Unit = { Factory.make().shared() } +} +` + ); + cg = await CodeGraph.init(tempDir, { index: true }); + expect(callerNamesOf('Base::shared')).toEqual(['run']); + expect(callerNamesOf('Decoy::shared')).toEqual([]); + }); + + it('creates NO edge when neither the factory return type nor a supertype has the method (silent miss)', async () => { + fs.writeFileSync( + path.join(tempDir, 'Main.scala'), + `object Foo { + def create(): Bar = new Bar() +} +class Bar { +} +class Other { + def onlyOther(): Unit = {} +} +object Main { + def run(): Unit = { Foo.create().onlyOther() } +} +` + ); + cg = await CodeGraph.init(tempDir, { index: true }); + // Bar has no onlyOther() — must not mis-attach to the same-named Other::onlyOther. + expect(callerNamesOf('Other::onlyOther')).toEqual([]); + }); + }); + + describe('Dart chained static-factory / factory-constructor call resolution (#645/#608 mechanism)', () => { + function callerNamesOf(qualifiedName: string): string[] { + const target = cg.getNodesByKind('method').find((n) => n.qualifiedName === qualifiedName); + if (!target) return []; + const names = cg + .getIncomingEdges(target.id) + .filter((e) => e.kind === 'calls') + .map((e) => cg.getNode(e.source)?.name) + .filter((n): n is string => !!n); + return [...new Set(names)].sort(); + } + + it('resolves a static-factory chain Foo.makeBar().doIt() to the return type, never a same-named decoy', async () => { + fs.writeFileSync( + path.join(tempDir, 'main.dart'), + `class Foo { + static Bar makeBar() => Bar(); +} +class Bar { + void doIt() {} +} +class Decoy { + void doIt() {} +} +void run() { + Foo.makeBar().doIt(); +} +` + ); + cg = await CodeGraph.init(tempDir, { index: true }); + expect(callerNamesOf('Bar::doIt')).toEqual(['run']); + expect(callerNamesOf('Decoy::doIt')).toEqual([]); + }); + + it('resolves a named factory-constructor chain Foo.create().ship() on the constructed class', async () => { + fs.writeFileSync( + path.join(tempDir, 'main.dart'), + `class Foo { + Foo._(); + factory Foo.create() => Foo._(); + void ship() {} +} +class Decoy { + void ship() {} +} +void run() { + Foo.create().ship(); +} +` + ); + cg = await CodeGraph.init(tempDir, { index: true }); + // The factory constructor `Foo.create` is now a node whose return type is Foo, + // so `ship` resolves on Foo, not the same-named Decoy. + expect(callerNamesOf('Foo::ship')).toEqual(['run']); + expect(callerNamesOf('Decoy::ship')).toEqual([]); + }); + + it('resolves a constructor-receiver chain Bar().doIt() on the constructed class', async () => { + fs.writeFileSync( + path.join(tempDir, 'main.dart'), + `class Bar { + void doIt() {} +} +class Decoy { + void doIt() {} +} +void run() { + Bar().doIt(); +} +` + ); + cg = await CodeGraph.init(tempDir, { index: true }); + expect(callerNamesOf('Bar::doIt')).toEqual(['run']); + expect(callerNamesOf('Decoy::doIt')).toEqual([]); + }); + + it('resolves a chained method inherited from a superclass the return type extends (via conformance)', async () => { + fs.writeFileSync( + path.join(tempDir, 'main.dart'), + `class Base { + void render() {} +} +class Widget extends Base { + static Widget make() => Widget(); +} +class Decoy { + void render() {} +} +void run() { + Widget.make().render(); +} +` + ); + cg = await CodeGraph.init(tempDir, { index: true }); + expect(callerNamesOf('Base::render')).toEqual(['run']); + expect(callerNamesOf('Decoy::render')).toEqual([]); + }); + + it('creates NO edge when neither the factory return type nor a supertype has the method (silent miss)', async () => { + fs.writeFileSync( + path.join(tempDir, 'main.dart'), + `class Foo { + static Bar makeBar() => Bar(); +} +class Bar { +} +class Other { + void onlyOther() {} +} +void run() { + Foo.makeBar().onlyOther(); +} +` + ); + cg = await CodeGraph.init(tempDir, { index: true }); + // Bar has no onlyOther() — must not mis-attach to the same-named Other::onlyOther. + expect(callerNamesOf('Other::onlyOther')).toEqual([]); + }); + + it('still extracts a method tree-sitter misparses as a constructor (@override + record return)', async () => { + // tree-sitter-dart misparses `@override (A, B) reduce()` — the annotation + // swallows the record return type, so `reduce()` looks like a single- + // identifier constructor_signature. It must NOT be skipped as an unnamed + // ctor (its name doesn't match the class); its body call must attribute to + // `reduce`, not the class. + fs.writeFileSync( + path.join(tempDir, 'main.dart'), + `class Base {} +class Action extends Base { + Action({required int x}); + @override + (int, String) reduce() { + return (compute(), "y"); + } + int compute() => 1; +} +` + ); + cg = await CodeGraph.init(tempDir, { index: true }); + // reduce must be a node and its body call must resolve to Action::compute. + expect(callerNamesOf('Action::compute')).toEqual(['reduce']); + }); + + it('keeps plain construction Foo() as instantiation, not a Foo::Foo method call', async () => { + // The unnamed constructor is intentionally NOT extracted as a `Foo::Foo` + // method, so `Foo(...)` resolves to the class (an `instantiates` edge), + // never hijacked into a call to a phantom constructor method. + fs.writeFileSync( + path.join(tempDir, 'main.dart'), + `class Widget { + final int x; + Widget(this.x); +} +void run() { + Widget(3); +} +` + ); + cg = await CodeGraph.init(tempDir, { index: true }); + // No Foo::Foo phantom method node. + expect(cg.getNodesByKind('method').some((n) => n.qualifiedName === 'Widget::Widget')).toBe(false); + // The construction resolves to the class as an `instantiates` edge. + const widget = cg.getNodesByKind('class').find((n) => n.name === 'Widget')!; + const incoming = cg.getIncomingEdges(widget.id); + expect(incoming.some((e) => e.kind === 'instantiates')).toBe(true); + }); + }); + + describe('Objective-C chained message-send call resolution (#645/#608 mechanism)', () => { + function callerNamesOf(qualifiedName: string): string[] { + const target = cg.getNodesByKind('method').find((n) => n.qualifiedName === qualifiedName); + if (!target) return []; + const names = cg + .getIncomingEdges(target.id) + .filter((e) => e.kind === 'calls') + .map((e) => cg.getNode(e.source)?.name) + .filter((n): n is string => !!n); + return [...new Set(names)].sort(); + } + + it('resolves a chained message send [[Foo create] doIt] via the return type, never a same-named decoy', async () => { + fs.writeFileSync( + path.join(tempDir, 'main.m'), + `@interface Bar : NSObject +- (void)doIt; +@end +@implementation Bar +- (void)doIt {} +@end +@interface Decoy : NSObject +- (void)doIt; +@end +@implementation Decoy +- (void)doIt {} +@end +@interface Foo : NSObject ++ (Bar *)create; +@end +@implementation Foo ++ (Bar *)create { return nil; } +- (void)run { [[Foo create] doIt]; } +@end +` + ); + cg = await CodeGraph.init(tempDir, { index: true }); + expect(callerNamesOf('Bar::doIt')).toEqual(['run']); + expect(callerNamesOf('Decoy::doIt')).toEqual([]); + }); + + it('resolves a chained message whose method is inherited from a superclass (via conformance)', async () => { + fs.writeFileSync( + path.join(tempDir, 'main.m'), + `@interface Base : NSObject +- (void)render; +@end +@implementation Base +- (void)render {} +@end +@interface Widget : Base +@end +@implementation Widget +@end +@interface Decoy : NSObject +- (void)render; +@end +@implementation Decoy +- (void)render {} +@end +@interface Factory : NSObject ++ (Widget *)make; +@end +@implementation Factory ++ (Widget *)make { return nil; } +- (void)run { [[Factory make] render]; } +@end +` + ); + cg = await CodeGraph.init(tempDir, { index: true }); + expect(callerNamesOf('Base::render')).toEqual(['run']); + expect(callerNamesOf('Decoy::render')).toEqual([]); + }); + + it('creates NO edge when the factory return type lacks the method (silent miss)', async () => { + fs.writeFileSync( + path.join(tempDir, 'main.m'), + `@interface Bar : NSObject +@end +@implementation Bar +@end +@interface Other : NSObject +- (void)onlyOther; +@end +@implementation Other +- (void)onlyOther {} +@end +@interface Foo : NSObject ++ (Bar *)create; +@end +@implementation Foo ++ (Bar *)create { return nil; } +- (void)run { [[Foo create] onlyOther]; } +@end +` + ); + cg = await CodeGraph.init(tempDir, { index: true }); + // Bar has no onlyOther — must not mis-attach to the same-named Other::onlyOther. + expect(callerNamesOf('Other::onlyOther')).toEqual([]); + }); + + it('resolves a singleton chain [[Cache shared] clearAll] whose factory returns nonnull instancetype', async () => { + // The factory returns `nonnull instancetype` — the nullability qualifier must + // be skipped (not captured AS the type), and an instancetype class-message + // factory returns the receiver class, so clearAll resolves on Cache, never a + // same-named decoy. (Regression for both: the captured-`nonnull` bug and the + // ubiquitous `[[X alloc] init]` / singleton pattern.) + fs.writeFileSync( + path.join(tempDir, 'main.m'), + `@interface Cache : NSObject ++ (nonnull instancetype)shared; +- (void)clearAll; +@end +@implementation Cache ++ (nonnull instancetype)shared { return nil; } +- (void)clearAll {} +@end +@interface Decoy : NSObject +- (void)clearAll; +@end +@implementation Decoy +- (void)clearAll {} +@end +@interface Caller : NSObject +- (void)run; +@end +@implementation Caller +- (void)run { [[Cache shared] clearAll]; } +@end +` + ); + cg = await CodeGraph.init(tempDir, { index: true }); + expect(callerNamesOf('Cache::clearAll')).toEqual(['run']); + expect(callerNamesOf('Decoy::clearAll')).toEqual([]); + }); + }); + + describe('Pascal/Delphi chained static-factory call resolution (#645/#608 mechanism)', () => { + function callerNamesOf(qualifiedName: string): string[] { + const target = cg.getNodesByKind('method').find((n) => n.qualifiedName === qualifiedName); + if (!target) return []; + const names = cg + .getIncomingEdges(target.id) + .filter((e) => e.kind === 'calls') + .map((e) => cg.getNode(e.source)?.name) + .filter((n): n is string => !!n); + return [...new Set(names)].sort(); + } + function isCalled(qn: string): boolean { + const t = cg.getNodesByKind('method').find((n) => n.qualifiedName === qn); + return !!t && cg.getIncomingEdges(t.id).some((e) => e.kind === 'calls'); + } + + it('resolves a chained factory call TFoo.GetInstance().DoIt() via the return type, never a same-named decoy', async () => { + fs.writeFileSync( + path.join(tempDir, 'main.pas'), + `unit Main; +interface +type + TBar = class + procedure DoIt; + end; + TDecoy = class + procedure DoIt; + end; + TFoo = class + class function GetInstance: TBar; + end; +implementation +procedure TBar.DoIt; begin end; +procedure TDecoy.DoIt; begin end; +class function TFoo.GetInstance: TBar; begin Result := nil; end; +procedure Run; +begin + TFoo.GetInstance().DoIt(); +end; +end. +` + ); + cg = await CodeGraph.init(tempDir, { index: true }); + expect(isCalled('TBar::DoIt')).toBe(true); + expect(isCalled('TDecoy::DoIt')).toBe(false); + }); + + it('resolves a constructor chain TFoo.Create().Configure() on the constructed class', async () => { + fs.writeFileSync( + path.join(tempDir, 'main.pas'), + `unit Main; +interface +type + TFoo = class + constructor Create; + procedure Configure; + end; + TDecoy = class + procedure Configure; + end; +implementation +constructor TFoo.Create; begin end; +procedure TFoo.Configure; begin end; +procedure TDecoy.Configure; begin end; +procedure Run; +begin + TFoo.Create().Configure(); +end; +end. +` + ); + cg = await CodeGraph.init(tempDir, { index: true }); + // A constructor returns its own class (no `: TBar` annotation), so Configure + // resolves on TFoo, not the same-named decoy. + expect(isCalled('TFoo::Configure')).toBe(true); + expect(isCalled('TDecoy::Configure')).toBe(false); + }); + + it('resolves a typecast chain TFoo(x).DoIt() on the cast type', async () => { + fs.writeFileSync( + path.join(tempDir, 'main.pas'), + `unit Main; +interface +type + TFoo = class + procedure DoIt; + end; + TDecoy = class + procedure DoIt; + end; +implementation +procedure TFoo.DoIt; begin end; +procedure TDecoy.DoIt; begin end; +procedure Run(obj: TObject); +begin + TFoo(obj).DoIt(); +end; +end. +` + ); + cg = await CodeGraph.init(tempDir, { index: true }); + expect(isCalled('TFoo::DoIt')).toBe(true); + expect(isCalled('TDecoy::DoIt')).toBe(false); + }); + + it('creates NO edge when the factory return type lacks the method (silent miss)', async () => { + fs.writeFileSync( + path.join(tempDir, 'main.pas'), + `unit Main; +interface +type + TBar = class + end; + TOther = class + procedure OnlyOther; + end; + TFoo = class + class function GetInstance: TBar; + end; +implementation +procedure TOther.OnlyOther; begin end; +class function TFoo.GetInstance: TBar; begin Result := nil; end; +procedure Run; +begin + TFoo.GetInstance().OnlyOther(); +end; +end. +` + ); + cg = await CodeGraph.init(tempDir, { index: true }); + // TBar has no OnlyOther — must not mis-attach to the same-named TOther::OnlyOther. + expect(isCalled('TOther::OnlyOther')).toBe(false); + }); + + it('extracts paren-less method calls (Pascal lets a no-arg method drop its parens)', async () => { + fs.writeFileSync( + path.join(tempDir, 'main.pas'), + `unit Main; +interface +type + TFoo = class + procedure DoThing; + procedure Reset; + end; +implementation +procedure TFoo.DoThing; begin end; +procedure TFoo.Reset; begin end; +procedure Run(f: TFoo); +begin + f.DoThing; + f.Reset; +end; +end. +` + ); + cg = await CodeGraph.init(tempDir, { index: true }); + expect(isCalled('TFoo::DoThing')).toBe(true); + expect(isCalled('TFoo::Reset')).toBe(true); + }); + + it('resolves a PAREN-LESS chained factory call TFoo.GetInstance.DoIt via the return type', async () => { + fs.writeFileSync( + path.join(tempDir, 'main.pas'), + `unit Main; +interface +type + TBar = class + procedure DoIt; + end; + TDecoy = class + procedure DoIt; + end; + TFoo = class + class function GetInstance: TBar; + end; +implementation +procedure TBar.DoIt; begin end; +procedure TDecoy.DoIt; begin end; +class function TFoo.GetInstance: TBar; begin Result := nil; end; +procedure Run; +begin + TFoo.GetInstance.DoIt; +end; +end. +` + ); + cg = await CodeGraph.init(tempDir, { index: true }); + expect(isCalled('TBar::DoIt')).toBe(true); + expect(isCalled('TDecoy::DoIt')).toBe(false); + }); + + it('does NOT turn a property write/read into a call edge (only statement-level dots are calls)', async () => { + fs.writeFileSync( + path.join(tempDir, 'main.pas'), + `unit Main; +interface +type + TFoo = class + function GetValue: Integer; + procedure SetValue(v: Integer); + property Value: Integer read GetValue write SetValue; + end; +implementation +function TFoo.GetValue: Integer; begin Result := 0; end; +procedure TFoo.SetValue(v: Integer); begin end; +procedure Run(f: TFoo); +var x: Integer; +begin + f.Value := 5; + x := f.Value; +end; +end. +` + ); + cg = await CodeGraph.init(tempDir, { index: true }); + // A property read/write is a bare dot in assignment position, not a statement, + // so it must not be mis-extracted as a call to the property's getter/setter. + expect(isCalled('TFoo::GetValue')).toBe(false); + expect(isCalled('TFoo::SetValue')).toBe(false); + }); + + it('attributes an implementation-only free procedure\'s calls to the procedure, not the file', async () => { + fs.writeFileSync( + path.join(tempDir, 'main.pas'), + `unit Main; +interface +type + TTgt = class + procedure Hit; + end; + TFoo = class + procedure DoStuff; + end; +implementation +procedure TTgt.Hit; begin end; +procedure TFoo.DoStuff; var t: TTgt; begin t.Hit; end; +procedure Helper; var t: TTgt; begin t.Hit; end; +` + ); + cg = await CodeGraph.init(tempDir, { index: true }); + // `Helper` is implementation-only (no interface decl, not a method), but its + // body's call must attribute to `Helper`, not the file/module — alongside the + // method `DoStuff`. + expect(callerNamesOf('TTgt::Hit')).toEqual(['DoStuff', 'Helper']); + }); + }); }); diff --git a/__tests__/same-name-disambiguation.test.ts b/__tests__/same-name-disambiguation.test.ts new file mode 100644 index 000000000..5c1ae4f1c --- /dev/null +++ b/__tests__/same-name-disambiguation.test.ts @@ -0,0 +1,138 @@ +/** + * Same-named symbols across monorepo apps (#764). + * + * A NestJS-style monorepo has one `UserService` (and friends) per app. The + * graph keeps them as distinct nodes (import + proximity resolution), but the + * MCP tools used to AGGREGATE them: callers/callees returned one merged list + * and impact merged both blast radii — the conflation agents warned about. + * + * Now: multiple DISTINCT definitions (different file/qualified-name) render + * one section per definition, and `file` narrows to a single definition. + * Same-file overloads still merge (that's the overload feature). + */ + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; +import { CodeGraph } from '../src'; +import { ToolHandler } from '../src/mcp/tools'; +import { initGrammars, loadAllGrammars } from '../src/extraction/grammars'; + +let tmpDir: string; +let cg: CodeGraph; +let handler: ToolHandler; + +const text = async (tool: string, args: Record): Promise => { + const res = await handler.execute(tool, args); + return res.content?.[0]?.text ?? ''; +}; + +beforeAll(async () => { + await initGrammars(); + await loadAllGrammars(); + + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-764-')); + const mk = (rel: string, content: string) => { + const p = path.join(tmpDir, rel); + fs.mkdirSync(path.dirname(p), { recursive: true }); + fs.writeFileSync(p, content); + }; + + for (const app of ['billing', 'admin']) { + mk( + `apps/${app}/src/users/user.service.ts`, + [ + "import { UserRepository } from './user.repository';", + 'export class UserService {', + ' constructor(private readonly repo: UserRepository) {}', + ' findAll(): string[] {', + ` return this.repo.load_${app}();`, + ' }', + '}', + ].join('\n') + ); + mk( + `apps/${app}/src/users/user.repository.ts`, + `export class UserRepository {\n load_${app}(): string[] { return []; }\n}\n` + ); + mk( + `apps/${app}/src/users/user.controller.ts`, + [ + "import { UserService } from './user.service';", + 'export class UserController {', + ' constructor(private readonly users: UserService) {}', + ' list(): string[] { return this.users.findAll(); }', + '}', + ].join('\n') + ); + } + + cg = CodeGraph.initSync(tmpDir); + await cg.indexAll(); + handler = new ToolHandler(cg); +}, 120_000); + +afterAll(() => { + cg?.destroy(); + if (tmpDir) fs.rmSync(tmpDir, { recursive: true, force: true }); +}); + +describe('same-named symbols across apps (#764)', () => { + it('graph keeps the apps apart: no cross-app edges at all', () => { + const billing = new Set( + cg.getNodesByName('findAll').filter((n) => n.filePath.includes('billing')).map((n) => n.id) + ); + for (const id of billing) { + for (const e of cg.getIncomingEdges(id)) { + const src = cg.getNode(e.source); + expect(src?.filePath.includes('admin')).toBe(false); + } + } + }); + + it('callers: one section per distinct definition, each with only its own callers', async () => { + const out = await text('codegraph_callers', { symbol: 'findAll' }); + expect(out).toContain('2 distinct definitions'); + // Section per definition… + expect(out).toContain('apps/admin/src/users/user.service.ts'); + expect(out).toContain('apps/billing/src/users/user.service.ts'); + // …and the billing section must list the billing controller, not admin's. + const billingSection = out.slice(out.indexOf('apps/billing/src/users/user.service.ts')); + const billingBody = billingSection.slice(0, billingSection.indexOf('###', 3) > 0 ? billingSection.indexOf('###', 3) : undefined); + expect(billingBody).toContain('apps/billing/src/users/user.controller.ts'); + expect(billingBody).not.toContain('apps/admin/src/users/user.controller.ts'); + }); + + it('callers: `file` narrows to one definition (flat list, no stale aggregation note)', async () => { + const out = await text('codegraph_callers', { + symbol: 'findAll', + file: 'apps/billing/src/users/user.service.ts', + }); + expect(out).not.toContain('distinct definitions'); + expect(out).toContain('apps/billing/src/users/user.controller.ts'); + expect(out).not.toContain('apps/admin/'); + expect(out).not.toContain('Aggregated results'); + }); + + it('callers: a non-matching `file` falls back to all definitions with a note', async () => { + const out = await text('codegraph_callers', { symbol: 'findAll', file: 'apps/nonexistent/x.ts' }); + expect(out).toContain('no definition of "findAll" matches file'); + expect(out).toContain('2 distinct definitions'); + }); + + it('impact: separate blast radius per definition, never a merged one', async () => { + const out = await text('codegraph_impact', { symbol: 'UserService' }); + expect(out).toContain('2 distinct definitions'); + // Each section's count covers ONE app (service + ctor + findAll + + // controller side), not the union of both. + const counts = [...out.matchAll(/affects (\d+) symbols/g)].map((m) => Number(m[1])); + expect(counts).toHaveLength(2); + for (const c of counts) expect(c).toBeLessThanOrEqual(7); + }); + + it('callees: grouped the same way', async () => { + const out = await text('codegraph_callees', { symbol: 'list' }); + expect(out).toContain('2 distinct definitions'); + }); +}); diff --git a/__tests__/stdin-teardown.test.ts b/__tests__/stdin-teardown.test.ts new file mode 100644 index 000000000..c538ac5b2 --- /dev/null +++ b/__tests__/stdin-teardown.test.ts @@ -0,0 +1,46 @@ +/** + * #799 — a socket-backed stdin that fails must shut the server down, not + * orphan/busy-spin. treatStdinFailureAsShutdown is the shared guard. + */ +import { describe, it, expect } from 'vitest'; +import { PassThrough } from 'stream'; +import { treatStdinFailureAsShutdown } from '../src/mcp/stdin-teardown'; + +describe('treatStdinFailureAsShutdown (#799)', () => { + it("treats a stdin 'error' (ECONNRESET/hangup) as a shutdown signal", () => { + const s = new PassThrough(); + let calls = 0; + treatStdinFailureAsShutdown(() => { calls++; }, s); + + // No extra 'error' listener would throw here — the guard registers one. + s.emit('error', new Error('read ECONNRESET')); + expect(calls).toBe(1); + }); + + it("also fires on 'end' and on 'close'", () => { + for (const ev of ['end', 'close'] as const) { + const s = new PassThrough(); + let calls = 0; + treatStdinFailureAsShutdown(() => { calls++; }, s); + s.emit(ev); + expect(calls, `event ${ev}`).toBe(1); + } + }); + + it('destroys the stream so a hung fd leaves epoll', () => { + const s = new PassThrough(); + treatStdinFailureAsShutdown(() => { /* noop */ }, s); + s.emit('error', new Error('boom')); + expect(s.destroyed).toBe(true); + }); + + it('fires onTerminal at most once, even across error → close', () => { + const s = new PassThrough(); + let calls = 0; + treatStdinFailureAsShutdown(() => { calls++; }, s); + s.emit('error', new Error('boom')); // fire() also destroys → emits 'close' + s.emit('close'); // must not double-fire + s.emit('end'); + expect(calls).toBe(1); + }); +}); diff --git a/__tests__/ts-field-classification.test.ts b/__tests__/ts-field-classification.test.ts new file mode 100644 index 000000000..82069a8d2 --- /dev/null +++ b/__tests__/ts-field-classification.test.ts @@ -0,0 +1,159 @@ +/** + * TS/JS class-field kind classification (#808). + * + * `public_field_definition` (TS) / `field_definition` (JS) previously + * extracted as method-kind nodes unconditionally, so a plain annotated field + * (`public fonts: Fonts;`) was reported as a method — misrepresenting class + * shape and defeating kind-based filtering (#756 had to work around it). + * + * Now classification follows the VALUE: arrow-function / function-expression + * fields (and HOF-wrapped ones, mirroring resolveBody) stay methods; every + * other field is a property. Parity requirements: the property keeps its + * type-annotation `references` edge, visibility, and static-ness; method + * fields keep walking their bodies (calls still attributed). + */ + +import { describe, it, expect, beforeAll, afterEach } from 'vitest'; +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; +import { CodeGraph } from '../src'; +import { initGrammars, loadAllGrammars } from '../src/extraction/grammars'; + +beforeAll(async () => { + await initGrammars(); + await loadAllGrammars(); +}); + +describe('TS/JS class field classification (#808)', () => { + let tmpDir: string | undefined; + afterEach(() => { + if (tmpDir) fs.rmSync(tmpDir, { recursive: true, force: true }); + tmpDir = undefined; + }); + + it('TS: plain fields are properties; function-valued fields are methods', async () => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-808-ts-')); + fs.writeFileSync( + path.join(tmpDir, 'app.ts'), + [ + 'declare function throttle(f: unknown, ms: number): unknown;', + 'class Fonts {}', + 'class History {}', + 'class App {', + ' public fonts: Fonts;', // plain annotated → property + ' private history: History = new History();', // annotated + initializer → property + ' interactiveCanvas: HTMLCanvasElement | null = null;', // union type → property + ' count = 0;', // plain value → property + ' static defaults = { a: 1 };', // object value → property + ' onClick = () => { this.run(); };', // arrow field → method + ' onScroll = throttle((e: Event) => { this.run(); }, 100);', // HOF-wrapped → method + ' handler = function namedFn() {};', // function expression → method + ' handleClick(): void {}', // real method + ' get value(): number { return 1; }', // getter stays method + ' run(): void {}', + '}', + ].join('\n') + ); + + const cg = CodeGraph.initSync(tmpDir); + try { + await cg.indexAll(); + + const kindOf = (name: string) => + cg.getNodesByName(name).map((n) => n.kind).sort().join(','); + + expect(kindOf('fonts')).toBe('property'); + expect(kindOf('history')).toBe('property'); + expect(kindOf('interactiveCanvas')).toBe('property'); + expect(kindOf('count')).toBe('property'); + expect(kindOf('defaults')).toBe('property'); + expect(kindOf('onClick')).toBe('method'); + expect(kindOf('onScroll')).toBe('method'); + expect(kindOf('handler')).toBe('method'); + expect(kindOf('handleClick')).toBe('method'); + expect(kindOf('value')).toBe('method'); + + // Parity: the property keeps its type-annotation reference edge. + const fontsProp = cg.getNodesByName('fonts').find((n) => n.kind === 'property')!; + const fontsRefs = cg + .getOutgoingEdges(fontsProp.id) + .filter((e) => e.kind === 'references') + .map((e) => cg.getNode(e.target)?.name); + expect(fontsRefs).toContain('Fonts'); + + // Parity: visibility survives the property path. + expect(fontsProp.visibility).toBe('public'); + const historyProp = cg.getNodesByName('history').find((n) => n.kind === 'property')!; + expect(historyProp.visibility).toBe('private'); + + // Parity: arrow-field bodies still walk — onClick calls run. + const onClick = cg.getNodesByName('onClick')[0]!; + const calls = cg + .getOutgoingEdges(onClick.id) + .filter((e) => e.kind === 'calls') + .map((e) => cg.getNode(e.target)?.name); + expect(calls).toContain('run'); + + // Signature carries the declared type, C#-style "Type name". + expect(fontsProp.signature).toBe('Fonts fonts'); + } finally { + cg.destroy(); + tmpDir = undefined; + } + }); + + it('JS: field_definition classifies the same way', async () => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-808-js-')); + fs.writeFileSync( + path.join(tmpDir, 'app.js'), + [ + 'class App {', + ' count = 0;', + ' config = { retries: 3 };', + ' onClick = () => { this.run(); };', + ' run() {}', + '}', + 'module.exports = App;', + ].join('\n') + ); + + const cg = CodeGraph.initSync(tmpDir); + try { + await cg.indexAll(); + expect(cg.getNodesByName('count')[0]?.kind).toBe('property'); + expect(cg.getNodesByName('config')[0]?.kind).toBe('property'); + expect(cg.getNodesByName('onClick')[0]?.kind).toBe('method'); + } finally { + cg.destroy(); + tmpDir = undefined; + } + }); + + it('field initializers still register callbacks (fn-ref scan)', async () => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-808-fnref-')); + fs.writeFileSync( + path.join(tmpDir, 'main.ts'), + [ + 'function onSave(): void {}', + 'function onLoad(): void {}', + 'export class Registry {', + ' static handlers = { save: onSave, load: onLoad };', + '}', + ].join('\n') + ); + + const cg = CodeGraph.initSync(tmpDir); + try { + await cg.indexAll(); + const onSave = cg.getNodesByName('onSave')[0]!; + const fnRefs = cg + .getIncomingEdges(onSave.id) + .filter((e) => e.metadata?.fnRef === true); + expect(fnRefs.length).toBeGreaterThan(0); + } finally { + cg.destroy(); + tmpDir = undefined; + } + }); +}); diff --git a/docs/design/agent-codegraph-adoption.md b/docs/design/agent-codegraph-adoption.md new file mode 100644 index 000000000..8b6c1c061 --- /dev/null +++ b/docs/design/agent-codegraph-adoption.md @@ -0,0 +1,136 @@ +# Getting agents to actually use codegraph (not Read) — design notes & handoff + +> Working doc for a fresh session. Two problems to crack: +> **(P1)** agents still reach for `Read`/`grep` during implementation instead of codegraph; +> **(P2)** on startup the codegraph MCP server can be `pending` when the agent's first turn fires, so the agent runs with *no* codegraph at all. +> +> Read `codegraph/CLAUDE.md` → "Retrieval performance & dynamic-dispatch coverage" first — it's the doctrine these ideas must respect. + +--- + +## Context — what already shipped (so you don't repeat it) + +- **#733 (`7175dc4`)** — reframed the agent-facing steering (`src/mcp/server-instructions.ts` + the `codegraph_node`/`codegraph_explore` descriptions in `src/mcp/tools.ts`) to cover *implementation*, not just Q&A; and added **file-view mode**: `codegraph_node` now accepts a bare `file` (no `symbol`) → returns that file's symbol map + its dependents (blast radius) + verbatim bodies (`includeCode`). `handleFileView` in `src/mcp/tools.ts`. +- **Clean A/B result** (new build vs baseline build, both codegraph-connected, same fully-implemented task — `kindExclude` added to `codegraph_search`): + - **baseline:** 0 codegraph calls, 8 Reads (agent *ignored* available codegraph). + - **new:** 2 `codegraph_explore` calls, 5 Reads. + - So the reframe *did* move tool-choice — but the agent used `codegraph_explore`, **never the file-view**, and still Read 5×. n=1/arm. +- **Eval harness fix** (`#735`): nested attach is a *startup-latency* problem, not a hard block. `scripts/agent-eval/ab-new-vs-baseline.sh` now pre-warms a daemon + skips the re-exec; use it (run non-nested for cleanest results). + +**Doctrine constraints (from CLAUDE.md — do not relitigate):** +- *Adapt the tool to the agent.* Changing tool descriptions / `server-instructions.ts` is **low-salience** and has *regressed* wall-clock before. Wording alone won't reliably move tool-choice. +- *New tools fare worse than extending an existing one* (the agent under-picks even `trace`; `codegraph_context` was removed). +- The real levers that landed historically: **coverage** (more flows connect statically → `explore` surfaces them) and **sufficiency** (output complete enough that the agent *stops* reading). +- The optimization target is **wall-clock + tool-call count + Read=0**, not token cost (cost is lower as a side effect). + +--- + +## P1 — Agents under-use codegraph during implementation + +### STATUS — 2026-06-08 (RESOLVED via Read-parity, not a hook) + +**The fix: make `codegraph_node` read a file *exactly like the Read tool*, only +faster — so the agent reaches for it naturally. No forcing.** The owner's steer +settled the direction: *"codegraph should be able to Read just like the Read +tool… make it as good as Read. Read is slow and old; querying the index is fast. +You keep diverging away from using codegraph rather than pursuing the fix."* + +**DONE — `handleFileView` (`src/mcp/tools.ts`) is now full Read parity:** +- A `file` with no `symbol` returns the file's current source numbered + **byte-for-byte the way Read does — `\t`, no padding, trailing empty + line kept** (verified by reading the same file with both and diffing). The only + addition is a **one-line blast-radius header** (`used by N files: …`). +- **`offset` / `limit` mean exactly what they do on Read** (1-based start; max + lines; default whole file capped at 2000 lines like Read). Large files paginate + honestly (`(lines X–Y of N — pass offset/limit…)`), never the 15k `truncateOutput` chop. +- Content is the **default** (no `includeCode` needed); `symbolsOnly: true` returns + the cheap structural map instead. Security preserved: `yaml`/`properties` + summarized by key, never dumped (#383); reads via `validatePathWithinRoot` (#527). +- Tests: `__tests__/node-file-view.test.ts` (9, incl. strict format parity + `^1000\t const v998 = 998;` and unpadded `^1\timport …`). Full suite green + (1270). Descriptions / `server-instructions.ts` / CHANGELOG reframed: "read a + source file with codegraph_node instead of Read — same bytes, faster." + +**The hook (idea 1) — A/B'd and REJECTED. Do not ship.** Kept only as an eval +artifact (`scripts/agent-eval/redirect-read-hook.sh` + `ab-hook.sh`). +- Clean A/B (2 runs/arm, devpit "add `dp ping`, build it"; both arms codegraph-attached): + - **nohook:** 0 codegraph calls, 1 Read, **5–7 tool calls, 6–8 turns, 55–77s.** (Reproduces P1: agent ignores codegraph — but read-once-and-edit is *efficient* here.) + - **hook (deny-redirect):** 0 *successful* Reads + 1 file-view call (parity worked, edit compiled), but **8–9 tool calls, 9–10 turns, 200–239s**, and the agent **fought the deny** — `ToolSearch` to find the tool, reflexive re-Read (denied), then **`Bash python3` to read the file around the block.** + - Verdict: a blanket Read-deny **regresses the target metrics (~2× tool calls, more turns) on a simple edit** and the agent routes around it. Forcing is the wrong lever; making the tool genuinely better than Read is the right one. +- If routing is ever revisited: not a blanket hook. Either a narrow trigger (large + files only / after-N-reads) **with a clean A/B on a Read-heavy multi-file task** + (the hook's best case, untested), or just keep widening coverage + sufficiency. + +--- + +**Symptom:** even with codegraph attached + the new steering, the agent reflexively `Read`s/`grep`s mid-implementation, and never reaches for the file-view. Descriptions can't fix this (low-salience wall). + +### Ideas, ranked by expected leverage + +1. **PreToolUse(Read/Grep) hook that redirects to codegraph** — *highest leverage; the only channel that actually changes behavior.* + - Claude Code **hooks** can intercept a tool call and inject context or block it — unlike descriptions, this is *not* low-salience. We already have `scripts/agent-eval/block-read-hook.sh` + `hook-settings.json` (used to force Read=0 in evals). + - Ship a **recommended (opt-in) hook**: on `Read` (or `Grep`) of a path that's *indexed*, inject "this file is indexed — `codegraph_node {file}` returns it + its blast radius for fewer tokens; treat its output as already-Read." Soft nudge (don't hard-block, or it'll frustrate users on configs/docs codegraph doesn't index). + - The installer (`src/installer/targets/claude.ts`) could offer to add this hook (opt-in, like the auto-allow permissions). + - **Validate** with `ab-new-vs-baseline.sh` (Read count, with vs without the hook). This is the experiment most likely to move the needle. + - Open Qs: how to know a path is indexed from inside a hook (query `codegraph files`/`status`, or a fast local check against `.codegraph`); avoiding noise on non-indexed files; per-language false positives. + +2. **Sufficiency: make the file-view the obvious Read replacement so the agent *wants* it.** + - The A/B showed the agent never passed a `file` to `codegraph_node`. Why? It doesn't think "Read this file" → "codegraph_node file=X". Investigate: is the file-view's value (symbols + dependents + bodies) actually *better than Read* for the agent's next step (an `Edit`)? It returns bodies — but does it return enough surrounding context to `Edit` confidently? If not, the agent Reads anyway. + - Consider: when the agent *does* Read an indexed file, is there a way to make codegraph's prior `explore`/`node` output have *already* given it what it needed? (i.e. fix the upstream sufficiency, not the Read itself.) + +3. **Coverage — the durable lever.** Every flow that connects statically is one the agent doesn't Read to reconstruct. Keep closing dynamic-dispatch gaps (`src/resolution/`). Less about "stop Reading," more about "never need to." + +4. **Naming / affordance experiments (low confidence, cheap).** The file-view is buried inside `codegraph_node`. A dedicated, obviously-named affordance might get picked more — *but* "new tools fare worse," so this likely loses. If tried, A/B it; don't assume. + +**Recommendation:** prototype **idea 1 (the Read-redirect hook)** and A/B it. It's the one lever with a real chance of moving behavior. Everything else is incremental. + +--- + +## P2 — Agent runs without codegraph because the server is `pending` at startup + +**Symptom:** `serve --mcp` isn't ready when the agent's first turn fires (the host marks the MCP server `status:"pending"` / 0 tools), so the agent starts Read/grep and never uses codegraph. We saw this hard in nested evals (~2-3s startup vs the agent's turn-1); **real users hit a milder version** — the first query of a session may not have codegraph. + +### Root cause +`serve --mcp` does a `--liftoff-only` **re-exec** (for a node memory flag) **and** spawns/binds a detached **daemon** before tools are usable. Under load that exceeds the host's MCP-startup window. (`CODEGRAPH_WASM_RELAUNCHED=1` skips the re-exec; pre-warming a daemon removes the bind latency — both proven in `ab-new-vs-baseline.sh`. But a real user can't pre-warm.) + +### Ideas, ranked + +1. **CODEGRAPH-SIDE — expose the static tool list INSTANTLY, decoupled from the daemon. *Biggest shippable win; helps every user.*** + - Hypothesis: the host marks codegraph `pending` because `tools/list` (tool exposure) waits on the daemon connect. The local handshake already answers `initialize` fast (~107ms; `runLocalHandshakeProxy` in `src/mcp/proxy.ts`, `getStaticTools` is imported there). **Investigate: does `serve --mcp` answer `tools/list` *locally and instantly* from `getStaticTools`, or does it forward it to the still-connecting daemon?** If the latter, decouple it: advertise the static tools the moment the client asks, mark connected, and resolve the daemon in the background for actual tool *calls*. + - Verify with: `printf '\n\n\n' | node dist/bin/codegraph.js serve --mcp --path ` and time the `tools/list` response, daemon-mode vs in-process. In-process answered in ~165ms; daemon-mode is the suspect. + - If this lands, `pending`-at-startup largely disappears without any host change. + +2. **CODEGRAPH-SIDE — speed/skip the re-exec on the MCP serve path.** The re-exec exists for a V8 memory flag (`src/extraction/wasm-runtime-flags.ts`, `RELAUNCH_GUARD_ENV = CODEGRAPH_WASM_RELAUNCHED`). For MCP serving on a normal repo the flag may be unnecessary, or settable without a full process re-exec. Removing one process spawn from the cold path shaves the startup window. + +3. **CODEGRAPH-SIDE — a SessionStart hook that pre-warms the daemon.** Ship an opt-in Claude Code `SessionStart` hook (installer-added) that spawns/warms the daemon for the project at session start, so it's bound before the first query. Mitigation if (1) is hard. + +4. **HOST-SIDE — "wait/retry on pending" — this is what you asked about, but it's a Claude Code (MCP client) behavior, not codegraph's to fix.** codegraph can't make the agent retry. Options: (a) raise it with Anthropic as an MCP-client improvement (don't let the agent's first turn proceed until configured MCP servers finish connecting, or retry `pending` servers); (b) note `MCP_TIMEOUT` exists but did **not** help here, because the problem is *tool exposure timing*, not a connection timeout. Frame this as a request, and lean on (1)–(3) for what we control. + +**Recommendation:** chase **idea 1** (decouple `tools/list` from the daemon). It's the fix that makes codegraph "connected" instantly for everyone. Ship **idea 3** (pre-warm SessionStart hook) as a cheap mitigation in parallel. File the host-side request (4) but don't depend on it. + +--- + +## Key files / pointers + +- **Steering / tools:** `src/mcp/server-instructions.ts` (the `initialize` instructions — single source of truth), `src/mcp/tools.ts` (tool descriptions + handlers; `handleNode`/`handleFileView`/`handleSearch`, `getStaticTools`). +- **Startup / daemon / proxy:** `src/mcp/proxy.ts` (`runProxy`, `connectWithHello`, `runLocalHandshakeProxy`, PPID watchdog), `src/mcp/index.ts` (`runProxyWithLocalHandshake`, `spawnDetachedDaemon`), `src/mcp/daemon.ts`. +- **Runtime flags:** `src/extraction/wasm-runtime-flags.ts` (`RELAUNCH_GUARD_ENV=CODEGRAPH_WASM_RELAUNCHED`, `HOST_PPID_ENV=CODEGRAPH_HOST_PPID`). +- **Hooks (existing):** `scripts/agent-eval/block-read-hook.sh`, `scripts/agent-eval/hook-settings.json` (the eval's force-Read-0 hook — basis for the P1 redirect hook). +- **Installer (where to add a recommended hook):** `src/installer/targets/claude.ts`. +- **Eval harness:** `scripts/agent-eval/ab-new-vs-baseline.sh` (new-vs-baseline, pre-warm baked in), `run-all.sh` (with-vs-without), `parse-run.mjs` (tool-by-type counts; `codegraph tools exposed: 0` + 0 codegraph calls = ran without). +- **Doctrine:** `CLAUDE.md` → "Retrieval performance & dynamic-dispatch coverage" + the agent-eval note under "Validation methodology". + +## How to validate anything here +- **P1 (Read displacement):** `bash scripts/agent-eval/ab-new-vs-baseline.sh "" [baseline-ref]` — compare `Read` vs `mcp__codegraph__*` counts. ≥2 runs/arm (n=1 is noisy). Run non-nested for cleanest results. Use a *genuinely new* feature task (verify it doesn't already exist — the first A/B attempt wasted a run on an already-implemented `--quiet`). +- **P2 (startup):** time `tools/list` from `serve --mcp` (above); and count cold-start runs where `init` shows `connected` + tools > 0. Don't trust a single `pending` init snapshot — confirm by whether the agent actually called codegraph. + +## Constraints / gotchas to remember +- Descriptions/instructions are low-salience — **A/B every behavioral claim**, don't ship wording on faith. +- New tools < extending existing ones. +- The host's `init` snapshot can say `pending` even when the server then connects — judge by actual usage. +- Don't run evals nested for "clean" numbers unless pre-warmed; even then, a real terminal is better. + +## Suggested start order for the fresh session +1. **P2 idea 1** — verify whether `serve --mcp` answers `tools/list` locally/instantly; if not, decouple it from the daemon. (Highest-value, shippable, helps all users, no behavioral guesswork.) +2. **P1 idea 1** — prototype the PreToolUse(Read) redirect hook; A/B it. (Highest-value behavioral lever.) +3. Ship the P2 SessionStart pre-warm hook as a mitigation; file the host-side wait/retry request. diff --git a/docs/design/chained-call-resolution.md b/docs/design/chained-call-resolution.md new file mode 100644 index 000000000..9fa34a6e6 --- /dev/null +++ b/docs/design/chained-call-resolution.md @@ -0,0 +1,146 @@ +# Design + status: chained static-factory / fluent call resolution + +**Status:** SHIPPED for **13 languages** (C++, C, PHP, Java, Kotlin, C#, Swift, Rust, +Go, Scala, Dart, Objective-C, Pascal/Delphi) + a conformance pass. **TypeScript and Luau +were evaluated and intentionally skipped** (both gradually typed → the mechanism is +0 / +regresses on real code). See "Full README classification" below. Tracking issue: +**#750** (which began as "the statically-typed README languages" but that enumeration was +incomplete — it missed ObjC / Pascal / Luau). + +**Motivation:** a call whose **receiver is itself a call** — a factory / singleton / +builder that returns an object — should produce a `calls` edge to the chained method: + +```java +Foo.getInstance().bar(); // bar() should resolve to Foo::bar, never a same-named decoy +``` + +Before this work, every statically-typed language **dropped the receiver** and +name-matched the bare method (`bar`), so in 7 of 9 languages it silently attached to a +**same-named method on an unrelated type** — a correctness bug, not just missing coverage. + +--- + +## The 3-part mechanism (per language) + +1. **Capture the factory's declared return type** — a per-language `getReturnType` + hook writes `nodes.return_type` (schema v5). `*Foo`→`Foo`, `List`→`List`, + `pkg.Foo`→`Foo`, `-> Self` / `: self` / `this.type` → the declaring type. +2. **Preserve the chained receiver at extraction** — `tree-sitter.ts` (or a bespoke + extractor) encodes `Foo.getInstance().bar()` as the marker string + `Foo.getInstance().bar` (the `().` marker never appears in an ordinary ref). A + per-language gate keeps **instance** chains (`list.map().filter()`) bare so their + existing resolution is untouched — only capitalized-receiver / factory chains re-encode. +3. **Resolve AND VALIDATE** — at resolution the receiver's type is inferred from what + the inner call returns, then the outer method is resolved **on that type** and + validated: the method must exist on the type (or a supertype it conforms to), so a + wrong inference yields **no edge**, never a wrong one. + +Three shared resolvers in `src/resolution/name-matcher.ts`, all calling +`resolveMethodOnType` (which has the conformance supertype-walk): + +| Resolver | Receiver style | Languages | +|---|---|---| +| `matchCppCallChain` | `field_expression` (`Foo::instance().bar`) | C++, C | +| `matchScopedCallChain` | `::` (`Cls::for($x)->m`, `Foo::new().bar`) | PHP, Rust | +| `matchDottedCallChain` | `.` (`Foo.create().bar`) | Java, Kotlin, C#, Swift, Go, Scala, Dart | + +**Conformance pass (#754).** When the chained method lives on a **supertype** the +return type conforms to (an inherited / default-interface / trait / mixin / embedded +method), the first pass can't see it — `implements`/`extends` edges aren't built yet. +So failed chain refs are deferred (`CHAIN_LANGUAGES` in `resolution/index.ts`) and +re-resolved in a second pass `resolveChainedCallsViaConformance()` after edges exist, +walking `context.getSupertypes(...)`. + +**Adding a language:** `getReturnType` in `languages/*.ts`; encode the chained receiver ++ a node-type gate; add the language to the right `matchReference` gate (and +`CONSTRUCTS_VIA_BARE_CALL` if a bare capitalized call constructs the class); add to +`CHAIN_LANGUAGES`; synthetic tests + a real-repo A/B; bump `EXTRACTION_VERSION`. + +--- + +## Coverage (validated — each via synthetic decoy/absent-method tests + a real-repo A/B) + +| Language | PR | Receiver | Real-repo A/B (unique `calls` edges) | Notes | +|---|---|---|---|---| +| **C++ / C** | #645 (#742) | `field_expression` | — | The original: singletons / factories / chained getters. | +| **PHP** | #608 (#749) | `::` → `->` | — | `Cls::for($x)->method()` — the Laravel per-tenant client idiom. `: self`/`: static`. | +| **Java** | #751 | `.` | Guava **+1,507 / −0** | Missing-edge → purely additive. | +| **Kotlin** | #752 | `.` | arrow **+49 / −438** | Wrong-edge → precision win (438 removed = test/doc noise + wrong). Needed the capitalized-receiver gate + constructor-receiver handling. | +| **C#** | #753 | `.` | Newtonsoft +3 / NodaTime **+73 / −0** | Additive. Return type is the `returns` field; extension-method chains correctly don't resolve. | +| **conformance** | #754 | (resolver upgrade) | arrow **+22 / −0** | Supertype walk — enables Swift protocol-ext, Rust trait, Go embedded, Dart mixin, Java/Kotlin/C# inherited chains. | +| **Swift** | #755 | `.` | Alamofire / Kingfisher **0 / 0** | Neutral-safe (unique fluent names already bare-resolved). Needed a nested-extension naming fix (`KF.Builder`→`KF::Builder`). | +| **Rust** | #757 | `::` | clap **+937 / −775** | Precision win (622 wrong→right retargets, +162 net). `-> Self`; trait-default methods via conformance. Single-hop. | +| **Go** | #760 | `.` | gin **net-zero** | `New().Method()`; embedded structs via conformance. Variable-inner fallback. **Found + fixed a batched-resolver runaway** (a mutated `original.referenceName` looped the offset-0 batch → 5M edges / 1.4 GB; fixed by tying the fallback to the original ref + a non-progress guard). | +| **Scala** | #761 | `.` | gatling **+14 / −59** | Precision win (−59 = stdlib `Option`/`Iterator` `.map`/`.flatMap` the baseline mis-tied to gatling's `Validation::*`). Companion factories + case-class `apply`. | +| **Dart** | #762 | `.` | localsend hand-written **+17 / −10** | Precision win **+ constructors made first-class** (factory/named ctors `Foo.create()`/`Foo._()` are now indexed; unnamed `Foo()` stays `instantiates`). `dartCtorInfo` validates a ctor against the enclosing class name — handles a tree-sitter misparse where `@override (A,B) m()` makes `m()` look like a ctor. | +| **Objective-C** | #786 | message send | SDWebImage **+35 / −75** | Precision win. Chained message send `[[Foo create] doIt]` over `message_expression`. getReturnType skips nullability qualifiers (`nonnull instancetype`). A class-message factory returns the receiver class by convention, so `[[X alloc] init]` / singleton chains resolve on `X` (validated). The −75 are wrong `init` mis-matches retargeted to the right class. | +| **Pascal/Delphi** | #791 | `.` (`exprDot`) | PascalCoin **+19 / −18** | Precision win. `TFoo.GetInstance().DoIt()` over Pascal's `exprCall`/`exprDot`. getReturnType from the `typeref` (incl. interface returns `IFoo`). Re-encoding gated on the Delphi `TFoo`/`IFoo` type convention so capitalized *variable* chains stay bare. A constructor (no `: TBar`) or typecast `TFoo(x)` resolves on the class. 15 of the −18 are correct class→interface retargets (`GetInstance(): IAsn1OctetString`). | +| **TypeScript** | — | `.` | typeorm +0/−6 · nest **+0/−164** | **Evaluated, NOT shipped** — gradual typing; see below. | +| **Luau** | — | `:` / `.` | Fusion +0/−0 · matter +0/−0 | **Evaluated, NOT shipped** — gradually typed; additive-safe (missing-edge gap, no regression) but real Luau rarely annotates factory returns, so +0 on both benchmarks. Works for `Foo.create(): Bar` then `:doIt()` (synthetic). | + +`EXTRACTION_VERSION` is now **18** (C++→…→Pascal chains→paren-less calls→free-routine attribution). Re-index with `codegraph index -f` +to pick up the newer extractor on an existing graph. + +## Why TypeScript was skipped + +The mechanism resolves a chain from the factory's **declared** return type. TypeScript +leans on **type inference** — e.g. NestJS's `Test.createTestingModule(m) { return new +TestingModuleBuilder(...) }` has no `: TestingModuleBuilder` annotation — so the +factory's type can't be recovered, the re-encoded chain can't resolve, and it **drops +the bare-name edge** the existing resolver found. Real-repo A/B was **+0 added on both +typeorm and nest** with a net recall regression (−164 on nest, mostly the ubiquitous +`Test.createTestingModule({…}).compile()` pattern). The removed edges were mostly +*wrong* (baseline mis-resolved `.compile()` to `ModuleCompiler::compile`), so it's +precision-positive but recall-negative — against the recall-first invariant, and adding +nothing where it doesn't hurt (TS method names are unique enough that bare-name already +lands them). It was fully implemented (5 synthetic tests passed, runaway-safe bare-name +fallback) and consciously not shipped. The only path to a TS win would be reading +**inferred** return types (resolving `return new X()` in the factory body) — a much +larger change. Full write-up on issue #750. + +--- + +## Full README classification (all 21 languages) + +The mechanism's real requirement is a **declared return type** to recover the receiver's +type — not "statically typed" (PHP qualifies via its `: self` / `: Type` return +declarations). Against the README's full supported-language list: + +| Bucket | Languages | +|---|---| +| **Covered** (13) | C++, C, PHP, Java, Kotlin, C#, Swift, Rust, Go, Scala, Dart, Objective-C, Pascal/Delphi | +| **Evaluated, skipped** (2) | **TypeScript** — gradual typing → inference-typed factories can't be recovered; net recall regression. **Luau** — gradually typed; additive-safe but +0 on Fusion AND matter (real Luau rarely annotates factory returns). Both: the mechanism needs reliably-declared return types, which gradually-typed code too often omits. | +| **Pascal call-coverage follow-ups** | Two gaps from the chained-call work, both resolved. **Paren-less calls (#793):** Pascal lets a no-arg method drop its parens (`Obj.Free;`, `TFoo.GetInstance.DoIt;`), which parse as a bare `exprDot` and weren't extracted as calls at all. Now extracted, scoped to STATEMENT position (a bare dot in assignment/condition position is left alone — ambiguous with a field/property access). PascalCoin A/B **+1131 / −1**, all new edges resolve to methods. **Free-routine attribution (#795):** a procedure/function defined only in the `implementation` section (no interface decl, not a method) had no node, so its body's calls were lumped under the file; now it gets a function node and its calls attribute to it. PascalCoin A/B **+511 / −145** (file-level aggregates → per-routine edges). | +| **Out of scope — no declared return types** (6) | JavaScript, Ruby, Lua, Svelte, Vue, Liquid (Liquid has no methods/chains at all) | +| **Partial / separate** (1) | Python — only optional `-> T` hints; tracked as #578, not part of this mechanism | + +So #750's original framing ("the 9 statically-typed README languages") was incomplete — +it missed three more typed languages, all now resolved: **Objective-C** shipped (#786, +same wrong-edge gap, mechanism ports directly); **Pascal/Delphi** shipped (#791, a clean +port for the paren'd chain — an initial "blocked" read was wrong, caused by probing only +the paren-less form); **Luau** evaluated and skipped (gradual typing → +0 on real repos, +additive-safe). + +The through-line: this mechanism fits languages with **reliably-declared return types** +(the 13 shipped). Gradually-typed languages (TypeScript, Luau) omit them too often for +it to pay off, and dynamically-typed languages have none. + +--- + +## Edge cases / model +- **Single-hop**: a chain re-encodes one hop; deeper hops (`a.b().c().d()`) keep the + bare name (the inner `()` defeats the `Class::method` split). Re-measure on deep + fluent-builder repos. +- **Validation, not guessing**: every resolver ends in `resolveMethodOnType`, so an + unknown / wrong inferred type produces **no edge** — the decoy / absent-method + guarantee that makes this safe to ship. +- **Per-language receiver gate** keeps instance chains bare so existing resolution is + never regressed; the A/B "removed" counts are wrong-edge corrections, not losses. + +## Related work +- **Dynamic-dispatch / callback synthesis** (a *different* mechanism): observer / + EventEmitter / React-render / JSX-child / django-ORM edge synthesis lives in + `callback-edge-synthesis.md` + `dynamic-dispatch-coverage-playbook.md`. +- The verbose session working-notes for #750 are in + `.claude/handoffs/chained-call-multilang-probe.md` (scratch; this doc is the + permanent record). diff --git a/docs/design/function-ref-capture.md b/docs/design/function-ref-capture.md new file mode 100644 index 000000000..7c8ef733f --- /dev/null +++ b/docs/design/function-ref-capture.md @@ -0,0 +1,226 @@ +# Function-as-value capture (#756) — registration-linking for callbacks + +**Problem.** A function used as a *value* — passed as an argument, assigned to a +function pointer or field, placed in a struct initializer or handler table — +produced **no edge** in any of the 19 tree-sitter languages (probed 2026-06-11; +0/19). `callers(my_recv_cb)` on a C callback showed nothing but direct calls, so +every registered callback looked dead, and the registration sites — the agent's +actual next question ("where is this wired up?") — were invisible. + +**Non-goal, deliberate.** Resolving the *dispatch* (`o->cb(x)` → the concrete +registered function) needs data-flow through struct fields; even an LSP needs +fallbacks there (see the #756 thread). Partial coverage is worse than none and +a wrong edge is worse than silence — dispatch resolution stays uncovered. What +ships is the *registration* side, which is deterministic: the function's name +is literally in the source at the registration site. + +## Mechanism + +``` +capture (tree-sitter.ts walkers, table-driven per language: src/extraction/function-ref.ts) + → gate (flushFnRefCandidates: same-file fn/method name ∪ imported binding names; + C-family file-scope initializers skip the gate — see below) + → unresolved ref, referenceKind 'function_ref' (internal-only kind) + → resolution (resolveOne branch: resolveViaImport first, then matchFunctionRef — + exact name, function/method kinds only, same-family, same-file first, + cross-file only when UNIQUE, never fuzzy) + → edge kind 'references', metadata { fnRef: true, resolvedBy, confidence } +``` + +`getCallers`/`getCallees`/`getImpactRadius` already traverse `references`, so +registration sites surface with no graph-layer changes. The MCP callers/callees +lists label them "via callback registration". + +Capture fires from three walkers (a node is only ever visited by one): +`visitNode` (file/class scope), `visitForCallsAndStructure` (function bodies), +`visitPascalBlock` (Pascal bodies). Subtrees the walkers consume without +descending (top-level variable initializers, class field/property initializers, +custom `visitNode` hooks like Scala's val/var handler) get a candidates-only +`scanFnRefSubtree` that halts at nested function boundaries. + +## Per-language value positions (probe-verified) + +| Language | arg | assign RHS | keyed init | list/table | wrapper forms | +|---|---|---|---|---|---| +| C / ObjC | `argument_list` | `assignment_expression.right` | `initializer_pair.value` | `initializer_list`, `init_declarator.value` | `&fn` (`pointer_expression`), `@selector(...)` (ObjC) | +| C++ | **`&` forms only** in args/rhs/varinit | (same — explicit `&` only) | bare ids at FILE scope only | bare ids at FILE scope only | `&fn`, `&Cls::method` (resolved scoped to the class) | +| TS / JS (tsx/jsx) | `arguments` | `assignment_expression.right` | `pair.value` | `array`, `variable_declarator.value` | `this.method` (`member_expression`, class-scoped — see rule 3) | +| Python | `argument_list`, `keyword_argument.value` | `assignment.right` | `pair.value` | `list` | `self.method` (`attribute`) | +| Go | `argument_list` | `assignment_statement` / `short_var_declaration` (`expression_list`) | `keyed_element` | `literal_value`, `var_spec.value` | — | +| Rust | `arguments` | `assignment_expression.right` | `field_initializer.value` | `array_expression`, `static_item` / `let_declaration.value` | — | +| Java | `argument_list` | `assignment_expression.right` | — | `variable_declarator.value` | `method_reference` (`Cls::m`, `this::m`) — the only form | +| Kotlin | `value_arguments` | `assignment` (last child) | — | — | `callable_reference` (`::f`), `navigation_expression` `this::m` | +| C# | `argument_list` (`argument`) | `assignment_expression.right` (incl. `+=`) | — | `initializer_expression`, `variable_declarator` | `this.M` (`member_access_expression`; vendored grammar keeps `this` anonymous — handled) | +| Ruby | `argument_list` | — | `pair.value` | — | only `method(:sym)` / `&method(:sym)` — bare ids are calls/locals in Ruby | +| Swift | `value_arguments` (`value_argument.value`) | `assignment.result` | (labeled ctor args = args) | `array_literal`, `property_declaration.value` | `#selector(...)` | +| Scala | `arguments` | `assignment_expression.right` | — | `val_definition.value` (via hook scan) | eta `fn _` (`postfix_expression`) | +| Dart | `arguments` (`argument`) | `assignment_expression.right` | `pair.value` | `list_literal`, `static_final_declaration` | — | +| Lua / Luau | `arguments` | `assignment_statement` (`expression_list.value`) | `field.value` (keyed + positional) | (same) | — | +| Pascal | `exprArgs` (via `visitPascalBlock`) | `assignment.rhs` (`OnFire := Handler`) | — | — | `@Handler` (`exprUnary.operand`) | +| PHP | string callables ONLY as args of known core HOFs (`usort`, `array_map`, `call_user_func*`… — `PHP_CALLABLE_HOFS`), ungated + unique-or-drop (PHP globals aren't imported) | — | — | — | `[$this, 'm']` → class-scoped `this.m`; `[Foo::class, 'm']` → qualified; `'Cls::m'` → qualified; first-class callable `fn(...)` already extracts as `calls` | +| Ruby hooks | `(skip_)?(before\|after\|around)_*` + `validate`/`set_callback`/`helper_method`/`rescue_from(with:)` symbols → class-scoped `this.` (rides the supertype pass: `before_action :authenticate` → ApplicationController). `validates` (plural) excluded — its symbols are ATTRIBUTES | — | — | — | symbols under any other call yield nothing | + +## Precision rules (each one bought by a real-repo false positive) + +1. **The gate** (extraction-time): a candidate survives only if its name matches + a same-file function/method or an **imported binding** (`referenceKind === + 'imports'` only — scraping type-annotation `references` names let locals that + shared a type-member's name through; excalidraw). +2. **C-family ungated file scope**: C has no symbol imports and registers + callbacks cross-file at repo scale (redis `server.c`'s command table names + handlers from `t_*.c`). File-scope initializer positions (`value`/`list` + modes) skip the gate — safe because a C file-scope initializer is a + **constant-expression context**: a bare identifier there can only be a + function address (enum/macro names get dropped by the kind filter). Local + initializers and assignments stay gated: `prev = next`, `*str = field`, + `arena_ind_prev = arena_ind` (redis/jemalloc) each matched a unique + same-named function somewhere and produced wrong edges when `rhs`/`varinit` + were ungated. +3. **TS/JS/Python: bare ids resolve to `function` kind only.** A bare + identifier can never be a method value in these languages (methods need a + receiver — `this.m` / `self.m`), so allowing method targets soaked up + locals passed as arguments (`new Set(selectedPointsIndices)`; + docopt.py's `name`/`match` params — excalidraw/fmt A/B findings). + TS/JS `this.X` values are captured as `this.`-PREFIXED candidates and + resolved CLASS-SCOPED (`resolveThisMemberFnRef` in + `src/resolution/index.ts`): the target must be a function/method whose + qualified name shares the from-symbol's class prefix, same file, no + fallback of any kind — `addEventListener(…, this.onResize)` hits the + enclosing class's method; `this.fonts` (a property, post-#808 field + classification) and inherited/unknown members yield no edge. Python's + `self.m` form keeps method targets through its own capture shape. + C#/Swift/Dart/Java/Kotlin keep method targets (method groups, + implicit-self, method references are real method values). +4. **C++ is `&`-explicit** (`addressOfOnly`): bare identifiers qualify only in + FILE-scope initializer tables; everywhere else (args, assignments, local + braced-init lists `{begin, size}`) only `&fn` / `&Cls::method` count. + C++ codebases are dense with generic free-function names (`begin`, `end`, + `out`, `size`, `data`) colliding with locals, and OUT-OF-LINE member + definitions extract as *function*-kind nodes, defeating the kind filter — + bare-id matching on fmt was mostly wrong edges (72 generic-name + 105 + member/macro mismatches → after the rule: 22 edges, ~20 genuine gtest + member-pointer wirings). `&x` vs `*x` share C's `pointer_expression`; only + the `&` operator qualifies. `&Cls::method` resolves SCOPED to that class. +5. **Swift overload-family refusal**: several same-named METHODS in one file + (`Session.request(...)` × N) + a bare identifier = almost always a + same-named parameter, not a method value (Alamofire) — refuse rather than + guess. A unique method (SwiftUI `action: handleTap`) still resolves. +6. **Param-forward skips**: `this.status = status` / `o->cb = cb` (assignment + whose member name equals the RHS identifier) and Swift/Kotlin labeled args + `value: value` — a forwarded local/parameter whose function value is + unknowable; a same-named function elsewhere would be the WRONG target. +7. **Destructuring skip**: `const { center } = ellipse` extracts data, never a + function alias. +8. **Generated/minified files** (`*.min.js` and the codegen patterns in + `generated-detection.ts`) produce no fn-ref candidates — minified + single-letter symbols resolve everywhere (Alamofire's vendored jquery). +9. **Resolution**: function/method kinds only, same language family, never the + ref's own node (no self-loops), same-file match first, cross-file only when + the name is UNIQUE — ambiguity yields **no edge**. No fuzzy fallback, + ever (`matchReference` short-circuits `function_ref` refs to + `matchFunctionRef`). +10. **Runaway invariant** (#760): `matchFunctionRef` always returns + `original: ref` — the stored row — so `deleteSpecificResolvedReferences` + drains the batch. + +## Validation (2026-06-11, EXTRACTION_VERSION 19) + +Stash-free A/B (baseline = worktree at `main`), fresh shallow clones, public +OSS only. Per repo: node count must be identical, `calls` edges identical, +`references` strictly additive, precision spot-checked by reading the source +line of sampled `fnRef` edges. + +Final build, all 17 repos (nodes identical and calls edges untouched on every +row; `unresolved_refs` fully drained — no batched-resolver runaway): + +| Lang | Repo | Nodes (base=fix) | calls Δ | refs gained | Notes | +|---|---|---|---|---|---| +| C | redis | 18931 | 0/0 | **+1918** | 30/30 sample genuine — ops tables, qsort comparators, module registration, lua lib tables | +| TS/React | excalidraw | 10299 | 0/0 | **+121** | 18/20 — residual = param shadowing an imported function (file-level dep real) | +| Go | gin | 2599 | 0/0 | +14 | | +| Rust | bytes | 947 | 0/0 | +76 | `map(fn)`, struct init | +| Java | okhttp | 16008 | 0/0 | +2 | method-ref forms only, by design | +| Kotlin | okio | 7801 | 0/0 | +1 | `::fn` forms only, by design | +| Swift | alamofire | 3477 | 0/0 | +116 | adversarial case (params mirror API names); overload-family + label==name rules applied | +| Python | flask | 2705 | 0/0 | +111 | 8/8 sample genuine — incl. `ensure_sync(self.dispatch_request)` | +| Ruby | sinatra | 1751 | 0/0 | +8 | `method(:sym)` only | +| C# | newtonsoft | 20208 | 0/0 | +38 | method groups, `+=` | +| Scala | scopt | 694 | 0/0 | +10 | eta-expansion | +| Dart | provider | 1154 | 0/0 | +73 | implicit-this getter reads — true same-class dependencies | +| Lua | busted | 1257 | 0/0 | +14 | | +| Luau | fusion | 2126 | 0/0 | +18 | `:Connect(fn)` | +| ObjC | afnetworking | 1487 | 0/0 | +52 | `@selector`, target-action | +| Pascal | pascalcoin | 48788 | 0/0 | +577 | `OnClick :=` event wiring + paren-less-call refs (see limits) | +| C++ | fmt | 7345 | 0/0 | +22 | ~20/22 genuine gtest member-pointer plumbing after addressOfOnly | + +Index cost on redis: +6% time, +5% db size. + +## Known limits (documented, deliberate) + +- **Dispatch resolution** (`o->cb(x)` → implementations): uncovered, see above. +- **C cross-file in gated positions**: an extern callback registered via + *assignment* in a different file than its definition only resolves when the + name is repo-unique (initializer tables don't have this limit — they're + ungated at file scope). +- **C++ bare-name registration** (`register_handler(my_cb)` without `&`): + dropped by `addressOfOnly` — the generic-name collision rate made bare ids + net-negative on real C++ (fmt). `&my_cb` / file-scope tables cover the + idioms; C files keep bare args. +- **Local/param shadowing an imported or same-file function** + (`mutateElement(newElement, …)` where the file also imports `newElement`; + JS plugins' `indexOf(val)` with a same-file `val()` helper): irreducible + without local-scope tracking — the data-flow frontier deliberately left + uncovered. ~1-2 per 20 sampled edges on callback-heavy repos; the file-level + dependency is real in every observed case. +- **Swift same-class param collisions** (`eventMonitor?.request(self, + didFailTask: task…)` where the enclosing type ALSO has a `task` method): + enclosing-type scoping (implicit self — methods match only the from-symbol's + own type, top-level bare ids never match methods) eliminated the CROSS-class + collision class on Alamofire (−44 wrong edges), but a parameter named after + a method of the SAME type is statically indistinguishable from an + implicit-self method value. Residual, documented. +- **Pascal paren-less calls** (`Result := DoInitialize`): captured as + references (Pascal can't distinguish a procedure VALUE from a paren-less + CALL without types). The dependency direction is correct and these calls + were previously invisible entirely (#791) — strictly more truth, imperfect + label. +- **Java/Kotlin method refs through a VARIABLE** (`subscriber::onNext`, + `m::run0`): receiver type unknown statically — deliberately no edge (the + obj.method class). RxJava's baseline bare capture was resolving these to + same-named same-file methods (a test method "registering" an anonymous + class's `onNext`); the qualified rework drops them. `Type::method` resolves + cross-file (scope gated on same-file types ∪ imported names, incl. the last + segment of dotted JVM imports); `this::m` / `super::m` ride the + class-scoped + supertype path. +- **Qualified `Type::member` candidates skip the name gate** (like `this.X`): + Java/Kotlin same-package references and Kotlin companions need NO import, + so the gate could never see their scope — and the explicit-ref syntax is + self-selecting while resolution stays scope-suffix-anchored + + unique-or-drop (a `Decoy::handle` can't match a `KtHandlers::handle` ref). + This is also what resolves companion-member refs: companions extract + TRANSPARENTLY (`KtHandlers::handle`, method of the class) in real + multi-line code. (A single-line `class X { companion object { … } }` is an + upstream tree-sitter-kotlin misparse — ERROR node — and only ever appeared + in our own probe fixture; don't chase it.) +- **Swift cross-file bare references**: Swift sees module-wide symbols without + imports, so cross-file bare callbacks only resolve when repo-unique + (functions; methods are enclosing-type-only). Cross-TYPE `#selector` + targets (rare — target-action is normally self) are scoped away too. +- **`obj.method` member values** where `obj` isn't `this`/`self`: deferred — + the receiver's type is statically unknowable without local data-flow. +- **PHP strings outside known-HOF positions** (a bare `'handler'` to an + arbitrary function; framework registries like WordPress `add_action`): + deliberately uncaptured — a string is only trustworthy as a callable in a + known callable position. Framework registries belong in a `frameworks/` + resolver if ever added. **Ruby symbols outside the hook DSLs** likewise. +- **The supertype pass is NODE-anchored** (file-anchored class node → + implements/extends edge targets → `contains`-anchored member lookup): a + name-keyed `getSupertypes('Engine')` unioned every rails `Engine`'s parents + and produced a cross-class wrong edge; the node walk eliminated it + (rails +440 → +385, all sampled edges genuine). +- **`this.X` inherited members resolve through the supertype pass** + (`resolveDeferredThisMemberRefs`, depth-capped BFS over implements/extends, + runs after edges persist — same lifecycle as the #750 conformance pass). + Reading a getter into a local (`const s = this.snapshot`) still produces a + references edge to the getter — a true dependency with an imperfect + "registration" flavor. diff --git a/scripts/agent-eval/ab-adoption.sh b/scripts/agent-eval/ab-adoption.sh new file mode 100644 index 000000000..d5c6dc222 --- /dev/null +++ b/scripts/agent-eval/ab-adoption.sh @@ -0,0 +1,91 @@ +#!/usr/bin/env bash +# Does the agent PICK codegraph_node to read a file, vs the built-in Read tool? +# Build A/B: NEW build (HEAD, codegraph_node has Read parity) vs BASELINE build +# (a ref where it doesn't), BOTH codegraph-attached + pre-warmed, same task. The +# metric is tool CHOICE: Read calls vs codegraph_node[file] calls per run. +# +# Usage: ab-adoption.sh "" [runs-per-arm] [baseline-ref] +# Env: AGENT_EVAL_OUT (default: /tmp/ab-adoption) +set -uo pipefail +TARGET="${1:?usage: ab-adoption.sh \"\" [runs] [baseline-ref]}" +TASK="${2:?task required}" +RUNS="${3:-2}" +BASE_REF="${4:-HEAD~1}" +ENGINE="$(cd "$(dirname "$0")/../.." && pwd)" +BIN="$ENGINE/dist/bin/codegraph.js" +OUT="${AGENT_EVAL_OUT:-/tmp/ab-adoption}" + +command -v claude >/dev/null || { echo "claude CLI not on PATH"; exit 1; } +[ -d "$TARGET/.codegraph" ] || { echo "target not indexed: run 'codegraph init $TARGET' first"; exit 1; } +git -C "$ENGINE" diff --quiet && git -C "$ENGINE" diff --cached --quiet || { echo "engine has uncommitted changes — commit/stash first"; exit 1; } +CHANGED=$(git -C "$ENGINE" diff --name-only "$BASE_REF" HEAD -- src 2>/dev/null) +[ -n "$CHANGED" ] || { echo "no src/ changes between $BASE_REF and HEAD"; exit 1; } + +cleanup() { + pkill -9 -f "serve --mcp --path $OUT/" 2>/dev/null + git -C "$ENGINE" checkout HEAD -- $CHANGED 2>/dev/null + ( cd "$ENGINE" && npm run build >/dev/null 2>&1 ) +} +trap cleanup EXIT +mkdir -p "$OUT" +echo "###### target=$TARGET runs/arm=$RUNS baseline=$BASE_REF" +echo "###### changed: $(echo "$CHANGED" | tr '\n' ' ')" +echo "###### task=$TASK"; echo + +prewarm() { + pkill -9 -f "serve --mcp --path $1" 2>/dev/null + CODEGRAPH_DAEMON_IDLE_TIMEOUT_MS=1800000 node "$BIN" serve --mcp --path "$1" /dev/null 2>&1 & + node -e 'const fs=require("fs");let n=0;const t=setInterval(()=>{if(fs.existsSync(process.argv[1]+"/.codegraph/daemon.sock")){clearInterval(t);process.exit(0)}if(n++>150){clearInterval(t);process.exit(1)}},100)' "$1" >/dev/null 2>&1 +} + +# Per-run tool-choice counts: Read vs codegraph_node[file] vs [symbol]. +count() { + node -e ' + const fs=require("fs"); + const lines=fs.readFileSync(process.argv[1],"utf8").split("\n").filter(Boolean); + let read=0,cgFile=0,cgSym=0,cgOther=0,exposed="?"; + for(const l of lines){try{const o=JSON.parse(l); + if(o.type==="system"&&o.subtype==="init"){exposed=(o.tools||[]).filter(t=>/codegraph/.test(t)).length;} + const blocks=o.message?.content||[]; + for(const b of (Array.isArray(blocks)?blocks:[])){ + if(b.type!=="tool_use")continue; + if(b.name==="Read")read++; + else if(b.name==="mcp__codegraph__codegraph_node"){ if(b.input&&b.input.symbol)cgSym++; else cgFile++; } + else if(/mcp__codegraph__/.test(b.name))cgOther++; + } + }catch{}} + console.log(` Read=${read} codegraph_node[file]=${cgFile} codegraph_node[symbol]=${cgSym} other_cg=${cgOther} (cg exposed=${exposed})`); + ' "$1" +} + +run_arm() { # label, N + local label="$1" n="$2" + local c="$OUT/mcp-$label.json" + for i in $(seq 1 "$n"); do + local tgt="$OUT/t-$label-$i" + rm -rf "$tgt" + rsync -a --exclude node_modules --exclude .git --exclude dist --exclude .codegraph "$TARGET/" "$tgt/" + node "$BIN" init "$tgt" >/dev/null 2>&1 + printf '{"mcpServers":{"codegraph":{"command":"env","args":["CODEGRAPH_WASM_RELAUNCHED=1","node","%s","serve","--mcp","--path","%s"]}}}' "$BIN" "$tgt" > "$c" + prewarm "$tgt" + echo "----- [$label] run $i -----" + ( cd "$tgt" && claude -p "$TASK" \ + --output-format stream-json --verbose --permission-mode bypassPermissions \ + --model "${MODEL:-sonnet}" --effort "${EFFORT:-high}" --max-budget-usd 4 --strict-mcp-config --mcp-config "$c" \ + "$OUT/run-$label-$i.jsonl" 2>"$OUT/run-$label-$i.err" ) + count "$OUT/run-$label-$i.jsonl" + pkill -9 -f "serve --mcp --path $tgt" 2>/dev/null + done + echo +} + +echo "== NEW build (HEAD: codegraph_node has Read parity) ==" +( cd "$ENGINE" && npm run build >/dev/null 2>&1 ) && echo "built" +run_arm new "$RUNS" + +echo "== BASELINE build ($BASE_REF) ==" +git -C "$ENGINE" checkout "$BASE_REF" -- $CHANGED +( cd "$ENGINE" && npm run build >/dev/null 2>&1 ) && echo "built" +run_arm baseline "$RUNS" + +echo "###### DONE — compare [new] vs [baseline]: does codegraph_node[file] rise / Read fall? Logs: $OUT" diff --git a/scripts/agent-eval/ab-hook.sh b/scripts/agent-eval/ab-hook.sh new file mode 100644 index 000000000..0e46fccbd --- /dev/null +++ b/scripts/agent-eval/ab-hook.sh @@ -0,0 +1,86 @@ +#!/usr/bin/env bash +# A/B the PreToolUse(Read) REDIRECT hook (P1): does steering Read → codegraph_node +# file-view actually move the agent off Read during implementation? BOTH arms use +# the CURRENT build with codegraph attached and pre-warmed; the only difference is +# the hook. Isolates the hook's behavioral effect from the build/file-view change +# (use ab-new-vs-baseline.sh for the build A/B). +# +# arm [nohook] — codegraph on, no hook (does the better file-view get picked on its own?) +# arm [hook] — codegraph on, + redirect hook (does routing close it?) +# +# Reliable attach (works nested): each arm pre-warms a persistent daemon and skips +# the startup re-exec (CODEGRAPH_WASM_RELAUNCHED=1), so claude connects before the +# agent's first turn. Judge by ACTUAL codegraph usage in parse-run.mjs's "by type", +# not claude's init snapshot (which can read pending even when it then connects). +# +# Usage: ab-hook.sh "" [runs-per-arm] +# a repo with a .codegraph index (copied per arm; never mutated) +# "" a GENUINELY-NEW implementation task (verify it isn't already done) +# [runs-per-arm] default 2 (n=1 is noisy — the doctrine says >=2) +# Env: AGENT_EVAL_OUT (default: /tmp/ab-hook) +set -uo pipefail + +TARGET="${1:?usage: ab-hook.sh \"\" [runs-per-arm]}" +TASK="${2:?task required}" +RUNS="${3:-2}" +ENGINE="$(cd "$(dirname "$0")/../.." && pwd)" +BIN="$ENGINE/dist/bin/codegraph.js" +HOOK="$ENGINE/scripts/agent-eval/redirect-read-hook.sh" +OUT="${AGENT_EVAL_OUT:-/tmp/ab-hook}" +PARSE="$ENGINE/scripts/agent-eval/parse-run.mjs" + +command -v claude >/dev/null || { echo "claude CLI not on PATH"; exit 1; } +command -v jq >/dev/null || { echo "jq not on PATH (the hook needs it)"; exit 1; } +[ -d "$TARGET/.codegraph" ] || { echo "target not indexed: run 'codegraph init $TARGET' first"; exit 1; } +chmod +x "$HOOK" + +cleanup() { pkill -9 -f "serve --mcp --path $OUT/" 2>/dev/null; } +trap cleanup EXIT + +mkdir -p "$OUT" +echo "###### engine=$ENGINE" +echo "###### target=$TARGET runs/arm=$RUNS" +echo "###### task=$TASK" +echo + +( cd "$ENGINE" && npm run build >/dev/null 2>&1 ) && echo "built" + +# A settings file carrying ONLY the PreToolUse(Read) redirect hook. +HOOK_SETTINGS="$OUT/hook-settings.json" +jq -n --arg cmd "bash $HOOK" \ + '{hooks:{PreToolUse:[{matcher:"Read",hooks:[{type:"command",command:$cmd}]}]}}' > "$HOOK_SETTINGS" + +prewarm() { # target — spawn a persistent daemon and wait for its socket + pkill -9 -f "serve --mcp --path $1" 2>/dev/null + CODEGRAPH_DAEMON_IDLE_TIMEOUT_MS=1800000 node "$BIN" serve --mcp --path "$1" /dev/null 2>&1 & + node -e 'const fs=require("fs");let n=0;const t=setInterval(()=>{if(fs.existsSync(process.argv[1]+"/.codegraph/daemon.sock")){clearInterval(t);process.exit(0)}if(n++>150){clearInterval(t);process.exit(1)}},100)' "$1" \ + && echo " daemon warm: $1" || echo " WARN: daemon never bound for $1" +} + +run_one() { # arm-label, run-index, use-hook(0|1) + local label="$1" idx="$2" hook="$3" + local tgt="$OUT/t-$label-$idx" c="$OUT/mcp-$label.json" + rm -rf "$tgt" + rsync -a --exclude node_modules --exclude .git --exclude dist --exclude .codegraph "$TARGET/" "$tgt/" + node "$BIN" init "$tgt" >/dev/null 2>&1 + printf '{"mcpServers":{"codegraph":{"command":"env","args":["CODEGRAPH_WASM_RELAUNCHED=1","node","%s","serve","--mcp","--path","%s"]}}}' "$BIN" "$tgt" > "$c" + prewarm "$tgt" + local extra=() + [ "$hook" = "1" ] && extra=(--settings "$HOOK_SETTINGS") + echo "----- [$label] run $idx -----" + # ${extra[@]+...} guard: bash 3.2 (macOS) under `set -u` errors on an empty + # array expansion otherwise, which would skip the no-hook arm's claude run. + ( cd "$tgt" && claude -p "$TASK" \ + --output-format stream-json --verbose --permission-mode bypassPermissions \ + --model "${MODEL:-sonnet}" --effort "${EFFORT:-high}" --max-budget-usd 4 --strict-mcp-config --mcp-config "$c" ${extra[@]+"${extra[@]}"} \ + "$OUT/run-$label-$idx.jsonl" 2>"$OUT/run-$label-$idx.err" ) + node "$PARSE" "$OUT/run-$label-$idx.jsonl" 2>&1 | grep -E "by type|Result" || echo " (parse failed — see $OUT/run-$label-$idx.jsonl)" + pkill -9 -f "serve --mcp --path $tgt" 2>/dev/null + echo +} + +for i in $(seq 1 "$RUNS"); do run_one nohook "$i" 0; done +for i in $(seq 1 "$RUNS"); do run_one hook "$i" 1; done + +echo "###### DONE. Compare [nohook] vs [hook] 'by type' — Read should fall and" +echo "###### mcp__codegraph__codegraph_node should rise in the [hook] arm. Logs: $OUT" diff --git a/scripts/agent-eval/ab-impl.sh b/scripts/agent-eval/ab-impl.sh new file mode 100644 index 000000000..b6f219b14 --- /dev/null +++ b/scripts/agent-eval/ab-impl.sh @@ -0,0 +1,78 @@ +#!/usr/bin/env bash +# Sufficiency A/B for an IMPLEMENTATION task (the agent edits): when it uses +# codegraph (explore/node) to understand before editing, does it still Read? Like +# ab-sufficiency.sh but copies+indexes a FRESH target per run (the agent mutates +# it), so runs don't see each other's edits. +# +# WITH codegraph (pre-warmed) vs WITHOUT (empty MCP), N runs each. Reports +# explore/node vs Read/Grep + the files Read, and whether the build still passes. +# +# Usage: ab-impl.sh "" [runs] [build-cmd] +# Env: AGENT_EVAL_OUT (default: /tmp/ab-impl) +set -uo pipefail +REPO="${1:?usage: ab-impl.sh \"\" [runs] [build-cmd]}" +Q="${2:?task required}" +RUNS="${3:-2}" +BUILD_CMD="${4:-}" +ENGINE="$(cd "$(dirname "$0")/../.." && pwd)" +BIN="$ENGINE/dist/bin/codegraph.js" +OUT="${AGENT_EVAL_OUT:-/tmp/ab-impl}" +command -v claude >/dev/null || { echo "claude CLI not on PATH"; exit 1; } +[ -d "$REPO/.codegraph" ] || { echo "no .codegraph index at $REPO"; exit 1; } +cleanup(){ pkill -9 -f "serve --mcp --path $OUT/" 2>/dev/null; } +trap cleanup EXIT +mkdir -p "$OUT" +( cd "$ENGINE" && npm run build >/dev/null 2>&1 ) && echo "built engine" +echo "###### repo=$REPO runs/arm=$RUNS" +echo "###### task=$Q"; echo +echo '{"mcpServers":{}}' > "$OUT/mcp-empty.json" + +prewarm(){ + pkill -9 -f "serve --mcp --path $1" 2>/dev/null + CODEGRAPH_DAEMON_IDLE_TIMEOUT_MS=1800000 node "$BIN" serve --mcp --path "$1" /dev/null 2>&1 & + node -e 'const fs=require("fs");let n=0;const t=setInterval(()=>{if(fs.existsSync(process.argv[1]+"/.codegraph/daemon.sock")){clearInterval(t);process.exit(0)}if(n++>150){clearInterval(t);process.exit(1)}},100)' "$1" >/dev/null 2>&1 +} + +analyze(){ + node -e ' + const fs=require("fs"); + const L=fs.readFileSync(process.argv[1],"utf8").split("\n").filter(Boolean); + let ex=0,nf=0,ns=0,oc=0,gr=0,ed=0,exposed="?";const reads=[]; + for(const l of L){try{const o=JSON.parse(l); + if(o.type==="system"&&o.subtype==="init")exposed=(o.tools||[]).filter(t=>/codegraph/.test(t)).length; + for(const b of (o.message?.content||[])){if(b.type!=="tool_use")continue; + if(b.name==="mcp__codegraph__codegraph_explore")ex++; + else if(b.name==="mcp__codegraph__codegraph_node"){if(b.input&&b.input.symbol)ns++;else nf++;} + else if(/mcp__codegraph__/.test(b.name))oc++; + else if(b.name==="Read")reads.push((b.input?.file_path||"").split("/").pop()); + else if(b.name==="Grep")gr++; + else if(b.name==="Edit"||b.name==="Write")ed++; + }}catch{}} + console.log(` explore=${ex} node[sym]=${ns} node[file]=${nf} other_cg=${oc} | Read=${reads.length}${reads.length?" ("+reads.join(", ")+")":""} Grep=${gr} Edit=${ed} [cg exposed=${exposed}]`); + ' "$1" +} + +run(){ # label, withCodegraph(0/1) + local label="$1" wcg="$2" + for i in $(seq 1 "$RUNS"); do + local tgt="$OUT/t-$label-$i" cfg="$OUT/mcp-$label.json" + rm -rf "$tgt" + rsync -a --exclude node_modules --exclude .git --exclude dist --exclude .codegraph "$REPO/" "$tgt/" + node "$BIN" init "$tgt" >/dev/null 2>&1 + if [ "$wcg" = "1" ]; then + printf '{"mcpServers":{"codegraph":{"command":"env","args":["CODEGRAPH_WASM_RELAUNCHED=1","node","%s","serve","--mcp","--path","%s"]}}}' "$BIN" "$tgt" > "$cfg" + prewarm "$tgt" + else cp "$OUT/mcp-empty.json" "$cfg"; fi + ( cd "$tgt" && claude -p "$Q" --output-format stream-json --verbose \ + --permission-mode bypassPermissions --model "${MODEL:-sonnet}" --effort "${EFFORT:-high}" --max-budget-usd 4 \ + --strict-mcp-config --mcp-config "$cfg" "$OUT/$label-$i.jsonl" 2>"$OUT/$label-$i.err" ) + echo "[$label] run $i:"; analyze "$OUT/$label-$i.jsonl" + if [ -n "$BUILD_CMD" ]; then ( cd "$tgt" && eval "$BUILD_CMD" >/dev/null 2>&1 && echo " build: PASS" || echo " build: FAIL" ); fi + pkill -9 -f "serve --mcp --path $tgt" 2>/dev/null + done + echo +} + +echo "== WITH codegraph =="; run with 1 +echo "== WITHOUT (Read/Grep only) =="; run without 0 +echo "###### DONE: $OUT" diff --git a/scripts/agent-eval/ab-new-vs-baseline.sh b/scripts/agent-eval/ab-new-vs-baseline.sh index 7f5d58d1d..b7fe4e768 100755 --- a/scripts/agent-eval/ab-new-vs-baseline.sh +++ b/scripts/agent-eval/ab-new-vs-baseline.sh @@ -2,18 +2,20 @@ # A/B a codegraph retrieval/steering change: the NEW build (current HEAD) vs a # BASELINE build (a git ref) — BOTH with codegraph attached — on the same # implementation task, measuring how many Read vs codegraph calls the agent -# makes. This ISOLATES the change (unlike run-all.sh, which is with-vs-without -# codegraph). The agent works on a throwaway copy of the target, so its edits -# never touch your repos. +# makes. ISOLATES the change (unlike run-all.sh's with-vs-without). The agent +# works on a throwaway copy of the target, so your repos are never touched. # -# *** RUN THIS IN A REAL TERMINAL — NOT nested inside a Claude Code session. *** -# A `claude -p` spawned from within another Claude session (e.g. from a Bash -# tool call) cannot reliably attach the codegraph MCP server: the server is -# healthy (full handshake ~165ms) but the nested client marks it -# status:"pending" / 0 tools under CPU/timing contention, and degrades to -# consistent failure over a long session. NO_DAEMON + `< /dev/null` do NOT fix -# it — it's the nested client, not the server. See codegraph/CLAUDE.md -# ("Running agent-evals — do NOT nest"). +# Reliable attach (works even when this is itself run nested inside a Claude +# session): each arm PRE-WARMS a persistent codegraph daemon for its target so +# claude connects to an already-bound, index-loaded daemon instantly — before +# the agent's first turn — and SKIPS codegraph's startup re-exec via +# CODEGRAPH_WASM_RELAUNCHED=1. Without this, on a multi-step task the agent +# dives into Read/grep before codegraph finishes its ~2-3s startup (worse under +# the CPU contention of a nested run) and runs with NO codegraph. +# +# Gotcha: claude's `system/init` snapshot can read status:"pending" / 0 tools +# even when the server then connects fine — judge by ACTUAL codegraph usage in +# parse-run.mjs's "by type", not the init line. # # Usage: ab-new-vs-baseline.sh "" [baseline-ref] # a repo with a .codegraph index (copied per arm) @@ -38,9 +40,13 @@ fi CHANGED=$(git -C "$ENGINE" diff --name-only "$BASE_REF" HEAD -- src 2>/dev/null) [ -n "$CHANGED" ] || { echo "no src/ changes between $BASE_REF and HEAD — nothing to A/B"; exit 1; } -# Always restore the engine to HEAD on exit, even if interrupted mid-arm. -restore() { git -C "$ENGINE" checkout HEAD -- $CHANGED 2>/dev/null; ( cd "$ENGINE" && npm run build >/dev/null 2>&1 ); } -trap restore EXIT +# On exit: kill any eval daemons + restore the engine to HEAD. +cleanup() { + pkill -9 -f "serve --mcp --path $OUT/" 2>/dev/null + git -C "$ENGINE" checkout HEAD -- $CHANGED 2>/dev/null + ( cd "$ENGINE" && npm run build >/dev/null 2>&1 ) +} +trap cleanup EXIT mkdir -p "$OUT" echo "###### engine=$ENGINE baseline=$BASE_REF" @@ -54,17 +60,25 @@ rm -rf "$OUT/t-new" "$OUT/t-base" rsync -a --exclude node_modules --exclude .git --exclude dist --exclude .codegraph "$TARGET/" "$OUT/t-new/" cp -R "$OUT/t-new" "$OUT/t-base" -cfg() { printf '{"mcpServers":{"codegraph":{"command":"%s","args":["serve","--mcp","--path","%s"]}}}' "$BIN" "$1" > "$2"; } +prewarm() { # target — spawn a persistent daemon (current $BIN) and wait for its socket + pkill -9 -f "serve --mcp --path $1" 2>/dev/null + CODEGRAPH_DAEMON_IDLE_TIMEOUT_MS=1800000 node "$BIN" serve --mcp --path "$1" /dev/null 2>&1 & + node -e 'const fs=require("fs");let n=0;const t=setInterval(()=>{if(fs.existsSync(process.argv[1]+"/.codegraph/daemon.sock")){clearInterval(t);process.exit(0)}if(n++>150){clearInterval(t);process.exit(1)}},100)' "$1" \ + && echo " daemon warm: $1" || echo " WARN: daemon never bound for $1 (arm may run without codegraph)" +} run_arm() { # label, target-copy local label="$1" tgt="$2" c="$OUT/mcp-$1.json" - cfg "$tgt" "$c" + # Connect to the pre-warmed daemon; skip the startup re-exec for a fast attach. + printf '{"mcpServers":{"codegraph":{"command":"env","args":["CODEGRAPH_WASM_RELAUNCHED=1","node","%s","serve","--mcp","--path","%s"]}}}' "$BIN" "$tgt" > "$c" + prewarm "$tgt" echo "############## ARM [$label] ##############" ( cd "$tgt" && claude -p "$TASK" \ --output-format stream-json --verbose --permission-mode bypassPermissions \ - --model opus --max-budget-usd 4 --strict-mcp-config --mcp-config "$c" \ - < /dev/null > "$OUT/run-$label.jsonl" 2>"$OUT/run-$label.err" ) - node "$PARSE" "$OUT/run-$label.jsonl" 2>&1 | grep -E "tools exposed|by type|Result" || echo " (parse failed — see $OUT/run-$label.jsonl)" + --model "${MODEL:-sonnet}" --effort "${EFFORT:-high}" --max-budget-usd 4 --strict-mcp-config --mcp-config "$c" \ + "$OUT/run-$label.jsonl" 2>"$OUT/run-$label.err" ) + node "$PARSE" "$OUT/run-$label.jsonl" 2>&1 | grep -E "by type|Result" || echo " (parse failed — see $OUT/run-$label.jsonl)" + pkill -9 -f "serve --mcp --path $tgt" 2>/dev/null echo } diff --git a/scripts/agent-eval/ab-sufficiency.sh b/scripts/agent-eval/ab-sufficiency.sh new file mode 100644 index 000000000..3db4ff1f6 --- /dev/null +++ b/scripts/agent-eval/ab-sufficiency.sh @@ -0,0 +1,78 @@ +#!/usr/bin/env bash +# Sufficiency A/B: on a real understanding/flow question, WHEN the agent uses +# codegraph (explore/node), does it still Read? Premise under test: explore/node +# return source WITH line numbers, so a Read should not be needed. +# +# WITH codegraph (pre-warmed daemon, reliable nested attach) vs WITHOUT (empty +# MCP, Read/Grep only), N runs each, on a throwaway copy of the repo. Reports +# explore/node vs Read/Grep, and LISTS the files Read in the WITH arm so a true +# sufficiency gap (an indexed source file) is distinguishable from out-of-scope +# (configs, docs, a file codegraph didn't index). +# +# Usage: ab-sufficiency.sh "" [runs-per-arm] +# Env: AGENT_EVAL_OUT (default: /tmp/ab-sufficiency) +set -uo pipefail +REPO="${1:?usage: ab-sufficiency.sh \"\" [runs]}" +Q="${2:?question required}" +RUNS="${3:-2}" +ENGINE="$(cd "$(dirname "$0")/../.." && pwd)" +BIN="$ENGINE/dist/bin/codegraph.js" +OUT="${AGENT_EVAL_OUT:-/tmp/ab-sufficiency}" +TGT="$OUT/target" +command -v claude >/dev/null || { echo "claude CLI not on PATH"; exit 1; } +[ -d "$REPO/.codegraph" ] || { echo "no .codegraph index at $REPO"; exit 1; } +cleanup(){ pkill -9 -f "serve --mcp --path $TGT" 2>/dev/null; } +trap cleanup EXIT +mkdir -p "$OUT" +( cd "$ENGINE" && npm run build >/dev/null 2>&1 ) && echo "built" + +# Throwaway copy + fresh index (the agent works here; a read-only question won't +# edit, but isolate anyway). Excludes the source repo's index/build/vcs. +rm -rf "$TGT" +rsync -a --exclude node_modules --exclude .git --exclude dist --exclude .codegraph "$REPO/" "$TGT/" +node "$BIN" init "$TGT" >/dev/null 2>&1 && echo "indexed copy ($(node "$BIN" status --json 2>/dev/null | node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>{try{console.log(JSON.parse(s).fileCount+" files")}catch{console.log("?")}})' 2>/dev/null || echo '?'))" + +echo "###### repo=$REPO runs/arm=$RUNS" +echo "###### Q=$Q"; echo +echo '{"mcpServers":{}}' > "$OUT/mcp-empty.json" +printf '{"mcpServers":{"codegraph":{"command":"env","args":["CODEGRAPH_WASM_RELAUNCHED=1","node","%s","serve","--mcp","--path","%s"]}}}' "$BIN" "$TGT" > "$OUT/mcp-cg.json" + +prewarm(){ + pkill -9 -f "serve --mcp --path $TGT" 2>/dev/null + CODEGRAPH_DAEMON_IDLE_TIMEOUT_MS=1800000 node "$BIN" serve --mcp --path "$TGT" /dev/null 2>&1 & + node -e 'const fs=require("fs");let n=0;const t=setInterval(()=>{if(fs.existsSync(process.argv[1]+"/.codegraph/daemon.sock")){clearInterval(t);process.exit(0)}if(n++>150){clearInterval(t);process.exit(1)}},100)' "$TGT" >/dev/null 2>&1 +} + +analyze(){ + node -e ' + const fs=require("fs"); + const L=fs.readFileSync(process.argv[1],"utf8").split("\n").filter(Boolean); + let ex=0,nf=0,ns=0,oc=0,gr=0,exposed="?";const reads=[]; + for(const l of L){try{const o=JSON.parse(l); + if(o.type==="system"&&o.subtype==="init")exposed=(o.tools||[]).filter(t=>/codegraph/.test(t)).length; + for(const b of (o.message?.content||[])){if(b.type!=="tool_use")continue; + if(b.name==="mcp__codegraph__codegraph_explore")ex++; + else if(b.name==="mcp__codegraph__codegraph_node"){if(b.input&&b.input.symbol)ns++;else nf++;} + else if(/mcp__codegraph__/.test(b.name))oc++; + else if(b.name==="Read")reads.push((b.input?.file_path||"").split("/").pop()); + else if(b.name==="Grep")gr++; + }}catch{}} + console.log(` explore=${ex} node[sym]=${ns} node[file]=${nf} other_cg=${oc} | Read=${reads.length}${reads.length?" ("+reads.join(", ")+")":""} Grep=${gr} [cg exposed=${exposed}]`); + ' "$1" +} + +run(){ # label, cfg, prewarm(0/1) + local label="$1" cfg="$2" pw="$3" + for i in $(seq 1 "$RUNS"); do + [ "$pw" = "1" ] && prewarm + ( cd "$TGT" && claude -p "$Q" --output-format stream-json --verbose \ + --permission-mode bypassPermissions --model "${MODEL:-sonnet}" --effort "${EFFORT:-high}" --max-budget-usd 4 \ + --strict-mcp-config --mcp-config "$cfg" "$OUT/$label-$i.jsonl" 2>"$OUT/$label-$i.err" ) + echo "[$label] run $i:"; analyze "$OUT/$label-$i.jsonl" + done + echo +} + +echo "== WITH codegraph (premise: explore/node used -> Read ~0) =="; run with "$OUT/mcp-cg.json" 1 +echo "== WITHOUT (Read/Grep only — the contrast) =="; run without "$OUT/mcp-empty.json" 0 +echo "###### DONE. In the WITH arm: are explore/node>0 and Read~0? Any Read of an INDEXED source file = sufficiency gap. Logs: $OUT" diff --git a/scripts/agent-eval/bench-why-repo.sh b/scripts/agent-eval/bench-why-repo.sh index 2bbedf8fc..2e26a2ffc 100644 --- a/scripts/agent-eval/bench-why-repo.sh +++ b/scripts/agent-eval/bench-why-repo.sh @@ -15,7 +15,7 @@ printf '{"mcpServers":{"codegraph":{"command":"%s","args":["serve","--mcp","--pa for i in $(seq 1 "$N"); do pkill -f "serve --mcp" 2>/dev/null; sleep 1; rm -f "$REPO/.codegraph/daemon.sock" ( cd "$REPO" && claude -p "$Q$WHY" --output-format stream-json --verbose \ - --permission-mode bypassPermissions --model opus --effort "${EFFORT:-high}" --max-budget-usd 4 \ + --permission-mode bypassPermissions --model "${MODEL:-sonnet}" --effort "${EFFORT:-high}" --max-budget-usd 4 \ --strict-mcp-config --mcp-config "$OUT/cg.json" > "$OUT/with$i.jsonl" 2>"$OUT/with$i.err" ) echo "WITH run $i: exit $? ($(wc -l < "$OUT/with$i.jsonl" | tr -d ' ') lines)" done diff --git a/scripts/agent-eval/redirect-read-hook.sh b/scripts/agent-eval/redirect-read-hook.sh new file mode 100755 index 000000000..3dce75652 --- /dev/null +++ b/scripts/agent-eval/redirect-read-hook.sh @@ -0,0 +1,38 @@ +#!/usr/bin/env bash +# PreToolUse(Read) REDIRECT hook — prototype for A/B (P1: get agents off Read and +# onto codegraph_node during implementation, not just for Q&A). +# +# When the agent Reads a SOURCE file, deny it and steer to codegraph_node's +# file-view, which (as of the Lever-1 change) returns the WHOLE file verbatim +# WITH line numbers — imports, top-level code, comments and all — PLUS the file's +# blast radius, in one call. That output is a strict superset of Read, so the +# redirect is lossless: the agent loses nothing by taking it, and gains who- +# depends-on-this for the edit it's about to make. +# +# Differs from block-read-hook.sh (which steers to explore/node-by-symbol): this +# names the FILE-VIEW path explicitly (file:"" + includeCode:true), the +# 1:1 Read replacement we're trying to get picked during implementation. +# +# Non-source files (configs, docs, lockfiles, .env) pass through to a real Read. +# A redirect to a file codegraph hasn't indexed SELF-CORRECTS: the file-view +# replies "No indexed file matches … Read it directly", so a just-created file +# never dead-ends — the agent Reads it on the next turn. +# +# Wire via: claude ... --settings +# Eval artifact only. The production version is an indexed-aware `codegraph` +# subcommand (cross-platform — no bash/jq — and queries the index so it never +# bounces a new/un-indexed file), wired opt-in by the installer. +set -uo pipefail +input="$(cat)" +fp="$(printf '%s' "$input" | jq -r '.tool_input.file_path // empty' 2>/dev/null)" +[ -n "$fp" ] || exit 0 +base="$(basename "$fp")" + +case "$fp" in + *.ts|*.tsx|*.js|*.jsx|*.mjs|*.cjs|*.py|*.go|*.rs|*.java|*.rb|*.php|*.swift|*.kt|*.kts|*.scala|*.c|*.cc|*.cpp|*.h|*.hpp|*.cs|*.lua|*.vue|*.svelte|*.m|*.mm) + msg="codegraph has this file indexed (kept in sync on every edit). Call codegraph_node with file:\"$base\" and includeCode:true instead of Read — it returns the WHOLE file verbatim WITH line numbers (imports, top-level code and all — safe to base an Edit on) PLUS which files depend on it, in one call. Treat its output as already-Read; do not Read this file. (If it answers that the file isn't indexed — e.g. you just created it — then Read it directly.)" + jq -n --arg m "$msg" '{hookSpecificOutput:{hookEventName:"PreToolUse",permissionDecision:"deny",permissionDecisionReason:$m}}' + exit 0 + ;; +esac +exit 0 diff --git a/scripts/agent-eval/run-agent.sh b/scripts/agent-eval/run-agent.sh index b599c43b3..abbee8726 100755 --- a/scripts/agent-eval/run-agent.sh +++ b/scripts/agent-eval/run-agent.sh @@ -25,7 +25,7 @@ cd "$REPO" || exit 1 claude -p "$PROMPT" \ --output-format stream-json --verbose \ --permission-mode bypassPermissions \ - --model opus \ + --model "${MODEL:-sonnet}" --effort "${EFFORT:-high}" \ --max-budget-usd 2 \ --strict-mcp-config --mcp-config "$MCP_CONFIG" \ > "$OUT" 2>"$OUT_DIR/run-${LABEL}.err" diff --git a/scripts/agent-eval/run-all.sh b/scripts/agent-eval/run-all.sh index 4b40dce9c..b68292591 100755 --- a/scripts/agent-eval/run-all.sh +++ b/scripts/agent-eval/run-all.sh @@ -7,6 +7,8 @@ # Usage: run-all.sh "" [headless|tmux|all] # Env: CG_BIN codegraph binary (default: command -v codegraph) # AGENT_EVAL_OUT output dir (default: /tmp/agent-eval) +# MODEL / EFFORT claude model/effort (default: sonnet / high — the +# standing A/B policy; see CLAUDE.md, don't raise) set -uo pipefail REPO="${1:?usage: run-all.sh \"\" [headless|tmux|all]}" @@ -39,7 +41,7 @@ headless() { ( cd "$REPO" && claude -p "$Q" \ --output-format stream-json --verbose \ --permission-mode bypassPermissions \ - --model opus \ + --model "${MODEL:-sonnet}" --effort "${EFFORT:-high}" \ --max-budget-usd 4 \ --strict-mcp-config --mcp-config "$cfg" \ > "$OUT/run-$label.jsonl" 2>"$OUT/run-$label.err" ) @@ -56,11 +58,11 @@ fi if [ "$MODE" = tmux ] || [ "$MODE" = all ]; then echo "############################## INTERACTIVE [with] ##############################" - CLAUDE_EXTRA_ARGS="--model opus --strict-mcp-config --mcp-config $OUT/mcp-codegraph.json" \ + CLAUDE_EXTRA_ARGS="--model ${MODEL:-sonnet} --effort ${EFFORT:-high} --strict-mcp-config --mcp-config $OUT/mcp-codegraph.json" \ bash "$HARNESS/itrun.sh" "$REPO" "int-with" "$Q" 2>&1 || echo "[itrun WITH failed]" echo echo "############################## INTERACTIVE [without] ##############################" - CLAUDE_EXTRA_ARGS="--model opus --strict-mcp-config --mcp-config $OUT/mcp-empty.json" \ + CLAUDE_EXTRA_ARGS="--model ${MODEL:-sonnet} --effort ${EFFORT:-high} --strict-mcp-config --mcp-config $OUT/mcp-empty.json" \ bash "$HARNESS/itrun.sh" "$REPO" "int-without" "$Q" 2>&1 || echo "[itrun WITHOUT failed]" echo fi diff --git a/scripts/agent-eval/run-arms.sh b/scripts/agent-eval/run-arms.sh index af3da6dc5..48d4cf856 100755 --- a/scripts/agent-eval/run-arms.sh +++ b/scripts/agent-eval/run-arms.sh @@ -48,7 +48,7 @@ fi LOG="$OUT/$ARM-r$RID.jsonl"; ERR="$OUT/$ARM-r$RID.err" ARGS=( -p "$Q" --output-format stream-json --verbose - --permission-mode bypassPermissions --model opus --max-budget-usd 4 + --permission-mode bypassPermissions --model "${MODEL:-sonnet}" --effort "${EFFORT:-high}" --max-budget-usd 4 --strict-mcp-config --mcp-config "$CFG" ) [ -n "$STEERING" ] && ARGS+=( --append-system-prompt "$STEERING" ) diff --git a/src/bin/codegraph.ts b/src/bin/codegraph.ts index bd667738b..1dbbca210 100644 --- a/src/bin/codegraph.ts +++ b/src/bin/codegraph.ts @@ -356,7 +356,7 @@ function printIndexResult(clack: typeof import('@clack/prompts'), result: IndexR clack.log.info(`The index is fully usable ${getGlyphs().dash} only the failed files are missing.`); } } else if (projectPath) { - const logPath = path.join(projectPath, '.codegraph', 'errors.log'); + const logPath = path.join(getCodeGraphDir(projectPath), 'errors.log'); if (fs.existsSync(logPath)) { fs.unlinkSync(logPath); } @@ -367,7 +367,7 @@ function printIndexResult(clack: typeof import('@clack/prompts'), result: IndexR * Write detailed error log to .codegraph/errors.log */ function writeErrorLog(projectPath: string, errors: Array<{ message: string; filePath?: string; severity: string; code?: string }>): void { - const cgDir = path.join(projectPath, '.codegraph'); + const cgDir = getCodeGraphDir(projectPath); if (!fs.existsSync(cgDir)) return; const logPath = path.join(cgDir, 'errors.log'); @@ -896,6 +896,106 @@ program } }); +/** + * codegraph explore + * + * The CLI face of the MCP codegraph_explore tool — same handler, same + * output (source of the relevant symbols grouped by file + the call path + * among them). Exists so agents WITHOUT the MCP tools — Task-tool + * subagents (which don't inherit MCP tools, #704) and non-MCP harnesses — + * can reach the graph through a plain shell command. + */ +program + .command('explore ') + .description('Explore an area: relevant symbols\' source + call paths in one shot (same output as the codegraph_explore MCP tool)') + .option('-p, --path ', 'Project path') + .option('--max-files ', 'Maximum number of files to include source from') + .action(async (queryParts: string[], options: { path?: string; maxFiles?: string }) => { + const projectPath = resolveProjectPath(options.path); + + try { + if (!isInitialized(projectPath)) { + error(`CodeGraph isn't available here — no .codegraph/ index exists in ${projectPath}. If you are an AI agent: continue with your usual tools; indexing is the user's decision, do not run it yourself. (The project owner can enable CodeGraph with 'codegraph init'.)`); + process.exit(1); + } + + const { default: CodeGraph } = await loadCodeGraph(); + const cg = await CodeGraph.open(projectPath); + const { ToolHandler } = await import('../mcp/tools'); + const handler = new ToolHandler(cg); + + const args: Record = { query: queryParts.join(' ') }; + if (options.maxFiles) args.maxFiles = parseInt(options.maxFiles, 10); + const result = await handler.execute('codegraph_explore', args); + + console.log(result.content[0]?.text ?? ''); + cg.destroy(); + if (result.isError) process.exit(1); + } catch (err) { + error(`Explore failed: ${err instanceof Error ? err.message : String(err)}`); + process.exit(1); + } + }); + +/** + * codegraph node + * + * The CLI face of the MCP codegraph_node tool: one symbol's source + + * caller/callee trail, or a whole file with line numbers + dependents + * (Read-parity). Same subagent/non-MCP rationale as `explore`. + */ +program + .command('node ') + .description('One symbol\'s source + caller/callee trail, or read a file with line numbers + dependents (same output as the codegraph_node MCP tool)') + .option('-p, --path ', 'Project path') + .option('-f, --file ', 'Treat as file mode (or disambiguate a symbol to this file)') + .option('--offset ', 'File mode: 1-based start line') + .option('--limit ', 'File mode: maximum lines') + .option('--symbols-only', 'File mode: just the symbol map + dependents') + .action(async (name: string, options: { path?: string; file?: string; offset?: string; limit?: string; symbolsOnly?: boolean }) => { + const projectPath = resolveProjectPath(options.path); + + try { + if (!isInitialized(projectPath)) { + error(`CodeGraph isn't available here — no .codegraph/ index exists in ${projectPath}. If you are an AI agent: continue with your usual tools; indexing is the user's decision, do not run it yourself. (The project owner can enable CodeGraph with 'codegraph init'.)`); + process.exit(1); + } + + const { default: CodeGraph } = await loadCodeGraph(); + const cg = await CodeGraph.open(projectPath); + const { ToolHandler } = await import('../mcp/tools'); + const handler = new ToolHandler(cg); + + // A name with a path separator is a file read; otherwise a symbol + // (use --file for basename-only file reads or to pin an overload). + // Both separators: Windows users type src\auth\session.ts. Symbols + // never contain either ('/' isn't an identifier char anywhere we + // index; C++ scope is '::', JS members '.'). + const args: Record = {}; + if (options.file) { + args.file = options.file; + if (name && name !== options.file) args.symbol = name; + } else if (name.includes('/') || name.includes('\\')) { + args.file = name.replace(/\\/g, '/'); + } else { + args.symbol = name; + args.includeCode = true; + } + if (options.offset) args.offset = parseInt(options.offset, 10); + if (options.limit) args.limit = parseInt(options.limit, 10); + if (options.symbolsOnly) args.symbolsOnly = true; + + const result = await handler.execute('codegraph_node', args); + + console.log(result.content[0]?.text ?? ''); + cg.destroy(); + if (result.isError) process.exit(1); + } catch (err) { + error(`Node lookup failed: ${err instanceof Error ? err.message : String(err)}`); + process.exit(1); + } + }); + /** * codegraph files [path] */ diff --git a/src/db/index.ts b/src/db/index.ts index cbc08b8f0..e6d52d47a 100644 --- a/src/db/index.ts +++ b/src/db/index.ts @@ -9,6 +9,7 @@ import * as fs from 'fs'; import * as path from 'path'; import { SchemaVersion } from '../types'; import { runMigrations, getCurrentVersion, CURRENT_SCHEMA_VERSION } from './migrations'; +import { getCodeGraphDir } from '../directory'; export { SqliteDatabase, SqliteBackend } from './sqlite-adapter'; @@ -240,5 +241,5 @@ export const DATABASE_FILENAME = 'codegraph.db'; * Get the default database path for a project */ export function getDatabasePath(projectRoot: string): string { - return path.join(projectRoot, '.codegraph', DATABASE_FILENAME); + return path.join(getCodeGraphDir(projectRoot), DATABASE_FILENAME); } diff --git a/src/db/migrations.ts b/src/db/migrations.ts index 1a8d1c542..bfea9024d 100644 --- a/src/db/migrations.ts +++ b/src/db/migrations.ts @@ -9,7 +9,7 @@ import { SqliteDatabase } from './sqlite-adapter'; /** * Current schema version */ -export const CURRENT_SCHEMA_VERSION = 4; +export const CURRENT_SCHEMA_VERSION = 5; /** * Migration definition @@ -65,6 +65,16 @@ const migrations: Migration[] = [ `); }, }, + { + version: 5, + description: + 'Add nodes.return_type — normalized return/result type for receiver-type inference (C++ singletons/factories, #645)', + up: (db) => { + db.exec(` + ALTER TABLE nodes ADD COLUMN return_type TEXT; + `); + }, + }, ]; /** diff --git a/src/db/queries.ts b/src/db/queries.ts index 3f35c5b04..adf239268 100644 --- a/src/db/queries.ts +++ b/src/db/queries.ts @@ -72,6 +72,7 @@ interface NodeRow { is_abstract: number; decorators: string | null; type_parameters: string | null; + return_type: string | null; updated_at: number; } @@ -133,6 +134,7 @@ function rowToNode(row: NodeRow): Node { isAbstract: row.is_abstract === 1, decorators: row.decorators ? safeJsonParse(row.decorators, undefined) : undefined, typeParameters: row.type_parameters ? safeJsonParse(row.type_parameters, undefined) : undefined, + returnType: row.return_type ?? undefined, updatedAt: row.updated_at, }; } @@ -174,6 +176,12 @@ function rowToFileRecord(row: FileRow): FileRecord { export class QueryBuilder { private db: SqliteDatabase; + // Project-name tokens (go.mod / package.json / repo dir), normalized. A query + // word matching one is dropped from path-relevance scoring — it names the + // whole project, not a symbol, so it carries no discriminative signal (#720). + // Set once by the CodeGraph instance; empty by default (no down-weighting). + private projectNameTokens: Set = new Set(); + // Node cache for frequently accessed nodes (LRU-style, max 1000 entries) private nodeCache: Map = new Map(); private readonly maxCacheSize = 1000; @@ -217,6 +225,17 @@ export class QueryBuilder { this.db = db; } + /** Set the normalized project-name tokens used to down-weight non-discriminative + * query words in path scoring (#720). Called once when the project opens. */ + setProjectNameTokens(tokens: Set): void { + this.projectNameTokens = tokens; + } + + /** The normalized project-name tokens (#720); empty if none were derived. */ + getProjectNameTokens(): Set { + return this.projectNameTokens; + } + // =========================================================================== // Node Operations // =========================================================================== @@ -232,13 +251,13 @@ export class QueryBuilder { start_line, end_line, start_column, end_column, docstring, signature, visibility, is_exported, is_async, is_static, is_abstract, - decorators, type_parameters, updated_at + decorators, type_parameters, return_type, updated_at ) VALUES ( @id, @kind, @name, @qualifiedName, @filePath, @language, @startLine, @endLine, @startColumn, @endColumn, @docstring, @signature, @visibility, @isExported, @isAsync, @isStatic, @isAbstract, - @decorators, @typeParameters, @updatedAt + @decorators, @typeParameters, @returnType, @updatedAt ) `); } @@ -281,6 +300,7 @@ export class QueryBuilder { isAbstract: node.isAbstract ? 1 : 0, decorators: node.decorators ? JSON.stringify(node.decorators) : null, typeParameters: node.typeParameters ? JSON.stringify(node.typeParameters) : null, + returnType: node.returnType ?? null, updatedAt: node.updatedAt ?? Date.now(), }); } @@ -321,6 +341,7 @@ export class QueryBuilder { is_abstract = @isAbstract, decorators = @decorators, type_parameters = @typeParameters, + return_type = @returnType, updated_at = @updatedAt WHERE id = @id `); @@ -355,6 +376,7 @@ export class QueryBuilder { isAbstract: node.isAbstract ? 1 : 0, decorators: node.decorators ? JSON.stringify(node.decorators) : null, typeParameters: node.typeParameters ? JSON.stringify(node.typeParameters) : null, + returnType: node.returnType ?? null, updatedAt: node.updatedAt ?? Date.now(), }); } @@ -837,7 +859,7 @@ export class QueryBuilder { ...r, score: r.score + kindBonus(r.node.kind) - + scorePathRelevance(r.node.filePath, scoringQuery) + + scorePathRelevance(r.node.filePath, scoringQuery, this.projectNameTokens) + nameMatchBonus(r.node.name, scoringQuery), })); results.sort((a, b) => b.score - a.score); diff --git a/src/db/schema.sql b/src/db/schema.sql index b08c34f37..292981c82 100644 --- a/src/db/schema.sql +++ b/src/db/schema.sql @@ -37,6 +37,7 @@ CREATE TABLE IF NOT EXISTS nodes ( is_abstract INTEGER DEFAULT 0, decorators TEXT, -- JSON array type_parameters TEXT, -- JSON array + return_type TEXT, -- normalized return/result type name (e.g. C++ method return, for receiver-type inference) updated_at INTEGER NOT NULL ); diff --git a/src/directory.ts b/src/directory.ts index 3a5c91d93..1c7729a42 100644 --- a/src/directory.ts +++ b/src/directory.ts @@ -7,16 +7,82 @@ import * as fs from 'fs'; import * as path from 'path'; +/** The default per-project data directory name. */ +const DEFAULT_CODEGRAPH_DIR = '.codegraph'; + +let warnedBadDirName = false; + +/** + * Resolve the per-project data directory name, honoring the `CODEGRAPH_DIR` + * environment override (default `.codegraph`). The override is a single path + * segment that lives in the project root. + * + * Why this exists: two environments that share one working tree must NOT share + * one `.codegraph/` — most concretely Windows-native and WSL (issue #636). The + * daemon lockfile (`.codegraph/daemon.pid`) records a platform-specific pid and + * socket path (a Windows named pipe vs a WSL Unix socket), and SQLite file + * locking across the WSL2 ↔ Windows filesystem boundary is unreliable, so two + * daemons sharing one index risks corruption. Setting `CODEGRAPH_DIR=.codegraph-win` + * on one side gives each environment its own index in the same tree. + * + * Read live (not captured at load) so it is both process-accurate and testable. + * An override that isn't a plain directory name — empty, containing a path + * separator, `.`, `..`/traversal, or absolute — is ignored (we keep the + * default) rather than risk writing the index outside the project or into the + * project root itself; we warn once to stderr so the misconfiguration is seen. + */ +export function codeGraphDirName(): string { + const raw = process.env.CODEGRAPH_DIR?.trim(); + if (!raw) return DEFAULT_CODEGRAPH_DIR; + const invalid = + raw === '.' || + raw.includes('..') || + raw.includes('/') || + raw.includes('\\') || + path.isAbsolute(raw); + if (invalid) { + if (!warnedBadDirName) { + warnedBadDirName = true; + // stderr only — stdout is the MCP protocol channel. + console.warn( + `[codegraph] Ignoring invalid CODEGRAPH_DIR="${raw}" — it must be a plain ` + + `directory name (no path separators, no "..", not absolute). Using "${DEFAULT_CODEGRAPH_DIR}".` + ); + } + return DEFAULT_CODEGRAPH_DIR; + } + return raw; +} + +/** + * CodeGraph directory name — a load-time snapshot of {@link codeGraphDirName}. + * A running process's environment is fixed, so this equals the live value; + * it's kept as a stable string export for backward compatibility. Internal code + * resolves the name through {@link codeGraphDirName} / {@link getCodeGraphDir} + * so the `CODEGRAPH_DIR` override always applies. + */ +export const CODEGRAPH_DIR = codeGraphDirName(); + /** - * CodeGraph directory name + * Is `name` (a single path segment) a CodeGraph data directory? Matches the + * default `.codegraph`, the active `CODEGRAPH_DIR` override, and any + * `.codegraph-*` sibling. File-watching and the indexer skip ALL of these, so + * when two environments share one working tree (Windows + WSL, issue #636) + * neither indexes or watches the other's index directory. */ -export const CODEGRAPH_DIR = '.codegraph'; +export function isCodeGraphDataDir(name: string): boolean { + return ( + name === DEFAULT_CODEGRAPH_DIR || + name === codeGraphDirName() || + name.startsWith(DEFAULT_CODEGRAPH_DIR + '-') + ); +} /** * Get the .codegraph directory path for a project */ export function getCodeGraphDir(projectRoot: string): string { - return path.join(projectRoot, CODEGRAPH_DIR); + return path.join(projectRoot, codeGraphDirName()); } /** @@ -63,6 +129,61 @@ export function findNearestCodeGraphRoot(startPath: string): string | null { return null; } +/** + * Contents of `.codegraph/.gitignore`. A single wildcard ignore keeps every + * transient file in the index dir — the database, `daemon.pid`, the socket, + * logs, cache, and anything future versions add — out of git, without having + * to enumerate each name (issues #788, #492, #484). Older versions wrote an + * explicit allowlist that never listed `daemon.pid` or the socket, so those + * runtime files were silently committed. + */ +const GITIGNORE_CONTENT = `# CodeGraph data files — local to each machine, not for committing. +# Ignore everything in .codegraph/ except this file itself, so transient +# files (the database, daemon.pid, sockets, logs) never show up in git. +* +!.gitignore +`; + +/** Header line that prefixes every .gitignore CodeGraph has auto-generated. */ +const GITIGNORE_MARKER = '# CodeGraph data files'; + +/** + * Is `content` a stale CodeGraph-generated `.gitignore` that should be + * regenerated in place? True when it carries our header but predates the + * wildcard ignore (it has no bare `*` line) — i.e. one of the old explicit + * allowlists (`*.db`, `cache/`, `.dirty`, …) that never ignored `daemon.pid` + * or the socket (issue #788). A file WITHOUT our header is user-authored and + * is left untouched; one that already has the wildcard is current. Matching + * on the header (not a byte-exact list of past defaults) heals every old + * variant — v0.7.x through 0.9.9 — and is idempotent once upgraded. + */ +function isStaleDefaultGitignore(content: string): boolean { + if (!content.trimStart().startsWith(GITIGNORE_MARKER)) return false; + return !content.split('\n').some((line) => line.trim() === '*'); +} + +/** + * Write `.codegraph/.gitignore` if it's absent, or upgrade a stale + * CodeGraph-generated default in place; a user-customized file is left alone. + * Best-effort — returns `false` only if a needed write failed. + */ +function ensureGitignore(gitignorePath: string): boolean { + let existing: string | null; + try { + existing = fs.readFileSync(gitignorePath, 'utf-8'); + } catch { + existing = null; // absent (ENOENT) or unreadable — (re)create below + } + // Current default or a user-authored file: nothing to do. + if (existing !== null && !isStaleDefaultGitignore(existing)) return true; + try { + fs.writeFileSync(gitignorePath, GITIGNORE_CONTENT, 'utf-8'); + return true; + } catch { + return false; + } +} + /** * Create the .codegraph directory structure * Note: Only throws if codegraph.db already exists, not just if .codegraph/ exists. @@ -80,18 +201,9 @@ export function createDirectory(projectRoot: string): void { // Create main directory (if it doesn't exist) fs.mkdirSync(codegraphDir, { recursive: true }); - // Create .gitignore inside .codegraph (if it doesn't exist) - const gitignorePath = path.join(codegraphDir, '.gitignore'); - if (!fs.existsSync(gitignorePath)) { - const gitignoreContent = `# CodeGraph data files — local to each machine, not for committing. -# Ignore everything in .codegraph/ except this file itself, so transient -# files (the database, daemon.pid, sockets, logs) never show up in git. -* -!.gitignore -`; - - fs.writeFileSync(gitignorePath, gitignoreContent, 'utf-8'); - } + // Write .gitignore inside .codegraph (create if absent, upgrade a stale + // pre-wildcard default left by an older version — issue #788). + ensureGitignore(path.join(codegraphDir, '.gitignore')); } /** @@ -230,16 +342,15 @@ export function validateDirectory(projectRoot: string): { return { valid: false, errors }; } - // Auto-repair missing .gitignore (non-critical file) + // Auto-repair / upgrade .gitignore (non-critical file). A missing one is + // recreated; a stale pre-wildcard default that never ignored daemon.pid is + // regenerated in place (issue #788); a user-authored file is left alone. const gitignorePath = path.join(codegraphDir, '.gitignore'); - if (!fs.existsSync(gitignorePath)) { - try { - const gitignoreContent = `# CodeGraph data files — local to each machine, not for committing.\n# Ignore everything in .codegraph/ except this file itself, so transient\n# files (the database, daemon.pid, sockets, logs) never show up in git.\n*\n!.gitignore\n`; - fs.writeFileSync(gitignorePath, gitignoreContent, 'utf-8'); - } catch { - // Non-fatal: warn but don't block - errors.push('.gitignore missing in .codegraph directory and could not be created'); - } + const existedBefore = fs.existsSync(gitignorePath); + if (!ensureGitignore(gitignorePath) && !existedBefore) { + // Only a missing-and-uncreatable file is surfaced; a failed in-place + // upgrade of an existing file is non-fatal — the index still works. + errors.push('.gitignore missing in .codegraph directory and could not be created'); } return { diff --git a/src/extraction/astro-extractor.ts b/src/extraction/astro-extractor.ts new file mode 100644 index 000000000..e38989375 --- /dev/null +++ b/src/extraction/astro-extractor.ts @@ -0,0 +1,365 @@ +import { Node, Edge, ExtractionResult, ExtractionError, UnresolvedReference } from '../types'; +import { generateNodeId } from './tree-sitter-helpers'; +import { TreeSitterExtractor } from './tree-sitter'; +import { isLanguageSupported } from './grammars'; + +/** + * Astro built-in components — compiler-provided (``) or shipped by + * `astro:components` (``, ``), not user code. + */ +const ASTRO_BUILTIN_COMPONENTS = new Set(['Fragment', 'Code', 'Debug']); + +/** + * AstroExtractor - Extracts code relationships from Astro component files + * + * Astro files are multi-language: a TypeScript frontmatter block fenced by + * `---` lines, a JSX-like HTML template, and optional