Skip to content

fix(ci): the Windows gate failed on its own cleanup after its verdict… #1947

fix(ci): the Windows gate failed on its own cleanup after its verdict…

fix(ci): the Windows gate failed on its own cleanup after its verdict… #1947

Workflow file for this run

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)
# still pins it -> OWN014; an AppDomain handler that captures instance state (Capturing-
# ShutdownSubscriber) is still pinned -> OWN014. Both must stay flagged.
echo "$out" | grep -qE "\[OWN014\].*NonAppDomainSubscriber" \
|| { echo "FAIL: a CAPTURING lambda on a non-AppDomain static event must still raise OWN014"; exit 1; }
echo "$out" | grep -qE "\[OWN014\].*CapturingShutdownSubscriber" \
|| { echo "FAIL: an instance-capturing AppDomain handler must still raise OWN014 (exemption is non-capturing only)"; exit 1; }
# issue #199 FP FIX: a NON-CAPTURING lambda on a static event retains no instance (the
# closure analog of the static-METHOD exemption / StaticHandlerViewModel) -> OWN014's
# premise ("subscriber pinned") fails -> SILENT. Reverses the prior false positive where a
# non-capturing static lambda was flagged (policy: docs/notes/subscription-leaks-and-
# profiles.md — "static + non-retaining handler -> silent; static + retaining -> OWN014").
if echo "$out" | grep -q "NonCapturingStaticSubscriber"; then
echo "FAIL: a NON-capturing lambda on a static event must be SILENT (retains no instance)"; exit 1
fi
# issue #199 cosmetic: a LAMBDA handler's OWN014 message spells out the no-'-=' handle,
# like OWN001 does (extractor stamps lambda:true; the bridge folds in the note).
echo "$out" | grep -qE "\[OWN014\].*NonAppDomainSubscriber.*inline lambda it has no" \
|| { echo "FAIL: OWN014 on a lambda handler must carry the inline-lambda no-'-=' note"; exit 1; }
# issue #199 — POSITIVE anchor for LambdaTiersSample.cs: an INJECTED-source lambda
# (publisher handed in as a ctor param, lifetime unknown) is the honest hedge -> OWN001
# WARNING, and it carries the inline-lambda no-'-=' note like OWN014 does. This is the ONLY
# component the extractor emits from LambdaTiersSample.cs, so it proves the file is actually
# extracted — without it the two silent negatives below would pass VACUOUSLY (CodeRabbit).
echo "$out" | grep -qE "LambdaTiersSample\.cs:[0-9]+: warning: \[OWN001\].*injected dependency.*'InjectedSourceLambda'" \
|| { echo "FAIL: expected OWN001 warning on the injected-source lambda (also the file's extraction anchor)"; exit 1; }
echo "$out" | grep -qE "\[OWN001\].*InjectedSourceLambda.*inline lambda it has no" \
|| { echo "FAIL: OWN001 on a lambda handler must also carry the inline-lambda no-'-=' note"; exit 1; }
# issue #199 — the SILENT lambda tiers (bounded/local + self-owned): a lambda on a
# method-bounded local source, or on a field the class constructs, is not a heap leak
# (the '-=-impossible-but-bounded' shape) -> must be silent. These negatives are NOT
# vacuous: the InjectedSourceLambda anchor above proves the file reached the extractor.
if echo "$out" | grep -qE "LocalBoundedLambda|SelfOwnedFieldLambda"; then
echo "FAIL: a lambda on a bounded/local or self-owned source must stay SILENT"; exit 1
fi
# the unsubscribed variant (a matching `-=`, released capture) is mitigated
# -> silent. Must NOT be reported.
if echo "$out" | grep -q "CleanStaticEventViewModel"; then
echo "FAIL: an unsubscribed (released) static-event subscription was wrongly reported"; exit 1
fi
# P-004 robust static-handler exemption (mined: ImageSharp MemoryAllocatorValidator): a
# static-METHOD handler on a static event stores a null-target delegate -> no instance is
# retained -> OWN014 must NOT fire, even when the method-group symbol surfaces as a member
# group (now resolved via CandidateSymbols). (StaticEventEscapeViewModel above proves an
# INSTANCE handler on the same static event still escapes, so this stays scoped.)
if echo "$out" | grep -q "StaticAllocationCounter"; then
echo "FAIL: a static-method handler on a static event was wrongly reported as a region escape"; exit 1
fi
# P-004 EventSource diagnostic-counter exemption (mined: Npgsql NpgsqlEventSource): a
# DiagnosticCounter (EventCounter / PollingCounter / Incrementing{Event,Polling}Counter)
# built with `this` is registered to the parent EventSource and shares its process
# lifetime -> idiomatically never field-disposed -> must NOT be flagged as an undisposed leak.
if echo "$out" | grep -qE "'_bytesPerSecond'|'_totalBytes'|'_commandDuration'|'_totalCommands'"; then
echo "FAIL: an EventSource-owned DiagnosticCounter field was wrongly reported as an undisposed leak"; exit 1
fi
# scope control (Codex): the exemption keys off the DiagnosticCounter type handed to
# `this`, NOT the EventSource class — so a plain owned IDisposable field in the same
# EventSource (`_scratch`) must STILL raise the OWN001 disposable-field leak.
echo "$out" | grep -qE "EventSourceCountersSample\.cs:[0-9]+:.*\[OWN001\].*'_scratch'" \
|| { echo "FAIL: a non-counter owned IDisposable field in an EventSource must still warn (exemption stays counter-type-scoped)"; exit 1; }
# declared-type control (Codex): a field DECLARED as a plain IDisposable that is ALSO
# assigned a counter once (`_mixed = new EventCounter(..., this)`) must STILL leak its
# earlier `new MemoryStream()` — the exemption requires the DECLARED field type to be a
# DiagnosticCounter, so a name-only skip cannot hide the non-counter resource.
echo "$out" | grep -qE "EventSourceCountersSample\.cs:[0-9]+:.*\[OWN001\].*'_mixed'" \
|| { echo "FAIL: a field declared as a non-counter IDisposable that is later assigned a counter must still leak (exemption requires the DECLARED type to be a DiagnosticCounter)"; exit 1; }
# P-004 process-lived-subscriber exemption (mined: ScreenToGif App +
# Translator): the WPF `App` singleton hooking the process-lived
# AppDomain.UnhandledException promotes nothing, so the static-source region
# escape (OWN014) must NOT fire — for both the name-based (`partial class
# App`) and base-based (`: Application`) shapes.
if echo "$out" | grep -q "AppLifetimeSample.cs"; then
echo "FAIL: a process-lived App static-event subscription was wrongly reported (OWN014 FP)"; exit 1
fi
# P-004 WPF MVVM ownership (mined: ScreenToGif VideoSource): a view that
# CONSTRUCTS its view-model in its own XAML (`<X.DataContext><VM/>` — read from
# the sibling .xaml) owns it, so a field assigned from `DataContext` is
# self-owned and subscribing to its events is a collectable cycle -> SILENT.
if echo "$out" | grep -q "ViewOwnsVmSample"; then
echo "FAIL: a view that owns its VM via its own XAML DataContext was wrongly reported"; exit 1
fi
# negative control: a view whose XAML BINDS its DataContext (`<Binding/>`) does
# NOT own the VM (it may be externally supplied), so the subscription must
# still WARN — proving the gate keys off proven construction, not every cast.
echo "$out" | grep -qE "InjectedDcViewSample\.xaml\.cs:[0-9]+: warning: \[OWN001\].*injected dependency whose lifetime is unknown" \
|| { echo "FAIL: a bound (unowned) DataContext subscription must still warn with the injected-source wording"; exit 1; }
# P-006 DI001 (captive dependency): the registration + constructor graph
# extracted from DiCaptiveSample.cs feeds ownlang/di.py. A singleton that
# captures a scoped service — directly, transitively through a transient,
# or through an interface registration — is flagged at the registration
# site; a singleton->singleton edge and the clean registrations stay silent.
echo "$out" | grep -q "DI001" \
|| { echo "FAIL: expected DI001 captive-dependency findings"; exit 1; }
echo "$out" | grep -q "singleton 'EmailSender' captures scoped service 'AppDbContext'" \
|| { echo "FAIL: expected the direct captive (singleton EmailSender -> scoped AppDbContext)"; exit 1; }
# the transitive capture must thread through the transient UnitOfWork.
echo "$out" | grep -q "ReportService -> UnitOfWork -> AppDbContext" \
|| { echo "FAIL: expected the transitive captive path via the transient UnitOfWork"; exit 1; }
# the interface registration (AddScoped<IRepo, Repo>) must map so the
# singleton consuming IRepo is caught.
echo "$out" | grep -q "singleton 'CacheService' captures scoped service 'IRepo'" \
|| { echo "FAIL: expected the interface-registration captive (CacheService -> IRepo)"; exit 1; }
# C# 12 primary-constructor injection (deps on the class declaration, not a
# ctor member) must be read too.
echo "$out" | grep -q "singleton 'PrimaryCtorService' captures scoped service 'AppDbContext'" \
|| { echo "FAIL: expected the primary-constructor captive (PrimaryCtorService -> AppDbContext)"; exit 1; }
echo "$out" | grep -q "DiCaptiveSample.cs" \
|| { echo "FAIL: expected the DI001 findings at the DiCaptiveSample.cs registration site"; exit 1; }
# NOT captive: singleton->singleton (Metrics->Clock), and PublicCtorOnly —
# DI resolves its public parameterless ctor, so the wider PRIVATE ctor's
# scoped dependency is never used. None of these may be flagged.
if echo "$out" | grep -qE "captures scoped service '(Clock|Metrics)'" \
|| echo "$out" | grep -q "'PublicCtorOnly'"; then
echo "FAIL: a singleton->singleton, public-ctor-only, or clean registration was wrongly flagged captive"; exit 1
fi
# exactly four captive dependencies (direct + transitive + interface + primary-ctor).
nd=$(echo "$out" | grep -cE "DiCaptiveSample\.cs:[0-9]+:.*\[DI001\]")
[ "$nd" = "4" ] \
|| { echo "FAIL: expected exactly 4 DI001 captive findings, got $nd"; exit 1; }
# P-006 Q#1: each captive finding anchors at the registration site but ALSO names its
# CONSUMING CONSTRUCTOR (where the capture is injected) — the explicit ctor line for
# EmailSender, and the class-declaration line for the C# 12 primary ctor.
echo "$out" | grep -qE "consumed by the 'EmailSender' constructor at .*DiCaptiveSample\.cs:25\]" \
|| { echo "FAIL: expected the consuming-constructor anchor (EmailSender ctor, line 25)"; exit 1; }
echo "$out" | grep -qE "consumed by the 'PrimaryCtorService' constructor at .*DiCaptiveSample\.cs:33\]" \
|| { echo "FAIL: expected the primary-constructor consuming anchor (PrimaryCtorService, line 33)"; exit 1; }
# P-006 DI003 (transient IDisposable captured by a singleton, WARNING): the
# singleton ConnectionWarmer holds the transient IDisposable PooledConnection
# for the app lifetime (disposed only at root disposal). A warning, distinct
# from a DI001 — the "exactly 4 DI001" count above proves it is not miscounted.
echo "$out" | grep -qE "\[DI003\].*'ConnectionWarmer' captures transient IDisposable 'PooledConnection'" \
|| { echo "FAIL: expected DI003 (ConnectionWarmer captures transient IDisposable PooledConnection)"; exit 1; }
nw=$(echo "$out" | grep -cE "DiCaptiveSample\.cs:[0-9]+:.*\[DI003\]")
[ "$nw" = "1" ] \
|| { echo "FAIL: expected exactly 1 DI003 finding, got $nw"; exit 1; }
# DI003 carries the consuming-constructor anchor too (ConnectionWarmer's ctor, line 50).
echo "$out" | grep -qE "consumed by the 'ConnectionWarmer' constructor at .*DiCaptiveSample\.cs:50\]" \
|| { echo "FAIL: expected the DI003 consuming-constructor anchor (ConnectionWarmer, line 50)"; exit 1; }
# P-006 DI002 (scoped service held by a singleton via WeakReference<T>, WARNING):
# the weak ref is the usual "fix" for a DI001 captive, but scoped AppDbContext is
# still root-resolved and app-lived — the lifetime contract is still violated. The
# weak edge is OFF the strong graph, so WeakCache is a DI002, NOT a 5th DI001 (the
# "exactly 4 DI001" count above proves it). A weak ref to a SINGLETON
# (WeakClockHolder -> Clock) is no mismatch -> silent.
echo "$out" | grep -qE "\[DI002\].*'WeakCache' weakly captures scoped service 'AppDbContext'" \
|| { echo "FAIL: expected DI002 (WeakCache weakly captures scoped AppDbContext)"; exit 1; }
# a NULLABLE WeakReference<AppDbContext>? is the same weak captive — the `?` annotation
# is unwrapped, so the scoped service is still seen (CodeRabbit review on #63).
echo "$out" | grep -qE "\[DI002\].*'WeakCacheOpt' weakly captures scoped service 'AppDbContext'" \
|| { echo "FAIL: expected DI002 on the nullable WeakReference (WeakCacheOpt)"; exit 1; }
# transitive DI002: a singleton weakly holds the transient UnitOfWork, which strongly
# drags in scoped AppDbContext (WeakReport -> UnitOfWork -> AppDbContext). The weak DFS
# follows the transient's strong edges like DI001 does.
echo "$out" | grep -qE "\[DI002\].*'WeakReport' weakly captures scoped service 'AppDbContext'" \
|| { echo "FAIL: expected transitive DI002 (WeakReport -> UnitOfWork -> AppDbContext)"; exit 1; }
# pin the rendered transitive PATH (not just the finding), so a path-rendering
# regression fails CI (CodeRabbit review on #64).
echo "$out" | grep -q "WeakReport -> UnitOfWork -> AppDbContext" \
|| { echo "FAIL: expected the transitive DI002 path text"; exit 1; }
nwk=$(echo "$out" | grep -cE "DiCaptiveSample\.cs:[0-9]+:.*\[DI002\]")
[ "$nwk" = "3" ] \
|| { echo "FAIL: expected exactly 3 DI002 findings, got $nwk"; exit 1; }
# DI002 carries the consuming-constructor anchor too (WeakCache's ctor, line 57).
echo "$out" | grep -qE "consumed by the 'WeakCache' constructor at .*DiCaptiveSample\.cs:57\]" \
|| { echo "FAIL: expected the DI002 consuming-constructor anchor (WeakCache, line 57)"; exit 1; }
if echo "$out" | grep -q "WeakClockHolder"; then
echo "FAIL: a weak ref to a singleton (WeakClockHolder) was wrongly flagged"; exit 1
fi
# P-006 DI004 (transient IDisposable resolved BY HAND from the root IServiceProvider,
# WARNING): a singleton that service-locates a transient IDisposable off its injected
# root provider — tracked to app shutdown. This is a CALL SITE the registration graph
# (DI001/2/3) cannot see, so it is the unique slice. Three flagged shapes:
# - ConnectionResolver -> PooledConnection (block-bodied ctor, direct)
# - ExprBodiedResolver -> PooledConnection (EXPRESSION-bodied ctor; Codex)
# - WrapperResolver -> MidConnection -> PooledConnection (TRANSITIVE: the root builds
# the non-disposable wrapper's transient subtree; the DFS mirrors DI003; Codex)
echo "$out" | grep -qE "\[DI004\].*'ConnectionResolver' resolves transient IDisposable 'PooledConnection'" \
|| { echo "FAIL: expected DI004 (ConnectionResolver service-locates PooledConnection)"; exit 1; }
echo "$out" | grep -qE "\[DI004\].*'ExprBodiedResolver' resolves transient IDisposable 'PooledConnection'" \
|| { echo "FAIL: expected DI004 on the expression-bodied ctor (ExprBodiedResolver)"; exit 1; }
echo "$out" | grep -qE "\[DI004\].*'WrapperResolver' resolves transient IDisposable 'PooledConnection'" \
|| { echo "FAIL: expected transitive DI004 (WrapperResolver -> MidConnection -> PooledConnection)"; exit 1; }
# pin the rendered transitive PATH (not just the finding), like the DI002 transitive case.
echo "$out" | grep -q "WrapperResolver -> MidConnection -> PooledConnection" \
|| { echo "FAIL: expected the transitive DI004 path text"; exit 1; }
n4=$(echo "$out" | grep -cE "DiCaptiveSample\.cs:[0-9]+:.*\[DI004\]")
[ "$n4" = "3" ] \
|| { echo "FAIL: expected exactly 3 DI004 findings, got $n4"; exit 1; }
# DI004's consumer is the GetRequiredService CALL SITE (not a ctor) — and the leak IS
# that call, so it is the PRIMARY anchor (the line prefix), not the registration site
# (Codex review): ConnectionResolver:79, ExprBodiedResolver:123, transitive
# WrapperResolver:137 (the entry MidConnection's call, not the dragged-in disposable).
echo "$out" | grep -qE "DiCaptiveSample\.cs:79: warning: \[DI004\].*'ConnectionResolver'" \
|| { echo "FAIL: expected DI004 ConnectionResolver ANCHORED at its call site (line 79)"; exit 1; }
echo "$out" | grep -qE "DiCaptiveSample\.cs:123: warning: \[DI004\].*'ExprBodiedResolver'" \
|| { echo "FAIL: expected DI004 ExprBodiedResolver anchored at its call site (line 123)"; exit 1; }
echo "$out" | grep -qE "DiCaptiveSample\.cs:137: warning: \[DI004\].*'WrapperResolver'" \
|| { echo "FAIL: expected transitive DI004 WrapperResolver anchored at the entry call site (line 137)"; exit 1; }
# the registration site rides along as the SECONDARY anchor (named in EACH DI004
# message tail) — exactly 3, one per finding, so a partial-suffix regression (the tail
# on some findings but not all) fails CI too (CodeRabbit review; mirrors the counts above).
nreg=$(echo "$out" | grep -cE "\[DI004\].*singleton registered at ")
[ "$nreg" = "3" ] \
|| { echo "FAIL: expected exactly 3 DI004 registration-site suffixes, got $nreg"; exit 1; }
# the three controls each pin one precision guard and must stay SILENT: ScopedResolver
# resolves from a SCOPE it creates (scope.ServiceProvider — the correct shape);
# PlainResolver resolves a NON-disposable transient whose only dep is scoped (the root
# does not track it); RequestResolver is SCOPED (its injected provider is the request
# scope, not the root). MidConnection itself (a transient wrapper) is not a singleton.
if echo "$out" | grep -qE "(ScopedResolver|PlainResolver|RequestResolver)"; then
echo "FAIL: a correct/non-leaking resolver (scope-resolved, non-disposable, or scoped) was wrongly flagged DI004"; exit 1
fi
# P-006 DI005 (scope-cached captive, WARNING): a singleton that resolves a SCOPED service
# from a scope it CREATES (the correct IServiceScopeFactory pattern) but CACHES it into a
# field — the scope is disposed when the operation ends, so the cached instance dangles
# and is promoted to application lifetime (the captive returns, hidden behind the fix).
echo "$out" | grep -qE "\[DI005\].*'ScopeCachingService' caches scoped service 'AppDbContext'" \
|| { echo "FAIL: expected DI005 (ScopeCachingService caches scope-resolved scoped AppDbContext)"; exit 1; }
# transitive DI005: a singleton caches the TRANSIENT UnitOfWork (which ctor-injects scoped
# AppDbContext) from a created scope — the DFS follows the cached transient's strong edges
# like DI001, so the dragged-in scoped service is found. A captive DI001/3/4 cannot see.
echo "$out" | grep -qE "\[DI005\].*'UnitOfWorkCachingService' caches scoped service 'AppDbContext'" \
|| { echo "FAIL: expected transitive DI005 (UnitOfWorkCachingService -> UnitOfWork -> AppDbContext)"; exit 1; }
echo "$out" | grep -q "UnitOfWorkCachingService -> UnitOfWork -> AppDbContext" \
|| { echo "FAIL: expected the transitive DI005 path text"; exit 1; }
n5=$(echo "$out" | grep -cE "DiCaptiveSample\.cs:[0-9]+:.*\[DI005\]")
[ "$n5" = "2" ] \
|| { echo "FAIL: expected exactly 2 DI005 findings (direct + transitive), got $n5"; exit 1; }
# DI005's consumer is the field-STORE site (not a ctor), the PRIMARY anchor — the direct
# case at line 154 (`_db = ...AppDbContext`), the transitive case at the cached ENTRY's
# store (line 201, `_uow = ...UnitOfWork`, NOT the dragged-in AppDbContext) — with the
# registration as the secondary suffix.
echo "$out" | grep -qE "DiCaptiveSample\.cs:154: warning: \[DI005\].*'ScopeCachingService'" \
|| { echo "FAIL: expected DI005 anchored at the field-store site (line 154)"; exit 1; }
echo "$out" | grep -qE "DiCaptiveSample\.cs:201: warning: \[DI005\].*'UnitOfWorkCachingService'" \
|| { echo "FAIL: expected transitive DI005 anchored at the cached-entry store site (line 201)"; exit 1; }
# the registration site rides along as the SECONDARY anchor in EACH DI005 message tail —
# exactly 2 (one per finding), so a partial-suffix regression fails CI (like DI004's nreg).
nreg5=$(echo "$out" | grep -cE "\[DI005\].*singleton registered at ")
[ "$nreg5" = "2" ] \
|| { echo "FAIL: expected exactly 2 DI005 registration-site suffixes, got $nreg5"; exit 1; }
# two controls stay SILENT: ScopeUsingService USES the scope-resolved service within the
# scope (a local, not a field store); ClockCachingService caches a SINGLETON (shareable,
# not a scoped service). Neither is a captive.
if echo "$out" | grep -qE "(ScopeUsingService|ClockCachingService)"; then
echo "FAIL: a correct scope use (used-in-scope, or a cached singleton) was wrongly flagged DI005"; exit 1
fi
# issue #209 — inline [OwnIgnore("reason")] per-site suppression (P-004), on an
# IDisposable field. Four contrasting shapes in OwnIgnoreSample.cs: the un-annotated
# leak, a reason-less [OwnIgnore], and an empty [OwnIgnore("")] all FIRE OWN001; only
# [OwnIgnore("reason")] is silent-but-COUNTED (SARIF suppressions), never failing the run.
echo "$out" | grep -qE "OwnIgnoreSample\.cs:[0-9]+: error: \[OWN001\].*'UnsuppressedLeak'" \
|| { echo "FAIL: an un-annotated IDisposable field must raise OWN001"; exit 1; }
echo "$out" | grep -qE "\[OWN001\].*'ReasonlessLeak'" \
|| { echo "FAIL: a reason-less [OwnIgnore] must NOT suppress (OWN001 must still fire)"; exit 1; }
echo "$out" | grep -qE "\[OWN001\].*'EmptyReasonLeak'" \
|| { echo "FAIL: an empty [OwnIgnore(\"\")] reason must NOT suppress (OWN001 must still fire)"; exit 1; }
# the suppressed leak is SILENT in the human findings stream...
if echo "$out" | grep -q "'SuppressedLeak'"; then
echo "FAIL: a [OwnIgnore(\"reason\")] finding must be silent in the human output"; exit 1
fi
# ...but COUNTED in the run summary (visibility over silence).
echo "$out" | grep -qE "[0-9]+ suppressed \(\[OwnIgnore\]\)" \
|| { echo "FAIL: the suppressed finding must be counted in the summary tally"; exit 1; }
# SARIF carries it as a result WITH a `suppressions` array (kind inSource + the
# mandatory reason as justification) — a consumer counts it, GitHub shows it
# suppressed rather than an open alert, and it never fails the run.
rc=0; python -m ownlang ownir "$RUNNER_TEMP/facts.json" --format sarif > "$RUNNER_TEMP/own.sarif" || rc=$?
[ "$rc" -le 1 ] || { echo "FAIL: SARIF generation errored (rc=$rc)"; exit 1; }
jq -e '[.runs[0].results[] | select(.properties.component == "SuppressedLeak")] as $s
| ($s | length) == 1
and ($s[0].suppressions[0].kind == "inSource")
and ($s[0].suppressions[0].justification | contains("owned and disposed by the DI container"))' \
"$RUNNER_TEMP/own.sarif" >/dev/null \
|| { echo "FAIL: SARIF must carry SuppressedLeak once, with an inSource suppressions justification"; \
jq '.runs[0].results[]|{ruleId,component:.properties.component,suppressions}' "$RUNNER_TEMP/own.sarif"; exit 1; }
# issue #218 — DP/property-changed old->new subscription ROTATION (unsub OLD, sub NEW, same
# handler = one paired lifecycle) must be SILENT. Two recognised forms: a DP callback reading
# e.OldValue/e.NewValue (CommandTriggerAction), and a plain virtual OnXChanged(old, new)
# override with two same-type params (AbstractMargin). Confirmed FP in 3 real repos, 6+ sites.
# InlineCastRotation covers the direct inline-cast receiver `((ICommand)e.NewValue!).Event +=`
# (OldValue/NewValue are object-typed, so the cast is mandatory) — Codex review catch.
if echo "$out" | grep -qE "'CommandTriggerAction'|'AbstractMargin'|'InlineCastRotation'"; then
echo "FAIL: a DP old->new subscription rotation was wrongly flagged as a leak (#218)"; exit 1
fi
# ...and it must NOT over-widen — three controls STAY flagged: (a) the += uses a DIFFERENT
# handler than the -= (a genuine unpaired subscription); (b) two UNRELATED, differently-typed
# params (not the old/new halves of one change); (c) `-=` on one class FIELD, `+=` on another.
echo "$out" | grep -qE "\[OWN001\].*'MismatchedHandlerRotation'" \
|| { echo "FAIL: a rotation with a DIFFERENT += handler must stay flagged (no over-widen)"; exit 1; }
echo "$out" | grep -qE "\[OWN001\].*'UnrelatedPairRotation'" \
|| { echo "FAIL: a -=/+= pair on differently-typed params must stay flagged (not old/new halves)"; exit 1; }
echo "$out" | grep -qE "\[OWN001\].*'TwoFieldsRotation'" \
|| { echo "FAIL: a -=/+= pair across two class fields must stay flagged (not a rotation)"; exit 1; }
# issue #223 — curated allowlist: CommandManager.RequerySuggested is implemented
# over weak references (see docs/notes/field-notes-patterns.md entry 17), so an
# ordinary instance-bound handler that never `-=`s it must NOT raise OWN014.
if echo "$out" | grep -q "ImeSupportLike"; then
echo "FAIL: an allowlisted CommandManager.RequerySuggested subscription was wrongly reported"; exit 1
fi
# control: an ORDINARY (non-allowlisted) static event, same never-detached
# instance-handler shape, must STILL raise OWN014 — the allowlist must not
# weaken the general static-source tier.
echo "$out" | grep -qE "RequerySuggestedAllowlistSample\.cs:[0-9]+: error: \[OWN014\].*'OrdinaryStaticSubscriber'" \
|| { echo "FAIL: expected OWN014 on the non-allowlisted static-event subscriber"; exit 1; }
# issue #224 — a handler that unsubscribes ITSELF inside its own body (a
# self-detaching one-shot handler) is bounded -> must be SILENT.
if echo "$out" | grep -q "DropDownButtonLike"; then
echo "FAIL: a self-detaching handler subscription was wrongly reported"; exit 1
fi
# control 1: the SAME shape but the handler does NOT self-detach -> must STILL warn.
echo "$out" | grep -qE "SelfDetachingHandlerSample\.cs:[0-9]+: warning: \[OWN001\].*'NonDetachingSubscriber'" \
|| { echo "FAIL: expected OWN001 on the non-self-detaching subscriber"; exit 1; }
# control 2: the handler detaches a DIFFERENT event name -> must NOT be credited
# as releasing the subscribed event -> must STILL warn.
echo "$out" | grep -qE "SelfDetachingHandlerSample\.cs:[0-9]+: warning: \[OWN001\].*'WrongEventDetachSubscriber'" \
|| { echo "FAIL: expected OWN001 when the self-detach targets the wrong event name"; exit 1; }
# control 3 (Codex P2 on PR #231): the handler detaches the CORRECT event name
# but off an UNRELATED receiver, not its own `sender` parameter -> the actual
# subscribed source is never released -> must STILL warn.
echo "$out" | grep -qE "SelfDetachingHandlerSample\.cs:[0-9]+: warning: \[OWN001\].*'WrongReceiverDetachSubscriber'" \
|| { echo "FAIL: expected OWN001 when the self-detach targets the wrong receiver (not sender)"; exit 1; }
# issue #220 — `using (field = new T())`: the field IS the `using` acquisition
# target, disposed at scope exit -> must be SILENT.
if echo "$out" | grep -q "HashCheckerLike"; then
echo "FAIL: a field disposed via using (field = new T()) was wrongly reported"; exit 1
fi
# control: the SAME field, constructed the same way, but OUTSIDE any `using` and
# never disposed -> must STILL warn (the recognition is using-scoped, not "any
# field assignment from new is a release").
echo "$out" | grep -qE "UsingFieldAcquisitionSample\.cs:[0-9]+: error: \[OWN001\].*'LeakyAssignerLike'" \
|| { echo "FAIL: expected OWN001 on the field assigned outside any using block"; exit 1; }
# issue #222 — a template part captured as a LOCAL (plain variable, via
# Template.FindName) or an `is T x` PATTERN variable (via GetTemplateChild), not
# only a field, is self-owned -> both must be SILENT.
if echo "$out" | grep -qE "MetroWindowLike|OverloadViewerLike"; then
echo "FAIL: a template part captured as a local/pattern variable was wrongly reported"; exit 1
fi
# control: a local variable that merely ALIASES an INJECTED field (not a
# GetTemplateChild/FindName fetch) must STILL warn — the exemption is scoped to
# an actual template-part fetch, not "any local-variable subscription is self-owned."
echo "$out" | grep -qE "TemplatePartLocalCaptureSample\.cs:[0-9]+: warning: \[OWN001\].*'InjectedLocalSubscriber'" \
|| { echo "FAIL: expected OWN001 on the injected-local (non-template-part) subscriber"; exit 1; }
# control (Codex P2 on PR #231): a template-part local in one method must NOT
# exempt an UNRELATED same-named local (aliasing an injected source) in a
# DIFFERENT method of the same class — locals are self-owned by SYMBOL, not
# name. The template-part method's own subscription must stay silent...
if echo "$out" | grep -q "OnTemplateClick"; then
echo "FAIL: the legitimate template-part-local subscription was wrongly reported"; exit 1
fi
# ...while the same-named local in the OTHER method must still warn.
echo "$out" | grep -qE "TemplatePartLocalCaptureSample\.cs:[0-9]+: warning: \[OWN001\].*'SameNameDifferentScopeSubscriber'" \
|| { echo "FAIL: expected OWN001 on the same-named-but-unrelated local in a different method"; exit 1; }
# issue #225 (narrowed by #238) — the empty-Dispose exemption is confined to ENUMERATOR
# types: source emptiness proves nothing about the COMPILED body once an IL weaver is in
# play (Janitor.Fody wove real cleanup into ClosedXML's XLWorkbook.Dispose — 263 silently
# swallowed findings). ScratchReader (empty source Dispose, NOT an enumerator — the
# XLWorkbook shape) must therefore STAY flagged on the flat name path...
echo "$out" | grep -qE "EmptyDisposeSample\.cs:[0-9]+:.*\[OWN001\].*'s'.*'ScratchReader'" \
|| { echo "FAIL: #238: a NON-enumerator empty-source-Dispose local must stay flagged (weaver soundness)"; exit 1; }
echo "$out" | grep -qE "EmptyDisposeSample\.cs:[0-9]+:.*\[OWN001\].*'lr'.*'LeakyReader'" \
|| { echo "FAIL: a non-empty-Dispose local (LeakyReader) must still leak (#225 stays scoped)"; exit 1; }
# Codex P2: an empty SYNC Dispose but a real DisposeAsync (a `*Reader` here) must still leak.
echo "$out" | grep -qE "EmptyDisposeSample\.cs:[0-9]+:.*\[OWN001\].*'ar'.*'AsyncReader'" \
|| { echo "FAIL: a type with a real DisposeAsync (empty sync Dispose) must still leak (#225)"; exit 1; }
# issue #228 — an Application-derived subscriber on a CURATED app-scoped
# resolver result (PaletteHelper.GetThemeManager) with a method-group handler
# of the App class itself must be SILENT: the source is process-lived (bound
# to the app's own state), so nothing is promoted. Both receiver forms — an
# `is`-pattern local (App) and the direct invocation (DirectApp).
if echo "$out" | grep -qE "AppScopedSourceSample\.cs:[0-9]+:.*('App'|'DirectApp')"; then
echo "FAIL: a curated app-scoped subscription inside the Application was wrongly reported (#228)"; exit 1
fi
# ...and the exemption must NOT over-widen — three controls STAY flagged:
# (1) the SAME curated shape from a NON-Application class (the subscriber
# gate stays clsIsApp, byte-for-byte);
echo "$out" | grep -qE "AppScopedSourceSample\.cs:[0-9]+: warning: \[OWN001\].*'NotAnApp'" \
|| { echo "FAIL: expected OWN001 on the non-Application subscriber (curated source alone must not exempt)"; exit 1; }
# (2) App + curated source, but a LAMBDA handler capturing a local — the
# exact hole that sank the rejected clsIsStatic broadening;
echo "$out" | grep -qE "AppScopedSourceSample\.cs:[0-9]+: warning: \[OWN001\].*'LambdaApp'" \
|| { echo "FAIL: expected OWN001 on the lambda-handler subscription inside the App (capture hole)"; exit 1; }
# (3) App + method-group handler, but a NON-curated resolver;
echo "$out" | grep -qE "AppScopedSourceSample\.cs:[0-9]+: warning: \[OWN001\].*'CuratedOnlyApp'" \
|| { echo "FAIL: expected OWN001 on the non-curated resolver source inside the App"; exit 1; }
# (4, Codex P2) the method group is qualified with ANOTHER instance of the
# same App type — right ContainingType, wrong delegate target;
echo "$out" | grep -qE "AppScopedSourceSample\.cs:[0-9]+: warning: \[OWN001\].*'TwinApp'" \
|| { echo "FAIL: expected OWN001 when the handler target is another instance, not this"; exit 1; }
# (5, Codex P2) the local is REASSIGNED to an injected publisher after the
# curated initializer — the stale declaration binding must not exempt.
echo "$out" | grep -qE "AppScopedSourceSample\.cs:[0-9]+: warning: \[OWN001\].*'ReassignApp'" \
|| { echo "FAIL: expected OWN001 when the resolver-bound local is reassigned before the +="; exit 1; }
# issue #219 — WinForms disposal CHANNELS: (a) a Control/ToolStripItem field added to THIS's
# own Controls/Items collection is disposed transitively by base.Dispose(disposing); (b) a
# component built with `new T(components)` / registered via `components.Add(x)` is disposed by
# components.Dispose(). ~30 ShareX FPs. Both require the class to reach a disposal root.
# SILENT: (a) direct Controls.Add, AddRange, a transitive ToolStrip Items add, a ComboBox
# added to Controls; (b) both IContainer-registration shapes incl. the named Add(x,"name").
if echo "$out" | grep -qE "WinFormsDisposalSample\.cs.*'(lblStatus|btnOk|menu|menuItem|trayIcon|icon|namedIcon|combo)'"; then
echo "FAIL: a WinForms Controls/Items-membership or IContainer-registered field was wrongly flagged (#219)"; exit 1
fi
# ...and the negative controls MUST stay flagged: an add into a FOREIGN (param) container;
# a plain owner with NO Dispose at all (owned container never disposed); a component NOT
# container-registered; and a real disposable stored as a ComboBox item (ObjectCollection
# does not dispose its items — Codex).
echo "$out" | grep -qE "WinFormsDisposalSample\.cs:[0-9]+:.*\[OWN001\].*'lblForeign'" \
|| { echo "FAIL: #219 (a): a control added to a FOREIGN container must stay flagged"; exit 1; }
echo "$out" | grep -qE "WinFormsDisposalSample\.cs:[0-9]+:.*\[OWN001\].*'item'" \
|| { echo "FAIL: #219 (a): a control in an owned container with NO Dispose must stay flagged"; exit 1; }
echo "$out" | grep -qE "WinFormsDisposalSample\.cs:[0-9]+:.*\[OWN001\].*'unregisteredIcon'" \
|| { echo "FAIL: #219 (b): a component NOT registered in the IContainer must stay flagged"; exit 1; }
echo "$out" | grep -qE "WinFormsDisposalSample\.cs:[0-9]+:.*\[OWN001\].*'comboItem'" \
|| { echo "FAIL: #219 (a): a disposable stored as a ComboBox item (ObjectCollection) must stay flagged"; exit 1; }
# exactly 5 WinFormsDisposalSample findings (lblForeign, cms, item, unregisteredIcon,
# comboItem) — the eight channel-disposed fields are all silent; any leak pushes it past 5.
nwf=$(echo "$out" | grep -cE "WinFormsDisposalSample\.cs:[0-9]+:.*\[OWN001\]")
[ "$nwf" = "5" ] \
|| { echo "FAIL: #219 expected exactly 5 WinFormsDisposalSample findings (controls only), got $nwf"; exit 1; }
# issue #227 — a `Behavior`-derived subscriber whose event source is (an element
# reached from) its own base-class `AssociatedObject` must be SILENT: the
# behavior cannot outlive being attached, so the source is co-lifetimed with the
# subscriber (a collectable self-cycle, not a leak). Three receiver forms:
# an `is`-pattern local off a field assigned from AssociatedObject (TiltLikeBehavior),
# the direct `this.AssociatedObject.Event` (DirectAssociatedBehavior), and a
# bare-identifier local bound from AssociatedObject (LocalAssociatedBehavior).
if echo "$out" | grep -qE "AssociatedObjectSourceSample\.cs:[0-9]+:.*'panel\.Loaded'.*'TiltLikeBehavior'"; then
echo "FAIL: the AssociatedObject-derived subscription in the Behavior was wrongly reported (#227)"; exit 1
fi
if echo "$out" | grep -qE "AssociatedObjectSourceSample\.cs:[0-9]+:.*('DirectAssociatedBehavior'|'LocalAssociatedBehavior')"; then
echo "FAIL: a direct/local AssociatedObject subscription in the Behavior was wrongly reported (#227)"; exit 1
fi
# ...and the required negative control: an UNRELATED injected source subscribed
# in the SAME OnAttached stays flagged.
echo "$out" | grep -qE "AssociatedObjectSourceSample\.cs:[0-9]+: warning: \[OWN001\].*'_bus\.Changed'.*'TiltLikeBehavior'" \
|| { echo "FAIL: expected OWN001 on the unrelated injected source in the same OnAttached (#227)"; exit 1; }
# ...and the exemption must NOT over-widen — three controls STAY flagged:
# (1) the same AssociatedObject shape from a NON-Behavior subscriber (the gate
# is the `Behavior` base, not the member name);
echo "$out" | grep -qE "AssociatedObjectSourceSample\.cs:[0-9]+: warning: \[OWN001\].*'NotABehavior'" \
|| { echo "FAIL: expected OWN001 on the non-Behavior subscriber (AssociatedObject name alone must not exempt)"; exit 1; }
# (2) a field assigned from AssociatedObject AND from an injected value elsewhere
# — every assignment must resolve to AssociatedObject;
echo "$out" | grep -qE "AssociatedObjectSourceSample\.cs:[0-9]+: warning: \[OWN001\].*'MixedFieldBehavior'" \
|| { echo "FAIL: expected OWN001 when the field is also assigned an injected value (#227)"; exit 1; }
# (3) the local starts as AssociatedObject but is REASSIGNED to an injected
# source before the `+=` — the stale declaration binding must not exempt.
echo "$out" | grep -qE "AssociatedObjectSourceSample\.cs:[0-9]+: warning: \[OWN001\].*'ReassignedLocalBehavior'" \
|| { echo "FAIL: expected OWN001 when the AssociatedObject-bound local is reassigned before the +="; exit 1; }
# (4, Codex P2) a PARAMETER named `AssociatedObject` SHADOWS the inherited base
# accessor — the name matches by text, but the symbol is an injected parameter,
# so the exemption must resolve the binding, not just the name.
echo "$out" | grep -qE "AssociatedObjectSourceSample\.cs:[0-9]+: warning: \[OWN001\].*'ShadowParamBehavior'" \
|| { echo "FAIL: expected OWN001 when a shadowing parameter is named AssociatedObject (#227)"; exit 1; }
# (5) a PARTIAL behavior whose field is assigned from AssociatedObject in one
# declaration but from an injected value in the sibling partial — the field-
# population scan must span every partial of the type.
echo "$out" | grep -qE "AssociatedObjectSourceSample\.cs:[0-9]+: warning: \[OWN001\].*'PartialFieldBehavior'" \
|| { echo "FAIL: expected OWN001 when a sibling partial injects the AssociatedObject field (#227)"; exit 1; }
# issue #229 — subscribing to an element of a collection the class itself
# populated (a `foreach` over a this-owned field/property assigned from the
# class's OWN construction/factory) must be SILENT: the collection and its
# elements share the constructing object's lifetime (a collectable self-cycle,
# not a leak). Three population forms: an own factory into a property
# (OwnedFactoryViewModel), a collection-initializer field (InlineNewViewModel),
# and a `new` assigned in the ctor (DirectNewViewModel).
if echo "$out" | grep -qE "OwnedCollectionElementSample\.cs:[0-9]+:.*('OwnedFactoryViewModel'|'InlineNewViewModel'|'DirectNewViewModel')"; then
echo "FAIL: a self-populated owned-collection element subscription was wrongly reported (#229)"; exit 1
fi
# ...and the exemption must NOT over-widen — four controls STAY flagged:
# (1, REQUIRED) the collection is a FIELD assigned from a ctor PARAMETER;
echo "$out" | grep -qE "OwnedCollectionElementSample\.cs:[0-9]+: warning: \[OWN001\].*'InjectedCollectionViewModel'" \
|| { echo "FAIL: expected OWN001 on the injected-collection field (#229)"; exit 1; }
# (2, REQUIRED) the `foreach` iterates a ctor PARAMETER collection directly;
echo "$out" | grep -qE "OwnedCollectionElementSample\.cs:[0-9]+: warning: \[OWN001\].*'ParamCollectionViewModel'" \
|| { echo "FAIL: expected OWN001 on the parameter-collection foreach (#229)"; exit 1; }
# (3) the member is populated from a SERVICE-LOCATED call (injected receiver);
echo "$out" | grep -qE "OwnedCollectionElementSample\.cs:[0-9]+: warning: \[OWN001\].*'ServiceLocatedViewModel'" \
|| { echo "FAIL: expected OWN001 on the service-located collection source (#229)"; exit 1; }
# (4) the member is own-populated in the ctor but ALSO reassigned an injected
# value elsewhere — every population site must be own-produced.
echo "$out" | grep -qE "OwnedCollectionElementSample\.cs:[0-9]+: warning: \[OWN001\].*'MixedCollectionViewModel'" \
|| { echo "FAIL: expected OWN001 when the collection is also assigned an injected value (#229)"; exit 1; }
# (5, Codex P1) a freshly-`new`d collection SEEDED from an injected parameter —
# the elements (which carry the events) are injected, so it is not owned;
echo "$out" | grep -qE "OwnedCollectionElementSample\.cs:[0-9]+: warning: \[OWN001\].*'SeededNewViewModel'" \
|| { echo "FAIL: expected OWN001 on a new collection seeded from injected elements (#229)"; exit 1; }
# (6, Codex P1) an own-class factory that FORWARDS a service-located collection;
echo "$out" | grep -qE "OwnedCollectionElementSample\.cs:[0-9]+: warning: \[OWN001\].*'ForwardingFactoryViewModel'" \
|| { echo "FAIL: expected OWN001 when the own factory forwards injected data (#229)"; exit 1; }
# (7, CodeRabbit) a PARTIAL class with a disqualifying injected assignment in a
# sibling partial — the population scan must span every partial of the type.
echo "$out" | grep -qE "OwnedCollectionElementSample\.cs:[0-9]+: warning: \[OWN001\].*'PartialInjectedViewModel'" \
|| { echo "FAIL: expected OWN001 when a sibling partial injects the collection (#229)"; exit 1; }
echo "OK: real C# -> facts -> OWN001 (subscription + timer + field + Subscribe + pool + local) + OWN014 (static-event region escape) + DI001 (captive dependency) + DI002 (scoped captured weakly) + DI003 (transient IDisposable captured by a singleton) + DI004 (transient IDisposable service-located from the root provider) + DI005 (scoped service cached from a created scope) + [OwnIgnore] suppression (silent-but-counted, SARIF suppressions) + #218 DP old->new subscription rotation (silent; controls flagged) + #225 empty-Dispose local exemption (silent; controls flagged) + #228 curated app-scoped source in App (silent; controls flagged) + #227 self-owned Behavior.AssociatedObject source (silent; controls flagged) + #219 WinForms Controls/IContainer disposal channels (silent; controls flagged) + #229 self-populated owned-collection element (silent; controls flagged) at the C# location"
- name: Flow-sensitive local IDisposables (--flow-locals, P-016 B0b/B2)
run: |
# Path-sensitive flow analysis of local IDisposables — bugs the flat D1
# detector cannot catch (use-after-dispose, double-dispose, leak-on-path).
dotnet run --project frontend/roslyn/OwnSharp.Extractor -- \
frontend/roslyn/samples/FlowLocalsSample.cs \
frontend/roslyn/samples/MemoryOwnerEscapeSample.cs \
frontend/roslyn/samples/FactoryLeakSample.cs \
frontend/roslyn/samples/OverloadSigSample.cs \
frontend/roslyn/samples/EmptyDisposeSample.cs --flow-locals -o "$RUNNER_TEMP/flow.json"
out=$(python -m ownlang ownir "$RUNNER_TEMP/flow.json" || true)
echo "$out"
echo "$out" | grep -q "OWN002" || { echo "FAIL: expected OWN002 (use-after-dispose)"; exit 1; }
echo "$out" | grep -q "OWN001" || { echo "FAIL: expected OWN001 (leak on a path)"; exit 1; }
echo "$out" | grep -q "OWN003" || { echo "FAIL: expected OWN003 (double-dispose)"; exit 1; }
# a real Timer leak the flat curated allowlist misses but the semantic path catches:
echo "$out" | grep -q "OWN001.*'realTimer'" || { echo "FAIL: expected OWN001 on the leaked Timer"; exit 1; }
# the OWN001 wording splits on whether the local was released anywhere: the
# Timer is released on no path -> "is never disposed"; LeakOnElse's `leak` is
# released on the then-branch only -> "may not be disposed on every path".
echo "$out" | grep -qE "'realTimer' is never disposed" \
|| { echo "FAIL: expected the never-disposed wording for the 0-release Timer"; exit 1; }
echo "$out" | grep -qE "'leak' may not be disposed on every path" \
|| { echo "FAIL: expected the partial-path wording for LeakOnElse"; exit 1; }
# P-016 A1 reached the frontend: `while`/`foreach`/`for` bodies are now
# lowered (not skipped), so a per-iteration leak in one is caught.
echo "$out" | grep -qE "OWN001.*'whileLeak' is never disposed" \
|| { echo "FAIL: expected OWN001 on the undisposed local in a while loop"; exit 1; }
echo "$out" | grep -qE "OWN001.*'foreachLeak'" \
|| { echo "FAIL: expected OWN001 on the undisposed local in a foreach loop"; exit 1; }
echo "$out" | grep -qE "OWN001.*'forLeak'" \
|| { echo "FAIL: expected OWN001 on the undisposed local in a for loop"; exit 1; }
# `try`/`finally` lowered with exception edges (try-methods no longer skipped):
# a local never disposed inside a try is caught...
echo "$out" | grep -qE "OWN001.*'tfLeak'" \
|| { echo "FAIL: expected OWN001 on the undisposed local in a try-method"; exit 1; }
# ...and so is dispose-not-called-on-throw: `dot` is disposed inside the try
# after a may-throw call, so it leaks on the exceptional path (matches CodeQL).
echo "$out" | grep -qE "OWN001.*'dot'" \
|| { echo "FAIL: expected OWN001 on the dispose-not-called-on-throw local"; exit 1; }
# exception-edge RECALL slice — three sound recall wins, each matching CodeQL's
# cs/dispose-not-called-on-throw: a may-throw in a nested `if` branch BEFORE the
# dispose ('nestedLeak'); a constructor (`new`) as a throw point that skips a PRIOR
# owned resource's dispose ('ctorPrior'); and a TYPED catch whose uncaught exception
# types propagate past a post-try dispose ('typedLeak').
echo "$out" | grep -qE "OWN001.*'nestedLeak'" \
|| { echo "FAIL: expected OWN001 on the nested-throw leak"; exit 1; }
echo "$out" | grep -qE "OWN001.*'ctorPrior'" \
|| { echo "FAIL: expected OWN001 on the constructor-throw prior-resource leak"; exit 1; }
echo "$out" | grep -qE "OWN001.*'typedLeak'" \
|| { echo "FAIL: expected OWN001 on the typed-catch uncaught-path leak"; exit 1; }
# ...and a qualified DOMAIN catch (`catch (DomainErrors.Exception)` — rightmost name
# `Exception` but NOT System.Exception) is typed too, so its uncaught types leak
# ('qualLeak'); IsCatchAll matches only the canonical spellings (CodeRabbit review).
echo "$out" | grep -qE "OWN001.*'qualLeak'" \
|| { echo "FAIL: expected OWN001 on the qualified-typed-catch leak"; exit 1; }
# remaining flow-lowering gaps closed: finally-before-return threading (an early
# return that skips a later dispose leaks -> 'earlyRet'), `do` desugar (a body-local
# never disposed leaks per iteration -> 'doLeak'), and `switch` lowering (a default
# branch that does not dispose leaks -> 'swLeak').
echo "$out" | grep -qE "OWN001.*'earlyRet'" \
|| { echo "FAIL: expected OWN001 on the early-return-skips-dispose leak"; exit 1; }
echo "$out" | grep -qE "OWN001.*'doLeak'" \
|| { echo "FAIL: expected OWN001 on the undisposed local in a do-while loop"; exit 1; }
echo "$out" | grep -qE "OWN001.*'swLeak'" \
|| { echo "FAIL: expected OWN001 on the switch default-branch leak"; exit 1; }
# body-level explicit `throw` (no enclosing try) — these methods used to bail the flow
# pass entirely (a `throw` hit the unmodelled default). Now an explicit throw is an
# abnormal exit, so they are analysed: a top-level validation throw no longer HIDES a
# later undisposed local ('vtl', never disposed), and a Dispose skipped by a `throw`
# on the guard path leaks on that path ('dotNoTry', partial). Closes the no-try slice
# of cs/dispose-not-called-on-throw + un-bails every validation-throw-guarded method.
echo "$out" | grep -qE "OWN001.*'vtl' is never disposed" \
|| { echo "FAIL: expected OWN001 on the local hidden behind a top-level validation throw"; exit 1; }
echo "$out" | grep -qE "OWN001.*'dotNoTry' may not be disposed on every path" \
|| { echo "FAIL: expected OWN001 on the body-level dispose-not-called-on-throw (no try)"; exit 1; }
# flow-path pool LABEL: an ArrayPool Rent returned on one path only leaks on the other ->
# the flow path must word it as a "pooled buffer" (Return), NOT the generic "disposable".
# The extractor stamps the acquire kind; the bridge tags [resource: pooled buffer]. Pins
# the mislabel fix surfaced by the --body-throw-edges Npgsql capstone.
echo "$out" | grep -qE "OWN001.*pooled buffer 'partialBuf' may not be returned to the pool on every path" \
|| { echo "FAIL: expected the flow-path pooled-buffer partial-path label on 'partialBuf'"; exit 1; }
# b′ pooled-view REASSIGNMENT FP (ReassignedView): a Span view local reused for a SECOND
# rented buffer no longer borrows the first. The pre-reassignment read (`v[0]` while bufA is
# returned) is a REAL use-after-return -> EXACTLY ONE OWN002 on 'bufA'; the reassignment's own
# LHS and the post-reassignment read (`v[1]`, now bufB) must NOT be re-attributed to the
# released bufA (were two extra false OWN002 before the fix). bufB is returned after its last
# read -> silent. The exact count is the guard: a regression re-introduces 2 more on bufA.
nrv=$(echo "$out" | grep -cE "OWN002.*'bufA'")
[ "$nrv" = "1" ] \
|| { echo "FAIL: expected exactly 1 OWN002 on 'bufA' (pre-reassignment use-after-return), got $nrv"; exit 1; }
if echo "$out" | grep -qE "OWN002.*'bufB'"; then
echo "FAIL: a Span view reassigned to the live 'bufB' was wrongly flagged use-after-return"; exit 1
fi
# Codex review on #98 — reslice-after-return: `sliced = sliced.Slice(1)` reads the STALE view
# on the RHS, so it still trips OWN002 on 'sb' (the assignment's own LHS must not suppress its
# own RHS). One finding only — the resliced view's forward owner is not tracked.
echo "$out" | grep -qE "OWN002.*'sb'" \
|| { echo "FAIL: expected OWN002 on 'sb' (reslice reads the returned buffer on the RHS)"; exit 1; }
# Codex review on #98 — `ref` arg is a USE: passing a stale view by `ref` after return reads
# the current value, so it is a use-after-return -> OWN002 on 'rb' (only `out` is exempt).
echo "$out" | grep -qE "OWN002.*'rb'" \
|| { echo "FAIL: expected OWN002 on 'rb' (ref arg reads the stale view after return)"; exit 1; }
# Codex review on #98 (follow-up) — same-call out arg: `Reinit(out ov, ov[0])` reads the stale
# view in a SIBLING argument (args evaluate before the callee writes the out param), so it
# trips OWN002 on 'ob'; the out rebind must not suppress a use in the same invocation.
echo "$out" | grep -qE "OWN002.*'ob'" \
|| { echo "FAIL: expected OWN002 on 'ob' (sibling-arg read before the out-write)"; exit 1; }
# Codex review on #98 (follow-up) — for-incrementor: the incrementor runs AFTER the body, so
# `fv[0]` reads the stale view on iteration 1 even though `fv = default` sits earlier in the
# header; the incrementor rebind must not suppress the body use -> OWN002 on 'fb'.
echo "$out" | grep -qE "OWN002.*'fb'" \
|| { echo "FAIL: expected OWN002 on 'fb' (for-incrementor must not suppress the body use)"; exit 1; }
# CodeRabbit review on #98 — out-arg rebind: after `Reset(out ov)` the view no longer borrows
# the returned 'obuf' (callee wrote an unknown value), so the later read is conservatively
# SILENT (an honest miss, never a false positive).
if echo "$out" | grep -qE "OWN002.*'obuf'"; then
echo "FAIL: an out-rebound view must not be attributed to the returned 'obuf'"; exit 1
fi
# closure-capture escape (precision): a SemaphoreSlim captured by a returned async
# lambda outlives the method, so it cannot be disposed at method scope -> escaped ->
# silent ('captured'). A SemaphoreSlim NOT captured and never disposed STILL leaks ->
# OWN001 ('semLeak'), proving the exemption is closure-capture, not a blanket
# SemaphoreSlim dispose-optional (reduced from a ShareX FP — Helpers.ForEachAsync).
echo "$out" | grep -qE "OWN001.*'semLeak' is never disposed" \
|| { echo "FAIL: expected OWN001 on the non-captured SemaphoreSlim leak"; exit 1; }
# a `nameof(x)` operand inside a lambda is NOT a closure capture (it is a compile-time
# string) -> the local stays method-bounded and still leaks -> OWN001 (Codex review on
# #59: nameof must not masquerade as a capture/escape).
echo "$out" | grep -qE "OWN001.*'nofLeak' is never disposed" \
|| { echo "FAIL: expected OWN001 on the nameof-in-lambda local (not a capture)"; exit 1; }
# owning-factory RECALL (crypto): a System.Security.Cryptography static `Create*` factory
# returning an IDisposable is an owning acquire like File.Open*/Create* -> an undisposed
# one leaks ('rngLeak' = RandomNumberGenerator.Create()); the disposed sibling
# ('shaClean' = SHA256.Create() + Dispose) stays silent. Reduced from the SECOND,
# previously-missed leak in ShareX's DeriveCryptoData.
echo "$out" | grep -qE "OWN001.*'rngLeak' is never disposed" \
|| { echo "FAIL: expected OWN001 on the undisposed crypto-factory acquire"; exit 1; }
# TcpListener precision (Codex review on #61): Stop() IS the cleanup (Dispose()
# delegates to Stop()), so a Stop()'d listener is modelled as released -> silent
# ('stopped'); a listener NEVER Stop()'d still holds the socket -> OWN001 ('tcpLeak'),
# proving the release is Stop()-specific, not a blanket TcpListener exemption.
echo "$out" | grep -qE "OWN001.*'tcpLeak' is never disposed" \
|| { echo "FAIL: expected OWN001 on the never-stopped TcpListener"; exit 1; }
# dispose-optional (Task), disposed/escaping locals, a `for` loop whose
# disposable is disposed after it (`looped`, balanced), a balanced
# acquire+dispose in a loop (`whileClean`), a try/finally dispose (`tfClean`,
# balanced) and a catch-disposes method (`tfCatch`, soundly skipped) must
# stay silent:
# released via `await x.DisposeAsync()` (asyncDisposed) and the chained
# `.ConfigureAwait(false)` form (asyncDisposedCfg) -> both must stay silent.
# PR #32 FP fixes: a swallowing catch with a Dispose AFTER the try/catch (cda),
# an `await DisposeAsync().ConfigureAwait(false)` INSIDE a try (daci), and a Dispose
# inside both branches of an `if` in a try alongside a may-throw call (cif) — all
# disposed on every path, so all must stay silent (were false OWN001 before).
# `ctorLater` is acquired AFTER the constructor-throw edge in CtorThrowLeaksPrior, so
# it is never live at that edge and must stay silent (only `ctorPrior` leaks there).
# `lamPrior`: a `new` inside a LAMBDA body is deferred (runs on invoke, not at the
# declaration), so the lambda statement is not a throw point -> no phantom edge skips
# its post-try dispose -> silent (Codex review: don't descend into lambda bodies).
# `other`: disposed by the finally, so threaded before the early return -> silent.
# `doClean`: acquire+dispose balanced each `do` iteration. `swAll`: every `switch`
# case disposes (no default) -> last case is the tail, no phantom no-match leak.
# `ncf`: `ncf?.Dispose()` (null-conditional) in a threaded finally IS a release
# (member-binding form), so it is disposed on the return path -> silent (Codex review).
# `ctorMoved`: a pooled buffer handed to a constructor whose result is RETURNED transfers
# ownership to the returned wrapper -> escaped -> silent (mined FP on
# Pipelines.Sockets.Unofficial: ArrayPoolBufferWriter.CreateNewSegment).
# P-016 escape-via-projection (mined: ImageSharp Image.WrapMemory; CodeQL agrees it is
# no leak): an IMemoryOwner whose `.Memory` view is handed to a consumer as an argument
# escapes the owner -> silent ('handedOwner', in the list below); one whose `.Memory` is
# only READ locally and never disposed still leaks ('leakedOwner').
echo "$out" | grep -qE "OWN001.*'leakedOwner'" \
|| { echo "FAIL: expected OWN001 on the read-only, never-disposed IMemoryOwner"; exit 1; }
# boundary: the projection-escape is scoped to `new`'d owners — a MemoryPool RENTAL whose
# .Memory is handed off after Dispose must KEEP its use-after-dispose tracking, NOT be
# silenced (Codex/CodeRabbit P1; benchmark memorypool-double-dispose parity).
echo "$out" | grep -qE "OWN002.*'pooled'" \
|| { echo "FAIL: a MemoryPool owner's .Memory used after Dispose must still trip OWN002"; exit 1; }
# tdClean: a Dispose BEFORE an explicit `throw` -> released at the abnormal exit ->
# silent. vtc: a local acquired after a top-level validation throw AND disposed ->
# analysed (no longer bailed) and balanced -> silent (the un-bail must not over-flag).
# tif (Codex P2): a `throw` inside an inner finally propagates through the OUTER finally
# that disposes it -> the throw-exit keeps BAILING the method (it can't run the enclosing
# cleanup) rather than emit a false leak -> silent.
for ok in clean looped esc exemptTask whileClean asyncDisposed asyncDisposedCfg tfClean tfCatch tfRet tfNull cda daci cif ctorLater lamPrior other doClean swAll ncf captured shaClean stopped defer ctorMoved handedOwner tdClean vtc tif; do
if echo "$out" | grep -q "'$ok'"; then echo "FAIL: silent/exempt case '$ok' was reported"; exit 1; fi
done
# P-005 D5.2 INTERPROCEDURAL fresh-returning factory (FactoryLeakSample.cs): the core
# infers `StreamFactory.Make` returns `fresh` (its `acquire; return <var>` body), so a
# caller that binds the result and drops it leaks at the CALL SITE — a finding the flat,
# intra-procedural detectors cannot see. The disposed caller and the factory itself stay
# silent (the factory transfers ownership out via its return).
echo "$out" | grep -qE "FactoryLeakSample\.cs:[0-9]+:.*\[OWN001\].*'factoryLeak'" \
|| { echo "FAIL: expected the interprocedural OWN001 on the dropped factory result at its call site"; exit 1; }
for ok in factoryOk made; do
if echo "$out" | grep -q "'$ok'"; then echo "FAIL: D5.2 silent case '$ok' was reported"; exit 1; fi
done
# Interprocedural stage 2 (spec/OwnIR.md §5.1): per-overload signature keys.
# SigOverloads.Open is overloaded — the (string) overload is a fresh factory,
# the (FileStream,bool) one returns its parameter — so the name-merged returns
# DISAGREE (no fresh claim). The `sig` stamped on both the functions[] records
# and the call op resolves the fresh overload's OWN summary, so the dropped
# stream in Drop surfaces as OWN001 at the call — the recall the merge lost.
# Structural check (CodeRabbit): pin BOTH sides of the edge exactly — the two
# overload records' sigs AND the Drop call op's callee/sig/result — so a
# missing or unmatched CALL-side sig fails here by itself, not only via the
# downstream finding.
python3 - "$RUNNER_TEMP/flow.json" <<'PY'
import json, sys
facts = json.load(open(sys.argv[1]))
fns = facts.get("functions", [])
sigs = sorted(str(f.get("sig")) for f in fns
if f.get("name") == "SigOverloads.Open")
assert sigs == ["System.IO.FileStream,System.Boolean", "System.String"], \
f"overload record sigs wrong: {sigs}"
calls = [op for f in fns if f.get("name") == "SigOverloads.Drop"
for op in f.get("body", []) if op.get("op") == "call"]
assert any(c.get("callee") == "SigOverloads.Open"
and c.get("sig") == "System.String"
and c.get("result") == "dropped" for c in calls), \
f"Drop call op missing the (string) overload sig: {calls}"
print("OK: stage-2 sig stamped on both sides of the Drop edge")
PY
echo "$out" | grep -qE "OverloadSigSample\.cs:[0-9]+:.*\[OWN001\].*'dropped' is never disposed" \
|| { echo "FAIL: expected the sig-resolved fresh-overload leak (OWN001 on 'dropped')"; exit 1; }
# the factory itself (transfers out via return), the non-fresh overload's
# balanced probe, and the fresh local it returns all stay silent.
for ok in opened probe; do
if echo "$out" | grep -q "'$ok'"; then echo "FAIL: stage-2 silent case '$ok' was reported"; exit 1; fi
done
# issue #225 (semantic flow path — the real ClosedXML Slice.Enumerator shape): a LOCAL of a
# user type whose Dispose() body is provably EMPTY holds no resource, so never disposing it
# cannot leak. The three controls STILL leak: a real IDisposable ('r'), a non-empty *Reader
# ('lr'), and an empty override over a base that owns a real Dispose ('d').
echo "$out" | grep -qE "EmptyDisposeSample\.cs:[0-9]+:.*\[OWN001\].*'r' is never disposed" \
|| { echo "FAIL: #225 flow: a real IDisposable local must still leak"; exit 1; }
echo "$out" | grep -qE "EmptyDisposeSample\.cs:[0-9]+:.*\[OWN001\].*'lr' is never disposed" \
|| { echo "FAIL: #225 flow: a non-empty *Reader local must still leak"; exit 1; }
echo "$out" | grep -qE "EmptyDisposeSample\.cs:[0-9]+:.*\[OWN001\].*'d' is never disposed" \
|| { echo "FAIL: #225 flow: an empty override over a real base Dispose must still leak"; exit 1; }
# Codex P2: an empty SYNC Dispose() but a real DisposeAsync() (IDisposable + IAsyncDisposable)
# must NOT be exempted — the real cleanup is async and the flow detector treats it as a release.
echo "$out" | grep -qE "EmptyDisposeSample\.cs:[0-9]+:.*\[OWN001\].*'ar' is never disposed" \
|| { echo "FAIL: #225 flow: a type with a real DisposeAsync (empty sync Dispose) must still leak"; exit 1; }
# #240 review P1: an enumerator whose sync Dispose is empty but whose BASE owns a real
# DisposeAsync (inherited IAsyncDisposable) must still leak — inherited async cleanup was
# the same silent-false-negative class #238 exists to close.
echo "$out" | grep -qE "EmptyDisposeSample\.cs:[0-9]+:.*\[OWN001\].*'ia' is never disposed" \
|| { echo "FAIL: #240 flow: an inherited-IAsyncDisposable enumerator must still leak"; exit 1; }
# #240 review round 2: a BASE with a bare DisposeAsync (no IAsyncDisposable interface) must
# be caught by the base-chain scan -> still leak.
echo "$out" | grep -qE "EmptyDisposeSample\.cs:[0-9]+:.*\[OWN001\].*'ba' is never disposed" \
|| { echo "FAIL: #240 flow: a base-chain bare DisposeAsync enumerator must still leak"; exit 1; }
# #240 review round 2: a NON-generic IEnumerator (does not force IDisposable) must stay
# flagged -> pins the IEnumerator<T>-only narrowing.
echo "$out" | grep -qE "EmptyDisposeSample\.cs:[0-9]+:.*\[OWN001\].*'ng' is never disposed" \
|| { echo "FAIL: #240 flow: a non-generic-IEnumerator empty-Dispose local must still leak"; exit 1; }
# #238 soundness: ScratchReader (empty source Dispose, NOT an enumerator) must stay flagged
# in the flow path too — a weaver can rewrite a non-enumerator Dispose at build time.
echo "$out" | grep -qE "EmptyDisposeSample\.cs:[0-9]+:.*\[OWN001\].*'s' is never disposed" \
|| { echo "FAIL: #238 flow: a NON-enumerator empty-source-Dispose local must stay flagged"; exit 1; }
# exactly 5 EmptyDisposeSample findings -> the two provably-empty-Dispose ENUMERATOR locals
# ('e' plain, 'x' explicit-interface — the #238 coverage fix) are SILENT; any leak of them
# would push the count past 8 (controls: s,r,lr,d,ar,ia,ba,ng).
n225=$(echo "$out" | grep -cE "EmptyDisposeSample\.cs:[0-9]+:.*\[OWN00")
[ "$n225" = "8" ] \
|| { echo "FAIL: #225/#238 flow: expected exactly 8 EmptyDisposeSample findings (controls only), got $n225"; exit 1; }
# #238 weaver kill-switch: a directory carrying FodyWeavers.xml disables the exemption even
# for a perfect enumerator shape — the weaved fixture's local must be FLAGGED.
dotnet run --project frontend/roslyn/OwnSharp.Extractor -- \
frontend/roslyn/samples/weaved/WeavedEmptyDispose.cs --flow-locals -o "$RUNNER_TEMP/weaved.json"
wout=$(python -m ownlang ownir "$RUNNER_TEMP/weaved.json" || true)
echo "$wout" | grep -qE "WeavedEmptyDispose\.cs:[0-9]+:.*\[OWN001\].*'we' is never disposed" \
|| { echo "FAIL: #238: FodyWeavers.xml present -> even an enumerator empty Dispose must stay flagged"; exit 1; }
# ...and the LINKED-source variant (Codex on #240): the shared file lives OUTSIDE the
# weaver project's directory (no FodyWeavers.xml above it) — analysed THROUGH the
# .csproj, the project's weaver config must still disable the exemption.
dotnet run --project frontend/roslyn/OwnSharp.Extractor -- \
frontend/roslyn/samples/weaved-linked/Proj/Linked.csproj --flow-locals -o "$RUNNER_TEMP/weavedlink.json"
lout=$(python -m ownlang ownir "$RUNNER_TEMP/weavedlink.json" || true)
echo "$lout" | grep -qE "SharedEnumerator\.cs:[0-9]+:.*\[OWN001\].*'se' is never disposed" \
|| { echo "FAIL: #240: a linked source of a Fody-enabled project must not get the empty-Dispose exemption"; exit 1; }
echo "OK: flow-sensitive OWN001/002/003 on real C# (path-sensitive, loops via while/foreach/for, try/finally sequential, never-vs-every-path wording, dispose-optional exempt, #225 empty-Dispose local exempt, beyond flat)"
- name: Gallery C#-native bad/ok pairs (examples/gallery/cs/)
run: |
# C#-native mirror of examples/gallery/*.own, run through the real extractor ->
# OwnIR -> core (not the toy .own DSL's own dataflow) — see
# examples/gallery/cs/README.md for the 7-of-12 mapping and why the remaining 5
# (move/borrow/stack-buffer/unknown-call) have no real C# detector yet.
dotnet run --project frontend/roslyn/OwnSharp.Extractor -- \
examples/gallery/cs --flow-locals -o "$RUNNER_TEMP/gallery.json"
set +e
out=$(python -m ownlang ownir "$RUNNER_TEMP/gallery.json")
rc=$?
set -e
echo "$out"
# own-check's contract: 0 clean, 1 findings (the expected outcome here — the
# .bad.cs files are SUPPOSED to trip a finding), >=2 a hard error (bad facts /
# drifted contract) that must fail loudly, not be swallowed as "no findings".
if [ "$rc" -ge 2 ]; then
echo "FAIL: ownir hard error (rc=$rc) — bad OwnIR facts or a drifted contract"; exit 1
fi
echo "$out" | grep -qE "01_leak_on_error_path\.bad\.cs:[0-9]+:.*\[OWN001\].*'galleryLeakOnError'" \
|| { echo "FAIL: expected OWN001 on galleryLeakOnError"; exit 1; }
echo "$out" | grep -qE "02_use_after_release\.bad\.cs:[0-9]+:.*\[OWN002\].*'galleryUseAfterRelease'" \
|| { echo "FAIL: expected OWN002 on galleryUseAfterRelease"; exit 1; }
echo "$out" | grep -qE "03_double_release\.bad\.cs:[0-9]+:.*\[OWN003\].*'galleryDoubleRelease'" \
|| { echo "FAIL: expected OWN003 on galleryDoubleRelease"; exit 1; }
echo "$out" | grep -qE "07_use_after_handoff\.bad\.cs:[0-9]+:.*\[OWN002\].*'galleryHandoff'" \
|| { echo "FAIL: expected OWN002 on galleryHandoff (use after handoff)"; exit 1; }
echo "$out" | grep -qE "10_leak_in_loop\.bad\.cs:[0-9]+:.*\[OWN001\].*'galleryLoopLeak'" \
|| { echo "FAIL: expected OWN001 on galleryLoopLeak"; exit 1; }
echo "$out" | grep -qE "11_overspan_full_view\.bad\.cs:[0-9]+:.*\[OWN025\].*'galleryOverspanBuf'" \
|| { echo "FAIL: expected OWN025 on galleryOverspanBuf"; exit 1; }
for ok in galleryClean galleryLeakOnErrorOk galleryUseAfterReleaseOk galleryDoubleReleaseOk galleryHandoffOk galleryLoopLeakOk galleryOverspanOkBuf; do
if echo "$out" | grep -q "'$ok'"; then echo "FAIL: clean gallery case '$ok' was reported"; exit 1; fi
done
# Stronger silence check: the ok/clean fixture FILES themselves must never
# appear as a flagged location, not just the variable names we happened to
# anticipate above (an unexpected finding on some other identifier in one of
# these files would otherwise slip through the name-only loop).
for f in 00_ok_clean.cs 01_leak_on_error_path.ok.cs 02_use_after_release.ok.cs \
03_double_release.ok.cs 07_use_after_handoff.ok.cs 10_leak_in_loop.ok.cs \
11_overspan_full_view.ok.cs; do
if echo "$out" | grep -q "$f:"; then
echo "FAIL: clean fixture '$f' was flagged"; exit 1
fi
done
echo "OK: examples/gallery/cs/ bad/ok pairs match the .own gallery's codes 1:1 on the real extractor pipeline"
- name: P-005 D5.4 T4 wrap/adopt (--flow-locals)
run: |
# The extractor recognises a first-party wrapper that ADOPTS a disposable arg into an
# owning field (its Dispose disposes that field; its ctor stores the arg into it) and
# emits an `alias_join`, so the wrapper and the inner share ONE obligation: disposing
# either is clean, dropping both leaks the resource ONCE, disposing both is OWN003. A
# non-adopting holder makes NO alias claim (precision-first: no false double-dispose).
dotnet run --project frontend/roslyn/OwnSharp.Extractor -- \
frontend/roslyn/samples/FactoryAdoptSample.cs --flow-locals -o "$RUNNER_TEMP/adopt.json"
out=$(python -m ownlang ownir "$RUNNER_TEMP/adopt.json" || true)
echo "$out"
# dropping both aliases leaks the ONE underlying resource once, on the inner local.
echo "$out" | grep -qE "FactoryAdoptSample\.cs:[0-9]+:.*\[OWN001\].*'adoptInnerLeak'" \
|| { echo "FAIL: expected the per-RID OWN001 on the dropped adopted inner (adoptInnerLeak)"; exit 1; }
# disposing both aliases is a double-dispose through the shared obligation.
echo "$out" | grep -q "OWN003" \
|| { echo "FAIL: expected OWN003 (double-dispose through the alias)"; exit 1; }
# clean / inner-only / non-adopt cases stay silent (no finding on their locals); the
# leaked WRAPPER alias must also be silent (one finding, attributed to the inner).
for ok in adoptInnerClean adoptWrapClean adoptInnerDirect adoptWrapDirect \
adoptInnerTt adoptWrapTt holdInner holdWrap adoptWrapLeak; do
if echo "$out" | grep -q "'$ok'"; then echo "FAIL: D5.4 case '$ok' must be silent"; exit 1; fi
done
echo "OK: D5.4 T4 alias_join on real C# — adopt verified (Dispose-field + ctor-param), double-dispose caught, non-adopt makes no claim"
- name: Opt-in body-throw-edges tier (--body-throw-edges, P-016 throw firehose)
run: |
# The opt-in tier: body-level "any call may throw" dispose-not-called-on-throw (CodeQL
# cs/dispose-not-called-on-throw parity on the no-try slice). OFF by default — its own
# sample file is run in BOTH modes to prove the flag GATES the firehose (running it over
# FlowLocalsSample would flood every acquire/use/dispose sample under the flag).
# default (flag off): the may-throw case is SILENT (a body-level call is not a leak point).
dotnet run --project frontend/roslyn/OwnSharp.Extractor -- \
frontend/roslyn/samples/BodyThrowEdgesSample.cs --flow-locals -o "$RUNNER_TEMP/bte_off.json"
off=$(python -m ownlang ownir "$RUNNER_TEMP/bte_off.json" || true)
echo "$off"
for s in mtbd mtf adc; do
if echo "$off" | grep -q "'$s'"; then echo "FAIL: '$s' must be SILENT without --body-throw-edges"; exit 1; fi
done
# opt-in (flag on): the may-throw WriteByte between acquire and Dispose is a throw point
# that skips the Dispose -> 'mtbd' leaks; 'adc' (adjacent dispose, nothing throws between)
# stays silent even under the flag (the edge needs an intervening may-throw statement).
dotnet run --project frontend/roslyn/OwnSharp.Extractor -- \
frontend/roslyn/samples/BodyThrowEdgesSample.cs --flow-locals --body-throw-edges -o "$RUNNER_TEMP/bte_on.json"
on=$(python -m ownlang ownir "$RUNNER_TEMP/bte_on.json" || true)
echo "$on"
echo "$on" | grep -qE "OWN001.*'mtbd' may not be disposed on every path" \
|| { echo "FAIL: expected OWN001 on 'mtbd' under --body-throw-edges"; exit 1; }
# adc: nothing throws between acquire and dispose. mtf (Codex P2): a may-throw call inside
# a finally must not get a synthetic bare exit — the outer finally disposes it. Both silent
# even under the flag.
for s in adc mtf; do
if echo "$on" | grep -q "'$s'"; then echo "FAIL: '$s' must stay silent even under --body-throw-edges"; exit 1; fi
done
echo "OK: --body-throw-edges gates the body-level dispose-not-called-on-throw firehose (off by default; fires only with an intervening may-throw; finally-internal throws excluded)"
- name: Coverage summary (--stats)
run: |
# --stats prints a flow-locals coverage line to stderr and stamps the same
# counts into the facts JSON: of the methods with a disposable local, how
# many were flow-analysed vs honestly skipped (an unmodelled construct).
cov=$(dotnet run --project frontend/roslyn/OwnSharp.Extractor -- \
frontend/roslyn/samples/FlowLocalsSample.cs --flow-locals --stats \
-o "$RUNNER_TEMP/stats.json" 2>&1 >/dev/null)
echo "$cov"
echo "$cov" | grep -qE '^coverage: [0-9]+/[0-9]+ methods .* flow-analysed' \
|| { echo "FAIL: expected a --stats coverage line on stderr"; exit 1; }
# Parse the JSON (not a substring grep): assert the stats object exists,
# all three counters are numbers, and the invariant holds — every method
# with a disposable local is either flow-analysed or honestly skipped.
jq -e '.stats as $s
| ($s.methods_with_local | type == "number")
and ($s.methods_flow_analysed | type == "number")
and ($s.methods_skipped_unmodelled | type == "number")
and ($s.methods_flow_analysed + $s.methods_skipped_unmodelled
== $s.methods_with_local)' \
"$RUNNER_TEMP/stats.json" >/dev/null \
|| { echo "FAIL: stats object missing / non-numeric / invariant violated";
cat "$RUNNER_TEMP/stats.json"; exit 1; }
echo "OK: --stats coverage on stderr + valid stats object (invariant holds)"
- name: Escape-via-projection leak — GTM UnitOfWork (--flow-locals, P-016 B0b/B2)
run: |
# A real GTM leak the flat detector misses: a UnitOfWork (IDisposable) used
# ONLY through member access to build a returned DEFERRED IQueryable. The
# bare `uow` never escapes, so it stays tracked and is disposed on no path
# -> OWN001. Crucially NOT fixable by a naive `using` (the deferred query
# would run after dispose) — the `using var`+materialize fix (uowFixed) is
# the one that must stay silent.
dotnet run --project frontend/roslyn/OwnSharp.Extractor -- \
frontend/roslyn/samples/UnitOfWorkFlowSample.cs --flow-locals -o "$RUNNER_TEMP/uow.json"
out=$(python -m ownlang ownir "$RUNNER_TEMP/uow.json" || true)
echo "$out"
# `uow` is released on no path, so the OWN001 reads "is never disposed".
echo "$out" | grep -qE "OWN001.*'uow' is never disposed" \
|| { echo "FAIL: expected OWN001 'is never disposed' on UnitOfWork 'uow'"; exit 1; }
echo "$out" | grep -q "UnitOfWorkFlowSample.cs" \
|| { echo "FAIL: expected the finding at the C# sample location"; exit 1; }
# the three correct fixes must stay silent: materialize inside `using`
# (uowFixed), and ownership TRANSFERRED to the caller — returned (uowOwned)
# or moved out as an argument (uowMoved).
for ok in uowFixed uowOwned uowMoved; do
if echo "$out" | grep -q "'$ok'"; then
echo "FAIL: a correct fix ('$ok') was wrongly reported as a leak"; exit 1
fi
done
echo "OK: escape-via-projection UnitOfWork leak -> OWN001 'never disposed'; materialize + ownership-transfer fixes stay silent"
- name: WinForms modeless-form precision (--flow-locals, P-016)
run: |
# WinForms owns a *modeless* form's lifetime: a form shown via Form.Show()
# is disposed by the framework on close, so the extractor models that Show()
# as a RELEASE at the show site (ownership transfers to the framework there).
# A *modal* dialog shown via ShowDialog() is the caller's to dispose ->
# ShowDialog is NOT a release, so it stays tracked and an undisposed one is a
# real OWN001. Reduced from a ShareX (WinForms) false positive: our WPF-tuned
# local-disposable detector over-fired on the idiomatic `new SomeForm().Show()`.
dotnet run --project frontend/roslyn/OwnSharp.Extractor -- \
frontend/roslyn/samples/WinFormsModelessSample.cs --flow-locals -o "$RUNNER_TEMP/winforms.json"
out=$(python -m ownlang ownir "$RUNNER_TEMP/winforms.json" || true)
echo "$out"
# the modal dialog never disposed is a real leak (ShowDialog is caller-owned):
echo "$out" | grep -qE "OWN001.*'modalLeak' is never disposed" \
|| { echo "FAIL: expected OWN001 on the undisposed ShowDialog modal dialog"; exit 1; }
echo "$out" | grep -q "WinFormsModelessSample.cs" \
|| { echo "FAIL: expected the finding at the C# sample location"; exit 1; }
# because Show() is a release AT THE SHOW SITE (path-sensitive, not a method-wide
# exemption), a form shown only on one branch leaks on the branch that never shows
# it -> OWN001 'may not be disposed on every path' (Codex review on PR #57).
echo "$out" | grep -qE "OWN001.*'condForm' may not be disposed on every path" \
|| { echo "FAIL: expected OWN001 on the conditionally-shown modeless form"; exit 1; }
# the precision fix: an unconditionally-shown modeless form (`.Show()`, ownership
# transferred to the framework) must stay silent, and so must a properly-disposed
# modal dialog (modalOk):
for ok in modeless modalOk; do
if echo "$out" | grep -q "'$ok'"; then
echo "FAIL: silent case '$ok' was reported (WinForms precision)"; exit 1
fi
done
echo "OK: WinForms modeless Form.Show() = call-site release (framework-owned); conditional show leaks on the no-show path; modal ShowDialog() leak caught; disposed modal silent"
# Project-file input (the CLI-first project/solution resolution borrowed from the
# roslyn-tools tooling shape): point the extractor at a .csproj instead of a file
# list and assert the same event leak surfaces. Proves ProjectCsFiles resolves the
# SDK-style project to its source set and feeds the core identically to the per-file
# path. Both the positional and the `--project` flag forms are exercised.
- name: Project-file input (.csproj -> source set -> core)
run: |
dotnet run --project frontend/roslyn/OwnSharp.Extractor -- \
frontend/roslyn/project-input-sample/ProjectInputSample.csproj \
-o "$RUNNER_TEMP/proj-facts.json"
out=$(python -m ownlang ownir "$RUNNER_TEMP/proj-facts.json" || true)
echo "$out"
echo "$out" | grep -q "CustomerSubscription.cs" \
|| { echo "FAIL: .csproj input did not resolve CustomerSubscription.cs"; exit 1; }
echo "$out" | grep -qE "CustomerSubscription\.cs:[0-9]+:.*\[OWN001\]" \
|| { echo "FAIL: expected OWN001 via .csproj input"; exit 1; }
# the `--project` flag form must resolve to the same source set as the positional form.
# Assert parity at the FACT boundary (canonicalized OwnIR), not after the Python core —
# diffing rendered diagnostics could pass even if the two paths emit different facts the
# core happens to collapse to the same warnings (CodeRabbit).
dotnet run --project frontend/roslyn/OwnSharp.Extractor -- \
--project frontend/roslyn/project-input-sample/ProjectInputSample.csproj \
-o "$RUNNER_TEMP/proj-facts-flag.json"
diff <(jq -S . "$RUNNER_TEMP/proj-facts.json") \
<(jq -S . "$RUNNER_TEMP/proj-facts-flag.json") \
|| { echo "FAIL: --project flag and positional .csproj emit different OwnIR facts"; exit 1; }
# the `extract` verb + `--out` long form must resolve to the same facts as the bare form.
dotnet run --project frontend/roslyn/OwnSharp.Extractor -- \
extract --project frontend/roslyn/project-input-sample/ProjectInputSample.csproj \
--out "$RUNNER_TEMP/proj-facts-verb.json"
diff <(jq -S . "$RUNNER_TEMP/proj-facts.json") \
<(jq -S . "$RUNNER_TEMP/proj-facts-verb.json") \
|| { echo "FAIL: 'extract --out' verb form disagrees with the bare form"; exit 1; }
echo "OK: .csproj input resolves to its source set and feeds the core identically (bare == --project == 'extract --out', fact-level parity)"
# --help renders the discoverable usage (commands/inputs/options) and exits 0.
dotnet run --project frontend/roslyn/OwnSharp.Extractor -- --help > "$RUNNER_TEMP/help.txt"
grep -q "Usage:" "$RUNNER_TEMP/help.txt" && grep -q -- "--no-project-refs" "$RUNNER_TEMP/help.txt" \
|| { echo "FAIL: --help did not render the usage/options"; exit 1; }
# --no-project-refs is accepted and (with no bin/ on the sample) yields identical facts.
# Guard the precondition: the parity below only holds while the sample is unbuilt, so a
# future step that builds it fails here with a clear message, not a confusing facts diff.
[ ! -d frontend/roslyn/project-input-sample/bin ] \
|| { echo "FAIL: sample project must be unbuilt for the --no-project-refs parity check"; exit 1; }
dotnet run --project frontend/roslyn/OwnSharp.Extractor -- \
--no-project-refs --project frontend/roslyn/project-input-sample/ProjectInputSample.csproj \
--out "$RUNNER_TEMP/proj-facts-norefs.json"
diff <(jq -S . "$RUNNER_TEMP/proj-facts.json") \
<(jq -S . "$RUNNER_TEMP/proj-facts-norefs.json") \
|| { echo "FAIL: --no-project-refs changed the facts for an unbuilt sample project"; exit 1; }
echo "OK: --help renders the option list; --no-project-refs is accepted"
# explain: the diagnostic-catalogue CLI surface lives in the core (one checker). Smoke
# it end-to-end — explain a code, and harvest+explain every code in a real findings file.
- name: explain command (code + --json harvest)
run: |
python -m ownlang explain OWN001 | grep -q "Fix:" \
|| { echo "FAIL: explain OWN001 missing a Fix line"; exit 1; }
# ownir exits 1 when findings exist (expected — the sample leaks); only 2+ is a real
# error (bad facts / emit regression). Tolerate 1, surface 2+, so a genuine SARIF
# generation failure is not masked by a blanket `|| true` (CodeRabbit).
rc=0; python -m ownlang ownir "$RUNNER_TEMP/proj-facts.json" --format sarif > "$RUNNER_TEMP/proj.sarif" || rc=$?
[ "$rc" -le 1 ] || { echo "FAIL: SARIF generation errored (rc=$rc)"; exit 1; }
python -m ownlang explain --json "$RUNNER_TEMP/proj.sarif" | grep -q "OWN001" \
|| { echo "FAIL: explain --json did not harvest OWN001 from the SARIF log"; exit 1; }
echo "OK: explain answers a code and harvests codes from a real findings/SARIF file"
# P-035 (Increment B0+B1): a project-DECLARED weak-subscribe wrapper is a
# first-class, already-released subscription. own.toml is the surface
# (own-check --config); the extractor's --weak-subscribe is internal transport.
- name: P-035 weak-subscribe — declared wrapper is an accepted release
run: |
sample=frontend/roslyn/samples/WeakSubscribeAllowlistSample.cs
unresolved=frontend/roslyn/samples/WeakSubscribeUnresolvedSample.cs
rxsample=frontend/roslyn/samples/WeakSubscribeRxNoEventsSample.cs
decl="WeakEvents.AddPropertyChanged"
# WITH the declared wrapper; WITHOUT (byte-for-byte baseline); WITH but
# --no-event-leaks (event analysis off); and the unresolved-external sample.
dotnet run --project frontend/roslyn/OwnSharp.Extractor -- \
"$sample" --weak-subscribe "$decl" -o "$RUNNER_TEMP/ws_on.json"
dotnet run --project frontend/roslyn/OwnSharp.Extractor -- \
"$sample" -o "$RUNNER_TEMP/ws_off.json"
dotnet run --project frontend/roslyn/OwnSharp.Extractor -- \
"$sample" --weak-subscribe "$decl" --no-event-leaks -o "$RUNNER_TEMP/ws_noev.json"
dotnet run --project frontend/roslyn/OwnSharp.Extractor -- \
"$unresolved" --weak-subscribe "$decl" -o "$RUNNER_TEMP/ws_unres.json"
# Rx-collision regression: a declared `Subscribe`-named IDisposable wrapper under
# --no-event-leaks must stay fully silent (suppression is unconditional).
dotnet run --project frontend/roslyn/OwnSharp.Extractor -- \
"$rxsample" --weak-subscribe "WeakBus.Subscribe" --no-event-leaks -o "$RUNNER_TEMP/ws_rxnoev.json"
# The Increment-B acceptance contract, at the fact level.
python tests/check_weak_subscribe_facts.py \
"$RUNNER_TEMP/ws_on.json" "$RUNNER_TEMP/ws_off.json" \
"$RUNNER_TEMP/ws_noev.json" "$RUNNER_TEMP/ws_unres.json" \
"$RUNNER_TEMP/ws_rxnoev.json"
# Contrast that proves it is the DECLARATION (not --no-event-leaks) doing the
# suppression: the same sample under --no-event-leaks with NO declaration still
# emits the Rx dropped-token subscription for RxCollisionSubscriber.
dotnet run --project frontend/roslyn/OwnSharp.Extractor -- \
"$rxsample" --no-event-leaks -o "$RUNNER_TEMP/ws_rxbase.json"
python -c "import json,sys; d=json.load(open(sys.argv[1])); c=[x for x in d['components'] if x['name']=='RxCollisionSubscriber']; subs=(c[0].get('subscriptions') or []) if c else []; sys.exit(0 if any(s.get('resource')=='subscribe' for s in subs) else 1)" \
"$RUNNER_TEMP/ws_rxbase.json" \
|| { echo "FAIL: without a declaration, --no-event-leaks should still leave the Rx dropped-token finding (regression baseline broken)"; exit 1; }
# End-to-end through the core: the ordinary += still surfaces (OWN001); the
# declared wrapper does not.
out=$(python -m ownlang ownir "$RUNNER_TEMP/ws_on.json" || true)
echo "$out" | grep -q "OrdinaryPlusEquals" \
|| { echo "FAIL: ordinary += leak not flagged"; exit 1; }
! echo "$out" | grep -q "WeaklySubscribed" \
|| { echo "FAIL: declared weak wrapper was flagged as a leak"; exit 1; }
# Action plumbing: own-check --config own.toml parses [weak-subscription] and
# forwards it to the extractor, so the wrapper is silent end-to-end.
printf '[weak-subscription]\nsubscribe = ["WeakEvents.AddPropertyChanged"]\n' > "$RUNNER_TEMP/own.toml"
cout=$(scripts/own-check.sh --config "$RUNNER_TEMP/own.toml" "$sample" || true)
! echo "$cout" | grep -q "WeaklySubscribed" \
|| { echo "FAIL: own-check --config did not silence the declared wrapper"; exit 1; }
echo "$cout" | grep -q "OrdinaryPlusEquals" \
|| { echo "FAIL: own-check --config lost the ordinary += leak"; exit 1; }
# A malformed config is a hard error (non-zero), never a silent skip.
printf '[weak-subscription]\nsubscribe = ["bad_no_dot"]\n' > "$RUNNER_TEMP/bad.toml"
if scripts/own-check.sh --config "$RUNNER_TEMP/bad.toml" "$sample" >/dev/null 2>&1; then
echo "FAIL: malformed --config was silently accepted"; exit 1
fi
echo "OK: declared weak-subscribe wrapper = accepted release; += unaffected; own.toml plumbed; malformed config is a hard error"
# S0 (--fix-candidates, Part A): ADDITIVE fix-candidate metadata — a
# namespaced `fix` block on each `+=` subscription, component
# `qualified_name`/shape, and a top-level `fix_candidates_version`. With the
# flag OFF the facts are byte-identical (the extractor fact tests above run
# flag-off and would break otherwise); this step asserts the metadata is
# correct and that the off-run leaks none of it.
- name: S0 fix-candidates — extractor metadata (Part A)
run: |
fc=frontend/roslyn/samples/FixCandidatesSample.cs
dotnet run --project frontend/roslyn/OwnSharp.Extractor -- \
"$fc" --fix-candidates -o "$RUNNER_TEMP/fc_on.json"
dotnet run --project frontend/roslyn/OwnSharp.Extractor -- \
"$fc" -o "$RUNNER_TEMP/fc_off.json"
python tests/check_fix_candidates_facts.py "$RUNNER_TEMP/fc_on.json" "$RUNNER_TEMP/fc_off.json"
# REAL byte parity: the flag-off output must equal the committed pre-S0 golden
# (generated by the base extractor at the S0 branch point) BYTE-for-byte -- a
# true comparison, not "no new keys". Regenerate the golden (documented in
# tests/goldens/README.md) only when an unrelated extractor change intentionally
# alters this sample's facts.
if ! diff -u tests/goldens/fix_candidates_off.golden.json "$RUNNER_TEMP/fc_off.json"; then
echo "FAIL: flag-off facts drifted from the pre-S0 golden (byte parity broken)"; exit 1
fi
echo "OK: fix-candidate metadata correct; flag-off is byte-identical to the pre-S0 golden"
# S0 Part B: the `own-fix subscriptions candidates` collector turns the fix
# metadata into a deterministic candidates.json (analysis-only). Reuses fc_on.json.
- name: S0 fix-candidates — own-fix collector (Part B)
run: |
on="$RUNNER_TEMP/fc_on.json"
printf '[weak-subscription]\nsubscribe = ["WeakEvents.AddPropertyChanged"]\n' > "$RUNNER_TEMP/fix.toml"
# A leaky INotifyPropertyChanged subscription -> one candidate, convert_acquire allowed.
python -m ownlang own-fix subscriptions candidates "$on" \
--config "$RUNNER_TEMP/fix.toml" \
--class Own.Samples.FixCandidates.InpcNoTeardown \
--output "$RUNNER_TEMP/cand.json" --root .
python -c "
import json, sys
d = json.load(open(sys.argv[1]))
assert d['target_api'] == {'subscribe': 'WeakEvents.AddPropertyChanged'}, d['target_api']
assert len(d['candidates']) == 1, d['candidates']
c = d['candidates'][0]
assert c['allowed_actions'] == ['convert_acquire', 'manual_review'], c['allowed_actions']
assert c['event_contract'] == 'inotify_property_changed'
assert c['finding_id'].startswith('OWN001:sha256:'), c['finding_id']
assert d['source_files'][0]['sha256'].startswith('sha256:')
assert d['selection']['constraints']['max_types_changed'] == 1
print('OK: candidate bundle well-formed')
" "$RUNNER_TEMP/cand.json"
# A name_only event -> manual_review only.
python -m ownlang own-fix subscriptions candidates "$on" \
--config "$RUNNER_TEMP/fix.toml" \
--class Own.Samples.FixCandidates.NameOnlySubscriber \
--output "$RUNNER_TEMP/cand2.json" --root .
python -c "import json,sys; a=json.load(open(sys.argv[1]))['candidates'][0]['allowed_actions']; sys.exit(0 if a==['manual_review'] else 1)" "$RUNNER_TEMP/cand2.json" \
|| { echo "FAIL: name_only should be manual_review only"; exit 1; }
# A nested class -> hard error.
if python -m ownlang own-fix subscriptions candidates "$on" --config "$RUNNER_TEMP/fix.toml" \
--class Own.Samples.FixCandidates.OuterWithNested.Nested --output "$RUNNER_TEMP/x.json" --root . 2>/dev/null; then
echo "FAIL: a nested class must be refused"; exit 1
fi
# An unknown finding-id -> hard error.
if python -m ownlang own-fix subscriptions candidates "$on" --config "$RUNNER_TEMP/fix.toml" \
--class Own.Samples.FixCandidates.InpcNoTeardown --finding-id OWN001:sha256:deadbeef \
--output "$RUNNER_TEMP/x.json" --root . 2>/dev/null; then
echo "FAIL: an unknown finding-id must be refused"; exit 1
fi
echo "OK: own-fix collector — bundle, permission tiering, and hard rejections"
# S1 orchestration (glue): render -> o7 invoke -> validate-plan, exercised with a
# FAKE o7 that drops the canned fixture result into --out, so the shell wiring is
# tested without a live model. The pure render/validate logic + fixture conformance
# run in the "tests" job (tests/test_fix_plan.py).
- name: S1 own-fix-plan orchestration (fake o7)
run: |
fx=tests/fixtures/o7-invoke/subscription-fix-plan-v1
bin="$RUNNER_TEMP/bin"; mkdir -p "$bin"
export O7_FAKE_RESULT="$PWD/$fx/o7-result.json"
cat > "$bin/o7" <<'SH'
#!/usr/bin/env bash
out=""
while [ $# -gt 0 ]; do case "$1" in --out) out="$2"; shift 2;; *) shift;; esac; done
mkdir -p "$out"; cp "$O7_FAKE_RESULT" "$out/result.json"
SH
chmod +x "$bin/o7"
PATH="$bin:$PATH" scripts/own-fix-plan.sh "$fx/candidates.json" \
"$RUNNER_TEMP/validated.json" --o7 o7
python -c "import json,sys; a=json.load(open(sys.argv[1])); b=json.load(open(sys.argv[2])); sys.exit(0 if a==b else 1)" \
"$RUNNER_TEMP/validated.json" "$fx/expected-validated-plan.json" \
|| { echo 'FAIL: glue-produced plan != expected'; exit 1; }
echo 'OK: render -> (fake o7) -> validate produces the expected validated plan'
# S2 (deterministic apply) — the Owen.CSharp.Rewriter core over the full chain:
# extractor --fix-candidates -> own-fix candidates -> validate_plan -> owen-rewrite.
# The rewriter is the LOAD-BEARING step, so its regressions assert the guarantees it
# must hold on its own, without assuming the Python gate ever ran: hash/root/input
# validation, the strict UTF-8 decoder, the target-API grammar, extractor-compatible
# identity normalization, transactional publication, and — throughout — that the
# source tree is never written to and a refusal writes nothing at all.
- name: S2 owen-rewrite core (guards + regressions)
run: bash tests/rewriter_regressions.sh "$RUNNER_TEMP/s2"
# S2 step 8 — the canonical patch bundle over the same real chain, ending in
# `own-fix subscriptions apply`. Asserts the three-part bundle, canonical patch
# headers with no temp/absolute path or timestamp, the exact byte-deterministic
# manifest, a `git apply` round-trip that reproduces the postimage byte for byte,
# the mixed-action partition, the empty-patch contract, and that a refusal leaves
# no output. (The transport-tampering cases are pure-function tests in
# tests/test_patch_bundle.py — forging a report must not need a hook in the rewriter.)
- name: S2 step 8 — canonical patch bundle (patch + manifest + postimage)
run: bash tests/patch_bundle_regressions.sh "$RUNNER_TEMP/s8"
# S2 step 9 — the structural self-gate over a step 8 bundle, ending in an INDEPENDENT
# git apply --check → apply in a hermetic throwaway repo that reproduces the postimage
# byte for byte. Asserts the ten gates, deterministic evidence, and — via forged
# fixtures rebound to reach the intended gate — the full APPLY_CHECK / APPLY_MISMATCH /
# HASH_MISMATCH / BUNDLE_LAYOUT / PRISTINE_SOURCE / PUBLICATION taxonomy, with the real
# checkout, index and config never touched. (The pure-function + byte-tampering cases
# are in tests/test_gate_patch.py, in the dotnet-free `tests` job.)
- name: S2 step 9 — structural self-gate (git apply verification)
run: bash tests/gate_regressions.sh "$RUNNER_TEMP/s9"
# A stale preimage SHA is the one refusal worth pinning in the workflow itself: it is
# the invariant the whole hash-bound chain rests on.
- name: S2 owen-rewrite — a stale preimage SHA is a hard refusal
run: |
# The preimage SHA must be stale ON ITS OWN: change it in BOTH files and re-bind
# the bundle hash, so the envelope still matches and the binding still holds.
# Rewriting every sha256-looking value instead would break the hash binding, and
# the refusal would come from there — the right exit code for the wrong reason.
python3 - <<'PY'
import json
import os
import sys
sys.path.insert(0, ".")
from ownlang.fix_plan import bundle_sha256
t = os.environ["RUNNER_TEMP"] + "/s2"
bundle = json.load(open(f"{t}/fc-candidates.json"))
plan = json.load(open(f"{t}/fc-plan.json"))
stale = "sha256:" + "0" * 64
bundle["source_files"][0]["sha256"] = stale
plan["source_files"][0]["sha256"] = stale
plan["input_bundle_sha256"] = bundle_sha256(bundle)
json.dump(bundle, open(f"{t}/stale-candidates.json", "w"))
json.dump(plan, open(f"{t}/stale-plan.json", "w"))
PY
if dotnet run --project frontend/roslyn/Owen.CSharp.Rewriter --no-build -- \
--plan "$RUNNER_TEMP/s2/stale-plan.json" \
--candidates "$RUNNER_TEMP/s2/stale-candidates.json" \
--root . --out "$RUNNER_TEMP/s2/stale_out" 2>"$RUNNER_TEMP/s2/stale.err"; then
echo 'FAIL: a stale preimage SHA must be refused'; exit 1
fi
grep -q 'STALE SOURCE / PREIMAGE MISMATCH' "$RUNNER_TEMP/s2/stale.err" \
|| { echo "FAIL: refused for the wrong reason: $(cat "$RUNNER_TEMP/s2/stale.err")"; exit 1; }
[ ! -d "$RUNNER_TEMP/s2/stale_out" ] || { echo 'FAIL: a refused run left an output dir'; exit 1; }
git diff --quiet -- frontend/roslyn/samples/ || { echo 'FAIL: the source tree was modified'; exit 1; }
echo 'OK: STALE SOURCE / PREIMAGE MISMATCH; nothing written; the source tree is untouched'
# The OwnTS frontend spike (P-020 Own.React): the SAME OwnIR seam, fed from a
# React .tsx instead of C#. Two analyses over the one core: (1) a useEffect
# acquire (timer / subscribe / listener) with no cleanup return is the core's
# OWN001 — the cross-language leak model; (2) EFF001, a NEW core analysis
# (ownlang/effects.py) — an IO effect whose dependency identity is unstable
# (a render-scope object literal) re-fires every render: the effect storm. The
# frontend emits only facts; the stability verdict is the core's. No dotnet.
ownts-react-effects:
name: OwnTS (React useEffect) -> OwnIR -> core
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
with:
python-version: "3.13"
- name: Pin the spike (leaky=3xOWN001+EFF001, clean=0, showcase=2xEFF001)
run: python frontend/ownts/test_ownts.py
- name: Extract OwnIR facts from a React .tsx and check through the core
run: |
python frontend/ownts/ownts.py frontend/ownts/examples/Dashboard.tsx \
-o "$RUNNER_TEMP/dash.facts.json"
cat "$RUNNER_TEMP/dash.facts.json"
out=$(python -m ownlang ownir "$RUNNER_TEMP/dash.facts.json" || true)
echo "$out"
echo "$out" | grep -q "Dashboard.tsx" \
|| { echo "FAIL: expected findings located at the .tsx"; exit 1; }
echo "$out" | grep -q "resource: timer" \
|| { echo "FAIL: expected the setInterval [resource: timer] leak"; exit 1; }
[ "$(echo "$out" | grep -c 'OWN001')" -eq 3 ] \
|| { echo "FAIL: expected three OWN001 leaks"; exit 1; }
echo "$out" | grep -q "\[EFF001\].*request storm" \
|| { echo "FAIL: expected the EFF001 effect-storm verdict"; exit 1; }
# exact finding count (a code-tagged line each), not a substring of "4 finding"
[ "$(echo "$out" | grep -cE '\[(OWN|EFF|DI)[0-9]{3}\]')" -eq 4 ] \
|| { echo "FAIL: expected 3 OWN001 + 1 EFF001 = 4 findings"; exit 1; }
- name: EFF001 stability showcase — only provable storms fire (low FP)
run: |
python frontend/ownts/ownts.py frontend/ownts/examples/EffectStorm.tsx \
-o "$RUNNER_TEMP/storm.facts.json"
storm=$(python -m ownlang ownir "$RUNNER_TEMP/storm.facts.json" || true)
echo "$storm"
# the direct object dep and its derived alias fire; memo/ref/call/primitive/no-IO stay silent
[ "$(echo "$storm" | grep -c '\[EFF001\]')" -eq 2 ] \
|| { echo "FAIL: expected exactly two EFF001 (object dep + derived alias)"; exit 1; }
echo "$storm" | grep -q "derives from" \
|| { echo "FAIL: expected the derivation (propagation) verdict"; exit 1; }
- name: Edge cases — partial timer cleanup + nested-scope shadow
run: |
python frontend/ownts/ownts.py frontend/ownts/examples/EffectEdges.tsx \
-o "$RUNNER_TEMP/edges.facts.json"
edges=$(python -m ownlang ownir "$RUNNER_TEMP/edges.facts.json" || true)
echo "$edges"
# only the SECOND, uncleared interval leaks; the memoized dep is not shadowed
[ "$(echo "$edges" | grep -cE '\[(OWN|EFF|DI)[0-9]{3}\]')" -eq 1 ] \
|| { echo "FAIL: expected exactly one OWN001 (the uncleared timer)"; exit 1; }
echo "$edges" | grep -q "pollB" \
|| { echo "FAIL: the leak must be the second (uncleared) interval"; exit 1; }
- name: Parser hardening — string literals, nested dep brackets, listener options
run: |
python frontend/ownts/ownts.py frontend/ownts/examples/EffectHardening.tsx \
-o "$RUNNER_TEMP/hard.facts.json"
hard=$(python -m ownlang ownir "$RUNNER_TEMP/hard.facts.json" || true)
echo "$hard"
# a string with commas/braces does not truncate the body (the timer is cleared);
# the leak is the options-dropped listener; the object dep fires EFF001 once
[ "$(echo "$hard" | grep -cE '\[(OWN|EFF|DI)[0-9]{3}\]')" -eq 2 ] \
|| { echo "FAIL: expected one OWN001 (listener) + one EFF001 (object dep)"; exit 1; }
echo "$hard" | grep -q "scroll" \
|| { echo "FAIL: the leak must be the options-dropped scroll listener"; exit 1; }
- name: Expression-bodied cleanup with an options object is silent
run: |
python frontend/ownts/ownts.py frontend/ownts/examples/EffectExprCleanup.tsx \
-o "$RUNNER_TEMP/expr.facts.json"
# no `|| true`: this case expects ZERO findings (rc 0), so a parser/core
# crash (rc 2) must FAIL the step, not be swallowed into an empty result.
expr=$(python -m ownlang ownir "$RUNNER_TEMP/expr.facts.json")
echo "$expr"
# the `{` of `{capture: true}` belongs to the call, not the cleanup block —
# the listener is released, so no false-positive leak
[ "$(echo "$expr" | grep -cE '\[(OWN|EFF|DI)[0-9]{3}\]')" -eq 0 ] \
|| { echo "FAIL: a properly-released listener must not be reported"; exit 1; }
- name: Real-world cleanup patterns (OSS-benchmark FP fixes) are silent
run: |
python frontend/ownts/ownts.py frontend/ownts/examples/EffectRealWorld.tsx \
-o "$RUNNER_TEMP/rw.facts.json"
rw=$(python -m ownlang ownir "$RUNNER_TEMP/rw.facts.json")
echo "$rw"
# AbortController signal, ref/pre-declared timer handles, nested-block
# cleanup, observer.subscribe/unsubscribe — all released, zero findings.
[ "$(echo "$rw" | grep -cE '\[(OWN|EFF|DI)[0-9]{3}\]')" -eq 0 ] \
|| { echo "FAIL: real-world cleanup patterns must not be reported"; exit 1; }
- name: False-negative controls (wrong controller / args / conditional / async arrow+ES5) still leak
run: |
python frontend/ownts/ownts.py frontend/ownts/examples/EffectLeakControl.tsx \
-o "$RUNNER_TEMP/leak.facts.json"
leak=$(python -m ownlang ownir "$RUNNER_TEMP/leak.facts.json" || true)
echo "$leak"
# a release-shaped cleanup that does not release THIS resource (wrong
# controller / mismatched args / conditional return / async arrow effect /
# async ES5 `function` effect) must not be over-suppressed — all five
# controls stay OWN001
[ "$(echo "$leak" | grep -c 'OWN001')" -eq 5 ] \
|| { echo "FAIL: broadened matchers must not over-suppress real leaks"; exit 1; }
- name: ES5 `function` callbacks parse; capture-mismatch leak caught (real bug shape)
run: |
python frontend/ownts/ownts.py frontend/ownts/examples/EffectFunctionCallback.tsx \
-o "$RUNNER_TEMP/fn.facts.json"
fn=$(python -m ownlang ownir "$RUNNER_TEMP/fn.facts.json" || true)
echo "$fn"
# the matched ES5 cleanup is silent; the capture:true-vs-default mismatch
# (react-scroll-to-bottom@4.2.0 shape) is the one OWN001
[ "$(echo "$fn" | grep -c 'OWN001')" -eq 1 ] \
|| { echo "FAIL: expected exactly the capture-mismatch leak"; exit 1; }
echo "$fn" | grep -q "focus" \
|| { echo "FAIL: the leak must be the capture-mismatched focus listener"; exit 1; }
- name: The clean fixture (cleanups + useMemo'd dep) is silent
run: |
python frontend/ownts/ownts.py frontend/ownts/examples/DashboardClean.tsx \
-o "$RUNNER_TEMP/clean.facts.json"
clean=$(python -m ownlang ownir "$RUNNER_TEMP/clean.facts.json" || true)
echo "$clean"
[ "$(echo "$clean" | grep -cE '\[(OWN|EFF|DI)[0-9]{3}\]')" -eq 0 ] \
|| { echo "FAIL: cleaned-up + memoised effects must not fire"; exit 1; }
# Runtime ground-truth for the two OwnTS OSS *true positives* (the
# react-scroll-to-bottom@4.2.0 capture-mismatch and the @reactuses/core@6.4.0
# fresh-fn-identity leaks from docs/notes/ownts-oss-benchmark.md). The analyzer
# flags these statically (ownts-react-effects, above); this job EXECUTES the
# reduced shapes in a real DOM (jsdom) and asserts the effect's own cleanup fails
# to remove the listener — with correct-cleanup controls that must go silent. So
# the "true positive" label rests on observed behaviour, not the analyzer alone.
ownts-oss-verify:
name: OwnTS OSS true positives -> runtime leak proof (jsdom)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "22"
- name: Confirm the two OSS leaks reproduce (and controls stay clean)
working-directory: frontend/ownts/oss-verify
run: |
npm ci
npm test
# The distribution surface (Уровень 1): the own-check.sh orchestrator walks a
# directory of real C# and prints findings in the host-parseable formats the
# GitHub Action (PR annotations) and a VS Error List (MSBuild) consume — and
# the composite action itself runs end-to-end. One checker: the script just
# chains the extractor and the Python core.
own-check-surface:
name: own-check repo scan (github + msbuild) + composite action
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: GitHub-annotation format over the sample tree (directory walk)
run: |
# stdout (captured) carries only the annotations; the extractor's build
# chatter and any error flow to stderr -> the job log (never muted).
out=$(scripts/own-check.sh --format github -- frontend/roslyn/samples)
echo "--- annotations ---"; echo "$out"; echo "-------------------"
echo "$out" | grep -q "^::error " \
|| { echo "FAIL: expected a ::error annotation"; exit 1; }
echo "$out" | grep -q "frontend/roslyn/samples/CustomerViewModel.cs" \
|| { echo "FAIL: expected the relative path to the Customer leak"; exit 1; }
echo "$out" | grep -q "title=OWN001" \
|| { echo "FAIL: expected the OWN001 title in the annotation"; exit 1; }
- name: MSBuild diagnostic format over the sample tree (severity tiering)
run: |
out=$(scripts/own-check.sh --format msbuild -- frontend/roslyn/samples)
echo "--- diagnostics ---"; echo "$out"; echo "-------------------"
# P-004 tiering at the default severity, both sides: an injected-source
# subscription (CustomerViewModel's `bus` is a ctor param of unknown
# lifetime) renders as a WARNING, while a provable leak — the started,
# never-stopped timer — stays an ERROR.
echo "$out" | grep -qE "CustomerViewModel\.cs\([0-9]+\): warning OWN001:" \
|| { echo "FAIL: expected CustomerViewModel as a warning (injected source)"; exit 1; }
echo "$out" | grep -qE "TimerViewModel\.cs\([0-9]+\): error OWN001:" \
|| { echo "FAIL: expected the timer leak to stay an error"; exit 1; }
- name: --severity warning renders advisory diagnostics
run: |
out=$(scripts/own-check.sh --format msbuild --severity warning -- frontend/roslyn/samples)
echo "$out"
echo "$out" | grep -qE "CustomerViewModel\.cs\([0-9]+\): warning OWN001:" \
|| { echo "FAIL: expected an MSBuild-format warning line"; exit 1; }
if echo "$out" | grep -qE ": error OWN001:"; then
echo "FAIL: --severity warning should not emit error-level lines"; exit 1
fi
- name: --fail-on-finding propagates the core's exit code
run: |
if scripts/own-check.sh --fail-on-finding -- frontend/roslyn/samples >/dev/null 2>&1; then
echo "FAIL: a tree with leaks should exit non-zero under --fail-on-finding"; exit 1
fi
echo "OK: --fail-on-finding surfaced the leaks as a non-zero exit"
- name: A broken stage 1 is a hard error, never the "findings" tier
run: |
# Exit 1 means "analysed, and there are findings". If a failed
# extractor build could also exit 1, then a caller that chooses NOT to
# gate on findings — which is now the Action's default — reads a run
# that analysed nothing as clean. So stage-1 failures are normalised
# into the >=2 tier, and this pins it: --root at a path with no
# extractor project makes `dotnet run` fail with 1.
set +e
scripts/own-check.sh --root "$RUNNER_TEMP/no-such-root" --format github \
-- "$RUNNER_TEMP/owen-action-clean" >/dev/null 2>"$RUNNER_TEMP/stage1.err"
rc=$?
set -e
tail -3 "$RUNNER_TEMP/stage1.err" || true
[ "$rc" -ge 2 ] || { echo "FAIL: a broken stage 1 must exit >=2 (the tool did not look), got $rc"; exit 1; }
echo "OK: stage-1 failure landed in the hard-error tier (exit $rc)"
- name: SARIF surface is a valid 2.1.0 log (the structure code scanning enforces)
run: |
# The contract GitHub's code-scanning ingest enforces, checked locally so
# the upload (own-check-codescan job) is never the first place a drift is
# found: a single-run 2.1.0 log, the Owen driver, and every result
# carrying a catalogue ruleId + a located file. No upload, no permissions.
out="$RUNNER_TEMP/own.sarif"
scripts/own-check.sh --format sarif --severity warning -- frontend/roslyn/samples > "$out"
echo "wrote $(wc -c < "$out") bytes"
jq -e '.version == "2.1.0" and ((.runs | length) == 1)' "$out" >/dev/null \
|| { echo "FAIL: not a single-run SARIF 2.1.0 log"; exit 1; }
jq -e '.runs[0].tool.driver.name == "Owen"' "$out" >/dev/null \
|| { echo "FAIL: tool.driver.name is not Owen"; exit 1; }
# A dangling ruleId or an unlocated result is the #1 reason GitHub rejects
# a SARIF; startLine is optional (a file-level finding omits it -> // 1).
jq -e '
(.runs[0].tool.driver.rules | map(.id)) as $ids
| .runs[0].results
| (length > 0)
and all(.[];
(.ruleId | type == "string")
and ((([.ruleId] - $ids) | length) == 0)
and (.locations[0].physicalLocation.artifactLocation.uri | type == "string")
and ((.locations[0].physicalLocation.region.startLine // 1) | type == "number"))
' "$out" >/dev/null \
|| { echo "FAIL: a result is unlocated or references an undeclared rule"; exit 1; }
echo "OK: SARIF 2.1.0 — Owen driver, every result rule-backed + located"
- name: Fixtures for the action's status contract (clean tree, broken config)
run: |
# Tier 0: a tree with nothing to report.
mkdir -p "$RUNNER_TEMP/owen-action-clean"
printf 'public class Clean { public int M() { return 1; } }\n' \
> "$RUNNER_TEMP/owen-action-clean/Clean.cs"
# Asserted here rather than on the action step: a `uses:` step's
# stdout is not capturable, so "annotates nothing" is checked through
# the same script the action runs.
out=$(scripts/own-check.sh --format github -- "$RUNNER_TEMP/owen-action-clean")
[ -z "$(echo "$out" | grep '^::' || true)" ] \
|| { echo "FAIL: a clean tree must emit no annotations, got:"; echo "$out"; exit 1; }
echo "OK: clean tree, zero annotations"
# Tier >=2: a config own-check refuses to parse, so it exits 2 before
# analysing anything.
printf 'this is not = valid toml [[[\n' > "$RUNNER_TEMP/owen-broken.toml"
- name: The composite action runs end-to-end (non-failing)
uses: ./
with:
path: frontend/roslyn/samples
format: github
fail-on-finding: "false"
# The four tiers of the action's status contract, each pinned. The point
# of "annotations, not failures" is that it applies to FINDINGS ONLY;
# every other tier must keep behaving exactly as before, or the friendly
# default silently turns "the tool could not run" into a green check.
#
# Deliberately here and not only in action-marketplace-readiness.yml,
# which covers the same ground for the consumer fixture: that workflow is
# PATH-FILTERED (action.yml, own-check.sh, …), so a change to the Python
# core's exit codes — where these tiers actually originate — would never
# wake it. This job runs on every push and PR.
- name: "Tier 1 — findings, DEFAULT inputs: annotations published, step succeeds"
id: default_findings
uses: ./
with:
path: frontend/roslyn/samples
format: github
# fail-on-finding deliberately NOT set: this is the out-of-the-box
# experience of someone who just added Owen to their repository.
- name: "Tier 1 — the default really was non-blocking"
run: |
[ "${{ steps.default_findings.outcome }}" = "success" ] \
|| { echo "FAIL: a leaky tree must not fail the step by default"; exit 1; }
echo "OK: findings did not fail the step under default inputs"
- name: "Tier 1 opt-in — fail-on-finding: true turns the same findings into a failure"
id: strict_findings
continue-on-error: true
uses: ./
with:
path: frontend/roslyn/samples
format: github
fail-on-finding: "true"
- name: "Tier 1 opt-in — the strict mode really did fail"
run: |
[ "${{ steps.strict_findings.outcome }}" = "failure" ] \
|| { echo "FAIL: fail-on-finding: true must fail on a leaky tree (outcome=${{ steps.strict_findings.outcome }})"; exit 1; }
echo "OK: the same tree, the same annotations, a failing status"
- name: "Tier 0 — a clean tree succeeds and annotates nothing"
id: clean_tree
uses: ./
with:
path: ${{ runner.temp }}/owen-action-clean
format: github
- name: "Tier >=2 — an operational failure fails the step even with fail-on-finding: false"
id: operational_failure
continue-on-error: true
uses: ./
with:
path: frontend/roslyn/samples
format: github
fail-on-finding: "false"
# A malformed own.toml makes own-check exit 2 before it can analyse
# anything. That is the tool failing to LOOK, not a defect found in
# the caller's code — the friendly default must not absorb it.
config: ${{ runner.temp }}/owen-broken.toml
- name: "Tier >=2 — the operational failure really did fail"
run: |
[ "${{ steps.operational_failure.outcome }}" = "failure" ] \
|| { echo "FAIL: an operational failure (exit >=2) must fail the step regardless of fail-on-finding (outcome=${{ steps.operational_failure.outcome }})"; exit 1; }
echo "OK: 'could not look' did not become 'looked and found nothing'"
# The PowerShell wrapper's exit-code tiers, on the platform it exists for
# (#313). own-check.ps1 is the Windows twin of own-check.sh and is never
# exercised by any other job. The composite action drives own-check.sh. The
# PowerShell wrapper can execute far enough on Linux to prove its stage-1
# failure tier, but its successful extraction path constructs Windows-style
# paths and is therefore exercised end to end on Windows.
# That left the stage-1 normalisation — a broken extractor must land in the
# >=2 tier, not borrow exit 1 from "findings present" — mirrored in code and
# proven nowhere. Symmetry of source is an argument, not evidence.
#
# Read $LASTEXITCODE, never the wrapper process's code: inside a PowerShell
# session (which is what `shell: pwsh` is) a script's `exit N` sets
# $LASTEXITCODE to N, but `pwsh -Command "& ./script.ps1"` collapses that to
# 1 at the process boundary — measuring the wrapper instead of the script.
#
# And every step here ENDS WITH `exit 0`. These steps run commands that are
# SUPPOSED to fail, and GitHub's pwsh wrapper finishes with
# `exit $LASTEXITCODE` — so a leftover non-zero code fails the step even when
# the assertion above it passed. The first run of this job did exactly that:
# it printed "OK: stage-1 failure landed in the hard-error tier (2)" and then
# reported the step as failed.
#
# Paths are passed with an explicit -Paths, never positionally and never
# after a `--` separator. In PowerShell `--` ends parameter parsing and what
# follows binds POSITIONALLY, and this script declares $Root first — so
# `own-check.ps1 -Format github -- src\App` silently binds src\App to -Root
# and scans "." instead. That is a defect in the wrapper's own documented
# examples, tracked separately; these assertions must exercise the tiers, not
# inherit the bug.
own-check-ps1-surface:
name: own-check.ps1 exit-code tiers (Windows)
runs-on: windows-latest
defaults:
run:
shell: pwsh
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: A clean tree to check against
run: |
$clean = Join-Path $env:RUNNER_TEMP "ps1-clean"
New-Item -ItemType Directory -Force -Path $clean | Out-Null
'public class Clean { public int M() { return 1; } }' |
Set-Content -Path (Join-Path $clean "Clean.cs")
- name: Fixtures for the invocation contract (a leaky target, a clean cwd)
run: |
# The target the user will ASK for, and a working directory that has
# nothing to report — so "scanned the wrong tree" cannot hide behind
# a finding that happens to exist in both.
foreach ($n in @("ps1-target-a", "ps1-target-b")) {
$d = Join-Path $env:RUNNER_TEMP $n
New-Item -ItemType Directory -Force -Path $d | Out-Null
"using System.IO;`npublic class Leaky_$($n -replace '-','_') { public void Run() { var s = new MemoryStream(); s.WriteByte(1); } }" |
Set-Content -Path (Join-Path $d "Leaky.cs")
}
$cwd = Join-Path $env:RUNNER_TEMP "ps1-cwd-clean"
New-Item -ItemType Directory -Force -Path $cwd | Out-Null
'public class NothingHere { public int M() { return 1; } }' |
Set-Content -Path (Join-Path $cwd "NothingHere.cs")
- name: "Tier >=2 — a broken stage 1 is a hard error, never the findings tier"
run: |
# -Root at a checkout that has no extractor project: `dotnet run`
# fails with 1, and the wrapper must NOT pass that through, or a
# caller that does not gate on findings reads a run that analysed
# nothing as clean.
& ./scripts/own-check.ps1 -Root (Join-Path $env:RUNNER_TEMP "no-such-root") `
-Format github -Paths (Join-Path $env:RUNNER_TEMP "ps1-clean") 2>$null 1>$null
if ($LASTEXITCODE -lt 2) {
Write-Host "FAIL: a broken stage 1 must exit >=2 (the tool did not look), got $LASTEXITCODE"
exit 1
}
Write-Host "OK: stage-1 failure landed in the hard-error tier ($LASTEXITCODE)"
exit 0
- name: "Tier 0 — a clean tree exits 0"
run: |
& ./scripts/own-check.ps1 -Format github -Paths (Join-Path $env:RUNNER_TEMP "ps1-clean") 1>$null
if ($LASTEXITCODE -ne 0) {
Write-Host "FAIL: a clean tree must exit 0, got $LASTEXITCODE"; exit 1
}
Write-Host "OK: clean tree exits 0"
exit 0
- name: "Tier 1 — findings exit 1 only with -FailOnFinding"
run: |
# The same tree, twice: the flag is the ONLY difference, and it must
# move nothing but the exit code.
$withFlag = & ./scripts/own-check.ps1 -Format github -FailOnFinding -Paths frontend/roslyn/samples
$rcFlag = $LASTEXITCODE
$noFlag = & ./scripts/own-check.ps1 -Format github -Paths frontend/roslyn/samples
$rcNoFlag = $LASTEXITCODE
if ($rcFlag -ne 1) {
Write-Host "FAIL: findings with -FailOnFinding must exit 1, got $rcFlag"; exit 1
}
if ($rcNoFlag -ne 0) {
Write-Host "FAIL: findings without the flag must exit 0, got $rcNoFlag"; exit 1
}
if (-not ($withFlag -match "OWN001")) {
Write-Host "FAIL: expected OWN001 in the annotated output"; exit 1
}
if (($withFlag -join "`n") -ne ($noFlag -join "`n")) {
Write-Host "FAIL: the flag changed the OUTPUT, not just the exit code"; exit 1
}
Write-Host "OK: findings -> 1 with the flag, 0 without, identical output"
exit 0
- name: "Invocation contract — the requested target is what gets scanned (#315)"
run: |
# The defect this pins: `own-check.ps1 -Format github -- src\App` used
# to bind src\App to -Root and scan "." instead — the tool looked, but
# not where it was asked to. Standing in a CLEAN directory is what
# makes that visible: a wrong-tree scan comes back with nothing and
# reads as good news.
$script = Join-Path $env:GITHUB_WORKSPACE "scripts/own-check.ps1"
$targetA = Join-Path $env:RUNNER_TEMP "ps1-target-a"
$targetB = Join-Path $env:RUNNER_TEMP "ps1-target-b"
Push-Location (Join-Path $env:RUNNER_TEMP "ps1-cwd-clean")
try {
$sep = & $script -Format github -- $targetA
$expl = & $script -Format github -Paths $targetA
$pos = & $script -Format github $targetA
$many = & $script -Format github -- $targetA $targetB
$none = & $script -Format github
} finally { Pop-Location }
foreach ($case in @(@{n="-- <target>"; o=$sep}, @{n="-Paths <target>"; o=$expl}, @{n="bare positional"; o=$pos})) {
if (-not (($case.o -join "`n") -match "OWN001")) {
Write-Host "FAIL: $($case.n) reported no finding — the target was not scanned"; exit 1
}
if (($case.o -join "`n") -match "NothingHere") {
Write-Host "FAIL: $($case.n) scanned the working directory instead of the target"; exit 1
}
}
# The two public forms must be EQUIVALENT, not merely both non-empty.
if (($sep -join "`n").Trim() -ne ($expl -join "`n").Trim()) {
Write-Host "FAIL: '-- <target>' and '-Paths <target>' disagree"
Write-Host "--- -- form ---"; $sep | Write-Host
Write-Host "--- -Paths form ---"; $expl | Write-Host
exit 1
}
# [string[]] is the declared type, so more than one path must work.
$manyText = $many -join "`n"
if (-not ($manyText -match "ps1-target-a") -or -not ($manyText -match "ps1-target-b")) {
Write-Host "FAIL: multiple positional paths did not both reach -Paths"; $many | Write-Host; exit 1
}
# No target at all still means the working directory, which is clean.
if (($none -join "`n") -match "OWN001") {
Write-Host "FAIL: with no target the clean cwd should report nothing"; $none | Write-Host; exit 1
}
Write-Host "OK: every documented form scans the requested target; the two public forms agree"
exit 0
# Dog-food the code-scanning surface end-to-end (P-013): run the composite action
# with format: sarif over the sample tree, then upload the log to GitHub code
# scanning. The repo is public, so code scanning is free — this is the live proof
# that GitHub *accepts* our SARIF (upload-sarif waits for processing and fails the
# job if the log is rejected), not just that it is schema-valid (the surface job
# above). It also lights up the Security tab + inline PR annotations — the
# consumer-facing payoff the exporter was built for. The samples are intentional
# leak fixtures, so the alerts are real-if-intentional; a dedicated
# `own-net-samples` category keeps them from colliding with anything else.
# P-022 step 8 (#262) STAGE 2. This is the repository's dog-food — Own.NET
# analysing its own tree and publishing the result to its own code scanning —
# so under Stage 2 it runs on the RUST core, selected explicitly.
#
# Explicitly, and that is the whole design. The product default stays Python
# (Stage 3 is a separate authorization), so a job that asks for nothing gets
# Python; "the dogfood is Rust-default" therefore has to be written at the
# call site, where tests/test_stage2_dogfood.py can read it back. The Action's
# own public default is unaffected and is still exercised bare by
# own-check-surface and by the marketplace consumer simulation.
own-check-codescan:
name: own-check SARIF -> GitHub code scanning (Rust dog-food)
runs-on: ubuntu-latest
# Skip on fork PRs: GitHub downgrades GITHUB_TOKEN to read-only for a
# pull_request from a fork, so security-events:write is never granted and the
# upload would fail — red CI for an external contributor through no fault of
# their own. Same-repo pushes and PRs (where the token keeps write) still run.
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository
# The one job that writes: scoped to security-events so it can upload to code
# scanning. Every other job stays contents:read (the workflow-level default).
permissions:
contents: read
security-events: write
# The candidate reaches the composite action through the environment, and
# job level rather than step level because that is the inheritance a
# composite action's own steps can be relied on to see.
env:
OWEN_RUST_CORE: ${{ github.workspace }}/rust/target/release/own-cli
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- uses: dtolnay/rust-toolchain@fa04a1451ff1842e2626ccb99004d0195b455a88 # master, 2026-07-10
with:
toolchain: stable
# The PRODUCTION binary, built here, from this commit. Never
# own-shadow-engine (the #260 dev adapter) and never the Stage-1 test
# stub: a dogfood run through an instrument proves nothing about the
# thing being dogfooded.
- name: Build the production own-cli candidate
working-directory: rust
run: cargo build -p own-cli --release
- name: Record which candidate ran
run: |
test -x "$OWEN_RUST_CORE" || { echo "FAIL: no candidate at $OWEN_RUST_CORE"; exit 1; }
echo "candidate: $OWEN_RUST_CORE"
echo "sha256: $(sha256sum "$OWEN_RUST_CORE" | cut -d' ' -f1)"
echo "bytes: $(wc -c < "$OWEN_RUST_CORE")"
- name: Own.NET leak check (SARIF surface, Rust engine)
id: own
uses: ./
with:
path: frontend/roslyn/samples
format: sarif
engine: rust # STAGE 2: the dogfood runs on the Rust core
severity: warning # include the injected-source (warning-tier) leaks
fail-on-finding: "false" # let code scanning be the gate, not the step
- name: The action exposes the SARIF path
run: |
f="${{ steps.own.outputs.sarif-file }}"
test -n "$f" || { echo "FAIL: action did not set the sarif-file output"; exit 1; }
test -s "$f" || { echo "FAIL: sarif-file '$f' is missing or empty"; exit 1; }
echo "OK: action wrote $(wc -c < "$f") bytes to $f"
- name: Upload to GitHub code scanning
uses: github/codeql-action/upload-sarif@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4
with:
sarif_file: ${{ steps.own.outputs.sarif-file }}
category: own-net-samples
# P-022 step 8 (#262) STAGE 2 — the platform half of the Rust-default claim.
#
# own-check-codescan is the dogfood of record, but it is ubuntu-only and
# uploads a single code-scanning category, so it cannot carry Windows. The
# two launcher surfaces differ in exactly the mechanics that cost Stage 1 six
# CI rounds — process launch, executable bits, path forms, stream capture —
# so a Linux-only "our CI runs on Rust" is a claim about half the product.
#
# This job is deliberately NOT a contract test. stage1-engine already proves
# the engine contract; this one only asks the operational question: does this
# repository's own tree analyse correctly, through the shipped launchers,
# with the Rust core explicitly selected, on both platforms.
stage2-dogfood:
name: Rust-default dogfood (Own.NET's own tree, via own-cli)
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
- name: Build the production own-cli candidate
working-directory: rust
run: cargo build -p own-cli --release
- name: Record which candidate ran
run: |
ext=""
if [ "${{ matrix.os }}" = "windows-latest" ]; then ext=".exe"; fi
core="$PWD/rust/target/release/own-cli$ext"
test -f "$core" || { echo "FAIL: no candidate at $core"; exit 1; }
echo "candidate: $core"
echo "sha256: $(sha256sum "$core" | cut -d' ' -f1)"
echo "bytes: $(wc -c < "$core")"
# The operational run. --fail-on-finding is deliberate: the dogfood tree
# HAS a leak, so exit 1 is the correct answer and exit 0 would mean the
# Rust core analysed nothing and said so quietly.
- name: Own.NET's own tree, through own-check.sh on the Rust core
run: |
ext=""
if [ "${{ matrix.os }}" = "windows-latest" ]; then ext=".exe"; fi
export OWEN_RUST_CORE="$PWD/rust/target/release/own-cli$ext"
set +e
out=$(bash scripts/own-check.sh --engine rust --format human --fail-on-finding \
-- frontend/roslyn/samples)
rc=$?
set -e
echo "$out"
[ "$rc" -eq 1 ] || { echo "FAIL: expected exit 1 (findings), got $rc"; exit 1; }
case "$out" in *OWN001*) ;; *) echo "FAIL: the Rust core found no OWN001"; exit 1 ;; esac
echo "OK: Rust-default dogfood on ${{ matrix.os }} (own-check.sh)"
# The Windows launcher is a separate implementation, not a wrapper around
# the shell one, so the Windows leg has to go through it to mean anything.
- name: Own.NET's own tree, through own-check.ps1 on the Rust core
if: matrix.os == 'windows-latest'
shell: pwsh
run: |
$env:OWEN_RUST_CORE = "$PWD/rust/target/release/own-cli.exe"
$out = & ./scripts/own-check.ps1 -Engine rust -Format human -FailOnFinding `
-Paths frontend/roslyn/samples
$rc = $LASTEXITCODE
$out | Write-Host
if ($rc -ne 1) { throw "expected exit 1 (findings), got $rc" }
if ($out -notmatch 'OWN001') { throw "the Rust core found no OWN001" }
Write-Host "OK: Rust-default dogfood on windows (own-check.ps1)"
exit 0
# No fallback, measured rather than asserted: with the candidate broken,
# the dogfood must fail visibly. A run that quietly produced a verdict
# here would mean Python had answered for Rust, and every green Rust
# dogfood above would be worth nothing.
- name: A broken candidate fails the dogfood instead of being rescued
run: |
broken="$RUNNER_TEMP/not-a-core"
printf 'this is not an executable image\n' > "$broken"
export OWEN_RUST_CORE="$broken"
set +e
out=$(bash scripts/own-check.sh --engine rust --format human \
-- frontend/roslyn/samples 2>&1)
rc=$?
set -e
case "$out" in
*OWN001*)
echo "FAIL: a verdict was produced with a broken candidate — Python answered for Rust"
exit 1 ;;
esac
[ "$rc" -ne 0 ] || { echo "FAIL: a broken candidate exited 0"; exit 1; }
echo "OK: a broken candidate is a visible failure (exit $rc), never a Python rescue"
# P-014 Tier B: external-reference resolution. The SAME sample, run two ways, must give two
# verdicts — proving the extractor binds a THIRD-PARTY event only when its DLL is referenced:
# A (no refs) -> ObservableObject is an error type -> OWN050 (honest skip), no leak
# B (--ref-dir DLL) -> PropertyChanged binds to an IEventSymbol -> real OWN001 leak, no OWN050
# The package DLL is fetched from nuget (a .nupkg is a zip) and pinned to a version known to
# expose the event; Roslyn reads its metadata only (no build, no source, no .NET Framework needed).
tier-b-refs:
name: P-014 Tier B — external reference resolution (--ref-dir)
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: Materialize a third-party reference (CommunityToolkit.Mvvm 8.2.2, pinned)
run: |
mkdir -p "$RUNNER_TEMP/refdir"
curl -sSL --retry 3 --max-time 120 -o "$RUNNER_TEMP/ct.nupkg" \
"https://api.nuget.org/v3-flatcontainer/communitytoolkit.mvvm/8.2.2/communitytoolkit.mvvm.8.2.2.nupkg"
# a .nupkg is a zip; lift just the netstandard2.0 assembly into the ref dir
unzip -o -j "$RUNNER_TEMP/ct.nupkg" "lib/netstandard2.0/CommunityToolkit.Mvvm.dll" -d "$RUNNER_TEMP/refdir"
test -s "$RUNNER_TEMP/refdir/CommunityToolkit.Mvvm.dll" \
|| { echo "FAIL: could not materialize CommunityToolkit.Mvvm.dll"; exit 1; }
# pre-flight: confirm the fixture DLL actually exposes the event the A/B test binds to —
# read its .NET metadata in pure Python (no runtime). A clear "fixture rotted" failure
# beats a confusing "OWN001 not found" if the package ever drops/renames the member.
pip install --quiet dnfile
python - <<'PY'
import os, dnfile
pe = dnfile.dnPE(os.path.join(os.environ["RUNNER_TEMP"], "refdir", "CommunityToolkit.Mvvm.dll"))
ev = getattr(pe.net.mdtables, "Event", None)
events = {str(r.Name) for r in ev.rows} if ev else set()
types = {f"{r.TypeNamespace}.{r.TypeName}" for r in pe.net.mdtables.TypeDef.rows}
assert "PropertyChanged" in events, f"fixture DLL missing PropertyChanged event; has {sorted(events)}"
assert "CommunityToolkit.Mvvm.ComponentModel.ObservableObject" in types, "fixture DLL missing ObservableObject"
print("pre-flight OK: ObservableObject + PropertyChanged present in fixture metadata")
PY
- name: A — without the reference, the external event is OWN050 (honest skip), not a leak
run: |
dotnet run --project frontend/roslyn/OwnSharp.Extractor -- \
frontend/roslyn/samples/TierBSample.cs -o "$RUNNER_TEMP/a.json"
out=$(python -m ownlang ownir "$RUNNER_TEMP/a.json" || true)
echo "$out"
echo "$out" | grep -q "\[OWN050\]" \
|| { echo "FAIL(A): expected OWN050 — ObservableObject unresolved without --ref-dir"; exit 1; }
if echo "$out" | grep -q "\[OWN001\]"; then
echo "FAIL(A): must NOT guess a leak when the declaring type is unresolved"; exit 1
fi
- name: B — with --ref-dir, the event resolves to a real subscription leak (OWN001)
run: |
dotnet run --project frontend/roslyn/OwnSharp.Extractor -- \
frontend/roslyn/samples/TierBSample.cs --ref-dir "$RUNNER_TEMP/refdir" -o "$RUNNER_TEMP/b.json"
out=$(python -m ownlang ownir "$RUNNER_TEMP/b.json" || true)
echo "$out"
echo "$out" | grep -q "\[OWN001\]" \
|| { echo "FAIL(B): expected OWN001 — PropertyChanged resolved via --ref-dir"; exit 1; }
if echo "$out" | grep -q "\[OWN050\]"; then
echo "FAIL(B): the external event must RESOLVE, not stay OWN050"; exit 1
fi
echo "OK: Tier B A/B — external event OWN050 (no ref) -> OWN001 (with --ref-dir)"
# P-012 slice 1: score the checker against the labeled corpus on REAL C# — not
# just the .own reduction tests/test_corpus.py checks. Per case: the bug must be
# CAUGHT in before.cs (recall) and the fix must be SILENT in after.cs
# (specificity / no false alarm). A defensible, regression-pinned number — and
# the RLVR reward scaffold: a deterministic verifier over labeled real-C# data.
corpus-benchmark:
name: corpus benchmark (real C# recall + specificity)
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"
# Some corpus cases subscribe to framework events (WPF Window, Microsoft.Win32
# SystemEvents); the type-aware extractor needs those refs to bind a `+=` to an
# event (else an OWN050 note, not a leak). Materialize the WindowsDesktop ref
# pack and export OWN_EXTRA_REF_DIRS — same mechanism as the oracle/mine jobs.
# Harmless for the self-contained cases (deduped against the runtime TPA).
- name: Materialize framework reference assemblies
continue-on-error: true
run: |
tmp=$(mktemp -d)
printf '%s\n' \
'<Project Sdk="Microsoft.NET.Sdk">' \
' <PropertyGroup>' \
' <TargetFramework>net8.0-windows</TargetFramework>' \
' <UseWPF>true</UseWPF>' \
' <UseWindowsForms>true</UseWindowsForms>' \
' <EnableWindowsTargeting>true</EnableWindowsTargeting>' \
' </PropertyGroup>' \
'</Project>' > "$tmp/ref.csproj"
dotnet restore "$tmp/ref.csproj" >/dev/null 2>&1 || echo "ref restore failed (continuing)"
d=$(find "$HOME/.nuget/packages/microsoft.windowsdesktop.app.ref" -type d -name 'net8.0' 2>/dev/null | sort | tail -1 || true)
if [ -n "$d" ]; then
echo "OWN_EXTRA_REF_DIRS=$d" >> "$GITHUB_ENV"
echo "framework refs: $d ($(find "$d" -name '*.dll' | wc -l) dlls)"
else
echo "framework refs not found — own-check resolves runtime types only"
fi
- name: Score the corpus on real C#
# Precision is gated absolutely (every fix silent, zero false positives);
# recall is pinned at the measured floor (the --min-recall value below, bumped
# per ratchet) and climbs as the extractor improves — pooled buffers ride the
# path-sensitive flow engine
# (Rent/Return: OWN003/OWN002, pool resolved via the Roslyn SemanticModel so an
# ALIASED receiver is caught), factory acquires (System.IO.File.Open*/Create*) are
# recognised alongside `new`, and the inter-procedural CONSUME contract is modelled:
# a first-party method that owns a by-value IDisposable param — by disposing it
# directly OR by forwarding it to another first-party consumer (the TRANSITIVE chain,
# `ConsumesParam`) — is a handoff that releases the argument at the call site, so a use
# after the handoff trips OWN002 (the cut is the signature, like Rust's move). The BORROW
# checker covers both view kinds: a `Span`/`Memory` view of a pooled buffer (`buf.AsSpan()` /
# `buf.AsMemory()`) is a borrow lowered to a use of the OWNER (`ViewOwner`), so using it
# after `Return(buf)` trips OWN002 — including RETURNING a `Memory<T>` view (which, unlike a
# ref-struct `Span`, can ESCAPE the method), a dangling borrow handed to the caller. A
# view of a pooled buffer reaches past the rented length into the oversized tail -> OWN025
# (P-007 POOL005, the over-read): the unbounded `buf.AsSpan()` AND the `.Length` view spelling
# (`buf.AsSpan(0, buf.Length)`) — the `arraypool-fullspan-overread` / `arraypool-length-
# overread` cases (a write/wipe like `Array.Clear(buf, 0, buf.Length)` is not flagged).
# MemoryPool is tracked too: a `MemoryPool<T>.Rent` IMemoryOwner is released by Dispose, so its
# leak / double-dispose ride the flow (POOL001/003 — `memorypool-double-dispose` -> OWN003), and
# its `owner.Memory` / `owner.Memory.Span` view is a borrow lowered to a use of the OWNER
# (`ViewOwner`), so reading it after Dispose trips OWN002 (POOL002 — `memorypool-view-after-
# dispose`). Returning the BARE owner under `using` (`using owner = …; return owner;`) is the twin
# of the returned-view dangle: the using-owner stays tracked through the bare return (a non-using
# transfer does not) and its use is threaded after the scope-exit release -> OWN002 (`memorypool-
# using-owner-escape`). A FIELD-mediated cross-method use-after-dispose is caught too: an IDisposable
# field disposed in `Dispose()` and read in a live subscription-target handler (RHS of a `+=` / arg
# of a `.Subscribe(...)`, not torn down, no `if (_disposed) return;` guard) — DIRECTLY
# (`field-use-after-dispose`) or ONE hop down through a private helper (`handler-use-after-dispose`)
# — lowered to a synthetic acquire/release/use flow -> OWN002. That pass also covers POOLED owners:
# an `IMemoryOwner<T>` field released in `Dispose()` and a `Memory` VIEW field of it (`_view =
# _owner.Memory`) read in such a handler is the view-in-a-field dangle -> OWN002 (`pooled-view-after-
# dispose`). The POOL005 field pass now also catches a full-length view of an ArrayPool `byte[]`
# buffer FIELD read past its logical length -> OWN025 (`arraypool-field-fullspan-overread`).
# use, and an injected-source region-escape. The DI captive family also has its first real-world
# case now — a singleton injecting a scoped EF `DbContext` -> DI001 (`corpus/di/`, a benchmark-only
# corpus: DI has no `.own` form, so it is not scanned by the Python `test_corpus` runner).
# Remaining backlog: a full-length view STORED into another field, a TWO-plus-hop indirect field
# use, and an injected-source region-escape. A drop below the floor is a regression.
run: python scripts/benchmark.py --min-recall 25 --json "$RUNNER_TEMP/benchmark-scorecard.json"
- name: publish the scorecard artifact (numbers + corpus + revision + methodology, A1)
uses: actions/upload-artifact@v4
with:
name: benchmark-scorecard
path: ${{ runner.temp }}/benchmark-scorecard.json
retention-days: 90
# Alpha gate A (issue #202): the single delightful command, proven end-to-end
# on a clean runner — install -> check -> findings. Packaging only, no
# analysis-behaviour change: the underlying project stays OwnSharp.Cli
# internally (P-013; not mass-renamed), but the PUBLISHED identity is the
# Owen public facade (docs/notes/owen-public-facade.md) — package ID
# Owen.Cli, command `owen`. Bundles the *unmodified* extractor
# (ProjectReference; invoked as a child process, same shape own-check.sh
# already uses) and vendors the *unmodified* ownlang/ core, run by the
# machine's own Python. Both ubuntu AND windows matter here specifically
# (not just "more coverage") — a dotnet-tool shim is a native apphost on
# Windows and a shell script on Unix, so they exercise genuinely different
# process-launch mechanics; ubuntu-only would not prove the Windows path.
ownsharp-cli-smoke:
name: owen CLI (gate A) — clean install -> check -> findings
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, windows-latest]
runs-on: ${{ matrix.os }}
defaults:
run:
# bash (git-bash on Windows runners) so one script works on both legs;
# the thing under test is the owen/dotnet/python binaries, not the
# shell driving them.
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.11"
- name: Put the dotnet global-tools shim dir on PATH
run: echo "$HOME/.dotnet/tools" >> "$GITHUB_PATH"
# CI-only stand-in for "download the package from nuget.org" (not
# published there yet, see P-013's Non-goals) -- pack from the source
# this job already checked out. Deliberately OUTSIDE the timed window
# below: it is not part of the "install -> check" claim being proven.
- name: Pack Owen.Cli (pulls in the extractor via ProjectReference)
run: dotnet pack frontend/roslyn/OwnSharp.Cli/OwnSharp.Cli.csproj -c Release -o "$RUNNER_TEMP/owen-nupkg"
# --add-source alone is not enough (Codex review, PR #244): `dotnet tool
# install` queries every configured source (nuget.org included) IN
# PARALLEL and takes whichever answers first — once a same-numbered
# version is ever actually on nuget.org, this install could silently
# resolve from there instead of the just-packed artifact. An isolated
# nuget.config with <clear/> removes the ambiguity.
- name: Isolated NuGet.config — the packed artifact is the ONLY visible source
run: |
cat > "$RUNNER_TEMP/isolated-nuget.config" <<EOF
<?xml version="1.0" encoding="utf-8"?>
<configuration>
<packageSources>
<clear />
<add key="local-artifact" value="$RUNNER_TEMP/owen-nupkg" />
</packageSources>
</configuration>
EOF
- name: Seed a leak sample, a clean sample, and an unsupported-input sample -- all OUTSIDE the repo
# Proves the tool needs nothing but itself + Python -- not the Own.NET
# checkout (a real end user obviously won't have this repo on disk).
run: |
mkdir -p "$RUNNER_TEMP/owen-sample" "$RUNNER_TEMP/owen-clean" "$RUNNER_TEMP/owen-unsupported"
cat > "$RUNNER_TEMP/owen-sample/Leak.cs" <<'EOF'
using System.IO;
public class Leaky
{
public void Run()
{
var s = new MemoryStream();
s.WriteByte(1);
}
}
EOF
cat > "$RUNNER_TEMP/owen-clean/Clean.cs" <<'EOF'
using System.IO;
public class Tidy
{
public void Run()
{
using var s = new MemoryStream();
s.WriteByte(1);
}
}
EOF
cat > "$RUNNER_TEMP/owen-unsupported/app.ts" <<'EOF'
console.log("not C# -- the .NET/C# frontend is the only one wired in today");
EOF
- name: Start the clean-machine timer (install -> check -> findings)
run: echo "SMOKE_START=$(date +%s)" >> "$GITHUB_ENV"
- name: dotnet tool install --global (the one install the user runs) -- pins the package ID
run: dotnet tool install --global Owen.Cli --version 0.1.0 --configfile "$RUNNER_TEMP/isolated-nuget.config"
- name: owen --help -- language-neutral product framing, explicit included-frontend list, no TypeScript claim
run: |
out=$(owen --help)
echo "$out"
echo "$out" | grep -qi "^owen " || { echo "FAIL: --help should open with the owen product line"; exit 1; }
echo "$out" | grep -q "Included frontend" || { echo "FAIL: --help should list the included frontend explicitly"; exit 1; }
echo "$out" | grep -q '\.cs, \.csproj, \.sln' || { echo "FAIL: --help should name the .NET/C# frontend's extensions"; exit 1; }
if echo "$out" | grep -qi "typescript"; then
echo "FAIL: --help must not claim TypeScript support before it's actually wired in"; exit 1
fi
- name: owen --version -- pins the command name via PATH resolution alone
run: |
out=$(owen --version)
[ -n "$out" ] || { echo "FAIL: --version printed nothing"; exit 1; }
echo "OK: owen --version -> $out"
- name: "owen <unknown command> -- prefix is owen:, not the pre-rebrand ownsharp:"
run: |
set +e
out=$(owen bogus-command 2>&1)
rc=$?
set -e
echo "$out"
[ "$rc" -ge 2 ] || { echo "FAIL: expected a non-zero exit for an unknown command, got $rc"; exit 1; }
echo "$out" | grep -q "^owen: unknown command" || { echo "FAIL: expected an 'owen: unknown command' prefix"; exit 1; }
- name: "owen check <unknown option> is a usage error (exit 2), not a phantom path (A1)"
run: |
set +e
out=$(owen check --verbose . 2>&1)
rc=$?
set -e
echo "$out"
[ "$rc" -eq 2 ] || { echo "FAIL: expected exit 2 for an unknown option, got $rc"; exit 1; }
echo "$out" | grep -q "unknown option '--verbose'" || { echo "FAIL: expected an 'unknown option' message"; exit 1; }
echo "$out" | grep -q "does not exist" && { echo "FAIL: the typo was treated as a path"; exit 1; }
true
- name: "owen --help documents the exit-code contract incl. internal-error 5 (A1)"
run: |
out=$(owen --help)
echo "$out" | grep -q "Exit codes:" || { echo "FAIL: --help must document exit codes"; exit 1; }
echo "$out" | grep -q "5 internal error" || { echo "FAIL: --help must document exit 5"; exit 1; }
echo "$out" | grep -q -- "--debug" || { echo "FAIL: --help must document --debug"; exit 1; }
- name: owen check finds the leak (installed execution, outside any checkout)
run: |
set +e
out=$(owen check "$RUNNER_TEMP/owen-sample" --fail-on-finding 2>&1)
rc=$?
set -e
echo "$out"
[ "$rc" -eq 1 ] || { echo "FAIL: expected exit 1 (findings), got $rc"; exit 1; }
echo "$out" | grep -q "OWN001" || { echo "FAIL: expected OWN001 in the output"; exit 1; }
- name: owen check on clean code exits 0 (negative control)
run: |
set +e
out=$(owen check "$RUNNER_TEMP/owen-clean" --fail-on-finding 2>&1)
rc=$?
set -e
echo "$out"
[ "$rc" -eq 0 ] || { echo "FAIL: expected exit 0 on clean code, got $rc"; exit 1; }
- name: "flagship console repro: bad is OWN001, ok is clean (A2)"
run: |
set +e
out=$(owen check "$GITHUB_WORKSPACE/examples/flagship/console/bad" --fail-on-finding 2>&1)
rc=$?
set -e
echo "$out"
[ "$rc" -eq 1 ] || { echo "FAIL: flagship bad must exit 1 (findings), got $rc"; exit 1; }
echo "$out" | grep -q "OWN001" || { echo "FAIL: flagship bad must be flagged OWN001"; exit 1; }
set +e
out=$(owen check "$GITHUB_WORKSPACE/examples/flagship/console/ok" --fail-on-finding 2>&1)
rc=$?
set -e
echo "$out"
[ "$rc" -eq 0 ] || { echo "FAIL: flagship ok must scan clean (exit 0), got $rc"; exit 1; }
- name: "flagship WPF repro: bad is OWN001, ok is clean — on BOTH platforms (A2)"
run: |
# The WPF pair is analysed everywhere, not only on Windows: the
# subscription binds through System.ComponentModel and the release is
# recognised by teardown NAME (`OnClosed`), so the verdict does not
# depend on the WindowsDesktop reference pack. Only RUNNING the
# sample needs Windows — that is the step below.
set +e
out=$(owen check "$GITHUB_WORKSPACE/examples/flagship/wpf/bad" --fail-on-finding 2>&1)
rc=$?
set -e
echo "$out"
[ "$rc" -eq 1 ] || { echo "FAIL: flagship WPF bad must exit 1 (findings), got $rc"; exit 1; }
echo "$out" | grep -q "OWN001" || { echo "FAIL: flagship WPF bad must be flagged OWN001"; exit 1; }
echo "$out" | grep -q "DocumentWindow" || { echo "FAIL: the finding must name the leaking window"; exit 1; }
set +e
out=$(owen check "$GITHUB_WORKSPACE/examples/flagship/wpf/ok" --fail-on-finding 2>&1)
rc=$?
set -e
echo "$out"
[ "$rc" -eq 0 ] || { echo "FAIL: flagship WPF ok must scan clean (exit 0), got $rc"; exit 1; }
- name: "core internal crash surfaces as owen exit 5, politely — never a clean scan (A1)"
if: runner.os == 'Linux'
run: |
# A crash-injection python: forwards everything to the real python3
# (so PythonResolver's version probe passes) but crashes the core
# stage exactly like `ownlang.run()` reports an internal error.
# Pre-A1 a core crash exited 1 and, without --fail-on-finding, owen
# mapped it to a CLEAN 0. (Cache sabotage cannot simulate this: the
# content-addressed core cache self-heals — an earlier step pins that.)
cat > "$RUNNER_TEMP/crashing-python" <<'EOF'
#!/bin/sh
case "$*" in
*"-m ownlang"*)
if [ "$OWNLANG_DEBUG" = "1" ]; then
echo "Traceback (most recent call last):" >&2
echo " synthetic gate-A crash frame" >&2
else
echo "ownlang: internal error: RuntimeError: synthetic gate-A crash" >&2
fi
exit 70 ;;
*) exec python3 "$@" ;;
esac
EOF
chmod +x "$RUNNER_TEMP/crashing-python"
set +e
out=$(OWEN_PYTHON="$RUNNER_TEMP/crashing-python" owen check "$RUNNER_TEMP/owen-sample" 2>&1)
rc=$?
set -e
echo "$out"
[ "$rc" -eq 5 ] || { echo "FAIL: expected exit 5 for a core crash, got $rc"; exit 1; }
echo "$out" | grep -q "ownlang: internal error" || { echo "FAIL: expected the core's polite one-liner"; exit 1; }
echo "$out" | grep -q "This is a bug in owen" || { echo "FAIL: expected owen's polite framing"; exit 1; }
echo "$out" | grep -q "Diagnostic report" || { echo "FAIL: the core-crash path must write the diagnostic report (Codex P2)"; exit 1; }
echo "$out" | grep -q "Traceback (most recent call last)" && { echo "FAIL: raw traceback leaked without --debug"; exit 1; }
echo "$out" | grep -qE "^0 findings\.$" && { echo "FAIL: a core crash must never read as a clean scan"; exit 1; }
true
- name: "the same crash with --debug shows the full cause and still exits 5 (A1)"
if: runner.os == 'Linux'
run: |
set +e
out=$(OWEN_PYTHON="$RUNNER_TEMP/crashing-python" owen check --debug "$RUNNER_TEMP/owen-sample" 2>&1)
rc=$?
set -e
echo "$out"
[ "$rc" -eq 5 ] || { echo "FAIL: expected exit 5 in debug mode too, got $rc"; exit 1; }
echo "$out" | grep -q "Traceback (most recent call last)" || { echo "FAIL: --debug must surface the full cause"; exit 1; }
- name: "retention-path witness MVP builds and its usage surface is honest (A3)"
run: |
dotnet build "$GITHUB_WORKSPACE/audit/runtime/RetentionPath" -c Release -v quiet
set +e
out=$(dotnet "$GITHUB_WORKSPACE"/audit/runtime/RetentionPath/bin/Release/net8.0/RetentionPath.dll 2>&1)
rc=$?
set -e
echo "$out"
[ "$rc" -eq 2 ] || { echo "FAIL: bare usage must exit 2 (never clean), got $rc"; exit 1; }
echo "$out" | grep -q "RETAINED (root path shown) | OBSERVED_ONLY" || { echo "FAIL: usage must document the verdict vocabulary"; exit 1; }
set +e
out=$(dotnet "$GITHUB_WORKSPACE"/audit/runtime/RetentionPath/bin/Release/net8.0/RetentionPath.dll roots --pid 999999 --type X \
--out "$RUNNER_TEMP/nopid.json" 2>&1)
rc=$?
set -e
echo "$out"
[ "$rc" -eq 2 ] || { echo "FAIL: a failed attach must exit 2, never read as clean, got $rc"; exit 1; }
# Every platform, no target needed: a run that could not look records
# that it could not look. Absence of a file is not that statement —
# it is also "never invoked" and "artifact lost", so it means nothing.
cat "$RUNNER_TEMP/nopid.json"
python3 - "$RUNNER_TEMP/nopid.json" unreadable-target <<'PY'
import json, sys
doc = json.load(open(sys.argv[1], encoding="utf-8"))
ex = doc.get("execution") or {}
problems = []
if ex.get("state") != "not_evaluated":
problems.append(f"execution.state {ex.get('state')!r}, want 'not_evaluated'")
if (ex.get("reason") or {}).get("code") != sys.argv[2]:
problems.append(f"reason.code {(ex.get('reason') or {}).get('code')!r}, want {sys.argv[2]!r}")
if not (ex.get("reason") or {}).get("detail"):
problems.append("a not_evaluated record must carry a reason detail")
for key in ("verdict", "retained"):
if key in doc:
problems.append(f"a run that did not look must not record {key!r}")
for p in problems:
print(f"FAIL: {p}", file=sys.stderr)
sys.exit(1 if problems else 0)
PY
# A usage error is equally a state the record has to carry.
set +e
dotnet "$GITHUB_WORKSPACE"/audit/runtime/RetentionPath/bin/Release/net8.0/RetentionPath.dll roots \
--out "$RUNNER_TEMP/usage.json" > /dev/null 2>&1
rc=$?
set -e
[ "$rc" -eq 2 ] || { echo "FAIL: a usage error must exit 2, got $rc"; exit 1; }
python3 - "$RUNNER_TEMP/usage.json" usage-error <<'PY'
import json, sys
doc = json.load(open(sys.argv[1], encoding="utf-8"))
ex = doc.get("execution") or {}
problems = []
if ex.get("state") != "not_evaluated":
problems.append(f"execution.state {ex.get('state')!r}, want 'not_evaluated'")
if (ex.get("reason") or {}).get("code") != sys.argv[2]:
problems.append(f"reason.code {(ex.get('reason') or {}).get('code')!r}, want {sys.argv[2]!r}")
for key in ("verdict", "retained"):
if key in doc:
problems.append(f"a run that did not look must not record {key!r}")
for p in problems:
print(f"FAIL: {p}", file=sys.stderr)
sys.exit(1 if problems else 0)
PY
dotnet "$GITHUB_WORKSPACE"/audit/runtime/RetentionPath/bin/Release/net8.0/RetentionPath.dll selftest
- name: "WPF flagship on Windows: the witness names the window the hub is holding (A2/A3)"
if: runner.os == 'Windows'
run: |
# The one thing that genuinely needs Windows: RUN the WPF sample and
# attach the witness to it. Everything else about this pair (build,
# XAML, the static verdict) is proven on every platform above.
#
# The pid comes from the app's own hold line, not from `$!`: under
# git-bash `$!` is the shell's job, not the dotnet.exe underneath it.
# The hold is released by creating a stop file — a runner's stdin is
# not a console, so a ReadLine hold would fall straight through.
dotnet build "$GITHUB_WORKSPACE/examples/flagship/wpf/bad" -c Release -v quiet
APP="$GITHUB_WORKSPACE/examples/flagship/wpf/bad/bin/Release/net8.0-windows/BadDocumentWindows.dll"
WITNESS="$GITHUB_WORKSPACE/audit/runtime/RetentionPath/bin/Release/net8.0/RetentionPath.dll"
STOP="$RUNNER_TEMP/wpf-stop"
LOG="$RUNNER_TEMP/wpf-app.log"
rm -f "$STOP"
OWEN_FLAGSHIP_HOLD=1 OWEN_FLAGSHIP_STOP="$STOP" OWEN_FLAGSHIP_HOLD_SECONDS=240 \
dotnet "$APP" > "$LOG" 2>&1 &
for _ in $(seq 1 90); do grep -q "holding (pid" "$LOG" 2>/dev/null && break; sleep 1; done
cat "$LOG"
grep -q "holding (pid" "$LOG" || { echo "FAIL: the WPF sample never reached its hold point"; exit 1; }
grep -q "200 still subscribed" "$LOG" || { echo "FAIL: the sample must report 200 live subscriptions"; exit 1; }
PID=$(sed -n 's/.*holding (pid \([0-9]*\)).*/\1/p' "$LOG" | head -1)
set +e
dotnet "$WITNESS" roots --pid "$PID" --type Owen.Flagship.Wpf.DocumentWindow \
--out "$RUNNER_TEMP/wpf-runtime.json"
WRC=$?
set -e
touch "$STOP"
cat "$RUNNER_TEMP/wpf-runtime.json"
[ "$WRC" -eq 1 ] || { echo "FAIL: witness must exit 1 (RETAINED) on the bad WPF sample, got $WRC"; exit 1; }
# JSON is the artifact; grep is not a parser. Semantic anchors only —
# addresses and hop counts are not a contract.
python3 - "$RUNNER_TEMP/wpf-runtime.json" <<'PY'
import json, sys
doc = json.load(open(sys.argv[1], encoding="utf-8"))
problems = []
if doc.get("verdict") != "RETAINED":
problems.append(f"verdict {doc.get('verdict')!r}, want RETAINED")
roots = (doc.get("retained") or [{}])[0].get("roots") or []
if "static-event" not in {r.get("kind") for r in roots}:
problems.append(f"no static-event root (got {sorted({r.get('kind') for r in roots})})")
text = " ".join(" ".join(r.get("path", [])) + str(r.get("holder", "")) for r in roots)
for anchor in ("AppSettings", "PropertyChanged", "_invocationList", "DocumentWindow"):
if anchor not in text:
problems.append(f"retention path lacks the {anchor!r} anchor")
for p in problems:
print(f"FAIL: {p}", file=sys.stderr)
sys.exit(1 if problems else 0)
PY
- name: "WPF flagship on Windows: the fixed variant releases the subscription (A2)"
if: runner.os == 'Windows'
run: |
# Same user-level contract as the console pair: nothing DURABLY
# retained — exit 0, verdict ABSENT or OBSERVED_ONLY, zero durable
# roots. Which of the two verdicts appears is not pinned (that would
# over-specify a GC timing detail); on windows-latest the closed
# windows are collected outright, so it reads ABSENT.
#
# An earlier round asserted only "no static-event root", because the
# fixed sample appeared to keep 200 windows alive through a
# [gc-handle] path. That was this sample parking its UI thread while
# WPF was still tearing the windows down — a measurement artifact,
# not a framework fact — and the weak assertion was hiding it.
dotnet build "$GITHUB_WORKSPACE/examples/flagship/wpf/ok" -c Release -v quiet
APP="$GITHUB_WORKSPACE/examples/flagship/wpf/ok/bin/Release/net8.0-windows/OkDocumentWindows.dll"
WITNESS="$GITHUB_WORKSPACE/audit/runtime/RetentionPath/bin/Release/net8.0/RetentionPath.dll"
STOP="$RUNNER_TEMP/wpf-ok-stop"
LOG="$RUNNER_TEMP/wpf-ok-app.log"
rm -f "$STOP"
OWEN_FLAGSHIP_HOLD=1 OWEN_FLAGSHIP_STOP="$STOP" OWEN_FLAGSHIP_HOLD_SECONDS=240 \
dotnet "$APP" > "$LOG" 2>&1 &
for _ in $(seq 1 90); do grep -q "holding (pid" "$LOG" 2>/dev/null && break; sleep 1; done
cat "$LOG"
grep -q "0 still subscribed" "$LOG" || { echo "FAIL: the fixed sample must report 0 live subscriptions"; exit 1; }
PID=$(sed -n 's/.*holding (pid \([0-9]*\)).*/\1/p' "$LOG" | head -1)
set +e
dotnet "$WITNESS" roots --pid "$PID" --type Owen.Flagship.Wpf.DocumentWindow \
--out "$RUNNER_TEMP/wpf-ok-runtime.json"
WRC=$?
set -e
touch "$STOP"
[ "$WRC" -eq 0 ] || { echo "FAIL: witness must exit 0 (nothing durably retained) on the fixed sample, got $WRC"; exit 1; }
cat "$RUNNER_TEMP/wpf-ok-runtime.json"
python3 - "$RUNNER_TEMP/wpf-ok-runtime.json" <<'PY'
import collections, json, sys
doc = json.load(open(sys.argv[1], encoding="utf-8"))
roots = (doc.get("retained") or [{}])[0].get("roots") or []
kinds = collections.Counter(r.get("kind") for r in roots)
problems = []
if doc.get("verdict") not in ("ABSENT", "OBSERVED_ONLY"):
problems.append(f"verdict {doc.get('verdict')!r}, want ABSENT or OBSERVED_ONLY")
durable = [k for k in kinds if k not in ("stack", "finalizer")]
if durable:
problems.append(f"durable retainer(s) on the fixed sample: {durable}")
for p in problems:
print(f"FAIL: {p}", file=sys.stderr)
if problems:
sys.exit(1)
print(f"ok: nothing durably retains the window — verdict "
f"{doc.get('verdict')}, roots seen: {dict(kinds) or 'none'}")
PY
- name: "attach denied by kernel policy is an honest exit 2, never a clean verdict (A4)"
if: runner.os == 'Linux'
run: |
# The operational contract, asserted rather than described: with Yama
# at its default scope, attaching to a NON-DESCENDANT process is
# refused by the kernel. The witness must say so, name the policy, and
# exit 2 — the tier that means "I did not look", distinct from 0
# (looked, nothing retained) and 1 (looked, retention found). This is
# the failure the first CI round of this arc actually hit.
#
# Runs BEFORE the demo step relaxes the scope; the app is a sibling of
# the witness (both children of this shell), which is exactly the
# shape Yama scope 1 forbids.
sudo sysctl -w kernel.yama.ptrace_scope=1
dotnet build "$GITHUB_WORKSPACE/examples/flagship/console/bad" -c Release -v quiet
APP="$GITHUB_WORKSPACE/examples/flagship/console/bad/bin/Release/net8.0/BadDocumentApp.dll"
WITNESS="$GITHUB_WORKSPACE/audit/runtime/RetentionPath/bin/Release/net8.0/RetentionPath.dll"
STOP="$RUNNER_TEMP/denied-stop"; LOG="$RUNNER_TEMP/denied-app.log"
rm -f "$STOP"
OWEN_FLAGSHIP_HOLD=1 OWEN_FLAGSHIP_STOP="$STOP" OWEN_FLAGSHIP_HOLD_SECONDS=120 \
dotnet "$APP" > "$LOG" 2>&1 &
for _ in $(seq 1 60); do grep -q "holding (pid" "$LOG" 2>/dev/null && break; sleep 1; done
grep -q "holding (pid" "$LOG" || { cat "$LOG"; echo "FAIL: sample never held"; exit 1; }
PID=$(sed -n 's/.*holding (pid \([0-9]*\)).*/\1/p' "$LOG" | head -1)
set +e
out=$(dotnet "$WITNESS" roots --pid "$PID" --type Owen.Flagship.DocumentView \
--out "$RUNNER_TEMP/denied.json" 2>&1)
rc=$?
set -e
touch "$STOP"
echo "$out"
[ "$rc" -eq 2 ] || { echo "FAIL: a denied attach must exit 2, got $rc"; exit 1; }
echo "$out" | grep -q "ptrace_scope" \
|| { echo "FAIL: the diagnostic must name the policy that refused"; exit 1; }
echo "$out" | grep -q "NOT a verdict" \
|| { echo "FAIL: the diagnostic must say it did not look"; exit 1; }
# The exit code says "I did not look" for as long as the process lives;
# the record has to say it afterwards. An absent file cannot: it also
# means never invoked, runner died, or artifact lost in transit. So the
# refusal is RECORDED — while the verdict it never earned is not.
[ -s "$RUNNER_TEMP/denied.json" ] \
|| { echo "FAIL: a refused attach must still record that it did not look"; exit 1; }
cat "$RUNNER_TEMP/denied.json"
python3 - "$RUNNER_TEMP/denied.json" <<'PY'
import json, sys
doc = json.load(open(sys.argv[1], encoding="utf-8"))
ex = doc.get("execution") or {}
problems = []
if ex.get("state") != "not_evaluated":
problems.append(f"execution.state {ex.get('state')!r}, want 'not_evaluated'")
reason = ex.get("reason") or {}
if reason.get("code") != "refused-attach":
problems.append(f"reason.code {reason.get('code')!r}, want 'refused-attach'")
# A permission claim belongs to the one stage a permission check applies
# to. Anything later opened the target fine and must not cite a policy.
if reason.get("stage") != "open-target":
problems.append(f"reason.stage {reason.get('stage')!r}, want 'open-target'")
if "ptrace_scope" not in str(reason.get("policy_in_force", "")):
problems.append("reason.policy_in_force must name the policy that was in force, "
f"got {reason.get('policy_in_force')!r}")
# The half that must NOT come back: an unearned verdict, or an empty
# `retained` that reads downstream as "looked, found nothing".
for key in ("verdict", "retained"):
if key in doc:
problems.append(f"a refused attach must not record {key!r} (got {doc[key]!r})")
for p in problems:
print(f"FAIL: {p}", file=sys.stderr)
sys.exit(1 if problems else 0)
PY
# The other half, with the SAME restricting policy still in force: a
# failure that is not an attach must not borrow it. Yama being on is
# observable; Yama having caused the failure in hand is not, and the
# record may only say the first.
echo "not a dump" > "$RUNNER_TEMP/not-a-dump"
set +e
dotnet "$WITNESS" roots --dump "$RUNNER_TEMP/not-a-dump" --type X \
--out "$RUNNER_TEMP/dumpfail.json" > "$RUNNER_TEMP/dumpfail.log" 2>&1
drc=$?
set -e
cat "$RUNNER_TEMP/dumpfail.log"
[ "$drc" -eq 2 ] || { echo "FAIL: an unreadable dump must exit 2, got $drc"; exit 1; }
grep -q "ptrace_scope" "$RUNNER_TEMP/dumpfail.log" \
&& { echo "FAIL: a dump read must not lecture about ptrace"; exit 1; }
python3 - "$RUNNER_TEMP/dumpfail.json" <<'PY'
import json, sys
reason = ((json.load(open(sys.argv[1], encoding="utf-8")).get("execution") or {})
.get("reason") or {})
problems = []
if reason.get("code") != "unreadable-target":
problems.append(f"reason.code {reason.get('code')!r}, want 'unreadable-target'")
if "policy_in_force" in reason:
problems.append(f"a dump read cited a ptrace policy: {reason['policy_in_force']!r}")
for p in problems:
print(f"FAIL: {p}", file=sys.stderr)
sys.exit(1 if problems else 0)
PY
echo "OK: denied attach -> exit 2, policy named, refusal recorded, no verdict;"
echo "OK: a non-attach failure under the same policy does not borrow it"
- name: "flagship demo orchestrator end-to-end: bad DEMONSTRATED, ok VERIFIED (A3/A4)"
if: runner.os == 'Linux'
run: |
# The ONE reproducible demo entrypoint (scripts/flagship-demo.sh) is
# itself the CI proof: build -> hold the app -> attach the witness
# through its public CLI -> machine-validate JSON against the human
# verdict -> one stable summary line. bad must demonstrate the
# static-event retention; ok must verify no established retention
# (the loop-local stack root correctly reads OBSERVED_ONLY, not
# RETAINED — the verdict consults the classification).
#
# GitHub runners ship Yama ptrace_scope=1, which blocks same-user
# non-ancestor PTRACE_ATTACH — the ClrMD live attach needs classic
# scope. CI-runner-only relaxation; the demo script itself stays
# sudo-free (a real user attaching to their own app under scope 1
# gets the witness's polite exit-2, not silence).
sudo sysctl -w kernel.yama.ptrace_scope=0
./scripts/flagship-demo.sh bad
./scripts/flagship-demo.sh ok
- name: "a top-level .NET exception exits 5 in BOTH modes — debug changes volume, not semantics (A1 P2)"
if: runner.os == 'Linux'
run: |
# TMPDIR pointing at a directory that does not exist makes
# Path.GetTempFileName() throw before any inner catch — the honest
# top-level owen exception, unreachable via the child stages.
set +e
out=$(TMPDIR=/definitely/does/not/exist owen check "$RUNNER_TEMP/owen-sample" 2>&1)
rc=$?
set -e
echo "$out"
[ "$rc" -eq 5 ] || { echo "FAIL: expected exit 5 for a top-level exception, got $rc"; exit 1; }
echo "$out" | grep -q "owen: internal error" || { echo "FAIL: expected owen's polite framing"; exit 1; }
echo "$out" | grep -q "Unhandled exception" && { echo "FAIL: raw runtime crash banner leaked"; exit 1; }
echo "$out" | grep -q " at " && { echo "FAIL: stack trace leaked without --debug"; exit 1; }
set +e
out=$(TMPDIR=/definitely/does/not/exist owen check --debug "$RUNNER_TEMP/owen-sample" 2>&1)
rc=$?
set -e
echo "$out"
[ "$rc" -eq 5 ] || { echo "FAIL: --debug must keep exit 5 (a rethrow would exit a runtime-chosen code), got $rc"; exit 1; }
echo "$out" | grep -q " at " || { echo "FAIL: --debug must print the full .NET stack"; exit 1; }
- name: owen check --format sarif -- Owen-branded SARIF driver name
run: |
out=$(owen check "$RUNNER_TEMP/owen-sample" --format sarif)
echo "$out" | python -c "
import json, sys
d = json.load(sys.stdin)
n = d['runs'][0]['tool']['driver']['name']
assert n == 'Owen', f'SARIF driver name is {n!r}, expected Owen'
print('OK: SARIF driver name is Owen')
"
- name: owen check on unsupported input fails explicitly -- never a silent clean scan
run: |
set +e
out=$(owen check "$RUNNER_TEMP/owen-unsupported" 2>&1)
rc=$?
set -e
echo "$out"
[ "$rc" -eq 4 ] || { echo "FAIL: expected exit 4 (no supported input), got $rc"; exit 1; }
echo "$out" | grep -qi "no supported input" || { echo "FAIL: expected an explicit unsupported-input message"; exit 1; }
# The exact phrase a genuinely clean C# scan would print (own-check's
# core render, e.g. "0 findings.") must NOT appear here -- that would
# mean unsupported input silently looked like a successful clean scan.
if echo "$out" | grep -q "^0 findings\.$"; then
echo "FAIL: must not report a clean 0-findings scan for unsupported input"; exit 1
fi
- name: Stop the timer -- report it, and gate on a generous regression ceiling
# A hard ceiling, not a precision claim: CI timing varies with runner
# load, so this is a regression guard (catch "it now takes 20 minutes"),
# not a rubber stamp of the "~3 minutes" marketing number itself.
run: |
elapsed=$(( $(date +%s) - SMOKE_START ))
echo "install -> check -> findings: ${elapsed}s"
[ "$elapsed" -lt 240 ] || { echo "FAIL: took ${elapsed}s (ceiling 240s) — see alpha-readiness.md gate A"; exit 1; }
- name: No Python found via OWEN_PYTHON -> a fast, actionable failure (never an auto-download)
run: |
set +e
out=$(OWEN_PYTHON=/definitely/does/not/exist/python3 owen check "$RUNNER_TEMP/owen-sample" 2>&1)
rc=$?
set -e
echo "$out"
[ "$rc" -eq 3 ] || { echo "FAIL: expected exit 3 (Python not found), got $rc"; exit 1; }
echo "$out" | grep -q "OWEN_PYTHON" || { echo "FAIL: expected the OWEN_PYTHON-specific message"; exit 1; }
echo "$out" | grep -Eiq "winget|apt|brew|python.org" || { echo "FAIL: expected an actionable install hint"; exit 1; }
- name: Legacy OWN_PYTHON still works as a temporary fallback, with a deprecation note
run: |
own_python="$(command -v python3 || command -v python)"
[ -n "$own_python" ] || { echo "FAIL: could not find a python3/python on PATH to test the fallback with"; exit 1; }
set +e
out=$(OWN_PYTHON="$own_python" owen check "$RUNNER_TEMP/owen-sample" --fail-on-finding 2>&1)
rc=$?
set -e
echo "$out"
[ "$rc" -eq 1 ] || { echo "FAIL: expected exit 1 (findings, via the legacy var), got $rc"; exit 1; }
echo "$out" | grep -qi "OWN_PYTHON is deprecated" || { echo "FAIL: expected a deprecation note when OWN_PYTHON is used"; exit 1; }
echo "$out" | grep -q "OWN001" || { echo "FAIL: expected OWN001 in the output even via the legacy var"; exit 1; }
- name: Seed a same-content legacy cache and confirm it is reused (no ~/.owen copy)
# Review, PR #246 -- the fallback must trust DESTINATION content, not a
# marker's say-so. This is the legitimate case: exact byte-for-byte
# match with the just-packed core, reused in place.
run: |
rm -rf "$HOME/.owen" "$HOME/.ownsharp"
mkdir -p "$HOME/.ownsharp/core/0.1.0/ownlang"
cp ownlang/*.py "$HOME/.ownsharp/core/0.1.0/ownlang/"
driver=$(owen check "$RUNNER_TEMP/owen-sample" --format sarif | python -c "import json,sys; print(json.load(sys.stdin)['runs'][0]['tool']['driver']['name'])")
[ "$driver" = "Owen" ] || { echo "FAIL: driver name '$driver' via the legacy cache"; exit 1; }
if [ -d "$HOME/.owen" ]; then
echo "FAIL: ~/.owen was created despite a legitimate matching legacy cache (fallback not reused in place)"; exit 1
fi
echo "OK: matching legacy cache reused in place, no unnecessary copy"
- name: Seed a legacy cache with an EXTRA (removed) file and confirm it is REJECTED
# The exact reproduction from review: an old cache holds a file the new
# source no longer has. A version-only marker would have missed this;
# the content fingerprint must not.
run: |
rm -rf "$HOME/.owen" "$HOME/.ownsharp"
mkdir -p "$HOME/.ownsharp/core/0.1.0/ownlang"
cp ownlang/*.py "$HOME/.ownsharp/core/0.1.0/ownlang/"
echo "# stale leftover module" > "$HOME/.ownsharp/core/0.1.0/ownlang/removed_module.py"
driver=$(owen check "$RUNNER_TEMP/owen-sample" --format sarif | python -c "import json,sys; print(json.load(sys.stdin)['runs'][0]['tool']['driver']['name'])")
[ "$driver" = "Owen" ] || { echo "FAIL: driver name '$driver'"; exit 1; }
[ -d "$HOME/.owen" ] || { echo "FAIL: expected a fresh ~/.owen unpack (extra-file legacy cache should have been rejected)"; exit 1; }
if find "$HOME/.owen" -name "removed_module.py" | grep -q .; then
echo "FAIL: the stale extra file leaked into the fresh cache"; exit 1
fi
echo "OK: extra-file legacy cache correctly rejected, fresh cache is clean"
- name: Seed a legacy cache with MODIFIED content (same filenames, different bytes) and confirm rejection
# Same version, same file SET, different CONTENT -- the same-version
# class of bug this whole fingerprint mechanism exists to catch.
run: |
rm -rf "$HOME/.owen" "$HOME/.ownsharp"
mkdir -p "$HOME/.ownsharp/core/0.1.0/ownlang"
cp ownlang/*.py "$HOME/.ownsharp/core/0.1.0/ownlang/"
echo "# tampered" >> "$HOME/.ownsharp/core/0.1.0/ownlang/ownir.py"
driver=$(owen check "$RUNNER_TEMP/owen-sample" --format sarif | python -c "import json,sys; print(json.load(sys.stdin)['runs'][0]['tool']['driver']['name'])")
[ "$driver" = "Owen" ] || { echo "FAIL: driver name '$driver'"; exit 1; }
[ -d "$HOME/.owen" ] || { echo "FAIL: expected a fresh ~/.owen unpack (modified-content legacy cache should have been rejected)"; exit 1; }
echo "OK: modified-content legacy cache correctly rejected"
- name: Directory containing only skipped files (bin/obj) -- exit 4, not a silent clean scan
run: |
rm -rf "$HOME/.owen" "$HOME/.ownsharp"
mkdir -p "$RUNNER_TEMP/owen-skip-only/bin" "$RUNNER_TEMP/owen-skip-only/obj"
cat > "$RUNNER_TEMP/owen-skip-only/bin/Ignored.cs" <<'EOF'
public class X { public void M() { var s = new System.IO.MemoryStream(); } }
EOF
cat > "$RUNNER_TEMP/owen-skip-only/obj/AlsoIgnored.cs" <<'EOF'
public class Y { public void M() { var s = new System.IO.MemoryStream(); } }
EOF
set +e
out=$(owen check "$RUNNER_TEMP/owen-skip-only" 2>&1)
rc=$?
set -e
echo "$out"
[ "$rc" -eq 4 ] || { echo "FAIL: expected exit 4 (bin/obj-only directory), got $rc"; exit 1; }
echo "$out" | grep -qi "no supported input" || { echo "FAIL: expected an explicit unsupported-input message"; exit 1; }
- name: Directory containing only generated files (*.g.cs) -- exit 4
run: |
mkdir -p "$RUNNER_TEMP/owen-generated-only"
cat > "$RUNNER_TEMP/owen-generated-only/Foo.g.cs" <<'EOF'
public class Gen { public void M() { var s = new System.IO.MemoryStream(); } }
EOF
set +e
out=$(owen check "$RUNNER_TEMP/owen-generated-only" 2>&1)
rc=$?
set -e
echo "$out"
[ "$rc" -eq 4 ] || { echo "FAIL: expected exit 4 (generated-only directory), got $rc"; exit 1; }
echo "$out" | grep -qi "no supported input" || { echo "FAIL: expected an explicit unsupported-input message"; exit 1; }
- name: Empty .csproj (no .cs files in its directory) -- exit 4
run: |
mkdir -p "$RUNNER_TEMP/owen-empty-proj"
cat > "$RUNNER_TEMP/owen-empty-proj/Empty.csproj" <<'EOF'
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup><TargetFramework>net8.0</TargetFramework></PropertyGroup>
</Project>
EOF
set +e
out=$(owen check "$RUNNER_TEMP/owen-empty-proj/Empty.csproj" 2>&1)
rc=$?
set -e
echo "$out"
[ "$rc" -eq 4 ] || { echo "FAIL: expected exit 4 (empty .csproj), got $rc"; exit 1; }
- name: .sln with no usable projects -- exit 4
run: |
mkdir -p "$RUNNER_TEMP/owen-empty-sln"
cat > "$RUNNER_TEMP/owen-empty-sln/Empty.sln" <<'EOF'
Microsoft Visual Studio Solution File, Format Version 12.00
# no Project( lines at all
EOF
set +e
out=$(owen check "$RUNNER_TEMP/owen-empty-sln/Empty.sln" 2>&1)
rc=$?
set -e
echo "$out"
[ "$rc" -eq 4 ] || { echo "FAIL: expected exit 4 (.sln with no projects), got $rc"; exit 1; }
- name: Skipped files plus one real source -- the real one is still found, exit 1
run: |
mkdir -p "$RUNNER_TEMP/owen-mixed-skip/bin" "$RUNNER_TEMP/owen-mixed-skip/src"
cat > "$RUNNER_TEMP/owen-mixed-skip/bin/Ignored.cs" <<'EOF'
public class X { public void M() { var s = new System.IO.MemoryStream(); } }
EOF
cat > "$RUNNER_TEMP/owen-mixed-skip/src/Real.cs" <<'EOF'
using System.IO;
public class Leaky { public void Run() { var s = new MemoryStream(); s.WriteByte(1); } }
EOF
set +e
out=$(owen check "$RUNNER_TEMP/owen-mixed-skip" --fail-on-finding 2>&1)
rc=$?
set -e
echo "$out"
[ "$rc" -eq 1 ] || { echo "FAIL: expected exit 1 (real source found despite bin/ noise), got $rc"; exit 1; }
echo "$out" | grep -q "OWN001" || { echo "FAIL: expected OWN001"; exit 1; }
- name: Inaccessible subtree plus one readable source -- tolerated, real source still found
# On these hosted Linux runners the job does not run as an all-powerful
# admin/root account, so chmod genuinely restricts access (unlike a
# root-based local sandbox, where this same scenario cannot be exercised,
# and unlike Windows runners, where chmod 000 does not reliably lock out
# the job's own account -- review, PR #246: restricted to Linux so the
# assertions below are actually exercising the locked path, not silently
# passing because both files got analyzed).
if: runner.os == 'Linux'
run: |
mkdir -p "$RUNNER_TEMP/owen-mixed-locked/locked" "$RUNNER_TEMP/owen-mixed-locked/readable"
cat > "$RUNNER_TEMP/owen-mixed-locked/locked/Secret.cs" <<'EOF'
public class X { public void M() { var s = new System.IO.MemoryStream(); } }
EOF
cat > "$RUNNER_TEMP/owen-mixed-locked/readable/Real.cs" <<'EOF'
using System.IO;
public class Leaky { public void Run() { var s = new MemoryStream(); s.WriteByte(1); } }
EOF
chmod 000 "$RUNNER_TEMP/owen-mixed-locked/locked"
set +e
out=$(owen check "$RUNNER_TEMP/owen-mixed-locked" --fail-on-finding 2>&1)
rc=$?
set -e
chmod 755 "$RUNNER_TEMP/owen-mixed-locked/locked"
echo "$out"
if echo "$out" | grep -qi "UnauthorizedAccess\|Unhandled exception"; then
echo "FAIL: crashed on the locked subdirectory instead of tolerating it"; exit 1
fi
[ "$rc" -eq 1 ] || { echo "FAIL: expected exit 1 (the readable source's leak), got $rc"; exit 1; }
echo "$out" | grep -q "Real.cs" || { echo "FAIL: readable source was not analyzed"; exit 1; }
if echo "$out" | grep -q "Secret.cs"; then
echo "FAIL: the inaccessible source was unexpectedly analyzed -- chmod 000 did not actually lock it out, so this run proves nothing"; exit 1
fi
- name: Uppercase extensions are accepted case-insensitively (Foo.CS)
run: |
mkdir -p "$RUNNER_TEMP/owen-uppercase"
cat > "$RUNNER_TEMP/owen-uppercase/Leak.CS" <<'EOF'
using System.IO;
public class Leaky { public void Run() { var s = new MemoryStream(); s.WriteByte(1); } }
EOF
set +e
out=$(owen check "$RUNNER_TEMP/owen-uppercase/Leak.CS" --fail-on-finding 2>&1)
rc=$?
set -e
echo "$out"
[ "$rc" -eq 1 ] || { echo "FAIL: expected exit 1 for uppercase .CS extension, got $rc"; exit 1; }
echo "$out" | grep -q "OWN001" || { echo "FAIL: expected OWN001"; exit 1; }
- name: A tampered CURRENT (fingerprint-named) cache is rejected and rebuilt
# Review, PR #246 round 4 -- the earlier fixes verified a LEGACY cache's
# actual content, but a hit at the current ~/.owen/core/<version>/<fingerprint>/
# path itself was still trusted on existence alone. Reproduction: run once
# to create it, then tamper the file content directly under that exact
# fingerprint-named path (not the legacy location) and add a stale extra
# file, then confirm the second run rejects it, rebuilds cleanly, and the
# stale file is gone from whatever cache directory actually got used.
run: |
rm -rf "$HOME/.owen" "$HOME/.ownsharp"
owen check "$RUNNER_TEMP/owen-sample" --fail-on-finding > /dev/null 2>&1 || true
cache_dir=$(find "$HOME/.owen/core" -mindepth 2 -maxdepth 2 -type d)
[ -n "$cache_dir" ] || { echo "FAIL: first run did not create a current-cache directory"; exit 1; }
echo "# tampered" >> "$cache_dir/ownlang/ownir.py"
echo "# stale leftover module" > "$cache_dir/ownlang/stale_module.py"
driver=$(owen check "$RUNNER_TEMP/owen-sample" --format sarif | python -c "import json,sys; print(json.load(sys.stdin)['runs'][0]['tool']['driver']['name'])")
[ "$driver" = "Owen" ] || { echo "FAIL: driver name '$driver' after rebuild"; exit 1; }
if find "$HOME/.owen" -name "stale_module.py" | grep -q .; then
echo "FAIL: the stale extra file survived under a used cache directory"; exit 1
fi
if grep -q "# tampered" "$cache_dir/ownlang/ownir.py" 2>/dev/null; then
echo "FAIL: the tampered file content is still being served from the original path"; exit 1
fi
echo "OK: tampered current-cache destination rejected and rebuilt clean"
- name: Clean up cache state left by the edge-case tests above
run: rm -rf "$HOME/.owen" "$HOME/.ownsharp"