Granular change detection for Rush monorepos. Analyzes code changes at the AST level to determine which library exports are affected by a pull request, then propagates taint through the workspace dependency graph to identify which e2e test targets need to run.
# latest version
curl -fsSL https://raw.githubusercontent.com/gooddata/gooddata-goodchanges/master/install.sh | sh
# specific version
curl -fsSL https://raw.githubusercontent.com/gooddata/gooddata-goodchanges/master/install.sh | sh -s v0.2.5
# custom install directory
curl -fsSL https://raw.githubusercontent.com/gooddata/gooddata-goodchanges/master/install.sh | BINDIR=~/.local/bin shOr run locally:
BINDIR=~/.local/bin ./install.sh v0.2.5goodchanges # run change detection, outputs JSON to stdout
goodchanges -v # print version
goodchanges --version # print version- Finds the merge base commit (comparison point)
- Gets the list of changed files
- Loads
rush.jsonand builds the workspace dependency graph - Identifies directly changed projects and lockfile dependency changes
- Computes the full affected subgraph (transitive dependents)
- Topologically sorts affected packages (dependencies first)
- For each library: parses old and new TypeScript ASTs, diffs symbols, and propagates taint through import graphs
- For each target: checks if it's affected via direct changes, lockfile changes, tainted imports, or a tainted corresponding app
- Outputs a JSON array of affected e2e package names to stdout
JSON array of target objects:
[
{"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
| Variable | Description | Default |
|---|---|---|
LOG_LEVEL |
Logging verbosity. BASIC for standard logging, DEBUG for verbose AST/taint tracing to stderr |
(no logging) |
INCLUDE_TYPES |
When set to any non-empty value, includes type-only changes (interfaces, type aliases, type annotations) in taint propagation | (disabled) |
INCLUDE_CSS |
When set to any non-empty value, enables CSS/SCSS change detection and taint propagation through @use/@import chains |
(disabled) |
COMPARE_COMMIT |
Specific git commit hash to compare against (overrides branch-based comparison) | (empty) |
COMPARE_BRANCH |
Git branch to compute merge base against | origin/master |
TARGETS |
Comma-delimited list of target names to include in output. Supports * wildcard (e.g. *backstop*,@gooddata/sdk-*). |
(all targets) |
A package is classified as a library if its package.json contains any of:
types(TypeScript type declarations)exports(modern package exports field)module(ES module entry)
Libraries get full AST-level analysis: entrypoint resolution, symbol diffing, and taint propagation through their internal import graph.
Everything else is an app (bundled). Apps are not analyzed for granular exports -- if any file in an app changes, the app is considered fully tainted.
Each project can optionally have a .goodchangesrc.json file in its root directory. A single config file can define multiple targets via the targets array.
{
"targets": [
{
"type": "target",
"app": "@gooddata/gdc-dashboards"
},
{
"type": "virtual-target",
"targetName": "neobackstop",
"changeDirs": [
{ "glob": "src/**/*" },
{ "glob": "scenarios/**/*" },
{ "glob": "stories/**/*.stories.tsx", "type": "fine-grained" },
{ "glob": "neobackstop/**/*" }
]
}
],
"ignores": ["scenarios/**/*.md"]
}Marks a project as an e2e test package. The package name is included in the output when any of the 4 trigger conditions are met.
Trigger conditions:
- Direct file changes -- files changed in the project folder (excluding ignored paths)
- External dependency changes -- a dependency version changed in
pnpm-lock.yaml - Tainted workspace imports -- the target imports a tainted symbol from a workspace library
- Corresponding app is tainted -- the app specified by
appis affected (any of the above conditions)
An aggregated target that uses glob patterns to match files across a project. Does not correspond to a real package name in the output -- uses targetName instead.
Each changeDirs entry is an object with:
glob-- glob pattern to match files (relative to project root). Uses doublestar syntax:*matches files in current directory only,**/*matches all nested files,**/*.stories.tsxmatches specific patterns recursively.filter-- optional output filter glob (fine-grained only). When set, theglobdefines the analysis scope andfilternarrows which affected files appear in the output. Example:{"glob": "src/**/*", "filter": "src/**/*.test.ts", "type": "fine-grained"}analyzes all files insrc/but only returns affected test files.type-- optional, set to"fine-grained"for granular file-level detection
Ignores override globs: if a file matches a changeDirs glob but also matches an ignores pattern, the file is excluded.
Normal globs (no type or omitted): any matching file change or tainted import triggers a full run.
Fine-grained globs ("type": "fine-grained"): instead of triggering a full run, collects the specific affected TS/TSX source 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 matched set)
Output behavior:
- If any normal glob triggers:
{"name": "neobackstop"}(full run, no detections) - If only fine-grained globs have detections:
{"name": "neobackstop", "detections": ["stories/Button.stories.tsx"]}(specific files)
Top-level fields:
| Field | Type | Description |
|---|---|---|
targets |
TargetDef[] |
Array of target definitions (see below) |
ignores |
string[] |
Glob patterns for files to exclude from change detection |
TargetDef fields (each entry in targets):
| Field | Type | Used by | Description |
|---|---|---|---|
type |
"target" | "virtual-target" |
Both | Declares what kind of target this 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 |
ChangeDir[] |
Virtual target | Glob patterns to match files. Each entry: {"glob": "...", "filter?": "...", "type?": "fine-grained"} |
ignores |
string[] |
Both | Per-target ignore globs. Additive with the global ignores -- only applies to this target's detection |
The .goodchangesrc.json file itself is always ignored.
Library entrypoints are resolved from package.json:
- If
exportsfield exists, all export paths are parsed (supports nested conditional exports) - Otherwise, falls back to
main,module,browser,typesfields
Build output paths (e.g. dist/index.js) are resolved back to source files (e.g. src/index.ts) by trying candidates in order: src/ prefix, original path, and index files.
For each changed .ts/.tsx/.js/.jsx file in a library:
- Fetches the old file content from git at the merge base
- Parses both old and new versions into ASTs using the vendored TypeScript parser
- Compares each symbol's body text to detect changes
- Distinguishes runtime changes from type-only changes (stripping type annotations, casts, generics)
Taint spreads through the import graph via unlimited BFS hops:
- Named imports: if
import { Button } from "./components"andButtonis tainted, symbols in the importing file that referenceButtonbecome tainted - Namespace imports:
import * as X from "./foo"-- any taint infoopropagates - Side-effect imports:
import "./setup"-- if the imported file is tainted, all symbols in the importing file are tainted - Re-exports:
export { X } from "./foo"andexport * from "./foo"are tracked as import edges - Cross-package: taint from upstream workspace dependencies is passed into downstream packages
- Intra-file: if symbol A is tainted and symbol B references A in its body, B becomes tainted
- External deps: lockfile version changes taint all imports from the affected package
When INCLUDE_CSS is set:
- Any changed
.css/.scssfile taints the entire package's styles - Style imports (
*.css,*.scss, paths containing/styles/) from tainted packages are detected - SCSS
@useand@importchains are followed transitively across packages
The tool vendors microsoft/typescript-go for AST parsing. The pinned commit hash is stored in TSGO_COMMIT.
We cannot use typescript-go as a regular Go dependency because its parser, AST types, and all other packages live under internal/. Go enforces that internal/ packages can only be imported by code within the same module, so no external project can import "github.com/microsoft/typescript-go/internal/parser". The project does not expose a public Go API — it is structured as a standalone tool, not a library.
The vendor script works around this by shallow-cloning the repository, renaming internal/ to pkg/, and rewriting all import paths. This makes the parser packages importable from our module via a local replace directive in go.mod.
# Vendor using the pinned commit
bash vendor-tsgo.sh
# Update to latest and vendor
bash vendor-tsgo.sh --updateThe vendor script:
- Reads the commit hash from
TSGO_COMMIT - Shallow-clones that specific commit
- Renames
internal/topkg/(to make packages importable) - Rewrites import paths and module name
The vendor ~ upgrade workflow (.github/workflows/vendor-upgrade.yml) can be triggered manually:
- Input:
commit_hash(defaults to"latest"which resolves to newest main) - Updates
TSGO_COMMIT, runs the vendor script, updates Go version inDockerfileandgo.mod, runsgo mod tidy, and opens a PR
# Build stage: Go + git + bash
FROM golang:X.Y.Z-alpine AS builder
# Vendors typescript-go, builds the binary
# Runtime stage: Alpine + git
FROM alpine:3.23
# Runs /usr/bin/goodchangesUsage:
docker run --rm \
-v /path/to/rush-monorepo:/repo \
-w /repo \
-e LOG_LEVEL=BASIC \
-e COMPARE_BRANCH=origin/master \
gooddata/gooddata-goodchanges:latestExample .goodchangesrc.json files can be found in:
testing_pre_merge/configFiles/-- configurations used for pre-merge (PR) analysistesting_post_merge/configFiles/-- configurations used for post-merge analysis
Example analysis reports are in testing_pre_merge/prs/ and testing_post_merge/prs/.
main.go # Entry point, orchestration
internal/
analyzer/
analyzer.go # Library analysis, taint propagation, CSS tracking
astdiff.go # AST-level symbol diffing, type-only detection
resolve.go # Entrypoint and import path resolution
diff/
diff.go # Unified diff parser (line ranges)
git/
git.go # Git operations (merge-base, diff, show)
lockfile/
lockfile.go # pnpm-lock.yaml parser, dep change detection
rush/
rush.go # Rush config, dependency graph, project configs
tsparse/
tsparse.go # TypeScript parser (imports, exports, symbols)
install.sh # Standalone binary installer
vendor-tsgo.sh # Vendor script for typescript-go
TSGO_COMMIT # Pinned typescript-go commit hash
Dockerfile # Multi-stage Docker build