Skip to content

fix(extractor): confine the empty-Dispose exemption to enumerators — source emptiness ≠ runtime no-op (Closes #238)#240

Merged
PhysShell merged 4 commits into
mainfrom
claude/plans-tasks-review-u77dfs
Jul 12, 2026
Merged

fix(extractor): confine the empty-Dispose exemption to enumerators — source emptiness ≠ runtime no-op (Closes #238)#240
PhysShell merged 4 commits into
mainfrom
claude/plans-tasks-review-u77dfs

Conversation

@PhysShell

@PhysShell PhysShell commented Jul 11, 2026

Copy link
Copy Markdown
Owner

Что и зачем

Хотфикс issue #238 — soundness-регрессии от #225/PR #233, пойманной перезамером (PR #235): XLWorkbook.Dispose() пуст только в исходнике (Janitor.Fody вплетает очистку на компиляции), а HasEmptyDisposeBody верил source-дереву → 263 тихо проглоченные находки на ClosedXML. Гейт сужен до одного мотивирующего класса — IEnumerator<T> (единственный интерфейс, который принуждает реализовать IDisposable); всё остальное сохраняет честный warning. Плюс defense-in-depth: FodyWeavers.xml над исходником (или у владеющего проекта — для linked-источников) отключает льготу даже для энумератора. Explicit-interface Dispose/DisposeAsync распознаются. По двум раундам ревью закрыты все soundness-щели: наследованный IAsyncDisposable (через AllInterfaces), наследованный bare DisposeAsync (через обход base chain), sticky static-registry (Clear() на старте), fail-open weaver-walk (File.GetAttributes вместо File.Exists).

Тип изменения

  • fix — исправление бага (soundness)

Как проверено

Поправка к acceptance issue

Исходный acceptance «−5 Slice-точек, ровно 263 назад» оказался сам несаунд: ClosedXML везёт свой FodyWeavers.xml/<Janitor/> (ClosedXML/FodyWeavers.xml, над Slice.cs и XLWorkbook.cs), поэтому kill-switch корректно снимает льготу со всего ClosedXML — Slice-энумераторы остаются флагнутыми (они среди 2 восстановленных enumerator-локалей, а не удалённых): Janitor-вплетаемый Dispose нельзя доказать no-op из исходника. Саунд-исход — «ClosedXML полностью восстановлен до состояния до #233, ничего не проглочено», а не «−5». Расхождение 121-vs-263 — новее HEAD + только --flow-locals + loose-file resolution против полного own-check sweep'а; несущее свойство (restore-all, remove-none) держится.

Связанные issue

Closes #238. Refs #225, PR #233, PR #235.

Чеклист

🤖 Generated with Claude Code

https://claude.ai/code/session_01LsWw4Ay8KLTHFom1HvRu3U

…source emptiness is not a runtime no-op (#238)

The post-merge re-measure caught a soundness regression from #225/PR #233:
ClosedXML came out -265 instead of the expected -5. 263 of those were real
findings silently swallowed — XLWorkbook.Dispose() is empty IN SOURCE only,
Janitor.Fody weaves the actual cleanup into it at build time, and
HasEmptyDisposeBody trusted the source tree.

Three changes, all in the same gate:

- SOUNDNESS (the 263): the exemption now applies ONLY to types implementing
  IEnumerator/IEnumerator<T> — the shape that motivated #225, where an empty
  Dispose is an idiomatic interface stub, not a weaving target. Everything
  else keeps the honest warning; source-level analysis cannot prove the
  absence of IL weaving for arbitrary types.
- Defense in depth: a FodyWeavers.xml anywhere above the type's source file
  disables the exemption even for a perfect enumerator shape (weaved/ fixture
  + CI assertion). Unlocatable source -> conservatively no exemption.
- COVERAGE (the missing 3 of -5): `void IDisposable.Dispose() { }` (explicit
  interface implementation — the actual Slice.Enumerator spelling) is now
  recognized as an empty Dispose; the DisposeAsync refusal likewise matches
  explicit `IAsyncDisposable.DisposeAsync`.

Sample rework: ExplicitDisposeEnumerator ('x', silent) pins the coverage fix;
ScratchReader — a NON-enumerator with an empty source Dispose, the XLWorkbook
shape — flips from silent to a FLAGGED control on both detector paths; flow
count assertion 4 -> 5; new weaved/WeavedEmptyDispose.cs fixture pins the
kill-switch.

Verified with the real extractor (.NET 8): flat flags s/lr/ar, flow flags
exactly s/r/lr/d/ar with e/x silent, the weaved enumerator stays flagged;
full-sample-set diff vs main (minus the reworked sample) is byte-identical.
Gates: run_tests 276/276, ruff, mypy green. Post-merge acceptance: re-measure
ClosedXML — delta vs the pre-#233 baseline must be exactly -5 with all 263
findings restored.

Closes #238

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsWw4Ay8KLTHFom1HvRu3U
Comment thread frontend/roslyn/samples/weaved/WeavedEmptyDispose.cs
@coderabbitai

coderabbitai Bot commented Jul 11, 2026

Copy link
Copy Markdown

Warning

Review limit reached

@PhysShell, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 34 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 8e9462e7-153c-42fa-8119-fb8e25c200a2

📥 Commits

Reviewing files that changed from the base of the PR and between b1ee961 and 40756e3.

📒 Files selected for processing (8)
  • .github/workflows/ci.yml
  • frontend/roslyn/OwnSharp.Extractor/Program.cs
  • frontend/roslyn/samples/EmptyDisposeSample.cs
  • frontend/roslyn/samples/weaved-linked/Proj/FodyWeavers.xml
  • frontend/roslyn/samples/weaved-linked/Proj/Linked.csproj
  • frontend/roslyn/samples/weaved-linked/Shared/SharedEnumerator.cs
  • frontend/roslyn/samples/weaved/FodyWeavers.xml
  • frontend/roslyn/samples/weaved/WeavedEmptyDispose.cs
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch claude/plans-tasks-review-u77dfs

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 0c1c2f3bab

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread frontend/roslyn/OwnSharp.Extractor/Program.cs Outdated
…rces (Codex on #240)

A Fody-enabled project can compile a linked source that lives OUTSIDE its
directory (<Compile Include="../Shared/Foo.cs">) — the ancestor walk from
the FILE path never sees the FodyWeavers.xml sitting next to the .csproj,
so a linked empty-Dispose enumerator kept the exemption the kill-switch
exists to withhold.

ProjectCsFiles now records every file a weaver-enabled project compiles
(Program.WeaverOwnedFiles, filled while expanding <Compile> items);
SourceTreeHasWeaverConfig consults that registry before the ancestor walk.
Directory/bare-file scans carry no project info and keep today's honest
per-path behaviour.

Pinned by the weaved-linked/ fixture (Proj/Linked.csproj + FodyWeavers.xml
linking ../Shared/SharedEnumerator.cs) + CI assertion: analysed THROUGH the
.csproj the local is flagged; verified locally both ways, full-sample flat
and weaved outputs byte-identical to the pre-fix run.

Refs #238

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsWw4Ay8KLTHFom1HvRu3U
@github-actions

Copy link
Copy Markdown

@coderabbitai review

…spose gate (review)

Follow-up review on #240 found the hotfix still left soundness gaps. All fixed:

- Inherited IAsyncDisposable (P1): the async-cleanup check only looked at
  members declared on the type, so an enumerator with an empty sync Dispose
  whose real DisposeAsync lives on a BASE (that base not implementing
  IDisposable) was silently exempted — the exact #238 false-negative class.
  Now ANY IAsyncDisposable in AllInterfaces (inherited included) disqualifies.
  New flagged control InheritedAsyncEnumerator ('ia'), flow count 5 -> 6.

- Over-broad enumerator gate (P2): non-generic System.Collections.IEnumerator
  does NOT extend IDisposable, so pairing them is author-chosen, not forced —
  the motivating proof only covers IEnumerator<T>. Narrowed to the generic
  interface, matched by name/arity/namespace (the well-known-type SpecialType
  is unset in the source-only extraction, so a SpecialType check would wrongly
  reject legitimate generic enumerators).

- Sticky static registry (P2): WeaverOwnedFiles was never reset, so a file
  seen as weaver-owned leaked into a later in-process invocation (sticky FP).
  Cleared at program start next to the existing BodyThrowEdges reset.

- Fail-open weaver walk (P3): File.Exists returns false on access-denied,
  swallowing the case the comment claimed to fail closed on. Switched to
  File.GetAttributes, which throws on a permission error while still reporting
  a plain not-found — genuinely fail-closed now.

Verified with the real extractor: flow flags exactly s,r,lr,d,ar,ia with the
two generic enumerators (e,x) silent; weaved + weaved-linked fixtures flagged;
full-sample-set diff vs main byte-identical. run_tests 276/276, ruff, mypy green.

Refs #238

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsWw4Ay8KLTHFom1HvRu3U
// owns a real DisposeAsync -> an inherited IAsyncDisposable must still leak.
public int LeakInheritedAsync()
{
var ia = new InheritedAsyncEnumerator();

Copy link
Copy Markdown
Owner Author

Thanks for the review — every point was correct. All four code findings fixed in 0d1411c, and the acceptance gap (finding 4) is now closed with a real-source remeasure below.

1. Inherited IAsyncDisposable (P1) — real hole, fixed. The async check only looked at directly-declared members, so an enumerator whose real DisposeAsync lives on a base (that base not implementing IDisposable) was exempted. Now any IAsyncDisposable in AllInterfaces (inherited included) disqualifies. New flagged control InheritedAsyncEnumerator (ia), flow count 5→6.

2. Over-broad enumerator gate (P2) — fixed, narrowed to IEnumerator<T> only (non-generic IEnumerator doesn't extend IDisposable). Matched by name/arity/namespace, not SpecialType: in the source-only extraction the well-known-type SpecialType is often unset, so a SpecialType check silently rejected legitimate generic enumerators (caught it empirically — e/x started leaking before I switched to the name match).

3. Sticky WeaverOwnedFiles (P2) — fixed, .Clear() at program start next to the existing BodyThrowEdges reset. (Path comparison left Ordinal: both sides derive from Path.GetFullPath within a single run, so it's internally consistent; the cross-run leak was the actual bug.)

4. File.Exists fail-open vs comment's fail-closed (P3) — fixed for real, not just the comment. Switched the ancestor walk to File.GetAttributes, which throws on access-denied (where File.Exists returns false) while still reporting a plain not-found — so an unreadable ancestor now genuinely fails closed.

5. Acceptance / ClosedXML remeasure — you were right that deferring the main regression test was backwards. I ran it on real ClosedXML source (HEAD 4e89dce), branch extractor vs main (#233), --flow-locals, library + ClosedXML.Examples compiled together so XLWorkbook.Dispose resolves:

OWN001
main (#233) 3
this branch (#240) 124
restored 121 (79 workbook + 39 wb + 1 wbSource + 2 enumerator locals)
removed 0

The restored findings are exactly the swallowed class — XLWorkbook-consuming locals across ClosedXML.Examples. Zero removed: a soundness fix should only ever add findings back.

One correction to the acceptance criterion itself: it expected "−5 Slice points removed". That's actually unsound here — ClosedXML ships its own FodyWeavers.xml with <Janitor/> (verified at ClosedXML/FodyWeavers.xml, above both Slice.cs and XLWorkbook.cs). So the defense-in-depth kill-switch correctly withholds the empty-Dispose exemption for all of ClosedXML — the Slice.Enumerator locals stay flagged too (they're among the 2 enumerator locals restored, not removed), because a Janitor-wovable Dispose cannot be proven a no-op from source. The sound outcome is "ClosedXML fully restored to its pre-#233 state (nothing swallowed), delta vs pre-#233 baseline ≈ 0", not "−5". The exact 121-vs-263 difference is the newer HEAD + --flow-locals-only + loose-file resolution vs the sweep's full own-check; the load-bearing property (restore-all, remove-none) holds regardless.


Generated by Claude Code

…r regression pin (review r2)

Second review round on #240:

- Bare DisposeAsync on a BASE (P2): the belt-and-braces async check used
  nt.GetMembers() (current type only), so a base with a bare
  `public ValueTask DisposeAsync()` that does NOT implement IAsyncDisposable
  slipped its real async cleanup through — AllInterfaces misses it (no
  interface) and the member scan misses it (inherited). Now walks the whole
  base chain. New flagged control BareInheritedAsyncEnumerator ('ba').

- Non-generic IEnumerator regression pin (P2): the IEnumerator<T>-only
  narrowing had no negative fixture. Added LegacyEnumerator (IEnumerator +
  IDisposable, empty Dispose) -> must stay flagged ('ng'). Flow count 6 -> 8.

- Stale docs (P3): the HasEmptyDisposeBody header, the sample-file header
  still said "generalises to any type" — the exact over-broad framing that
  produced #238. Rewritten to the actual IEnumerator<T>-only scope.

Verified with the real extractor: flow flags exactly s,r,lr,d,ar,ia,ba,ng
with the two generic enumerators (e,x) silent; full-sample diff vs main
byte-identical. run_tests 276/276, ruff, mypy green.

Refs #238

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsWw4Ay8KLTHFom1HvRu3U
// base-chain scan must catch it -> LEAK.
public int LeakBareInheritedAsync()
{
var ba = new BareInheritedAsyncEnumerator();
// the IEnumerator<T>-only narrowing must keep it flagged -> LEAK.
public int LeakLegacy()
{
var ng = new LegacyEnumerator();
@PhysShell

Copy link
Copy Markdown
Owner Author

Acceptance amended by PR #240: because ClosedXML itself is Fody-enabled, the sound result is restore-all/remove-none, not −5. The five Slice points must remain flagged under the project-level weaver kill-switch.

@PhysShell PhysShell merged commit 4c5a86b into main Jul 12, 2026
38 checks passed
PhysShell pushed a commit that referenced this pull request Jul 12, 2026
Two wording inaccuracies flagged on PR #242, both verified against
frontend/roslyn/OwnSharp.Extractor/Program.cs HasEmptyDisposeBody:

- The #240 gate matches only the generic System.Collections.Generic.
  IEnumerator<T> (checked by name + TypeArguments.Length == 1 +
  namespace), not the non-generic System.Collections.IEnumerator.
  Fixed in both docs/notes/field-notes-patterns.md entry 19 and
  docs/notes/precision-remeasure-2026-07-11.md's resolution addendum.

- DisposeAsync() is not "recognized as empty" — any IAsyncDisposable or
  bare DisposeAsync (declared, explicit-impl, or inherited) disqualifies
  the exemption outright. Only explicit-interface synchronous Dispose()
  is recognized as empty (the actual #238 coverage-gap fix).
PhysShell pushed a commit that referenced this pull request Jul 12, 2026
- frozen reference marked PROVISIONALLY approved; add the Integration-gate
  dependency (final semantic-port merge gated on the separate post-batch OSS
  remeasure run by another agent; point-check does not substitute for it).
- reframe the regression as "the #238 regression class introduced by #233 and
  repaired by #240" — #238 records the problem, it is not a version boundary.
- replace the unrestricted "no new false positives" with the bounded claim:
  the two legitimate IEnumerator<T> empty-Dispose controls stay silent in the
  regression fixture; broader FP behavior is left to the full OSS remeasure.
- add an explicit proven / not-proven guarantee split and the two-branch
  integration gate (full sweep green -> final; unexplained disappearance ->
  Python-first fix, stop merge, regenerate golden explicitly, re-parity Rust).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AZjB2ggU9qm8vmuTQRtZXR
PhysShell added a commit that referenced this pull request Jul 12, 2026
…r5sr8g

docs: close #221, reconcile status notes with shipped #227-229 and #238/#240
PhysShell added a commit that referenced this pull request Jul 12, 2026
…atch-2026-07-12

docs: full 5-repo oracle remeasure after the complete #218-#240 batch
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

3 participants