From 0cff00dc50b94577791a862cc1e23116ec7f5957 Mon Sep 17 00:00:00 2001 From: "denizirgin@gmail.com" Date: Tue, 11 Aug 2026 10:55:36 +0300 Subject: [PATCH 01/19] feat(tools): full hosting composition and ingestion package pins --- .editorconfig | 6 ++ AGENTS.md | 2 +- Directory.Build.props | 4 + Directory.Packages.props | 58 ++++++------ docs/ROADMAP.md | 2 +- .../handover-prompts/HANDOFF-2026-08-11.md | 5 +- docs/engineering/coding-style.md | 6 +- docs/engineering/testing-style.md | 12 +-- docs/research/00-research-log.md | 30 ++++--- .../2026-08-10-implementation-slice-map.md | 12 +-- .../2026-08-11-slice-01-ingestion-specir.md | 45 +++++----- .../2026-08-09-generator-architecture.md | 9 +- .../specs/2026-08-09-public-api-design.md | 8 +- .../2026-08-10-testing-architecture-design.md | 3 +- .../Logging/ToolLoggingTests.cs | 88 +++++++++++++++++++ .../OpenCode.Sdk.Tools.Tests.csproj | 8 +- .../OpenCode.Sdk.Tools.Tests/ToolAppTests.cs | 65 ++++++++++++++ .../Commands/GenerateCommand.cs | 25 +++++- tools/OpenCode.Sdk.Tools/GlobalSettings.cs | 22 +++++ .../GlobalOptionsInterceptor.cs | 32 +++++++ .../Logging/FileLoggerProvider.cs | 74 ++++++++++++++++ .../Logging/SpectreConsoleLoggerProvider.cs | 61 +++++++++++++ .../Logging/ToolLogLevelConverter.cs | 33 +++++++ .../Logging/ToolLoggingOptions.cs | 30 +++++++ .../OpenCode.Sdk.Tools.csproj | 10 ++- tools/OpenCode.Sdk.Tools/ToolApp.cs | 24 ++++- 26 files changed, 573 insertions(+), 101 deletions(-) create mode 100644 tests/OpenCode.Sdk.Tools.Tests/Infrastructure/Logging/ToolLoggingTests.cs create mode 100644 tools/OpenCode.Sdk.Tools/GlobalSettings.cs create mode 100644 tools/OpenCode.Sdk.Tools/Infrastructure/GlobalOptionsInterceptor.cs create mode 100644 tools/OpenCode.Sdk.Tools/Infrastructure/Logging/FileLoggerProvider.cs create mode 100644 tools/OpenCode.Sdk.Tools/Infrastructure/Logging/SpectreConsoleLoggerProvider.cs create mode 100644 tools/OpenCode.Sdk.Tools/Infrastructure/Logging/ToolLogLevelConverter.cs create mode 100644 tools/OpenCode.Sdk.Tools/Infrastructure/Logging/ToolLoggingOptions.cs diff --git a/.editorconfig b/.editorconfig index 20471311..554f588e 100644 --- a/.editorconfig +++ b/.editorconfig @@ -739,6 +739,12 @@ dotnet_diagnostic.VSTHRD111.severity = none [tools/opencode-tool.cs] dotnet_diagnostic.MA0048.severity = none +# Spectre.Console.Cli binds the command-specific settings type declared inside +# its command. Winner: the nested CLI settings contract; CA1034 remains enabled +# for every other file. +[tools/OpenCode.Sdk.Tools/Commands/GenerateCommand.cs] +dotnet_diagnostic.CA1034.severity = none + # The wire-faithful SpecIR primitive-kind contract requires the exact String # and Integer member names. Winner: the wire-faithful public contract; CA1720 # remains enabled for every other file. diff --git a/AGENTS.md b/AGENTS.md index c69a60cb..3a93600f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -121,7 +121,7 @@ reachable via `docs/ROADMAP.md`) until build-out distills them. infrastructure is first-class (central scenario assembly, domain-aware fluent builders; named scenario classes promoted only on reuse/complexity/domain identity); test data lives in embedded fixtures, typed builders, or centralized constants — never as inline dumps in - test bodies; TestableIO is the only filesystem seam. + test bodies; Testably supplies the repository's shared `IFileSystem` seam and canonical fake. - **Test naming:** `{Symbol}_Should_{Expected_Behavior}[_When_{Condition}]`. Symbol names stay intact as one token (`TryResolve`, `NuGet`); every other word is `_`-separated and starts with a capital. Example: `TryResolve_Should_Return_False_When_Routes_Are_Invalid`. Test classes are diff --git a/Directory.Build.props b/Directory.Build.props index 193781a0..fb2f25fd 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -92,6 +92,10 @@ all runtime; build; native; contentfiles; analyzers; buildtransitive + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + diff --git a/Directory.Packages.props b/Directory.Packages.props index 54710f0b..6df319f6 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -1,37 +1,43 @@ - - - - - - - - + + + + + + + + + - - + + - - + + + - - + + - - - - - + + + + + - - - + + + + + - - - - + + + + + + diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 158b77ef..b5949a58 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -21,7 +21,7 @@ The Slice 0 tooling skeleton has landed. Repo-local Slopwatch pinning, a committ baseline, and the Linux CI gate are active. Slice 1 is ready for execution: the pinned `Microsoft.OpenApi` reader feeds a fail-closed semantic projection into minimal SpecIR (ADR-0003; generator spec §4.1); the foundation is the complete PathSmith-shaped ToolApp -host (single production/test composition path, TestableIO, `IAnsiConsole`, MEL +host (single production/test composition path, Testably `IFileSystem`, `IAnsiConsole`, MEL Spectre/optional-file providers, global settings, interceptor); test setup is centralized and lambda-first, with named scenarios promoted only for reuse, complexity, or durable domain identity. The wall/tripwire design and the execution plan survived independent diff --git a/docs/agents/handover-prompts/HANDOFF-2026-08-11.md b/docs/agents/handover-prompts/HANDOFF-2026-08-11.md index 2ea50ea6..1f5161f6 100644 --- a/docs/agents/handover-prompts/HANDOFF-2026-08-11.md +++ b/docs/agents/handover-prompts/HANDOFF-2026-08-11.md @@ -44,7 +44,7 @@ repo's need. - **Extraction & testing guidelines (origin of `docs/engineering/`):** - - -- **Scenario test infrastructure model** (first-class fakes over TestableIO — the mechanics +- **Scenario test infrastructure model** (first-class fakes over Testably — the mechanics Task 2 adapts, not its class-per-situation file count): — study `Scenarios/Base/ScenarioBase.cs` (+`ScenarioContext`), `Utils/ProjectBuilder.cs` @@ -98,5 +98,8 @@ the counter-example — research log session 12 records why. scenario/promotion rule is binding; all test cases remain, only their setup housing changed. - Do not reduce Task 1 to `IFileSystem` registration. The complete ToolApp hosting battery, MEL providers, global settings, interceptor, and shared test override path are slice scope. +- The filesystem seam uses `Testably.Abstractions` in production and + `Testably.Abstractions.Testing` in tests. The repo-wide enforcement package remains the + independent `TestableIO.System.IO.Abstractions.Analyzers`; Testably does not bundle it. - Deviation protocol applies; subagents never self-resolve level 2+. - Delete this handover in the slice's docs-pass task once the PR merges. diff --git a/docs/engineering/coding-style.md b/docs/engineering/coding-style.md index 58632782..528ead19 100644 --- a/docs/engineering/coding-style.md +++ b/docs/engineering/coding-style.md @@ -57,10 +57,10 @@ for a pile of static steps. public static ServiceCollection CreateServices() { var services = new ServiceCollection(); - services.AddSingleton(new FileSystem()); + services.AddSingleton(); services.AddSingleton(AnsiConsole.Console); services.AddSingleton(); - services.AddLogging(); + services.AddLogging(logging => logging.SetMinimumLevel(LogLevel.Trace)); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); @@ -83,7 +83,7 @@ for a pile of static steps. `IAnsiConsole`, process/network collaborators), Microsoft.Extensions.Logging providers, application services, and interceptors. Tool logging uses MEL end to end: structured `ILogger` consumption, a Spectre-backed console provider, and an optional - TestableIO-backed file provider configured by global log-level/log-file settings. Tests + Testably-backed file provider configured by global log-level/log-file settings. Tests start from the same production registration path and replace seams after composition; they never reproduce the registration list in a parallel test-only root. - **Cross-cutting CLI concerns ride an interceptor** (logging verbosity, global diff --git a/docs/engineering/testing-style.md b/docs/engineering/testing-style.md index cba3f305..1b9bf3b0 100644 --- a/docs/engineering/testing-style.md +++ b/docs/engineering/testing-style.md @@ -58,13 +58,13 @@ sized to need: ## 2. Filesystem rule -**TestableIO is the repository's only filesystem seam** (sealed decision; the TestableIO -analyzer enforces it repo-wide — even test code reaches `Path` through -`IFileSystem.Path`): +**Testably supplies the repository's only filesystem seam** through the shared +`System.IO.Abstractions.IFileSystem` contract (sealed decision; the independent TestableIO +analyzer enforces it repo-wide — even test code reaches `Path` through `IFileSystem.Path`): -- Levels 1–2 (unit, contract): `MockFileSystem` (`TestingHelpers`), assembled through - the scenario builders. -- Level 3 and full-artifact smoke tests: the real `FileSystem` (`Wrappers`). +- Levels 1–2 (unit, contract): `Testably.Abstractions.Testing.MockFileSystem`, assembled + through the scenario builders. +- Level 3 and full-artifact smoke tests: `Testably.Abstractions.RealFileSystem`. - Raw `System.IO` never appears in test code, and no second filesystem fake is ever introduced — one canonical fake per repository. diff --git a/docs/research/00-research-log.md b/docs/research/00-research-log.md index ecddda08..4169360a 100644 --- a/docs/research/00-research-log.md +++ b/docs/research/00-research-log.md @@ -1095,18 +1095,20 @@ decision was touched; the two-stage pipeline, wall philosophy, and node-kind mod absorbed all four findings without structural change. The plan's wall tables carry the complete known/known-ignored/refused sets validated by the census. -## Q54: Which packages seal the slice, and does the TestableIO analyzer join the wall? +## Q54: Which filesystem packages seal the slice, and does the TestableIO analyzer join the wall? **How researched:** NuGet flat-container/registration queries for newest stable versions -and dependency shapes; the analyzer's rule docs read from its repository +and dependency shapes; the official Testably package README and repository documentation; +restored package API surfaces; the analyzer's rule docs read from its repository (`TestableIO/System.IO.Abstractions.Analyzers`, active, last push 2026-07); a scratchpad strict-analyzer probe for CA1720 on the planned node-type names; TUnit exception-assert syntax verified against its documentation. -**Found:** TestableIO trio newest stable **22.2.0** — in v22 the interfaces moved to the -shared `Testably.Abstractions.FileSystem.Interface` package (namespace -`System.IO.Abstractions` retained), `TestingHelpers` depends on `Wrappers`; library needs -only the abstractions package, tests add `TestingHelpers` + `Wrappers`. +**Found:** the active two-package filesystem setup is `Testably.Abstractions` **10.3.0** +for production and `Testably.Abstractions.Testing` **7.0.2** for tests. Both implement the +shared `System.IO.Abstractions.IFileSystem` contract; production supplies +`Testably.Abstractions.RealFileSystem`, and tests supply +`Testably.Abstractions.Testing.MockFileSystem`. `TestableIO.System.IO.Abstractions.Analyzers` newest stable **2022.0.0** (maintainer proposal): rules IO0001–IO0011 all default-enabled at Warning — TWAE escalates them, so no `.editorconfig` section is needed; **IO0006 covers `Path`**, which caught the plan's @@ -1115,12 +1117,12 @@ sealing). CA1720 does not fire on compound type names (`ObjectNode`, `ContentEncodedStringNode` shapes probed clean). TUnit's `await Assert.That(action).Throws()` returns the exception for follow-up asserts. -**Decision:** four CPM pins for slice 1 — the trio at 22.2.0 plus the IO analyzer at -2022.0.0, the analyzer wired repo-wide in `Directory.Build.props` like its seven -siblings (fail-closed maximalist posture: the TestableIO seam is now mechanically -enforced, not review-enforced). The analyzer is an older-Roslyn build; if the current -compiler refuses to load it, the plan marks that a stop-and-report finding, never a -silent drop. +**Decision:** three filesystem CPM pins for slice 1 — the Testably production/test pair +plus the IO analyzer at 2022.0.0. The analyzer remains an independent package and is wired +repo-wide in `Directory.Build.props` like its seven siblings (fail-closed maximalist +posture: the shared filesystem seam is mechanically enforced, not review-enforced). The +analyzer is an older-Roslyn build; if the current compiler refuses to load it, the plan +marks that a stop-and-report finding, never a silent drop. # Session 12 — 2026-08-11: redesign research — Microsoft.OpenApi ingestion @@ -1166,7 +1168,7 @@ radar. A ~240-line projection answers all Task 10 landmarks 19/19, with typed re access (`OpenApiSchemaReference.Reference.Id` — the spike's reflection was unnecessary) and deterministic repeated ingestion (SHA-256-stable, including `x-effect-stream` serialization). `OpenApiModelFactory.Parse` parses the raw `prefixItems` -items with host-document context. `LeaveStreamOpen` is honored (TestableIO seam intact). +items with host-document context. `LeaveStreamOpen` is honored (Testably seam intact). Iteration order is deterministic across loads and equals document order (observed behavior, not a contract — outputs still sort). No ref cycles in the pin (deepest `Target` chain 15); parse cost 72 ms / 26 MB. 3.9.0 is the newest stable. Direct-Binder input (no SpecIR) was @@ -1360,7 +1362,7 @@ code path over the full pin, while Task 8's hash properties remain independently `ToolApp.CreateServices` root behind `DependencyInjectionRegistrar`/`CommandApp`, options, `IFileSystem`, `IAnsiConsole`, core and application services, global settings, and an `ICommandInterceptor`. Logging is intentionally adapted rather than transplanted: -Microsoft.Extensions.Logging `ILogger` with Spectre and optional TestableIO-backed file +Microsoft.Extensions.Logging `ILogger` with Spectre and optional Testably-backed file providers replaces PathSmith's custom logger; tests override seams after the production registrations and never copy the service list. Test setup becomes lambda-first through one central scenario mechanism and domain builders. A named scenario class is promoted only for diff --git a/docs/superpowers/plans/2026-08-10-implementation-slice-map.md b/docs/superpowers/plans/2026-08-10-implementation-slice-map.md index 7f4c506a..5d656964 100644 --- a/docs/superpowers/plans/2026-08-10-implementation-slice-map.md +++ b/docs/superpowers/plans/2026-08-10-implementation-slice-map.md @@ -75,9 +75,10 @@ invocation forms; the Tools.Tests harness pattern. Foundation first: the full ToolApp composition root (`docs/engineering/coding-style.md` §2: `IFileSystem`, `IAnsiConsole`, MEL Spectre/optional-file providers, global settings, -interceptor, and the shared production/test registration path), the TestableIO seam + -analyzer joining the repo-wide wall, and the lambda-first scenario/builder/fixture test -infrastructure with promotion-only named scenarios (`docs/engineering/testing-style.md` +interceptor, and the shared production/test registration path), the Testably filesystem seam +and independent TestableIO analyzer joining the repo-wide wall, and the lambda-first +scenario/builder/fixture test infrastructure with promotion-only named scenarios +(`docs/engineering/testing-style.md` §1). On that foundation, the pinned Microsoft.OpenApi reader as the tooling ingestion layer; the fail-closed projection behind the whitelist dialect wall (admitted typed members, the @@ -87,8 +88,9 @@ Binder/emitter/refresh-diff consumption); projection normalizations (duplicate-r dedup, envelope-shape classification, error-style detection, literal markers in both dialects, special-value numbers, parameter-stripped media types, opaque `x-effect-stream`, unrestricted `{}` nodes, the `prefixItems` fragment adapter). -Filesystem I/O via TestableIO. Tests: projection quirk fixtures loaded through the -real reader + wall red tests + tripwires + the full-spec landmark smoke test (no +Filesystem I/O via Testably's shared `IFileSystem` contract. Tests: projection quirk +fixtures loaded through the real reader + wall red tests + tripwires + the full-spec +landmark smoke test (no count assertions). Honest note: the generation pipeline remains a fail-loud stub until slice 3, while the complete hosting composition and its global CLI options become live in this slice. diff --git a/docs/superpowers/plans/2026-08-11-slice-01-ingestion-specir.md b/docs/superpowers/plans/2026-08-11-slice-01-ingestion-specir.md index 99e9b7db..89b6d9de 100644 --- a/docs/superpowers/plans/2026-08-11-slice-01-ingestion-specir.md +++ b/docs/superpowers/plans/2026-08-11-slice-01-ingestion-specir.md @@ -7,8 +7,8 @@ Date: 2026-08-11 > use checkbox (`- [ ]`) syntax for tracking. **Goal:** stand up the tooling foundation (full-battery DI composition, MEL logging, -global CLI interception, TestableIO seam, first-class test infrastructure) and build the -fail-closed ingestion stage on it — the pinned Microsoft.OpenApi +global CLI interception, Testably filesystem seam, first-class test infrastructure) and +build the fail-closed ingestion stage on it — the pinned Microsoft.OpenApi reader, the per-host whitelist dialect wall, the semantic projection into the minimal immutable SpecIR, raw-content hashes, library-upgrade tripwires, DOM-boundary guards, and the full pinned-spec landmark smoke test (generator spec §4.1). @@ -25,10 +25,11 @@ composition, seams + DI, signature hygiene) and `docs/engineering/testing-style. no inline JSON dumps). The mutable Microsoft.OpenApi DOM never escapes `Generator/Ingestion/`. -**Tech Stack:** Microsoft.OpenApi 3.9.0 (pinned; tooling-only), TestableIO trio 22.2.0 + +**Tech Stack:** Microsoft.OpenApi 3.9.0 (pinned; tooling-only), Testably.Abstractions 10.3.0, +Testably.Abstractions.Testing 7.0.2, and the independent `TestableIO.System.IO.Abstractions.Analyzers` 2022.0.0 (repo-wide), Spectre.Console + Spectre.Console.Cli + DI registrar (already pinned), Microsoft.Extensions.Logging with -Spectre and optional TestableIO-backed file providers, System.Text.Json (in-box), TUnit on +Spectre and optional Testably-backed file providers, System.Text.Json (in-box), TUnit on MTP (pinned), Verify.TUnit (new pin — the SpecIR-of-the-pin snapshot). ## Global Constraints @@ -60,7 +61,8 @@ MTP (pinned), Verify.TUnit (new pin — the SpecIR-of-the-pin snapshot). `var ex = await Assert.That(() => ...).Throws();` then assert on `ex!.Message`. Adapt in place if the pinned TUnit differs — level-0 deviation. - CPM: new pins are `Microsoft.Extensions.Logging`, `Spectre.Console`, - `Microsoft.OpenApi` 3.9.0, the TestableIO trio 22.2.0, + `Microsoft.OpenApi` 3.9.0, `Testably.Abstractions` 10.3.0, + `Testably.Abstractions.Testing` 7.0.2, `TestableIO.System.IO.Abstractions.Analyzers` 2022.0.0, and `Verify.TUnit` (newest stable). Before pinning, re-check with `dotnet package search --exact-match` and pin the newer stable if one exists. If the ingestion behavior differs on a newer Microsoft.OpenApi, @@ -165,7 +167,7 @@ flag (operations), `x-effect-stream` opaque (SSE media only), all other `x-*` re single registration root. Defaults are registered first; the optional callback runs last for seam replacement. `CreateRegistrar` retains its existing optional override callback and delegates to this method. Tests never reproduce the registration list. - - Core seams: singleton `IFileSystem` → `FileSystem` and `IAnsiConsole` → + - Core seams: singleton `IFileSystem` → `RealFileSystem` and `IAnsiConsole` → `AnsiConsole.Console`; later tasks extend this same root with `ISpecIngestion`, Binder, emitters, Writer, and infrastructure wrappers. - MEL end to end: commands/services consume `ILogger`; a Spectre provider writes @@ -183,21 +185,20 @@ flag (operations), `x-effect-stream` opaque (SSE media only), all other `x-*` re invocation, and remains the same nonzero fail-loud stub. Future command settings derive from the same global base. -- [ ] **Step 1: Add the package pins.** In `Directory.Packages.props` (re-check newest +- [x] **Step 1: Add the package pins.** In `Directory.Packages.props` (re-check newest stable first — Global Constraints): add `Microsoft.Extensions.Logging` beside the existing DI package; add the direct `Spectre.Console` dependency beside its CLI packages; under third-party analyzers add `TestableIO.System.IO.Abstractions.Analyzers` `2022.0.0`; under third-party packages add `Microsoft.OpenApi` `3.9.0` and - `TestableIO.System.IO.Abstractions` `22.2.0`; under test packages add - `TestableIO.System.IO.Abstractions.TestingHelpers` `22.2.0`, - `TestableIO.System.IO.Abstractions.Wrappers` `22.2.0`, and `Verify.TUnit` (newest stable). + `Testably.Abstractions` `10.3.0`; under test packages add + `Testably.Abstractions.Testing` `7.0.2` and `Verify.TUnit` (newest stable). In `Directory.Build.props`, append the analyzer to the repo-wide analyzer ItemGroup with the same `PrivateAssets`/`IncludeAssets` shape as its siblings. (The analyzer is an older-Roslyn build; if the compiler refuses to load it, stop — level 2.) -- [ ] **Step 2: Reference packages.** Tools csproj adds `Microsoft.Extensions.Logging`, - `Spectre.Console`, `Microsoft.OpenApi`, and `TestableIO.System.IO.Abstractions`; tests - csproj adds `TestingHelpers`, `Wrappers`, and `Verify.TUnit`. -- [ ] **Step 3: Write the failing tests:** +- [x] **Step 2: Reference packages.** Tools csproj adds `Microsoft.Extensions.Logging`, + `Spectre.Console`, `Microsoft.OpenApi`, and `Testably.Abstractions`; tests csproj adds + `Testably.Abstractions.Testing` and `Verify.TUnit`. +- [x] **Step 3: Write the failing tests:** - `CreateServices_Should_Resolve_Full_Hosting_Composition` builds `CreateServices()` and resolves the concrete filesystem, `IAnsiConsole`, `ILogger`, `ToolLoggingOptions`, and `IEnumerable` containing exactly the @@ -211,17 +212,17 @@ flag (operations), `x-effect-stream` opaque (SSE media only), all other `x-*` re the file is created through the fake, and its content includes the command log. - Focused provider tests prove default-warning filtering, Spectre markup-safe output, and fail-loud behavior when an explicitly configured log file cannot be initialized. -- [ ] **Step 4: Run to verify they fail** — missing composition/settings/provider types and +- [x] **Step 4: Run to verify they fail** — missing composition/settings/provider types and `ToolApp.CreateServices` are the red state. -- [ ] **Step 5: Implement** the Produces block. Provider logic is split by destination; +- [x] **Step 5: Implement** the Produces block. Provider logic is split by destination; the interceptor owns invocation configuration; `ToolApp` owns registration and command wiring only. All file paths and writes ride `IFileSystem`; no custom logger facade and no silent logging-I/O fallback. -- [ ] **Step 6: Run to verify pass** — full suite green (slice-0 command behavior retained). -- [ ] **Step 7: Full gate** (all four commands clean). Expect the new IO analyzer to fire +- [x] **Step 6: Run to verify pass** — full suite green (slice-0 command behavior retained). +- [x] **Step 7: Full gate** (all four commands clean). Expect the new IO analyzer to fire wherever slice-0 code touched `File`/`Path` directly; fix by routing through the injected `IFileSystem` — that is the analyzer doing its job, not a misfire. -- [ ] **Step 8: Commit** — `feat(tools): full hosting composition and ingestion package pins` +- [x] **Step 8: Commit** — `feat(tools): full hosting composition and ingestion package pins` --- @@ -238,7 +239,7 @@ flag (operations), `x-effect-stream` opaque (SSE media only), all other `x-*` re `Support/SpecScenarioTests.cs`, `Support/FixtureLoaderTests.cs` **Interfaces:** -- Consumes: `MockFileSystem` (TestingHelpers). +- Consumes: `Testably.Abstractions.Testing.MockFileSystem`. - Produces the centralized testing-style §1 shape: - `SpecDocumentBuilder` — sealed, fluent, JSON-producing: `WithOpenApiVersion(string version = "3.1.0")`, @@ -899,7 +900,7 @@ public async Task Hash_Should_Be_Stable_Under_Key_Reordering() (a) reflection over `SpecDocument`'s transitive public surface: no type from the `Microsoft.OpenApi` assembly reachable; (b) source scan: enumerate `tools/OpenCode.Sdk.Tools/**/*.cs` through the real - `FileSystem`; any file outside `Generator/Ingestion/` containing + `RealFileSystem`; any file outside `Generator/Ingestion/` containing `using Microsoft.OpenApi` fails with the path named. - [ ] **Step 3: Run — all green against the pin;** verify the snapshot's `.verified.txt` is committed and stable across two runs. @@ -914,7 +915,7 @@ public async Task Hash_Should_Be_Stable_Under_Key_Reordering() - Test: `tests/OpenCode.Sdk.Tools.Tests/PinnedSpecSmokeTests.cs` **Interfaces:** -- Consumes: `ISpecIngestion` + the real `FileSystem` (Wrappers) + the linked pinned spec. +- Consumes: `ISpecIngestion` + `RealFileSystem` + the linked pinned spec. - Produces: the structural gate every future spec refresh runs through. **No count assertions.** diff --git a/docs/superpowers/specs/2026-08-09-generator-architecture.md b/docs/superpowers/specs/2026-08-09-generator-architecture.md index 65b24261..1bf44a51 100644 --- a/docs/superpowers/specs/2026-08-09-generator-architecture.md +++ b/docs/superpowers/specs/2026-08-09-generator-architecture.md @@ -89,14 +89,14 @@ tools/ ├── Logging/ ← Spectre + optional file MEL providers └── … ← I/O, git/process wrappers -tests/OpenCode.Sdk.Tools.Tests/ ← TUnit + Spectre.Console.Cli.Testing + TestingHelpers +tests/OpenCode.Sdk.Tools.Tests/ ← TUnit + Spectre.Console.Cli.Testing + Testably.Abstractions.Testing ``` This resolves ADR-0003's "emission library behind a thin file-based entry" by synthesis: the library carries a full DI composition per the repo's coding style (`docs/engineering/coding-style.md` §2 — one `ServiceCollection` composition root behind Spectre's `DependencyInjectionRegistrar`; `IFileSystem` and `IAnsiConsole`; MEL structured -logging with Spectre and optional TestableIO-backed file providers; global log-level/file +logging with Spectre and optional Testably-backed file providers; global log-level/file settings applied by an `ICommandInterceptor`; collaborators behind seams in per-slice `Abstractions/` folders), but the `CommandApp` wiring lives in a `ToolApp` factory so `Spectre.Console.Cli.Testing`'s @@ -118,7 +118,8 @@ a second composition root. **Stack** (ROADMAP queue 2, confirmed): Spectre.Console.Cli (+ the DI registrar + Testing packages), Microsoft.Extensions.Logging, CliWrap for git and `dotnet format` invocations -(the no-CliWrap rule is SDK-product-scoped, ADR-0001), the TestableIO trio for all +(the no-CliWrap rule is SDK-product-scoped, ADR-0001), Testably.Abstractions in production, +Testably.Abstractions.Testing in tests, and the independent TestableIO analyzer for all filesystem I/O (public API spec §3), the pinned Microsoft.OpenApi reader for spec ingestion, System.Text.Json for curation parsing, Microsoft.CodeAnalysis.CSharp for emission (ADR-0003). All versions enter `Directory.Packages.props`. @@ -192,7 +193,7 @@ and operations) from `emitPromise`/`emitEffect`/`write` over that IR **Reader.** The pinned `Microsoft.OpenApi` package (CPM-pinned; tooling-only — it never enters the shipped packages) parses `spec/openapi.json` into its typed DOM via -`OpenApiDocument.LoadAsync` over a stream opened through the TestableIO seam +`OpenApiDocument.LoadAsync` over a stream opened through the Testably filesystem seam (`LeaveStreamOpen` honored **[verified]**). The library owns lexical JSON parsing, OpenAPI container parsing, `$ref` construction and resolution, and lossless retention of unknown schema keywords (`UnrecognizedKeywords`) and vendor extensions. Three diff --git a/docs/superpowers/specs/2026-08-09-public-api-design.md b/docs/superpowers/specs/2026-08-09-public-api-design.md index 084947a1..ce99c38a 100644 --- a/docs/superpowers/specs/2026-08-09-public-api-design.md +++ b/docs/superpowers/specs/2026-08-09-public-api-design.md @@ -1,6 +1,6 @@ # Public API Design — OpenCode.Sdk & OpenCode.Sdk.Extensions -Date: 2026-08-09 +Date: 2026-08-11 Design specification produced by the public-API brainstorm session (2026-08-08/09, ROADMAP queue item 1). Every decision below was discussed and sealed individually with the @@ -83,8 +83,10 @@ ADR-0006 and unchanged. This spec adds/confirms: evolve together in the same change (the `AddOpenCodeClient` → `IHttpClientBuilder` shape is part of the core transport design, not an afterthought). Versioning stays independent (ADR-0006). The MCP server remains sequenced after both. -- Wherever the SDK or its tooling performs filesystem I/O, the TestableIO trio is used - (`TestableIO.System.IO.Abstractions` + `.TestingHelpers` + the analyzer package). +- Wherever the SDK or its tooling performs filesystem I/O, Testably supplies the shared + `System.IO.Abstractions.IFileSystem` contract: `Testably.Abstractions` in production and + `Testably.Abstractions.Testing` in tests. The independent + `TestableIO.System.IO.Abstractions.Analyzers` package enforces the seam repo-wide. ## 4. Error model diff --git a/docs/superpowers/specs/2026-08-10-testing-architecture-design.md b/docs/superpowers/specs/2026-08-10-testing-architecture-design.md index 1c578612..08b8cd9c 100644 --- a/docs/superpowers/specs/2026-08-10-testing-architecture-design.md +++ b/docs/superpowers/specs/2026-08-10-testing-architecture-design.md @@ -487,8 +487,7 @@ this session's to revisit). Revisions: missing, guarded getter / guarded `PrintMembers`) are product tests and land in `OpenCode.Sdk.Tests` (level 1), not Tools.Tests. -Dependency note: Verify (Verify.TUnit) is not yet in `Directory.Packages.props` — added at -build-out. +Dependency note: Verify.TUnit is centrally pinned for the tooling snapshot tests. ## 11. CI architecture diff --git a/tests/OpenCode.Sdk.Tools.Tests/Infrastructure/Logging/ToolLoggingTests.cs b/tests/OpenCode.Sdk.Tools.Tests/Infrastructure/Logging/ToolLoggingTests.cs new file mode 100644 index 00000000..5a2c2bc8 --- /dev/null +++ b/tests/OpenCode.Sdk.Tools.Tests/Infrastructure/Logging/ToolLoggingTests.cs @@ -0,0 +1,88 @@ +using Microsoft.Extensions.Logging; +using OpenCode.Sdk.Tools.Infrastructure.Logging; +using Spectre.Console.Testing; +using Testably.Abstractions.Testing; +using Testably.Abstractions.Testing.FileSystem; + +namespace OpenCode.Sdk.Tools.Tests.Infrastructure.Logging; + +public sealed class ToolLoggingTests +{ + [Test] + [Arguments("trace", LogLevel.Trace)] + [Arguments("debug", LogLevel.Debug)] + [Arguments("info", LogLevel.Information)] + [Arguments("warning", LogLevel.Warning)] + [Arguments("error", LogLevel.Error)] + [Arguments("none", LogLevel.None)] + public async Task ConvertFrom_Should_Map_Tooling_Log_Level(string value, LogLevel expected) + { + var converter = new ToolLogLevelConverter(); + + var actual = converter.ConvertFromInvariantString(value); + + await Assert.That(actual).IsEqualTo(expected); + } + + [Test] + public async Task Log_Should_Filter_Messages_Below_Default_Warning_Level() + { + using var console = new TestConsole(); + var options = new ToolLoggingOptions(); + using var provider = new SpectreConsoleLoggerProvider(console, options); + var logger = provider.CreateLogger("OpenCode.Sdk.Tools.Tests"); + + logger.Log( + LogLevel.Information, + new EventId(1), + "Information message", + null, + static (message, _) => message); + logger.Log( + LogLevel.Warning, + new EventId(2), + "Warning message", + null, + static (message, _) => message); + + await Assert.That(console.Output).DoesNotContain("Information message"); + await Assert.That(console.Output).Contains("Warning message"); + } + + [Test] + public async Task Log_Should_Render_Spectre_Markup_As_Literal_Text() + { + const string message = "[red]Consumer-provided text[/]"; + using var console = new TestConsole(); + var options = new ToolLoggingOptions(); + using var provider = new SpectreConsoleLoggerProvider(console, options); + var logger = provider.CreateLogger("OpenCode.Sdk.Tools.Tests"); + + logger.Log( + LogLevel.Warning, + new EventId(3), + message, + null, + static (text, _) => text); + + await Assert.That(console.Output).Contains(message); + } + + [Test] + public async Task CreateLogger_Should_Fail_Loud_When_Configured_Log_File_Cannot_Be_Initialized() + { + var fileSystem = new MockFileSystem(); + fileSystem.WithAccessControlStrategy( + new DefaultAccessControlStrategy(static (_, _) => false)); + var options = new ToolLoggingOptions(); + options.Apply(LogLevel.Debug, "logs/tool.log"); + + await Assert + .That(() => + { + using var provider = new FileLoggerProvider(fileSystem, options); + provider.CreateLogger("OpenCode.Sdk.Tools.Tests"); + }) + .Throws(); + } +} 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 f1cd837b..ee4241d9 100644 --- a/tests/OpenCode.Sdk.Tools.Tests/OpenCode.Sdk.Tools.Tests.csproj +++ b/tests/OpenCode.Sdk.Tools.Tests/OpenCode.Sdk.Tools.Tests.csproj @@ -6,12 +6,14 @@ - - + + + + - + diff --git a/tests/OpenCode.Sdk.Tools.Tests/ToolAppTests.cs b/tests/OpenCode.Sdk.Tools.Tests/ToolAppTests.cs index 4354c13c..38289958 100644 --- a/tests/OpenCode.Sdk.Tools.Tests/ToolAppTests.cs +++ b/tests/OpenCode.Sdk.Tools.Tests/ToolAppTests.cs @@ -1,9 +1,74 @@ +using System.IO.Abstractions; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using OpenCode.Sdk.Tools.Commands; +using OpenCode.Sdk.Tools.Infrastructure; +using OpenCode.Sdk.Tools.Infrastructure.Logging; +using Spectre.Console; +using Spectre.Console.Cli; using Spectre.Console.Cli.Testing; +using Spectre.Console.Testing; +using Testably.Abstractions; +using Testably.Abstractions.Testing; namespace OpenCode.Sdk.Tools.Tests; public sealed class ToolAppTests { + [Test] + public async Task CreateServices_Should_Resolve_Full_Hosting_Composition() + { + await using var provider = ToolApp.CreateServices().BuildServiceProvider(); + + var fileSystem = provider.GetRequiredService(); + var console = provider.GetRequiredService(); + var logger = provider.GetRequiredService>(); + var loggingOptions = 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(interceptors.Length).IsEqualTo(1); + await Assert.That(interceptors[0]).IsTypeOf(); + } + + [Test] + public async Task CreateRegistrar_Should_Apply_Seam_Overrides_After_Production_Registrations() + { + var expected = new MockFileSystem(); + using var registrar = ToolApp.CreateRegistrar(services => + services.AddSingleton(expected)); + var resolver = registrar.Build(); + + var actual = resolver.Resolve(typeof(IFileSystem)); + + await Assert.That(actual).IsSameReferenceAs(expected); + } + + [Test] + public async Task RunAsync_Should_Apply_Global_Logging_Options_Before_Command_Execution() + { + const string logPath = "logs/tool.log"; + var fileSystem = new MockFileSystem(); + using var registrar = ToolApp.CreateRegistrar(services => + { + services.AddSingleton(fileSystem); + services.AddSingleton(); + }); + 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(fileSystem.File.Exists(logPath)).IsTrue(); + var log = await fileSystem.File.ReadAllTextAsync(logPath, CancellationToken.None); + await Assert.That(log).Contains("Generate command invoked."); + } + [Test] public async Task RunAsync_Should_Return_Nonzero_When_Command_Is_Unknown() { diff --git a/tools/OpenCode.Sdk.Tools/Commands/GenerateCommand.cs b/tools/OpenCode.Sdk.Tools/Commands/GenerateCommand.cs index 7c0e672a..629d64c1 100644 --- a/tools/OpenCode.Sdk.Tools/Commands/GenerateCommand.cs +++ b/tools/OpenCode.Sdk.Tools/Commands/GenerateCommand.cs @@ -1,26 +1,43 @@ +using Microsoft.Extensions.Logging; 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 class GenerateCommand : AsyncCommand +public sealed partial class GenerateCommand : AsyncCommand { private readonly IAnsiConsole _console; + private readonly ILogger _logger; - /// Creates the command; the console is injected so tests capture output. - public GenerateCommand(IAnsiConsole console) + /// Creates the command with its console and logging seams. + public GenerateCommand(IAnsiConsole console, ILogger logger) { ArgumentNullException.ThrowIfNull(console); + ArgumentNullException.ThrowIfNull(logger); _console = console; + _logger = logger; } /// - protected override Task ExecuteAsync(CommandContext context, CancellationToken cancellationToken) + protected override 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."); return Task.FromResult(1); } + + [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; } diff --git a/tools/OpenCode.Sdk.Tools/GlobalSettings.cs b/tools/OpenCode.Sdk.Tools/GlobalSettings.cs new file mode 100644 index 00000000..d817af99 --- /dev/null +++ b/tools/OpenCode.Sdk.Tools/GlobalSettings.cs @@ -0,0 +1,22 @@ +using System.ComponentModel; +using Microsoft.Extensions.Logging; +using OpenCode.Sdk.Tools.Infrastructure.Logging; +using Spectre.Console.Cli; + +namespace OpenCode.Sdk.Tools; + +/// Defines options shared by every tool command. +public abstract class GlobalSettings : CommandSettings +{ + /// Gets or sets the minimum logging level for the invocation. + [CommandOption("--log-level ")] + [Description("Minimum log level: trace, debug, info, warning, error, none.")] + [DefaultValue(LogLevel.Warning)] + [TypeConverter(typeof(ToolLogLevelConverter))] + public LogLevel LogLevel { get; init; } = LogLevel.Warning; + + /// Gets or sets the optional log-file path. + [CommandOption("--log-file ")] + [Description("Optional file path for logging output.")] + public string? LogFile { get; init; } +} diff --git a/tools/OpenCode.Sdk.Tools/Infrastructure/GlobalOptionsInterceptor.cs b/tools/OpenCode.Sdk.Tools/Infrastructure/GlobalOptionsInterceptor.cs new file mode 100644 index 00000000..817fb1a7 --- /dev/null +++ b/tools/OpenCode.Sdk.Tools/Infrastructure/GlobalOptionsInterceptor.cs @@ -0,0 +1,32 @@ +using OpenCode.Sdk.Tools.Infrastructure.Logging; +using Spectre.Console.Cli; + +namespace OpenCode.Sdk.Tools.Infrastructure; + +/// Applies global command options before command execution. +public sealed class GlobalOptionsInterceptor : ICommandInterceptor +{ + private readonly ToolLoggingOptions _loggingOptions; + + /// Initializes a new instance of the class. + public GlobalOptionsInterceptor(ToolLoggingOptions loggingOptions) + { + ArgumentNullException.ThrowIfNull(loggingOptions); + + _loggingOptions = loggingOptions; + } + + /// + public void Intercept(CommandContext context, CommandSettings settings) + { + ArgumentNullException.ThrowIfNull(context); + ArgumentNullException.ThrowIfNull(settings); + + if (settings is not GlobalSettings globalSettings) + { + throw new InvalidOperationException("Every command settings type must derive from GlobalSettings."); + } + + _loggingOptions.Apply(globalSettings.LogLevel, globalSettings.LogFile); + } +} diff --git a/tools/OpenCode.Sdk.Tools/Infrastructure/Logging/FileLoggerProvider.cs b/tools/OpenCode.Sdk.Tools/Infrastructure/Logging/FileLoggerProvider.cs new file mode 100644 index 00000000..f86310ee --- /dev/null +++ b/tools/OpenCode.Sdk.Tools/Infrastructure/Logging/FileLoggerProvider.cs @@ -0,0 +1,74 @@ +using System.IO.Abstractions; +using Microsoft.Extensions.Logging; + +namespace OpenCode.Sdk.Tools.Infrastructure.Logging; + +/// Provides optional MEL file logging through the configured filesystem seam. +public sealed class FileLoggerProvider : ILoggerProvider, ILogger +{ + private readonly IFileSystem _fileSystem; + private readonly ToolLoggingOptions _options; + + /// Initializes a new instance of the class. + public FileLoggerProvider(IFileSystem fileSystem, ToolLoggingOptions options) + { + ArgumentNullException.ThrowIfNull(fileSystem); + ArgumentNullException.ThrowIfNull(options); + + _fileSystem = fileSystem; + _options = options; + } + + /// + public ILogger CreateLogger(string categoryName) + { + ArgumentException.ThrowIfNullOrWhiteSpace(categoryName); + + if (_options.LogFile is { } logFile) + { + var directory = _fileSystem.Path.GetDirectoryName(logFile); + if (!string.IsNullOrEmpty(directory)) + { + _fileSystem.Directory.CreateDirectory(directory); + } + + _fileSystem.File.AppendAllText(logFile, string.Empty); + } + + return this; + } + + /// + public IDisposable? BeginScope(TState state) + where TState : notnull => null; + + /// + public bool IsEnabled(LogLevel logLevel) => _options.LogFile is not null && logLevel >= _options.MinimumLevel; + + /// + public void Log(LogLevel logLevel, + EventId eventId, + TState state, + Exception? exception, + Func formatter) + { + ArgumentNullException.ThrowIfNull(formatter); + + var logFile = _options.LogFile; + if (logFile is null || !IsEnabled(logLevel)) + { + return; + } + + var message = formatter(state, exception); + if (exception is not null) + { + message = $"{message} {exception}"; + } + + _fileSystem.File.AppendAllText(logFile, $"{logLevel}: {message}\n"); + } + + /// + public void Dispose() => GC.SuppressFinalize(this); +} diff --git a/tools/OpenCode.Sdk.Tools/Infrastructure/Logging/SpectreConsoleLoggerProvider.cs b/tools/OpenCode.Sdk.Tools/Infrastructure/Logging/SpectreConsoleLoggerProvider.cs new file mode 100644 index 00000000..0bbf762b --- /dev/null +++ b/tools/OpenCode.Sdk.Tools/Infrastructure/Logging/SpectreConsoleLoggerProvider.cs @@ -0,0 +1,61 @@ +using Microsoft.Extensions.Logging; +using Spectre.Console; + +namespace OpenCode.Sdk.Tools.Infrastructure.Logging; + +/// Provides MEL loggers that render through the configured Spectre console. +public sealed class SpectreConsoleLoggerProvider : ILoggerProvider, ILogger +{ + private readonly IAnsiConsole _console; + private readonly ToolLoggingOptions _options; + + /// Initializes a new instance of the class. + public SpectreConsoleLoggerProvider(IAnsiConsole console, ToolLoggingOptions options) + { + ArgumentNullException.ThrowIfNull(console); + ArgumentNullException.ThrowIfNull(options); + + _console = console; + _options = options; + } + + /// + public ILogger CreateLogger(string categoryName) + { + ArgumentException.ThrowIfNullOrWhiteSpace(categoryName); + return this; + } + + /// + public IDisposable? BeginScope(TState state) + where TState : notnull => null; + + /// + public bool IsEnabled(LogLevel logLevel) => logLevel >= _options.MinimumLevel; + + /// + public void Log(LogLevel logLevel, + EventId eventId, + TState state, + Exception? exception, + Func formatter) + { + ArgumentNullException.ThrowIfNull(formatter); + + if (!IsEnabled(logLevel)) + { + return; + } + + var message = formatter(state, exception); + if (exception is not null) + { + message = $"{message} {exception}"; + } + + _console.WriteLine($"{logLevel}: {message}"); + } + + /// + public void Dispose() => GC.SuppressFinalize(this); +} diff --git a/tools/OpenCode.Sdk.Tools/Infrastructure/Logging/ToolLogLevelConverter.cs b/tools/OpenCode.Sdk.Tools/Infrastructure/Logging/ToolLogLevelConverter.cs new file mode 100644 index 00000000..6e6c39cd --- /dev/null +++ b/tools/OpenCode.Sdk.Tools/Infrastructure/Logging/ToolLogLevelConverter.cs @@ -0,0 +1,33 @@ +using System.ComponentModel; +using System.Globalization; +using Microsoft.Extensions.Logging; + +namespace OpenCode.Sdk.Tools.Infrastructure.Logging; + +/// Converts the tool's log-level vocabulary to MEL levels. +public sealed class ToolLogLevelConverter : TypeConverter +{ + /// + public override bool CanConvertFrom(ITypeDescriptorContext? context, Type sourceType) => + sourceType == typeof(string) || base.CanConvertFrom(context, sourceType); + + /// + public override object? ConvertFrom(ITypeDescriptorContext? context, CultureInfo? culture, object value) + { + if (value is string text) + { + return text.Trim().ToLowerInvariant() switch + { + "trace" => LogLevel.Trace, + "debug" => LogLevel.Debug, + "info" => LogLevel.Information, + "warning" => LogLevel.Warning, + "error" => LogLevel.Error, + "none" => LogLevel.None, + _ => throw new FormatException("Log level must be one of: trace, debug, info, warning, error, none."), + }; + } + + return base.ConvertFrom(context, culture, value); + } +} diff --git a/tools/OpenCode.Sdk.Tools/Infrastructure/Logging/ToolLoggingOptions.cs b/tools/OpenCode.Sdk.Tools/Infrastructure/Logging/ToolLoggingOptions.cs new file mode 100644 index 00000000..ba175195 --- /dev/null +++ b/tools/OpenCode.Sdk.Tools/Infrastructure/Logging/ToolLoggingOptions.cs @@ -0,0 +1,30 @@ +using Microsoft.Extensions.Logging; + +namespace OpenCode.Sdk.Tools.Infrastructure.Logging; + +/// Holds mutable logging options for one tool invocation. +public sealed class ToolLoggingOptions +{ + /// Gets the minimum enabled logging level. + public LogLevel MinimumLevel { get; private set; } = LogLevel.Warning; + + /// Gets the optional path receiving file logs. + public string? LogFile { get; private set; } + + /// Applies logging options parsed from command settings. + public void Apply(LogLevel minimumLevel, string? logFile) + { + if (!Enum.IsDefined(minimumLevel)) + { + throw new ArgumentOutOfRangeException(nameof(minimumLevel), minimumLevel, "The log level is not defined."); + } + + if (logFile is not null) + { + ArgumentException.ThrowIfNullOrWhiteSpace(logFile); + } + + MinimumLevel = minimumLevel; + LogFile = logFile; + } +} diff --git a/tools/OpenCode.Sdk.Tools/OpenCode.Sdk.Tools.csproj b/tools/OpenCode.Sdk.Tools/OpenCode.Sdk.Tools.csproj index 04fea304..e740ca0f 100644 --- a/tools/OpenCode.Sdk.Tools/OpenCode.Sdk.Tools.csproj +++ b/tools/OpenCode.Sdk.Tools/OpenCode.Sdk.Tools.csproj @@ -6,9 +6,13 @@ - - - + + + + + + + diff --git a/tools/OpenCode.Sdk.Tools/ToolApp.cs b/tools/OpenCode.Sdk.Tools/ToolApp.cs index 609d1801..05d204cc 100644 --- a/tools/OpenCode.Sdk.Tools/ToolApp.cs +++ b/tools/OpenCode.Sdk.Tools/ToolApp.cs @@ -1,21 +1,39 @@ +using System.IO.Abstractions; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; using OpenCode.Sdk.Tools.Commands; +using OpenCode.Sdk.Tools.Infrastructure; +using OpenCode.Sdk.Tools.Infrastructure.Logging; using Spectre.Console; using Spectre.Console.Cli; using Spectre.Console.Cli.Extensions.DependencyInjection; +using Testably.Abstractions; namespace OpenCode.Sdk.Tools; /// Composition root for the repo tool: DI registrar plus the command surface. public static class ToolApp { - /// Builds the DI registrar; tests inject service overrides. - public static DependencyInjectionRegistrar CreateRegistrar(Action? overrideServices = null) + /// Builds the tool's complete production service collection. + public static ServiceCollection CreateServices(Action? overrideServices = null) { var services = new ServiceCollection(); + services.AddSingleton(); services.AddSingleton(AnsiConsole.Console); + services.AddSingleton(); + services.AddLogging(logging => logging.SetMinimumLevel(LogLevel.Trace)); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + overrideServices?.Invoke(services); - return new DependencyInjectionRegistrar(services); + return services; + } + + /// Builds the DI registrar; tests inject service overrides. + public static DependencyInjectionRegistrar CreateRegistrar(Action? overrideServices = null) + { + return new DependencyInjectionRegistrar(CreateServices(overrideServices)); } /// Registers the command surface; shared by the app and CommandAppTester. From 1e26366c9cabd1531c7109bcedd83deb2a6af5c4 Mon Sep 17 00:00:00 2001 From: "denizirgin@gmail.com" Date: Tue, 11 Aug 2026 11:34:57 +0300 Subject: [PATCH 02/19] test(tools): centralized spec scenarios, builders and fixtures --- .../OpenCode.Sdk.Tools.Tests.csproj | 7 + .../Support/FixtureLoader.cs | 40 +++ .../Support/FixtureLoaderTests.cs | 16 ++ .../Support/OperationBuilder.cs | 183 ++++++++++++++ .../Support/ScenarioContext.cs | 5 + .../Support/SchemaBuilder.cs | 231 +++++++++++++++++ .../Support/SpecDocumentBuilder.cs | 104 ++++++++ .../Support/SpecDocumentBuilderTests.cs | 232 ++++++++++++++++++ .../Support/SpecScenario.cs | 51 ++++ .../Support/SpecScenarioTests.cs | 26 ++ 10 files changed, 895 insertions(+) create mode 100644 tests/OpenCode.Sdk.Tools.Tests/Support/FixtureLoader.cs create mode 100644 tests/OpenCode.Sdk.Tools.Tests/Support/FixtureLoaderTests.cs create mode 100644 tests/OpenCode.Sdk.Tools.Tests/Support/OperationBuilder.cs create mode 100644 tests/OpenCode.Sdk.Tools.Tests/Support/ScenarioContext.cs create mode 100644 tests/OpenCode.Sdk.Tools.Tests/Support/SchemaBuilder.cs create mode 100644 tests/OpenCode.Sdk.Tools.Tests/Support/SpecDocumentBuilder.cs create mode 100644 tests/OpenCode.Sdk.Tools.Tests/Support/SpecDocumentBuilderTests.cs create mode 100644 tests/OpenCode.Sdk.Tools.Tests/Support/SpecScenario.cs create mode 100644 tests/OpenCode.Sdk.Tools.Tests/Support/SpecScenarioTests.cs 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 ee4241d9..f8753874 100644 --- a/tests/OpenCode.Sdk.Tools.Tests/OpenCode.Sdk.Tools.Tests.csproj +++ b/tests/OpenCode.Sdk.Tools.Tests/OpenCode.Sdk.Tools.Tests.csproj @@ -16,4 +16,11 @@ + + + + + diff --git a/tests/OpenCode.Sdk.Tools.Tests/Support/FixtureLoader.cs b/tests/OpenCode.Sdk.Tools.Tests/Support/FixtureLoader.cs new file mode 100644 index 00000000..abe8e11d --- /dev/null +++ b/tests/OpenCode.Sdk.Tools.Tests/Support/FixtureLoader.cs @@ -0,0 +1,40 @@ +using System.Reflection; +using System.Text; + +namespace OpenCode.Sdk.Tools.Tests.Support; + +internal sealed class FixtureLoader +{ + private const string ResourcePrefix = "OpenCode.Sdk.Tools.Tests.Fixtures."; + + private readonly Assembly _assembly = typeof(FixtureLoader).Assembly; + + public string Load(string name) + { + ArgumentException.ThrowIfNullOrWhiteSpace(name); + + var resourceName = ResourcePrefix + name; + var stream = _assembly.GetManifestResourceStream(resourceName); + if (stream is null) + { + var knownNames = GetKnownNames(); + var knownNamesList = knownNames.Length == 0 + ? "" + : string.Join(", ", knownNames); + throw new ArgumentException($"Embedded fixture '{name}' was not found. Known fixtures: {knownNamesList}.", 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.Length > 0 && content[0] == '\uFEFF' ? content[1..] : content; + } + + private string[] GetKnownNames() => + [ + .. _assembly + .GetManifestResourceNames() + .Where(name => name.StartsWith(ResourcePrefix, StringComparison.Ordinal)) + .Select(name => name[ResourcePrefix.Length..]) + .Order(StringComparer.Ordinal), + ]; +} diff --git a/tests/OpenCode.Sdk.Tools.Tests/Support/FixtureLoaderTests.cs b/tests/OpenCode.Sdk.Tools.Tests/Support/FixtureLoaderTests.cs new file mode 100644 index 00000000..3093a39c --- /dev/null +++ b/tests/OpenCode.Sdk.Tools.Tests/Support/FixtureLoaderTests.cs @@ -0,0 +1,16 @@ +namespace OpenCode.Sdk.Tools.Tests.Support; + +public sealed class FixtureLoaderTests +{ + [Test] + public async Task Load_Should_Throw_With_Known_Names_When_Fixture_Missing() + { + var loader = new FixtureLoader(); + + var exception = await Assert.That(() => loader.Load("no-such-fixture")).Throws(); + var message = exception?.Message ?? throw new InvalidOperationException("The assertion did not return the exception."); + + await Assert.That(message).Contains("no-such-fixture"); + await Assert.That(message).Contains("Known fixtures:"); + } +} diff --git a/tests/OpenCode.Sdk.Tools.Tests/Support/OperationBuilder.cs b/tests/OpenCode.Sdk.Tools.Tests/Support/OperationBuilder.cs new file mode 100644 index 00000000..c07d6ca2 --- /dev/null +++ b/tests/OpenCode.Sdk.Tools.Tests/Support/OperationBuilder.cs @@ -0,0 +1,183 @@ +using System.Globalization; +using System.Text.Json.Nodes; + +namespace OpenCode.Sdk.Tools.Tests.Support; + +internal sealed class OperationBuilder +{ + private readonly JsonObject _operation; + private readonly JsonObject _responses = []; + + public OperationBuilder(string operationId) + { + ArgumentException.ThrowIfNullOrWhiteSpace(operationId); + + _operation = new JsonObject + { + ["operationId"] = operationId, + ["responses"] = _responses, + }; + } + + public OperationBuilder Parameter(string name, string location, Action configure, bool required = false, bool deepObject = false) + { + ArgumentException.ThrowIfNullOrWhiteSpace(name); + ArgumentException.ThrowIfNullOrWhiteSpace(location); + ArgumentNullException.ThrowIfNull(configure); + + if (_operation["parameters"] is not JsonArray parameters) + { + parameters = []; + _operation["parameters"] = parameters; + } + + var schema = new SchemaBuilder(); + configure(schema); + var parameter = new JsonObject + { + ["name"] = name, + ["in"] = location, + ["schema"] = schema.Build(), + }; + + if (required) + { + parameter["required"] = true; + } + + if (deepObject) + { + parameter["style"] = "deepObject"; + parameter["explode"] = true; + } + + parameters.Add(parameter); + return this; + } + + public OperationBuilder RequestBody(string mediaType, Action configure, bool required = false) + { + ArgumentException.ThrowIfNullOrWhiteSpace(mediaType); + ArgumentNullException.ThrowIfNull(configure); + + var requestBody = new JsonObject + { + ["content"] = new JsonObject + { + [mediaType] = new JsonObject + { + ["schema"] = BuildSchema(configure), + }, + }, + }; + if (required) + { + requestBody["required"] = true; + } + + _operation["requestBody"] = requestBody; + return this; + } + + public OperationBuilder Response(int status, string? mediaType = null, Action? schema = null) + { + ArgumentOutOfRangeException.ThrowIfLessThan(status, 100); + ArgumentOutOfRangeException.ThrowIfGreaterThan(status, 599); + + if (mediaType is null && schema is not null) + { + throw new ArgumentException("A response schema requires a media type.", nameof(mediaType)); + } + + var response = new JsonObject + { + ["description"] = "Response", + }; + + if (mediaType is not null) + { + ArgumentException.ThrowIfNullOrWhiteSpace(mediaType); + var media = new JsonObject(); + if (schema is not null) + { + var schemaBuilder = new SchemaBuilder(); + schema(schemaBuilder); + media["schema"] = schemaBuilder.Build(); + } + + response["content"] = new JsonObject + { + [mediaType] = media, + }; + } + + _responses[status.ToString(CultureInfo.InvariantCulture)] = response; + return this; + } + + public OperationBuilder SseResponse(Action schema, string? effectStreamJson = null) + { + ArgumentNullException.ThrowIfNull(schema); + + var media = new JsonObject + { + ["schema"] = BuildSchema(schema), + }; + if (effectStreamJson is not null) + { + media["x-effect-stream"] = JsonNode.Parse(effectStreamJson); + } + + _responses["200"] = new JsonObject + { + ["description"] = "Event stream", + ["content"] = new JsonObject + { + ["text/event-stream"] = media, + }, + }; + return this; + } + + public OperationBuilder Extension(string key, string rawJson) + { + ArgumentException.ThrowIfNullOrWhiteSpace(key); + ArgumentNullException.ThrowIfNull(rawJson); + + return !key.StartsWith("x-", StringComparison.Ordinal) + ? throw new ArgumentException("OpenAPI extension keys must start with 'x-'.", nameof(key)) + : Raw(key, rawJson); + } + + public OperationBuilder Raw(string key, string rawJson) + { + ArgumentException.ThrowIfNullOrWhiteSpace(key); + ArgumentNullException.ThrowIfNull(rawJson); + + _operation[key] = JsonNode.Parse(rawJson); + return this; + } + + public OperationBuilder Deprecated() + { + _operation["deprecated"] = true; + return this; + } + + public OperationBuilder Summary(string summary) + { + ArgumentNullException.ThrowIfNull(summary); + + _operation["summary"] = summary; + return this; + } + + internal JsonObject Build() => _operation.DeepClone().AsObject(); + + private static JsonObject BuildSchema(Action configure) + { + var schema = new SchemaBuilder(); + configure(schema); + return schema.Build(); + } +} diff --git a/tests/OpenCode.Sdk.Tools.Tests/Support/ScenarioContext.cs b/tests/OpenCode.Sdk.Tools.Tests/Support/ScenarioContext.cs new file mode 100644 index 00000000..42801d6a --- /dev/null +++ b/tests/OpenCode.Sdk.Tools.Tests/Support/ScenarioContext.cs @@ -0,0 +1,5 @@ +using System.IO.Abstractions; + +namespace OpenCode.Sdk.Tools.Tests.Support; + +internal sealed record ScenarioContext(IFileSystem FileSystem, string SpecPath); diff --git a/tests/OpenCode.Sdk.Tools.Tests/Support/SchemaBuilder.cs b/tests/OpenCode.Sdk.Tools.Tests/Support/SchemaBuilder.cs new file mode 100644 index 00000000..22311ec4 --- /dev/null +++ b/tests/OpenCode.Sdk.Tools.Tests/Support/SchemaBuilder.cs @@ -0,0 +1,231 @@ +using System.Text.Json.Nodes; + +namespace OpenCode.Sdk.Tools.Tests.Support; + +internal sealed class SchemaBuilder +{ + private readonly JsonObject _schema = []; + + public SchemaBuilder Type(string type) + { + ArgumentException.ThrowIfNullOrWhiteSpace(type); + + _schema["type"] = type; + return this; + } + + public SchemaBuilder Property(string name, Action configure, bool required = false) + { + ArgumentException.ThrowIfNullOrWhiteSpace(name); + ArgumentNullException.ThrowIfNull(configure); + + if (_schema["properties"] is not JsonObject properties) + { + properties = []; + _schema["properties"] = properties; + } + + var property = new SchemaBuilder(); + configure(property); + properties[name] = property.Build(); + + if (required) + { + Required(name); + } + + return this; + } + + public SchemaBuilder Required(params string[] names) + { + ArgumentNullException.ThrowIfNull(names); + + if (_schema["required"] is not JsonArray required) + { + required = []; + _schema["required"] = required; + } + + foreach (var name in names) + { + if (string.IsNullOrWhiteSpace(name)) + { + throw new ArgumentException("Required names cannot contain null or whitespace.", nameof(names)); + } + + if (!required.Any(node => node?.GetValue() == name)) + { + required.Add(name); + } + } + + return this; + } + + public SchemaBuilder Enum(params string[] values) + { + ArgumentNullException.ThrowIfNull(values); + + var enumValues = new JsonArray(); + foreach (var value in values) + { + if (value is null) + { + throw new ArgumentException("Enum values cannot contain null.", nameof(values)); + } + + enumValues.Add(value); + } + + _schema["enum"] = enumValues; + return this; + } + + public SchemaBuilder Const(string value) + { + ArgumentNullException.ThrowIfNull(value); + + _schema["const"] = value; + return this; + } + + public SchemaBuilder AnyOf(params Action[] configure) => AddComposition("anyOf", configure); + + public SchemaBuilder OneOf(params Action[] configure) => AddComposition("oneOf", configure); + + public SchemaBuilder AllOf(params Action[] configure) => AddComposition("allOf", configure); + + public SchemaBuilder AdditionalPropertiesFalse() + { + _schema["additionalProperties"] = false; + return this; + } + + public SchemaBuilder Ref(string target) + { + ArgumentException.ThrowIfNullOrWhiteSpace(target); + + _schema["$ref"] = $"#/components/schemas/{target}"; + return this; + } + + public SchemaBuilder Items(Action configure) + { + ArgumentNullException.ThrowIfNull(configure); + + _schema["items"] = BuildSchema(configure); + return this; + } + + public SchemaBuilder PrefixItems(params Action[] configure) + { + ArgumentNullException.ThrowIfNull(configure); + + var items = new JsonArray(); + foreach (var item in configure) + { + if (item is null) + { + throw new ArgumentException("Prefix item configurations cannot contain null.", nameof(configure)); + } + + items.Add(BuildSchema(item)); + } + + _schema["prefixItems"] = items; + return this; + } + + public SchemaBuilder AdditionalProperties(Action configure) + { + ArgumentNullException.ThrowIfNull(configure); + + _schema["additionalProperties"] = BuildSchema(configure); + return this; + } + + public SchemaBuilder PatternProperties(string pattern, Action configure) + { + ArgumentException.ThrowIfNullOrWhiteSpace(pattern); + ArgumentNullException.ThrowIfNull(configure); + + if (_schema["patternProperties"] is not JsonObject patterns) + { + patterns = []; + _schema["patternProperties"] = patterns; + } + + patterns[pattern] = BuildSchema(configure); + return this; + } + + public SchemaBuilder ContentSchema(string mediaType, Action configure) + { + ArgumentException.ThrowIfNullOrWhiteSpace(mediaType); + ArgumentNullException.ThrowIfNull(configure); + + _schema["contentMediaType"] = mediaType; + _schema["contentSchema"] = BuildSchema(configure); + return this; + } + + public SchemaBuilder Unrestricted() + { + _schema.Clear(); + return this; + } + + public SchemaBuilder Raw(string key, string rawJson) + { + ArgumentException.ThrowIfNullOrWhiteSpace(key); + ArgumentNullException.ThrowIfNull(rawJson); + + _schema[key] = JsonNode.Parse(rawJson); + return this; + } + + public SchemaBuilder Description(string description) + { + ArgumentNullException.ThrowIfNull(description); + + _schema["description"] = description; + return this; + } + + public SchemaBuilder Format(string format) + { + ArgumentException.ThrowIfNullOrWhiteSpace(format); + + _schema["format"] = format; + return this; + } + + internal JsonObject Build() => _schema.DeepClone().AsObject(); + + private SchemaBuilder AddComposition(string keyword, IEnumerable> configure) + { + ArgumentNullException.ThrowIfNull(configure); + + var branches = new JsonArray(); + foreach (var branch in configure) + { + if (branch is null) + { + throw new ArgumentException("Composition configurations cannot contain null.", nameof(configure)); + } + + branches.Add(BuildSchema(branch)); + } + + _schema[keyword] = branches; + return this; + } + + private static JsonObject BuildSchema(Action configure) + { + var schema = new SchemaBuilder(); + configure(schema); + return schema.Build(); + } +} diff --git a/tests/OpenCode.Sdk.Tools.Tests/Support/SpecDocumentBuilder.cs b/tests/OpenCode.Sdk.Tools.Tests/Support/SpecDocumentBuilder.cs new file mode 100644 index 00000000..c0f60011 --- /dev/null +++ b/tests/OpenCode.Sdk.Tools.Tests/Support/SpecDocumentBuilder.cs @@ -0,0 +1,104 @@ +using System.Text.Json.Nodes; + +namespace OpenCode.Sdk.Tools.Tests.Support; + +internal sealed class SpecDocumentBuilder +{ + private readonly FixtureLoader _fixtureLoader; + private readonly JsonObject _paths = []; + private readonly JsonObject _schemas = []; + private readonly JsonObject _root; + + public SpecDocumentBuilder() + : this(new FixtureLoader()) + { + } + + public SpecDocumentBuilder(FixtureLoader fixtureLoader) + { + ArgumentNullException.ThrowIfNull(fixtureLoader); + + _fixtureLoader = fixtureLoader; + _root = new JsonObject + { + ["openapi"] = "3.1.0", + ["info"] = new JsonObject + { + ["title"] = "Test API", + ["version"] = "1.0.0", + }, + ["paths"] = _paths, + ["components"] = new JsonObject + { + ["schemas"] = _schemas, + }, + }; + } + + public SpecDocumentBuilder WithOpenApiVersion(string version = "3.1.0") + { + ArgumentException.ThrowIfNullOrWhiteSpace(version); + + _root["openapi"] = version; + return this; + } + + public SpecDocumentBuilder WithRawSchema(string name, string fixtureName) + { + ArgumentException.ThrowIfNullOrWhiteSpace(name); + ArgumentException.ThrowIfNullOrWhiteSpace(fixtureName); + + _schemas[name] = JsonNode.Parse(_fixtureLoader.Load(fixtureName)); + return this; + } + + public SpecDocumentBuilder WithSchema(string name, Action configure) + { + ArgumentException.ThrowIfNullOrWhiteSpace(name); + ArgumentNullException.ThrowIfNull(configure); + + var schema = new SchemaBuilder(); + configure(schema); + _schemas[name] = schema.Build(); + return this; + } + + public SpecDocumentBuilder WithOperation(string operationId, string method = "get", string path = "/api/x", + Action? configure = null) + { + ArgumentException.ThrowIfNullOrWhiteSpace(operationId); + ArgumentException.ThrowIfNullOrWhiteSpace(method); + ArgumentException.ThrowIfNullOrWhiteSpace(path); + + var operation = new OperationBuilder(operationId); + configure?.Invoke(operation); + + if (_paths[path] is not JsonObject pathItem) + { + pathItem = []; + _paths[path] = pathItem; + } + + pathItem[method] = operation.Build(); + return this; + } + + public SpecDocumentBuilder WithRawTopLevel(string key, string rawJson) + { + ArgumentException.ThrowIfNullOrWhiteSpace(key); + ArgumentNullException.ThrowIfNull(rawJson); + + _root[key] = JsonNode.Parse(rawJson); + return this; + } + + public SpecDocumentBuilder WithRawTopLevelFromFixture(string key, string fixtureName) + { + ArgumentException.ThrowIfNullOrWhiteSpace(key); + ArgumentException.ThrowIfNullOrWhiteSpace(fixtureName); + + return WithRawTopLevel(key, _fixtureLoader.Load(fixtureName)); + } + + public string BuildJson() => _root.ToJsonString(); +} diff --git a/tests/OpenCode.Sdk.Tools.Tests/Support/SpecDocumentBuilderTests.cs b/tests/OpenCode.Sdk.Tools.Tests/Support/SpecDocumentBuilderTests.cs new file mode 100644 index 00000000..2f0df79e --- /dev/null +++ b/tests/OpenCode.Sdk.Tools.Tests/Support/SpecDocumentBuilderTests.cs @@ -0,0 +1,232 @@ +using System.Text.Json.Nodes; + +namespace OpenCode.Sdk.Tools.Tests.Support; + +public sealed class SpecDocumentBuilderTests +{ + [Test] + public async Task WithRawSchema_Should_Report_Missing_Embedded_Fixture() + { + var builder = new SpecDocumentBuilder(new FixtureLoader()); + + var exception = await Assert + .That(() => builder.WithRawSchema("Missing", "no-such-schema.json")) + .Throws(); + var message = exception?.Message ?? throw new InvalidOperationException("The assertion did not return the exception."); + + await Assert.That(message).Contains("no-such-schema.json"); + } + + [Test] + public async Task WithRawTopLevelFromFixture_Should_Report_Missing_Embedded_Fixture() + { + var builder = new SpecDocumentBuilder(new FixtureLoader()); + + var exception = await Assert + .That(() => builder.WithRawTopLevelFromFixture("webhooks", "no-such-wall.json")) + .Throws(); + var message = exception?.Message ?? throw new InvalidOperationException("The assertion did not return the exception."); + + await Assert.That(message).Contains("no-such-wall.json"); + } + + [Test] + public async Task BuildJson_Should_Apply_Document_Level_Overrides() + { + var json = new SpecDocumentBuilder() + .WithOpenApiVersion("3.2.0") + .WithRawTopLevel("x-document", "true") + .BuildJson(); + + var root = JsonNode.Parse(json) ?? throw new InvalidOperationException("The builder returned the JSON null literal."); + + await Assert.That(root["openapi"]?.GetValue()).IsEqualTo("3.2.0"); + await Assert.That(root["x-document"]?.GetValue()).IsTrue(); + } + + [Test] + public async Task BuildJson_Should_Produce_Document_With_Schema_And_Operation() + { + var json = new SpecDocumentBuilder() + .WithSchema( + "Session", + schema => schema + .Type("object") + .Property("id", property => property.Type("string"), required: true) + .AdditionalPropertiesFalse()) + .WithOperation( + "v2.session.get", + method: "get", + path: "/api/session/{sessionID}", + configure: operation => operation + .Parameter( + "sessionID", + "path", + schema => schema.Type("string"), + required: true) + .Response(200, "application/json", schema => schema.Ref("Session"))) + .BuildJson(); + + var root = JsonNode.Parse(json) + ?? throw new InvalidOperationException("The builder returned the JSON null literal."); + + await Assert.That(root["openapi"]!.GetValue()).IsEqualTo("3.1.0"); + await Assert + .That(root["components"]!["schemas"]!["Session"]!["required"]!.AsArray()) + .Count() + .IsEqualTo(1); + await Assert + .That(root["paths"]!["/api/session/{sessionID}"]!["get"]!["operationId"]!.GetValue()) + .IsEqualTo("v2.session.get"); + } + + [Test] + public async Task BuildJson_Should_Produce_Composed_And_Collection_Schema_Keywords() + { + var json = new SpecDocumentBuilder() + .WithSchema( + "Composite", + schema => schema + .Type("object") + .Property( + "kind", + property => property + .Type("string") + .Enum("one", "two") + .Const("one")) + .Property( + "values", + property => property + .Type("array") + .Items(item => item.Type("string")) + .PrefixItems( + item => item.Type("string"), + item => item.Type("number"))) + .Property( + "choice", + property => property + .AnyOf( + branch => branch.Type("string"), + branch => branch.Type("number")) + .OneOf(branch => branch.Type("boolean")) + .AllOf(branch => branch.Ref("Shared"))) + .Required("kind") + .AdditionalProperties(value => value.Type("integer"))) + .BuildJson(); + + var root = JsonNode.Parse(json) ?? throw new InvalidOperationException("The builder returned the JSON null literal."); + var schema = root["components"]!["schemas"]!["Composite"]!; + + await Assert.That(schema["required"]![0]!.GetValue()).IsEqualTo("kind"); + await Assert + .That(schema["properties"]!["kind"]!["enum"]!.AsArray().Count) + .IsEqualTo(2); + await Assert + .That(schema["properties"]!["kind"]!["const"]!.GetValue()) + .IsEqualTo("one"); + await Assert + .That(schema["properties"]!["values"]!["items"]!["type"]!.GetValue()) + .IsEqualTo("string"); + await Assert + .That(schema["properties"]!["values"]!["prefixItems"]!.AsArray().Count) + .IsEqualTo(2); + await Assert + .That(schema["properties"]!["choice"]!["anyOf"]!.AsArray().Count) + .IsEqualTo(2); + await Assert + .That(schema["properties"]!["choice"]!["oneOf"]!.AsArray().Count) + .IsEqualTo(1); + await Assert + .That(schema["properties"]!["choice"]!["allOf"]!.AsArray().Count) + .IsEqualTo(1); + await Assert + .That(schema["additionalProperties"]!["type"]!.GetValue()) + .IsEqualTo("integer"); + } + + [Test] + public async Task BuildJson_Should_Produce_Specialized_Schema_Keywords() + { + var json = new SpecDocumentBuilder() + .WithSchema( + "Specialized", + schema => schema + .Type("object") + .Description("Specialized schema") + .Format("custom") + .PatternProperties("^x-", value => value.Type("string")) + .ContentSchema("application/json", content => content.Type("object")) + .Property("anything", property => property.Unrestricted()) + .Raw("x-test-keyword", "true")) + .BuildJson(); + + var root = JsonNode.Parse(json) + ?? throw new InvalidOperationException("The builder returned the JSON null literal."); + var schema = root["components"]!["schemas"]!["Specialized"]!; + + await Assert + .That(schema["description"]!.GetValue()) + .IsEqualTo("Specialized schema"); + await Assert.That(schema["format"]!.GetValue()).IsEqualTo("custom"); + await Assert + .That(schema["patternProperties"]!["^x-"]!["type"]!.GetValue()) + .IsEqualTo("string"); + await Assert + .That(schema["contentMediaType"]!.GetValue()) + .IsEqualTo("application/json"); + await Assert + .That(schema["contentSchema"]!["type"]!.GetValue()) + .IsEqualTo("object"); + await Assert.That(schema["properties"]!["anything"]!.AsObject().Count).IsEqualTo(0); + await Assert.That(schema["x-test-keyword"]!.GetValue()).IsTrue(); + } + + [Test] + public async Task BuildJson_Should_Produce_Operation_Request_Response_And_Metadata() + { + var json = new SpecDocumentBuilder() + .WithOperation( + "v2.events.subscribe", + method: "post", + path: "/api/events", + configure: operation => operation + .Parameter( + "filter", + "query", + schema => schema.Type("object"), + deepObject: true) + .RequestBody( + "application/json", + schema => schema.Ref("Subscription"), + required: true) + .Response(204) + .SseResponse( + schema => schema.Ref("Event"), + effectStreamJson: "{\"encoding\":\"sse\"}") + .Extension("x-operation", "true") + .Raw("tags", "[\"events\"]") + .Deprecated() + .Summary("Subscribe to events")) + .BuildJson(); + + var root = JsonNode.Parse(json) ?? throw new InvalidOperationException("The builder returned the JSON null literal."); + var operation = root["paths"]!["/api/events"]!["post"]!; + var parameter = operation["parameters"]![0]!; + var requestBody = operation["requestBody"]!; + var eventStream = operation["responses"]!["200"]!["content"]!["text/event-stream"]!; + + await Assert.That(parameter["style"]!.GetValue()).IsEqualTo("deepObject"); + await Assert.That(parameter["explode"]!.GetValue()).IsTrue(); + await Assert.That(requestBody["required"]!.GetValue()).IsTrue(); + await Assert + .That(requestBody["content"]!["application/json"]!["schema"]!["$ref"]!.GetValue()) + .IsEqualTo("#/components/schemas/Subscription"); + await Assert.That(operation["responses"]!["204"]).IsNotNull(); + await Assert.That(eventStream["schema"]!["$ref"]!.GetValue()).IsEqualTo("#/components/schemas/Event"); + await Assert.That(eventStream["x-effect-stream"]!["encoding"]!.GetValue()).IsEqualTo("sse"); + await Assert.That(operation["x-operation"]!.GetValue()).IsTrue(); + await Assert.That(operation["tags"]![0]!.GetValue()).IsEqualTo("events"); + await Assert.That(operation["deprecated"]!.GetValue()).IsTrue(); + await Assert.That(operation["summary"]!.GetValue()).IsEqualTo("Subscribe to events"); + } +} diff --git a/tests/OpenCode.Sdk.Tools.Tests/Support/SpecScenario.cs b/tests/OpenCode.Sdk.Tools.Tests/Support/SpecScenario.cs new file mode 100644 index 00000000..f2ab9e93 --- /dev/null +++ b/tests/OpenCode.Sdk.Tools.Tests/Support/SpecScenario.cs @@ -0,0 +1,51 @@ +using Testably.Abstractions.Testing; +using Testably.Abstractions.Testing.Initializer; + +namespace OpenCode.Sdk.Tools.Tests.Support; + +internal abstract class SpecScenario +{ + private const string CanonicalSpecPath = "spec/openapi.json"; + + public static SpecScenario Define(Action arrange) + { + ArgumentNullException.ThrowIfNull(arrange); + return new DefinedSpecScenario(arrange); + } + + public static SpecScenario FromRawJson(string rawJson) + { + ArgumentNullException.ThrowIfNull(rawJson); + return new RawJsonSpecScenario(rawJson); + } + + public ScenarioContext Build() + { + var fileSystem = new MockFileSystem(); + var payload = CreatePayload(new FixtureLoader()); + fileSystem.Initialize().With(new FileDescription(CanonicalSpecPath, payload)); + + return new ScenarioContext(fileSystem, CanonicalSpecPath); + } + + protected virtual void Arrange(SpecDocumentBuilder spec) + { + } + + private protected virtual string CreatePayload(FixtureLoader fixtureLoader) + { + var document = new SpecDocumentBuilder(fixtureLoader); + Arrange(document); + return document.BuildJson(); + } + + private sealed class DefinedSpecScenario(Action arrange) : SpecScenario + { + protected override void Arrange(SpecDocumentBuilder spec) => arrange(spec); + } + + private sealed class RawJsonSpecScenario(string rawJson) : SpecScenario + { + private protected override string CreatePayload(FixtureLoader fixtureLoader) => rawJson; + } +} diff --git a/tests/OpenCode.Sdk.Tools.Tests/Support/SpecScenarioTests.cs b/tests/OpenCode.Sdk.Tools.Tests/Support/SpecScenarioTests.cs new file mode 100644 index 00000000..afb1fa34 --- /dev/null +++ b/tests/OpenCode.Sdk.Tools.Tests/Support/SpecScenarioTests.cs @@ -0,0 +1,26 @@ +namespace OpenCode.Sdk.Tools.Tests.Support; + +public sealed class SpecScenarioTests +{ + [Test] + public async Task Build_Should_Write_Spec_To_Mock_FileSystem() + { + var context = SpecScenario.Define(_ => { }).Build(); + + await Assert.That(context.FileSystem.File.Exists(context.SpecPath)).IsTrue(); + await Assert.That(context.SpecPath).IsEqualTo("spec/openapi.json"); + } + + [Test] + public async Task FromRawJson_Should_Write_The_Subject_Payload_Unchanged() + { + const string rawJson = "{ not json"; + var context = SpecScenario.FromRawJson(rawJson).Build(); + + var actual = await context.FileSystem.File.ReadAllTextAsync( + context.SpecPath, + CancellationToken.None); + + await Assert.That(actual).IsEqualTo(rawJson); + } +} From b412a2f8c6f8f97d5c9b448c0d0f032332402bf9 Mon Sep 17 00:00:00 2001 From: "denizirgin@gmail.com" Date: Tue, 11 Aug 2026 11:41:41 +0300 Subject: [PATCH 03/19] refactor(tools): simplify schema requirement check --- tests/OpenCode.Sdk.Tools.Tests/Support/SchemaBuilder.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/OpenCode.Sdk.Tools.Tests/Support/SchemaBuilder.cs b/tests/OpenCode.Sdk.Tools.Tests/Support/SchemaBuilder.cs index 22311ec4..12878cc0 100644 --- a/tests/OpenCode.Sdk.Tools.Tests/Support/SchemaBuilder.cs +++ b/tests/OpenCode.Sdk.Tools.Tests/Support/SchemaBuilder.cs @@ -54,7 +54,7 @@ public SchemaBuilder Required(params string[] names) throw new ArgumentException("Required names cannot contain null or whitespace.", nameof(names)); } - if (!required.Any(node => node?.GetValue() == name)) + if (required.All(node => node?.GetValue() != name)) { required.Add(name); } From db823d08a8d2d8db5ece5245b86a1f47e8c37a67 Mon Sep 17 00:00:00 2001 From: "denizirgin@gmail.com" Date: Tue, 11 Aug 2026 12:25:19 +0300 Subject: [PATCH 04/19] build: produce downlevel reference assemblies --- src/OpenCode.Sdk/OpenCode.Sdk.csproj | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/OpenCode.Sdk/OpenCode.Sdk.csproj b/src/OpenCode.Sdk/OpenCode.Sdk.csproj index 95fa9b9f..44d712b2 100644 --- a/src/OpenCode.Sdk/OpenCode.Sdk.csproj +++ b/src/OpenCode.Sdk/OpenCode.Sdk.csproj @@ -5,6 +5,8 @@ Unofficial .NET SDK for opencode: a typed client for the opencode HTTP API. opencode;sdk;client;ai;agent true + + true From cb68244dc067495d57796cbb7aa9387b126f27bc Mon Sep 17 00:00:00 2001 From: "denizirgin@gmail.com" Date: Tue, 11 Aug 2026 12:41:04 +0300 Subject: [PATCH 05/19] feat(tools): spec reader gate with version, diagnostic and crash walls --- .../Fixtures/boolean-property-schema.json | 6 ++ .../SpecReaderTests.cs | 74 ++++++++++++++++++ .../Ingestion/IngestionErrorCollector.cs | 25 +++++++ .../Generator/Ingestion/LoadedSpec.cs | 6 ++ .../Ingestion/Models/IngestionError.cs | 6 ++ .../Ingestion/Models/IngestionException.cs | 75 +++++++++++++++++++ .../Generator/Ingestion/SpecReader.cs | 67 +++++++++++++++++ .../OpenCode.Sdk.Tools.csproj | 2 + .../Properties/AssemblyInfo.cs | 3 + 9 files changed, 264 insertions(+) create mode 100644 tests/OpenCode.Sdk.Tools.Tests/Fixtures/boolean-property-schema.json create mode 100644 tests/OpenCode.Sdk.Tools.Tests/SpecReaderTests.cs create mode 100644 tools/OpenCode.Sdk.Tools/Generator/Ingestion/IngestionErrorCollector.cs create mode 100644 tools/OpenCode.Sdk.Tools/Generator/Ingestion/LoadedSpec.cs create mode 100644 tools/OpenCode.Sdk.Tools/Generator/Ingestion/Models/IngestionError.cs create mode 100644 tools/OpenCode.Sdk.Tools/Generator/Ingestion/Models/IngestionException.cs create mode 100644 tools/OpenCode.Sdk.Tools/Generator/Ingestion/SpecReader.cs create mode 100644 tools/OpenCode.Sdk.Tools/Properties/AssemblyInfo.cs diff --git a/tests/OpenCode.Sdk.Tools.Tests/Fixtures/boolean-property-schema.json b/tests/OpenCode.Sdk.Tools.Tests/Fixtures/boolean-property-schema.json new file mode 100644 index 00000000..24e8e56e --- /dev/null +++ b/tests/OpenCode.Sdk.Tools.Tests/Fixtures/boolean-property-schema.json @@ -0,0 +1,6 @@ +{ + "type": "object", + "properties": { + "enabled": true + } +} diff --git a/tests/OpenCode.Sdk.Tools.Tests/SpecReaderTests.cs b/tests/OpenCode.Sdk.Tools.Tests/SpecReaderTests.cs new file mode 100644 index 00000000..7c41a2d9 --- /dev/null +++ b/tests/OpenCode.Sdk.Tools.Tests/SpecReaderTests.cs @@ -0,0 +1,74 @@ +using OpenCode.Sdk.Tools.Generator.Ingestion; +using OpenCode.Sdk.Tools.Generator.Ingestion.Models; +using OpenCode.Sdk.Tools.Tests.Support; +using Testably.Abstractions.Testing; + +namespace OpenCode.Sdk.Tools.Tests; + +public sealed class SpecReaderTests +{ + private static async Task LoadExpectingRefusal(SpecScenario scenario) + { + var context = scenario.Build(); + var reader = new SpecReader(context.FileSystem); + var errors = new IngestionErrorCollector(); + var ex = await Assert + .That(async () => { _ = await reader.LoadAsync(context.SpecPath, errors, CancellationToken.None); }) + .Throws(); + return ex!; + } + + [Test] + public async Task LoadAsync_Should_Return_Document_And_Raw_For_Valid_31_Document() + { + var context = SpecScenario.Define(_ => { }).Build(); + var reader = new SpecReader(context.FileSystem); + + var loaded = await reader.LoadAsync(context.SpecPath, new IngestionErrorCollector(), CancellationToken.None); + + await Assert.That(loaded.Document.Paths).IsNotNull(); + await Assert.That(loaded.Raw["openapi"]!.GetValue()).IsEqualTo("3.1.0"); + } + + [Test] + public async Task LoadAsync_Should_Refuse_When_Version_Is_Not_31() + { + var ex = await LoadExpectingRefusal( + SpecScenario.Define(spec => spec.WithOpenApiVersion("3.2.0"))); + await Assert.That(ex.Message).Contains("3.2"); + } + + [Test] + public async Task LoadAsync_Should_Refuse_When_Spec_File_Is_Missing() + { + var reader = new SpecReader(new MockFileSystem()); + var ex = await Assert + .That(async () => { _ = await reader.LoadAsync("spec/openapi.json", new IngestionErrorCollector(), CancellationToken.None); }) + .Throws(); + await Assert.That(ex!.Message).Contains("spec/openapi.json"); + } + + [Test] + public async Task LoadAsync_Should_Translate_Reader_Crash_When_Schema_Is_Boolean() + { + var ex = await LoadExpectingRefusal(SpecScenario.Define(spec => + spec.WithRawSchema("Bad", "boolean-property-schema.json"))); + await Assert.That(ex.Message).Contains("reader failed"); + } + + [Test] + public async Task LoadAsync_Should_Promote_Reader_Diagnostics_To_Errors() + { + var ex = await LoadExpectingRefusal(SpecScenario.Define(spec => + spec.WithOperation("v2.test.get", configure: operation => + operation.Raw("madeUpKey", "{}")))); + await Assert.That(ex.Message).Contains("madeUpKey"); + } + + [Test] + public async Task LoadAsync_Should_Refuse_When_Json_Is_Malformed() + { + var ex = await LoadExpectingRefusal(SpecScenario.FromRawJson("{ not json")); + await Assert.That(ex.Message).Contains("reader failed"); + } +} diff --git a/tools/OpenCode.Sdk.Tools/Generator/Ingestion/IngestionErrorCollector.cs b/tools/OpenCode.Sdk.Tools/Generator/Ingestion/IngestionErrorCollector.cs new file mode 100644 index 00000000..85720375 --- /dev/null +++ b/tools/OpenCode.Sdk.Tools/Generator/Ingestion/IngestionErrorCollector.cs @@ -0,0 +1,25 @@ +using OpenCode.Sdk.Tools.Generator.Ingestion.Models; + +namespace OpenCode.Sdk.Tools.Generator.Ingestion; + +internal sealed class IngestionErrorCollector +{ + private readonly List _errors = []; + + public bool HasErrors => _errors.Count > 0; + + public void Add(string location, string problem) + { + ArgumentException.ThrowIfNullOrWhiteSpace(location); + ArgumentException.ThrowIfNullOrWhiteSpace(problem); + _errors.Add(new IngestionError(location, problem)); + } + + public void ThrowIfAny() + { + if (HasErrors) + { + throw new IngestionException(_errors); + } + } +} diff --git a/tools/OpenCode.Sdk.Tools/Generator/Ingestion/LoadedSpec.cs b/tools/OpenCode.Sdk.Tools/Generator/Ingestion/LoadedSpec.cs new file mode 100644 index 00000000..a000815a --- /dev/null +++ b/tools/OpenCode.Sdk.Tools/Generator/Ingestion/LoadedSpec.cs @@ -0,0 +1,6 @@ +using System.Text.Json.Nodes; +using Microsoft.OpenApi; + +namespace OpenCode.Sdk.Tools.Generator.Ingestion; + +internal sealed record LoadedSpec(OpenApiDocument Document, JsonNode Raw); diff --git a/tools/OpenCode.Sdk.Tools/Generator/Ingestion/Models/IngestionError.cs b/tools/OpenCode.Sdk.Tools/Generator/Ingestion/Models/IngestionError.cs new file mode 100644 index 00000000..6e786086 --- /dev/null +++ b/tools/OpenCode.Sdk.Tools/Generator/Ingestion/Models/IngestionError.cs @@ -0,0 +1,6 @@ +namespace OpenCode.Sdk.Tools.Generator.Ingestion.Models; + +/// Describes one failure discovered while ingesting an OpenAPI document. +/// The document location associated with the failure. +/// A description of the failure. +public sealed record IngestionError(string Location, string Problem); diff --git a/tools/OpenCode.Sdk.Tools/Generator/Ingestion/Models/IngestionException.cs b/tools/OpenCode.Sdk.Tools/Generator/Ingestion/Models/IngestionException.cs new file mode 100644 index 00000000..280b7702 --- /dev/null +++ b/tools/OpenCode.Sdk.Tools/Generator/Ingestion/Models/IngestionException.cs @@ -0,0 +1,75 @@ +using System.Collections.ObjectModel; +using System.Globalization; +using System.Text; + +namespace OpenCode.Sdk.Tools.Generator.Ingestion.Models; + +/// Represents one or more failures that prevent OpenAPI ingestion. +public sealed class IngestionException : Exception +{ + private static readonly IReadOnlyList NoErrors = Array.AsReadOnly(Array.Empty()); + + /// Initializes an exception without ingestion errors. + public IngestionException() + { + Errors = NoErrors; + } + + /// Initializes an exception with a caller-supplied message. + /// The message that describes the exception. + public IngestionException(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 IngestionException(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 ingestion errors. + /// The failures that prevent ingestion. + public IngestionException(IReadOnlyList errors) + : this(CopyErrors(errors)) + { + } + + private IngestionException(ReadOnlyCollection errors) + : base(CreateMessage(errors)) + { + Errors = errors; + } + + /// Gets the failures that prevent ingestion. + public IReadOnlyList Errors { get; } + + private static ReadOnlyCollection CopyErrors(IReadOnlyList errors) + { + ArgumentNullException.ThrowIfNull(errors); + return Array.AsReadOnly(errors.ToArray()); + } + + private static string CreateMessage(ReadOnlyCollection errors) + { + var message = new StringBuilder("Ingestion failed with ") + .Append(errors.Count.ToString(CultureInfo.InvariantCulture)) + .Append(" error(s):"); + + foreach (var error in errors) + { + message + .Append('\n') + .Append("- ") + .Append(error.Location) + .Append(": ") + .Append(error.Problem); + } + + return message.ToString(); + } +} diff --git a/tools/OpenCode.Sdk.Tools/Generator/Ingestion/SpecReader.cs b/tools/OpenCode.Sdk.Tools/Generator/Ingestion/SpecReader.cs new file mode 100644 index 00000000..f3001373 --- /dev/null +++ b/tools/OpenCode.Sdk.Tools/Generator/Ingestion/SpecReader.cs @@ -0,0 +1,67 @@ +using System.IO.Abstractions; +using System.Text.Json.Nodes; +using Microsoft.OpenApi; +using Microsoft.OpenApi.Reader; + +namespace OpenCode.Sdk.Tools.Generator.Ingestion; + +internal sealed class SpecReader(IFileSystem fileSystem) +{ + private readonly IFileSystem _fileSystem = fileSystem ?? throw new ArgumentNullException(nameof(fileSystem)); + + public async Task LoadAsync(string specPath, IngestionErrorCollector errors, CancellationToken ct) + { + ArgumentException.ThrowIfNullOrWhiteSpace(specPath); + ArgumentNullException.ThrowIfNull(errors); + + if (!_fileSystem.File.Exists(specPath)) + { + errors.Add("document", $"spec file was not found — {specPath}"); + errors.ThrowIfAny(); + } + + var stream = _fileSystem.File.OpenRead(specPath); + await using (stream.ConfigureAwait(false)) + { + var settings = new OpenApiReaderSettings + { + LeaveStreamOpen = true, + }; + + ReadResult result; + JsonNode raw; + try + { + result = await OpenApiDocument.LoadAsync(stream, "json", settings, ct).ConfigureAwait(false); + stream.Position = 0; + raw = await JsonNode.ParseAsync(stream, cancellationToken: ct).ConfigureAwait(false) + ?? throw new InvalidOperationException("The OpenAPI reader accepted an empty JSON document."); + } + catch (Exception exception) when (exception is not OperationCanceledException) + { + errors.Add("document", $"the reader failed — {exception.GetType().Name}: {exception.Message}"); + errors.ThrowIfAny(); + throw; + } + + var diagnostic = result.Diagnostic + ?? throw new InvalidOperationException("The OpenAPI reader returned no diagnostic information."); + + foreach (var error in diagnostic.Errors) + { + errors.Add(string.IsNullOrEmpty(error.Pointer) ? "document" : error.Pointer, error.Message); + } + + if (diagnostic.SpecificationVersion is not OpenApiSpecVersion.OpenApi3_1) + { + var declaredVersion = raw["openapi"]?.GetValue() ?? diagnostic.SpecificationVersion.ToString(); + errors.Add("document", $"OpenAPI specification version '{declaredVersion}' is not supported; expected 3.1."); + } + + errors.ThrowIfAny(); + var document = result.Document + ?? throw new InvalidOperationException("The OpenAPI reader returned no document without a diagnostic error."); + return new LoadedSpec(document, raw); + } + } +} diff --git a/tools/OpenCode.Sdk.Tools/OpenCode.Sdk.Tools.csproj b/tools/OpenCode.Sdk.Tools/OpenCode.Sdk.Tools.csproj index e740ca0f..7932a5da 100644 --- a/tools/OpenCode.Sdk.Tools/OpenCode.Sdk.Tools.csproj +++ b/tools/OpenCode.Sdk.Tools/OpenCode.Sdk.Tools.csproj @@ -3,6 +3,8 @@ $(DefaultTargetFramework) false + + true diff --git a/tools/OpenCode.Sdk.Tools/Properties/AssemblyInfo.cs b/tools/OpenCode.Sdk.Tools/Properties/AssemblyInfo.cs new file mode 100644 index 00000000..e155c9bb --- /dev/null +++ b/tools/OpenCode.Sdk.Tools/Properties/AssemblyInfo.cs @@ -0,0 +1,3 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("OpenCode.Sdk.Tools.Tests")] From 1d38b5b07cdd8af936edfd018d90790f0ddd6e45 Mon Sep 17 00:00:00 2001 From: "denizirgin@gmail.com" Date: Tue, 11 Aug 2026 12:55:58 +0300 Subject: [PATCH 06/19] docs(plan): correct union graph-key task ordering --- .../plans/2026-08-11-slice-01-ingestion-specir.md | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/docs/superpowers/plans/2026-08-11-slice-01-ingestion-specir.md b/docs/superpowers/plans/2026-08-11-slice-01-ingestion-specir.md index 89b6d9de..e48e9b66 100644 --- a/docs/superpowers/plans/2026-08-11-slice-01-ingestion-specir.md +++ b/docs/superpowers/plans/2026-08-11-slice-01-ingestion-specir.md @@ -471,7 +471,7 @@ public sealed class SpecReaderTests `Models/UnrestrictedNode.cs` - Create: `Generator/Ingestion/Walls/SchemaWallPolicy.cs` (internal sealed) - Create: `Generator/Ingestion/Projection/SchemaProjector.cs` (internal sealed), - `Projection/GraphKeyBuilder.cs` (internal sealed) + `Projection/GraphKeyBuilder.cs` (internal sealed), `Projection/ProjectionState.cs` (internal) - Create: `tests/OpenCode.Sdk.Tools.Tests/Support/SchemaProjectionTestHost.cs`, `Support/SchemaProjectionResult.cs` - Test: `tests/.../SchemaWallPolicyTests.cs`, `SchemaProjectorTests.cs`, `GraphKeyBuilderTests.cs` @@ -497,8 +497,7 @@ public sealed class SpecReaderTests (`prefixItems` at `Config/properties/plugin/items/anyOf/1` — Task 6 consumes it); schema-level `Extensions` → error. Bookkeeping members (`Metadata`) exempt. - `internal sealed class GraphKeyBuilder` — `string Root(string wireNameOrOpId)`, - `string Append(string parentPointer, string segment)` (escapes `~`→`~0`, `/`→`~1`), - `string UnionBranch(string parentPointer, string keyword, int index, LiteralMarker? marker)`. + `string Append(string parentPointer, string segment)` (escapes `~`→`~0`, `/`→`~1`). - `internal sealed class SchemaProjector(SchemaWallPolicy wall, GraphKeyBuilder keys)` — `SchemaNode? Project(IOpenApiSchema schema, string root, string pointer, ProjectionState state)` where `ProjectionState` (internal) carries the graph dictionary + error collector + @@ -638,7 +637,8 @@ public async Task Project_Should_Keep_Property_Schema_And_Dictionary_Value_For_H `Models/TupleNode.cs`, `Models/JsonStringNode.cs`, `Models/ErrorStyle.cs` - Create: `Projection/UnionNormalizer.cs`, `Projection/LiteralClassifier.cs`, `Projection/ErrorStyleClassifier.cs`, `Projection/PrefixItemsAdapter.cs` (all internal sealed) -- Modify: `Projection/SchemaProjector.cs`, `Models/ObjectNode.cs` construction site +- Modify: `Projection/SchemaProjector.cs`, `Projection/GraphKeyBuilder.cs`, + `Models/ObjectNode.cs` construction site - Test: `tests/.../UnionNormalizerTests.cs`, `LiteralClassifierTests.cs`, `ErrorStyleClassifierTests.cs`, `PrefixItemsAdapterTests.cs` @@ -653,6 +653,10 @@ public async Task Project_Should_Keep_Property_Schema_And_Dictionary_Value_For_H - `LiteralMarker { required string PropertyName; required LiteralKind Kind; required string Value; }` — computed on `ObjectNode` construction: required properties whose schema is a `LiteralNode`, property order (mechanical — never a name list). + - `GraphKeyBuilder` gains + `string UnionBranch(string parentPointer, string keyword, int index, LiteralMarker? marker)` + once the marker model exists; marked branches use `{property}={value}` and unmarked + branches use the ordinal index. - `ErrorStyle { None, EffectTag, NameData }` via `ErrorStyleClassifier`: required `_tag` literal → `EffectTag`; required `name` literal + required `data` → `NameData`. - `UnionNode { required IReadOnlyList Branches; required UnionKeyword Keyword; required UnionClassification Classification; }` From f91be43957ffe666a4632e0e292937d3cd97170f Mon Sep 17 00:00:00 2001 From: "denizirgin@gmail.com" Date: Tue, 11 Aug 2026 13:17:33 +0300 Subject: [PATCH 07/19] feat(tools): schema wall policy and core schema projection --- .editorconfig | 7 + .../GraphKeyBuilderTests.cs | 26 ++ .../SchemaProjectorTests.cs | 233 ++++++++++++++++++ .../SchemaWallPolicyTests.cs | 158 ++++++++++++ .../Support/SchemaProjectionResult.cs | 19 ++ .../Support/SchemaProjectionTestHost.cs | 58 +++++ .../Generator/Ingestion/Models/ArrayNode.cs | 11 + .../Generator/Ingestion/Models/EnumNode.cs | 19 ++ .../Ingestion/Models/PrimitiveKind.cs | 17 ++ .../Ingestion/Models/PrimitiveNode.cs | 11 + .../Generator/Ingestion/Models/RefNode.cs | 11 + .../Generator/Ingestion/Models/SchemaNode.cs | 14 ++ .../Ingestion/Models/UnrestrictedNode.cs | 8 + .../Ingestion/Projection/GraphKeyBuilder.cs | 22 ++ .../Ingestion/Projection/ProjectionState.cs | 80 ++++++ .../Ingestion/Projection/SchemaProjector.cs | 151 ++++++++++++ .../Ingestion/Walls/SchemaWallPolicy.cs | 112 +++++++++ 17 files changed, 957 insertions(+) create mode 100644 tests/OpenCode.Sdk.Tools.Tests/GraphKeyBuilderTests.cs create mode 100644 tests/OpenCode.Sdk.Tools.Tests/SchemaProjectorTests.cs create mode 100644 tests/OpenCode.Sdk.Tools.Tests/SchemaWallPolicyTests.cs create mode 100644 tests/OpenCode.Sdk.Tools.Tests/Support/SchemaProjectionResult.cs create mode 100644 tests/OpenCode.Sdk.Tools.Tests/Support/SchemaProjectionTestHost.cs create mode 100644 tools/OpenCode.Sdk.Tools/Generator/Ingestion/Models/ArrayNode.cs create mode 100644 tools/OpenCode.Sdk.Tools/Generator/Ingestion/Models/EnumNode.cs create mode 100644 tools/OpenCode.Sdk.Tools/Generator/Ingestion/Models/PrimitiveKind.cs create mode 100644 tools/OpenCode.Sdk.Tools/Generator/Ingestion/Models/PrimitiveNode.cs create mode 100644 tools/OpenCode.Sdk.Tools/Generator/Ingestion/Models/RefNode.cs create mode 100644 tools/OpenCode.Sdk.Tools/Generator/Ingestion/Models/SchemaNode.cs create mode 100644 tools/OpenCode.Sdk.Tools/Generator/Ingestion/Models/UnrestrictedNode.cs create mode 100644 tools/OpenCode.Sdk.Tools/Generator/Ingestion/Projection/GraphKeyBuilder.cs create mode 100644 tools/OpenCode.Sdk.Tools/Generator/Ingestion/Projection/ProjectionState.cs create mode 100644 tools/OpenCode.Sdk.Tools/Generator/Ingestion/Projection/SchemaProjector.cs create mode 100644 tools/OpenCode.Sdk.Tools/Generator/Ingestion/Walls/SchemaWallPolicy.cs diff --git a/.editorconfig b/.editorconfig index 554f588e..7677d807 100644 --- a/.editorconfig +++ b/.editorconfig @@ -751,6 +751,13 @@ dotnet_diagnostic.CA1034.severity = none [tools/OpenCode.Sdk.Tools/Generator/Ingestion/Models/PrimitiveKind.cs] dotnet_diagnostic.CA1720.severity = none +# GraphKeyBuilder is an injected collaborator whose instance interface keeps graph-key +# policy replaceable as later projection slices deepen it. Winner: the sealed projector +# collaborator contract; static-member rules remain enabled for every other file. +[tools/OpenCode.Sdk.Tools/Generator/Ingestion/Projection/GraphKeyBuilder.cs] +dotnet_diagnostic.CA1822.severity = none +dotnet_diagnostic.S2325.severity = none + # The wire-faithful SpecIR literal-kind contract likewise requires the exact # String member name. Winner: the wire-faithful public contract; CA1720 remains # enabled for every other file. diff --git a/tests/OpenCode.Sdk.Tools.Tests/GraphKeyBuilderTests.cs b/tests/OpenCode.Sdk.Tools.Tests/GraphKeyBuilderTests.cs new file mode 100644 index 00000000..369f952c --- /dev/null +++ b/tests/OpenCode.Sdk.Tools.Tests/GraphKeyBuilderTests.cs @@ -0,0 +1,26 @@ +using OpenCode.Sdk.Tools.Generator.Ingestion.Projection; + +namespace OpenCode.Sdk.Tools.Tests; + +public sealed class GraphKeyBuilderTests +{ + [Test] + public async Task Append_Should_Escape_Slash_In_Segment() + { + var keys = new GraphKeyBuilder(); + + var pointer = keys.Append(string.Empty, "a/b"); + + await Assert.That(pointer).IsEqualTo("/a~1b"); + } + + [Test] + public async Task Append_Should_Escape_Tilde_In_Segment() + { + var keys = new GraphKeyBuilder(); + + var pointer = keys.Append(string.Empty, "a~b"); + + await Assert.That(pointer).IsEqualTo("/a~0b"); + } +} diff --git a/tests/OpenCode.Sdk.Tools.Tests/SchemaProjectorTests.cs b/tests/OpenCode.Sdk.Tools.Tests/SchemaProjectorTests.cs new file mode 100644 index 00000000..a8e1144d --- /dev/null +++ b/tests/OpenCode.Sdk.Tools.Tests/SchemaProjectorTests.cs @@ -0,0 +1,233 @@ +using System.Text.Json.Nodes; +using OpenCode.Sdk.Tools.Generator.Ingestion; +using OpenCode.Sdk.Tools.Generator.Ingestion.Models; +using OpenCode.Sdk.Tools.Generator.Ingestion.Projection; +using OpenCode.Sdk.Tools.Generator.Ingestion.Walls; +using OpenCode.Sdk.Tools.Tests.Support; + +namespace OpenCode.Sdk.Tools.Tests; + +public sealed class SchemaProjectorTests +{ + [Test] + public async Task Project_Should_Produce_String_Primitive_Node() + { + var host = new SchemaProjectionTestHost(); + var scenario = SpecScenario.Define(spec => spec.WithSchema("Value", schema => schema.Type("string"))); + + var result = await host.ProjectAsync(scenario); + + await Assert.That(result.Schemas["Value"]).IsTypeOf(); + await Assert.That(((PrimitiveNode)result.Schemas["Value"]).Kind).IsEqualTo(PrimitiveKind.String); + } + + [Test] + public async Task Project_Should_Produce_Number_Primitive_Node() + { + var host = new SchemaProjectionTestHost(); + var scenario = SpecScenario.Define(spec => spec.WithSchema("Value", schema => schema.Type("number"))); + + var result = await host.ProjectAsync(scenario); + + await Assert.That(result.Schemas["Value"]).IsTypeOf(); + await Assert.That(((PrimitiveNode)result.Schemas["Value"]).Kind).IsEqualTo(PrimitiveKind.Number); + } + + [Test] + public async Task Project_Should_Produce_Integer_Primitive_Node() + { + var host = new SchemaProjectionTestHost(); + var scenario = SpecScenario.Define(spec => spec.WithSchema("Value", schema => schema.Type("integer"))); + + var result = await host.ProjectAsync(scenario); + + await Assert.That(result.Schemas["Value"]).IsTypeOf(); + await Assert.That(((PrimitiveNode)result.Schemas["Value"]).Kind).IsEqualTo(PrimitiveKind.Integer); + } + + [Test] + public async Task Project_Should_Produce_Boolean_Primitive_Node() + { + var host = new SchemaProjectionTestHost(); + var scenario = SpecScenario.Define(spec => spec.WithSchema("Value", schema => schema.Type("boolean"))); + + var result = await host.ProjectAsync(scenario); + + await Assert.That(result.Schemas["Value"]).IsTypeOf(); + await Assert.That(((PrimitiveNode)result.Schemas["Value"]).Kind).IsEqualTo(PrimitiveKind.Boolean); + } + + [Test] + public async Task Project_Should_Record_Format() + { + var host = new SchemaProjectionTestHost(); + var scenario = SpecScenario.Define(spec => spec.WithSchema("Value", schema => schema.Type("string").Format("uuid"))); + + var result = await host.ProjectAsync(scenario); + + await Assert.That(result.Schemas["Value"].Format).IsEqualTo("uuid"); + } + + [Test] + public async Task Project_Should_Record_Description() + { + var host = new SchemaProjectionTestHost(); + var scenario = SpecScenario.Define(spec => spec.WithSchema("Value", schema => schema.Type("string").Description("A value."))); + + var result = await host.ProjectAsync(scenario); + + await Assert.That(result.Schemas["Value"].Description).IsEqualTo("A value."); + } + + [Test] + public async Task Project_Should_Produce_Unrestricted_Node_For_Description_Only_Schema() + { + var host = new SchemaProjectionTestHost(); + var scenario = SpecScenario.Define(spec => spec.WithSchema("ToolResult", schema => schema.Description("Any result."))); + + var result = await host.ProjectAsync(scenario); + + await Assert.That(result.Schemas["ToolResult"]).IsTypeOf(); + await Assert.That(result.Schemas["ToolResult"].Description).IsEqualTo("Any result."); + } + + [Test] + public async Task Project_Should_Produce_Unrestricted_Node_For_Empty_Schema() + { + var host = new SchemaProjectionTestHost(); + var scenario = SpecScenario.Define(spec => spec.WithSchema("ToolResult", schema => schema.Unrestricted())); + + var result = await host.ProjectAsync(scenario); + + await Assert.That(result.Schemas["ToolResult"]).IsTypeOf(); + } + + [Test] + public async Task Project_Should_Keep_Dotted_Schema_Name_Verbatim() + { + var host = new SchemaProjectionTestHost(); + var scenario = SpecScenario.Define(spec => spec.WithSchema("session.info", schema => schema.Type("string"))); + + var result = await host.ProjectAsync(scenario); + + await Assert.That(result.Schemas.ContainsKey("session.info")).IsTrue(); + } + + [Test] + public async Task Project_Should_Produce_Enum_Node_For_Multiple_String_Values() + { + var host = new SchemaProjectionTestHost(); + var scenario = SpecScenario.Define(spec => spec.WithSchema("Mode", schema => schema.Type("string").Enum("fast", "safe"))); + + var result = await host.ProjectAsync(scenario); + + await Assert.That(result.Schemas["Mode"]).IsTypeOf(); + var node = (EnumNode)result.Schemas["Mode"]; + await Assert.That(node.Values).Count().IsEqualTo(2); + await Assert.That(node.Values[0]).IsEqualTo("fast"); + await Assert.That(node.Values[1]).IsEqualTo("safe"); + } + + [Test] + public async Task Project_Should_Produce_Array_Node_With_Item() + { + var host = new SchemaProjectionTestHost(); + var scenario = SpecScenario.Define(spec => spec.WithSchema("Values", schema => schema.Type("array").Items(item => item.Type("integer")))); + + var result = await host.ProjectAsync(scenario); + + await Assert.That(result.Schemas["Values"]).IsTypeOf(); + var item = ((ArrayNode)result.Schemas["Values"]).Item; + await Assert.That(item).IsTypeOf(); + await Assert.That(((PrimitiveNode)item).Kind).IsEqualTo(PrimitiveKind.Integer); + } + + [Test] + public async Task Project_Should_Promote_Inline_Enum_Under_Items() + { + var host = new SchemaProjectionTestHost(); + var scenario = SpecScenario.Define(spec => + spec.WithSchema("Modes", schema => schema.Type("array").Items(item => item.Type("string").Enum("fast", "safe")))); + + var result = await host.ProjectAsync(scenario); + + var item = ((ArrayNode)result.Schemas["Modes"]).Item; + await Assert.That(item).IsTypeOf(); + await Assert.That(((RefNode)item).Target).IsEqualTo("Modes#/items"); + await Assert.That(result.Schemas["Modes#/items"]).IsTypeOf(); + } + + [Test] + public async Task Project_Should_Produce_Ref_Node_For_Existing_Target() + { + var host = new SchemaProjectionTestHost(); + var scenario = SpecScenario.Define(spec => spec + .WithSchema("Target", schema => schema.Type("string")) + .WithSchema("Alias", schema => schema.Ref("Target"))); + + var result = await host.ProjectAsync(scenario); + + await Assert.That(result.Schemas["Alias"]).IsTypeOf(); + await Assert.That(((RefNode)result.Schemas["Alias"]).Target).IsEqualTo("Target"); + } + + [Test] + public async Task Project_Should_Refuse_When_Ref_Target_Is_Missing() + { + var host = new SchemaProjectionTestHost(); + var scenario = SpecScenario.Define(spec => spec.WithSchema("Bad", schema => schema.Ref("Missing"))); + + var ex = await host.ProjectExpectingRefusalAsync(scenario); + + await Assert.That(ex.Message).Contains("Bad"); + await Assert.That(ex.Message).Contains("Missing"); + } + + [Test] + public async Task Project_Should_Refuse_When_Array_Has_No_Items() + { + var host = new SchemaProjectionTestHost(); + var scenario = SpecScenario.Define(spec => spec.WithSchema("Bad", schema => schema.Type("array"))); + + var ex = await host.ProjectExpectingRefusalAsync(scenario); + + await Assert.That(ex.Message).Contains("items"); + await Assert.That(ex.Message).Contains("Bad"); + } + + [Test] + public async Task Project_Should_Batch_Errors_From_Multiple_Schemas() + { + var host = new SchemaProjectionTestHost(); + var scenario = SpecScenario.Define(spec => spec + .WithSchema("BadArray", schema => schema.Type("array")) + .WithSchema("BadTitle", schema => schema.Raw("title", "\"unsupported\""))); + + var ex = await host.ProjectExpectingRefusalAsync(scenario); + + await Assert.That(ex.Errors).Count().IsEqualTo(2); + await Assert.That(ex.Message).Contains("BadArray"); + await Assert.That(ex.Message).Contains("BadTitle"); + } + + [Test] + public async Task Project_Should_Refuse_When_Promoted_Graph_Key_Collides() + { + var scenario = SpecScenario.Define(spec => spec + .WithSchema("First", schema => schema.Type("string").Enum("fast", "safe")) + .WithSchema("Second", schema => schema.Type("string").Enum("on", "off"))); + var context = scenario.Build(); + var errors = new IngestionErrorCollector(); + var loaded = await new SpecReader(context.FileSystem).LoadAsync(context.SpecPath, errors, CancellationToken.None); + var keys = new GraphKeyBuilder(); + var projector = new SchemaProjector(new SchemaWallPolicy(), keys); + var state = new ProjectionState(errors, new Dictionary(StringComparer.Ordinal)); + + _ = projector.Project(loaded.Document.Components!.Schemas!["First"], "Shared", "/value", state); + _ = projector.Project(loaded.Document.Components.Schemas["Second"], "Shared", "/value", state); + var ex = await Assert.That(errors.ThrowIfAny).Throws(); + + await Assert.That(ex!.Message).Contains("collision"); + await Assert.That(ex.Message).Contains("Shared#/value"); + } +} diff --git a/tests/OpenCode.Sdk.Tools.Tests/SchemaWallPolicyTests.cs b/tests/OpenCode.Sdk.Tools.Tests/SchemaWallPolicyTests.cs new file mode 100644 index 00000000..3dae08bb --- /dev/null +++ b/tests/OpenCode.Sdk.Tools.Tests/SchemaWallPolicyTests.cs @@ -0,0 +1,158 @@ +using OpenCode.Sdk.Tools.Generator.Ingestion.Models; +using OpenCode.Sdk.Tools.Tests.Support; + +namespace OpenCode.Sdk.Tools.Tests; + +public sealed class SchemaWallPolicyTests +{ + [Test] + public async Task Project_Should_Refuse_When_Schema_Uses_AllOf() + { + var host = new SchemaProjectionTestHost(); + var scenario = SpecScenario.Define(spec => spec.WithSchema("Bad", schema => schema.AllOf(branch => branch.Type("string")))); + + var ex = await host.ProjectExpectingRefusalAsync(scenario); + + await Assert.That(ex.Message).Contains("allOf"); + await Assert.That(ex.Message).Contains("Bad"); + } + + [Test] + public async Task Project_Should_Refuse_When_Type_Is_An_Array() + { + var host = new SchemaProjectionTestHost(); + var scenario = SpecScenario.Define(spec => spec.WithSchema("Bad", schema => schema.Raw("type", "[\"string\",\"null\"]"))); + + var ex = await host.ProjectExpectingRefusalAsync(scenario); + + await Assert.That(ex.Message).Contains("type"); + await Assert.That(ex.Message).Contains("Bad"); + } + + [Test] + public async Task Project_Should_Refuse_When_Schema_Uses_Discriminator() + { + var host = new SchemaProjectionTestHost(); + var scenario = SpecScenario.Define(spec => spec.WithSchema("Bad", schema => schema.Raw("discriminator", "{}"))); + + var ex = await host.ProjectExpectingRefusalAsync(scenario); + + await Assert.That(ex.Message).Contains("discriminator"); + await Assert.That(ex.Message).Contains("Bad"); + } + + [Test] + public async Task Project_Should_Refuse_When_Schema_Uses_If_And_Then() + { + var host = new SchemaProjectionTestHost(); + var scenario = SpecScenario.Define(spec => spec.WithSchema("Bad", schema => schema.Raw("if", "{}").Raw("then", "{}"))); + + var ex = await host.ProjectExpectingRefusalAsync(scenario); + + await Assert.That(ex.Message).Contains("if"); + await Assert.That(ex.Message).Contains("then"); + await Assert.That(ex.Message).Contains("Bad"); + } + + [Test] + public async Task Project_Should_Refuse_When_Schema_Uses_Title() + { + var host = new SchemaProjectionTestHost(); + var scenario = SpecScenario.Define(spec => spec.WithSchema("Bad", schema => schema.Raw("title", "\"unsupported\""))); + + var ex = await host.ProjectExpectingRefusalAsync(scenario); + + await Assert.That(ex.Message).Contains("title"); + await Assert.That(ex.Message).Contains("Bad"); + } + + [Test] + public async Task Project_Should_Refuse_When_Schema_Uses_Default() + { + var host = new SchemaProjectionTestHost(); + var scenario = SpecScenario.Define(spec => spec.WithSchema("Bad", schema => schema.Raw("default", "\"unsupported\""))); + + var ex = await host.ProjectExpectingRefusalAsync(scenario); + + await Assert.That(ex.Message).Contains("default"); + await Assert.That(ex.Message).Contains("Bad"); + } + + [Test] + public async Task Project_Should_Refuse_When_Schema_Is_ReadOnly() + { + var host = new SchemaProjectionTestHost(); + var scenario = SpecScenario.Define(spec => spec.WithSchema("Bad", schema => schema.Raw("readOnly", "true"))); + + var ex = await host.ProjectExpectingRefusalAsync(scenario); + + await Assert.That(ex.Message).Contains("readOnly"); + await Assert.That(ex.Message).Contains("Bad"); + } + + [Test] + public async Task Project_Should_Refuse_When_Schema_Uses_MultipleOf() + { + var host = new SchemaProjectionTestHost(); + var scenario = SpecScenario.Define(spec => spec.WithSchema("Bad", schema => schema.Raw("multipleOf", "2"))); + + var ex = await host.ProjectExpectingRefusalAsync(scenario); + + await Assert.That(ex.Message).Contains("multipleOf"); + await Assert.That(ex.Message).Contains("Bad"); + } + + [Test] + public async Task Project_Should_Refuse_When_Schema_Uses_UniqueItems() + { + var host = new SchemaProjectionTestHost(); + var scenario = SpecScenario.Define(spec => spec.WithSchema("Bad", schema => schema.Raw("uniqueItems", "true"))); + + var ex = await host.ProjectExpectingRefusalAsync(scenario); + + await Assert.That(ex.Message).Contains("uniqueItems"); + await Assert.That(ex.Message).Contains("Bad"); + } + + [Test] + public async Task Project_Should_Refuse_When_Schema_Has_Unknown_Raw_Keyword() + { + var host = new SchemaProjectionTestHost(); + var scenario = SpecScenario.Define(spec => spec.WithSchema("Bad", schema => schema.Raw("madeUpKeyword", "true"))); + + var ex = await host.ProjectExpectingRefusalAsync(scenario); + + await Assert.That(ex.Message).Contains("madeUpKeyword"); + await Assert.That(ex.Message).Contains("Bad"); + } + + [Test] + public async Task Project_Should_Refuse_When_Schema_Has_Extension() + { + var host = new SchemaProjectionTestHost(); + var scenario = SpecScenario.Define(spec => spec.WithSchema("Bad", schema => schema.Raw("x-schema", "true"))); + + var ex = await host.ProjectExpectingRefusalAsync(scenario); + + await Assert.That(ex.Message).Contains("x-schema"); + await Assert.That(ex.Message).Contains("Bad"); + } + + [Test] + public async Task Project_Should_Ignore_Known_Validation_Keywords() + { + var host = new SchemaProjectionTestHost(); + var scenario = SpecScenario.Define(spec => spec.WithSchema("Value", schema => schema + .Type("string") + .Raw("pattern", "\"^[a-z]+$\"") + .Raw("minimum", "0") + .Raw("maximum", "10") + .Raw("exclusiveMinimum", "1") + .Raw("minItems", "1") + .Raw("maxItems", "5"))); + + var result = await host.ProjectAsync(scenario); + + await Assert.That(result.Schemas["Value"]).IsTypeOf(); + } +} diff --git a/tests/OpenCode.Sdk.Tools.Tests/Support/SchemaProjectionResult.cs b/tests/OpenCode.Sdk.Tools.Tests/Support/SchemaProjectionResult.cs new file mode 100644 index 00000000..e3e05950 --- /dev/null +++ b/tests/OpenCode.Sdk.Tools.Tests/Support/SchemaProjectionResult.cs @@ -0,0 +1,19 @@ +using System.Collections.ObjectModel; +using OpenCode.Sdk.Tools.Generator.Ingestion.Models; + +namespace OpenCode.Sdk.Tools.Tests.Support; + +internal sealed record SchemaProjectionResult +{ + public SchemaProjectionResult(IReadOnlyDictionary schemas, IngestionException? refusal) + { + ArgumentNullException.ThrowIfNull(schemas); + + Schemas = new ReadOnlyDictionary(new Dictionary(schemas, StringComparer.Ordinal)); + Refusal = refusal; + } + + public IReadOnlyDictionary Schemas { get; } + + public IngestionException? Refusal { get; } +} diff --git a/tests/OpenCode.Sdk.Tools.Tests/Support/SchemaProjectionTestHost.cs b/tests/OpenCode.Sdk.Tools.Tests/Support/SchemaProjectionTestHost.cs new file mode 100644 index 00000000..0bfe8121 --- /dev/null +++ b/tests/OpenCode.Sdk.Tools.Tests/Support/SchemaProjectionTestHost.cs @@ -0,0 +1,58 @@ +using System.Collections.Immutable; +using System.Text.Json.Nodes; +using OpenCode.Sdk.Tools.Generator.Ingestion; +using OpenCode.Sdk.Tools.Generator.Ingestion.Models; +using OpenCode.Sdk.Tools.Generator.Ingestion.Projection; +using OpenCode.Sdk.Tools.Generator.Ingestion.Walls; + +namespace OpenCode.Sdk.Tools.Tests.Support; + +internal sealed class SchemaProjectionTestHost +{ + private readonly GraphKeyBuilder _keys = new(); + private readonly SchemaWallPolicy _wall = new(); + + public async Task ProjectAsync(SpecScenario scenario) + { + var result = await ProjectCoreAsync(scenario); + return result.Refusal is not null + ? throw new InvalidOperationException("The schema projection was unexpectedly refused.", result.Refusal) + : result; + } + + public async Task ProjectExpectingRefusalAsync(SpecScenario scenario) + { + var result = await ProjectCoreAsync(scenario); + return result.Refusal ?? throw new InvalidOperationException("The schema projection was expected to be refused."); + } + + private async Task ProjectCoreAsync(SpecScenario scenario) + { + ArgumentNullException.ThrowIfNull(scenario); + + var context = scenario.Build(); + var errors = new IngestionErrorCollector(); + try + { + var loaded = await new SpecReader(context.FileSystem).LoadAsync(context.SpecPath, errors, CancellationToken.None); + var projector = new SchemaProjector(_wall, _keys); + var rawPointerLookup = ImmutableDictionary.Empty; + var state = new ProjectionState(errors, rawPointerLookup); + + if (loaded.Document.Components?.Schemas is not null) + { + foreach (var (name, schema) in loaded.Document.Components.Schemas) + { + _ = projector.Project(schema, _keys.Root(name), string.Empty, state); + } + } + + errors.ThrowIfAny(); + return new SchemaProjectionResult(state.SnapshotGraph(), refusal: null); + } + catch (IngestionException exception) + { + return new SchemaProjectionResult(new Dictionary(StringComparer.Ordinal), exception); + } + } +} diff --git a/tools/OpenCode.Sdk.Tools/Generator/Ingestion/Models/ArrayNode.cs b/tools/OpenCode.Sdk.Tools/Generator/Ingestion/Models/ArrayNode.cs new file mode 100644 index 00000000..bdd4b960 --- /dev/null +++ b/tools/OpenCode.Sdk.Tools/Generator/Ingestion/Models/ArrayNode.cs @@ -0,0 +1,11 @@ +namespace OpenCode.Sdk.Tools.Generator.Ingestion.Models; + +/// Represents a projected homogeneous array schema. +public sealed record ArrayNode : SchemaNode +{ + /// Gets the projected item schema. + public required SchemaNode Item { get; init; } + + /// + public override IEnumerable Children => [Item]; +} diff --git a/tools/OpenCode.Sdk.Tools/Generator/Ingestion/Models/EnumNode.cs b/tools/OpenCode.Sdk.Tools/Generator/Ingestion/Models/EnumNode.cs new file mode 100644 index 00000000..c070a021 --- /dev/null +++ b/tools/OpenCode.Sdk.Tools/Generator/Ingestion/Models/EnumNode.cs @@ -0,0 +1,19 @@ +namespace OpenCode.Sdk.Tools.Generator.Ingestion.Models; + +/// Represents a projected string enum schema. +public sealed record EnumNode : SchemaNode +{ + /// Gets the admitted string values in wire order. + public required IReadOnlyList Values + { + get; + init + { + ArgumentNullException.ThrowIfNull(value); + field = Array.AsReadOnly([.. value]); + } + } = Array.AsReadOnly(Array.Empty()); + + /// + public override IEnumerable Children => []; +} diff --git a/tools/OpenCode.Sdk.Tools/Generator/Ingestion/Models/PrimitiveKind.cs b/tools/OpenCode.Sdk.Tools/Generator/Ingestion/Models/PrimitiveKind.cs new file mode 100644 index 00000000..6a2c814d --- /dev/null +++ b/tools/OpenCode.Sdk.Tools/Generator/Ingestion/Models/PrimitiveKind.cs @@ -0,0 +1,17 @@ +namespace OpenCode.Sdk.Tools.Generator.Ingestion.Models; + +/// Identifies a scalar primitive supported by schema projection. +public enum PrimitiveKind +{ + /// A JSON string. + String = 0, + + /// A JSON number. + Number = 1, + + /// A JSON integer. + Integer = 2, + + /// A JSON boolean. + Boolean = 3, +} diff --git a/tools/OpenCode.Sdk.Tools/Generator/Ingestion/Models/PrimitiveNode.cs b/tools/OpenCode.Sdk.Tools/Generator/Ingestion/Models/PrimitiveNode.cs new file mode 100644 index 00000000..e43985c8 --- /dev/null +++ b/tools/OpenCode.Sdk.Tools/Generator/Ingestion/Models/PrimitiveNode.cs @@ -0,0 +1,11 @@ +namespace OpenCode.Sdk.Tools.Generator.Ingestion.Models; + +/// Represents a projected scalar primitive schema. +public sealed record PrimitiveNode : SchemaNode +{ + /// Gets the projected primitive kind. + public required PrimitiveKind Kind { get; init; } + + /// + public override IEnumerable Children => []; +} diff --git a/tools/OpenCode.Sdk.Tools/Generator/Ingestion/Models/RefNode.cs b/tools/OpenCode.Sdk.Tools/Generator/Ingestion/Models/RefNode.cs new file mode 100644 index 00000000..f76c8ce4 --- /dev/null +++ b/tools/OpenCode.Sdk.Tools/Generator/Ingestion/Models/RefNode.cs @@ -0,0 +1,11 @@ +namespace OpenCode.Sdk.Tools.Generator.Ingestion.Models; + +/// Represents a projected reference to another schema graph key. +public sealed record RefNode : SchemaNode +{ + /// Gets the referenced schema graph key. + public required string Target { get; init; } + + /// + public override IEnumerable Children => []; +} diff --git a/tools/OpenCode.Sdk.Tools/Generator/Ingestion/Models/SchemaNode.cs b/tools/OpenCode.Sdk.Tools/Generator/Ingestion/Models/SchemaNode.cs new file mode 100644 index 00000000..b02f63de --- /dev/null +++ b/tools/OpenCode.Sdk.Tools/Generator/Ingestion/Models/SchemaNode.cs @@ -0,0 +1,14 @@ +namespace OpenCode.Sdk.Tools.Generator.Ingestion.Models; + +/// Represents one projected schema in the generator's supported schema dialect. +public abstract record SchemaNode +{ + /// Gets the schema description, when supplied by the wire schema. + public string? Description { get; init; } + + /// Gets the schema format, when supplied by the wire schema. + public string? Format { get; init; } + + /// Gets the schemas directly contained by this node. + public abstract IEnumerable Children { get; } +} diff --git a/tools/OpenCode.Sdk.Tools/Generator/Ingestion/Models/UnrestrictedNode.cs b/tools/OpenCode.Sdk.Tools/Generator/Ingestion/Models/UnrestrictedNode.cs new file mode 100644 index 00000000..90423de6 --- /dev/null +++ b/tools/OpenCode.Sdk.Tools/Generator/Ingestion/Models/UnrestrictedNode.cs @@ -0,0 +1,8 @@ +namespace OpenCode.Sdk.Tools.Generator.Ingestion.Models; + +/// Represents a schema that admits any JSON value. +public sealed record UnrestrictedNode : SchemaNode +{ + /// + public override IEnumerable Children => []; +} diff --git a/tools/OpenCode.Sdk.Tools/Generator/Ingestion/Projection/GraphKeyBuilder.cs b/tools/OpenCode.Sdk.Tools/Generator/Ingestion/Projection/GraphKeyBuilder.cs new file mode 100644 index 00000000..5c9ec6bb --- /dev/null +++ b/tools/OpenCode.Sdk.Tools/Generator/Ingestion/Projection/GraphKeyBuilder.cs @@ -0,0 +1,22 @@ +namespace OpenCode.Sdk.Tools.Generator.Ingestion.Projection; + +internal sealed class GraphKeyBuilder +{ + public string Root(string wireNameOrOpId) + { + ArgumentException.ThrowIfNullOrWhiteSpace(wireNameOrOpId); + return wireNameOrOpId; + } + + public string Append(string parentPointer, string segment) + { + ArgumentNullException.ThrowIfNull(parentPointer); + ArgumentNullException.ThrowIfNull(segment); + + var escaped = segment + .Replace("~", "~0", StringComparison.Ordinal) + .Replace("/", "~1", StringComparison.Ordinal); + + return $"{parentPointer}/{escaped}"; + } +} diff --git a/tools/OpenCode.Sdk.Tools/Generator/Ingestion/Projection/ProjectionState.cs b/tools/OpenCode.Sdk.Tools/Generator/Ingestion/Projection/ProjectionState.cs new file mode 100644 index 00000000..a0442b75 --- /dev/null +++ b/tools/OpenCode.Sdk.Tools/Generator/Ingestion/Projection/ProjectionState.cs @@ -0,0 +1,80 @@ +using System.Collections.ObjectModel; +using System.Text.Json.Nodes; +using Microsoft.OpenApi; +using OpenCode.Sdk.Tools.Generator.Ingestion.Models; + +namespace OpenCode.Sdk.Tools.Generator.Ingestion.Projection; + +internal sealed class ProjectionState +{ + private readonly Dictionary _graph = new(StringComparer.Ordinal); + private readonly Dictionary _rawPointerLookup; + private readonly HashSet _visited = new(ReferenceEqualityComparer.Instance); + + public ProjectionState(IngestionErrorCollector errors, IReadOnlyDictionary rawPointerLookup) + { + ArgumentNullException.ThrowIfNull(errors); + ArgumentNullException.ThrowIfNull(rawPointerLookup); + + Errors = errors; + _rawPointerLookup = rawPointerLookup.ToDictionary(pair => pair.Key, pair => pair.Value.DeepClone(), StringComparer.Ordinal); + } + + public IngestionErrorCollector Errors { get; } + + public JsonNode? FindRawSchema(string pointer) + { + ArgumentNullException.ThrowIfNull(pointer); + + return _rawPointerLookup.TryGetValue(pointer, out var schema) ? schema.DeepClone() : null; + } + + public SchemaNode? Register(SchemaNode? schema, string root, string pointer, string location) + { + ArgumentException.ThrowIfNullOrWhiteSpace(root); + ArgumentNullException.ThrowIfNull(pointer); + ArgumentException.ThrowIfNullOrWhiteSpace(location); + + if (schema is null) + { + return null; + } + + if (pointer.Length > 0 && schema is EnumNode) + { + var promotedKey = $"{root}#{pointer}"; + if (!_graph.TryAdd(promotedKey, schema)) + { + Errors.Add(location, $"schema graph key collision for '{promotedKey}'"); + return null; + } + + return new RefNode + { + Target = promotedKey + }; + } + + if (pointer.Length == 0 && !_graph.TryAdd(root, schema)) + { + Errors.Add(location, $"schema graph key collision for '{root}'"); + return null; + } + + return schema; + } + + public bool TryVisit(IOpenApiSchema schema) + { + ArgumentNullException.ThrowIfNull(schema); + return _visited.Add(schema); + } + + public IReadOnlyDictionary SnapshotGraph() + { + var snapshot = _graph + .OrderBy(pair => pair.Key, StringComparer.Ordinal) + .ToDictionary(pair => pair.Key, pair => pair.Value, StringComparer.Ordinal); + return new ReadOnlyDictionary(snapshot); + } +} diff --git a/tools/OpenCode.Sdk.Tools/Generator/Ingestion/Projection/SchemaProjector.cs b/tools/OpenCode.Sdk.Tools/Generator/Ingestion/Projection/SchemaProjector.cs new file mode 100644 index 00000000..580dad0a --- /dev/null +++ b/tools/OpenCode.Sdk.Tools/Generator/Ingestion/Projection/SchemaProjector.cs @@ -0,0 +1,151 @@ +using System.Text.Json.Nodes; +using Microsoft.OpenApi; +using OpenCode.Sdk.Tools.Generator.Ingestion.Models; +using OpenCode.Sdk.Tools.Generator.Ingestion.Walls; + +namespace OpenCode.Sdk.Tools.Generator.Ingestion.Projection; + +internal sealed class SchemaProjector +{ + private readonly GraphKeyBuilder _keys; + private readonly SchemaWallPolicy _wall; + + public SchemaProjector(SchemaWallPolicy wall, GraphKeyBuilder keys) + { + _wall = wall ?? throw new ArgumentNullException(nameof(wall)); + _keys = keys ?? throw new ArgumentNullException(nameof(keys)); + } + + public SchemaNode? Project(IOpenApiSchema schema, string root, string pointer, ProjectionState state) + { + ArgumentNullException.ThrowIfNull(schema); + ArgumentException.ThrowIfNullOrWhiteSpace(root); + ArgumentNullException.ThrowIfNull(pointer); + ArgumentNullException.ThrowIfNull(state); + + var location = string.Concat(root, pointer); + if (schema is OpenApiSchemaReference reference) + { + return state.Register(ProjectReference(reference, location, state), root, pointer, location); + } + + if (schema is not OpenApiSchema concrete) + { + state.Errors.Add(location, $"schema implementation '{schema.GetType().Name}' is not supported"); + return null; + } + + if (!state.TryVisit(concrete)) + { + state.Errors.Add(location, "schema identity was visited more than once"); + return null; + } + + _wall.Check(concrete, location, state.Errors); + var projected = ProjectConcrete(concrete, root, pointer, location, state); + return state.Register(projected, root, pointer, location); + } + + private SchemaNode? ProjectConcrete(OpenApiSchema schema, string root, string pointer, string location, ProjectionState state) + { + if (schema.Enum is { Count: > 0 }) + { + return ProjectEnum(schema, location, state); + } + + return schema.Type switch + { + JsonSchemaType.String => CreatePrimitive(schema, PrimitiveKind.String), + JsonSchemaType.Number => CreatePrimitive(schema, PrimitiveKind.Number), + JsonSchemaType.Integer => CreatePrimitive(schema, PrimitiveKind.Integer), + JsonSchemaType.Boolean => CreatePrimitive(schema, PrimitiveKind.Boolean), + JsonSchemaType.Array => ProjectArray(schema, root, pointer, location, state), + JsonSchemaType.Null or JsonSchemaType.Object => RefuseUnsupportedShape(schema, location, state), + null when _wall.IsUnrestricted(schema) => CreateUnrestricted(schema), + _ => RefuseUnsupportedShape(schema, location, state), + }; + } + + private static EnumNode? ProjectEnum(OpenApiSchema schema, string location, ProjectionState state) + { + if (schema.Type is not JsonSchemaType.String || schema.Enum is not { Count: > 1 } values) + { + state.Errors.Add(location, "only string enums with multiple values are supported by core schema projection"); + return null; + } + + var projectedValues = new string[values.Count]; + for (var index = 0; index < values.Count; index++) + { + if (values[index] is not JsonValue value || !value.TryGetValue(out var projectedValue)) + { + state.Errors.Add(location, "enum values must all be strings"); + return null; + } + + projectedValues[index] = projectedValue; + } + + return new EnumNode + { + Description = schema.Description, + Format = schema.Format, + Values = projectedValues, + }; + } + + private ArrayNode? ProjectArray(OpenApiSchema schema, string root, string pointer, string location, ProjectionState state) + { + if (schema.Items is null) + { + state.Errors.Add(location, "array schema must declare items"); + return null; + } + + var item = Project(schema.Items, root, _keys.Append(pointer, "items"), state); + return item is null + ? null + : new ArrayNode + { + Description = schema.Description, + Format = schema.Format, + Item = item, + }; + } + + private static PrimitiveNode CreatePrimitive(OpenApiSchema schema, PrimitiveKind kind) => + new() + { + Description = schema.Description, + Format = schema.Format, + Kind = kind, + }; + + private static UnrestrictedNode CreateUnrestricted(OpenApiSchema schema) => + new() + { + Description = schema.Description, + Format = schema.Format, + }; + + private static RefNode? ProjectReference(OpenApiSchemaReference reference, string location, ProjectionState state) + { + var target = reference.Reference.Id; + if (string.IsNullOrWhiteSpace(target) || reference.Target is null) + { + state.Errors.Add(location, $"reference target '{target ?? ""}' could not be resolved"); + return null; + } + + return new RefNode + { + Target = target + }; + } + + private static SchemaNode? RefuseUnsupportedShape(OpenApiSchema schema, string location, ProjectionState state) + { + state.Errors.Add(location, $"schema type '{schema.Type?.ToString() ?? "unspecified"}' is not supported by core schema projection"); + return null; + } +} diff --git a/tools/OpenCode.Sdk.Tools/Generator/Ingestion/Walls/SchemaWallPolicy.cs b/tools/OpenCode.Sdk.Tools/Generator/Ingestion/Walls/SchemaWallPolicy.cs new file mode 100644 index 00000000..f66ab5ae --- /dev/null +++ b/tools/OpenCode.Sdk.Tools/Generator/Ingestion/Walls/SchemaWallPolicy.cs @@ -0,0 +1,112 @@ +using Microsoft.OpenApi; + +namespace OpenCode.Sdk.Tools.Generator.Ingestion.Walls; + +internal sealed class SchemaWallPolicy +{ + private const string PrefixItemsAdmissionLocation = "Config/properties/plugin/items/anyOf/1"; + + private readonly Func[] _admittedConstraintRules = + [ + static schema => schema.Type is not null, + static schema => schema.Const is not null, + static schema => schema.Enum is { Count: > 0 }, + static schema => schema.OneOf is { Count: > 0 }, + static schema => schema.AnyOf is { Count: > 0 }, + static schema => schema.AllOf is { Count: > 0 }, + static schema => schema.Required is { Count: > 0 }, + static schema => schema.Items is not null, + static schema => schema.Properties is { Count: > 0 }, + static schema => schema.PatternProperties is { Count: > 0 }, + static schema => !schema.AdditionalPropertiesAllowed, + static schema => schema.AdditionalProperties is not null, + static schema => schema.ContentEncoding is not null, + static schema => schema.ContentMediaType is not null, + static schema => schema.ContentSchema is not null, + ]; + + private readonly SchemaRule[] _rejectedRules = + [ + new("allOf", static schema => schema.AllOf is { Count: > 0 }), + new("type arrays", static schema => schema.Type is { } type && ((int)type & ((int)type - 1)) != 0), + new("discriminator", static schema => schema.Discriminator is not null), + new("not", static schema => schema.Not is not null), + new("if", static schema => schema.If is not null), + new("then", static schema => schema.Then is not null), + new("else", static schema => schema.Else is not null), + new("dependentSchemas", static schema => schema.DependentSchemas is { Count: > 0 }), + new("dependentRequired", static schema => schema.DependentRequired is { Count: > 0 }), + new("propertyNames", static schema => schema.PropertyNames is not null), + new("contains", static schema => schema.Contains is not null), + new("unevaluatedProperties", static schema => !schema.UnevaluatedProperties), + new("unevaluatedProperties", static schema => schema.UnevaluatedPropertiesSchema is not null), + new("$defs", static schema => schema.Definitions is { Count: > 0 }), + new("$dynamicRef", static schema => schema.DynamicRef is not null), + new("$dynamicAnchor", static schema => schema.DynamicAnchor is not null), + new("$schema", static schema => schema.Schema is not null), + new("$id", static schema => schema.Id is not null), + new("$anchor", static schema => schema.Anchor is not null), + new("$comment", static schema => schema.Comment is not null), + new("$vocabulary", static schema => schema.Vocabulary is { Count: > 0 }), + new("title", static schema => schema.Title is not null), + new("default", static schema => schema.Default is not null), + new("examples", static schema => schema.Examples is { Count: > 0 }), + new("example", static schema => schema.Example is not null), + new("readOnly", static schema => schema.ReadOnly), + new("writeOnly", static schema => schema.WriteOnly), + new("xml", static schema => schema.Xml is not null), + new("externalDocs", static schema => schema.ExternalDocs is not null), + new("minLength", static schema => schema.MinLength is not null), + new("maxLength", static schema => schema.MaxLength is not null), + new("multipleOf", static schema => schema.MultipleOf is not null), + new("exclusiveMaximum", static schema => schema.ExclusiveMaximum is not null), + new("minProperties", static schema => schema.MinProperties is not null), + new("maxProperties", static schema => schema.MaxProperties is not null), + new("uniqueItems", static schema => schema.UniqueItems is not null), + new("minContains", static schema => schema.MinContains is not null), + new("maxContains", static schema => schema.MaxContains is not null), + new("deprecated", static schema => schema.Deprecated), + ]; + + public void Check(OpenApiSchema schema, string location, IngestionErrorCollector errors) + { + ArgumentNullException.ThrowIfNull(schema); + ArgumentException.ThrowIfNullOrWhiteSpace(location); + ArgumentNullException.ThrowIfNull(errors); + + foreach (var rule in _rejectedRules.Where(rule => rule.IsPopulated(schema))) + { + errors.Add(location, $"schema keyword '{rule.Keyword}' is not supported"); + } + + if (schema.UnrecognizedKeywords is not null) + { + foreach (var keyword in schema + .UnrecognizedKeywords.Keys.Where(keyword => !IsAdmittedPrefixItems(keyword, location)) + .Order(StringComparer.Ordinal)) + { + errors.Add(location, $"unrecognized schema keyword '{keyword}' is not supported"); + } + } + + if (schema.Extensions is not null) + { + foreach (var extension in schema.Extensions.Keys.Order(StringComparer.Ordinal)) + { + errors.Add(location, $"schema-level extension '{extension}' is not supported"); + } + } + } + + public bool IsUnrestricted(OpenApiSchema schema) + { + ArgumentNullException.ThrowIfNull(schema); + return !_admittedConstraintRules.Any(rule => rule(schema)); + } + + private static bool IsAdmittedPrefixItems(string keyword, string location) => + string.Equals(keyword, "prefixItems", StringComparison.Ordinal) + && string.Equals(location, PrefixItemsAdmissionLocation, StringComparison.Ordinal); + + private sealed record SchemaRule(string Keyword, Func IsPopulated); +} From 7a737ebb27484310e7939595b9daf4a005d0c9e3 Mon Sep 17 00:00:00 2001 From: "denizirgin@gmail.com" Date: Tue, 11 Aug 2026 13:40:37 +0300 Subject: [PATCH 08/19] refactor(tools): align test layout and formatting --- .../2026-08-11-slice-01-ingestion-specir.md | 9 ++-- .../Ingestion}/GraphKeyBuilderTests.cs | 2 +- .../Ingestion}/SchemaProjectorTests.cs | 2 +- .../Ingestion}/SpecReaderTests.cs | 46 ++++++++++--------- .../Ingestion/Walls}/SchemaWallPolicyTests.cs | 2 +- .../Support/{ => Tests}/FixtureLoaderTests.cs | 2 +- .../{ => Tests}/SpecDocumentBuilderTests.cs | 2 +- .../Support/{ => Tests}/SpecScenarioTests.cs | 2 +- .../OpenCode.Sdk.Tools.Tests/ToolAppTests.cs | 6 +-- .../Commands/GenerateCommand.cs | 5 +- .../Ingestion/IngestionErrorCollector.cs | 1 + .../Ingestion/Models/IngestionException.cs | 2 +- .../Ingestion/Projection/ProjectionState.cs | 34 +++++++------- .../Ingestion/Projection/SchemaProjector.cs | 14 +++--- .../Generator/Ingestion/SpecReader.cs | 4 +- .../Ingestion/Walls/SchemaWallPolicy.cs | 13 ++++-- .../Logging/FileLoggerProvider.cs | 22 ++++----- .../Logging/SpectreConsoleLoggerProvider.cs | 6 +-- 18 files changed, 87 insertions(+), 87 deletions(-) rename tests/OpenCode.Sdk.Tools.Tests/{ => Generator/Ingestion}/GraphKeyBuilderTests.cs (91%) rename tests/OpenCode.Sdk.Tools.Tests/{ => Generator/Ingestion}/SchemaProjectorTests.cs (99%) rename tests/OpenCode.Sdk.Tools.Tests/{ => Generator/Ingestion}/SpecReaderTests.cs (66%) rename tests/OpenCode.Sdk.Tools.Tests/{ => Generator/Ingestion/Walls}/SchemaWallPolicyTests.cs (98%) rename tests/OpenCode.Sdk.Tools.Tests/Support/{ => Tests}/FixtureLoaderTests.cs (91%) rename tests/OpenCode.Sdk.Tools.Tests/Support/{ => Tests}/SpecDocumentBuilderTests.cs (99%) rename tests/OpenCode.Sdk.Tools.Tests/Support/{ => Tests}/SpecScenarioTests.cs (93%) diff --git a/docs/superpowers/plans/2026-08-11-slice-01-ingestion-specir.md b/docs/superpowers/plans/2026-08-11-slice-01-ingestion-specir.md index e48e9b66..d908f0bf 100644 --- a/docs/superpowers/plans/2026-08-11-slice-01-ingestion-specir.md +++ b/docs/superpowers/plans/2026-08-11-slice-01-ingestion-specir.md @@ -235,8 +235,8 @@ flag (operations), `x-effect-stream` opaque (SSE media only), all other `x-*` re - Modify: `tests/OpenCode.Sdk.Tools.Tests/OpenCode.Sdk.Tools.Tests.csproj` (embed `Fixtures/**/*.json`; link `spec/openapi.json` as `Fixtures/openapi.json`, `CopyToOutputDirectory="PreserveNewest"`) -- Test: `tests/OpenCode.Sdk.Tools.Tests/Support/SpecDocumentBuilderTests.cs`, - `Support/SpecScenarioTests.cs`, `Support/FixtureLoaderTests.cs` +- Test: `tests/OpenCode.Sdk.Tools.Tests/Support/Tests/SpecDocumentBuilderTests.cs`, + `Support/Tests/SpecScenarioTests.cs`, `Support/Tests/FixtureLoaderTests.cs` **Interfaces:** - Consumes: `Testably.Abstractions.Testing.MockFileSystem`. @@ -355,7 +355,7 @@ public async Task Load_Should_Throw_With_Known_Names_When_Fixture_Missing() - Create: `tools/OpenCode.Sdk.Tools/Generator/Ingestion/SpecReader.cs` (internal sealed) - Create: `tools/OpenCode.Sdk.Tools/Generator/Ingestion/LoadedSpec.cs` (internal record) - Create: `tests/OpenCode.Sdk.Tools.Tests/Fixtures/boolean-property-schema.json` -- Test: `tests/OpenCode.Sdk.Tools.Tests/SpecReaderTests.cs` +- Test: `tests/OpenCode.Sdk.Tools.Tests/Generator/Ingestion/SpecReaderTests.cs` **Interfaces:** - Consumes: `IFileSystem` (Task 1 DI), `SpecScenario` and the domain builders (Task 2). @@ -474,7 +474,8 @@ public sealed class SpecReaderTests `Projection/GraphKeyBuilder.cs` (internal sealed), `Projection/ProjectionState.cs` (internal) - Create: `tests/OpenCode.Sdk.Tools.Tests/Support/SchemaProjectionTestHost.cs`, `Support/SchemaProjectionResult.cs` -- Test: `tests/.../SchemaWallPolicyTests.cs`, `SchemaProjectorTests.cs`, `GraphKeyBuilderTests.cs` +- Test: `tests/OpenCode.Sdk.Tools.Tests/Generator/Ingestion/Walls/SchemaWallPolicyTests.cs`, + `Generator/Ingestion/SchemaProjectorTests.cs`, `Generator/Ingestion/GraphKeyBuilderTests.cs` **Interfaces:** - Consumes: `SpecReader`/`LoadedSpec` (Task 3), builders (Task 2). diff --git a/tests/OpenCode.Sdk.Tools.Tests/GraphKeyBuilderTests.cs b/tests/OpenCode.Sdk.Tools.Tests/Generator/Ingestion/GraphKeyBuilderTests.cs similarity index 91% rename from tests/OpenCode.Sdk.Tools.Tests/GraphKeyBuilderTests.cs rename to tests/OpenCode.Sdk.Tools.Tests/Generator/Ingestion/GraphKeyBuilderTests.cs index 369f952c..a1207cf7 100644 --- a/tests/OpenCode.Sdk.Tools.Tests/GraphKeyBuilderTests.cs +++ b/tests/OpenCode.Sdk.Tools.Tests/Generator/Ingestion/GraphKeyBuilderTests.cs @@ -1,6 +1,6 @@ using OpenCode.Sdk.Tools.Generator.Ingestion.Projection; -namespace OpenCode.Sdk.Tools.Tests; +namespace OpenCode.Sdk.Tools.Tests.Generator.Ingestion; public sealed class GraphKeyBuilderTests { diff --git a/tests/OpenCode.Sdk.Tools.Tests/SchemaProjectorTests.cs b/tests/OpenCode.Sdk.Tools.Tests/Generator/Ingestion/SchemaProjectorTests.cs similarity index 99% rename from tests/OpenCode.Sdk.Tools.Tests/SchemaProjectorTests.cs rename to tests/OpenCode.Sdk.Tools.Tests/Generator/Ingestion/SchemaProjectorTests.cs index a8e1144d..d0eef9ba 100644 --- a/tests/OpenCode.Sdk.Tools.Tests/SchemaProjectorTests.cs +++ b/tests/OpenCode.Sdk.Tools.Tests/Generator/Ingestion/SchemaProjectorTests.cs @@ -5,7 +5,7 @@ using OpenCode.Sdk.Tools.Generator.Ingestion.Walls; using OpenCode.Sdk.Tools.Tests.Support; -namespace OpenCode.Sdk.Tools.Tests; +namespace OpenCode.Sdk.Tools.Tests.Generator.Ingestion; public sealed class SchemaProjectorTests { diff --git a/tests/OpenCode.Sdk.Tools.Tests/SpecReaderTests.cs b/tests/OpenCode.Sdk.Tools.Tests/Generator/Ingestion/SpecReaderTests.cs similarity index 66% rename from tests/OpenCode.Sdk.Tools.Tests/SpecReaderTests.cs rename to tests/OpenCode.Sdk.Tools.Tests/Generator/Ingestion/SpecReaderTests.cs index 7c41a2d9..d2b0cce9 100644 --- a/tests/OpenCode.Sdk.Tools.Tests/SpecReaderTests.cs +++ b/tests/OpenCode.Sdk.Tools.Tests/Generator/Ingestion/SpecReaderTests.cs @@ -3,21 +3,10 @@ using OpenCode.Sdk.Tools.Tests.Support; using Testably.Abstractions.Testing; -namespace OpenCode.Sdk.Tools.Tests; +namespace OpenCode.Sdk.Tools.Tests.Generator.Ingestion; public sealed class SpecReaderTests { - private static async Task LoadExpectingRefusal(SpecScenario scenario) - { - var context = scenario.Build(); - var reader = new SpecReader(context.FileSystem); - var errors = new IngestionErrorCollector(); - var ex = await Assert - .That(async () => { _ = await reader.LoadAsync(context.SpecPath, errors, CancellationToken.None); }) - .Throws(); - return ex!; - } - [Test] public async Task LoadAsync_Should_Return_Document_And_Raw_For_Valid_31_Document() { @@ -33,8 +22,8 @@ public async Task LoadAsync_Should_Return_Document_And_Raw_For_Valid_31_Document [Test] public async Task LoadAsync_Should_Refuse_When_Version_Is_Not_31() { - var ex = await LoadExpectingRefusal( - SpecScenario.Define(spec => spec.WithOpenApiVersion("3.2.0"))); + var ex = await LoadExpectingRefusalAsync(SpecScenario.Define(spec => spec.WithOpenApiVersion("3.2.0"))); + await Assert.That(ex.Message).Contains("3.2"); } @@ -43,7 +32,7 @@ public async Task LoadAsync_Should_Refuse_When_Spec_File_Is_Missing() { var reader = new SpecReader(new MockFileSystem()); var ex = await Assert - .That(async () => { _ = await reader.LoadAsync("spec/openapi.json", new IngestionErrorCollector(), CancellationToken.None); }) + .That(async () => _ = await reader.LoadAsync("spec/openapi.json", new IngestionErrorCollector(), CancellationToken.None)) .Throws(); await Assert.That(ex!.Message).Contains("spec/openapi.json"); } @@ -51,24 +40,39 @@ public async Task LoadAsync_Should_Refuse_When_Spec_File_Is_Missing() [Test] public async Task LoadAsync_Should_Translate_Reader_Crash_When_Schema_Is_Boolean() { - var ex = await LoadExpectingRefusal(SpecScenario.Define(spec => - spec.WithRawSchema("Bad", "boolean-property-schema.json"))); + var specScenario = SpecScenario.Define(spec => spec.WithRawSchema("Bad", "boolean-property-schema.json")); + + var ex = await LoadExpectingRefusalAsync(specScenario); await Assert.That(ex.Message).Contains("reader failed"); } [Test] public async Task LoadAsync_Should_Promote_Reader_Diagnostics_To_Errors() { - var ex = await LoadExpectingRefusal(SpecScenario.Define(spec => - spec.WithOperation("v2.test.get", configure: operation => - operation.Raw("madeUpKey", "{}")))); + var specScenario = SpecScenario + .Define(spec => spec.WithOperation("v2.test.get", configure: operation => operation.Raw("madeUpKey", "{}"))); + + var ex = await LoadExpectingRefusalAsync(specScenario); await Assert.That(ex.Message).Contains("madeUpKey"); } [Test] public async Task LoadAsync_Should_Refuse_When_Json_Is_Malformed() { - var ex = await LoadExpectingRefusal(SpecScenario.FromRawJson("{ not json")); + var specScenario = SpecScenario.FromRawJson("{ not json"); + + var ex = await LoadExpectingRefusalAsync(specScenario); await Assert.That(ex.Message).Contains("reader failed"); } + + private static async Task LoadExpectingRefusalAsync(SpecScenario scenario) + { + var context = scenario.Build(); + var reader = new SpecReader(context.FileSystem); + var errors = new IngestionErrorCollector(); + var ex = await Assert + .That(async () => _ = await reader.LoadAsync(context.SpecPath, errors, CancellationToken.None)) + .Throws(); + return ex!; + } } diff --git a/tests/OpenCode.Sdk.Tools.Tests/SchemaWallPolicyTests.cs b/tests/OpenCode.Sdk.Tools.Tests/Generator/Ingestion/Walls/SchemaWallPolicyTests.cs similarity index 98% rename from tests/OpenCode.Sdk.Tools.Tests/SchemaWallPolicyTests.cs rename to tests/OpenCode.Sdk.Tools.Tests/Generator/Ingestion/Walls/SchemaWallPolicyTests.cs index 3dae08bb..a411002d 100644 --- a/tests/OpenCode.Sdk.Tools.Tests/SchemaWallPolicyTests.cs +++ b/tests/OpenCode.Sdk.Tools.Tests/Generator/Ingestion/Walls/SchemaWallPolicyTests.cs @@ -1,7 +1,7 @@ using OpenCode.Sdk.Tools.Generator.Ingestion.Models; using OpenCode.Sdk.Tools.Tests.Support; -namespace OpenCode.Sdk.Tools.Tests; +namespace OpenCode.Sdk.Tools.Tests.Generator.Ingestion.Walls; public sealed class SchemaWallPolicyTests { diff --git a/tests/OpenCode.Sdk.Tools.Tests/Support/FixtureLoaderTests.cs b/tests/OpenCode.Sdk.Tools.Tests/Support/Tests/FixtureLoaderTests.cs similarity index 91% rename from tests/OpenCode.Sdk.Tools.Tests/Support/FixtureLoaderTests.cs rename to tests/OpenCode.Sdk.Tools.Tests/Support/Tests/FixtureLoaderTests.cs index 3093a39c..c0af29b3 100644 --- a/tests/OpenCode.Sdk.Tools.Tests/Support/FixtureLoaderTests.cs +++ b/tests/OpenCode.Sdk.Tools.Tests/Support/Tests/FixtureLoaderTests.cs @@ -1,4 +1,4 @@ -namespace OpenCode.Sdk.Tools.Tests.Support; +namespace OpenCode.Sdk.Tools.Tests.Support.Tests; public sealed class FixtureLoaderTests { diff --git a/tests/OpenCode.Sdk.Tools.Tests/Support/SpecDocumentBuilderTests.cs b/tests/OpenCode.Sdk.Tools.Tests/Support/Tests/SpecDocumentBuilderTests.cs similarity index 99% rename from tests/OpenCode.Sdk.Tools.Tests/Support/SpecDocumentBuilderTests.cs rename to tests/OpenCode.Sdk.Tools.Tests/Support/Tests/SpecDocumentBuilderTests.cs index 2f0df79e..79a4e790 100644 --- a/tests/OpenCode.Sdk.Tools.Tests/Support/SpecDocumentBuilderTests.cs +++ b/tests/OpenCode.Sdk.Tools.Tests/Support/Tests/SpecDocumentBuilderTests.cs @@ -1,6 +1,6 @@ using System.Text.Json.Nodes; -namespace OpenCode.Sdk.Tools.Tests.Support; +namespace OpenCode.Sdk.Tools.Tests.Support.Tests; public sealed class SpecDocumentBuilderTests { diff --git a/tests/OpenCode.Sdk.Tools.Tests/Support/SpecScenarioTests.cs b/tests/OpenCode.Sdk.Tools.Tests/Support/Tests/SpecScenarioTests.cs similarity index 93% rename from tests/OpenCode.Sdk.Tools.Tests/Support/SpecScenarioTests.cs rename to tests/OpenCode.Sdk.Tools.Tests/Support/Tests/SpecScenarioTests.cs index afb1fa34..a642b969 100644 --- a/tests/OpenCode.Sdk.Tools.Tests/Support/SpecScenarioTests.cs +++ b/tests/OpenCode.Sdk.Tools.Tests/Support/Tests/SpecScenarioTests.cs @@ -1,4 +1,4 @@ -namespace OpenCode.Sdk.Tools.Tests.Support; +namespace OpenCode.Sdk.Tools.Tests.Support.Tests; public sealed class SpecScenarioTests { diff --git a/tests/OpenCode.Sdk.Tools.Tests/ToolAppTests.cs b/tests/OpenCode.Sdk.Tools.Tests/ToolAppTests.cs index 38289958..a005ea75 100644 --- a/tests/OpenCode.Sdk.Tools.Tests/ToolAppTests.cs +++ b/tests/OpenCode.Sdk.Tools.Tests/ToolAppTests.cs @@ -38,8 +38,7 @@ public async Task CreateServices_Should_Resolve_Full_Hosting_Composition() public async Task CreateRegistrar_Should_Apply_Seam_Overrides_After_Production_Registrations() { var expected = new MockFileSystem(); - using var registrar = ToolApp.CreateRegistrar(services => - services.AddSingleton(expected)); + using var registrar = ToolApp.CreateRegistrar(services => services.AddSingleton(expected)); var resolver = registrar.Build(); var actual = resolver.Resolve(typeof(IFileSystem)); @@ -60,8 +59,7 @@ public async Task RunAsync_Should_Apply_Global_Logging_Options_Before_Command_Ex var tester = new CommandAppTester(registrar); tester.Configure(ToolApp.Configure); - var result = await tester.RunAsync( - ["generate", "--log-level", "debug", "--log-file", logPath]); + var result = await tester.RunAsync(["generate", "--log-level", "debug", "--log-file", logPath]); await Assert.That(result.ExitCode).IsEqualTo(1); await Assert.That(fileSystem.File.Exists(logPath)).IsTrue(); diff --git a/tools/OpenCode.Sdk.Tools/Commands/GenerateCommand.cs b/tools/OpenCode.Sdk.Tools/Commands/GenerateCommand.cs index 629d64c1..10365295 100644 --- a/tools/OpenCode.Sdk.Tools/Commands/GenerateCommand.cs +++ b/tools/OpenCode.Sdk.Tools/Commands/GenerateCommand.cs @@ -21,12 +21,11 @@ public GenerateCommand(IAnsiConsole console, ILogger logger) } /// - protected override Task ExecuteAsync(CommandContext context, - Settings settings, - CancellationToken cancellationToken) + protected override Task ExecuteAsync(CommandContext context, Settings settings, CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(context); ArgumentNullException.ThrowIfNull(settings); + cancellationToken.ThrowIfCancellationRequested(); LogInvocation(_logger); diff --git a/tools/OpenCode.Sdk.Tools/Generator/Ingestion/IngestionErrorCollector.cs b/tools/OpenCode.Sdk.Tools/Generator/Ingestion/IngestionErrorCollector.cs index 85720375..bec5f97a 100644 --- a/tools/OpenCode.Sdk.Tools/Generator/Ingestion/IngestionErrorCollector.cs +++ b/tools/OpenCode.Sdk.Tools/Generator/Ingestion/IngestionErrorCollector.cs @@ -12,6 +12,7 @@ public void Add(string location, string problem) { ArgumentException.ThrowIfNullOrWhiteSpace(location); ArgumentException.ThrowIfNullOrWhiteSpace(problem); + _errors.Add(new IngestionError(location, problem)); } diff --git a/tools/OpenCode.Sdk.Tools/Generator/Ingestion/Models/IngestionException.cs b/tools/OpenCode.Sdk.Tools/Generator/Ingestion/Models/IngestionException.cs index 280b7702..4a08ce52 100644 --- a/tools/OpenCode.Sdk.Tools/Generator/Ingestion/Models/IngestionException.cs +++ b/tools/OpenCode.Sdk.Tools/Generator/Ingestion/Models/IngestionException.cs @@ -51,7 +51,7 @@ private IngestionException(ReadOnlyCollection errors) private static ReadOnlyCollection CopyErrors(IReadOnlyList errors) { ArgumentNullException.ThrowIfNull(errors); - return Array.AsReadOnly(errors.ToArray()); + return Array.AsReadOnly([.. errors]); } private static string CreateMessage(ReadOnlyCollection errors) diff --git a/tools/OpenCode.Sdk.Tools/Generator/Ingestion/Projection/ProjectionState.cs b/tools/OpenCode.Sdk.Tools/Generator/Ingestion/Projection/ProjectionState.cs index a0442b75..595736d9 100644 --- a/tools/OpenCode.Sdk.Tools/Generator/Ingestion/Projection/ProjectionState.cs +++ b/tools/OpenCode.Sdk.Tools/Generator/Ingestion/Projection/ProjectionState.cs @@ -40,28 +40,28 @@ public ProjectionState(IngestionErrorCollector errors, IReadOnlyDictionary 0 && schema is EnumNode) + switch (pointer.Length) { - var promotedKey = $"{root}#{pointer}"; - if (!_graph.TryAdd(promotedKey, schema)) - { + case > 0 when schema is EnumNode: + var promotedKey = $"{root}#{pointer}"; + + if (_graph.TryAdd(promotedKey, schema)) + { + return new RefNode + { + Target = promotedKey, + }; + } + Errors.Add(location, $"schema graph key collision for '{promotedKey}'"); return null; - } - - return new RefNode - { - Target = promotedKey - }; - } - if (pointer.Length == 0 && !_graph.TryAdd(root, schema)) - { - Errors.Add(location, $"schema graph key collision for '{root}'"); - return null; + case 0 when !_graph.TryAdd(root, schema): + Errors.Add(location, $"schema graph key collision for '{root}'"); + return null; + default: + return schema; } - - return schema; } public bool TryVisit(IOpenApiSchema schema) diff --git a/tools/OpenCode.Sdk.Tools/Generator/Ingestion/Projection/SchemaProjector.cs b/tools/OpenCode.Sdk.Tools/Generator/Ingestion/Projection/SchemaProjector.cs index 580dad0a..10cc06e6 100644 --- a/tools/OpenCode.Sdk.Tools/Generator/Ingestion/Projection/SchemaProjector.cs +++ b/tools/OpenCode.Sdk.Tools/Generator/Ingestion/Projection/SchemaProjector.cs @@ -131,16 +131,16 @@ private static UnrestrictedNode CreateUnrestricted(OpenApiSchema schema) => private static RefNode? ProjectReference(OpenApiSchemaReference reference, string location, ProjectionState state) { var target = reference.Reference.Id; - if (string.IsNullOrWhiteSpace(target) || reference.Target is null) + if (!string.IsNullOrWhiteSpace(target) && reference.Target is not null) { - state.Errors.Add(location, $"reference target '{target ?? ""}' could not be resolved"); - return null; + return new RefNode + { + Target = target, + }; } - return new RefNode - { - Target = target - }; + state.Errors.Add(location, $"reference target '{target ?? ""}' could not be resolved"); + return null; } private static SchemaNode? RefuseUnsupportedShape(OpenApiSchema schema, string location, ProjectionState state) diff --git a/tools/OpenCode.Sdk.Tools/Generator/Ingestion/SpecReader.cs b/tools/OpenCode.Sdk.Tools/Generator/Ingestion/SpecReader.cs index f3001373..51e5979a 100644 --- a/tools/OpenCode.Sdk.Tools/Generator/Ingestion/SpecReader.cs +++ b/tools/OpenCode.Sdk.Tools/Generator/Ingestion/SpecReader.cs @@ -44,8 +44,7 @@ public async Task LoadAsync(string specPath, IngestionErrorCollector throw; } - var diagnostic = result.Diagnostic - ?? throw new InvalidOperationException("The OpenAPI reader returned no diagnostic information."); + var diagnostic = result.Diagnostic ?? throw new InvalidOperationException("The OpenAPI reader returned no diagnostic information."); foreach (var error in diagnostic.Errors) { @@ -61,6 +60,7 @@ public async Task LoadAsync(string specPath, IngestionErrorCollector errors.ThrowIfAny(); var document = result.Document ?? throw new InvalidOperationException("The OpenAPI reader returned no document without a diagnostic error."); + return new LoadedSpec(document, raw); } } diff --git a/tools/OpenCode.Sdk.Tools/Generator/Ingestion/Walls/SchemaWallPolicy.cs b/tools/OpenCode.Sdk.Tools/Generator/Ingestion/Walls/SchemaWallPolicy.cs index f66ab5ae..072bc975 100644 --- a/tools/OpenCode.Sdk.Tools/Generator/Ingestion/Walls/SchemaWallPolicy.cs +++ b/tools/OpenCode.Sdk.Tools/Generator/Ingestion/Walls/SchemaWallPolicy.cs @@ -89,18 +89,21 @@ public void Check(OpenApiSchema schema, string location, IngestionErrorCollector } } - if (schema.Extensions is not null) + if (schema.Extensions is null) { - foreach (var extension in schema.Extensions.Keys.Order(StringComparer.Ordinal)) - { - errors.Add(location, $"schema-level extension '{extension}' is not supported"); - } + return; + } + + foreach (var extension in schema.Extensions.Keys.Order(StringComparer.Ordinal)) + { + errors.Add(location, $"schema-level extension '{extension}' is not supported"); } } public bool IsUnrestricted(OpenApiSchema schema) { ArgumentNullException.ThrowIfNull(schema); + return !_admittedConstraintRules.Any(rule => rule(schema)); } diff --git a/tools/OpenCode.Sdk.Tools/Infrastructure/Logging/FileLoggerProvider.cs b/tools/OpenCode.Sdk.Tools/Infrastructure/Logging/FileLoggerProvider.cs index f86310ee..26cff608 100644 --- a/tools/OpenCode.Sdk.Tools/Infrastructure/Logging/FileLoggerProvider.cs +++ b/tools/OpenCode.Sdk.Tools/Infrastructure/Logging/FileLoggerProvider.cs @@ -24,17 +24,19 @@ public ILogger CreateLogger(string categoryName) { ArgumentException.ThrowIfNullOrWhiteSpace(categoryName); - if (_options.LogFile is { } logFile) + if (_options.LogFile is not { } logFile) { - var directory = _fileSystem.Path.GetDirectoryName(logFile); - if (!string.IsNullOrEmpty(directory)) - { - _fileSystem.Directory.CreateDirectory(directory); - } + return this; + } - _fileSystem.File.AppendAllText(logFile, string.Empty); + var directory = _fileSystem.Path.GetDirectoryName(logFile); + if (!string.IsNullOrEmpty(directory)) + { + _fileSystem.Directory.CreateDirectory(directory); } + _fileSystem.File.AppendAllText(logFile, string.Empty); + return this; } @@ -46,11 +48,7 @@ public ILogger CreateLogger(string categoryName) public bool IsEnabled(LogLevel logLevel) => _options.LogFile is not null && logLevel >= _options.MinimumLevel; /// - public void Log(LogLevel logLevel, - EventId eventId, - TState state, - Exception? exception, - Func formatter) + public void Log(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func formatter) { ArgumentNullException.ThrowIfNull(formatter); diff --git a/tools/OpenCode.Sdk.Tools/Infrastructure/Logging/SpectreConsoleLoggerProvider.cs b/tools/OpenCode.Sdk.Tools/Infrastructure/Logging/SpectreConsoleLoggerProvider.cs index 0bbf762b..7d4baaab 100644 --- a/tools/OpenCode.Sdk.Tools/Infrastructure/Logging/SpectreConsoleLoggerProvider.cs +++ b/tools/OpenCode.Sdk.Tools/Infrastructure/Logging/SpectreConsoleLoggerProvider.cs @@ -34,11 +34,7 @@ public ILogger CreateLogger(string categoryName) public bool IsEnabled(LogLevel logLevel) => logLevel >= _options.MinimumLevel; /// - public void Log(LogLevel logLevel, - EventId eventId, - TState state, - Exception? exception, - Func formatter) + public void Log(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func formatter) { ArgumentNullException.ThrowIfNull(formatter); From d6bef650dfd0ec7fd4cd0bdb799a3f92804fe8b7 Mon Sep 17 00:00:00 2001 From: "denizirgin@gmail.com" Date: Tue, 11 Aug 2026 13:52:39 +0300 Subject: [PATCH 09/19] fix(tools): enforce fail-closed schema shape projection --- .../Ingestion/SchemaProjectorTests.cs | 66 +++++++++++ .../Walls/SchemaMemberWhitelistTests.cs | 48 ++++++++ .../Ingestion/Projection/CoreSchemaShape.cs | 10 ++ .../Projection/EnumSchemaProjector.cs | 51 ++++++++ .../Ingestion/Projection/SchemaProjector.cs | 58 +++------ .../Projection/SchemaShapeClassifier.cs | 78 +++++++++++++ .../Ingestion/Walls/SchemaMemberWhitelist.cs | 110 ++++++++++++++++++ .../Ingestion/Walls/SchemaWallPolicy.cs | 77 +----------- 8 files changed, 381 insertions(+), 117 deletions(-) create mode 100644 tests/OpenCode.Sdk.Tools.Tests/Generator/Ingestion/Walls/SchemaMemberWhitelistTests.cs create mode 100644 tools/OpenCode.Sdk.Tools/Generator/Ingestion/Projection/CoreSchemaShape.cs create mode 100644 tools/OpenCode.Sdk.Tools/Generator/Ingestion/Projection/EnumSchemaProjector.cs create mode 100644 tools/OpenCode.Sdk.Tools/Generator/Ingestion/Projection/SchemaShapeClassifier.cs create mode 100644 tools/OpenCode.Sdk.Tools/Generator/Ingestion/Walls/SchemaMemberWhitelist.cs diff --git a/tests/OpenCode.Sdk.Tools.Tests/Generator/Ingestion/SchemaProjectorTests.cs b/tests/OpenCode.Sdk.Tools.Tests/Generator/Ingestion/SchemaProjectorTests.cs index d0eef9ba..7ba5e042 100644 --- a/tests/OpenCode.Sdk.Tools.Tests/Generator/Ingestion/SchemaProjectorTests.cs +++ b/tests/OpenCode.Sdk.Tools.Tests/Generator/Ingestion/SchemaProjectorTests.cs @@ -195,6 +195,72 @@ public async Task Project_Should_Refuse_When_Array_Has_No_Items() await Assert.That(ex.Message).Contains("Bad"); } + [Test] + public async Task Project_Should_Refuse_When_Primitive_Has_OneOf_Constraint() + { + var host = new SchemaProjectionTestHost(); + var scenario = SpecScenario.Define(spec => spec.WithSchema("Bad", schema => schema.Type("string").OneOf(branch => branch.Type("number")))); + + var ex = await host.ProjectExpectingRefusalAsync(scenario); + + await Assert.That(ex.Message).Contains("oneOf"); + await Assert.That(ex.Message).Contains("Bad"); + } + + [Test] + public async Task Project_Should_Refuse_When_Primitive_Has_Const_Constraint() + { + var host = new SchemaProjectionTestHost(); + var scenario = SpecScenario.Define(spec => spec.WithSchema("Bad", schema => schema.Type("string").Const("fixed"))); + + var ex = await host.ProjectExpectingRefusalAsync(scenario); + + await Assert.That(ex.Message).Contains("const"); + await Assert.That(ex.Message).Contains("Bad"); + } + + [Test] + public async Task Project_Should_Refuse_When_Enum_Has_Object_Constraint() + { + var host = new SchemaProjectionTestHost(); + var scenario = SpecScenario.Define(spec => spec.WithSchema("Bad", schema => schema + .Type("string") + .Enum("one", "two") + .Property("value", property => property.Type("string")))); + + var ex = await host.ProjectExpectingRefusalAsync(scenario); + + await Assert.That(ex.Message).Contains("properties"); + await Assert.That(ex.Message).Contains("Bad"); + } + + [Test] + public async Task Project_Should_Refuse_When_Array_Has_Union_Constraint() + { + var host = new SchemaProjectionTestHost(); + var scenario = SpecScenario.Define(spec => spec.WithSchema("Bad", schema => schema + .Type("array") + .Items(item => item.Type("string")) + .AnyOf(branch => branch.Type("string")))); + + var ex = await host.ProjectExpectingRefusalAsync(scenario); + + await Assert.That(ex.Message).Contains("anyOf"); + await Assert.That(ex.Message).Contains("Bad"); + } + + [Test] + public async Task Project_Should_Refuse_When_Future_Shape_Has_No_Core_Type() + { + var host = new SchemaProjectionTestHost(); + var scenario = SpecScenario.Define(spec => spec.WithSchema("Bad", schema => schema.OneOf(branch => branch.Type("string")))); + + var ex = await host.ProjectExpectingRefusalAsync(scenario); + + await Assert.That(ex.Message).Contains("oneOf"); + await Assert.That(ex.Message).Contains("Bad"); + } + [Test] public async Task Project_Should_Batch_Errors_From_Multiple_Schemas() { diff --git a/tests/OpenCode.Sdk.Tools.Tests/Generator/Ingestion/Walls/SchemaMemberWhitelistTests.cs b/tests/OpenCode.Sdk.Tools.Tests/Generator/Ingestion/Walls/SchemaMemberWhitelistTests.cs new file mode 100644 index 00000000..4bf94de4 --- /dev/null +++ b/tests/OpenCode.Sdk.Tools.Tests/Generator/Ingestion/Walls/SchemaMemberWhitelistTests.cs @@ -0,0 +1,48 @@ +using Microsoft.OpenApi; +using OpenCode.Sdk.Tools.Generator.Ingestion; +using OpenCode.Sdk.Tools.Generator.Ingestion.Models; +using OpenCode.Sdk.Tools.Generator.Ingestion.Walls; +using OpenCode.Sdk.Tools.Tests.Support; + +namespace OpenCode.Sdk.Tools.Tests.Generator.Ingestion.Walls; + +public sealed class SchemaMemberWhitelistTests +{ + [Test] + public async Task Check_Should_Refuse_Populated_Pinned_Member_Outside_Whitelist() + { + var context = SpecScenario.Define(spec => spec.WithSchema("Bad", schema => schema.Raw("deprecated", "true"))).Build(); + var errors = new IngestionErrorCollector(); + var loaded = await new SpecReader(context.FileSystem).LoadAsync(context.SpecPath, errors, CancellationToken.None); + var schema = loaded.Document.Components!.Schemas!["Bad"] as OpenApiSchema + ?? throw new InvalidOperationException("The real reader did not produce a concrete schema."); + var whitelist = new SchemaMemberWhitelist(); + + whitelist.Check(schema, "Bad", errors); + var ex = await Assert.That(errors.ThrowIfAny).Throws(); + + await Assert.That(ex!.Message).Contains("deprecated"); + await Assert.That(ex.Message).Contains("Bad"); + } + + [Test] + public async Task Check_Should_Allow_Admitted_And_Known_Ignored_Pinned_Members() + { + var context = SpecScenario + .Define(spec => spec.WithSchema("Value", schema => schema + .Type("string") + .Description("A value.") + .Format("custom") + .Raw("pattern", "\"^[a-z]+$\""))) + .Build(); + var errors = new IngestionErrorCollector(); + var loaded = await new SpecReader(context.FileSystem).LoadAsync(context.SpecPath, errors, CancellationToken.None); + var schema = loaded.Document.Components!.Schemas!["Value"] as OpenApiSchema + ?? throw new InvalidOperationException("The real reader did not produce a concrete schema."); + var whitelist = new SchemaMemberWhitelist(); + + whitelist.Check(schema, "Value", errors); + + await Assert.That(errors.HasErrors).IsFalse(); + } +} diff --git a/tools/OpenCode.Sdk.Tools/Generator/Ingestion/Projection/CoreSchemaShape.cs b/tools/OpenCode.Sdk.Tools/Generator/Ingestion/Projection/CoreSchemaShape.cs new file mode 100644 index 00000000..aef5bcbc --- /dev/null +++ b/tools/OpenCode.Sdk.Tools/Generator/Ingestion/Projection/CoreSchemaShape.cs @@ -0,0 +1,10 @@ +namespace OpenCode.Sdk.Tools.Generator.Ingestion.Projection; + +internal enum CoreSchemaShape +{ + Unsupported = 0, + Unrestricted = 1, + Primitive = 2, + Enum = 3, + Array = 4, +} diff --git a/tools/OpenCode.Sdk.Tools/Generator/Ingestion/Projection/EnumSchemaProjector.cs b/tools/OpenCode.Sdk.Tools/Generator/Ingestion/Projection/EnumSchemaProjector.cs new file mode 100644 index 00000000..37538ba0 --- /dev/null +++ b/tools/OpenCode.Sdk.Tools/Generator/Ingestion/Projection/EnumSchemaProjector.cs @@ -0,0 +1,51 @@ +using System.Text.Json.Nodes; +using Microsoft.OpenApi; +using OpenCode.Sdk.Tools.Generator.Ingestion.Models; + +namespace OpenCode.Sdk.Tools.Generator.Ingestion.Projection; + +internal sealed class EnumSchemaProjector +{ + private readonly SchemaShapeClassifier _shapeClassifier; + + public EnumSchemaProjector(SchemaShapeClassifier shapeClassifier) + { + _shapeClassifier = shapeClassifier ?? throw new ArgumentNullException(nameof(shapeClassifier)); + } + + public EnumNode? Project(OpenApiSchema schema, string location, IngestionErrorCollector errors) + { + ArgumentNullException.ThrowIfNull(schema); + ArgumentException.ThrowIfNullOrWhiteSpace(location); + ArgumentNullException.ThrowIfNull(errors); + + if (_shapeClassifier.Classify(schema, location, errors) is not CoreSchemaShape.Enum || schema.Enum is not { Count: > 1 } values) + { + if (schema.Enum is not { Count: > 1 }) + { + errors.Add(location, "only string enums with multiple values are supported by core schema projection"); + } + + return null; + } + + var projectedValues = new string[values.Count]; + for (var index = 0; index < values.Count; index++) + { + if (values[index] is not JsonValue value || !value.TryGetValue(out var projectedValue)) + { + errors.Add(location, "enum values must all be strings"); + return null; + } + + projectedValues[index] = projectedValue; + } + + return new EnumNode + { + Description = schema.Description, + Format = schema.Format, + Values = projectedValues, + }; + } +} diff --git a/tools/OpenCode.Sdk.Tools/Generator/Ingestion/Projection/SchemaProjector.cs b/tools/OpenCode.Sdk.Tools/Generator/Ingestion/Projection/SchemaProjector.cs index 10cc06e6..b7c6b4f2 100644 --- a/tools/OpenCode.Sdk.Tools/Generator/Ingestion/Projection/SchemaProjector.cs +++ b/tools/OpenCode.Sdk.Tools/Generator/Ingestion/Projection/SchemaProjector.cs @@ -1,4 +1,3 @@ -using System.Text.Json.Nodes; using Microsoft.OpenApi; using OpenCode.Sdk.Tools.Generator.Ingestion.Models; using OpenCode.Sdk.Tools.Generator.Ingestion.Walls; @@ -8,12 +7,16 @@ namespace OpenCode.Sdk.Tools.Generator.Ingestion.Projection; internal sealed class SchemaProjector { private readonly GraphKeyBuilder _keys; + private readonly EnumSchemaProjector _enumProjector; + private readonly SchemaShapeClassifier _shapeClassifier; private readonly SchemaWallPolicy _wall; public SchemaProjector(SchemaWallPolicy wall, GraphKeyBuilder keys) { _wall = wall ?? throw new ArgumentNullException(nameof(wall)); _keys = keys ?? throw new ArgumentNullException(nameof(keys)); + _shapeClassifier = new SchemaShapeClassifier(); + _enumProjector = new EnumSchemaProjector(_shapeClassifier); } public SchemaNode? Project(IOpenApiSchema schema, string root, string pointer, ProjectionState state) @@ -50,47 +53,18 @@ public SchemaProjector(SchemaWallPolicy wall, GraphKeyBuilder keys) { if (schema.Enum is { Count: > 0 }) { - return ProjectEnum(schema, location, state); + return _enumProjector.Project(schema, location, state.Errors); } - return schema.Type switch + return (_shapeClassifier.Classify(schema, location, state.Errors), schema.Type) switch { - JsonSchemaType.String => CreatePrimitive(schema, PrimitiveKind.String), - JsonSchemaType.Number => CreatePrimitive(schema, PrimitiveKind.Number), - JsonSchemaType.Integer => CreatePrimitive(schema, PrimitiveKind.Integer), - JsonSchemaType.Boolean => CreatePrimitive(schema, PrimitiveKind.Boolean), - JsonSchemaType.Array => ProjectArray(schema, root, pointer, location, state), - JsonSchemaType.Null or JsonSchemaType.Object => RefuseUnsupportedShape(schema, location, state), - null when _wall.IsUnrestricted(schema) => CreateUnrestricted(schema), - _ => RefuseUnsupportedShape(schema, location, state), - }; - } - - private static EnumNode? ProjectEnum(OpenApiSchema schema, string location, ProjectionState state) - { - if (schema.Type is not JsonSchemaType.String || schema.Enum is not { Count: > 1 } values) - { - state.Errors.Add(location, "only string enums with multiple values are supported by core schema projection"); - return null; - } - - var projectedValues = new string[values.Count]; - for (var index = 0; index < values.Count; index++) - { - if (values[index] is not JsonValue value || !value.TryGetValue(out var projectedValue)) - { - state.Errors.Add(location, "enum values must all be strings"); - return null; - } - - projectedValues[index] = projectedValue; - } - - return new EnumNode - { - Description = schema.Description, - Format = schema.Format, - Values = projectedValues, + (CoreSchemaShape.Primitive, JsonSchemaType.String) => CreatePrimitive(schema, PrimitiveKind.String), + (CoreSchemaShape.Primitive, JsonSchemaType.Number) => CreatePrimitive(schema, PrimitiveKind.Number), + (CoreSchemaShape.Primitive, JsonSchemaType.Integer) => CreatePrimitive(schema, PrimitiveKind.Integer), + (CoreSchemaShape.Primitive, JsonSchemaType.Boolean) => CreatePrimitive(schema, PrimitiveKind.Boolean), + (CoreSchemaShape.Array, _) => ProjectArray(schema, root, pointer, location, state), + (CoreSchemaShape.Unrestricted, _) => CreateUnrestricted(schema), + _ => null, }; } @@ -142,10 +116,4 @@ private static UnrestrictedNode CreateUnrestricted(OpenApiSchema schema) => state.Errors.Add(location, $"reference target '{target ?? ""}' could not be resolved"); return null; } - - private static SchemaNode? RefuseUnsupportedShape(OpenApiSchema schema, string location, ProjectionState state) - { - state.Errors.Add(location, $"schema type '{schema.Type?.ToString() ?? "unspecified"}' is not supported by core schema projection"); - return null; - } } diff --git a/tools/OpenCode.Sdk.Tools/Generator/Ingestion/Projection/SchemaShapeClassifier.cs b/tools/OpenCode.Sdk.Tools/Generator/Ingestion/Projection/SchemaShapeClassifier.cs new file mode 100644 index 00000000..c5669209 --- /dev/null +++ b/tools/OpenCode.Sdk.Tools/Generator/Ingestion/Projection/SchemaShapeClassifier.cs @@ -0,0 +1,78 @@ +using Microsoft.OpenApi; + +namespace OpenCode.Sdk.Tools.Generator.Ingestion.Projection; + +internal sealed class SchemaShapeClassifier +{ + private readonly ConstraintRule[] _constraintRules = + [ + new(Constraint.Type, "type", static schema => schema.Type is not null), + new(Constraint.Const, "const", static schema => schema.Const is not null), + new(Constraint.Enum, "enum", static schema => schema.Enum is { Count: > 0 }), + new(Constraint.OneOf, "oneOf", static schema => schema.OneOf is { Count: > 0 }), + new(Constraint.AnyOf, "anyOf", static schema => schema.AnyOf is { Count: > 0 }), + new(Constraint.AllOf, "allOf", static schema => schema.AllOf is { Count: > 0 }), + new(Constraint.Required, "required", static schema => schema.Required is { Count: > 0 }), + new(Constraint.Items, "items", static schema => schema.Items is not null), + new(Constraint.Properties, "properties", static schema => schema.Properties is { Count: > 0 }), + new(Constraint.PatternProperties, "patternProperties", static schema => schema.PatternProperties is { Count: > 0 }), + new(Constraint.AdditionalPropertiesAllowed, "additionalProperties", static schema => !schema.AdditionalPropertiesAllowed), + new(Constraint.AdditionalProperties, "additionalProperties", static schema => schema.AdditionalProperties is not null), + new(Constraint.ContentEncoding, "contentEncoding", static schema => schema.ContentEncoding is not null), + new(Constraint.ContentMediaType, "contentMediaType", static schema => schema.ContentMediaType is not null), + new(Constraint.ContentSchema, "contentSchema", static schema => schema.ContentSchema is not null), + ]; + + public CoreSchemaShape Classify(OpenApiSchema schema, string location, IngestionErrorCollector errors) + { + ArgumentNullException.ThrowIfNull(schema); + ArgumentException.ThrowIfNullOrWhiteSpace(location); + ArgumentNullException.ThrowIfNull(errors); + + var populatedRules = _constraintRules.Where(rule => rule.IsPopulated(schema)).ToArray(); + var constraints = populatedRules.Aggregate(Constraint.None, static (current, rule) => current | rule.Constraint); + var shape = (constraints, schema.Type) switch + { + (Constraint.None, _) => CoreSchemaShape.Unrestricted, + (Constraint.Type, JsonSchemaType.String or JsonSchemaType.Number or JsonSchemaType.Integer or JsonSchemaType.Boolean) => CoreSchemaShape + .Primitive, + (Constraint.Type | Constraint.Enum, JsonSchemaType.String) => CoreSchemaShape.Enum, + (Constraint.Type, JsonSchemaType.Array) => CoreSchemaShape.Array, + (Constraint.Type | Constraint.Items, JsonSchemaType.Array) => CoreSchemaShape.Array, + _ => CoreSchemaShape.Unsupported, + }; + + if (shape is not CoreSchemaShape.Unsupported) + { + return shape; + } + + var names = string.Join(", ", populatedRules.Select(rule => rule.WireName).Distinct(StringComparer.Ordinal)); + errors.Add(location, $"schema node-kind constraints '{names}' do not form a supported core shape"); + + return shape; + } + + [Flags] + private enum Constraint + { + None = 0, + Type = 1 << 0, + Const = 1 << 1, + Enum = 1 << 2, + OneOf = 1 << 3, + AnyOf = 1 << 4, + AllOf = 1 << 5, + Required = 1 << 6, + Items = 1 << 7, + Properties = 1 << 8, + PatternProperties = 1 << 9, + AdditionalPropertiesAllowed = 1 << 10, + AdditionalProperties = 1 << 11, + ContentEncoding = 1 << 12, + ContentMediaType = 1 << 13, + ContentSchema = 1 << 14, + } + + private sealed record ConstraintRule(Constraint Constraint, string WireName, Func IsPopulated); +} diff --git a/tools/OpenCode.Sdk.Tools/Generator/Ingestion/Walls/SchemaMemberWhitelist.cs b/tools/OpenCode.Sdk.Tools/Generator/Ingestion/Walls/SchemaMemberWhitelist.cs new file mode 100644 index 00000000..3cee6694 --- /dev/null +++ b/tools/OpenCode.Sdk.Tools/Generator/Ingestion/Walls/SchemaMemberWhitelist.cs @@ -0,0 +1,110 @@ +using System.Collections; +using System.Collections.Frozen; +using System.Reflection; +using Microsoft.OpenApi; + +namespace OpenCode.Sdk.Tools.Generator.Ingestion.Walls; + +internal sealed class SchemaMemberWhitelist +{ + private static readonly FrozenSet AdmittedMembers = new[] + { + nameof(OpenApiSchema.AdditionalProperties), nameof(OpenApiSchema.AdditionalPropertiesAllowed), nameof(OpenApiSchema.AnyOf), + nameof(OpenApiSchema.Const), nameof(OpenApiSchema.ContentEncoding), nameof(OpenApiSchema.ContentMediaType), + nameof(OpenApiSchema.ContentSchema), nameof(OpenApiSchema.Description), nameof(OpenApiSchema.Enum), nameof(OpenApiSchema.Format), + nameof(OpenApiSchema.Items), nameof(OpenApiSchema.OneOf), nameof(OpenApiSchema.PatternProperties), nameof(OpenApiSchema.Properties), + nameof(OpenApiSchema.Required), nameof(OpenApiSchema.Type), + }.ToFrozenSet(StringComparer.Ordinal); + + private static readonly FrozenSet KnownIgnoredMembers = new[] + { + nameof(OpenApiSchema.ExclusiveMinimum), nameof(OpenApiSchema.Maximum), nameof(OpenApiSchema.MaxItems), nameof(OpenApiSchema.Minimum), + nameof(OpenApiSchema.MinItems), nameof(OpenApiSchema.Pattern), + }.ToFrozenSet(StringComparer.Ordinal); + + private static readonly FrozenSet SeparatelyCheckedMembers = + new[] { nameof(OpenApiSchema.Extensions), nameof(OpenApiSchema.UnrecognizedKeywords), }.ToFrozenSet(StringComparer.Ordinal); + + private static readonly FrozenDictionary WireNames = new Dictionary(StringComparer.Ordinal) + { + [nameof(OpenApiSchema.Anchor)] = "$anchor", + [nameof(OpenApiSchema.Comment)] = "$comment", + [nameof(OpenApiSchema.Definitions)] = "$defs", + [nameof(OpenApiSchema.DynamicAnchor)] = "$dynamicAnchor", + [nameof(OpenApiSchema.DynamicRef)] = "$dynamicRef", + [nameof(OpenApiSchema.Id)] = "$id", + [nameof(OpenApiSchema.Schema)] = "$schema", + [nameof(OpenApiSchema.UnevaluatedPropertiesSchema)] = "unevaluatedProperties", + [nameof(OpenApiSchema.Vocabulary)] = "$vocabulary", + }.ToFrozenDictionary(StringComparer.Ordinal); + + private readonly OpenApiSchema _defaultSchema = new(); + + private readonly PropertyInfo[] _schemaMembers = + [ + .. typeof(OpenApiSchema) + .GetProperties(BindingFlags.Instance | BindingFlags.Public) + .OrderBy(property => property.Name, StringComparer.Ordinal), + ]; + + public void Check(OpenApiSchema schema, string location, IngestionErrorCollector errors) + { + ArgumentNullException.ThrowIfNull(schema); + ArgumentException.ThrowIfNullOrWhiteSpace(location); + ArgumentNullException.ThrowIfNull(errors); + + foreach (var member in _schemaMembers) + { + if (IsExempt(member.Name) || !IsPopulated(member, schema)) + { + continue; + } + + errors.Add(location, $"schema keyword '{GetWireName(member.Name)}' is not supported"); + } + } + + private static string GetWireName(string memberName) + { + if (WireNames.TryGetValue(memberName, out var wireName)) + { + return wireName; + } + + return $"{char.ToLowerInvariant(memberName[0])}{memberName[1..]}"; + } + + private static bool IsCollection(Type type) => + type != typeof(string) + && typeof(IEnumerable).IsAssignableFrom(type); + + private static bool IsExempt(string memberName) => + AdmittedMembers.Contains(memberName) + || KnownIgnoredMembers.Contains(memberName) + || SeparatelyCheckedMembers.Contains(memberName) + || string.Equals(memberName, nameof(OpenApiSchema.Metadata), StringComparison.Ordinal); + + private bool IsPopulated(PropertyInfo member, OpenApiSchema schema) + { + var value = member.GetValue(schema); + if (value is null || Equals(value, member.GetValue(_defaultSchema))) + { + return false; + } + + if (!IsCollection(member.PropertyType)) + { + return true; + } + + var enumerator = ((IEnumerable)value).GetEnumerator(); + try + { + return enumerator.MoveNext(); + } + finally + { + (enumerator as IDisposable)?.Dispose(); + } + } +} diff --git a/tools/OpenCode.Sdk.Tools/Generator/Ingestion/Walls/SchemaWallPolicy.cs b/tools/OpenCode.Sdk.Tools/Generator/Ingestion/Walls/SchemaWallPolicy.cs index 072bc975..a6b2955c 100644 --- a/tools/OpenCode.Sdk.Tools/Generator/Ingestion/Walls/SchemaWallPolicy.cs +++ b/tools/OpenCode.Sdk.Tools/Generator/Ingestion/Walls/SchemaWallPolicy.cs @@ -6,67 +6,7 @@ internal sealed class SchemaWallPolicy { private const string PrefixItemsAdmissionLocation = "Config/properties/plugin/items/anyOf/1"; - private readonly Func[] _admittedConstraintRules = - [ - static schema => schema.Type is not null, - static schema => schema.Const is not null, - static schema => schema.Enum is { Count: > 0 }, - static schema => schema.OneOf is { Count: > 0 }, - static schema => schema.AnyOf is { Count: > 0 }, - static schema => schema.AllOf is { Count: > 0 }, - static schema => schema.Required is { Count: > 0 }, - static schema => schema.Items is not null, - static schema => schema.Properties is { Count: > 0 }, - static schema => schema.PatternProperties is { Count: > 0 }, - static schema => !schema.AdditionalPropertiesAllowed, - static schema => schema.AdditionalProperties is not null, - static schema => schema.ContentEncoding is not null, - static schema => schema.ContentMediaType is not null, - static schema => schema.ContentSchema is not null, - ]; - - private readonly SchemaRule[] _rejectedRules = - [ - new("allOf", static schema => schema.AllOf is { Count: > 0 }), - new("type arrays", static schema => schema.Type is { } type && ((int)type & ((int)type - 1)) != 0), - new("discriminator", static schema => schema.Discriminator is not null), - new("not", static schema => schema.Not is not null), - new("if", static schema => schema.If is not null), - new("then", static schema => schema.Then is not null), - new("else", static schema => schema.Else is not null), - new("dependentSchemas", static schema => schema.DependentSchemas is { Count: > 0 }), - new("dependentRequired", static schema => schema.DependentRequired is { Count: > 0 }), - new("propertyNames", static schema => schema.PropertyNames is not null), - new("contains", static schema => schema.Contains is not null), - new("unevaluatedProperties", static schema => !schema.UnevaluatedProperties), - new("unevaluatedProperties", static schema => schema.UnevaluatedPropertiesSchema is not null), - new("$defs", static schema => schema.Definitions is { Count: > 0 }), - new("$dynamicRef", static schema => schema.DynamicRef is not null), - new("$dynamicAnchor", static schema => schema.DynamicAnchor is not null), - new("$schema", static schema => schema.Schema is not null), - new("$id", static schema => schema.Id is not null), - new("$anchor", static schema => schema.Anchor is not null), - new("$comment", static schema => schema.Comment is not null), - new("$vocabulary", static schema => schema.Vocabulary is { Count: > 0 }), - new("title", static schema => schema.Title is not null), - new("default", static schema => schema.Default is not null), - new("examples", static schema => schema.Examples is { Count: > 0 }), - new("example", static schema => schema.Example is not null), - new("readOnly", static schema => schema.ReadOnly), - new("writeOnly", static schema => schema.WriteOnly), - new("xml", static schema => schema.Xml is not null), - new("externalDocs", static schema => schema.ExternalDocs is not null), - new("minLength", static schema => schema.MinLength is not null), - new("maxLength", static schema => schema.MaxLength is not null), - new("multipleOf", static schema => schema.MultipleOf is not null), - new("exclusiveMaximum", static schema => schema.ExclusiveMaximum is not null), - new("minProperties", static schema => schema.MinProperties is not null), - new("maxProperties", static schema => schema.MaxProperties is not null), - new("uniqueItems", static schema => schema.UniqueItems is not null), - new("minContains", static schema => schema.MinContains is not null), - new("maxContains", static schema => schema.MaxContains is not null), - new("deprecated", static schema => schema.Deprecated), - ]; + private readonly SchemaMemberWhitelist _memberWhitelist = new(); public void Check(OpenApiSchema schema, string location, IngestionErrorCollector errors) { @@ -74,9 +14,11 @@ public void Check(OpenApiSchema schema, string location, IngestionErrorCollector ArgumentException.ThrowIfNullOrWhiteSpace(location); ArgumentNullException.ThrowIfNull(errors); - foreach (var rule in _rejectedRules.Where(rule => rule.IsPopulated(schema))) + _memberWhitelist.Check(schema, location, errors); + + if (schema.Type is { } type && ((int)type & ((int)type - 1)) != 0) { - errors.Add(location, $"schema keyword '{rule.Keyword}' is not supported"); + errors.Add(location, "schema keyword 'type' does not support multiple values"); } if (schema.UnrecognizedKeywords is not null) @@ -100,16 +42,7 @@ public void Check(OpenApiSchema schema, string location, IngestionErrorCollector } } - public bool IsUnrestricted(OpenApiSchema schema) - { - ArgumentNullException.ThrowIfNull(schema); - - return !_admittedConstraintRules.Any(rule => rule(schema)); - } - private static bool IsAdmittedPrefixItems(string keyword, string location) => string.Equals(keyword, "prefixItems", StringComparison.Ordinal) && string.Equals(location, PrefixItemsAdmissionLocation, StringComparison.Ordinal); - - private sealed record SchemaRule(string Keyword, Func IsPopulated); } From e89988508756cd46205de2dffd4f628a9344f808 Mon Sep 17 00:00:00 2001 From: "denizirgin@gmail.com" Date: Tue, 11 Aug 2026 14:08:22 +0300 Subject: [PATCH 10/19] docs(plan): merge coupled schema projection tasks --- .../2026-08-11-slice-01-ingestion-specir.md | 165 +++++++++--------- 1 file changed, 80 insertions(+), 85 deletions(-) diff --git a/docs/superpowers/plans/2026-08-11-slice-01-ingestion-specir.md b/docs/superpowers/plans/2026-08-11-slice-01-ingestion-specir.md index d908f0bf..63a20ba0 100644 --- a/docs/superpowers/plans/2026-08-11-slice-01-ingestion-specir.md +++ b/docs/superpowers/plans/2026-08-11-slice-01-ingestion-specir.md @@ -488,14 +488,14 @@ public sealed class SpecReaderTests - `internal sealed class SchemaWallPolicy` — `void Check(OpenApiSchema schema, string location, IngestionErrorCollector errors)`: the §4.1 schema table rendered as code. Refuses populated `AllOf` (outside union - analysis — Task 6 consumes it first), multi-flag `Type`, `Discriminator`, `Not`, + analysis — Task 5 consumes it first), multi-flag `Type`, `Discriminator`, `Not`, `If`/`Then`/`Else`, `DependentSchemas`/`DependentRequired`, `PropertyNames`, `Contains`, `UnevaluatedProperties == false`, `$defs`/dynamic members, `Title`, `Default`, `Examples`/`Example`, `ReadOnly`/`WriteOnly`, `Xml`, `ExternalDocs`, `MinLength`/`MaxLength`, `MultipleOf`, `MinProperties`/`MaxProperties`, `UniqueItems`; known-ignored: `pattern`, `minimum`, `maximum`, `exclusiveMinimum`, `minItems`, `maxItems`; `UnrecognizedKeywords` non-empty → error unless the admitted site - (`prefixItems` at `Config/properties/plugin/items/anyOf/1` — Task 6 consumes it); + (`prefixItems` at `Config/properties/plugin/items/anyOf/1` — Task 5 consumes it); schema-level `Extensions` → error. Bookkeeping members (`Metadata`) exempt. - `internal sealed class GraphKeyBuilder` — `string Root(string wireNameOrOpId)`, `string Append(string parentPointer, string segment)` (escapes `~`→`~0`, `/`→`~1`). @@ -510,12 +510,12 @@ public sealed class SpecReaderTests constraint member populated; annotations allowed) → `UnrestrictedNode`; scalar `Type` → `PrimitiveNode` (+`Format` recorded); `string`+multi-value `Enum` → `EnumNode`; `array`+`Items` → `ArrayNode`; array without items → error; promotion: inline - `EnumNode` (this task; `ObjectNode`/`UnionNode` in Tasks 5–6) registers under + `EnumNode` (this task; `ObjectNode`/`UnionNode` in Task 5) registers under `{root}#{pointer}` and returns a `RefNode`; key collision → error. - [ ] **Step 1: Write the failing tests.** Exemplars in full; every listed case becomes a real `[Test]` following the same pattern. `SchemaProjectionTestHost` is the reusable - named host for Tasks 4–6: it takes a `SpecScenario`, runs the real reader + projector, + named host for Tasks 4–5: it takes a `SpecScenario`, runs the real reader + projector, and returns `SchemaProjectionResult` (schemas + optional refusal) — never a tuple. One-off arrangements stay inline: @@ -566,26 +566,39 @@ public async Task Project_Should_Refuse_When_Schema_Uses_AllOf() --- -### Task 5: Object family — objects, hybrids, dictionaries, free-form +### Task 5: Object family, literals, unions, special numbers, tuples, content-encoded strings **Files:** - Create: `Models/ObjectNode.cs`, `Models/SpecProperty.cs`, `Models/AdditionalPropertiesKind.cs`, - `Models/DictionaryNode.cs`, `Models/FreeFormObjectNode.cs` -- Modify: `Projection/SchemaProjector.cs` -- Test: `tests/.../SchemaProjectorObjectTests.cs` + `Models/DictionaryNode.cs`, `Models/FreeFormObjectNode.cs`, `Models/LiteralNode.cs`, + `Models/LiteralKind.cs`, `Models/LiteralDialect.cs`, `Models/LiteralMarker.cs`, + `Models/UnionNode.cs`, `Models/UnionKeyword.cs`, `Models/UnionClassification.cs`, + `Models/NullableNode.cs`, `Models/SpecialNumberNode.cs`, `Models/TupleNode.cs`, + `Models/JsonStringNode.cs`, `Models/ErrorStyle.cs` +- Create: `Projection/ObjectSchemaProjector.cs`, `Projection/UnionNormalizer.cs`, + `Projection/LiteralClassifier.cs`, `Projection/ErrorStyleClassifier.cs`, + `Projection/PrefixItemsAdapter.cs` (all internal sealed) +- Modify: `Projection/SchemaProjector.cs`, `Projection/ProjectionState.cs`, + `Projection/SchemaShapeClassifier.cs`, `Projection/CoreSchemaShape.cs`, + `Projection/GraphKeyBuilder.cs` +- Create: `tests/OpenCode.Sdk.Tools.Tests/Fixtures/special-number.json`, + `Fixtures/config-plugin-tuple.json` +- Test: `tests/OpenCode.Sdk.Tools.Tests/Generator/Ingestion/SchemaProjectorObjectTests.cs`, + `Generator/Ingestion/UnionNormalizerTests.cs`, `Generator/Ingestion/LiteralClassifierTests.cs`, + `Generator/Ingestion/ErrorStyleClassifierTests.cs`, + `Generator/Ingestion/PrefixItemsAdapterTests.cs` **Interfaces:** -- Consumes: Task 4's dispatch + promotion + wall. +- Consumes: Task 4's fail-closed dispatch, promotion, wall, and raw-pointer state. - Produces: - `SpecProperty { required string Name; required SchemaNode Schema; required bool IsRequired; }` - - `ObjectNode { required IReadOnlyList Properties; required AdditionalPropertiesKind AdditionalProperties; SchemaNode? AdditionalPropertiesSchema; required IReadOnlyList LiteralMarkers; required ErrorStyle ErrorStyle; }` - — `LiteralMarkers`/`ErrorStyle` arrive in Task 6; this task constructs them empty/None - at the single construction site. **Hybrid objects** (properties **and** an - additionalProperties schema — 6 pin sites) populate both `Properties` and - `AdditionalPropertiesSchema` with `AdditionalProperties == Schema`. + - `ObjectNode { required IReadOnlyList Properties; required AdditionalPropertiesKind AdditionalProperties; SchemaNode? AdditionalPropertiesSchema; required IReadOnlyList LiteralMarkers; required ErrorStyle ErrorStyle; }`. + **Hybrid objects** (properties **and** an additionalProperties schema — 6 pin sites) + populate both `Properties` and `AdditionalPropertiesSchema` with + `AdditionalProperties == Schema`. - `AdditionalPropertiesKind { Open, Forbidden, Schema }`; `DictionaryNode { required SchemaNode Value; }`; `FreeFormObjectNode`. - - Dispatch rules: `properties` present → `ObjectNode` (property names are opaque wire + - Object dispatch: `properties` present → `ObjectNode` (property names are opaque wire data — a property named `type`/`properties`/`required` never keyword-matches; property order is document order; `required` names must match properties, else error); no properties + AP schema → `DictionaryNode`; no properties + single @@ -594,8 +607,39 @@ public async Task Project_Should_Refuse_When_Schema_Uses_AllOf() `FreeFormObjectNode`; `additionalProperties: true` explicit → treated as absent (`AdditionalPropertiesAllowed` default-true semantics; the tripwire snapshot pins the default); inline `ObjectNode` becomes promotion-eligible. + - `LiteralNode { required LiteralKind Kind; required string Value; required LiteralDialect Dialect; }` + (`Kind`: `String|Boolean`; `Dialect`: `SingleValueEnum|Const`); `const` admitted **only + on string-typed schemas** (the DOM's `Const` is a string and cannot preserve other + literal kinds — session 13); boolean single-value enum accepted; multi-value boolean + enum refuses; `enum`+`const` together refuses. + - `LiteralMarker { required string PropertyName; required LiteralKind Kind; required string Value; }` — + computed at the single `ObjectNode` construction site from required properties whose + schema is a `LiteralNode`, in property order (mechanical — never a name list). + - `ErrorStyle { None, EffectTag, NameData }` via `ErrorStyleClassifier`: required `_tag` + literal → `EffectTag`; required `name` literal + required `data` → `NameData`. + - `GraphKeyBuilder` gains + `string UnionBranch(string parentPointer, string keyword, int index, LiteralMarker? marker)`; + marked branches use `{property}={value}` and unmarked branches use the ordinal index. + - `UnionNode { required IReadOnlyList Branches; required UnionKeyword Keyword; required UnionClassification Classification; }` + (`Keyword`: `AnyOf|OneOf`; `Classification`: `Marked` — every object branch carries ≥1 + literal marker — else `Structural`; the 5 heterogeneous pin sites are `Structural`). + - `UnionNormalizer` — locked order: (1) special-number check (one `number` branch + only + NaN/Infinity string-literal branches, subset rule absorbs the combined literal) → + `SpecialNumberNode`; (2) duplicate-`$ref` dedup by target id; (3) null-branch extraction + → `NullableNode { required SchemaNode Inner }`; (4) one branch left → that node plain; + zero → error; (5) otherwise `UnionNode` with keyword + classification; + `anyOf`+`oneOf` together → error. + - `PrefixItemsAdapter` — reads the admitted raw `prefixItems` array; each item parses via + `OpenApiModelFactory.Parse(itemJson, OpenApiSpecVersion.OpenApi3_1, hostDocument, out var diagnostic, "json")`; + diagnostic errors → located errors; result → + `TupleNode { required IReadOnlyList Items }`; `items`+`prefixItems` together + → error; `minItems`/`maxItems` must equal arity → else error. Non-admitted + `prefixItems` sites stay wall errors (Task 4). + - `type: "string"` + `ContentSchema` → + `JsonStringNode { required SchemaNode Inner }`; `ContentMediaType` must be + `application/json` → else error. -- [ ] **Step 1: Write the failing tests** — exemplar plus inventory (each written in full): +- [ ] **Step 1: Write the failing object-family tests.** Exemplar plus every inventory case: ```csharp [Test] @@ -616,68 +660,14 @@ public async Task Project_Should_Keep_Property_Schema_And_Dictionary_Value_For_H } ``` - Inventory: object with required set; property order = document order; promoted inline - object property (graph key asserted); keyword-named properties as plain data (the - `GlobalEvent` trap — properties literally named `type`/`properties`/`required`); - dictionary via AP schema; dictionary via single patternProperties; patternProperties - multi-pattern refuse; patternProperties+properties refuse; free-form; empty-properties - object; `required` naming a missing property → located error; `additionalProperties: true` - → `Open`. -- [ ] **Step 2: verify fail.** — [ ] **Step 3: Implement.** — [ ] **Step 4: verify pass.** -- [ ] **Step 5: Full gate.** — [ ] **Step 6: Commit** — - `feat(tools): object, hybrid, dictionary and free-form projection` - ---- + Object inventory: required set; property order = document order; promoted inline object + property (graph key asserted); keyword-named properties as plain data (the `GlobalEvent` + trap — properties literally named `type`/`properties`/`required`); dictionary via AP + schema; dictionary via single patternProperties; patternProperties multi-pattern refuse; + patternProperties+properties refuse; free-form; empty-properties object; `required` + naming a missing property → located error; `additionalProperties: true` → `Open`. -### Task 6: Unions, literals, special numbers, tuples, content-encoded strings - -**Files:** -- Create: `Models/LiteralNode.cs`, `Models/LiteralKind.cs`, `Models/LiteralDialect.cs`, - `Models/LiteralMarker.cs`, `Models/UnionNode.cs`, `Models/UnionKeyword.cs`, - `Models/UnionClassification.cs`, `Models/NullableNode.cs`, `Models/SpecialNumberNode.cs`, - `Models/TupleNode.cs`, `Models/JsonStringNode.cs`, `Models/ErrorStyle.cs` -- Create: `Projection/UnionNormalizer.cs`, `Projection/LiteralClassifier.cs`, - `Projection/ErrorStyleClassifier.cs`, `Projection/PrefixItemsAdapter.cs` (all internal sealed) -- Modify: `Projection/SchemaProjector.cs`, `Projection/GraphKeyBuilder.cs`, - `Models/ObjectNode.cs` construction site -- Test: `tests/.../UnionNormalizerTests.cs`, `LiteralClassifierTests.cs`, - `ErrorStyleClassifierTests.cs`, `PrefixItemsAdapterTests.cs` - -**Interfaces:** -- Consumes: Tasks 4–5 dispatch/promotion; the raw admitted-site rule (wall). -- Produces: - - `LiteralNode { required LiteralKind Kind; required string Value; required LiteralDialect Dialect; }` - (`Kind`: `String|Boolean`; `Dialect`: `SingleValueEnum|Const`); `const` admitted **only - on string-typed schemas** (the DOM's `Const` is a string and cannot preserve other - literal kinds — session 13); boolean single-value enum accepted; multi-value boolean - enum refuses; `enum`+`const` together refuses. - - `LiteralMarker { required string PropertyName; required LiteralKind Kind; required string Value; }` — - computed on `ObjectNode` construction: required properties whose schema is a - `LiteralNode`, property order (mechanical — never a name list). - - `GraphKeyBuilder` gains - `string UnionBranch(string parentPointer, string keyword, int index, LiteralMarker? marker)` - once the marker model exists; marked branches use `{property}={value}` and unmarked - branches use the ordinal index. - - `ErrorStyle { None, EffectTag, NameData }` via `ErrorStyleClassifier`: required `_tag` - literal → `EffectTag`; required `name` literal + required `data` → `NameData`. - - `UnionNode { required IReadOnlyList Branches; required UnionKeyword Keyword; required UnionClassification Classification; }` - (`Keyword`: `AnyOf|OneOf`; `Classification`: `Marked` — every object branch carries ≥1 - literal marker — else `Structural`; the 5 heterogeneous pin sites are `Structural`). - - `UnionNormalizer` — the locked analysis order: (1) special-number check (one `number` - branch + only NaN/Infinity string-literal branches, subset rule absorbs the combined - literal) → `SpecialNumberNode`; (2) duplicate-`$ref` dedup by target id; (3) null-branch - extraction → wrap result in `NullableNode { required SchemaNode Inner }`; (4) single - branch left → that node plain; zero → error; (5) else `UnionNode` with keyword + - classification; `anyOf`+`oneOf` together → error. - - `PrefixItemsAdapter` — reads the admitted raw `prefixItems` array; each item parses via - `OpenApiModelFactory.Parse(itemJson, OpenApiSpecVersion.OpenApi3_1, hostDocument, out var diagnostic, "json")`; - diagnostic errors → located errors; result → `TupleNode { required IReadOnlyList Items }`; - `items`+`prefixItems` together → error; `minItems`/`maxItems` must equal arity → else - error. Non-admitted `prefixItems` sites stay wall errors (Task 4). - - `type: "string"` + `ContentSchema` → `JsonStringNode { required SchemaNode Inner }`; - `ContentMediaType` must be `application/json` → else error. - -- [ ] **Step 1: Write the failing tests.** Exemplar: +- [ ] **Step 2: Write the failing literal/union/normalization tests.** Exemplar: ```csharp [Test] @@ -707,13 +697,18 @@ public async Task Project_Should_Classify_Structural_Union_When_Branches_Carry_N `Workspace.timeUsed` five-branch shape incl. combined literal, via embedded fixture `special-number.json` + `WithRawSchema`); special-number near-miss (extra branch) → ordinary union; `boolean|string-enum` parameter-style union stays union; tuple via - admitted site (fixture reproducing `Config.plugin`); tuple arity conflict refuse; - `items`+`prefixItems` refuse; content-encoded string (ref inner); wrong - `contentMediaType` refuse; `EffectTag`/`NameData`/`None` classification; multi-value - boolean enum refuse. -- [ ] **Step 2: verify fail.** — [ ] **Step 3: Implement.** — [ ] **Step 4: verify pass.** -- [ ] **Step 5: Full gate.** — [ ] **Step 6: Commit** — - `feat(tools): union analysis, literal dialects, tuples and special numbers` + `config-plugin-tuple.json`; tuple arity conflict refuse; `items`+`prefixItems` refuse; + content-encoded string (ref inner); wrong `contentMediaType` refuse; + `EffectTag`/`NameData`/`None` classification; multi-value boolean enum refuse. + +- [ ] **Step 3: Run the combined focused tests to verify RED.** +- [ ] **Step 4: Implement in dependency order:** literal classification → object-family + projection/markers/error style → union normalization/promotion → tuple/content adapters. + Extend `SchemaShapeClassifier` and `ProjectionState` fail-closed; keep policy in the named + collaborators above rather than accumulating private methods in `SchemaProjector`. +- [ ] **Step 5: Run the combined focused tests to verify GREEN.** +- [ ] **Step 6: Full gate.** +- [ ] **Step 7: Commit** — `feat(tools): object, literal and union schema projection` --- @@ -735,7 +730,7 @@ public async Task Project_Should_Classify_Structural_Union_When_Branches_Carry_N `EnvelopeClassifierTests.cs`, `SpecMediaTypeTests.cs` **Interfaces:** -- Consumes: Tasks 3–6 (reader, schema projection under `op:{operationId}` roots). +- Consumes: Tasks 3–5 (reader, schema projection under `op:{operationId}` roots). - Produces: - `SpecMediaType { required string Raw; required string Stripped; required bool IsJson; required bool IsEventStream; }` with `static SpecMediaType Create(string raw)` — parameter-stripped, lowercased, From c0d098d71748455e9e8b2280bc44ea80ef7e9950 Mon Sep 17 00:00:00 2001 From: "denizirgin@gmail.com" Date: Tue, 11 Aug 2026 14:55:21 +0300 Subject: [PATCH 11/19] feat(tools): object, literal and union schema projection --- .editorconfig | 24 +- .../Fixtures/config-plugin-tuple.json | 28 ++ .../Fixtures/special-number.json | 23 ++ .../Ingestion/ErrorStyleClassifierTests.cs | 49 ++++ .../Ingestion/GraphKeyBuilderTests.cs | 27 ++ .../Ingestion/LiteralClassifierTests.cs | 108 +++++++ .../Ingestion/PrefixItemsAdapterTests.cs | 90 ++++++ .../Ingestion/SchemaProjectorObjectTests.cs | 232 +++++++++++++++ .../Ingestion/SchemaProjectorTests.cs | 26 +- .../Ingestion/UnionNormalizerTests.cs | 264 +++++++++++++++++ .../Support/SchemaBuilder.cs | 42 +++ .../Support/SchemaProjectionTestHost.cs | 2 +- .../Support/SpecDocumentBuilder.cs | 13 + .../Ingestion/IngestionErrorCollector.cs | 2 + .../Models/AdditionalPropertiesKind.cs | 14 + .../Ingestion/Models/DictionaryNode.cs | 19 ++ .../Generator/Ingestion/Models/ErrorStyle.cs | 14 + .../Ingestion/Models/FreeFormObjectNode.cs | 8 + .../Ingestion/Models/JsonStringNode.cs | 19 ++ .../Ingestion/Models/LiteralDialect.cs | 11 + .../Generator/Ingestion/Models/LiteralKind.cs | 11 + .../Ingestion/Models/LiteralMarker.cs | 30 ++ .../Generator/Ingestion/Models/LiteralNode.cs | 25 ++ .../Ingestion/Models/NullableNode.cs | 19 ++ .../Generator/Ingestion/Models/ObjectNode.cs | 41 +++ .../Ingestion/Models/SpecProperty.cs | 30 ++ .../Ingestion/Models/SpecialNumberNode.cs | 8 + .../Generator/Ingestion/Models/TupleNode.cs | 19 ++ .../Ingestion/Models/UnionClassification.cs | 11 + .../Ingestion/Models/UnionKeyword.cs | 11 + .../Generator/Ingestion/Models/UnionNode.cs | 25 ++ .../Ingestion/Projection/CoreSchemaShape.cs | 6 + .../Projection/EnumSchemaProjector.cs | 15 +- .../Projection/ErrorStyleClassifier.cs | 20 ++ .../Ingestion/Projection/GraphKeyBuilder.cs | 15 + .../Projection/JsonStringSchemaProjector.cs | 47 ++++ .../Ingestion/Projection/LiteralClassifier.cs | 157 +++++++++++ .../Projection/ObjectSchemaProjector.cs | 170 +++++++++++ .../Projection/PrefixItemsAdapter.cs | 105 +++++++ .../Ingestion/Projection/ProjectionState.cs | 8 +- .../Ingestion/Projection/SchemaProjector.cs | 35 ++- .../Projection/SchemaShapeClassifier.cs | 37 ++- .../Ingestion/Projection/UnionNormalizer.cs | 266 ++++++++++++++++++ 43 files changed, 2078 insertions(+), 48 deletions(-) create mode 100644 tests/OpenCode.Sdk.Tools.Tests/Fixtures/config-plugin-tuple.json create mode 100644 tests/OpenCode.Sdk.Tools.Tests/Fixtures/special-number.json create mode 100644 tests/OpenCode.Sdk.Tools.Tests/Generator/Ingestion/ErrorStyleClassifierTests.cs create mode 100644 tests/OpenCode.Sdk.Tools.Tests/Generator/Ingestion/LiteralClassifierTests.cs create mode 100644 tests/OpenCode.Sdk.Tools.Tests/Generator/Ingestion/PrefixItemsAdapterTests.cs create mode 100644 tests/OpenCode.Sdk.Tools.Tests/Generator/Ingestion/SchemaProjectorObjectTests.cs create mode 100644 tests/OpenCode.Sdk.Tools.Tests/Generator/Ingestion/UnionNormalizerTests.cs create mode 100644 tools/OpenCode.Sdk.Tools/Generator/Ingestion/Models/AdditionalPropertiesKind.cs create mode 100644 tools/OpenCode.Sdk.Tools/Generator/Ingestion/Models/DictionaryNode.cs create mode 100644 tools/OpenCode.Sdk.Tools/Generator/Ingestion/Models/ErrorStyle.cs create mode 100644 tools/OpenCode.Sdk.Tools/Generator/Ingestion/Models/FreeFormObjectNode.cs create mode 100644 tools/OpenCode.Sdk.Tools/Generator/Ingestion/Models/JsonStringNode.cs create mode 100644 tools/OpenCode.Sdk.Tools/Generator/Ingestion/Models/LiteralDialect.cs create mode 100644 tools/OpenCode.Sdk.Tools/Generator/Ingestion/Models/LiteralKind.cs create mode 100644 tools/OpenCode.Sdk.Tools/Generator/Ingestion/Models/LiteralMarker.cs create mode 100644 tools/OpenCode.Sdk.Tools/Generator/Ingestion/Models/LiteralNode.cs create mode 100644 tools/OpenCode.Sdk.Tools/Generator/Ingestion/Models/NullableNode.cs create mode 100644 tools/OpenCode.Sdk.Tools/Generator/Ingestion/Models/ObjectNode.cs create mode 100644 tools/OpenCode.Sdk.Tools/Generator/Ingestion/Models/SpecProperty.cs create mode 100644 tools/OpenCode.Sdk.Tools/Generator/Ingestion/Models/SpecialNumberNode.cs create mode 100644 tools/OpenCode.Sdk.Tools/Generator/Ingestion/Models/TupleNode.cs create mode 100644 tools/OpenCode.Sdk.Tools/Generator/Ingestion/Models/UnionClassification.cs create mode 100644 tools/OpenCode.Sdk.Tools/Generator/Ingestion/Models/UnionKeyword.cs create mode 100644 tools/OpenCode.Sdk.Tools/Generator/Ingestion/Models/UnionNode.cs create mode 100644 tools/OpenCode.Sdk.Tools/Generator/Ingestion/Projection/ErrorStyleClassifier.cs create mode 100644 tools/OpenCode.Sdk.Tools/Generator/Ingestion/Projection/JsonStringSchemaProjector.cs create mode 100644 tools/OpenCode.Sdk.Tools/Generator/Ingestion/Projection/LiteralClassifier.cs create mode 100644 tools/OpenCode.Sdk.Tools/Generator/Ingestion/Projection/ObjectSchemaProjector.cs create mode 100644 tools/OpenCode.Sdk.Tools/Generator/Ingestion/Projection/PrefixItemsAdapter.cs create mode 100644 tools/OpenCode.Sdk.Tools/Generator/Ingestion/Projection/UnionNormalizer.cs diff --git a/.editorconfig b/.editorconfig index 7677d807..734a0360 100644 --- a/.editorconfig +++ b/.editorconfig @@ -751,13 +751,31 @@ dotnet_diagnostic.CA1034.severity = none [tools/OpenCode.Sdk.Tools/Generator/Ingestion/Models/PrimitiveKind.cs] dotnet_diagnostic.CA1720.severity = none -# GraphKeyBuilder is an injected collaborator whose instance interface keeps graph-key -# policy replaceable as later projection slices deepen it. Winner: the sealed projector -# collaborator contract; static-member rules remain enabled for every other file. +# GraphKeyBuilder centralizes pointer and branch-identity policy shared by projection +# collaborators. Winner: one sealed policy owner instead of duplicated static mechanics; +# static-member rules remain enabled for every other file. [tools/OpenCode.Sdk.Tools/Generator/Ingestion/Projection/GraphKeyBuilder.cs] dotnet_diagnostic.CA1822.severity = none dotnet_diagnostic.S2325.severity = none +# ErrorStyleClassifier is a named policy collaborator constructed by object projection; +# making its only operation static would erase that ownership boundary. Winner: the sealed +# classifier contract; static-member rules remain enabled for every other file. +[tools/OpenCode.Sdk.Tools/Generator/Ingestion/Projection/ErrorStyleClassifier.cs] +dotnet_diagnostic.CA1822.severity = none +dotnet_diagnostic.S2325.severity = none + +# EnumSchemaProjector and LiteralClassifier are named schema-policy collaborators composed by +# SchemaProjector; static operations would erase that ownership. Winner: the sealed collaborator +# contracts; static-member rules remain enabled for every other file. +[tools/OpenCode.Sdk.Tools/Generator/Ingestion/Projection/EnumSchemaProjector.cs] +dotnet_diagnostic.CA1822.severity = none +dotnet_diagnostic.S2325.severity = none + +[tools/OpenCode.Sdk.Tools/Generator/Ingestion/Projection/LiteralClassifier.cs] +dotnet_diagnostic.CA1822.severity = none +dotnet_diagnostic.S2325.severity = none + # The wire-faithful SpecIR literal-kind contract likewise requires the exact # String member name. Winner: the wire-faithful public contract; CA1720 remains # enabled for every other file. diff --git a/tests/OpenCode.Sdk.Tools.Tests/Fixtures/config-plugin-tuple.json b/tests/OpenCode.Sdk.Tools.Tests/Fixtures/config-plugin-tuple.json new file mode 100644 index 00000000..5c07c7fb --- /dev/null +++ b/tests/OpenCode.Sdk.Tools.Tests/Fixtures/config-plugin-tuple.json @@ -0,0 +1,28 @@ +{ + "type": "object", + "properties": { + "plugin": { + "type": "array", + "items": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "prefixItems": [ + { + "type": "string" + }, + { + "type": "object" + } + ], + "maxItems": 2, + "minItems": 2 + } + ] + } + } + } +} diff --git a/tests/OpenCode.Sdk.Tools.Tests/Fixtures/special-number.json b/tests/OpenCode.Sdk.Tools.Tests/Fixtures/special-number.json new file mode 100644 index 00000000..68500eb3 --- /dev/null +++ b/tests/OpenCode.Sdk.Tools.Tests/Fixtures/special-number.json @@ -0,0 +1,23 @@ +{ + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": ["NaN"] + }, + { + "type": "string", + "enum": ["Infinity"] + }, + { + "type": "string", + "enum": ["-Infinity"] + }, + { + "type": "string", + "enum": ["Infinity", "-Infinity", "NaN"] + } + ] +} diff --git a/tests/OpenCode.Sdk.Tools.Tests/Generator/Ingestion/ErrorStyleClassifierTests.cs b/tests/OpenCode.Sdk.Tools.Tests/Generator/Ingestion/ErrorStyleClassifierTests.cs new file mode 100644 index 00000000..160ccdd7 --- /dev/null +++ b/tests/OpenCode.Sdk.Tools.Tests/Generator/Ingestion/ErrorStyleClassifierTests.cs @@ -0,0 +1,49 @@ +using OpenCode.Sdk.Tools.Generator.Ingestion.Models; +using OpenCode.Sdk.Tools.Tests.Support; + +namespace OpenCode.Sdk.Tools.Tests.Generator.Ingestion; + +public sealed class ErrorStyleClassifierTests +{ + [Test] + public async Task Project_Should_Classify_EffectTag_Error_Style() + { + var host = new SchemaProjectionTestHost(); + var scenario = SpecScenario.Define(spec => spec.WithSchema("EffectError", schema => schema + .Type("object") + .Property("_tag", property => property.Type("string").Enum("EffectError"), required: true) + .Property("message", property => property.Type("string"), required: true))); + + var result = await host.ProjectAsync(scenario); + + await Assert.That(((ObjectNode)result.Schemas["EffectError"]).ErrorStyle).IsEqualTo(ErrorStyle.EffectTag); + } + + [Test] + public async Task Project_Should_Classify_NameData_Error_Style() + { + var host = new SchemaProjectionTestHost(); + var scenario = SpecScenario.Define(spec => spec.WithSchema("NamedError", schema => schema + .Type("object") + .Property("name", property => property.Type("string").Enum("NamedError"), required: true) + .Property("data", property => property.Type("object"), required: true))); + + var result = await host.ProjectAsync(scenario); + + await Assert.That(((ObjectNode)result.Schemas["NamedError"]).ErrorStyle).IsEqualTo(ErrorStyle.NameData); + } + + [Test] + public async Task Project_Should_Classify_None_When_NameData_Requirements_Are_Not_Met() + { + var host = new SchemaProjectionTestHost(); + var scenario = SpecScenario.Define(spec => spec.WithSchema("NotAnError", schema => schema + .Type("object") + .Property("name", property => property.Type("string").Enum("NotAnError"), required: true) + .Property("data", property => property.Type("object")))); + + var result = await host.ProjectAsync(scenario); + + await Assert.That(((ObjectNode)result.Schemas["NotAnError"]).ErrorStyle).IsEqualTo(ErrorStyle.None); + } +} diff --git a/tests/OpenCode.Sdk.Tools.Tests/Generator/Ingestion/GraphKeyBuilderTests.cs b/tests/OpenCode.Sdk.Tools.Tests/Generator/Ingestion/GraphKeyBuilderTests.cs index a1207cf7..5a7dfd1d 100644 --- a/tests/OpenCode.Sdk.Tools.Tests/Generator/Ingestion/GraphKeyBuilderTests.cs +++ b/tests/OpenCode.Sdk.Tools.Tests/Generator/Ingestion/GraphKeyBuilderTests.cs @@ -1,3 +1,4 @@ +using OpenCode.Sdk.Tools.Generator.Ingestion.Models; using OpenCode.Sdk.Tools.Generator.Ingestion.Projection; namespace OpenCode.Sdk.Tools.Tests.Generator.Ingestion; @@ -23,4 +24,30 @@ public async Task Append_Should_Escape_Tilde_In_Segment() await Assert.That(pointer).IsEqualTo("/a~0b"); } + + [Test] + public async Task UnionBranch_Should_Use_And_Escape_Literal_Marker_Identity() + { + var keys = new GraphKeyBuilder(); + var marker = new LiteralMarker + { + PropertyName = "type/name", + Kind = LiteralKind.String, + Value = "created~new", + }; + + var pointer = keys.UnionBranch(string.Empty, "anyOf", 0, marker); + + await Assert.That(pointer).IsEqualTo("/anyOf/type~1name=created~0new"); + } + + [Test] + public async Task UnionBranch_Should_Use_Ordinal_For_Unmarked_Branch() + { + var keys = new GraphKeyBuilder(); + + var pointer = keys.UnionBranch(string.Empty, "oneOf", 3, marker: null); + + await Assert.That(pointer).IsEqualTo("/oneOf/3"); + } } diff --git a/tests/OpenCode.Sdk.Tools.Tests/Generator/Ingestion/LiteralClassifierTests.cs b/tests/OpenCode.Sdk.Tools.Tests/Generator/Ingestion/LiteralClassifierTests.cs new file mode 100644 index 00000000..a746e839 --- /dev/null +++ b/tests/OpenCode.Sdk.Tools.Tests/Generator/Ingestion/LiteralClassifierTests.cs @@ -0,0 +1,108 @@ +using OpenCode.Sdk.Tools.Generator.Ingestion.Models; +using OpenCode.Sdk.Tools.Tests.Support; + +namespace OpenCode.Sdk.Tools.Tests.Generator.Ingestion; + +public sealed class LiteralClassifierTests +{ + [Test] + public async Task Project_Should_Produce_String_Literal_From_Single_Value_Enum() + { + var host = new SchemaProjectionTestHost(); + var scenario = SpecScenario.Define(spec => spec.WithSchema("Kind", schema => schema.Type("string").Enum("created"))); + + var result = await host.ProjectAsync(scenario); + + var literal = (LiteralNode)result.Schemas["Kind"]; + await Assert.That(literal.Kind).IsEqualTo(LiteralKind.String); + await Assert.That(literal.Value).IsEqualTo("created"); + await Assert.That(literal.Dialect).IsEqualTo(LiteralDialect.SingleValueEnum); + } + + [Test] + public async Task Project_Should_Produce_Boolean_Literal_From_Single_Value_Enum() + { + var host = new SchemaProjectionTestHost(); + var scenario = SpecScenario.Define(spec => spec.WithSchema("Healthy", schema => schema.Type("boolean").BooleanEnum(true))); + + var result = await host.ProjectAsync(scenario); + + var literal = (LiteralNode)result.Schemas["Healthy"]; + await Assert.That(literal.Kind).IsEqualTo(LiteralKind.Boolean); + await Assert.That(literal.Value).IsEqualTo("true"); + await Assert.That(literal.Dialect).IsEqualTo(LiteralDialect.SingleValueEnum); + } + + [Test] + public async Task Project_Should_Produce_String_Literal_From_Const() + { + var host = new SchemaProjectionTestHost(); + var scenario = SpecScenario.Define(spec => spec.WithSchema("Kind", schema => schema.Type("string").Const("updated"))); + + var result = await host.ProjectAsync(scenario); + + var literal = (LiteralNode)result.Schemas["Kind"]; + await Assert.That(literal.Kind).IsEqualTo(LiteralKind.String); + await Assert.That(literal.Value).IsEqualTo("updated"); + await Assert.That(literal.Dialect).IsEqualTo(LiteralDialect.Const); + } + + [Test] + public async Task Project_Should_Refuse_Const_On_Non_String_Schema() + { + var host = new SchemaProjectionTestHost(); + var scenario = SpecScenario.Define(spec => spec.WithSchema("Bad", schema => schema.Type("number").Const("1"))); + + var exception = await host.ProjectExpectingRefusalAsync(scenario); + + await Assert.That(exception.Message).Contains("const"); + await Assert.That(exception.Message).Contains("string"); + } + + [Test] + public async Task Project_Should_Refuse_When_Enum_And_Const_Are_Combined() + { + var host = new SchemaProjectionTestHost(); + var scenario = SpecScenario.Define(spec => spec.WithSchema("Bad", schema => schema.Type("string").Enum("one").Const("one"))); + + var exception = await host.ProjectExpectingRefusalAsync(scenario); + + await Assert.That(exception.Message).Contains("enum"); + await Assert.That(exception.Message).Contains("const"); + } + + [Test] + public async Task Project_Should_Refuse_Multi_Value_Boolean_Enum() + { + var host = new SchemaProjectionTestHost(); + var scenario = SpecScenario.Define(spec => spec.WithSchema("Bad", schema => schema.Type("boolean").BooleanEnum(true, false))); + + var exception = await host.ProjectExpectingRefusalAsync(scenario); + + await Assert.That(exception.Message).Contains("boolean"); + await Assert.That(exception.Message).Contains("multiple"); + } + + [Test] + public async Task Project_Should_Collect_Only_Required_Literal_Markers() + { + var host = new SchemaProjectionTestHost(); + var scenario = SpecScenario.Define(spec => spec.WithSchema("Event", schema => schema + .Type("object") + .Property("type", property => property.Type("string").Enum("created"), required: true) + .Property("healthy", property => property.Type("boolean").BooleanEnum(true)) + .Property("data", property => property.Type("string"), required: true) + .Property("success", property => property.Type("boolean").BooleanEnum(false), required: true))); + + var result = await host.ProjectAsync(scenario); + + var markers = ((ObjectNode)result.Schemas["Event"]).LiteralMarkers; + await Assert.That(markers).Count().IsEqualTo(2); + await Assert.That(markers[0].PropertyName).IsEqualTo("type"); + await Assert.That(markers[0].Kind).IsEqualTo(LiteralKind.String); + await Assert.That(markers[0].Value).IsEqualTo("created"); + await Assert.That(markers[1].PropertyName).IsEqualTo("success"); + await Assert.That(markers[1].Kind).IsEqualTo(LiteralKind.Boolean); + await Assert.That(markers[1].Value).IsEqualTo("false"); + } +} diff --git a/tests/OpenCode.Sdk.Tools.Tests/Generator/Ingestion/PrefixItemsAdapterTests.cs b/tests/OpenCode.Sdk.Tools.Tests/Generator/Ingestion/PrefixItemsAdapterTests.cs new file mode 100644 index 00000000..9986e3e5 --- /dev/null +++ b/tests/OpenCode.Sdk.Tools.Tests/Generator/Ingestion/PrefixItemsAdapterTests.cs @@ -0,0 +1,90 @@ +using OpenCode.Sdk.Tools.Generator.Ingestion.Models; +using OpenCode.Sdk.Tools.Tests.Support; + +namespace OpenCode.Sdk.Tools.Tests.Generator.Ingestion; + +public sealed class PrefixItemsAdapterTests +{ + [Test] + public async Task Project_Should_Produce_Tuple_From_Config_Plugin_PrefixItems() + { + var host = new SchemaProjectionTestHost(); + var scenario = SpecScenario.Define(spec => spec.WithRawSchema("Config", "config-plugin-tuple.json")); + + var result = await host.ProjectAsync(scenario); + + var config = (ObjectNode)result.Schemas["Config"]; + var plugin = (ArrayNode)config.Properties.Single().Schema; + var alternativesReference = (RefNode)plugin.Item; + await Assert.That(alternativesReference.Target).IsEqualTo("Config#/properties/plugin/items"); + var alternatives = (UnionNode)result.Schemas[alternativesReference.Target]; + var tuple = (TupleNode)alternatives.Branches[1]; + await Assert.That(tuple.Items).Count().IsEqualTo(2); + await Assert.That(tuple.Items[0]).IsTypeOf(); + await Assert.That(tuple.Items[1]).IsTypeOf(); + await Assert.That(tuple.Children).Count().IsEqualTo(2); + } + + [Test] + public async Task Project_Should_Refuse_When_Tuple_Arity_Conflicts_With_MinItems() + { + var host = new SchemaProjectionTestHost(); + var scenario = SpecScenario.Define(spec => spec.WithConfigPluginTuple(tuple => tuple + .Type("array") + .PrefixItems(item => item.Type("string"), item => item.Type("object")) + .MinItems(1) + .MaxItems(2))); + + var exception = await host.ProjectExpectingRefusalAsync(scenario); + + await Assert.That(exception.Errors.Single().Location).IsEqualTo("Config/properties/plugin/items/anyOf/1/minItems"); + await Assert.That(exception.Message).Contains("arity"); + } + + [Test] + public async Task Project_Should_Refuse_When_Items_And_PrefixItems_Are_Combined() + { + var host = new SchemaProjectionTestHost(); + var scenario = SpecScenario.Define(spec => spec.WithConfigPluginTuple(tuple => tuple + .Type("array") + .Items(item => item.Type("string")) + .PrefixItems(item => item.Type("string")) + .MinItems(1) + .MaxItems(1))); + + var exception = await host.ProjectExpectingRefusalAsync(scenario); + + await Assert.That(exception.Message).Contains("items"); + await Assert.That(exception.Message).Contains("prefixItems"); + } + + [Test] + public async Task Project_Should_Produce_Json_String_Node_With_Reference_Inner() + { + var host = new SchemaProjectionTestHost(); + var scenario = SpecScenario.Define(spec => spec + .WithSchema("Payload", schema => schema.Type("object").Property("id", property => property.Type("string"))) + .WithSchema("Encoded", schema => schema.Type("string").ContentSchema("application/json", inner => inner.Ref("Payload")))); + + var result = await host.ProjectAsync(scenario); + + var node = (JsonStringNode)result.Schemas["Encoded"]; + await Assert.That(node.Inner).IsTypeOf(); + await Assert.That(((RefNode)node.Inner).Target).IsEqualTo("Payload"); + await Assert.That(node.Children.Single()).IsSameReferenceAs(node.Inner); + } + + [Test] + public async Task Project_Should_Refuse_Json_String_With_Wrong_ContentMediaType() + { + var host = new SchemaProjectionTestHost(); + var scenario = SpecScenario.Define(spec => spec.WithSchema("Encoded", schema => schema + .Type("string") + .ContentSchema("text/plain", inner => inner.Type("string")))); + + var exception = await host.ProjectExpectingRefusalAsync(scenario); + + await Assert.That(exception.Errors.Single().Location).IsEqualTo("Encoded/contentMediaType"); + await Assert.That(exception.Message).Contains("application/json"); + } +} diff --git a/tests/OpenCode.Sdk.Tools.Tests/Generator/Ingestion/SchemaProjectorObjectTests.cs b/tests/OpenCode.Sdk.Tools.Tests/Generator/Ingestion/SchemaProjectorObjectTests.cs new file mode 100644 index 00000000..96a4c67c --- /dev/null +++ b/tests/OpenCode.Sdk.Tools.Tests/Generator/Ingestion/SchemaProjectorObjectTests.cs @@ -0,0 +1,232 @@ +using OpenCode.Sdk.Tools.Generator.Ingestion.Models; +using OpenCode.Sdk.Tools.Tests.Support; + +namespace OpenCode.Sdk.Tools.Tests.Generator.Ingestion; + +public sealed class SchemaProjectorObjectTests +{ + [Test] + public async Task Project_Should_Keep_Property_Schema_And_Dictionary_Value_For_Hybrid_Objects() + { + var host = new SchemaProjectionTestHost(); + var scenario = SpecScenario.Define(spec => spec.WithSchema("ProviderOptions", schema => schema + .Type("object") + .Property("timeout", property => property.Type("number")) + .AdditionalProperties(value => value.Type("string")))); + + var result = await host.ProjectAsync(scenario); + + var node = (ObjectNode)result.Schemas["ProviderOptions"]; + await Assert.That(node.AdditionalProperties).IsEqualTo(AdditionalPropertiesKind.Schema); + await Assert.That(node.AdditionalPropertiesSchema).IsTypeOf(); + await Assert.That(node.Properties.Single(property => property.Name == "timeout").IsRequired).IsFalse(); + await Assert.That(node.Children).Count().IsEqualTo(2); + } + + [Test] + public async Task Project_Should_Record_Required_Properties() + { + var host = new SchemaProjectionTestHost(); + var scenario = SpecScenario.Define(spec => spec.WithSchema("Request", schema => schema + .Type("object") + .Property("requiredValue", property => property.Type("string"), required: true) + .Property("optionalValue", property => property.Type("string")) + .AdditionalPropertiesFalse())); + + var result = await host.ProjectAsync(scenario); + + var node = (ObjectNode)result.Schemas["Request"]; + await Assert.That(node.Properties[0].IsRequired).IsTrue(); + await Assert.That(node.Properties[1].IsRequired).IsFalse(); + await Assert.That(node.AdditionalProperties).IsEqualTo(AdditionalPropertiesKind.Forbidden); + await Assert.That(node.LiteralMarkers).IsEmpty(); + await Assert.That(node.ErrorStyle).IsEqualTo(ErrorStyle.None); + } + + [Test] + public async Task Project_Should_Preserve_Property_Document_Order() + { + var host = new SchemaProjectionTestHost(); + var scenario = SpecScenario.Define(spec => spec.WithSchema("Ordered", schema => schema + .Type("object") + .Property("third", property => property.Type("string")) + .Property("first", property => property.Type("string")) + .Property("second", property => property.Type("string")))); + + var result = await host.ProjectAsync(scenario); + + var properties = ((ObjectNode)result.Schemas["Ordered"]).Properties; + await Assert.That(properties).Count().IsEqualTo(3); + await Assert.That(properties[0].Name).IsEqualTo("third"); + await Assert.That(properties[1].Name).IsEqualTo("first"); + await Assert.That(properties[2].Name).IsEqualTo("second"); + } + + [Test] + public async Task Project_Should_Promote_Inline_Object_Property() + { + var host = new SchemaProjectionTestHost(); + var scenario = SpecScenario.Define(spec => spec.WithSchema("Envelope", schema => schema + .Type("object") + .Property("payload", property => property + .Type("object") + .Property("value", value => value.Type("string"))))); + + var result = await host.ProjectAsync(scenario); + + var property = ((ObjectNode)result.Schemas["Envelope"]).Properties.Single(); + await Assert.That(property.Schema).IsTypeOf(); + await Assert.That(((RefNode)property.Schema).Target).IsEqualTo("Envelope#/properties/payload"); + await Assert.That(result.Schemas["Envelope#/properties/payload"]).IsTypeOf(); + } + + [Test] + public async Task Project_Should_Treat_Keyword_Named_Properties_As_Wire_Data() + { + var host = new SchemaProjectionTestHost(); + var scenario = SpecScenario.Define(spec => spec.WithSchema("GlobalEvent", schema => schema + .Type("object") + .Property("type", property => property.Type("string")) + .Property("properties", property => property.Type("string")) + .Property("required", property => property.Type("string")))); + + var result = await host.ProjectAsync(scenario); + + var properties = ((ObjectNode)result.Schemas["GlobalEvent"]).Properties; + await Assert.That(properties).Count().IsEqualTo(3); + await Assert.That(properties[0].Name).IsEqualTo("type"); + await Assert.That(properties[1].Name).IsEqualTo("properties"); + await Assert.That(properties[2].Name).IsEqualTo("required"); + } + + [Test] + public async Task Project_Should_Produce_Dictionary_From_AdditionalProperties_Schema() + { + var host = new SchemaProjectionTestHost(); + var scenario = SpecScenario.Define(spec => spec.WithSchema("Labels", schema => schema + .Type("object") + .AdditionalProperties(value => value.Type("string")))); + + var result = await host.ProjectAsync(scenario); + + var node = (DictionaryNode)result.Schemas["Labels"]; + await Assert.That(node.Value).IsTypeOf(); + await Assert.That(((PrimitiveNode)node.Value).Kind).IsEqualTo(PrimitiveKind.String); + } + + [Test] + public async Task Project_Should_Produce_Dictionary_From_Single_PatternProperties_Entry() + { + var host = new SchemaProjectionTestHost(); + var scenario = SpecScenario.Define(spec => spec.WithSchema("Sessions", schema => schema + .Type("object") + .PatternProperties("^.*$", value => value.Type("integer")))); + + var result = await host.ProjectAsync(scenario); + + var node = (DictionaryNode)result.Schemas["Sessions"]; + await Assert.That(node.Value).IsTypeOf(); + await Assert.That(((PrimitiveNode)node.Value).Kind).IsEqualTo(PrimitiveKind.Integer); + } + + [Test] + public async Task Project_Should_Refuse_When_PatternProperties_Has_Multiple_Entries() + { + var host = new SchemaProjectionTestHost(); + var scenario = SpecScenario.Define(spec => spec.WithSchema("Bad", schema => schema + .Type("object") + .PatternProperties("^a", value => value.Type("string")) + .PatternProperties("^b", value => value.Type("string")))); + + var exception = await host.ProjectExpectingRefusalAsync(scenario); + + await Assert.That(exception.Message).Contains("patternProperties"); + await Assert.That(exception.Message).Contains("exactly one"); + } + + [Test] + public async Task Project_Should_Refuse_When_PatternProperties_Is_Combined_With_Properties() + { + var host = new SchemaProjectionTestHost(); + var scenario = SpecScenario.Define(spec => spec.WithSchema("Bad", schema => schema + .Type("object") + .Property("value", property => property.Type("string")) + .PatternProperties("^.*$", value => value.Type("string")))); + + var exception = await host.ProjectExpectingRefusalAsync(scenario); + + await Assert.That(exception.Message).Contains("patternProperties"); + await Assert.That(exception.Message).Contains("properties"); + } + + [Test] + public async Task Project_Should_Refuse_When_PatternProperties_Is_Combined_With_AdditionalProperties_Schema() + { + var host = new SchemaProjectionTestHost(); + var scenario = SpecScenario.Define(spec => spec.WithSchema("Bad", schema => schema + .Type("object") + .PatternProperties("^.*$", value => value.Type("string")) + .AdditionalProperties(value => value.Type("string")))); + + var exception = await host.ProjectExpectingRefusalAsync(scenario); + + await Assert.That(exception.Message).Contains("patternProperties"); + await Assert.That(exception.Message).Contains("additionalProperties"); + } + + [Test] + public async Task Project_Should_Produce_FreeForm_Object_For_Bare_Object_Schema() + { + var host = new SchemaProjectionTestHost(); + var scenario = SpecScenario.Define(spec => spec.WithSchema("Metadata", schema => schema.Type("object"))); + + var result = await host.ProjectAsync(scenario); + + await Assert.That(result.Schemas["Metadata"]).IsTypeOf(); + } + + [Test] + public async Task Project_Should_Produce_Object_For_Empty_Properties_Object() + { + var host = new SchemaProjectionTestHost(); + var scenario = SpecScenario.Define(spec => spec.WithSchema("Empty", schema => schema.Type("object").EmptyProperties())); + + var result = await host.ProjectAsync(scenario); + + var node = (ObjectNode)result.Schemas["Empty"]; + await Assert.That(node.Properties).IsEmpty(); + await Assert.That(node.AdditionalProperties).IsEqualTo(AdditionalPropertiesKind.Open); + } + + [Test] + public async Task Project_Should_Report_Required_Location_When_Name_Has_No_Property() + { + var host = new SchemaProjectionTestHost(); + var scenario = SpecScenario.Define(spec => spec.WithSchema("Bad", schema => schema + .Type("object") + .EmptyProperties() + .Required("missing"))); + + var exception = await host.ProjectExpectingRefusalAsync(scenario); + + await Assert.That(exception.Errors).HasSingleItem(); + await Assert.That(exception.Errors.Single().Location).IsEqualTo("Bad/required"); + await Assert.That(exception.Errors.Single().Problem).Contains("missing"); + } + + [Test] + public async Task Project_Should_Treat_Explicit_True_AdditionalProperties_As_Open() + { + var host = new SchemaProjectionTestHost(); + var scenario = SpecScenario.Define(spec => spec.WithSchema("Open", schema => schema + .Type("object") + .Property("value", property => property.Type("string")) + .AdditionalPropertiesTrue())); + + var result = await host.ProjectAsync(scenario); + + var node = (ObjectNode)result.Schemas["Open"]; + await Assert.That(node.AdditionalProperties).IsEqualTo(AdditionalPropertiesKind.Open); + await Assert.That(node.AdditionalPropertiesSchema).IsNull(); + } +} diff --git a/tests/OpenCode.Sdk.Tools.Tests/Generator/Ingestion/SchemaProjectorTests.cs b/tests/OpenCode.Sdk.Tools.Tests/Generator/Ingestion/SchemaProjectorTests.cs index 7ba5e042..457f9af7 100644 --- a/tests/OpenCode.Sdk.Tools.Tests/Generator/Ingestion/SchemaProjectorTests.cs +++ b/tests/OpenCode.Sdk.Tools.Tests/Generator/Ingestion/SchemaProjectorTests.cs @@ -207,18 +207,6 @@ public async Task Project_Should_Refuse_When_Primitive_Has_OneOf_Constraint() await Assert.That(ex.Message).Contains("Bad"); } - [Test] - public async Task Project_Should_Refuse_When_Primitive_Has_Const_Constraint() - { - var host = new SchemaProjectionTestHost(); - var scenario = SpecScenario.Define(spec => spec.WithSchema("Bad", schema => schema.Type("string").Const("fixed"))); - - var ex = await host.ProjectExpectingRefusalAsync(scenario); - - await Assert.That(ex.Message).Contains("const"); - await Assert.That(ex.Message).Contains("Bad"); - } - [Test] public async Task Project_Should_Refuse_When_Enum_Has_Object_Constraint() { @@ -249,18 +237,6 @@ public async Task Project_Should_Refuse_When_Array_Has_Union_Constraint() await Assert.That(ex.Message).Contains("Bad"); } - [Test] - public async Task Project_Should_Refuse_When_Future_Shape_Has_No_Core_Type() - { - var host = new SchemaProjectionTestHost(); - var scenario = SpecScenario.Define(spec => spec.WithSchema("Bad", schema => schema.OneOf(branch => branch.Type("string")))); - - var ex = await host.ProjectExpectingRefusalAsync(scenario); - - await Assert.That(ex.Message).Contains("oneOf"); - await Assert.That(ex.Message).Contains("Bad"); - } - [Test] public async Task Project_Should_Batch_Errors_From_Multiple_Schemas() { @@ -287,7 +263,7 @@ public async Task Project_Should_Refuse_When_Promoted_Graph_Key_Collides() var loaded = await new SpecReader(context.FileSystem).LoadAsync(context.SpecPath, errors, CancellationToken.None); var keys = new GraphKeyBuilder(); var projector = new SchemaProjector(new SchemaWallPolicy(), keys); - var state = new ProjectionState(errors, new Dictionary(StringComparer.Ordinal)); + var state = new ProjectionState(errors, loaded.Document, new Dictionary(StringComparer.Ordinal)); _ = projector.Project(loaded.Document.Components!.Schemas!["First"], "Shared", "/value", state); _ = projector.Project(loaded.Document.Components.Schemas["Second"], "Shared", "/value", state); diff --git a/tests/OpenCode.Sdk.Tools.Tests/Generator/Ingestion/UnionNormalizerTests.cs b/tests/OpenCode.Sdk.Tools.Tests/Generator/Ingestion/UnionNormalizerTests.cs new file mode 100644 index 00000000..689e0dfe --- /dev/null +++ b/tests/OpenCode.Sdk.Tools.Tests/Generator/Ingestion/UnionNormalizerTests.cs @@ -0,0 +1,264 @@ +using OpenCode.Sdk.Tools.Generator.Ingestion.Models; +using OpenCode.Sdk.Tools.Tests.Support; + +namespace OpenCode.Sdk.Tools.Tests.Generator.Ingestion; + +public sealed class UnionNormalizerTests +{ + [Test] + public async Task Project_Should_Classify_Marked_AnyOf_Union_Of_References() + { + var host = new SchemaProjectionTestHost(); + var scenario = SpecScenario.Define(spec => spec + .WithSchema("Created", schema => schema + .Type("object") + .Property("type", property => property.Type("string").Enum("created"), required: true)) + .WithSchema("Deleted", schema => schema + .Type("object") + .Property("type", property => property.Type("string").Enum("deleted"), required: true)) + .WithSchema("Event", schema => schema.AnyOf(branch => branch.Ref("Created"), branch => branch.Ref("Deleted")))); + + var result = await host.ProjectAsync(scenario); + + var union = (UnionNode)result.Schemas["Event"]; + await Assert.That(union.Keyword).IsEqualTo(UnionKeyword.AnyOf); + await Assert.That(union.Classification).IsEqualTo(UnionClassification.Marked); + await Assert.That(union.Branches).Count().IsEqualTo(2); + await Assert.That(union.Children).Count().IsEqualTo(2); + } + + [Test] + public async Task Project_Should_Preserve_OneOf_Keyword() + { + var host = new SchemaProjectionTestHost(); + var scenario = SpecScenario.Define(spec => spec + .WithSchema("Started", schema => schema + .Type("object") + .Property("type", property => property.Type("string").Enum("started"), required: true)) + .WithSchema("Stopped", schema => schema + .Type("object") + .Property("type", property => property.Type("string").Enum("stopped"), required: true)) + .WithSchema("DurableEvent", schema => schema.OneOf(branch => branch.Ref("Started"), branch => branch.Ref("Stopped")))); + + var result = await host.ProjectAsync(scenario); + + var union = (UnionNode)result.Schemas["DurableEvent"]; + await Assert.That(union.Keyword).IsEqualTo(UnionKeyword.OneOf); + await Assert.That(union.Classification).IsEqualTo(UnionClassification.Marked); + } + + [Test] + public async Task Project_Should_Classify_Structural_Union_When_Branches_Carry_No_Markers() + { + var host = new SchemaProjectionTestHost(); + var scenario = SpecScenario.Define(spec => spec.WithSchema("Formatter", schema => schema.AnyOf( + branch => branch.Type("boolean"), + branch => branch.Type("object").AdditionalProperties(value => value.Type("string"))))); + + var result = await host.ProjectAsync(scenario); + + var union = (UnionNode)result.Schemas["Formatter"]; + await Assert.That(union.Classification).IsEqualTo(UnionClassification.Structural); + } + + [Test] + public async Task Project_Should_Promote_Marked_Inline_Union_Branches_With_Marker_Keys() + { + var host = new SchemaProjectionTestHost(); + var scenario = SpecScenario.Define(spec => spec.WithSchema("Evt", schema => schema.AnyOf( + branch => branch + .Type("object") + .Property("type", property => property.Type("string").Enum("created"), required: true), + branch => branch + .Type("object") + .Property("type", property => property.Type("string").Enum("deleted"), required: true)))); + + var result = await host.ProjectAsync(scenario); + + var union = (UnionNode)result.Schemas["Evt"]; + await Assert.That(((RefNode)union.Branches[0]).Target).IsEqualTo("Evt#/anyOf/type=created"); + await Assert.That(((RefNode)union.Branches[1]).Target).IsEqualTo("Evt#/anyOf/type=deleted"); + await Assert.That(result.Schemas["Evt#/anyOf/type=created"]).IsTypeOf(); + await Assert.That(result.Schemas["Evt#/anyOf/type=deleted"]).IsTypeOf(); + } + + [Test] + public async Task Project_Should_Use_Ordinal_Key_For_Unmarked_Inline_Union_Branch() + { + var host = new SchemaProjectionTestHost(); + var scenario = SpecScenario.Define(spec => spec.WithSchema("Formatter", schema => schema.AnyOf( + branch => branch.Type("object").Property("command", property => property.Type("string")), + branch => branch.Type("boolean")))); + + var result = await host.ProjectAsync(scenario); + + var union = (UnionNode)result.Schemas["Formatter"]; + await Assert.That(((RefNode)union.Branches[0]).Target).IsEqualTo("Formatter#/anyOf/0"); + await Assert.That(result.Schemas["Formatter#/anyOf/0"]).IsTypeOf(); + await Assert.That(union.Classification).IsEqualTo(UnionClassification.Structural); + } + + [Test] + public async Task Project_Should_Deduplicate_Repeated_Reference_Branches() + { + var host = new SchemaProjectionTestHost(); + var scenario = SpecScenario.Define(spec => spec + .WithSchema("A", schema => schema.Type("string")) + .WithSchema("B", schema => schema.Type("number")) + .WithSchema("Choice", schema => schema.AnyOf(branch => branch.Ref("A"), branch => branch.Ref("B"), branch => branch.Ref("B")))); + + var result = await host.ProjectAsync(scenario); + + var union = (UnionNode)result.Schemas["Choice"]; + await Assert.That(union.Branches).Count().IsEqualTo(2); + await Assert.That(((RefNode)union.Branches[0]).Target).IsEqualTo("A"); + await Assert.That(((RefNode)union.Branches[1]).Target).IsEqualTo("B"); + } + + [Test] + public async Task Project_Should_Collapse_Deduplicated_Single_Reference_Union() + { + var host = new SchemaProjectionTestHost(); + var scenario = SpecScenario.Define(spec => spec + .WithSchema("A", schema => schema.Type("string")) + .WithSchema("Choice", schema => schema.AnyOf(branch => branch.Ref("A"), branch => branch.Ref("A")))); + + var result = await host.ProjectAsync(scenario); + + await Assert.That(result.Schemas["Choice"]).IsTypeOf(); + await Assert.That(((RefNode)result.Schemas["Choice"]).Target).IsEqualTo("A"); + } + + [Test] + public async Task Project_Should_Extract_Null_Branch_Into_Nullable_Node() + { + var host = new SchemaProjectionTestHost(); + var scenario = SpecScenario.Define(spec => spec.WithSchema("MaybeName", schema => schema.AnyOf( + branch => branch.Type("string"), + branch => branch.Type("null")))); + + var result = await host.ProjectAsync(scenario); + + var nullable = (NullableNode)result.Schemas["MaybeName"]; + await Assert.That(nullable.Inner).IsTypeOf(); + await Assert.That(nullable.Children.Single()).IsSameReferenceAs(nullable.Inner); + } + + [Test] + public async Task Project_Should_Extract_Null_After_Deduplicating_References() + { + var host = new SchemaProjectionTestHost(); + var scenario = SpecScenario.Define(spec => spec + .WithSchema("A", schema => schema.Type("string")) + .WithSchema("MaybeA", schema => schema.AnyOf( + branch => branch.Ref("A"), + branch => branch.Ref("A"), + branch => branch.Type("null")))); + + var result = await host.ProjectAsync(scenario); + + var nullable = (NullableNode)result.Schemas["MaybeA"]; + await Assert.That(nullable.Inner).IsTypeOf(); + await Assert.That(((RefNode)nullable.Inner).Target).IsEqualTo("A"); + } + + [Test] + public async Task Project_Should_Normalize_Special_Number_Before_Projecting_Branches() + { + var host = new SchemaProjectionTestHost(); + var scenario = SpecScenario.Define(spec => spec.WithRawSchema("Workspace.timeUsed", "special-number.json")); + + var result = await host.ProjectAsync(scenario); + + await Assert.That(result.Schemas["Workspace.timeUsed"]).IsTypeOf(); + await Assert.That(result.Schemas).Count().IsEqualTo(1); + } + + [Test] + public async Task Project_Should_Normalize_Special_Number_With_Const_Literal_Branch() + { + var host = new SchemaProjectionTestHost(); + var scenario = SpecScenario.Define(spec => spec.WithSchema("Special", schema => schema.AnyOf( + branch => branch.Type("number"), + branch => branch.Type("string").Const("NaN")))); + + var result = await host.ProjectAsync(scenario); + + await Assert.That(result.Schemas["Special"]).IsTypeOf(); + } + + [Test] + public async Task Project_Should_Keep_Special_Number_Near_Miss_As_Ordinary_Union() + { + var host = new SchemaProjectionTestHost(); + var scenario = SpecScenario.Define(spec => spec.WithSchema("NearMiss", schema => schema.AnyOf( + branch => branch.Type("number"), + branch => branch.Type("string").Enum("NaN"), + branch => branch.Type("string").Enum("Infinity"), + branch => branch.Type("string").Enum("-Infinity"), + branch => branch.Type("string").Enum("Infinity", "-Infinity", "NaN"), + branch => branch.Type("boolean")))); + + var result = await host.ProjectAsync(scenario); + + var union = (UnionNode)result.Schemas["NearMiss"]; + await Assert.That(union.Classification).IsEqualTo(UnionClassification.Structural); + await Assert.That(union.Branches).Count().IsEqualTo(6); + } + + [Test] + public async Task Project_Should_Refuse_When_Special_Number_Branch_Has_Competing_Constraint() + { + var host = new SchemaProjectionTestHost(); + var scenario = SpecScenario.Define(spec => spec.WithSchema("Bad", schema => schema.AnyOf( + branch => branch.Type("number").Property("ignored", property => property.Type("string")), + branch => branch.Type("string").Enum("NaN")))); + + var exception = await host.ProjectExpectingRefusalAsync(scenario); + + await Assert.That(exception.Message).Contains("properties"); + await Assert.That(exception.Message).Contains("Bad/anyOf/0"); + } + + [Test] + public async Task Project_Should_Keep_Boolean_And_String_Enum_As_Union() + { + var host = new SchemaProjectionTestHost(); + var scenario = SpecScenario.Define(spec => spec.WithSchema("AutoUpdate", schema => schema.AnyOf( + branch => branch.Type("boolean"), + branch => branch.Type("string").Enum("notify", "never")))); + + var result = await host.ProjectAsync(scenario); + + var union = (UnionNode)result.Schemas["AutoUpdate"]; + await Assert.That(union.Classification).IsEqualTo(UnionClassification.Structural); + await Assert.That(union.Branches).Count().IsEqualTo(2); + } + + [Test] + public async Task Project_Should_Refuse_When_AnyOf_And_OneOf_Are_Combined() + { + var host = new SchemaProjectionTestHost(); + var scenario = SpecScenario.Define(spec => spec.WithSchema("Bad", schema => schema + .AnyOf(branch => branch.Type("string")) + .OneOf(branch => branch.Type("number")))); + + var exception = await host.ProjectExpectingRefusalAsync(scenario); + + await Assert.That(exception.Message).Contains("anyOf"); + await Assert.That(exception.Message).Contains("oneOf"); + await Assert.That(exception.Message).Contains("cannot be combined"); + } + + [Test] + public async Task Project_Should_Refuse_When_Null_Extraction_Leaves_No_Branch() + { + var host = new SchemaProjectionTestHost(); + var scenario = SpecScenario.Define(spec => spec.WithSchema("Bad", schema => schema.AnyOf(branch => branch.Type("null")))); + + var exception = await host.ProjectExpectingRefusalAsync(scenario); + + await Assert.That(exception.Message).Contains("zero"); + await Assert.That(exception.Message).Contains("Bad"); + } +} diff --git a/tests/OpenCode.Sdk.Tools.Tests/Support/SchemaBuilder.cs b/tests/OpenCode.Sdk.Tools.Tests/Support/SchemaBuilder.cs index 12878cc0..0d043683 100644 --- a/tests/OpenCode.Sdk.Tools.Tests/Support/SchemaBuilder.cs +++ b/tests/OpenCode.Sdk.Tools.Tests/Support/SchemaBuilder.cs @@ -82,6 +82,20 @@ public SchemaBuilder Enum(params string[] values) return this; } + public SchemaBuilder BooleanEnum(params bool[] values) + { + ArgumentNullException.ThrowIfNull(values); + + var enumValues = new JsonArray(); + foreach (var value in values) + { + enumValues.Add(value); + } + + _schema["enum"] = enumValues; + return this; + } + public SchemaBuilder Const(string value) { ArgumentNullException.ThrowIfNull(value); @@ -102,6 +116,18 @@ public SchemaBuilder AdditionalPropertiesFalse() return this; } + public SchemaBuilder AdditionalPropertiesTrue() + { + _schema["additionalProperties"] = true; + return this; + } + + public SchemaBuilder EmptyProperties() + { + _schema["properties"] = new JsonObject(); + return this; + } + public SchemaBuilder Ref(string target) { ArgumentException.ThrowIfNullOrWhiteSpace(target); @@ -137,6 +163,22 @@ public SchemaBuilder PrefixItems(params Action[] configure) return this; } + public SchemaBuilder MinItems(int count) + { + ArgumentOutOfRangeException.ThrowIfNegative(count); + + _schema["minItems"] = count; + return this; + } + + public SchemaBuilder MaxItems(int count) + { + ArgumentOutOfRangeException.ThrowIfNegative(count); + + _schema["maxItems"] = count; + return this; + } + public SchemaBuilder AdditionalProperties(Action configure) { ArgumentNullException.ThrowIfNull(configure); diff --git a/tests/OpenCode.Sdk.Tools.Tests/Support/SchemaProjectionTestHost.cs b/tests/OpenCode.Sdk.Tools.Tests/Support/SchemaProjectionTestHost.cs index 0bfe8121..5a3be4a5 100644 --- a/tests/OpenCode.Sdk.Tools.Tests/Support/SchemaProjectionTestHost.cs +++ b/tests/OpenCode.Sdk.Tools.Tests/Support/SchemaProjectionTestHost.cs @@ -37,7 +37,7 @@ private async Task ProjectCoreAsync(SpecScenario scenari var loaded = await new SpecReader(context.FileSystem).LoadAsync(context.SpecPath, errors, CancellationToken.None); var projector = new SchemaProjector(_wall, _keys); var rawPointerLookup = ImmutableDictionary.Empty; - var state = new ProjectionState(errors, rawPointerLookup); + var state = new ProjectionState(errors, loaded.Document, rawPointerLookup); if (loaded.Document.Components?.Schemas is not null) { diff --git a/tests/OpenCode.Sdk.Tools.Tests/Support/SpecDocumentBuilder.cs b/tests/OpenCode.Sdk.Tools.Tests/Support/SpecDocumentBuilder.cs index c0f60011..207147f6 100644 --- a/tests/OpenCode.Sdk.Tools.Tests/Support/SpecDocumentBuilder.cs +++ b/tests/OpenCode.Sdk.Tools.Tests/Support/SpecDocumentBuilder.cs @@ -63,6 +63,19 @@ public SpecDocumentBuilder WithSchema(string name, Action configu return this; } + public SpecDocumentBuilder WithConfigPluginTuple(Action configureTuple) + { + ArgumentNullException.ThrowIfNull(configureTuple); + + return WithSchema("Config", schema => schema + .Type("object") + .Property("plugin", plugin => plugin + .Type("array") + .Items(item => item.AnyOf( + branch => branch.Type("string"), + configureTuple)))); + } + public SpecDocumentBuilder WithOperation(string operationId, string method = "get", string path = "/api/x", Action? configure = null) { diff --git a/tools/OpenCode.Sdk.Tools/Generator/Ingestion/IngestionErrorCollector.cs b/tools/OpenCode.Sdk.Tools/Generator/Ingestion/IngestionErrorCollector.cs index bec5f97a..15180e06 100644 --- a/tools/OpenCode.Sdk.Tools/Generator/Ingestion/IngestionErrorCollector.cs +++ b/tools/OpenCode.Sdk.Tools/Generator/Ingestion/IngestionErrorCollector.cs @@ -8,6 +8,8 @@ internal sealed class IngestionErrorCollector public bool HasErrors => _errors.Count > 0; + public int Count => _errors.Count; + public void Add(string location, string problem) { ArgumentException.ThrowIfNullOrWhiteSpace(location); diff --git a/tools/OpenCode.Sdk.Tools/Generator/Ingestion/Models/AdditionalPropertiesKind.cs b/tools/OpenCode.Sdk.Tools/Generator/Ingestion/Models/AdditionalPropertiesKind.cs new file mode 100644 index 00000000..d96c3c1d --- /dev/null +++ b/tools/OpenCode.Sdk.Tools/Generator/Ingestion/Models/AdditionalPropertiesKind.cs @@ -0,0 +1,14 @@ +namespace OpenCode.Sdk.Tools.Generator.Ingestion.Models; + +/// Identifies how an object schema treats properties not declared by name. +public enum AdditionalPropertiesKind +{ + /// Additional properties are permitted without a schema constraint. + Open = 0, + + /// Additional properties are forbidden. + Forbidden = 1, + + /// Additional properties are constrained by a schema. + Schema = 2, +} diff --git a/tools/OpenCode.Sdk.Tools/Generator/Ingestion/Models/DictionaryNode.cs b/tools/OpenCode.Sdk.Tools/Generator/Ingestion/Models/DictionaryNode.cs new file mode 100644 index 00000000..43dee2c9 --- /dev/null +++ b/tools/OpenCode.Sdk.Tools/Generator/Ingestion/Models/DictionaryNode.cs @@ -0,0 +1,19 @@ +namespace OpenCode.Sdk.Tools.Generator.Ingestion.Models; + +/// Represents an object whose values share one schema. +public sealed record DictionaryNode : SchemaNode +{ + /// Gets the projected dictionary value schema. + public required SchemaNode Value + { + get; + init + { + ArgumentNullException.ThrowIfNull(value); + field = value; + } + } + + /// + public override IEnumerable Children => [Value]; +} diff --git a/tools/OpenCode.Sdk.Tools/Generator/Ingestion/Models/ErrorStyle.cs b/tools/OpenCode.Sdk.Tools/Generator/Ingestion/Models/ErrorStyle.cs new file mode 100644 index 00000000..b9ae1a3a --- /dev/null +++ b/tools/OpenCode.Sdk.Tools/Generator/Ingestion/Models/ErrorStyle.cs @@ -0,0 +1,14 @@ +namespace OpenCode.Sdk.Tools.Generator.Ingestion.Models; + +/// Identifies an object schema's recognized error payload convention. +public enum ErrorStyle +{ + /// The object is not a recognized error payload. + None = 0, + + /// The object uses a required literal _tag property. + EffectTag = 1, + + /// The object uses required literal name and required data properties. + NameData = 2, +} diff --git a/tools/OpenCode.Sdk.Tools/Generator/Ingestion/Models/FreeFormObjectNode.cs b/tools/OpenCode.Sdk.Tools/Generator/Ingestion/Models/FreeFormObjectNode.cs new file mode 100644 index 00000000..5a92180c --- /dev/null +++ b/tools/OpenCode.Sdk.Tools/Generator/Ingestion/Models/FreeFormObjectNode.cs @@ -0,0 +1,8 @@ +namespace OpenCode.Sdk.Tools.Generator.Ingestion.Models; + +/// Represents an object whose property names and values are unrestricted. +public sealed record FreeFormObjectNode : SchemaNode +{ + /// + public override IEnumerable Children => []; +} diff --git a/tools/OpenCode.Sdk.Tools/Generator/Ingestion/Models/JsonStringNode.cs b/tools/OpenCode.Sdk.Tools/Generator/Ingestion/Models/JsonStringNode.cs new file mode 100644 index 00000000..274f8c55 --- /dev/null +++ b/tools/OpenCode.Sdk.Tools/Generator/Ingestion/Models/JsonStringNode.cs @@ -0,0 +1,19 @@ +namespace OpenCode.Sdk.Tools.Generator.Ingestion.Models; + +/// Represents a string whose content is JSON constrained by another schema. +public sealed record JsonStringNode : SchemaNode +{ + /// Gets the schema for the JSON value encoded in the string. + public required SchemaNode Inner + { + get; + init + { + ArgumentNullException.ThrowIfNull(value); + field = value; + } + } + + /// + public override IEnumerable Children => [Inner]; +} diff --git a/tools/OpenCode.Sdk.Tools/Generator/Ingestion/Models/LiteralDialect.cs b/tools/OpenCode.Sdk.Tools/Generator/Ingestion/Models/LiteralDialect.cs new file mode 100644 index 00000000..d377b3b0 --- /dev/null +++ b/tools/OpenCode.Sdk.Tools/Generator/Ingestion/Models/LiteralDialect.cs @@ -0,0 +1,11 @@ +namespace OpenCode.Sdk.Tools.Generator.Ingestion.Models; + +/// Identifies the wire spelling used to express a literal schema. +public enum LiteralDialect +{ + /// The literal is expressed as an enum with exactly one value. + SingleValueEnum = 0, + + /// The literal is expressed with the JSON Schema const keyword. + Const = 1, +} diff --git a/tools/OpenCode.Sdk.Tools/Generator/Ingestion/Models/LiteralKind.cs b/tools/OpenCode.Sdk.Tools/Generator/Ingestion/Models/LiteralKind.cs new file mode 100644 index 00000000..35561f7a --- /dev/null +++ b/tools/OpenCode.Sdk.Tools/Generator/Ingestion/Models/LiteralKind.cs @@ -0,0 +1,11 @@ +namespace OpenCode.Sdk.Tools.Generator.Ingestion.Models; + +/// Identifies the JSON primitive kind carried by a literal schema. +public enum LiteralKind +{ + /// A JSON string literal. + String = 0, + + /// A JSON boolean literal. + Boolean = 1, +} diff --git a/tools/OpenCode.Sdk.Tools/Generator/Ingestion/Models/LiteralMarker.cs b/tools/OpenCode.Sdk.Tools/Generator/Ingestion/Models/LiteralMarker.cs new file mode 100644 index 00000000..0282d5a2 --- /dev/null +++ b/tools/OpenCode.Sdk.Tools/Generator/Ingestion/Models/LiteralMarker.cs @@ -0,0 +1,30 @@ +namespace OpenCode.Sdk.Tools.Generator.Ingestion.Models; + +/// Describes a required literal property that identifies an object variant. +public sealed record LiteralMarker +{ + /// Gets the opaque wire property name. + public required string PropertyName + { + get; + init + { + ArgumentNullException.ThrowIfNull(value); + field = value; + } + } + + /// Gets the JSON primitive kind carried by the marker. + public required LiteralKind Kind { get; init; } + + /// Gets the literal value in its deterministic textual form. + public required string Value + { + get; + init + { + ArgumentNullException.ThrowIfNull(value); + field = value; + } + } +} diff --git a/tools/OpenCode.Sdk.Tools/Generator/Ingestion/Models/LiteralNode.cs b/tools/OpenCode.Sdk.Tools/Generator/Ingestion/Models/LiteralNode.cs new file mode 100644 index 00000000..921ebc1b --- /dev/null +++ b/tools/OpenCode.Sdk.Tools/Generator/Ingestion/Models/LiteralNode.cs @@ -0,0 +1,25 @@ +namespace OpenCode.Sdk.Tools.Generator.Ingestion.Models; + +/// Represents a schema constrained to one string or boolean value. +public sealed record LiteralNode : SchemaNode +{ + /// Gets the JSON primitive kind carried by the literal. + public required LiteralKind Kind { get; init; } + + /// Gets the literal value in its deterministic textual form. + public required string Value + { + get; + init + { + ArgumentNullException.ThrowIfNull(value); + field = value; + } + } + + /// Gets the wire spelling used to express the literal. + public required LiteralDialect Dialect { get; init; } + + /// + public override IEnumerable Children => []; +} diff --git a/tools/OpenCode.Sdk.Tools/Generator/Ingestion/Models/NullableNode.cs b/tools/OpenCode.Sdk.Tools/Generator/Ingestion/Models/NullableNode.cs new file mode 100644 index 00000000..48cf07ea --- /dev/null +++ b/tools/OpenCode.Sdk.Tools/Generator/Ingestion/Models/NullableNode.cs @@ -0,0 +1,19 @@ +namespace OpenCode.Sdk.Tools.Generator.Ingestion.Models; + +/// Represents a projected schema that also admits JSON null. +public sealed record NullableNode : SchemaNode +{ + /// Gets the projected non-null schema. + public required SchemaNode Inner + { + get; + init + { + ArgumentNullException.ThrowIfNull(value); + field = value; + } + } + + /// + public override IEnumerable Children => [Inner]; +} diff --git a/tools/OpenCode.Sdk.Tools/Generator/Ingestion/Models/ObjectNode.cs b/tools/OpenCode.Sdk.Tools/Generator/Ingestion/Models/ObjectNode.cs new file mode 100644 index 00000000..885bd607 --- /dev/null +++ b/tools/OpenCode.Sdk.Tools/Generator/Ingestion/Models/ObjectNode.cs @@ -0,0 +1,41 @@ +namespace OpenCode.Sdk.Tools.Generator.Ingestion.Models; + +/// Represents a projected object with named properties. +public sealed record ObjectNode : SchemaNode +{ + /// Gets the named properties in document order. + public required IReadOnlyList Properties + { + get; + init + { + ArgumentNullException.ThrowIfNull(value); + field = Array.AsReadOnly([.. value]); + } + } = Array.AsReadOnly(Array.Empty()); + + /// Gets how properties not declared by name are treated. + public required AdditionalPropertiesKind AdditionalProperties { get; init; } + + /// Gets the additional-property schema when is . + public SchemaNode? AdditionalPropertiesSchema { get; init; } + + /// Gets required literal properties in document order. + public required IReadOnlyList LiteralMarkers + { + get; + init + { + ArgumentNullException.ThrowIfNull(value); + field = Array.AsReadOnly([.. value]); + } + } = Array.AsReadOnly(Array.Empty()); + + /// Gets the recognized error payload convention. + public required ErrorStyle ErrorStyle { get; init; } + + /// + public override IEnumerable Children => AdditionalPropertiesSchema is null + ? Properties.Select(static property => property.Schema) + : Properties.Select(static property => property.Schema).Append(AdditionalPropertiesSchema); +} diff --git a/tools/OpenCode.Sdk.Tools/Generator/Ingestion/Models/SpecProperty.cs b/tools/OpenCode.Sdk.Tools/Generator/Ingestion/Models/SpecProperty.cs new file mode 100644 index 00000000..32071b28 --- /dev/null +++ b/tools/OpenCode.Sdk.Tools/Generator/Ingestion/Models/SpecProperty.cs @@ -0,0 +1,30 @@ +namespace OpenCode.Sdk.Tools.Generator.Ingestion.Models; + +/// Describes one property of a projected object schema. +public sealed record SpecProperty +{ + /// Gets the opaque wire property name. + public required string Name + { + get; + init + { + ArgumentNullException.ThrowIfNull(value); + field = value; + } + } + + /// Gets the projected property schema. + public required SchemaNode Schema + { + get; + init + { + ArgumentNullException.ThrowIfNull(value); + field = value; + } + } + + /// Gets a value indicating whether the property is required on the wire. + public required bool IsRequired { get; init; } +} diff --git a/tools/OpenCode.Sdk.Tools/Generator/Ingestion/Models/SpecialNumberNode.cs b/tools/OpenCode.Sdk.Tools/Generator/Ingestion/Models/SpecialNumberNode.cs new file mode 100644 index 00000000..fe6ca16e --- /dev/null +++ b/tools/OpenCode.Sdk.Tools/Generator/Ingestion/Models/SpecialNumberNode.cs @@ -0,0 +1,8 @@ +namespace OpenCode.Sdk.Tools.Generator.Ingestion.Models; + +/// Represents a number schema that also admits JSON string spellings for non-finite values. +public sealed record SpecialNumberNode : SchemaNode +{ + /// + public override IEnumerable Children => []; +} diff --git a/tools/OpenCode.Sdk.Tools/Generator/Ingestion/Models/TupleNode.cs b/tools/OpenCode.Sdk.Tools/Generator/Ingestion/Models/TupleNode.cs new file mode 100644 index 00000000..3784c65b --- /dev/null +++ b/tools/OpenCode.Sdk.Tools/Generator/Ingestion/Models/TupleNode.cs @@ -0,0 +1,19 @@ +namespace OpenCode.Sdk.Tools.Generator.Ingestion.Models; + +/// Represents a fixed-position JSON array schema. +public sealed record TupleNode : SchemaNode +{ + /// Gets the projected item schemas in positional order. + public required IReadOnlyList Items + { + get; + init + { + ArgumentNullException.ThrowIfNull(value); + field = Array.AsReadOnly([.. value]); + } + } = Array.AsReadOnly(Array.Empty()); + + /// + public override IEnumerable Children => Items; +} diff --git a/tools/OpenCode.Sdk.Tools/Generator/Ingestion/Models/UnionClassification.cs b/tools/OpenCode.Sdk.Tools/Generator/Ingestion/Models/UnionClassification.cs new file mode 100644 index 00000000..291a7330 --- /dev/null +++ b/tools/OpenCode.Sdk.Tools/Generator/Ingestion/Models/UnionClassification.cs @@ -0,0 +1,11 @@ +namespace OpenCode.Sdk.Tools.Generator.Ingestion.Models; + +/// Identifies how union branches can be distinguished. +public enum UnionClassification +{ + /// Every branch is an object carrying a required literal marker. + Marked = 0, + + /// The branches must be distinguished structurally. + Structural = 1, +} diff --git a/tools/OpenCode.Sdk.Tools/Generator/Ingestion/Models/UnionKeyword.cs b/tools/OpenCode.Sdk.Tools/Generator/Ingestion/Models/UnionKeyword.cs new file mode 100644 index 00000000..447068af --- /dev/null +++ b/tools/OpenCode.Sdk.Tools/Generator/Ingestion/Models/UnionKeyword.cs @@ -0,0 +1,11 @@ +namespace OpenCode.Sdk.Tools.Generator.Ingestion.Models; + +/// Identifies the OpenAPI keyword that declared a projected union. +public enum UnionKeyword +{ + /// The union was declared with anyOf. + AnyOf = 0, + + /// The union was declared with oneOf. + OneOf = 1, +} diff --git a/tools/OpenCode.Sdk.Tools/Generator/Ingestion/Models/UnionNode.cs b/tools/OpenCode.Sdk.Tools/Generator/Ingestion/Models/UnionNode.cs new file mode 100644 index 00000000..fb0a3b09 --- /dev/null +++ b/tools/OpenCode.Sdk.Tools/Generator/Ingestion/Models/UnionNode.cs @@ -0,0 +1,25 @@ +namespace OpenCode.Sdk.Tools.Generator.Ingestion.Models; + +/// Represents a projected union of schema branches. +public sealed record UnionNode : SchemaNode +{ + /// Gets the normalized branches in document order. + public required IReadOnlyList Branches + { + get; + init + { + ArgumentNullException.ThrowIfNull(value); + field = Array.AsReadOnly([.. value]); + } + } = Array.AsReadOnly(Array.Empty()); + + /// Gets the OpenAPI keyword that declared the union. + public required UnionKeyword Keyword { get; init; } + + /// Gets how the union branches can be distinguished. + public required UnionClassification Classification { get; init; } + + /// + public override IEnumerable Children => Branches; +} diff --git a/tools/OpenCode.Sdk.Tools/Generator/Ingestion/Projection/CoreSchemaShape.cs b/tools/OpenCode.Sdk.Tools/Generator/Ingestion/Projection/CoreSchemaShape.cs index aef5bcbc..d9f43aa2 100644 --- a/tools/OpenCode.Sdk.Tools/Generator/Ingestion/Projection/CoreSchemaShape.cs +++ b/tools/OpenCode.Sdk.Tools/Generator/Ingestion/Projection/CoreSchemaShape.cs @@ -7,4 +7,10 @@ internal enum CoreSchemaShape Primitive = 2, Enum = 3, Array = 4, + Object = 5, + Literal = 6, + Union = 7, + Null = 8, + Tuple = 9, + JsonString = 10, } diff --git a/tools/OpenCode.Sdk.Tools/Generator/Ingestion/Projection/EnumSchemaProjector.cs b/tools/OpenCode.Sdk.Tools/Generator/Ingestion/Projection/EnumSchemaProjector.cs index 37538ba0..15bf310e 100644 --- a/tools/OpenCode.Sdk.Tools/Generator/Ingestion/Projection/EnumSchemaProjector.cs +++ b/tools/OpenCode.Sdk.Tools/Generator/Ingestion/Projection/EnumSchemaProjector.cs @@ -6,26 +6,15 @@ namespace OpenCode.Sdk.Tools.Generator.Ingestion.Projection; internal sealed class EnumSchemaProjector { - private readonly SchemaShapeClassifier _shapeClassifier; - - public EnumSchemaProjector(SchemaShapeClassifier shapeClassifier) - { - _shapeClassifier = shapeClassifier ?? throw new ArgumentNullException(nameof(shapeClassifier)); - } - public EnumNode? Project(OpenApiSchema schema, string location, IngestionErrorCollector errors) { ArgumentNullException.ThrowIfNull(schema); ArgumentException.ThrowIfNullOrWhiteSpace(location); ArgumentNullException.ThrowIfNull(errors); - if (_shapeClassifier.Classify(schema, location, errors) is not CoreSchemaShape.Enum || schema.Enum is not { Count: > 1 } values) + if (schema.Type is not JsonSchemaType.String || schema.Enum is not { Count: > 1 } values) { - if (schema.Enum is not { Count: > 1 }) - { - errors.Add(location, "only string enums with multiple values are supported by core schema projection"); - } - + errors.Add(location, "only string enums with multiple values are supported by core schema projection"); return null; } diff --git a/tools/OpenCode.Sdk.Tools/Generator/Ingestion/Projection/ErrorStyleClassifier.cs b/tools/OpenCode.Sdk.Tools/Generator/Ingestion/Projection/ErrorStyleClassifier.cs new file mode 100644 index 00000000..60dce1b7 --- /dev/null +++ b/tools/OpenCode.Sdk.Tools/Generator/Ingestion/Projection/ErrorStyleClassifier.cs @@ -0,0 +1,20 @@ +using OpenCode.Sdk.Tools.Generator.Ingestion.Models; + +namespace OpenCode.Sdk.Tools.Generator.Ingestion.Projection; + +internal sealed class ErrorStyleClassifier +{ + public ErrorStyle Classify(IReadOnlyList properties) + { + ArgumentNullException.ThrowIfNull(properties); + + if (properties.Any(static property => property is { Name: "_tag", IsRequired: true, Schema: LiteralNode })) + { + return ErrorStyle.EffectTag; + } + + var hasLiteralName = properties.Any(static property => property is { Name: "name", IsRequired: true, Schema: LiteralNode }); + var hasRequiredData = properties.Any(static property => property is { Name: "data", IsRequired: true }); + return hasLiteralName && hasRequiredData ? ErrorStyle.NameData : ErrorStyle.None; + } +} diff --git a/tools/OpenCode.Sdk.Tools/Generator/Ingestion/Projection/GraphKeyBuilder.cs b/tools/OpenCode.Sdk.Tools/Generator/Ingestion/Projection/GraphKeyBuilder.cs index 5c9ec6bb..d673f77f 100644 --- a/tools/OpenCode.Sdk.Tools/Generator/Ingestion/Projection/GraphKeyBuilder.cs +++ b/tools/OpenCode.Sdk.Tools/Generator/Ingestion/Projection/GraphKeyBuilder.cs @@ -1,3 +1,6 @@ +using System.Globalization; +using OpenCode.Sdk.Tools.Generator.Ingestion.Models; + namespace OpenCode.Sdk.Tools.Generator.Ingestion.Projection; internal sealed class GraphKeyBuilder @@ -19,4 +22,16 @@ public string Append(string parentPointer, string segment) return $"{parentPointer}/{escaped}"; } + + public string UnionBranch(string parentPointer, string keyword, int index, LiteralMarker? marker) + { + ArgumentNullException.ThrowIfNull(parentPointer); + ArgumentException.ThrowIfNullOrWhiteSpace(keyword); + ArgumentOutOfRangeException.ThrowIfNegative(index); + + var branchIdentity = marker is null + ? index.ToString(CultureInfo.InvariantCulture) + : $"{marker.PropertyName}={marker.Value}"; + return Append(Append(parentPointer, keyword), branchIdentity); + } } diff --git a/tools/OpenCode.Sdk.Tools/Generator/Ingestion/Projection/JsonStringSchemaProjector.cs b/tools/OpenCode.Sdk.Tools/Generator/Ingestion/Projection/JsonStringSchemaProjector.cs new file mode 100644 index 00000000..cc420aa6 --- /dev/null +++ b/tools/OpenCode.Sdk.Tools/Generator/Ingestion/Projection/JsonStringSchemaProjector.cs @@ -0,0 +1,47 @@ +using Microsoft.OpenApi; +using OpenCode.Sdk.Tools.Generator.Ingestion.Models; + +namespace OpenCode.Sdk.Tools.Generator.Ingestion.Projection; + +internal sealed class JsonStringSchemaProjector +{ + private readonly GraphKeyBuilder _keys; + private readonly SchemaProjector _schemaProjector; + + public JsonStringSchemaProjector(SchemaProjector schemaProjector, GraphKeyBuilder keys) + { + _schemaProjector = schemaProjector ?? throw new ArgumentNullException(nameof(schemaProjector)); + _keys = keys ?? throw new ArgumentNullException(nameof(keys)); + } + + public JsonStringNode? Project(OpenApiSchema schema, string root, string pointer, string location, ProjectionState state) + { + ArgumentNullException.ThrowIfNull(schema); + ArgumentException.ThrowIfNullOrWhiteSpace(root); + ArgumentNullException.ThrowIfNull(pointer); + ArgumentException.ThrowIfNullOrWhiteSpace(location); + ArgumentNullException.ThrowIfNull(state); + + if (!string.Equals(schema.ContentMediaType, "application/json", StringComparison.Ordinal)) + { + state.Errors.Add(string.Concat(root, _keys.Append(pointer, "contentMediaType")), "contentMediaType must be 'application/json'"); + return null; + } + + if (schema.ContentSchema is null) + { + state.Errors.Add(location, "JSON content string must declare contentSchema"); + return null; + } + + var inner = _schemaProjector.Project(schema.ContentSchema, root, _keys.Append(pointer, "contentSchema"), state); + return inner is null + ? null + : new JsonStringNode + { + Description = schema.Description, + Format = schema.Format, + Inner = inner, + }; + } +} diff --git a/tools/OpenCode.Sdk.Tools/Generator/Ingestion/Projection/LiteralClassifier.cs b/tools/OpenCode.Sdk.Tools/Generator/Ingestion/Projection/LiteralClassifier.cs new file mode 100644 index 00000000..11098345 --- /dev/null +++ b/tools/OpenCode.Sdk.Tools/Generator/Ingestion/Projection/LiteralClassifier.cs @@ -0,0 +1,157 @@ +using System.Text.Json.Nodes; +using Microsoft.OpenApi; +using OpenCode.Sdk.Tools.Generator.Ingestion.Models; + +namespace OpenCode.Sdk.Tools.Generator.Ingestion.Projection; + +internal sealed class LiteralClassifier +{ + public LiteralMarker? FindFirstMarker(IOpenApiSchema schema) + { + ArgumentNullException.ThrowIfNull(schema); + + var candidate = schema is OpenApiSchemaReference reference ? reference.Target : schema; + if (candidate is not OpenApiSchema { Properties: not null, Required: not null } objectSchema) + { + return null; + } + + foreach (var property in objectSchema.Properties) + { + if (!objectSchema.Required.Contains(property.Key) || property.Value is not OpenApiSchema propertySchema) + { + continue; + } + + var marker = CreateMarker(property.Key, propertySchema); + if (marker is not null) + { + return marker; + } + } + + return null; + } + + public LiteralNode? Project(OpenApiSchema schema, string location, IngestionErrorCollector errors) + { + ArgumentNullException.ThrowIfNull(schema); + ArgumentException.ThrowIfNullOrWhiteSpace(location); + ArgumentNullException.ThrowIfNull(errors); + + if (schema.Const is not null) + { + if (schema.Type is JsonSchemaType.String) + { + return new LiteralNode + { + Description = schema.Description, + Format = schema.Format, + Kind = LiteralKind.String, + Value = schema.Const, + Dialect = LiteralDialect.Const, + }; + } + + errors.Add(location, "const literals are supported only on string schemas"); + return null; + } + + if (schema.Enum is not { Count: 1 } values) + { + errors.Add(location, "boolean enums with multiple values are not supported"); + return null; + } + + if (schema.Type is JsonSchemaType.String) + { + return ProjectStringEnum(schema, values[0], location, errors); + } + + return schema.Type is JsonSchemaType.Boolean + ? ProjectBooleanEnum(schema, values[0], location, errors) + : RefuseUnsupportedEnum(location, errors); + } + + private static LiteralNode? ProjectStringEnum(OpenApiSchema schema, JsonNode? value, string location, IngestionErrorCollector errors) + { + if (value is JsonValue jsonValue && jsonValue.TryGetValue(out var text)) + { + return new LiteralNode + { + Description = schema.Description, + Format = schema.Format, + Kind = LiteralKind.String, + Value = text, + Dialect = LiteralDialect.SingleValueEnum, + }; + } + + errors.Add(location, "single-value string enum must contain a string"); + return null; + } + + private static LiteralNode? ProjectBooleanEnum(OpenApiSchema schema, JsonNode? value, string location, IngestionErrorCollector errors) + { + if (value is JsonValue jsonValue && jsonValue.TryGetValue(out var boolean)) + { + return new LiteralNode + { + Description = schema.Description, + Format = schema.Format, + Kind = LiteralKind.Boolean, + Value = boolean ? "true" : "false", + Dialect = LiteralDialect.SingleValueEnum, + }; + } + + errors.Add(location, "single-value boolean enum must contain a boolean"); + return null; + } + + private static LiteralNode? RefuseUnsupportedEnum(string location, IngestionErrorCollector errors) + { + errors.Add(location, "single-value enums are supported only on string or boolean schemas"); + return null; + } + + private static LiteralMarker? CreateMarker(string propertyName, OpenApiSchema schema) + { + if (schema is { Type: JsonSchemaType.String, Const: not null, Enum: null }) + { + return new LiteralMarker + { + PropertyName = propertyName, + Kind = LiteralKind.String, + Value = schema.Const, + }; + } + + if (schema is not { Enum: [JsonValue value], Const: null }) + { + return null; + } + + if (schema.Type is JsonSchemaType.String && value.TryGetValue(out var text)) + { + return new LiteralMarker + { + PropertyName = propertyName, + Kind = LiteralKind.String, + Value = text, + }; + } + + if (schema.Type is JsonSchemaType.Boolean && value.TryGetValue(out var boolean)) + { + return new LiteralMarker + { + PropertyName = propertyName, + Kind = LiteralKind.Boolean, + Value = boolean ? "true" : "false", + }; + } + + return null; + } +} diff --git a/tools/OpenCode.Sdk.Tools/Generator/Ingestion/Projection/ObjectSchemaProjector.cs b/tools/OpenCode.Sdk.Tools/Generator/Ingestion/Projection/ObjectSchemaProjector.cs new file mode 100644 index 00000000..bdc88fd2 --- /dev/null +++ b/tools/OpenCode.Sdk.Tools/Generator/Ingestion/Projection/ObjectSchemaProjector.cs @@ -0,0 +1,170 @@ +using Microsoft.OpenApi; +using OpenCode.Sdk.Tools.Generator.Ingestion.Models; + +namespace OpenCode.Sdk.Tools.Generator.Ingestion.Projection; + +internal sealed class ObjectSchemaProjector +{ + private readonly ErrorStyleClassifier _errorStyleClassifier; + private readonly GraphKeyBuilder _keys; + private readonly SchemaProjector _schemaProjector; + + public ObjectSchemaProjector(SchemaProjector schemaProjector, GraphKeyBuilder keys, ErrorStyleClassifier errorStyleClassifier) + { + _schemaProjector = schemaProjector ?? throw new ArgumentNullException(nameof(schemaProjector)); + _keys = keys ?? throw new ArgumentNullException(nameof(keys)); + _errorStyleClassifier = errorStyleClassifier ?? throw new ArgumentNullException(nameof(errorStyleClassifier)); + } + + public SchemaNode? Project(OpenApiSchema schema, string root, string pointer, string location, ProjectionState state) + { + ArgumentNullException.ThrowIfNull(schema); + ArgumentException.ThrowIfNullOrWhiteSpace(root); + ArgumentNullException.ThrowIfNull(pointer); + ArgumentException.ThrowIfNullOrWhiteSpace(location); + ArgumentNullException.ThrowIfNull(state); + + if (schema.PatternProperties is { Count: > 0 }) + { + return ProjectPatternDictionary(schema, root, pointer, location, state); + } + + if (schema.Properties is not null || !schema.AdditionalPropertiesAllowed) + { + return ProjectObject(schema, root, pointer, state); + } + + if (schema.AdditionalProperties is not null) + { + return ProjectDictionary(schema, schema.AdditionalProperties, root, _keys.Append(pointer, "additionalProperties"), state); + } + + return new FreeFormObjectNode + { + Description = schema.Description, + Format = schema.Format, + }; + } + + private DictionaryNode? ProjectPatternDictionary(OpenApiSchema schema, string root, string pointer, string location, ProjectionState state) + { + if (schema.PatternProperties is not { Count: 1 } patterns) + { + state.Errors.Add(location, "patternProperties must contain exactly one entry"); + return null; + } + + if (schema.Properties is not null) + { + state.Errors.Add(location, "patternProperties cannot be combined with properties"); + return null; + } + + if (schema.AdditionalProperties is not null || !schema.AdditionalPropertiesAllowed) + { + state.Errors.Add(location, "patternProperties cannot be combined with additionalProperties"); + return null; + } + + var pattern = patterns.Single(); + var valuePointer = _keys.Append(_keys.Append(pointer, "patternProperties"), pattern.Key); + return ProjectDictionary(schema, pattern.Value, root, valuePointer, state); + } + + private DictionaryNode? ProjectDictionary(OpenApiSchema schema, IOpenApiSchema valueSchema, string root, string valuePointer, + ProjectionState state) + { + var value = _schemaProjector.Project(valueSchema, root, valuePointer, state); + return value is null + ? null + : new DictionaryNode + { + Description = schema.Description, + Format = schema.Format, + Value = value, + }; + } + + private ObjectNode? ProjectObject(OpenApiSchema schema, string root, string pointer, ProjectionState state) + { + var properties = schema.Properties ?? new Dictionary(StringComparer.Ordinal); + var missingRequired = schema.Required?.Where(name => !properties.ContainsKey(name)).ToArray() ?? []; + if (missingRequired.Length > 0) + { + var requiredLocation = string.Concat(root, _keys.Append(pointer, "required")); + foreach (var missing in missingRequired) + { + state.Errors.Add(requiredLocation, $"required property '{missing}' is not declared in properties"); + } + + return null; + } + + var projectedProperties = new List(properties.Count); + foreach (var property in properties) + { + var projected = _schemaProjector.Project(property.Value, root, _keys.Append(_keys.Append(pointer, "properties"), property.Key), state); + if (projected is null) + { + continue; + } + + projectedProperties.Add(new SpecProperty + { + Name = property.Key, + Schema = projected, + IsRequired = schema.Required?.Contains(property.Key) is true, + }); + } + + if (projectedProperties.Count != properties.Count) + { + return null; + } + + SchemaNode? additionalPropertiesSchema = null; + if (schema.AdditionalProperties is not null) + { + additionalPropertiesSchema = _schemaProjector.Project(schema.AdditionalProperties, root, _keys.Append(pointer, "additionalProperties"), state); + if (additionalPropertiesSchema is null) + { + return null; + } + } + + var markers = projectedProperties + .Where(static property => property is { IsRequired: true, Schema: LiteralNode }) + .Select(static property => + { + var literal = (LiteralNode)property.Schema; + return new LiteralMarker + { + PropertyName = property.Name, + Kind = literal.Kind, + Value = literal.Value, + }; + }) + .ToArray(); + + var additionalProperties = AdditionalPropertiesKind.Open; + if (additionalPropertiesSchema is not null) + { + additionalProperties = AdditionalPropertiesKind.Schema; + } + else if (!schema.AdditionalPropertiesAllowed) + { + additionalProperties = AdditionalPropertiesKind.Forbidden; + } + + return new ObjectNode + { + Description = schema.Description, + Format = schema.Format, + Properties = projectedProperties, + AdditionalProperties = additionalProperties, + AdditionalPropertiesSchema = additionalPropertiesSchema, + LiteralMarkers = markers, + ErrorStyle = _errorStyleClassifier.Classify(projectedProperties), + }; + } +} diff --git a/tools/OpenCode.Sdk.Tools/Generator/Ingestion/Projection/PrefixItemsAdapter.cs b/tools/OpenCode.Sdk.Tools/Generator/Ingestion/Projection/PrefixItemsAdapter.cs new file mode 100644 index 00000000..24c11060 --- /dev/null +++ b/tools/OpenCode.Sdk.Tools/Generator/Ingestion/Projection/PrefixItemsAdapter.cs @@ -0,0 +1,105 @@ +using System.Text.Json.Nodes; +using Microsoft.OpenApi; +using Microsoft.OpenApi.Reader; +using OpenCode.Sdk.Tools.Generator.Ingestion.Models; + +namespace OpenCode.Sdk.Tools.Generator.Ingestion.Projection; + +internal sealed class PrefixItemsAdapter +{ + private readonly GraphKeyBuilder _keys; + private readonly SchemaProjector _schemaProjector; + + public PrefixItemsAdapter(SchemaProjector schemaProjector, GraphKeyBuilder keys) + { + _schemaProjector = schemaProjector ?? throw new ArgumentNullException(nameof(schemaProjector)); + _keys = keys ?? throw new ArgumentNullException(nameof(keys)); + } + + public TupleNode? Project(OpenApiSchema schema, string root, string pointer, string location, ProjectionState state) + { + ArgumentNullException.ThrowIfNull(schema); + ArgumentException.ThrowIfNullOrWhiteSpace(root); + ArgumentNullException.ThrowIfNull(pointer); + ArgumentException.ThrowIfNullOrWhiteSpace(location); + ArgumentNullException.ThrowIfNull(state); + + if (schema.UnrecognizedKeywords is null + || !schema.UnrecognizedKeywords.TryGetValue("prefixItems", out var rawPrefixItems) + || rawPrefixItems is not JsonArray prefixItems) + { + state.Errors.Add(location, "prefixItems was admitted but its raw array was unavailable"); + return null; + } + + if (!ValidateArity(schema, prefixItems.Count, root, pointer, state.Errors)) + { + return null; + } + + var items = new List(prefixItems.Count); + for (var index = 0; index < prefixItems.Count; index++) + { + var itemPointer = _keys.Append(_keys.Append(pointer, "prefixItems"), index.ToString(System.Globalization.CultureInfo.InvariantCulture)); + var itemLocation = string.Concat(root, itemPointer); + if (prefixItems[index] is not { } rawItem) + { + state.Errors.Add(itemLocation, "prefixItems entries cannot be null"); + continue; + } + + var parsed = OpenApiModelFactory.Parse(rawItem.ToJsonString(), OpenApiSpecVersion.OpenApi3_1, state.Document, + out var diagnostic, "json"); + foreach (var error in diagnostic.Errors) + { + state.Errors.Add(itemLocation, error.Message); + } + + if (diagnostic.Errors.Count > 0) + { + continue; + } + + if (parsed is null) + { + state.Errors.Add(itemLocation, "prefixItems entry did not produce a schema"); + continue; + } + + var projected = _schemaProjector.Project(parsed, root, itemPointer, state); + if (projected is not null) + { + items.Add(projected); + } + } + + return items.Count != prefixItems.Count + ? null + : new TupleNode + { + Description = schema.Description, + Format = schema.Format, + Items = items, + }; + } + + private bool ValidateArity(OpenApiSchema schema, int arity, string root, string pointer, IngestionErrorCollector errors) + { + var isValid = true; + var arityText = arity.ToString(System.Globalization.CultureInfo.InvariantCulture); + if (schema.MinItems != arity) + { + errors.Add(string.Concat(root, _keys.Append(pointer, "minItems")), $"minItems must equal tuple arity {arityText}"); + isValid = false; + } + + if (schema.MaxItems == arity) + { + return isValid; + } + + errors.Add(string.Concat(root, _keys.Append(pointer, "maxItems")), $"maxItems must equal tuple arity {arityText}"); + + return false; + } +} diff --git a/tools/OpenCode.Sdk.Tools/Generator/Ingestion/Projection/ProjectionState.cs b/tools/OpenCode.Sdk.Tools/Generator/Ingestion/Projection/ProjectionState.cs index 595736d9..fff9fa84 100644 --- a/tools/OpenCode.Sdk.Tools/Generator/Ingestion/Projection/ProjectionState.cs +++ b/tools/OpenCode.Sdk.Tools/Generator/Ingestion/Projection/ProjectionState.cs @@ -11,17 +11,21 @@ internal sealed class ProjectionState private readonly Dictionary _rawPointerLookup; private readonly HashSet _visited = new(ReferenceEqualityComparer.Instance); - public ProjectionState(IngestionErrorCollector errors, IReadOnlyDictionary rawPointerLookup) + public ProjectionState(IngestionErrorCollector errors, OpenApiDocument document, IReadOnlyDictionary rawPointerLookup) { ArgumentNullException.ThrowIfNull(errors); + ArgumentNullException.ThrowIfNull(document); ArgumentNullException.ThrowIfNull(rawPointerLookup); Errors = errors; + Document = document; _rawPointerLookup = rawPointerLookup.ToDictionary(pair => pair.Key, pair => pair.Value.DeepClone(), StringComparer.Ordinal); } public IngestionErrorCollector Errors { get; } + public OpenApiDocument Document { get; } + public JsonNode? FindRawSchema(string pointer) { ArgumentNullException.ThrowIfNull(pointer); @@ -42,7 +46,7 @@ public ProjectionState(IngestionErrorCollector errors, IReadOnlyDictionary 0 when schema is EnumNode: + case > 0 when schema is EnumNode or ObjectNode or UnionNode: var promotedKey = $"{root}#{pointer}"; if (_graph.TryAdd(promotedKey, schema)) diff --git a/tools/OpenCode.Sdk.Tools/Generator/Ingestion/Projection/SchemaProjector.cs b/tools/OpenCode.Sdk.Tools/Generator/Ingestion/Projection/SchemaProjector.cs index b7c6b4f2..171dbccc 100644 --- a/tools/OpenCode.Sdk.Tools/Generator/Ingestion/Projection/SchemaProjector.cs +++ b/tools/OpenCode.Sdk.Tools/Generator/Ingestion/Projection/SchemaProjector.cs @@ -8,6 +8,11 @@ internal sealed class SchemaProjector { private readonly GraphKeyBuilder _keys; private readonly EnumSchemaProjector _enumProjector; + private readonly JsonStringSchemaProjector _jsonStringProjector; + private readonly LiteralClassifier _literalClassifier; + private readonly ObjectSchemaProjector _objectProjector; + private readonly PrefixItemsAdapter _prefixItemsAdapter; + private readonly UnionNormalizer _unionNormalizer; private readonly SchemaShapeClassifier _shapeClassifier; private readonly SchemaWallPolicy _wall; @@ -16,7 +21,12 @@ public SchemaProjector(SchemaWallPolicy wall, GraphKeyBuilder keys) _wall = wall ?? throw new ArgumentNullException(nameof(wall)); _keys = keys ?? throw new ArgumentNullException(nameof(keys)); _shapeClassifier = new SchemaShapeClassifier(); - _enumProjector = new EnumSchemaProjector(_shapeClassifier); + _enumProjector = new EnumSchemaProjector(); + _literalClassifier = new LiteralClassifier(); + _jsonStringProjector = new JsonStringSchemaProjector(this, _keys); + _objectProjector = new ObjectSchemaProjector(this, _keys, new ErrorStyleClassifier()); + _unionNormalizer = new UnionNormalizer(this, _wall, _keys, _shapeClassifier, _literalClassifier); + _prefixItemsAdapter = new PrefixItemsAdapter(this, _keys); } public SchemaNode? Project(IOpenApiSchema schema, string root, string pointer, ProjectionState state) @@ -44,16 +54,22 @@ public SchemaProjector(SchemaWallPolicy wall, GraphKeyBuilder keys) return null; } + var errorCount = state.Errors.Count; _wall.Check(concrete, location, state.Errors); + if (state.Errors.Count != errorCount) + { + return null; + } + var projected = ProjectConcrete(concrete, root, pointer, location, state); return state.Register(projected, root, pointer, location); } private SchemaNode? ProjectConcrete(OpenApiSchema schema, string root, string pointer, string location, ProjectionState state) { - if (schema.Enum is { Count: > 0 }) + if (schema is { AnyOf.Count: > 0, OneOf.Count: > 0 }) { - return _enumProjector.Project(schema, location, state.Errors); + return _unionNormalizer.Project(schema, root, pointer, location, state); } return (_shapeClassifier.Classify(schema, location, state.Errors), schema.Type) switch @@ -63,6 +79,13 @@ public SchemaProjector(SchemaWallPolicy wall, GraphKeyBuilder keys) (CoreSchemaShape.Primitive, JsonSchemaType.Integer) => CreatePrimitive(schema, PrimitiveKind.Integer), (CoreSchemaShape.Primitive, JsonSchemaType.Boolean) => CreatePrimitive(schema, PrimitiveKind.Boolean), (CoreSchemaShape.Array, _) => ProjectArray(schema, root, pointer, location, state), + (CoreSchemaShape.Object, _) => _objectProjector.Project(schema, root, pointer, location, state), + (CoreSchemaShape.Enum, _) => _enumProjector.Project(schema, location, state.Errors), + (CoreSchemaShape.Literal, _) => _literalClassifier.Project(schema, location, state.Errors), + (CoreSchemaShape.Union, _) => _unionNormalizer.Project(schema, root, pointer, location, state), + (CoreSchemaShape.Null, _) => RefuseStandaloneNull(location, state), + (CoreSchemaShape.Tuple, _) => _prefixItemsAdapter.Project(schema, root, pointer, location, state), + (CoreSchemaShape.JsonString, _) => _jsonStringProjector.Project(schema, root, pointer, location, state), (CoreSchemaShape.Unrestricted, _) => CreateUnrestricted(schema), _ => null, }; @@ -116,4 +139,10 @@ private static UnrestrictedNode CreateUnrestricted(OpenApiSchema schema) => state.Errors.Add(location, $"reference target '{target ?? ""}' could not be resolved"); return null; } + + private static SchemaNode? RefuseStandaloneNull(string location, ProjectionState state) + { + state.Errors.Add(location, "null schemas are supported only as union branches"); + return null; + } } diff --git a/tools/OpenCode.Sdk.Tools/Generator/Ingestion/Projection/SchemaShapeClassifier.cs b/tools/OpenCode.Sdk.Tools/Generator/Ingestion/Projection/SchemaShapeClassifier.cs index c5669209..aea840cd 100644 --- a/tools/OpenCode.Sdk.Tools/Generator/Ingestion/Projection/SchemaShapeClassifier.cs +++ b/tools/OpenCode.Sdk.Tools/Generator/Ingestion/Projection/SchemaShapeClassifier.cs @@ -14,13 +14,14 @@ internal sealed class SchemaShapeClassifier new(Constraint.AllOf, "allOf", static schema => schema.AllOf is { Count: > 0 }), new(Constraint.Required, "required", static schema => schema.Required is { Count: > 0 }), new(Constraint.Items, "items", static schema => schema.Items is not null), - new(Constraint.Properties, "properties", static schema => schema.Properties is { Count: > 0 }), + new(Constraint.Properties, "properties", static schema => schema.Properties is not null), new(Constraint.PatternProperties, "patternProperties", static schema => schema.PatternProperties is { Count: > 0 }), new(Constraint.AdditionalPropertiesAllowed, "additionalProperties", static schema => !schema.AdditionalPropertiesAllowed), new(Constraint.AdditionalProperties, "additionalProperties", static schema => schema.AdditionalProperties is not null), new(Constraint.ContentEncoding, "contentEncoding", static schema => schema.ContentEncoding is not null), new(Constraint.ContentMediaType, "contentMediaType", static schema => schema.ContentMediaType is not null), new(Constraint.ContentSchema, "contentSchema", static schema => schema.ContentSchema is not null), + new(Constraint.PrefixItems, "prefixItems", static schema => schema.UnrecognizedKeywords?.ContainsKey("prefixItems") is true), ]; public CoreSchemaShape Classify(OpenApiSchema schema, string location, IngestionErrorCollector errors) @@ -36,9 +37,19 @@ public CoreSchemaShape Classify(OpenApiSchema schema, string location, Ingestion (Constraint.None, _) => CoreSchemaShape.Unrestricted, (Constraint.Type, JsonSchemaType.String or JsonSchemaType.Number or JsonSchemaType.Integer or JsonSchemaType.Boolean) => CoreSchemaShape .Primitive, + (Constraint.Type | Constraint.Enum, JsonSchemaType.String) when schema.Enum?.Count is 1 => CoreSchemaShape.Literal, (Constraint.Type | Constraint.Enum, JsonSchemaType.String) => CoreSchemaShape.Enum, + (Constraint.Type | Constraint.Enum, JsonSchemaType.Boolean) => CoreSchemaShape.Literal, + (Constraint.Type | Constraint.Const, _) => CoreSchemaShape.Literal, + (Constraint.AnyOf, _) => CoreSchemaShape.Union, + (Constraint.OneOf, _) => CoreSchemaShape.Union, + (Constraint.Type, JsonSchemaType.Null) => CoreSchemaShape.Null, + (Constraint.Type | Constraint.PrefixItems, JsonSchemaType.Array) => CoreSchemaShape.Tuple, + (Constraint.Type | Constraint.ContentSchema, JsonSchemaType.String) => CoreSchemaShape.JsonString, + (Constraint.Type | Constraint.ContentMediaType | Constraint.ContentSchema, JsonSchemaType.String) => CoreSchemaShape.JsonString, (Constraint.Type, JsonSchemaType.Array) => CoreSchemaShape.Array, (Constraint.Type | Constraint.Items, JsonSchemaType.Array) => CoreSchemaShape.Array, + _ when IsObjectShape(constraints, schema.Type) => CoreSchemaShape.Object, _ => CoreSchemaShape.Unsupported, }; @@ -53,6 +64,29 @@ public CoreSchemaShape Classify(OpenApiSchema schema, string location, Ingestion return shape; } + private static bool IsObjectShape(Constraint constraints, JsonSchemaType? type) + { + const Constraint objectConstraints = Constraint.Type | Constraint.Required | Constraint.Properties | Constraint.PatternProperties + | Constraint.AdditionalPropertiesAllowed | Constraint.AdditionalProperties; + if ((constraints & ~objectConstraints) is not Constraint.None || type is not (null or JsonSchemaType.Object)) + { + return false; + } + + var objectMembers = constraints & ~Constraint.Type; + if (type is JsonSchemaType.Object && objectMembers is Constraint.None) + { + return true; + } + + if (constraints.HasFlag(Constraint.Required) && !constraints.HasFlag(Constraint.Properties)) + { + return false; + } + + return objectMembers is not Constraint.None; + } + [Flags] private enum Constraint { @@ -72,6 +106,7 @@ private enum Constraint ContentEncoding = 1 << 12, ContentMediaType = 1 << 13, ContentSchema = 1 << 14, + PrefixItems = 1 << 15, } private sealed record ConstraintRule(Constraint Constraint, string WireName, Func IsPopulated); diff --git a/tools/OpenCode.Sdk.Tools/Generator/Ingestion/Projection/UnionNormalizer.cs b/tools/OpenCode.Sdk.Tools/Generator/Ingestion/Projection/UnionNormalizer.cs new file mode 100644 index 00000000..6fd14dfe --- /dev/null +++ b/tools/OpenCode.Sdk.Tools/Generator/Ingestion/Projection/UnionNormalizer.cs @@ -0,0 +1,266 @@ +using System.Text.Json.Nodes; +using Microsoft.OpenApi; +using OpenCode.Sdk.Tools.Generator.Ingestion.Models; +using OpenCode.Sdk.Tools.Generator.Ingestion.Walls; + +namespace OpenCode.Sdk.Tools.Generator.Ingestion.Projection; + +internal sealed class UnionNormalizer +{ + private static readonly HashSet SpecialNumberValues = new(StringComparer.Ordinal) + { + "NaN", + "Infinity", + "-Infinity", + }; + + private readonly GraphKeyBuilder _keys; + private readonly LiteralClassifier _literalClassifier; + private readonly SchemaProjector _schemaProjector; + private readonly SchemaShapeClassifier _shapeClassifier; + private readonly SchemaWallPolicy _wall; + + public UnionNormalizer(SchemaProjector schemaProjector, SchemaWallPolicy wall, GraphKeyBuilder keys, SchemaShapeClassifier shapeClassifier, + LiteralClassifier literalClassifier) + { + _schemaProjector = schemaProjector ?? throw new ArgumentNullException(nameof(schemaProjector)); + _wall = wall ?? throw new ArgumentNullException(nameof(wall)); + _keys = keys ?? throw new ArgumentNullException(nameof(keys)); + _shapeClassifier = shapeClassifier ?? throw new ArgumentNullException(nameof(shapeClassifier)); + _literalClassifier = literalClassifier ?? throw new ArgumentNullException(nameof(literalClassifier)); + } + + public SchemaNode? Project(OpenApiSchema schema, string root, string pointer, string location, ProjectionState state) + { + ArgumentNullException.ThrowIfNull(schema); + ArgumentException.ThrowIfNullOrWhiteSpace(root); + ArgumentNullException.ThrowIfNull(pointer); + ArgumentException.ThrowIfNullOrWhiteSpace(location); + ArgumentNullException.ThrowIfNull(state); + + if (schema is { AnyOf.Count: > 0, OneOf.Count: > 0 }) + { + state.Errors.Add(location, "anyOf and oneOf cannot be combined on one schema"); + return null; + } + + var keyword = schema.OneOf is { Count: > 0 } ? UnionKeyword.OneOf : UnionKeyword.AnyOf; + var keywordName = keyword is UnionKeyword.OneOf ? "oneOf" : "anyOf"; + var sourceBranches = keyword is UnionKeyword.OneOf ? schema.OneOf : schema.AnyOf; + if (sourceBranches is null) + { + state.Errors.Add(location, $"{keywordName} must contain at least one branch"); + return null; + } + + if (IsSpecialNumber(sourceBranches)) + { + if (!ValidateSpecialNumberBranches(sourceBranches, root, pointer, keywordName, state)) + { + return null; + } + + return new SpecialNumberNode + { + Description = schema.Description, + Format = schema.Format, + }; + } + + var branches = ProjectBranches(sourceBranches, root, pointer, keywordName, state); + if (branches is null) + { + return null; + } + + var deduplicated = DeduplicateReferences(branches); + var hasNull = deduplicated.Any(static branch => branch.IsNull); + var nonNullBranches = deduplicated.Where(static branch => !branch.IsNull).ToArray(); + if (nonNullBranches.Length is 0) + { + state.Errors.Add(location, "union normalization produced zero non-null branches"); + return null; + } + + var inner = CreateInner(nonNullBranches, keyword); + if (hasNull) + { + return new NullableNode + { + Description = schema.Description, + Format = schema.Format, + Inner = inner, + }; + } + + if (nonNullBranches.Length is 1) + { + return inner with + { + Description = schema.Description ?? inner.Description, + Format = schema.Format ?? inner.Format, + }; + } + + return inner with + { + Description = schema.Description, + Format = schema.Format, + }; + } + + private bool ValidateSpecialNumberBranches(IList branches, string root, string pointer, string keyword, ProjectionState state) + { + var errorCount = state.Errors.Count; + for (var index = 0; index < branches.Count; index++) + { + if (branches[index] is not OpenApiSchema branch) + { + continue; + } + + var branchPointer = _keys.UnionBranch(pointer, keyword, index, marker: null); + _wall.Check(branch, string.Concat(root, branchPointer), state.Errors); + } + + return state.Errors.Count == errorCount; + } + + private ProjectedBranch[]? ProjectBranches(IList sourceBranches, string root, string pointer, string keyword, + ProjectionState state) + { + var branches = new List(sourceBranches.Count); + var failed = false; + for (var index = 0; index < sourceBranches.Count; index++) + { + var source = sourceBranches[index]; + var marker = _literalClassifier.FindFirstMarker(source); + var branchPointer = _keys.UnionBranch(pointer, keyword, index, marker); + var branchLocation = string.Concat(root, branchPointer); + if (source is OpenApiSchema { Type: JsonSchemaType.Null } concrete) + { + var errorCount = state.Errors.Count; + _wall.Check(concrete, branchLocation, state.Errors); + var shape = _shapeClassifier.Classify(concrete, branchLocation, state.Errors); + if (state.Errors.Count == errorCount && shape is CoreSchemaShape.Null) + { + branches.Add(new ProjectedBranch(Node: null, marker, IsNull: true)); + } + else + { + failed = true; + } + + continue; + } + + var projected = _schemaProjector.Project(source, root, branchPointer, state); + if (projected is null) + { + failed = true; + continue; + } + + branches.Add(new ProjectedBranch(projected, marker, IsNull: false)); + } + + return failed ? null : [.. branches]; + } + + private static ProjectedBranch[] DeduplicateReferences(IReadOnlyList branches) + { + var targets = new HashSet(StringComparer.Ordinal); + return + [ + .. branches.Where(branch => branch.Node is not RefNode reference || targets.Add(reference.Target)), + ]; + } + + private static SchemaNode CreateInner(IReadOnlyList branches, UnionKeyword keyword) + { + if (branches.Count is 1) + { + return branches[0].Node ?? throw new InvalidOperationException("A non-null union branch had no projected node."); + } + + var classification = branches.All(static branch => branch.Marker is not null) + ? UnionClassification.Marked + : UnionClassification.Structural; + return new UnionNode + { + Branches = + [ + .. branches.Select(static branch => + branch.Node ?? throw new InvalidOperationException("A non-null union branch had no projected node.")), + ], + Keyword = keyword, + Classification = classification, + }; + } + + private static bool IsSpecialNumber(IEnumerable branches) + { + var numberBranches = 0; + var specialBranches = 0; + foreach (var branch in branches) + { + if (branch is not OpenApiSchema schema) + { + return false; + } + + if (schema is { Type: JsonSchemaType.Number, Enum: null, Const: null } && !HasCompetingNodeConstraints(schema)) + { + numberBranches++; + continue; + } + + if (!IsSpecialStringBranch(schema)) + { + return false; + } + + specialBranches++; + } + + return numberBranches is 1 && specialBranches > 0; + } + + private static bool IsSpecialStringBranch(OpenApiSchema schema) + { + if (schema.Type is not JsonSchemaType.String || HasCompetingNodeConstraints(schema)) + { + return false; + } + + if (schema is { Const: not null, Enum: null }) + { + return SpecialNumberValues.Contains(schema.Const); + } + + if (schema is not { Const: null, Enum.Count: > 0 } || schema.Enum is not { } values) + { + return false; + } + + return values.All(static value => value is JsonValue jsonValue + && jsonValue.TryGetValue(out var text) + && SpecialNumberValues.Contains(text)); + } + + private static bool HasCompetingNodeConstraints(OpenApiSchema schema) => + schema.AnyOf is { Count: > 0 } + || schema.OneOf is { Count: > 0 } + || schema.AllOf is { Count: > 0 } + || schema.Required is { Count: > 0 } + || schema.Items is not null + || schema.Properties is not null + || schema.PatternProperties is { Count: > 0 } + || !schema.AdditionalPropertiesAllowed + || schema.AdditionalProperties is not null + || schema.ContentEncoding is not null + || schema.ContentMediaType is not null + || schema.ContentSchema is not null; + + private sealed record ProjectedBranch(SchemaNode? Node, LiteralMarker? Marker, bool IsNull); +} From 19349300e5133c6e629a23c60b7a1898dac80ff4 Mon Sep 17 00:00:00 2001 From: "denizirgin@gmail.com" Date: Tue, 11 Aug 2026 15:48:49 +0300 Subject: [PATCH 12/19] feat(tools): per-host walls and operation projection --- .../Fixtures/effect-stream.json | 11 + .../Fixtures/envelope-cursor-data.json | 15 ++ .../Fixtures/envelope-data-has-more.json | 15 ++ .../Fixtures/envelope-data-location.json | 15 ++ .../Fixtures/envelope-data.json | 11 + .../Fixtures/path-level-parameters.json | 22 ++ .../Ingestion/EnvelopeClassifierTests.cs | 133 ++++++++++ .../Ingestion/OperationProjectorTests.cs | 237 +++++++++++++++++ .../Generator/Ingestion/SpecMediaTypeTests.cs | 28 ++ .../Ingestion/Walls/HostWallPolicyTests.cs | 243 ++++++++++++++++++ .../Support/OperationBuilder.cs | 188 ++++++++------ .../Support/OperationParameterBuilder.cs | 86 +++++++ .../Support/OperationProjectionResult.cs | 24 ++ .../Support/OperationProjectionTestHost.cs | 58 +++++ .../Support/OperationResponseBuilder.cs | 187 ++++++++++++++ .../Support/SpecDocumentBuilder.cs | 57 +++- .../Ingestion/Models/SpecEnvelopeShape.cs | 23 ++ .../Ingestion/Models/SpecMediaType.cs | 64 +++++ .../Ingestion/Models/SpecOperation.cs | 106 ++++++++ .../Ingestion/Models/SpecParameter.cs | 36 +++ .../Ingestion/Models/SpecParameterLocation.cs | 11 + .../Ingestion/Models/SpecRequestBody.cs | 30 +++ .../Ingestion/Models/SpecResponse.cs | 26 ++ .../Generator/Ingestion/Models/SpecSurface.cs | 11 + .../Projection/EnvelopeClassifier.cs | 75 ++++++ .../Projection/MediaTypeProjection.cs | 5 + .../Projection/MediaTypeProjector.cs | 32 +++ .../Ingestion/Projection/OperationIdentity.cs | 5 + .../Projection/OperationIdentityParser.cs | 36 +++ .../Projection/OperationProjectionContext.cs | 42 +++ .../Projection/OperationProjector.cs | 140 ++++++++++ .../Projection/ParameterProjector.cs | 93 +++++++ .../Projection/PathTemplateValidator.cs | 75 ++++++ .../Projection/RequestBodyProjector.cs | 65 +++++ .../Ingestion/Projection/ResponseProjector.cs | 125 +++++++++ .../Ingestion/Walls/DocumentWallPolicy.cs | 33 +++ .../Ingestion/Walls/HostMemberWhitelist.cs | 77 ++++++ .../Ingestion/Walls/MediaTypeWallPolicy.cs | 59 +++++ .../Ingestion/Walls/OperationWallPolicy.cs | 56 ++++ .../Ingestion/Walls/ParameterWallPolicy.cs | 63 +++++ .../Ingestion/Walls/PathItemWallPolicy.cs | 67 +++++ .../Ingestion/Walls/RequestBodyWallPolicy.cs | 34 +++ .../Ingestion/Walls/ResponseWallPolicy.cs | 34 +++ 43 files changed, 2679 insertions(+), 74 deletions(-) create mode 100644 tests/OpenCode.Sdk.Tools.Tests/Fixtures/effect-stream.json create mode 100644 tests/OpenCode.Sdk.Tools.Tests/Fixtures/envelope-cursor-data.json create mode 100644 tests/OpenCode.Sdk.Tools.Tests/Fixtures/envelope-data-has-more.json create mode 100644 tests/OpenCode.Sdk.Tools.Tests/Fixtures/envelope-data-location.json create mode 100644 tests/OpenCode.Sdk.Tools.Tests/Fixtures/envelope-data.json create mode 100644 tests/OpenCode.Sdk.Tools.Tests/Fixtures/path-level-parameters.json create mode 100644 tests/OpenCode.Sdk.Tools.Tests/Generator/Ingestion/EnvelopeClassifierTests.cs create mode 100644 tests/OpenCode.Sdk.Tools.Tests/Generator/Ingestion/OperationProjectorTests.cs create mode 100644 tests/OpenCode.Sdk.Tools.Tests/Generator/Ingestion/SpecMediaTypeTests.cs create mode 100644 tests/OpenCode.Sdk.Tools.Tests/Generator/Ingestion/Walls/HostWallPolicyTests.cs create mode 100644 tests/OpenCode.Sdk.Tools.Tests/Support/OperationParameterBuilder.cs create mode 100644 tests/OpenCode.Sdk.Tools.Tests/Support/OperationProjectionResult.cs create mode 100644 tests/OpenCode.Sdk.Tools.Tests/Support/OperationProjectionTestHost.cs create mode 100644 tests/OpenCode.Sdk.Tools.Tests/Support/OperationResponseBuilder.cs create mode 100644 tools/OpenCode.Sdk.Tools/Generator/Ingestion/Models/SpecEnvelopeShape.cs create mode 100644 tools/OpenCode.Sdk.Tools/Generator/Ingestion/Models/SpecMediaType.cs create mode 100644 tools/OpenCode.Sdk.Tools/Generator/Ingestion/Models/SpecOperation.cs create mode 100644 tools/OpenCode.Sdk.Tools/Generator/Ingestion/Models/SpecParameter.cs create mode 100644 tools/OpenCode.Sdk.Tools/Generator/Ingestion/Models/SpecParameterLocation.cs create mode 100644 tools/OpenCode.Sdk.Tools/Generator/Ingestion/Models/SpecRequestBody.cs create mode 100644 tools/OpenCode.Sdk.Tools/Generator/Ingestion/Models/SpecResponse.cs create mode 100644 tools/OpenCode.Sdk.Tools/Generator/Ingestion/Models/SpecSurface.cs create mode 100644 tools/OpenCode.Sdk.Tools/Generator/Ingestion/Projection/EnvelopeClassifier.cs create mode 100644 tools/OpenCode.Sdk.Tools/Generator/Ingestion/Projection/MediaTypeProjection.cs create mode 100644 tools/OpenCode.Sdk.Tools/Generator/Ingestion/Projection/MediaTypeProjector.cs create mode 100644 tools/OpenCode.Sdk.Tools/Generator/Ingestion/Projection/OperationIdentity.cs create mode 100644 tools/OpenCode.Sdk.Tools/Generator/Ingestion/Projection/OperationIdentityParser.cs create mode 100644 tools/OpenCode.Sdk.Tools/Generator/Ingestion/Projection/OperationProjectionContext.cs create mode 100644 tools/OpenCode.Sdk.Tools/Generator/Ingestion/Projection/OperationProjector.cs create mode 100644 tools/OpenCode.Sdk.Tools/Generator/Ingestion/Projection/ParameterProjector.cs create mode 100644 tools/OpenCode.Sdk.Tools/Generator/Ingestion/Projection/PathTemplateValidator.cs create mode 100644 tools/OpenCode.Sdk.Tools/Generator/Ingestion/Projection/RequestBodyProjector.cs create mode 100644 tools/OpenCode.Sdk.Tools/Generator/Ingestion/Projection/ResponseProjector.cs create mode 100644 tools/OpenCode.Sdk.Tools/Generator/Ingestion/Walls/DocumentWallPolicy.cs create mode 100644 tools/OpenCode.Sdk.Tools/Generator/Ingestion/Walls/HostMemberWhitelist.cs create mode 100644 tools/OpenCode.Sdk.Tools/Generator/Ingestion/Walls/MediaTypeWallPolicy.cs create mode 100644 tools/OpenCode.Sdk.Tools/Generator/Ingestion/Walls/OperationWallPolicy.cs create mode 100644 tools/OpenCode.Sdk.Tools/Generator/Ingestion/Walls/ParameterWallPolicy.cs create mode 100644 tools/OpenCode.Sdk.Tools/Generator/Ingestion/Walls/PathItemWallPolicy.cs create mode 100644 tools/OpenCode.Sdk.Tools/Generator/Ingestion/Walls/RequestBodyWallPolicy.cs create mode 100644 tools/OpenCode.Sdk.Tools/Generator/Ingestion/Walls/ResponseWallPolicy.cs diff --git a/tests/OpenCode.Sdk.Tools.Tests/Fixtures/effect-stream.json b/tests/OpenCode.Sdk.Tools.Tests/Fixtures/effect-stream.json new file mode 100644 index 00000000..968e7ab7 --- /dev/null +++ b/tests/OpenCode.Sdk.Tools.Tests/Fixtures/effect-stream.json @@ -0,0 +1,11 @@ +{ + "events": [ + { + "type": "session.updated", + "payload": { + "type": "string" + } + } + ], + "nullable": true +} diff --git a/tests/OpenCode.Sdk.Tools.Tests/Fixtures/envelope-cursor-data.json b/tests/OpenCode.Sdk.Tools.Tests/Fixtures/envelope-cursor-data.json new file mode 100644 index 00000000..d9797d9a --- /dev/null +++ b/tests/OpenCode.Sdk.Tools.Tests/Fixtures/envelope-cursor-data.json @@ -0,0 +1,15 @@ +{ + "type": "object", + "properties": { + "cursor": { + "type": "string" + }, + "data": { + "type": "string" + } + }, + "required": [ + "cursor", + "data" + ] +} diff --git a/tests/OpenCode.Sdk.Tools.Tests/Fixtures/envelope-data-has-more.json b/tests/OpenCode.Sdk.Tools.Tests/Fixtures/envelope-data-has-more.json new file mode 100644 index 00000000..7b87c9e2 --- /dev/null +++ b/tests/OpenCode.Sdk.Tools.Tests/Fixtures/envelope-data-has-more.json @@ -0,0 +1,15 @@ +{ + "type": "object", + "properties": { + "data": { + "type": "string" + }, + "hasMore": { + "type": "boolean" + } + }, + "required": [ + "data", + "hasMore" + ] +} diff --git a/tests/OpenCode.Sdk.Tools.Tests/Fixtures/envelope-data-location.json b/tests/OpenCode.Sdk.Tools.Tests/Fixtures/envelope-data-location.json new file mode 100644 index 00000000..addb92a9 --- /dev/null +++ b/tests/OpenCode.Sdk.Tools.Tests/Fixtures/envelope-data-location.json @@ -0,0 +1,15 @@ +{ + "type": "object", + "properties": { + "data": { + "type": "string" + }, + "location": { + "type": "string" + } + }, + "required": [ + "data", + "location" + ] +} diff --git a/tests/OpenCode.Sdk.Tools.Tests/Fixtures/envelope-data.json b/tests/OpenCode.Sdk.Tools.Tests/Fixtures/envelope-data.json new file mode 100644 index 00000000..a50cec3e --- /dev/null +++ b/tests/OpenCode.Sdk.Tools.Tests/Fixtures/envelope-data.json @@ -0,0 +1,11 @@ +{ + "type": "object", + "properties": { + "data": { + "type": "string" + } + }, + "required": [ + "data" + ] +} diff --git a/tests/OpenCode.Sdk.Tools.Tests/Fixtures/path-level-parameters.json b/tests/OpenCode.Sdk.Tools.Tests/Fixtures/path-level-parameters.json new file mode 100644 index 00000000..dc32f5a0 --- /dev/null +++ b/tests/OpenCode.Sdk.Tools.Tests/Fixtures/path-level-parameters.json @@ -0,0 +1,22 @@ +{ + "/api/items/{id}": { + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "get": { + "operationId": "item.get", + "responses": { + "200": { + "description": "Response" + } + } + } + } +} diff --git a/tests/OpenCode.Sdk.Tools.Tests/Generator/Ingestion/EnvelopeClassifierTests.cs b/tests/OpenCode.Sdk.Tools.Tests/Generator/Ingestion/EnvelopeClassifierTests.cs new file mode 100644 index 00000000..212946a6 --- /dev/null +++ b/tests/OpenCode.Sdk.Tools.Tests/Generator/Ingestion/EnvelopeClassifierTests.cs @@ -0,0 +1,133 @@ +using System.Text.Json.Nodes; +using OpenCode.Sdk.Tools.Generator.Ingestion.Models; +using OpenCode.Sdk.Tools.Tests.Support; + +namespace OpenCode.Sdk.Tools.Tests.Generator.Ingestion; + +public sealed class EnvelopeClassifierTests +{ + [Test] + public async Task Classify_Should_Recognize_Data_Envelope_Through_Named_Reference() + { + var host = new OperationProjectionTestHost(); + var scenario = SpecScenario.Define(spec => spec + .WithRawSchema("DataEnvelope", "envelope-data.json") + .WithOperation("v2.test.data", configure: operation => operation + .Response(200, "application/json", schema => schema.Ref("DataEnvelope")))); + + var result = await host.ProjectAsync(scenario); + + await Assert.That(result.Operations[0].Responses[0].EnvelopeShape).IsEqualTo(SpecEnvelopeShape.Data); + } + + [Test] + public async Task Classify_Should_Recognize_DataLocation_Envelope_Through_Named_Reference() + { + var host = new OperationProjectionTestHost(); + var scenario = SpecScenario.Define(spec => spec + .WithRawSchema("LocatedEnvelope", "envelope-data-location.json") + .WithOperation("v2.test.location", configure: operation => operation + .Response(200, "application/json", schema => schema.Ref("LocatedEnvelope")))); + + var result = await host.ProjectAsync(scenario); + + await Assert.That(result.Operations[0].Responses[0].EnvelopeShape).IsEqualTo(SpecEnvelopeShape.DataLocation); + } + + [Test] + public async Task Classify_Should_Recognize_CursorData_Envelope_From_Inline_Fixture() + { + var host = new OperationProjectionTestHost(); + var scenario = SpecScenario.Define(spec => spec.WithOperation("v2.test.cursor", configure: operation => operation + .ResponseFromFixture(200, "application/json", "envelope-cursor-data.json"))); + + var result = await host.ProjectAsync(scenario); + + await Assert.That(result.Operations[0].Responses[0].EnvelopeShape).IsEqualTo(SpecEnvelopeShape.CursorData); + } + + [Test] + public async Task Classify_Should_Recognize_DataHasMore_Envelope_From_Inline_Fixture() + { + var host = new OperationProjectionTestHost(); + var scenario = SpecScenario.Define(spec => spec.WithOperation("v2.test.more", configure: operation => operation + .ResponseFromFixture(200, "application/json", "envelope-data-has-more.json"))); + + var result = await host.ProjectAsync(scenario); + + await Assert.That(result.Operations[0].Responses[0].EnvelopeShape).IsEqualTo(SpecEnvelopeShape.DataHasMore); + } + + [Test] + public async Task Classify_Should_Return_Bare_For_Unrecognized_Json_Shape() + { + var host = new OperationProjectionTestHost(); + var scenario = SpecScenario.Define(spec => spec.WithOperation("v2.test.bare", configure: operation => operation + .Response(200, "application/json", schema => schema.Type("string")))); + + var result = await host.ProjectAsync(scenario); + + await Assert.That(result.Operations[0].Responses[0].EnvelopeShape).IsEqualTo(SpecEnvelopeShape.Bare); + } + + [Test] + public async Task Classify_Should_Return_None_For_Response_Without_Content() + { + var host = new OperationProjectionTestHost(); + var scenario = SpecScenario.Define(spec => spec.WithOperation("v2.test.none", configure: operation => operation.Response(204))); + + var result = await host.ProjectAsync(scenario); + + await Assert.That(result.Operations[0].Responses[0].EnvelopeShape).IsEqualTo(SpecEnvelopeShape.None); + } + + [Test] + public async Task Classify_Should_Not_Recognize_Envelope_Superset() + { + var host = new OperationProjectionTestHost(); + var scenario = SpecScenario.Define(spec => spec.WithOperation("v2.test.superset", configure: operation => operation + .Response(200, "application/json", schema => schema + .Type("object") + .Property("data", property => property.Type("string")) + .Property("extra", property => property.Type("string"))))); + + var result = await host.ProjectAsync(scenario); + + await Assert.That(result.Operations[0].Responses[0].EnvelopeShape).IsEqualTo(SpecEnvelopeShape.Bare); + } + + [Test] + public async Task Classify_Should_Refuse_Reference_Cycle() + { + var host = new OperationProjectionTestHost(); + var scenario = SpecScenario.Define(spec => spec + .WithSchema("First", schema => schema.Ref("Second")) + .WithSchema("Second", schema => schema.Ref("First")) + .WithOperation("v2.test.cycle", configure: operation => operation + .Response(200, "application/json", schema => schema.Ref("First")))); + + var ex = await host.ProjectExpectingRefusalAsync(scenario); + + await Assert.That(ex.Message).Contains("reference cycle"); + await Assert.That(ex.Message).Contains("responses/200"); + } + + [Test] + public async Task Classify_Should_Detect_Sse_And_Preserve_Effect_Stream_Json() + { + var host = new OperationProjectionTestHost(); + var scenario = SpecScenario.Define(spec => spec.WithOperation("v2.session.events", configure: operation => operation + .SseResponseFromFixture(schema => schema.Type("string"), "effect-stream.json"))); + + var result = await host.ProjectAsync(scenario); + + var operation = result.Operations[0]; + var response = operation.Responses[0]; + var expected = JsonNode.Parse(new FixtureLoader().Load("effect-stream.json")); + var actual = JsonNode.Parse(response.EffectStreamJson!); + await Assert.That(operation.IsSse).IsTrue(); + await Assert.That(response.IsSse).IsTrue(); + await Assert.That(response.ContentType!.IsEventStream).IsTrue(); + await Assert.That(JsonNode.DeepEquals(actual, expected)).IsTrue(); + } +} diff --git a/tests/OpenCode.Sdk.Tools.Tests/Generator/Ingestion/OperationProjectorTests.cs b/tests/OpenCode.Sdk.Tools.Tests/Generator/Ingestion/OperationProjectorTests.cs new file mode 100644 index 00000000..f63d0672 --- /dev/null +++ b/tests/OpenCode.Sdk.Tools.Tests/Generator/Ingestion/OperationProjectorTests.cs @@ -0,0 +1,237 @@ +using OpenCode.Sdk.Tools.Generator.Ingestion.Models; +using OpenCode.Sdk.Tools.Tests.Support; + +namespace OpenCode.Sdk.Tools.Tests.Generator.Ingestion; + +public sealed class OperationProjectorTests +{ + [Test] + public async Task Project_Should_Preserve_Order_And_Derive_Surface_And_Segments() + { + var host = new OperationProjectionTestHost(); + var scenario = SpecScenario.Define(spec => spec + .WithOperation("v2.session.list", path: "/api/session") + .WithOperation("session.list", path: "/api/v2/session")); + + var result = await host.ProjectAsync(scenario); + + await Assert.That(result.Operations).Count().IsEqualTo(2); + await Assert.That(result.Operations[0].OperationId).IsEqualTo("v2.session.list"); + await Assert.That(result.Operations[0].Surface).IsEqualTo(SpecSurface.Modern); + await Assert.That(result.Operations[0].Segments[0]).IsEqualTo("session"); + await Assert.That(result.Operations[0].Segments[1]).IsEqualTo("list"); + await Assert.That(result.Operations[0].RawContentHash).IsEqualTo(string.Empty); + await Assert.That(result.Operations[1].OperationId).IsEqualTo("session.list"); + await Assert.That(result.Operations[1].Surface).IsEqualTo(SpecSurface.Legacy); + await Assert.That(result.Operations[1].Segments[0]).IsEqualTo("session"); + await Assert.That(result.Operations[1].Segments[1]).IsEqualTo("list"); + } + + [Test] + public async Task Project_Should_Preserve_Deep_Operation_Segments() + { + var host = new OperationProjectionTestHost(); + var scenario = SpecScenario.Define(spec => spec.WithOperation("v2.session.revert.stage")); + + var result = await host.ProjectAsync(scenario); + + await Assert.That(result.Operations[0].Segments).Count().IsEqualTo(3); + await Assert.That(result.Operations[0].Segments[0]).IsEqualTo("session"); + await Assert.That(result.Operations[0].Segments[1]).IsEqualTo("revert"); + await Assert.That(result.Operations[0].Segments[2]).IsEqualTo("stage"); + } + + [Test] + public async Task Project_Should_Record_Trailing_Wildcard_Path() + { + var host = new OperationProjectionTestHost(); + var scenario = SpecScenario.Define(spec => spec.WithOperation("v2.fs.read", path: "/api/fs/read/*")); + + var result = await host.ProjectAsync(scenario); + + await Assert.That(result.Operations[0].HasWildcardPath).IsTrue(); + await Assert.That(result.Operations[0].Path).IsEqualTo("/api/fs/read/*"); + } + + [Test] + public async Task Project_Should_Refuse_Non_Trailing_Wildcard_Path() + { + var host = new OperationProjectionTestHost(); + var scenario = SpecScenario.Define(spec => spec.WithOperation("v2.fs.read", path: "/api/*/read")); + + var ex = await host.ProjectExpectingRefusalAsync(scenario); + + await Assert.That(ex.Message).Contains("wildcard"); + await Assert.That(ex.Message).Contains("/api/*/read"); + } + + [Test] + public async Task Project_Should_Record_WebSocket_Flag() + { + var host = new OperationProjectionTestHost(); + var scenario = SpecScenario.Define(spec => spec.WithOperation("v2.pty.connect", configure: operation => + operation.Extension("x-websocket", "true"))); + + var result = await host.ProjectAsync(scenario); + + await Assert.That(result.Operations[0].IsWebSocket).IsTrue(); + } + + [Test] + public async Task Project_Should_Record_Deprecation_And_Documentation() + { + var host = new OperationProjectionTestHost(); + var scenario = SpecScenario.Define(spec => spec.WithOperation("session.old", configure: operation => operation + .Deprecated() + .Summary("Old operation") + .Description("Use the replacement."))); + + var result = await host.ProjectAsync(scenario); + + await Assert.That(result.Operations[0].IsDeprecated).IsTrue(); + await Assert.That(result.Operations[0].Summary).IsEqualTo("Old operation"); + await Assert.That(result.Operations[0].Description).IsEqualTo("Use the replacement."); + } + + [Test] + public async Task Project_Should_Ignore_Known_Operation_Metadata() + { + var host = new OperationProjectionTestHost(); + var scenario = SpecScenario.Define(spec => spec.WithOperation("v2.session.list", configure: operation => operation + .WithIgnoredTagsAndSecurity() + .Extension("x-codeSamples", "[]"))); + + var result = await host.ProjectAsync(scenario); + + await Assert.That(result.Operations).Count().IsEqualTo(1); + } + + [Test] + public async Task Project_Should_Refuse_Duplicate_OperationId() + { + var host = new OperationProjectionTestHost(); + var scenario = SpecScenario.Define(spec => spec + .WithOperation("v2.session.get", path: "/api/session/one") + .WithOperation("v2.session.get", path: "/api/session/two")); + + var ex = await host.ProjectExpectingRefusalAsync(scenario); + + await Assert.That(ex.Message).Contains("duplicate operationId"); + await Assert.That(ex.Message).Contains("v2.session.get"); + } + + [Test] + [Arguments("get")] + [Arguments("post")] + [Arguments("put")] + [Arguments("patch")] + [Arguments("delete")] + public async Task Project_Should_Admit_Supported_Methods(string method) + { + var host = new OperationProjectionTestHost(); + var scenario = SpecScenario.Define(spec => spec.WithOperation("v2.test.method", method)); + + var result = await host.ProjectAsync(scenario); + + await Assert.That(result.Operations[0].Method).IsEqualTo(method); + } + + [Test] + public async Task Project_Should_Project_DeepObject_Parameter() + { + var host = new OperationProjectionTestHost(); + var scenario = SpecScenario.Define(spec => spec.WithOperation("v2.search.list", configure: operation => operation + .Parameter("filter", "query", schema => schema.Type("object").EmptyProperties(), deepObject: true))); + + var result = await host.ProjectAsync(scenario); + + await Assert.That(result.Operations[0].Parameters[0].IsDeepObject).IsTrue(); + await Assert.That(result.Operations[0].Parameters[0].Location).IsEqualTo(SpecParameterLocation.Query); + } + + [Test] + public async Task Project_Should_Preserve_Parameter_Order_And_Bracketed_Name() + { + var host = new OperationProjectionTestHost(); + var scenario = SpecScenario.Define(spec => spec.WithOperation("v2.search.list", configure: operation => operation + .Parameter("query", "query", schema => schema.Type("string")) + .Parameter("filter[status]", "query", schema => schema.Type("string")))); + + var result = await host.ProjectAsync(scenario); + + await Assert.That(result.Operations[0].Parameters).Count().IsEqualTo(2); + await Assert.That(result.Operations[0].Parameters[0].Name).IsEqualTo("query"); + await Assert.That(result.Operations[0].Parameters[1].Name).IsEqualTo("filter[status]"); + } + + [Test] + public async Task Project_Should_Project_Boolean_Enum_Parameter_As_Union() + { + var host = new OperationProjectionTestHost(); + var scenario = SpecScenario.Define(spec => spec.WithOperation("v2.search.list", configure: operation => operation + .Parameter("enabled", "query", schema => schema.AnyOf( + branch => branch.Type("boolean"), + branch => branch.Type("boolean").BooleanEnum(true))))); + + var result = await host.ProjectAsync(scenario); + + var reference = (RefNode)result.Operations[0].Parameters[0].Schema; + await Assert.That(result.Schemas[reference.Target]).IsTypeOf(); + } + + [Test] + public async Task Project_Should_Project_Required_Request_Body() + { + var host = new OperationProjectionTestHost(); + var scenario = SpecScenario.Define(spec => spec.WithOperation("v2.session.create", method: "post", configure: operation => operation + .RequestBody("application/json", schema => schema.Type("string"), required: true))); + + var result = await host.ProjectAsync(scenario); + + var requestBody = result.Operations[0].RequestBody; + await Assert.That(requestBody).IsNotNull(); + await Assert.That(requestBody!.ContentType.Stripped).IsEqualTo("application/json"); + await Assert.That(requestBody.IsRequired).IsTrue(); + await Assert.That(requestBody.Schema).IsTypeOf(); + } + + [Test] + public async Task Project_Should_Refuse_Undeclared_Path_Token() + { + var host = new OperationProjectionTestHost(); + var scenario = SpecScenario.Define(spec => spec.WithOperation("v2.session.get", path: "/api/session/{sessionID}")); + + var ex = await host.ProjectExpectingRefusalAsync(scenario); + + await Assert.That(ex.Message).Contains("sessionID"); + await Assert.That(ex.Message).Contains("not declared"); + } + + [Test] + public async Task Project_Should_Refuse_Path_Parameter_Absent_From_Template() + { + var host = new OperationProjectionTestHost(); + var scenario = SpecScenario.Define(spec => spec.WithOperation("v2.session.get", path: "/api/session", configure: operation => operation + .Parameter("sessionID", "path", schema => schema.Type("string"), required: true))); + + var ex = await host.ProjectExpectingRefusalAsync(scenario); + + await Assert.That(ex.Message).Contains("sessionID"); + await Assert.That(ex.Message).Contains("path"); + } + + [Test] + public async Task Project_Should_Sort_Responses_By_Status_Code() + { + var host = new OperationProjectionTestHost(); + var scenario = SpecScenario.Define(spec => spec.WithOperation("v2.session.get", configure: operation => operation + .Response(404) + .Response(200))); + + var result = await host.ProjectAsync(scenario); + + await Assert.That(result.Operations[0].Responses).Count().IsEqualTo(2); + await Assert.That(result.Operations[0].Responses[0].StatusCode).IsEqualTo(200); + await Assert.That(result.Operations[0].Responses[1].StatusCode).IsEqualTo(404); + } +} diff --git a/tests/OpenCode.Sdk.Tools.Tests/Generator/Ingestion/SpecMediaTypeTests.cs b/tests/OpenCode.Sdk.Tools.Tests/Generator/Ingestion/SpecMediaTypeTests.cs new file mode 100644 index 00000000..0dfe324c --- /dev/null +++ b/tests/OpenCode.Sdk.Tools.Tests/Generator/Ingestion/SpecMediaTypeTests.cs @@ -0,0 +1,28 @@ +using OpenCode.Sdk.Tools.Generator.Ingestion.Models; + +namespace OpenCode.Sdk.Tools.Tests.Generator.Ingestion; + +public sealed class SpecMediaTypeTests +{ + [Test] + [Arguments("application/json; charset=utf-8", "application/json", true, false)] + [Arguments("APPLICATION/PROBLEM+JSON", "application/problem+json", true, false)] + [Arguments("Text/Event-Stream", "text/event-stream", false, true)] + public async Task Create_Should_Normalize_And_Classify_Media_Types(string raw, string stripped, bool isJson, bool isEventStream) + { + var mediaType = SpecMediaType.Create(raw); + + await Assert.That(mediaType.Raw).IsEqualTo(raw); + await Assert.That(mediaType.Stripped).IsEqualTo(stripped); + await Assert.That(mediaType.IsJson).IsEqualTo(isJson); + await Assert.That(mediaType.IsEventStream).IsEqualTo(isEventStream); + } + + [Test] + public async Task Create_Should_Refuse_Malformed_Media_Type() + { + var action = () => SpecMediaType.Create("application-json"); + + await Assert.That(action).Throws(); + } +} diff --git a/tests/OpenCode.Sdk.Tools.Tests/Generator/Ingestion/Walls/HostWallPolicyTests.cs b/tests/OpenCode.Sdk.Tools.Tests/Generator/Ingestion/Walls/HostWallPolicyTests.cs new file mode 100644 index 00000000..1cb0d5af --- /dev/null +++ b/tests/OpenCode.Sdk.Tools.Tests/Generator/Ingestion/Walls/HostWallPolicyTests.cs @@ -0,0 +1,243 @@ +using OpenCode.Sdk.Tools.Tests.Support; + +namespace OpenCode.Sdk.Tools.Tests.Generator.Ingestion.Walls; + +public sealed class HostWallPolicyTests +{ + [Test] + public async Task Project_Should_Refuse_Path_Level_Parameters() + { + var host = new OperationProjectionTestHost(); + var scenario = SpecScenario.Define(spec => spec.WithRawTopLevelFromFixture("paths", "path-level-parameters.json")); + + var ex = await host.ProjectExpectingRefusalAsync(scenario); + + await Assert.That(ex.Message).Contains("path-level parameters"); + await Assert.That(ex.Message).Contains("/api/items/{id}"); + } + + [Test] + public async Task Project_Should_Refuse_Unsupported_Method() + { + var host = new OperationProjectionTestHost(); + var scenario = SpecScenario.Define(spec => spec.WithOperation("v2.test.head", method: "head")); + + var ex = await host.ProjectExpectingRefusalAsync(scenario); + + await Assert.That(ex.Message).Contains("method 'head'"); + } + + [Test] + public async Task Project_Should_Refuse_Document_Webhooks() + { + var host = new OperationProjectionTestHost(); + var scenario = SpecScenario.Define(spec => spec.WithWebhook()); + + var ex = await host.ProjectExpectingRefusalAsync(scenario); + + await Assert.That(ex.Message).Contains("document"); + await Assert.That(ex.Message).Contains("webhooks"); + } + + [Test] + public async Task Project_Should_Refuse_Non_Schema_Component() + { + var host = new OperationProjectionTestHost(); + var scenario = SpecScenario.Define(spec => spec.WithNonSchemaComponent()); + + var ex = await host.ProjectExpectingRefusalAsync(scenario); + + await Assert.That(ex.Message).Contains("components"); + await Assert.That(ex.Message).Contains("parameters"); + } + + [Test] + public async Task Project_Should_Refuse_Path_Level_Servers() + { + var host = new OperationProjectionTestHost(); + var scenario = SpecScenario.Define(spec => spec.WithPathServer("v2.test.server")); + + var ex = await host.ProjectExpectingRefusalAsync(scenario); + + await Assert.That(ex.Message).Contains("path-level servers"); + } + + [Test] + public async Task Project_Should_Refuse_Response_Headers() + { + var host = new OperationProjectionTestHost(); + var scenario = SpecScenario.Define(spec => spec.WithOperation("v2.test.headers", configure: operation => operation.ResponseWithHeader(200))); + + var ex = await host.ProjectExpectingRefusalAsync(scenario); + + await Assert.That(ex.Message).Contains("headers"); + await Assert.That(ex.Message).Contains("responses/200"); + } + + [Test] + public async Task Project_Should_Refuse_Media_ItemSchema_With_Location() + { + var host = new OperationProjectionTestHost(); + var scenario = SpecScenario.Define(spec => spec.WithOperation("v2.test.item", configure: operation => operation.ResponseWithItemSchema(200))); + + var ex = await host.ProjectExpectingRefusalAsync(scenario); + + await Assert.That(ex.Message).Contains("itemSchema"); + await Assert.That(ex.Message).Contains("paths"); + } + + [Test] + public async Task Project_Should_Refuse_Media_Encoding() + { + var host = new OperationProjectionTestHost(); + var scenario = + SpecScenario.Define(spec => spec.WithOperation("v2.test.encoding", configure: operation => operation.ResponseWithEncoding(200))); + + var ex = await host.ProjectExpectingRefusalAsync(scenario); + + await Assert.That(ex.Message).Contains("encoding"); + await Assert.That(ex.Message).Contains("responses/200/content/application~1json"); + } + + [Test] + public async Task Project_Should_Refuse_Header_Parameter() + { + var host = new OperationProjectionTestHost(); + var scenario = SpecScenario.Define(spec => spec.WithOperation("v2.test.header", configure: operation => operation + .Parameter("x-test", "header", schema => schema.Type("string")))); + + var ex = await host.ProjectExpectingRefusalAsync(scenario); + + await Assert.That(ex.Message).Contains("header"); + await Assert.That(ex.Message).Contains("parameters/0"); + } + + [Test] + public async Task Project_Should_Refuse_Form_Style_Parameter() + { + var host = new OperationProjectionTestHost(); + var scenario = SpecScenario.Define(spec => spec.WithOperation("v2.test.form", configure: operation => operation + .ParameterWithStyle("filter", "form", explode: true))); + + var ex = await host.ProjectExpectingRefusalAsync(scenario); + + await Assert.That(ex.Message).Contains("style"); + await Assert.That(ex.Message).Contains("form"); + } + + [Test] + public async Task Project_Should_Refuse_Content_Based_Parameter() + { + var host = new OperationProjectionTestHost(); + var scenario = + SpecScenario.Define(spec => spec.WithOperation("v2.test.content", configure: operation => operation.ContentParameter("filter"))); + + var ex = await host.ProjectExpectingRefusalAsync(scenario); + + await Assert.That(ex.Message).Contains("content"); + await Assert.That(ex.Message).Contains("parameters/0"); + } + + [Test] + public async Task Project_Should_Refuse_Duplicate_Parameter_Identity() + { + var host = new OperationProjectionTestHost(); + var scenario = SpecScenario.Define(spec => spec.WithOperation("v2.test.duplicate", configure: operation => operation + .Parameter("filter", "query", schema => schema.Type("string")) + .Parameter("filter", "query", schema => schema.Type("string")))); + + var ex = await host.ProjectExpectingRefusalAsync(scenario); + + await Assert.That(ex.Message).Contains("duplicate parameter"); + await Assert.That(ex.Message).Contains("filter"); + } + + [Test] + public async Task Project_Should_Refuse_Request_Body_With_Multiple_Media_Types() + { + var host = new OperationProjectionTestHost(); + var scenario = SpecScenario.Define(spec => spec.WithOperation("v2.test.body", method: "post", configure: operation => operation + .RequestBodyMediaTypes("application/json", "text/plain"))); + + var ex = await host.ProjectExpectingRefusalAsync(scenario); + + await Assert.That(ex.Message).Contains("request body"); + await Assert.That(ex.Message).Contains("exactly one media type"); + } + + [Test] + public async Task Project_Should_Refuse_Response_With_Multiple_Media_Types() + { + var host = new OperationProjectionTestHost(); + var scenario = SpecScenario.Define(spec => spec.WithOperation("v2.test.response", configure: operation => operation + .ResponseMediaTypes(200, "application/json", "text/plain"))); + + var ex = await host.ProjectExpectingRefusalAsync(scenario); + + await Assert.That(ex.Message).Contains("response content"); + await Assert.That(ex.Message).Contains("at most one media type"); + } + + [Test] + public async Task Project_Should_Refuse_Effect_Stream_On_Json_Media() + { + var host = new OperationProjectionTestHost(); + var scenario = SpecScenario.Define(spec => spec.WithOperation("v2.test.effect", configure: operation => operation + .JsonResponseWithEffectStreamFromFixture("effect-stream.json"))); + + var ex = await host.ProjectExpectingRefusalAsync(scenario); + + await Assert.That(ex.Message).Contains("x-effect-stream"); + await Assert.That(ex.Message).Contains("text/event-stream"); + } + + [Test] + public async Task Project_Should_Refuse_Default_Response_Key() + { + var host = new OperationProjectionTestHost(); + var scenario = SpecScenario.Define(spec => spec.WithOperation("v2.test.default", configure: operation => operation.DefaultResponse())); + + var ex = await host.ProjectExpectingRefusalAsync(scenario); + + await Assert.That(ex.Message).Contains("default"); + await Assert.That(ex.Message).Contains("integer status"); + } + + [Test] + public async Task Project_Should_Refuse_Unknown_Operation_Extension() + { + var host = new OperationProjectionTestHost(); + var scenario = SpecScenario.Define(spec => spec.WithOperation("v2.test.extension", configure: operation => operation + .Extension("x-unknown", "true"))); + + var ex = await host.ProjectExpectingRefusalAsync(scenario); + + await Assert.That(ex.Message).Contains("x-unknown"); + await Assert.That(ex.Message).Contains("operation"); + } + + [Test] + public async Task Project_Should_Refuse_Operation_Callbacks() + { + var host = new OperationProjectionTestHost(); + var scenario = SpecScenario.Define(spec => spec.WithOperation("v2.test.callback", configure: operation => operation.Callback())); + + var ex = await host.ProjectExpectingRefusalAsync(scenario); + + await Assert.That(ex.Message).Contains("callbacks"); + await Assert.That(ex.Message).Contains("operation"); + } + + [Test] + public async Task Project_Should_Locate_Malformed_Media_Type() + { + var host = new OperationProjectionTestHost(); + var scenario = SpecScenario.Define(spec => spec.WithOperation("v2.test.media", configure: operation => operation + .Response(200, "application-json", schema => schema.Type("string")))); + + var ex = await host.ProjectExpectingRefusalAsync(scenario); + + await Assert.That(ex.Message).Contains("application-json"); + await Assert.That(ex.Message).Contains("responses/200/content"); + } +} diff --git a/tests/OpenCode.Sdk.Tools.Tests/Support/OperationBuilder.cs b/tests/OpenCode.Sdk.Tools.Tests/Support/OperationBuilder.cs index c07d6ca2..4d481647 100644 --- a/tests/OpenCode.Sdk.Tools.Tests/Support/OperationBuilder.cs +++ b/tests/OpenCode.Sdk.Tools.Tests/Support/OperationBuilder.cs @@ -1,4 +1,3 @@ -using System.Globalization; using System.Text.Json.Nodes; namespace OpenCode.Sdk.Tools.Tests.Support; @@ -6,52 +5,40 @@ namespace OpenCode.Sdk.Tools.Tests.Support; internal sealed class OperationBuilder { private readonly JsonObject _operation; - private readonly JsonObject _responses = []; + private readonly OperationParameterBuilder _parameters; + private readonly OperationResponseBuilder _responses; - public OperationBuilder(string operationId) + public OperationBuilder(string operationId, FixtureLoader fixtureLoader) { ArgumentException.ThrowIfNullOrWhiteSpace(operationId); + ArgumentNullException.ThrowIfNull(fixtureLoader); + var responses = new JsonObject(); _operation = new JsonObject { ["operationId"] = operationId, - ["responses"] = _responses, + ["responses"] = responses, }; + _parameters = new OperationParameterBuilder(_operation); + _responses = new OperationResponseBuilder(responses, fixtureLoader); + Response(200); } public OperationBuilder Parameter(string name, string location, Action configure, bool required = false, bool deepObject = false) { - ArgumentException.ThrowIfNullOrWhiteSpace(name); - ArgumentException.ThrowIfNullOrWhiteSpace(location); - ArgumentNullException.ThrowIfNull(configure); - - if (_operation["parameters"] is not JsonArray parameters) - { - parameters = []; - _operation["parameters"] = parameters; - } - - var schema = new SchemaBuilder(); - configure(schema); - var parameter = new JsonObject - { - ["name"] = name, - ["in"] = location, - ["schema"] = schema.Build(), - }; - - if (required) - { - parameter["required"] = true; - } + _parameters.Add(name, location, configure, required, deepObject); + return this; + } - if (deepObject) - { - parameter["style"] = "deepObject"; - parameter["explode"] = true; - } + public OperationBuilder ParameterWithStyle(string name, string style, bool explode) + { + _parameters.AddWithStyle(name, style, explode); + return this; + } - parameters.Add(parameter); + public OperationBuilder ContentParameter(string name) + { + _parameters.AddContent(name); return this; } @@ -79,63 +66,91 @@ public OperationBuilder RequestBody(string mediaType, Action conf return this; } - public OperationBuilder Response(int status, string? mediaType = null, Action? schema = null) + public OperationBuilder RequestBodyMediaTypes(params string[] mediaTypes) { - ArgumentOutOfRangeException.ThrowIfLessThan(status, 100); - ArgumentOutOfRangeException.ThrowIfGreaterThan(status, 599); + ArgumentNullException.ThrowIfNull(mediaTypes); - if (mediaType is null && schema is not null) + var content = new JsonObject(); + foreach (var mediaType in mediaTypes) { - throw new ArgumentException("A response schema requires a media type.", nameof(mediaType)); - } - - var response = new JsonObject - { - ["description"] = "Response", - }; - - if (mediaType is not null) - { - ArgumentException.ThrowIfNullOrWhiteSpace(mediaType); - var media = new JsonObject(); - if (schema is not null) + if (string.IsNullOrWhiteSpace(mediaType)) { - var schemaBuilder = new SchemaBuilder(); - schema(schemaBuilder); - media["schema"] = schemaBuilder.Build(); + throw new ArgumentException("Media types cannot contain null or whitespace.", nameof(mediaTypes)); } - response["content"] = new JsonObject + content[mediaType] = new JsonObject { - [mediaType] = media, + ["schema"] = new JsonObject + { + ["type"] = "string", + }, }; } - _responses[status.ToString(CultureInfo.InvariantCulture)] = response; + _operation["requestBody"] = new JsonObject + { + ["content"] = content, + }; + return this; + } + + public OperationBuilder Response(int status, string? mediaType = null, Action? schema = null) + { + _responses.Add(status, mediaType, schema); + return this; + } + + public OperationBuilder ResponseFromFixture(int status, string mediaType, string fixtureName) + { + _responses.AddFromFixture(status, mediaType, fixtureName); + return this; + } + + public OperationBuilder ResponseWithHeader(int status) + { + _responses.AddWithHeader(status); + return this; + } + + public OperationBuilder ResponseWithItemSchema(int status) + { + _responses.AddWithItemSchema(status); + return this; + } + + public OperationBuilder ResponseMediaTypes(int status, params string[] mediaTypes) + { + _responses.AddMediaTypes(status, mediaTypes); + return this; + } + + public OperationBuilder ResponseWithEncoding(int status) + { + _responses.AddWithEncoding(status); + return this; + } + + public OperationBuilder DefaultResponse() + { + _responses.AddDefault(); return this; } public OperationBuilder SseResponse(Action schema, string? effectStreamJson = null) { - ArgumentNullException.ThrowIfNull(schema); + _responses.AddSse(schema, effectStreamJson); + return this; + } - var media = new JsonObject - { - ["schema"] = BuildSchema(schema), - }; - if (effectStreamJson is not null) - { - media["x-effect-stream"] = JsonNode.Parse(effectStreamJson); - } + public OperationBuilder SseResponseFromFixture(Action schema, string effectStreamFixtureName) + { + _responses.AddSseFromFixture(schema, effectStreamFixtureName); + return this; + } - _responses["200"] = new JsonObject - { - ["description"] = "Event stream", - ["content"] = new JsonObject - { - ["text/event-stream"] = media, - }, - }; + public OperationBuilder JsonResponseWithEffectStreamFromFixture(string effectStreamFixtureName) + { + _responses.AddJsonWithEffectStreamFromFixture(effectStreamFixtureName); return this; } @@ -172,6 +187,33 @@ public OperationBuilder Summary(string summary) return this; } + public OperationBuilder Description(string description) + { + ArgumentNullException.ThrowIfNull(description); + + _operation["description"] = description; + return this; + } + + public OperationBuilder WithIgnoredTagsAndSecurity() + { + _operation["tags"] = new JsonArray("test"); + _operation["security"] = new JsonArray(); + return this; + } + + public OperationBuilder Callback() + { + _operation["callbacks"] = new JsonObject + { + ["result"] = new JsonObject + { + ["{$request.body#/callbackUrl}"] = new JsonObject(), + }, + }; + return this; + } + internal JsonObject Build() => _operation.DeepClone().AsObject(); private static JsonObject BuildSchema(Action configure) diff --git a/tests/OpenCode.Sdk.Tools.Tests/Support/OperationParameterBuilder.cs b/tests/OpenCode.Sdk.Tools.Tests/Support/OperationParameterBuilder.cs new file mode 100644 index 00000000..6ac0161f --- /dev/null +++ b/tests/OpenCode.Sdk.Tools.Tests/Support/OperationParameterBuilder.cs @@ -0,0 +1,86 @@ +using System.Text.Json.Nodes; + +namespace OpenCode.Sdk.Tools.Tests.Support; + +internal sealed class OperationParameterBuilder(JsonObject operation) +{ + private readonly JsonObject _operation = operation ?? throw new ArgumentNullException(nameof(operation)); + + public void Add(string name, string location, Action configure, bool required, bool deepObject) + { + ArgumentException.ThrowIfNullOrWhiteSpace(name); + ArgumentException.ThrowIfNullOrWhiteSpace(location); + ArgumentNullException.ThrowIfNull(configure); + + var parameter = new JsonObject + { + ["name"] = name, + ["in"] = location, + ["schema"] = BuildSchema(configure), + }; + if (required) + { + parameter["required"] = true; + } + + if (deepObject) + { + parameter["style"] = "deepObject"; + parameter["explode"] = true; + } + + GetParameters().Add(parameter); + } + + public void AddWithStyle(string name, string style, bool explode) + { + ArgumentException.ThrowIfNullOrWhiteSpace(name); + ArgumentException.ThrowIfNullOrWhiteSpace(style); + + Add(name, "query", schema => schema.Type("string"), required: false, deepObject: false); + var parameter = GetParameters()[^1]!.AsObject(); + parameter["style"] = style; + parameter["explode"] = explode; + } + + public void AddContent(string name) + { + ArgumentException.ThrowIfNullOrWhiteSpace(name); + + GetParameters() + .Add(new JsonObject + { + ["name"] = name, + ["in"] = "query", + ["content"] = new JsonObject + { + ["application/json"] = new JsonObject + { + ["schema"] = new JsonObject + { + ["type"] = "string", + }, + }, + }, + }); + } + + private JsonArray GetParameters() + { + if (_operation["parameters"] is JsonArray parameters) + { + return parameters; + } + + parameters = []; + _operation["parameters"] = parameters; + return parameters; + } + + private static JsonObject BuildSchema(Action configure) + { + var schema = new SchemaBuilder(); + configure(schema); + return schema.Build(); + } +} diff --git a/tests/OpenCode.Sdk.Tools.Tests/Support/OperationProjectionResult.cs b/tests/OpenCode.Sdk.Tools.Tests/Support/OperationProjectionResult.cs new file mode 100644 index 00000000..41f0d48b --- /dev/null +++ b/tests/OpenCode.Sdk.Tools.Tests/Support/OperationProjectionResult.cs @@ -0,0 +1,24 @@ +using System.Collections.ObjectModel; +using OpenCode.Sdk.Tools.Generator.Ingestion.Models; + +namespace OpenCode.Sdk.Tools.Tests.Support; + +internal sealed record OperationProjectionResult +{ + public OperationProjectionResult(IReadOnlyList operations, IReadOnlyDictionary schemas, + IngestionException? refusal) + { + ArgumentNullException.ThrowIfNull(operations); + ArgumentNullException.ThrowIfNull(schemas); + + Operations = Array.AsReadOnly([.. operations]); + Schemas = new ReadOnlyDictionary(new Dictionary(schemas, StringComparer.Ordinal)); + Refusal = refusal; + } + + public IReadOnlyList Operations { get; } + + public IReadOnlyDictionary Schemas { get; } + + public IngestionException? Refusal { get; } +} diff --git a/tests/OpenCode.Sdk.Tools.Tests/Support/OperationProjectionTestHost.cs b/tests/OpenCode.Sdk.Tools.Tests/Support/OperationProjectionTestHost.cs new file mode 100644 index 00000000..bfeb6fa7 --- /dev/null +++ b/tests/OpenCode.Sdk.Tools.Tests/Support/OperationProjectionTestHost.cs @@ -0,0 +1,58 @@ +using System.Collections.Immutable; +using System.Text.Json.Nodes; +using OpenCode.Sdk.Tools.Generator.Ingestion; +using OpenCode.Sdk.Tools.Generator.Ingestion.Models; +using OpenCode.Sdk.Tools.Generator.Ingestion.Projection; +using OpenCode.Sdk.Tools.Generator.Ingestion.Walls; + +namespace OpenCode.Sdk.Tools.Tests.Support; + +internal sealed class OperationProjectionTestHost +{ + private readonly GraphKeyBuilder _keys = new(); + private readonly SchemaWallPolicy _schemaWall = new(); + + public async Task ProjectAsync(SpecScenario scenario) + { + var result = await ProjectCoreAsync(scenario); + return result.Refusal is not null + ? throw new InvalidOperationException("The operation projection was unexpectedly refused.", result.Refusal) + : result; + } + + public async Task ProjectExpectingRefusalAsync(SpecScenario scenario) + { + var result = await ProjectCoreAsync(scenario); + return result.Refusal ?? throw new InvalidOperationException("The operation projection was expected to be refused."); + } + + private async Task ProjectCoreAsync(SpecScenario scenario) + { + ArgumentNullException.ThrowIfNull(scenario); + + var context = scenario.Build(); + var errors = new IngestionErrorCollector(); + try + { + var loaded = await new SpecReader(context.FileSystem).LoadAsync(context.SpecPath, errors, CancellationToken.None); + var schemaProjector = new SchemaProjector(_schemaWall, _keys); + var state = new ProjectionState(errors, loaded.Document, ImmutableDictionary.Empty); + var operations = new OperationProjector(schemaProjector, _keys).Project(loaded, state); + + if (loaded.Document.Components?.Schemas is not null) + { + foreach (var (name, schema) in loaded.Document.Components.Schemas) + { + _ = schemaProjector.Project(schema, _keys.Root(name), string.Empty, state); + } + } + + errors.ThrowIfAny(); + return new OperationProjectionResult(operations, state.SnapshotGraph(), refusal: null); + } + catch (IngestionException exception) + { + return new OperationProjectionResult([], new Dictionary(StringComparer.Ordinal), exception); + } + } +} diff --git a/tests/OpenCode.Sdk.Tools.Tests/Support/OperationResponseBuilder.cs b/tests/OpenCode.Sdk.Tools.Tests/Support/OperationResponseBuilder.cs new file mode 100644 index 00000000..3f978fb0 --- /dev/null +++ b/tests/OpenCode.Sdk.Tools.Tests/Support/OperationResponseBuilder.cs @@ -0,0 +1,187 @@ +using System.Globalization; +using System.Text.Json.Nodes; + +namespace OpenCode.Sdk.Tools.Tests.Support; + +internal sealed class OperationResponseBuilder(JsonObject responses, FixtureLoader fixtureLoader) +{ + private readonly FixtureLoader _fixtureLoader = fixtureLoader ?? throw new ArgumentNullException(nameof(fixtureLoader)); + private readonly JsonObject _responses = responses ?? throw new ArgumentNullException(nameof(responses)); + + public void Add(int status, string? mediaType, Action? configureSchema) + { + ValidateStatus(status); + if (mediaType is null && configureSchema is not null) + { + throw new ArgumentException("A response schema requires a media type.", nameof(mediaType)); + } + + var response = new JsonObject + { + ["description"] = "Response", + }; + if (mediaType is not null) + { + ArgumentException.ThrowIfNullOrWhiteSpace(mediaType); + var media = new JsonObject(); + if (configureSchema is not null) + { + media["schema"] = BuildSchema(configureSchema); + } + + response["content"] = new JsonObject + { + [mediaType] = media, + }; + } + + _responses[FormatStatus(status)] = response; + } + + public void AddFromFixture(int status, string mediaType, string fixtureName) + { + ValidateStatus(status); + ArgumentException.ThrowIfNullOrWhiteSpace(mediaType); + ArgumentException.ThrowIfNullOrWhiteSpace(fixtureName); + + _responses[FormatStatus(status)] = CreateResponse(mediaType, JsonNode.Parse(_fixtureLoader.Load(fixtureName))); + } + + public void AddWithHeader(int status) + { + Add(status, mediaType: null, configureSchema: null); + _responses[FormatStatus(status)]!["headers"] = new JsonObject + { + ["x-test"] = new JsonObject + { + ["schema"] = new JsonObject + { + ["type"] = "string", + }, + }, + }; + } + + public void AddWithItemSchema(int status) + { + Add(status, "application/json", configureSchema: null); + var media = _responses[FormatStatus(status)]!["content"]!["application/json"]!; + media["itemSchema"] = new JsonObject + { + ["type"] = "string", + }; + } + + public void AddMediaTypes(int status, IReadOnlyList mediaTypes) + { + ValidateStatus(status); + ArgumentNullException.ThrowIfNull(mediaTypes); + + var content = new JsonObject(); + foreach (var mediaType in mediaTypes) + { + if (string.IsNullOrWhiteSpace(mediaType)) + { + throw new ArgumentException("Media types cannot contain null or whitespace.", nameof(mediaTypes)); + } + + content[mediaType] = new JsonObject(); + } + + _responses[FormatStatus(status)] = new JsonObject + { + ["description"] = "Response", + ["content"] = content, + }; + } + + public void AddWithEncoding(int status) + { + Add(status, "application/json", schema => schema + .Type("object") + .Property("data", property => property.Type("string"))); + var media = _responses[FormatStatus(status)]!["content"]!["application/json"]!; + media["encoding"] = new JsonObject + { + ["data"] = new JsonObject + { + ["contentType"] = "text/plain", + }, + }; + } + + public void AddDefault() + { + _responses["default"] = new JsonObject + { + ["description"] = "Default response", + }; + } + + public void AddSse(Action configureSchema, string? effectStreamJson) + { + ArgumentNullException.ThrowIfNull(configureSchema); + + var media = new JsonObject + { + ["schema"] = BuildSchema(configureSchema), + }; + if (effectStreamJson is not null) + { + media["x-effect-stream"] = JsonNode.Parse(effectStreamJson); + } + + _responses["200"] = new JsonObject + { + ["description"] = "Event stream", + ["content"] = new JsonObject + { + ["text/event-stream"] = media, + }, + }; + } + + public void AddSseFromFixture(Action configureSchema, string effectStreamFixtureName) + { + ArgumentNullException.ThrowIfNull(configureSchema); + ArgumentException.ThrowIfNullOrWhiteSpace(effectStreamFixtureName); + AddSse(configureSchema, _fixtureLoader.Load(effectStreamFixtureName)); + } + + public void AddJsonWithEffectStreamFromFixture(string effectStreamFixtureName) + { + ArgumentException.ThrowIfNullOrWhiteSpace(effectStreamFixtureName); + + Add(200, "application/json", schema => schema.Type("string")); + var media = _responses["200"]!["content"]!["application/json"]!; + media["x-effect-stream"] = JsonNode.Parse(_fixtureLoader.Load(effectStreamFixtureName)); + } + + private static JsonObject BuildSchema(Action configure) + { + var schema = new SchemaBuilder(); + configure(schema); + return schema.Build(); + } + + private static JsonObject CreateResponse(string mediaType, JsonNode? schema) => + new() + { + ["description"] = "Response", + ["content"] = new JsonObject + { + [mediaType] = new JsonObject + { + ["schema"] = schema, + }, + }, + }; + + private static string FormatStatus(int status) => status.ToString(CultureInfo.InvariantCulture); + + private static void ValidateStatus(int status) + { + ArgumentOutOfRangeException.ThrowIfLessThan(status, 100); + ArgumentOutOfRangeException.ThrowIfGreaterThan(status, 599); + } +} diff --git a/tests/OpenCode.Sdk.Tools.Tests/Support/SpecDocumentBuilder.cs b/tests/OpenCode.Sdk.Tools.Tests/Support/SpecDocumentBuilder.cs index 207147f6..b2042b38 100644 --- a/tests/OpenCode.Sdk.Tools.Tests/Support/SpecDocumentBuilder.cs +++ b/tests/OpenCode.Sdk.Tools.Tests/Support/SpecDocumentBuilder.cs @@ -83,7 +83,7 @@ public SpecDocumentBuilder WithOperation(string operationId, string method = "ge ArgumentException.ThrowIfNullOrWhiteSpace(method); ArgumentException.ThrowIfNullOrWhiteSpace(path); - var operation = new OperationBuilder(operationId); + var operation = new OperationBuilder(operationId, _fixtureLoader); configure?.Invoke(operation); if (_paths[path] is not JsonObject pathItem) @@ -96,6 +96,61 @@ public SpecDocumentBuilder WithOperation(string operationId, string method = "ge return this; } + public SpecDocumentBuilder WithWebhook() + { + _root["webhooks"] = new JsonObject + { + ["test"] = new JsonObject + { + ["post"] = new JsonObject + { + ["operationId"] = "webhook.test", + ["responses"] = new JsonObject + { + ["200"] = new JsonObject + { + ["description"] = "Response", + }, + }, + }, + }, + }; + return this; + } + + public SpecDocumentBuilder WithNonSchemaComponent() + { + _root["components"]!["parameters"] = new JsonObject + { + ["test"] = new JsonObject + { + ["name"] = "test", + ["in"] = "query", + ["schema"] = new JsonObject + { + ["type"] = "string", + }, + }, + }; + return this; + } + + public SpecDocumentBuilder WithPathServer(string operationId, string path = "/api/x") + { + ArgumentException.ThrowIfNullOrWhiteSpace(operationId); + ArgumentException.ThrowIfNullOrWhiteSpace(path); + + WithOperation(operationId, path: path); + _paths[path]!["servers"] = new JsonArray + { + new JsonObject + { + ["url"] = "https://example.test", + }, + }; + return this; + } + public SpecDocumentBuilder WithRawTopLevel(string key, string rawJson) { ArgumentException.ThrowIfNullOrWhiteSpace(key); diff --git a/tools/OpenCode.Sdk.Tools/Generator/Ingestion/Models/SpecEnvelopeShape.cs b/tools/OpenCode.Sdk.Tools/Generator/Ingestion/Models/SpecEnvelopeShape.cs new file mode 100644 index 00000000..9a31a725 --- /dev/null +++ b/tools/OpenCode.Sdk.Tools/Generator/Ingestion/Models/SpecEnvelopeShape.cs @@ -0,0 +1,23 @@ +namespace OpenCode.Sdk.Tools.Generator.Ingestion.Models; + +/// Identifies a recognized response-envelope property shape. +public enum SpecEnvelopeShape +{ + /// The response has no content. + None = 0, + + /// The response has content without a recognized envelope. + Bare = 1, + + /// The response has exactly a data property. + Data = 2, + + /// The response has exactly data and location properties. + DataLocation = 3, + + /// The response has exactly cursor and data properties. + CursorData = 4, + + /// The response has exactly data and hasMore properties. + DataHasMore = 5, +} diff --git a/tools/OpenCode.Sdk.Tools/Generator/Ingestion/Models/SpecMediaType.cs b/tools/OpenCode.Sdk.Tools/Generator/Ingestion/Models/SpecMediaType.cs new file mode 100644 index 00000000..b94966a7 --- /dev/null +++ b/tools/OpenCode.Sdk.Tools/Generator/Ingestion/Models/SpecMediaType.cs @@ -0,0 +1,64 @@ +using System.Net.Http.Headers; + +namespace OpenCode.Sdk.Tools.Generator.Ingestion.Models; + +/// Describes a normalized response or request media type. +public sealed record SpecMediaType +{ + /// Gets the media type exactly as declared by the document. + public required string Raw + { + get; + init + { + ArgumentNullException.ThrowIfNull(value); + field = value; + } + } + + /// Gets the parameter-free, invariant-lowercase media type. + public required string Stripped + { + get; + init + { + ArgumentNullException.ThrowIfNull(value); + field = value; + } + } + + /// Gets a value indicating whether the media type carries JSON. + public required bool IsJson { get; init; } + + /// Gets a value indicating whether the media type is an event stream. + public required bool IsEventStream { get; init; } + + /// Creates a normalized media type from a wire declaration. + /// The media type declaration. + /// The normalized media type. + public static SpecMediaType Create(string raw) + { + ArgumentException.ThrowIfNullOrWhiteSpace(raw); + + if (!MediaTypeHeaderValue.TryParse(raw, out var parsed) || string.IsNullOrEmpty(parsed.MediaType)) + { + throw new ArgumentException($"Media type '{raw}' is malformed.", nameof(raw)); + } + + var stripped = parsed.MediaType.ToLowerInvariant(); + var separator = stripped.IndexOf('/', StringComparison.Ordinal); + if (separator <= 0 || separator == stripped.Length - 1) + { + throw new ArgumentException($"Media type '{raw}' is malformed.", nameof(raw)); + } + + return new SpecMediaType + { + Raw = raw, + Stripped = stripped, + IsJson = string.Equals(stripped, "application/json", StringComparison.Ordinal) + || stripped.EndsWith("+json", StringComparison.Ordinal), + IsEventStream = string.Equals(stripped, "text/event-stream", StringComparison.Ordinal), + }; + } +} diff --git a/tools/OpenCode.Sdk.Tools/Generator/Ingestion/Models/SpecOperation.cs b/tools/OpenCode.Sdk.Tools/Generator/Ingestion/Models/SpecOperation.cs new file mode 100644 index 00000000..688a4f6d --- /dev/null +++ b/tools/OpenCode.Sdk.Tools/Generator/Ingestion/Models/SpecOperation.cs @@ -0,0 +1,106 @@ +namespace OpenCode.Sdk.Tools.Generator.Ingestion.Models; + +/// Describes one projected OpenAPI operation. +public sealed record SpecOperation +{ + /// Gets the verbatim operation identifier. + public required string OperationId + { + get; + init + { + ArgumentNullException.ThrowIfNull(value); + field = value; + } + } + + /// Gets the operation surface. + public required SpecSurface Surface { get; init; } + + /// Gets operation-identifier segments without the modern v2. marker. + public required IReadOnlyList Segments + { + get; + init + { + ArgumentNullException.ThrowIfNull(value); + field = Array.AsReadOnly([.. value]); + } + } = Array.AsReadOnly(Array.Empty()); + + /// Gets the invariant-lowercase HTTP method. + public required string Method + { + get; + init + { + ArgumentNullException.ThrowIfNull(value); + field = value; + } + } + + /// Gets the verbatim path template. + public required string Path + { + get; + init + { + ArgumentNullException.ThrowIfNull(value); + field = value; + } + } + + /// Gets a value indicating whether the path ends in a wildcard segment. + public required bool HasWildcardPath { get; init; } + + /// Gets a value indicating whether the operation upgrades to a WebSocket. + public required bool IsWebSocket { get; init; } + + /// Gets a value indicating whether any response is an event stream. + public required bool IsSse { get; init; } + + /// Gets a value indicating whether the operation is deprecated. + public required bool IsDeprecated { get; init; } + + /// Gets the operation summary, when supplied. + public string? Summary { get; init; } + + /// Gets the operation description, when supplied. + public string? Description { get; init; } + + /// Gets operation parameters in document order. + public required IReadOnlyList Parameters + { + get; + init + { + ArgumentNullException.ThrowIfNull(value); + field = Array.AsReadOnly([.. value]); + } + } = Array.AsReadOnly(Array.Empty()); + + /// Gets the request body, when supplied. + public SpecRequestBody? RequestBody { get; init; } + + /// Gets responses sorted by ascending status code. + public required IReadOnlyList Responses + { + get; + init + { + ArgumentNullException.ThrowIfNull(value); + field = Array.AsReadOnly([.. value]); + } + } = Array.AsReadOnly(Array.Empty()); + + /// Gets the canonical raw-content hash, when hash projection is enabled. + public required string RawContentHash + { + get; + init + { + ArgumentNullException.ThrowIfNull(value); + field = value; + } + } +} diff --git a/tools/OpenCode.Sdk.Tools/Generator/Ingestion/Models/SpecParameter.cs b/tools/OpenCode.Sdk.Tools/Generator/Ingestion/Models/SpecParameter.cs new file mode 100644 index 00000000..b8dbf7e3 --- /dev/null +++ b/tools/OpenCode.Sdk.Tools/Generator/Ingestion/Models/SpecParameter.cs @@ -0,0 +1,36 @@ +namespace OpenCode.Sdk.Tools.Generator.Ingestion.Models; + +/// Describes one projected operation parameter. +public sealed record SpecParameter +{ + /// Gets the opaque wire parameter name. + public required string Name + { + get; + init + { + ArgumentNullException.ThrowIfNull(value); + field = value; + } + } + + /// Gets the wire location of the parameter. + public required SpecParameterLocation Location { get; init; } + + /// Gets the projected parameter schema. + public required SchemaNode Schema + { + get; + init + { + ArgumentNullException.ThrowIfNull(value); + field = value; + } + } + + /// Gets a value indicating whether the parameter is required. + public required bool IsRequired { get; init; } + + /// Gets a value indicating whether the parameter uses deep-object encoding. + public required bool IsDeepObject { get; init; } +} diff --git a/tools/OpenCode.Sdk.Tools/Generator/Ingestion/Models/SpecParameterLocation.cs b/tools/OpenCode.Sdk.Tools/Generator/Ingestion/Models/SpecParameterLocation.cs new file mode 100644 index 00000000..d92a2b1b --- /dev/null +++ b/tools/OpenCode.Sdk.Tools/Generator/Ingestion/Models/SpecParameterLocation.cs @@ -0,0 +1,11 @@ +namespace OpenCode.Sdk.Tools.Generator.Ingestion.Models; + +/// Identifies an admitted wire location for an operation parameter. +public enum SpecParameterLocation +{ + /// The parameter is embedded in the path template. + Path = 0, + + /// The parameter is carried in the query string. + Query = 1, +} diff --git a/tools/OpenCode.Sdk.Tools/Generator/Ingestion/Models/SpecRequestBody.cs b/tools/OpenCode.Sdk.Tools/Generator/Ingestion/Models/SpecRequestBody.cs new file mode 100644 index 00000000..afedcbaa --- /dev/null +++ b/tools/OpenCode.Sdk.Tools/Generator/Ingestion/Models/SpecRequestBody.cs @@ -0,0 +1,30 @@ +namespace OpenCode.Sdk.Tools.Generator.Ingestion.Models; + +/// Describes one projected operation request body. +public sealed record SpecRequestBody +{ + /// Gets the request content type. + public required SpecMediaType ContentType + { + get; + init + { + ArgumentNullException.ThrowIfNull(value); + field = value; + } + } + + /// Gets the projected request schema. + public required SchemaNode Schema + { + get; + init + { + ArgumentNullException.ThrowIfNull(value); + field = value; + } + } + + /// Gets a value indicating whether the request body is required. + public required bool IsRequired { get; init; } +} diff --git a/tools/OpenCode.Sdk.Tools/Generator/Ingestion/Models/SpecResponse.cs b/tools/OpenCode.Sdk.Tools/Generator/Ingestion/Models/SpecResponse.cs new file mode 100644 index 00000000..94f8ca89 --- /dev/null +++ b/tools/OpenCode.Sdk.Tools/Generator/Ingestion/Models/SpecResponse.cs @@ -0,0 +1,26 @@ +namespace OpenCode.Sdk.Tools.Generator.Ingestion.Models; + +/// Describes one projected operation response. +public sealed record SpecResponse +{ + /// Gets the integer HTTP status code. + public required int StatusCode { get; init; } + + /// Gets the response description, when supplied. + public string? Description { get; init; } + + /// Gets the response content type, or when there is no content. + public SpecMediaType? ContentType { get; init; } + + /// Gets the projected response schema, when supplied. + public SchemaNode? Schema { get; init; } + + /// Gets the classified response-envelope shape. + public required SpecEnvelopeShape EnvelopeShape { get; init; } + + /// Gets a value indicating whether the response is an event stream. + public required bool IsSse { get; init; } + + /// Gets the opaque effect-stream JSON metadata, when supplied. + public string? EffectStreamJson { get; init; } +} diff --git a/tools/OpenCode.Sdk.Tools/Generator/Ingestion/Models/SpecSurface.cs b/tools/OpenCode.Sdk.Tools/Generator/Ingestion/Models/SpecSurface.cs new file mode 100644 index 00000000..d522ecb8 --- /dev/null +++ b/tools/OpenCode.Sdk.Tools/Generator/Ingestion/Models/SpecSurface.cs @@ -0,0 +1,11 @@ +namespace OpenCode.Sdk.Tools.Generator.Ingestion.Models; + +/// Identifies the upstream operation generation represented by an operation. +public enum SpecSurface +{ + /// The modern operation surface whose operation identifiers begin with v2.. + Modern = 0, + + /// The legacy operation surface. + Legacy = 1, +} diff --git a/tools/OpenCode.Sdk.Tools/Generator/Ingestion/Projection/EnvelopeClassifier.cs b/tools/OpenCode.Sdk.Tools/Generator/Ingestion/Projection/EnvelopeClassifier.cs new file mode 100644 index 00000000..5b1d59c2 --- /dev/null +++ b/tools/OpenCode.Sdk.Tools/Generator/Ingestion/Projection/EnvelopeClassifier.cs @@ -0,0 +1,75 @@ +using System.Collections.Frozen; +using Microsoft.OpenApi; +using OpenCode.Sdk.Tools.Generator.Ingestion.Models; + +namespace OpenCode.Sdk.Tools.Generator.Ingestion.Projection; + +internal sealed class EnvelopeClassifier +{ + private readonly FrozenSet _cursorData = new[] { "cursor", "data", }.ToFrozenSet(StringComparer.Ordinal); + private readonly FrozenSet _data = new[] { "data", }.ToFrozenSet(StringComparer.Ordinal); + private readonly FrozenSet _dataHasMore = new[] { "data", "hasMore", }.ToFrozenSet(StringComparer.Ordinal); + private readonly FrozenSet _dataLocation = new[] { "data", "location", }.ToFrozenSet(StringComparer.Ordinal); + + public SpecEnvelopeShape Classify(SpecMediaType? contentType, IOpenApiSchema? schema, string location, IngestionErrorCollector errors) + { + ArgumentException.ThrowIfNullOrWhiteSpace(location); + ArgumentNullException.ThrowIfNull(errors); + + if (contentType is null) + { + return SpecEnvelopeShape.None; + } + + if (!contentType.IsJson || schema is null) + { + return SpecEnvelopeShape.Bare; + } + + var concrete = ChaseReference(schema, location, errors); + if (concrete?.Properties is not { } properties) + { + return SpecEnvelopeShape.Bare; + } + + var propertyNames = properties.Keys.ToHashSet(StringComparer.Ordinal); + return properties.Count switch + { + 1 when propertyNames.SetEquals(_data) => SpecEnvelopeShape.Data, + 2 when propertyNames.SetEquals(_dataLocation) => SpecEnvelopeShape.DataLocation, + 2 when propertyNames.SetEquals(_cursorData) => SpecEnvelopeShape.CursorData, + 2 when propertyNames.SetEquals(_dataHasMore) => SpecEnvelopeShape.DataHasMore, + _ => SpecEnvelopeShape.Bare, + }; + } + + private static OpenApiSchema? ChaseReference(IOpenApiSchema schema, string location, IngestionErrorCollector errors) + { + var visited = new HashSet(ReferenceEqualityComparer.Instance); + var current = schema; + while (current is OpenApiSchemaReference reference) + { + if (!visited.Add(current)) + { + errors.Add(location, "response envelope schema contains a reference cycle"); + return null; + } + + if (reference.Target is null) + { + errors.Add(location, $"response envelope reference target '{reference.Reference.Id ?? ""}' could not be resolved"); + return null; + } + + current = reference.Target; + } + + if (current is OpenApiSchema concrete) + { + return concrete; + } + + errors.Add(location, $"response envelope schema implementation '{current.GetType().Name}' is not supported"); + return null; + } +} diff --git a/tools/OpenCode.Sdk.Tools/Generator/Ingestion/Projection/MediaTypeProjection.cs b/tools/OpenCode.Sdk.Tools/Generator/Ingestion/Projection/MediaTypeProjection.cs new file mode 100644 index 00000000..bf65fb3e --- /dev/null +++ b/tools/OpenCode.Sdk.Tools/Generator/Ingestion/Projection/MediaTypeProjection.cs @@ -0,0 +1,5 @@ +using OpenCode.Sdk.Tools.Generator.Ingestion.Models; + +namespace OpenCode.Sdk.Tools.Generator.Ingestion.Projection; + +internal sealed record MediaTypeProjection(SpecMediaType ContentType, string? EffectStreamJson); diff --git a/tools/OpenCode.Sdk.Tools/Generator/Ingestion/Projection/MediaTypeProjector.cs b/tools/OpenCode.Sdk.Tools/Generator/Ingestion/Projection/MediaTypeProjector.cs new file mode 100644 index 00000000..cf4a8d26 --- /dev/null +++ b/tools/OpenCode.Sdk.Tools/Generator/Ingestion/Projection/MediaTypeProjector.cs @@ -0,0 +1,32 @@ +using Microsoft.OpenApi; +using OpenCode.Sdk.Tools.Generator.Ingestion.Models; +using OpenCode.Sdk.Tools.Generator.Ingestion.Walls; + +namespace OpenCode.Sdk.Tools.Generator.Ingestion.Projection; + +internal sealed class MediaTypeProjector(MediaTypeWallPolicy wall) +{ + private readonly MediaTypeWallPolicy _wall = wall ?? throw new ArgumentNullException(nameof(wall)); + + public MediaTypeProjection? Project(string raw, IOpenApiMediaType media, string location, IngestionErrorCollector errors) + { + ArgumentException.ThrowIfNullOrWhiteSpace(raw); + ArgumentNullException.ThrowIfNull(media); + ArgumentException.ThrowIfNullOrWhiteSpace(location); + ArgumentNullException.ThrowIfNull(errors); + + SpecMediaType contentType; + try + { + contentType = SpecMediaType.Create(raw); + } + catch (ArgumentException exception) + { + errors.Add(location, exception.Message); + return null; + } + + var effectStreamJson = _wall.Check(media, contentType, location, errors); + return new MediaTypeProjection(contentType, effectStreamJson); + } +} diff --git a/tools/OpenCode.Sdk.Tools/Generator/Ingestion/Projection/OperationIdentity.cs b/tools/OpenCode.Sdk.Tools/Generator/Ingestion/Projection/OperationIdentity.cs new file mode 100644 index 00000000..b55ee7db --- /dev/null +++ b/tools/OpenCode.Sdk.Tools/Generator/Ingestion/Projection/OperationIdentity.cs @@ -0,0 +1,5 @@ +using OpenCode.Sdk.Tools.Generator.Ingestion.Models; + +namespace OpenCode.Sdk.Tools.Generator.Ingestion.Projection; + +internal sealed record OperationIdentity(SpecSurface Surface, IReadOnlyList Segments, bool HasWildcardPath); diff --git a/tools/OpenCode.Sdk.Tools/Generator/Ingestion/Projection/OperationIdentityParser.cs b/tools/OpenCode.Sdk.Tools/Generator/Ingestion/Projection/OperationIdentityParser.cs new file mode 100644 index 00000000..e2fab263 --- /dev/null +++ b/tools/OpenCode.Sdk.Tools/Generator/Ingestion/Projection/OperationIdentityParser.cs @@ -0,0 +1,36 @@ +using OpenCode.Sdk.Tools.Generator.Ingestion.Models; + +namespace OpenCode.Sdk.Tools.Generator.Ingestion.Projection; + +internal sealed class OperationIdentityParser +{ + private readonly string _modernPrefix = "v2."; + + public OperationIdentity? Parse(string operationId, string path, string location, IngestionErrorCollector errors) + { + ArgumentException.ThrowIfNullOrWhiteSpace(operationId); + ArgumentException.ThrowIfNullOrWhiteSpace(path); + ArgumentException.ThrowIfNullOrWhiteSpace(location); + ArgumentNullException.ThrowIfNull(errors); + + var surface = operationId.StartsWith(_modernPrefix, StringComparison.Ordinal) ? SpecSurface.Modern : SpecSurface.Legacy; + var segmentSource = surface is SpecSurface.Modern ? operationId[_modernPrefix.Length..] : operationId; + var segments = segmentSource.Split('.'); + if (segments.Any(string.IsNullOrWhiteSpace)) + { + errors.Add(location, $"operationId '{operationId}' contains an empty segment"); + return null; + } + + var wildcardIndex = path.IndexOf('*', StringComparison.Ordinal); + var hasWildcard = wildcardIndex >= 0; + if (!hasWildcard || (wildcardIndex == path.Length - 1 && path.EndsWith("/*", StringComparison.Ordinal) + && path.LastIndexOf('*', StringComparison.Ordinal) == wildcardIndex)) + { + return new OperationIdentity(surface, Array.AsReadOnly(segments), hasWildcard); + } + + errors.Add(location, $"path '{path}' has a wildcard outside the trailing '/*' position"); + return null; + } +} diff --git a/tools/OpenCode.Sdk.Tools/Generator/Ingestion/Projection/OperationProjectionContext.cs b/tools/OpenCode.Sdk.Tools/Generator/Ingestion/Projection/OperationProjectionContext.cs new file mode 100644 index 00000000..d5c2b251 --- /dev/null +++ b/tools/OpenCode.Sdk.Tools/Generator/Ingestion/Projection/OperationProjectionContext.cs @@ -0,0 +1,42 @@ +using System.Diagnostics.CodeAnalysis; +using System.Text.Json.Nodes; +using OpenCode.Sdk.Tools.Generator.Ingestion.Models; + +namespace OpenCode.Sdk.Tools.Generator.Ingestion.Projection; + +internal sealed class OperationProjectionContext(JsonNode rawPaths, ProjectionState state) +{ + private readonly HashSet _operationIds = new(StringComparer.Ordinal); + private readonly List _operations = []; + private readonly JsonNode _rawPaths = rawPaths ?? throw new ArgumentNullException(nameof(rawPaths)); + + public ProjectionState State { get; } = state ?? throw new ArgumentNullException(nameof(state)); + + public void Add(SpecOperation operation) + { + ArgumentNullException.ThrowIfNull(operation); + _operations.Add(operation); + } + + public bool TryGetRawPath(string path, [NotNullWhen(true)] out JsonObject? rawPath) + { + ArgumentException.ThrowIfNullOrWhiteSpace(path); + + if (_rawPaths[path] is JsonObject found) + { + rawPath = found; + return true; + } + + rawPath = null; + return false; + } + + public bool TryRegisterOperationId(string operationId) + { + ArgumentException.ThrowIfNullOrWhiteSpace(operationId); + return _operationIds.Add(operationId); + } + + public IReadOnlyList Snapshot() => Array.AsReadOnly([.. _operations]); +} diff --git a/tools/OpenCode.Sdk.Tools/Generator/Ingestion/Projection/OperationProjector.cs b/tools/OpenCode.Sdk.Tools/Generator/Ingestion/Projection/OperationProjector.cs new file mode 100644 index 00000000..b0083db8 --- /dev/null +++ b/tools/OpenCode.Sdk.Tools/Generator/Ingestion/Projection/OperationProjector.cs @@ -0,0 +1,140 @@ +using System.Text.Json.Nodes; +using Microsoft.OpenApi; +using OpenCode.Sdk.Tools.Generator.Ingestion.Models; +using OpenCode.Sdk.Tools.Generator.Ingestion.Walls; + +namespace OpenCode.Sdk.Tools.Generator.Ingestion.Projection; + +internal sealed class OperationProjector +{ + private readonly DocumentWallPolicy _documentWall = new(); + private readonly OperationIdentityParser _identityParser = new(); + private readonly GraphKeyBuilder _keys; + private readonly OperationWallPolicy _operationWall = new(); + private readonly ParameterProjector _parameters; + private readonly PathItemWallPolicy _pathItemWall = new(); + private readonly PathTemplateValidator _pathTemplates = new(); + private readonly RequestBodyProjector _requestBodies; + private readonly ResponseProjector _responses; + + public OperationProjector(SchemaProjector schemaProjector, GraphKeyBuilder keys) + { + ArgumentNullException.ThrowIfNull(schemaProjector); + _keys = keys ?? throw new ArgumentNullException(nameof(keys)); + + var mediaTypes = new MediaTypeProjector(new MediaTypeWallPolicy()); + _parameters = new ParameterProjector(schemaProjector, keys, new ParameterWallPolicy()); + _requestBodies = new RequestBodyProjector(schemaProjector, keys, new RequestBodyWallPolicy(), mediaTypes); + _responses = new ResponseProjector(schemaProjector, keys, new ResponseWallPolicy(), mediaTypes, new EnvelopeClassifier()); + } + + public IReadOnlyList Project(LoadedSpec loaded, ProjectionState state) + { + ArgumentNullException.ThrowIfNull(loaded); + ArgumentNullException.ThrowIfNull(state); + + _documentWall.Check(loaded.Document, state.Errors); + if (loaded.Raw is not JsonObject rawDocument || rawDocument["paths"] is not JsonObject rawPaths) + { + state.Errors.Add("document/paths", "raw paths object was unavailable"); + return Array.AsReadOnly(Array.Empty()); + } + + var context = new OperationProjectionContext(rawPaths, state); + foreach (var (path, pathItem) in loaded.Document.Paths) + { + ProjectPath(path, pathItem, context); + } + + return context.Snapshot(); + } + + private void ProjectPath(string path, IOpenApiPathItem pathItem, OperationProjectionContext context) + { + var pathLocation = $"paths/{path}"; + _pathItemWall.Check(pathItem, pathLocation, context.State.Errors); + if (pathItem is not OpenApiPathItem { Operations: not null } concrete) + { + return; + } + + if (!context.TryGetRawPath(path, out var rawPathItem)) + { + context.State.Errors.Add(pathLocation, "raw path item was unavailable"); + return; + } + + foreach (var operation in concrete.Operations) + { + ProjectOperation(path, operation, rawPathItem, context); + } + } + + private void ProjectOperation(string path, KeyValuePair source, JsonObject rawPathItem, + OperationProjectionContext context) + { + var (method, operation) = source; + if (!PathItemWallPolicy.IsAdmitted(method)) + { + return; + } + + var normalizedMethod = method.Method.ToLowerInvariant(); + var location = $"paths/{path}/{normalizedMethod}"; + if (rawPathItem[normalizedMethod] is not JsonObject rawOperation) + { + context.State.Errors.Add(location, "raw operation was unavailable"); + return; + } + + var operationErrorCount = context.State.Errors.Count; + var isWebSocket = _operationWall.Check(operation, location, context.State.Errors); + if (string.IsNullOrWhiteSpace(operation.OperationId)) + { + context.State.Errors.Add(location, "operationId is required"); + return; + } + + var operationId = operation.OperationId; + if (!context.TryRegisterOperationId(operationId)) + { + context.State.Errors.Add(location, $"duplicate operationId '{operationId}'"); + return; + } + + var identity = _identityParser.Parse(operationId, path, location, context.State.Errors); + if (identity is null) + { + return; + } + + var root = _keys.Root($"op:{operationId}"); + var parameters = _parameters.Project(operation, rawOperation, root, context.State); + _pathTemplates.Check(path, parameters, location, context.State.Errors); + var requestBody = _requestBodies.Project(operation.RequestBody, root, context.State); + var responses = _responses.Project(operation.Responses, root, context.State); + if (context.State.Errors.Count != operationErrorCount) + { + return; + } + + context.Add(new SpecOperation + { + OperationId = operationId, + Surface = identity.Surface, + Segments = identity.Segments, + Method = normalizedMethod, + Path = path, + HasWildcardPath = identity.HasWildcardPath, + IsWebSocket = isWebSocket, + IsSse = responses.Any(static response => response.IsSse), + IsDeprecated = operation.Deprecated, + Summary = operation.Summary, + Description = operation.Description, + Parameters = parameters, + RequestBody = requestBody, + Responses = responses, + RawContentHash = string.Empty, + }); + } +} diff --git a/tools/OpenCode.Sdk.Tools/Generator/Ingestion/Projection/ParameterProjector.cs b/tools/OpenCode.Sdk.Tools/Generator/Ingestion/Projection/ParameterProjector.cs new file mode 100644 index 00000000..de56116f --- /dev/null +++ b/tools/OpenCode.Sdk.Tools/Generator/Ingestion/Projection/ParameterProjector.cs @@ -0,0 +1,93 @@ +using System.Text.Json.Nodes; +using Microsoft.OpenApi; +using OpenCode.Sdk.Tools.Generator.Ingestion.Models; +using OpenCode.Sdk.Tools.Generator.Ingestion.Walls; + +namespace OpenCode.Sdk.Tools.Generator.Ingestion.Projection; + +internal sealed class ParameterProjector +{ + private readonly GraphKeyBuilder _keys; + private readonly SchemaProjector _schemaProjector; + private readonly ParameterWallPolicy _wall; + + public ParameterProjector(SchemaProjector schemaProjector, GraphKeyBuilder keys, ParameterWallPolicy wall) + { + _schemaProjector = schemaProjector ?? throw new ArgumentNullException(nameof(schemaProjector)); + _keys = keys ?? throw new ArgumentNullException(nameof(keys)); + _wall = wall ?? throw new ArgumentNullException(nameof(wall)); + } + + public IReadOnlyList Project(OpenApiOperation operation, JsonObject rawOperation, string root, ProjectionState state) + { + ArgumentNullException.ThrowIfNull(operation); + ArgumentNullException.ThrowIfNull(rawOperation); + ArgumentException.ThrowIfNullOrWhiteSpace(root); + ArgumentNullException.ThrowIfNull(state); + + if (operation.Parameters is not { } parameters) + { + return Array.AsReadOnly(Array.Empty()); + } + + if (rawOperation["parameters"] is not JsonArray rawParameters || rawParameters.Count != parameters.Count) + { + state.Errors.Add(root, "raw operation parameters did not match the typed operation"); + return Array.AsReadOnly(Array.Empty()); + } + + var projected = new List(parameters.Count); + var identities = new HashSet<(string Name, ParameterLocation Location)>(); + for (var index = 0; index < parameters.Count; index++) + { + var pointer = _keys.Append(_keys.Append(string.Empty, "parameters"), index.ToString(System.Globalization.CultureInfo.InvariantCulture)); + var location = string.Concat(root, pointer); + if (rawParameters[index] is not JsonObject rawParameter) + { + state.Errors.Add(location, "raw parameter was not an object"); + continue; + } + + var parameter = parameters[index]; + var errorCount = state.Errors.Count; + var isDeepObject = _wall.Check(parameter, rawParameter, location, state.Errors); + if (string.IsNullOrWhiteSpace(parameter.Name)) + { + state.Errors.Add(location, "parameter name is required"); + } + + if (parameter.In is not ParameterLocation.Path and not ParameterLocation.Query) + { + continue; + } + + if (!identities.Add((parameter.Name ?? string.Empty, parameter.In.Value))) + { + state.Errors.Add(location, $"duplicate parameter '{parameter.Name}' in '{parameter.In.Value.ToString().ToLowerInvariant()}'"); + } + + if (parameter.Schema is null) + { + state.Errors.Add(location, "parameter schema is required"); + continue; + } + + var schema = _schemaProjector.Project(parameter.Schema, root, _keys.Append(pointer, "schema"), state); + if (schema is null || state.Errors.Count != errorCount) + { + continue; + } + + projected.Add(new SpecParameter + { + Name = parameter.Name!, + Location = parameter.In is ParameterLocation.Path ? SpecParameterLocation.Path : SpecParameterLocation.Query, + Schema = schema, + IsRequired = parameter.Required, + IsDeepObject = isDeepObject, + }); + } + + return Array.AsReadOnly([.. projected]); + } +} diff --git a/tools/OpenCode.Sdk.Tools/Generator/Ingestion/Projection/PathTemplateValidator.cs b/tools/OpenCode.Sdk.Tools/Generator/Ingestion/Projection/PathTemplateValidator.cs new file mode 100644 index 00000000..aa5decbf --- /dev/null +++ b/tools/OpenCode.Sdk.Tools/Generator/Ingestion/Projection/PathTemplateValidator.cs @@ -0,0 +1,75 @@ +using OpenCode.Sdk.Tools.Generator.Ingestion.Models; + +namespace OpenCode.Sdk.Tools.Generator.Ingestion.Projection; + +internal sealed class PathTemplateValidator +{ + private readonly StringComparer _comparer = StringComparer.Ordinal; + + public void Check(string path, IReadOnlyList parameters, string location, IngestionErrorCollector errors) + { + ArgumentException.ThrowIfNullOrWhiteSpace(path); + ArgumentNullException.ThrowIfNull(parameters); + ArgumentException.ThrowIfNullOrWhiteSpace(location); + ArgumentNullException.ThrowIfNull(errors); + + var tokens = ReadTokens(path, location, errors); + var declared = parameters + .Where(static parameter => parameter.Location is SpecParameterLocation.Path) + .Select(static parameter => parameter.Name) + .ToHashSet(_comparer); + + foreach (var token in tokens.Where(token => !declared.Contains(token)).Order(_comparer)) + { + errors.Add(location, $"path token '{token}' is not declared as a path parameter"); + } + + foreach (var name in declared.Where(name => !tokens.Contains(name)).Order(_comparer)) + { + errors.Add(location, $"path parameter '{name}' is absent from the path template"); + } + } + + private static HashSet ReadTokens(string path, string location, IngestionErrorCollector errors) + { + var tokens = new HashSet(StringComparer.Ordinal); + var index = 0; + while (index < path.Length) + { + var open = path.IndexOf('{', index, StringComparison.Ordinal); + var closeBeforeOpen = path.IndexOf('}', index, StringComparison.Ordinal); + if (closeBeforeOpen >= 0 && (open < 0 || closeBeforeOpen < open)) + { + errors.Add(location, $"path '{path}' contains an unmatched closing brace"); + index = closeBeforeOpen + 1; + continue; + } + + if (open < 0) + { + break; + } + + var close = path.IndexOf('}', open + 1, StringComparison.Ordinal); + if (close < 0) + { + errors.Add(location, $"path '{path}' contains an unmatched opening brace"); + break; + } + + var token = path[(open + 1)..close]; + if (token.Length == 0 || token.Contains('{', StringComparison.Ordinal)) + { + errors.Add(location, $"path '{path}' contains a malformed template token"); + } + else + { + tokens.Add(token); + } + + index = close + 1; + } + + return tokens; + } +} diff --git a/tools/OpenCode.Sdk.Tools/Generator/Ingestion/Projection/RequestBodyProjector.cs b/tools/OpenCode.Sdk.Tools/Generator/Ingestion/Projection/RequestBodyProjector.cs new file mode 100644 index 00000000..5e9fcc64 --- /dev/null +++ b/tools/OpenCode.Sdk.Tools/Generator/Ingestion/Projection/RequestBodyProjector.cs @@ -0,0 +1,65 @@ +using Microsoft.OpenApi; +using OpenCode.Sdk.Tools.Generator.Ingestion.Models; +using OpenCode.Sdk.Tools.Generator.Ingestion.Walls; + +namespace OpenCode.Sdk.Tools.Generator.Ingestion.Projection; + +internal sealed class RequestBodyProjector +{ + private readonly GraphKeyBuilder _keys; + private readonly MediaTypeProjector _mediaTypes; + private readonly SchemaProjector _schemaProjector; + private readonly RequestBodyWallPolicy _wall; + + public RequestBodyProjector(SchemaProjector schemaProjector, GraphKeyBuilder keys, RequestBodyWallPolicy wall, MediaTypeProjector mediaTypes) + { + _schemaProjector = schemaProjector ?? throw new ArgumentNullException(nameof(schemaProjector)); + _keys = keys ?? throw new ArgumentNullException(nameof(keys)); + _wall = wall ?? throw new ArgumentNullException(nameof(wall)); + _mediaTypes = mediaTypes ?? throw new ArgumentNullException(nameof(mediaTypes)); + } + + public SpecRequestBody? Project(IOpenApiRequestBody? requestBody, string root, ProjectionState state) + { + ArgumentException.ThrowIfNullOrWhiteSpace(root); + ArgumentNullException.ThrowIfNull(state); + + if (requestBody is null) + { + return null; + } + + var pointer = _keys.Append(string.Empty, "requestBody"); + var location = string.Concat(root, pointer); + var errorCount = state.Errors.Count; + if (!_wall.Check(requestBody, location, state.Errors) || requestBody.Content is not { Count: 1 } content) + { + return null; + } + + var mediaEntry = content.Single(); + var mediaPointer = _keys.Append(_keys.Append(pointer, "content"), mediaEntry.Key); + var mediaLocation = string.Concat(root, mediaPointer); + var media = _mediaTypes.Project(mediaEntry.Key, mediaEntry.Value, mediaLocation, state.Errors); + if (media is null) + { + return null; + } + + if (mediaEntry.Value.Schema is null) + { + state.Errors.Add(mediaLocation, "request body media schema is required"); + return null; + } + + var schema = _schemaProjector.Project(mediaEntry.Value.Schema, root, _keys.Append(mediaPointer, "schema"), state); + return schema is null || state.Errors.Count != errorCount + ? null + : new SpecRequestBody + { + ContentType = media.ContentType, + Schema = schema, + IsRequired = requestBody.Required, + }; + } +} diff --git a/tools/OpenCode.Sdk.Tools/Generator/Ingestion/Projection/ResponseProjector.cs b/tools/OpenCode.Sdk.Tools/Generator/Ingestion/Projection/ResponseProjector.cs new file mode 100644 index 00000000..7610315c --- /dev/null +++ b/tools/OpenCode.Sdk.Tools/Generator/Ingestion/Projection/ResponseProjector.cs @@ -0,0 +1,125 @@ +using System.Globalization; +using Microsoft.OpenApi; +using OpenCode.Sdk.Tools.Generator.Ingestion.Models; +using OpenCode.Sdk.Tools.Generator.Ingestion.Walls; + +namespace OpenCode.Sdk.Tools.Generator.Ingestion.Projection; + +internal sealed class ResponseProjector +{ + private readonly EnvelopeClassifier _envelopes; + private readonly GraphKeyBuilder _keys; + private readonly MediaTypeProjector _mediaTypes; + private readonly SchemaProjector _schemaProjector; + private readonly ResponseWallPolicy _wall; + + public ResponseProjector(SchemaProjector schemaProjector, GraphKeyBuilder keys, ResponseWallPolicy wall, MediaTypeProjector mediaTypes, + EnvelopeClassifier envelopes) + { + _schemaProjector = schemaProjector ?? throw new ArgumentNullException(nameof(schemaProjector)); + _keys = keys ?? throw new ArgumentNullException(nameof(keys)); + _wall = wall ?? throw new ArgumentNullException(nameof(wall)); + _mediaTypes = mediaTypes ?? throw new ArgumentNullException(nameof(mediaTypes)); + _envelopes = envelopes ?? throw new ArgumentNullException(nameof(envelopes)); + } + + public IReadOnlyList Project(OpenApiResponses? responses, string root, ProjectionState state) + { + ArgumentException.ThrowIfNullOrWhiteSpace(root); + ArgumentNullException.ThrowIfNull(state); + + if (responses is null) + { + return Array.AsReadOnly(Array.Empty()); + } + + var parsed = new List>(responses.Count); + var statusCodes = new HashSet(); + foreach (var (key, response) in responses) + { + var keyLocation = string.Concat(root, _keys.Append(_keys.Append(string.Empty, "responses"), key)); + if (!int.TryParse(key, NumberStyles.None, CultureInfo.InvariantCulture, out var statusCode)) + { + state.Errors.Add(keyLocation, $"response key '{key}' must be an integer status code"); + continue; + } + + if (!statusCodes.Add(statusCode)) + { + state.Errors.Add(keyLocation, $"response status code '{statusCode.ToString(CultureInfo.InvariantCulture)}' is duplicated"); + continue; + } + + parsed.Add(new KeyValuePair(statusCode, response)); + } + + var projected = new List(parsed.Count); + foreach (var entry in parsed.OrderBy(static entry => entry.Key)) + { + var response = ProjectResponse(entry.Key, entry.Value, root, state); + if (response is not null) + { + projected.Add(response); + } + } + + return Array.AsReadOnly([.. projected]); + } + + private SpecResponse? ProjectResponse(int statusCode, IOpenApiResponse response, string root, ProjectionState state) + { + var status = statusCode.ToString(CultureInfo.InvariantCulture); + var pointer = _keys.Append(_keys.Append(string.Empty, "responses"), status); + var location = string.Concat(root, pointer); + var errorCount = state.Errors.Count; + if (!_wall.Check(response, location, state.Errors)) + { + return null; + } + + if (response.Content is not { Count: 1 } content) + { + return state.Errors.Count != errorCount + ? null + : new SpecResponse + { + StatusCode = statusCode, + Description = response.Description, + ContentType = null, + Schema = null, + EnvelopeShape = _envelopes.Classify(contentType: null, schema: null, location, state.Errors), + IsSse = false, + EffectStreamJson = null, + }; + } + + var mediaEntry = content.Single(); + var mediaPointer = _keys.Append(_keys.Append(pointer, "content"), mediaEntry.Key); + var mediaLocation = string.Concat(root, mediaPointer); + var media = _mediaTypes.Project(mediaEntry.Key, mediaEntry.Value, mediaLocation, state.Errors); + if (media is null) + { + return null; + } + + SchemaNode? schema = null; + if (mediaEntry.Value.Schema is not null) + { + schema = _schemaProjector.Project(mediaEntry.Value.Schema, root, _keys.Append(mediaPointer, "schema"), state); + } + + var envelope = _envelopes.Classify(media.ContentType, mediaEntry.Value.Schema, location, state.Errors); + return state.Errors.Count != errorCount + ? null + : new SpecResponse + { + StatusCode = statusCode, + Description = response.Description, + ContentType = media.ContentType, + Schema = schema, + EnvelopeShape = envelope, + IsSse = media.ContentType.IsEventStream, + EffectStreamJson = media.EffectStreamJson, + }; + } +} diff --git a/tools/OpenCode.Sdk.Tools/Generator/Ingestion/Walls/DocumentWallPolicy.cs b/tools/OpenCode.Sdk.Tools/Generator/Ingestion/Walls/DocumentWallPolicy.cs new file mode 100644 index 00000000..b08914d6 --- /dev/null +++ b/tools/OpenCode.Sdk.Tools/Generator/Ingestion/Walls/DocumentWallPolicy.cs @@ -0,0 +1,33 @@ +using Microsoft.OpenApi; + +namespace OpenCode.Sdk.Tools.Generator.Ingestion.Walls; + +internal sealed class DocumentWallPolicy +{ + private readonly HostMemberWhitelist _componentMembers = new("components", [nameof(OpenApiComponents.Schemas)]); + + private readonly HostMemberWhitelist _documentMembers = new("document", + [ + nameof(OpenApiDocument.BaseUri), + nameof(OpenApiDocument.Components), + nameof(OpenApiDocument.Info), + nameof(OpenApiDocument.Metadata), + nameof(OpenApiDocument.Paths), + nameof(OpenApiDocument.Security), + nameof(OpenApiDocument.Self), + nameof(OpenApiDocument.Tags), + nameof(OpenApiDocument.Workspace), + ]); + + public void Check(OpenApiDocument document, IngestionErrorCollector errors) + { + ArgumentNullException.ThrowIfNull(document); + ArgumentNullException.ThrowIfNull(errors); + + _documentMembers.Check(document, "document", errors); + if (document.Components is not null) + { + _componentMembers.Check(document.Components, "document/components", errors); + } + } +} diff --git a/tools/OpenCode.Sdk.Tools/Generator/Ingestion/Walls/HostMemberWhitelist.cs b/tools/OpenCode.Sdk.Tools/Generator/Ingestion/Walls/HostMemberWhitelist.cs new file mode 100644 index 00000000..3442cd14 --- /dev/null +++ b/tools/OpenCode.Sdk.Tools/Generator/Ingestion/Walls/HostMemberWhitelist.cs @@ -0,0 +1,77 @@ +using System.Collections; +using System.Collections.Frozen; +using System.Reflection; + +namespace OpenCode.Sdk.Tools.Generator.Ingestion.Walls; + +internal sealed class HostMemberWhitelist + where T : class, new() +{ + private readonly T _defaultValue = new(); + private readonly FrozenSet _exemptMembers; + private readonly string _hostName; + private readonly PropertyInfo[] _members; + + public HostMemberWhitelist(string hostName, IEnumerable exemptMembers) + { + ArgumentException.ThrowIfNullOrWhiteSpace(hostName); + ArgumentNullException.ThrowIfNull(exemptMembers); + + _hostName = hostName; + _exemptMembers = exemptMembers.ToFrozenSet(StringComparer.Ordinal); + _members = + [ + .. typeof(T) + .GetProperties(BindingFlags.Instance | BindingFlags.Public) + .OrderBy(static property => property.Name, StringComparer.Ordinal), + ]; + } + + public void Check(T value, string location, IngestionErrorCollector errors) + { + ArgumentNullException.ThrowIfNull(value); + ArgumentException.ThrowIfNullOrWhiteSpace(location); + ArgumentNullException.ThrowIfNull(errors); + + foreach (var member in _members) + { + if (_exemptMembers.Contains(member.Name) || !IsPopulated(member, value)) + { + continue; + } + + errors.Add(location, $"{_hostName} member '{GetWireName(member.Name)}' is not supported"); + } + } + + private static string GetWireName(string memberName) => + $"{char.ToLowerInvariant(memberName[0])}{memberName[1..]}"; + + private static bool IsCollection(Type type) => + type != typeof(string) + && typeof(IEnumerable).IsAssignableFrom(type); + + private bool IsPopulated(PropertyInfo member, T value) + { + var memberValue = member.GetValue(value); + if (memberValue is null || Equals(memberValue, member.GetValue(_defaultValue))) + { + return false; + } + + if (!IsCollection(member.PropertyType)) + { + return true; + } + + var enumerator = ((IEnumerable)memberValue).GetEnumerator(); + try + { + return enumerator.MoveNext(); + } + finally + { + (enumerator as IDisposable)?.Dispose(); + } + } +} diff --git a/tools/OpenCode.Sdk.Tools/Generator/Ingestion/Walls/MediaTypeWallPolicy.cs b/tools/OpenCode.Sdk.Tools/Generator/Ingestion/Walls/MediaTypeWallPolicy.cs new file mode 100644 index 00000000..c9f76f15 --- /dev/null +++ b/tools/OpenCode.Sdk.Tools/Generator/Ingestion/Walls/MediaTypeWallPolicy.cs @@ -0,0 +1,59 @@ +using Microsoft.OpenApi; +using OpenCode.Sdk.Tools.Generator.Ingestion.Models; + +namespace OpenCode.Sdk.Tools.Generator.Ingestion.Walls; + +internal sealed class MediaTypeWallPolicy +{ + private readonly HostMemberWhitelist _members = new("media type", + [ + nameof(OpenApiMediaType.Extensions), + nameof(OpenApiMediaType.Schema), + ]); + + public string? Check(IOpenApiMediaType media, SpecMediaType contentType, string location, IngestionErrorCollector errors) + { + ArgumentNullException.ThrowIfNull(media); + ArgumentNullException.ThrowIfNull(contentType); + ArgumentException.ThrowIfNullOrWhiteSpace(location); + ArgumentNullException.ThrowIfNull(errors); + + if (media is not OpenApiMediaType concrete) + { + errors.Add(location, $"media type implementation '{media.GetType().Name}' is not supported"); + return null; + } + + _members.Check(concrete, location, errors); + string? effectStreamJson = null; + if (concrete.Extensions is null) + { + return effectStreamJson; + } + + foreach (var (name, extension) in concrete.Extensions.OrderBy(static pair => pair.Key, StringComparer.Ordinal)) + { + if (string.Equals(name, "x-effect-stream", StringComparison.Ordinal) && contentType.IsEventStream) + { + if (extension is JsonNodeExtension json) + { + effectStreamJson = json.Node.ToJsonString(); + } + else + { + errors.Add(location, "media extension 'x-effect-stream' must contain JSON"); + } + } + else if (string.Equals(name, "x-effect-stream", StringComparison.Ordinal)) + { + errors.Add(location, "media extension 'x-effect-stream' is supported only on text/event-stream"); + } + else + { + errors.Add(location, $"media extension '{name}' is not supported"); + } + } + + return effectStreamJson; + } +} diff --git a/tools/OpenCode.Sdk.Tools/Generator/Ingestion/Walls/OperationWallPolicy.cs b/tools/OpenCode.Sdk.Tools/Generator/Ingestion/Walls/OperationWallPolicy.cs new file mode 100644 index 00000000..9d96e42f --- /dev/null +++ b/tools/OpenCode.Sdk.Tools/Generator/Ingestion/Walls/OperationWallPolicy.cs @@ -0,0 +1,56 @@ +using System.Text.Json.Nodes; +using Microsoft.OpenApi; + +namespace OpenCode.Sdk.Tools.Generator.Ingestion.Walls; + +internal sealed class OperationWallPolicy +{ + private readonly HostMemberWhitelist _members = new("operation", + [ + nameof(OpenApiOperation.Deprecated), + nameof(OpenApiOperation.Description), + nameof(OpenApiOperation.Extensions), + nameof(OpenApiOperation.Metadata), + nameof(OpenApiOperation.OperationId), + nameof(OpenApiOperation.Parameters), + nameof(OpenApiOperation.RequestBody), + nameof(OpenApiOperation.Responses), + nameof(OpenApiOperation.Security), + nameof(OpenApiOperation.Summary), + nameof(OpenApiOperation.Tags), + ]); + + public bool Check(OpenApiOperation operation, string location, IngestionErrorCollector errors) + { + ArgumentNullException.ThrowIfNull(operation); + ArgumentException.ThrowIfNullOrWhiteSpace(location); + ArgumentNullException.ThrowIfNull(errors); + + _members.Check(operation, location, errors); + var isWebSocket = false; + if (operation.Extensions is null) + { + return isWebSocket; + } + + foreach (var (name, extension) in operation.Extensions.OrderBy(static pair => pair.Key, StringComparer.Ordinal)) + { + switch (name) + { + case "x-codeSamples": + break; + case "x-websocket" when extension is JsonNodeExtension { Node: JsonValue value } && value.TryGetValue(out var flag): + isWebSocket = flag; + break; + case "x-websocket": + errors.Add(location, "operation extension 'x-websocket' must be a boolean"); + break; + default: + errors.Add(location, $"operation extension '{name}' is not supported"); + break; + } + } + + return isWebSocket; + } +} diff --git a/tools/OpenCode.Sdk.Tools/Generator/Ingestion/Walls/ParameterWallPolicy.cs b/tools/OpenCode.Sdk.Tools/Generator/Ingestion/Walls/ParameterWallPolicy.cs new file mode 100644 index 00000000..11105bfa --- /dev/null +++ b/tools/OpenCode.Sdk.Tools/Generator/Ingestion/Walls/ParameterWallPolicy.cs @@ -0,0 +1,63 @@ +using System.Text.Json.Nodes; +using Microsoft.OpenApi; + +namespace OpenCode.Sdk.Tools.Generator.Ingestion.Walls; + +internal sealed class ParameterWallPolicy +{ + private readonly HostMemberWhitelist _members = new("parameter", + [ + nameof(OpenApiParameter.Explode), + nameof(OpenApiParameter.In), + nameof(OpenApiParameter.Name), + nameof(OpenApiParameter.Required), + nameof(OpenApiParameter.Schema), + nameof(OpenApiParameter.Style), + ]); + + public bool Check(IOpenApiParameter parameter, JsonObject rawParameter, string location, IngestionErrorCollector errors) + { + ArgumentNullException.ThrowIfNull(parameter); + ArgumentNullException.ThrowIfNull(rawParameter); + ArgumentException.ThrowIfNullOrWhiteSpace(location); + ArgumentNullException.ThrowIfNull(errors); + + if (parameter is not OpenApiParameter concrete) + { + errors.Add(location, $"parameter implementation '{parameter.GetType().Name}' is not supported"); + return false; + } + + _members.Check(concrete, location, errors); + if (concrete.In is not ParameterLocation.Path and not ParameterLocation.Query) + { + errors.Add(location, $"parameter location '{GetLocationName(concrete.In)}' is not supported"); + } + + var hasStyle = rawParameter.ContainsKey("style"); + var hasExplode = rawParameter.ContainsKey("explode"); + switch (hasStyle) + { + case false when !hasExplode: + return false; + case true when hasExplode && concrete is { Style: ParameterStyle.DeepObject, Explode: true }: + return true; + default: + errors.Add(location, + $"parameter style '{GetStyleName(concrete.Style)}' with explode '{concrete.Explode}' is not supported; expected deepObject with true"); + return false; + } + } + + private static string GetLocationName(ParameterLocation? location) => location?.ToString().ToLowerInvariant() ?? ""; + + private static string GetStyleName(ParameterStyle? style) + { + if (style is null) + { + return ""; + } + + return style is ParameterStyle.DeepObject ? "deepObject" : style.Value.ToString().ToLowerInvariant(); + } +} diff --git a/tools/OpenCode.Sdk.Tools/Generator/Ingestion/Walls/PathItemWallPolicy.cs b/tools/OpenCode.Sdk.Tools/Generator/Ingestion/Walls/PathItemWallPolicy.cs new file mode 100644 index 00000000..30f868d5 --- /dev/null +++ b/tools/OpenCode.Sdk.Tools/Generator/Ingestion/Walls/PathItemWallPolicy.cs @@ -0,0 +1,67 @@ +using System.Collections.Frozen; +using Microsoft.OpenApi; + +namespace OpenCode.Sdk.Tools.Generator.Ingestion.Walls; + +internal sealed class PathItemWallPolicy +{ + private static readonly FrozenSet AdmittedMethods = new[] { "delete", "get", "patch", "post", "put", } + .ToFrozenSet(StringComparer.Ordinal); + + private readonly HostMemberWhitelist _members = new("path item", + [ + nameof(OpenApiPathItem.Operations), + nameof(OpenApiPathItem.Parameters), + nameof(OpenApiPathItem.Servers), + ]); + + public void Check(IOpenApiPathItem pathItem, string location, IngestionErrorCollector errors) + { + ArgumentNullException.ThrowIfNull(pathItem); + ArgumentException.ThrowIfNullOrWhiteSpace(location); + ArgumentNullException.ThrowIfNull(errors); + + if (pathItem is OpenApiPathItemReference) + { + errors.Add(location, "path item references are not supported"); + return; + } + + if (pathItem is not OpenApiPathItem concrete) + { + errors.Add(location, $"path item implementation '{pathItem.GetType().Name}' is not supported"); + return; + } + + _members.Check(concrete, location, errors); + if (concrete.Parameters is { Count: > 0 }) + { + errors.Add(location, "path-level parameters are not supported"); + } + + if (concrete.Servers is { Count: > 0 }) + { + errors.Add(location, "path-level servers are not supported"); + } + + if (concrete.Operations is null) + { + return; + } + + foreach (var method in concrete.Operations.Keys) + { + var normalized = method.Method.ToLowerInvariant(); + if (!IsAdmitted(method)) + { + errors.Add(location, $"path item method '{normalized}' is not supported"); + } + } + } + + public static bool IsAdmitted(HttpMethod method) + { + ArgumentNullException.ThrowIfNull(method); + return AdmittedMethods.Contains(method.Method.ToLowerInvariant()); + } +} diff --git a/tools/OpenCode.Sdk.Tools/Generator/Ingestion/Walls/RequestBodyWallPolicy.cs b/tools/OpenCode.Sdk.Tools/Generator/Ingestion/Walls/RequestBodyWallPolicy.cs new file mode 100644 index 00000000..1664a55f --- /dev/null +++ b/tools/OpenCode.Sdk.Tools/Generator/Ingestion/Walls/RequestBodyWallPolicy.cs @@ -0,0 +1,34 @@ +using Microsoft.OpenApi; + +namespace OpenCode.Sdk.Tools.Generator.Ingestion.Walls; + +internal sealed class RequestBodyWallPolicy +{ + private readonly HostMemberWhitelist _members = new("request body", + [ + nameof(OpenApiRequestBody.Content), + nameof(OpenApiRequestBody.Required), + ]); + + public bool Check(IOpenApiRequestBody requestBody, string location, IngestionErrorCollector errors) + { + ArgumentNullException.ThrowIfNull(requestBody); + ArgumentException.ThrowIfNullOrWhiteSpace(location); + ArgumentNullException.ThrowIfNull(errors); + + if (requestBody is not OpenApiRequestBody concrete) + { + errors.Add(location, $"request body implementation '{requestBody.GetType().Name}' is not supported"); + return false; + } + + _members.Check(concrete, location, errors); + if (concrete.Content is not { Count: 1 }) + { + errors.Add(location, "request body must contain exactly one media type"); + return false; + } + + return true; + } +} diff --git a/tools/OpenCode.Sdk.Tools/Generator/Ingestion/Walls/ResponseWallPolicy.cs b/tools/OpenCode.Sdk.Tools/Generator/Ingestion/Walls/ResponseWallPolicy.cs new file mode 100644 index 00000000..3cb6227e --- /dev/null +++ b/tools/OpenCode.Sdk.Tools/Generator/Ingestion/Walls/ResponseWallPolicy.cs @@ -0,0 +1,34 @@ +using Microsoft.OpenApi; + +namespace OpenCode.Sdk.Tools.Generator.Ingestion.Walls; + +internal sealed class ResponseWallPolicy +{ + private readonly HostMemberWhitelist _members = new("response", + [ + nameof(OpenApiResponse.Content), + nameof(OpenApiResponse.Description), + ]); + + public bool Check(IOpenApiResponse response, string location, IngestionErrorCollector errors) + { + ArgumentNullException.ThrowIfNull(response); + ArgumentException.ThrowIfNullOrWhiteSpace(location); + ArgumentNullException.ThrowIfNull(errors); + + if (response is not OpenApiResponse concrete) + { + errors.Add(location, $"response implementation '{response.GetType().Name}' is not supported"); + return false; + } + + _members.Check(concrete, location, errors); + if (concrete.Content is { Count: > 1 }) + { + errors.Add(location, "response content supports at most one media type"); + return false; + } + + return true; + } +} From 3e2139335794135e9706aa09e41aa785f905fb0e Mon Sep 17 00:00:00 2001 From: "denizirgin@gmail.com" Date: Tue, 11 Aug 2026 18:30:59 +0300 Subject: [PATCH 13/19] docs(spec): walking skeleton design and complexity checkpoint --- .../13-generator-complexity-checkpoint.md | 290 ++++++++ ...8-11-production-walking-skeleton-design.md | 627 ++++++++++++++++++ 2 files changed, 917 insertions(+) create mode 100644 docs/research/13-generator-complexity-checkpoint.md create mode 100644 docs/superpowers/specs/2026-08-11-production-walking-skeleton-design.md diff --git a/docs/research/13-generator-complexity-checkpoint.md b/docs/research/13-generator-complexity-checkpoint.md new file mode 100644 index 00000000..975f7766 --- /dev/null +++ b/docs/research/13-generator-complexity-checkpoint.md @@ -0,0 +1,290 @@ +# Generator complexity checkpoint: findings and delivery decision + +Date: 2026-08-11 + +This document records the completed checkpoint raised after Slice 1 Task 7. The concern was +that ingestion and SpecIR complexity had grown substantially before the repository produced any +SDK source. It captures the evidence gathered against the pinned spec, the reassessment of local +open-source generators, and the delivery direction selected from that evidence. + +Current execution state belongs in `docs/ROADMAP.md` and the active SDD ledger. This document is +the dated question -> finding -> decision record. + +## Q1: Was the complexity concern grounded? + +### How researched + +The checkpoint measured the branch at commit +`19349300e5133c6e629a23c60b7a1898dac80ff4`, inspected the generator production and test trees, +read the still-stubbed `generate` command, and compared the repository with upstream's published +JavaScript SDK build path. + +### Found + +- Since the Slice 1 preparation baseline (`c76a09b^..1934930`), the branch contained 136 changed + files and +8141 / -370 lines. +- `tools/OpenCode.Sdk.Tools/Generator/` contained 70 files and about 2869 non-blank production + lines, almost entirely under ingestion. Binder, EmitPlan, emitters, Writer, and generated SDK + output did not yet exist. +- Two late projection commits accounted for +4757 / -122 lines: + `c0d098d` added object/literal/union schema projection and `1934930` added per-host walls and + operation projection. +- Task 7 implementation existed at `1934930`, but its independent review and controller gate had + not run. The later absorption probes deliberately used that exact production tree without + changing it. +- Upstream's `packages/sdk/js/script/build.ts` is 119 handwritten lines, but it is not an + OpenAPI-to-TypeScript generator. It delegates parsing, IR, and emission to + `@hey-api/openapi-ts`, performs pre-generation document surgery, then applies assertion-guarded + post-generation patches. +- Upstream's small script and this repository's ingestion tree are therefore not equivalent + units. This repository had chosen to internalize work that upstream delegates in exchange for + immutable models, unknown-variant carriers, one AOT serializer registry, both API surfaces, + and exact public-shape control. + +### Decision + +The concern was valid. The upstream comparison did not prove that an own generator was wrong, +but the absence of user-visible SDK output made every assurance mechanism justify its cost. No +further horizontal architecture was allowed to land until real-spec absorption and an +off-the-shelf runtime check supplied evidence. + +## Q2: Is the repository rebuilding OpenAPI parsing? + +### How researched + +The Microsoft.OpenApi boundary, current projection code, prior reader probes, and the pinned +document's actual constructs were separated into syntax responsibilities and product-semantic +responsibilities. + +### Found + +Microsoft.OpenApi 3.9.0 owns: + +- JSON and OpenAPI container parsing into a typed DOM. +- Reference construction and resolution. +- Retention of unknown schema keywords and vendor extensions. +- Reader diagnostics and document loading. + +The repository's projection owns decisions that the reader cannot make for this SDK: + +| Concern | Why it is not parsing | +|---|---| +| Marked versus structural unions | Determines tagged converters versus structural representation for legal `anyOf` / `oneOf` shapes | +| Effect-tag and name/data error styles | Maps two legal object conventions into one typed error hierarchy | +| Response envelope shapes | Drives named typed response members rather than exposing raw wire wrappers | +| Modern versus legacy surface | Applies ADR-0005 product policy from operation IDs | +| Immutable, nullable-last-resort models | Applies the SDK's public API policy | +| `prefixItems` adapter | Recovers one standard JSON Schema 2020-12 keyword retained by the reader as an unrecognized keyword | +| `x-effect-stream` and `x-websocket` | Interprets opencode-specific protocol semantics | + +### Decision + +The repository is not maintaining a second JSON/OpenAPI parser. A stable semantic projection is +load-bearing, but this finding does not justify every current collaborator or every exhaustive +reader-DOM assurance wall. + +## Q3: Can the current production projection absorb the complete pin? + +### How researched + +A throwaway net10.0 console harness composed the production internals at commit `1934930` by +hand: `SpecReader`, `ProjectionState`, `SchemaProjector`, `GraphKeyBuilder`, and +`OperationProjector` with its current host policies. It ran against `spec/openapi.json` twice +from fresh loads and compared ordered identities, graph key/kind sequences, histograms, errors, +and semantic landmarks. + +The harness did not implement or modify production orchestration. It intentionally excluded the +future raw `$ref` sibling sweep, dangling-reference sweep, and hash persistence. Its raw-pointer +lookup was empty; the real `Config.plugin` tuple still projected because the `prefixItems` +adapter consumes the reader's retained unrecognized keyword. + +### Found + +| Metric | Result | +|---|---| +| OpenAPI version | 3.1.0 | +| Paths | 162 | +| Named component schemas | 472 | +| DOM operations | 188 | +| Reader errors / crashes | 0 / 0 | +| Projected operations | 188: 61 modern, 127 legacy | +| Missing DOM operation IDs | 0 | +| Missing named schemas | 0 | +| Projection errors / crashes | 0 / 0 | +| Graph nodes | 1501 | +| SSE / WebSocket operations | 4 / 1 | +| Semantic landmarks | 23 passed, 0 failed, 0 blocked | +| Fresh-run comparison | Exact match | + +The landmarks demonstrated semantics beyond a DOM echo: + +- Both API surfaces, SSE metadata, effect-stream payload retention, wildcard paths, and + WebSocket detection. +- `SessionDurableEvent` as a 28-branch marked `oneOf`, and `Config.formatter` as a structural + `anyOf`. +- `Config.plugin` as the one tuple reached through `prefixItems`. +- Effect-tag and name/data error styles. +- Bare, data, data/location, cursor/data, data/hasMore, and no-content envelopes. +- Unrestricted-schema and special-number sites. + +The projected pin contained 99 marked `anyOf` unions, 34 structural `anyOf` unions, and one +marked `oneOf`. It also contained 20 Effect-tag errors and 15 name/data errors. These are +generation decisions the Binder would otherwise repeatedly derive from the reader DOM. + +### Decision + +The absorption result was green: current production projection can represent the complete pin, +deterministically and without silent schema or operation loss. This validates the projection +boundary and its core vocabulary. It does not validate reflection-based host inventories, full +intermediate snapshots, document-wide pre-consumer hashes, or every existing test as the least +costly assurance policy. + +## Q4: Can an OSS/local generator replace the custom semantic layer? + +### How researched + +The earlier real-spec generation runs in `docs/research/08-codegen-spike.md` were rechecked +against current local/pinnable candidates. Kiota then received an additional generated-runtime +wire-fidelity probe using Microsoft.OpenApi.Kiota 1.34.1, Microsoft.Kiota.Bundle 2.0.0, and a +net10.0 recording HTTP handler. Refitter 2.1.3 was inspected from its tagged source because it +adds immutable records and a generated `JsonSerializerContext` on top of NSwag. + +### Found + +The prior generation results still held: + +| Generator | Blocking result on this pin | +|---|---| +| Kiota 1.34.1 | Intersection wrappers require an OpenAPI discriminator that the pin does not have; no System.Text.Json source-generation model | +| NSwag 14.7.1 | Discriminator-free unions collapse or mis-shape; output also had independent compile and OpenAPI 3.1 interpretation failures | +| OpenAPI Generator 7.24.0 | Speculative unions, hundreds of contexts, collisions, reflection fallback, and output that did not compile | +| Refitter 2.1.3 | NSwag 14.7.1 underneath; union rewriting requires `DiscriminatorObject` | + +Refitter can emit immutable records and a serializer context, but those features do not discover +opencode's single-value-enum marker convention. Supplying synthetic discriminators would require +a semantic preprocessor and custom unknown-carrier/error/public-surface work, recreating the +boundary the replacement was meant to remove. + +Kiota's generation log contained 24 named-schema no-discriminator warnings plus one inline-route +warning, and 91 failures to create declared error types. The harness itself built with zero +warnings and errors; the failures below are generated-runtime behavior, not harness compilation +problems. + +| Runtime probe | Result | +|---|---| +| Simple health response | Preserved request and typed values | +| Deliberately selected outbound `TextPart` branch | PATCH path and request JSON preserved | +| Valid inbound `TextPart` | `type` disappeared; identity fields populated `AgentPart`, text populated `ReasoningPart`, and `TextPart` stayed empty | +| Unknown `Part` tag | Tag and unknown raw field disappeared on round trip | +| Valid `SessionDurableEvent` | No branch selected; the complete payload was lost | +| Declared 400 body | Generic `ApiException`; body unavailable because no error factory was generated | +| SSE transport | Bytes preserved exactly, but exposed only as `Stream` | + +### Decision + +No OSS/local candidate can be adopted as-is without violating the locked wire, unknown-variant, +error, AOT, or public-surface contracts. A delegate-and-patch route would need a semantic +preprocessor and substantial generated-runtime replacement, so it is not a smaller bounded +adaptation. The own Microsoft.OpenApi -> SpecIR -> Binder -> Roslyn path remains selected. + +## Q5: Which assurance mechanisms are actually justified? + +### Found + +The two probes separate correctness machinery from assurance policy: + +- Semantic projection is necessary: raw Kiota lost known and unknown union data and typed error + bodies, while the production projection represented every pinned operation and schema. +- The absorption run included the reflection host walls, so it proves compatibility with the pin, + not that those walls caused the semantic result or are safe to delete without replacement. +- Reflection host walls enforce an assurance policy. Their consumer/deletion audit must retain + explicit checks wherever removing one would otherwise permit wrong emitted behavior. +- A full DOM default inventory reports library shape changes whether or not they affect emitted + behavior. +- A full SpecIR snapshot would duplicate the generated source that consumers actually review. +- Raw hashes for every operation and schema have no current consumer. Fingerprints become useful + when an operation is deliberately excluded or hand-wired. +- Git diffs cannot reveal silently ignored constructs, so deleting exhaustive walls does not + justify best-effort generation. Known lossy seams still need explicit validators and landmarks. + +### Decision + +Generation remains fail-closed by semantic risk. The generator refuses any selected construct +that could change emitted wire or public behavior, while descriptive unused reader members and +unknown non-semantic extensions do not fail merely because they exist. + +Drift detection uses the pinned-spec diff and committed generated-source diff as the primary +radar, backed by targeted gates for reader/version diagnostics, `$ref` siblings and dangling +references, `prefixItems`, Microsoft.OpenApi boundary leakage, deterministic graph keys, +union classification, unrestricted schemas, stream metadata, and both API surfaces. + +The selected design replaces or defers the generic reflection `HostMemberWhitelist`, complete +DOM/default inventory, full SpecIR snapshot, pre-consumer document-wide hashes, and fatal +treatment of every unknown extension. Each persistent SpecIR fact and validation mechanism must +name an emitter, Binder, runtime, refresh, or standing contract consumer. + +## Q6: How should delivery change? + +### Found + +The original horizontal sequence delayed the first callable client until after broad ingestion, +binding, model emission, transport, and operation work. Even if each layer were correct, that +sequence amplified integration and review risk because no production SDK call exercised the +complete seam early. + +`v2.health.get` supplies the minimal control path. `v2.session.message` supplies a representative +hard path with bound-client placement, two path parameters, a data envelope, nested marked +unions, and declared typed errors. Together they exercise the final architecture without +requiring retry, telemetry, DI, launcher, or streams. + +### Decision + +Slice 1 closes as a lean ingestion/SpecIR module. The first callable SDK phase then lands as two +independently mergeable production arcs: + +1. Selected compiler and committed models for exactly `v2.health.get` and + `v2.session.message`. +2. Callable generated clients over the minimal hand-written transport, response, request-option, + and typed-error core. + +Generated output is committed under `src/OpenCode.Sdk`. Pending operations are reported as +build-out state, not product exclusions, and a machine-owned marker makes `dotnet pack` fail while +the profile is partial. Later breadth proceeds in bounded vertical operation batches, each with +its reachable models, operation methods, and contract tests; there is no separate orphan-model +horizontal milestone. + +The detailed architecture, public inventory, verification matrix, stop conditions, and required +decision-document cascade are canonical in +`docs/superpowers/specs/2026-08-11-production-walking-skeleton-design.md`. + +## Sources + +### Repository sources + +- `spec/openapi.json` - pinned OpenAPI artifact used by both probes. +- `external/opencode/packages/sdk/js/script/build.ts` - upstream delegate-and-patch build script. +- `external/opencode/packages/sdk/js/package.json` - pinned `@hey-api/openapi-ts` dependency. +- `external/opencode/packages/sdk/js/src/v2/client.ts` - generated client consumption and thin + handwritten wrapper. +- `docs/research/08-codegen-spike.md` - Kiota, NSwag, OpenAPI Generator, and own-emitter run + evidence. +- `docs/research/00-research-log.md` Q56-Q59 and Q61 - Microsoft.OpenApi boundary and + complexity-grill evidence. +- `docs/adr/0003-model-layer-codegen.md` - own generator, Microsoft.OpenApi reader, semantic + projection, and Roslyn emission. +- `docs/superpowers/specs/2026-08-11-production-walking-skeleton-design.md` - selected delivery + design. +- Commit `1934930` - exact production ingestion implementation used by the absorption probe. + +### External primary sources + +- [Refitter 2.1.3 release](https://github.com/christianhelle/refitter/releases/tag/2.1.3). +- [Refitter 2.1.3 core project][refitter-core] - pins NSwag 14.7.1. +- [Refitter 2.1.3 discriminator mutator][refitter-mutator] - exits when + `DiscriminatorObject` is absent. +- [Refitter 2.1.3 serializer-context polymorphism test][refitter-context] - confirms the context + feature against an explicit OpenAPI discriminator. + +[refitter-core]: https://github.com/christianhelle/refitter/blob/2.1.3/src/Refitter.Core/Refitter.Core.csproj +[refitter-mutator]: https://github.com/christianhelle/refitter/blob/2.1.3/src/Refitter.Core/Mutators/OneOfDiscriminatorToAllOfMutator.cs +[refitter-context]: https://github.com/christianhelle/refitter/blob/2.1.3/src/Refitter.Tests/Scenarios/GenerateJsonSerializerContextPolymorphismTests.cs diff --git a/docs/superpowers/specs/2026-08-11-production-walking-skeleton-design.md b/docs/superpowers/specs/2026-08-11-production-walking-skeleton-design.md new file mode 100644 index 00000000..1e42c2b8 --- /dev/null +++ b/docs/superpowers/specs/2026-08-11-production-walking-skeleton-design.md @@ -0,0 +1,627 @@ +# Production walking skeleton: first callable SDK milestone + +Date: 2026-08-11 + +## Decision + +The SDK build-out will stop sequencing ingestion, binding, emission, transport, and the +operation surface as five broad horizontal stages before the first callable client. Slice 1 +will close as a lean semantic-ingestion module, then two bounded, independently mergeable +production arcs will carry two modern operations through the final pipeline and commit their +generated SDK surface under `src/OpenCode.Sdk`: + +- `v2.health.get` — the simple control path. +- `v2.session.message` — the representative hard path: a bound session handle, two path + parameters, a `{data}` success envelope, an eight-variant marked union, a content array whose + items form a three-variant marked union, a nested status-marked tool-state union, and declared + 400/401/404 errors. + +This is production code, not a throwaway prototype. Every interface and public name starts in +its final architectural role. Later milestones extend the supported operation and schema set; +they do not replace a temporary pipeline or rename the first public surface. + +The generator remains our own Roslyn-emission generator over Microsoft.OpenApi. The ingestion +policy changes from exhaustive DOM-member surveillance to semantic-risk fail-closed validation: +generation refuses when an unsupported construct can change emitted wire or public behavior, +not merely because the reader exposes metadata the generator does not consume. Pinned-spec and +generated-source diffs become the primary drift radar, backed by targeted semantic and runtime +guards. + +## Evidence + +The decision follows two independent real-spec probes: + +1. The current ingestion implementation absorbed the complete pin: Microsoft.OpenApi loaded + 162 paths, 472 named schemas, and 188 operations with zero reader errors; projection covered + all 188 operations and all 472 named schemas with zero projection errors; 23 semantic + landmarks passed; two fresh runs were identical. +2. Raw Kiota 1.34.1 generated correct basic HTTP plumbing but failed wire fidelity for the + semantics this SDK promises. A valid `TextPart` response lost its `type` marker and scattered + fields across unrelated branch objects; a valid `SessionDurableEvent` selected no branch and + lost the complete payload; an unknown part lost its tag and raw fields; a declared 400 body + became a generic body-less `ApiException`. A deliberately constructed outbound `TextPart`, a + simple health response, and raw SSE bytes were preserved. + +The probes establish two different conclusions. A semantic projection is load-bearing; the +generic C# generators do not preserve this pin's discriminator-free literal-marker unions. +Exhaustive reflection walls, default-value inventories, full intermediate snapshots, and +pre-consumer hashing are assurance-policy choices, not prerequisites for correct SDK output. + +## Goals + +- Put a callable, generated `OpenCode.Sdk` surface on the main development line after two + bounded production arcs rather than after four broad horizontal slices. +- Exercise the actual seam chain: pinned spec → SpecIR → Binder → EmitPlan → Roslyn emitters → + committed source → transport → generated method. +- Validate both the easy HTTP path and the highest-risk union/envelope/error path before broad + generator implementation continues. +- Preserve the final public API rules: immutable generated models, required mirroring, + source-generated System.Text.Json metadata, explicit unknown-variant carriers, typed response + envelopes, typed errors, throw-by-default, and per-call `NoThrow`. +- Make later work consumer-pulled: add an ingestion fact, EmitPlan decision, runtime behavior, or + test only when a selected operation or an agreed standing contract consumes it. +- Keep generated output extend-only. Expanding scope adds public types and methods without + changing the first milestone's names or behavior contracts. + +## Non-goals + +- Publishing a NuGet package from the partial milestone. +- Supporting all 61 modern and 127 legacy operations in the first callable milestone. +- Retry, telemetry, per-attempt hooks, Extensions DI, launcher, SSE parsing, or real-process + integration. +- Replacing Microsoft.OpenApi, SpecIR, the Binder/EmitPlan split, or Roslyn emission. +- Adopting Kiota, Refitter, NSwag, OpenAPI Generator, or a second generated runtime surface. +- Weakening runtime unknown-variant tolerance. Generation-time strictness and runtime + forward-compatibility remain intentionally different regimes. +- Adding temporary public facades, duplicate handwritten operation methods, or compatibility + shims for code that has not shipped. + +## Engineering Policy + +### Semantic-risk fail-closed + +The generator fails when continuing could emit a wrong request, wrong response model, wrong +serializer, wrong route, or misleading public contract. Examples include: + +- Reader diagnostics, reader crashes, or an OpenAPI version outside the accepted version. +- Unresolved references and unsupported `$ref` siblings whose semantics cannot be retained. +- Ambiguous or unsupported union shapes in the selected reachable closure. +- Path-level parameters, parameter encodings, request/response media shapes, response headers, + callbacks, or other protocol constructs that the selected operation would otherwise drop. +- Unknown response content types that have no payload mapping. +- Missing literal markers for a union that requires tag dispatch. +- Any selected operation, response, or schema that cannot produce the promised immutable, + AOT-safe SDK shape. + +The generator does not fail solely because: + +- Microsoft.OpenApi adds a public property or changes a fresh-instance default that no emitted + rule reads. +- A document contains descriptive or organizational metadata the SDK intentionally ignores. +- An unrecognized vendor extension has no declared generator semantics; it is reported as a + located informational drift entry rather than silently disappearing. +- A legal construct exists outside the selected milestone scope and cannot affect its output. + +The last case is a staged-build tolerance, not permanent best-effort generation. The final +generation profile selects both API surfaces; a construct reachable from that full profile must +either be supported or fail with a located diagnostic. + +Extension handling has three explicit dispositions. `x-effect-stream` and `x-websocket` carry +declared semantics. `x-codeSamples` is known-ignored descriptive input. Any other `x-*` value is +listed with its location in the generation report; it becomes fatal only when a projection, +Binder, or runtime rule would otherwise lose behavior it claims to emit. This keeps refresh +review informed without restoring an exhaustive extension allowlist. + +### Git-led drift detection + +A spec refresh is reviewed through four evidence layers: + +1. The pinned `spec/openapi.json` diff. +2. The committed generated-source diff. +3. Targeted semantic tests for known lossy seams. +4. Build, analyzer, AOT, and runtime contract gates. + +This intentionally replaces a full reflection inventory of every reader DOM member and a full +SpecIR snapshot. Git cannot detect a construct silently ignored by the generator when no output +changes, so targeted validators remain at known lossy seams. Git is the primary radar, not the +only radar. + +### Consumer-pull rule + +Every SpecIR field and every persistent validation mechanism must name a downstream consumer or +an observed failure it prevents. A fact needed only by a later feature is added in that feature's +milestone. A test that protects neither an observed failure nor a standing public/wire contract +is deferred. + +## Lean Slice 1 Contract + +Slice 1 still produces one deep module: `ISpecIngestion` returns an immutable `SpecDocument` +whose public surface contains no Microsoft.OpenApi types. Microsoft.OpenApi remains confined to +the ingestion implementation. + +### Keep + +| Capability | Reason | +|---|---| +| Reader, version gate, diagnostics, exception translation | The maintained parsing and hard-failure base | +| Schema and operation semantic projection | Proven necessary by the Kiota wire-fidelity probe | +| Primitive, object, dictionary, free-form, unrestricted, enum, literal, array, tuple, ref, nullable, special-number, content-string, and union nodes | Required by the real pin and later emitters | +| Marked versus structural union classification | Determines converter versus structural representation | +| Literal marker and error-style facts | Drives known/unknown dispatch and typed HTTP errors | +| Modern/legacy surface, route, method, parameters, media, envelope, SSE/WebSocket, and documentation facts | Drives operation and response emission | +| Deterministic graph keys and stable ordering | Keeps generated diffs local and repeatable | +| Raw `$ref` sibling detection and dangling-reference validation | Prevents reader proxy behavior from hiding wire semantics | +| `prefixItems` adapter | Required by the pinned `Config.plugin` tuple | +| Batched located diagnostics for actual semantic failures | Makes generator refusal actionable | +| Boundary guard preventing Microsoft.OpenApi leakage | Preserves the deep module seam | + +### Remove or defer + +| Capability | Disposition | +|---|---| +| Generic reflection `HostMemberWhitelist` over every public property/default | Replace with explicit checks tied to emitted semantics | +| Full DOM member/default inventory baseline | Remove | +| Full pinned SpecIR Verify snapshot | Remove; generated source is the reviewed artifact | +| Raw hash for every operation and named schema | Defer until excluded/hand-wired fingerprint persistence has a consumer | +| `SpecOperation.RawContentHash` and document-wide schema hash fields with no current consumer | Remove now; add with the fingerprint feature | +| Fatal handling of every unknown `x-*` extension | Replace with located informational reporting unless the extension affects emitted behavior | +| Exhaustive landmark inventory | Reduce to representative lossy seams and final-scope smoke | +| Facts or collaborators with no Binder/emitter/refresh consumer | Delete or defer after the consumer audit | + +Existing schema and operation projectors are not presumed disposable. The simplification review +uses the deletion test: if removing a module spreads union, route, media, or normalization logic +across Binder rules, the module is earning its keep. Assurance-only machinery that disappears +without moving correctness complexity elsewhere is removed. + +The standing targeted ingestion gates are explicit: OpenAPI version and reader diagnostics; +`prefixItems` still reaching the supported raw-key adapter; `$ref` sibling and dangling-reference +behavior; no Microsoft.OpenApi type outside the ingestion seam; deterministic graph keys; and +pinned known/unknown union, unrestricted-schema, SSE-metadata, and both-surface landmarks. A new +gate requires a concrete lossy seam or standing public contract. + +## Operation Selection + +The Binder consumes an internal immutable `OperationSelection`. Selection is a final generator +capability used by focused emitter tests, diagnostics, and staged repository generation; it is +not a public SDK concept. + +A checked-in generation profile under `tools/` supplies the repository's current selection. The +first profile contains exactly: + +```text +v2.health.get +v2.session.message +``` + +Selection rules are fail-loud: + +- Every listed operation ID must exist exactly once. +- Duplicate IDs refuse. +- The selected schema set is the transitive closure of every selected parameter, request body, + success/error response, nested property, and union branch. +- An unselected operation is pending breadth, not a product exclusion; it receives no exclusion + fingerprint and does not appear in the public SDK. +- The generation report names pending modern and legacy operation counts so partial scope cannot + masquerade as completion. +- Before any package release, the checked-in profile moves to both complete surfaces and the + full-coverage gate becomes mandatory. + +The generator reports three disjoint operation sets: + +| Set | Meaning | Fingerprint policy | +|---|---|---| +| Selected | Emitted by the current profile | Generated source and contract fixtures are the drift radar | +| Pending | Present in the spec but intentionally outside the pre-release staged profile | No fingerprint; count and IDs are reported, and packaging is blocked | +| Excluded or hand-wired | Inside the intended full product surface but deliberately not emitted as an ordinary method | Fingerprint required before the first member enters this set | + +Pending is a build-out state, not a product policy. The first operation cannot move from pending +into the excluded or hand-wired set until fingerprint persistence is implemented and verified. +Moving the profile to full breadth is likewise blocked on the fingerprint manifest. + +Partial breadth is machine-enforced. The Writer owns a partial-generation marker beside its +manifest whenever pending operations exist. `dotnet pack` fails while that marker exists, CI +asserts that the marker agrees with the profile and report, and `generate --verify` verifies the +marker as an owned output. Full breadth removes it. Operational prose is not the release guard. + +`curation.json` remains sparse public-API policy. Operation selection and curation are separate: +selection answers what is emitted in the current milestone; curation answers how selected +constructs map to the final public API. + +Layer-1 curation coverage is selection-scoped during staged generation: + +- Spec-to-curation checks cover selected operation IDs, their groups, envelope payload names, + content types, null-semantics decisions, and reachable override sites. +- Curation-to-spec checks remain global: every row must name a real construct in the pin. +- A row that targets a pending operation is rejected until that operation becomes selected, so + speculative curation cannot accumulate. +- The full profile restores spec-to-curation coverage over both complete surfaces before + packaging can be enabled. + +The selected-scope curation change is a public API review even though the package is not yet +published. + +## Walking-Skeleton Pipeline + +```text +spec/openapi.json + │ + ▼ +ISpecIngestion ───────────────▶ SpecDocument + │ + OperationSelection + sparse curation + │ + ▼ + Binder + │ + ▼ + EmitPlan + │ + ┌─────────────┴─────────────┐ + ▼ ▼ + Roslyn emitters Writer + │ │ + └─────────────┬─────────────┘ + ▼ + src/OpenCode.Sdk + │ + generated methods delegate once + ▼ + Pipeline.ExecuteAsync core + │ + ▼ + HttpClient +``` + +The Binder is the only module that knows both wire semantics and C# public-policy inputs. The +emitters do not read Microsoft.OpenApi or SpecIR. The runtime does not know the spec, Binder, or +EmitPlan. + +### Binder and EmitPlan scope + +The first Binder implementation is complete for the selected closure, not a mock. It owns: + +- Reachable-closure computation. +- Mechanical naming and identifier mapping. +- Root method and bound-session-handle placement. +- Envelope payload naming. +- Known union converter and unknown-carrier decisions. +- Error hierarchy and status mapping. +- Source-generation registry membership. +- XML documentation inputs. + +An unsupported selected fact produces a categorized Binder error. The Binder never falls back to +raw Microsoft.OpenApi access and never silently asks an emitter to infer wire semantics. + +EmitPlan types are final role-specific instructions. They carry only what an emitter consumes. +The first milestone may not instantiate every future plan subtype; it must not add placeholder +fields for hypothetical emitters. + +### Emitters and Writer scope + +The milestone implements the final emitters required by the selected closure: + +- Immutable models and literal enums. +- Marked-union bases, known variants, explicit unknown carriers, and custom converters. +- One source-generated `JsonSerializerContext` registry. +- Typed success/error envelopes and input types required by the selected operations. +- Root client, sessions collection, bound `SessionClient`, routes, and operation methods. +- XML documentation and analyzer-clean source construction. + +The Writer owns the final output manifest, stale-file deletion within its owned roots, +deterministic writes, and the formatting post-step. `generate` becomes real in this milestone; +`generate --verify` regenerates the selected profile and fails on a diff. + +Raw `$ref` sibling detection does not depend on hashing. A dedicated raw-key scanner walks schema +positions and reports siblings independently; the later hashing implementation may share the +same traversal abstraction but is not required for correctness. + +## Runtime Core + +The first callable milestone implements only the final behavior needed to prove a real call: + +- Standalone endpoint construction and BYO `HttpClient` construction. +- Correct `HttpClient` ownership for those paths. +- `OpenCodeClient.Dispose` disposes the self-created `HttpClient` and never the injected client. +- Basic authentication resolves explicit `Password` then the environment fallback at client + construction. User-Agent and client/per-call `x-opencode-directory` decoration are active. +- The sealed public design's shared `Pipeline.ExecuteAsync` behavior core used by + one-line generated methods; it is not a consumer-extensibility policy framework. +- Route expansion, request headers/content needed by the selected operations, cancellation, and + response disposal. +- Source-generated JSON serialization/deserialization with no reflection fallback. +- Typed success envelopes. +- Declared HTTP error parsing into the generated `OpenCodeError` hierarchy. +- Throw-by-default via `OpenCodeApiException` carrying status and typed error data. +- Per-call `OpenCodeRequestOptions.NoThrow`, returning an error envelope without changing the + method's return type. + +Retry, telemetry, hooks, DI Extensions, launcher, raw escape hatch, and streams remain later +runtime breadth. The first milestone does not expose switches whose behavior is not implemented. +Adding those capabilities later extends the shared behavior core without changing generated +method bodies. + +## Public Surface + +The selected modern health group is a single-operation root group, so it produces a root client +method. The selected message operation carries `{sessionID}` and therefore lives only on the +bound session handle. The intended shape is: + +```csharp +var client = new OpenCodeClient(new Uri("http://localhost:4096")); + +HealthResponse health = await client.GetHealthAsync(cancellationToken: cancellationToken); + +SessionClient session = client.Sessions.GetSessionClient(sessionId); +SessionMessageResponse response = await session.GetMessageAsync( + messageId, + options: requestOptions, + cancellationToken: cancellationToken); +``` + +Exact generated type names follow the existing naming and curation rules, but these structural +decisions are fixed: + +- `GetHealthAsync` is a root method. +- `GetMessageAsync` exists only on `SessionClient`; no duplicate flat overload is emitted. +- Every method is generated and delegates in one line through `Pipeline.ExecuteAsync`. +- Every operation returns a named typed response envelope. +- Client/root/sub-client methods use the final virtual-member mock seam. +- Models, options, exceptions, envelopes, and non-client generated types remain sealed where the + public design requires it. + +The partial milestone is not published. Its public source still follows extend-only rules so +scope expansion can only add members and types. + +### First callable public inventory + +The runtime arc lands only public members whose behavior is complete: + +| Type | Members present in the first callable arc | +|---|---| +| `OpenCodeClient` | `IDisposable`; standalone `Uri`, `Uri` + options, and BYO `HttpClient` + options constructors; root `GetHealthAsync`; readonly `Sessions`; final protected mock seam | +| `OpenCodeClientOptions` | `Endpoint`, `Password`, and default `Directory`; basic authentication and directory decoration are implemented when these members land | +| `OpenCodeRequestOptions` | `ErrorBehavior`, per-call `Directory`, and static `NoThrow` exactly as the public API design specifies | +| `ErrorBehavior` | `Default` and `NoThrow` values used only through per-call request options | +| Exception spine | Hand-written `OpenCodeException`, `OpenCodeApiException`, and `OpenCodeTransportException` with their final inheritance and payload roles | +| Response spine | Hand-written `OpenCodeResponse` base with status, `IsError`, and guarded error/success behavior | +| Generated clients | `Sessions`, bound `SessionClient`, `GetHealthAsync`, and `GetMessageAsync` only | +| Generated data | Selected success/error models, envelopes, marked unions, unknown carriers, converter set, routes, and serializer context | + +`Retry`, delegate hooks, logging plumbing, and their option members do not exist until their +behavior lands. Adding those option properties later is the already-sealed extend-only growth +model. The raw `SendAsync` escape hatch also remains absent until its behavior is implemented. + +## Serialization and Errors + +### SessionMessage + +`SessionMessage` is an eight-variant marked union keyed by the required `type` literal. The +assistant variant's `content` is an array whose items form a three-variant marked union keyed by +`type`. Its tool branch contains a four-state marked union keyed by `status`. The generated +converter set: + +1. Buffers one JSON value. +2. Reads `type` independently of property order. +3. Dispatches known values through the generated context. +4. Produces `UnknownSessionMessage` for an unrecognized tag, carrying the tag and raw + `JsonElement`. +5. Re-serializes both known and unknown values without semantic data loss. + +The converter never uses reflection and never delegates unknown handling to +`JsonUnknownDerivedTypeHandling`, which cannot implement the required deserialization carrier. + +The health response's required single-value boolean enum is not used as a polymorphic marker. +The Binder emits it as a required `bool` property so a version-skew `false` value remains visible +instead of being silently rewritten to `true`; the pinned `true` constraint remains a landmark +and contract expectation. + +### HTTP errors + +For `v2.session.message`, the milestone covers: + +| Status | Wire data | +|---|---| +| 400 | `InvalidRequestError` | +| 401 | `UnauthorizedError` | +| 404 | deduplicated `MessageNotFoundError` or `SessionNotFoundError` | + +The exception bases and `OpenCodeApiException` are hand-written identity core. Concrete error +models and their unknown carrier are generated. Default calls throw `OpenCodeApiException` with +the status and typed `OpenCodeError`. `NoThrow` +returns `SessionMessageResponse` with `IsError = true`, the typed error populated, and guarded +success payload access. An unknown future error tag becomes the generic unknown-error carrier; +the raw payload remains available. + +Transport failures and cancellation stay separate: network/protocol failures use the transport +exception spine; cancellation follows the BCL `OperationCanceledException` convention. + +## Verification + +### Generator acceptance + +| Check | Required result | +|---|---| +| Pinned ingestion | Selected profile ingests with no semantic errors | +| Scope | Exactly the two operation IDs; pending counts reported honestly | +| Closure | Every referenced success/error/nested union type present; no orphan emitted | +| Repeat | Two generations byte-identical after formatting | +| Verify | `generate --verify` exits successfully on a clean tree and fails on drift | +| Manifest | Owned files listed; stale owned file removed; unrelated file untouched | +| Boundary | No Microsoft.OpenApi type reaches SpecDocument, Binder output, generated code, or runtime | + +### Generated-model acceptance + +| Check | Required result | +|---|---| +| Known user message | Correct concrete variant and property values | +| Known assistant message | Correct outer variant and ordered content array of text/reasoning/tool variants | +| Property order | `type` and nested `status` markers may appear anywhere in their objects | +| Unknown message | Explicit carrier with exact tag and raw payload | +| Unknown nested content | An unknown content-array item preserves its tag and raw payload | +| Assistant tool state | Known and unknown `status`-marked tool-state variants preserve data | +| Round trip | Known and unknown payloads remain semantically equal | +| Registry | One generated context contains the complete selected closure | +| Reflection | Reflection-disabled runtime probe succeeds | +| Immutability | Records/init/read-only collections and required mirroring hold | + +### Generated-client acceptance + +All calls use an in-memory recording `HttpMessageHandler` and the real generated client/runtime: + +| Scenario | Required result | +|---|---| +| Health 200 | `GET /api/health`; typed `Healthy = true` response | +| Health 400/401 | Every declared health error maps through default throw and `NoThrow` paths | +| Session message 200 | Correct `/api/session/{sessionID}/message/{messageID}` expansion and typed union payload | +| Session message 400 | Typed `InvalidRequestError` on default throw path | +| Session message 401 | Typed `UnauthorizedError` on default throw path | +| Session message 404 | Both not-found variants select the correct subtype | +| `NoThrow` | Every selected declared status returns the same typed error in an error envelope | +| Unknown error | Generic carrier retains tag and raw body | +| Authentication | Explicit password and environment fallback produce the expected Basic authorization header | +| Directory | Client default and per-call override produce the expected `x-opencode-directory` header | +| User-Agent | Every selected call carries the SDK User-Agent decoration | +| Cancellation | Token reaches `HttpClient`; cancellation is not remapped as API error | +| Transport failure | Network/protocol failure maps to `OpenCodeTransportException`, never an API error | +| Client disposal | Standalone client disposes its owned HTTP client; BYO HTTP client remains usable | + +### Repository gates + +- Generated and handwritten product code builds with zero warnings and errors on + `netstandard2.0;net472;net8.0;net9.0;net10.0`. +- Relevant unit/contract tests run on the repository's supported test legs. +- Generated output passes the analyzer wall on merit. +- The existing net10+ `IsAotCompatible=true` metadata remains active; a reflection-disabled + serializer probe is mandatory. A real Native AOT publish smoke is owned by complete operation + breadth before packaging is enabled. +- `dotnet format --verify-no-changes --no-restore` passes. +- Slopwatch reports no warnings. +- The partial-generation marker is present and verified while operations remain pending; + `dotnet pack` fails mechanically, independent of operational documentation. +- Level-2 contract breadth is complete for every selected public operation and every declared + response. The gate expands to both complete surfaces with the full profile. + +## Revised Sequencing + +### Decision-document alignment + +Before production code resumes, update the affected ADRs, architecture/public/testing specs, +slice map, active Slice 1 plan, ROADMAP, and issue graph so they describe this design. The revised +execution plan is reviewed as the gate into the arcs below. + +### Lean Slice 1 — ingestion and SpecIR + +Close the existing branch under the lean policy. Review the existing Task 7 semantic operation +projection after replacing or removing assurance-only host-wall machinery. Implement minimal +orchestration, targeted boundary/reader guards, and focused pinned-spec smoke. Do not add +document-wide content hashes or exhaustive library-upgrade snapshots. + +### Production arc A — selected compiler and committed models + +In one independently mergeable PR, implement selection-scoped curation checks, Binder/EmitPlan, +the selected model/union/registry emitters, Writer, partial-profile enforcement, the real +`generate` / `--verify` path, and the selected generated model closure under `src/OpenCode.Sdk`. +The selected sources compile on all five TFMs but expose no nonfunctional client methods. + +### Production arc B — callable client and core errors + +In the next independently mergeable PR, implement the final exception/response/request-option +spines, the minimal shared behavior core, client/envelope/routes/operation emitters, the first +generated methods, and the selected-operation contract suite. This is the first callable SDK +milestone. + +The revised implementation slice map assigns concrete slice and issue numbers to these arcs; +this design does not silently reuse the old issue meanings. + +### Vertical breadth expansion + +Expand the checked-in profile in bounded operation batches. Each batch carries its complete +reachable model closure through curation, Binder/EmitPlan, generated models and methods, and +level-2 contracts. There is no separate full-model horizontal stage and no orphan model +inventory. The Writer, manifest, registry, runtime delegation, and partial-packaging guard remain +unchanged. + +### Transport and Extensions breadth + +Add retry, telemetry, hooks, the raw escape hatch, complete options, and +`OpenCode.Sdk.Extensions` DI composition around the existing pipeline. Existing generated method +bodies do not change. + +### Complete operation surface + +Author full sparse curation, implement fingerprint persistence, expand the generation profile to +all 61 modern and 127 legacy operations, add the remaining operation/envelope/input/routes/ +paginator emitters, and enforce full happy-path plus declared error contract breadth. The Native +AOT publish smoke passes and the partial-scope marker disappears before this milestone can +complete. + +Launcher, real-process harness, SSE, container, coverage, and operational-closure sequencing can +remain after the complete client, subject to the implementation slice map's dependency review. + +## Stop Conditions + +Stop and revisit the design if any of the following occurs: + +- Supporting `v2.session.message` requires a second wire model outside SpecIR rather than a + narrow Binder decision. +- The Binder must access Microsoft.OpenApi or raw JSON to recover facts projection claimed to + provide. +- The first public method cannot use its final placement or signature without speculative + compatibility code. +- Selected generation needs a temporary emitter or transport path that later breadth would + replace instead of extend. +- The selected closure cannot compile across the five TFMs without reopening a locked product + requirement. +- Partial generation can silently present itself as full breadth. +- Either production arc cannot fit one focused execution arc without crossing into the next + arc's acceptance contract. +- The milestone grows to include retry, telemetry, DI, launcher, SSE, or broad operation support + merely to make the two selected calls work. + +These are design contradictions, not invitations to hide the gap behind a fallback. + +## Documentation and Decision Impact + +This design changes sealed architecture and sequencing claims and therefore follows the +repository deviation protocol before implementation: + +- Update the generator architecture's ingestion policy from exhaustive reader-DOM whitelist + surveillance to semantic-risk fail-closed validation. +- Update ADR-0003 in place to describe the current ingestion policy while retaining the own + generator, Microsoft.OpenApi, SpecIR, Roslyn emission, and tooling-package decisions. +- Update the implementation slice map so the first callable SDK phase is represented by two + independently mergeable production arcs and `generate` becomes real in the compiler arc + rather than in the old Slice 3. +- Rewrite the active Slice 1 plan's remaining Tasks 7–11 around lean closeout; completed-task + history remains in git and the SDD ledger. +- Update the public API design to pin the first callable member inventory, clarify that + `Pipeline.ExecuteAsync` is the existing behavior core rather than a new extensibility + framework, and distinguish selection-scoped pre-release curation from the full release gate. +- Stage the testing architecture's level-2 rule: complete contract breadth over selected public + operations in the callable arc, restoring both-surface breadth when the full profile lands. +- Update ADR-0008 as needed to distinguish pending build-out operations from product exclusions + and hand-wired operations; its final all-operation and fingerprint commitments remain. +- Update ROADMAP, the slice issue graph, and `blocked_by` edges to the revised arc meanings. +- Preserve the complexity checkpoint and both spike findings in research documentation without + making temporary workspace artifacts permanent dependencies. + +No implementation resumes until these documents agree and the maintainer approves the written +design and revised execution plan. + +## Sources + +- `docs/research/13-generator-complexity-checkpoint.md` +- `docs/research/08-codegen-spike.md` +- `.superpowers/sdd/2026-08-11-slice-01-ingestion-specir/progress.md` +- `docs/adr/0003-model-layer-codegen.md` +- `docs/adr/0004-generated-model-principles.md` +- `docs/adr/0005-both-api-surfaces.md` +- `docs/adr/0007-error-model.md` +- `docs/adr/0008-generated-operation-methods.md` +- `docs/adr/0009-unknown-variant-tolerance.md` +- `docs/superpowers/specs/2026-08-09-generator-architecture.md` +- `docs/superpowers/specs/2026-08-09-public-api-design.md` +- `docs/superpowers/specs/2026-08-10-testing-architecture-design.md` +- `docs/superpowers/plans/2026-08-10-implementation-slice-map.md` +- `docs/superpowers/plans/2026-08-11-slice-01-ingestion-specir.md` +- `spec/openapi.json` From 3e6e52ee29266494bdd8dd951fe6ee7a28a67ce8 Mon Sep 17 00:00:00 2001 From: "denizirgin@gmail.com" Date: Tue, 11 Aug 2026 19:58:18 +0300 Subject: [PATCH 14/19] refactor(tools): replace DOM surveillance walls with semantic-risk checks The reflection member whitelists (HostMemberWhitelist, SchemaMemberWhitelist), the document wall, and the fatal handling of typed annotation/validation members and unknown extensions are removed. Semantic-loss checks survive as explicit typed-member checks: path-level parameters, unknown HTTP methods, parameter location/style/content, multi-media bodies and responses, allOf (via shape classification), multi-value type, unrecognized raw keywords, and the declared x-websocket/x-effect-stream semantics. The unused SpecOperation.RawContentHash field is dropped; stateless policies become static per the analyzer wall. --- .../Ingestion/OperationProjectorTests.cs | 1 - .../Ingestion/SchemaProjectorTests.cs | 7 +- .../Ingestion/Walls/HostWallPolicyTests.cs | 105 ++-------------- .../Walls/SchemaMemberWhitelistTests.cs | 48 -------- .../Ingestion/Walls/SchemaWallPolicyTests.cs | 114 +++--------------- .../Support/OperationBuilder.cs | 30 ----- .../Support/OperationProjectionTestHost.cs | 4 +- .../Support/OperationResponseBuilder.cs | 40 ------ .../Support/SchemaProjectionTestHost.cs | 4 +- .../Support/SpecDocumentBuilder.cs | 55 --------- .../Ingestion/Models/SpecOperation.cs | 11 -- .../Projection/MediaTypeProjector.cs | 42 +++++-- .../Projection/OperationProjector.cs | 16 +-- .../Projection/ParameterProjector.cs | 6 +- .../Projection/RequestBodyProjector.cs | 19 +-- .../Ingestion/Projection/ResponseProjector.cs | 20 +-- .../Ingestion/Projection/SchemaProjector.cs | 8 +- .../Ingestion/Projection/UnionNormalizer.cs | 8 +- .../Ingestion/Walls/DocumentWallPolicy.cs | 33 ----- .../Ingestion/Walls/HostMemberWhitelist.cs | 77 ------------ .../Ingestion/Walls/MediaTypeWallPolicy.cs | 59 --------- ...lPolicy.cs => OperationExtensionPolicy.cs} | 26 +--- .../Ingestion/Walls/ParameterWallPolicy.cs | 22 ++-- .../Ingestion/Walls/PathItemWallPolicy.cs | 19 +-- .../Ingestion/Walls/RequestBodyWallPolicy.cs | 34 ------ .../Ingestion/Walls/ResponseWallPolicy.cs | 34 ------ .../Ingestion/Walls/SchemaMemberWhitelist.cs | 110 ----------------- .../Ingestion/Walls/SchemaWallPolicy.cs | 30 ++--- 28 files changed, 131 insertions(+), 851 deletions(-) delete mode 100644 tests/OpenCode.Sdk.Tools.Tests/Generator/Ingestion/Walls/SchemaMemberWhitelistTests.cs delete mode 100644 tools/OpenCode.Sdk.Tools/Generator/Ingestion/Walls/DocumentWallPolicy.cs delete mode 100644 tools/OpenCode.Sdk.Tools/Generator/Ingestion/Walls/HostMemberWhitelist.cs delete mode 100644 tools/OpenCode.Sdk.Tools/Generator/Ingestion/Walls/MediaTypeWallPolicy.cs rename tools/OpenCode.Sdk.Tools/Generator/Ingestion/Walls/{OperationWallPolicy.cs => OperationExtensionPolicy.cs} (53%) delete mode 100644 tools/OpenCode.Sdk.Tools/Generator/Ingestion/Walls/RequestBodyWallPolicy.cs delete mode 100644 tools/OpenCode.Sdk.Tools/Generator/Ingestion/Walls/ResponseWallPolicy.cs delete mode 100644 tools/OpenCode.Sdk.Tools/Generator/Ingestion/Walls/SchemaMemberWhitelist.cs diff --git a/tests/OpenCode.Sdk.Tools.Tests/Generator/Ingestion/OperationProjectorTests.cs b/tests/OpenCode.Sdk.Tools.Tests/Generator/Ingestion/OperationProjectorTests.cs index f63d0672..e796c029 100644 --- a/tests/OpenCode.Sdk.Tools.Tests/Generator/Ingestion/OperationProjectorTests.cs +++ b/tests/OpenCode.Sdk.Tools.Tests/Generator/Ingestion/OperationProjectorTests.cs @@ -20,7 +20,6 @@ public async Task Project_Should_Preserve_Order_And_Derive_Surface_And_Segments( await Assert.That(result.Operations[0].Surface).IsEqualTo(SpecSurface.Modern); await Assert.That(result.Operations[0].Segments[0]).IsEqualTo("session"); await Assert.That(result.Operations[0].Segments[1]).IsEqualTo("list"); - await Assert.That(result.Operations[0].RawContentHash).IsEqualTo(string.Empty); await Assert.That(result.Operations[1].OperationId).IsEqualTo("session.list"); await Assert.That(result.Operations[1].Surface).IsEqualTo(SpecSurface.Legacy); await Assert.That(result.Operations[1].Segments[0]).IsEqualTo("session"); diff --git a/tests/OpenCode.Sdk.Tools.Tests/Generator/Ingestion/SchemaProjectorTests.cs b/tests/OpenCode.Sdk.Tools.Tests/Generator/Ingestion/SchemaProjectorTests.cs index 457f9af7..c2881289 100644 --- a/tests/OpenCode.Sdk.Tools.Tests/Generator/Ingestion/SchemaProjectorTests.cs +++ b/tests/OpenCode.Sdk.Tools.Tests/Generator/Ingestion/SchemaProjectorTests.cs @@ -2,7 +2,6 @@ using OpenCode.Sdk.Tools.Generator.Ingestion; using OpenCode.Sdk.Tools.Generator.Ingestion.Models; using OpenCode.Sdk.Tools.Generator.Ingestion.Projection; -using OpenCode.Sdk.Tools.Generator.Ingestion.Walls; using OpenCode.Sdk.Tools.Tests.Support; namespace OpenCode.Sdk.Tools.Tests.Generator.Ingestion; @@ -243,13 +242,13 @@ public async Task Project_Should_Batch_Errors_From_Multiple_Schemas() var host = new SchemaProjectionTestHost(); var scenario = SpecScenario.Define(spec => spec .WithSchema("BadArray", schema => schema.Type("array")) - .WithSchema("BadTitle", schema => schema.Raw("title", "\"unsupported\""))); + .WithSchema("BadKeyword", schema => schema.Raw("madeUpKeyword", "true"))); var ex = await host.ProjectExpectingRefusalAsync(scenario); await Assert.That(ex.Errors).Count().IsEqualTo(2); await Assert.That(ex.Message).Contains("BadArray"); - await Assert.That(ex.Message).Contains("BadTitle"); + await Assert.That(ex.Message).Contains("BadKeyword"); } [Test] @@ -262,7 +261,7 @@ public async Task Project_Should_Refuse_When_Promoted_Graph_Key_Collides() var errors = new IngestionErrorCollector(); var loaded = await new SpecReader(context.FileSystem).LoadAsync(context.SpecPath, errors, CancellationToken.None); var keys = new GraphKeyBuilder(); - var projector = new SchemaProjector(new SchemaWallPolicy(), keys); + var projector = new SchemaProjector(keys); var state = new ProjectionState(errors, loaded.Document, new Dictionary(StringComparer.Ordinal)); _ = projector.Project(loaded.Document.Components!.Schemas!["First"], "Shared", "/value", state); diff --git a/tests/OpenCode.Sdk.Tools.Tests/Generator/Ingestion/Walls/HostWallPolicyTests.cs b/tests/OpenCode.Sdk.Tools.Tests/Generator/Ingestion/Walls/HostWallPolicyTests.cs index 1cb0d5af..9a26556c 100644 --- a/tests/OpenCode.Sdk.Tools.Tests/Generator/Ingestion/Walls/HostWallPolicyTests.cs +++ b/tests/OpenCode.Sdk.Tools.Tests/Generator/Ingestion/Walls/HostWallPolicyTests.cs @@ -27,78 +27,6 @@ public async Task Project_Should_Refuse_Unsupported_Method() await Assert.That(ex.Message).Contains("method 'head'"); } - [Test] - public async Task Project_Should_Refuse_Document_Webhooks() - { - var host = new OperationProjectionTestHost(); - var scenario = SpecScenario.Define(spec => spec.WithWebhook()); - - var ex = await host.ProjectExpectingRefusalAsync(scenario); - - await Assert.That(ex.Message).Contains("document"); - await Assert.That(ex.Message).Contains("webhooks"); - } - - [Test] - public async Task Project_Should_Refuse_Non_Schema_Component() - { - var host = new OperationProjectionTestHost(); - var scenario = SpecScenario.Define(spec => spec.WithNonSchemaComponent()); - - var ex = await host.ProjectExpectingRefusalAsync(scenario); - - await Assert.That(ex.Message).Contains("components"); - await Assert.That(ex.Message).Contains("parameters"); - } - - [Test] - public async Task Project_Should_Refuse_Path_Level_Servers() - { - var host = new OperationProjectionTestHost(); - var scenario = SpecScenario.Define(spec => spec.WithPathServer("v2.test.server")); - - var ex = await host.ProjectExpectingRefusalAsync(scenario); - - await Assert.That(ex.Message).Contains("path-level servers"); - } - - [Test] - public async Task Project_Should_Refuse_Response_Headers() - { - var host = new OperationProjectionTestHost(); - var scenario = SpecScenario.Define(spec => spec.WithOperation("v2.test.headers", configure: operation => operation.ResponseWithHeader(200))); - - var ex = await host.ProjectExpectingRefusalAsync(scenario); - - await Assert.That(ex.Message).Contains("headers"); - await Assert.That(ex.Message).Contains("responses/200"); - } - - [Test] - public async Task Project_Should_Refuse_Media_ItemSchema_With_Location() - { - var host = new OperationProjectionTestHost(); - var scenario = SpecScenario.Define(spec => spec.WithOperation("v2.test.item", configure: operation => operation.ResponseWithItemSchema(200))); - - var ex = await host.ProjectExpectingRefusalAsync(scenario); - - await Assert.That(ex.Message).Contains("itemSchema"); - await Assert.That(ex.Message).Contains("paths"); - } - - [Test] - public async Task Project_Should_Refuse_Media_Encoding() - { - var host = new OperationProjectionTestHost(); - var scenario = - SpecScenario.Define(spec => spec.WithOperation("v2.test.encoding", configure: operation => operation.ResponseWithEncoding(200))); - - var ex = await host.ProjectExpectingRefusalAsync(scenario); - - await Assert.That(ex.Message).Contains("encoding"); - await Assert.That(ex.Message).Contains("responses/200/content/application~1json"); - } - [Test] public async Task Project_Should_Refuse_Header_Parameter() { @@ -204,40 +132,27 @@ public async Task Project_Should_Refuse_Default_Response_Key() } [Test] - public async Task Project_Should_Refuse_Unknown_Operation_Extension() - { - var host = new OperationProjectionTestHost(); - var scenario = SpecScenario.Define(spec => spec.WithOperation("v2.test.extension", configure: operation => operation - .Extension("x-unknown", "true"))); - - var ex = await host.ProjectExpectingRefusalAsync(scenario); - - await Assert.That(ex.Message).Contains("x-unknown"); - await Assert.That(ex.Message).Contains("operation"); - } - - [Test] - public async Task Project_Should_Refuse_Operation_Callbacks() + public async Task Project_Should_Locate_Malformed_Media_Type() { var host = new OperationProjectionTestHost(); - var scenario = SpecScenario.Define(spec => spec.WithOperation("v2.test.callback", configure: operation => operation.Callback())); + var scenario = SpecScenario.Define(spec => spec.WithOperation("v2.test.media", configure: operation => operation + .Response(200, "application-json", schema => schema.Type("string")))); var ex = await host.ProjectExpectingRefusalAsync(scenario); - await Assert.That(ex.Message).Contains("callbacks"); - await Assert.That(ex.Message).Contains("operation"); + await Assert.That(ex.Message).Contains("application-json"); + await Assert.That(ex.Message).Contains("responses/200/content"); } [Test] - public async Task Project_Should_Locate_Malformed_Media_Type() + public async Task Project_Should_Ignore_Unknown_Operation_Extension() { var host = new OperationProjectionTestHost(); - var scenario = SpecScenario.Define(spec => spec.WithOperation("v2.test.media", configure: operation => operation - .Response(200, "application-json", schema => schema.Type("string")))); + var scenario = SpecScenario.Define(spec => spec.WithOperation("v2.test.extension", configure: operation => operation + .Extension("x-unknown", "true"))); - var ex = await host.ProjectExpectingRefusalAsync(scenario); + var result = await host.ProjectAsync(scenario); - await Assert.That(ex.Message).Contains("application-json"); - await Assert.That(ex.Message).Contains("responses/200/content"); + await Assert.That(result.Operations).Count().IsEqualTo(1); } } diff --git a/tests/OpenCode.Sdk.Tools.Tests/Generator/Ingestion/Walls/SchemaMemberWhitelistTests.cs b/tests/OpenCode.Sdk.Tools.Tests/Generator/Ingestion/Walls/SchemaMemberWhitelistTests.cs deleted file mode 100644 index 4bf94de4..00000000 --- a/tests/OpenCode.Sdk.Tools.Tests/Generator/Ingestion/Walls/SchemaMemberWhitelistTests.cs +++ /dev/null @@ -1,48 +0,0 @@ -using Microsoft.OpenApi; -using OpenCode.Sdk.Tools.Generator.Ingestion; -using OpenCode.Sdk.Tools.Generator.Ingestion.Models; -using OpenCode.Sdk.Tools.Generator.Ingestion.Walls; -using OpenCode.Sdk.Tools.Tests.Support; - -namespace OpenCode.Sdk.Tools.Tests.Generator.Ingestion.Walls; - -public sealed class SchemaMemberWhitelistTests -{ - [Test] - public async Task Check_Should_Refuse_Populated_Pinned_Member_Outside_Whitelist() - { - var context = SpecScenario.Define(spec => spec.WithSchema("Bad", schema => schema.Raw("deprecated", "true"))).Build(); - var errors = new IngestionErrorCollector(); - var loaded = await new SpecReader(context.FileSystem).LoadAsync(context.SpecPath, errors, CancellationToken.None); - var schema = loaded.Document.Components!.Schemas!["Bad"] as OpenApiSchema - ?? throw new InvalidOperationException("The real reader did not produce a concrete schema."); - var whitelist = new SchemaMemberWhitelist(); - - whitelist.Check(schema, "Bad", errors); - var ex = await Assert.That(errors.ThrowIfAny).Throws(); - - await Assert.That(ex!.Message).Contains("deprecated"); - await Assert.That(ex.Message).Contains("Bad"); - } - - [Test] - public async Task Check_Should_Allow_Admitted_And_Known_Ignored_Pinned_Members() - { - var context = SpecScenario - .Define(spec => spec.WithSchema("Value", schema => schema - .Type("string") - .Description("A value.") - .Format("custom") - .Raw("pattern", "\"^[a-z]+$\""))) - .Build(); - var errors = new IngestionErrorCollector(); - var loaded = await new SpecReader(context.FileSystem).LoadAsync(context.SpecPath, errors, CancellationToken.None); - var schema = loaded.Document.Components!.Schemas!["Value"] as OpenApiSchema - ?? throw new InvalidOperationException("The real reader did not produce a concrete schema."); - var whitelist = new SchemaMemberWhitelist(); - - whitelist.Check(schema, "Value", errors); - - await Assert.That(errors.HasErrors).IsFalse(); - } -} diff --git a/tests/OpenCode.Sdk.Tools.Tests/Generator/Ingestion/Walls/SchemaWallPolicyTests.cs b/tests/OpenCode.Sdk.Tools.Tests/Generator/Ingestion/Walls/SchemaWallPolicyTests.cs index a411002d..b4efdcc2 100644 --- a/tests/OpenCode.Sdk.Tools.Tests/Generator/Ingestion/Walls/SchemaWallPolicyTests.cs +++ b/tests/OpenCode.Sdk.Tools.Tests/Generator/Ingestion/Walls/SchemaWallPolicyTests.cs @@ -29,91 +29,6 @@ public async Task Project_Should_Refuse_When_Type_Is_An_Array() await Assert.That(ex.Message).Contains("Bad"); } - [Test] - public async Task Project_Should_Refuse_When_Schema_Uses_Discriminator() - { - var host = new SchemaProjectionTestHost(); - var scenario = SpecScenario.Define(spec => spec.WithSchema("Bad", schema => schema.Raw("discriminator", "{}"))); - - var ex = await host.ProjectExpectingRefusalAsync(scenario); - - await Assert.That(ex.Message).Contains("discriminator"); - await Assert.That(ex.Message).Contains("Bad"); - } - - [Test] - public async Task Project_Should_Refuse_When_Schema_Uses_If_And_Then() - { - var host = new SchemaProjectionTestHost(); - var scenario = SpecScenario.Define(spec => spec.WithSchema("Bad", schema => schema.Raw("if", "{}").Raw("then", "{}"))); - - var ex = await host.ProjectExpectingRefusalAsync(scenario); - - await Assert.That(ex.Message).Contains("if"); - await Assert.That(ex.Message).Contains("then"); - await Assert.That(ex.Message).Contains("Bad"); - } - - [Test] - public async Task Project_Should_Refuse_When_Schema_Uses_Title() - { - var host = new SchemaProjectionTestHost(); - var scenario = SpecScenario.Define(spec => spec.WithSchema("Bad", schema => schema.Raw("title", "\"unsupported\""))); - - var ex = await host.ProjectExpectingRefusalAsync(scenario); - - await Assert.That(ex.Message).Contains("title"); - await Assert.That(ex.Message).Contains("Bad"); - } - - [Test] - public async Task Project_Should_Refuse_When_Schema_Uses_Default() - { - var host = new SchemaProjectionTestHost(); - var scenario = SpecScenario.Define(spec => spec.WithSchema("Bad", schema => schema.Raw("default", "\"unsupported\""))); - - var ex = await host.ProjectExpectingRefusalAsync(scenario); - - await Assert.That(ex.Message).Contains("default"); - await Assert.That(ex.Message).Contains("Bad"); - } - - [Test] - public async Task Project_Should_Refuse_When_Schema_Is_ReadOnly() - { - var host = new SchemaProjectionTestHost(); - var scenario = SpecScenario.Define(spec => spec.WithSchema("Bad", schema => schema.Raw("readOnly", "true"))); - - var ex = await host.ProjectExpectingRefusalAsync(scenario); - - await Assert.That(ex.Message).Contains("readOnly"); - await Assert.That(ex.Message).Contains("Bad"); - } - - [Test] - public async Task Project_Should_Refuse_When_Schema_Uses_MultipleOf() - { - var host = new SchemaProjectionTestHost(); - var scenario = SpecScenario.Define(spec => spec.WithSchema("Bad", schema => schema.Raw("multipleOf", "2"))); - - var ex = await host.ProjectExpectingRefusalAsync(scenario); - - await Assert.That(ex.Message).Contains("multipleOf"); - await Assert.That(ex.Message).Contains("Bad"); - } - - [Test] - public async Task Project_Should_Refuse_When_Schema_Uses_UniqueItems() - { - var host = new SchemaProjectionTestHost(); - var scenario = SpecScenario.Define(spec => spec.WithSchema("Bad", schema => schema.Raw("uniqueItems", "true"))); - - var ex = await host.ProjectExpectingRefusalAsync(scenario); - - await Assert.That(ex.Message).Contains("uniqueItems"); - await Assert.That(ex.Message).Contains("Bad"); - } - [Test] public async Task Project_Should_Refuse_When_Schema_Has_Unknown_Raw_Keyword() { @@ -126,18 +41,6 @@ public async Task Project_Should_Refuse_When_Schema_Has_Unknown_Raw_Keyword() await Assert.That(ex.Message).Contains("Bad"); } - [Test] - public async Task Project_Should_Refuse_When_Schema_Has_Extension() - { - var host = new SchemaProjectionTestHost(); - var scenario = SpecScenario.Define(spec => spec.WithSchema("Bad", schema => schema.Raw("x-schema", "true"))); - - var ex = await host.ProjectExpectingRefusalAsync(scenario); - - await Assert.That(ex.Message).Contains("x-schema"); - await Assert.That(ex.Message).Contains("Bad"); - } - [Test] public async Task Project_Should_Ignore_Known_Validation_Keywords() { @@ -155,4 +58,21 @@ public async Task Project_Should_Ignore_Known_Validation_Keywords() await Assert.That(result.Schemas["Value"]).IsTypeOf(); } + + [Test] + public async Task Project_Should_Ignore_Typed_Annotation_And_Validation_Members() + { + var host = new SchemaProjectionTestHost(); + var scenario = SpecScenario.Define(spec => spec.WithSchema("Value", schema => schema + .Type("string") + .Raw("title", "\"a title\"") + .Raw("default", "\"fallback\"") + .Raw("readOnly", "true") + .Raw("minLength", "1") + .Raw("x-custom", "true"))); + + var result = await host.ProjectAsync(scenario); + + await Assert.That(result.Schemas["Value"]).IsTypeOf(); + } } diff --git a/tests/OpenCode.Sdk.Tools.Tests/Support/OperationBuilder.cs b/tests/OpenCode.Sdk.Tools.Tests/Support/OperationBuilder.cs index 4d481647..9b7d5e91 100644 --- a/tests/OpenCode.Sdk.Tools.Tests/Support/OperationBuilder.cs +++ b/tests/OpenCode.Sdk.Tools.Tests/Support/OperationBuilder.cs @@ -106,30 +106,12 @@ public OperationBuilder ResponseFromFixture(int status, string mediaType, string return this; } - public OperationBuilder ResponseWithHeader(int status) - { - _responses.AddWithHeader(status); - return this; - } - - public OperationBuilder ResponseWithItemSchema(int status) - { - _responses.AddWithItemSchema(status); - return this; - } - public OperationBuilder ResponseMediaTypes(int status, params string[] mediaTypes) { _responses.AddMediaTypes(status, mediaTypes); return this; } - public OperationBuilder ResponseWithEncoding(int status) - { - _responses.AddWithEncoding(status); - return this; - } - public OperationBuilder DefaultResponse() { _responses.AddDefault(); @@ -202,18 +184,6 @@ public OperationBuilder WithIgnoredTagsAndSecurity() return this; } - public OperationBuilder Callback() - { - _operation["callbacks"] = new JsonObject - { - ["result"] = new JsonObject - { - ["{$request.body#/callbackUrl}"] = new JsonObject(), - }, - }; - return this; - } - internal JsonObject Build() => _operation.DeepClone().AsObject(); private static JsonObject BuildSchema(Action configure) diff --git a/tests/OpenCode.Sdk.Tools.Tests/Support/OperationProjectionTestHost.cs b/tests/OpenCode.Sdk.Tools.Tests/Support/OperationProjectionTestHost.cs index bfeb6fa7..0662bee0 100644 --- a/tests/OpenCode.Sdk.Tools.Tests/Support/OperationProjectionTestHost.cs +++ b/tests/OpenCode.Sdk.Tools.Tests/Support/OperationProjectionTestHost.cs @@ -3,14 +3,12 @@ using OpenCode.Sdk.Tools.Generator.Ingestion; using OpenCode.Sdk.Tools.Generator.Ingestion.Models; using OpenCode.Sdk.Tools.Generator.Ingestion.Projection; -using OpenCode.Sdk.Tools.Generator.Ingestion.Walls; namespace OpenCode.Sdk.Tools.Tests.Support; internal sealed class OperationProjectionTestHost { private readonly GraphKeyBuilder _keys = new(); - private readonly SchemaWallPolicy _schemaWall = new(); public async Task ProjectAsync(SpecScenario scenario) { @@ -35,7 +33,7 @@ private async Task ProjectCoreAsync(SpecScenario scen try { var loaded = await new SpecReader(context.FileSystem).LoadAsync(context.SpecPath, errors, CancellationToken.None); - var schemaProjector = new SchemaProjector(_schemaWall, _keys); + var schemaProjector = new SchemaProjector(_keys); var state = new ProjectionState(errors, loaded.Document, ImmutableDictionary.Empty); var operations = new OperationProjector(schemaProjector, _keys).Project(loaded, state); diff --git a/tests/OpenCode.Sdk.Tools.Tests/Support/OperationResponseBuilder.cs b/tests/OpenCode.Sdk.Tools.Tests/Support/OperationResponseBuilder.cs index 3f978fb0..9049bb5e 100644 --- a/tests/OpenCode.Sdk.Tools.Tests/Support/OperationResponseBuilder.cs +++ b/tests/OpenCode.Sdk.Tools.Tests/Support/OperationResponseBuilder.cs @@ -47,31 +47,6 @@ public void AddFromFixture(int status, string mediaType, string fixtureName) _responses[FormatStatus(status)] = CreateResponse(mediaType, JsonNode.Parse(_fixtureLoader.Load(fixtureName))); } - public void AddWithHeader(int status) - { - Add(status, mediaType: null, configureSchema: null); - _responses[FormatStatus(status)]!["headers"] = new JsonObject - { - ["x-test"] = new JsonObject - { - ["schema"] = new JsonObject - { - ["type"] = "string", - }, - }, - }; - } - - public void AddWithItemSchema(int status) - { - Add(status, "application/json", configureSchema: null); - var media = _responses[FormatStatus(status)]!["content"]!["application/json"]!; - media["itemSchema"] = new JsonObject - { - ["type"] = "string", - }; - } - public void AddMediaTypes(int status, IReadOnlyList mediaTypes) { ValidateStatus(status); @@ -95,21 +70,6 @@ public void AddMediaTypes(int status, IReadOnlyList mediaTypes) }; } - public void AddWithEncoding(int status) - { - Add(status, "application/json", schema => schema - .Type("object") - .Property("data", property => property.Type("string"))); - var media = _responses[FormatStatus(status)]!["content"]!["application/json"]!; - media["encoding"] = new JsonObject - { - ["data"] = new JsonObject - { - ["contentType"] = "text/plain", - }, - }; - } - public void AddDefault() { _responses["default"] = new JsonObject diff --git a/tests/OpenCode.Sdk.Tools.Tests/Support/SchemaProjectionTestHost.cs b/tests/OpenCode.Sdk.Tools.Tests/Support/SchemaProjectionTestHost.cs index 5a3be4a5..4df59fe3 100644 --- a/tests/OpenCode.Sdk.Tools.Tests/Support/SchemaProjectionTestHost.cs +++ b/tests/OpenCode.Sdk.Tools.Tests/Support/SchemaProjectionTestHost.cs @@ -3,14 +3,12 @@ using OpenCode.Sdk.Tools.Generator.Ingestion; using OpenCode.Sdk.Tools.Generator.Ingestion.Models; using OpenCode.Sdk.Tools.Generator.Ingestion.Projection; -using OpenCode.Sdk.Tools.Generator.Ingestion.Walls; namespace OpenCode.Sdk.Tools.Tests.Support; internal sealed class SchemaProjectionTestHost { private readonly GraphKeyBuilder _keys = new(); - private readonly SchemaWallPolicy _wall = new(); public async Task ProjectAsync(SpecScenario scenario) { @@ -35,7 +33,7 @@ private async Task ProjectCoreAsync(SpecScenario scenari try { var loaded = await new SpecReader(context.FileSystem).LoadAsync(context.SpecPath, errors, CancellationToken.None); - var projector = new SchemaProjector(_wall, _keys); + var projector = new SchemaProjector(_keys); var rawPointerLookup = ImmutableDictionary.Empty; var state = new ProjectionState(errors, loaded.Document, rawPointerLookup); diff --git a/tests/OpenCode.Sdk.Tools.Tests/Support/SpecDocumentBuilder.cs b/tests/OpenCode.Sdk.Tools.Tests/Support/SpecDocumentBuilder.cs index b2042b38..ccfb3f13 100644 --- a/tests/OpenCode.Sdk.Tools.Tests/Support/SpecDocumentBuilder.cs +++ b/tests/OpenCode.Sdk.Tools.Tests/Support/SpecDocumentBuilder.cs @@ -96,61 +96,6 @@ public SpecDocumentBuilder WithOperation(string operationId, string method = "ge return this; } - public SpecDocumentBuilder WithWebhook() - { - _root["webhooks"] = new JsonObject - { - ["test"] = new JsonObject - { - ["post"] = new JsonObject - { - ["operationId"] = "webhook.test", - ["responses"] = new JsonObject - { - ["200"] = new JsonObject - { - ["description"] = "Response", - }, - }, - }, - }, - }; - return this; - } - - public SpecDocumentBuilder WithNonSchemaComponent() - { - _root["components"]!["parameters"] = new JsonObject - { - ["test"] = new JsonObject - { - ["name"] = "test", - ["in"] = "query", - ["schema"] = new JsonObject - { - ["type"] = "string", - }, - }, - }; - return this; - } - - public SpecDocumentBuilder WithPathServer(string operationId, string path = "/api/x") - { - ArgumentException.ThrowIfNullOrWhiteSpace(operationId); - ArgumentException.ThrowIfNullOrWhiteSpace(path); - - WithOperation(operationId, path: path); - _paths[path]!["servers"] = new JsonArray - { - new JsonObject - { - ["url"] = "https://example.test", - }, - }; - return this; - } - public SpecDocumentBuilder WithRawTopLevel(string key, string rawJson) { ArgumentException.ThrowIfNullOrWhiteSpace(key); diff --git a/tools/OpenCode.Sdk.Tools/Generator/Ingestion/Models/SpecOperation.cs b/tools/OpenCode.Sdk.Tools/Generator/Ingestion/Models/SpecOperation.cs index 688a4f6d..36a97943 100644 --- a/tools/OpenCode.Sdk.Tools/Generator/Ingestion/Models/SpecOperation.cs +++ b/tools/OpenCode.Sdk.Tools/Generator/Ingestion/Models/SpecOperation.cs @@ -92,15 +92,4 @@ public required IReadOnlyList Responses field = Array.AsReadOnly([.. value]); } } = Array.AsReadOnly(Array.Empty()); - - /// Gets the canonical raw-content hash, when hash projection is enabled. - public required string RawContentHash - { - get; - init - { - ArgumentNullException.ThrowIfNull(value); - field = value; - } - } } diff --git a/tools/OpenCode.Sdk.Tools/Generator/Ingestion/Projection/MediaTypeProjector.cs b/tools/OpenCode.Sdk.Tools/Generator/Ingestion/Projection/MediaTypeProjector.cs index cf4a8d26..bc936d24 100644 --- a/tools/OpenCode.Sdk.Tools/Generator/Ingestion/Projection/MediaTypeProjector.cs +++ b/tools/OpenCode.Sdk.Tools/Generator/Ingestion/Projection/MediaTypeProjector.cs @@ -1,14 +1,11 @@ using Microsoft.OpenApi; using OpenCode.Sdk.Tools.Generator.Ingestion.Models; -using OpenCode.Sdk.Tools.Generator.Ingestion.Walls; namespace OpenCode.Sdk.Tools.Generator.Ingestion.Projection; -internal sealed class MediaTypeProjector(MediaTypeWallPolicy wall) +internal static class MediaTypeProjector { - private readonly MediaTypeWallPolicy _wall = wall ?? throw new ArgumentNullException(nameof(wall)); - - public MediaTypeProjection? Project(string raw, IOpenApiMediaType media, string location, IngestionErrorCollector errors) + public static MediaTypeProjection? Project(string raw, IOpenApiMediaType media, string location, IngestionErrorCollector errors) { ArgumentException.ThrowIfNullOrWhiteSpace(raw); ArgumentNullException.ThrowIfNull(media); @@ -26,7 +23,38 @@ internal sealed class MediaTypeProjector(MediaTypeWallPolicy wall) return null; } - var effectStreamJson = _wall.Check(media, contentType, location, errors); - return new MediaTypeProjection(contentType, effectStreamJson); + if (media is not OpenApiMediaType concrete) + { + errors.Add(location, $"media type implementation '{media.GetType().Name}' is not supported"); + return null; + } + + return new MediaTypeProjection(contentType, ExtractEffectStream(concrete, contentType, location, errors)); + } + + private static string? ExtractEffectStream(OpenApiMediaType media, SpecMediaType contentType, string location, + IngestionErrorCollector errors) + { + // x-effect-stream is the one media extension with declared semantics (the SSE item + // contract); it must be JSON and may only sit on text/event-stream. Other extensions + // carry no generator semantics and are ignored. + if (media.Extensions is null || !media.Extensions.TryGetValue("x-effect-stream", out var extension)) + { + return null; + } + + if (!contentType.IsEventStream) + { + errors.Add(location, "media extension 'x-effect-stream' is supported only on text/event-stream"); + return null; + } + + if (extension is JsonNodeExtension json) + { + return json.Node.ToJsonString(); + } + + errors.Add(location, "media extension 'x-effect-stream' must contain JSON"); + return null; } } diff --git a/tools/OpenCode.Sdk.Tools/Generator/Ingestion/Projection/OperationProjector.cs b/tools/OpenCode.Sdk.Tools/Generator/Ingestion/Projection/OperationProjector.cs index b0083db8..f096645e 100644 --- a/tools/OpenCode.Sdk.Tools/Generator/Ingestion/Projection/OperationProjector.cs +++ b/tools/OpenCode.Sdk.Tools/Generator/Ingestion/Projection/OperationProjector.cs @@ -7,12 +7,9 @@ namespace OpenCode.Sdk.Tools.Generator.Ingestion.Projection; internal sealed class OperationProjector { - private readonly DocumentWallPolicy _documentWall = new(); private readonly OperationIdentityParser _identityParser = new(); private readonly GraphKeyBuilder _keys; - private readonly OperationWallPolicy _operationWall = new(); private readonly ParameterProjector _parameters; - private readonly PathItemWallPolicy _pathItemWall = new(); private readonly PathTemplateValidator _pathTemplates = new(); private readonly RequestBodyProjector _requestBodies; private readonly ResponseProjector _responses; @@ -22,10 +19,9 @@ public OperationProjector(SchemaProjector schemaProjector, GraphKeyBuilder keys) ArgumentNullException.ThrowIfNull(schemaProjector); _keys = keys ?? throw new ArgumentNullException(nameof(keys)); - var mediaTypes = new MediaTypeProjector(new MediaTypeWallPolicy()); - _parameters = new ParameterProjector(schemaProjector, keys, new ParameterWallPolicy()); - _requestBodies = new RequestBodyProjector(schemaProjector, keys, new RequestBodyWallPolicy(), mediaTypes); - _responses = new ResponseProjector(schemaProjector, keys, new ResponseWallPolicy(), mediaTypes, new EnvelopeClassifier()); + _parameters = new ParameterProjector(schemaProjector, keys); + _requestBodies = new RequestBodyProjector(schemaProjector, keys); + _responses = new ResponseProjector(schemaProjector, keys, new EnvelopeClassifier()); } public IReadOnlyList Project(LoadedSpec loaded, ProjectionState state) @@ -33,7 +29,6 @@ public IReadOnlyList Project(LoadedSpec loaded, ProjectionState s ArgumentNullException.ThrowIfNull(loaded); ArgumentNullException.ThrowIfNull(state); - _documentWall.Check(loaded.Document, state.Errors); if (loaded.Raw is not JsonObject rawDocument || rawDocument["paths"] is not JsonObject rawPaths) { state.Errors.Add("document/paths", "raw paths object was unavailable"); @@ -52,7 +47,7 @@ public IReadOnlyList Project(LoadedSpec loaded, ProjectionState s private void ProjectPath(string path, IOpenApiPathItem pathItem, OperationProjectionContext context) { var pathLocation = $"paths/{path}"; - _pathItemWall.Check(pathItem, pathLocation, context.State.Errors); + PathItemWallPolicy.Check(pathItem, pathLocation, context.State.Errors); if (pathItem is not OpenApiPathItem { Operations: not null } concrete) { return; @@ -88,7 +83,7 @@ private void ProjectOperation(string path, KeyValuePair Project(OpenApiOperation operation, JsonObject rawOperation, string root, ProjectionState state) @@ -50,7 +48,7 @@ public IReadOnlyList Project(OpenApiOperation operation, JsonObje var parameter = parameters[index]; var errorCount = state.Errors.Count; - var isDeepObject = _wall.Check(parameter, rawParameter, location, state.Errors); + var isDeepObject = ParameterWallPolicy.Check(parameter, rawParameter, location, state.Errors); if (string.IsNullOrWhiteSpace(parameter.Name)) { state.Errors.Add(location, "parameter name is required"); diff --git a/tools/OpenCode.Sdk.Tools/Generator/Ingestion/Projection/RequestBodyProjector.cs b/tools/OpenCode.Sdk.Tools/Generator/Ingestion/Projection/RequestBodyProjector.cs index 5e9fcc64..58649020 100644 --- a/tools/OpenCode.Sdk.Tools/Generator/Ingestion/Projection/RequestBodyProjector.cs +++ b/tools/OpenCode.Sdk.Tools/Generator/Ingestion/Projection/RequestBodyProjector.cs @@ -1,22 +1,17 @@ using Microsoft.OpenApi; using OpenCode.Sdk.Tools.Generator.Ingestion.Models; -using OpenCode.Sdk.Tools.Generator.Ingestion.Walls; namespace OpenCode.Sdk.Tools.Generator.Ingestion.Projection; internal sealed class RequestBodyProjector { private readonly GraphKeyBuilder _keys; - private readonly MediaTypeProjector _mediaTypes; private readonly SchemaProjector _schemaProjector; - private readonly RequestBodyWallPolicy _wall; - public RequestBodyProjector(SchemaProjector schemaProjector, GraphKeyBuilder keys, RequestBodyWallPolicy wall, MediaTypeProjector mediaTypes) + public RequestBodyProjector(SchemaProjector schemaProjector, GraphKeyBuilder keys) { _schemaProjector = schemaProjector ?? throw new ArgumentNullException(nameof(schemaProjector)); _keys = keys ?? throw new ArgumentNullException(nameof(keys)); - _wall = wall ?? throw new ArgumentNullException(nameof(wall)); - _mediaTypes = mediaTypes ?? throw new ArgumentNullException(nameof(mediaTypes)); } public SpecRequestBody? Project(IOpenApiRequestBody? requestBody, string root, ProjectionState state) @@ -32,15 +27,23 @@ public RequestBodyProjector(SchemaProjector schemaProjector, GraphKeyBuilder key var pointer = _keys.Append(string.Empty, "requestBody"); var location = string.Concat(root, pointer); var errorCount = state.Errors.Count; - if (!_wall.Check(requestBody, location, state.Errors) || requestBody.Content is not { Count: 1 } content) + if (requestBody is not OpenApiRequestBody) { + state.Errors.Add(location, $"request body implementation '{requestBody.GetType().Name}' is not supported"); + return null; + } + + // A multi-media body has no single serialization the generated method could pick. + if (requestBody.Content is not { Count: 1 } content) + { + state.Errors.Add(location, "request body must contain exactly one media type"); return null; } var mediaEntry = content.Single(); var mediaPointer = _keys.Append(_keys.Append(pointer, "content"), mediaEntry.Key); var mediaLocation = string.Concat(root, mediaPointer); - var media = _mediaTypes.Project(mediaEntry.Key, mediaEntry.Value, mediaLocation, state.Errors); + var media = MediaTypeProjector.Project(mediaEntry.Key, mediaEntry.Value, mediaLocation, state.Errors); if (media is null) { return null; diff --git a/tools/OpenCode.Sdk.Tools/Generator/Ingestion/Projection/ResponseProjector.cs b/tools/OpenCode.Sdk.Tools/Generator/Ingestion/Projection/ResponseProjector.cs index 7610315c..0e2cabb9 100644 --- a/tools/OpenCode.Sdk.Tools/Generator/Ingestion/Projection/ResponseProjector.cs +++ b/tools/OpenCode.Sdk.Tools/Generator/Ingestion/Projection/ResponseProjector.cs @@ -1,7 +1,6 @@ using System.Globalization; using Microsoft.OpenApi; using OpenCode.Sdk.Tools.Generator.Ingestion.Models; -using OpenCode.Sdk.Tools.Generator.Ingestion.Walls; namespace OpenCode.Sdk.Tools.Generator.Ingestion.Projection; @@ -9,17 +8,12 @@ internal sealed class ResponseProjector { private readonly EnvelopeClassifier _envelopes; private readonly GraphKeyBuilder _keys; - private readonly MediaTypeProjector _mediaTypes; private readonly SchemaProjector _schemaProjector; - private readonly ResponseWallPolicy _wall; - public ResponseProjector(SchemaProjector schemaProjector, GraphKeyBuilder keys, ResponseWallPolicy wall, MediaTypeProjector mediaTypes, - EnvelopeClassifier envelopes) + public ResponseProjector(SchemaProjector schemaProjector, GraphKeyBuilder keys, EnvelopeClassifier envelopes) { _schemaProjector = schemaProjector ?? throw new ArgumentNullException(nameof(schemaProjector)); _keys = keys ?? throw new ArgumentNullException(nameof(keys)); - _wall = wall ?? throw new ArgumentNullException(nameof(wall)); - _mediaTypes = mediaTypes ?? throw new ArgumentNullException(nameof(mediaTypes)); _envelopes = envelopes ?? throw new ArgumentNullException(nameof(envelopes)); } @@ -72,8 +66,16 @@ public IReadOnlyList Project(OpenApiResponses? responses, string r var pointer = _keys.Append(_keys.Append(string.Empty, "responses"), status); var location = string.Concat(root, pointer); var errorCount = state.Errors.Count; - if (!_wall.Check(response, location, state.Errors)) + if (response is not OpenApiResponse) { + state.Errors.Add(location, $"response implementation '{response.GetType().Name}' is not supported"); + return null; + } + + // Two media entries would leave the envelope/payload mapping ambiguous. + if (response.Content is { Count: > 1 }) + { + state.Errors.Add(location, "response content supports at most one media type"); return null; } @@ -96,7 +98,7 @@ public IReadOnlyList Project(OpenApiResponses? responses, string r var mediaEntry = content.Single(); var mediaPointer = _keys.Append(_keys.Append(pointer, "content"), mediaEntry.Key); var mediaLocation = string.Concat(root, mediaPointer); - var media = _mediaTypes.Project(mediaEntry.Key, mediaEntry.Value, mediaLocation, state.Errors); + var media = MediaTypeProjector.Project(mediaEntry.Key, mediaEntry.Value, mediaLocation, state.Errors); if (media is null) { return null; diff --git a/tools/OpenCode.Sdk.Tools/Generator/Ingestion/Projection/SchemaProjector.cs b/tools/OpenCode.Sdk.Tools/Generator/Ingestion/Projection/SchemaProjector.cs index 171dbccc..540efb8f 100644 --- a/tools/OpenCode.Sdk.Tools/Generator/Ingestion/Projection/SchemaProjector.cs +++ b/tools/OpenCode.Sdk.Tools/Generator/Ingestion/Projection/SchemaProjector.cs @@ -14,18 +14,16 @@ internal sealed class SchemaProjector private readonly PrefixItemsAdapter _prefixItemsAdapter; private readonly UnionNormalizer _unionNormalizer; private readonly SchemaShapeClassifier _shapeClassifier; - private readonly SchemaWallPolicy _wall; - public SchemaProjector(SchemaWallPolicy wall, GraphKeyBuilder keys) + public SchemaProjector(GraphKeyBuilder keys) { - _wall = wall ?? throw new ArgumentNullException(nameof(wall)); _keys = keys ?? throw new ArgumentNullException(nameof(keys)); _shapeClassifier = new SchemaShapeClassifier(); _enumProjector = new EnumSchemaProjector(); _literalClassifier = new LiteralClassifier(); _jsonStringProjector = new JsonStringSchemaProjector(this, _keys); _objectProjector = new ObjectSchemaProjector(this, _keys, new ErrorStyleClassifier()); - _unionNormalizer = new UnionNormalizer(this, _wall, _keys, _shapeClassifier, _literalClassifier); + _unionNormalizer = new UnionNormalizer(this, _keys, _shapeClassifier, _literalClassifier); _prefixItemsAdapter = new PrefixItemsAdapter(this, _keys); } @@ -55,7 +53,7 @@ public SchemaProjector(SchemaWallPolicy wall, GraphKeyBuilder keys) } var errorCount = state.Errors.Count; - _wall.Check(concrete, location, state.Errors); + SchemaWallPolicy.Check(concrete, location, state.Errors); if (state.Errors.Count != errorCount) { return null; diff --git a/tools/OpenCode.Sdk.Tools/Generator/Ingestion/Projection/UnionNormalizer.cs b/tools/OpenCode.Sdk.Tools/Generator/Ingestion/Projection/UnionNormalizer.cs index 6fd14dfe..61f35e9c 100644 --- a/tools/OpenCode.Sdk.Tools/Generator/Ingestion/Projection/UnionNormalizer.cs +++ b/tools/OpenCode.Sdk.Tools/Generator/Ingestion/Projection/UnionNormalizer.cs @@ -18,13 +18,11 @@ internal sealed class UnionNormalizer private readonly LiteralClassifier _literalClassifier; private readonly SchemaProjector _schemaProjector; private readonly SchemaShapeClassifier _shapeClassifier; - private readonly SchemaWallPolicy _wall; - public UnionNormalizer(SchemaProjector schemaProjector, SchemaWallPolicy wall, GraphKeyBuilder keys, SchemaShapeClassifier shapeClassifier, + public UnionNormalizer(SchemaProjector schemaProjector, GraphKeyBuilder keys, SchemaShapeClassifier shapeClassifier, LiteralClassifier literalClassifier) { _schemaProjector = schemaProjector ?? throw new ArgumentNullException(nameof(schemaProjector)); - _wall = wall ?? throw new ArgumentNullException(nameof(wall)); _keys = keys ?? throw new ArgumentNullException(nameof(keys)); _shapeClassifier = shapeClassifier ?? throw new ArgumentNullException(nameof(shapeClassifier)); _literalClassifier = literalClassifier ?? throw new ArgumentNullException(nameof(literalClassifier)); @@ -120,7 +118,7 @@ private bool ValidateSpecialNumberBranches(IList branches, strin } var branchPointer = _keys.UnionBranch(pointer, keyword, index, marker: null); - _wall.Check(branch, string.Concat(root, branchPointer), state.Errors); + SchemaWallPolicy.Check(branch, string.Concat(root, branchPointer), state.Errors); } return state.Errors.Count == errorCount; @@ -140,7 +138,7 @@ private bool ValidateSpecialNumberBranches(IList branches, strin if (source is OpenApiSchema { Type: JsonSchemaType.Null } concrete) { var errorCount = state.Errors.Count; - _wall.Check(concrete, branchLocation, state.Errors); + SchemaWallPolicy.Check(concrete, branchLocation, state.Errors); var shape = _shapeClassifier.Classify(concrete, branchLocation, state.Errors); if (state.Errors.Count == errorCount && shape is CoreSchemaShape.Null) { diff --git a/tools/OpenCode.Sdk.Tools/Generator/Ingestion/Walls/DocumentWallPolicy.cs b/tools/OpenCode.Sdk.Tools/Generator/Ingestion/Walls/DocumentWallPolicy.cs deleted file mode 100644 index b08914d6..00000000 --- a/tools/OpenCode.Sdk.Tools/Generator/Ingestion/Walls/DocumentWallPolicy.cs +++ /dev/null @@ -1,33 +0,0 @@ -using Microsoft.OpenApi; - -namespace OpenCode.Sdk.Tools.Generator.Ingestion.Walls; - -internal sealed class DocumentWallPolicy -{ - private readonly HostMemberWhitelist _componentMembers = new("components", [nameof(OpenApiComponents.Schemas)]); - - private readonly HostMemberWhitelist _documentMembers = new("document", - [ - nameof(OpenApiDocument.BaseUri), - nameof(OpenApiDocument.Components), - nameof(OpenApiDocument.Info), - nameof(OpenApiDocument.Metadata), - nameof(OpenApiDocument.Paths), - nameof(OpenApiDocument.Security), - nameof(OpenApiDocument.Self), - nameof(OpenApiDocument.Tags), - nameof(OpenApiDocument.Workspace), - ]); - - public void Check(OpenApiDocument document, IngestionErrorCollector errors) - { - ArgumentNullException.ThrowIfNull(document); - ArgumentNullException.ThrowIfNull(errors); - - _documentMembers.Check(document, "document", errors); - if (document.Components is not null) - { - _componentMembers.Check(document.Components, "document/components", errors); - } - } -} diff --git a/tools/OpenCode.Sdk.Tools/Generator/Ingestion/Walls/HostMemberWhitelist.cs b/tools/OpenCode.Sdk.Tools/Generator/Ingestion/Walls/HostMemberWhitelist.cs deleted file mode 100644 index 3442cd14..00000000 --- a/tools/OpenCode.Sdk.Tools/Generator/Ingestion/Walls/HostMemberWhitelist.cs +++ /dev/null @@ -1,77 +0,0 @@ -using System.Collections; -using System.Collections.Frozen; -using System.Reflection; - -namespace OpenCode.Sdk.Tools.Generator.Ingestion.Walls; - -internal sealed class HostMemberWhitelist - where T : class, new() -{ - private readonly T _defaultValue = new(); - private readonly FrozenSet _exemptMembers; - private readonly string _hostName; - private readonly PropertyInfo[] _members; - - public HostMemberWhitelist(string hostName, IEnumerable exemptMembers) - { - ArgumentException.ThrowIfNullOrWhiteSpace(hostName); - ArgumentNullException.ThrowIfNull(exemptMembers); - - _hostName = hostName; - _exemptMembers = exemptMembers.ToFrozenSet(StringComparer.Ordinal); - _members = - [ - .. typeof(T) - .GetProperties(BindingFlags.Instance | BindingFlags.Public) - .OrderBy(static property => property.Name, StringComparer.Ordinal), - ]; - } - - public void Check(T value, string location, IngestionErrorCollector errors) - { - ArgumentNullException.ThrowIfNull(value); - ArgumentException.ThrowIfNullOrWhiteSpace(location); - ArgumentNullException.ThrowIfNull(errors); - - foreach (var member in _members) - { - if (_exemptMembers.Contains(member.Name) || !IsPopulated(member, value)) - { - continue; - } - - errors.Add(location, $"{_hostName} member '{GetWireName(member.Name)}' is not supported"); - } - } - - private static string GetWireName(string memberName) => - $"{char.ToLowerInvariant(memberName[0])}{memberName[1..]}"; - - private static bool IsCollection(Type type) => - type != typeof(string) - && typeof(IEnumerable).IsAssignableFrom(type); - - private bool IsPopulated(PropertyInfo member, T value) - { - var memberValue = member.GetValue(value); - if (memberValue is null || Equals(memberValue, member.GetValue(_defaultValue))) - { - return false; - } - - if (!IsCollection(member.PropertyType)) - { - return true; - } - - var enumerator = ((IEnumerable)memberValue).GetEnumerator(); - try - { - return enumerator.MoveNext(); - } - finally - { - (enumerator as IDisposable)?.Dispose(); - } - } -} diff --git a/tools/OpenCode.Sdk.Tools/Generator/Ingestion/Walls/MediaTypeWallPolicy.cs b/tools/OpenCode.Sdk.Tools/Generator/Ingestion/Walls/MediaTypeWallPolicy.cs deleted file mode 100644 index c9f76f15..00000000 --- a/tools/OpenCode.Sdk.Tools/Generator/Ingestion/Walls/MediaTypeWallPolicy.cs +++ /dev/null @@ -1,59 +0,0 @@ -using Microsoft.OpenApi; -using OpenCode.Sdk.Tools.Generator.Ingestion.Models; - -namespace OpenCode.Sdk.Tools.Generator.Ingestion.Walls; - -internal sealed class MediaTypeWallPolicy -{ - private readonly HostMemberWhitelist _members = new("media type", - [ - nameof(OpenApiMediaType.Extensions), - nameof(OpenApiMediaType.Schema), - ]); - - public string? Check(IOpenApiMediaType media, SpecMediaType contentType, string location, IngestionErrorCollector errors) - { - ArgumentNullException.ThrowIfNull(media); - ArgumentNullException.ThrowIfNull(contentType); - ArgumentException.ThrowIfNullOrWhiteSpace(location); - ArgumentNullException.ThrowIfNull(errors); - - if (media is not OpenApiMediaType concrete) - { - errors.Add(location, $"media type implementation '{media.GetType().Name}' is not supported"); - return null; - } - - _members.Check(concrete, location, errors); - string? effectStreamJson = null; - if (concrete.Extensions is null) - { - return effectStreamJson; - } - - foreach (var (name, extension) in concrete.Extensions.OrderBy(static pair => pair.Key, StringComparer.Ordinal)) - { - if (string.Equals(name, "x-effect-stream", StringComparison.Ordinal) && contentType.IsEventStream) - { - if (extension is JsonNodeExtension json) - { - effectStreamJson = json.Node.ToJsonString(); - } - else - { - errors.Add(location, "media extension 'x-effect-stream' must contain JSON"); - } - } - else if (string.Equals(name, "x-effect-stream", StringComparison.Ordinal)) - { - errors.Add(location, "media extension 'x-effect-stream' is supported only on text/event-stream"); - } - else - { - errors.Add(location, $"media extension '{name}' is not supported"); - } - } - - return effectStreamJson; - } -} diff --git a/tools/OpenCode.Sdk.Tools/Generator/Ingestion/Walls/OperationWallPolicy.cs b/tools/OpenCode.Sdk.Tools/Generator/Ingestion/Walls/OperationExtensionPolicy.cs similarity index 53% rename from tools/OpenCode.Sdk.Tools/Generator/Ingestion/Walls/OperationWallPolicy.cs rename to tools/OpenCode.Sdk.Tools/Generator/Ingestion/Walls/OperationExtensionPolicy.cs index 9d96e42f..9418ae7b 100644 --- a/tools/OpenCode.Sdk.Tools/Generator/Ingestion/Walls/OperationWallPolicy.cs +++ b/tools/OpenCode.Sdk.Tools/Generator/Ingestion/Walls/OperationExtensionPolicy.cs @@ -3,42 +3,27 @@ namespace OpenCode.Sdk.Tools.Generator.Ingestion.Walls; -internal sealed class OperationWallPolicy +internal static class OperationExtensionPolicy { - private readonly HostMemberWhitelist _members = new("operation", - [ - nameof(OpenApiOperation.Deprecated), - nameof(OpenApiOperation.Description), - nameof(OpenApiOperation.Extensions), - nameof(OpenApiOperation.Metadata), - nameof(OpenApiOperation.OperationId), - nameof(OpenApiOperation.Parameters), - nameof(OpenApiOperation.RequestBody), - nameof(OpenApiOperation.Responses), - nameof(OpenApiOperation.Security), - nameof(OpenApiOperation.Summary), - nameof(OpenApiOperation.Tags), - ]); - - public bool Check(OpenApiOperation operation, string location, IngestionErrorCollector errors) + public static bool Check(OpenApiOperation operation, string location, IngestionErrorCollector errors) { ArgumentNullException.ThrowIfNull(operation); ArgumentException.ThrowIfNullOrWhiteSpace(location); ArgumentNullException.ThrowIfNull(errors); - _members.Check(operation, location, errors); var isWebSocket = false; if (operation.Extensions is null) { return isWebSocket; } + // Only extensions with declared generator semantics are interpreted; x-codeSamples + // is known descriptive input and anything else is ignored — an unknown extension + // cannot change emitted behavior, so it is not a generation failure. foreach (var (name, extension) in operation.Extensions.OrderBy(static pair => pair.Key, StringComparer.Ordinal)) { switch (name) { - case "x-codeSamples": - break; case "x-websocket" when extension is JsonNodeExtension { Node: JsonValue value } && value.TryGetValue(out var flag): isWebSocket = flag; break; @@ -46,7 +31,6 @@ public bool Check(OpenApiOperation operation, string location, IngestionErrorCol errors.Add(location, "operation extension 'x-websocket' must be a boolean"); break; default: - errors.Add(location, $"operation extension '{name}' is not supported"); break; } } diff --git a/tools/OpenCode.Sdk.Tools/Generator/Ingestion/Walls/ParameterWallPolicy.cs b/tools/OpenCode.Sdk.Tools/Generator/Ingestion/Walls/ParameterWallPolicy.cs index 11105bfa..8c879a46 100644 --- a/tools/OpenCode.Sdk.Tools/Generator/Ingestion/Walls/ParameterWallPolicy.cs +++ b/tools/OpenCode.Sdk.Tools/Generator/Ingestion/Walls/ParameterWallPolicy.cs @@ -3,19 +3,9 @@ namespace OpenCode.Sdk.Tools.Generator.Ingestion.Walls; -internal sealed class ParameterWallPolicy +internal static class ParameterWallPolicy { - private readonly HostMemberWhitelist _members = new("parameter", - [ - nameof(OpenApiParameter.Explode), - nameof(OpenApiParameter.In), - nameof(OpenApiParameter.Name), - nameof(OpenApiParameter.Required), - nameof(OpenApiParameter.Schema), - nameof(OpenApiParameter.Style), - ]); - - public bool Check(IOpenApiParameter parameter, JsonObject rawParameter, string location, IngestionErrorCollector errors) + public static bool Check(IOpenApiParameter parameter, JsonObject rawParameter, string location, IngestionErrorCollector errors) { ArgumentNullException.ThrowIfNull(parameter); ArgumentNullException.ThrowIfNull(rawParameter); @@ -28,12 +18,18 @@ public bool Check(IOpenApiParameter parameter, JsonObject rawParameter, string l return false; } - _members.Check(concrete, location, errors); + // Every check here guards request serialization: a mislocated, content-encoded, + // or differently styled parameter would produce a wrong request on the wire. if (concrete.In is not ParameterLocation.Path and not ParameterLocation.Query) { errors.Add(location, $"parameter location '{GetLocationName(concrete.In)}' is not supported"); } + if (concrete.Content is { Count: > 0 }) + { + errors.Add(location, "content-based parameters are not supported"); + } + var hasStyle = rawParameter.ContainsKey("style"); var hasExplode = rawParameter.ContainsKey("explode"); switch (hasStyle) diff --git a/tools/OpenCode.Sdk.Tools/Generator/Ingestion/Walls/PathItemWallPolicy.cs b/tools/OpenCode.Sdk.Tools/Generator/Ingestion/Walls/PathItemWallPolicy.cs index 30f868d5..8c702ebf 100644 --- a/tools/OpenCode.Sdk.Tools/Generator/Ingestion/Walls/PathItemWallPolicy.cs +++ b/tools/OpenCode.Sdk.Tools/Generator/Ingestion/Walls/PathItemWallPolicy.cs @@ -3,19 +3,12 @@ namespace OpenCode.Sdk.Tools.Generator.Ingestion.Walls; -internal sealed class PathItemWallPolicy +internal static class PathItemWallPolicy { private static readonly FrozenSet AdmittedMethods = new[] { "delete", "get", "patch", "post", "put", } .ToFrozenSet(StringComparer.Ordinal); - private readonly HostMemberWhitelist _members = new("path item", - [ - nameof(OpenApiPathItem.Operations), - nameof(OpenApiPathItem.Parameters), - nameof(OpenApiPathItem.Servers), - ]); - - public void Check(IOpenApiPathItem pathItem, string location, IngestionErrorCollector errors) + public static void Check(IOpenApiPathItem pathItem, string location, IngestionErrorCollector errors) { ArgumentNullException.ThrowIfNull(pathItem); ArgumentException.ThrowIfNullOrWhiteSpace(location); @@ -33,17 +26,13 @@ public void Check(IOpenApiPathItem pathItem, string location, IngestionErrorColl return; } - _members.Check(concrete, location, errors); + // Silently skipping either of these would drop a real parameter or a whole + // operation from the generated client — both are semantic loss, so they refuse. if (concrete.Parameters is { Count: > 0 }) { errors.Add(location, "path-level parameters are not supported"); } - if (concrete.Servers is { Count: > 0 }) - { - errors.Add(location, "path-level servers are not supported"); - } - if (concrete.Operations is null) { return; diff --git a/tools/OpenCode.Sdk.Tools/Generator/Ingestion/Walls/RequestBodyWallPolicy.cs b/tools/OpenCode.Sdk.Tools/Generator/Ingestion/Walls/RequestBodyWallPolicy.cs deleted file mode 100644 index 1664a55f..00000000 --- a/tools/OpenCode.Sdk.Tools/Generator/Ingestion/Walls/RequestBodyWallPolicy.cs +++ /dev/null @@ -1,34 +0,0 @@ -using Microsoft.OpenApi; - -namespace OpenCode.Sdk.Tools.Generator.Ingestion.Walls; - -internal sealed class RequestBodyWallPolicy -{ - private readonly HostMemberWhitelist _members = new("request body", - [ - nameof(OpenApiRequestBody.Content), - nameof(OpenApiRequestBody.Required), - ]); - - public bool Check(IOpenApiRequestBody requestBody, string location, IngestionErrorCollector errors) - { - ArgumentNullException.ThrowIfNull(requestBody); - ArgumentException.ThrowIfNullOrWhiteSpace(location); - ArgumentNullException.ThrowIfNull(errors); - - if (requestBody is not OpenApiRequestBody concrete) - { - errors.Add(location, $"request body implementation '{requestBody.GetType().Name}' is not supported"); - return false; - } - - _members.Check(concrete, location, errors); - if (concrete.Content is not { Count: 1 }) - { - errors.Add(location, "request body must contain exactly one media type"); - return false; - } - - return true; - } -} diff --git a/tools/OpenCode.Sdk.Tools/Generator/Ingestion/Walls/ResponseWallPolicy.cs b/tools/OpenCode.Sdk.Tools/Generator/Ingestion/Walls/ResponseWallPolicy.cs deleted file mode 100644 index 3cb6227e..00000000 --- a/tools/OpenCode.Sdk.Tools/Generator/Ingestion/Walls/ResponseWallPolicy.cs +++ /dev/null @@ -1,34 +0,0 @@ -using Microsoft.OpenApi; - -namespace OpenCode.Sdk.Tools.Generator.Ingestion.Walls; - -internal sealed class ResponseWallPolicy -{ - private readonly HostMemberWhitelist _members = new("response", - [ - nameof(OpenApiResponse.Content), - nameof(OpenApiResponse.Description), - ]); - - public bool Check(IOpenApiResponse response, string location, IngestionErrorCollector errors) - { - ArgumentNullException.ThrowIfNull(response); - ArgumentException.ThrowIfNullOrWhiteSpace(location); - ArgumentNullException.ThrowIfNull(errors); - - if (response is not OpenApiResponse concrete) - { - errors.Add(location, $"response implementation '{response.GetType().Name}' is not supported"); - return false; - } - - _members.Check(concrete, location, errors); - if (concrete.Content is { Count: > 1 }) - { - errors.Add(location, "response content supports at most one media type"); - return false; - } - - return true; - } -} diff --git a/tools/OpenCode.Sdk.Tools/Generator/Ingestion/Walls/SchemaMemberWhitelist.cs b/tools/OpenCode.Sdk.Tools/Generator/Ingestion/Walls/SchemaMemberWhitelist.cs deleted file mode 100644 index 3cee6694..00000000 --- a/tools/OpenCode.Sdk.Tools/Generator/Ingestion/Walls/SchemaMemberWhitelist.cs +++ /dev/null @@ -1,110 +0,0 @@ -using System.Collections; -using System.Collections.Frozen; -using System.Reflection; -using Microsoft.OpenApi; - -namespace OpenCode.Sdk.Tools.Generator.Ingestion.Walls; - -internal sealed class SchemaMemberWhitelist -{ - private static readonly FrozenSet AdmittedMembers = new[] - { - nameof(OpenApiSchema.AdditionalProperties), nameof(OpenApiSchema.AdditionalPropertiesAllowed), nameof(OpenApiSchema.AnyOf), - nameof(OpenApiSchema.Const), nameof(OpenApiSchema.ContentEncoding), nameof(OpenApiSchema.ContentMediaType), - nameof(OpenApiSchema.ContentSchema), nameof(OpenApiSchema.Description), nameof(OpenApiSchema.Enum), nameof(OpenApiSchema.Format), - nameof(OpenApiSchema.Items), nameof(OpenApiSchema.OneOf), nameof(OpenApiSchema.PatternProperties), nameof(OpenApiSchema.Properties), - nameof(OpenApiSchema.Required), nameof(OpenApiSchema.Type), - }.ToFrozenSet(StringComparer.Ordinal); - - private static readonly FrozenSet KnownIgnoredMembers = new[] - { - nameof(OpenApiSchema.ExclusiveMinimum), nameof(OpenApiSchema.Maximum), nameof(OpenApiSchema.MaxItems), nameof(OpenApiSchema.Minimum), - nameof(OpenApiSchema.MinItems), nameof(OpenApiSchema.Pattern), - }.ToFrozenSet(StringComparer.Ordinal); - - private static readonly FrozenSet SeparatelyCheckedMembers = - new[] { nameof(OpenApiSchema.Extensions), nameof(OpenApiSchema.UnrecognizedKeywords), }.ToFrozenSet(StringComparer.Ordinal); - - private static readonly FrozenDictionary WireNames = new Dictionary(StringComparer.Ordinal) - { - [nameof(OpenApiSchema.Anchor)] = "$anchor", - [nameof(OpenApiSchema.Comment)] = "$comment", - [nameof(OpenApiSchema.Definitions)] = "$defs", - [nameof(OpenApiSchema.DynamicAnchor)] = "$dynamicAnchor", - [nameof(OpenApiSchema.DynamicRef)] = "$dynamicRef", - [nameof(OpenApiSchema.Id)] = "$id", - [nameof(OpenApiSchema.Schema)] = "$schema", - [nameof(OpenApiSchema.UnevaluatedPropertiesSchema)] = "unevaluatedProperties", - [nameof(OpenApiSchema.Vocabulary)] = "$vocabulary", - }.ToFrozenDictionary(StringComparer.Ordinal); - - private readonly OpenApiSchema _defaultSchema = new(); - - private readonly PropertyInfo[] _schemaMembers = - [ - .. typeof(OpenApiSchema) - .GetProperties(BindingFlags.Instance | BindingFlags.Public) - .OrderBy(property => property.Name, StringComparer.Ordinal), - ]; - - public void Check(OpenApiSchema schema, string location, IngestionErrorCollector errors) - { - ArgumentNullException.ThrowIfNull(schema); - ArgumentException.ThrowIfNullOrWhiteSpace(location); - ArgumentNullException.ThrowIfNull(errors); - - foreach (var member in _schemaMembers) - { - if (IsExempt(member.Name) || !IsPopulated(member, schema)) - { - continue; - } - - errors.Add(location, $"schema keyword '{GetWireName(member.Name)}' is not supported"); - } - } - - private static string GetWireName(string memberName) - { - if (WireNames.TryGetValue(memberName, out var wireName)) - { - return wireName; - } - - return $"{char.ToLowerInvariant(memberName[0])}{memberName[1..]}"; - } - - private static bool IsCollection(Type type) => - type != typeof(string) - && typeof(IEnumerable).IsAssignableFrom(type); - - private static bool IsExempt(string memberName) => - AdmittedMembers.Contains(memberName) - || KnownIgnoredMembers.Contains(memberName) - || SeparatelyCheckedMembers.Contains(memberName) - || string.Equals(memberName, nameof(OpenApiSchema.Metadata), StringComparison.Ordinal); - - private bool IsPopulated(PropertyInfo member, OpenApiSchema schema) - { - var value = member.GetValue(schema); - if (value is null || Equals(value, member.GetValue(_defaultSchema))) - { - return false; - } - - if (!IsCollection(member.PropertyType)) - { - return true; - } - - var enumerator = ((IEnumerable)value).GetEnumerator(); - try - { - return enumerator.MoveNext(); - } - finally - { - (enumerator as IDisposable)?.Dispose(); - } - } -} diff --git a/tools/OpenCode.Sdk.Tools/Generator/Ingestion/Walls/SchemaWallPolicy.cs b/tools/OpenCode.Sdk.Tools/Generator/Ingestion/Walls/SchemaWallPolicy.cs index a6b2955c..6fde243d 100644 --- a/tools/OpenCode.Sdk.Tools/Generator/Ingestion/Walls/SchemaWallPolicy.cs +++ b/tools/OpenCode.Sdk.Tools/Generator/Ingestion/Walls/SchemaWallPolicy.cs @@ -2,43 +2,35 @@ namespace OpenCode.Sdk.Tools.Generator.Ingestion.Walls; -internal sealed class SchemaWallPolicy +internal static class SchemaWallPolicy { private const string PrefixItemsAdmissionLocation = "Config/properties/plugin/items/anyOf/1"; - private readonly SchemaMemberWhitelist _memberWhitelist = new(); - - public void Check(OpenApiSchema schema, string location, IngestionErrorCollector errors) + public static void Check(OpenApiSchema schema, string location, IngestionErrorCollector errors) { ArgumentNullException.ThrowIfNull(schema); ArgumentException.ThrowIfNullOrWhiteSpace(location); ArgumentNullException.ThrowIfNull(errors); - _memberWhitelist.Check(schema, location, errors); - if (schema.Type is { } type && ((int)type & ((int)type - 1)) != 0) { errors.Add(location, "schema keyword 'type' does not support multiple values"); } - if (schema.UnrecognizedKeywords is not null) - { - foreach (var keyword in schema - .UnrecognizedKeywords.Keys.Where(keyword => !IsAdmittedPrefixItems(keyword, location)) - .Order(StringComparer.Ordinal)) - { - errors.Add(location, $"unrecognized schema keyword '{keyword}' is not supported"); - } - } - - if (schema.Extensions is null) + // Unrecognized keywords are the one construct nothing downstream can ever see: the + // reader retains them raw and every typed consumer is blind to them. Silence here + // would be silent wire loss, so they refuse — unlike typed annotation/validation + // members, which are known vocabulary and are deliberately ignored. + if (schema.UnrecognizedKeywords is null) { return; } - foreach (var extension in schema.Extensions.Keys.Order(StringComparer.Ordinal)) + foreach (var keyword in schema + .UnrecognizedKeywords.Keys.Where(keyword => !IsAdmittedPrefixItems(keyword, location)) + .Order(StringComparer.Ordinal)) { - errors.Add(location, $"schema-level extension '{extension}' is not supported"); + errors.Add(location, $"unrecognized schema keyword '{keyword}' is not supported"); } } From 011630db54c5e2fe1765920e443bcb1cf5565224 Mon Sep 17 00:00:00 2001 From: "denizirgin@gmail.com" Date: Tue, 11 Aug 2026 20:05:11 +0300 Subject: [PATCH 15/19] feat(tools): ingestion seam with raw sibling scan and dangling-ref sweep ISpecIngestion/SpecIngestion compose reader, walls, projection, the raw $ref-sibling scan, and a dangling-reference sweep into one pass returning the immutable SpecDocument. The unconsumed raw-pointer lookup leaves ProjectionState per the consumer-pull rule; the seam is DI-registered in ToolApp. --- .../Ingestion/SchemaProjectorTests.cs | 3 +- .../Generator/Ingestion/SpecIngestionTests.cs | 88 +++++++++++ .../Support/OperationProjectionTestHost.cs | 4 +- .../Support/SchemaProjectionTestHost.cs | 5 +- .../Ingestion/Abstractions/ISpecIngestion.cs | 10 ++ .../Ingestion/Models/SpecDocument.cs | 27 ++++ .../Ingestion/Projection/ProjectionState.cs | 13 +- .../Ingestion/Projection/RawSiblingScanner.cs | 148 ++++++++++++++++++ .../Generator/Ingestion/SpecIngestion.cs | 81 ++++++++++ tools/OpenCode.Sdk.Tools/ToolApp.cs | 3 + 10 files changed, 361 insertions(+), 21 deletions(-) create mode 100644 tests/OpenCode.Sdk.Tools.Tests/Generator/Ingestion/SpecIngestionTests.cs create mode 100644 tools/OpenCode.Sdk.Tools/Generator/Ingestion/Abstractions/ISpecIngestion.cs create mode 100644 tools/OpenCode.Sdk.Tools/Generator/Ingestion/Models/SpecDocument.cs create mode 100644 tools/OpenCode.Sdk.Tools/Generator/Ingestion/Projection/RawSiblingScanner.cs create mode 100644 tools/OpenCode.Sdk.Tools/Generator/Ingestion/SpecIngestion.cs diff --git a/tests/OpenCode.Sdk.Tools.Tests/Generator/Ingestion/SchemaProjectorTests.cs b/tests/OpenCode.Sdk.Tools.Tests/Generator/Ingestion/SchemaProjectorTests.cs index c2881289..537a8dba 100644 --- a/tests/OpenCode.Sdk.Tools.Tests/Generator/Ingestion/SchemaProjectorTests.cs +++ b/tests/OpenCode.Sdk.Tools.Tests/Generator/Ingestion/SchemaProjectorTests.cs @@ -1,4 +1,3 @@ -using System.Text.Json.Nodes; using OpenCode.Sdk.Tools.Generator.Ingestion; using OpenCode.Sdk.Tools.Generator.Ingestion.Models; using OpenCode.Sdk.Tools.Generator.Ingestion.Projection; @@ -262,7 +261,7 @@ public async Task Project_Should_Refuse_When_Promoted_Graph_Key_Collides() var loaded = await new SpecReader(context.FileSystem).LoadAsync(context.SpecPath, errors, CancellationToken.None); var keys = new GraphKeyBuilder(); var projector = new SchemaProjector(keys); - var state = new ProjectionState(errors, loaded.Document, new Dictionary(StringComparer.Ordinal)); + var state = new ProjectionState(errors, loaded.Document); _ = projector.Project(loaded.Document.Components!.Schemas!["First"], "Shared", "/value", state); _ = projector.Project(loaded.Document.Components.Schemas["Second"], "Shared", "/value", state); diff --git a/tests/OpenCode.Sdk.Tools.Tests/Generator/Ingestion/SpecIngestionTests.cs b/tests/OpenCode.Sdk.Tools.Tests/Generator/Ingestion/SpecIngestionTests.cs new file mode 100644 index 00000000..6c74b28f --- /dev/null +++ b/tests/OpenCode.Sdk.Tools.Tests/Generator/Ingestion/SpecIngestionTests.cs @@ -0,0 +1,88 @@ +using System.IO.Abstractions; +using Microsoft.Extensions.DependencyInjection; +using OpenCode.Sdk.Tools.Generator.Ingestion; +using OpenCode.Sdk.Tools.Generator.Ingestion.Abstractions; +using OpenCode.Sdk.Tools.Generator.Ingestion.Models; +using OpenCode.Sdk.Tools.Tests.Support; + +namespace OpenCode.Sdk.Tools.Tests.Generator.Ingestion; + +public sealed class SpecIngestionTests +{ + [Test] + public async Task IngestAsync_Should_Produce_Operations_And_Schemas() + { + var context = SpecScenario.Define(spec => spec + .WithSchema("Session", schema => schema.Type("object") + .Property("id", property => property.Type("string"), required: true)) + .WithOperation("v2.session.get", path: "/api/session/{sessionID}", configure: operation => operation + .Parameter("sessionID", "path", parameter => parameter.Type("string"), required: true) + .Response(200, "application/json", schema => schema.Ref("Session")))) + .Build(); + + var document = await new SpecIngestion(context.FileSystem).IngestAsync(context.SpecPath, CancellationToken.None); + + await Assert.That(document.Operations).Count().IsEqualTo(1); + await Assert.That(document.Operations[0].OperationId).IsEqualTo("v2.session.get"); + await Assert.That(document.Schemas["Session"]).IsTypeOf(); + } + + [Test] + public async Task IngestAsync_Should_Refuse_Ref_With_Constraint_Sibling() + { + var context = SpecScenario.Define(spec => spec + .WithSchema("Session", schema => schema.Type("object")) + .WithSchema("Alias", schema => schema.Ref("Session").Raw("pattern", "\"^ses_\""))) + .Build(); + + var ex = await Assert + .That(async () => _ = await new SpecIngestion(context.FileSystem).IngestAsync(context.SpecPath, CancellationToken.None)) + .Throws(); + + await Assert.That(ex!.Message).Contains("$ref"); + await Assert.That(ex.Message).Contains("pattern"); + } + + [Test] + public async Task IngestAsync_Should_Admit_Ref_With_Description_Sibling() + { + var context = SpecScenario.Define(spec => spec + .WithSchema("Session", schema => schema.Type("object")) + .WithSchema("Holder", schema => schema.Type("object") + .Property("session", property => property.Ref("Session").Description("The session.")))) + .Build(); + + var document = await new SpecIngestion(context.FileSystem).IngestAsync(context.SpecPath, CancellationToken.None); + + await Assert.That(document.Schemas.Keys).Contains("Holder"); + } + + [Test] + public async Task IngestAsync_Should_Not_Flag_Property_Named_Ref() + { + var context = SpecScenario.Define(spec => spec + .WithSchema("Bag", schema => schema.Type("object") + .Property("$ref", property => property.Type("string")) + .Property("other", property => property.Type("number")))) + .Build(); + + var document = await new SpecIngestion(context.FileSystem).IngestAsync(context.SpecPath, CancellationToken.None); + + await Assert.That(document.Schemas["Bag"]).IsTypeOf(); + } + + [Test] + public async Task CreateServices_Should_Resolve_Spec_Ingestion_Seam() + { + var context = SpecScenario.Define(_ => { }).Build(); + using var provider = ToolApp + .CreateServices(services => services.AddSingleton(context.FileSystem)) + .BuildServiceProvider(); + + var ingestion = provider.GetRequiredService(); + var document = await ingestion.IngestAsync(context.SpecPath, CancellationToken.None); + + await Assert.That(provider.GetRequiredService()).IsSameReferenceAs(context.FileSystem); + await Assert.That(document.Operations).IsEmpty(); + } +} diff --git a/tests/OpenCode.Sdk.Tools.Tests/Support/OperationProjectionTestHost.cs b/tests/OpenCode.Sdk.Tools.Tests/Support/OperationProjectionTestHost.cs index 0662bee0..afc8ea99 100644 --- a/tests/OpenCode.Sdk.Tools.Tests/Support/OperationProjectionTestHost.cs +++ b/tests/OpenCode.Sdk.Tools.Tests/Support/OperationProjectionTestHost.cs @@ -1,5 +1,3 @@ -using System.Collections.Immutable; -using System.Text.Json.Nodes; using OpenCode.Sdk.Tools.Generator.Ingestion; using OpenCode.Sdk.Tools.Generator.Ingestion.Models; using OpenCode.Sdk.Tools.Generator.Ingestion.Projection; @@ -34,7 +32,7 @@ private async Task ProjectCoreAsync(SpecScenario scen { var loaded = await new SpecReader(context.FileSystem).LoadAsync(context.SpecPath, errors, CancellationToken.None); var schemaProjector = new SchemaProjector(_keys); - var state = new ProjectionState(errors, loaded.Document, ImmutableDictionary.Empty); + var state = new ProjectionState(errors, loaded.Document); var operations = new OperationProjector(schemaProjector, _keys).Project(loaded, state); if (loaded.Document.Components?.Schemas is not null) diff --git a/tests/OpenCode.Sdk.Tools.Tests/Support/SchemaProjectionTestHost.cs b/tests/OpenCode.Sdk.Tools.Tests/Support/SchemaProjectionTestHost.cs index 4df59fe3..9d4d0443 100644 --- a/tests/OpenCode.Sdk.Tools.Tests/Support/SchemaProjectionTestHost.cs +++ b/tests/OpenCode.Sdk.Tools.Tests/Support/SchemaProjectionTestHost.cs @@ -1,5 +1,3 @@ -using System.Collections.Immutable; -using System.Text.Json.Nodes; using OpenCode.Sdk.Tools.Generator.Ingestion; using OpenCode.Sdk.Tools.Generator.Ingestion.Models; using OpenCode.Sdk.Tools.Generator.Ingestion.Projection; @@ -34,8 +32,7 @@ private async Task ProjectCoreAsync(SpecScenario scenari { var loaded = await new SpecReader(context.FileSystem).LoadAsync(context.SpecPath, errors, CancellationToken.None); var projector = new SchemaProjector(_keys); - var rawPointerLookup = ImmutableDictionary.Empty; - var state = new ProjectionState(errors, loaded.Document, rawPointerLookup); + var state = new ProjectionState(errors, loaded.Document); if (loaded.Document.Components?.Schemas is not null) { diff --git a/tools/OpenCode.Sdk.Tools/Generator/Ingestion/Abstractions/ISpecIngestion.cs b/tools/OpenCode.Sdk.Tools/Generator/Ingestion/Abstractions/ISpecIngestion.cs new file mode 100644 index 00000000..56f647b2 --- /dev/null +++ b/tools/OpenCode.Sdk.Tools/Generator/Ingestion/Abstractions/ISpecIngestion.cs @@ -0,0 +1,10 @@ +using OpenCode.Sdk.Tools.Generator.Ingestion.Models; + +namespace OpenCode.Sdk.Tools.Generator.Ingestion.Abstractions; + +/// Ingests the pinned OpenAPI document into the generator's semantic model. +public interface ISpecIngestion +{ + /// Loads, validates, and projects the spec; refuses with batched located errors. + public Task IngestAsync(string specPath, CancellationToken cancellationToken); +} diff --git a/tools/OpenCode.Sdk.Tools/Generator/Ingestion/Models/SpecDocument.cs b/tools/OpenCode.Sdk.Tools/Generator/Ingestion/Models/SpecDocument.cs new file mode 100644 index 00000000..f84db170 --- /dev/null +++ b/tools/OpenCode.Sdk.Tools/Generator/Ingestion/Models/SpecDocument.cs @@ -0,0 +1,27 @@ +namespace OpenCode.Sdk.Tools.Generator.Ingestion.Models; + +/// The ingested spec: the Binder's sole spec-side input. +public sealed record SpecDocument +{ + /// Gets the projected operations in document order. + public required IReadOnlyList Operations + { + get; + init + { + ArgumentNullException.ThrowIfNull(value); + field = Array.AsReadOnly([.. value]); + } + } = Array.AsReadOnly(Array.Empty()); + + /// Gets the schema graph under ordinal-sorted keys. + public required IReadOnlyDictionary Schemas + { + get; + init + { + ArgumentNullException.ThrowIfNull(value); + field = value; + } + } +} diff --git a/tools/OpenCode.Sdk.Tools/Generator/Ingestion/Projection/ProjectionState.cs b/tools/OpenCode.Sdk.Tools/Generator/Ingestion/Projection/ProjectionState.cs index fff9fa84..8e5c3610 100644 --- a/tools/OpenCode.Sdk.Tools/Generator/Ingestion/Projection/ProjectionState.cs +++ b/tools/OpenCode.Sdk.Tools/Generator/Ingestion/Projection/ProjectionState.cs @@ -1,5 +1,4 @@ using System.Collections.ObjectModel; -using System.Text.Json.Nodes; using Microsoft.OpenApi; using OpenCode.Sdk.Tools.Generator.Ingestion.Models; @@ -8,31 +7,21 @@ namespace OpenCode.Sdk.Tools.Generator.Ingestion.Projection; internal sealed class ProjectionState { private readonly Dictionary _graph = new(StringComparer.Ordinal); - private readonly Dictionary _rawPointerLookup; private readonly HashSet _visited = new(ReferenceEqualityComparer.Instance); - public ProjectionState(IngestionErrorCollector errors, OpenApiDocument document, IReadOnlyDictionary rawPointerLookup) + public ProjectionState(IngestionErrorCollector errors, OpenApiDocument document) { ArgumentNullException.ThrowIfNull(errors); ArgumentNullException.ThrowIfNull(document); - ArgumentNullException.ThrowIfNull(rawPointerLookup); Errors = errors; Document = document; - _rawPointerLookup = rawPointerLookup.ToDictionary(pair => pair.Key, pair => pair.Value.DeepClone(), StringComparer.Ordinal); } public IngestionErrorCollector Errors { get; } public OpenApiDocument Document { get; } - public JsonNode? FindRawSchema(string pointer) - { - ArgumentNullException.ThrowIfNull(pointer); - - return _rawPointerLookup.TryGetValue(pointer, out var schema) ? schema.DeepClone() : null; - } - public SchemaNode? Register(SchemaNode? schema, string root, string pointer, string location) { ArgumentException.ThrowIfNullOrWhiteSpace(root); diff --git a/tools/OpenCode.Sdk.Tools/Generator/Ingestion/Projection/RawSiblingScanner.cs b/tools/OpenCode.Sdk.Tools/Generator/Ingestion/Projection/RawSiblingScanner.cs new file mode 100644 index 00000000..f4c31429 --- /dev/null +++ b/tools/OpenCode.Sdk.Tools/Generator/Ingestion/Projection/RawSiblingScanner.cs @@ -0,0 +1,148 @@ +using System.Globalization; +using System.Text.Json.Nodes; + +namespace OpenCode.Sdk.Tools.Generator.Ingestion.Projection; + +internal static class RawSiblingScanner +{ + private static readonly string[] SchemaChildKeywords = ["additionalProperties", "contentSchema", "items",]; + + private static readonly string[] SchemaListKeywords = ["allOf", "anyOf", "oneOf", "prefixItems",]; + + private static readonly string[] SchemaMapKeywords = ["patternProperties", "properties",]; + + public static void Scan(JsonNode raw, IngestionErrorCollector errors) + { + ArgumentNullException.ThrowIfNull(raw); + ArgumentNullException.ThrowIfNull(errors); + + // The typed DOM proxies every member of a reference to its target, so a local + // sibling key is invisible there; only the raw document can reveal it. Silently + // proxying past a sibling would drop wire semantics, hence the scan refuses. + + if (raw is not JsonObject document) + { + return; + } + + if (document["components"]?["schemas"] is JsonObject schemas) + { + foreach (var (name, schema) in schemas) + { + ScanSchema(schema, $"components/schemas/{name}", errors); + } + } + + if (document["paths"] is JsonObject paths) + { + foreach (var (path, pathItem) in paths) + { + ScanPathItem(pathItem, $"paths/{path}", errors); + } + } + } + + private static void ScanPathItem(JsonNode? pathItem, string location, IngestionErrorCollector errors) + { + if (pathItem is not JsonObject operations) + { + return; + } + + foreach (var (method, operation) in operations) + { + if (operation is not JsonObject concrete) + { + continue; + } + + var operationLocation = $"{location}/{method}"; + if (concrete["parameters"] is JsonArray parameters) + { + for (var index = 0; index < parameters.Count; index++) + { + ScanSchema(parameters[index]?["schema"], + string.Create(CultureInfo.InvariantCulture, $"{operationLocation}/parameters/{index}/schema"), errors); + } + } + + ScanContent(concrete["requestBody"], $"{operationLocation}/requestBody", errors); + if (concrete["responses"] is not JsonObject responses) + { + continue; + } + + foreach (var (status, response) in responses) + { + ScanContent(response, $"{operationLocation}/responses/{status}", errors); + } + } + } + + private static void ScanContent(JsonNode? owner, string location, IngestionErrorCollector errors) + { + if (owner?["content"] is not JsonObject content) + { + return; + } + + foreach (var (mediaType, media) in content) + { + ScanSchema(media?["schema"], $"{location}/content/{mediaType}/schema", errors); + } + } + + private static void ScanSchema(JsonNode? schema, string location, IngestionErrorCollector errors) + { + if (schema is not JsonObject concrete) + { + return; + } + + if (concrete.ContainsKey("$ref")) + { + foreach (var (key, _) in concrete) + { + if (key is not ("$ref" or "description" or "summary")) + { + errors.Add(location, $"'$ref' cannot carry sibling key '{key}'"); + } + } + + return; + } + + foreach (var keyword in SchemaChildKeywords) + { + ScanSchema(concrete[keyword], $"{location}/{keyword}", errors); + } + + foreach (var keyword in SchemaListKeywords) + { + if (concrete[keyword] is not JsonArray branches) + { + continue; + } + + for (var index = 0; index < branches.Count; index++) + { + ScanSchema(branches[index], string.Create(CultureInfo.InvariantCulture, $"{location}/{keyword}/{index}"), errors); + } + } + + // Property names are opaque wire data — only the values are schemas, so a + // property literally named "$ref" never triggers the sibling rule. + foreach (var keyword in SchemaMapKeywords) + { + if (concrete[keyword] is not JsonObject map) + { + continue; + } + + foreach (var (name, value) in map) + { + ScanSchema(value, $"{location}/{keyword}/{name}", errors); + } + } + } +} diff --git a/tools/OpenCode.Sdk.Tools/Generator/Ingestion/SpecIngestion.cs b/tools/OpenCode.Sdk.Tools/Generator/Ingestion/SpecIngestion.cs new file mode 100644 index 00000000..4160797d --- /dev/null +++ b/tools/OpenCode.Sdk.Tools/Generator/Ingestion/SpecIngestion.cs @@ -0,0 +1,81 @@ +using System.IO.Abstractions; +using OpenCode.Sdk.Tools.Generator.Ingestion.Abstractions; +using OpenCode.Sdk.Tools.Generator.Ingestion.Models; +using OpenCode.Sdk.Tools.Generator.Ingestion.Projection; + +namespace OpenCode.Sdk.Tools.Generator.Ingestion; + +/// Composes the reader, projection, and raw-side validations into one ingestion pass. +public sealed class SpecIngestion : ISpecIngestion +{ + private readonly SpecReader _reader; + + /// Creates the ingestion seam over the injected filesystem. + public SpecIngestion(IFileSystem fileSystem) + { + ArgumentNullException.ThrowIfNull(fileSystem); + _reader = new SpecReader(fileSystem); + } + + /// + public async Task IngestAsync(string specPath, CancellationToken cancellationToken) + { + ArgumentException.ThrowIfNullOrWhiteSpace(specPath); + + var errors = new IngestionErrorCollector(); + var loaded = await _reader.LoadAsync(specPath, errors, cancellationToken).ConfigureAwait(false); + + var keys = new GraphKeyBuilder(); + var schemaProjector = new SchemaProjector(keys); + var state = new ProjectionState(errors, loaded.Document); + var operations = new OperationProjector(schemaProjector, keys).Project(loaded, state); + if (loaded.Document.Components?.Schemas is { } namedSchemas) + { + foreach (var (name, schema) in namedSchemas) + { + _ = schemaProjector.Project(schema, keys.Root(name), string.Empty, state); + } + } + + RawSiblingScanner.Scan(loaded.Raw, errors); + var schemas = state.SnapshotGraph(); + CheckDanglingReferences(operations, schemas, errors); + errors.ThrowIfAny(); + + return new SpecDocument + { + Operations = operations, + Schemas = schemas, + }; + } + + private static void CheckDanglingReferences(IReadOnlyList operations, IReadOnlyDictionary schemas, + IngestionErrorCollector errors) + { + var roots = operations + .SelectMany(static operation => operation.Parameters.Select(static parameter => parameter.Schema) + .Concat(operation.Responses.Where(static response => response.Schema is not null).Select(static response => response.Schema!)) + .Append(operation.RequestBody?.Schema)) + .Concat(schemas.Values) + .Where(static node => node is not null) + .Select(static node => node!); + + foreach (var target in roots + .SelectMany(Descendants) + .OfType() + .Select(static reference => reference.Target) + .Where(target => !schemas.ContainsKey(target))) + { + errors.Add(target, "schema reference target does not exist in the projected graph"); + } + } + + private static IEnumerable Descendants(SchemaNode node) + { + yield return node; + foreach (var descendant in node.Children.SelectMany(Descendants)) + { + yield return descendant; + } + } +} diff --git a/tools/OpenCode.Sdk.Tools/ToolApp.cs b/tools/OpenCode.Sdk.Tools/ToolApp.cs index 05d204cc..e13cc6c5 100644 --- a/tools/OpenCode.Sdk.Tools/ToolApp.cs +++ b/tools/OpenCode.Sdk.Tools/ToolApp.cs @@ -2,6 +2,8 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using OpenCode.Sdk.Tools.Commands; +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 Spectre.Console; @@ -24,6 +26,7 @@ public static ServiceCollection CreateServices(Action? overr services.AddLogging(logging => logging.SetMinimumLevel(LogLevel.Trace)); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); overrideServices?.Invoke(services); From 12dd3302086ac299b392b80f2446ef179e2e6812 Mon Sep 17 00:00:00 2001 From: "denizirgin@gmail.com" Date: Tue, 11 Aug 2026 20:11:14 +0300 Subject: [PATCH 16/19] test(tools): prefixItems tripwire, boundary guards and pinned-spec landmark smoke MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The tripwire pins the one raw keyword the adapter depends on; the guards keep Microsoft.OpenApi inside the ingestion slice; the smoke asserts representative landmarks (surfaces, SSE/wildcard/WebSocket flags, marked/structural unions, the plugin tuple, special-number and unrestricted sites, both error styles, envelope shapes, repeat determinism) against the full pin — no counts. --- .../Ingestion/IngestionBoundaryTests.cs | 120 ++++++++++++++++++ .../Ingestion/PinnedSpecSmokeTests.cs | 106 ++++++++++++++++ .../Ingestion/PrefixItemsTripwireTests.cs | 26 ++++ 3 files changed, 252 insertions(+) create mode 100644 tests/OpenCode.Sdk.Tools.Tests/Generator/Ingestion/IngestionBoundaryTests.cs create mode 100644 tests/OpenCode.Sdk.Tools.Tests/Generator/Ingestion/PinnedSpecSmokeTests.cs create mode 100644 tests/OpenCode.Sdk.Tools.Tests/Generator/Ingestion/PrefixItemsTripwireTests.cs diff --git a/tests/OpenCode.Sdk.Tools.Tests/Generator/Ingestion/IngestionBoundaryTests.cs b/tests/OpenCode.Sdk.Tools.Tests/Generator/Ingestion/IngestionBoundaryTests.cs new file mode 100644 index 00000000..c73aacf5 --- /dev/null +++ b/tests/OpenCode.Sdk.Tools.Tests/Generator/Ingestion/IngestionBoundaryTests.cs @@ -0,0 +1,120 @@ +using System.Reflection; +using OpenCode.Sdk.Tools.Generator.Ingestion; +using Testably.Abstractions; + +namespace OpenCode.Sdk.Tools.Tests.Generator.Ingestion; + +/// +/// Boundary guards: the mutable Microsoft.OpenApi DOM must never escape the ingestion +/// implementation — SpecDocument is the deep-module seam the Binder builds on. +/// +public sealed class IngestionBoundaryTests +{ + [Test] + public async Task Ingestion_Public_Surface_Should_Not_Expose_Microsoft_OpenApi_Types() + { + var toolsAssembly = typeof(SpecIngestion).Assembly; + var openApiAssembly = typeof(Microsoft.OpenApi.OpenApiDocument).Assembly; + var ingestionTypes = toolsAssembly + .GetTypes() + .Where(static type => type.IsPublic + && type.Namespace?.StartsWith("OpenCode.Sdk.Tools.Generator.Ingestion", StringComparison.Ordinal) is true); + + var leaks = new List(); + foreach (var type in ingestionTypes) + { + leaks.AddRange(ReferencedTypes(type) + .Where(referenced => referenced.Assembly == openApiAssembly) + .Select(referenced => $"{type.FullName} exposes {referenced.FullName}")); + } + + await Assert.That(leaks).IsEmpty(); + } + + [Test] + public async Task Only_The_Ingestion_Slice_Should_Use_Microsoft_OpenApi() + { + var fileSystem = new RealFileSystem(); + var toolsRoot = FindToolsSourceRoot(fileSystem); + var offenders = new List(); + foreach (var path in fileSystem.Directory.EnumerateFiles(toolsRoot, "*.cs", SearchOption.AllDirectories)) + { + if (IsInsideIngestion(fileSystem, path)) + { + continue; + } + + var source = await fileSystem.File.ReadAllTextAsync(path); + if (source.Contains("using Microsoft.OpenApi", StringComparison.Ordinal)) + { + offenders.Add(path); + } + } + + await Assert.That(offenders).IsEmpty(); + } + + private static bool IsInsideIngestion(RealFileSystem fileSystem, string path) + { + var normalized = path.Replace(fileSystem.Path.DirectorySeparatorChar, '/'); + return normalized.Contains("/Generator/Ingestion/", StringComparison.Ordinal); + } + + private static string FindToolsSourceRoot(RealFileSystem fileSystem) + { + var current = fileSystem.DirectoryInfo.New(AppContext.BaseDirectory); + while (current is not null) + { + if (fileSystem.File.Exists(fileSystem.Path.Combine(current.FullName, "OpenCode.slnx"))) + { + return fileSystem.Path.Combine(current.FullName, "tools", "OpenCode.Sdk.Tools"); + } + + current = current.Parent; + } + + throw new InvalidOperationException("The repository root (OpenCode.slnx) was not found above the test output directory."); + } + + private static IEnumerable ReferencedTypes(Type type) + { + const BindingFlags declaredPublic = BindingFlags.Public | BindingFlags.Instance | BindingFlags.Static | BindingFlags.DeclaredOnly; + var referenced = new List + { + type.BaseType, + }; + referenced.AddRange(type.GetProperties(declaredPublic).Select(static property => property.PropertyType)); + foreach (var method in type.GetMethods(declaredPublic)) + { + referenced.Add(method.ReturnType); + referenced.AddRange(method.GetParameters().Select(static parameter => parameter.ParameterType)); + } + + foreach (var constructor in type.GetConstructors()) + { + referenced.AddRange(constructor.GetParameters().Select(static parameter => parameter.ParameterType)); + } + + return referenced.OfType().SelectMany(Expand).Distinct(); + } + + private static IEnumerable Expand(Type type) + { + yield return type; + if (type.IsGenericType) + { + foreach (var argument in type.GetGenericArguments().SelectMany(Expand)) + { + yield return argument; + } + } + + if (type.HasElementType && type.GetElementType() is { } element) + { + foreach (var expanded in Expand(element)) + { + yield return expanded; + } + } + } +} diff --git a/tests/OpenCode.Sdk.Tools.Tests/Generator/Ingestion/PinnedSpecSmokeTests.cs b/tests/OpenCode.Sdk.Tools.Tests/Generator/Ingestion/PinnedSpecSmokeTests.cs new file mode 100644 index 00000000..dbcb08c8 --- /dev/null +++ b/tests/OpenCode.Sdk.Tools.Tests/Generator/Ingestion/PinnedSpecSmokeTests.cs @@ -0,0 +1,106 @@ +using OpenCode.Sdk.Tools.Generator.Ingestion; +using OpenCode.Sdk.Tools.Generator.Ingestion.Models; +using Testably.Abstractions; + +namespace OpenCode.Sdk.Tools.Tests.Generator.Ingestion; + +/// +/// The structural gate every future spec refresh runs through: representative landmarks at +/// the known lossy seams, no count assertions. A red landmark is evidence about the wire — +/// stop and classify per the deviation protocol, never patch the assertion. +/// +public sealed class PinnedSpecSmokeTests +{ + [Test] + public async Task Ingest_Should_Absorb_The_Pinned_Spec_With_Both_Surfaces() + { + var document = await IngestPinnedSpecAsync(); + + await Assert.That(document.Operations.Any(static operation => operation.Surface == SpecSurface.Modern)).IsTrue(); + await Assert.That(document.Operations.Any(static operation => operation.Surface == SpecSurface.Legacy)).IsTrue(); + } + + [Test] + public async Task Ingest_Should_Flag_Stream_Wildcard_And_WebSocket_Operations() + { + var document = await IngestPinnedSpecAsync(); + + var durableStream = document.Operations.Single(static operation => operation.OperationId == "v2.session.events"); + await Assert.That(durableStream.IsSse).IsTrue(); + await Assert.That(durableStream.Responses.Single(static response => response.IsSse).EffectStreamJson).IsNotNull(); + await Assert.That(document.Operations.Single(static operation => operation.OperationId == "v2.fs.read").HasWildcardPath).IsTrue(); + await Assert.That(document.Operations.Single(static operation => operation.OperationId == "v2.pty.connect").IsWebSocket).IsTrue(); + } + + [Test] + public async Task Ingest_Should_Classify_Marked_And_Structural_Union_Landmarks() + { + var document = await IngestPinnedSpecAsync(); + + var durableEvent = (UnionNode)document.Schemas["SessionDurableEvent"]; + await Assert.That(durableEvent.Keyword).IsEqualTo(UnionKeyword.OneOf); + await Assert.That(durableEvent.Classification).IsEqualTo(UnionClassification.Marked); + + var formatter = (UnionNode)document.Schemas["Config#/properties/formatter"]; + await Assert.That(formatter.Classification).IsEqualTo(UnionClassification.Structural); + + var pluginItems = (UnionNode)document.Schemas["Config#/properties/plugin/items"]; + await Assert.That(pluginItems.Branches.OfType().Single().Items).Count().IsEqualTo(2); + } + + [Test] + public async Task Ingest_Should_Project_Special_Number_And_Unrestricted_Landmarks() + { + var document = await IngestPinnedSpecAsync(); + + var workspace = (ObjectNode)document.Schemas["Workspace"]; + await Assert.That(workspace.Properties.Single(static property => property.Name == "timeUsed").Schema) + .IsTypeOf(); + + var assistantMessage = (ObjectNode)document.Schemas["AssistantMessage"]; + await Assert.That(assistantMessage.Properties.Single(static property => property.Name == "structured").Schema) + .IsTypeOf(); + } + + [Test] + public async Task Ingest_Should_Classify_Both_Error_Styles() + { + var document = await IngestPinnedSpecAsync(); + + await Assert.That(((ObjectNode)document.Schemas["MoveSessionError"]).ErrorStyle).IsEqualTo(ErrorStyle.NameData); + await Assert.That(((ObjectNode)document.Schemas["effect_HttpApiError_BadRequest"]).ErrorStyle).IsEqualTo(ErrorStyle.EffectTag); + } + + [Test] + public async Task Ingest_Should_Classify_Envelope_Shapes() + { + var document = await IngestPinnedSpecAsync(); + + await Assert.That(EnvelopeOf(document, "v2.session.list")).IsEqualTo(SpecEnvelopeShape.CursorData); + await Assert.That(EnvelopeOf(document, "v2.session.history")).IsEqualTo(SpecEnvelopeShape.DataHasMore); + await Assert.That(EnvelopeOf(document, "v2.agent.list")).IsEqualTo(SpecEnvelopeShape.DataLocation); + } + + [Test] + public async Task Ingest_Should_Be_Deterministic_Across_Repeated_Runs() + { + var first = await IngestPinnedSpecAsync(); + var second = await IngestPinnedSpecAsync(); + + await Assert.That(first.Schemas.Keys.SequenceEqual(second.Schemas.Keys, StringComparer.Ordinal)).IsTrue(); + await Assert.That(first.Operations.Select(static operation => operation.OperationId) + .SequenceEqual(second.Operations.Select(static operation => operation.OperationId), StringComparer.Ordinal)).IsTrue(); + } + + private static SpecEnvelopeShape EnvelopeOf(SpecDocument document, string operationId) => + document.Operations.Single(operation => operation.OperationId == operationId) + .Responses.Single(static response => response.StatusCode == 200) + .EnvelopeShape; + + private static Task IngestPinnedSpecAsync() + { + var fileSystem = new RealFileSystem(); + var specPath = fileSystem.Path.Combine(AppContext.BaseDirectory, "Fixtures", "openapi.json"); + return new SpecIngestion(fileSystem).IngestAsync(specPath, CancellationToken.None); + } +} diff --git a/tests/OpenCode.Sdk.Tools.Tests/Generator/Ingestion/PrefixItemsTripwireTests.cs b/tests/OpenCode.Sdk.Tools.Tests/Generator/Ingestion/PrefixItemsTripwireTests.cs new file mode 100644 index 00000000..7fa4eff9 --- /dev/null +++ b/tests/OpenCode.Sdk.Tools.Tests/Generator/Ingestion/PrefixItemsTripwireTests.cs @@ -0,0 +1,26 @@ +using Microsoft.OpenApi; +using OpenCode.Sdk.Tools.Generator.Ingestion; +using Testably.Abstractions; + +namespace OpenCode.Sdk.Tools.Tests.Generator.Ingestion; + +public sealed class PrefixItemsTripwireTests +{ + [Test] + public async Task Reader_Should_Retain_PrefixItems_As_Unrecognized_Keyword_At_The_Admitted_Site() + { + // Library-upgrade tripwire: the prefixItems adapter reads the raw keyword the pinned + // Microsoft.OpenApi retains as unrecognized. A newer library that types prefixItems + // would leave UnrecognizedKeywords empty, blind the adapter, and silently degrade the + // Config.plugin tuple — this test must fail loudly before that can ship. + var fileSystem = new RealFileSystem(); + var specPath = fileSystem.Path.Combine(AppContext.BaseDirectory, "Fixtures", "openapi.json"); + var errors = new IngestionErrorCollector(); + + var loaded = await new SpecReader(fileSystem).LoadAsync(specPath, errors, CancellationToken.None); + + var plugin = (OpenApiSchema)loaded.Document.Components!.Schemas!["Config"].Properties!["plugin"]; + var tupleBranch = (OpenApiSchema)plugin.Items!.AnyOf![1]; + await Assert.That(tupleBranch.UnrecognizedKeywords!.Keys).Contains("prefixItems"); + } +} From 6c8b7202bdb6c012ff069a66af171d0df12b9f38 Mon Sep 17 00:00:00 2001 From: "denizirgin@gmail.com" Date: Tue, 11 Aug 2026 20:17:03 +0300 Subject: [PATCH 17/19] =?UTF-8?q?docs:=20milestone-driven=20delivery=20?= =?UTF-8?q?=E2=80=94=20specs=20demoted=20to=20vision,=20slice=20map=20reti?= =?UTF-8?q?red?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Research log session 15 records the correction: the three design specs and the walking-skeleton design carry vision/reference banners (sealed surface = ADRs + AGENTS.md), the slice map and the superseded Slice 1 plan retire, ROADMAP carries the six-milestone deliverable-first list (M1 = two-operation walking skeleton with a manual real-server demo), the deviation protocol's level 2 narrows to canonical documents, AGENTS.md gains the vision framing and blast-radius-scaled assurance, and the consumed execution handover is deleted. --- AGENTS.md | 9 +- docs/ROADMAP.md | 103 +- docs/agents/deviation-protocol.md | 12 +- .../handover-prompts/HANDOFF-2026-08-11.md | 105 -- docs/engineering/testing-style.md | 6 +- docs/research/00-research-log.md | 72 ++ .../2026-08-10-implementation-slice-map.md | 328 ------ .../2026-08-11-slice-01-ingestion-specir.md | 982 ------------------ .../2026-08-09-generator-architecture.md | 4 + .../specs/2026-08-09-public-api-design.md | 4 + .../2026-08-10-testing-architecture-design.md | 4 + ...8-11-production-walking-skeleton-design.md | 4 + 12 files changed, 148 insertions(+), 1485 deletions(-) delete mode 100644 docs/agents/handover-prompts/HANDOFF-2026-08-11.md delete mode 100644 docs/superpowers/plans/2026-08-10-implementation-slice-map.md delete mode 100644 docs/superpowers/plans/2026-08-11-slice-01-ingestion-specir.md diff --git a/AGENTS.md b/AGENTS.md index 3a93600f..9cb664df 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -18,8 +18,8 @@ One line per decision; rationale lives in the ADRs (`docs/adr/`), dated evidence `docs/research/`. **Reading the ADRs is an integral part of onboarding — do not propose or change design without knowing them.** Not every locked decision has an ADR (ADRs are created lazily); this list is the complete inventory of what is settled. Do not reopen without new -evidence. Fine-grained design currently lives in the sealed design specs (transient; -reachable via `docs/ROADMAP.md`) until build-out distills them. +evidence. The design specs under `docs/superpowers/` are vision/reference material — +direction and rationale, not law; only this list and the ADRs bind. - **Target artifact:** upstream's `packages/sdk/openapi.json` (OpenAPI 3.1), pinned under `spec/` — the same spec the official JS SDK generates from. @@ -56,7 +56,10 @@ reachable via `docs/ROADMAP.md`) until build-out distills them. - **Testing posture:** borderline-paranoid, fail-closed, defensive by default — observation-based gates, absolute determinism, fake only published contracts; TUnit on Microsoft.Testing.Platform, real-process integration, three-OS launcher acceptance - (ADR-0001). + (ADR-0001). Assurance intensity scales with blast radius: shipped SDK runtime highest; + committed generated output next (git diff + analyzer wall + contract tests are its + radar); repo tooling internals lightest — every extra mechanism must name a consumer or + a concrete failure it prevents. ## Hard Rules diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index b5949a58..929e8ece 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -7,72 +7,55 @@ Evergreen rules and locked decisions live in `../AGENTS.md`; decision records in ## Status -Structural skeleton in place: `OpenCode.slnx`; `src/OpenCode.Sdk` + `src/OpenCode.Sdk.Extensions` -(empty multi-targeted shells, full TFM matrix); `tests/OpenCode.Sdk.Tests` (TUnit smoke test, -net472 leg Windows-only); three-OS CI (`.github/workflows/ci.yml`: build + test + TRX reporting); -pinned OpenAPI snapshot (`spec/openapi.json`, provenance in `spec/SNAPSHOT.md`). Packages current -as of 2026-08-08. Design runway complete and grill-hardened: three sealed specs under -`superpowers/specs/` (public API, generator architecture, testing architecture), ADRs 0001–0009, -research log sessions 1–14. Implementation planning is maintained in the slice map at -`superpowers/plans/2026-08-10-implementation-slice-map.md` (12 vertical slices), slice issues -**#1–#12** with native `blocked_by` ordering; deviation protocol added -(`docs/agents/deviation-protocol.md`), falsifiability working agreement added to `AGENTS.md`. -The Slice 0 tooling skeleton has landed. Repo-local Slopwatch pinning, a committed zero-entry -baseline, and the Linux CI gate are active. Slice 1 is ready for execution: the pinned -`Microsoft.OpenApi` reader feeds a fail-closed semantic projection into minimal SpecIR -(ADR-0003; generator spec §4.1); the foundation is the complete PathSmith-shaped ToolApp -host (single production/test composition path, Testably `IFileSystem`, `IAnsiConsole`, MEL -Spectre/optional-file providers, global settings, interceptor); test setup is centralized -and lambda-first, with named scenarios promoted only for reuse, complexity, or durable -domain identity. The wall/tripwire design and the execution plan survived independent -Grok/GLM/Kimi review; the only duplicate removed is Task 8's small-document determinism -assertion, superseded by Task 10's pinned-document repeat (research log sessions 12–14). -The retired parser remains read-only on `feature/slice-01-parser-specir` as evidence; delete -its worktree only with maintainer approval. The corrected plan is -`superpowers/plans/2026-08-11-slice-01-ingestion-specir.md`; issue #2 is -`ready-for-agent`; next is the execution handoff -(`docs/agents/handover-prompts/HANDOFF-2026-08-11.md`). +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. -## Queue +## Milestones -In order — do not improvise beyond it without asking the maintainer. +Deliverable-first: every milestone ends in something callable or demonstrable. The next +milestone gets a short (1–2 page) plan when it starts — never earlier. Ordering beyond M2 +is revisited at each milestone boundary. -1. **SDK build-out — remaining slice issues #2–#12.** Scope, sequencing, and the per-slice done - definition live in the slice map - (`superpowers/plans/2026-08-10-implementation-slice-map.md`); task-level progress lives in - per-slice plan checkboxes (plans written just-in-time; a slice whose plan exists gets - `ready-for-agent`). Execution: `subagent-driven-development` per slice on a - `feature/slice-NN-*` worktree branch; one PR to master closes the issue; per-task commits on - the slice branch are the agreed development loop (the `AGENTS.md` commit-rule exception). - TDD for transport/SSE/launcher; `api-design` (extend-only) + `snapshot-testing` (Verify) - lock the public surface as implementation lands (testing spec §12; slices 4–5). **Spec - retirement rides slice 11:** durable sealed decisions without an ADR home distill into ADRs - before the transient `docs/superpowers/` documents are deleted. -2. **Later** — **MCP server**: in this repo as a thin SDK adapter (ADR-0006). Tech targeting - decided 2026-08-08 (research doc 05), **re-sealed at phase start against the then-current - MCP landscape:** the 2026-07-28 protocol revision via MCP C# SDK v2.0, stdio + streamable - HTTP, no investment in deprecated features. Evaluate NuGet's `McpServer` package type for - distribution; its SDK usage defines the deep-tested legacy set (ADR-0005). Aspire AppHost - for local dev/test (mini UI, `opencode serve` as a resource) — planned. "opencode HQ" — - multi-instance aggregation above the SDK — is a valued future deliverable in its own - right, not SDK scope. +1. **M1 — Walking skeleton.** `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` with the output pasted into the PR. Two + independently mergeable arcs: selected compiler + committed models, then the callable + client with typed errors and `NoThrow`. Design reference: + `superpowers/specs/2026-08-11-production-walking-skeleton-design.md`. +2. **M2 — Breadth batches.** The generation profile grows in vertical operation batches; + each batch lands its curation rows, reachable models, operation methods, and contract + tests together. +3. **M3 — Streams.** SSE engine, live/durable subscribe with `after` resume; demo: watching + a real session's event stream. The net472 `ServicePointManager` item lands here. +4. **M4 — Launcher.** `OpenCodeServer.StartAsync` with three-OS acceptance (ADR-0001); + demo: the SDK starts the server itself and calls health. The net472 stdout/tree-kill + items land here. +5. **M5 — Full surface.** Legacy hub, complete generation profile, exclusion fingerprints + (ADR-0008), packaging unblocked. +6. **M6 — Operational closure.** `refresh-spec`, Extensions DI breadth, + retry/telemetry/hooks, quarantine lane, nightly canary; durable decisions distill into + ADRs and the `superpowers/` documents retire. ## Open Questions -- **net472 spike items** — distributed to their SUT slices (slice map, sealed decision 4): - polyfill-set validation + generated-model downlevel compile → slice 3 (the 5-TFM - milestone; checklist in generator spec §12); async stdout reading + `taskkill /T /F` - tree-kill → slice 6; SSE long-lived-response behavior - (`ServicePointManager.DefaultConnectionLimit = 2`) → slice 8. -- **Spec tracking:** `openapi.json` changes on every upstream push — snapshot per SDK release + - diff/regen workflow (`spec/SNAPSHOT.md` is the pin; the submodule tracks upstream). The - `refresh-spec` tool lands in slice 11; the refresh **cadence** policy stays open. -- **Launcher deep-dive items** (public API spec §13; doc 06 §3) — owned by slice 6, including - release-binary confirmation of `--port 0`. -- **Release mechanics** — decided parts live in ADR-0006 (independent semver, per-merge GitHub - Packages CD, manual NuGet.org release pipeline). Still open: pre-1.0/preview numbering, - `VersionPrefix`, RELEASE_NOTES flow, the concrete workflows. **Not covered by the slice - map** — scheduled when the first publishable increment approaches. +- **Spec refresh cadence** — the `refresh-spec` tool lands in M6; the cadence policy stays + open. +- **Structural-union emission shape** — the five structural-union pin sites + (`Config.formatter` et al.) need an emission decision when a breadth batch first reaches + one (a public API review). +- **Release mechanics** — decided parts live in ADR-0006 (independent semver, per-merge + GitHub Packages CD, manual NuGet.org releases). Pre-1.0 numbering, `VersionPrefix`, + RELEASE_NOTES flow, and the concrete workflows are scheduled when the first publishable + increment approaches. - **Upstream v2 watch:** the active upstream `v2` branch publishes its spec at `packages/protocol/openapi.json`; its dialect adds single-element `allOf` wrappers (validation keywords, occasionally with annotations), keeps `v2.`-prefixed operationIds, diff --git a/docs/agents/deviation-protocol.md b/docs/agents/deviation-protocol.md index a6f59983..12a4044d 100644 --- a/docs/agents/deviation-protocol.md +++ b/docs/agents/deviation-protocol.md @@ -28,15 +28,19 @@ for exactly this case (e.g. the generator spec §3.3 two-condition entry fallbac state that the trigger fired, and the documents the fallback touches are corrected in the same change. Add a research-log entry when the evidence is worth keeping. -### Level 2 — Spec correction +### Level 2 — Canonical correction -Reality contradicts a sealed spec claim and no fallback is recorded: upstream behaves -differently, an API does not exist, a verified count is wrong, a design detail cannot be +Reality contradicts a canonical claim — the engineering canon, `CONTEXT.md`, an active +plan's binding constraint, a verified research fact — and no fallback is recorded: +upstream behaves differently, an API does not exist, a count is wrong, a detail cannot be built as written. **Stop the affected task** (neighboring tasks may continue if untouched). Capture the evidence — commands, output, source references. Propose the -correction to the maintainer; on approval, the spec/plan is corrected in place and the +correction to the maintainer; on approval, the document is corrected in place and the task resumes. Findings worth keeping go to the research log. +Vision/reference documents (`docs/superpowers/`) are not protocol subjects: contradicting +one is a level-0 note in the PR, and the document is corrected opportunistically. + ### Level 3 — Locked-decision challenge New evidence contradicts an `AGENTS.md` locked decision or an ADR. **Stop the slice.** diff --git a/docs/agents/handover-prompts/HANDOFF-2026-08-11.md b/docs/agents/handover-prompts/HANDOFF-2026-08-11.md deleted file mode 100644 index 1f5161f6..00000000 --- a/docs/agents/handover-prompts/HANDOFF-2026-08-11.md +++ /dev/null @@ -1,105 +0,0 @@ -# Handover — Slice 1 execution: Ingestion + SpecIR - -Date: 2026-08-11 - -Priming prompt for the Slice 1 execution session. Consume against live git; delete when the -slice PR merges. Transient and agent-facing — unlike canonical docs, this file may point at -the maintainer's other repositories and machine-local paths. - -## Task - -Execute `docs/superpowers/plans/2026-08-11-slice-01-ingestion-specir.md` task-by-task via -deniz-process:subagent-driven-development on a fresh worktree branch -`feature/slice-01-ingestion-specir` (deniz-process:using-git-worktrees). One PR to master, -`Closes #2`. The plan is the canonical execution artifact; this handover adds context the -plan deliberately keeps machine-independent. - -## Onboarding order - -1. `AGENTS.md`; `docs/agents/deviation-protocol.md` -2. `docs/engineering/coding-style.md` + `docs/engineering/testing-style.md` — **binding on - every task**; the executing agents' output is reviewed against them -3. Generator spec §3.1, §4, §4.1, §4.2, §9, §11 - (`docs/superpowers/specs/2026-08-09-generator-architecture.md`) -4. The plan itself, in full -5. Research log sessions 12–14 (`docs/research/00-research-log.md`) — why the previous - parser died, what the wall grill fixed, and the overengineering grill's hosting/test - authorship corrections - -## Live state - -| Item | Value | -|---|---| -| Main workspace | `E:\repos\my-projects\opencode-sdk-dotnet`, branch `master` | -| Slice issue | #2 (`ready-for-agent`) | -| Evidence worktree (READ-ONLY) | `C:\Users\deniz\AppData\Local\Temp\opencode\opencode-sdk-dotnet-slice-01-parser-specir` | -| Evidence branch | `feature/slice-01-parser-specir` — the retired parser; never modify, never delete | - -## Style model repositories (the maintainer's own — "build it in this style") - -The engineering docs distill these; read the originals when a rule needs depth or a concrete -model. The repos are models for *shape*, not sources to copy — size everything to this -repo's need. - -- **Extraction & testing guidelines (origin of `docs/engineering/`):** - - - - -- **Scenario test infrastructure model** (first-class fakes over Testably — the mechanics - Task 2 adapts, not its class-per-situation file count): - - — study `Scenarios/Base/ScenarioBase.cs` (+`ScenarioContext`), `Utils/ProjectBuilder.cs` - (fluent, domain-aware, `MockFileSystem`-backed, embedded resources via `ResourceLoader`). - Small one-off variations stay inline through `SpecScenario.Define`; named scenario - classes require reuse, non-trivial setup, or durable cross-slice identity. -- **Hosting/DI composition model** (Task 1 takes the complete topology, not a subset): - `E:\repos\my-projects\env-variable-tools` — `src/PathSmith/Program.cs` (one - `ServiceCollection` composition root → `DependencyInjectionRegistrar` → `CommandApp`), - `Services/Abstractions/` (interfaces) vs `Services/` (implementations), `Models/`, - `GlobalSettings`, `GlobalOptionsInterceptor` (`ICommandInterceptor` for cross-cutting - CLI concerns), filesystem/console seams, logging, core services, and application - services. Preserve that full hosting and registration shape; render logging through - Microsoft.Extensions.Logging (`ILogger` + Spectre/optional-file providers), not the - model repo's literal custom `ILogger`. Tests replace seams after `ToolApp.CreateServices` - instead of copying PathSmith's test registration list. - -## Reference implementations — per-task map (adapt, never transplant) - -The redesign probes live in the evidence worktree's gitignored scratchpad: -`\.scratchpad\OpenApiProbe\` — run from the worktree root with -`dotnet run --project .scratchpad/OpenApiProbe/OpenApiProbe.csproj -- `; preserved -outputs at `.scratchpad/probe-outputs-2026-08-11.md`. **Caveats:** the probes sit behind a -`root = true` editorconfig (the analyzer wall never ran on them), are single-file -blacklist-shaped scratch code, and violate the coding style on purpose — take the -*mechanics* (proven API calls, predicates, traversal shapes), never the shape. Production -is whitelist-shaped collaborators. - -| Plan task | Reference | -|---|---| -| 3 — reader gate | `OperationalProbe.cs` (`LoadAsync` + `LeaveStreamOpen` usage); `HostLossBisect.cs` (the boolean-schema NRE and `3.2.0` injections — the red-test shapes) | -| 4 — schema wall/projection | `ValidationProbe.cs` `WalkSchema` (traversal + refusal mechanics — **invert blacklist→whitelist**); `InventoryProbe.cs` (`IsUnrestricted` predicate, DOM walk, pointer building) | -| 5–6 — object/union family | `ProjectionProbe.cs` (`IsSpecialNumber`, `ClassifyErrorStyle`, `IsLiteral`, `ErrorBranches` — typed `OpenApiSchemaReference.Reference.Id` access, dedup); `OperationalProbe.cs` `TryParseFragments` (the exact `OpenApiModelFactory.Parse` call for `prefixItems`) | -| 7 — operation surface | `ProjectionProbe.cs` (`StripParameters`, `ClassifyEnvelope`, SSE + `x-effect-stream` extraction via `JsonNodeExtension`); `GrillProbes.cs`/`HostLossBisect.cs` (per-host silent-typing evidence → wall red tests) | -| 8 — hashes + raw walk | `ProjectionProbe.cs` `Fingerprint`/`Sha256`; `InventoryProbe.cs` raw `JsonNode` walk (the `RawSiblingScanner`/`RawContentHasher` traversal shape) | -| 9 — tripwires/guards | `GrillProbes.cs` `RunMembers` (the DOM member+defaults inventory the baseline is built from) | -| 10 — landmark smoke | `ProjectionProbe.cs` landmark checks + the preserved outputs file | - -The retired parser (committed on the evidence branch, `tools/OpenCode.Sdk.Tools/Generator/Parsing/`) -adapts well for: `SpecMediaType` (near-verbatim), `SpecParseErrorCollector` (→ -`IngestionErrorCollector`), graph-key/promotion mechanics, and the old plan's exhaustive -test-case inventories (the superseded plan file exists on that branch). Its architecture is -the counter-example — research log session 12 records why. - -## Warnings - -- Never modify, clean, or delete the evidence worktree or its branch; disposition is the - maintainer's call after this slice merges. -- The evidence worktree contains a maintainer-owned dirty `GenerateCommand.cs` — untouchable. -- Do not recreate the pre-grill class-per-five-line-scenario pattern. The plan's inline - scenario/promotion rule is binding; all test cases remain, only their setup housing changed. -- Do not reduce Task 1 to `IFileSystem` registration. The complete ToolApp hosting battery, - MEL providers, global settings, interceptor, and shared test override path are slice scope. -- The filesystem seam uses `Testably.Abstractions` in production and - `Testably.Abstractions.Testing` in tests. The repo-wide enforcement package remains the - independent `TestableIO.System.IO.Abstractions.Analyzers`; Testably does not bundle it. -- Deviation protocol applies; subagents never self-resolve level 2+. -- Delete this handover in the slice's docs-pass task once the PR merges. diff --git a/docs/engineering/testing-style.md b/docs/engineering/testing-style.md index 1b9bf3b0..4887be37 100644 --- a/docs/engineering/testing-style.md +++ b/docs/engineering/testing-style.md @@ -109,9 +109,9 @@ goes through 1 or 2. test files only when it outgrows comfortable navigation. - TUnit creates a fresh instance per test: setup belongs in the constructor or `[Before(Test)]`; no state carried between tests, no shared mutable fields. -- Assertions state intent in the test body — snapshot testing is reserved for its three - sealed uses (emitter micro-snapshots, the ingestion SpecIR-of-the-pin snapshot, the - public API surface lock); behavior tests never snapshot. +- Assertions state intent in the test body — snapshot testing is reserved for its two + sealed uses (emitter micro-snapshots and the public API surface lock); behavior tests + never snapshot. ## 6. Anti-patterns (never) diff --git a/docs/research/00-research-log.md b/docs/research/00-research-log.md index 4169360a..0938506a 100644 --- a/docs/research/00-research-log.md +++ b/docs/research/00-research-log.md @@ -1397,3 +1397,75 @@ container leg. The recorded ~15-minute measurement guard remains the only trigge TFM trimming. The fake LLM stays limited to the published `/v1/chat/completions` contract and currently required scripted behaviors; quarantine/skip discipline and the label-deduped nightly canary issue flow remain as designed. + +# Session 15 — 2026-08-11: course correction — checkpoint absorbed, lean close-out + +The maintainer reviewed the Slice 1 branch outcome (8.6K lines, no SDK output) together +with the complexity checkpoint (research doc 13) and redirected process, assurance policy, +and sequencing in one pass. Decisions sealed in-session; the branch was then closed lean in +the same session. This entry is the chain. + +## Q63: Where did Slice 1's budget actually go, and which failure causes hold? + +**How researched:** per-area line accounting over `master...feature/slice-01-ingestion-specir` +(git numstat bucketed by path); the checkpoint's probes re-read; the maintainer's two +hypothesized causes tested against the numbers. + +**Found:** 8,593 added lines split into generator production 3,372 (≈1K of it surveillance +walls), test-infrastructure DSL 1,472, tests 2,159, docs 1,075, hosting 299 — before Binder, +emitters, or any SDK source existed. Both maintainer causes confirmed, with the mechanisms +named: (1) fail-closed maximalism was transplanted from the analyzer wall — where it is +cheap, rules pre-exist and misfires cost one arbitration comment — onto the reader DOM, +where a whitelist wall's cost scales with the *library's* surface, not with product risk; +(2) the slice map was a layer cake in vertical clothing — first compiled SDK code at +slice 3, first callable client at slice 5, first real request at slice 7 — so the +learn-what-works moment sat behind maximal investment. A third cause joined them: paper +grill sessions ratchet monotonically — every discovered risk gets a mechanism because +nothing on paper answers "what does this cost in code?" — and assurance intensity was never +scaled by blast radius (repo tooling got the shipped-SDK treatment). The checkpoint's +absorption and Kiota wire-fidelity probes also stand as the counter-evidence that the +architecture itself was right: the projection absorbs the full pin and no OSS generator +preserves this dialect. + +**Decisions (maintainer):** red lines kept — the analyzer wall and the +Testably/`IFileSystem` seam with its enforcement analyzer stay untouched; `Microsoft.OpenApi` +stays the reader. Everything else lightened: git diffs of committed generated output plus +the test suites become the primary drift radar, with targeted validators only at known +lossy seams. + +## Q64: What was cut, folded, and kept in the lean close-out? + +**Found/decided (keep/drop list approved item by item, then executed on the branch):** +**Dies** — `HostMemberWhitelist`/`SchemaMemberWhitelist` reflection surveillance, the +document wall, fatal handling of typed annotation/validation members (`title`, `default`, +`readOnly`, `examples`, length/bound keywords — now silently ignored: known vocabulary that +cannot change emitted behavior), fatal unknown `x-*` (ignored; a generation-report line +lands with the real `generate`), the DOM member/default inventory, the SpecIR-of-the-pin +Verify snapshot, raw content hashes and `SpecOperation.RawContentHash` (no consumer; +returns with the fingerprint feature), the per-keyword red-test inventory, and the +23-landmark census (trimmed to ~10 representative landmarks). **Folds** — path-level +parameters, unknown HTTP methods, parameter location/style/content, multi-media +bodies/responses, and non-integer status keys survive as explicit typed-member checks +inside the projectors (semantic loss ⇒ wrong wire). **Stays** — reader gate (version, +diagnostics-as-errors, crash translation), the semantic projection core, unrecognized-raw- +keyword refusal (the one construct nothing downstream can ever see) with the admitted +`prefixItems` site, the `$ref`-sibling raw scan and dangling-reference sweep, the +`prefixItems` library-upgrade tripwire, both DOM boundary guards, determinism, and batched +located errors. Post-surgery evidence: the ingestion seam (`ISpecIngestion`) still absorbs +the complete pin — 188 operations (61/127), 1,501 graph nodes, all landmarks green, +repeat-deterministic — at −851/+131 for the deletion pass. + +## Q65: What replaces the spec-and-slice-map process? + +**Decisions (maintainer):** the three design specs and the walking-skeleton design are +demoted to vision/reference (banner added; the sealed surface is the ADRs plus `AGENTS.md` +— its two edits: the vision/reference framing and blast-radius-scaled assurance). The +deviation protocol's level 2 narrows to canonical documents; vision-doc contradictions are +level-0 notes. The slice map is retired; `docs/ROADMAP.md` now carries a six-item +deliverable-first milestone list (M1 walking skeleton → M6 operational closure) with +just-in-time 1–2-page plans. The first deliverable is sealed: `v2.health.get` + +`v2.session.message` through the full pipeline into a callable client, demonstrated once +by hand against a real `opencode serve` — deliberately process-free (no launcher, no +harness, no CI leg; the demo output rides the PR description). Consumer-pull is the +standing rule: every SpecIR fact, mechanism, and test names a consumer or a concrete +failure it prevents. diff --git a/docs/superpowers/plans/2026-08-10-implementation-slice-map.md b/docs/superpowers/plans/2026-08-10-implementation-slice-map.md deleted file mode 100644 index 5d656964..00000000 --- a/docs/superpowers/plans/2026-08-10-implementation-slice-map.md +++ /dev/null @@ -1,328 +0,0 @@ -# Implementation Slice Map — SDK build-out - -Date: 2026-08-11 - -> **For agentic workers:** this is the **master map**, not an executable plan. Execution -> happens through per-slice plan files in this directory (checkbox tasks, written -> just-in-time — see "Planning model" below) via deniz-process:subagent-driven-development. -> Do not implement from this document alone. - -**Goal:** build `OpenCode.Sdk`, `OpenCode.Sdk.Extensions`, and the `tools/` generator from -the three sealed design specs, as vertical slices that each end in working, tested, -analyzer-clean software. - -**Inputs (canonical, sealed):** `specs/2026-08-09-public-api-design.md`, -`specs/2026-08-09-generator-architecture.md`, -`specs/2026-08-10-testing-architecture-design.md`; ADRs 0001–0009; `AGENTS.md` locked -decisions. This map adds sequencing and scope-cutting only — design content lives in the -specs; where this map summarizes them it relays, never overrides. - -## Planning model (sealed 2026-08-10) - -- **Just-in-time detail plans.** Each slice gets its own plan file - (`YYYY-MM-DD-slice-NN-.md`, writing-plans format) written at or just before its - execution session, so learning from landed slices feeds the next plan instead of rotting - in a pre-written one. This map is the only document that spans all slices. -- **Progress model.** Task/step level: the slice plan's checkboxes. Slice level: one GitHub - issue per slice, linking to this map (and to its plan file once written); ordering via - native `blocked_by` edges; a slice whose detail plan exists gets `ready-for-agent`; the - executing session claims with `--add-assignee @me`; the issue closes when the slice's PR - merges. ROADMAP relays the issue range + this directory + a one-line status. -- **Execution model.** deniz-process:subagent-driven-development per slice. Commit rhythm - (the `AGENTS.md` development-loop exception, sealed): on a slice branch, per-task commits - need no per-commit approval; merging to `master` always does (PR review). -- **Branching.** No implementation on `master`. One worktree + branch per slice - (`feature/slice-NN-`, created at execution time via - deniz-process:using-git-worktrees), one PR per slice targeting `master`. -- **Change management.** Specs and plans are falsifiable — deviations follow - `docs/agents/deviation-protocol.md` (levels 0–3). Slice issues and plans link it. - -## Done definition — every slice - -1. Analyzer wall clean (TWAE, on-merit for generated output — no new exemptions without - per-rule arbitration). -2. The slice's tests green on every leg the slice claims (TFM × OS per testing spec §4). -3. `dotnet format --verify-no-changes` clean. -4. `generate --verify` clean whenever the slice touches the tool or its outputs. -5. Docs pass in the same PR: ROADMAP one-liner, research-log entry when evidence is worth - keeping, corrections to any doc the slice contradicted (deviation protocol). -6. Conventional Commits; PR merged with maintainer approval; slice issue closed. - -## Slice sequence - -Dependencies are `blocked_by` edges on the issues. The order below is the default -execution order; slice 6 may start any time after 4 (it only needs the client root), and -slice 11's `refresh-spec` half may be pulled earlier if an urgent spec refresh materializes. - -### Slice 0 — Tooling skeleton - -**Depends on:** — · **Spec anchors:** generator spec §3.1–§3.3 - -`tools/` layout: file-based entry (`opencode-tool.cs`, shebang + `#:project`, committed -executable bit), `OpenCode.Sdk.Tools` library (net10.0) with the `ToolApp` factory -(Spectre.Console.Cli + DI registrar) and a fail-loud `generate` stub; -`OpenCode.Sdk.Tools.Tests` (net10.0, `CommandAppTester`); the §3.3 verification list — -strict-props build, `#:project` cache staleness, invocation-form pinning — with its -recorded two-condition fallback (console-app promotion + one-line ADR-0003 correction); -CI smoke step running the entry on the Linux leg. - -**Hands over:** `ToolApp.CreateRegistrar`/`ToolApp.Configure` composition seam; pinned -invocation forms; the Tools.Tests harness pattern. - -### Slice 1 — Ingestion + SpecIR - -**Depends on:** 0 · **Spec anchors:** generator spec §4.1, §11 (projection tests) - -Foundation first: the full ToolApp composition root (`docs/engineering/coding-style.md` -§2: `IFileSystem`, `IAnsiConsole`, MEL Spectre/optional-file providers, global settings, -interceptor, and the shared production/test registration path), the Testably filesystem seam -and independent TestableIO analyzer joining the repo-wide wall, and the lambda-first -scenario/builder/fixture test infrastructure with promotion-only named scenarios -(`docs/engineering/testing-style.md` -§1). On that foundation, the -pinned Microsoft.OpenApi reader as the tooling ingestion layer; the fail-closed -projection behind the whitelist dialect wall (admitted typed members, the -unrecognized-keyword net, extension dispositions, library-upgrade tripwires); the -minimal immutable SpecIR (record inventory derived backward from -Binder/emitter/refresh-diff consumption); projection normalizations (duplicate-ref -dedup, envelope-shape classification, error-style detection, literal markers in both -dialects, special-value numbers, parameter-stripped media types, opaque -`x-effect-stream`, unrestricted `{}` nodes, the `prefixItems` fragment adapter). -Filesystem I/O via Testably's shared `IFileSystem` contract. Tests: projection quirk -fixtures loaded through the real reader + wall red tests + tripwires + the full-spec -landmark smoke test (no -count assertions). Honest note: the generation pipeline remains a fail-loud stub until -slice 3, while the complete hosting composition and its global CLI options become live -in this slice. - -**Hands over:** SpecIR types consumed by the Binder; the full ToolApp hosting composition -and centralized scenario/builder test infrastructure every later slice builds on. - -### Slice 2 — Binder + curation v0 - -**Depends on:** 1 · **Spec anchors:** generator spec §4.2, §5; ADR-0008 - -`tools/curation.json` v0 authored in full (groups/handles, envelope payload names, -exclusions, content-type map, parameter/property overrides with mandatory reasons, brand -spellings) — **curation rows are public-API review surface (ADR-0008); this slice's PR is -an API review.** Binder: curation load (`Disallow` + comment-skip + trailing commas, -`[JsonPropertyName]` pins), bidirectional coverage checks with batched categorized -reporting, reachable-closure computation with orphan info-listing, mechanical name -computation (FDG acronyms, dotted-name mangling, handle rule), derived emission decisions -(paginators, converters, registry, stream item schemas), XML-doc computation with the -deterministic synthesized fallback, fingerprint computation (both kinds). Tests: one red -test per coverage check, name-computation cases, handle routing, derivation cases. - -**Hands over:** `EmitPlan` types consumed by all emitters; the fingerprint values the -Writer will persist. - -### Slice 3 — Model-layer emission — `generate` becomes real; the 5-TFM milestone - -**Depends on:** 2 · **Spec anchors:** generator spec §6–§9, §12, §13; ADR-0003/0004/0009 - -`ModelEmitter`, `UnionEmitter` (tolerant converters, sealed dispatch-as-data shape), -`RegistryEmitter`; the Writer (output manifest, stale cleanup, determinism, whole-project -`dotnet format` post-step); `generate` gains its real pipeline plus `--verify` (with the -dirty-paths precondition) and `--update-fingerprints`; `spec/fingerprints.json` written -and verified on every run; the committed full-spec model layer lands in -`src/OpenCode.Sdk` (plain `.cs`, non-magic header, manifest-tracked); CI's entry smoke -step upgrades to `generate --verify`. **Milestone (downlevel-early):** full-spec -generated output compiles on all five TFMs — the SDK gains its downlevel System.Text.Json -dependency and the ROADMAP net472 spike items *polyfill-set validation* and -*generated-model downlevel compile* resolve here, before any emitter polish. Also here: -round-trip behavior tests in `OpenCode.Sdk.Tests` (level 1: tag dispatch, unknown-variant -carrier + re-serialization, out-of-order discriminators, explicit-null vs missing), -per-emitter Verify micro-snapshots (Verify.TUnit enters CPM), the double-emit determinism -test, and the **CS1591 → `error` flip** (synthesized fallback docs make it holdable). - -**Hands over:** generated models + serializer registry; a live regen-verify CI gate. - -### Slice 4 — Transport core + Extensions (co-development) - -**Depends on:** 3 · **Spec anchors:** public API spec §4, §5.1 (envelope base), §6, §7, -§9, §10; ADR-0007 - -Hand-written identity core, TDD against in-memory `HttpMessageHandler` stubs: exception -spine (`OpenCodeException`/`OpenCodeApiException`/`OpenCodeTransportException`), envelope -base (`OpenCodeResponse`, `[SetsRequiredMembers]` error path), `OpenCodeClientOptions` / -`OpenCodeRequestOptions` (`NoThrow`, `Directory`), the `ExecuteAsync` behavior core -(decoration: basic auth chain, directory header, User-Agent; idempotent-only retry with -one disable knob; tagged→typed error mapping incl. unknown-tag carrier; -throw-vs-populate; `ActivitySource` + `ILogger` telemetry; sync per-attempt hooks), -`SendAsync` escape hatch, `HttpClient` ownership rules, `OpenCodeClient` root with the -mock seam (virtual members, protected ctor, instructive `Pipeline` guard). Extensions: -`AddOpenCodeClient` (options binding, `AddHttpClient`, returns `IHttpClientBuilder`) + -`OpenCode.Sdk.Extensions.Tests` project. The **public API surface lock** (Verify surface -dump per package) starts here for both packages. - -**Hands over:** the `Pipeline.ExecuteAsync` seam, envelope base, and options types the -generated operation methods delegate into; the DI composition shape. - -### Slice 5 — Operation surface (remaining emitters + contract tests) - -**Depends on:** 4 · **Spec anchors:** public API spec §5, §8; generator spec §6; ADR-0008; -testing spec §3 (level 2), §7.1 - -`EnvelopeEmitter` (guarded getters, guarded `PrintMembers`, disposable `Stream` -envelopes, 204 envelopes, `SessionsCursor`), `InputRecordEmitter`, -`OperationMethodEmitter` (sub-clients, `SessionClient` handle, `Legacy` hub, one-line -virtual delegations, guards, XML docs with ``), `RoutesEmitter`, -`PaginatorEmitter`. Tool-emitted test artifacts land as the second manifest root: the -**operation inventory** (with HTTP method + path template) and the **contract fixtures**. -Level-2 contract suite in `OpenCode.Sdk.Tests/Contract/`: every operation's happy path + -every declared error response through the stub handler — 100% breadth on both surfaces, -running on all unit-leg TFMs (net472 leg = ns2.0 proxy). Surface lock extends to the full -generated client. - -**Hands over:** the complete typed client; the inventory the auth sweep loops over; -`OpenCodeRoutes` for raw probes. - -### Slice 6 — Launcher + three-OS acceptance - -**Depends on:** 4 (5 recommended first — acceptance uses the typed health call) · **Spec -anchors:** public API spec §13; ADR-0001; testing spec §9.2 - -`OpenCodeServer.StartAsync` deep-dive: six-point anatomy (arg quoting per TFM, continuous -stdout/stderr drain, Unix SIGTERM grace, tree-kill fallback, Windows Job Object orphan -protection; net11 light-up deferred post-GA), the auto-port chain (`--port=0` first with -release-binary confirmation — a §13 UNVERIFIED item resolved here — then `TcpListener(0)` -probe + bounded retry), readiness-line parsing, `StartupTimeout` kill + captured output, -`CreateClient()` auth wiring. The ROADMAP net472 spike items *async stdout reading* and -*`taskkill /T /F` tree-kill* resolve here. `OpenCode.Sdk.Integration.Tests` project is -born (net472 Windows-only + net8/9/10): fake-binary tests (scripted stdout/exit) + -real-binary three-OS acceptance (auto-port ×2 concurrent, explicit-port conflict -fail-loud, dispose = whole tree gone, env injection) + helper-process orphan technique. -CI: pinned opencode install on all three legs; `spec/opencode-version` created -(hand-stamped now; `refresh-spec` owns it from slice 11). - -**Hands over:** the launcher the direct-mode fixture dogfoods; the integration project -and its CI legs. - -### Slice 7 — Integration harness + fake LLM + first scenario wave - -**Depends on:** 5 + 6 · **Spec anchors:** testing spec §5, §6, §7.2 (stage 1), §8 - -Fixture family: common base + `DirectOpenCodeServerFixture` (launcher-started, §5.2 -env-isolation set verbatim) + `DedicatedOpenCodeServerFixture`; the workspace model -(`CreateWorkspace()`, `ServerPath`/`HostPath` views); `[Quarantined]` attribute + the -quarantine conventions; the fake LLM server (Kestrel, port 0, -`POST /v1/chat/completions` only: scripted reply queue incl. failure modes, auto-`"ok"`, -title auto-answer, per-reply `Usage`, deterministic chunking, `WaitForRequestsAsync` + -`Hits`/`Inputs`; `[NotInParallel("llm")]` group); `[ExercisesOperation]` attribute + the -coverage gate at **stage 1 — reporting mode**: the declaration-vs-inventory diff runs and -prints the uncovered list on every run but does not fail the build yet (sealed -interpretation: the gate is born here, its hard-fail flip is slice 10's exit criterion). -First scenario wave: session/message CRUD, config, fs, catalog reads, one prompt -round-trip through the fake LLM. - -**Hands over:** fixtures, workspace, fake LLM, gate plumbing for slices 8–10. - -### Slice 8 — SSE engine + stream scenarios (streams-early seal) - -**Depends on:** 7 · **Spec anchors:** public API spec §4.5, §11; testing spec §9.1; -ADR-0009 - -The hand-written SSE engine, TDD at level 1 with canned streams (`SseParser` over -`ResponseHeadersRead`, lazy `IAsyncEnumerable`, cancellation, exception surfacing from -`MoveNextAsync`, unknown-event tolerance — level 1 is its honest home); hand-wired stream -endpoints (`client.Events.SubscribeAsync`, `session.Events.SubscribeAsync(after)`, -`ListHistoryAsync`) delegating item typing to the generated unions. The six §9.1 -integration scenarios in the same slice — live sequence, **durable gap-free resume**, -mid-stream disconnect (dedicated fixture), `session.error` union via fake-LLM failure -modes, numeric `after`/`limit` (the designated behavior-premise catch point), concurrent -streams + CRUD — including the net472 `ServicePointManager.DefaultConnectionLimit` fix -and its standing regression test (the last ROADMAP net472 spike item resolves here). - -**Hands over:** the complete stream plane, exercised end-to-end in direct mode. - -### Slice 9 — Container mode (clean-install lane) - -**Depends on:** 7 (+8 for the inherited stream suites) · **Spec anchors:** testing spec -§5.3, §11.2, §14 - -Clean-install Dockerfile (pinned opencode) + GHCR image build/push workflow (pin-tagged, -rebuilt only on Dockerfile/pin change); `ContainerOpenCodeServerFixture` (Testcontainers: -workspace bind mount at `/workspace`, host-port exposure for the fake LLM, port + health -readiness, Docker-unavailable conditional skip); the §14 UNVERIFIED items (Testcontainers -API shapes, GHCR anonymous pull, health-probe choice) resolve here; selective dual-mode -suites via abstract base + `[InheritsTests]` for process, workspace/filesystem, and -stream-sensitive scenarios plus basic container health/typed-CRUD smoke; Linux container -CI legs for net8.0, net9.0, and net10.0 (net472 remains Windows/direct only). - -**Hands over:** the process-management control lane ("direct red, container green -indicts the launcher"). - -### Slice 10 — Coverage completion — the gate turns green - -**Depends on:** 8 · **Spec anchors:** testing spec §7.2 (stage 2), §7.3; ADR-0005 - -The remaining modern-surface coverage to 61/61 (one workflow may declare and observe -multiple operations; deep scenarios concentrate on risk, with honest `ErrorPathOnly` -declarations for the console-backed `integration.connect.*`/`integration.attempt.*` -family); the -auth + reachability sweep (dedicated password-enabled instance, sequential, data-driven -over the inventory, `SendAsync` + `OpenCodeRoutes` probes, curated probe table with -mandatory reasons + `authOnly` flags, SSE probes via `ResponseHeadersRead`); the status -ledger (tallying `DelegatingHandler` + closing gate: every modern op observed ≥1 2xx); -the skip-ban reflection gate (whitelist: conditional Docker skip, `[Quarantined]`); -**the coverage gate flips from reporting to hard-fail** — this slice's exit criterion. -Legacy scenarios stay best-effort until the MCP server's consumed set arrives (ADR-0005). - -**Hands over:** the fail-closed coverage regime the MCP phase will extend. - -### Slice 11 — Operational closure - -**Depends on:** 3 (`refresh-spec`); 10 (canary/quarantine steps) · **Spec anchors:** -generator spec §10; testing spec §8.2, §11.3–§11.4 - -`refresh-spec --ref` command + tests (faked git/copy wrappers: submodule bump, spec copy, -`SNAPSHOT.md` rewrite, `spec/opencode-version` stamp, SpecIR diff summary); -`spec/SNAPSHOT.md`'s refresh section rewritten to the tool-based playbook; the nightly -non-blocking canary against `opencode@latest` with label-deduped issue filing; the -non-blocking quarantine CI step (category filter). **Phase close rides this slice:** -durable sealed decisions without an ADR home distill into ADRs, the transient -`docs/superpowers/` specs and this map retire, ROADMAP shrinks to the MCP phase. - -## Sequencing summary - -``` -0 → 1 → 2 → 3 → 4 → 5 → 7 → 8 → 9 - └→ 6 ──↗ └→ (9 inherits stream suites) - 8 → 10 → 11 (canary/quarantine) - 3 ─────────────────→ 11 (refresh-spec half) -``` - -Slices are sized for one focused execution arc each; if one proves oversized mid-flight, -splitting it is a level-0/1 call recorded on its issue — merging two is the same in -reverse. - -## Decisions sealed in the planning session (2026-08-10) - -1. **Launcher before streams.** The handover skeleton's SSE→launcher order inverted: the - direct-mode fixture dogfoods the launcher (testing spec §5.2), and streams-early binds - the SSE engine to its integration scenarios — so launcher (6) and harness (7) precede - streams (8). -2. **Coverage-gate staging.** "Hard gate from day one" is implemented as: gate born in - slice 7 (reporting mode, diff visible on every run), hard-fail flip as slice 10's exit - criterion. Never optional once the suite claims completeness. -3. **Test projects are born in the slice that needs them** (Tools.Tests → 0, - Extensions.Tests → 4, Integration.Tests → 6), per testing spec §4's own staging. -4. **net472 spike items distributed to their SUT slices:** polyfill/model downlevel → 3, - stdout/tree-kill → 6, `ServicePointManager` SSE → 8. -5. **CS1591 → `error` flips in slice 3** (synthesized generated docs make it holdable; - hand-written surfaces are documented as they land). -6. **JIT planning + this map** as the plan architecture; slice issues with `blocked_by` - edges; `ready-for-agent` only with a written detail plan. -7. **Execution:** subagent-driven-development; per-task commits free on slice branches - (the AGENTS.md development-loop exception), master merges by PR approval only. -8. **Branching:** worktree + `feature/slice-NN-` per slice, one PR per slice. -9. **Deviation protocol** (`docs/agents/deviation-protocol.md`) governs all mid-slice - contradictions; AGENTS.md carries the falsifiability working agreement. -10. **Slice 1 hosting and test-authorship correction:** the ToolApp foundation is the - full PathSmith-shaped DI host rendered through MEL; small one-off spec variations are - inline scenarios, while named scenario classes require reuse, complexity, or durable - domain identity. Every test case and full per-task gate remains. -11. **Integration breadth without ceremonial depth:** modern 61/61 declaration + observed - 2xx remains hard; workflows may cover multiple operations and deep assertions are - risk-based. Dual-mode is selective, and its Linux container set runs on every modern - runtime TFM (`net8.0;net9.0;net10.0`). diff --git a/docs/superpowers/plans/2026-08-11-slice-01-ingestion-specir.md b/docs/superpowers/plans/2026-08-11-slice-01-ingestion-specir.md deleted file mode 100644 index 63a20ba0..00000000 --- a/docs/superpowers/plans/2026-08-11-slice-01-ingestion-specir.md +++ /dev/null @@ -1,982 +0,0 @@ -# Slice 1 — Ingestion + SpecIR Implementation Plan - -Date: 2026-08-11 - -> **For agentic workers:** REQUIRED SUB-SKILL: Use deniz-process:subagent-driven-development -> (recommended) or deniz-process:executing-plans to implement this plan task-by-task. Steps -> use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** stand up the tooling foundation (full-battery DI composition, MEL logging, -global CLI interception, Testably filesystem seam, first-class test infrastructure) and -build the fail-closed ingestion stage on it — the pinned Microsoft.OpenApi -reader, the per-host whitelist dialect wall, the semantic projection into the minimal -immutable SpecIR, raw-content hashes, library-upgrade tripwires, DOM-boundary guards, and the -full pinned-spec landmark smoke test (generator spec §4.1). - -**Architecture:** `ISpecIngestion` (the Binder-facing seam) orchestrates three stages inside -`tools/OpenCode.Sdk.Tools/Generator/Ingestion/`: `SpecReader` (loads the document through -`IFileSystem` + `OpenApiDocument.LoadAsync`, enforces the version gate, promotes reader -diagnostics to errors, translates reader exceptions), the per-host wall policies (whitelist -tables over every consumed DOM type), and the projectors (schema classification, -normalizations, operation surface, graph keys, raw-content hashes) producing `SpecDocument`. -Everything follows `docs/engineering/coding-style.md` (named collaborators, full CLI -composition, seams + DI, signature hygiene) and `docs/engineering/testing-style.md` -(lambda-first one-off scenarios, promoted named scenarios, domain builders, embedded fixtures — -no inline JSON dumps). The mutable Microsoft.OpenApi DOM never escapes -`Generator/Ingestion/`. - -**Tech Stack:** Microsoft.OpenApi 3.9.0 (pinned; tooling-only), Testably.Abstractions 10.3.0, -Testably.Abstractions.Testing 7.0.2, and the independent -`TestableIO.System.IO.Abstractions.Analyzers` 2022.0.0 (repo-wide), Spectre.Console + -Spectre.Console.Cli + DI registrar (already pinned), Microsoft.Extensions.Logging with -Spectre and optional Testably-backed file providers, System.Text.Json (in-box), TUnit on -MTP (pinned), Verify.TUnit (new pin — the SpecIR-of-the-pin snapshot). - -## Global Constraints - -- `LangVersion=14.0`, `AnalysisLevel=10.0` — deliberate numeric pins; never "fix" to - `latest` (AGENTS.md Hard Rules). -- Full analyzer wall + `TreatWarningsAsErrors=true` applies to `tools/`: CA1062 guards - (`ArgumentNullException.ThrowIfNull` / `ArgumentException.ThrowIfNullOrWhiteSpace`), - MA0048 one type per file, IDE0130 folder = namespace, MA0051 method length, - culture-invariant formatting (`FormattableString.Invariant` / `CultureInfo.InvariantCulture`), - `StringComparison.Ordinal` everywhere. Async code carries the `ConfigureAwait(false)` - triple (product code; tests exempt). -- **`docs/engineering/coding-style.md` and `docs/engineering/testing-style.md` are binding - constraints of every task** — named collaborators over private-method accumulation; - interfaces at seams only, `sealed` elsewhere; no tuple returns or concrete-collection - parameters across class boundaries; vertical layout with `Abstractions/`+`Models/` groups; - centralized scenario assembly + domain builders + embedded fixtures; **no inline JSON - dumps in test bodies**. Violations are defects, not preferences. -- **Scenario granularity:** small one-off variations use `SpecScenario.Define(...)` or - `SpecScenario.FromRawJson(...)` inline. A named scenario class is promoted only when the - arrangement is reused across test classes, is non-trivial (roughly more than five fluent - statements or fixture-plus-shaping), or carries durable cross-slice domain/landmark - identity. Every inventory case remains a full test; only its setup housing changes. -- Analyzer misfires: per-rule arbitration with a winner-naming comment — never a rollback, - never an inline suppression without arbitration. -- Test naming: `{Symbol}_Should_{Expected_Behavior}[_When_{Condition}]`; classes - `{Sut}Tests`, one per file. TUnit syntax: `[Test]`, - `await Assert.That(x).IsEqualTo(y)`; exception capture: - `var ex = await Assert.That(() => ...).Throws();` then assert on - `ex!.Message`. Adapt in place if the pinned TUnit differs — level-0 deviation. -- CPM: new pins are `Microsoft.Extensions.Logging`, `Spectre.Console`, - `Microsoft.OpenApi` 3.9.0, `Testably.Abstractions` 10.3.0, - `Testably.Abstractions.Testing` 7.0.2, - `TestableIO.System.IO.Abstractions.Analyzers` 2022.0.0, and `Verify.TUnit` (newest stable). - Before pinning, re-check with `dotnet package search --exact-match` and pin the newer - stable if one exists. If the ingestion behavior differs on a newer Microsoft.OpenApi, - stop — level 2 (the spec's evidence is 3.9.0). -- No count assertions against the pinned full spec — shape and named-landmark assertions - only. -- Determinism: schema-graph keys ordinal-sorted; operation list and member order are - document order; no wall-clock, no randomness. -- Defensive programming default: unknown constructs refuse with located, batched errors; - silent fallbacks are forbidden outside the recorded tolerances (known-ignored validation - keywords, the opaque `x-effect-stream`, the admitted `prefixItems` site, library - bookkeeping members). -- Everything temporary goes to `.scratchpad/` (gitignored). -- After every task (before its commit): run - `dotnet tool run slopwatch analyze --exclude ".scratchpad/**,external/**" --fail-on warning`. -- **The full gate** (every task) = `dotnet build --configuration Release` → - `dotnet test --configuration Release --no-build` → - `dotnet format --verify-no-changes --no-restore` → the Slopwatch command. All four clean. -- Conventional Commits; per-task commits on the slice branch are the agreed loop; master - merges via PR only. -- Contradictions with a sealed spec: stop and classify per - `docs/agents/deviation-protocol.md`. Subagents never self-resolve level 2+. -- Work happens on branch `feature/slice-01-ingestion-specir` in a worktree - (deniz-process:using-git-worktrees). -- Out of scope (hidden-scope ban): Binder, curation, emitters, Writer, generated output, - `generate` pipeline wiring beyond DI registration, fingerprint *persistence* (§9 composing - is Binder work — this slice only computes and carries the raw hashes), CI workflow changes. - -## Reference implementations (adapt, never transplant) - -Run-proven reference code exists for nearly every mechanism in this plan — the redesign -probes (five modes plus the grill's `members`/`hostbisect`) and the retired parser on the -`feature/slice-01-parser-specir` evidence branch (its `SpecMediaType`, error collector, -graph-key mechanics and test-case inventories adapt well; its architecture is the -counter-example — research log session 12). Exact locations and a per-task map live in the -execution handover (`docs/agents/handover-prompts/HANDOFF-2026-08-11.md`). Rules: probes -are blacklist-shaped single files — production is whitelist-shaped collaborators under -`coding-style.md`; adapt the mechanics, never copy the shape; never modify the evidence -worktree. - -## SpecIR at a glance (locked reference — each task restates what it needs) - -All ingestion types live under `tools/OpenCode.Sdk.Tools/Generator/Ingestion/`, namespace -`OpenCode.Sdk.Tools.Generator.Ingestion[.Models|.Abstractions|.Walls|.Projection]`, one file -per type, records immutable (`required`/`init`, `IReadOnly*` frozen copies). - -| Type | Role | -|---|---| -| `ISpecIngestion` | the seam: `Task IngestAsync(string specPath, CancellationToken ct)` | -| `IngestionException` | batched refusal; `IReadOnlyList Errors` | -| `IngestionError` | `record IngestionError(string Location, string Problem)` | -| `SpecDocument` | `Operations` (document order), `Schemas` (ordinal-sorted keys), `SchemaContentHashes` (ordinal-sorted; name → SHA-256 hex) | -| `SpecOperation` | id, surface, method, path, wildcard/websocket/SSE/deprecated flags, summary/description, parameters, request body, responses (status-ascending; each carries envelope shape, SSE flag and the opaque `EffectStreamJson`), `RawContentHash` | -| `SpecParameter`, `SpecRequestBody`, `SpecResponse`, `SpecMediaType` | operation surface details | -| `SpecSurface`, `SpecParameterLocation`, `SpecEnvelopeShape` | operation-side enums | -| `SchemaNode` (abstract: `Description`, `Format`, `Children`) | graph node base | -| `PrimitiveNode`, `EnumNode`, `LiteralNode`, `ObjectNode` (+`SpecProperty`; hybrid carries `AdditionalPropertiesSchema`), `DictionaryNode`, `FreeFormObjectNode`, **`UnrestrictedNode`**, `ArrayNode`, `TupleNode`, `UnionNode` (+`UnionClassification` Marked/Structural), `NullableNode`, `RefNode`, `SpecialNumberNode`, `JsonStringNode` | node kinds | -| `LiteralMarker`, `ErrorStyle`, `LiteralDialect`, `UnionKeyword`, `AdditionalPropertiesKind`, `PrimitiveKind`, `LiteralKind` | node-side facts | - -**Graph keys (locked):** named schemas use the wire name verbatim (`Session`, -`session.status`). Promoted inline types use `{root}#{pointer}`: root = owning named schema's -wire name or `op:{operationId}`; pointer segments `/properties/{name}`, `/items`, -`/additionalProperties`, `/patternProperties`, `/prefixItems/{index}`, `/contentSchema`, -`/anyOf/{branch}`, `/oneOf/{branch}`, `/parameters/{name}`, `/requestBody`, -`/responses/{status}`. A union-branch `{branch}` is `{prop}={value}` from the branch's -alphabetically-first literal marker; **branches without a marker use the ordinal index**. -Segment names JSON-pointer-escape `~` → `~0` and `/` → `~1`. Key collision ⇒ error. Never a -document-global counter. - -**The wall (locked; generator spec §4.1's per-host tables are the authority):** any populated -*spec-derived* member outside a host's admitted/known-ignored table refuses with a located -error; library bookkeeping members (`BaseUri`, `Self`, `Workspace`, `Metadata`) are exempt -from the wall but covered by the tripwire snapshots. Reader rules: `SpecificationVersion` -must be `OpenApi3_1`; any reader diagnostic error fails ingestion; reader exceptions -translate to located errors. `UnrecognizedKeywords` must be empty except `prefixItems` under -`Config.plugin`. Extension dispositions: `x-codeSamples` ignored (operations), `x-websocket` -flag (operations), `x-effect-stream` opaque (SSE media only), all other `x-*` refuse. - ---- - -### Task 1: Package pins + full ToolApp composition root - -**Files:** -- Modify: `Directory.Packages.props` -- Modify: `Directory.Build.props` (TestableIO analyzer joins the repo-wide analyzer wall) -- Modify: `tools/OpenCode.Sdk.Tools/OpenCode.Sdk.Tools.csproj` -- Modify: `tests/OpenCode.Sdk.Tools.Tests/OpenCode.Sdk.Tools.Tests.csproj` -- Modify: `tools/OpenCode.Sdk.Tools/ToolApp.cs` -- Modify: `tools/OpenCode.Sdk.Tools/Commands/GenerateCommand.cs` -- Create: `tools/OpenCode.Sdk.Tools/GlobalSettings.cs` -- Create: `tools/OpenCode.Sdk.Tools/Infrastructure/GlobalOptionsInterceptor.cs` -- Create: `tools/OpenCode.Sdk.Tools/Infrastructure/Logging/ToolLoggingOptions.cs`, - `SpectreConsoleLoggerProvider.cs`, `FileLoggerProvider.cs`, `ToolLogLevelConverter.cs` -- Test: `tests/OpenCode.Sdk.Tools.Tests/ToolAppTests.cs` (extend the existing class), - `tests/OpenCode.Sdk.Tools.Tests/Infrastructure/Logging/ToolLoggingTests.cs` - -**Interfaces:** -- Consumes: slice 0's `ToolApp` factory seam (`CreateRegistrar`/`Configure`) and the - existing `CommandAppTester` harness. -- Produces the complete CLI hosting/DI topology (coding-style §2), not a skeleton: - - `ToolApp.CreateServices(Action? overrideServices = null)` is the - single registration root. Defaults are registered first; the optional callback runs - last for seam replacement. `CreateRegistrar` retains its existing optional override - callback and delegates to this method. Tests never reproduce the registration list. - - Core seams: singleton `IFileSystem` → `RealFileSystem` and `IAnsiConsole` → - `AnsiConsole.Console`; later tasks extend this same root with `ISpecIngestion`, Binder, - emitters, Writer, and infrastructure wrappers. - - MEL end to end: commands/services consume `ILogger`; a Spectre provider writes - through `IAnsiConsole`; an optional file provider writes only through `IFileSystem`. - Both providers read the singleton `ToolLoggingOptions` applied per invocation. - - `GlobalSettings : CommandSettings` carries global `--log-level` and `--log-file`. - Accepted levels match the tooling UX (`trace`, `debug`, `info`, `warning`, `error`, - `none`), default `warning`; `ToolLogLevelConverter` maps them to MEL. Every command's - settings derive from this base. - - `GlobalOptionsInterceptor : ICommandInterceptor` maps `GlobalSettings` into - `ToolLoggingOptions` before command execution. It is DI-registered; do not use the - obsolete single-interceptor setter. - - `GenerateCommand.Settings : GlobalSettings` is its nested concrete settings type; - `GenerateCommand` injects `ILogger` alongside `IAnsiConsole`, logs its - invocation, and remains the same nonzero fail-loud stub. Future command settings derive - from the same global base. - -- [x] **Step 1: Add the package pins.** In `Directory.Packages.props` (re-check newest - stable first — Global Constraints): add `Microsoft.Extensions.Logging` beside the - existing DI package; add the direct `Spectre.Console` dependency beside its CLI packages; - under third-party analyzers add `TestableIO.System.IO.Abstractions.Analyzers` `2022.0.0`; - under third-party packages add `Microsoft.OpenApi` `3.9.0` and - `Testably.Abstractions` `10.3.0`; under test packages add - `Testably.Abstractions.Testing` `7.0.2` and `Verify.TUnit` (newest stable). - In `Directory.Build.props`, append the analyzer to the repo-wide analyzer ItemGroup with - the same `PrivateAssets`/`IncludeAssets` shape as its siblings. (The analyzer is an - older-Roslyn build; if the compiler refuses to load it, stop — level 2.) -- [x] **Step 2: Reference packages.** Tools csproj adds `Microsoft.Extensions.Logging`, - `Spectre.Console`, `Microsoft.OpenApi`, and `Testably.Abstractions`; tests csproj adds - `Testably.Abstractions.Testing` and `Verify.TUnit`. -- [x] **Step 3: Write the failing tests:** - - `CreateServices_Should_Resolve_Full_Hosting_Composition` builds `CreateServices()` and resolves - the concrete filesystem, `IAnsiConsole`, `ILogger`, - `ToolLoggingOptions`, and `IEnumerable` containing exactly the - global interceptor. - - `CreateRegistrar_Should_Apply_Seam_Overrides_After_Production_Registrations` replaces - `IFileSystem` with one `MockFileSystem` through the callback and proves the resolved - singleton is that instance — no test-only service-list reconstruction. - - `RunAsync_Should_Apply_Global_Logging_Options_Before_Command_Execution` runs - `generate --log-level debug --log-file logs/tool.log` through `CommandAppTester` with - MockFileSystem/console seam replacements; the existing nonzero stub result remains, - the file is created through the fake, and its content includes the command log. - - Focused provider tests prove default-warning filtering, Spectre markup-safe output, - and fail-loud behavior when an explicitly configured log file cannot be initialized. -- [x] **Step 4: Run to verify they fail** — missing composition/settings/provider types and - `ToolApp.CreateServices` are the red state. -- [x] **Step 5: Implement** the Produces block. Provider logic is split by destination; - the interceptor owns invocation configuration; `ToolApp` owns registration and command - wiring only. All file paths and writes ride `IFileSystem`; no custom logger facade and no - silent logging-I/O fallback. -- [x] **Step 6: Run to verify pass** — full suite green (slice-0 command behavior retained). -- [x] **Step 7: Full gate** (all four commands clean). Expect the new IO analyzer to fire - wherever slice-0 code touched `File`/`Path` directly; fix by routing through the injected - `IFileSystem` — that is the analyzer doing its job, not a misfire. -- [x] **Step 8: Commit** — `feat(tools): full hosting composition and ingestion package pins` - ---- - -### Task 2: Test infrastructure — scenarios, builders, embedded fixtures - -**Files:** -- Create: `tests/OpenCode.Sdk.Tools.Tests/Support/SpecScenario.cs`, - `Support/ScenarioContext.cs`, `Support/SpecDocumentBuilder.cs`, `Support/SchemaBuilder.cs`, - `Support/OperationBuilder.cs`, `Support/FixtureLoader.cs` -- Modify: `tests/OpenCode.Sdk.Tools.Tests/OpenCode.Sdk.Tools.Tests.csproj` (embed - `Fixtures/**/*.json`; link `spec/openapi.json` as `Fixtures/openapi.json`, - `CopyToOutputDirectory="PreserveNewest"`) -- Test: `tests/OpenCode.Sdk.Tools.Tests/Support/Tests/SpecDocumentBuilderTests.cs`, - `Support/Tests/SpecScenarioTests.cs`, `Support/Tests/FixtureLoaderTests.cs` - -**Interfaces:** -- Consumes: `Testably.Abstractions.Testing.MockFileSystem`. -- Produces the centralized testing-style §1 shape: - - `SpecDocumentBuilder` — sealed, fluent, JSON-producing: - `WithOpenApiVersion(string version = "3.1.0")`, - `WithSchema(string name, Action configure)`, - `WithRawSchema(string name, string fixtureName)` (embedded-fixture body), - `WithOperation(string operationId, string method = "get", string path = "/api/x", Action? configure = null)`, - `WithRawTopLevel(string key, string rawJson)` (short subject literals), - `WithRawTopLevelFromFixture(string key, string fixtureName)` (structural wall fixtures), - `string BuildJson()`. The builder receives one `FixtureLoader` collaborator; scenario - assembly supplies it centrally. - - `SchemaBuilder` — domain verbs used across this plan: `Type(string)`, - `Property(string name, Action, bool required = false)`, - `Required(params string[])`, `Enum(params string[])`, `Const(string)`, - `AnyOf(params Action[])`, `OneOf(...)`, `AllOf(...)`, `Ref(string target)`, - `Items(Action)`, `PrefixItems(params Action[])`, - `AdditionalProperties(Action)`, `AdditionalPropertiesFalse()`, - `PatternProperties(string pattern, Action)`, - `ContentSchema(string mediaType, Action)`, `Unrestricted()`, - `Raw(string key, string rawJson)` (unknown-keyword injection), `Description(string)`, - `Format(string)`. - - `OperationBuilder` — `Parameter(string name, string location, Action, bool required = false, bool deepObject = false)`, - `RequestBody(string mediaType, Action, bool required = false)`, - `Response(int status, string? mediaType = null, Action? schema = null)`, - `SseResponse(Action schema, string? effectStreamJson = null)`, - `Extension(string key, string rawJson)`, `Raw(string key, string rawJson)`, - `Deprecated()`, `Summary(string)`. - - `SpecScenario` — the common scenario mechanism. `Define(Action)` - creates the default one-off arranged scenario; `FromRawJson(string)` creates a scenario - whose literal payload is itself the test subject; `Build()` writes to - `spec/openapi.json` on a fresh `MockFileSystem` and returns the context. A promoted named - scenario derives from this same type and overrides its arrangement hook — there is no - second base/factory hierarchy. - - `ScenarioContext` — - `record ScenarioContext(IFileSystem FileSystem, string SpecPath)`. - - `FixtureLoader` — a sealed instance collaborator with `string Load(string name)` from embedded resources - (`OpenCode.Sdk.Tools.Tests.Fixtures.{name}`), throwing with the known-names list on a - miss. - -The promotion rule in Global Constraints is binding throughout Tasks 3–7. A one-verb wall -case stays inline; the test remains individually named and asserted. Reusable builder verbs -or presets are extracted at the second repeated arrangement; named scenario classes appear -only when their stronger promotion rule fires. - -- [ ] **Step 1: Write the failing tests:** - -```csharp -[Test] -public async Task BuildJson_Should_Produce_Document_With_Schema_And_Operation() -{ - var json = new SpecDocumentBuilder() - .WithSchema("Session", s => s.Type("object") - .Property("id", p => p.Type("string"), required: true) - .AdditionalPropertiesFalse()) - .WithOperation("v2.session.get", method: "get", path: "/api/session/{sessionID}", - configure: op => op - .Parameter("sessionID", "path", p => p.Type("string"), required: true) - .Response(200, "application/json", s => s.Ref("Session"))) - .BuildJson(); - - var root = JsonNode.Parse(json)!; - await Assert.That(root["openapi"]!.GetValue()).IsEqualTo("3.1.0"); - await Assert.That(root["components"]!["schemas"]!["Session"]!["required"]!.AsArray()).HasCount(1); - await Assert.That(root["paths"]!["/api/session/{sessionID}"]!["get"]!["operationId"]! - .GetValue()).IsEqualTo("v2.session.get"); -} - -[Test] -public async Task Build_Should_Write_Spec_To_Mock_FileSystem() -{ - var context = SpecScenario.Define(_ => { }).Build(); - - await Assert.That(context.FileSystem.File.Exists(context.SpecPath)).IsTrue(); -} - -[Test] -public async Task FromRawJson_Should_Write_The_Subject_Payload_Unchanged() -{ - var context = SpecScenario.FromRawJson("{ not json").Build(); - - await Assert.That(context.FileSystem.File.ReadAllText(context.SpecPath)) - .IsEqualTo("{ not json"); -} - -[Test] -public async Task Load_Should_Throw_With_Known_Names_When_Fixture_Missing() -{ - var loader = new FixtureLoader(); - - var ex = await Assert.That(() => loader.Load("no-such-fixture")) - .Throws(); - await Assert.That(ex!.Message).Contains("no-such-fixture"); -} -``` - -- [ ] **Step 2: Run to verify they fail** (CS0246 for the new types). -- [ ] **Step 3: Implement** the six Support types. Builders compose `JsonObject` trees - internally (never string concatenation); coding-style applies (sealed concrete helpers, - no tuples, no concrete-collection parameters on non-private members). The private - implementations behind `SpecScenario.Define`/`FromRawJson` stay inside the scenario - mechanism rather than creating one top-level type/file per test variation. -- [ ] **Step 4: Run to verify pass.** -- [ ] **Step 5: Full gate.** -- [ ] **Step 6: Commit** — `test(tools): centralized spec scenarios, builders and fixtures` - ---- - -### Task 3: Reader gate — `SpecReader`, errors, exception translation - -**Files:** -- Create: `tools/OpenCode.Sdk.Tools/Generator/Ingestion/Models/IngestionError.cs`, - `Models/IngestionException.cs` -- Create: `tools/OpenCode.Sdk.Tools/Generator/Ingestion/IngestionErrorCollector.cs` (internal) -- Create: `tools/OpenCode.Sdk.Tools/Generator/Ingestion/SpecReader.cs` (internal sealed) -- Create: `tools/OpenCode.Sdk.Tools/Generator/Ingestion/LoadedSpec.cs` (internal record) -- Create: `tests/OpenCode.Sdk.Tools.Tests/Fixtures/boolean-property-schema.json` -- Test: `tests/OpenCode.Sdk.Tools.Tests/Generator/Ingestion/SpecReaderTests.cs` - -**Interfaces:** -- Consumes: `IFileSystem` (Task 1 DI), `SpecScenario` and the domain builders (Task 2). -- Produces: - - `public sealed record IngestionError(string Location, string Problem);` - - `public sealed class IngestionException : Exception` — - `IReadOnlyList Errors` + the three standard constructors (empty - `Errors`); message = `Ingestion failed with {n} error(s):` + one line per error. - - `internal sealed class IngestionErrorCollector` — `void Add(string location, string problem)`, - `bool HasErrors`, `void ThrowIfAny()`. - - `internal sealed record LoadedSpec(OpenApiDocument Document, JsonNode Raw);` - - `internal sealed class SpecReader(IFileSystem fileSystem)` — - `Task LoadAsync(string specPath, IngestionErrorCollector errors, CancellationToken ct)`: - missing file → error; opens the stream through `IFileSystem`, calls - `OpenApiDocument.LoadAsync(stream, "json", settings)` with - `OpenApiReaderSettings { LeaveStreamOpen = true }` inside try/catch — **any** reader - exception becomes `document: the reader failed — {type}: {message}` (the boolean-schema - NRE class); rewinds and parses the same stream into `JsonNode` (the raw side for hashes - and sibling scans); every `result.Diagnostic` error becomes an `IngestionError` - (`Location` = the diagnostic pointer when non-empty, else `document`; the message text - carries the position for this reader); `SpecificationVersion != OpenApi3_1` → error. - On any error: `ThrowIfAny` before returning. - -- [ ] **Step 1: Write the failing tests** (each red case is a builder/scenario expression — - no inline JSON): - -```csharp -public sealed class SpecReaderTests -{ - private static async Task LoadExpectingRefusal(SpecScenario scenario) - { - var context = scenario.Build(); - var reader = new SpecReader(context.FileSystem); - var errors = new IngestionErrorCollector(); - var ex = await Assert.That(() => reader.LoadAsync(context.SpecPath, errors, CancellationToken.None)) - .Throws(); - return ex!; - } - - [Test] - public async Task LoadAsync_Should_Return_Document_And_Raw_For_Valid_31_Document() - { - var context = SpecScenario.Define(_ => { }).Build(); - var reader = new SpecReader(context.FileSystem); - - var loaded = await reader.LoadAsync(context.SpecPath, new IngestionErrorCollector(), CancellationToken.None); - - await Assert.That(loaded.Document.Paths).IsNotNull(); - await Assert.That(loaded.Raw["openapi"]!.GetValue()).IsEqualTo("3.1.0"); - } - - [Test] - public async Task LoadAsync_Should_Refuse_When_Version_Is_Not_31() - { - var ex = await LoadExpectingRefusal( - SpecScenario.Define(spec => spec.WithOpenApiVersion("3.2.0"))); - await Assert.That(ex.Message).Contains("3.2"); - } - - [Test] - public async Task LoadAsync_Should_Refuse_When_Spec_File_Is_Missing() - { - var reader = new SpecReader(new MockFileSystem()); - var ex = await Assert.That(() => reader.LoadAsync("spec/openapi.json", new IngestionErrorCollector(), CancellationToken.None)) - .Throws(); - await Assert.That(ex!.Message).Contains("spec/openapi.json"); - } - - [Test] - public async Task LoadAsync_Should_Translate_Reader_Crash_When_Schema_Is_Boolean() - { - var ex = await LoadExpectingRefusal(SpecScenario.Define(spec => - spec.WithRawSchema("Bad", "boolean-property-schema.json"))); - await Assert.That(ex.Message).Contains("reader failed"); - } - - [Test] - public async Task LoadAsync_Should_Promote_Reader_Diagnostics_To_Errors() - { - var ex = await LoadExpectingRefusal(SpecScenario.Define(spec => - spec.WithOperation("v2.test.get", configure: operation => - operation.Raw("madeUpKey", "{}")))); - await Assert.That(ex.Message).Contains("madeUpKey"); - } - - [Test] - public async Task LoadAsync_Should_Refuse_When_Json_Is_Malformed() - { - var ex = await LoadExpectingRefusal(SpecScenario.FromRawJson("{ not json")); - await Assert.That(ex.Message).Contains("reader failed"); - } -} -``` - - These are deliberately inline one-off variations. The boolean-schema payload is an - embedded fixture because it is structural wire data; malformed JSON uses - `FromRawJson` because the literal itself is the subject. No scenario class/file is - created for version, unknown-key, or malformed-input variants. -- [ ] **Step 2: Run to verify they fail** (CS0246). -- [ ] **Step 3: Implement** per the Produces block. Keep `SpecReader` small — it is the - reader *gate*, not the wall. -- [ ] **Step 4: Run to verify pass.** -- [ ] **Step 5: Full gate.** -- [ ] **Step 6: Commit** — `feat(tools): spec reader gate with version, diagnostic and crash walls` - ---- - -### Task 4: Schema wall + core schema projection (primitives, enum, array, ref, unrestricted, promotion) - -**Files:** -- Create: `Generator/Ingestion/Models/SchemaNode.cs`, `Models/PrimitiveNode.cs`, - `Models/PrimitiveKind.cs`, `Models/EnumNode.cs`, `Models/ArrayNode.cs`, `Models/RefNode.cs`, - `Models/UnrestrictedNode.cs` -- Create: `Generator/Ingestion/Walls/SchemaWallPolicy.cs` (internal sealed) -- Create: `Generator/Ingestion/Projection/SchemaProjector.cs` (internal sealed), - `Projection/GraphKeyBuilder.cs` (internal sealed), `Projection/ProjectionState.cs` (internal) -- Create: `tests/OpenCode.Sdk.Tools.Tests/Support/SchemaProjectionTestHost.cs`, - `Support/SchemaProjectionResult.cs` -- Test: `tests/OpenCode.Sdk.Tools.Tests/Generator/Ingestion/Walls/SchemaWallPolicyTests.cs`, - `Generator/Ingestion/SchemaProjectorTests.cs`, `Generator/Ingestion/GraphKeyBuilderTests.cs` - -**Interfaces:** -- Consumes: `SpecReader`/`LoadedSpec` (Task 3), builders (Task 2). -- Produces: - - `public abstract record SchemaNode { string? Description; string? Format; abstract IEnumerable Children; }` - - `PrimitiveNode { required PrimitiveKind Kind }` (`String|Number|Integer|Boolean`), - `EnumNode { required IReadOnlyList Values }`, - `ArrayNode { required SchemaNode Item }`, `RefNode { required string Target }`, - `UnrestrictedNode` (no members — the any-value node). - - `internal sealed class SchemaWallPolicy` — - `void Check(OpenApiSchema schema, string location, IngestionErrorCollector errors)`: - the §4.1 schema table rendered as code. Refuses populated `AllOf` (outside union - analysis — Task 5 consumes it first), multi-flag `Type`, `Discriminator`, `Not`, - `If`/`Then`/`Else`, `DependentSchemas`/`DependentRequired`, `PropertyNames`, `Contains`, - `UnevaluatedProperties == false`, `$defs`/dynamic members, `Title`, `Default`, - `Examples`/`Example`, `ReadOnly`/`WriteOnly`, `Xml`, `ExternalDocs`, - `MinLength`/`MaxLength`, `MultipleOf`, `MinProperties`/`MaxProperties`, `UniqueItems`; - known-ignored: `pattern`, `minimum`, `maximum`, `exclusiveMinimum`, `minItems`, - `maxItems`; `UnrecognizedKeywords` non-empty → error unless the admitted site - (`prefixItems` at `Config/properties/plugin/items/anyOf/1` — Task 5 consumes it); - schema-level `Extensions` → error. Bookkeeping members (`Metadata`) exempt. - - `internal sealed class GraphKeyBuilder` — `string Root(string wireNameOrOpId)`, - `string Append(string parentPointer, string segment)` (escapes `~`→`~0`, `/`→`~1`). - - `internal sealed class SchemaProjector(SchemaWallPolicy wall, GraphKeyBuilder keys)` — - `SchemaNode? Project(IOpenApiSchema schema, string root, string pointer, ProjectionState state)` - where `ProjectionState` (internal) carries the graph dictionary + error collector + - visited identity set. Reference wrappers → `RefNode` via typed - `OpenApiSchemaReference.Reference.Id` (never proxy-walked; unresolved `Target` → - error); `$ref` siblings detected on the **raw side** in Task 8's orchestration (state - carries the raw pointer lookup) — this task records the rule; concrete sibling check - lands with the raw walk in Task 8. Dispatch (this task): unrestricted (no admitted - constraint member populated; annotations allowed) → `UnrestrictedNode`; scalar `Type` - → `PrimitiveNode` (+`Format` recorded); `string`+multi-value `Enum` → `EnumNode`; - `array`+`Items` → `ArrayNode`; array without items → error; promotion: inline - `EnumNode` (this task; `ObjectNode`/`UnionNode` in Task 5) registers under - `{root}#{pointer}` and returns a `RefNode`; key collision → error. - -- [ ] **Step 1: Write the failing tests.** Exemplars in full; every listed case becomes a - real `[Test]` following the same pattern. `SchemaProjectionTestHost` is the reusable - named host for Tasks 4–5: it takes a `SpecScenario`, runs the real reader + projector, - and returns `SchemaProjectionResult` (schemas + optional refusal) — never a tuple. One-off - arrangements stay inline: - -```csharp -[Test] -public async Task Project_Should_Produce_Unrestricted_Node_For_Empty_Schema() -{ - var host = new SchemaProjectionTestHost(); - var scenario = SpecScenario.Define(spec => - spec.WithSchema("ToolResult", schema => schema.Unrestricted())); - - var result = await host.ProjectAsync(scenario); - - await Assert.That(result.Schemas["ToolResult"]).IsTypeOf(); -} - -[Test] -public async Task Project_Should_Refuse_When_Schema_Uses_AllOf() -{ - var host = new SchemaProjectionTestHost(); - var scenario = SpecScenario.Define(spec => - spec.WithSchema("Bad", schema => schema.AllOf(branch => branch.Type("string")))); - - var ex = await host.ProjectExpectingRefusalAsync(scenario); - - await Assert.That(ex.Message).Contains("allOf"); - await Assert.That(ex.Message).Contains("Bad"); -} -``` - - Case inventory (write each in full): primitives ×4 kinds; `Format` recorded; - `Description` recorded; description-only schema → `UnrestrictedNode` (annotations - permitted — the session-13 definition); dotted schema name kept verbatim; multi-value - enum; array with item; promoted inline enum under `/items` (graph key asserted); ref to - existing target; refusals: type array, `discriminator`, `if/then`, `title`, `default`, - `readOnly`, `multipleOf`, `uniqueItems`, unknown raw keyword (via `SchemaBuilder.Raw`), - schema-level `x-*`, ref target missing, array without items, batched multi-error - (two bad schemas both named); known-ignored validation keywords parse clean; - `GraphKeyBuilder` escaping (`a/b` → `a~1b`, `a~b` → `a~0b`). The one-verb variants - use inline scenarios; no per-keyword scenario files are created. -- [ ] **Step 2: Run to verify they fail.** -- [ ] **Step 3: Implement** per Produces. `SchemaWallPolicy` is a table-driven check, not a - method chain; `SchemaProjector` dispatch stays a thin switch delegating to small - factory-per-kind privates only for *narrative* — anything with policy weight is its own - collaborator (coding-style §1 red flags govern splits from the start). -- [ ] **Step 4: Run to verify pass.** — [ ] **Step 5: Full gate.** -- [ ] **Step 6: Commit** — `feat(tools): schema wall policy and core schema projection` - ---- - -### Task 5: Object family, literals, unions, special numbers, tuples, content-encoded strings - -**Files:** -- Create: `Models/ObjectNode.cs`, `Models/SpecProperty.cs`, `Models/AdditionalPropertiesKind.cs`, - `Models/DictionaryNode.cs`, `Models/FreeFormObjectNode.cs`, `Models/LiteralNode.cs`, - `Models/LiteralKind.cs`, `Models/LiteralDialect.cs`, `Models/LiteralMarker.cs`, - `Models/UnionNode.cs`, `Models/UnionKeyword.cs`, `Models/UnionClassification.cs`, - `Models/NullableNode.cs`, `Models/SpecialNumberNode.cs`, `Models/TupleNode.cs`, - `Models/JsonStringNode.cs`, `Models/ErrorStyle.cs` -- Create: `Projection/ObjectSchemaProjector.cs`, `Projection/UnionNormalizer.cs`, - `Projection/LiteralClassifier.cs`, `Projection/ErrorStyleClassifier.cs`, - `Projection/PrefixItemsAdapter.cs` (all internal sealed) -- Modify: `Projection/SchemaProjector.cs`, `Projection/ProjectionState.cs`, - `Projection/SchemaShapeClassifier.cs`, `Projection/CoreSchemaShape.cs`, - `Projection/GraphKeyBuilder.cs` -- Create: `tests/OpenCode.Sdk.Tools.Tests/Fixtures/special-number.json`, - `Fixtures/config-plugin-tuple.json` -- Test: `tests/OpenCode.Sdk.Tools.Tests/Generator/Ingestion/SchemaProjectorObjectTests.cs`, - `Generator/Ingestion/UnionNormalizerTests.cs`, `Generator/Ingestion/LiteralClassifierTests.cs`, - `Generator/Ingestion/ErrorStyleClassifierTests.cs`, - `Generator/Ingestion/PrefixItemsAdapterTests.cs` - -**Interfaces:** -- Consumes: Task 4's fail-closed dispatch, promotion, wall, and raw-pointer state. -- Produces: - - `SpecProperty { required string Name; required SchemaNode Schema; required bool IsRequired; }` - - `ObjectNode { required IReadOnlyList Properties; required AdditionalPropertiesKind AdditionalProperties; SchemaNode? AdditionalPropertiesSchema; required IReadOnlyList LiteralMarkers; required ErrorStyle ErrorStyle; }`. - **Hybrid objects** (properties **and** an additionalProperties schema — 6 pin sites) - populate both `Properties` and `AdditionalPropertiesSchema` with - `AdditionalProperties == Schema`. - - `AdditionalPropertiesKind { Open, Forbidden, Schema }`; - `DictionaryNode { required SchemaNode Value; }`; `FreeFormObjectNode`. - - Object dispatch: `properties` present → `ObjectNode` (property names are opaque wire - data — a property named `type`/`properties`/`required` never keyword-matches; property - order is document order; `required` names must match properties, else error); - no properties + AP schema → `DictionaryNode`; no properties + single - `patternProperties` entry → `DictionaryNode` (pattern dropped as validation-only; - multiple patterns or combination with properties/AP → error); bare `object` → - `FreeFormObjectNode`; `additionalProperties: true` explicit → treated as absent - (`AdditionalPropertiesAllowed` default-true semantics; the tripwire snapshot pins the - default); inline `ObjectNode` becomes promotion-eligible. - - `LiteralNode { required LiteralKind Kind; required string Value; required LiteralDialect Dialect; }` - (`Kind`: `String|Boolean`; `Dialect`: `SingleValueEnum|Const`); `const` admitted **only - on string-typed schemas** (the DOM's `Const` is a string and cannot preserve other - literal kinds — session 13); boolean single-value enum accepted; multi-value boolean - enum refuses; `enum`+`const` together refuses. - - `LiteralMarker { required string PropertyName; required LiteralKind Kind; required string Value; }` — - computed at the single `ObjectNode` construction site from required properties whose - schema is a `LiteralNode`, in property order (mechanical — never a name list). - - `ErrorStyle { None, EffectTag, NameData }` via `ErrorStyleClassifier`: required `_tag` - literal → `EffectTag`; required `name` literal + required `data` → `NameData`. - - `GraphKeyBuilder` gains - `string UnionBranch(string parentPointer, string keyword, int index, LiteralMarker? marker)`; - marked branches use `{property}={value}` and unmarked branches use the ordinal index. - - `UnionNode { required IReadOnlyList Branches; required UnionKeyword Keyword; required UnionClassification Classification; }` - (`Keyword`: `AnyOf|OneOf`; `Classification`: `Marked` — every object branch carries ≥1 - literal marker — else `Structural`; the 5 heterogeneous pin sites are `Structural`). - - `UnionNormalizer` — locked order: (1) special-number check (one `number` branch + only - NaN/Infinity string-literal branches, subset rule absorbs the combined literal) → - `SpecialNumberNode`; (2) duplicate-`$ref` dedup by target id; (3) null-branch extraction - → `NullableNode { required SchemaNode Inner }`; (4) one branch left → that node plain; - zero → error; (5) otherwise `UnionNode` with keyword + classification; - `anyOf`+`oneOf` together → error. - - `PrefixItemsAdapter` — reads the admitted raw `prefixItems` array; each item parses via - `OpenApiModelFactory.Parse(itemJson, OpenApiSpecVersion.OpenApi3_1, hostDocument, out var diagnostic, "json")`; - diagnostic errors → located errors; result → - `TupleNode { required IReadOnlyList Items }`; `items`+`prefixItems` together - → error; `minItems`/`maxItems` must equal arity → else error. Non-admitted - `prefixItems` sites stay wall errors (Task 4). - - `type: "string"` + `ContentSchema` → - `JsonStringNode { required SchemaNode Inner }`; `ContentMediaType` must be - `application/json` → else error. - -- [ ] **Step 1: Write the failing object-family tests.** Exemplar plus every inventory case: - -```csharp -[Test] -public async Task Project_Should_Keep_Property_Schema_And_Dictionary_Value_For_Hybrid_Objects() -{ - var host = new SchemaProjectionTestHost(); - var scenario = SpecScenario.Define(spec => spec.WithSchema("ProviderOptions", schema => - schema.Type("object") - .Property("timeout", property => property.Type("number")) - .AdditionalProperties(value => value.Type("string")))); - - var result = await host.ProjectAsync(scenario); - - var node = (ObjectNode)result.Schemas["ProviderOptions"]; - await Assert.That(node.AdditionalProperties).IsEqualTo(AdditionalPropertiesKind.Schema); - await Assert.That(node.AdditionalPropertiesSchema).IsNotNull(); - await Assert.That(node.Properties.Single(p => p.Name == "timeout").IsRequired).IsFalse(); -} -``` - - Object inventory: required set; property order = document order; promoted inline object - property (graph key asserted); keyword-named properties as plain data (the `GlobalEvent` - trap — properties literally named `type`/`properties`/`required`); dictionary via AP - schema; dictionary via single patternProperties; patternProperties multi-pattern refuse; - patternProperties+properties refuse; free-form; empty-properties object; `required` - naming a missing property → located error; `additionalProperties: true` → `Open`. - -- [ ] **Step 2: Write the failing literal/union/normalization tests.** Exemplar: - -```csharp -[Test] -public async Task Project_Should_Classify_Structural_Union_When_Branches_Carry_No_Markers() -{ - var host = new SchemaProjectionTestHost(); - var scenario = SpecScenario.Define(spec => spec.WithSchema("Formatter", schema => - schema.AnyOf( - branch => branch.Type("boolean"), - branch => branch.Type("object") - .AdditionalProperties(value => value.Type("string"))))); - - var result = await host.ProjectAsync(scenario); - - var union = (UnionNode)result.Schemas["Formatter"]; - await Assert.That(union.Classification).IsEqualTo(UnionClassification.Structural); -} -``` - - Inventory (each in full): single-value enum literal (string + boolean); `const` on string - → `Const` dialect; `const` on non-string → refuse; marker collection on objects (required - literal only; optional literal and required non-literal excluded); marked `anyOf` union of - refs; the one `oneOf`; nested union via promoted branches (marker-keyed graph keys - asserted: `Evt#/anyOf/type=created`); unmarked-branch index fallback key - (`Formatter#/anyOf/0`); dedup `[A,B,B]` → `[A,B]`; dedup-to-single `[A,A]` → plain - `RefNode`; nullable extraction; nullable+dedup combined; special number (verbatim - `Workspace.timeUsed` five-branch shape incl. combined literal, via embedded fixture - `special-number.json` + `WithRawSchema`); special-number near-miss (extra branch) → - ordinary union; `boolean|string-enum` parameter-style union stays union; tuple via - `config-plugin-tuple.json`; tuple arity conflict refuse; `items`+`prefixItems` refuse; - content-encoded string (ref inner); wrong `contentMediaType` refuse; - `EffectTag`/`NameData`/`None` classification; multi-value boolean enum refuse. - -- [ ] **Step 3: Run the combined focused tests to verify RED.** -- [ ] **Step 4: Implement in dependency order:** literal classification → object-family - projection/markers/error style → union normalization/promotion → tuple/content adapters. - Extend `SchemaShapeClassifier` and `ProjectionState` fail-closed; keep policy in the named - collaborators above rather than accumulating private methods in `SchemaProjector`. -- [ ] **Step 5: Run the combined focused tests to verify GREEN.** -- [ ] **Step 6: Full gate.** -- [ ] **Step 7: Commit** — `feat(tools): object, literal and union schema projection` - ---- - -### Task 7: Operation surface — per-host walls + operation projection - -**Files:** -- Create: `Models/SpecOperation.cs`, `Models/SpecSurface.cs`, `Models/SpecParameter.cs`, - `Models/SpecParameterLocation.cs`, `Models/SpecRequestBody.cs`, `Models/SpecResponse.cs`, - `Models/SpecEnvelopeShape.cs`, `Models/SpecMediaType.cs` -- Create: `Walls/DocumentWallPolicy.cs`, `Walls/PathItemWallPolicy.cs`, - `Walls/OperationWallPolicy.cs`, `Walls/ParameterWallPolicy.cs`, - `Walls/RequestBodyWallPolicy.cs`, `Walls/ResponseWallPolicy.cs`, - `Walls/MediaTypeWallPolicy.cs` (internal sealed, one file each) -- Create: `Projection/OperationProjector.cs`, `Projection/EnvelopeClassifier.cs` (internal sealed) -- Create: `tests/OpenCode.Sdk.Tools.Tests/Support/OperationProjectionTestHost.cs`, - `Support/OperationProjectionResult.cs`, - `Fixtures/path-level-parameters.json` -- Test: `tests/.../HostWallPolicyTests.cs`, `OperationProjectorTests.cs`, - `EnvelopeClassifierTests.cs`, `SpecMediaTypeTests.cs` - -**Interfaces:** -- Consumes: Tasks 3–5 (reader, schema projection under `op:{operationId}` roots). -- Produces: - - `SpecMediaType { required string Raw; required string Stripped; required bool IsJson; required bool IsEventStream; }` - with `static SpecMediaType Create(string raw)` — parameter-stripped, lowercased, - `+json` suffix detection; malformed (no `/`) → `ArgumentException` (caught into a - located error by callers). - - `SpecParameter { required string Name; required SpecParameterLocation Location; required SchemaNode Schema; required bool IsRequired; required bool IsDeepObject; }` - (`Location`: `Path|Query` — `header` is not admitted); - `SpecRequestBody { required SpecMediaType ContentType; required SchemaNode Schema; required bool IsRequired; }`; - `SpecResponse { required int StatusCode; string? Description; SpecMediaType? ContentType; SchemaNode? Schema; required SpecEnvelopeShape EnvelopeShape; required bool IsSse; string? EffectStreamJson; }`; - `SpecEnvelopeShape { None, Bare, Data, DataLocation, CursorData, DataHasMore }`; - `SpecSurface { Modern, Legacy }`. - - `SpecOperation { required string OperationId; required SpecSurface Surface; required IReadOnlyList Segments; required string Method; required string Path; required bool HasWildcardPath; required bool IsWebSocket; required bool IsSse; required bool IsDeprecated; string? Summary; string? Description; required IReadOnlyList Parameters; SpecRequestBody? RequestBody; required IReadOnlyList Responses; required string RawContentHash; }` - — `RawContentHash` empty-string until Task 8 wires the hasher (single construction - site). - - Wall policies render the §4.1 per-host tables: document (refuses `webhooks`, `servers`, - `jsonSchemaDialect`, non-`schemas` components; `info`/`security`/`tags` ignored; - bookkeeping `BaseUri`/`Self`/`Workspace`/`Metadata` exempt), path item (five methods - only; refuses path-level `parameters`, `$ref`, `servers`), operation (admits - `operationId` required + `summary`/`description`/`deprecated`/`parameters`/ - `requestBody`/`responses`; ignores `tags`/`security`; extensions: `x-codeSamples` - ignored, `x-websocket` → flag, others refuse; refuses `callbacks`/`servers`), parameter - (`path`/`query` only; `style`+`explode` only as `deepObject`+`true` pair; refuses - `Content`, `examples`; duplicate (name,in) → error), request body (exactly one media - entry), response (integer status keys only; refuses `headers`/`links`; 0–1 media), - media (schema only; `x-effect-stream` on `text/event-stream` only; refuses - `ItemSchema`/`ItemEncoding`/`PrefixEncoding`/`Encoding`/`examples`). - - `OperationProjector` — surface split on the `v2.` operationId prefix (never the path); - segments; wildcard flag (`/*` suffix only; mid-path `*` refuses); duplicate operationId - → error; path-template cross-check both directions (every `{token}` declared, every - declared path parameter present); parameters/requestBody/responses projected under - `op:{operationId}` roots; responses status-ascending; SSE flag from media; - `EffectStreamJson` = the `x-effect-stream` `JsonNodeExtension` serialized verbatim. - - `EnvelopeClassifier` — JSON media only; ref-chase with identity set (cycle → error); - exact property-name-set match `{data}`→`Data`, `{data,location}`→`DataLocation`, - `{cursor,data}`→`CursorData`, `{data,hasMore}`→`DataHasMore`; else `Bare`; no content → - `None`; non-JSON → `Bare`. - -- [ ] **Step 1: Write the failing tests.** Exemplar: - -```csharp -[Test] -public async Task Project_Should_Refuse_Path_Level_Parameters() -{ - var host = new OperationProjectionTestHost(); - var scenario = SpecScenario.Define(spec => spec.WithRawTopLevelFromFixture( - "paths", "path-level-parameters.json")); - - var ex = await host.ProjectExpectingRefusalAsync(scenario); - - await Assert.That(ex.Message).Contains("path-level parameters"); -} -``` - - Inventory (each in full): modern/legacy surface split + segments; deep segments; - wildcard flag; non-trailing wildcard refuse; websocket flag; deprecated/summary/ - description recorded; `tags`/`security` ignored clean; duplicate operationId refuse; - unknown method key refuse; document-level `webhooks` refuse; response `headers` refuse; - media `itemSchema` refuse (raw-injected — under 3.1 the diagnostic wall catches it; - the test asserts the *located* refusal either way); parameter `in: header` refuse; - parameter `style: form` refuse; content-based parameter refuse; deepObject+true - admitted (`IsDeepObject`); bracketed parameter names verbatim; boolean-ish - `anyOf [boolean, enum]` parameter → `UnionNode`; requestBody multi-media refuse; - path-token cross-check both directions; envelope shapes ×4 via named-ref and inline - fixtures + bare + none; envelope ref-cycle refuse; SSE detection + opaque - `x-effect-stream` carried verbatim (round-trip compare); `x-effect-stream` on JSON media - refuse; `SpecMediaType` parameter stripping / `+json` / case; responses sorted; status - `"default"` refuse. One-off host-wall variations stay inline; fixtures carry structural - raw payloads and no per-case scenario class/file is created. -- [ ] **Step 2: verify fail.** — [ ] **Step 3: Implement.** — [ ] **Step 4: verify pass.** -- [ ] **Step 5: Full gate.** — [ ] **Step 6: Commit** — - `feat(tools): per-host walls and operation projection` - ---- - -### Task 8: Orchestration seam — `SpecIngestion`, raw walk, hashes, `$ref` siblings - -**Files:** -- Create: `Generator/Ingestion/Abstractions/ISpecIngestion.cs` -- Create: `Generator/Ingestion/SpecIngestion.cs` (sealed, the seam implementation) -- Create: `Generator/Ingestion/Models/SpecDocument.cs` -- Create: `Generator/Ingestion/Projection/RawContentHasher.cs`, - `Projection/RawSiblingScanner.cs` (internal sealed) -- Modify: `tools/OpenCode.Sdk.Tools/ToolApp.cs` (register the seam + collaborators) -- Test: `tests/.../SpecIngestionTests.cs`, `RawContentHasherTests.cs`, - `RawSiblingScannerTests.cs` - -**Interfaces:** -- Consumes: everything above. -- Produces: - - `public interface ISpecIngestion { Task IngestAsync(string specPath, CancellationToken cancellationToken); }` - - `SpecDocument { required string OpenApiVersion; required IReadOnlyList Operations; required IReadOnlyDictionary Schemas; required IReadOnlyDictionary SchemaContentHashes; }` - (both dictionaries ordinal-sorted; `OpenApiVersion` read from the raw side — the DOM - does not retain the string). - - `RawContentHasher` — `string Hash(JsonNode subtree)`: canonical JSON (recursively - sorted object keys, no whitespace) → SHA-256 hex. Fills `SpecOperation.RawContentHash` - (the raw operation object) and `SpecDocument.SchemaContentHashes` (every named schema's - raw subtree). - - `RawSiblingScanner` — walks raw schema positions; any object carrying `$ref` plus keys - beyond `description`/`summary` → located error (typed members cannot distinguish a - sibling from the proxied target — session 13). - - `SpecIngestion(IFileSystem fs)` composition: reader → document/pathItem walls → - schema roots (components) → operations → raw sibling scan → dangling-ref sweep over - `SchemaNode.Children` → hashes → `ThrowIfAny` → frozen `SpecDocument`. DI: `ToolApp` - registers `ISpecIngestion` → `SpecIngestion` singleton (collaborators constructed - internally — they are pure and need no substitution seam). - -- [ ] **Step 1: Write the failing tests.** Exemplar: - -```csharp -[Test] -public async Task Hash_Should_Be_Stable_Under_Key_Reordering() -{ - var hasher = new RawContentHasher(); - - var ordered = hasher.Hash(JsonNode.Parse("""{"a":1,"b":[{"x":1,"y":2}]}""")!); - var reordered = hasher.Hash(JsonNode.Parse("""{"b":[{"y":2,"x":1}],"a":1}""")!); - - await Assert.That(ordered).IsEqualTo(reordered); -} -``` - - (These two literals are sanctioned: the literal *is* the subject — canonicalization.) - Inventory: hash differs on value change; array order preserved (reordering array items - changes the hash); `IngestAsync` end-to-end on a builder scenario → operations + - schemas + hashes populated, per-op hash non-empty; `$ref`+`pattern` sibling refuse; - `$ref`+`description` sibling admitted; dangling `RefNode` sweep error; DI resolution test - starts from `ToolApp.CreateServices(...)`, replaces `IFileSystem` with the scenario's - instance after production registration, then resolves - `provider.GetRequiredService()`. Full-ingestion repeat determinism is - asserted once, against the pinned document in Task 10; this task keeps the distinct - canonical-hash unit properties. -- [ ] **Step 2: verify fail.** — [ ] **Step 3: Implement.** — [ ] **Step 4: verify pass.** -- [ ] **Step 5: Full gate.** — [ ] **Step 6: Commit** — - `feat(tools): ingestion seam with raw-content hashes and sibling scan` - ---- - -### Task 9: Library-upgrade tripwires + DOM-boundary guards - -**Files:** -- Test: `tests/.../Tripwires/PrefixItemsTripwireTests.cs`, - `Tripwires/DomMemberInventoryTests.cs`, `Tripwires/SpecIrSnapshotTests.cs`, - `Guards/IngestionBoundaryTests.cs` - -**Interfaces:** -- Consumes: `ISpecIngestion` (Task 8), the pinned spec fixture (Task 2 link), Verify.TUnit. -- Produces: the four §4.1 tripwire/guard suites later slices rely on. - -- [ ] **Step 1: Write the tripwires (they pass immediately — their red day is a library - bump; each carries a comment naming what a failure means):** - - `PrefixItemsTripwireTests` — load the pinned spec; assert - `Config.plugin/items/anyOf[1]` still exposes `prefixItems` via `UnrecognizedKeywords` - (a newer library typing it must fail this loudly). - - `DomMemberInventoryTests` — reflection over every consumed DOM type - (`OpenApiDocument`, `OpenApiPathItem`, `OpenApiOperation`, `OpenApiParameter`, - `OpenApiRequestBody`, `OpenApiResponse`, `OpenApiMediaType`, `OpenApiSchema`): sorted - public-member names plus fresh-instance default values of `bool?`/`bool` members — - compared against a committed baseline string (in a `DomInventoryData` static class — - testing-style §3 home 3). A new/removed member or flipped default fails with the - member named. - - `SpecIrSnapshotTests` — `IngestAsync(spec/openapi.json)` → deterministic multi-line - rendering (operations with surface/method/path/flags/envelope shapes/hash prefixes; - ordinal-sorted schema keys with node kinds) → `Verify()` snapshot. Changes only on a - spec refresh or admit-rule change — both reviewed events. -- [ ] **Step 2: Write the guards:** - - `IngestionBoundaryTests` — - (a) reflection over `SpecDocument`'s transitive public surface: no type from the - `Microsoft.OpenApi` assembly reachable; - (b) source scan: enumerate `tools/OpenCode.Sdk.Tools/**/*.cs` through the real - `RealFileSystem`; any file outside `Generator/Ingestion/` containing - `using Microsoft.OpenApi` fails with the path named. -- [ ] **Step 3: Run — all green against the pin;** verify the snapshot's `.verified.txt` - is committed and stable across two runs. -- [ ] **Step 4: Full gate.** — [ ] **Step 5: Commit** — - `test(tools): library tripwires and ingestion boundary guards` - ---- - -### Task 10: Full pinned-spec landmark smoke - -**Files:** -- Test: `tests/OpenCode.Sdk.Tools.Tests/PinnedSpecSmokeTests.cs` - -**Interfaces:** -- Consumes: `ISpecIngestion` + `RealFileSystem` + the linked pinned spec. -- Produces: the structural gate every future spec refresh runs through. **No count - assertions.** - -- [ ] **Step 1: Write the tests** — the landmark inventory, asserted against SpecIR: - full-document ingest succeeds; both surfaces present; `v2.session.events` `IsSse` + - `EffectStreamJson` non-null; `v2.fs.read` wildcard; `v2.pty.connect` websocket flag; - dotted key `session.status` present; `SessionDurableEvent` = `UnionNode` - (`OneOf`, `Marked`); `Workspace.timeUsed` = `SpecialNumberNode`; - `Config.formatter` = `UnionNode` `Structural`; `MoveSessionError` `ErrorStyle.NameData`; - `effect_HttpApiError_BadRequest` `ErrorStyle.EffectTag`; `v2.session.list` 200 - `CursorData` and 400 branches dedup to `[InvalidCursorError, InvalidRequestError]`; - `v2.session.history` 200 `DataHasMore`; `v2.agent.list` 200 `DataLocation`; a - `None`-envelope response exists; `v2.session.active` data resolves to `DictionaryNode`; - the six unrestricted landmark sites (`AssistantMessage.structured`, - `AgentConfig` AP, `ToolListItem.parameters`, `Workspace.extra/anyOf/0` via its graph - key, `SessionMessageToolStateCompleted.result`, the `tui.control.response` request - root) are `UnrestrictedNode`; `Config.plugin` tuple = `TupleNode` arity 2; repeated - ingest → identical schema-key sequence and hash set. (If a landmark contradicts the - pinned wire, that is evidence — stop and classify; never patch the assertion.) -- [ ] **Step 2: Run — green against the real spec** (the parser-era failure mode is the - RED reference: session 12 records why these landmarks exist). -- [ ] **Step 3: Full gate.** — [ ] **Step 4: Commit** — - `test(tools): full pinned-spec landmark smoke` - ---- - -### Task 11: Docs pass, push, PR - -**Files:** -- Modify: `docs/ROADMAP.md`; `docs/research/00-research-log.md` (only if execution produced - findings beyond sessions 12–14) - -- [ ] **Step 1:** ROADMAP status: Slice 1 landed (ingestion + SpecIR under - `Generator/Ingestion/`, full ToolApp hosting + centralized scenario/builder test - infrastructure live; `generate` remains a fail-loud stub until slice 3); next is Slice 2 - planning (issue #3). -- [ ] **Step 2:** Research-log entry only for new evidence (wall refusals the plan did not - predict, analyzer arbitrations, library surprises). -- [ ] **Step 3:** Final full gate on the branch. -- [ ] **Step 4:** Push `feature/slice-01-ingestion-specir`; PR - `feat(tools): slice 1 — ingestion + SpecIR` (body: what landed; the honest "generation - remains stubbed while full hosting/global options are live" note; deviations with levels; - `Closes #2`). Three CI legs green; maintainer merge. - ---- - -## Handoff to Slice 2 (Binder + curation v0) - -- **`ToolApp.CreateServices` is the only composition path.** Slice 2 extends it with Binder - and curation collaborators; tests replace seams after the production registrations rather - than constructing a second root. Global settings, MEL providers, and the interceptor are - already live. -- **`SpecDocument` is the Binder's sole spec-side input**; the reachable closure walks - `SchemaNode.Children`/`RefNode.Target` keys. Envelope-classified response-root schemas - are already classified per response — the Binder subtracts them from model emission - (generator spec §4.2). -- **Fingerprint source:** `SpecOperation.RawContentHash` + `SpecDocument.SchemaContentHashes` - — the Binder composes §9's two kinds; it never re-reads the spec. -- **Recorded facts consumed downstream:** `SpecEnvelopeShape`, `ErrorStyle`, - `LiteralMarker`s, `UnionClassification` (structural unions have no tag-dispatch - converter — emission shape is a §15 open item), `IsSse`+`EffectStreamJson`, - `IsWebSocket`, `SpecMediaType.Stripped`, `Description`/`Format`, `IsDeprecated`. -- **Not produced here:** curation, coverage checks, closure computation, fingerprint - persistence, EmitPlan, emitters, Writer, `generate` wiring (still the fail-loud stub). diff --git a/docs/superpowers/specs/2026-08-09-generator-architecture.md b/docs/superpowers/specs/2026-08-09-generator-architecture.md index 1bf44a51..c3456a9d 100644 --- a/docs/superpowers/specs/2026-08-09-generator-architecture.md +++ b/docs/superpowers/specs/2026-08-09-generator-architecture.md @@ -2,6 +2,10 @@ Date: 2026-08-11 +> **Status: vision / reference — not sealed.** Binding decisions live in the ADRs and +> `AGENTS.md`; this document is direction and design rationale, not law. Contradicting it +> is a finding to note, not a deviation-protocol event. + Design specification produced by the generator-architecture brainstorm session (2026-08-09, ROADMAP queue item 1). Every decision below was discussed and sealed individually with the maintainer. This spec feeds a fresh-context grill session next; claims are therefore tied to diff --git a/docs/superpowers/specs/2026-08-09-public-api-design.md b/docs/superpowers/specs/2026-08-09-public-api-design.md index ce99c38a..e443e535 100644 --- a/docs/superpowers/specs/2026-08-09-public-api-design.md +++ b/docs/superpowers/specs/2026-08-09-public-api-design.md @@ -2,6 +2,10 @@ Date: 2026-08-11 +> **Status: vision / reference — not sealed.** Binding decisions live in the ADRs and +> `AGENTS.md`; this document is direction and design rationale, not law. Contradicting it +> is a finding to note, not a deviation-protocol event. + Design specification produced by the public-API brainstorm session (2026-08-08/09, ROADMAP queue item 1). Every decision below was discussed and sealed individually with the maintainer; rationale and evidence are recorded inline. Process sequencing agreed for what diff --git a/docs/superpowers/specs/2026-08-10-testing-architecture-design.md b/docs/superpowers/specs/2026-08-10-testing-architecture-design.md index 08b8cd9c..1d81937e 100644 --- a/docs/superpowers/specs/2026-08-10-testing-architecture-design.md +++ b/docs/superpowers/specs/2026-08-10-testing-architecture-design.md @@ -2,6 +2,10 @@ Date: 2026-08-11 +> **Status: vision / reference — not sealed.** Binding decisions live in the ADRs and +> `AGENTS.md`; this document is direction and design rationale, not law. Contradicting it +> is a finding to note, not a deviation-protocol event. + Design specification produced by the testing-architecture brainstorm session (2026-08-09/10, ROADMAP queue item 1 — its final design step). Every decision below was discussed and sealed individually with the maintainer. This spec owns the ROADMAP Open Questions item "Testing diff --git a/docs/superpowers/specs/2026-08-11-production-walking-skeleton-design.md b/docs/superpowers/specs/2026-08-11-production-walking-skeleton-design.md index 1e42c2b8..64b562f0 100644 --- a/docs/superpowers/specs/2026-08-11-production-walking-skeleton-design.md +++ b/docs/superpowers/specs/2026-08-11-production-walking-skeleton-design.md @@ -2,6 +2,10 @@ Date: 2026-08-11 +> **Status: vision / reference — not sealed.** Binding decisions live in the ADRs and +> `AGENTS.md`; this document is direction and design rationale, not law. Contradicting it +> is a finding to note, not a deviation-protocol event. + ## Decision The SDK build-out will stop sequencing ingestion, binding, emission, transport, and the From b2fc62c557ff8c16ef23a0415800b31f20e650d9 Mon Sep 17 00:00:00 2001 From: "denizirgin@gmail.com" Date: Tue, 11 Aug 2026 20:33:43 +0300 Subject: [PATCH 18/19] fix(tests): portable failed-log-file simulation for non-Windows legs The Windows-only ACL strategy threw PlatformNotSupportedException on the Linux/macOS legs; a file occupying the log directory's path fails initialization identically on every OS. --- .../Infrastructure/Logging/ToolLoggingTests.cs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/OpenCode.Sdk.Tools.Tests/Infrastructure/Logging/ToolLoggingTests.cs b/tests/OpenCode.Sdk.Tools.Tests/Infrastructure/Logging/ToolLoggingTests.cs index 5a2c2bc8..bd4ec054 100644 --- a/tests/OpenCode.Sdk.Tools.Tests/Infrastructure/Logging/ToolLoggingTests.cs +++ b/tests/OpenCode.Sdk.Tools.Tests/Infrastructure/Logging/ToolLoggingTests.cs @@ -2,7 +2,6 @@ using OpenCode.Sdk.Tools.Infrastructure.Logging; using Spectre.Console.Testing; using Testably.Abstractions.Testing; -using Testably.Abstractions.Testing.FileSystem; namespace OpenCode.Sdk.Tools.Tests.Infrastructure.Logging; @@ -71,9 +70,10 @@ public async Task Log_Should_Render_Spectre_Markup_As_Literal_Text() [Test] public async Task CreateLogger_Should_Fail_Loud_When_Configured_Log_File_Cannot_Be_Initialized() { + // A file occupying the log directory's path makes initialization fail identically + // on every OS; the Windows-only ACL simulation is not portable to the Linux/macOS legs. var fileSystem = new MockFileSystem(); - fileSystem.WithAccessControlStrategy( - new DefaultAccessControlStrategy(static (_, _) => false)); + await fileSystem.File.WriteAllTextAsync("logs", string.Empty); var options = new ToolLoggingOptions(); options.Apply(LogLevel.Debug, "logs/tool.log"); @@ -83,6 +83,6 @@ await Assert using var provider = new FileLoggerProvider(fileSystem, options); provider.CreateLogger("OpenCode.Sdk.Tools.Tests"); }) - .Throws(); + .Throws(); } } From 2185438387e340b1d18a00c3de02a05173895edb Mon Sep 17 00:00:00 2001 From: "denizirgin@gmail.com" Date: Tue, 11 Aug 2026 20:34:58 +0300 Subject: [PATCH 19/19] docs(agents): M1 execution handover with course-correction history --- .../handover-prompts/HANDOFF-2026-08-11-2.md | 146 ++++++++++++++++++ 1 file changed, 146 insertions(+) create mode 100644 docs/agents/handover-prompts/HANDOFF-2026-08-11-2.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 new file mode 100644 index 00000000..b8f4ac8f --- /dev/null +++ b/docs/agents/handover-prompts/HANDOFF-2026-08-11-2.md @@ -0,0 +1,146 @@ +# 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.