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

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions