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
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,12 @@ 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.15.0] - 2026-02-17

### Added
- lockfileVersion change detection: when `lockfileVersion` changes in a subspace's pnpm-lock.yaml, all projects in that subspace are treated as having all external deps changed, and all library exports are wildcard-tainted. This propagates transitively through the existing dependency graph and taint analysis.
- `ParseLockfileVersion` using proper YAML parsing (gopkg.in/yaml.v3) to compare old vs new lockfile versions

## [0.14.2] - 2026-02-16

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

[0.15.0]: https://github.com/gooddata/gooddata-goodchanges/compare/v0.14.2...v0.15.0
[0.14.2]: https://github.com/gooddata/gooddata-goodchanges/compare/v0.14.1...v0.14.2
[0.14.1]: https://github.com/gooddata/gooddata-goodchanges/compare/v0.14.0...v0.14.1
[0.14.0]: https://github.com/gooddata/gooddata-goodchanges/compare/v0.13.0...v0.14.0
Expand Down
2 changes: 1 addition & 1 deletion VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
0.14.2
0.15.0
1 change: 1 addition & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -16,4 +16,5 @@ require (
golang.org/x/sync v0.19.0 // indirect
golang.org/x/sys v0.40.0 // indirect
golang.org/x/text v0.33.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
3 changes: 3 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -20,5 +20,8 @@ golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE=
golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q=
gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA=
24 changes: 24 additions & 0 deletions internal/lockfile/lockfile.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import (
"path/filepath"
"strconv"
"strings"

"gopkg.in/yaml.v3"
)

type depLineInfo struct {
Expand Down Expand Up @@ -173,6 +175,28 @@ func countLeadingSpaces(s string) int {
return len(s)
}

// ParseLockfileVersion extracts the lockfileVersion value from pnpm-lock.yaml content
// using proper YAML parsing.
func ParseLockfileVersion(content []byte) string {
var doc yaml.Node
if err := yaml.Unmarshal(content, &doc); err != nil {
return ""
}
if doc.Kind != yaml.DocumentNode || len(doc.Content) == 0 {
return ""
}
mapping := doc.Content[0]
if mapping.Kind != yaml.MappingNode {
return ""
}
for i := 0; i < len(mapping.Content)-1; i += 2 {
if mapping.Content[i].Value == "lockfileVersion" {
return mapping.Content[i+1].Value
}
}
return ""
}

// parseDiffChangedLines extracts the new-file line numbers of changed lines from a unified diff.
func parseDiffChangedLines(diffText string) []int {
var result []int
Expand Down
76 changes: 67 additions & 9 deletions main.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,11 @@ var flagIncludeCSS bool
var flagLog bool
var flagDebug bool

type TargetResult struct {
Name string `json:"name"`
Detections []string `json:"detections,omitempty"`
}

// envBool returns true if the environment variable is set to a non-empty value.
func envBool(key string) bool {
return os.Getenv(key) != ""
Expand Down Expand Up @@ -119,7 +124,23 @@ func main() {
changedProjects := rush.FindChangedProjects(rushConfig, projectMap, changedFiles, configMap, relevantPackages)

// Detect lockfile dep changes per subspace (folder → set of changed dep names)
depChangedDeps := findLockfileAffectedProjects(rushConfig, mergeBase)
depChangedDeps, versionChangedSubspaces := findLockfileAffectedProjects(rushConfig, mergeBase)

// When lockfileVersion changes in a subspace, treat all projects in that subspace
// as having all external deps changed. This feeds into the existing taint propagation:
// depChangedDeps → changedProjects → affectedSet → library analysis → target detection.
for _, rp := range rushConfig.Projects {
subspace := rp.SubspaceName
if subspace == "" {
subspace = "default"
}
if versionChangedSubspaces[subspace] {
if depChangedDeps[rp.ProjectFolder] == nil {
depChangedDeps[rp.ProjectFolder] = make(map[string]bool)
}
depChangedDeps[rp.ProjectFolder]["*"] = true
}
}

// Add dep-affected projects to the changed set (they count as directly changed)
for folder := range depChangedDeps {
Expand Down Expand Up @@ -164,6 +185,29 @@ func main() {
// Track affected exports per package for cross-package propagation.
allUpstreamTaint := make(map[string]map[string]bool)

// Seed upstream taint for libraries in version-changed subspaces.
// A lockfileVersion change means we can't reliably diff individual deps,
// so treat all exports as tainted. This propagates through the analysis loop.
for _, rp := range rushConfig.Projects {
subspace := rp.SubspaceName
if subspace == "" {
subspace = "default"
}
if !versionChangedSubspaces[subspace] {
continue
}
info := projectMap[rp.PackageName]
if info == nil {
continue
}
if analyzer.IsLibrary(info.Package) {
if allUpstreamTaint[rp.PackageName] == nil {
allUpstreamTaint[rp.PackageName] = make(map[string]bool)
}
allUpstreamTaint[rp.PackageName]["*"] = true
}
}

type pkgResult struct {
pkgName string
affected []analyzer.AffectedExport
Expand Down Expand Up @@ -300,10 +344,6 @@ func main() {
// Load project configs and detect affected targets.
// Targets are defined by "type": "target" in .goodchangesrc.json.
// Virtual targets are defined by "type": "virtual-target".
type TargetResult struct {
Name string `json:"name"`
Detections []string `json:"detections,omitempty"`
}
changedE2E := make(map[string]*TargetResult)

for _, rp := range rushConfig.Projects {
Expand All @@ -322,7 +362,7 @@ func main() {
}
// Target detection with 4 conditions:
// 1. Direct file changes (outside ignores)
// 2. External dep changes in lockfile
// 2. External dep changes in lockfile (incl. lockfileVersion wildcard)
// 3. Tainted workspace imports
// 4. Corresponding app is tainted
info := projectMap[rp.PackageName]
Expand Down Expand Up @@ -380,6 +420,7 @@ func main() {
if len(targetPatterns) > 0 && !matchesTargetFilter(*td.TargetName, targetPatterns) {
continue
}

// Virtual target: check changeDirs globs for file changes or tainted imports.
// Normal globs trigger a full run; fine-grained globs collect specific affected files.
normalTriggered := false
Expand Down Expand Up @@ -463,8 +504,10 @@ func main() {
}

// findLockfileAffectedProjects checks each subspace's pnpm-lock.yaml for dep changes.
// Returns a map of project folder → set of changed external dep package names.
func findLockfileAffectedProjects(config *rush.Config, mergeBase string) map[string]map[string]bool {
// Returns:
// - depChanges: project folder → set of changed external dep package names
// - versionChanges: subspace name → true for subspaces where lockfileVersion changed
func findLockfileAffectedProjects(config *rush.Config, mergeBase string) (map[string]map[string]bool, map[string]bool) {
// Collect subspaces: "default" for projects without subspaceName, plus named ones
subspaces := make(map[string]bool)
subspaces["default"] = true
Expand All @@ -475,11 +518,26 @@ func findLockfileAffectedProjects(config *rush.Config, mergeBase string) map[str
}

result := make(map[string]map[string]bool)
versionChanged := make(map[string]bool)
for subspace := range subspaces {
lockfilePath := filepath.Join("common", "config", "subspaces", subspace, "pnpm-lock.yaml")
if _, err := os.Stat(lockfilePath); err != nil {
continue
}

// Compare lockfileVersion between base commit and current
newContent, err := os.ReadFile(lockfilePath)
if err != nil {
continue
}
oldContent, _ := git.ShowFile(mergeBase, lockfilePath)
oldVersion := lockfile.ParseLockfileVersion([]byte(oldContent))
newVersion := lockfile.ParseLockfileVersion(newContent)
if oldVersion != newVersion {
versionChanged[subspace] = true
logf("lockfileVersion changed in subspace %q: %q → %q\n", subspace, oldVersion, newVersion)
}

diffText, err := git.DiffSincePath(mergeBase, lockfilePath)
if err != nil || diffText == "" {
continue
Expand All @@ -494,7 +552,7 @@ func findLockfileAffectedProjects(config *rush.Config, mergeBase string) map[str
}
}
}
return result
return result, versionChanged
}

// matchesTargetFilter checks if a target name matches any of the given patterns.
Expand Down