Header menu logo TestPrune

Integration guide

The test-prune CLI is a reference implementation: it shows how the pieces fit, but it re-analyzes serially and isn't tuned for large codebases. For real workflows, embed TestPrune.Core directly in your build system or editor, where you can cache aggressively and parallelize across projects.

dotnet add package TestPrune.Core

All the snippets below are drawn from how the CLI itself wires the library (see src/TestPrune/Orchestration.fs). FSharp.Compiler.Service type-checking is not instant, so plan on caching.

1. Index your project

First, build a dependency graph of your code. This parses every .fs file with FCS and stores the results in a local SQLite database:

open TestPrune.AstAnalyzer
open TestPrune.Database

let checker = FSharpChecker.Create()
let db = Database.create ".test-prune.db"

let fileName = "/abs/path/to/src/Lib.fs"
let source = System.IO.File.ReadAllText fileName
let projectName = "MyProject"

let projOptions = getScriptOptions checker fileName source |> Async.RunSynchronously

match
    analyzeSource checker fileName source projOptions projectName
    |> Async.RunSynchronously
with
| Ok result ->
    let normalized =
        { result with
            Symbols = normalizeSymbolPaths repoRoot result.Symbols }

    db.RebuildProjects([ normalized ])
| Error msg -> eprintfn $"Failed: %s{msg}"

analyzeSource takes checker source-file source project-options project-name and returns Result<AnalysisResult, string>. getScriptOptions is a convenient way to get project options for a single file; in a real build you'll usually have full project options already. normalizeSymbolPaths repoRoot rewrites absolute source paths to repo-relative ones so the graph is stable across machines.

2. Cache for speed

Re-analysis is the expensive part, so skip it whenever you can. TestPrune supports two cache levels — project and file — both keyed on content hashes that you supply. Read them with db.GetProjectKey / db.GetFileKey, and write them in the same transaction as the symbols via RebuildProjects's optional fileKeys / projectKeys arguments:

match db.GetProjectKey("MyProject") with
| Some key when key = currentKey -> () // unchanged — skip the whole project
| _ ->
    // For files that haven't changed, reuse cached rows instead of re-analyzing:
    match db.GetFileKey("src/Lib.fs") with
    | Some key when key = currentFileKey ->
        let symbols = db.GetSymbolsInFile("src/Lib.fs")
        let deps = db.GetDependenciesFromFile("src/Lib.fs")
        let tests = db.GetTestMethodsInFile("src/Lib.fs")
        () // ... use cached data
    | _ -> () // file changed — run analyzeSource as above

    // Write symbols and both sets of cache keys atomically.
    db.RebuildProjects(
        [ combined ],
        fileKeys = [ "src/Lib.fs", currentFileKey ],
        projectKeys = [ "MyProject", currentKey ]
    )

Cache keys can be anything that changes when source files change. Good options:

3. Find affected tests

When you're ready to test, compare the current code against the index to find what changed, then ask which tests are affected. selectTests returns a TestSelection * AnalysisEvent list tuple (the events are an audit trail you can ignore):

open TestPrune.Ports
open TestPrune.ImpactAnalysis

let store = toSymbolStore db

let selection, _events = selectTests store changedFiles currentSymbolsByFile

match selection with
| RunSubset tests -> () // only these test methods need to run
| RunAll reason -> () // can't analyze the change — run everything

RunSubset carries a list of specific test methods. RunAll is the safe fallback for .fsproj changes, brand-new files, or analysis failures — anything where TestPrune can't be sure what's affected. The reason says which.

4. (Bonus) Find dead code

The same dependency graph can find code that's never reached from your entry points. Resolve entry-point patterns to symbol names, compute the reachable set, then call findDeadCode:

open TestPrune.DeadCode

let store = toSymbolStore db
let allSymbols = store.GetAllSymbols()
let allNames = store.GetAllSymbolNames()

let entryPatterns = [ "*.main"; "*.Program.*" ]
let entryPoints = findEntryPoints allNames entryPatterns
let reachable = store.GetReachableSymbols(entryPoints)
let testMethodNames = store.GetTestMethodSymbolNames()

// result.UnreachableSymbols — symbols nothing reaches from the entry points
let result, _events = findDeadCode allSymbols reachable testMethodNames false

The last argument is includeTests. By default (false) symbols in test files are excluded from the report; pass true to find dead code in your test suite too, e.g. unused test helpers. For per-symbol "why is this unreachable" detail, use findDeadCodeVerbose, which also takes a getIncomingEdgesBatch (available as store.GetIncomingEdgesBatch).

Extensions

Some dependencies don't show up in code — like HTTP routes mapping to handler files. Extensions let you teach TestPrune about these by implementing ITestPruneExtension to inject extra dependency edges.

Build those edges with EdgeEmission.edgesTo: emit an edge from each dependent to the specific symbol it depends on across the boundary, scoped precisely when the fact names a symbol and degraded to the whole changed file when it doesn't. Both bugs TestPrune has shipped came from an extension hand-rolling this step — one over-selected (a cross-product of every test and every symbol in the file), one under-selected (a scoping filter that kept only the first match). Scoping to the direct symbol is safe because QueryAffectedTests walks the graph transitively in reverse, so an edge to a handler already selects that test when anything the handler calls changes:

open TestPrune.EdgeEmission
open TestPrune.Extensions

type ExampleExtension() =
    interface ITestPruneExtension with
        member _.Name = "example"

        member _.AnalyzeEdges
            (symbolStore: SymbolStore)
            (changedFiles: string list)
            (repoRoot: string)
            : Dependency list =
            // The out-of-band fact this extension knows and the AST cannot: the tests in
            // `tests/ApiTests.fs` exercise the handler `Handlers.getUser`.
            let dependents = symbolStore.GetSymbolsInFile "tests/ApiTests.fs"

            changedFiles
            |> List.collect (fun changedFile ->
                let candidates = symbolStore.GetSymbolsInFile changedFile

                // `edgesTo` scopes the edge to the symbol the fact names. A fact that
                // names none (`UnnamedSymbol`), or names one that no longer resolves,
                // falls back to every symbol in the changed file — coarse, but never
                // empty: a missing edge is a test that silently stops being re-run.
                // Never hand-roll a cross-product of all tests x all symbols.
                //
                // The DIRECT symbol is enough. Core's `QueryAffectedTests` is a recursive
                // TRANSITIVE reverse-walk of the graph, so `test -> getUser` already
                // re-selects the test when anything getUser calls changes.
                edgesTo "example" SharedState candidates (NamedSymbol "Handlers.getUser") dependents)

TestPrune.Falco is an extension for Falco web apps that maps URL routes to integration tests.

Extension-owned storage

Most extensions derive their facts from the symbol graph and need no storage. An extension whose facts come from outside the AST — Falco's routes live in a route DU plus runtime wiring, so they're seeded by a separate build step — can own a table inside TestPrune's cache database via Ports.toPluginStore db, which hands it a connection. Core never learns what the table means.

The contract runs both ways. Core owns the file: opening it checks the schema version and, on a mismatch, deletes and recreates it — dropping extension tables with it, because core cannot migrate a table it knows nothing about. So an extension owns its tables but must never assume they exist: issue CREATE TABLE IF NOT EXISTS before every read and write, and store only what you can re-derive (Falco re-seeds its routes each run).

Dependency-change fanout

When a project's dependency fingerprint changes — a NuGet / PackageReference bump, or a ProjectReferenced project rebuilt against a changed dependency — every test in the projects that transitively reference it is selected, even though no source symbol changed (the ProjectFanout module). This catches behavior changes the symbol graph can't see.

Analyzer (opt-in)

Anonymous records ({| Year = d.Year |} and the matching {| Year: int |} type annotations) have no stable cross-build name, so TestPrune's AST impact analysis skips them. A test or symbol coupled to a change only through an anonymous record is therefore invisible to impact selection.

TestPrune.Analyzers is an opt-in FSharp.Analyzers.SDK analyzer that flags every anonymous-record occurrence (diagnostic TP001, TestPrune.AnonymousRecord, severity Warning) so precision-sensitive repos can steer that coupling to a tracked alternative — a named record, or an explicit [<TestPrune.DependsOnFile>] / [<TestPrune.DependsOnGlob>] edge. It's opt-in by construction: nothing changes unless you load the analyzer into your analyzer host (Ionide or fsharp-analyzers).

val checker: obj
val db: obj
val fileName: string
val source: string
namespace System
namespace System.IO
type File = static member AppendAllBytes: path: string * bytes: byte array -> unit + 1 overload static member AppendAllBytesAsync: path: string * bytes: byte array * ?cancellationToken: CancellationToken -> Task + 1 overload static member AppendAllLines: path: string * contents: IEnumerable<string> -> unit + 1 overload static member AppendAllLinesAsync: path: string * contents: IEnumerable<string> * encoding: Encoding * ?cancellationToken: CancellationToken -> Task + 1 overload static member AppendAllText: path: string * contents: ReadOnlySpan<char> -> unit + 3 overloads static member AppendAllTextAsync: path: string * contents: ReadOnlyMemory<char> * encoding: Encoding * ?cancellationToken: CancellationToken -> Task + 3 overloads static member AppendText: path: string -> StreamWriter static member Copy: sourceFileName: string * destFileName: string -> unit + 1 overload static member Create: path: string -> FileStream + 2 overloads static member CreateSymbolicLink: path: string * pathToTarget: string -> FileSystemInfo ...
<summary>Provides static methods for the creation, copying, deletion, moving, and opening of a single file, and aids in the creation of <see cref="T:System.IO.FileStream" /> objects.</summary>
System.IO.File.ReadAllText(path: string) : string
System.IO.File.ReadAllText(path: string, encoding: System.Text.Encoding) : string
val projectName: string
val projOptions: obj
Multiple items
type Async = static member AsBeginEnd: computation: ('Arg -> Async<'T>) -> ('Arg * AsyncCallback * objnull -> IAsyncResult) * (IAsyncResult -> 'T) * (IAsyncResult -> unit) static member AwaitEvent: event: IEvent<'Del,'T> * ?cancelAction: (unit -> unit) -> Async<'T> (requires delegate and 'Del :> Delegate) static member AwaitIAsyncResult: iar: IAsyncResult * ?millisecondsTimeout: int -> Async<bool> static member AwaitTask: task: Task<'T> -> Async<'T> + 1 overload static member AwaitWaitHandle: waitHandle: WaitHandle * ?millisecondsTimeout: int -> Async<bool> static member CancelDefaultToken: unit -> unit static member Catch: computation: Async<'T> -> Async<Choice<'T,exn>> static member Choice: computations: Async<'T option> seq -> Async<'T option> static member FromBeginEnd: beginAction: (AsyncCallback * objnull -> IAsyncResult) * endAction: (IAsyncResult -> 'T) * ?cancelAction: (unit -> unit) -> Async<'T> + 3 overloads static member FromContinuations: callback: (('T -> unit) * (exn -> unit) * (OperationCanceledException -> unit) -> unit) -> Async<'T> ...

--------------------
type Async<'T>
static member Async.RunSynchronously: computation: Async<'T> * ?timeout: int * ?cancellationToken: System.Threading.CancellationToken -> 'T
union case Result.Ok: ResultValue: 'T -> Result<'T,'TError>
val result: obj
val normalized: obj
union case Result.Error: ErrorValue: 'TError -> Result<'T,'TError>
val msg: string
val eprintfn: format: Printf.TextWriterFormat<'T> -> 'T
union case Option.Some: Value: 'T -> Option<'T>
val key: obj
val symbols: obj
val deps: obj
val tests: obj
val store: obj
val selection: obj
val _events: obj
val reason: obj
val allSymbols: obj
val allNames: obj
val entryPatterns: string list
val entryPoints: obj
val reachable: obj
val testMethodNames: obj
Multiple items
type ExampleExtension = interface obj new: unit -> ExampleExtension override AnalyzeEdges: symbolStore: 'a -> changedFiles: string list -> repoRoot: string -> 'b list override Name: string with get

--------------------
new: unit -> ExampleExtension
val symbolStore: 'a
val changedFiles: string list
Multiple items
val string: value: 'T -> string

--------------------
type string = System.String
type 'T list = List<'T>
val repoRoot: string
val dependents: obj
Multiple items
module List from Microsoft.FSharp.Collections

--------------------
type List<'T> = | op_Nil | op_ColonColon of Head: 'T * Tail: 'T list interface IReadOnlyList<'T> interface IReadOnlyCollection<'T> interface IEnumerable interface IEnumerable<'T> member GetReverseIndex: rank: int * offset: int -> int member GetSlice: startIndex: int option * endIndex: int option -> 'T list static member Cons: head: 'T * tail: 'T list -> 'T list member Head: 'T with get member IsEmpty: bool with get member Item: index: int -> 'T with get ...
val collect: mapping: ('T -> 'U list) -> list: 'T list -> 'U list
val changedFile: string
val candidates: obj

Type something to start searching.