From a7669e0f490ff0648aaa35f28619e05f4e21ca84 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 16 Jun 2026 16:20:41 +0000 Subject: [PATCH 1/2] =?UTF-8?q?feat:=20corpus=20miner=20=E2=80=94=20run=20?= =?UTF-8?q?own-check=20over=20public=20C#=20repos=20and=20aggregate=20a=20?= =?UTF-8?q?report?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Evaluation tooling for the analyser: clone one public C# repo (shallow) and run the existing own-check pipeline over it, then aggregate the findings into a structured Markdown report. Mining is cheap because the extractor needs no per-repo build (error-tolerant SemanticModel; unresolved external types become OWN050 "unchecked"), so we just point it at the .cs. - scripts/mine.sh: clone (--depth 1) + own-check + report; needs dotnet (or CI). - scripts/mine_report.py: dotnet-free aggregator (counts by code/severity/kind, noisiest files, candidate-leak triage, OWN050 coverage signal); --selftest. - .github/workflows/mine.yml: workflow_dispatch (inputs via env, no injection); report to the run summary + artifact, so any repo can be mined without local .NET. - corpus/targets.txt: seed list (Dapper, CsvHelper, Newtonsoft.Json, RestSharp). - .gitignore: corpus/mined/ (never commit third-party source; reduce real finds into corpus/real-world/ instead). - docs/notes/mining.md: usage, how to read the report, the triage loop, honest gaps. - own-check.sh: refresh the stale loop caveat (A1 + while/foreach landed). mine_report --selftest 7/7, ruff clean, suite unaffected (corpus 2/2). The real end-to-end mine runs in CI (dotnet); first target Dapper, once the workflow is on the default branch. https://claude.ai/code/session_01Rg8kSk1YT14x7A1vo5zgED --- .github/workflows/mine.yml | 69 ++++++++++++ .gitignore | 5 + corpus/targets.txt | 13 +++ docs/notes/mining.md | 65 +++++++++++ scripts/mine.sh | 90 +++++++++++++++ scripts/mine_report.py | 218 +++++++++++++++++++++++++++++++++++++ scripts/own-check.sh | 7 +- 7 files changed, 464 insertions(+), 3 deletions(-) create mode 100644 .github/workflows/mine.yml create mode 100644 corpus/targets.txt create mode 100644 docs/notes/mining.md create mode 100755 scripts/mine.sh create mode 100644 scripts/mine_report.py diff --git a/.github/workflows/mine.yml b/.github/workflows/mine.yml new file mode 100644 index 00000000..546d01c0 --- /dev/null +++ b/.github/workflows/mine.yml @@ -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=$(ls corpus/mined/*/report.md 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 diff --git a/.gitignore b/.gitignore index ab4f781d..94428ec2 100644 --- a/.gitignore +++ b/.gitignore @@ -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/ diff --git a/corpus/targets.txt b/corpus/targets.txt new file mode 100644 index 00000000..33892320 --- /dev/null +++ b/corpus/targets.txt @@ -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 diff --git a/docs/notes/mining.md b/docs/notes/mining.md new file mode 100644 index 00000000..abaf16e5 --- /dev/null +++ b/docs/notes/mining.md @@ -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: + +``` +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//` (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. diff --git a/scripts/mine.sh b/scripts/mine.sh new file mode 100755 index 00000000..5c4d782a --- /dev/null +++ b/scripts/mine.sh @@ -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 ] [--paths ] [--format human] +# [--out ] [--keep-src] +# +# Output goes to corpus/mined// (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 diff --git a/scripts/mine_report.py b/scripts/mine_report.py new file mode 100644 index 00000000..b6ca30f2 --- /dev/null +++ b/scripts/mine_report.py @@ -0,0 +1,218 @@ +#!/usr/bin/env python3 +""" +Mining report aggregator — corpus mining (see docs/notes/mining.md). + +Reads the human-format findings that `own-check` prints over a real C# repo and +turns them into a structured Markdown summary: counts by OWN code, severity +(errors = candidate leaks, warnings = advisory / OWN050 "unchecked"), resource +kind, the noisiest files, and a triage list of the error-severity findings to +eyeball. + +This is evaluation tooling for the analyser itself: a clean run is a precision +signal; a pile of OWN001s is either real bugs or a false-positive pattern to +harden; a high OWN050 count flags a coverage gap (unresolved external refs). + +dotnet-free: the extractor (own-check) runs upstream; this only reads its text. + +Usage: + own-check.sh --format human -- | mine_report.py --repo owner/name --commit SHA + mine_report.py findings.txt --repo owner/name --commit SHA [--json out.json] + mine_report.py --selftest +""" + +from __future__ import annotations + +import argparse +import json +import re +import sys +from collections import Counter +from datetime import UTC, datetime +from pathlib import Path +from typing import Any + + +def _load_titles() -> dict[str, str]: + """The OWN-code -> human title map, for labelling the report. Imported from the + core when this script runs inside the repo checkout (its parent dir is the repo + root); an empty map is a fine fallback when it is not importable.""" + sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + try: + from ownlang.diagnostics import TITLES + except Exception: + return {} + return dict(TITLES) + + +TITLES = _load_titles() + +# own-check human line: ":: : [] [resource: ]" +_LINE = re.compile( + r"^(?P.+?):(?P\d+): (?Perror|warning): " + r"\[(?P[A-Z]+\d+)\] (?P.*) \[resource: (?P[^\]]*)\]\s*$" +) +# the core's trailing chatter ("N findings.", "... ok — no ...") is not a finding. +_CHATTER = re.compile(r"\b\d+ (finding|error)s?\b|: ok | no ownership| no subscription") + + +def parse(text: str) -> tuple[list[dict[str, Any]], int]: + """Parse own-check human output into finding dicts; return (findings, unparsed). + Build chatter shouldn't reach here (own-check sends it to stderr), but if a + stray line does, count it rather than silently dropping it.""" + findings: list[dict[str, Any]] = [] + unparsed = 0 + for raw in text.splitlines(): + line = raw.rstrip() + if not line: + continue + m = _LINE.match(line) + if m is None: + if not _CHATTER.search(line): + unparsed += 1 + continue + findings.append({ + "file": m["file"], + "line": int(m["line"]), + "severity": m["sev"], + "code": m["code"], + "message": m["msg"], + "kind": m["kind"], + }) + return findings, unparsed + + +def aggregate(findings: list[dict[str, Any]]) -> dict[str, Any]: + """Counts the report is built from (also returned as JSON).""" + errors = [f for f in findings if f["severity"] == "error"] + advisories = [f for f in findings if f["severity"] != "error"] + return { + "total": len(findings), + "errors": len(errors), + "advisories": len(advisories), + "by_code": dict(Counter(f["code"] for f in findings).most_common()), + "by_kind": dict(Counter(f["kind"] for f in findings).most_common()), + "by_file": dict(Counter(f["file"] for f in findings).most_common()), + "files_with_findings": len({f["file"] for f in findings}), + } + + +def render_md(findings: list[dict[str, Any]], unparsed: int, repo: str, + commit: str, max_list: int = 60) -> str: + """Render the Markdown report (the human-facing miner output).""" + agg = aggregate(findings) + errors = [f for f in findings if f["severity"] == "error"] + own050 = agg["by_code"].get("OWN050", 0) + out: list[str] = [ + f"# Mining report — `{repo or '?'}`", + "", + f"- commit: `{commit or '?'}`", + f"- generated: {datetime.now(UTC).strftime('%Y-%m-%d %H:%M UTC')}", + f"- findings: **{agg['total']}** " + f"({agg['errors']} error / {agg['advisories']} advisory) " + f"across {agg['files_with_findings']} file(s)" + + (f"; {unparsed} unparsed line(s)" if unparsed else ""), + "", + ] + + if agg["total"] == 0: + out += ["**Clean** — no findings. (A clean run on real code is a precision " + "signal; pair it with the extractor's `--stats` coverage once that " + "lands to know how much was actually analysed vs honestly skipped.)", + ""] + return "\n".join(out) + + out += ["## By code", "", "| code | n | what |", "|---|---:|---|"] + for code, n in agg["by_code"].items(): + out.append(f"| {code} | {n} | {TITLES.get(code, '')} |") + out += ["", "## By resource kind", "", "| kind | n |", "|---|---:|"] + for kind, n in agg["by_kind"].items(): + out.append(f"| {kind} | {n} |") + + top_files = list(agg["by_file"].items())[:15] + out += ["", "## Noisiest files", "", "| file | n |", "|---|---:|"] + out += [f"| `{f}` | {n} |" for f, n in top_files] + + out += ["", f"## Candidate leaks — error severity ({len(errors)}) — review these", + ""] + shown = errors[:max_list] + out += [f"- `{f['file']}:{f['line']}` **[{f['code']}]** {f['message']}" + for f in shown] + if len(errors) > len(shown): + out.append(f"- … and {len(errors) - len(shown)} more (see findings.txt)") + + out += ["", "## Coverage signal", "", + f"- **OWN050** (unchecked — declaring type is an unresolved external " + f"reference): **{own050}**. These are honestly *not* analysed; resolving " + f"more references (or a deeper compile) would let the checker reach them.", + ""] + return "\n".join(out) + + +def main(argv: list[str] | None = None) -> int: + ap = argparse.ArgumentParser(description="Aggregate own-check findings into a " + "Markdown mining report.") + ap.add_argument("findings", nargs="?", + help="own-check human output file (default: stdin)") + ap.add_argument("--repo", default="", help="owner/repo (for the report header)") + ap.add_argument("--commit", default="", help="commit SHA (for the report header)") + ap.add_argument("--json", dest="json_out", default="", + help="also write the raw aggregates as JSON to this path") + ap.add_argument("--selftest", action="store_true", + help="run built-in parser/aggregator checks and exit") + args = ap.parse_args(argv) + + if args.selftest: + return _selftest() + + text = (open(args.findings, encoding="utf-8").read() if args.findings + else sys.stdin.read()) + findings, unparsed = parse(text) + if args.json_out: + with open(args.json_out, "w", encoding="utf-8") as f: + json.dump({"repo": args.repo, "commit": args.commit, + "unparsed": unparsed, "findings": findings, + **aggregate(findings)}, f, indent=2) + print(render_md(findings, unparsed, args.repo, args.commit)) + return 0 + + +def _selftest() -> int: + sample = ( + "src/A.cs:12: error: [OWN001] IDisposable local 'uow' is never disposed " + "(leak) [resource: disposable]\n" + "src/A.cs:40: error: [OWN001] IDisposable local 'y' is never disposed " + "(leak) [resource: disposable]\n" + "src/A.cs:30: error: [OWN002] IDisposable local 'x' is used after it is " + "disposed [resource: disposable]\n" + "src/B.cs:5: warning: [OWN050] cannot verify 'Foo.Bar' — its declaring type " + "is an unresolved reference (build the project or pass references); leakage " + "analysis skipped [resource: unresolved reference]\n" + "[dotnet build chatter that should be ignored if it leaks to stdout]\n" + "4 findings.\n" + ) + findings, unparsed = parse(sample) + agg = aggregate(findings) + fails: list[str] = [] + if len(findings) != 4: + fails.append(f"expected 4 findings, got {len(findings)}") + if unparsed != 1: + fails.append(f"expected 1 unparsed line, got {unparsed}") + if agg["by_code"] != {"OWN001": 2, "OWN002": 1, "OWN050": 1}: + fails.append(f"by_code wrong: {agg['by_code']}") + if (agg["errors"], agg["advisories"]) != (3, 1): + fails.append(f"severity split wrong: {agg['errors']}/{agg['advisories']}") + if agg["by_file"].get("src/A.cs") != 3: + fails.append(f"by_file wrong: {agg['by_file']}") + if "Mining report" not in render_md(findings, unparsed, "o/r", "abc123"): + fails.append("markdown render missing header") + # a clean run renders without crashing and says so. + if "Clean" not in render_md([], 0, "o/r", "abc123"): + fails.append("clean render missing 'Clean'") + for f in fails: + print(f"MINE SELFTEST FAIL: {f}") + print(f"mine_report selftest: {7 - len(fails)}/7 checks passed") + return 1 if fails else 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/own-check.sh b/scripts/own-check.sh index 55369c82..e51476ed 100755 --- a/scripts/own-check.sh +++ b/scripts/own-check.sh @@ -22,9 +22,10 @@ # # Local IDisposables are checked by default with the path-sensitive flow analysis # (--flow-locals): more precise (no Task/DataTable false positives; catches -# use-after-dispose / double-dispose / leak-on-a-path, any IDisposable type) but it -# honestly skips methods with loops / try until P-016 A1 lands. --legacy falls back -# to the broad, name-based flat detector. +# use-after-dispose / double-dispose / leak-on-a-path, any IDisposable type). +# Branches and while/foreach loops are analysed (P-016 A1); methods with a +# construct it can't model yet (for/do loops, try) are honestly skipped. --legacy +# falls back to the broad, name-based flat detector. # # Requirements: a .NET SDK (`dotnet`) and Python 3.11+ on PATH. From d66b5ff8f36e50a140b6f5270ff825593296e0ec Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 16 Jun 2026 16:28:12 +0000 Subject: [PATCH 2/2] chore: address CodeRabbit nitpicks on the miner (markdownlint + shellcheck) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - docs/notes/mining.md: tag the inputs code fence as `text` (markdownlint MD040). - .github/workflows/mine.yml: locate the report with `find` instead of `ls` (shellcheck SC2012 — robust to odd filenames). https://claude.ai/code/session_01Rg8kSk1YT14x7A1vo5zgED --- .github/workflows/mine.yml | 2 +- docs/notes/mining.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/mine.yml b/.github/workflows/mine.yml index 546d01c0..c3ca10ed 100644 --- a/.github/workflows/mine.yml +++ b/.github/workflows/mine.yml @@ -50,7 +50,7 @@ jobs: - name: Publish the report to the run summary if: always() run: | - report=$(ls corpus/mined/*/report.md 2>/dev/null | head -1 || true) + 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 diff --git a/docs/notes/mining.md b/docs/notes/mining.md index abaf16e5..0fefdd28 100644 --- a/docs/notes/mining.md +++ b/docs/notes/mining.md @@ -17,7 +17,7 @@ So mining needs **no per-repo build setup** — just point it at the `.cs`. 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) ```