diff --git a/README.md b/README.md index 57051fe..ec6a6cb 100644 --- a/README.md +++ b/README.md @@ -143,7 +143,7 @@ The JSON payload is versioned (`schema_version: codemap.analysis/v1`) so consume ### Supported languages -21 language rules for dependency analysis: Go, Python, JavaScript, JSX, TypeScript, TSX, Rust, Ruby, C, C++, Java, Swift, Dart, Kotlin, C#, PHP, Bash, Lua, Scala, Elixir, Solidity. Dart projects, including Flutter apps and packages, also get `pubspec.yaml` dependency discovery. +21 ast-grep language rules for dependency analysis: Go, Python, JavaScript, JSX, TypeScript, TSX, Rust, Ruby, C, C++, Java, Swift, Dart, Kotlin, C#, PHP, Bash, Lua, Scala, Elixir, Solidity. Dart projects, including Flutter apps and packages, also get `pubspec.yaml` dependency discovery. CUE files also contribute module-scoped package edges through lexical import extraction; CUE is not an ast-grep rule. > Powered by [ast-grep](https://ast-grep.github.io/). Installed automatically with the Homebrew formula. diff --git a/main.go b/main.go index 3c4d6ce..0563f78 100644 --- a/main.go +++ b/main.go @@ -581,7 +581,7 @@ func runDepsMode(absRoot, root string, jsonMode bool, diffRef string, changedFil if graph != nil { coverageSources = graph.Coverage.Sources } - depsProject := scanner.NewDepsProjectWithCoverage(absRoot, outcome.Analyses, externalDeps, diffRef, scanner.CoverageFromSources(coverageSources)) + depsProject := scanner.NewDepsProjectWithCoverageAndFilters(absRoot, outcome.Analyses, externalDeps, diffRef, scanner.CoverageFromSources(coverageSources), filters) // Render or output JSON if jsonMode { diff --git a/render/colors.go b/render/colors.go index aeac11e..10e7c19 100644 --- a/render/colors.go +++ b/render/colors.go @@ -61,7 +61,7 @@ func GetFileColor(ext string) string { strings.ToLower(ext) == "makefile" || strings.ToLower(ext) == "dockerfile": return BoldWhite case ext == ".swift" || ext == ".kt" || ext == ".java" || ext == ".scala" || - ext == ".groovy" || ext == ".rs" || ext == ".rlib": + ext == ".groovy" || ext == ".rs" || ext == ".rlib" || ext == ".cue": return BoldRed case ext == ".c" || ext == ".cpp" || ext == ".h" || ext == ".hpp" || ext == ".cc" || ext == ".m" || ext == ".mm" || ext == ".cs" || ext == ".fs": diff --git a/render/colors_test.go b/render/colors_test.go index e55e499..21aa4b9 100644 --- a/render/colors_test.go +++ b/render/colors_test.go @@ -36,6 +36,8 @@ func TestGetFileColor(t *testing.T) { {".toml", Red}, {".xml", Red}, {".rb", Red}, + // CUE + {".cue", BoldRed}, // Shell scripts {".sh", BoldWhite}, {".bat", BoldWhite}, @@ -120,7 +122,7 @@ func TestIsAssetExtension(t *testing.T) { func TestIsAssetExtensionSourceFiles(t *testing.T) { sourceExts := []string{ ".go", ".py", ".js", ".ts", ".rs", ".c", ".cpp", - ".java", ".swift", ".kt", ".rb", ".php", ".html", ".css", + ".java", ".swift", ".kt", ".rb", ".php", ".html", ".css", ".cue", } for _, ext := range sourceExts { diff --git a/render/depgraph.go b/render/depgraph.go index 4135c49..b67e297 100644 --- a/render/depgraph.go +++ b/render/depgraph.go @@ -77,7 +77,11 @@ func Depgraph(ctx context.Context, w io.Writer, project scanner.DepsProject) { // Build the graph from the analyses the caller already produced instead of // re-scanning with BuildFileGraph, which would double the ast-grep work. - fg, err := scanner.BuildFileGraphFromAnalyses(ctx, project.Root, files, scanner.ConfiguredFilters(project.Root)) + graphFilters := scanner.ConfiguredFilters(project.Root) + if project.EffectiveFilters != nil { + graphFilters = *project.EffectiveFilters + } + fg, err := scanner.BuildFileGraphFromAnalyses(ctx, project.Root, files, graphFilters) var internalDeps map[string][]string var depCounts map[string]int if err == nil && fg != nil { @@ -170,7 +174,7 @@ func Depgraph(ctx context.Context, w io.Writer, project scanner.DepsProject) { // Format dep lines var depLines []string - langOrder := []string{"go", "javascript", "python", "swift", "dart", "rust", "ruby", "bash", "kotlin", "csharp", "php", "lua", "scala", "elixir", "solidity"} + langOrder := []string{"go", "cue", "javascript", "python", "swift", "dart", "rust", "ruby", "bash", "kotlin", "csharp", "php", "lua", "scala", "elixir", "solidity"} for _, lang := range langOrder { if names, ok := extByLang[lang]; ok { diff --git a/render/depgraph_test.go b/render/depgraph_test.go index 8bc17c0..6806625 100644 --- a/render/depgraph_test.go +++ b/render/depgraph_test.go @@ -82,6 +82,7 @@ func TestDepgraphRendersExternalDepsAndSummarySection(t *testing.T) { }, ExternalDeps: map[string][]string{ "go": {"github.com/acme/module/v2", "github.com/acme/pkg", "github.com/acme/pkg"}, + "cue": {"cue.example/schema"}, "javascript": {"react", "react"}, "dart": {"flutter", "riverpod"}, }, @@ -94,6 +95,7 @@ func TestDepgraphRendersExternalDepsAndSummarySection(t *testing.T) { expectedSnippets := []string{ "Dependency Flow", "Go: module, pkg", + "CUE: schema", "JavaScript: react", "Dart: flutter, riverpod", "Src", @@ -145,6 +147,31 @@ func TestDepgraphBuildsGraphFromAnalysesWithoutRescanning(t *testing.T) { } } +func TestDepgraphUsesEffectiveFiltersForCUEEdges(t *testing.T) { + root := t.TempDir() + writeDepgraphFile(t, root, ".codemap/config.json", `{"only":["go"]}`) + writeDepgraphFile(t, root, "cue.mod/module.cue", "module: \"timoni.sh/hilfe\"\n") + writeDepgraphFile(t, root, "timoni.cue", "package app\n\nimport \"timoni.sh/hilfe/templates\"\n") + writeDepgraphFile(t, root, "templates/admin.cue", "package templates\n") + + project := scanner.DepsProject{ + Root: root, + Files: []scanner.FileAnalysis{ + {Path: "cue.mod/module.cue", Language: "cue"}, + {Path: "timoni.cue", Language: "cue", Imports: []string{"timoni.sh/hilfe/templates"}}, + {Path: "templates/admin.cue", Language: "cue"}, + }, + EffectiveFilters: &scanner.Filters{Only: []string{"cue"}}, + } + + var buf bytes.Buffer + Depgraph(context.Background(), &buf, project) + output := buf.String() + if !strings.Contains(output, "timoni ───▶ templates/admin") { + t.Fatalf("expected CUE edge with caller filters, got:\n%s", output) + } +} + // Regression for PR #105: a degraded scan must surface its coverage in text // output instead of reading as a complete, empty graph. func TestDepgraphRendersDegradedCoverage(t *testing.T) { diff --git a/render/skyline.go b/render/skyline.go index 7db192d..b21b011 100644 --- a/render/skyline.go +++ b/render/skyline.go @@ -26,7 +26,7 @@ var codeExtensions = map[string]bool{ ".swift": true, ".kt": true, ".scala": true, ".c": true, ".cpp": true, ".h": true, ".hpp": true, ".cs": true, ".fs": true, ".php": true, ".lua": true, ".r": true, ".dart": true, ".vue": true, ".svelte": true, ".elm": true, ".ex": true, ".exs": true, ".hs": true, ".ml": true, ".clj": true, ".erl": true, ".sh": true, ".bash": true, ".zsh": true, ".fish": true, ".ps1": true, - ".html": true, ".css": true, ".scss": true, ".sass": true, ".less": true, + ".html": true, ".css": true, ".scss": true, ".sass": true, ".less": true, ".cue": true, ".sql": true, ".graphql": true, ".proto": true, } diff --git a/render/skyline_test.go b/render/skyline_test.go index 1db7f81..89980a8 100644 --- a/render/skyline_test.go +++ b/render/skyline_test.go @@ -36,10 +36,11 @@ func TestSkylineFilterCodeFiles(t *testing.T) { name: "returns only code files when present", files: []scanner.FileInfo{ {Path: "main.go", Ext: ".go"}, + {Path: "schema.cue", Ext: ".cue"}, {Path: "photo.png", Ext: ".png"}, {Path: "Dockerfile"}, }, - expected: 2, + expected: 3, }, { name: "returns original files when no code files found", diff --git a/scanner/cue.go b/scanner/cue.go new file mode 100644 index 0000000..d3b1705 --- /dev/null +++ b/scanner/cue.go @@ -0,0 +1,205 @@ +package scanner + +import ( + "context" + "os" + "path/filepath" + "sort" + "strconv" + "strings" + textscanner "text/scanner" + + "codemap/analysis" +) + +// scanCUEFiles extracts CUE package imports without evaluating the module. +// CUE packages are resolved later from cue.mod/module.cue and the file index. +func scanCUEFiles(ctx context.Context, root string, filters Filters) (ScanOutcome, error) { + files, err := ScanFiles(ctx, root, NewGitIgnoreCache(root), filters.Only, filters.Exclude) + if err != nil { + return ScanOutcome{}, err + } + return scanCUEFilesFromFiles(ctx, root, files) +} + +func scanCUEFilesFromFiles(ctx context.Context, root string, files []FileInfo) (ScanOutcome, error) { + var analyses []FileAnalysis + cueFiles := 0 + for _, file := range files { + if !strings.EqualFold(filepath.Ext(file.Path), ".cue") { + continue + } + cueFiles++ + if err := ctx.Err(); err != nil { + return ScanOutcome{}, err + } + path := filepath.Clean(file.Path) + data, err := os.ReadFile(filepath.Join(root, path)) + if err != nil { + continue + } + pkg, imports := cueHeader(data) + analyses = append(analyses, FileAnalysis{ + Path: path, + Language: "cue", + Package: pkg, + Imports: imports, + }) + } + if cueFiles == 0 { + return ScanOutcome{}, nil + } + return ScanOutcome{ + Analyses: analyses, + Sources: []analysis.Source{{ + Name: "cue-imports", + Status: analysis.SourceAuthoritative, + Detail: "CUE package evaluation and external module loading are not graph edges", + }}, + }, nil +} + +func cueImports(data []byte) []string { + _, imports := cueHeader(data) + return imports +} + +// CUE imports are valid only in the file preamble. +func cueHeader(data []byte) (string, []string) { + s := newCUEScanner(data) + + var pkg string + var imports []string + for token := s.Scan(); token != textscanner.EOF; { + if token != textscanner.Ident { + break + } + switch s.TokenText() { + case "package": + if s.Scan() == textscanner.Ident { + pkg = s.TokenText() + } + token = skipCUEHeaderLine(s) + case "import": + token = scanCUEImport(s, &imports) + default: + return pkg, dedupe(imports) + } + } + return pkg, dedupe(imports) +} + +func skipCUEHeaderLine(s *textscanner.Scanner) rune { + line := s.Line + for token := s.Scan(); token != textscanner.EOF; token = s.Scan() { + if s.Line > line { + return token + } + } + return textscanner.EOF +} + +func scanCUEImport(s *textscanner.Scanner, imports *[]string) rune { + token := s.Scan() + if token == '(' { + for token = s.Scan(); token != textscanner.EOF && token != ')'; token = s.Scan() { + if token == textscanner.String { + if path, err := strconv.Unquote(s.TokenText()); err == nil && path != "" { + *imports = append(*imports, path) + } + } + } + return s.Scan() + } + if token == textscanner.Ident { + token = s.Scan() + } + if token == textscanner.String { + if path, err := strconv.Unquote(s.TokenText()); err == nil && path != "" { + *imports = append(*imports, path) + } + } + return s.Scan() +} + +func detectCUEModule(root string) string { + return readCUEModule(filepath.Join(root, "cue.mod", "module.cue")) +} + +type cueModuleInfo struct { + path string + root string +} + +func detectCUEModulesWithFiles(root string, files []FileInfo) []cueModuleInfo { + paths := map[string]bool{"cue.mod/module.cue": true} + for _, file := range files { + path := filepath.ToSlash(filepath.Clean(file.Path)) + if path == "cue.mod/module.cue" || strings.HasSuffix(path, "/cue.mod/module.cue") { + paths[path] = true + } + } + var modules []cueModuleInfo + for path := range paths { + module := readCUEModule(filepath.Join(root, filepath.FromSlash(path))) + if module == "" { + continue + } + moduleRoot := strings.TrimSuffix(path, "/cue.mod/module.cue") + if moduleRoot == path { + moduleRoot = "" + } + modules = append(modules, cueModuleInfo{path: module, root: moduleRoot}) + } + sort.Slice(modules, func(i, j int) bool { + if len(modules[i].root) != len(modules[j].root) { + return len(modules[i].root) > len(modules[j].root) + } + return modules[i].root < modules[j].root + }) + return modules +} + +func readCUEModule(path string) string { + data, err := os.ReadFile(path) + if err != nil { + return "" + } + + s := newCUEScanner(data) + for token := s.Scan(); token != textscanner.EOF; token = s.Scan() { + if token != textscanner.Ident || s.TokenText() != "module" { + continue + } + if s.Scan() != ':' || s.Scan() != textscanner.String { + continue + } + module, err := strconv.Unquote(s.TokenText()) + if err == nil { + return normalizeCUEModule(module) + } + } + return "" +} + +func normalizeCUEModule(module string) string { + module = strings.TrimSpace(module) + marker := strings.LastIndex(module, "@v") + if marker < 0 || marker+2 == len(module) { + return module + } + for _, r := range module[marker+2:] { + if r < '0' || r > '9' { + return module + } + } + return module[:marker] +} + +func newCUEScanner(data []byte) *textscanner.Scanner { + s := &textscanner.Scanner{} + s.Init(strings.NewReader(string(data))) + s.Mode = textscanner.ScanIdents | textscanner.ScanStrings | textscanner.ScanComments | textscanner.SkipComments + s.Error = func(*textscanner.Scanner, string) {} + return s +} diff --git a/scanner/cue_external_test.go b/scanner/cue_external_test.go new file mode 100644 index 0000000..5e9f22d --- /dev/null +++ b/scanner/cue_external_test.go @@ -0,0 +1,62 @@ +package scanner_test + +import ( + "context" + "os" + "path/filepath" + "reflect" + "testing" + + "codemap/scanner" +) + +func TestBuildFileGraphFromAnalysesResolvesCUEPackage(t *testing.T) { + root := t.TempDir() + writeCUE(t, root, "cue.mod/module.cue", "module: \"example.com/acme\"\n") + writeCUE(t, root, "main.cue", "package app\n") + writeCUE(t, root, "types/types.cue", "package types\n") + + graph, err := scanner.BuildFileGraphFromAnalyses(context.Background(), root, []scanner.FileAnalysis{{ + Path: "main.cue", + Language: "cue", + Imports: []string{"example.com/acme/types"}, + }}, scanner.Filters{Only: []string{"cue"}}) + if err != nil { + t.Fatal(err) + } + if got, want := graph.Imports["main.cue"], []string{filepath.Join("types", "types.cue")}; !reflect.DeepEqual(got, want) { + t.Fatalf("graph imports = %v, want %v", got, want) + } +} + +func TestBuildFileGraphScansCUEFromFileInventory(t *testing.T) { + astScanner, err := scanner.NewAstGrepScanner() + if err != nil || !astScanner.Available() { + t.Skip("ast-grep not available") + } + astScanner.Close() + + root := t.TempDir() + writeCUE(t, root, "cue.mod/module.cue", "module: \"example.com/acme\"\n") + writeCUE(t, root, "main.cue", "package app\nimport \"example.com/acme/types\"\n") + writeCUE(t, root, "types/types.cue", "package types\n") + + graph, err := scanner.BuildFileGraph(context.Background(), root, scanner.Filters{Only: []string{"cue"}}) + if err != nil { + t.Fatal(err) + } + if got, want := graph.Imports["main.cue"], []string{filepath.Join("types", "types.cue")}; !reflect.DeepEqual(got, want) { + t.Fatalf("graph imports = %v, want %v", got, want) + } +} + +func writeCUE(t *testing.T, root, name, content string) { + t.Helper() + path := filepath.Join(root, filepath.FromSlash(name)) + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte(content), 0o644); err != nil { + t.Fatal(err) + } +} diff --git a/scanner/cue_test.go b/scanner/cue_test.go new file mode 100644 index 0000000..3fe495f --- /dev/null +++ b/scanner/cue_test.go @@ -0,0 +1,233 @@ +package scanner + +import ( + "context" + "os" + "path/filepath" + "reflect" + "testing" +) + +func TestCueImportsHandlesAliasesCommentsAndBlocks(t *testing.T) { + data := []byte(`package app + +// import "ignored" +import "example.com/acme/types" +import alias "example.com/acme/alias" +import ( + "example.com/acme/core" + other "example.com/acme/other" +) +message: "import \"not-an-import\"" +`) + want := []string{ + "example.com/acme/types", + "example.com/acme/alias", + "example.com/acme/core", + "example.com/acme/other", + } + if got := cueImports(data); !reflect.DeepEqual(got, want) { + t.Fatalf("cueImports() = %v, want %v", got, want) + } +} + +func TestCueImportsStopAtFirstDeclaration(t *testing.T) { + data := []byte(`package app +import "example.com/acme/real" +value: """ + import "example.com/acme/fake" +""" +snippet: ''' import "example.com/acme/fake2" ''' +`) + want := []string{"example.com/acme/real"} + if got := cueImports(data); !reflect.DeepEqual(got, want) { + t.Fatalf("cueImports() = %v, want %v", got, want) + } +} + +func TestCueImportsSkipsMalformedStrings(t *testing.T) { + data := []byte("import \"\\xZZ\"\nimport \"example.com/acme/ok\"\n") + want := []string{"example.com/acme/ok"} + if got := cueImports(data); !reflect.DeepEqual(got, want) { + t.Fatalf("cueImports() = %v, want %v", got, want) + } +} + +func TestScanCUEFilesReturnsEmptyForNonCUERepo(t *testing.T) { + root := t.TempDir() + if err := os.WriteFile(filepath.Join(root, "README.md"), []byte("not CUE\n"), 0o644); err != nil { + t.Fatal(err) + } + + outcome, err := scanCUEFiles(context.Background(), root, Filters{}) + if err != nil { + t.Fatal(err) + } + if outcome.Analyses != nil || outcome.Sources != nil { + t.Fatalf("non-CUE repository returned an outcome: %+v", outcome) + } +} + +func TestScanCUEFilesFromFilesUsesProvidedInventory(t *testing.T) { + root := t.TempDir() + writeCueFile(t, root, "listed.cue", "package listed\n") + writeCueFile(t, root, "unlisted.cue", "package unlisted\n") + + outcome, err := scanCUEFilesFromFiles(context.Background(), root, []FileInfo{{Path: "listed.cue"}}) + if err != nil { + t.Fatal(err) + } + if len(outcome.Analyses) != 1 || outcome.Analyses[0].Path != "listed.cue" { + t.Fatalf("provided CUE inventory = %#v, want listed.cue only", outcome.Analyses) + } +} + +func TestScanCUEFilesHonorsFilters(t *testing.T) { + root := t.TempDir() + writeCueFile(t, root, "keep.cue", "package keep\n") + writeCueFile(t, root, "vendor/drop.cue", "package drop\n") + + outcome, err := scanCUEFiles(context.Background(), root, Filters{Exclude: []string{"vendor"}}) + if err != nil { + t.Fatal(err) + } + if len(outcome.Analyses) != 1 || outcome.Analyses[0].Path != "keep.cue" { + t.Fatalf("filtered CUE analyses = %#v, want keep.cue only", outcome.Analyses) + } +} + +func TestScanCUEFilesReadsAllPackagesAndModule(t *testing.T) { + root := t.TempDir() + writeCueFile(t, root, "cue.mod/module.cue", "module: \"example.com/acme\"\n") + writeCueFile(t, root, "main.cue", "package app\nimport \"example.com/acme/types\"\n") + writeCueFile(t, root, "types/types.cue", "package types\nvalue: string\n") + + outcome, err := scanCUEFiles(context.Background(), root, Filters{}) + if err != nil { + t.Fatal(err) + } + if got := detectCUEModule(root); got != "example.com/acme" { + t.Fatalf("detectCUEModule() = %q", got) + } + if len(outcome.Analyses) != 3 || outcome.Sources[0].Name != "cue-imports" { + t.Fatalf("unexpected CUE outcome: %+v", outcome) + } + for _, analysis := range outcome.Analyses { + if analysis.Path == "main.cue" && analysis.Package != "app" { + t.Fatalf("main package = %q, want app", analysis.Package) + } + } +} + +func TestCueImportResolvesOnlyLocalModulePackage(t *testing.T) { + idx := buildFileIndex([]FileInfo{ + {Path: "main.cue"}, + {Path: filepath.Join("types", "types.cue")}, + {Path: filepath.Join("other", "other.cue")}, + }, "") + idx.cueModules = []cueModuleInfo{{path: "example.com/acme"}} + idx.cuePackages = map[string]string{"types/types.cue": "types"} + + if got := fuzzyResolve("example.com/acme/types", "main.cue", idx, "", nil, ""); !reflect.DeepEqual(got, []string{filepath.Join("types", "types.cue")}) { + t.Fatalf("local CUE import resolved to %v", got) + } + if got := fuzzyResolve("example.com/other/types", "main.cue", idx, "", nil, ""); len(got) != 0 { + t.Fatalf("external CUE import resolved to %v", got) + } +} + +func TestCueImportResolvesNearestNestedModule(t *testing.T) { + idx := buildFileIndex([]FileInfo{ + {Path: "outer/types/types.cue"}, + {Path: "inner/types/types.cue"}, + {Path: "inner/main.cue"}, + }, "") + idx.cueModules = []cueModuleInfo{ + {path: "example.com/inner", root: "inner"}, + {path: "example.com/outer", root: ""}, + } + idx.cuePackages = map[string]string{ + "outer/types/types.cue": "types", + "inner/types/types.cue": "types", + } + if got := fuzzyResolve("example.com/outer/types", "inner/main.cue", idx, "", nil, ""); len(got) != 0 { + t.Fatalf("nested CUE file crossed module boundary: %v", got) + } + if got := fuzzyResolve("example.com/inner/types", "inner/main.cue", idx, "", nil, ""); !reflect.DeepEqual(got, []string{"inner/types/types.cue"}) { + t.Fatalf("nested CUE import resolved to %v", got) + } +} + +func TestCueImportFiltersPackageSelector(t *testing.T) { + root := t.TempDir() + writeCueFile(t, root, "cue.mod/module.cue", "module: \"example.com/acme@v0\"\n") + writeCueFile(t, root, "main.cue", "package app\n") + writeCueFile(t, root, "templates/one.cue", "package one\n") + writeCueFile(t, root, "templates/default.cue", "package templates\n") + writeCueFile(t, root, "templates/two.cue", "package two\n") + + graph, err := BuildFileGraphFromAnalyses(context.Background(), root, []FileAnalysis{{ + Path: "main.cue", Language: "cue", Package: "app", Imports: []string{ + "example.com/acme/templates:one", "example.com/acme/templates", + }, + }}, Filters{}) + if err != nil { + t.Fatal(err) + } + want := []string{"templates/one.cue", "templates/default.cue"} + if got := graph.Imports["main.cue"]; !reflect.DeepEqual(got, want) { + t.Fatalf("selected CUE package = %v, want %v", got, want) + } +} + +func TestBuildFileGraphResolvesCUEPackageFromModule(t *testing.T) { + root := t.TempDir() + writeCueFile(t, root, "cue.mod/module.cue", "module: \"example.com/acme\"\n") + writeCueFile(t, root, "main.cue", "package app\n") + writeCueFile(t, root, "types/types.cue", "package types\n") + + graph, err := BuildFileGraphFromAnalyses(context.Background(), root, []FileAnalysis{{ + Path: "main.cue", + Language: "cue", + Imports: []string{"example.com/acme/types"}, + }}, Filters{}) + if err != nil { + t.Fatal(err) + } + want := []string{filepath.Join("types", "types.cue")} + if got := graph.Imports["main.cue"]; !reflect.DeepEqual(got, want) { + t.Fatalf("CUE graph imports = %v, want %v", got, want) + } +} + +func TestBuildFileGraphResolvesNestedCUEPackageFromModule(t *testing.T) { + root := t.TempDir() + moduleRoot := filepath.Join("deploy", "timoni", "modules", "hilfe") + writeCueFile(t, root, filepath.Join(moduleRoot, "cue.mod", "module.cue"), "module: \"timoni.sh/hilfe\"\n") + writeCueFile(t, root, filepath.Join(moduleRoot, "timoni.cue"), "package app\n") + writeCueFile(t, root, filepath.Join(moduleRoot, "templates", "admin.cue"), "package templates\n") + + graph, err := BuildFileGraphFromAnalyses(context.Background(), root, []FileAnalysis{{ + Path: filepath.Join(moduleRoot, "timoni.cue"), + Language: "cue", + Imports: []string{"timoni.sh/hilfe/templates"}, + }}, Filters{Only: []string{"cue"}}) + if err != nil { + t.Fatal(err) + } + want := []string{filepath.Join(moduleRoot, "templates", "admin.cue")} + if got := graph.Imports[filepath.Join(moduleRoot, "timoni.cue")]; !reflect.DeepEqual(got, want) { + t.Fatalf("nested CUE graph imports = %v, want %v", got, want) + } +} + +func writeCueFile(t *testing.T, root, name, content string) { + t.Helper() + path := filepath.Join(root, filepath.FromSlash(name)) + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte(content), 0o644); err != nil { + t.Fatal(err) + } +} diff --git a/scanner/filegraph.go b/scanner/filegraph.go index 315ac05..bb1acf0 100644 --- a/scanner/filegraph.go +++ b/scanner/filegraph.go @@ -26,10 +26,12 @@ type FileGraph struct { // fileIndex provides fast lookup of files by various import-like keys type fileIndex struct { - byExact map[string][]string // exact path -> files - bySuffix map[string][]string // path suffix -> files (for nested packages) - byDir map[string][]string // directory -> files in it - goPkgs map[string][]string // Go package path -> files + byExact map[string][]string // exact path -> files + bySuffix map[string][]string // path suffix -> files (for nested packages) + byDir map[string][]string // directory -> files in it + goPkgs map[string][]string // Go package path -> files + cueModules []cueModuleInfo + cuePackages map[string]string } // BuildFileGraph scans a project with explicit filters and builds its file @@ -38,7 +40,7 @@ type fileIndex struct { // and the graph is marked partial. func BuildFileGraph(ctx context.Context, root string, filters Filters) (*FileGraph, error) { return buildFileGraphWithFallbackWithFilters(ctx, root, filters, func(r string) (ScanOutcome, error) { - return ScanForDeps(ctx, r, filters) + return scanForDepsPrimaryOutcome(ctx, r) }, loadCargoFallbackMetadata) } @@ -121,6 +123,22 @@ func buildFileGraphFromAnalysesWithCargoMetadataAndFilters(ctx context.Context, } } } + if !hasCUEAnalyses(analyses) { + for _, file := range files { + if !strings.EqualFold(filepath.Ext(file.Path), ".cue") { + continue + } + cueOutcome, cueErr := scanCUEFilesFromFiles(ctx, absRoot, files) + if cueErr != nil { + return nil, cueErr + } + analyses = append(analyses, cueOutcome.Analyses...) + for _, source := range cueOutcome.Sources { + fg.Coverage.AddSource(source) + } + break + } + } rustWorkspace, cargoOutcome, err := buildRustWorkspaceIndex(ctx, absRoot, analyses, files, loader) if err != nil { return nil, err @@ -136,6 +154,23 @@ func buildFileGraphFromAnalysesWithCargoMetadataAndFilters(ctx context.Context, if err != nil { return nil, err } + idx.cueModules = detectCUEModulesWithFiles(absRoot, files) + idx.cuePackages = make(map[string]string) + for _, file := range files { + if DetectLanguage(file.Path) != "cue" { + continue + } + path := filepath.ToSlash(filepath.Clean(file.Path)) + data, readErr := os.ReadFile(filepath.Join(absRoot, filepath.FromSlash(path))) + if readErr == nil { + idx.cuePackages[path], _ = cueHeader(data) + } + } + for _, analysis := range analyses { + if analysis.Language == "cue" && analysis.Package != "" { + idx.cuePackages[filepath.ToSlash(filepath.Clean(analysis.Path))] = analysis.Package + } + } fg.Packages = idx.goPkgs for _, file := range files { if err := ctx.Err(); err != nil { @@ -343,6 +378,25 @@ func fuzzyResolveWithWorkspace( return dartResolver.resolve(imp, fromFile, idx) } + // CUE imports name packages, not individual files. Resolve only packages + // under a declared CUE module to avoid linking external modules by + // suffix coincidence. + if sourceLanguage == "cue" { + module, ok := nearestCUEModule(fromFile, idx.cueModules) + if !ok { + return nil + } + imp = strings.Trim(imp, "\"'`") + imp, selector := splitCUEImport(imp) + if imp != module.path && !strings.HasPrefix(imp, module.path+"/") { + return nil + } + packagePath := strings.TrimPrefix(strings.TrimPrefix(imp, module.path), "/") + packagePath = filepath.Join(module.root, filepath.FromSlash(packagePath)) + files := idx.byDir[packagePath] + return resolveCUEPackage(files, idx.cuePackages, selector) + } + // Normalize the import path normalized := normalizeImport(imp) @@ -383,6 +437,64 @@ func fuzzyResolveWithWorkspace( return nil } +func nearestCUEModule(fromFile string, modules []cueModuleInfo) (cueModuleInfo, bool) { + fromFile = filepath.ToSlash(filepath.Clean(fromFile)) + for _, module := range modules { + if module.root == "" || fromFile == module.root || strings.HasPrefix(fromFile, module.root+"/") { + return module, true + } + } + return cueModuleInfo{}, false +} + +func hasCUEAnalyses(analyses []FileAnalysis) bool { + for _, analysis := range analyses { + if analysis.Language == "cue" { + return true + } + } + return false +} + +func splitCUEImport(imp string) (string, string) { + separator := strings.LastIndex(imp, ":") + if separator <= strings.LastIndex(imp, "/") { + return imp, "" + } + return imp[:separator], imp[separator+1:] +} + +func resolveCUEPackage(files []string, packages map[string]string, selector string) []string { + files = compatibleFiles("cue", files) + if len(files) == 0 { + return nil + } + known := make(map[string]bool) + for _, file := range files { + if pkg := packages[file]; pkg != "" { + known[pkg] = true + } + } + if selector == "" && len(known) > 1 { + base := filepath.Base(filepath.Dir(files[0])) + if known[base] { + selector = base + } else { + return nil + } + } + if selector == "" || len(known) == 0 { + return files + } + resolved := make([]string, 0, len(files)) + for _, file := range files { + if packages[file] == selector { + resolved = append(resolved, file) + } + } + return resolved +} + func isLocalGoImport(imp, module string) bool { if module == "" { return false diff --git a/scanner/gofallback_test.go b/scanner/gofallback_test.go index bc03197..980f311 100644 --- a/scanner/gofallback_test.go +++ b/scanner/gofallback_test.go @@ -9,6 +9,8 @@ import ( "runtime" "strings" "testing" + + "codemap/analysis" ) func TestBuildGoFallbackOutcome(t *testing.T) { @@ -87,27 +89,29 @@ func TestScanForDepsOutcomeUsesGoFallbackWithoutAstGrep(t *testing.T) { root := t.TempDir() writeRustCargoFixture(t, root, map[string]string{ "cmd/main.go": "package main\n\nimport \"example.com/lib\"\n\nfunc main() {}\n", + "docs.cue": "package docs\nimport \"example.com/acme/templates\"\n", }) outcome, err := ScanForDeps(context.Background(), root, Filters{}) if err != nil { t.Fatal(err) } - want := []FileAnalysis{{ - Path: filepath.FromSlash("cmd/main.go"), - Language: "go", - Functions: []string{"main"}, - Imports: []string{"example.com/lib"}, - }} + want := []FileAnalysis{ + {Path: filepath.FromSlash("cmd/main.go"), Language: "go", Functions: []string{"main"}, Imports: []string{"example.com/lib"}}, + {Path: "docs.cue", Language: "cue", Package: "docs", Imports: []string{"example.com/acme/templates"}}, + } if !reflect.DeepEqual(outcome.Analyses, want) { t.Fatalf("analyses = %#v, want %#v", outcome.Analyses, want) } - if len(outcome.Sources) != 2 || + if len(outcome.Sources) != 3 || outcome.Sources[0].Name != "ast-grep" || outcome.Sources[0].Status != ScanSourceUnavailable || outcome.Sources[1].Name != "go-parser" || outcome.Sources[1].Status != ScanSourceFallback { - t.Fatalf("sources = %#v, want unavailable ast-grep followed by Go parser fallback", outcome.Sources) + t.Fatalf("sources = %#v, want unavailable ast-grep, Go fallback, and CUE source", outcome.Sources) + } + if outcome.Sources[2].Name != "cue-imports" || CoverageFromSources(outcome.Sources).Status != analysis.CoveragePartial { + t.Fatalf("sources = %#v, want CUE provenance with partial coverage", outcome.Sources) } } diff --git a/scanner/types.go b/scanner/types.go index 5aad78a..967cfab 100644 --- a/scanner/types.go +++ b/scanner/types.go @@ -38,6 +38,7 @@ type Project struct { type FileAnalysis struct { Path string `json:"path"` Language string `json:"language"` + Package string `json:"-"` Functions []string `json:"functions"` Imports []string `json:"imports"` References []ImportReference `json:"-"` @@ -60,6 +61,8 @@ type DepsProject struct { Files []FileAnalysis `json:"files"` ExternalDeps map[string][]string `json:"external_deps"` DiffRef string `json:"diff_ref,omitempty"` + // EffectiveFilters are the filters already applied by the caller. + EffectiveFilters *Filters `json:"-"` } // newDepsProject builds a DepsProject with default coverage derived from the @@ -103,6 +106,17 @@ func newDepsProject(root string, files []FileAnalysis, externalDeps map[string][ // NewDepsProjectWithCoverage builds a normalized DepsProject with caller-provided // provenance coverage, so degraded scans stay honest in the output. func NewDepsProjectWithCoverage(root string, files []FileAnalysis, externalDeps map[string][]string, diffRef string, coverage analysis.Coverage) DepsProject { + return newDepsProjectWithFilters(root, files, externalDeps, diffRef, coverage, nil) +} + +// NewDepsProjectWithCoverageAndFilters preserves the caller's graph filters +// for the text renderer without changing the JSON schema. +func NewDepsProjectWithCoverageAndFilters(root string, files []FileAnalysis, externalDeps map[string][]string, diffRef string, coverage analysis.Coverage, filters Filters) DepsProject { + copy := Filters{Only: slices.Clone(filters.Only), Exclude: slices.Clone(filters.Exclude)} + return newDepsProjectWithFilters(root, files, externalDeps, diffRef, coverage, ©) +} + +func newDepsProjectWithFilters(root string, files []FileAnalysis, externalDeps map[string][]string, diffRef string, coverage analysis.Coverage, effectiveFilters *Filters) DepsProject { files = slices.Clone(files) if files == nil { files = []FileAnalysis{} @@ -143,6 +157,7 @@ func NewDepsProjectWithCoverage(root string, files []FileAnalysis, externalDeps SchemaVersion: analysis.SchemaVersion, Coverage: analysis.NormalizeCoverage(coverage), Root: root, Mode: "deps", Files: files, ExternalDeps: deps, DiffRef: diffRef, + EffectiveFilters: effectiveFilters, } } @@ -191,6 +206,7 @@ var extToLang = map[string]string{ ".ex": "elixir", ".exs": "elixir", ".sol": "solidity", + ".cue": "cue", } // DetectLanguage returns the language name for a file path @@ -276,6 +292,7 @@ var resolverLanguageFamilies = map[string]string{ "elixir": "elixir", "solidity": "solidity", "bash": "bash", + "cue": "cue", } // languagesCompatible reports whether an import may resolve across the two @@ -307,6 +324,7 @@ var LangDisplay = map[string]string{ "scala": "Scala", "elixir": "Elixir", "solidity": "Solidity", + "cue": "CUE", } // dedupe removes duplicate strings from a slice diff --git a/scanner/types_test.go b/scanner/types_test.go new file mode 100644 index 0000000..0a85feb --- /dev/null +++ b/scanner/types_test.go @@ -0,0 +1,17 @@ +package scanner + +import ( + "testing" + + "codemap/analysis" +) + +func TestNewDepsProjectWithCoverageAndFiltersClonesEffectiveFilters(t *testing.T) { + filters := Filters{Only: []string{"cue"}, Exclude: []string{"vendor"}} + project := NewDepsProjectWithCoverageAndFilters("root", nil, nil, "", analysis.Coverage{}, filters) + filters.Only[0], filters.Exclude[0] = "go", "target" + + if project.EffectiveFilters == nil || project.EffectiveFilters.Only[0] != "cue" || project.EffectiveFilters.Exclude[0] != "vendor" { + t.Fatalf("effective filters were not cloned: %+v", project.EffectiveFilters) + } +} diff --git a/scanner/walker.go b/scanner/walker.go index 4286f61..72ea3d0 100644 --- a/scanner/walker.go +++ b/scanner/walker.go @@ -370,7 +370,16 @@ func ScanForDeps(ctx context.Context, root string, filters Filters) (ScanOutcome outcome, _, err := scanForGraphOutcomeWithFilters(ctx, root, filters, func(r string) (ScanOutcome, error) { return scanForDepsPrimaryOutcome(ctx, r) }, loadCargoFallbackMetadata, false) - return outcome, err + if err != nil { + return outcome, err + } + cueOutcome, err := scanCUEFiles(ctx, root, filters) + if err != nil { + return ScanOutcome{}, err + } + outcome.Analyses = append(outcome.Analyses, cueOutcome.Analyses...) + outcome.Sources = append(outcome.Sources, cueOutcome.Sources...) + return outcome, nil } func scanForDepsPrimaryOutcome(ctx context.Context, root string) (ScanOutcome, error) {