From 03ae1f238e2296f5e68a7a479fa837a8ca2e6c91 Mon Sep 17 00:00:00 2001 From: Alexander Nachtmann Date: Tue, 13 Oct 2026 07:03:40 +0200 Subject: [PATCH] Release 3.0.0: explicit thresholds, complete CRAP scoring, deterministic file identity Three gate contracts change: check, test, the Fallout target and the badge action have no default threshold; the CRAP gate needs every method scored; a report with several source roots no longer triggers a disk probe, and unresolved files make every gate NODATA until --probe-sources or --source-root resolves them. --exclude-test-code joins --exclude-generated, and an active exclusion is listed on stderr or in JSON. Seven counting fixes: ParseAsync finishes method-only classes; CRAP rows stay per file and per overload; condition identity no longer depends on XML packaging; contradictory branch data warns instead of inflating rates; an empty class summary decides the file; and ./src/C.cs and src/C.cs are one file. Co-Authored-By: Claude Fable 5.1 --- .github/actions/badge/action.yml | 14 +- .github/actions/badge/badge.sh | 5 +- .github/tests/consumer_coverage.py | 8 +- .github/tests/readme_badge_coverage.py | 7 +- .github/workflows/nuget-publish.yml | 5 + CHANGELOG.md | 57 ++ Directory.Build.props | 12 +- README.md | 57 +- build/Build.cs | 7 +- docs/releases/3.0.0.md | 93 ++++ src/DotCov.Fallout/CoverageParameters.cs | 68 ++- src/DotCov.Fallout/ICoverageReport.cs | 59 ++- src/DotCov.Fallout/README.md | 18 +- src/DotCov.Tool/DotCovCli.Test.cs | 12 +- src/DotCov.Tool/DotCovCli.cs | 296 +++++++---- src/DotCov.Tool/README.md | 32 +- src/DotCov/CoberturaParser.Methods.cs | 38 +- src/DotCov/CoberturaParser.cs | 488 +++++++++++++----- src/DotCov/CoverageReport.cs | 165 ++++-- src/DotCov/CrapAnalysis.cs | 165 +++--- src/DotCov/FileIdentity.cs | 134 +++++ src/DotCov/Formatters/CrapFormatter.cs | 2 + src/DotCov/Formatters/JsonFormatter.cs | 21 + src/DotCov/MethodCoverageReport.cs | 38 +- src/DotCov/MethodIdentity.cs | 142 +++-- src/DotCov/PathIdentity.cs | 49 +- src/DotCov/README.md | 26 +- tests/AGENTS.md | 2 +- tests/DotCov.Tests/BadgeActionTests.cs | 22 +- tests/DotCov.Tests/CliBadgeTests.cs | 4 +- tests/DotCov.Tests/CliCrapTests.cs | 41 ++ tests/DotCov.Tests/CliTestCommandTests.cs | 18 +- tests/DotCov.Tests/CliTests.cs | 6 +- tests/DotCov.Tests/CliUsageProcessTests.cs | 6 +- .../DotCov.Tests/CoberturaParserAsyncTests.cs | 27 + tests/DotCov.Tests/CorePathIdentityTests.cs | 18 + tests/DotCov.Tests/CoverageParametersTests.cs | 76 ++- tests/DotCov.Tests/CoverageReportEdgeTests.cs | 10 +- tests/DotCov.Tests/CrapAnalysisEdgeTests.cs | 20 +- tests/DotCov.Tests/CrapAnalysisTests.cs | 28 +- tests/DotCov.Tests/CrapFoldingTests.cs | 193 +++++++ tests/DotCov.Tests/DotCovCliEdgeTests.cs | 9 +- .../DotCovCliTestCommandEdgeTests.cs | 2 +- tests/DotCov.Tests/ExcludeTestCodeTests.cs | 239 +++++++++ tests/DotCov.Tests/FalloutBuildTests.cs | 138 ++++- tests/DotCov.Tests/FileIdentityCliTests.cs | 190 +++++++ tests/DotCov.Tests/FileIdentityTests.cs | 308 +++++++++++ .../Infrastructure/DotcovProcess.cs | 40 ++ .../DotCov.Tests/Infrastructure/FakeDotnet.cs | 43 ++ .../DotCov.Tests/MethodCoverageParseTests.cs | 3 +- tests/DotCov.Tests/ParserIntegrityTests.cs | 349 +++++++++++++ tests/DotCov.Tests/ParserSourceTests.cs | 17 +- tests/DotCov.Tests/RepositoryBuildTests.cs | 18 +- tests/DotCov.Tests/ThresholdContractTests.cs | 151 ++++++ 54 files changed, 3448 insertions(+), 548 deletions(-) create mode 100644 docs/releases/3.0.0.md create mode 100644 src/DotCov/FileIdentity.cs create mode 100644 tests/DotCov.Tests/CrapFoldingTests.cs create mode 100644 tests/DotCov.Tests/ExcludeTestCodeTests.cs create mode 100644 tests/DotCov.Tests/FileIdentityCliTests.cs create mode 100644 tests/DotCov.Tests/FileIdentityTests.cs create mode 100644 tests/DotCov.Tests/Infrastructure/DotcovProcess.cs create mode 100644 tests/DotCov.Tests/Infrastructure/FakeDotnet.cs create mode 100644 tests/DotCov.Tests/ParserIntegrityTests.cs create mode 100644 tests/DotCov.Tests/ThresholdContractTests.cs diff --git a/.github/actions/badge/action.yml b/.github/actions/badge/action.yml index 6df2d8f..c6a6552 100644 --- a/.github/actions/badge/action.yml +++ b/.github/actions/badge/action.yml @@ -21,11 +21,15 @@ inputs: description: Report file name to scan a directory for ('filename' or '**/filename'). Empty uses dotcov's default. default: '' min-line: - description: Minimum line coverage percentage. - default: '80' + description: >- + Minimum line coverage percentage, 0 to 100. There is no default threshold: set min-line, + min-branch, or both; a dimension left empty is not checked. + default: '' min-branch: - description: Minimum branch coverage percentage; above 0 the badge shows the branch rate too. - default: '0' + description: >- + Minimum branch coverage percentage, 0 to 100; above 0 the badge shows the branch rate too. + Set min-line, min-branch, or both. + default: '' args: description: Further `dotcov check` arguments, split on whitespace, e.g. `--exclude-generated`. default: '' @@ -42,7 +46,7 @@ inputs: default: 'true' version: description: dotcov.tool version to install. - default: '2.0.0' + default: '3.0.0' command: description: >- Command that runs dotcov instead of installing it, split on whitespace, e.g. diff --git a/.github/actions/badge/badge.sh b/.github/actions/badge/badge.sh index b5c8cbe..335f28a 100755 --- a/.github/actions/badge/badge.sh +++ b/.github/actions/badge/badge.sh @@ -40,7 +40,10 @@ else dotcov=("$tools/dotcov") fi - args=(check "$DOTCOV_REPORTS" --min-line "$DOTCOV_MIN_LINE" --min-branch "$DOTCOV_MIN_BRANCH" --badge-dir "$badge_dir") + # Only the thresholds that were set: dotcov has no default, and rejects a gate without one. + args=(check "$DOTCOV_REPORTS" --badge-dir "$badge_dir") + if [ -n "$DOTCOV_MIN_LINE" ]; then args+=(--min-line "$DOTCOV_MIN_LINE"); fi + if [ -n "$DOTCOV_MIN_BRANCH" ]; then args+=(--min-branch "$DOTCOV_MIN_BRANCH"); fi if [ -n "$DOTCOV_PATTERN" ]; then args+=(--pattern "$DOTCOV_PATTERN"); fi if [ -n "${DOTCOV_ARGS//[[:space:]]/}" ]; then read -ra extra <<<"$DOTCOV_ARGS" diff --git a/.github/tests/consumer_coverage.py b/.github/tests/consumer_coverage.py index a453d62..6d865f3 100644 --- a/.github/tests/consumer_coverage.py +++ b/.github/tests/consumer_coverage.py @@ -76,8 +76,10 @@ def measure(args): files = {file_key(path, root): counts(value["summary"], args.language) for path, value in native["files"].items()} + # --probe-sources: the job reads the report where the sources are checked out, so each + # relative filename resolves against the root that holds it (dotcov's default is lexical). dotcov = ["dotnet", tool] - actual = json.loads(run([*dotcov, "report", report, "--format", "json"])) + actual = json.loads(run([*dotcov, "report", report, "--format", "json", "--probe-sources"])) (output / "dotcov.json").write_text(json.dumps(actual, indent=2), encoding="utf-8") summary = actual["summary"] observed = tuple(summary[key] for key in ("coveredLines", "totalLines", "coveredBranches", "totalBranches")) @@ -89,7 +91,7 @@ def measure(args): } assert actual_files == files, f"Per-file mismatch: native {files}; DotCov {actual_files}" # ItsDangerous's upstream reporting config also names tests as a source root. Every - # measured file exists under exactly one root, so DotCov attributes it without the + # measured file exists under exactly one root, so the disk probe attributes it without the # multi-root warning; the per-file comparison above proves that attribution. warnings = [warning["kind"] for warning in actual.get("warnings", [])] assert warnings == [], warnings @@ -98,7 +100,7 @@ def measure(args): assert 0.9 <= line_hit / line_total < 1 assert 0.9 <= branch_hit / branch_total < 1 for name, line, branch, code in [("pass", 90, 90, 0), ("line-fail", 100, 90, 1), ("branch-fail", 90, 100, 1)]: - text = run([*dotcov, "check", report, "--min-line", line, "--min-branch", branch, + text = run([*dotcov, "check", report, "--min-line", line, "--min-branch", branch, "--probe-sources", "--badge-dir", output / f"cli-{name}"], expected=code) assert ("PASS:" if code == 0 else "FAIL:") in text expected = {"counts": total, "files": len(files), "language": args.language} diff --git a/.github/tests/readme_badge_coverage.py b/.github/tests/readme_badge_coverage.py index 301456e..e731131 100644 --- a/.github/tests/readme_badge_coverage.py +++ b/.github/tests/readme_badge_coverage.py @@ -132,7 +132,8 @@ def measure(args): assert len(historical) == 14, "Missing upstream per-file evidence" assert files == historical, f"Fresh coverage differs from the published Python 3.12 run: {files}" - actual = json.loads(run(["dotnet", tool, "report", report, "--format", "json"])) + # --probe-sources: the report is read beside the checked-out sources (dotcov's default is lexical). + actual = json.loads(run(["dotnet", tool, "report", report, "--format", "json", "--probe-sources"])) (output / "dotcov.json").write_text(json.dumps(actual, indent=2), encoding="utf-8") actual_files = {file_key(file["path"], root): tuple(file[key] for key in ("linesHit", "linesTotal", "branchesHit", "branchesTotal")) for file in actual["files"]} @@ -164,13 +165,13 @@ def measure(args): ("line-fail", 100, minimum_branch, 1), ("branch-fail", minimum_line, 100, 1)]: text = run(["dotnet", tool, "check", report, "--min-line", line, "--min-branch", branch, - "--badge-dir", output / f"cli-{phase}"], expected=code) + "--probe-sources", "--badge-dir", output / f"cli-{phase}"], expected=code) assert ("PASS:" if code == 0 else "FAIL:") in text badge_details = {"badgeMessages": messages} else: assert Decimal(str(summary["lineRate"])) == percent text = run(["dotnet", tool, "check", report, "--min-line", percent, - "--min-branch", 100 if total[3] else 0, "--badge-dir", output / "cli-badge"]) + "--min-branch", 100 if total[3] else 0, "--probe-sources", "--badge-dir", output / "cli-badge"]) assert "PASS:" in text badge_details = {"badgeMessages": {"pass": f"{percent:.1f}%"}} expected = {"target": args.target, "commit": config["commit"], diff --git a/.github/workflows/nuget-publish.yml b/.github/workflows/nuget-publish.yml index 485182d..d6fd8d1 100644 --- a/.github/workflows/nuget-publish.yml +++ b/.github/workflows/nuget-publish.yml @@ -256,6 +256,7 @@ jobs: min-line: '90' min-branch: '90' command: dotnet src/DotCov.Tool/bin/Release/net10.0/DotCov.Tool.dll + args: --probe-sources - name: Publish a red badge when the real branch rate fails id: fail continue-on-error: true @@ -265,6 +266,7 @@ jobs: min-line: '90' min-branch: '100' command: dotnet src/DotCov.Tool/bin/Release/net10.0/DotCov.Tool.dll + args: --probe-sources - name: Verify badge values, action outputs and published files env: PASS_CODE: ${{ steps.pass.outputs.exit-code }} @@ -383,6 +385,7 @@ jobs: min-line: ${{ matrix.min-line }} min-branch: ${{ matrix.min-branch }} command: dotnet src/DotCov.Tool/bin/Release/net10.0/DotCov.Tool.dll + args: --probe-sources - name: Compare the action badge with the published upstream percentage env: BADGE_CODE: ${{ steps.badge.outputs.exit-code }} @@ -398,6 +401,7 @@ jobs: min-line: '100' min-branch: ${{ matrix.min-branch }} command: dotnet src/DotCov.Tool/bin/Release/net10.0/DotCov.Tool.dll + args: --probe-sources - name: Require the line gate failure and exact published badge if: matrix.target == 'peek-readable' env: @@ -415,6 +419,7 @@ jobs: min-line: ${{ matrix.min-line }} min-branch: '100' command: dotnet src/DotCov.Tool/bin/Release/net10.0/DotCov.Tool.dll + args: --probe-sources - name: Require the branch gate failure and exact published badge if: matrix.target == 'peek-readable' env: diff --git a/CHANGELOG.md b/CHANGELOG.md index 9883666..ae3f59d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -782,3 +782,60 @@ Verified: `DOTNET_SYSTEM_GLOBALIZATION_INVARIANT=1`. - `dotcov report TestResults --pattern "**/dotcov.coverage.cobertura.*.xml" --format json` on the coverlet.MTP output: 27 files, line 99.08%, branch 94.41% → badge `99.1%` brightgreen. + +## Task 20 — 2026-10-13 — 3.0.0: explicit thresholds, complete CRAP scoring, deterministic file identity + +Three gate contracts change and seven counting bugs are fixed, so every number a gate decides +on may move. Release notes: `docs/releases/3.0.0.md`. + +Changed (contracts): +- Thresholds are explicit. The `"80"` defaults are gone from `check`, `test` + (`TryGetThresholds`), the Fallout component (`CoverageParameters.DefaultMinLine`/ + `DefaultMinBranch` removed; `ICoverageReport.MinLine`/`MinBranch` are `string?`) and the badge + action (`min-line`/`min-branch` default empty; `badge.sh` passes only what was set). Neither + threshold → usage error before any report is read or `dotnet test` starts (Fallout: build + error naming both parameters); one → the other is disabled; both 0 → `Disabled`; outside + 0–100, NaN or infinity → exit 2. `report --threshold` and the repo wrapper's badge threshold + parse follow the same range; `--max-crap` keeps its own grammar (infinity disables). +- `CrapReport.Evaluate` needs complete scoring: any `Unscored` method → `NoData` naming the + count and `--metrics`; zero scored stays `NoData`; otherwise `Fail` above the limit, `Pass`. +- File identity is lexical by default (`FileIdentity.Lexical`): several roots and a relative + filename → first-root key, one `FileIdentityAmbiguous` warning per file naming the candidate + roots, `AmbiguousFiles` on `CoverageReport`/`MethodCoverageReport`/`CrapReport`, and `NoData` + from `CoverageReport.Evaluate` and `CrapReport.Evaluate`. 2.0's disk probe is opt-in + (`FileIdentity.ProbeSources`, `--probe-sources`, `--coverage-probe-sources-param`); an + explicit root (`FileIdentity.FromSourceRoot`, `--source-root`, `--coverage-source-root`) must + be declared, else `UndeclaredSourceRootException` → exit 2. The mode is a parser parameter + (`IdentityScope` per call), never process state; `ReportResolver` resolves report files only + and needs no mode. +- `--exclude-test-code` (`report`, `check`, `crap`, `snapshot`, `test`) and + `ExcludeTestCodeParam` select `ExclusionRules.TestCode` through `ExclusionRules.Excluded`; + `--keep` exempts from either exclusion. `CoverageReport.Exclude` and the new + `MethodCoverageReport.Exclude` (replacing `CrapAnalysis.ExcludeFiles`) record a + `FileExclusion`; stderr lists rules and files for table/md output, JSON carries `excluded` + and `exclusionRules`, the Fallout target logs them. + +Fixed (numbers): +- `ParseAsync` finishes method-only classes through the same `ClassContext.Complete` as the + sync walk (A1). +- CRAP rows key by file (A2) and synthesized material by its origin's method ordinal (A3, + `SyntheticOrigin`); a folded group attaches only to the one same-named method of its file and + type, and a method's own state machine never joins an entry with lines. +- Condition sets that differ between class blocks of one document poison the line like a merge + does (A4); per-`` detail must reproduce its own covered count, and `branch="true"` + without `condition-coverage` or an unparseable condition warns (A5). Detail that is not 2-way + shaped (switch tables, grcov's per-outcome counts) is left out silently, as before. +- An empty class-level `` element still decides the file's lines (A6). +- `PathIdentity.RemoveDotSegments` normalizes `./`, `/./` and `/.` in file names and roots alike; + `..` is kept (A7). + +Also: `dotcov --version` prints three components (`3.0.0`); `RejectUnusedArguments` split into +`UnknownOption`/`RejectInvalidValues` so the repo's own CRAP gate stays at or below 30; CI's +consumer and README-badge jobs pass `--probe-sources` (itsdangerous declares two roots); +`tests/AGENTS.md` names TUnit 1.74. + +Verified: +- `fallout Coverage`: 100% lines and branches; full suite green. Each A-fix's regression test + fails on `5a50ddf` and passes here; each policy has a contract test per command through the + CLI entry point and through the Fallout target (`FalloutBuildTests`, `RepositoryBuildTests`); + `dotcov test` contracts run against a stand-in dotnet (`FakeDotnet`). diff --git a/Directory.Build.props b/Directory.Build.props index 600aa1a..1d5b625 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -12,7 +12,7 @@ - 2.0.0 + 3.0.0 local $(VersionPrefix)-$(VersionSuffix) $(VersionPrefix) @@ -26,6 +26,16 @@ $(RepositoryUrl) coverage;cobertura;code-coverage;testing + + +DotCov 3.0.0 changes three gate contracts; every number a gate decides on may move. +1. Thresholds are explicit: check, test, the Fallout target and the badge action have no default --min-line 80. Pass --min-line, --min-branch, or both (0 to 100); without either the command exits 2 before reading a report. +2. The CRAP gate needs every method scored: one unscored method makes it NODATA (exit 3). Emitters without a usable complexity attribute need --metrics. +3. File identity is deterministic: several source roots no longer trigger a disk probe; unresolved files make check, crap and test NODATA until --probe-sources or --source-root. +Fixes: contradictory condition detail and empty class summaries no longer inflate branch and line rates; condition identity no longer depends on XML packaging; CRAP rows stay per file and per overload, raising diluted scores; ./src/C.cs and src/C.cs count once; ParseAsync finishes method-only classes. +New: --exclude-test-code. Full notes: https://github.com/ANcpLua/dotcov/blob/main/docs/releases/$(VersionPrefix).md +