fix(extract): resolve bash source edges built from ${VAR} paths (#2079)

`source "${BENCH_DIR}/lib/x.sh"` (the `dirname "${BASH_SOURCE[0]}"` idiom)
took the bare-name branch, which baked the unexpanded `${BENCH_DIR}` text
into the target id via `_make_id`. That id matches no node, so the edge was
flagged dangling and dropped at export — shared shell libraries looked
orphaned and were split into separate communities.

Detect a `$`-expansion in the source argument, strip the leading
expansion segment(s), and resolve the literal suffix against the script's
own directory (which is what the canonical idiom makes the variable). Emit
`imports_from` as INFERRED only when it resolves to a real file on disk;
skip otherwise instead of emitting a dead id. Bare-name sources keep their
existing behavior.
This commit is contained in:
HerenderKumar
2026-07-24 23:41:28 +01:00
committed by safishamsi
parent bdcae25a26
commit 0019fc4d90
2 changed files with 90 additions and 1 deletions
+50 -1
View File
@@ -1,12 +1,34 @@
"""Bash extractor. Moved verbatim from graphify/extract.py."""
from __future__ import annotations
import re
from pathlib import Path
from typing import Any
from graphify.extractors.base import _file_stem, _make_id, _read_text
# Leading `${VAR}` / `$VAR` expansion segment(s) of a `source` path argument. The
# canonical `BENCH_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"` idiom makes
# such a variable resolve to the script's own directory, so the literal suffix that
# follows (`lib/x.sh`) can be resolved against the sourcing file's own dir (#2079).
_BASH_LEADING_EXPANSION = re.compile(
r"^(?:(?:\$\{[^}]*\}|\$[A-Za-z_][A-Za-z0-9_]*)/?)+"
)
def _bash_source_suffix(raw: str) -> str | None:
"""Return the literal path suffix of a variable-built `source` argument, or
None when the remainder is empty, still holds an expansion, or escapes upward
with ``..``. ``"${DIR}/lib/x.sh"`` -> ``"lib/x.sh"``."""
suffix = _BASH_LEADING_EXPANSION.sub("", raw, count=1).lstrip("/")
if not suffix or "$" in suffix:
return None
if ".." in suffix.split("/"):
return None
return suffix
def extract_bash(path: Path) -> dict:
"""Extract functions, source imports, and cross-function calls from a .sh file."""
try:
@@ -215,6 +237,33 @@ def extract_bash(path: Path) -> dict:
"source_file": str_path,
"source_location": f"L{line}",
})
elif "$" in raw:
# Variable-built path, e.g. the ubiquitous
# `source "${BENCH_DIR}/lib/x.sh"` idiom. The raw text
# bakes the unexpanded `${VAR}` into the id, which
# matches no node and is dropped as a dangling edge
# (#2079). Strip the leading expansion(s) and resolve
# the literal suffix against the script's own dir;
# emit INFERRED (the expansion can't be proven
# statically) only when it resolves to a real file,
# never a dead id.
suffix = _bash_source_suffix(raw)
if suffix:
resolved = (path.parent / suffix).resolve()
if resolved.is_file():
add_edge(file_nid, _make_id(str(resolved)),
"imports_from", line,
confidence="INFERRED", context="import")
# Integration (#2141 + #2079): record the resolved
# sourced file so calls into its functions resolve
# too, not just the source edge. target_path is
# absolute here; resolve_bash_source_edges takes it
# as-is.
bash_sources.append({
"target_path": str(resolved),
"source_file": str_path,
"source_location": f"L{line}",
})
else:
tgt_nid = _make_id(raw)
if tgt_nid:
+40
View File
@@ -1541,6 +1541,46 @@ def test_extract_bash_emits_source_imports_from(tmp_path):
assert import_edges[0].get("context") == "import"
def test_extract_bash_source_via_variable_path_resolves_to_real_file(tmp_path):
"""`source "${DIR}/lib/x.sh"` (the `dirname "${BASH_SOURCE[0]}"` idiom) must
resolve to the real file node relative to the script dir — never emit a dead
id baking in the literal `${DIR}` text (#2079)."""
lib = tmp_path / "lib"
lib.mkdir()
helper = lib / "gpu-discover.sh"
helper.write_text("# helper\n", encoding="utf-8")
script = tmp_path / "bench.sh"
script.write_text(
'#!/bin/bash\n'
'BENCH_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"\n'
'source "${BENCH_DIR}/lib/gpu-discover.sh"\n',
encoding="utf-8",
)
result = extract_bash(script)
import_edges = [e for e in result["edges"] if e["relation"] == "imports_from"]
targets = [e["target"] for e in import_edges]
assert _make_id(str(helper.resolve())) in targets, import_edges
assert not any("$" in t for t in targets), f"dead expansion id emitted: {targets}"
inferred = next(e for e in import_edges
if e["target"] == _make_id(str(helper.resolve())))
assert inferred.get("confidence") == "INFERRED"
assert inferred.get("context") == "import"
def test_extract_bash_source_via_variable_path_no_match_emits_no_dead_edge(tmp_path):
"""A variable-built source path with no matching file on disk must emit no
import edge at all — not an `imports` edge to an id containing `${VAR}` (#2079)."""
script = tmp_path / "bench.sh"
script.write_text(
'#!/bin/bash\nsource "${BENCH_DIR}/lib/missing.sh"\n',
encoding="utf-8",
)
result = extract_bash(script)
edges = [e for e in result["edges"]
if e["relation"] in ("imports", "imports_from")]
assert edges == [], f"variable source with no on-disk match must emit no edge; got: {edges}"
@pytest.mark.parametrize("command", ["./helpers.sh", "bash ./helpers.sh"])
def test_extract_bash_emits_script_invocation_calls(tmp_path, command):
helpers = tmp_path / "helpers.sh"