fix: resolve bash sources for extensionless libs and bare names (#2171)

Two coverage gaps left by #2141 / #2157, both misses rather than fabrications.

1. Extensionless shebang scripts. _SHEBANG_DISPATCH already routes a
   `#!/usr/bin/env bash` file with no extension to extract_bash, so its functions
   are indexed, but the cross-file source-resolution pass picked participants by
   filename suffix (`p.suffix in (".sh", ".bash")`). A sourced extensionless lib
   was therefore excluded and calls into it never bound. Select by shape as well:
   the bash extractor tags every node it emits with metadata.language == "bash".
   The suffix check stays so an empty .sh file, which has no nodes to inspect,
   still participates.

2. Bare `source lib.sh` (no ./ prefix). Only the `raw.startswith((".", "/"))`
   branch recorded a bash_sources entry; a bare name fell through to the opaque
   `imports` fallback, so neither the source edge nor calls into the lib resolved
   even though the file usually sits beside the script. The else branch now binds
   a sibling of that name when one exists.

The existence gate from the ./-prefixed branch carries over: a bare name that
resolves to no sibling keeps the old `imports` edge and records no bash_sources
entry, so nothing is invented. is_file() is wrapped against OSError so a name
that is invalid for the platform degrades instead of raising.

Transitive sources (a->b->c) remain unresolved, as the issue notes.
This commit is contained in:
Souptik Chakraborty
2026-07-26 11:08:09 +01:00
committed by safishamsi
parent 556e615cc6
commit 4710b864ea
3 changed files with 113 additions and 7 deletions
+28 -3
View File
@@ -5006,9 +5006,34 @@ def extract(
# same-named function in an unsourced file. Runs after the id-remap passes
# above so caller_nids and function node ids are final; dedups the source
# edge the extractor already emitted via existing_edges.
sh_paths = [p for p in paths if p.suffix in (".sh", ".bash")]
if sh_paths:
sh_results = [r for r, p in zip(per_file, paths) if p.suffix in (".sh", ".bash")]
# Selecting by filename suffix alone missed extensionless scripts:
# _SHEBANG_DISPATCH routes a `#!/usr/bin/env bash` file with no extension to
# extract_bash, so its functions get indexed, but a suffix-only filter left it
# out of this pass and calls into it never resolved (#2171). Select by shape
# too — the bash extractor tags every node it emits with
# metadata.language == "bash" — while keeping the suffix check so an empty
# .sh file (no nodes to inspect) still participates.
def _looks_like_bash(result: object) -> bool:
if not isinstance(result, dict):
return False
nodes = result.get("nodes")
if not isinstance(nodes, list):
return False
for n in nodes:
if not isinstance(n, dict):
continue
md = n.get("metadata")
if isinstance(md, dict) and md.get("language") == "bash":
return True
return False
sh_pairs = [
(r, p) for r, p in zip(per_file, paths)
if p.suffix in (".sh", ".bash") or _looks_like_bash(r)
]
if sh_pairs:
sh_results = [r for r, _ in sh_pairs]
sh_paths = [p for _, p in sh_pairs]
try:
all_edges.extend(
resolve_bash_source_edges(sh_results, sh_paths, root, existing_edges=all_edges)
+29 -4
View File
@@ -265,10 +265,35 @@ def extract_bash(path: Path) -> dict:
"source_location": f"L{line}",
})
else:
tgt_nid = _make_id(raw)
if tgt_nid:
add_edge(file_nid, tgt_nid, "imports", line,
context="import")
# Bare `source lib.sh` (no leading ./ or /). Bash
# itself resolves such a name via $PATH at runtime,
# but in practice the file sits next to the script,
# so bind it when a sibling of that name exists
# (#2171). Same existence gate as the ./-prefixed
# branch above: a name that resolves to nothing keeps
# the old opaque `imports` edge and records no
# bash_sources entry, so nothing is fabricated.
sibling: Path | None = None
if raw:
try:
candidate = path.parent / raw
if candidate.is_file():
sibling = candidate.resolve()
except OSError:
sibling = None
if sibling is not None:
add_edge(file_nid, _make_id(str(sibling)),
"imports_from", line, context="import")
bash_sources.append({
"target_path": raw,
"source_file": str_path,
"source_location": f"L{line}",
})
else:
tgt_nid = _make_id(raw)
if tgt_nid:
add_edge(file_nid, tgt_nid, "imports", line,
context="import")
elif cmd and cmd not in defined_functions:
raw = cmd if cmd.endswith(".sh") else None
if cmd in _BASH_SCRIPT_RUNNERS and args:
+56
View File
@@ -2013,6 +2013,62 @@ def test_extract_bash_call_to_external_command_stays_unlinked(tmp_path):
assert ("a_main", "b_deploy") not in calls, sorted(calls)
def test_extract_bash_call_into_extensionless_sourced_lib_resolves(tmp_path):
"""#2171: a sourced lib with a bash shebang but no extension must resolve.
_SHEBANG_DISPATCH already routes an extensionless `#!/usr/bin/env bash` file to
extract_bash, so its functions are indexed, but the cross-file source pass
selected participants by filename suffix only — so the lib was left out and
calls into it never bound.
"""
lib = tmp_path / "mylib"
lib.write_text("#!/usr/bin/env bash\nlib_helper() { echo ok; }\n", encoding="utf-8")
(tmp_path / "a.sh").write_text(
"#!/usr/bin/env bash\n"
"source ./mylib\n"
"main() { lib_helper; }\n",
encoding="utf-8",
)
result = extract([tmp_path / "a.sh", lib], cache_root=tmp_path)
calls = {(e["source"], e["target"]) for e in result["edges"] if e["relation"] == "calls"}
assert ("a_main", "mylib_lib_helper") in calls, sorted(calls)
def test_extract_bash_bare_source_name_resolves_to_sibling(tmp_path):
"""#2171: `source lib.sh` with no ./ prefix must bind to the sibling file.
Only the ``./``/``/``-prefixed branch recorded bash_sources; a bare name fell
through to the opaque ``imports`` fallback, so neither the source edge nor
calls into the lib resolved even though the file sits next to the script.
"""
(tmp_path / "lib.sh").write_text(
"#!/usr/bin/env bash\nbare_helper() { echo ok; }\n", encoding="utf-8"
)
(tmp_path / "a.sh").write_text(
"#!/usr/bin/env bash\n"
"source lib.sh\n"
"main() { bare_helper; }\n",
encoding="utf-8",
)
result = extract([tmp_path / "a.sh", tmp_path / "lib.sh"], cache_root=tmp_path)
imports = [(e["source"], e["target"]) for e in result["edges"]
if e["relation"] == "imports_from"]
assert ("a", "lib") in imports, imports
calls = {(e["source"], e["target"]) for e in result["edges"] if e["relation"] == "calls"}
assert ("a_main", "lib_bare_helper") in calls, sorted(calls)
def test_extract_bash_bare_source_missing_file_fabricates_nothing(tmp_path):
"""The #2171 bare-name branch keeps the existence gate: a name that resolves to
no sibling must not produce an imports_from edge or a bash_sources entry."""
script = tmp_path / "a.sh"
script.write_text("#!/usr/bin/env bash\nsource nope.sh\n", encoding="utf-8")
result = extract_bash(script)
assert result["bash_sources"] == [], result["bash_sources"]
imports_from = [e for e in result["edges"] if e["relation"] == "imports_from"]
assert imports_from == [], imports_from
def test_bash_var_sourced_function_call_resolves(tmp_path):
"""End-to-end integration of #2079 + #2141 (#2157/#2139): a library sourced
via the canonical ``${VAR}`` idiom must feed ``bash_sources`` so that