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 52fe75be93)
This commit is contained in:
ayushcodes10 authored and safishamsi committed 2026-10-04 02:07:00 +01:00
1 parent c96a743b0c
commit 0d2ade622d
1 file changed
+42
+42
View File
@@ -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":