diff --git a/scanner/filegraph.go b/scanner/filegraph.go index 2514ff5..6658cab 100644 --- a/scanner/filegraph.go +++ b/scanner/filegraph.go @@ -91,12 +91,25 @@ func buildFileGraphFromFilteredAnalysesWithCargoMetadata(ctx context.Context, ro // Detect path aliases from tsconfig.json (for TS/JS import resolution) fg.PathAliases, fg.BaseURL = detectPathAliases(absRoot) - // Scan all files with the same filters used for the analyses. + useJSWorkspace := needsJSWorkspaceResolver(analyses) gitCache := NewGitIgnoreCache(root) - files, err := ScanFiles(root, gitCache, filters.Only, filters.Exclude) + scanOnly := filters.Only + if useJSWorkspace { + scanOnly = nil + } + allFiles, err := ScanFiles(root, gitCache, scanOnly, filters.Exclude) if err != nil { return nil, err } + files := allFiles + if useJSWorkspace { + files = make([]FileInfo, 0, len(allFiles)) + for _, file := range allFiles { + if MatchesFilters(file.Path, filepath.Ext(file.Path), filters.Only, nil) { + files = append(files, file) + } + } + } rustWorkspace := buildRustWorkspaceIndex(ctx, absRoot, analyses, files, loader) if err := ctx.Err(); err != nil { return nil, err @@ -112,6 +125,14 @@ func buildFileGraphFromFilteredAnalysesWithCargoMetadata(ctx context.Context, ro } } + var jsResolver *jsWorkspaceResolver + if useJSWorkspace { + jsResolver, err = buildJSWorkspaceResolver(ctx, absRoot, allFiles) + if err != nil { + return nil, err + } + } + // Resolve imports to files using universal fuzzy matching for _, a := range analyses { var resolvedImports []string @@ -120,7 +141,7 @@ func buildFileGraphFromFilteredAnalysesWithCargoMetadata(ctx context.Context, ro resolvedImports = resolveRustReferences(absRoot, a, idx, rustWorkspace) } else { for _, imp := range a.Imports { - resolved := fuzzyResolve(imp, a.Path, idx, fg.Module, fg.PathAliases, fg.BaseURL) + 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 @@ -146,6 +167,15 @@ func buildFileGraphFromFilteredAnalysesWithCargoMetadata(ctx context.Context, ro return fg, nil } +func needsJSWorkspaceResolver(analyses []FileAnalysis) bool { + for _, analysis := range analyses { + if isJavaScriptLanguage(DetectLanguage(analysis.Path)) { + return true + } + } + return false +} + // buildFileIndex creates a multi-key index for fast import resolution func buildFileIndex(files []FileInfo, goModule string) *fileIndex { idx := &fileIndex{ @@ -200,6 +230,10 @@ func buildFileIndex(files []FileInfo, goModule string) *fileIndex { // fuzzyResolve converts an import path to compatible local file paths. func fuzzyResolve(imp, fromFile string, idx *fileIndex, goModule string, pathAliases map[string][]string, baseURL string) []string { + return fuzzyResolveWithWorkspace(imp, fromFile, idx, goModule, pathAliases, baseURL, nil) +} + +func fuzzyResolveWithWorkspace(imp, fromFile string, idx *fileIndex, goModule string, pathAliases map[string][]string, baseURL string, jsResolver *jsWorkspaceResolver) []string { sourceLanguage := DetectLanguage(fromFile) if sourceLanguage == "" { return nil @@ -236,6 +270,9 @@ func fuzzyResolve(imp, fromFile string, idx *fileIndex, goModule string, pathAli } } if isJavaScriptLanguage(sourceLanguage) { + if files := jsResolver.resolve(imp, fromFile, idx, sourceLanguage); len(files) > 0 { + return files + } return nil } diff --git a/scanner/jsworkspace.go b/scanner/jsworkspace.go new file mode 100644 index 0000000..3268b13 --- /dev/null +++ b/scanner/jsworkspace.go @@ -0,0 +1,976 @@ +package scanner + +import ( + "context" + "encoding/json" + "errors" + "os" + "path" + "path/filepath" + "sort" + "strings" + + "gopkg.in/yaml.v3" +) + +type jsWorkspaceResolver struct { + packageScopes []jsPackageScope + denoScopes []jsImportScope + workspaces []jsPackageWorkspace +} + +type jsPackageWorkspace struct { + root string + packages map[string][]*jsWorkspacePackage +} + +type jsWorkspacePackage struct { + root string + name string + exports jsSpecifierMap + hasExports bool + entryTargets []string + sourceRoot string + outDir string +} + +type jsPackageScope struct { + root string + pkg *jsWorkspacePackage + imports jsSpecifierMap +} + +type jsImportScope struct { + root string + imports jsSpecifierMap +} + +type jsWorkspaceManifest struct { + root string + pkg *jsWorkspacePackage + imports jsSpecifierMap + workspaces []string +} + +type jsSpecifierMap struct { + exact map[string]jsSpecifierTarget + dynamic []jsSpecifierTarget +} + +type jsSpecifierTarget struct { + key string + target string + valid bool + prefix bool +} + +func buildJSWorkspaceResolver(ctx context.Context, root string, files []FileInfo) (*jsWorkspaceResolver, error) { + if err := ctx.Err(); err != nil { + return nil, err + } + packages := make(map[string]*jsWorkspaceManifest) + denoConfigs := make(map[string]*jsWorkspaceManifest) + tsConfigs := make(map[string]map[string]any) + pnpmWorkspaces := make(map[string][]string) + for _, file := range files { + if err := ctx.Err(); err != nil { + return nil, err + } + name := filepath.Base(file.Path) + manifestRoot := cleanRepoPath(filepath.Dir(file.Path)) + if name == "pnpm-workspace.yaml" { + pnpmWorkspaces[manifestRoot] = readPnpmWorkspace(filepath.Join(root, file.Path)) + continue + } + if name == "tsconfig.json" { + if doc, ok := readJSWorkspaceManifest(filepath.Join(root, file.Path)); ok { + tsConfigs[manifestRoot] = doc + } + continue + } + if name != "package.json" && name != "deno.json" && name != "deno.jsonc" { + continue + } + + doc, ok := readJSWorkspaceManifest(filepath.Join(root, file.Path)) + if !ok { + continue + } + if name == "package.json" { + packages[manifestRoot] = parsePackageManifest(manifestRoot, doc) + } else { + denoConfigs[manifestRoot] = parseDenoManifest(manifestRoot, doc) + } + } + for root, doc := range tsConfigs { + if err := ctx.Err(); err != nil { + return nil, err + } + if manifest := packages[root]; manifest != nil { + manifest.pkg.sourceRoot, manifest.pkg.outDir = parseTSOutputDirs(doc) + } + } + + resolver := &jsWorkspaceResolver{} + for _, manifest := range packages { + if err := ctx.Err(); err != nil { + return nil, err + } + resolver.packageScopes = append(resolver.packageScopes, jsPackageScope{ + root: manifest.root, + pkg: manifest.pkg, + imports: manifest.imports, + }) + } + for _, manifest := range denoConfigs { + if err := ctx.Err(); err != nil { + return nil, err + } + resolver.denoScopes = append(resolver.denoScopes, jsImportScope{ + root: manifest.root, + imports: manifest.imports, + }) + } + sort.Slice(resolver.packageScopes, func(i, j int) bool { + return len(resolver.packageScopes[i].root) > len(resolver.packageScopes[j].root) + }) + sort.Slice(resolver.denoScopes, func(i, j int) bool { + return len(resolver.denoScopes[i].root) > len(resolver.denoScopes[j].root) + }) + + workspaces := make(map[string]*jsPackageWorkspace) + addWorkspace := func(owner *jsWorkspaceManifest, ownerRoot string, patterns []string, groups ...map[string]*jsWorkspaceManifest) error { + if len(patterns) == 0 { + return nil + } + workspace := workspaces[ownerRoot] + if workspace == nil { + workspace = newJSPackageWorkspace(ownerRoot) + workspaces[ownerRoot] = workspace + } + if owner != nil { + workspace.add(owner.pkg) + } + return addJSWorkspaceMembers(ctx, workspace, ownerRoot, patterns, groups...) + } + for _, owner := range packages { + if err := ctx.Err(); err != nil { + return nil, err + } + if err := addWorkspace(owner, owner.root, owner.workspaces, packages); err != nil { + return nil, err + } + } + for _, owner := range denoConfigs { + if err := ctx.Err(); err != nil { + return nil, err + } + if err := addWorkspace(owner, owner.root, owner.workspaces, packages, denoConfigs); err != nil { + return nil, err + } + } + for ownerRoot, patterns := range pnpmWorkspaces { + if err := ctx.Err(); err != nil { + return nil, err + } + if err := addWorkspace(nil, ownerRoot, patterns, packages); err != nil { + return nil, err + } + } + for _, workspace := range workspaces { + resolver.workspaces = append(resolver.workspaces, *workspace) + } + sort.Slice(resolver.workspaces, func(i, j int) bool { + return len(resolver.workspaces[i].root) > len(resolver.workspaces[j].root) + }) + + return resolver, nil +} + +func addJSWorkspaceMembers(ctx context.Context, workspace *jsPackageWorkspace, ownerRoot string, patterns []string, groups ...map[string]*jsWorkspaceManifest) error { + for _, manifests := range groups { + for _, member := range manifests { + if err := ctx.Err(); err != nil { + return err + } + if matchesWorkspaceMember(ownerRoot, member.root, patterns) { + workspace.add(member.pkg) + } + } + } + return nil +} + +func newJSPackageWorkspace(root string) *jsPackageWorkspace { + return &jsPackageWorkspace{ + root: root, + packages: make(map[string][]*jsWorkspacePackage), + } +} + +func (w *jsPackageWorkspace) add(pkg *jsWorkspacePackage) { + if pkg == nil || pkg.name == "" { + return + } + for _, existing := range w.packages[pkg.name] { + if existing.root == pkg.root { + return + } + } + w.packages[pkg.name] = append(w.packages[pkg.name], pkg) +} + +func (r *jsWorkspaceResolver) resolve(imp, fromFile string, idx *fileIndex, sourceLanguage string) []string { + if r == nil || isExternalJSSpecifier(imp) { + return nil + } + + if strings.HasPrefix(imp, "#") { + scope := r.nearestPackageScope(fromFile) + if scope == nil { + return nil + } + target, matched := scope.imports.resolve(imp) + if !matched || !target.valid { + return nil + } + return scope.pkg.resolveTarget(target.target, idx, sourceLanguage) + } + + if scope := r.nearestDenoScope(fromFile); scope != nil { + if target, matched := scope.imports.resolve(imp); matched { + if !target.valid { + return nil + } + return resolveManifestTarget(scope.root, target.target, idx, sourceLanguage) + } + } + + name, subpath, ok := splitJSPackageSpecifier(imp) + if !ok { + return nil + } + var candidates []*jsWorkspacePackage + for _, workspace := range r.workspaces { + if pathContains(workspace.root, fromFile) { + candidates = workspace.packages[name] + break + } + } + if len(candidates) != 1 { + scope := r.nearestPackageScope(fromFile) + if scope == nil || scope.pkg == nil || scope.pkg.name != name { + return nil + } + candidates = []*jsWorkspacePackage{scope.pkg} + } + + return candidates[0].resolve(subpath, idx, sourceLanguage) +} + +func (r *jsWorkspaceResolver) nearestPackageScope(fromFile string) *jsPackageScope { + for i := range r.packageScopes { + if pathContains(r.packageScopes[i].root, fromFile) { + return &r.packageScopes[i] + } + } + return nil +} + +func (r *jsWorkspaceResolver) nearestDenoScope(fromFile string) *jsImportScope { + for i := range r.denoScopes { + if pathContains(r.denoScopes[i].root, fromFile) { + return &r.denoScopes[i] + } + } + return nil +} + +func (pkg *jsWorkspacePackage) resolve(subpath string, idx *fileIndex, sourceLanguage string) []string { + key := "." + if subpath != "" { + key = "./" + subpath + } + if pkg.hasExports { + target, matched := pkg.exports.resolve(key) + if !matched || !target.valid { + return nil + } + return pkg.resolveTarget(target.target, idx, sourceLanguage) + } + if subpath != "" { + return pkg.resolveInferredTarget("./"+subpath, idx, sourceLanguage) + } + if len(pkg.entryTargets) != 1 { + return nil + } + return pkg.resolveInferredTarget(pkg.entryTargets[0], idx, sourceLanguage) +} + +func resolveManifestTarget(root, target string, idx *fileIndex, sourceLanguage string) []string { + if isTypeOnlyTarget(target) { + return nil + } + localPath, ok := manifestLocalPath(root, target) + if !ok { + return nil + } + for _, candidate := range compatibleFiles(sourceLanguage, idx.byExact[localPath]) { + if candidate == localPath { + return []string{candidate} + } + } + return nil +} + +func (pkg *jsWorkspacePackage) resolveTarget(target string, idx *fileIndex, sourceLanguage string) []string { + if files := resolveManifestTarget(pkg.root, target, idx, sourceLanguage); len(files) > 0 { + return files + } + if pkg.sourceRoot == "" || pkg.outDir == "" { + return nil + } + + localPath, ok := manifestLocalPath(pkg.root, target) + if !ok { + return nil + } + outRoot := cleanRepoPath(filepath.Join(repoPath(pkg.root), filepath.FromSlash(pkg.outDir))) + if !pathContains(outRoot, localPath) || localPath == outRoot { + return nil + } + relative, err := filepath.Rel(outRoot, localPath) + if err != nil || relative == ".." || strings.HasPrefix(relative, ".."+string(filepath.Separator)) { + return nil + } + sourcePath := cleanRepoPath(filepath.Join(repoPath(pkg.root), filepath.FromSlash(pkg.sourceRoot), relative)) + for _, extension := range []string{".mjs", ".cjs", ".jsx", ".js"} { + sourcePath = strings.TrimSuffix(sourcePath, extension) + } + return tryExactMatch(sourcePath, idx, sourceLanguage) +} + +func (pkg *jsWorkspacePackage) resolveInferredTarget(target string, idx *fileIndex, sourceLanguage string) []string { + if localPath, ok := manifestLocalPath(pkg.root, target); ok { + if files := tryExactMatch(localPath, idx, sourceLanguage); len(files) > 0 { + return files + } + } + return pkg.resolveTarget(target, idx, sourceLanguage) +} + +func (m jsSpecifierMap) resolve(specifier string) (jsSpecifierTarget, bool) { + if target, ok := m.exact[specifier]; ok { + return target, true + } + + var matches []jsSpecifierTarget + bestSpecificity := -1 + for _, mapping := range m.dynamic { + var remainder string + switch { + case mapping.prefix && strings.HasPrefix(specifier, mapping.key): + remainder = strings.TrimPrefix(specifier, mapping.key) + case !mapping.prefix && strings.Count(mapping.key, "*") == 1: + prefix, suffix, _ := strings.Cut(mapping.key, "*") + if !strings.HasPrefix(specifier, prefix) || !strings.HasSuffix(specifier, suffix) { + continue + } + remainder = specifier[len(prefix) : len(specifier)-len(suffix)] + default: + continue + } + + specificity := len(mapping.key) + if specificity < bestSpecificity { + continue + } + resolved := mapping + if mapping.valid { + if mapping.prefix { + resolved.target += remainder + } else { + resolved.target = strings.Replace(mapping.target, "*", remainder, 1) + } + } + if specificity > bestSpecificity { + bestSpecificity = specificity + matches = matches[:0] + } + matches = append(matches, resolved) + } + if len(matches) != 1 { + return jsSpecifierTarget{}, len(matches) > 0 + } + return matches[0], true +} + +func parsePackageManifest(root string, doc map[string]any) *jsWorkspaceManifest { + name, _ := doc["name"].(string) + pkg := &jsWorkspacePackage{root: root, name: name} + pkg.exports, pkg.hasExports = parseExports(doc) + pkg.entryTargets = unambiguousEntryTargets(doc) + return &jsWorkspaceManifest{ + root: root, + pkg: pkg, + imports: parsePackageImports(doc["imports"]), + workspaces: parseWorkspacePatterns(doc["workspaces"], "packages"), + } +} + +func parseDenoManifest(root string, doc map[string]any) *jsWorkspaceManifest { + name, _ := doc["name"].(string) + pkg := &jsWorkspacePackage{root: root, name: name} + pkg.exports, pkg.hasExports = parseExports(doc) + return &jsWorkspaceManifest{ + root: root, + pkg: pkg, + imports: parseDenoImports(doc["imports"]), + workspaces: parseWorkspacePatterns(doc["workspace"], "members"), + } +} + +func parseExports(doc map[string]any) (jsSpecifierMap, bool) { + value, exists := doc["exports"] + if !exists { + return jsSpecifierMap{}, false + } + mappings := newJSSpecifierMap() + switch exports := value.(type) { + case string: + mappings.addExact(".", exports) + case map[string]any: + subpaths := true + for key := range exports { + subpaths = subpaths && strings.HasPrefix(key, ".") + } + if !subpaths { + target, ok := unambiguousRuntimeTarget(exports) + if !ok { + mappings.addInvalid(".") + } else { + mappings.addExact(".", target) + } + break + } + for key, value := range exports { + target, ok := unambiguousRuntimeTarget(value) + if !ok { + mappings.addInvalid(key) + } else { + mappings.addPattern(key, target) + } + } + default: + mappings.addInvalid(".") + } + return mappings, true +} + +func unambiguousRuntimeTarget(value any) (string, bool) { + targets := make(map[string]bool) + valid := true + var collect func(any, bool) + collect = func(current any, typeOnly bool) { + if typeOnly { + return + } + switch current := current.(type) { + case string: + if !isTypeOnlyTarget(current) { + targets[current] = true + } + case map[string]any: + for key, nested := range current { + if key == "types" || strings.HasPrefix(key, "types@") { + collect(nested, true) + } else if isJSRuntimeCondition(key) { + collect(nested, false) + } else { + valid = false + } + } + default: + valid = false + } + } + collect(value, false) + if !valid || len(targets) != 1 { + return "", false + } + for target := range targets { + return target, true + } + return "", false +} + +func isJSRuntimeCondition(condition string) bool { + switch condition { + case "default", "import", "require", "module-sync", "node", "node-addons", "browser", "development", "production", "deno", "bun": + return true + default: + return false + } +} + +func isTypeOnlyTarget(target string) bool { + for _, suffix := range []string{".d.ts", ".d.mts", ".d.cts"} { + if strings.HasSuffix(target, suffix) { + return true + } + } + return false +} + +func parsePackageImports(value any) jsSpecifierMap { + mappings := newJSSpecifierMap() + imports, ok := value.(map[string]any) + if !ok { + return mappings + } + for key, target := range imports { + if !strings.HasPrefix(key, "#") || key == "#" || strings.HasPrefix(key, "#/") { + continue + } + targetString, ok := target.(string) + if !ok { + mappings.addInvalid(key) + continue + } + mappings.addPattern(key, targetString) + } + return mappings +} + +func parseDenoImports(value any) jsSpecifierMap { + mappings := newJSSpecifierMap() + imports, ok := value.(map[string]any) + if !ok { + return mappings + } + for key, target := range imports { + targetString, ok := target.(string) + if !ok { + mappings.addInvalid(key) + continue + } + if strings.HasSuffix(key, "/") { + mappings.addPrefix(key, targetString) + } else { + mappings.addExact(key, targetString) + } + } + return mappings +} + +func newJSSpecifierMap() jsSpecifierMap { + return jsSpecifierMap{exact: make(map[string]jsSpecifierTarget)} +} + +func (m *jsSpecifierMap) addExact(key, target string) { + m.exact[key] = jsSpecifierTarget{key: key, target: target, valid: validLocalTarget(target)} +} + +func (m *jsSpecifierMap) addInvalid(key string) { + m.exact[key] = jsSpecifierTarget{key: key} +} + +func (m *jsSpecifierMap) addPrefix(key, target string) { + m.dynamic = append(m.dynamic, jsSpecifierTarget{ + key: key, + target: target, + valid: strings.HasSuffix(target, "/") && validLocalTarget(target), + prefix: true, + }) +} + +func (m *jsSpecifierMap) addPattern(key, target string) { + keyStars := strings.Count(key, "*") + targetStars := strings.Count(target, "*") + if keyStars == 0 { + m.addExact(key, target) + return + } + m.dynamic = append(m.dynamic, jsSpecifierTarget{ + key: key, + target: target, + valid: keyStars == 1 && targetStars == 1 && validLocalTarget(target), + }) +} + +func unambiguousEntryTargets(doc map[string]any) []string { + seen := make(map[string]bool) + for _, key := range []string{"module", "main"} { + if target, ok := doc[key].(string); ok { + target = strings.TrimSpace(filepath.ToSlash(target)) + if target != "" && !strings.Contains(target, ":") && !path.IsAbs(target) { + if !strings.HasPrefix(target, "./") { + target = "./" + target + } + if validLocalTarget(target) { + seen[target] = true + } + } + } + } + if len(seen) != 1 { + return nil + } + for target := range seen { + return []string{target} + } + return nil +} + +func parseWorkspacePatterns(value any, objectKey string) []string { + if object, ok := value.(map[string]any); ok { + value = object[objectKey] + } + items, ok := value.([]any) + if !ok { + return nil + } + var patterns []string + for _, item := range items { + pattern, ok := item.(string) + if !ok { + return nil + } + pattern, ok = normalizeWorkspacePattern(pattern) + if !ok { + return nil + } + patterns = append(patterns, pattern) + } + return patterns +} + +func readPnpmWorkspace(manifestPath string) []string { + data, err := os.ReadFile(manifestPath) + if err != nil { + return nil + } + var workspace struct { + Packages []string `yaml:"packages"` + } + if err := yaml.Unmarshal(data, &workspace); err != nil { + return nil + } + patterns := make([]string, 0, len(workspace.Packages)) + for _, pattern := range workspace.Packages { + pattern, ok := normalizeWorkspacePattern(pattern) + if !ok { + return nil + } + patterns = append(patterns, pattern) + } + return patterns +} + +func parseTSOutputDirs(doc map[string]any) (string, string) { + options, _ := doc["compilerOptions"].(map[string]any) + rootDir, _ := options["rootDir"].(string) + outDir, _ := options["outDir"].(string) + rootDir = cleanManifestDir(rootDir) + outDir = cleanManifestDir(outDir) + return rootDir, outDir +} + +func cleanManifestDir(value string) string { + value = strings.TrimPrefix(strings.TrimSpace(filepath.ToSlash(value)), "./") + if value == "" || path.IsAbs(value) { + return "" + } + for _, part := range strings.Split(value, "/") { + if part == ".." { + return "" + } + } + return value +} + +func matchesWorkspaceMember(ownerRoot, memberRoot string, patterns []string) bool { + relative, err := filepath.Rel(repoPath(ownerRoot), repoPath(memberRoot)) + if err != nil { + return false + } + relative = filepath.ToSlash(relative) + if relative == "." || relative == ".." || strings.HasPrefix(relative, "../") { + return false + } + included := false + for _, pattern := range patterns { + excluded := strings.HasPrefix(pattern, "!") + pattern = strings.TrimPrefix(pattern, "!") + if matchWorkspaceGlob(pattern, relative) { + included = !excluded + } + } + return included +} + +func matchWorkspaceGlob(pattern, value string) bool { + patternParts := strings.Split(pattern, "/") + valueParts := strings.Split(value, "/") + type position struct{ pattern, value int } + memo := make(map[position]bool) + seen := make(map[position]bool) + + var match func(int, int) bool + match = func(patternIndex, valueIndex int) bool { + key := position{patternIndex, valueIndex} + if seen[key] { + return memo[key] + } + seen[key] = true + + switch { + case patternIndex == len(patternParts): + memo[key] = valueIndex == len(valueParts) + case patternParts[patternIndex] == "**": + memo[key] = match(patternIndex+1, valueIndex) || + (valueIndex < len(valueParts) && match(patternIndex, valueIndex+1)) + case valueIndex < len(valueParts): + segmentMatch, err := path.Match(patternParts[patternIndex], valueParts[valueIndex]) + memo[key] = err == nil && segmentMatch && match(patternIndex+1, valueIndex+1) + } + return memo[key] + } + return match(0, 0) +} + +func validWorkspacePattern(pattern string) bool { + _, ok := normalizeWorkspacePattern(pattern) + return ok +} + +func normalizeWorkspacePattern(pattern string) (string, bool) { + pattern = strings.TrimSpace(filepath.ToSlash(pattern)) + excluded := strings.HasPrefix(pattern, "!") + if excluded { + pattern = strings.TrimSpace(strings.TrimPrefix(pattern, "!")) + } + pattern = strings.TrimPrefix(pattern, "./") + if pattern == "" || strings.HasPrefix(pattern, "!") || path.IsAbs(pattern) || hasDrivePrefix(pattern) { + return "", false + } + for _, part := range strings.Split(pattern, "/") { + if part == ".." { + return "", false + } + if part == "**" { + continue + } + if _, err := path.Match(part, "candidate"); err != nil { + return "", false + } + } + if excluded { + pattern = "!" + pattern + } + return pattern, true +} + +func hasDrivePrefix(value string) bool { + return len(value) >= 2 && value[1] == ':' && + (value[0] >= 'A' && value[0] <= 'Z' || value[0] >= 'a' && value[0] <= 'z') +} + +func validLocalTarget(target string) bool { + if !strings.HasPrefix(target, "./") || strings.ContainsRune(target, 0) { + return false + } + trimmed := strings.TrimPrefix(filepath.ToSlash(target), "./") + if trimmed == "" { + return false + } + for _, part := range strings.Split(trimmed, "/") { + if part == ".." { + return false + } + } + return true +} + +func manifestLocalPath(root, target string) (string, bool) { + if !validLocalTarget(target) { + return "", false + } + relative := strings.TrimPrefix(filepath.ToSlash(target), "./") + resolved := cleanRepoPath(filepath.Join(repoPath(root), filepath.FromSlash(relative))) + if !pathContains(root, resolved) { + return "", false + } + return resolved, true +} + +func splitJSPackageSpecifier(specifier string) (string, string, bool) { + if specifier == "" || strings.HasPrefix(specifier, ".") || strings.HasPrefix(specifier, "#") { + return "", "", false + } + parts := strings.Split(specifier, "/") + if strings.HasPrefix(specifier, "@") { + if len(parts) < 2 || parts[0] == "" || parts[1] == "" { + return "", "", false + } + return strings.Join(parts[:2], "/"), strings.Join(parts[2:], "/"), true + } + if parts[0] == "" { + return "", "", false + } + return parts[0], strings.Join(parts[1:], "/"), true +} + +func isExternalJSSpecifier(specifier string) bool { + lower := strings.ToLower(specifier) + for _, prefix := range []string{ + "node:", "bun:", "npm:", "jsr:", "http:", "https:", "data:", + } { + if strings.HasPrefix(lower, prefix) { + return true + } + } + return false +} + +func pathContains(root, file string) bool { + root = cleanRepoPath(root) + file = cleanRepoPath(file) + return root == "" || file == root || strings.HasPrefix(file, root+string(filepath.Separator)) +} + +func cleanRepoPath(value string) string { + value = filepath.Clean(value) + if value == "." { + return "" + } + return value +} + +func repoPath(value string) string { + if value == "" { + return "." + } + return value +} + +func readJSWorkspaceManifest(path string) (map[string]any, bool) { + data, err := os.ReadFile(path) + if err != nil { + return nil, false + } + doc, err := stripJSONC(data) + return doc, err == nil +} + +func stripJSONC(data []byte) (map[string]any, error) { + withoutComments, err := removeJSONComments(data) + if err != nil { + return nil, err + } + withoutTrailingCommas := removeJSONTrailingCommas(withoutComments) + var value map[string]any + if err := json.Unmarshal(withoutTrailingCommas, &value); err != nil { + return nil, err + } + return value, nil +} + +func removeJSONComments(data []byte) ([]byte, error) { + result := make([]byte, 0, len(data)) + inString := false + escaped := false + for i := 0; i < len(data); i++ { + current := data[i] + if inString { + result = append(result, current) + switch { + case escaped: + escaped = false + case current == '\\': + escaped = true + case current == '"': + inString = false + } + continue + } + if current == '"' { + inString = true + result = append(result, current) + continue + } + if current != '/' || i+1 >= len(data) { + result = append(result, current) + continue + } + switch data[i+1] { + case '/': + i += 2 + for ; i < len(data) && data[i] != '\n'; i++ { + result = append(result, ' ') + } + if i < len(data) { + result = append(result, data[i]) + } + case '*': + i += 2 + closed := false + for ; i < len(data); i++ { + if data[i] == '\n' { + result = append(result, '\n') + } else { + result = append(result, ' ') + } + if i+1 < len(data) && data[i] == '*' && data[i+1] == '/' { + result = append(result, ' ') + i++ + closed = true + break + } + } + if !closed { + return nil, errors.New("unterminated JSON block comment") + } + default: + result = append(result, current) + } + } + return result, nil +} + +func removeJSONTrailingCommas(data []byte) []byte { + result := make([]byte, 0, len(data)) + inString := false + escaped := false + for i := 0; i < len(data); i++ { + current := data[i] + if inString { + result = append(result, current) + switch { + case escaped: + escaped = false + case current == '\\': + escaped = true + case current == '"': + inString = false + } + continue + } + if current == '"' { + inString = true + result = append(result, current) + continue + } + if current == ',' { + next := i + 1 + for next < len(data) && (data[next] == ' ' || data[next] == '\t' || data[next] == '\r' || data[next] == '\n') { + next++ + } + if next < len(data) && (data[next] == '}' || data[next] == ']') { + continue + } + } + result = append(result, current) + } + return result +} diff --git a/scanner/jsworkspace_test.go b/scanner/jsworkspace_test.go new file mode 100644 index 0000000..0997894 --- /dev/null +++ b/scanner/jsworkspace_test.go @@ -0,0 +1,408 @@ +package scanner + +import ( + "context" + "os" + "path/filepath" + "reflect" + "sort" + "testing" +) + +func TestBuildFileGraphResolvesPackageWorkspaces(t *testing.T) { + tests := []struct{ name, workspaces, memberDir string }{ + {"node nested workspace glob", `["packages/**"]`, "packages/components/ui"}, + {"bun workspace object", `{"packages":["modules/*"]}`, "modules/ui"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + root := t.TempDir() + writeWorkspaceFile(t, root, "package.json", `{"name":"workspace-root","workspaces":`+tt.workspaces+`}`) + writeWorkspaceFile(t, root, ".codemap/config.json", `{"only":["ts"]}`) + writeWorkspaceFile(t, root, tt.memberDir+"/package.json", + `{"name":"@acme/ui","exports":{".":"./src/index.ts","./button":"./src/button.ts","./features/*":"./src/features/*.ts"},"imports":{"#internal/*":"./src/internal/*.ts"}}`) + writeWorkspaceFiles(t, root, + "app/main.ts", "app/local.ts", tt.memberDir+"/src/index.ts", tt.memberDir+"/src/button.ts", + tt.memberDir+"/src/features/card.ts", + tt.memberDir+"/src/internal/util.ts", tt.memberDir+"/src/consumer.ts", + ) + graph := buildWorkspaceGraph(t, root, + workspaceAnalysis("app/main.ts", + "@acme/ui", "@acme/ui/button", "@acme/ui/features/card", "#internal/util", + "node:path", "external-package", "./local", + ), + workspaceAnalysis(tt.memberDir+"/src/consumer.ts", "#internal/util"), + ) + assertWorkspaceImports(t, graph, "app/main.ts", []string{ + filepath.FromSlash("app/local.ts"), + filepath.FromSlash(tt.memberDir + "/src/button.ts"), + filepath.FromSlash(tt.memberDir + "/src/features/card.ts"), + filepath.FromSlash(tt.memberDir + "/src/index.ts"), + }) + assertWorkspaceImports(t, graph, tt.memberDir+"/src/consumer.ts", []string{ + filepath.FromSlash(tt.memberDir + "/src/internal/util.ts"), + }) + }) + } +} + +func TestBuildFileGraphResolvesDenoAndHybridWorkspaces(t *testing.T) { + root := t.TempDir() + writeWorkspaceFile(t, root, "deno.jsonc", `{ + // URL comments and trailing commas exercise JSONC normalization. + "imports":{"@exact":"./lib/exact.ts","@core/":"./lib/core/","url-literal":"https://example.com/a//b"}, + "workspace":["./members/tool","./members/hybrid"], + }`) + writeWorkspaceFile(t, root, "members/tool/deno.json", + `{"name":"@deno/tool","exports":"./mod.ts","imports":{"@exact":"./local.ts"}}`) + writeWorkspaceFile(t, root, "members/hybrid/package.json", + `{"name":"@hybrid/pkg","exports":{".":"./src/index.ts"}}`) + + writeWorkspaceFiles(t, root, + "app/main.ts", "lib/exact.ts", "lib/core/util.ts", + "members/tool/mod.ts", "members/tool/local.ts", "members/tool/consumer.ts", + "members/hybrid/src/index.ts", + ) + + graph := buildWorkspaceGraph(t, root, + workspaceAnalysis("app/main.ts", + "@exact", "@core/util.ts", "@deno/tool", "@hybrid/pkg", "url-literal", + "npm:chalk", "jsr:@std/path", "bun:test", "node:path", "https://example.com/mod.ts", + "data:text/javascript,export default 1", + ), + workspaceAnalysis("members/tool/consumer.ts", "@exact"), + ) + + assertWorkspaceImports(t, graph, "app/main.ts", []string{ + filepath.FromSlash("lib/core/util.ts"), + filepath.FromSlash("lib/exact.ts"), + filepath.FromSlash("members/hybrid/src/index.ts"), + filepath.FromSlash("members/tool/mod.ts"), + }) + assertWorkspaceImports(t, graph, "members/tool/consumer.ts", []string{ + filepath.FromSlash("members/tool/local.ts"), + }) +} + +func TestBuildFileGraphResolvesDenoObjectWorkspaceMembers(t *testing.T) { + root := t.TempDir() + writeWorkspaceFile(t, root, "deno.json", `{"workspace":{"members":["members/tool"]}}`) + writeWorkspaceFile(t, root, "members/tool/deno.json", `{"name":"@deno/tool","exports":"./mod.ts"}`) + writeWorkspaceFiles(t, root, "app.ts", "members/tool/mod.ts") + graph := buildWorkspaceGraph(t, root, workspaceAnalysis("app.ts", "@deno/tool")) + assertWorkspaceImports(t, graph, "app.ts", []string{filepath.FromSlash("members/tool/mod.ts")}) +} + +func TestBuildFileGraphCombinesWorkspaceDeclarationsAtSameRoot(t *testing.T) { + root := t.TempDir() + writeWorkspaceFile(t, root, "package.json", `{"workspaces":["packages/*"]}`) + writeWorkspaceFile(t, root, "pnpm-workspace.yaml", "packages:\n - \"tools/*\"\n") + writeWorkspaceFile(t, root, "packages/web/package.json", `{"name":"web","exports":"./index.ts"}`) + writeWorkspaceFile(t, root, "tools/cli/package.json", `{"name":"tooling","exports":"./index.ts"}`) + writeWorkspaceFiles(t, root, "app.ts", "packages/web/index.ts", "tools/cli/index.ts") + graph := buildWorkspaceGraph(t, root, workspaceAnalysis("app.ts", "web", "tooling")) + assertWorkspaceImports(t, graph, "app.ts", []string{ + filepath.FromSlash("packages/web/index.ts"), + filepath.FromSlash("tools/cli/index.ts"), + }) +} + +func TestJSWorkspaceResolverUsesExplicitFilters(t *testing.T) { + root := t.TempDir() + writeWorkspaceFile(t, root, ".codemap/config.json", `{"exclude":["packages/web"]}`) + writeWorkspaceFile(t, root, "package.json", `{"workspaces":["packages/*"]}`) + writeWorkspaceFile(t, root, "packages/web/package.json", `{"name":"web","exports":"./index.ts"}`) + writeWorkspaceFile(t, root, "app.ts", "") + writeWorkspaceFile(t, root, "packages/web/index.ts", "") + + graph, err := BuildFileGraphFromFilteredAnalyses(root, []FileAnalysis{{ + Path: "app.ts", Language: "typescript", Imports: []string{"web"}, + }}, Filters{}) + if err != nil { + t.Fatal(err) + } + assertWorkspaceImports(t, graph, "app.ts", []string{filepath.FromSlash("packages/web/index.ts")}) +} + +func TestJSWorkspaceResolverOnlyWidensJavaScriptScans(t *testing.T) { + if needsJSWorkspaceResolver([]FileAnalysis{{Path: "main.go", Language: "go"}}) { + t.Fatal("Go-only analysis should not widen the filtered file scan") + } + if !needsJSWorkspaceResolver([]FileAnalysis{{Path: "app.ts", Language: "typescript"}}) { + t.Fatal("TypeScript analysis should enable workspace manifest scanning") + } +} + +func TestBuildJSWorkspaceResolverHonorsCancellation(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + if _, err := buildJSWorkspaceResolver(ctx, t.TempDir(), nil); err != context.Canceled { + t.Fatalf("buildJSWorkspaceResolver() error = %v, want context.Canceled", err) + } +} + +func TestAddJSWorkspaceMembersHonorsMidBuildCancellation(t *testing.T) { + ctx := &cancelAfterContext{Context: context.Background(), remaining: 1} + workspace := newJSPackageWorkspace("") + members := map[string]*jsWorkspaceManifest{ + "a": {root: "packages/a", pkg: &jsWorkspacePackage{root: "packages/a", name: "a"}}, + "b": {root: "packages/b", pkg: &jsWorkspacePackage{root: "packages/b", name: "b"}}, + "c": {root: "packages/c", pkg: &jsWorkspacePackage{root: "packages/c", name: "c"}}, + } + if err := addJSWorkspaceMembers(ctx, workspace, "", []string{"packages/*"}, members); err != context.Canceled { + t.Fatalf("addJSWorkspaceMembers() error = %v, want context.Canceled", err) + } + if len(workspace.packages) > 1 { + t.Fatalf("added %d packages after cancellation boundary, want at most 1", len(workspace.packages)) + } +} + +type cancelAfterContext struct { + context.Context + remaining int +} + +func (c *cancelAfterContext) Err() error { + if c.remaining == 0 { + return context.Canceled + } + c.remaining-- + return nil +} + +func TestBuildFileGraphResolvesWorkspaceEntriesWithoutExports(t *testing.T) { + root := t.TempDir() + writeWorkspaceFile(t, root, "package.json", `{"workspaces":["packages/*"]}`) + writeWorkspaceFile(t, root, "packages/plain/package.json", `{"name":"@acme/plain","main":"src/index.ts"}`) + writeWorkspaceFile(t, root, "packages/conflict/package.json", + `{"name":"@acme/conflict","module":"src/module.ts","main":"src/main.ts"}`) + writeWorkspaceFiles(t, root, + "app/main.ts", + "packages/plain/src/index.ts", + "packages/plain/feature.ts", + "packages/conflict/src/module.ts", + "packages/conflict/src/main.ts", + ) + + graph := buildWorkspaceGraph(t, root, + workspaceAnalysis("app/main.ts", "@acme/plain", "@acme/plain/feature", "@acme/conflict"), + ) + assertWorkspaceImports(t, graph, "app/main.ts", []string{ + filepath.FromSlash("packages/plain/feature.ts"), + filepath.FromSlash("packages/plain/src/index.ts"), + }) +} + +func TestBuildFileGraphResolvesPnpmConditionalExportsToSources(t *testing.T) { + root := t.TempDir() + writeWorkspaceFile(t, root, "pnpm-workspace.yaml", "packages:\n - packages/**\n - '!packages/excluded'\n") + writeWorkspaceFile(t, root, "packages/foundation/core/package.json", + `{"name":"@acme/core","exports":{"./*":{"import":"./dist/*.js","types":"./dist/*.d.ts","types@>=5.0":"./dist/*.d.ts"}}}`) + writeWorkspaceFile(t, root, "packages/foundation/core/tsconfig.json", + `{"compilerOptions":{"rootDir":"src","outDir":"dist"}}`) + writeWorkspaceFile(t, root, "packages/ambiguous/package.json", + `{"name":"@acme/ambiguous","exports":{".":{"import":"./dist/import.js","require":"./dist/require.cjs"},"./array":["./dist/one.js","./dist/two.js"],"./empty":null}}`) + writeWorkspaceFile(t, root, "packages/ambiguous/tsconfig.json", + `{"compilerOptions":{"rootDir":"src","outDir":"dist"}}`) + writeWorkspaceFile(t, root, "packages/no-map/package.json", + `{"name":"@acme/no-map","exports":{"./*":{"import":"./dist/*.js"}}}`) + writeWorkspaceFile(t, root, "packages/excluded/package.json", + `{"name":"@acme/excluded","exports":"./src/index.ts"}`) + + writeWorkspaceFiles(t, root, + "app/main.ts", + "packages/foundation/core/src/context.ts", + "packages/ambiguous/src/import.ts", + "packages/ambiguous/src/require.ts", + "packages/ambiguous/src/one.ts", + "packages/ambiguous/src/two.ts", + "packages/no-map/src/context.ts", + "packages/excluded/src/index.ts", + ) + + graph := buildWorkspaceGraph(t, root, + workspaceAnalysis("app/main.ts", + "@acme/core/context", + "@acme/ambiguous", + "@acme/ambiguous/array", + "@acme/ambiguous/empty", + "@acme/no-map/context", + "@acme/excluded", + ), + ) + assertWorkspaceImports(t, graph, "app/main.ts", []string{ + filepath.FromSlash("packages/foundation/core/src/context.ts"), + }) +} + +func TestBuildFileGraphDoesNotLeakPackagesAcrossWorkspaceOwners(t *testing.T) { + root := t.TempDir() + writeWorkspaceFile(t, root, "one/package.json", `{"name":"one-root","workspaces":["packages/*"]}`) + writeWorkspaceFile(t, root, "one/packages/local/package.json", `{"name":"@acme/local","exports":"./src/index.ts"}`) + writeWorkspaceFile(t, root, "two/deno.json", `{"workspace":["./packages/external"]}`) + writeWorkspaceFile(t, root, "two/packages/external/package.json", `{"name":"@acme/external","exports":"./src/index.ts"}`) + writeWorkspaceFiles(t, root, + "one/app/main.ts", + "one/packages/local/src/index.ts", + "two/packages/external/src/index.ts", + ) + + graph := buildWorkspaceGraph(t, root, + workspaceAnalysis("one/app/main.ts", "@acme/local", "@acme/external"), + ) + assertWorkspaceImports(t, graph, "one/app/main.ts", []string{ + filepath.FromSlash("one/packages/local/src/index.ts"), + }) +} + +func TestBuildFileGraphAppliesOrderedWorkspacePatterns(t *testing.T) { + root := t.TempDir() + writeWorkspaceFile(t, root, "package.json", `{"workspaces":["packages/*"," !packages/private ","!packages/restored","packages/restored"]}`) + writeWorkspaceFile(t, root, "packages/ui/package.json", `{"name":"@acme/ui","exports":"./src/index.ts"}`) + writeWorkspaceFile(t, root, "packages/private/package.json", `{"name":"@acme/private","exports":"./src/index.ts"}`) + writeWorkspaceFile(t, root, "packages/restored/package.json", `{"name":"@acme/restored","exports":"./src/index.ts"}`) + writeWorkspaceFiles(t, root, "packages/ui/src/index.ts", "packages/private/src/index.ts", "packages/restored/src/index.ts") + writeWorkspaceFile(t, root, "app/main.ts", "") + + graph := buildWorkspaceGraph(t, root, workspaceAnalysis("app/main.ts", "@acme/ui", "@acme/private", "@acme/restored")) + assertWorkspaceImports(t, graph, "app/main.ts", []string{ + filepath.FromSlash("packages/restored/src/index.ts"), + filepath.FromSlash("packages/ui/src/index.ts"), + }) +} + +func TestValidWorkspacePatterns(t *testing.T) { + for pattern, want := range map[string]bool{ + "!packages/private": true, + "!../outside": false, + "!!packages/*": false, + "!": false, + "C:/outside": false, + "!C:/outside": false, + } { + if got := validWorkspacePattern(pattern); got != want { + t.Errorf("validWorkspacePattern(%q) = %v, want %v", pattern, got, want) + } + } +} + +func TestBuildFileGraphRejectsInvalidWorkspacePatterns(t *testing.T) { + root := t.TempDir() + writeWorkspaceFile(t, root, "package.json", `{"workspaces":["packages/*","../outside/*"]}`) + writeWorkspaceFile(t, root, "packages/ui/package.json", `{"name":"@acme/ui","exports":"./src/index.ts"}`) + writeWorkspaceFiles(t, root, "packages/ui/src/index.ts", "app/main.ts") + graph := buildWorkspaceGraph(t, root, workspaceAnalysis("app/main.ts", "@acme/ui")) + assertWorkspaceImports(t, graph, "app/main.ts", nil) +} + +func TestBuildFileGraphRejectsAmbiguousOrEscapingWorkspaceTargets(t *testing.T) { + root := t.TempDir() + writeWorkspaceFile(t, root, "package.json", `{"workspaces":["packages/*"]}`) + + manifests := map[string]string{ + "packages/dup-a/package.json": `{"name":"@acme/duplicate","exports":"./src/index.ts"}`, + "packages/dup-b/package.json": `{"name":"@acme/duplicate","exports":"./src/index.ts"}`, + "packages/conditional/package.json": `{"name":"@acme/conditional","exports":{".":{"import":"./src/import.ts","require":"./src/require.ts"}}}`, + "packages/escape/package.json": `{"name":"@acme/escape","exports":"../outside.ts"}`, + "packages/unknown/package.json": `{"name":"@acme/unknown","exports":{".":{"custom":"./src/index.ts"}}}`, + "packages/typesfoo/package.json": `{"name":"@acme/typesfoo","exports":{".":{"typesfoo":"./src/index.ts","import":"./src/index.ts"}}}`, + "packages/declaration/package.json": `{"name":"@acme/declaration","exports":"./src/index.d.ts"}`, + "packages/incompatible/package.json": `{"name":"@acme/incompatible","exports":"./src/index.go"}`, + "packages/implicit/package.json": `{"name":"@acme/implicit","exports":"./src/index"}`, + } + for path, body := range manifests { + writeWorkspaceFile(t, root, path, body) + } + writeWorkspaceFiles(t, root, + "app/main.ts", + "outside.ts", + "packages/dup-a/src/index.ts", + "packages/dup-b/src/index.ts", + "packages/conditional/src/import.ts", + "packages/conditional/src/require.ts", + "packages/unknown/src/index.ts", + "packages/typesfoo/src/index.ts", + "packages/declaration/src/index.d.ts", + "packages/incompatible/src/index.go", + "packages/implicit/src/index.ts", + ) + + graph := buildWorkspaceGraph(t, root, + workspaceAnalysis("app/main.ts", "@acme/duplicate", "@acme/conditional", "@acme/escape", "@acme/unknown", "@acme/typesfoo", "@acme/declaration", "@acme/incompatible", "@acme/implicit"), + ) + assertWorkspaceImports(t, graph, "app/main.ts", nil) +} + +func TestStripJSONC(t *testing.T) { + input := []byte(`{ + "url": "https://example.com/a//b", + "quoted": "/* not a comment */", + // line comment + "array": [1, 2,], + /* block + comment */ + "object": {"ok": true,}, + }`) + + got, err := stripJSONC(input) + if err != nil { + t.Fatal(err) + } + want := map[string]any{ + "url": "https://example.com/a//b", + "quoted": "/* not a comment */", + "array": []any{float64(1), float64(2)}, + "object": map[string]any{"ok": true}, + } + if !reflect.DeepEqual(got, want) { + t.Fatalf("stripJSONC() = %#v, want %#v", got, want) + } + + if _, err := stripJSONC([]byte(`{"value": /* unterminated`)); err == nil { + t.Fatal("expected unterminated block comment to fail") + } + if _, err := stripJSONC([]byte(`{"value": invalid}`)); err == nil { + t.Fatal("expected invalid JSON to fail") + } +} + +func writeWorkspaceFile(t *testing.T, root, path, body string) { + t.Helper() + 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(body), 0o644); err != nil { + t.Fatal(err) + } +} + +func writeWorkspaceFiles(t *testing.T, root string, paths ...string) { + t.Helper() + for _, path := range paths { + writeWorkspaceFile(t, root, path, "") + } +} + +func workspaceAnalysis(path string, imports ...string) FileAnalysis { + return FileAnalysis{Path: filepath.FromSlash(path), Language: "typescript", Imports: imports} +} + +func buildWorkspaceGraph(t *testing.T, root string, analyses ...FileAnalysis) *FileGraph { + t.Helper() + graph, err := BuildFileGraphFromAnalyses(root, analyses) + if err != nil { + t.Fatal(err) + } + return graph +} + +func assertWorkspaceImports(t *testing.T, graph *FileGraph, path string, want []string) { + t.Helper() + got := append([]string(nil), graph.Imports[filepath.FromSlash(path)]...) + sort.Strings(got) + sort.Strings(want) + if !reflect.DeepEqual(got, want) { + t.Fatalf("imports[%q] = %v, want %v", path, got, want) + } +}