Skip to content

test: mutation-harden LibFixedPointDecimalParse coverage - #22

Open
thedavidmeister wants to merge 4 commits into
mainfrom
2026-06-15-fixedpoint-coverage
Open

test: mutation-harden LibFixedPointDecimalParse coverage#22
thedavidmeister wants to merge 4 commits into
mainfrom
2026-06-15-fixedpoint-coverage

Conversation

@thedavidmeister

@thedavidmeister thedavidmeister commented Jun 15, 2026

Copy link
Copy Markdown
Contributor

What

Scoped adversarial-mutation-test coverage pass over the core string→fixed-point
parser LibFixedPointDecimalParse.decimalStringTofixedPoint. This was the only
module with a real coverage gap (97.5% lines / 88.9% branches; every other module
is at 100%).

Update (2026-07-04 rework note): per the human design ruling recorded in #24
— an EMPTY FRACTION IS INVALID — this PR no longer pins "1." as a valid empty
fraction. The parser fix for #24 (branch fix-24-trailing-decimal-revert,
PR #25) is merged into this branch so the parser fix and the hardened suite land
together: the decimal-point gate in src/lib/parse/LibFixedPointDecimalParse.sol
now requires at least one digit after the point, returning
ParseDecimalInvalidString.selector for a bare trailing point, and the "1."
acceptance pin is replaced with a revert pin on that exact selector. The two
fractional-overflow tests and the "1x"/"10x" decimal-point-gate tests are
kept as-is.

The discipline: for each behaviour, break the exact line and run the whole suite.
Mutants the existing tests already kill are credited (no new test). Only mutants
that survive the existing suite (real gaps) get a new discriminating test that
passes clean and fails under the mutation.

Group hardened

decimalStringTofixedPoint — 15 behaviours probed. The existing example + round-trip
tests already kill most mutants. Two genuine survivors were found and killed.

Mutation matrix (behaviour → mutation → killer)

Behaviour Mutation vs existing suite Disposition
Integer overflow guard !=== killed existing overflow test ✓
Decimal-point gate (L44) == 0false SURVIVED GAP → new testDecimalStringToFixedPointInvalidAfterInteger
Garbage-after-frac gate cursor<endfalse killed existing ✓
Strip lower-bound (L60) >=> fracStart survived (both) equivalent mutant — a '0' at fracStart contributes 0 whether stripped or parsed
Strip mask flag == 1== 0 killed existing ✓
Strip recombine +1+0 killed existing ✓
Strip init cursor-1cursor-0 killed existing ✓
Strip decrement --++ killed existing ✓
cursor > fracStart gate true killed existing ✓
Frac-error propagate (L71) (errorSelector,0)(0,0) SURVIVED GAP → new testDecimalStringToFixedPointFailureFractionalPartOverflow
Precision-loss boundary >18>19 killed existing precision-loss test ✓
Precision-loss boundary >18>=18 killed existing ✓
ooms = 18 - digits 17 - digits killed existing ✓
Frac-add overflow guard (L84) <<= survived (both) equivalent mutantscaledFrac is always strictly > 0 (frac > 0 after strip, frac*10**ooms < 2^256), so value == preValue is impossible
Frac-add overflow guard if (value<preValue)if (false) killed existing overflow test ✓

The two gaps filled

  • Fractional-part overflow (line 71 — previously the one uncovered line). A
    fraction with more than 77 digits overflows uint256 inside the inner
    unsafeDecimalStringToInt, before the digits > 18 precision-loss check is
    reached, and the resulting ParseDecimalOverflow selector is returned from the
    fractional error branch. No existing test passed an overflowing fraction. New
    test pins a 78-nine fraction → ParseDecimalOverflow.
  • Decimal-point gate (line 44). A non-point character directly after the
    integer with nothing after it ("1x") must be rejected as an invalid string.
    Bypassing the gate makes the parse wrongly succeed as 1e18. The pre-existing
    corrupt-integer tests only reached this code via inputs ("1a1.1") whose garbage
    is caught by a later gate, so they did not discriminate the decimal-point gate
    itself. New test pins "1x"/"10x"ParseDecimalInvalidString, plus "1."
    ParseDecimalInvalidString (a bare trailing point is a malformed empty
    fraction per the ruling in Parser accepts a bare trailing decimal point ('1.') as a valid empty fraction — ruled invalid #24; this pin was originally acceptance → 1e18 and
    was replaced per the rework note).

Parser fix carried from #24 (PR #25 merged in)

Rejected now (ParseDecimalInvalidString, selector 0x3e8e62d6):

  • "1.", "0.", "123.", "00.", "115792089237316195423570985008687907853269984665640564039457." — the whole category: any integer digits followed by a bare trailing point
  • "1.e5" — scientific notation is unsupported here, so nothing after the bare point can rescue the empty fraction
  • Fuzz: every non-overflowing integer with "." appended

Unchanged:

  • "1", "1.0", "0", "0.0", "123", "123.0", and the uint256-max string still parse to the same values as before (pinned)
  • "." and ".5" still reject with ParseEmptyDecimalString (empty integer part, rejected before the fraction is considered; their status is pinned, not changed)
  • Sweep for reliance on the permissive acceptance: the only occurrence in the repo was the "1." pin in this suite, replaced here. LibFixedPointDecimalFormat.fixedPointToDecimalString only emits a . when the fraction is nonzero with at least one nonzero digit after it, so the testStringRoundTripFuzz round-trip is unaffected.

This repo has no generated pointer/deploy-constant files and nothing deployed
(pure internal library), so no pin regeneration and no redeploy is required.

Remaining-gaps checklist

  • All 15 enumerated behaviours of decimalStringTofixedPoint probed.
  • Both genuine surviving mutants (decimal-point gate, frac-error propagation) killed.
  • Two residual survivors confirmed equivalent mutants (strip lower-bound >=/>, frac-add guard </<=) — no behaviour can distinguish them, no test added.
  • src/ change is exactly the single empty-fraction guard from the Parser accepts a bare trailing decimal point ('1.') as a valid empty fraction — ruled invalid #24 fix; full suite green (83 tests, was 78); forge fmt --check and forge lint clean.
  • Other modules (LibFixedPointDecimalScale, LibWillOverflow, LibFixedPointDecimalArithmeticOpenZeppelin, LibFixedPointDecimalFormat) are at 100% line/branch and heavily fuzzed against a slow reference implementation; not re-probed in this scoped pass (candidates for a follow-up scoped pass if desired).

Triage note (not a bug, not in this PR's scope to change)

A fraction of >77 digits reports ParseDecimalOverflow rather than
ParseDecimalPrecisionLoss, because the inner-parse overflow check runs before the
digits > 18 check. Both outcomes reject the (invalid) string; only the selector
differs from a "precision-loss-first" reading. The new test pins the current
(Overflow) behaviour.

Closes #24

QA

Test summary: merged head 83 passed / 0 failed; forge fmt --check and forge lint clean.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • Bug Fixes

    • Decimal parsing now rejects inputs with an empty fractional part, such as 1..
    • Invalid characters and fractional-part overflow are handled with the expected parse errors.
    • Scientific notation is explicitly unsupported.
  • Tests

    • Added coverage for invalid decimal formats, overflow cases, trailing decimal points, fuzzed inputs, and valid adjacent values.

Adversarial mutation pass over decimalStringTofixedPoint. Existing tests
already kill most mutants; two genuine surviving mutants were found and
killed with new discriminating tests:

- Fractional-part overflow propagation (line 71, previously uncovered): a
  >77-digit fraction overflows uint256 in the inner integer parse and the
  resulting ParseDecimalOverflow selector is returned from the fractional
  error branch. Pinned with a 78-nine fraction.
- Decimal-point gate (line 44): a non-point character directly after the
  integer with no further input ("1x") must be rejected as an invalid
  string. Without the gate the parse wrongly succeeds as 1e18. The
  pre-existing corrupt-integer tests only reached this path via inputs whose
  garbage is caught by a later gate, so they did not discriminate it.

Two remaining survivors are equivalent mutants (the strip-loop lower bound
>= -> > at fracStart, and the frac-add overflow guard < -> <=) that cannot
change any output; documented as such, no test added.

Tests only: src/ is unchanged. Adds an audit/mutation-test-scans.json scan
record for org-wide health tracking.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@thedavidmeister thedavidmeister self-assigned this Jun 15, 2026
@coderabbitai

coderabbitai Bot commented Jun 15, 2026

Copy link
Copy Markdown

Review Change Stack

Walkthrough

The decimal parser now rejects inputs such as "1." with ParseDecimalInvalidString. Documentation, regression tests, fuzz coverage, and mutation-test audit metadata were added.

Changes

Decimal parser validation

Layer / File(s) Summary
Reject empty fractional parts
src/lib/parse/LibFixedPointDecimalParse.sol
The parser requires at least one digit after a decimal point and rejects empty fractional parts.
Validate parser edge cases
test/src/lib/parse/LibFixedPointDecimalParse.decimalStringToFixedPoint.t.sol, audit/mutation-test-scans.json
Tests cover overflow, invalid characters, empty fractions, trailing-point fuzz cases, and valid integer and zero-fraction forms. Mutation-test scan metadata was recorded.

Estimated code review effort: 2 (Simple) | ~10 minutes

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly identifies mutation hardening for LibFixedPointDecimalParse coverage.
Linked Issues check ✅ Passed The parser rejects bare trailing decimal points with ParseDecimalInvalidString and adds targeted tests required by issue #24.
Out of Scope Changes check ✅ Passed The audit record, parser fix, and tests all support the stated mutation-hardening and issue #24 objectives.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch 2026-06-15-fixedpoint-coverage

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@thedavidmeister

Copy link
Copy Markdown
Contributor Author

Rework note (human reject, 2026-07-04): design ruling — an EMPTY FRACTION IS INVALID. The checkDecimalStringToFixedPoint("1.", 1e18) pin enshrines behavior ruled wrong; the parser itself must change (tracked with the ruling in the new parser issue: bare trailing point → ParseDecimalInvalidString). Rework: land the parser fix + this suite together — keep both overflow tests and the "1x"/"10x" gate tests as-is (unaffected), replace the "1." acceptance pin with a revert pin on the exact selector, and Closes the new issue.

…tring parse

An empty fraction is invalid per the org design ruling in #24: the
decimal point MUST be followed by at least one digit, so "1." returns
ParseDecimalInvalidString instead of parsing as 1e18. Pins the revert on
the exact selector for the whole category and pins adjacent valid forms
unchanged.

Closes #24

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@thedavidmeister

Copy link
Copy Markdown
Contributor Author

🤖 ai:producer
Producer note: executed the 2026-07-04 rework note — merged the #25 parser fix into this branch per the land-together instruction (fast-forward to 0e5cfe9; #25 was based on this branch's head, so its diff is fully absorbed and the two branches now point at the same commit), adapted the "1." pin to expect revert on ParseDecimalInvalidString (0x3e8e62d6), kept both fractional-overflow tests and the "1x"/"10x" decimal-point-gate tests as-is. Test evidence: full suite green on the merged head (83 passed / 0 failed), forge fmt --check and forge lint clean; mutation validation — deleting the empty-fraction guard from decimalStringTofixedPoint (i.e. restoring the pre-fix parser) fails exactly the 3 discriminating tests (testDecimalStringToFixedPointEmptyFraction, testDecimalStringToFixedPointEmptyFractionFuzz, testDecimalStringToFixedPointInvalidAfterInteger) with 0x00000000… != 0x3e8e62d6…, guard restored → 83/83 green. PR body updated: Closes #24, QA evidence block appended.

@thedavidmeister thedavidmeister added ai:needs-work AI vetter: needs rework (code issue) and removed human:needs-work Human maintainer: rework required (sacred) labels Jul 30, 2026

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@audit/mutation-test-scans.json`:
- Around line 3-6: Update the mutation-scan audit record for publishedTag v0.2.0
by rerunning the scan at the current HEAD, replacing the stale commit and
commitsAheadOfTag values with the resulting revision and count, and refreshing
the timestamp as appropriate. Preserve the existing audit record format and
scoped-file coverage.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 311d0caa-71cc-41fc-b5f0-773576e2c1da

📥 Commits

Reviewing files that changed from the base of the PR and between 154c471 and 18e9235.

📒 Files selected for processing (3)
  • audit/mutation-test-scans.json
  • src/lib/parse/LibFixedPointDecimalParse.sol
  • test/src/lib/parse/LibFixedPointDecimalParse.decimalStringToFixedPoint.t.sol

Comment on lines +3 to +6
"timestamp": "2026-06-15T00:00:00Z",
"commit": "dc85846152e2fd42f8f2c07feb72cc1abc708674",
"publishedTag": "v0.2.0",
"commitsAheadOfTag": 0,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/usr/bin/env bash
set -euo pipefail

recorded_commit="$(jq -r '.[0].commit' audit/mutation-test-scans.json)"
published_tag="$(jq -r '.[0].publishedTag' audit/mutation-test-scans.json)"
expected_ahead="$(jq -r '.[0].commitsAheadOfTag' audit/mutation-test-scans.json)"

tag_commit="$(git rev-parse "${published_tag}^{commit}")"
actual_ahead="$(git rev-list --count "${tag_commit}..${recorded_commit}")"

test "${tag_commit}" = "${recorded_commit}"
test "${actual_ahead}" -eq "${expected_ahead}"

git diff --quiet "${recorded_commit}" HEAD -- \
  src/lib/parse/LibFixedPointDecimalParse.sol \
  test/src/lib/parse/LibFixedPointDecimalParse.decimalStringToFixedPoint.t.sol

Repository: rainlanguage/rain.math.fixedpoint

Length of output: 371


🏁 Script executed:

#!/usr/bin/env bash
set -euo pipefail

printf '%s\n' '--- record ---'
cat -n audit/mutation-test-scans.json

printf '%s\n' '--- local refs and objects ---'
git show-ref --tags | tail -20 || true
git rev-parse --verify dc85846152e2fd42f8f2c07feb72cc1abc708674^{commit} 2>&1 || true
git rev-parse --verify HEAD
git diff --stat dc85846152e2fd42f8f2c07feb72cc1abc708674 HEAD -- \
  src/lib/parse/LibFixedPointDecimalParse.sol \
  test/src/lib/parse/LibFixedPointDecimalParse.decimalStringToFixedPoint.t.sol 2>&1 || true

printf '%s\n' '--- remote metadata ---'
git remote -v || true

Repository: rainlanguage/rain.math.fixedpoint

Length of output: 1291


🏁 Script executed:

#!/usr/bin/env bash
set -euo pipefail

repo='rainlanguage/rain.math.fixedpoint'
tag='v0.2.0'
sha='dc85846152e2fd42f8f2c07feb72cc1abc708674'

api() {
  curl --fail --silent --show-error \
    -H 'Accept: application/vnd.github+json' \
    "https://api.github.com/$1"
}

printf '%s\n' '--- tag ref ---'
api "repos/${repo}/git/ref/tags/${tag}" | jq '{ref, object}'

printf '%s\n' '--- recorded commit ---'
api "repos/${repo}/commits/${sha}" | jq '{sha, message: .commit.message, date: .commit.committer.date, parents: [.parents[].sha]}'

printf '%s\n' '--- tag commit list ---'
api "repos/${repo}/commits?sha=${tag}&per_page=100" |
  jq --arg sha "$sha" '[.[] | {sha, date: .commit.committer.date}] | {count: length, recorded: map(select(.sha == $sha))}'

printf '%s\n' '--- repository contents at recorded commit ---'
for path in \
  src/lib/parse/LibFixedPointDecimalParse.sol \
  test/src/lib/parse/LibFixedPointDecimalParse.decimalStringToFixedPoint.t.sol
do
  printf '%s\n' "PATH: $path"
  api "repos/${repo}/contents/${path}?ref=${sha}" |
    jq '{path, sha, size}'
done

Repository: rainlanguage/rain.math.fixedpoint

Length of output: 238


🏁 Script executed:

#!/usr/bin/env bash
set -euo pipefail

sha='dc85846152e2fd42f8f2c07feb72cc1abc708674'
tag='v0.2.0'

printf '%s\n' '--- all local tag refs ---'
git show-ref --tags --dereference || true

printf '%s\n' '--- recorded commit ---'
git show -s --format='commit=%H%nparents=%P%nauthor=%aI%ncommitter=%cI%ntitle=%s' "$sha"

printf '%s\n' '--- current commit ---'
git show -s --format='commit=%H%nparents=%P%nauthor=%aI%ncommitter=%cI%ntitle=%s' HEAD

printf '%s\n' '--- remote tag refs ---'
git ls-remote --tags origin "refs/tags/${tag}" "refs/tags/${tag}^{}" || true

printf '%s\n' '--- ancestry and distance ---'
if git cat-file -e "${sha}^{commit}" 2>/dev/null; then
  printf 'recorded_is_ancestor_of_HEAD=%s\n' \
    "$(git merge-base --is-ancestor "$sha" HEAD; printf '%s' "$?")"
  git rev-list --count "${sha}..HEAD"
fi

printf '%s\n' '--- scoped file blob IDs at scan and HEAD ---'
for path in \
  src/lib/parse/LibFixedPointDecimalParse.sol \
  test/src/lib/parse/LibFixedPointDecimalParse.decimalStringToFixedPoint.t.sol
do
  printf 'PATH %s\n' "$path"
  printf 'scan='
  git rev-parse "${sha}:${path}" 2>&1 || true
  printf 'head='
  git rev-parse "HEAD:${path}" 2>&1 || true
  git diff --quiet "$sha" HEAD -- "$path"; printf 'unchanged=%s\n' "$?"
done

Repository: rainlanguage/rain.math.fixedpoint

Length of output: 1198


🏁 Script executed:

#!/usr/bin/env bash
set -u

sha='dc85846152e2fd42f8f2c07feb72cc1abc708674'
tag_commit='a535add9a2d76dfc0464af0177dbe53ef13ed600'

printf '%s\n' '--- tag relation ---'
printf 'tag_commit=%s\n' "$tag_commit"
printf 'recorded_commit=%s\n' "$sha"
printf 'recorded_equals_tag=%s\n' "$([ "$tag_commit" = "$sha" ] && echo true || echo false)"
printf 'commits_ahead=%s\n' "$(git rev-list --count "${tag_commit}..${sha}")"

printf '%s\n' '--- scoped file comparison ---'
for path in \
  src/lib/parse/LibFixedPointDecimalParse.sol \
  test/src/lib/parse/LibFixedPointDecimalParse.decimalStringToFixedPoint.t.sol
do
  printf 'PATH %s\n' "$path"
  printf 'scan_blob='
  git rev-parse "${sha}:${path}" 2>&1 || true
  printf 'head_blob='
  git rev-parse "HEAD:${path}" 2>&1 || true
  if git diff --quiet "$sha" HEAD -- "$path"; then
    printf 'unchanged=true\n'
  else
    printf 'unchanged=false\n'
  fi
done

printf '%s\n' '--- scoped changes after recorded commit ---'
git diff --name-status "$sha" HEAD -- \
  src/lib/parse/LibFixedPointDecimalParse.sol \
  test/src/lib/parse/LibFixedPointDecimalParse.decimalStringToFixedPoint.t.sol

printf '%s\n' '--- remote tag resolution ---'
git ls-remote --tags origin 'refs/tags/v0.2.0' 'refs/tags/v0.2.0^{}'

Repository: rainlanguage/rain.math.fixedpoint

Length of output: 1060


Correct the mutation-scan audit record.

v0.2.0 resolves to a535add9a2d76dfc0464af0177dbe53ef13ed600. The recorded commit is two commits ahead, not zero. Both scoped files changed between the recorded commit and HEAD. Rerun the scan at the current revision and update the audit record.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@audit/mutation-test-scans.json` around lines 3 - 6, Update the mutation-scan
audit record for publishedTag v0.2.0 by rerunning the scan at the current HEAD,
replacing the stale commit and commitsAheadOfTag values with the resulting
revision and count, and refreshing the timestamp as appropriate. Preserve the
existing audit record format and scoped-file coverage.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ai:needs-work AI vetter: needs rework (code issue)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Parser accepts a bare trailing decimal point ('1.') as a valid empty fraction — ruled invalid

1 participant