Skip to content

Commit ccfb6bd

Browse files
authored
fix: Destructured exports losing taint propagation
2 parents f936b06 + 3e965c9 commit ccfb6bd

3 files changed

Lines changed: 87 additions & 5 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 6 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.24.13] - 2026-08-01
9+
10+
### Fixed
11+
- 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.
12+
813
## [0.24.12] - 2026-07-30
914

1015
### Fixed
@@ -382,6 +387,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
382387
- Multi-stage Docker build
383388
- Automated vendor upgrade workflow
384389

390+
[0.24.13]: https://github.com/gooddata/gooddata-goodchanges/compare/v0.24.12...v0.24.13
385391
[0.24.12]: https://github.com/gooddata/gooddata-goodchanges/compare/v0.24.11...v0.24.12
386392
[0.24.11]: https://github.com/gooddata/gooddata-goodchanges/compare/v0.24.10...v0.24.11
387393
[0.24.10]: https://github.com/gooddata/gooddata-goodchanges/compare/v0.24.9...v0.24.10

‎VERSION‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
0.24.12
1+
0.24.13

‎internal/tsparse/tsparse.go‎

Lines changed: 80 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -236,8 +236,7 @@ func extractExports(stmt *ast.Node, analysis *FileAnalysis) {
236236
dl := vs.DeclarationList.AsVariableDeclarationList()
237237
if dl.Declarations != nil {
238238
for _, decl := range dl.Declarations.Nodes {
239-
name := getDeclName(decl)
240-
if name != "" {
239+
if name := getDeclName(decl); name != "" {
241240
exportName := name
242241
if isDefault {
243242
exportName = "default"
@@ -246,6 +245,19 @@ func extractExports(stmt *ast.Node, analysis *FileAnalysis) {
246245
Name: exportName,
247246
LocalName: name,
248247
})
248+
continue
249+
}
250+
// Destructuring export: `export const { a, b } = init` binds each
251+
// name locally; record them all (destructuring can't be `default`).
252+
declName := decl.Name()
253+
if declName == nil || !(ast.IsObjectBindingPattern(declName) || ast.IsArrayBindingPattern(declName)) {
254+
continue
255+
}
256+
for _, name := range bindingPatternNames(declName) {
257+
analysis.Exports = append(analysis.Exports, Export{
258+
Name: name,
259+
LocalName: name,
260+
})
249261
}
250262
}
251263
}
@@ -365,8 +377,7 @@ func extractDeclarations(stmt *ast.Node, lineMap []core.TextPos, analysis *FileA
365377
dl := vs.DeclarationList.AsVariableDeclarationList()
366378
if dl.Declarations != nil {
367379
for _, decl := range dl.Declarations.Nodes {
368-
name := getDeclName(decl)
369-
if name != "" {
380+
if name := getDeclName(decl); name != "" {
370381
analysis.Symbols = append(analysis.Symbols, SymbolDecl{
371382
Name: name,
372383
Kind: "variable",
@@ -375,6 +386,30 @@ func extractDeclarations(stmt *ast.Node, lineMap []core.TextPos, analysis *FileA
375386
IsExported: isExported,
376387
ExportName: name,
377388
})
389+
continue
390+
}
391+
// Destructuring: `const { a, b } = init`. Attribute each bound name to
392+
// the shared initializer expression's line span — NOT the whole statement,
393+
// which would drag the sibling binding names into the body and cross-link
394+
// them in the AST diff. Each binding genuinely depends on `init`.
395+
declName := decl.Name()
396+
if declName == nil || !(ast.IsObjectBindingPattern(declName) || ast.IsArrayBindingPattern(declName)) {
397+
continue
398+
}
399+
startLine, endLine := declInitLines(decl, text, lineMap)
400+
if startLine == 0 {
401+
startLine = stmtStartLine(stmt, text, lineMap)
402+
endLine = posToLine(stmt.End(), lineMap)
403+
}
404+
for _, name := range bindingPatternNames(declName) {
405+
analysis.Symbols = append(analysis.Symbols, SymbolDecl{
406+
Name: name,
407+
Kind: "variable",
408+
StartLine: startLine,
409+
EndLine: endLine,
410+
IsExported: isExported,
411+
ExportName: name,
412+
})
378413
}
379414
}
380415
}
@@ -393,6 +428,47 @@ func getDeclName(node *ast.Node) string {
393428
return ""
394429
}
395430

431+
// bindingPatternNames returns every local identifier bound by an object/array
432+
// destructuring pattern, following renames (`{ a: b }` yields `b`), rest
433+
// elements (`...x`) and nested patterns; array holes (omitted elements) are
434+
// skipped.
435+
func bindingPatternNames(pattern *ast.Node) []string {
436+
bp := pattern.AsBindingPattern()
437+
if bp.Elements == nil {
438+
return nil
439+
}
440+
var names []string
441+
for _, elem := range bp.Elements.Nodes {
442+
if !ast.IsBindingElement(elem) {
443+
continue // array hole (OmittedExpression)
444+
}
445+
en := elem.AsBindingElement().Name()
446+
if en == nil {
447+
continue
448+
}
449+
switch {
450+
case ast.IsIdentifier(en):
451+
names = append(names, en.Text())
452+
case ast.IsObjectBindingPattern(en) || ast.IsArrayBindingPattern(en):
453+
names = append(names, bindingPatternNames(en)...)
454+
}
455+
}
456+
return names
457+
}
458+
459+
// declInitLines returns the 1-based [start, end] line span of a variable
460+
// declaration's initializer expression (leading trivia skipped). Returns (0, 0)
461+
// when the declaration has no initializer.
462+
func declInitLines(decl *ast.Node, text string, lineMap []core.TextPos) (int, int) {
463+
vd := decl.AsVariableDeclaration()
464+
if vd.Initializer == nil {
465+
return 0, 0
466+
}
467+
start := posToLine(scanner.SkipTrivia(text, vd.Initializer.Pos()), lineMap)
468+
end := posToLine(vd.Initializer.End(), lineMap)
469+
return start, end
470+
}
471+
396472
// extractDynamicImports walks the full AST to find dynamic import() calls
397473
// and adds them to the imports list.
398474
//

0 commit comments

Comments
 (0)