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.21.1] - 2026-05-31

### Fixed
- Aliased named imports (`import { X as Y }`) now propagate taint correctly. The import parser only captured the local binding name (`Y`) and discarded the original imported name (`X`), so taint matching — which keys on the name the source module exports — never matched, and propagation stopped at the renamed hop. Imports now carry both names: the source-side name is used to match exported/affected symbols, and the local name is used to scan the importing file's body for usage. This fixes cases like `import { Root as InnerRoot }` where a change to `Root` failed to reach the importing file's exports (and therefore its entrypoint and dependent targets).

## [0.21.0] - 2026-05-02

### Added
Expand Down
2 changes: 1 addition & 1 deletion VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
0.21.0
0.21.1
60 changes: 41 additions & 19 deletions internal/analyzer/analyzer.go
Original file line number Diff line number Diff line change
Expand Up @@ -398,12 +398,13 @@ func AnalyzeLibraryPackage(projectFolder string, entrypoints []Entrypoint, merge
continue
}
var localNames, origNames []string
for _, name := range imp.Names {
for i, name := range imp.Names {
local := importLocalName(imp, i)
if strings.HasPrefix(name, "*:") {
localNames = append(localNames, name)
localNames = append(localNames, local)
origNames = append(origNames, "*")
} else {
localNames = append(localNames, name)
localNames = append(localNames, local)
origNames = append(origNames, name)
}
}
Expand Down Expand Up @@ -525,7 +526,7 @@ func AnalyzeLibraryPackage(projectFolder string, entrypoints []Entrypoint, merge
}
if isCSSModule(imp.Source) && len(imp.Names) > 0 {
// CSS module with assigned import: only taint symbols that use the binding
usageTainted := findTaintedSymbolsByUsage(analysis, imp.Names)
usageTainted := findTaintedSymbolsByUsage(analysis, importLocalNames(imp))
for _, s := range usageTainted {
tainted[stem][s] = true
}
Expand Down Expand Up @@ -569,7 +570,7 @@ func AnalyzeLibraryPackage(projectFolder string, entrypoints []Entrypoint, merge
tainted[stem] = make(map[string]bool)
}
if len(imp.Names) > 0 {
usageTainted := findTaintedSymbolsByUsage(analysis, imp.Names)
usageTainted := findTaintedSymbolsByUsage(analysis, importLocalNames(imp))
for _, s := range usageTainted {
tainted[stem][s] = true
}
Expand Down Expand Up @@ -617,12 +618,12 @@ func AnalyzeLibraryPackage(projectFolder string, entrypoints []Entrypoint, merge
continue
}
var taintedLocalNames []string
for _, name := range imp.Names {
for i, name := range imp.Names {
if strings.HasPrefix(name, "*:") {
// Namespace import — any upstream taint means the namespace is tainted
taintedLocalNames = append(taintedLocalNames, name)
taintedLocalNames = append(taintedLocalNames, importLocalName(imp, i))
} else if affectedNames[name] {
taintedLocalNames = append(taintedLocalNames, name)
taintedLocalNames = append(taintedLocalNames, importLocalName(imp, i))
}
}
if len(taintedLocalNames) == 0 {
Expand Down Expand Up @@ -679,14 +680,14 @@ func AnalyzeLibraryPackage(projectFolder string, entrypoints []Entrypoint, merge
}
} else {
// All imported names are tainted — find symbols that use them
usageTainted := findTaintedSymbolsByUsage(analysis, imp.Names)
usageTainted := findTaintedSymbolsByUsage(analysis, importLocalNames(imp))
for _, s := range usageTainted {
tainted[stem][s] = true
}
// Check if any imported names are directly re-exported
for _, exp := range analysis.Exports {
if exp.Source == "" {
for _, name := range imp.Names {
for _, name := range importLocalNames(imp) {
cleanName := name
if strings.HasPrefix(cleanName, "*:") {
cleanName = strings.TrimPrefix(cleanName, "*:")
Expand Down Expand Up @@ -1031,6 +1032,26 @@ func AnalyzeLibraryPackage(projectFolder string, entrypoints []Entrypoint, merge
return result, nil
}

// importLocalName returns the local binding name for the i-th imported name,
// falling back to the source-side name when LocalNames is absent (e.g. older
// Import entries). For `import { X as Y }`, imp.Names[i] is "X" and the local
// name is "Y" — usage scans must look for the local name in the file body.
func importLocalName(imp tsparse.Import, i int) string {
if i < len(imp.LocalNames) {
return imp.LocalNames[i]
}
return imp.Names[i]
}

// importLocalNames returns the local binding names for an import, used when
// scanning a file body for usage of the imported symbols.
func importLocalNames(imp tsparse.Import) []string {
if len(imp.LocalNames) == len(imp.Names) {
return imp.LocalNames
}
return imp.Names
}

func findTaintedSymbolsByUsage(analysis *tsparse.FileAnalysis, taintedNames []string) []string {
if analysis.SourceFile == nil || len(taintedNames) == 0 {
return nil
Expand Down Expand Up @@ -1268,12 +1289,13 @@ func FindAffectedFiles(globPattern string, filterPattern string, upstreamTaint m
continue
}
var localNames, origNames []string
for _, name := range imp.Names {
for i, name := range imp.Names {
local := importLocalName(imp, i)
if strings.HasPrefix(name, "*:") {
localNames = append(localNames, name)
localNames = append(localNames, local)
origNames = append(origNames, "*")
} else {
localNames = append(localNames, name)
localNames = append(localNames, local)
origNames = append(origNames, name)
}
}
Expand Down Expand Up @@ -1411,11 +1433,11 @@ func FindAffectedFiles(globPattern string, filterPattern string, upstreamTaint m
continue
}
var taintedLocalNames []string
for _, name := range imp.Names {
for i, name := range imp.Names {
if strings.HasPrefix(name, "*:") {
taintedLocalNames = append(taintedLocalNames, name)
taintedLocalNames = append(taintedLocalNames, importLocalName(imp, i))
} else if affectedNames[name] {
taintedLocalNames = append(taintedLocalNames, name)
taintedLocalNames = append(taintedLocalNames, importLocalName(imp, i))
}
}
if len(taintedLocalNames) > 0 {
Expand Down Expand Up @@ -1454,7 +1476,7 @@ func FindAffectedFiles(globPattern string, filterPattern string, upstreamTaint m
}
log.Debugf(" %s: all symbols tainted via external dep %s (unassigned import)", stem, imp.Source)
} else {
usageTainted := findTaintedSymbolsByUsage(analysis, imp.Names)
usageTainted := findTaintedSymbolsByUsage(analysis, importLocalNames(imp))
for _, s := range usageTainted {
tainted[stem][s] = true
}
Expand Down Expand Up @@ -1499,7 +1521,7 @@ func FindAffectedFiles(globPattern string, filterPattern string, upstreamTaint m
tainted[stem] = make(map[string]bool)
}
if isCSSModule(imp.Source) && len(imp.Names) > 0 {
usageTainted := findTaintedSymbolsByUsage(analysis, imp.Names)
usageTainted := findTaintedSymbolsByUsage(analysis, importLocalNames(imp))
for _, s := range usageTainted {
tainted[stem][s] = true
}
Expand Down Expand Up @@ -1545,7 +1567,7 @@ func FindAffectedFiles(globPattern string, filterPattern string, upstreamTaint m
tainted[stem] = make(map[string]bool)
}
if len(imp.Names) > 0 {
usageTainted := findTaintedSymbolsByUsage(analysis, imp.Names)
usageTainted := findTaintedSymbolsByUsage(analysis, importLocalNames(imp))
for _, s := range usageTainted {
tainted[stem][s] = true
}
Expand Down
46 changes: 33 additions & 13 deletions internal/tsparse/tsparse.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,13 @@ import (
)

type Import struct {
Names []string // imported names, or ["*:alias"] for namespace import
Source string // module specifier (e.g., "./Button/Button.js")
Names []string // imported (source-side) names, or ["*:alias"] for namespace import
// LocalNames holds the local binding name for each entry in Names (parallel slice).
// For a plain `import { X }` LocalNames[i] == Names[i]; for an aliased
// `import { X as Y }` Names[i] is "X" (what the source exports) and
// LocalNames[i] is "Y" (what this file references in its body).
LocalNames []string
Source string // module specifier (e.g., "./Button/Button.js")
}

type Export struct {
Expand Down Expand Up @@ -128,31 +133,43 @@ func extractImports(stmt *ast.Node, analysis *FileAnalysis) {
imp := stmt.AsImportDeclaration()
source := strings.Trim(imp.ModuleSpecifier.Text(), "\"'`")

var names []string
var names, localNames []string
if imp.ImportClause != nil {
clause := imp.ImportClause.AsImportClause()
if clause.Name() != nil {
names = append(names, clause.Name().Text())
n := clause.Name().Text()
names = append(names, n)
localNames = append(localNames, n)
}
if clause.NamedBindings != nil {
if ast.IsNamespaceImport(clause.NamedBindings) {
ns := clause.NamedBindings.AsNamespaceImport()
names = append(names, "*:"+ns.Name().Text())
localNames = append(localNames, "*:"+ns.Name().Text())
} else if ast.IsNamedImports(clause.NamedBindings) {
ni := clause.NamedBindings.AsNamedImports()
if ni.Elements != nil {
for _, spec := range ni.Elements.Nodes {
is := spec.AsImportSpecifier()
names = append(names, is.Name().Text())
// is.Name() is the local binding; is.PropertyName (when present)
// is the original name exported by the source module.
local := is.Name().Text()
orig := local
if is.PropertyName != nil {
orig = is.PropertyName.Text()
}
names = append(names, orig)
localNames = append(localNames, local)
}
}
}
}
}

analysis.Imports = append(analysis.Imports, Import{
Names: names,
Source: source,
Names: names,
LocalNames: localNames,
Source: source,
})
}

Expand Down Expand Up @@ -422,8 +439,9 @@ func extractDynamicImports(sf *ast.SourceFile, analysis *FileAnalysis) {
}
if len(names) > 0 {
analysis.Imports = append(analysis.Imports, Import{
Names: names,
Source: specifier,
Names: names,
LocalNames: names,
Source: specifier,
})
}
}
Expand Down Expand Up @@ -493,8 +511,9 @@ func extractDynamicImports(sf *ast.SourceFile, analysis *FileAnalysis) {
nameList = append(nameList, n)
}
analysis.Imports = append(analysis.Imports, Import{
Names: nameList,
Source: specifier,
Names: nameList,
LocalNames: nameList,
Source: specifier,
})
}

Expand Down Expand Up @@ -580,8 +599,9 @@ func extractThenDynamicImports(sf *ast.SourceFile, analysis *FileAnalysis) {
names := extractNamesFromThenCallback(ce.Arguments.Nodes[0])
if len(names) > 0 {
analysis.Imports = append(analysis.Imports, Import{
Names: names,
Source: specifier,
Names: names,
LocalNames: names,
Source: specifier,
})
staticSources[specifier] = true
}
Expand Down
Loading