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"]: