From 6e347f4c8218a57aa37de35411cdf3d5a3536f32 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 16 Dec 2025 03:32:09 +0000 Subject: [PATCH] Add TypeScript/JavaScript path alias support to --deps Resolves imports using path aliases configured in tsconfig.json and jsconfig.json (e.g., @modules/auth, @shared/utils). - Reads compilerOptions.paths and baseUrl from tsconfig.json - Supports extends inheritance from parent configs - Handles wildcard patterns (e.g., @modules/*) and exact aliases - Falls back gracefully when no config present --- scanner/deps_test.go | 181 +++++++++++++++++++++++++++++++++++++++++++ scanner/filegraph.go | 177 ++++++++++++++++++++++++++++++++++++++---- 2 files changed, 345 insertions(+), 13 deletions(-) diff --git a/scanner/deps_test.go b/scanner/deps_test.go index 695ec35..a4af78e 100644 --- a/scanner/deps_test.go +++ b/scanner/deps_test.go @@ -312,3 +312,184 @@ func TestReadExternalDepsIgnoresNodeModules(t *testing.T) { t.Errorf("Expected javascript deps, got: %v", deps) } } + +func TestDetectPathAliases(t *testing.T) { + tmpDir := t.TempDir() + + // Create a tsconfig.json with path aliases + tsconfig := `{ + "compilerOptions": { + "baseUrl": ".", + "paths": { + "@modules/*": ["src/modules/*"], + "@shared/*": ["src/shared/*"], + "@utils": ["src/utils/index"] + } + } +}` + if err := os.WriteFile(filepath.Join(tmpDir, "tsconfig.json"), []byte(tsconfig), 0644); err != nil { + t.Fatal(err) + } + + paths, baseURL := detectPathAliases(tmpDir) + + if baseURL != "." { + t.Errorf("Expected baseUrl '.', got %q", baseURL) + } + + if len(paths) != 3 { + t.Errorf("Expected 3 path aliases, got %d: %v", len(paths), paths) + } + + if targets, ok := paths["@modules/*"]; !ok { + t.Error("Expected @modules/* alias") + } else if len(targets) != 1 || targets[0] != "src/modules/*" { + t.Errorf("Expected @modules/* -> src/modules/*, got %v", targets) + } + + if targets, ok := paths["@shared/*"]; !ok { + t.Error("Expected @shared/* alias") + } else if len(targets) != 1 || targets[0] != "src/shared/*" { + t.Errorf("Expected @shared/* -> src/shared/*, got %v", targets) + } +} + +func TestDetectPathAliasesWithExtends(t *testing.T) { + tmpDir := t.TempDir() + + // Create base tsconfig + baseConfig := `{ + "compilerOptions": { + "baseUrl": ".", + "paths": { + "@base/*": ["src/base/*"] + } + } +}` + if err := os.WriteFile(filepath.Join(tmpDir, "tsconfig.base.json"), []byte(baseConfig), 0644); err != nil { + t.Fatal(err) + } + + // Create extending tsconfig + tsconfig := `{ + "extends": "./tsconfig.base.json", + "compilerOptions": { + "paths": { + "@app/*": ["src/app/*"] + } + } +}` + if err := os.WriteFile(filepath.Join(tmpDir, "tsconfig.json"), []byte(tsconfig), 0644); err != nil { + t.Fatal(err) + } + + paths, baseURL := detectPathAliases(tmpDir) + + if baseURL != "." { + t.Errorf("Expected baseUrl '.', got %q", baseURL) + } + + // Should have both parent and child paths + if len(paths) != 2 { + t.Errorf("Expected 2 path aliases (merged), got %d: %v", len(paths), paths) + } + + if _, ok := paths["@app/*"]; !ok { + t.Error("Expected @app/* alias from child config") + } + + if _, ok := paths["@base/*"]; !ok { + t.Error("Expected @base/* alias from parent config") + } +} + +func TestDetectPathAliasesJsconfig(t *testing.T) { + tmpDir := t.TempDir() + + // Create jsconfig.json (used in JavaScript projects without TypeScript) + jsconfig := `{ + "compilerOptions": { + "baseUrl": "src", + "paths": { + "@/*": ["./*"] + } + } +}` + if err := os.WriteFile(filepath.Join(tmpDir, "jsconfig.json"), []byte(jsconfig), 0644); err != nil { + t.Fatal(err) + } + + paths, baseURL := detectPathAliases(tmpDir) + + if baseURL != "src" { + t.Errorf("Expected baseUrl 'src', got %q", baseURL) + } + + if len(paths) != 1 { + t.Errorf("Expected 1 path alias, got %d: %v", len(paths), paths) + } +} + +func TestResolvePathAlias(t *testing.T) { + // Build a simple file index + files := []FileInfo{ + {Path: "src/modules/auth/index.ts"}, + {Path: "src/modules/auth/login.ts"}, + {Path: "src/shared/utils/helpers.ts"}, + {Path: "src/utils/index.ts"}, + } + idx := buildFileIndex(files, "") + + pathAliases := map[string][]string{ + "@modules/*": {"src/modules/*"}, + "@shared/*": {"src/shared/*"}, + "@utils": {"src/utils/index"}, + } + + tests := []struct { + name string + imp string + expected string + }{ + {"wildcard alias", "@modules/auth/login", "src/modules/auth/login.ts"}, + {"wildcard with index", "@modules/auth", "src/modules/auth/index.ts"}, + {"nested wildcard", "@shared/utils/helpers", "src/shared/utils/helpers.ts"}, + {"exact alias", "@utils", "src/utils/index.ts"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := resolvePathAlias(tt.imp, pathAliases, ".", idx) + if len(result) == 0 { + t.Errorf("Expected to resolve %q, got no results", tt.imp) + return + } + if result[0] != tt.expected { + t.Errorf("Expected %q to resolve to %q, got %q", tt.imp, tt.expected, result[0]) + } + }) + } +} + +func TestResolvePathAliasNoMatch(t *testing.T) { + files := []FileInfo{ + {Path: "src/modules/auth/index.ts"}, + } + idx := buildFileIndex(files, "") + + pathAliases := map[string][]string{ + "@modules/*": {"src/modules/*"}, + } + + // Import that doesn't match any alias + result := resolvePathAlias("lodash", pathAliases, ".", idx) + if len(result) != 0 { + t.Errorf("Expected no results for non-alias import, got %v", result) + } + + // Import that matches alias but file doesn't exist + result = resolvePathAlias("@modules/nonexistent", pathAliases, ".", idx) + if len(result) != 0 { + t.Errorf("Expected no results for non-existent file, got %v", result) + } +} diff --git a/scanner/filegraph.go b/scanner/filegraph.go index aedb869..ff952a0 100644 --- a/scanner/filegraph.go +++ b/scanner/filegraph.go @@ -2,6 +2,7 @@ package scanner import ( "bufio" + "encoding/json" "os" "path/filepath" "strings" @@ -9,11 +10,13 @@ import ( // FileGraph represents internal file-to-file dependencies within a project type FileGraph struct { - Root string // project root - Module string // go module name (e.g., "codemap") - Imports map[string][]string // file -> files it imports - Importers map[string][]string // file -> files that import it - Packages map[string][]string // package path -> files in that package + Root string // project root + Module string // go module name (e.g., "codemap") + Imports map[string][]string // file -> files it imports + Importers map[string][]string // file -> files that import it + Packages map[string][]string // package path -> files in that package + PathAliases map[string][]string // TS/JS path aliases from tsconfig.json (e.g., "@modules/*" -> ["src/modules/*"]) + BaseURL string // TS/JS baseUrl from tsconfig.json } // fileIndex provides fast lookup of files by various import-like keys @@ -33,15 +36,19 @@ func BuildFileGraph(root string) (*FileGraph, error) { } fg := &FileGraph{ - Root: absRoot, - Imports: make(map[string][]string), - Importers: make(map[string][]string), - Packages: make(map[string][]string), + Root: absRoot, + Imports: make(map[string][]string), + Importers: make(map[string][]string), + Packages: make(map[string][]string), + PathAliases: make(map[string][]string), } // Detect module name from go.mod (for Go import resolution) fg.Module = detectModule(absRoot) + // Detect path aliases from tsconfig.json (for TS/JS import resolution) + fg.PathAliases, fg.BaseURL = detectPathAliases(absRoot) + // Scan all files gitCache := NewGitIgnoreCache(root) files, err := ScanFiles(root, gitCache, nil, nil) @@ -64,7 +71,7 @@ func BuildFileGraph(root string) (*FileGraph, error) { var resolvedImports []string for _, imp := range a.Imports { - resolved := fuzzyResolve(imp, a.Path, idx, fg.Module) + resolved := fuzzyResolve(imp, a.Path, idx, fg.Module, fg.PathAliases, fg.BaseURL) // Only count imports that resolve to exactly one file. // If an import resolves to multiple files, it's a package/module // import (Go, Python, Rust, etc.) not a file-level import. @@ -140,7 +147,7 @@ func buildFileIndex(files []FileInfo, goModule string) *fileIndex { // fuzzyResolve converts an import path to actual file paths using universal matching // No language-specific switch - relies on pattern matching against file index -func fuzzyResolve(imp, fromFile string, idx *fileIndex, goModule string) []string { +func fuzzyResolve(imp, fromFile string, idx *fileIndex, goModule string, pathAliases map[string][]string, baseURL string) []string { fromDir := filepath.Dir(fromFile) if fromDir == "." { fromDir = "" @@ -161,12 +168,19 @@ func fuzzyResolve(imp, fromFile string, idx *fileIndex, goModule string) []strin return resolveRelative(imp, fromDir, idx) } - // Strategy 3: Exact match (with common extensions) + // Strategy 3: TypeScript/JavaScript path alias resolution (@modules/auth, @shared/utils) + if len(pathAliases) > 0 { + if files := resolvePathAlias(imp, pathAliases, baseURL, idx); len(files) > 0 { + return files + } + } + + // Strategy 4: Exact match (with common extensions) if files := tryExactMatch(normalized, idx); len(files) > 0 { return files } - // Strategy 4: Suffix match (for nested packages like app.core.config -> */app/core/config.py) + // Strategy 5: Suffix match (for nested packages like app.core.config -> */app/core/config.py) if files := trySuffixMatch(normalized, idx); len(files) > 0 { return files } @@ -323,3 +337,140 @@ func (fg *FileGraph) ConnectedFiles(path string) []string { } return result } + +// tsConfig represents the structure of tsconfig.json we care about +type tsConfig struct { + CompilerOptions struct { + BaseURL string `json:"baseUrl"` + Paths map[string][]string `json:"paths"` + } `json:"compilerOptions"` + Extends string `json:"extends"` +} + +// detectPathAliases reads tsconfig.json (and jsconfig.json) to find path aliases +// Returns the paths map and baseUrl +func detectPathAliases(root string) (map[string][]string, string) { + // Try tsconfig.json first, then jsconfig.json + for _, configFile := range []string{"tsconfig.json", "jsconfig.json"} { + configPath := filepath.Join(root, configFile) + paths, baseURL := readTSConfig(configPath, root) + if len(paths) > 0 { + return paths, baseURL + } + } + return nil, "" +} + +// readTSConfig reads a tsconfig/jsconfig file and extracts paths, following extends +func readTSConfig(configPath, root string) (map[string][]string, string) { + data, err := os.ReadFile(configPath) + if err != nil { + return nil, "" + } + + var config tsConfig + if err := json.Unmarshal(data, &config); err != nil { + return nil, "" + } + + paths := config.CompilerOptions.Paths + baseURL := config.CompilerOptions.BaseURL + + // If this config extends another, merge paths from parent + if config.Extends != "" { + parentPath := config.Extends + // Resolve relative extends path + if !filepath.IsAbs(parentPath) { + parentPath = filepath.Join(filepath.Dir(configPath), parentPath) + } + // Add .json if not present + if !strings.HasSuffix(parentPath, ".json") { + parentPath += ".json" + } + + parentPaths, parentBaseURL := readTSConfig(parentPath, root) + if parentPaths != nil { + // Child paths override parent paths + if paths == nil { + paths = parentPaths + } else { + for k, v := range parentPaths { + if _, exists := paths[k]; !exists { + paths[k] = v + } + } + } + } + // Child baseUrl overrides parent + if baseURL == "" { + baseURL = parentBaseURL + } + } + + return paths, baseURL +} + +// resolvePathAlias attempts to resolve an import using TypeScript path aliases +// e.g., "@modules/auth" with alias "@modules/*" -> ["src/modules/*"] becomes "src/modules/auth" +func resolvePathAlias(imp string, pathAliases map[string][]string, baseURL string, idx *fileIndex) []string { + // Try each alias pattern + for pattern, targets := range pathAliases { + var prefix, suffix string + if starIdx := strings.Index(pattern, "*"); starIdx >= 0 { + prefix = pattern[:starIdx] + suffix = pattern[starIdx+1:] + } else { + // Exact match pattern (no wildcard) + if imp == pattern { + for _, target := range targets { + resolved := target + if baseURL != "" && !filepath.IsAbs(resolved) { + resolved = filepath.Join(baseURL, resolved) + } + if files := tryExactMatch(resolved, idx); len(files) > 0 { + return files + } + } + } + continue + } + + // Check if import matches this pattern + if !strings.HasPrefix(imp, prefix) { + continue + } + if suffix != "" && !strings.HasSuffix(imp, suffix) { + continue + } + + // Extract the wildcard portion + wildcardPart := imp[len(prefix):] + if suffix != "" { + wildcardPart = wildcardPart[:len(wildcardPart)-len(suffix)] + } + + // Try each target mapping + for _, target := range targets { + resolved := target + if starIdx := strings.Index(target, "*"); starIdx >= 0 { + // Replace wildcard with captured portion + resolved = target[:starIdx] + wildcardPart + target[starIdx+1:] + } + + // Apply baseUrl if set + if baseURL != "" && !filepath.IsAbs(resolved) { + resolved = filepath.Join(baseURL, resolved) + } + + // Try to find matching files + if files := tryExactMatch(resolved, idx); len(files) > 0 { + return files + } + if files := trySuffixMatch(resolved, idx); len(files) > 0 { + return files + } + } + } + + return nil +}