Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 28 additions & 3 deletions graphify/extract.py
Original file line number Diff line number Diff line change
Expand Up @@ -4947,9 +4947,34 @@ def _learn(e: dict) -> None:
# 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)
Expand Down
33 changes: 29 additions & 4 deletions graphify/extractors/bash.py
Original file line number Diff line number Diff line change
Expand Up @@ -265,10 +265,35 @@ def walk(node, parent_nid: str) -> None:
"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:
Expand Down
56 changes: 56 additions & 0 deletions tests/test_extract.py
Original file line number Diff line number Diff line change
Expand Up @@ -1956,6 +1956,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
Expand Down