diff --git a/graphify/extract.py b/graphify/extract.py index bbfa301cc..b86f7687c 100644 --- a/graphify/extract.py +++ b/graphify/extract.py @@ -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) diff --git a/graphify/extractors/bash.py b/graphify/extractors/bash.py index de483638f..d7585f811 100644 --- a/graphify/extractors/bash.py +++ b/graphify/extractors/bash.py @@ -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: diff --git a/tests/test_extract.py b/tests/test_extract.py index be633c0fd..44325a368 100644 --- a/tests/test_extract.py +++ b/tests/test_extract.py @@ -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