Repository navigation
Bash: source "${VAR}/lib.sh" emits an unresolvable edge — sourced libraries appear orphaned #2079
Description
Summary
extract_bash handles source / . includes, but only when the path argument is a
literal starting with . or /. The very common idiom of sourcing through a
variable-built path:
BENCH_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "${BENCH_DIR}/lib/gpu-discover.sh"
source "${BENCH_DIR}/lib/gpu-control.sh"produces an edge whose target ID contains the literal ${BENCH_DIR} text. That ID
matches no real file node, so the edge is reported as dangling by the Step-4.5
health check and dropped at export. Net effect: shared shell libraries look
completely disconnected from their consumers, community detection places them in
separate communities, and the report flags them as "weakly-connected nodes /
possible documentation gaps" when the repo is actually fine.
Reproduction
Repo layout:
bench/ollama-bench-all.sh # source "${BENCH_DIR}/lib/gpu-discover.sh" (L22)
bench/ollama-bench-power.sh # source "${BENCH_DIR}/lib/gpu-control.sh" (L33)
bench/lib/gpu-discover.sh
bench/lib/gpu-control.sh
Build the graph, then:
$ graphify path "ollama-bench-all.sh" "gpu-control.sh"
# → no path found (expected: 1-hop imports_from)
The Step-4.5 diagnostic reports the dropped edges as dangling-endpoint edges
(they were 4 of the 38 dangling edges in our build).
Root cause
In extractors/bash.py, the source-command handler branches on the raw path text:
raw = _read_text(args[0], source).strip().strip("'\"")
if raw.startswith((".", "/")):
resolved = (path.parent / raw).resolve() # literal paths: resolved + existence-checked
...
else:
tgt_nid = _make_id(raw) # "${BENCH_DIR}/lib/gpu-discover.sh" → dead ID
add_edge(file_nid, tgt_nid, "imports", line, context="import")A ${VAR}-prefixed path takes the else branch and _make_id() bakes the
unexpanded variable text into the node ID, which can never match the AST-generated
ID of the actual file.
The hardening in #892 / #893 deliberately treats expansions and substitutions as
noise to be filtered (token-level literal() returning None,
is_inside_expansion() gating) — which is correct for command detection, but as a
side effect leaves the most robust sourcing idiom invisible to extraction. As far
as I can tell this side effect has not been reported before.
Why repos can't work around it in code
Rewriting source "${BENCH_DIR}/lib/x.sh" as a literal source ./lib/x.sh would
extract fine — but bash resolves a relative source against the runtime cwd,
not the script's location, so the rewrite breaks the script when invoked from any
other directory. The dirname "${BASH_SOURCE[0]}" idiom exists precisely to make
sourcing cwd-independent; repos shouldn't have to trade runtime robustness for
graph edges.
Suggested fix
When the source argument contains a ${...} (or $VAR) expansion, don't emit the
dead ID. Instead:
- Strip the variable segment(s) and take the remaining path suffix
(lib/gpu-discover.sh). - Match that suffix against files in the scan root.
- Unique match → emit
imports_fromto the real file node, markedINFERRED
(the expansion can't be proven statically).
Multiple matches →AMBIGUOUS, or skip.
No match → skip (current behavior, minus the dead ID).
This mirrors what #1778 did for executed scripts, and is the same problem class as
#155 (tsconfig path aliases in TS imports). The emit-an-ID-that-matches-nothing
failure mode was also fixed for json_config in #1764. If #1871 (graphify curate)
lands, ambiguous cases could alternatively be deferred to a curate entry instead
of guessing.
Workaround we're using
A post-build script that re-injects the ground-truth edges into graph.json
(tagged _origin: manual_source_fix) and re-runs graphify export html. It works,
but build_merge's replace-per-source semantics drop the manual edges whenever the
sourcing files are re-extracted (or on a full rebuild), so it has to be re-run
after updates — exactly the kind of correction #1871 aims to make durable.
Environment
- graphifyy 0.9.22 (uv tool install), Python 3.11, Linux
- Corpus: mixed bash/python repo, code-only AST path (no LLM extraction involved)
Activity
fix(extract): resolve bash source edges built from ${VAR} paths (#2079)
chore: bump to 0.9.26; changelog for #2137/#2126/#2148/#2141/#2079/#2163
safishamsi commentedon Jul 24, 2026
Fixed in v0.9.26 (#2139, thanks @HerenderKumar).
source "${BENCH_DIR}/lib/x.sh"no longer bakes the unexpanded variable into a dead node id; the leading expansion is stripped and the literal suffix resolved against the script's directory, emitted as INFERRED only when it resolves to a real file, and calls into the sourced library resolve too. A refinement to track literal assignments (avoiding a wrong-directory match) is tracked in #2172. https://github.com/Graphify-Labs/graphify/releases/tag/v0.9.26fix: resolve ${VAR} bash sources against the variable's real base (#2172
Squashed commit of the following:
fix(extract): resolve bash source edges built from ${VAR} paths (Grap…
chore: bump to 0.9.26; changelog for Graphify-Labs#2137/Graphify-Labs…
fix: resolve ${VAR} bash sources against the variable's real base (Gr…
fix(bash): resolve exec-position script paths built from a variable