From 0d2ade622db540e352fe0f5d9612d7816d66ba27 Mon Sep 17 00:00:00 2001 From: ayushcodes10 Date: Fri, 2 Oct 2026 23:23:16 +0530 Subject: [PATCH] Record a bash script's invocation of another script as an edge The bash extractor recorded functions, source imports, and calls between functions, but nothing for the programs a script actually runs. In a pipeline driven by a shell script that is most of what the script does, so an orchestrator looked disconnected from every stage it runs: path between the runner and a stage it invokes found nothing. Add an invokes edge when a command's first word is a known interpreter (python, python3, python2, node, nodejs, ruby, perl) or a bare variable standing in for one, and an argument resolves to an existing file in the repo with a recognized script extension. Kept entirely separate from the existing bash to bash runner handling (bash/sh/zsh/ksh/dash plus direct dot slash invocation), which still emits its own calls edge unchanged, so this only adds coverage rather than risking the existing path. Gated on the target actually existing on disk, same as every other heuristic edge in this file, so a plain command that merely happens to take a dot py argument (cp, a custom tool) is never mistaken for an interpreter invocation. Fixes #3802. (cherry picked from commit 52fe75be932a5b5aa36092bd1fa0d5155b7045e2) --- graphify/extractors/bash.py | 42 +++++++++++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/graphify/extractors/bash.py b/graphify/extractors/bash.py index 0c7217130..6046281a6 100644 --- a/graphify/extractors/bash.py +++ b/graphify/extractors/bash.py @@ -174,6 +174,13 @@ def extract_bash(path: Path) -> dict: _BASH_SOURCE_COMMANDS = frozenset({"source", "."}) _BASH_SCRIPT_RUNNERS = frozenset({"bash", "sh", "zsh", "ksh", "dash"}) + # Non-shell interpreters a pipeline orchestrator commonly hands a script + # path to (#3802): `python3 scripts/stage_one.py`, `node build.js`. A + # script run this way is most of what an orchestrator does, and none of + # it produced an edge before — the orchestrator looked disconnected from + # every stage it runs. + _SCRIPT_INVOKERS = frozenset({"python", "python3", "python2", "node", "nodejs", "ruby", "perl"}) + _INVOKABLE_SCRIPT_EXTENSIONS = (".py", ".js", ".mjs", ".cjs", ".rb", ".pl", ".sh") # 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 @@ -511,6 +518,41 @@ def extract_bash(path: Path) -> dict: "calls", node.start_point[0] + 1, context="script_invocation", target_file=str(target_path)) + # python3 scripts/stage_one.py / node build.js / "$PYTHON" + # scripts/stage_two.py (#3802): a script run through a + # non-shell interpreter, or through a bare variable standing + # in for one, is still a real dependency on that file - + # independent of the .sh-specific runner/direct-invocation + # handling above, so this never interferes with it. + is_known_interpreter = ( + cmd is not None and cmd in _SCRIPT_INVOKERS + and cmd not in defined_functions + ) + 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] + is_interpreter_variable = ( + cmd is None and cmd_word.startswith("$") and "$(" not in cmd_word + ) + if (is_known_interpreter or is_interpreter_variable) and args: + invoked_raw = literal(args[0]) + if invoked_raw and invoked_raw.endswith(_INVOKABLE_SCRIPT_EXTENSIONS): + resolved = (path.parent / invoked_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 + tgt_nid = _make_id(str(target_path)) + if resolved.suffix == ".sh": + tgt_nid += "__entry" + add_edge(caller_nid, tgt_nid, "invokes", + node.start_point[0] + 1, + confidence="INFERRED", context="script_invocation", + target_file=str(target_path)) return if t == "declaration_command":