Skip to content

Bash: source "${VAR}/lib.sh" emits an unresolvable edge — sourced libraries appear orphaned #2079

Description

@martr1a

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:

  1. Strip the variable segment(s) and take the remaining path suffix
    (lib/gpu-discover.sh).
  2. Match that suffix against files in the scan root.
  3. 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)

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions