sharp-runtime is a C++23 static library that reimplements a practical subset of the .NET System.* namespace so that ported C#/XNA game code compiles against C++ headers with minimal changes. It is the foundation for CNA (C++ XNA port) and mobile-eggbert.
The maintainer intends to write, review, and commit future C++ implementation changes personally.
AI is a consultant for code reading, design alternatives, .NET reference checks, test ideas,
profiling interpretation, and review. It must not author implementation patches or silently
change tickets in routine future work. A direct, task-specific instruction from the maintainer
may authorize an exception, as it does for the isolated 2026-09-30 handoff branch.
See docs/HumanDevelopmentHandoff-2026-09-30.md for the takeover workflow.
- Zero errors, zero warnings before any commit.
cmake --build build --parallel 2must be clean. - No test-count regression.
scripts/run_component_tests.sh buildmust show no failures or skips. The latest verified test count is 18,123 tests across 41 executables — 18,123 passed, 0 failed, 0 skipped on 2026-09-30 in the isolated handoff worktree. The two live ServiceModel tests requireSHARP_RUNTIME_SOAP_ENDPOINT; the complete run used the unchanged original Yacht service throughscripts/run_component_tests_with_soap_fixture.pyon a private loopback port. Graph 44 / 109; test-only seams 5 / 22; negative fixtures 55 / 284. Clang 19.1.7 built all 230 first-party production translation units with-Werror, zero warnings and errors. Doxygen 1.9.8 emitted 2,674 warnings under the unchanged no-regression ceiling of 2,675. The earlier 17,840 / 38 result belongs to 2026-08-22. Historical test-count ledger: seedocs/TestCountLedger.md(moved out of always-loaded scope 2026-08-28; it was 88.6% of this file's bytes). - Push only to
feature/work. Never push todevelopormaster, and never create tags, without explicit per-action user approval. - SPDX header on every project source/header —
// SPDX-License-Identifier: MIT+ copyright + .NET attribution. Vendored sources retain their upstream headers; Markdown uses an HTML SPDX comment where one is present. - Property naming: always
getXxxProperty()/setXxxProperty(). Exception: indexers (C#this[key]equivalents) usegetItem()/setItem(), notgetItemProperty()/setItemProperty()— a deliberate, consistent convention for the parameterized-property case, applied across every indexer in the codebase. - Namespace syntax:
namespace System::Collections::Generic {(C++17 nested form). - Use
SharpRuntime::intcs, notintin public APIs that mirror .NETintparameters. - No LINQ in the code this project writes — use
std::rangesin ported bodies instead. This does not mean the tree contains no LINQ surface, and the distinction is worth stating here because the two look like a contradiction otherwise:modules/core/include/System/Linq.hppis a 508-lineSystem::LinqprovidingWhere,Select,FirstOrDefaultand ~17 more overstd::vector<T>. It exists as a compatibility surface for ported C#/XNA call sites, not as a licence to use those operators in new code. Measured 2026-08-19: a strict search forSystem::Linq::finds zero uses in this repository's production code, zero incnaand zero inmobile-eggbert— its only users are its own two test files. So it is a capability offered in advance of a caller; removing it would be a public-surface decision rather than a cleanup. - No merge to master or tags without explicit per-action user approval.
- No broad header refactor — naming conventions touch 449+ files and would break CNA.
- Copy doc-comments from .NET source — when porting a type, if the
.NETsource (/rv/tmp/runtime/src/libraries/) has XML doc comments and the sharp-runtime header has none, copy them as Doxygen/** */comments where the meaning translates cleanly to C++. - At most two parallel compilation jobs. See "Build-resource policy" below. This is binding on every build, rebuild, sanitizer build, probe, fixture, and test script in this repository, permanently and for all future work. The ceiling was four until 2026-07-28, when the user lowered it to three, and two from 2026-08-01; historical ticket records that state a four- or three-job measurement describe what was correct under the then-current rule and are not retro-edited.
- Push immediately after every commit. A commit is not finished until it is on the
remote. Run
git push -u origin <current-branch>as the next action after eachgit commit— do not batch commits up for a later push, and do not end a turn with the local branch ahead of its upstream. This was introduced on 2026-08-09 after 108 commits (three to nine days old, +42,818/−1,051 across 277 files) were found sitting unpushed onclaude/remediation-batch-1804-namespace-b1yjh5; the container that held them is ephemeral, so that backlog was one reclaim away from being lost. The rule does not relax rules 3 and 9: the push target is the session's designated working branch, anddevelop,masterand tags still need explicit per-action user approval. If a push fails on a network error, retry up to four times with exponential backoff (2s, 4s, 8s, 16s); if it still fails, say so explicitly rather than leaving the commit silently unpushed. - Standing approvals live in
docs/StandingApprovals.md— read it before recording any ticket asblockedon an approval. Do not restate their content here; that file is the single source of truth for SA-1 through SA-16 and the environment facts they rest on.
This section is permanent and binding for all work in this repository, by any contributor and by any future Claude Code session, on every ticket — not only the ticket that introduced it. It has two halves: a CPU ceiling and the pre-existing SSD-saving rules. Both must be obeyed together.
-
Every compilation, link, build, rebuild, sanitizer build, consumer fixture, compile probe, dependency build, CMake configure step that compiles, and test script that performs compilation internally may use at most two parallel jobs / two CPU cores.
-
Use commands equivalent to:
cmake --build <dir> --parallel 2 ninja -C <dir> -j2 make -C <dir> -j2 ctest --test-dir <dir> -j2
or any lower value.
-
Never use unrestricted or automatically detected parallelism. All of the following are forbidden:
- a bare
ninjainvocation, which defaults to every CPU plus two; -jwith no number, which is unbounded;--parallelwith no explicit maximum, which uses all detected cores;$(nproc),nproc,sysctl -n hw.ncpu,getconf _NPROCESSORS_ONLN, or any equivalent core-count substitution;std::thread::hardware_concurrency()— or any runtime core count — used to choose a build-job count;CMAKE_BUILD_PARALLEL_LEVEL,MAKEFLAGS,NINJA_STATUS-adjacent environment variables, or CI defaults left to expand to all cores;- any script that defaults to "all available cores" when no job count is supplied.
- a bare
-
When a repository script compiles internally, pass whatever argument or environment variable constrains it to two jobs (for example
CMAKE_BUILD_PARALLEL_LEVEL=2,MAKEFLAGS=-j2, or the script's own job-count parameter), and record in the ticket that the constraint was applied. -
If a build script cannot currently be limited to two jobs, fix the script first, or use a bounded alternative (a direct
cmake --build … --parallel 2on the same targets). Do not run the unbounded script "just this once".scripts/job_count_policy.pyis the single resolver used byscripts/check_selective_components.sh,scripts/local_ci_check.sh, andscripts/check_negative_consumer_fixtures.py. Precedence is explicit--jobs, thenSHARP_RUNTIME_BUILD_JOBS, then the safe default 2; only 1 or 2 is accepted, and wrappers export the resolved value to nested helpers. Local CI still passes the resolved value explicitly to the fixture checker. -
The two-job limit applies even when the machine has more CPU cores. Core count is not a licence to raise it.
-
Fewer than two jobs is always allowed and is preferred whenever a target is memory-heavy (sanitizer or template-heavy translation units): drop to
-j1rather than risking swap or an OOM kill. -
Exceeding two jobs requires new explicit user approval, per action. A previous approval never carries over to another command, another ticket, or another session.
-
Every final ticket report must list:
- every build directory used;
- the maximum parallel job count actually used;
- any script that required special handling (an argument, an environment variable, or a bounded substitute) to enforce the limit.
Why this matters: repeated from-scratch builds wrote 3.5 TB to this SSD in five days (measured 2026-07-28), on top of the earlier ~270 GB / two-day scratchpad measurement. That is real, irreversible flash wear, not just wasted time. Every avoided full rebuild is avoided write endurance spent. Reuse an existing build directory whenever it is usable — a full reconfigure/rebuild is the exception that must be justified, never the default.
-
There is a FIXED, CLOSED set of build directory names. Never invent another one, and never suffix one with a ticket number, a date, a branch, or a topic. The complete list:
Directory Purpose build/the default build and the repository gate build-modular/the modular/selective component build build-asan/,build-ubsan/,build-tsan/sanitizer trees build-probe/every ticket's throwaway probes, ABI experiments, shims and sweeps build-consumer/every ticket's standalone consumer fixture build-tmp/repository-local TMPDIRformktemp-based scriptscmake-build-debug/the IDE tree Directories like
build-probe-1794/,build-consumer-1785/,build-asan-sortedset/are the mistake this rule exists to stop: measured on 2026-07-28, twenty-one such one-shot directories held 441 MB and guaranteed that nothing in them was ever reused. A ticket separates its own work by file name prefix inside the shared directory (build-probe/1797_probe1_escapes.cpp), never by a new directory. -
Delete a ticket's probe artefacts once its evidence is transcribed into the design record. The design document is the durable evidence; the binaries are not. Sanitizer probe binaries reach hundreds of megabytes each.
-
Prefer incremental builds. Do not clean, delete, or reconfigure a build tree unless it is genuinely broken or the configuration genuinely changed; document any such build and why.
-
Retain
ccachewherever it is already configured, and do not retrofit it where doing so would force an unnecessary full recompilation. -
Never create a build tree under
/tmp,/var/tmp, or/dev/shm, including the per-session scratchpad. Redirectmktemp-based scripts through a repository-localTMPDIR(this repository usesbuild-tmp/). -
Remove large disposable binaries once their results are recorded, and never delete a build directory another session may still be using.
The current full build/test baseline is Linux/GCC; the complete production-only All graph is
also warning-clean under Clang 19.1.7 and is enforced locally and in CI. Post-modular MinGW-w64 GCC
14-win32/CMake 3.31.6 and Emscripten 5.0.7/CMake 3.31.6 library builds both
compile the All graph and a selective Text.Json graph without
GoogleTest/runtime execution. Real downstream Apple Clang/Xcode 15.4 builds
drove the portability fixes from 1d22a7b2 through b797928f. The
repository's tracked CI is Ubuntu-only, so do not describe Windows,
Emscripten, or macOS as having the same current test coverage as Linux.
Unsupported runtime operations must still compile. They should throw
PlatformNotSupportedException clearly rather than fail the build or silently
degrade.
Current platform-limited areas include:
| Subsystem | Implemented runtime platforms | Explicit limitation |
|---|---|---|
System::Net::Sockets |
Windows and POSIX | Socket operations throw on Emscripten. |
System::IO::RandomAccess |
Windows and POSIX | Operations throw on Emscripten. |
System::AppDomain base directory |
Windows, macOS, Linux/POSIX | Emscripten uses the virtual-FS-relative ./ fallback. |
System::TimeZoneInfo |
Windows and POSIX | Emscripten provides UTC/local fallback and rejects system-zone lookup. |
System::Diagnostics::Process |
POSIX | Operations throw on Windows/Emscripten. |
PosixSignal/PosixSignalRegistration |
POSIX | Registration throws on Windows/Emscripten. |
NetworkInterface enumeration |
Linux | Enumeration/query operations throw elsewhere. |
FileSystemWatcher |
Linux/inotify | Enabling events throws elsewhere. |
System::Decimal, System::Int128, and System::UInt128 require a compiler-provided 16-byte
__int128/unsigned __int128 type. This is a compiler-capability dependency, not an OS-name
test: x86_64 GCC/Clang and x86_64 MinGW GCC provide it, while MSVC and i686 MinGW GCC do not.
The public SHARP_RUNTIME_HAS_NATIVE_INT128 macro is always 0 or 1; CMake sets it from an
actual compile probe and SharpRuntimeHelper.hpp provides the same __SIZEOF_INT128__-based
fallback for non-CMake consumers.
When the macro is 0, the three direct type headers reject inclusion with a clear diagnostic and
the library omits only Decimal.cpp. Otherwise-portable types remain available, with only their
native-128-dependent members absent: Int64::BigMul(long,long), the 64-bit Math::BigMul
overload, 128-bit BitConverter/BinaryPrimitives overloads, BinaryReader::ReadDecimal,
IConvertible/DBNull::ToDecimal, and the Decimal XmlConvert overloads. This is the supported
i686 MinGW compile/link boundary used by CNA's Glide backend; it does not claim Decimal or
Int128/UInt128 support on that compiler.
The lack of native 128-bit integers is a known, accepted, permanent limitation — never hide it with a hand-rolled representation or reduced semantics. The 2026-07-11 decision remains that the risk and complexity of a from-scratch implementation outweigh its benefit.
| Type | Requires | Availability |
|---|---|---|
System::Decimal |
16-byte unsigned __int128 |
SHARP_RUNTIME_HAS_NATIVE_INT128 == 1 |
System::Int128 |
16-byte __int128 |
SHARP_RUNTIME_HAS_NATIVE_INT128 == 1 |
System::UInt128 |
16-byte unsigned __int128 |
SHARP_RUNTIME_HAS_NATIVE_INT128 == 1 |
- POSIX includes (
<unistd.h>,<sys/socket.h>, etc.) must not appear in public.hppheaders. - Platform-specific code belongs in
.cppfiles guarded by#ifdef _WIN32/#elif defined(__EMSCRIPTEN__)/#else(POSIX). - On unsupported platforms, throw
System::PlatformNotSupportedExceptionwith a clear message — never silently fail. - Emscripten builds must compile without errors even when the feature is unavailable at runtime.
- ✅ DONE — implemented, tested, compiles clean on the verified native baseline, no known bugs.
⚠️ PARTIAL — compiles and mostly works but has known, documented API or behavior gaps.⚠️ PLATFORM-LIMITED — compiles across the intended toolchains but some operations are available only on named platforms and throw explicitly elsewhere.⚠️ STUB — API surface exists, bodies are no-ops or throwNotImplementedException.
sharp-runtime and .NET will naturally differ — C++ has no GC, no IL, no runtime reflection, and no delegate infrastructure. The goal is maximum practical parity: the public API, method semantics, default values, error messages, and algorithmic behaviour should match .NET as closely as C++ allows.
Known permanent deviations (not bugs, not TODO):
- Reflection (
System::Type,System::Activator,Enum.GetNames/GetValues, etc.) — completely out of scope. Stubs are the correct end state. - GC (
System::GC) — all methods are no-ops. Memory is managed by RAII /std::shared_ptr. - Delegates — three tiers, not one blanket rule (verified 2026-07-11 while auditing ticket 72,
since the previous one-line version of this bullet was itself inaccurate for two of the three):
the majority of delegate-shaped types (
Action,Func,EventHandler, and most*EventHandler/*Callbackaliases across the codebase) are bareusing X = std::function<...>;aliases — single-target only, no multicast, noBeginInvoke/EndInvoke(async delegate invocation is out of scope entirely, matching .NET's own removal of the pattern). ButSystem::Delegate(modules/core/include/System/Delegate.hpp) is a real multicast delegate base class with workingCombine/Remove/RemoveAll/GetInvocationList, andSystem::MulticastAction<Args...>(modules/core/include/System/MulticastAction.hpp) is a purpose-built multicast event-field type with+=/=and reentrancy-safe snapshot invocation — both genuinely support multicast where a ported type needs it.Delegate::DynamicInvokealways throwsNotImplementedException(no late-boundobject[]invocation equivalent in C++) in all three tiers. - Serialization — no serializer. There is no
BinaryFormatter, no surrogate selector, no type resolution by name, no stream format and no object-graph walk;[Serializable]is ignored, and nothing in this runtime writes a serialized stream anywhere. What does exist, since 2026-09-20, is the value store the obsolete-but-documented(SerializationInfo, StreamingContext)constructor /GetObjectDatapair needs:System::Runtime::Serialization::SerializationInfois a real name-to-value bag with .NET's own rules (add a name once, read it as the type it was stored as,SerializationExceptionotherwise),StreamingContextcarriesStreamingContextStatesand a context object, andSerializationExceptionexists. That lets a ported type write its own fields out by name and reconstruct itself from them, in process -- which is what CNA'sContentLoadException,NetworkSessionJoinExceptionandStorageDeviceNotConnectedExceptionneed for their documented XNA 4.0 signatures. It is not a step towards a serializer, andSystem::Exceptiondeliberately still has no serialization constructor or virtualGetObjectData: those live in CoreLib alongsideSerializationInfoin .NET, and here they are in different modules, so a deriving type writes and restores its own base state instead. - P/Invoke / interop — out of scope.
- The unit of every public index, length and count is a UTF-8 storage byte, where .NET's is a
UTF-16 code unit — decided by ticket #2015 (SR-AUD-290, SR-AUD-296) on 2026-08-17 and
declared, not repaired.
System::Stringis a UTF-8std::stringthroughout this runtime, soEncoding::GetCharCountof U+1F600 is4where .NET reports2, andStringBuilder'sLength/Insert/Remove/CopyToindex bytes. Adopting .NET's unit is not a change toSystem::Text; it is a re-architecture of every index in the port. What makes this the faithful adaptation rather than merely the cheap one is that a byte index into UTF-8 is the exact analogue of a code-unit index into UTF-16, including the ability to split a character: .NET'sStringBuilder.Removevalidates only the numeric range (StringBuilder.cs:1024-1042), with no surrogate-pair guard, so it can leave a lone surrogate and an ill-formed string exactly as this port can leave an ill-formed UTF-8 sequence. Adopting .NET's unit would have moved that hazard to a different character, not removed it. Pinned byTextUnitContractTests.Decl2015_*, including that the unit is consistent across the component — a mixture would be far worse than either unit consistently applied. - Unicode normalization —
StringNormalizationExtensions::IsNormalizedreturnstrueandNormalizereturns its argument unchanged for every input. Decided by ticket #2338 on 2026-08-19 and declared, not repaired — and the point is that this is not a divergence: it is exactly .NET's behaviour in invariant globalization mode, which says so in its own comment ("In Invariant mode we assume all characters are normalized because we don't support any linguistic operations on strings",Normalization.cs:11-40). .NET has no normalization tables of its own — it delegates to ICU on Unix and NLS on Windows, andCharUnicodeInfoData.cs, the source of record SA-4 names, carries zero decomposition, combining-class, composition-exclusion or quick-check data. Two alternatives were offered and declined: own UCD tables plus a UAX #15 implementation (size L-to-XL, and a second Unicode version to keep in step with SA-4's 16.0), and an ICU dependency, which is the shape this list already declines for cryptography — "a large new external dependency" — so taking it would reverse a standing decision rather than make a new one. Measured: zero call sites incnaand inmobile-eggbert; the only in-repository uses are the type's own tests. What a caller must read into it:truemeans this runtime performs no linguistic normalization, not that the string is in the requested form. The form is validated on every platform, becauseCheckNormalizationFormruns before the invariant shortcut (#2386). Pinned byStringNormalizationTests.Decl2338_*. - tzdata rule structures —
TimeZoneInfo::GetAdjustmentRules()returns an empty array andHasSameRules()therefore cannot distinguish two zones that share a base offset and a DST flag (America/New_YorkandAmerica/Havanareport as same-rule zones where .NET reportsfalse). Decided by ticket #2185 on 2026-08-19 and declared, not repaired, on two measurements. It is not closable by sampling libc at any granularity — two zones can agree on every sampled instant and still differ in rule, so no finer sampling turns offsets into rules; closing it needs tzdata's own structures (the TZif POSIX-TZ footer, or the full transition table with its per-era type records), i.e. a TZif reader, which is out of scope for this port. And the failure is one-directional: this method can only ever be too permissive, never too strict, so a caller using it as a necessary condition is correct and one using it as a sufficient condition is not. The layout cost an earlier design measured (sizeof(TimeZoneInfo)160 → 184) is real but is not what blocks it and is not paid. Pinned byTimeZoneInfoTests.Decl2185_*. - Symmetric/asymmetric cryptography, X.509 certificates, TLS (
System.Security.Cryptography'sAes*/RSA*/EC*/ChaCha20Poly1305/CryptoStream,System.Security.Cryptography.X509Certificates,System.Net.Security'sSslStreamand friends) — out of scope by explicit decision (2026-07-07): implementing this correctly needs either a large new external dependency (OpenSSL/mbedTLS) or a hand-rolled, security-critical implementation, neither of which is worth it for game code. Hash algorithms (MD5/SHA*/HMAC/PBKDF2— no key material, no confidentiality guarantees to get wrong) are already ported and remain in scope; they are not affected by this deviation.
When a method cannot be meaningfully implemented (e.g. it requires reflection), it should throw System::NotImplementedException with a comment explaining why — never silently return a wrong value.
A type may be marked ported only when all of the following hold:
- A header under the owning module's
include/System/.../*.hppexists with the full public API: all public methods, constructors, properties, and operators that appear in the .NETref/surface file. - Properties follow
getXxxProperty()/setXxxProperty()naming. - Complex types have a
.cppbody file; simple types may be header-only. - No method body is a bare
throw NotImplementedException()stub — those are STUB, not ported.
SharpRuntime::intcs(notint) for public API parameters that mirror .NETint.- Namespace opened with C++17 nested syntax:
namespace System::Collections::Generic {. - No LINQ — use
std::rangesinstead. - POSIX-only internals are in
.cppfiles behind#ifdef, not in public.hppheaders.
- Doxygen
/** */block comments on every public type and method. - Comments copied/adapted from .NET XML doc-comments in
/rv/tmp/runtime/src/libraries/where available.
Every .hpp and .cpp file starts with:
// SPDX-License-Identifier: MIT
// Copyright (c) Robert Vokac and contributors
// Portions based on .NET runtime API (MIT License, Copyright .NET Foundation and Contributors)- Compare the C++ implementation in sharp-runtime against the reference C# source in
/rv/tmp/runtime/src/libraries/. - All non-trivial method bodies must match the .NET logic (algorithm, edge-case handling, error conditions).
- Verify that default messages, constants, and HResult/error codes match the .NET source where applicable.
- Discrepancies must be either fixed or explicitly documented as intentional deviations.
cmake --build build --parallel 2— zero errors, zero warnings.
scripts/run_component_tests.sh build— all component and integration tests pass exactly once (no failures, no crashes).- At least basic GoogleTest coverage exists for the ported type's key methods.
- Complex types:
.hppdeclaration +.cppbody. Move bodies to.cppwhen a header grows unwieldy. - Simple types: header-only is fine.
- CMake: component-specific
CONFIGURE_DEPENDSglobs discovermodules/*/{src,tests}/*.cpp;scripts/validate_module_boundaries.pyvalidates every implementation/header owner and public/private/test dependency. Every module declares its include root, sources, tests, dependencies, and platform setup inmodules/<module>/CMakeLists.txt. - Component boundaries: internal code depends on narrow physical targets
(
Core.Base,Collections.Core, etc.), never theCore,Collections, orAllcompatibility umbrellas. Public-header edges arePUBLIC_DEPENDENCIES, implementation-only edges arePRIVATE_DEPENDENCIES, and test-only edges areTEST_DEPENDENCIES.BlockingCollection<T>belongs toCollections.Blocking; do not add itsThreadingrequirements back toCollections.Coreor weaken the Text.Json isolation fixture. - Vendored libs: GoogleTest, nlohmann/json, tinyxml2, miniz, all under
vendor/. Never commit binaries. Files undervendor/are third-party source unmodified from upstream — exceptvendor/tinyxml2, whose names (not behaviour) are moved intoSharpRuntime::Vendor::tinyxml2so it cannot collide with a consumer's own tinyxml2 (docs/VendoredTinyXml2.md) — and are exempt from this project's SPDX-header, doc-comment, andgetXxxProperty()/namespace-syntax naming rules — those rules apply only to moduleinclude/,src/, andtests/trees. - Templates: deferred
inlinedefinitions after forward declarations to resolve circular includes. - Collection mutation counters: a collection with a fail-fast enumerator must hold its
counter as
System::Collections::detail::MutationCounterand its enumerator must snapshotdetail::MutationVersion(System/Collections/detail/MutationCounter.hpp). Never a bareintcs—++on a signed counter is undefined behaviour atINTCS_MAX, and the implicitly declared assignment operator would transplant the source's counter into the destination, leaving an enumerator apparently valid over storage the assignment destroyed. Both defects existed in fourteen collections and are recorded with reproductions indocs/CollectionVersionCounterSweep.md.detail::NarrowMutationCounterhas no user left and must not gain one. It was the 32-bit counter for the two types whose measured layout had no room for eight bytes; ticket #1788 movedLinkedList<T>off it (growingsizeof(LinkedList<T>)40 → 48) and ticket #1789 movedBitArrayoff it (growingsizeof(BitArray::Enumerator)32 → 40), each under its own explicit user approval, so no collection retains a 2^32 enumerator-snapshot ABA horizon. The alias survives only as history and as the second instantiation the counter tests pin.SortedSet<T>keeps its ownulongcscounter inside the sharedStateits live views co-own (ticket #1786) — do not migrate it. When a collection's counter is widened, its enumerator's snapshot must be widened in the same change: a narrow snapshot compared against a wide counter is a silent truncation that leaves the alias in place while the code claims otherwise. - Test-only access seams: a class template that a production header declares inside
namespace SharpRuntime::Testingand never defines (CollectionVersionAccess,SortedSetVersionAccess) may be defined in exactly one file, and every suite that needs it must include that file. For the collection mutation counters that file ismodules/collections/tests/support/CollectionVersionSeam.hpp; add a new collection there, once, through itsSHARP_RUNTIME_COLLECTION_VERSION_SEAMmacro. Never writetemplate<> struct CollectionVersionAccess<…> { … }in a test translation unit. Five suites did, in two divergent families, and two token-different definitions of one class in one program is a one-definition-rule violation that is ill-formed with no diagnostic required: measured on 2026-07-29, swapping two object files on the link line changed the answer a correctly written suite got, andld,-flto -Wodr, ASan withdetect_odr_violation=2and UBSan all said nothing (docs/CollectionVersionTestSeamDesign.md, ticket #1800).scripts/check_version_seam_odr.pyenforces this and runs inscripts/local_ci_check.sh; never define a seam inmodules/*/includeormodules/*/src, because that would make it reachable from a consumer and break the consumer-side fixture that pins it —test/consumer/collections_mutation_version_negative.cppforCollectionVersionAccess(2 sites, #1787/#1801) andtest/consumer/collections_sorted_set_version_negative.cppforSortedSetVersionAccess(15 sites, #1803). Every seam needs both checks — they catch different mutations. Ticket #1804 (2026-07-30) closed one earlier gap in the seam checker: giving a seam's primary template a body in a public header used to make it stop being discovered as a seam, socheck_version_seam_odr.pyexited 0 while one of two seams silently vanished; the checker now surfaces a defined primary and rejects it as a seam defined in a production tree (docs/CollectionVersionTestSeamDesign.md§15). The consumer fixture is still required for a mutation the checker cannot see — making a collection's private counter public — which is caught only by compilation (docs/NegativeConsumerFixtureValidation.md§18.4 row four). A seam added by a future ticket must therefore gain atest/consumer/*_negative.cppsite too, not only a single definition site. Note also what neither check can express: a consumer that reopensnamespace SharpRuntime::Testingand writes its own explicit specialisation does get the access the friend declaration grants, for both seams; that is well-formed ISO C++, is unsupported, and is recorded in §18.5 rather than assumed away. - Negative consumer fixtures: a
test/consumer/*_negative.cppproves that a spelling a ticket outlawed is rejected by the compiler. It must carry a// NEGATIVE-FIXTURE: component=<Component>directive, an#ifndef SHARP_RUNTIME_NEGATIVE_SITE / #define … 0 / #endifprelude, and one#if SHARP_RUNTIME_NEGATIVE_SITE == Nguard per negative site, each holding exactly one// NEGATIVE(<kebab-id>): <expected diagnostic fragment>marker (further alternatives on following// | <fragment>lines). Site numbers must be1..N; the#elsebranch is where the migrated spelling goes. With no site selected the file must compile with zero diagnostics — that clean baseline is what lets a per-site verdict be attributed to its own source, so add(void)x;wherever disabling a site orphans a local.scripts/check_negative_consumer_fixtures.pycompiles the baseline plus each site separately (-fsyntax-only,-Wall -Wextra -Wpedantic -Werror, at most two jobs, include directories derived from the CMake component metadata) and runs inscripts/local_ci_check.sh. Never assert only that a whole fixture fails to compile: one broken line hides every other line, and a whole-file check reported a false pass while one of eleven claims had silently become legal again (docs/NegativeConsumerFixtureValidation.md, ticket #1801).
Classifying unclassified .NET types tracked in plan.sqlite3 is the classify-plan-types skill — invoke it when reviewing or advancing that queue. Full workflow detail lives in prompt.md.
# Build
cmake --build build --parallel 2
# Run all tests
scripts/run_component_tests.sh build
# Errors/warnings only
cmake --build build --parallel 2 2>&1 | grep -E "error:|warning:" | grep -v "^#"
# Run a specific suite
./build/SharpRuntimeTests_Net_Sockets --gtest_filter="TcpClient*"