diff --git a/docs/evidence/n1-competitor-gate-2026-10/00-preregistration.md b/docs/evidence/n1-competitor-gate-2026-10/00-preregistration.md new file mode 100644 index 00000000..06a6a154 --- /dev/null +++ b/docs/evidence/n1-competitor-gate-2026-10/00-preregistration.md @@ -0,0 +1,102 @@ +# N1 competitor gate — pre-registration + +Written 2026-10-20, 21:11 +05. The four fix diffs have been read in order to build the reproductions. No analyzer +has been run on any reproduction. + +Authority: owner decision `GO-N1-COMPETITOR-GATE`, given after `KILL-N3-PILOT` was accepted. This gate is not a GO +on an N1 PoC. + +- **Budget.** 0.25 agent-day. +- **Rules of the work.** No third-party project is built and no package is downloaded. The analyzers come from the + .NET SDK and from the local NuGet cache, the same versions as direction-research Phase 5. + +## Question + +N1: a disposable is acquired, a step that can throw runs before ownership is handed over (return, field or +collection store, wrapping), and no dispose runs on the throw path. + +Do existing analyzers miss the four confirmed N1 defects of Phase 0 once they are configured to look? The question +matters most for CA2000 with exception paths on (`dispose_analysis_kind = AllPaths`), because that is the +documented setting for exactly this case. + +## Why the earlier "not detected" does not answer it + +Phase 5 ran the analyzers over loose source trees. Those runs had about 35,000 compile errors (whizbang: 34,859 on +base), so the disposable types were mostly unresolved, and CA2000 ran with its default kind. Those misses therefore +say little about what CA2000 can do. + +## Pairs (fixed now) + +| Id | Fix | Buggy method | Leaked resource and acquisition | Throwing step before hand-over | Hand-over on success | +|---|---|---|---|---|---| +| W | whizbang-lib/whizbang#1204 | `AzureServiceBusConnectionRetry.CreateClientWithRetryAsync` | `new ServiceBusClient(cs)` (`IAsyncDisposable`), inside a retry loop's `try` | `await VerifyNamespaceReachableAsync(…)` | `return client;` | +| R | MarimerLLC/rockbot#638 (record 638-2) | `McpBridgeService.ConnectServerAsync` | `await McpClient.CreateAsync(transport, …)`, an async factory (`IAsyncDisposable`) | `await newClient.ListToolsAsync(…)`, prompt listing | `_clients[name] = newClient;` (dictionary field) | +| X | NexusFFXIV/NexusKit#34 (record 34-2) | `PluginHostBuilder.BuildAsync` | `services.BuildServiceProvider(validateScopes: false)`, a factory (`ServiceProvider`, `IDisposable` + `IAsyncDisposable`) | `await DbInitializer.InitializeAsync(…)`, service resolution | `return new PluginHost(provider);` (wrapping) | +| B | Wizard-Software/BareWire#73 | `RabbitMqTransportAdapter.ConsumeAsync` (an `async` iterator) | `await _connection.CreateChannelAsync(…)`, an async factory (`IChannel`, `IAsyncDisposable` + `IDisposable`) | `await consumerChannel.BasicQosAsync(…)` | `_activeConsumerChannels[id] = consumerChannel;` (dictionary field) | + +The four fixes are by different owners. + +## Reproductions + +For each pair: one file `repro/.cs` with two self-contained classes, `Buggy` and `Fixed`. The +acquisition line carries the marker comment `// ACQ:`. + +- **Kept from the real code.** + - the acquisition expression form: `new`, factory call, or awaited async factory; + - the throwing step, as an awaited call that can throw; + - the success hand-over (return, wrap, or dictionary store); + - the enclosing control flow: the retry loop with `try`/`catch` in W, the `try` around the attempt in R, and the + `async` iterator in B; + - each fix's exact cleanup shape: W `try`/`catch` + `DisposeAsync` + rethrow, with acquisition through a + `Func<…>` seam; R a nullable local, a `published` flag and cleanup on the failure path; X `try`/`catch` + wrap + + `DisposeAsync` + rethrow; B early registration into the dictionary, then `try`/`catch` + release. +- **Third-party types are stubs** with the real disposable interfaces and the members used: `ServiceBusClient`, + `McpClient`, `IChannel`/`IConnection`. `ServiceCollection`/`ServiceProvider` are the real types from the local + ASP.NET Core reference pack. +- **Lines not on the resource's path are dropped**: logging, metrics, unrelated locals. +- The reproductions must compile with **0 errors**. An analyzer that sees a compile error proves nothing. + +## Analyzers and configurations + +One SDK-style project, `net10.0`, with no `PackageReference`. Analyzers are referenced by path: + +- the .NET SDK `Microsoft.CodeAnalysis.NetAnalyzers` (CA2000); +- `IDisposableAnalyzers` 4.0.8 (IDISP001–IDISP026); +- `SonarAnalyzer.CSharp` 10.35.0.4138 (S2930 and others). + +Every rule of every analyzer is set to warning (`dotnet_analyzer_diagnostic.severity = warning`). Configurations: + +| K | `dispose_analysis_kind` | ownership transfer at constructor | ownership transfer at method call | +|---|---|---|---| +| K1 | default (`NonExceptionPaths`) | default (false) | default (false) | +| K2 | `AllPaths` | false | false | +| K3 | `AllPaths` | **true** | false | +| K4 | `AllPaths` | false | **true** | +| K5 | `AllPathsOnlyNotDisposed` | false | false | + +**Harness control.** A two-method file, `Control.cs`: + +- `new StreamReader(path)`, then a call that can throw, then `Dispose()`. CA2000 must report it under K2 and must + not report it under K1. +- the same with `using`: silent in every configuration. + +If this control fails, the harness is invalid and the gate stops as `INVALID`, not as KILL. + +## Scoring (fixed now) + +- **Relevant diagnostic.** A diagnostic whose message is about disposal or ownership, located on the leaked + resource's acquisition statement or its declared variable, in the buggy method. +- **Pair verdict.** + - **`CAUGHT`:** at least one configuration gives a relevant diagnostic on the buggy reproduction. This holds even + when the fixed reproduction also gets one: a flag on the buggy site counts against N1's novelty. Whether the + flag also distinguishes the fix is recorded separately. + - **`MISSED`:** no relevant diagnostic on the buggy reproduction in any of K1–K5, from any of the three analyzers. + +**Decision.** +- **`GO-CANDIDATE-N1`** (for an owner decision, not a PoC GO) if at least **2** pairs are `MISSED`. +- **`KILL-N1`** otherwise. + +Fidelity risk: a stub or a dropped line can change an analyzer's result. Every `MISSED` is therefore re-checked by +listing the analyzer's documented limits for that shape: a factory, an `async` iterator or a dictionary store. A +`MISSED` that is explained only by a reproduction deviation from the real code becomes `UNRESOLVED` and does not +count toward GO. diff --git a/docs/evidence/n1-competitor-gate-2026-10/REPORT.md b/docs/evidence/n1-competitor-gate-2026-10/REPORT.md new file mode 100644 index 00000000..b00260f5 --- /dev/null +++ b/docs/evidence/n1-competitor-gate-2026-10/REPORT.md @@ -0,0 +1,100 @@ +# N1 competitor gate — report + +**Result: `GO-CANDIDATE-N1`.** This is a decision for the owner, not a GO on an N1 PoC. + +| Pair | Verdict | What CA2000 (`AllPaths`) does | +|---|---|---| +| W, whizbang#1204 | CAUGHT | reports the buggy `new ServiceBusClient(…)` in K2–K4; silent on the fix | +| R, rockbot#638 | MISSED | does not track `await McpClient.CreateAsync(…)` | +| X, NexusKit#34 | MISSED | does not track `services.BuildServiceProvider(…)` | +| B, BareWire#73 | MISSED | does not track `await _connection.CreateChannelAsync(…)` | + +So 3 of 4 pairs are missed by every configuration (K1–K5) of all three analyzers: CA2000, IDisposableAnalyzers +4.0.8 and Sonar 10.35. In run 2, none of the three analyzers reports anything on the R, X or B acquisition lines. + +Rules: [`00-preregistration.md`](00-preregistration.md), committed in `d06caf75` before any analyzer run. Time used: +21:11–21:20 +05 on 2026-10-20 for the scored work, inside the 0.25 agent-day budget. + +No third-party project was built and no package was downloaded. The analyzers came from the SDK and the local NuGet +cache. Every build ran from a copy in the session scratch directory, with a nuget.org-only config and no +`PackageReference`. + +## 1. Runs, in order (runs 1 and 2 kept in `data/`; run 0's output was overwritten and is not kept) + +| Run | What changed | Result | +|---|---|---| +| 0 | first build | 0 diagnostics matched, because of a bug in my SARIF reader: csc wrote SARIF 1.0 and the reader expected 2.1. Not a result; the reader was fixed. No rule changed | +| 1 (`run1_stubs_in_compilation.tsv`) | reader fixed | control valid; W CAUGHT; R, X, B silent | +| 2 (`run2_scored.tsv`) | **scored run**: the stubs moved into a separate referenced assembly (§2) | control valid; W CAUGHT; R, X, B MISSED | + +The harness control was valid in both runs 1 and 2: +- `new StreamReader` followed by an opaque throwing call and `Dispose` is reported under K2–K5 and silent under K1; +- the same with `using` is silent everywhere. + +## 2. Fidelity correction before scoring (disclosed) + +The pre-registration requires every MISSED to be checked for a reproduction deviation. The first check found one. + +In run 1, the stubs were in the analysed compilation. `McpClient.CreateAsync` is a stub whose body is `throw`, and +CA2000's interprocedural analysis can read that body. + +The real types (`ServiceBusClient`, `McpClient`, `IChannel`) live in external packages whose bodies the analyzer +does not see. Run 2 therefore compiles the stubs as their own assembly (`stubs/`), referenced by the reproductions. +The reproductions and the rules did not change. The verdicts were the same in both runs. + +## 3. Why R, X and B are missed: analyzer limits, not reproduction artifacts + +The probes are not scored (`probes/`, `data/probes_opaque_step.tsv`, `data/ablation_and_probes.tsv`). Each one has +the same leak: acquire a disposable, make an **opaque** throwing call through an interface, then dispose or hand it +over. Only the acquisition form changes. + +A first probe set used empty local `MayThrow` methods. CA2000 proved that those cannot throw and stayed silent, so +the set was invalid; its output was overwritten and is not kept. It was redone with interface calls, which is how +the reproductions throw. + +| Acquisition form (K2, `AllPaths`) | Tracked? | +|---|---| +| `new T(…)`; an in-source sync or async factory; `File.OpenText`; an external static sync factory (P10, P12) | yes | +| `new T(…)` then store in a dictionary, wrap and return, return, or inside an `async` iterator (P5–P8) | yes | +| **await of an external async factory** (P11), the shape of R and B | **no** | +| **result of an interface method** (P13), the shape of B (`IConnection.CreateChannelAsync`) | **no** | +| **`ServiceCollection.BuildServiceProvider()`** (P9), the shape of X; the real M.E.DI type | **no**, even in a three-line method; the reason was not established | + +The ablation of each MISSED reproduction (`probes/Ablation.cs`) found no other feature that silences the analyzer: +- removing the catch-all retry loop (R) leaves it silent; +- removing the resolve calls (X) leaves it silent; +- removing the iterator, or swapping the interface for a class (B), leaves it silent. + +The acquisition form alone decides. In all three pairs that form is the real code's own API, so the MISSEDs stand +and none is `UNRESOLVED`. + +## 4. What this does and does not show + +Confirmed: +- CA2000 with `AllPaths` is the documented tool for this class, and it does catch the `new`-acquired case (W). +- For the acquisition forms that are common in modern async .NET, it reports nothing on these reproductions: + awaited async factories, interface factory methods and `BuildServiceProvider`. +- IDisposableAnalyzers and Sonar report nothing on any of the four acquisition lines. Inference, not measured: they + accept a return or store as a hand-over and do not model the exception path before it. + +Not shown: +- **No real build was run.** The verdicts come from faithful reproductions of the methods with stub third-party + types, not from building the four repositories. CA2000 on the real repositories could differ if a real package + type carries attributes or annotations that the stubs lack. +- **Only the options in the pre-registration were tested.** `interprocedural_analysis_kind` and + `excluded_symbol_names` were not varied. +- W's fixed version is silent partly because the fix acquires through a `Func<…>` seam that CA2000 does not + track. A `new` + `try`/`catch` fix was not tested. +- That Own.NET can do better is not shown. Phase 0 recorded that Own.NET tracks only non-escaping `new` locals and + missed 3/3 here. A detector for factory-acquired and awaited acquisitions would be new work. +- Mature-code yield and precision of such a detector are unknown. This gate measured only competitor coverage. + +## 5. Files + +- [`00-preregistration.md`](00-preregistration.md) +- [`repro/`](repro/): W, R, X, B buggy/fixed reproductions, `Control.cs` and `N1Repro.csproj`. +- [`stubs/`](stubs/): the third-party stand-ins, built as a separate assembly. +- [`probes/`](probes/): explanatory probes and ablation. `ProbeExternals.cs` goes into the stub assembly for the + ablation run only. +- [`scripts/run_gate.py`](scripts/run_gate.py): builds K1–K5 and collects the diagnostics on each acquisition line. +- `data/`: outputs of run 1, run 2 and the probes. diff --git a/docs/evidence/n1-competitor-gate-2026-10/data/ablation_and_probes.tsv b/docs/evidence/n1-competitor-gate-2026-10/data/ablation_and_probes.tsv new file mode 100644 index 00000000..bfc272d1 --- /dev/null +++ b/docs/evidence/n1-competitor-gate-2026-10/data/ablation_and_probes.tsv @@ -0,0 +1,21 @@ +config id class file line rule message +K1 _build rc=0 0 errors=0 results=27 +K2 P10 ExternalProbes ExternalProbes.cs 15 CA2000 Object created by 'ExternalFactories.OpenSync(p)' is not disposed along all exception paths. Call System.IDisposable.Dispose on the object before all references to it are out of scope. +K2 P12 ExternalProbes ExternalProbes.cs 29 CA2000 Object created by 'ExternalFactories.CreateClient(cs)' is not disposed along all exception paths. Call System.IDisposable.Dispose on the object before all references to it are out of scope. +K2 W WBuggy W.cs 23 CA2000 Use recommended dispose pattern to ensure that object created by 'new ServiceBusClient(connectionString)' is disposed on all exception paths. If possible, wrap the creation within a 'using' statement or a 'using' declaration. Otherwise, use a try-finally pattern, with a dedicated local variable declared before the try region and an unconditional Dispose invocation on non-null value in the 'finally' region, say 'x?.Dispose()'. If the object is explicitly disposed within the try region or the dispose ownership is transfered to another object or method, assign 'null' to the local variable just after such an operation to prevent double dispose in 'finally'. +K2 _build rc=0 0 errors=0 results=32 +K2 control Control Control.cs 11 CA2000 Object created by 'new StreamReader(path)' is not disposed along all exception paths. Call System.IDisposable.Dispose on the object before all references to it are out of scope. +K3 P10 ExternalProbes ExternalProbes.cs 15 CA2000 Object created by 'ExternalFactories.OpenSync(p)' is not disposed along all exception paths. Call System.IDisposable.Dispose on the object before all references to it are out of scope. +K3 P12 ExternalProbes ExternalProbes.cs 29 CA2000 Object created by 'ExternalFactories.CreateClient(cs)' is not disposed along all exception paths. Call System.IDisposable.Dispose on the object before all references to it are out of scope. +K3 W WBuggy W.cs 23 CA2000 Use recommended dispose pattern to ensure that object created by 'new ServiceBusClient(connectionString)' is disposed on all exception paths. If possible, wrap the creation within a 'using' statement or a 'using' declaration. Otherwise, use a try-finally pattern, with a dedicated local variable declared before the try region and an unconditional Dispose invocation on non-null value in the 'finally' region, say 'x?.Dispose()'. If the object is explicitly disposed within the try region or the dispose ownership is transfered to another object or method, assign 'null' to the local variable just after such an operation to prevent double dispose in 'finally'. +K3 _build rc=0 0 errors=0 results=32 +K3 control Control Control.cs 11 CA2000 Object created by 'new StreamReader(path)' is not disposed along all exception paths. Call System.IDisposable.Dispose on the object before all references to it are out of scope. +K4 P10 ExternalProbes ExternalProbes.cs 15 CA2000 Object created by 'ExternalFactories.OpenSync(p)' is not disposed along all exception paths. Call System.IDisposable.Dispose on the object before all references to it are out of scope. +K4 P12 ExternalProbes ExternalProbes.cs 29 CA2000 Object created by 'ExternalFactories.CreateClient(cs)' is not disposed along all exception paths. Call System.IDisposable.Dispose on the object before all references to it are out of scope. +K4 W WBuggy W.cs 23 CA2000 Use recommended dispose pattern to ensure that object created by 'new ServiceBusClient(connectionString)' is disposed on all exception paths. If possible, wrap the creation within a 'using' statement or a 'using' declaration. Otherwise, use a try-finally pattern, with a dedicated local variable declared before the try region and an unconditional Dispose invocation on non-null value in the 'finally' region, say 'x?.Dispose()'. If the object is explicitly disposed within the try region or the dispose ownership is transfered to another object or method, assign 'null' to the local variable just after such an operation to prevent double dispose in 'finally'. +K4 _build rc=0 0 errors=0 results=32 +K4 control Control Control.cs 11 CA2000 Object created by 'new StreamReader(path)' is not disposed along all exception paths. Call System.IDisposable.Dispose on the object before all references to it are out of scope. +K5 P10 ExternalProbes ExternalProbes.cs 15 CA2000 Object created by 'ExternalFactories.OpenSync(p)' is not disposed along all exception paths. Call System.IDisposable.Dispose on the object before all references to it are out of scope. +K5 P12 ExternalProbes ExternalProbes.cs 29 CA2000 Object created by 'ExternalFactories.CreateClient(cs)' is not disposed along all exception paths. Call System.IDisposable.Dispose on the object before all references to it are out of scope. +K5 _build rc=0 0 errors=0 results=28 +K5 control Control Control.cs 11 CA2000 Object created by 'new StreamReader(path)' is not disposed along all exception paths. Call System.IDisposable.Dispose on the object before all references to it are out of scope. diff --git a/docs/evidence/n1-competitor-gate-2026-10/data/probes_opaque_step.tsv b/docs/evidence/n1-competitor-gate-2026-10/data/probes_opaque_step.tsv new file mode 100644 index 00000000..19c14b0a --- /dev/null +++ b/docs/evidence/n1-competitor-gate-2026-10/data/probes_opaque_step.tsv @@ -0,0 +1,57 @@ +config id class file line rule message +K1 P1 Probes Probes.cs 26 IDISP017 Prefer using +K1 P2 Probes Probes.cs 29 IDISP017 Prefer using +K1 P3 Probes Probes.cs 32 IDISP017 Prefer using +K1 P4 Probes Probes.cs 35 IDISP017 Prefer using +K1 _build rc=0 0 errors=0 results=6 +K2 P1 Probes Probes.cs 26 CA2000 Object created by 'new StreamReader(p)' is not disposed along all exception paths. Call System.IDisposable.Dispose on the object before all references to it are out of scope. +K2 P1 Probes Probes.cs 26 IDISP017 Prefer using +K2 P2 Probes Probes.cs 29 CA2000 Object created by 'Open(p)' is not disposed along all exception paths. Call System.IDisposable.Dispose on the object before all references to it are out of scope. +K2 P2 Probes Probes.cs 29 IDISP017 Prefer using +K2 P3 Probes Probes.cs 32 CA2000 Object created by 'File.OpenText(p)' is not disposed along all exception paths. Call System.IDisposable.Dispose on the object before all references to it are out of scope. +K2 P3 Probes Probes.cs 32 IDISP017 Prefer using +K2 P4 Probes Probes.cs 35 CA2000 Object created by 'OpenAsync(p)' is not disposed along all exception paths. Call System.IDisposable.Dispose on the object before all references to it are out of scope. +K2 P4 Probes Probes.cs 35 IDISP017 Prefer using +K2 P5 Probes Probes.cs 38 CA2000 Object created by 'new StreamReader(p)' is not disposed along all exception paths. Call System.IDisposable.Dispose on the object before all references to it are out of scope. +K2 P6 Probes Probes.cs 41 CA2000 Object created by 'new StreamReader(p)' is not disposed along all exception paths. Call System.IDisposable.Dispose on the object before all references to it are out of scope. +K2 P7 Probes Probes.cs 46 CA2000 Object created by 'new StreamReader(p)' is not disposed along all exception paths. Call System.IDisposable.Dispose on the object before all references to it are out of scope. +K2 P8 Probes Probes.cs 53 CA2000 Object created by 'new StreamReader(p)' is not disposed along all exception paths. Call System.IDisposable.Dispose on the object before all references to it are out of scope. +K2 _build rc=0 0 errors=0 results=15 +K3 P1 Probes Probes.cs 26 CA2000 Object created by 'new StreamReader(p)' is not disposed along all exception paths. Call System.IDisposable.Dispose on the object before all references to it are out of scope. +K3 P1 Probes Probes.cs 26 IDISP017 Prefer using +K3 P2 Probes Probes.cs 29 CA2000 Object created by 'Open(p)' is not disposed along all exception paths. Call System.IDisposable.Dispose on the object before all references to it are out of scope. +K3 P2 Probes Probes.cs 29 IDISP017 Prefer using +K3 P3 Probes Probes.cs 32 CA2000 Object created by 'File.OpenText(p)' is not disposed along all exception paths. Call System.IDisposable.Dispose on the object before all references to it are out of scope. +K3 P3 Probes Probes.cs 32 IDISP017 Prefer using +K3 P4 Probes Probes.cs 35 CA2000 Object created by 'OpenAsync(p)' is not disposed along all exception paths. Call System.IDisposable.Dispose on the object before all references to it are out of scope. +K3 P4 Probes Probes.cs 35 IDISP017 Prefer using +K3 P5 Probes Probes.cs 38 CA2000 Object created by 'new StreamReader(p)' is not disposed along all exception paths. Call System.IDisposable.Dispose on the object before all references to it are out of scope. +K3 P6 Probes Probes.cs 41 CA2000 Object created by 'new StreamReader(p)' is not disposed along all exception paths. Call System.IDisposable.Dispose on the object before all references to it are out of scope. +K3 P7 Probes Probes.cs 46 CA2000 Object created by 'new StreamReader(p)' is not disposed along all exception paths. Call System.IDisposable.Dispose on the object before all references to it are out of scope. +K3 P8 Probes Probes.cs 53 CA2000 Object created by 'new StreamReader(p)' is not disposed along all exception paths. Call System.IDisposable.Dispose on the object before all references to it are out of scope. +K3 _build rc=0 0 errors=0 results=15 +K4 P1 Probes Probes.cs 26 CA2000 Object created by 'new StreamReader(p)' is not disposed along all exception paths. Call System.IDisposable.Dispose on the object before all references to it are out of scope. +K4 P1 Probes Probes.cs 26 IDISP017 Prefer using +K4 P2 Probes Probes.cs 29 CA2000 Object created by 'Open(p)' is not disposed along all exception paths. Call System.IDisposable.Dispose on the object before all references to it are out of scope. +K4 P2 Probes Probes.cs 29 IDISP017 Prefer using +K4 P3 Probes Probes.cs 32 CA2000 Object created by 'File.OpenText(p)' is not disposed along all exception paths. Call System.IDisposable.Dispose on the object before all references to it are out of scope. +K4 P3 Probes Probes.cs 32 IDISP017 Prefer using +K4 P4 Probes Probes.cs 35 IDISP017 Prefer using +K4 P5 Probes Probes.cs 38 CA2000 Object created by 'new StreamReader(p)' is not disposed along all exception paths. Call System.IDisposable.Dispose on the object before all references to it are out of scope. +K4 P6 Probes Probes.cs 41 CA2000 Object created by 'new StreamReader(p)' is not disposed along all exception paths. Call System.IDisposable.Dispose on the object before all references to it are out of scope. +K4 P7 Probes Probes.cs 46 CA2000 Object created by 'new StreamReader(p)' is not disposed along all exception paths. Call System.IDisposable.Dispose on the object before all references to it are out of scope. +K4 P8 Probes Probes.cs 53 CA2000 Object created by 'new StreamReader(p)' is not disposed along all exception paths. Call System.IDisposable.Dispose on the object before all references to it are out of scope. +K4 _build rc=0 0 errors=0 results=14 +K5 P1 Probes Probes.cs 26 CA2000 Object created by 'new StreamReader(p)' is not disposed along all exception paths. Call System.IDisposable.Dispose on the object before all references to it are out of scope. +K5 P1 Probes Probes.cs 26 IDISP017 Prefer using +K5 P2 Probes Probes.cs 29 CA2000 Object created by 'Open(p)' is not disposed along all exception paths. Call System.IDisposable.Dispose on the object before all references to it are out of scope. +K5 P2 Probes Probes.cs 29 IDISP017 Prefer using +K5 P3 Probes Probes.cs 32 CA2000 Object created by 'File.OpenText(p)' is not disposed along all exception paths. Call System.IDisposable.Dispose on the object before all references to it are out of scope. +K5 P3 Probes Probes.cs 32 IDISP017 Prefer using +K5 P4 Probes Probes.cs 35 CA2000 Object created by 'OpenAsync(p)' is not disposed along all exception paths. Call System.IDisposable.Dispose on the object before all references to it are out of scope. +K5 P4 Probes Probes.cs 35 IDISP017 Prefer using +K5 P5 Probes Probes.cs 38 CA2000 Object created by 'new StreamReader(p)' is not disposed along all exception paths. Call System.IDisposable.Dispose on the object before all references to it are out of scope. +K5 P6 Probes Probes.cs 41 CA2000 Object created by 'new StreamReader(p)' is not disposed along all exception paths. Call System.IDisposable.Dispose on the object before all references to it are out of scope. +K5 P7 Probes Probes.cs 46 CA2000 Object created by 'new StreamReader(p)' is not disposed along all exception paths. Call System.IDisposable.Dispose on the object before all references to it are out of scope. +K5 P8 Probes Probes.cs 53 CA2000 Object created by 'new StreamReader(p)' is not disposed along all exception paths. Call System.IDisposable.Dispose on the object before all references to it are out of scope. +K5 _build rc=0 0 errors=0 results=15 diff --git a/docs/evidence/n1-competitor-gate-2026-10/data/run1_stubs_in_compilation.tsv b/docs/evidence/n1-competitor-gate-2026-10/data/run1_stubs_in_compilation.tsv new file mode 100644 index 00000000..cacc65b3 --- /dev/null +++ b/docs/evidence/n1-competitor-gate-2026-10/data/run1_stubs_in_compilation.tsv @@ -0,0 +1,13 @@ +config id class file line rule message +K1 _build rc=0 0 errors=0 results=19 +K2 W WBuggy W.cs 23 CA2000 Use recommended dispose pattern to ensure that object created by 'new ServiceBusClient(connectionString)' is disposed on all exception paths. If possible, wrap the creation within a 'using' statement or a 'using' declaration. Otherwise, use a try-finally pattern, with a dedicated local variable declared before the try region and an unconditional Dispose invocation on non-null value in the 'finally' region, say 'x?.Dispose()'. If the object is explicitly disposed within the try region or the dispose ownership is transfered to another object or method, assign 'null' to the local variable just after such an operation to prevent double dispose in 'finally'. +K2 _build rc=0 0 errors=0 results=22 +K2 control Control Control.cs 11 CA2000 Object created by 'new StreamReader(path)' is not disposed along all exception paths. Call System.IDisposable.Dispose on the object before all references to it are out of scope. +K3 W WBuggy W.cs 23 CA2000 Use recommended dispose pattern to ensure that object created by 'new ServiceBusClient(connectionString)' is disposed on all exception paths. If possible, wrap the creation within a 'using' statement or a 'using' declaration. Otherwise, use a try-finally pattern, with a dedicated local variable declared before the try region and an unconditional Dispose invocation on non-null value in the 'finally' region, say 'x?.Dispose()'. If the object is explicitly disposed within the try region or the dispose ownership is transfered to another object or method, assign 'null' to the local variable just after such an operation to prevent double dispose in 'finally'. +K3 _build rc=0 0 errors=0 results=22 +K3 control Control Control.cs 11 CA2000 Object created by 'new StreamReader(path)' is not disposed along all exception paths. Call System.IDisposable.Dispose on the object before all references to it are out of scope. +K4 W WBuggy W.cs 23 CA2000 Use recommended dispose pattern to ensure that object created by 'new ServiceBusClient(connectionString)' is disposed on all exception paths. If possible, wrap the creation within a 'using' statement or a 'using' declaration. Otherwise, use a try-finally pattern, with a dedicated local variable declared before the try region and an unconditional Dispose invocation on non-null value in the 'finally' region, say 'x?.Dispose()'. If the object is explicitly disposed within the try region or the dispose ownership is transfered to another object or method, assign 'null' to the local variable just after such an operation to prevent double dispose in 'finally'. +K4 _build rc=0 0 errors=0 results=22 +K4 control Control Control.cs 11 CA2000 Object created by 'new StreamReader(path)' is not disposed along all exception paths. Call System.IDisposable.Dispose on the object before all references to it are out of scope. +K5 _build rc=0 0 errors=0 results=18 +K5 control Control Control.cs 11 CA2000 Object created by 'new StreamReader(path)' is not disposed along all exception paths. Call System.IDisposable.Dispose on the object before all references to it are out of scope. diff --git a/docs/evidence/n1-competitor-gate-2026-10/data/run2_scored.tsv b/docs/evidence/n1-competitor-gate-2026-10/data/run2_scored.tsv new file mode 100644 index 00000000..0c0b6d6c --- /dev/null +++ b/docs/evidence/n1-competitor-gate-2026-10/data/run2_scored.tsv @@ -0,0 +1,13 @@ +config id class file line rule message +K1 _build rc=0 0 errors=0 results=16 +K2 W WBuggy W.cs 23 CA2000 Use recommended dispose pattern to ensure that object created by 'new ServiceBusClient(connectionString)' is disposed on all exception paths. If possible, wrap the creation within a 'using' statement or a 'using' declaration. Otherwise, use a try-finally pattern, with a dedicated local variable declared before the try region and an unconditional Dispose invocation on non-null value in the 'finally' region, say 'x?.Dispose()'. If the object is explicitly disposed within the try region or the dispose ownership is transfered to another object or method, assign 'null' to the local variable just after such an operation to prevent double dispose in 'finally'. +K2 _build rc=0 0 errors=0 results=19 +K2 control Control Control.cs 11 CA2000 Object created by 'new StreamReader(path)' is not disposed along all exception paths. Call System.IDisposable.Dispose on the object before all references to it are out of scope. +K3 W WBuggy W.cs 23 CA2000 Use recommended dispose pattern to ensure that object created by 'new ServiceBusClient(connectionString)' is disposed on all exception paths. If possible, wrap the creation within a 'using' statement or a 'using' declaration. Otherwise, use a try-finally pattern, with a dedicated local variable declared before the try region and an unconditional Dispose invocation on non-null value in the 'finally' region, say 'x?.Dispose()'. If the object is explicitly disposed within the try region or the dispose ownership is transfered to another object or method, assign 'null' to the local variable just after such an operation to prevent double dispose in 'finally'. +K3 _build rc=0 0 errors=0 results=19 +K3 control Control Control.cs 11 CA2000 Object created by 'new StreamReader(path)' is not disposed along all exception paths. Call System.IDisposable.Dispose on the object before all references to it are out of scope. +K4 W WBuggy W.cs 23 CA2000 Use recommended dispose pattern to ensure that object created by 'new ServiceBusClient(connectionString)' is disposed on all exception paths. If possible, wrap the creation within a 'using' statement or a 'using' declaration. Otherwise, use a try-finally pattern, with a dedicated local variable declared before the try region and an unconditional Dispose invocation on non-null value in the 'finally' region, say 'x?.Dispose()'. If the object is explicitly disposed within the try region or the dispose ownership is transfered to another object or method, assign 'null' to the local variable just after such an operation to prevent double dispose in 'finally'. +K4 _build rc=0 0 errors=0 results=19 +K4 control Control Control.cs 11 CA2000 Object created by 'new StreamReader(path)' is not disposed along all exception paths. Call System.IDisposable.Dispose on the object before all references to it are out of scope. +K5 _build rc=0 0 errors=0 results=15 +K5 control Control Control.cs 11 CA2000 Object created by 'new StreamReader(path)' is not disposed along all exception paths. Call System.IDisposable.Dispose on the object before all references to it are out of scope. diff --git a/docs/evidence/n1-competitor-gate-2026-10/probes/Ablation.cs b/docs/evidence/n1-competitor-gate-2026-10/probes/Ablation.cs new file mode 100644 index 00000000..5ef764ee --- /dev/null +++ b/docs/evidence/n1-competitor-gate-2026-10/probes/Ablation.cs @@ -0,0 +1,149 @@ +// Ablation of the three MISSED reproductions (not scored): each variant removes one feature of +// the buggy reproduction, to see which feature silences CA2000 (AllPaths). +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Runtime.CompilerServices; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.DependencyInjection; +using Repro; +using Stubs; + +namespace Ablation; + +// ---- X: ServiceProvider from BuildServiceProvider, passed to GetService/GetServices before the throw ---- +public sealed class XNoResolve +{ + private readonly IDbInitializer _db; + public XNoResolve(IDbInitializer db) { _db = db; } + + // X minus the GetService/GetServices calls on the provider + public async Task BuildAsync(CancellationToken ct = default) + { + var services = new ServiceCollection(); + var provider = services.BuildServiceProvider(validateScopes: false); // ACQ:X-noresolve + await _db.InitializeAsync(ct).ConfigureAwait(false); + return new PluginHost(provider); + } +} + +public sealed class XOnlyResolve +{ + // X with the GetService call kept and the await removed (the resolve is the only step before hand-over) + public static PluginHost Build() + { + var services = new ServiceCollection(); + var provider = services.BuildServiceProvider(validateScopes: false); // ACQ:X-onlyresolve + provider.GetRequiredService(); + return new PluginHost(provider); + } +} + +// ---- B: IChannel is an interface obtained from an interface method ---- +public sealed class ChannelClass : IAsyncDisposable, IDisposable +{ + public Task BasicQosAsync(uint size, ushort count, bool global, CancellationToken ct = default) => Task.CompletedTask; + public ValueTask DisposeAsync() => default; + public void Dispose() { } +} + +public interface IConnectionOfClass { Task CreateChannelAsync(CancellationToken ct = default); } + +public sealed class BClassChannel +{ + private IConnectionOfClass? _connection; + private readonly IStep2 _step; + private readonly ConcurrentDictionary _channels = new(); + public BClassChannel(IStep2 step, IConnectionOfClass c) { _step = step; _connection = c; } + + // B with a sealed class instead of the IChannel interface; throwing step kept opaque + public async IAsyncEnumerable ConsumeAsync([EnumeratorCancellation] CancellationToken ct = default) + { + ChannelClass ch = await _connection!.CreateChannelAsync(ct).ConfigureAwait(false); // ACQ:B-class + await _step.RunAsync().ConfigureAwait(false); + _channels["id"] = ch; + yield return "tag"; + } +} + +public sealed class BNoIterator +{ + private IConnection? _connection; + private readonly ConcurrentDictionary _channels = new(); + public BNoIterator(IConnection c) { _connection = c; } + + // B as a plain async method instead of an async iterator + public async Task ConsumeAsync(ushort prefetch, CancellationToken ct = default) + { + IChannel ch = await _connection!.CreateChannelAsync(ct).ConfigureAwait(false); // ACQ:B-noiter + await ch.BasicQosAsync(0, prefetch, false, ct).ConfigureAwait(false); + _channels["id"] = ch; + return "tag"; + } +} + +public sealed class BOpaqueStep +{ + private IConnection? _connection; + private readonly IStep2 _step; + private readonly ConcurrentDictionary _channels = new(); + public BOpaqueStep(IStep2 step, IConnection c) { _step = step; _connection = c; } + + // B with the throwing step moved off the channel (the channel is not the receiver of the throwing call) + public async Task ConsumeAsync(CancellationToken ct = default) + { + IChannel ch = await _connection!.CreateChannelAsync(ct).ConfigureAwait(false); // ACQ:B-opaque + await _step.RunAsync().ConfigureAwait(false); + _channels["id"] = ch; + return "tag"; + } +} + +// ---- R: the leaked client is the receiver of the throwing call, inside a catch-all retry loop ---- +public sealed class RNoCatchAll +{ + private readonly ConcurrentDictionary _clients = new(); + + // R without the swallowing catch(Exception) / retry loop + public async Task ConnectServerAsync(string name, Uri url, CancellationToken ct) + { + var transport = new HttpClientTransport(url); + var newClient = await McpClient.CreateAsync(transport, null, cancellationToken: ct); // ACQ:R-nocatch + var tools = await newClient.ListToolsAsync(cancellationToken: ct); + _clients[name] = newClient; + GC.KeepAlive(tools); + } +} + +public sealed class ROpaqueStep +{ + private readonly IStep2 _step; + private readonly ConcurrentDictionary _clients = new(); + public ROpaqueStep(IStep2 step) { _step = step; } + + // R without the catch-all and with the throwing step moved off the client + public async Task ConnectServerAsync(string name, Uri url, CancellationToken ct) + { + var transport = new HttpClientTransport(url); + var newClient = await McpClient.CreateAsync(transport, null, cancellationToken: ct); // ACQ:R-opaque + await _step.RunAsync(); + _clients[name] = newClient; + } +} + +public interface IStep2 { Task RunAsync(); } + +// ---- P9: is BuildServiceProvider's result tracked at all? (dispose after an opaque await) ---- +public sealed class P9Provider +{ + private readonly IStep2 _step; + public P9Provider(IStep2 step) { _step = step; } + + public async Task Run() + { + var provider = new ServiceCollection().BuildServiceProvider(); // ACQ:P9 + await _step.RunAsync(); + provider.Dispose(); + } +} diff --git a/docs/evidence/n1-competitor-gate-2026-10/probes/ExternalProbes.cs b/docs/evidence/n1-competitor-gate-2026-10/probes/ExternalProbes.cs new file mode 100644 index 00000000..c17b9509 --- /dev/null +++ b/docs/evidence/n1-competitor-gate-2026-10/probes/ExternalProbes.cs @@ -0,0 +1,40 @@ +// External-factory probes (not scored): acquire from an external method, opaque await, then dispose. +using System.Threading.Tasks; +using Stubs; + +namespace Ablation; + +public sealed class ExternalProbes +{ + private readonly IStep2 _step; + private readonly IReaderFactory _factory; + public ExternalProbes(IStep2 step, IReaderFactory factory) { _step = step; _factory = factory; } + + public async Task P10ExternalSync(string p) + { + var r = ExternalFactories.OpenSync(p); // ACQ:P10 + await _step.RunAsync(); + r.Dispose(); + } + + public async Task P11ExternalAsync(string p) + { + var r = await ExternalFactories.OpenAsync(p); // ACQ:P11 + await _step.RunAsync(); + r.Dispose(); + } + + public async Task P12ExternalStubType(string cs) + { + var c = ExternalFactories.CreateClient(cs); // ACQ:P12 + await _step.RunAsync(); + await c.DisposeAsync(); + } + + public async Task P13InterfaceMethod(string p) + { + var r = _factory.Open(p); // ACQ:P13 + await _step.RunAsync(); + r.Dispose(); + } +} diff --git a/docs/evidence/n1-competitor-gate-2026-10/probes/ProbeExternals.cs b/docs/evidence/n1-competitor-gate-2026-10/probes/ProbeExternals.cs new file mode 100644 index 00000000..2d536ac1 --- /dev/null +++ b/docs/evidence/n1-competitor-gate-2026-10/probes/ProbeExternals.cs @@ -0,0 +1,14 @@ +// Compiled into the stub assembly for the ablation run only (external to the analysed compilation). +using System.IO; +using System.Threading.Tasks; + +namespace Stubs; + +public static class ExternalFactories +{ + public static StreamReader OpenSync(string p) => new StreamReader(p); + public static Task OpenAsync(string p) => Task.FromResult(new StreamReader(p)); + public static ServiceBusClient CreateClient(string cs) => new ServiceBusClient(cs); +} + +public interface IReaderFactory { StreamReader Open(string p); } diff --git a/docs/evidence/n1-competitor-gate-2026-10/probes/Probes.cs b/docs/evidence/n1-competitor-gate-2026-10/probes/Probes.cs new file mode 100644 index 00000000..440af81c --- /dev/null +++ b/docs/evidence/n1-competitor-gate-2026-10/probes/Probes.cs @@ -0,0 +1,63 @@ +// Explanatory probes for the MISSED verdicts (not scored). Same leak each time: acquire, a call that +// can throw, then hand over or dispose. Only the acquisition form or the enclosing construct changes. +using System; +using System.Collections.Generic; +using System.IO; +using System.Runtime.CompilerServices; +using System.Threading; +using System.Threading.Tasks; + +namespace Probes; + +public sealed class Probes +{ + private readonly Dictionary _store = new(); + + private static StreamReader Open(string p) => new StreamReader(p); + private static Task OpenAsync(string p) => Task.FromResult(new StreamReader(p)); + // the throwing step is opaque (an interface call), as in the reproductions; an empty local + // method would let CA2000's interprocedural analysis prove it cannot throw + private readonly IStep _step; + public Probes(IStep step) { _step = step; } + private Task MayThrowAsync() => _step.RunAsync(); + private void MayThrow() => _step.Run(); + + // P1 `new`, sync throw, dispose after: the harness control shape + public void P1New(string p) { var r = new StreamReader(p); MayThrow(); r.Dispose(); } // ACQ:P1 + + // P2 synchronous factory method instead of `new` + public void P2Factory(string p) { var r = Open(p); MayThrow(); r.Dispose(); } // ACQ:P2 + + // P3 BCL factory (File.OpenText) + public void P3BclFactory(string p) { var r = File.OpenText(p); MayThrow(); r.Dispose(); } // ACQ:P3 + + // P4 awaited async factory + public async Task P4AsyncFactory(string p) { var r = await OpenAsync(p); await MayThrowAsync(); r.Dispose(); } // ACQ:P4 + + // P5 `new`, awaited throw, then stored into a dictionary field (R/B hand-over, with `new`) + public async Task P5NewThenStore(string p) { var r = new StreamReader(p); await MayThrowAsync(); _store["k"] = r; } // ACQ:P5 + + // P6 `new`, awaited throw, then wrapped and returned (X hand-over, with `new`) + public async Task P6NewThenWrap(string p) { var r = new StreamReader(p); await MayThrowAsync(); return new Wrapper(r); } // ACQ:P6 + + // P7 `new` inside an async iterator, awaited throw, then dispose (B construct, with `new`) + public async IAsyncEnumerable P7Iterator(string p, [EnumeratorCancellation] CancellationToken ct = default) + { + var r = new StreamReader(p); // ACQ:P7 + await MayThrowAsync(); + r.Dispose(); + yield return 1; + } + + // P8 `new`, awaited throw, return (W hand-over without the loop) + public async Task P8NewThenReturn(string p) { var r = new StreamReader(p); await MayThrowAsync(); return r; } // ACQ:P8 +} + +public interface IStep { void Run(); Task RunAsync(); } + +public sealed class Wrapper : IDisposable +{ + private readonly StreamReader _r; + public Wrapper(StreamReader r) { _r = r; } + public void Dispose() => _r.Dispose(); +} diff --git a/docs/evidence/n1-competitor-gate-2026-10/repro/B.cs b/docs/evidence/n1-competitor-gate-2026-10/repro/B.cs new file mode 100644 index 00000000..533f4b42 --- /dev/null +++ b/docs/evidence/n1-competitor-gate-2026-10/repro/B.cs @@ -0,0 +1,83 @@ +// B: Wizard-Software/BareWire#73, RabbitMqTransportAdapter.ConsumeAsync (an async iterator). +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Runtime.CompilerServices; +using System.Threading; +using System.Threading.Tasks; +using Stubs; + +namespace Repro; + +public sealed class BBuggy +{ + private IConnection? _connection; + private readonly ConcurrentDictionary _activeConsumerChannels = new(); + + public async IAsyncEnumerable ConsumeAsync(string endpointName, ushort prefetch, + [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + IChannel consumerChannel = await _connection!.CreateChannelAsync(cancellationToken).ConfigureAwait(false); // ACQ:B + + await consumerChannel.BasicQosAsync(prefetchSize: 0, prefetchCount: prefetch, global: false, + cancellationToken: cancellationToken).ConfigureAwait(false); + + string consumerChannelId = Guid.NewGuid().ToString("N"); + _activeConsumerChannels[consumerChannelId] = consumerChannel; + + string assignedTag; + try + { + assignedTag = await consumerChannel.BasicConsumeAsync(endpointName, autoAck: false, cancellationToken) + .ConfigureAwait(false); + } + catch + { + _activeConsumerChannels.TryRemove(consumerChannelId, out _); + throw; + } + + yield return assignedTag; + } +} + +public sealed class BFixed +{ + private IConnection? _connection; + private readonly ConcurrentDictionary _activeConsumerChannels = new(); + + public async IAsyncEnumerable ConsumeAsync(string endpointName, ushort prefetch, + [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + IChannel consumerChannel = await _connection!.CreateChannelAsync(cancellationToken).ConfigureAwait(false); // ACQ:B + + string consumerChannelId = Guid.NewGuid().ToString("N"); + _activeConsumerChannels[consumerChannelId] = consumerChannel; + + string assignedTag; + try + { + await consumerChannel.BasicQosAsync(prefetchSize: 0, prefetchCount: prefetch, global: false, + cancellationToken: cancellationToken).ConfigureAwait(false); + + assignedTag = await consumerChannel.BasicConsumeAsync(endpointName, autoAck: false, cancellationToken) + .ConfigureAwait(false); + } + catch + { + await ReleaseConsumerChannelSafeAsync(consumerChannelId).ConfigureAwait(false); + throw; + } + + yield return assignedTag; + } + + private async Task ReleaseConsumerChannelSafeAsync(string channelId) + { + if (_activeConsumerChannels.TryRemove(channelId, out IChannel? channel)) + { + try { await channel.CloseAsync().ConfigureAwait(false); } catch { } + await channel.DisposeAsync().ConfigureAwait(false); + } + } +} diff --git a/docs/evidence/n1-competitor-gate-2026-10/repro/Control.cs b/docs/evidence/n1-competitor-gate-2026-10/repro/Control.cs new file mode 100644 index 00000000..7d05e849 --- /dev/null +++ b/docs/evidence/n1-competitor-gate-2026-10/repro/Control.cs @@ -0,0 +1,24 @@ +// Harness control (00-preregistration.md): CA2000 must report ControlLeakOnThrow under AllPaths (K2) +// and not under the default kind (K1); ControlUsing must stay silent everywhere. +using System.IO; + +namespace Repro; + +public static class Control +{ + public static string ControlLeakOnThrow(string path) + { + var reader = new StreamReader(path); // ACQ:control + var text = MayThrow(reader.ReadLine()); + reader.Dispose(); + return text; + } + + public static string ControlUsing(string path) + { + using var reader = new StreamReader(path); // ACQ:control-using + return MayThrow(reader.ReadLine()); + } + + private static string MayThrow(string? s) => s ?? throw new InvalidDataException(); +} diff --git a/docs/evidence/n1-competitor-gate-2026-10/repro/N1Repro.csproj b/docs/evidence/n1-competitor-gate-2026-10/repro/N1Repro.csproj new file mode 100644 index 00000000..5efbac82 --- /dev/null +++ b/docs/evidence/n1-competitor-gate-2026-10/repro/N1Repro.csproj @@ -0,0 +1,21 @@ + + + + net10.0 + Library + enable + disable + true + false + $(NoWarn);CS1591 + $(N1ErrorLog) + + + + + + + + diff --git a/docs/evidence/n1-competitor-gate-2026-10/repro/R.cs b/docs/evidence/n1-competitor-gate-2026-10/repro/R.cs new file mode 100644 index 00000000..1f112ddf --- /dev/null +++ b/docs/evidence/n1-competitor-gate-2026-10/repro/R.cs @@ -0,0 +1,104 @@ +// R: MarimerLLC/rockbot#638, McpBridgeService.ConnectServerAsync. +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Stubs; + +namespace Repro; + +public sealed class RBuggy +{ + private readonly ConcurrentDictionary _clients = new(); + private readonly ConcurrentDictionary> _serverTools = new(); + + public async Task ConnectServerAsync(string name, Uri url, int maxAttempts, CancellationToken ct) + { + for (var attempt = 1; attempt <= maxAttempts; attempt++) + { + try + { + var transport = new HttpClientTransport(url); + var newClient = await McpClient.CreateAsync(transport, null, cancellationToken: ct); // ACQ:R + + var tools = await newClient.ListToolsAsync(cancellationToken: ct); + var prompts = await newClient.ListPromptsAsync(ct); + + if (_clients.TryRemove(name, out var oldClient)) + { + try { await oldClient.DisposeAsync(); } + catch { /* Best-effort cleanup */ } + } + + _clients[name] = newClient; + _serverTools[name] = tools; + return; + } + catch (OperationCanceledException) when (ct.IsCancellationRequested) + { + throw; + } + catch (Exception) + { + if (attempt == maxAttempts) return; + await Task.Delay(100 * attempt, ct); + } + } + } +} + +public sealed class RFixed +{ + private readonly ConcurrentDictionary _clients = new(); + private readonly ConcurrentDictionary> _serverTools = new(); + + public async Task ConnectServerAsync(string name, Uri url, int maxAttempts, CancellationToken ct) + { + for (var attempt = 1; attempt <= maxAttempts; attempt++) + { + McpClient? newClient = null; + var published = false; + try + { + var transport = new HttpClientTransport(url); + newClient = await McpClient.CreateAsync(transport, null, cancellationToken: ct); // ACQ:R + + var tools = await newClient.ListToolsAsync(cancellationToken: ct); + var prompts = await newClient.ListPromptsAsync(ct); + + if (_clients.TryRemove(name, out var oldClient)) + { + await DisposeQuietlyAsync(oldClient); + } + + _clients[name] = newClient; + published = true; + _serverTools[name] = tools; + return; + } + catch (OperationCanceledException) when (ct.IsCancellationRequested) + { + throw; + } + catch (Exception) + { + if (attempt == maxAttempts) return; + await Task.Delay(100 * attempt, ct); + } + finally + { + if (!published && newClient is not null) + { + await DisposeQuietlyAsync(newClient); + } + } + } + } + + private static async Task DisposeQuietlyAsync(IAsyncDisposable disposable) + { + try { await disposable.DisposeAsync(); } + catch { /* Best-effort cleanup */ } + } +} diff --git a/docs/evidence/n1-competitor-gate-2026-10/repro/W.cs b/docs/evidence/n1-competitor-gate-2026-10/repro/W.cs new file mode 100644 index 00000000..023837eb --- /dev/null +++ b/docs/evidence/n1-competitor-gate-2026-10/repro/W.cs @@ -0,0 +1,72 @@ +// W: whizbang-lib/whizbang#1204, AzureServiceBusConnectionRetry.CreateClientWithRetryAsync. +using System; +using System.Threading; +using System.Threading.Tasks; +using Stubs; + +namespace Repro; + +public sealed class WBuggy +{ + internal Func VerifyNamespaceReachableAsync { get; init; } = (_, _) => Task.CompletedTask; + + public async Task CreateClientWithRetryAsync(string connectionString, CancellationToken cancellationToken = default) + { + var currentDelay = TimeSpan.FromSeconds(1); + var attempt = 0; + while (true) + { + attempt++; + cancellationToken.ThrowIfCancellationRequested(); + try + { + var client = new ServiceBusClient(connectionString); // ACQ:W + await VerifyNamespaceReachableAsync(connectionString, cancellationToken).ConfigureAwait(false); + return client; + } + catch (Exception ex) when (ex is ServiceBusException || ex is TimeoutException) + { + if (attempt > 5) throw; + await Task.Delay(currentDelay, cancellationToken).ConfigureAwait(false); + } + } + } +} + +public sealed class WFixed +{ + internal Func VerifyNamespaceReachableAsync { get; init; } = (_, _) => Task.CompletedTask; + + internal Func CreateClient { get; init; } = + static connectionString => new ServiceBusClient(connectionString); + + public async Task CreateClientWithRetryAsync(string connectionString, CancellationToken cancellationToken = default) + { + var currentDelay = TimeSpan.FromSeconds(1); + var attempt = 0; + while (true) + { + attempt++; + cancellationToken.ThrowIfCancellationRequested(); + try + { + var client = CreateClient(connectionString); // ACQ:W + try + { + await VerifyNamespaceReachableAsync(connectionString, cancellationToken).ConfigureAwait(false); + } + catch + { + await client.DisposeAsync().ConfigureAwait(false); + throw; + } + return client; + } + catch (Exception ex) when (ex is ServiceBusException || ex is TimeoutException) + { + if (attempt > 5) throw; + await Task.Delay(currentDelay, cancellationToken).ConfigureAwait(false); + } + } + } +} diff --git a/docs/evidence/n1-competitor-gate-2026-10/repro/X.cs b/docs/evidence/n1-competitor-gate-2026-10/repro/X.cs new file mode 100644 index 00000000..481f9d2d --- /dev/null +++ b/docs/evidence/n1-competitor-gate-2026-10/repro/X.cs @@ -0,0 +1,84 @@ +// X: NexusFFXIV/NexusKit#34, PluginHostBuilder.BuildAsync. ServiceCollection/ServiceProvider are real. +using System; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.DependencyInjection; + +namespace Repro; + +public interface IDbInitializer { Task InitializeAsync(CancellationToken ct); } +public interface IBackgroundService { } + +public sealed class PluginHost : IAsyncDisposable +{ + private readonly ServiceProvider _provider; + public PluginHost(ServiceProvider provider) { _provider = provider; } + public ValueTask DisposeAsync() => _provider.DisposeAsync(); +} + +public sealed class XBuggy +{ + private readonly Action[] _configure = Array.Empty>(); + + public async Task BuildAsync(CancellationToken ct = default) + { + var services = new ServiceCollection(); + foreach (var configure in _configure) + configure(services); + + var provider = services.BuildServiceProvider(validateScopes: false); // ACQ:X + + var db = provider.GetService(); + if (db is not null) + { + await db.InitializeAsync(ct).ConfigureAwait(false); + } + + foreach (var _ in provider.GetServices()) + { + } + + return new PluginHost(provider); + } +} + +public sealed class XFixed +{ + private readonly Action[] _configure = Array.Empty>(); + + public async Task BuildAsync(CancellationToken ct = default) + { + var services = new ServiceCollection(); + foreach (var configure in _configure) + configure(services); + + var provider = services.BuildServiceProvider(validateScopes: false); // ACQ:X + + try + { + var db = provider.GetService(); + if (db is not null) + { + await db.InitializeAsync(ct).ConfigureAwait(false); + } + + foreach (var _ in provider.GetServices()) + { + } + + return new PluginHost(provider); + } + catch (Exception) + { + try + { + await new PluginHost(provider).DisposeAsync().ConfigureAwait(false); + } + catch (Exception) + { + // swallowed, as in the fix + } + throw; + } + } +} diff --git a/docs/evidence/n1-competitor-gate-2026-10/scripts/run_gate.py b/docs/evidence/n1-competitor-gate-2026-10/scripts/run_gate.py new file mode 100644 index 00000000..e317581d --- /dev/null +++ b/docs/evidence/n1-competitor-gate-2026-10/scripts/run_gate.py @@ -0,0 +1,126 @@ +"""N1 competitor gate: build the reproductions under K1-K5 and collect every diagnostic on an +acquisition line (`// ACQ:`), per class (Buggy / Fixed / Control). + +usage: python -I run_gate.py --repro --work --nuget --out +""" + +from __future__ import annotations + +import argparse +import json +import os +import re +import shutil +import subprocess + +BASE = [ + "root = true", + "", + "[*.cs]", + "dotnet_analyzer_diagnostic.severity = warning", + "dotnet_diagnostic.CA2000.severity = warning", +] +CONFIGS = { + "K1": [], + "K2": ["dotnet_code_quality.CA2000.dispose_analysis_kind = AllPaths"], + "K3": [ + "dotnet_code_quality.CA2000.dispose_analysis_kind = AllPaths", + "dotnet_code_quality.CA2000.dispose_ownership_transfer_at_constructor = true", + ], + "K4": [ + "dotnet_code_quality.CA2000.dispose_analysis_kind = AllPaths", + "dotnet_code_quality.CA2000.dispose_ownership_transfer_at_method_call = true", + ], + "K5": ["dotnet_code_quality.CA2000.dispose_analysis_kind = AllPathsOnlyNotDisposed"], +} +ACQ = re.compile(r"//\s*ACQ:([\w-]+)") +CLASS = re.compile(r"^\s*public (?:sealed |static )?class (\w+)") + + +def acquisition_lines(repro: str) -> dict[tuple[str, int], tuple[str, str]]: + """(file, line) -> (marker id, enclosing class).""" + res = {} + for f in sorted(os.listdir(repro)): + if not f.endswith(".cs"): + continue + cls = "?" + for i, line in enumerate(open(os.path.join(repro, f), encoding="utf-8"), 1): + if m := CLASS.match(line): + cls = m.group(1) + if m := ACQ.search(line): + res[(f, i)] = (m.group(1), cls) + return res + + +def main() -> int: + ap = argparse.ArgumentParser() + ap.add_argument("--repro", required=True) + ap.add_argument("--work", required=True) + ap.add_argument("--nuget", required=True) + ap.add_argument("--out", required=True) + a = ap.parse_args() + acq = acquisition_lines(a.repro) + os.makedirs(a.out, exist_ok=True) + # the stub assembly sits next to each config's copy, as ../stubs (see N1Repro.csproj) + stubs = os.path.join(os.path.dirname(os.path.abspath(a.repro)), "stubs") + shutil.rmtree(os.path.join(a.work, "stubs"), ignore_errors=True) + shutil.copytree(stubs, os.path.join(a.work, "stubs")) + rows = [] + for k, extra in CONFIGS.items(): + work = os.path.join(a.work, k) + shutil.rmtree(work, ignore_errors=True) + shutil.copytree(a.repro, work) + with open(os.path.join(work, ".editorconfig"), "w", encoding="utf-8", newline="\n") as f: + f.write("\n".join(BASE + extra) + "\n") + sarif = os.path.join(work, "out.sarif") + cmd = [ + "dotnet", + "build", + "-c", + "Release", + "-nologo", + "-v", + "q", + f"-p:NuGetRoot={a.nuget}", + f"-p:N1ErrorLog={sarif}", + "--configfile", + os.path.join(a.work, "nuget.config"), + ] + env = dict(os.environ, DOTNET_CLI_UI_LANGUAGE="en") # English diagnostic messages + p = subprocess.run( + cmd, cwd=work, env=env, capture_output=True, text=True, encoding="utf-8", + errors="replace", + ) + log = json.load(open(sarif, encoding="utf-8")) + n_err, n_all = 0, 0 + for run in log.get("runs", []): + for r in run.get("results", []): + n_all += 1 + if r.get("level") == "error": + n_err += 1 + for loc in r.get("locations", []): + # SARIF 2.1 physicalLocation, or the 1.0 resultFile csc writes by default + pl = loc.get("physicalLocation") or loc.get("resultFile") or {} + art = pl.get("artifactLocation", {}).get("uri", "") or pl.get("uri", "") + uri = os.path.basename(art) + line = pl.get("region", {}).get("startLine", 0) + if (uri, line) in acq: + mid, cls = acq[(uri, line)] + msg = r.get("message", {}) + text = msg.get("text", "") if isinstance(msg, dict) else str(msg) + rows.append( + (k, mid, cls, uri, line, r.get("ruleId", ""), text.replace("\t", " ")) + ) + rows.append( + (k, "_build", f"rc={p.returncode}", "", n_err, f"errors={n_err}", f"results={n_all}") + ) + with open(os.path.join(a.out, "acq_diagnostics.tsv"), "w", encoding="utf-8", newline="\n") as f: + f.write("config\tid\tclass\tfile\tline\trule\tmessage\n") + for r in sorted(set(rows)): + f.write("\t".join(map(str, r)) + "\n") + print(open(os.path.join(a.out, "acq_diagnostics.tsv"), encoding="utf-8").read()) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/docs/evidence/n1-competitor-gate-2026-10/stubs/N1Stubs.csproj b/docs/evidence/n1-competitor-gate-2026-10/stubs/N1Stubs.csproj new file mode 100644 index 00000000..817ee654 --- /dev/null +++ b/docs/evidence/n1-competitor-gate-2026-10/stubs/N1Stubs.csproj @@ -0,0 +1,11 @@ + + + + net10.0 + enable + disable + false + false + + diff --git a/docs/evidence/n1-competitor-gate-2026-10/stubs/Stubs.cs b/docs/evidence/n1-competitor-gate-2026-10/stubs/Stubs.cs new file mode 100644 index 00000000..c4de3706 --- /dev/null +++ b/docs/evidence/n1-competitor-gate-2026-10/stubs/Stubs.cs @@ -0,0 +1,47 @@ +// Stubs for third-party types used by the N1 reproductions. Each keeps the real type's disposable +// interfaces and the members the reproduced method calls; bodies are irrelevant to the analyzers. +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +namespace Stubs; + +// Azure.Messaging.ServiceBus.ServiceBusClient : IAsyncDisposable +public class ServiceBusClient : IAsyncDisposable +{ + public ServiceBusClient(string connectionString) { } + public ValueTask DisposeAsync() => default; +} + +public class ServiceBusException : Exception { } + +// ModelContextProtocol.Client.McpClient : IAsyncDisposable, created by an async factory +public abstract class McpClient : IAsyncDisposable +{ + public static Task CreateAsync(IClientTransport transport, object? options = null, + CancellationToken cancellationToken = default) => throw new NotImplementedException(); + public abstract Task> ListToolsAsync(CancellationToken cancellationToken = default); + public abstract Task> ListPromptsAsync(CancellationToken cancellationToken = default); + public abstract ValueTask DisposeAsync(); +} + +public interface IClientTransport { } + +public sealed class HttpClientTransport : IClientTransport +{ + public HttpClientTransport(Uri endpoint) { } +} + +// RabbitMQ.Client 7: IChannel : IAsyncDisposable, IDisposable; created by IConnection.CreateChannelAsync +public interface IChannel : IAsyncDisposable, IDisposable +{ + Task BasicQosAsync(uint prefetchSize, ushort prefetchCount, bool global, CancellationToken cancellationToken = default); + Task BasicConsumeAsync(string queue, bool autoAck, CancellationToken cancellationToken = default); + Task CloseAsync(CancellationToken cancellationToken = default); +} + +public interface IConnection : IAsyncDisposable, IDisposable +{ + Task CreateChannelAsync(CancellationToken cancellationToken = default); +}