-
Notifications
You must be signed in to change notification settings - Fork 0
3833 lines (3793 loc) · 252 KB
/
Copy pathci.yml
File metadata and controls
3833 lines (3793 loc) · 252 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
name: CI
# Least privilege: every job only reads the repo (no job pushes or needs write).
# Every third-party `uses:` is pinned to a commit SHA (with a `# vN` comment for
# the human-readable version) — see README "Where it cheats" item #7. (The local
# `uses: ./` composite-action references are this repo's own action, not a
# pinnable external dependency.) `persist-credentials: false` is a separate,
# still-open hardening item (no job pushes or has secrets, so the exposure is
# checkout-token-lifetime only).
permissions:
contents: read
on:
push:
branches: ["**"]
pull_request:
workflow_dispatch:
jobs:
# Quality gate: ruff (style/bugs) on the whole tree, and mypy --strict on the
# ownlang package (tests are dynamic/fuzzer code, covered by ruff only). These
# are the "tighten the screws on Python" guard rails — see README.
lint:
name: lint (ruff + mypy --strict)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
with:
python-version: "3.11"
- name: Install linters
run: pip install "ruff==0.15.8" "mypy==1.19.1"
- name: ruff
run: ruff check .
- name: mypy --strict (ownlang)
run: mypy
# The evaluation scripts (corpus miner, cross-tool oracle diff, metamorphic
# analyzer tester) carry embedded fixtures / sweep the .own corpus; run their
# selftests here so the parsers/aggregators and the robustness invariants stay
# honest on every push, not only on workflow_dispatch.
- name: script selftests (miner + oracle + metamorphic + benchmark + contrib)
run: |
python scripts/mine_report.py --selftest
python scripts/oracle_compare.py --selftest
python scripts/oracle_exact.py --selftest
python scripts/metamorphic.py --selftest
python scripts/metamorphic_facts.py --selftest
python scripts/benchmark.py --selftest
python scripts/validate_contrib.py --selftest
# The Rust core workspace (P-022): fmt + clippy under the workspace's own
# strict [workspace.lints] + the test suite (incl. the own-ir round-trip of
# every OwnIR fixture). This is the Rust half of the migration gate; the
# Python half (the reference) is gated by `tests` / `lint` above, and the
# differential oracle ties the two together as crates land.
rust-core:
name: rust (fmt + clippy + tests)
runs-on: ubuntu-latest
defaults:
run:
working-directory: rust
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- uses: dtolnay/rust-toolchain@fa04a1451ff1842e2626ccb99004d0195b455a88 # master, 2026-07-10
with:
toolchain: stable
components: rustfmt, clippy
- name: cargo fmt --check
run: cargo fmt --check
- name: cargo clippy (workspace lints are the gate)
run: cargo clippy --all-targets
- name: cargo test
run: cargo test
# P-022 step 7b (#261) — the production OwnIR executable's parity replay, on
# BOTH platforms. The Python half of this contract is gated by the `tests`
# matrix above (`tests/test_cli_ownir_fixtures.py` is auto-discovered like
# every other `test_*.py`, and re-verifies each `oracle: "python"` case
# against the reference on 3.11/3.12/3.13). This job is the other half and
# runs NO Python: it builds the binary and replays the frozen bytes.
#
# Windows is here because the fixture is authored on Linux and a byte
# contract that has only ever been replayed on its authoring platform has not
# been tested — path forms, line endings and the OS error text are exactly
# where a CLI port diverges. `rust-core` is deliberately NOT widened to
# Windows for every crate: that is a separate cost decision, and this is the
# crate whose contract is platform-shaped.
own-cli-parity:
name: own-cli (ownir parity replay)
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, windows-latest]
runs-on: ${{ matrix.os }}
defaults:
run:
working-directory: rust
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- uses: dtolnay/rust-toolchain@fa04a1451ff1842e2626ccb99004d0195b455a88 # master, 2026-07-10
with:
toolchain: stable
- name: cargo test -p own-cli (the frozen CLI contract, zero Python)
run: cargo test -p own-cli
# The forced-panic and forced-death controls are OFF in every production
# build, so they need their own invocation. They are what makes #261's
# panic ruling a measurement rather than a design claim.
- name: cargo test -p own-cli --features fault-injection (the failure-mode controls)
run: cargo test -p own-cli --features fault-injection --test faults
# P-022 step 8 (#262) STAGE 1 — the launcher's engine-selection contract.
#
# Stage 1 makes the Rust core SELECTABLE while Python stays the default and
# the reference. Every claim in that sentence is a behaviour, so this job
# drives real launchers against a real candidate binary and asserts on the
# observable result. It is deliberately BOTH platforms: the ratified evidence
# is explicit Rust-selected runs on Windows and Linux, and the two differ in
# exactly the mechanics (process launch, executable bits, path forms) that a
# Linux-only job would leave unproven.
#
# OWEN_STAGE1_REQUIRE=1 is the zero-denominator guard: this job exists to
# provide the toolchain, so a control that skips here is a control that did
# not run, and a run of skips would otherwise be indistinguishable from a
# green one.
stage1-engine:
name: owen --engine (#262 Stage 1 controls)
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, windows-latest]
runs-on: ${{ matrix.os }}
defaults:
run:
shell: bash
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4
with:
dotnet-version: "8.0.x"
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
with:
python-version: "3.13"
- uses: dtolnay/rust-toolchain@fa04a1451ff1842e2626ccb99004d0195b455a88 # master, 2026-07-10
with:
toolchain: stable
# The PRODUCTION candidate. The controls run the real `own-cli`, not a
# stand-in: a control that proves a mock's behaviour proves nothing about
# the binary the launcher will actually spawn.
- name: Build the production own-cli candidate
working-directory: rust
run: cargo build -p own-cli --release
# A second build with #261's off-by-default fault-injection feature, so
# the failure-mode controls force failures through the real binary.
- name: Build the fault-injection own-cli (forced failure modes)
working-directory: rust
run: cargo build -p own-cli --release --features fault-injection --target-dir target-fault
# The controllable native candidate the compare controls need. Built
# with plain `rustc`, deliberately NOT as a cargo workspace member, so it
# cannot move the crate-edge DAG that #261's gate pins.
- name: Build the Stage-1 stub candidate
run: |
ext=""
if [ "${{ matrix.os }}" = "windows-latest" ]; then ext=".exe"; fi
rustc -O tests/helpers/stage1_stub.rs -o "$RUNNER_TEMP/stage1-stub$ext"
- name: Build the owen launcher
run: dotnet build frontend/roslyn/OwnSharp.Cli/OwnSharp.Cli.csproj -c Release --nologo
# The vendored Python core is a PACK-time payload, so a plain build does
# not place it where the launcher looks. Staging it here is harness setup
# for the Python-engine paths, not a production behaviour.
- name: Stage the vendored core beside the built launcher
run: |
out=frontend/roslyn/OwnSharp.Cli/bin/Release/net8.0
mkdir -p "$out/ownlang-core/ownlang"
cp ownlang/*.py "$out/ownlang-core/ownlang/"
- name: Stage-1 engine controls (no fail-fast)
env:
OWEN_STAGE1_REQUIRE: "1"
run: |
ext=""
if [ "${{ matrix.os }}" = "windows-latest" ]; then ext=".exe"; fi
export OWEN_RUST_CORE="$PWD/rust/target/release/own-cli$ext"
export OWEN_STAGE1_RUST_FAULT="$PWD/rust/target-fault/release/own-cli$ext"
export OWEN_STAGE1_STUB="$RUNNER_TEMP/stage1-stub$ext"
export OWEN_STAGE1_LAUNCHER_DLL="$PWD/frontend/roslyn/OwnSharp.Cli/bin/Release/net8.0/ownsharp.dll"
python tests/test_stage1_engine.py
# The PowerShell surface's own controls. They run on BOTH legs — the
# logic is platform-neutral and a Linux run catches regressions early —
# but only the Windows leg is evidence for a PowerShell-targeted
# mutation, which is what the stage1-windows-mutations job below settles.
- name: Stage-1 PowerShell controls
env:
OWEN_STAGE1_REQUIRE: "1"
run: |
ext=""
if [ "${{ matrix.os }}" = "windows-latest" ]; then ext=".exe"; fi
export OWEN_RUST_CORE="$PWD/rust/target/release/own-cli$ext"
export OWEN_STAGE1_STUB="$RUNNER_TEMP/stage1-stub$ext"
python tests/test_stage1_ps1.py
# P-022 Stage 2 (#262): the CI/dogfood census and the Rust-default
# controls. They live here because this is the job that already has the
# candidate, the launcher and both platforms — and REQUIRE=1 because a
# census that skips is a census that measured nothing.
- name: Stage-2 dogfood controls (census + Rust-default + public contract)
env:
OWEN_STAGE2_REQUIRE: "1"
run: |
ext=""
if [ "${{ matrix.os }}" = "windows-latest" ]; then ext=".exe"; fi
export OWEN_RUST_CORE="$PWD/rust/target/release/own-cli$ext"
export OWEN_STAGE1_LAUNCHER_DLL="$PWD/frontend/roslyn/OwnSharp.Cli/bin/Release/net8.0/ownsharp.dll"
python tests/test_stage2_dogfood.py
# The explicit Rust-selected run on this platform, through the shell
# launcher, recorded as its own step so the evidence names the surface
# and the platform rather than being inferred from a green job.
- name: Explicit Rust-selected run through own-check.sh
run: |
ext=""
if [ "${{ matrix.os }}" = "windows-latest" ]; then ext=".exe"; fi
export OWEN_RUST_CORE="$PWD/rust/target/release/own-cli$ext"
mkdir -p "$RUNNER_TEMP/stage1-sample"
cat > "$RUNNER_TEMP/stage1-sample/Leak.cs" <<'CS'
using System;
using System.IO;
public class Leaky
{
public void Run()
{
var s = new FileStream("x.txt", FileMode.OpenOrCreate);
Console.WriteLine(s.Length);
}
}
CS
set +e
out=$(bash scripts/own-check.sh --engine rust --format human --fail-on-finding -- "$RUNNER_TEMP/stage1-sample")
rc=$?
set -e
echo "$out"
[ "$rc" -eq 1 ] || { echo "FAIL: --engine rust expected exit 1 (findings), got $rc"; exit 1; }
case "$out" in *OWN001*) ;; *) echo "FAIL: no OWN001 finding from the Rust engine"; exit 1 ;; esac
echo "Rust-selected run OK on ${{ matrix.os }}"
# The Windows twin of the shell surface. own-check.ps1 is never exercised
# by the Linux legs, so its engine contract needs its own step here.
- name: own-check.ps1 -Engine rust (Windows only)
if: matrix.os == 'windows-latest'
shell: pwsh
run: |
$env:OWEN_RUST_CORE = "$PWD/rust/target/release/own-cli.exe"
$sample = Join-Path $env:RUNNER_TEMP "stage1-sample"
& ./scripts/own-check.ps1 -Engine rust -Format human -FailOnFinding -- $sample
if ($LASTEXITCODE -ne 1) { throw "own-check.ps1 -Engine rust expected exit 1, got $LASTEXITCODE" }
# And the D3.1 seam on this surface: an unusable locator is exit 2.
$env:OWEN_RUST_CORE = "C:\definitely\not\a\binary.exe"
& ./scripts/own-check.ps1 -Engine rust -Format human -- $sample
if ($LASTEXITCODE -ne 2) { throw "own-check.ps1 with a bad OWEN_RUST_CORE expected exit 2, got $LASTEXITCODE" }
Write-Host "own-check.ps1 engine contract OK"
# The assertions above ARE this step's verdict, so say so
# explicitly. GitHub's pwsh wrapper appends `exit $LASTEXITCODE`,
# and the last command here exits 2 ON PURPOSE — without this the
# step would inherit the exit code of a case it was written to
# require.
exit 0
# P-022 step 8 (#262) Stage 1 — the WINDOWS-NATIVE mutation leg.
#
# A mutation whose target is scripts/own-check.ps1 is only `caught` when a
# Windows PowerShell catcher observes the mutant and fails. Running those
# mutants on Linux would execute the mutated PowerShell under a different
# runtime, and the Windows-specific halves — the spawn seam above all —
# cannot be settled there at all: a mutant that runs where its control is
# weakest is decorative, and proves nothing about the surface it edits.
#
# The same is true of one arm of own-check.sh. A drive-rooted path is
# absolute only where Windows resolves it, so a mutant of that arm changes
# nothing a Linux runner can observe, and the Linux campaign would record it
# as caught or survived on the strength of a question it never asked.
#
# So this campaign runs here, on Windows, and this job is the gate. It fails
# unless every mutation is caught with its expected catcher and the
# honesty control survives the unmutated tree.
stage1-windows-mutations:
name: Windows-native mutation campaign (own-check.ps1 + the drive-rooted locator arm)
runs-on: windows-latest
defaults:
run:
shell: bash
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4
with:
dotnet-version: "8.0.x"
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
with:
python-version: "3.13"
- uses: dtolnay/rust-toolchain@fa04a1451ff1842e2626ccb99004d0195b455a88 # master, 2026-07-10
with:
toolchain: stable
- name: Build the production own-cli candidate
working-directory: rust
run: cargo build -p own-cli --release
- name: Build the Stage-1 stub candidate
run: rustc -O tests/helpers/stage1_stub.rs -o "$RUNNER_TEMP/stage1-stub.exe"
- name: Run the Windows-native mutation campaign
env:
OWEN_STAGE1_REQUIRE: "1"
run: |
export OWEN_RUST_CORE="$PWD/rust/target/release/own-cli.exe"
export OWEN_STAGE1_STUB="$RUNNER_TEMP/stage1-stub.exe"
python scripts/mutate_campaign.py --campaign docs/evidence/p022-stage1-windows.json --run
# The recorded run, printed in full so its provenance and per-mutation
# catchers can be read off this job rather than taken on trust — and so
# the committed record can be reproduced from a named CI run, the way
# #260's sweep record is.
- name: Print the recorded result
if: always()
run: cat docs/evidence/p022-stage1-windows.result.json
- name: Assert every mutation was caught by a Windows catcher
run: |
python - <<'PY'
import json, sys
d = json.load(open("docs/evidence/p022-stage1-windows.result.json", encoding="utf-8"))
defn = json.load(open("docs/evidence/p022-stage1-windows.json", encoding="utf-8"))
exp = {m["id"]: set(m["expected_catchers"]) for m in defn["mutations"]}
problems = []
if d["control"]["outcome"] != "survived":
problems.append("the honesty control did not survive the unmutated tree")
for m in d["mutations"]:
if m["outcome"] != "caught":
problems.append(f"{m['id']}: {m['outcome']}")
elif not exp[m["id"]] <= set(m["catchers"]):
problems.append(f"{m['id']}: expected catchers missed ({m['catchers']})")
print("\n".join(problems) if problems
else f"all {len(d['mutations'])} mutations caught natively on Windows, "
"each by the catcher its definition names")
sys.exit(1 if problems else 0)
PY
# P-022 step 8 (#262) STAGE 2 — the same campaign, on Windows, as a GATE.
#
# The Linux run is the recorded one and every Stage-2 mutant edits declarative
# text, so the verdicts ought to be identical here. "Ought to" is the word
# that cost this branch two review rounds: the Stage-2 controls themselves
# failed on Windows and passed on Linux, because a path key built with the
# host separator missed every ledger entry. That defect was in the harness,
# not in a mutant, and no Linux campaign could have reported it.
#
# So the campaign is MEASURED on both platforms rather than argued to be
# platform-independent. This job records nothing — the committed provenance
# stays the Linux run — it only fails if Windows disagrees.
stage2-windows-mutations:
name: Stage-2 mutation campaign, Windows verdict (gate only, records nothing)
runs-on: windows-latest
defaults:
run:
shell: bash
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4
with:
dotnet-version: "8.0.x"
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
with:
python-version: "3.13"
- uses: dtolnay/rust-toolchain@fa04a1451ff1842e2626ccb99004d0195b455a88 # master, 2026-07-10
with:
toolchain: stable
- name: Build the production own-cli candidate
working-directory: rust
run: cargo build -p own-cli --release
- name: Run the Stage-2 campaign on Windows
run: |
export OWEN_RUST_CORE="$PWD/rust/target/release/own-cli.exe"
python scripts/mutate_campaign.py --campaign docs/evidence/p022-stage2-1.json --run
- name: The Windows verdict must match the recorded Linux one
run: |
python - <<'PY'
import json, sys
run = json.load(open("docs/evidence/p022-stage2-1.result.json", encoding="utf-8"))
defn = json.load(open("docs/evidence/p022-stage2-1.json", encoding="utf-8"))
exp = {m["id"]: set(m["expected_catchers"]) for m in defn["mutations"]}
problems = []
if run["control"]["outcome"] != "survived":
problems.append("the honesty control did not survive the unmutated tree")
for m in run["mutations"]:
if m["outcome"] != "caught":
problems.append(f"{m['id']}: {m['outcome']} on Windows")
elif not exp[m["id"]] <= set(m["catchers"]):
problems.append(f"{m['id']}: expected catchers missed ({m['catchers']})")
print("\n".join(problems) if problems
else f"Windows agrees: all {len(run['mutations'])} mutations caught, "
"each by the catcher its definition names")
sys.exit(1 if problems else 0)
PY
# Left uncommitted on purpose: one campaign has one recorded provenance,
# and it is the Linux run. A second file claiming the same campaign name
# would make "which tree was measured" ambiguous.
#
# Restore-or-remove, because the recorded result is tracked at some
# commits and not at others, and `git checkout --` on an untracked path
# is an error rather than a no-op — which is exactly how the first
# version of this step failed a job whose verdict had already agreed.
# The assertion is the point, not the cleanup: the step fails if this
# gate left a result behind.
- name: Confirm nothing was recorded from this run
if: always()
run: |
f=docs/evidence/p022-stage2-1.result.json
if git ls-files --error-unmatch "$f" >/dev/null 2>&1; then
git checkout -- "$f"
else
rm -f "$f"
fi
test -z "$(git status --porcelain -- "$f")" \
|| { echo "FAIL: the Windows gate left a recorded result behind"; exit 1; }
echo "OK: the Windows verdict recorded nothing"
# P-022 step 7a (#260) — COMPARE MODE over the committed corpus: the FAST half
# of #260's test matrix, and one leg of it. The five pinned OSS repositories,
# the large-solution controls and the examples tree are the scheduled/manual
# half and live in `.github/workflows/shadow-sweep.yml`. Nothing here may be
# read as shadow mode having been achieved.
#
# What it gates: every committed facts document through BOTH engines on
# byte-attested same input, failing on any acceptance-`unexplained`
# observation at any of the three layers, any `renderer-only divergence` on
# the derived SARIF, or any execution failure. The generated compact and
# malformed controls run in the same pass, as the driver's own negative
# controls, so a gate that reported agreement over an empty set would be red.
shadow-compare:
name: shadow compare (committed corpus)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
with:
python-version: "3.13"
- uses: dtolnay/rust-toolchain@fa04a1451ff1842e2626ccb99004d0195b455a88 # master, 2026-07-10
with:
toolchain: stable
- name: Build the dev-only engine adapter
working-directory: rust
run: cargo build --release -p own-shadow --bin own-shadow-engine
- name: Compare mode over the committed corpus
env:
OWN_SHADOW_ENGINE: ${{ github.workspace }}/rust/target/release/own-shadow-engine
run: python scripts/shadow_compare.py --engine compare --corpus --quiet --out "$RUNNER_TEMP/shadow"
# The driver's own controls, REQUIRED here rather than skipped: this is
# the job that has the adapter, so it is the job that cannot be allowed
# to pass without exercising it. The raw-variant, negative and
# execution-failure controls all run in this pass.
- name: The compare driver's controls (adapter required)
env:
OWN_SHADOW_ENGINE: ${{ github.workspace }}/rust/target/release/own-shadow-engine
OWN_SHADOW_COMPARE_REQUIRED: "1"
run: python tests/test_shadow_compare.py
- name: Upload the divergence reports
if: failure()
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: shadow-compare-reports
path: ${{ runner.temp }}/shadow
retention-days: 14
if-no-files-found: ignore
# The same gate over the OwnIR the Roslyn extractor already produced. It
# consumes the artifact `wpf-extractor` published rather than running the
# extractor again — #260 forbids a second extraction, and this is the
# packaging that makes the first one reachable. The driver reads the
# downloaded file ONCE, as bytes.
shadow-compare-samples:
name: shadow compare (C# samples)
runs-on: ubuntu-latest
needs: wpf-extractor
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
with:
python-version: "3.13"
- uses: dtolnay/rust-toolchain@fa04a1451ff1842e2626ccb99004d0195b455a88 # master, 2026-07-10
with:
toolchain: stable
- name: Build the dev-only engine adapter
working-directory: rust
run: cargo build --release -p own-shadow --bin own-shadow-engine
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
with:
name: csharp-samples-ownir
path: ${{ runner.temp }}/samples
- name: Compare mode over the extracted C# sample facts
env:
OWN_SHADOW_ENGINE: ${{ github.workspace }}/rust/target/release/own-shadow-engine
run: |
python scripts/shadow_compare.py --engine compare \
"$RUNNER_TEMP/samples/facts.json" --quiet --out "$RUNNER_TEMP/shadow"
- name: Upload the divergence report
if: failure()
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: shadow-compare-samples-report
path: ${{ runner.temp }}/shadow
retention-days: 14
if-no-files-found: ignore
# Own.NET Audit (audit/) — the aggregation layer's selftests, the only thing the
# Linux CI gates for the audit (the target itself is analyzed on a local Windows
# machine, never in CI; see audit/README.md and Plan.md §3.2). PyYAML is scoped
# to audit/ here so the core test suite stays zero-dependency.
audit-selftests:
name: audit aggregation selftests
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
with:
python-version: "3.11"
- name: Install audit deps (PyYAML, audit-scoped)
run: pip install -r audit/requirements.txt
- name: Own.NET Audit selftests (normalize + score + report + orchestrator)
run: |
python audit/aggregate/normalize.py --selftest
python audit/aggregate/score.py --selftest
python audit/aggregate/report.py --selftest
python audit/static/tools/xaml_check.py --selftest
python audit/static/tools/xaml_facts.py --selftest
python audit/static/tools/xaml_join.py --selftest
python audit/static/run_static.py --selftest
python audit/runtime/ingest.py --selftest
environment-protection-selftest:
name: release-workflow environment-protection predicate (fixture-driven)
runs-on: ubuntu-latest
# No GitHub API call, no real Environment needed here -- this tests only
# the accept/reject PREDICATE the owen-cli-release.yml `publish` job and
# action-marketplace-readiness.yml `move-major-tag` job both call
# (scripts/check_environment_protection.sh) against fixture "Get an
# environment" API responses, entirely offline. Review: a bare
# `.protection_rules | length` check would have accepted a wait_timer-
# or branch_policy-only environment, or a required_reviewers rule with
# zero actual reviewers, as if it were a real human-approval gate.
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- name: zero protection rules -> reject
run: |
if ./scripts/check_environment_protection.sh scripts/fixtures/environment-protection/zero-rules.json; then
echo "FAIL: expected rejection (zero rules)"; exit 1
fi
- name: wait_timer only -> reject
run: |
if ./scripts/check_environment_protection.sh scripts/fixtures/environment-protection/wait-timer-only.json; then
echo "FAIL: expected rejection (wait_timer only)"; exit 1
fi
- name: branch_policy only -> reject
run: |
if ./scripts/check_environment_protection.sh scripts/fixtures/environment-protection/branch-policy-only.json; then
echo "FAIL: expected rejection (branch_policy only)"; exit 1
fi
- name: required_reviewers with zero users -> reject
run: |
if ./scripts/check_environment_protection.sh scripts/fixtures/environment-protection/required-reviewers-empty.json; then
echo "FAIL: expected rejection (required_reviewers, zero reviewers)"; exit 1
fi
- name: required_reviewers with a reviewer -> accept
run: |
./scripts/check_environment_protection.sh scripts/fixtures/environment-protection/required-reviewers-with-reviewer.json \
|| { echo "FAIL: expected acceptance (required_reviewers with a reviewer)"; exit 1; }
tests:
name: tests (py${{ matrix.python-version }})
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
# The PoC needs 3.11+ (see README). Run the floor and current releases.
python-version: ["3.11", "3.12", "3.13"]
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
# tests/test_checkpoint_status.py verifies that a recorded mutation
# campaign names a commit that exists and is an ancestor of HEAD;
# a depth-1 checkout cannot answer that, so this job takes the history.
fetch-depth: 0
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
with:
python-version: ${{ matrix.python-version }}
# Zero-dependency project: nothing to install. The suite runs the
# analyzer cases, the golden ArrayPool lowering, the codegen content
# assertions, and the property fuzzer (fixed seed) in one entrypoint.
- name: Run test suite
run: python tests/run_tests.py
# A heavier, non-blocking fuzz pass so a flake-free regression that only
# shows up on other random draws still gets surfaced on every push.
fuzz-extended:
name: extended codegen fuzz
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
with:
python-version: "3.13"
- name: Property fuzz (50k draws, rotating seed)
run: python tests/test_codegen_props.py 50000 ${{ github.run_number }}
# Prove the lowering is real: take the generated C# and put it through the
# actual .NET compiler (the PoC sandbox has no SDK, so this is the only place
# the golden example is genuinely compiled and run, not "verified by
# construction").
dotnet-golden:
name: golden C# compiles & runs (.NET)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
with:
python-version: "3.13"
- uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4
with:
dotnet-version: "8.0.x"
- name: Check the emitted method is still in sync with the golden host
run: python examples/golden_arraypool/verify_emit.py
- name: Compile & run the generated C# with the real compiler
run: |
dotnet new console -o "$RUNNER_TEMP/golden_app"
cp examples/golden_arraypool/Program.cs "$RUNNER_TEMP/golden_app/Program.cs"
dotnet run --project "$RUNNER_TEMP/golden_app"
# P-001: prove the C# leak pipeline end-to-end on real C# — the Roslyn
# extractor turns sample .cs into OwnIR facts, and the core surfaces the
# subscription leak at its C# location (and stays silent on the disposed one).
wpf-extractor:
name: C# leak extractor (Roslyn) -> OwnIR -> core
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
with:
python-version: "3.13"
- uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4
with:
# 8.0.x runs the pinned net8.0 extractor/probe; 9.0.x is only needed to BUILD the
# deliberately-incompatible net9 wrapper fixture in the step 11 Tier B suite (which
# then proves it is refused WRAPPER_RUNTIME_UNSUPPORTED under the fixed net8 probe).
dotnet-version: |
8.0.x
9.0.x
- name: Extract OwnIR facts from sample C#
run: |
dotnet run --project frontend/roslyn/OwnSharp.Extractor -- \
frontend/roslyn/samples/CustomerViewModel.cs \
frontend/roslyn/samples/LambdaHandlerViewModel.cs \
frontend/roslyn/samples/AliasedSourceViewModel.cs \
frontend/roslyn/samples/OrdersViewModel.cs \
frontend/roslyn/samples/TimerViewModel.cs \
frontend/roslyn/samples/DisposableFieldViewModel.cs \
frontend/roslyn/samples/MessengerViewModel.cs \
frontend/roslyn/samples/PooledBufferSample.cs \
frontend/roslyn/samples/LocalDisposableSample.cs \
frontend/roslyn/samples/SelfOwnedViewModel.cs \
frontend/roslyn/samples/SelfOwnedControlParts.cs \
frontend/roslyn/samples/ExternalRefSubscription.cs \
frontend/roslyn/samples/StaticHandlerViewModel.cs \
frontend/roslyn/samples/StaticEventEscapeViewModel.cs \
frontend/roslyn/samples/WhenAnyValueViewModel.cs \
frontend/roslyn/samples/DiCaptiveSample.cs \
frontend/roslyn/samples/SampleTypes.cs \
frontend/roslyn/samples/PipeFieldsSample.cs \
frontend/roslyn/samples/AppLifetimeSample.cs \
frontend/roslyn/samples/ViewOwnsVmSample.xaml.cs \
frontend/roslyn/samples/InjectedDcViewSample.xaml.cs \
frontend/roslyn/samples/ResolvedDisposableSample.cs \
frontend/roslyn/samples/FieldReleaseSample.cs \
frontend/roslyn/samples/StaticClassEscapeSample.cs \
frontend/roslyn/samples/EventSourceCountersSample.cs \
frontend/roslyn/samples/AppDomainShutdownSample.cs \
frontend/roslyn/samples/LambdaTiersSample.cs \
frontend/roslyn/samples/AliasDisposeSample.cs \
frontend/roslyn/samples/CloseReleaseSample.cs \
frontend/roslyn/samples/SemaphoreFieldSample.cs \
frontend/roslyn/samples/VoidSubscribeSample.cs \
frontend/roslyn/samples/ReturnedPublisherSample.cs \
frontend/roslyn/samples/OwnIgnoreSample.cs \
frontend/roslyn/samples/DpRotationSample.cs \
frontend/roslyn/samples/RequerySuggestedAllowlistSample.cs \
frontend/roslyn/samples/SelfDetachingHandlerSample.cs \
frontend/roslyn/samples/UsingFieldAcquisitionSample.cs \
frontend/roslyn/samples/TemplatePartLocalCaptureSample.cs \
frontend/roslyn/samples/EmptyDisposeSample.cs \
frontend/roslyn/samples/AppScopedSourceSample.cs \
frontend/roslyn/samples/WinFormsDisposalSample.cs \
frontend/roslyn/samples/AssociatedObjectSourceSample.cs \
frontend/roslyn/samples/OwnedCollectionElementSample.cs \
-o "$RUNNER_TEMP/facts.json"
cat "$RUNNER_TEMP/facts.json"
# The extractor runs ONCE in this workflow, and #260 forbids running it
# twice: frontend nondeterminism would contaminate any comparison taken
# over its output. Publishing the bytes it already wrote is how the
# shadow compare gate reaches them without a second extraction — the
# file crosses the job boundary unchanged, and the driver's own
# attestation (`input.raw` against each engine's `consumed`) is what
# proves it did.
- name: publish the extracted OwnIR for the shadow compare gate (#260)
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: csharp-samples-ownir
path: ${{ runner.temp }}/facts.json
retention-days: 7
- name: Extractor source columns (Own.NET#317)
# The producer half of #317. Runs the real extractor over a fixture that puts two
# anchor sites on ONE line and asserts each record's exact (line, column) - the
# only place in this repo where an emitted column is checked against the source
# text rather than against another hand-written fixture. REQUIRED here (dotnet is
# present); it skips cleanly in the offline Tier-A job.
env:
OWN_TIERB_REQUIRED: "1"
run: python tests/test_extractor_columns.py
- name: S2 step 10 analyzer-delta verifier (Tier B, full public CLI)
env:
OWN_TIERB_REQUIRED: "1"
run: python tests/test_verify_delta_tierb.py
- name: S2 step 11 verified-target-wrapper gate (Tier B, full public CLI)
env:
OWN_TIERB_REQUIRED: "1"
run: python tests/test_verify_target_tierb.py
- name: S2 step 12 final-evidence certification (Tier B, full public CLI)
env:
OWN_TIERB_REQUIRED: "1"
run: python tests/test_certify_tierb.py
- name: Check facts through the core
run: |
out=$(python -m ownlang ownir "$RUNNER_TEMP/facts.json" || true)
echo "$out"
echo "$out" | grep -q "OWN001" \
|| { echo "FAIL: expected OWN001"; exit 1; }
# P-004 tiering: CustomerViewModel subscribes to an INJECTED bus (a ctor
# param of unknown lifetime). We cannot prove it outlives the view model,
# so the leak is reported at WARNING level (an honest "possible leak"),
# not a hard error — until lifetime/ownership modelling lands.
echo "$out" | grep -qE "CustomerViewModel\.cs:[0-9]+: warning: \[OWN001\]" \
|| { echo "FAIL: expected CustomerViewModel as a WARNING (injected source)"; exit 1; }
echo "$out" | grep -q "injected dependency whose lifetime is unknown" \
|| { echo "FAIL: expected the injected-source wording"; exit 1; }
if echo "$out" | grep -q "OrdersViewModel.cs"; then
echo "FAIL: disposed subscription wrongly reported"; exit 1
fi
# Mined FP regression (Pipelines.Sockets.Unofficial): System.IO.Pipelines PipeReader/PipeWriter
# END WITH Reader/Writer but are NOT IDisposable (they finish via Complete(), not Dispose()), so
# an undisposed PipeReader/PipeWriter FIELD must NOT be flagged as a leak —
# IsNonDisposableReaderWriter excludes them from the field-disposable name heuristic.
if echo "$out" | grep -q "PipeFieldsSample.cs"; then
echo "FAIL: PipeReader/PipeWriter field wrongly reported as an undisposed-disposable leak"; exit 1
fi
# a lambda handler has no stored delegate, so it can NEVER be `-=`'d — the
# finding says so. (Same injected source as Customer -> also a warning.)
echo "$out" | grep -qE "LambdaHandlerViewModel\.cs:[0-9]+: warning: \[OWN001\]" \
|| { echo "FAIL: expected the lambda-handler subscription leak (warning)"; exit 1; }
echo "$out" | grep -q "inline lambda it has no '-=' handle" \
|| { echo "FAIL: expected the lambda no-handle wording"; exit 1; }
# #146 interprocedural publisher provenance (the Newtonsoft
# Create->ApplySerializerSettings shape): every caller of ApplyBounded
# constructs the publisher and returns it, so the param-publisher
# subscription is bounded -> the extractor stamps
# `source_provenance: "returned_fresh"` and the bridge drops it (SILENT).
grep -q '"source_provenance": "returned_fresh"' "$RUNNER_TEMP/facts.json" \
|| { echo "FAIL: expected the returned_fresh provenance stamp in the facts"; exit 1; }
if echo "$out" | grep -q "publisher.Error"; then
echo "FAIL: the proven returned-fresh publisher subscription must be silent"; exit 1
fi
# ...and every denial case KEEPS the honest OWN001 warning — public
# candidate, mixed callers, field-stored fresh local, the param->param
# DI dual this feature must never silence, and the two local-function
# closure escapes (callee-side capture / caller-side capture, Codex P2).
for ev in "pub.Faulted" "target.Mixed" "stored.Stored" "bus.Changed" \
"deferred.Deferred" "later.Later"; do
echo "$out" | grep -qE "ReturnedPublisherSample\.cs:[0-9]+: warning: \[OWN001\].*'$ev'" \
|| { echo "FAIL: expected the OWN001 warning to survive for '$ev' (provenance must deny)"; exit 1; }
done
# P-004 provenance: a local that ALIASES an injected source (var src =
# _bus) is NOT method-bounded — it must warn, not be silently dropped. A
# local the scope CONSTRUCTS (var owned = new Calc()) IS bounded -> silent.
echo "$out" | grep -qE "AliasedSourceViewModel\.cs:[0-9]+: warning: \[OWN001\]" \
|| { echo "FAIL: aliased-injected local should warn, not be dropped"; exit 1; }
if echo "$out" | grep -q "owned.Changed"; then
echo "FAIL: a locally-constructed publisher must be dropped"; exit 1
fi
# WPF002: the started, never-stopped timer leaks with a [resource: timer]
# tag; the timer stopped in Dispose stays silent.
echo "$out" | grep -q "TimerViewModel.cs" \
|| { echo "FAIL: expected the TimerViewModel timer leak"; exit 1; }
echo "$out" | grep -q "resource: timer" \
|| { echo "FAIL: expected a [resource: timer] tag"; exit 1; }
if echo "$out" | grep -q "CleanTimerViewModel"; then
echo "FAIL: stopped timer wrongly reported"; exit 1
fi
# WPF003: the IDisposable field the class new's but never disposes leaks
# with a [resource: disposable field] tag; the one disposed in Dispose
# stays silent.
echo "$out" | grep -q "DisposableFieldViewModel.cs" \
|| { echo "FAIL: expected the ReportViewModel field leak"; exit 1; }
echo "$out" | grep -q "resource: disposable field" \
|| { echo "FAIL: expected a [resource: disposable field] tag"; exit 1; }
if echo "$out" | grep -q "CleanReportViewModel"; then
echo "FAIL: disposed field wrongly reported"; exit 1
fi
# a static IDisposable field is a process-lifetime singleton (Dapper's
# DisposedReader.Instance) — never an owned leak, so it stays silent.
if echo "$out" | grep -q "SharedTokenHolder"; then
echo "FAIL: a static singleton IDisposable field was wrongly reported"; exit 1
fi
# P-004 resolve-aware disposability (mined: ImageSharp Vp8BitWriter/JpegBitReader):
# a field whose type NAME ends in Writer/Reader/Stream but is NOT IDisposable (and
# RESOLVES) must NOT be flagged — IsOwnedDisposableType asks the real interface.
if echo "$out" | grep -q "EncoderWithNonDisposableWriter"; then
echo "FAIL: a resolved non-IDisposable Writer/Reader field was wrongly flagged"; exit 1
fi
# control: resolved IDisposable fields (MemoryStream / CancellationTokenSource) the
# class new's but never disposes must STILL warn — real detection intact. (CodeRabbit:
# tie the assertion to OWN001 + the disposable-field resource, not just the class name.
# Severity-agnostic on purpose — the disposable-field leak renders as error, not warning.)
echo "$out" | grep -qE "ResolvedDisposableSample\.cs:[0-9]+:.*\[OWN001\].*resource: disposable field" \
|| { echo "FAIL: expected the OWN001 disposable-field finding on the resolved IDisposable control"; exit 1; }
echo "$out" | grep -q "HolderWithRealDisposable" \
|| { echo "FAIL: the resolved IDisposable control (MemoryStream/CTS) must be flagged by owner name"; exit 1; }
# dispose-optional control (Codex): Task / DataTable ARE IDisposable but disposal is
# optional (IsDisposeOptional) — a new'd, undisposed field of these must stay SILENT.
if echo "$out" | grep -q "HolderWithDisposeOptional"; then
echo "FAIL: a dispose-optional (Task/DataTable) field was wrongly flagged"; exit 1
fi
# the same rule for string-backed reader/writer fields (field-notes #8, Newtonsoft
# TraceJsonReader/Writer): a new'd, undisposed StringWriter/StringReader holds no
# unmanaged resource -> must stay SILENT (IsDisposeOptional, System.IO).
if echo "$out" | grep -q "HolderWithStringWriter"; then
echo "FAIL: a dispose-optional (StringWriter/StringReader) field was wrongly flagged"; exit 1
fi
# field release recognition (mined: ImageSharp). #2 null-conditional dispose
# `field?.Dispose()` must be recognized -> silent; the undisposed control still warns.
if echo "$out" | grep -q "DisposesViaConditional"; then
echo "FAIL: a field disposed via null-conditional field?.Dispose() was wrongly flagged"; exit 1
fi
echo "$out" | grep -q "NeverDisposesField" \
|| { echo "FAIL: an undisposed IDisposable field control must still warn"; exit 1; }
# #3 a pooled FIELD released cross-member (ctor rent + Dispose Return) must be silent;
# the rented-never-returned control still warns.
if echo "$out" | grep -q "pooled buffer 'returnedBuf'"; then
echo "FAIL: a pooled field returned in Dispose was wrongly flagged"; exit 1
fi
echo "$out" | grep -q "pooled buffer 'leakedBuf'" \
|| { echo "FAIL: a pooled field rented but never returned must still warn"; exit 1; }
# field disposed through a local ALIAS (mined: Npgsql NpgsqlDataSource): `var cts = _cts;
# cts.Dispose();` (and the `this._f` / `cts?.Dispose()` shapes) releases the field -> the
# aliased fields must be SILENT.
if echo "$out" | grep -qE "'_aliased'|'_aliasedQ'"; then
echo "FAIL: a field disposed through a local alias was wrongly reported as undisposed"; exit 1
fi
# controls: an alias that is never disposed, and an alias REBOUND to a new object, must
# both STILL leak (the recognition needs an actual dispose on an un-reassigned alias).
echo "$out" | grep -qE "AliasDisposeSample\.cs:[0-9]+:.*\[OWN001\].*'_neverDisposed'" \
|| { echo "FAIL: a field aliased but never disposed must still warn"; exit 1; }
echo "$out" | grep -qE "AliasDisposeSample\.cs:[0-9]+:.*\[OWN001\].*'_rebound'" \
|| { echo "FAIL: a field whose alias was rebound to a new object must still warn"; exit 1; }
# Codex control: an alias rebound through a ref/out ARGUMENT must still leak.
echo "$out" | grep -qE "AliasDisposeSample\.cs:[0-9]+:.*\[OWN001\].*'_refRebound'" \
|| { echo "FAIL: a field whose alias was rebound via a ref/out argument must still warn"; exit 1; }
# Codex/CodeRabbit control: aliases are symbol-scoped, not name-keyed — an unrelated
# same-named local disposed in another method must NOT credit the field, so it still leaks.
echo "$out" | grep -qE "AliasDisposeSample\.cs:[0-9]+:.*\[OWN001\].*'_scopedLeak'" \
|| { echo "FAIL: a same-named local in another scope must not be miscredited (symbol-scoped aliases)"; exit 1; }
# a field released via `.Close()` (direct and null-conditional) must be SILENT — mirrors the
# local detector's Dispose/Close/DisposeAsync set (mined: Npgsql ReplicationConnection._npgsqlConnection).
if echo "$out" | grep -qE "'_closedConn'|'_closedConnQ'"; then
echo "FAIL: a field released via .Close() was wrongly reported as undisposed"; exit 1
fi
# control: a connection-like field NEITHER closed NOR disposed must STILL warn.
echo "$out" | grep -qE "CloseReleaseSample\.cs:[0-9]+:.*\[OWN001\].*'_leakedConn'" \
|| { echo "FAIL: a field that is never closed/disposed must still warn (Close-as-release stays scoped to an actual Close call)"; exit 1; }
# Codex/CodeRabbit control: Close() credits THIS instance's field only — closing ANOTHER instance
# of the same class's same-named field must NOT suppress this object's leak (ThisFieldName, not a
# receiver-stripping name match that a same-class ContainingType check would also miss).
echo "$out" | grep -qE "CloseReleaseSample\.cs:[0-9]+:.*\[OWN001\].*'_xconn'" \
|| { echo "FAIL: other-instance .Close() must not credit this field (receiver-scoped to this/alias)"; exit 1; }
# P-004 SemaphoreSlim FIELD dispose-optional (mined: Npgsql NpgsqlDataSource._setupMappingsSemaphore):
# a SemaphoreSlim field used only for Wait/Release (AvailableWaitHandle never read) frees nothing on
# Dispose -> must be SILENT.
if echo "$out" | grep -q "'_optionalSem'"; then
echo "FAIL: a SemaphoreSlim field whose AvailableWaitHandle is never read was wrongly reported (dispose-optional)"; exit 1
fi
# gate control: a SemaphoreSlim field whose AvailableWaitHandle IS read allocates a handle Dispose
# must release -> it must STILL warn (proves the exemption is gated, not blanket — Codex).
echo "$out" | grep -qE "SemaphoreFieldSample\.cs:[0-9]+:.*\[OWN001\].*'_handleSem'" \
|| { echo "FAIL: a SemaphoreSlim field whose AvailableWaitHandle is read must still warn"; exit 1; }
# Codex control: an AvailableWaitHandle read THROUGH A FIELD ALIAS must credit the field -> still warn.
echo "$out" | grep -qE "SemaphoreFieldSample\.cs:[0-9]+:.*\[OWN001\].*'_aliasedSem'" \
|| { echo "FAIL: an aliased AvailableWaitHandle read must keep the field tracked (alias-aware gate)"; exit 1; }
# type-scope control: a non-SemaphoreSlim owned IDisposable (CTS) never disposed must STILL warn.
echo "$out" | grep -qE "SemaphoreFieldSample\.cs:[0-9]+:.*\[OWN001\].*'_ctsControl'" \
|| { echo "FAIL: a non-SemaphoreSlim owned IDisposable field must still warn (exemption stays SemaphoreSlim-scoped)"; exit 1; }
# field-scoped: the existing method-bounded LOCAL SemaphoreSlim leak (FlowLocalsSample.semLeak) must
# be UNAFFECTED — checked in the --flow-locals step below; this exemption never touches IsDisposeOptional.
# WPF004: an ignored `X.Subscribe(...)` result leaks; the captured+
# disposed one stays silent. "ignored" is unique to the WPF004 message.
echo "$out" | grep -q "MessengerViewModel.cs" \
|| { echo "FAIL: expected the InboxViewModel ignored-Subscribe leak"; exit 1; }
echo "$out" | grep -q "is ignored" \
|| { echo "FAIL: expected the ignored-Subscribe message"; exit 1; }
# P-004 resolve-aware ignored-Subscribe (mined: StackExchange.Redis): a bare `x.Subscribe(...)`
# whose call returns VOID (the Redis `ISubscriber.Subscribe(channel, handler, flags)` shape) has
# no IDisposable token -> must be SILENT; the IDisposable-returning Subscribe still WARNs.
if echo "$out" | grep -q "leaking 'VoidSubscriber'"; then
echo "FAIL: a void-returning .Subscribe(...) was wrongly flagged as an ignored IDisposable subscription"; exit 1
fi
echo "$out" | grep -q "leaking 'DisposableSubscriber'" \
|| { echo "FAIL: an ignored IDisposable-returning .Subscribe(...) must still warn (resolve-aware stays scoped)"; exit 1; }
# Codex control: a `dynamic` receiver's Subscribe has a dynamic return -> unprovable -> still WARN.
echo "$out" | grep -q "leaking 'DynamicSubscriber'" \
|| { echo "FAIL: an ignored dynamic .Subscribe(...) must still warn (dynamic return is unknown, not silenced)"; exit 1; }
if echo "$out" | grep -q "CleanInboxViewModel"; then
echo "FAIL: captured+disposed subscription wrongly reported"; exit 1
fi
# POOL001: a Rent'd-but-never-Return'd buffer leaks; the rent+return
# (finally) one stays silent.
echo "$out" | grep -q "pooled buffer 'leaky'" \
|| { echo "FAIL: expected the rented-not-returned buffer leak"; exit 1; }
if echo "$out" | grep -q "pooled buffer 'ok'"; then
echo "FAIL: returned buffer wrongly reported"; exit 1
fi
# P-005 D1: a `new`'d local IDisposable never disposed leaks; a `using`
# one and a returned (transferred) one stay silent.
echo "$out" | grep -q "local IDisposable 'leaky'" \
|| { echo "FAIL: expected the undisposed-local leak"; exit 1; }
echo "$out" | grep -q "LocalDisposableSample.cs" \
|| { echo "FAIL: expected LocalDisposableSample.cs in the local-disposable finding"; exit 1; }
echo "$out" | grep -q "resource: disposable]" \
|| { echo "FAIL: expected a [resource: disposable] tag"; exit 1; }
if echo "$out" | grep -qE "'guarded'|'moved'"; then
echo "FAIL: using/returned local wrongly reported"; exit 1
fi
# P-004 self-owned exemption: a subscription whose source is a field the
# class constructs (owns) is a GC-collectable cycle, not a leak — silent.
if echo "$out" | grep -q "SelfOwnedViewModel.cs"; then
echo "FAIL: a self-owned subscription was wrongly reported"; exit 1
fi
# P-004 self-owned (extended): a field built indirectly via a `ref`/`out`
# helper, or fetched as one of the control's own template parts
# (GetTemplateChild), is owned just like a `new`'d field — both
# subscriptions in SelfOwnedControlParts are collectable cycles -> silent.
if echo "$out" | grep -q "SelfOwnedControlParts.cs"; then
echo "FAIL: a self-owned (ref-built / template-part) subscription was wrongly reported"; exit 1
fi
# P-004 (ref/out narrowing, Codex P2): a field populated by an EXTERNAL
# class's ref method (not this class's own helper) is NOT self-owned — the
# subscription must still be reported, not silently suppressed.
echo "$out" | grep -qE "ExternalRefSubscription\.cs:[0-9]+: warning: \[OWN001\]" \
|| { echo "FAIL: expected OWN001 on the external-ref subscription (must not be exempted)"; exit 1; }
# P-004 self-WhenAnyValue classifier (docs/notes/self-whenany-precision.md):
# `this.WhenAnyValue(p => p.SelfProp[, q => q.Other]).Subscribe` over the
# component's OWN single-hop properties is a collectable self-cycle ->
# silent; a nested path through an INJECTED object, or a combinator that
# mixes in an EXTERNAL observable, stays a flagged leak (OWN001).
echo "$out" | grep -q "x.Svc.Name" \
|| { echo "FAIL: nested-path WhenAnyValue (injected Svc) must leak"; exit 1; }
echo "$out" | grep -q "CombineLatest" \
|| { echo "FAIL: combinator WhenAnyValue (external observable) must leak"; exit 1; }
# the multi-arg single-hop self chain must be SILENCED (the fix): `x => x.B`
# appears only in that chain, so it must not surface anywhere.
if echo "$out" | grep -q "x => x.B"; then
echo "FAIL: multi-arg single-hop self WhenAnyValue must be silenced"; exit 1
fi
# exactly two WhenAnyValueViewModel leaks (nested + combinator) — the three
# self-rooted chains produce nothing.
n=$(echo "$out" | grep -cE "WhenAnyValueViewModel\.cs:[0-9]+:.*\[OWN001\]")
[ "$n" = "2" ] \
|| { echo "FAIL: expected exactly 2 WhenAnyValueViewModel leaks, got $n"; exit 1; }
# P-004 static-handler exemption: a static-method handler has a null
# delegate target — no instance retained, so not a leak — silent.
if echo "$out" | grep -q "StaticHandlerViewModel.cs"; then
echo "FAIL: a static-handler subscription was wrongly reported"; exit 1
fi
# P-004 WPF005 region escape: an INSTANCE handler subscribed to a
# process-lived STATIC event (Calc.GlobalPing) with no `-=` is a region
# escape, NOT a token leak. The extractor lowers the static-source `+=` to
# a `capture` fact and the core's region engine reports OWN014 (the
# view-model is promoted to process lifetime), an error — proving real C#
# static-event subscriptions reach the region core, not only OWN001.
echo "$out" | grep -qE "StaticEventEscapeViewModel\.cs:[0-9]+: error: \[OWN014\]" \
|| { echo "FAIL: expected OWN014 region escape on the static-event instance subscription"; exit 1; }
echo "$out" | grep -q "region escape" \
|| { echo "FAIL: expected the region-escape wording on the static-event capture"; exit 1; }
# P-004 process-lifetime AppDomain-event exemption (mined: Npgsql PoolManager): a
# NON-CAPTURING handler on a process-host AppDomain event (ProcessExit/DomainUnload/
# UnhandledException/FirstChanceException) is a shutdown/diagnostics hook meant to live
# for the process -> NOT a region escape -> silent. (Scoped to ShutdownCleanup, the
# exempt class — CapturingShutdownSubscriber in the same file MUST still raise OWN014.)
if echo "$out" | grep -qE "OWN014.*'ShutdownCleanup'"; then
echo "FAIL: ShutdownCleanup's non-capturing AppDomain subscriptions were wrongly reported as OWN014"; exit 1
fi
# issue #199 — capture-aware static tier: the region escape keys off whether the handler
# RETAINS an instance. A CAPTURING lambda on a non-AppDomain static event (NonAppDomain-
# Subscriber captures the ctor's `cts` — the CsvHelper cts/resetEvent shape Codex defended)