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
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,7 @@ Doctor checks project scope and falls back to user scope, reporting which one sa
| **Go** | module path from `go.mod`; stdlib and third-party imports are not fuzzy-matched into local files |
| **Rust** | `cargo metadata` — workspace membership, target kinds (lib/bin/test/bench/example/build), and `dev-dependencies` reachable from `#[cfg(test)]` blocks |
| **JS/TS** | `package.json` `exports`/`imports` maps, npm/pnpm/Bun workspaces, Deno import maps, and `tsconfig` `rootDir`/`outDir` remapping (including `extends`) |
| **Dart/Flutter** | `pubspec.yaml` package names and declared dependencies; `package:` URIs resolve within the owning package's `lib/`, while undeclared or duplicate package names fail closed |
| **Everything else** | ast-grep import extraction with suffix and directory matching |

### The coverage contract
Expand Down Expand Up @@ -142,7 +143,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
3 changes: 2 additions & 1 deletion cmd/context.go
Original file line number Diff line number Diff line change
Expand Up @@ -271,7 +271,7 @@ func detectManifestLanguages(root string) map[string]bool {
langs := make(map[string]bool)
manifests := map[string][]string{
"go.mod": {"go"}, "package.json": {"javascript"}, "Cargo.toml": {"rust"},
"pyproject.toml": {"python"}, "Package.swift": {"swift"},
"pyproject.toml": {"python"}, "Package.swift": {"swift"}, "pubspec.yaml": {"dart"},
"build.gradle": {"java"}, "build.gradle.kts": {"kotlin", "java"},
}
for file, signalLangs := range manifests {
Expand Down Expand Up @@ -307,6 +307,7 @@ func detectLanguagesFromFiles(root string) map[string]bool {
"build.gradle.kts": {"kotlin", "java"},
"pom.xml": {"java"},
"Package.swift": {"swift"},
"pubspec.yaml": {"dart"},
"Podfile": {"swift"},
"mix.exs": {"elixir"},
"composer.json": {"php"},
Expand Down
12 changes: 11 additions & 1 deletion cmd/context_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -37,16 +37,26 @@ func TestDetectLanguagesFromFiles_ManifestSignals(t *testing.T) {
mustWriteFile(t, filepath.Join(root, "tsconfig.json"), "{}")
mustWriteFile(t, filepath.Join(root, "Makefile"), "CC=gcc\nCXX=g++\n")
mustWriteFile(t, filepath.Join(root, "packages", "ui", "package.json"), "{}")
mustWriteFile(t, filepath.Join(root, "pubspec.yaml"), "name: app\n")

langs := detectLanguagesFromFiles(root)

for _, want := range []string{"csharp", "kotlin", "java", "swift", "typescript", "javascript", "c", "cpp"} {
for _, want := range []string{"csharp", "kotlin", "java", "swift", "typescript", "javascript", "dart", "c", "cpp"} {
if !langs[want] {
t.Fatalf("expected %q to be detected, got %#v", want, langs)
}
}
}

func TestDetectManifestLanguages_Pubspec(t *testing.T) {
root := t.TempDir()
mustWriteFile(t, filepath.Join(root, "pubspec.yaml"), "name: app\n")

if langs := detectManifestLanguages(root); !langs["dart"] {
t.Fatalf("expected Dart manifest signal, got %#v", langs)
}
}

func TestDetectLanguagesFromFiles_SubdirectorySources(t *testing.T) {
root := t.TempDir()

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
33 changes: 33 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,9 @@ func extractFunctionName(text string, lang string) string {
}
}

case "dart":
return extractDartFunctionName(text)

case "c", "cpp":
// type name(...) - find last identifier before (
if paren := strings.Index(text, "("); paren > 0 {
Expand Down Expand Up @@ -778,6 +782,35 @@ func extractFunctionName(text string, lang string) string {
return ""
}

func extractDartFunctionName(text string) string {
depth := 0
name := ""
for i := range len(text) {
switch text[i] {
case '(':
if depth == 0 {
before := strings.TrimSpace(text[:i])
parts := strings.Fields(before)
if len(parts) > 0 {
candidate := parts[len(parts)-1]
if bracket := strings.Index(candidate, "<"); bracket > 0 {
candidate = candidate[:bracket]
}
if isValidIdentifier(candidate) {
name = candidate
}
}
}
depth++
case ')':
if depth > 0 {
depth--
}
}
}
return name
}

func isValidIdentifier(s string) bool {
if s == "" {
return false
Expand Down
72 changes: 72 additions & 0 deletions scanner/astgrep_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -501,3 +501,75 @@ 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 Function(int) makeCallback() => print;

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", "makeCallback", "build"} {
if !functions[want] {
t.Errorf("functions = %#v, missing %q", got.Functions, want)
}
}
if functions["Function"] {
t.Errorf("functions = %#v, extracted return type as function name", got.Functions)
}

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)
}
}
}
117 changes: 117 additions & 0 deletions scanner/dartworkspace.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
package scanner

import (
"context"
"os"
"path/filepath"
"sort"
"strings"
)

type dartWorkspaceResolver struct {
packageRoots map[string][]string
packageScopes []dartPackageScope
}

type dartPackageScope struct {
root string
manifest pubspecManifest
}

func buildDartWorkspaceResolver(ctx context.Context, root string, files []FileInfo) (*dartWorkspaceResolver, error) {
if err := ctx.Err(); err != nil {
return nil, err
}
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)
resolver.packageScopes = append(resolver.packageScopes, dartPackageScope{
root: packageRoot,
manifest: manifest,
})
}
sort.Slice(resolver.packageScopes, func(i, j int) bool {
return len(resolver.packageScopes[i].root) > len(resolver.packageScopes[j].root)
})
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 {
pkgName, pkgPath, _ := strings.Cut(packageURI, "/")
if pkgName == "" || pkgPath == "" {
return nil
}
scope := r.nearestPackageScope(fromFile)
if scope == nil {
return nil
}
if scope.manifest.Name != pkgName && !pubspecDeclaresDependency(scope.manifest, pkgName) {
return nil
}
roots := r.packageRoots[pkgName]
if len(roots) != 1 {
return nil
}
libRoot := filepath.Join(roots[0], "lib")
candidate := filepath.Join(libRoot, filepath.FromSlash(pkgPath))
if !pathContains(libRoot, candidate) {
return nil
}
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")
}

func (r *dartWorkspaceResolver) nearestPackageScope(fromFile string) *dartPackageScope {
for i := range r.packageScopes {
if pathContains(r.packageScopes[i].root, fromFile) {
return &r.packageScopes[i]
}
}
return nil
}

func pubspecDeclaresDependency(manifest pubspecManifest, name string) bool {
if _, ok := manifest.Dependencies[name]; ok {
return true
}
if _, ok := manifest.DevDependencies[name]; ok {
return true
}
_, ok := manifest.DependencyOverrides[name]
return ok
}
Loading
Loading