Summary
extract_bash creates a cross-file edge only when a command is source or .. The two most common ways one shell script runs another — bash other.sh and ./other.sh — produce no edge at all. In any repo where scripts invoke each other by execution rather than sourcing, every script ends up as an isolated pair of nodes (the file node plus its <name> script entrypoint), and the call topology is missing from the graph.
Version
Reproduced on 0.9.11 (also present in 0.9.7). Python 3.14.
Reproduction
Four files in an empty directory:
# b.sh
#!/bin/bash
echo hi
# exec_call.sh
#!/bin/bash
bash ./b.sh
# bare_call.sh
#!/bin/bash
./b.sh
# source_call.sh
#!/bin/bash
source ./b.sh
from pathlib import Path
from graphify.extractors.bash import extract_bash
for f in ["exec_call.sh", "bare_call.sh", "source_call.sh"]:
r = extract_bash(Path(f))
rels = sorted({e.get("relation") or e.get("type") for e in r["edges"]})
print(f, rels)
Actual
exec_call.sh ['contains']
bare_call.sh ['contains']
source_call.sh ['contains', 'imports_from']
Expected
exec_call.sh and bare_call.sh should each yield a cross-file edge to b.sh — something like invokes, alongside the existing imports_from for source.
Cause
graphify/extractors/bash.py:64
_BASH_SOURCE_COMMANDS = frozenset({"source", "."})
The command branch (~line 164) checks cmd in _BASH_SOURCE_COMMANDS and returns otherwise. An exec-position script path is never considered.
Suggested fix
In the same command branch, additionally treat as an invocation:
- a
command_name whose literal ends in .sh (covers ./b.sh, /abs/path/b.sh);
- the first word argument of
bash, sh, zsh, ksh, dash (covers bash ./b.sh).
Resolve the path the same way source already does, and keep the existing resolved.exists() guard — it preserves the traversal protection noted in the B-1 comment, since an edge is only emitted for a file that is actually present in the tree.
Paths that cannot be resolved on disk are out of scope here; skipping them silently, as source already does, is fine.
Impact
Any shell-heavy repository. source is the minority idiom for invoking a sibling script; bash x.sh and ./x.sh are the common ones, and today both are invisible to the graph.
Summary
extract_bashcreates a cross-file edge only when a command issourceor.. The two most common ways one shell script runs another —bash other.shand./other.sh— produce no edge at all. In any repo where scripts invoke each other by execution rather than sourcing, every script ends up as an isolated pair of nodes (the file node plus its<name> scriptentrypoint), and the call topology is missing from the graph.Version
Reproduced on 0.9.11 (also present in 0.9.7). Python 3.14.
Reproduction
Four files in an empty directory:
Actual
Expected
exec_call.shandbare_call.shshould each yield a cross-file edge tob.sh— something likeinvokes, alongside the existingimports_fromforsource.Cause
graphify/extractors/bash.py:64The
commandbranch (~line 164) checkscmd in _BASH_SOURCE_COMMANDSand returns otherwise. An exec-position script path is never considered.Suggested fix
In the same
commandbranch, additionally treat as an invocation:command_namewhose literal ends in.sh(covers./b.sh,/abs/path/b.sh);bash,sh,zsh,ksh,dash(coversbash ./b.sh).Resolve the path the same way
sourcealready does, and keep the existingresolved.exists()guard — it preserves the traversal protection noted in the B-1 comment, since an edge is only emitted for a file that is actually present in the tree.Paths that cannot be resolved on disk are out of scope here; skipping them silently, as
sourcealready does, is fine.Impact
Any shell-heavy repository.
sourceis the minority idiom for invoking a sibling script;bash x.shand./x.share the common ones, and today both are invisible to the graph.