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:
edwardselby authored and safishamsi committed 2026-09-09 17:20:57 +01:00
1 parent 34735fa762
commit 3dc3f4d78c
2 files changed
+122 -3

No files matched your search

+18 -3
View File
@@ -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():
+104
View 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"]: