Logo TestPrune

ADR 0001 — TestPrune.Falco resolves Falco.UnionRoutes symbolic navigation via source (AST) composition, not runtime reflection

Status: Accepted (2026-08-04)

Context

TestPrune.Falco selects integration tests for a changed HTTP handler by matching the changed route's URL against the raw text of each test file. That is purely textual, so a test that navigates via a typed route value rather than a URL literal —

let! resp = client.GetAsync(Route.link (Route.Admin(NoPreCondition, AdminPages.Settings)))

— carries no /admin/settings substring in its own span and was silently dropped (an under-selection soundness hole: a real change to that route would not select its covering test).

Closing it needs a case→URL map: given the route DU, resolve AdminPages.Settings to /admin/settings and feed it to the same matcher. The question this ADR settles is how to obtain that map.

Route.link/Route.info navigation is real in a large private downstream repository (e.g. its SystemHealth.fs), so this is a live gap, not a hypothetical one.

Options

  1. AST source-derivation (dependency-free). Parse the route DU's [<Route(Path=…)>] attributes and nesting from source (FCS untyped tree) and re-derive the URL by the same rules Falco.UnionRoutes uses (explicit path, empty-segment convention names, kebab fallback, nested concatenation, field-inferred params). Match constraint-insensitively ({id:guid} ~ {id}) because the source cannot infer a constraint from a wrapped id type. No dependency on Falco.UnionRoutes; no built assembly required.

  2. Runtime reflection. Add Falco.UnionRoutes as a dependency and call its canonical Route.enumerate/Route.info over the host's route type, loaded from the host's built route assembly at analysis time. Canonical by construction, but adds Falco.UnionRoutes — and transitively Falco + ASP.NET Core — as a hard dependency of every TestPrune.Falco consumer (most of whom write plain string routes), and requires loading the host's compiled assembly (AssemblyLoadContext + .deps.json resolution + load-failure risk).

Evidence

Both options were built and measured head-to-head, in a synthetic corpus and against the real route union of that downstream repository (241 distinct canonical URL patterns):

Resolver

Real-route exact

Over-selection

Runtime dep

Built assembly

AST, exact matching

149 / 241 (62%)

none

none

no

AST, constraint-insensitive

241 / 241

0 collisions

none

no

Reflection (Route.info)

241 / 241

0

Falco.UnionRoutes (+Falco+ASP.NET)

yes

Decision

Adopt Option 1: AST source-derivation with constraint-insensitive matching, in a dependency-free core (UnionRouteLinks), plus a drift-alarm contract test (SyntheticRouteContractTests) that asserts the AST composition equals Falco.UnionRoutes' canonical Route.enumerate over the synthetic corpus (modulo constraints), reflecting the synthetic type in-process — no host-assembly loading.

This keeps TestPrune.Falco string-route-pure: any consumer adopts it without pulling a routing library, and the string-route path (StringRouteConstants, named-URL-constant resolution) and the core matcher stay UnionRoutes-agnostic. Falco.UnionRoutes is a test-only dependency of TestPrune.Tests (the drift alarm); it does not appear in the shipped TestPrune.Falco package.

Consequences

Rejected / deferred

Runtime reflection resolver — measured equivalent (241/241) but broad cost (couples every consumer to a minority routing library + needs the host's built assembly) with no concrete gain today. It was fully built during the evaluation (host-assembly AssemblyLoadContext loader, fromType/fromAssembly, the head-to-head harness) and then abandoned.

val resp: obj

Type something to start searching.