Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
2 changes: 1 addition & 1 deletion main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
2 changes: 1 addition & 1 deletion render/colors.go
Original file line number Diff line number Diff line change
Expand Up @@ -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":
Expand Down
4 changes: 3 additions & 1 deletion render/colors_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,8 @@ func TestGetFileColor(t *testing.T) {
{".toml", Red},
{".xml", Red},
{".rb", Red},
// CUE
{".cue", BoldRed},
// Shell scripts
{".sh", BoldWhite},
{".bat", BoldWhite},
Expand Down Expand Up @@ -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 {
Expand Down
8 changes: 6 additions & 2 deletions render/depgraph.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down
27 changes: 27 additions & 0 deletions render/depgraph_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"},
},
Expand All @@ -94,6 +95,7 @@ func TestDepgraphRendersExternalDepsAndSummarySection(t *testing.T) {
expectedSnippets := []string{
"Dependency Flow",
"Go: module, pkg",
"CUE: schema",
"JavaScript: react",
"Dart: flutter, riverpod",
"Src",
Expand Down Expand Up @@ -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) {
Expand Down
2 changes: 1 addition & 1 deletion render/skyline.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}

Expand Down
3 changes: 2 additions & 1 deletion render/skyline_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
205 changes: 205 additions & 0 deletions scanner/cue.go
Original file line number Diff line number Diff line change
@@ -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
}
Loading
Loading