Import graph
Fairlead builds a file-level graph of what imports what, and later milestones plan tests from it. You can inspect it yourself:
fairlead graph stats # files, edges, what didn't resolve, build time
fairlead graph why FROM TO # the chain by which FROM depends on TO
fairlead graph importers FILE # the files that depend on FILE directly
What counts as an edge
| Edge | From |
|---|---|
| import | import, export … from, import x = require(…) |
| type import | import type and export type … from; off with graph.type_imports = false |
| dynamic | import("…") with a plain string |
| require | require("…") and require.resolve("…") |
| mock | vi.mock, jest.mock and their relatives, naming a module |
| path literal | any string literal that names a file in the repository, so a test that spawns a CLI by its path depends on it |
| snapshot | __snapshots__/x.test.ts.snap to x.test.ts |
| rule | a [[graph.edges]] rule, below |
Single-file components are sources too. In a .vue or .svelte file every <script> block is read, <script setup> and Svelte’s context="module" included, and in an .astro file its --- frontmatter and its <script> blocks. Each block is parsed as its lang says (ts, tsx, js), else as JavaScript in Vue and Svelte and TypeScript in Astro, and a <script src="…"> counts as an import. The component file is the node, so import Card from './Card.vue' resolves to it like any other import, and a change to a component reaches the tests that import it, directly or through other components.
An import() or require() whose argument isn’t a plain string is counted as an unknown dynamic import; the planner treats the file as depending on its whole module.
Edges the imports don’t show
Some tests reach their code without importing it. An API test boots the server and calls it over HTTP, so no import joins the test to the area it exercises. A rule adds that edge, and the planner follows it like an import:
[[graph.edges]]
from = "apps/api/test/api/{area}{,-*}.test.ts"
to = ["apps/api/src/{area}/**"]
Each file matching from depends on every file its to globs match. A {name} stands for one path segment, with the same value on both sides, so orders.test.ts and orders-refunds.test.ts depend on src/orders/. Its value is read from the side where it’s a whole segment: {area}* alone would also take orders-refunds as the name. config check refuses a {name} that is never a whole segment, or a to glob that names different ones than from (a to with none links to the same files for every match). graph stats counts rule edges, and names any rule that linked no file.
Names in the code
Since 0.10.0: some dependencies are named in a file’s text rather than imported: a view by name, a config file by its key, a model’s factory by convention. A rule with find searches each from file’s text with a regex, and links only the files it matches in. Its capture, if it has one, fills {1} in to, and {1|path} fills it with its dots as slashes:
[[graph.edges]]
from = "**/*.php"
find = '''\bview\(\s*['"]([\w.\-/]+)['"]'''
to = ["resources/views/{1|path}.blade.php"]
[[graph.edges]]
from = "tests/**/*.php"
find = '''\bRefreshDatabase\b'''
to = ["database/migrations/**"]
The first makes a controller that calls view('users.index') depend on resources/views/users/index.blade.php. The second has no capture, so it links each test that uses RefreshDatabase to every migration. from may still use {name}s, as whole segments, and to may use them beside {1}. A filled to without wildcards is looked up directly; one with wildcards is matched like any glob. A name only a running program knows, such as view("admin.$page"), never matches, so it adds no edge. graph stats names each rule whose captures matched no file, with an example, which is where to look when replay finds a miss.
Framework packs
Since 0.10.0: a framework makes dependencies by convention, the same in every project that uses it. A pack bundles the rules for one framework, and a config names it in extends:
extends = ["laravel"]
Since 0.11.0: fairlead init names the pack a project’s manifests point to (artisan and laravel/framework for laravel, next in package.json for next), and init --pack NAME writes it. graph stats counts a pack’s rules that linked no file in one line rather than listing them, since a pack covers layouts a project needn’t use.
A pack is a layer under the project’s own config files, so a project’s rules and barriers come after its, and { replace = [...] } drops one of them (graph.barrier = { replace = [] } keeps none). A pack sets only graph.edges, graph.barrier and plan.run_all. A built-in pack ships in the binary, so it changes with the release, and the plan id changes with it. Any other name is a pack file’s path from the project root, such as extends = ["packs/ours.toml"], which is how a team writes its own.
| Pack | What it adds |
|---|---|
laravel | tests/Feature/** depend on app/Http/**, routes/** and resources/views/**, since HTTP tests reach controllers through routes no import shows. Views named in view(), View::make() and Blade’s @include, @extends, @each and @component; Blade components (<x-forms.input>); config('mail.from') to config/mail.php; Song::factory() to database/factories/SongFactory.php; tests using RefreshDatabase, LazilyRefreshDatabase or DatabaseMigrations to every migration. A barrier on bootstrap/app.php and bootstrap/providers.php, so the boot path, which names every route file and service provider, doesn’t tie each test to the whole app; a change under bootstrap/ or app/Providers/ runs everything instead. |
next | Since 0.11.0. App Router routes (page, route, default) depend on the layout, template, loading, error and not-found files of every folder above them, five deep, and the root’s global-error; Pages Router pages depend on _app and _document. app/ and src/app/ alike. A change to next.config.* runs everything. Since 0.12.0: the end-to-end rule below moved to next-e2e. |
next-e2e | Since 0.12.0. With next: an end-to-end spec that calls page.goto( depends on every route and on middleware. It is coarse, every spec reaching every route, so a project whose owner rules map pages to specs leaves it out (#212). |
PHP
.php files are scanned too (Blade templates, .blade.php, are left as plain files), and graph stats lists them as the php provider. PHP refers to classes by name, not by path, so each reference is first made fully qualified the way PHP does it: through the file’s namespace, its use imports (grouped and aliased ones included), and \ or namespace\ prefixes. A file depends on:
- each class it names in a type,
new,::,extends,implements, a traituse, an attribute orinstanceof, and each name it imports withuse; - each function it calls by name, tried in its namespace and then globally, as PHP does, so a call to a helper reaches the file that declares it;
- each file an
includeorrequirenames with a literal:'x.php',__DIR__ . '/x.php'ordirname(__DIR__, 2) . '/x.php'; any other string that looks like a path counts as a path literal, as in JavaScript.
A name becomes a file through composer’s autoloading, read from every composer.json in the tree, so path repositories count: the longest psr-4 or psr-0 prefix under autoload or autoload-dev whose file exists, else any file that declares the class or function, which covers classmaps and files. A name under one of the repository’s own prefixes that no file declares is kept as the path autoloading would load, so deleting that file still reaches the files that named it. Anything else is a vendor or built-in name and is left out.
PHP tests aren’t matched by default. Name them, and the runner that runs them:
[tests]
match = ["tests/**/*Test.php"]
[[tests.runners]]
id = "phpunit"
match = ["tests/**/*Test.php"]
command = ["vendor/bin/phpunit", "{files}"]
In a Laravel application every test boots the app, and booting it loads bootstrap/app.php, which names every route file, which names every controller, so a change to one controller reaches every test. That’s the safe answer, and the one this gives without a pack. The laravel framework pack stops the walk at the boot path and links feature tests to the HTTP layer, views, config files, factories and migrations instead. An app that loads its routes another way can have the opposite problem, a controller almost nothing reaches, which the pack’s feature-test rule covers too.
Go
.go files are scanned as the go provider. A Go package is every .go file in one directory, so:
- a file that imports a package depends on each of its non-test files;
- a test file depends on every other
.gofile in its directory, the package’s own and its other test files, since they’re compiled together; - a
//go:embedpattern depends on the file it names, every file under a directory it names, or the files a glob matches.
Import paths resolve through every go.mod in the tree, so a go.work workspace’s modules are all found. The module whose path is the longest prefix of the import owns it, and a replace with a local path (=> ../lib) points a module at that directory. An import no module in the tree owns is looked for under the importing module’s vendor/, and otherwise is the standard library or a dependency, left out. Build constraints aren’t read, so a file for another platform still counts, which only ever adds tests.
go test takes packages rather than files, so its runner uses {packages}, which expands to one ./dir per directory holding a selected test, or ./... when everything runs:
[tests]
match = ["**/*_test.go"]
[[tests.runners]]
id = "go"
match = ["**/*_test.go"]
command = ["go", "test", "{packages}"]
Each Go file depends on its module’s go.mod and go.sum (edge kind manifest), since they pick the versions of everything it imports. A dependency bump in one module therefore selects that module’s tests and the tests of every module importing it, not the whole repository. Under a go.work, which picks versions across every module it spans, a file depends on all of those modules’ manifests instead. A change to go.work or go.work.sum, which spans the workspace, runs everything by default (plan.run_all), as a lockfile does. To have a go.mod change run everything anyway, add **/go.mod to plan.run_all.
Python
.py files are scanned as the python provider, with tree-sitter, so an import inside a function or a try counts like one at the top. A file depends on:
- the module each
import a.bnames, and the package__init__.pyfiles above it, which importing it runs; - for
from a.b import c, the modulea.b.cwhen there is one, elsea.b, whose namecis;from a.b import *namesa.b; - relative imports (
from . import x,from ..models import y) from the file’s own package; - any string that looks like a path, as in JavaScript.
Top-level modules are looked for under the repository root, each folder holding a pyproject.toml, setup.py or setup.cfg, and the src folder beside any of those, the ones above the importing file first. A module found under none of them is the standard library or an installed package, and is left out.
pytest loads every conftest.py from the test’s folder up to the root before the test, so a test file (test_*.py or *_test.py) and a conftest.py depend on each conftest.py above them. A conftest.py that imports much of the package makes most changes reach every test, which is what pytest does too.
[tests]
match = ["tests/**/test_*.py"]
[[tests.runners]]
id = "pytest"
match = ["tests/**/test_*.py"]
command = ["pytest", "{files}"]
A change to pyproject.toml, setup.py, setup.cfg, requirements*.txt, poetry.lock, uv.lock or Pipfile.lock runs everything by default (plan.run_all).
Java and Kotlin
Since 0.7.0: .java and .kt files are scanned as the jvm provider, with
a small lexer of its own rather than a grammar, so it costs the binary almost
nothing. Java and Kotlin refer to a class by its qualified name, and a class
in the same package needs no import, which is how a JUnit test usually
reaches the class it tests. So every file’s package line and top-level
declarations (classes, interfaces, enums, records, annotations, and Kotlin’s
objects, type aliases and top-level functions) make an index of qualified
names, and a file depends on:
- each class an
importnames, or the class that holds a nested class or a static member it names (import static a.b.Money.formatisa.b.Money); - each class it uses by name that its own package, or a wildcard import
(
import a.b.*), declares; - in Kotlin, each top-level function it calls that its package declares.
A name nothing in the tree declares, such as the JDK’s or a dependency’s, is left out. Paths and build files don’t matter: the index is built from what the files say, so a multi-module Maven or Gradle build resolves across modules. Names used only in comments or strings, and reflection, don’t count.
Maven and Gradle take test classes rather than files. An argument holding
{class} is repeated once per selected test class, and {classes} joins
them with commas; the class comes from the source-set layout, so
core/src/test/java/com/acme/OrderTest.java is com.acme.OrderTest. When
everything runs, the argument is dropped and the whole suite runs.
[tests]
match = ["**/src/test/**/*.java", "**/src/test/**/*.kt"]
[[tests.runners]]
id = "gradle"
match = ["**/src/test/**"]
command = ["./gradlew", "test", "--tests={class}"]
# Maven instead: command = ["mvn", "test", "-Dtest={classes}", "-Dsurefire.failIfNoSpecifiedTests=false"]
[plan]
# Build files pick every dependency's version, as a lockfile does.
run_all = ["**/pom.xml", "**/build.gradle", "**/build.gradle.kts", "settings.gradle*", "gradle/libs.versions.toml"]
Setting plan.run_all replaces the default list, so copy the defaults you
still want (fairlead config show --origin prints them).
On two public repositories, a change to each of 40 sampled main classes selected every test file that names the class, apart from names that appear only in a comment, a string or a test’s own class of the same name: gson (122 main and 142 test files) and moshi (Kotlin and Java, 95 and 48).
Other languages: external providers
The built-in scanners read JavaScript, TypeScript, PHP, Go and Python, and Java and Kotlin (since 0.7.0). For any other language, or a build tool that already knows its own graph, a [[graph.providers]] entry names a command that prints the graph for the files it claims:
[[graph.providers]]
id = "go"
command = ["./tools/go-graph.sh"]
files = ["**/*.go"]
- The command runs at the project root and reads the files it claims on stdin, one per line. It prints
{"version": 1, "edges": [{"from": "pkg/a_test.go", "to": "pkg/a.go"}]}: repo-relative paths, wherefromdepends onto. The shape is a public contract, committed asprovider-v1.schema.json. - Claims: a file a provider’s
filesmatch belongs to that provider, and the built-in scanner leaves it alone. When two providers match one file, the one whose pattern has the longer literal part before its first wildcard wins (src/go/**over**/*.go), the earlier one on a tie, andgraph statsreports the conflict. - Its edges count only from files it claims, to files in the tree; the rest are counted as ignored in
graph stats. They’re followed like imports, with their own edge kind,provider. - A provider that fails (it can’t start, exits non-zero, runs past
timeout_seconds, 120 by default, or prints something that isn’t a version 1 graph) tells nothing about its files. A change to one of them runs every test, with aprovider-failedwarning naming the provider and why, as other uncertainty does. graph statslists each provider with its files and edges, the built-in scanners astypescript,php,goandpython.
Build tools that already know their graph plug in the same way, through a command that turns their output into this shape, such as cargo metadata for Rust, or a monorepo tool’s project graph.
Coverage maps
Some dependencies only show up when the code runs: a container that builds a class from a string, importlib.import_module, framework wiring. A coverage run sees them all. A coverage map records which source files each test ran, and its edges (kind coverage) join the static graph. A change reaches a test through either, and a new file the map predates still reaches its tests through its imports. It’s off by default, since it needs a full coverage run to make.
Make the map from a coverage run’s report, in the same job:
# PHPUnit, with pcov or Xdebug
vendor/bin/phpunit --coverage-xml build/coverage-xml
fairlead coverage import --format phpunit-xml build/coverage-xml
# pytest
pytest --cov=src --cov-context=test
coverage json --show-contexts -o coverage.json
fairlead coverage import --format coverage-py coverage.json
and name it:
[graph.coverage]
map = ".fairlead/coverage.json"
max_age_days = 14
- PHPUnit names each test
Class::method, so the test file is the file that declares the class, through the same autoloading as PHP. coverage.py needs the per-test contexts pytest-cov records with--cov-context=test; lines run outside any test, such as imports at collection, count for none. - The map is
{"version": 1, "commit": ..., "created": ..., "source": ..., "tests": {"tests/a_test.py": ["src/a.py", ...]}}, stamped with the commit it tested and the day it was made. The shape is a public contract, committed ascoverage-v1.schema.json, so any tool can write one. A path from another checkout is matched by its longest tail that’s a file here. - A map older than
max_age_daysleaves acoverage-stalewarning on the plan, naming its date and commit; one that can’t be read leavescoverage-unreadable, and the plan uses the static graph alone. Neither fails the plan. A change to the map itself selects nothing. graph statsreports the map’s edges, tests, source, date and commit, and how many of its pairs name a file no longer in the tree.
Refresh it on a schedule, such as a nightly workflow that runs the suite with coverage, imports the report and commits the map or keeps it where CI can fetch it.
Barriers
In a typical server, every area imports a shared module for its routes or its auth, and that module imports every area. A walk through it reaches every test from any change. A barrier stops that:
[graph]
barrier = ["apps/api/src/{http,db,auth}/**"]
The same fan-out happens on the test side: end-to-end specs often import one shared module, such as a strings or fixtures index, only to build their expected values, and then any change under it plans every spec. A barrier on that index fixes it.
The walk reaches a barrier file, but doesn’t go on to the files that import it. That includes a changed barrier file, which reaches nothing and is left to tests.unreached, unless plan.run_all or an owner rule covers it. So list a barrier’s paths in plan.run_all when a change to them should run everything. graph why still shows a chain through a barrier and says the plan stops there, and test --explain names the barrier that kept a test out.
A rule edge is a claim the imports can’t check. Replay measures it: a failure in a test the rule doesn’t reach shows up as a miss.
Resolution, without an install
Specifiers resolve the way Node and TypeScript do: tsconfig paths and project references (the nearest tsconfig to each file), .js specifiers that point at .ts files, directory index files, and package exports with the conditions in graph.conditions.
Workspace packages, from workspaces in the root package.json or packages in pnpm-workspace.yaml, resolve without node_modules: Fairlead shows the resolver a virtual node_modules that points at each package’s folder. So a plan can run before npm install, and in CI before the install step.
- An import that lands in a package’s build output (its tsconfig
outDir, not in git) maps back to the same path underrootDir, whether or not a local build has put the output on disk. - A workspace import that still lands on a missing or git-ignored file becomes an edge to every file of that package, which is always safe.
- A tsconfig that
extendssomething that can’t be read (a shared config published as a package, before install) is skipped for that file, and counted ingraph stats. - Since 0.8.0: an import of a package from outside the repository is no edge, but the graph keeps its package name, from the specifier and, when it resolved into
node_modules, from the folder it landed in. A plan uses it to find the files a dependency’s version bump reaches, installed or not.
Files larger than 256 KB, almost always generated, are scanned for import strings instead of parsed. The scan can’t tell a comment from code, so it takes from, import and require only where they start a word, and only a specifier without whitespace, which no module name has: a doc comment reading from "My booking" isn’t an import.
Speed
A cold build of a 2,000-file workspace takes well under the 1.5 second budget on 4 cores, and a warm one under half a second. CI checks both on every pull request.
Parsing is most of a cold build. Fairlead keeps each file’s parse result in .git/fairlead/parse-cache.json, keyed by the file’s git blob id (the same id git hash-object prints) and its extension, so a file is parsed again only when its bytes change. The cache sits inside the git directory, so it’s never committed and needs no .gitignore line; in a worktree it goes in that worktree’s git directory. A new Fairlead version starts it over, entries for files that are gone drop out on the next build, and an unreadable cache is rebuilt. graph stats reports hits and files parsed. To turn it off, set graph.cache = false, or FAIRLEAD_GRAPH__CACHE=false for one run.
On the benchmark repositories, 4 cores:
| Repository | Cold | Warm |
|---|---|---|
| Effect-TS/effect | 1.19 s | 0.07 s |
| pnpm/pnpm | 1.02 s | 0.26 s |
| vitest-dev/vitest | 0.41 s | 0.05 s |