Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 69 additions & 0 deletions .github/workflows/mine.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
name: mine (corpus)

# On-demand corpus mining: clone one public C# repo and run the Own.NET leak
# check over it, uploading a structured report. Evaluation tooling for the
# analyser — see docs/notes/mining.md. One repo per run (be a good citizen).
#
# Trigger from the Actions tab ("Run workflow") or the API. Inputs are passed to
# the miner via env (never interpolated into the shell) to avoid script injection.

on:
workflow_dispatch:
inputs:
repo:
description: "Target: owner/repo (e.g. DapperLib/Dapper) or a git URL"
required: true
ref:
description: "Branch / tag / sha to mine (optional, default: repo HEAD)"
required: false
default: ""
paths:
description: "Subdir of the target to scan (optional, default: whole repo)"
required: false
default: ""

permissions:
contents: read

jobs:
mine:
name: mine ${{ inputs.repo }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.13"
- uses: actions/setup-dotnet@v4
with:
dotnet-version: "8.0.x"
- name: Mine the target
env:
REPO: ${{ inputs.repo }}
REF: ${{ inputs.ref }}
PATHS: ${{ inputs.paths }}
run: |
args=()
[[ -n "$REF" ]] && args+=(--ref "$REF")
[[ -n "$PATHS" ]] && args+=(--paths "$PATHS")
scripts/mine.sh "${args[@]}" "$REPO"
- name: Publish the report to the run summary
if: always()
run: |
report=$(find corpus/mined -name report.md -type f 2>/dev/null | head -1 || true)
if [[ -n "$report" ]]; then
cat "$report" >> "$GITHUB_STEP_SUMMARY"
else
echo "no report produced (see the Mine step log)" >> "$GITHUB_STEP_SUMMARY"
fi
- name: Upload the report
if: always()
uses: actions/upload-artifact@v4
with:
name: mine-report
path: |
corpus/mined/*/report.md
corpus/mined/*/report.json
corpus/mined/*/findings.txt
corpus/mined/*/extract.log
if-no-files-found: warn
5 changes: 5 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,8 @@ __pycache__/
# .NET build output (golden_arraypool demo)
bin/
obj/

# Corpus mining: cloned third-party source + raw reports (scripts/mine.sh).
# Never commit other projects' code; promote interesting findings into
# corpus/real-world/ as minimal reduced cases instead.
corpus/mined/
13 changes: 13 additions & 0 deletions corpus/targets.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
# Seed targets for the miner — scripts/mine.sh / .github/workflows/mine.yml.
#
# Well-known, permissively-licensed C# repos with IDisposable-dense code
# (ADO.NET / streams / readers), good for stress-testing the leak detector on
# unseen shapes. Mine ONE at a time — shallow, read-only; this is a spot-check,
# not a crawler.
#
# One `owner/repo` per line; everything after `#` is a note. Verify the license
# before doing anything beyond local analysis (e.g. reporting bugs upstream).
DapperLib/Dapper # micro-ORM, ADO.NET (SqlConnection/IDataReader) — closest to GTM's domain
JoshClose/CsvHelper # TextReader/TextWriter dense
JamesNK/Newtonsoft.Json # JsonReader/Writer (IDisposable), streams
restsharp/RestSharp # HttpClient / streams
65 changes: 65 additions & 0 deletions docs/notes/mining.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
# Corpus mining — stress-testing the analyser on real repos

A spot-check harness: take a public C# repo, run the Own.NET leak check over it,
and aggregate the result into a structured report. The goal is **evaluating the
analyser**, not crawling GitHub — one repo at a time, shallow and read-only.

## Why it's cheap

The Roslyn extractor (P-014 Tier A) builds a best-effort `SemanticModel` from the
runtime's trusted-platform assemblies and is error-tolerant: it reads symbols
without a `dotnet restore`/build of the target, and external (NuGet) types it
can't resolve become an honest `OWN050` "unchecked" marker rather than a guess.
So mining needs **no per-repo build setup** — just point it at the `.cs`.

## Run it

In CI (no local .NET needed) — Actions tab → **mine (corpus)** → *Run workflow*,
or via the API; the report lands in the run summary and as an artifact:

```text
inputs: repo = DapperLib/Dapper ref = (optional) paths = (optional subdir)
```

Locally (needs `dotnet`, `git`, Python 3.11+):

```sh
scripts/mine.sh DapperLib/Dapper # whole repo
scripts/mine.sh --paths src JoshClose/CsvHelper # focus a subdir
```

Output → `corpus/mined/<slug>/` (gitignored): `findings.txt`, `extract.log`,
`report.md`, `report.json`. Seed targets live in `corpus/targets.txt`.

## What the report says — and how to read it

`scripts/mine_report.py` aggregates the findings into: counts by OWN code, the
error/advisory split, resource kinds, the noisiest files, and a triage list of
the error-severity findings.

- **A clean run is a signal, not a dud.** On well-disciplined code (lots of
`using`) zero findings is the *precision* result we want to see.
- **A pile of `OWN001`s** is either real leaks (reduce one to a minimal `.cs` and
add it to `corpus/real-world/` as a regression) **or** a false-positive pattern
— the cue to harden the extractor (a new exemption, better escape analysis, a
new lowering).
- **A high `OWN050` count** is a *coverage* gap: the declaring types are
unresolved external references. Not wrong, just not analysed.

## The loop

`mine → triage → (a) regressions in corpus/, (b) fixes in the extractor/exemptions`
→ repeat. The methodology matches the GTM triage (real leaks kept, dispose-optional
FPs exempted → 100% precision); mining stresses that on shapes we haven't seen.

## Honest gaps (v1)

- **No coverage/skip rate yet.** A method the extractor can't model (a `for`/`do`
loop, `try`, …) is silently absent from the facts, so the report can't say
"analysed N of M methods". Adding a `--stats` summary to the extractor is the
planned next step.
- **One target, by hand.** Auto-discovery (GitHub code search for IDisposable
patterns) is deliberately out of scope — keep it a deliberate spot-check.
- **Reporting upstream is a separate, manual step.** If a finding is a real bug
worth disclosing, do it deliberately (and check the license); the miner never
contacts the target project.
90 changes: 90 additions & 0 deletions scripts/mine.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
#!/usr/bin/env bash
#
# mine.sh — clone a public C# repo (shallow) and run own-check over it, writing a
# structured Markdown report. Evaluation tooling for the analyser itself: see
# docs/notes/mining.md. Mine ONE repo at a time — shallow, read-only; this is a
# spot-check, not a crawler. Be a good citizen.
#
# Usage:
# scripts/mine.sh [--ref <branch|tag|sha>] [--paths <subdir>] [--format human]
# [--out <dir>] [--keep-src] <owner/repo | git-url>
#
# Output goes to corpus/mined/<slug>/ (gitignored): findings.txt, extract.log,
# report.md, report.json (and src/ with --keep-src).
#
# Requires: git, a .NET SDK (dotnet), Python 3.11+.

set -euo pipefail

ref=""
subpaths=""
format="human"
outdir=""
keep_src=0
target=""

while [[ $# -gt 0 ]]; do
case "$1" in
--ref) [[ $# -ge 2 ]] || { echo "mine: --ref needs a value" >&2; exit 2; }; ref="$2"; shift 2 ;;
--paths) [[ $# -ge 2 ]] || { echo "mine: --paths needs a value" >&2; exit 2; }; subpaths="$2"; shift 2 ;;
--format) [[ $# -ge 2 ]] || { echo "mine: --format needs a value" >&2; exit 2; }; format="$2"; shift 2 ;;
--out) [[ $# -ge 2 ]] || { echo "mine: --out needs a value" >&2; exit 2; }; outdir="$2"; shift 2 ;;
--keep-src) keep_src=1; shift ;;
-h|--help) sed -n '2,19p' "$0"; exit 0 ;;
--) shift; [[ $# -gt 0 ]] && { target="$1"; shift; } ;;
*) target="$1"; shift ;;
esac
done

[[ -n "$target" ]] || { echo "mine: a target (owner/repo or git URL) is required" >&2; exit 2; }
command -v git >/dev/null || { echo "mine: git not found" >&2; exit 2; }
command -v python >/dev/null || { echo "mine: python not found" >&2; exit 2; }
if ! command -v dotnet >/dev/null; then
echo "mine: a .NET SDK (dotnet) is required to run the extractor." >&2
echo "mine: run this in CI via .github/workflows/mine.yml, or install the SDK." >&2
exit 2
fi

root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"

# owner/repo -> https URL; pass full git URLs through untouched.
case "$target" in
http://*|https://*|git@*) url="$target" ;;
*) url="/${target}.git" ;;
esac
slug="$(printf '%s' "$target" | sed -E 's#^https?://[^/]+/##; s#^git@[^:]+:##; s#\.git$##; s#[^A-Za-z0-9._-]#_#g')"
[[ -n "$outdir" ]] || outdir="$root/corpus/mined/$slug"

mkdir -p "$outdir"
src="$outdir/src"
rm -rf "$src"

echo "mine: $target -> $outdir" >&2
clone_args=(--quiet --depth 1)
[[ -n "$ref" ]] && clone_args+=(--branch "$ref")
git clone "${clone_args[@]}" "$url" "$src"
commit="$(git -C "$src" rev-parse HEAD)"

scan="$src"
[[ -n "$subpaths" ]] && scan="$src/$subpaths"
[[ -e "$scan" ]] || { echo "mine: scan path '$scan' does not exist in the repo" >&2; exit 2; }

echo "mine: scanning $scan (commit $commit)" >&2
# own-check sends host-parseable findings to stdout and dotnet/build chatter to
# stderr; keep them apart. Without --fail-on-finding it exits 0 even with leaks;
# rc>=2 is a hard error (bad facts) — note it but still report what we captured.
set +e
"$root/scripts/own-check.sh" --root "$root" --format "$format" -- "$scan" \
>"$outdir/findings.txt" 2>"$outdir/extract.log"
rc=$?
set -e
[[ "$rc" -ge 2 ]] && echo "mine: own-check hard error (rc=$rc); see $outdir/extract.log" >&2

python "$root/scripts/mine_report.py" "$outdir/findings.txt" \
--repo "$target" --commit "$commit" --json "$outdir/report.json" \
>"$outdir/report.md"

[[ "$keep_src" -eq 1 ]] || rm -rf "$src"

echo "mine: done -> $outdir/report.md" >&2
grep -E '^- findings:' "$outdir/report.md" || true
Loading
Loading