mirror of
https://github.com/safishamsi/graphify.git
synced 2026-10-19 02:19:37 +00:00
fix(bash): link executed scripts across files (#1756)
extract_bash only created a cross-file edge for `source x.sh` / `. x.sh`. The two most common ways one script runs another — `bash x.sh` and `./x.sh` — produced no edge, so in any repo where scripts invoke each other by execution the call topology was missing (each script left an isolated file+entry pair). Emit a `calls` edge (context `script_invocation`) from the caller's entry (or enclosing function) to the invoked script's entry node, for script-runner commands (bash/sh/zsh/ksh/dash <path>) and bare `./x.sh`, but only when the target resolves to a real .sh file on disk — so no phantom edges to missing or function-shadowed names. Verified end-to-end: the edges land on real target nodes (no dangling drop at build). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
5777fec8c1
commit
a646d66a67
3 files changed
+103
-3
No files matched your search
@@ -4,6 +4,8 @@ Full release notes with details on each version: [GitHub Releases](https://githu
|
||||
|
||||
## 0.9.13 (unreleased)
|
||||
|
||||
- Fix: Bash scripts that run each other by execution now get a cross-file edge (#1756, thanks @balloon72). `extract_bash` only linked `source x.sh` / `. x.sh`; the two most common forms — `bash x.sh` and `./x.sh` — produced no edge, so execution topology was missing. They now emit a `calls` edge (context `script_invocation`) to the invoked script's entry node when the target resolves to a real file on disk (script runners `bash`/`sh`/`zsh`/`ksh`/`dash` and bare `./x.sh`), skipping missing or shadowed targets.
|
||||
|
||||
- Fix: Ruby `.rake` files are now extracted and participate in Ruby cross-file resolution like `.rb` (#1784, thanks @krishnateja7). `.rake` is plain Ruby but the extension was gated out of seven places (classification, extractor dispatch, the language-name/family maps, the `ruby_member_calls` resolver's suffix set, both `.rb`-suffix filters in `ruby_resolution.py`, and the build repo-tag map), so every rake task was skipped and its calls were invisible. All seven now include `.rake`; `Widget.tally` from a `.rake` task resolves to its `.rb` definition.
|
||||
|
||||
- Fix: cross-module references to a function now resolve to its definition instead of dangling on a name-only stub (#1781, thanks @EmilNyg). `_rewire_unique_stub_nodes` gated merge targets through `_is_type_like_definition`, which rejects any label ending in `)` — so function/method defs could never absorb their reference stubs, and "who references this function" returned nothing on the definition node while a sourceless stub held all the edges. Top-level function defs are now eligible rewire targets when the label match is globally unique, gated by a language-family match with the referrers (a Python `get_db` reference can't bind to a unique Go `get_db()`) and excluding stubs used as a supertype (`inherits`/`implements`/`extends` — you don't inherit from a function). Types are unchanged.
|
||||
|
||||
@@ -62,6 +62,7 @@ def extract_bash(path: Path) -> dict:
|
||||
add_edge(file_nid, entry_nid, "contains", 1)
|
||||
|
||||
_BASH_SOURCE_COMMANDS = frozenset({"source", "."})
|
||||
_BASH_SCRIPT_RUNNERS = frozenset({"bash", "sh", "zsh", "ksh", "dash"})
|
||||
# Parent node types that mean a contained command is part of a substitution
|
||||
# or expansion, not a real function call. Token-level filtering misses
|
||||
# these because `$(build)` exposes `build` as a child command whose name
|
||||
@@ -161,11 +162,11 @@ def extract_bash(path: Path) -> dict:
|
||||
cmd_name_node = node.children[0]
|
||||
if cmd_name_node:
|
||||
cmd = literal(cmd_name_node)
|
||||
args = [c for c in node.children
|
||||
if c.type in ("word", "string", "concatenation")
|
||||
and c != cmd_name_node]
|
||||
if cmd in _BASH_SOURCE_COMMANDS and cmd not in defined_functions:
|
||||
# find the path argument (first word after command name)
|
||||
args = [c for c in node.children
|
||||
if c.type in ("word", "string", "concatenation")
|
||||
and c != cmd_name_node]
|
||||
if args:
|
||||
raw = _read_text(args[0], source).strip().strip("'\"")
|
||||
line = node.start_point[0] + 1
|
||||
@@ -184,6 +185,23 @@ def extract_bash(path: Path) -> dict:
|
||||
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:
|
||||
raw = literal(args[0])
|
||||
if raw and raw.endswith(".sh"):
|
||||
resolved = (path.parent / raw).resolve()
|
||||
if resolved.is_file():
|
||||
target_path = resolved
|
||||
if not path.is_absolute():
|
||||
try:
|
||||
target_path = resolved.relative_to(Path.cwd().resolve())
|
||||
except ValueError:
|
||||
pass
|
||||
caller_nid = entry_nid if parent_nid == file_nid else parent_nid
|
||||
add_edge(caller_nid, _make_id(str(target_path)) + "__entry",
|
||||
"calls", node.start_point[0] + 1,
|
||||
context="script_invocation")
|
||||
return
|
||||
|
||||
if t == "declaration_command":
|
||||
|
||||
@@ -1210,6 +1210,86 @@ def test_extract_bash_emits_source_imports_from(tmp_path):
|
||||
assert import_edges[0].get("context") == "import"
|
||||
|
||||
|
||||
@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"
|
||||
helpers.write_text("#!/bin/bash\necho helper\n", encoding="utf-8")
|
||||
script = tmp_path / "deploy.sh"
|
||||
script.write_text(f"#!/bin/bash\n{command}\n", encoding="utf-8")
|
||||
|
||||
result = extract_bash(script)
|
||||
invocation = [
|
||||
edge for edge in result["edges"]
|
||||
if edge.get("relation") == "calls" and edge.get("context") == "script_invocation"
|
||||
]
|
||||
|
||||
assert invocation == [{
|
||||
"source": _make_id(str(script)) + "__entry",
|
||||
"target": _make_id(str(helpers.resolve())) + "__entry",
|
||||
"relation": "calls",
|
||||
"confidence": "EXTRACTED",
|
||||
"source_file": str(script),
|
||||
"source_location": "L2",
|
||||
"weight": 1.0,
|
||||
"context": "script_invocation",
|
||||
}]
|
||||
|
||||
|
||||
def test_extract_bash_skips_missing_and_shadowed_script_invocations(tmp_path):
|
||||
helpers = tmp_path / "helpers.sh"
|
||||
helpers.write_text("#!/bin/bash\necho helper\n", encoding="utf-8")
|
||||
script = tmp_path / "deploy.sh"
|
||||
script.write_text(
|
||||
"#!/bin/bash\n"
|
||||
"bash() { echo custom; }\n"
|
||||
"bash ./helpers.sh\n"
|
||||
"./missing.sh\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
result = extract_bash(script)
|
||||
|
||||
assert not any(edge.get("context") == "script_invocation" for edge in result["edges"])
|
||||
|
||||
|
||||
def test_extract_bash_skips_dynamic_script_invocation(tmp_path):
|
||||
helpers = tmp_path / "helpers.sh"
|
||||
helpers.write_text("#!/bin/bash\necho helper\n", encoding="utf-8")
|
||||
script = tmp_path / "deploy.sh"
|
||||
script.write_text('#!/bin/bash\nbash "./$SCRIPT.sh"\n', encoding="utf-8")
|
||||
|
||||
result = extract_bash(script)
|
||||
|
||||
assert not any(edge.get("context") == "script_invocation" for edge in result["edges"])
|
||||
|
||||
|
||||
def test_extract_bash_relative_script_invocation_targets_existing_entrypoint(tmp_path, monkeypatch):
|
||||
monkeypatch.chdir(tmp_path)
|
||||
helpers = Path("helpers.sh")
|
||||
helpers.write_text("#!/bin/bash\necho helper\n", encoding="utf-8")
|
||||
script = Path("deploy.sh")
|
||||
script.write_text("#!/bin/bash\n./helpers.sh\n", encoding="utf-8")
|
||||
|
||||
result = extract([script, helpers], cache_root=tmp_path, parallel=False)
|
||||
node_ids = {node["id"] for node in result["nodes"]}
|
||||
invocation = next(edge for edge in result["edges"] if edge.get("context") == "script_invocation")
|
||||
|
||||
assert invocation["target"] in node_ids
|
||||
|
||||
|
||||
def test_extract_bash_attributes_script_invocation_to_function(tmp_path):
|
||||
helpers = tmp_path / "helpers.sh"
|
||||
helpers.write_text("#!/bin/bash\necho helper\n", encoding="utf-8")
|
||||
script = tmp_path / "deploy.sh"
|
||||
script.write_text("#!/bin/bash\ndeploy() { bash ./helpers.sh; }\n", encoding="utf-8")
|
||||
|
||||
result = extract_bash(script)
|
||||
deploy = next(node for node in result["nodes"] if node["label"] == "deploy()")
|
||||
invocation = next(edge for edge in result["edges"] if edge.get("context") == "script_invocation")
|
||||
|
||||
assert invocation["source"] == deploy["id"]
|
||||
|
||||
|
||||
def test_extract_bash_no_self_loops():
|
||||
result = extract_bash(FIXTURES / "sample.sh")
|
||||
for e in result["edges"]:
|
||||
|
||||
Reference in new issue
Block a user