Skip to content

feat(csharp): generate the transport options class from the Rust schema - #745

Draft
wkirschenmann wants to merge 8 commits into
wk/feat/rust-ffi-skeletonfrom
wk/feat/csharp-options-generator
Draft

wkirschenmann wants to merge 8 commits into
wk/feat/rust-ffi-skeletonfrom
wk/feat/csharp-options-generator

Conversation

@wkirschenmann

@wkirschenmann wkirschenmann commented Aug 8, 2026

Copy link
Copy Markdown
Contributor

Sits on wk/feat/rust-ffi-skeleton, to merge first.

Motivation

armonik-transport describes its flat option vocabulary as a JSON schema, and a C# caller needs a
class to fill in. A hand-written mirror would drift the day someone forgot it.

Description

A console tool under packages/csharp/tools reads the schema and writes TransportOptions:
twenty-eight options, each a string defaulting to "", documented from the schema's own
descriptions, plus ToTransportJson writing the document the transport reads. The class is the
union of the options the schema names, at its root and in every allOf/anyOf branch, deduplicated
by name with the first description winning.

No validation is generated. The anyOf groups are tautological - one branch requires nothing - so
they document the accepted shapes rather than constrain them, and the transport stays the single
validator, refusing by option name. ToTransportJson omits an empty option, a missing key reading
as the empty string, and writes its own JSON so that hosting the file costs no dependency. An option
the tool cannot spell stops it rather than reaching the compiler.

Testing

Adds 11. Five pin the generator against a committed schema snapshot and the class written from it;
the golden is compiled into the test project as well as read by it, which proves it is valid C# with
every escape closed. Five pin the document the class writes, the last walking every option by
reflection. dotnet test on the test project, all green.

The eleventh answers SonarCloud's always-true condition on the guard refusing a schema that names no
option. The condition is not always true - the suite drives 28 options through the return the
engine called dead, which its analysis does not follow through a local function capturing the list
it fills - but that branch had no test, so it was unexercised in the sense a reader cares about, and
demonstrating the path is the better answer: the test asserts the message, not the exception type
alone. Sonar's other five findings are shape rather than behaviour, a schema walk and three loops
given names and expressions, and not one emitted byte moves with them - the fixtures diff empty
across the three commits, and the golden is still reproduced from the committed fixture.

Impact

Nothing existing changes; both projects are new and IsPackable=false. They are deliberately out of
ArmoniK.Api.sln, which CI restores and jb cleanupcode rewrites before requiring a clean diff,
against a golden compared byte for byte; the <auto-generated> banner is meant to keep the
reformatter off it, but nothing here proves it does. They join the solution with the gate.

Additional Information

The fixture is a snapshot recording no Rust revision it came from, and nothing in dotnet test
compares it to the live schema - the golden class is generated from the fixture, not from the Rust
source. The freshness gate arrives with the PR that commits the real schema.

Checklist

  • My code adheres to the coding and style guidelines of the project.
  • I have performed a self-review of my code.
  • I have commented my code, particularly in hard-to-understand areas.
  • I have made corresponding changes to the documentation. (none yet; the tool is not wired in)
  • I have thoroughly tested my modifications and added tests when necessary.
  • Tests pass locally and in the CI. (locally yes; no CI job builds these projects)
  • I have assessed the performance impact of my modifications. (a build-time generator)

@github-actions

github-actions Bot commented Aug 8, 2026

Copy link
Copy Markdown

☂️ Python Coverage

current status: ✅

Overall Coverage

Lines Covered Coverage Threshold Status
1478 1247 84% 0% 🟢

New Files

No new covered files...

Modified Files

No covered modified files...

updated for commit: 9977bff by action🐍

@wkirschenmann
wkirschenmann force-pushed the wk/feat/csharp-options-generator branch 2 times, most recently from f230436 to ef0fd75 Compare August 9, 2026 10:40
The armonik-transport crate describes its flat option vocabulary as a JSON schema. A
console tool reads that schema and writes TransportOptions, the class a C# caller
fills in: every option a string defaulting to "", documented from the schema's own
descriptions, and ToTransportJson writing the document the transport reads. The schema
is an argument, so the tool is not tied to where that file eventually lives.

The class is the union of the options the schema names, those at its root and those in
every allOf/anyOf branch, deduplicated by name with the first description winning. A
name really does come back with a different description, and the first describes the
option rather than a shape.

No validation is generated. The anyOf groups are tautological, one branch requiring
nothing, so every document satisfies the group; they document the accepted shapes
rather than constrain them, and the transport stays the single validator, refusing by
option name. ToTransportJson leaves an empty option out, since the schema requires
none of them and a missing key reads as the empty string, and it writes its own JSON
so that hosting the generated file costs no serialisation dependency.

The output is one byte sequence on any platform: newlines, no byte order mark, and a
licence year that is a literal rather than the clock, so that regenerating and diffing
is a check. The file opens with an auto-generated banner, so that a reformatter leaves
those bytes alone.

An option the tool cannot spell stops it rather than reaching the compiler: a value
typed as anything but a string is a change of contract, and a name that is not
PascalCase is not the vocabulary's and may be a C# keyword.

Verification, from the worktree root, on a tree cleaned of bin and obj:

  dotnet build packages/csharp/tools/ArmoniK.Api.TransportOptionsGenerator
  0 Warning(s), 0 Error(s)

  cd packages/rust && cargo run -q -p armonik-transport --features schema \
    --example generate_schema > $TMP/tree.schema.json
  dotnet run --project packages/csharp/tools/ArmoniK.Api.TransportOptionsGenerator -- \
    --schema $TMP/tree.schema.json --output $TMP/gen1.cs
  385 lines, 28 options; a second run into gen2.cs compares equal

  --help                                          exit 0, the usage
  --schema x                                      exit 1, both arguments named
  --schema <a file that is not a schema> ...      exit 2
  --schema <the schema> --output no/such/dir/x    exit 3
The generator has nothing to check it against until the schema it reads is committed
somewhere. A snapshot of that schema and the class written from it live next to the
tests, so regenerating and comparing is a check that runs today: the same schema has
to give the same bytes, and the bytes are in the tree to compare with.

The snapshot is not the file the FFI headers will carry. The gate that regenerates the
class from that file, and the CI job that runs it, come with the PR that commits it.

The golden TransportOptions.cs is compiled into the test project as well as read by
it, which is what proves it is valid C#, in the namespace asked for, with every escape
closed. The two fixtures keep their newlines through checkout, since a comparison of
bytes is one a platform must not decide.

Twenty-eight options, and one name that the schema describes twice: ProxyAddress
documents a credential-free URL in one proxy branch and a URL carrying its credentials
in the other. The first wins, and a test says so, because that is the rule a reader
would otherwise have to infer from the golden file.

Verification, from the worktree root, on a tree cleaned of bin and obj:

  cd packages/rust && cargo run -q -p armonik-transport --features schema \
    --example generate_schema | diff - \
    ../csharp/tools/ArmoniK.Api.TransportOptionsGenerator.Tests/Fixtures/http_config.schema.json
  no difference: the fixture is the schema of this tree

  dotnet run --project packages/csharp/tools/ArmoniK.Api.TransportOptionsGenerator -- \
    --schema  packages/csharp/tools/.../Fixtures/http_config.schema.json \
    --output  packages/csharp/tools/.../Fixtures/TransportOptions.cs
  git diff --exit-code
  clean: the committed class is the one this schema gives

  dotnet test packages/csharp/tools/ArmoniK.Api.TransportOptionsGenerator.Tests
  Passed! Failed: 0, Passed: 5, Skipped: 0, Total: 5
Comparing the generated file with a golden copy says the generator is reproducible. It
says nothing about whether the class in it writes a document the transport can read,
and that is the half a caller depends on.

So the tests set options on the class and read the document back: nothing set gives an
empty object, two options give those two and no empty third, and a value carrying a
quote, a backslash and a control character comes back through a parser unchanged. The
last of those walks every option by reflection rather than naming a few, since an
escape that closes its string early or swallows a character is worth catching wherever
it happens, and since a test naming options has to be edited every time the vocabulary
grows.

Verification, from the worktree root, on a tree cleaned of bin and obj:

  dotnet test packages/csharp/tools/ArmoniK.Api.TransportOptionsGenerator.Tests
  Passed! Failed: 0, Passed: 10, Skipped: 0, Total: 10

The Rust side accepts what the class writes, checked by hand: a TransportOptions
carrying sixteen options, ProxyPassword among them spelling a quote and a backslash,
emitted

  {"AllowUnsafeConnection":"true","BackOffMultiplier":"1.5","ConnectTimeout":"30s",
   "Endpoint":"http://localhost:5001","Http2KeepAliveInterval":"20s",
   "InitialBackOff":"1s","MaxAttempts":"3","MaxBackOff":"5s","PoolIdleTimeout":"90s",
   "RateLimit":"100/1s","TcpKeepalive":"30s","Timeout":"45s",
   "UserAgent":"armonik-csharp/1.0","ProxyAddress":"http://proxy.example:3128",
   "ProxyPassword":"p\"a\\ss","ProxyUsername":"user"}

on one line, which a throwaway example deserialising through serde_json read back as
an HttpConfig: endpoint, connect_timeout 30s, timeout 45s, pool_idle_timeout 90s,
user_agent, max_attempts 3, and the proxy with its username and its redacted password.
The generator throws rather than write a class with no property, since a schema that
names nothing produces an options class a caller cannot use and cannot debug. Nothing
said so, and a reader had only the line itself to go on.

The test names the branch and its message, so the refusal is a decision on record
rather than a guard nobody has exercised.

Verification, from the worktree root, on a tree cleaned of bin and obj:

  dotnet test packages/csharp/tools/ArmoniK.Api.TransportOptionsGenerator.Tests
  Passed! Failed: 0, Passed: 11, Skipped: 0, Total: 11
Reading the options did two things at once: it walked the schema for the places that
name options, and it turned each name it found into a property. The walk was a local
function mutating a list its caller owned, so neither half could be read without the
other.

The walk is now PropertyBags, which yields every properties object the schema holds in
the order it holds them, and reading the options is the loop over what it yields:
refuse a name that is not the vocabulary's, refuse a value that is not text, keep the
first description of a name that comes back. Whether a bag comes from the root, from
an allOf entry or from one of its anyOf branches stops mattering at the point where it
never mattered.

The generated file does not move; the two helpers named along the way, IsText and
Description, are the conditions that were already there, spelled where they can be
read.

Verification, from the worktree root, on a tree cleaned of bin and obj:

  dotnet build packages/csharp/tools/ArmoniK.Api.TransportOptionsGenerator
  0 Warning(s), 0 Error(s)

  dotnet run --project packages/csharp/tools/ArmoniK.Api.TransportOptionsGenerator -- \
    --schema  packages/csharp/tools/.../Fixtures/http_config.schema.json \
    --output  packages/csharp/tools/.../Fixtures/TransportOptions.cs
  git diff --exit-code packages/csharp/tools/.../Fixtures
  clean: the golden file is byte for byte the one this tree already carries

  dotnet test packages/csharp/tools/ArmoniK.Api.TransportOptionsGenerator.Tests
  Passed! Failed: 0, Passed: 11, Skipped: 0, Total: 11
Three loops that each hid their subject one layer down.

The loop writing the ToTransportJson calls reads a name out of every option and never
touches the description, so it iterates the names.

The check on an option name walked the string to find a character that disqualifies
it, which is the shape of All spelled the long way.

The command line moved its index in the loop header and again in the body, so an
option taking a value had to be read as an exception to the loop's own rule. A while
over one index moving in one place says it instead: reading an argument consumes it,
twice where an option carries a value.

No behaviour moves with them. The generated file is byte for byte the one already
committed, and the command line answers as before, down to naming '--schema' as
unexpected when it is the last argument and its value is missing.

Verification, from the worktree root, on a tree cleaned of bin and obj:

  dotnet build packages/csharp/tools/ArmoniK.Api.TransportOptionsGenerator
  0 Warning(s), 0 Error(s)

  dotnet run --project packages/csharp/tools/ArmoniK.Api.TransportOptionsGenerator -- \
    --schema  packages/csharp/tools/.../Fixtures/http_config.schema.json \
    --output  packages/csharp/tools/.../Fixtures/TransportOptions.cs
  git diff --exit-code packages/csharp/tools/.../Fixtures
  clean

  dotnet test packages/csharp/tools/ArmoniK.Api.TransportOptionsGenerator.Tests
  Passed! Failed: 0, Passed: 11, Skipped: 0, Total: 11

  --help 0, --schema alone 1, --bogus 1, an output directory that does not exist 3
Generator holds a Description helper and, nested in it, an Option whose positional
Description property hides that helper inside the record's own scope. Two names for
two different things, one of which cannot be reached from where the other lives.

The property keeps its name: it is the option's description, the emission code reads
it as option.Description, and there is nothing else it could be called. The helper is
the newcomer, and what it does is read a description out of a schema property, so it
says so, next to ReadOptions which reads the options out of a schema.

Verification, from the worktree root, on a tree cleaned of bin and obj:

  dotnet build packages/csharp/tools/ArmoniK.Api.TransportOptionsGenerator
  0 Warning(s), 0 Error(s)

  dotnet run --project packages/csharp/tools/ArmoniK.Api.TransportOptionsGenerator -- \
    --schema  packages/csharp/tools/.../Fixtures/http_config.schema.json \
    --output  packages/csharp/tools/.../Fixtures/TransportOptions.cs
  git diff --exit-code packages/csharp/tools/.../Fixtures
  clean: not one emitted byte moves with the name

  dotnet test packages/csharp/tools/ArmoniK.Api.TransportOptionsGenerator.Tests
  Passed! Failed: 0, Passed: 11, Skipped: 0, Total: 11
@wkirschenmann
wkirschenmann force-pushed the wk/feat/csharp-options-generator branch from ef0fd75 to 2857db1 Compare August 9, 2026 10:52
The option vocabulary spells the Certificate Authority option CaCertPath, and the
snapshot the generator reads still spelled it CaCert. A caller filling in the class
would have named an option the transport does not have, and the transport would have
gone looking for a CA it was never given.

The suite did not notice, and cannot: the golden class is generated from the committed
snapshot, so the two agree with each other whatever the vocabulary says. Only a run
against the schema this tree prints tells them apart, which is what the snapshot's own
verification recipe does and what the gate on the committed schema will do.

Twenty-eight options still, one renamed, none added and none removed. Three of the
schema's descriptions are rewritten too, but all three describe an anyOf branch rather
than an option, and a branch documents a shape the generator deliberately does not
carry: the class documents options. So the class moves on the rename alone, in the
property and in the call that writes it.

Verification, from the worktree root, on a tree cleaned of bin and obj:

  cd packages/rust && cargo run -q -p armonik-transport --features schema \
    --example generate_schema | diff - \
    ../csharp/tools/ArmoniK.Api.TransportOptionsGenerator.Tests/Fixtures/http_config.schema.json
  no difference: the fixture is the schema of this tree

  dotnet build packages/csharp/tools/ArmoniK.Api.TransportOptionsGenerator
  0 Warning(s), 0 Error(s)

  dotnet run --project packages/csharp/tools/ArmoniK.Api.TransportOptionsGenerator -- \
    --schema  packages/csharp/tools/.../Fixtures/http_config.schema.json \
    --output  packages/csharp/tools/.../Fixtures/TransportOptions.cs
  git diff --exit-code
  clean: the committed class is the one this schema gives

  dotnet test packages/csharp/tools/ArmoniK.Api.TransportOptionsGenerator.Tests
  Passed! Failed: 0, Passed: 11, Skipped: 0, Total: 11

The transport reads what the class writes, checked by hand: a TransportOptions setting
seventeen options, CaCertPath among them, emitted

  {"AllowUnsafeConnection":"true","BackOffMultiplier":"1.5",
   "CaCertPath":"<a PEM certificate>","ConnectTimeout":"30s",
   "Endpoint":"http://localhost:5001","Http2KeepAliveInterval":"20s",
   "InitialBackOff":"1s","MaxAttempts":"3","MaxBackOff":"5s","PoolIdleTimeout":"90s",
   "RateLimit":"100/1s","TcpKeepalive":"30s","Timeout":"45s",
   "UserAgent":"armonik-csharp/1.0","ProxyAddress":"http://proxy.example:3128",
   "ProxyPassword":"p\"a\\ss","ProxyUsername":"user"}

on one line, which a throwaway example deserialising through serde_json read back as
an HttpConfig with ca_cert set, alongside the timeouts, the retry policy and the proxy
credentials.

CaCertPath has to name a file that exists: reading the configuration reads the PEM and
parses it there and then, rather than at connection time, so a path that resolves
nowhere is refused while the document is being read. The round trip points it at a
certificate the repository already carries for its own tests.
@sonarqubecloud

sonarqubecloud Bot commented Aug 9, 2026

Copy link
Copy Markdown

wkirschenmann added a commit that referenced this pull request Aug 12, 2026
`github.head_ref` is a branch name, so `actions/checkout` resolves it to
wherever the branch stands when the step runs rather than to the commit the
run was triggered on. A run that waits in the queue while its branch moves
therefore builds, and names, a commit that is not its own head.

Seen on #745:

  08:50:17  push 6df26e6, run 31304468328 starts
  08:55:55  push f230436, run 31304689662 starts
  09:08:46  run 31304468328 reaches its Context job, resolves
            3.30.0-wkfeatcsharpoptionsgenerator.77.f2304365 - a commit that
            is not its head - and pushes it to nuget.org
  09:09:11  run 31304689662, whose head really is f230436, resolves the
            same version and fails with 409 Conflict on ArmoniK.Api.Core

The failed checks are cosmetic. What is not is that a run reported against
6df26e6 built and published f230436, so the run says nothing about the
commit it is attached to.

`github.event.pull_request.head.sha` is the head at the moment the event was
delivered, and actions/checkout treats a 40-hex `ref` as a commit and checks
it out detached. The C# and npm jobs move with the context job rather than
being left behind: the version now names one specific commit, and those jobs
have to build that one.

Detached breaks the version generator, which reads `git rev-parse
--abbrev-ref HEAD` (`config.to` in
@aneoconsultingfr/generate-next-version@0.0.5) both as the pre-release label
and as the end of the commit range it counts, and detached reads `HEAD`.
The generator takes no argument or environment variable for it: the CLI
declares only --language, --base and --edge, and cac rejects anything else.
So the branch name is put back on the pinned commit with `git checkout -B`.

Verified against the real generator on a synthetic repository tagged 3.29.3,
with a branch whose tip has moved one commit past the pin:

  checkout the branch name  3.30.0-wkfeatcsharpoptionsgenerator.3.b44c3b9
  pinned SHA, detached      3.30.0-head.2.2acb0df
  pinned SHA + checkout -B  3.30.0-wkfeatcsharpoptionsgenerator.2.2acb0df

Only the last one names the pinned commit, and its commit count is the
pinned commit's too. Re-run in the shape CI produces - fetch
`+refs/heads/*:refs/remotes/origin/*` and `+refs/tags/*:refs/tags/*`, then
force-checkout the SHA - where the tags the generator needs are present and
`checkout -B` creates the local branch at the pinned commit rather than
following `origin/<branch>`, leaving the working tree untouched.

`fetch-depth: 0` is what keeps the tags available: actions/checkout 4.2.2
fetches every branch and every tag whenever the depth is 0, whatever `ref`
holds, and refetches the bare SHA if it is not reachable - which is also why
this works for a pull request from a fork, where `github.head_ref` names no
branch of the base repository at all.

Not verified: the workflow itself, which cannot be run from here. YAML and
workflow schema checked with @action-validator/cli.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant