feat(scaffold): openapi:roundtrip drift gate (#164)#170
Merged
Conversation
Adds bin/altair openapi:roundtrip — the CI gate that exercises the full OpenAPI → Altair YAML → OpenAPI chain in memory and refuses to let an emitter / parser change silently degrade the round-trip. Mirrors spec:emit-sdk --check: human or JSON report; exit 1 in --check mode on drift. What the gate compares - Operation set per (method, path). - Summary text per operation. - x-altair-domain / x-altair-persistence / x-altair-queue blocks (the extensions defined in #163). - Response status set, restricted to statuses that carry an application/json schema in the source. What the gate intentionally ignores (documented in docs/openapi/roundtrip.md) - Key order, empty optional arrays, info block, doc-level tags — trivial drift that says nothing about semantic loss. - components/schemas — refs resolve to inlined types in the spec and re-emit as inlined objects; schema-level comparison lands when the importer learns to preserve components. - Description-only responses (204, 404, …) — Altair's output: block can't represent an empty body. - Enriched extensions — a source without x-altair-domain that gets a synthesised one back is the importer working as intended, not a regression. Drift only fires on LOSS, not on enrichment. JSON receipt { "clean": true|false, "input": "...", "operations_compared": N, "differences": [ { "kind": ..., "pointer": "#/paths/...", "expected": ..., "actual": ..., "message": ... } ], "error": null|string } kind enum: missing_operation, extra_operation, summary_drift, extension_drift, status_drift. Receipt is byte-stable for the same input. Implementation - Altair\Scaffold\Cli\OpenApiRoundtripRunner — in-memory orchestrator (no temp dirs). Parses source → emits Altair specs (#161) → re-parses each → re-emits OpenAPI fragment → merges back → projects both sides into the comparison view → diffs. - Altair\Scaffold\Cli\OpenApiRoundtripCommand — thin CLI shell. - Altair\Scaffold\Cli\{OpenApiRoundtripOptions, RoundtripReceipt, RoundtripDifference} — value objects. Tests - 9 new tests across happy-path (1 op), schemaless-status tolerance, schema-bearing status preservation, extension preservation, malformed-document handling, missing-document handling, JSON- receipt byte stability, receipt structure, and the kind enum constants. Verified end-to-end against benchmarks/tokens-to-ship/fixtures/posts.openapi.yaml: 5 operations round-trip clean. Closes #164. Closes epic #160.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #164. Closes epic #160.
Summary
Adds
bin/altair openapi:roundtrip\xe2\x80\x94 a CI-friendly drift gate that exercises the full OpenAPI \xe2\x86\x92 Altair YAML \xe2\x86\x92 OpenAPI chain in memory and refuses to let an emitter / parser change silently degrade the round-trip. Same contract shape asspec:emit-sdk --check: human or JSON report; exit code 1 in--checkmode on drift so a build can refuse to merge.This is the last piece of #160 \xe2\x80\x94 with this in, the import path is safe to depend on.
What the gate compares
For every
(method, path)operation:summary\xe2\x80\x94 exact string match.x-altair-domain/x-altair-persistence/x-altair-queue\xe2\x80\x94 the extensions from x-altair-* OpenAPI extensions \xe2\x80\x94 round-trippable persistence, queue, webhook, idempotency #163. Deep equality.application/jsonschema in the source.Operations missing from either side are flagged as
missing_operation/extra_operation.What the gate intentionally ignores
Documented in
docs/openapi/roundtrip.mdso the contract is explicit:infoblock, doc-leveltags\xe2\x80\x94 trivial drift, says nothing about semantic loss.components/schemas\xe2\x80\x94 refs resolve to inlined types in the spec and re-emit as inlined objects; schema-level comparison lands when the importer learns to preserve components.output:block can't represent an empty body.x-altair-domainthat gets a synthesised one back is the importer working as intended, not a regression. Drift only fires on loss, not on enrichment.JSON receipt
{ \"clean\": false, \"input\": \"openapi.yaml\", \"operations_compared\": 5, \"differences\": [ { \"kind\": \"extension_drift\", \"pointer\": \"#/paths/~1users/post/x-altair-persistence\", \"expected\": { \"entity\": { \"class\": \"App\\\\User\\\\User\", \"...\": \"...\" } }, \"actual\": null, \"message\": \"'x-altair-persistence' present in source was lost or changed by the round-trip.\" } ], \"error\": null }kindis a stable enum:missing_operation,extra_operation,summary_drift,extension_drift,status_drift. Receipt is byte-stable for the same input (no timestamps / IDs) \xe2\x80\x94 golden-file-safe for CI.Files
src/Altair/Scaffold/Cli/{OpenApiRoundtripCommand,OpenApiRoundtripRunner,OpenApiRoundtripOptions,RoundtripReceipt,RoundtripDifference}.phpdocs/openapi/roundtrip.mdtests/Scaffold/Cli/OpenApiRoundtripRunnerTest.php(9 tests)Verified
benchmarks/tokens-to-ship/fixtures/posts.openapi.yamlround-trip clean.Test plan
composer cs\xe2\x80\x94 greencomposer stan\xe2\x80\x94 greencomposer rector(full tree, no cache) \xe2\x80\x94 greencomposer test\xe2\x80\x94 6229 tests (+9 new), 0 new failures (5 pre-existing env errors unchanged)bin/altair manifest:generate\xe2\x80\x94 cleanbin/altair openapi:roundtrip benchmarks/tokens-to-ship/fixtures/posts.openapi.yamlreportsclean: 5 operation(s) round-tripped without driftFuture work flagged in the docs
--strictflag for schema-level comparison onceOpenApiParserlearnsparameters[]+components/schemaspreservation.--fixtures=<dir>for batch round-trip over a fixtures directory (mentioned in openapi:roundtrip \xe2\x80\x94 drift gate for openapi \xe2\x86\x92 spec \xe2\x86\x92 openapi #164's original CLI surface; deferred since the single-file form covers the common case).The single-file form is what CI configurations need; the batch form lands when there's a second consumer asking for it.