ADR-013: .NET callgraph extractor — dual-mode (M1 restore-aware + M2 lightweight) at scan-rootfs

Status: Accepted Date: 2026-05-30 Sprint: SPRINT_20260530_075_Scanner_dotnet_extractor_at_scan_rootfs_plus_reach_gate_orchestration.md (S075-001) Related ADRs: ADR-006 (execution plugin framework — scanner-worker host substrate), ADR-009 (component-per-env registry override — image-size budget cohabitant) Related sprints:

Related code:

In one line: the Stella Ops scanner builds .NET call graphs in two modes — M1, a high-fidelity Roslyn/MSBuild path that needs a restored project and the .NET SDK, and M2, a dependency-free lexical parser that works against any scanned image — selected automatically so .NET reachability works everywhere without bloating the default scanner-worker image.

Audience: Scanner / call-graph engineers and operators choosing between scan fidelity and image footprint. Assumes familiarity with the reachability pipeline and the tier-A/tier-B extractor model.

Note: this is one of two ADRs numbered 013 (the other is ADR-013 Findings Runtime Disabled Mode); the collision is a known artefact of two streams landing the same day. See the flag in the docs-reorg tracking.

Context

Sprint 074 wired the scan-rootfs callgraph extraction path: DetectLanguageCandidates recurses subdirectories, ReachabilityBuildStageExecutor no longer clobbers a non-trivial union graph, and SynthesizeDependencyReachabilityReport produces a real DependencyReachabilityReport when no SBOM is present. JS/TS sample worked clean against scan-rootfs (AST-based, no restored deps needed) — live evidence rel-s074-jsts-v5 produced nodes=2 edges=1 paths=1, reach-graph publish created=True, release-evidence row landed in postgres with content hash sha256:48466d14c515365795261bf8a6752efd1559fab1b10efb890580ee9b652ed27f.

The .NET sample (rel-s074-dotnet-v4) reached terminal Succeeded but produced an empty callgraph at scan-rootfs:

Published reachgraph for scan 7a14d68fe... (tenant e2e-lab):
  digest=blake3:bf068b3a... created=True nodes=0 edges=0

The root cause is structural: DotNetCallGraphExtractor (Sprint 018) builds a Roslyn workspace via MSBuildWorkspace.Create() (DotNetCallGraphExtractor.cs:40) and opens the project/solution via workspace.OpenSolutionAsync(...) / workspace.OpenProjectAsync(...) (DotNetCallGraphExtractor.cs:43-45). MSBuildWorkspace requires:

  1. A restored NuGet package graph (obj/project.assets.json + the package cache under ~/.nuget/packages or NUGET_PACKAGES) — without it, OpenProjectAsync produces a workspace where the semantic model has no type resolution and model.GetSymbolInfo(invocation, ct).Symbol as IMethodSymbol (DotNetCallGraphExtractor.cs:106) returns null for nearly every invocation, so the loop produces 0 edges.
  2. MSBuild SDK lookups (the MSBuildLocator.RegisterDefaults() call at DotNetCallGraphExtractor.cs:331 finds an SDK on the host, but the project’s <Project Sdk="Microsoft.NET.Sdk"> then resolves SDK targets relative to that SDK’s tree — needs dotnet SDK installed at extraction time).

In the Sprint 074 .NET smoke, the scan target was an extracted alpine rootfs at /tmp/stellaops/scan-pipeline/<scan>/rootfs containing /src/Sample.sln, /src/Sample.Api/Sample.Api.csproj, /src/Sample.Api/Program.cs. There was no obj/, no NuGet cache, and no dotnet SDK in the scanner-worker container image. OpenProjectAsync logged warnings to workspace.WorkspaceFailed (which the extractor swallows at DotNetCallGraphExtractor.cs:41) and produced a workspace with zero usable semantic models; the extractor exited with no nodes and no edges. The input-stage log line prepare-reachability-inputs: dotnet call graph extraction failed for /tmp/stellaops/scan-pipeline/7a14d68fe.../rootfs; trying next candidate. captures the same failure mode from the worker’s perspective.

This is the last structural gap before Sprint 019 RCH-019-007 fully closes. Two viable paths exist:

Pattern A (“pick one”) was considered and rejected. The two modes are not substitutes — they cover different operator contexts:

Operator decision 2026-05-30: implement BOTH M1 + M2. Selection at runtime is driven by an extractor-mode heuristic (default M2 for scan-rootfs; M1 when scan context provides a restored project root, e.g., from a prior dotnet restore step in scanner-worker or an upstream prep stage).

Decision

Adopt a dual-mode .NET callgraph extractor for scan-rootfs. M2 ships first as the default (no image bloat, no infrastructure dependency, behaviour-preserving for the Sprint 074 .NET sample → produces a non-empty callgraph where the current path produces zero). M1 is a sibling mode that activates when the analysis context provides a restored-project-root signal; it preserves the Sprint 018 Roslyn-grade accuracy for the operator contexts that pay the image + restore cost.

Single dotnet extractor; mode is internal

The ICallGraphExtractor registry keeps a single dotnet extractor entry (mode selection MUST NOT leak into the Language key — that key is consumed by the worker’s language-detection routing and by SBOM-driven extractor dispatch in Sprint 020). The selector is internal to the .NET extractor:

public sealed class DotNetCallGraphExtractor : ICallGraphExtractor
{
    public string Language => "dotnet";

    public async Task<CallGraphSnapshot> ExtractAsync(
        CallGraphExtractionRequest request,
        CancellationToken ct)
    {
        var mode = DotNetExtractorModeSelector.Select(request);
        return mode switch
        {
            DotNetExtractorMode.M1RestoreAware => await _m1.ExtractAsync(request, ct),
            DotNetExtractorMode.M2Lightweight   => await _m2.ExtractAsync(request, ct),
            _ => throw new InvalidOperationException($"Unknown mode {mode}.")
        };
    }
}

The current DotNetCallGraphExtractor.cs implementation (Sprint 018 — MSBuildWorkspace + Roslyn semantic model + framework-registration enrichment for Minimal API / AddHostedService<T>() / RouteGroupBuilder) becomes the body of M1, moved to a sibling class (e.g., DotNetRestoreAwareCallGraphExtractor) with no behavioural change. M2 is a new sibling (e.g., DotNetLightweightCallGraphExtractor) that follows the structural shape of PythonCallGraphExtractor / JavaScriptCallGraphExtractor.

Mode selector — heuristic

DotNetExtractorMode.M1RestoreAware   ⇐  request.AnalysisContext contains "restored-project-root"
                                        AND that root has an obj/project.assets.json
                                        AND MSBuildLocator.RegisterDefaults() finds an SDK
DotNetExtractorMode.M2Lightweight    ⇐  default (scan-rootfs, no restored deps detected)

The selector is heuristic (no operator config) because the request side already carries the deciding signal — the upstream worker stage either does or does not materialise a restored project root for the .NET extractor. Operators who want to force M1 against an arbitrary image (knowing they pay the size + restore cost) configure the upstream worker stage to run dotnet restore first; they do not need a per-extractor flag.

A future operator-override config key (Scanner:Worker:Reachability:DotNet:ForceMode = "M1RestoreAware" | "M2Lightweight") is reserved but not in scope for this ADR — adding it later does not break the heuristic-default contract.

M1 — restore-aware (preserves Sprint 018 accuracy)

Behaviour-preserving. Existing DotNetCallGraphExtractor.cs logic moves verbatim into DotNetRestoreAwareCallGraphExtractor. No changes to:

Operator-visible requirements when M1 is selected:

  1. dotnet SDK present on the host (scanner-worker image or sidecar). The selector verifies via MSBuildLocator.RegisterDefaults(); if no SDK is found, the selector falls back to M2 with a warning log (dotnet extractor: M1 requested but no SDK on host; falling back to M2 with reduced accuracy).
  2. Restored project root (the upstream worker stage either runs dotnet restore against the discovered .sln/.csproj or surfaces an operator-provided pre-restored root in the analysis context).
  3. Wall-clock budget: restore + Roslyn workspace open adds 30s-5min depending on package-graph size. The worker stage’s per-stage timeout (ReachabilityInputStageExecutor already has a CancellationToken) is the bound.

M2 — lightweight (works at any scan-rootfs)

New sibling extractor DotNetLightweightCallGraphExtractor. Structural shape mirrors PythonCallGraphExtractor (file walk + per-file extraction + framework-registration second pass + sink matcher + entrypoint classifier):

  1. Discover .cs source files under the request’s TargetPath. Recurse subdirectories; skip bin/, obj/, node_modules/, .git/ (same exclusion list as PythonCallGraphExtractor.GetPythonFiles).
  2. Per-file lexical extraction using Roslyn’s standalone parser (Microsoft.CodeAnalysis.CSharp.SyntaxFactory.ParseSyntaxTree / CSharpSyntaxTree.ParseText) — this needs no workspace, no semantic model, no restored deps. It is the same Microsoft.CodeAnalysis.CSharp package M1 already depends on (zero new package dependency; same MIT license; same image-size impact).
  3. Method-declaration enumeration (root.DescendantNodes().OfType<MethodDeclarationSyntax>()) — identical to M1’s loop but operating on the parse tree directly.
  4. Invocation lexical resolution — for each InvocationExpressionSyntax, resolve the call target by textual analysis (member-access Foo.Bar()Foo.Bar; identifier Bar()Bar in the enclosing namespace/type). This is the accuracy gap: without a semantic model, we cannot disambiguate Foo.Bar to a specific assembly, cannot follow generic dispatch, cannot resolve var x = factory.Create(); x.Method() to the concrete type. M2 captures the call-edge with CallEdgeExplanation.LexicalGuess(confidence=0.5) (new explanation type) so downstream consumers (reach-gate, classifier) can weight or filter lexical-only edges.
  5. Framework-registration patterns captured by syntactic matching against the parse tree (no semantic resolution needed for the pattern, only for the symbol; the symbol is captured as a string):
    • Minimal API: app.MapGet("/", Handler) / MapPost / MapPut / MapDelete / MapPatch / MapMethods / MapFallback — handler identifier captured as a string.
    • services.AddHostedService<T>() — type argument captured as a string.
    • [HttpGet] / [HttpPost] / [Route] attributes on method declarations — captured syntactically.
    • Main(string[] args) static method — captured syntactically.
    • BackgroundService / IHostedService base/interface — captured syntactically by base-list inspection.
  6. Sink matching reuses the existing SinkRegistry.MatchSink("dotnet", symbol) with the lexically resolved symbol string. The sink registry already operates on string symbols (DotNetCallGraphExtractor.cs:487, 509) — no change required.
  7. Deterministic node ID + digest — reuses CallGraphNodeIds.Compute + CallGraphDigests.ComputeGraphDigest (lines 482, 217). Determinism property preserved.

Accuracy gap (documented, surfaced on the edge explanation):

CapabilityM1 (Roslyn semantic)M2 (lexical/AST)
Direct call Foo.Bar() where Foo is in scope✅ exact symbol✅ same lexical match (high confidence)
Virtual dispatch via interface✅ resolves to declared method on interface⚠️ captured as lexical method name; cannot resolve to implementations
Generic dispatch factory.Create<T>().Method()✅ resolves to T.Method❌ not captured (lexical analysis cannot walk the fluent chain)
Source-generator output✅ included in the workspace❌ not present in the source tree
Reflection (Type.GetMethod(...).Invoke(...))⚠️ classified as ReflectionCall(0.5) by M1⚠️ same classification (the call to Invoke is captured lexically)
Dynamic assembly load⚠️ classified as DynamicLoad(0.6) by M1⚠️ same classification
Minimal API handler registration with method group✅ resolves handler symbol✅ captures handler identifier as string (sufficient for entrypoint promotion)
AddHostedService<T>()✅ resolves type symbol✅ captures type identifier as string (sufficient for StartAsync/ExecuteAsync promotion)
Cross-assembly call edges✅ resolves to declaring assembly❌ assembly attribution unknown (uses "unknown" placeholder, matching the M1 fallback at DotNetCallGraphExtractor.cs:494)
Determinism of graph digest✅ stable across runs✅ stable across runs (same CallGraphDigests machinery)

The accuracy gap is honest — downstream reach-gate evaluation already weights edges by CallEdgeExplanation.Confidence. M2 edges land with confidence ≤ 0.5 for the lexical-guess cases; the reach-gate’s existing MaxAllowedPaths + confidence threshold logic naturally discounts low-confidence paths, so an M2-only deployment biases toward fewer false-positive reachable paths (under-approximation) rather than fewer false-negative paths (over-approximation). Operators who need the higher fidelity for a specific scan opt in to M1 by configuring the upstream restore step.

Image impact

M1 (when selected) requires the dotnet SDK in the scanner-worker container image. Empirical sizes:

ComponentCompressed image-size deltaUncompressed
mcr.microsoft.com/dotnet/sdk:8.0-alpine (minimal SDK only)~180 MB~700 MB
mcr.microsoft.com/dotnet/sdk:8.0 (Debian-based SDK)~230 MB~870 MB
Roslyn-only (no SDK, parser-only) — what M2 needs~5 MB (already in scanner-worker via Microsoft.CodeAnalysis.CSharp)~30 MB

Decision: the default scanner-worker image does NOT include the .NET SDK. M1 is opt-in either via (a) an operator-built scanner-worker variant image with the SDK installed, or (b) a sidecar pattern where the worker invokes dotnet restore against a sidecar with the SDK (out of scope for this ADR; tracked as a Sprint 076+ follow-up). Default image impact for M2 is zeroMicrosoft.CodeAnalysis.CSharp is already a transitive dependency via the existing DotNetCallGraphExtractor package reference, so the parse-only path adds no new dependency.

License impact

Per the dependency-license gate (CLAUDE.md §2.6 + docs/legal/THIRD-PARTY-DEPENDENCIES.md + NOTICE.md):

No NOTICE.md or third-party-licenses/ change is required for the M2 default ship. The M1 + SDK variant image is a downstream operator-build concern.

Test scaffolding

Sprint 018 tests for DotNetCallGraphExtractor cover the MSBuildWorkspace happy paths. The dual-mode design needs:

  1. M1 regression — existing Sprint 018 xunit tests run unchanged against the moved DotNetRestoreAwareCallGraphExtractor. Selector test ensures M1 is picked when the analysis context provides restored-project-root.
  2. M2 happy path — new xunit fixtures under __Tests/StellaOps.Scanner.CallGraph.Tests/DotNet/Lightweight/ containing minimal .cs source fixtures (no restored deps): a Minimal API handler, an AddHostedService<T>() registration, a BackgroundService-derived class, a CLI Main, a controller with [HttpGet]. Assert the extractor produces non-empty nodes/edges/entrypoints with CallEdgeExplanation reflecting lexical-guess confidence on the appropriate edges.
  3. M2 accuracy-gap honesty test — fixture exercises a generic-dispatch case + a fluent factory chain; assert M2 either omits the edge OR captures it at confidence ≤ 0.5. Test name and assertions document the gap so future contributors cannot silently raise confidence without re-evaluating the heuristic.
  4. Mode-selector fallback test — selector configured for M1 but no SDK on host → asserts fall-back to M2 + warning log captured.
  5. End-to-end parity check against the Sprint 074 dotnet-reachable:1.0 sample shape — fixture mirrors /src/Sample.sln + /src/Sample.Api/Sample.Api.csproj + /src/Sample.Api/Program.cs (CommandsController → Process.Start sink) and asserts M2 produces ≥ 1 entrypoint, ≥ 1 edge, ≥ 1 sink-reaching path. This is the test that proves the Sprint 075 S075-002 implementation closes the Sprint 074 stage-3 gap.

Per feedback_dotnet_test_filter_mtp: tests are executed by the in-proc xunit v3 runner with the -class filter against the built test .exe, NOT dotnet test --filter (which the MTP runner silently ignores). Sprint 075 S075-002 captures the exact runner command + result counts in evidence.

Consequences

Positive

Tradeoffs

Alternatives Considered

Pick only M1 (project-restore pre-step inside worker; SDK in scanner-worker image)

Rejected as the sole approach. Pros: preserves full Roslyn-grade accuracy. Cons: adds ~180-230 MB compressed to the default scanner-worker image; adds a dotnet restore step to every .NET scan (30s-5min wall-clock + network egress for NuGet — violates the offline/air-gap-first posture unless the operator pre-mirrors the package feed); breaks operators who scan arbitrary OCI images they did not author (the typical case for a CI scan). Acceptable for an opt-in variant image; inappropriate for the default.

Pick only M2 (lightweight regex/AST extractor; no SDK)

Rejected as the sole approach. Pros: zero image bloat, zero infrastructure dependency, works everywhere. Cons: permanently caps .NET reachability fidelity below the Sprint 018 tier-A bar; operators who want the higher fidelity (pre-prod gate, hand-built source image) have no path to it. Acceptable as the default; inappropriate as the only option.

Mode selection via operator config flag (instead of analysis-context heuristic)

Rejected for the initial ship. Pros: explicit operator control. Cons: every operator has to reason about which mode they want per scan; the worker stage already carries the deciding signal (restored-project-root present or not); a config flag duplicates a decision that the upstream stage is in a better position to make. The flag is reserved for a Sprint 076+ follow-up — adding it later does not break the heuristic-default contract.

Use a separate plugin (per ADR-006 execution plugin framework) for each mode

Rejected. Pros: would isolate the SDK dependency to the M1 plugin only. Cons: callgraph extractors live in the worker process today (ICallGraphExtractor is an in-proc DI registration, not a plugin); promoting them to ADR-006 plugins is a larger refactor than this ADR justifies. If a future operator wants to ship the M1 mode as a sandboxed container-host execution plugin (per ADR-006), the design admits it cleanly — the IDotNetExtractorMode seam this ADR introduces is the contract a plugin would implement.

Defer .NET until an out-of-process dotnet-callgraph sidecar exists

Rejected. Pros: clean separation of language toolchains from the worker process. Cons: blocks Sprint 019 RCH-019-007 closure on a much larger sidecar protocol design; defers .NET reachability indefinitely; existing JS/TS/Python/Go extractors all live in-proc — adding .NET there is the consistent choice for now.

References