Skip to content
Closed
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
29 changes: 29 additions & 0 deletions graphify/extract.py
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,8 @@
_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
Expand Down Expand Up @@ -4937,6 +4939,25 @@ def _learn(e: dict) -> None:
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.
Expand Down Expand Up @@ -5047,6 +5068,14 @@ def _learn(e: dict) -> None:
# 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
Expand Down
38 changes: 37 additions & 1 deletion graphify/extractors/bash.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -129,6 +137,24 @@ def walk_calls(body_node, func_nid: str, seen_calls: set) -> None:
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:
Expand Down Expand Up @@ -180,6 +206,15 @@ def walk(node, parent_nid: str) -> None:
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:
Expand Down Expand Up @@ -245,4 +280,5 @@ def _prescan_functions(node) -> None:
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 changes: 74 additions & 0 deletions tests/test_extract.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
# ---------------------------------------------------------------------------
Expand Down
Loading