Merge remote-tracking branch 'origin/main' into fix/kotlin-property-initializer-walk

# Conflicts:
#	CHANGELOG.md
This commit is contained in:
danusha2345
2026-09-08 17:31:08 +03:00
21 changed files with 1301 additions and 67 deletions
+1 -1
View File
@@ -105,7 +105,7 @@ Defined in `src/types.ts`. Both extractors and resolvers must use these exact st
- `targets/types.ts` defines the `AgentTarget` interface — adding a 5th agent (Continue, Zed, Windsurf…) is **one new file in `targets/` + one entry in `registry.ts`**. Each target owns its config-file location and MCP-server JSON/TOML/JSONC writing. (Targets no longer write an instructions file — see below.)
- Current targets: `claude.ts`, `cursor.ts`, `codex.ts`, `opencode.ts`.
- `targets/toml.ts` is a hand-rolled TOML serializer scoped to `[mcp_servers.codegraph]` (used by Codex). Sibling tables and `[[array_of_tables]]` are preserved verbatim. No new dependency.
- opencode reads `opencode.jsonc` by default; the installer prefers existing `.jsonc`, falls back to `.json`, and creates `.jsonc` for greenfield installs. Edits are surgical via `jsonc-parser` so user comments and formatting survive install/re-install/uninstall round-trips.
- opencode reads `opencode.jsonc` by default; the installer prefers existing `.jsonc`, falls back to `.json`, and creates `.jsonc` for greenfield installs. Edits are surgical via `jsonc-parser` so user comments and formatting survive install/re-install/uninstall round-trips. The MCP entry is OpenCode 2's native `mcp.servers.codegraph` with `disabled: false` and `codemode: false` (so `codegraph_explore` stays on the native tool list); a pre-#1698 `mcp.codegraph` + `enabled` entry is migrated on re-install and removed by uninstall.
- `instructions-template.ts` no longer holds an instructions body — it exports only the `<!-- CODEGRAPH_START -->`/`<!-- CODEGRAPH_END -->` markers. The installer **stopped writing** a `## CodeGraph` block into each agent's instructions file (`CLAUDE.md` / `~/.codex/AGENTS.md` / `~/.config/opencode/AGENTS.md` / `~/.gemini/GEMINI.md` / `.cursor/rules/codegraph.mdc` / Kiro steering doc) because it duplicated the MCP `initialize` instructions verbatim (issue #529). Each target's `install` (self-heal on upgrade) and `uninstall` use the markers to **strip** a block a previous install left behind. `server-instructions.ts` is the single source of truth for agent-facing guidance.
- All installer changes need matching coverage in `__tests__/installer-targets.test.ts` — there are ~47 parameterized contract tests covering install idempotency, sibling preservation, uninstall reverses install, byte-equal re-runs returning `unchanged`, and partial-state recovery for Codex.
+7
View File
@@ -222,12 +222,15 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
- Kotlin `init { }` blocks and destructuring declarations no longer swallow their code: `init { val cfg = load() }` and `val (a, b) = makePair()` contributed no call edge at all, and now attribute to the enclosing class or file.
- A Kotlin property's accessor body now belongs to the property whichever line it is written on, instead of being dropped (same line) or handed to the enclosing class (own line).
- Kotlin properties that hold a lambda, a SAM callback or an anonymous object — `private val frameListener = CameraFrameListener { … }`, the way Android and MSDK callbacks are almost always declared — now contribute call edges. Previously everything inside such an initializer was dropped, so a function reached only through one of these callbacks looked like it had no callers at all and its blast radius came back far too small. Delegated properties (`by lazy { … }`) and plain initializers (`val x = compute()`) were affected the same way and are fixed too. Re-index with `codegraph index -f` after upgrading to pick up the new edges.
- Python parameters annotated with a quoted forward reference — `def f(o: "Alpha")`, or anything under `from __future__ import annotations` — now resolve the methods called on them, the same as the unquoted annotation. Re-index Python projects after upgrading. (#1684)
- **A C macro call written with designated initializers no longer swallows every function after it.** Betaflight resets each config struct with `RESET_CONFIG(type, dst, .field = value, …)`, a shape the C grammar cannot parse; past a hundred or so fields its error recovery ran the enclosing function to the end of the file, the next function vanished from the index and every later one was filed under the first, where name matching then treated it as an unreachable closure. The argument list of such a call is now blanked before parsing, offsets kept, so the file's functions come out with their real extents. On that tree 45 functions in `pid.c` alone moved back to top level and their 117 callers resolve at exact-match confidence. Re-index after upgrading. (#1729)
- A method called on the result of another call — `d.setdefault(k, []).append(v)`, `make().run()` — no longer produces a call edge to an unrelated top-level function that merely shares the name, in Python and JavaScript/TypeScript. The receiver is kept so the inner call still resolves; the outer method stays unresolved rather than guessed. Re-index after upgrading. (#1683, #1681)
- A Python call through an imported project module whose name collides with a builtin collection method — `ledger.append(row)` after `from . import ledger` — is no longer dropped as `list.append`. The builtin-method filter now lets the receiver through when it is an imported module that resolves to a file in the project, so `resolveViaImport` can attach the real edge; a stdlib/PyPI receiver (`os.remove`) still produces none. Re-index after upgrading. (#1681, via #1704)
- **A definition its language makes file-local no longer captures calls from other files.** A C `static` in another source file (`.c`/`.cc`/… — not a header's `static inline`, which is textually included), a Kotlin/Java/C#/Swift/Scala/Dart/PHP `private` member, a Go unexported name in another package, and a Rust non-`pub` item outside its module subtree cannot be what a name in another file means, but name matching accepted them whenever the names agreed: an Android `editor.apply()` onto an unrelated class's `private fun apply`, a JavaScript `fail(...)` onto a Go `func fail`, a Rust `.count()` onto a private `fn count` in another crate, and C USB helpers onto a `static` in a `.c` they never link. Such a target is now declined after the whole name-matching pipeline settles — the reference stays unresolved rather than falling through to a fuzzy namesake. Same-file definitions, a child Rust module reaching its ancestors' private items, and Rust `impl Trait for Type` methods stay resolvable. Re-index after upgrading. (#1730, #1731)
- **A binding in a module that exports nothing is no longer a cross-file target.** On vite, every `import { defineConfig } from 'vite'` across the playground resolved onto a `const vite = await createServer(…)` sitting at module scope in `playground/ssr-html/test-stacktrace.js` — a file with an import and no export, so that binding is reachable from nowhere but itself. Name matching commits as soon as one candidate survives, and nothing asked whether an import could reach the survivor; that one binding took 157 edges. A JS/TS file holding an `import` and no export of any kind now offers its locals to no other file. Classic scripts, CommonJS (including `exports["x"] = …`), a later `export { … }`, and names contributed through `declare global` are all unaffected. Across vite this removed 320 wrong edges and added 18, each addition a reference that was previously ambiguous rather than newly invented. Re-index after upgrading. (#1719)
- **A bare call inside a JavaScript or TypeScript method no longer resolves to the method itself.** When a method and a module-scope function share a name, `serialize(this.raw)` written inside `Record.serialize` means the function, but the nearest same-named definition won the tie and the graph recorded the method calling itself. A call written without a receiver can never reach a method in JS/TS, so methods are no longer candidates for it; `this.serialize()` and `other.serialize()` resolve as before. (#1714)
- **Fuzzy matching no longer lands on a closure it cannot reach.** A function nested inside another function is only callable from inside its container, and exact-name matching already declined such candidates; the fuzzy fallback did not, so a builtin method call (`res.text()`, `items.push()`) whose only same-named project symbol was some file's closure resolved onto that closure. The fallback now checks that the one candidate it would commit to is reachable, and declines otherwise — it does not filter the candidate list first, which would turn a crowd of same-named definitions into a single "unique" survivor and hand it every call of that name. On vite that removes the 12 edges onto nested functions and adds none. Re-index after upgrading. Thanks @bompus. (#1708, #1709)
- **An import that names the emitted extension resolves to its source.** Under `moduleResolution: node16 | nodenext | bundler` TypeScript requires `import { x } from './util.js'` for `util.ts`, and no file of that name exists, so the import resolver returned nothing and every name imported that way fell through to bare-name matching: a method wrapping the same-named helper it imports (`renderDockStyles() { return renderDockStyles(); }`) resolved to itself, and cross-module edges in such projects were name guesses. `.js` / `.jsx` / `.mjs` / `.cjs` specifiers now retry with the source extensions TypeScript compiles from when the emitted file is absent; a real `.js` beside the `.ts` still wins. On a 582-file repo whose `.ts` files import this way, import-backed `calls`/`imports` edges went from 4,002 to 7,312 and the eight wrapper-method self-edges disappeared. Re-index after upgrading. Thanks @bompus. (#1705, #1706)
- **Files under an `e2e/` directory count as tests.** Their calls no longer appear as production callers in Steps, dead-code and test badges.
@@ -241,6 +244,10 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
- **The Map covers a multi-root project.** A React Native app's `ios/` beside its `src/` — or any second root holding a fifth of the code — is now on the picture, one level deeper, instead of the map silently drawing only the larger root.
- **The Claude Code prompt hook's context now arrives inline.** The hook capped its injection at 16,000 characters, but Claude Code shows hook output inline only up to 10,000 and otherwise persists it to a file with a 2 KB preview, so on any repo where explore filled the cap the model saw a file path and the first 2 KB. The cap is now 9,000 characters, under the limit with room for the wrapper. (#1694)
- **`codegraph_explore` is loaded from the first prompt in Claude Code.** Claude Code defers every MCP tool behind a tool-search step, so a fresh session saw only the tool's name until the model searched for it, and the server's "call `codegraph_explore` instead of Read" had nothing loaded to act on. The tool now carries `anthropic/alwaysLoad` in its `_meta`, which exempts it on existing installs, and `codegraph install` writes `alwaysLoad: true` on the Claude Code server entry (re-run it to add the key). Copilot CLI's tool search holds MCP tools back the same way once ~30 tools are connected, so its entry now carries `deferTools: "never"`. (#1696)
- Fixed a long-running `codegraph ui` session serving a symbol that a sync had already deleted. The viewer keeps one connection to your index open, and its in-memory lookup didn't notice when another process — your agent's sync, or `codegraph sync` — rewrote the file underneath it, so a symbol screen could keep showing a body with no callers while search correctly reported it had moved. Because a symbol's identity includes the line it starts on, this happened after almost any edit above it.
## [1.6.0] - 2026-08-26
+5 -2
View File
@@ -508,12 +508,15 @@ npm install -g @colbymchenry/codegraph
"codegraph": {
"type": "stdio",
"command": "codegraph",
"args": ["serve", "--mcp"]
"args": ["serve", "--mcp"],
"alwaysLoad": true
}
}
}
```
`alwaysLoad` keeps `codegraph_explore` loaded from the first prompt. Claude Code otherwise defers every MCP tool behind a tool-search step, so a fresh session sees only the tool's name until the model searches for it.
**Add to `~/.claude/settings.json` (optional, for auto-allow):**
```json
{
@@ -854,7 +857,7 @@ is written):
- **Claude Code**
- **Cursor**
- **Codex CLI**
- **opencode**
- **opencode** — MCP entry is OpenCode 2's `mcp.servers.codegraph` with `codemode: false` (keeps `codegraph_explore` on the native tool list; `codegraph install` migrates the older `mcp.codegraph` shape)
- **Hermes Agent**
- **Gemini CLI**
- **Antigravity IDE**
@@ -0,0 +1,153 @@
/**
* Regression for #1711 — when codegraph_explore trims a file, elided symbols
* must be named (gap markers + header bias), not left as a bare `... (gap) ...`
* while the footer asks for "exact names" the model was never given.
*/
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/index';
import {
ToolHandler,
formatGapMarker,
symbolsBetweenRanges,
biasHeaderSymbols,
joinPartsWithNamedGaps,
} from '../src/mcp/tools';
describe('#1711 helpers — name what a trim dropped', () => {
it('formatGapMarker stays bare when the hole has no symbols', () => {
expect(formatGapMarker('a.ts', [])).toBe('\n\n... (gap) ...\n\n');
});
it('formatGapMarker lists name (file:line) for elided symbols', () => {
const marker = formatGapMarker('src/obs.ts', [
{ name: 'syncStateNow', kind: 'method', startLine: 1913 },
{ name: 'performHeavyDraftSync', kind: 'method', startLine: 1867 },
]);
expect(marker).toContain('syncStateNow (src/obs.ts:1913)');
expect(marker).toContain('performHeavyDraftSync (src/obs.ts:1867)');
expect(marker).toMatch(/\.\.\. \(gap: .+\) \.\.\./);
});
it('symbolsBetweenRanges only returns defs that start in the hole', () => {
const nodes = [
{ name: 'keep', kind: 'method', startLine: 10, endLine: 20 },
{ name: 'elided', kind: 'method', startLine: 30, endLine: 40 },
{ name: 'also', kind: 'method', startLine: 45, endLine: 50 },
{ name: 'later', kind: 'method', startLine: 60, endLine: 70 },
{ name: 'imp', kind: 'import', startLine: 35, endLine: 35 },
];
const hit = symbolsBetweenRanges(nodes, 20, 60);
expect(hit.map((h) => h.name)).toEqual(['elided', 'also']);
});
it('biasHeaderSymbols prefers elided labels over frequency alone', () => {
const { shown } = biasHeaderSymbols(
[
'imports0(method)', 'imports0(method)', 'imports0(method)',
'imports1(method)', 'imports1(method)',
'noise(method)',
],
[{ name: 'syncStateNow', kind: 'method', startLine: 100 }],
3,
);
expect(shown[0]).toBe('syncStateNow(method)');
expect(shown).toContain('imports0(method)');
});
it('joinPartsWithNamedGaps annotates the hole between parts', () => {
const text = joinPartsWithNamedGaps(
'f.ts',
[
{ range: { start: 1, end: 5 }, text: 'ONE' },
{ range: { start: 40, end: 45 }, text: 'TWO' },
],
[{ name: 'mid', kind: 'function', startLine: 20, endLine: 25 }],
);
expect(text).toContain('ONE');
expect(text).toContain('TWO');
expect(text).toContain('mid (f.ts:20)');
});
});
describe('#1711 explore — trimmed file names its elisions', () => {
let dir: string;
let cg: CodeGraph;
let response: string;
beforeAll(async () => {
dir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-1711-'));
fs.writeFileSync(path.join(dir, 'package.json'), '{"name":"cg1711","version":"1.0.0"}\n');
const srcDir = path.join(dir, 'src');
fs.mkdirSync(srcDir);
// One large observer + noise files so the budget trims rather than shipping whole.
const lines: string[] = ['export class EspnDraftObserver {'];
for (let i = 0; i < 40; i++) {
lines.push(` imports${i}() { return ${i}; }`, '');
}
const big = (name: string, next: string | null, n: number) => {
lines.push(` ${name}() {`);
lines.push(` const marker = "${name}_MARKER";`);
for (let j = 0; j < n; j++) lines.push(` const x${j} = ${j} + marker.length;`);
lines.push(next ? ` return this.${next}();` : ' return marker;');
lines.push(' }', '');
};
big('persistDraftState', null, 60);
big('performHeavyDraftSync', 'persistDraftState', 60);
big('syncStateNow', 'performHeavyDraftSync', 60);
big('scrapeFullDraftState', 'syncStateNow', 60);
for (let i = 0; i < 30; i++) {
lines.push(` calls${i}() { return ${i}; }`, '');
}
lines.push('}', '');
fs.writeFileSync(path.join(srcDir, 'espn-draft-observer.ts'), lines.join('\n'));
for (let i = 1; i <= 20; i++) {
fs.writeFileSync(path.join(srcDir, `noise${i}.ts`), `export const n${i} = ${i};\n`);
}
cg = CodeGraph.initSync(dir);
await cg.indexAll();
const result = await new ToolHandler(cg).execute('codegraph_explore', {
query:
'In this repos ESPN draft observer (espn-draft-observer.ts), name in order the chain of methods from scrapeFullDraftState to the method that calls storage.saveDraftState. One line.',
});
response = result.content?.[0]?.text ?? '';
}, 120_000);
afterAll(() => {
cg?.destroy();
if (dir && fs.existsSync(dir)) fs.rmSync(dir, { recursive: true, force: true });
});
it('still renders the observer file (ranker chooses the right file)', () => {
expect(response).toContain('espn-draft-observer.ts');
});
it('names elided symbols inside gap markers as name (file:line)', () => {
// Match only in-fence gap markers that list at least one path:line ref.
const namedGaps = response.match(/\.\.\. \(gap: [^\n]*?\([^\n]+?:\d+\)[^\n]*?\) \.\.\./g) ?? [];
expect(namedGaps.length, `response head:\n${response.slice(0, 2000)}`).toBeGreaterThan(0);
for (const g of namedGaps) {
expect(g).toMatch(/\w+ \([^\s)]+:\d+\)/);
}
});
it('footer points at named gaps / header instead of asking for unknown names', () => {
if (!response.includes('trimmed for size')) return;
expect(response).toMatch(/preferred in the file header|named inside gap markers/);
});
it('biases the file header away from filler-only when symbols were elided', () => {
const header = response.split('\n').find((l) => l.includes('**`src/espn-draft-observer.ts`**'));
expect(header).toBeDefined();
// Either the header names a chain method, or a named gap does — never
// neither while the footer asks for exact names.
const namesAnswer = /syncStateNow|performHeavyDraftSync|persistDraftState|scrapeFullDraftState/;
const namedSomewhere =
namesAnswer.test(header!) ||
namesAnswer.test(response);
expect(namedSomewhere).toBe(true);
});
});
+26 -1
View File
@@ -12,7 +12,7 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { planFrontload, findIndexedSubprojectRoots, isStructuralPrompt, hasStructuralKeyword, extractCodeTokens } from '../src/directory';
import { planFrontload, findIndexedSubprojectRoots, isStructuralPrompt, hasStructuralKeyword, extractCodeTokens, PROMPT_HOOK_INJECTION_MAX, CLAUDE_CODE_INLINE_HOOK_OUTPUT_LIMIT, capPromptHookInjection } from '../src/directory';
/** Make `dir` look indexed (isInitialized needs `.codegraph/codegraph.db`). */
function mkIndexed(dir: string): string {
@@ -321,3 +321,28 @@ describe('isStructuralPrompt — cheap candidate gate (keyword OR code-token)',
expect(isStructuralPrompt('')).toBe(false);
});
});
describe('prompt-hook injection cap (#1694)', () => {
it('PROMPT_HOOK_INJECTION_MAX stays under Claude Code\'s 10k inline hook-output limit', () => {
expect(PROMPT_HOOK_INJECTION_MAX).toBe(9000);
expect(CLAUDE_CODE_INLINE_HOOK_OUTPUT_LIMIT).toBe(10_000);
expect(PROMPT_HOOK_INJECTION_MAX).toBeLessThan(CLAUDE_CODE_INLINE_HOOK_OUTPUT_LIMIT);
// Leave headroom for the <codegraph_context> wrapper + projectPath nudge lines.
expect(CLAUDE_CODE_INLINE_HOOK_OUTPUT_LIMIT - PROMPT_HOOK_INJECTION_MAX).toBeGreaterThanOrEqual(500);
});
it('capPromptHookInjection leaves short payloads intact', () => {
expect(capPromptHookInjection('hello')).toBe('hello');
expect(capPromptHookInjection('x'.repeat(PROMPT_HOOK_INJECTION_MAX))).toBe('x'.repeat(PROMPT_HOOK_INJECTION_MAX));
});
it('capPromptHookInjection truncates oversize payloads with the explore notice', () => {
const over = 'a'.repeat(PROMPT_HOOK_INJECTION_MAX + 500);
const out = capPromptHookInjection(over);
expect(out.length).toBeLessThan(over.length);
expect(out.startsWith('a'.repeat(PROMPT_HOOK_INJECTION_MAX))).toBe(true);
expect(out).toContain('…(truncated; call codegraph_explore for the rest)');
// Capped body alone must still fit under the host inline limit.
expect(out.length).toBeLessThan(CLAUDE_CODE_INLINE_HOOK_OUTPUT_LIMIT);
});
});
+153
View File
@@ -0,0 +1,153 @@
/**
* A function nested inside another function is only callable from inside its
* container. matchByExactName already filters candidates that way; matchFuzzy
* must too, or a call to a builtin method (`res.text()`) whose only same-named
* project symbol is some file's closure resolves onto that closure.
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
import { CodeGraph } from '../src';
import { matchFuzzy } from '../src/resolution/name-matcher';
import type { Node } from '../src/types';
import type { ResolutionContext, UnresolvedRef } from '../src/resolution/types';
describe('fuzzy matching respects lexical reachability of nested functions', () => {
let tempDir: string;
let cg: CodeGraph | null = null;
beforeEach(() => {
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-fuzzy-reach-'));
});
afterEach(() => {
cg?.destroy();
cg = null;
try {
fs.rmSync(tempDir, { recursive: true, force: true });
} catch {
// Windows can still hold the SQLite handle for a moment; the OS temp dir is swept anyway.
}
});
it('does not resolve a builtin method call onto another file\'s closure of the same name', async () => {
fs.writeFileSync(
path.join(tempDir, 'seed.ts'),
[
'export function readSeedState(raw: string): string {',
' function text(): string {',
' return raw.trim();',
' }',
' return text();',
'}',
'',
].join('\n')
);
fs.writeFileSync(
path.join(tempDir, 'fetch.ts'),
[
'export async function readOkText(settled: { value: Response }): Promise<string> {',
' // A chained receiver reaches the resolver as the bare method name.',
' return settled.value.text();',
'}',
'',
].join('\n')
);
cg = await CodeGraph.init(tempDir, { index: true });
cg.resolveReferences();
const closure = cg
.getNodesByKind('function')
.find((n) => n.name === 'text' && n.filePath === 'seed.ts');
const caller = cg.getNodesByKind('function').find((n) => n.name === 'readOkText');
expect(closure).toBeDefined();
expect(caller).toBeDefined();
const fromCaller = cg.getOutgoingEdges(caller!.id).filter((e) => e.kind === 'calls');
expect(fromCaller.map((e) => e.target)).not.toContain(closure!.id);
// The in-container call still resolves.
const container = cg.getNodesByKind('function').find((n) => n.name === 'readSeedState');
const inside = cg.getOutgoingEdges(container!.id).filter((e) => e.kind === 'calls');
expect(inside.map((e) => e.target)).toContain(closure!.id);
});
});
/**
* The reachability check must sit on the one candidate matchFuzzy would
* commit to, never on the candidate set. Filtering a crowd of same-named
* definitions down to the reachable ones leaves a single survivor, and the
* strategy then hands it every call of that name: vite has a dozen `resolve`
* definitions, most nested, and one reachable `resolve` method inherited 59
* `import { resolve } from 'node:path'` calls that way (#1709). Driven
* directly, so the shape is pinned regardless of what the earlier strategies
* make of a given fixture.
*/
describe('fuzzy reachability rejects a unique guess but never manufactures one', () => {
const node = (partial: Partial<Node> & Pick<Node, 'id' | 'kind' | 'name' | 'filePath'>): Node => ({
qualifiedName: partial.name,
language: 'typescript',
startLine: 1,
endLine: 1,
startColumn: 0,
endColumn: 0,
updatedAt: 0,
...partial,
});
// build.ts: function build() { const resolve = …; function resolve() {} }
const container = node({ id: 'f:build', kind: 'function', name: 'build', filePath: 'build.ts', startLine: 1, endLine: 40 });
const closure = node({ id: 'f:build.resolve', kind: 'function', name: 'resolve', qualifiedName: 'build::resolve', filePath: 'build.ts', startLine: 10, endLine: 12 });
// pluginContainer.ts: class PluginContainer { resolve() {} }
const method = node({ id: 'm:resolve', kind: 'method', name: 'resolve', qualifiedName: 'PluginContainer::resolve', filePath: 'pluginContainer.ts', startLine: 5, endLine: 9 });
const contextWith = (nodes: Node[]): ResolutionContext =>
({
getNodesInFile: () => [],
getNodesByName: (name: string) => nodes.filter((n) => n.name === name),
getNodesByLowerName: (name: string) => nodes.filter((n) => n.name.toLowerCase() === name),
getNodesByQualifiedName: (qn: string) => [container].filter((n) => n.qualifiedName === qn),
getNodesByKind: () => [],
fileExists: () => false,
readFile: () => null,
getFileLines: () => [],
getProjectRoot: () => '',
getAllFiles: () => [],
getImportMappings: () => [],
}) as unknown as ResolutionContext;
const callFrom = (filePath: string, line: number): UnresolvedRef => ({
fromNodeId: 'f:caller',
referenceName: 'resolve',
referenceKind: 'calls',
line,
column: 2,
filePath,
language: 'typescript',
});
it('declines the sole candidate when it is a closure the call cannot reach', () => {
expect(matchFuzzy(callFrom('vite.config.js', 3), contextWith([closure]))).toBeNull();
});
it('still resolves the sole candidate from inside its container', () => {
expect(matchFuzzy(callFrom('build.ts', 20), contextWith([closure]))?.targetNodeId).toBe('f:build.resolve');
});
it('does not let the unreachable closure drop out and leave the method as a "unique" match', () => {
// Two same-named callables: ambiguous, exactly as before the check existed.
expect(matchFuzzy(callFrom('vite.config.js', 3), contextWith([closure, method]))).toBeNull();
});
it('trusts no nesting in C, where a nested function is an extraction artifact', () => {
// betaflight: tree-sitter-c's recovery from `RESET_CONFIG(…, .pid = {…})`
// runs resetPidProfile to the end of pid.c, so every function after it is
// "nested" in the graph. C has no nested named functions; the call reaches it.
const cClosure = node({ ...closure, id: 'f:c', language: 'c' as Node['language'], filePath: 'pid.c' });
const cRef = { ...callFrom('core.c', 3), language: 'c' as UnresolvedRef['language'] };
expect(matchFuzzy(cRef, contextWith([cClosure]))?.targetNodeId).toBe('f:c');
});
it('resolves a lone reachable method as before', () => {
expect(matchFuzzy(callFrom('vite.config.js', 3), contextWith([method]))?.targetNodeId).toBe('m:resolve');
});
});
+126
View File
@@ -0,0 +1,126 @@
/**
* TypeScript's node16/nodenext/bundler resolution writes the EMITTED extension
* in a relative specifier (`./util.js` for `util.ts`). The import resolver must
* map that back to the source file that is actually in the repo; otherwise the
* imported names fall through to bare-name matching and a method that wraps a
* same-named import resolves to itself.
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
import { CodeGraph } from '../src';
import { resolveImportPath } from '../src/resolution/import-resolver';
import type { ResolutionContext } from '../src/resolution';
function contextWithFiles(files: string[]): ResolutionContext {
const set = new Set(files);
return {
getNodesInFile: () => [],
getNodesByName: () => [],
getNodesByQualifiedName: () => [],
getNodesByKind: () => [],
fileExists: (p: string) => set.has(p),
readFile: () => null,
getProjectRoot: () => '/test',
getAllFiles: () => files,
getNodesByLowerName: () => [],
getImportMappings: () => [],
} as unknown as ResolutionContext;
}
describe('emitted-extension import specifiers (`./x.js` naming `x.ts`)', () => {
it('maps a relative .js specifier onto the .ts source', () => {
const ctx = contextWithFiles(['shared/engine.ts', 'shared/util.ts']);
expect(resolveImportPath('./util.js', 'shared/engine.ts', 'typescript', ctx)).toBe('shared/util.ts');
});
it('prefers a real .js file over the remap when both exist', () => {
const ctx = contextWithFiles(['shared/engine.ts', 'shared/util.js', 'shared/util.ts']);
expect(resolveImportPath('./util.js', 'shared/engine.ts', 'typescript', ctx)).toBe('shared/util.js');
});
it('maps .jsx, .mjs and .cjs onto their TypeScript sources', () => {
const ctx = contextWithFiles(['app/a.tsx', 'app/View.tsx', 'app/esm.mts', 'app/cjs.cts']);
expect(resolveImportPath('./View.jsx', 'app/a.tsx', 'tsx', ctx)).toBe('app/View.tsx');
expect(resolveImportPath('./esm.mjs', 'app/a.tsx', 'tsx', ctx)).toBe('app/esm.mts');
expect(resolveImportPath('./cjs.cjs', 'app/a.tsx', 'tsx', ctx)).toBe('app/cjs.cts');
});
it('maps an aliased .js specifier through tsconfig paths', () => {
const files = ['src/main.ts', 'src/lib/util.ts'];
const ctx = {
...contextWithFiles(files),
getProjectAliases: () => ({
baseUrl: '/test',
patterns: [{ prefix: '@/', suffix: '', hasWildcard: true, replacements: ['src/*'] }],
}),
} as unknown as ResolutionContext;
expect(resolveImportPath('@/lib/util.js', 'src/main.ts', 'typescript', ctx)).toBe('src/lib/util.ts');
});
it('leaves a specifier that names no source unresolved', () => {
const ctx = contextWithFiles(['shared/engine.ts']);
expect(resolveImportPath('./missing.js', 'shared/engine.ts', 'typescript', ctx)).toBeNull();
});
it('does not remap for a language without TypeScript emit (python)', () => {
const ctx = contextWithFiles(['pkg/a.py', 'pkg/b.ts']);
expect(resolveImportPath('./b.js', 'pkg/a.py', 'python', ctx)).toBeNull();
});
});
describe('end to end: a wrapper method calling the same-named import it wraps', () => {
let tempDir: string;
let cg: CodeGraph | null = null;
beforeEach(() => {
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-emitted-spec-'));
});
afterEach(() => {
cg?.destroy();
cg = null;
try {
fs.rmSync(tempDir, { recursive: true, force: true });
} catch {
// Windows can still hold the SQLite handle for a moment; the OS temp dir is swept anyway.
}
});
it('links the call to the imported function, not to the method itself', async () => {
fs.writeFileSync(
path.join(tempDir, 'template.ts'),
'export function renderDockStyles(): string {\n return ".dock {}";\n}\n'
);
fs.writeFileSync(
path.join(tempDir, 'sidebar.ts'),
[
'import { renderDockStyles } from "./template.js";',
'',
'export class Sidebar {',
' renderDockStyles(): string {',
' return renderDockStyles();',
' }',
'}',
'',
].join('\n')
);
cg = await CodeGraph.init(tempDir, { index: true });
cg.resolveReferences();
const method = cg.getNodesByKind('method').find((n) => n.name === 'renderDockStyles');
const fn = cg
.getNodesByKind('function')
.find((n) => n.name === 'renderDockStyles' && n.filePath === 'template.ts');
expect(method).toBeDefined();
expect(fn).toBeDefined();
const targets = cg
.getOutgoingEdges(method!.id)
.filter((e) => e.kind === 'calls')
.map((e) => e.target);
expect(targets).toContain(fn!.id);
expect(targets).not.toContain(method!.id);
});
});
+190 -6
View File
@@ -138,6 +138,8 @@ describe('Installer targets — contract', () => {
// opencode uses `mcp` not `mcpServers`. Match its shape too.
if (target.id === 'opencode') {
delete seed.mcpServers;
// Keep a v1-shaped sibling — real configs mix shapes during the
// OpenCode 1→2 transition; install must not disturb it (#1698).
seed.mcp = { other: { type: 'local', command: ['x'], enabled: true } };
}
// VS Code's mcp.json uses `servers`; the JetBrains Copilot
@@ -153,7 +155,10 @@ describe('Installer targets — contract', () => {
const after = JSON.parse(fs.readFileSync(jsonPath, 'utf-8'));
if (target.id === 'opencode') {
expect(after.mcp.other).toBeDefined();
expect(after.mcp.codegraph).toBeDefined();
expect(after.mcp.servers.codegraph).toBeDefined();
expect(after.mcp.servers.codegraph.codemode).toBe(false);
expect(after.mcp.servers.codegraph.disabled).toBe(false);
expect(after.mcp.codegraph).toBeUndefined();
} else if (target.id === 'copilot-vscode' || target.id === 'copilot-jetbrains') {
expect(after.servers.other).toBeDefined();
expect(after.servers.codegraph).toBeDefined();
@@ -875,7 +880,7 @@ describe('Installer targets — partial-state idempotency', () => {
expect(body).toContain(' telegram:\n - hermes-telegram');
});
it('opencode: uninstall removes only mcp.codegraph, preserves comments and siblings', () => {
it('opencode: uninstall removes only mcp.servers.codegraph, preserves comments and siblings', () => {
const opencode = getTarget('opencode')!;
const dir = path.join(tmpHome, '.config', 'opencode');
fs.mkdirSync(dir, { recursive: true });
@@ -892,13 +897,15 @@ describe('Installer targets — partial-state idempotency', () => {
].join('\n'));
opencode.install('global', { autoAllow: true });
const afterInstall = fs.readFileSync(file, 'utf-8');
expect(afterInstall).toContain('"codegraph"');
expect(afterInstall).toContain('"other"');
const afterInstall = parseJsonc(fs.readFileSync(file, 'utf-8'));
expect(afterInstall.mcp.servers.codegraph).toBeDefined();
expect(afterInstall.mcp.servers.codegraph.codemode).toBe(false);
expect(afterInstall.mcp.other).toBeDefined();
opencode.uninstall('global');
const afterUninstall = fs.readFileSync(file, 'utf-8');
expect(afterUninstall).not.toContain('codegraph');
expect(afterUninstall).not.toContain('"servers"');
expect(afterUninstall).toContain('// important comment');
expect(afterUninstall).toContain('"other"');
});
@@ -976,6 +983,22 @@ describe('Installer targets — partial-state idempotency', () => {
expect(fs.existsSync(path.join(tmpCwd, '.claude.json'))).toBe(false);
const cfg = JSON.parse(fs.readFileSync(path.join(tmpCwd, '.mcp.json'), 'utf-8'));
expect(cfg.mcpServers.codegraph).toBeDefined();
// Exempt from Claude Code's tool-search deferral (#1696).
expect(cfg.mcpServers.codegraph.alwaysLoad).toBe(true);
});
it('claude: re-running install on an entry that predates alwaysLoad adds the key (#1696)', () => {
const claude = getTarget('claude')!;
fs.writeFileSync(
path.join(tmpCwd, '.mcp.json'),
JSON.stringify({ mcpServers: { codegraph: { type: 'stdio', command: 'codegraph', args: ['serve', '--mcp'] } } }, null, 2),
);
const result = claude.install('local', { autoAllow: false });
const mcp = result.files.find((f) => f.path.replace(/\\/g, '/').endsWith('/.mcp.json'));
expect(mcp?.action).toBe('updated');
const cfg = JSON.parse(fs.readFileSync(path.join(tmpCwd, '.mcp.json'), 'utf-8'));
expect(cfg.mcpServers.codegraph.alwaysLoad).toBe(true);
expect(cfg.mcpServers.codegraph.args).toEqual(['serve', '--mcp']);
});
it('claude: install creates the CLAUDE.md codegraph block (#704)', () => {
@@ -1010,6 +1033,7 @@ describe('Installer targets — partial-state idempotency', () => {
claude.install('global', { autoAllow: false });
const cfg = JSON.parse(fs.readFileSync(path.join(tmpHome, '.claude.json'), 'utf-8'));
expect(cfg.mcpServers.codegraph).toBeDefined();
expect(cfg.mcpServers.codegraph.alwaysLoad).toBe(true);
});
it('claude: local install migrates a legacy ./.claude.json codegraph entry into ./.mcp.json', () => {
@@ -1813,6 +1837,149 @@ function listAllFiles(dir: string): string[] {
return out;
}
// ---------------------------------------------------------------------------
// opencode OpenCode 2 native MCP shape (#1698)
//
// OpenCode 2 reads `mcp.servers.<name>` with `disabled` / `codemode`. The
// v1 `mcp.<name>` + `enabled` shape still connects but drops `codemode`
// during normalization — so the installer must write the native shape and
// migrate/uninstall either.
// ---------------------------------------------------------------------------
describe('Installer targets — opencode native MCP shape (#1698)', () => {
let tmpHome: string;
let tmpCwd: string;
let origCwd: string;
let homeRestore: { restore: () => void };
beforeEach(() => {
tmpHome = mkTmpDir('home');
tmpCwd = mkTmpDir('cwd');
origCwd = process.cwd();
process.chdir(tmpCwd);
homeRestore = setHome(tmpHome);
});
afterEach(() => {
homeRestore.restore();
process.chdir(origCwd);
fs.rmSync(tmpHome, { recursive: true, force: true });
fs.rmSync(tmpCwd, { recursive: true, force: true });
});
const configFile = () => path.join(tmpHome, '.config', 'opencode', 'opencode.jsonc');
it('install writes mcp.servers.codegraph with disabled:false and codemode:false', () => {
const opencode = getTarget('opencode')!;
opencode.install('global', { autoAllow: true });
const cfg = JSON.parse(fs.readFileSync(configFile(), 'utf-8'));
expect(cfg.mcp.codegraph).toBeUndefined();
expect(cfg.mcp.servers.codegraph).toEqual({
type: 'local',
command: ['codegraph', 'serve', '--mcp'],
disabled: false,
codemode: false,
});
});
it('printConfig shows the native OpenCode 2 shape', () => {
const out = getTarget('opencode')!.printConfig('global');
expect(out).toContain('"servers"');
expect(out).toContain('"codemode": false');
expect(out).toContain('"disabled": false');
expect(out).not.toContain('"enabled"');
// No v1 top-level mcp.codegraph key in the snippet.
expect(out).not.toMatch(/"mcp"\s*:\s*\{\s*"codegraph"/);
});
it('re-install migrates a v1 mcp.codegraph entry to mcp.servers.codegraph', () => {
const dir = path.dirname(configFile());
fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(configFile(), [
'{',
' // keep me',
' "$schema": "https://opencode.ai/config.json",',
' "mcp": {',
' "codegraph": { "type": "local", "command": ["codegraph", "serve", "--mcp"], "enabled": true },',
' "other": { "type": "local", "command": ["x"], "enabled": true }',
' }',
'}',
'',
].join('\n'));
const opencode = getTarget('opencode')!;
expect(opencode.detect('global').alreadyConfigured).toBe(true);
const result = opencode.install('global', { autoAllow: true });
expect(result.files.find((f) => f.path === configFile())!.action).toBe('updated');
const text = fs.readFileSync(configFile(), 'utf-8');
expect(text).toContain('// keep me');
const cfg = parseJsonc(text);
expect(cfg.mcp.codegraph).toBeUndefined();
expect(cfg.mcp.other).toBeDefined();
expect(cfg.mcp.servers.codegraph).toEqual({
type: 'local',
command: ['codegraph', 'serve', '--mcp'],
disabled: false,
codemode: false,
});
// Idempotent after migration.
const second = opencode.install('global', { autoAllow: true });
expect(second.files.find((f) => f.path === configFile())!.action).toBe('unchanged');
});
it('uninstall removes a leftover v1 mcp.codegraph entry', () => {
const dir = path.dirname(configFile());
fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(configFile(), [
'{',
' // keep me',
' "$schema": "https://opencode.ai/config.json",',
' "mcp": {',
' "codegraph": { "type": "local", "command": ["codegraph", "serve", "--mcp"], "enabled": true },',
' "other": { "type": "local", "command": ["x"], "enabled": true }',
' }',
'}',
'',
].join('\n'));
const opencode = getTarget('opencode')!;
opencode.uninstall('global');
const text = fs.readFileSync(configFile(), 'utf-8');
expect(text).toContain('// keep me');
expect(text).toContain('"other"');
expect(text).not.toContain('codegraph');
expect(opencode.detect('global').alreadyConfigured).toBe(false);
});
it('uninstall removes a native mcp.servers.codegraph entry and an emptied servers wrapper', () => {
const dir = path.dirname(configFile());
fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(configFile(), JSON.stringify({
$schema: 'https://opencode.ai/config.json',
mcp: {
servers: {
codegraph: {
type: 'local',
command: ['codegraph', 'serve', '--mcp'],
disabled: false,
codemode: false,
},
},
},
}, null, 2) + '\n');
const opencode = getTarget('opencode')!;
opencode.uninstall('global');
const text = fs.readFileSync(configFile(), 'utf-8');
expect(text).not.toContain('codegraph');
expect(text).not.toContain('"servers"');
expect(text).not.toContain('"mcp"');
expect(opencode.detect('global').alreadyConfigured).toBe(false);
});
});
// ---------------------------------------------------------------------------
// opencode global config path — XDG on every platform (#535)
//
@@ -2180,7 +2347,7 @@ describe('Installer targets — Copilot family', () => {
// ---- copilot-cli ----
it('copilot-cli: global install writes ~/.copilot/mcp-config.json with the documented entry shape (tools: ["*"])', () => {
it('copilot-cli: global install writes ~/.copilot/mcp-config.json with the documented entry shape (tools: ["*"], deferTools: "never")', () => {
const t = getTarget('copilot-cli')!;
const result = t.install('global', { autoAllow: true });
@@ -2193,9 +2360,26 @@ describe('Installer targets — Copilot family', () => {
command: 'codegraph',
args: ['serve', '--mcp'],
tools: ['*'],
// Exempt from Copilot CLI's tool search, the same way `alwaysLoad` exempts it in Claude Code (#1696).
deferTools: 'never',
});
});
it('copilot-cli: re-running install on an entry that predates deferTools adds the key (#1696)', () => {
const t = getTarget('copilot-cli')!;
const file = path.join(tmpHome, '.copilot', 'mcp-config.json');
fs.mkdirSync(path.dirname(file), { recursive: true });
fs.writeFileSync(
file,
JSON.stringify({ mcpServers: { codegraph: { type: 'stdio', command: 'codegraph', args: ['serve', '--mcp'], tools: ['*'] } } }, null, 2),
);
const result = t.install('global', { autoAllow: true });
expect(result.files[0].action).toBe('updated');
const cfg = JSON.parse(fs.readFileSync(file, 'utf-8'));
expect(cfg.mcpServers.codegraph.deferTools).toBe('never');
expect(cfg.mcpServers.codegraph.tools).toEqual(['*']);
});
it('copilot-cli: is global-only — local install skips with a clear note, uninstall is a no-op', () => {
const t = getTarget('copilot-cli')!;
expect(t.supportsLocation('local')).toBe(false);
+14
View File
@@ -12,6 +12,9 @@
* rewrites codegraph_explore's description via spread), and the no-default-
* project surface (`withRequiredProjectPath`, which clones the schema). A drop in
* any of those would silently re-block the tools in Ask mode.
*
* `codegraph_explore`'s `_meta` (`anthropic/alwaysLoad`, #1696) rides the same
* spreads, so each surface is checked for it here too.
*/
import { describe, it, expect, afterEach, beforeEach } from 'vitest';
import * as fs from 'fs';
@@ -34,6 +37,13 @@ function expectReadOnly(tool: ToolDefinition): void {
expect(tool.annotations!.openWorldHint).toBe(false);
}
/** Assert the explore tool in a `tools/list` surface is marked always-load for Claude Code (#1696). */
function expectExploreAlwaysLoad(surface: ToolDefinition[]): void {
const explore = surface.find((t) => t.name === 'codegraph_explore');
expect(explore, 'codegraph_explore is missing from the surface').toBeDefined();
expect(explore!._meta).toEqual({ 'anthropic/alwaysLoad': true });
}
describe('Read-only annotations on the codegraph MCP tools (#1018)', () => {
const original = process.env[ENV];
afterEach(() => {
@@ -44,6 +54,7 @@ describe('Read-only annotations on the codegraph MCP tools (#1018)', () => {
it('every tool in the master array is annotated read-only', () => {
expect(tools.length).toBeGreaterThan(0);
for (const tool of tools) expectReadOnly(tool);
expectExploreAlwaysLoad(tools);
});
it('the static proxy surface carries annotations on every exposed tool', () => {
@@ -52,6 +63,7 @@ describe('Read-only annotations on the codegraph MCP tools (#1018)', () => {
const got = getStaticTools();
expect(got.map((t) => t.name).sort()).toEqual(tools.map((t) => t.name).sort());
for (const tool of got) expectReadOnly(tool);
expectExploreAlwaysLoad(got);
});
it('the no-default-project surface keeps annotations through the schema clone', () => {
@@ -65,6 +77,7 @@ describe('Read-only annotations on the codegraph MCP tools (#1018)', () => {
// Sanity: this IS the clone path (projectPath got marked required).
expect(tool.inputSchema.required ?? []).toContain('projectPath');
}
expectExploreAlwaysLoad(got);
});
});
@@ -101,5 +114,6 @@ describe('Live tool surface keeps annotations with a project open (#1018)', () =
expect(explore).toBeDefined();
expect(explore!.description).toMatch(/Budget: make at most/);
expectReadOnly(explore!);
expectExploreAlwaysLoad(got);
});
});
@@ -0,0 +1,55 @@
/**
* A quoted (forward-reference) parameter annotation names a receiver type too
* (#1684): `def f(o: "Alpha")` resolves `o.render()` exactly like `def f(o:
* Alpha)`. Quoted annotations are ordinary Python — forward references, and
* everything under `from __future__ import annotations`.
*/
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { CodeGraph } from '../src';
import { initGrammars, loadAllGrammars } from '../src/extraction/grammars';
let dir: string;
let cg: CodeGraph;
beforeAll(async () => {
await initGrammars();
await loadAllGrammars();
dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-1684-'));
fs.mkdirSync(path.join(dir, 'pkg'));
fs.writeFileSync(path.join(dir, 'pkg', '__init__.py'), '');
fs.writeFileSync(
path.join(dir, 'pkg', 'a.py'),
'def render(x):\n return x\n\nclass Alpha:\n def render(self):\n return "a"\n\nclass Beta:\n def render(self):\n return "b"\n'
);
fs.writeFileSync(
path.join(dir, 'pkg', 'b.py'),
'from __future__ import annotations\nfrom pkg.a import Alpha, Beta\n\n' +
'def quoted(o: "Alpha"):\n return o.render()\n\n' +
"def single_quoted(o: 'Beta'):\n return o.render()\n\n" +
'def unquoted(o: Alpha):\n return o.render()\n'
);
cg = CodeGraph.initSync(dir);
await cg.indexAll();
});
afterAll(() => {
cg.destroy();
fs.rmSync(dir, { recursive: true, force: true });
});
const calleeOf = (fn: string): string[] =>
cg
.getCallees(cg.getNodesByName(fn).find((n) => n.kind === 'function')!.id)
.map(({ node }) => node.qualifiedName)
.sort();
describe('quoted forward-reference annotations (#1684)', () => {
it('resolves the method on the quoted type, the same as the unquoted annotation', () => {
expect(calleeOf('unquoted')).toEqual(['Alpha::render']);
expect(calleeOf('quoted')).toEqual(['Alpha::render']);
expect(calleeOf('single_quoted')).toEqual(['Beta::render']);
});
});
+91
View File
@@ -0,0 +1,91 @@
/**
* A TS/JS member call reached through a host namespace — `chrome.storage.local
* .get(k)`, `document.body.querySelector(s)` — ends in a platform API. Emitting
* the bare method name for it let every such call exact-match whatever project
* symbol shared the name, so a storage wrapper's `get` called itself (#1707).
* Those are dropped. A chain rooted at a project value keeps the bare name:
* `window.MyNs.run()` and `this.<field>.m()` reach real targets.
*/
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { CodeGraph } from '../src';
let dir: string;
let cg: CodeGraph;
beforeAll(async () => {
dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-1707-'));
const w = (rel: string, body: string) => fs.writeFileSync(path.join(dir, rel), body);
w(
'storage.ts',
'declare const chrome: any;\n' +
'export const DraftHubStorage = {\n' +
' async get(key: string): Promise<unknown> {\n' +
' const result = await chrome.storage.local.get([key]);\n' +
' return result[key];\n' +
' },\n' +
'};\n'
);
w(
'dom.ts',
'export function querySelector(sel: string): string { return sel; }\n' +
'export function findRow(): unknown {\n' +
' return document.body.querySelector("tr");\n' +
'}\n'
);
w(
'service.ts',
'declare const window: any;\n' +
'export function ping(): string { return "pong"; }\n' +
'export function viaGlobal(): string {\n' +
' return window.MyNs.ping();\n' +
'}\n' +
'export class Runner {\n' +
' constructor(private svc: { ping(): string }) {}\n' +
' run(): string { return this.svc.ping(); }\n' +
'}\n'
);
cg = await CodeGraph.init(dir, { index: true });
cg.resolveReferences();
});
afterAll(() => {
cg.destroy();
try {
fs.rmSync(dir, { recursive: true, force: true });
} catch {
// Windows can still hold the SQLite handle for a moment; the OS temp dir is swept anyway.
}
});
const fn = (name: string, file: string) =>
cg.getNodesByKind('function').find((n) => n.name === name && n.filePath === file)!;
const method = (qn: string) => cg.getNodesByKind('method').find((n) => n.qualifiedName === qn)!;
const callTargets = (id: string) =>
cg
.getOutgoingEdges(id)
.filter((e) => e.kind === 'calls')
.map((e) => e.target);
describe('TS/JS call through a host-global chain (#1707)', () => {
it('does not make a storage wrapper call itself through chrome.storage.local.get', () => {
const get = fn('get', 'storage.ts');
expect(get).toBeDefined();
expect(callTargets(get.id)).not.toContain(get.id);
});
it('does not bind document.body.querySelector to a same-named project function', () => {
expect(callTargets(fn('findRow', 'dom.ts').id)).not.toContain(
fn('querySelector', 'dom.ts').id
);
});
it('keeps a chain rooted at a project value — window.MyNs.m() and this.<field>.m()', () => {
const ping = fn('ping', 'service.ts').id;
expect(callTargets(fn('viaGlobal', 'service.ts').id)).toContain(ping);
expect(callTargets(method('Runner::run').id)).toContain(ping);
});
});
+32
View File
@@ -1073,6 +1073,28 @@ impl<'t> Walker<'t> {
// --- extractCall (TS/JS generic tail) -------------------------------------------------
/// Whether a member-call receiver is a chain rooted at a host object a
/// TS/JS project never declares. `window` is absent on purpose:
/// `window.MyNs.doThing()` reaches a project symbol (#1707).
fn is_host_global_chain(&self, receiver: Node<'t>) -> bool {
const HOST_GLOBAL_ROOTS: [&str; 19] = [
"chrome", "browser", "document", "navigator", "performance", "console",
"localStorage", "sessionStorage", "indexedDB", "crypto", "globalThis",
"process", "Math", "JSON", "Object", "Array", "Reflect", "Promise", "Intl",
];
let mut cur = receiver;
if !matches!(cur.kind(), "member_expression" | "subscript_expression") {
return false;
}
while matches!(cur.kind(), "member_expression" | "subscript_expression") {
match cur.child_by_field_name("object") {
Some(next) => cur = next,
None => return false,
}
}
cur.kind() == "identifier" && HOST_GLOBAL_ROOTS.contains(&self.text(cur))
}
pub(super) fn extract_call(&mut self, node: Node<'t>) {
if self.stack.is_empty() {
return;
@@ -1100,6 +1122,16 @@ impl<'t> Walker<'t> {
if is_literal_receiver(r.kind()) {
return;
}
// A chain rooted at a host namespace — `chrome.storage
// .local.get(k)`, `document.body.querySelector(s)` —
// ends in a platform API, so the bare method name emitted
// here could only exact-match an unrelated project symbol
// sharing it (#1707). Emit nothing. A chain rooted at a
// project value keeps the bare name. Mirrors the TS
// extractor's extractCall (extraction/tree-sitter.ts).
if self.is_host_global_chain(r) {
return;
}
}
let recv_ident = receiver.filter(|r| {
matches!(r.kind(), "identifier" | "simple_identifier" | "field_identifier")
+6 -3
View File
@@ -41,7 +41,7 @@ try {
import { Command } from 'commander';
import * as path from 'path';
import * as fs from 'fs';
import { getCodeGraphDir, isInitialized, unsafeIndexRootReason, findNearestCodeGraphRoot, planFrontload, hasStructuralKeyword, extractCodeTokens } from '../directory';
import { getCodeGraphDir, isInitialized, unsafeIndexRootReason, findNearestCodeGraphRoot, planFrontload, hasStructuralKeyword, extractCodeTokens, capPromptHookInjection } from '../directory';
import { extractProseCandidates } from '../search/identifier-segments';
import { detectWorktreeIndexMismatch, worktreeMismatchWarning } from '../sync/worktree';
import { createShimmerProgress } from '../ui/shimmer-progress';
@@ -1412,8 +1412,11 @@ program
const text = result.content[0]?.text ?? '';
if (!result.isError && text.trim()) {
// Cap the injection so a large-repo explore can't flood the prompt.
const MAX = 16000;
const body = text.length > MAX ? `${text.slice(0, MAX)}\n…(truncated; call codegraph_explore for the rest)` : text;
// Claude Code shows hook stdout inline only up to 10,000 characters;
// above that it persists the output to a file and the model sees a
// 2 KB preview (#1694). PROMPT_HOOK_INJECTION_MAX (9,000) leaves
// room for the wrapper and the projectPath nudge lines below.
const body = capPromptHookInjection(text);
// For a front-loaded SUB-project, a follow-up explore needs its path.
const more = plan.viaSubScan
? `call codegraph_explore with projectPath: "${plan.exploreRoot}" for more`
+27
View File
@@ -568,6 +568,33 @@ export function isStructuralPrompt(prompt: string): boolean {
return hasStructuralKeyword(prompt) || extractCodeTokens(prompt).length > 0;
}
/**
* Claude Code persists `UserPromptSubmit` hook stdout above this many
* characters to a file and shows the model a ~2 KB preview instead (#1694).
* Measured on Claude Code 2.1.261; documented in the hooks reference as a
* 10,000-character cap on hook output strings.
*/
export const CLAUDE_CODE_INLINE_HOOK_OUTPUT_LIMIT = 10_000;
/**
* Max characters of explore text injected by `codegraph prompt-hook` before
* truncation. Must stay under {@link CLAUDE_CODE_INLINE_HOOK_OUTPUT_LIMIT} so
* the host delivers the payload inline. 9,000 leaves ~1k for the
* `<codegraph_context>` wrapper and the `projectPath` nudge lines appended
* after the cap is applied.
*/
export const PROMPT_HOOK_INJECTION_MAX = 9_000;
/**
* Cap explore text for the prompt-hook injection, preserving the existing
* "call codegraph_explore for the rest" notice when truncated.
*/
export function capPromptHookInjection(text: string, max = PROMPT_HOOK_INJECTION_MAX): string {
return text.length > max
? `${text.slice(0, max)}\n…(truncated; call codegraph_explore for the rest)`
: text;
}
/**
* What the front-load hook should do for a prompt issued from a directory.
*/
+53
View File
@@ -388,6 +388,41 @@ const LITERAL_RECEIVER_TYPES = new Set([
'dictionary', 'dict_literal', 'object', 'tuple', 'set',
]);
/**
* Languages whose member calls go through the TS/JS grammars.
*/
const TS_JS_CHAIN_LANGUAGES = new Set(['typescript', 'tsx', 'javascript', 'jsx']);
/**
* Host objects a TS/JS project never declares: the browser, extension, and
* runtime namespaces, plus the builtin constructors whose statics are library
* calls. A member chain ROOTED at one of these ends in a platform API, so the
* bare method name the extractor used to emit for `chrome.storage.local.get(k)`
* or `document.body.querySelector(s)` could only ever exact-match an unrelated
* project symbol that happened to share the name (#1707). `window` is absent on
* purpose: `window.MyNamespace.doThing()` reaches a project symbol.
*/
const TS_JS_HOST_GLOBAL_ROOTS = new Set([
'chrome', 'browser', 'document', 'navigator', 'performance', 'console',
'localStorage', 'sessionStorage', 'indexedDB', 'crypto', 'globalThis',
'process', 'Math', 'JSON', 'Object', 'Array', 'Reflect', 'Promise', 'Intl',
]);
/** Receiver node types (TS/JS grammars) that continue a member chain downward. */
const TS_JS_CHAIN_RECEIVER_TYPES = new Set(['member_expression', 'subscript_expression']);
/**
* Root identifier of a TS/JS member chain — `chrome` for `chrome.storage.local`
* — or null when the chain bottoms out in a call, a literal, or `this`.
*/
function tsJsChainRoot(node: SyntaxNode, source: string): string | null {
let cur: SyntaxNode | null = node;
while (cur && TS_JS_CHAIN_RECEIVER_TYPES.has(cur.type)) {
cur = getChildByField(cur, 'object');
}
return cur && cur.type === 'identifier' ? getNodeText(cur, source) : null;
}
/**
* React hooks that bind a NAME to a handler function (`const onPress =
* useCallback(() => {…}, [])`). The arrow inside is extracted as a function
@@ -4676,6 +4711,24 @@ export class TreeSitterExtractor {
// Go receivers resolve strictly via validated field-hop
// inference (see matchGoFieldChainCall) or stay unresolved.
calleeName = `${getNodeText(receiver, this.source).replace(/\s+/g, '')}.${methodName}`;
} else if (
TS_JS_CHAIN_LANGUAGES.has(this.language) &&
receiver &&
TS_JS_CHAIN_RECEIVER_TYPES.has(receiver.type) &&
TS_JS_HOST_GLOBAL_ROOTS.has(tsJsChainRoot(receiver, this.source) ?? '')
) {
// TS/JS member call reached through a host namespace —
// `chrome.storage.local.get(key)`, `document.body.querySelector(s)`.
// The bare method name this used to emit exact-matched whatever
// project symbol shared it: every `chrome.storage.local.get/set`
// in a storage wrapper bound to the wrapper's own `get`/`set`,
// a self-edge not in the source (#1707). Emit nothing: a silent
// miss, never a wrong edge. A chain rooted at a project value
// (`window.MyNs.run()`, `store.getState().act()`, `ref.value.m()`)
// keeps the bare name — those targets are real, and dropping them
// would cost far more recall than the mis-bind costs precision.
// Mirrored in the kernel's extract_call (tsjs/extractors.rs).
return;
} else {
calleeName = methodName;
}
+14 -2
View File
@@ -41,6 +41,18 @@ import {
CODEGRAPH_SECTION_START,
} from '../instructions-template';
/**
* The shared stdio entry plus `alwaysLoad: true`, Claude Code's exemption from
* tool-search deferral (https://code.claude.com/docs/en/mcp#exempt-a-server-from-deferral).
* `codegraph_explore` carries the same flag in its `_meta`, which covers an
* entry written before this key; the entry-level key additionally makes
* Claude Code wait for this server's tools at startup, so they are in the
* first prompt rather than listed after the server connects in the background.
*/
function getClaudeMcpServerConfig() {
return { ...getMcpServerConfig(), alwaysLoad: true };
}
function configDir(loc: Location): string {
return loc === 'global'
? path.join(os.homedir(), '.claude')
@@ -211,7 +223,7 @@ class ClaudeCodeTarget implements AgentTarget {
printConfig(loc: Location): string {
const target = mcpJsonPath(loc);
const snippet = JSON.stringify({ mcpServers: { codegraph: getMcpServerConfig() } }, null, 2);
const snippet = JSON.stringify({ mcpServers: { codegraph: getClaudeMcpServerConfig() } }, null, 2);
return `# Add to ${target}\n\n${snippet}\n`;
}
@@ -231,7 +243,7 @@ export function writeMcpEntry(loc: Location): WriteResult['files'][number] {
const file = mcpJsonPath(loc);
const existing = readJsonFile(file);
const before = existing.mcpServers?.codegraph;
const after = getMcpServerConfig();
const after = getClaudeMcpServerConfig();
if (jsonDeepEqual(before, after)) {
// Already exactly what we'd write — preserve byte-identical file.
+8 -2
View File
@@ -95,9 +95,15 @@ function copilotOnPath(): boolean {
return false;
}
function buildCopilotMcpConfig(): { type: string; command: string; args: string[]; tools: string[] } {
/**
* The shared stdio entry, every tool allowed, and `deferTools: 'never'`: Copilot CLI's tool
* search (on by default from ~30 connected tools on Claude and GPT-5.4+ models) otherwise holds
* MCP tools back until the model searches for them, so `codegraph_explore` would be a name the
* model has to go looking for before it can follow the "call it instead of Read" instruction.
*/
function buildCopilotMcpConfig(): { type: string; command: string; args: string[]; tools: string[]; deferTools: 'never' } {
const base = getMcpServerConfig();
return { ...base, tools: ['*'] };
return { ...base, tools: ['*'], deferTools: 'never' };
}
class CopilotCliTarget implements AgentTarget {
+82 -23
View File
@@ -19,15 +19,26 @@
* instructions — same convention Codex CLI uses.
* - No permissions concept.
*
* Config shape uses opencode's wrapper:
* Config shape uses OpenCode 2's native wrapper (also read by 1.18+):
* {
* "$schema": "https://opencode.ai/config.json",
* "mcp": { "codegraph": { "type": "local", "command": [...], "enabled": true } }
* "mcp": {
* "servers": {
* "codegraph": {
* "type": "local",
* "command": [...],
* "disabled": false,
* "codemode": false
* }
* }
* }
* }
*
* The shape differs from Claude/Cursor — opencode uses `mcp.<name>`
* (not `mcpServers`), takes `command` as a string array combining
* binary + args, and includes an explicit `enabled` flag.
* OpenCode 2 puts servers under `mcp.servers` (not `mcp.<name>`), uses
* `disabled` instead of `enabled`, and defaults tools through Code Mode —
* `codemode: false` keeps `codegraph_explore` on the provider's native
* tool list (#1698). Pre-#1698 installs wrote the v1 `mcp.codegraph` +
* `enabled` shape; re-install migrates, uninstall removes either.
*
* Reads + writes go through `jsonc-parser` so any `//` and `/* *\/`
* comments the user has added to their `.jsonc` survive idempotent
@@ -115,14 +126,27 @@ function parseConfig(text: string): Record<string, any> {
return result as Record<string, any>;
}
function getOpencodeServerEntry(): { type: string; command: string[]; enabled: boolean } {
function getOpencodeServerEntry(): {
type: string;
command: string[];
disabled: boolean;
codemode: boolean;
} {
return {
type: 'local',
command: ['codegraph', 'serve', '--mcp'],
enabled: true,
disabled: false,
// Keep codegraph_explore on the native tool list — OpenCode 2's
// default Code Mode would otherwise hide the one-tool server (#1698).
codemode: false,
};
}
/** True when either the OpenCode 2 native entry or a pre-#1698 v1 entry is present. */
function hasCodegraphEntry(config: Record<string, any>): boolean {
return !!(config.mcp?.servers?.codegraph || config.mcp?.codegraph);
}
const FORMATTING = { tabSize: 2, insertSpaces: true, eol: '\n' };
class OpencodeTarget implements AgentTarget {
@@ -137,7 +161,7 @@ class OpencodeTarget implements AgentTarget {
detect(loc: Location): DetectionResult {
const file = configPath(loc);
const config = parseConfig(readConfigText(file));
const alreadyConfigured = !!config.mcp?.codegraph;
const alreadyConfigured = hasCodegraphEntry(config);
// Global: the XDG dir is what current opencode creates on first run; the
// legacy %APPDATA% dir still counts as "opencode present" so a re-install
// can sweep the stale pre-#535 entry out of it.
@@ -176,7 +200,7 @@ class OpencodeTarget implements AgentTarget {
const target = configPath(loc);
const snippet = JSON.stringify({
$schema: 'https://opencode.ai/config.json',
mcp: { codegraph: getOpencodeServerEntry() },
mcp: { servers: { codegraph: getOpencodeServerEntry() } },
}, null, 2);
return `# Add to ${target}\n\n${snippet}\n`;
}
@@ -199,10 +223,12 @@ function writeMcpEntry(loc: Location): WriteResult['files'][number] {
}
const config = parseConfig(text);
const before = config.mcp?.codegraph;
const before = config.mcp?.servers?.codegraph;
const after = getOpencodeServerEntry();
const hasLegacy = !!config.mcp?.codegraph;
if (jsonDeepEqual(before, after)) {
// Native entry already matches and no v1 leftover → nothing to do.
if (jsonDeepEqual(before, after) && !hasLegacy) {
return { path: file, action: 'unchanged' };
}
@@ -214,9 +240,18 @@ function writeMcpEntry(loc: Location): WriteResult['files'][number] {
text = applyEdits(text, schemaEdits);
}
// Migrate pre-#1698 `mcp.codegraph` (+ enabled) off the file so OpenCode 2
// keeps only the native entry where `codemode` survives normalization.
if (hasLegacy) {
const legacyEdits = modify(text, ['mcp', 'codegraph'], undefined, {
formattingOptions: FORMATTING,
});
text = applyEdits(text, legacyEdits);
}
// Surgical edit — preserves comments, formatting, and order of
// every key we don't touch.
const edits = modify(text, ['mcp', 'codegraph'], after, {
const edits = modify(text, ['mcp', 'servers', 'codegraph'], after, {
formattingOptions: FORMATTING,
});
const updated = applyEdits(text, edits);
@@ -226,26 +261,50 @@ function writeMcpEntry(loc: Location): WriteResult['files'][number] {
}
/**
* Surgically drop `mcp.codegraph` from one config file. Leaves sibling
* servers, comments, and formatting untouched; drops an emptied `mcp`
* wrapper too. Shared by uninstall and the legacy-%APPDATA% sweep.
* Surgically drop our CodeGraph entry from one config file — either the
* OpenCode 2 native `mcp.servers.codegraph` or a pre-#1698 `mcp.codegraph`.
* Leaves sibling servers, comments, and formatting untouched; drops emptied
* `mcp.servers` / `mcp` wrappers too. Shared by uninstall and the
* legacy-%APPDATA% sweep.
*/
function removeMcpEntryAt(file: string): WriteResult['files'][number] {
if (!fs.existsSync(file)) return { path: file, action: 'not-found' };
const text = readConfigText(file);
let text = readConfigText(file);
const config = parseConfig(text);
if (!config.mcp?.codegraph) return { path: file, action: 'not-found' };
if (!hasCodegraphEntry(config)) return { path: file, action: 'not-found' };
let edits = modify(text, ['mcp', 'codegraph'], undefined, {
formattingOptions: FORMATTING,
});
let updated = applyEdits(text, edits);
let updated = text;
if (config.mcp?.servers?.codegraph) {
const edits = modify(updated, ['mcp', 'servers', 'codegraph'], undefined, {
formattingOptions: FORMATTING,
});
updated = applyEdits(updated, edits);
}
// Re-parse after the native removal so a file that held BOTH shapes
// (unusual, but possible mid-migration) still drops the v1 leftover.
const mid = parseConfig(updated);
if (mid.mcp?.codegraph) {
const edits = modify(updated, ['mcp', 'codegraph'], undefined, {
formattingOptions: FORMATTING,
});
updated = applyEdits(updated, edits);
}
// If `mcp.servers` is now an empty object, drop that wrapper.
let afterParsed = parseConfig(updated);
if (afterParsed.mcp?.servers && typeof afterParsed.mcp.servers === 'object' &&
Object.keys(afterParsed.mcp.servers).length === 0) {
const edits = modify(updated, ['mcp', 'servers'], undefined, {
formattingOptions: FORMATTING,
});
updated = applyEdits(updated, edits);
afterParsed = parseConfig(updated);
}
// If `mcp` is now an empty object, drop the wrapper too.
const afterParsed = parseConfig(updated);
if (afterParsed.mcp && typeof afterParsed.mcp === 'object' &&
Object.keys(afterParsed.mcp).length === 0) {
edits = modify(updated, ['mcp'], undefined, { formattingOptions: FORMATTING });
const edits = modify(updated, ['mcp'], undefined, { formattingOptions: FORMATTING });
updated = applyEdits(updated, edits);
}
+189 -25
View File
@@ -846,6 +846,141 @@ function fileSectionHeader(filePath: string, suffix: string): string {
const POINTER_HEADER = '**Not shown above — explore these names for their source**';
/** Most files the pointer list ever names one-per-line; the rest are a count. */
const POINTER_MAX_FILES = 10;
/**
* How many elided-in-file symbols a gap marker or biased header names (#1711).
* Kept small: the names exist so a follow-up explore has a target, not so the
* meta-text rivals the source it is pointing at.
*/
const ELIDED_SYMBOL_CAP = 6;
type ElidedSymbolRef = { name: string; kind: string; startLine: number };
/**
* Indexed symbols whose definition starts strictly between two rendered
* spans. The hole is what a trim dropped; naming them is what lets the agent
* follow up by name instead of guessing (#1711).
*/
export function symbolsBetweenRanges(
nodes: ReadonlyArray<{ name: string; kind: string; startLine: number; endLine: number }>,
fromEnd: number,
toStart: number,
): ElidedSymbolRef[] {
if (toStart <= fromEnd + 1) return [];
const out: ElidedSymbolRef[] = [];
const seen = new Set<string>();
for (const n of nodes) {
if (n.kind === 'import' || n.kind === 'export') continue;
if (n.startLine <= fromEnd || n.startLine >= toStart) continue;
if (seen.has(n.name)) continue;
seen.add(n.name);
out.push({ name: n.name, kind: n.kind, startLine: n.startLine });
}
out.sort((a, b) => a.startLine - b.startLine);
return out;
}
/**
* Symbols in `candidates` whose start line is not covered by any emitted range.
* Used to bias the per-file header toward what the trim dropped (#1711).
*/
export function symbolsNotInRanges(
candidates: ReadonlyArray<ElidedSymbolRef>,
ranges: ReadonlyArray<ExploreLineRange>,
): ElidedSymbolRef[] {
if (ranges.length === 0) return [...candidates].sort((a, b) => a.startLine - b.startLine);
const out: ElidedSymbolRef[] = [];
const seen = new Set<string>();
for (const n of candidates) {
if (seen.has(n.name)) continue;
if (ranges.some((r) => n.startLine >= r.start && n.startLine <= r.end)) continue;
seen.add(n.name);
out.push(n);
}
out.sort((a, b) => a.startLine - b.startLine);
return out;
}
/**
* Gap marker between two non-contiguous slices of one file.
*
* Bare `... (gap) ...` told the agent something was missing but not WHAT —
* and the footer then asked it to re-explore "with its exact name", which is
* circular when the missing names *are* the question (#1711). When the hole
* holds indexed symbols, list them as `name (file:line)` (same shape the
* flow / blast-radius lines already use).
*/
export function formatGapMarker(
filePath: string,
elided: ReadonlyArray<ElidedSymbolRef>,
): string {
if (elided.length === 0) return '\n\n... (gap) ...\n\n';
const shown = elided.slice(0, ELIDED_SYMBOL_CAP);
const more = elided.length - shown.length;
const names = shown.map((s) => `${s.name} (${filePath}:${s.startLine})`).join(', ')
+ (more > 0 ? `, +${more} more` : '');
return `\n\n... (gap: ${names}) ...\n\n`;
}
/** Join rendered parts with gap markers that name whatever the trim skipped. */
export function joinPartsWithNamedGaps(
filePath: string,
parts: ReadonlyArray<{ range: ExploreLineRange; text: string }>,
nodes: ReadonlyArray<{ name: string; kind: string; startLine: number; endLine: number }>,
): string {
if (parts.length === 0) return '';
let out = parts[0]!.text;
for (let i = 1; i < parts.length; i++) {
const prev = parts[i - 1]!;
const next = parts[i]!;
out += formatGapMarker(filePath, symbolsBetweenRanges(nodes, prev.range.end, next.range.start));
out += next.text;
}
return out;
}
/**
* Prefer symbols the trim dropped when filling the per-file header's named
* slots, so `+N more` is less likely to hide the answer (#1711). Frequency
* still breaks ties among the preferred / remaining groups.
*/
export function biasHeaderSymbols(
symbols: readonly string[],
elided: ReadonlyArray<ElidedSymbolRef>,
cap: number,
): { shown: string[]; omitted: number } {
const elidedLabels = elided.map((s) => `${s.name}(${s.kind})`);
const elidedSet = new Set(elidedLabels);
const elidedNames = new Set(elided.map((s) => s.name));
const counts = new Map<string, number>();
for (const s of symbols) counts.set(s, (counts.get(s) ?? 0) + 1);
// Also surface elided symbols that never made it into `symbols` (a dropped
// cluster's members are absent from assembleSection's list today).
for (const label of elidedLabels) {
if (!counts.has(label)) counts.set(label, 1);
}
// Earlier elided defs first (elided is startLine-sorted) so the header's
// named slots track source order through the hole rather than alphabetical
// filler (`calls0` beating `syncStateNow`).
const elidedRank = new Map<string, number>();
elided.forEach((s, i) => {
elidedRank.set(`${s.name}(${s.kind})`, i);
if (!elidedRank.has(s.name)) elidedRank.set(s.name, i);
});
const score = (label: string): [number, number, number] => {
const name = label.replace(/\(.*\)$/, '');
const preferred = elidedSet.has(label) || elidedNames.has(name) ? 1 : 0;
const rank = elidedRank.get(label) ?? elidedRank.get(name) ?? 9999;
return [preferred, counts.get(label) ?? 0, -rank];
};
const sorted = [...counts.keys()].sort((a, b) => {
const [pa, ca, ra] = score(a);
const [pb, cb, rb] = score(b);
return pb - pa || cb - ca || rb - ra || a.localeCompare(b);
});
const shown = sorted.slice(0, cap);
return { shown, omitted: Math.max(0, sorted.length - shown.length) };
}
/**
* One pointer line: the file plus enough symbol names to make it NAMEABLE in a
* follow-up explore. Capped — an un-capped list ran to ~1.9K on the #1500
@@ -942,6 +1077,12 @@ export interface ToolDefinition {
};
/** Behavioral hints for clients (see {@link ToolAnnotations}). */
annotations?: ToolAnnotations;
/**
* MCP `_meta` on the tool definition. `anthropic/alwaysLoad: true` makes
* Claude Code load the tool at session start instead of deferring it behind
* its tool search (https://code.claude.com/docs/en/mcp#exempt-a-server-from-deferral).
*/
_meta?: Record<string, unknown>;
}
/**
@@ -1196,6 +1337,9 @@ export const tools: ToolDefinition[] = [
required: ['query'],
},
annotations: READ_ONLY_ANNOTATIONS,
// Loaded from the first prompt in Claude Code, which otherwise defers every
// MCP tool behind a ToolSearch step (#1696).
_meta: { 'anthropic/alwaysLoad': true },
},
{
name: 'codegraph_status',
@@ -4391,6 +4535,10 @@ export class ToolHandler {
// (no `//` — not a comment in Python, Ruby, etc.). With line numbers on,
// the line-number jump also signals the gap.
const GAP_MARKER = '\n\n... (gap) ...\n\n';
// Full file index — not just the relevance-gathered `group.nodes`. A trim
// often drops filler symbols that never scored into the gather set; naming
// those holes still needs their defs (#1711).
const fileIndexNodes = cg.getNodesInFile(filePath);
// Cross-call dedup (CG-18). `served` is what THIS session already sent the
// agent for THIS file, and it is empty unless the file still hashes to the
@@ -4991,8 +5139,12 @@ export class ToolHandler {
// does the slicing; a second function mirroring these window/padding rules
// would drift.
type SectionPart = { range: ExploreLineRange; text: string };
// Named gaps (#1711): a bare `... (gap) ...` hid the answer when the
// trim dropped the symbols the query was asking for. Budget estimates
// still use GAP_MARKER.length (a lower bound); the final fit test
// measures the real joined text.
const sectionText = (parts: ReadonlyArray<SectionPart>): string =>
parts.map((p) => p.text).join(GAP_MARKER);
joinPartsWithNamedGaps(filePath, parts, fileIndexNodes);
const buildSection = (
c: { start: number; end: number; hasSpine?: boolean; spineCallLine?: number },
): SectionPart[] => {
@@ -5412,24 +5564,36 @@ export class ToolHandler {
// the fit test below trims the weakest cluster and re-assembles rather
// than skipping the file (CG-26).
const assembleSection = (chosen: ReadonlySet<number>) => {
let text = '';
const parts: SectionPart[] = [];
const symbols: string[] = [];
const ranges: ExploreLineRange[] = [];
const covered: ExploreLineRange[] = [];
for (let i = 0; i < clusters.length; i++) {
if (!chosen.has(i)) continue;
const cluster = clusters[i]!;
const section = renderedClusters.get(i)!;
const part = sectionText(section.parts);
if (part.length > 0) {
if (text.length > 0) text += GAP_MARKER;
text += part;
}
ranges.push(...section.parts.map((p) => p.range));
parts.push(...section.parts);
covered.push(...section.covered);
symbols.push(...cluster.symbols);
}
return { text, symbols, ranges, covered };
// Every member across ALL clusters (chosen or not) is a candidate for
// the header bias — a dropped cluster's symbols used to vanish from
// the header entirely (#1711).
const memberRefs: ElidedSymbolRef[] = [];
const seenMember = new Set<string>();
for (const c of clusters) {
for (const m of c.members) {
if (seenMember.has(m.name)) continue;
seenMember.add(m.name);
memberRefs.push({ name: m.name, kind: m.kind, startLine: m.start });
}
}
const ranges = parts.map((p) => p.range);
const text = joinPartsWithNamedGaps(filePath, parts, fileIndexNodes);
// Header bias prefers RELEVANT elisions (cluster members the trim cut)
// over incidental index filler — otherwise locale-sorted `calls0`…
// crowds out the answer methods (#1711).
const elided = symbolsNotInRanges(memberRefs, ranges);
return { text, symbols, ranges, covered, elided };
};
let assembled = assembleSection(chosenIndices);
@@ -5447,18 +5611,18 @@ export class ToolHandler {
// Dedupe + cap the symbols list shown in the per-file header. Some
// files (Session.swift in Alamofire) produced 3.4KB symbol lists
// from cluster scoring + edge-source lines, dwarfing the per-file
// body cap. Show top names by frequency, with a "+N more" tail.
const headerFor = (symbols: readonly string[]): string => {
const symbolCounts = new Map<string, number>();
for (const s of symbols) symbolCounts.set(s, (symbolCounts.get(s) ?? 0) + 1);
const sortedSymbols = [...symbolCounts.entries()]
.sort((a, b) => b[1] - a[1])
.map(([name]) => name);
const headerSymbols = sortedSymbols.slice(0, budget.maxSymbolsInFileHeader);
const omittedCount = sortedSymbols.length - headerSymbols.length;
return fileSectionHeader(filePath, omittedCount > 0
? `${headerSymbols.join(', ')}, +${omittedCount} more`
: headerSymbols.join(', '));
// body cap. Prefer symbols the trim elided (#1711), then frequency,
// with a "+N more" tail.
const headerFor = (
symbols: readonly string[],
elided: ReadonlyArray<ElidedSymbolRef> = [],
): string => {
const { shown, omitted } = biasHeaderSymbols(
symbols, elided, budget.maxSymbolsInFileHeader,
);
return fileSectionHeader(filePath, omitted > 0
? `${shown.join(', ')}, +${omitted} more`
: shown.join(', '));
};
// Last stop before the hard ceiling. The reservation already bounded cluster
@@ -5477,7 +5641,7 @@ export class ToolHandler {
// admitted, reserved and rendered, and would have delivered nothing.
// Only when the top-ranked cluster alone cannot fit is the file skipped —
// that one is never sliced mid-method.
let fileHeader = headerFor(assembled.symbols);
let fileHeader = headerFor(assembled.symbols, assembled.elided);
let chosenNow = chosenIndices;
const costOfSection = (header: string, body: string) =>
header.length + 2 + (body.length > 0 ? body.length + lang.length + 11 : 0);
@@ -5523,7 +5687,7 @@ export class ToolHandler {
chosenNow = trimmed;
}
assembled = assembleSection(chosenNow);
fileHeader = headerFor(assembled.symbols);
fileHeader = headerFor(assembled.symbols, assembled.elided);
anyFileTrimmed = true;
}
// One cluster left and still over — by the header estimate's error, at
@@ -5691,7 +5855,7 @@ export class ToolHandler {
const completenessBlock: string[] = budget.includeCompletenessSignal
? ['', '---', `> **Complete source for ${filesIncluded} files is included above — do NOT re-read them.** If your question also needs files/symbols listed under "Not shown above" (or any area this call didn't cover), make ANOTHER codegraph_explore targeting those names — it returns the same source with line numbers and is cheaper and more complete than reading. Reserve Read for a single specific line range explore can't surface.`]
: anyFileTrimmed
? ['', `> Some file sections were trimmed for size. For a specific symbol you still need, run another \`codegraph_explore\` (or \`codegraph_node\`) with its exact name — line-numbered source, cheaper and more complete than Read.`]
? ['', `> Some file sections were trimmed for size. Elided symbols are named inside gap markers as \`name (file:line)\` and preferred in the file header — run another \`codegraph_explore\` (or \`codegraph_node\`) with those exact names for their source.`]
: [];
// Explore budget note based on project size.
+41 -1
View File
@@ -452,9 +452,49 @@ function resolveRelativeImport(
return relativePath;
}
return findSourceForEmittedSpecifier(relativePath, language, context);
}
/**
* TypeScript under `moduleResolution: node16 | nodenext | bundler` writes the
* EMITTED extension in the specifier (`import x from './util.js'` for
* `util.ts`, `.mjs` for `.mts`, `.cjs` for `.cts`), and the source file with that
* exact name never exists in the repo. Without this remap the import resolver
* returned null for every such import, so each imported name fell through to
* bare-name matching: a method wrapping the same-named helper it imports
* (`renderDockStyles() { return renderDockStyles() }`) resolved to ITSELF, and
* any repo-wide same-named symbol could win the cross-module edge.
*/
const EMITTED_TO_SOURCE_EXTENSIONS: ReadonlyArray<readonly [RegExp, readonly string[]]> = [
[/\.js$/, ['.ts', '.tsx', '.d.ts']],
[/\.jsx$/, ['.tsx']],
[/\.mjs$/, ['.mts', '.d.mts']],
[/\.cjs$/, ['.cts', '.d.cts']],
];
function findSourceForEmittedSpecifier(
relativePath: string,
language: Language,
context: ResolutionContext
): string | null {
if (!EMITTED_SPECIFIER_LANGUAGES.has(language)) return null;
for (const [emitted, sources] of EMITTED_TO_SOURCE_EXTENSIONS) {
if (!emitted.test(relativePath)) continue;
const stem = relativePath.replace(emitted, '');
for (const ext of sources) {
const candidate = stem + ext;
if (context.fileExists(candidate)) return candidate;
}
return null;
}
return null;
}
/** Languages whose import specifiers can name the emitted `.js` of a `.ts` source. */
const EMITTED_SPECIFIER_LANGUAGES: ReadonlySet<string> = new Set([
'typescript', 'tsx', 'javascript', 'jsx', 'vue', 'svelte', 'astro', 'arkts',
]);
/**
* Resolve an aliased/absolute import.
*
@@ -479,7 +519,7 @@ function resolveAliasedImport(
if (context.fileExists(candidate)) return candidate;
}
if (context.fileExists(basePath)) return basePath;
return null;
return findSourceForEmittedSpecifier(basePath, language, context);
};
// 1. Project tsconfig/jsconfig paths.
+28 -1
View File
@@ -354,6 +354,9 @@ export function matchFunctionRef(
return null;
}
/** Languages with no nested named functions: nesting in the graph is never a scope. */
const NO_NESTED_FUNCTIONS = new Set<string>(['c', 'cpp']);
/**
* A function nested inside another FUNCTION is only callable from within its
* container — Python, JS/TS, and every closure language scope it lexically.
@@ -371,6 +374,14 @@ function isLexicallyReachable(
context: ResolutionContext
): boolean {
if (candidate.kind !== 'function') return true;
// C and C++ have no nested named functions, so a function the graph shows
// inside another is an extraction artifact, not a scope: tree-sitter-c
// cannot parse a macro call whose arguments are designated initializers
// (betaflight's `RESET_CONFIG(pidProfile_t, pidProfile, .pid = {…})`), and
// its error recovery runs the enclosing function_definition to the end of
// the file, nesting every function after it. Trusting that nesting rejected
// 117 real calls into pid.c on that tree; the functions are reachable.
if (NO_NESTED_FUNCTIONS.has(candidate.language)) return true;
const qn = candidate.qualifiedName;
if (!qn || !qn.includes('::')) return true;
const parentQn = qn.slice(0, qn.lastIndexOf('::'));
@@ -1686,6 +1697,12 @@ function buildLocalReceiverTypePatterns(language: Language, r: string): RegExp[]
case 'python':
return [
new RegExp(`\\b${r}\\b\\s*=\\s*([A-Z][\\w.]*)\\s*\\(`), // lg = Logger(...)
// A quoted forward reference (`lg: "Logger"`, `lg: 'pkg.Logger'`) is the
// same annotation — and what every file under `from __future__ import
// annotations` or with a not-yet-defined class writes. The unquoted
// pattern below stopped at the quote and read no type at all, so the
// call produced no edge (#1684). Tried first: it is the stricter shape.
new RegExp(`\\b${r}\\b\\s*:\\s*["']([A-Z][\\w.]*)["']`), // lg: "Logger"
new RegExp(`\\b${r}\\b\\s*:\\s*([A-Z][\\w.]*)`), // lg: Logger (PEP 526)
];
case 'java':
@@ -2799,13 +2816,23 @@ export function matchFuzzy(
// a lone one and manufacture a 0.5 guess out of an ambiguity fuzzy declines.
// Also decline a bare JS/TS call whose only survivor is a method or a
// cross-file name the file already binds locally (#1714).
// A function nested inside another function is only callable from inside
// its container (#1230), so a builtin method call (`res.text()`) whose only
// same-named project symbol is some file's closure must decline (#1708).
// The check sits on the ONE candidate this strategy would commit to, not on
// the candidate set: filtering the unreachable ones out of a crowd would
// leave a single survivor and hand it every call of that name — on vite,
// `import { resolve } from 'node:path'` in a dozen playground configs onto
// the one reachable `resolve` method (#1709). Reachability may reject a
// unique guess; it must never manufacture one.
if (
finalCandidates.length === 1 &&
isVisibleAcrossFiles(finalCandidates[0]!, ref, context) &&
isCrossFileReachable(finalCandidates[0]!, ref, context) &&
!(isBareJsCall(ref, context) &&
(finalCandidates[0]!.kind === 'method' ||
(finalCandidates[0]!.filePath !== ref.filePath && isLocallyBoundJsName(ref.referenceName, ref.filePath, context))))
(finalCandidates[0]!.filePath !== ref.filePath && isLocallyBoundJsName(ref.referenceName, ref.filePath, context)))) &&
isLexicallyReachable(finalCandidates[0]!, ref, context)
) {
const isCrossLanguage = finalCandidates[0]!.language !== ref.language;
return {