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
14 changes: 9 additions & 5 deletions .github/actions/badge/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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: ''
Expand All @@ -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.
Expand Down
5 changes: 4 additions & 1 deletion .github/actions/badge/badge.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
8 changes: 5 additions & 3 deletions .github/tests/consumer_coverage.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"))
Expand All @@ -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
Expand All @@ -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}
Expand Down
7 changes: 4 additions & 3 deletions .github/tests/readme_badge_coverage.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"]}
Expand Down Expand Up @@ -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"],
Expand Down
5 changes: 5 additions & 0 deletions .github/workflows/nuget-publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 }}
Expand Down Expand Up @@ -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 }}
Expand All @@ -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:
Expand All @@ -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:
Expand Down
57 changes: 57 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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-`<line>` 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 `<lines>` 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`).
12 changes: 11 additions & 1 deletion Directory.Build.props
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@

<!-- Versioning: CI passes -p:Version=x.y.z -->
<PropertyGroup>
<VersionPrefix Condition="'$(VersionPrefix)' == ''">2.0.0</VersionPrefix>
<VersionPrefix Condition="'$(VersionPrefix)' == ''">3.0.0</VersionPrefix>
<VersionSuffix Condition="'$(VersionSuffix)' == ''">local</VersionSuffix>
<Version Condition="'$(VersionSuffix)' != ''">$(VersionPrefix)-$(VersionSuffix)</Version>
<Version Condition="'$(VersionSuffix)' == ''">$(VersionPrefix)</Version>
Expand All @@ -26,6 +26,16 @@
<PackageProjectUrl>$(RepositoryUrl)</PackageProjectUrl>
<!-- Common core; each packable project appends its own specifics via $(PackageTags). -->
<PackageTags>coverage;cobertura;code-coverage;testing</PackageTags>
<!-- The release's contract changes first, then the fixes with the direction each number moves;
docs/releases/$(VersionPrefix).md holds the full notes. -->
<PackageReleaseNotes>
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
</PackageReleaseNotes>
</PropertyGroup>

<!-- Package README: without PackageReadmeFile nuget.org renders only <Description> and shows a
Expand Down
Loading
Loading