diff --git a/CHANGELOG.md b/CHANGELOG.md index e607c36..8fab009 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.24.13] - 2026-08-01 + +### Fixed +- Destructuring variable declarations (`const { a, b } = expr` / `const [a, b] = expr`) are now decomposed into their individual bindings for both symbol and export extraction. Previously `getDeclName` only handled plain identifiers and returned `""` for binding patterns, so destructured names were registered as neither symbols nor exports — they simply didn't exist in the file model. That silently broke taint propagation through a very common pattern: e.g. `export const { store, startSagas, … } = createStore()` in `gdc-analytical-designer-runtime`'s `reduxStore.ts`. A runtime change to the reducers feeding `createStore` tainted `createStore`, but the taint could not reach `store`/`startSagas`/…, so the package reported **0 affected exports** and nothing propagated to the AD module/harness (or the dashboards harness that embeds it) — a false negative. Each destructured binding is now emitted as a symbol/export; renames (`{ a: b }` → `b`), rest elements (`...x`), nested patterns and array holes are handled. To stay precise, a binding's symbol span is the **initializer expression** (not the whole statement), so the sibling binding names don't bleed into the compared body and cross-link in the AST diff — each binding is attributed exactly to the initializer it destructures. + ## [0.24.12] - 2026-07-30 ### Fixed @@ -382,6 +387,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Multi-stage Docker build - Automated vendor upgrade workflow +[0.24.13]: https://github.com/gooddata/gooddata-goodchanges/compare/v0.24.12...v0.24.13 [0.24.12]: https://github.com/gooddata/gooddata-goodchanges/compare/v0.24.11...v0.24.12 [0.24.11]: https://github.com/gooddata/gooddata-goodchanges/compare/v0.24.10...v0.24.11 [0.24.10]: https://github.com/gooddata/gooddata-goodchanges/compare/v0.24.9...v0.24.10 diff --git a/VERSION b/VERSION index bd79490..46b794d 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.24.12 \ No newline at end of file +0.24.13 \ No newline at end of file diff --git a/internal/tsparse/tsparse.go b/internal/tsparse/tsparse.go index 4e5740e..34bbfe6 100644 --- a/internal/tsparse/tsparse.go +++ b/internal/tsparse/tsparse.go @@ -236,8 +236,7 @@ func extractExports(stmt *ast.Node, analysis *FileAnalysis) { dl := vs.DeclarationList.AsVariableDeclarationList() if dl.Declarations != nil { for _, decl := range dl.Declarations.Nodes { - name := getDeclName(decl) - if name != "" { + if name := getDeclName(decl); name != "" { exportName := name if isDefault { exportName = "default" @@ -246,6 +245,19 @@ func extractExports(stmt *ast.Node, analysis *FileAnalysis) { Name: exportName, LocalName: name, }) + continue + } + // Destructuring export: `export const { a, b } = init` binds each + // name locally; record them all (destructuring can't be `default`). + declName := decl.Name() + if declName == nil || !(ast.IsObjectBindingPattern(declName) || ast.IsArrayBindingPattern(declName)) { + continue + } + for _, name := range bindingPatternNames(declName) { + analysis.Exports = append(analysis.Exports, Export{ + Name: name, + LocalName: name, + }) } } } @@ -365,8 +377,7 @@ func extractDeclarations(stmt *ast.Node, lineMap []core.TextPos, analysis *FileA dl := vs.DeclarationList.AsVariableDeclarationList() if dl.Declarations != nil { for _, decl := range dl.Declarations.Nodes { - name := getDeclName(decl) - if name != "" { + if name := getDeclName(decl); name != "" { analysis.Symbols = append(analysis.Symbols, SymbolDecl{ Name: name, Kind: "variable", @@ -375,6 +386,30 @@ func extractDeclarations(stmt *ast.Node, lineMap []core.TextPos, analysis *FileA IsExported: isExported, ExportName: name, }) + continue + } + // Destructuring: `const { a, b } = init`. Attribute each bound name to + // the shared initializer expression's line span — NOT the whole statement, + // which would drag the sibling binding names into the body and cross-link + // them in the AST diff. Each binding genuinely depends on `init`. + declName := decl.Name() + if declName == nil || !(ast.IsObjectBindingPattern(declName) || ast.IsArrayBindingPattern(declName)) { + continue + } + startLine, endLine := declInitLines(decl, text, lineMap) + if startLine == 0 { + startLine = stmtStartLine(stmt, text, lineMap) + endLine = posToLine(stmt.End(), lineMap) + } + for _, name := range bindingPatternNames(declName) { + analysis.Symbols = append(analysis.Symbols, SymbolDecl{ + Name: name, + Kind: "variable", + StartLine: startLine, + EndLine: endLine, + IsExported: isExported, + ExportName: name, + }) } } } @@ -393,6 +428,47 @@ func getDeclName(node *ast.Node) string { return "" } +// bindingPatternNames returns every local identifier bound by an object/array +// destructuring pattern, following renames (`{ a: b }` yields `b`), rest +// elements (`...x`) and nested patterns; array holes (omitted elements) are +// skipped. +func bindingPatternNames(pattern *ast.Node) []string { + bp := pattern.AsBindingPattern() + if bp.Elements == nil { + return nil + } + var names []string + for _, elem := range bp.Elements.Nodes { + if !ast.IsBindingElement(elem) { + continue // array hole (OmittedExpression) + } + en := elem.AsBindingElement().Name() + if en == nil { + continue + } + switch { + case ast.IsIdentifier(en): + names = append(names, en.Text()) + case ast.IsObjectBindingPattern(en) || ast.IsArrayBindingPattern(en): + names = append(names, bindingPatternNames(en)...) + } + } + return names +} + +// declInitLines returns the 1-based [start, end] line span of a variable +// declaration's initializer expression (leading trivia skipped). Returns (0, 0) +// when the declaration has no initializer. +func declInitLines(decl *ast.Node, text string, lineMap []core.TextPos) (int, int) { + vd := decl.AsVariableDeclaration() + if vd.Initializer == nil { + return 0, 0 + } + start := posToLine(scanner.SkipTrivia(text, vd.Initializer.Pos()), lineMap) + end := posToLine(vd.Initializer.End(), lineMap) + return start, end +} + // extractDynamicImports walks the full AST to find dynamic import() calls // and adds them to the imports list. //