Extract functions, source imports, and cross-function calls from a .sh file.
(path: Path)
| 13902 | |
| 13903 | |
| 13904 | def extract_bash(path: Path) -> dict: |
| 13905 | """Extract functions, source imports, and cross-function calls from a .sh file.""" |
| 13906 | try: |
| 13907 | import tree_sitter_bash as tsbash |
| 13908 | from tree_sitter import Language, Parser |
| 13909 | except ImportError: |
| 13910 | return {"nodes": [], "edges": [], "error": "tree-sitter-bash not installed"} |
| 13911 | |
| 13912 | try: |
| 13913 | language = Language(tsbash.language()) |
| 13914 | parser = Parser(language) |
| 13915 | source = path.read_bytes() |
| 13916 | tree = parser.parse(source) |
| 13917 | root = tree.root_node |
| 13918 | except Exception as e: |
| 13919 | return {"nodes": [], "edges": [], "error": str(e)} |
| 13920 | |
| 13921 | stem = _file_stem(path) |
| 13922 | str_path = str(path) |
| 13923 | nodes: list[dict] = [] |
| 13924 | edges: list[dict] = [] |
| 13925 | seen_ids: set[str] = set() |
| 13926 | function_bodies: list[tuple[str, Any]] = [] |
| 13927 | defined_functions: set[str] = set() |
| 13928 | |
| 13929 | from graphify.security import sanitize_metadata # module-level cached import |
| 13930 | |
| 13931 | def add_node(nid: str, label: str, line: int, kind: str = "code") -> None: |
| 13932 | if nid and nid not in seen_ids: |
| 13933 | seen_ids.add(nid) |
| 13934 | nodes.append({"id": nid, "label": label, "file_type": "code", |
| 13935 | "source_file": str_path, "source_location": f"L{line}", |
| 13936 | "metadata": sanitize_metadata({"language": "bash", "kind": kind})}) # noqa: E501 |
| 13937 | |
| 13938 | def add_edge(src: str, tgt: str, relation: str, line: int, |
| 13939 | confidence: str = "EXTRACTED", weight: float = 1.0, |
| 13940 | context: str | None = None) -> None: |
| 13941 | if not src or not tgt or src == tgt: |
| 13942 | return |
| 13943 | edge = {"source": src, "target": tgt, "relation": relation, |
| 13944 | "confidence": confidence, "source_file": str_path, |
| 13945 | "source_location": f"L{line}", "weight": weight} |
| 13946 | if context: |
| 13947 | edge["context"] = context |
| 13948 | edges.append(edge) |
| 13949 | |
| 13950 | file_nid = _make_id(str(path)) |
| 13951 | # file_nid is fully path-derived and never produced by _make_id(stem, func_name), |
| 13952 | # so appending "__entry" guarantees a distinct ID from any function node. |
| 13953 | entry_nid = file_nid + "__entry" |
| 13954 | add_node(file_nid, path.name, 1, kind="file") |
| 13955 | add_node(entry_nid, f"{path.name} script", 1, kind="bash_entrypoint") |
| 13956 | add_edge(file_nid, entry_nid, "contains", 1) |
| 13957 | |
| 13958 | _BASH_SOURCE_COMMANDS = frozenset({"source", "."}) |
| 13959 | # Parent node types that mean a contained command is part of a substitution |
| 13960 | # or expansion, not a real function call. Token-level filtering misses |
| 13961 | # these because `$(build)` exposes `build` as a child command whose name |