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
67 changes: 67 additions & 0 deletions graphify/extract.py
Original file line number Diff line number Diff line change
Expand Up @@ -852,6 +852,10 @@ def _get_c_func_name(node, source: bytes) -> str | None:
}),
function_types=frozenset({"method_declaration", "constructor_declaration"}),
import_types=frozenset({"import_declaration"}),
# A Java constant is a field_declaration, which none of the sets above
# covers. See the value_types branch in engine.walk.
value_types=frozenset({"field_declaration"}),
value_kind="field",
# object_creation_expression (`new Foo(...)`) is handled by a dedicated Java
# branch in walk_calls below — its callee is in the `type` field, not `name`.
call_types=frozenset({"method_invocation", "object_creation_expression"}),
Expand Down Expand Up @@ -2487,6 +2491,56 @@ def _augment_js_reexport_edges(
# Header / implementation file-extension pairing for the decl/def class merge.



def _bind_cross_file_value_refs(
per_file: list[dict],
all_nodes: list[dict],
all_edges: list[dict],
) -> None:
"""Bind value references that cross a file boundary.

An extractor sees one file. It can bind a constant only where that
constant is also declared, and reports the rest as ``raw_value_refs``
rather than guessing - the same split ``raw_calls`` already uses for
calls. Here every file is available.

Measured on a 59-file Go project: of fifteen constants each used in
exactly one function, three were used from a sibling file, and those
three stayed unreachable until this pass existed.

Only for an unambiguous name, the same god-node guard the call
resolver applies: two same-named constants in two packages would
otherwise wire a caller to the wrong one.
"""
by_name: dict[str, list[str]] = {}
for n in all_nodes:
if n.get("value_kind"):
by_name.setdefault(str(n.get("label", "")), []).append(n["id"])
if not by_name:
return

have = {(e.get("source"), e.get("target")) for e in all_edges}
for result in per_file:
for rv in result.get("raw_value_refs") or []:
ids = by_name.get(rv.get("name", ""), [])
if len(ids) != 1:
continue
src, tgt = rv.get("caller_nid"), ids[0]
if not src or src == tgt or (src, tgt) in have:
continue
have.add((src, tgt))
all_edges.append({
"source": src,
"target": tgt,
"relation": "references",
"context": "value_use",
"confidence": "EXTRACTED",
"source_file": rv.get("source_file", ""),
"source_location": rv.get("source_location", ""),
"weight": 1.0,
})


def _merge_swift_extensions(
per_file: list[dict],
all_nodes: list[dict],
Expand Down Expand Up @@ -6198,6 +6252,13 @@ def _portable_out_of_root_sf(p: Path) -> str:
cn = rc.get("caller_nid")
if cn in id_remap:
rc["caller_nid"] = id_remap[cn]
# raw_value_refs carry the same kind of id and are consumed by
# _bind_cross_file_value_refs, so they need the same rewrite.
for result in per_file:
for rv in result.get("raw_value_refs") or []:
vn = rv.get("caller_nid")
if vn in id_remap:
rv["caller_nid"] = id_remap[vn]
# swift_extensions[].nid is the same kind of id carrier as caller_nid
# above (cache.py remaps both), consumed by _merge_swift_extensions far
# below. Left stale it matches no node, so whether the extension merge
Expand Down Expand Up @@ -6270,6 +6331,11 @@ def _portable_out_of_root_sf(p: Path) -> str:
cn = rc.get("caller_nid")
if cn in sym_remap:
rc["caller_nid"] = sym_remap[cn]
for result in per_file:
for rv in result.get("raw_value_refs") or []:
vn = rv.get("caller_nid")
if vn in sym_remap:
rv["caller_nid"] = sym_remap[vn]
# Same for swift_extensions[].nid (see the id_remap pass above).
for result in per_file:
for ext in result.get("swift_extensions", []) or []:
Expand Down Expand Up @@ -6400,6 +6466,7 @@ def _learn(e: dict) -> None:
# (src/) package root before the resolver/import-evidence passes run, so the
# graph is identical regardless of scan root (#2072).
_repoint_python_package_imports(paths, all_nodes, all_edges, root)
_bind_cross_file_value_refs(per_file, all_nodes, all_edges)
_merge_swift_extensions(per_file, all_nodes, all_edges)
_merge_csharp_partial_class_nodes(per_file, all_nodes, all_edges, paths, root)
_disambiguate_colliding_node_ids(all_nodes, all_edges, all_raw_calls, root)
Expand Down
73 changes: 72 additions & 1 deletion graphify/extractors/engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -2920,6 +2920,13 @@ def _extract_generic(
nodes: list[dict] = []
edges: list[dict] = []
seen_ids: set[str] = set()
# Package/class-level value names declared in this file, and the reads
# this file cannot resolve. Same split as raw_calls: one file is not
# enough to bind a name declared in a sibling.
value_nids: dict[str, str] = {}
raw_value_refs: list[dict] = []
seen_value_refs: set[tuple[str, str]] = set()

namespace_stack: list[str] = []
# Ruby only: enclosing module/class segments, so `module Foo::Bar` (compact)
# and `module Foo; module Bar` (nested) label the same node `Foo::Bar` and
Expand Down Expand Up @@ -3099,6 +3106,42 @@ def walk(node, parent_class_nid: str | None = None) -> None:
walk(child, parent_class_nid)
return

# Value declarations: a name that is read rather than called.
#
# The dispatch below knows classes, functions, imports and calls. A
# Java constant is none of those, so it never became a node and
# nothing could point at it. Measured on a 14-file Java project
# using five constants each read in exactly one method: none of the
# five was a node, while all five reading methods were.
#
# Opt-in per language: value_types is empty unless a config sets it,
# which is what every language did implicitly before.
if config.value_types and t in config.value_types:
for decl in node.children:
if decl.type not in ("variable_declarator", "identifier"):
continue
nm = decl.child_by_field_name("name") if decl.type == "variable_declarator" else decl
if nm is None:
continue
vname = _read_text(nm, source)
if not vname or vname == "_":
continue
vline = decl.start_point[0] + 1
vnid = _make_id(stem, vname)
add_node(vnid, vname, vline)
for n in nodes:
if n["id"] == vnid:
n["value_kind"] = config.value_kind
break
add_edge(parent_class_nid or file_nid, vnid, "contains", vline,
context=config.value_kind)
value_nids[vname] = vnid
# Deliberately no return: a field_declaration also carries its
# type, which the field-type-reference pass and the Java
# receiver-type table read from the same subtree. Returning
# here broke eight Java tests that had nothing to do with
# values.

# Class types
if t in config.class_types:
# Resolve class name
Expand Down Expand Up @@ -5044,6 +5087,33 @@ def walk_calls(
receiver_types: dict[str, str] | tuple | None = None,
extra_locals: frozenset[str] = frozenset(),
) -> None:
# A read is a relation this engine did not record. "Where is this
# constant used" asks for exactly that, and without the edge the
# node exists but stays unreachable. Only names declared as values,
# so an ordinary local identifier produces nothing.
if config.value_types and node.type == "identifier":
vname = _read_text(node, source)
vtgt = value_nids.get(vname)
if vtgt is None and vname:
raw_value_refs.append({
"caller_nid": caller_nid,
"name": vname,
"source_file": str_path,
"source_location": f"L{node.start_point[0] + 1}",
})
elif vtgt and vtgt != caller_nid and (caller_nid, vtgt) not in seen_value_refs:
seen_value_refs.add((caller_nid, vtgt))
edges.append({
"source": caller_nid,
"target": vtgt,
"relation": "references",
"context": "value_use",
"confidence": "EXTRACTED",
"source_file": str_path,
"source_location": f"L{node.start_point[0] + 1}",
"weight": 1.0,
})

if node.type in config.function_boundary_types:
# JS/TS: an inline/returned closure not separately tracked in
# function_bodies would otherwise drop its calls at this boundary.
Expand Down Expand Up @@ -5908,7 +5978,8 @@ def _scan_js_module_dispatch(n) -> None:
# fold them in so the cross-file resolver sees them (#1668).
if _ruby_mixin_calls:
raw_calls.extend(_ruby_mixin_calls)
result = {"nodes": nodes, "edges": clean_edges, "raw_calls": raw_calls}
result = {"nodes": nodes, "edges": clean_edges, "raw_calls": raw_calls,
"raw_value_refs": raw_value_refs}
# #2551: the parser recovered from syntax errors, so extraction may be
# partial (in the worst case, nothing but the file node). Record the first
# error's line so extract() can warn instead of reporting silent success.
Expand Down
81 changes: 80 additions & 1 deletion graphify/extractors/go.py
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,7 @@ def _go_collect_type_refs(node, source: bytes, generic: bool, out: list[tuple[st
_go_collect_type_refs(c, source, generic, out)

def extract_go(path: Path) -> dict:
"""Extract functions, methods, type declarations, and imports from a .go file."""
"""Extract functions, methods, types, values, and imports from a .go file."""
try:
import tree_sitter_go as tsgo
from tree_sitter import Language, Parser
Expand Down Expand Up @@ -269,9 +269,51 @@ def symbol_nid(plain_nid: str, name: str) -> str:
salt = hashlib.sha1(name.encode("utf-8"), usedforsecurity=False).hexdigest()[:6]
return _make_id(plain_nid, salt)

# Package-level value names declared in this file. Kept separate from
# label_to_nid so the reference pass below binds only these and not
# every identifier it walks past.
value_nids: dict[str, str] = {}

def add_value_decl(node, kind: str) -> None:
"""Emit a node per name in a const_declaration or var_declaration.

The dispatch below handled function_declaration,
method_declaration, type_declaration and import_declaration. Go's
grammar also has const_declaration and var_declaration, so a
package-level constant never became a node and nothing could
point at it.

Measured on a 59-file Go project: of fifteen constants each used
in exactly one function, none was a node, while all fifteen of
those functions were. Asked "which function uses X", the graph
had the answer but no way in.
"""
for spec in node.children:
if spec.type not in ("const_spec", "var_spec"):
continue
line = spec.start_point[0] + 1
for child in spec.children:
if child.type != "identifier":
continue
name = _read_text(child, source)
if not name or name == "_":
continue
nid = _make_id(pkg_scope, name)
add_node(nid, name, line)
for n in nodes:
if n["id"] == nid:
n["value_kind"] = kind
break
add_edge(file_nid, nid, "contains", line, context=kind)
value_nids[name] = nid

def walk(node) -> None:
t = node.type

if t in ("const_declaration", "var_declaration"):
add_value_decl(node, "const" if t == "const_declaration" else "var")
return

if t == "function_declaration":
name_node = node.child_by_field_name("name")
if name_node:
Expand Down Expand Up @@ -430,6 +472,8 @@ def walk(node) -> None:
label_to_nid[normalised] = n["id"]

seen_call_pairs: set[tuple[str, str]] = set()
seen_value_refs: set[tuple[str, str]] = set()
raw_value_refs: list[dict] = []
raw_calls: list[dict] = []

def walk_calls(node, caller_nid: str) -> None:
Expand Down Expand Up @@ -493,6 +537,40 @@ def walk_calls(node, caller_nid: str) -> None:
"source_file": str_path,
"source_location": f"L{node.start_point[0] + 1}",
})
# Reading a value is a relation this extractor did not record.
# "Where is this constant used" asks for exactly that, and
# without the edge the node stays unreachable even once it
# exists. Restricted to names declared as package-level values,
# so a local identifier does not produce an edge.
if node.type == "identifier":
name = _read_text(node, source)
tgt = value_nids.get(name)
if tgt is None and name and name not in _LANGUAGE_BUILTIN_GLOBALS:
# Not declared in this file. It may be a constant from a
# sibling file or a local variable; only a pass that sees
# every file can tell, which is how cross-file calls are
# already resolved. See _bind_cross_file_value_refs.
raw_value_refs.append({
"caller_nid": caller_nid,
"name": name,
"source_file": str_path,
"source_location": f"L{node.start_point[0] + 1}",
})
elif tgt and tgt != caller_nid:
pair = (caller_nid, tgt)
if pair not in seen_value_refs:
seen_value_refs.add(pair)
edges.append({
"source": caller_nid,
"target": tgt,
"relation": "references",
"context": "value_use",
"confidence": "EXTRACTED",
"source_file": str_path,
"source_location": f"L{node.start_point[0] + 1}",
"weight": 1.0,
})

for child in node.children:
walk_calls(child, caller_nid)

Expand All @@ -510,5 +588,6 @@ def walk_calls(node, caller_nid: str) -> None:
"nodes": nodes,
"edges": clean_edges,
"raw_calls": raw_calls,
"raw_value_refs": raw_value_refs,
"go_imports": dict(go_imported_pkgs),
}
6 changes: 6 additions & 0 deletions graphify/extractors/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,12 @@ class LanguageConfig:
import_types: frozenset = frozenset()
call_types: frozenset = frozenset()
static_prop_types: frozenset = frozenset()
# Declarations that introduce a named value rather than a callable: a Java
# field, a Rust const_item. Empty means the language opts out, which is
# what every language did implicitly before the field existed.
value_types: frozenset = frozenset()
# What the value_kind marker on those nodes says.
value_kind: str = "value"
helper_fn_names: frozenset = frozenset()
container_bind_methods: frozenset = frozenset()
event_listener_properties: frozenset = frozenset()
Expand Down
Loading