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)
- 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.
- 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.
- 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.
- 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 |
#2079 — source "${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.
Measured on graphify 0.9.38 (
uv tool, macOS 15.6, Python 3.11), against the real extractor.Summary
#2079taught the bash extractor to resolvesource "${VAR}/lib/x.sh"by stripping a leadingexpansion, and
#2172addedvar_basesso 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:
ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"— assignment value built from another variable_bash_assignment_base,bash.py:65(if "$" in val … return None)if(or any non-top-level block)var_basesloop,bash.py:425— it iteratesroot.childrenonlysource "$(dirname "$VAR")/lib/x.sh"— command substitution in thesourceargument_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 thevar_baseslookup, so even a perfectly tracked$VARloses 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/#2172supportand still produce no edge, because the failure is on the
sourceline, 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:
rc=0with an empty result is indistinguishable from "this file genuinely has no dependents". Inour repo that exact output was produced while 20 tracked scripts sourced
lib/common.sh, andthe 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'tuse
affected, usegrep" — i.e. the tool was documented as untrustworthy for a whole language.Minimal reproduction (run, output below)
Contrast fixture, identical except that the root is computed in the idiom the extractor already
tracks:
Actual output
Three real
sourcestatements, all resolvable statically, all pointing at a file that exists inthe scan → zero edges. One statement in the supported spelling → one
imports_fromedge. Thetarget 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.sourcestatements in the repo (tree-sitter AST enumeration)$HOME/...targets (correctly unresolvable — outside the repo)imports/imports_fromedges with a.shtarget ingraph.jsongraphify affected "lib/common.sh"No affected nodes found, rc=0We 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, baselinerebuilt from the base commit so the delta is not confounded with an upgrade):
imports/imports_fromedges with a.shtargetgraphify affected "lib/common.sh"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
readlinkloop because they areinvoked through a symlink. For those, rewriting to
${BASH_SOURCE[0]}would make the scriptresolve 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)
$(dirname "$VAR")in thesourceargument. Recognise thedirnameidiom on thesourceline itself, the same way_BASH_DIRNAME_IDIOMalready recognises it on the assignmentline: resolve
$(dirname "$VAR")tovar_bases[VAR].parent(falling back to the script dir whenVARis untracked, exactly as today), then resolve the literal suffix against it. Highestpayoff, and it reuses machinery that already exists.
_bash_assignment_base, before theif "$" in valbail-out, try resolving the value against already-known
var_basesentries ("$(cd "$SCRIPT_DIR/.." && pwd)"→var_bases["SCRIPT_DIR"].parent). This needsvar_basesto bebuilt in source order and passed in — a one-pass change, and it keeps the fail-closed default
for anything unrecognised.
variable_assignmentnodes recursively instead ofiterating
root.children. The guard-block spelling (if [ -z "${X_LOADED:-}" ]; then DIR=…) iscommon in libraries that must be idempotent when sourced twice.
..in the suffix. The".." in suffix.split("/")rejection made sense when thebase was always a guess (the script's own dir), since
..could escape the tree. Once thebase comes from
var_basesit is a known directory, andbase / suffixnormalised withos.path.normpath+ the existingis_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 unlessthe 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 wouldlet a repo declare
ROOT=.once instead of touching every script. We'd take either; the heuristicfix is the one that helps repos that never file an issue.
Secondary ask, independent of the above:
affectedreturningrc=0on an empty result is whatturns 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/graphifybefore filing (gh search issues), by the three phrasingsbash extractor source,imports_from bash,dirname source path, plus term searches forBASH_SOURCE,dirname,imports_from, andbashrestricted to titles.source "${VAR}/lib.sh"emits an unresolvable edge$(argumentvar_bases; explicitly leaves "a value built from other variables, or command substitution we do not model" untracked — forms 1 and 3 are precisely that residuesource lib.shNo 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.