From 166c3c39c9cb2e0d5118c44d25d93a32f3f5f3fc Mon Sep 17 00:00:00 2001 From: Martin Najemi Date: Thu, 23 Apr 2026 15:23:15 +0200 Subject: [PATCH] fix: Global changeDirs taint losing downstream targets Risk: low --- CHANGELOG.md | 5 ++++ VERSION | 2 +- internal/analyzer/analyzer.go | 50 ++++++++++++++++++++++++++--------- main.go | 22 +++++++++++---- 4 files changed, 60 insertions(+), 19 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a9a48ac..12cb528 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,11 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.19.1] - 2026-04-23 + +### Fixed +- Global `changeDirs` taint now enumerates every export from each entrypoint (including recursively via `export * from "./local"`) instead of seeding a `"*"` wildcard. Downstream packages consume exports by exact name, so the wildcard never matched named imports — taint stopped at the first hop and targets transitively dependent on the tainted library were missed. + ## [0.19.0] - 2026-04-20 ### Added diff --git a/VERSION b/VERSION index 3f46c4d..4559101 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.19.0 \ No newline at end of file +0.19.1 \ No newline at end of file diff --git a/internal/analyzer/analyzer.go b/internal/analyzer/analyzer.go index 0102015..9d9d060 100644 --- a/internal/analyzer/analyzer.go +++ b/internal/analyzer/analyzer.go @@ -95,28 +95,52 @@ func FindEntrypoints(projectFolder string, pkg rush.PackageJSON) []Entrypoint { return entrypoints } -// CollectEntrypointExports parses an entrypoint file and returns all export names. +// CollectEntrypointExports returns every export name reachable from an entrypoint, +// recursively following `export * from "./local"` chains within the same project. +// If an `export *` points at a source that cannot be enumerated (external package +// or unresolvable path), "*" is included as a wildcard fallback marker. func CollectEntrypointExports(projectFolder string, ep Entrypoint) []string { - fullPath := filepath.Join(projectFolder, ep.SourceFile) + seen := make(map[string]bool) + visited := make(map[string]bool) + collectExportsFromFile(projectFolder, ep.SourceFile, seen, visited) + names := make([]string, 0, len(seen)) + for n := range seen { + names = append(names, n) + } + sort.Strings(names) + debugf("CollectEntrypointExports: %s (%s) → %d exports", ep.ExportPath, ep.SourceFile, len(names)) + return names +} + +func collectExportsFromFile(projectFolder, relFile string, seen, visited map[string]bool) { + if visited[relFile] { + return + } + visited[relFile] = true + fullPath := filepath.Join(projectFolder, relFile) analysis, err := tsparse.ParseFile(fullPath) if err != nil { - debugf("CollectEntrypointExports: parse error for %s: %v", fullPath, err) - return nil + debugf("collectExportsFromFile: parse error for %s: %v", fullPath, err) + return } - var names []string - seen := make(map[string]bool) + fileDir := filepath.Dir(relFile) for _, exp := range analysis.Exports { - name := exp.Name - if name == "*" { + if exp.IsStar && exp.Name == "*" { + if strings.HasPrefix(exp.Source, ".") { + resolved := resolveImportToFile(fileDir, exp.Source, projectFolder) + if resolved != "" { + collectExportsFromFile(projectFolder, resolved, seen, visited) + continue + } + } + seen["*"] = true continue } - if !seen[name] { - seen[name] = true - names = append(names, name) + if exp.Name == "" { + continue } + seen[exp.Name] = true } - debugf("CollectEntrypointExports: %s (%s) → %d exports", ep.ExportPath, ep.SourceFile, len(names)) - return names } // HasTaintedImports checks if any source file in the given folder imports diff --git a/main.go b/main.go index 6a54a41..30ef160 100644 --- a/main.go +++ b/main.go @@ -280,15 +280,27 @@ func main() { logf(" Changed external deps: %s\n", strings.Join(depNames, ", ")) } - // Global changeDirs: if triggered, taint all exports (skip expensive analysis) + // Global changeDirs: if triggered, enumerate all exports per entrypoint + // and seed them as tainted (skip expensive per-symbol analysis). libCfg := configMap[info.ProjectFolder] if libCfg != nil && len(libCfg.ChangeDirs) > 0 { if globalChangeDirTriggered(libCfg.ChangeDirs, changedFiles, info.ProjectFolder, libCfg) { - logf(" Global changeDirs triggered — all exports tainted\n\n") - if allUpstreamTaint[pkgName] == nil { - allUpstreamTaint[pkgName] = make(map[string]bool) + totalExports := 0 + for _, ep := range entrypoints { + specifier := pkgName + if ep.ExportPath != "." { + specifier = pkgName + strings.TrimPrefix(ep.ExportPath, ".") + } + exports := analyzer.CollectEntrypointExports(info.ProjectFolder, ep) + if allUpstreamTaint[specifier] == nil { + allUpstreamTaint[specifier] = make(map[string]bool) + } + for _, name := range exports { + allUpstreamTaint[specifier][name] = true + } + totalExports += len(exports) } - allUpstreamTaint[pkgName]["*"] = true + logf(" Global changeDirs triggered — %d exports tainted across %d entrypoints\n\n", totalExports, len(entrypoints)) continue } }