From a646d66a67e79462d6fe97741d6b487533d31f3a Mon Sep 17 00:00:00 2001 From: balloon72 Date: Sat, 11 Jul 2026 11:48:10 +0100 Subject: [PATCH] fix(bash): link executed scripts across files (#1756) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit extract_bash only created a cross-file edge for `source x.sh` / `. x.sh`. The two most common ways one script runs another — `bash x.sh` and `./x.sh` — produced no edge, so in any repo where scripts invoke each other by execution the call topology was missing (each script left an isolated file+entry pair). Emit a `calls` edge (context `script_invocation`) from the caller's entry (or enclosing function) to the invoked script's entry node, for script-runner commands (bash/sh/zsh/ksh/dash ) and bare `./x.sh`, but only when the target resolves to a real .sh file on disk — so no phantom edges to missing or function-shadowed names. Verified end-to-end: the edges land on real target nodes (no dangling drop at build). Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 2 + graphify/extractors/bash.py | 24 +++++++++-- tests/test_extract.py | 80 +++++++++++++++++++++++++++++++++++++ 3 files changed, 103 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 524ea4444..759758b8d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,8 @@ Full release notes with details on each version: [GitHub Releases](https://githu ## 0.9.13 (unreleased) +- Fix: Bash scripts that run each other by execution now get a cross-file edge (#1756, thanks @balloon72). `extract_bash` only linked `source x.sh` / `. x.sh`; the two most common forms — `bash x.sh` and `./x.sh` — produced no edge, so execution topology was missing. They now emit a `calls` edge (context `script_invocation`) to the invoked script's entry node when the target resolves to a real file on disk (script runners `bash`/`sh`/`zsh`/`ksh`/`dash` and bare `./x.sh`), skipping missing or shadowed targets. + - Fix: Ruby `.rake` files are now extracted and participate in Ruby cross-file resolution like `.rb` (#1784, thanks @krishnateja7). `.rake` is plain Ruby but the extension was gated out of seven places (classification, extractor dispatch, the language-name/family maps, the `ruby_member_calls` resolver's suffix set, both `.rb`-suffix filters in `ruby_resolution.py`, and the build repo-tag map), so every rake task was skipped and its calls were invisible. All seven now include `.rake`; `Widget.tally` from a `.rake` task resolves to its `.rb` definition. - Fix: cross-module references to a function now resolve to its definition instead of dangling on a name-only stub (#1781, thanks @EmilNyg). `_rewire_unique_stub_nodes` gated merge targets through `_is_type_like_definition`, which rejects any label ending in `)` — so function/method defs could never absorb their reference stubs, and "who references this function" returned nothing on the definition node while a sourceless stub held all the edges. Top-level function defs are now eligible rewire targets when the label match is globally unique, gated by a language-family match with the referrers (a Python `get_db` reference can't bind to a unique Go `get_db()`) and excluding stubs used as a supertype (`inherits`/`implements`/`extends` — you don't inherit from a function). Types are unchanged. diff --git a/graphify/extractors/bash.py b/graphify/extractors/bash.py index 160ea032c..455c7eb81 100644 --- a/graphify/extractors/bash.py +++ b/graphify/extractors/bash.py @@ -62,6 +62,7 @@ def extract_bash(path: Path) -> dict: add_edge(file_nid, entry_nid, "contains", 1) _BASH_SOURCE_COMMANDS = frozenset({"source", "."}) + _BASH_SCRIPT_RUNNERS = frozenset({"bash", "sh", "zsh", "ksh", "dash"}) # Parent node types that mean a contained command is part of a substitution # or expansion, not a real function call. Token-level filtering misses # these because `$(build)` exposes `build` as a child command whose name @@ -161,11 +162,11 @@ def extract_bash(path: Path) -> dict: cmd_name_node = node.children[0] if cmd_name_node: cmd = literal(cmd_name_node) + args = [c for c in node.children + if c.type in ("word", "string", "concatenation") + and c != cmd_name_node] if cmd in _BASH_SOURCE_COMMANDS and cmd not in defined_functions: # find the path argument (first word after command name) - args = [c for c in node.children - if c.type in ("word", "string", "concatenation") - and c != cmd_name_node] if args: raw = _read_text(args[0], source).strip().strip("'\"") line = node.start_point[0] + 1 @@ -184,6 +185,23 @@ def extract_bash(path: Path) -> dict: if tgt_nid: add_edge(file_nid, tgt_nid, "imports", line, context="import") + elif cmd and cmd not in defined_functions: + raw = cmd if cmd.endswith(".sh") else None + if cmd in _BASH_SCRIPT_RUNNERS and args: + raw = literal(args[0]) + if raw and raw.endswith(".sh"): + resolved = (path.parent / raw).resolve() + if resolved.is_file(): + target_path = resolved + if not path.is_absolute(): + try: + target_path = resolved.relative_to(Path.cwd().resolve()) + except ValueError: + pass + caller_nid = entry_nid if parent_nid == file_nid else parent_nid + add_edge(caller_nid, _make_id(str(target_path)) + "__entry", + "calls", node.start_point[0] + 1, + context="script_invocation") return if t == "declaration_command": diff --git a/tests/test_extract.py b/tests/test_extract.py index 0fd4030aa..519c9ccca 100644 --- a/tests/test_extract.py +++ b/tests/test_extract.py @@ -1210,6 +1210,86 @@ def test_extract_bash_emits_source_imports_from(tmp_path): assert import_edges[0].get("context") == "import" +@pytest.mark.parametrize("command", ["./helpers.sh", "bash ./helpers.sh"]) +def test_extract_bash_emits_script_invocation_calls(tmp_path, command): + helpers = tmp_path / "helpers.sh" + helpers.write_text("#!/bin/bash\necho helper\n", encoding="utf-8") + script = tmp_path / "deploy.sh" + script.write_text(f"#!/bin/bash\n{command}\n", encoding="utf-8") + + result = extract_bash(script) + invocation = [ + edge for edge in result["edges"] + if edge.get("relation") == "calls" and edge.get("context") == "script_invocation" + ] + + assert invocation == [{ + "source": _make_id(str(script)) + "__entry", + "target": _make_id(str(helpers.resolve())) + "__entry", + "relation": "calls", + "confidence": "EXTRACTED", + "source_file": str(script), + "source_location": "L2", + "weight": 1.0, + "context": "script_invocation", + }] + + +def test_extract_bash_skips_missing_and_shadowed_script_invocations(tmp_path): + helpers = tmp_path / "helpers.sh" + helpers.write_text("#!/bin/bash\necho helper\n", encoding="utf-8") + script = tmp_path / "deploy.sh" + script.write_text( + "#!/bin/bash\n" + "bash() { echo custom; }\n" + "bash ./helpers.sh\n" + "./missing.sh\n", + encoding="utf-8", + ) + + result = extract_bash(script) + + assert not any(edge.get("context") == "script_invocation" for edge in result["edges"]) + + +def test_extract_bash_skips_dynamic_script_invocation(tmp_path): + helpers = tmp_path / "helpers.sh" + helpers.write_text("#!/bin/bash\necho helper\n", encoding="utf-8") + script = tmp_path / "deploy.sh" + script.write_text('#!/bin/bash\nbash "./$SCRIPT.sh"\n', encoding="utf-8") + + result = extract_bash(script) + + assert not any(edge.get("context") == "script_invocation" for edge in result["edges"]) + + +def test_extract_bash_relative_script_invocation_targets_existing_entrypoint(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + helpers = Path("helpers.sh") + helpers.write_text("#!/bin/bash\necho helper\n", encoding="utf-8") + script = Path("deploy.sh") + script.write_text("#!/bin/bash\n./helpers.sh\n", encoding="utf-8") + + result = extract([script, helpers], cache_root=tmp_path, parallel=False) + node_ids = {node["id"] for node in result["nodes"]} + invocation = next(edge for edge in result["edges"] if edge.get("context") == "script_invocation") + + assert invocation["target"] in node_ids + + +def test_extract_bash_attributes_script_invocation_to_function(tmp_path): + helpers = tmp_path / "helpers.sh" + helpers.write_text("#!/bin/bash\necho helper\n", encoding="utf-8") + script = tmp_path / "deploy.sh" + script.write_text("#!/bin/bash\ndeploy() { bash ./helpers.sh; }\n", encoding="utf-8") + + result = extract_bash(script) + deploy = next(node for node in result["nodes"] if node["label"] == "deploy()") + invocation = next(edge for edge in result["edges"] if edge.get("context") == "script_invocation") + + assert invocation["source"] == deploy["id"] + + def test_extract_bash_no_self_loops(): result = extract_bash(FIXTURES / "sample.sh") for e in result["edges"]: