Repository navigation
Expand file tree
/
Copy pathProgram.cs
More file actions
7449 lines (7165 loc) · 440 KB
/
Copy pathProgram.cs
File metadata and controls
7449 lines (7165 loc) · 440 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
// OwnSharp OwnIR extractor (P-001 v0).
//
// Scans C# and emits OwnIR facts (JSON) in the OwnLang spec's vocabulary; the
// Python core (`python -m ownlang ownir facts.json`) produces the verdict
// (OWN001 leak) at the C# location.
//
// Event subscriptions are resolved type-aware (P-014 Tier A): all inputs are
// parsed into ONE CSharpCompilation with the runtime's framework references, and
// a `target += handler` is a subscription only when the SemanticModel binds the
// left side to an event symbol — so `sum += value` (arithmetic) is not a leak.
// When the left side's declaring type is an unresolved external reference we do
// not guess: a handler-shaped RHS surfaces as an OWN050 "leakage analysis
// skipped" note, never a leak. A subscription is "released" by a matching
// `target -= handler` in a recognised TEARDOWN CONTEXT of the class (#278:
// Dispose/DisposeAsync/OnClosed/Unloaded-style methods, a handler wired in code
// to the class's own Closed/Unloaded-style lifecycle event, or a method/local
// function the teardown path provably calls intra-class, symbol-resolved) that
// is not guarded by a parameter of its enclosing method — a `-=` in an
// arbitrary method, a finalizer, an unwired lambda, an uncalled local function,
// or behind a caller-controlled flag, is not proven to run and keeps the honest
// warning.
// A `Tick`/`Elapsed` handler is tagged resource=timer (WPF002) and is released
// only by a `.Stop()` on the timer's receiver that is PROVEN to run at teardown
// (the same teardown/guard doctrine as the `-=` above — an arbitrary-method,
// finalizer, unwired-handler or parameter-guarded Stop() credits nothing).
// The IDisposable/pool/local detectors remain syntactic for now
// (P-014 rollout: the event fact goes type-aware first).
//
// Usage: ownsharp-extract [extract] <file.cs | dir | *.csproj | *.sln> [more ...] [-o|--out facts.json]
// ownsharp-extract extract --project App.csproj --out facts.json
// ownsharp-extract --solution App.sln
//
// `extract` is an optional leading verb (the tool's one job); the bare form is the
// default. The sibling `check`/`explain` verbs are NOT here — `check` is the
// own-check orchestrator that chains this + the core, and `explain` is `python -m
// ownlang explain OWN001`. One checker: the C# tool only emits facts.
//
// Inputs may be .cs files, directories, a .csproj, or a .sln. A directory is walked
// recursively for *.cs, skipping build output (bin/obj), VCS/vendor dirs (.git,
// node_modules) and generated files (*.g.cs, *.Designer.cs) — so you can point it at a
// whole repo (this is what the `own-check` script / GitHub Action do). A .csproj resolves
// to its source set (SDK-style directory scan + concrete linked <Compile> files; no full
// MSBuild evaluation — see ProjectCsFiles); a .sln fans out over its member projects.
using System.Text;
using System.Text.Json;
using System.Text.RegularExpressions;
using System.Xml.Linq;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis.CSharp.Syntax;
var rawInputs = new List<string>();
string? outPath = null;
// H0 heap-effect source facts: written to THIS file, never into the facts document.
string? heapEffectsPath = null;
// --ref-dir <dir> (repeatable, P-014 Tier B): widen the compilation's reference set with the
// DLLs under <dir>, searched RECURSIVELY — point it at a project's built `bin/` output (or a
// restored package's `lib/`) and the SemanticModel can then bind events on third-party types
// (DevExpress, etc.) instead of surfacing them as OWN050. The first-class, scriptable twin of the
// OWN_EXTRA_REF_DIRS env var (which stays non-recursive, for framework ref packs). Roslyn reads
// metadata only, so it resolves a .NET Framework `bin/` exactly as a modern-.NET one — only the
// DLLs differ. First simple-name wins, so a TPA/framework reference is never double-added.
var refDirs = new List<string>();
// P-035: the project-declared weak-subscribe wrapper API, as exact
// (containing-type simple name, method name) pairs. Populated ONLY from the
// `--weak-subscribe` transport flag (own-check.sh forwards `[weak-subscription].
// subscribe` from own.toml). Empty set => the detector below is inert and facts
// are byte-for-byte unchanged. A data allowlist, never an inference.
var weakSubscribe = new HashSet<(string Type, string Method)>();
// Event-subscription detection is on by default now that it is type-aware
// (P-014 Tier A graduates it from the interim off). `--no-event-leaks` opts out
// (e.g. to run only the disposable/pool detectors); it is the first instance of
// the broader check-selection surface tracked in P-015.
bool emitEvents = true;
// --fix-candidates (S0, internal, default off): emit ADDITIVE fix-candidate metadata
// for the `own-fix subscriptions candidates` collector — a namespaced `fix` block on
// each eligible `+=` subscription fact, a `qualified_name` + type-shape metadata on the
// component, and a top-level `fix_candidates_version`. STRICTLY additive: with the flag
// OFF the facts JSON is byte-for-byte identical (no `fix`, no `qualified_name`, no
// `fix_candidates_version`), and `ownir_version` is unchanged either way. It changes NO
// existing field and NOT the `released` computation — only new optional metadata.
bool emitFixCandidates = false;
// --flow-locals (P-016 B0b/B2, EXPERIMENTAL, default off): emit per-method flow
// facts for non-escaping local IDisposables (acquire/use/release/if/return over a
// CFG) so the core checks them path-sensitively (OWN001/002/003). Supersedes the
// flat D1 local-disposable detector when on. Default off keeps the shipped surface.
bool flowLocals = false;
// --stats (coverage): print a one-line flow-locals coverage summary to stderr
// (of the methods that have a disposable local worth checking, how many were
// 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;
// --no-project-refs: opt out of the automatic reference derivation below. By default, when an
// input is a `.csproj`/`.sln`, the project's built `bin/` output (if present) is auto-added to
// the reference set — the scriptable `--ref-dir` you'd otherwise pass by hand — so third-party
// events on a restored/built project bind to real symbols instead of surfacing as OWN050. This
// flag turns that off (e.g. to measure raw Tier-A coverage, or when bin/ is stale).
bool noProjectRefs = 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.
// 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;
// #240 (review P2): WeaverOwnedFiles is the other process-global mutable state — a file recorded
// as belonging to a Fody-enabled project must not stay "weaver-owned" for an unrelated later
// invocation in the same process (a sticky false positive). Same reset discipline as above.
WeaverOwnedFiles.Clear();
// `extract` verb: the tool's one job is extraction, so an optional leading `extract`
// makes the advertised `ownsharp-extract extract --project App.csproj --out facts.json`
// UX real while the bare form (no verb) stays the default and back-compatible. (The
// sibling `check`/`explain` verbs live where the architecture puts them — `check` is the
// own-check orchestrator, `explain` is `python -m ownlang explain`; the C# tool is not a
// second checker.) A different first token (a path/flag) is left untouched as input.
var args0 = args.Length > 0 && args[0] == "extract" ? args[1..] : args;
// `-h` / `--help`: print the full usage (commands, inputs, options) and exit 0. A discoverable
// CLI is the value the roslyn-tools `System.CommandLine` shape buys; we render it by hand (no
// preview dependency) rather than adopt the framework — the surface is small and stable.
const string UsageText = """
ownsharp-extract — emit OwnIR leak facts from C# for the Own.NET core to check.
Usage:
ownsharp-extract [extract] <input>... [options]
`extract` is an optional leading verb (the tool's one job; the bare form is the default).
The sibling verbs live elsewhere by design — `check` is scripts/own-check.sh (extractor +
core), `explain` is `python -m ownlang explain OWN001`. One checker: this tool only emits facts.
Inputs (any mix; positional, or via --project/--solution):
file.cs a single C# file
dir a directory, walked recursively (skips bin/obj, generated, vendor)
App.csproj a project — resolved to its source set (no MSBuild evaluation)
App.sln a solution — fans out over its member projects
Options:
-o, --out FILE write the OwnIR facts JSON to FILE (default: stdout)
--project FILE add a .csproj input (flag twin of the positional form)
--solution FILE add a .sln input
--ref-dir DIR add DIR's DLLs (recursively) to the reference set, so third-party
events bind to real symbols instead of OWN050 (repeatable)
--weak-subscribe T.M internal transport (repeatable): a declared weak-subscribe
wrapper "SimpleType.Method" (P-035). Pass it via own-check
--config own.toml, not by hand; own.toml is the public surface.
--no-project-refs don't auto-add a .csproj/.sln project's bin/ output to the references
--no-event-leaks skip event-subscription detection (run only disposable/pool detectors)
--fix-candidates emit additive S0 fix-candidate metadata (namespaced `fix` block +
component `qualified_name`/shape + top-level `fix_candidates_version`);
facts are byte-identical without it, `ownir_version` unchanged
--flow-locals path-sensitive flow analysis of non-escaping local IDisposables
--stats print flow-locals coverage (requires --flow-locals)
--body-throw-edges treat escaping body-level may-throw as a dispose-on-throw point (needs --flow-locals)
--heap-effects FILE also write heap-effect source facts (H0, inert) to FILE; the facts
document is byte-identical with or without it
-h, --help show this help and exit
""";
if (args0.Contains("-h") || args0.Contains("--help"))
{
Console.WriteLine(UsageText);
return 0;
}
for (int i = 0; i < args0.Length; i++)
{
// `--out FILE` is the long-form twin of `-o FILE` (the advertised `extract --out` UX).
if ((args0[i] == "-o" || args0[i] == "--out") && i + 1 < args0.Length) outPath = args0[++i];
// `--project <App.csproj>` / `--solution <App.sln>`: the explicit-flag twin of passing the
// project/solution as a positional input (both resolve through Expand). The flag form matches
// the advertised `ownsharp extract --project ...` UX borrowed from the roslyn-tools CLI shape;
// the positional form keeps the command unambiguous next to dotnet's own `run --project`.
else if ((args0[i] == "--project" || args0[i] == "--solution") && i + 1 < args0.Length) rawInputs.Add(args0[++i]);
else if (args0[i] == "--ref-dir" && i + 1 < args0.Length) refDirs.Add(args0[++i]);
// P-035 transport flag (repeatable): "SimpleType.Method" — one declared weak-
// subscribe wrapper. own.toml is parsed/validated by the Python config carrier
// (own-check.sh); this only splits the already-validated pair.
else if (args0[i] == "--weak-subscribe" && i + 1 < args0.Length)
{
var parts = args0[++i].Split('.');
if (parts.Length == 2 && parts[0].Length > 0 && parts[1].Length > 0)
weakSubscribe.Add((parts[0], parts[1]));
}
else if (args0[i] == "--no-project-refs") noProjectRefs = true;
else if (args0[i] == "--no-event-leaks") emitEvents = false;
else if (args0[i] == "--fix-candidates") emitFixCandidates = true;
else if (args0[i] == "--flow-locals") flowLocals = true;
else if (args0[i] == "--body-throw-edges") BodyThrowEdges = true;
else if (args0[i] == "--stats") reportStats = true;
else if (args0[i] == "--heap-effects" && i + 1 < args0.Length) heapEffectsPath = args0[++i];
else rawInputs.Add(args0[i]);
}
if (rawInputs.Count == 0)
{
Console.Error.WriteLine("usage: ownsharp-extract [extract] <file.cs | dir | *.csproj | *.sln> [...] [-o|--out facts.json] [--ref-dir <bin-dir>]");
Console.Error.WriteLine(" ownsharp-extract --help for the full option list");
return 2;
}
// --stats reports flow-locals coverage; the counters only move inside the
// --flow-locals pass. Without it they would all be zero and the summary would
// read "0/0 methods flow-analysed" — the exact ambiguous zero --stats exists to
// kill (e.g. `own-check.sh --legacy --stats`). Refuse the contradictory combo.
if (reportStats && !flowLocals)
{
Console.Error.WriteLine("ownsharp-extract: --stats requires --flow-locals");
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";
// Generated C# the author did not write (and cannot fix): skip it.
static bool IsGenerated(string path) =>
path.EndsWith(".g.cs", StringComparison.Ordinal)
|| path.EndsWith(".Designer.cs", StringComparison.Ordinal)
|| path.EndsWith(".AssemblyInfo.cs", StringComparison.Ordinal);
static bool IsSkipped(string path)
{
foreach (var seg in path.Split('/', '\\'))
if (IsSkippedDir(seg)) return true;
return IsGenerated(path);
}
// Translate an MSBuild item spec (a path, optionally with `*`, `?`, `**` globs and either
// separator) into a predicate over a full file path, evaluated RELATIVE to the project dir.
// Enough for the common `<Compile Include>` / `<Compile Remove>` forms (a concrete path, `*.cs`,
// `**/*.cs`, `Folder/**`) without a full MSBuild glob engine: `**` matches across directories,
// `*` / `?` within a single path segment. Match is case-insensitive (MSBuild globbing is).
static Func<string, bool> SpecMatcher(string spec, string dir)
{
var glob = spec.Replace('\\', '/').Trim();
var rx = new StringBuilder("^");
for (int i = 0; i < glob.Length; i++)
{
var c = glob[i];
if (c == '*')
{
if (i + 1 < glob.Length && glob[i + 1] == '*') // `**` -> any chars, including '/'
{
rx.Append(".*");
i++;
if (i + 1 < glob.Length && glob[i + 1] == '/') i++; // swallow the slash after `**`
}
else rx.Append("[^/]*"); // `*` -> within one path segment
}
else if (c == '?') rx.Append("[^/]");
else rx.Append(Regex.Escape(c.ToString()));
}
rx.Append('$');
// `.csproj` content is untrusted in CI, and adjacent wildcards (`**/**`, `**/*`) translate to
// ambiguous adjacent quantifiers the backtracking .NET engine can blow up on (ReDoS). Bound the
// match with a timeout, and treat a timeout as "no match" rather than letting it crash the run.
var regex = new Regex(rx.ToString(), RegexOptions.IgnoreCase, TimeSpan.FromSeconds(1));
return path =>
{
var rel = Path.GetRelativePath(dir, path).Replace('\\', '/');
try { return regex.IsMatch(rel); }
catch (RegexMatchTimeoutException)
{
Console.Error.WriteLine($"ownsharp-extract: glob match timed out for '{spec}' on '{rel}'; treated as no match");
return false;
}
};
}
// Resolve a `.csproj` to its C# source set. Doing this the MSBuild way needs a full
// project evaluation (and the `Microsoft.CodeAnalysis.Workspaces.MSBuild` + MSBuildLocator
// dependency that P-014 / the "ProjectDependencies as a category" note deliberately parks
// for the DI/solution-graph work — not for the v0 leak extractor). The pragmatic resolution
// that covers the SDK-style common case WITHOUT that baggage:
// - candidates = every in-tree *.cs (SDK default-compile-items), with bin/obj and generated
// files already excluded by IsSkipped;
// - but honour the project's explicit compile set: `<EnableDefaultCompileItems>false` turns the
// default OFF (then only files an explicit `<Compile Include>` selects are kept), and
// `<Compile Remove="...">` subtracts excluded files — so `.csproj` input does not emit findings
// from files the project does not actually compile (CodeRabbit: a source-set mismatch, not a
// harmless over-approximation);
// - plus any concrete linked `<Compile Include="..\Shared\Foo.cs" />` that points OUTSIDE the tree.
// Include/Remove globs are matched by SpecMatcher (not a full MSBuild engine, but enough for the
// common forms). Builds a list rather than yielding so the XML read can sit in a try/catch (an
// iterator may not yield from inside one); a malformed project degrades to the plain directory scan.
static List<string> ProjectCsFiles(string csproj, EnumerationOptions opts)
{
var full = Path.GetFullPath(csproj);
var result = new List<string>();
// A missing project file must NOT degrade to "scan its parent directory": a typo'd
// `--project src/Missing.csproj` would otherwise analyse all of src/**/*.cs (an
// unintended source set), or throw if the parent is absent too. Skip the bad input with
// a warning — the directory-scan fallback below is only for a PRESENT-but-malformed
// project (whose <Compile> items we could not read), never an absent one. (Codex P2.)
if (!File.Exists(full))
{
Console.Error.WriteLine($"ownsharp-extract: project not found: {csproj}");
return result;
}
var dir = Path.GetDirectoryName(full) ?? ".";
XDocument? doc = null;
try { doc = XDocument.Load(full); }
catch (Exception ex)
{
Console.Error.WriteLine(
$"ownsharp-extract: {csproj}: reading <Compile> items failed ({ex.Message}); used directory scan only");
}
var elements = (doc?.Descendants() ?? Enumerable.Empty<XElement>()).ToList();
// `<EnableDefaultCompileItems>false</…>` (last value wins, as MSBuild evaluates top-to-bottom)
// turns off the implicit "every *.cs is compiled" default.
var defaultItems = elements
.Where(e => e.Name.LocalName == "EnableDefaultCompileItems")
.Select(e => e.Value.Trim())
.LastOrDefault();
var defaultCompile = !string.Equals(defaultItems, "false", StringComparison.OrdinalIgnoreCase);
var includes = elements.Where(e => e.Name.LocalName == "Compile")
.Select(e => e.Attribute("Include")?.Value)
.Where(v => !string.IsNullOrWhiteSpace(v)).Select(v => v!).ToList();
var removes = elements.Where(e => e.Name.LocalName == "Compile")
.Select(e => e.Attribute("Remove")?.Value)
.Where(v => !string.IsNullOrWhiteSpace(v)).Select(v => v!).ToList();
var candidates = Directory.EnumerateFiles(dir, "*.cs", opts).Where(f => !IsSkipped(f)).ToList();
if (defaultCompile)
result.AddRange(candidates);
else
{
// Explicit-list project: keep only in-tree files an `<Compile Include>` selects.
var incMatch = includes.Select(s => SpecMatcher(s, dir)).ToList();
result.AddRange(candidates.Where(f => incMatch.Any(m => m(f))));
}
// Concrete linked `<Compile Include>` that points OUTSIDE the project tree, in either mode.
var dirPrefix = dir.EndsWith(Path.DirectorySeparatorChar) ? dir : dir + Path.DirectorySeparatorChar;
foreach (var inc in includes)
{
if (inc.IndexOfAny(new[] { '*', '?' }) >= 0) continue; // a glob — handled above / by the scan
var path = Path.GetFullPath(Path.Combine(dir, inc.Replace('\\', Path.DirectorySeparatorChar)));
if (path.EndsWith(".cs", StringComparison.OrdinalIgnoreCase)
&& File.Exists(path) && !IsSkipped(path)
&& !path.StartsWith(dirPrefix, StringComparison.Ordinal)) // in-tree links already added
result.Add(path);
}
// Honour `<Compile Remove="...">`: drop excluded files (concrete or glob, relative to the dir).
if (removes.Count > 0)
{
var rmMatch = removes.Select(s => SpecMatcher(s, dir)).ToList();
result.RemoveAll(f => rmMatch.Any(m => m(f)));
}
// #240: if THIS project is weaver-enabled (FodyWeavers.xml in its directory or above),
// EVERY file it compiles — including linked sources outside its tree — is weavable at
// build time; record them so the empty-Dispose kill-switch sees past the file's own path.
if (AncestorsHaveWeaverConfig(dir))
foreach (var f in result)
WeaverOwnedFiles.Add(Path.GetFullPath(f));
return result.Distinct().ToList();
}
// Extract the double-quoted fields from a solution `Project(...)` line tail, in order. The fields
// are `"Name", "relpath", "{guid}"`; splitting on quotes (not raw commas) means a comma INSIDE a
// quoted name or path no longer misreads the line — CodeRabbit flagged the naive `Split(',')`,
// which could skip a valid project or resolve the wrong path. (`.sln` does not use `""` escaping.)
static List<string> QuotedFields(string s)
{
var fields = new List<string>();
int i = 0;
while (true)
{
int a = s.IndexOf('"', i);
if (a < 0) break;
int b = s.IndexOf('"', a + 1);
if (b < 0) break;
fields.Add(s.Substring(a + 1, b - a - 1));
i = b + 1;
}
return fields;
}
// Resolve a classic `.sln` to its member `.csproj` paths. The solution file lists each project
// as `Project("{type-guid}") = "Name", "rel\path.csproj", "{guid}"`; solution folders use the
// same line shape but their path is not a `.csproj`, so filtering on the extension drops them.
// Text parsing (no MSBuild) keeps this dependency-free; a missing member is reported and skipped,
// never fatal — a solution-wide scan should survive one stale project reference.
static List<string> SolutionProjects(string sln)
{
var dir = Path.GetDirectoryName(Path.GetFullPath(sln)) ?? ".";
var projects = new List<string>();
string[] lines;
try { lines = File.ReadAllLines(sln); }
catch (Exception ex)
{
Console.Error.WriteLine($"ownsharp-extract: cannot read solution {sln} ({ex.Message})");
return projects;
}
foreach (var line in lines)
{
var t = line.TrimStart();
if (!t.StartsWith("Project(", StringComparison.Ordinal)) continue;
var eq = t.IndexOf('=');
if (eq < 0) continue;
// The path is the SECOND double-quoted field after '=' ("Name", "relpath", "{guid}");
// quote-aware extraction tolerates a comma inside the name or path.
var fields = QuotedFields(t.Substring(eq + 1));
if (fields.Count < 2) continue;
var rel = fields[1].Trim();
if (!rel.EndsWith(".csproj", StringComparison.OrdinalIgnoreCase)) continue;
var path = Path.GetFullPath(Path.Combine(dir, rel.Replace('\\', Path.DirectorySeparatorChar)));
if (File.Exists(path)) projects.Add(path);
else Console.Error.WriteLine($"ownsharp-extract: {sln}: project not found: {rel}");
}
return projects;
}
// The built `bin/` output directory of each `.csproj` (or each member project of a `.sln`) among
// the inputs — the references to auto-derive (P-014 Tier B convenience). Returns only directories
// that exist, deduped: an unbuilt project contributes nothing (and its third-party events degrade
// to OWN050, never a crash). The caller adds these to the reference set exactly like a hand-passed
// `--ref-dir`, so a built/restored project's third-party events bind without one.
static List<string> ProjectBinDirs(IEnumerable<string> rawInputs)
{
var bins = new List<string>();
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
void AddFor(string csproj)
{
var full = Path.GetFullPath(csproj);
if (!File.Exists(full)) return; // missing project: nothing to derive
var dir = Path.GetDirectoryName(full);
if (dir is null) return;
var bin = Path.Combine(dir, "bin");
if (Directory.Exists(bin) && seen.Add(bin)) bins.Add(bin);
}
foreach (var p in rawInputs)
{
if (p.EndsWith(".csproj", StringComparison.OrdinalIgnoreCase)) AddFor(p);
else if (p.EndsWith(".sln", StringComparison.OrdinalIgnoreCase))
foreach (var proj in SolutionProjects(p)) AddFor(proj);
}
return bins;
}
// Expand inputs into their .cs files. A directory is walked recursively; a `.csproj`/`.sln`
// is resolved to its source set (so `ownsharp-extract App.csproj` / `App.sln` works, the
// CLI-first project input borrowed from the roslyn-tools tooling shape); an explicit file
// passes through as-is. IgnoreInaccessible tolerates an unreadable subdir mid-walk (otherwise
// the whole scan would abort with an unhandled exception on a locked directory).
static IEnumerable<string> Expand(IEnumerable<string> roots)
{
var opts = new EnumerationOptions
{
RecurseSubdirectories = true,
IgnoreInaccessible = true,
};
foreach (var p in roots)
{
if (Directory.Exists(p))
{
foreach (var f in Directory.EnumerateFiles(p, "*.cs", opts))
if (!IsSkipped(f))
yield return f;
}
else if (p.EndsWith(".sln", StringComparison.OrdinalIgnoreCase))
{
foreach (var proj in SolutionProjects(p))
foreach (var f in ProjectCsFiles(proj, opts))
yield return f;
}
else if (p.EndsWith(".csproj", StringComparison.OrdinalIgnoreCase))
{
foreach (var f in ProjectCsFiles(p, opts))
yield return f;
}
else
{
yield return p;
}
}
}
// A finding's file is reported relative to the current directory (the repo root
// in CI / under the Action), with forward slashes — so a GitHub annotation or an
// MSBuild diagnostic points at the right file even when two files share a name.
// ===== OWN053 "orphaned awaitable" (promoted from ownership-semantics-lab H-29; Own.NET-paperwork h29-prereg-v1.json,
// h29-rule-prereg-v1.json, h29-promotion-v1.json, p-own053-door-v1.json). For every local declared with an UN-AWAITED
// invocation returning Task / Task<T> / ValueTask / ValueTask<T> (through parentheses / ConfigureAwait) whose operation
// is effectful — the unwrapped result is a real disposable (family A) or the member is a connection / transaction /
// persistence lifecycle call (family B) — and that is never referenced again in its member, nor declared inside a
// lambda / local function, one entry goes into the ADDITIVE top-level facts list `orphaned_awaitables`, from which
// both engines mint the advisory OWN053. The scope is frozen as measured: discards (`_ = …`), bare statements and
// stored / passed / returned awaitables stay out, the family vocabulary grows only by witnesses, nothing is lowered.
static void CollectOrphanedAwaitables(BlockSyntax mbody, SemanticModel model)
{
static bool IsAwaitable(ITypeSymbol? t) => t is INamedTypeSymbol n && ((n.ContainingNamespace?.ToString() == "System.Threading.Tasks" && n.Name is "Task" or "ValueTask") || (n.ContainingNamespace?.ToString() == "System.Runtime.CompilerServices" && n.Name is "ConfiguredTaskAwaitable" or "ConfiguredValueTaskAwaitable"));
static ITypeSymbol? Result(ITypeSymbol? t) => t is INamedTypeSymbol { IsGenericType: true } n ? n.TypeArguments[0] : null;
static bool Lifecycle(string n) => n is "BeginTransaction" or "BeginTransactionAsync" or "Commit" or "CommitAsync" or "Rollback" or "RollbackAsync" or "Open" or "OpenAsync" or "Close" or "CloseAsync" or "DisposeAsync" or "SaveChangesAsync" or "FlushAsync";
static string Key(IMethodSymbol m) => $"{m.ContainingType.ToDisplayString()}.{m.Name}/{m.Parameters.Length}";
// the invocation behind an awaitable expression: strip parentheses and a trailing .ConfigureAwait(...)
static InvocationExpressionSyntax? Core(ExpressionSyntax e)
{
while (e is ParenthesizedExpressionSyntax p) e = p.Expression;
if (e is InvocationExpressionSyntax { Expression: MemberAccessExpressionSyntax { Name.Identifier.Text: "ConfigureAwait" } cma }) { e = cma.Expression; while (e is ParenthesizedExpressionSyntax p2) e = p2.Expression; }
return e as InvocationExpressionSyntax;
}
foreach (var ld in mbody.DescendantNodes().OfType<LocalDeclarationStatementSyntax>())
{
if (ld.UsingKeyword.RawKind > 0) continue;
foreach (var v in ld.Declaration.Variables)
{
if (v.Initializer?.Value is not { } init || model.GetDeclaredSymbol(v) is not ILocalSymbol ls) continue;
if (!IsAwaitable(model.GetTypeInfo(init).Type)) continue;
var inv = Core(init); if (inv is null || model.GetSymbolInfo(inv).Symbol is not IMethodSymbol m) continue;
var res = Result(m.ReturnType);
var fam = res is not null && ImplementsIDisposable(res) && !IsDisposeOptional(res) && !HasEmptyDisposeBody(res) ? "A_owned_result" : Lifecycle(m.Name) ? "B_protocol_lifecycle" : null;
if (fam is null) continue;
// zero later references in the member: the strict primary of the frozen scope
var refs = mbody.DescendantNodes().OfType<IdentifierNameSyntax>().Count(id => id.Identifier.Text == ls.Name && SymbolEqualityComparer.Default.Equals(model.GetSymbolInfo(id).Symbol, ls));
if (refs != 0) continue;
if (v.Ancestors().TakeWhile(a => a != mbody).Any(a => a is AnonymousFunctionExpressionSyntax or LocalFunctionStatementSyntax)) continue;
var member = v.Ancestors().OfType<MemberDeclarationSyntax>().FirstOrDefault();
var pos = PosOf(v);
lock (OrphanedAwaitables.Sites) OrphanedAwaitables.Sites.Add(new { file = Rel(v.SyntaxTree.FilePath), line = pos.Line, column = pos.Column, method = member is BaseMethodDeclarationSyntax bmd ? FlowFunctionName(bmd, "?", model) : (member?.Kind().ToString() ?? "?"), local = ls.Name, callee = Key(m), family = fam, result_type = res?.ToDisplayString() });
}
}
}
static string Rel(string path) =>
Path.GetRelativePath(Directory.GetCurrentDirectory(), path).Replace('\\', '/');
// Auto-derive project references (unless --no-project-refs): for a .csproj/.sln input, add its
// built bin/ output to the reference set so third-party events bind without a hand-passed --ref-dir.
// Appended to refDirs before the reference set is built; the recursive, first-name-wins --ref-dir
// loader handles it from there (a framework/TPA simple-name already loaded is never double-added).
if (!noProjectRefs)
foreach (var bin in ProjectBinDirs(rawInputs))
{
refDirs.Add(bin);
Console.Error.WriteLine($"extractor: auto-referencing project output {bin} (--no-project-refs to disable)");
}
var inputs = Expand(rawInputs).Distinct().ToList();
// Every raw input resolved to nothing analyzable — every path either doesn't
// exist, or every .cs file under it was filtered out by IsSkipped (bin/obj,
// generated, vendor trees; review, PR #246). Distinct from the "no inputs
// GIVEN at all" usage error above (exit 2): this is "inputs were given, but
// none of them contain anything this frontend can read" — the same shape a
// caller (owen check, own-check.sh) must not read as "0 findings, clean".
// Exit 4 deliberately matches the CLI's own no-supported-input contract
// (CheckCommand.cs) so a caller never needs to special-case which layer
// caught it; own-check.sh/action.yml propagate any non-zero extractor exit
// as-is (no python-core stage 2 is reached), same as the existing exit-2 path.
if (inputs.Count == 0)
{
Console.Error.WriteLine(
"extractor: no supported input found — every given path either does not exist " +
"or contains no .cs file after skipping bin/obj/generated/vendor trees.");
return 4;
}
static bool IsHandler(ExpressionSyntax rhs) =>
rhs is IdentifierNameSyntax || rhs is MemberAccessExpressionSyntax;
// A handler written `new SomeEventHandler(H)` (explicit delegate-creation) denotes
// the SAME subscription as the bare `H`. Unwrap a single-argument delegate creation
// to its inner handler expression so `+= new H(m)` and `-= m` — the dominant SectorTS
// idiom (BrokerDataClasses/*.cs setters: `+=` wraps, `-=` is bare) — match on the
// release key, and the P-004 static-handler exemption sees the wrapped method. Only
// event-assignment RHS is passed here, where a `new T(arg)` is always a delegate
// creation — including the C# 9 target-typed form `new(H)`; a non-method-group inner
// (a lambda / opaque value) is left for IsHandler to reject. BaseObjectCreationExpression
// covers both explicit `new T(H)` (ObjectCreation) and target-typed `new(H)`
// (ImplicitObjectCreation). Loops to peel a (rare) doubly-wrapped delegate.
static ExpressionSyntax NormalizeHandler(ExpressionSyntax e)
{
while (e is BaseObjectCreationExpressionSyntax { ArgumentList.Arguments: { Count: 1 } args })
e = args[0].Expression;
return e;
}
// ---- the one source-coordinate authority (Own.NET#317) ----
//
// Every anchor-producing fact takes its `line` AND its `column` from a SINGLE
// RangeOf() on the SINGLE node the record anchors on. There is deliberately no
// parallel `ColumnOf(node)`: two independent lookups are two opportunities to pass
// two different nodes, and the result would be a well-formed coordinate pointing at
// a place that does not exist — the worst kind of wrong, because nothing downstream
// can detect it. `LineOf` is kept as the name every existing call site already uses;
// it is now a projection of the same range rather than a second computation.
//
// Roslyn's LinePosition is 0-based in both axes. The +1 happens here, once, so no
// caller ever sees a mixed convention.
static SourceRange RangeOf(SyntaxNode node)
{
var ls = node.GetLocation().GetLineSpan();
return new SourceRange(
new SourcePos(ls.StartLinePosition.Line + 1, ls.StartLinePosition.Character + 1),
new SourcePos(ls.EndLinePosition.Line + 1, ls.EndLinePosition.Character + 1));
}
static SourcePos PosOf(SyntaxNode node) => RangeOf(node).Start;
static int LineOf(SyntaxNode node) => PosOf(node).Line;
// ---- S0 fix-candidate metadata (--fix-candidates only; strictly additive) ----
// The one documented FQN format for `qualified_name` and every symbol identity the
// fix block emits: fully-qualified with namespaces + nested-type path, no `global::`,
// generic type parameters included, special types spelled (`int`, not `System.Int32`).
static SymbolDisplayFormat FixFqnFormat() => new SymbolDisplayFormat(
globalNamespaceStyle: SymbolDisplayGlobalNamespaceStyle.Omitted,
typeQualificationStyle: SymbolDisplayTypeQualificationStyle.NameAndContainingTypesAndNamespaces,
genericsOptions: SymbolDisplayGenericsOptions.IncludeTypeParameters,
memberOptions: SymbolDisplayMemberOptions.IncludeContainingType | SymbolDisplayMemberOptions.IncludeParameters,
// IncludeParamsRefOut so `M(T)` and `M(ref T)` — legal C# overloads — get DISTINCT
// signatures; without it the enclosing-member component of a finding identity could
// collide across ref/value overloads.
parameterOptions: SymbolDisplayParameterOptions.IncludeType | SymbolDisplayParameterOptions.IncludeParamsRefOut,
miscellaneousOptions: SymbolDisplayMiscellaneousOptions.UseSpecialTypes);
// Whitespace-normalized syntax fingerprint (collapse runs of whitespace to single
// spaces, trim) — used as the identity of a lambda handler / an unresolved receiver,
// where no symbol exists to name it. Deterministic; no regex.
static string FixNormWs(string s) =>
string.Join(" ", s.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries));
// A full Roslyn TextSpan for `node`: absolute UTF-16 (start,length) for a future
// rewriter, plus 1-based line/column for humans and diagnostics UI.
static object FixSpanOf(SyntaxNode node)
{
var span = node.Span;
// Routed through RangeOf so the fix block and the anchor records share ONE
// definition of "the 1-based position of this node". The serialized shape is
// unchanged — same six properties, same order, same values.
var range = RangeOf(node);
return new
{
start = span.Start,
length = span.Length,
start_line = range.Start.Line,
start_column = range.Start.Column,
end_line = range.End.Line,
end_column = range.End.Column,
};
}
// The receiver expression of an event access (`source` in `source.PropertyChanged`),
// or null when the event is named bare (`Changed += h`, an implicit `this`).
static ExpressionSyntax? FixEventReceiver(ExpressionSyntax eventAccess) =>
eventAccess is MemberAccessExpressionSyntax m ? m.Expression : null;
// Semantic INotifyPropertyChanged classification — NEVER by the name alone. Only the
// actual `INotifyPropertyChanged.PropertyChanged` member, or a symbol that provably
// implements it, is `inotify_property_changed`; a same-named event on an unrelated
// type is `name_only`; anything else is `other`. (The `unresolved` category is for the
// unresolved-subscription lane where no event symbol binds, not reached here.)
static string FixEventContract(IEventSymbol ev, Compilation compilation)
{
var inpc = compilation.GetTypeByMetadataName("System.ComponentModel.INotifyPropertyChanged");
var inpcEvent = inpc?.GetMembers("PropertyChanged").OfType<IEventSymbol>().FirstOrDefault();
if (inpcEvent is not null)
{
if (SymbolEqualityComparer.Default.Equals(ev.OriginalDefinition, inpcEvent))
return "inotify_property_changed";
var impl = ev.ContainingType?.FindImplementationForInterfaceMember(inpcEvent);
if (impl is not null && SymbolEqualityComparer.Default.Equals(impl, ev))
return "inotify_property_changed";
}
return ev.Name == "PropertyChanged" ? "name_only" : "other";
}
// Best-effort "did this file reach the extractor already generated?" — a name marker
// (`.g.cs`/`.g.i.cs`/`.designer.cs`/`.generated.cs`) or an `<auto-generated>` header.
static bool FixIsGeneratedFile(SyntaxTree tree, string file)
{
var lower = file.Replace('\\', '/').ToLowerInvariant();
var name = lower.Substring(lower.LastIndexOf('/') + 1);
if (name.EndsWith(".g.cs", StringComparison.Ordinal)
|| name.EndsWith(".g.i.cs", StringComparison.Ordinal)
|| name.EndsWith(".designer.cs", StringComparison.Ordinal)
|| name.EndsWith(".generated.cs", StringComparison.Ordinal))
return true;
foreach (var tr in tree.GetRoot().GetFirstToken(includeZeroWidth: true).LeadingTrivia)
if (tr.IsKind(SyntaxKind.SingleLineCommentTrivia) || tr.IsKind(SyntaxKind.MultiLineCommentTrivia))
{
var t = tr.ToString().ToLowerInvariant();
if (t.Contains("<auto-generated") || t.Contains("auto-generated") || t.Contains("autogenerated"))
return true;
}
return false;
}
// The signature of the member (method/ctor/accessor/property) that lexically encloses
// `node` — part of the fix finding-identity (a subscription is identified by WHERE it
// is declared, not by its line). Symbol signature when resolvable, else a syntax
// fallback.
static string FixEnclosingMemberSignature(SyntaxNode node, SemanticModel model)
{
var member = node.Ancestors().FirstOrDefault(n =>
n is BaseMethodDeclarationSyntax or AccessorDeclarationSyntax
or PropertyDeclarationSyntax or EventDeclarationSyntax or IndexerDeclarationSyntax);
if (member is null)
return "<none>";
var sym = model.GetDeclaredSymbol(member);
return sym?.ToDisplayString(FixFqnFormat()) ?? FixNormWs(member.ToString());
}
// Strip enclosing parentheses so `(x)` classifies like `x`.
static ExpressionSyntax FixStripParens(ExpressionSyntax e) =>
e is ParenthesizedExpressionSyntax p ? FixStripParens(p.Expression) : e;
// Conservative RECEIVER (event-source) identity. Only a receiver that names a
// definitely-stable instance may ground an `exact` teardown; a receiver whose runtime
// instance is computed per evaluation must not — two `GetPublisher()` calls, or a
// property getter, can return different objects even though the SYMBOL is identical.
// "stable_symbol": `this` / local / parameter / instance field of `this` / static
// field — the same syntactic reference is the same instance;
// "computed": invocation / property / indexer / conditional access / a field of
// some OTHER value (`a.Publisher`) — instance identity not proven;
// "unresolved": no symbol bound.
static (string Kind, ISymbol? Sym) FixReceiverIdentity(
ExpressionSyntax? recv, SemanticModel model, INamedTypeSymbol? clsSymbol)
{
if (recv is null)
return ("stable_symbol", clsSymbol); // bare event => implicit `this`
recv = FixStripParens(recv);
if (recv is ThisExpressionSyntax)
return ("stable_symbol", clsSymbol);
var sym = model.GetSymbolInfo(recv).Symbol;
switch (recv)
{
case IdentifierNameSyntax:
// bare identifier: local / parameter / instance-field-of-`this` / static field
if (sym is ILocalSymbol or IParameterSymbol or IFieldSymbol)
return ("stable_symbol", sym);
return sym is null ? ("unresolved", null) : ("computed", sym);
case MemberAccessExpressionSyntax ma:
// only `this._field` or a static field is a proven-stable instance; a field
// of another value (`a.Publisher`) or a property/method is computed.
if (sym is IFieldSymbol f
&& (f.IsStatic || FixStripParens(ma.Expression) is ThisExpressionSyntax))
return ("stable_symbol", sym);
return sym is null ? ("unresolved", null) : ("computed", sym);
default:
return sym is null ? ("unresolved", null) : ("computed", sym);
}
}
// Conservative HANDLER identity. A method SYMBOL is not a delegate identity, and a
// storage SYMBOL is not its (mutable) value — so `stable_symbol` is limited to the two
// forms whose delegate is provably fixed for the class WITHOUT dataflow:
// * a STATIC method group (null target); or
// * an INSTANCE method group on `this` — bare `OnChanged` or `this.OnChanged`.
// A method group on any OTHER receiver (`left.OnChanged`) binds to that receiver's
// instance — `left.OnChanged` and `right.OnChanged` are the same IMethodSymbol but
// different delegates. A delegate held in a local / parameter / field / property is a
// storage location that can be reassigned between the `+=` and the `-=` (same symbol,
// different value). A lambda is a fresh delegate each evaluation. All of these are
// `computed` — none may ground an `exact` (proving them stable is dataflow, out of S0).
static (string Kind, ISymbol? Sym) FixHandlerIdentity(ExpressionSyntax handler, SemanticModel model)
{
var nh = NormalizeHandler(handler);
if (nh is AnonymousFunctionExpressionSyntax)
return ("computed", null);
var info = model.GetSymbolInfo(nh);
var sym = info.Symbol ?? info.CandidateSymbols.FirstOrDefault();
if (sym is IMethodSymbol method)
{
if (method.IsStatic)
return ("stable_symbol", sym); // static method group: null target
if (nh is IdentifierNameSyntax)
return ("stable_symbol", sym); // bare `OnChanged` => implicit `this`
if (nh is MemberAccessExpressionSyntax ma
&& FixStripParens(ma.Expression) is ThisExpressionSyntax)
return ("stable_symbol", sym); // `this.OnChanged`
return ("computed", sym); // method group on some other receiver instance
}
// A local / parameter / field / property / indexer delegate: a storage location or a
// computed value, not a proven-immutable delegate.
return sym is null ? ("unresolved", null) : ("computed", sym);
}
// Build the namespaced `fix` block for one eligible `+=` acquire `a` (event `ev`). An
// `exact` teardown requires a single `-=` site whose event symbol, RECEIVER identity, and
// HANDLER identity all match by STABLE symbol — a computed/unresolved identity, or a
// text-only agreement, tops out at `ambiguous`. The occurrence ordinal is keyed by the
// FULL identity INCLUDING the enclosing member (so a subscription added to a different
// member never shifts an existing one's ordinal). Teardown scan is scoped to `a`'s
// IMMEDIATE containing type.
static object FixBuildSubscriptionFix(
AssignmentExpressionSyntax a, IEventSymbol ev, SemanticModel model, Compilation compilation,
INamedTypeSymbol? clsSymbol, ClassDeclarationSyntax cls, Dictionary<string, int> occ)
{
var eventId = ev.OriginalDefinition.ToDisplayString(FixFqnFormat());
var contract = FixEventContract(ev, compilation);
var enclosing = FixEnclosingMemberSignature(a, model);
var recv = FixEventReceiver(a.Left);
var sourceText = recv is null ? "this" : recv.ToString();
var (srcKind, srcSym) = FixReceiverIdentity(recv, model, clsSymbol);
var sourceId = srcKind == "stable_symbol" && srcSym is not null
? srcSym.ToDisplayString(FixFqnFormat())
: FixNormWs(sourceText);
var handlerText = NormalizeHandler(a.Right).ToString();
var (hKind, hSym) = FixHandlerIdentity(a.Right, model);
var handlerId = hKind == "stable_symbol" && hSym is not null
? hSym.ToDisplayString(FixFqnFormat())
: FixNormWs(handlerText);
var acquireStable = srcKind == "stable_symbol" && hKind == "stable_symbol";
var candidates = new List<object>();
var anyNonStable = false;
foreach (var t in cls.DescendantNodes().OfType<AssignmentExpressionSyntax>())
{
if (!t.IsKind(SyntaxKind.SubtractAssignmentExpression))
continue;
if (!ReferenceEquals(t.FirstAncestorOrSelf<TypeDeclarationSyntax>(), cls))
continue; // nested-type boundary: only this immediate type's teardown sites
if (model.GetSymbolInfo(t.Left).Symbol is not IEventSymbol tev
|| !SymbolEqualityComparer.Default.Equals(tev, ev))
continue; // must be the same event symbol
var tRecv = FixEventReceiver(t.Left);
var tSourceText = tRecv is null ? "this" : tRecv.ToString();
var (tSrcKind, tSrcSym) = FixReceiverIdentity(tRecv, model, clsSymbol);
var tHandlerText = NormalizeHandler(t.Right).ToString();
var (tHKind, tHSym) = FixHandlerIdentity(t.Right, model);
var srcStableEq = srcKind == "stable_symbol" && tSrcKind == "stable_symbol"
&& SymbolEqualityComparer.Default.Equals(srcSym, tSrcSym);
var hStableEq = hKind == "stable_symbol" && tHKind == "stable_symbol"
&& SymbolEqualityComparer.Default.Equals(hSym, tHSym);
var srcAgree = srcStableEq || FixNormWs(sourceText) == FixNormWs(tSourceText);
var hAgree = hStableEq || FixNormWs(handlerText) == FixNormWs(tHandlerText);
if (!srcAgree || !hAgree)
continue;
var stableMatch = srcStableEq && hStableEq;
if (!stableMatch)
anyNonStable = true; // a text-only match cannot ground an `exact`
candidates.Add(new
{
source = tSourceText,
handler = tHandlerText,
match = stableMatch ? "stable" : "text",
span = FixSpanOf(t),
});
}
var status = candidates.Count == 0 ? "none"
: (candidates.Count == 1 && acquireStable && !anyNonStable) ? "exact"
: "ambiguous";
var key = $"{enclosing}\0{eventId}\0{sourceId}\0{handlerId}";
var ordinal = occ.TryGetValue(key, out var seen) ? seen : 0;
occ[key] = ordinal + 1;
return new
{
enclosing_member = enclosing,
event_identity = eventId,
event_contract = contract,
source_identity = sourceId,
source_identity_kind = srcKind,
handler_identity = handlerId,
handler_identity_kind = hKind,
occurrence_ordinal = ordinal,
span = FixSpanOf(a),
teardown = new { status, candidates },
};
}
// The receiver of `target.Member` ("_timer" for `_timer.Tick`), or null when the
// left side is a bare identifier (`Changed += h`).
static string? Receiver(ExpressionSyntax expr) =>
expr is MemberAccessExpressionSyntax m ? m.Expression.ToString() : null;
// A timer subscription is a `Tick`/`Elapsed` handler — DispatcherTimer and the
// WinForms timer expose `Tick`, System.Timers.Timer exposes `Elapsed`. A running
// timer strong-refs the handler's owner, so an undetached one leaks it.
static bool IsTimerEvent(ExpressionSyntax left) =>
left is MemberAccessExpressionSyntax m
&& (m.Name.Identifier.Text == "Tick" || m.Name.Identifier.Text == "Elapsed");
// --- #278 soundness: a `-=` that exists is not a `-=` that runs. -----------------
//
// A matching `-=` credits the subscription's release ONLY when it sits in a
// recognised TEARDOWN CONTEXT of the subscribing class (P-001/P-004 as written:
// `Dispose`/`OnClosed`/`Unloaded`), and is not guarded away by a parameter of its
// enclosing method. A `-=` in an arbitrary method — one nothing here proves is
// ever called, or whose guard the caller can flip — keeps today's honest OWN001
// warning instead of silently swallowing the leak class (the #238 doctrine).
// Heap-proven on SectorTS GTD: ctor `+=` to a static publisher, the only `-=`
// inside `UnregisterEventHandlers(bool UnregOnlyGoodys)` behind `if (!UnregOnly-
// Goodys)`, and the leaking callers pass `true` — the old "any `-=` in the class"
// model paired them and stayed silent over a 66%-retained heap.
//
// Teardown contexts (deliberately NO whole-program call graph — intra-class only):
// * a method the platform itself runs at end-of-life, by exact name:
// Dispose / DisposeAsync / OnClosed / OnClosing / OnUnloaded / OnFormClosed /
// OnFormClosing;
// * a handler this class wires (`+=`, bare/`this.` receiver) to its own
// lifecycle event: Closed / Closing / Unloaded / FormClosed / FormClosing /
// Disposed — including an inline lambda handler on those events;
// * any method or local function such a context PROVABLY calls (`Cleanup()`
// from `Dispose()`), transitively within the class — a SYMBOL-based
// fixpoint: only an invocation that RESOLVES to a specific own method
// extends the set, so a call to `Cleanup()` never credits an uncalled
// `Cleanup(bool)` overload.
//
// Explicit NON-contexts (follow-up to #278 — each was a silent-exemption hole):
// * a FINALIZER: for a subscription leak the subscriber stays REACHABLE
// through the publisher's delegate, so its finalizer never runs while the
// subscription is live — a `-=` there can never break the hold;
// * a `Window_Closing`-style NAME with no code wiring: the XAML attach never
// reaches this extractor, so the name alone proves nothing (it may be stale
// dead code) — a XAML-aware slice can restore it with evidence;
// * a local function or lambda merely DECLARED inside a teardown method:
// declaration is not execution — a local function counts only when the
// symbol-based closure proves a teardown calls it, a lambda only when it is
// the handler wired to a lifecycle event.
// A method name the platform (or the IDisposable contract) itself invokes at the
// end of the object's life.
static bool IsTeardownMethodName(string name) =>
name is "Dispose" or "DisposeAsync"
or "OnClosed" or "OnClosing" or "OnUnloaded" or "OnFormClosed" or "OnFormClosing";
// An event MEMBER name that fires at the subscriber's own end-of-life, so a handler
// attached to it is a teardown context.
static bool IsTeardownEventName(string name) =>
name is "Closed" or "Closing" or "Unloaded" or "FormClosed" or "FormClosing" or "Disposed";
// The event-member simple name of a `+=`/`-=` left side (`Closed`, `win.Closed`),
// null when the shape names no member.
static string? EventMemberName(ExpressionSyntax left) => left switch
{
IdentifierNameSyntax id => id.Identifier.Text,
MemberAccessExpressionSyntax ma => ma.Name.Identifier.Text,
_ => null,
};
// Is the `+=` receiver THIS object (bare `Closed +=` / `this.Closed +=`)? A handler
// hooked to ANOTHER object's `Closed` runs at that object's teardown, not ours, so
// it may not ground OUR release.
static bool IsSelfLifecycleReceiver(ExpressionSyntax left) =>
left is IdentifierNameSyntax
|| (left is MemberAccessExpressionSyntax ma && ma.Expression is ThisExpressionSyntax);
// The descendant nodes of one callable's OWN body — never descending into a
// nested lambda or local function, whose bodies do not run just because the
// enclosing method does (they get their own teardown decision).
static IEnumerable<SyntaxNode> DirectBodyNodes(SyntaxNode body) =>
body.DescendantNodes(n =>
n is not AnonymousFunctionExpressionSyntax and not LocalFunctionStatementSyntax);
// The set of THIS class's method (and local-function) SYMBOLS that are teardown
// contexts: the named/wired roots plus everything a member of the set PROVABLY
// calls, to a fixpoint. Symbol-based on purpose (#278 follow-up): the closure
// extends only through an invocation that RESOLVES to a specific own method or
// local function, so `Dispose() => Cleanup();` credits exactly `Cleanup()` —
// never a same-named, uncalled `Cleanup(bool)` overload.
static HashSet<IMethodSymbol> TeardownContextMethods(
ClassDeclarationSyntax cls, SemanticModel model, INamedTypeSymbol? clsSymbol)
{
var teardown = new HashSet<IMethodSymbol>(SymbolEqualityComparer.Default);
var work = new Queue<IMethodSymbol>();
void Add(IMethodSymbol? m)
{
if (m is not null && teardown.Add(m))
work.Enqueue(m);
}
// roots: exact platform teardown names among the class's own methods.
foreach (var md in cls.Members.OfType<MethodDeclarationSyntax>())
if (IsTeardownMethodName(md.Identifier.Text))
Add(model.GetDeclaredSymbol(md));
// roots: method-group handlers this class wires to its OWN lifecycle events
// (`Closed += OnDone;` / `this.Closing += Window_Closing;`).
foreach (var a in cls.DescendantNodes().OfType<AssignmentExpressionSyntax>())
{
if (!a.IsKind(SyntaxKind.AddAssignmentExpression)
|| EventMemberName(a.Left) is not { } evName || !IsTeardownEventName(evName)
|| !IsSelfLifecycleReceiver(a.Left))
continue;
var h = NormalizeHandler(a.Right);
if (h is not (IdentifierNameSyntax or MemberAccessExpressionSyntax { Expression: ThisExpressionSyntax }))
continue;
var info = model.GetSymbolInfo(h);