Skip to content

feat(csharp): add C# support to verification and code generation#252

Closed
apoorv7g wants to merge 2 commits into
accordproject:mainfrom
apoorv7g:csharp-update
Closed

feat(csharp): add C# support to verification and code generation#252
apoorv7g wants to merge 2 commits into
accordproject:mainfrom
apoorv7g:csharp-update

Conversation

@apoorv7g

Copy link
Copy Markdown
Contributor

C# Code Generation and Verification

This document describes the C# verification work introduced in commit fcaabbf (feat(csharp): add C# support to verification and code generation). It explains what changed, why each decision was made, and how the pieces fit together.

The goal is to guarantee that generated C# from Concerto models compiles successfully under .NET 8, using the same verification pattern already established for TypeScript (tsc) and JSON Schema (ajv).


Table of contents

  1. High-level summary
  2. Verification architecture
  3. CSharpVisitor changes
  4. Local Mocha verification tests
  5. Docker-based verification (CI)
  6. Verify.csproj template
  7. CI workflow and npm scripts
  8. Snapshot test updates
  9. Running verification locally
  10. Known limitations and follow-ups

High-level summary

Area What was added or changed
Codegen CSharpVisitor gained optional Concerto runtime emission, C# reserved-keyword escaping in namespaces, and fixes for DateTime/enum defaults
Local tests test/verification/csharp.compile.test.js - generates C# and runs dotnet build for 7 model fixtures
Docker New verification/docker/csharp/ image; base image symlink fix so CLI uses checkout codegen
Templates verification/templates/Verify.csproj - minimal net8.0 project for compile checks
CI .github/workflows/verify-codegen.yml matrix extended with csharp
Scripts npm run verify:docker:csharp and related entries in package.json

C# as a codegen target already existed in this repository (see prior commits such as #231, #234, #236). This change adds automated verification so regressions are caught in CI and locally, mirroring the TypeScript/JSON Schema pipeline from PR #246.


Verification architecture

Verification follows a generate → compile pipeline:

Concerto model (CTO / metamodel)
        │
        ▼
   CSharpVisitor
        │
        ▼
   *.cs files + Verify.csproj
        │
        ▼
   dotnet build  ──► pass / fail

There are two entry points that share concepts but differ in how codegen is invoked:

Path Codegen invoked via When it runs
Mocha (test/verification/csharp.compile.test.js) Direct CSharpVisitor import from lib/ npm run test:verify, pre-push local dev
Docker (verification/docker/csharp/entrypoint.sh) concerto compile --target CSharp CLI npm run verify:docker:csharp, GitHub Actions

Both paths copy the same Verify.csproj template and run dotnet build.

Shared test cases

Model fixtures are defined once in test/verification/cases.js:

Case Models Notes
metamodel Concerto metamodel CTO Same as concerto compile --metamodel
hr_base hr_base.cto Includes namespace segment base (C# keyword)
hr_integration hr_base.cto + hr.cto Cross-namespace imports, maps, relationships
stringlength stringlength.cto Validator decorators
model-base model-base.cto Base Concerto types
agreement agreement.cto Real-world-style model
circular circular.cto Circular type references

Environment flags applied to all cases (via applyVerificationEnv()):

  • ENABLE_MAP_TYPE=true
  • IMPORT_ALIASING=true

Per-target skips are supported via testCase.skip (string or { csharp: 'reason' }).

Docker corpus (currently smaller)

verification/corpus/manifest.json currently lists only the metamodel case. Docker CI validates the CLI path against the largest built-in model. The Mocha suite exercises the broader fixture set. Expanding the Docker corpus to mirror cases.js is a natural follow-up.


CSharpVisitor changes

File: lib/codegen/fromcto/csharp/csharpvisitor.js

These changes support both production codegen (with optional Concerto .NET runtime) and verification (standalone POJOs that compile without external NuGet packages).

1. Optional Concerto runtime (useConcertoRuntime)

Problem: Generated C# previously always emitted attributes and types from the AccordProject.Concerto NuGet package:

  • [AccordProject.Concerto.Type(...)]
  • [AccordProject.Concerto.Identifier()]
  • [AccordProject.Concerto.ConcertoConverterFactorySystem]
  • using AccordProject.Concerto.Metamodel for concerto.decorator types

Verification builds use a bare .csproj with no package references. Referencing those types caused hundreds of CS0234 errors.

Solution: Introduced useConcertoRuntime(parameters):

useConcertoRuntime(parameters) {
    if (parameters.useConcertoRuntime !== undefined) {
        return !!parameters.useConcertoRuntime;
    }
    return process.env.CSHARP_USE_CONCERTO_RUNTIME !== 'false';
}
Mode How enabled Output
Runtime on (default for CLI users) Default, or useConcertoRuntime: true Full Concerto attributes, runtime JSON converters
Runtime off (verification) useConcertoRuntime: false or CSHARP_USE_CONCERTO_RUNTIME=false Plain C# classes; still uses System.Text.Json attributes where applicable

Runtime-gated emission sites:

  • Class-level [AccordProject.Concerto.Type] and converter attributes
  • Field-level [AccordProject.Concerto.Identifier()]
  • concerto.decorator namespace → AccordProject.Concerto.Metamodel mapping
  • @DotNetNamespace decorator handling (see below)

Why default to runtime on: Existing CLI and production consumers expect Concerto-aware output. Verification explicitly opts out rather than changing the default developer experience.

2. @DotNetNamespace decorator scope

Before: If a model file had @DotNetNamespace("..."), that value was always used as the .NET namespace.

After: The decorator is honoured only when useConcertoRuntime is true. In verification mode, namespaces are derived from the Concerto namespace via toCSharpNamespace(), keeping output predictable and independent of decorator metadata that may target the runtime package layout.

3. C# reserved keywords in namespace segments (toCase)

Problem: The Concerto namespace org.acme.hr.base contains base, a C# keyword. namespace org.acme.hr.base; is invalid C#.

Solution: In toCase(), non-PascalCase namespace components that match reservedKeywords are prefixed with _:

org.acme.hr.base  →  org.acme.hr._base   (C# namespace)
org.acme.hr.base  →  org.acme.hr.base    (unchanged in Concerto metadata, e.g. Type attribute strings)

Important distinction:

  • C# namespace / using / qualified types use escaped names (_base)
  • Concerto metadata (Namespace = "org.acme.hr.base", $class FQNs) keep the original Concerto namespace

Snapshot updates in test/codegen/__snapshots__/codegen.js.snap reflect this for the hr_base / hr_integration fixtures.

4. Unresolved import namespaces

When an imported namespace cannot be resolved to a model file, the using directive now applies toCSharpNamespace() so keyword escaping is consistent:

`using ${namespacePrefix}${this.toCSharpNamespace(namespace, parameters)};`

5. DateTime default literals

Problem: DateTime field defaults were emitted as raw strings, which is not valid C# for System.DateTime properties.

Solution: formatDefaultLiteral() now handles DateTime:

// scalar struct
new(System.DateTime.Parse("2020-01-01"))
// plain property
System.DateTime.Parse("2020-01-01")

6. Enum default literals

Problem: Enum defaults used camelCase for member names, which could produce invalid or inconsistent C# enum member references.

Solution: Enum default values now use toCSharpIdentifier() for both the enum type name and member name, respecting PascalCase settings and keyword escaping consistently with the rest of the visitor.


Local Mocha verification tests

File: test/verification/csharp.compile.test.js

Design

Mirrors test/verification/typescript.compile.test.js:

  1. Load each case from cases.js into a ModelManager
  2. Run CSharpVisitor with FileWriter into a temp directory
  3. Copy Verify.csproj into that directory
  4. Run dotnet build Verify.csproj --nologo -v q
  5. Fail the test with compiler output on error

Key parameters

modelManager.accept(new CSharpVisitor(), {
    fileWriter: new FileWriter(outputDir),
    useConcertoRuntime: false,  // no AccordProject.Concerto package required
    ...visitorOptions,
});

.NET SDK detection

Tests gracefully skip when .NET 8 is not installed locally:

  • Checks dotnet --version
  • Checks dotnet --list-sdks for an 8.x SDK
  • Emits a console warning and uses it.skip rather than failing the suite

This avoids blocking contributors who work on non-C# parts of the repo without the SDK installed.

Timeout

Suite timeout is 120000 ms (2 minutes) because dotnet build on first run may restore tooling and compile multiple files.


Docker-based verification (CI)

Docker verification ensures the Concerto CLI path (concerto compile --target CSharp) produces compilable output, not just the programmatic visitor API used in Mocha.

File layout

verification/
├── corpus/
│   └── manifest.json          # Docker case list (metamodel only today)
├── docker/
│   ├── base/
│   │   ├── Dockerfile         # Node + concerto-cli + checkout symlink
│   │   └── run-case.sh        # Runs concerto compile for one case
│   ├── csharp/
│   │   ├── Dockerfile         # Adds dotnet8-sdk, sets CSHARP_USE_CONCERTO_RUNTIME=false
│   │   └── entrypoint.sh      # Generate all cases, dotnet build each
│   └── targets.json           # Target metadata (cli name, tool, base image hint)
├── templates/
│   └── Verify.csproj
└── work-csharp/               # Output mount (gitignored)

C# target Dockerfile

ARG BASE_IMAGE=concerto-verify-base:local
FROM ${BASE_IMAGE}

ENV CSHARP_USE_CONCERTO_RUNTIME=false

RUN apk add --no-cache dotnet8-sdk

Why Alpine + dotnet8-sdk package: Keeps the image small while matching the net8.0 target in Verify.csproj. The base image is Node Alpine; C# verification adds only the SDK layer.

Why CSHARP_USE_CONCERTO_RUNTIME=false in the image: Docker uses the CLI, which does not pass useConcertoRuntime: false as a parameter. The environment variable is the supported override for CLI-driven builds.

C# entrypoint flow

For each case in manifest.json:

  1. run-case.sh metamodel CSharp csharp /work/metamodel
  2. Skip if no .cs files were generated
  3. Copy Verify.csproj into the output directory
  4. dotnet build /work/metamodel/Verify.csproj --nologo -v q

Base Dockerfile: checkout codegen symlink (critical fix)

The base image installs @accordproject/concerto-cli globally and symlinks @accordproject/concerto-codegen to the PR checkout at /opt/concerto-codegen.

Problem discovered during development: Symlinking only the global node_modules/@accordproject/concerto-codegen is insufficient. Node resolves dependencies from concerto-cli's nested node_modules first, so the CLI was still using the bundled 4.1.x visitor (which always emitted runtime attributes).

Fix: Replace both copies:

RUN npm install -g @accordproject/concerto-cli \
    && GLOBAL_ROOT="$(npm root -g)" \
    && CODEGEN_LINK=/opt/concerto-codegen \
    && rm -rf "$GLOBAL_ROOT/@accordproject/concerto-codegen" \
    && ln -s "$CODEGEN_LINK" "$GLOBAL_ROOT/@accordproject/concerto-codegen" \
    && CLI_CODEGEN="$GLOBAL_ROOT/@accordproject/concerto-cli/node_modules/@accordproject/concerto-codegen" \
    && rm -rf "$CLI_CODEGEN" \
    && ln -s "$CODEGEN_LINK" "$CLI_CODEGEN"

This ensures PRs test the branch under review, not the published npm package - the same intent as the original TypeScript/JSON Schema verification design.

scripts/verification/docker-run.js

Extended TARGETS array:

const TARGETS = ['typescript', 'jsonschema', 'csharp'];

Builds base image → target image → runs container with corpus and work volume mounts. Supports filtering: node scripts/verification/docker-run.js csharp.

verification/docker/targets.json

Metadata describing each verification target (CLI name, toolchain, verify command). Not consumed by build scripts today - it documents the intended layout for humans and future automation.

All targets share the same Node base image (concerto-verify-base, built from node:20-alpine). Target-specific tools are added in per-target Dockerfiles:

Target Extra toolchain Verify command
typescript npm install -g typescript tsc --noEmit
jsonschema npm install -g ajv-cli ajv compile
csharp apk add dotnet8-sdk dotnet build

C# entry:

"csharp": {
    "cli": "CSharp",
    "baseImage": "node:20-alpine",
    "toolInstall": "apk add --no-cache dotnet8-sdk",
    "tool": "dotnet build",
    "type": "compiled"
}

Why not mcr.microsoft.com/dotnet/sdk:8.0-alpine? An earlier draft listed the official Microsoft SDK image as the C# baseImage. That would require a separate image without Node/concerto-cli, or a much larger multi-stage setup. The implemented design extends the shared Node base (same as TypeScript and JSON Schema) and installs .NET via Alpine's dotnet8-sdk package for layer reuse and a single concerto-cli install. baseImage reflects that shared foundation; toolInstall records how .NET is added.


Verify.csproj template

File: verification/templates/Verify.csproj

Minimal SDK-style project used only to answer: does the generated C# compile?

<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <TargetFramework>net8.0</TargetFramework>
    <Nullable>disable</Nullable>
    <ImplicitUsings>enable</ImplicitUsings>
    <TreatWarningsAsErrors>false</TreatWarningsAsErrors>
    <NoWarn>$(NoWarn);CS8618;CS8632</NoWarn>
  </PropertyGroup>
</Project>

Design decisions

Setting Value Reason
TargetFramework net8.0 Matches current LTS tooling; Alpine package dotnet8-sdk
Nullable disable Generated models are not fully nullable-annotated; avoids CS8618 flood
NoWarn CS8618 suppressed Non-nullable property warnings when nullable is enabled
NoWarn CS8632 suppressed Generated code uses string? on optional fields; with nullable disabled, ? annotations trigger CS8632
No NuGet packages - Verification tests standalone codegen; runtime package is opt-in for production
TreatWarningsAsErrors false Verification checks compile success, not warning-free code

Why not add AccordProject.Concerto NuGet for verification? That would only validate the runtime-enabled path. The dual-mode useConcertoRuntime design intentionally supports both standalone POJOs and runtime-integrated output; verification focuses on the standalone path because it has no external dependencies and catches the widest set of C# syntax errors.


CI workflow and npm scripts

GitHub Actions (.github/workflows/verify-codegen.yml)

Matrix job verify-${{ matrix.target }} now includes:

matrix:
  target:
    - typescript
    - jsonschema
    - csharp

Each target:

  1. Builds the shared base Docker image (cached via GHA)
  2. Builds the target-specific Dockerfile with BASE_IMAGE build arg
  3. Runs the container with corpus + work volume mounts
  4. Uploads verification/work-<target>/ artifacts on failure

fail-fast: false so one target failing does not cancel the others.

npm scripts (package.json)

"test:verify": "mocha test/verification --timeout 60000",
"verify:docker": "node scripts/verification/docker-run.js",
"verify:docker:csharp": "node scripts/verification/docker-run.js csharp"

(verify:docker:typescript and verify:docker:jsonschema were already present.)


Snapshot test updates

File: test/codegen/__snapshots__/codegen.js.snap

Updated C# snapshots for the HR fixtures after reserved-keyword namespace escaping:

Before After
namespace org.acme.hr.base; namespace org.acme.hr._base;
using org.acme.hr.base; using org.acme.hr._base;
org.acme.hr.base.Address org.acme.hr._base.Address

Concerto metadata strings (Namespace = "org.acme.hr.base", _class FQNs) are unchanged.

Note: When updating snapshots, run the full format test or apply targeted edits. Running mocha --updateSnapshot with --grep on a single format can remove snapshots for tests that did not execute.


Running verification locally

Mocha (requires .NET 8 SDK)

npm run test:verify
# or C# only:
npx mocha test/verification/csharp.compile.test.js --timeout 120000

Docker (requires Docker)

npm run verify:docker:csharp
# or all targets:
npm run verify:docker

Rebuild images after changing verification/docker/base/Dockerfile or Verify.csproj.

Manual CLI check

# Runtime-off (verification style)
CSHARP_USE_CONCERTO_RUNTIME=false concerto compile --target CSharp --output ./output --metamodel --offline

# Runtime-on (default production style)
concerto compile --target CSharp --output ./output --model path/to/model.cto

Known limitations and follow-ups

  1. Two codegen invocation paths - Mocha passes useConcertoRuntime: false explicitly; Docker relies on CSHARP_USE_CONCERTO_RUNTIME=false. Both must stay aligned.

  2. Runtime-enabled path is not compile-verified - Adding AccordProject.Concerto to Verify.csproj would enable testing runtime output separately; not in scope for initial verification.

  3. Nullable warnings suppressed, not fixed in codegen - Optional fields emit ? annotations regardless of project nullable setting. A future improvement could align codegen nullable syntax with project settings.

Author Checklist

  • Ensure you provide a DCO sign-off for your commits using the --signoff option of git commit.
  • Vital features and changes captured in unit and/or integration tests
  • Commits messages follow AP format
  • Extend the documentation, if necessary - will be done in a later PR
  • Merging to main from fork:branchname

apoorv7g added 2 commits June 21, 2026 16:56
- Introduced C# as a new target for code generation and verification processes.
- Updated Docker configurations to include C# build and verification steps.
- Enhanced the CSharpVisitor to support new features and ensure compatibility with the Concerto runtime.
- Added tests to verify C# code generation and compilation using dotnet.
- Updated package.json and workflows to integrate C# verification into the existing framework.

This commit expands the capabilities of the project by enabling C# code generation and verification, ensuring a consistent development experience across multiple languages.

Signed-off-by: Apoorv <130035517+APOORV7G@users.noreply.github.com>
- Changed the base image for the C# target from the .NET SDK to Node.js.
- Added installation command for the .NET SDK using Alpine package manager.

This update ensures the C# build environment is correctly set up for verification processes.

Signed-off-by: Apoorv <130035517+APOORV7G@users.noreply.github.com>
@mttrbrts

Copy link
Copy Markdown
Member

You're making good progress @apoorv7g, although I think that I've misled you on the "Concerto Runtime" aspect.

There's a difference between using the metamodel type definitions, and requiring the dotnet (de)serialization logic.
The concerto-dotnet package also contains copies of the metamodel type definitions, but they can also be generated from source using this codegen package.

For example, look at the concerto-playground, and see how many of the code targets actually include multiple files.
https://concerto-playground.accordproject.org/?view=csharp&headless=true

In the csharp generator we shouldn't branch based on the presence of the runtime, it's up to the bundling application whether they want to use the runtime version of the metamodel, or just generate it again locally. Either way the csharp output should be the same?

@ekarademir @DianaLease what are your thoughts?

I also spotted a couple of breaking changes to the csharp output, so we'll need a major version bump when this is released.

@apoorv7g

Copy link
Copy Markdown
Contributor Author

@mttrbrts Got it, Thanks. A follow up, For C# verification, should we compile generated output with the AccordProject.Concerto NuGet reference, generate all files including metamodel (like playground), or both and should we remove useConcertoRuntime entirely?

And how do I go about it, in what direction do you want me proceed ? I am a little lost.

@ekarademir

Copy link
Copy Markdown
Contributor

@apoorv7g I thought we discussed that if there are compile errors, we'd keep the errors for now and get the CI pipeline running with errors, not blocking, and address the errors later on. Was that not possible?

@ekarademir ekarademir left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Let's try to get the pipeline in place even if the compilation fails. Fixing the compilation errors won't be easy it looks like. So let's remove the "fixes" and just add the pipeline, making it non-blocking for merges.

if (!otherModelFile) {
// Couldn't resolve the other model file.
parameters.fileWriter.writeLine(0, `using ${namespacePrefix}${namespace};`);
parameters.fileWriter.writeLine(0, `using ${namespacePrefix}${this.toCSharpNamespace(namespace, parameters)};`);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Was this a bug?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

It was not a bug, it was additional check added for verification which enabled compilation without NuGet Package. The NuGet Package when used to compile the generated C# code gave the below errors:

error CS0234: The type or namespace name 'Type' does not exist in the namespace 'AccordProject.Concerto'
error CS0234: The type or namespace name 'ConcertoConverterFactorySystem' does not exist in the namespace 'AccordProject.Concerto'

Now I don't exactly know the root cause so as a workaround I added the check.

Comment thread lib/codegen/fromcto/csharp/csharpvisitor.js
* @returns {boolean} true when Concerto runtime attributes and namespaces should be emitted
* @private
*/
useConcertoRuntime(parameters) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Looking at how this switch is being used, I'm not sure useConcertoRuntime is the correct naming here. You are removing references to the metamodel types. Emitted types by this library should work with those types.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Okay, I understand, we can change the name. For verification, what’s the preferred setup? generate all metamodel files like playground, add a pinned NuGet reference, or both?

@apoorv7g

apoorv7g commented Jun 23, 2026

Copy link
Copy Markdown
Contributor Author

@apoorv7g I thought we discussed that if there are compile errors, we'd keep the errors for now and get the CI pipeline running with errors, not blocking, and address the errors later on. Was that not possible?

@ekarademir Apologies, Based on our discussion, my interpretation was to skip the six non necessary .cto files and just make sure Metamodel works.

Let's try to get the pipeline in place even if the compilation fails. Fixing the compilation errors won't be easy it looks like. So let's remove the "fixes" and just add the pipeline, making it non-blocking for merges.

Yes, based on the current conversation under this PR, I will focus on only adding the pipeline for C#. [ will figure out a way to skip test cases ] I will create separate issues later for the compile time bugs.

@apoorv7g
apoorv7g requested a review from ekarademir June 25, 2026 06:11
@apoorv7g

apoorv7g commented Jul 3, 2026

Copy link
Copy Markdown
Contributor Author

For documentation purposes, #259 has added C# support, currently we skip all test cases , need to fix generator for C#. Shall be done later.

@apoorv7g apoorv7g closed this Jul 3, 2026
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.

3 participants