Skip to content

bash extractor: source paths built from a second-level variable or from $(dirname "$VAR") are silently dropped #2596

Description

@wpoPR

Measured on graphify 0.9.38 (uv tool, macOS 15.6, Python 3.11), against the real extractor.

Summary

#2079 taught the bash extractor to resolve source "${VAR}/lib/x.sh" by stripping a leading
expansion, and #2172 added var_bases so the leading variable binds to the right base directory.
Both are working. Three adjacent forms are still dropped, and they are the ones a repo hits once
its scripts are invoked through symlinks or live one directory below the root:

# Form Where it dies
1 ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" — assignment value built from another variable _bash_assignment_base, bash.py:65 (if "$" in val … return None)
2 assignment inside an if (or any non-top-level block) var_bases loop, bash.py:425 — it iterates root.children only
3 source "$(dirname "$VAR")/lib/x.sh"command substitution in the source argument _BASH_LEADING_EXPANSION, bash.py:15-17 — matches ${VAR} / $VAR, never $(

Plus a smaller one, independent of all three:

| 4 | source "$VAR/../lib/x.sh".. in the literal suffix | _bash_source_suffix, bash.py:27-28 — the ".." in suffix.split("/") guard rejects before the var_bases lookup, so even a perfectly tracked $VAR loses the edge |

Form 3 is the dominant one in practice: it accounted for 22 of the 36 unresolved statements in
our repo. It is also the one where the assignment is irrelevant — a script can use the exact
canonical DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" idiom that #2079/#2172 support
and still produce no edge, because the failure is on the source line, not the assignment.

Why this is worth fixing rather than working around

The failure is silent on both sides. Nothing is logged, no dangling edge is reported by the
health check (the edge is never created at all), and the consumer-facing symptom is:

$ graphify affected "lib/common.sh"
No affected nodes found
$ echo $?
0

rc=0 with an empty result is indistinguishable from "this file genuinely has no dependents". In
our repo that exact output was produced while 20 tracked scripts sourced lib/common.sh, and
the file had 77 nodes in the graph. It took a dedicated investigation to discover the answer was wrong
rather than empty, and the interim guidance we shipped to our own agents was "for lib/*.sh, don't
use affected, use grep" — i.e. the tool was documented as untrustworthy for a whole language.

Minimal reproduction (run, output below)

mkdir -p fixture/bin fixture/lib
printf '#!/bin/bash\necho lib\n' > fixture/lib/y.sh
cat > fixture/bin/x.sh <<'EOF'
#!/bin/bash
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "$(dirname "$SCRIPT_DIR")/lib/y.sh"   # form 3 — cmdsubst in the source argument
ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
source "$ROOT/lib/y.sh"                       # form 1 — second-level variable
source "$SCRIPT_DIR/../lib/y.sh"              # form 4 — `..` in the suffix
EOF
cd fixture && git init -q && git add -A && git commit -qm init
graphify update . --no-cluster
python3 -c "
import json;g=json.load(open('graphify-out/graph.json'))
print('imports:',[l for l in g['links'] if l['relation'].startswith('imports')])"

Contrast fixture, identical except that the root is computed in the idiom the extractor already
tracks:

ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
source "$ROOT/lib/y.sh"

Actual output

$ graphify --version
graphify 0.9.38

--- variant A: the three broken forms ---
imports: []

--- variant B: the tracked idiom ---
imports: [{'source': 'bin_x', 'target': 'lib_y', 'relation': 'imports_from', 'source_file': 'bin/x.sh', 'source_location': 'L3'}]

Three real source statements, all resolvable statically, all pointing at a file that exists in
the scan → zero edges. One statement in the supported spelling → one imports_from edge. The
target file, the directory layout and the runtime behaviour are identical in both fixtures; only
the spelling differs.

Impact measured on a real repo

Repo: ~110 shell scripts, bin/ + lib/ layout, scripts invoked through symlinks.

Measure Value
source statements in the repo (tree-sitter AST enumeration) 109
of those, resolved to an edge by the extractor 32
$HOME/... targets (correctly unresolvable — outside the repo) 41
repo-relative statements silently dropped 36, across 24 files
imports/imports_from edges with a .sh target in graph.json 24
graphify affected "lib/common.sh" No affected nodes found, rc=0

We fixed this on our side by rewriting the bootstrap of 29 statements in 24 files into the
supported spelling (_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" + source "$_ROOT/lib/x.sh"). Same graphify version on both sides of the comparison (0.9.38, baseline
rebuilt from the base commit so the delta is not confounded with an upgrade):

Measure Before After
imports/imports_from edges with a .sh target 24 53
graphify affected "lib/common.sh" empty, rc=0 23 dependents
migrated statements ↔ new edges bijection 29 ↔ 29, no edges lost

So the information was fully recoverable from the source — the extractor just could not see it in
those spellings. 17 statements remain permanently unresolvable for us, and 6 of those are the
interesting class: scripts that resolve their own path with a readlink loop because they are
invoked through a symlink. For those, rewriting to ${BASH_SOURCE[0]} would make the script
resolve its root to the symlink's directory — a real behavioural regression — so no amount of
rewriting on our side can produce those edges. That is the subset only an extractor-side fix
reaches.

Fix directions (in the order we'd rank them)

  1. Form 3 — $(dirname "$VAR") in the source argument. Recognise the dirname idiom on the
    source line itself, the same way _BASH_DIRNAME_IDIOM already recognises it on the assignment
    line: resolve $(dirname "$VAR") to var_bases[VAR].parent (falling back to the script dir when
    VAR is untracked, exactly as today), then resolve the literal suffix against it. Highest
    payoff, and it reuses machinery that already exists.
  2. Form 1 — second-level assignment. In _bash_assignment_base, before the if "$" in val
    bail-out, try resolving the value against already-known var_bases entries ("$(cd "$SCRIPT_DIR/.." && pwd)"var_bases["SCRIPT_DIR"].parent). This needs var_bases to be
    built in source order and passed in — a one-pass change, and it keeps the fail-closed default
    for anything unrecognised.
  3. Form 2 — non-top-level assignments. Walk variable_assignment nodes recursively instead of
    iterating root.children. The guard-block spelling (if [ -z "${X_LOADED:-}" ]; then DIR=…) is
    common in libraries that must be idempotent when sourced twice.
  4. Form 4 — .. in the suffix. The ".." in suffix.split("/") rejection made sense when the
    base was always a guess (the script's own dir), since .. could escape the tree. Once the
    base comes from var_bases it is a known directory, and base / suffix normalised with
    os.path.normpath + the existing is_file() gate is as safe as any other suffix. Suggestion:
    keep the rejection only on the script-dir-guess path.

All four keep the current safety properties: edges stay INFERRED, and nothing is emitted unless
the resolved path is_file().

Config-driven alternative, if extending the heuristics is not wanted: a per-repo mapping of
variable name → base directory, in the spirit of PR #666 (GRAPHIFY_EXTENSION_ALIASES). It would
let a repo declare ROOT=. once instead of touching every script. We'd take either; the heuristic
fix is the one that helps repos that never file an issue.

Secondary ask, independent of the above: affected returning rc=0 on an empty result is what
turns any of these misses into a silent wrong answer. A distinct exit code (or a one-line note on
stderr when the queried node exists but has no in-edges) would make the failure legible without
changing any extraction behaviour.

Dedupe

Searched Graphify-Labs/graphify before filing (gh search issues), by the three phrasings
bash extractor source, imports_from bash, dirname source path, plus term searches for
BASH_SOURCE, dirname, imports_from, and bash restricted to titles.

Issue State Overlap
#2079source "${VAR}/lib.sh" emits an unresolvable edge closed the fix this builds on: leading-expansion strip. Does not cover a $( argument
#2172 — track literal assignments to avoid a wrong-directory false positive closed introduced var_bases; explicitly leaves "a value built from other variables, or command substitution we do not model" untracked — forms 1 and 3 are precisely that residue
#2171 — extensionless shebang scripts and bare source lib.sh closed different axis (target spelling, not path construction)
#2141 — calls into functions of a sourced file get no call edges closed downstream of resolution, not resolution itself

No open issue covers forms 1-4. The three phrasings returned no matches at all.


Filed from a repo that adopted graphify as its primary code-structure query tool; happy to test a
branch against the 109-statement corpus described above.

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