Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion src/generate_topdown_layers.py
Original file line number Diff line number Diff line change
Expand Up @@ -313,7 +313,16 @@ def _build_call_graph(phase_files, proj_dir, global_stem_to_fqns=None, extra_cal
edge_aliases_map = defaultdict(lambda: defaultdict(set)) # callee -> caller -> aliases

phase_langs = {_detect_lang_from_ext(fp) for fp, _ in phase_files if _detect_lang_from_ext(fp)}
registry_edges, registry_langs = call_edges_all(proj_dir, phase_langs)
registry_edges_list, registry_langs = call_edges_all(proj_dir, phase_langs)
# codegraph edges are [{"caller": fqn, "callee": fqn, "kind": k}, ...]
# normalize_call_edges guarantees caller/callee are present, but use .get()
# defensively in case an edge bypasses the normalization layer.
registry_edges = defaultdict(set)
for edge in registry_edges_list:
caller = edge.get("caller")
callee = edge.get("callee")
if caller and callee:
registry_edges[caller].add(callee)

for filepath, module_name in phase_files:
fqn = fqn_map[filepath]
Expand Down
95 changes: 65 additions & 30 deletions src/languages/codegraph.py
Original file line number Diff line number Diff line change
Expand Up @@ -379,29 +379,19 @@ def get_function_spans(self, lang_key: str, abs_filepath: str):
for name, qualified_name, start, end in rows
]

def get_call_edges(self, lang_key: str) -> dict:
"""Return {caller_fqn: {callee_fqn, ...}} for the given language.

Each FQN matches generate_topdown_layers._file_to_fqn for the
corresponding extracted function (``dir::file-ext::dedup_name``). Edges
are resolved by codegraph NODE ID (not by bare name), so the precise
caller/callee identity codegraph computed — which file, which same-named
sibling — is preserved end-to-end. This lets the call-graph builder use
the edges directly instead of re-resolving bare names against every
same-named function (which collapsed siblings and over-approximated
across files).

Constructor calls are synthesised from `instantiates` edges: when a
function instantiates a class, the corresponding constructor method is
added as a callee. See _CONSTRUCTOR_FILTER for per-language details.

NOTE: codegraph itself collapses calls to same-named classes in different
files onto the first definition (a codegraph resolver limitation for C++,
not addressable here — see issues/codegraph-samename-class-resolution).
def get_call_edges(self, lang_key: str) -> list:
"""Return precisely resolved call edges for the given language.

Each edge preserves exact codegraph node identity through the extracted-
function FQN. No bare-name resolution is performed here.

Returns a list of ``{"caller": fqn, "callee": fqn, "kind": str,
"span": {...}}`` dicts, ordered by source location so call-site order
aligns with the source appearance order.
"""
cg_langs = _CG_LANG.get(lang_key)
if not cg_langs:
return {}
return []

conn = sqlite3.connect(self._db)
cur = conn.cursor()
Expand All @@ -411,32 +401,60 @@ def get_call_edges(self, lang_key: str) -> dict:
# dedup as get_functions_by_file, then resolve edges by node id.
fqn_of = _node_fqn_map(cur, cg_langs)

result = defaultdict(set)
result = []
seen = set()

# Query 1: regular function/method calls, kept as (source_id, target_id)
# so each endpoint resolves to its exact node's FQN.
# Call-site coordinates come from the EDGE (e.line/e.col), not the caller
# function node (s.start_line is the function DEFINITION location). The
# caller node provides the source FILE identity (the call site is always
# inside the caller's file), while the edge provides the precise
# call-site line/column. ORDER BY the edge coordinates so multiple call
# sites of the same callee are returned in true source order.
cur.execute(
f"""
SELECT e.source, e.target
SELECT e.source, e.target, s.file_path, e.line, e.col
FROM edges e
JOIN nodes s ON e.source = s.id
WHERE e.kind = 'calls' AND s.language IN ({placeholders})
ORDER BY s.file_path, e.line, e.col
""",
cg_langs,
)
for src_id, tgt_id in cur.fetchall():
for src_id, tgt_id, file_path, start_line, start_col in cur.fetchall():
caller, callee = fqn_of.get(src_id), fqn_of.get(tgt_id)
if caller and callee:
result[caller].add(callee)
if not caller or not callee:
continue
# key 包含调用点坐标,区分同一函数内对同一 callee 的多次不同调用点,
# 否则第 2 次及以后的调用会被误判为重复而丢弃(order_index/arg_bindings 失效)
key = (caller, callee, "calls", file_path, start_line, start_col)
if key in seen:
continue
seen.add(key)
result.append({
"caller": caller,
"callee": callee,
"kind": "calls",
"span": {
"file": file_path,
"start_line": start_line,
"start_column": start_col,
},
})

# Query 2: constructor calls synthesised from instantiates edges.
# For each `caller instantiates ClassName` edge, find the constructor
# method inside that class and add it as a synthetic callee.
# instantiates edges may lack call-site coordinates, so fall back to the
# caller node's definition location when the edge coordinates are NULL.
ctor_filter = _CONSTRUCTOR_FILTER.get(lang_key)
if ctor_filter:
cur.execute(
f"""
SELECT e.source, ctor.id
SELECT e.source, ctor.id, s.file_path,
COALESCE(e.line, s.start_line),
COALESCE(e.col, s.start_column)
FROM edges e
JOIN nodes s ON e.source = s.id
JOIN nodes cls ON e.target = cls.id AND cls.kind = 'class'
Expand All @@ -445,16 +463,33 @@ def get_call_edges(self, lang_key: str) -> dict:
AND ctor.kind IN ('method', 'function')
WHERE e.kind = 'instantiates' AND s.language IN ({placeholders})
AND {ctor_filter}
ORDER BY s.file_path, COALESCE(e.line, s.start_line),
COALESCE(e.col, s.start_column)
""",
cg_langs,
)
for src_id, ctor_id in cur.fetchall():
for src_id, ctor_id, file_path, start_line, start_col in cur.fetchall():
caller, callee = fqn_of.get(src_id), fqn_of.get(ctor_id)
if caller and callee:
result[caller].add(callee)
if not caller or not callee:
continue
# key 含调用点坐标,区分同一函数内对同一构造器的多次调用点
key = (caller, callee, "constructor", file_path, start_line, start_col)
if key in seen:
continue
seen.add(key)
result.append({
"caller": caller,
"callee": callee,
"kind": "constructor",
"span": {
"file": file_path,
"start_line": start_line,
"start_column": start_col,
},
})

conn.close()
return dict(result)
return result


def _codegraph_cmd() -> str:
Expand Down
156 changes: 142 additions & 14 deletions src/languages/registry.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
from dataclasses import dataclass
from typing import Callable

import logging

from src.languages import python as _python
from src.languages import go as _go
from src.languages import c as _c
Expand Down Expand Up @@ -120,27 +122,153 @@ def extract_incremental_sources(proj_dir: str, lang_key: str, sources: dict):
return handler.incremental_source_extract(proj_dir, sources)


_logger = logging.getLogger(__name__)

# 允许透传的 edge 字段(内部缓存字段如 _internal_cache 会被过滤)
_ALLOWED_EDGE_FIELDS = {
"caller", "callee", "kind", "language",
"span", "arg_bindings", "order_index",
}

# span 内部的标准字段名(不同后端可能叫 file/path/source_file,统一到 file)
_SPAN_FIELD_ALIASES = {
"file": ("file", "path", "source_file", "filename"),
"start_line": ("start_line", "line"),
"start_column": ("start_column", "col", "column"),
}


def _normalize_span(span) -> dict:
"""Unify span field names: {file, start_line, start_column}."""
if not isinstance(span, dict):
return span
out = {}
for canonical, aliases in _SPAN_FIELD_ALIASES.items():
for a in aliases:
if a in span and span[a] is not None:
out[canonical] = span[a]
break
# 保留未识别字段(向后兼容)
for k, v in span.items():
if k not in {x for aliases in _SPAN_FIELD_ALIASES.values() for x in aliases}:
out[k] = v
return out


def _edge_dedup_key(d: dict) -> tuple:
"""Dedup key at call-site granularity (mirrors codegraph.py).

Includes language so edges from different backends (e.g. C and C++)
with the same caller/callee/span are not merged away.
"""
span = d.get("span") if isinstance(d.get("span"), dict) else {}
return (
d.get("language"),
d.get("caller"),
d.get("callee"),
d.get("kind", "call"),
span.get("file"),
span.get("start_line"),
span.get("start_column"),
)


def normalize_call_edges(edges, language=None) -> list:
"""Normalize language-backend call edges into FM-Agent standard format.

Accepts:
- dict form: {caller: {callee, ...}} / {caller: "callee_str"}
- list form: [{"caller": ..., "callee": ..., "kind": ...}]
- custom objects: 可转换为 dict 的 edge object(保留额外字段)

Returns a list of normalized edge dicts. Malformed edges are skipped with
a warning (never silently dropped — this is shared infrastructure).
Dedup is applied at call-site granularity so the function is idempotent.
"""
if edges is None:
return []

out = []
seen = set()

def _append(d: dict) -> None:
# span 字段名统一
if "span" in d and isinstance(d["span"], dict):
d["span"] = _normalize_span(d["span"])
# language 空字符串规范化:非空 language 参数覆盖空值
if not d.get("language") and language:
d["language"] = language
key = _edge_dedup_key(d)
if key in seen:
return
seen.add(key)
out.append(d)

# dict form: {caller: {callee, ...}} / {caller: "callee_str"}
if isinstance(edges, dict):
for caller, callees in edges.items():
if isinstance(callees, str):
callees = [callees] # 兼容 {caller: "callee_str"}
elif not isinstance(callees, (list, set, tuple)):
_logger.warning("Skipping malformed call edge (unknown container): %r", callees)
continue
for callee in callees:
_append({
"caller": caller,
"callee": callee,
"kind": "call",
"language": language,
})
return out

# list form: normalized dicts or edge objects
if isinstance(edges, list):
for e in edges:
if isinstance(e, dict):
d = dict(e)
# schema 校验:缺 caller/callee 跳过(不静默——基础设施层要留日志)
if "caller" not in d or "callee" not in d:
_logger.warning("Skipping malformed call edge: %s", d)
continue
d.setdefault("kind", "call")
_append(d)
else:
# custom object: 统一走 getattr(兼容 __dict__ / __slots__ / @property)
d = {}
for key in _ALLOWED_EDGE_FIELDS:
if hasattr(e, key):
val = getattr(e, key, None)
if val is not None:
d[key] = val
if "caller" in d and "callee" in d:
d.setdefault("kind", "call")
_append(d)
return out

return []


def call_edges_all(proj_dir: str, lang_keys) -> tuple:
"""Call call_edges for each language in lang_keys and merge results.

Returns (edges, langs) where edges is {caller_fqn: {callee_fqns}} and langs is
the set of language keys codegraph handled (it returned a dict, even if empty
— None means the backend was unavailable and the caller should use regex).
Returns (edges, langs) where edges is a list of normalized edge dicts and
langs is the set of language keys codegraph handled. Edges are deduped
across language backends at call-site granularity.
"""
edges = {}
edges = []
langs = set()
seen = set()
for lang in lang_keys:
if lang not in REGISTRY:
continue
result = REGISTRY[lang].call_edges(proj_dir)
# A handler returns None when its backend (codegraph) is unavailable, and
# a dict (possibly empty) when it handled the language. Treat "handled but
# no edges" as codegraph-authoritative — add the language to `langs` so the
# caller uses the codegraph path — instead of falling back to regex, which
# would otherwise invent edges (e.g. match a function's own signature) for
# a genuinely call-free project.
if result is not None:
langs.add(lang)
for key, callees in result.items():
edges.setdefault(key, set()).update(callees)
if result is None:
continue
langs.add(lang)
for edge in normalize_call_edges(result, language=lang):
key = _edge_dedup_key(edge)
if key in seen:
continue
seen.add(key)
edges.append(edge)
return edges, langs