diff --git a/internal/projectpath/path.go b/internal/projectpath/path.go
index abfbcca..88b2a37 100644
--- a/internal/projectpath/path.go
+++ b/internal/projectpath/path.go
@@ -192,6 +192,43 @@ func CheckedRuntimeCodemapDir(projectRoot string) (string, error) {
return filepath.Join(selection.RuntimeDir, "projects", ProjectKey(selection.ProjectRoot)), nil
}
+// CanonicalPath returns an absolute, cleaned path with platform symlinks
+// resolved; missing trailing components are retained for event paths.
+func CanonicalPath(path string) string {
+ absPath, err := filepath.Abs(path)
+ if err != nil {
+ return filepath.Clean(path)
+ }
+ if canonical, err := filepath.EvalSymlinks(absPath); err == nil {
+ return filepath.Clean(canonical)
+ } else if !os.IsNotExist(err) {
+ return filepath.Clean(absPath)
+ }
+
+ current := absPath
+ var suffix []string
+ for {
+ parent := filepath.Dir(current)
+ if parent == current {
+ break
+ }
+ suffix = append(suffix, filepath.Base(current))
+ current = parent
+ canonical, err := filepath.EvalSymlinks(current)
+ if err != nil {
+ if !os.IsNotExist(err) {
+ return filepath.Clean(absPath)
+ }
+ continue
+ }
+ for i := len(suffix) - 1; i >= 0; i-- {
+ canonical = filepath.Join(canonical, suffix[i])
+ }
+ return filepath.Clean(canonical)
+ }
+ return filepath.Clean(absPath)
+}
+
func canonicalProjectRoot(root string) (string, error) {
absRoot, err := filepath.Abs(root)
if err != nil {
diff --git a/internal/projectpath/path_test.go b/internal/projectpath/path_test.go
index ef2c601..6eb2b5f 100644
--- a/internal/projectpath/path_test.go
+++ b/internal/projectpath/path_test.go
@@ -486,6 +486,34 @@ func TestRuntimeRootAndCheckedRuntimeCodemapDir(t *testing.T) {
}
}
+func TestCanonicalPathResolvesAliasesWithMissingLeaf(t *testing.T) {
+ if runtime.GOOS == "windows" {
+ t.Skip("symlinks may require elevated privileges")
+ }
+ target := t.TempDir()
+ if err := os.WriteFile(filepath.Join(target, "existing.txt"), nil, 0o644); err != nil {
+ t.Fatal(err)
+ }
+ alias := filepath.Join(t.TempDir(), "alias")
+ if err := os.Symlink(target, alias); err != nil {
+ t.Fatal(err)
+ }
+ canonicalTarget, err := filepath.EvalSymlinks(target)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ tests := map[string]string{
+ filepath.Join(alias, "existing.txt"): filepath.Join(canonicalTarget, "existing.txt"),
+ filepath.Join(alias, "missing", "config.json"): filepath.Join(canonicalTarget, "missing", "config.json"),
+ }
+ for path, want := range tests {
+ if got := CanonicalPath(path); got != want {
+ t.Fatalf("CanonicalPath(%q) = %q, want %q", path, got, want)
+ }
+ }
+}
+
func TestProjectKeyScopesProjectsAndSharesRepoKey(t *testing.T) {
a := filepath.Join(t.TempDir(), "projA")
if err := os.MkdirAll(filepath.Join(a, ".git"), 0o755); err != nil {
diff --git a/scanner/walker.go b/scanner/walker.go
index 72ea3d0..5c4d7a8 100644
--- a/scanner/walker.go
+++ b/scanner/walker.go
@@ -8,6 +8,7 @@ import (
"strings"
"codemap/config"
+ "codemap/internal/projectpath"
ignore "github.com/sabhiram/go-gitignore"
)
@@ -25,7 +26,7 @@ type GitIgnoreCache struct {
// NewGitIgnoreCache creates a cache that supports nested .gitignore files.
// root should be the project root directory.
func NewGitIgnoreCache(root string) *GitIgnoreCache {
- absRoot, _ := filepath.Abs(root)
+ absRoot := projectpath.CanonicalPath(root)
c := &GitIgnoreCache{
root: absRoot,
cache: make(map[string]*ignore.GitIgnore),
@@ -72,7 +73,7 @@ func (c *GitIgnoreCache) EnsureDir(dir string) {
if c == nil || dir == "" {
return
}
- c.tryLoadGitignore(dir)
+ c.tryLoadGitignore(projectpath.CanonicalPath(dir))
}
// ShouldIgnore checks if a path should be ignored based on all applicable .gitignore files.
@@ -81,6 +82,7 @@ func (c *GitIgnoreCache) ShouldIgnore(absPath string) bool {
if len(c.cache) == 0 {
return false
}
+ absPath = projectpath.CanonicalPath(absPath)
// Collect directories from leaf to root
var dirs []string
@@ -233,9 +235,9 @@ func ScanFiles(ctx context.Context, root string, cache *GitIgnoreCache, only []s
return nil, err
}
var files []FileInfo
- absRoot, _ := filepath.Abs(root)
+ absRoot := projectpath.CanonicalPath(root)
- err := filepath.Walk(root, func(path string, info os.FileInfo, err error) error {
+ err := filepath.Walk(absRoot, func(path string, info os.FileInfo, err error) error {
if ctxErr := ctx.Err(); ctxErr != nil {
return ctxErr
}
diff --git a/scanner/walker_test.go b/scanner/walker_test.go
index 8c81fcd..e58f319 100644
--- a/scanner/walker_test.go
+++ b/scanner/walker_test.go
@@ -9,6 +9,8 @@ import (
"sort"
"strings"
"testing"
+
+ "codemap/internal/projectpath"
)
func TestIgnoredDirs(t *testing.T) {
@@ -739,11 +741,12 @@ func TestGitIgnoreCacheEnsureDir(t *testing.T) {
}
cache := NewGitIgnoreCache(root)
cache.EnsureDir(sub)
- if _, ok := cache.cache[sub]; !ok {
- t.Fatalf("expected gitignore cache for %q", sub)
+ canonicalSub := projectpath.CanonicalPath(sub)
+ if _, ok := cache.cache[canonicalSub]; !ok {
+ t.Fatalf("expected gitignore cache for %q", canonicalSub)
}
- if _, ok := cache.patterns[sub]; !ok {
- t.Fatalf("expected gitignore patterns for %q", sub)
+ if _, ok := cache.patterns[canonicalSub]; !ok {
+ t.Fatalf("expected gitignore patterns for %q", canonicalSub)
}
if !cache.ShouldIgnore(filepath.Join(sub, "file.tmp")) {
t.Fatal("expected nested .gitignore pattern to apply")
diff --git a/topology/gradle.go b/topology/gradle.go
new file mode 100644
index 0000000..072427f
--- /dev/null
+++ b/topology/gradle.go
@@ -0,0 +1,589 @@
+package topology
+
+import (
+ "bufio"
+ "codemap/scanner"
+ "context"
+ "fmt"
+ "os"
+ "path/filepath"
+ "regexp"
+ "sort"
+ "strings"
+ "unicode"
+)
+
+type gradleBuild struct {
+ settings string
+ root string
+ rootName string
+ projects map[string]*gradleProject
+ includeBuilds []gradleIncludeBuild
+ issues []Issue
+}
+
+type gradleProject struct {
+ path string
+ root string
+ sourceRoots []string
+ testSourceRoots []string
+}
+
+type gradleIncludeBuild struct {
+ path string
+ line int
+}
+
+var (
+ gradleRootNamePattern = regexp.MustCompile(`^\s*rootProject\.name\s*=\s*["']([^"']+)["']`)
+ gradleIncludeCallPattern = regexp.MustCompile(`^\s*include\s*\((.*)\)\s*$`)
+ gradleIncludeGroovyPattern = regexp.MustCompile(`^\s*include\s+(.+)$`)
+ gradleIncludeBuildPattern = regexp.MustCompile(`^\s*includeBuild\s*\(\s*["']([^"']+)["']\s*\)`)
+ gradleIncludeBuildGroovy = regexp.MustCompile(`^\s*includeBuild\s+["']([^"']+)["']`)
+ gradleProjectDirPattern = regexp.MustCompile(`^\s*project\(\s*["'](:[^"']*)["']\s*\)\.projectDir\s*=\s*(?:file\(\s*["']([^"']+)["']\s*\)|new File\(\s*rootDir\s*,\s*["']([^"']+)["']\s*\))`)
+ gradleProjectDependency = regexp.MustCompile(`^\s*([A-Za-z][A-Za-z0-9_]*)\s*\(\s*project\(\s*["'](:[^"']+)["']\s*\)\s*\)`)
+ gradleProjectDependencyGroovy = regexp.MustCompile(`^\s*([A-Za-z][A-Za-z0-9_]*)\s+project\(\s*["'](:[^"']+)["']\s*\)`)
+ gradleAccessorDependency = regexp.MustCompile(`^\s*([A-Za-z][A-Za-z0-9_]*)\s*\(\s*(projects(?:\.[A-Za-z_][A-Za-z0-9_]*)+)\s*\)`)
+ gradleAccessorDependencyGroovy = regexp.MustCompile(`^\s*([A-Za-z][A-Za-z0-9_]*)\s+(projects(?:\.[A-Za-z_][A-Za-z0-9_]*)+)`)
+ gradleSourceRootCall = regexp.MustCompile(`(?:java|kotlin|scala)\.srcDirs?\s*\((.*)\)`)
+ gradleSourceRootGroovy = regexp.MustCompile(`(?:java|kotlin|scala)\.srcDirs?\s*(?:=)?\s*(.+)$`)
+ gradleStringLiteralPattern = regexp.MustCompile(`["']([^"']+)["']`)
+)
+
+func buildGradleFragment(ctx context.Context, inventory Inventory, manifests []string) (Fragment, error) {
+ settingsPaths := filterManifestBasenames(manifests, "settings.gradle", "settings.gradle.kts")
+ if len(settingsPaths) == 0 {
+ return Fragment{
+ Provider: "jvm",
+ Coverage: Coverage{
+ Status: CoveragePartial,
+ Issues: []Issue{{Provider: "jvm", Code: "gradle-settings-missing", Message: "Gradle build files were found without a settings manifest"}},
+ },
+ }, nil
+ }
+
+ builds := make([]*gradleBuild, 0, len(settingsPaths))
+ for _, settings := range settingsPaths {
+ if err := ctx.Err(); err != nil {
+ return Fragment{}, err
+ }
+ build, err := parseGradleSettings(inventory.Root, settings)
+ if err != nil {
+ return Fragment{}, err
+ }
+ builds = append(builds, build)
+ }
+ sort.Slice(builds, func(i, j int) bool { return builds[i].settings < builds[j].settings })
+
+ buildByRoot := make(map[string]*gradleBuild)
+ for _, build := range builds {
+ buildByRoot[filepath.Clean(build.root)] = build
+ }
+
+ fragment := Fragment{
+ Provider: "jvm",
+ Members: make(map[ID][]string),
+ Coverage: Coverage{Status: CoverageComplete},
+ }
+ for _, build := range builds {
+ for _, projectPath := range sortedGradleProjectPaths(build.projects) {
+ project := build.projects[projectPath]
+ node := gradleNode(build, project)
+ fragment.Nodes = append(fragment.Nodes, node)
+ }
+ fragment.Coverage.Issues = append(fragment.Coverage.Issues, build.issues...)
+ }
+
+ for _, build := range builds {
+ accessors := make(map[string][]string)
+ for projectPath := range build.projects {
+ accessor := gradleAccessorForProject(projectPath)
+ accessors[accessor] = append(accessors[accessor], projectPath)
+ }
+ for _, projectPath := range sortedGradleProjectPaths(build.projects) {
+ project := build.projects[projectPath]
+ buildFile := gradleBuildFileForProject(manifests, project.root)
+ if buildFile == "" {
+ continue
+ }
+ edges, sourceRoots, testRoots, issues, err := parseGradleBuildFile(inventory.Root, build, projectPath, buildFile, accessors)
+ if err != nil {
+ return Fragment{}, err
+ }
+ project.sourceRoots = uniqueSortedStrings(append(project.sourceRoots, sourceRoots...))
+ project.testSourceRoots = uniqueSortedStrings(append(project.testSourceRoots, testRoots...))
+ fragment.Edges = append(fragment.Edges, edges...)
+ fragment.Coverage.Issues = append(fragment.Coverage.Issues, issues...)
+ }
+ }
+
+ for i, node := range fragment.Nodes {
+ build := buildForSettings(builds, node.Manifest)
+ if build == nil {
+ continue
+ }
+ project := build.projects[gradlePathFromID(node.ID)]
+ if project == nil {
+ continue
+ }
+ node.SourceRoots = uniqueSortedStrings(project.sourceRoots)
+ node.TestSourceRoots = uniqueSortedStrings(project.testSourceRoots)
+ fragment.Nodes[i] = node
+ fragment.Members[node.ID] = membersForRoots(inventory.Files, append(
+ append([]string(nil), node.SourceRoots...),
+ node.TestSourceRoots...,
+ ))
+ }
+
+ for _, build := range builds {
+ sourceID := gradleID(build.settings, ":")
+ for _, include := range build.includeBuilds {
+ targetRoot := filepath.Clean(filepath.Join(build.root, filepath.FromSlash(include.path)))
+ target := buildByRoot[targetRoot]
+ if target == nil {
+ fragment.Coverage.Issues = append(fragment.Coverage.Issues, Issue{
+ Provider: "jvm",
+ Code: "unresolved-gradle-include-build",
+ Message: fmt.Sprintf("%s:%d includeBuild %q has no local settings manifest", build.settings, include.line, include.path),
+ })
+ continue
+ }
+ fragment.Edges = append(fragment.Edges, Edge{
+ From: sourceID,
+ To: gradleID(target.settings, ":"),
+ Kind: EdgeBuildBoundary,
+ Evidence: Evidence{Manifest: build.settings, Line: include.line},
+ })
+ }
+ }
+
+ if len(fragment.Coverage.Issues) > 0 {
+ fragment.Coverage.Status = CoveragePartial
+ }
+ return fragment, nil
+}
+
+func parseGradleSettings(root, settings string) (*gradleBuild, error) {
+ data, err := os.ReadFile(filepath.Join(root, settings))
+ if err != nil {
+ return nil, err
+ }
+ buildRoot := filepath.Dir(settings)
+ if buildRoot == "." {
+ buildRoot = "."
+ }
+ build := &gradleBuild{
+ settings: settings,
+ root: buildRoot,
+ rootName: filepath.Base(filepath.Clean(buildRoot)),
+ projects: map[string]*gradleProject{
+ ":": newGradleProject(buildRoot),
+ },
+ }
+ if build.rootName == "." || build.rootName == string(filepath.Separator) {
+ build.rootName = "root"
+ }
+
+ scanner := bufio.NewScanner(strings.NewReader(string(data)))
+ lineNumber := 0
+ for scanner.Scan() {
+ lineNumber++
+ line := stripGradleLineComment(scanner.Text())
+ if match := gradleRootNamePattern.FindStringSubmatch(line); match != nil {
+ build.rootName = match[1]
+ continue
+ }
+ if match := gradleIncludeBuildPattern.FindStringSubmatch(line); match != nil {
+ build.includeBuilds = append(build.includeBuilds, gradleIncludeBuild{path: filepath.FromSlash(match[1]), line: lineNumber})
+ continue
+ }
+ if match := gradleIncludeBuildGroovy.FindStringSubmatch(line); match != nil {
+ build.includeBuilds = append(build.includeBuilds, gradleIncludeBuild{path: filepath.FromSlash(match[1]), line: lineNumber})
+ continue
+ }
+ if strings.HasPrefix(strings.TrimSpace(line), "includeBuild") {
+ build.issues = append(build.issues, Issue{
+ Provider: "jvm",
+ Code: "dynamic-gradle-include-build",
+ Message: fmt.Sprintf("%s:%d includeBuild is not a literal path", settings, lineNumber),
+ })
+ continue
+ }
+ if match := gradleIncludeCallPattern.FindStringSubmatch(line); match != nil {
+ includes := gradleStringLiterals(match[1])
+ if len(includes) == 0 {
+ build.issues = append(build.issues, Issue{Provider: "jvm", Code: "dynamic-gradle-include", Message: fmt.Sprintf("%s:%d include has no literal project paths", settings, lineNumber)})
+ continue
+ }
+ addGradleProjects(build, includes)
+ continue
+ }
+ if match := gradleIncludeGroovyPattern.FindStringSubmatch(line); match != nil {
+ includes := gradleStringLiterals(match[1])
+ if len(includes) == 0 {
+ build.issues = append(build.issues, Issue{Provider: "jvm", Code: "dynamic-gradle-include", Message: fmt.Sprintf("%s:%d include has no literal project paths", settings, lineNumber)})
+ continue
+ }
+ addGradleProjects(build, includes)
+ continue
+ }
+ if match := gradleProjectDirPattern.FindStringSubmatch(line); match != nil {
+ project := build.projects[canonicalGradlePath(match[1])]
+ if project == nil {
+ build.issues = append(build.issues, Issue{Provider: "jvm", Code: "unknown-gradle-project-dir", Message: fmt.Sprintf("%s:%d projectDir references unknown project %s", settings, lineNumber, match[1])})
+ continue
+ }
+ dir := match[2]
+ if dir == "" {
+ dir = match[3]
+ }
+ project.root = filepath.Clean(filepath.Join(build.root, filepath.FromSlash(dir)))
+ project.sourceRoots, project.testSourceRoots = conventionalGradleRoots(project.root)
+ }
+ }
+ if err := scanner.Err(); err != nil {
+ return nil, err
+ }
+ return build, nil
+}
+
+func parseGradleBuildFile(root string, build *gradleBuild, projectPath, manifest string, accessors map[string][]string) ([]Edge, []string, []string, []Issue, error) {
+ data, err := os.ReadFile(filepath.Join(root, manifest))
+ if err != nil {
+ return nil, nil, nil, nil, err
+ }
+ project := build.projects[projectPath]
+ sourceID := gradleID(build.settings, projectPath)
+ var edges []Edge
+ var sourceRoots, testRoots []string
+ var issues []Issue
+ scanner := bufio.NewScanner(strings.NewReader(string(data)))
+ lineNumber := 0
+ for scanner.Scan() {
+ lineNumber++
+ line := stripGradleLineComment(scanner.Text())
+ if match := gradleProjectDependency.FindStringSubmatch(line); match != nil {
+ targetPath := canonicalGradlePath(match[2])
+ target := build.projects[targetPath]
+ if target == nil {
+ issues = append(issues, Issue{Provider: "jvm", Code: "unknown-gradle-project", Message: fmt.Sprintf("%s:%d references unknown project %s", manifest, lineNumber, match[2])})
+ continue
+ }
+ edges = append(edges, Edge{
+ From: sourceID,
+ To: gradleID(build.settings, targetPath),
+ Kind: EdgeDependency,
+ Scope: EdgeScope(match[1]),
+ Evidence: Evidence{Manifest: manifest, Line: lineNumber},
+ })
+ continue
+ }
+ if match := gradleProjectDependencyGroovy.FindStringSubmatch(line); match != nil {
+ targetPath := canonicalGradlePath(match[2])
+ target := build.projects[targetPath]
+ if target == nil {
+ issues = append(issues, Issue{Provider: "jvm", Code: "unknown-gradle-project", Message: fmt.Sprintf("%s:%d references unknown project %s", manifest, lineNumber, match[2])})
+ continue
+ }
+ edges = append(edges, Edge{
+ From: sourceID,
+ To: gradleID(build.settings, targetPath),
+ Kind: EdgeDependency,
+ Scope: EdgeScope(match[1]),
+ Evidence: Evidence{Manifest: manifest, Line: lineNumber},
+ })
+ continue
+ }
+ if match := gradleAccessorDependency.FindStringSubmatch(line); match != nil {
+ targetPaths := uniqueSortedStrings(accessors[match[2]])
+ if len(targetPaths) == 0 {
+ issues = append(issues, Issue{Provider: "jvm", Code: "unknown-gradle-accessor", Message: fmt.Sprintf("%s:%d references unknown accessor %s", manifest, lineNumber, match[2])})
+ continue
+ }
+ if len(targetPaths) > 1 {
+ candidates := make([]ID, 0, len(targetPaths))
+ for _, targetPath := range targetPaths {
+ candidates = append(candidates, gradleID(build.settings, targetPath))
+ }
+ issues = append(issues, Issue{
+ Provider: "jvm",
+ Code: "ambiguous-gradle-accessor",
+ Message: fmt.Sprintf("%s:%d accessor %s maps to multiple projects", manifest, lineNumber, match[2]),
+ Candidates: candidates,
+ })
+ continue
+ }
+ targetPath := targetPaths[0]
+ edges = append(edges, Edge{
+ From: sourceID,
+ To: gradleID(build.settings, targetPath),
+ Kind: EdgeDependency,
+ Scope: EdgeScope(match[1]),
+ Evidence: Evidence{Manifest: manifest, Line: lineNumber},
+ })
+ continue
+ }
+ if match := gradleAccessorDependencyGroovy.FindStringSubmatch(line); match != nil {
+ targetPaths := uniqueSortedStrings(accessors[match[2]])
+ if len(targetPaths) == 0 {
+ issues = append(issues, Issue{Provider: "jvm", Code: "unknown-gradle-accessor", Message: fmt.Sprintf("%s:%d references unknown accessor %s", manifest, lineNumber, match[2])})
+ continue
+ }
+ if len(targetPaths) > 1 {
+ candidates := make([]ID, 0, len(targetPaths))
+ for _, targetPath := range targetPaths {
+ candidates = append(candidates, gradleID(build.settings, targetPath))
+ }
+ issues = append(issues, Issue{
+ Provider: "jvm",
+ Code: "ambiguous-gradle-accessor",
+ Message: fmt.Sprintf("%s:%d accessor %s maps to multiple projects", manifest, lineNumber, match[2]),
+ Candidates: candidates,
+ })
+ continue
+ }
+ targetPath := targetPaths[0]
+ edges = append(edges, Edge{
+ From: sourceID,
+ To: gradleID(build.settings, targetPath),
+ Kind: EdgeDependency,
+ Scope: EdgeScope(match[1]),
+ Evidence: Evidence{Manifest: manifest, Line: lineNumber},
+ })
+ continue
+ }
+ if strings.Contains(line, "project(") || strings.Contains(line, "projects.") {
+ issues = append(issues, Issue{Provider: "jvm", Code: "dynamic-gradle-dependency", Message: fmt.Sprintf("%s:%d project dependency is not a supported literal form", manifest, lineNumber)})
+ }
+ for _, literal := range gradleSourceRootLiterals(line) {
+ path := filepath.Clean(filepath.Join(project.root, filepath.FromSlash(literal)))
+ if strings.Contains(strings.ToLower(line), "test") {
+ testRoots = append(testRoots, path)
+ } else {
+ sourceRoots = append(sourceRoots, path)
+ }
+ }
+ }
+ if err := scanner.Err(); err != nil {
+ return nil, nil, nil, nil, err
+ }
+ return edges, sourceRoots, testRoots, issues, nil
+}
+
+func gradleSourceRootLiterals(line string) []string {
+ if match := gradleSourceRootCall.FindStringSubmatch(line); match != nil {
+ return gradleStringLiterals(match[1])
+ }
+ if match := gradleSourceRootGroovy.FindStringSubmatch(line); match != nil {
+ return gradleStringLiterals(match[1])
+ }
+ return nil
+}
+
+func newGradleProject(root string) *gradleProject {
+ source, test := conventionalGradleRoots(root)
+ return &gradleProject{root: root, sourceRoots: source, testSourceRoots: test}
+}
+
+func addGradleProjects(build *gradleBuild, includes []string) {
+ for _, include := range includes {
+ projectPath := canonicalGradlePath(include)
+ if projectPath == ":" {
+ continue
+ }
+ relative := strings.TrimPrefix(projectPath, ":")
+ projectRoot := filepath.Join(build.root, filepath.FromSlash(strings.ReplaceAll(relative, ":", "/")))
+ project := newGradleProject(filepath.Clean(projectRoot))
+ project.path = projectPath
+ build.projects[projectPath] = project
+ }
+}
+
+func conventionalGradleRoots(root string) ([]string, []string) {
+ var sourceRoots, testRoots []string
+ for _, language := range []string{"java", "kotlin", "scala"} {
+ sourceRoots = append(sourceRoots, filepath.Join(root, "src", "main", language))
+ testRoots = append(testRoots, filepath.Join(root, "src", "test", language))
+ }
+ return sourceRoots, testRoots
+}
+
+func gradleNode(build *gradleBuild, project *gradleProject) Node {
+ projectPath := project.path
+ if projectPath == "" {
+ projectPath = ":"
+ }
+ name := build.rootName
+ if projectPath != ":" {
+ parts := strings.Split(strings.TrimPrefix(projectPath, ":"), ":")
+ name = parts[len(parts)-1]
+ }
+ return Node{
+ ID: gradleID(build.settings, projectPath),
+ Kind: NodeKind("gradle-project"),
+ Name: name,
+ Manifest: build.settings,
+ Root: filepath.Clean(project.root),
+ SourceRoots: uniqueSortedStrings(project.sourceRoots),
+ TestSourceRoots: uniqueSortedStrings(project.testSourceRoots),
+ Provider: "jvm",
+ }
+}
+
+func gradleID(settings, projectPath string) ID {
+ return ID("gradle:" + filepath.ToSlash(filepath.Clean(settings)) + ":" + canonicalGradlePath(projectPath))
+}
+
+func gradlePathFromID(id ID) string {
+ text := string(id)
+ last := strings.LastIndex(text, "::")
+ if last >= 0 {
+ return text[last+1:]
+ }
+ parts := strings.Split(text, ":")
+ if len(parts) < 3 {
+ return ":"
+ }
+ return ":" + strings.Join(parts[3:], ":")
+}
+
+func gradleAccessorForProject(projectPath string) string {
+ segments := strings.Split(strings.TrimPrefix(canonicalGradlePath(projectPath), ":"), ":")
+ if len(segments) == 1 && segments[0] == "" {
+ return "projects"
+ }
+ for i, segment := range segments {
+ segments[i] = lowerCamelGradleSegment(segment)
+ }
+ return "projects." + strings.Join(segments, ".")
+}
+
+func lowerCamelGradleSegment(segment string) string {
+ var builder strings.Builder
+ upperNext := false
+ for _, char := range segment {
+ if char == '_' || char == '-' {
+ upperNext = true
+ continue
+ }
+ if upperNext {
+ builder.WriteRune(unicode.ToUpper(char))
+ upperNext = false
+ } else {
+ builder.WriteRune(char)
+ }
+ }
+ return builder.String()
+}
+
+func canonicalGradlePath(path string) string {
+ path = strings.TrimSpace(path)
+ if path == "" || path == ":" {
+ return ":"
+ }
+ if !strings.HasPrefix(path, ":") {
+ path = ":" + path
+ }
+ return strings.TrimRight(path, ":")
+}
+
+func gradleStringLiterals(text string) []string {
+ matches := gradleStringLiteralPattern.FindAllStringSubmatch(text, -1)
+ result := make([]string, 0, len(matches))
+ for _, match := range matches {
+ result = append(result, match[1])
+ }
+ return result
+}
+
+func stripGradleLineComment(line string) string {
+ inString, escaped := byte(0), false
+ for index := 0; index < len(line); index++ {
+ char := line[index]
+ if inString != 0 {
+ if escaped {
+ escaped = false
+ } else if char == '\\' {
+ escaped = true
+ } else if char == inString {
+ inString = 0
+ }
+ continue
+ }
+ if char == '\'' || char == '"' {
+ inString = char
+ continue
+ }
+ if char == '/' && index+1 < len(line) && line[index+1] == '/' {
+ return line[:index]
+ }
+ }
+ return line
+}
+
+func sortedGradleProjectPaths(projects map[string]*gradleProject) []string {
+ paths := make([]string, 0, len(projects))
+ for path := range projects {
+ paths = append(paths, path)
+ }
+ sort.Strings(paths)
+ return paths
+}
+
+func filterManifestBasenames(manifests []string, names ...string) []string {
+ allowed := make(map[string]bool, len(names))
+ for _, name := range names {
+ allowed[name] = true
+ }
+ var result []string
+ for _, manifest := range manifests {
+ if allowed[filepath.Base(manifest)] {
+ result = append(result, filepath.Clean(manifest))
+ }
+ }
+ sort.Strings(result)
+ return result
+}
+
+func gradleBuildFileForProject(manifests []string, projectRoot string) string {
+ for _, name := range []string{"build.gradle.kts", "build.gradle"} {
+ candidate := filepath.Clean(filepath.Join(projectRoot, name))
+ for _, manifest := range manifests {
+ if filepath.Clean(manifest) == candidate {
+ return candidate
+ }
+ }
+ }
+ return ""
+}
+
+func buildForSettings(builds []*gradleBuild, settings string) *gradleBuild {
+ for _, build := range builds {
+ if build.settings == settings {
+ return build
+ }
+ }
+ return nil
+}
+
+func membersForRoots(files []scanner.FileInfo, roots []string) []string {
+ var members []string
+ for _, file := range files {
+ for _, root := range roots {
+ if pathWithin(file.Path, root) {
+ members = append(members, filepath.Clean(file.Path))
+ break
+ }
+ }
+ }
+ return uniqueSortedStrings(members)
+}
+
+func pathWithin(path, root string) bool {
+ path = filepath.Clean(path)
+ root = filepath.Clean(root)
+ if path == root {
+ return true
+ }
+ return strings.HasPrefix(path, root+string(filepath.Separator))
+}
diff --git a/topology/gradle_test.go b/topology/gradle_test.go
new file mode 100644
index 0000000..b4bf0e0
--- /dev/null
+++ b/topology/gradle_test.go
@@ -0,0 +1,236 @@
+package topology
+
+import (
+ "context"
+ "path/filepath"
+ "reflect"
+ "testing"
+
+ "codemap/config"
+ "codemap/scanner"
+)
+
+func TestGradleBuildsProjectsDependenciesAndMembership(t *testing.T) {
+ root := t.TempDir()
+ writeTopologyFixture(t, root, "settings.gradle.kts", `
+rootProject.name = "Demo"
+include(":app", ":business:app_lock", ":core")
+project(":core").projectDir = file("modules/core")
+includeBuild("build-logic")
+`)
+ writeTopologyFixture(t, root, "app/build.gradle.kts", `
+dependencies {
+ implementation(project(":core"))
+ testImplementation(projects.business.appLock)
+}
+
+`)
+ writeTopologyFixture(t, root, "business/app_lock/build.gradle.kts", "")
+ writeTopologyFixture(t, root, "modules/core/build.gradle.kts", `
+sourceSets {
+ main {
+ java.srcDir("src/generated/java")
+ }
+}
+`)
+ writeTopologyFixture(t, root, "build-logic/settings.gradle.kts", `rootProject.name = "build-logic"`)
+ files := []scanner.FileInfo{
+ {Path: filepath.FromSlash("app/src/main/kotlin/App.kt"), Ext: ".kt"},
+ {Path: filepath.FromSlash("app/src/test/kotlin/AppTest.kt"), Ext: ".kt"},
+ {Path: filepath.FromSlash("business/app_lock/src/main/kotlin/Lock.kt"), Ext: ".kt"},
+ {Path: filepath.FromSlash("modules/core/src/main/java/Core.java"), Ext: ".java"},
+ {Path: filepath.FromSlash("modules/core/src/generated/java/Generated.java"), Ext: ".java"},
+ {Path: filepath.FromSlash("build-logic/src/main/kotlin/Plugin.kt"), Ext: ".kt"},
+ }
+ for _, file := range files {
+ writeTopologyFixture(t, root, filepath.ToSlash(file.Path), "// source\n")
+ }
+
+ fragment, err := (jvmProvider{}).Build(context.Background(), Inventory{
+ Root: root,
+ Files: files,
+ Manifests: []string{
+ "settings.gradle.kts",
+ filepath.FromSlash("app/build.gradle.kts"),
+ filepath.FromSlash("business/app_lock/build.gradle.kts"),
+ filepath.FromSlash("modules/core/build.gradle.kts"),
+ filepath.FromSlash("build-logic/settings.gradle.kts"),
+ },
+ Config: config.ProjectConfig{},
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ graph := MergeFragments(root, []Fragment{fragment})
+ if graph.Coverage.Status != CoverageComplete {
+ t.Fatalf("coverage = %q: %#v", graph.Coverage.Status, graph.Coverage.Issues)
+ }
+
+ rootID := gradleID("settings.gradle.kts", ":")
+ appID := gradleID("settings.gradle.kts", ":app")
+ lockID := gradleID("settings.gradle.kts", ":business:app_lock")
+ coreID := gradleID("settings.gradle.kts", ":core")
+ buildLogicID := gradleID(filepath.FromSlash("build-logic/settings.gradle.kts"), ":")
+ for _, id := range []ID{rootID, appID, lockID, coreID, buildLogicID} {
+ if _, ok := graph.Nodes[id]; !ok {
+ t.Fatalf("missing Gradle node %q: %#v", id, graph.Nodes)
+ }
+ }
+ if got := graph.Nodes[coreID].Root; got != filepath.FromSlash("modules/core") {
+ t.Fatalf("core root = %q", got)
+ }
+ if got := graph.Members[coreID]; !reflect.DeepEqual(got, []string{
+ filepath.FromSlash("modules/core/src/generated/java/Generated.java"),
+ filepath.FromSlash("modules/core/src/main/java/Core.java"),
+ }) {
+ t.Fatalf("core members = %#v", got)
+ }
+ assertTopologyEdge(t, graph.Dependencies[appID], coreID, EdgeDependency, EdgeScope("implementation"))
+ assertTopologyEdge(t, graph.Dependencies[appID], lockID, EdgeDependency, EdgeScope("testImplementation"))
+ assertTopologyEdge(t, graph.Dependencies[rootID], buildLogicID, EdgeBuildBoundary, EdgeScope(""))
+}
+
+func TestGradleReportsDynamicAndEscapingDeclarations(t *testing.T) {
+ root := t.TempDir()
+ writeTopologyFixture(t, root, "settings.gradle.kts", `
+val projectName = ":dynamic"
+include(projectName)
+include(":safe")
+project(":safe").projectDir = file("../outside")
+includeBuild(compositePath)
+`)
+ fragment, err := (jvmProvider{}).Build(context.Background(), Inventory{
+ Root: root,
+ Manifests: []string{"settings.gradle.kts"},
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ graph := MergeFragments(root, []Fragment{fragment})
+ if graph.Coverage.Status != CoveragePartial {
+ t.Fatalf("coverage = %q, want partial", graph.Coverage.Status)
+ }
+ for _, code := range []string{"dynamic-gradle-include", "dynamic-gradle-include-build", "invalid-node-path"} {
+ if !hasIssueCode(graph.Coverage.Issues, code) {
+ t.Fatalf("issues = %#v, want %s", graph.Coverage.Issues, code)
+ }
+ }
+ if _, ok := graph.Nodes[gradleID("settings.gradle.kts", ":safe")]; ok {
+ t.Fatal("escaping projectDir node was retained")
+ }
+}
+
+func TestGradleBuildsLegacyGroovyDeclarations(t *testing.T) {
+ root := t.TempDir()
+ writeTopologyFixture(t, root, "settings.gradle", `
+rootProject.name = 'Demo'
+include ':app', ':core'
+includeBuild 'build-logic'
+`)
+ writeTopologyFixture(t, root, "app/build.gradle", `
+dependencies {
+ implementation project(':core')
+}
+sourceSets.main.java.srcDirs = ['src/generated/java']
+sourceSets.test.java.srcDir 'src/integration/java'
+`)
+ writeTopologyFixture(t, root, "core/build.gradle", "")
+ writeTopologyFixture(t, root, "build-logic/settings.gradle", "rootProject.name = 'build-logic'")
+ files := []scanner.FileInfo{
+ {Path: filepath.FromSlash("app/src/generated/java/Generated.java"), Ext: ".java"},
+ {Path: filepath.FromSlash("app/src/integration/java/AppTest.java"), Ext: ".java"},
+ {Path: filepath.FromSlash("core/src/main/kotlin/Core.kt"), Ext: ".kt"},
+ }
+ fragment, err := (jvmProvider{}).Build(context.Background(), Inventory{
+ Root: root,
+ Files: files,
+ Manifests: []string{
+ "settings.gradle",
+ filepath.FromSlash("app/build.gradle"),
+ filepath.FromSlash("core/build.gradle"),
+ filepath.FromSlash("build-logic/settings.gradle"),
+ },
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ graph := MergeFragments(root, []Fragment{fragment})
+ appID := gradleID("settings.gradle", ":app")
+ coreID := gradleID("settings.gradle", ":core")
+ assertTopologyEdge(t, graph.Dependencies[appID], coreID, EdgeDependency, EdgeScope("implementation"))
+ assertTopologyEdge(t, graph.Dependencies[gradleID("settings.gradle", ":")], gradleID(filepath.FromSlash("build-logic/settings.gradle"), ":"), EdgeBuildBoundary, EdgeScope(""))
+ if got := graph.Members[appID]; !reflect.DeepEqual(got, []string{
+ filepath.FromSlash("app/src/generated/java/Generated.java"),
+ filepath.FromSlash("app/src/integration/java/AppTest.java"),
+ }) {
+ t.Fatalf("app members = %#v", got)
+ }
+}
+
+func TestGradleAccessorMappingUsesIncludedProjectNames(t *testing.T) {
+ got := gradleAccessorForProject(":business:app_lock")
+ if got != "projects.business.appLock" {
+ t.Fatalf("accessor = %q", got)
+ }
+}
+
+func TestGradleCommentStripperPreservesURLLikeStringLiterals(t *testing.T) {
+ line := `rootProject.name = "demo//test" // trailing comment`
+ if got := stripGradleLineComment(line); got != `rootProject.name = "demo//test" ` {
+ t.Fatalf("stripped line = %q", got)
+ }
+}
+
+func TestGradleFailsClosedForAmbiguousAccessors(t *testing.T) {
+ root := t.TempDir()
+ writeTopologyFixture(t, root, "settings.gradle.kts", `include(":app", ":foo_bar", ":foo-bar")`)
+ writeTopologyFixture(t, root, "app/build.gradle.kts", `implementation(projects.fooBar)`)
+
+ fragment, err := (jvmProvider{}).Build(context.Background(), Inventory{
+ Root: root,
+ Manifests: []string{
+ "settings.gradle.kts",
+ filepath.FromSlash("app/build.gradle.kts"),
+ },
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ graph := MergeFragments(root, []Fragment{fragment})
+ if !hasIssueCode(graph.Coverage.Issues, "ambiguous-gradle-accessor") {
+ t.Fatalf("issues = %#v", graph.Coverage.Issues)
+ }
+ appID := gradleID("settings.gradle.kts", ":app")
+ for _, edge := range graph.Dependencies[appID] {
+ if edge.Kind == EdgeDependency {
+ t.Fatalf("ambiguous Gradle accessor emitted edge: %#v", edge)
+ }
+ }
+}
+
+func assertTopologyEdge(t *testing.T, edges []Edge, target ID, kind EdgeKind, scope EdgeScope) {
+ t.Helper()
+ for _, edge := range edges {
+ if edge.To == target && edge.Kind == kind && edge.Scope == scope {
+ return
+ }
+ }
+ t.Fatalf("edge to %q kind=%q scope=%q missing from %#v", target, kind, scope, edges)
+}
+
+func TestJVMProviderMetadata(t *testing.T) {
+ provider := jvmProvider{}
+ if provider.Name() != "jvm" || provider.Version() == "" {
+ t.Fatalf("provider metadata = %q %q", provider.Name(), provider.Version())
+ }
+ if got := provider.Manifests().Names; !reflect.DeepEqual(got, []string{
+ "build.gradle",
+ "build.gradle.kts",
+ "build.sbt",
+ "pom.xml",
+ "settings.gradle",
+ "settings.gradle.kts",
+ }) {
+ t.Fatalf("manifest names = %#v", got)
+ }
+}
diff --git a/topology/jvm_provider.go b/topology/jvm_provider.go
new file mode 100644
index 0000000..1132463
--- /dev/null
+++ b/topology/jvm_provider.go
@@ -0,0 +1,102 @@
+package topology
+
+import (
+ "context"
+ "path/filepath"
+ "sort"
+)
+
+type jvmProvider struct{}
+
+func init() {
+ RegisterProvider(jvmProvider{})
+}
+
+func (jvmProvider) Name() string { return "jvm" }
+func (jvmProvider) Version() string { return "1" }
+func (jvmProvider) Languages() []string {
+ return []string{"java", "kotlin", "kt", "scala"}
+}
+func (jvmProvider) Manifests() ManifestSelector {
+ return ManifestSelector{Names: []string{
+ "build.gradle",
+ "build.gradle.kts",
+ "build.sbt",
+ "pom.xml",
+ "settings.gradle",
+ "settings.gradle.kts",
+ }}
+}
+
+func (jvmProvider) Build(ctx context.Context, inventory Inventory) (Fragment, error) {
+ if err := ctx.Err(); err != nil {
+ return Fragment{}, err
+ }
+ var gradleManifests, mavenManifests, sbtManifests []string
+ for _, manifest := range inventory.Manifests {
+ switch filepath.Base(manifest) {
+ case "settings.gradle", "settings.gradle.kts", "build.gradle", "build.gradle.kts":
+ gradleManifests = append(gradleManifests, manifest)
+ case "pom.xml":
+ mavenManifests = append(mavenManifests, manifest)
+ case "build.sbt":
+ sbtManifests = append(sbtManifests, manifest)
+ }
+ }
+ sort.Strings(gradleManifests)
+ sort.Strings(mavenManifests)
+ sort.Strings(sbtManifests)
+ if len(gradleManifests) == 0 && len(mavenManifests) == 0 && len(sbtManifests) == 0 {
+ return Fragment{
+ Provider: "jvm",
+ Coverage: Coverage{Status: CoverageUnavailable},
+ }, nil
+ }
+
+ fragments := make([]Fragment, 0, 3)
+ if len(gradleManifests) > 0 {
+ fragment, err := buildGradleFragment(ctx, inventory, gradleManifests)
+ if err != nil {
+ return Fragment{}, err
+ }
+ fragments = append(fragments, fragment)
+ }
+ if len(mavenManifests) > 0 {
+ fragment, err := buildMavenFragment(ctx, inventory, mavenManifests)
+ if err != nil {
+ return Fragment{}, err
+ }
+ fragments = append(fragments, fragment)
+ }
+ if len(sbtManifests) > 0 {
+ fragment, err := buildSBTFragment(ctx, inventory, sbtManifests)
+ if err != nil {
+ return Fragment{}, err
+ }
+ fragments = append(fragments, fragment)
+ }
+ return combineProviderFragments("jvm", fragments), nil
+}
+
+func combineProviderFragments(provider string, fragments []Fragment) Fragment {
+ combined := Fragment{
+ Provider: provider,
+ Members: make(map[ID][]string),
+ Coverage: Coverage{Status: CoverageComplete},
+ }
+ for _, fragment := range fragments {
+ combined.Nodes = append(combined.Nodes, fragment.Nodes...)
+ combined.Edges = append(combined.Edges, fragment.Edges...)
+ for id, members := range fragment.Members {
+ combined.Members[id] = append(combined.Members[id], members...)
+ }
+ combined.Coverage.Issues = append(combined.Coverage.Issues, fragment.Coverage.Issues...)
+ if fragment.Coverage.Status != CoverageComplete {
+ combined.Coverage.Status = CoveragePartial
+ }
+ }
+ if len(combined.Coverage.Issues) > 0 {
+ combined.Coverage.Status = CoveragePartial
+ }
+ return combined
+}
diff --git a/topology/maven.go b/topology/maven.go
new file mode 100644
index 0000000..61a326e
--- /dev/null
+++ b/topology/maven.go
@@ -0,0 +1,453 @@
+package topology
+
+import (
+ "context"
+ "encoding/xml"
+ "fmt"
+ "os"
+ "path/filepath"
+ "sort"
+ "strings"
+)
+
+type mavenPOM struct {
+ manifest string
+ root string
+ raw mavenProjectXML
+ parent *mavenPOM
+ groupID string
+ artifactID string
+ version string
+ properties map[string]string
+ sourceRoots []string
+ testRoots []string
+ resolving bool
+ resolved bool
+ resolveIssue []Issue
+}
+
+type mavenProjectXML struct {
+ ModelVersion string `xml:"modelVersion"`
+ GroupID string `xml:"groupId"`
+ ArtifactID string `xml:"artifactId"`
+ Version string `xml:"version"`
+ Packaging string `xml:"packaging"`
+ Parent *mavenParentXML `xml:"parent"`
+ Modules []string `xml:"modules>module"`
+ Subprojects []string `xml:"subprojects>subproject"`
+ Dependencies []mavenDependencyXML `xml:"dependencies>dependency"`
+ Build mavenBuildXML `xml:"build"`
+ Profiles []mavenProfileXML `xml:"profiles>profile"`
+ Properties mavenProperties `xml:"properties"`
+}
+
+type mavenParentXML struct {
+ GroupID string `xml:"groupId"`
+ ArtifactID string `xml:"artifactId"`
+ Version string `xml:"version"`
+ RelativePath *string `xml:"relativePath"`
+}
+
+type mavenDependencyXML struct {
+ GroupID string `xml:"groupId"`
+ ArtifactID string `xml:"artifactId"`
+ Version string `xml:"version"`
+ Scope string `xml:"scope"`
+ Optional bool `xml:"optional"`
+}
+
+type mavenBuildXML struct {
+ SourceDirectory string `xml:"sourceDirectory"`
+ TestSourceDirectory string `xml:"testSourceDirectory"`
+ Sources []mavenSourceXML `xml:"sources>source"`
+}
+
+type mavenSourceXML struct {
+ Scope string `xml:"scope"`
+ Directory string `xml:"directory"`
+}
+
+type mavenProfileXML struct {
+ Modules []string `xml:"modules>module"`
+ Subprojects []string `xml:"subprojects>subproject"`
+ Dependencies []mavenDependencyXML `xml:"dependencies>dependency"`
+}
+
+type mavenProperties map[string]string
+
+func (properties *mavenProperties) UnmarshalXML(decoder *xml.Decoder, start xml.StartElement) error {
+ *properties = make(map[string]string)
+ for {
+ token, err := decoder.Token()
+ if err != nil {
+ return err
+ }
+ switch token := token.(type) {
+ case xml.StartElement:
+ var value string
+ if err := decoder.DecodeElement(&value, &token); err != nil {
+ return err
+ }
+ (*properties)[token.Name.Local] = strings.TrimSpace(value)
+ case xml.EndElement:
+ if token.Name == start.Name {
+ return nil
+ }
+ }
+ }
+}
+
+func buildMavenFragment(ctx context.Context, inventory Inventory, manifests []string) (Fragment, error) {
+ poms := make(map[string]*mavenPOM, len(manifests))
+ for _, manifest := range manifests {
+ if err := ctx.Err(); err != nil {
+ return Fragment{}, err
+ }
+ data, err := os.ReadFile(filepath.Join(inventory.Root, manifest))
+ if err != nil {
+ return Fragment{}, err
+ }
+ var project mavenProjectXML
+ if err := xml.Unmarshal(data, &project); err != nil {
+ return Fragment{}, fmt.Errorf("%s: %w", manifest, err)
+ }
+ clean := filepath.Clean(manifest)
+ poms[clean] = &mavenPOM{
+ manifest: clean,
+ root: filepath.Dir(clean),
+ raw: project,
+ }
+ }
+
+ for _, pom := range sortedMavenPOMs(poms) {
+ linkMavenParentByPath(pom, poms)
+ }
+ for _, pom := range sortedMavenPOMs(poms) {
+ resolveMavenPOM(pom)
+ }
+ for _, pom := range sortedMavenPOMs(poms) {
+ if len(mavenSubprojects(pom.raw)) == 0 && pom.raw.Packaging == "pom" && pom.raw.ModelVersion == "4.1.0" {
+ for _, child := range sortedMavenPOMs(poms) {
+ if child == pom || filepath.Dir(child.root) != pom.root {
+ continue
+ }
+ pom.raw.Subprojects = append(pom.raw.Subprojects, filepath.Base(child.root))
+ }
+ }
+ }
+
+ fragment := Fragment{
+ Provider: "jvm",
+ Members: make(map[ID][]string),
+ Coverage: Coverage{Status: CoverageComplete},
+ }
+ coordinateIndex := make(map[string][]ID)
+ for _, pom := range sortedMavenPOMs(poms) {
+ id := mavenID(pom.manifest, pom.groupID, pom.artifactID)
+ coordinateIndex[mavenCoordinate(pom.groupID, pom.artifactID)] = append(
+ coordinateIndex[mavenCoordinate(pom.groupID, pom.artifactID)],
+ id,
+ )
+ fragment.Nodes = append(fragment.Nodes, Node{
+ ID: id,
+ Kind: NodeKind("maven-project"),
+ Name: pom.artifactID,
+ Manifest: pom.manifest,
+ Root: pom.root,
+ SourceRoots: pom.sourceRoots,
+ TestSourceRoots: pom.testRoots,
+ Provider: "jvm",
+ })
+ fragment.Members[id] = membersForRoots(
+ inventory.Files,
+ append(append([]string(nil), pom.sourceRoots...), pom.testRoots...),
+ )
+ fragment.Coverage.Issues = append(fragment.Coverage.Issues, pom.resolveIssue...)
+ if mavenProfilesChangeTopology(pom.raw.Profiles) {
+ fragment.Coverage.Issues = append(fragment.Coverage.Issues, Issue{
+ Provider: "jvm",
+ Code: "maven-profile-topology",
+ Message: fmt.Sprintf("%s contains profile-controlled modules or dependencies", pom.manifest),
+ })
+ }
+ }
+ for coordinate := range coordinateIndex {
+ coordinateIndex[coordinate] = uniqueSortedIDs(coordinateIndex[coordinate])
+ }
+
+ for _, pom := range sortedMavenPOMs(poms) {
+ sourceID := mavenID(pom.manifest, pom.groupID, pom.artifactID)
+ if parentID, ok := localMavenParentID(pom, coordinateIndex); ok {
+ fragment.Edges = append(fragment.Edges, Edge{
+ From: sourceID, To: parentID, Kind: EdgeInheritance,
+ Evidence: Evidence{Manifest: pom.manifest},
+ })
+ }
+ for _, module := range mavenSubprojects(pom.raw) {
+ moduleManifest := filepath.Clean(filepath.Join(pom.root, filepath.FromSlash(strings.TrimSpace(module)), "pom.xml"))
+ child := poms[moduleManifest]
+ if child == nil {
+ fragment.Coverage.Issues = append(fragment.Coverage.Issues, Issue{
+ Provider: "jvm",
+ Code: "unresolved-maven-module",
+ Message: fmt.Sprintf("%s declares missing local module %q", pom.manifest, module),
+ })
+ continue
+ }
+ childID := mavenID(child.manifest, child.groupID, child.artifactID)
+ fragment.Edges = append(fragment.Edges, Edge{
+ From: sourceID, To: childID, Kind: EdgeBuildBoundary,
+ Evidence: Evidence{Manifest: pom.manifest},
+ })
+ }
+ for _, dependency := range pom.raw.Dependencies {
+ groupID, groupOK := resolveMavenValue(dependency.GroupID, pom.properties)
+ artifactID, artifactOK := resolveMavenValue(dependency.ArtifactID, pom.properties)
+ if !groupOK || !artifactOK {
+ fragment.Coverage.Issues = append(fragment.Coverage.Issues, Issue{
+ Provider: "jvm",
+ Code: "unresolved-maven-property",
+ Message: fmt.Sprintf("%s contains an unresolved dependency coordinate", pom.manifest),
+ })
+ continue
+ }
+ targets := coordinateIndex[mavenCoordinate(groupID, artifactID)]
+ if len(targets) == 0 {
+ continue
+ }
+ scope := strings.TrimSpace(dependency.Scope)
+ if scope == "" {
+ scope = "compile"
+ }
+ if dependency.Optional {
+ scope = "optional"
+ }
+ resolution := ReferenceResolution{Targets: targets}
+ switch len(targets) {
+ case 1:
+ resolution.Status = ResolutionResolved
+ default:
+ resolution.Status = ResolutionAmbiguous
+ resolution.Candidates = targets
+ resolution.Note = fmt.Sprintf("%s dependency %s is ambiguous", pom.manifest, mavenCoordinate(groupID, artifactID))
+ }
+ edges, issue := ExpandReference(sourceID, Edge{
+ Kind: EdgeDependency, Scope: EdgeScope(scope),
+ Evidence: Evidence{Manifest: pom.manifest},
+ }, resolution)
+ fragment.Edges = append(fragment.Edges, edges...)
+ if issue != nil {
+ issue.Provider = "jvm"
+ issue.Code = "ambiguous-maven-coordinate"
+ fragment.Coverage.Issues = append(fragment.Coverage.Issues, *issue)
+ }
+ }
+ }
+
+ if len(fragment.Coverage.Issues) > 0 {
+ fragment.Coverage.Status = CoveragePartial
+ }
+ return fragment, nil
+}
+
+func linkMavenParentByPath(pom *mavenPOM, poms map[string]*mavenPOM) {
+ if pom.raw.Parent == nil {
+ return
+ }
+ relative := "../pom.xml"
+ if pom.raw.Parent.RelativePath != nil {
+ relative = strings.TrimSpace(*pom.raw.Parent.RelativePath)
+ if relative == "" {
+ return
+ }
+ }
+ pom.parent = poms[filepath.Clean(filepath.Join(pom.root, filepath.FromSlash(relative)))]
+}
+
+func resolveMavenPOM(pom *mavenPOM) {
+ if pom.resolved {
+ return
+ }
+ if pom.resolving {
+ pom.resolveIssue = append(pom.resolveIssue, Issue{
+ Provider: "jvm", Code: "maven-parent-cycle",
+ Message: fmt.Sprintf("%s participates in a local parent cycle", pom.manifest),
+ })
+ return
+ }
+ pom.resolving = true
+ if pom.parent != nil {
+ resolveMavenPOM(pom.parent)
+ }
+ properties := make(map[string]string)
+ if pom.parent != nil {
+ for key, value := range pom.parent.properties {
+ properties[key] = value
+ }
+ }
+ for key, value := range pom.raw.Properties {
+ properties[key] = value
+ }
+
+ groupID := pom.raw.GroupID
+ version := pom.raw.Version
+ if pom.parent != nil {
+ if groupID == "" {
+ groupID = pom.parent.groupID
+ }
+ if version == "" {
+ version = pom.parent.version
+ }
+ } else if pom.raw.Parent != nil {
+ if groupID == "" {
+ groupID = pom.raw.Parent.GroupID
+ }
+ if version == "" {
+ version = pom.raw.Parent.Version
+ }
+ }
+ properties["project.groupId"] = groupID
+ properties["pom.groupId"] = groupID
+ properties["project.artifactId"] = pom.raw.ArtifactID
+ properties["pom.artifactId"] = pom.raw.ArtifactID
+ properties["project.version"] = version
+ properties["pom.version"] = version
+ properties["project.basedir"] = "."
+ properties["basedir"] = "."
+
+ pom.groupID, _ = resolveMavenValue(groupID, properties)
+ pom.artifactID, _ = resolveMavenValue(pom.raw.ArtifactID, properties)
+ pom.version, _ = resolveMavenValue(version, properties)
+ for range len(properties) {
+ changed := false
+ for key, value := range properties {
+ if resolved, ok := resolveMavenValue(value, properties); ok && resolved != value {
+ properties[key] = resolved
+ changed = true
+ }
+ }
+ if !changed {
+ break
+ }
+ }
+ pom.properties = properties
+ pom.sourceRoots, pom.testRoots = conventionalMavenRoots(pom.root)
+ if pom.raw.Build.SourceDirectory != "" {
+ if source, ok := resolveMavenValue(pom.raw.Build.SourceDirectory, properties); ok {
+ pom.sourceRoots = append(pom.sourceRoots, filepath.Clean(filepath.Join(pom.root, filepath.FromSlash(source))))
+ } else {
+ pom.resolveIssue = append(pom.resolveIssue, Issue{Provider: "jvm", Code: "unresolved-maven-property", Message: fmt.Sprintf("%s contains an unresolved source directory", pom.manifest)})
+ }
+ }
+ if pom.raw.Build.TestSourceDirectory != "" {
+ if source, ok := resolveMavenValue(pom.raw.Build.TestSourceDirectory, properties); ok {
+ pom.testRoots = append(pom.testRoots, filepath.Clean(filepath.Join(pom.root, filepath.FromSlash(source))))
+ } else {
+ pom.resolveIssue = append(pom.resolveIssue, Issue{Provider: "jvm", Code: "unresolved-maven-property", Message: fmt.Sprintf("%s contains an unresolved test source directory", pom.manifest)})
+ }
+ }
+ for _, source := range pom.raw.Build.Sources {
+ if source.Directory == "" {
+ continue
+ }
+ resolved, ok := resolveMavenValue(source.Directory, properties)
+ if !ok {
+ pom.resolveIssue = append(pom.resolveIssue, Issue{Provider: "jvm", Code: "unresolved-maven-property", Message: fmt.Sprintf("%s contains an unresolved source directory", pom.manifest)})
+ continue
+ }
+ path := filepath.Clean(filepath.Join(pom.root, filepath.FromSlash(resolved)))
+ if strings.EqualFold(strings.TrimSpace(source.Scope), "test") {
+ pom.testRoots = append(pom.testRoots, path)
+ } else {
+ pom.sourceRoots = append(pom.sourceRoots, path)
+ }
+ }
+ pom.sourceRoots = uniqueSortedStrings(pom.sourceRoots)
+ pom.testRoots = uniqueSortedStrings(pom.testRoots)
+ pom.resolving = false
+ pom.resolved = true
+}
+
+func mavenSubprojects(project mavenProjectXML) []string {
+ return uniqueSortedStrings(append(append([]string(nil), project.Modules...), project.Subprojects...))
+}
+
+func localMavenParentID(pom *mavenPOM, coordinateIndex map[string][]ID) (ID, bool) {
+ if pom.raw.Parent == nil {
+ return "", false
+ }
+ if pom.parent != nil {
+ return mavenID(pom.parent.manifest, pom.parent.groupID, pom.parent.artifactID), true
+ }
+ groupID, groupOK := resolveMavenValue(pom.raw.Parent.GroupID, pom.properties)
+ artifactID, artifactOK := resolveMavenValue(pom.raw.Parent.ArtifactID, pom.properties)
+ if !groupOK || !artifactOK {
+ return "", false
+ }
+ targets := coordinateIndex[mavenCoordinate(groupID, artifactID)]
+ if len(targets) != 1 {
+ return "", false
+ }
+ return targets[0], true
+}
+
+func mavenID(manifest, groupID, artifactID string) ID {
+ return ID("maven:" + filepath.ToSlash(filepath.Clean(manifest)) + ":" + groupID + ":" + artifactID)
+}
+
+func mavenCoordinate(groupID, artifactID string) string {
+ return strings.TrimSpace(groupID) + ":" + strings.TrimSpace(artifactID)
+}
+
+func resolveMavenValue(value string, properties map[string]string) (string, bool) {
+ value = strings.TrimSpace(value)
+ for range 8 {
+ start := strings.Index(value, "${")
+ if start < 0 {
+ return value, value != ""
+ }
+ end := strings.Index(value[start+2:], "}")
+ if end < 0 {
+ return value, false
+ }
+ end += start + 2
+ key := value[start+2 : end]
+ replacement, ok := properties[key]
+ if !ok || replacement == value[start:end+1] {
+ return value, false
+ }
+ value = value[:start] + replacement + value[end+1:]
+ }
+ return value, !strings.Contains(value, "${")
+}
+
+func conventionalMavenRoots(root string) ([]string, []string) {
+ var sourceRoots, testRoots []string
+ for _, language := range []string{"java", "kotlin", "scala"} {
+ sourceRoots = append(sourceRoots, filepath.Join(root, "src", "main", language))
+ testRoots = append(testRoots, filepath.Join(root, "src", "test", language))
+ }
+ return sourceRoots, testRoots
+}
+
+func sortedMavenPOMs(poms map[string]*mavenPOM) []*mavenPOM {
+ manifests := make([]string, 0, len(poms))
+ for manifest := range poms {
+ manifests = append(manifests, manifest)
+ }
+ sort.Strings(manifests)
+ result := make([]*mavenPOM, 0, len(manifests))
+ for _, manifest := range manifests {
+ result = append(result, poms[manifest])
+ }
+ return result
+}
+
+func mavenProfilesChangeTopology(profiles []mavenProfileXML) bool {
+ for _, profile := range profiles {
+ if len(profile.Modules) > 0 || len(profile.Subprojects) > 0 || len(profile.Dependencies) > 0 {
+ return true
+ }
+ }
+ return false
+}
diff --git a/topology/maven_test.go b/topology/maven_test.go
new file mode 100644
index 0000000..e946e0b
--- /dev/null
+++ b/topology/maven_test.go
@@ -0,0 +1,328 @@
+package topology
+
+import (
+ "context"
+ "path/filepath"
+ "slices"
+ "testing"
+
+ "codemap/scanner"
+)
+
+func TestMavenBuildsReactorParentsDependenciesAndMembership(t *testing.T) {
+ root := t.TempDir()
+ writeTopologyFixture(t, root, "pom.xml", `
+
+ 4.0.0
+ com.example
+ root
+ 1.0.0
+ pom
+ coreapp
+`)
+ writeTopologyFixture(t, root, "core/pom.xml", `
+
+
+ com.exampleroot1.0.0
+ ../pom.xml
+
+ core
+`)
+ writeTopologyFixture(t, root, "app/pom.xml", `
+
+
+ com.exampleroot1.0.0
+ ../pom.xml
+
+ app
+
+
+ com.examplecore1.0.0
+ runtime
+
+
+`)
+ files := []scanner.FileInfo{
+ {Path: filepath.FromSlash("core/src/main/java/Core.java"), Ext: ".java"},
+ {Path: filepath.FromSlash("core/src/test/kotlin/CoreTest.kt"), Ext: ".kt"},
+ {Path: filepath.FromSlash("app/src/main/scala/App.scala"), Ext: ".scala"},
+ }
+ for _, file := range files {
+ writeTopologyFixture(t, root, filepath.ToSlash(file.Path), "// source\n")
+ }
+
+ fragment, err := (jvmProvider{}).Build(context.Background(), Inventory{
+ Root: root,
+ Files: files,
+ Manifests: []string{
+ "pom.xml",
+ filepath.FromSlash("core/pom.xml"),
+ filepath.FromSlash("app/pom.xml"),
+ },
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ graph := MergeFragments(root, []Fragment{fragment})
+ if graph.Coverage.Status != CoverageComplete {
+ t.Fatalf("coverage = %q: %#v", graph.Coverage.Status, graph.Coverage.Issues)
+ }
+
+ rootID := mavenID("pom.xml", "com.example", "root")
+ coreID := mavenID(filepath.FromSlash("core/pom.xml"), "com.example", "core")
+ appID := mavenID(filepath.FromSlash("app/pom.xml"), "com.example", "app")
+ for _, id := range []ID{rootID, coreID, appID} {
+ if _, ok := graph.Nodes[id]; !ok {
+ t.Fatalf("missing Maven node %q", id)
+ }
+ }
+ assertTopologyEdge(t, graph.Dependencies[coreID], rootID, EdgeInheritance, EdgeScope(""))
+ assertTopologyEdge(t, graph.Dependencies[appID], rootID, EdgeInheritance, EdgeScope(""))
+ assertTopologyEdge(t, graph.Dependencies[appID], coreID, EdgeDependency, EdgeScope("runtime"))
+ if len(graph.Members[coreID]) != 2 || len(graph.Members[appID]) != 1 {
+ t.Fatalf("members: core=%#v app=%#v", graph.Members[coreID], graph.Members[appID])
+ }
+}
+
+func TestMaven4BuildsSubprojectsAndSources(t *testing.T) {
+ root := t.TempDir()
+ writeTopologyFixture(t, root, "pom.xml", `
+
+ 4.1.0
+ com.exampleroot1.0.0
+ pom
+ app
+`)
+ writeTopologyFixture(t, root, "app/pom.xml", `
+
+ 4.1.0
+ com.exampleroot
+ app
+
+ mainsrc/main/generated
+ testsrc/integration/kotlin
+
+`)
+ files := []scanner.FileInfo{
+ {Path: filepath.FromSlash("app/src/main/generated/Generated.java"), Ext: ".java"},
+ {Path: filepath.FromSlash("app/src/integration/kotlin/AppTest.kt"), Ext: ".kt"},
+ }
+ fragment, err := (jvmProvider{}).Build(context.Background(), Inventory{
+ Root: root, Files: files, Manifests: []string{"pom.xml", "app/pom.xml"},
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ graph := MergeFragments(root, []Fragment{fragment})
+ if graph.Coverage.Status != CoverageComplete {
+ t.Fatalf("coverage = %q: %#v", graph.Coverage.Status, graph.Coverage.Issues)
+ }
+ rootID := mavenID("pom.xml", "com.example", "root")
+ appID := mavenID(filepath.FromSlash("app/pom.xml"), "com.example", "app")
+ assertTopologyEdge(t, graph.Dependencies[rootID], appID, EdgeBuildBoundary, EdgeScope(""))
+ if !slices.Contains(graph.Nodes[appID].SourceRoots, filepath.FromSlash("app/src/main/generated")) ||
+ !slices.Contains(graph.Nodes[appID].TestSourceRoots, filepath.FromSlash("app/src/integration/kotlin")) {
+ t.Fatalf("app roots = %#v", graph.Nodes[appID])
+ }
+ if !slices.Contains(graph.Members[appID], filepath.FromSlash("app/src/main/generated/Generated.java")) ||
+ !slices.Contains(graph.Members[appID], filepath.FromSlash("app/src/integration/kotlin/AppTest.kt")) {
+ t.Fatalf("app members = %#v", graph.Members[appID])
+ }
+}
+
+func TestMaven4AutoDiscoversDirectSubprojects(t *testing.T) {
+ root := t.TempDir()
+ writeTopologyFixture(t, root, "pom.xml", `
+ 4.1.0com.example
+ rootpom
+`)
+ writeTopologyFixture(t, root, "app/pom.xml", `
+ 4.1.0
+ com.exampleroot
+ app
+`)
+ fragment, err := (jvmProvider{}).Build(context.Background(), Inventory{
+ Root: root, Manifests: []string{"pom.xml", "app/pom.xml"},
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ graph := MergeFragments(root, []Fragment{fragment})
+ assertTopologyEdge(t, graph.Dependencies[mavenID("pom.xml", "com.example", "root")],
+ mavenID(filepath.FromSlash("app/pom.xml"), "com.example", "app"), EdgeBuildBoundary, EdgeScope(""))
+}
+
+func TestMaven3DoesNotAutoDiscoverDirectSubprojects(t *testing.T) {
+ root := t.TempDir()
+ writeTopologyFixture(t, root, "pom.xml", `
+ 4.0.0com.example
+ rootpom
+`)
+ writeTopologyFixture(t, root, "app/pom.xml", `
+ 4.0.0
+ com.exampleroot
+ app
+`)
+ fragment, err := (jvmProvider{}).Build(context.Background(), Inventory{
+ Root: root, Manifests: []string{"pom.xml", "app/pom.xml"},
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ graph := MergeFragments(root, []Fragment{fragment})
+ for _, edge := range graph.Dependencies[mavenID("pom.xml", "com.example", "root")] {
+ if edge.Kind == EdgeBuildBoundary {
+ t.Fatalf("Maven 3 root unexpectedly auto-discovered child: %#v", edge)
+ }
+ }
+}
+
+func TestMavenFailsClosedForDuplicateCoordinatesAndTopologyProfiles(t *testing.T) {
+ root := t.TempDir()
+ writeTopologyFixture(t, root, "one/pom.xml", `
+com.exampleshared1`)
+ writeTopologyFixture(t, root, "two/pom.xml", `
+com.exampleshared2`)
+ writeTopologyFixture(t, root, "app/pom.xml", `
+
+ com.exampleapp
+ com.exampleshared
+ extradynamic
+`)
+
+ fragment, err := (jvmProvider{}).Build(context.Background(), Inventory{
+ Root: root,
+ Manifests: []string{
+ filepath.FromSlash("one/pom.xml"),
+ filepath.FromSlash("two/pom.xml"),
+ filepath.FromSlash("app/pom.xml"),
+ },
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ graph := MergeFragments(root, []Fragment{fragment})
+ if graph.Coverage.Status != CoveragePartial {
+ t.Fatalf("coverage = %q, want partial", graph.Coverage.Status)
+ }
+ for _, code := range []string{"ambiguous-maven-coordinate", "maven-profile-topology"} {
+ if !hasIssueCode(graph.Coverage.Issues, code) {
+ t.Fatalf("issues = %#v, want %s", graph.Coverage.Issues, code)
+ }
+ }
+ appID := mavenID(filepath.FromSlash("app/pom.xml"), "com.example", "app")
+ for _, edge := range graph.Dependencies[appID] {
+ if edge.Kind == EdgeDependency {
+ t.Fatalf("ambiguous Maven coordinate emitted edge: %#v", edge)
+ }
+ }
+}
+
+func TestMavenReportsUnresolvedPropertyCoordinates(t *testing.T) {
+ root := t.TempDir()
+ writeTopologyFixture(t, root, "pom.xml", `
+
+ com.exampleapp
+
+ ${local.group}core
+
+`)
+ fragment, err := (jvmProvider{}).Build(context.Background(), Inventory{
+ Root: root,
+ Manifests: []string{"pom.xml"},
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ graph := MergeFragments(root, []Fragment{fragment})
+ if !hasIssueCode(graph.Coverage.Issues, "unresolved-maven-property") {
+ t.Fatalf("issues = %#v", graph.Coverage.Issues)
+ }
+}
+
+func TestMavenResolvesPropertiesScopesAndSourceRoots(t *testing.T) {
+ root := t.TempDir()
+ writeTopologyFixture(t, root, "pom.xml", `
+
+ com.exampleapp
+ com.examplesrc/generated/java
+
+ ${project.basedir}/${generated}
+ src/integration/kotlin
+
+
+ ${local.group}compile-lib
+ com.exampleprovided-libprovided
+ com.exampletest-libtest
+ com.exampleoptional-libtrue
+
+`)
+ for _, artifact := range []string{"compile-lib", "provided-lib", "test-lib", "optional-lib"} {
+ writeTopologyFixture(t, root, artifact+"/pom.xml",
+ `com.example`+artifact+``)
+ }
+ manifests := []string{"pom.xml"}
+ for _, artifact := range []string{"compile-lib", "provided-lib", "test-lib", "optional-lib"} {
+ manifests = append(manifests, filepath.FromSlash(artifact+"/pom.xml"))
+ }
+
+ fragment, err := (jvmProvider{}).Build(context.Background(), Inventory{Root: root, Manifests: manifests})
+ if err != nil {
+ t.Fatal(err)
+ }
+ graph := MergeFragments(root, []Fragment{fragment})
+ if graph.Coverage.Status != CoverageComplete {
+ t.Fatalf("coverage = %q: %#v", graph.Coverage.Status, graph.Coverage.Issues)
+ }
+ appID := mavenID("pom.xml", "com.example", "app")
+ for _, expectation := range []struct {
+ artifact string
+ scope EdgeScope
+ }{
+ {"compile-lib", "compile"},
+ {"provided-lib", "provided"},
+ {"test-lib", "test"},
+ {"optional-lib", "optional"},
+ } {
+ assertTopologyEdge(t, graph.Dependencies[appID],
+ mavenID(filepath.FromSlash(expectation.artifact+"/pom.xml"), "com.example", expectation.artifact),
+ EdgeDependency, expectation.scope)
+ }
+ node := graph.Nodes[appID]
+ if !slices.Contains(node.SourceRoots, filepath.FromSlash("src/generated/java")) {
+ t.Fatalf("source roots = %#v", node.SourceRoots)
+ }
+ if !slices.Contains(node.TestSourceRoots, filepath.FromSlash("src/integration/kotlin")) {
+ t.Fatalf("test roots = %#v", node.TestSourceRoots)
+ }
+}
+
+func TestMavenResolvesChainedPropertiesDeterministically(t *testing.T) {
+ properties := map[string]string{
+ "first": "${second}",
+ "second": "${third}",
+ "third": "com.example",
+ }
+ if got, ok := resolveMavenValue(properties["first"], properties); !ok || got != "com.example" {
+ t.Fatalf("single property resolution = %q, %v", got, ok)
+ }
+
+ root := t.TempDir()
+ writeTopologyFixture(t, root, "pom.xml", `
+ com.exampleapp
+ ${second}${third}com.example
+ ${first}lib
+`)
+ writeTopologyFixture(t, root, "lib/pom.xml", `com.examplelib`)
+ fragment, err := (jvmProvider{}).Build(context.Background(), Inventory{Root: root, Manifests: []string{"pom.xml", "lib/pom.xml"}})
+ if err != nil {
+ t.Fatal(err)
+ }
+ graph := MergeFragments(root, []Fragment{fragment})
+ if hasIssueCode(graph.Coverage.Issues, "unresolved-maven-property") {
+ t.Fatalf("chained property remained unresolved: %#v", graph.Coverage.Issues)
+ }
+ appID := mavenID("pom.xml", "com.example", "app")
+ libID := mavenID("lib/pom.xml", "com.example", "lib")
+ assertTopologyEdge(t, graph.Dependencies[appID], libID, EdgeDependency, EdgeScope("compile"))
+}
diff --git a/topology/sbt.go b/topology/sbt.go
new file mode 100644
index 0000000..0165da1
--- /dev/null
+++ b/topology/sbt.go
@@ -0,0 +1,332 @@
+package topology
+
+import (
+ "bufio"
+ "context"
+ "fmt"
+ "os"
+ "path/filepath"
+ "regexp"
+ "sort"
+ "strings"
+)
+
+type sbtBuild struct {
+ manifest string
+ root string
+ rootName string
+ projects map[string]*sbtProject
+ references []sbtReference
+ issues []Issue
+}
+
+type sbtProject struct {
+ name string
+ root string
+ sourceRoots []string
+ testSourceRoots []string
+}
+
+type sbtReference struct {
+ source string
+ target string
+ scope string
+ kind EdgeKind
+ line int
+}
+
+var (
+ sbtProjectPattern = regexp.MustCompile(`^\s*(?:lazy\s+)?val\s+([A-Za-z_][A-Za-z0-9_]*)\s*=\s*\(?\s*(?:project|rootProject)\b(.*)$`)
+ sbtProjectPathPattern = regexp.MustCompile(`(?:project\.in\s*\(\s*file\s*\(\s*|project\s+in\s+file\s*\(\s*)["']([^"']+)["']`)
+ sbtNamePattern = regexp.MustCompile(`^\s*(?:ThisBuild\s*/\s*)?name\s*:=\s*["']([^"']+)["']`)
+ sbtReferencePattern = regexp.MustCompile(`\.(dependsOn|aggregate)\s*\(([^)]*)\)`)
+ sbtSourcePattern = regexp.MustCompile(`(?:unmanagedSourceDirectories|sourceDirectories|scalaSource|javaSource|kotlinSource)\b(.*)$`)
+ sbtProjectNamePattern = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`)
+ sbtStringPattern = regexp.MustCompile(`["']([^"']+)["']`)
+)
+
+func buildSBTFragment(ctx context.Context, inventory Inventory, manifests []string) (Fragment, error) {
+ builds := make([]*sbtBuild, 0, len(manifests))
+ for _, manifest := range manifests {
+ if err := ctx.Err(); err != nil {
+ return Fragment{}, err
+ }
+ build, err := parseSBTBuild(inventory.Root, manifest)
+ if err != nil {
+ return Fragment{}, err
+ }
+ builds = append(builds, build)
+ }
+
+ fragment := Fragment{
+ Provider: "jvm",
+ Members: make(map[ID][]string),
+ Coverage: Coverage{Status: CoverageComplete},
+ }
+ for _, build := range builds {
+ for _, name := range sortedSBTProjectNames(build.projects) {
+ project := build.projects[name]
+ id := sbtID(build.manifest, name)
+ fragment.Nodes = append(fragment.Nodes, Node{
+ ID: id,
+ Kind: NodeKind("sbt-project"),
+ Name: projectDisplayName(build.rootName, project),
+ Manifest: build.manifest,
+ Root: project.root,
+ SourceRoots: uniqueSortedStrings(project.sourceRoots),
+ TestSourceRoots: uniqueSortedStrings(project.testSourceRoots),
+ Provider: "jvm",
+ })
+ fragment.Members[id] = membersForRoots(inventory.Files, append(
+ append([]string(nil), project.sourceRoots...), project.testSourceRoots...,
+ ))
+ }
+ fragment.Coverage.Issues = append(fragment.Coverage.Issues, build.issues...)
+ for _, reference := range build.references {
+ target := build.projects[reference.target]
+ if target == nil {
+ fragment.Coverage.Issues = append(fragment.Coverage.Issues, Issue{
+ Provider: "jvm",
+ Code: "unknown-sbt-project",
+ Message: fmt.Sprintf("%s:%d references unknown project %s", build.manifest, reference.line, reference.target),
+ })
+ continue
+ }
+ fragment.Edges = append(fragment.Edges, Edge{
+ From: sbtID(build.manifest, reference.source),
+ To: sbtID(build.manifest, reference.target),
+ Kind: reference.kind,
+ Scope: EdgeScope(reference.scope),
+ Evidence: Evidence{Manifest: build.manifest, Line: reference.line},
+ })
+ }
+ }
+ if len(fragment.Coverage.Issues) > 0 {
+ fragment.Coverage.Status = CoveragePartial
+ }
+ return fragment, nil
+}
+
+func parseSBTBuild(root, manifest string) (*sbtBuild, error) {
+ data, err := os.ReadFile(filepath.Join(root, manifest))
+ if err != nil {
+ return nil, err
+ }
+ buildRoot := filepath.Dir(filepath.Clean(manifest))
+ if buildRoot == "." {
+ buildRoot = "."
+ }
+ build := &sbtBuild{
+ manifest: filepath.Clean(manifest),
+ root: buildRoot,
+ rootName: filepath.Base(filepath.Clean(buildRoot)),
+ projects: make(map[string]*sbtProject),
+ }
+ if build.rootName == "." || build.rootName == string(filepath.Separator) {
+ build.rootName = "root"
+ }
+
+ currentProject := ""
+ scanner := bufio.NewScanner(strings.NewReader(string(data)))
+ inBlockComment := false
+ lineNumber := 0
+ for scanner.Scan() {
+ lineNumber++
+ line := stripSBTComments(scanner.Text(), &inBlockComment)
+ if match := sbtNamePattern.FindStringSubmatch(line); match != nil {
+ build.rootName = match[1]
+ }
+ if match := sbtProjectPattern.FindStringSubmatch(line); match != nil {
+ name := match[1]
+ projectRoot := name
+ if pathMatch := sbtProjectPathPattern.FindStringSubmatch(match[2]); pathMatch != nil {
+ projectRoot = pathMatch[1]
+ }
+ if name == "root" && projectRoot == name {
+ projectRoot = "."
+ }
+ if projectRoot == "." {
+ projectRoot = "."
+ }
+ project := &sbtProject{
+ name: name,
+ root: filepath.Clean(filepath.Join(build.root, filepath.FromSlash(projectRoot))),
+ }
+ project.sourceRoots, project.testSourceRoots = conventionalSBTRoots(project.root)
+ if _, exists := build.projects[name]; exists {
+ build.issues = append(build.issues, Issue{Provider: "jvm", Code: "duplicate-sbt-project", Message: fmt.Sprintf("%s:%d defines project %s more than once", build.manifest, lineNumber, name)})
+ } else {
+ build.projects[name] = project
+ }
+ currentProject = name
+ }
+ if currentProject == "" {
+ continue
+ }
+ for _, match := range sbtReferencePattern.FindAllStringSubmatch(line, -1) {
+ kind := EdgeDependency
+ if match[1] == "aggregate" {
+ kind = EdgeBuildBoundary
+ }
+ arguments := splitSBTArguments(match[2])
+ if len(arguments) == 0 {
+ build.issues = append(build.issues, Issue{Provider: "jvm", Code: "dynamic-sbt-reference", Message: fmt.Sprintf("%s:%d %s has no literal project names", build.manifest, lineNumber, match[1])})
+ continue
+ }
+ for _, argument := range arguments {
+ name, scope := parseSBTReferenceArgument(argument)
+ if name == "" {
+ build.issues = append(build.issues, Issue{Provider: "jvm", Code: "dynamic-sbt-reference", Message: fmt.Sprintf("%s:%d %s contains a non-literal project reference", build.manifest, lineNumber, match[1])})
+ continue
+ }
+ if kind == EdgeBuildBoundary && !strings.Contains(argument, "%") {
+ scope = ""
+ }
+ build.references = append(build.references, sbtReference{source: currentProject, target: name, scope: scope, kind: kind, line: lineNumber})
+ }
+ }
+ if match := sbtSourcePattern.FindStringSubmatch(line); match != nil {
+ literals := sbtStringPattern.FindAllStringSubmatch(match[1], -1)
+ if len(literals) == 0 {
+ build.issues = append(build.issues, Issue{Provider: "jvm", Code: "dynamic-sbt-source-root", Message: fmt.Sprintf("%s:%d source root is not a literal path", build.manifest, lineNumber)})
+ continue
+ }
+ for _, literal := range literals {
+ path := filepath.Clean(filepath.Join(build.projects[currentProject].root, filepath.FromSlash(literal[1])))
+ if strings.Contains(strings.ToLower(line), "test") {
+ build.projects[currentProject].testSourceRoots = append(build.projects[currentProject].testSourceRoots, path)
+ } else {
+ build.projects[currentProject].sourceRoots = append(build.projects[currentProject].sourceRoots, path)
+ }
+ }
+ }
+ }
+ if err := scanner.Err(); err != nil {
+ return nil, err
+ }
+ if _, ok := build.projects["root"]; !ok {
+ rootProject := &sbtProject{name: "root", root: build.root}
+ rootProject.sourceRoots, rootProject.testSourceRoots = conventionalSBTRoots(rootProject.root)
+ build.projects["root"] = rootProject
+ }
+ for _, project := range build.projects {
+ project.sourceRoots = uniqueSortedStrings(project.sourceRoots)
+ project.testSourceRoots = uniqueSortedStrings(project.testSourceRoots)
+ }
+ return build, nil
+}
+
+func sbtID(manifest, project string) ID {
+ return ID("sbt:" + filepath.ToSlash(filepath.Clean(manifest)) + ":" + project)
+}
+
+func projectDisplayName(rootName string, project *sbtProject) string {
+ if project.name == "root" {
+ return rootName
+ }
+ return project.name
+}
+
+func conventionalSBTRoots(root string) ([]string, []string) {
+ var sourceRoots, testRoots []string
+ for _, language := range []string{"java", "kotlin", "scala"} {
+ sourceRoots = append(sourceRoots, filepath.Join(root, "src", "main", language))
+ testRoots = append(testRoots, filepath.Join(root, "src", "test", language))
+ }
+ return sourceRoots, testRoots
+}
+
+func sortedSBTProjectNames(projects map[string]*sbtProject) []string {
+ result := make([]string, 0, len(projects))
+ for name := range projects {
+ result = append(result, name)
+ }
+ sort.Strings(result)
+ return result
+}
+
+func splitSBTArguments(text string) []string {
+ var result []string
+ start := 0
+ depth := 0
+ for index, char := range text {
+ switch char {
+ case '(', '[', '{':
+ depth++
+ case ')', ']', '}':
+ if depth > 0 {
+ depth--
+ }
+ case ',':
+ if depth == 0 {
+ result = append(result, strings.TrimSpace(text[start:index]))
+ start = index + 1
+ }
+ }
+ }
+ if tail := strings.TrimSpace(text[start:]); tail != "" {
+ result = append(result, tail)
+ }
+ return result
+}
+
+func parseSBTReferenceArgument(argument string) (string, string) {
+ argument = strings.TrimSpace(argument)
+ parts := strings.SplitN(argument, "%", 2)
+ name := strings.TrimSpace(parts[0])
+ if !sbtProjectNamePattern.MatchString(name) {
+ return "", ""
+ }
+ if len(parts) == 1 {
+ return name, "compile"
+ }
+ scope := strings.TrimSpace(parts[1])
+ if match := sbtStringPattern.FindStringSubmatch(scope); match != nil {
+ scope = match[1]
+ }
+ if index := strings.IndexAny(scope, "->;"); index >= 0 {
+ scope = scope[:index]
+ }
+ return name, strings.ToLower(strings.TrimSpace(scope))
+}
+
+func stripSBTComments(line string, inBlockComment *bool) string {
+ inString, escaped := byte(0), false
+ var result strings.Builder
+ for index := 0; index < len(line); index++ {
+ char := line[index]
+ if *inBlockComment {
+ if char == '*' && index+1 < len(line) && line[index+1] == '/' {
+ *inBlockComment = false
+ index++
+ }
+ continue
+ }
+ if inString != 0 {
+ result.WriteByte(char)
+ if escaped {
+ escaped = false
+ } else if char == '\\' {
+ escaped = true
+ } else if char == inString {
+ inString = 0
+ }
+ continue
+ }
+ if char == '\'' || char == '"' {
+ inString = char
+ result.WriteByte(char)
+ continue
+ }
+ if char == '/' && index+1 < len(line) && line[index+1] == '/' {
+ return result.String()
+ }
+ if char == '/' && index+1 < len(line) && line[index+1] == '*' {
+ *inBlockComment = true
+ index++
+ continue
+ }
+ result.WriteByte(char)
+ }
+ return result.String()
+}
diff --git a/topology/sbt_test.go b/topology/sbt_test.go
new file mode 100644
index 0000000..fff722c
--- /dev/null
+++ b/topology/sbt_test.go
@@ -0,0 +1,110 @@
+package topology
+
+import (
+ "context"
+ "path/filepath"
+ "reflect"
+ "testing"
+
+ "codemap/scanner"
+)
+
+func TestSBTBuildsModernAndLegacyProjects(t *testing.T) {
+ root := t.TempDir()
+ writeTopologyFixture(t, root, "build.sbt", `
+ThisBuild / name := "Demo"
+scalaVersion := "3.8.4"
+lazy val root = project.in(file("."))
+ .aggregate(core)
+lazy val core = project.in(file("core"))
+lazy val app = (project in file("app"))
+ .dependsOn(core % Test)
+/*
+ .aggregate(hidden)
+*/
+Compile / unmanagedSourceDirectories ++= Seq(baseDirectory.value / "src/main/generated", baseDirectory.value / "src/main/extra")
+Test / unmanagedSourceDirectories += baseDirectory.value / "src/integration/scala"
+`)
+ files := []scanner.FileInfo{
+ {Path: filepath.FromSlash("core/src/main/java/Core.java"), Ext: ".java"},
+ {Path: filepath.FromSlash("core/src/main/kotlin/Core.kt"), Ext: ".kt"},
+ {Path: filepath.FromSlash("app/src/main/extra/Extra.scala"), Ext: ".scala"},
+ {Path: filepath.FromSlash("app/src/main/generated/Generated.scala"), Ext: ".scala"},
+ {Path: filepath.FromSlash("app/src/integration/scala/AppTest.scala"), Ext: ".scala"},
+ }
+ fragment, err := (jvmProvider{}).Build(context.Background(), Inventory{
+ Root: root,
+ Files: files,
+ Manifests: []string{"build.sbt"},
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ graph := MergeFragments(root, []Fragment{fragment})
+ rootID := sbtID("build.sbt", "root")
+ coreID := sbtID("build.sbt", "core")
+ appID := sbtID("build.sbt", "app")
+ if graph.Coverage.Status != CoverageComplete {
+ t.Fatalf("coverage = %q: %#v", graph.Coverage.Status, graph.Coverage.Issues)
+ }
+ assertTopologyEdge(t, graph.Dependencies[rootID], coreID, EdgeBuildBoundary, EdgeScope(""))
+ assertTopologyEdge(t, graph.Dependencies[appID], coreID, EdgeDependency, EdgeScope("test"))
+ if got := graph.Members[coreID]; !reflect.DeepEqual(got, []string{
+ filepath.FromSlash("core/src/main/java/Core.java"),
+ filepath.FromSlash("core/src/main/kotlin/Core.kt"),
+ }) {
+ t.Fatalf("core members = %#v", got)
+ }
+ if got := graph.Members[appID]; !reflect.DeepEqual(got, []string{
+ filepath.FromSlash("app/src/integration/scala/AppTest.scala"),
+ filepath.FromSlash("app/src/main/extra/Extra.scala"),
+ filepath.FromSlash("app/src/main/generated/Generated.scala"),
+ }) {
+ t.Fatalf("app members = %#v", got)
+ }
+}
+
+func TestSBTSupportsScala2RootProjectSyntax(t *testing.T) {
+ root := t.TempDir()
+ writeTopologyFixture(t, root, "build.sbt", `
+scalaVersion := "2.13.16"
+val root = rootProject
+ .aggregate(core)
+val core = project
+`)
+ fragment, err := (jvmProvider{}).Build(context.Background(), Inventory{
+ Root: root, Manifests: []string{"build.sbt"},
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ graph := MergeFragments(root, []Fragment{fragment})
+ if graph.Coverage.Status != CoverageComplete {
+ t.Fatalf("coverage = %q: %#v", graph.Coverage.Status, graph.Coverage.Issues)
+ }
+ assertTopologyEdge(t, graph.Dependencies[sbtID("build.sbt", "root")], sbtID("build.sbt", "core"), EdgeBuildBoundary, EdgeScope(""))
+}
+
+func TestSBTReportsDynamicAndUnknownReferences(t *testing.T) {
+ root := t.TempDir()
+ writeTopologyFixture(t, root, "build.sbt", `
+lazy val root = project
+ .dependsOn(projects.map(_.project))
+ .aggregate(missing)
+`)
+ fragment, err := (jvmProvider{}).Build(context.Background(), Inventory{
+ Root: root, Manifests: []string{"build.sbt"},
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ graph := MergeFragments(root, []Fragment{fragment})
+ if graph.Coverage.Status != CoveragePartial {
+ t.Fatalf("coverage = %q, want partial", graph.Coverage.Status)
+ }
+ for _, code := range []string{"dynamic-sbt-reference", "unknown-sbt-project"} {
+ if !hasIssueCode(graph.Coverage.Issues, code) {
+ t.Fatalf("issues = %#v, want %s", graph.Coverage.Issues, code)
+ }
+ }
+}
diff --git a/watch/control_events_test.go b/watch/control_events_test.go
index 26c85c6..45994c8 100644
--- a/watch/control_events_test.go
+++ b/watch/control_events_test.go
@@ -6,11 +6,14 @@ import (
"sync/atomic"
"testing"
"time"
+
+ "codemap/internal/projectpath"
+ "github.com/fsnotify/fsnotify"
)
-// startControlEventDaemon boots a daemon over a minimal Go project and returns
-// it with the path to its config file.
-func startControlEventDaemon(t *testing.T) (*Daemon, string) {
+// newControlEventDaemon creates a daemon over a minimal Go project without
+// starting its event loop.
+func newControlEventDaemon(t *testing.T) (*Daemon, string) {
t.Helper()
root := t.TempDir()
if err := os.MkdirAll(filepath.Join(root, ".codemap"), 0o755); err != nil {
@@ -27,10 +30,18 @@ func startControlEventDaemon(t *testing.T) (*Daemon, string) {
if err != nil {
t.Fatal(err)
}
+ t.Cleanup(func() { d.Stop() })
+ return d, configPath
+}
+
+// startControlEventDaemon boots a daemon over a minimal Go project and returns
+// it with the path to its config file.
+func startControlEventDaemon(t *testing.T) (*Daemon, string) {
+ t.Helper()
+ d, configPath := newControlEventDaemon(t)
if err := d.Start(); err != nil {
t.Fatal(err)
}
- t.Cleanup(func() { d.Stop() })
return d, configPath
}
@@ -48,13 +59,22 @@ func TestControlEventBurstTriggersOneRefresh(t *testing.T) {
return original(d, resetIgnoreCache)
}
- _, configPath := startControlEventDaemon(t)
+ d, _ := newControlEventDaemon(t)
+ d.watcher.Events = make(chan fsnotify.Event, 5)
+ d.watcher.Errors = make(chan error)
+ if err := d.Start(); err != nil {
+ t.Fatal(err)
+ }
- // A tight burst, well inside the coalescing window.
+ configPath := filepath.Join(d.configDir, "config.json")
+
+ if err := os.WriteFile(configPath, []byte(`{"only":["go","md"]}`), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ // Inject a tight burst so CI scheduling cannot stretch filesystem delivery
+ // beyond the coalescing window.
for i := 0; i < 5; i++ {
- if err := os.WriteFile(configPath, []byte(`{"only":["go","md"]}`), 0o644); err != nil {
- t.Fatal(err)
- }
+ d.watcher.Events <- fsnotify.Event{Name: configPath, Op: fsnotify.Write}
}
waitForWatchCondition(t, 5*time.Second, func() bool { return refreshes.Load() >= 1 })
@@ -95,3 +115,27 @@ func TestControlEventBurstPreservesIgnoreCacheReset(t *testing.T) {
waitForWatchCondition(t, 5*time.Second, func() bool { return sawReset.Load() })
}
+
+func TestFilterControlEventCanonicalizesAliasAndMissingLeaf(t *testing.T) {
+ if os.PathSeparator == '\\' {
+ t.Skip("symlinks may require elevated privileges")
+ }
+ d, _ := newControlEventDaemon(t)
+ alias := filepath.Join(t.TempDir(), "codemap-alias")
+ if err := os.Symlink(d.configDir, alias); err != nil {
+ t.Fatal(err)
+ }
+ configPath := filepath.Join(alias, "config.json")
+ if _, control := d.filterControlEvent(configPath); !control {
+ t.Fatalf("alias config path %q was not classified as control", configPath)
+ }
+ if err := os.Remove(filepath.Join(d.configDir, "config.json")); err != nil {
+ t.Fatal(err)
+ }
+ if _, control := d.filterControlEvent(configPath); !control {
+ t.Fatalf("missing alias config path %q was not classified as control", configPath)
+ }
+ if got, want := projectpath.CanonicalPath(configPath), filepath.Join(projectpath.CanonicalPath(d.configDir), "config.json"); got != want {
+ t.Fatalf("CanonicalPath(%q) = %q, want %q", configPath, got, want)
+ }
+}
diff --git a/watch/daemon.go b/watch/daemon.go
index e386747..dc0fdfd 100644
--- a/watch/daemon.go
+++ b/watch/daemon.go
@@ -48,14 +48,8 @@ func (d *Daemon) runtimeStateDir() (string, error) {
// NewDaemon creates a new watch daemon for the given root
func NewDaemon(root string, verbose bool) (*Daemon, error) {
- absRoot, err := filepath.Abs(root)
- if err != nil {
- return nil, fmt.Errorf("invalid root path: %w", err)
- }
// Canonicalize so d.root, the runtime dir, and fsnotify paths agree.
- if canonical, err := filepath.EvalSymlinks(absRoot); err == nil {
- absRoot = canonical
- }
+ absRoot := projectpath.CanonicalPath(root)
selection, err := projectpath.SelectRuntime(absRoot)
if err != nil {
return nil, fmt.Errorf("resolve runtime state: %w", err)
diff --git a/watch/events.go b/watch/events.go
index 9084ca2..f6e5ae0 100644
--- a/watch/events.go
+++ b/watch/events.go
@@ -12,6 +12,7 @@ import (
"strings"
"time"
+ "codemap/internal/projectpath"
"codemap/internal/runtimefile"
"codemap/limits"
"codemap/scanner"
@@ -228,6 +229,7 @@ func (d *Daemon) eventLoop() {
if !ok {
return
}
+ event.Name = projectpath.CanonicalPath(event.Name)
now := time.Now()
if resetIgnoreCache, control := d.filterControlEvent(event.Name); control {
// OR the flag across the burst: a coalesced refresh must still
@@ -290,8 +292,8 @@ func (d *Daemon) eventLoop() {
}
func (d *Daemon) filterControlEvent(path string) (resetIgnoreCache, control bool) {
- clean := filepath.Clean(path)
- if clean == filepath.Join(d.configDir, "config.json") {
+ clean := projectpath.CanonicalPath(path)
+ if clean == filepath.Join(projectpath.CanonicalPath(d.configDir), "config.json") {
return false, true
}
if filepath.Base(clean) == ".gitignore" {
@@ -301,7 +303,8 @@ func (d *Daemon) filterControlEvent(path string) (resetIgnoreCache, control bool
}
func (d *Daemon) handleConfiguredMembershipEvent(event fsnotify.Event) {
- relPath, err := filepath.Rel(d.root, event.Name)
+ event.Name = projectpath.CanonicalPath(event.Name)
+ relPath, err := filepath.Rel(projectpath.CanonicalPath(d.root), event.Name)
if err != nil {
return
}
@@ -329,7 +332,8 @@ func (d *Daemon) handleConfiguredMembershipEvent(event fsnotify.Event) {
}
func (d *Daemon) handleTopologyControlEvent(event fsnotify.Event) bool {
- rel, err := filepath.Rel(d.configDir, event.Name)
+ event.Name = projectpath.CanonicalPath(event.Name)
+ rel, err := filepath.Rel(projectpath.CanonicalPath(d.configDir), event.Name)
if err != nil || filepath.Clean(rel) != "config.json" {
return false
}
@@ -340,10 +344,11 @@ func (d *Daemon) handleTopologyControlEvent(event fsnotify.Event) bool {
}
func (d *Daemon) debounceAction(debouncer *eventDebouncer, event fsnotify.Event, now time.Time) debounceAction {
+ event.Name = projectpath.CanonicalPath(event.Name)
if !debouncer.shouldSkip(event, now) {
return debounceProcess
}
- relPath, err := filepath.Rel(d.root, event.Name)
+ relPath, err := filepath.Rel(projectpath.CanonicalPath(d.root), event.Name)
if err != nil {
return debounceProcess
}
@@ -393,15 +398,16 @@ func isTransientFile(path string) bool {
// handleEvent processes a single file event
func (d *Daemon) handleEvent(fsEvent fsnotify.Event) {
- absPath, absErr := filepath.Abs(fsEvent.Name)
- if absErr == nil && d.gitCache != nil {
+ fsEvent.Name = projectpath.CanonicalPath(fsEvent.Name)
+ absPath := fsEvent.Name
+ if d.gitCache != nil {
// Ignore gitignored paths entirely so watcher churn cannot come from excluded trees.
if d.gitCache.ShouldIgnore(absPath) {
return
}
}
- relPath, err := filepath.Rel(d.root, fsEvent.Name)
+ relPath, err := filepath.Rel(projectpath.CanonicalPath(d.root), fsEvent.Name)
if err != nil {
relPath = fsEvent.Name
}
@@ -451,10 +457,7 @@ func (d *Daemon) handleEvent(fsEvent fsnotify.Event) {
if info.IsDir() {
name := filepath.Base(fsEvent.Name)
if d.gitCache != nil {
- dirPath := fsEvent.Name
- if absErr == nil {
- dirPath = absPath
- }
+ dirPath := absPath
d.gitCache.EnsureDir(dirPath)
if d.gitCache.ShouldIgnore(dirPath) {
d.graph.mu.Unlock()
diff --git a/watch/state.go b/watch/state.go
index c99eb0f..7c8d5b2 100644
--- a/watch/state.go
+++ b/watch/state.go
@@ -23,17 +23,10 @@ var ErrDaemonOwnershipUnknown = errors.New("could not verify that watch.pid belo
var ErrDaemonExitTimeout = errors.New("watch daemon did not exit before transition deadline")
-// canonicalRoot returns root as an absolute, symlink-resolved path; on error
-// it returns the absolute path unchanged.
+// canonicalRoot returns the same platform-normalized path identity used by
+// daemon setup and event handling.
func canonicalRoot(root string) string {
- abs, err := filepath.Abs(root)
- if err != nil {
- return root
- }
- if canonical, err := filepath.EvalSymlinks(abs); err == nil {
- return canonical
- }
- return abs
+ return projectpath.CanonicalPath(root)
}
// ReadState reads daemon state for hooks and returns nil when it is unavailable
@@ -242,18 +235,13 @@ func daemonOwnershipForPID(root string, pid int) daemonOwnership {
return ownershipUnknown
}
- absRoot, err := filepath.Abs(root)
- if err != nil {
- absRoot = root
- } else if canonicalRoot, canonicalErr := filepath.EvalSymlinks(absRoot); canonicalErr == nil {
- absRoot = canonicalRoot
- }
+ absRoot := projectpath.CanonicalPath(root)
const daemonMarker = " watch daemon "
marker := strings.LastIndex(cmdline, daemonMarker)
if marker >= 0 {
candidate := strings.Trim(strings.TrimSpace(cmdline[marker+len(daemonMarker):]), `"`)
- candidate, canonicalErr := filepath.EvalSymlinks(candidate)
- if canonicalErr == nil && filepath.Clean(candidate) == filepath.Clean(absRoot) {
+ candidate = projectpath.CanonicalPath(candidate)
+ if candidate == absRoot {
return ownershipOwned
}
}