From ef6497c175238a0608087c93639e0c92628309d3 Mon Sep 17 00:00:00 2001 From: Rene Leonhardt <65483435+reneleonhardt@users.noreply.github.com> Date: Sun, 2 Aug 2026 14:43:42 +0200 Subject: [PATCH] feat(scanner): Resolve JS/TS workspace imports Index package, pnpm, Bun, and Deno workspace manifests so local bare imports resolve without invoking external tooling. Preserve relative and tsconfig resolution, and fail closed for external, ambiguous, escaping, or unsupported targets. Signed-off-by: Rene Leonhardt <65483435+reneleonhardt@users.noreply.github.com> Co-Authored-By: GPT-5.6 Sol --- scanner/filegraph.go | 43 +- scanner/jsworkspace.go | 976 ++++++++++++++++++++++++++++++++++++ scanner/jsworkspace_test.go | 408 +++++++++++++++ 3 files changed, 1424 insertions(+), 3 deletions(-) create mode 100644 scanner/jsworkspace.go create mode 100644 scanner/jsworkspace_test.go 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) + } +}