Production-grade railway-oriented programming (ROP) for modern C#.
Sandlada.RopSharp gives you immutable structs — Result<TOk, TErr>, Option<T> and Either<TLeft, TRight> —
together with a structured RopError, applicative validation, parsing helpers, and cancellation-aware async
combinators. Expected failures are values, never exceptions. The public surface is fully symmetric, has no
aliases, performs no reflection, and is AOT/trim friendly.
using Sandlada.RopSharp;
// An order is priced, charged and finally shipped. Every failure is a value.
Result<string, RopError> receipt =
Result.Try(() => Charge(order), "E_CHARGE")
.BindOk(payment => Result.Ok(payment.Id))
.Ensure(id => id.Length > 0, RopError.Create("E_RECEIPT", "the receipt id is empty"))
.TeeOk(id => Log(id))
.MapErr(e => e.WithCode("E_PAYMENT"));
string text = receipt.Match(
ok: id => $"receipt {id}",
err: e => $"fail {e.Code}: {e.Message}");- Targets:
net10.0andnet11.0only. - Package:
Sandlada.RopSharp(MIT). - Dependencies: none.
- Entry points:
Resultbuilds aResult<TOk, TErr>,Optionbuilds anOption<T>(ADR 0012).
Every example in this repository — in the README, in the XML documentation of the library and in the Traditional Chinese guide — is drawn from one small domain, so a reader keeps watching the same program grow instead of a series of unrelated fragments:
| Type | Role |
|---|---|
Customer |
The buyer. The shipping address is optional, so the absence path can be shown. |
Address |
A postal address. |
Order |
The order to fulfill, with its lines. Total and ItemCount are derived. |
OrderLine |
One ordered line, with a derived Amount. |
Payment |
A payment attempt against an order. |
PaymentStatus |
The lifecycle state of a payment. |
Shipment |
A parcel handed over to a carrier. |
The types live in
Sandlada.RopSharp.Tests/DocExamples/DocExampleDomain.cs
and are compiled into the test assembly. OrderFixtures stands in for the external systems: Charge throws for a
declined order, so a boundary built on top of it shows both tracks deterministically.
dotnet add package Sandlada.RopSharp| Question | Type | Success | Absence/failure |
|---|---|---|---|
| "Did it fail, and why?" | Result<TOk, TErr> |
Ok(value) |
Err(error) |
| "Is there a value?" | Option<T> |
Some(value) |
None |
| "Which of two ordinary values?" | Either<TLeft, TRight> |
Left(left) |
Right(right) |
Option never carries a reason. When the reason matters, cross over explicitly with RequireSome.
When both branches are ordinary values (not success/failure), use Either<TLeft,TRight> instead of forcing a
result convention:
Result<Address, RopError> address =
Option.FromNullable(order.Customer.Address)
.BindSome(shipping => Option.Some(shipping))
.RequireSome(() => RopError.Create("E_ADDRESS", "the customer has no shipping address"));// Construction (production default: Err = RopError)
Result<int, RopError> ok = Result.Ok(42);
Result<int, RopError> err = Result.Err<int>(RopError.Create("E_X", "bad"));
// Custom error types are allowed (tests, interop), RopError is recommended in production
Result<int, string> custom = Result.Ok<int, string>(42);
// Non-throwing branch queries
if (result.TryGetOk(out var value)) { /* ... */ }
if (result.TryGetErr(out var error)) { /* ... */ }
// The only full exit
string text = result.Match(ok: v => v.ToString(), err: e => e.Code);
// Lightweight exits (explicit defaults only)
int fallback = result.BindErrValue(0);
int lazy = result.BindErrValue(() => ComputeDefault());OrThrow(), OkOrThrow() and ErrOrThrow() exist for tests and boundaries only.
Option<Address> some = Option.Some(order.Customer.Address!);
Option<Address> none = Option.None<Address>();
Address? maybeAddress = null;
Option<Address> lifted = Option.FromNullable(maybeAddress); // reference types
Option<int> count = Option.FromNullableValue(maybeItemCount); // Nullable<T>
string city = some.Match(some: a => a.City, none: () => "unknown");
// default(Option<T>) is contractually None — safe in arrays, fields and generic containers
Option<Order>[] slots = new Option<Order>[3]; // all None| Concept | Member |
|---|---|
| Success-track transform | MapOk / MapSome |
| Error-track transform | MapErr (no MapNone: a missing value has nothing to map) |
| Success-track expansion | BindOk / BindSome |
| Failure-track recovery | BindErr / BindNone |
| Failure-track recovery with value | BindErrValue / BindNoneValue |
| Observation without changing track | TeeOk / TeeErr / TeeSome / TeeNone |
| Assertion | Ensure (Result) / Filter (Option) |
| Value replacement | MapOkValue / MapSomeValue |
| Full exit | Match / MatchFirst |
| Action exit | Switch / SwitchAsync / SwitchFirst |
| Non-throwing probe | TryGetOk / TryGetErr / TryGetValue |
| Short-circuit combination | Result.Combine, Result.Zip, Result.Sequence, Result.Traverse |
| Aggregating combination | Result.Validate, Result.SequenceValidation, Result.TraverseValidation |
| Explicit model conversion | ToValidation / ToSingleError / RequireSome / ToOption / ToEither / Transpose |
Every combinator is data-last (mapper/binder/predicate/action last), so chains read left to right.
Crossing between the three models is always explicit, and every bridge names which side becomes which:
Either<RopError, Payment> fromResult = Result.Ok(payment).ToEither(); // Ok -> Right, Err -> Left
Result<Payment, RopError> back = fromResult.ToResult(error => error); // Left -> Err, Right -> Ok
Either<string, Payment> fromOption = Option.None<Payment>().ToEither("missing"); // None -> Left, Some -> Right
Option<Payment> present = Either.Right<string, Payment>(payment).ToOption(); // Left is discardedToOption on Result and Either deliberately drops the reason; use RequireSome or ToResult when it must
survive the crossing.
RopError is immutable and structured:
RopError error = RopError.Create(
code: "E_ORDER_NOT_FOUND", // stable, machine-readable, safe to switch on
message: "order 42 does not exist", // human-readable, display only
details: "id=42"); // optional machine/diagnostic payload
RopError fromBoundary = RopError.FromException(exception, "E_IO"); // Cause is captured here only
RopError retagged = error.WithCode("E_ORDER_404");
RopError aggregate = RopError.Combine([notFound, outOfStock]);
IReadOnlyList<RopError> leaves = aggregate.InnerErrors; // the originals, in supplied orderCodeandMessagemust be non-blank; blank input is a programming error (ArgumentException).Detailsmay benullbut must not be blank.Kindis a coarse category (Failure,Validation,NotFound,Conflict,Unauthorized,Forbidden,Unexpected,Timeout,Unavailable,RateLimited) for transport/presentation mapping; branch onCodefor precise domain decisions. New values are appended, so treat the enum as open-ended.InnerErrorsis nevernull: atomic errors expose an empty list, while aCombineaggregate keeps every input in order (no flattening) so nothing is lost by collapsing.WithCodeand the otherWith*methods preserve it.- Equality compares
Kind,Code,MessageandDetails;CauseandInnerErrorsare excluded so stack-trace noise and structural differences cannot make two equally reported errors unequal. RopError.Combine(errors)collapses a non-empty list intoE_MULTIPLE; a uniformKindis preserved and mixed kinds fall back toFailure. A single-element call returns that error unchanged instead of wrapping it.
Recommended code convention: E_<DOMAIN>[_<REASON>], uppercase with underscores.
Result<TOk, TErr> is generic; RopError is the recommended default, not a constraint. Every combinator,
aggregation helper and LINQ operator already works with any TErr. The convenience and boundary helpers have
TErr overloads so a domain error type can be used end to end:
public sealed record AppError(string Code, string Message);
Result<decimal, AppError> parsed =
Result.Parse<decimal, AppError>(raw, failure => new AppError(failure.Code, failure.Message));
Result<Order, AppError> loaded =
Result.Try(() => LoadOrder(id),
ex => new AppError("E_IO", ex.Message),
() => new AppError("E_NULL", "the loader returned null"));- The
RopErroroverloads remain the default and keep theirE_NULL_RESULT/(func, code)behavior. Try/TryAsync/TrySwitch/TrySwitchAsyncrequire an explicit null-result factory for value-producing boundaries, because the library cannot synthesize an arbitrary error. Action boundaries do not need one.Parsepasses aParseFailure(Code/Message/Input/TypeName/Details) to the factory, so the bounded, escaped diagnostics survive.ToSingleError()takes the first error; useToSingleError(combine)for a custom collapse.
Caveats: Result.Err rejects null error values; default(Result<,>) is undefined; Result equality delegates
to EqualityComparer<TErr>.Default (records and structs compare by value, plain classes by reference); TErr
is inlined in the struct, so prefer a reference type or a small struct. A custom error type also gives up
Code/Kind/InnerErrors; model what you need on the type itself.
The two models are deliberately separate and never mix implicitly:
// Short-circuit: first error wins, later functions are not executed
Result<Order, RopError> order =
Result.Ok(raw)
.BindOk(CheckCustomer) // stops here on failure
.BindOk(CheckAmount);
// Aggregating: every input is inspected, all errors are collected in argument order
Result<Order, IReadOnlyList<RopError>> validated =
Result.Validate(CheckCustomer(raw), CheckAmount(raw), CheckStock(raw),
(customer, amount, stock) => new Order("ord-1001", customer, stock));
// Explicit conversions between the tracks
Result<Order, IReadOnlyList<RopError>> lifted = order.ToValidation();
Result<Order, RopError> single = validated.ToSingleError(); // first error
Result<Order, RopError> merged = validated.ToSingleError(RopError.Combine);Sequence, Traverse and Partition accept any IEnumerable<T> but materialize exactly once. Empty input
yields Ok([]) (or Some([]) for options), never null. SequenceValidation and TraverseValidation share
those collection rules but aggregate: every element is mapped and every error is returned in element order.
The map/bind/tee combinators have ...Async counterparts for both Result and Option. The token is optional
and always last; cancellation is reported as OperationCanceledException and never converted into an error,
because errors describe domain failures.
Task<Result<string, RopError>> chained =
ChargeAsync(order, ct)
.MapOkAsync(payment => payment.Id, ct)
.BindOkAsync(id => GetShipmentAsync(id, ct), ct);
// Cancellation-aware mapper receives the same token; the library never creates its own CTS
Task<Result<string, RopError>> ct2 =
ChargeAsync(order, ct).MapOkAsync((payment, token) => FetchReceiptAsync(payment, token), ct);Semantics:
- Cancelled token + pending antecedent → the returned task is cancelled, the callback is not invoked.
- The track that would invoke a callback checks the token first, so a cancelled token never starts new user work
(
OkforMapOk/BindOk/TeeOk,ErrforMapErr/BindErr/TeeErr, and the matchingSome/Nonetrack). - The pass-through track (no callback to run) returns the original value without observing the token.
SequenceAsyncandTraverseAsynccheck the token before the loop and before every element, so an already-completed task cannot bypass cancellation andTraverseAsyncnever maps after cancellation.- Library code always uses
ConfigureAwait(false). - Async argument validation surfaces through the returned task (the methods are asynchronous).
- A callback that returns a
Taskmust not returnnull; that programming error faults withInvalidOperationExceptionnaming the callback (mapper,binder,fallback,actionorpredicate).
Result.TryAsync, Result.SequenceAsync and Result.TraverseAsync follow the same rules. SequenceAsync awaits in
enumeration order so the reported failure is deterministic; start tasks eagerly if they should run concurrently.
The aggregating traversal helpers keep their no-short-circuit contract on the async track:
Result.SequenceValidationAsync and Result.TraverseValidationAsync (plain and cancellation-aware mapper) await every
task and return every error in source order. A faulted task surfaces as an exception rather than an error, and when
several tasks fault the first fault wins while the rest are still observed.
Result<IReadOnlyList<OrderLine>, IReadOnlyList<RopError>> validated =
await Result.TraverseValidationAsync(order.Lines, (line, token) => ValidateLineAsync(line, token), ct);
// Same aggregation without async work
Result<IReadOnlyList<OrderLine>, IReadOnlyList<RopError>> sync =
Result.TraverseValidation(order.Lines, ValidateLine);Option mirrors that surface for map/bind/tee/filter, aggregation and LINQ. A synchronous Option<T> can be
lifted directly with the async
MapSomeAsync / BindSomeAsync / BindNoneAsync / TeeSomeAsync / TeeNoneAsync / FilterAsync
overloads; Option.CombineAsync / ZipAsync await every input, while Option.SequenceAsync / TraverseAsync
await in order and stop at the first None. Task<Option<T>> supports query syntax through
Select / SelectMany / Where:
Option<string> city = await GetAddressAsync(customer.Id, ct)
.MapSomeAsync(address => address.City, ct)
.FilterAsync(value => value.Length > 0, ct);Exceptions become domain values in exactly one place:
Result<decimal, RopError> parsed = Result.Try(() => decimal.Parse(text), ex => RopError.FromException(ex, "E_PARSE"));
Result<decimal, RopError> byCode = Result.Try(() => decimal.Parse(text), code: "E_PARSE");
// Side-effecting boundaries keep the result composable via Unit
Result<Unit, RopError> saved = Result.Try(() => repository.Save(order), ex => RopError.FromException(ex, "E_SAVE"));
Task<Result<Unit, RopError>> sent = Result.TryAsync(() => gateway.SendAsync(order, ct), "E_SEND", ct);
Result<Address, RopError> required = Result.FromNullable(customer.Address, () => RopError.Create("E_ADDRESS", "the customer has no shipping address"));
Result<int, RopError> requiredCount = Result.FromNullableValue(maybeItemCount, () => RopError.Create("E_COUNT", "the order has no item count"));OperationCanceledExceptionis always rethrown, never converted.- A boundary function returning
nullyieldsErrwith codeE_NULL_RESULT— anOk(null)is never produced byTry. Result.Switch/Result.TrySwitchlift a plain function without and with exception conversion;Result.SwitchAsync/Result.TrySwitchAsyncare the awaited counterparts. They adapt a function, they do not consume an existing result (that isSwitch/SwitchAsynconResult).Result.Try(Action, ...)andResult.TryAsync(Func<Task>, ...)cover boundaries with no payload; they returnResult<Unit, RopError>so the outcome still flows through the normal combinators.- Do not pass a
Task-returning lambda to the synchronousResult.Try<T>: theTaskwould become the success payload. UseResult.TryAsyncfor asynchronous work.
Result.Parse<T> and Result.TryParse<T> use the invariant culture and the static abstract IParsable<T> contract.
A parse failure echoes the offending input in the message and in Details as input=<quoted>, so the error is
self-describing without logging the raw payload separately.
Result<decimal, RopError> amount = Result.Parse<decimal>(raw, "E_AMOUNT");
// Err: Code=E_AMOUNT, Message='19,50' is not a valid Decimal., Details=input="19,50"The echo is bounded to 64 characters, truncates on a code-point boundary, and escapes quotes, backslashes and
control characters. null renders as the literal null instead of an empty pair of quotes, so "missing" and
"empty" stay distinguishable.
// Result: select + from (no where — there is no error value to produce for a predicate)
var total = from customer in GetCustomer(id)
from order in GetOrder(customer.Id)
select order.Total;
// Option: select + from + where (where is just Filter)
var total = from address in Option.FromNullable(customer.Address)
from line in Option.Some(order.Lines[0])
where line.Quantity > 0
select line.Amount;Result.Ok(null),Result.Err(null),Option.Some(null)throwArgumentNullException(input guards, programming errors).- A mapper is allowed to produce a nullable success payload:
Result<string?, RopError>. Enforce non-nullness withRequireNotNull; useOptionwhen absence is expected. Option<T>never containsnull; map nullable results withBindSomeNullable/BindSomeNullableValue.ToNullableis the only place the library hands outnullfor reference types; cross back withOption.FromNullableat the consuming boundary.- Collection helpers treat
nullas a programming error:TryFirst,TryLast,TryFindand the projectingChoosethrowArgumentNullExceptionwhen the element, stored value or mapped value isnull, instead of producingSome(null)or a null list entry. Filter the sequence or lift withOption.FromNullablefirst.
Result<Address, RopError> address =
GetOrder(id)
.MapOk(o => o.Customer.Address) // Address?
.RequireNotNull(() => RopError.Create("E_ADDRESS", "the customer has no shipping address"));- Symmetry over brevity.
MapOk/MapErr,BindOk/BindErr,TeeOk/TeeErr. There is noMap,BindorTeeshorthand; aliases are rejected by design. - Two static entry points.
Result(results and boundaries) andOption(options). Receiver-based operations are extension methods only. - No implicit conversions and no operators.
T → Result,T → Option,Result → T,Result → bool,Deconstructand|/>>are all rejected: they hide the track. - No null on the public surface. Aggregations return empty collections, never
null. - No reflection on hot paths, no boxing of the core types.
default(Option<T>)isNone;default(Result<,>)is undefined — never rely on it.
Three larger topics are deliberately caller-owned (ADR 0010):
- Arity stops at three.
Combine/Zip/Validatecover 2 and 3 inputs; larger aggregations use a record, a collection withSequenceValidation/TraverseValidation, or nested calls. - Concurrency policy stays at the call site. The async helpers are sequential or await-all; for bounded fan-out
compose
Task.WhenAll/Parallel.ForEachAsyncat the boundary. The library never throttles or creates a token source. IAsyncEnumerable<T>is composed, not wrapped. Materialize withawait foreachand pass the results toResult.SequenceAsync/SequenceValidationAsync(orOption.SequenceAsync).
Result.Ok<T>(value) Result.Err<T>(error)
Result.Ok<TOk,TErr>(value) Result.Err<TOk,TErr>(error)
TryGetOk / TryGetErr / OkOrDefault / IsOk / IsErr
MapOk / MapErr / TeeOk / TeeErr
Ensure(predicate, error) / Ensure(predicate, errorFactory)
MapOkValue / RequireNotNull
BindOk / BindErr / BindErrValue(value|factory)
Match(ok, err) / OrThrow / OkOrThrow / ErrOrThrow
Result.Combine(a, b, merger) Result.Combine(a, b, c, merger)
Result.Zip(a, b) Result.Zip(a, b, c)
Result.Validate(a, b, merger) Result.Validate(a, b, c, merger)
Result.Sequence(results) Result.SequenceValidation(results)
Result.Traverse(source, mapper) Result.TraverseValidation(source, mapper)
Result.Partition(results)
ToValidation() / ToSingleError() / ToSingleError(combine)
Result.Try(func, errorFactory) / Result.Try(func, code)
Result.Try(action, errorFactory) / Result.Try(action, code) -> Result<Unit, RopError>
Result.TryAsync(func, errorFactory, ct) / Result.TryAsync(func, code, ct)
Result.TryAsync(action, errorFactory, ct) / Result.TryAsync(action, code, ct) -> Task<Result<Unit, RopError>>
Result.Switch(func) / Result.TrySwitch(func, errorFactory)
Result.SwitchAsync(func) / Result.TrySwitchAsync(func, errorFactory)
Result.FromNullable(value, errorFactory) / Result.FromNullableValue(value, errorFactory)
Result.SequenceAsync(tasks, ct) / Result.TraverseAsync(source, mapper, ct)
Result.SequenceValidationAsync(tasks, ct) / Result.TraverseValidationAsync(source, mapper, ct)
Select / SelectMany
AsTask
MapOkAsync (sync mapper | async mapper | CT-aware async mapper) on Task<Result<,>> and Result<,>
MapErrAsync (sync mapper | async mapper | CT-aware async mapper) on Task<Result<,>> and Result<,>
BindOkAsync (async binder | CT-aware binder) on Task<Result<,>> and Result<,>
BindErrAsync(async fallback | CT-aware fallback) on Task<Result<,>> and Result<,>
TeeOkAsync / TeeErrAsync (async action | CT-aware action)
CombineAsync (2, 3) / ValidateAsync (2, 3) / ZipAsync (2, 3) / PartitionAsync
SequenceValidationAsync / TraverseValidationAsync (async | CT-aware mapper)
Select / SelectMany on Task<Result<,>> (async LINQ)
Option.Some / Option.None / Option.FromNullable / Option.FromNullableValue / Option.When
Option.Combine (2, 3) / Option.Zip (2, 3) / Option.Sequence / Option.Traverse / Option.Values
Option.CombineAsync (2, 3) / Option.ZipAsync (2, 3)
Option.SequenceAsync / Option.TraverseAsync (async | CT-aware mapper)
MapSome / TeeSome / TeeNone / Filter / MapSomeValue
BindSome / BindSomeNullable / BindSomeNullableValue / BindNone / BindNoneValue
Flatten / Match / OrThrow
RequireSome(error|factory) / ToOption / ToEither(value|factory) / ToNullable / ToNullableValue / Transpose
AsTask / MapSomeAsync (sync on Task; async | CT-aware on Option and Task)
BindSomeAsync (async | CT-aware) / BindNoneAsync (async | CT-aware)
TeeSomeAsync (2) / TeeNoneAsync (2) / FilterAsync (sync | async | CT-aware)
Select / SelectMany / Where (Option and Task sources)
TryFirst / TryLast / TryFind / Choose / Execute / ExecuteNoValue (OptionEnumerableExtensions)
SuccessIf / FailureIf (value | factory) (Result)
Result.Parse<T> / Result.TryParse<T> (invariant culture, IParsable<T>)
Switch / SwitchAsync / SwitchFirst / MatchFirst (ResultTerminalExtensions)
FailIf / ThenEnsure (ResultEnsureExtensions)
Either.Left / Either.Right / Match / MapLeft / MapRight / Swap / ToResult / ToOption
Result<,>.ToEither / Option<>.ToEither (value | factory)
Every entry below keeps an identical RopError overload; the TErr form is additive.
FromNullable<TOk,TErr>(value, errorFactory) FromNullableValue<TOk,TErr>(value, errorFactory)
SuccessIf<T,TErr>(condition, value, error|factory) FailureIf<T,TErr>(condition, value, error|factory)
RequireNotNull<T,TErr>(errorFactory) Either.ToResult<TErr>(errorFactory)
ToSingleError<T,TErr>() ToSingleError<T,TErr>(combine)
Try<T,TErr>(func, errorFactory, nullResultErrorFactory)
Try<TErr>(action, errorFactory)
TryAsync<T,TErr>(func, errorFactory, nullResultErrorFactory, ct)
TryAsync<TErr>(action, errorFactory, ct)
TrySwitch<T,TOut,TErr>(func, errorFactory, nullResultErrorFactory)
TrySwitchAsync<T,TOut,TErr>(func, errorFactory, nullResultErrorFactory)
Parse<T,TErr>(value, Func<ParseFailure,TErr>, code = "E_PARSE")
try/catcharound a combinator chain — lift the boundary withResult.Tryinstead.- Using
OrThrowin production — exit withMatch,BindErrValueorTryGet*. - Using
nullto mean failure — useErr. - Using
Optionfor domain failure that carries a code/message — useResult. - Wrapping a collection in
Option— an empty collection is already empty. - Mixing
OptionandNullable<T>inside one public signature — convert once at the boundary. - Branching on
Message— branch onCode. - Using
Validatewhere a short-circuit is required — useBindOk. - Mutating external state in
Tee*and depending on its execution order — logging and metrics only.
net10.0;net11.0,LangVersionlatest, nullable reference types enabled.IsAotCompatible, trim/AOT analyzers enabled; no reflection, no dynamic code.Result<,>andOption<>arereadonly structs with value equality; they are immutable and safe to share across threads. Combinators do not lock; user callbacks are the caller's responsibility.- Aggregations materialize once and expose
IReadOnlyList<T>backed by a read-only wrapper. - Async mapper overloads use
OverloadResolutionPrioritysox => Task<T>binds to the asynchronous overload without casts.
The library started from an early draft specification. The still-valid decisions (including C#-specific
corrections such as the ...Value suffix for nullable value-type helpers) are recorded in
docs/adr/0001-core-design-decisions.md. API additions and the
rationale for capabilities that remain deliberately out of scope are recorded in
docs/adr/0002-api-gap-remediation.md. Option asynchronous parity is
recorded in docs/adr/0003-option-async-parity.md, validation traversal
in docs/adr/0004-validation-traversal.md, lossless error aggregation in
docs/adr/0005-lossless-error-aggregation.md, asynchronous
boundaries in docs/adr/0006-async-boundaries.md, diagnostics in
docs/adr/0007-diagnostics.md, model bridging in
docs/adr/0008-model-bridges.md, the null-element policy in
docs/adr/0009-null-elements.md, the concurrency/streaming scope in
docs/adr/0010-concurrency-and-streaming-scope.md, and
custom error types in docs/adr/0011-custom-error-types.md.
Sandlada.RopSharp/ library (net10.0; net11.0)
Sandlada.RopSharp.Tests/ TUnit suite (net10.0; net11.0)
Sandlada.RopSharp.Sample/ runnable multi-scenario tour (see its README)
Sandlada.RopSharp.Benchmarks/ BenchmarkDotNet baselines
docs/ ADRs, benchmarks and the Traditional Chinese guide
CONTRIBUTING.md / SECURITY.md contribution rules and vulnerability policy
Traditional Chinese guide: docs/zh-TW/getting-started.md.
Documentation-example rules (how the XML examples are written and kept compiling):
docs/doc-examples.md.
Benchmark baselines: docs/benchmarks.md.
Verification gates (including the trim-published run): docs/verification.md.
- Contribution rules, design constraints and the public-API procedure: CONTRIBUTING.md.
- Vulnerability reporting: SECURITY.md (private advisory; please do not open a public issue).
MIT — see LICENSE.