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
6 changes: 6 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.17.1] - 2026-04-10

### Fixed
- Detect runtime side-effect statements (e.g. `console.log()`) in entrypoint/barrel files as affecting all exports — previously these were misclassified as "comments/imports only" and seeded zero taint

## [0.17.0] - 2026-04-04

### Changed
Expand Down Expand Up @@ -242,6 +247,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Multi-stage Docker build
- Automated vendor upgrade workflow

[0.17.1]: https://github.com/gooddata/gooddata-goodchanges/compare/v0.17.0...v0.17.1
[0.17.0]: https://github.com/gooddata/gooddata-goodchanges/compare/v0.16.7...v0.17.0
[0.16.7]: https://github.com/gooddata/gooddata-goodchanges/compare/v0.16.6...v0.16.7
[0.16.6]: https://github.com/gooddata/gooddata-goodchanges/compare/v0.16.5...v0.16.6
Expand Down
2 changes: 1 addition & 1 deletion VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
0.17.0
0.17.1
13 changes: 13 additions & 0 deletions internal/analyzer/analyzer.go
Original file line number Diff line number Diff line change
Expand Up @@ -927,11 +927,24 @@ func AnalyzeLibraryPackage(projectFolder string, entrypoints []Entrypoint, merge
var affectedNames []string
epDir := filepath.Dir(ep.SourceFile)

// If the entrypoint file itself has "*" taint (e.g. runtime side-effect
// changes like console.log()), ALL exports are affected since every
// importer will execute the entrypoint module at load time.
epAllTainted := tainted[epStem]["*"]
if epAllTainted {
debugf(" entrypoint file has '*' taint — all exports affected")
}

for _, exp := range epAnalysis.Exports {
if exp.IsTypeOnly && !includeTypes {
continue
}

if epAllTainted {
affectedNames = append(affectedNames, exp.Name)
continue
}

if exp.Source == "" {
if tainted[epStem][exp.LocalName] || tainted[epStem]["*"] {
affectedNames = append(affectedNames, exp.Name)
Expand Down
80 changes: 76 additions & 4 deletions internal/analyzer/astdiff.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (

"goodchanges/internal/tsparse"
"goodchanges/tsgo-vendor/pkg/ast"
"goodchanges/tsgo-vendor/pkg/scanner"
)

// findAffectedSymbolsByASTDiff compares OLD and NEW file ASTs to find which symbols changed.
Expand Down Expand Up @@ -188,17 +189,30 @@ func findAffectedSymbolsByASTDiff(oldAnalysis *tsparse.FileAnalysis, newAnalysis

// Fallback: if no symbols were detected but the file clearly changed,
// check if changes are outside any symbol (e.g. top-level side effects,
// copyright comments). If there are exported symbols, taint them all.
// copyright comments). If there are runtime side-effect changes, taint all symbols.
if len(affected) == 0 && oldAnalysis != nil {
oldText := ""
if oldAnalysis.SourceFile != nil {
oldText = oldAnalysis.SourceFile.Text()
}
if normalizeWhitespace(oldText) != normalizeWhitespace(newText) {
// File changed but no symbol was affected — changes are outside symbols.
// This could be comments, imports, or top-level side-effect code.
// Don't taint anything — changes outside symbols don't affect exports.
debugf(" file changed but no symbols affected (comments/imports only)")
// Check if the changes include runtime side-effect statements.
if hasSideEffectStmtChanges(oldAnalysis.SourceFile, newAnalysis.SourceFile) {
debugf(" file changed with RUNTIME side-effect statements — tainting all symbols")
// Use "*" wildcard to mark all exports as affected.
// This handles barrel/entrypoint files that have no symbol declarations
// but whose runtime side effects affect all importers.
affected = append(affected, "*")
for _, sym := range newAnalysis.Symbols {
if sym.IsTypeOnly && !includeTypes {
continue
}
affected = append(affected, sym.Name)
}
} else {
debugf(" file changed but no symbols affected (comments/imports only)")
}
}
}

Expand Down Expand Up @@ -419,3 +433,61 @@ func normalizeWhitespace(s string) string {
}
return strings.TrimSpace(b.String())
}

// hasSideEffectStmtChanges checks whether the top-level side-effect statements
// (statements that are NOT declarations, imports, or exports) differ between
// old and new source files. A change in side-effect statements means the module
// has different runtime behavior at load time, affecting all importers.
func hasSideEffectStmtChanges(oldSF *ast.SourceFile, newSF *ast.SourceFile) bool {
oldText := collectSideEffectText(oldSF)
newText := collectSideEffectText(newSF)
return oldText != newText
}

// collectSideEffectText extracts and normalizes the text of all top-level
// side-effect statements from a source file. Side-effect statements are
// everything except declarations, imports, exports, and empty statements.
func collectSideEffectText(sf *ast.SourceFile) string {
if sf == nil {
return ""
}
sourceText := sf.Text()
var b strings.Builder
for _, stmt := range sf.Statements.Nodes {
if isSideEffectStatement(stmt) {
// Use SkipTrivia to exclude leading comments/whitespace so that
// comment-only changes before a side-effect statement don't
// cause false positives.
start := scanner.SkipTrivia(sourceText, stmt.Pos())
end := stmt.End()
if start >= 0 && end <= len(sourceText) && start < end {
b.WriteString(normalizeWhitespace(sourceText[start:end]))
b.WriteByte('\n')
}
}
}
return b.String()
}

// isSideEffectStatement returns true if a top-level statement is a runtime
// side effect (not a declaration, import, export, or empty statement).
// Examples: console.log(), Object.defineProperty(), bare function calls.
func isSideEffectStatement(stmt *ast.Node) bool {
switch stmt.Kind {
case ast.KindFunctionDeclaration,
ast.KindClassDeclaration,
ast.KindInterfaceDeclaration,
ast.KindTypeAliasDeclaration,
ast.KindEnumDeclaration,
ast.KindVariableStatement,
ast.KindModuleDeclaration,
ast.KindImportDeclaration,
ast.KindImportEqualsDeclaration,
ast.KindExportDeclaration,
ast.KindExportAssignment,
ast.KindEmptyStatement:
return false
default:
return true
}
}
Loading