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.25.0] - 2026-08-03

### Changed
- Destructured bindings are now attributed **element-wise** when the initializer is an array or object literal, refining the whole-initializer attribution added in 0.24.13. Previously every binding in `const [a, b] = [tainted(), safe()]` (or `const { a, b } = { a: tainted(), b: safe() }`) shared the entire initializer's span, so a change touching only one element tainted *all* the bindings — a false positive. Each binding is now mapped to its corresponding element (by index for array literals, by key for object literals, following nested patterns), and only falls back to the shared initializer span when mapping can't be done statically: a non-literal initializer (a call/identifier/member access — e.g. `createStore()`, where all bindings genuinely share the dependency), rest bindings (`...rest`), spreads, computed keys, or out-of-range indices. Net effect: `a` depends on `tainted()` and `b` on `safe()`, so a change to `safe()` no longer flags consumers of `a`.

## [0.24.13] - 2026-08-01

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

[0.25.0]: https://github.com/gooddata/gooddata-goodchanges/compare/v0.24.13...v0.25.0
[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
Expand Down
2 changes: 1 addition & 1 deletion VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
0.24.13
0.25.0
190 changes: 174 additions & 16 deletions internal/tsparse/tsparse.go
Original file line number Diff line number Diff line change
Expand Up @@ -388,27 +388,26 @@ func extractDeclarations(stmt *ast.Node, lineMap []core.TextPos, analysis *FileA
})
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) {
// Destructuring: `const { a, b } = init` / `const [a, b] = init`.
// Attribute each binding to the specific initializer element/property it
// destructures (element-wise for array/object *literals*), falling back to
// the whole initializer expression otherwise. Using the element rather than
// the whole statement keeps sibling binding names out of the compared body
// (no AST-diff cross-linking) and avoids tainting a binding via an unrelated
// element's dependency.
for _, b := range destructuredBindings(decl, text, lineMap) {
startLine, endLine := b.startLine, b.endLine
if startLine == 0 {
startLine = stmtStartLine(stmt, text, lineMap)
endLine = posToLine(stmt.End(), lineMap)
}
analysis.Symbols = append(analysis.Symbols, SymbolDecl{
Name: name,
Name: b.name,
Kind: "variable",
StartLine: startLine,
EndLine: endLine,
IsExported: isExported,
ExportName: name,
ExportName: b.name,
})
}
}
Expand Down Expand Up @@ -469,6 +468,165 @@ func declInitLines(decl *ast.Node, text string, lineMap []core.TextPos) (int, in
return start, end
}

// boundBinding is a single identifier bound by a destructuring declaration,
// paired with the line span of the initializer element/property it depends on.
type boundBinding struct {
name string
startLine int
endLine int
}

// destructuredBindings decomposes a destructuring variable declaration into its
// bound identifiers, each paired with the line span it depends on. When the
// initializer is an array/object literal that maps cleanly (no spread, no
// computed keys), each binding is attributed to its corresponding element or
// property, and nested patterns recurse into the matched element. Anything that
// cannot be mapped positionally/by key — rest bindings, spreads, computed keys,
// a non-literal initializer, out-of-range indices — falls back to the enclosing
// initializer span (startLine 0 if there is no initializer), so those bindings
// conservatively share its dependencies.
func destructuredBindings(decl *ast.Node, text string, lineMap []core.TextPos) []boundBinding {
vd := decl.AsVariableDeclaration()
name := vd.Name()
if name == nil || !(ast.IsObjectBindingPattern(name) || ast.IsArrayBindingPattern(name)) {
return nil
}
fbStart, fbEnd := declInitLines(decl, text, lineMap)
var out []boundBinding
collectBindings(name, vd.Initializer, fbStart, fbEnd, text, lineMap, &out)
return out
}

func collectBindings(pattern *ast.Node, init *ast.Node, fbStart, fbEnd int, text string, lineMap []core.TextPos, out *[]boundBinding) {
bp := pattern.AsBindingPattern()
if bp.Elements == nil {
return
}
isArray := ast.IsArrayBindingPattern(pattern)

// Try to map the initializer element-wise. Only a literal of the matching
// kind, with no spread/computed keys, is cleanly mappable.
var arrElems []*ast.Node
var objVals map[string]*ast.Node
mappable := false
if init != nil {
if isArray && ast.IsArrayLiteralExpression(init) {
arrElems, mappable = arrayLiteralElements(init)
} else if !isArray && ast.IsObjectLiteralExpression(init) {
objVals, mappable = objectLiteralValues(init)
}
}

idx := 0
for _, elem := range bp.Elements.Nodes {
if !ast.IsBindingElement(elem) {
idx++ // array elision in the pattern still consumes a slot
continue
}
be := elem.AsBindingElement()

// Resolve the source expression + span for this binding.
var src *ast.Node
start, end := fbStart, fbEnd
if be.DotDotDotToken == nil && mappable {
if isArray {
if idx < len(arrElems) {
src = arrElems[idx]
}
} else if key := bindingSourceKey(be); key != "" {
src = objVals[key]
}
if src != nil && !ast.IsOmittedExpression(src) {
start = posToLine(scanner.SkipTrivia(text, src.Pos()), lineMap)
end = posToLine(src.End(), lineMap)
}
}
Comment on lines +528 to +543

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Locate the BindingElement definition in the vendored tsgo AST.
fd -e go -g '*ast*' --exec rg -nP -A 12 'type\s+BindingElement\s+struct'

Repository: gooddata/gooddata-goodchanges

Length of output: 167


🏁 Script executed:

#!/bin/bash
set -eu
printf '%s\n' '--- module metadata ---'
find . -maxdepth 3 -type f \( -name 'go.mod' -o -name 'go.sum' \) -print
printf '%s\n' '--- relevant files ---'
git ls-files | rg '(^|/)(internal/tsparse/tsparse\.go|internal/analyzer/analyzer\.go|.*ts.*ast.*|go\.mod|go\.sum)$' || true
printf '%s\n' '--- symbols ---'
rg -n -S 'BindingElement|collectBindings|findTaintedSymbolsByUsage' . --glob '*.go' --glob '!vendor/**' || true

Repository: gooddata/gooddata-goodchanges

Length of output: 2499


🏁 Script executed:

#!/bin/bash
set -eu
printf '%s\n' '--- go.mod ---'
cat go.mod
printf '%s\n' '--- collectBindings context ---'
sed -n '470,570p' internal/tsparse/tsparse.go
printf '%s\n' '--- binding helper context ---'
sed -n '590,625p' internal/tsparse/tsparse.go
printf '%s\n' '--- taint usage function ---'
sed -n '980,1035p' internal/analyzer/analyzer.go
printf '%s\n' '--- dependency references ---'
rg -n -S 'tsgo|typescript-go|github.com/.*/go-ts' go.mod go.sum internal --glob '*.go' || true
printf '%s\n' '--- Go module cache candidates ---'
go env GOPATH GOMODCACHE 2>/dev/null || true

Repository: gooddata/gooddata-goodchanges

Length of output: 7710


🏁 Script executed:

#!/bin/bash
set -eu
printf '%s\n' '--- vendor root ---'
find _vendor/typescript-go -maxdepth 3 -type f | head -80
printf '%s\n' '--- BindingElement definitions and references ---'
rg -n -S -A 18 -B 5 'type[[:space:]]+BindingElement[[:space:]]+struct|BindingElement struct|Initializer.*BindingElement|func .*BindingElement' _vendor/typescript-go --glob '*.go' || true
printf '%s\n' '--- AST initializer field references ---'
rg -n -S 'BindingElement|\.Initializer' _vendor/typescript-go/pkg/ast --glob '*.go' | head -120 || true

Repository: gooddata/gooddata-goodchanges

Length of output: 581


🏁 Script executed:

#!/bin/bash
set -eu
printf '%s\n' '--- repository top-level entries ---'
find . -maxdepth 2 -type d -print | sort
printf '%s\n' '--- all local BindingElement matches ---'
rg -n -S 'BindingElement|type[[:space:]]+BindingElement' . /home/jailuser/go/pkg/mod /home/jailuser/go/pkg/mod/cache 2>/dev/null | head -200 || true
printf '%s\n' '--- module cache directories matching typescript or tsgo ---'
find /home/jailuser/go/pkg/mod -maxdepth 5 -type d \( -iname '*typescript*' -o -iname '*tsgo*' \) -print 2>/dev/null | head -100
printf '%s\n' '--- git tracked replacement metadata ---'
git ls-files | rg '(^|/)(_vendor|vendor|typescript-go|go\.mod|go\.sum)' || true

Repository: gooddata/gooddata-goodchanges

Length of output: 1184


🏁 Script executed:

#!/bin/bash
set -eu
printf '%s\n' '--- vendor-tsgo.sh ---'
cat vendor-tsgo.sh
printf '%s\n' '--- concise Git metadata ---'
git status --short
git log -1 --format='%h %s'
printf '%s\n' '--- dependency strings in repository metadata ---'
rg -n -S 'typescript-go|tsgo|goodchanges/tsgo-vendor|github.com' .github vendor-tsgo.sh go.mod go.sum .git/config 2>/dev/null || true

Repository: gooddata/gooddata-goodchanges

Length of output: 7742


🌐 Web query:

microsoft typescript-go AST BindingElement struct Initializer Node source

💡 Result:

In the microsoft/typescript-go project, a BindingElement is represented as a struct within the ast package [1][2][3]. This node type is part of the AST (Abstract Syntax Tree) used to represent binding elements within object or array binding patterns [2]. A BindingElement struct typically includes an Initializer field, which is an optional node (or pointer to a Node) representing the default value assigned to the binding element if the property or array element is missing or undefined during destructuring [1]. Key technical aspects include: - The Initializer field allows for default value assignment, similar to the TypeScript compiler's BindingElement interface [4][5]. - Transformer utilities in the project frequently check for the presence of this Initializer to convert binding patterns into standard assignment expressions during code generation [1]. - The node structure is designed to support both object and array destructuring, with methods like convertBindingElementToArrayAssignmentElement and convertBindingElementToObjectAssignmentElement handling the transformation of these elements [1]. - The AST node also holds other essential properties such as the Name (the binding name) and the optional PropertyName (used in object binding patterns) [1][2][3]. This implementation mirrors the TypeScript compiler's underlying AST, where BindingElement is defined as an interface that includes a name, an optional property name, an optional initializer, and a dotDotDotToken for rest elements [4][5].

Citations:


🏁 Script executed:

#!/bin/bash
set -eu
python3 - <<'PY'
examples = {
    "object": "const { a = compute() } = { a: p }\n",
    "array":  "const [a = fallbackVal] = [p]\n",
}
for kind, text in examples.items():
    lines = text.splitlines()
    source = lines[0]
    if kind == "object":
        mapped = "p"
        default = "compute()"
    else:
        mapped = "p"
        default = "fallbackVal"
    mapped_start = source.index(mapped)
    mapped_end = mapped_start + len(mapped)
    default_start = source.index(default)
    default_end = default_start + len(default)
    mapped_text = source[mapped_start:mapped_end]
    print(f"{kind}: mapped span text={mapped_text!r}, default={source[default_start:default_end]!r}, "
          f"default_in_mapped_span={default_start >= mapped_start and default_end <= mapped_end}, "
          f"tainted_default_detected={default in mapped_text}")
PY

Repository: gooddata/gooddata-goodchanges

Length of output: 392


🏁 Script executed:

#!/bin/bash
set -eu
python3 - <<'PY'
examples = {
    "object": "const { a = compute() } = { a: p }\n",
    "array":  "const [a = fallbackVal] = [p]\n",
}
for kind, text in examples.items():
    source = text.splitlines()[0]
    mapped = "p"
    default = "compute()" if kind == "object" else "fallbackVal"
    mapped_start = source.index(mapped)
    mapped_end = mapped_start + len(mapped)
    default_start = source.index(default)
    default_end = default_start + len(default)
    mapped_text = source[mapped_start:mapped_end]
    print(
        f"{kind}: mapped span text={mapped_text!r}, "
        f"default={source[default_start:default_end]!r}, "
        f"default_in_mapped_span="
        f"{default_start >= mapped_start and default_end <= mapped_end}, "
        f"tainted_default_detected={default in mapped_text}"
    )
PY

Repository: gooddata/gooddata-goodchanges

Length of output: 392


Include binding default initializers in the recorded span.

When be.Initializer is present, extend the mapped span to include that expression. Otherwise, compute() in const { a = compute() } = { a: p } and fallbackVal in const [a = fallbackVal] = [p] are excluded from findTaintedSymbolsByUsage and taint detection misses them.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/tsparse/tsparse.go` around lines 528 - 543, Update the source-span
calculation in the binding-resolution logic around be.Initializer so mapped
bindings with default initializers extend end through the initializer
expression. Preserve the existing source-expression mapping for object and array
bindings, while ensuring initializer expressions are included in the span used
by findTaintedSymbolsByUsage and taint detection.

idx++

en := be.Name()
if en == nil {
continue
}
switch {
case ast.IsIdentifier(en):
*out = append(*out, boundBinding{en.Text(), start, end})
case ast.IsObjectBindingPattern(en) || ast.IsArrayBindingPattern(en):
collectBindings(en, src, start, end, text, lineMap, out)
}
}
}

// arrayLiteralElements returns an array literal's element expressions, and false
// if the literal contains a spread (`...x`), which shifts positions and breaks
// index-based mapping.
func arrayLiteralElements(arr *ast.Node) ([]*ast.Node, bool) {
ale := arr.AsArrayLiteralExpression()
if ale.Elements == nil {
return nil, true
}
for _, e := range ale.Elements.Nodes {
if ast.IsSpreadElement(e) {
return nil, false
}
}
return ale.Elements.Nodes, true
}

// objectLiteralValues maps an object literal's static keys to their value
// expressions, and returns false if any property is a spread, method/accessor,
// or computed key — cases where a binding's source can't be matched by name.
func objectLiteralValues(obj *ast.Node) (map[string]*ast.Node, bool) {
ole := obj.AsObjectLiteralExpression()
if ole.Properties == nil {
return map[string]*ast.Node{}, true
}
m := make(map[string]*ast.Node, len(ole.Properties.Nodes))
for _, p := range ole.Properties.Nodes {
switch {
case ast.IsPropertyAssignment(p):
key := propNameText(p.Name())
if key == "" {
return nil, false
}
m[key] = p.AsPropertyAssignment().Initializer
case ast.IsShorthandPropertyAssignment(p):
n := p.Name()
if n == nil || !ast.IsIdentifier(n) {
return nil, false
}
m[n.Text()] = n // value is the shorthand identifier itself
default:
return nil, false
}
}
return m, true
}

// bindingSourceKey returns the object-literal key an object-pattern binding
// element reads from: its property name (`{ a: b }` → "a"), else the local
// shorthand name (`{ a }` → "a").
func bindingSourceKey(be *ast.BindingElement) string {
if be.PropertyName != nil {
return propNameText(be.PropertyName)
}
if n := be.Name(); n != nil && ast.IsIdentifier(n) {
return n.Text()
}
return ""
}

// propNameText returns the static text of a property name, or "" for computed /
// private names that can't be matched statically.
func propNameText(n *ast.Node) string {
if n == nil {
return ""
}
if ast.IsIdentifier(n) || ast.IsStringLiteral(n) || ast.IsNumericLiteral(n) {
return n.Text()
}
return ""
}

// extractDynamicImports walks the full AST to find dynamic import() calls
// and adds them to the imports list.
//
Expand Down
Loading