diff --git a/CHANGELOG.md b/CHANGELOG.md index 24f9f18..2e73d4d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 @@ -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 diff --git a/VERSION b/VERSION index 1c16bd1..7092c7c 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.14.2 \ No newline at end of file +0.15.0 \ No newline at end of file diff --git a/go.mod b/go.mod index d830585..540b588 100644 --- a/go.mod +++ b/go.mod @@ -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 ) diff --git a/go.sum b/go.sum index 3ac3cc8..4977dbd 100644 --- a/go.sum +++ b/go.sum @@ -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= diff --git a/internal/lockfile/lockfile.go b/internal/lockfile/lockfile.go index 1213a61..d775f00 100644 --- a/internal/lockfile/lockfile.go +++ b/internal/lockfile/lockfile.go @@ -5,6 +5,8 @@ import ( "path/filepath" "strconv" "strings" + + "gopkg.in/yaml.v3" ) type depLineInfo struct { @@ -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 diff --git a/main.go b/main.go index eb2407a..bb6c133 100644 --- a/main.go +++ b/main.go @@ -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) != "" @@ -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 { @@ -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 @@ -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 { @@ -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] @@ -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 @@ -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 @@ -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 @@ -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.