affected-ci
v1.0.2
Published
Run lint, typecheck, tests and Playwright specs on only what your diff can reach — from your import graph, with no workspaces required
Maintainers
Readme
affected-ci
Run lint, typecheck, tests and Playwright specs on only what your diff can reach — derived from your import graph, with no workspaces required.
Change one line in Button.tsx and you want Button's tests, plus the tests of
everything that imports Button however deep the chain goes. You want the same
for lint and typecheck. Billing never imports Button, so Billing should not be
touched at all. And you want that list computed from your code every time, so
it is never wrong and you never maintain it.
Turborepo and Nx do this by reading workspace manifests. This does it by reading your imports, which means it works in a single app in a single repo with no package boundaries at all.
Install
npm install --save-dev affected-ci
# recommended, and the default engine when present:
npm install --save-dev dependency-cruiserWhat each tier needs
Every tier shells out to a tool you already have. Nothing here is bundled, and
nothing is a hard dependency — you only need the tool for the tier you actually
run. affected list and affected specs need nothing but git.
| Tier | Runs | Needs |
| ----------- | ------------------------------------------------------------------------------- | ----------------------------------------------------------------- |
| list | nothing | git |
| specs | nothing | git, and route files (see below) |
| lint | eslint --cache --cache-strategy content --cache-location .eslintcache <files> | ESLint 7.21+ (when --cache-strategy landed) |
| typecheck | tsc -p tsconfig.affected.json | TypeScript, and a tsconfig.json for the generated one to extend |
| test | vitest related --run --passWithNoTests <files> | Vitest with the related subcommand |
| e2e | playwright test <specs> | Playwright |
dependency-cruiser and typescript are the only declared peer dependencies,
both optional, and they are for the engine, not the tiers. The tier tools
are deliberately not declared: a repo that only wants affected test should
not be told it needs ESLint.
--cache-strategy content matters: ESLint's default is mtime, and a CI
checkout rewrites every mtime, so a restored cache would miss on every file.
Using different tools
Every tier is just a command and its arguments, so swap them in
affected.config.mjs. Jest has a direct equivalent of vitest related:
export default {
tiers: {
test: {
command: 'npx',
args: ['jest', '--findRelatedTests', '--passWithNoTests'],
allArgs: ['jest'], // the "global change, run everything" branch
},
lint: {
command: 'npx',
args: ['biome', 'check'],
cache: [], // biome has its own; do not pass ESLint's flags
all: ['src'],
extensions: ['.ts', '.tsx', '.js', '.jsx'],
maxArgs: 2000,
},
},
};The contract a tier's tool has to satisfy is small: accept a list of file paths as trailing arguments, and exit non-zero on failure. Anything that does both works.
What specs assumes
The specs and e2e tiers are the only ones with a structural requirement:
routes have to be files you can name, so a changed file can be traced to a
URL. The default is the Next.js app router convention,
src/app/**/page.tsx → /**. If your routes come from a config object
instead, override routes and map it yourself. The other four tiers do not
care how you route.
Use it locally
npx affected list # print the affected files, or ALL
npx affected specs # print the affected e2e specs, or ALL
npx affected verify # lint, typecheck, test — the whole local gate
npx affected lint
npx affected typecheck
npx affected test
npx affected e2e -- --shard=1/4 # everything after -- goes to the toolBASE_REF defaults to origin/main; --base-ref overrides it.
In package.json:
{
"scripts": {
"verify": "affected verify"
}
}and in lefthook.yml:
pre-push:
commands:
verify:
run: npm run verifyUse it in CI
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0 # the graph needs history to diff against
filter: blob:none
- uses: actions/setup-node@v4
with: { node-version: 22, cache: npm }
- run: npm ci
- uses: Cst2989/affected-ci@v1
with:
tier: lint
base-ref: origin/${{ github.base_ref }}Run every job on every PR. Each one decides internally how much work to do, so every required check reports a result and nothing sits pending forever.
tier: list and tier: specs also set outputs — files, specs, is-global.
The action has no runtime dependencies, so consumers install nothing for it.
Adopting in a repo that already has CI
You do not delete or replace your workflows. This changes the command inside a step, not the step, the job, or the workflow.
- uses: actions/checkout@v4
+ with:
+ fetch-depth: 0 # the graph needs history to diff against
+ filter: blob:none
- uses: actions/setup-node@v4
- run: npm ci
- - run: npm run lint
+ - run: npx affected lint
+ env:
+ BASE_REF: origin/${{ github.base_ref }}Your checkout, caching, matrix and job names stay exactly as they are.
fetch-depth: 0 is the one thing you must change. actions/checkout
clones with fetch-depth: 1, so the runner holds a single commit and has no
base branch to compare against.
Your required checks keep working, and that is the point. Because the job
names do not move, every check still reports on every PR. The tempting
alternative — a paths: filter on the workflow trigger — breaks branch
protection permanently: a workflow that never starts never reports, GitHub does
not read that silence as success, and the PR sits on
Expected — Waiting for status to be reported forever. Here every job runs and
each one decides internally how much work to do.
Convert one tier at a time. They are independent. Do lint first, watch a
few PRs, then typecheck, then test.
Keep running everything on main. Narrowing is a PR optimisation. Your
post-merge workflow, and your merge queue if you have one, should keep running
the full suite — a green baseline is the assumption all of this rests on.
Check that it is selecting, not skipping. The failure mode worth fearing is
a PR going green because nothing ran. On the first tier you convert,
deliberately break something and confirm CI catches it. npx affected list
prints exactly what was selected, so you can read it before trusting it. If the
config is wrong in a way that would under-select, add the offending path to
globalPaths and you are back to running everything — the safe direction.
Configure
affected.config.mjs at your repo root:
export { default } from 'affected-ci/presets/vite-react';or spell it out:
export default {
sourceDirs: ['src', 'e2e'],
aliases: { '@/': 'src/' }, // builtin engine only; depcruise reads tsconfig
globalPaths: ['package.json', 'tsconfig.json', '.github/', 'src/types/'],
engine: 'auto', // auto | dependency-cruiser | builtin
};Presets ship for next-app-router and vite-react.
The two engines
| | dependency-cruiser (default) | builtin |
| ----------------------- | ---------------------------------------------------------- | ------------------------------------------- |
| Resolution | real TypeScript parsing, tsconfig paths, dynamic imports | regex over import statements, one alias map |
| Dependencies | needs dependency-cruiser + typescript | none |
| Cold run on ~70 modules | ~150ms | ~50ms |
| Warm (content cache) | ~5ms | n/a |
auto uses dependency-cruiser when it resolves and falls back to the builtin
resolver, saying why. Naming an engine explicitly is a hard requirement — if
you asked for dependency-cruiser and it cannot run correctly, that is an error
rather than a silent downgrade to a resolver with different blind spots.
Both engines run against the same test suite, with the differences pinned in
test/engine-differences.test.mjs. The one that matters: a tsconfig paths
mapping you did not mirror in aliases is followed by dependency-cruiser and
missed by the builtin resolver.
What null / ALL means
Some changes no import graph can model: a lockfile bump, a lint config, an
ambient .d.ts, the CI workflow itself. Those are globalPaths, and they
return null from the API and print ALL from the CLI, meaning run
everything. An empty list means the graph genuinely reached nothing.
Failing toward over-selection
Every judgement call errs on the side of running too much, because the failure mode of running too little is a broken change that merges green.
- A file whose internal import does not resolve — a typo, a half-finished
rename, a computed
import()— produces no edge and would silently drop out of the graph. Those files are always treated as affected. - A layout is not a page, so no route regex reaches it, but it renders on every
route beneath it.
src/app/**/layout.tsxexpands into those routes. - A spec is selected on any route-shaped literal it contains, or that any
helper it imports contains — not just a literal in
page.goto()'s first argument. - Deleted paths still drag their dependents into the walk, then drop out of the
result, because handing a missing path to ESLint or
tscis a hard error. - A cruise that returns zero modules is an error, not "nothing affected".
Why not depcruise --affected?
It is close, and it is where this started. Two reasons it is not enough:
- It compares with two dots.
--affectedis sugar over--reaches "$(watskeburt <rev>)", and watskeburt shells out togit diff <rev> --name-status. Against a base branch that has moved ahead, every file that branch touched looks like yours. This package computes the changed set from the merge base — three dots — and hands the result to the engine. - It cannot map routes to specs. A Playwright spec imports nothing from
your app; it navigates to a URL. Any import-based selector skips it, which
is also why
playwright --only-changedreports green on a broken page. Thespecstier connects them through the route instead.
Known limits
- Static imports only. A module loaded from a runtime-computed path is invisible, though the file doing the loading is force-selected.
page.goto()with an absolute URL or one assembled entirely by interpolation has no path literal to read.- Typecheck precision is partly illusory: your app entry is in most affected
sets and
tscfollows its imports forward into everything.
License
MIT
