mirror of
https://github.com/safishamsi/graphify.git
synced 2026-08-29 01:36:33 +00:00
fix(extract): resolve bash source edges built from ${VAR} paths (#2079)
`source "${BENCH_DIR}/lib/x.sh"` (the `dirname "${BASH_SOURCE[0]}"` idiom)
took the bare-name branch, which baked the unexpanded `${BENCH_DIR}` text
into the target id via `_make_id`. That id matches no node, so the edge was
flagged dangling and dropped at export — shared shell libraries looked
orphaned and were split into separate communities.
Detect a `$`-expansion in the source argument, strip the leading
expansion segment(s), and resolve the literal suffix against the script's
own directory (which is what the canonical idiom makes the variable). Emit
`imports_from` as INFERRED only when it resolves to a real file on disk;
skip otherwise instead of emitting a dead id. Bare-name sources keep their
existing behavior.
This commit is contained in:
committed by
safishamsi
parent
bdcae25a26
commit
0019fc4d90
@@ -1,12 +1,34 @@
|
||||
"""Bash extractor. Moved verbatim from graphify/extract.py."""
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from graphify.extractors.base import _file_stem, _make_id, _read_text
|
||||
|
||||
|
||||
# Leading `${VAR}` / `$VAR` expansion segment(s) of a `source` path argument. The
|
||||
# canonical `BENCH_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"` idiom makes
|
||||
# such a variable resolve to the script's own directory, so the literal suffix that
|
||||
# follows (`lib/x.sh`) can be resolved against the sourcing file's own dir (#2079).
|
||||
_BASH_LEADING_EXPANSION = re.compile(
|
||||
r"^(?:(?:\$\{[^}]*\}|\$[A-Za-z_][A-Za-z0-9_]*)/?)+"
|
||||
)
|
||||
|
||||
|
||||
def _bash_source_suffix(raw: str) -> str | None:
|
||||
"""Return the literal path suffix of a variable-built `source` argument, or
|
||||
None when the remainder is empty, still holds an expansion, or escapes upward
|
||||
with ``..``. ``"${DIR}/lib/x.sh"`` -> ``"lib/x.sh"``."""
|
||||
suffix = _BASH_LEADING_EXPANSION.sub("", raw, count=1).lstrip("/")
|
||||
if not suffix or "$" in suffix:
|
||||
return None
|
||||
if ".." in suffix.split("/"):
|
||||
return None
|
||||
return suffix
|
||||
|
||||
|
||||
def extract_bash(path: Path) -> dict:
|
||||
"""Extract functions, source imports, and cross-function calls from a .sh file."""
|
||||
try:
|
||||
@@ -215,6 +237,33 @@ def extract_bash(path: Path) -> dict:
|
||||
"source_file": str_path,
|
||||
"source_location": f"L{line}",
|
||||
})
|
||||
elif "$" in raw:
|
||||
# Variable-built path, e.g. the ubiquitous
|
||||
# `source "${BENCH_DIR}/lib/x.sh"` idiom. The raw text
|
||||
# bakes the unexpanded `${VAR}` into the id, which
|
||||
# matches no node and is dropped as a dangling edge
|
||||
# (#2079). Strip the leading expansion(s) and resolve
|
||||
# the literal suffix against the script's own dir;
|
||||
# emit INFERRED (the expansion can't be proven
|
||||
# statically) only when it resolves to a real file,
|
||||
# never a dead id.
|
||||
suffix = _bash_source_suffix(raw)
|
||||
if suffix:
|
||||
resolved = (path.parent / suffix).resolve()
|
||||
if resolved.is_file():
|
||||
add_edge(file_nid, _make_id(str(resolved)),
|
||||
"imports_from", line,
|
||||
confidence="INFERRED", context="import")
|
||||
# Integration (#2141 + #2079): record the resolved
|
||||
# sourced file so calls into its functions resolve
|
||||
# too, not just the source edge. target_path is
|
||||
# absolute here; resolve_bash_source_edges takes it
|
||||
# as-is.
|
||||
bash_sources.append({
|
||||
"target_path": str(resolved),
|
||||
"source_file": str_path,
|
||||
"source_location": f"L{line}",
|
||||
})
|
||||
else:
|
||||
tgt_nid = _make_id(raw)
|
||||
if tgt_nid:
|
||||
|
||||
@@ -1541,6 +1541,46 @@ def test_extract_bash_emits_source_imports_from(tmp_path):
|
||||
assert import_edges[0].get("context") == "import"
|
||||
|
||||
|
||||
def test_extract_bash_source_via_variable_path_resolves_to_real_file(tmp_path):
|
||||
"""`source "${DIR}/lib/x.sh"` (the `dirname "${BASH_SOURCE[0]}"` idiom) must
|
||||
resolve to the real file node relative to the script dir — never emit a dead
|
||||
id baking in the literal `${DIR}` text (#2079)."""
|
||||
lib = tmp_path / "lib"
|
||||
lib.mkdir()
|
||||
helper = lib / "gpu-discover.sh"
|
||||
helper.write_text("# helper\n", encoding="utf-8")
|
||||
script = tmp_path / "bench.sh"
|
||||
script.write_text(
|
||||
'#!/bin/bash\n'
|
||||
'BENCH_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"\n'
|
||||
'source "${BENCH_DIR}/lib/gpu-discover.sh"\n',
|
||||
encoding="utf-8",
|
||||
)
|
||||
result = extract_bash(script)
|
||||
import_edges = [e for e in result["edges"] if e["relation"] == "imports_from"]
|
||||
targets = [e["target"] for e in import_edges]
|
||||
assert _make_id(str(helper.resolve())) in targets, import_edges
|
||||
assert not any("$" in t for t in targets), f"dead expansion id emitted: {targets}"
|
||||
inferred = next(e for e in import_edges
|
||||
if e["target"] == _make_id(str(helper.resolve())))
|
||||
assert inferred.get("confidence") == "INFERRED"
|
||||
assert inferred.get("context") == "import"
|
||||
|
||||
|
||||
def test_extract_bash_source_via_variable_path_no_match_emits_no_dead_edge(tmp_path):
|
||||
"""A variable-built source path with no matching file on disk must emit no
|
||||
import edge at all — not an `imports` edge to an id containing `${VAR}` (#2079)."""
|
||||
script = tmp_path / "bench.sh"
|
||||
script.write_text(
|
||||
'#!/bin/bash\nsource "${BENCH_DIR}/lib/missing.sh"\n',
|
||||
encoding="utf-8",
|
||||
)
|
||||
result = extract_bash(script)
|
||||
edges = [e for e in result["edges"]
|
||||
if e["relation"] in ("imports", "imports_from")]
|
||||
assert edges == [], f"variable source with no on-disk match must emit no edge; got: {edges}"
|
||||
|
||||
|
||||
@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"
|
||||
|
||||
Reference in New Issue
Block a user