diff --git a/CHANGELOG.md b/CHANGELOG.md index 1b8b80b..0da908c 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.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 @@ -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 diff --git a/VERSION b/VERSION index c5523bd..7cca771 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.17.0 +0.17.1 diff --git a/internal/analyzer/analyzer.go b/internal/analyzer/analyzer.go index 2973060..0102015 100644 --- a/internal/analyzer/analyzer.go +++ b/internal/analyzer/analyzer.go @@ -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) diff --git a/internal/analyzer/astdiff.go b/internal/analyzer/astdiff.go index d713479..28671ac 100644 --- a/internal/analyzer/astdiff.go +++ b/internal/analyzer/astdiff.go @@ -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. @@ -188,7 +189,7 @@ 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 { @@ -196,9 +197,22 @@ func findAffectedSymbolsByASTDiff(oldAnalysis *tsparse.FileAnalysis, newAnalysis } 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)") + } } } @@ -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 + } +}