a06f331eb8
CI / benchmark (push) Has been skipped
install-script / posix-syntax (push) Successful in 6m1s
CI / build-onnx (push) Failing after 6m43s
init-smoke / dry-run (push) Failing after 15m57s
security / govulncheck (push) Has been cancelled
security / trivy-fs (push) Has been cancelled
CI / test (1.26, ubuntu-latest) (push) Has been cancelled
Scorecard supply-chain security / Scorecard analysis (push) Has been cancelled
CI / test (1.26, macos-latest) (push) Has been cancelled
CI / build-windows (push) Has been cancelled
CI / lint (push) Has been cancelled
install-script / powershell-syntax (push) Has been cancelled
install-script / install (macos-14) (push) Has been cancelled
install-script / install (ubuntu-latest) (push) Has been cancelled
367 lines
11 KiB
Go
367 lines
11 KiB
Go
package languages
|
|
|
|
import (
|
|
"path"
|
|
"strings"
|
|
|
|
"github.com/zzet/gortex/internal/graph"
|
|
"github.com/zzet/gortex/internal/parser"
|
|
sitter "github.com/zzet/gortex/internal/parser/tsitter"
|
|
"github.com/zzet/gortex/internal/parser/tsitter/hcl"
|
|
)
|
|
|
|
// HCLExtractor extracts HCL/Terraform files into graph nodes and edges.
|
|
// Top-level blocks (resource, data, module, variable, output, provider,
|
|
// locals, terraform, …) become KindType nodes; the block kind rides on
|
|
// Meta["block_type"] and its Terraform reference address on
|
|
// Meta["tf_address"]. Each `locals` declaration additionally yields a
|
|
// KindConstant node addressed `local.<key>`. Cross-block value
|
|
// expressions (var.x, local.y, module.m, data.t.n, aws_instance.web.id)
|
|
// produce EdgeReferences edges so a change-impact walk can answer "what
|
|
// breaks if this resource/variable changes?". Block node IDs are scoped
|
|
// to the file's directory — the Terraform module boundary — so a
|
|
// reference in one .tf file resolves to a block defined in a sibling .tf
|
|
// file of the same module by exact ID match.
|
|
type HCLExtractor struct {
|
|
lang *sitter.Language
|
|
}
|
|
|
|
func NewHCLExtractor() *HCLExtractor {
|
|
return &HCLExtractor{lang: hcl.GetLanguage()}
|
|
}
|
|
|
|
func (e *HCLExtractor) Language() string { return "hcl" }
|
|
func (e *HCLExtractor) Extensions() []string { return []string{".tf", ".tfvars", ".hcl"} }
|
|
|
|
func (e *HCLExtractor) Extract(filePath string, src []byte) (*parser.ExtractionResult, error) {
|
|
tree, err := parser.ParseFile(src, e.lang)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer tree.Close()
|
|
|
|
root := tree.RootNode()
|
|
result := &parser.ExtractionResult{}
|
|
|
|
fileNode := &graph.Node{
|
|
ID: filePath, Kind: graph.KindFile, Name: filePath,
|
|
FilePath: filePath, StartLine: 1, EndLine: int(root.EndPoint().Row) + 1,
|
|
Language: "hcl",
|
|
}
|
|
result.Nodes = append(result.Nodes, fileNode)
|
|
|
|
dir := hclModuleDir(filePath)
|
|
seen := make(map[string]bool) // block-name dedup, per file
|
|
refSeen := make(map[string]bool) // (from\x00to) reference dedup
|
|
e.walkTopLevel(root, src, filePath, dir, fileNode.ID, result, seen, refSeen)
|
|
|
|
return result, nil
|
|
}
|
|
|
|
// hclModuleDir returns the directory holding the file — the Terraform
|
|
// module boundary. Every .tf file in a directory shares one address
|
|
// space, so block node IDs are scoped to the directory
|
|
// (hcl::<dir>::<address>); a reference in one file then resolves to a
|
|
// block defined in a sibling file of the same module by exact ID match.
|
|
func hclModuleDir(filePath string) string {
|
|
d := path.Dir(filePath)
|
|
if d == "" {
|
|
return "."
|
|
}
|
|
return d
|
|
}
|
|
|
|
func hclNodeID(dir, address string) string { return "hcl::" + dir + "::" + address }
|
|
|
|
// walkTopLevel descends config_file / body wrappers and dispatches every
|
|
// TOP-LEVEL block (one not nested inside another block) to extractBlock.
|
|
// Nested blocks (ingress, lifecycle, dynamic, …) are not separate
|
|
// definition nodes — their value expressions are attributed to the
|
|
// enclosing top-level block as references.
|
|
func (e *HCLExtractor) walkTopLevel(node *sitter.Node, src []byte, filePath, dir, fileID string, result *parser.ExtractionResult, seen, refSeen map[string]bool) {
|
|
if node == nil {
|
|
return
|
|
}
|
|
for i, _nc := 0, int(node.ChildCount()); i < _nc; i++ {
|
|
child := node.Child(i)
|
|
if child == nil {
|
|
continue
|
|
}
|
|
switch child.Type() {
|
|
case "block":
|
|
e.extractBlock(child, src, filePath, dir, fileID, result, seen, refSeen)
|
|
case "config_file", "body":
|
|
e.walkTopLevel(child, src, filePath, dir, fileID, result, seen, refSeen)
|
|
}
|
|
}
|
|
}
|
|
|
|
func (e *HCLExtractor) extractBlock(node *sitter.Node, src []byte, filePath, dir, fileID string, result *parser.ExtractionResult, seen, refSeen map[string]bool) {
|
|
// A block is: identifier (block type), string_lit labels, then body.
|
|
// E.g. resource "aws_instance" "web" { ... }
|
|
var blockType string
|
|
var labels []string
|
|
var body *sitter.Node
|
|
for i, _nc := 0, int(node.ChildCount()); i < _nc; i++ {
|
|
child := node.Child(i)
|
|
if child == nil {
|
|
continue
|
|
}
|
|
switch child.Type() {
|
|
case "identifier":
|
|
if blockType == "" {
|
|
blockType = child.Content(src)
|
|
}
|
|
case "string_lit":
|
|
if text := trimQuotes(child.Content(src)); text != "" {
|
|
labels = append(labels, text)
|
|
}
|
|
case "body":
|
|
body = child
|
|
}
|
|
}
|
|
if blockType == "" {
|
|
return
|
|
}
|
|
|
|
// Name keeps the block-type prefix (resource.aws_instance.web) for
|
|
// human display; tf_address is the reference form other blocks use.
|
|
name := blockType
|
|
for _, l := range labels {
|
|
name += "." + l
|
|
}
|
|
address := hclBlockAddress(blockType, labels)
|
|
id := hclNodeID(dir, address)
|
|
startLine := int(node.StartPoint().Row) + 1
|
|
|
|
if !seen[name] {
|
|
seen[name] = true
|
|
result.Nodes = append(result.Nodes, &graph.Node{
|
|
ID: id, Kind: graph.KindType, Name: name,
|
|
FilePath: filePath, StartLine: startLine, EndLine: int(node.EndPoint().Row) + 1,
|
|
Language: "hcl",
|
|
Meta: map[string]any{
|
|
"block_type": blockType,
|
|
"type_flavor": blockType,
|
|
"labels": labels,
|
|
"tf_address": address,
|
|
},
|
|
})
|
|
result.Edges = append(result.Edges, &graph.Edge{
|
|
From: fileID, To: id, Kind: graph.EdgeDefines,
|
|
FilePath: filePath, Line: startLine,
|
|
})
|
|
}
|
|
|
|
// A locals block declares N independently-addressable values
|
|
// (local.<key>); emit one KindConstant per key and resolve each
|
|
// value's references from that key's node.
|
|
if blockType == "locals" && body != nil {
|
|
e.extractLocals(body, src, filePath, dir, fileID, result, refSeen)
|
|
return
|
|
}
|
|
|
|
// Cross-block references: var.x, local.y, module.m, data.t.n, and
|
|
// <resource_type>.<name> traversals anywhere in the block body.
|
|
if body != nil {
|
|
e.collectReferences(body, src, filePath, dir, id, result, refSeen)
|
|
}
|
|
}
|
|
|
|
// extractLocals emits a KindConstant node per declaration in a `locals`
|
|
// block (addressed local.<key>) and links each to the blocks its value
|
|
// expression references.
|
|
func (e *HCLExtractor) extractLocals(body *sitter.Node, src []byte, filePath, dir, fileID string, result *parser.ExtractionResult, refSeen map[string]bool) {
|
|
for i, _nc := 0, int(body.ChildCount()); i < _nc; i++ {
|
|
attr := body.Child(i)
|
|
if attr == nil || attr.Type() != "attribute" {
|
|
continue
|
|
}
|
|
var key string
|
|
var expr *sitter.Node
|
|
for j, _nc := 0, int(attr.ChildCount()); j < _nc; j++ {
|
|
c := attr.Child(j)
|
|
if c == nil {
|
|
continue
|
|
}
|
|
switch c.Type() {
|
|
case "identifier":
|
|
if key == "" {
|
|
key = c.Content(src)
|
|
}
|
|
case "expression":
|
|
expr = c
|
|
}
|
|
}
|
|
if key == "" {
|
|
continue
|
|
}
|
|
address := "local." + key
|
|
id := hclNodeID(dir, address)
|
|
line := int(attr.StartPoint().Row) + 1
|
|
result.Nodes = append(result.Nodes, &graph.Node{
|
|
ID: id, Kind: graph.KindConstant, Name: address,
|
|
FilePath: filePath, StartLine: line, EndLine: int(attr.EndPoint().Row) + 1,
|
|
Language: "hcl",
|
|
Meta: map[string]any{"block_type": "local", "type_flavor": "local", "tf_address": address},
|
|
})
|
|
result.Edges = append(result.Edges, &graph.Edge{
|
|
From: fileID, To: id, Kind: graph.EdgeDefines,
|
|
FilePath: filePath, Line: line,
|
|
})
|
|
if expr != nil {
|
|
e.collectReferences(expr, src, filePath, dir, id, result, refSeen)
|
|
}
|
|
}
|
|
}
|
|
|
|
// collectReferences walks an expression subtree and emits an
|
|
// EdgeReferences from fromID to every block/variable/local/module/data
|
|
// address it traverses. A traversal is a variable_expr (the head
|
|
// identifier) immediately followed by get_attr children within the same
|
|
// parent — var.region, aws_instance.web.id, data.aws_ami.ubuntu.id,
|
|
// module.vpc.subnet_ids. The recursion reaches traversals nested inside
|
|
// templates ("web-${var.region}"), objects, function-call args, and
|
|
// for-expressions.
|
|
func (e *HCLExtractor) collectReferences(node *sitter.Node, src []byte, filePath, dir, fromID string, result *parser.ExtractionResult, refSeen map[string]bool) {
|
|
if node == nil {
|
|
return
|
|
}
|
|
cc := int(node.ChildCount())
|
|
for i := 0; i < cc; i++ {
|
|
child := node.Child(i)
|
|
if child == nil || child.Type() != "variable_expr" {
|
|
continue
|
|
}
|
|
head := hclIdentText(child, src)
|
|
if head == "" {
|
|
continue
|
|
}
|
|
var attrs []string
|
|
for j := i + 1; j < cc; j++ {
|
|
sib := node.Child(j)
|
|
if sib == nil || sib.Type() != "get_attr" {
|
|
break // stop the chain at the first index ([0]) or operator
|
|
}
|
|
if a := hclGetAttrName(sib, src); a != "" {
|
|
attrs = append(attrs, a)
|
|
}
|
|
}
|
|
addr := hclRefAddress(head, attrs)
|
|
if addr == "" {
|
|
continue
|
|
}
|
|
to := hclNodeID(dir, addr)
|
|
if to == fromID {
|
|
continue
|
|
}
|
|
key := fromID + "\x00" + to
|
|
if refSeen[key] {
|
|
continue
|
|
}
|
|
refSeen[key] = true
|
|
result.Edges = append(result.Edges, &graph.Edge{
|
|
From: fromID, To: to, Kind: graph.EdgeReferences,
|
|
FilePath: filePath, Line: int(child.StartPoint().Row) + 1,
|
|
Origin: graph.OriginASTResolved,
|
|
})
|
|
}
|
|
for i := 0; i < cc; i++ {
|
|
e.collectReferences(node.Child(i), src, filePath, dir, fromID, result, refSeen)
|
|
}
|
|
}
|
|
|
|
// hclBlockAddress returns the Terraform reference address for a block —
|
|
// the form other blocks use to refer to it: resource → <type>.<name>
|
|
// (no leading "resource."), data → data.<type>.<name>, variable →
|
|
// var.<name>, module/output/provider → <type>.<name>; everything else
|
|
// (locals, terraform, moved, import, check, …) is addressed by its type
|
|
// plus any labels.
|
|
func hclBlockAddress(blockType string, labels []string) string {
|
|
switch blockType {
|
|
case "resource":
|
|
if len(labels) >= 2 {
|
|
return labels[0] + "." + labels[1]
|
|
}
|
|
case "data":
|
|
if len(labels) >= 2 {
|
|
return "data." + labels[0] + "." + labels[1]
|
|
}
|
|
case "variable":
|
|
if len(labels) >= 1 {
|
|
return "var." + labels[0]
|
|
}
|
|
case "module", "output", "provider":
|
|
if len(labels) >= 1 {
|
|
return blockType + "." + labels[0]
|
|
}
|
|
}
|
|
addr := blockType
|
|
for _, l := range labels {
|
|
addr += "." + l
|
|
}
|
|
return addr
|
|
}
|
|
|
|
// hclRefAddress maps a parsed traversal (head identifier + get_attr chain)
|
|
// to the Terraform address of the block it refers to, or "" when the head
|
|
// is a built-in scope (each/count/self/path/terraform) or the traversal
|
|
// is too short to name a block.
|
|
func hclRefAddress(head string, attrs []string) string {
|
|
switch head {
|
|
case "each", "count", "self", "path", "terraform":
|
|
return ""
|
|
case "var":
|
|
if len(attrs) >= 1 {
|
|
return "var." + attrs[0]
|
|
}
|
|
case "local":
|
|
if len(attrs) >= 1 {
|
|
return "local." + attrs[0]
|
|
}
|
|
case "module":
|
|
if len(attrs) >= 1 {
|
|
return "module." + attrs[0]
|
|
}
|
|
case "data":
|
|
if len(attrs) >= 2 {
|
|
return "data." + attrs[0] + "." + attrs[1]
|
|
}
|
|
default:
|
|
if len(attrs) >= 1 {
|
|
// Resource reference: <type>.<name>[.attr…] → <type>.<name>.
|
|
return head + "." + attrs[0]
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// hclIdentText returns the identifier text of a variable_expr node.
|
|
func hclIdentText(varExpr *sitter.Node, src []byte) string {
|
|
for i, _nc := 0, int(varExpr.ChildCount()); i < _nc; i++ {
|
|
c := varExpr.Child(i)
|
|
if c != nil && c.Type() == "identifier" {
|
|
return c.Content(src)
|
|
}
|
|
}
|
|
return strings.TrimSpace(varExpr.Content(src))
|
|
}
|
|
|
|
// hclGetAttrName returns the attribute name of a get_attr node (".id" → "id").
|
|
func hclGetAttrName(getAttr *sitter.Node, src []byte) string {
|
|
for i, _nc := 0, int(getAttr.ChildCount()); i < _nc; i++ {
|
|
c := getAttr.Child(i)
|
|
if c != nil && c.Type() == "identifier" {
|
|
return c.Content(src)
|
|
}
|
|
}
|
|
return strings.TrimPrefix(strings.TrimSpace(getAttr.Content(src)), ".")
|
|
}
|
|
|
|
func trimQuotes(s string) string {
|
|
if len(s) >= 2 && (s[0] == '"' || s[0] == '\'') {
|
|
return s[1 : len(s)-1]
|
|
}
|
|
return s
|
|
}
|