Repository navigation
Road to 1.0: refactoring roadmap from the quality review #350
Description
A full quality review of the SDK ran ahead of 1.0. It covered every hand-written slice, the generator, the generated output, the tests and the build, and each finding was verified by two model families. This issue tracks the refactoring that came out of it.
What the work is for
The review measured the codebase against seven goals: reusability, no slop, duplication, maintainability, usability, simplicity and explicitness. The result in short:
| Area | Where it stands | The main structural causes |
|---|---|---|
| Tests | Duplication and red-test usability are poor: about 8% duplicated lines, and about 430 assertions that print only false on failure (the guard's exact count; the review's sample had found 253) |
Builders stop at the syntax layer, there is no shared assertion vocabulary, clock seams are partial, and live-test lifecycles are composed by hand |
| Generator and tools | Maintainability is poor: the binders are private-method workflows over shared state, in flat 77- and 89-file folders | An under-resolved emit plan, per-operation behaviour emitted as templates, curation rules without a shared row contract, and refresh state without a model |
| Runtime | Mostly sound for consumers. Poor on semantic duplication: two child-process stacks, three clock abstractions, and failure mapping decided per call site | Stateful workflows with no owning type; platform knowledge with no single owner |
Every epic below closes a named set of verified findings. Each lands together with the mechanical gate that stops its problem from coming back.
How behaviour stays the same
- Generator refactors keep the generated output byte-identical (
generate --verify). - The public API changes only in the freeze pass, as a reviewed diff of
PublicApi.verified.txt. - Tests are strengthened first (order-strict, readable assertions; no real-clock verdicts), and only then are product refactors made.
- No PR refactors a product area together with its tests.
- Bug fixes each come with their own red-then-green test.
- Benchmarks run before and after. The before-set for this round is already archived.
Before the API freeze
- Public surface freeze pass. Every breaking public change, made once ([FRZ] generator: internal routes and operation naming #361, [FRZ] Seal the two exceptions, value-equal SessionParentFilter, idempotent AddOpenCode #365, [FRZ] Streams and cursor traversals carry per-call request options (spine, internal) #366, [FRZ] generator: one shape per concept (bool? queries, one assistant-content union, PtySize) #367, [FRZ-H2b] per-call request options on the stream and traversal doors; the stream-failure Tag on the marker #398):
- Query booleans bind as
bool?instead ofQueryBoolean(whose default value wasTrue) - Streaming members take
OpenCodeRequestOptions, as upstream's client does -
OpenCodeApiExceptionandOpenCodeServerExceptionare sealed;SessionParentFilterbecomes a sealed record; stream failure causes exposeTag -
GetLogAsync→ReadLogAsync; nested list names are pluralised by one generator rule - Generated type names keep their HTTP-method segment;
OpenCodeRoutesbecomes internal - Duplicate unions and the three identical terminal size records each fold into one type
- The canon states the DTO naming rule and the timestamp rule
- Query booleans bind as
- One clock seam for the progress-window tests, so their verdicts stop racing real time ([TST-E2a] test: decide progress-window and ladder verdicts on the test clock #356)
- The error reader moves next to its only callers ([RT-E4.1] The error-body reader lives with the operation adapters (P07) #357)
- Guardrails in report mode: a test clone baseline, plus assertion, clock, size and placement sensors ([G1] tools: Slopwatch feedback at edit time for Claude Code and opencode sessions #352, [TST-E0] test(clones): gate tests/ in the clone gate at 8.5% #354, [TST-E0] test(guards): freeze today's test smells in six report-mode guards #358, [TST-E0] test(guards): count the remaining suppression channels and read comments once #362, [G1] docs: merge on a fresh base, gates on an idle machine, baseline growth, native AOT on Windows #369, [G1] test(perf): BenchmarkDotNet 0.16.0-preview.2, the .NET 11 leg from --runtimes (#344) #372, [G1] docs: a local check before each pull request; CI runs the full gate #374, [TST-E0] test: the tests clone baseline drops 19 fingerprints no clone matches #385, [TST-E0] build: the clone gate ignores using directives and fails on stale baseline entries #410)
During the release candidate
Runtime
- Every door maps cancellation, disposal and malformed input the same way ([RT-E1] Failure boundaries, part 1: caller-token identity, client disposal, stream release (E1a) #383, [RT-E1] Failure boundaries, part 2: hosting doors (E1b) #390)
- One clock (
TimeProvider) everywhere; the parallel clock abstractions are removed ([RT-E2] refactor: the background service runs on an injected TimeProvider #429, [RT-E2] refactor: terminals run on the client's TimeProvider #432, [RT-E2] refactor: the launcher runs on its seams' clock, and shipped src bans the system clock #442) -
Pipelineis split, and the reconnect loop gets an owning type → [RT-E4]Pipelineis split, and the reconnect loop gets an owning type #483 - A byte-level SSE reader, with no UTF-16 round trip (about 3× less allocation) → [RT-E5] A byte-level SSE reader, with no UTF-16 round trip #484
- One child-process core for hosting, with one owner for platform checks → [RT-E6] One child-process core for hosting, with one owner for platform checks. Characterization tests merged first #485
- Owning types for the standalone start and the background-service contender pool → [RT-E7] Owning types for the standalone start and the background-service contender pool #486
- Terminal families share their failure text and use one seam shape → [RT-E8] Terminal families share their failure text and use one seam shape #487
- An owned seam between generated and hand-written code → [RT-E9] An owned seam between generated and hand-written code. Lands paired with GEN-E5 #488
- Docs and canon are reconciled, and the remaining divergences from upstream are recorded → [RT-E10] Docs and canon are reconciled, and the remaining divergences from upstream are recorded #489
- Extensions:
AddOpenCodeis idempotent, dependencies are declared, and registration is tested → [RT-E11] Extensions:AddOpenCodeis idempotent, dependencies are declared, and registration is tested #490
Generator and tools
- Curation rows share one typed contract (reason, duplicates, existence and staleness are checked once) ([GEN-E2] curation: one row contract checks every row; malformed tool JSON refuses by name #394, [GEN-E2] curation: every refusal family pins its full literal messages (P51) #416)
- Binding is decomposed into modules, unions first, with no shared mutable accumulators ([GEN-E3] union binding returns its decisions; nested-union and object-or-array defects refuse or bind correctly (PR 1/3) #425, [GEN-E3] operations bind through returned facet results; one inventory refuses every type-name collision (PR 2/3, #414) #437, [GEN-E3] ref chains, pure policies and schema-name resolvers get one owner each; an error-union refusal no longer cascades (PR 3/3, #436) #456)
- Emission renders a resolved plan and shares one converter skeleton → [GEN-E4] Emission renders a resolved plan and shares one converter skeleton #481
- Fixed per-operation behaviour moves into hand-written bases (a spike first, then a design record) → [GEN-E5] Fixed per-operation behaviour moves into hand-written bases #482
- Generated documentation says something beyond the member name ([GEN-E6] generated summaries: grammatical articles, whole brand spellings, digits on their word #418, [GEN-E6] a guard keeps generated summaries free of grammar defects and caps the placeholder ratio #435, [GEN-E6] every curated description cites the upstream declaration it states, proven against the pinned tree (PR 2a) #445, [GEN-E6] curated interface documentation reaches its implementing members; 26 moved rows return narrowed (PR 2b) #455)
-
refresh-specgets one state model and a two-phase apply (the release tag can no longer go stale) ([GEN-E7] refresh-spec: one state model, a two-phase apply, and a receipt-rendered pin identity #371) - The CLI shell supports cancellation, has one refusal type, and stages its writes ([GEN-E8] tool shell: Ctrl+C cancellation, one refusal path, the formatter on the process seam #389, [GEN-E8] generation writer: an interrupted generate leaves a tree the next run repairs #412)
Tests
- Harness files move once into their homes; shared files stay linked, made explicit with
.props([TST-E7] test: the test harness types live in OpenCode.Sdk.Tests.Harness #373, [TST-E7] test: live-server harness code lives in OpenCode.Sdk.Tests/Harness #379, [TST-E7] test: SDK test helpers and fixtures live in the slice that uses them #402, [TST-E7] test: Tools test helpers, fixtures and mirrored tests live in the slice that uses them #406) - An assertion vocabulary: collapsed and order-blind assertions go to zero ([TST-E1] test: SDK and Extensions assertions print the values that differ #411, [TST-E1] test: Tools assertions print the values that differ #415, [TST-E1] test: the binder tests' assertions print the values that differ #419)
- Tools tests are organised by subject: the pinned plan is bound once per session, with domain presets and one class per binder facet → [TST-E3] Tools tests are organised by subject: the pinned plan is bound once per session, with domain presets and one cl #491
- SDK contract tests become tables with scenario builders (one declared-error table instead of 129 methods) → [TST-E4] SDK contract tests become tables with scenario builders #492
- Terminal family rules are tested once, over both families → [TST-E6] Terminal family rules are tested once, over both families #493
- Live tests get one server lifetime and one per-test cleanup scope → [TST-E5] Live tests get one server lifetime and one per-test cleanup scope #494
- The harness calls production wiring; MSBuild feeds the generated-source compile probe → [TST-E8] The harness calls production wiring; MSBuild feeds the generated-source compile probe #495
- OS-only test subjects run on their OS, and the roster proves it → [TST-E9] OS-only test subjects run on their OS, and the roster proves it #496
- The remaining clock-based tests move to
ManualTimeProvider→ [TST-E2b] The remaining clock-based tests move toManualTimeProvider#497
Sweeps and gates
- One per-unit sweep PR for each group of small findings that no epic covers → [SWEEPS] One per-unit sweep PR for each group of small findings that no epic covers #498
- Every guardrail flips from report mode to a gate: tests clone duplication ≤ 5% (enforced since [TST-E4] test: fourteen more declared-error doors become table rows in five new slices #474), no assertion that prints only
false, test files ≤ 600 lines → [G1-GATES] Every guardrail flips from report mode to a gate: tests clone duplication ≤ 5% #499
Progress shows in the PRs: each PR title starts with its epic.
Each unfinished row links its epic issue (sub-issues of this issue); the finding issues each epic closes are that epic's sub-issues.
Activity
Pipelineis split, and the reconnect loop gets an owning type #483AddOpenCodeis idempotent, dependencies are declared, and registration is tested #490ManualTimeProvider#497