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:
committed by
safishamsi
parent
556e615cc6
commit
4710b864ea
+28
-3
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user