Skip to content
Open
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 @@ -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.

Expand Down
2 changes: 1 addition & 1 deletion render/depgraph.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
2 changes: 2 additions & 0 deletions render/depgraph_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"},
},
}

Expand All @@ -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",
Expand Down
17 changes: 17 additions & 0 deletions scanner/astgrep.go
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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 {
Expand Down
68 changes: 68 additions & 0 deletions scanner/astgrep_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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>(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)
}
}
}
69 changes: 69 additions & 0 deletions scanner/dartworkspace.go
Original file line number Diff line number Diff line change
@@ -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")
}
93 changes: 93 additions & 0 deletions scanner/dartworkspace_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
43 changes: 41 additions & 2 deletions scanner/deps.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,15 +6,18 @@ import (
"io"
"os"
"path/filepath"
"sort"
"strings"

"gopkg.in/yaml.v3"
)

// MCPManifestByteBudget bounds each manifest read performed for one MCP request.
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.
Expand Down Expand Up @@ -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:
Expand All @@ -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")
Expand Down Expand Up @@ -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") {
Expand Down
Loading