Skip to content

Commit 85cf702

Browse files
feat(graph): code change impact graph with blast-radius queries (#65)
Implements issue #30. Builds an in-memory directed dependency graph and answers blast-radius queries: given a set of changed files, which other files are transitively affected? - Graph: nodes (files/packages) + directed edges with weights - BlastRadius: BFS over reverse edges, returns affected nodes ranked by impact score (1.0 at depth 1, halved per level) - BuildFromGoFiles: walks a directory tree and extracts Go import edges using line-by-line parsing (no cgo/go/packages dependency) - Stats: node/edge counts, max in/out degree, top-5 hub nodes Co-authored-by: Ona <no-reply@ona.com>
1 parent c4455b8 commit 85cf702

3 files changed

Lines changed: 595 additions & 0 deletions

File tree

pkg/graph/builder.go

Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
package graph
2+
3+
import (
4+
"bufio"
5+
"os"
6+
"path/filepath"
7+
"strings"
8+
)
9+
10+
// BuildFromGoFiles walks a directory tree and builds a dependency graph from
11+
// Go import statements. It does not require go/packages or cgo — it uses
12+
// simple line-by-line parsing of import blocks.
13+
func BuildFromGoFiles(root string) (*Graph, error) {
14+
g := New()
15+
16+
err := filepath.WalkDir(root, func(path string, d os.DirEntry, err error) error {
17+
if err != nil {
18+
return nil // skip unreadable entries
19+
}
20+
if d.IsDir() {
21+
// Skip hidden dirs and vendor.
22+
name := d.Name()
23+
if strings.HasPrefix(name, ".") || name == "vendor" || name == "testdata" {
24+
return filepath.SkipDir
25+
}
26+
return nil
27+
}
28+
if !strings.HasSuffix(path, ".go") {
29+
return nil
30+
}
31+
32+
rel, err := filepath.Rel(root, path)
33+
if err != nil {
34+
return nil
35+
}
36+
rel = filepath.ToSlash(rel)
37+
38+
pkg := filepath.ToSlash(filepath.Dir(rel))
39+
if pkg == "." {
40+
pkg = ""
41+
}
42+
43+
lang := "go"
44+
tags := []string{}
45+
if strings.HasSuffix(path, "_test.go") {
46+
tags = append(tags, "test")
47+
}
48+
49+
g.AddNode(Node{
50+
ID: rel,
51+
Type: NodeTypeFile,
52+
Package: pkg,
53+
Language: lang,
54+
Tags: tags,
55+
})
56+
57+
imports, err := parseGoImports(path)
58+
if err != nil {
59+
return nil
60+
}
61+
62+
for _, imp := range imports {
63+
// Only track intra-repo imports (those that start with the module path
64+
// or are relative). We store the import path as the target node ID.
65+
// Callers can resolve these to file paths if needed.
66+
g.AddEdge(Edge{
67+
From: rel,
68+
To: imp,
69+
Weight: 1.0,
70+
})
71+
}
72+
return nil
73+
})
74+
return g, err
75+
}
76+
77+
// parseGoImports extracts import paths from a Go source file.
78+
func parseGoImports(path string) ([]string, error) {
79+
f, err := os.Open(path)
80+
if err != nil {
81+
return nil, err
82+
}
83+
defer f.Close()
84+
85+
var imports []string
86+
scanner := bufio.NewScanner(f)
87+
inImport := false
88+
89+
for scanner.Scan() {
90+
line := strings.TrimSpace(scanner.Text())
91+
92+
if line == "import (" {
93+
inImport = true
94+
continue
95+
}
96+
if inImport && line == ")" {
97+
inImport = false
98+
continue
99+
}
100+
if strings.HasPrefix(line, "import ") && !inImport {
101+
// Single-line import.
102+
imp := extractImportPath(line)
103+
if imp != "" {
104+
imports = append(imports, imp)
105+
}
106+
continue
107+
}
108+
if inImport && line != "" && !strings.HasPrefix(line, "//") {
109+
imp := extractImportPath(line)
110+
if imp != "" {
111+
imports = append(imports, imp)
112+
}
113+
}
114+
}
115+
return imports, scanner.Err()
116+
}
117+
118+
// extractImportPath extracts the import path string from a line like:
119+
//
120+
// "github.com/foo/bar"
121+
// alias "github.com/foo/bar"
122+
// _ "github.com/foo/bar"
123+
func extractImportPath(line string) string {
124+
// Find the quoted string.
125+
start := strings.Index(line, `"`)
126+
end := strings.LastIndex(line, `"`)
127+
if start < 0 || end <= start {
128+
return ""
129+
}
130+
return line[start+1 : end]
131+
}

0 commit comments

Comments
 (0)