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:
committed by
safishamsi
parent
0da6929e57
commit
bdcae25a26
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user