Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
93 changes: 93 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
# Repository Guidelines

## Audience & Scope
This guide is for AI agents and human contributors working anywhere in this mono-repo. Per-project `AGENTS.md` files (for example, `Egil.SystemTextJson.Migration/AGENTS.md`, `Egil.Orleans.Testing/AGENTS.md`) extend these rules with project-specific build, test, and style conventions. When a per-project file conflicts with this one, the per-project file wins for changes in that project.

## Projects in this Mono-Repo
Each top-level project ships as its own NuGet package and has its own CI workflow under `.github/workflows/`. Use the project's short scope code in every Conventional Commit subject:

| Project directory | Conventional Commit scope |
|----------------------------------|---------------------------|
| `Egil.SystemTextJson.Migration/` | `stjm` |
| `Egil.Orleans.EventSourcing/` | `oes` |
| `Egil.Orleans.Storage/` | `os` |
| `Egil.Orleans.Testing/` | `ot` |
| `Egil.StronglyTypedPrimitives/` | `stp` |

Cross-cutting changes that don't belong to a single package may use:
- `ci` for CI/workflow changes under `.github/`
- `build` for repo-wide build/tooling changes (for example, root `Directory.Build.props`)
- `docs` for repo-level documentation outside any one project (root `README.md`, etc.)

Do not invent new scopes. Do not omit the scope. Every commit that touches files inside one of the project directories above must use that project's scope.

## Commit & Pull Request Guidelines
Commits and PR titles must follow [Conventional Commits](https://www.conventionalcommits.org/):

```
<type>(<scope>): <short summary>

<body explaining what and why>

<footers>
```

- **Type**: `feat`, `fix`, `chore`, `docs`, `test`, `refactor`, `perf`, `build`, `ci`, `style`.
- **Scope**: required, from the table above.
- **Body**: required for `feat`/`fix` and any change a package consumer should know about. Write user-facing prose explaining *what* changed and *why*. Avoid internal-only context (chat logs, agent session IDs, implementation minutiae).
- **Breaking changes**: add a `BREAKING CHANGE: <description>` footer and/or use `<type>(<scope>)!:` in the subject.

Do not mix unrelated commit types in a single commit. Tests that validate a `feat` or `fix` belong in the same commit as that `feat`/`fix`. Use `test(<scope>)` only for test-only additions to existing behavior. Refactors, docs, and fixes each go in their own commits.

PR titles must use the same Conventional Commit format as the primary commit they ship.

### Release Notes and `[skip notes]`
Each project generates its release notes from its own commit history (see `<project>/scripts/generate-release-notes.ps1`). The commit subject becomes the changelog entry heading and the commit body becomes the entry detail.

To exclude a commit from release notes — for example, build/CI/tooling/refactor work that uses `feat` or `fix` types but isn't relevant to package consumers — add `[skip notes]` anywhere in the subject or body.

Examples of when to use `[skip notes]`:
- Internal refactors that don't change observable behavior.
- CI workflow tweaks committed with `fix(ci): ...`.
- Dependency bumps that don't affect the public API.
- Docs updates already covered by another commit in the same release.

Examples of when **not** to use `[skip notes]`:
- Any user-visible feature, fix, or behavior change.
- Performance improvements consumers can observe.
- Breaking changes (these must always appear in notes).

Trailers (such as `Co-authored-by:`) are stripped from the body before inclusion in release notes, so they are safe to add freely.

### Sign-off Trailer
Always append the following trailer to commit messages:

```
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
```

## Development Process
- Work one project at a time. Don't bundle unrelated changes across projects in one commit.
- Commit logical units of work; keep commits small and focused.
- `Release` builds fail on warnings. Make sure each commit is warning-free.
- Never change both production code (under `/src`) and test code (under `/test`) in a single step without running tests in between. Change one or the other, then run tests.

For new features or bug fixes in production code:
1. Write a failing test covering the change.
2. Run the test; confirm it fails for the expected reason.
3. Implement the change in `/src`.
4. Run the test; confirm it now passes.
5. Run the full project test suite to confirm nothing else broke.

For new tests against existing behavior:
1. Write the test with the assertion inverted.
2. Run it; confirm it fails for the expected reason.
3. Correct the assertion.
4. Run it; confirm it now passes.

For refactors:
1. Refactor production code.
2. Run all tests in the affected project; confirm all still pass.

## Build, Test, and Tooling
Each project has its own solution file (`<project>/<project>.slnx`). Run `dotnet restore`, `dotnet build`, `dotnet test`, and `dotnet pack` against the relevant project's solution. Per-project `AGENTS.md` files document the exact commands and any project-specific tooling (BenchmarkDotNet, mdsnippets, etc.).
1 change: 1 addition & 0 deletions Egil.SystemTextJson.Migration/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ Use the solution file from repository root:
- `dotnet test Egil.SystemTextJson.Migration.slnx -c Release`: run test suite (xUnit + Microsoft Testing Platform).
- `dotnet pack src/Egil.SystemTextJson.Migration/Egil.SystemTextJson.Migration.csproj -c Release`: produce NuGet package.
- `dotnet run --project perf/Egil.SystemTextJson.Migration.PerfTests -c Release`: run benchmarks.
- `.\scripts\update-perf-docs.ps1`: after running benchmarks, copy BenchmarkDotNet reports into `docs/perf/` and refresh the README performance summary.
- `dotnet outdated`: check for dependency updates.

## Coding Style & Naming Conventions
Expand Down
76 changes: 60 additions & 16 deletions Egil.SystemTextJson.Migration/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,45 @@ UserV2 user = JsonSerializer.Deserialize<UserV2>(json, options)!;
<sup><a href='/samples/Egil.SystemTextJson.Migration.Samples/StaticMigrationSample.cs#L25-L33' title='Snippet source file'>snippet source</a> | <a href='#snippet-static_migration_usage' title='Start of snippet'>anchor</a></sup>
<!-- endSnippet -->

### Discriminator-less object payloads

When stored JSON was written before migration support existed and represents an older source shape, configure the target with `UndiscriminatedSourceType`:

<!-- snippet: legacy_undiscriminated_source_type -->
<a id='snippet-legacy_undiscriminated_source_type'></a>
```cs
[JsonMigratable(
TypeDiscriminator = "customer-name-v1",
UndiscriminatedSourceType = typeof(CustomerNameV0))]
public record class CustomerNameV1(string Name)
: IMigrateFrom<CustomerNameV0, CustomerNameV1>
{
public static bool TryMigrateFrom(CustomerNameV0 source, out CustomerNameV1 result)
{
result = new CustomerNameV1($"{source.FirstName} {source.LastName}");
return true;
}
}
```
<sup><a href='/samples/Egil.SystemTextJson.Migration.Samples/LegacyPayloadSample.cs#L19-L32' title='Snippet source file'>snippet source</a> | <a href='#snippet-legacy_undiscriminated_source_type' title='Start of snippet'>anchor</a></sup>
<!-- endSnippet -->

<!-- snippet: legacy_undiscriminated_source_usage -->
<a id='snippet-legacy_undiscriminated_source_usage'></a>
```cs
var options = new JsonSerializerOptions(JsonSerializerDefaults.Web);
options.AddJsonMigrationSupport();

// Existing stored JSON was written before migration support existed,
// so it has no $type discriminator. CustomerNameV1 opts in to treating
// discriminator-less objects as CustomerNameV0 and runs its migrator.
var json = """{"firstName":"Jane","lastName":"Doe"}""";

CustomerNameV1 customer = JsonSerializer.Deserialize<CustomerNameV1>(json, options)!;
// customer is CustomerNameV1 { Name = "Jane Doe" }
```
<sup><a href='/samples/Egil.SystemTextJson.Migration.Samples/LegacyPayloadSample.cs#L79-L90' title='Snippet source file'>snippet source</a> | <a href='#snippet-legacy_undiscriminated_source_usage' title='Start of snippet'>anchor</a></sup>
<!-- endSnippet -->

### External migration

Expand Down Expand Up @@ -431,8 +470,9 @@ Every benchmark compares the library against hand-written migration code on top
**Key takeaways:**

- **Happy path (no migration needed):** deserialization is ~1.0–1.3× plain STJ with **zero extra allocations**. The overhead comes from the O(1) first-property discriminator check and is constant regardless of payload size.
- **Migration path:** 1.4–1.5× plain STJ for small payloads, converging toward ~1.0× as payload size grows — the fixed migration overhead is amortized over more data.
- **Legacy payloads (no discriminator):** 1.0–1.2× plain STJ with **zero extra allocations** — the same as current-version payloads.
- **Migration path:** static/external discriminator-based migrations are ~1.4× plain STJ for small payloads, converging toward ~1.0× as payload size grows — the fixed migration overhead is amortized over more data.
- **Undiscriminated source migration:** ~1.0–1.1× plain STJ with **zero extra allocations** compared to a manual source deserialize plus conversion.
- **Target-shaped legacy payloads (no discriminator):** 1.0–1.2× plain STJ with **zero extra allocations** — the same as current-version payloads.
- **Serialization:** near 1:1 at larger payloads (ratio ≈ 1.0). Small payloads show ~2× due to the fixed cost of writing the discriminator property.

Detailed results with source-generated `JsonSerializerContext`:
Expand All @@ -442,26 +482,30 @@ Detailed results with source-generated `JsonSerializerContext`:
<!-- perf-summary:start -->
| Scenario | TagCount | Ratio vs plain STJ | Alloc Ratio |
|----------|:--------:|:-------------------:|:-----------:|
| **No migration (happy path)** | 2 | 1.25× | 1.00 |
| | 32 | 0.76× | 1.00 |
| **No migration (happy path)** | 2 | 1.24× | 1.00 |
| | 32 | 1.08× | 1.00 |
| | 256 | 1.02× | 1.00 |
| **Static migration** | 2 | 1.42× | 1.00 |
| | 32 | 1.14× | 1.00 |
| | 256 | 1.02× | 1.00 |
| **External migration** | 2 | 1.37× | 1.00 |
| | 32 | 1.13× | 1.00 |
| | 256 | 1.01× | 1.00 |
| **Undiscriminated source migration** | 2 | 0.99× | 1.00 |
| | 32 | 1.07× | 1.00 |
| | 256 | 1.02× | 1.00 |
| **Static migration** | 2 | 1.43× | 1.13 |
| | 32 | 1.22× | 1.04 |
| | 256 | 1.06× | 1.01 |
| **External migration** | 2 | 1.50× | 1.13 |
| | 32 | 1.18× | 1.05 |
| | 256 | 1.04× | 1.01 |
| **Legacy payload** | 2 | 1.16× | 1.00 |
| | 32 | 1.05× | 1.00 |
| | 256 | 0.82× | 1.00 |
| **Serialization** | 2 | 2.10× | 5.45 |
| | 32 | 1.17× | 2.02 |
| | 256 | 0.93× | 1.15 |
| **Legacy payload** | 2 | 1.13× | 1.00 |
| | 32 | 1.07× | 1.00 |
| | 256 | 1.01× | 1.00 |
| **Serialization** | 2 | 1.81× | 5.45 |
| | 32 | 1.08× | 2.02 |
| | 256 | 0.89× | 1.15 |
<!-- perf-summary:end -->

> Full benchmark reports: [source-gen](https://github.com/egil/framework/blob/main/Egil.SystemTextJson.Migration/docs/perf/source-gen-benchmarks.md) · [reflection](https://github.com/egil/framework/blob/main/Egil.SystemTextJson.Migration/docs/perf/reflection-benchmarks.md)
>
> Run benchmarks locally with `dotnet run --project perf/Egil.SystemTextJson.Migration.PerfTests -c Release`.
> Refresh these docs from the latest BenchmarkDotNet output with `.\scripts\update-perf-docs.ps1`.

## Design notes

Expand Down
Loading
Loading