From f40a6f21bd495c8925f0e205d03018b7083841d1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deniz=20=C4=B0rgin?= Date: Tue, 11 Aug 2026 21:55:47 +0300 Subject: [PATCH 1/7] docs(plan): define M1 walking skeleton --- .../handover-prompts/HANDOFF-2026-08-11-2.md | 146 -------------- .../plans/2026-08-11-m1-walking-skeleton.md | 188 ++++++++++++++++++ 2 files changed, 188 insertions(+), 146 deletions(-) delete mode 100644 docs/agents/handover-prompts/HANDOFF-2026-08-11-2.md create mode 100644 docs/superpowers/plans/2026-08-11-m1-walking-skeleton.md diff --git a/docs/agents/handover-prompts/HANDOFF-2026-08-11-2.md b/docs/agents/handover-prompts/HANDOFF-2026-08-11-2.md deleted file mode 100644 index b8f4ac8f..00000000 --- a/docs/agents/handover-prompts/HANDOFF-2026-08-11-2.md +++ /dev/null @@ -1,146 +0,0 @@ -# Handover — M1 walking skeleton (post course-correction) - -Date: 2026-08-11 - -Priming prompt for the M1 session. Consume against live git; delete when the M1 plan is -written and approved (or fold anything still unconsumed into that plan). Transient and -agent-facing. - -## How we got here — read this before anything else - -The project ran a heavy research/design runway first (research log sessions 1–14): three -design specs, ADRs 0001–0009, and a 12-slice implementation map, all sealed through -repeated grill sessions. Slice 0 (tooling skeleton) landed fine. **Slice 1 (ingestion) -then produced 8.6K lines with zero SDK output** — a semantic projection buried under -DOM-surveillance machinery (reflection member whitelists over every reader type, planned -document-wide hashes, DOM inventories, SpecIR snapshots) plus a 1.5K-line test DSL, all -before any generator, model, or client existed. - -A mid-execution **complexity checkpoint** (research doc 13) ran two probes that decided -everything after it: (1) the production projection **absorbs the complete pin** — 188 -operations, 472 named schemas, all landmarks, deterministically — so the semantic core is -right; (2) raw **Kiota loses this dialect's wire data** (drops `type` markers, loses whole -union payloads, silences unknown tags, no typed error bodies) — so the own-generator -decision is right. The failure was therefore not architecture but process, and the -maintainer sealed a course correction (research log **session 15**, Q63–Q65): - -- **Cause 1:** fail-closed maximalism was transplanted from the analyzer wall (cheap - there) onto the reader DOM, where a whitelist's cost scales with the *library's* - surface, not with product risk. -- **Cause 2:** the slice map was a layer cake in vertical clothing — first callable - client at slice 5, first real request at slice 7; feedback arrived after maximal - investment. -- **Cause 3:** paper grill sessions ratchet monotonically (every risk gains a mechanism; - nothing answers "what does this cost in code?"), and assurance intensity was never - scaled by blast radius. - -The Slice 1 branch was then **closed lean in the same session**: surveillance machinery -deleted (−851/+131), semantic-loss checks folded into projectors, `ISpecIngestion` -orchestration + `$ref`-sibling raw scan + dangling-ref sweep added, tripwire/boundary/ -landmark tests written, docs regime changed. PR #14 (squash) closed issue #2. - -## What is true now (the standing rules) - -- **Sealed surface = ADRs + `AGENTS.md` only.** The specs under `docs/superpowers/` are - vision/reference (bannered): direction and rationale, not law. Contradicting one is a - level-0 note; the deviation protocol's level 2 covers canonical docs only. -- **Red lines (maintainer):** the analyzer wall + TWAE stays exactly as is; the - Testably/`IFileSystem` seam with the TestableIO enforcement analyzer stays; - `Microsoft.OpenApi` (pinned 3.9.0) stays the reader. -- **Drift radar:** git diffs of committed generated output + the test suites are primary; - targeted validators exist only at known lossy seams (unrecognized raw keywords, - `prefixItems` tripwire, `$ref` siblings, dangling refs, boundary guards, landmark - smoke). No DOM inventories, no SpecIR snapshots, no speculative hashes. -- **Consumer-pull:** every SpecIR fact, mechanism, option, and test names a consumer or a - concrete failure it prevents. A fact needed by a later feature is added *in* that - feature. -- **Blast-radius assurance** (`AGENTS.md`): shipped SDK runtime highest; committed - generated output next; repo tooling internals lightest. -- **Delivery model:** `docs/ROADMAP.md` carries six milestones (M1 walking skeleton → M6 - operational closure). Each milestone gets a **1–2 page** plan written when it starts. - The slice map and per-slice issue chain are retired; issues #3–#12 need re-triage - against the milestone model (maintainer's call — do not bulk-close on your own). -- Test authorship: builders/scenarios exist under `tests/OpenCode.Sdk.Tools.Tests/Support/` - — use them, **grow them only when a test pulls a new verb**. Named scenario classes only - on the promotion rule. No count assertions against the pin. - -## Live state - -| Item | Value | -|---|---| -| master | PR #14 squash-merged (lean Slice 1); issue #2 closed | -| Ingestion | `ISpecIngestion` → `SpecDocument` under `tools/OpenCode.Sdk.Tools/Generator/Ingestion/`; absorbs the full pin (188 ops = 61 modern/127 legacy, 1,501 graph nodes, landmark smoke green, repeat-deterministic) | -| `generate` | still a fail-loud stub — becomes real in M1 arc A | -| Hosting | full ToolApp DI composition live (MEL Spectre/file providers, global `--log-level`/`--log-file`, interceptor, `IFileSystem`/`IAnsiConsole` seams) | -| Tests | 168 green (Tools.Tests 164 + Sdk.Tests smoke ×4 TFM legs) | -| Verify.TUnit | pinned + referenced but **currently unused** — its first consumer is arc A's emitter micro-snapshots (the other sanctioned use: the public API surface lock) | -| Evidence worktree | `C:\Users\deniz\AppData\Local\Temp\opencode\opencode-sdk-dotnet-slice-01-parser-specir` (branch `feature/slice-01-parser-specir`, retired parser) — READ-ONLY, disposition is the maintainer's call | -| SpecDocument gaps (deliberate) | no content hashes (return with the fingerprint feature in M5), no version string (the 3.1 gate guarantees it) | - -## The task: M1 — walking skeleton - -**Sealed deliverable:** `v2.health.get` + `v2.session.message` end to end — SpecDocument → -Binder → EmitPlan → Roslyn emitters → committed source under `src/OpenCode.Sdk` → minimal -transport core → callable client — **demonstrated once by hand against a real -`opencode serve`**, output pasted into the PR. Deliberately process-free: no launcher, no -test harness, no CI integration legs, no coverage gate. Two independently mergeable arcs: - -- **Arc A — compiler + committed models:** two-operation selection profile (a checked-in - text file under `tools/`), sparse curation v0 with selection-scoped coverage checks, - Binder → EmitPlan, model/union/registry emitters for the selected reachable closure, - Writer (output manifest, stale cleanup, `dotnet format` post-step), real `generate` + - `--verify`; the committed models compile on all five TFMs. -- **Arc B — callable client:** exception/response/request-option spines, - `Pipeline.ExecuteAsync` behavior core (endpoint + BYO HttpClient construction, basic - auth chain, directory header, User-Agent, cancellation — **no** retry/telemetry/hooks/ - DI-extensions yet), envelope/routes/client emitters, the two generated methods, level-2 - contract tests for both operations and their declared errors, the manual demo. - -**First step of the session: write the M1 plan (1–2 pages), get the maintainer's OK, then -execute.** The walking-skeleton spec -(`docs/superpowers/specs/2026-08-11-production-walking-skeleton-design.md`) is the design -reference — take its decisions (operation selection semantics, semantic-risk policy, the -`SessionMessage` converter contract, error mapping table, stop conditions), not its -ceremony; its verification matrices are a menu, not a checklist. Keep the partial-breadth -guard to the cheapest mechanical form that actually blocks `dotnet pack`. - -`v2.session.message` is chosen because its closure exercises the hard path: bound session -handle, two path parameters, `{data}` envelope, an 8-variant marked union whose assistant -branch nests a 3-variant content union and a 4-state tool-state union, declared -400/401/404 errors (404 dedups to two not-found variants). Its unions are all *marked* — -if the closure unexpectedly pulls in a structural union, that is the deferred emission -decision surfacing early: stop and take it to the maintainer as an API review. - -## Onboarding order - -1. `AGENTS.md` (note the vision/reference sentence and the blast-radius testing sentence) - + `docs/agents/deviation-protocol.md` (narrowed level 2) -2. `docs/ROADMAP.md` — status + the six milestones -3. Research log **session 15** (Q63–Q65) + research doc - `13-generator-complexity-checkpoint.md` — the why behind everything above -4. The walking-skeleton spec (vision status — design reference for M1) -5. The ingestion seam it builds on: `Generator/Ingestion/` source + - `SpecIngestionTests`/`PinnedSpecSmokeTests` (what `SpecDocument` carries; the - handoff-to-Binder facts: `SpecEnvelopeShape`, `ErrorStyle`, `LiteralMarker`s, - `UnionClassification`, `IsSse`/`EffectStreamJson`, `IsWebSocket`, - `SpecMediaType.Stripped`, `Description`/`Format`, `IsDeprecated`) -6. `docs/engineering/coding-style.md` + `testing-style.md` — binding on every task -7. Public API + generator specs — consult per topic while planning (envelope §5, naming - §8, curation §5, emitters §6, file mechanics §7, Writer §8); do not re-read whole - -## Warnings - -- **Do not resurrect the deleted machinery** — reflection member whitelists, DOM - member/default inventories, SpecIR-of-the-pin snapshots, document-wide raw hashes, - fatal unknown `x-*`, per-keyword refusal tests. Session 15 records why each died. A new - assurance mechanism needs a consumer or a demonstrated failure, in writing. -- Typed annotation/validation members (`title`, `default`, `readOnly`, bounds…) are - **silently ignored** by design; unrecognized *raw* keywords still refuse (nothing - downstream can ever see them). Don't "fix" either direction. -- The landmark smoke is the refresh gate: a red landmark is evidence about the wire — - stop and classify, never patch the assertion. -- Plans stay 1–2 pages; per-task commits on the arc branch are the agreed loop; master - merges via PR with maintainer approval; squash-merge is the house style since PR #14. -- Never modify the evidence worktree or its branch. -- The maintainer works in Turkish conversation, English artifacts; align on structure - before writing documents; canonical-doc edits need explicit per-edit approval. diff --git a/docs/superpowers/plans/2026-08-11-m1-walking-skeleton.md b/docs/superpowers/plans/2026-08-11-m1-walking-skeleton.md new file mode 100644 index 00000000..5245c6ce --- /dev/null +++ b/docs/superpowers/plans/2026-08-11-m1-walking-skeleton.md @@ -0,0 +1,188 @@ +# M1 Walking Skeleton Implementation Plan + +Date: 2026-08-11 + +> **For agentic workers:** REQUIRED SUB-SKILL: Use +> `superpowers:subagent-driven-development` (recommended) or +> `superpowers:executing-plans` to implement this plan task-by-task. Steps use checkbox +> (`- [ ]`) syntax for tracking. + +**Goal:** Land a callable, pre-release SDK surface for `v2.health.get` and +`v2.session.message`, demonstrated once against a real `opencode serve`. + +**Architecture:** `ISpecIngestion` remains the only OpenAPI boundary. A selection-scoped +Binder turns `SpecDocument` plus sparse curation into role-specific EmitPlan records; dumb +Roslyn emitters produce committed source, and generated methods delegate once into a +hand-written HTTP behavior core. Arc A lands the compiler/models; Arc B lands the client. + +**Tech Stack:** .NET SDK 10.0.302, C# 14, Microsoft.OpenApi 3.9.0, +Microsoft.CodeAnalysis.CSharp 5.6.0, System.Text.Json 10.0.11, CliWrap 3.10.4, TUnit, +Verify.TUnit, Testably.Abstractions, and PublicApiGenerator 11.5.4. + +## Global Constraints + +- Binding decisions are `AGENTS.md` plus ADRs 0001-0009; design specs are reference only. +- Emit exactly the two selected modern operations. Pending operations are reported, not + fingerprinted, and keep `dotnet pack` blocked by an owned `.generation-incomplete` marker. +- Clients, options, responses, routes, and exceptions use `OpenCode.Sdk`; generated domain and + error models use `OpenCode.Sdk.Models`; serializer infrastructure is internal. +- The first payload members are `SessionMessageResponse.Message` and + `HealthResponse.Healthy`; health's one-value boolean enum emits as `bool`. +- Every marked union has a position-independent converter and explicit `Unknown*` carrier with + its tag and a cloned raw `JsonElement`; serialization uses only generated metadata. +- Generated output is deterministic, LF-only, manifest-owned, analyzer-visible plain `.cs`. +- Product code targets `netstandard2.0;net472;net8.0;net9.0;net10.0` and uses + `ConfigureAwait(false)`. Tests obey `docs/engineering/testing-style.md`. +- Exclude retry, telemetry, hooks, DI Extensions, launcher, SSE, process harness, coverage/CI + legs, hashes, fingerprints, full curation, and unselected operation support. + +## File Map + +- Inputs: `tools/generation-profile.txt`, `tools/curation.json`. +- Compiler: `tools/OpenCode.Sdk.Tools/Generator/{Binding,Emission,Output}/` and + `Generator/GenerationCoordinator.cs`. +- Product: generated `src/OpenCode.Sdk/{Models,Serialization,Internal}/` plus root clients and + responses; hand-written root spines plus `Internal/Pipeline.cs`. +- Tests: matching `Generator/` areas in Tools.Tests and + `{Serialization,Contract,Support,Fixtures}/` in Sdk.Tests. + +--- + +## Arc A - Selected Compiler And Committed Models + +Use `feature/m1-compiler-models`; each task is one reviewable commit. Merge Arc A before Arc B. + +### Task 1: Selection, Curation, And Binder + +**Files:** Create the two input files; `Binding/Abstractions/ISpecBinder.cs`; +`Binding/{SpecBinder,OperationSelectionLoader,CurationLoader,ReachableSchemaCollector, +CSharpNamePolicy}.cs`; role records under `Binding/Models/`; matching loader/Binder tests. + +**Interface:** `ISpecBinder.Bind(SpecDocument, OperationSelection, GenerationCuration)` returns +`EmitPlan` containing only model, marked-union, registry, and pending-operation plans. + +- [ ] Red-test missing/duplicate IDs, unknown config fields, orphan/pending curation rows, + selected coverage gaps, batched errors, closure, duplicate-ref collapse, and determinism. +- [ ] Check in the exact two-operation profile and sparse curation: root `health`; `Sessions` + + `SessionClient`; payload `Message`; reasoned `Uri` overrides for + `PromptFileAttachment.uri` and `ToolFileContent.uri`. +- [ ] Bind only the selected closure, require every reached union to be marked, apply FDG names + (`Llm*`), and derive pending modern/legacy sets without full-pin count assertions. +- [ ] Run `dotnet test tests/OpenCode.Sdk.Tools.Tests/OpenCode.Sdk.Tools.Tests.csproj + --configuration Release`; commit `feat(generator): bind the M1 operation selection`. + +### Task 2: Model, Union, And Registry Emitters + +**Files:** Run `dotnet add tools/OpenCode.Sdk.Tools/OpenCode.Sdk.Tools.csproj package +Microsoft.CodeAnalysis.CSharp --version 5.6.0`; create +`Emission/{GeneratedSource,SourceEmitter,ModelEmitter,UnionEmitter,RegistryEmitter}.cs` and +matching tests with one Verify micro-snapshot per emitter family. + +**Interface:** `SourceEmitter.Emit(EmitPlan)` returns an ordinal-sorted +`IReadOnlyList`; each item carries a manifest-relative path and UTF-8 source. +Emitters consume no SpecIR, curation, or filesystem. + +- [ ] Red-test immutable records, `required`, empty optional collections, wire names, XML docs, + error inheritance, marked dispatch, unknown round-trip, and registry membership. +- [ ] Emit Roslyn trees for models, `OpenCodeError`, concrete errors, union bases/variants, + `Unknown*` carriers, converters, and internal `OpenCodeJsonContext`. +- [ ] Compile emitted snippets, approve only the three micro-snapshots, run + `dotnet test tests/OpenCode.Sdk.Tools.Tests/OpenCode.Sdk.Tools.Tests.csproj --configuration + Release`, and commit `feat(generator): emit selected models and unions`. + +### Task 3: Writer, Generate Command, And Arc A Gate + +**Files:** Run `dotnet add src/OpenCode.Sdk/OpenCode.Sdk.csproj package System.Text.Json +--version 10.0.11` and `dotnet add tools/OpenCode.Sdk.Tools/OpenCode.Sdk.Tools.csproj package +CliWrap --version 3.10.4`; create `Generator/{GenerationCoordinator,GenerationRequest, +GenerationReport}.cs`, +`Output/Abstractions/{IGenerationWriter,IProjectFormatter}.cs`, and +`Output/{GenerationWriter,CliWrapProjectFormatter,GenerationManifest,WriteResult}.cs`; modify +`GenerateCommand.cs`, `ToolApp.cs`, and `OpenCode.Sdk.csproj`; add product serialization tests +and embedded fixtures; remove `SmokeTests.cs` after real tests cover each test TFM. + +- [ ] Unit-test manifest path safety, stale cleanup, unrelated-file preservation, repeat output, + clean/drift `--verify`, report/marker behavior, and command exits using MockFileSystem and a + substituted formatter. +- [ ] Implement write-then-format ownership. `--verify` snapshots old/new owned paths, writes, + formats the SDK project, byte-compares owned output, and reports changed/created/deleted paths + without Git. +- [ ] Generate and commit `Models/`, internal converters/context, `.generated-manifest.json`, + and `.generation-incomplete`; product-test known/unknown outer, assistant-content, and + tool-state variants, arbitrary marker order, semantic round-trip, and context-only metadata. +- [ ] Run `dotnet run --file tools/opencode-tool.cs -- generate`, repeat with `--verify`, then + run Release build/tests and format. Run + `dotnet pack src/OpenCode.Sdk/OpenCode.Sdk.csproj --configuration Release` separately and + require failure solely from the partial marker. +- [ ] Commit `feat(generator): commit the M1 model closure`; open and merge the Arc A PR. + +--- + +## Arc B - Callable Client + +Use `feature/m1-callable-client` from merged Arc A. + +### Task 4: Hand-Written Behavior Core + +**Files:** Create root `{OpenCodeClientOptions,OpenCodeRequestOptions,ErrorBehavior, +OpenCodeResponse,OpenCodeException,OpenCodeApiException,OpenCodeTransportException}.cs`; +`Internal/Abstractions/IEnvironmentProvider.cs`; +`Internal/{SystemEnvironmentProvider,Pipeline,ResponseAdapter}.cs`; SDK friend assembly metadata; +recording-handler/environment test support and focused runtime tests. + +**Interface:** `Pipeline.ExecuteAsync(HttpMethod, string, +ResponseAdapter, OpenCodeRequestOptions?, CancellationToken)` returns +`Task` where `TResponse : OpenCodeResponse`. Generated adapters own typed success/error +deserialization and envelope construction; Pipeline owns transport and throw/`NoThrow` behavior. + +- [ ] Red-test endpoint joining; explicit password then one-time + `OPENCODE_SERVER_PASSWORD` fallback with Basic user `opencode`; directory precedence; + `x-opencode-directory`; `OpenCode.Sdk/` User-Agent; cancellation; + transport/protocol errors; response/HttpClient disposal. +- [ ] Implement only those behaviors. Preserve `OperationCanceledException`; expose status, + typed `OpenCodeError`, and raw body on `OpenCodeApiException`; never dispose BYO HttpClient. + Options expose `Endpoint`, `Password`, and client `Directory`; request options expose + `ErrorBehavior` (`Default`/`NoThrow`), per-call `Directory`, and static `NoThrow`; + `OpenCodeResponse` exposes `Status`, `IsError`, and `Error`. +- [ ] Run `dotnet test tests/OpenCode.Sdk.Tests/OpenCode.Sdk.Tests.csproj --configuration + Release`; commit + `feat(sdk): add the minimal HTTP behavior core`. + +### Task 5: Client Emission, Contracts, And M1 Closure + +**Files:** Add consumed-only `{EnvelopePlan,ClientPlan,OperationPlan,ErrorMapPlan}` binding records; +create `Emission/{EnvelopeEmitter,RoutesEmitter,ClientEmitter,OperationMethodEmitter, +ResponseAdapterEmitter}.cs`; generate root clients/routes/responses and two internal adapters; +run `dotnet add tests/OpenCode.Sdk.Tests/OpenCode.Sdk.Tests.csproj package PublicApiGenerator +--version 11.5.4`; add `Contract/{HealthContract, +SessionMessageContract}Tests.cs`, API approval, recording support, and embedded payload fixtures. + +**Public surface:** `OpenCodeClient` implements `IDisposable`, has `(Uri)`, +`(Uri, OpenCodeClientOptions)`, `(HttpClient, OpenCodeClientOptions)`, and protected mock +constructors, readonly `Sessions`, and `GetHealthAsync(options, cancellationToken)`; +`SessionsClient.GetSessionClient(sessionId)` returns `SessionClient`, whose +`GetMessageAsync(messageId, options, cancellationToken)` returns `SessionMessageResponse`. + +- [ ] Emit final clients, guarded/escaped IDs and routes, guarded `Healthy`/`Message` getters and + `PrintMembers`, one-expression method delegation, and status maps: health 400/401; message + 400/401/404 with both deduplicated not-found variants and `UnknownOpenCodeError` fallback. +- [ ] Contract-test every selected success/declared error through default and `NoThrow`; test + unknown retention, path escaping, auth/directory/User-Agent, cancellation, transport failure, + and client ownership through the real generated client. +- [ ] Approve the first API baseline; run `generate --verify`, Release build/tests, format, and + Slopwatch. Keep the partial marker and pack refusal. +- [ ] Start `OPENCODE_SERVER_PASSWORD=m1-demo opencode serve --port 4096` in a directory with an + existing message; obtain IDs from `GET /api/session?limit=1` and + `GET /api/session/{sessionID}/message?limit=1`; use a temporary `.scratchpad/` console to print + `Healthy`, concrete message type, and message ID; paste output into the PR. +- [ ] Update `docs/ROADMAP.md`, commit `feat(sdk): generate the first callable client`, and open + the Arc B PR. + +## Final Gates And Stop Conditions + +Run `dotnet build --configuration Release`, `dotnet test --configuration Release --no-build`, +`dotnet format --verify-no-changes --no-restore`, and `dotnet tool run slopwatch analyze +--exclude ".scratchpad/**,external/**" --fail-on warning`. Stop for maintainer review if the +closure reaches a structural union, Binder needs Microsoft.OpenApi/raw JSON, a temporary public +or transport path would later be replaced, output cannot compile uniformly on all five TFMs, +partial breadth can pack, or excluded M1 capabilities become necessary for either call. From 68579cb169b1373344025db43b037607a7e6bfc7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deniz=20=C4=B0rgin?= Date: Tue, 11 Aug 2026 22:30:31 +0300 Subject: [PATCH 2/7] feat(generator): bind the M1 operation selection --- .../Binding/unknown-curation-field.json | 10 + .../Fixtures/Binding/valid-curation.json | 9 + .../Generator/Binding/CurationLoaderTests.cs | 44 ++ .../Binding/OperationSelectionLoaderTests.cs | 51 ++ .../Generator/Binding/SpecBinderTests.cs | 274 +++++++++++ .../OpenCode.Sdk.Tools.Tests.csproj | 6 + .../Support/BindingTestHost.cs | 24 + .../Binding/Abstractions/ISpecBinder.cs | 9 + .../Binding/BindingErrorCollector.cs | 25 + .../Generator/Binding/CSharpNamePolicy.cs | 111 +++++ .../Generator/Binding/CurationLoader.cs | 53 ++ .../Generator/Binding/CurationValidator.cs | 156 ++++++ .../Generator/Binding/Models/BindingError.cs | 7 + .../Binding/Models/BindingErrorCategory.cs | 17 + .../Binding/Models/BindingException.cs | 76 +++ .../Models/DictionaryTypeReferencePlan.cs | 8 + .../Generator/Binding/Models/EmitPlan.cs | 46 ++ .../Generator/Binding/Models/EnumModelPlan.cs | 14 + .../Generator/Binding/Models/EnumValuePlan.cs | 8 + .../Binding/Models/GenerationCuration.cs | 40 ++ .../Generator/Binding/Models/GroupCuration.cs | 15 + .../Binding/Models/GroupPlacement.cs | 7 + .../Binding/Models/ListTypeReferencePlan.cs | 8 + .../Generator/Binding/Models/ModelPlan.cs | 10 + .../Binding/Models/ModelPropertyPlan.cs | 22 + .../Binding/Models/NamedTypeReferencePlan.cs | 8 + .../Binding/Models/ObjectModelPlan.cs | 16 + .../Binding/Models/OperationSelection.cs | 14 + .../Binding/Models/PendingOperationPlan.cs | 10 + .../Binding/Models/PropertyOverride.cs | 18 + .../Binding/Models/PropertyOverrideType.cs | 6 + .../Binding/Models/ReachableSchemaSet.cs | 24 + .../Generator/Binding/Models/RegistryPlan.cs | 14 + .../Binding/Models/SchemaBindingResult.cs | 10 + .../Binding/Models/TypeReferencePlan.cs | 8 + .../Generator/Binding/Models/UnionPlan.cs | 30 ++ .../Binding/Models/UnionVariantPlan.cs | 8 + .../Binding/OperationSelectionLoader.cs | 60 +++ .../Binding/ReachableSchemaCollector.cs | 99 ++++ .../Generator/Binding/SchemaNameResolver.cs | 120 +++++ .../Generator/Binding/SchemaPlanBinder.cs | 455 ++++++++++++++++++ .../Generator/Binding/SpecBinder.cs | 81 ++++ tools/curation.json | 29 ++ tools/generation-profile.txt | 2 + 44 files changed, 2062 insertions(+) create mode 100644 tests/OpenCode.Sdk.Tools.Tests/Fixtures/Binding/unknown-curation-field.json create mode 100644 tests/OpenCode.Sdk.Tools.Tests/Fixtures/Binding/valid-curation.json create mode 100644 tests/OpenCode.Sdk.Tools.Tests/Generator/Binding/CurationLoaderTests.cs create mode 100644 tests/OpenCode.Sdk.Tools.Tests/Generator/Binding/OperationSelectionLoaderTests.cs create mode 100644 tests/OpenCode.Sdk.Tools.Tests/Generator/Binding/SpecBinderTests.cs create mode 100644 tests/OpenCode.Sdk.Tools.Tests/Support/BindingTestHost.cs create mode 100644 tools/OpenCode.Sdk.Tools/Generator/Binding/Abstractions/ISpecBinder.cs create mode 100644 tools/OpenCode.Sdk.Tools/Generator/Binding/BindingErrorCollector.cs create mode 100644 tools/OpenCode.Sdk.Tools/Generator/Binding/CSharpNamePolicy.cs create mode 100644 tools/OpenCode.Sdk.Tools/Generator/Binding/CurationLoader.cs create mode 100644 tools/OpenCode.Sdk.Tools/Generator/Binding/CurationValidator.cs create mode 100644 tools/OpenCode.Sdk.Tools/Generator/Binding/Models/BindingError.cs create mode 100644 tools/OpenCode.Sdk.Tools/Generator/Binding/Models/BindingErrorCategory.cs create mode 100644 tools/OpenCode.Sdk.Tools/Generator/Binding/Models/BindingException.cs create mode 100644 tools/OpenCode.Sdk.Tools/Generator/Binding/Models/DictionaryTypeReferencePlan.cs create mode 100644 tools/OpenCode.Sdk.Tools/Generator/Binding/Models/EmitPlan.cs create mode 100644 tools/OpenCode.Sdk.Tools/Generator/Binding/Models/EnumModelPlan.cs create mode 100644 tools/OpenCode.Sdk.Tools/Generator/Binding/Models/EnumValuePlan.cs create mode 100644 tools/OpenCode.Sdk.Tools/Generator/Binding/Models/GenerationCuration.cs create mode 100644 tools/OpenCode.Sdk.Tools/Generator/Binding/Models/GroupCuration.cs create mode 100644 tools/OpenCode.Sdk.Tools/Generator/Binding/Models/GroupPlacement.cs create mode 100644 tools/OpenCode.Sdk.Tools/Generator/Binding/Models/ListTypeReferencePlan.cs create mode 100644 tools/OpenCode.Sdk.Tools/Generator/Binding/Models/ModelPlan.cs create mode 100644 tools/OpenCode.Sdk.Tools/Generator/Binding/Models/ModelPropertyPlan.cs create mode 100644 tools/OpenCode.Sdk.Tools/Generator/Binding/Models/NamedTypeReferencePlan.cs create mode 100644 tools/OpenCode.Sdk.Tools/Generator/Binding/Models/ObjectModelPlan.cs create mode 100644 tools/OpenCode.Sdk.Tools/Generator/Binding/Models/OperationSelection.cs create mode 100644 tools/OpenCode.Sdk.Tools/Generator/Binding/Models/PendingOperationPlan.cs create mode 100644 tools/OpenCode.Sdk.Tools/Generator/Binding/Models/PropertyOverride.cs create mode 100644 tools/OpenCode.Sdk.Tools/Generator/Binding/Models/PropertyOverrideType.cs create mode 100644 tools/OpenCode.Sdk.Tools/Generator/Binding/Models/ReachableSchemaSet.cs create mode 100644 tools/OpenCode.Sdk.Tools/Generator/Binding/Models/RegistryPlan.cs create mode 100644 tools/OpenCode.Sdk.Tools/Generator/Binding/Models/SchemaBindingResult.cs create mode 100644 tools/OpenCode.Sdk.Tools/Generator/Binding/Models/TypeReferencePlan.cs create mode 100644 tools/OpenCode.Sdk.Tools/Generator/Binding/Models/UnionPlan.cs create mode 100644 tools/OpenCode.Sdk.Tools/Generator/Binding/Models/UnionVariantPlan.cs create mode 100644 tools/OpenCode.Sdk.Tools/Generator/Binding/OperationSelectionLoader.cs create mode 100644 tools/OpenCode.Sdk.Tools/Generator/Binding/ReachableSchemaCollector.cs create mode 100644 tools/OpenCode.Sdk.Tools/Generator/Binding/SchemaNameResolver.cs create mode 100644 tools/OpenCode.Sdk.Tools/Generator/Binding/SchemaPlanBinder.cs create mode 100644 tools/OpenCode.Sdk.Tools/Generator/Binding/SpecBinder.cs create mode 100644 tools/curation.json create mode 100644 tools/generation-profile.txt diff --git a/tests/OpenCode.Sdk.Tools.Tests/Fixtures/Binding/unknown-curation-field.json b/tests/OpenCode.Sdk.Tools.Tests/Fixtures/Binding/unknown-curation-field.json new file mode 100644 index 00000000..c0235006 --- /dev/null +++ b/tests/OpenCode.Sdk.Tools.Tests/Fixtures/Binding/unknown-curation-field.json @@ -0,0 +1,10 @@ +{ + "groups": { + "health": { + "placement": "root" + } + }, + "envelopePayloadNames": {}, + "propertyOverrides": [], + "mystery": true +} diff --git a/tests/OpenCode.Sdk.Tools.Tests/Fixtures/Binding/valid-curation.json b/tests/OpenCode.Sdk.Tools.Tests/Fixtures/Binding/valid-curation.json new file mode 100644 index 00000000..4eb66d7a --- /dev/null +++ b/tests/OpenCode.Sdk.Tools.Tests/Fixtures/Binding/valid-curation.json @@ -0,0 +1,9 @@ +{ + "groups": { + "health": { + "placement": "root" + } + }, + "envelopePayloadNames": {}, + "propertyOverrides": [] +} diff --git a/tests/OpenCode.Sdk.Tools.Tests/Generator/Binding/CurationLoaderTests.cs b/tests/OpenCode.Sdk.Tools.Tests/Generator/Binding/CurationLoaderTests.cs new file mode 100644 index 00000000..f9827c19 --- /dev/null +++ b/tests/OpenCode.Sdk.Tools.Tests/Generator/Binding/CurationLoaderTests.cs @@ -0,0 +1,44 @@ +using OpenCode.Sdk.Tools.Generator.Binding; +using OpenCode.Sdk.Tools.Generator.Binding.Models; +using OpenCode.Sdk.Tools.Tests.Support; +using Testably.Abstractions.Testing; +using Testably.Abstractions.Testing.Initializer; + +namespace OpenCode.Sdk.Tools.Tests.Generator.Binding; + +public sealed class CurationLoaderTests +{ + private const string CurationPath = "tools/curation.json"; + + [Test] + public async Task LoadAsync_Should_Read_Strict_Curation() + { + var fileSystem = CreateFileSystem("Binding.valid-curation.json"); + + var curation = await new CurationLoader(fileSystem).LoadAsync(CurationPath, CancellationToken.None); + + await Assert.That(curation.Groups["health"].Placement).IsEqualTo(GroupPlacement.Root); + await Assert.That(curation.EnvelopePayloadNames).IsEmpty(); + await Assert.That(curation.PropertyOverrides).IsEmpty(); + } + + [Test] + public async Task LoadAsync_Should_Refuse_Unknown_Fields() + { + var fileSystem = CreateFileSystem("Binding.unknown-curation-field.json"); + + var exception = await Assert + .That(async () => _ = await new CurationLoader(fileSystem).LoadAsync(CurationPath, CancellationToken.None)) + .Throws(); + + await Assert.That(exception!.Errors.Single().Category).IsEqualTo(BindingErrorCategory.Curation); + await Assert.That(exception.Errors.Single().Problem).Contains("mystery"); + } + + private static MockFileSystem CreateFileSystem(string fixtureName) + { + var fileSystem = new MockFileSystem(); + fileSystem.Initialize().With(new FileDescription(CurationPath, new FixtureLoader().Load(fixtureName))); + return fileSystem; + } +} diff --git a/tests/OpenCode.Sdk.Tools.Tests/Generator/Binding/OperationSelectionLoaderTests.cs b/tests/OpenCode.Sdk.Tools.Tests/Generator/Binding/OperationSelectionLoaderTests.cs new file mode 100644 index 00000000..3bbcf2c7 --- /dev/null +++ b/tests/OpenCode.Sdk.Tools.Tests/Generator/Binding/OperationSelectionLoaderTests.cs @@ -0,0 +1,51 @@ +using OpenCode.Sdk.Tools.Generator.Binding; +using OpenCode.Sdk.Tools.Generator.Binding.Models; +using Testably.Abstractions.Testing; +using Testably.Abstractions.Testing.Initializer; + +namespace OpenCode.Sdk.Tools.Tests.Generator.Binding; + +public sealed class OperationSelectionLoaderTests +{ + private const string ProfilePath = "tools/generation-profile.txt"; + + [Test] + public async Task LoadAsync_Should_Preserve_Operation_Order() + { + var fileSystem = new MockFileSystem(); + fileSystem.Initialize().With(new FileDescription(ProfilePath, "v2.health.get\nv2.session.message\n")); + + var selection = await new OperationSelectionLoader(fileSystem).LoadAsync(ProfilePath, CancellationToken.None); + + await Assert.That(selection.OperationIds).Count().IsEqualTo(2); + await Assert.That(selection.OperationIds[0]).IsEqualTo("v2.health.get"); + await Assert.That(selection.OperationIds[1]).IsEqualTo("v2.session.message"); + } + + [Test] + public async Task LoadAsync_Should_Refuse_Duplicate_Operation_IDs() + { + var fileSystem = new MockFileSystem(); + fileSystem.Initialize().With(new FileDescription(ProfilePath, "v2.health.get\nv2.health.get\n")); + + var exception = await Assert + .That(async () => _ = await new OperationSelectionLoader(fileSystem).LoadAsync(ProfilePath, CancellationToken.None)) + .Throws(); + + await Assert.That(exception!.Errors.Single().Category).IsEqualTo(BindingErrorCategory.Selection); + await Assert.That(exception.Errors.Single().Problem).Contains("duplicate"); + } + + [Test] + public async Task LoadAsync_Should_Refuse_Blank_Lines() + { + var fileSystem = new MockFileSystem(); + fileSystem.Initialize().With(new FileDescription(ProfilePath, "v2.health.get\n\nv2.session.message\n")); + + var exception = await Assert + .That(async () => _ = await new OperationSelectionLoader(fileSystem).LoadAsync(ProfilePath, CancellationToken.None)) + .Throws(); + + await Assert.That(exception!.Errors.Single().Problem).Contains("blank"); + } +} diff --git a/tests/OpenCode.Sdk.Tools.Tests/Generator/Binding/SpecBinderTests.cs b/tests/OpenCode.Sdk.Tools.Tests/Generator/Binding/SpecBinderTests.cs new file mode 100644 index 00000000..4d96acf4 --- /dev/null +++ b/tests/OpenCode.Sdk.Tools.Tests/Generator/Binding/SpecBinderTests.cs @@ -0,0 +1,274 @@ +using OpenCode.Sdk.Tools.Generator.Binding; +using OpenCode.Sdk.Tools.Generator.Binding.Models; +using OpenCode.Sdk.Tools.Generator.Ingestion; +using OpenCode.Sdk.Tools.Generator.Ingestion.Models; +using OpenCode.Sdk.Tools.Tests.Support; +using Testably.Abstractions; + +namespace OpenCode.Sdk.Tools.Tests.Generator.Binding; + +public sealed class SpecBinderTests +{ + private static readonly string[] ExpectedErrorTypeNames = + [ + "InvalidRequestError", + "MessageNotFoundError", + "SessionNotFoundError", + "UnauthorizedError", + ]; + + [Test] + public async Task Bind_Should_Create_The_Selected_Pinned_Emit_Plan() + { + var (document, selection, curation) = await LoadPinnedInputsAsync(); + + var plan = new BindingTestHost().Bind(document, selection, curation); + + await Assert.That(plan.SelectedOperationIds.SequenceEqual(selection.OperationIds, StringComparer.Ordinal)).IsTrue(); + await Assert.That(plan.PendingOperations.Any(static operation => operation.Surface == SpecSurface.Modern)).IsTrue(); + await Assert.That(plan.PendingOperations.Any(static operation => operation.Surface == SpecSurface.Legacy)).IsTrue(); + await Assert.That(plan.Models.Any(static model => model.Name == "PromptFileAttachment")).IsTrue(); + await Assert.That(plan.Models.Any(static model => model.Name == "Config")).IsFalse(); + + var promptFile = plan.Models.OfType().Single(static model => model.Name == "PromptFileAttachment"); + var promptUri = promptFile.Properties.Single(static property => property.WireName == "uri").Type; + await Assert.That(promptUri).IsTypeOf(); + await Assert.That(((NamedTypeReferencePlan)promptUri).Name).IsEqualTo("Uri"); + + var toolFile = plan.Models.OfType().Single(static model => model.Name == "ToolFileContent"); + var toolUri = toolFile.Properties.Single(static property => property.WireName == "uri").Type; + await Assert.That(toolUri).IsTypeOf(); + await Assert.That(((NamedTypeReferencePlan)toolUri).Name).IsEqualTo("Uri"); + + var sessionMessage = plan.Unions.Single(static union => union.Name == "SessionMessage"); + await Assert.That(sessionMessage.MarkerWireName).IsEqualTo("type"); + await Assert.That(sessionMessage.Variants).Count().IsEqualTo(8); + await Assert.That(plan.Unions.Any(static union => union.Name == "SessionMessageAssistantContent")).IsTrue(); + await Assert.That(plan.Unions.Any(static union => union.Name == "SessionMessageToolState")).IsTrue(); + await Assert.That(plan.Unions.Any(static union => union.Name == "LlmToolContent")).IsTrue(); + + var errors = plan.Unions.Single(static union => union.Name == "OpenCodeError"); + await Assert.That(errors.Variants.Select(static variant => variant.TypeName).Order(StringComparer.Ordinal) + .SequenceEqual(ExpectedErrorTypeNames, StringComparer.Ordinal)).IsTrue(); + } + + [Test] + public async Task Bind_Should_Be_Deterministic_For_The_Selected_Pin() + { + var (document, selection, curation) = await LoadPinnedInputsAsync(); + var host = new BindingTestHost(); + + var first = host.Bind(document, selection, curation); + var second = host.Bind(document, selection, curation); + + await Assert.That(first.Models.Select(static model => model.Name) + .SequenceEqual(second.Models.Select(static model => model.Name), StringComparer.Ordinal)).IsTrue(); + await Assert.That(first.Unions.Select(static union => union.Name) + .SequenceEqual(second.Unions.Select(static union => union.Name), StringComparer.Ordinal)).IsTrue(); + await Assert.That(first.Registry.TypeNames.SequenceEqual(second.Registry.TypeNames, StringComparer.Ordinal)).IsTrue(); + } + + [Test] + public async Task Bind_Should_Report_Selection_And_Curation_Errors_Together() + { + var document = await IngestAsync(SpecScenario.Define(spec => spec + .WithOperation("v2.health.get"))); + var selection = Selection("v2.health.get", "v2.missing.get"); + var curation = Curation( + new Dictionary(StringComparer.Ordinal) + { + ["health"] = RootGroup(), + }, + new Dictionary(StringComparer.Ordinal) + { + ["v2.orphan.get"] = "Orphan", + }); + + var exception = Assert.Throws(() => _ = new BindingTestHost().Bind(document, selection, curation)); + + await Assert.That(exception.Errors.Any(static error => error.Category == BindingErrorCategory.Selection)).IsTrue(); + await Assert.That(exception.Errors.Any(static error => error.Category == BindingErrorCategory.Curation)).IsTrue(); + } + + [Test] + public async Task Bind_Should_Refuse_Structural_Union_In_Selected_Closure() + { + var document = await IngestAsync(SpecScenario.Define(spec => spec + .WithSchema("Choice", schema => schema.AnyOf( + branch => branch.Type("string"), + branch => branch.Type("boolean"))) + .WithOperation("v2.health.get", configure: operation => operation + .Response(200, "application/json", schema => schema.Ref("Choice"))))); + + var exception = Assert.Throws(() => _ = new BindingTestHost().Bind( + document, + Selection("v2.health.get"), + Curation(new Dictionary(StringComparer.Ordinal) { ["health"] = RootGroup(), }))); + + await Assert.That(exception.Errors.Single(static error => error.Category == BindingErrorCategory.Schema).Problem) + .Contains("structural union"); + } + + [Test] + public async Task Bind_Should_Require_Selected_Envelope_Payload_Name() + { + var document = await IngestAsync(SpecScenario.Define(spec => spec + .WithOperation("v2.session.message", path: "/api/session/{sessionID}/message", configure: operation => operation + .Parameter("sessionID", "path", schema => schema.Type("string"), required: true) + .Response(200, "application/json", schema => schema.Type("object") + .Property("data", property => property.Type("string"), required: true))))); + var groups = new Dictionary(StringComparer.Ordinal) + { + ["session"] = ClientGroup(), + }; + + var exception = Assert.Throws(() => _ = new BindingTestHost().Bind( + document, + Selection("v2.session.message"), + Curation(groups))); + + await Assert.That(exception.Errors.Single(static error => error.Category == BindingErrorCategory.Curation).Problem) + .Contains("payload name"); + } + + [Test] + public async Task Bind_Should_Refuse_Operation_Curation_For_Pending_Operation() + { + var document = await IngestAsync(SpecScenario.Define(spec => spec + .WithOperation("v2.health.get") + .WithOperation("v2.session.message", path: "/api/session/message", configure: operation => operation + .Response(200, "application/json", schema => schema.Type("object") + .Property("data", property => property.Type("string"), required: true))))); + var envelopeNames = new Dictionary(StringComparer.Ordinal) + { + ["v2.session.message"] = "Message", + }; + + var exception = Assert.Throws(() => _ = new BindingTestHost().Bind( + document, + Selection("v2.health.get"), + Curation(new Dictionary(StringComparer.Ordinal) { ["health"] = RootGroup(), }, envelopeNames))); + + await Assert.That(exception.Errors.Single(static error => error.Category == BindingErrorCategory.Curation).Problem) + .Contains("not selected"); + } + + [Test] + public async Task Bind_Should_Collapse_Duplicate_References_Into_Semantic_Type_Plans() + { + var document = await IngestAsync(SpecScenario.Define(spec => spec + .WithSchema("Shared", schema => schema.Type("object") + .Property("value", property => property.Type("string"), required: true)) + .WithSchema("Container", schema => schema.Type("object") + .Property("first", property => property.Ref("Shared"), required: true) + .Property("items", property => property.Type("array").Items(item => item.Ref("Shared")), required: true)) + .WithOperation("v2.health.get", configure: operation => operation + .Response(200, "application/json", schema => schema.Ref("Container"))))); + + var plan = new BindingTestHost().Bind( + document, + Selection("v2.health.get"), + Curation(new Dictionary(StringComparer.Ordinal) { ["health"] = RootGroup(), })); + + await Assert.That(plan.Models.Count(static model => model.Name == "Shared")).IsEqualTo(1); + var container = plan.Models.OfType().Single(static model => model.Name == "Container"); + var items = container.Properties.Single(static property => property.WireName == "items").Type; + await Assert.That(items).IsTypeOf(); + await Assert.That(((ListTypeReferencePlan)items).ElementType).IsTypeOf(); + await Assert.That(((NamedTypeReferencePlan)((ListTypeReferencePlan)items).ElementType).Name).IsEqualTo("Shared"); + } + + [Test] + public async Task Bind_Should_Require_Curation_For_Every_Selected_Group() + { + var document = await IngestAsync(SpecScenario.Define(spec => spec.WithOperation("v2.health.get"))); + + var exception = Assert.Throws(() => _ = new BindingTestHost().Bind( + document, + Selection("v2.health.get"), + Curation(new Dictionary(StringComparer.Ordinal)))); + + await Assert.That(exception.Errors.Single(static error => error.Category == BindingErrorCategory.Curation).Problem) + .Contains("no curation row"); + } + + [Test] + public async Task Bind_Should_Report_Duplicate_Property_Overrides_As_Binding_Errors() + { + var document = await IngestAsync(SpecScenario.Define(spec => spec + .WithSchema("Resource", schema => schema.Type("object") + .Property("uri", property => property.Type("string"), required: true)) + .WithOperation("v2.health.get", configure: operation => operation + .Response(200, "application/json", schema => schema.Ref("Resource"))))); + var curation = Curation( + new Dictionary(StringComparer.Ordinal) { ["health"] = RootGroup(), }, + propertyOverrides: [UriOverride("Resource", "uri"), UriOverride("Resource", "uri")]); + + var exception = Assert.Throws(() => _ = new BindingTestHost().Bind( + document, + Selection("v2.health.get"), + curation)); + + await Assert.That(exception.Errors.Single(static error => error.Category == BindingErrorCategory.Curation).Problem) + .Contains("duplicated"); + } + + private static async Task<(SpecDocument Document, OperationSelection Selection, GenerationCuration Curation)> LoadPinnedInputsAsync() + { + var fileSystem = new RealFileSystem(); + var fixtureRoot = fileSystem.Path.Combine(AppContext.BaseDirectory, "Fixtures"); + var document = await new SpecIngestion(fileSystem) + .IngestAsync(fileSystem.Path.Combine(fixtureRoot, "openapi.json"), CancellationToken.None); + var selection = await new OperationSelectionLoader(fileSystem) + .LoadAsync(fileSystem.Path.Combine(fixtureRoot, "generation-profile.txt"), CancellationToken.None); + var curation = await new CurationLoader(fileSystem) + .LoadAsync(fileSystem.Path.Combine(fixtureRoot, "curation.json"), CancellationToken.None); + return (document, selection, curation); + } + + private static async Task IngestAsync(SpecScenario scenario) + { + var context = scenario.Build(); + return await new SpecIngestion(context.FileSystem).IngestAsync(context.SpecPath, CancellationToken.None); + } + + private static OperationSelection Selection(params string[] operationIds) => + new() + { + OperationIds = operationIds, + }; + + private static GenerationCuration Curation( + IReadOnlyDictionary groups, + IReadOnlyDictionary? envelopePayloadNames = null, + IReadOnlyList? propertyOverrides = null) => + new() + { + Groups = groups, + EnvelopePayloadNames = envelopePayloadNames ?? new Dictionary(StringComparer.Ordinal), + PropertyOverrides = propertyOverrides ?? [], + }; + + private static PropertyOverride UriOverride(string schema, string property) => + new() + { + Schema = schema, + Property = property, + Type = PropertyOverrideType.Uri, + Reason = "The fixture omits format: uri.", + }; + + private static GroupCuration RootGroup() => + new() + { + Placement = GroupPlacement.Root, + }; + + private static GroupCuration ClientGroup() => + new() + { + Placement = GroupPlacement.Client, + ClientName = "Sessions", + HandleName = "SessionClient", + }; +} diff --git a/tests/OpenCode.Sdk.Tools.Tests/OpenCode.Sdk.Tools.Tests.csproj b/tests/OpenCode.Sdk.Tools.Tests/OpenCode.Sdk.Tools.Tests.csproj index f8753874..78646c8c 100644 --- a/tests/OpenCode.Sdk.Tools.Tests/OpenCode.Sdk.Tools.Tests.csproj +++ b/tests/OpenCode.Sdk.Tools.Tests/OpenCode.Sdk.Tools.Tests.csproj @@ -21,6 +21,12 @@ + + diff --git a/tests/OpenCode.Sdk.Tools.Tests/Support/BindingTestHost.cs b/tests/OpenCode.Sdk.Tools.Tests/Support/BindingTestHost.cs new file mode 100644 index 00000000..ff75593c --- /dev/null +++ b/tests/OpenCode.Sdk.Tools.Tests/Support/BindingTestHost.cs @@ -0,0 +1,24 @@ +using OpenCode.Sdk.Tools.Generator.Binding; +using OpenCode.Sdk.Tools.Generator.Binding.Models; +using OpenCode.Sdk.Tools.Generator.Ingestion.Models; + +namespace OpenCode.Sdk.Tools.Tests.Support; + +internal sealed class BindingTestHost +{ + private readonly SpecBinder _binder; + + public BindingTestHost() + { + var schemaNames = new SchemaNameResolver(); + _binder = new SpecBinder( + new ReachableSchemaCollector(), + new CurationValidator(), + new SchemaPlanBinder(schemaNames)); + } + + public EmitPlan Bind(SpecDocument document, OperationSelection selection, GenerationCuration curation) + { + return _binder.Bind(document, selection, curation); + } +} diff --git a/tools/OpenCode.Sdk.Tools/Generator/Binding/Abstractions/ISpecBinder.cs b/tools/OpenCode.Sdk.Tools/Generator/Binding/Abstractions/ISpecBinder.cs new file mode 100644 index 00000000..280010b2 --- /dev/null +++ b/tools/OpenCode.Sdk.Tools/Generator/Binding/Abstractions/ISpecBinder.cs @@ -0,0 +1,9 @@ +using OpenCode.Sdk.Tools.Generator.Binding.Models; +using OpenCode.Sdk.Tools.Generator.Ingestion.Models; + +namespace OpenCode.Sdk.Tools.Generator.Binding.Abstractions; + +internal interface ISpecBinder +{ + public EmitPlan Bind(SpecDocument document, OperationSelection selection, GenerationCuration curation); +} diff --git a/tools/OpenCode.Sdk.Tools/Generator/Binding/BindingErrorCollector.cs b/tools/OpenCode.Sdk.Tools/Generator/Binding/BindingErrorCollector.cs new file mode 100644 index 00000000..28cfd31c --- /dev/null +++ b/tools/OpenCode.Sdk.Tools/Generator/Binding/BindingErrorCollector.cs @@ -0,0 +1,25 @@ +using OpenCode.Sdk.Tools.Generator.Binding.Models; + +namespace OpenCode.Sdk.Tools.Generator.Binding; + +internal sealed class BindingErrorCollector +{ + private readonly List _errors = []; + + public int Count => _errors.Count; + + public void Add(BindingErrorCategory category, string subject, string problem) + { + ArgumentException.ThrowIfNullOrWhiteSpace(subject); + ArgumentException.ThrowIfNullOrWhiteSpace(problem); + _errors.Add(new BindingError(category, subject, problem)); + } + + public void ThrowIfAny() + { + if (_errors.Count > 0) + { + throw new BindingException(_errors); + } + } +} diff --git a/tools/OpenCode.Sdk.Tools/Generator/Binding/CSharpNamePolicy.cs b/tools/OpenCode.Sdk.Tools/Generator/Binding/CSharpNamePolicy.cs new file mode 100644 index 00000000..76dfd77b --- /dev/null +++ b/tools/OpenCode.Sdk.Tools/Generator/Binding/CSharpNamePolicy.cs @@ -0,0 +1,111 @@ +using System.Text; + +namespace OpenCode.Sdk.Tools.Generator.Binding; + +internal static class CSharpNamePolicy +{ + public static string ToPascalCase(string wireName) + { + ArgumentException.ThrowIfNullOrWhiteSpace(wireName); + + var words = SplitWords(wireName); + var result = new StringBuilder(wireName.Length); + foreach (var word in words) + { + AppendPascalWord(result, word); + } + + if (result.Length is 0) + { + throw new ArgumentException("Wire name must contain at least one letter or digit.", nameof(wireName)); + } + + if (char.IsDigit(result[0])) + { + result.Insert(0, '_'); + } + + return result.ToString(); + } + + public static string ToCamelCase(string wireName) + { + var pascal = ToPascalCase(wireName); + return string.Concat(char.ToLowerInvariant(pascal[0]), pascal[1..]); + } + + public static bool IsValidIdentifier(string candidate) + { + if (string.IsNullOrEmpty(candidate) || !(char.IsLetter(candidate[0]) || candidate[0] is '_')) + { + return false; + } + + return candidate.Skip(1).All(static character => char.IsLetterOrDigit(character) || character is '_'); + } + + public static IReadOnlyList SplitWords(string value) + { + ArgumentException.ThrowIfNullOrWhiteSpace(value); + + var words = new List(); + var start = -1; + for (var index = 0; index < value.Length; index++) + { + var current = value[index]; + if (!char.IsLetterOrDigit(current)) + { + AddWord(value, start, index, words); + start = -1; + continue; + } + + if (start < 0) + { + start = index; + continue; + } + + var previous = value[index - 1]; + var nextIsLower = index + 1 < value.Length && char.IsLower(value[index + 1]); + var upperBoundary = char.IsUpper(current) + && (char.IsLower(previous) || char.IsDigit(previous) || (char.IsUpper(previous) && nextIsLower)); + var digitBoundary = char.IsDigit(current) != char.IsDigit(previous) + && (char.IsDigit(current) || char.IsDigit(previous)); + var boundary = upperBoundary || digitBoundary; + if (!boundary) + { + continue; + } + + AddWord(value, start, index, words); + start = index; + } + + AddWord(value, start, value.Length, words); + return Array.AsReadOnly([.. words]); + } + + private static void AppendPascalWord(StringBuilder result, string word) + { + if (word.Length is 2 && word.All(char.IsLetter)) + { + _ = result.Append(word.ToUpperInvariant()); + return; + } + + _ = result.Append(char.ToUpperInvariant(word[0])); + if (word.Length > 1) + { + _ = result.Append(word[1..].ToLowerInvariant()); + } + } + + private static void AddWord(string value, int start, int end, List words) + { + if (start >= 0 && end > start) + { + words.Add(value[start..end]); + } + } +} diff --git a/tools/OpenCode.Sdk.Tools/Generator/Binding/CurationLoader.cs b/tools/OpenCode.Sdk.Tools/Generator/Binding/CurationLoader.cs new file mode 100644 index 00000000..f6c4d204 --- /dev/null +++ b/tools/OpenCode.Sdk.Tools/Generator/Binding/CurationLoader.cs @@ -0,0 +1,53 @@ +using System.IO.Abstractions; +using System.Text.Json; +using System.Text.Json.Serialization; +using OpenCode.Sdk.Tools.Generator.Binding.Models; + +namespace OpenCode.Sdk.Tools.Generator.Binding; + +internal sealed class CurationLoader(IFileSystem fileSystem) +{ + private static readonly JsonSerializerOptions SerializerOptions = CreateSerializerOptions(); + private readonly IFileSystem _fileSystem = fileSystem ?? throw new ArgumentNullException(nameof(fileSystem)); + + public async Task LoadAsync(string curationPath, CancellationToken cancellationToken) + { + ArgumentException.ThrowIfNullOrWhiteSpace(curationPath); + + if (!_fileSystem.File.Exists(curationPath)) + { + throw CreateException(curationPath, "curation file was not found"); + } + + var stream = _fileSystem.File.OpenRead(curationPath); + await using (stream.ConfigureAwait(false)) + { + try + { + return await JsonSerializer.DeserializeAsync(stream, SerializerOptions, cancellationToken) + .ConfigureAwait(false) + ?? throw CreateException(curationPath, "curation document cannot be JSON null"); + } + catch (JsonException exception) + { + throw CreateException(curationPath, exception.Message); + } + } + } + + private static JsonSerializerOptions CreateSerializerOptions() + { + var options = new JsonSerializerOptions + { + AllowTrailingCommas = true, + ReadCommentHandling = JsonCommentHandling.Skip, + UnmappedMemberHandling = JsonUnmappedMemberHandling.Disallow, + }; + options.Converters.Add(new JsonStringEnumConverter(JsonNamingPolicy.CamelCase)); + options.Converters.Add(new JsonStringEnumConverter(JsonNamingPolicy.CamelCase)); + return options; + } + + private static BindingException CreateException(string subject, string problem) => + new(Array.AsReadOnly([new BindingError(BindingErrorCategory.Curation, subject, problem)])); +} diff --git a/tools/OpenCode.Sdk.Tools/Generator/Binding/CurationValidator.cs b/tools/OpenCode.Sdk.Tools/Generator/Binding/CurationValidator.cs new file mode 100644 index 00000000..3ae1092c --- /dev/null +++ b/tools/OpenCode.Sdk.Tools/Generator/Binding/CurationValidator.cs @@ -0,0 +1,156 @@ +using OpenCode.Sdk.Tools.Generator.Binding.Models; +using OpenCode.Sdk.Tools.Generator.Ingestion.Models; + +namespace OpenCode.Sdk.Tools.Generator.Binding; + +internal sealed class CurationValidator +{ + private readonly StringComparer _comparer = StringComparer.Ordinal; + + public void Validate(SpecDocument document, IReadOnlyList selected, ReachableSchemaSet reachable, + GenerationCuration curation, BindingErrorCollector errors) + { + ArgumentNullException.ThrowIfNull(document); + ArgumentNullException.ThrowIfNull(selected); + ArgumentNullException.ThrowIfNull(reachable); + ArgumentNullException.ThrowIfNull(curation); + ArgumentNullException.ThrowIfNull(errors); + + var selectedIds = selected.Select(static operation => operation.OperationId).ToHashSet(_comparer); + var selectedGroups = selected.Select(GetGroup).ToHashSet(_comparer); + var documentIds = document.Operations.Select(static operation => operation.OperationId).ToHashSet(_comparer); + var documentGroups = document.Operations.Select(GetGroup).ToHashSet(_comparer); + ValidateGroups(selected, selectedGroups, documentGroups, curation, errors); + ValidateEnvelopeNames(selected, selectedIds, documentIds, curation, errors); + ValidatePropertyOverrides(document, reachable, curation, errors); + } + + private static void ValidateGroups(IReadOnlyList selected, HashSet selectedGroups, + HashSet documentGroups, GenerationCuration curation, BindingErrorCollector errors) + { + foreach (var group in selectedGroups.Where(group => !curation.Groups.ContainsKey(group)).Order(StringComparer.Ordinal)) + { + errors.Add(BindingErrorCategory.Curation, group, "selected operation group has no curation row"); + } + + foreach (var (wireName, group) in curation.Groups.OrderBy(static pair => pair.Key, StringComparer.Ordinal)) + { + if (!documentGroups.Contains(wireName)) + { + errors.Add(BindingErrorCategory.Curation, wireName, "curated group does not exist in the spec"); + } + else if (!selectedGroups.Contains(wireName)) + { + errors.Add(BindingErrorCategory.Curation, wireName, "curated group is not selected by the current profile"); + } + + ValidateGroupShape(wireName, group, selected, errors); + } + } + + private static void ValidateGroupShape(string wireName, GroupCuration group, IReadOnlyList selected, + BindingErrorCollector errors) + { + switch (group.Placement) + { + case GroupPlacement.Root when group.ClientName is not null || group.HandleName is not null: + errors.Add(BindingErrorCategory.Curation, wireName, "root group cannot declare clientName or handleName"); + break; + case GroupPlacement.Client when string.IsNullOrWhiteSpace(group.ClientName): + errors.Add(BindingErrorCategory.Curation, wireName, "client group must declare clientName"); + break; + } + + if (group.ClientName is not null && !CSharpNamePolicy.IsValidIdentifier(group.ClientName)) + { + errors.Add(BindingErrorCategory.Naming, wireName, $"client name '{group.ClientName}' is not a valid C# identifier"); + } + + if (group.HandleName is not null && !CSharpNamePolicy.IsValidIdentifier(group.HandleName)) + { + errors.Add(BindingErrorCategory.Naming, wireName, $"handle name '{group.HandleName}' is not a valid C# identifier"); + } + + var requiresSessionHandle = selected.Any(operation => string.Equals(GetGroup(operation), wireName, StringComparison.Ordinal) + && operation.Parameters.Any(static parameter => parameter is + { + Name: "sessionID", + Location: SpecParameterLocation.Path, + })); + if (requiresSessionHandle && string.IsNullOrWhiteSpace(group.HandleName)) + { + errors.Add(BindingErrorCategory.Curation, wireName, "session-scoped selected operation requires handleName"); + } + } + + private static void ValidateEnvelopeNames(IReadOnlyList selected, HashSet selectedIds, + HashSet documentIds, GenerationCuration curation, BindingErrorCollector errors) + { + foreach (var operation in selected) + { + var requiresPayloadName = operation.Responses.Any(static response => response.StatusCode is >= 200 and < 300 + && response.EnvelopeShape is SpecEnvelopeShape.Data or SpecEnvelopeShape.DataLocation + or SpecEnvelopeShape.CursorData or SpecEnvelopeShape.DataHasMore); + if (requiresPayloadName && !curation.EnvelopePayloadNames.ContainsKey(operation.OperationId)) + { + errors.Add(BindingErrorCategory.Curation, operation.OperationId, "selected response envelope has no payload name"); + } + } + + foreach (var (operationId, payloadName) in curation.EnvelopePayloadNames.OrderBy(static pair => pair.Key, StringComparer.Ordinal)) + { + if (!documentIds.Contains(operationId)) + { + errors.Add(BindingErrorCategory.Curation, operationId, "curated operation does not exist in the spec"); + } + else if (!selectedIds.Contains(operationId)) + { + errors.Add(BindingErrorCategory.Curation, operationId, "curated operation is not selected by the current profile"); + } + + if (!CSharpNamePolicy.IsValidIdentifier(payloadName)) + { + errors.Add(BindingErrorCategory.Naming, operationId, $"payload name '{payloadName}' is not a valid C# identifier"); + } + } + } + + private static void ValidatePropertyOverrides(SpecDocument document, ReachableSchemaSet reachable, + GenerationCuration curation, BindingErrorCollector errors) + { + var reachableKeys = reachable.GraphKeys.ToHashSet(StringComparer.Ordinal); + var targets = new HashSet<(string Schema, string Property)>(); + foreach (var propertyOverride in curation.PropertyOverrides) + { + var subject = $"{propertyOverride.Schema}.{propertyOverride.Property}"; + if (!targets.Add((propertyOverride.Schema, propertyOverride.Property))) + { + errors.Add(BindingErrorCategory.Curation, subject, "property override is duplicated"); + } + + if (string.IsNullOrWhiteSpace(propertyOverride.Reason)) + { + errors.Add(BindingErrorCategory.Curation, subject, "property override must declare a reason"); + } + + if (!document.Schemas.TryGetValue(propertyOverride.Schema, out var schema)) + { + errors.Add(BindingErrorCategory.Curation, subject, "curated schema does not exist in the spec"); + continue; + } + + if (!reachableKeys.Contains(propertyOverride.Schema)) + { + errors.Add(BindingErrorCategory.Curation, subject, "curated property is not selected by the current profile"); + } + + if (schema is not ObjectNode objectSchema + || !objectSchema.Properties.Any(property => string.Equals(property.Name, propertyOverride.Property, StringComparison.Ordinal))) + { + errors.Add(BindingErrorCategory.Curation, subject, "curated property does not exist on the schema"); + } + } + } + + private static string GetGroup(SpecOperation operation) => operation.Segments[0]; +} diff --git a/tools/OpenCode.Sdk.Tools/Generator/Binding/Models/BindingError.cs b/tools/OpenCode.Sdk.Tools/Generator/Binding/Models/BindingError.cs new file mode 100644 index 00000000..60b68832 --- /dev/null +++ b/tools/OpenCode.Sdk.Tools/Generator/Binding/Models/BindingError.cs @@ -0,0 +1,7 @@ +namespace OpenCode.Sdk.Tools.Generator.Binding.Models; + +/// Describes one located failure discovered while binding selected operations. +/// The binding stage that refused the input. +/// The operation, schema, or curation key associated with the failure. +/// A description of the failure. +public sealed record BindingError(BindingErrorCategory Category, string Subject, string Problem); diff --git a/tools/OpenCode.Sdk.Tools/Generator/Binding/Models/BindingErrorCategory.cs b/tools/OpenCode.Sdk.Tools/Generator/Binding/Models/BindingErrorCategory.cs new file mode 100644 index 00000000..d231e729 --- /dev/null +++ b/tools/OpenCode.Sdk.Tools/Generator/Binding/Models/BindingErrorCategory.cs @@ -0,0 +1,17 @@ +namespace OpenCode.Sdk.Tools.Generator.Binding.Models; + +/// Identifies the generator stage that refused a binding input. +public enum BindingErrorCategory +{ + /// The checked-in operation selection is invalid. + Selection = 0, + + /// The public API curation is invalid or incomplete. + Curation = 1, + + /// A selected schema cannot be represented by the emitter plan. + Schema = 2, + + /// A selected wire name cannot be mapped to a unique C# identifier. + Naming = 3, +} diff --git a/tools/OpenCode.Sdk.Tools/Generator/Binding/Models/BindingException.cs b/tools/OpenCode.Sdk.Tools/Generator/Binding/Models/BindingException.cs new file mode 100644 index 00000000..2de74c42 --- /dev/null +++ b/tools/OpenCode.Sdk.Tools/Generator/Binding/Models/BindingException.cs @@ -0,0 +1,76 @@ +using System.Collections.ObjectModel; +using System.Globalization; +using System.Text; + +namespace OpenCode.Sdk.Tools.Generator.Binding.Models; + +/// Represents one or more failures that prevent generator binding. +public sealed class BindingException : Exception +{ + private static readonly IReadOnlyList NoErrors = Array.AsReadOnly(Array.Empty()); + + /// Initializes an exception without binding errors. + public BindingException() + { + Errors = NoErrors; + } + + /// Initializes an exception with a caller-supplied message. + /// The message that describes the exception. + public BindingException(string message) + : base(message ?? throw new ArgumentNullException(nameof(message))) + { + Errors = NoErrors; + } + + /// Initializes an exception with a caller-supplied message and inner exception. + /// The message that describes the exception. + /// The exception that caused this exception. + public BindingException(string message, Exception innerException) + : base(message ?? throw new ArgumentNullException(nameof(message)), innerException ?? throw new ArgumentNullException(nameof(innerException))) + { + Errors = NoErrors; + } + + /// Initializes an exception for the supplied binding errors. + /// The failures that prevent binding. + public BindingException(IReadOnlyList errors) + : this(CopyErrors(errors)) + { + } + + private BindingException(ReadOnlyCollection errors) + : base(CreateMessage(errors)) + { + Errors = errors; + } + + /// Gets the failures that prevent binding. + public IReadOnlyList Errors { get; } + + private static ReadOnlyCollection CopyErrors(IReadOnlyList errors) + { + ArgumentNullException.ThrowIfNull(errors); + return Array.AsReadOnly([.. errors]); + } + + private static string CreateMessage(ReadOnlyCollection errors) + { + var message = new StringBuilder("Binding failed with ") + .Append(errors.Count.ToString(CultureInfo.InvariantCulture)) + .Append(" error(s):"); + foreach (var error in errors) + { + message + .Append('\n') + .Append("- ") + .Append(error.Category) + .Append(" [") + .Append(error.Subject) + .Append("]: ") + .Append(error.Problem); + } + + return message.ToString(); + } +} diff --git a/tools/OpenCode.Sdk.Tools/Generator/Binding/Models/DictionaryTypeReferencePlan.cs b/tools/OpenCode.Sdk.Tools/Generator/Binding/Models/DictionaryTypeReferencePlan.cs new file mode 100644 index 00000000..b0c3d3b0 --- /dev/null +++ b/tools/OpenCode.Sdk.Tools/Generator/Binding/Models/DictionaryTypeReferencePlan.cs @@ -0,0 +1,8 @@ +namespace OpenCode.Sdk.Tools.Generator.Binding.Models; + +internal sealed record DictionaryTypeReferencePlan : TypeReferencePlan +{ + public required TypeReferencePlan ValueType { get; init; } + + public override bool IsCollection => true; +} diff --git a/tools/OpenCode.Sdk.Tools/Generator/Binding/Models/EmitPlan.cs b/tools/OpenCode.Sdk.Tools/Generator/Binding/Models/EmitPlan.cs new file mode 100644 index 00000000..aec3e283 --- /dev/null +++ b/tools/OpenCode.Sdk.Tools/Generator/Binding/Models/EmitPlan.cs @@ -0,0 +1,46 @@ +namespace OpenCode.Sdk.Tools.Generator.Binding.Models; + +internal sealed record EmitPlan +{ + public required IReadOnlyList SelectedOperationIds + { + get; + init + { + ArgumentNullException.ThrowIfNull(value); + field = Array.AsReadOnly([.. value]); + } + } = Array.AsReadOnly(Array.Empty()); + + public required IReadOnlyList Models + { + get; + init + { + ArgumentNullException.ThrowIfNull(value); + field = Array.AsReadOnly([.. value]); + } + } = Array.AsReadOnly(Array.Empty()); + + public required IReadOnlyList Unions + { + get; + init + { + ArgumentNullException.ThrowIfNull(value); + field = Array.AsReadOnly([.. value]); + } + } = Array.AsReadOnly(Array.Empty()); + + public required RegistryPlan Registry { get; init; } + + public required IReadOnlyList PendingOperations + { + get; + init + { + ArgumentNullException.ThrowIfNull(value); + field = Array.AsReadOnly([.. value]); + } + } = Array.AsReadOnly(Array.Empty()); +} diff --git a/tools/OpenCode.Sdk.Tools/Generator/Binding/Models/EnumModelPlan.cs b/tools/OpenCode.Sdk.Tools/Generator/Binding/Models/EnumModelPlan.cs new file mode 100644 index 00000000..1b79a362 --- /dev/null +++ b/tools/OpenCode.Sdk.Tools/Generator/Binding/Models/EnumModelPlan.cs @@ -0,0 +1,14 @@ +namespace OpenCode.Sdk.Tools.Generator.Binding.Models; + +internal sealed record EnumModelPlan : ModelPlan +{ + public required IReadOnlyList Values + { + get; + init + { + ArgumentNullException.ThrowIfNull(value); + field = Array.AsReadOnly([.. value]); + } + } = Array.AsReadOnly(Array.Empty()); +} diff --git a/tools/OpenCode.Sdk.Tools/Generator/Binding/Models/EnumValuePlan.cs b/tools/OpenCode.Sdk.Tools/Generator/Binding/Models/EnumValuePlan.cs new file mode 100644 index 00000000..24533acf --- /dev/null +++ b/tools/OpenCode.Sdk.Tools/Generator/Binding/Models/EnumValuePlan.cs @@ -0,0 +1,8 @@ +namespace OpenCode.Sdk.Tools.Generator.Binding.Models; + +internal sealed record EnumValuePlan +{ + public required string Name { get; init; } + + public required string WireValue { get; init; } +} diff --git a/tools/OpenCode.Sdk.Tools/Generator/Binding/Models/GenerationCuration.cs b/tools/OpenCode.Sdk.Tools/Generator/Binding/Models/GenerationCuration.cs new file mode 100644 index 00000000..d40cb38c --- /dev/null +++ b/tools/OpenCode.Sdk.Tools/Generator/Binding/Models/GenerationCuration.cs @@ -0,0 +1,40 @@ +using System.Collections.ObjectModel; +using System.Text.Json.Serialization; + +namespace OpenCode.Sdk.Tools.Generator.Binding.Models; + +internal sealed record GenerationCuration +{ + [JsonPropertyName("groups")] + public required IReadOnlyDictionary Groups + { + get; + init + { + ArgumentNullException.ThrowIfNull(value); + field = new ReadOnlyDictionary(new Dictionary(value, StringComparer.Ordinal)); + } + } = new ReadOnlyDictionary(new Dictionary(StringComparer.Ordinal)); + + [JsonPropertyName("envelopePayloadNames")] + public required IReadOnlyDictionary EnvelopePayloadNames + { + get; + init + { + ArgumentNullException.ThrowIfNull(value); + field = new ReadOnlyDictionary(new Dictionary(value, StringComparer.Ordinal)); + } + } = new ReadOnlyDictionary(new Dictionary(StringComparer.Ordinal)); + + [JsonPropertyName("propertyOverrides")] + public required IReadOnlyList PropertyOverrides + { + get; + init + { + ArgumentNullException.ThrowIfNull(value); + field = Array.AsReadOnly([.. value]); + } + } = Array.AsReadOnly(Array.Empty()); +} diff --git a/tools/OpenCode.Sdk.Tools/Generator/Binding/Models/GroupCuration.cs b/tools/OpenCode.Sdk.Tools/Generator/Binding/Models/GroupCuration.cs new file mode 100644 index 00000000..0270c47b --- /dev/null +++ b/tools/OpenCode.Sdk.Tools/Generator/Binding/Models/GroupCuration.cs @@ -0,0 +1,15 @@ +using System.Text.Json.Serialization; + +namespace OpenCode.Sdk.Tools.Generator.Binding.Models; + +internal sealed record GroupCuration +{ + [JsonPropertyName("placement")] + public required GroupPlacement Placement { get; init; } + + [JsonPropertyName("clientName")] + public string? ClientName { get; init; } + + [JsonPropertyName("handleName")] + public string? HandleName { get; init; } +} diff --git a/tools/OpenCode.Sdk.Tools/Generator/Binding/Models/GroupPlacement.cs b/tools/OpenCode.Sdk.Tools/Generator/Binding/Models/GroupPlacement.cs new file mode 100644 index 00000000..7a6d97bc --- /dev/null +++ b/tools/OpenCode.Sdk.Tools/Generator/Binding/Models/GroupPlacement.cs @@ -0,0 +1,7 @@ +namespace OpenCode.Sdk.Tools.Generator.Binding.Models; + +internal enum GroupPlacement +{ + Root = 0, + Client = 1, +} diff --git a/tools/OpenCode.Sdk.Tools/Generator/Binding/Models/ListTypeReferencePlan.cs b/tools/OpenCode.Sdk.Tools/Generator/Binding/Models/ListTypeReferencePlan.cs new file mode 100644 index 00000000..7319ccb7 --- /dev/null +++ b/tools/OpenCode.Sdk.Tools/Generator/Binding/Models/ListTypeReferencePlan.cs @@ -0,0 +1,8 @@ +namespace OpenCode.Sdk.Tools.Generator.Binding.Models; + +internal sealed record ListTypeReferencePlan : TypeReferencePlan +{ + public required TypeReferencePlan ElementType { get; init; } + + public override bool IsCollection => true; +} diff --git a/tools/OpenCode.Sdk.Tools/Generator/Binding/Models/ModelPlan.cs b/tools/OpenCode.Sdk.Tools/Generator/Binding/Models/ModelPlan.cs new file mode 100644 index 00000000..f4b7ed51 --- /dev/null +++ b/tools/OpenCode.Sdk.Tools/Generator/Binding/Models/ModelPlan.cs @@ -0,0 +1,10 @@ +namespace OpenCode.Sdk.Tools.Generator.Binding.Models; + +internal abstract record ModelPlan +{ + public required string Name { get; init; } + + public required string Namespace { get; init; } + + public string? Description { get; init; } +} diff --git a/tools/OpenCode.Sdk.Tools/Generator/Binding/Models/ModelPropertyPlan.cs b/tools/OpenCode.Sdk.Tools/Generator/Binding/Models/ModelPropertyPlan.cs new file mode 100644 index 00000000..38fd6bbc --- /dev/null +++ b/tools/OpenCode.Sdk.Tools/Generator/Binding/Models/ModelPropertyPlan.cs @@ -0,0 +1,22 @@ +using OpenCode.Sdk.Tools.Generator.Ingestion.Models; + +namespace OpenCode.Sdk.Tools.Generator.Binding.Models; + +internal sealed record ModelPropertyPlan +{ + public required string WireName { get; init; } + + public required string Name { get; init; } + + public required TypeReferencePlan Type { get; init; } + + public required bool IsRequired { get; init; } + + public required bool IsLiteral { get; init; } + + public LiteralKind? LiteralKind { get; init; } + + public string? LiteralValue { get; init; } + + public string? Description { get; init; } +} diff --git a/tools/OpenCode.Sdk.Tools/Generator/Binding/Models/NamedTypeReferencePlan.cs b/tools/OpenCode.Sdk.Tools/Generator/Binding/Models/NamedTypeReferencePlan.cs new file mode 100644 index 00000000..787f19e3 --- /dev/null +++ b/tools/OpenCode.Sdk.Tools/Generator/Binding/Models/NamedTypeReferencePlan.cs @@ -0,0 +1,8 @@ +namespace OpenCode.Sdk.Tools.Generator.Binding.Models; + +internal sealed record NamedTypeReferencePlan : TypeReferencePlan +{ + public required string Name { get; init; } + + public override bool IsCollection => false; +} diff --git a/tools/OpenCode.Sdk.Tools/Generator/Binding/Models/ObjectModelPlan.cs b/tools/OpenCode.Sdk.Tools/Generator/Binding/Models/ObjectModelPlan.cs new file mode 100644 index 00000000..7cd90e7d --- /dev/null +++ b/tools/OpenCode.Sdk.Tools/Generator/Binding/Models/ObjectModelPlan.cs @@ -0,0 +1,16 @@ +namespace OpenCode.Sdk.Tools.Generator.Binding.Models; + +internal sealed record ObjectModelPlan : ModelPlan +{ + public required IReadOnlyList Properties + { + get; + init + { + ArgumentNullException.ThrowIfNull(value); + field = Array.AsReadOnly([.. value]); + } + } = Array.AsReadOnly(Array.Empty()); + + public string? BaseTypeName { get; init; } +} diff --git a/tools/OpenCode.Sdk.Tools/Generator/Binding/Models/OperationSelection.cs b/tools/OpenCode.Sdk.Tools/Generator/Binding/Models/OperationSelection.cs new file mode 100644 index 00000000..43b47673 --- /dev/null +++ b/tools/OpenCode.Sdk.Tools/Generator/Binding/Models/OperationSelection.cs @@ -0,0 +1,14 @@ +namespace OpenCode.Sdk.Tools.Generator.Binding.Models; + +internal sealed record OperationSelection +{ + public required IReadOnlyList OperationIds + { + get; + init + { + ArgumentNullException.ThrowIfNull(value); + field = Array.AsReadOnly([.. value]); + } + } = Array.AsReadOnly(Array.Empty()); +} diff --git a/tools/OpenCode.Sdk.Tools/Generator/Binding/Models/PendingOperationPlan.cs b/tools/OpenCode.Sdk.Tools/Generator/Binding/Models/PendingOperationPlan.cs new file mode 100644 index 00000000..762a30aa --- /dev/null +++ b/tools/OpenCode.Sdk.Tools/Generator/Binding/Models/PendingOperationPlan.cs @@ -0,0 +1,10 @@ +using OpenCode.Sdk.Tools.Generator.Ingestion.Models; + +namespace OpenCode.Sdk.Tools.Generator.Binding.Models; + +internal sealed record PendingOperationPlan +{ + public required string OperationId { get; init; } + + public required SpecSurface Surface { get; init; } +} diff --git a/tools/OpenCode.Sdk.Tools/Generator/Binding/Models/PropertyOverride.cs b/tools/OpenCode.Sdk.Tools/Generator/Binding/Models/PropertyOverride.cs new file mode 100644 index 00000000..0dbffe88 --- /dev/null +++ b/tools/OpenCode.Sdk.Tools/Generator/Binding/Models/PropertyOverride.cs @@ -0,0 +1,18 @@ +using System.Text.Json.Serialization; + +namespace OpenCode.Sdk.Tools.Generator.Binding.Models; + +internal sealed record PropertyOverride +{ + [JsonPropertyName("schema")] + public required string Schema { get; init; } + + [JsonPropertyName("property")] + public required string Property { get; init; } + + [JsonPropertyName("type")] + public required PropertyOverrideType Type { get; init; } + + [JsonPropertyName("reason")] + public required string Reason { get; init; } +} diff --git a/tools/OpenCode.Sdk.Tools/Generator/Binding/Models/PropertyOverrideType.cs b/tools/OpenCode.Sdk.Tools/Generator/Binding/Models/PropertyOverrideType.cs new file mode 100644 index 00000000..1c155a23 --- /dev/null +++ b/tools/OpenCode.Sdk.Tools/Generator/Binding/Models/PropertyOverrideType.cs @@ -0,0 +1,6 @@ +namespace OpenCode.Sdk.Tools.Generator.Binding.Models; + +internal enum PropertyOverrideType +{ + Uri = 0, +} diff --git a/tools/OpenCode.Sdk.Tools/Generator/Binding/Models/ReachableSchemaSet.cs b/tools/OpenCode.Sdk.Tools/Generator/Binding/Models/ReachableSchemaSet.cs new file mode 100644 index 00000000..b4e3180a --- /dev/null +++ b/tools/OpenCode.Sdk.Tools/Generator/Binding/Models/ReachableSchemaSet.cs @@ -0,0 +1,24 @@ +namespace OpenCode.Sdk.Tools.Generator.Binding.Models; + +internal sealed record ReachableSchemaSet +{ + public required IReadOnlyList GraphKeys + { + get; + init + { + ArgumentNullException.ThrowIfNull(value); + field = Array.AsReadOnly([.. value]); + } + } = Array.AsReadOnly(Array.Empty()); + + public required IReadOnlyList ResponseRootKeys + { + get; + init + { + ArgumentNullException.ThrowIfNull(value); + field = Array.AsReadOnly([.. value]); + } + } = Array.AsReadOnly(Array.Empty()); +} diff --git a/tools/OpenCode.Sdk.Tools/Generator/Binding/Models/RegistryPlan.cs b/tools/OpenCode.Sdk.Tools/Generator/Binding/Models/RegistryPlan.cs new file mode 100644 index 00000000..23329e8d --- /dev/null +++ b/tools/OpenCode.Sdk.Tools/Generator/Binding/Models/RegistryPlan.cs @@ -0,0 +1,14 @@ +namespace OpenCode.Sdk.Tools.Generator.Binding.Models; + +internal sealed record RegistryPlan +{ + public required IReadOnlyList TypeNames + { + get; + init + { + ArgumentNullException.ThrowIfNull(value); + field = Array.AsReadOnly([.. value]); + } + } = Array.AsReadOnly(Array.Empty()); +} diff --git a/tools/OpenCode.Sdk.Tools/Generator/Binding/Models/SchemaBindingResult.cs b/tools/OpenCode.Sdk.Tools/Generator/Binding/Models/SchemaBindingResult.cs new file mode 100644 index 00000000..7a8f2a45 --- /dev/null +++ b/tools/OpenCode.Sdk.Tools/Generator/Binding/Models/SchemaBindingResult.cs @@ -0,0 +1,10 @@ +namespace OpenCode.Sdk.Tools.Generator.Binding.Models; + +internal sealed record SchemaBindingResult +{ + public required IReadOnlyList Models { get; init; } + + public required IReadOnlyList Unions { get; init; } + + public required RegistryPlan Registry { get; init; } +} diff --git a/tools/OpenCode.Sdk.Tools/Generator/Binding/Models/TypeReferencePlan.cs b/tools/OpenCode.Sdk.Tools/Generator/Binding/Models/TypeReferencePlan.cs new file mode 100644 index 00000000..bd09940e --- /dev/null +++ b/tools/OpenCode.Sdk.Tools/Generator/Binding/Models/TypeReferencePlan.cs @@ -0,0 +1,8 @@ +namespace OpenCode.Sdk.Tools.Generator.Binding.Models; + +internal abstract record TypeReferencePlan +{ + public required bool IsNullable { get; init; } + + public abstract bool IsCollection { get; } +} diff --git a/tools/OpenCode.Sdk.Tools/Generator/Binding/Models/UnionPlan.cs b/tools/OpenCode.Sdk.Tools/Generator/Binding/Models/UnionPlan.cs new file mode 100644 index 00000000..97ff2ac8 --- /dev/null +++ b/tools/OpenCode.Sdk.Tools/Generator/Binding/Models/UnionPlan.cs @@ -0,0 +1,30 @@ +using OpenCode.Sdk.Tools.Generator.Ingestion.Models; + +namespace OpenCode.Sdk.Tools.Generator.Binding.Models; + +internal sealed record UnionPlan +{ + public required string Name { get; init; } + + public required string Namespace { get; init; } + + public required string UnknownTypeName { get; init; } + + public required string MarkerWireName { get; init; } + + public required string MarkerName { get; init; } + + public required LiteralKind MarkerKind { get; init; } + + public required IReadOnlyList Variants + { + get; + init + { + ArgumentNullException.ThrowIfNull(value); + field = Array.AsReadOnly([.. value]); + } + } = Array.AsReadOnly(Array.Empty()); + + public string? Description { get; init; } +} diff --git a/tools/OpenCode.Sdk.Tools/Generator/Binding/Models/UnionVariantPlan.cs b/tools/OpenCode.Sdk.Tools/Generator/Binding/Models/UnionVariantPlan.cs new file mode 100644 index 00000000..e2ae45e5 --- /dev/null +++ b/tools/OpenCode.Sdk.Tools/Generator/Binding/Models/UnionVariantPlan.cs @@ -0,0 +1,8 @@ +namespace OpenCode.Sdk.Tools.Generator.Binding.Models; + +internal sealed record UnionVariantPlan +{ + public required string TypeName { get; init; } + + public required string Tag { get; init; } +} diff --git a/tools/OpenCode.Sdk.Tools/Generator/Binding/OperationSelectionLoader.cs b/tools/OpenCode.Sdk.Tools/Generator/Binding/OperationSelectionLoader.cs new file mode 100644 index 00000000..2d941dbe --- /dev/null +++ b/tools/OpenCode.Sdk.Tools/Generator/Binding/OperationSelectionLoader.cs @@ -0,0 +1,60 @@ +using System.IO.Abstractions; +using OpenCode.Sdk.Tools.Generator.Binding.Models; + +namespace OpenCode.Sdk.Tools.Generator.Binding; + +internal sealed class OperationSelectionLoader(IFileSystem fileSystem) +{ + private readonly IFileSystem _fileSystem = fileSystem ?? throw new ArgumentNullException(nameof(fileSystem)); + + public async Task LoadAsync(string profilePath, CancellationToken cancellationToken) + { + ArgumentException.ThrowIfNullOrWhiteSpace(profilePath); + + var errors = new BindingErrorCollector(); + if (!_fileSystem.File.Exists(profilePath)) + { + errors.Add(BindingErrorCategory.Selection, profilePath, "generation profile was not found"); + errors.ThrowIfAny(); + } + + var lines = await _fileSystem.File.ReadAllLinesAsync(profilePath, cancellationToken).ConfigureAwait(false); + var operationIds = new List(lines.Length); + var seen = new HashSet(StringComparer.Ordinal); + for (var index = 0; index < lines.Length; index++) + { + var line = lines[index]; + var subject = $"{profilePath}:{(index + 1).ToString(System.Globalization.CultureInfo.InvariantCulture)}"; + if (string.IsNullOrWhiteSpace(line)) + { + errors.Add(BindingErrorCategory.Selection, subject, "generation profile cannot contain a blank line"); + continue; + } + + if (!string.Equals(line, line.Trim(), StringComparison.Ordinal)) + { + errors.Add(BindingErrorCategory.Selection, subject, "operation ID cannot carry leading or trailing whitespace"); + continue; + } + + if (!seen.Add(line)) + { + errors.Add(BindingErrorCategory.Selection, subject, $"duplicate operation ID '{line}'"); + continue; + } + + operationIds.Add(line); + } + + if (operationIds.Count is 0) + { + errors.Add(BindingErrorCategory.Selection, profilePath, "generation profile must select at least one operation"); + } + + errors.ThrowIfAny(); + return new OperationSelection + { + OperationIds = operationIds, + }; + } +} diff --git a/tools/OpenCode.Sdk.Tools/Generator/Binding/ReachableSchemaCollector.cs b/tools/OpenCode.Sdk.Tools/Generator/Binding/ReachableSchemaCollector.cs new file mode 100644 index 00000000..21448a7c --- /dev/null +++ b/tools/OpenCode.Sdk.Tools/Generator/Binding/ReachableSchemaCollector.cs @@ -0,0 +1,99 @@ +using OpenCode.Sdk.Tools.Generator.Binding.Models; +using OpenCode.Sdk.Tools.Generator.Ingestion.Models; + +namespace OpenCode.Sdk.Tools.Generator.Binding; + +internal sealed class ReachableSchemaCollector +{ + private readonly StringComparer _comparer = StringComparer.Ordinal; + + public ReachableSchemaSet Collect(SpecDocument document, IReadOnlyList operations, BindingErrorCollector errors) + { + ArgumentNullException.ThrowIfNull(document); + ArgumentNullException.ThrowIfNull(operations); + ArgumentNullException.ThrowIfNull(errors); + + var traversal = new ReachabilityTraversal(document.Schemas, errors, _comparer); + foreach (var operation in operations) + { + foreach (var parameter in operation.Parameters) + { + traversal.Visit(parameter.Schema); + } + + if (operation.RequestBody is not null) + { + traversal.Visit(operation.RequestBody.Schema); + } + + foreach (var response in operation.Responses) + { + traversal.VisitResponse(operation.OperationId, response.Schema); + } + } + + return traversal.Snapshot(); + } + + private sealed class ReachabilityTraversal(IReadOnlyDictionary graph, BindingErrorCollector errors, + StringComparer comparer) + { + private readonly StringComparer _comparer = comparer ?? throw new ArgumentNullException(nameof(comparer)); + private readonly BindingErrorCollector _errors = errors ?? throw new ArgumentNullException(nameof(errors)); + private readonly IReadOnlyDictionary _graph = graph ?? throw new ArgumentNullException(nameof(graph)); + private readonly HashSet _keys = new(comparer); + private readonly HashSet _responseRoots = new(comparer); + + public void VisitResponse(string operationId, SchemaNode? schema) + { + ArgumentException.ThrowIfNullOrWhiteSpace(operationId); + if (schema is RefNode reference && reference.Target.StartsWith($"op:{operationId}#", StringComparison.Ordinal)) + { + _ = _responseRoots.Add(reference.Target); + } + + if (schema is not null) + { + Visit(schema); + } + } + + public void Visit(SchemaNode schema) + { + ArgumentNullException.ThrowIfNull(schema); + if (schema is RefNode reference) + { + VisitReference(reference.Target); + return; + } + + foreach (var child in schema.Children) + { + Visit(child); + } + } + + public ReachableSchemaSet Snapshot() => + new() + { + GraphKeys = [.. _keys.Order(_comparer)], + ResponseRootKeys = [.. _responseRoots.Order(_comparer)], + }; + + private void VisitReference(string target) + { + if (!_keys.Add(target)) + { + return; + } + + if (!_graph.TryGetValue(target, out var targetSchema)) + { + _errors.Add(BindingErrorCategory.Schema, target, "referenced schema is absent from the ingested graph"); + return; + } + + Visit(targetSchema); + } + } +} diff --git a/tools/OpenCode.Sdk.Tools/Generator/Binding/SchemaNameResolver.cs b/tools/OpenCode.Sdk.Tools/Generator/Binding/SchemaNameResolver.cs new file mode 100644 index 00000000..98f370fd --- /dev/null +++ b/tools/OpenCode.Sdk.Tools/Generator/Binding/SchemaNameResolver.cs @@ -0,0 +1,120 @@ +using System.Collections.ObjectModel; +using OpenCode.Sdk.Tools.Generator.Binding.Models; +using OpenCode.Sdk.Tools.Generator.Ingestion.Models; + +namespace OpenCode.Sdk.Tools.Generator.Binding; + +internal sealed class SchemaNameResolver +{ + private readonly StringComparer _comparer = StringComparer.Ordinal; + + public IReadOnlyDictionary Resolve(SpecDocument document, ReachableSchemaSet reachable, + BindingErrorCollector errors) + { + ArgumentNullException.ThrowIfNull(document); + ArgumentNullException.ThrowIfNull(reachable); + ArgumentNullException.ThrowIfNull(errors); + + var responseRoots = reachable.ResponseRootKeys.ToHashSet(_comparer); + var result = new Dictionary(_comparer); + var owners = new Dictionary(_comparer); + foreach (var key in reachable.GraphKeys) + { + if (responseRoots.Contains(key) || !document.Schemas.TryGetValue(key, out var schema) || !IsNominal(schema)) + { + continue; + } + + var name = schema is UnionNode union ? ResolveUnionName(key, union, document.Schemas) : ResolveDefault(key); + if (owners.TryGetValue(name, out var existing)) + { + errors.Add(BindingErrorCategory.Naming, key, $"C# type name '{name}' collides with schema '{existing}'"); + continue; + } + + owners.Add(name, key); + result.Add(key, name); + } + + return new ReadOnlyDictionary(result); + } + + private static bool IsNominal(SchemaNode schema) => schema is ObjectNode or EnumNode or UnionNode; + + private string ResolveUnionName(string key, UnionNode union, IReadOnlyDictionary graph) + { + if (!key.Contains('#', StringComparison.Ordinal)) + { + return ResolveDefault(key); + } + + var branchNames = union.Branches + .OfType() + .Where(reference => graph.ContainsKey(reference.Target)) + .Select(reference => ResolveDefault(reference.Target)) + .ToArray(); + if (branchNames.Length != union.Branches.Count) + { + return ResolveDefault(key); + } + + var commonWords = CSharpNamePolicy.SplitWords(branchNames[0]).ToArray(); + foreach (var branchName in branchNames.Skip(1)) + { + var words = CSharpNamePolicy.SplitWords(branchName); + var commonLength = 0; + while (commonLength < commonWords.Length && commonLength < words.Count + && StringComparer.OrdinalIgnoreCase.Equals(commonWords[commonLength], words[commonLength])) + { + commonLength++; + } + + commonWords = commonWords[..commonLength]; + } + + var owner = CSharpNamePolicy.ToPascalCase(GetRoot(key)); + var common = string.Concat(commonWords.Select(CSharpNamePolicy.ToPascalCase)); + return common.Length > 0 && !_comparer.Equals(common, owner) + ? common + : ResolveDefault(key); + } + + private static string ResolveDefault(string key) + { + var root = GetRoot(key); + var result = new System.Text.StringBuilder(CSharpNamePolicy.ToPascalCase(NormalizeRoot(root))); + var hash = key.IndexOf('#', StringComparison.Ordinal); + if (hash < 0) + { + return result.ToString(); + } + + var segments = key[(hash + 1)..].Split('/', StringSplitOptions.RemoveEmptyEntries); + var index = 0; + while (index < segments.Length - 1) + { + if (!string.Equals(segments[index], "properties", StringComparison.Ordinal)) + { + index++; + continue; + } + + _ = result.Append(CSharpNamePolicy.ToPascalCase(DecodePointer(segments[index + 1]))); + index += 2; + } + + return result.ToString(); + } + + private static string GetRoot(string key) + { + var hash = key.IndexOf('#', StringComparison.Ordinal); + return hash < 0 ? key : key[..hash]; + } + + private static string NormalizeRoot(string root) => root.StartsWith("op:", StringComparison.Ordinal) ? root[3..] : root; + + private static string DecodePointer(string segment) => segment + .Replace("~1", "/", StringComparison.Ordinal) + .Replace("~0", "~", StringComparison.Ordinal); +} diff --git a/tools/OpenCode.Sdk.Tools/Generator/Binding/SchemaPlanBinder.cs b/tools/OpenCode.Sdk.Tools/Generator/Binding/SchemaPlanBinder.cs new file mode 100644 index 00000000..a7a20691 --- /dev/null +++ b/tools/OpenCode.Sdk.Tools/Generator/Binding/SchemaPlanBinder.cs @@ -0,0 +1,455 @@ +using OpenCode.Sdk.Tools.Generator.Binding.Models; +using OpenCode.Sdk.Tools.Generator.Ingestion.Models; + +namespace OpenCode.Sdk.Tools.Generator.Binding; + +internal sealed class SchemaPlanBinder(SchemaNameResolver schemaNames) +{ + private const string ModelNamespace = "OpenCode.Sdk.Models"; + private readonly SchemaNameResolver _schemaNames = schemaNames ?? throw new ArgumentNullException(nameof(schemaNames)); + + public SchemaBindingResult Bind(SpecDocument document, ReachableSchemaSet reachable, GenerationCuration curation, + BindingErrorCollector errors) + { + ArgumentNullException.ThrowIfNull(document); + ArgumentNullException.ThrowIfNull(reachable); + ArgumentNullException.ThrowIfNull(curation); + ArgumentNullException.ThrowIfNull(errors); + + var typeNames = _schemaNames.Resolve(document, reachable, errors); + var responseRoots = reachable.ResponseRootKeys.ToHashSet(StringComparer.Ordinal); + RefuseStructuralUnions(document, reachable, errors); + + var inheritance = new Dictionary(StringComparer.Ordinal); + var unions = BindExplicitUnions(document, reachable, responseRoots, typeNames, inheritance, errors); + var errorUnion = BindErrorUnion(document, reachable, responseRoots, typeNames, inheritance, errors); + if (errorUnion is not null) + { + unions.Add(errorUnion); + } + + var typeBinder = new TypePlanBinder(document.Schemas, typeNames, curation.PropertyOverrides, errors); + var models = new List(); + foreach (var key in reachable.GraphKeys) + { + if (responseRoots.Contains(key) || !document.Schemas.TryGetValue(key, out var schema) || !typeNames.TryGetValue(key, out var name)) + { + continue; + } + + switch (schema) + { + case ObjectNode objectNode: + var objectPlan = BindObject(key, name, objectNode, inheritance, typeBinder, errors); + if (objectPlan is not null) + { + models.Add(objectPlan); + } + + break; + case EnumNode enumNode: + models.Add(BindEnum(name, enumNode, errors)); + break; + } + } + + var orderedModels = models.OrderBy(static model => model.Name, StringComparer.Ordinal).ToArray(); + var orderedUnions = unions.OrderBy(static union => union.Name, StringComparer.Ordinal).ToArray(); + var registryNames = orderedModels.Select(static model => model.Name) + .Concat(orderedUnions.SelectMany(static union => new[] { union.Name, union.UnknownTypeName, })) + .Distinct(StringComparer.Ordinal) + .Order(StringComparer.Ordinal) + .ToArray(); + return new SchemaBindingResult + { + Models = orderedModels, + Unions = orderedUnions, + Registry = new RegistryPlan + { + TypeNames = registryNames, + }, + }; + } + + private static void RefuseStructuralUnions(SpecDocument document, ReachableSchemaSet reachable, BindingErrorCollector errors) + { + foreach (var key in reachable.GraphKeys) + { + if (document.Schemas.TryGetValue(key, out var schema) && schema is UnionNode { Classification: UnionClassification.Structural }) + { + errors.Add(BindingErrorCategory.Schema, key, "selected closure contains a structural union"); + } + } + } + + private static List BindExplicitUnions(SpecDocument document, ReachableSchemaSet reachable, HashSet responseRoots, + IReadOnlyDictionary names, IDictionary inheritance, BindingErrorCollector errors) + { + var result = new List(); + foreach (var key in reachable.GraphKeys) + { + if (responseRoots.Contains(key) + || !document.Schemas.TryGetValue(key, out var schema) + || schema is not UnionNode { Classification: UnionClassification.Marked } union + || !names.TryGetValue(key, out var name)) + { + continue; + } + + var plan = BindUnion(name, key, union, document.Schemas, names, inheritance, errors); + if (plan is not null) + { + result.Add(plan); + } + } + + return result; + } + + private static UnionPlan? BindUnion(string name, string key, UnionNode union, IReadOnlyDictionary graph, + IReadOnlyDictionary names, IDictionary inheritance, BindingErrorCollector errors) + { + var variants = new List(union.Branches.Count); + LiteralMarker? expectedMarker = null; + var tags = new HashSet(StringComparer.Ordinal); + foreach (var branch in union.Branches) + { + if (branch is not RefNode reference + || !graph.TryGetValue(reference.Target, out var target) + || target is not ObjectNode { LiteralMarkers.Count: > 0 } objectNode + || !names.TryGetValue(reference.Target, out var typeName)) + { + errors.Add(BindingErrorCategory.Schema, key, "marked union branch must reference a named object with a literal marker"); + continue; + } + + var marker = objectNode.LiteralMarkers[0]; + expectedMarker ??= marker; + if (!string.Equals(marker.PropertyName, expectedMarker.PropertyName, StringComparison.Ordinal) || marker.Kind != expectedMarker.Kind) + { + errors.Add(BindingErrorCategory.Schema, key, "marked union branches do not share one marker property and kind"); + continue; + } + + if (!tags.Add(marker.Value)) + { + errors.Add(BindingErrorCategory.Schema, key, $"marked union tag '{marker.Value}' is duplicated"); + continue; + } + + AddInheritance(reference.Target, name, inheritance, errors); + variants.Add(new UnionVariantPlan + { + TypeName = typeName, + Tag = marker.Value, + }); + } + + return expectedMarker is null || variants.Count != union.Branches.Count + ? null + : new UnionPlan + { + Name = name, + Namespace = ModelNamespace, + UnknownTypeName = $"Unknown{name}", + MarkerWireName = expectedMarker.PropertyName, + MarkerName = CSharpNamePolicy.ToPascalCase(expectedMarker.PropertyName), + MarkerKind = expectedMarker.Kind, + Variants = variants, + Description = union.Description, + }; + } + + private static UnionPlan? BindErrorUnion(SpecDocument document, ReachableSchemaSet reachable, HashSet responseRoots, + IReadOnlyDictionary names, IDictionary inheritance, BindingErrorCollector errors) + { + var errorsInClosure = new List>(); + foreach (var key in reachable.GraphKeys) + { + if (!responseRoots.Contains(key) + && document.Schemas.TryGetValue(key, out var schema) + && schema is ObjectNode { ErrorStyle: not ErrorStyle.None } objectNode) + { + errorsInClosure.Add(new KeyValuePair(key, objectNode)); + } + } + + if (errorsInClosure.Count is 0) + { + return null; + } + + var styles = errorsInClosure.Select(static pair => pair.Value.ErrorStyle).Distinct().ToArray(); + if (styles is not [ErrorStyle.EffectTag]) + { + errors.Add(BindingErrorCategory.Schema, "OpenCodeError", "selected errors must use the Effect _tag style in M1"); + return null; + } + + var variants = new List(errorsInClosure.Count); + foreach (var (key, objectNode) in errorsInClosure.OrderBy(static pair => pair.Key, StringComparer.Ordinal)) + { + if (!names.TryGetValue(key, out var typeName)) + { + errors.Add(BindingErrorCategory.Naming, key, "error schema has no unique C# type name"); + continue; + } + + var markers = objectNode.LiteralMarkers.Where(static marker => marker.PropertyName is "_tag").ToArray(); + if (markers is not [var marker]) + { + errors.Add(BindingErrorCategory.Schema, key, "Effect error must declare exactly one required _tag literal"); + continue; + } + + AddInheritance(key, "OpenCodeError", inheritance, errors); + variants.Add(new UnionVariantPlan + { + TypeName = typeName, + Tag = marker.Value, + }); + } + + return new UnionPlan + { + Name = "OpenCodeError", + Namespace = ModelNamespace, + UnknownTypeName = "UnknownOpenCodeError", + MarkerWireName = "_tag", + MarkerName = "Tag", + MarkerKind = LiteralKind.String, + Variants = [.. variants.OrderBy(static variant => variant.TypeName, StringComparer.Ordinal)], + Description = "Represents a typed error returned by the opencode API.", + }; + } + + private static ObjectModelPlan? BindObject(string key, string name, ObjectNode node, IReadOnlyDictionary inheritance, + TypePlanBinder typeBinder, BindingErrorCollector errors) + { + var properties = new List(node.Properties.Count); + foreach (var property in node.Properties) + { + var type = typeBinder.Bind(key, property.Name, property.Schema); + if (type is null) + { + continue; + } + + if (!property.IsRequired && !type.IsCollection) + { + type = type with { IsNullable = true }; + } + + var literal = property.Schema as LiteralNode; + properties.Add(new ModelPropertyPlan + { + WireName = property.Name, + Name = CSharpNamePolicy.ToPascalCase(property.Name), + Type = type, + IsRequired = property.IsRequired, + IsLiteral = literal is not null, + LiteralKind = literal?.Kind, + LiteralValue = literal?.Value, + Description = property.Schema.Description, + }); + } + + if (properties.Count != node.Properties.Count) + { + return null; + } + + var duplicate = properties.GroupBy(static property => property.Name, StringComparer.Ordinal) + .FirstOrDefault(static group => group.Count() > 1); + if (duplicate is not null) + { + errors.Add(BindingErrorCategory.Naming, key, $"multiple properties map to C# name '{duplicate.Key}'"); + return null; + } + + return new ObjectModelPlan + { + Name = name, + Namespace = ModelNamespace, + Description = node.Description, + Properties = properties, + BaseTypeName = inheritance.GetValueOrDefault(key), + }; + } + + private static EnumModelPlan BindEnum(string name, EnumNode node, BindingErrorCollector errors) + { + var values = node.Values.Select(value => new EnumValuePlan + { + Name = CSharpNamePolicy.ToPascalCase(value), + WireValue = value, + }).ToArray(); + var duplicate = values.GroupBy(static value => value.Name, StringComparer.Ordinal).FirstOrDefault(static group => group.Count() > 1); + if (duplicate is not null) + { + errors.Add(BindingErrorCategory.Naming, name, $"multiple enum values map to C# name '{duplicate.Key}'"); + } + + return new EnumModelPlan + { + Name = name, + Namespace = ModelNamespace, + Description = node.Description, + Values = values, + }; + } + + private static void AddInheritance(string schemaKey, string baseType, IDictionary inheritance, + BindingErrorCollector errors) + { + if (inheritance.TryGetValue(schemaKey, out var existing) && !string.Equals(existing, baseType, StringComparison.Ordinal)) + { + errors.Add(BindingErrorCategory.Schema, schemaKey, $"schema cannot derive from both '{existing}' and '{baseType}'"); + return; + } + + inheritance[schemaKey] = baseType; + } + + private sealed class TypePlanBinder + { + private readonly IReadOnlyDictionary _graph; + private readonly IReadOnlyDictionary _names; + private readonly Dictionary _overrides; + private readonly BindingErrorCollector _errors; + + public TypePlanBinder(IReadOnlyDictionary graph, IReadOnlyDictionary names, + IReadOnlyList overrides, BindingErrorCollector errors) + { + _graph = graph ?? throw new ArgumentNullException(nameof(graph)); + _names = names ?? throw new ArgumentNullException(nameof(names)); + ArgumentNullException.ThrowIfNull(overrides); + _overrides = new Dictionary(StringComparer.Ordinal); + foreach (var propertyOverride in overrides) + { + _overrides.TryAdd($"{propertyOverride.Schema}\0{propertyOverride.Property}", propertyOverride.Type); + } + + _errors = errors ?? throw new ArgumentNullException(nameof(errors)); + } + + public TypeReferencePlan? Bind(string schemaKey, string propertyName, SchemaNode schema) + { + ArgumentException.ThrowIfNullOrWhiteSpace(schemaKey); + ArgumentException.ThrowIfNullOrWhiteSpace(propertyName); + ArgumentNullException.ThrowIfNull(schema); + + var type = BindCore(schema, $"{schemaKey}.{propertyName}", []); + if (type is null || !_overrides.TryGetValue($"{schemaKey}\0{propertyName}", out var propertyOverride)) + { + return type; + } + + if (propertyOverride is PropertyOverrideType.Uri && type is NamedTypeReferencePlan { Name: "string" }) + { + return new NamedTypeReferencePlan + { + Name = "Uri", + IsNullable = type.IsNullable, + }; + } + + _errors.Add(BindingErrorCategory.Curation, $"{schemaKey}.{propertyName}", "uri override requires a scalar string property"); + return null; + } + + private TypeReferencePlan? BindCore(SchemaNode schema, string subject, HashSet aliases) => schema switch + { + RefNode reference => BindReference(reference, subject, aliases), + PrimitiveNode primitive => BindPrimitive(primitive), + LiteralNode literal => Named(literal.Kind is LiteralKind.Boolean ? "bool" : "string"), + ArrayNode array => BindArray(array, subject, aliases), + DictionaryNode dictionary => BindDictionary(dictionary, subject, aliases), + FreeFormObjectNode => DictionaryOf(Named("JsonElement")), + UnrestrictedNode => Named("JsonElement"), + SpecialNumberNode => Named("double"), + NullableNode nullable => BindNullable(nullable, subject, aliases), + JsonStringNode => Refuse(subject, "JSON-encoded strings are not supported by the M1 emitter"), + TupleNode => Refuse(subject, "tuple schemas are not supported by the M1 emitter"), + ObjectNode or EnumNode or UnionNode => Refuse(subject, "inline nominal schema was not promoted into the graph"), + _ => Refuse(subject, $"schema node '{schema.GetType().Name}' is not supported by the M1 emitter"), + }; + + private TypeReferencePlan? BindReference(RefNode reference, string subject, HashSet aliases) + { + if (_names.TryGetValue(reference.Target, out var name)) + { + return Named(name); + } + + if (!_graph.TryGetValue(reference.Target, out var target)) + { + return Refuse(subject, $"schema reference '{reference.Target}' is missing"); + } + + if (!aliases.Add(reference.Target)) + { + return Refuse(subject, $"non-nominal schema alias cycle reaches '{reference.Target}'"); + } + + var result = BindCore(target, subject, aliases); + _ = aliases.Remove(reference.Target); + return result; + } + + private static NamedTypeReferencePlan BindPrimitive(PrimitiveNode primitive) => primitive.Kind switch + { + PrimitiveKind.String when string.Equals(primitive.Format, "uri", StringComparison.Ordinal) => Named("Uri"), + PrimitiveKind.String => Named("string"), + PrimitiveKind.Number => Named("double"), + PrimitiveKind.Integer => Named("long"), + PrimitiveKind.Boolean => Named("bool"), + _ => throw new InvalidOperationException($"Unknown primitive kind '{primitive.Kind}'."), + }; + + private ListTypeReferencePlan? BindArray(ArrayNode array, string subject, HashSet aliases) + { + var item = BindCore(array.Item, subject, aliases); + return item is null ? null : ListOf(item); + } + + private DictionaryTypeReferencePlan? BindDictionary(DictionaryNode dictionary, string subject, HashSet aliases) + { + var value = BindCore(dictionary.Value, subject, aliases); + return value is null ? null : DictionaryOf(value); + } + + private TypeReferencePlan? BindNullable(NullableNode nullable, string subject, HashSet aliases) + { + var inner = BindCore(nullable.Inner, subject, aliases); + return inner is null ? null : inner with { IsNullable = true }; + } + + private TypeReferencePlan? Refuse(string subject, string problem) + { + _errors.Add(BindingErrorCategory.Schema, subject, problem); + return null; + } + + private static NamedTypeReferencePlan Named(string name) => + new() + { + Name = name, + IsNullable = false, + }; + + private static ListTypeReferencePlan ListOf(TypeReferencePlan elementType) => + new() + { + ElementType = elementType, + IsNullable = false, + }; + + private static DictionaryTypeReferencePlan DictionaryOf(TypeReferencePlan valueType) => + new() + { + ValueType = valueType, + IsNullable = false, + }; + } +} diff --git a/tools/OpenCode.Sdk.Tools/Generator/Binding/SpecBinder.cs b/tools/OpenCode.Sdk.Tools/Generator/Binding/SpecBinder.cs new file mode 100644 index 00000000..97d329a3 --- /dev/null +++ b/tools/OpenCode.Sdk.Tools/Generator/Binding/SpecBinder.cs @@ -0,0 +1,81 @@ +using OpenCode.Sdk.Tools.Generator.Binding.Abstractions; +using OpenCode.Sdk.Tools.Generator.Binding.Models; +using OpenCode.Sdk.Tools.Generator.Ingestion.Models; + +namespace OpenCode.Sdk.Tools.Generator.Binding; + +internal sealed class SpecBinder( + ReachableSchemaCollector reachableSchemas, + CurationValidator curationValidator, + SchemaPlanBinder schemaPlans) : ISpecBinder +{ + private readonly CurationValidator _curationValidator = curationValidator ?? throw new ArgumentNullException(nameof(curationValidator)); + private readonly ReachableSchemaCollector _reachableSchemas = reachableSchemas ?? throw new ArgumentNullException(nameof(reachableSchemas)); + private readonly SchemaPlanBinder _schemaPlans = schemaPlans ?? throw new ArgumentNullException(nameof(schemaPlans)); + + public EmitPlan Bind(SpecDocument document, OperationSelection selection, GenerationCuration curation) + { + ArgumentNullException.ThrowIfNull(document); + ArgumentNullException.ThrowIfNull(selection); + ArgumentNullException.ThrowIfNull(curation); + + var errors = new BindingErrorCollector(); + var operationsById = document.Operations.ToDictionary(static operation => operation.OperationId, StringComparer.Ordinal); + var selected = SelectOperations(selection, operationsById, errors); + var reachable = _reachableSchemas.Collect(document, selected, errors); + _curationValidator.Validate(document, selected, reachable, curation, errors); + var schemaResult = _schemaPlans.Bind(document, reachable, curation, errors); + var selectedIds = selected.Select(static operation => operation.OperationId).ToHashSet(StringComparer.Ordinal); + var pending = document.Operations + .Where(operation => !selectedIds.Contains(operation.OperationId)) + .OrderBy(static operation => operation.Surface) + .ThenBy(static operation => operation.OperationId, StringComparer.Ordinal) + .Select(static operation => new PendingOperationPlan + { + OperationId = operation.OperationId, + Surface = operation.Surface, + }) + .ToArray(); + + errors.ThrowIfAny(); + return new EmitPlan + { + SelectedOperationIds = selection.OperationIds, + Models = schemaResult.Models, + Unions = schemaResult.Unions, + Registry = schemaResult.Registry, + PendingOperations = pending, + }; + } + + private static System.Collections.ObjectModel.ReadOnlyCollection SelectOperations(OperationSelection selection, + Dictionary operationsById, BindingErrorCollector errors) + { + var selected = new List(selection.OperationIds.Count); + var seen = new HashSet(StringComparer.Ordinal); + foreach (var operationId in selection.OperationIds) + { + if (string.IsNullOrWhiteSpace(operationId)) + { + errors.Add(BindingErrorCategory.Selection, "profile", "selected operation ID cannot be blank"); + continue; + } + + if (!seen.Add(operationId)) + { + errors.Add(BindingErrorCategory.Selection, operationId, "selected operation ID is duplicated"); + continue; + } + + if (!operationsById.TryGetValue(operationId, out var operation)) + { + errors.Add(BindingErrorCategory.Selection, operationId, "selected operation does not exist in the ingested spec"); + continue; + } + + selected.Add(operation); + } + + return Array.AsReadOnly([.. selected]); + } +} diff --git a/tools/curation.json b/tools/curation.json new file mode 100644 index 00000000..377bf301 --- /dev/null +++ b/tools/curation.json @@ -0,0 +1,29 @@ +{ + "groups": { + "health": { + "placement": "root" + }, + "session": { + "placement": "client", + "clientName": "Sessions", + "handleName": "SessionClient" + } + }, + "envelopePayloadNames": { + "v2.session.message": "Message" + }, + "propertyOverrides": [ + { + "schema": "PromptFileAttachment", + "property": "uri", + "type": "uri", + "reason": "The wire field carries a URI, but the pinned schema omits format: uri." + }, + { + "schema": "ToolFileContent", + "property": "uri", + "type": "uri", + "reason": "The wire field carries a URI, but the pinned schema omits format: uri." + } + ] +} diff --git a/tools/generation-profile.txt b/tools/generation-profile.txt new file mode 100644 index 00000000..94a70703 --- /dev/null +++ b/tools/generation-profile.txt @@ -0,0 +1,2 @@ +v2.health.get +v2.session.message From d02178a493d456fb1e1b7bde4c860c12f7b8b88a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deniz=20=C4=B0rgin?= Date: Tue, 11 Aug 2026 23:04:01 +0300 Subject: [PATCH 3/7] feat(generator): emit selected models and unions --- Directory.Packages.props | 1 + .../Generator/Emission/ModelEmitterTests.cs | 15 + .../Emission/RegistryEmitterTests.cs | 15 + .../Generator/Emission/SourceEmitterTests.cs | 42 ++ .../Generator/Emission/UnionEmitterTests.cs | 15 + .../ModuleInitializer.cs | 12 + ...ce_Immutable_Annotated_Models.verified.txt | 103 ++++ ...ce_Generated_Context_Registry.verified.txt | 20 + ...erant_Marked_Union_Converters.verified.txt | 113 +++++ .../Support/BindingTestHost.cs | 15 + .../Support/CompilerAnalyzerAssemblyLoader.cs | 17 + .../Support/EmitterPlanFixture.cs | 199 ++++++++ .../Support/EmitterSnapshot.cs | 25 + .../Support/GeneratedSourceCompiler.cs | 80 ++++ .../Generator/Emission/EmissionSyntax.cs | 141 ++++++ .../Generator/Emission/GeneratedSource.cs | 20 + .../Generator/Emission/ModelEmitter.cs | 295 ++++++++++++ .../Generator/Emission/RegistryEmitter.cs | 41 ++ .../Generator/Emission/SourceEmitter.cs | 24 + .../Generator/Emission/TypeSyntaxEmitter.cs | 54 +++ .../Generator/Emission/UnionEmitter.cs | 438 ++++++++++++++++++ .../OpenCode.Sdk.Tools.csproj | 1 + 22 files changed, 1686 insertions(+) create mode 100644 tests/OpenCode.Sdk.Tools.Tests/Generator/Emission/ModelEmitterTests.cs create mode 100644 tests/OpenCode.Sdk.Tools.Tests/Generator/Emission/RegistryEmitterTests.cs create mode 100644 tests/OpenCode.Sdk.Tools.Tests/Generator/Emission/SourceEmitterTests.cs create mode 100644 tests/OpenCode.Sdk.Tools.Tests/Generator/Emission/UnionEmitterTests.cs create mode 100644 tests/OpenCode.Sdk.Tools.Tests/ModuleInitializer.cs create mode 100644 tests/OpenCode.Sdk.Tools.Tests/Snapshots/ModelEmitterTests.Emit_Should_Produce_Immutable_Annotated_Models.verified.txt create mode 100644 tests/OpenCode.Sdk.Tools.Tests/Snapshots/RegistryEmitterTests.Emit_Should_Produce_The_Source_Generated_Context_Registry.verified.txt create mode 100644 tests/OpenCode.Sdk.Tools.Tests/Snapshots/UnionEmitterTests.Emit_Should_Produce_Tolerant_Marked_Union_Converters.verified.txt create mode 100644 tests/OpenCode.Sdk.Tools.Tests/Support/CompilerAnalyzerAssemblyLoader.cs create mode 100644 tests/OpenCode.Sdk.Tools.Tests/Support/EmitterPlanFixture.cs create mode 100644 tests/OpenCode.Sdk.Tools.Tests/Support/EmitterSnapshot.cs create mode 100644 tests/OpenCode.Sdk.Tools.Tests/Support/GeneratedSourceCompiler.cs create mode 100644 tools/OpenCode.Sdk.Tools/Generator/Emission/EmissionSyntax.cs create mode 100644 tools/OpenCode.Sdk.Tools/Generator/Emission/GeneratedSource.cs create mode 100644 tools/OpenCode.Sdk.Tools/Generator/Emission/ModelEmitter.cs create mode 100644 tools/OpenCode.Sdk.Tools/Generator/Emission/RegistryEmitter.cs create mode 100644 tools/OpenCode.Sdk.Tools/Generator/Emission/SourceEmitter.cs create mode 100644 tools/OpenCode.Sdk.Tools/Generator/Emission/TypeSyntaxEmitter.cs create mode 100644 tools/OpenCode.Sdk.Tools/Generator/Emission/UnionEmitter.cs diff --git a/Directory.Packages.props b/Directory.Packages.props index 6df319f6..70d09c08 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -3,6 +3,7 @@ + diff --git a/tests/OpenCode.Sdk.Tools.Tests/Generator/Emission/ModelEmitterTests.cs b/tests/OpenCode.Sdk.Tools.Tests/Generator/Emission/ModelEmitterTests.cs new file mode 100644 index 00000000..4d958ab0 --- /dev/null +++ b/tests/OpenCode.Sdk.Tools.Tests/Generator/Emission/ModelEmitterTests.cs @@ -0,0 +1,15 @@ +using OpenCode.Sdk.Tools.Generator.Emission; +using OpenCode.Sdk.Tools.Tests.Support; + +namespace OpenCode.Sdk.Tools.Tests.Generator.Emission; + +public sealed class ModelEmitterTests +{ + [Test] + public async Task Emit_Should_Produce_Immutable_Annotated_Models() + { + var sources = ModelEmitter.Emit(EmitterPlanFixture.CreateModelSnapshot()); + + await Verify(EmitterSnapshot.Create(sources)); + } +} diff --git a/tests/OpenCode.Sdk.Tools.Tests/Generator/Emission/RegistryEmitterTests.cs b/tests/OpenCode.Sdk.Tools.Tests/Generator/Emission/RegistryEmitterTests.cs new file mode 100644 index 00000000..8e6b032d --- /dev/null +++ b/tests/OpenCode.Sdk.Tools.Tests/Generator/Emission/RegistryEmitterTests.cs @@ -0,0 +1,15 @@ +using OpenCode.Sdk.Tools.Generator.Emission; +using OpenCode.Sdk.Tools.Tests.Support; + +namespace OpenCode.Sdk.Tools.Tests.Generator.Emission; + +public sealed class RegistryEmitterTests +{ + [Test] + public async Task Emit_Should_Produce_The_Source_Generated_Context_Registry() + { + var sources = RegistryEmitter.Emit(EmitterPlanFixture.CreateRegistry()); + + await Verify(EmitterSnapshot.Create(sources)); + } +} diff --git a/tests/OpenCode.Sdk.Tools.Tests/Generator/Emission/SourceEmitterTests.cs b/tests/OpenCode.Sdk.Tools.Tests/Generator/Emission/SourceEmitterTests.cs new file mode 100644 index 00000000..714f7411 --- /dev/null +++ b/tests/OpenCode.Sdk.Tools.Tests/Generator/Emission/SourceEmitterTests.cs @@ -0,0 +1,42 @@ +using OpenCode.Sdk.Tools.Generator.Emission; +using OpenCode.Sdk.Tools.Tests.Support; + +namespace OpenCode.Sdk.Tools.Tests.Generator.Emission; + +public sealed class SourceEmitterTests +{ + [Test] + public async Task Emit_Should_Return_Ordinal_Sorted_Byte_Identical_Source() + { + var plan = EmitterPlanFixture.Create(); + + var first = SourceEmitter.Emit(plan); + var second = SourceEmitter.Emit(plan); + + await Assert.That(first.Select(static source => source.RelativePath) + .SequenceEqual(first.Select(static source => source.RelativePath).Order(StringComparer.Ordinal), StringComparer.Ordinal)).IsTrue(); + await Assert.That(first.Select(static source => source.Utf8Source.ToArray()) + .Zip(second.Select(static source => source.Utf8Source.ToArray()), static (left, right) => left.SequenceEqual(right)) + .All(static equal => equal)).IsTrue(); + } + + [Test] + public async Task Emit_Should_Produce_Compilable_Source() + { + var sources = SourceEmitter.Emit(EmitterPlanFixture.Create()); + + var diagnostics = GeneratedSourceCompiler.Compile(sources); + + await Assert.That(diagnostics).IsEmpty(); + } + + [Test] + public async Task Emit_Should_Produce_Compilable_Source_For_The_Selected_Pin() + { + var plan = await new BindingTestHost().BindPinnedAsync(); + + var diagnostics = GeneratedSourceCompiler.Compile(SourceEmitter.Emit(plan)); + + await Assert.That(diagnostics).IsEmpty(); + } +} diff --git a/tests/OpenCode.Sdk.Tools.Tests/Generator/Emission/UnionEmitterTests.cs b/tests/OpenCode.Sdk.Tools.Tests/Generator/Emission/UnionEmitterTests.cs new file mode 100644 index 00000000..3d705aa1 --- /dev/null +++ b/tests/OpenCode.Sdk.Tools.Tests/Generator/Emission/UnionEmitterTests.cs @@ -0,0 +1,15 @@ +using OpenCode.Sdk.Tools.Generator.Emission; +using OpenCode.Sdk.Tools.Tests.Support; + +namespace OpenCode.Sdk.Tools.Tests.Generator.Emission; + +public sealed class UnionEmitterTests +{ + [Test] + public async Task Emit_Should_Produce_Tolerant_Marked_Union_Converters() + { + var sources = UnionEmitter.Emit(EmitterPlanFixture.CreateUnionSnapshot()); + + await Verify(EmitterSnapshot.Create(sources)); + } +} diff --git a/tests/OpenCode.Sdk.Tools.Tests/ModuleInitializer.cs b/tests/OpenCode.Sdk.Tools.Tests/ModuleInitializer.cs new file mode 100644 index 00000000..7ee575c4 --- /dev/null +++ b/tests/OpenCode.Sdk.Tools.Tests/ModuleInitializer.cs @@ -0,0 +1,12 @@ +using System.Runtime.CompilerServices; + +namespace OpenCode.Sdk.Tools.Tests; + +internal static class ModuleInitializer +{ + [ModuleInitializer] + public static void Initialize() + { + UseProjectRelativeDirectory("Snapshots"); + } +} diff --git a/tests/OpenCode.Sdk.Tools.Tests/Snapshots/ModelEmitterTests.Emit_Should_Produce_Immutable_Annotated_Models.verified.txt b/tests/OpenCode.Sdk.Tools.Tests/Snapshots/ModelEmitterTests.Emit_Should_Produce_Immutable_Annotated_Models.verified.txt new file mode 100644 index 00000000..08219549 --- /dev/null +++ b/tests/OpenCode.Sdk.Tools.Tests/Snapshots/ModelEmitterTests.Emit_Should_Produce_Immutable_Annotated_Models.verified.txt @@ -0,0 +1,103 @@ +// File: Models/BadRequestError.cs +// Generated by OpenCode.Sdk.Tools from spec/openapi.json. +// Do not edit by hand — change tools/curation.json or the emitters, then regenerate. +using System.Text.Json.Serialization; + +namespace OpenCode.Sdk.Models; +/// +/// Represents a rejected request. +/// +public sealed record BadRequestError : OpenCodeError +{ + /// + /// Gets the tag value. + /// + [JsonPropertyName("_tag")] + public override string Tag => "BadRequestError"; + + /// + /// Gets the error message. + /// + [JsonPropertyName("message")] + public required string Message { get; init; } +} + +// File: Models/ExampleItem.cs +// Generated by OpenCode.Sdk.Tools from spec/openapi.json. +// Do not edit by hand — change tools/curation.json or the emitters, then regenerate. +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Text.Json.Serialization; + +namespace OpenCode.Sdk.Models; +/// +/// Represents one emitted item. +/// +public sealed record ExampleItem +{ + /// + /// Gets the item identifier. + /// + [JsonPropertyName("id")] + public required string ID { get; init; } + + /// + /// Gets the note value. + /// + [JsonPropertyName("note")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Note { get; init; } + + /// + /// Gets the item tags. + /// + [JsonPropertyName("tags")] + public IReadOnlyList Tags + { + get; + init + { + ArgumentNullException.ThrowIfNull(value); + field = new List(value).AsReadOnly(); + } + } = Array.Empty(); + + /// + /// Gets links by relation. + /// + [JsonPropertyName("links")] + public IReadOnlyDictionary Links + { + get; + init + { + ArgumentNullException.ThrowIfNull(value); + field = new ReadOnlyDictionary(new Dictionary(value, StringComparer.Ordinal)); + } + } = new ReadOnlyDictionary(new Dictionary(StringComparer.Ordinal)); +} + +// File: Models/ExampleMode.cs +// Generated by OpenCode.Sdk.Tools from spec/openapi.json. +// Do not edit by hand — change tools/curation.json or the emitters, then regenerate. +using System.Text.Json.Serialization; + +namespace OpenCode.Sdk.Models; +/// +/// Defines the supported example mode values. +/// +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum ExampleMode +{ + /// + /// Represents the 'fast-mode' wire value. + /// + [JsonStringEnumMemberName("fast-mode")] + FastMode, + /// + /// Represents the 'safe' wire value. + /// + [JsonStringEnumMemberName("safe")] + Safe +} diff --git a/tests/OpenCode.Sdk.Tools.Tests/Snapshots/RegistryEmitterTests.Emit_Should_Produce_The_Source_Generated_Context_Registry.verified.txt b/tests/OpenCode.Sdk.Tools.Tests/Snapshots/RegistryEmitterTests.Emit_Should_Produce_The_Source_Generated_Context_Registry.verified.txt new file mode 100644 index 00000000..f351ff34 --- /dev/null +++ b/tests/OpenCode.Sdk.Tools.Tests/Snapshots/RegistryEmitterTests.Emit_Should_Produce_The_Source_Generated_Context_Registry.verified.txt @@ -0,0 +1,20 @@ +// File: Internal/Serialization/OpenCodeJsonContext.cs +// Generated by OpenCode.Sdk.Tools from spec/openapi.json. +// Do not edit by hand — change tools/curation.json or the emitters, then regenerate. +using System.Text.Json.Serialization; +using OpenCode.Sdk.Models; + +namespace OpenCode.Sdk.Internal.Serialization; +[JsonSourceGenerationOptions(GenerationMode = JsonSourceGenerationMode.Metadata)] +[JsonSerializable(typeof(BadRequestError))] +[JsonSerializable(typeof(CreatedEvent))] +[JsonSerializable(typeof(DeletedEvent))] +[JsonSerializable(typeof(ExampleEvent))] +[JsonSerializable(typeof(ExampleItem))] +[JsonSerializable(typeof(ExampleMode))] +[JsonSerializable(typeof(OpenCodeError))] +[JsonSerializable(typeof(UnknownExampleEvent))] +[JsonSerializable(typeof(UnknownOpenCodeError))] +internal sealed partial class OpenCodeJsonContext : JsonSerializerContext +{ +} diff --git a/tests/OpenCode.Sdk.Tools.Tests/Snapshots/UnionEmitterTests.Emit_Should_Produce_Tolerant_Marked_Union_Converters.verified.txt b/tests/OpenCode.Sdk.Tools.Tests/Snapshots/UnionEmitterTests.Emit_Should_Produce_Tolerant_Marked_Union_Converters.verified.txt new file mode 100644 index 00000000..c22a3a6e --- /dev/null +++ b/tests/OpenCode.Sdk.Tools.Tests/Snapshots/UnionEmitterTests.Emit_Should_Produce_Tolerant_Marked_Union_Converters.verified.txt @@ -0,0 +1,113 @@ +// File: Models/ExampleEvent.cs +// Generated by OpenCode.Sdk.Tools from spec/openapi.json. +// Do not edit by hand — change tools/curation.json or the emitters, then regenerate. +using System.Text.Json.Serialization; +using OpenCode.Sdk.Internal.Serialization; + +namespace OpenCode.Sdk.Models; +/// +/// Represents an example event. +/// +[JsonConverter(typeof(ExampleEventJsonConverter))] +public abstract record ExampleEvent +{ + /// + /// Gets the 'type' union marker. + /// + [JsonPropertyName("type")] + public abstract string Type { get; } +} + +// File: Models/UnknownExampleEvent.cs +// Generated by OpenCode.Sdk.Tools from spec/openapi.json. +// Do not edit by hand — change tools/curation.json or the emitters, then regenerate. +using System; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace OpenCode.Sdk.Models; +/// +/// Preserves an unknown example event payload. +/// +public sealed record UnknownExampleEvent : ExampleEvent +{ + private readonly string _marker; + /// + /// Initializes an unknown union value from its marker and raw payload. + /// + [JsonConstructor] + public UnknownExampleEvent(string type, JsonElement payload) + { + ArgumentException.ThrowIfNullOrEmpty(type); + _marker = type; + Payload = payload.Clone(); + } + + /// + /// Gets the unrecognized 'type' marker. + /// + public override string Type => _marker; + /// + /// Gets the preserved raw JSON payload. + /// + public JsonElement Payload { get; } +} + +// File: Internal/Serialization/ExampleEventJsonConverter.cs +// Generated by OpenCode.Sdk.Tools from spec/openapi.json. +// Do not edit by hand — change tools/curation.json or the emitters, then regenerate. +using System; +using System.Collections.Generic; +using System.Text.Json; +using System.Text.Json.Serialization; +using OpenCode.Sdk.Models; + +namespace OpenCode.Sdk.Internal.Serialization; +internal sealed class ExampleEventJsonConverter : JsonConverter +{ + private static readonly Dictionary TypesByTag = new Dictionary(StringComparer.Ordinal) + { + ["created"] = typeof(CreatedEvent), + ["deleted"] = typeof(DeletedEvent) + }; + public override bool HandleNull => true; + + public override ExampleEvent Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + ArgumentNullException.ThrowIfNull(typeToConvert); + ArgumentNullException.ThrowIfNull(options); + if (reader.TokenType == JsonTokenType.Null) + throw new JsonException("The ExampleEvent payload cannot be null."); + using var document = JsonDocument.ParseValue(ref reader); + var payload = document.RootElement; + if (!payload.TryGetProperty("type", out var markerElement)) + throw new JsonException("The ExampleEvent payload must contain 'type'."); + if (markerElement.ValueKind != JsonValueKind.String) + throw new JsonException("The 'type' marker must be a string."); + var marker = markerElement.GetString(); + if (marker is null) + throw new JsonException("The 'type' marker cannot be null."); + if (TypesByTag.TryGetValue(marker, out var targetType)) + { + var typeInfo = OpenCodeJsonContext.Default.GetTypeInfo(targetType) ?? throw new JsonException("The generated context has no metadata for ExampleEvent."); + return JsonSerializer.Deserialize(payload, typeInfo) as ExampleEvent ?? throw new JsonException("The ExampleEvent payload deserialized to null."); + } + + return new UnknownExampleEvent(marker, payload); + } + + public override void Write(Utf8JsonWriter writer, ExampleEvent value, JsonSerializerOptions options) + { + ArgumentNullException.ThrowIfNull(writer); + ArgumentNullException.ThrowIfNull(value); + ArgumentNullException.ThrowIfNull(options); + if (value is UnknownExampleEvent unknown) + { + unknown.Payload.WriteTo(writer); + return; + } + + var typeInfo = OpenCodeJsonContext.Default.GetTypeInfo(value.GetType()) ?? throw new JsonException("The generated context has no metadata for ExampleEvent."); + JsonSerializer.Serialize(writer, value, typeInfo); + } +} diff --git a/tests/OpenCode.Sdk.Tools.Tests/Support/BindingTestHost.cs b/tests/OpenCode.Sdk.Tools.Tests/Support/BindingTestHost.cs index ff75593c..7e7090bc 100644 --- a/tests/OpenCode.Sdk.Tools.Tests/Support/BindingTestHost.cs +++ b/tests/OpenCode.Sdk.Tools.Tests/Support/BindingTestHost.cs @@ -1,6 +1,8 @@ using OpenCode.Sdk.Tools.Generator.Binding; using OpenCode.Sdk.Tools.Generator.Binding.Models; +using OpenCode.Sdk.Tools.Generator.Ingestion; using OpenCode.Sdk.Tools.Generator.Ingestion.Models; +using Testably.Abstractions; namespace OpenCode.Sdk.Tools.Tests.Support; @@ -21,4 +23,17 @@ public EmitPlan Bind(SpecDocument document, OperationSelection selection, Genera { return _binder.Bind(document, selection, curation); } + + public async Task BindPinnedAsync(CancellationToken cancellationToken = default) + { + var fileSystem = new RealFileSystem(); + var fixtureRoot = fileSystem.Path.Combine(AppContext.BaseDirectory, "Fixtures"); + var document = await new SpecIngestion(fileSystem) + .IngestAsync(fileSystem.Path.Combine(fixtureRoot, "openapi.json"), cancellationToken); + var selection = await new OperationSelectionLoader(fileSystem) + .LoadAsync(fileSystem.Path.Combine(fixtureRoot, "generation-profile.txt"), cancellationToken); + var curation = await new CurationLoader(fileSystem) + .LoadAsync(fileSystem.Path.Combine(fixtureRoot, "curation.json"), cancellationToken); + return Bind(document, selection, curation); + } } diff --git a/tests/OpenCode.Sdk.Tools.Tests/Support/CompilerAnalyzerAssemblyLoader.cs b/tests/OpenCode.Sdk.Tools.Tests/Support/CompilerAnalyzerAssemblyLoader.cs new file mode 100644 index 00000000..908027a7 --- /dev/null +++ b/tests/OpenCode.Sdk.Tools.Tests/Support/CompilerAnalyzerAssemblyLoader.cs @@ -0,0 +1,17 @@ +using Microsoft.CodeAnalysis; + +namespace OpenCode.Sdk.Tools.Tests.Support; + +internal sealed class CompilerAnalyzerAssemblyLoader : IAnalyzerAssemblyLoader +{ + public void AddDependencyLocation(string fullPath) + { + ArgumentException.ThrowIfNullOrWhiteSpace(fullPath); + } + + public System.Reflection.Assembly LoadFromPath(string fullPath) + { + ArgumentException.ThrowIfNullOrWhiteSpace(fullPath); + return System.Runtime.Loader.AssemblyLoadContext.Default.LoadFromAssemblyPath(fullPath); + } +} diff --git a/tests/OpenCode.Sdk.Tools.Tests/Support/EmitterPlanFixture.cs b/tests/OpenCode.Sdk.Tools.Tests/Support/EmitterPlanFixture.cs new file mode 100644 index 00000000..df2f3725 --- /dev/null +++ b/tests/OpenCode.Sdk.Tools.Tests/Support/EmitterPlanFixture.cs @@ -0,0 +1,199 @@ +using OpenCode.Sdk.Tools.Generator.Binding.Models; +using OpenCode.Sdk.Tools.Generator.Ingestion.Models; + +namespace OpenCode.Sdk.Tools.Tests.Support; + +internal static class EmitterPlanFixture +{ + public static EmitPlan Create() + { + var models = new ModelPlan[] + { + CreateExampleItem(), + CreateExampleMode(), + CreateVariant("CreatedEvent", "created", "item", "Item", Named("ExampleItem")), + CreateVariant("DeletedEvent", "deleted", "id", "ID", Named("string")), + CreateError(), + }; + var unions = new[] + { + CreateExampleEvent(), + CreateOpenCodeError(), + }; + + return new EmitPlan + { + SelectedOperationIds = ["v2.example.get"], + Models = models, + Unions = unions, + Registry = new RegistryPlan + { + TypeNames = + [ + "BadRequestError", + "CreatedEvent", + "DeletedEvent", + "ExampleEvent", + "ExampleItem", + "ExampleMode", + "OpenCodeError", + "UnknownExampleEvent", + "UnknownOpenCodeError", + ], + }, + PendingOperations = [], + }; + } + + public static EmitPlan CreateModelSnapshot() + { + var plan = Create(); + return plan with + { + Models = + [ + plan.Models.Single(static model => model.Name == "BadRequestError"), + plan.Models.Single(static model => model.Name == "ExampleItem"), + plan.Models.Single(static model => model.Name == "ExampleMode"), + ], + Unions = [plan.Unions.Single(static union => union.Name == "OpenCodeError")], + }; + } + + public static IReadOnlyList CreateUnionSnapshot() => [CreateExampleEvent()]; + + public static RegistryPlan CreateRegistry() => Create().Registry; + + private static ObjectModelPlan CreateExampleItem() => + new() + { + Name = "ExampleItem", + Namespace = "OpenCode.Sdk.Models", + Description = "Represents one emitted item.", + Properties = + [ + Property("id", "ID", Named("string"), isRequired: true, "Gets the item identifier."), + Property("note", "Note", Named("string", isNullable: true), isRequired: false, description: null), + Property("tags", "Tags", ListOf(Named("string")), isRequired: false, "Gets the item tags."), + Property("links", "Links", DictionaryOf(Named("Uri")), isRequired: false, "Gets links by relation."), + ], + }; + + private static EnumModelPlan CreateExampleMode() => + new() + { + Name = "ExampleMode", + Namespace = "OpenCode.Sdk.Models", + Description = null, + Values = + [ + new EnumValuePlan { Name = "FastMode", WireValue = "fast-mode", }, + new EnumValuePlan { Name = "Safe", WireValue = "safe", }, + ], + }; + + private static ObjectModelPlan CreateVariant(string name, string tag, string wireName, string propertyName, + TypeReferencePlan propertyType) => + new() + { + Name = name, + Namespace = "OpenCode.Sdk.Models", + Description = null, + BaseTypeName = "ExampleEvent", + Properties = + [ + LiteralProperty("type", "Type", tag), + Property(wireName, propertyName, propertyType, isRequired: true, description: null), + ], + }; + + private static ObjectModelPlan CreateError() => + new() + { + Name = "BadRequestError", + Namespace = "OpenCode.Sdk.Models", + Description = "Represents a rejected request.", + BaseTypeName = "OpenCodeError", + Properties = + [ + LiteralProperty("_tag", "Tag", "BadRequestError"), + Property("message", "Message", Named("string"), isRequired: true, "Gets the error message."), + ], + }; + + private static UnionPlan CreateExampleEvent() => + new() + { + Name = "ExampleEvent", + Namespace = "OpenCode.Sdk.Models", + UnknownTypeName = "UnknownExampleEvent", + MarkerWireName = "type", + MarkerName = "Type", + MarkerKind = LiteralKind.String, + Variants = + [ + new UnionVariantPlan { TypeName = "CreatedEvent", Tag = "created", }, + new UnionVariantPlan { TypeName = "DeletedEvent", Tag = "deleted", }, + ], + Description = "Represents an example event.", + }; + + private static UnionPlan CreateOpenCodeError() => + new() + { + Name = "OpenCodeError", + Namespace = "OpenCode.Sdk.Models", + UnknownTypeName = "UnknownOpenCodeError", + MarkerWireName = "_tag", + MarkerName = "Tag", + MarkerKind = LiteralKind.String, + Variants = [new UnionVariantPlan { TypeName = "BadRequestError", Tag = "BadRequestError", }], + Description = "Represents an opencode API error.", + }; + + private static ModelPropertyPlan Property(string wireName, string name, TypeReferencePlan type, bool isRequired, + string? description) => + new() + { + WireName = wireName, + Name = name, + Type = type, + IsRequired = isRequired, + IsLiteral = false, + Description = description, + }; + + private static ModelPropertyPlan LiteralProperty(string wireName, string name, string value) => + new() + { + WireName = wireName, + Name = name, + Type = Named("string"), + IsRequired = true, + IsLiteral = true, + LiteralKind = LiteralKind.String, + LiteralValue = value, + Description = null, + }; + + private static NamedTypeReferencePlan Named(string name, bool isNullable = false) => + new() + { + Name = name, + IsNullable = isNullable, + }; + + private static ListTypeReferencePlan ListOf(TypeReferencePlan elementType) => + new() + { + ElementType = elementType, + IsNullable = false, + }; + + private static DictionaryTypeReferencePlan DictionaryOf(TypeReferencePlan valueType) => + new() + { + ValueType = valueType, + IsNullable = false, + }; +} diff --git a/tests/OpenCode.Sdk.Tools.Tests/Support/EmitterSnapshot.cs b/tests/OpenCode.Sdk.Tools.Tests/Support/EmitterSnapshot.cs new file mode 100644 index 00000000..8763e5f6 --- /dev/null +++ b/tests/OpenCode.Sdk.Tools.Tests/Support/EmitterSnapshot.cs @@ -0,0 +1,25 @@ +using System.Text; +using OpenCode.Sdk.Tools.Generator.Emission; + +namespace OpenCode.Sdk.Tools.Tests.Support; + +internal static class EmitterSnapshot +{ + public static string Create(IReadOnlyList sources) + { + ArgumentNullException.ThrowIfNull(sources); + var result = new StringBuilder(); + for (var index = 0; index < sources.Count; index++) + { + var source = sources[index]; + _ = result.Append("// File: ").Append(source.RelativePath).Append('\n'); + _ = result.Append(Encoding.UTF8.GetString(source.Utf8Source.Span)); + if (index < sources.Count - 1) + { + _ = result.Append('\n'); + } + } + + return result.ToString(); + } +} diff --git a/tests/OpenCode.Sdk.Tools.Tests/Support/GeneratedSourceCompiler.cs b/tests/OpenCode.Sdk.Tools.Tests/Support/GeneratedSourceCompiler.cs new file mode 100644 index 00000000..281103a6 --- /dev/null +++ b/tests/OpenCode.Sdk.Tools.Tests/Support/GeneratedSourceCompiler.cs @@ -0,0 +1,80 @@ +using System.Text; +using System.Text.Json; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.Diagnostics; +using OpenCode.Sdk.Tools.Generator.Emission; +using Testably.Abstractions; + +namespace OpenCode.Sdk.Tools.Tests.Support; + +internal static class GeneratedSourceCompiler +{ + private static readonly CSharpParseOptions ParseOptions = new(LanguageVersion.CSharp14); + private static readonly PortableExecutableReference[] References = CreateReferences(); + private static readonly AnalyzerFileReference SourceGenerator = CreateSourceGeneratorReference(); + + public static Diagnostic[] Compile(IReadOnlyList sources) + { + ArgumentNullException.ThrowIfNull(sources); + var syntaxTrees = sources.Select(static source => CSharpSyntaxTree.ParseText( + Encoding.UTF8.GetString(source.Utf8Source.Span), + ParseOptions, + source.RelativePath, + Encoding.UTF8)); + var compilation = CSharpCompilation.Create( + "GeneratedSourceProbe", + syntaxTrees, + References, + new CSharpCompilationOptions( + OutputKind.DynamicallyLinkedLibrary, + nullableContextOptions: NullableContextOptions.Enable, + deterministic: true)); + GeneratorDriver driver = CSharpGeneratorDriver.Create( + SourceGenerator.GetGenerators(LanguageNames.CSharp), + parseOptions: ParseOptions); + _ = driver.RunGeneratorsAndUpdateCompilation(compilation, out var outputCompilation, out var generatorDiagnostics); + + return + [ + .. generatorDiagnostics.Concat(outputCompilation.GetDiagnostics()) + .Where(static diagnostic => diagnostic.Severity is DiagnosticSeverity.Warning or DiagnosticSeverity.Error), + ]; + } + + private static PortableExecutableReference[] CreateReferences() + { + var trustedAssemblies = AppContext.GetData("TRUSTED_PLATFORM_ASSEMBLIES") as string + ?? throw new InvalidOperationException("The runtime did not expose trusted platform assemblies."); + return + [ + .. trustedAssemblies + .Split(Path.PathSeparator, StringSplitOptions.RemoveEmptyEntries) + .Select(static path => MetadataReference.CreateFromFile(path)), + ]; + } + + private static AnalyzerFileReference CreateSourceGeneratorReference() + { + var fileSystem = new RealFileSystem(); + var frameworkDirectory = fileSystem.Path.GetDirectoryName(typeof(JsonSerializer).Assembly.Location) + ?? throw new InvalidOperationException("The runtime framework directory could not be resolved."); + var frameworkVersion = fileSystem.Path.GetFileName(frameworkDirectory); + var dotnetRoot = fileSystem.Path.GetFullPath(fileSystem.Path.Combine(frameworkDirectory, "..", "..", "..")); + var generatorPath = fileSystem.Path.Combine( + dotnetRoot, + "packs", + "Microsoft.NETCore.App.Ref", + frameworkVersion, + "analyzers", + "dotnet", + "cs", + "System.Text.Json.SourceGeneration.dll"); + if (!fileSystem.File.Exists(generatorPath)) + { + throw new InvalidOperationException($"The System.Text.Json source generator was not found at '{generatorPath}'."); + } + + return new AnalyzerFileReference(generatorPath, new CompilerAnalyzerAssemblyLoader()); + } +} diff --git a/tools/OpenCode.Sdk.Tools/Generator/Emission/EmissionSyntax.cs b/tools/OpenCode.Sdk.Tools/Generator/Emission/EmissionSyntax.cs new file mode 100644 index 00000000..25e1d647 --- /dev/null +++ b/tools/OpenCode.Sdk.Tools/Generator/Emission/EmissionSyntax.cs @@ -0,0 +1,141 @@ +using System.Text; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; + +namespace OpenCode.Sdk.Tools.Generator.Emission; + +internal static class EmissionSyntax +{ + private static readonly UTF8Encoding Utf8 = new(encoderShouldEmitUTF8Identifier: false); + private static readonly SyntaxTrivia LineFeed = SyntaxFactory.EndOfLine("\n"); + + public static GeneratedSource CreateSource(string relativePath, CompilationUnitSyntax compilationUnit) + { + ArgumentException.ThrowIfNullOrWhiteSpace(relativePath); + ArgumentNullException.ThrowIfNull(compilationUnit); + + var normalized = compilationUnit.NormalizeWhitespace(indentation: " ", eol: "\n", elasticTrivia: false).ToFullString(); + if (normalized.Length is 0 || normalized[^1] is not '\n') + { + normalized = string.Concat(normalized, "\n"); + } + + return new GeneratedSource + { + RelativePath = relativePath, + Utf8Source = Utf8.GetBytes(normalized), + }; + } + + public static CompilationUnitSyntax CompilationUnit(string namespaceName, IReadOnlyList usingNames, + IReadOnlyList members) + { + ArgumentException.ThrowIfNullOrWhiteSpace(namespaceName); + ArgumentNullException.ThrowIfNull(usingNames); + ArgumentNullException.ThrowIfNull(members); + + var namespaceDeclaration = SyntaxFactory.FileScopedNamespaceDeclaration(QualifiedName(namespaceName)) + .WithMembers(SyntaxFactory.List(members)); + var compilationUnit = SyntaxFactory.CompilationUnit() + .WithUsings(SyntaxFactory.List(usingNames + .Distinct(StringComparer.Ordinal) + .OrderBy(static name => name is "System" || name.StartsWith("System.", StringComparison.Ordinal) ? 0 : 1) + .ThenBy(static name => name, StringComparer.Ordinal) + .Select(static name => SyntaxFactory.UsingDirective(QualifiedName(name))))) + .WithMembers(SyntaxFactory.SingletonList(namespaceDeclaration)); + + return compilationUnit.WithLeadingTrivia( + SyntaxFactory.Comment("// Generated by OpenCode.Sdk.Tools from spec/openapi.json."), + LineFeed, + SyntaxFactory.Comment("// Do not edit by hand — change tools/curation.json or the emitters, then regenerate."), + LineFeed, + LineFeed); + } + + public static NameSyntax QualifiedName(string dottedName) + { + ArgumentException.ThrowIfNullOrWhiteSpace(dottedName); + var segments = dottedName.Split('.', StringSplitOptions.RemoveEmptyEntries); + NameSyntax result = SyntaxFactory.IdentifierName(segments[0]); + for (var index = 1; index < segments.Length; index++) + { + result = SyntaxFactory.QualifiedName(result, SyntaxFactory.IdentifierName(segments[index])); + } + + return result; + } + + public static SyntaxTriviaList Documentation(string summary) + { + ArgumentException.ThrowIfNullOrWhiteSpace(summary); + var normalized = NormalizeDocumentation(summary); + var text = string.Concat("/// \n/// ", EscapeXml(normalized), "\n/// \n"); + return SyntaxFactory.ParseLeadingTrivia(text); + } + + public static AttributeListSyntax Attribute(string name, params AttributeArgumentSyntax[] arguments) + { + ArgumentException.ThrowIfNullOrWhiteSpace(name); + ArgumentNullException.ThrowIfNull(arguments); + var attribute = SyntaxFactory.Attribute(QualifiedName(name)); + if (arguments.Length > 0) + { + attribute = attribute.WithArgumentList(SyntaxFactory.AttributeArgumentList(SyntaxFactory.SeparatedList(arguments))); + } + + return SyntaxFactory.AttributeList(SyntaxFactory.SingletonSeparatedList(attribute)); + } + + public static AttributeArgumentSyntax StringArgument(string value) => + SyntaxFactory.AttributeArgument(SyntaxFactory.LiteralExpression( + SyntaxKind.StringLiteralExpression, + SyntaxFactory.Literal(value ?? throw new ArgumentNullException(nameof(value))))); + + public static MemberAccessExpressionSyntax MemberAccess(ExpressionSyntax expression, string memberName) => + SyntaxFactory.MemberAccessExpression( + SyntaxKind.SimpleMemberAccessExpression, + expression ?? throw new ArgumentNullException(nameof(expression)), + SyntaxFactory.IdentifierName(memberName)); + + public static MemberAccessExpressionSyntax MemberAccess(ExpressionSyntax expression, SimpleNameSyntax memberName) => + SyntaxFactory.MemberAccessExpression( + SyntaxKind.SimpleMemberAccessExpression, + expression ?? throw new ArgumentNullException(nameof(expression)), + memberName ?? throw new ArgumentNullException(nameof(memberName))); + + public static InvocationExpressionSyntax Invocation(ExpressionSyntax expression, params ArgumentSyntax[] arguments) => + SyntaxFactory.InvocationExpression(expression ?? throw new ArgumentNullException(nameof(expression))) + .WithArgumentList(SyntaxFactory.ArgumentList(SyntaxFactory.SeparatedList(arguments))); + + private static string NormalizeDocumentation(string value) + { + var result = new StringBuilder(value.Length); + var previousWasWhitespace = false; + foreach (var character in value) + { + if (char.IsWhiteSpace(character)) + { + if (!previousWasWhitespace && result.Length > 0) + { + _ = result.Append(' '); + } + + previousWasWhitespace = true; + continue; + } + + _ = result.Append(character); + previousWasWhitespace = false; + } + + return result.ToString().Trim(); + } + + private static string EscapeXml(string value) => value + .Replace("&", "&", StringComparison.Ordinal) + .Replace("<", "<", StringComparison.Ordinal) + .Replace(">", ">", StringComparison.Ordinal) + .Replace("\"", """, StringComparison.Ordinal) + .Replace("'", "'", StringComparison.Ordinal); +} diff --git a/tools/OpenCode.Sdk.Tools/Generator/Emission/GeneratedSource.cs b/tools/OpenCode.Sdk.Tools/Generator/Emission/GeneratedSource.cs new file mode 100644 index 00000000..f0c52d10 --- /dev/null +++ b/tools/OpenCode.Sdk.Tools/Generator/Emission/GeneratedSource.cs @@ -0,0 +1,20 @@ +namespace OpenCode.Sdk.Tools.Generator.Emission; + +internal sealed record GeneratedSource +{ + public required string RelativePath + { + get; + init + { + ArgumentException.ThrowIfNullOrWhiteSpace(value); + field = value; + } + } + + public required ReadOnlyMemory Utf8Source + { + get; + init => field = value.ToArray(); + } +} diff --git a/tools/OpenCode.Sdk.Tools/Generator/Emission/ModelEmitter.cs b/tools/OpenCode.Sdk.Tools/Generator/Emission/ModelEmitter.cs new file mode 100644 index 00000000..bd0a9b9e --- /dev/null +++ b/tools/OpenCode.Sdk.Tools/Generator/Emission/ModelEmitter.cs @@ -0,0 +1,295 @@ +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using OpenCode.Sdk.Tools.Generator.Binding; +using OpenCode.Sdk.Tools.Generator.Binding.Models; +using OpenCode.Sdk.Tools.Generator.Ingestion.Models; + +namespace OpenCode.Sdk.Tools.Generator.Emission; + +internal static class ModelEmitter +{ + public static IReadOnlyList Emit(EmitPlan plan) + { + ArgumentNullException.ThrowIfNull(plan); + var unions = plan.Unions.ToDictionary(static union => union.Name, StringComparer.Ordinal); + var result = new List(plan.Models.Count); + foreach (var model in plan.Models.OrderBy(static model => model.Name, StringComparer.Ordinal)) + { + result.Add(model switch + { + ObjectModelPlan objectModel => EmitObject(objectModel, ResolveBaseUnion(objectModel, unions)), + EnumModelPlan enumModel => EmitEnum(enumModel), + _ => throw new InvalidOperationException($"Unknown model plan '{model.GetType().Name}'."), + }); + } + + return Array.AsReadOnly([.. result]); + } + + private static GeneratedSource EmitObject(ObjectModelPlan model, UnionPlan? baseUnion) + { + if (baseUnion is not null && model.Properties.Count(property => IsDiscriminator(property, baseUnion)) is not 1) + { + throw new InvalidOperationException($"Union variant '{model.Name}' must carry exactly one '{baseUnion.MarkerWireName}' marker."); + } + + var declaration = SyntaxFactory.RecordDeclaration(SyntaxFactory.Token(SyntaxKind.RecordKeyword), model.Name) + .WithModifiers(SyntaxFactory.TokenList( + SyntaxFactory.Token(SyntaxKind.PublicKeyword), + SyntaxFactory.Token(SyntaxKind.SealedKeyword))) + .WithOpenBraceToken(SyntaxFactory.Token(SyntaxKind.OpenBraceToken)) + .WithCloseBraceToken(SyntaxFactory.Token(SyntaxKind.CloseBraceToken)) + .WithMembers(SyntaxFactory.List(model.Properties.Select(property => + EmitProperty(property, baseUnion is not null && IsDiscriminator(property, baseUnion))))) + .WithLeadingTrivia(EmissionSyntax.Documentation(model.Description ?? $"Represents a {DisplayName(model.Name)} value.")); + if (model.BaseTypeName is not null) + { + declaration = declaration.WithBaseList(SyntaxFactory.BaseList( + SyntaxFactory.SingletonSeparatedList( + SyntaxFactory.SimpleBaseType(TypeSyntaxEmitter.EmitNamed(model.BaseTypeName))))); + } + + var unit = EmissionSyntax.CompilationUnit(model.Namespace, CollectUsings(model), [declaration]); + return EmissionSyntax.CreateSource($"Models/{model.Name}.cs", unit); + } + + private static GeneratedSource EmitEnum(EnumModelPlan model) + { + var converterType = TypeSyntaxEmitter.Generic("JsonStringEnumConverter", TypeSyntaxEmitter.EmitNamed(model.Name)); + var declaration = SyntaxFactory.EnumDeclaration(model.Name) + .WithModifiers(SyntaxFactory.TokenList(SyntaxFactory.Token(SyntaxKind.PublicKeyword))) + .AddAttributeLists(EmissionSyntax.Attribute("JsonConverter", SyntaxFactory.AttributeArgument( + SyntaxFactory.TypeOfExpression(converterType)))) + .WithMembers(SyntaxFactory.SeparatedList(model.Values.Select(static value => SyntaxFactory.EnumMemberDeclaration(value.Name) + .AddAttributeLists(EmissionSyntax.Attribute("JsonStringEnumMemberName", EmissionSyntax.StringArgument(value.WireValue))) + .WithLeadingTrivia(EmissionSyntax.Documentation($"Represents the '{value.WireValue}' wire value."))))) + .WithLeadingTrivia(EmissionSyntax.Documentation(model.Description ?? $"Defines the supported {DisplayName(model.Name)} values.")); + var unit = EmissionSyntax.CompilationUnit(model.Namespace, ["System.Text.Json.Serialization"], [declaration]); + return EmissionSyntax.CreateSource($"Models/{model.Name}.cs", unit); + } + + private static PropertyDeclarationSyntax EmitProperty(ModelPropertyPlan property, bool isDiscriminator) + { + var declaration = SyntaxFactory.PropertyDeclaration(TypeSyntaxEmitter.Emit(property.Type), property.Name) + .AddAttributeLists(EmissionSyntax.Attribute("JsonPropertyName", EmissionSyntax.StringArgument(property.WireName))) + .WithLeadingTrivia(EmissionSyntax.Documentation(property.Description ?? $"Gets the {DisplayName(property.Name)} value.")); + if (property.Type.IsNullable) + { + declaration = declaration.AddAttributeLists(EmissionSyntax.Attribute( + "JsonIgnore", + SyntaxFactory.AttributeArgument(EmissionSyntax.MemberAccess( + SyntaxFactory.IdentifierName("JsonIgnoreCondition"), + "WhenWritingNull")) + .WithNameEquals(SyntaxFactory.NameEquals("Condition")))); + } + + if (isDiscriminator) + { + return declaration + .WithModifiers(SyntaxFactory.TokenList( + SyntaxFactory.Token(SyntaxKind.PublicKeyword), + SyntaxFactory.Token(SyntaxKind.OverrideKeyword))) + .WithExpressionBody(SyntaxFactory.ArrowExpressionClause(EmitLiteral(property))) + .WithSemicolonToken(SyntaxFactory.Token(SyntaxKind.SemicolonToken)); + } + + var modifiers = new List { SyntaxFactory.Token(SyntaxKind.PublicKeyword), }; + if (property.IsRequired) + { + modifiers.Add(SyntaxFactory.Token(SyntaxKind.RequiredKeyword)); + } + + declaration = declaration + .WithModifiers(SyntaxFactory.TokenList(modifiers)) + .WithAccessorList(property.Type.IsCollection ? EmitCollectionAccessors(property.Type) : EmitAutoAccessors()); + var initializer = EmitCollectionInitializer(property.Type); + return initializer is null + ? declaration + : declaration.WithInitializer(SyntaxFactory.EqualsValueClause(initializer)) + .WithSemicolonToken(SyntaxFactory.Token(SyntaxKind.SemicolonToken)); + } + + private static AccessorListSyntax EmitAutoAccessors() => SyntaxFactory.AccessorList(SyntaxFactory.List( + [ + SyntaxFactory.AccessorDeclaration(SyntaxKind.GetAccessorDeclaration) + .WithSemicolonToken(SyntaxFactory.Token(SyntaxKind.SemicolonToken)), + SyntaxFactory.AccessorDeclaration(SyntaxKind.InitAccessorDeclaration) + .WithSemicolonToken(SyntaxFactory.Token(SyntaxKind.SemicolonToken)), + ])); + + private static AccessorListSyntax EmitCollectionAccessors(TypeReferencePlan type) + { + var copy = EmitCollectionCopy(type, SyntaxFactory.IdentifierName("value")); + StatementSyntax assignment; + var statements = new List(); + if (type.IsNullable) + { + assignment = SyntaxFactory.ExpressionStatement(SyntaxFactory.AssignmentExpression( + SyntaxKind.SimpleAssignmentExpression, + SyntaxFactory.IdentifierName("field"), + SyntaxFactory.ConditionalExpression( + SyntaxFactory.IsPatternExpression( + SyntaxFactory.IdentifierName("value"), + SyntaxFactory.ConstantPattern(SyntaxFactory.LiteralExpression(SyntaxKind.NullLiteralExpression))), + SyntaxFactory.LiteralExpression(SyntaxKind.NullLiteralExpression), + copy))); + } + else + { + statements.Add(SyntaxFactory.ExpressionStatement(EmissionSyntax.Invocation( + EmissionSyntax.MemberAccess(SyntaxFactory.IdentifierName("ArgumentNullException"), "ThrowIfNull"), + SyntaxFactory.Argument(SyntaxFactory.IdentifierName("value"))))); + assignment = SyntaxFactory.ExpressionStatement(SyntaxFactory.AssignmentExpression( + SyntaxKind.SimpleAssignmentExpression, + SyntaxFactory.IdentifierName("field"), + copy)); + } + + statements.Add(assignment); + return SyntaxFactory.AccessorList(SyntaxFactory.List( + [ + SyntaxFactory.AccessorDeclaration(SyntaxKind.GetAccessorDeclaration) + .WithSemicolonToken(SyntaxFactory.Token(SyntaxKind.SemicolonToken)), + SyntaxFactory.AccessorDeclaration(SyntaxKind.InitAccessorDeclaration) + .WithBody(SyntaxFactory.Block(statements)), + ])); + } + + private static ExpressionSyntax? EmitCollectionInitializer(TypeReferencePlan type) + { + if (!type.IsCollection || type.IsNullable) + { + return null; + } + + return type switch + { + ListTypeReferencePlan list => EmissionSyntax.Invocation( + EmissionSyntax.MemberAccess( + SyntaxFactory.IdentifierName("Array"), + TypeSyntaxEmitter.Generic("Empty", TypeSyntaxEmitter.Emit(list.ElementType)))), + DictionaryTypeReferencePlan dictionary => EmitEmptyDictionary(dictionary), + _ => throw new InvalidOperationException($"Unknown collection plan '{type.GetType().Name}'."), + }; + } + + private static ExpressionSyntax EmitCollectionCopy(TypeReferencePlan type, ExpressionSyntax value) => type switch + { + ListTypeReferencePlan list => EmissionSyntax.Invocation( + EmissionSyntax.MemberAccess( + SyntaxFactory.ObjectCreationExpression(TypeSyntaxEmitter.Generic("List", TypeSyntaxEmitter.Emit(list.ElementType))) + .WithArgumentList(SyntaxFactory.ArgumentList( + SyntaxFactory.SingletonSeparatedList(SyntaxFactory.Argument(value)))), + "AsReadOnly")), + DictionaryTypeReferencePlan dictionary => EmitDictionaryCopy(dictionary, value), + _ => throw new InvalidOperationException($"Unknown collection plan '{type.GetType().Name}'."), + }; + + private static ObjectCreationExpressionSyntax EmitDictionaryCopy(DictionaryTypeReferencePlan dictionary, ExpressionSyntax value) + { + var dictionaryType = TypeSyntaxEmitter.Generic( + "Dictionary", + SyntaxFactory.PredefinedType(SyntaxFactory.Token(SyntaxKind.StringKeyword)), + TypeSyntaxEmitter.Emit(dictionary.ValueType)); + var mutableCopy = SyntaxFactory.ObjectCreationExpression(dictionaryType) + .WithArgumentList(SyntaxFactory.ArgumentList(SyntaxFactory.SeparatedList( + [ + SyntaxFactory.Argument(value), + SyntaxFactory.Argument(EmissionSyntax.MemberAccess(SyntaxFactory.IdentifierName("StringComparer"), "Ordinal")), + ]))); + var readOnlyType = TypeSyntaxEmitter.Generic( + "ReadOnlyDictionary", + SyntaxFactory.PredefinedType(SyntaxFactory.Token(SyntaxKind.StringKeyword)), + TypeSyntaxEmitter.Emit(dictionary.ValueType)); + return SyntaxFactory.ObjectCreationExpression(readOnlyType) + .WithArgumentList(SyntaxFactory.ArgumentList( + SyntaxFactory.SingletonSeparatedList(SyntaxFactory.Argument(mutableCopy)))); + } + + private static ObjectCreationExpressionSyntax EmitEmptyDictionary(DictionaryTypeReferencePlan dictionary) + { + var dictionaryType = TypeSyntaxEmitter.Generic( + "Dictionary", + SyntaxFactory.PredefinedType(SyntaxFactory.Token(SyntaxKind.StringKeyword)), + TypeSyntaxEmitter.Emit(dictionary.ValueType)); + var empty = SyntaxFactory.ObjectCreationExpression(dictionaryType) + .WithArgumentList(SyntaxFactory.ArgumentList(SyntaxFactory.SingletonSeparatedList( + SyntaxFactory.Argument(EmissionSyntax.MemberAccess(SyntaxFactory.IdentifierName("StringComparer"), "Ordinal"))))); + var readOnlyType = TypeSyntaxEmitter.Generic( + "ReadOnlyDictionary", + SyntaxFactory.PredefinedType(SyntaxFactory.Token(SyntaxKind.StringKeyword)), + TypeSyntaxEmitter.Emit(dictionary.ValueType)); + return SyntaxFactory.ObjectCreationExpression(readOnlyType) + .WithArgumentList(SyntaxFactory.ArgumentList( + SyntaxFactory.SingletonSeparatedList(SyntaxFactory.Argument(empty)))); + } + + private static LiteralExpressionSyntax EmitLiteral(ModelPropertyPlan property) => property.LiteralKind switch + { + LiteralKind.String => SyntaxFactory.LiteralExpression( + SyntaxKind.StringLiteralExpression, + SyntaxFactory.Literal(property.LiteralValue ?? throw new InvalidOperationException("String literal had no value."))), + LiteralKind.Boolean when bool.TryParse(property.LiteralValue, out var value) => SyntaxFactory.LiteralExpression( + value ? SyntaxKind.TrueLiteralExpression : SyntaxKind.FalseLiteralExpression), + _ => throw new InvalidOperationException($"Property '{property.Name}' has an invalid literal plan."), + }; + + private static IReadOnlyList CollectUsings(ObjectModelPlan model) + { + var result = new HashSet(StringComparer.Ordinal); + if (model.Properties.Count > 0) + { + _ = result.Add("System.Text.Json.Serialization"); + } + + foreach (var property in model.Properties) + { + CollectTypeUsings(property.Type, result); + } + + return [.. result.Order(StringComparer.Ordinal)]; + } + + private static void CollectTypeUsings(TypeReferencePlan type, ISet usings) + { + switch (type) + { + case NamedTypeReferencePlan { Name: "Uri" }: + _ = usings.Add("System"); + break; + case NamedTypeReferencePlan { Name: "JsonElement" }: + _ = usings.Add("System.Text.Json"); + break; + case ListTypeReferencePlan list: + _ = usings.Add("System"); + _ = usings.Add("System.Collections.Generic"); + CollectTypeUsings(list.ElementType, usings); + break; + case DictionaryTypeReferencePlan dictionary: + _ = usings.Add("System"); + _ = usings.Add("System.Collections.Generic"); + _ = usings.Add("System.Collections.ObjectModel"); + CollectTypeUsings(dictionary.ValueType, usings); + break; + } + } + + private static UnionPlan? ResolveBaseUnion(ObjectModelPlan model, Dictionary unions) + { + if (model.BaseTypeName is null) + { + return null; + } + + return unions.TryGetValue(model.BaseTypeName, out var union) + ? union + : throw new InvalidOperationException($"Model '{model.Name}' references absent union '{model.BaseTypeName}'."); + } + + private static bool IsDiscriminator(ModelPropertyPlan property, UnionPlan union) => + property.IsLiteral && string.Equals(property.WireName, union.MarkerWireName, StringComparison.Ordinal); + + private static string DisplayName(string name) => + string.Join(' ', CSharpNamePolicy.SplitWords(name).Select(static word => word.ToLowerInvariant())); +} diff --git a/tools/OpenCode.Sdk.Tools/Generator/Emission/RegistryEmitter.cs b/tools/OpenCode.Sdk.Tools/Generator/Emission/RegistryEmitter.cs new file mode 100644 index 00000000..e676223b --- /dev/null +++ b/tools/OpenCode.Sdk.Tools/Generator/Emission/RegistryEmitter.cs @@ -0,0 +1,41 @@ +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using OpenCode.Sdk.Tools.Generator.Binding.Models; + +namespace OpenCode.Sdk.Tools.Generator.Emission; + +internal static class RegistryEmitter +{ + public static IReadOnlyList Emit(RegistryPlan plan) + { + ArgumentNullException.ThrowIfNull(plan); + var declaration = SyntaxFactory.ClassDeclaration("OpenCodeJsonContext") + .WithModifiers(SyntaxFactory.TokenList( + SyntaxFactory.Token(SyntaxKind.InternalKeyword), + SyntaxFactory.Token(SyntaxKind.SealedKeyword), + SyntaxFactory.Token(SyntaxKind.PartialKeyword))) + .WithBaseList(SyntaxFactory.BaseList(SyntaxFactory.SingletonSeparatedList( + SyntaxFactory.SimpleBaseType(SyntaxFactory.IdentifierName("JsonSerializerContext"))))) + .AddAttributeLists(EmissionSyntax.Attribute( + "JsonSourceGenerationOptions", + SyntaxFactory.AttributeArgument(EmissionSyntax.MemberAccess( + SyntaxFactory.IdentifierName("JsonSourceGenerationMode"), + "Metadata")) + .WithNameEquals(SyntaxFactory.NameEquals("GenerationMode")))); + foreach (var typeName in plan.TypeNames.Order(StringComparer.Ordinal)) + { + declaration = declaration.AddAttributeLists(EmissionSyntax.Attribute( + "JsonSerializable", + SyntaxFactory.AttributeArgument(SyntaxFactory.TypeOfExpression(TypeSyntaxEmitter.EmitNamed(typeName))))); + } + + var unit = EmissionSyntax.CompilationUnit( + "OpenCode.Sdk.Internal.Serialization", + ["OpenCode.Sdk.Models", "System.Text.Json.Serialization"], + [declaration]); + return Array.AsReadOnly( + [ + EmissionSyntax.CreateSource("Internal/Serialization/OpenCodeJsonContext.cs", unit), + ]); + } +} diff --git a/tools/OpenCode.Sdk.Tools/Generator/Emission/SourceEmitter.cs b/tools/OpenCode.Sdk.Tools/Generator/Emission/SourceEmitter.cs new file mode 100644 index 00000000..dab9b9fd --- /dev/null +++ b/tools/OpenCode.Sdk.Tools/Generator/Emission/SourceEmitter.cs @@ -0,0 +1,24 @@ +using OpenCode.Sdk.Tools.Generator.Binding.Models; + +namespace OpenCode.Sdk.Tools.Generator.Emission; + +internal static class SourceEmitter +{ + public static IReadOnlyList Emit(EmitPlan plan) + { + ArgumentNullException.ThrowIfNull(plan); + var sources = new List(); + sources.AddRange(ModelEmitter.Emit(plan)); + sources.AddRange(UnionEmitter.Emit(plan.Unions)); + sources.AddRange(RegistryEmitter.Emit(plan.Registry)); + + var duplicate = sources.GroupBy(static source => source.RelativePath, StringComparer.Ordinal) + .FirstOrDefault(static group => group.Count() > 1); + if (duplicate is not null) + { + throw new InvalidOperationException($"Multiple emitters produced '{duplicate.Key}'."); + } + + return Array.AsReadOnly([.. sources.OrderBy(static source => source.RelativePath, StringComparer.Ordinal)]); + } +} diff --git a/tools/OpenCode.Sdk.Tools/Generator/Emission/TypeSyntaxEmitter.cs b/tools/OpenCode.Sdk.Tools/Generator/Emission/TypeSyntaxEmitter.cs new file mode 100644 index 00000000..f340b0b8 --- /dev/null +++ b/tools/OpenCode.Sdk.Tools/Generator/Emission/TypeSyntaxEmitter.cs @@ -0,0 +1,54 @@ +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using OpenCode.Sdk.Tools.Generator.Binding.Models; +using OpenCode.Sdk.Tools.Generator.Ingestion.Models; + +namespace OpenCode.Sdk.Tools.Generator.Emission; + +internal static class TypeSyntaxEmitter +{ + public static TypeSyntax Emit(TypeReferencePlan plan) + { + ArgumentNullException.ThrowIfNull(plan); + var type = plan switch + { + NamedTypeReferencePlan named => EmitNamed(named.Name), + ListTypeReferencePlan list => Generic("IReadOnlyList", Emit(list.ElementType)), + DictionaryTypeReferencePlan dictionary => Generic( + "IReadOnlyDictionary", + SyntaxFactory.PredefinedType(SyntaxFactory.Token(SyntaxKind.StringKeyword)), + Emit(dictionary.ValueType)), + _ => throw new InvalidOperationException($"Unknown type-reference plan '{plan.GetType().Name}'."), + }; + + return plan.IsNullable ? SyntaxFactory.NullableType(type) : type; + } + + public static TypeSyntax EmitMarker(LiteralKind kind) => kind switch + { + LiteralKind.String => SyntaxFactory.PredefinedType(SyntaxFactory.Token(SyntaxKind.StringKeyword)), + LiteralKind.Boolean => SyntaxFactory.PredefinedType(SyntaxFactory.Token(SyntaxKind.BoolKeyword)), + _ => throw new InvalidOperationException($"Unknown literal kind '{kind}'."), + }; + + public static TypeSyntax EmitNamed(string name) + { + ArgumentException.ThrowIfNullOrWhiteSpace(name); + return name switch + { + "bool" => SyntaxFactory.PredefinedType(SyntaxFactory.Token(SyntaxKind.BoolKeyword)), + "double" => SyntaxFactory.PredefinedType(SyntaxFactory.Token(SyntaxKind.DoubleKeyword)), + "long" => SyntaxFactory.PredefinedType(SyntaxFactory.Token(SyntaxKind.LongKeyword)), + "string" => SyntaxFactory.PredefinedType(SyntaxFactory.Token(SyntaxKind.StringKeyword)), + _ => EmissionSyntax.QualifiedName(name), + }; + } + + public static GenericNameSyntax Generic(string name, params TypeSyntax[] arguments) + { + ArgumentException.ThrowIfNullOrWhiteSpace(name); + ArgumentNullException.ThrowIfNull(arguments); + return SyntaxFactory.GenericName(SyntaxFactory.Identifier(name)) + .WithTypeArgumentList(SyntaxFactory.TypeArgumentList(SyntaxFactory.SeparatedList(arguments))); + } +} diff --git a/tools/OpenCode.Sdk.Tools/Generator/Emission/UnionEmitter.cs b/tools/OpenCode.Sdk.Tools/Generator/Emission/UnionEmitter.cs new file mode 100644 index 00000000..ebefcf90 --- /dev/null +++ b/tools/OpenCode.Sdk.Tools/Generator/Emission/UnionEmitter.cs @@ -0,0 +1,438 @@ +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using OpenCode.Sdk.Tools.Generator.Binding; +using OpenCode.Sdk.Tools.Generator.Binding.Models; +using OpenCode.Sdk.Tools.Generator.Ingestion.Models; + +namespace OpenCode.Sdk.Tools.Generator.Emission; + +internal static class UnionEmitter +{ + public static IReadOnlyList Emit(IReadOnlyList unions) + { + ArgumentNullException.ThrowIfNull(unions); + var result = new List(unions.Count * 3); + foreach (var union in unions.OrderBy(static union => union.Name, StringComparer.Ordinal)) + { + result.Add(EmitBase(union)); + result.Add(EmitUnknown(union)); + result.Add(EmitConverter(union)); + } + + return Array.AsReadOnly([.. result]); + } + + private static GeneratedSource EmitBase(UnionPlan union) + { + var converterTypeName = string.Concat(union.Name, "JsonConverter"); + var marker = SyntaxFactory.PropertyDeclaration(TypeSyntaxEmitter.EmitMarker(union.MarkerKind), union.MarkerName) + .WithModifiers(SyntaxFactory.TokenList( + SyntaxFactory.Token(SyntaxKind.PublicKeyword), + SyntaxFactory.Token(SyntaxKind.AbstractKeyword))) + .AddAttributeLists(EmissionSyntax.Attribute("JsonPropertyName", EmissionSyntax.StringArgument(union.MarkerWireName))) + .WithAccessorList(SyntaxFactory.AccessorList(SyntaxFactory.SingletonList( + SyntaxFactory.AccessorDeclaration(SyntaxKind.GetAccessorDeclaration) + .WithSemicolonToken(SyntaxFactory.Token(SyntaxKind.SemicolonToken))))) + .WithLeadingTrivia(EmissionSyntax.Documentation($"Gets the '{union.MarkerWireName}' union marker.")); + var declaration = SyntaxFactory.RecordDeclaration(SyntaxFactory.Token(SyntaxKind.RecordKeyword), union.Name) + .WithModifiers(SyntaxFactory.TokenList( + SyntaxFactory.Token(SyntaxKind.PublicKeyword), + SyntaxFactory.Token(SyntaxKind.AbstractKeyword))) + .WithOpenBraceToken(SyntaxFactory.Token(SyntaxKind.OpenBraceToken)) + .WithCloseBraceToken(SyntaxFactory.Token(SyntaxKind.CloseBraceToken)) + .AddAttributeLists(EmissionSyntax.Attribute( + "JsonConverter", + SyntaxFactory.AttributeArgument(SyntaxFactory.TypeOfExpression(TypeSyntaxEmitter.EmitNamed(converterTypeName))))) + .WithMembers(SyntaxFactory.SingletonList(marker)) + .WithLeadingTrivia(EmissionSyntax.Documentation(union.Description ?? $"Represents a {DisplayName(union.Name)} union.")); + var unit = EmissionSyntax.CompilationUnit( + union.Namespace, + ["OpenCode.Sdk.Internal.Serialization", "System.Text.Json.Serialization"], + [declaration]); + return EmissionSyntax.CreateSource($"Models/{union.Name}.cs", unit); + } + + private static GeneratedSource EmitUnknown(UnionPlan union) + { + var markerType = TypeSyntaxEmitter.EmitMarker(union.MarkerKind); + var members = new MemberDeclarationSyntax[] + { + EmitUnknownMarkerField(markerType), + EmitUnknownConstructor(union, markerType), + EmitUnknownMarkerProperty(union, markerType), + EmitUnknownPayloadProperty(), + }; + var declaration = SyntaxFactory.RecordDeclaration(SyntaxFactory.Token(SyntaxKind.RecordKeyword), union.UnknownTypeName) + .WithModifiers(SyntaxFactory.TokenList( + SyntaxFactory.Token(SyntaxKind.PublicKeyword), + SyntaxFactory.Token(SyntaxKind.SealedKeyword))) + .WithOpenBraceToken(SyntaxFactory.Token(SyntaxKind.OpenBraceToken)) + .WithCloseBraceToken(SyntaxFactory.Token(SyntaxKind.CloseBraceToken)) + .WithBaseList(SyntaxFactory.BaseList(SyntaxFactory.SingletonSeparatedList( + SyntaxFactory.SimpleBaseType(TypeSyntaxEmitter.EmitNamed(union.Name))))) + .WithMembers(SyntaxFactory.List(members)) + .WithLeadingTrivia(EmissionSyntax.Documentation($"Preserves an unknown {DisplayName(union.Name)} payload.")); + var usingNames = union.MarkerKind is LiteralKind.String + ? new[] { "System", "System.Text.Json", "System.Text.Json.Serialization", } + : ["System.Text.Json", "System.Text.Json.Serialization"]; + var unit = EmissionSyntax.CompilationUnit(union.Namespace, usingNames, [declaration]); + return EmissionSyntax.CreateSource($"Models/{union.UnknownTypeName}.cs", unit); + } + + private static FieldDeclarationSyntax EmitUnknownMarkerField(TypeSyntax markerType) => + SyntaxFactory.FieldDeclaration(SyntaxFactory.VariableDeclaration(markerType) + .WithVariables(SyntaxFactory.SingletonSeparatedList(SyntaxFactory.VariableDeclarator("_marker")))) + .WithModifiers(SyntaxFactory.TokenList( + SyntaxFactory.Token(SyntaxKind.PrivateKeyword), + SyntaxFactory.Token(SyntaxKind.ReadOnlyKeyword))); + + private static ConstructorDeclarationSyntax EmitUnknownConstructor(UnionPlan union, TypeSyntax markerType) + { + var markerParameterName = CSharpNamePolicy.ToCamelCase(union.MarkerName); + var statements = new List(); + if (union.MarkerKind is LiteralKind.String) + { + statements.Add(SyntaxFactory.ExpressionStatement(EmissionSyntax.Invocation( + EmissionSyntax.MemberAccess(SyntaxFactory.IdentifierName("ArgumentException"), "ThrowIfNullOrEmpty"), + SyntaxFactory.Argument(SyntaxFactory.IdentifierName(markerParameterName))))); + } + + statements.Add(SyntaxFactory.ExpressionStatement(SyntaxFactory.AssignmentExpression( + SyntaxKind.SimpleAssignmentExpression, + SyntaxFactory.IdentifierName("_marker"), + SyntaxFactory.IdentifierName(markerParameterName)))); + statements.Add(SyntaxFactory.ExpressionStatement(SyntaxFactory.AssignmentExpression( + SyntaxKind.SimpleAssignmentExpression, + SyntaxFactory.IdentifierName("Payload"), + EmissionSyntax.Invocation(EmissionSyntax.MemberAccess(SyntaxFactory.IdentifierName("payload"), "Clone"))))); + return SyntaxFactory.ConstructorDeclaration(union.UnknownTypeName) + .WithModifiers(SyntaxFactory.TokenList(SyntaxFactory.Token(SyntaxKind.PublicKeyword))) + .AddAttributeLists(EmissionSyntax.Attribute("JsonConstructor")) + .WithParameterList(SyntaxFactory.ParameterList(SyntaxFactory.SeparatedList( + [ + SyntaxFactory.Parameter(SyntaxFactory.Identifier(markerParameterName)).WithType(markerType), + SyntaxFactory.Parameter(SyntaxFactory.Identifier("payload")).WithType(SyntaxFactory.IdentifierName("JsonElement")), + ]))) + .WithBody(SyntaxFactory.Block(statements)) + .WithLeadingTrivia(EmissionSyntax.Documentation("Initializes an unknown union value from its marker and raw payload.")); + } + + private static PropertyDeclarationSyntax EmitUnknownMarkerProperty(UnionPlan union, TypeSyntax markerType) => + SyntaxFactory.PropertyDeclaration(markerType, union.MarkerName) + .WithModifiers(SyntaxFactory.TokenList( + SyntaxFactory.Token(SyntaxKind.PublicKeyword), + SyntaxFactory.Token(SyntaxKind.OverrideKeyword))) + .WithExpressionBody(SyntaxFactory.ArrowExpressionClause(SyntaxFactory.IdentifierName("_marker"))) + .WithSemicolonToken(SyntaxFactory.Token(SyntaxKind.SemicolonToken)) + .WithLeadingTrivia(EmissionSyntax.Documentation($"Gets the unrecognized '{union.MarkerWireName}' marker.")); + + private static PropertyDeclarationSyntax EmitUnknownPayloadProperty() => + SyntaxFactory.PropertyDeclaration(SyntaxFactory.IdentifierName("JsonElement"), "Payload") + .WithModifiers(SyntaxFactory.TokenList(SyntaxFactory.Token(SyntaxKind.PublicKeyword))) + .WithAccessorList(SyntaxFactory.AccessorList(SyntaxFactory.SingletonList( + SyntaxFactory.AccessorDeclaration(SyntaxKind.GetAccessorDeclaration) + .WithSemicolonToken(SyntaxFactory.Token(SyntaxKind.SemicolonToken))))) + .WithLeadingTrivia(EmissionSyntax.Documentation("Gets the preserved raw JSON payload.")); + + private static GeneratedSource EmitConverter(UnionPlan union) + { + var converterName = string.Concat(union.Name, "JsonConverter"); + var declaration = SyntaxFactory.ClassDeclaration(converterName) + .WithModifiers(SyntaxFactory.TokenList( + SyntaxFactory.Token(SyntaxKind.InternalKeyword), + SyntaxFactory.Token(SyntaxKind.SealedKeyword))) + .WithBaseList(SyntaxFactory.BaseList(SyntaxFactory.SingletonSeparatedList( + SyntaxFactory.SimpleBaseType(TypeSyntaxEmitter.Generic("JsonConverter", TypeSyntaxEmitter.EmitNamed(union.Name)))))) + .WithMembers(SyntaxFactory.List( + [ + EmitDispatchMap(union), + EmitHandleNull(), + EmitRead(union), + EmitWrite(union), + ])); + var unit = EmissionSyntax.CompilationUnit( + "OpenCode.Sdk.Internal.Serialization", + [ + "OpenCode.Sdk.Models", + "System", + "System.Collections.Generic", + "System.Text.Json", + "System.Text.Json.Serialization", + ], + [declaration]); + return EmissionSyntax.CreateSource($"Internal/Serialization/{converterName}.cs", unit); + } + + private static FieldDeclarationSyntax EmitDispatchMap(UnionPlan union) + { + var markerType = TypeSyntaxEmitter.EmitMarker(union.MarkerKind); + var dictionaryType = TypeSyntaxEmitter.Generic("Dictionary", markerType, SyntaxFactory.IdentifierName("Type")); + var entries = union.Variants.OrderBy(static variant => variant.Tag, StringComparer.Ordinal) + .Select(variant => (ExpressionSyntax)SyntaxFactory.AssignmentExpression( + SyntaxKind.SimpleAssignmentExpression, + SyntaxFactory.ImplicitElementAccess(SyntaxFactory.BracketedArgumentList( + SyntaxFactory.SingletonSeparatedList(SyntaxFactory.Argument(EmitMarkerLiteral(union.MarkerKind, variant.Tag))))), + SyntaxFactory.TypeOfExpression(TypeSyntaxEmitter.EmitNamed(variant.TypeName)))) + .ToArray(); + var initializer = SyntaxFactory.ObjectCreationExpression(dictionaryType) + .WithArgumentList(union.MarkerKind is LiteralKind.String + ? SyntaxFactory.ArgumentList(SyntaxFactory.SingletonSeparatedList( + SyntaxFactory.Argument(EmissionSyntax.MemberAccess(SyntaxFactory.IdentifierName("StringComparer"), "Ordinal")))) + : SyntaxFactory.ArgumentList()) + .WithInitializer(SyntaxFactory.InitializerExpression( + SyntaxKind.CollectionInitializerExpression, + SyntaxFactory.SeparatedList(entries))); + return SyntaxFactory.FieldDeclaration(SyntaxFactory.VariableDeclaration(dictionaryType) + .WithVariables(SyntaxFactory.SingletonSeparatedList( + SyntaxFactory.VariableDeclarator("TypesByTag") + .WithInitializer(SyntaxFactory.EqualsValueClause(initializer))))) + .WithModifiers(SyntaxFactory.TokenList( + SyntaxFactory.Token(SyntaxKind.PrivateKeyword), + SyntaxFactory.Token(SyntaxKind.StaticKeyword), + SyntaxFactory.Token(SyntaxKind.ReadOnlyKeyword))); + } + + private static PropertyDeclarationSyntax EmitHandleNull() => + SyntaxFactory.PropertyDeclaration( + SyntaxFactory.PredefinedType(SyntaxFactory.Token(SyntaxKind.BoolKeyword)), + "HandleNull") + .WithModifiers(SyntaxFactory.TokenList( + SyntaxFactory.Token(SyntaxKind.PublicKeyword), + SyntaxFactory.Token(SyntaxKind.OverrideKeyword))) + .WithExpressionBody(SyntaxFactory.ArrowExpressionClause( + SyntaxFactory.LiteralExpression(SyntaxKind.TrueLiteralExpression))) + .WithSemicolonToken(SyntaxFactory.Token(SyntaxKind.SemicolonToken)); + + private static MethodDeclarationSyntax EmitRead(UnionPlan union) + { + var statements = new List + { + Guard("typeToConvert"), + Guard("options"), + SyntaxFactory.IfStatement( + SyntaxFactory.BinaryExpression( + SyntaxKind.EqualsExpression, + EmissionSyntax.MemberAccess(SyntaxFactory.IdentifierName("reader"), "TokenType"), + EmissionSyntax.MemberAccess(SyntaxFactory.IdentifierName("JsonTokenType"), "Null")), + ThrowJson($"The {union.Name} payload cannot be null.")), + EmitPayloadDocument(), + Local("payload", EmissionSyntax.MemberAccess(SyntaxFactory.IdentifierName("document"), "RootElement")), + EmitMarkerPresenceCheck(union), + }; + statements.AddRange(EmitMarkerRead(union)); + statements.Add(EmitKnownDispatch(union)); + statements.Add(SyntaxFactory.ReturnStatement(SyntaxFactory.ObjectCreationExpression( + SyntaxFactory.IdentifierName(union.UnknownTypeName)) + .WithArgumentList(SyntaxFactory.ArgumentList(SyntaxFactory.SeparatedList( + [ + SyntaxFactory.Argument(SyntaxFactory.IdentifierName("marker")), + SyntaxFactory.Argument(SyntaxFactory.IdentifierName("payload")), + ]))))); + + return SyntaxFactory.MethodDeclaration(TypeSyntaxEmitter.EmitNamed(union.Name), "Read") + .WithModifiers(SyntaxFactory.TokenList( + SyntaxFactory.Token(SyntaxKind.PublicKeyword), + SyntaxFactory.Token(SyntaxKind.OverrideKeyword))) + .WithParameterList(SyntaxFactory.ParameterList(SyntaxFactory.SeparatedList( + [ + SyntaxFactory.Parameter(SyntaxFactory.Identifier("reader")) + .WithType(SyntaxFactory.IdentifierName("Utf8JsonReader")) + .WithModifiers(SyntaxFactory.TokenList(SyntaxFactory.Token(SyntaxKind.RefKeyword))), + SyntaxFactory.Parameter(SyntaxFactory.Identifier("typeToConvert")).WithType(SyntaxFactory.IdentifierName("Type")), + SyntaxFactory.Parameter(SyntaxFactory.Identifier("options")).WithType(SyntaxFactory.IdentifierName("JsonSerializerOptions")), + ]))) + .WithBody(SyntaxFactory.Block(statements)); + } + + private static LocalDeclarationStatementSyntax EmitPayloadDocument() + { + var parse = EmissionSyntax.Invocation( + EmissionSyntax.MemberAccess(SyntaxFactory.IdentifierName("JsonDocument"), "ParseValue"), + SyntaxFactory.Argument(SyntaxFactory.IdentifierName("reader")) + .WithRefKindKeyword(SyntaxFactory.Token(SyntaxKind.RefKeyword))); + return SyntaxFactory.LocalDeclarationStatement(SyntaxFactory.VariableDeclaration(SyntaxFactory.IdentifierName("var")) + .WithVariables(SyntaxFactory.SingletonSeparatedList( + SyntaxFactory.VariableDeclarator("document").WithInitializer(SyntaxFactory.EqualsValueClause(parse))))) + .WithUsingKeyword(SyntaxFactory.Token(SyntaxKind.UsingKeyword)); + } + + private static IfStatementSyntax EmitMarkerPresenceCheck(UnionPlan union) + { + var tryGet = EmissionSyntax.Invocation( + EmissionSyntax.MemberAccess(SyntaxFactory.IdentifierName("payload"), "TryGetProperty"), + SyntaxFactory.Argument(SyntaxFactory.LiteralExpression( + SyntaxKind.StringLiteralExpression, + SyntaxFactory.Literal(union.MarkerWireName))), + SyntaxFactory.Argument(SyntaxFactory.DeclarationExpression( + SyntaxFactory.IdentifierName("var"), + SyntaxFactory.SingleVariableDesignation(SyntaxFactory.Identifier("markerElement")))) + .WithRefKindKeyword(SyntaxFactory.Token(SyntaxKind.OutKeyword))); + return SyntaxFactory.IfStatement( + SyntaxFactory.PrefixUnaryExpression(SyntaxKind.LogicalNotExpression, tryGet), + ThrowJson($"The {union.Name} payload must contain '{union.MarkerWireName}'.")); + } + + private static IReadOnlyList EmitMarkerRead(UnionPlan union) => union.MarkerKind switch + { + LiteralKind.String => EmitStringMarkerRead(union), + LiteralKind.Boolean => EmitBooleanMarkerRead(union), + _ => throw new InvalidOperationException($"Unknown marker kind '{union.MarkerKind}'."), + }; + + private static IReadOnlyList EmitStringMarkerRead(UnionPlan union) => + [ + SyntaxFactory.IfStatement( + SyntaxFactory.BinaryExpression( + SyntaxKind.NotEqualsExpression, + EmissionSyntax.MemberAccess(SyntaxFactory.IdentifierName("markerElement"), "ValueKind"), + EmissionSyntax.MemberAccess(SyntaxFactory.IdentifierName("JsonValueKind"), "String")), + ThrowJson($"The '{union.MarkerWireName}' marker must be a string.")), + Local("marker", EmissionSyntax.Invocation( + EmissionSyntax.MemberAccess(SyntaxFactory.IdentifierName("markerElement"), "GetString"))), + SyntaxFactory.IfStatement( + SyntaxFactory.IsPatternExpression( + SyntaxFactory.IdentifierName("marker"), + SyntaxFactory.ConstantPattern(SyntaxFactory.LiteralExpression(SyntaxKind.NullLiteralExpression))), + ThrowJson($"The '{union.MarkerWireName}' marker cannot be null.")), + ]; + + private static IReadOnlyList EmitBooleanMarkerRead(UnionPlan union) + { + var valueKind = EmissionSyntax.MemberAccess(SyntaxFactory.IdentifierName("markerElement"), "ValueKind"); + return + [ + SyntaxFactory.IfStatement( + SyntaxFactory.BinaryExpression( + SyntaxKind.LogicalAndExpression, + SyntaxFactory.BinaryExpression( + SyntaxKind.NotEqualsExpression, + valueKind, + EmissionSyntax.MemberAccess(SyntaxFactory.IdentifierName("JsonValueKind"), "True")), + SyntaxFactory.BinaryExpression( + SyntaxKind.NotEqualsExpression, + valueKind, + EmissionSyntax.MemberAccess(SyntaxFactory.IdentifierName("JsonValueKind"), "False"))), + ThrowJson($"The '{union.MarkerWireName}' marker must be a boolean.")), + Local("marker", EmissionSyntax.Invocation( + EmissionSyntax.MemberAccess(SyntaxFactory.IdentifierName("markerElement"), "GetBoolean"))), + ]; + } + + private static IfStatementSyntax EmitKnownDispatch(UnionPlan union) + { + var tryGet = EmissionSyntax.Invocation( + EmissionSyntax.MemberAccess(SyntaxFactory.IdentifierName("TypesByTag"), "TryGetValue"), + SyntaxFactory.Argument(SyntaxFactory.IdentifierName("marker")), + SyntaxFactory.Argument(SyntaxFactory.DeclarationExpression( + SyntaxFactory.IdentifierName("var"), + SyntaxFactory.SingleVariableDesignation(SyntaxFactory.Identifier("targetType")))) + .WithRefKindKeyword(SyntaxFactory.Token(SyntaxKind.OutKeyword))); + var getTypeInfo = EmissionSyntax.Invocation( + EmissionSyntax.MemberAccess( + EmissionSyntax.MemberAccess(SyntaxFactory.IdentifierName("OpenCodeJsonContext"), "Default"), + "GetTypeInfo"), + SyntaxFactory.Argument(SyntaxFactory.IdentifierName("targetType"))); + var typeInfo = SyntaxFactory.BinaryExpression( + SyntaxKind.CoalesceExpression, + getTypeInfo, + SyntaxFactory.ThrowExpression(JsonException($"The generated context has no metadata for {union.Name}."))); + var deserialize = EmissionSyntax.Invocation( + EmissionSyntax.MemberAccess(SyntaxFactory.IdentifierName("JsonSerializer"), "Deserialize"), + SyntaxFactory.Argument(SyntaxFactory.IdentifierName("payload")), + SyntaxFactory.Argument(SyntaxFactory.IdentifierName("typeInfo"))); + var result = SyntaxFactory.BinaryExpression( + SyntaxKind.CoalesceExpression, + SyntaxFactory.BinaryExpression( + SyntaxKind.AsExpression, + deserialize, + TypeSyntaxEmitter.EmitNamed(union.Name)), + SyntaxFactory.ThrowExpression(JsonException($"The {union.Name} payload deserialized to null."))); + return SyntaxFactory.IfStatement(tryGet, SyntaxFactory.Block( + Local("typeInfo", typeInfo), + SyntaxFactory.ReturnStatement(result))); + } + + private static MethodDeclarationSyntax EmitWrite(UnionPlan union) + { + var unknownPattern = SyntaxFactory.IsPatternExpression( + SyntaxFactory.IdentifierName("value"), + SyntaxFactory.DeclarationPattern( + SyntaxFactory.IdentifierName(union.UnknownTypeName), + SyntaxFactory.SingleVariableDesignation(SyntaxFactory.Identifier("unknown")))); + var writeUnknown = SyntaxFactory.Block( + SyntaxFactory.ExpressionStatement(EmissionSyntax.Invocation( + EmissionSyntax.MemberAccess( + EmissionSyntax.MemberAccess(SyntaxFactory.IdentifierName("unknown"), "Payload"), + "WriteTo"), + SyntaxFactory.Argument(SyntaxFactory.IdentifierName("writer")))), + SyntaxFactory.ReturnStatement()); + var getRuntimeType = EmissionSyntax.Invocation( + EmissionSyntax.MemberAccess(SyntaxFactory.IdentifierName("value"), "GetType")); + var getTypeInfo = EmissionSyntax.Invocation( + EmissionSyntax.MemberAccess( + EmissionSyntax.MemberAccess(SyntaxFactory.IdentifierName("OpenCodeJsonContext"), "Default"), + "GetTypeInfo"), + SyntaxFactory.Argument(getRuntimeType)); + var typeInfo = SyntaxFactory.BinaryExpression( + SyntaxKind.CoalesceExpression, + getTypeInfo, + SyntaxFactory.ThrowExpression(JsonException($"The generated context has no metadata for {union.Name}."))); + var serialize = EmissionSyntax.Invocation( + EmissionSyntax.MemberAccess(SyntaxFactory.IdentifierName("JsonSerializer"), "Serialize"), + SyntaxFactory.Argument(SyntaxFactory.IdentifierName("writer")), + SyntaxFactory.Argument(SyntaxFactory.IdentifierName("value")), + SyntaxFactory.Argument(SyntaxFactory.IdentifierName("typeInfo"))); + return SyntaxFactory.MethodDeclaration( + SyntaxFactory.PredefinedType(SyntaxFactory.Token(SyntaxKind.VoidKeyword)), + "Write") + .WithModifiers(SyntaxFactory.TokenList( + SyntaxFactory.Token(SyntaxKind.PublicKeyword), + SyntaxFactory.Token(SyntaxKind.OverrideKeyword))) + .WithParameterList(SyntaxFactory.ParameterList(SyntaxFactory.SeparatedList( + [ + SyntaxFactory.Parameter(SyntaxFactory.Identifier("writer")).WithType(SyntaxFactory.IdentifierName("Utf8JsonWriter")), + SyntaxFactory.Parameter(SyntaxFactory.Identifier("value")).WithType(TypeSyntaxEmitter.EmitNamed(union.Name)), + SyntaxFactory.Parameter(SyntaxFactory.Identifier("options")).WithType(SyntaxFactory.IdentifierName("JsonSerializerOptions")), + ]))) + .WithBody(SyntaxFactory.Block( + Guard("writer"), + Guard("value"), + Guard("options"), + SyntaxFactory.IfStatement(unknownPattern, writeUnknown), + Local("typeInfo", typeInfo), + SyntaxFactory.ExpressionStatement(serialize))); + } + + private static ExpressionStatementSyntax Guard(string parameterName) => + SyntaxFactory.ExpressionStatement(EmissionSyntax.Invocation( + EmissionSyntax.MemberAccess(SyntaxFactory.IdentifierName("ArgumentNullException"), "ThrowIfNull"), + SyntaxFactory.Argument(SyntaxFactory.IdentifierName(parameterName)))); + + private static LocalDeclarationStatementSyntax Local(string name, ExpressionSyntax value) => + SyntaxFactory.LocalDeclarationStatement(SyntaxFactory.VariableDeclaration(SyntaxFactory.IdentifierName("var")) + .WithVariables(SyntaxFactory.SingletonSeparatedList( + SyntaxFactory.VariableDeclarator(name).WithInitializer(SyntaxFactory.EqualsValueClause(value))))); + + private static ThrowStatementSyntax ThrowJson(string message) => SyntaxFactory.ThrowStatement(JsonException(message)); + + private static ObjectCreationExpressionSyntax JsonException(string message) => + SyntaxFactory.ObjectCreationExpression(SyntaxFactory.IdentifierName("JsonException")) + .WithArgumentList(SyntaxFactory.ArgumentList(SyntaxFactory.SingletonSeparatedList( + SyntaxFactory.Argument(SyntaxFactory.LiteralExpression( + SyntaxKind.StringLiteralExpression, + SyntaxFactory.Literal(message)))))); + + private static LiteralExpressionSyntax EmitMarkerLiteral(LiteralKind kind, string value) => kind switch + { + LiteralKind.String => SyntaxFactory.LiteralExpression( + SyntaxKind.StringLiteralExpression, + SyntaxFactory.Literal(value)), + LiteralKind.Boolean when bool.TryParse(value, out var boolean) => SyntaxFactory.LiteralExpression( + boolean ? SyntaxKind.TrueLiteralExpression : SyntaxKind.FalseLiteralExpression), + _ => throw new InvalidOperationException($"Marker value '{value}' is invalid for '{kind}'."), + }; + + private static string DisplayName(string name) => + string.Join(' ', CSharpNamePolicy.SplitWords(name).Select(static word => word.ToLowerInvariant())); +} diff --git a/tools/OpenCode.Sdk.Tools/OpenCode.Sdk.Tools.csproj b/tools/OpenCode.Sdk.Tools/OpenCode.Sdk.Tools.csproj index 7932a5da..5555683f 100644 --- a/tools/OpenCode.Sdk.Tools/OpenCode.Sdk.Tools.csproj +++ b/tools/OpenCode.Sdk.Tools/OpenCode.Sdk.Tools.csproj @@ -8,6 +8,7 @@ + From 613e1cb6dd927a76b4c74c7b9593bdf530b4d2fe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deniz=20=C4=B0rgin?= Date: Wed, 12 Aug 2026 00:56:24 +0300 Subject: [PATCH 4/7] feat(generator): commit the M1 model closure --- Directory.Build.targets | 8 + Directory.Packages.props | 2 + docs/ROADMAP.md | 18 +- src/OpenCode.Sdk/.generated-manifest.json | 61 ++++ src/OpenCode.Sdk/.generation-incomplete | 195 +++++++++++ .../LlmToolContentJsonConverter.cs | 108 +++++++ .../OpenCodeErrorJsonConverter.cs | 110 +++++++ .../Serialization/OpenCodeJsonContext.cs | 62 ++++ ...ionMessageAssistantContentJsonConverter.cs | 109 +++++++ .../SessionMessageJsonConverter.cs | 114 +++++++ .../SessionMessageToolStateJsonConverter.cs | 110 +++++++ .../Models/InvalidRequestError.cs | 36 +++ src/OpenCode.Sdk/Models/LlmToolContent.cs | 18 ++ .../Models/MessageNotFoundError.cs | 34 ++ src/OpenCode.Sdk/Models/ModelRef.cs | 29 ++ src/OpenCode.Sdk/Models/OpenCodeError.cs | 18 ++ .../Models/PromptAgentAttachment.cs | 23 ++ .../Models/PromptFileAttachment.cs | 43 +++ src/OpenCode.Sdk/Models/PromptSource.cs | 28 ++ .../Models/SessionErrorUnknown.cs | 22 ++ src/OpenCode.Sdk/Models/SessionMessage.cs | 18 ++ .../Models/SessionMessageAgentSwitched.cs | 50 +++ .../Models/SessionMessageAgentSwitchedTime.cs | 16 + .../Models/SessionMessageAssistant.cs | 113 +++++++ .../Models/SessionMessageAssistantContent.cs | 18 ++ .../SessionMessageAssistantReasoning.cs | 51 +++ .../SessionMessageAssistantReasoningTime.cs | 23 ++ .../Models/SessionMessageAssistantSnapshot.cs | 38 +++ .../Models/SessionMessageAssistantText.cs | 28 ++ .../Models/SessionMessageAssistantTime.cs | 23 ++ .../Models/SessionMessageAssistantTokens.cs | 34 ++ .../SessionMessageAssistantTokensCache.cs | 22 ++ .../Models/SessionMessageAssistantTool.cs | 47 +++ .../SessionMessageAssistantToolProvider.cs | 46 +++ .../Models/SessionMessageAssistantToolTime.cs | 37 +++ .../Models/SessionMessageCompaction.cs | 62 ++++ .../Models/SessionMessageCompactionReason.cs | 22 ++ .../Models/SessionMessageCompactionTime.cs | 16 + .../Models/SessionMessageModelSwitched.cs | 50 +++ .../Models/SessionMessageModelSwitchedTime.cs | 16 + .../Models/SessionMessageShell.cs | 62 ++++ .../Models/SessionMessageShellTime.cs | 23 ++ .../Models/SessionMessageSynthetic.cs | 56 ++++ .../Models/SessionMessageSyntheticTime.cs | 16 + .../Models/SessionMessageSystem.cs | 50 +++ .../Models/SessionMessageSystemTime.cs | 16 + .../Models/SessionMessageToolState.cs | 18 ++ .../SessionMessageToolStateCompleted.cs | 119 +++++++ .../Models/SessionMessageToolStateError.cs | 97 ++++++ .../Models/SessionMessageToolStatePending.cs | 22 ++ .../Models/SessionMessageToolStateRunning.cs | 84 +++++ src/OpenCode.Sdk/Models/SessionMessageUser.cs | 78 +++++ .../Models/SessionMessageUserTime.cs | 16 + .../Models/SessionNotFoundError.cs | 28 ++ src/OpenCode.Sdk/Models/ToolFileContent.cs | 35 ++ src/OpenCode.Sdk/Models/ToolTextContent.cs | 22 ++ src/OpenCode.Sdk/Models/UnauthorizedError.cs | 22 ++ .../Models/UnknownLlmToolContent.cs | 40 +++ .../Models/UnknownOpenCodeError.cs | 40 +++ .../Models/UnknownSessionMessage.cs | 40 +++ .../UnknownSessionMessageAssistantContent.cs | 40 +++ .../Models/UnknownSessionMessageToolState.cs | 40 +++ src/OpenCode.Sdk/OpenCode.Sdk.csproj | 4 + .../known-assistant-content.json | 1 + .../Serialization/known-session-message.json | 1 + .../Serialization/known-tool-state.json | 1 + .../malformed-assistant-marker.json | 1 + .../missing-assistant-marker.json | 1 + .../non-object-assistant-payload.json | 1 + .../null-optional-collection.json | 1 + .../unknown-assistant-content.json | 1 + .../unknown-session-message.json | 1 + .../Serialization/unknown-tool-state.json | 1 + .../OpenCode.Sdk.Tests.csproj | 5 + .../Serialization/OpenCodeJsonContextTests.cs | 170 ++++++++++ tests/OpenCode.Sdk.Tests/SmokeTests.cs | 12 - .../Support/FixtureLoader.cs | 22 ++ .../Support/GeneratedJsonSerializer.cs | 45 +++ .../Output/GenerationWriterTests.cs | 215 +++++++++++++ ...ce_Immutable_Annotated_Models.verified.txt | 9 +- ...ce_Generated_Context_Registry.verified.txt | 2 +- ...erant_Marked_Union_Converters.verified.txt | 50 +++ .../Support/GenerationTestData.cs | 54 ++++ .../Support/RecordingProjectFormatter.cs | 36 +++ .../OpenCode.Sdk.Tools.Tests/ToolAppTests.cs | 74 ++++- .../Commands/GenerateCommand.cs | 78 ++++- .../Generator/Binding/CurationLoader.cs | 23 +- .../Generator/Emission/EmissionSyntax.cs | 68 ++++ .../Generator/Emission/ModelEmitter.cs | 100 ++++-- .../Generator/Emission/RegistryEmitter.cs | 4 +- .../Generator/Emission/UnionEmitter.cs | 44 +-- .../Generator/GenerationCoordinator.cs | 83 +++++ .../Generator/GenerationReport.cs | 38 +++ .../Generator/GenerationRequest.cs | 27 ++ .../OpenCode.Sdk.Tools.csproj | 1 + .../Output/Abstractions/IGenerationWriter.cs | 14 + .../Output/Abstractions/IProjectFormatter.cs | 9 + .../Output/CliWrapProjectFormatter.cs | 46 +++ .../Output/GenerationManifest.cs | 17 + .../Output/GenerationWriter.cs | 303 ++++++++++++++++++ .../OpenCode.Sdk.Tools/Output/WriteResult.cs | 38 +++ .../Serialization/ToolJsonContext.cs | 17 + tools/OpenCode.Sdk.Tools/ToolApp.cs | 15 + 103 files changed, 4370 insertions(+), 105 deletions(-) create mode 100644 Directory.Build.targets create mode 100644 src/OpenCode.Sdk/.generated-manifest.json create mode 100644 src/OpenCode.Sdk/.generation-incomplete create mode 100644 src/OpenCode.Sdk/Internal/Serialization/LlmToolContentJsonConverter.cs create mode 100644 src/OpenCode.Sdk/Internal/Serialization/OpenCodeErrorJsonConverter.cs create mode 100644 src/OpenCode.Sdk/Internal/Serialization/OpenCodeJsonContext.cs create mode 100644 src/OpenCode.Sdk/Internal/Serialization/SessionMessageAssistantContentJsonConverter.cs create mode 100644 src/OpenCode.Sdk/Internal/Serialization/SessionMessageJsonConverter.cs create mode 100644 src/OpenCode.Sdk/Internal/Serialization/SessionMessageToolStateJsonConverter.cs create mode 100644 src/OpenCode.Sdk/Models/InvalidRequestError.cs create mode 100644 src/OpenCode.Sdk/Models/LlmToolContent.cs create mode 100644 src/OpenCode.Sdk/Models/MessageNotFoundError.cs create mode 100644 src/OpenCode.Sdk/Models/ModelRef.cs create mode 100644 src/OpenCode.Sdk/Models/OpenCodeError.cs create mode 100644 src/OpenCode.Sdk/Models/PromptAgentAttachment.cs create mode 100644 src/OpenCode.Sdk/Models/PromptFileAttachment.cs create mode 100644 src/OpenCode.Sdk/Models/PromptSource.cs create mode 100644 src/OpenCode.Sdk/Models/SessionErrorUnknown.cs create mode 100644 src/OpenCode.Sdk/Models/SessionMessage.cs create mode 100644 src/OpenCode.Sdk/Models/SessionMessageAgentSwitched.cs create mode 100644 src/OpenCode.Sdk/Models/SessionMessageAgentSwitchedTime.cs create mode 100644 src/OpenCode.Sdk/Models/SessionMessageAssistant.cs create mode 100644 src/OpenCode.Sdk/Models/SessionMessageAssistantContent.cs create mode 100644 src/OpenCode.Sdk/Models/SessionMessageAssistantReasoning.cs create mode 100644 src/OpenCode.Sdk/Models/SessionMessageAssistantReasoningTime.cs create mode 100644 src/OpenCode.Sdk/Models/SessionMessageAssistantSnapshot.cs create mode 100644 src/OpenCode.Sdk/Models/SessionMessageAssistantText.cs create mode 100644 src/OpenCode.Sdk/Models/SessionMessageAssistantTime.cs create mode 100644 src/OpenCode.Sdk/Models/SessionMessageAssistantTokens.cs create mode 100644 src/OpenCode.Sdk/Models/SessionMessageAssistantTokensCache.cs create mode 100644 src/OpenCode.Sdk/Models/SessionMessageAssistantTool.cs create mode 100644 src/OpenCode.Sdk/Models/SessionMessageAssistantToolProvider.cs create mode 100644 src/OpenCode.Sdk/Models/SessionMessageAssistantToolTime.cs create mode 100644 src/OpenCode.Sdk/Models/SessionMessageCompaction.cs create mode 100644 src/OpenCode.Sdk/Models/SessionMessageCompactionReason.cs create mode 100644 src/OpenCode.Sdk/Models/SessionMessageCompactionTime.cs create mode 100644 src/OpenCode.Sdk/Models/SessionMessageModelSwitched.cs create mode 100644 src/OpenCode.Sdk/Models/SessionMessageModelSwitchedTime.cs create mode 100644 src/OpenCode.Sdk/Models/SessionMessageShell.cs create mode 100644 src/OpenCode.Sdk/Models/SessionMessageShellTime.cs create mode 100644 src/OpenCode.Sdk/Models/SessionMessageSynthetic.cs create mode 100644 src/OpenCode.Sdk/Models/SessionMessageSyntheticTime.cs create mode 100644 src/OpenCode.Sdk/Models/SessionMessageSystem.cs create mode 100644 src/OpenCode.Sdk/Models/SessionMessageSystemTime.cs create mode 100644 src/OpenCode.Sdk/Models/SessionMessageToolState.cs create mode 100644 src/OpenCode.Sdk/Models/SessionMessageToolStateCompleted.cs create mode 100644 src/OpenCode.Sdk/Models/SessionMessageToolStateError.cs create mode 100644 src/OpenCode.Sdk/Models/SessionMessageToolStatePending.cs create mode 100644 src/OpenCode.Sdk/Models/SessionMessageToolStateRunning.cs create mode 100644 src/OpenCode.Sdk/Models/SessionMessageUser.cs create mode 100644 src/OpenCode.Sdk/Models/SessionMessageUserTime.cs create mode 100644 src/OpenCode.Sdk/Models/SessionNotFoundError.cs create mode 100644 src/OpenCode.Sdk/Models/ToolFileContent.cs create mode 100644 src/OpenCode.Sdk/Models/ToolTextContent.cs create mode 100644 src/OpenCode.Sdk/Models/UnauthorizedError.cs create mode 100644 src/OpenCode.Sdk/Models/UnknownLlmToolContent.cs create mode 100644 src/OpenCode.Sdk/Models/UnknownOpenCodeError.cs create mode 100644 src/OpenCode.Sdk/Models/UnknownSessionMessage.cs create mode 100644 src/OpenCode.Sdk/Models/UnknownSessionMessageAssistantContent.cs create mode 100644 src/OpenCode.Sdk/Models/UnknownSessionMessageToolState.cs create mode 100644 tests/OpenCode.Sdk.Tests/Fixtures/Serialization/known-assistant-content.json create mode 100644 tests/OpenCode.Sdk.Tests/Fixtures/Serialization/known-session-message.json create mode 100644 tests/OpenCode.Sdk.Tests/Fixtures/Serialization/known-tool-state.json create mode 100644 tests/OpenCode.Sdk.Tests/Fixtures/Serialization/malformed-assistant-marker.json create mode 100644 tests/OpenCode.Sdk.Tests/Fixtures/Serialization/missing-assistant-marker.json create mode 100644 tests/OpenCode.Sdk.Tests/Fixtures/Serialization/non-object-assistant-payload.json create mode 100644 tests/OpenCode.Sdk.Tests/Fixtures/Serialization/null-optional-collection.json create mode 100644 tests/OpenCode.Sdk.Tests/Fixtures/Serialization/unknown-assistant-content.json create mode 100644 tests/OpenCode.Sdk.Tests/Fixtures/Serialization/unknown-session-message.json create mode 100644 tests/OpenCode.Sdk.Tests/Fixtures/Serialization/unknown-tool-state.json create mode 100644 tests/OpenCode.Sdk.Tests/Serialization/OpenCodeJsonContextTests.cs delete mode 100644 tests/OpenCode.Sdk.Tests/SmokeTests.cs create mode 100644 tests/OpenCode.Sdk.Tests/Support/FixtureLoader.cs create mode 100644 tests/OpenCode.Sdk.Tests/Support/GeneratedJsonSerializer.cs create mode 100644 tests/OpenCode.Sdk.Tools.Tests/Output/GenerationWriterTests.cs create mode 100644 tests/OpenCode.Sdk.Tools.Tests/Support/GenerationTestData.cs create mode 100644 tests/OpenCode.Sdk.Tools.Tests/Support/RecordingProjectFormatter.cs create mode 100644 tools/OpenCode.Sdk.Tools/Generator/GenerationCoordinator.cs create mode 100644 tools/OpenCode.Sdk.Tools/Generator/GenerationReport.cs create mode 100644 tools/OpenCode.Sdk.Tools/Generator/GenerationRequest.cs create mode 100644 tools/OpenCode.Sdk.Tools/Output/Abstractions/IGenerationWriter.cs create mode 100644 tools/OpenCode.Sdk.Tools/Output/Abstractions/IProjectFormatter.cs create mode 100644 tools/OpenCode.Sdk.Tools/Output/CliWrapProjectFormatter.cs create mode 100644 tools/OpenCode.Sdk.Tools/Output/GenerationManifest.cs create mode 100644 tools/OpenCode.Sdk.Tools/Output/GenerationWriter.cs create mode 100644 tools/OpenCode.Sdk.Tools/Output/WriteResult.cs create mode 100644 tools/OpenCode.Sdk.Tools/Serialization/ToolJsonContext.cs diff --git a/Directory.Build.targets b/Directory.Build.targets new file mode 100644 index 00000000..e28ddd6f --- /dev/null +++ b/Directory.Build.targets @@ -0,0 +1,8 @@ + + + + + + diff --git a/Directory.Packages.props b/Directory.Packages.props index 70d09c08..fc4a1e41 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -18,6 +18,7 @@ + @@ -28,6 +29,7 @@ + diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 929e8ece..9f5b0403 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -1,22 +1,18 @@ # Roadmap -Date: 2026-08-11 +Date: 2026-08-12 Operational state: what is done, what is next, what is open. This file shrinks as work lands. Evergreen rules and locked decisions live in `../AGENTS.md`; decision records in `adr/`. ## Status -Structural skeleton, three-OS CI, the pinned spec snapshot, and the Slice 0 tooling skeleton -are in place. The generator-complexity checkpoint (research doc 13; research log session 15) -redirected the build-out: the design specs under `superpowers/` are vision/reference -documents (the sealed surface is the ADRs plus `AGENTS.md`), the slice map is retired in -favor of the milestone list below, and ingestion policy moved from exhaustive DOM -surveillance to semantic-risk fail-closed validation. Slice 1 closed lean on -`feature/slice-01-ingestion-specir`: `ISpecIngestion` projects the complete pin (both -surfaces, landmark smoke green, deterministic) behind the Microsoft.OpenApi boundary -guards; `generate` remains a fail-loud stub until M1's compiler arc. Issue #2 closes with -that branch's PR. +Structural skeleton, three-OS CI, the pinned spec snapshot, Slice 0 tooling, and complete-pin +SpecIR ingestion are in place. M1's compiler arc selects the two walking-skeleton operations, +binds their reachable closure, emits committed Roslyn-built models plus source-generated JSON +metadata, and owns deterministic `generate` / `generate --verify` writes through a guarded +manifest. The partial-operation marker blocks every package while breadth remains incomplete. +The next M1 arc is the minimal transport and callable client with typed errors and `NoThrow`. ## Milestones diff --git a/src/OpenCode.Sdk/.generated-manifest.json b/src/OpenCode.Sdk/.generated-manifest.json new file mode 100644 index 00000000..185076f4 --- /dev/null +++ b/src/OpenCode.Sdk/.generated-manifest.json @@ -0,0 +1,61 @@ +{ + "files": [ + "Internal/Serialization/LlmToolContentJsonConverter.cs", + "Internal/Serialization/OpenCodeErrorJsonConverter.cs", + "Internal/Serialization/OpenCodeJsonContext.cs", + "Internal/Serialization/SessionMessageAssistantContentJsonConverter.cs", + "Internal/Serialization/SessionMessageJsonConverter.cs", + "Internal/Serialization/SessionMessageToolStateJsonConverter.cs", + "Models/InvalidRequestError.cs", + "Models/LlmToolContent.cs", + "Models/MessageNotFoundError.cs", + "Models/ModelRef.cs", + "Models/OpenCodeError.cs", + "Models/PromptAgentAttachment.cs", + "Models/PromptFileAttachment.cs", + "Models/PromptSource.cs", + "Models/SessionErrorUnknown.cs", + "Models/SessionMessage.cs", + "Models/SessionMessageAgentSwitched.cs", + "Models/SessionMessageAgentSwitchedTime.cs", + "Models/SessionMessageAssistant.cs", + "Models/SessionMessageAssistantContent.cs", + "Models/SessionMessageAssistantReasoning.cs", + "Models/SessionMessageAssistantReasoningTime.cs", + "Models/SessionMessageAssistantSnapshot.cs", + "Models/SessionMessageAssistantText.cs", + "Models/SessionMessageAssistantTime.cs", + "Models/SessionMessageAssistantTokens.cs", + "Models/SessionMessageAssistantTokensCache.cs", + "Models/SessionMessageAssistantTool.cs", + "Models/SessionMessageAssistantToolProvider.cs", + "Models/SessionMessageAssistantToolTime.cs", + "Models/SessionMessageCompaction.cs", + "Models/SessionMessageCompactionReason.cs", + "Models/SessionMessageCompactionTime.cs", + "Models/SessionMessageModelSwitched.cs", + "Models/SessionMessageModelSwitchedTime.cs", + "Models/SessionMessageShell.cs", + "Models/SessionMessageShellTime.cs", + "Models/SessionMessageSynthetic.cs", + "Models/SessionMessageSyntheticTime.cs", + "Models/SessionMessageSystem.cs", + "Models/SessionMessageSystemTime.cs", + "Models/SessionMessageToolState.cs", + "Models/SessionMessageToolStateCompleted.cs", + "Models/SessionMessageToolStateError.cs", + "Models/SessionMessageToolStatePending.cs", + "Models/SessionMessageToolStateRunning.cs", + "Models/SessionMessageUser.cs", + "Models/SessionMessageUserTime.cs", + "Models/SessionNotFoundError.cs", + "Models/ToolFileContent.cs", + "Models/ToolTextContent.cs", + "Models/UnauthorizedError.cs", + "Models/UnknownLlmToolContent.cs", + "Models/UnknownOpenCodeError.cs", + "Models/UnknownSessionMessage.cs", + "Models/UnknownSessionMessageAssistantContent.cs", + "Models/UnknownSessionMessageToolState.cs" + ] +} diff --git a/src/OpenCode.Sdk/.generation-incomplete b/src/OpenCode.Sdk/.generation-incomplete new file mode 100644 index 00000000..c1434933 --- /dev/null +++ b/src/OpenCode.Sdk/.generation-incomplete @@ -0,0 +1,195 @@ +Generation is incomplete; packages must not be published. +Selected operations: 2 +Pending modern operations: 59 +Pending legacy operations: 127 +Selected: +- v2.health.get +- v2.session.message +Pending modern: +- v2.agent.list +- v2.command.list +- v2.credential.remove +- v2.credential.update +- v2.event.subscribe +- v2.fs.find +- v2.fs.list +- v2.fs.read +- v2.integration.attempt.cancel +- v2.integration.attempt.complete +- v2.integration.attempt.status +- v2.integration.connect.key +- v2.integration.connect.oauth +- v2.integration.get +- v2.integration.list +- v2.location.get +- v2.model.list +- v2.permission.request.list +- v2.permission.saved.list +- v2.permission.saved.remove +- v2.projectCopy.create +- v2.projectCopy.refresh +- v2.projectCopy.remove +- v2.provider.get +- v2.provider.list +- v2.pty.connect +- v2.pty.connectToken +- v2.pty.create +- v2.pty.get +- v2.pty.list +- v2.pty.remove +- v2.pty.update +- v2.question.request.list +- v2.reference.list +- v2.session.active +- v2.session.compact +- v2.session.context +- v2.session.create +- v2.session.events +- v2.session.get +- v2.session.history +- v2.session.interrupt +- v2.session.list +- v2.session.messages +- v2.session.permission.create +- v2.session.permission.get +- v2.session.permission.list +- v2.session.permission.reply +- v2.session.prompt +- v2.session.question.list +- v2.session.question.reject +- v2.session.question.reply +- v2.session.revert.clear +- v2.session.revert.commit +- v2.session.revert.stage +- v2.session.switchAgent +- v2.session.switchModel +- v2.session.wait +- v2.skill.list +Pending legacy: +- app.agents +- app.log +- app.skills +- auth.remove +- auth.set +- command.list +- config.get +- config.providers +- config.update +- event.subscribe +- experimental.capabilities.get +- experimental.console.get +- experimental.console.listOrgs +- experimental.console.switchOrg +- experimental.controlPlane.moveSession +- experimental.projectCopy.generateName +- experimental.resource.list +- experimental.session.background +- experimental.session.list +- experimental.workspace.adapter.list +- experimental.workspace.create +- experimental.workspace.list +- experimental.workspace.remove +- experimental.workspace.status +- experimental.workspace.syncList +- experimental.workspace.warp +- file.list +- file.read +- file.status +- find.files +- find.symbols +- find.text +- formatter.status +- global.config.get +- global.config.update +- global.dispose +- global.event +- global.health +- global.upgrade +- instance.dispose +- lsp.status +- mcp.add +- mcp.auth.authenticate +- mcp.auth.callback +- mcp.auth.remove +- mcp.auth.start +- mcp.connect +- mcp.disconnect +- mcp.status +- part.delete +- part.update +- path.get +- permission.list +- permission.reply +- permission.respond +- project.current +- project.directories +- project.initGit +- project.list +- project.update +- provider.auth +- provider.list +- provider.oauth.authorize +- provider.oauth.callback +- pty.connect +- pty.connectToken +- pty.create +- pty.get +- pty.list +- pty.remove +- pty.shells +- pty.update +- question.list +- question.reject +- question.reply +- session.abort +- session.children +- session.command +- session.create +- session.delete +- session.deleteMessage +- session.diff +- session.fork +- session.get +- session.init +- session.list +- session.message +- session.messages +- session.prompt +- session.prompt_async +- session.revert +- session.share +- session.shell +- session.status +- session.summarize +- session.todo +- session.unrevert +- session.unshare +- session.update +- sync.history.list +- sync.replay +- sync.start +- sync.steal +- tool.ids +- tool.list +- tui.appendPrompt +- tui.clearPrompt +- tui.control.next +- tui.control.response +- tui.executeCommand +- tui.openHelp +- tui.openModels +- tui.openSessions +- tui.openThemes +- tui.publish +- tui.selectSession +- tui.showToast +- tui.submitPrompt +- vcs.apply +- vcs.diff +- vcs.diff.raw +- vcs.get +- vcs.status +- worktree.create +- worktree.list +- worktree.remove +- worktree.reset diff --git a/src/OpenCode.Sdk/Internal/Serialization/LlmToolContentJsonConverter.cs b/src/OpenCode.Sdk/Internal/Serialization/LlmToolContentJsonConverter.cs new file mode 100644 index 00000000..fa1e93c8 --- /dev/null +++ b/src/OpenCode.Sdk/Internal/Serialization/LlmToolContentJsonConverter.cs @@ -0,0 +1,108 @@ +// Generated by OpenCode.Sdk.Tools from spec/openapi.json. +// Do not edit by hand — change tools/curation.json or the emitters, then regenerate. +using System.Text.Json; +using System.Text.Json.Serialization; +using OpenCode.Sdk.Models; + +namespace OpenCode.Sdk.Internal.Serialization; + +internal sealed class LlmToolContentJsonConverter : JsonConverter +{ + private static readonly Dictionary TypesByTag = new(StringComparer.Ordinal) + { + ["file"] = typeof(ToolFileContent), + ["text"] = typeof(ToolTextContent) + }; + public override bool HandleNull => true; + + public override LlmToolContent Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { +#if NET8_0_OR_GREATER + ArgumentNullException.ThrowIfNull(typeToConvert); +#else + if (typeToConvert is null) + { + throw new ArgumentNullException(nameof(typeToConvert)); + } +#endif + +#if NET8_0_OR_GREATER + ArgumentNullException.ThrowIfNull(options); +#else + if (options is null) + { + throw new ArgumentNullException(nameof(options)); + } +#endif + + if (reader.TokenType == JsonTokenType.Null) + { + throw new JsonException("The LlmToolContent payload cannot be null."); + } + + using var document = JsonDocument.ParseValue(ref reader); + var payload = document.RootElement; + if (payload.ValueKind != JsonValueKind.Object) + { + throw new JsonException("The LlmToolContent payload must be a JSON object."); + } + + if (!payload.TryGetProperty("type", out var markerElement)) + { + throw new JsonException("The LlmToolContent payload must contain 'type'."); + } + + if (markerElement.ValueKind != JsonValueKind.String) + { + throw new JsonException("The 'type' marker must be a string."); + } + + var marker = markerElement.GetString() ?? throw new JsonException("The 'type' marker cannot be null."); + if (TypesByTag.TryGetValue(marker, out var targetType)) + { + var typeInfo = OpenCodeJsonContext.Default.GetTypeInfo(targetType) ?? throw new JsonException("The generated context has no metadata for LlmToolContent."); + return JsonSerializer.Deserialize(payload, typeInfo) as LlmToolContent ?? throw new JsonException("The LlmToolContent payload deserialized to null."); + } + + return new UnknownLlmToolContent(marker, payload); + } + + public override void Write(Utf8JsonWriter writer, LlmToolContent value, JsonSerializerOptions options) + { +#if NET8_0_OR_GREATER + ArgumentNullException.ThrowIfNull(writer); +#else + if (writer is null) + { + throw new ArgumentNullException(nameof(writer)); + } +#endif + +#if NET8_0_OR_GREATER + ArgumentNullException.ThrowIfNull(value); +#else + if (value is null) + { + throw new ArgumentNullException(nameof(value)); + } +#endif + +#if NET8_0_OR_GREATER + ArgumentNullException.ThrowIfNull(options); +#else + if (options is null) + { + throw new ArgumentNullException(nameof(options)); + } +#endif + + if (value is UnknownLlmToolContent unknown) + { + unknown.Payload.WriteTo(writer); + return; + } + + var typeInfo = OpenCodeJsonContext.Default.GetTypeInfo(value.GetType()) ?? throw new JsonException("The generated context has no metadata for LlmToolContent."); + JsonSerializer.Serialize(writer, value, typeInfo); + } +} diff --git a/src/OpenCode.Sdk/Internal/Serialization/OpenCodeErrorJsonConverter.cs b/src/OpenCode.Sdk/Internal/Serialization/OpenCodeErrorJsonConverter.cs new file mode 100644 index 00000000..2babd4b6 --- /dev/null +++ b/src/OpenCode.Sdk/Internal/Serialization/OpenCodeErrorJsonConverter.cs @@ -0,0 +1,110 @@ +// Generated by OpenCode.Sdk.Tools from spec/openapi.json. +// Do not edit by hand — change tools/curation.json or the emitters, then regenerate. +using System.Text.Json; +using System.Text.Json.Serialization; +using OpenCode.Sdk.Models; + +namespace OpenCode.Sdk.Internal.Serialization; + +internal sealed class OpenCodeErrorJsonConverter : JsonConverter +{ + private static readonly Dictionary TypesByTag = new(StringComparer.Ordinal) + { + ["InvalidRequestError"] = typeof(InvalidRequestError), + ["MessageNotFoundError"] = typeof(MessageNotFoundError), + ["SessionNotFoundError"] = typeof(SessionNotFoundError), + ["UnauthorizedError"] = typeof(UnauthorizedError) + }; + public override bool HandleNull => true; + + public override OpenCodeError Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { +#if NET8_0_OR_GREATER + ArgumentNullException.ThrowIfNull(typeToConvert); +#else + if (typeToConvert is null) + { + throw new ArgumentNullException(nameof(typeToConvert)); + } +#endif + +#if NET8_0_OR_GREATER + ArgumentNullException.ThrowIfNull(options); +#else + if (options is null) + { + throw new ArgumentNullException(nameof(options)); + } +#endif + + if (reader.TokenType == JsonTokenType.Null) + { + throw new JsonException("The OpenCodeError payload cannot be null."); + } + + using var document = JsonDocument.ParseValue(ref reader); + var payload = document.RootElement; + if (payload.ValueKind != JsonValueKind.Object) + { + throw new JsonException("The OpenCodeError payload must be a JSON object."); + } + + if (!payload.TryGetProperty("_tag", out var markerElement)) + { + throw new JsonException("The OpenCodeError payload must contain '_tag'."); + } + + if (markerElement.ValueKind != JsonValueKind.String) + { + throw new JsonException("The '_tag' marker must be a string."); + } + + var marker = markerElement.GetString() ?? throw new JsonException("The '_tag' marker cannot be null."); + if (TypesByTag.TryGetValue(marker, out var targetType)) + { + var typeInfo = OpenCodeJsonContext.Default.GetTypeInfo(targetType) ?? throw new JsonException("The generated context has no metadata for OpenCodeError."); + return JsonSerializer.Deserialize(payload, typeInfo) as OpenCodeError ?? throw new JsonException("The OpenCodeError payload deserialized to null."); + } + + return new UnknownOpenCodeError(marker, payload); + } + + public override void Write(Utf8JsonWriter writer, OpenCodeError value, JsonSerializerOptions options) + { +#if NET8_0_OR_GREATER + ArgumentNullException.ThrowIfNull(writer); +#else + if (writer is null) + { + throw new ArgumentNullException(nameof(writer)); + } +#endif + +#if NET8_0_OR_GREATER + ArgumentNullException.ThrowIfNull(value); +#else + if (value is null) + { + throw new ArgumentNullException(nameof(value)); + } +#endif + +#if NET8_0_OR_GREATER + ArgumentNullException.ThrowIfNull(options); +#else + if (options is null) + { + throw new ArgumentNullException(nameof(options)); + } +#endif + + if (value is UnknownOpenCodeError unknown) + { + unknown.Payload.WriteTo(writer); + return; + } + + var typeInfo = OpenCodeJsonContext.Default.GetTypeInfo(value.GetType()) ?? throw new JsonException("The generated context has no metadata for OpenCodeError."); + JsonSerializer.Serialize(writer, value, typeInfo); + } +} diff --git a/src/OpenCode.Sdk/Internal/Serialization/OpenCodeJsonContext.cs b/src/OpenCode.Sdk/Internal/Serialization/OpenCodeJsonContext.cs new file mode 100644 index 00000000..2d9e8576 --- /dev/null +++ b/src/OpenCode.Sdk/Internal/Serialization/OpenCodeJsonContext.cs @@ -0,0 +1,62 @@ +// Generated by OpenCode.Sdk.Tools from spec/openapi.json. +// Do not edit by hand — change tools/curation.json or the emitters, then regenerate. +using System.Text.Json.Serialization; +using OpenCode.Sdk.Models; + +namespace OpenCode.Sdk.Internal.Serialization; + +[JsonSourceGenerationOptions(GenerationMode = JsonSourceGenerationMode.Metadata, RespectNullableAnnotations = true)] +[JsonSerializable(typeof(InvalidRequestError))] +[JsonSerializable(typeof(LlmToolContent))] +[JsonSerializable(typeof(MessageNotFoundError))] +[JsonSerializable(typeof(ModelRef))] +[JsonSerializable(typeof(OpenCodeError))] +[JsonSerializable(typeof(PromptAgentAttachment))] +[JsonSerializable(typeof(PromptFileAttachment))] +[JsonSerializable(typeof(PromptSource))] +[JsonSerializable(typeof(SessionErrorUnknown))] +[JsonSerializable(typeof(SessionMessage))] +[JsonSerializable(typeof(SessionMessageAgentSwitched))] +[JsonSerializable(typeof(SessionMessageAgentSwitchedTime))] +[JsonSerializable(typeof(SessionMessageAssistant))] +[JsonSerializable(typeof(SessionMessageAssistantContent))] +[JsonSerializable(typeof(SessionMessageAssistantReasoning))] +[JsonSerializable(typeof(SessionMessageAssistantReasoningTime))] +[JsonSerializable(typeof(SessionMessageAssistantSnapshot))] +[JsonSerializable(typeof(SessionMessageAssistantText))] +[JsonSerializable(typeof(SessionMessageAssistantTime))] +[JsonSerializable(typeof(SessionMessageAssistantTokens))] +[JsonSerializable(typeof(SessionMessageAssistantTokensCache))] +[JsonSerializable(typeof(SessionMessageAssistantTool))] +[JsonSerializable(typeof(SessionMessageAssistantToolProvider))] +[JsonSerializable(typeof(SessionMessageAssistantToolTime))] +[JsonSerializable(typeof(SessionMessageCompaction))] +[JsonSerializable(typeof(SessionMessageCompactionReason))] +[JsonSerializable(typeof(SessionMessageCompactionTime))] +[JsonSerializable(typeof(SessionMessageModelSwitched))] +[JsonSerializable(typeof(SessionMessageModelSwitchedTime))] +[JsonSerializable(typeof(SessionMessageShell))] +[JsonSerializable(typeof(SessionMessageShellTime))] +[JsonSerializable(typeof(SessionMessageSynthetic))] +[JsonSerializable(typeof(SessionMessageSyntheticTime))] +[JsonSerializable(typeof(SessionMessageSystem))] +[JsonSerializable(typeof(SessionMessageSystemTime))] +[JsonSerializable(typeof(SessionMessageToolState))] +[JsonSerializable(typeof(SessionMessageToolStateCompleted))] +[JsonSerializable(typeof(SessionMessageToolStateError))] +[JsonSerializable(typeof(SessionMessageToolStatePending))] +[JsonSerializable(typeof(SessionMessageToolStateRunning))] +[JsonSerializable(typeof(SessionMessageUser))] +[JsonSerializable(typeof(SessionMessageUserTime))] +[JsonSerializable(typeof(SessionNotFoundError))] +[JsonSerializable(typeof(ToolFileContent))] +[JsonSerializable(typeof(ToolTextContent))] +[JsonSerializable(typeof(UnauthorizedError))] +[JsonSerializable(typeof(UnknownLlmToolContent))] +[JsonSerializable(typeof(UnknownOpenCodeError))] +[JsonSerializable(typeof(UnknownSessionMessage))] +[JsonSerializable(typeof(UnknownSessionMessageAssistantContent))] +[JsonSerializable(typeof(UnknownSessionMessageToolState))] +internal sealed partial class OpenCodeJsonContext : JsonSerializerContext +{ +} diff --git a/src/OpenCode.Sdk/Internal/Serialization/SessionMessageAssistantContentJsonConverter.cs b/src/OpenCode.Sdk/Internal/Serialization/SessionMessageAssistantContentJsonConverter.cs new file mode 100644 index 00000000..53592713 --- /dev/null +++ b/src/OpenCode.Sdk/Internal/Serialization/SessionMessageAssistantContentJsonConverter.cs @@ -0,0 +1,109 @@ +// Generated by OpenCode.Sdk.Tools from spec/openapi.json. +// Do not edit by hand — change tools/curation.json or the emitters, then regenerate. +using System.Text.Json; +using System.Text.Json.Serialization; +using OpenCode.Sdk.Models; + +namespace OpenCode.Sdk.Internal.Serialization; + +internal sealed class SessionMessageAssistantContentJsonConverter : JsonConverter +{ + private static readonly Dictionary TypesByTag = new(StringComparer.Ordinal) + { + ["reasoning"] = typeof(SessionMessageAssistantReasoning), + ["text"] = typeof(SessionMessageAssistantText), + ["tool"] = typeof(SessionMessageAssistantTool) + }; + public override bool HandleNull => true; + + public override SessionMessageAssistantContent Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { +#if NET8_0_OR_GREATER + ArgumentNullException.ThrowIfNull(typeToConvert); +#else + if (typeToConvert is null) + { + throw new ArgumentNullException(nameof(typeToConvert)); + } +#endif + +#if NET8_0_OR_GREATER + ArgumentNullException.ThrowIfNull(options); +#else + if (options is null) + { + throw new ArgumentNullException(nameof(options)); + } +#endif + + if (reader.TokenType == JsonTokenType.Null) + { + throw new JsonException("The SessionMessageAssistantContent payload cannot be null."); + } + + using var document = JsonDocument.ParseValue(ref reader); + var payload = document.RootElement; + if (payload.ValueKind != JsonValueKind.Object) + { + throw new JsonException("The SessionMessageAssistantContent payload must be a JSON object."); + } + + if (!payload.TryGetProperty("type", out var markerElement)) + { + throw new JsonException("The SessionMessageAssistantContent payload must contain 'type'."); + } + + if (markerElement.ValueKind != JsonValueKind.String) + { + throw new JsonException("The 'type' marker must be a string."); + } + + var marker = markerElement.GetString() ?? throw new JsonException("The 'type' marker cannot be null."); + if (TypesByTag.TryGetValue(marker, out var targetType)) + { + var typeInfo = OpenCodeJsonContext.Default.GetTypeInfo(targetType) ?? throw new JsonException("The generated context has no metadata for SessionMessageAssistantContent."); + return JsonSerializer.Deserialize(payload, typeInfo) as SessionMessageAssistantContent ?? throw new JsonException("The SessionMessageAssistantContent payload deserialized to null."); + } + + return new UnknownSessionMessageAssistantContent(marker, payload); + } + + public override void Write(Utf8JsonWriter writer, SessionMessageAssistantContent value, JsonSerializerOptions options) + { +#if NET8_0_OR_GREATER + ArgumentNullException.ThrowIfNull(writer); +#else + if (writer is null) + { + throw new ArgumentNullException(nameof(writer)); + } +#endif + +#if NET8_0_OR_GREATER + ArgumentNullException.ThrowIfNull(value); +#else + if (value is null) + { + throw new ArgumentNullException(nameof(value)); + } +#endif + +#if NET8_0_OR_GREATER + ArgumentNullException.ThrowIfNull(options); +#else + if (options is null) + { + throw new ArgumentNullException(nameof(options)); + } +#endif + + if (value is UnknownSessionMessageAssistantContent unknown) + { + unknown.Payload.WriteTo(writer); + return; + } + + var typeInfo = OpenCodeJsonContext.Default.GetTypeInfo(value.GetType()) ?? throw new JsonException("The generated context has no metadata for SessionMessageAssistantContent."); + JsonSerializer.Serialize(writer, value, typeInfo); + } +} diff --git a/src/OpenCode.Sdk/Internal/Serialization/SessionMessageJsonConverter.cs b/src/OpenCode.Sdk/Internal/Serialization/SessionMessageJsonConverter.cs new file mode 100644 index 00000000..1cd4682b --- /dev/null +++ b/src/OpenCode.Sdk/Internal/Serialization/SessionMessageJsonConverter.cs @@ -0,0 +1,114 @@ +// Generated by OpenCode.Sdk.Tools from spec/openapi.json. +// Do not edit by hand — change tools/curation.json or the emitters, then regenerate. +using System.Text.Json; +using System.Text.Json.Serialization; +using OpenCode.Sdk.Models; + +namespace OpenCode.Sdk.Internal.Serialization; + +internal sealed class SessionMessageJsonConverter : JsonConverter +{ + private static readonly Dictionary TypesByTag = new(StringComparer.Ordinal) + { + ["agent-switched"] = typeof(SessionMessageAgentSwitched), + ["assistant"] = typeof(SessionMessageAssistant), + ["compaction"] = typeof(SessionMessageCompaction), + ["model-switched"] = typeof(SessionMessageModelSwitched), + ["shell"] = typeof(SessionMessageShell), + ["synthetic"] = typeof(SessionMessageSynthetic), + ["system"] = typeof(SessionMessageSystem), + ["user"] = typeof(SessionMessageUser) + }; + public override bool HandleNull => true; + + public override SessionMessage Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { +#if NET8_0_OR_GREATER + ArgumentNullException.ThrowIfNull(typeToConvert); +#else + if (typeToConvert is null) + { + throw new ArgumentNullException(nameof(typeToConvert)); + } +#endif + +#if NET8_0_OR_GREATER + ArgumentNullException.ThrowIfNull(options); +#else + if (options is null) + { + throw new ArgumentNullException(nameof(options)); + } +#endif + + if (reader.TokenType == JsonTokenType.Null) + { + throw new JsonException("The SessionMessage payload cannot be null."); + } + + using var document = JsonDocument.ParseValue(ref reader); + var payload = document.RootElement; + if (payload.ValueKind != JsonValueKind.Object) + { + throw new JsonException("The SessionMessage payload must be a JSON object."); + } + + if (!payload.TryGetProperty("type", out var markerElement)) + { + throw new JsonException("The SessionMessage payload must contain 'type'."); + } + + if (markerElement.ValueKind != JsonValueKind.String) + { + throw new JsonException("The 'type' marker must be a string."); + } + + var marker = markerElement.GetString() ?? throw new JsonException("The 'type' marker cannot be null."); + if (TypesByTag.TryGetValue(marker, out var targetType)) + { + var typeInfo = OpenCodeJsonContext.Default.GetTypeInfo(targetType) ?? throw new JsonException("The generated context has no metadata for SessionMessage."); + return JsonSerializer.Deserialize(payload, typeInfo) as SessionMessage ?? throw new JsonException("The SessionMessage payload deserialized to null."); + } + + return new UnknownSessionMessage(marker, payload); + } + + public override void Write(Utf8JsonWriter writer, SessionMessage value, JsonSerializerOptions options) + { +#if NET8_0_OR_GREATER + ArgumentNullException.ThrowIfNull(writer); +#else + if (writer is null) + { + throw new ArgumentNullException(nameof(writer)); + } +#endif + +#if NET8_0_OR_GREATER + ArgumentNullException.ThrowIfNull(value); +#else + if (value is null) + { + throw new ArgumentNullException(nameof(value)); + } +#endif + +#if NET8_0_OR_GREATER + ArgumentNullException.ThrowIfNull(options); +#else + if (options is null) + { + throw new ArgumentNullException(nameof(options)); + } +#endif + + if (value is UnknownSessionMessage unknown) + { + unknown.Payload.WriteTo(writer); + return; + } + + var typeInfo = OpenCodeJsonContext.Default.GetTypeInfo(value.GetType()) ?? throw new JsonException("The generated context has no metadata for SessionMessage."); + JsonSerializer.Serialize(writer, value, typeInfo); + } +} diff --git a/src/OpenCode.Sdk/Internal/Serialization/SessionMessageToolStateJsonConverter.cs b/src/OpenCode.Sdk/Internal/Serialization/SessionMessageToolStateJsonConverter.cs new file mode 100644 index 00000000..3a0ae972 --- /dev/null +++ b/src/OpenCode.Sdk/Internal/Serialization/SessionMessageToolStateJsonConverter.cs @@ -0,0 +1,110 @@ +// Generated by OpenCode.Sdk.Tools from spec/openapi.json. +// Do not edit by hand — change tools/curation.json or the emitters, then regenerate. +using System.Text.Json; +using System.Text.Json.Serialization; +using OpenCode.Sdk.Models; + +namespace OpenCode.Sdk.Internal.Serialization; + +internal sealed class SessionMessageToolStateJsonConverter : JsonConverter +{ + private static readonly Dictionary TypesByTag = new(StringComparer.Ordinal) + { + ["completed"] = typeof(SessionMessageToolStateCompleted), + ["error"] = typeof(SessionMessageToolStateError), + ["pending"] = typeof(SessionMessageToolStatePending), + ["running"] = typeof(SessionMessageToolStateRunning) + }; + public override bool HandleNull => true; + + public override SessionMessageToolState Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { +#if NET8_0_OR_GREATER + ArgumentNullException.ThrowIfNull(typeToConvert); +#else + if (typeToConvert is null) + { + throw new ArgumentNullException(nameof(typeToConvert)); + } +#endif + +#if NET8_0_OR_GREATER + ArgumentNullException.ThrowIfNull(options); +#else + if (options is null) + { + throw new ArgumentNullException(nameof(options)); + } +#endif + + if (reader.TokenType == JsonTokenType.Null) + { + throw new JsonException("The SessionMessageToolState payload cannot be null."); + } + + using var document = JsonDocument.ParseValue(ref reader); + var payload = document.RootElement; + if (payload.ValueKind != JsonValueKind.Object) + { + throw new JsonException("The SessionMessageToolState payload must be a JSON object."); + } + + if (!payload.TryGetProperty("status", out var markerElement)) + { + throw new JsonException("The SessionMessageToolState payload must contain 'status'."); + } + + if (markerElement.ValueKind != JsonValueKind.String) + { + throw new JsonException("The 'status' marker must be a string."); + } + + var marker = markerElement.GetString() ?? throw new JsonException("The 'status' marker cannot be null."); + if (TypesByTag.TryGetValue(marker, out var targetType)) + { + var typeInfo = OpenCodeJsonContext.Default.GetTypeInfo(targetType) ?? throw new JsonException("The generated context has no metadata for SessionMessageToolState."); + return JsonSerializer.Deserialize(payload, typeInfo) as SessionMessageToolState ?? throw new JsonException("The SessionMessageToolState payload deserialized to null."); + } + + return new UnknownSessionMessageToolState(marker, payload); + } + + public override void Write(Utf8JsonWriter writer, SessionMessageToolState value, JsonSerializerOptions options) + { +#if NET8_0_OR_GREATER + ArgumentNullException.ThrowIfNull(writer); +#else + if (writer is null) + { + throw new ArgumentNullException(nameof(writer)); + } +#endif + +#if NET8_0_OR_GREATER + ArgumentNullException.ThrowIfNull(value); +#else + if (value is null) + { + throw new ArgumentNullException(nameof(value)); + } +#endif + +#if NET8_0_OR_GREATER + ArgumentNullException.ThrowIfNull(options); +#else + if (options is null) + { + throw new ArgumentNullException(nameof(options)); + } +#endif + + if (value is UnknownSessionMessageToolState unknown) + { + unknown.Payload.WriteTo(writer); + return; + } + + var typeInfo = OpenCodeJsonContext.Default.GetTypeInfo(value.GetType()) ?? throw new JsonException("The generated context has no metadata for SessionMessageToolState."); + JsonSerializer.Serialize(writer, value, typeInfo); + } +} diff --git a/src/OpenCode.Sdk/Models/InvalidRequestError.cs b/src/OpenCode.Sdk/Models/InvalidRequestError.cs new file mode 100644 index 00000000..e41cac39 --- /dev/null +++ b/src/OpenCode.Sdk/Models/InvalidRequestError.cs @@ -0,0 +1,36 @@ +// Generated by OpenCode.Sdk.Tools from spec/openapi.json. +// Do not edit by hand — change tools/curation.json or the emitters, then regenerate. +using System.Text.Json.Serialization; + +namespace OpenCode.Sdk.Models; +/// +/// Represents a invalid request error value. +/// +public sealed record InvalidRequestError : OpenCodeError +{ + /// + /// Gets the tag value. + /// + [JsonPropertyName("_tag")] + public override string Tag => "InvalidRequestError"; + + /// + /// Gets the message value. + /// + [JsonPropertyName("message")] + public required string Message { get; init; } + + /// + /// Gets the kind value. + /// + [JsonPropertyName("kind")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Kind { get; init; } + + /// + /// Gets the field value. + /// + [JsonPropertyName("field")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Field { get; init; } +} diff --git a/src/OpenCode.Sdk/Models/LlmToolContent.cs b/src/OpenCode.Sdk/Models/LlmToolContent.cs new file mode 100644 index 00000000..7be23e7b --- /dev/null +++ b/src/OpenCode.Sdk/Models/LlmToolContent.cs @@ -0,0 +1,18 @@ +// Generated by OpenCode.Sdk.Tools from spec/openapi.json. +// Do not edit by hand — change tools/curation.json or the emitters, then regenerate. +using System.Text.Json.Serialization; +using OpenCode.Sdk.Internal.Serialization; + +namespace OpenCode.Sdk.Models; +/// +/// Represents a llm tool content union. +/// +[JsonConverter(typeof(LlmToolContentJsonConverter))] +public abstract record LlmToolContent +{ + /// + /// Gets the 'type' union marker. + /// + [JsonPropertyName("type")] + public abstract string Type { get; } +} diff --git a/src/OpenCode.Sdk/Models/MessageNotFoundError.cs b/src/OpenCode.Sdk/Models/MessageNotFoundError.cs new file mode 100644 index 00000000..4ee91c6b --- /dev/null +++ b/src/OpenCode.Sdk/Models/MessageNotFoundError.cs @@ -0,0 +1,34 @@ +// Generated by OpenCode.Sdk.Tools from spec/openapi.json. +// Do not edit by hand — change tools/curation.json or the emitters, then regenerate. +using System.Text.Json.Serialization; + +namespace OpenCode.Sdk.Models; +/// +/// Represents a message not found error value. +/// +public sealed record MessageNotFoundError : OpenCodeError +{ + /// + /// Gets the tag value. + /// + [JsonPropertyName("_tag")] + public override string Tag => "MessageNotFoundError"; + + /// + /// Gets the session id value. + /// + [JsonPropertyName("sessionID")] + public required string SessionID { get; init; } + + /// + /// Gets the message id value. + /// + [JsonPropertyName("messageID")] + public required string MessageID { get; init; } + + /// + /// Gets the message value. + /// + [JsonPropertyName("message")] + public required string Message { get; init; } +} diff --git a/src/OpenCode.Sdk/Models/ModelRef.cs b/src/OpenCode.Sdk/Models/ModelRef.cs new file mode 100644 index 00000000..6b7bb617 --- /dev/null +++ b/src/OpenCode.Sdk/Models/ModelRef.cs @@ -0,0 +1,29 @@ +// Generated by OpenCode.Sdk.Tools from spec/openapi.json. +// Do not edit by hand — change tools/curation.json or the emitters, then regenerate. +using System.Text.Json.Serialization; + +namespace OpenCode.Sdk.Models; +/// +/// Represents a model ref value. +/// +public sealed record ModelRef +{ + /// + /// Gets the id value. + /// + [JsonPropertyName("id")] + public required string ID { get; init; } + + /// + /// Gets the provider id value. + /// + [JsonPropertyName("providerID")] + public required string ProviderID { get; init; } + + /// + /// Gets the variant value. + /// + [JsonPropertyName("variant")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Variant { get; init; } +} diff --git a/src/OpenCode.Sdk/Models/OpenCodeError.cs b/src/OpenCode.Sdk/Models/OpenCodeError.cs new file mode 100644 index 00000000..e0afed8e --- /dev/null +++ b/src/OpenCode.Sdk/Models/OpenCodeError.cs @@ -0,0 +1,18 @@ +// Generated by OpenCode.Sdk.Tools from spec/openapi.json. +// Do not edit by hand — change tools/curation.json or the emitters, then regenerate. +using System.Text.Json.Serialization; +using OpenCode.Sdk.Internal.Serialization; + +namespace OpenCode.Sdk.Models; +/// +/// Represents a typed error returned by the opencode API. +/// +[JsonConverter(typeof(OpenCodeErrorJsonConverter))] +public abstract record OpenCodeError +{ + /// + /// Gets the '_tag' union marker. + /// + [JsonPropertyName("_tag")] + public abstract string Tag { get; } +} diff --git a/src/OpenCode.Sdk/Models/PromptAgentAttachment.cs b/src/OpenCode.Sdk/Models/PromptAgentAttachment.cs new file mode 100644 index 00000000..f56e3923 --- /dev/null +++ b/src/OpenCode.Sdk/Models/PromptAgentAttachment.cs @@ -0,0 +1,23 @@ +// Generated by OpenCode.Sdk.Tools from spec/openapi.json. +// Do not edit by hand — change tools/curation.json or the emitters, then regenerate. +using System.Text.Json.Serialization; + +namespace OpenCode.Sdk.Models; +/// +/// Represents a prompt agent attachment value. +/// +public sealed record PromptAgentAttachment +{ + /// + /// Gets the name value. + /// + [JsonPropertyName("name")] + public required string Name { get; init; } + + /// + /// Gets the source value. + /// + [JsonPropertyName("source")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public PromptSource? Source { get; init; } +} diff --git a/src/OpenCode.Sdk/Models/PromptFileAttachment.cs b/src/OpenCode.Sdk/Models/PromptFileAttachment.cs new file mode 100644 index 00000000..f17bd341 --- /dev/null +++ b/src/OpenCode.Sdk/Models/PromptFileAttachment.cs @@ -0,0 +1,43 @@ +// Generated by OpenCode.Sdk.Tools from spec/openapi.json. +// Do not edit by hand — change tools/curation.json or the emitters, then regenerate. +using System.Text.Json.Serialization; + +namespace OpenCode.Sdk.Models; +/// +/// Represents a prompt file attachment value. +/// +public sealed record PromptFileAttachment +{ + /// + /// Gets the uri value. + /// + [JsonPropertyName("uri")] + public required Uri Uri { get; init; } + + /// + /// Gets the mime value. + /// + [JsonPropertyName("mime")] + public required string Mime { get; init; } + + /// + /// Gets the name value. + /// + [JsonPropertyName("name")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Name { get; init; } + + /// + /// Gets the description value. + /// + [JsonPropertyName("description")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Description { get; init; } + + /// + /// Gets the source value. + /// + [JsonPropertyName("source")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public PromptSource? Source { get; init; } +} diff --git a/src/OpenCode.Sdk/Models/PromptSource.cs b/src/OpenCode.Sdk/Models/PromptSource.cs new file mode 100644 index 00000000..d1998bcc --- /dev/null +++ b/src/OpenCode.Sdk/Models/PromptSource.cs @@ -0,0 +1,28 @@ +// Generated by OpenCode.Sdk.Tools from spec/openapi.json. +// Do not edit by hand — change tools/curation.json or the emitters, then regenerate. +using System.Text.Json.Serialization; + +namespace OpenCode.Sdk.Models; +/// +/// Represents a prompt source value. +/// +public sealed record PromptSource +{ + /// + /// Gets the start value. + /// + [JsonPropertyName("start")] + public required double Start { get; init; } + + /// + /// Gets the end value. + /// + [JsonPropertyName("end")] + public required double End { get; init; } + + /// + /// Gets the text value. + /// + [JsonPropertyName("text")] + public required string Text { get; init; } +} diff --git a/src/OpenCode.Sdk/Models/SessionErrorUnknown.cs b/src/OpenCode.Sdk/Models/SessionErrorUnknown.cs new file mode 100644 index 00000000..ff316f61 --- /dev/null +++ b/src/OpenCode.Sdk/Models/SessionErrorUnknown.cs @@ -0,0 +1,22 @@ +// Generated by OpenCode.Sdk.Tools from spec/openapi.json. +// Do not edit by hand — change tools/curation.json or the emitters, then regenerate. +using System.Text.Json.Serialization; + +namespace OpenCode.Sdk.Models; +/// +/// Represents a session error unknown value. +/// +public sealed record SessionErrorUnknown +{ + /// + /// Gets the type value. + /// + [JsonPropertyName("type")] + public required string Type { get; init; } + + /// + /// Gets the message value. + /// + [JsonPropertyName("message")] + public required string Message { get; init; } +} diff --git a/src/OpenCode.Sdk/Models/SessionMessage.cs b/src/OpenCode.Sdk/Models/SessionMessage.cs new file mode 100644 index 00000000..256ca83b --- /dev/null +++ b/src/OpenCode.Sdk/Models/SessionMessage.cs @@ -0,0 +1,18 @@ +// Generated by OpenCode.Sdk.Tools from spec/openapi.json. +// Do not edit by hand — change tools/curation.json or the emitters, then regenerate. +using System.Text.Json.Serialization; +using OpenCode.Sdk.Internal.Serialization; + +namespace OpenCode.Sdk.Models; +/// +/// Represents a session message union. +/// +[JsonConverter(typeof(SessionMessageJsonConverter))] +public abstract record SessionMessage +{ + /// + /// Gets the 'type' union marker. + /// + [JsonPropertyName("type")] + public abstract string Type { get; } +} diff --git a/src/OpenCode.Sdk/Models/SessionMessageAgentSwitched.cs b/src/OpenCode.Sdk/Models/SessionMessageAgentSwitched.cs new file mode 100644 index 00000000..092d3f0a --- /dev/null +++ b/src/OpenCode.Sdk/Models/SessionMessageAgentSwitched.cs @@ -0,0 +1,50 @@ +// Generated by OpenCode.Sdk.Tools from spec/openapi.json. +// Do not edit by hand — change tools/curation.json or the emitters, then regenerate. +using System.Collections.ObjectModel; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace OpenCode.Sdk.Models; +/// +/// Represents a session message agent switched value. +/// +public sealed record SessionMessageAgentSwitched : SessionMessage +{ + /// + /// Gets the id value. + /// + [JsonPropertyName("id")] + public required string ID { get; init; } + + /// + /// Gets the metadata value. + /// + [JsonPropertyName("metadata")] + public IReadOnlyDictionary Metadata + { + get; + init + { + // Source-generated deserialization passes null for an absent optional init-only collection. + field = value is null ? new ReadOnlyDictionary(new Dictionary(StringComparer.Ordinal)) : new ReadOnlyDictionary(value.ToDictionary(static pair => pair.Key, static pair => pair.Value, StringComparer.Ordinal)); + } + } = new ReadOnlyDictionary(new Dictionary(StringComparer.Ordinal)); + + /// + /// Gets the time value. + /// + [JsonPropertyName("time")] + public required SessionMessageAgentSwitchedTime Time { get; init; } + + /// + /// Gets the type value. + /// + [JsonPropertyName("type")] + public override string Type => "agent-switched"; + + /// + /// Gets the agent value. + /// + [JsonPropertyName("agent")] + public required string Agent { get; init; } +} diff --git a/src/OpenCode.Sdk/Models/SessionMessageAgentSwitchedTime.cs b/src/OpenCode.Sdk/Models/SessionMessageAgentSwitchedTime.cs new file mode 100644 index 00000000..0d4f7dd1 --- /dev/null +++ b/src/OpenCode.Sdk/Models/SessionMessageAgentSwitchedTime.cs @@ -0,0 +1,16 @@ +// Generated by OpenCode.Sdk.Tools from spec/openapi.json. +// Do not edit by hand — change tools/curation.json or the emitters, then regenerate. +using System.Text.Json.Serialization; + +namespace OpenCode.Sdk.Models; +/// +/// Represents a session message agent switched time value. +/// +public sealed record SessionMessageAgentSwitchedTime +{ + /// + /// Gets the created value. + /// + [JsonPropertyName("created")] + public required double Created { get; init; } +} diff --git a/src/OpenCode.Sdk/Models/SessionMessageAssistant.cs b/src/OpenCode.Sdk/Models/SessionMessageAssistant.cs new file mode 100644 index 00000000..fc294eb2 --- /dev/null +++ b/src/OpenCode.Sdk/Models/SessionMessageAssistant.cs @@ -0,0 +1,113 @@ +// Generated by OpenCode.Sdk.Tools from spec/openapi.json. +// Do not edit by hand — change tools/curation.json or the emitters, then regenerate. +using System.Collections.ObjectModel; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace OpenCode.Sdk.Models; +/// +/// Represents a session message assistant value. +/// +public sealed record SessionMessageAssistant : SessionMessage +{ + /// + /// Gets the id value. + /// + [JsonPropertyName("id")] + public required string ID { get; init; } + + /// + /// Gets the metadata value. + /// + [JsonPropertyName("metadata")] + public IReadOnlyDictionary Metadata + { + get; + init + { + // Source-generated deserialization passes null for an absent optional init-only collection. + field = value is null ? new ReadOnlyDictionary(new Dictionary(StringComparer.Ordinal)) : new ReadOnlyDictionary(value.ToDictionary(static pair => pair.Key, static pair => pair.Value, StringComparer.Ordinal)); + } + } = new ReadOnlyDictionary(new Dictionary(StringComparer.Ordinal)); + + /// + /// Gets the time value. + /// + [JsonPropertyName("time")] + public required SessionMessageAssistantTime Time { get; init; } + + /// + /// Gets the type value. + /// + [JsonPropertyName("type")] + public override string Type => "assistant"; + + /// + /// Gets the agent value. + /// + [JsonPropertyName("agent")] + public required string Agent { get; init; } + + /// + /// Gets the model value. + /// + [JsonPropertyName("model")] + public required ModelRef Model { get; init; } + + /// + /// Gets the content value. + /// + [JsonPropertyName("content")] + public required IReadOnlyList Content + { + get; + init + { +#if NET8_0_OR_GREATER + ArgumentNullException.ThrowIfNull(value); +#else + if (value is null) + { + throw new ArgumentNullException(nameof(value)); + } +#endif + + field = new List(value).AsReadOnly(); + } + } = []; + + /// + /// Gets the snapshot value. + /// + [JsonPropertyName("snapshot")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public SessionMessageAssistantSnapshot? Snapshot { get; init; } + + /// + /// Gets the finish value. + /// + [JsonPropertyName("finish")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Finish { get; init; } + + /// + /// Gets the cost value. + /// + [JsonPropertyName("cost")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public double? Cost { get; init; } + + /// + /// Gets the tokens value. + /// + [JsonPropertyName("tokens")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public SessionMessageAssistantTokens? Tokens { get; init; } + + /// + /// Gets the error value. + /// + [JsonPropertyName("error")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public SessionErrorUnknown? Error { get; init; } +} diff --git a/src/OpenCode.Sdk/Models/SessionMessageAssistantContent.cs b/src/OpenCode.Sdk/Models/SessionMessageAssistantContent.cs new file mode 100644 index 00000000..4cef28bd --- /dev/null +++ b/src/OpenCode.Sdk/Models/SessionMessageAssistantContent.cs @@ -0,0 +1,18 @@ +// Generated by OpenCode.Sdk.Tools from spec/openapi.json. +// Do not edit by hand — change tools/curation.json or the emitters, then regenerate. +using System.Text.Json.Serialization; +using OpenCode.Sdk.Internal.Serialization; + +namespace OpenCode.Sdk.Models; +/// +/// Represents a session message assistant content union. +/// +[JsonConverter(typeof(SessionMessageAssistantContentJsonConverter))] +public abstract record SessionMessageAssistantContent +{ + /// + /// Gets the 'type' union marker. + /// + [JsonPropertyName("type")] + public abstract string Type { get; } +} diff --git a/src/OpenCode.Sdk/Models/SessionMessageAssistantReasoning.cs b/src/OpenCode.Sdk/Models/SessionMessageAssistantReasoning.cs new file mode 100644 index 00000000..31ee4287 --- /dev/null +++ b/src/OpenCode.Sdk/Models/SessionMessageAssistantReasoning.cs @@ -0,0 +1,51 @@ +// Generated by OpenCode.Sdk.Tools from spec/openapi.json. +// Do not edit by hand — change tools/curation.json or the emitters, then regenerate. +using System.Collections.ObjectModel; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace OpenCode.Sdk.Models; +/// +/// Represents a session message assistant reasoning value. +/// +public sealed record SessionMessageAssistantReasoning : SessionMessageAssistantContent +{ + /// + /// Gets the type value. + /// + [JsonPropertyName("type")] + public override string Type => "reasoning"; + + /// + /// Gets the id value. + /// + [JsonPropertyName("id")] + public required string ID { get; init; } + + /// + /// Gets the text value. + /// + [JsonPropertyName("text")] + public required string Text { get; init; } + + /// + /// Gets the provider metadata value. + /// + [JsonPropertyName("providerMetadata")] + public IReadOnlyDictionary> ProviderMetadata + { + get; + init + { + // Source-generated deserialization passes null for an absent optional init-only collection. + field = value is null ? new ReadOnlyDictionary>(new Dictionary>(StringComparer.Ordinal)) : new ReadOnlyDictionary>(value.ToDictionary(static pair => pair.Key, static pair => (IReadOnlyDictionary)new ReadOnlyDictionary(pair.Value.ToDictionary(static pair => pair.Key, static pair => pair.Value, StringComparer.Ordinal)), StringComparer.Ordinal)); + } + } = new ReadOnlyDictionary>(new Dictionary>(StringComparer.Ordinal)); + + /// + /// Gets the time value. + /// + [JsonPropertyName("time")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public SessionMessageAssistantReasoningTime? Time { get; init; } +} diff --git a/src/OpenCode.Sdk/Models/SessionMessageAssistantReasoningTime.cs b/src/OpenCode.Sdk/Models/SessionMessageAssistantReasoningTime.cs new file mode 100644 index 00000000..c03d7bbf --- /dev/null +++ b/src/OpenCode.Sdk/Models/SessionMessageAssistantReasoningTime.cs @@ -0,0 +1,23 @@ +// Generated by OpenCode.Sdk.Tools from spec/openapi.json. +// Do not edit by hand — change tools/curation.json or the emitters, then regenerate. +using System.Text.Json.Serialization; + +namespace OpenCode.Sdk.Models; +/// +/// Represents a session message assistant reasoning time value. +/// +public sealed record SessionMessageAssistantReasoningTime +{ + /// + /// Gets the created value. + /// + [JsonPropertyName("created")] + public required double Created { get; init; } + + /// + /// Gets the completed value. + /// + [JsonPropertyName("completed")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public double? Completed { get; init; } +} diff --git a/src/OpenCode.Sdk/Models/SessionMessageAssistantSnapshot.cs b/src/OpenCode.Sdk/Models/SessionMessageAssistantSnapshot.cs new file mode 100644 index 00000000..13914338 --- /dev/null +++ b/src/OpenCode.Sdk/Models/SessionMessageAssistantSnapshot.cs @@ -0,0 +1,38 @@ +// Generated by OpenCode.Sdk.Tools from spec/openapi.json. +// Do not edit by hand — change tools/curation.json or the emitters, then regenerate. +using System.Text.Json.Serialization; + +namespace OpenCode.Sdk.Models; +/// +/// Represents a session message assistant snapshot value. +/// +public sealed record SessionMessageAssistantSnapshot +{ + /// + /// Gets the start value. + /// + [JsonPropertyName("start")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Start { get; init; } + + /// + /// Gets the end value. + /// + [JsonPropertyName("end")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? End { get; init; } + + /// + /// Gets the files value. + /// + [JsonPropertyName("files")] + public IReadOnlyList Files + { + get; + init + { + // Source-generated deserialization passes null for an absent optional init-only collection. + field = value is null ? Array.Empty() : new List(value).AsReadOnly(); + } + } = []; +} diff --git a/src/OpenCode.Sdk/Models/SessionMessageAssistantText.cs b/src/OpenCode.Sdk/Models/SessionMessageAssistantText.cs new file mode 100644 index 00000000..491ce784 --- /dev/null +++ b/src/OpenCode.Sdk/Models/SessionMessageAssistantText.cs @@ -0,0 +1,28 @@ +// Generated by OpenCode.Sdk.Tools from spec/openapi.json. +// Do not edit by hand — change tools/curation.json or the emitters, then regenerate. +using System.Text.Json.Serialization; + +namespace OpenCode.Sdk.Models; +/// +/// Represents a session message assistant text value. +/// +public sealed record SessionMessageAssistantText : SessionMessageAssistantContent +{ + /// + /// Gets the type value. + /// + [JsonPropertyName("type")] + public override string Type => "text"; + + /// + /// Gets the id value. + /// + [JsonPropertyName("id")] + public required string ID { get; init; } + + /// + /// Gets the text value. + /// + [JsonPropertyName("text")] + public required string Text { get; init; } +} diff --git a/src/OpenCode.Sdk/Models/SessionMessageAssistantTime.cs b/src/OpenCode.Sdk/Models/SessionMessageAssistantTime.cs new file mode 100644 index 00000000..4d52d615 --- /dev/null +++ b/src/OpenCode.Sdk/Models/SessionMessageAssistantTime.cs @@ -0,0 +1,23 @@ +// Generated by OpenCode.Sdk.Tools from spec/openapi.json. +// Do not edit by hand — change tools/curation.json or the emitters, then regenerate. +using System.Text.Json.Serialization; + +namespace OpenCode.Sdk.Models; +/// +/// Represents a session message assistant time value. +/// +public sealed record SessionMessageAssistantTime +{ + /// + /// Gets the created value. + /// + [JsonPropertyName("created")] + public required double Created { get; init; } + + /// + /// Gets the completed value. + /// + [JsonPropertyName("completed")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public double? Completed { get; init; } +} diff --git a/src/OpenCode.Sdk/Models/SessionMessageAssistantTokens.cs b/src/OpenCode.Sdk/Models/SessionMessageAssistantTokens.cs new file mode 100644 index 00000000..f324769e --- /dev/null +++ b/src/OpenCode.Sdk/Models/SessionMessageAssistantTokens.cs @@ -0,0 +1,34 @@ +// Generated by OpenCode.Sdk.Tools from spec/openapi.json. +// Do not edit by hand — change tools/curation.json or the emitters, then regenerate. +using System.Text.Json.Serialization; + +namespace OpenCode.Sdk.Models; +/// +/// Represents a session message assistant tokens value. +/// +public sealed record SessionMessageAssistantTokens +{ + /// + /// Gets the input value. + /// + [JsonPropertyName("input")] + public required double Input { get; init; } + + /// + /// Gets the output value. + /// + [JsonPropertyName("output")] + public required double Output { get; init; } + + /// + /// Gets the reasoning value. + /// + [JsonPropertyName("reasoning")] + public required double Reasoning { get; init; } + + /// + /// Gets the cache value. + /// + [JsonPropertyName("cache")] + public required SessionMessageAssistantTokensCache Cache { get; init; } +} diff --git a/src/OpenCode.Sdk/Models/SessionMessageAssistantTokensCache.cs b/src/OpenCode.Sdk/Models/SessionMessageAssistantTokensCache.cs new file mode 100644 index 00000000..078ed48a --- /dev/null +++ b/src/OpenCode.Sdk/Models/SessionMessageAssistantTokensCache.cs @@ -0,0 +1,22 @@ +// Generated by OpenCode.Sdk.Tools from spec/openapi.json. +// Do not edit by hand — change tools/curation.json or the emitters, then regenerate. +using System.Text.Json.Serialization; + +namespace OpenCode.Sdk.Models; +/// +/// Represents a session message assistant tokens cache value. +/// +public sealed record SessionMessageAssistantTokensCache +{ + /// + /// Gets the read value. + /// + [JsonPropertyName("read")] + public required double Read { get; init; } + + /// + /// Gets the write value. + /// + [JsonPropertyName("write")] + public required double Write { get; init; } +} diff --git a/src/OpenCode.Sdk/Models/SessionMessageAssistantTool.cs b/src/OpenCode.Sdk/Models/SessionMessageAssistantTool.cs new file mode 100644 index 00000000..7013ccb2 --- /dev/null +++ b/src/OpenCode.Sdk/Models/SessionMessageAssistantTool.cs @@ -0,0 +1,47 @@ +// Generated by OpenCode.Sdk.Tools from spec/openapi.json. +// Do not edit by hand — change tools/curation.json or the emitters, then regenerate. +using System.Text.Json.Serialization; + +namespace OpenCode.Sdk.Models; +/// +/// Represents a session message assistant tool value. +/// +public sealed record SessionMessageAssistantTool : SessionMessageAssistantContent +{ + /// + /// Gets the type value. + /// + [JsonPropertyName("type")] + public override string Type => "tool"; + + /// + /// Gets the id value. + /// + [JsonPropertyName("id")] + public required string ID { get; init; } + + /// + /// Gets the name value. + /// + [JsonPropertyName("name")] + public required string Name { get; init; } + + /// + /// Gets the provider value. + /// + [JsonPropertyName("provider")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public SessionMessageAssistantToolProvider? Provider { get; init; } + + /// + /// Gets the state value. + /// + [JsonPropertyName("state")] + public required SessionMessageToolState State { get; init; } + + /// + /// Gets the time value. + /// + [JsonPropertyName("time")] + public required SessionMessageAssistantToolTime Time { get; init; } +} diff --git a/src/OpenCode.Sdk/Models/SessionMessageAssistantToolProvider.cs b/src/OpenCode.Sdk/Models/SessionMessageAssistantToolProvider.cs new file mode 100644 index 00000000..44f44229 --- /dev/null +++ b/src/OpenCode.Sdk/Models/SessionMessageAssistantToolProvider.cs @@ -0,0 +1,46 @@ +// Generated by OpenCode.Sdk.Tools from spec/openapi.json. +// Do not edit by hand — change tools/curation.json or the emitters, then regenerate. +using System.Collections.ObjectModel; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace OpenCode.Sdk.Models; +/// +/// Represents a session message assistant tool provider value. +/// +public sealed record SessionMessageAssistantToolProvider +{ + /// + /// Gets the executed value. + /// + [JsonPropertyName("executed")] + public required bool Executed { get; init; } + + /// + /// Gets the metadata value. + /// + [JsonPropertyName("metadata")] + public IReadOnlyDictionary> Metadata + { + get; + init + { + // Source-generated deserialization passes null for an absent optional init-only collection. + field = value is null ? new ReadOnlyDictionary>(new Dictionary>(StringComparer.Ordinal)) : new ReadOnlyDictionary>(value.ToDictionary(static pair => pair.Key, static pair => (IReadOnlyDictionary)new ReadOnlyDictionary(pair.Value.ToDictionary(static pair => pair.Key, static pair => pair.Value, StringComparer.Ordinal)), StringComparer.Ordinal)); + } + } = new ReadOnlyDictionary>(new Dictionary>(StringComparer.Ordinal)); + + /// + /// Gets the result metadata value. + /// + [JsonPropertyName("resultMetadata")] + public IReadOnlyDictionary> ResultMetadata + { + get; + init + { + // Source-generated deserialization passes null for an absent optional init-only collection. + field = value is null ? new ReadOnlyDictionary>(new Dictionary>(StringComparer.Ordinal)) : new ReadOnlyDictionary>(value.ToDictionary(static pair => pair.Key, static pair => (IReadOnlyDictionary)new ReadOnlyDictionary(pair.Value.ToDictionary(static pair => pair.Key, static pair => pair.Value, StringComparer.Ordinal)), StringComparer.Ordinal)); + } + } = new ReadOnlyDictionary>(new Dictionary>(StringComparer.Ordinal)); +} diff --git a/src/OpenCode.Sdk/Models/SessionMessageAssistantToolTime.cs b/src/OpenCode.Sdk/Models/SessionMessageAssistantToolTime.cs new file mode 100644 index 00000000..12a3894e --- /dev/null +++ b/src/OpenCode.Sdk/Models/SessionMessageAssistantToolTime.cs @@ -0,0 +1,37 @@ +// Generated by OpenCode.Sdk.Tools from spec/openapi.json. +// Do not edit by hand — change tools/curation.json or the emitters, then regenerate. +using System.Text.Json.Serialization; + +namespace OpenCode.Sdk.Models; +/// +/// Represents a session message assistant tool time value. +/// +public sealed record SessionMessageAssistantToolTime +{ + /// + /// Gets the created value. + /// + [JsonPropertyName("created")] + public required double Created { get; init; } + + /// + /// Gets the ran value. + /// + [JsonPropertyName("ran")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public double? Ran { get; init; } + + /// + /// Gets the completed value. + /// + [JsonPropertyName("completed")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public double? Completed { get; init; } + + /// + /// Gets the pruned value. + /// + [JsonPropertyName("pruned")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public double? Pruned { get; init; } +} diff --git a/src/OpenCode.Sdk/Models/SessionMessageCompaction.cs b/src/OpenCode.Sdk/Models/SessionMessageCompaction.cs new file mode 100644 index 00000000..de7ba3c0 --- /dev/null +++ b/src/OpenCode.Sdk/Models/SessionMessageCompaction.cs @@ -0,0 +1,62 @@ +// Generated by OpenCode.Sdk.Tools from spec/openapi.json. +// Do not edit by hand — change tools/curation.json or the emitters, then regenerate. +using System.Collections.ObjectModel; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace OpenCode.Sdk.Models; +/// +/// Represents a session message compaction value. +/// +public sealed record SessionMessageCompaction : SessionMessage +{ + /// + /// Gets the type value. + /// + [JsonPropertyName("type")] + public override string Type => "compaction"; + + /// + /// Gets the reason value. + /// + [JsonPropertyName("reason")] + public required SessionMessageCompactionReason Reason { get; init; } + + /// + /// Gets the summary value. + /// + [JsonPropertyName("summary")] + public required string Summary { get; init; } + + /// + /// Gets the recent value. + /// + [JsonPropertyName("recent")] + public required string Recent { get; init; } + + /// + /// Gets the id value. + /// + [JsonPropertyName("id")] + public required string ID { get; init; } + + /// + /// Gets the metadata value. + /// + [JsonPropertyName("metadata")] + public IReadOnlyDictionary Metadata + { + get; + init + { + // Source-generated deserialization passes null for an absent optional init-only collection. + field = value is null ? new ReadOnlyDictionary(new Dictionary(StringComparer.Ordinal)) : new ReadOnlyDictionary(value.ToDictionary(static pair => pair.Key, static pair => pair.Value, StringComparer.Ordinal)); + } + } = new ReadOnlyDictionary(new Dictionary(StringComparer.Ordinal)); + + /// + /// Gets the time value. + /// + [JsonPropertyName("time")] + public required SessionMessageCompactionTime Time { get; init; } +} diff --git a/src/OpenCode.Sdk/Models/SessionMessageCompactionReason.cs b/src/OpenCode.Sdk/Models/SessionMessageCompactionReason.cs new file mode 100644 index 00000000..e5b0238e --- /dev/null +++ b/src/OpenCode.Sdk/Models/SessionMessageCompactionReason.cs @@ -0,0 +1,22 @@ +// Generated by OpenCode.Sdk.Tools from spec/openapi.json. +// Do not edit by hand — change tools/curation.json or the emitters, then regenerate. +using System.Text.Json.Serialization; + +namespace OpenCode.Sdk.Models; +/// +/// Defines the supported session message compaction reason values. +/// +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum SessionMessageCompactionReason +{ + /// + /// Represents the 'auto' wire value. + /// + [JsonStringEnumMemberName("auto")] + Auto, + /// + /// Represents the 'manual' wire value. + /// + [JsonStringEnumMemberName("manual")] + Manual +} diff --git a/src/OpenCode.Sdk/Models/SessionMessageCompactionTime.cs b/src/OpenCode.Sdk/Models/SessionMessageCompactionTime.cs new file mode 100644 index 00000000..e4ee8680 --- /dev/null +++ b/src/OpenCode.Sdk/Models/SessionMessageCompactionTime.cs @@ -0,0 +1,16 @@ +// Generated by OpenCode.Sdk.Tools from spec/openapi.json. +// Do not edit by hand — change tools/curation.json or the emitters, then regenerate. +using System.Text.Json.Serialization; + +namespace OpenCode.Sdk.Models; +/// +/// Represents a session message compaction time value. +/// +public sealed record SessionMessageCompactionTime +{ + /// + /// Gets the created value. + /// + [JsonPropertyName("created")] + public required double Created { get; init; } +} diff --git a/src/OpenCode.Sdk/Models/SessionMessageModelSwitched.cs b/src/OpenCode.Sdk/Models/SessionMessageModelSwitched.cs new file mode 100644 index 00000000..b40383fd --- /dev/null +++ b/src/OpenCode.Sdk/Models/SessionMessageModelSwitched.cs @@ -0,0 +1,50 @@ +// Generated by OpenCode.Sdk.Tools from spec/openapi.json. +// Do not edit by hand — change tools/curation.json or the emitters, then regenerate. +using System.Collections.ObjectModel; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace OpenCode.Sdk.Models; +/// +/// Represents a session message model switched value. +/// +public sealed record SessionMessageModelSwitched : SessionMessage +{ + /// + /// Gets the id value. + /// + [JsonPropertyName("id")] + public required string ID { get; init; } + + /// + /// Gets the metadata value. + /// + [JsonPropertyName("metadata")] + public IReadOnlyDictionary Metadata + { + get; + init + { + // Source-generated deserialization passes null for an absent optional init-only collection. + field = value is null ? new ReadOnlyDictionary(new Dictionary(StringComparer.Ordinal)) : new ReadOnlyDictionary(value.ToDictionary(static pair => pair.Key, static pair => pair.Value, StringComparer.Ordinal)); + } + } = new ReadOnlyDictionary(new Dictionary(StringComparer.Ordinal)); + + /// + /// Gets the time value. + /// + [JsonPropertyName("time")] + public required SessionMessageModelSwitchedTime Time { get; init; } + + /// + /// Gets the type value. + /// + [JsonPropertyName("type")] + public override string Type => "model-switched"; + + /// + /// Gets the model value. + /// + [JsonPropertyName("model")] + public required ModelRef Model { get; init; } +} diff --git a/src/OpenCode.Sdk/Models/SessionMessageModelSwitchedTime.cs b/src/OpenCode.Sdk/Models/SessionMessageModelSwitchedTime.cs new file mode 100644 index 00000000..d01ce534 --- /dev/null +++ b/src/OpenCode.Sdk/Models/SessionMessageModelSwitchedTime.cs @@ -0,0 +1,16 @@ +// Generated by OpenCode.Sdk.Tools from spec/openapi.json. +// Do not edit by hand — change tools/curation.json or the emitters, then regenerate. +using System.Text.Json.Serialization; + +namespace OpenCode.Sdk.Models; +/// +/// Represents a session message model switched time value. +/// +public sealed record SessionMessageModelSwitchedTime +{ + /// + /// Gets the created value. + /// + [JsonPropertyName("created")] + public required double Created { get; init; } +} diff --git a/src/OpenCode.Sdk/Models/SessionMessageShell.cs b/src/OpenCode.Sdk/Models/SessionMessageShell.cs new file mode 100644 index 00000000..d382d087 --- /dev/null +++ b/src/OpenCode.Sdk/Models/SessionMessageShell.cs @@ -0,0 +1,62 @@ +// Generated by OpenCode.Sdk.Tools from spec/openapi.json. +// Do not edit by hand — change tools/curation.json or the emitters, then regenerate. +using System.Collections.ObjectModel; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace OpenCode.Sdk.Models; +/// +/// Represents a session message shell value. +/// +public sealed record SessionMessageShell : SessionMessage +{ + /// + /// Gets the id value. + /// + [JsonPropertyName("id")] + public required string ID { get; init; } + + /// + /// Gets the metadata value. + /// + [JsonPropertyName("metadata")] + public IReadOnlyDictionary Metadata + { + get; + init + { + // Source-generated deserialization passes null for an absent optional init-only collection. + field = value is null ? new ReadOnlyDictionary(new Dictionary(StringComparer.Ordinal)) : new ReadOnlyDictionary(value.ToDictionary(static pair => pair.Key, static pair => pair.Value, StringComparer.Ordinal)); + } + } = new ReadOnlyDictionary(new Dictionary(StringComparer.Ordinal)); + + /// + /// Gets the time value. + /// + [JsonPropertyName("time")] + public required SessionMessageShellTime Time { get; init; } + + /// + /// Gets the type value. + /// + [JsonPropertyName("type")] + public override string Type => "shell"; + + /// + /// Gets the call id value. + /// + [JsonPropertyName("callID")] + public required string CallID { get; init; } + + /// + /// Gets the command value. + /// + [JsonPropertyName("command")] + public required string Command { get; init; } + + /// + /// Gets the output value. + /// + [JsonPropertyName("output")] + public required string Output { get; init; } +} diff --git a/src/OpenCode.Sdk/Models/SessionMessageShellTime.cs b/src/OpenCode.Sdk/Models/SessionMessageShellTime.cs new file mode 100644 index 00000000..ffd023fd --- /dev/null +++ b/src/OpenCode.Sdk/Models/SessionMessageShellTime.cs @@ -0,0 +1,23 @@ +// Generated by OpenCode.Sdk.Tools from spec/openapi.json. +// Do not edit by hand — change tools/curation.json or the emitters, then regenerate. +using System.Text.Json.Serialization; + +namespace OpenCode.Sdk.Models; +/// +/// Represents a session message shell time value. +/// +public sealed record SessionMessageShellTime +{ + /// + /// Gets the created value. + /// + [JsonPropertyName("created")] + public required double Created { get; init; } + + /// + /// Gets the completed value. + /// + [JsonPropertyName("completed")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public double? Completed { get; init; } +} diff --git a/src/OpenCode.Sdk/Models/SessionMessageSynthetic.cs b/src/OpenCode.Sdk/Models/SessionMessageSynthetic.cs new file mode 100644 index 00000000..b617eb92 --- /dev/null +++ b/src/OpenCode.Sdk/Models/SessionMessageSynthetic.cs @@ -0,0 +1,56 @@ +// Generated by OpenCode.Sdk.Tools from spec/openapi.json. +// Do not edit by hand — change tools/curation.json or the emitters, then regenerate. +using System.Collections.ObjectModel; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace OpenCode.Sdk.Models; +/// +/// Represents a session message synthetic value. +/// +public sealed record SessionMessageSynthetic : SessionMessage +{ + /// + /// Gets the id value. + /// + [JsonPropertyName("id")] + public required string ID { get; init; } + + /// + /// Gets the metadata value. + /// + [JsonPropertyName("metadata")] + public IReadOnlyDictionary Metadata + { + get; + init + { + // Source-generated deserialization passes null for an absent optional init-only collection. + field = value is null ? new ReadOnlyDictionary(new Dictionary(StringComparer.Ordinal)) : new ReadOnlyDictionary(value.ToDictionary(static pair => pair.Key, static pair => pair.Value, StringComparer.Ordinal)); + } + } = new ReadOnlyDictionary(new Dictionary(StringComparer.Ordinal)); + + /// + /// Gets the time value. + /// + [JsonPropertyName("time")] + public required SessionMessageSyntheticTime Time { get; init; } + + /// + /// Gets the session id value. + /// + [JsonPropertyName("sessionID")] + public required string SessionID { get; init; } + + /// + /// Gets the text value. + /// + [JsonPropertyName("text")] + public required string Text { get; init; } + + /// + /// Gets the type value. + /// + [JsonPropertyName("type")] + public override string Type => "synthetic"; +} diff --git a/src/OpenCode.Sdk/Models/SessionMessageSyntheticTime.cs b/src/OpenCode.Sdk/Models/SessionMessageSyntheticTime.cs new file mode 100644 index 00000000..76740e03 --- /dev/null +++ b/src/OpenCode.Sdk/Models/SessionMessageSyntheticTime.cs @@ -0,0 +1,16 @@ +// Generated by OpenCode.Sdk.Tools from spec/openapi.json. +// Do not edit by hand — change tools/curation.json or the emitters, then regenerate. +using System.Text.Json.Serialization; + +namespace OpenCode.Sdk.Models; +/// +/// Represents a session message synthetic time value. +/// +public sealed record SessionMessageSyntheticTime +{ + /// + /// Gets the created value. + /// + [JsonPropertyName("created")] + public required double Created { get; init; } +} diff --git a/src/OpenCode.Sdk/Models/SessionMessageSystem.cs b/src/OpenCode.Sdk/Models/SessionMessageSystem.cs new file mode 100644 index 00000000..b15265d9 --- /dev/null +++ b/src/OpenCode.Sdk/Models/SessionMessageSystem.cs @@ -0,0 +1,50 @@ +// Generated by OpenCode.Sdk.Tools from spec/openapi.json. +// Do not edit by hand — change tools/curation.json or the emitters, then regenerate. +using System.Collections.ObjectModel; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace OpenCode.Sdk.Models; +/// +/// Represents a session message system value. +/// +public sealed record SessionMessageSystem : SessionMessage +{ + /// + /// Gets the id value. + /// + [JsonPropertyName("id")] + public required string ID { get; init; } + + /// + /// Gets the metadata value. + /// + [JsonPropertyName("metadata")] + public IReadOnlyDictionary Metadata + { + get; + init + { + // Source-generated deserialization passes null for an absent optional init-only collection. + field = value is null ? new ReadOnlyDictionary(new Dictionary(StringComparer.Ordinal)) : new ReadOnlyDictionary(value.ToDictionary(static pair => pair.Key, static pair => pair.Value, StringComparer.Ordinal)); + } + } = new ReadOnlyDictionary(new Dictionary(StringComparer.Ordinal)); + + /// + /// Gets the time value. + /// + [JsonPropertyName("time")] + public required SessionMessageSystemTime Time { get; init; } + + /// + /// Gets the type value. + /// + [JsonPropertyName("type")] + public override string Type => "system"; + + /// + /// Gets the text value. + /// + [JsonPropertyName("text")] + public required string Text { get; init; } +} diff --git a/src/OpenCode.Sdk/Models/SessionMessageSystemTime.cs b/src/OpenCode.Sdk/Models/SessionMessageSystemTime.cs new file mode 100644 index 00000000..c74c5cf0 --- /dev/null +++ b/src/OpenCode.Sdk/Models/SessionMessageSystemTime.cs @@ -0,0 +1,16 @@ +// Generated by OpenCode.Sdk.Tools from spec/openapi.json. +// Do not edit by hand — change tools/curation.json or the emitters, then regenerate. +using System.Text.Json.Serialization; + +namespace OpenCode.Sdk.Models; +/// +/// Represents a session message system time value. +/// +public sealed record SessionMessageSystemTime +{ + /// + /// Gets the created value. + /// + [JsonPropertyName("created")] + public required double Created { get; init; } +} diff --git a/src/OpenCode.Sdk/Models/SessionMessageToolState.cs b/src/OpenCode.Sdk/Models/SessionMessageToolState.cs new file mode 100644 index 00000000..3f69eed0 --- /dev/null +++ b/src/OpenCode.Sdk/Models/SessionMessageToolState.cs @@ -0,0 +1,18 @@ +// Generated by OpenCode.Sdk.Tools from spec/openapi.json. +// Do not edit by hand — change tools/curation.json or the emitters, then regenerate. +using System.Text.Json.Serialization; +using OpenCode.Sdk.Internal.Serialization; + +namespace OpenCode.Sdk.Models; +/// +/// Represents a session message tool state union. +/// +[JsonConverter(typeof(SessionMessageToolStateJsonConverter))] +public abstract record SessionMessageToolState +{ + /// + /// Gets the 'status' union marker. + /// + [JsonPropertyName("status")] + public abstract string Status { get; } +} diff --git a/src/OpenCode.Sdk/Models/SessionMessageToolStateCompleted.cs b/src/OpenCode.Sdk/Models/SessionMessageToolStateCompleted.cs new file mode 100644 index 00000000..070a999f --- /dev/null +++ b/src/OpenCode.Sdk/Models/SessionMessageToolStateCompleted.cs @@ -0,0 +1,119 @@ +// Generated by OpenCode.Sdk.Tools from spec/openapi.json. +// Do not edit by hand — change tools/curation.json or the emitters, then regenerate. +using System.Collections.ObjectModel; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace OpenCode.Sdk.Models; +/// +/// Represents a session message tool state completed value. +/// +public sealed record SessionMessageToolStateCompleted : SessionMessageToolState +{ + /// + /// Gets the status value. + /// + [JsonPropertyName("status")] + public override string Status => "completed"; + + /// + /// Gets the input value. + /// + [JsonPropertyName("input")] + public required IReadOnlyDictionary Input + { + get; + init + { +#if NET8_0_OR_GREATER + ArgumentNullException.ThrowIfNull(value); +#else + if (value is null) + { + throw new ArgumentNullException(nameof(value)); + } +#endif + + field = new ReadOnlyDictionary(value.ToDictionary(static pair => pair.Key, static pair => pair.Value, StringComparer.Ordinal)); + } + } = new ReadOnlyDictionary(new Dictionary(StringComparer.Ordinal)); + + /// + /// Gets the attachments value. + /// + [JsonPropertyName("attachments")] + public IReadOnlyList Attachments + { + get; + init + { + // Source-generated deserialization passes null for an absent optional init-only collection. + field = value is null ? Array.Empty() : new List(value).AsReadOnly(); + } + } = []; + + /// + /// Gets the content value. + /// + [JsonPropertyName("content")] + public required IReadOnlyList Content + { + get; + init + { +#if NET8_0_OR_GREATER + ArgumentNullException.ThrowIfNull(value); +#else + if (value is null) + { + throw new ArgumentNullException(nameof(value)); + } +#endif + + field = new List(value).AsReadOnly(); + } + } = []; + + /// + /// Gets the output paths value. + /// + [JsonPropertyName("outputPaths")] + public IReadOnlyList OutputPaths + { + get; + init + { + // Source-generated deserialization passes null for an absent optional init-only collection. + field = value is null ? Array.Empty() : new List(value).AsReadOnly(); + } + } = []; + + /// + /// Gets the structured value. + /// + [JsonPropertyName("structured")] + public required IReadOnlyDictionary Structured + { + get; + init + { +#if NET8_0_OR_GREATER + ArgumentNullException.ThrowIfNull(value); +#else + if (value is null) + { + throw new ArgumentNullException(nameof(value)); + } +#endif + + field = new ReadOnlyDictionary(value.ToDictionary(static pair => pair.Key, static pair => pair.Value, StringComparer.Ordinal)); + } + } = new ReadOnlyDictionary(new Dictionary(StringComparer.Ordinal)); + + /// + /// Gets the result value. + /// + [JsonPropertyName("result")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public JsonElement? Result { get; init; } +} diff --git a/src/OpenCode.Sdk/Models/SessionMessageToolStateError.cs b/src/OpenCode.Sdk/Models/SessionMessageToolStateError.cs new file mode 100644 index 00000000..4e14a921 --- /dev/null +++ b/src/OpenCode.Sdk/Models/SessionMessageToolStateError.cs @@ -0,0 +1,97 @@ +// Generated by OpenCode.Sdk.Tools from spec/openapi.json. +// Do not edit by hand — change tools/curation.json or the emitters, then regenerate. +using System.Collections.ObjectModel; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace OpenCode.Sdk.Models; +/// +/// Represents a session message tool state error value. +/// +public sealed record SessionMessageToolStateError : SessionMessageToolState +{ + /// + /// Gets the status value. + /// + [JsonPropertyName("status")] + public override string Status => "error"; + + /// + /// Gets the input value. + /// + [JsonPropertyName("input")] + public required IReadOnlyDictionary Input + { + get; + init + { +#if NET8_0_OR_GREATER + ArgumentNullException.ThrowIfNull(value); +#else + if (value is null) + { + throw new ArgumentNullException(nameof(value)); + } +#endif + + field = new ReadOnlyDictionary(value.ToDictionary(static pair => pair.Key, static pair => pair.Value, StringComparer.Ordinal)); + } + } = new ReadOnlyDictionary(new Dictionary(StringComparer.Ordinal)); + + /// + /// Gets the content value. + /// + [JsonPropertyName("content")] + public required IReadOnlyList Content + { + get; + init + { +#if NET8_0_OR_GREATER + ArgumentNullException.ThrowIfNull(value); +#else + if (value is null) + { + throw new ArgumentNullException(nameof(value)); + } +#endif + + field = new List(value).AsReadOnly(); + } + } = []; + + /// + /// Gets the structured value. + /// + [JsonPropertyName("structured")] + public required IReadOnlyDictionary Structured + { + get; + init + { +#if NET8_0_OR_GREATER + ArgumentNullException.ThrowIfNull(value); +#else + if (value is null) + { + throw new ArgumentNullException(nameof(value)); + } +#endif + + field = new ReadOnlyDictionary(value.ToDictionary(static pair => pair.Key, static pair => pair.Value, StringComparer.Ordinal)); + } + } = new ReadOnlyDictionary(new Dictionary(StringComparer.Ordinal)); + + /// + /// Gets the error value. + /// + [JsonPropertyName("error")] + public required SessionErrorUnknown Error { get; init; } + + /// + /// Gets the result value. + /// + [JsonPropertyName("result")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public JsonElement? Result { get; init; } +} diff --git a/src/OpenCode.Sdk/Models/SessionMessageToolStatePending.cs b/src/OpenCode.Sdk/Models/SessionMessageToolStatePending.cs new file mode 100644 index 00000000..6c89ec19 --- /dev/null +++ b/src/OpenCode.Sdk/Models/SessionMessageToolStatePending.cs @@ -0,0 +1,22 @@ +// Generated by OpenCode.Sdk.Tools from spec/openapi.json. +// Do not edit by hand — change tools/curation.json or the emitters, then regenerate. +using System.Text.Json.Serialization; + +namespace OpenCode.Sdk.Models; +/// +/// Represents a session message tool state pending value. +/// +public sealed record SessionMessageToolStatePending : SessionMessageToolState +{ + /// + /// Gets the status value. + /// + [JsonPropertyName("status")] + public override string Status => "pending"; + + /// + /// Gets the input value. + /// + [JsonPropertyName("input")] + public required string Input { get; init; } +} diff --git a/src/OpenCode.Sdk/Models/SessionMessageToolStateRunning.cs b/src/OpenCode.Sdk/Models/SessionMessageToolStateRunning.cs new file mode 100644 index 00000000..2a613365 --- /dev/null +++ b/src/OpenCode.Sdk/Models/SessionMessageToolStateRunning.cs @@ -0,0 +1,84 @@ +// Generated by OpenCode.Sdk.Tools from spec/openapi.json. +// Do not edit by hand — change tools/curation.json or the emitters, then regenerate. +using System.Collections.ObjectModel; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace OpenCode.Sdk.Models; +/// +/// Represents a session message tool state running value. +/// +public sealed record SessionMessageToolStateRunning : SessionMessageToolState +{ + /// + /// Gets the status value. + /// + [JsonPropertyName("status")] + public override string Status => "running"; + + /// + /// Gets the input value. + /// + [JsonPropertyName("input")] + public required IReadOnlyDictionary Input + { + get; + init + { +#if NET8_0_OR_GREATER + ArgumentNullException.ThrowIfNull(value); +#else + if (value is null) + { + throw new ArgumentNullException(nameof(value)); + } +#endif + + field = new ReadOnlyDictionary(value.ToDictionary(static pair => pair.Key, static pair => pair.Value, StringComparer.Ordinal)); + } + } = new ReadOnlyDictionary(new Dictionary(StringComparer.Ordinal)); + + /// + /// Gets the structured value. + /// + [JsonPropertyName("structured")] + public required IReadOnlyDictionary Structured + { + get; + init + { +#if NET8_0_OR_GREATER + ArgumentNullException.ThrowIfNull(value); +#else + if (value is null) + { + throw new ArgumentNullException(nameof(value)); + } +#endif + + field = new ReadOnlyDictionary(value.ToDictionary(static pair => pair.Key, static pair => pair.Value, StringComparer.Ordinal)); + } + } = new ReadOnlyDictionary(new Dictionary(StringComparer.Ordinal)); + + /// + /// Gets the content value. + /// + [JsonPropertyName("content")] + public required IReadOnlyList Content + { + get; + init + { +#if NET8_0_OR_GREATER + ArgumentNullException.ThrowIfNull(value); +#else + if (value is null) + { + throw new ArgumentNullException(nameof(value)); + } +#endif + + field = new List(value).AsReadOnly(); + } + } = []; +} diff --git a/src/OpenCode.Sdk/Models/SessionMessageUser.cs b/src/OpenCode.Sdk/Models/SessionMessageUser.cs new file mode 100644 index 00000000..4987e920 --- /dev/null +++ b/src/OpenCode.Sdk/Models/SessionMessageUser.cs @@ -0,0 +1,78 @@ +// Generated by OpenCode.Sdk.Tools from spec/openapi.json. +// Do not edit by hand — change tools/curation.json or the emitters, then regenerate. +using System.Collections.ObjectModel; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace OpenCode.Sdk.Models; +/// +/// Represents a session message user value. +/// +public sealed record SessionMessageUser : SessionMessage +{ + /// + /// Gets the id value. + /// + [JsonPropertyName("id")] + public required string ID { get; init; } + + /// + /// Gets the metadata value. + /// + [JsonPropertyName("metadata")] + public IReadOnlyDictionary Metadata + { + get; + init + { + // Source-generated deserialization passes null for an absent optional init-only collection. + field = value is null ? new ReadOnlyDictionary(new Dictionary(StringComparer.Ordinal)) : new ReadOnlyDictionary(value.ToDictionary(static pair => pair.Key, static pair => pair.Value, StringComparer.Ordinal)); + } + } = new ReadOnlyDictionary(new Dictionary(StringComparer.Ordinal)); + + /// + /// Gets the time value. + /// + [JsonPropertyName("time")] + public required SessionMessageUserTime Time { get; init; } + + /// + /// Gets the text value. + /// + [JsonPropertyName("text")] + public required string Text { get; init; } + + /// + /// Gets the files value. + /// + [JsonPropertyName("files")] + public IReadOnlyList Files + { + get; + init + { + // Source-generated deserialization passes null for an absent optional init-only collection. + field = value is null ? Array.Empty() : new List(value).AsReadOnly(); + } + } = []; + + /// + /// Gets the agents value. + /// + [JsonPropertyName("agents")] + public IReadOnlyList Agents + { + get; + init + { + // Source-generated deserialization passes null for an absent optional init-only collection. + field = value is null ? Array.Empty() : new List(value).AsReadOnly(); + } + } = []; + + /// + /// Gets the type value. + /// + [JsonPropertyName("type")] + public override string Type => "user"; +} diff --git a/src/OpenCode.Sdk/Models/SessionMessageUserTime.cs b/src/OpenCode.Sdk/Models/SessionMessageUserTime.cs new file mode 100644 index 00000000..8c84314e --- /dev/null +++ b/src/OpenCode.Sdk/Models/SessionMessageUserTime.cs @@ -0,0 +1,16 @@ +// Generated by OpenCode.Sdk.Tools from spec/openapi.json. +// Do not edit by hand — change tools/curation.json or the emitters, then regenerate. +using System.Text.Json.Serialization; + +namespace OpenCode.Sdk.Models; +/// +/// Represents a session message user time value. +/// +public sealed record SessionMessageUserTime +{ + /// + /// Gets the created value. + /// + [JsonPropertyName("created")] + public required double Created { get; init; } +} diff --git a/src/OpenCode.Sdk/Models/SessionNotFoundError.cs b/src/OpenCode.Sdk/Models/SessionNotFoundError.cs new file mode 100644 index 00000000..092b396b --- /dev/null +++ b/src/OpenCode.Sdk/Models/SessionNotFoundError.cs @@ -0,0 +1,28 @@ +// Generated by OpenCode.Sdk.Tools from spec/openapi.json. +// Do not edit by hand — change tools/curation.json or the emitters, then regenerate. +using System.Text.Json.Serialization; + +namespace OpenCode.Sdk.Models; +/// +/// Represents a session not found error value. +/// +public sealed record SessionNotFoundError : OpenCodeError +{ + /// + /// Gets the tag value. + /// + [JsonPropertyName("_tag")] + public override string Tag => "SessionNotFoundError"; + + /// + /// Gets the session id value. + /// + [JsonPropertyName("sessionID")] + public required string SessionID { get; init; } + + /// + /// Gets the message value. + /// + [JsonPropertyName("message")] + public required string Message { get; init; } +} diff --git a/src/OpenCode.Sdk/Models/ToolFileContent.cs b/src/OpenCode.Sdk/Models/ToolFileContent.cs new file mode 100644 index 00000000..698ab967 --- /dev/null +++ b/src/OpenCode.Sdk/Models/ToolFileContent.cs @@ -0,0 +1,35 @@ +// Generated by OpenCode.Sdk.Tools from spec/openapi.json. +// Do not edit by hand — change tools/curation.json or the emitters, then regenerate. +using System.Text.Json.Serialization; + +namespace OpenCode.Sdk.Models; +/// +/// Represents a tool file content value. +/// +public sealed record ToolFileContent : LlmToolContent +{ + /// + /// Gets the type value. + /// + [JsonPropertyName("type")] + public override string Type => "file"; + + /// + /// Gets the uri value. + /// + [JsonPropertyName("uri")] + public required Uri Uri { get; init; } + + /// + /// Gets the mime value. + /// + [JsonPropertyName("mime")] + public required string Mime { get; init; } + + /// + /// Gets the name value. + /// + [JsonPropertyName("name")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Name { get; init; } +} diff --git a/src/OpenCode.Sdk/Models/ToolTextContent.cs b/src/OpenCode.Sdk/Models/ToolTextContent.cs new file mode 100644 index 00000000..0e285c2a --- /dev/null +++ b/src/OpenCode.Sdk/Models/ToolTextContent.cs @@ -0,0 +1,22 @@ +// Generated by OpenCode.Sdk.Tools from spec/openapi.json. +// Do not edit by hand — change tools/curation.json or the emitters, then regenerate. +using System.Text.Json.Serialization; + +namespace OpenCode.Sdk.Models; +/// +/// Represents a tool text content value. +/// +public sealed record ToolTextContent : LlmToolContent +{ + /// + /// Gets the type value. + /// + [JsonPropertyName("type")] + public override string Type => "text"; + + /// + /// Gets the text value. + /// + [JsonPropertyName("text")] + public required string Text { get; init; } +} diff --git a/src/OpenCode.Sdk/Models/UnauthorizedError.cs b/src/OpenCode.Sdk/Models/UnauthorizedError.cs new file mode 100644 index 00000000..6cfc9b1a --- /dev/null +++ b/src/OpenCode.Sdk/Models/UnauthorizedError.cs @@ -0,0 +1,22 @@ +// Generated by OpenCode.Sdk.Tools from spec/openapi.json. +// Do not edit by hand — change tools/curation.json or the emitters, then regenerate. +using System.Text.Json.Serialization; + +namespace OpenCode.Sdk.Models; +/// +/// Represents a unauthorized error value. +/// +public sealed record UnauthorizedError : OpenCodeError +{ + /// + /// Gets the tag value. + /// + [JsonPropertyName("_tag")] + public override string Tag => "UnauthorizedError"; + + /// + /// Gets the message value. + /// + [JsonPropertyName("message")] + public required string Message { get; init; } +} diff --git a/src/OpenCode.Sdk/Models/UnknownLlmToolContent.cs b/src/OpenCode.Sdk/Models/UnknownLlmToolContent.cs new file mode 100644 index 00000000..eb738f5a --- /dev/null +++ b/src/OpenCode.Sdk/Models/UnknownLlmToolContent.cs @@ -0,0 +1,40 @@ +// Generated by OpenCode.Sdk.Tools from spec/openapi.json. +// Do not edit by hand — change tools/curation.json or the emitters, then regenerate. +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace OpenCode.Sdk.Models; +/// +/// Preserves an unknown llm tool content payload. +/// +public sealed record UnknownLlmToolContent : LlmToolContent +{ + private readonly string _marker; + /// + /// Initializes an unknown union value from its marker and raw payload. + /// + [JsonConstructor] + public UnknownLlmToolContent(string type, JsonElement payload) + { +#if NET8_0_OR_GREATER + ArgumentException.ThrowIfNullOrEmpty(type); +#else + if (string.IsNullOrEmpty(type)) + { + throw new ArgumentException("Value cannot be null or empty.", nameof(type)); + } +#endif + + _marker = type; + Payload = payload.Clone(); + } + + /// + /// Gets the unrecognized 'type' marker. + /// + public override string Type => _marker; + /// + /// Gets the preserved raw JSON payload. + /// + public JsonElement Payload { get; } +} diff --git a/src/OpenCode.Sdk/Models/UnknownOpenCodeError.cs b/src/OpenCode.Sdk/Models/UnknownOpenCodeError.cs new file mode 100644 index 00000000..bd8c48a5 --- /dev/null +++ b/src/OpenCode.Sdk/Models/UnknownOpenCodeError.cs @@ -0,0 +1,40 @@ +// Generated by OpenCode.Sdk.Tools from spec/openapi.json. +// Do not edit by hand — change tools/curation.json or the emitters, then regenerate. +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace OpenCode.Sdk.Models; +/// +/// Preserves an unknown open code error payload. +/// +public sealed record UnknownOpenCodeError : OpenCodeError +{ + private readonly string _marker; + /// + /// Initializes an unknown union value from its marker and raw payload. + /// + [JsonConstructor] + public UnknownOpenCodeError(string tag, JsonElement payload) + { +#if NET8_0_OR_GREATER + ArgumentException.ThrowIfNullOrEmpty(tag); +#else + if (string.IsNullOrEmpty(tag)) + { + throw new ArgumentException("Value cannot be null or empty.", nameof(tag)); + } +#endif + + _marker = tag; + Payload = payload.Clone(); + } + + /// + /// Gets the unrecognized '_tag' marker. + /// + public override string Tag => _marker; + /// + /// Gets the preserved raw JSON payload. + /// + public JsonElement Payload { get; } +} diff --git a/src/OpenCode.Sdk/Models/UnknownSessionMessage.cs b/src/OpenCode.Sdk/Models/UnknownSessionMessage.cs new file mode 100644 index 00000000..faf0d17c --- /dev/null +++ b/src/OpenCode.Sdk/Models/UnknownSessionMessage.cs @@ -0,0 +1,40 @@ +// Generated by OpenCode.Sdk.Tools from spec/openapi.json. +// Do not edit by hand — change tools/curation.json or the emitters, then regenerate. +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace OpenCode.Sdk.Models; +/// +/// Preserves an unknown session message payload. +/// +public sealed record UnknownSessionMessage : SessionMessage +{ + private readonly string _marker; + /// + /// Initializes an unknown union value from its marker and raw payload. + /// + [JsonConstructor] + public UnknownSessionMessage(string type, JsonElement payload) + { +#if NET8_0_OR_GREATER + ArgumentException.ThrowIfNullOrEmpty(type); +#else + if (string.IsNullOrEmpty(type)) + { + throw new ArgumentException("Value cannot be null or empty.", nameof(type)); + } +#endif + + _marker = type; + Payload = payload.Clone(); + } + + /// + /// Gets the unrecognized 'type' marker. + /// + public override string Type => _marker; + /// + /// Gets the preserved raw JSON payload. + /// + public JsonElement Payload { get; } +} diff --git a/src/OpenCode.Sdk/Models/UnknownSessionMessageAssistantContent.cs b/src/OpenCode.Sdk/Models/UnknownSessionMessageAssistantContent.cs new file mode 100644 index 00000000..65d615a7 --- /dev/null +++ b/src/OpenCode.Sdk/Models/UnknownSessionMessageAssistantContent.cs @@ -0,0 +1,40 @@ +// Generated by OpenCode.Sdk.Tools from spec/openapi.json. +// Do not edit by hand — change tools/curation.json or the emitters, then regenerate. +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace OpenCode.Sdk.Models; +/// +/// Preserves an unknown session message assistant content payload. +/// +public sealed record UnknownSessionMessageAssistantContent : SessionMessageAssistantContent +{ + private readonly string _marker; + /// + /// Initializes an unknown union value from its marker and raw payload. + /// + [JsonConstructor] + public UnknownSessionMessageAssistantContent(string type, JsonElement payload) + { +#if NET8_0_OR_GREATER + ArgumentException.ThrowIfNullOrEmpty(type); +#else + if (string.IsNullOrEmpty(type)) + { + throw new ArgumentException("Value cannot be null or empty.", nameof(type)); + } +#endif + + _marker = type; + Payload = payload.Clone(); + } + + /// + /// Gets the unrecognized 'type' marker. + /// + public override string Type => _marker; + /// + /// Gets the preserved raw JSON payload. + /// + public JsonElement Payload { get; } +} diff --git a/src/OpenCode.Sdk/Models/UnknownSessionMessageToolState.cs b/src/OpenCode.Sdk/Models/UnknownSessionMessageToolState.cs new file mode 100644 index 00000000..7d56f119 --- /dev/null +++ b/src/OpenCode.Sdk/Models/UnknownSessionMessageToolState.cs @@ -0,0 +1,40 @@ +// Generated by OpenCode.Sdk.Tools from spec/openapi.json. +// Do not edit by hand — change tools/curation.json or the emitters, then regenerate. +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace OpenCode.Sdk.Models; +/// +/// Preserves an unknown session message tool state payload. +/// +public sealed record UnknownSessionMessageToolState : SessionMessageToolState +{ + private readonly string _marker; + /// + /// Initializes an unknown union value from its marker and raw payload. + /// + [JsonConstructor] + public UnknownSessionMessageToolState(string status, JsonElement payload) + { +#if NET8_0_OR_GREATER + ArgumentException.ThrowIfNullOrEmpty(status); +#else + if (string.IsNullOrEmpty(status)) + { + throw new ArgumentException("Value cannot be null or empty.", nameof(status)); + } +#endif + + _marker = status; + Payload = payload.Clone(); + } + + /// + /// Gets the unrecognized 'status' marker. + /// + public override string Status => _marker; + /// + /// Gets the preserved raw JSON payload. + /// + public JsonElement Payload { get; } +} diff --git a/src/OpenCode.Sdk/OpenCode.Sdk.csproj b/src/OpenCode.Sdk/OpenCode.Sdk.csproj index 44d712b2..bfc3d1be 100644 --- a/src/OpenCode.Sdk/OpenCode.Sdk.csproj +++ b/src/OpenCode.Sdk/OpenCode.Sdk.csproj @@ -9,4 +9,8 @@ true + + + + diff --git a/tests/OpenCode.Sdk.Tests/Fixtures/Serialization/known-assistant-content.json b/tests/OpenCode.Sdk.Tests/Fixtures/Serialization/known-assistant-content.json new file mode 100644 index 00000000..b9f4ec4a --- /dev/null +++ b/tests/OpenCode.Sdk.Tests/Fixtures/Serialization/known-assistant-content.json @@ -0,0 +1 @@ +{"id":"part-1","text":"answer","type":"text"} diff --git a/tests/OpenCode.Sdk.Tests/Fixtures/Serialization/known-session-message.json b/tests/OpenCode.Sdk.Tests/Fixtures/Serialization/known-session-message.json new file mode 100644 index 00000000..a9c5e639 --- /dev/null +++ b/tests/OpenCode.Sdk.Tests/Fixtures/Serialization/known-session-message.json @@ -0,0 +1 @@ +{"id":"message-1","time":{"created":1},"text":"hello","type":"user"} diff --git a/tests/OpenCode.Sdk.Tests/Fixtures/Serialization/known-tool-state.json b/tests/OpenCode.Sdk.Tests/Fixtures/Serialization/known-tool-state.json new file mode 100644 index 00000000..c3fd5c77 --- /dev/null +++ b/tests/OpenCode.Sdk.Tests/Fixtures/Serialization/known-tool-state.json @@ -0,0 +1 @@ +{"input":"queued input","status":"pending"} diff --git a/tests/OpenCode.Sdk.Tests/Fixtures/Serialization/malformed-assistant-marker.json b/tests/OpenCode.Sdk.Tests/Fixtures/Serialization/malformed-assistant-marker.json new file mode 100644 index 00000000..02e072c0 --- /dev/null +++ b/tests/OpenCode.Sdk.Tests/Fixtures/Serialization/malformed-assistant-marker.json @@ -0,0 +1 @@ +{"id":"part-4","text":"malformed marker","type":42} diff --git a/tests/OpenCode.Sdk.Tests/Fixtures/Serialization/missing-assistant-marker.json b/tests/OpenCode.Sdk.Tests/Fixtures/Serialization/missing-assistant-marker.json new file mode 100644 index 00000000..57d48e18 --- /dev/null +++ b/tests/OpenCode.Sdk.Tests/Fixtures/Serialization/missing-assistant-marker.json @@ -0,0 +1 @@ +{"id":"part-3","text":"missing marker"} diff --git a/tests/OpenCode.Sdk.Tests/Fixtures/Serialization/non-object-assistant-payload.json b/tests/OpenCode.Sdk.Tests/Fixtures/Serialization/non-object-assistant-payload.json new file mode 100644 index 00000000..fe51488c --- /dev/null +++ b/tests/OpenCode.Sdk.Tests/Fixtures/Serialization/non-object-assistant-payload.json @@ -0,0 +1 @@ +[] diff --git a/tests/OpenCode.Sdk.Tests/Fixtures/Serialization/null-optional-collection.json b/tests/OpenCode.Sdk.Tests/Fixtures/Serialization/null-optional-collection.json new file mode 100644 index 00000000..dcf0a8c9 --- /dev/null +++ b/tests/OpenCode.Sdk.Tests/Fixtures/Serialization/null-optional-collection.json @@ -0,0 +1 @@ +{"id":"message-3","metadata":null,"time":{"created":1},"text":"hello","type":"user"} diff --git a/tests/OpenCode.Sdk.Tests/Fixtures/Serialization/unknown-assistant-content.json b/tests/OpenCode.Sdk.Tests/Fixtures/Serialization/unknown-assistant-content.json new file mode 100644 index 00000000..16c68029 --- /dev/null +++ b/tests/OpenCode.Sdk.Tests/Fixtures/Serialization/unknown-assistant-content.json @@ -0,0 +1 @@ +{"id":"part-2","payload":{"tokens":[1,2]},"type":"future-content"} diff --git a/tests/OpenCode.Sdk.Tests/Fixtures/Serialization/unknown-session-message.json b/tests/OpenCode.Sdk.Tests/Fixtures/Serialization/unknown-session-message.json new file mode 100644 index 00000000..4898279b --- /dev/null +++ b/tests/OpenCode.Sdk.Tests/Fixtures/Serialization/unknown-session-message.json @@ -0,0 +1 @@ +{"id":"message-2","future":{"enabled":true},"type":"future-message"} diff --git a/tests/OpenCode.Sdk.Tests/Fixtures/Serialization/unknown-tool-state.json b/tests/OpenCode.Sdk.Tests/Fixtures/Serialization/unknown-tool-state.json new file mode 100644 index 00000000..63d46ada --- /dev/null +++ b/tests/OpenCode.Sdk.Tests/Fixtures/Serialization/unknown-tool-state.json @@ -0,0 +1 @@ +{"details":{"reason":"approval"},"status":"paused"} diff --git a/tests/OpenCode.Sdk.Tests/OpenCode.Sdk.Tests.csproj b/tests/OpenCode.Sdk.Tests/OpenCode.Sdk.Tests.csproj index 87c92715..3f3a8c6d 100644 --- a/tests/OpenCode.Sdk.Tests/OpenCode.Sdk.Tests.csproj +++ b/tests/OpenCode.Sdk.Tests/OpenCode.Sdk.Tests.csproj @@ -3,6 +3,7 @@ Exe net8.0;net9.0;$(DefaultTargetFramework) + false net472;$(TargetFrameworks) @@ -20,4 +21,8 @@ + + + + diff --git a/tests/OpenCode.Sdk.Tests/Serialization/OpenCodeJsonContextTests.cs b/tests/OpenCode.Sdk.Tests/Serialization/OpenCodeJsonContextTests.cs new file mode 100644 index 00000000..ab7fbe3b --- /dev/null +++ b/tests/OpenCode.Sdk.Tests/Serialization/OpenCodeJsonContextTests.cs @@ -0,0 +1,170 @@ +using System.Text.Json; +using OpenCode.Sdk.Models; +using OpenCode.Sdk.Tests.Support; + +namespace OpenCode.Sdk.Tests.Serialization; + +public sealed class OpenCodeJsonContextTests +{ + private readonly FixtureLoader _fixtures = new(); + private readonly GeneratedJsonSerializer _serializer = new(); + + [Test] + public async Task Deserialize_Should_Create_Known_Outer_Variant_When_Marker_Is_Last() + { + var json = _fixtures.LoadJson("Serialization.known-session-message.json"); + + var result = _serializer.Deserialize(json); + + await Assert.That(result).IsTypeOf(); + var user = (SessionMessageUser)result; + await Assert.That(user.ID).IsEqualTo("message-1"); + await Assert.That(user.Text).IsEqualTo("hello"); + var serialized = _serializer.Serialize(user); + using var document = JsonDocument.Parse(serialized); + await Assert.That(document.RootElement.GetProperty("type").GetString()).IsEqualTo("user"); + } + + [Test] + public async Task Deserialize_Should_Preserve_Unknown_Outer_Variant_Byte_For_Byte() + { + var json = _fixtures.LoadJson("Serialization.unknown-session-message.json"); + + var result = _serializer.Deserialize(json); + + await Assert.That(result).IsTypeOf(); + var unknown = (UnknownSessionMessage)result; + await Assert.That(unknown.Type).IsEqualTo("future-message"); + var roundTrip = _serializer.Serialize(unknown); + await Assert.That(roundTrip).IsEqualTo(json); + } + + [Test] + public async Task Deserialize_Should_Create_Known_Assistant_Content_When_Marker_Is_Last() + { + var json = _fixtures.LoadJson("Serialization.known-assistant-content.json"); + + var result = _serializer.Deserialize(json); + + await Assert.That(result).IsTypeOf(); + var text = (SessionMessageAssistantText)result; + await Assert.That(text.Text).IsEqualTo("answer"); + var serialized = _serializer.Serialize(text); + using var document = JsonDocument.Parse(serialized); + await Assert.That(document.RootElement.GetProperty("type").GetString()).IsEqualTo("text"); + } + + [Test] + public async Task Deserialize_Should_Preserve_Unknown_Assistant_Content_Semantically() + { + var json = _fixtures.LoadJson("Serialization.unknown-assistant-content.json"); + + var result = _serializer.Deserialize(json); + + await Assert.That(result).IsTypeOf(); + var unknown = (UnknownSessionMessageAssistantContent)result; + var roundTrip = _serializer.Serialize(unknown); + using var expected = JsonDocument.Parse(json); + using var actual = JsonDocument.Parse(roundTrip); + await Assert.That(JsonElement.DeepEquals(expected.RootElement, actual.RootElement)).IsTrue(); + } + + [Test] + public async Task Deserialize_Should_Create_Known_Tool_State_When_Marker_Is_Last() + { + var json = _fixtures.LoadJson("Serialization.known-tool-state.json"); + + var result = _serializer.Deserialize(json); + + await Assert.That(result).IsTypeOf(); + var pending = (SessionMessageToolStatePending)result; + await Assert.That(pending.Input).IsEqualTo("queued input"); + var serialized = _serializer.Serialize(pending); + using var document = JsonDocument.Parse(serialized); + await Assert.That(document.RootElement.GetProperty("status").GetString()).IsEqualTo("pending"); + } + + [Test] + public async Task Deserialize_Should_Preserve_Unknown_Tool_State_Byte_For_Byte() + { + var json = _fixtures.LoadJson("Serialization.unknown-tool-state.json"); + + var result = _serializer.Deserialize(json); + + await Assert.That(result).IsTypeOf(); + var unknown = (UnknownSessionMessageToolState)result; + await Assert.That(unknown.Status).IsEqualTo("paused"); + var roundTrip = _serializer.Serialize(unknown); + await Assert.That(roundTrip).IsEqualTo(json); + } + + [Test] + public async Task Deserialize_Should_Throw_When_Union_Marker_Is_Missing() + { + var json = _fixtures.LoadJson("Serialization.missing-assistant-marker.json"); + + _ = await Assert.That(() => _serializer.Deserialize(json)) + .Throws(); + } + + [Test] + public async Task Deserialize_Should_Throw_When_Union_Marker_Has_The_Wrong_Type() + { + var json = _fixtures.LoadJson("Serialization.malformed-assistant-marker.json"); + + _ = await Assert.That(() => _serializer.Deserialize(json)) + .Throws(); + } + + [Test] + public async Task Deserialize_Should_Reject_Explicit_Null_For_Optional_Nonnull_Collections() + { + var json = _fixtures.LoadJson("Serialization.null-optional-collection.json"); + + _ = await Assert.That(() => _serializer.Deserialize(json)) + .Throws(); + } + + [Test] + public async Task Deserialize_Should_Throw_JsonException_When_Union_Payload_Is_Not_An_Object() + { + var json = _fixtures.LoadJson("Serialization.non-object-assistant-payload.json"); + + _ = await Assert.That(() => _serializer.Deserialize(json)) + .Throws(); + } + + [Test] + public async Task ProviderMetadata_Should_Recursively_Copy_Collections() + { + var inner = new Dictionary(StringComparer.Ordinal) + { + ["original"] = default, + }; + var outer = new Dictionary>(StringComparer.Ordinal) + { + ["provider"] = inner, + }; + var model = new SessionMessageAssistantReasoning + { + ID = "part-1", + Text = "reasoning", + ProviderMetadata = outer, + }; + + inner.Add("inner-mutation", default); + outer.Add("outer-mutation", new Dictionary(StringComparer.Ordinal)); + + await Assert.That(model.ProviderMetadata.ContainsKey("outer-mutation")).IsFalse(); + await Assert.That(model.ProviderMetadata["provider"].ContainsKey("inner-mutation")).IsFalse(); + } + + [Test] + public async Task GetTypeInfo_Should_Resolve_Union_Metadata_Without_Reflection_Fallback() + { + await Assert.That(JsonSerializer.IsReflectionEnabledByDefault).IsFalse(); + await Assert.That(_serializer.GetTypeInfo(typeof(SessionMessage))).IsNotNull(); + await Assert.That(_serializer.GetTypeInfo(typeof(SessionMessageAssistantContent))).IsNotNull(); + await Assert.That(_serializer.GetTypeInfo(typeof(SessionMessageToolState))).IsNotNull(); + } +} diff --git a/tests/OpenCode.Sdk.Tests/SmokeTests.cs b/tests/OpenCode.Sdk.Tests/SmokeTests.cs deleted file mode 100644 index 478e87d2..00000000 --- a/tests/OpenCode.Sdk.Tests/SmokeTests.cs +++ /dev/null @@ -1,12 +0,0 @@ -namespace OpenCode.Sdk.Tests; - -public sealed class SmokeTests -{ - [Test] - public async Task TestInfrastructure_Should_Run_On_Every_Target_Framework() - { - // Placeholder until the first real SDK code lands; keeps the TUnit + - // Microsoft.Testing.Platform pipeline exercised on every TFM and OS leg. - await Assert.That(Environment.Version).IsNotNull(); - } -} diff --git a/tests/OpenCode.Sdk.Tests/Support/FixtureLoader.cs b/tests/OpenCode.Sdk.Tests/Support/FixtureLoader.cs new file mode 100644 index 00000000..d02ea2a4 --- /dev/null +++ b/tests/OpenCode.Sdk.Tests/Support/FixtureLoader.cs @@ -0,0 +1,22 @@ +using System.Reflection; +using System.Text; + +namespace OpenCode.Sdk.Tests.Support; + +internal sealed class FixtureLoader +{ + private const string ResourcePrefix = "OpenCode.Sdk.Tests.Fixtures."; + private readonly Assembly _assembly = typeof(FixtureLoader).Assembly; + + public string LoadJson(string name) + { + ArgumentException.ThrowIfNullOrWhiteSpace(name); + var resourceName = string.Concat(ResourcePrefix, name); + var stream = _assembly.GetManifestResourceStream(resourceName) + ?? throw new ArgumentException($"Embedded JSON fixture '{name}' was not found.", nameof(name)); + + using var reader = new BinaryReader(stream, Encoding.UTF8, leaveOpen: false); + var content = Encoding.UTF8.GetString(reader.ReadBytes(checked((int)stream.Length))); + return content.TrimEnd('\r', '\n'); + } +} diff --git a/tests/OpenCode.Sdk.Tests/Support/GeneratedJsonSerializer.cs b/tests/OpenCode.Sdk.Tests/Support/GeneratedJsonSerializer.cs new file mode 100644 index 00000000..b7945917 --- /dev/null +++ b/tests/OpenCode.Sdk.Tests/Support/GeneratedJsonSerializer.cs @@ -0,0 +1,45 @@ +using System.Reflection; +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Text.Json.Serialization.Metadata; +using OpenCode.Sdk.Models; + +namespace OpenCode.Sdk.Tests.Support; + +internal sealed class GeneratedJsonSerializer +{ + private const string ContextTypeName = "OpenCode.Sdk.Internal.Serialization.OpenCodeJsonContext"; + private readonly JsonSerializerContext _context = LoadContext(); + + public T Deserialize(string json) + where T : class + { + ArgumentException.ThrowIfNullOrWhiteSpace(json); + return JsonSerializer.Deserialize(json, GetTypeInfo(typeof(T))) as T + ?? throw new JsonException($"The {typeof(T).Name} fixture deserialized to null."); + } + + public string Serialize(T value) + where T : class + { + ArgumentNullException.ThrowIfNull(value); + return JsonSerializer.Serialize(value, GetTypeInfo(typeof(T))); + } + + public JsonTypeInfo GetTypeInfo(Type type) + { + ArgumentNullException.ThrowIfNull(type); + return _context.GetTypeInfo(type) + ?? throw new InvalidOperationException($"The generated JSON context has no metadata for {type.Name}."); + } + + private static JsonSerializerContext LoadContext() + { + var contextType = typeof(SessionMessage).Assembly.GetType(ContextTypeName, throwOnError: true, ignoreCase: false) + ?? throw new InvalidOperationException($"The generated JSON context '{ContextTypeName}' was not found."); + var defaultProperty = contextType.GetProperty("Default", BindingFlags.Public | BindingFlags.Static) + ?? throw new InvalidOperationException("The generated JSON context has no Default property."); + return defaultProperty.GetValue(null) as JsonSerializerContext + ?? throw new InvalidOperationException("The generated JSON context Default property returned no context."); + } +} diff --git a/tests/OpenCode.Sdk.Tools.Tests/Output/GenerationWriterTests.cs b/tests/OpenCode.Sdk.Tools.Tests/Output/GenerationWriterTests.cs new file mode 100644 index 00000000..a32711e1 --- /dev/null +++ b/tests/OpenCode.Sdk.Tools.Tests/Output/GenerationWriterTests.cs @@ -0,0 +1,215 @@ +using System.Text.Json; +using OpenCode.Sdk.Tools.Output; +using OpenCode.Sdk.Tools.Tests.Support; +using Testably.Abstractions.Testing; +using Testably.Abstractions.Testing.Initializer; + +namespace OpenCode.Sdk.Tools.Tests.Output; + +public sealed class GenerationWriterTests +{ + [Test] + public async Task WriteAsync_Should_Refuse_Unsafe_Source_Paths() + { + var fileSystem = GenerationTestData.CreateFileSystem(); + var writer = new GenerationWriter(fileSystem, new RecordingProjectFormatter(fileSystem)); + + var exception = await Assert.That(async () => await writer.WriteAsync( + GenerationTestData.OutputRoot, + GenerationTestData.ProjectPath, + [GenerationTestData.Source("../escape.cs", "unsafe")], + partialMarkerContent: null, + verify: false, + CancellationToken.None)) + .Throws(); + + await Assert.That(exception!.Message).Contains("unsafe"); + } + + [Test] + public async Task WriteAsync_Should_Refuse_Unsafe_Manifest_Paths() + { + var fileSystem = GenerationTestData.CreateFileSystem(); + fileSystem.Initialize().With(new FileDescription( + GenerationTestData.ManifestPath, + "{\"files\":[\"../outside.cs\"]}")); + var writer = new GenerationWriter(fileSystem, new RecordingProjectFormatter(fileSystem)); + + var exception = await Assert.That(async () => await writer.WriteAsync( + GenerationTestData.OutputRoot, + GenerationTestData.ProjectPath, + [], + partialMarkerContent: null, + verify: false, + CancellationToken.None)) + .Throws(); + + await Assert.That(exception!.Message).Contains("unsafe"); + } + + [Test] + public async Task WriteAsync_Should_Refuse_Symbolic_Links_In_Owned_Paths() + { + const string outsideRoot = "outside"; + var fileSystem = GenerationTestData.CreateFileSystem(); + fileSystem.Directory.CreateDirectory(outsideRoot); + _ = fileSystem.Directory.CreateSymbolicLink( + fileSystem.Path.Combine(GenerationTestData.OutputRoot, "Models"), + outsideRoot); + var writer = new GenerationWriter(fileSystem, new RecordingProjectFormatter(fileSystem)); + + var exception = await Assert.That(async () => await writer.WriteAsync( + GenerationTestData.OutputRoot, + GenerationTestData.ProjectPath, + [GenerationTestData.Source("Models/Escape.cs", "unsafe")], + partialMarkerContent: null, + verify: false, + CancellationToken.None)) + .Throws(); + + await Assert.That(exception!.Message).Contains("symbolic link"); + } + + [Test] + public async Task WriteAsync_Should_Refuse_Case_Only_Owned_Path_Changes() + { + const string oldPath = "Models/Widget.cs"; + var fileSystem = GenerationTestData.CreateFileSystem(); + var manifest = JsonSerializer.Serialize(new GenerationManifest { Files = [oldPath], }); + fileSystem.Initialize().With( + new FileDescription(GenerationTestData.ManifestPath, manifest), + new FileDescription(fileSystem.Path.Combine(GenerationTestData.OutputRoot, oldPath), "old")); + var writer = new GenerationWriter(fileSystem, new RecordingProjectFormatter(fileSystem)); + + var exception = await Assert.That(async () => await writer.WriteAsync( + GenerationTestData.OutputRoot, + GenerationTestData.ProjectPath, + [GenerationTestData.Source("Models/widget.cs", "new")], + partialMarkerContent: null, + verify: false, + CancellationToken.None)) + .Throws(); + + await Assert.That(exception!.Message).Contains("only by case"); + } + + [Test] + public async Task WriteAsync_Should_Delete_Only_Stale_Manifest_Files() + { + const string stalePath = "src/OpenCode.Sdk/Models/Stale.cs"; + const string unrelatedPath = "src/OpenCode.Sdk/Manual.cs"; + var fileSystem = GenerationTestData.CreateFileSystem(); + var manifest = JsonSerializer.Serialize(new GenerationManifest { Files = ["Models/Stale.cs"], }); + fileSystem.Initialize().With( + new FileDescription(GenerationTestData.ManifestPath, manifest), + new FileDescription(stalePath, "stale"), + new FileDescription(unrelatedPath, "manual")); + var writer = new GenerationWriter(fileSystem, new RecordingProjectFormatter(fileSystem)); + + var result = await writer.WriteAsync( + GenerationTestData.OutputRoot, + GenerationTestData.ProjectPath, + [GenerationTestData.Source("Models/Fresh.cs", "fresh")], + partialMarkerContent: null, + verify: false, + CancellationToken.None); + + await Assert.That(fileSystem.File.Exists(stalePath)).IsFalse(); + await Assert.That(fileSystem.File.Exists(unrelatedPath)).IsTrue(); + await Assert.That(result.DeletedPaths).Contains("Models/Stale.cs"); + await Assert.That(result.CreatedPaths).Contains("Models/Fresh.cs"); + } + + [Test] + public async Task WriteAsync_Should_Be_Idempotent_After_Project_Formatting() + { + const string relativePath = "Models/Example.cs"; + var fileSystem = GenerationTestData.CreateFileSystem(); + var formatter = new RecordingProjectFormatter(fileSystem, async (currentFileSystem, _, _, cancellationToken) => + { + var path = currentFileSystem.Path.Combine(GenerationTestData.OutputRoot, relativePath); + var source = await currentFileSystem.File.ReadAllTextAsync(path, cancellationToken); + await currentFileSystem.File.WriteAllTextAsync( + path, + source.Replace("public record", "public sealed record", StringComparison.Ordinal), + cancellationToken); + }); + var writer = new GenerationWriter(fileSystem, formatter); + var sources = new[] { GenerationTestData.Source(relativePath, "public record Example;\n"), }; + + _ = await writer.WriteAsync( + GenerationTestData.OutputRoot, + GenerationTestData.ProjectPath, + sources, + partialMarkerContent: null, + verify: false, + CancellationToken.None); + var verify = await writer.WriteAsync( + GenerationTestData.OutputRoot, + GenerationTestData.ProjectPath, + sources, + partialMarkerContent: null, + verify: true, + CancellationToken.None); + + await Assert.That(formatter.CallCount).IsEqualTo(2); + await Assert.That(formatter.SourcePaths).Contains(relativePath); + await Assert.That(verify.HasChanges).IsFalse(); + } + + [Test] + public async Task WriteAsync_Should_Report_And_Repair_Verify_Drift() + { + const string relativePath = "Models/Example.cs"; + var fileSystem = GenerationTestData.CreateFileSystem(); + var writer = new GenerationWriter(fileSystem, new RecordingProjectFormatter(fileSystem)); + var sources = new[] { GenerationTestData.Source(relativePath, "expected\n"), }; + _ = await writer.WriteAsync( + GenerationTestData.OutputRoot, + GenerationTestData.ProjectPath, + sources, + partialMarkerContent: null, + verify: false, + CancellationToken.None); + var outputPath = fileSystem.Path.Combine(GenerationTestData.OutputRoot, relativePath); + await fileSystem.File.WriteAllTextAsync(outputPath, "drift\n", CancellationToken.None); + + var result = await writer.WriteAsync( + GenerationTestData.OutputRoot, + GenerationTestData.ProjectPath, + sources, + partialMarkerContent: null, + verify: true, + CancellationToken.None); + + await Assert.That(result.HasChanges).IsTrue(); + await Assert.That(result.ChangedPaths).Contains(relativePath); + var repaired = await fileSystem.File.ReadAllTextAsync(outputPath, CancellationToken.None); + await Assert.That(repaired).IsEqualTo("expected\n"); + } + + [Test] + public async Task WriteAsync_Should_Create_And_Remove_The_Partial_Marker() + { + var fileSystem = GenerationTestData.CreateFileSystem(); + var writer = new GenerationWriter(fileSystem, new RecordingProjectFormatter(fileSystem)); + + _ = await writer.WriteAsync( + GenerationTestData.OutputRoot, + GenerationTestData.ProjectPath, + [], + "Pending modern operations: 1.\nPending legacy operations: 2.\n", + verify: false, + CancellationToken.None); + var removal = await writer.WriteAsync( + GenerationTestData.OutputRoot, + GenerationTestData.ProjectPath, + [], + partialMarkerContent: null, + verify: false, + CancellationToken.None); + + await Assert.That(fileSystem.File.Exists(GenerationTestData.MarkerPath)).IsFalse(); + await Assert.That(removal.DeletedPaths).Contains(".generation-incomplete"); + } +} diff --git a/tests/OpenCode.Sdk.Tools.Tests/Snapshots/ModelEmitterTests.Emit_Should_Produce_Immutable_Annotated_Models.verified.txt b/tests/OpenCode.Sdk.Tools.Tests/Snapshots/ModelEmitterTests.Emit_Should_Produce_Immutable_Annotated_Models.verified.txt index 08219549..6a3f9913 100644 --- a/tests/OpenCode.Sdk.Tools.Tests/Snapshots/ModelEmitterTests.Emit_Should_Produce_Immutable_Annotated_Models.verified.txt +++ b/tests/OpenCode.Sdk.Tools.Tests/Snapshots/ModelEmitterTests.Emit_Should_Produce_Immutable_Annotated_Models.verified.txt @@ -28,6 +28,7 @@ public sealed record BadRequestError : OpenCodeError using System; using System.Collections.Generic; using System.Collections.ObjectModel; +using System.Linq; using System.Text.Json.Serialization; namespace OpenCode.Sdk.Models; @@ -58,8 +59,8 @@ public sealed record ExampleItem get; init { - ArgumentNullException.ThrowIfNull(value); - field = new List(value).AsReadOnly(); + // Source-generated deserialization passes null for an absent optional init-only collection. + field = value is null ? Array.Empty() : new List(value).AsReadOnly(); } } = Array.Empty(); @@ -72,8 +73,8 @@ public sealed record ExampleItem get; init { - ArgumentNullException.ThrowIfNull(value); - field = new ReadOnlyDictionary(new Dictionary(value, StringComparer.Ordinal)); + // Source-generated deserialization passes null for an absent optional init-only collection. + field = value is null ? new ReadOnlyDictionary(new Dictionary(StringComparer.Ordinal)) : new ReadOnlyDictionary(value.ToDictionary(static pair => pair.Key, static pair => pair.Value, StringComparer.Ordinal)); } } = new ReadOnlyDictionary(new Dictionary(StringComparer.Ordinal)); } diff --git a/tests/OpenCode.Sdk.Tools.Tests/Snapshots/RegistryEmitterTests.Emit_Should_Produce_The_Source_Generated_Context_Registry.verified.txt b/tests/OpenCode.Sdk.Tools.Tests/Snapshots/RegistryEmitterTests.Emit_Should_Produce_The_Source_Generated_Context_Registry.verified.txt index f351ff34..a60d367f 100644 --- a/tests/OpenCode.Sdk.Tools.Tests/Snapshots/RegistryEmitterTests.Emit_Should_Produce_The_Source_Generated_Context_Registry.verified.txt +++ b/tests/OpenCode.Sdk.Tools.Tests/Snapshots/RegistryEmitterTests.Emit_Should_Produce_The_Source_Generated_Context_Registry.verified.txt @@ -5,7 +5,7 @@ using System.Text.Json.Serialization; using OpenCode.Sdk.Models; namespace OpenCode.Sdk.Internal.Serialization; -[JsonSourceGenerationOptions(GenerationMode = JsonSourceGenerationMode.Metadata)] +[JsonSourceGenerationOptions(GenerationMode = JsonSourceGenerationMode.Metadata, RespectNullableAnnotations = true)] [JsonSerializable(typeof(BadRequestError))] [JsonSerializable(typeof(CreatedEvent))] [JsonSerializable(typeof(DeletedEvent))] diff --git a/tests/OpenCode.Sdk.Tools.Tests/Snapshots/UnionEmitterTests.Emit_Should_Produce_Tolerant_Marked_Union_Converters.verified.txt b/tests/OpenCode.Sdk.Tools.Tests/Snapshots/UnionEmitterTests.Emit_Should_Produce_Tolerant_Marked_Union_Converters.verified.txt index c22a3a6e..5d0b8fbe 100644 --- a/tests/OpenCode.Sdk.Tools.Tests/Snapshots/UnionEmitterTests.Emit_Should_Produce_Tolerant_Marked_Union_Converters.verified.txt +++ b/tests/OpenCode.Sdk.Tools.Tests/Snapshots/UnionEmitterTests.Emit_Should_Produce_Tolerant_Marked_Union_Converters.verified.txt @@ -38,7 +38,15 @@ public sealed record UnknownExampleEvent : ExampleEvent [JsonConstructor] public UnknownExampleEvent(string type, JsonElement payload) { +#if NET8_0_OR_GREATER ArgumentException.ThrowIfNullOrEmpty(type); +#else + if (string.IsNullOrEmpty(type)) + { + throw new ArgumentException("Value cannot be null or empty.", nameof(type)); + } +#endif + _marker = type; Payload = payload.Clone(); } @@ -74,12 +82,30 @@ internal sealed class ExampleEventJsonConverter : JsonConverter public override ExampleEvent Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) { +#if NET8_0_OR_GREATER ArgumentNullException.ThrowIfNull(typeToConvert); +#else + if (typeToConvert is null) + { + throw new ArgumentNullException(nameof(typeToConvert)); + } +#endif + +#if NET8_0_OR_GREATER ArgumentNullException.ThrowIfNull(options); +#else + if (options is null) + { + throw new ArgumentNullException(nameof(options)); + } +#endif + if (reader.TokenType == JsonTokenType.Null) throw new JsonException("The ExampleEvent payload cannot be null."); using var document = JsonDocument.ParseValue(ref reader); var payload = document.RootElement; + if (payload.ValueKind != JsonValueKind.Object) + throw new JsonException("The ExampleEvent payload must be a JSON object."); if (!payload.TryGetProperty("type", out var markerElement)) throw new JsonException("The ExampleEvent payload must contain 'type'."); if (markerElement.ValueKind != JsonValueKind.String) @@ -98,9 +124,33 @@ internal sealed class ExampleEventJsonConverter : JsonConverter public override void Write(Utf8JsonWriter writer, ExampleEvent value, JsonSerializerOptions options) { +#if NET8_0_OR_GREATER ArgumentNullException.ThrowIfNull(writer); +#else + if (writer is null) + { + throw new ArgumentNullException(nameof(writer)); + } +#endif + +#if NET8_0_OR_GREATER ArgumentNullException.ThrowIfNull(value); +#else + if (value is null) + { + throw new ArgumentNullException(nameof(value)); + } +#endif + +#if NET8_0_OR_GREATER ArgumentNullException.ThrowIfNull(options); +#else + if (options is null) + { + throw new ArgumentNullException(nameof(options)); + } +#endif + if (value is UnknownExampleEvent unknown) { unknown.Payload.WriteTo(writer); diff --git a/tests/OpenCode.Sdk.Tools.Tests/Support/GenerationTestData.cs b/tests/OpenCode.Sdk.Tools.Tests/Support/GenerationTestData.cs new file mode 100644 index 00000000..cac1866f --- /dev/null +++ b/tests/OpenCode.Sdk.Tools.Tests/Support/GenerationTestData.cs @@ -0,0 +1,54 @@ +using System.Text; +using OpenCode.Sdk.Tools.Generator.Emission; +using Testably.Abstractions.Testing; +using Testably.Abstractions.Testing.Initializer; + +namespace OpenCode.Sdk.Tools.Tests.Support; + +internal static class GenerationTestData +{ + public const string ManifestPath = "src/OpenCode.Sdk/.generated-manifest.json"; + public const string MarkerPath = "src/OpenCode.Sdk/.generation-incomplete"; + public const string OutputRoot = "src/OpenCode.Sdk"; + public const string ProjectPath = "src/OpenCode.Sdk/OpenCode.Sdk.csproj"; + + private const string Curation = """ + { + "groups": { + "health": { + "placement": "root" + } + }, + "envelopePayloadNames": {}, + "propertyOverrides": [] + } + """; + + public static MockFileSystem CreateFileSystem() + { + var fileSystem = new MockFileSystem(); + fileSystem.Initialize().With(new FileDescription(ProjectPath, "")); + return fileSystem; + } + + public static MockFileSystem CreateCommandFileSystem() + { + var spec = new SpecDocumentBuilder() + .WithOperation("v2.health.get", path: "/api/health") + .WithOperation("session.list", path: "/session") + .BuildJson(); + var fileSystem = CreateFileSystem(); + fileSystem.Initialize().With( + new FileDescription("spec/openapi.json", spec), + new FileDescription("tools/generation-profile.txt", "v2.health.get\n"), + new FileDescription("tools/curation.json", Curation)); + return fileSystem; + } + + public static GeneratedSource Source(string relativePath, string source) => + new() + { + RelativePath = relativePath, + Utf8Source = Encoding.UTF8.GetBytes(source), + }; +} diff --git a/tests/OpenCode.Sdk.Tools.Tests/Support/RecordingProjectFormatter.cs b/tests/OpenCode.Sdk.Tools.Tests/Support/RecordingProjectFormatter.cs new file mode 100644 index 00000000..8402a416 --- /dev/null +++ b/tests/OpenCode.Sdk.Tools.Tests/Support/RecordingProjectFormatter.cs @@ -0,0 +1,36 @@ +using System.IO.Abstractions; +using OpenCode.Sdk.Tools.Output.Abstractions; + +namespace OpenCode.Sdk.Tools.Tests.Support; + +internal sealed class RecordingProjectFormatter( + IFileSystem fileSystem, + Func, CancellationToken, Task>? onFormat = null) + : IProjectFormatter +{ + private readonly IFileSystem _fileSystem = fileSystem ?? throw new ArgumentNullException(nameof(fileSystem)); + private readonly Func, CancellationToken, Task>? _onFormat = onFormat; + + public int CallCount { get; private set; } + + public string? ProjectPath { get; private set; } + + public IReadOnlyList SourcePaths { get; private set; } = Array.AsReadOnly(Array.Empty()); + + public async Task FormatAsync( + string projectPath, + IReadOnlyList sourcePaths, + CancellationToken cancellationToken) + { + ArgumentException.ThrowIfNullOrWhiteSpace(projectPath); + ArgumentNullException.ThrowIfNull(sourcePaths); + cancellationToken.ThrowIfCancellationRequested(); + CallCount++; + ProjectPath = projectPath; + SourcePaths = Array.AsReadOnly([.. sourcePaths]); + if (_onFormat is not null) + { + await _onFormat(_fileSystem, projectPath, sourcePaths, cancellationToken); + } + } +} diff --git a/tests/OpenCode.Sdk.Tools.Tests/ToolAppTests.cs b/tests/OpenCode.Sdk.Tools.Tests/ToolAppTests.cs index a005ea75..02919293 100644 --- a/tests/OpenCode.Sdk.Tools.Tests/ToolAppTests.cs +++ b/tests/OpenCode.Sdk.Tools.Tests/ToolAppTests.cs @@ -2,8 +2,11 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using OpenCode.Sdk.Tools.Commands; +using OpenCode.Sdk.Tools.Generator; using OpenCode.Sdk.Tools.Infrastructure; using OpenCode.Sdk.Tools.Infrastructure.Logging; +using OpenCode.Sdk.Tools.Output.Abstractions; +using OpenCode.Sdk.Tools.Tests.Support; using Spectre.Console; using Spectre.Console.Cli; using Spectre.Console.Cli.Testing; @@ -24,12 +27,18 @@ public async Task CreateServices_Should_Resolve_Full_Hosting_Composition() var console = provider.GetRequiredService(); var logger = provider.GetRequiredService>(); var loggingOptions = provider.GetRequiredService(); + var coordinator = provider.GetRequiredService(); + var writer = provider.GetRequiredService(); + var formatter = provider.GetRequiredService(); var interceptors = provider.GetServices().ToArray(); await Assert.That(fileSystem).IsTypeOf(); await Assert.That(console).IsSameReferenceAs(AnsiConsole.Console); await Assert.That(logger).IsNotNull(); await Assert.That(loggingOptions).IsNotNull(); + await Assert.That(coordinator).IsNotNull(); + await Assert.That(writer).IsNotNull(); + await Assert.That(formatter).IsNotNull(); await Assert.That(interceptors.Length).IsEqualTo(1); await Assert.That(interceptors[0]).IsTypeOf(); } @@ -50,18 +59,19 @@ public async Task CreateRegistrar_Should_Apply_Seam_Overrides_After_Production_R public async Task RunAsync_Should_Apply_Global_Logging_Options_Before_Command_Execution() { const string logPath = "logs/tool.log"; - var fileSystem = new MockFileSystem(); + var fileSystem = GenerationTestData.CreateCommandFileSystem(); using var registrar = ToolApp.CreateRegistrar(services => { services.AddSingleton(fileSystem); services.AddSingleton(); + services.AddSingleton(new RecordingProjectFormatter(fileSystem)); }); var tester = new CommandAppTester(registrar); tester.Configure(ToolApp.Configure); var result = await tester.RunAsync(["generate", "--log-level", "debug", "--log-file", logPath]); - await Assert.That(result.ExitCode).IsEqualTo(1); + await Assert.That(result.ExitCode).IsEqualTo(0); await Assert.That(fileSystem.File.Exists(logPath)).IsTrue(); var log = await fileSystem.File.ReadAllTextAsync(logPath, CancellationToken.None); await Assert.That(log).Contains("Generate command invoked."); @@ -80,15 +90,69 @@ public async Task RunAsync_Should_Return_Nonzero_When_Command_Is_Unknown() } [Test] - public async Task RunAsync_Should_Fail_Loud_When_Generate_Is_Invoked() + public async Task RunAsync_Should_Generate_Selected_Output() { - using var registrar = ToolApp.CreateRegistrar(); + var fileSystem = GenerationTestData.CreateCommandFileSystem(); + using var registrar = ToolApp.CreateRegistrar(services => + { + services.AddSingleton(fileSystem); + services.AddSingleton(); + services.AddSingleton(new RecordingProjectFormatter(fileSystem)); + }); var tester = new CommandAppTester(registrar); tester.Configure(ToolApp.Configure); var result = await tester.RunAsync(["generate"]); + await Assert.That(result.ExitCode).IsEqualTo(0); + await Assert.That(fileSystem.File.Exists(GenerationTestData.ManifestPath)).IsTrue(); + await Assert.That(fileSystem.File.Exists(GenerationTestData.MarkerPath)).IsTrue(); + await Assert.That(result.Output).Contains("Pending modern"); + await Assert.That(result.Output).Contains("Pending legacy"); + } + + [Test] + public async Task RunAsync_Should_Return_Zero_When_Verify_Is_Clean() + { + var fileSystem = GenerationTestData.CreateCommandFileSystem(); + using var registrar = ToolApp.CreateRegistrar(services => + { + services.AddSingleton(fileSystem); + services.AddSingleton(); + services.AddSingleton(new RecordingProjectFormatter(fileSystem)); + }); + var generateTester = new CommandAppTester(registrar); + generateTester.Configure(ToolApp.Configure); + _ = await generateTester.RunAsync(["generate"]); + + var verifyTester = new CommandAppTester(registrar); + verifyTester.Configure(ToolApp.Configure); + var result = await verifyTester.RunAsync(["generate", "--verify"]); + + await Assert.That(result.ExitCode).IsEqualTo(0); + } + + [Test] + public async Task RunAsync_Should_Return_Nonzero_And_Report_Verify_Drift() + { + const string generatedPath = "src/OpenCode.Sdk/Internal/Serialization/OpenCodeJsonContext.cs"; + var fileSystem = GenerationTestData.CreateCommandFileSystem(); + using var registrar = ToolApp.CreateRegistrar(services => + { + services.AddSingleton(fileSystem); + services.AddSingleton(); + services.AddSingleton(new RecordingProjectFormatter(fileSystem)); + }); + var generateTester = new CommandAppTester(registrar); + generateTester.Configure(ToolApp.Configure); + _ = await generateTester.RunAsync(["generate"]); + await fileSystem.File.WriteAllTextAsync(generatedPath, "drift", CancellationToken.None); + + var verifyTester = new CommandAppTester(registrar); + verifyTester.Configure(ToolApp.Configure); + var result = await verifyTester.RunAsync(["generate", "--verify"]); + await Assert.That(result.ExitCode).IsEqualTo(1); - await Assert.That(result.Output).Contains("not implemented"); + await Assert.That(result.Output).Contains("Internal/Serialization/OpenCodeJsonContext.cs"); } } diff --git a/tools/OpenCode.Sdk.Tools/Commands/GenerateCommand.cs b/tools/OpenCode.Sdk.Tools/Commands/GenerateCommand.cs index 10365295..11a65534 100644 --- a/tools/OpenCode.Sdk.Tools/Commands/GenerateCommand.cs +++ b/tools/OpenCode.Sdk.Tools/Commands/GenerateCommand.cs @@ -1,42 +1,94 @@ +using System.ComponentModel; +using System.Globalization; using Microsoft.Extensions.Logging; +using OpenCode.Sdk.Tools.Generator; +using OpenCode.Sdk.Tools.Output; using Spectre.Console; using Spectre.Console.Cli; namespace OpenCode.Sdk.Tools.Commands; -/// Fail-loud stub; the generator pipeline replaces the body in a later slice. -public sealed partial class GenerateCommand : AsyncCommand +internal sealed partial class GenerateCommand : AsyncCommand { private readonly IAnsiConsole _console; + private readonly GenerationCoordinator _coordinator; private readonly ILogger _logger; - /// Creates the command with its console and logging seams. - public GenerateCommand(IAnsiConsole console, ILogger logger) + public GenerateCommand(IAnsiConsole console, GenerationCoordinator coordinator, ILogger logger) { ArgumentNullException.ThrowIfNull(console); + ArgumentNullException.ThrowIfNull(coordinator); ArgumentNullException.ThrowIfNull(logger); _console = console; + _coordinator = coordinator; _logger = logger; } - /// - protected override Task ExecuteAsync(CommandContext context, Settings settings, CancellationToken cancellationToken) + protected override async Task ExecuteAsync( + CommandContext context, + Settings settings, + CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(context); ArgumentNullException.ThrowIfNull(settings); - - cancellationToken.ThrowIfCancellationRequested(); - LogInvocation(_logger); - _console.MarkupLine("[red]generate is not implemented yet[/] — the generator pipeline has not landed."); + var report = await _coordinator.GenerateAsync( + GenerationRequest.RepositoryDefault(settings.Verify), + cancellationToken).ConfigureAwait(false); + WriteSummary(report); - return Task.FromResult(1); + if (settings.Verify && report.WriteResult.HasChanges) + { + _console.MarkupLine("[red]Generated output was stale and has been repaired:[/]"); + WritePaths(report.WriteResult); + return 1; + } + + _console.MarkupLine(settings.Verify + ? "[green]Generated output is current.[/]" + : "[green]Generated output updated.[/]"); + return 0; } [LoggerMessage(EventId = 1, Level = LogLevel.Debug, Message = "Generate command invoked.")] private static partial void LogInvocation(ILogger logger); - /// Defines settings for the generate command. - public sealed class Settings : GlobalSettings; + private void WriteSummary(GenerationReport report) + { + _console.MarkupLine(string.Concat( + "[grey]Selected operations:[/] ", + report.SelectedOperationIds.Count.ToString(CultureInfo.InvariantCulture))); + _console.MarkupLine(string.Concat( + "[yellow]Pending modern operations:[/] ", + report.PendingModernOperationIds.Count.ToString(CultureInfo.InvariantCulture))); + _console.MarkupLine(string.Concat( + "[yellow]Pending legacy operations:[/] ", + report.PendingLegacyOperationIds.Count.ToString(CultureInfo.InvariantCulture))); + } + + private void WritePaths(WriteResult result) + { + foreach (var path in result.CreatedPaths) + { + _console.MarkupLine(string.Concat("[green]+[/] ", Markup.Escape(path))); + } + + foreach (var path in result.ChangedPaths) + { + _console.MarkupLine(string.Concat("[yellow]~[/] ", Markup.Escape(path))); + } + + foreach (var path in result.DeletedPaths) + { + _console.MarkupLine(string.Concat("[red]-[/] ", Markup.Escape(path))); + } + } + + internal sealed class Settings : GlobalSettings + { + [CommandOption("--verify")] + [Description("Return a nonzero exit code when generated output was stale before this run.")] + public bool Verify { get; init; } + } } diff --git a/tools/OpenCode.Sdk.Tools/Generator/Binding/CurationLoader.cs b/tools/OpenCode.Sdk.Tools/Generator/Binding/CurationLoader.cs index f6c4d204..c248f9c2 100644 --- a/tools/OpenCode.Sdk.Tools/Generator/Binding/CurationLoader.cs +++ b/tools/OpenCode.Sdk.Tools/Generator/Binding/CurationLoader.cs @@ -1,13 +1,12 @@ using System.IO.Abstractions; using System.Text.Json; -using System.Text.Json.Serialization; using OpenCode.Sdk.Tools.Generator.Binding.Models; +using OpenCode.Sdk.Tools.Serialization; namespace OpenCode.Sdk.Tools.Generator.Binding; internal sealed class CurationLoader(IFileSystem fileSystem) { - private static readonly JsonSerializerOptions SerializerOptions = CreateSerializerOptions(); private readonly IFileSystem _fileSystem = fileSystem ?? throw new ArgumentNullException(nameof(fileSystem)); public async Task LoadAsync(string curationPath, CancellationToken cancellationToken) @@ -24,8 +23,11 @@ public async Task LoadAsync(string curationPath, Cancellatio { try { - return await JsonSerializer.DeserializeAsync(stream, SerializerOptions, cancellationToken) - .ConfigureAwait(false) + return await JsonSerializer.DeserializeAsync( + stream, + ToolJsonContext.Default.GenerationCuration, + cancellationToken) + .ConfigureAwait(false) ?? throw CreateException(curationPath, "curation document cannot be JSON null"); } catch (JsonException exception) @@ -35,19 +37,6 @@ public async Task LoadAsync(string curationPath, Cancellatio } } - private static JsonSerializerOptions CreateSerializerOptions() - { - var options = new JsonSerializerOptions - { - AllowTrailingCommas = true, - ReadCommentHandling = JsonCommentHandling.Skip, - UnmappedMemberHandling = JsonUnmappedMemberHandling.Disallow, - }; - options.Converters.Add(new JsonStringEnumConverter(JsonNamingPolicy.CamelCase)); - options.Converters.Add(new JsonStringEnumConverter(JsonNamingPolicy.CamelCase)); - return options; - } - private static BindingException CreateException(string subject, string problem) => new(Array.AsReadOnly([new BindingError(BindingErrorCategory.Curation, subject, problem)])); } diff --git a/tools/OpenCode.Sdk.Tools/Generator/Emission/EmissionSyntax.cs b/tools/OpenCode.Sdk.Tools/Generator/Emission/EmissionSyntax.cs index 25e1d647..51ca8b8d 100644 --- a/tools/OpenCode.Sdk.Tools/Generator/Emission/EmissionSyntax.cs +++ b/tools/OpenCode.Sdk.Tools/Generator/Emission/EmissionSyntax.cs @@ -108,6 +108,74 @@ public static InvocationExpressionSyntax Invocation(ExpressionSyntax expression, SyntaxFactory.InvocationExpression(expression ?? throw new ArgumentNullException(nameof(expression))) .WithArgumentList(SyntaxFactory.ArgumentList(SyntaxFactory.SeparatedList(arguments))); + public static IReadOnlyList ArgumentNullGuard(string parameterName) + { + ArgumentException.ThrowIfNullOrWhiteSpace(parameterName); + var parameter = SyntaxFactory.IdentifierName(parameterName); + var modern = SyntaxFactory.ExpressionStatement(Invocation( + MemberAccess(SyntaxFactory.IdentifierName("ArgumentNullException"), "ThrowIfNull"), + SyntaxFactory.Argument(parameter))) + .WithLeadingTrivia(IfModernRuntime()); + var fallback = SyntaxFactory.IfStatement( + SyntaxFactory.IsPatternExpression( + parameter, + SyntaxFactory.ConstantPattern(SyntaxFactory.LiteralExpression(SyntaxKind.NullLiteralExpression))), + SyntaxFactory.Block(SyntaxFactory.ThrowStatement( + SyntaxFactory.ObjectCreationExpression(SyntaxFactory.IdentifierName("ArgumentNullException")) + .WithArgumentList(SyntaxFactory.ArgumentList(SyntaxFactory.SingletonSeparatedList( + SyntaxFactory.Argument(NameOf(parameterName)))))))) + .WithLeadingTrivia(ElseRuntime()) + .WithTrailingTrivia(EndIfRuntime()); + return Array.AsReadOnly([modern, fallback]); + } + + public static IReadOnlyList ArgumentNullOrEmptyGuard(string parameterName) + { + ArgumentException.ThrowIfNullOrWhiteSpace(parameterName); + var parameter = SyntaxFactory.IdentifierName(parameterName); + var modern = SyntaxFactory.ExpressionStatement(Invocation( + MemberAccess(SyntaxFactory.IdentifierName("ArgumentException"), "ThrowIfNullOrEmpty"), + SyntaxFactory.Argument(parameter))) + .WithLeadingTrivia(IfModernRuntime()); + var fallback = SyntaxFactory.IfStatement( + Invocation( + MemberAccess(SyntaxFactory.IdentifierName("string"), "IsNullOrEmpty"), + SyntaxFactory.Argument(parameter)), + SyntaxFactory.Block(SyntaxFactory.ThrowStatement( + SyntaxFactory.ObjectCreationExpression(SyntaxFactory.IdentifierName("ArgumentException")) + .WithArgumentList(SyntaxFactory.ArgumentList(SyntaxFactory.SeparatedList( + [ + SyntaxFactory.Argument(SyntaxFactory.LiteralExpression( + SyntaxKind.StringLiteralExpression, + SyntaxFactory.Literal("Value cannot be null or empty."))), + SyntaxFactory.Argument(NameOf(parameterName)), + ])))))) + .WithLeadingTrivia(ElseRuntime()) + .WithTrailingTrivia(EndIfRuntime()); + return Array.AsReadOnly([modern, fallback]); + } + + private static InvocationExpressionSyntax NameOf(string parameterName) => Invocation( + SyntaxFactory.IdentifierName("nameof"), + SyntaxFactory.Argument(SyntaxFactory.IdentifierName(parameterName))); + + private static SyntaxTriviaList IfModernRuntime() => SyntaxFactory.TriviaList( + SyntaxFactory.Trivia(SyntaxFactory.IfDirectiveTrivia( + SyntaxFactory.IdentifierName("NET8_0_OR_GREATER"), + isActive: true, + branchTaken: true, + conditionValue: true)), + LineFeed); + + private static SyntaxTriviaList ElseRuntime() => SyntaxFactory.TriviaList( + SyntaxFactory.Trivia(SyntaxFactory.ElseDirectiveTrivia(isActive: true, branchTaken: false)), + LineFeed); + + private static SyntaxTriviaList EndIfRuntime() => SyntaxFactory.TriviaList( + LineFeed, + SyntaxFactory.Trivia(SyntaxFactory.EndIfDirectiveTrivia(isActive: true)), + LineFeed); + private static string NormalizeDocumentation(string value) { var result = new StringBuilder(value.Length); diff --git a/tools/OpenCode.Sdk.Tools/Generator/Emission/ModelEmitter.cs b/tools/OpenCode.Sdk.Tools/Generator/Emission/ModelEmitter.cs index bd0a9b9e..3bd9b46d 100644 --- a/tools/OpenCode.Sdk.Tools/Generator/Emission/ModelEmitter.cs +++ b/tools/OpenCode.Sdk.Tools/Generator/Emission/ModelEmitter.cs @@ -102,7 +102,9 @@ private static PropertyDeclarationSyntax EmitProperty(ModelPropertyPlan property declaration = declaration .WithModifiers(SyntaxFactory.TokenList(modifiers)) - .WithAccessorList(property.Type.IsCollection ? EmitCollectionAccessors(property.Type) : EmitAutoAccessors()); + .WithAccessorList(property.Type.IsCollection + ? EmitCollectionAccessors(property.Type, property.IsRequired) + : EmitAutoAccessors()); var initializer = EmitCollectionInitializer(property.Type); return initializer is null ? declaration @@ -118,7 +120,7 @@ private static AccessorListSyntax EmitAutoAccessors() => SyntaxFactory.AccessorL .WithSemicolonToken(SyntaxFactory.Token(SyntaxKind.SemicolonToken)), ])); - private static AccessorListSyntax EmitCollectionAccessors(TypeReferencePlan type) + private static AccessorListSyntax EmitCollectionAccessors(TypeReferencePlan type, bool isRequired) { var copy = EmitCollectionCopy(type, SyntaxFactory.IdentifierName("value")); StatementSyntax assignment; @@ -135,11 +137,26 @@ private static AccessorListSyntax EmitCollectionAccessors(TypeReferencePlan type SyntaxFactory.LiteralExpression(SyntaxKind.NullLiteralExpression), copy))); } + else if (!isRequired) + { + var empty = EmitCollectionInitializer(type) + ?? throw new InvalidOperationException("A non-null collection plan has no empty initializer."); + assignment = SyntaxFactory.ExpressionStatement(SyntaxFactory.AssignmentExpression( + SyntaxKind.SimpleAssignmentExpression, + SyntaxFactory.IdentifierName("field"), + SyntaxFactory.ConditionalExpression( + SyntaxFactory.IsPatternExpression( + SyntaxFactory.IdentifierName("value"), + SyntaxFactory.ConstantPattern(SyntaxFactory.LiteralExpression(SyntaxKind.NullLiteralExpression))), + empty, + copy))) + .WithLeadingTrivia( + SyntaxFactory.Comment("// Source-generated deserialization passes null for an absent optional init-only collection."), + SyntaxFactory.EndOfLine("\n")); + } else { - statements.Add(SyntaxFactory.ExpressionStatement(EmissionSyntax.Invocation( - EmissionSyntax.MemberAccess(SyntaxFactory.IdentifierName("ArgumentNullException"), "ThrowIfNull"), - SyntaxFactory.Argument(SyntaxFactory.IdentifierName("value"))))); + statements.AddRange(EmissionSyntax.ArgumentNullGuard("value")); assignment = SyntaxFactory.ExpressionStatement(SyntaxFactory.AssignmentExpression( SyntaxKind.SimpleAssignmentExpression, SyntaxFactory.IdentifierName("field"), @@ -176,28 +193,44 @@ private static AccessorListSyntax EmitCollectionAccessors(TypeReferencePlan type private static ExpressionSyntax EmitCollectionCopy(TypeReferencePlan type, ExpressionSyntax value) => type switch { - ListTypeReferencePlan list => EmissionSyntax.Invocation( - EmissionSyntax.MemberAccess( - SyntaxFactory.ObjectCreationExpression(TypeSyntaxEmitter.Generic("List", TypeSyntaxEmitter.Emit(list.ElementType))) - .WithArgumentList(SyntaxFactory.ArgumentList( - SyntaxFactory.SingletonSeparatedList(SyntaxFactory.Argument(value)))), - "AsReadOnly")), + ListTypeReferencePlan list => EmitListCopy(list, value), DictionaryTypeReferencePlan dictionary => EmitDictionaryCopy(dictionary, value), _ => throw new InvalidOperationException($"Unknown collection plan '{type.GetType().Name}'."), }; + private static InvocationExpressionSyntax EmitListCopy(ListTypeReferencePlan list, ExpressionSyntax value) + { + var source = list.ElementType.IsCollection + ? EmissionSyntax.Invocation( + EmissionSyntax.MemberAccess(value, "Select"), + SyntaxFactory.Argument(Projection( + "element", + SyntaxFactory.CastExpression( + TypeSyntaxEmitter.Emit(list.ElementType), + EmitNestedCollectionCopy(list.ElementType, SyntaxFactory.IdentifierName("element")))))) + : value; + return EmissionSyntax.Invocation( + EmissionSyntax.MemberAccess( + SyntaxFactory.ObjectCreationExpression(TypeSyntaxEmitter.Generic("List", TypeSyntaxEmitter.Emit(list.ElementType))) + .WithArgumentList(SyntaxFactory.ArgumentList( + SyntaxFactory.SingletonSeparatedList(SyntaxFactory.Argument(source)))), + "AsReadOnly")); + } + private static ObjectCreationExpressionSyntax EmitDictionaryCopy(DictionaryTypeReferencePlan dictionary, ExpressionSyntax value) { - var dictionaryType = TypeSyntaxEmitter.Generic( - "Dictionary", - SyntaxFactory.PredefinedType(SyntaxFactory.Token(SyntaxKind.StringKeyword)), - TypeSyntaxEmitter.Emit(dictionary.ValueType)); - var mutableCopy = SyntaxFactory.ObjectCreationExpression(dictionaryType) - .WithArgumentList(SyntaxFactory.ArgumentList(SyntaxFactory.SeparatedList( - [ - SyntaxFactory.Argument(value), - SyntaxFactory.Argument(EmissionSyntax.MemberAccess(SyntaxFactory.IdentifierName("StringComparer"), "Ordinal")), - ]))); + var pair = SyntaxFactory.IdentifierName("pair"); + var mutableCopy = EmissionSyntax.Invocation( + EmissionSyntax.MemberAccess(value, "ToDictionary"), + SyntaxFactory.Argument(Projection("pair", EmissionSyntax.MemberAccess(pair, "Key"))), + SyntaxFactory.Argument(Projection( + "pair", + dictionary.ValueType.IsCollection + ? SyntaxFactory.CastExpression( + TypeSyntaxEmitter.Emit(dictionary.ValueType), + EmitNestedCollectionCopy(dictionary.ValueType, EmissionSyntax.MemberAccess(pair, "Value"))) + : EmissionSyntax.MemberAccess(pair, "Value"))), + SyntaxFactory.Argument(EmissionSyntax.MemberAccess(SyntaxFactory.IdentifierName("StringComparer"), "Ordinal"))); var readOnlyType = TypeSyntaxEmitter.Generic( "ReadOnlyDictionary", SyntaxFactory.PredefinedType(SyntaxFactory.Token(SyntaxKind.StringKeyword)), @@ -207,6 +240,25 @@ private static ObjectCreationExpressionSyntax EmitDictionaryCopy(DictionaryTypeR SyntaxFactory.SingletonSeparatedList(SyntaxFactory.Argument(mutableCopy)))); } + private static ExpressionSyntax EmitNestedCollectionCopy(TypeReferencePlan type, ExpressionSyntax value) + { + var copy = EmitCollectionCopy(type, value); + return type.IsNullable + ? SyntaxFactory.ConditionalExpression( + SyntaxFactory.IsPatternExpression( + value, + SyntaxFactory.ConstantPattern(SyntaxFactory.LiteralExpression(SyntaxKind.NullLiteralExpression))), + SyntaxFactory.LiteralExpression(SyntaxKind.NullLiteralExpression), + copy) + : copy; + } + + private static SimpleLambdaExpressionSyntax Projection(string parameterName, ExpressionSyntax expression) => + SyntaxFactory.SimpleLambdaExpression( + SyntaxFactory.Parameter(SyntaxFactory.Identifier(parameterName)), + expression) + .WithModifiers(SyntaxFactory.TokenList(SyntaxFactory.Token(SyntaxKind.StaticKeyword))); + private static ObjectCreationExpressionSyntax EmitEmptyDictionary(DictionaryTypeReferencePlan dictionary) { var dictionaryType = TypeSyntaxEmitter.Generic( @@ -264,12 +316,18 @@ private static void CollectTypeUsings(TypeReferencePlan type, ISet using case ListTypeReferencePlan list: _ = usings.Add("System"); _ = usings.Add("System.Collections.Generic"); + if (list.ElementType.IsCollection) + { + _ = usings.Add("System.Linq"); + } + CollectTypeUsings(list.ElementType, usings); break; case DictionaryTypeReferencePlan dictionary: _ = usings.Add("System"); _ = usings.Add("System.Collections.Generic"); _ = usings.Add("System.Collections.ObjectModel"); + _ = usings.Add("System.Linq"); CollectTypeUsings(dictionary.ValueType, usings); break; } diff --git a/tools/OpenCode.Sdk.Tools/Generator/Emission/RegistryEmitter.cs b/tools/OpenCode.Sdk.Tools/Generator/Emission/RegistryEmitter.cs index e676223b..d4232da3 100644 --- a/tools/OpenCode.Sdk.Tools/Generator/Emission/RegistryEmitter.cs +++ b/tools/OpenCode.Sdk.Tools/Generator/Emission/RegistryEmitter.cs @@ -21,7 +21,9 @@ public static IReadOnlyList Emit(RegistryPlan plan) SyntaxFactory.AttributeArgument(EmissionSyntax.MemberAccess( SyntaxFactory.IdentifierName("JsonSourceGenerationMode"), "Metadata")) - .WithNameEquals(SyntaxFactory.NameEquals("GenerationMode")))); + .WithNameEquals(SyntaxFactory.NameEquals("GenerationMode")), + SyntaxFactory.AttributeArgument(SyntaxFactory.LiteralExpression(SyntaxKind.TrueLiteralExpression)) + .WithNameEquals(SyntaxFactory.NameEquals("RespectNullableAnnotations")))); foreach (var typeName in plan.TypeNames.Order(StringComparer.Ordinal)) { declaration = declaration.AddAttributeLists(EmissionSyntax.Attribute( diff --git a/tools/OpenCode.Sdk.Tools/Generator/Emission/UnionEmitter.cs b/tools/OpenCode.Sdk.Tools/Generator/Emission/UnionEmitter.cs index ebefcf90..efaf6e01 100644 --- a/tools/OpenCode.Sdk.Tools/Generator/Emission/UnionEmitter.cs +++ b/tools/OpenCode.Sdk.Tools/Generator/Emission/UnionEmitter.cs @@ -93,9 +93,7 @@ private static ConstructorDeclarationSyntax EmitUnknownConstructor(UnionPlan uni var statements = new List(); if (union.MarkerKind is LiteralKind.String) { - statements.Add(SyntaxFactory.ExpressionStatement(EmissionSyntax.Invocation( - EmissionSyntax.MemberAccess(SyntaxFactory.IdentifierName("ArgumentException"), "ThrowIfNullOrEmpty"), - SyntaxFactory.Argument(SyntaxFactory.IdentifierName(markerParameterName))))); + statements.AddRange(EmissionSyntax.ArgumentNullOrEmptyGuard(markerParameterName)); } statements.Add(SyntaxFactory.ExpressionStatement(SyntaxFactory.AssignmentExpression( @@ -206,10 +204,11 @@ private static PropertyDeclarationSyntax EmitHandleNull() => private static MethodDeclarationSyntax EmitRead(UnionPlan union) { - var statements = new List - { - Guard("typeToConvert"), - Guard("options"), + var statements = new List(); + statements.AddRange(EmissionSyntax.ArgumentNullGuard("typeToConvert")); + statements.AddRange(EmissionSyntax.ArgumentNullGuard("options")); + statements.AddRange( + [ SyntaxFactory.IfStatement( SyntaxFactory.BinaryExpression( SyntaxKind.EqualsExpression, @@ -218,8 +217,9 @@ private static MethodDeclarationSyntax EmitRead(UnionPlan union) ThrowJson($"The {union.Name} payload cannot be null.")), EmitPayloadDocument(), Local("payload", EmissionSyntax.MemberAccess(SyntaxFactory.IdentifierName("document"), "RootElement")), + EmitObjectPayloadCheck(union), EmitMarkerPresenceCheck(union), - }; + ]); statements.AddRange(EmitMarkerRead(union)); statements.Add(EmitKnownDispatch(union)); statements.Add(SyntaxFactory.ReturnStatement(SyntaxFactory.ObjectCreationExpression( @@ -257,6 +257,14 @@ private static LocalDeclarationStatementSyntax EmitPayloadDocument() .WithUsingKeyword(SyntaxFactory.Token(SyntaxKind.UsingKeyword)); } + private static IfStatementSyntax EmitObjectPayloadCheck(UnionPlan union) => + SyntaxFactory.IfStatement( + SyntaxFactory.BinaryExpression( + SyntaxKind.NotEqualsExpression, + EmissionSyntax.MemberAccess(SyntaxFactory.IdentifierName("payload"), "ValueKind"), + EmissionSyntax.MemberAccess(SyntaxFactory.IdentifierName("JsonValueKind"), "Object")), + ThrowJson($"The {union.Name} payload must be a JSON object.")); + private static IfStatementSyntax EmitMarkerPresenceCheck(UnionPlan union) { var tryGet = EmissionSyntax.Invocation( @@ -383,6 +391,13 @@ private static MethodDeclarationSyntax EmitWrite(UnionPlan union) SyntaxFactory.Argument(SyntaxFactory.IdentifierName("writer")), SyntaxFactory.Argument(SyntaxFactory.IdentifierName("value")), SyntaxFactory.Argument(SyntaxFactory.IdentifierName("typeInfo"))); + var statements = new List(); + statements.AddRange(EmissionSyntax.ArgumentNullGuard("writer")); + statements.AddRange(EmissionSyntax.ArgumentNullGuard("value")); + statements.AddRange(EmissionSyntax.ArgumentNullGuard("options")); + statements.Add(SyntaxFactory.IfStatement(unknownPattern, writeUnknown)); + statements.Add(Local("typeInfo", typeInfo)); + statements.Add(SyntaxFactory.ExpressionStatement(serialize)); return SyntaxFactory.MethodDeclaration( SyntaxFactory.PredefinedType(SyntaxFactory.Token(SyntaxKind.VoidKeyword)), "Write") @@ -395,20 +410,9 @@ private static MethodDeclarationSyntax EmitWrite(UnionPlan union) SyntaxFactory.Parameter(SyntaxFactory.Identifier("value")).WithType(TypeSyntaxEmitter.EmitNamed(union.Name)), SyntaxFactory.Parameter(SyntaxFactory.Identifier("options")).WithType(SyntaxFactory.IdentifierName("JsonSerializerOptions")), ]))) - .WithBody(SyntaxFactory.Block( - Guard("writer"), - Guard("value"), - Guard("options"), - SyntaxFactory.IfStatement(unknownPattern, writeUnknown), - Local("typeInfo", typeInfo), - SyntaxFactory.ExpressionStatement(serialize))); + .WithBody(SyntaxFactory.Block(statements)); } - private static ExpressionStatementSyntax Guard(string parameterName) => - SyntaxFactory.ExpressionStatement(EmissionSyntax.Invocation( - EmissionSyntax.MemberAccess(SyntaxFactory.IdentifierName("ArgumentNullException"), "ThrowIfNull"), - SyntaxFactory.Argument(SyntaxFactory.IdentifierName(parameterName)))); - private static LocalDeclarationStatementSyntax Local(string name, ExpressionSyntax value) => SyntaxFactory.LocalDeclarationStatement(SyntaxFactory.VariableDeclaration(SyntaxFactory.IdentifierName("var")) .WithVariables(SyntaxFactory.SingletonSeparatedList( diff --git a/tools/OpenCode.Sdk.Tools/Generator/GenerationCoordinator.cs b/tools/OpenCode.Sdk.Tools/Generator/GenerationCoordinator.cs new file mode 100644 index 00000000..a27c09e4 --- /dev/null +++ b/tools/OpenCode.Sdk.Tools/Generator/GenerationCoordinator.cs @@ -0,0 +1,83 @@ +using System.Globalization; +using System.Text; +using OpenCode.Sdk.Tools.Generator.Binding; +using OpenCode.Sdk.Tools.Generator.Binding.Abstractions; +using OpenCode.Sdk.Tools.Generator.Emission; +using OpenCode.Sdk.Tools.Generator.Ingestion.Abstractions; +using OpenCode.Sdk.Tools.Generator.Ingestion.Models; +using OpenCode.Sdk.Tools.Output.Abstractions; + +namespace OpenCode.Sdk.Tools.Generator; + +internal sealed class GenerationCoordinator( + ISpecIngestion ingestion, + OperationSelectionLoader selectionLoader, + CurationLoader curationLoader, + ISpecBinder binder, + IGenerationWriter writer) +{ + private readonly ISpecBinder _binder = binder ?? throw new ArgumentNullException(nameof(binder)); + private readonly CurationLoader _curationLoader = curationLoader ?? throw new ArgumentNullException(nameof(curationLoader)); + private readonly ISpecIngestion _ingestion = ingestion ?? throw new ArgumentNullException(nameof(ingestion)); + private readonly OperationSelectionLoader _selectionLoader = selectionLoader ?? throw new ArgumentNullException(nameof(selectionLoader)); + private readonly IGenerationWriter _writer = writer ?? throw new ArgumentNullException(nameof(writer)); + + public async Task GenerateAsync(GenerationRequest request, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(request); + var document = await _ingestion.IngestAsync(request.SpecPath, cancellationToken).ConfigureAwait(false); + var selection = await _selectionLoader.LoadAsync(request.ProfilePath, cancellationToken).ConfigureAwait(false); + var curation = await _curationLoader.LoadAsync(request.CurationPath, cancellationToken).ConfigureAwait(false); + var plan = _binder.Bind(document, selection, curation); + var modern = plan.PendingOperations + .Where(static operation => operation.Surface == SpecSurface.Modern) + .Select(static operation => operation.OperationId) + .Order(StringComparer.Ordinal) + .ToArray(); + var legacy = plan.PendingOperations + .Where(static operation => operation.Surface == SpecSurface.Legacy) + .Select(static operation => operation.OperationId) + .Order(StringComparer.Ordinal) + .ToArray(); + var marker = modern.Length + legacy.Length > 0 ? CreatePartialMarker(plan.SelectedOperationIds, modern, legacy) : null; + var writeResult = await _writer.WriteAsync( + request.OutputRoot, + request.ProjectPath, + SourceEmitter.Emit(plan), + marker, + request.Verify, + cancellationToken).ConfigureAwait(false); + return new GenerationReport + { + SelectedOperationIds = plan.SelectedOperationIds, + PendingModernOperationIds = modern, + PendingLegacyOperationIds = legacy, + WriteResult = writeResult, + }; + } + + private static string CreatePartialMarker(IReadOnlyList selected, string[] modern, string[] legacy) + { + var content = new StringBuilder() + .AppendLine("Generation is incomplete; packages must not be published.") + .Append("Selected operations: ") + .AppendLine(selected.Count.ToString(CultureInfo.InvariantCulture)) + .Append("Pending modern operations: ") + .AppendLine(modern.Length.ToString(CultureInfo.InvariantCulture)) + .Append("Pending legacy operations: ") + .AppendLine(legacy.Length.ToString(CultureInfo.InvariantCulture)); + AppendOperations(content, "Selected", selected); + AppendOperations(content, "Pending modern", modern); + AppendOperations(content, "Pending legacy", legacy); + return content.ToString().ReplaceLineEndings("\n"); + } + + private static void AppendOperations(StringBuilder content, string heading, IEnumerable operationIds) + { + _ = content.Append(heading).AppendLine(":"); + foreach (var operationId in operationIds) + { + _ = content.Append("- ").AppendLine(operationId); + } + } +} diff --git a/tools/OpenCode.Sdk.Tools/Generator/GenerationReport.cs b/tools/OpenCode.Sdk.Tools/Generator/GenerationReport.cs new file mode 100644 index 00000000..091a74f5 --- /dev/null +++ b/tools/OpenCode.Sdk.Tools/Generator/GenerationReport.cs @@ -0,0 +1,38 @@ +using OpenCode.Sdk.Tools.Output; + +namespace OpenCode.Sdk.Tools.Generator; + +internal sealed record GenerationReport +{ + public required IReadOnlyList SelectedOperationIds + { + get; + init + { + ArgumentNullException.ThrowIfNull(value); + field = Array.AsReadOnly([.. value]); + } + } = Array.AsReadOnly(Array.Empty()); + + public required IReadOnlyList PendingModernOperationIds + { + get; + init + { + ArgumentNullException.ThrowIfNull(value); + field = Array.AsReadOnly([.. value]); + } + } = Array.AsReadOnly(Array.Empty()); + + public required IReadOnlyList PendingLegacyOperationIds + { + get; + init + { + ArgumentNullException.ThrowIfNull(value); + field = Array.AsReadOnly([.. value]); + } + } = Array.AsReadOnly(Array.Empty()); + + public required WriteResult WriteResult { get; init; } +} diff --git a/tools/OpenCode.Sdk.Tools/Generator/GenerationRequest.cs b/tools/OpenCode.Sdk.Tools/Generator/GenerationRequest.cs new file mode 100644 index 00000000..69a9ee31 --- /dev/null +++ b/tools/OpenCode.Sdk.Tools/Generator/GenerationRequest.cs @@ -0,0 +1,27 @@ +namespace OpenCode.Sdk.Tools.Generator; + +internal sealed record GenerationRequest +{ + public required string SpecPath { get; init; } + + public required string ProfilePath { get; init; } + + public required string CurationPath { get; init; } + + public required string OutputRoot { get; init; } + + public required string ProjectPath { get; init; } + + public required bool Verify { get; init; } + + public static GenerationRequest RepositoryDefault(bool verify) => + new() + { + SpecPath = "spec/openapi.json", + ProfilePath = "tools/generation-profile.txt", + CurationPath = "tools/curation.json", + OutputRoot = "src/OpenCode.Sdk", + ProjectPath = "src/OpenCode.Sdk/OpenCode.Sdk.csproj", + Verify = verify, + }; +} diff --git a/tools/OpenCode.Sdk.Tools/OpenCode.Sdk.Tools.csproj b/tools/OpenCode.Sdk.Tools/OpenCode.Sdk.Tools.csproj index 5555683f..635f4a8f 100644 --- a/tools/OpenCode.Sdk.Tools/OpenCode.Sdk.Tools.csproj +++ b/tools/OpenCode.Sdk.Tools/OpenCode.Sdk.Tools.csproj @@ -8,6 +8,7 @@ + diff --git a/tools/OpenCode.Sdk.Tools/Output/Abstractions/IGenerationWriter.cs b/tools/OpenCode.Sdk.Tools/Output/Abstractions/IGenerationWriter.cs new file mode 100644 index 00000000..96b1a9cd --- /dev/null +++ b/tools/OpenCode.Sdk.Tools/Output/Abstractions/IGenerationWriter.cs @@ -0,0 +1,14 @@ +using OpenCode.Sdk.Tools.Generator.Emission; + +namespace OpenCode.Sdk.Tools.Output.Abstractions; + +internal interface IGenerationWriter +{ + public Task WriteAsync( + string outputRoot, + string projectPath, + IReadOnlyList sources, + string? partialMarkerContent, + bool verify, + CancellationToken cancellationToken); +} diff --git a/tools/OpenCode.Sdk.Tools/Output/Abstractions/IProjectFormatter.cs b/tools/OpenCode.Sdk.Tools/Output/Abstractions/IProjectFormatter.cs new file mode 100644 index 00000000..d7842181 --- /dev/null +++ b/tools/OpenCode.Sdk.Tools/Output/Abstractions/IProjectFormatter.cs @@ -0,0 +1,9 @@ +namespace OpenCode.Sdk.Tools.Output.Abstractions; + +internal interface IProjectFormatter +{ + public Task FormatAsync( + string projectPath, + IReadOnlyList sourcePaths, + CancellationToken cancellationToken); +} diff --git a/tools/OpenCode.Sdk.Tools/Output/CliWrapProjectFormatter.cs b/tools/OpenCode.Sdk.Tools/Output/CliWrapProjectFormatter.cs new file mode 100644 index 00000000..99408966 --- /dev/null +++ b/tools/OpenCode.Sdk.Tools/Output/CliWrapProjectFormatter.cs @@ -0,0 +1,46 @@ +using System.IO.Abstractions; +using CliWrap; +using OpenCode.Sdk.Tools.Output.Abstractions; + +namespace OpenCode.Sdk.Tools.Output; + +internal sealed class CliWrapProjectFormatter(IFileSystem fileSystem) : IProjectFormatter +{ + private readonly IFileSystem _fileSystem = fileSystem ?? throw new ArgumentNullException(nameof(fileSystem)); + + public async Task FormatAsync( + string projectPath, + IReadOnlyList sourcePaths, + CancellationToken cancellationToken) + { + ArgumentException.ThrowIfNullOrWhiteSpace(projectPath); + ArgumentNullException.ThrowIfNull(sourcePaths); + if (sourcePaths.Count is 0) + { + return; + } + + if (sourcePaths.Any(static sourcePath => string.IsNullOrWhiteSpace(sourcePath))) + { + throw new ArgumentException("Source paths cannot contain null or whitespace entries.", nameof(sourcePaths)); + } + + var fullProjectPath = _fileSystem.Path.GetFullPath(projectPath); + var projectDirectory = _fileSystem.Path.GetDirectoryName(fullProjectPath) + ?? throw new InvalidOperationException($"Project path '{projectPath}' has no parent directory."); + var projectFileName = _fileSystem.Path.GetFileName(fullProjectPath); + + _ = await Cli.Wrap("dotnet") + .WithArguments(arguments => arguments + .Add("format") + .Add(projectFileName) + .Add("--no-restore") + .Add("--include") + .Add(sourcePaths)) + .WithWorkingDirectory(projectDirectory) + .WithEnvironmentVariables(environment => environment.Set("TargetFramework", "net10.0")) + .WithValidation(CommandResultValidation.ZeroExitCode) + .ExecuteAsync(cancellationToken) + .ConfigureAwait(false); + } +} diff --git a/tools/OpenCode.Sdk.Tools/Output/GenerationManifest.cs b/tools/OpenCode.Sdk.Tools/Output/GenerationManifest.cs new file mode 100644 index 00000000..36a64ca8 --- /dev/null +++ b/tools/OpenCode.Sdk.Tools/Output/GenerationManifest.cs @@ -0,0 +1,17 @@ +using System.Text.Json.Serialization; + +namespace OpenCode.Sdk.Tools.Output; + +internal sealed record GenerationManifest +{ + [JsonPropertyName("files")] + public required IReadOnlyList Files + { + get; + init + { + ArgumentNullException.ThrowIfNull(value); + field = Array.AsReadOnly([.. value]); + } + } = Array.AsReadOnly(Array.Empty()); +} diff --git a/tools/OpenCode.Sdk.Tools/Output/GenerationWriter.cs b/tools/OpenCode.Sdk.Tools/Output/GenerationWriter.cs new file mode 100644 index 00000000..99d36b7c --- /dev/null +++ b/tools/OpenCode.Sdk.Tools/Output/GenerationWriter.cs @@ -0,0 +1,303 @@ +using System.IO.Abstractions; +using System.Text; +using System.Text.Json; +using OpenCode.Sdk.Tools.Generator.Emission; +using OpenCode.Sdk.Tools.Output.Abstractions; +using OpenCode.Sdk.Tools.Serialization; + +namespace OpenCode.Sdk.Tools.Output; + +internal sealed class GenerationWriter(IFileSystem fileSystem, IProjectFormatter formatter) : IGenerationWriter +{ + private const string ManifestRelativePath = ".generated-manifest.json"; + private const string MarkerRelativePath = ".generation-incomplete"; + private static readonly StringComparer OwnedPathComparer = StringComparer.OrdinalIgnoreCase; + private readonly IFileSystem _fileSystem = fileSystem ?? throw new ArgumentNullException(nameof(fileSystem)); + private readonly IProjectFormatter _formatter = formatter ?? throw new ArgumentNullException(nameof(formatter)); + + public async Task WriteAsync( + string outputRoot, + string projectPath, + IReadOnlyList sources, + string? partialMarkerContent, + bool verify, + CancellationToken cancellationToken) + { + ArgumentException.ThrowIfNullOrWhiteSpace(outputRoot); + ArgumentException.ThrowIfNullOrWhiteSpace(projectPath); + ArgumentNullException.ThrowIfNull(sources); + if (partialMarkerContent is not null) + { + ArgumentException.ThrowIfNullOrWhiteSpace(partialMarkerContent); + } + + cancellationToken.ThrowIfCancellationRequested(); + var newSources = ValidateSources(outputRoot, sources); + var oldManifest = await LoadManifestAsync(outputRoot, cancellationToken).ConfigureAwait(false); + var oldPaths = ValidateManifest(outputRoot, oldManifest); + RefuseCaseOnlyPathChanges(oldPaths, newSources.Keys); + var trackedPaths = oldPaths + .Concat(newSources.Keys) + .Append(ManifestRelativePath) + .Append(MarkerRelativePath) + .Distinct(OwnedPathComparer) + .Order(StringComparer.Ordinal) + .ToArray(); + var before = await SnapshotAsync(outputRoot, trackedPaths, cancellationToken).ConfigureAwait(false); + + await WriteSourcesAsync(outputRoot, newSources, cancellationToken).ConfigureAwait(false); + DeleteStaleSources(outputRoot, oldPaths, newSources.Keys); + await WriteManifestAsync(outputRoot, newSources.Keys, cancellationToken).ConfigureAwait(false); + await WriteMarkerAsync(outputRoot, partialMarkerContent, cancellationToken).ConfigureAwait(false); + var sourcePaths = newSources.Keys.Order(StringComparer.Ordinal).ToArray(); + await _formatter.FormatAsync(projectPath, sourcePaths, cancellationToken).ConfigureAwait(false); + + var after = await SnapshotAsync(outputRoot, trackedPaths, cancellationToken).ConfigureAwait(false); + return Compare(before, after, verify); + } + + private Dictionary ValidateSources(string outputRoot, IReadOnlyList sources) + { + var result = new Dictionary(OwnedPathComparer); + foreach (var source in sources) + { + if (source is null) + { + throw new ArgumentException("Generated sources cannot contain null entries.", nameof(sources)); + } + + ValidateRelativePath(outputRoot, source.RelativePath); + ValidateGeneratedSourcePath(source.RelativePath); + + if (!result.TryAdd(source.RelativePath, source)) + { + throw new InvalidOperationException($"Generated source path '{source.RelativePath}' is duplicated."); + } + } + + return result; + } + + private async Task LoadManifestAsync(string outputRoot, CancellationToken cancellationToken) + { + var path = ResolveOwnedPath(outputRoot, ManifestRelativePath); + if (!_fileSystem.File.Exists(path)) + { + return new GenerationManifest { Files = [], }; + } + + try + { + var json = await _fileSystem.File.ReadAllTextAsync(path, cancellationToken).ConfigureAwait(false); + return JsonSerializer.Deserialize(json, ToolJsonContext.Default.GenerationManifest) + ?? throw new InvalidOperationException("The generation manifest cannot be JSON null."); + } + catch (JsonException exception) + { + throw new InvalidOperationException("The generation manifest is invalid.", exception); + } + } + + private HashSet ValidateManifest(string outputRoot, GenerationManifest manifest) + { + var result = new HashSet(OwnedPathComparer); + foreach (var relativePath in manifest.Files) + { + ValidateRelativePath(outputRoot, relativePath); + ValidateGeneratedSourcePath(relativePath); + if (!result.Add(relativePath)) + { + throw new InvalidOperationException($"Generation manifest path '{relativePath}' is duplicated."); + } + } + + return result; + } + + private async Task> SnapshotAsync(string outputRoot, IReadOnlyList relativePaths, + CancellationToken cancellationToken) + { + var result = new Dictionary(StringComparer.Ordinal); + foreach (var relativePath in relativePaths) + { + var path = ResolveOwnedPath(outputRoot, relativePath); + result.Add( + relativePath, + _fileSystem.File.Exists(path) + ? await _fileSystem.File.ReadAllBytesAsync(path, cancellationToken).ConfigureAwait(false) + : null); + } + + return result; + } + + private async Task WriteSourcesAsync(string outputRoot, Dictionary sources, + CancellationToken cancellationToken) + { + foreach (var (relativePath, source) in sources.OrderBy(static pair => pair.Key, StringComparer.Ordinal)) + { + var path = ResolveOwnedPath(outputRoot, relativePath); + EnsureParentDirectory(path); + await _fileSystem.File.WriteAllBytesAsync(path, source.Utf8Source.ToArray(), cancellationToken).ConfigureAwait(false); + } + } + + private void DeleteStaleSources(string outputRoot, IReadOnlySet oldPaths, IEnumerable newPaths) + { + var retained = newPaths.ToHashSet(OwnedPathComparer); + foreach (var relativePath in oldPaths.Where(path => !retained.Contains(path)).Order(StringComparer.Ordinal)) + { + var path = ResolveOwnedPath(outputRoot, relativePath); + if (_fileSystem.File.Exists(path)) + { + _fileSystem.File.Delete(path); + } + } + } + + private async Task WriteManifestAsync(string outputRoot, IEnumerable paths, CancellationToken cancellationToken) + { + var manifest = new GenerationManifest + { + Files = [.. paths.Order(StringComparer.Ordinal)], + }; + var json = string.Concat(JsonSerializer.Serialize(manifest, ToolJsonContext.Default.GenerationManifest), "\n"); + await WriteBytesAsync( + ResolveOwnedPath(outputRoot, ManifestRelativePath), + Encoding.UTF8.GetBytes(json), + cancellationToken).ConfigureAwait(false); + } + + private async Task WriteMarkerAsync(string outputRoot, string? content, CancellationToken cancellationToken) + { + var path = ResolveOwnedPath(outputRoot, MarkerRelativePath); + if (content is null) + { + if (_fileSystem.File.Exists(path)) + { + _fileSystem.File.Delete(path); + } + + return; + } + + await WriteBytesAsync(path, Encoding.UTF8.GetBytes(content), cancellationToken).ConfigureAwait(false); + } + + private async Task WriteBytesAsync(string path, byte[] content, CancellationToken cancellationToken) + { + EnsureParentDirectory(path); + await _fileSystem.File.WriteAllBytesAsync(path, content, cancellationToken).ConfigureAwait(false); + } + + private void EnsureParentDirectory(string path) + { + var directory = _fileSystem.Path.GetDirectoryName(path) + ?? throw new InvalidOperationException($"Path '{path}' has no parent directory."); + _fileSystem.Directory.CreateDirectory(directory); + } + + private void ValidateRelativePath(string outputRoot, string relativePath) + { + _ = ResolveOwnedPath(outputRoot, relativePath); + } + + private static void ValidateGeneratedSourcePath(string relativePath) + { + if (!relativePath.EndsWith(".cs", StringComparison.Ordinal) + || !(relativePath.StartsWith("Models/", StringComparison.Ordinal) + || relativePath.StartsWith("Internal/Serialization/", StringComparison.Ordinal))) + { + throw new InvalidOperationException($"Generated source path '{relativePath}' is outside the owned source directories."); + } + } + + private string ResolveOwnedPath(string outputRoot, string relativePath) + { + if (string.IsNullOrWhiteSpace(relativePath) + || _fileSystem.Path.IsPathRooted(relativePath) + || relativePath.Contains('\\', StringComparison.Ordinal)) + { + throw new InvalidOperationException($"Owned path '{relativePath}' is unsafe."); + } + + var segments = relativePath.Split('/'); + if (segments.Any(static segment => string.IsNullOrWhiteSpace(segment) || segment is "." or "..")) + { + throw new InvalidOperationException($"Owned path '{relativePath}' is unsafe."); + } + + var root = _fileSystem.Path.GetFullPath(outputRoot); + var path = _fileSystem.Path.GetFullPath(_fileSystem.Path.Combine([root, .. segments])); + var prefix = string.Concat( + root.TrimEnd(_fileSystem.Path.DirectorySeparatorChar, _fileSystem.Path.AltDirectorySeparatorChar), + _fileSystem.Path.DirectorySeparatorChar); + var pathComparison = OperatingSystem.IsWindows() ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal; + if (!path.StartsWith(prefix, pathComparison)) + { + throw new InvalidOperationException($"Owned path '{relativePath}' escapes the output root."); + } + + RefuseSymbolicLinks(root, segments, relativePath); + + return path; + } + + private static void RefuseCaseOnlyPathChanges(HashSet oldPaths, IEnumerable newPaths) + { + foreach (var newPath in newPaths) + { + if (oldPaths.TryGetValue(newPath, out var oldPath) + && !string.Equals(oldPath, newPath, StringComparison.Ordinal)) + { + throw new InvalidOperationException( + $"Generated source path '{newPath}' differs from owned path '{oldPath}' only by case."); + } + } + } + + private void RefuseSymbolicLinks(string root, IEnumerable segments, string relativePath) + { + var current = root; + foreach (var segment in segments) + { + current = _fileSystem.Path.Combine(current, segment); + if (_fileSystem.FileInfo.New(current).LinkTarget is not null) + { + throw new InvalidOperationException($"Owned path '{relativePath}' contains a symbolic link."); + } + } + } + + private static WriteResult Compare(Dictionary before, Dictionary after, bool verify) + { + var created = new List(); + var changed = new List(); + var deleted = new List(); + foreach (var relativePath in before.Keys.Order(StringComparer.Ordinal)) + { + var oldContent = before[relativePath]; + var newContent = after[relativePath]; + if (oldContent is null && newContent is not null) + { + created.Add(relativePath); + } + else if (oldContent is not null && newContent is null) + { + deleted.Add(relativePath); + } + else if (oldContent is not null && newContent is not null && !oldContent.AsSpan().SequenceEqual(newContent)) + { + changed.Add(relativePath); + } + } + + return new WriteResult + { + IsVerification = verify, + CreatedPaths = created, + ChangedPaths = changed, + DeletedPaths = deleted, + }; + } +} diff --git a/tools/OpenCode.Sdk.Tools/Output/WriteResult.cs b/tools/OpenCode.Sdk.Tools/Output/WriteResult.cs new file mode 100644 index 00000000..35159d5a --- /dev/null +++ b/tools/OpenCode.Sdk.Tools/Output/WriteResult.cs @@ -0,0 +1,38 @@ +namespace OpenCode.Sdk.Tools.Output; + +internal sealed record WriteResult +{ + public required bool IsVerification { get; init; } + + public required IReadOnlyList CreatedPaths + { + get; + init + { + ArgumentNullException.ThrowIfNull(value); + field = Array.AsReadOnly([.. value]); + } + } = Array.AsReadOnly(Array.Empty()); + + public required IReadOnlyList ChangedPaths + { + get; + init + { + ArgumentNullException.ThrowIfNull(value); + field = Array.AsReadOnly([.. value]); + } + } = Array.AsReadOnly(Array.Empty()); + + public required IReadOnlyList DeletedPaths + { + get; + init + { + ArgumentNullException.ThrowIfNull(value); + field = Array.AsReadOnly([.. value]); + } + } = Array.AsReadOnly(Array.Empty()); + + public bool HasChanges => CreatedPaths.Count > 0 || ChangedPaths.Count > 0 || DeletedPaths.Count > 0; +} diff --git a/tools/OpenCode.Sdk.Tools/Serialization/ToolJsonContext.cs b/tools/OpenCode.Sdk.Tools/Serialization/ToolJsonContext.cs new file mode 100644 index 00000000..3d5f3335 --- /dev/null +++ b/tools/OpenCode.Sdk.Tools/Serialization/ToolJsonContext.cs @@ -0,0 +1,17 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using OpenCode.Sdk.Tools.Generator.Binding.Models; +using OpenCode.Sdk.Tools.Output; + +namespace OpenCode.Sdk.Tools.Serialization; + +[JsonSourceGenerationOptions( + AllowTrailingCommas = true, + GenerationMode = JsonSourceGenerationMode.Metadata, + ReadCommentHandling = JsonCommentHandling.Skip, + UnmappedMemberHandling = JsonUnmappedMemberHandling.Disallow, + UseStringEnumConverter = true, + WriteIndented = true)] +[JsonSerializable(typeof(GenerationCuration))] +[JsonSerializable(typeof(GenerationManifest))] +internal sealed partial class ToolJsonContext : JsonSerializerContext; diff --git a/tools/OpenCode.Sdk.Tools/ToolApp.cs b/tools/OpenCode.Sdk.Tools/ToolApp.cs index e13cc6c5..e2564c6b 100644 --- a/tools/OpenCode.Sdk.Tools/ToolApp.cs +++ b/tools/OpenCode.Sdk.Tools/ToolApp.cs @@ -2,10 +2,15 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using OpenCode.Sdk.Tools.Commands; +using OpenCode.Sdk.Tools.Generator; +using OpenCode.Sdk.Tools.Generator.Binding; +using OpenCode.Sdk.Tools.Generator.Binding.Abstractions; using OpenCode.Sdk.Tools.Generator.Ingestion; using OpenCode.Sdk.Tools.Generator.Ingestion.Abstractions; using OpenCode.Sdk.Tools.Infrastructure; using OpenCode.Sdk.Tools.Infrastructure.Logging; +using OpenCode.Sdk.Tools.Output; +using OpenCode.Sdk.Tools.Output.Abstractions; using Spectre.Console; using Spectre.Console.Cli; using Spectre.Console.Cli.Extensions.DependencyInjection; @@ -27,6 +32,16 @@ public static ServiceCollection CreateServices(Action? overr services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); overrideServices?.Invoke(services); From 3396ee466ef8f3ff892a764d1a6f79cd7bf5a856 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deniz=20=C4=B0rgin?= Date: Wed, 12 Aug 2026 12:48:33 +0300 Subject: [PATCH 5/7] fix(build): enable downlevel argument guards --- Directory.Build.props | 4 ++ .../LlmToolContentJsonConverter.cs | 40 ------------- .../OpenCodeErrorJsonConverter.cs | 40 ------------- ...ionMessageAssistantContentJsonConverter.cs | 40 ------------- .../SessionMessageJsonConverter.cs | 40 ------------- .../SessionMessageToolStateJsonConverter.cs | 40 ------------- .../Models/SessionMessageAssistant.cs | 8 --- .../SessionMessageToolStateCompleted.cs | 24 -------- .../Models/SessionMessageToolStateError.cs | 24 -------- .../Models/SessionMessageToolStateRunning.cs | 24 -------- .../Models/UnknownLlmToolContent.cs | 8 --- .../Models/UnknownOpenCodeError.cs | 8 --- .../Models/UnknownSessionMessage.cs | 8 --- .../UnknownSessionMessageAssistantContent.cs | 8 --- .../Models/UnknownSessionMessageToolState.cs | 8 --- ...erant_Marked_Union_Converters.verified.txt | 48 --------------- .../Generator/Emission/EmissionSyntax.cs | 60 ++----------------- 17 files changed, 10 insertions(+), 422 deletions(-) diff --git a/Directory.Build.props b/Directory.Build.props index fb2f25fd..621f8169 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -105,6 +105,10 @@ + + true + + all diff --git a/src/OpenCode.Sdk/Internal/Serialization/LlmToolContentJsonConverter.cs b/src/OpenCode.Sdk/Internal/Serialization/LlmToolContentJsonConverter.cs index fa1e93c8..0927249d 100644 --- a/src/OpenCode.Sdk/Internal/Serialization/LlmToolContentJsonConverter.cs +++ b/src/OpenCode.Sdk/Internal/Serialization/LlmToolContentJsonConverter.cs @@ -17,24 +17,8 @@ internal sealed class LlmToolContentJsonConverter : JsonConverter public override OpenCodeError Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) { -#if NET8_0_OR_GREATER ArgumentNullException.ThrowIfNull(typeToConvert); -#else - if (typeToConvert is null) - { - throw new ArgumentNullException(nameof(typeToConvert)); - } -#endif - -#if NET8_0_OR_GREATER ArgumentNullException.ThrowIfNull(options); -#else - if (options is null) - { - throw new ArgumentNullException(nameof(options)); - } -#endif - if (reader.TokenType == JsonTokenType.Null) { throw new JsonException("The OpenCodeError payload cannot be null."); @@ -71,33 +55,9 @@ public override OpenCodeError Read(ref Utf8JsonReader reader, Type typeToConvert public override void Write(Utf8JsonWriter writer, OpenCodeError value, JsonSerializerOptions options) { -#if NET8_0_OR_GREATER ArgumentNullException.ThrowIfNull(writer); -#else - if (writer is null) - { - throw new ArgumentNullException(nameof(writer)); - } -#endif - -#if NET8_0_OR_GREATER ArgumentNullException.ThrowIfNull(value); -#else - if (value is null) - { - throw new ArgumentNullException(nameof(value)); - } -#endif - -#if NET8_0_OR_GREATER ArgumentNullException.ThrowIfNull(options); -#else - if (options is null) - { - throw new ArgumentNullException(nameof(options)); - } -#endif - if (value is UnknownOpenCodeError unknown) { unknown.Payload.WriteTo(writer); diff --git a/src/OpenCode.Sdk/Internal/Serialization/SessionMessageAssistantContentJsonConverter.cs b/src/OpenCode.Sdk/Internal/Serialization/SessionMessageAssistantContentJsonConverter.cs index 53592713..aca524b0 100644 --- a/src/OpenCode.Sdk/Internal/Serialization/SessionMessageAssistantContentJsonConverter.cs +++ b/src/OpenCode.Sdk/Internal/Serialization/SessionMessageAssistantContentJsonConverter.cs @@ -18,24 +18,8 @@ internal sealed class SessionMessageAssistantContentJsonConverter : JsonConverte public override SessionMessageAssistantContent Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) { -#if NET8_0_OR_GREATER ArgumentNullException.ThrowIfNull(typeToConvert); -#else - if (typeToConvert is null) - { - throw new ArgumentNullException(nameof(typeToConvert)); - } -#endif - -#if NET8_0_OR_GREATER ArgumentNullException.ThrowIfNull(options); -#else - if (options is null) - { - throw new ArgumentNullException(nameof(options)); - } -#endif - if (reader.TokenType == JsonTokenType.Null) { throw new JsonException("The SessionMessageAssistantContent payload cannot be null."); @@ -70,33 +54,9 @@ public override SessionMessageAssistantContent Read(ref Utf8JsonReader reader, T public override void Write(Utf8JsonWriter writer, SessionMessageAssistantContent value, JsonSerializerOptions options) { -#if NET8_0_OR_GREATER ArgumentNullException.ThrowIfNull(writer); -#else - if (writer is null) - { - throw new ArgumentNullException(nameof(writer)); - } -#endif - -#if NET8_0_OR_GREATER ArgumentNullException.ThrowIfNull(value); -#else - if (value is null) - { - throw new ArgumentNullException(nameof(value)); - } -#endif - -#if NET8_0_OR_GREATER ArgumentNullException.ThrowIfNull(options); -#else - if (options is null) - { - throw new ArgumentNullException(nameof(options)); - } -#endif - if (value is UnknownSessionMessageAssistantContent unknown) { unknown.Payload.WriteTo(writer); diff --git a/src/OpenCode.Sdk/Internal/Serialization/SessionMessageJsonConverter.cs b/src/OpenCode.Sdk/Internal/Serialization/SessionMessageJsonConverter.cs index 1cd4682b..68c5f841 100644 --- a/src/OpenCode.Sdk/Internal/Serialization/SessionMessageJsonConverter.cs +++ b/src/OpenCode.Sdk/Internal/Serialization/SessionMessageJsonConverter.cs @@ -23,24 +23,8 @@ internal sealed class SessionMessageJsonConverter : JsonConverter Content get; init { -#if NET8_0_OR_GREATER ArgumentNullException.ThrowIfNull(value); -#else - if (value is null) - { - throw new ArgumentNullException(nameof(value)); - } -#endif - field = new List(value).AsReadOnly(); } } = []; diff --git a/src/OpenCode.Sdk/Models/SessionMessageToolStateCompleted.cs b/src/OpenCode.Sdk/Models/SessionMessageToolStateCompleted.cs index 070a999f..c7fcfa05 100644 --- a/src/OpenCode.Sdk/Models/SessionMessageToolStateCompleted.cs +++ b/src/OpenCode.Sdk/Models/SessionMessageToolStateCompleted.cs @@ -25,15 +25,7 @@ public required IReadOnlyDictionary Input get; init { -#if NET8_0_OR_GREATER ArgumentNullException.ThrowIfNull(value); -#else - if (value is null) - { - throw new ArgumentNullException(nameof(value)); - } -#endif - field = new ReadOnlyDictionary(value.ToDictionary(static pair => pair.Key, static pair => pair.Value, StringComparer.Ordinal)); } } = new ReadOnlyDictionary(new Dictionary(StringComparer.Ordinal)); @@ -61,15 +53,7 @@ public required IReadOnlyList Content get; init { -#if NET8_0_OR_GREATER ArgumentNullException.ThrowIfNull(value); -#else - if (value is null) - { - throw new ArgumentNullException(nameof(value)); - } -#endif - field = new List(value).AsReadOnly(); } } = []; @@ -97,15 +81,7 @@ public required IReadOnlyDictionary Structured get; init { -#if NET8_0_OR_GREATER ArgumentNullException.ThrowIfNull(value); -#else - if (value is null) - { - throw new ArgumentNullException(nameof(value)); - } -#endif - field = new ReadOnlyDictionary(value.ToDictionary(static pair => pair.Key, static pair => pair.Value, StringComparer.Ordinal)); } } = new ReadOnlyDictionary(new Dictionary(StringComparer.Ordinal)); diff --git a/src/OpenCode.Sdk/Models/SessionMessageToolStateError.cs b/src/OpenCode.Sdk/Models/SessionMessageToolStateError.cs index 4e14a921..4500937a 100644 --- a/src/OpenCode.Sdk/Models/SessionMessageToolStateError.cs +++ b/src/OpenCode.Sdk/Models/SessionMessageToolStateError.cs @@ -25,15 +25,7 @@ public required IReadOnlyDictionary Input get; init { -#if NET8_0_OR_GREATER ArgumentNullException.ThrowIfNull(value); -#else - if (value is null) - { - throw new ArgumentNullException(nameof(value)); - } -#endif - field = new ReadOnlyDictionary(value.ToDictionary(static pair => pair.Key, static pair => pair.Value, StringComparer.Ordinal)); } } = new ReadOnlyDictionary(new Dictionary(StringComparer.Ordinal)); @@ -47,15 +39,7 @@ public required IReadOnlyList Content get; init { -#if NET8_0_OR_GREATER ArgumentNullException.ThrowIfNull(value); -#else - if (value is null) - { - throw new ArgumentNullException(nameof(value)); - } -#endif - field = new List(value).AsReadOnly(); } } = []; @@ -69,15 +53,7 @@ public required IReadOnlyDictionary Structured get; init { -#if NET8_0_OR_GREATER ArgumentNullException.ThrowIfNull(value); -#else - if (value is null) - { - throw new ArgumentNullException(nameof(value)); - } -#endif - field = new ReadOnlyDictionary(value.ToDictionary(static pair => pair.Key, static pair => pair.Value, StringComparer.Ordinal)); } } = new ReadOnlyDictionary(new Dictionary(StringComparer.Ordinal)); diff --git a/src/OpenCode.Sdk/Models/SessionMessageToolStateRunning.cs b/src/OpenCode.Sdk/Models/SessionMessageToolStateRunning.cs index 2a613365..983eb02f 100644 --- a/src/OpenCode.Sdk/Models/SessionMessageToolStateRunning.cs +++ b/src/OpenCode.Sdk/Models/SessionMessageToolStateRunning.cs @@ -25,15 +25,7 @@ public required IReadOnlyDictionary Input get; init { -#if NET8_0_OR_GREATER ArgumentNullException.ThrowIfNull(value); -#else - if (value is null) - { - throw new ArgumentNullException(nameof(value)); - } -#endif - field = new ReadOnlyDictionary(value.ToDictionary(static pair => pair.Key, static pair => pair.Value, StringComparer.Ordinal)); } } = new ReadOnlyDictionary(new Dictionary(StringComparer.Ordinal)); @@ -47,15 +39,7 @@ public required IReadOnlyDictionary Structured get; init { -#if NET8_0_OR_GREATER ArgumentNullException.ThrowIfNull(value); -#else - if (value is null) - { - throw new ArgumentNullException(nameof(value)); - } -#endif - field = new ReadOnlyDictionary(value.ToDictionary(static pair => pair.Key, static pair => pair.Value, StringComparer.Ordinal)); } } = new ReadOnlyDictionary(new Dictionary(StringComparer.Ordinal)); @@ -69,15 +53,7 @@ public required IReadOnlyList Content get; init { -#if NET8_0_OR_GREATER ArgumentNullException.ThrowIfNull(value); -#else - if (value is null) - { - throw new ArgumentNullException(nameof(value)); - } -#endif - field = new List(value).AsReadOnly(); } } = []; diff --git a/src/OpenCode.Sdk/Models/UnknownLlmToolContent.cs b/src/OpenCode.Sdk/Models/UnknownLlmToolContent.cs index eb738f5a..58b81984 100644 --- a/src/OpenCode.Sdk/Models/UnknownLlmToolContent.cs +++ b/src/OpenCode.Sdk/Models/UnknownLlmToolContent.cs @@ -16,15 +16,7 @@ public sealed record UnknownLlmToolContent : LlmToolContent [JsonConstructor] public UnknownLlmToolContent(string type, JsonElement payload) { -#if NET8_0_OR_GREATER ArgumentException.ThrowIfNullOrEmpty(type); -#else - if (string.IsNullOrEmpty(type)) - { - throw new ArgumentException("Value cannot be null or empty.", nameof(type)); - } -#endif - _marker = type; Payload = payload.Clone(); } diff --git a/src/OpenCode.Sdk/Models/UnknownOpenCodeError.cs b/src/OpenCode.Sdk/Models/UnknownOpenCodeError.cs index bd8c48a5..fa84ba41 100644 --- a/src/OpenCode.Sdk/Models/UnknownOpenCodeError.cs +++ b/src/OpenCode.Sdk/Models/UnknownOpenCodeError.cs @@ -16,15 +16,7 @@ public sealed record UnknownOpenCodeError : OpenCodeError [JsonConstructor] public UnknownOpenCodeError(string tag, JsonElement payload) { -#if NET8_0_OR_GREATER ArgumentException.ThrowIfNullOrEmpty(tag); -#else - if (string.IsNullOrEmpty(tag)) - { - throw new ArgumentException("Value cannot be null or empty.", nameof(tag)); - } -#endif - _marker = tag; Payload = payload.Clone(); } diff --git a/src/OpenCode.Sdk/Models/UnknownSessionMessage.cs b/src/OpenCode.Sdk/Models/UnknownSessionMessage.cs index faf0d17c..464f80cb 100644 --- a/src/OpenCode.Sdk/Models/UnknownSessionMessage.cs +++ b/src/OpenCode.Sdk/Models/UnknownSessionMessage.cs @@ -16,15 +16,7 @@ public sealed record UnknownSessionMessage : SessionMessage [JsonConstructor] public UnknownSessionMessage(string type, JsonElement payload) { -#if NET8_0_OR_GREATER ArgumentException.ThrowIfNullOrEmpty(type); -#else - if (string.IsNullOrEmpty(type)) - { - throw new ArgumentException("Value cannot be null or empty.", nameof(type)); - } -#endif - _marker = type; Payload = payload.Clone(); } diff --git a/src/OpenCode.Sdk/Models/UnknownSessionMessageAssistantContent.cs b/src/OpenCode.Sdk/Models/UnknownSessionMessageAssistantContent.cs index 65d615a7..c903cb1a 100644 --- a/src/OpenCode.Sdk/Models/UnknownSessionMessageAssistantContent.cs +++ b/src/OpenCode.Sdk/Models/UnknownSessionMessageAssistantContent.cs @@ -16,15 +16,7 @@ public sealed record UnknownSessionMessageAssistantContent : SessionMessageAssis [JsonConstructor] public UnknownSessionMessageAssistantContent(string type, JsonElement payload) { -#if NET8_0_OR_GREATER ArgumentException.ThrowIfNullOrEmpty(type); -#else - if (string.IsNullOrEmpty(type)) - { - throw new ArgumentException("Value cannot be null or empty.", nameof(type)); - } -#endif - _marker = type; Payload = payload.Clone(); } diff --git a/src/OpenCode.Sdk/Models/UnknownSessionMessageToolState.cs b/src/OpenCode.Sdk/Models/UnknownSessionMessageToolState.cs index 7d56f119..547ff367 100644 --- a/src/OpenCode.Sdk/Models/UnknownSessionMessageToolState.cs +++ b/src/OpenCode.Sdk/Models/UnknownSessionMessageToolState.cs @@ -16,15 +16,7 @@ public sealed record UnknownSessionMessageToolState : SessionMessageToolState [JsonConstructor] public UnknownSessionMessageToolState(string status, JsonElement payload) { -#if NET8_0_OR_GREATER ArgumentException.ThrowIfNullOrEmpty(status); -#else - if (string.IsNullOrEmpty(status)) - { - throw new ArgumentException("Value cannot be null or empty.", nameof(status)); - } -#endif - _marker = status; Payload = payload.Clone(); } diff --git a/tests/OpenCode.Sdk.Tools.Tests/Snapshots/UnionEmitterTests.Emit_Should_Produce_Tolerant_Marked_Union_Converters.verified.txt b/tests/OpenCode.Sdk.Tools.Tests/Snapshots/UnionEmitterTests.Emit_Should_Produce_Tolerant_Marked_Union_Converters.verified.txt index 5d0b8fbe..7977bccd 100644 --- a/tests/OpenCode.Sdk.Tools.Tests/Snapshots/UnionEmitterTests.Emit_Should_Produce_Tolerant_Marked_Union_Converters.verified.txt +++ b/tests/OpenCode.Sdk.Tools.Tests/Snapshots/UnionEmitterTests.Emit_Should_Produce_Tolerant_Marked_Union_Converters.verified.txt @@ -38,15 +38,7 @@ public sealed record UnknownExampleEvent : ExampleEvent [JsonConstructor] public UnknownExampleEvent(string type, JsonElement payload) { -#if NET8_0_OR_GREATER ArgumentException.ThrowIfNullOrEmpty(type); -#else - if (string.IsNullOrEmpty(type)) - { - throw new ArgumentException("Value cannot be null or empty.", nameof(type)); - } -#endif - _marker = type; Payload = payload.Clone(); } @@ -82,24 +74,8 @@ internal sealed class ExampleEventJsonConverter : JsonConverter public override ExampleEvent Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) { -#if NET8_0_OR_GREATER ArgumentNullException.ThrowIfNull(typeToConvert); -#else - if (typeToConvert is null) - { - throw new ArgumentNullException(nameof(typeToConvert)); - } -#endif - -#if NET8_0_OR_GREATER ArgumentNullException.ThrowIfNull(options); -#else - if (options is null) - { - throw new ArgumentNullException(nameof(options)); - } -#endif - if (reader.TokenType == JsonTokenType.Null) throw new JsonException("The ExampleEvent payload cannot be null."); using var document = JsonDocument.ParseValue(ref reader); @@ -124,33 +100,9 @@ internal sealed class ExampleEventJsonConverter : JsonConverter public override void Write(Utf8JsonWriter writer, ExampleEvent value, JsonSerializerOptions options) { -#if NET8_0_OR_GREATER ArgumentNullException.ThrowIfNull(writer); -#else - if (writer is null) - { - throw new ArgumentNullException(nameof(writer)); - } -#endif - -#if NET8_0_OR_GREATER ArgumentNullException.ThrowIfNull(value); -#else - if (value is null) - { - throw new ArgumentNullException(nameof(value)); - } -#endif - -#if NET8_0_OR_GREATER ArgumentNullException.ThrowIfNull(options); -#else - if (options is null) - { - throw new ArgumentNullException(nameof(options)); - } -#endif - if (value is UnknownExampleEvent unknown) { unknown.Payload.WriteTo(writer); diff --git a/tools/OpenCode.Sdk.Tools/Generator/Emission/EmissionSyntax.cs b/tools/OpenCode.Sdk.Tools/Generator/Emission/EmissionSyntax.cs index 51ca8b8d..74d130fa 100644 --- a/tools/OpenCode.Sdk.Tools/Generator/Emission/EmissionSyntax.cs +++ b/tools/OpenCode.Sdk.Tools/Generator/Emission/EmissionSyntax.cs @@ -112,70 +112,22 @@ public static IReadOnlyList ArgumentNullGuard(string parameterN { ArgumentException.ThrowIfNullOrWhiteSpace(parameterName); var parameter = SyntaxFactory.IdentifierName(parameterName); - var modern = SyntaxFactory.ExpressionStatement(Invocation( + var guard = SyntaxFactory.ExpressionStatement(Invocation( MemberAccess(SyntaxFactory.IdentifierName("ArgumentNullException"), "ThrowIfNull"), - SyntaxFactory.Argument(parameter))) - .WithLeadingTrivia(IfModernRuntime()); - var fallback = SyntaxFactory.IfStatement( - SyntaxFactory.IsPatternExpression( - parameter, - SyntaxFactory.ConstantPattern(SyntaxFactory.LiteralExpression(SyntaxKind.NullLiteralExpression))), - SyntaxFactory.Block(SyntaxFactory.ThrowStatement( - SyntaxFactory.ObjectCreationExpression(SyntaxFactory.IdentifierName("ArgumentNullException")) - .WithArgumentList(SyntaxFactory.ArgumentList(SyntaxFactory.SingletonSeparatedList( - SyntaxFactory.Argument(NameOf(parameterName)))))))) - .WithLeadingTrivia(ElseRuntime()) - .WithTrailingTrivia(EndIfRuntime()); - return Array.AsReadOnly([modern, fallback]); + SyntaxFactory.Argument(parameter))); + return Array.AsReadOnly([guard]); } public static IReadOnlyList ArgumentNullOrEmptyGuard(string parameterName) { ArgumentException.ThrowIfNullOrWhiteSpace(parameterName); var parameter = SyntaxFactory.IdentifierName(parameterName); - var modern = SyntaxFactory.ExpressionStatement(Invocation( + var guard = SyntaxFactory.ExpressionStatement(Invocation( MemberAccess(SyntaxFactory.IdentifierName("ArgumentException"), "ThrowIfNullOrEmpty"), - SyntaxFactory.Argument(parameter))) - .WithLeadingTrivia(IfModernRuntime()); - var fallback = SyntaxFactory.IfStatement( - Invocation( - MemberAccess(SyntaxFactory.IdentifierName("string"), "IsNullOrEmpty"), - SyntaxFactory.Argument(parameter)), - SyntaxFactory.Block(SyntaxFactory.ThrowStatement( - SyntaxFactory.ObjectCreationExpression(SyntaxFactory.IdentifierName("ArgumentException")) - .WithArgumentList(SyntaxFactory.ArgumentList(SyntaxFactory.SeparatedList( - [ - SyntaxFactory.Argument(SyntaxFactory.LiteralExpression( - SyntaxKind.StringLiteralExpression, - SyntaxFactory.Literal("Value cannot be null or empty."))), - SyntaxFactory.Argument(NameOf(parameterName)), - ])))))) - .WithLeadingTrivia(ElseRuntime()) - .WithTrailingTrivia(EndIfRuntime()); - return Array.AsReadOnly([modern, fallback]); + SyntaxFactory.Argument(parameter))); + return Array.AsReadOnly([guard]); } - private static InvocationExpressionSyntax NameOf(string parameterName) => Invocation( - SyntaxFactory.IdentifierName("nameof"), - SyntaxFactory.Argument(SyntaxFactory.IdentifierName(parameterName))); - - private static SyntaxTriviaList IfModernRuntime() => SyntaxFactory.TriviaList( - SyntaxFactory.Trivia(SyntaxFactory.IfDirectiveTrivia( - SyntaxFactory.IdentifierName("NET8_0_OR_GREATER"), - isActive: true, - branchTaken: true, - conditionValue: true)), - LineFeed); - - private static SyntaxTriviaList ElseRuntime() => SyntaxFactory.TriviaList( - SyntaxFactory.Trivia(SyntaxFactory.ElseDirectiveTrivia(isActive: true, branchTaken: false)), - LineFeed); - - private static SyntaxTriviaList EndIfRuntime() => SyntaxFactory.TriviaList( - LineFeed, - SyntaxFactory.Trivia(SyntaxFactory.EndIfDirectiveTrivia(isActive: true)), - LineFeed); - private static string NormalizeDocumentation(string value) { var result = new StringBuilder(value.Length); From 6d7500ded4a1448dea838aab6a80517cb0d7921b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deniz=20=C4=B0rgin?= Date: Wed, 12 Aug 2026 13:15:37 +0300 Subject: [PATCH 6/7] fix(build): align cross-targeting project behavior --- src/OpenCode.Sdk/OpenCode.Sdk.csproj | 6 ++++++ tests/OpenCode.Sdk.Tests/App.net472.config | 6 ++++++ tests/OpenCode.Sdk.Tests/OpenCode.Sdk.Tests.csproj | 2 ++ 3 files changed, 14 insertions(+) create mode 100644 tests/OpenCode.Sdk.Tests/App.net472.config diff --git a/src/OpenCode.Sdk/OpenCode.Sdk.csproj b/src/OpenCode.Sdk/OpenCode.Sdk.csproj index bfc3d1be..35617b08 100644 --- a/src/OpenCode.Sdk/OpenCode.Sdk.csproj +++ b/src/OpenCode.Sdk/OpenCode.Sdk.csproj @@ -13,4 +13,10 @@ + + + + + diff --git a/tests/OpenCode.Sdk.Tests/App.net472.config b/tests/OpenCode.Sdk.Tests/App.net472.config new file mode 100644 index 00000000..1995750c --- /dev/null +++ b/tests/OpenCode.Sdk.Tests/App.net472.config @@ -0,0 +1,6 @@ + + + + + + diff --git a/tests/OpenCode.Sdk.Tests/OpenCode.Sdk.Tests.csproj b/tests/OpenCode.Sdk.Tests/OpenCode.Sdk.Tests.csproj index 3f3a8c6d..935638c3 100644 --- a/tests/OpenCode.Sdk.Tests/OpenCode.Sdk.Tests.csproj +++ b/tests/OpenCode.Sdk.Tests/OpenCode.Sdk.Tests.csproj @@ -6,6 +6,8 @@ false net472;$(TargetFrameworks) + + App.net472.config From 0481f212dd3d2f5003d37511ebe4112bebfe9f2b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deniz=20=C4=B0rgin?= Date: Wed, 12 Aug 2026 15:34:39 +0300 Subject: [PATCH 7/7] fix(build): rely on implicit source items --- src/OpenCode.Sdk/OpenCode.Sdk.csproj | 6 ------ 1 file changed, 6 deletions(-) diff --git a/src/OpenCode.Sdk/OpenCode.Sdk.csproj b/src/OpenCode.Sdk/OpenCode.Sdk.csproj index 35617b08..bfc3d1be 100644 --- a/src/OpenCode.Sdk/OpenCode.Sdk.csproj +++ b/src/OpenCode.Sdk/OpenCode.Sdk.csproj @@ -13,10 +13,4 @@ - - - - -