diff --git a/.editorconfig b/.editorconfig
index 20471311..734a0360 100644
--- a/.editorconfig
+++ b/.editorconfig
@@ -739,12 +739,43 @@ 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.
[tools/OpenCode.Sdk.Tools/Generator/Ingestion/Models/PrimitiveKind.cs]
dotnet_diagnostic.CA1720.severity = none
+# 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/AGENTS.md b/AGENTS.md
index c69a60cb..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
@@ -121,7 +124,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..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, TestableIO, `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-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.
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 2ea50ea6..00000000
--- a/docs/agents/handover-prompts/HANDOFF-2026-08-11.md
+++ /dev/null
@@ -1,102 +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 TestableIO — 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.
-- 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..4887be37 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.
@@ -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 ecddda08..0938506a 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
@@ -1395,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/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/plans/2026-08-10-implementation-slice-map.md b/docs/superpowers/plans/2026-08-10-implementation-slice-map.md
deleted file mode 100644
index 7f4c506a..00000000
--- a/docs/superpowers/plans/2026-08-10-implementation-slice-map.md
+++ /dev/null
@@ -1,326 +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 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`
-§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 TestableIO. 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 99e9b7db..00000000
--- a/docs/superpowers/plans/2026-08-11-slice-01-ingestion-specir.md
+++ /dev/null
@@ -1,981 +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, TestableIO 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), TestableIO trio 22.2.0 +
-`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
-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, the TestableIO trio 22.2.0,
- `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` → `FileSystem` 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.
-
-- [ ] **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).
- 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:**
- - `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.
-- [ ] **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;
- 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
- 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`
-
----
-
-### 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/SpecDocumentBuilderTests.cs`,
- `Support/SpecScenarioTests.cs`, `Support/FixtureLoaderTests.cs`
-
-**Interfaces:**
-- Consumes: `MockFileSystem` (TestingHelpers).
-- 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/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)
-- Create: `tests/OpenCode.Sdk.Tools.Tests/Support/SchemaProjectionTestHost.cs`,
- `Support/SchemaProjectionResult.cs`
-- Test: `tests/.../SchemaWallPolicyTests.cs`, `SchemaProjectorTests.cs`, `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 6 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);
- 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)`.
- - `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 Tasks 5–6) 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,
- 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 — objects, hybrids, dictionaries, free-form
-
-**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`
-
-**Interfaces:**
-- Consumes: Task 4's dispatch + promotion + wall.
-- 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`.
- - `AdditionalPropertiesKind { Open, Forbidden, Schema }`;
- `DictionaryNode { required SchemaNode Value; }`; `FreeFormObjectNode`.
- - Dispatch rules: `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.
-
-- [ ] **Step 1: Write the failing tests** — exemplar plus inventory (each written in full):
-
-```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();
-}
-```
-
- 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`
-
----
-
-### 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`, `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).
- - `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:
-
-```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
- 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`
-
----
-
-### 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–6 (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
- `FileSystem`; 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` + the real `FileSystem` (Wrappers) + 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 65b24261..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
@@ -89,14 +93,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 +122,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 +197,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..e443e535 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,10 @@
# Public API Design — OpenCode.Sdk & OpenCode.Sdk.Extensions
-Date: 2026-08-09
+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
@@ -83,8 +87,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..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
@@ -487,8 +491,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/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..64b562f0
--- /dev/null
+++ b/docs/superpowers/specs/2026-08-11-production-walking-skeleton-design.md
@@ -0,0 +1,631 @@
+# Production walking skeleton: first callable SDK milestone
+
+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
+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`
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
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/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/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/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/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/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
new file mode 100644
index 00000000..5a7dfd1d
--- /dev/null
+++ b/tests/OpenCode.Sdk.Tools.Tests/Generator/Ingestion/GraphKeyBuilderTests.cs
@@ -0,0 +1,53 @@
+using OpenCode.Sdk.Tools.Generator.Ingestion.Models;
+using OpenCode.Sdk.Tools.Generator.Ingestion.Projection;
+
+namespace OpenCode.Sdk.Tools.Tests.Generator.Ingestion;
+
+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");
+ }
+
+ [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/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/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/OperationProjectorTests.cs b/tests/OpenCode.Sdk.Tools.Tests/Generator/Ingestion/OperationProjectorTests.cs
new file mode 100644
index 00000000..e796c029
--- /dev/null
+++ b/tests/OpenCode.Sdk.Tools.Tests/Generator/Ingestion/OperationProjectorTests.cs
@@ -0,0 +1,236 @@
+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[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/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/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/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");
+ }
+}
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
new file mode 100644
index 00000000..537a8dba
--- /dev/null
+++ b/tests/OpenCode.Sdk.Tools.Tests/Generator/Ingestion/SchemaProjectorTests.cs
@@ -0,0 +1,273 @@
+using OpenCode.Sdk.Tools.Generator.Ingestion;
+using OpenCode.Sdk.Tools.Generator.Ingestion.Models;
+using OpenCode.Sdk.Tools.Generator.Ingestion.Projection;
+using OpenCode.Sdk.Tools.Tests.Support;
+
+namespace OpenCode.Sdk.Tools.Tests.Generator.Ingestion;
+
+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_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_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_Batch_Errors_From_Multiple_Schemas()
+ {
+ var host = new SchemaProjectionTestHost();
+ var scenario = SpecScenario.Define(spec => spec
+ .WithSchema("BadArray", schema => schema.Type("array"))
+ .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("BadKeyword");
+ }
+
+ [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(keys);
+ 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);
+ 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/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/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/SpecReaderTests.cs b/tests/OpenCode.Sdk.Tools.Tests/Generator/Ingestion/SpecReaderTests.cs
new file mode 100644
index 00000000..d2b0cce9
--- /dev/null
+++ b/tests/OpenCode.Sdk.Tools.Tests/Generator/Ingestion/SpecReaderTests.cs
@@ -0,0 +1,78 @@
+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.Generator.Ingestion;
+
+public sealed class SpecReaderTests
+{
+ [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 LoadExpectingRefusalAsync(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 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 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 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/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/Generator/Ingestion/Walls/HostWallPolicyTests.cs b/tests/OpenCode.Sdk.Tools.Tests/Generator/Ingestion/Walls/HostWallPolicyTests.cs
new file mode 100644
index 00000000..9a26556c
--- /dev/null
+++ b/tests/OpenCode.Sdk.Tools.Tests/Generator/Ingestion/Walls/HostWallPolicyTests.cs
@@ -0,0 +1,158 @@
+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_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_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");
+ }
+
+ [Test]
+ public async Task Project_Should_Ignore_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 result = await host.ProjectAsync(scenario);
+
+ await Assert.That(result.Operations).Count().IsEqualTo(1);
+ }
+}
diff --git a/tests/OpenCode.Sdk.Tools.Tests/Generator/Ingestion/Walls/SchemaWallPolicyTests.cs b/tests/OpenCode.Sdk.Tools.Tests/Generator/Ingestion/Walls/SchemaWallPolicyTests.cs
new file mode 100644
index 00000000..b4efdcc2
--- /dev/null
+++ b/tests/OpenCode.Sdk.Tools.Tests/Generator/Ingestion/Walls/SchemaWallPolicyTests.cs
@@ -0,0 +1,78 @@
+using OpenCode.Sdk.Tools.Generator.Ingestion.Models;
+using OpenCode.Sdk.Tools.Tests.Support;
+
+namespace OpenCode.Sdk.Tools.Tests.Generator.Ingestion.Walls;
+
+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_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_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();
+ }
+
+ [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/Infrastructure/Logging/ToolLoggingTests.cs b/tests/OpenCode.Sdk.Tools.Tests/Infrastructure/Logging/ToolLoggingTests.cs
new file mode 100644
index 00000000..bd4ec054
--- /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;
+
+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()
+ {
+ // 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();
+ await fileSystem.File.WriteAllTextAsync("logs", string.Empty);
+ 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..f8753874 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,21 @@
-
-
+
+
+
+
-
+
+
+
+
+
+
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/OperationBuilder.cs b/tests/OpenCode.Sdk.Tools.Tests/Support/OperationBuilder.cs
new file mode 100644
index 00000000..9b7d5e91
--- /dev/null
+++ b/tests/OpenCode.Sdk.Tools.Tests/Support/OperationBuilder.cs
@@ -0,0 +1,195 @@
+using System.Text.Json.Nodes;
+
+namespace OpenCode.Sdk.Tools.Tests.Support;
+
+internal sealed class OperationBuilder
+{
+ private readonly JsonObject _operation;
+ private readonly OperationParameterBuilder _parameters;
+ private readonly OperationResponseBuilder _responses;
+
+ public OperationBuilder(string operationId, FixtureLoader fixtureLoader)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(operationId);
+ ArgumentNullException.ThrowIfNull(fixtureLoader);
+
+ var responses = new JsonObject();
+ _operation = new JsonObject
+ {
+ ["operationId"] = operationId,
+ ["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)
+ {
+ _parameters.Add(name, location, configure, required, deepObject);
+ return this;
+ }
+
+ public OperationBuilder ParameterWithStyle(string name, string style, bool explode)
+ {
+ _parameters.AddWithStyle(name, style, explode);
+ return this;
+ }
+
+ public OperationBuilder ContentParameter(string name)
+ {
+ _parameters.AddContent(name);
+ 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 RequestBodyMediaTypes(params string[] mediaTypes)
+ {
+ 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
+ {
+ ["schema"] = new JsonObject
+ {
+ ["type"] = "string",
+ },
+ };
+ }
+
+ _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 ResponseMediaTypes(int status, params string[] mediaTypes)
+ {
+ _responses.AddMediaTypes(status, mediaTypes);
+ return this;
+ }
+
+ public OperationBuilder DefaultResponse()
+ {
+ _responses.AddDefault();
+ return this;
+ }
+
+ public OperationBuilder SseResponse(Action schema, string? effectStreamJson = null)
+ {
+ _responses.AddSse(schema, effectStreamJson);
+ return this;
+ }
+
+ public OperationBuilder SseResponseFromFixture(Action schema, string effectStreamFixtureName)
+ {
+ _responses.AddSseFromFixture(schema, effectStreamFixtureName);
+ return this;
+ }
+
+ public OperationBuilder JsonResponseWithEffectStreamFromFixture(string effectStreamFixtureName)
+ {
+ _responses.AddJsonWithEffectStreamFromFixture(effectStreamFixtureName);
+ 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;
+ }
+
+ 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;
+ }
+
+ 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/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..afc8ea99
--- /dev/null
+++ b/tests/OpenCode.Sdk.Tools.Tests/Support/OperationProjectionTestHost.cs
@@ -0,0 +1,54 @@
+using OpenCode.Sdk.Tools.Generator.Ingestion;
+using OpenCode.Sdk.Tools.Generator.Ingestion.Models;
+using OpenCode.Sdk.Tools.Generator.Ingestion.Projection;
+
+namespace OpenCode.Sdk.Tools.Tests.Support;
+
+internal sealed class OperationProjectionTestHost
+{
+ private readonly GraphKeyBuilder _keys = 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(_keys);
+ var state = new ProjectionState(errors, loaded.Document);
+ 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..9049bb5e
--- /dev/null
+++ b/tests/OpenCode.Sdk.Tools.Tests/Support/OperationResponseBuilder.cs
@@ -0,0 +1,147 @@
+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 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 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/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..0d043683
--- /dev/null
+++ b/tests/OpenCode.Sdk.Tools.Tests/Support/SchemaBuilder.cs
@@ -0,0 +1,273 @@
+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.All(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 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);
+
+ _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 AdditionalPropertiesTrue()
+ {
+ _schema["additionalProperties"] = true;
+ return this;
+ }
+
+ public SchemaBuilder EmptyProperties()
+ {
+ _schema["properties"] = new JsonObject();
+ 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 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);
+
+ _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/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..9d4d0443
--- /dev/null
+++ b/tests/OpenCode.Sdk.Tools.Tests/Support/SchemaProjectionTestHost.cs
@@ -0,0 +1,53 @@
+using OpenCode.Sdk.Tools.Generator.Ingestion;
+using OpenCode.Sdk.Tools.Generator.Ingestion.Models;
+using OpenCode.Sdk.Tools.Generator.Ingestion.Projection;
+
+namespace OpenCode.Sdk.Tools.Tests.Support;
+
+internal sealed class SchemaProjectionTestHost
+{
+ private readonly GraphKeyBuilder _keys = 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(_keys);
+ var state = new ProjectionState(errors, loaded.Document);
+
+ 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/tests/OpenCode.Sdk.Tools.Tests/Support/SpecDocumentBuilder.cs b/tests/OpenCode.Sdk.Tools.Tests/Support/SpecDocumentBuilder.cs
new file mode 100644
index 00000000..ccfb3f13
--- /dev/null
+++ b/tests/OpenCode.Sdk.Tools.Tests/Support/SpecDocumentBuilder.cs
@@ -0,0 +1,117 @@
+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 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)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(operationId);
+ ArgumentException.ThrowIfNullOrWhiteSpace(method);
+ ArgumentException.ThrowIfNullOrWhiteSpace(path);
+
+ var operation = new OperationBuilder(operationId, _fixtureLoader);
+ 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/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/Tests/FixtureLoaderTests.cs b/tests/OpenCode.Sdk.Tools.Tests/Support/Tests/FixtureLoaderTests.cs
new file mode 100644
index 00000000..c0af29b3
--- /dev/null
+++ b/tests/OpenCode.Sdk.Tools.Tests/Support/Tests/FixtureLoaderTests.cs
@@ -0,0 +1,16 @@
+namespace OpenCode.Sdk.Tools.Tests.Support.Tests;
+
+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/Tests/SpecDocumentBuilderTests.cs b/tests/OpenCode.Sdk.Tools.Tests/Support/Tests/SpecDocumentBuilderTests.cs
new file mode 100644
index 00000000..79a4e790
--- /dev/null
+++ b/tests/OpenCode.Sdk.Tools.Tests/Support/Tests/SpecDocumentBuilderTests.cs
@@ -0,0 +1,232 @@
+using System.Text.Json.Nodes;
+
+namespace OpenCode.Sdk.Tools.Tests.Support.Tests;
+
+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/Tests/SpecScenarioTests.cs b/tests/OpenCode.Sdk.Tools.Tests/Support/Tests/SpecScenarioTests.cs
new file mode 100644
index 00000000..a642b969
--- /dev/null
+++ b/tests/OpenCode.Sdk.Tools.Tests/Support/Tests/SpecScenarioTests.cs
@@ -0,0 +1,26 @@
+namespace OpenCode.Sdk.Tools.Tests.Support.Tests;
+
+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);
+ }
+}
diff --git a/tests/OpenCode.Sdk.Tools.Tests/ToolAppTests.cs b/tests/OpenCode.Sdk.Tools.Tests/ToolAppTests.cs
index 4354c13c..a005ea75 100644
--- a/tests/OpenCode.Sdk.Tools.Tests/ToolAppTests.cs
+++ b/tests/OpenCode.Sdk.Tools.Tests/ToolAppTests.cs
@@ -1,9 +1,72 @@
+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..10365295 100644
--- a/tools/OpenCode.Sdk.Tools/Commands/GenerateCommand.cs
+++ b/tools/OpenCode.Sdk.Tools/Commands/GenerateCommand.cs
@@ -1,26 +1,42 @@
+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/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/IngestionErrorCollector.cs b/tools/OpenCode.Sdk.Tools/Generator/Ingestion/IngestionErrorCollector.cs
new file mode 100644
index 00000000..15180e06
--- /dev/null
+++ b/tools/OpenCode.Sdk.Tools/Generator/Ingestion/IngestionErrorCollector.cs
@@ -0,0 +1,28 @@
+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 int Count => _errors.Count;
+
+ 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/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/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/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/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/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/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..4a08ce52
--- /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]);
+ }
+
+ 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/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/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/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/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..36a97943
--- /dev/null
+++ b/tools/OpenCode.Sdk.Tools/Generator/Ingestion/Models/SpecOperation.cs
@@ -0,0 +1,95 @@
+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());
+
+ ///