diff --git a/graphify/extract.py b/graphify/extract.py index e5a48dc04..df6efa5ed 100644 --- a/graphify/extract.py +++ b/graphify/extract.py @@ -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 @@ -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. @@ -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 diff --git a/graphify/extractors/bash.py b/graphify/extractors/bash.py index 455c7eb81..2f97f4246 100644 --- a/graphify/extractors/bash.py +++ b/graphify/extractors/bash.py @@ -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 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: @@ -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: @@ -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} diff --git a/tests/test_extract.py b/tests/test_extract.py index 824419edd..6e727b653 100644 --- a/tests/test_extract.py +++ b/tests/test_extract.py @@ -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) # ---------------------------------------------------------------------------