Pull requests run only the tests affected by their changed files, using a
static dependency graph (scripts/test-graph.json). This keeps CI fast
without sacrificing correctness.
Pushes to main always run the full test suite, unconditionally.
PR opened / updated
│
▼
git diff --name-only origin/<base>...HEAD
│
▼
npx tsx scripts/select-tests.ts
│
├── Every changed file IS in the graph
│ and maps to a non-empty list?
│ │
│ ▼
│ Run only the listed test files
│ (passed as Jest --testPathPattern)
│
└── Any changed file NOT in the graph,
OR maps to [] (full-run sentinel),
OR any error?
│
▼
Run the full test suite
| File | Purpose |
|---|---|
scripts/test-graph.json |
Static map: source file → test files |
scripts/select-tests.ts |
CLI tool that reads the graph and emits a test list or __full_run__ |
.github/workflows/ci.yml |
Calls the tool on PRs; full run unconditionally on main |
src/__tests__/select-tests.test.ts |
Unit tests (38 tests, ≥95% coverage) |
Keys are POSIX-style paths relative to the repo root. Values are arrays of test file paths (also POSIX-style, relative to root).
| Value | Meaning |
|---|---|
| Non-empty array | Run exactly these test files when the source file changes |
[] empty array |
Full-run sentinel — any change to this file triggers the full suite |
Use [] for: jest.config.cjs, tsconfig.json, package.json,
package-lock.json, all .github/workflows/*.yml, Dockerfile, etc.
All six properties are tested in src/__tests__/select-tests.test.ts:
-
Unknown file → full run. Any changed file not present in the graph triggers
__full_run__. A brand-new source file can never silently skip its tests. -
Empty-list sentinel → full run. CI/config files map to
[], so any infrastructure change revalidates the whole suite. -
Deleted test → silently skipped.
filterExistingdrops test paths that no longer exist on disk. If all resolved tests are missing, falls back to a full run. -
Corrupt or missing graph → full run. Any I/O or JSON parse error causes the tool to emit
__full_run__— never a silent empty run. -
Unexpected internal error → full run. The resolve step is wrapped in a broad catch that falls back rather than failing CI.
-
No changed files → full run. An empty diff is treated conservatively.
The relevant section of .github/workflows/ci.yml:
# Fetch full history on PRs so git diff can find the merge-base.
- uses: actions/checkout@v4
with:
fetch-depth: ${{ github.event_name == 'pull_request' && 0 || 1 }}
# Step 1 — collect changed files (PR only)
- name: Get changed files
id: changed_files
if: github.event_name == 'pull_request'
run: |
git diff --name-only origin/${{ github.base_ref }}...HEAD \
> /tmp/changed_files.txt
# Step 2 — select tests
- name: Select tests
id: select_tests
if: github.event_name == 'pull_request'
run: |
mapfile -t changed < /tmp/changed_files.txt
npx tsx scripts/select-tests.ts \
--changed-files "${changed[@]}" \
--graph scripts/test-graph.json \
--output /tmp/selected-tests.txt \
--verbose
# Step 3a — full run on main
- name: Run tests (full — main)
if: github.event_name != 'pull_request'
run: npm test
# Step 3b — selected or full run on PR
- name: Run tests (selected — PR)
if: github.event_name == 'pull_request'
run: |
SELECTION=$(cat /tmp/selected-tests.txt)
if [ "$SELECTION" = "__full_run__" ]; then
npm test
else
mapfile -t TEST_PATHS < /tmp/selected-tests.txt
npm test -- --testPathPattern="$(IFS='|'; echo "${TEST_PATHS[*]}")"
fifetch-depth: 0 on PRs ensures git diff origin/<base>...HEAD always has
enough history to compute the merge-base correctly. On main pushes,
depth: 1 is sufficient because the diff step is skipped entirely.
# Print selected tests to stdout
npx tsx scripts/select-tests.ts \
--changed-files src/services/checkout.ts src/routes/checkout.ts \
--verbose
# Write to a file and run with Jest
npx tsx scripts/select-tests.ts \
--changed-files src/services/checkout.ts \
--output /tmp/selected.txt
SELECTION=$(cat /tmp/selected.txt)
if [ "$SELECTION" = "__full_run__" ]; then
npm test
else
mapfile -t TESTS < /tmp/selected.txt
npm test -- --testPathPattern="$(IFS='|'; echo "${TESTS[*]}")"
fiThe graph is manually maintained. Keep it accurate — stale entries cause missed test runs.
| Change | Required update |
|---|---|
| Add a new source file | Add a key mapping it to its test file(s) |
| Add a new test file | Update all source keys that should trigger it |
| Rename a source or test file | Update all affected keys and values |
| Delete a source file | Remove its key |
| Delete a test file | Remove it from all value arrays |
| Add a new CI/config file | Add a key with [] |
- Every new source file has a graph entry
- Every new test file appears in at least one value array
- No value array contains a deleted/renamed test path
- New CI/infrastructure files map to
[] -
npm test -- --testPathPattern="select-tests"passes
src/__tests__/select-tests.test.ts includes tests that run against the
committed graph file itself, verifying:
- It is valid JSON and a plain object
__ci_files__maps to[]scripts/select-tests.tsmaps tosrc/__tests__/select-tests.test.tsscripts/test-graph.jsonmaps tosrc/__tests__/select-tests.test.ts- All CI/config files listed map to
[]
Static graph, not dynamic import analysis. Parsing import statements at
CI time is fragile (runtime-only deps, barrel re-exports, plugin loading) and
adds tooling complexity. A static JSON file is explicit, reviewable, and
requires no execution.
Fail-open (full run on any uncertainty). The worst outcome of a bug in the selector is a slower CI run, not a missed regression. Unknown file → full run. Corrupt graph → full run. Empty changeset → full run.
POSIX paths everywhere. Git always outputs forward-slash paths even on Windows. The tool normalises all incoming paths before lookup so the graph works identically on all platforms.
[] sentinel over a special key. Having infrastructure files map to the
empty list keeps the format uniform — one file, one entry, one intent — rather
than requiring a separate allowlist of "CI files".
scripts/select-tests.ts maintains ≥95% statement and line coverage
(currently 97%). The only excluded code is the CLI entry point
(parseArgs, main, and the isMain guard), marked
/* istanbul ignore next */ because they require direct process execution.
Run coverage locally:
npm test -- \
--testPathPattern="select-tests" \
--coverage \
--collectCoverageFrom="scripts/select-tests.ts" \
--coverageThreshold="{}" \
--coverageReporters="text"
{ // Human-readable notes — never a file path key "__comment__": ["..."], // CI/config sentinel — any key mapping to [] triggers a full run "__ci_files__": [], // Normal entries "src/services/checkout.ts": [ "src/__tests__/checkout-pay.test.ts", "src/routes/__tests__/checkout.test.ts" ], // Config/infrastructure — empty list forces a full run on any change "jest.config.cjs": [], ".github/workflows/ci.yml": [] }