fix(extract): resolve bash calls into sourced-file functions (#2141)

extract_bash only linked calls whose callee was defined in the same file, so a
call to a function from a `source`d library was silently dropped and shell
scripts looked disconnected from the libraries they use. The resolver for exactly
this, resolve_bash_source_edges, already existed with tests but had no call site
and no raw_calls/bash_sources to work from.

extract_bash now emits `bash_sources` (the files it `source`s) and `raw_calls`
(calls whose callee isn't defined locally), and the pipeline runs them through
resolve_bash_source_edges after the id-remap passes, so caller and function node
ids are final and the already-emitted source edge is de-duped. Resolution is
scoped to the source relationship: a call to an external command never binds to a
same-named function in an unsourced file, and bash raw_calls are excluded from the
generic global-name resolver for the same reason.
This commit is contained in:
HerenderKumar
2026-07-24 23:37:14 +01:00
committed by safishamsi
parent 0da6929e57
commit bdcae25a26
3 changed files with 140 additions and 1 deletions
+29
View File
@@ -133,6 +133,8 @@ from graphify.extractors.resolution import ( # noqa: E402,F401
_workspace_globs,
)
from graphify.symbol_resolution import resolve_bash_source_edges # noqa: E402
from graphify.extractors.engine import REFERENCE_CONTEXTS, _CSHARP_TYPE_PARAMETER_SCOPE_DECLARATIONS, _C_PRIMITIVE_TYPE_NODES, _JAVA_BUILTIN_TYPES, _JAVA_TYPE_PARAMETER_SCOPE_DECLARATIONS, _JS_FUNCTION_VALUE_TYPES, _JS_SCOPE_BOUNDARY, _PYTHON_ANNOTATION_NOISE, _PYTHON_TYPE_CONTAINERS, _RUBY_CLASS_FACTORIES, _c_collect_type_refs, _cpp_collect_type_refs, _cpp_declarator_name, _cpp_local_var_types, _csharp_attribute_names, _csharp_classify_base, _csharp_collect_type_refs, _csharp_extra_walk, _csharp_member_type_table, _csharp_namespace_id, _csharp_namespace_name, _csharp_pre_scan_interfaces, _csharp_type_parameters_in_scope, _dynamic_import_js, _extract_generic, _find_body, _find_require_call, _get_cpp_func_name, _java_annotation_names, _java_collect_type_refs, _java_extra_walk, _java_type_parameters_in_scope, _js_collect_pattern_idents, _js_dispatch_value_idents, _js_extra_walk, _js_local_bound_names, _js_member_assignment_target, _js_module_bound_names, _kotlin_collect_type_refs, _kotlin_function_return_type_node, _kotlin_property_type_node, _kotlin_user_type_name, _php_collect_type_refs, _php_method_return_type_node, _php_name_text, _python_collect_assignment_targets, _python_collect_param_refs, _python_collect_type_refs, _python_local_bound_names, _python_module_bound_names, _python_param_names, _read_csharp_type_name, _require_imports_js, _ruby_const_last_name, _ruby_extra_walk, _ruby_local_class_bindings, _ruby_new_class_name, _scala_collect_type_refs, _semantic_reference_edge, _source_location, _swift_classify_base, _swift_collect_type_refs, _swift_constructor_type, _swift_declaration_keyword, _swift_extra_walk, _swift_local_var_types, _swift_pre_scan, _swift_property_name, _swift_property_type_node, _swift_receiver_name, _swift_user_type_name, _ts_decorator_name, _ts_descendant_decorators, _ts_emit_decorator_edges, _ts_extra_walk, _ts_method_name, _ts_receiver_type_table # noqa: E402,F401
from graphify.extractors.pascal import _PAS_BEGIN_END_TOKEN_RE, _PAS_CALL_RE, _PAS_END_SEMI_RE, _PAS_IMPL_HEADER_RE, _PAS_KEYWORDS, _PAS_METHOD_DECL_RE, _PAS_MODULE_RE, _PAS_TOKEN_RE, _PAS_TYPE_HEADER_RE, _PAS_USES_RE, _extract_pascal_regex, _pascal_find_body, _pascal_split_bases, _pascal_split_sections, _pascal_split_uses, _pascal_strip_comments, extract_pascal # noqa: E402,F401
@@ -4937,6 +4939,25 @@ def extract(
import logging
logging.getLogger(__name__).warning("C# cross-file import resolution failed, skipping: %s", exc)
# Cross-file Bash source-backed call resolution: a call to a function defined
# in a file this one `source`s is left unresolved by the per-file extractor
# (it only links calls to same-file functions, #2141). Match each bash raw_call
# against functions in the sourced files and emit the calls edge — scoped to
# the source relationship, so a call to an external command never binds to a
# same-named function in an unsourced file. Runs after the id-remap passes
# above so caller_nids and function node ids are final; dedups the source
# edge the extractor already emitted via existing_edges.
sh_paths = [p for p in paths if p.suffix in (".sh", ".bash")]
if sh_paths:
sh_results = [r for r, p in zip(per_file, paths) if p.suffix in (".sh", ".bash")]
try:
all_edges.extend(
resolve_bash_source_edges(sh_results, sh_paths, root, existing_edges=all_edges)
)
except Exception as exc:
import logging
logging.getLogger(__name__).warning("Bash cross-file call resolution failed, skipping: %s", exc)
# Cross-file call resolution for all languages
# Each extractor saved unresolved calls in raw_calls. Now that we have all
# nodes from all files, resolve any callee that exists in another file.
@@ -5051,6 +5072,14 @@ def extract(
# both mislabel the relation and block the mixes_in emit as a dup (#1668).
if rc.get("is_mixin"):
continue
# Bash calls are resolved only by resolve_bash_source_edges (run above),
# which scopes resolution to the files a script actually `source`s. The
# global name match here would bind a bash call to any same-named function
# in an unsourced file (an INFERRED phantom edge) and would resolve calls
# to external commands that merely share a name with a function elsewhere
# in the corpus — exactly what #2141 must not do.
if rc.get("language") == "bash":
continue
# Exact-case match first (case is semantic). Fold only when the CALLING
# file's language is case-insensitive, and only against the folded index of
# case-insensitive-language definitions — so a Python `Path()` call can never
+37 -1
View File
@@ -28,6 +28,14 @@ def extract_bash(path: Path) -> dict:
str_path = str(path)
nodes: list[dict] = []
edges: list[dict] = []
# Cross-file resolution scaffolding consumed by resolve_bash_source_edges in
# the extract pipeline: `bash_sources` records which files this one `source`s,
# `raw_calls` records calls whose callee isn't defined in this file (candidate
# calls into a sourced library). The extractor sees one file at a time and so
# can't resolve these itself (#2141).
raw_calls: list[dict] = []
bash_sources: list[dict] = []
raw_seen: set[tuple[str, str]] = set()
seen_ids: set[str] = set()
function_bodies: list[tuple[str, Any]] = []
defined_functions: set[str] = set()
@@ -129,6 +137,24 @@ def extract_bash(path: Path) -> dict:
add_edge(func_nid, tgt, "calls",
child.start_point[0] + 1,
confidence="EXTRACTED", context="call")
elif (name and name not in _BASH_SOURCE_COMMANDS
and name not in _BASH_SCRIPT_RUNNERS):
# Callee isn't defined in this file — it may be a function
# from a sourced library. Record an unresolved raw_call for
# resolve_bash_source_edges to bind against the files this
# script `source`s. A callee that is not a sourced function
# (a genuine external command) matches nothing there and
# yields no edge, so this can't over-connect the graph (#2141).
raw_key = (func_nid, name)
if raw_key not in raw_seen:
raw_seen.add(raw_key)
raw_calls.append({
"language": "bash",
"callee": name,
"caller_nid": func_nid,
"source_file": str_path,
"source_location": f"L{child.start_point[0] + 1}",
})
walk_calls(child, func_nid, seen_calls)
def walk(node, parent_nid: str) -> None:
@@ -180,6 +206,15 @@ def extract_bash(path: Path) -> dict:
tgt_nid = _make_id(str(resolved))
add_edge(file_nid, tgt_nid, "imports_from", line,
context="import")
# Record the sourced file so resolve_bash_source_edges
# can bind calls into its functions (#2141). Gated on
# existence like the edge above, so crafted traversal
# paths never enter the resolver's data.
bash_sources.append({
"target_path": raw,
"source_file": str_path,
"source_location": f"L{line}",
})
else:
tgt_nid = _make_id(raw)
if tgt_nid:
@@ -245,4 +280,5 @@ def extract_bash(path: Path) -> dict:
for fn_nid, body in function_bodies:
walk_calls(body, fn_nid, set())
return {"nodes": nodes, "edges": edges}
return {"nodes": nodes, "edges": edges,
"raw_calls": raw_calls, "bash_sources": bash_sources}
+74
View File
@@ -1842,6 +1842,80 @@ def test_extract_bash_source_user_defined_emits_calls_not_imports_from(tmp_path)
)
def test_extract_bash_emits_raw_calls_and_bash_sources_for_sourced_calls(tmp_path):
"""extract_bash must surface the data cross-file resolution needs: a
``bash_sources`` entry per sourced file and a ``raw_calls`` entry for each
call whose callee is not defined in the same file. Without these,
resolve_bash_source_edges has nothing to resolve a sourced-function call
from (#2141)."""
(tmp_path / "b.sh").write_text("#!/usr/bin/env bash\nb_func() { echo ok; }\n")
a = tmp_path / "a.sh"
a.write_text(
"#!/usr/bin/env bash\n"
"source ./b.sh\n"
"main() { b_func; }\n"
)
result = extract_bash(a)
sources = result.get("bash_sources", [])
assert any(str(s.get("target_path", "")).endswith("b.sh") for s in sources), sources
main_nid = next(n["id"] for n in result["nodes"] if n.get("label") == "main()")
raw_calls = result.get("raw_calls", [])
assert any(
rc.get("language") == "bash"
and rc.get("callee") == "b_func"
and rc.get("caller_nid") == main_nid
for rc in raw_calls
), raw_calls
def test_extract_bash_call_to_sourced_function_resolves(tmp_path):
"""#2141 repro: a call to a function defined in a sourced file must produce a
real ``calls`` edge through the full extract() pipeline, so ``path`` and
``callers`` can traverse it."""
(tmp_path / "b.sh").write_text("#!/usr/bin/env bash\nb_func() { echo ok; }\n")
(tmp_path / "a.sh").write_text(
"#!/usr/bin/env bash\n"
"source ./b.sh\n"
"main() { b_func; }\n"
)
result = extract([tmp_path / "a.sh", tmp_path / "b.sh"], cache_root=tmp_path)
calls = {(e["source"], e["target"]) for e in result["edges"] if e["relation"] == "calls"}
assert ("a_main", "b_b_func") in calls, sorted(calls)
def test_extract_bash_sourced_call_does_not_duplicate_source_edge(tmp_path):
"""Wiring the source-backed call resolver must not re-emit the ``imports_from``
source edge the extractor already resolved for ``source ./b.sh`` (#2141)."""
(tmp_path / "b.sh").write_text("#!/usr/bin/env bash\nb_func() { echo ok; }\n")
(tmp_path / "a.sh").write_text(
"#!/usr/bin/env bash\n"
"source ./b.sh\n"
"main() { b_func; }\n"
)
result = extract([tmp_path / "a.sh", tmp_path / "b.sh"], cache_root=tmp_path)
imports = [(e["source"], e["target"]) for e in result["edges"]
if e["relation"] == "imports_from"]
assert imports.count(("a", "b")) == 1, imports
def test_extract_bash_call_to_external_command_stays_unlinked(tmp_path):
"""A call to a command that is not a function in any sourced file (an external
binary) must not gain a cross-file ``calls`` edge — even when a same-named
function exists in an *unsourced* file. Source-scoped resolution is what keeps
#2141 from over-connecting the graph (acceptance criterion)."""
# b.sh is NOT sourced by a.sh, yet defines a function named `deploy`.
(tmp_path / "b.sh").write_text("#!/usr/bin/env bash\ndeploy() { echo ok; }\n")
(tmp_path / "a.sh").write_text(
"#!/usr/bin/env bash\n"
"main() { deploy; }\n"
)
result = extract([tmp_path / "a.sh", tmp_path / "b.sh"], cache_root=tmp_path)
calls = {(e["source"], e["target"]) for e in result["edges"] if e["relation"] == "calls"}
assert ("a_main", "b_deploy") not in calls, sorted(calls)
# ---------------------------------------------------------------------------
# JSON extractor tests (#866)
# ---------------------------------------------------------------------------