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:
- Sprint 018 (
.NETtier-A callgraph extractor — the MSBuildWorkspace approach this ADR is wrapping in a mode selector). - Sprint 020 (tier-B language extractors — Python/Go/Java/JS/TS — the AST-pattern reference M2 inherits its shape from).
- Sprint 074 (commit
2083653163— scan-rootfs extraction wiring; JS/TS proven liverel-s074-jsts-v5, content hashsha256:48466d14c5...; .NETrel-s074-dotnet-v4stages 1-2 PASS, stage 3 SKIPPED-UPSTREAM via the extractor failure documented here). - Sprint 019 RCH-019-007 (the .NET gap closes the last stage of the Sprint 019 reach-gate chain).
Related code:
src/Scanner/__Libraries/StellaOps.Scanner.CallGraph/Extraction/DotNet/DotNetCallGraphExtractor.cs— the current MSBuildWorkspace-based tier-A extractor (Sprint 018). Mode-selector wraps this verbatim under M1.src/Scanner/__Libraries/StellaOps.Scanner.CallGraph/Extraction/JavaScript/JavaScriptCallGraphExtractor.cs— the AST-pattern reference M2 inherits its shape from (proven against scan-rootfs in Sprint 074).src/Scanner/__Libraries/StellaOps.Scanner.CallGraph/Extraction/Python/PythonCallGraphExtractor.cs— the closest “no-build-system-restore-needed” sibling (file walk + regex/AST framework registrations + entrypoint classifier).src/Scanner/__Libraries/StellaOps.Scanner.CallGraph/Extraction/CallGraphExtractorRegistry.cs— the per-language registry that selects an extractor byLanguage. The mode-selector lives one level below this (per-dotnetextractor that branches on context).src/Scanner/StellaOps.Scanner.Worker/Processing/Reachability/ReachabilityInputStageExecutor.cs— the worker stage that resolves an extractor per language candidate. The selector signal (restored-project-rootvs scan-rootfs) is sourced from this stage’s analysis-context.docs/implplan/_evidence/2026-05-29-sprint-074-reachgraph-phase3-stages-3-5-smoke.json— the live evidence behind the structural-gap finding (stage_2_reachgraph_upsert: PASS-EMPTY+stage_3_release_evidence_publish: SKIPPED-UPSTREAM-EXTRACTOR-GAPon the .NET sample).
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:
- A restored NuGet package graph (
obj/project.assets.json+ the package cache under~/.nuget/packagesorNUGET_PACKAGES) — without it,OpenProjectAsyncproduces a workspace where the semantic model has no type resolution andmodel.GetSymbolInfo(invocation, ct).Symbol as IMethodSymbol(DotNetCallGraphExtractor.cs:106) returnsnullfor nearly every invocation, so the loop produces 0 edges. - MSBuild SDK lookups (the
MSBuildLocator.RegisterDefaults()call atDotNetCallGraphExtractor.cs:331finds an SDK on the host, but the project’s<Project Sdk="Microsoft.NET.Sdk">then resolves SDK targets relative to that SDK’s tree — needsdotnetSDK 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:
- M1 — project-restore pre-step inside scanner-worker before invoking
DotNetCallGraphExtractor. Either (a) bake thedotnetSDK into the scanner-worker image and rundotnet restoreagainst the discovered.sln/.csprojbefore extraction, or (b) require the upstream scan-prep stage to provision arestored-project-rootanalysis-context key pointing at a pre-restored project tree (operator-owned out-of-band restore). - M2 — lightweight regex/AST-based .NET extractor that walks the discovered
.csfiles directly, identifies methods, invocations, attributes, and framework-registration patterns without any MSBuild/Roslyn-workspace dependency. Mirrors the structural shape ofPythonCallGraphExtractorandJavaScriptCallGraphExtractor(both of which proved out at scan-rootfs in Sprint 020 + Sprint 074). Lower accuracy (no semantic-model resolution → cannot disambiguate generics-driven dispatch, cannot follow source-generator output, cannot resolve cross-assembly virtual dispatch) but works on any scan-rootfs with zero infrastructure dependencies.
Pattern A (“pick one”) was considered and rejected. The two modes are not substitutes — they cover different operator contexts:
- A future scanner-worker shard configured for “high-fidelity scan” (e.g., a pre-prod gate where operator chose to ship a fatter image + restore-time budget for accurate reachability) wants M1.
- The default scanner-worker shard scanning arbitrary OCI images (where the operator did not pre-restore and cannot install the SDK at scan time) wants M2.
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:
EnsureMsBuildRegistered()/MSBuildLocator.RegisterDefaults()(line 331).MSBuildWorkspace.Create()/OpenSolutionAsync/OpenProjectAsync(lines 40-45).- The two-pass framework-registration enrichment for Minimal API /
AddHostedService<T>()/RouteGroupBuilder(lines 49-176). - Reflection/dynamic-loading / platform-guard classification (lines 433-459).
- Entrypoint classifier (HTTP attributes,
Main, IHostedService/BackgroundService, gRPCBindableService).
Operator-visible requirements when M1 is selected:
dotnetSDK present on the host (scanner-worker image or sidecar). The selector verifies viaMSBuildLocator.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).- Restored project root (the upstream worker stage either runs
dotnet restoreagainst the discovered.sln/.csprojor surfaces an operator-provided pre-restored root in the analysis context). - Wall-clock budget: restore + Roslyn workspace open adds 30s-5min depending on package-graph size. The worker stage’s per-stage timeout (
ReachabilityInputStageExecutoralready has aCancellationToken) 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):
- Discover
.cssource files under the request’sTargetPath. Recurse subdirectories; skipbin/,obj/,node_modules/,.git/(same exclusion list asPythonCallGraphExtractor.GetPythonFiles). - 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 sameMicrosoft.CodeAnalysis.CSharppackage M1 already depends on (zero new package dependency; same MIT license; same image-size impact). - Method-declaration enumeration (
root.DescendantNodes().OfType<MethodDeclarationSyntax>()) — identical to M1’s loop but operating on the parse tree directly. - Invocation lexical resolution — for each
InvocationExpressionSyntax, resolve the call target by textual analysis (member-accessFoo.Bar()→Foo.Bar; identifierBar()→Barin the enclosing namespace/type). This is the accuracy gap: without a semantic model, we cannot disambiguateFoo.Barto a specific assembly, cannot follow generic dispatch, cannot resolvevar x = factory.Create(); x.Method()to the concrete type. M2 captures the call-edge withCallEdgeExplanation.LexicalGuess(confidence=0.5)(new explanation type) so downstream consumers (reach-gate, classifier) can weight or filter lexical-only edges. - 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/IHostedServicebase/interface — captured syntactically by base-list inspection.
- Minimal API:
- 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. - Deterministic node ID + digest — reuses
CallGraphNodeIds.Compute+CallGraphDigests.ComputeGraphDigest(lines 482, 217). Determinism property preserved.
Accuracy gap (documented, surfaced on the edge explanation):
| Capability | M1 (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:
| Component | Compressed image-size delta | Uncompressed |
|---|---|---|
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 zero — Microsoft.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):
Microsoft.CodeAnalysis.CSharp(Roslyn) — MIT (already in the BUSL-1.1-compatible allowlist via the Sprint 018 dependency entry). M2 uses the parse-only API surface of the same package; no new dependency added; no license-gate action required.Microsoft.Build.Locator/Microsoft.CodeAnalysis.Workspaces.MSBuild— MIT (already in the allowlist via the Sprint 018 entry). M1 reuses these verbatim; no change..NET SDK(when present in an operator-built M1-capable scanner-worker variant) — MIT (the SDK itself); transitive includes vary (the SDK ships with .NET Standard Library facade assemblies, NuGet client, MSBuild, dotnet-format, etc., all under MIT or Apache-2.0). When an operator builds an M1-capable variant image, the operator-side dependency gate (per the project’s BUSL-1.1 distribution posture) is the operator’s responsibility — the upstream scanner-worker image we ship does not include the SDK and therefore does not assert a license for it. Add a note todocs/legal/THIRD-PARTY-DEPENDENCIES.md(Sprint 076 follow-up if operator-shipped M1 image lands) documenting the SDK license posture for the variant image build.
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:
- M1 regression — existing Sprint 018 xunit tests run unchanged against the moved
DotNetRestoreAwareCallGraphExtractor. Selector test ensures M1 is picked when the analysis context providesrestored-project-root. - M2 happy path — new xunit fixtures under
__Tests/StellaOps.Scanner.CallGraph.Tests/DotNet/Lightweight/containing minimal.cssource fixtures (no restored deps): a Minimal API handler, anAddHostedService<T>()registration, aBackgroundService-derived class, a CLIMain, a controller with[HttpGet]. Assert the extractor produces non-empty nodes/edges/entrypoints withCallEdgeExplanationreflecting lexical-guess confidence on the appropriate edges. - 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. - Mode-selector fallback test — selector configured for M1 but no SDK on host → asserts fall-back to M2 + warning log captured.
- End-to-end parity check against the Sprint 074
dotnet-reachable:1.0sample shape — fixture mirrors/src/Sample.sln+/src/Sample.Api/Sample.Api.csproj+/src/Sample.Api/Program.cs(CommandsController →Process.Startsink) 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
- The Sprint 074 .NET sample stops producing an empty callgraph at scan-rootfs. M2 produces a non-empty, deterministic callgraph against an arbitrary OCI image with no infrastructure dependency. Sprint 019 RCH-019-007 can fully close.
- Operator choice between fidelity and footprint. High-fidelity scans (operator-built M1 variant image + restore step) preserve Roslyn-grade accuracy; default scans (M2) work everywhere with zero new dependency.
- Zero default image-size impact. M2 reuses the already-present
Microsoft.CodeAnalysis.CSharpparser. The default scanner-worker image grows by 0 bytes. - Determinism property preserved. Same
CallGraphNodeIds/CallGraphDigestsmachinery; both modes produce stable digests across runs. - Honest accuracy reporting. M2 edges carry
confidence ≤ 0.5for lexical-guess cases; reach-gate confidence threshold naturally biases toward under-approximation, which is the right error direction for a deny-style gate. - Forward-compatible. Future Sprint 076+ can land a
dotnet-SDK-bearing scanner-worker variant image + the upstreamdotnet restoreworker stage to make M1 available out-of-the-box for operators that want higher fidelity, without re-deriving the dual-mode design.
Tradeoffs
- M2 under-approximates. Generic dispatch, source-generator output, fluent factory chains, and cross-assembly virtual dispatch are not captured by M2. Documented as an accuracy gap; surfaced on the edge explanation (
confidence). Mitigation: operators who need the higher fidelity build an M1 variant image. - Mode-selector heuristic is implicit. No operator config to force a mode (yet). If an operator’s restored-project-root signal is missing for a reason that’s not “no restore performed” (e.g., a worker-stage bug), M2 silently runs instead of failing loudly. Mitigation: the selector logs the chosen mode + the reason at INFO; a Sprint 076+ follow-up can add a
ForceModeconfig key. - M1 fall-back-to-M2 silently degrades accuracy. When M1 is requested but no SDK is found, the selector falls back to M2 with a warning. An operator that requires M1 accuracy could miss the warning and consume an M2 graph thinking it’s M1. Mitigation: the warning is structured (
mode_fallback: { requested: "M1", actual: "M2", reason: "no_sdk_on_host" }); the publisher attaches the mode + fallback reason to the reach-graph metadata so the consuming reach-gate can detect a mode mismatch against operator expectation. - Two extractors to maintain. Sink-matcher, entrypoint classifier, and framework-registration patterns are shared (or at minimum mirrored) between M1 and M2. Risk: drift over time (e.g., a new Minimal API method shape added to M1 but not M2). Mitigation: extract the framework-registration pattern table into a shared
DotNetFrameworkPatternshelper consumed by both modes; xunit parity test asserts both modes capture the same handler set for a shared fixture.
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
docs/implplan/SPRINT_20260530_075_Scanner_dotnet_extractor_at_scan_rootfs_plus_reach_gate_orchestration.md— this ADR’s source sprint; S075-002 implements the dual-mode extractor.docs/implplan/_evidence/2026-05-29-sprint-074-reachgraph-phase3-stages-3-5-smoke.json— live evidence behind the structural-gap finding (commit2083653163).docs-archive/implplan/— Sprint 018 (tier-A .NET extractor) + Sprint 020 (tier-B extractor pattern reference) dossiers when archived.- ADR-006 — execution plugin framework (forward-compat — a future per-mode plugin reuses the
IDotNetExtractorModeseam). - ADR-009 — component-per-env registry override (image-size budget cohabitant — the same scanner-worker image both ADRs land in).
src/Scanner/__Libraries/StellaOps.Scanner.CallGraph/Extraction/DotNet/DotNetCallGraphExtractor.cs— Sprint 018 implementation; becomes M1 body.src/Scanner/__Libraries/StellaOps.Scanner.CallGraph/Extraction/JavaScript/JavaScriptCallGraphExtractor.cs— M2 structural reference (file walk + AST patterns + framework registrations, no workspace).src/Scanner/__Libraries/StellaOps.Scanner.CallGraph/Extraction/Python/PythonCallGraphExtractor.cs— closest “no-build-system-restore-needed” sibling (regex/AST framework registrations + entrypoint classifier).src/Scanner/__Libraries/StellaOps.Scanner.CallGraph/Extraction/CallGraphExtractorRegistry.cs— per-language registry (selector lives below this; theLanguagekey staysdotnetregardless of mode).src/Scanner/StellaOps.Scanner.Worker/Processing/Reachability/ReachabilityInputStageExecutor.cs— worker stage; surfacesrestored-project-rootanalysis-context key when present.CLAUDE.md§2.6 — dependency license gate (BUSL-1.1 compatibility check).feedback_on_prem_first_no_cloud_defaults— informs the M1-as-opt-in posture (offline/air-gap-first; default ship cannot require external NuGet egress).feedback_dotnet_test_filter_mtp— xunit MTP runner pattern (S075-002 tests use the in-proc-classfilter, notdotnet test --filter).
