From d196313e406a4a824715e01af5507e7fd30969d4 Mon Sep 17 00:00:00 2001 From: Martin Najemi Date: Wed, 11 Feb 2026 15:19:47 +0100 Subject: [PATCH] chore: Fine-grained virtual target detection Risk: low JIRA: STL-2317 --- CHANGELOG.md | 11 +++ README.md | 38 ++++++++-- VERSION | 2 +- internal/analyzer/analyzer.go | 136 ++++++++++++++++++++++++++++++++++ internal/rush/rush.go | 20 +++-- main.go | 95 ++++++++++++++++-------- 6 files changed, 259 insertions(+), 43 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 78ab097..296246e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,16 @@ 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.5.0] - 2026-02-13 + +### Added +- Fine-grained virtual target detection: `changeDirs` entries can specify `"type": "fine-grained"` to collect specific affected files instead of triggering a full run +- New `FindAffectedFiles` analyzer function for transitive file-level taint propagation within directories +- Output format changed from `[]string` to `[]{"name", "detections?"}` for richer target information + +### Changed +- `changeDirs` config field is now an array of objects (`{"path": "...", "type?": "..."}`) instead of plain strings + ## [0.4.0] - 2026-02-13 ### Changed @@ -63,6 +73,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Multi-stage Docker build - Automated vendor upgrade workflow +[0.5.0]: https://github.com/gooddata/gooddata-goodchanges/compare/v0.4.0...v0.5.0 [0.4.0]: https://github.com/gooddata/gooddata-goodchanges/compare/v0.3.0...v0.4.0 [0.3.0]: https://github.com/gooddata/gooddata-goodchanges/compare/v0.2.5...v0.3.0 [0.2.5]: https://github.com/gooddata/gooddata-goodchanges/compare/v0.2.4...v0.2.5 diff --git a/README.md b/README.md index 7041206..777bf98 100644 --- a/README.md +++ b/README.md @@ -35,10 +35,18 @@ BINDIR=~/.local/bin ./install.sh v0.2.5 ## Output +JSON array of target objects: + ```json -["gdc-dashboards-e2e", "home-ui-e2e", "sdk-ui-tests-e2e"] +[ + {"name": "gdc-dashboards-e2e"}, + {"name": "neobackstop", "detections": ["stories/Button.stories.tsx", "stories/Dialog.stories.tsx"]} +] ``` +- Normal targets and fully-triggered virtual targets: `{"name": "..."}` +- Virtual targets where only fine-grained directories detected changes: `{"name": "...", "detections": ["..."]}` with the specific affected file paths + ## Environment variables | Variable | Description | Default | @@ -91,15 +99,31 @@ An aggregated target that watches specific directories across a project. Does no ```json { "type": "virtual-target", - "targetName": "sdk-ui-tests-e2e", - "changeDirs": ["scenarios", "stories"] + "targetName": "neobackstop", + "changeDirs": [ + { "path": "src" }, + { "path": "scenarios" }, + { "path": "stories", "type": "fine-grained" }, + { "path": "neobackstop" } + ] } ``` -**Trigger conditions:** +Each `changeDirs` entry is an object with: + +- `path` -- directory to watch (relative to project root) +- `type` -- optional, set to `"fine-grained"` for granular file-level detection + +**Normal directories** (no `type` or omitted): any file change or tainted import triggers a full run. + +**Fine-grained directories** (`"type": "fine-grained"`): instead of triggering a full run, collects the specific affected files. A file is affected if it: +- Was directly changed +- Imports tainted symbols from upstream workspace libraries +- Imports from a file that is affected (transitive within the directory) -- Any file in a `changeDirs` directory is changed -- Any file in a `changeDirs` directory imports a tainted symbol +**Output behavior:** +- If any **normal** directory triggers: `{"name": "neobackstop"}` (full run, no detections) +- If **only fine-grained** directories have detections: `{"name": "neobackstop", "detections": ["stories/Button.stories.tsx"]}` (specific files) ### Fields reference @@ -108,7 +132,7 @@ An aggregated target that watches specific directories across a project. Does no | `type` | `"target"` \| `"virtual-target"` | Both | Declares what kind of target this project is | | `app` | `string` | Target | Package name of the corresponding app this e2e package tests | | `targetName` | `string` | Virtual target | Output name emitted when the virtual target is triggered | -| `changeDirs` | `string[]` | Virtual target | Directories (relative to project root) to watch for changes | +| `changeDirs` | `ChangeDir[]` | Virtual target | Directories to watch. Each entry: `{"path": "...", "type?": "fine-grained"}` | | `ignores` | `string[]` | Both | Glob patterns for files to exclude from change detection | The `.goodchangesrc.json` file itself is always ignored. diff --git a/VERSION b/VERSION index 60a2d3e..79a2734 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.4.0 \ No newline at end of file +0.5.0 \ No newline at end of file diff --git a/internal/analyzer/analyzer.go b/internal/analyzer/analyzer.go index b3085ad..44ef9b7 100644 --- a/internal/analyzer/analyzer.go +++ b/internal/analyzer/analyzer.go @@ -4,6 +4,7 @@ import ( "fmt" "os" "path/filepath" + "sort" "strings" "goodchanges/internal/git" @@ -956,6 +957,141 @@ func parseScssUses(filePath string) []string { return uses } +// FindAffectedFiles returns a list of affected source files (relative to projectFolder) +// within the given directory. A file is affected if it: +// - was directly changed +// - imports tainted symbols from upstream libraries +// - imports from a file that is affected (transitive, BFS) +func FindAffectedFiles(dir string, upstreamTaint map[string]map[string]bool, changedFiles []string, projectFolder string) []string { + fullDir := filepath.Join(projectFolder, dir) + + allFiles, err := globSourceFiles(fullDir) + if err != nil { + return nil + } + + // Parse all files in the directory + type fileInfo struct { + relToProject string // relative to projectFolder (e.g. "stories/foo.stories.tsx") + relToDir string // relative to dir (e.g. "foo.stories.tsx") + analysis *tsparse.FileAnalysis + } + fileMap := make(map[string]*fileInfo) // keyed by relToDir + for _, relToDir := range allFiles { + relToProject := filepath.Join(dir, relToDir) + fullPath := filepath.Join(fullDir, relToDir) + analysis, err := tsparse.ParseFile(fullPath) + if err != nil { + continue + } + fileMap[relToDir] = &fileInfo{ + relToProject: relToProject, + relToDir: relToDir, + analysis: analysis, + } + } + + affected := make(map[string]bool) + + // Seed from directly changed files + for _, f := range changedFiles { + if !strings.HasPrefix(f, fullDir+"/") { + continue + } + rel, _ := filepath.Rel(fullDir, f) + if _, ok := fileMap[rel]; ok { + affected[rel] = true + } + } + + // Seed from files importing tainted upstream symbols + for relToDir, fi := range fileMap { + if affected[relToDir] { + continue + } + for _, imp := range fi.analysis.Imports { + if strings.HasPrefix(imp.Source, ".") { + continue + } + affectedNames, ok := upstreamTaint[imp.Source] + if !ok || len(affectedNames) == 0 { + if IncludeCSS && matchesCSSTaint(imp.Source, upstreamTaint) { + affected[relToDir] = true + break + } + continue + } + if len(imp.Names) == 0 { + affected[relToDir] = true + break + } + for _, name := range imp.Names { + if strings.HasPrefix(name, "*:") || affectedNames[name] { + affected[relToDir] = true + break + } + } + if affected[relToDir] { + break + } + } + } + + if len(affected) == 0 { + return nil + } + + // Build local import graph: importer -> [imported files] + // and reverse: imported file -> [importers] + reverseImports := make(map[string][]string) + for relToDir, fi := range fileMap { + fileDir := filepath.Dir(filepath.Join(fullDir, relToDir)) + for _, imp := range fi.analysis.Imports { + if !strings.HasPrefix(imp.Source, ".") { + continue + } + resolved := resolveImportSource(fileDir, imp.Source, fullDir) + if resolved == "" { + continue + } + // resolved is relative to fullDir; find matching file + for candidate := range fileMap { + if stripTSExtension(candidate) == resolved { + reverseImports[candidate] = append(reverseImports[candidate], relToDir) + break + } + } + } + } + + // BFS propagation: if file A is affected and file B imports from A, B is affected + queue := make([]string, 0, len(affected)) + for f := range affected { + queue = append(queue, f) + } + for len(queue) > 0 { + current := queue[0] + queue = queue[1:] + for _, importer := range reverseImports[current] { + if !affected[importer] { + affected[importer] = true + queue = append(queue, importer) + } + } + } + + // Collect results relative to projectFolder + var result []string + for relToDir := range affected { + fi := fileMap[relToDir] + if fi != nil { + result = append(result, fi.relToProject) + } + } + sort.Strings(result) + return result +} + func globSourceFiles(projectFolder string) ([]string, error) { var files []string err := filepath.Walk(projectFolder, func(path string, info os.FileInfo, err error) error { diff --git a/internal/rush/rush.go b/internal/rush/rush.go index 8659d0e..d41e910 100644 --- a/internal/rush/rush.go +++ b/internal/rush/rush.go @@ -105,12 +105,22 @@ func BuildProjectMap(config *Config) map[string]*ProjectInfo { return projectMap } +type ChangeDir struct { + Path string `json:"path"` + Type *string `json:"type,omitempty"` // nil = normal, "fine-grained" +} + +// IsFineGrained returns true if this changeDir is configured for fine-grained detection. +func (cd ChangeDir) IsFineGrained() bool { + return cd.Type != nil && *cd.Type == "fine-grained" +} + type ProjectConfig struct { - Type *string `json:"type,omitempty"` // "target", "virtual-target" - App *string `json:"app,omitempty"` // rush project name of corresponding app - TargetName *string `json:"targetName,omitempty"` // output name for virtual targets - ChangeDirs []string `json:"changeDirs,omitempty"` // dirs to watch for virtual targets - Ignores []string `json:"ignores,omitempty"` + Type *string `json:"type,omitempty"` // "target", "virtual-target" + App *string `json:"app,omitempty"` // rush project name of corresponding app + TargetName *string `json:"targetName,omitempty"` // output name for virtual targets + ChangeDirs []ChangeDir `json:"changeDirs,omitempty"` // dirs to watch for virtual targets + Ignores []string `json:"ignores,omitempty"` } // LoadProjectConfig reads .goodchangesrc.json from the project folder. diff --git a/main.go b/main.go index 02a5468..6d67220 100644 --- a/main.go +++ b/main.go @@ -244,7 +244,11 @@ 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". - changedE2E := make(map[string]bool) + type TargetResult struct { + Name string `json:"name"` + Detections []string `json:"detections,omitempty"` + } + changedE2E := make(map[string]*TargetResult) for _, rp := range rushConfig.Projects { cfg := rush.LoadProjectConfig(rp.ProjectFolder) @@ -261,28 +265,30 @@ func main() { } // Condition 1: Direct file changes + triggered := false for _, f := range changedFiles { if strings.HasPrefix(f, rp.ProjectFolder+"/") { relPath := strings.TrimPrefix(f, rp.ProjectFolder+"/") if !cfg.IsIgnored(relPath) { - changedE2E[rp.PackageName] = true + triggered = true break } } } - if changedE2E[rp.PackageName] { + if triggered { + changedE2E[rp.PackageName] = &TargetResult{Name: rp.PackageName} continue } // Condition 2: External dep changes in lockfile if len(depChangedDeps[rp.ProjectFolder]) > 0 { - changedE2E[rp.PackageName] = true + changedE2E[rp.PackageName] = &TargetResult{Name: rp.PackageName} continue } // Condition 3: Tainted workspace imports if analyzer.HasTaintedImports(rp.ProjectFolder, allUpstreamTaint, cfg) { - changedE2E[rp.PackageName] = true + changedE2E[rp.PackageName] = &TargetResult{Name: rp.PackageName} continue } @@ -291,55 +297,84 @@ func main() { appInfo := projectMap[*cfg.App] if appInfo != nil { if changedProjects[*cfg.App] != nil { - changedE2E[rp.PackageName] = true + changedE2E[rp.PackageName] = &TargetResult{Name: rp.PackageName} continue } if len(depChangedDeps[appInfo.ProjectFolder]) > 0 { - changedE2E[rp.PackageName] = true + changedE2E[rp.PackageName] = &TargetResult{Name: rp.PackageName} continue } if analyzer.HasTaintedImports(appInfo.ProjectFolder, allUpstreamTaint, nil) { - changedE2E[rp.PackageName] = true + changedE2E[rp.PackageName] = &TargetResult{Name: rp.PackageName} continue } } } } else if cfg.IsVirtualTarget() && cfg.TargetName != nil { - // Virtual target: check changeDirs for file changes or tainted imports - triggered := false - for _, dir := range cfg.ChangeDirs { - fullDir := filepath.Join(rp.ProjectFolder, dir) - for _, f := range changedFiles { - if strings.HasPrefix(f, fullDir+"/") { - triggered = true - break + // Virtual target: check changeDirs for file changes or tainted imports. + // Normal dirs trigger a full run; fine-grained dirs collect specific affected files. + normalTriggered := false + var fineGrainedDetections []string + + for _, cd := range cfg.ChangeDirs { + fullDir := filepath.Join(rp.ProjectFolder, cd.Path) + + if cd.IsFineGrained() { + detected := analyzer.FindAffectedFiles(cd.Path, allUpstreamTaint, changedFiles, rp.ProjectFolder) + if len(detected) > 0 { + fineGrainedDetections = append(fineGrainedDetections, detected...) + } + } else { + // Normal: check for any file changes or tainted imports + for _, f := range changedFiles { + if strings.HasPrefix(f, fullDir+"/") { + normalTriggered = true + break + } + } + if !normalTriggered { + if analyzer.HasTaintedImports(fullDir, allUpstreamTaint, nil) { + normalTriggered = true + } } } - if triggered { - break - } - if analyzer.HasTaintedImports(fullDir, allUpstreamTaint, nil) { - triggered = true + if normalTriggered { break } } - if triggered { - changedE2E[*cfg.TargetName] = true + + if normalTriggered { + changedE2E[*cfg.TargetName] = &TargetResult{Name: *cfg.TargetName} + } else if len(fineGrainedDetections) > 0 { + sort.Strings(fineGrainedDetections) + changedE2E[*cfg.TargetName] = &TargetResult{ + Name: *cfg.TargetName, + Detections: fineGrainedDetections, + } } } } - // Build sorted list of affected e2e packages - e2eList := make([]string, 0, len(changedE2E)) - for name := range changedE2E { - e2eList = append(e2eList, name) + // Build sorted list of affected targets + e2eList := make([]*TargetResult, 0, len(changedE2E)) + for _, result := range changedE2E { + e2eList = append(e2eList, result) } - sort.Strings(e2eList) + sort.Slice(e2eList, func(i, j int) bool { + return e2eList[i].Name < e2eList[j].Name + }) if flagLog { logf("Affected e2e packages (%d):\n", len(e2eList)) - for _, name := range e2eList { - logf(" - %s\n", name) + for _, result := range e2eList { + if len(result.Detections) > 0 { + logf(" - %s (fine-grained: %d files)\n", result.Name, len(result.Detections)) + for _, d := range result.Detections { + logf(" %s\n", d) + } + } else { + logf(" - %s\n", result.Name) + } } }