mirror of
https://github.com/colbymchenry/codegraph.git
synced 2026-10-26 04:19:33 +08:00
Merge remote-tracking branch 'origin/main' into fix/kotlin-property-initializer-walk
# Conflicts: # CHANGELOG.md
This commit is contained in:
@@ -222,6 +222,7 @@ 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.
|
||||
- `codegraph callers`, `callees`, and `query` now clearly report when their result limit hides additional matches, including exact totals in callers/callees JSON output; the `codegraph_callers` and `codegraph_callees` MCP answers carry the same "showing N of M" note. (#1639, #1674)
|
||||
- CommonJS controllers written as `exports.getItems = async (req, res) => {…}` or `module.exports.x = function () {…}` are now indexed as exported functions, so `node`, `callers` and impact find every Express handler in that style and the calls inside them belong to the handler instead of the file. Re-index JavaScript projects after upgrading. (#1675)
|
||||
- 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)
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
import { spawnSync } from 'child_process';
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import { CodeGraph } from '../src';
|
||||
|
||||
const BIN = path.resolve(__dirname, '../dist/bin/codegraph.js');
|
||||
|
||||
function runCli(cwd: string, args: string[]) {
|
||||
return spawnSync(process.execPath, [BIN, ...args, '-p', cwd], {
|
||||
encoding: 'utf-8',
|
||||
env: { ...process.env, CODEGRAPH_NO_DAEMON: '1', CODEGRAPH_WASM_RELAUNCHED: '1', NO_COLOR: '1' },
|
||||
});
|
||||
}
|
||||
|
||||
describe('CLI truncation reporting (#1639)', () => {
|
||||
let tempDir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-cli-truncation-'));
|
||||
fs.writeFileSync(
|
||||
path.join(tempDir, 'lib.ts'),
|
||||
[
|
||||
'export function target() {}',
|
||||
'export function helperA() {}',
|
||||
'export function helperB() {}',
|
||||
'export function helperC() {}',
|
||||
'export function source() { helperA(); helperB(); helperC(); }',
|
||||
'export function TargetHitOne() {}',
|
||||
'export function TargetHitTwo() {}',
|
||||
'export function TargetHitThree() {}',
|
||||
].join('\n'),
|
||||
);
|
||||
for (let i = 0; i < 3; i++) {
|
||||
fs.writeFileSync(
|
||||
path.join(tempDir, `caller-${i}.ts`),
|
||||
`import { target } from './lib';\nexport function caller${i}() { target(); }\n`,
|
||||
);
|
||||
}
|
||||
const cg = CodeGraph.initSync(tempDir);
|
||||
await cg.indexAll();
|
||||
cg.close();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('reports exact callers metadata in JSON and human output', () => {
|
||||
const jsonRun = runCli(tempDir, ['callers', 'target', '--limit', '2', '--json']);
|
||||
expect(jsonRun.status).toBe(0);
|
||||
const parsed = JSON.parse(jsonRun.stdout);
|
||||
expect(parsed.callers).toHaveLength(2);
|
||||
expect(parsed.total).toBeGreaterThan(2);
|
||||
expect(parsed.limit).toBe(2);
|
||||
expect(parsed.truncated).toBe(true);
|
||||
|
||||
const humanRun = runCli(tempDir, ['callers', 'target', '--limit', '2']);
|
||||
expect(humanRun.stdout).toMatch(/Callers of "target" \(2 of \d+\):/);
|
||||
expect(humanRun.stdout).toMatch(/Showing 2 of \d+; pass --limit to widen\./);
|
||||
|
||||
const complete = JSON.parse(runCli(tempDir, ['callers', 'target', '--limit', '100', '--json']).stdout);
|
||||
expect(complete.total).toBe(complete.callers.length);
|
||||
expect(complete.limit).toBe(100);
|
||||
expect(complete.truncated).toBe(false);
|
||||
});
|
||||
|
||||
it('reports exact callees metadata in JSON and human output', () => {
|
||||
const jsonRun = runCli(tempDir, ['callees', 'source', '--limit', '2', '--json']);
|
||||
expect(jsonRun.status).toBe(0);
|
||||
const parsed = JSON.parse(jsonRun.stdout);
|
||||
expect(parsed.callees).toHaveLength(2);
|
||||
expect(parsed.total).toBeGreaterThan(2);
|
||||
expect(parsed.limit).toBe(2);
|
||||
expect(parsed.truncated).toBe(true);
|
||||
|
||||
const humanRun = runCli(tempDir, ['callees', 'source', '--limit', '2']);
|
||||
expect(humanRun.stdout).toMatch(/Callees of "source" \(2 of \d+\):/);
|
||||
expect(humanRun.stdout).toMatch(/Showing 2 of \d+; pass --limit to widen\./);
|
||||
|
||||
const complete = JSON.parse(runCli(tempDir, ['callees', 'source', '--limit', '100', '--json']).stdout);
|
||||
expect(complete.total).toBe(complete.callees.length);
|
||||
expect(complete.limit).toBe(100);
|
||||
expect(complete.truncated).toBe(false);
|
||||
});
|
||||
|
||||
it('keeps query --json as an array and reports truncation on stderr', () => {
|
||||
const jsonRun = runCli(tempDir, ['query', 'TargetHit', '--limit', '1', '--json']);
|
||||
expect(jsonRun.status).toBe(0);
|
||||
expect(JSON.parse(jsonRun.stdout)).toHaveLength(1);
|
||||
expect(jsonRun.stderr).toContain('Results truncated at 1; pass --limit to widen.');
|
||||
|
||||
const humanRun = runCli(tempDir, ['query', 'TargetHit', '--limit', '1']);
|
||||
expect(humanRun.stdout).toContain('Results truncated at 1; pass --limit to widen.');
|
||||
|
||||
const complete = runCli(tempDir, ['query', 'TargetHit', '--limit', '100', '--json']);
|
||||
expect(Array.isArray(JSON.parse(complete.stdout))).toBe(true);
|
||||
expect(complete.stderr).not.toContain('Results truncated');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,97 @@
|
||||
/**
|
||||
* The MCP `codegraph_callers` / `codegraph_callees` answers say when their
|
||||
* `limit` cut the list (#1639, #1674). A capped list with no marker reads as
|
||||
* the complete set, and an agent under-counts "who calls this" from it.
|
||||
*/
|
||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import * as os from 'os';
|
||||
import { CodeGraph } from '../src';
|
||||
import { ToolHandler } from '../src/mcp/tools';
|
||||
import { initGrammars, loadAllGrammars } from '../src/extraction/grammars';
|
||||
|
||||
let tmpDir: string;
|
||||
let cg: CodeGraph;
|
||||
let handler: ToolHandler;
|
||||
|
||||
const text = async (tool: string, args: Record<string, unknown>): Promise<string> => {
|
||||
const res = await handler.execute(tool, args);
|
||||
return res.content?.[0]?.text ?? '';
|
||||
};
|
||||
|
||||
const CALLERS = 25;
|
||||
|
||||
beforeAll(async () => {
|
||||
await initGrammars();
|
||||
await loadAllGrammars();
|
||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-1674-'));
|
||||
fs.mkdirSync(path.join(tmpDir, 'src'));
|
||||
// `warm` lives in a file of another name: one definition, the flat list.
|
||||
fs.writeFileSync(path.join(tmpDir, 'src', 'target.ts'), 'export function warm(n: number): number { return n; }\n');
|
||||
fs.writeFileSync(
|
||||
path.join(tmpDir, 'src', 'callers.ts'),
|
||||
"import { warm } from './target';\n" +
|
||||
Array.from({ length: CALLERS }, (_, i) => `export function caller${i}(): number { return warm(${i}); }`).join('\n') +
|
||||
'\n'
|
||||
);
|
||||
// `hot` shares its name with its file, so the answer groups per definition.
|
||||
fs.writeFileSync(path.join(tmpDir, 'src', 'hot.ts'), 'export function hot(n: number): number { return n; }\n');
|
||||
fs.writeFileSync(
|
||||
path.join(tmpDir, 'src', 'hot-callers.ts'),
|
||||
"import { hot } from './hot';\n" +
|
||||
Array.from({ length: CALLERS }, (_, i) => `export function hotCaller${i}(): number { return hot(${i}); }`).join('\n') +
|
||||
'\n'
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(tmpDir, 'src', 'fan.ts'),
|
||||
Array.from({ length: CALLERS }, (_, i) => `export function helper${i}(): number { return ${i}; }`).join('\n') +
|
||||
`\nexport function fanout(): number { return ${Array.from({ length: CALLERS }, (_, i) => `helper${i}()`).join(' + ')}; }\n`
|
||||
);
|
||||
cg = CodeGraph.initSync(tmpDir);
|
||||
await cg.indexAll();
|
||||
handler = new ToolHandler(cg);
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
cg.destroy();
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe('codegraph_callers truncation', () => {
|
||||
it('says how many callers the default limit hid', async () => {
|
||||
const out = await text('codegraph_callers', { symbol: 'warm' });
|
||||
// The importing file counts as a caller too, so the total is at least CALLERS.
|
||||
const m = out.match(/Showing 20 of (\d+) callers; pass `limit`/);
|
||||
expect(m).not.toBeNull();
|
||||
expect(Number(m![1])).toBeGreaterThanOrEqual(CALLERS);
|
||||
expect(out.match(/^- caller\d+ /gm)?.length).toBe(20);
|
||||
});
|
||||
|
||||
it('is silent when the list is complete', async () => {
|
||||
const out = await text('codegraph_callers', { symbol: 'warm', limit: 100 });
|
||||
expect(out).not.toContain('Showing');
|
||||
expect(out.match(/^- caller\d+ /gm)?.length).toBe(CALLERS);
|
||||
});
|
||||
|
||||
it('marks the cut inside each per-definition section too', async () => {
|
||||
const out = await text('codegraph_callers', { symbol: 'hot' });
|
||||
expect(out).toContain('distinct definitions');
|
||||
expect(out).toMatch(/- … \+\d+ more \(pass `limit` to widen\)/);
|
||||
expect(await text('codegraph_callers', { symbol: 'hot', limit: 100 })).not.toContain('more (pass');
|
||||
});
|
||||
});
|
||||
|
||||
describe('codegraph_callees truncation', () => {
|
||||
it('says how many callees the default limit hid', async () => {
|
||||
const out = await text('codegraph_callees', { symbol: 'fanout' });
|
||||
const m = out.match(/Showing 20 of (\d+) callees; pass `limit`/);
|
||||
expect(m).not.toBeNull();
|
||||
expect(Number(m![1])).toBe(CALLERS);
|
||||
});
|
||||
|
||||
it('is silent when the list is complete', async () => {
|
||||
const out = await text('codegraph_callees', { symbol: 'fanout', limit: 100 });
|
||||
expect(out).not.toContain('Showing');
|
||||
});
|
||||
});
|
||||
+21
-6
@@ -1164,7 +1164,9 @@ program
|
||||
|
||||
const limit = parseInt(options.limit || '10', 10);
|
||||
const rawResults = cg.searchNodes(search, {
|
||||
limit,
|
||||
// Fetch one extra row so the CLI can report a cut without changing the
|
||||
// long-standing bare-array contract of `query --json` (#1639).
|
||||
limit: limit + 1,
|
||||
kinds: options.kind ? [options.kind as any] : undefined,
|
||||
});
|
||||
|
||||
@@ -1172,14 +1174,18 @@ program
|
||||
// hand-written implementation before protobuf/gRPC scaffolding
|
||||
// when both share a name. See extraction/generated-detection.ts.
|
||||
const isGen = cg.generatedFilePredicate(rawResults.map((r) => r.node.filePath));
|
||||
const results = [...rawResults].sort((a, b) => {
|
||||
const rankedResults = [...rawResults].sort((a, b) => {
|
||||
const aGen = isGen(a.node.filePath) ? 1 : 0;
|
||||
const bGen = isGen(b.node.filePath) ? 1 : 0;
|
||||
return aGen - bGen;
|
||||
});
|
||||
const truncated = rankedResults.length > limit;
|
||||
const results = rankedResults.slice(0, limit);
|
||||
const truncationMessage = `Results truncated at ${limit}; pass --limit to widen.`;
|
||||
|
||||
if (options.json) {
|
||||
console.log(JSON.stringify(results, null, 2));
|
||||
if (truncated) console.error(truncationMessage);
|
||||
} else {
|
||||
if (results.length === 0) {
|
||||
info(`No results found for "${search}"`);
|
||||
@@ -1205,6 +1211,7 @@ program
|
||||
}
|
||||
console.log();
|
||||
}
|
||||
if (truncated) console.log(chalk.dim(truncationMessage));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2183,13 +2190,16 @@ program
|
||||
}
|
||||
|
||||
const limited = allCallers.slice(0, limit);
|
||||
const total = allCallers.length;
|
||||
const truncated = total > limit;
|
||||
|
||||
if (options.json) {
|
||||
console.log(JSON.stringify({ symbol, callers: limited }, null, 2));
|
||||
console.log(JSON.stringify({ symbol, callers: limited, total, limit, truncated }, null, 2));
|
||||
} else if (limited.length === 0) {
|
||||
info(`No callers found for "${symbol}"`);
|
||||
} else {
|
||||
console.log(chalk.bold(`\nCallers of "${symbol}" (${limited.length}):\n`));
|
||||
const count = truncated ? `${limited.length} of ${total}` : String(total);
|
||||
console.log(chalk.bold(`\nCallers of "${symbol}" (${count}):\n`));
|
||||
for (const node of limited) {
|
||||
const loc = node.startLine ? `:${node.startLine}` : '';
|
||||
console.log(
|
||||
@@ -2199,6 +2209,7 @@ program
|
||||
console.log(chalk.dim(` ${node.filePath}${loc}`));
|
||||
console.log();
|
||||
}
|
||||
if (truncated) console.log(chalk.dim(`Showing ${limited.length} of ${total}; pass --limit to widen.`));
|
||||
}
|
||||
|
||||
cg.destroy();
|
||||
@@ -2261,13 +2272,16 @@ program
|
||||
}
|
||||
|
||||
const limited = allCallees.slice(0, limit);
|
||||
const total = allCallees.length;
|
||||
const truncated = total > limit;
|
||||
|
||||
if (options.json) {
|
||||
console.log(JSON.stringify({ symbol, callees: limited }, null, 2));
|
||||
console.log(JSON.stringify({ symbol, callees: limited, total, limit, truncated }, null, 2));
|
||||
} else if (limited.length === 0) {
|
||||
info(`No callees found for "${symbol}"`);
|
||||
} else {
|
||||
console.log(chalk.bold(`\nCallees of "${symbol}" (${limited.length}):\n`));
|
||||
const count = truncated ? `${limited.length} of ${total}` : String(total);
|
||||
console.log(chalk.bold(`\nCallees of "${symbol}" (${count}):\n`));
|
||||
for (const node of limited) {
|
||||
const loc = node.startLine ? `:${node.startLine}` : '';
|
||||
console.log(
|
||||
@@ -2277,6 +2291,7 @@ program
|
||||
console.log(chalk.dim(` ${node.filePath}${loc}`));
|
||||
console.log();
|
||||
}
|
||||
if (truncated) console.log(chalk.dim(`Showing ${limited.length} of ${total}; pass --limit to widen.`));
|
||||
}
|
||||
|
||||
cg.destroy();
|
||||
|
||||
+18
-2
@@ -2419,7 +2419,12 @@ export class ToolHandler {
|
||||
// A successful `file` narrowing makes the multi-symbol aggregation note
|
||||
// stale — suppress it.
|
||||
const note = fileFilter && !filteredOut ? '' : allMatches.note;
|
||||
const formatted = this.formatNodeList(callers.slice(0, limit), `Callers of ${symbol}`, labels) + note + filterNote;
|
||||
// Say when the cap cut the list (#1639, #1674): a truncated answer with
|
||||
// no marker reads as the complete set, and an agent under-counts from it.
|
||||
const cut = callers.length > limit
|
||||
? `\n\n> Showing ${limit} of ${callers.length} callers; pass \`limit\` (up to 100) to widen.`
|
||||
: '';
|
||||
const formatted = this.formatNodeList(callers.slice(0, limit), `Callers of ${symbol}`, labels) + cut + note + filterNote;
|
||||
return this.textResult(this.truncateOutput(formatted));
|
||||
}
|
||||
|
||||
@@ -2441,6 +2446,9 @@ export class ToolHandler {
|
||||
const label = labels.get(node.id);
|
||||
lines.push(`- ${node.name} (${node.kind}) - ${node.filePath}${location}${label ? ` — via ${label}` : ''}`);
|
||||
}
|
||||
if (callers.length > limit) {
|
||||
lines.push(`- … +${callers.length - limit} more (pass \`limit\` to widen)`);
|
||||
}
|
||||
}
|
||||
return this.textResult(this.truncateOutput(lines.join('\n') + filterNote));
|
||||
}
|
||||
@@ -2491,7 +2499,12 @@ export class ToolHandler {
|
||||
// A successful `file` narrowing makes the multi-symbol aggregation note
|
||||
// stale — suppress it.
|
||||
const note = fileFilter && !filteredOut ? '' : allMatches.note;
|
||||
const formatted = this.formatNodeList(callees.slice(0, limit), `Callees of ${symbol}`, labels) + note + filterNote;
|
||||
// Say when the cap cut the list (#1639, #1674): a truncated answer with
|
||||
// no marker reads as the complete set, and an agent under-counts from it.
|
||||
const cut = callees.length > limit
|
||||
? `\n\n> Showing ${limit} of ${callees.length} callees; pass \`limit\` (up to 100) to widen.`
|
||||
: '';
|
||||
const formatted = this.formatNodeList(callees.slice(0, limit), `Callees of ${symbol}`, labels) + cut + note + filterNote;
|
||||
return this.textResult(this.truncateOutput(formatted));
|
||||
}
|
||||
|
||||
@@ -2511,6 +2524,9 @@ export class ToolHandler {
|
||||
const label = labels.get(node.id);
|
||||
lines.push(`- ${node.name} (${node.kind}) - ${node.filePath}${location}${label ? ` — via ${label}` : ''}`);
|
||||
}
|
||||
if (callees.length > limit) {
|
||||
lines.push(`- … +${callees.length - limit} more (pass \`limit\` to widen)`);
|
||||
}
|
||||
}
|
||||
return this.textResult(this.truncateOutput(lines.join('\n') + filterNote));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user