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_from to the real file node, marked INFERRED
(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)
Summary
extract_bashhandlessource/.includes, but only when the path argument is aliteral starting with
.or/. The very common idiom of sourcing through avariable-built path:
produces an edge whose target ID contains the literal
${BENCH_DIR}text. That IDmatches 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:
Build the graph, then:
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:A
${VAR}-prefixed path takes theelsebranch and_make_id()bakes theunexpanded 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()returningNone,is_inside_expansion()gating) — which is correct for command detection, but as aside 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 literalsource ./lib/x.shwouldextract fine — but bash resolves a relative
sourceagainst 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 makesourcing 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 thedead ID. Instead:
(
lib/gpu-discover.sh).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-runsgraphify export html. It works,but
build_merge's replace-per-source semantics drop the manual edges whenever thesourcing 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