diff --git a/graphify/extractors/bash.py b/graphify/extractors/bash.py index 6046281a6..de76e3f2d 100644 --- a/graphify/extractors/bash.py +++ b/graphify/extractors/bash.py @@ -531,8 +531,14 @@ def extract_bash(path: Path) -> dict: cmd_word = text(cmd_name_node).strip() if cmd_word[0:1] in {"'", '"'} and cmd_word[-1:] == cmd_word[0]: cmd_word = cmd_word[1:-1] + # A bare variable standing in for an interpreter (`$PYTHON x.py`) + # has no path separator; `"$DIR/run.sh" x.py` runs run.sh with + # x.py as its ARGUMENT, so the `/` guard keeps us from minting a + # spurious invokes edge to the argument on top of the real + # `calls` edge the .sh runner path already emits. is_interpreter_variable = ( - cmd is None and cmd_word.startswith("$") and "$(" not in cmd_word + cmd is None and cmd_word.startswith("$") + and "$(" not in cmd_word and "/" not in cmd_word ) if (is_known_interpreter or is_interpreter_variable) and args: invoked_raw = literal(args[0]) diff --git a/tests/test_extract.py b/tests/test_extract.py index cfabce45e..e17e7e81c 100644 --- a/tests/test_extract.py +++ b/tests/test_extract.py @@ -3291,6 +3291,25 @@ def test_extract_bash_skips_invokes_for_a_non_interpreter_command(tmp_path): assert not any(edge.get("relation") == "invokes" for edge in result["edges"]) +def test_extract_bash_variable_path_command_does_not_invoke_its_argument(tmp_path): + """A variable-built PATH command (`"$DIR/run.sh" stage.py`) runs run.sh with + stage.py as its argument; the `/` in the command word means it is not a bare + interpreter variable, so no spurious `invokes` edge to the argument is minted + (only the real .sh `calls` edge the runner path already emits).""" + stage = tmp_path / "stage.py" + stage.write_text("print('stage')\n", encoding="utf-8") + runner = tmp_path / "run.sh" + runner.write_text("#!/bin/bash\necho run\n", encoding="utf-8") + script = tmp_path / "caller.sh" + script.write_text('#!/bin/bash\nDIR="."\n"$DIR/run.sh" stage.py\n', encoding="utf-8") + + result = extract_bash(script) + assert not any( + edge.get("relation") == "invokes" and edge.get("target") == _make_id(str(stage.resolve())) + for edge in result["edges"] + ) + + def test_extract_bash_skips_invokes_for_a_missing_script(tmp_path): script = tmp_path / "runner.sh" script.write_text("#!/bin/bash\npython3 does_not_exist.py\n", encoding="utf-8")