Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
130de81
Support native AsyncAPI reports and bounded bilingual history
daveshanley Oct 29, 2026
911da02
Preserve lazy history counts and reject malformed first revisions
daveshanley Oct 29, 2026
a1631be
Benchmark AsyncAPI shared-schema report scaling
daveshanley Oct 29, 2026
00089a9
Render native AsyncAPI labels and channel icons in HTML reports
daveshanley Oct 29, 2026
c47eae4
fix: use semantic AsyncAPI HTML and Markdown reports
daveshanley Oct 29, 2026
f21e2ce
fix: ship connected AsyncAPI explorer payloads
daveshanley Oct 29, 2026
6462d7e
fix: embed readable AsyncAPI action badges
daveshanley Oct 30, 2026
b56cc77
fix: ship pb33f action palette and label spacing
daveshanley Oct 30, 2026
885a583
fix: ship compact explorer cards and aligned action labels
daveshanley Oct 30, 2026
6156ff1
fix: ship aligned explorer rows and annotations
daveshanley Oct 30, 2026
f440600
fix: ship unclipped explorer selection decorations
daveshanley Oct 30, 2026
f529c1d
deps: use released libopenapi v0.42.0 across comparison engines
daveshanley Nov 1, 2026
abb28f6
deps: consume libasyncapi v0.0.6 and updated Doctor
daveshanley Nov 1, 2026
49c007f
deps: pin released Doctor v0.0.86
daveshanley Nov 1, 2026
f2647a4
fix: embed Cowboy native label styles from shared CSS
daveshanley Nov 1, 2026
a9d906c
build: align report pipeline on Node 26 and refresh dependencies
daveshanley Nov 1, 2026
88c696b
build: align Docker report builder with Node 26
daveshanley Nov 1, 2026
fb15ae0
build: pin Cowboy with audited browser test tooling
daveshanley Nov 1, 2026
1de1f7e
build: use public Cowboy Components 0.16.0 release
daveshanley Nov 1, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
101 changes: 101 additions & 0 deletions .ai-docs/bilingual-comparison.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
# OpenAPI and AsyncAPI comparisons

One command family supports OpenAPI and AsyncAPI 3.x. `model.ParseSpecification`
uses libopenapi's strict YAML/JSON parser once, checks the root version marker,
and transfers the parsed AsyncAPI tree to libasyncapi. Mixed formats, ambiguous
markers, unsupported AsyncAPI versions and partial AsyncAPI models fail clearly.

The stack uses libopenapi v0.42.0. Its default YAML alias expansion limit remains
enabled for both formats. Excessive alias expansion fails during loading; normal
aliases retain bounded HTML previews. Doctor tests opt in to trusted excessive
aliases through libasyncapi to exercise graph bounds independently of this limit.

`cmd.runChangerator` selects doctor's native engine. Both engines return the
existing change primitives and node graph. Commands share count, flattening,
rendering, exit-code and report-file code. Native OpenAPI `Commit.Changes` remains
available; `Commit.AllChanges` selects the additive neutral report when present.

## Policy

Existing unscoped rule files remain OpenAPI-only. A combined file has optional
`openapiRules`, `asyncapiRules`, and `schemaRules` sections. The last section
configures JSON Schemas embedded in AsyncAPI; it does not change OpenAPI policy.
Unknown sections are rejected. Rules are merged into fresh defaults. The CLI's
shared breakingrules lock covers the entire delegated comparison and restores
defaults afterwards, including comparisons without a custom policy.

## Source and resource ownership

Direct file, URL and revision inputs use the same source loader for both formats.
Local revision references use GitRevisionFS. GitHub references use that virtual
filesystem with doctor's GitHub file service, pinned to the same commit SHA.
The working tree is never used to supply a historical sibling reference.

Canonical history loading retains source bytes and configuration factories.
Batch reports keep a two-document model window and reuse adjacent revisions.
Malformed predecessor inputs are bypassed; selected baseline bytes and revision
identity are preserved. Failed candidate revisions still reach the existing
warning and JSON `metaData.partial/skippedCommits` handling. A successful
unchanged pair counts as success. The console disables idle model retention;
its existing bounded result cache owns and releases each comparison pair.
The console stores only scalar total/breaking counts outside that cache. Counts
appear after a revision is visited and survive graph eviction; unvisited and
baseline-only revisions show `-`, while unchanged comparisons show zero.
Summary and console validate lazy first revisions before reporting that no prior
version exists. A malformed first revision fails instead of reporting no changes.

The JSON flattening boundary drops model-only object pointers and copies only
commit metadata. It preserves semantic paths, rawPath when normalized, and the
raw versus deduplicated count distinction. Reports add a `specification` object
with `kind`, `originalVersion`, and `modifiedVersion`; direct reports continue
to omit synthetic git metadata. HTML items have the same additive metadata and
retain the unpruned graph for the document and explorer views.

The shared UI renders native AsyncAPI document labels and send/receive actions.
Action badges use `--primary-color` for send and `--terminal-text` for receive,
matching the method badge palette, with vertically centered badges and spaced labels in the
document tree, explorer, and detail views. Cowboy's shared external
`native-labels.css.ts` supplies these rules in each component's shadow root,
using Cowboy palette tokens directly instead of inline Shoelace color overrides.
Channels use hash icons in the document tree and explorer. Icons are embedded
in the offline report bundle. The UI pins the public Cowboy Components 0.16.0
package; installation does not require private Git repository access.

HTML and Markdown use doctor's native AsyncAPI report traversal with the same
section levels, object headings, separators and change formatting as OpenAPI.
Channels also use the hash icon in the report statistics. JSONPaths remain
machine identities; the human report uses object names and nested message,
payload, variable, tag and example labels.

Both serialized graphs include parent-child edges with stable IDs. The explorer
filters those edges with its changed-node tree, so every retained child stays
connected. Reference edges preserve authored targets. Shared definition edits
appear once in node timelines and detail rows; distinct edits to a property
remain visible. The native machine-readable change list remains raw.

Change-view card dimensions include the header, changed child rows and extension
metadata. Change-count badges remain below the card; they do not add an empty
body row or widen child rows. Operation widths include native action labels.
Change-view rows center icons, chevrons, badges and labels within each 25px row.
Extension counts remain right-aligned; change annotations align with the card's
left edge in both regular Doctor and change views.
SVG node wrappers permit decoration overflow so selected borders do not clip
the annotation badges or active arrow. The explorer viewport remains clipped.

## Build runtime

CI and publication read Node 26.13.0 from the root `.nvmrc`, matching Cowboy
Components. The Docker UI builder pins the same version in its Node image.
The report UI lockfile retains its Mermaid, UUID, and KaTeX
overrides; dependency overrides in Cowboy do not propagate to consumers.
Rebuild both embedded bundles whenever the Cowboy pin or build dependencies
change. Use the locked public npm package when rebuilding these assets.

## Verification

Keep the canonical petstore totals unchanged: summary 42 changes / 18 breaking;
components summary 9/2 and raw JSON 10/3. Run cmd and full Go suites, race checks
on dispatch/policy/history, and command smoke checks. Compare
`BenchmarkOpenAPIReport` against the base branch with the same Go toolchain and
hardware; use `-benchmem` and multiple samples. The model-window test verifies
that 30 adjacent revisions parse once each and retain at most two idle models.
4 changes: 2 additions & 2 deletions .github/workflows/build.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ jobs:
- name: Set up Node.js
uses: actions/setup-node@v7
with:
node-version: 24
node-version-file: .nvmrc

- name: Build report UI
run: npm ci && npm run build
Expand Down Expand Up @@ -82,7 +82,7 @@ jobs:
- name: Set up Node.js
uses: actions/setup-node@v7
with:
node-version: 24
node-version-file: .nvmrc

- name: Install dependencies
run: npm ci
Expand Down
6 changes: 3 additions & 3 deletions .github/workflows/publish.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ jobs:
- name: Set up Node.js
uses: actions/setup-node@v7
with:
node-version: 24
node-version-file: .nvmrc

- name: Build report UI
run: npm ci && npm run build
Expand Down Expand Up @@ -66,10 +66,10 @@ jobs:
- name: Check version
run: echo "Version ${{ github.event.client_payload.new-tag }}"

- name: Use Node.js 24
- name: Set up Node.js
uses: actions/setup-node@v7
with:
node-version: 24
node-version-file: .nvmrc
registry-url: https://registry.npmjs.org/

- name: Bump NPM version
Expand Down
1 change: 1 addition & 0 deletions .nvmrc
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
26.13.0
2 changes: 1 addition & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
FROM --platform=$BUILDPLATFORM node:24-bookworm-slim AS ui-builder
FROM --platform=$BUILDPLATFORM node:26.13.0-bookworm-slim AS ui-builder

WORKDIR /opt/openapi-changes/html-report/ui
COPY html-report/ui/package.json html-report/ui/package-lock.json html-report/ui/tsconfig.json html-report/ui/vite.config.ts html-report/ui/index.html ./
Expand Down
37 changes: 36 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@

## The world's **_most powerful and complete_** OpenAPI diff tool.

`openapi-changes` lets you inspect what changed in an OpenAPI specification between two files,
`openapi-changes` lets you inspect what changed in an OpenAPI or AsyncAPI 3.x specification between two files,
between git revisions of the same file, across local git history, or directly from a GitHub-hosted file URL.

It can render the same semantic change model as:
Expand All @@ -20,6 +20,41 @@ It can render the same semantic change model as:
- a markdown report
- a self-contained offline HTML report

The same commands accept both formats, including git history and revision-scoped
references. Each comparison must use the same format on both sides. AsyncAPI 2.x
and mixed OpenAPI/AsyncAPI comparisons are rejected.

```sh
openapi-changes summary previous-asyncapi.yaml asyncapi.yaml
openapi-changes report HEAD~1:asyncapi.yaml HEAD:asyncapi.yaml
openapi-changes html-report --report-file changes.html previous-asyncapi.yaml asyncapi.yaml
```

Existing breaking-rule files retain their OpenAPI meaning. To configure both
engines, use named sections:

```yaml
openapiRules:
info:
description:
modified: false
asyncapiRules:
channel:
address:
modified: true
multiFormatSchema:
modified: true
schemaRules:
schema:
description:
modified: false
```

`schemaRules` applies to JSON Schemas in AsyncAPI. Foreign schema languages are
compared as opaque content; changing their format or content is breaking by
default. JSON and HTML report items include format and version metadata in
`specification`. Human counts remain deduplicated; JSON retains raw changes.

It works well for local exploration, CI/CD checks, release notes, and API review workflows.

## How is it the 'most powerful and complete?'?
Expand Down
106 changes: 106 additions & 0 deletions cmd/asyncapi_serialization_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
package cmd

import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
"testing"

v3 "github.com/pb33f/doctor/model/high/v3"
htmlReport "github.com/pb33f/openapi-changes/html-report"
"github.com/pb33f/testify/require"
)

func TestAsyncAPIHTMLExplorerConnectivityAndCounts(t *testing.T) {
commits, err := loadLeftRightCommits("../sample-specs/asyncapi-original.yaml", "../sample-specs/asyncapi.yaml", summaryOpts{})
require.NoError(t, err)
items, err := buildHTMLReportItems(commits, nil)
require.NoError(t, err)
require.Len(t, items, 1)
item := items[0]
require.Equal(t, 5, item.Summary.TotalChanges)
require.Equal(t, 4, item.Summary.BreakingChanges)
for _, graph := range []*htmlReport.GraphData{item.Graph, item.ExplorerGraph} {
require.NotNil(t, graph)
var nodes []struct {
ID string `json:"id"`
ParentID string `json:"parentId"`
Timeline []json.RawMessage `json:"timeline"`
Subtree *v3.ChangeSummary `json:"subtreeChanges"`
Height int `json:"height"`
Extensions int `json:"extensions"`
ChildChanges []json.RawMessage `json:"childChanges"`
}
var edges []*v3.Edge
require.NoError(t, json.Unmarshal(graph.Nodes, &nodes))
require.NoError(t, json.Unmarshal(graph.Edges, &edges))
ids := make(map[string]bool, len(nodes))
for _, node := range nodes {
ids[node.ID] = true
if graph.Mode == "change" {
rows := 1 + len(node.ChildChanges)
if node.Extensions > 0 {
rows++
}
require.Equal(t, rows*v3.HEIGHT, node.Height, "card height must match its visible rows: %s", node.ID)
}
if node.ID == "$.components.schemas.Order.properties.id" {
require.Len(t, node.Timeline, 1, "one edit must produce one detail row")
}
if graph.Mode == "change" && node.ID == "root" {
require.NotNil(t, node.Subtree)
require.Equal(t, item.Summary.TotalChanges, node.Subtree.Total)
require.Equal(t, item.Summary.BreakingChanges, node.Subtree.Breaking)
}
}
require.Contains(t, ids, "$.components.schemas.Order.properties.id")
pairs := make(map[string]bool, len(edges))
edgeIDs := make(map[string]bool, len(edges))
for _, edge := range edges {
require.NotEmpty(t, edge.Id)
require.False(t, edgeIDs[edge.Id], "duplicate edge ID")
edgeIDs[edge.Id] = true
require.Len(t, edge.Sources, 1)
require.Len(t, edge.Targets, 1)
require.Contains(t, ids, edge.Sources[0])
require.Contains(t, ids, edge.Targets[0])
pairs[edge.Sources[0]+" -> "+edge.Targets[0]] = true
}
for _, node := range nodes {
if node.ParentID != "" {
require.True(t, pairs[node.ParentID+" -> "+node.ID], "disconnected node: %s", node.ID)
}
}
}
}

func TestAsyncAPIHTMLPreservesAliasBounds(t *testing.T) {
var sizes []int
for _, levels := range []int{0, 2, 8} {
var source strings.Builder
source.WriteString("asyncapi: 3.0.0\ninfo: {title: Events, version: 1.0.0}\nx-data:\n a0: &a0 {value: data}\n")
for i := 1; i <= levels; i++ {
aliases := strings.TrimSuffix(strings.Repeat(fmt.Sprintf("*a%d, ", i-1), 8), ", ")
fmt.Fprintf(&source, " a%d: &a%d [%s]\n", i, i, aliases)
}
dir := t.TempDir()
left, right := filepath.Join(dir, "left.yaml"), filepath.Join(dir, "right.yaml")
require.NoError(t, os.WriteFile(left, []byte(source.String()), 0600))
require.NoError(t, os.WriteFile(right, []byte(strings.Replace(source.String(), "title: Events", "title: Events v2", 1)), 0600))
commits, err := loadLeftRightCommits(left, right, summaryOpts{})
if levels == 8 {
require.ErrorContains(t, err, "excessive aliasing")
continue
}
require.NoError(t, err)
report, err := generateHTMLReport(commits, nil, false, left, right)
require.NoError(t, err)
sizes = append(sizes, len(report))
if levels > 0 {
require.Contains(t, string(report), "$yamlAlias")
}
}
require.Less(t, sizes[1]-sizes[0], 100000, "HTML must not expand alias targets in node properties")
}
Loading
Loading