.NET without a build: --mode source
Compiled mode reads the built assemblies and their portable PDBs, and it is the gate: the compiler has resolved every reference, so its edges are exact (ADR-0011). On a large solution a build takes minutes, and an agent will not build to check one import (design ยง Where it would be ignored). --mode source reads the .cs files instead, with tree-sitter-c-sharp, and resolves names the way the compiler looks them up, without compiling anything. It is the inner loop's answer, never the gate (Wave 3, Step 14; FR-EXT-DN-04).
rulebearing cruise --mode source -T err
or, in a native configuration:
languages:
dotnet:
mode: source # compiled (the default) | source
--mode wins over languages.dotnet.mode.
What is read
| Step | What happens |
|---|---|
| Projects | Discovered as compiled mode discovers them: the solution (languages.dotnet.solution, else the only .sln or .slnx), else every project file; excludeProjects applied. Nothing needs to have been built |
| Files | Every .cs file under a discovered project's folder, except build output (bin/, obj/, artifacts/) and generated code (*.g.cs, *.g.i.cs, *.Designer.cs). A file belongs to the project whose folder is nearest above it; a file whose nearest project is outside the solution or excluded is not read. With no project file anywhere, every .cs file is read |
| Parse | Each file with tree-sitter-c-sharp, in parallel: the types it declares, its using directives by the namespace declaration they are written in, and every name written where a type can stand |
| Resolution | Each name looked up as C# orders the lookup: the nested types of the enclosing types; then, from the innermost namespace out, the types each namespace declares, then the aliases and the namespaces its directives import; the compilation unit's directives and the project's global using directives last. Generic arity must match |
| Projection | One module per file that declares a type or holds top-level statements, with an edge per target file and dependency kind, as compiled mode projects them |
A partial type lands in one file, as a compiled build attributes it: the part that declares a constructor when exactly one does, else the file named after the type, else the first by path. What a build compiles into the type rather than into a method (a field's type, a signature, an attribute, a base type, an initializer) is attributed to that file, and a part with code has an edge to it; what is written in a method body stays with the file that writes it. A namespace a using directive imports that no file declares is an external module named by the namespace: package when a package reference of the project provides it, framework for System.* and Microsoft.*, else undetermined, since without a build nothing says whether it resolves.
What is marked
| Field | Value |
|---|---|
| every .NET dependency | approximate: true |
| every .NET file module | attribution: source |
summary.inspected.dotnet |
mode: source, assemblies: 0 |
the agent report |
opens with approximate, a sentence saying the findings are namespace-level and compiled mode is the gate; each finding on an approximate edge carries approximate: true |
The document's marks are additive, and the json reporter's --strict-schema removes them (ADR-0004), so a source-mode run asked for that output exits 2 with approximate-mode-not-strict unless --allow-approximate-gate is given: without its marks the result could gate later (cli.md); the agent report keeps its header and its per-finding marks, since an agent reading it must know. Source mode has no code layer, so element rules have nothing to evaluate there; compiled mode evaluates them.
What it cannot know
A name is resolved without a compiler, so what only a compiler decides is not known: overload resolution, members inherited from a base class (a call to one lands in the class that uses it, not the one that declares it), and the type behind var. An extension method call (services.AddClock()) is resolved as C# looks extension methods up, from the innermost namespace out through the static classes each level declares and imports, with C# 14 extension(T x) { ... } blocks included; without the receiver's type every candidate at the first level that has one counts. A constant or an enum member read is no edge, since the compiler writes its value where it is read. A local variable or a property that shares its name with a type in scope reads as that type. Every edge is therefore approximate, and how close it is to a compiled graph is measured rather than assumed:
| Graph | Source edges | Compiled edges | Agreeing | Precision | Recall |
|---|---|---|---|---|---|
crates/rb-extract-dotnet/tests/fixtures/sample |
5 | 5 | 5 | 100% | 100% |
crates/rb-extract-dotnet/tests/fixtures/toplevel |
2 | 2 | 2 | 100% | 100% |
| ardalis/RiverBooks | 97 | 119 | 97 | 100% | 82% |
| NeVeSpl/NetArchTest.eNhancedEdition | 512 | 609 | 488 | 95.3% | 80% |
| evolutionary-architecture/evolutionary-architecture-by-example | 129 | 140 | 128 | 99.2% | 91% |
| onebeyond/monaco | 263 | 342 | 258 | 98.1% | 75% |
| phongnguyend/Practical.CleanArchitecture | 888 | 1,066 | 884 | 99.6% | 83% |
| nager/Nager.Date | 1,051 | 1,264 | 1,051 | 100% | 83% |
| karaoke-dev/karaoke | 4,428 | 5,068 | 4,407 | 99.5% | 87% |
| DrJohnMelville/Pdf | 4,256 | 4,890 | 4,078 | 95.8% | 83% |
| TNG/ArchUnitNET | 2,364 | 3,550 | 2,329 | 98.5% | 66% |
| BenMorris/NetArchTest | 371 | 384 | 350 | 94.3% | 91% |
The ten oracles are every .NET oracle of testbeds/manifest.yaml, and each is above the 90% precision target. The comparison covers edges between two files both modes know, each oracle at its pinned SHA with the solution the oracle row names, the compiled side built in Release as the nightly builds it, with the row's msbuild arguments and the newest 10.0 SDK beside 8.0 and 9.0 (TNG/ArchUnitNET's global.json asks for 10.0.400, and DrJohnMelville/Pdf's [FromConstructor] source generator emits no constructors under the 10.0.100 SDK). cargo test -p rb-extract-dotnet --features source-mode --test source_mode asserts at least 90% precision and recall on the two built fixtures and writes target/source-mode-precision.json; the nightly runs testbeds/oracles/precision.py on every .NET oracle and joins the results into source-mode-precision.json (Wave 3, Step 14). Measuring it also found two edges compiled mode missed, now fixed: the body of an async or iterator method of a release build, and top-level statements.
The nightly measures it again on every .NET oracle row, against the compiled graph that row's own architecture tests run against: the assemblies the imported tests name, not the whole solution. testbeds/oracles/dotnet.sh writes each result and the summary job joins them into testbeds/results/source-mode-precision.json. TNG/ArchUnitNET and BenMorris/NetArchTest are the conformance suites of gate 2, not oracle rows there, so the nightly lists eight. Measured that way on 2026-10-08, each at its pinned SHA with the 10.0.401 SDK:
| Oracle | Source edges | Compiled edges | Agreeing | Precision | Recall |
|---|---|---|---|---|---|
| ardalis/RiverBooks | 47 | 59 | 47 | 100% | 80% |
| NeVeSpl/NetArchTest.eNhancedEdition | 99 | 96 | 96 | 97% | 100% |
| evolutionary-architecture/evolutionary-architecture-by-example | 115 | 125 | 113 | 98.3% | 90% |
| onebeyond/monaco | 152 | 211 | 152 | 100% | 72% |
| phongnguyend/Practical.CleanArchitecture | 386 | 491 | 384 | 99.5% | 78% |
| nager/Nager.Date | 1,048 | 1,260 | 1,047 | 99.9% | 83% |
| karaoke-dev/karaoke | 291 | 351 | 291 | 100% | 83% |
| DrJohnMelville/Pdf | 2,202 | 2,326 | 2,064 | 93.7% | 89% |
DrJohnMelville/Pdf was at 70.75% on every night from 2026-10-03 to 2026-10-08. Its [StaticSingleton] source generator writes a partial type's constructor under obj/, and compiled mode credited the type's base class and fields to that generated file, which source mode never reads. Compiled mode now attributes a type to a file its developer wrote whenever the PDB names one (ADR-0061); the other seven oracles have no generated partial types and did not move.
Never the gate
A run read in source mode never passes or fails a gate. cruise with a reporter that gates, fmt --exit-code on a saved source-mode result and diff --exit-code with a source-mode side print warning: approximate-mode-not-a-gate: ... and exit 2, whether the count was zero or not; attest refuses to sign such a graph at all. cruise --from-hook answers as it always does, because the Stop hook is the inner loop source mode exists for. --allow-approximate-gate gives the count back for a local script (cli.md; FR-EXT-DN-04).
The cache
Under --cache, a changed .cs file is the only one parsed again; every other file's parse is taken from the entry, and the resolution, which depends on every file, is run over all of them, so an incremental run equals a full one. A .cs file added or deleted, or a project file changed, reads everything again. A build since the entry was written changes nothing, since no assembly is read (cli.md).