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 } }