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
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
38 changes: 31 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down Expand Up @@ -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

Expand All @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
0.4.0
0.5.0
136 changes: 136 additions & 0 deletions internal/analyzer/analyzer.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"fmt"
"os"
"path/filepath"
"sort"
"strings"

"goodchanges/internal/git"
Expand Down Expand Up @@ -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 {
Expand Down
20 changes: 15 additions & 5 deletions internal/rush/rush.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Loading