mirror of
https://github.com/safishamsi/graphify.git
synced 2026-10-25 00:03:32 +00:00
fix(bash): resolve exec-position script paths built from a variable
`script_invocation` edges (#1756) only ever resolved a path that is a
bare literal -- `./helpers.sh`, or `bash ./helpers.sh`. Both paths into
that branch go through `literal()`, which rejects by design any token
containing `$`, so the single most common way a shell script calls a
sibling is invisible:
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
"$script_dir/helpers.sh" --flag
`cmd` is None for that command node, the `elif cmd and ...` guard short-
circuits, and no edge is emitted. On a 206-file shell tree that uses the
`script_dir` idiom throughout, `script_invocation` produced *zero* edges
-- the feature was effectively dead for that whole style of codebase.
The `source` branch already solved the same problem for
`source "$DIR/lib/x.sh"` (#2079): strip the leading `${VAR}/` segments
with `_bash_source_suffix` and resolve the literal remainder against the
sourcing file's own directory, because the canonical `script_dir` idiom
makes that variable equal to exactly that. This applies the identical
treatment to the exec position -- read the command-name node's raw text
rather than its `literal()`, strip the same way, and fall through to the
existing `resolved.is_file()` gate.
Nothing new is fabricated. `_bash_source_suffix` still returns None when
the remainder holds another expansion (`"$dir/$name.sh"`) or a `..`
segment, and the on-disk check still means a wrong script-dir guess
emits nothing at all. The three existing negative tests -- missing,
shadowed, and dynamic invocations -- pass unchanged.
Measured on the same 206-file tree: 0 -> 16 edges across 10 distinct
file pairs, every one hand-checked to a real invocation of a real file,
no false positives. Full suite: no change in outcomes (the 25 failures
here are pre-existing and unrelated -- terraform, ollama, skillgen).
Fixes #3416.
(cherry picked from commit 2e112c39fc)
This commit is contained in:
1 parent
34735fa762
commit
3dc3f4d78c
2 files changed
+122
-3
No files matched your search
@@ -473,10 +473,25 @@ 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:
|
||||
elif cmd not in defined_functions:
|
||||
raw = cmd if (cmd and cmd.endswith(".sh")) else None
|
||||
if cmd and cmd in _BASH_SCRIPT_RUNNERS and args:
|
||||
raw = literal(args[0])
|
||||
if raw is None:
|
||||
# The command name is itself an expansion, e.g.
|
||||
# `"$script_dir/x.sh" --flag`. `literal()` rejects any
|
||||
# candidate containing "$", so `cmd` is None and neither
|
||||
# of the two branches above can see the path. Read the
|
||||
# node's raw text the way the `source` branch does and
|
||||
# strip the leading ${VAR}/ segments -- the same
|
||||
# script-dir assumption already used for `source
|
||||
# "$DIR/lib/x.sh"` (#2079). `_bash_source_suffix`
|
||||
# rejects a remainder that still holds an expansion or
|
||||
# a `..` segment, and the `resolved.is_file()` gate
|
||||
# below means a wrong script-dir guess emits nothing.
|
||||
cand = _read_text(cmd_name_node, source).strip().strip("'\"")
|
||||
if cand.endswith(".sh") and "$" in cand:
|
||||
raw = _bash_source_suffix(cand) or None
|
||||
if raw and raw.endswith(".sh"):
|
||||
resolved = (path.parent / raw).resolve()
|
||||
if resolved.is_file():
|
||||
|
||||
@@ -2703,6 +2703,110 @@ def test_extract_bash_attributes_script_invocation_to_function(tmp_path):
|
||||
assert invocation["source"] == deploy["id"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("invocation", [
|
||||
'"$script_dir/helpers.sh"',
|
||||
'"${script_dir}/helpers.sh"',
|
||||
'"$script_dir/helpers.sh" --flag value',
|
||||
'$script_dir/helpers.sh',
|
||||
])
|
||||
def test_extract_bash_script_invocation_via_variable_built_path(tmp_path, invocation):
|
||||
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"
|
||||
'script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"\n'
|
||||
f"{invocation}\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
result = extract_bash(script)
|
||||
invocations = [
|
||||
edge for edge in result["edges"]
|
||||
if edge.get("relation") == "calls" and edge.get("context") == "script_invocation"
|
||||
]
|
||||
|
||||
assert invocations == [{
|
||||
"source": _make_id(str(script)) + "__entry",
|
||||
"target": _make_id(str(helpers.resolve())) + "__entry",
|
||||
"relation": "calls",
|
||||
"confidence": "EXTRACTED",
|
||||
"source_file": str(script),
|
||||
"source_location": "L3",
|
||||
"weight": 1.0,
|
||||
"context": "script_invocation",
|
||||
# Transient canonicalization hint (#2243); popped before persist.
|
||||
"target_file": str(helpers.resolve()),
|
||||
}]
|
||||
|
||||
|
||||
def test_extract_bash_variable_built_invocation_with_dynamic_stem_emits_no_edge(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"
|
||||
'script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"\n'
|
||||
'"$script_dir/$name.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_variable_built_invocation_no_on_disk_match_emits_no_edge(tmp_path):
|
||||
script = tmp_path / "deploy.sh"
|
||||
script.write_text(
|
||||
"#!/bin/bash\n"
|
||||
'script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"\n'
|
||||
'"$script_dir/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_variable_built_invocation_targets_existing_entrypoint(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"
|
||||
'script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"\n'
|
||||
'"$script_dir/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["source"] in node_ids
|
||||
assert invocation["target"] in node_ids
|
||||
|
||||
|
||||
def test_extract_bash_attributes_variable_built_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\n"
|
||||
'script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"\n'
|
||||
'deploy() { "$script_dir/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