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
@@ -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);
});
});