From a6d3198f33b3820f6fcbc09fc711052bb8334147 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 23 Jun 2026 13:39:01 +0000 Subject: [PATCH 1/3] =?UTF-8?q?flow:=20add=20opt-in=20`--body-throw-edges`?= =?UTF-8?q?=20tier=20=E2=80=94=20body-level=20(no-try)=20dispose-not-calle?= =?UTF-8?q?d-on-throw?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR #94 modelled the SOUND slice (explicit throw + the existing in-try edges). This adds the OPT-IN firehose for full CodeQL cs/dispose-not-called-on-throw parity on the no-try slice: when --body-throw-edges is set, InjectThrowEdge also treats any ESCAPING body-level may-throw call/`new` as a throw point (synthesizing a bare method exit as its continuation), not only those inside a `try`. OFF by default: it is the CA2000 firehose (flags even harmless MemoryStream/ StringWriter dispose-on-throw), so the shipped posture stays low-FP; the oracle flips it on to MEASURE full recall without shifting the default. Read deep in InjectThrowEdge via a static Program.BodyThrowEdges (set from the flag) rather than threaded through the whole LowerFlow* recursion — the explicit-throw case (onThrow stays null at body level) is untouched, so PR #94's behaviour is unaffected. own-check.sh forwards --body-throw-edges. New sample BodyThrowEdgesSample (run in BOTH modes in CI) proves the flag gates it: MayThrowLeaks 'mtbd' leaks ONLY under the flag; AdjacentDisposeClean 'adc' (no intervening may-throw) stays silent in both modes. Core handling of the {op:return} exit is unchanged (re-validated on the firehose IR shape). P-016 documents the two tiers. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01Rg8kSk1YT14x7A1vo5zgED --- .github/workflows/ci.yml | 23 ++++++++++ docs/proposals/P-016-deep-fact-extraction.md | 12 ++++++ frontend/roslyn/OwnSharp.Extractor/Program.cs | 43 ++++++++++++++++--- .../roslyn/samples/BodyThrowEdgesSample.cs | 33 ++++++++++++++ scripts/own-check.sh | 9 +++- 5 files changed, 112 insertions(+), 8 deletions(-) create mode 100644 frontend/roslyn/samples/BodyThrowEdgesSample.cs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b6c13833..3abb1639 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -719,6 +719,29 @@ jobs: if echo "$out" | grep -q "'$ok'"; then echo "FAIL: silent/exempt case '$ok' was reported"; exit 1; fi done echo "OK: flow-sensitive OWN001/002/003 on real C# (path-sensitive, loops via while/foreach/for, try/finally sequential, never-vs-every-path wording, dispose-optional exempt, beyond flat)" + - name: Opt-in body-throw-edges tier (--body-throw-edges, P-016 throw firehose) + run: | + # The opt-in tier: body-level "any call may throw" dispose-not-called-on-throw (CodeQL + # cs/dispose-not-called-on-throw parity on the no-try slice). OFF by default — its own + # sample file is run in BOTH modes to prove the flag GATES the firehose (running it over + # FlowLocalsSample would flood every acquire/use/dispose sample under the flag). + # default (flag off): the may-throw case is SILENT (a body-level call is not a leak point). + dotnet run --project frontend/roslyn/OwnSharp.Extractor -- \ + frontend/roslyn/samples/BodyThrowEdgesSample.cs --flow-locals -o "$RUNNER_TEMP/bte_off.json" + off=$(python -m ownlang ownir "$RUNNER_TEMP/bte_off.json" || true) + echo "$off" + if echo "$off" | grep -q "'mtbd'"; then echo "FAIL: 'mtbd' must be SILENT without --body-throw-edges"; exit 1; fi + # opt-in (flag on): the may-throw WriteByte between acquire and Dispose is a throw point + # that skips the Dispose -> 'mtbd' leaks; 'adc' (adjacent dispose, nothing throws between) + # stays silent even under the flag (the edge needs an intervening may-throw statement). + dotnet run --project frontend/roslyn/OwnSharp.Extractor -- \ + frontend/roslyn/samples/BodyThrowEdgesSample.cs --flow-locals --body-throw-edges -o "$RUNNER_TEMP/bte_on.json" + on=$(python -m ownlang ownir "$RUNNER_TEMP/bte_on.json" || true) + echo "$on" + echo "$on" | grep -qE "OWN001.*'mtbd' may not be disposed on every path" \ + || { echo "FAIL: expected OWN001 on 'mtbd' under --body-throw-edges"; exit 1; } + if echo "$on" | grep -q "'adc'"; then echo "FAIL: 'adc' (adjacent dispose) must stay silent even under the flag"; exit 1; fi + echo "OK: --body-throw-edges gates the body-level dispose-not-called-on-throw firehose (off by default; fires only with an intervening may-throw)" - name: Coverage summary (--stats) run: | # --stats prints a flow-locals coverage line to stderr and stamps the same diff --git a/docs/proposals/P-016-deep-fact-extraction.md b/docs/proposals/P-016-deep-fact-extraction.md index 3fadbf09..bb54f340 100644 --- a/docs/proposals/P-016-deep-fact-extraction.md +++ b/docs/proposals/P-016-deep-fact-extraction.md @@ -119,6 +119,18 @@ a CFG-carrying bridge, and the existing core checks real code. the most common .NET resource bug, and the first time the core's flow analysis bites real code. B2 (`using`/try-finally → scoped release) is the smallest first slice; do it before the general case. + - **dispose-not-called-on-throw — two tiers (mined: the oracle's CodeQL + `cs/dispose-not-called-on-throw` showed up oracle-only on Serilog + Npgsql).** + *Tier 1 (default, sound):* exceptional-exit edges inside a `try` already model it; + an explicit `throw` with no enclosing try is modelled as a bare method exit (so + `acquire; if (bad) throw; dispose;` leaks on the throw path, and a top-level + validation throw no longer bails the whole method). FP-free — a throw with no + enclosing try definitely exits. *Tier 2 (opt-in `--body-throw-edges`, off by + default):* also treat ANY escaping body-level may-throw call/`new` as a throw point + (full CodeQL parity on the no-try slice). This is the CA2000 firehose — it flags + even harmless `MemoryStream`/`StringWriter` dispose-on-throw — so it stays off the + shipped low-FP default; the oracle enables it to measure full recall. Graduating it + to default would need a "managed-only, no OS handle" dispose-optional expansion. - **B3 — Move / ownership transfer.** `return disposable`, or passing it to a callee that consumes it → move / escape (P-005 D5). Intraprocedural, driven by declared signatures, not whole-program tracing. *Landed (consume handoff):* a call to a diff --git a/frontend/roslyn/OwnSharp.Extractor/Program.cs b/frontend/roslyn/OwnSharp.Extractor/Program.cs index 85cae321..23b3c9e4 100644 --- a/frontend/roslyn/OwnSharp.Extractor/Program.cs +++ b/frontend/roslyn/OwnSharp.Extractor/Program.cs @@ -46,11 +46,18 @@ // flow-analysed vs honestly skipped for an unmodelled construct) and stamp the // same counts into the facts JSON. Turns "0 findings" into "clean vs didn't-reach". bool reportStats = false; +// --body-throw-edges (opt-in, P-016 throw tier): also treat an ESCAPING body-level may-throw +// call/`new` (not only those inside a `try`) as a dispose-not-called-on-throw point — CodeQL +// cs/dispose-not-called-on-throw parity. OFF by default: it is the CA2000 firehose (flags even +// harmless MemoryStream/StringWriter dispose-on-throw), so the shipped posture stays low-FP; the +// oracle turns it on to measure full recall. Read deep in InjectThrowEdge via the static +// Program.BodyThrowEdges (declared at end of file) rather than threaded through the flow recursion. for (int i = 0; i < args.Length; i++) { if (args[i] == "-o" && i + 1 < args.Length) outPath = args[++i]; else if (args[i] == "--no-event-leaks") emitEvents = false; else if (args[i] == "--flow-locals") flowLocals = true; + else if (args[i] == "--body-throw-edges") BodyThrowEdges = true; else if (args[i] == "--stats") reportStats = true; else rawInputs.Add(args[i]); } @@ -71,6 +78,11 @@ return 2; } +// --body-throw-edges only injects edges during the --flow-locals pass; without it it is a no-op. +// Warn (non-fatal) rather than fail — it is an additive recall knob layered on flow, not a mode. +if (BodyThrowEdges && !flowLocals) + Console.Error.WriteLine("ownsharp-extract: --body-throw-edges has no effect without --flow-locals"); + // A path segment we never scan: build output, VCS, and vendored trees. static bool IsSkippedDir(string seg) => seg is "bin" or "obj" or ".git" or ".vs" or "node_modules" or "packages"; @@ -590,11 +602,21 @@ static bool IsInsideFinally(SyntaxNode node) // LEAF statements only; a COMPOUND statement (if/loop/block) is recursed into so the edge // lands before the nested leaf — at the point the resource's ownership is exact (after any // in-branch dispose), which is what makes nesting sound rather than a false leak. -static void InjectThrowEdge(StatementSyntax st, List nodes, List? onThrow) +static void InjectThrowEdge(StatementSyntax st, List nodes, List? onThrow, bool canEscape) { - if (onThrow is not null && StatementMayThrow(st)) + // Inside a `try`, `onThrow` is the finally+exit continuation a throw here runs. At the + // method-body level `onThrow` is null — by default no edge is injected (the shipped low-FP + // posture: a body-level may-throw call is NOT treated as a leak point). The opt-in + // --body-throw-edges tier (Program.BodyThrowEdges) lifts that: an ESCAPING body-level + // may-throw statement (`canEscape`, so no enclosing catch-all swallows it) gets a synthetic + // bare method exit as its continuation, matching CodeQL's cs/dispose-not-called-on-throw on + // the no-try slice. A catch-all-suppressed region (`canEscape` false) still injects nothing. + var cont = onThrow ?? (BodyThrowEdges && canEscape + ? new List { new { op = "return", var = (string?)null, line = LineOf(st) } } + : null); + if (cont is not null && StatementMayThrow(st)) nodes.Add(new { op = "if", line = LineOf(st), - then = new List(onThrow), @else = new List() }); + then = new List(cont), @else = new List() }); } // A statement that can raise an exception part-way through: it makes a call that is not @@ -664,7 +686,7 @@ static bool LowerFlowStatements(IReadOnlyList stmts, int start, var uv = usingDecl.Declaration.Variables[0]; var owner = uv.Identifier.Text; var exit = new List { new { op = "return", var = (string?)null, line = LineOf(usingDecl) } }; - InjectThrowEdge(usingDecl, nodes, onThrow); // a throw DURING Rent() runs the OUTER path (owner not yet acquired) + InjectThrowEdge(usingDecl, nodes, onThrow, canEscape); // a throw DURING Rent() runs the OUTER path (owner not yet acquired) nodes.Add(new { op = "acquire", var = owner, line = LineOf(uv) }); var release = new { op = "release", var = owner, line = LineOf(uv) }; // The rest of THIS block is the try-body; the implicit using-dispose is its finally — run @@ -704,7 +726,7 @@ static bool LowerFlowStmt(StatementSyntax st, HashSet tracked, SemanticM case BlockSyntax b: return LowerFlowStatements(b.Statements, 0, tracked, model, nodes, canEscape, onThrow, onReturn); case LocalDeclarationStatementSyntax ld: - InjectThrowEdge(ld, nodes, onThrow); + InjectThrowEdge(ld, nodes, onThrow, canEscape); if (ld.UsingKeyword == default) foreach (var v in ld.Declaration.Variables) { @@ -723,7 +745,7 @@ or ImplicitObjectCreationExpressionSyntax } return true; case ExpressionStatementSyntax es: - InjectThrowEdge(es, nodes, onThrow); + InjectThrowEdge(es, nodes, onThrow, canEscape); EmitFlowExpr(es.Expression, tracked, model, nodes); return true; case IfStatementSyntax ifs: @@ -2944,3 +2966,12 @@ or ImplicitObjectCreationExpressionSyntax } init if (outPath is null) Console.WriteLine(json); else File.WriteAllText(outPath, json); return 0; + +// Opt-in recall knob for the flow pass, read deep in InjectThrowEdge (a static field rather than +// a bool threaded through the whole LowerFlow* recursion). Set once from --body-throw-edges; see +// that flag's note above. Default false keeps the shipped low-FP posture; the oracle flips it on +// to measure CodeQL-parity dispose-not-called-on-throw recall on the no-try slice. +partial class Program +{ + internal static bool BodyThrowEdges; +} diff --git a/frontend/roslyn/samples/BodyThrowEdgesSample.cs b/frontend/roslyn/samples/BodyThrowEdgesSample.cs new file mode 100644 index 00000000..46d8343f --- /dev/null +++ b/frontend/roslyn/samples/BodyThrowEdgesSample.cs @@ -0,0 +1,33 @@ +using System.IO; + +namespace Own.Samples; + +// P-016 throw tier — the OPT-IN `--body-throw-edges` firehose: body-level "any call may throw" +// dispose-not-called-on-throw, matching CodeQL's cs/dispose-not-called-on-throw on the no-try +// slice. OFF by default (it is CA2000-noisy — it flags even harmless MemoryStream dispose-on- +// throw); the oracle enables it to measure full recall without shifting the shipped low-FP +// default. The leak verdict below holds ONLY under --body-throw-edges; by default this file is +// silent. Kept in its own file so CI can run it in BOTH modes — running it against the default +// FlowLocalsSample would flood every acquire/use/dispose sample under the flag. +public class BodyThrowEdgesSample +{ + // acquire; a may-throw call; dispose — no try. Under --body-throw-edges the WriteByte call is + // a throw point that skips the Dispose, so `mtbd` leaks on that exceptional path -> OWN001 + // "may not be disposed on every path". Default (flag off): SILENT — a body-level call is not + // treated as a leak point (the shipped posture stays below CA2000). + public void MayThrowLeaks() + { + var mtbd = new MemoryStream(); + mtbd.WriteByte(1); + mtbd.Dispose(); + } + + // control: NOTHING between acquire and dispose can throw (adjacent), so even under the flag + // there is no throw point to skip the Dispose -> SILENT in both modes. Proves the edge needs + // an intervening may-throw statement — the flag is not "flag any undisposed-looking local". + public void AdjacentDisposeClean() + { + var adc = new MemoryStream(); + adc.Dispose(); + } +} diff --git a/scripts/own-check.sh b/scripts/own-check.sh index 7856791d..44875089 100755 --- a/scripts/own-check.sh +++ b/scripts/own-check.sh @@ -12,8 +12,8 @@ # # Usage: # scripts/own-check.sh [--format human|github|msbuild|sarif] [--severity error|warning] -# [--fail-on-finding] [--legacy] [--stats] [--root ] -# [--] [more ...] +# [--fail-on-finding] [--legacy] [--stats] [--body-throw-edges] +# [--root ] [--] [more ...] # # Defaults: --format human, --severity error, scans ".", does not fail the shell # on findings, --root is the repo this script lives in. --severity picks how a @@ -37,6 +37,7 @@ severity="error" fail_on_finding=0 legacy=0 stats=0 +body_throw_edges=0 paths=() while [[ $# -gt 0 ]]; do @@ -53,6 +54,7 @@ while [[ $# -gt 0 ]]; do --fail-on-finding) fail_on_finding=1; shift ;; --legacy) legacy=1; shift ;; --stats) stats=1; shift ;; + --body-throw-edges) body_throw_edges=1; shift ;; --) shift; while [[ $# -gt 0 ]]; do paths+=("$1"); shift; done ;; -h|--help) sed -n '2,30p' "$0"; exit 0 ;; *) paths+=("$1"); shift ;; @@ -78,6 +80,9 @@ trap 'rm -f "$facts"' EXIT extractor_args=("${paths[@]}" -o "$facts") [[ "$legacy" -eq 0 ]] && extractor_args+=(--flow-locals) [[ "$stats" -eq 1 ]] && extractor_args+=(--stats) +# Opt-in P-016 throw tier: also flag body-level (no-try) dispose-not-called-on-throw — CodeQL +# cs/dispose-not-called-on-throw parity. CA2000-noisy, so off by default (oracle recall measurement). +[[ "$body_throw_edges" -eq 1 ]] && extractor_args+=(--body-throw-edges) dotnet run --project "$extractor" -- "${extractor_args[@]}" 1>&2 # Stage 2: the one checker produces the verdict at the C# location. From 47ef5f8b2da1a6159776cf25e5e72259eeb1d27e Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 23 Jun 2026 13:45:23 +0000 Subject: [PATCH 2/3] flow: exclude finally-internal may-throw from the body-throw-edges firehose (Codex P2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Symmetric to the explicit-throw fix on PR #94: under --body-throw-edges, a may-throw call lexically inside a `finally` is lowered with a null onThrow, so InjectThrowEdge synthesized a bare method exit for it — skipping any ENCLOSING finally/using cleanup and falsely flagging a resource the outer finally disposes (Codex P2: `using var o = ...; try {} finally { Log(); }`). Guard the synthesized body-level continuation with `!IsInsideFinally(st)` — the same predicate the explicit-throw path already uses. A finally-internal may-throw now gets no firehose edge (its real continuation is the outer cleanup), so the flag-on behaviour there matches flag-off. Body-level may-throws (not in a finally) unaffected. Sample MayThrowInFinallyClean ('mtf', disposed by the outer finally) pins it silent in both modes; CI asserts mtbd/mtf/adc silent in off-mode and adc/mtf silent under the flag. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01Rg8kSk1YT14x7A1vo5zgED --- .github/workflows/ci.yml | 13 ++++++++++--- frontend/roslyn/OwnSharp.Extractor/Program.cs | 6 +++++- frontend/roslyn/samples/BodyThrowEdgesSample.cs | 17 +++++++++++++++++ 3 files changed, 32 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3abb1639..5f0a4d64 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -730,7 +730,9 @@ jobs: frontend/roslyn/samples/BodyThrowEdgesSample.cs --flow-locals -o "$RUNNER_TEMP/bte_off.json" off=$(python -m ownlang ownir "$RUNNER_TEMP/bte_off.json" || true) echo "$off" - if echo "$off" | grep -q "'mtbd'"; then echo "FAIL: 'mtbd' must be SILENT without --body-throw-edges"; exit 1; fi + for s in mtbd mtf adc; do + if echo "$off" | grep -q "'$s'"; then echo "FAIL: '$s' must be SILENT without --body-throw-edges"; exit 1; fi + done # opt-in (flag on): the may-throw WriteByte between acquire and Dispose is a throw point # that skips the Dispose -> 'mtbd' leaks; 'adc' (adjacent dispose, nothing throws between) # stays silent even under the flag (the edge needs an intervening may-throw statement). @@ -740,8 +742,13 @@ jobs: echo "$on" echo "$on" | grep -qE "OWN001.*'mtbd' may not be disposed on every path" \ || { echo "FAIL: expected OWN001 on 'mtbd' under --body-throw-edges"; exit 1; } - if echo "$on" | grep -q "'adc'"; then echo "FAIL: 'adc' (adjacent dispose) must stay silent even under the flag"; exit 1; fi - echo "OK: --body-throw-edges gates the body-level dispose-not-called-on-throw firehose (off by default; fires only with an intervening may-throw)" + # adc: nothing throws between acquire and dispose. mtf (Codex P2): a may-throw call inside + # a finally must not get a synthetic bare exit — the outer finally disposes it. Both silent + # even under the flag. + for s in adc mtf; do + if echo "$on" | grep -q "'$s'"; then echo "FAIL: '$s' must stay silent even under --body-throw-edges"; exit 1; fi + done + echo "OK: --body-throw-edges gates the body-level dispose-not-called-on-throw firehose (off by default; fires only with an intervening may-throw; finally-internal throws excluded)" - name: Coverage summary (--stats) run: | # --stats prints a flow-locals coverage line to stderr and stamps the same diff --git a/frontend/roslyn/OwnSharp.Extractor/Program.cs b/frontend/roslyn/OwnSharp.Extractor/Program.cs index 23b3c9e4..fb441f11 100644 --- a/frontend/roslyn/OwnSharp.Extractor/Program.cs +++ b/frontend/roslyn/OwnSharp.Extractor/Program.cs @@ -611,7 +611,11 @@ static void InjectThrowEdge(StatementSyntax st, List nodes, List // may-throw statement (`canEscape`, so no enclosing catch-all swallows it) gets a synthetic // bare method exit as its continuation, matching CodeQL's cs/dispose-not-called-on-throw on // the no-try slice. A catch-all-suppressed region (`canEscape` false) still injects nothing. - var cont = onThrow ?? (BodyThrowEdges && canEscape + // `!IsInsideFinally`: a may-throw statement lexically inside a `finally` is lowered with a null + // onThrow too, but a real exception there runs the ENCLOSING cleanup — a bare exit would skip + // it and falsely flag a resource the outer finally/using disposes, so synthesize no edge there + // (the symmetric guard the explicit-throw path already uses — Codex P2 on the may-throw tier). + var cont = onThrow ?? (BodyThrowEdges && canEscape && !IsInsideFinally(st) ? new List { new { op = "return", var = (string?)null, line = LineOf(st) } } : null); if (cont is not null && StatementMayThrow(st)) diff --git a/frontend/roslyn/samples/BodyThrowEdgesSample.cs b/frontend/roslyn/samples/BodyThrowEdgesSample.cs index 46d8343f..9e6a7703 100644 --- a/frontend/roslyn/samples/BodyThrowEdgesSample.cs +++ b/frontend/roslyn/samples/BodyThrowEdgesSample.cs @@ -30,4 +30,21 @@ public void AdjacentDisposeClean() var adc = new MemoryStream(); adc.Dispose(); } + + // Codex P2 (may-throw tier): a may-throw call lexically inside a `finally` must NOT get a + // synthetic bare exit even under the flag — a real exception there runs the ENCLOSING cleanup. + // `mtf` is disposed by the OUTER finally; the inner finally's `mtf.WriteByte(1)` may throw, but + // that throw runs the outer `mtf.Dispose()`, so `mtf` is released on every path. Without the + // IsInsideFinally guard on the may-throw path, the bare exit would skip that outer release and + // falsely flag `mtf` -> it must stay SILENT in BOTH modes (mirrors FlowLocalsSample's tif). + public void MayThrowInFinallyClean() + { + var mtf = new MemoryStream(); + try + { + try { } + finally { mtf.WriteByte(1); } + } + finally { mtf.Dispose(); } + } } From 74f6842199facfdc986d50eb58439b45c1122375 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 23 Jun 2026 13:46:44 +0000 Subject: [PATCH 3/3] =?UTF-8?q?flow:=20reset=20static=20BodyThrowEdges=20a?= =?UTF-8?q?t=20startup=20(CodeRabbit=20=E2=80=94=20sticky=20in-process=20s?= =?UTF-8?q?tate)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BodyThrowEdges is static and was only ever SET, so a prior IN-PROCESS invocation with --body-throw-edges could leak into a later run that did not request it (the CLI normally runs as a fresh process, but a static set-once field is fragile if it is ever embedded/tested in-process). Reset it to false before arg parsing. The other config (emitEvents/flowLocals/reportStats) are locals, re-initialized each call, so only this static field needs the reset. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01Rg8kSk1YT14x7A1vo5zgED --- frontend/roslyn/OwnSharp.Extractor/Program.cs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/frontend/roslyn/OwnSharp.Extractor/Program.cs b/frontend/roslyn/OwnSharp.Extractor/Program.cs index fb441f11..7547331a 100644 --- a/frontend/roslyn/OwnSharp.Extractor/Program.cs +++ b/frontend/roslyn/OwnSharp.Extractor/Program.cs @@ -52,6 +52,10 @@ // harmless MemoryStream/StringWriter dispose-on-throw), so the shipped posture stays low-FP; the // oracle turns it on to measure full recall. Read deep in InjectThrowEdge via the static // Program.BodyThrowEdges (declared at end of file) rather than threaded through the flow recursion. +// Reset the static field up front so a flag from a prior IN-PROCESS invocation can't leak into a +// run that did not request it (the other config — emitEvents/flowLocals/reportStats — are locals, +// re-initialized each call, so they need no reset; only this static one does). CodeRabbit. +BodyThrowEdges = false; for (int i = 0; i < args.Length; i++) { if (args[i] == "-o" && i + 1 < args.Length) outPath = args[++i];