Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
0.19.0
0.19.1
50 changes: 37 additions & 13 deletions internal/analyzer/analyzer.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
22 changes: 17 additions & 5 deletions main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
Expand Down
Loading