Skip to content

feat(scaffold): openapi:roundtrip drift gate (#164)#170

Merged
tonydspaniard merged 1 commit into
masterfrom
feat/164-openapi-roundtrip
May 30, 2026
Merged

feat(scaffold): openapi:roundtrip drift gate (#164)#170
tonydspaniard merged 1 commit into
masterfrom
feat/164-openapi-roundtrip

Conversation

@tonydspaniard
Copy link
Copy Markdown
Member

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 as spec:emit-sdk --check: human or JSON report; exit code 1 in --check mode 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:

Operations missing from either side are flagged as missing_operation / extra_operation.

What the gate intentionally ignores

Documented in docs/openapi/roundtrip.md so the contract is explicit:

  • Key order, empty optional arrays, info block, doc-level tags \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.
  • Description-only responses (204, 404, etc.) \xe2\x80\x94 Altair's output: block can't represent an empty body.
  • Enriched extensions \xe2\x80\x94 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\": 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
}

kind is 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

Layer Files
Runner + CLI src/Altair/Scaffold/Cli/{OpenApiRoundtripCommand,OpenApiRoundtripRunner,OpenApiRoundtripOptions,RoundtripReceipt,RoundtripDifference}.php
Docs docs/openapi/roundtrip.md
Tests tests/Scaffold/Cli/OpenApiRoundtripRunnerTest.php (9 tests)

Verified

Test plan

  • composer cs \xe2\x80\x94 green
  • composer stan \xe2\x80\x94 green
  • composer rector (full tree, no cache) \xe2\x80\x94 green
  • composer test \xe2\x80\x94 6229 tests (+9 new), 0 new failures (5 pre-existing env errors unchanged)
  • bin/altair manifest:generate \xe2\x80\x94 clean
  • Real-CLI smoke: bin/altair openapi:roundtrip benchmarks/tokens-to-ship/fixtures/posts.openapi.yaml reports clean: 5 operation(s) round-tripped without drift

Future work flagged in the docs

The single-file form is what CI configurations need; the batch form lands when there's a second consumer asking for it.

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.
@tonydspaniard tonydspaniard merged commit d720f5f into master May 30, 2026
4 checks passed
@tonydspaniard tonydspaniard deleted the feat/164-openapi-roundtrip branch May 30, 2026 21:00
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

openapi:roundtrip \xe2\x80\x94 drift gate for openapi \xe2\x86\x92 spec \xe2\x86\x92 openapi

1 participant