diff --git a/README.md b/README.md index 6e80578..92c7cb5 100644 --- a/README.md +++ b/README.md @@ -142,7 +142,7 @@ The JSON payload is versioned (`schema_version: codemap.analysis/v1`) so consume ### Supported languages -20 language rules for dependency analysis: Go, Python, JavaScript, JSX, TypeScript, TSX, Rust, Ruby, C, C++, Java, Swift, Kotlin, C#, PHP, Bash, Lua, Scala, Elixir, Solidity. +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. > Powered by [ast-grep](https://ast-grep.github.io/). Installed automatically with the Homebrew formula. diff --git a/render/depgraph.go b/render/depgraph.go index a992f96..4135c49 100644 --- a/render/depgraph.go +++ b/render/depgraph.go @@ -170,7 +170,7 @@ func Depgraph(ctx context.Context, w io.Writer, project scanner.DepsProject) { // Format dep lines var depLines []string - langOrder := []string{"go", "javascript", "python", "swift", "rust", "ruby", "bash", "kotlin", "csharp", "php", "lua", "scala", "elixir", "solidity"} + langOrder := []string{"go", "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 7b2db56..8bc17c0 100644 --- a/render/depgraph_test.go +++ b/render/depgraph_test.go @@ -83,6 +83,7 @@ func TestDepgraphRendersExternalDepsAndSummarySection(t *testing.T) { ExternalDeps: map[string][]string{ "go": {"github.com/acme/module/v2", "github.com/acme/pkg", "github.com/acme/pkg"}, "javascript": {"react", "react"}, + "dart": {"flutter", "riverpod"}, }, } @@ -94,6 +95,7 @@ func TestDepgraphRendersExternalDepsAndSummarySection(t *testing.T) { "Dependency Flow", "Go: module, pkg", "JavaScript: react", + "Dart: flutter, riverpod", "Src", "+1 standalone files", "1 files", diff --git a/scanner/astgrep.go b/scanner/astgrep.go index b5ef71a..a6fd81d 100644 --- a/scanner/astgrep.go +++ b/scanner/astgrep.go @@ -535,6 +535,7 @@ var ruleIDToLang = map[string]string{ "js": "javascript", "jsx": "javascript", "py": "python", "rust": "rust", "java": "java", "ruby": "ruby", "swift": "swift", "kotlin": "kotlin", "c": "c", "cpp": "cpp", + "dart": "dart", "bash": "bash", "csharp": "csharp", "php": "php", "lua": "lua", "scala": "scala", "elixir": "elixir", "solidity": "solidity", @@ -748,6 +749,22 @@ func extractFunctionName(text string, lang string) string { } } + case "dart": + // Dart top-level functions and class methods. + if paren := strings.Index(text, "("); paren > 0 { + before := strings.TrimSpace(text[:paren]) + parts := strings.Fields(before) + if len(parts) > 0 { + name := parts[len(parts)-1] + if bracket := strings.Index(name, "<"); bracket > 0 { + name = name[:bracket] + } + if isValidIdentifier(name) { + return name + } + } + } + case "c", "cpp": // type name(...) - find last identifier before ( if paren := strings.Index(text, "("); paren > 0 { diff --git a/scanner/astgrep_test.go b/scanner/astgrep_test.go index 6d4fe51..631b3af 100644 --- a/scanner/astgrep_test.go +++ b/scanner/astgrep_test.go @@ -501,3 +501,71 @@ func TestScanDirectoryUsesCmdShimBinary(t *testing.T) { t.Fatalf("expected authoritative source status, got %v", outcome.Sources[0].Status) } } + +func TestAstGrepDartFlutter(t *testing.T) { + analyzer := NewAstGrepAnalyzer() + if !analyzer.Available() { + t.Skip("ast-grep not available") + } + + tmpDir := t.TempDir() + dartFile := filepath.Join(tmpDir, "main.dart") + source := `import 'dart:async'; +import 'package:flutter/material.dart'; +import 'src/platform_stub.dart' + if (dart.library.io) 'src/platform_io.dart'; +export 'src/routes.dart'; +part 'main.g.dart'; + +T identity(T value) => value; + +void main() {} + +class App extends StatelessWidget { + Widget build(BuildContext context) { + return const SizedBox(); + } +} +` + if err := os.WriteFile(dartFile, []byte(source), 0o644); err != nil { + t.Fatal(err) + } + + got, err := analyzer.AnalyzeFile(dartFile) + if err != nil { + t.Fatalf("AnalyzeFile() error: %v", err) + } + if got == nil { + t.Fatal("AnalyzeFile() returned nil") + } + if got.Language != "dart" { + t.Fatalf("language = %q, want dart", got.Language) + } + + functions := make(map[string]bool) + for _, name := range got.Functions { + functions[name] = true + } + for _, want := range []string{"main", "identity", "build"} { + if !functions[want] { + t.Errorf("functions = %#v, missing %q", got.Functions, want) + } + } + + imports := make(map[string]bool) + for _, path := range got.Imports { + imports[path] = true + } + for _, want := range []string{ + "dart:async", + "package:flutter/material.dart", + "src/platform_stub.dart", + "src/platform_io.dart", + "src/routes.dart", + "main.g.dart", + } { + if !imports[want] { + t.Errorf("imports = %#v, missing %q", got.Imports, want) + } + } +} diff --git a/scanner/dartworkspace.go b/scanner/dartworkspace.go new file mode 100644 index 0000000..dc41040 --- /dev/null +++ b/scanner/dartworkspace.go @@ -0,0 +1,69 @@ +package scanner + +import ( + "context" + "os" + "path/filepath" + "strings" +) + +type dartWorkspaceResolver struct { + packageRoots map[string][]string +} + +func buildDartWorkspaceResolver(ctx context.Context, root string, files []FileInfo) (*dartWorkspaceResolver, error) { + resolver := &dartWorkspaceResolver{packageRoots: make(map[string][]string)} + for _, file := range files { + if err := ctx.Err(); err != nil { + return nil, err + } + if filepath.Base(file.Path) != "pubspec.yaml" { + continue + } + + content, err := os.ReadFile(filepath.Join(root, file.Path)) + if err != nil { + continue + } + manifest, err := decodePubspec(content) + if err != nil || manifest.Name == "" { + continue + } + + packageRoot := filepath.Dir(file.Path) + if packageRoot == "." { + packageRoot = "" + } + resolver.packageRoots[manifest.Name] = append(resolver.packageRoots[manifest.Name], packageRoot) + } + return resolver, nil +} + +func (r *dartWorkspaceResolver) resolve(imp, fromFile string, idx *fileIndex) []string { + if r == nil { + return nil + } + + uri := strings.Trim(strings.TrimSpace(imp), "\"'`") + if packageURI, ok := strings.CutPrefix(uri, "package:"); ok { + parts := strings.SplitN(packageURI, "/", 2) + if len(parts) != 2 || parts[0] == "" || parts[1] == "" { + return nil + } + roots := r.packageRoots[parts[0]] + if len(roots) != 1 { + return nil + } + candidate := filepath.Join(roots[0], "lib", filepath.FromSlash(parts[1])) + return tryExactMatch(candidate, idx, "dart") + } + + if uri == "" || strings.Contains(uri, ":") || filepath.IsAbs(uri) { + return nil + } + fromDir := filepath.Dir(fromFile) + if fromDir == "." { + fromDir = "" + } + return tryExactMatch(filepath.Join(fromDir, filepath.FromSlash(uri)), idx, "dart") +} diff --git a/scanner/dartworkspace_test.go b/scanner/dartworkspace_test.go new file mode 100644 index 0000000..22b3527 --- /dev/null +++ b/scanner/dartworkspace_test.go @@ -0,0 +1,93 @@ +package scanner + +import ( + "context" + "os" + "path/filepath" + "reflect" + "sort" + "testing" +) + +func TestDartWorkspaceResolvesPackageAndRelativeImports(t *testing.T) { + root := t.TempDir() + files := map[string]string{ + "pubspec.yaml": "name: app\ndependencies:\n flutter:\n sdk: flutter\n", + "lib/main.dart": "", + "lib/src/shared.dart": "", + "lib/src/widget.dart": "", + "other/src/shared.dart": "", + "packages/design/pubspec.yaml": "name: design_system\n", + "packages/design/lib/button.dart": "", + } + for path, content := range files { + fullPath := filepath.Join(root, filepath.FromSlash(path)) + if err := os.MkdirAll(filepath.Dir(fullPath), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(fullPath, []byte(content), 0o644); err != nil { + t.Fatal(err) + } + } + + analyses := []FileAnalysis{{ + Path: filepath.FromSlash("lib/main.dart"), + Imports: []string{ + "package:app/src/widget.dart", + "src/shared.dart", + "package:design_system/button.dart", + "package:flutter/material.dart", + "dart:async", + }, + }} + graph, err := BuildFileGraphFromAnalyses(context.Background(), root, analyses, Filters{Only: []string{"dart"}}) + if err != nil { + t.Fatal(err) + } + + got := append([]string(nil), graph.Imports[filepath.FromSlash("lib/main.dart")]...) + sort.Strings(got) + want := []string{ + filepath.FromSlash("lib/src/shared.dart"), + filepath.FromSlash("lib/src/widget.dart"), + filepath.FromSlash("packages/design/lib/button.dart"), + } + sort.Strings(want) + if !reflect.DeepEqual(got, want) { + t.Fatalf("Dart imports = %#v, want local package and relative targets %#v", got, want) + } +} + +func TestDartWorkspaceRejectsAmbiguousPackageNames(t *testing.T) { + root := t.TempDir() + for _, path := range []string{ + "lib/main.dart", + "packages/one/lib/api.dart", + "packages/two/lib/api.dart", + } { + fullPath := filepath.Join(root, filepath.FromSlash(path)) + if err := os.MkdirAll(filepath.Dir(fullPath), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(fullPath, nil, 0o644); err != nil { + t.Fatal(err) + } + } + for _, path := range []string{"packages/one/pubspec.yaml", "packages/two/pubspec.yaml"} { + if err := os.WriteFile(filepath.Join(root, filepath.FromSlash(path)), []byte("name: duplicate\n"), 0o644); err != nil { + t.Fatal(err) + } + } + + graph, err := BuildFileGraphFromAnalyses(context.Background(), root, []FileAnalysis{{ + Path: filepath.FromSlash("lib/main.dart"), + Language: "dart", + Imports: []string{"package:duplicate/api.dart"}, + }}, Filters{Only: []string{"dart"}}) + if err != nil { + t.Fatal(err) + } + if got := graph.Imports[filepath.FromSlash("lib/main.dart")]; len(got) != 0 { + t.Fatalf("ambiguous Dart package import resolved to %#v", got) + } +} diff --git a/scanner/deps.go b/scanner/deps.go index e111838..5ea2777 100644 --- a/scanner/deps.go +++ b/scanner/deps.go @@ -6,7 +6,10 @@ import ( "io" "os" "path/filepath" + "sort" "strings" + + "gopkg.in/yaml.v3" ) // MCPManifestByteBudget bounds each manifest read performed for one MCP request. @@ -14,7 +17,7 @@ const MCPManifestByteBudget int64 = 1 << 20 var errManifestBudgetExceeded = errors.New("manifest exceeds byte budget") -// ReadExternalDeps reads manifest files (go.mod, requirements.txt, package.json) +// ReadExternalDeps reads supported dependency manifests throughout the project // while honoring caller cancellation. A positive manifestByteBudget skips // individual oversized manifests; zero keeps the legacy unbounded behavior // used by CLI and blast-radius callers. @@ -62,6 +65,8 @@ func ReadExternalDeps(ctx context.Context, root string, manifestByteBudget int64 deps["swift"] = append(deps["swift"], parsePodfile(string(content))...) case "Package.swift": deps["swift"] = append(deps["swift"], parsePackageSwift(string(content))...) + case "pubspec.yaml": + deps["dart"] = append(deps["dart"], parsePubspec(string(content))...) case "packages.config": deps["csharp"] = append(deps["csharp"], parsePackagesConfig(string(content))...) default: @@ -86,7 +91,7 @@ func ReadExternalDeps(ctx context.Context, root string, manifestByteBudget int64 func isDependencyManifest(name string) bool { switch name { - case "go.mod", "requirements.txt", "package.json", "Podfile", "Package.swift", "packages.config": + case "go.mod", "requirements.txt", "package.json", "Podfile", "Package.swift", "packages.config", "pubspec.yaml": return true default: return strings.HasSuffix(name, ".csproj") @@ -134,6 +139,40 @@ func budgetCapacity(budget int64) int64 { return budget } +type pubspecManifest struct { + Name string `yaml:"name"` + Dependencies map[string]any `yaml:"dependencies"` + DevDependencies map[string]any `yaml:"dev_dependencies"` +} + +func decodePubspec(content []byte) (pubspecManifest, error) { + var manifest pubspecManifest + err := yaml.Unmarshal(content, &manifest) + return manifest, err +} + +func parsePubspec(content string) []string { + manifest, err := decodePubspec([]byte(content)) + if err != nil { + return nil + } + + dependencySet := make(map[string]struct{}, len(manifest.Dependencies)+len(manifest.DevDependencies)) + for name := range manifest.Dependencies { + dependencySet[name] = struct{}{} + } + for name := range manifest.DevDependencies { + dependencySet[name] = struct{}{} + } + + dependencies := make([]string, 0, len(dependencySet)) + for name := range dependencySet { + dependencies = append(dependencies, name) + } + sort.Strings(dependencies) + return dependencies +} + func parseGoMod(c string) (deps []string) { inReq := false for _, line := range strings.Split(c, "\n") { diff --git a/scanner/deps_test.go b/scanner/deps_test.go index 51cc823..0f1f586 100644 --- a/scanner/deps_test.go +++ b/scanner/deps_test.go @@ -912,3 +912,47 @@ func TestReadExternalDepsPackagesConfig(t *testing.T) { t.Errorf("Expected csharp deps %v, got %v", expected, csDeps) } } + +func TestParsePubspecFlutterDependencies(t *testing.T) { + pubspec := `name: example_app +environment: + sdk: ^3.8.0 +dependencies: + flutter: + sdk: flutter + provider: ^6.1.0 +dev_dependencies: + flutter_test: + sdk: flutter + provider: ^6.1.0 +` + + got := parsePubspec(pubspec) + want := []string{"flutter", "flutter_test", "provider"} + if !reflect.DeepEqual(got, want) { + t.Fatalf("parsePubspec() = %#v, want %#v", got, want) + } +} + +func TestReadExternalDepsFindsPubspec(t *testing.T) { + root := t.TempDir() + pubspec := "name: example_app\ndependencies:\n flutter:\n sdk: flutter\n riverpod: ^3.0.0\n" + if err := os.WriteFile(filepath.Join(root, "pubspec.yaml"), []byte(pubspec), 0o644); err != nil { + t.Fatal(err) + } + + deps, err := ReadExternalDeps(context.Background(), root, 0) + if err != nil { + t.Fatal(err) + } + want := []string{"flutter", "riverpod"} + if !reflect.DeepEqual(deps["dart"], want) { + t.Fatalf("Dart dependencies = %#v, want %#v", deps["dart"], want) + } +} + +func TestParsePubspecRejectsMalformedYAML(t *testing.T) { + if got := parsePubspec("dependencies: ["); got != nil { + t.Fatalf("parsePubspec() = %#v, want nil for malformed YAML", got) + } +} diff --git a/scanner/filegraph.go b/scanner/filegraph.go index e183fc6..cc76381 100644 --- a/scanner/filegraph.go +++ b/scanner/filegraph.go @@ -102,9 +102,10 @@ func buildFileGraphFromAnalysesWithCargoMetadataAndFilters(ctx context.Context, fg.PathAliases, fg.BaseURL = detectPathAliases(absRoot) useJSWorkspace := needsJSWorkspaceResolver(analyses) + useDartWorkspace := needsDartWorkspaceResolver(analyses) gitCache := NewGitIgnoreCache(root) scanOnly := filters.Only - if useJSWorkspace { + if useJSWorkspace || useDartWorkspace { scanOnly = nil } allFiles, err := ScanFiles(ctx, root, gitCache, scanOnly, filters.Exclude) @@ -112,7 +113,7 @@ func buildFileGraphFromAnalysesWithCargoMetadataAndFilters(ctx context.Context, return nil, err } files := allFiles - if useJSWorkspace { + if useJSWorkspace || useDartWorkspace { files = make([]FileInfo, 0, len(allFiles)) for _, file := range allFiles { if MatchesFilters(file.Path, filepath.Ext(file.Path), filters.Only, nil) { @@ -156,6 +157,14 @@ func buildFileGraphFromAnalysesWithCargoMetadataAndFilters(ctx context.Context, } } + var dartResolver *dartWorkspaceResolver + if useDartWorkspace { + dartResolver, err = buildDartWorkspaceResolver(ctx, absRoot, allFiles) + if err != nil { + return nil, err + } + } + // Resolve imports to files using universal fuzzy matching for _, a := range analyses { if err := ctx.Err(); err != nil { @@ -170,7 +179,12 @@ func buildFileGraphFromAnalysesWithCargoMetadataAndFilters(ctx context.Context, if err := ctx.Err(); err != nil { return nil, err } - resolved := fuzzyResolveWithWorkspace(imp, a.Path, idx, fg.Module, fg.PathAliases, fg.BaseURL, jsResolver) + var resolved []string + if DetectLanguage(a.Path) == "dart" { + resolved = dartResolver.resolve(imp, a.Path, idx) + } else { + resolved = fuzzyResolveWithWorkspace(imp, a.Path, idx, fg.Module, fg.PathAliases, fg.BaseURL, jsResolver) + } // Exclude multi-file Go package imports to avoid inflating hub counts. // Go package imports start with the module prefix and resolve to all // files in that package. For all other imports (e.g., C# namespace @@ -218,6 +232,15 @@ func needsJSWorkspaceResolver(analyses []FileAnalysis) bool { return false } +func needsDartWorkspaceResolver(analyses []FileAnalysis) bool { + for _, file := range analyses { + if DetectLanguage(file.Path) == "dart" { + return true + } + } + return false +} + // buildFileIndex creates a multi-key index for fast import resolution func buildFileIndex(files []FileInfo, goModule string) *fileIndex { idx, _ := buildFileIndexContext(context.Background(), files, goModule) diff --git a/scanner/filegraph_test.go b/scanner/filegraph_test.go index 28d91bb..2690f10 100644 --- a/scanner/filegraph_test.go +++ b/scanner/filegraph_test.go @@ -144,6 +144,7 @@ func TestDetectLanguage(t *testing.T) { }{ {name: "go extension", path: "main.go", expected: "go"}, {name: "uppercase extension", path: "handler.PY", expected: "python"}, + {name: "dart extension", path: "widget.dart", expected: "dart"}, {name: "unknown extension", path: "README.md", expected: ""}, {name: "no extension", path: "Makefile", expected: ""}, } @@ -551,6 +552,7 @@ func TestLanguageCompatibility(t *testing.T) { {name: "go singleton", importer: "go", candidate: "go", want: true}, {name: "rust singleton", importer: "rust", candidate: "rust", want: true}, {name: "swift singleton", importer: "swift", candidate: "swift", want: true}, + {name: "dart singleton", importer: "dart", candidate: "dart", want: true}, {name: "ruby singleton", importer: "ruby", candidate: "ruby", want: true}, {name: "php singleton", importer: "php", candidate: "php", want: true}, {name: "lua singleton", importer: "lua", candidate: "lua", want: true}, diff --git a/scanner/sg-rules/dart.yml b/scanner/sg-rules/dart.yml new file mode 100644 index 0000000..d95b076 --- /dev/null +++ b/scanner/sg-rules/dart.yml @@ -0,0 +1,14 @@ +id: dart-imports +language: dart +rule: + kind: uri +--- +id: dart-functions +language: dart +rule: + kind: function_declaration +--- +id: dart-method-functions +language: dart +rule: + kind: method_declaration diff --git a/scanner/types.go b/scanner/types.go index 50f731d..5aad78a 100644 --- a/scanner/types.go +++ b/scanner/types.go @@ -178,6 +178,7 @@ var extToLang = map[string]string{ ".cc": "cpp", ".java": "java", ".swift": "swift", + ".dart": "dart", ".sh": "bash", ".bash": "bash", ".kt": "kotlin", @@ -268,6 +269,7 @@ var resolverLanguageFamilies = map[string]string{ "go": "go", "rust": "rust", "swift": "swift", + "dart": "dart", "ruby": "ruby", "php": "php", "lua": "lua", @@ -296,6 +298,7 @@ var LangDisplay = map[string]string{ "cpp": "C++", "java": "Java", "swift": "Swift", + "dart": "Dart", "bash": "Bash", "kotlin": "Kotlin", "csharp": "C#", diff --git a/scanner/walker.go b/scanner/walker.go index ddba508..4286f61 100644 --- a/scanner/walker.go +++ b/scanner/walker.go @@ -136,6 +136,7 @@ var IgnoredDirs = map[string]bool{ "target": true, ".gradle": true, ".cargo": true, + ".dart_tool": true, ".grammar-build": true, "grammars": true, } diff --git a/scanner/walker_test.go b/scanner/walker_test.go index ae6f608..8c81fcd 100644 --- a/scanner/walker_test.go +++ b/scanner/walker_test.go @@ -15,7 +15,7 @@ func TestIgnoredDirs(t *testing.T) { // Verify common directories are in the ignored list expectedIgnored := []string{ ".git", "node_modules", "vendor", "__pycache__", - ".venv", "dist", "target", ".gradle", + ".venv", "dist", "target", ".gradle", ".dart_tool", } for _, dir := range expectedIgnored {