Contributing
Issues and pull requests are welcome. For a security report, follow SECURITY.md instead — please don't open a public issue for those.
Getting set up
dotnet build CodoMetis.ValueRanges.slnx
dotnet test
global.json pins the SDK to the line's major (10.0.x on main), so install a .NET 10 SDK; a
newer major on the machine is refused rather than silently used (branches and
releases).
Shipping projects live under src/, test projects under test/.
AGENTS.md is the architecture guide, with docs/architecture.md
and docs/testing.md behind it — worth reading before changing a range type, a
value set family, or the EF plugin.
The two integration projects (test/*IntegrationTests/) spin up a real PostgreSQL container via
Testcontainers. Without Docker they go inconclusive locally, so skip them if you need to:
dotnet test --filter "FullyQualifiedName!~IntegrationTests"
On CI they fail rather than skip. An unreachable container must not quietly retire the layer that is the authority on PostgreSQL semantics while the build reports green.
The container runs PostgreSQL 16, the oldest version 10.x supports. CI also runs both projects on 18. To run them on another version, name its image:
VALUERANGES_POSTGRES_IMAGE=postgres:18-alpine dotnet test test/CodoMetis.ValueRanges.EFCore.PostgreSQL.IntegrationTests
A server older than 16 fails every test that needs it, with the server's version in the message.
The EF packages are built against their floor (Npgsql EF 10.0.0, so EF Core 10.0.0), but a consumer's
application resolves the newest patch. CI therefore runs the four EF test projects twice: on the floor,
and with the test hosts floated to the newest published EF Core and Npgsql EF 10 patch while src/
stays on the floor (test/Directory.Build.targets). Both are required. To run the float locally, pass
the two versions, which CI keeps in the integration-legs matrix of .github/workflows/dotnet.yml:
dotnet test test/CodoMetis.ValueRanges.EFCore.PostgreSQL.Tests -p:EfFloat=10.0.14 -p:NpgsqlEfFloat=10.0.3
A floor-only CI missed #302 that way: an empty captured list failed on the server on every EF Core patch from 10.0.2 on.
The quality bar
This package's characteristic failure is not a crash. It is a value that looks right, compares
equal to itself, round-trips without error — and means something different from what was declared.
A boundary that is inclusive on one side of the database wire and exclusive on the other. A set
that does not contain the element it was built from. A JSON payload that reads back as default.
Nothing throws, and the caller gets an answer with a straight face.
Everything below exists because of that failure mode.
A bug fix needs a regression test, and the test must be proven to work. Write the test, watch
it fail, apply the fix, watch it pass — then revert the fix and confirm the test fails again. A
test that passes both with and without the fix is not a regression test. This has caught vacuous
tests in this repository more than once, including two written during the hardening pass that added
the convention suite. See .claude/skills/verify-the-guard.
For an oracle sweep the revert is kept as a seed patch under test/oracles/seeds/, so the proof is
repeated rather than remembered — see Oracle tests.
Changes to a range type, a value set family, the canonical form, a type mapping, or a translator
get a value-semantics review. The lens is written down in
.claude/skills/value-semantics-review: what the
value means on both sides of the wire, whether every construction path canonicalizes, whether a
probe is comparable with what was stored, and whether the ordering the array was sorted by is the
ordering it is searched with.
PostgreSQL is the specification. Where the in-memory algebra and PostgreSQL could disagree, the
integration suite decides — it has already caught the discrete upper() canonicalization offset
and the directional multirange adjacency rule. A translation change without an integration test is
an assertion about PostgreSQL, not a demonstration.
Prefer a loud failure to a silent one. Where the package cannot represent something correctly,
it should throw with an actionable message — never return a plausible value that is wrong. A query
that refuses to translate beats one that translates to different semantics: Count over a Union
is deliberately refused for exactly this reason.
Prefer a test to a convention. Mechanical rules belong in the suite, not in a document nobody
re-reads. test/CodoMetis.ValueRanges.Conventions.Tests exists because the alternative was
remembering — and the value set rules in AGENTS.md are asserted there behaviourally, not merely
written down.
Issues
Use the Bug or Enhancement form. The bug form asks whether the failure is silent or loud, because that decides how the fix ships. A wrong answer without an exception found before 10.0.0 ships is fixed in 10.0.0, even if it's old. After that it ships as a minor on every supported line, under "Changed answers" (below). A loud failure can wait for a minor either way.
Releases and longer themes are tracked as epic issues, with the work as sub-issues. The pinned
Roadmap lists them all,
along with what each label means. A maintainer sets the package/*, area/* and severity/*
labels when triaging, and a pull request gets the same labels as the issue it closes.
Pull requests
- One concern per PR, with the reasoning in the description rather than only the diff.
- Full suite green, including integration if you have Docker.
- User-visible changes get a changelog entry in the root CHANGELOG.md and in the
affected package changelogs under
src/, in the form below. The convention tests enforce that the version being shipped is documented everywhere it needs to be. - New range or set types must be wired through the registries — the parity tests will tell you if they are not.
- Match the surrounding style. Comments explain why, especially where behaviour is load-bearing and non-obvious.
Branches and releases
Up to two lines are supported at once, one per .NET major: 10.x, the LTS line on .NET 10, and
11.x on .NET 11 (SECURITY.md lists what each line gets). main
is the next release: 10.0.0 until its tag, the 11 line after it. Each line has one branch, one Version in its
Directory.Build.props, and the same CI and protection, and a release is a v* tag on the branch
it ships from. The tag must match that Version, or the release workflow stops before publishing.
Until the v10.0.0 tag
mainis 10.0.0, the last release made under the old rule: it takes the silent fixes as a major.release/9.xholds 9.x patches and ends at thev10.0.0tag. A change for both goes intomainfirst; once it has merged, cherry-pick it onto a branch fromrelease/9.x(git cherry-pick -x, so the commit names the original) and open a PR intorelease/9.xthat links themainPR. Only loud fixes and additions go to 9.x. A silent fix waits for 10.0.0.- There is no preview branch. The .NET 11 retarget waits for the tag, so 11.0.0 is validated against a 10.0.0 that is on NuGet and starts with every 10.0.0 fix.
At the v10.0.0 tag
Cut release/10.x from the tag. Then retarget main to .NET 11 (#103) in a PR that also moves
Version to 11.0.0-preview.1 and the ApiCompat baseline and SECURITY.md row to 10.0.0, and tag
the previews v11.0.0-preview.N on main. release/9.x takes no changes from then on, and a fix
reaches 9.x users only by upgrading.
After the v10.0.0 tag
mainis the 11 line: previews until .NET 11 is released, thenv11.0.0and its minors.release/10.xis the LTS line. A change for both goes intomainfirst and is cherry-picked with-xinto a PR againstrelease/10.xthat links themainPR. A change for 10.x only, such as its version bump or a fix to codemainno longer has, goes straight intorelease/10.x.- Additions ship as 10.x minors for six months after
v11.0.0, and always reachmainfirst, so the LTS line never carries something the current line lacks. The cut-off date is written here whenv11.0.0is tagged (the tag date plus six months). After it, 10.x takes fixes only: loud ones in a patch or a minor, silent ones in a minor.
A fix that changes an answer
A silent fix is one where a call returned a wrong value without throwing, and the fix changes what it returns. With two lines, a major means a new .NET release and nothing else (11.0.0 is the first), so a silent fix has no major to wait for on the LTS line. After 10.0.0 it ships as a minor on every supported line, never a patch, and that minor says so:
- Its changelog entry opens with
### Changed answers, one row per changed answer, each linking its issue and its section of docs/migration.md. - docs/migration.md gets a
## Changed answers in x.y.0section for that minor, saying how to find the calls whose answer changed.
10.0.0 is the last release under the old rule, and main keeps it until its tag: a silent fix is
listed under Breaking in the major, and no 9.x minor carries one. ChangelogConventionTests holds the form: Changed answers
appears only in a minor, each row links an issue and an existing migration heading, and after
10.0.0 a change that says it used to produce a value ("where it returned …") must sit there. That
test reads the wording, not the label. The label half is .github/scripts/pr-hygiene.sh: a PR
labelled silent into a minor fails until its closing issue has a row there, and into a patch it
fails outright.
Releases on two lines
- Issues:
Closes #ncloses an issue only when the PR merges into the default branch,main. An issue fixed only on a release branch is closed by hand, with a comment linking the PR. - Changelog: a change goes under the version it ships in on each branch, never under the next
major too.
main's changelog keeps every release's section, of both lines, so it stays the full history; a cherry-pick brings its 10.x entry with it. A preview has its own## [11.0.0-preview.N]section, which the release workflow publishes as its notes. - SECURITY.md's supported-versions table names the latest release of each line on NuGet,
never the version being prepared, and has no row for a preview.
SecurityPolicyConventionTestscompares this branch's row withPackageValidationBaselineVersion, so move the row (and the walkthrough sample, below) when you move the baseline, which happens when you open the next version right after a tag; the tagged commit still names the release before it. The other line's row is compared withCompanionLineReleaseinDirectory.Build.props, which is empty until 11.0.0 ships. From then on, a minor on one line moves its row andCompanionLineReleaseon the other branch in a PR of its own. The table gets its second row in the PR that opens the first version afterv11.0.0. - The ApiCompat baseline is the latest stable release before the version being prepared, never a preview: the 11 line validates against 10.0.0 until 11.0.0 ships.
- The walkthrough sample (
samples/Bookings/Bookings.csproj) names the baseline's version and the framework that release targets, because GitHub's dependency submission and a reader who copies it restore it from nuget.org. The smoke test moves its copy toVersionand this line's framework. So movingVersionleaves the sample alone: move it with the baseline, in the same PR (WalkthroughSampleConventionTestsfails until you do). - GitHub's Latest release is the highest stable version. The release workflow decides it rather than trusting the API's default, so a 10.x tag pushed after 11.0.0 exists, or any preview, can't take Latest from it.
Changelog
A version's section of the root CHANGELOG.md is the GitHub release's notes, word for word, so it
is written for someone deciding what to change before they upgrade. The package changelogs use the
same form, with the entries that affect that package, and a package's section is its release notes
on nuget.org and in Visual Studio: pack writes it into the nuspec in plain text, because nuget.org
shows no Markdown, and fails when the section is missing or empty (Directory.Build.targets).
- The heading is
## [x.y.z] — yyyy-mm-dd. Write entries under the version being prepared, never underUnreleased. The date is the tag's: set it in all five changelogs in the commit that gets tagged, because no test or workflow checks it. - One optional line first, for a version where something can break you: what kind of change it is, and a link to its section of docs/migration.md.
- Then the sections, in this order:
### Changed answers(a minor only, see above),### Breaking,### Added,### Changed,### Fixed. Leave out the ones you don't need. A version with nothing for a package says so in one line: "No change in this package." - Breaking holds everything that can break existing code or data, in a major or not: a call that answers, accepts, writes or translates differently, a payload that changes shape, a query that now throws. Each line says what to do about it.
- One change per line, on one line. Say what happens now and, for a fix, what happened before:
"
RangeSet.Contains(T)on the infinite set returnstrue, where it threw …". Start with the area where it helps a reader skim (JSON:,Literals:,EF Core:,NodaTime:). InAdded, lead with the new member in bold. - Keep each section in area order:
Ranges,Value sets,Literals,JSON,ASP.NET Core,EF Core,NodaTime,Docs,Packaging, so a reader finds an area's lines together. InAdded, the core members come first, thenEF Core:, thenDocs:. Use no headings below the three sections. - End with a link to the guide that explains it. Links are absolute
(
https://github.com/CaffeinatedCoder/CodoMetis.ValueRanges/blob/main/docs/…), because the release page can't resolve a relative one. - Leave out the reasoning, the measurements and the history of how it was found, test names, internal refactoring with no effect you can observe, and fixes to something new in the same release. The reasoning goes in the guide or the migration guide, the history in the commit and the issue.
- Write in the plain voice of the guides: short sentences, active voice, no emphasis markers or emoji.
ChangelogConventionTests holds the form: known sections in order, one line per change, at most
60 words a change and 40 for the introduction, absolute links, and the Changed answers rules.
Oracle tests
An oracle test checks the library against an independent answer over generated cases instead of a
hand-written expectation: set theory over every representable range of a small universe
(SmallModelOracleTests), PostgreSQL over every ordered pair of shapes (ShapeMatrixParityTests),
the invariant literal under five cultures for every discovered type (LiteralCultureContractTests).
docs/oracles.md describes the machinery around them, which lives under
test/oracles/:
- Claims.
claims.jsonlists what the consumer-facing documents promise, each quoting its sentence verbatim and naming the tests that hold it. An emptyheldByis the worklist. A reworded document fails the catalogue test until the claim follows. - Surface. Every public member of the four packages is named by a claim or waived with a reason in
surface-waivers.json. A new public member needs one or the other. - Seeds. Every registered oracle (
oracles.json) ships a seed underseeds/: a patch tosrc/that plants a defect the oracle was shown to catch.OracleSeedTestskeeps the seeds present and applicable in CI;dotnet run tools/oracles.cs seedsre-proves locally that each is caught. Run it before a release, and refresh a seed a refactor has stranded. - Candidates. A disagreement an oracle finds goes into
candidates.jsonwith its family and the issue that tracks it. It fails labelled until the issue is filed, is reported as skipped while the issue is open, and fails ascandidate fixedonce the fix lands, so the entry is removed in the same PR. - Clones. The hand-copied type families — the value sets and their wrapper arities, the range
unions, the EF registrations — are compared sibling by sibling from the source text
(
CloneSweep.cs,CloneDivergenceTests). A member one sibling lacks or alone has, or a body that drifted from the one the others share, is either waived with a reason inclone-waivers.jsonor fails CI.dotnet run tools/oracles.cs clonesprints the list with the JSON line to paste for a new one; a fix that lands at one site and not its siblings is what this catches.
Commit trailers
A fix commit says how the defect was found and what kind it is, as git trailers after the body:
Found-By: OT
Defect-Class: ALG
Found-By:—OToracle test,RVcode review,ISissue report,USuser,CAanalyst,SMthe consumer smoke test. An issue an oracle found carries thefound-by/oraclelabel, so the trailer and the label count the same finds from either side.Defect-Class:—ALGrange or set algebra answering wrongly,CANcanonical form,LITliteral parsing or formatting,JSONSystem.Text.Json or TypeConverter,SQLtranslation or type mapping,STOREthe value stored differs from the value declared,PKGpackaging or release wiring.
git log --format='%(trailers:key=Found-By,valueonly)' | grep . | sort | uniq -c counts the finders.
AI-assisted development
A substantial portion of this codebase — including much of the range algebra, the value set family and the test suite — was written with AI assistance, using Claude Code. This is stated plainly because the project has real adopters who deserve to know how it is built, not because anything about it is unusual.
What that does and does not mean:
- Direction, architecture, and acceptance are the maintainer's. Design decisions, trade-offs, and what merges are human calls. AI is a tool used under review, not an autonomous committer.
- Nothing is accepted on the basis that it looks right. The verification discipline above exists precisely because plausible-looking code is cheap to produce. Fixes are proven against the bug they claim to fix; convention tests are proven by seeding the defect they claim to catch.
- The audit trail is public. Commit messages record what was wrong, what a consumer would have experienced, and how the fix was verified. That is the evidence, and it is more informative than any statement about authorship.
- Code is judged on behaviour, not provenance. A defect is a defect whoever typed it. AI assistance neither excuses a bug nor makes one more likely to be excused.
If you contribute with AI assistance, that is fine — the same bar applies. Please make sure you understand and can defend what you are submitting, and that it is yours to contribute.