From c3f9e7ed799bcf98d055afca68f06d002cfd9575 Mon Sep 17 00:00:00 2001 From: Rene Leonhardt <65483435+reneleonhardt@users.noreply.github.com> Date: Sun, 23 Aug 2026 12:51:51 +0200 Subject: [PATCH 1/3] feat(scanner): Track CUE package dependency edges --- README.md | 2 +- main.go | 2 +- render/colors.go | 2 +- render/colors_test.go | 3 +- render/depgraph.go | 8 +- render/depgraph_test.go | 27 +++++++ render/skyline.go | 2 +- render/skyline_test.go | 3 +- scanner/cue.go | 137 ++++++++++++++++++++++++++++++++++ scanner/cue_external_test.go | 41 ++++++++++ scanner/cue_test.go | 141 +++++++++++++++++++++++++++++++++++ scanner/filegraph.go | 28 ++++++- scanner/types.go | 17 +++++ scanner/types_test.go | 17 +++++ scanner/walker.go | 16 +++- 15 files changed, 433 insertions(+), 13 deletions(-) create mode 100644 scanner/cue.go create mode 100644 scanner/cue_external_test.go create mode 100644 scanner/cue_test.go create mode 100644 scanner/types_test.go 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..f071dc2 100644 --- a/render/colors.go +++ b/render/colors.go @@ -54,7 +54,7 @@ func GetFileColor(ext string) string { case ext == ".md" || ext == ".txt" || ext == ".rst" || ext == ".adoc": return Green case ext == ".json" || ext == ".yaml" || ext == ".yml" || ext == ".toml" || - ext == ".xml" || ext == ".csv" || ext == ".ini" || ext == ".conf" || + ext == ".xml" || ext == ".csv" || ext == ".ini" || ext == ".conf" || ext == ".cue" || ext == ".env" || ext == ".rb" || ext == ".erb" || ext == ".gemspec": return Red case ext == ".sh" || ext == ".bat" || ext == ".ps1" || diff --git a/render/colors_test.go b/render/colors_test.go index e55e499..9a5d47b 100644 --- a/render/colors_test.go +++ b/render/colors_test.go @@ -34,6 +34,7 @@ func TestGetFileColor(t *testing.T) { {".yaml", Red}, {".yml", Red}, {".toml", Red}, + {".cue", Red}, {".xml", Red}, {".rb", Red}, // Shell scripts @@ -120,7 +121,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..88d0694 --- /dev/null +++ b/scanner/cue.go @@ -0,0 +1,137 @@ +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) (ScanOutcome, error) { + files, err := ScanFiles(ctx, root, NewGitIgnoreCache(root), nil, nil) + if err != nil { + return ScanOutcome{}, err + } + + 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 + } + analyses = append(analyses, FileAnalysis{ + Path: path, + Language: "cue", + Imports: cueImports(data), + }) + } + 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 { + s := newCUEScanner(data) + + var imports []string + for token := s.Scan(); token != textscanner.EOF; token = s.Scan() { + if token != textscanner.Ident || s.TokenText() != "import" { + continue + } + 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) + } + } + } + continue + } + 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 dedupe(imports) +} + +func detectCUEModule(root string) string { + return readCUEModule(filepath.Join(root, "cue.mod", "module.cue")) +} + +func detectCUEModuleWithFiles(root string, files []FileInfo) (string, string) { + paths := []string{"cue.mod/module.cue"} + for _, file := range files { + path := filepath.Clean(file.Path) + if filepath.Base(path) == "module.cue" && filepath.Base(filepath.Dir(path)) == "cue.mod" { + paths = append(paths, path) + } + } + sort.Strings(paths) + for _, path := range paths { + if module := readCUEModule(filepath.Join(root, path)); module != "" { + return module, filepath.Dir(filepath.Dir(path)) + } + } + return "", "" +} + +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 module + } + } + return "" +} + +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..2ff92de --- /dev/null +++ b/scanner/cue_external_test.go @@ -0,0 +1,41 @@ +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 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..d03110d --- /dev/null +++ b/scanner/cue_test.go @@ -0,0 +1,141 @@ +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 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) + if err != nil { + t.Fatal(err) + } + if outcome.Analyses != nil || outcome.Sources != nil { + t.Fatalf("non-CUE repository returned an outcome: %+v", outcome) + } +} + +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) + 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) + } +} + +func TestCueImportResolvesOnlyLocalModulePackage(t *testing.T) { + idx := buildFileIndex([]FileInfo{ + {Path: "main.cue"}, + {Path: filepath.Join("types", "types.cue")}, + {Path: filepath.Join("other", "other.cue")}, + }, "") + idx.cueModule = "example.com/acme" + + 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 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..f38541b 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 + cueModule string // CUE module path from cue.mod/module.cue + cueRoot string // repository-relative root of the CUE module } // BuildFileGraph scans a project with explicit filters and builds its file @@ -136,6 +138,7 @@ func buildFileGraphFromAnalysesWithCargoMetadataAndFilters(ctx context.Context, if err != nil { return nil, err } + idx.cueModule, idx.cueRoot = detectCUEModuleWithFiles(absRoot, files) fg.Packages = idx.goPkgs for _, file := range files { if err := ctx.Err(); err != nil { @@ -343,6 +346,23 @@ func fuzzyResolveWithWorkspace( return dartResolver.resolve(imp, fromFile, idx) } + // CUE imports name packages, not individual files. Resolve only packages + // under the project's declared module to avoid linking external modules by + // suffix coincidence. + if sourceLanguage == "cue" { + if idx.cueModule == "" { + return nil + } + imp = strings.Trim(imp, "\"'`") + if imp != idx.cueModule && !strings.HasPrefix(imp, idx.cueModule+"/") { + return nil + } + packagePath := strings.TrimPrefix(strings.TrimPrefix(imp, idx.cueModule), "/") + packagePath = filepath.Join(idx.cueRoot, filepath.FromSlash(packagePath)) + files := idx.byDir[filepath.FromSlash(packagePath)] + return compatibleFiles(sourceLanguage, files) + } + // Normalize the import path normalized := normalizeImport(imp) diff --git a/scanner/types.go b/scanner/types.go index 5aad78a..2d33b25 100644 --- a/scanner/types.go +++ b/scanner/types.go @@ -60,6 +60,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 +105,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 +156,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 +205,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 +291,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 +323,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..55aa856 100644 --- a/scanner/walker.go +++ b/scanner/walker.go @@ -3,6 +3,7 @@ package scanner import ( "bufio" "context" + "errors" "os" "path/filepath" "strings" @@ -377,10 +378,23 @@ func scanForDepsPrimaryOutcome(ctx context.Context, root string) (ScanOutcome, e if err := ctx.Err(); err != nil { return ScanOutcome{}, err } + cueOutcome, err := scanCUEFiles(ctx, root) + if err != nil { + return ScanOutcome{}, err + } astScanner, err := NewAstGrepScanner() if err != nil { return ScanOutcome{}, err } defer astScanner.Close() - return astScanner.ScanDirectory(ctx, root) + astOutcome, err := astScanner.ScanDirectory(ctx, root) + if err != nil { + if len(cueOutcome.Sources) > 0 && errors.Is(err, ErrAstGrepNotFound) { + return cueOutcome, nil + } + return ScanOutcome{}, err + } + astOutcome.Analyses = append(astOutcome.Analyses, cueOutcome.Analyses...) + astOutcome.Sources = append(astOutcome.Sources, cueOutcome.Sources...) + return astOutcome, nil } From e6f7f9e20f18ecad3c37d55ba10fbd2242a9d7fb Mon Sep 17 00:00:00 2001 From: Rene Leonhardt <65483435+reneleonhardt@users.noreply.github.com> Date: Thu, 27 Aug 2026 08:19:22 +0200 Subject: [PATCH 2/3] fix(scanner): harden CUE dependency analysis --- render/colors.go | 4 +- render/colors_test.go | 3 +- scanner/cue.go | 129 ++++++++++++++++++++++++++++--------- scanner/cue_test.go | 84 +++++++++++++++++++++++- scanner/filegraph.go | 95 +++++++++++++++++++++++---- scanner/gofallback_test.go | 20 +++--- scanner/types.go | 1 + scanner/walker.go | 27 ++++---- 8 files changed, 287 insertions(+), 76 deletions(-) diff --git a/render/colors.go b/render/colors.go index f071dc2..10e7c19 100644 --- a/render/colors.go +++ b/render/colors.go @@ -54,14 +54,14 @@ func GetFileColor(ext string) string { case ext == ".md" || ext == ".txt" || ext == ".rst" || ext == ".adoc": return Green case ext == ".json" || ext == ".yaml" || ext == ".yml" || ext == ".toml" || - ext == ".xml" || ext == ".csv" || ext == ".ini" || ext == ".conf" || ext == ".cue" || + ext == ".xml" || ext == ".csv" || ext == ".ini" || ext == ".conf" || ext == ".env" || ext == ".rb" || ext == ".erb" || ext == ".gemspec": return Red case ext == ".sh" || ext == ".bat" || ext == ".ps1" || 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 9a5d47b..21aa4b9 100644 --- a/render/colors_test.go +++ b/render/colors_test.go @@ -34,9 +34,10 @@ func TestGetFileColor(t *testing.T) { {".yaml", Red}, {".yml", Red}, {".toml", Red}, - {".cue", Red}, {".xml", Red}, {".rb", Red}, + // CUE + {".cue", BoldRed}, // Shell scripts {".sh", BoldWhite}, {".bat", BoldWhite}, diff --git a/scanner/cue.go b/scanner/cue.go index 88d0694..8a9352e 100644 --- a/scanner/cue.go +++ b/scanner/cue.go @@ -14,8 +14,8 @@ import ( // 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) (ScanOutcome, error) { - files, err := ScanFiles(ctx, root, NewGitIgnoreCache(root), nil, nil) +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 } @@ -35,10 +35,12 @@ func scanCUEFiles(ctx context.Context, root string) (ScanOutcome, error) { if err != nil { continue } + pkg, imports := cueHeader(data) analyses = append(analyses, FileAnalysis{ Path: path, Language: "cue", - Imports: cueImports(data), + Package: pkg, + Imports: imports, }) } if cueFiles == 0 { @@ -55,55 +57,104 @@ func scanCUEFiles(ctx context.Context, root string) (ScanOutcome, error) { } 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; token = s.Scan() { - if token != textscanner.Ident || s.TokenText() != "import" { - continue + for token := s.Scan(); token != textscanner.EOF; { + if token != textscanner.Ident { + break } - 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) - } - } + switch s.TokenText() { + case "package": + if s.Scan() == textscanner.Ident { + pkg = s.TokenText() } - continue + token = skipCUEHeaderLine(s) + case "import": + token = scanCUEImport(s, &imports) + default: + return pkg, dedupe(imports) } - if token == textscanner.Ident { - token = s.Scan() + } + 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 } - if token == textscanner.String { - if path, err := strconv.Unquote(s.TokenText()); err == nil && path != "" { - imports = append(imports, path) + } + 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 dedupe(imports) + return s.Scan() } func detectCUEModule(root string) string { return readCUEModule(filepath.Join(root, "cue.mod", "module.cue")) } -func detectCUEModuleWithFiles(root string, files []FileInfo) (string, string) { - paths := []string{"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.Clean(file.Path) - if filepath.Base(path) == "module.cue" && filepath.Base(filepath.Dir(path)) == "cue.mod" { - paths = append(paths, path) + path := filepath.ToSlash(filepath.Clean(file.Path)) + if path == "cue.mod/module.cue" || strings.HasSuffix(path, "/cue.mod/module.cue") { + paths[path] = true } } - sort.Strings(paths) - for _, path := range paths { - if module := readCUEModule(filepath.Join(root, path)); module != "" { - return module, filepath.Dir(filepath.Dir(path)) + 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}) } - return "", "" + 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 { @@ -122,12 +173,26 @@ func readCUEModule(path string) string { } module, err := strconv.Unquote(s.TokenText()) if err == nil { - return module + 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))) diff --git a/scanner/cue_test.go b/scanner/cue_test.go index d03110d..f08ea81 100644 --- a/scanner/cue_test.go +++ b/scanner/cue_test.go @@ -31,6 +31,20 @@ message: "import \"not-an-import\"" } } +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"} @@ -45,7 +59,7 @@ func TestScanCUEFilesReturnsEmptyForNonCUERepo(t *testing.T) { t.Fatal(err) } - outcome, err := scanCUEFiles(context.Background(), root) + outcome, err := scanCUEFiles(context.Background(), root, Filters{}) if err != nil { t.Fatal(err) } @@ -54,13 +68,27 @@ func TestScanCUEFilesReturnsEmptyForNonCUERepo(t *testing.T) { } } +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) + outcome, err := scanCUEFiles(context.Background(), root, Filters{}) if err != nil { t.Fatal(err) } @@ -70,6 +98,11 @@ func TestScanCUEFilesReadsAllPackagesAndModule(t *testing.T) { 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) { @@ -78,7 +111,8 @@ func TestCueImportResolvesOnlyLocalModulePackage(t *testing.T) { {Path: filepath.Join("types", "types.cue")}, {Path: filepath.Join("other", "other.cue")}, }, "") - idx.cueModule = "example.com/acme" + 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) @@ -88,6 +122,50 @@ func TestCueImportResolvesOnlyLocalModulePackage(t *testing.T) { } } +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") diff --git a/scanner/filegraph.go b/scanner/filegraph.go index f38541b..8caa41c 100644 --- a/scanner/filegraph.go +++ b/scanner/filegraph.go @@ -26,12 +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 - cueModule string // CUE module path from cue.mod/module.cue - cueRoot string // repository-relative root of the CUE module + 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 @@ -138,7 +138,23 @@ func buildFileGraphFromAnalysesWithCargoMetadataAndFilters(ctx context.Context, if err != nil { return nil, err } - idx.cueModule, idx.cueRoot = detectCUEModuleWithFiles(absRoot, files) + 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 { @@ -347,20 +363,22 @@ func fuzzyResolveWithWorkspace( } // CUE imports name packages, not individual files. Resolve only packages - // under the project's declared module to avoid linking external modules by + // under a declared CUE module to avoid linking external modules by // suffix coincidence. if sourceLanguage == "cue" { - if idx.cueModule == "" { + module, ok := nearestCUEModule(fromFile, idx.cueModules) + if !ok { return nil } imp = strings.Trim(imp, "\"'`") - if imp != idx.cueModule && !strings.HasPrefix(imp, idx.cueModule+"/") { + imp, selector := splitCUEImport(imp) + if imp != module.path && !strings.HasPrefix(imp, module.path+"/") { return nil } - packagePath := strings.TrimPrefix(strings.TrimPrefix(imp, idx.cueModule), "/") - packagePath = filepath.Join(idx.cueRoot, filepath.FromSlash(packagePath)) - files := idx.byDir[filepath.FromSlash(packagePath)] - return compatibleFiles(sourceLanguage, files) + 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 @@ -403,6 +421,55 @@ 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 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 2d33b25..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:"-"` diff --git a/scanner/walker.go b/scanner/walker.go index 55aa856..72ea3d0 100644 --- a/scanner/walker.go +++ b/scanner/walker.go @@ -3,7 +3,6 @@ package scanner import ( "bufio" "context" - "errors" "os" "path/filepath" "strings" @@ -371,30 +370,26 @@ 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) { if err := ctx.Err(); err != nil { return ScanOutcome{}, err } - cueOutcome, err := scanCUEFiles(ctx, root) - if err != nil { - return ScanOutcome{}, err - } astScanner, err := NewAstGrepScanner() if err != nil { return ScanOutcome{}, err } defer astScanner.Close() - astOutcome, err := astScanner.ScanDirectory(ctx, root) - if err != nil { - if len(cueOutcome.Sources) > 0 && errors.Is(err, ErrAstGrepNotFound) { - return cueOutcome, nil - } - return ScanOutcome{}, err - } - astOutcome.Analyses = append(astOutcome.Analyses, cueOutcome.Analyses...) - astOutcome.Sources = append(astOutcome.Sources, cueOutcome.Sources...) - return astOutcome, nil + return astScanner.ScanDirectory(ctx, root) } From d5521018c57e118f753fa2bf47a90a491408e5ac Mon Sep 17 00:00:00 2001 From: Rene Leonhardt <65483435+reneleonhardt@users.noreply.github.com> Date: Thu, 27 Aug 2026 08:34:26 +0200 Subject: [PATCH 3/3] perf(scanner): reuse CUE file inventory --- scanner/cue.go | 3 +++ scanner/cue_external_test.go | 21 +++++++++++++++++++++ scanner/cue_test.go | 14 ++++++++++++++ scanner/filegraph.go | 27 ++++++++++++++++++++++++++- 4 files changed, 64 insertions(+), 1 deletion(-) diff --git a/scanner/cue.go b/scanner/cue.go index 8a9352e..d3b1705 100644 --- a/scanner/cue.go +++ b/scanner/cue.go @@ -19,7 +19,10 @@ func scanCUEFiles(ctx context.Context, root string, filters Filters) (ScanOutcom 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 { diff --git a/scanner/cue_external_test.go b/scanner/cue_external_test.go index 2ff92de..5e9f22d 100644 --- a/scanner/cue_external_test.go +++ b/scanner/cue_external_test.go @@ -29,6 +29,27 @@ func TestBuildFileGraphFromAnalysesResolvesCUEPackage(t *testing.T) { } } +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)) diff --git a/scanner/cue_test.go b/scanner/cue_test.go index f08ea81..3fe495f 100644 --- a/scanner/cue_test.go +++ b/scanner/cue_test.go @@ -68,6 +68,20 @@ func TestScanCUEFilesReturnsEmptyForNonCUERepo(t *testing.T) { } } +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") diff --git a/scanner/filegraph.go b/scanner/filegraph.go index 8caa41c..bb1acf0 100644 --- a/scanner/filegraph.go +++ b/scanner/filegraph.go @@ -40,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) } @@ -123,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 @@ -431,6 +447,15 @@ func nearestCUEModule(fromFile string, modules []cueModuleInfo) (cueModuleInfo, 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, "/") {