Skip to content

Commit f2594f3

Browse files
committed
feat(mcp): return function-pointer calls apart from resolved callees
- grepnavi_callees lists member calls under indirect_calls with the member name, call line, receiver and source text, instead of resolving the member name and attaching a confidence to whatever same-named function the index returned first - The list survives compact mode and deeper levels, so an agent cannot conclude that a function calls nothing through its ops table - A one-line note points at grepnavi_references(word, assign: true) for the registration sites; the limits (positional and macro-built tables) are in the tool description
1 parent c04f1ea commit f2594f3

6 files changed

Lines changed: 113 additions & 15 deletions

File tree

mcp/README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ flowchart TD
4040
| `grepnavi_ifdef_context` | ある行を囲んでいる `#ifdef` / `#if` を外側から順に列挙 |
4141
| `grepnavi_path` | 関数 A から関数 B への**呼び出し経路を 1 本だけ**返す (callers を全部辿らずに繋がりを確かめる) |
4242
| `grepnavi_callers` | 関数を呼んでいる箇所一覧 (call tree を上に辿る、`depth` で再帰) |
43-
| `grepnavi_callees` | 関数内から呼ばれている識別子一覧 + 各定義解決 (call_line, kind, engine, **`confidence`** (high/medium/low — low は silent failure 警告), likely_macro / likely_non_callable, self 自動除外、`depth` で再帰)。**`exclude_macros` / `exclude_non_callable` はデフォルト true** (ノイズ除去)。`excluded.macros` / `excluded.non_callable`**名前リスト** で返す → 再 query 無しで「捨てたもの」を確認可能。definitions は path proximity で **top 1** のみ surface、`definitions_total` で件数通知 |
43+
| `grepnavi_callees` | 関数内から呼ばれている識別子一覧 + 各定義解決。関数ポインタ経由 (`s->method->ssl_read(`) は `indirect_calls` に分けて返し、メンバ名で定義を引かない (call_line, kind, engine, **`confidence`** (high/medium/low — low は silent failure 警告), likely_macro / likely_non_callable, self 自動除外、`depth` で再帰)。**`exclude_macros` / `exclude_non_callable` はデフォルト true** (ノイズ除去)。`excluded.macros` / `excluded.non_callable`**名前リスト** で返す → 再 query 無しで「捨てたもの」を確認可能。definitions は path proximity で **top 1** のみ surface、`definitions_total` で件数通知 |
4444
| `grepnavi_list_insertions` | **記録済みデバッグ行の一覧**(読み取りのみ)。`source``mcp` なら AI が撒いた行、空なら人が GUI で入れた行。`id` はソースに焼き込まれた `{tag}` そのものなので、実機やプログラムが吐いた `[GN9]` から file:line と行の中身を引ける (`tag: "GN9"`)。`group` / `file` で絞り込み。**出力に出なかった = その経路を通らなかった**の判断は `enabled: true` かつバイナリが挿入後にビルドされている場合にのみ成立する |
4545
| `grepnavi_graph_list` | 既存ノード一覧 (id / label / file:line / memo / children) |
4646

mcp/skills/grepnavi/references/accuracy.md

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -28,9 +28,12 @@ gtags was right — which is why it goes first.
2828
These are invisible to `callers`, `callees`, and `path`. They are not bugs to be fixed; they are the
2929
boundary of the approach:
3030

31-
- **Calls through function pointers.** `pol->pd_alloc_fn(...)` resolves to nothing. In callee
32-
results these arrive with `kind: "member"`; grepnavi marks them and refuses to expand them,
33-
because guessing would silently attach an unrelated function.
31+
- **Calls through function pointers.** `pol->pd_alloc_fn(...)` resolves to nothing. `callees`
32+
returns them apart, under `indirect_calls` with the member `name` and its `receiver`
33+
(`pol`), and never looks the member name up as a function — that would attach an unrelated
34+
same-named function with a confidence on it. To see what may be stored there,
35+
`references(word: name, assign: true)` lists `.name = fn` initializers and `x->name = fn`
36+
assignments; tables filled positionally or by macros (OpenSSL method tables) stay invisible.
3437
- **Calls generated by macros.** A macro that expands into a call leaves no textual call site.
3538
- **Anything behind a preprocessor condition you did not check.** Text search sees all branches.
3639

mcp/src/client.ts

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,17 @@ export interface CallSite {
5050
text?: string;
5151
}
5252

53+
/** /api/callees の 1 件。indirect は `s->method->ssl_read(` のようなメンバ呼び出し。 */
54+
export interface RawCallee {
55+
name: string;
56+
call_line: number;
57+
kind?: string;
58+
text?: string;
59+
indirect?: boolean;
60+
/** メンバの持ち主の式 (`s->method`)。戻り値のメンバなど名前で追えない形では無い。 */
61+
receiver?: string;
62+
}
63+
5364
export interface SearchMatch {
5465
file: string;
5566
line: number;
@@ -527,16 +538,12 @@ export class GrepnaviClient {
527538
// /api/callees は (file, line) で「その関数定義の中から呼ばれる識別子と呼び出し行」を返す。
528539
// 新しい server は [{name, call_line}]、古い server は []string を返すので両形式を吸収する
529540
// (古い server に当たっても crash させない)。
530-
async callees(file: string, line: number): Promise<Array<{ name: string; call_line: number }>> {
541+
async callees(file: string, line: number): Promise<RawCallee[]> {
531542
const params = new URLSearchParams({ file, line: String(line) });
532543
const r = await this.req("/api/callees?" + params.toString());
533544
const data = (await r.json()) as unknown;
534545
if (!Array.isArray(data)) return [];
535-
return data.map((c) =>
536-
typeof c === "string"
537-
? { name: c, call_line: 0 }
538-
: (c as { name: string; call_line: number }),
539-
);
546+
return data.map((c) => (typeof c === "string" ? { name: c, call_line: 0 } : (c as RawCallee)));
540547
}
541548

542549
async graph(): Promise<GraphResponse> {

mcp/src/helpers.ts

Lines changed: 55 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { annotateMemo, likelyTrivial, inCallerSubtree } from "./client.js";
2-
import type { DefHit, GrepnaviClient, MemoCategory } from "./client.js";
2+
import type { DefHit, GrepnaviClient, MemoCategory, RawCallee } from "./client.js";
33
import { client } from "./shared.js";
44
import type { BatchNodeInput, CallerTreeNode } from "./shared.js";
55

@@ -492,10 +492,41 @@ export async function enrichCallee(name: string, callerFile?: string) {
492492
export type EnrichedCallee = Awaited<ReturnType<typeof enrichCallee>> & {
493493
call_line: number;
494494
children?: EnrichedCallee[];
495+
indirect_calls?: IndirectCall[];
495496
recursion_stopped?: "depth_limit" | "non_callable" | "already_visited" | "no_definition";
496497
body_preview?: string;
497498
};
498499

500+
// 関数ポインタ経由の呼び出し。名前で定義を引くと同名の別関数に当たるので、
501+
// 定義解決の列には混ぜず、「メンバ名」「持ち主」「行」だけを別の列で返す。
502+
export type IndirectCall = {
503+
name: string;
504+
call_line: number;
505+
receiver?: string;
506+
text?: string;
507+
};
508+
509+
// 関数ポインタ経由の列に添える一行。手段と限界の説明はツール説明文にあり、
510+
// 応答は呼ぶたびに読まれるので、ここは次の一手だけを言う。
511+
export const indirectCallsNote =
512+
"The target is whatever `receiver`.`name` holds, not decidable from text. Candidates: grepnavi_references(word: name, assign: true).";
513+
514+
export function splitIndirectCallees(raw: RawCallee[]): { direct: RawCallee[]; indirect: IndirectCall[] } {
515+
const direct: RawCallee[] = [];
516+
const indirect: IndirectCall[] = [];
517+
for (const c of raw) {
518+
if (!c.indirect) {
519+
direct.push(c);
520+
continue;
521+
}
522+
const e: IndirectCall = { name: c.name, call_line: c.call_line };
523+
if (c.receiver) e.receiver = c.receiver;
524+
if (c.text) e.text = c.text.trim();
525+
indirect.push(e);
526+
}
527+
return { direct, indirect };
528+
}
529+
499530
// callees に optional な func body preview を付ける。
500531
// 各 callee の top definition (kind=func 推奨) に対して /api/func-body を呼び、
501532
// 先頭 N 行を抜き出して body_preview に入れる。
@@ -529,14 +560,18 @@ export async function fetchEnrichedCallees(
529560
selfName: string,
530561
filters: { exclude_macros?: boolean; exclude_non_callable?: boolean },
531562
): Promise<{
532-
raw: Array<{ name: string; call_line: number }>;
533-
filtered: Array<{ name: string; call_line: number }>;
563+
raw: RawCallee[];
564+
filtered: RawCallee[];
534565
enriched: EnrichedCallee[];
566+
indirect: IndirectCall[];
535567
excludedMacroNames: string[];
536568
excludedNonCallableNames: string[];
537569
}> {
538570
const raw = await client.callees(file, line);
539-
const filtered = selfName ? raw.filter((c) => c.name !== selfName) : raw;
571+
// 関数ポインタ経由は定義解決に掛けない: メンバ名で definition を引くと同名の
572+
// 別関数 (`read`) が top に立ち、confidence まで付いて本物に見える。
573+
const { direct, indirect } = splitIndirectCallees(raw);
574+
const filtered = selfName ? direct.filter((c) => c.name !== selfName) : direct;
540575
const enriched = await Promise.all(
541576
filtered.map(async (c) => ({ ...c, ...(await enrichCallee(c.name, file)) })),
542577
);
@@ -560,6 +595,7 @@ export async function fetchEnrichedCallees(
560595
raw,
561596
filtered,
562597
enriched: kept,
598+
indirect,
563599
excludedMacroNames,
564600
excludedNonCallableNames,
565601
};
@@ -653,10 +689,15 @@ export async function resolveAndEnrichCallees(args: {
653689
depth: maxDepth,
654690
total: top.raw.length,
655691
excluded: {
656-
self: top.raw.length - top.filtered.length,
692+
self: top.raw.length - top.filtered.length - top.indirect.length,
657693
macros: top.excludedMacroNames,
658694
non_callable: top.excludedNonCallableNames,
659695
},
696+
// 関数ポインタ経由は callees の列に混ぜない。混ぜると同名の別関数に
697+
// 解決された「本物らしい」行が並び、読む側は辿っているつもりで別の実装へ行く
698+
...(top.indirect.length
699+
? { indirect_calls: { count: top.indirect.length, note: indirectCallsNote, calls: top.indirect } }
700+
: {}),
660701
};
661702
if (args.compact !== undefined) {
662703
return { ...base, callees: args.compact ? toCompactCallees(top.enriched) : top.enriched };
@@ -696,6 +737,9 @@ export type CompactCallee = {
696737
pin?: boolean;
697738
def?: string; // "file:line"。定義が引けなかったときは省略
698739
children?: CompactCallee[];
740+
// 関数ポインタ経由は compact でも落とさない: 落とすと「この関数は ops 経由で
741+
// 何も呼んでいない」と読めてしまう。行と受け手だけに畳む
742+
indirect_calls?: Array<{ name: string; call_line: number; receiver?: string }>;
699743
recursion_stopped?: string;
700744
};
701745

@@ -708,6 +752,11 @@ export function toCompactCallees(nodes: EnrichedCallee[]): CompactCallee[] {
708752
if (def) c.def = `${def.file}:${def.line}`;
709753
if (n.recursion_stopped) c.recursion_stopped = n.recursion_stopped;
710754
if (n.children?.length) c.children = toCompactCallees(n.children);
755+
if (n.indirect_calls?.length) {
756+
c.indirect_calls = n.indirect_calls.map(({ name, call_line, receiver }) =>
757+
receiver ? { name, call_line, receiver } : { name, call_line },
758+
);
759+
}
711760
return c;
712761
});
713762
}
@@ -742,6 +791,7 @@ export async function expandCalleeRecursive(
742791

743792
const sub = await fetchEnrichedCallees(target.file, target.line, node.name, filters);
744793
node.children = sub.enriched;
794+
if (sub.indirect.length) node.indirect_calls = sub.indirect;
745795
for (const child of sub.enriched) {
746796
await expandCalleeRecursive(child, maxDepth, currentDepth + 1, visited, filters);
747797
}

mcp/src/tools/search.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -307,6 +307,7 @@ export const definitions: ToolDef[] = [
307307
"Pass `word` (caller name) for auto-resolve via grepnavi_definition; errors on ambiguity with candidate list, then disambiguate via `file`+`line`. Or pass `file`+`line` directly.\n\n" +
308308
"Each result: `name`, `call_line`, `kind`, `engine`, `confidence` ('high'|'medium'|'low' for the picked top definition — **'low' means the pick may be wrong**), `likely_macro`, `likely_non_callable`, `likely_trivial` (well-known primitives: locking / atomics / mem-str / printk / le_to_cpu / container_of etc — definition lookup is **skipped entirely** for these to avoid bogus picks like spin_lock → selftests/.../spinlock.c), `in_caller_subtree` (def shares caller's dir tree = same subsystem), `recommended_for_tree` (= !macro && !non_callable && !trivial — **the simple filter for 'what to actually pin'**), `definitions` (top 1, proximity-ranked), `definitions_total`. Caller itself auto-excluded.\n\n" +
309309
"**Defaults**: `exclude_macros: true`, `exclude_non_callable: true` (noise filtered out; pass false to see). The response's `excluded.macros` / `excluded.non_callable` are **arrays of NAMES** that were dropped — eyeball them to confirm they're real noise, no re-query needed.\n\n" +
310+
"**Calls through function pointers come back separately as `indirect_calls`** (`s->method->ssl_read(...)`, `ops.read(...)`): each has `name` (the member), `call_line`, `receiver` (`s->method`) and `text`. They are kept out of `callees` on purpose — resolving the member name as a function would pick an unrelated same-named function and dress it in a confidence. What the member holds is not decidable from text; grepnavi_references(word: name, assign: true) shows `.name = fn` initializers and `x->name = fn` assignments, but not tables built positionally or by macros.\n\n" +
310311
"`compact: true` drops the judgement fields and returns name / call_line / kind / `pin` (= recommended_for_tree) / `def` only — a fraction of the tokens when you only need the list to pick from.\n\n" +
311312
"**A large answer compacts itself.** When you pass neither value and the full form would be over ~8 KB, the response comes back compact with a `compacted` field naming what was dropped and how to get it — a function with 38 callees is 12.5 KB full and 5 KB compact, and past that size the answer tends to be set aside unread rather than used. Pass `compact: false` when you actually need the judgement fields, ideally with `depth: 1` or a narrower caller.\n\n" +
312313
"`depth` > 1 recurses (max 5). Macros / no-def / cycles don't recurse further. **Cost note**: each extra level fans out and can take seconds; start at depth 1 unless you need more.\n\n" +

mcp/test/callees.test.js

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
// callees の関数ポインタ経由の分離。
2+
import { test } from "node:test";
3+
import assert from "node:assert/strict";
4+
5+
import { splitIndirectCallees, toCompactCallees } from "../dist/helpers.js";
6+
7+
test("callees - メンバ呼び出しは定義解決の列から外れる", () => {
8+
const { direct, indirect } = splitIndirectCallees([
9+
{ name: "plain", call_line: 3, text: "plain(x);" },
10+
{ name: "ssl_read", call_line: 5, indirect: true, receiver: "s->method", text: " return s->method->ssl_read(s, buf, num);" },
11+
{ name: "write", call_line: 7, indirect: true, text: "get(s)->write(f);" },
12+
]);
13+
assert.deepEqual(direct.map((c) => c.name), ["plain"]);
14+
assert.deepEqual(indirect, [
15+
{ name: "ssl_read", call_line: 5, receiver: "s->method", text: "return s->method->ssl_read(s, buf, num);" },
16+
{ name: "write", call_line: 7, text: "get(s)->write(f);" },
17+
]);
18+
});
19+
20+
test("callees - compact でもメンバ呼び出しは残る", () => {
21+
const compact = toCompactCallees([
22+
{
23+
name: "f", call_line: 1, kind: "func", engine: "gtags",
24+
likely_macro: false, likely_non_callable: false, likely_trivial: false,
25+
in_caller_subtree: true, confidence: "high", recommended_for_tree: true,
26+
definitions_total: 1, definitions: [{ file: "/a.c", line: 10, kind: "func" }],
27+
indirect_calls: [
28+
{ name: "read", call_line: 12, receiver: "f->f_op", text: "f->f_op->read(f);" },
29+
{ name: "cb", call_line: 13, text: "get()->cb();" },
30+
],
31+
},
32+
]);
33+
assert.deepEqual(compact[0].indirect_calls, [
34+
{ name: "read", call_line: 12, receiver: "f->f_op" },
35+
{ name: "cb", call_line: 13 },
36+
]);
37+
});

0 commit comments

Comments
 (0)