Extract Terraform/HCL blocks and the references between them via tree-sitter. Nodes: resources, data sources, modules, variables, outputs, providers, and locals. Edges: `contains` (file -> block), `references` (block -> the blocks it interpolates, e.g. `aws_instance.web` -> `var.region`
(path: Path)
| 15727 | |
| 15728 | |
| 15729 | def extract_terraform(path: Path) -> dict: |
| 15730 | """Extract Terraform/HCL blocks and the references between them via tree-sitter. |
| 15731 | |
| 15732 | Nodes: resources, data sources, modules, variables, outputs, providers, and |
| 15733 | locals. Edges: `contains` (file -> block), `references` (block -> the blocks |
| 15734 | it interpolates, e.g. `aws_instance.web` -> `var.region`), and `depends_on` |
| 15735 | (explicit dependency edges). |
| 15736 | |
| 15737 | Node IDs are scoped by the parent directory, not the file stem, because |
| 15738 | Terraform resources are module(directory)-scoped: a resource defined in |
| 15739 | main.tf is referenced from other .tf files in the same directory. Directory |
| 15740 | scoping lets those cross-file references resolve when per-file extractions |
| 15741 | are merged (stem scoping would split a definition from its references). |
| 15742 | """ |
| 15743 | try: |
| 15744 | import tree_sitter_hcl as tshcl |
| 15745 | from tree_sitter import Language, Parser |
| 15746 | except ImportError: |
| 15747 | return {"nodes": [], "edges": [], "error": "tree_sitter_hcl not installed. Run: pip install tree-sitter-hcl"} |
| 15748 | |
| 15749 | try: |
| 15750 | language = Language(tshcl.language()) |
| 15751 | parser = Parser(language) |
| 15752 | source = path.read_bytes() |
| 15753 | tree = parser.parse(source) |
| 15754 | root = tree.root_node |
| 15755 | except Exception as e: |
| 15756 | return {"nodes": [], "edges": [], "error": str(e)} |
| 15757 | |
| 15758 | str_path = str(path) |
| 15759 | file_nid = _make_id(str_path) |
| 15760 | scope = path.parent.name or "tf" |
| 15761 | |
| 15762 | nodes: list[dict] = [{"id": file_nid, "label": path.name, "file_type": "code", |
| 15763 | "source_file": str_path, "source_location": None}] |
| 15764 | edges: list[dict] = [] |
| 15765 | seen_ids: set[str] = {file_nid} |
| 15766 | seen_edges: set[tuple[str, str, str]] = set() |
| 15767 | |
| 15768 | def _read(n) -> str: |
| 15769 | return source[n.start_byte:n.end_byte].decode("utf-8", errors="replace") |
| 15770 | |
| 15771 | def _label_text(n) -> str: |
| 15772 | return _read(n).strip().strip('"') |
| 15773 | |
| 15774 | def _add_node(address: str, label: str, line: int) -> str: |
| 15775 | nid = _make_id(scope, address) |
| 15776 | if nid not in seen_ids: |
| 15777 | seen_ids.add(nid) |
| 15778 | nodes.append({"id": nid, "label": label, "file_type": "code", |
| 15779 | "source_file": str_path, "source_location": f"L{line}"}) |
| 15780 | edges.append({"source": file_nid, "target": nid, "relation": "contains", |
| 15781 | "confidence": "EXTRACTED", "source_file": str_path, |
| 15782 | "source_location": f"L{line}", "weight": 1.0}) |
| 15783 | return nid |
| 15784 | |
| 15785 | def _add_edge(src: str, address: str, relation: str, line: int) -> None: |
| 15786 | tgt = _make_id(scope, address) |