From 3dc3f4d78c34adf9f5e40d667ec09a1d2321f5d7 Mon Sep 17 00:00:00 2001 From: edwardselby Date: Tue, 8 Sep 2026 16:08:03 +0100 Subject: [PATCH] fix(bash): resolve exec-position script paths built from a variable `script_invocation` edges (#1756) only ever resolved a path that is a bare literal -- `./helpers.sh`, or `bash ./helpers.sh`. Both paths into that branch go through `literal()`, which rejects by design any token containing `$`, so the single most common way a shell script calls a sibling is invisible: script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" "$script_dir/helpers.sh" --flag `cmd` is None for that command node, the `elif cmd and ...` guard short- circuits, and no edge is emitted. On a 206-file shell tree that uses the `script_dir` idiom throughout, `script_invocation` produced *zero* edges -- the feature was effectively dead for that whole style of codebase. The `source` branch already solved the same problem for `source "$DIR/lib/x.sh"` (#2079): strip the leading `${VAR}/` segments with `_bash_source_suffix` and resolve the literal remainder against the sourcing file's own directory, because the canonical `script_dir` idiom makes that variable equal to exactly that. This applies the identical treatment to the exec position -- read the command-name node's raw text rather than its `literal()`, strip the same way, and fall through to the existing `resolved.is_file()` gate. Nothing new is fabricated. `_bash_source_suffix` still returns None when the remainder holds another expansion (`"$dir/$name.sh"`) or a `..` segment, and the on-disk check still means a wrong script-dir guess emits nothing at all. The three existing negative tests -- missing, shadowed, and dynamic invocations -- pass unchanged. Measured on the same 206-file tree: 0 -> 16 edges across 10 distinct file pairs, every one hand-checked to a real invocation of a real file, no false positives. Full suite: no change in outcomes (the 25 failures here are pre-existing and unrelated -- terraform, ollama, skillgen). Fixes #3416. (cherry picked from commit 2e112c39fc4b412d867ba9254701bd412d598e78) --- graphify/extractors/bash.py | 21 ++++++-- tests/test_extract.py | 104 ++++++++++++++++++++++++++++++++++++ 2 files changed, 122 insertions(+), 3 deletions(-) diff --git a/graphify/extractors/bash.py b/graphify/extractors/bash.py index 341a63785..0c7217130 100644 --- a/graphify/extractors/bash.py +++ b/graphify/extractors/bash.py @@ -473,10 +473,25 @@ 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: + elif cmd not in defined_functions: + raw = cmd if (cmd and cmd.endswith(".sh")) else None + if cmd and cmd in _BASH_SCRIPT_RUNNERS and args: raw = literal(args[0]) + if raw is None: + # The command name is itself an expansion, e.g. + # `"$script_dir/x.sh" --flag`. `literal()` rejects any + # candidate containing "$", so `cmd` is None and neither + # of the two branches above can see the path. Read the + # node's raw text the way the `source` branch does and + # strip the leading ${VAR}/ segments -- the same + # script-dir assumption already used for `source + # "$DIR/lib/x.sh"` (#2079). `_bash_source_suffix` + # rejects a remainder that still holds an expansion or + # a `..` segment, and the `resolved.is_file()` gate + # below means a wrong script-dir guess emits nothing. + cand = _read_text(cmd_name_node, source).strip().strip("'\"") + if cand.endswith(".sh") and "$" in cand: + raw = _bash_source_suffix(cand) or None if raw and raw.endswith(".sh"): resolved = (path.parent / raw).resolve() if resolved.is_file(): diff --git a/tests/test_extract.py b/tests/test_extract.py index 5597c6688..f488ac752 100644 --- a/tests/test_extract.py +++ b/tests/test_extract.py @@ -2703,6 +2703,110 @@ def test_extract_bash_attributes_script_invocation_to_function(tmp_path): assert invocation["source"] == deploy["id"] +@pytest.mark.parametrize("invocation", [ + '"$script_dir/helpers.sh"', + '"${script_dir}/helpers.sh"', + '"$script_dir/helpers.sh" --flag value', + '$script_dir/helpers.sh', +]) +def test_extract_bash_script_invocation_via_variable_built_path(tmp_path, invocation): + 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" + 'script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"\n' + f"{invocation}\n", + encoding="utf-8", + ) + + result = extract_bash(script) + invocations = [ + edge for edge in result["edges"] + if edge.get("relation") == "calls" and edge.get("context") == "script_invocation" + ] + + assert invocations == [{ + "source": _make_id(str(script)) + "__entry", + "target": _make_id(str(helpers.resolve())) + "__entry", + "relation": "calls", + "confidence": "EXTRACTED", + "source_file": str(script), + "source_location": "L3", + "weight": 1.0, + "context": "script_invocation", + # Transient canonicalization hint (#2243); popped before persist. + "target_file": str(helpers.resolve()), + }] + + +def test_extract_bash_variable_built_invocation_with_dynamic_stem_emits_no_edge(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" + 'script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"\n' + '"$script_dir/$name.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_variable_built_invocation_no_on_disk_match_emits_no_edge(tmp_path): + script = tmp_path / "deploy.sh" + script.write_text( + "#!/bin/bash\n" + 'script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"\n' + '"$script_dir/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_variable_built_invocation_targets_existing_entrypoint(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" + 'script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"\n' + '"$script_dir/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["source"] in node_ids + assert invocation["target"] in node_ids + + +def test_extract_bash_attributes_variable_built_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\n" + 'script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"\n' + 'deploy() { "$script_dir/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"]: