Skip to content

Commit ce8c591

Browse files
authored
fix: Global changeDirs taint losing downstream targets
2 parents 72b473f + 166c3c3 commit ce8c591

4 files changed

Lines changed: 60 additions & 19 deletions

File tree

CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,11 @@ All notable changes to this project will be documented in this file.
55
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
66
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
77

8+
## [0.19.1] - 2026-04-23
9+
10+
### Fixed
11+
- 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.
12+
813
## [0.19.0] - 2026-04-20
914

1015
### Added

VERSION

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
0.19.0
1+
0.19.1

internal/analyzer/analyzer.go

Lines changed: 37 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -95,28 +95,52 @@ func FindEntrypoints(projectFolder string, pkg rush.PackageJSON) []Entrypoint {
9595
return entrypoints
9696
}
9797

98-
// CollectEntrypointExports parses an entrypoint file and returns all export names.
98+
// CollectEntrypointExports returns every export name reachable from an entrypoint,
99+
// recursively following `export * from "./local"` chains within the same project.
100+
// If an `export *` points at a source that cannot be enumerated (external package
101+
// or unresolvable path), "*" is included as a wildcard fallback marker.
99102
func CollectEntrypointExports(projectFolder string, ep Entrypoint) []string {
100-
fullPath := filepath.Join(projectFolder, ep.SourceFile)
103+
seen := make(map[string]bool)
104+
visited := make(map[string]bool)
105+
collectExportsFromFile(projectFolder, ep.SourceFile, seen, visited)
106+
names := make([]string, 0, len(seen))
107+
for n := range seen {
108+
names = append(names, n)
109+
}
110+
sort.Strings(names)
111+
debugf("CollectEntrypointExports: %s (%s) → %d exports", ep.ExportPath, ep.SourceFile, len(names))
112+
return names
113+
}
114+
115+
func collectExportsFromFile(projectFolder, relFile string, seen, visited map[string]bool) {
116+
if visited[relFile] {
117+
return
118+
}
119+
visited[relFile] = true
120+
fullPath := filepath.Join(projectFolder, relFile)
101121
analysis, err := tsparse.ParseFile(fullPath)
102122
if err != nil {
103-
debugf("CollectEntrypointExports: parse error for %s: %v", fullPath, err)
104-
return nil
123+
debugf("collectExportsFromFile: parse error for %s: %v", fullPath, err)
124+
return
105125
}
106-
var names []string
107-
seen := make(map[string]bool)
126+
fileDir := filepath.Dir(relFile)
108127
for _, exp := range analysis.Exports {
109-
name := exp.Name
110-
if name == "*" {
128+
if exp.IsStar && exp.Name == "*" {
129+
if strings.HasPrefix(exp.Source, ".") {
130+
resolved := resolveImportToFile(fileDir, exp.Source, projectFolder)
131+
if resolved != "" {
132+
collectExportsFromFile(projectFolder, resolved, seen, visited)
133+
continue
134+
}
135+
}
136+
seen["*"] = true
111137
continue
112138
}
113-
if !seen[name] {
114-
seen[name] = true
115-
names = append(names, name)
139+
if exp.Name == "" {
140+
continue
116141
}
142+
seen[exp.Name] = true
117143
}
118-
debugf("CollectEntrypointExports: %s (%s) → %d exports", ep.ExportPath, ep.SourceFile, len(names))
119-
return names
120144
}
121145

122146
// HasTaintedImports checks if any source file in the given folder imports

main.go

Lines changed: 17 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -280,15 +280,27 @@ func main() {
280280
logf(" Changed external deps: %s\n", strings.Join(depNames, ", "))
281281
}
282282

283-
// Global changeDirs: if triggered, taint all exports (skip expensive analysis)
283+
// Global changeDirs: if triggered, enumerate all exports per entrypoint
284+
// and seed them as tainted (skip expensive per-symbol analysis).
284285
libCfg := configMap[info.ProjectFolder]
285286
if libCfg != nil && len(libCfg.ChangeDirs) > 0 {
286287
if globalChangeDirTriggered(libCfg.ChangeDirs, changedFiles, info.ProjectFolder, libCfg) {
287-
logf(" Global changeDirs triggered — all exports tainted\n\n")
288-
if allUpstreamTaint[pkgName] == nil {
289-
allUpstreamTaint[pkgName] = make(map[string]bool)
288+
totalExports := 0
289+
for _, ep := range entrypoints {
290+
specifier := pkgName
291+
if ep.ExportPath != "." {
292+
specifier = pkgName + strings.TrimPrefix(ep.ExportPath, ".")
293+
}
294+
exports := analyzer.CollectEntrypointExports(info.ProjectFolder, ep)
295+
if allUpstreamTaint[specifier] == nil {
296+
allUpstreamTaint[specifier] = make(map[string]bool)
297+
}
298+
for _, name := range exports {
299+
allUpstreamTaint[specifier][name] = true
300+
}
301+
totalExports += len(exports)
290302
}
291-
allUpstreamTaint[pkgName]["*"] = true
303+
logf(" Global changeDirs triggered — %d exports tainted across %d entrypoints\n\n", totalExports, len(entrypoints))
292304
continue
293305
}
294306
}

0 commit comments

Comments
 (0)